---
name: mix-web-demo
description: mix-web-demo 多语言微服务仓库专属编码规范，覆盖 Spring WebFlux、NestJS、FastAPI、GoZero 四个服务与 APISIX 网关的分层组织、依赖注入、请求校验、实时链路、日志、测试、常量、并行化、SQL 参数化、远程调用、Swagger 生成与 Apifox 同步约定，并含仓库工程约束（行尾、goctl 生成流程）。在本仓库生成或修改任何服务代码时必须使用；与通用 skills 冲突时以本 skill 的项目实证约定为准。
---

# mix-web-demo 项目专属编码规范

## 适用范围与配套 skills

本仓库包含四个业务服务与一个网关，生成代码前必须先确认目标服务：

| 目录       | 技术栈                                                   | 端口 |
| ---------- | -------------------------------------------------------- | ---- |
| `spring/`  | Spring Boot 3 WebFlux + R2DBC + WebClient + Resilience4j | 8081 |
| `gozero/`  | go-zero 1.10 + rest/httpc + validator + sqlx             | 8082 |
| `nestjs/`  | NestJS + Fastify + Mongoose/TypeORM + opossum            | 8083 |
| `fastapi/` | FastAPI + SQLAlchemy async + contextvars + APScheduler   | 8084 |
| `gateway/` | Apache APISIX（配置驱动，无业务代码）                    | 8080 |

配套通用 skills（本 skill 未覆盖的细节查阅它们，冲突时以本 skill 为准）：

**Spring / Java**

- `springboot-patterns`、`spring-boot-engineer`：Spring WebFlux 响应式模式
- `java-spring-boot`、`java-springboot`：Spring Boot 模块划分与最佳实践

**NestJS / Node.js**

- `nestjs-patterns`、`nestjs-best-practices`、`nestjs-expert`：NestJS 模块、DI 与架构模式
- `nodejs-backend-typescript`：Node.js + TypeScript 后端通用约定

**FastAPI / Python**

- `fastapi-patterns`、`fastapi-expert`、`fastapi-python`：FastAPI 依赖注入与异步模式
- `python-backend`：Python 后端安全与代码质量
- `sqlalchemy-postgres`：SQLAlchemy 2.0 + Pydantic + PostgreSQL（FastAPI 侧的 ORM 与 pgvector 访问）

**Go / GoZero**

- `golang-design-patterns`、`golang-pro`：Go 组合根与并发模式
- `golang-code-style`：Go 代码风格与可读性约定
- `golang-database`：Go 数据库访问（sqlx 参数化查询、事务、连接池）
- `golang-error-handling`：Go 错误创建、包装与结构化日志
- `golang-testing`：Go 表驱动测试与 testify
- `golang-security`：Go 安全实践（注入防护、密钥管理、日志安全）

**数据库**

- `database-schema-design`、`database-migration`：Schema 设计与迁移（MySQL / PostgreSQL / MongoDB）

**AI / LLM**

- `langchain`、`llm-application-dev-langchain-agent`：LangChain Agent、工具调用与多模型编排
- `rag`：RAG 检索增强（pgvector 向量库与语义检索）

**工程化**

- `devops`：Docker / Docker Compose 部署与 CI/CD

## 按任务加载参考规范

- 创建、修改、删除或评审测试，以及修复需要回归测试的缺陷时，必须完整读取 [references/unit-testing.md](references/unit-testing.md)
- 测试规范只约束测试边界与质量，不要求为了覆盖率测试生成代码、DTO、getter 或第三方库内部行为

## 通用规则（全部服务）

1. 使用中文回复；代码注释与字符串信息使用中文；注释与说明不含任何 emoji 与标志
2. 不主动生成任何文档，除非明确要求
3. SQL 一律参数化，禁止字符串拼接用户值。各服务占位风格：Spring R2DBC 用 `:param`；FastAPI ClickHouse 用 `%(name)s`；GoZero sqlx 用 `?`；NestJS 走 ORM 方法 API
4. **每次生成或修改代码后必须执行代码格式化与格式检查**：先跑 `./mix format <service>` 自动格式化，再跑 `./mix lint <service>` 确认检查通过，不允许把未格式化的代码交付；省略服务名时处理 spring、gozero、nestjs、fastapi 全部，`gateway` 为配置驱动不参与
5. **交付前必须验证编译通过**：格式与 lint 之后跑 `./mix compile <service>`（只编译校验，不产出打包目录），未通过不得交付；该步骤与「验证命令」章节里各服务的编译命令等价，省略服务名时处理四个服务全部。`./mix compile` 与打包命令 `./mix dist build` 职责不同，不要用后者替代前者（后者会清理 dist 并产出可运行产物）。对应工具未安装时该服务返回码 2 自动跳过，不允许据此判定通过
6. 常量抽取：四个服务均有常量类，消息类字符串必须进常量类引用
   - Spring：`common/constants/Messages.java`、`HttpCode.java`
   - NestJS：`common/constants/`（`Messages`、`HttpCode`、`Defaults`、`ErrorIds`）
   - FastAPI：`core/constants/`（`Messages`、`HttpCode`、`RedisKeys`、`Scripts`、`WarehouseScripts`）
   - GoZero：`app/common/constants/`（`messages.go`、`defaults.go`）
   - 语言支持字符串模板时（Python f-string、TS 模板串、Go fmt），动态部分用模板拼接常量，不把整句抽离
   - **常量类内部成员顺序：函数型成员必须全部排在普通常量之前**（NestJS 为 `static readonly NAME = (...) => ...` 形式的静态属性，FastAPI 为 `@staticmethod` 方法），新增常量时先写函数成员再写普通常量；该约定由 `./mix lint` 与 `./mix format` 里的 `scripts/constants-order.py` 检查与自动整理
   - 注解 / 装饰器的字符串参数**不抽常量**，直接写字面量：这类字符串只在声明处使用一次，抽到常量类后需跳转查看，反而降低可读性
     - Spring：`@Operation(summary, description)`、`@Tag`
     - NestJS：`@ApiOperation({ summary, description })`、`@ApiTags`
     - FastAPI：`@router.get/post(..., summary=, description=)`
     - GoZero 无注解机制，不适用本条
7. 相互独立的 IO 调用（RPC/HTTP/DB/Redis/ES/MQ）必须并行化，禁止串行等待；各服务并行原语见对应章节
8. 日志遵循各服务现有封装（见各服务章节），禁止绕过封装直接 print/console/logx 散用
9. 远程调用统一走各服务封装的客户端，自动透传用户上下文头：`X-User-Id`、`X-Username`、`X-Session-Id`、`Authorization`、`X-Internal-Token`；无登录用户时内部令牌 `userId=-1` 表示系统调用。禁止在新代码里裸用 httpx/axios/WebClient/http.Client 直连其他服务。各服务的唯一入口与底层 HTTP 客户端：
   - Spring：`infra/client/ServiceWebClient`（**WebClient** + `@LoadBalanced` 按服务名寻址 + Resilience4j 熔断/重试），按目标服务加方法
   - GoZero：`app/common/client` 的 `ServiceDiscovery.CallService`（**go-zero `rest/httpc`** + Nacos 轮询 + 退避重试），按目标服务在 `internal/client/` 封装
   - NestJS：`module/common/nacos/nacos.service.ts` 的 `call(opts)`（**axios** + axios-retry + opossum），按目标服务在调用方封装 Client 类
   - FastAPI：`app/core/client/client.py` 的 `call_remote_service`（**httpx** 共享连接池 + SimpleCircuitBreaker + tenacity），按服务在 `internal/clients/` 封装
   - **客户端只保留有生产调用方的方法（四个服务统一要求）**：不要照下游接口清单成批包装；只为单测存在或完全无引用的方法直接删掉，连同只服务于它的请求/响应类型、解析函数与测试桩一起清。成批包装留下的死代码没人维护，下游契约变更后响应结构漂移也不会暴露（FastAPI 曾出现 5 个批量统计方法把 Spring 的 `List/VO` 响应当 `Map` 返回值，直到 GoZero 侧同契约 bug 才被连带发现）。新增客户端方法前先确认调用方；确认手段：全仓检索方法名（Spring/GoZero 还要对照「接口 + 实现」两处），区分生产调用与 `*_test.go` / `*.spec.ts` / `src/test` 里的测试调用
   - **内部的「服务间接口」同样按调用方清理（四个服务统一要求）**：被内部令牌保护的接口若无人调用，先判断接口定位再处置——本该服务外部的对外化（去掉内部令牌），确无消费者的连实现一起删。识别方式、判定流程、连带清理范围、已清理清单与产物重生成（含 Spring `target/classes` 残留坑）见「服务间内部接口与无效接口清理」一节
10. **HTTP 连接池必须随服务生命周期释放**，不能指望进程退出收尾。现状与要求：

- FastAPI 已规范：两个 `httpx.AsyncClient`（内网 `trust_env=False` 与外部抓取各一个）由 `lifespan` 创建、`yield` 之后 `aclose()`，**新增长连接客户端必须走同一处创建与释放**
- Spring 由容器管理 reactor-netty 全局连接池，无需手工关闭；但禁止在请求路径上反复 `WebClient.builder().build()`，应注入 `WebClient.Builder` 并缓存实例
- GoZero 已收口为单条链路：`ServiceDiscovery.Close()` → 三个业务 Client 的 `Close()` → `ClientContext.Close()` → `ServiceContext.Close()`，新增客户端必须接进来
- NestJS 已收口：`NacosService` 实现 `OnModuleDestroy`，注销 Nacos 实例 + `shutdown()` 熔断器 + `destroy()` 专用 agent；新增持有的连接资源挂同一处，不要另建包级 stop 函数

11. 服务发现基于 Nacos；新服务接入需注册实例并在 metadata 声明能力
    - **注册地址必须是网关容器能回连的本机 IP**：显式配置 `SERVER_IP`（Spring 为 `SERVER_ADDRESS`）时以它为准，未指定（空、`127.0.0.1`、`0.0.0.0`）则自动探测本机 IP；**禁止在 dev 模式下固定注册 `127.0.0.1`**。`./mix dev` 的网关跑在 Docker 里，容器内的 `127.0.0.1` 是容器自身，会让网关 `connect() failed (111: Connection refused)` 返回 502，且下游服务一条日志都没有（请求根本没到）
    - 同理，服务监听地址也不能只绑 `127.0.0.1`，dev 下的 `SERVER_IP` 应为 `0.0.0.0`，否则容器无论如何都连不上
12. 生成代码时参考目标服务同类文件的命名与组织方式；已有成熟风格优先
13. 注释说明：注释的结束不能包含中文句号，直接留空，如果注释过长，使用多行注释形式，而不是多条单行注释，短注释使用1行的单行注释即可

- 新增或修改的代码注释、装饰器说明和文档说明均不得以中文句号 `。` 结尾，统一以无句号文本或其他必要标点结束

14. **日志采集必须排除敏感字段**：请求体进入日志后会被投递到 `api-log-queue`，最终落在 MongoDB `apilogs` 与 ClickHouse `ods_api_log`，因此凡是携带密码、验证码、令牌、授权码的接口都要显式排除。各服务能力：Spring `@ApiLog(excludeFields = {...})`、NestJS `@ApiLog({ excludeFields: [...] })`、FastAPI `@logWithConfig(exclude_fields = [...])`（`@log` 不支持）；**GoZero 的 `ApplyApiLog` 没有任何排除能力**，涉及凭据的接口不要挂它，或先给中间件补过滤参数
15. **新增接口必须带参数校验**，任何接收请求参数的接口都要声明校验规则，不得只靠业务层兜底。各服务写法见对应章节；**GoZero 的校验标签写在 `.api` 文件里**（随 goctl 生成进 `types.go`），漏写标签等于该参数没有校验，不会报错也不会告警
16. **新增、修改或删除对外接口后，必须重新生成静态 OpenAPI 文档并同步 Apifox**：先 `./mix swag <service>`，产物（`openapi.json` + `openapi.yaml`）与服务代码同一次提交；再 `./mix apifox <service>` 同步到 Apifox，未配置令牌时跳过并说明。完整流程见「接口文档收尾流程」章节
17. **业务配置统一经 YAML 导入环境变量**：服务配置值在对应 `application.yaml` 中声明 `${ENV_NAME:default}`，业务代码通过项目配置加载器或框架配置服务读取；禁止在业务模块直接调用 `os.getenv`、`process.env`、`System.getenv`、`os.LookupEnv` 等绕过 YAML。环境变量直接读取仅保留在配置加载器/启动引导，以及第三方库必须从标准进程环境读取且 YAML 无法替代的场景（如 HTTP 代理变量），新增例外需说明原因。可选变量当前不启用时，在 `.env`、`.env.example`、`.env.docker` 中注释变量行，并注明未配置时的默认行为；不要用空值赋值伪装成已配置
18. **接口响应必须使用具名结构，禁止匿名 Map**：控制器/路由的返回类型要有明确的结构定义并携带字段说明，便于 Swagger 呈现与下游解析。各服务落地：Spring 用 `entity/vo/` 下的 VO（字段加 `@Schema`）、NestJS 用 `common/utils/swaggerResponse.ts` 的 `SchemaObject` 加响应 DTO、FastAPI 用 `internal/schemas/` 的 Pydantic 模型、GoZero 用 `.api" 里的具名 `type`。**Spring 额外硬性要求**：禁止 `Map<String, Object>`（含 `java.util.Map`）作为控制器返回类型，`./mix lint spring`会检测；用于替换匿名 Map 的 VO 必须保持原有下划线字段名（类上加`@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)`），否则下游 FastAPI/GoZero 会解析不到字段；确实只能使用 Map 的场景（如列由运行时决定的动态查询结果）必须显式豁免：方法或其注解上方写 `// vo-exempt: 原因`，整个控制器写 `// vo-exempt-class: 原因`

## Spring 服务（spring/，WebFlux 响应式栈）

### 目录组织（三层 + 横切）

```
api/controller   接口层（返回 Mono/Flux）
api/service      业务接口
api/service/impl 业务实现
api/repository   R2DBC Repository
entity/po|vo|dto|event|projection  实体、视图对象、领域事件与投影（跨服务变更事件放 entity/event）
entity/assembler 实体到 VO 的装配器（依赖 api/repository，供 service/impl 复用，跨包调用需 public）
infra/client     远程调用客户端（ServiceWebClient）
infra/filter     WebFilter（UserContextWebFilter）
infra/handler    全局异常处理（GlobalExceptionHandler）
core/config|aspect|annotation|properties  配置、切面、注解、属性
common/constants|utils  常量与工具（RedisUtil、JwtUtil、UserContext）
```

### 硬性约定

- **控制器返回类型禁止匿名 Map**：一律用 `entity/vo/` 下的 VO 并给字段加 `@Schema`，`./mix lint spring` 会检查（`scripts/controller-vo.py`）。把原先的 `Map<String, Object>` 换成 VO 时，类上必须加 `@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)` 保持 `user_id`、`create_time` 这类下划线字段名，否则 FastAPI/GoZero 按原字段名解析会取不到值；只有运行时结构不固定（动态列、动态键）时才允许 Map，并在方法或其注解上方写 `// vo-exempt: 原因`，整类场景写 `// vo-exempt-class: 原因`
- **依赖方向**：`api` 是最上层，`infra` / `core` / `common` / `entity` 都不依赖它；目前唯一例外是 `entity/assembler`（装配器需要 `api/repository` 才能加载关联数据）。新增跨层组件前先确认会不会引入新的反向依赖
- ORM 是 Spring Data R2DBC，不是 MyBatisPlus：Repository 继承 `ReactiveCrudRepository<T, ID>`；自定义 SQL 用 `@Query` + `:param`，更新加 `@Modifying`；事务用 `TransactionalOperator.transactional()` 包裹，禁止 `@Transactional`
- Controller 返回 `Mono<Result<T>>` / `Flux<Result<T>>`，Service 返回 `Mono<T>` / `Flux<T>`；禁止返回裸类型、禁止调用 `.block()`
- 请求上下文走 Reactor Context：`UserContextWebFilter` 用 `.contextWrite()` 写入，业务用 `Mono.deferContextual(ctx -> ...)` 读取 `UserContext.getUserId(ctx)`；禁止 `ThreadLocal`、`RequestContextHolder`、`OncePerRequestFilter`、`HandlerInterceptor`
- 并行独立 IO 用 `Mono.zip()` / `Mono.when()`，禁止 `CompletableFuture.allOf()`
- 阻塞操作（加密、验证码图片等）用 `Mono.fromCallable(() -> ...).subscribeOn(Schedulers.boundedElastic())` 隔离
- 全局异常：`@RestControllerAdvice` + `@ExceptionHandler` 返回 `Mono<ResponseEntity<Result<?>>>`，通过 ResponseEntity 设置 HTTP 状态码；`ResponseBodyAdvice` 在 WebFlux 不可用
- **WebFlux 的请求体校验失败抛的是 `WebExchangeBindException`（继承 `ServerWebInputException`），不是 MVC 的 `MethodArgumentNotValidException` / `org.springframework.validation.BindException`**。`@ExceptionHandler` 未声明它就会被 `@ExceptionHandler(Exception.class)` 兜底吞成 500，必须显式声明；畸形 JSON 与参数类型不匹配抛 `ServerWebInputException`，一并声明。字段消息取 `getBindingResult().getFieldError().getDefaultMessage()`
- **请求 DTO 用 jakarta 注解声明约束（放 `entity/dto/`），控制器参数用 `@Valid` 触发**；新增接口必须带校验（通用规则 13），只有 `@RequestParam` / `@PathVariable` 这类无 DTO 的入参属于例外，那种情况控制器**没有**类级 `@Validated`，需要在 service 层手工校验
- 微服务调用用 `ServiceWebClient`（WebClient + `@LoadBalanced` 服务名寻址 + Resilience4j 熔断/重试），新增远程接口在该类中加方法，通过 `deferContextual` 注入上下文头；禁止 OpenFeign。**当前实现在每次请求里 `webClientBuilder.build()` 重建 WebClient**：连接池是 reactor-netty 全局的所以不会泄漏，但每次构建有额外开销，改造时应在 `@PostConstruct` 缓存一个实例（`WebClient` 不可变且线程安全）
- Redis 用 `ReactiveStringRedisTemplate`，封装在 `common/utils/RedisUtil`；缓存读写走 Mono 链式 `switchIfEmpty(Mono.defer(...))` 模式，禁止 `@Cacheable`
- AOP 切面中 `pjp.proceed()` 返回 `Mono<?>` 时必须在 Mono 链内操作（`.flatMap()` / `.doOnSuccess()`），上下文用 `.deferContextual()` 获取
- 横切能力由 AOP 实现：`ApiLogAspect`（接口日志）、`PermissionValidationAspect`（`@RequirePermission` 声明式权限）、`InternalTokenAspect`（`@RequireInternalToken` 内部令牌）、`ArticleSyncAspect`（文章变更同步 MQ / ES / 向量）、`Neo4jSyncAspect`（图谱同步）。新增写接口时确认是否需要权限注解与同步触发，同步失败只记日志、没有补偿机制
- **响应式切面的固定写法**：`pjp.proceed()` 返回的是**未订阅的冷流**（业务代码此时尚未执行），且 Reactor Context 只在订阅时可见。所以切面必须把校验 / 日志 / 同步挂到 Mono 链上（`Mono.deferContextual(ctx -> ...)`）：校验类用 `validate(ctx).then(businessMono)` 保证「校验先于业务」，副作用类用 `monoResult.doOnSuccess(...)` 发后即忘；副作用里若需要用户身份，必须用 `UserContext.writeContext(Context.empty(), ...)` 重建 Context 再 `contextWrite`，否则异步链上读不到。**禁止用同步代码在 `proceed()` 之前读上下文**，那时 ctx 不可见，只会拿到 null
- 注入风格：`@Service` + `@RequiredArgsConstructor` + `private final` 字段（构造器注入），禁止字段 `@Autowired`
- 外部基础设施统一由 Spring Bean 提供：`PasswordEncryptor` 注入 `BCryptPasswordEncoder`，禁止在工具或 Service 内部直接 `new` 加密器、客户端或连接资源；新增同类依赖放入 `core/config` 的 `@Configuration` Bean
- Swagger 用 springdoc：`@Operation(summary, description)`、`@Tag`，注解参数直接写字面量，不抽常量
- 日志：`common/utils/SimpleLogger` 实例注入使用

## NestJS 服务（nestjs/，Fastify + Mongoose/TypeORM）

### 目录组织

```
config/           YAML 配置与 index.ts 加载器
common/constants  常量（Messages、HttpCode、Defaults、ErrorIds）
common/exceptions 全局异常与错误结构
common/utils      工具类
framework/        全局横切：decorators、guards、interceptors、filters、middleware
module/common/    公共服务模块（nacos、logger、oss、redis、mail、github、task、client）
module/system/    系统业务模块（apiLog、articleLog、sqlTools 等）
```

### 模块内组织与约定

- 每个业务模块：`xxx.module.ts` + `xxx.controller.ts` + `xxx.service.ts`，辅以 `dto/` 与 `schema/`（Mongoose）或 `entities/`（TypeORM）
- ORM 按模块现状选择：日志类用 Mongoose（`@Schema` + `SchemaFactory`，`@InjectModel`）；结构化表用 TypeORM（`@InjectRepository`）。新模块先参考同类模块，禁止凭空引入新 ORM
- **入参校验走全局 `ValidationPipe`（`app/index.ts` 注册，`transform` + `enableImplicitConversion`），DTO 用 class-validator 注解**；新增接口必须带校验（通用规则 13），每个 `@Body()` / `@Query()` / `@Param()` 都要有对应 DTO 与约束注解，直接用裸参数接请求等于没有校验。自定义 `exceptionFactory` 把错误包成 `BusinessException(400, PARAM_PARSE_FAILED)`，最终由 `AllExceptionsFilter` 输出统一结构。**当前未开 `whitelist` / `forbidNonWhitelisted`**，未声明字段既不剥离也不报错，不要依赖"多余字段会被拒"
- 请求上下文用 nestjs-cls：`ClsMiddleware` 写入（userId/username/sessionId/token/internalToken），业务注入 `ClsService` 读取；gRPC 或脱离 HTTP 异步链的场景必须用 `ClsService.run` 手动开启上下文
  - **未登录语义注意**：`parseUserId` 要求 `Number.isInteger(userId) && userId > 0`，所以 ≤0 的值（含约定的系统调用 `-1`）在 CLS 里是 `undefined`；出站头由 `NacosService.call` 生成（`X-User-Id: String(userId || 0)`，内部令牌按 `userId > 0 ? userId : -1`）。需要区分"未登录 / 系统调用"时不要直接依赖 CLS 的值
- 公共层入口是 `module/common/common.module.ts`：`imports` 只保留需要根级加载的子模块（Logger、Client、Github、Mail、Task），`exports` **只导出 ClientModule**（根模块的 `RequireAdminGuard` 需要它）。业务模块用到的其他公共能力（oss、word、nacos、redis）由消费方**自行 import 对应模块**，禁止把 CommonModule 做成"全量中转池"（那会让模块边界形同虚设）
- 外部客户端必须通过模块 Provider 注入：Mail 的 Nodemailer Transporter 使用 `MAIL_TRANSPORTER` Provider，OSS 的 ali-oss 客户端使用 `OSS_CLIENT` Provider；Service 构造函数只接收抽象依赖，禁止直接调用 `nodemailer.createTransport()` 或 `new ali-oss(...)`
- `LoggerModule` 是 `@Global`，日志能力全模块可直接注入，无需 import
- 用户可控字符串在拼接存储 key 前必须清洗（长度 + 字符白名单）：`upload` 模块的 `customFilename` 走 `@Query` 原样拼 OSS key，属需要加固的写法
- 远程调用统一走 `module/common/nacos/nacos.service.ts` 的 `call(opts: CallOptions)`（Nacos 发现 + 轮询 + opossum 熔断 + axios-retry + 自动上下文头/内部令牌），按目标服务在调用方封装 Client 类。**`NacosService` 自持专用 `http`/`https` Agent（keepAlive）并传给每次 `axios.request`**，不再依赖 `http.globalAgent`；`onModuleDestroy` 负责注销 Nacos 实例、`shutdown()` 全部熔断器、`destroy()` 两个 agent。新增远程调用必须复用同一个 agent，不要在模块里另建 axios 实例。两个坑：**`NacosNamingClient._close()` 不可用**（私有方法且内部调用的是不存在的 `this._beatReactor.close()`，beat_reactor 只定义了 `_close`，调用会抛错），所以只做 `deregisterInstance`；**`src/types/nacos.d.ts` 是项目自建的类型声明**，用到新的 SDK 方法要在这里补声明，否则编译不过
- 独立异步调用用 `Promise.all` 并行；条件不满足时用 `Promise.resolve(空值)` 占位保证数组结构一致
- 并行调用中存在「部分失败可容忍」时用 `Promise.allSettled`，逐项判断 `status` 决定降级或抛出，避免单个失败拖垮整体；不要用 `Promise.all` + `catch` 吞异常来模拟降级，会丢失失败项与输入项的对应关系
  - 批量任务逐项降级（参考 `module/system/sqlTools/sqlTools.service.ts` 并行查多表行数）：`results.map((r, i) => r.status === "fulfilled" ? r.value : 降级值)`，用索引 `i` 关联回输入项
  - 多个独立调用中区分必选与可选（参考 `module/common/github/github.service.ts` 并行拉 profile 与 email）：解构后分别判断，必选项 `rejected` 直接 `throw reason`，可选项 `fulfilled` 才取值、否则走兜底
- 尽可能使用 TypeScript 类型标注（接口、泛型、联合类型），即使编译通过也补全类型
- 遵循项目 `eslint.config.mjs`，提交前通过 lint
- Swagger 用 `@nestjs/swagger`：`@ApiOperation({ summary, description })`、`@ApiTags`，注解参数直接写字面量，不抽常量
- **响应模型必须显式声明**：TypeScript 泛型会在运行时擦除，不能只依赖 `Promise<ApiResponse<T>>` 生成 Swagger schema。对统一响应使用 `common/utils/swaggerResponse.ts` 的 `ApiResponseModel`，为 `data` 传入具体的 `SchemaObject`；空响应使用 `SwaggerNullData`，字符串、数组和对象响应分别声明对应结构。OAuth 重定向等非 JSON 响应使用 `ApiRedirectResponse`，不要套统一响应包装体
- **响应 Schema 必须统一抽离**：业务响应的 `SchemaObject`（包括分页、同步、统计、上传和查询结果）统一定义在 `common/utils/swaggerResponse.ts`，控制器中的 `@ApiResponseModel` 只引用命名常量，禁止在控制器内重复编写内联 `data: { ... }`。除 `data: null` 外，必须为实际字段、数组项和嵌套对象补充 `description`；不能用无字段的 `SwaggerObjectData` 代替已知结构。
- **路径和查询参数命名必须与实际 URL 一致**：本项目 OpenAPI 会将字段转换为 snake_case。路径参数统一使用 snake_case，并让路由、`@Param`、`@ApiParam` 三处名称完全一致，例如 `:user_id` + `@Param("user_id")`、`:table_key` + `@Param("table_key")`。查询参数也必须统一，例如 `custom_filename` + `@Query("custom_filename")`，禁止同时出现 camelCase 与 snake_case。
- **Swagger 参数重复检查**：修改接口后重新生成 OpenAPI，检查每个 operation 的 `parameters` 是否存在重复语义或同时存在大小写不同的参数；重点排查 `userId/user_id`、`tableKey/table_key`、`customFilename/custom_filename` 等组合。
- 日志：注入 `LoggerService`（`module/common/logger`）

## FastAPI 服务（fastapi/）

### 目录组织

```
app/core/         基础设施：auth、base(Logger)、client(远程调用)、config、constants、db、errors、decorators、middleware（contextvars 上下文中间件）
app/internal/api        路由（按业务分 router）
app/internal/services   业务服务（类组织 + 工厂函数）
app/internal/crud       数据访问 Mapper（含 ClickHouse 查询）
app/internal/models     SQLAlchemy 模型
app/internal/schemas    Pydantic 模型
app/internal/clients    按服务封装的远程客户端（SpringClient、NestjsClient、GozeroClient）
app/internal/cache      两级缓存（L1 内存 + L2 Redis + 版本号失效）
app/internal/tasks      APScheduler 调度任务（scheduler.py + logic/）
app/internal/agents     LangChain Agent 与工具
```

### 硬性约定

- 依赖注入分三层，各有固定登记处：
  - 定义层 `internal/{clients,crud,cache}/` 各文件底部：类 + 构造 + `@lru_cache` 工厂三位一体
  - 注册层 `app/dependencies/` 包（`database.py`、`mappers.py`、`caches.py`、`clients.py`、`services.py`、`tools.py`、`llm.py`）：集中定义 `Annotated` 别名（`DbSession`、`XxxServiceDep`、`XxxToolDep`），由包 `__init__.py` 统一导出
  - 装配层 `dependencies/services.py` / `tools.py` / `llm.py`：`provide_xxx(...)` 函数，**参数即依赖别名**，函数体调用底层工厂；服务与工具的构造依赖必须由这里显式传入，构造函数内不得自建依赖
- 注册范围只含**路由函数签名里直接出现**的依赖（DB 会话 + 服务 + agent 工具）；Client / Mapper / Cache 工厂不注册，只被 `provide_*` 与 `get_*_service` 消费
- `@lru_cache` 工厂是承重墙：`provide_*` 每次请求都会执行，底层工厂漏 `@lru_cache` 会让 `get_xxx_service` 的缓存按新键无界累积
- 非请求链路（APScheduler 任务）用 `resolve_xxx_service()` 复用同一组单例工厂，禁止 `cls(...)` 直接 new（会造出第二个实例、内部状态不共享）
- 全异步：接口必须 `async def`；优先用 asyncio 生态；同步阻塞库（clickhouse_driver、连接池同步接口）用 `asyncio.to_thread(...)` 包裹后 `await`
- 并行：独立 IO 用 `asyncio.gather`；循环内并行把每次迭代封装为内部 async 函数后 gather
- `__init__.py` 导出：包内有 `__init__.py` 导出的功能，导入一律走包路径（`from app.internal.crud import UserAnalysisMapper`），禁止深入到文件路径；新增模块必须同步更新 `__init__.py` 导出。该约定由 `./mix lint` 里的 `scripts/package-imports.py` 检查（目标目录没有 `__init__.py`、与目标同包、或导入方位于目标包导入链上时不受约束），历史违规可执行 `python scripts/package-imports.py --fix fastapi` 批量整理
- 类型标注全覆盖；`import` 全部在文件顶部，禁止逻辑中导入；生成后检查并删除未使用的 import
- **入参校验用 Pydantic 模型**（放 `internal/schemas/`，文件名 `xxxDTO.py`）：字段用 `Field(...)` 声明，单字段规则用 `@field_validator` + `PydanticCustomError`（中文消息，参考 `createHistoryDTO.py`），请求体禁止用裸 `dict` / `Any` 接收；新增接口必须带校验（通用规则 13）
- **统一响应模型使用泛型**：`core/base/response.py` 的 `ApiResponse[T]` 负责声明 `code`、`msg` 和 `data`，路由必须通过 `response_model=ApiResponse[具体类型]` 暴露返回结构。已知结构使用领域 DTO、列表或字典泛型；只有运行时结构确实不固定时才使用 `ApiResponse[object]`，禁止退回不带类型参数的 `ApiResponse`
- **FastAPI 响应结构必须可展开**：已知返回字段必须定义 Pydantic 响应 DTO，并在路由使用 `response_model=ApiResponse[具体 DTO]`；禁止用 `ApiResponse[object]` 隐藏可确定的 `data` 结构。DTO 的每个字段、嵌套对象和列表项都要用 `Field(description=...)` 写明含义。
- SQL：ClickHouse 查询用 `%(name)s` 参数化；SQL 模板集中在 `core/constants/scripts.py` 与 `warehouse.py`，业务代码不内联 SQL 字符串
- 缓存：使用 `internal/cache` 的两级缓存体系（L1 内存 5 分钟 + L2 Redis 1 天 + ClickHouse 版本号失效），新统计接口优先接入而非自造缓存
- 定时任务：APScheduler 注册在 `internal/tasks/scheduler.py`，任务逻辑放 `tasks/logic/`；多实例互斥用 Redis `try_lock`/`unlock`（key 进 `RedisKeys` 常量）
- 非请求任务也必须显式传递依赖：向量同步使用 `VectorSyncDependencies` 传入 Spring、VectorMapper 和 Redis；Neo4j 同步 Service 构造函数接收 Neo4j/Spring Client，由工厂负责装配；禁止在任务逻辑内部临时获取或创建基础设施
- 权限装饰器保留 `@requireAdmin` / `@requireSelf` 语法，同时支持传入 `admin_checker` 检查器；检查器通过 `build_admin_checker(client)` 从外部 Client 创建，禁止让装饰器绕过依赖装配直接固定 Client
- 远程调用：统一 `core/client/call_remote_service`（httpx 共享连接池 + SimpleCircuitBreaker 熔断 + tenacity 重试 + 上下文头自动注入），按服务在 `internal/clients/` 封装 Client 类。两个共享 `AsyncClient`（内网 `trust_env=False`、外部抓取走系统代理）在 `lifespan.py` 创建、`yield` 之后 `aclose()` 释放，**新增客户端必须走同一处创建与释放**，不要另建模块级客户端
- ClickHouse ORM 会话的唯一入口是 `core/db/clickhouse.py` 的 `clickhouse_session()`（`get_clickhouse_db` 复用它）。**粒度保持"每查询一个会话"**：`crud/user.py` 有 `asyncio.gather` 并发跑两条 CH 查询，而 `AsyncSession` 不支持并发复用；非请求链路用引擎级 `execute_clickhouse_query` / `execute_clickhouse_sql`
- Agent 工具经 `internal/agents/toolFactories.py` 的 `AgentToolFactories` 注入 `BaseAiService`（**传工厂而非实例**，以保留线程池并行加载与单组失败隔离）
- Agent 工具权限：`internal/agents/toolScope.py` 的 contextvars 作用域（**fail-closed**）——`intentRouter` 在路由前统一写入 `ToolScope(user_id, is_admin)`，SQL 与 MongoDB 工具执行前校验行级范围（查询必须含 `user_id` 且绑定值等于当前用户，admin 放行）。禁止把请求态写进 `@lru_cache` 单例的实例字段
- 日志：`from app.core.base import Logger`，`Logger.info/warning/error/debug`
- 上下文：`core/middleware/contextMiddleware.py` 的 contextvars（`get_current_user_id` 等），深层函数免参读取用户信息

## GoZero 服务（gozero/，API-First）

### API-First 流程（强制）

1. 先在 `gozero/api/` 修改 `.api` 文件：`main.api` 按业务分组 import（chat、search、sqlTools、task 等），路由写在分组文件中，`@server` 块声明 `group`、`prefix`、`middleware`
2. 生成代码：`gozero/script/goctl/genApi.sh`（bash）或 `genApi.ps1`（PowerShell），两者等价，顶层入口 `scripts/goctl-api-init.sh`。核心动作是 `goctl api format -dir .` + `goctl api go -api main.api -dir ../app --style=goZero --home ../template`；脚本会自动备份并还原 `main.go` 与 `etc/`、删除生成的 `app.go`，随后清理 goctl 生成的小写中间件骨架并调用 `scripts/format.sh gozero` 收敛格式
3. ORM：`gozero/script/goctl/genOrm.sh` / `genOrm.ps1`（`goctl model mysql ddl --style goZero --home ../template`），SQL DDL 放 `gozero/script/sql/`；实际执行生成时同样会调用 `scripts/format.sh gozero`
4. 模板在 `gozero/template/`，只保留 `api/` 与 `model/` 两个目录（goctl 其它子命令的默认模板已删除），生成代码基于模板；禁止手写 handler/types 绕过生成流程，logic 文件头部有 `// Code scaffolded by goctl. Safe to edit.` 标记可编辑
5. 生成脚本已自动处理、不需要手工介入的事项：
   - **小写中间件骨架**：goctl 会把 `.api` 里声明的 `XxxMiddleware` 转成全小写开头的文件名（`userContextMiddleware` → `usercontextMiddleware.go`），与仓库手写的 camelCase 文件重名并造成中间件构造函数重复声明、编译失败。脚本只删除同时满足「位于 `app/internal/middleware`」「同目录存在忽略大小写后重名的另一个文件」「内容含 goctl 骨架占位注释」三个条件的文件，避免误删真实实现
   - **格式与行尾**：脚本最后调用 `scripts/format.sh gozero`（即 `golangci-lint fmt`）。仓库行尾由根目录 `.gitattributes` 的 `* text=auto eol=lf` 统一为 LF，不再需要手工转换或还原
6. 仍需人工确认的事项：
   - 已存在的 handler / logic 会被跳过（`exists, ignored generation`），**模板改动不会回灌到已有文件**：`template/api/handler.tpl` 的 `ApplyApiLog` 描述参数如今是 `constants.API_LOG_PENDING_DESCRIPTION` 占位（存量 handler 都换成了具体的 `constants.API_LOG_*`），所以「重新生成」只对**缺失**文件安全，改了 `handler.tpl` 必须手工同步存量 handler
   - `goctl model mysql ddl` 会**覆盖**已存在的 `<table>.go` 自定义文件，手工写在其中的业务方法会丢失（如 `app/model/chatMessages/chatMessagesModel.go` 的 6 个业务方法），执行 `genOrm.sh -s` 前先备份
   - 生成后跑 `cd gozero/app && GOTOOLCHAIN=local go build ./... && go vet ./... && go test ./... -count=1` 确认模板不变量未被破坏

### 目录组织与硬性约定

```
app/internal/boot        启动（Run/CreateServer）
app/internal/config      配置结构体
app/internal/handler     goctl 生成的路由与 handler（routes.go 挂中间件）
app/internal/logic       业务逻辑（按业务分目录：chat、search、sqlTools、task）
app/internal/middleware  中间件（usercontext、internalservice、recovery、apilog）
app/internal/svc         ServiceContext 组合根（分域上下文 + dependencyManifest 依赖清单校验）
app/internal/client      按服务封装的远程客户端（fastapiClient、nestjsClient、springClient）
app/internal/types       请求响应类型（goctl 生成的 types.go，校验标签在 .api 声明）
app/internal/hub         实时通信（chatHub、sseHub、chatRealtimeDispatcher、realtimeTypes）
app/internal/task        定时任务（cron 调度器 + logic/esSyncerTask）
app/common/constants     常量（messages.go、defaults.go、validations.go、httpCode.go、sqlTools.go、scripts.go、redisKeys.go）
app/common/keys          context key（未导出类型）
app/common/client        ServiceDiscovery 统一远程调用（rest/httpc 发送 + Nacos 轮询 + 退避重试）
app/common/validation    请求参数校验器（httpx.Validator 适配器、自定义规则与中文消息）
app/common/exceptions    业务异常类型（BadRequest / InternalServerError 等）
app/common/pubsub        Redis Pub/Sub 跨实例广播（redisPubSub.go）
app/common/utils         ZeroLogger 与工具（response、redisLock、internalToken、safeGo）
app/model/<table>        数据模型（goctl 生成 _gen.go + custom 扩展文件）
```

- **分层方向单向**：`internal/*` 可依赖 `common/*`，`common/*` **不得**依赖 `internal/*`。实时帧类型定义在 `.api` → `internal/types`，hub 因此放在 `internal/hub`；若放 `common/hub` 就会产生反向依赖
- logic 中禁止 SQL：数据访问全部封装在 `app/model/`，logic 通过 `l.svcCtx.XxxModel.Method(l.ctx, ...)` 调用；model 方法第一个参数是 `ctx`
- svc 分域：`ServiceContext` 匿名嵌入 `RuntimeContext`、`InfrastructureContext`、`ModelContext`、`HubContext`、`ClientContext`、`LoggerContext`、`MiddlewareContext`；新增依赖加入对应分域，在 `serviceComponentsContext.go` 组装，禁止往 ServiceContext 平铺字段
- **定时任务调度器挂在 `RuntimeContext.TaskScheduler`**（从包级变量收口）：`internal/task` 提供 `NewTaskScheduler(svcCtx, lockFactory) *cron.Cron`，Redis 锁工厂由 `boot/server.go` 传入，任务每次运行通过工厂创建锁对象；返回值由 boot 赋给 `ctx.TaskScheduler`。停止统一由 `ServiceContext.Close()` 承担（先 `TaskScheduler.Stop()` 再 `Cancel()`），`boot/init.go` 的关闭入口调 `ctx.Close()` 而非直接操作调度器。**不要再引入包级调度器变量**（测试无法替换、生命周期不受 Close 管理）。cron 表达式用标准 5 字段（分 时 日 月 周），"每小时"是 `0 * * * *`，写成 `* * */1 * *` 会变成每分钟执行
- 实时 Hub 由组合根创建：`newHubContext` 创建 `SSEHub` 和 `ChatHub`，`ChatHub` 持有自己的 `ChatQueue`；禁止恢复包级 Hub、队列或其他运行态单例。需要在连接生命周期中清理状态时，Client 必须持有所属 Hub 引用
- `ServiceContext.Close()` 是唯一的关闭入口；新增需要释放的资源时把释放逻辑加进 `Close()`，不要另建包级 stop 函数
- 日志：logic 结构体嵌入 `*utils.ZeroLogger`（构造时 `ZeroLogger: svcCtx.Logger.WithContext(ctx)`），调用 `l.Info/l.Errorf/l.Error`；logx 全局方法仅限启动阶段（logx 无 Warn/Warnf）；项目 ZeroLogger 提供 `Warningf`，警告级日志用它，异常一律 `l.Errorf`。`template/api/logic.tpl` 已与这套写法对齐；模板里那行 `var _ = logx.Logger{}` 是为了消化 goctl 无条件注入的 `logx` 导包（不引用会编译报「导入但未使用」），不要当成无用代码清理
- 并行：`mr.Finish`，每个任务为 `func() error`，结果写入闭包局部变量，任务内部吞错返回 nil（错误在任务外统一处理）
- context：一切可能阻塞的函数第一个参数接收 `context.Context`，嵌套调用透传同一 ctx；logic 用 `l.ctx`
- 中间件：`.api` 文件 `@server middleware:` 声明 + `routes.go` 的 `rest.WithMiddlewares`；内部服务接口必须挂 `InternalServiceMiddleware`（校验 `X-Internal-Token`）
- 远程调用：统一走 `common/client` 的 `ServiceDiscovery.CallService(ctx, serviceName, path, RequestOptions)`，按目标服务在 `internal/client/` 封装 Client 结构体，logic 里禁止直接发 HTTP。发送层是 go-zero 的 `rest/httpc`，服务发现/负载均衡/重试/响应解析仍由自己实现，详见下面「远程调用（httpc）」小节
- 常量：消息进 `app/common/constants/messages.go`，动态部分 `fmt.Sprintf(constants.XXX+": %v", err)` 拼接
- 构建校验：`cd gozero/app && GOTOOLCHAIN=local go build ./... && go vet ./... && go test ./... -count=1` 必须通过；**必须带 `GOTOOLCHAIN=local`**（对齐 Docker 的构建约束，避免 toolchain 自动升级掩盖版本漂移）；检查未使用导包与变量；产物命名与入口一致；格式与静态检查统一走 `./mix lint gozero`，不要用裸 `gofmt -l`（对行尾差异会报大量假阳性），也不要直接 `gofmt -w` 做全量重排

### 请求参数校验

- **机制：go-playground/validator 标签式校验**。标签写在 `.api` 字段上，随 goctl 生成进 `internal/types/types.go`；**不再有手写的 `<domain>Validate.go`**，新增请求类型不需要补任何 Go 代码
- 生效链路：`boot.CreateServer` 调 `validation.InitValidator()` → `httpx.SetValidator` 注册 → `httpx.Parse` 在解析完 path/form/header/json 后触发校验。**请求类型绝不能实现 `Validate() error`**：`httpx.Parse` 是 `if 实现接口 { ... } else if 注册的校验器 != nil { ... }`，一旦实现了方法就会走前一个分支，让 `SetValidator` 静默失效（本项目早期踩过这个坑并回退过一次）
- | 自定义标签（实现在`common/validation/validator.go`）： | 标签                                                                                                                                              | 语义 |
  | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------- | ---- |
  | `gt=0` / `required` 等                                 | validator 内置规则，直接用                                                                                                                        |      |
  | `notblank`                                             | 去首尾空白后非空；`required` 拦不住纯空白字符串                                                                                                   |      |
  | `positiveint`                                          | 字符串形式的正整数，用于 path 变量；`gt=0` 作用在 string 上比的是长度                                                                             |      |
  | `datetime`                                             | 符合`constants.DateTimeFormat`                                                                                                                    |      |
  | `searchmode`                                           | `keyword` / `hybrid` / `graph`，大小写与首尾空白不敏感                                                                                            |      |
  | `maxrunes=N`                                           | 按字符数限制长度；不用内置`max`，避免中文被按字节误判                                                                                             |      |
  | `notbefore=StartDate`                                  | 不早于同级指定时间字段；**跨字段校验必须用带参标签**，不要注册 struct-level validation（那需要 `common` 包导入 `internal/types`，会造成反向依赖） |      |
- 错误消息：官方 zh 翻译 + 自定义标签中文文案（常量在 `constants/validations.go` 的 `VALIDATOR_*`），字段名取 `json` / `form` / `path` 标签名，**只返回第一条错误**
- **handler 里用 `utils.HandleErrorWithCode(w, err, constants.HttpBadRequest)` 输出解析错误**，不要写成 `utils.Error(w, constants.HttpBadRequest, err.Error())`：后者会把 `*exceptions.BusinessError` 的状态码降级成硬编码的 400；`utils.HandleError` 那条分支则永远不会因校验失败而触发
- `template/api/handler.tpl` **不再生成 `req.Validate()`**，校验完全由 `httpx.Parse` 承担
- 标签只做请求边界检查（非空、长度、数值范围、格式）；业务与安全规则留在 logic（如 `sqlToolsQueryLogic.validateQuery` 的只读前缀白名单、表名白名单、强制 LIMIT + 上限），避免同一规则两处维护
- SSE / WebSocket 的 `user_id` 用 `omitempty,gt=0`：**允许缺省**（EventSource 与 WebSocket 握手无法自定义请求头，身份可能来自网关透传的 `X-User-Id`）；身份解析在 logic 的 `ResolveUserID`，handler 只负责接管连接
- `.api` 里的长度上限是字面量（如 `maxrunes=8000`），`.api` 语法无法引用 Go 常量，**改 `constants.SqlToolsMaxQueryLength` 这类常量时必须同步标签**
- 校验器是必需依赖：`InitValidator()` 失败直接 panic 拒绝启动；`internal/types/validate_test.go` 的 `TestHttpxParseRunsTagValidation` 专门守护「注册的校验器确实被 `httpx.Parse` 触发」这条链路，不要删

### 远程调用（httpc）

- 入口：`common/client` 的 `ServiceDiscovery.CallService`，按目标服务在 `internal/client/` 封装 Client 结构体，logic 里禁止直接发 HTTP
- **发送层用 go-zero 的 `rest/httpc`**：`sd.service(serviceName)` 按服务名缓存 `httpc.Service`（熔断器名与链路追踪都绑定服务名），构造用 `httpc.NewServiceWithClient(名, sd.httpClient, injectContextHeaders)`。**必须用 `NewServiceWithClient` 注入项目自己的 `http.Client`**，`httpc.NewService` 内部是 `http.DefaultClient`，没有超时
- **httpc 只接管「发出去」这一段**：按服务名的熔断、OpenTelemetry span 与 traceparent 注入、`[HTTP] 状态码 - 方法 URL` 耗时日志。Nacos 服务发现、轮询负载均衡、退避重试、`{code,msg,data}` 响应体解析仍由 `ServiceDiscovery` 自己实现
- `httpc.Do(ctx, method, url, data)` 的 `data` 走 `mapping.Marshal`，**只接受结构体**（字段带 `path`/`form`/`header`/`json` 标签），传 `map[string]any` 会直接 panic。所以发送走 `sd.service(name).DoRequest(req)`，请求体继续由 `buildRequestBody` 构造
- `httpc` **不把 5xx 当错误**（只给 `resp.StatusCode`），状态码判定与 `httpStatusError` 必须保留；响应解析继续用 `json.Unmarshal`，**不要换成 `httpc.Parse`**（它不支持 `any` / `json.RawMessage` 字段，`map[string]any` 还会把数字弱化成字符串）
- 上下文头与内部令牌在 httpc 的 `Option`（`injectContextHeaders`）里注入，从 `r.Context()` 读用户身份；请求头常量统一放 `common/constants/remoteCall.go`
- 熔断打开时 httpc 抢先返回 `breaker.ErrServiceUnavailable`：`CallService` 负责转成降级文案，`shouldRetry` 对该错误返回 false（重试无意义且会持续冲击熔断器）
- 熔断判定比早期更准：只有 **5xx / 超时 / 网络错误**计失败，4xx 与业务错误码不计
- **释放链路（2026-09-19 补齐）**：`httpc.Service` 不暴露底层 `http.Client`，所以在 `ServiceDiscovery` 上加了 `Close()`（内部 `httpClient.CloseIdleConnections()`）；三个业务 Client 各转发一个 `Close()`，由 `ClientContext.Close()` 汇总，最终挂在 `ServiceContext.Close()` 里。注意 `ClientContext` 的 FastapiClient / NestjsClient / SpringClient 三个字段统一为业务契约接口（`fastapiClient.Client` / `nestjsClient.Client` / `springClient.Client`，测试用 mock 实现），生命周期方法**不并入**接口，用包内 `resourceCloser` 断言处理，新增客户端时保持这条链路
- 链路追踪：`core/trace` 在包 `init()` 里已设置 `TraceContext` propagator，`rest.MustNewServer` → `ServiceConf.SetUp()` 也会调 `trace.StartAgent`，所以**不配 `Telemetry` 也会注入 traceparent**，只是 span 不上报；接入采集端只需在 `etc/application.yaml` 加 `Telemetry: {Endpoint, Batcher}`
- 客户端可观测性：httpc 每次调用都会打一条 logx 日志（4xx/5xx 为 error 级），日志量会上升，这是换取链路可视化的代价

### 实时链路（SSE / WebSocket）

- **帧结构的单一来源是 `.api`**：`ChatSSEMessage`、`ChatWsMessage` 声明在 `api/chat/stream.api`，接口的 `returns` 直接指向它们，**不要**再建空的 `XxxConnectResp` 占位类型。goctl 生成类型用 `Id / UserId / SenderId / ReceiverId / MessageId` 命名，与手写结构旧的 `ID / UserID / ...` 不同，跨用时需改字段名并把 `uint` 改 `uint64`
- hub 与实时分发在 `internal/hub`（`chatHub`、`sseHub`、`chatRealtimeDispatcher`、`realtimeTypes`）：本机连接管理 + 跨 Pod 事件分发。内部信封 `ChatRealtimeEvent` 只在对内使用，**不进 `.api`**
- **不要采用 `sse_handler.tpl` 的「每请求 chan + handler 内 flush 循环」单机模式**：跨实例广播依赖 `internal/hub` + `common/pubsub/redisPubSub.go` 的 Redis Pub/Sub，照模板改会丢掉多副本下的消息投递。连接接管（`HandleConnection` / `upgrader.Upgrade`）必须留在 handler，且接管前不得写 `w`。该模板内已加警示注释；当前 `.api` 的 `/sse/chat` 与 `/ws/chat` 都是普通 `get` 路由，goctl 实际走 `handler.tpl` 生成，两个 handler 都已手工改为委托 hub
- **`common/pubsub.RedisPubSub` 是「一个实例一个频道」**：频道在 `NewRedisPubSub(client, logger, channel)` 构造时绑定，`Publish` / `Start` 都不收频道参数。内部只持有单个 `*redis.PubSub` 引用，所以**同一个实例不能订阅第二个频道**（会覆盖前一个订阅的引用、造成连接泄漏与丢消息）。需要新频道就新建实例，频道常量统一放 `common/constants/redisKeys.go`
- 心跳与初始帧属协议内容：SSE 连接建立时下发 `{"type":"connected"}`，WS 收到 `{"type":"ping"}` 回 `{"type":"pong"}`

### 搜索链路

- 流程是「召回 → 融合重排 → 切页」：`resolveRecallWindow(page, size)` 把 `page * size` 按 `SEARCH_RECALL_STEP_SIZE`（100）向上取整成 `recallSize`，ES 以 `Page=1, Size=recallSize` 取一档候选；融合后由 `pageSlice` 在内存切出目标页，`total` 始终取 ES 的 `TotalHits`
- `page * size` 超过 `SEARCH_RECALL_MAX_SIZE`（200）时退化为窗口内重排并打 `SEARCH_RECALL_DEGRADE_LOG`
- 融合归一化分母取**整个召回候选集**的最大 ES 分（同一档位内各页可比）；未被向量或图谱召回的候选用**候选集均值**填充，权重保持全局固定。禁止把缺失信号的权重按比例重分配给其余信号
- 两层权重分开看：ES 内 `script_score` 权重（`ESScoreWeight` / `ViewsWeight` / `RecencyWeight` 等）管第一层，`FusionConfig`（`VectorScoreWeight` / `GraphScoreWeight` / `HybridMinESWeight`）管第二层
- ES 搜索脚本、权重、参数名映射三件套任一拉取失败 → 整条增强链路关闭，降级为普通 ES 条件分页查询（无脚本时补 `create_at` 降序，避免 `_score` 恒为 0 时顺序不确定）；ES 召回后回填 Spring 实时统计与评分（views / likes / collects / follows / comment_scores）五路任一失败 → 该指标保留 ES 原值，不抛错
- 回填只改变返回给前端的展示值，`script_score` 在 ES 内执行、仍用文档快照里的 `ai_score` / `user_score` 打分，因此评分展示实时、排序最多滞后一个同步周期（整点 cron 或精确同步）
- **Spring 的批量统计数据是 List/VO，不是 Map**：`/articles/views/batch` 直接返回 `[{id,count}]`，`/likes|collects|focus/counts/batch` 返回 `{counts:[{id,count}]}`，`/comments/scores/batch` 返回 `[{article_id, role_scores}]`（Spring 全局 Jackson 是 SNAKE_CASE，见 `spring/src/main/resources/application.yaml`）。GoZero 侧由 `springClient` 的 `ParseArticleViewsMap` / `ParseCountsMap` / `ParseCommentScoresMap` 转成内部 Map，结构体 tag 必须与 Spring 的 VO 对齐（`IdCountVO`、`BatchCountVO`、`ArticleCommentScoresVO`）
- **解析错误必须显式暴露，禁止 `_` 丢弃**：ES 同步任务（`esSyncerTask`）解析失败直接 `*_PARSE_ERROR` 报错退出，检索回填（`es.go`）解析失败计入 `SEARCH_STATS_FILL_DEGRADE_LOG` 的 metrics。曾因 Spring 把这三个接口的返回从 Map 改成 List/VO 而 GoZero 仍按 Map 解析，错误被静默吞掉，导致 ES 里点赞/收藏/粉丝数/AI 与用户评分长期全为 0（`views` 不受影响是因为它随文章实体一起同步、回填失败时保留 ES 原值）
- 两侧候选上限必须对齐：`SEARCH_VECTOR_CANDIDATE_LIMIT` / `SEARCH_GRAPH_CANDIDATE_LIMIT` ≥ 召回上限，FastAPI 侧 `VECTOR_SEARCH_CANDIDATE_LIMIT` / `GRAPH_SEARCH_CANDIDATE_LIMIT` 同步

### 错误处理与依赖

- **禁止用错误文案做控制流**（重试 / 降级判定）：用类型化错误 + `errors.As`。`common/client/clientErrors.go` 的 `httpStatusError{StatusCode, Body}` 承载下游状态码，`shouldRetry` 集中判定（context 取消/超时、5xx、超时、拨号失败、EOF → 可重试；4xx、业务错误、读连接被重置 → 不重试）
- **平台约束**：Windows 的 `syscall` 只声明 `WSAE*`，没有 `syscall.ECONNREFUSED` 等哨兵，网络错误判定用 `net.OpError.Op == "dial"` 与 `net.Error.Timeout()`
- 依赖清单：`internal/svc/dependencyManifest.go` 的 `validateDependencies` 在基础设施装配后执行——`nacos` / `mysql` / `elasticsearch` 缺失直接 panic 拒绝启动，`rabbitmq` / `redis` / `mysql_raw` 缺失聚合一条降级警告。新增必需依赖需在清单登记
- **工具链与依赖版本锁定**：`go.mod` 的 `go` 指令是 `1.24.0`，`Dockerfile` 用 `golang:1.24-alpine3.20` 且 `GOTOOLCHAIN=local`。因此 `validator/v10` 必须停在 **v10.30.0**（v10.30.1 起要求 go 1.25），`golang.org/x/*` 也压在对应版本（crypto v0.46.0 / net v0.47.0 / sync v0.19.0 / sys v0.39.0 / text v0.32.0），否则 Docker 构建会报 `go.mod requires go >= 1.25.0`。**升级依赖后必须用 `GOTOOLCHAIN=local go build ./...` 复核**：默认的 `GOTOOLCHAIN=auto` 会静默下载并使用更高的 toolchain，让版本漂移在本地完全看不出来，本仓库踩过一次、是 Docker 构建失败才暴露的

### Swagger 文档

- 产物在 `app/docs/`：`openapi.json` 被 `docs/embed.go` **嵌入二进制**（改后需重新编译并重启才生效），`openapi.yaml` 不嵌入。两个产物名是本仓库约定（goctl 固定输出 `main.json`，由脚本改名），改动会让 embed 失效
- 生成流程 `gozero/script/swagger/genSwagger.sh`（Windows 为 `genSwagger.ps1`，两者等价，顶层入口 `./mix swag gozero`）：
  1. `goctl api swagger --api main.api --dir app/docs` 产出 `main.json`
  2. 按仓库约定改名为 `openapi.json`，并清理历史遗留的 `main.yaml`
  3. `swagger2openapi -o openapi.json -p openapi.json` 转成 OpenAPI 3（未安装时自动 `npm install -g`，PATH 不可见则退化为 `npx -y`）
  4. `python fix.py openapi.json openapi.yaml` 做后处理（需要 PyYAML）
- **不再使用 goctl 的 `--yaml`**：那份停留在 Swagger 2.0，而 JSON 已转成 OpenAPI 3，两边格式会分叉；现在 YAML 由处理后的 JSON 直接派生，两份产物内容完全等价。`fix.py` 写文件时显式 `newline="\n"`，避免 Windows 下写成 CRLF
- **`fix.py` 的输入必须是 goctl + swagger2openapi 的原始产物**，对已处理过的产物重跑不幂等：`/ws/chat` 已被剔除会把 `docs/websocket.json` 写成空数组、`/sse/chat` 的 `400` 响应键序被打乱。要改产物走 `./mix swag gozero` 全流程；只想改个别字段时直接编辑产物，不要手工重跑 `fix.py`
- `fix.py` 是生成后修补产物的固定环节，按顺序做以下修补：
  - 补中文标签定义与 `info`（`tag_mapping`、`x-author`），并把每个 operation 的 `tags` 由英文替换为中文名
  - **把规范版本固定为 `OPENAPI_VERSION`（`3.1.0`）**：产物必须留 3.1 —— 空数据响应用 `type: "null"`（`EMPTY_DATA_SCHEMA`，Apifox 靠它展示空值），而 3.0 的 `type` 只接受 `array`/`boolean`/`integer`/`number`/`object`/`string`，降到 3.0 会让该 schema 非法。**版本只在 `fix.py` 里改**，`genSwagger.sh` 与 `genSwagger.ps1` 都不再单独改写版本（曾因 sh 里多一段内联 Python 强制 3.1 后与 ps1 分叉），两个脚本改为校验产物版本是 `3.1.0`
  - **服务内嵌的 swagger-ui 必须支持 3.1**：`gozero/app/docs/` 下的 `swagger-ui.css`、`swagger-ui-bundle.js`、`swagger-ui-standalone-preset.js`、`favicon-16x16.png`、`favicon-32x32.png` 取自 `swagger-ui-dist` 5.x，由 `go:embed` 编译进二进制，**不再使用 `swaggo/files`**（该库只到 v1.0.1，内嵌 Swagger UI 4.x，渲染 3.1 会白屏）。升级 UI 时只替换这几个文件，并同步 `docs/embed.go` 的 embed 指令与 `boot/swagger.go` 的资源表；不引入 `.map` 文件
  - `boot/swagger.go` 的 `swaggerContentTypes` 显式声明内嵌资源的 `Content-Type`，不走 `mime.TypeByExtension`（Windows 上会读系统 MIME 注册表，类型不可控；`swagger-ui-bundle.js` 类型不对会被浏览器拒绝执行、同样白屏）
  - 移除 `swagger2openapi` 注入的全局 / path 级 / operation 级 `servers` 与 per-operation `schemes`（Swagger 2.0 文档没有 host，转换工具会默认补 `https://`，导致前端用错协议）
  - 剔除易变字段 `x-date`、`x-goctl-version`（`VOLATILE_FIELDS`），保证同一份 `.api` 在不同机器、不同 goctl 版本下生成结果可复现，不产生无意义 diff
  - 修正 `/sse/chat` 的 200 响应（`fix_streaming_endpoints`）：帧 schema 复用 goctl 按 `returns` 生成的 inline 结构，**同时挂到 `text/event-stream` 与 `application/json` 两个媒体类型下**，JSON 视图额外加 `SSE_FRAME_SCHEMA_TITLE` 标题（Apifox 取 schema 的 `title` 作为「返回响应」下的节点名），再补 `400` 统一错误体。**Apifox 不会渲染只声明 `text/event-stream` 的响应**（「返回响应」里只显示空的 SSE 封装结构、No schema defined）；FastAPI 侧的流式聊天同样是靠帧 schema 的 JSON 视图才画出帧字段，两边必须保持一致
  - 剔除 WebSocket 路径 `/ws/chat`（`WEBSOCKET_PATHS`）：OpenAPI 无法表达 WebSocket，留在产物里导入只会按 URL + method 多出一条无法发起握手的 GET 接口。剔除前由 `collect_websocket_endpoints` 摘出 `description` 与 `tags`，写成同级产物 `docs/websocket.json`，改由 `apifox-sync.sh` 经 Apifox CLI 的 `websocket` 命令同步（见「接口文档收尾流程」）
- **`fix.py` 内的执行顺序有硬约束**：`fix_streaming_endpoints` 必须排在 `wrap_unified_responses` 之前 —— 前者从 200 响应的原始 `content` 里取第一个带 schema 的媒体类型当作帧结构，若后者先执行，取到的会是已被包装成 `{code,msg,data}` 的 schema，帧字段全部丢失。`drop_websocket_paths` 放最后
- GoZero 的 handler 模板会在运行时通过 `utils.Success/Error` 输出统一外壳 `{code,msg,data}`，而 `goctl api swagger` 只生成 `returns` 对应的 `data` schema；因此 `fix.py` 必须对 `application/json` 响应补齐该外壳，并保持重复执行幂等。**跳过规则必须按内容类型显式判断：响应 `content` 里含 `text/event-stream` 就整条跳过**，不能依赖「没有 `application/json` 就自然跳过」——`/sse/chat` 的 200 现在同时声明两种媒体类型，沿用旧的隐式判断会把帧 schema 包进统一外壳。修改 `.api` 或响应模型后必须重新生成 JSON/YAML，检查响应 schema 的 `data` 是否仍为原始业务结构，以及 `/sse/chat` 的帧字段是否仍在
- GoZero 的 `xxxResp` 按前端兼容规范保留业务 `Data` 字段并声明 `json:"data"`；handler 必须使用 `utils.Success(w, resp)`，由 `utils.Success` 识别并展开响应对象的 `Data` 字段，最终统一输出单层 `{code, msg, data}`，禁止产生 `data.data`。Swagger 后处理也必须同步展开该字段，保证文档与运行时响应一致。
- **该文档的 schema 全部是 inline（`components` 下没有 `schemas`），不要改成 `$ref`**
- 改完 `.api` 后按「接口文档收尾流程」重新生成并提交产物

## 服务间内部接口与无效接口清理

「内部接口」= 只给其它服务调用、靠内部令牌 `X-Internal-Token` 鉴权、并在网关侧被 `block-internal` 挡住外部访问的接口。四个服务的识别方式与校验实现：

| 服务 | 标记方式 | 校验实现 | 网关侧 |
| --- | --- | --- | --- |
| Spring | `@RequireInternalToken` | 切面 `InternalTokenAspect` | `block-internal` 黑名单 |
| NestJS | `@RequireInternalToken()` | `framework/guards/internalToken.guard.ts` | 同上 |
| FastAPI | `@requireInternalToken`（装饰器会给 wrapper 打 `requires_internal_token` 标记，供契约测试自省） | `core/decorators/requireInternalToken.py` | 同上 |
| GoZero | `.api` 的 `@server middleware:` + `InternalServiceMiddleware` | `common/middleware/internalServiceMiddleware.go` | 同上 |

### 判定「无效内部接口」的流程（清理前逐步执行）

1. **列全路径**：`类级 @RequestMapping/@Controller` 前缀 + 方法级路径拼成完整路径，路径参数写成模板（`/users/by-github-id/{githubId}`）；GoZero 以 `.api` 里的 `@handler` 路径为准
2. **全仓检索调用方**：其它三个服务的 client 封装（`infra/client`、`internal/client`、`internal/clients`、`common/client`）、`gateway/apisix/apisix.yaml`、`scripts/`、各服务测试目录。Spring/NestJS 必须**同时对照「接口声明 + 实现」两处**，GoZero 对照 `.api` 与 handler
3. **区分引用性质**：只出现在 `*_test.go` / `*.spec.ts` / `src/test` 的**不算调用方**；测试里为满足接口而写的 stub 方法同理
4. **判断定位，别直接删**：
   - 本该服务外部（前端语言契约、APISIX 可达）→ **去掉内部令牌对外化**（例：`/chat/send` 普通聊天），并确认网关黑名单不再拦它
   - 确实只服务服务间调用且无消费者 → 删除
5. **连带清理只服务它的实现**：Service 方法 → Mapper/Repository 查询方法 → 请求/响应 DTO → 测试用例；删除过程中每一层都要重新确认「是否还有其它调用方」
6. **任务类的手动触发接口一律保留，不纳入清理范围**：`/task/*` 这类手动触发入口（Spring `TaskController`、NestJS `TaskController`、FastAPI `taskRouter`）是运维兜底能力——手工补跑、修数据、紧急刷缓存都靠它。**即使当前没有任何服务调用、也即使 `@Scheduled`（Spring）/ `@Cron`（NestJS）/ scheduler（FastAPI）已在自动调度，都必须保留**：自动调度是常态通道，HTTP 入口是运维通道，两者互补（曾误删 `POST /task/clean`、`/task/apilog`、`/task/articlelog`、`/task/update-analyze-caches`、`/task/init-hash-cache`、`/task/clear-analyze-caches`，已全部恢复）
7. **重新生成产物并同步文档**：`./scripts/swag-init.sh <service>`（可选 service 名），需要时 `./mix apifox`。**Spring 删过控制器源文件后必须先清掉 `target/classes` 下的残留 `.class`**——Maven 增量编译不删除已移除源文件的产物，文档生成器扫描 classpath，否则已删接口会继续出现在产物里

### 已清理的无效内部接口（勿重复排查）

| 服务 | 已删除 | 已对外化 |
| --- | --- | --- |
| Spring | `GET /users/by-github-id/{githubId}`（连带 `UserService.findByGithubId` 接口+实现）、`GET /focus/statistics/monthly-trend/{userId}`（连带 `FocusService.getMonthlyFollowTrend`、`FocusRepository.countMonthlyByUserIdGroupByDate`） | — |
| FastAPI | `POST /ai_history`、`GET/PUT/DELETE /ai_history/internal/{id}`（连带 `AiHistoryService`/`AiHistoryMapper` 各 3 个方法与 `CreateHistoryDTO`/`UpdateHistoryDTO`/`DeletedResponse`） | `POST /chat/send`：去掉 `@requireInternalToken` 作为普通聊天接口，补 `tests/internal/api/chat/chatRouter_test.py` 固化「聊天接口不带内部令牌」契约 |
| NestJS | `POST /article-logs`、`POST /api-logs`（连带两个 Service 的 `create`） | — |
| GoZero | 无（`/task/syncer`、`/sql-tools/tables`、`/sql-tools/query` 均有生产调用方） | — |

### 必须保留的接口（勿当死代码清理）

`/task/*` 手动触发接口一律保留，判定「无效内部接口」时直接排除：

| 服务 | 接口 | 保留理由 |
| --- | --- | --- |
| Spring | `POST /task/clean`（`TaskController`） | 手动触发过期 Token 清理；`TokenCleanupTask` 的 `@Scheduled(cron = "0 0 * * * *")` 是常态通道，接口是运维通道 |
| FastAPI | `POST /task/vector`、`POST /task/sync-neo4j`、`POST /task/sync-warehouse` | 手动补跑向量 / 图谱 / 数仓同步（Spring 的同步切面也会按 ids 触发同一入口） |
| FastAPI | `POST /task/update-analyze-caches`、`POST /task/init-hash-cache`、`POST /task/clear-analyze-caches` | 手动刷分析缓存、初始化文章内容 hash 缓存、清空分析缓存；scheduler 自动跑同一批函数，接口用于紧急修复 |
| NestJS | `POST /task/apilog`、`POST /task/articlelog` | 手动清理超期 API 日志 / 文章日志；`TaskService` 的 `@Cron` 是常态通道 |
| GoZero | `POST /task/syncer` | 按 ids 精确同步 ES（Spring 同步切面在用），同时是手动全量重刷入口 |

**删除时容易误伤的保留项**（都已确认仍在使用，勿再删）：FastAPI 的 `create_ai_history`（流式聊天保存历史）、`update_analyze_caches_async` 与 `initialize_article_content_hash_cache_async`（scheduler / vectorSyncTask，以及恢复后的 `/task/update-analyze-caches`、`/task/init-hash-cache`）、`ChatRequest`（被 `ChatStreamRequest` 继承）、`ChatResponseData`（被 `ChatResponse` 引用）；NestJS 的 `CreateArticleLogDto` / `CreateApiLogDto`（RabbitMQ 消费者攒批 `insertMany` 在用）；各 Cache 的 `clear_all()`（被恢复的 `/task/clear-analyze-caches` 调用）。

## 网关（gateway/，Apache APISIX）

- 以 YAML 数据面运行（`role_data_plane` + `config_provider: yaml`），无业务代码；路由与插件全部在 `apisix/apisix.yaml`
- **认证外置**：业务路由挂 `forward-auth` 回调 Spring 的 `/users/internal/auth/validate`，由网关注入 `X-User-Id` / `X-Username` / `X-Session-Id` 供下游透传；**下游服务只信任请求头**，不自行解析 JWT
- 内部接口防护靠 `block-internal` 路由（URI 黑名单），新增内部接口必须同步加入黑名单
- **`block-internal` 的 `uri` 是精确匹配**（只有以 `*` 结尾才是前缀匹配），而它要挡的接口往往落在宽松前缀路由（`/articles/*`、`/users/*`、`/email/*`、`/ai_history/*`）覆盖范围内。新增内部接口时除了加黑名单，还要确认黑名单条目能覆盖实际访问路径，否则会出现「精确路径被挡、子路径可直达」的缺口
- 长连接用独立 upstream（读超时 3600s），与普通接口区分
- 新增对外路由：按既有分组挂 `forward-auth` 与 `limit-req`（登录类用 `*login_limit`）；public 路由必须有明确理由且不能漏挂限流
- 改配置后用 YAML 解析器校验语法，并确认目标路由的插件确实挂载
- **端口暴露**：根 `docker-compose.yml` 里只有 gateway 映射宿主机（8080 → 容器 9080），spring / gozero / nestjs / fastapi **均不映射宿主机端口**，容器间经 `hcsy` 网络 + Nacos 直连。下游无条件信任 `X-User-Id`，所以这个收敛状态正是身份不可伪造的前提，改动编排时不要给业务服务加回 `ports`；本地开发模式（`./mix dev seq`）直连服务端口，没有这层保护

## 仓库工程约束

- **禁止删除、清空、移动或重建本地依赖环境**：`fastapi/.venv`、`nestjs/node_modules` 属于开发者本机已安装的依赖环境，删除与整体重建都是禁止操作（重装需联网且耗时，也会打断本地校验命令）；排查问题、清理临时文件、执行格式化与校验时一律绕开这些目录，确需变更依赖时用对应包管理命令做增量安装。**不要为了任何理由运行删除 `.venv` 的命令**（含 `rm -rf`、`Remove-Item -Recurse`、`uv venv --clear`、`python -m venv --clear` 等）
- **Windows 主机上禁止用 WSL 的 bash 跑 `./mix compile fastapi` 与 `./mix test fastapi`（本问题只在 Windows + WSL 场景出现，Linux/macOS 无此问题）**：这两步底层分别是 `uv run python -c ...` 与 `uv run pytest`。在 Windows + WSL 的组合下，WSL bash 里 `uv` / `ruff` / `python3` 解析到的是 Linux 版本，`fastapi/.venv` 却是 Windows 的 uv 创建的（`pyvenv.cfg` 里 `home = C:\...`）；Linux uv 判定环境与自身平台不匹配后，会按 `uv run` 的 sync 语义**清空并重建 `.venv`**，一旦中途被打断就留下半成品，`fastapi/pyproject.toml` 的 `venvPath = "."` / `venv = ".venv"` 随即解析失败并回退到根目录或全局解释器，表现为「IDE 只能解析到根目录的环境」，等价于「重建本地依赖环境」，属禁止操作。Linux/macOS 上 `uv` 与 `.venv` 同平台，`uv run pytest` 就是正常入口，不受本条约束
  - 正确做法（Windows）：`./mix` 只在 Windows 侧 shell（Git Bash 或 PowerShell，且 PATH 指向 Windows 工具）执行；fastapi 的校验改用项目自带解释器做只读操作——`fastapi/.venv/Scripts/python.exe -m pytest -q`、`fastapi/.venv/Scripts/python.exe -m ruff check .`，以及 `scripts/compile.sh` 里那段字节码编译片段。macOS/Linux 上按默认命令执行 `uv run pytest` 即可
  - 全服务形式同样禁止（Windows + WSL 场景）：`./mix compile`、`./mix test` 会把 fastapi 一起带上，要么写明确的服务名、要么改用 Windows 侧 shell
  - 唯一例外是技能同步：`./mix skills` 只在技能目录间做镜像复制，不碰依赖环境，在 WSL bash 下可用 `HOME=/mnt/c/Users/<user> ./mix skills` 显式指定目标（见本节的技能同步条目）；除此之外不要自行执行 `uv run` / `uv sync` / `uv venv`
- **Windows 主机上出现下列任一信号就停止用 WSL bash 跑 `./mix`，改到 Windows 侧 shell（同样只在 Windows + WSL 下出现）**：`./mix skills` 把技能同步到 `/home/<user>` 而不是 `C:\Users\<user>`（IDE 看到的技能没更新）、`./mix lint gozero` 报 golangci-lint「未安装」而本机其实已安装、`./mix format fastapi` 实际调用的是 Linux ruff。三者都是「bash 解析到了 Linux 工具链」的同一根因，在 WSL bash 下继续跑 `./mix compile` / `./mix test` 就会踩到重建 `.venv` 那条
- 行尾由根目录 `.gitattributes`（`* text=auto eol=lf`）统一为 LF，仓库不再按 CRLF 维护；新建文件不需要手工转换行尾
- 格式化统一走各服务的工具（`./mix format`）：Spring Spotless、GoZero golangci-lint、NestJS Prettier、FastAPI Ruff；不要再手工执行 `gofmt -w` 或 `prettier --write` 做全量重排
- Go 的格式问题用 `./mix lint gozero` 判断（golangci-lint 的 gofmt formatter），不要用裸 `gofmt -l`：它对行尾差异会报大量假阳性
- `goctl`（`api format` / `api go`）生成后由 `genApi.sh` / `genOrm.sh` 自动调用 `./mix format gozero` 收敛格式；手工执行 goctl 时兜底跑一次即可，不需要手工处理行尾
- **goctl 版本**：仓库已生成的 34 个文件头部标记 `goctl 1.9.2`，本机安装的如果是其他版本，重新生成会把版本注释刷成 对应版本（`types.go`、`routes.go` 一并刷新）。
- git 用于精确核对与回退：`git status --porcelain`、`git diff --ignore-cr-at-eol`（判断是否仅行尾差异）、`git checkout -- <文件>`
- 本机环境参考：Go / gofmt 在 `C:\Program Files\Go\bin\`，goctl 在 `C:\Users\30708\go\bin\goctl.EXE`，git 在 `C:\Program Files\Git\cmd\git.EXE`（`usr\bin\` 下有 grep / tr / sed / basename 等，用完整路径调用），maven 在 `C:\apache-maven-3.9.11\bin\mvn.CMD`，javap 在 `C:\Program Files\Java\jdk-17\bin\javap.exe`
- bash 环境的 `dirname` / `head` 等不稳定（PATH 时有时无），批量格式与行尾校验优先走 `./mix format` / `./mix lint`，需要脚本兜底时用 Python `subprocess` 调绝对路径
- `mix` 脚本顶层子命令有 `setup`、`swag`、`apifox`、`apifox-readme`、`goctl-api`、`goctl-orm`、`lint`、`format`、`test`、`compile`、`token`、`hooks`、`skills`、`dev`、`dist`、`docker`、`docker-services`、`loki`、`compose`、`help`；开发模式必须写全 `./mix dev multi|seq|stop`（**没有** `./mix seq` / `./mix multi` / `./mix stop`，README 历史版本里这三处写错）
- **内部服务令牌用 `./mix token [service]` 获取，不要再靠单测打印**：只签一枚令牌（`scripts/internal-token.py`，只依赖 Python 标准库，密钥取各服务 `.env` 的 `INTERNAL_TOKEN_SECRET`）；不传服务时随机挑一个（四服务密钥一致、当前没有接口校验 `serviceName`，一枚通用），可选 `--user-id`（默认 -1）、`--ttl`（默认 24h）、`--header`、`--quiet`、`--json`、`--no-apifox`、`--dry-run`；**请求头必须带 Bearer 前缀**（`X-Internal-Token: Bearer <token>`），四个服务都会剥掉前缀再校验，漏掉会返回 401
- **`./mix token` 同时写 Apifox 变量（根目录 `.env` 配好 `APIFOX_ACCESS_TOKEN` / `APIFOX_PROJECT_ID` 时）**：默认 `apifox variables set --scope global` 写项目全局变量 `internal_token`（`--variable-name` 可改）并回读校验，Apifox 全局参数配 `X-Internal-Token: Bearer {{internal_token}}`（变量值不含 Bearer 前缀，前缀写在参数值里）；`--variable-scope environment` 时改写环境变量，**此时必须先用 `apifox export --format apifox` 回读已有 variables 再增量合并**：`environment get` / `list` 都不返回 `variables` 字段，直接 PUT 会把手工维护的变量清掉
- `scripts/run.sh` 的运行工具默认值：`--java-build` 默认 `maven`、`--node-runtime` 默认 `bun`、`--python-runtime` 默认 `uv`
- 两套容器编排的容器名不同：`./mix docker` 用 `mix-<service>-container`，`./mix compose` 用 `mix-<service>`（compose 的 `container_name`）
- `README.md` 行尾由 `.gitattributes` 统一为 LF，批量改文档按「归一化 LF → 断言唯一性后替换」处理，不要逐处手工编辑
- **数据库初始化脚本**统一放根目录 `db/<类型>/`（`mysql`、`postgresql`、`clickhouse`、`mongodb`、`es`、`neo4j`），只放表、扩展、索引、集合级别的脚本；`CREATE DATABASE` 与 `USE` 一律由使用方按需自行执行，**不在脚本中写死库名**，库名以各服务的数据库配置项为准（`DB_NAME`、`DB_MYSQL_DBNAME`、`DB_DATABASE`、`DB_MYSQL_DATABASE`、`DB_POSTGRES_DATABASE`、`DB_MONGODB_DATABASE`）。PostgreSQL 容器侧的初始库由 `scripts/docker-services.sh` 的 `POSTGRES_DB` 创建，需与 `DB_POSTGRES_DATABASE` 保持一致
- 本技能包（`skills/mix-web-demo/`，目录名与 SKILL.md 的 `name` 一致）改完后用 `./mix skills` 同步到本机 Agent 技能目录：目标清单在 `scripts/skills-targets.conf`（只列用户级 `<home>` 技能目录，可自行追加工具，仓库内项目级目录不参与），只同步技能根目录已存在的目标，同步为镜像覆盖且内容一致时跳过；`--list` 看检测结果、`--dry-run` 预演，脚本为 `scripts/skills-sync.sh`（Windows 在 Git Bash 下执行）。脚本用 `HOME`（无则 `USERPROFILE`）解析清单里的 `<home>`，因此在 WSL 的 bash 里执行会把技能写到 Linux 侧主目录 `/home/<user>`，而 IDE 读的是 `C:\Users\<user>`，看起来「同步成功」但技能并未更新（同样是 Windows + WSL 才有的问题）；Windows 下要么用 Git Bash，要么先执行 `HOME=/mnt/c/Users/<user> ./mix skills`

## 接口文档收尾流程

新增、修改或删除对外接口后，除了本服务的格式化、lint 与构建校验，还要走完「重新生成静态文档 → 同步 Apifox」两步，文档产物与服务代码在同一次提交里。

### 第一步：生成静态 OpenAPI 文档（必做）

```bash
# 只处理本次改动的服务
./mix swag spring
./mix swag gozero
./mix swag nestjs
./mix swag fastapi

# 或一次生成四个服务
./mix swag

# 直接调用脚本
./scripts/swag-init.sh spring
```

产物落在各服务自己的 `docs/` 目录，每个服务输出 `openapi.json` 与 `openapi.yaml` 两份等价文档：

| 服务    | 产物                           | 生成方式                                                | 前置工具  |
| ------- | ------------------------------ | ------------------------------------------------------- | --------- |
| Spring  | `spring/docs/openapi.json`     | `OpenApiDocGenerator` 隔离 Web 层上下文 + 依赖自动 Mock | Maven     |
| GoZero  | `gozero/app/docs/openapi.json` | `goctl api swagger` → `swagger2openapi` → `fix.py`      | goctl     |
| NestJS  | `nestjs/docs/openapi.json`     | `NestFactory.create(..., { preview: true })`            | Bun       |
| FastAPI | `fastapi/docs/openapi.json`    | 导入`create_app()` 后取 `app.openapi()`                 | uv/Python |

四个服务都是**离线生成**：不启动 HTTP 服务，不连 Nacos / MySQL / Redis / RabbitMQ，也不需要各服务的 `.env`（Spring 的 Nacos 配置拉取由 `spring/src/test/resources/bootstrap.yaml` 覆盖关闭）。对应工具未安装时该服务跳过并提示，其余服务继续生成；`gateway` 为配置驱动，无 OpenAPI 产物。

生成后核对三件事：

1. 新接口出现在产物里，路径、方法、参数与响应结构和实现一致
2. `openapi.json` 与 `openapi.yaml` 成对更新，不要只提交其中一份
3. GoZero 若改了 `.api`，确认产物里没有 `x-date` / `x-goctl-version`（出现即说明 `fix.py` 后处理没跑到）；同时确认 `gozero/app/docs/websocket.json` 里的描述与 `.api` 的 `@doc` 一致，该文件与 `openapi.json` 一样属于要提交的产物

### 第二步：同步到 Apifox（配置了令牌时执行）

```bash
# 先校验产物与配置，不发起导入请求
./mix apifox --dry-run

# 同步本次改动的服务
./mix apifox spring

# 或同步全部
./mix apifox
```

- 脚本读取的是仓库内的静态产物，**必须先完成第一步**，否则同步上去的是旧文档
- 需要根目录 `.env` 中的 `APIFOX_ACCESS_TOKEN` 与 `APIFOX_PROJECT_ID`；未配置时脚本会直接报错退出，这种情况先提交文档产物，把同步留到配好令牌的环境执行
- 按服务串行导入，输出「新增 / 更新 / 忽略 / 失败」计数；出现失败计数或非零退出码必须排查后重试，不要当成噪声放过
- 覆盖策略默认 `AUTO_MERGE`，保留 Apifox 侧手工维护的中文名、mock 与返回示例，只按文档更新结构化内容
- **同步默认以文档为准做全量对齐**：`APIFOX_DELETE_UNMATCHED_RESOURCES` 默认 `true`，对应开放 API 的 `options.deleteUnmatchedResources`，会删除**该服务导入目录内**（`APIFOX_FOLDER_*`）文档里没有的接口与数据模型（本地实测：只同步 fastapi 时项目接口总数 217 → 210，正好清掉 fastapi 目录内的 7 个残留，其它服务目录未受影响；Apifox 的 WebSocket 接口是独立资源类型，删除后仍完好）。所以删掉代码里的接口后，重新生成产物再同步即可自动清理，**不需要手工删 Apifox 接口**
- 对齐是全量覆盖语义：Apifox 侧手工新增的 HTTP 接口与数据模型会在下次同步时被清掉（`WebSocket` 与 Markdown 文档不受影响）。排查期可临时关闭：`APIFOX_DELETE_UNMATCHED_RESOURCES=false ./mix apifox <service>`；删除是真实删除，可先 `apifox export --project <id> --format apifox --scope all --output <file>` 整项目备份，误删内容可从 Apifox 回收站找回
- 导入前会为每个接口补写 Apifox 责任人扩展 `x-apifox-maintainer`（值取团队内昵称或用户账户名），默认取 `APIFOX_ACCESS_TOKEN` 对应账号的用户名；可用 `APIFOX_MAINTAINER` 显式指定，或用 `--no-maintainer` 关闭填充
- 该扩展会被 Apifox 保留在接口 `oasExtensions` 里并在文档页多渲染一行 `x-apifox-maintainer`，脚本导入后会再导入一次不带扩展的原文档把它清掉（`APIFOX_MAINTAINER_CLEAN_EXTENSION=false` 可保留），责任人字段不受影响
- 令牌等同账号密码，脚本输出不回显令牌，根目录 `.env` 已被 `.gitignore` 忽略
- `./mix apifox` 现在一次做完两件事：接口走开放 API 导入，README 走 Apifox CLI（`doc create` / `doc update`）写入 `APIFOX_README_DOC_ID` 指定的项目 Markdown 文档；README 目标未配置或未装 CLI 时该步骤跳过并提示，不影响接口导入。`--no-readme` 只同步接口，`--create-readme` 首次创建 README 文档，单独同步用 `./mix apifox-readme`
- **WebSocket 接口走独立通道**：Apifox CLI 有 `websocket list/get/create/update/delete` 命令族（顶层 `--help` 不列出，由 `cli-schema list` 的 `websocket-create` / `websocket-update` 暴露），与 HTTP 接口的 `import-openapi` 互不影响。`apifox-sync.sh` 对 gozero 额外执行一步：读 `gozero/app/docs/websocket.json` → `websocket list` 按 `path` 匹配接口 ID → `websocket get` 取完整资源 → 只覆盖 `description` 与 `tags` → `cli-schema validate websocket-update` 校验 → `websocket update`
- WebSocket 同步**不碰** `name`、`parameters`、`commonParameters`、`folderId`、`moduleId`：这些由 Apifox 侧手工维护（浏览器 WebSocket 无法自定义请求头，`token` 是以 query 参数手工补的）。`websocket-update` 的 `additionalProperties` 为 false 且不是 JSON Patch，必须提交完整结构，所以脚本先 `get` 再在完整结构上替换，并剔除 `id` / `projectId` / `createdAt` 等只读字段，否则结构校验失败
- WebSocket 接口在 Apifox 中**不存在时只提示不创建**（首次仍需手工建一次，之后描述与标签变更会自动同步）；该步骤依赖 Apifox CLI 与 python，任一缺失时跳过并提示，不影响 HTTP 接口同步

### 接口描述写在代码声明处

产物内容由代码里的注解 / 装饰器决定，新增接口时同步补上，否则文档里只有路径没有说明（注解字符串一律写字面量，按通用规则 5 不抽常量）：

- 响应结构也必须在代码声明中保持可发现：NestJS 控制器为统一响应补充 `ApiResponseModel` 或重定向响应装饰器，FastAPI 路由为统一响应补充 `ApiResponse[T]` 泛型参数。修改后重新生成对应服务的 `docs/openapi.json` 与 `docs/openapi.yaml`，检查响应的 `data` schema 与实际返回值一致。
- Spring：`@Operation(summary, description)`、`@Tag`
- NestJS：`@ApiOperation({ summary, description })`、`@ApiTags`
- FastAPI：`@router.get/post(..., summary=, description=)`
- GoZero：`.api` 中的 `@doc(summary, description)`；handler 的 `ApplyApiLog` 描述不能留 `constants.API_LOG_PENDING_DESCRIPTION` 占位，必须换成 `app/common/constants/messages.go` 里具体的 `API_LOG_*` 文案

## 验证命令

统一入口是 `./mix compile <service>`（只编译校验，不产出打包目录）；下表列出各服务等价或更细的底层命令，编译未通过不得交付。

| 服务    | 验证                                                                                                                                |
| ------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| spring  | `./mix compile spring`（`mvn -q -DskipTests -Dspotless.check.skip=true compile`；打包 `mvn clean package -DskipTests`）             |
| nestjs  | `./mix compile nestjs`（`tsc --noEmit -p tsconfig.build.json`；打包 `npm run node:build`）                                          |
| fastapi | `./mix compile fastapi`（逐文件字节码编译，只报语法错误、不写 `__pycache__`；另有 pytest）                                          |
| gozero  | `cd gozero/app && GOTOOLCHAIN=local go build ./... && go vet ./... && go test ./... -count=1`；格式与静态检查走 `./mix lint gozero` |
| gateway | `apisix.yaml` 改动后校验 YAML 语法与目标路由的插件挂载（配置驱动，无业务代码）                                                      |

FastAPI 的编译校验刻意不用 Pyright：仓库既有类型基线存在大量报错（主要在测试文件），作为门禁会一直失败；`pyright` 仍保留在 `fastapi/pyproject.toml` 供编辑器使用。`./mix compile` 对未安装工具的服务返回码 2 并跳过，不能把「跳过」当成「通过」。

**Windows 主机上，fastapi 的 `./mix compile fastapi` 与 `./mix test fastapi` 不要用 WSL 的 bash 执行（仅 Windows + WSL 场景，Linux/macOS 按默认命令即可）**：底层是 `uv run`，WSL 里的 Linux uv 会清空并重建 Windows 创建的 `fastapi/.venv`（机制与处置见「仓库工程约束」的依赖环境条目）。这两步在 Windows 上只在 Git Bash / PowerShell 里跑，或改用 `fastapi/.venv/Scripts/python.exe` 的只读等价命令。

### 单元测试与测试工具

各服务的测试工具与运行入口固定，不要引入第二套测试框架；测试分层、必测矩阵与编写规则见 `references/unit-testing.md`：

| 服务    | 测试工具                                                                                        | 命令                                                             |
| ------- | ----------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
| spring  | JUnit 5（Jupiter）+ Mockito + AssertJ；响应式链用 `StepVerifier`，Controller 用 `WebTestClient`  | `./mix test spring`（`cd spring && mvn test`）                    |
| nestjs  | Jest + ts-jest（配置在 `nestjs/package.json` 的 `jest` 段）                                      | `./mix test nestjs`（`bun run test -- --runInBand`）              |
| fastapi | pytest，异步用例用 anyio 插件的 `@pytest.mark.anyio`                                             | `./mix test fastapi`（`cd fastapi && uv run pytest -q`）          |
| gozero  | Go 标准库 `testing` + `net/http/httptest`（当前未引入 testify / sqlmock）                        | `./mix test gozero`（`cd gozero/app && go test ./... -count=1`）  |
| gateway | 无单元测试（配置驱动，不参与）                                                                   | `./mix test gateway` 直接跳过                                    |

工具未安装时该服务返回码 2 并跳过，「跳过」不能当成「通过」；只要改动了服务代码或测试，`./mix test <service>` 就是交付前必跑项（顺序见下一节）。

**NestJS 的 spec 必须同时能被 Jest 与 `bun test` 执行**：`./mix test nestjs` 与 `bun run test` 走的是 Jest（仓库基线，用 `ts-jest` 转换），而 `bun test` 走的是 Bun 内置 `bun:test` 与一层 `jest` 兼容层，两者 API 并不完全对齐。因此 spec 只使用两者都提供的 API（`jest.mock`、`jest.fn`、`jest.spyOn`、`jest.restoreAllMocks` 与 `expect.*`），**禁止 `jest.requireActual`、`jest.requireMock`、`jest.unmock`、`jest.deepUnmock`**，也禁止 `import ... from "bun:test"` 或为兼容新增双导出层，仓库不维护两套测试入口。mock 系统边界（如 `node:http`）时在工厂里显式返回被测代码真正用到的成员即可。报错 `TypeError: jest.requireActual is not a function` 表示用 `bun test` 跑到了含 Jest 专有 API 的 spec，修改 spec 而不是换运行器。

### 代码风格检查与格式化

生成或修改代码后，先格式化再检查，两者都通过才算完成。省略服务名时处理 spring、gozero、nestjs、fastapi 全部：

```bash
# Spring：Eclipse formatter + import 四段分组，配置在 spring/eclipse-formatter.xml
# lint 额外执行 scripts/controller-vo.py，检查控制器是否返回匿名 Map
./mix format spring      # mvn spotless:apply
./mix lint spring        # mvn spotless:check + 控制器返回类型检查

# GoZero：配置在 gozero/app/.golangci.yml
./mix format gozero      # golangci-lint fmt
./mix lint gozero        # golangci-lint run

# NestJS：配置在 nestjs/.prettierrc 与 nestjs/eslint.config.mjs
./mix format nestjs      # npm run format + npm run lint:fix
./mix lint nestjs        # npm run lint + npm run format:check

# FastAPI：配置在 fastapi/pyproject.toml
./mix format fastapi     # ruff format . + ruff check --fix .
./mix lint fastapi       # ruff check .
```

底层脚本是 `scripts/format.sh` 与 `scripts/lint.sh`，对应工具未安装时该服务会自动跳过并提示；`gateway` 为 APISIX 配置，不参与。行尾由根目录 `.gitattributes` 统一为 LF。

### 交付前校验顺序

改完代码后按固定顺序自检，任一步未通过都不算完成（各步命令与底层实现见「验证命令」与「代码风格检查与格式化」章节）：

```bash
./mix format <service>    # 1. 自动格式化
./mix lint <service>      # 2. 代码检查
./mix compile <service>   # 3. 编译校验（只编译，不打包）
./mix test <service>      # 4. 单元测试
```

Windows 主机上执行第 3、4 步时，fastapi 必须在 Git Bash / PowerShell 里跑（WSL bash 中的 `uv run` 会重建 `.venv`，此项只影响 Windows + WSL，Linux/macOS 不受影响）；用不带服务名的 `./mix compile` / `./mix test` 会连带 fastapi，同样要在 Windows 侧 shell 执行，或把 fastapi 换成 `fastapi/.venv/Scripts/python.exe` 的只读等价命令

### Git 提交钩子

仓库内置版本化钩子，启用后 `git commit` 会自动按上面的顺序执行「格式化 → 检查 → 编译校验 → 单元测试」，任一步失败即阻止提交：

```bash
./mix hooks              # 启用（git config core.hooksPath .githooks）
./mix hooks status       # 查看当前配置
./mix hooks uninstall    # 停用
```

- 钩子只对暂存区里属于 `spring/`、`gozero/`、`nestjs/`、`fastapi/` 的改动生效；非这四个目录的提交（文档、`scripts/`、`gateway/`）直接放行
- 格式化跑完后会把结果重新 `git add -u` 进暂存区，保证提交的是格式化后的版本
- 临时跳过单次提交：`SKIP_HOOKS=1 git commit ...` 或 `git commit --no-verify`
- 钩子是 bash 脚本，由 Git 自带的 `sh` 执行：Linux/macOS 直接可用，Windows 走 Git for Windows 自带的 Git Bash（无需 PowerShell 版本）；换行符由 `.gitattributes` 统一为 LF
- 新克隆的仓库需执行一次 `./mix hooks` 才会启用；生成代码时不要依赖钩子替代手动校验，钩子有跨服务共享脚本的覆盖缺口（改 `scripts/` 下的检查脚本本身不会触发服务检查）

`mix` 脚本顶层子命令新增 `compile` 与 `hooks`，完整列表见「仓库工程约束」章节。
