--- title: "模块开发规范:通信契约、接口与故障隔离" status: active created: 2026-09-20 updated: 2026-09-20 --- # 模块开发规范:通信契约、接口与故障隔离 > 本文件是开发者入口,不是新的架构 SSOT。 > 决策仍以 [模块化单体 ADR](../adr/2026-09-14-modular-monolith-decoupling.md) 为准。 > 机器强制仍以 [module-boundaries.yaml](./module-boundaries.yaml) 与 `./scripts/arch-check.sh --mode block` 为准。 > 影响面守则不在此重复,见 [partial-deferred 计划 §2](../remediation/2026-09-16-partial-deferred-completion-plan.md)。 2026-09-20 确认:继续模块化单体。模块之间用进程内窄端口或 PostgreSQL 领域事件通信。不新增通用 IPC/RPC,不拆微服务。跨进程只允许两类既有边界:HTTP(Web↔server、server↔PI Runner)和 `cmd/events` 经 PG lease 消费。 ## 1. 问题必须停在本模块 一次故障只允许表现为下面之一,不允许连带改第二个领域包才能编译或才能回滚数据: | 故障位置 | 允许看见的影响 | 禁止 | |----------|----------------|------| | 领域不变量 | 本包返回 typed error;同一 UnitOfWork 回滚 | 别的领域包 import 本包实现后自行改表 | | 用例编排 | 组合根换一个端口实现 | handler 直接 new 领域服务或拿 `*pgxpool.Pool` 写别人的表 | | 异步副作用 | 该消费者 delivery 重试、死信或 quarantine | 一个消费者失败卡住其他消费者,或在持锁时调用 LLM/HTTP | | 对外协议 | server 把领域错误映射成固定错误信封 | 把 SQL、路径、prompt、正文放进 HTTP message | 静态底线已经在门禁里:领域实现互不 import(消费端口和稳定词汇除外)、每张表一个写者、每个事件名有生产者/消费者 allowlist。 ## 2. 三种通信契约 只允许这三种。新增第四种(包括模块间 gRPC、进程内全局 bus 单例、跨包直接调未导出帮助函数)先写 ADR。 ### 2.1 进程内窄端口 消费者拥有接口,组合根注入实现。参照 `internal/app/chatflow/ports.go`:接口只表达这一个用例要的能力。 | 规则 | 说明 | |------|------| | 谁定义 | 调用方包,不放在被调用的领域包里“顺便导出” | | 宽度 | 一个用例一组方法。三个以上消费者语义相同时才提升为公共端口 | | 禁止万能接口 | 不得出现聚合所有模块方法的 `Store` / `Engine` 门面 | | 禁止传输细节 | 端口结构体不带 JSON tag,不返回 `http.Response`、SQL 行、`pgx.Tx` | | 身份 | `Principal` 只来自已认证会话或服务身份,不从请求体覆盖 UserID/ProjectID | | 装配 | 只有 `internal/app` 与 `cmd/*` 构造实现。`internal/server` 只拿用例或端口 | ### 2.2 领域事件(跨进程总线) 生产路径是 PostgreSQL outbox + `cmd/events` worker(`FOR UPDATE SKIP LOCKED`),不是内存 `MemoryBus`。`MemoryBus` 只用于 dispatcher 特征测试。 契约只有一处运行时副本:`internal/events/registry.go` 的 `StaticContracts()`,并且必须与 YAML 的 event 段 parity。未登记的事件名或消费者在构建期失败。`fav.artifact_staged` 保持 `Enabled: false`,直到独立 FAV 计划放量。 信封至少包含:`event_id`、`event_name`、`schema_version`、`environment`、`project_id`、aggregate 标识与版本、`actor_ref`、`correlation_id` / `causation_id`、`idempotency_key`、`payload_digest`、`occurred_at`。 事件不得携带 secret、token、prompt、正文或绝对路径。大 payload 先冻结为不可变引用,事件只留 digest。 投递是至少一次。消费者用唯一键或 inbox receipt 保证对外只看见一次结果。副作用与 receipt 能进同一数据库事务的必须同事务。永久失败进 dead letter,不得阻塞其他订阅者。 ### 2.3 对外 wire DTO HTTP/SSE 的请求响应只存在于 `internal/server`(及 `internal/stream` 的传输类型)。领域包不 import server。 错误信封沿用已合入的 typed envelope:`code` / `message` / `request_id` / 可选 `details`。`message` 用固定安全文案;`details` 只允许该 code 的公开字段。授权拒绝不得泄露资源是否存在。 PI Runner 继续 typed-result-v1。typed 字段存在但校验失败必须 fail closed,不得降级成旧解析。 ## 3. 输入与输出 ### 3.1 新增或修改端口时的 checklist - [ ] 接口放在消费者用例包,方法名是业务动作而不是表操作。 - [ ] 入参是该动作的命令/查询结构;出参是结果或本用例的 typed error。 - [ ] 无 JSON tag,无 `pgx` 类型,无物理路径,无另一领域包的具体 struct(稳定词汇除外)。 - [ ] 错误值留在领域包或用例包;server 只做 `errors.Is` 到 HTTP code 的映射,映射表集中在一处,不散落在每个 handler。 - [ ] 组合根测试用假实现替换端口,不 import 对方的 SQL。 ### 3.2 新增事件时的 checklist - [ ] `event_name` 形如 `.`,表达已发生的事实,不是命令。 - [ ] `schema_version` 从 1 起;破坏性字段变更升版本,未知 major fail closed。 - [ ] payload 字段写入 `StaticContracts().PayloadFields`,并同步 YAML allowlist。 - [ ] 生产者、消费者包名写入同一条契约;运行时注册必须是静态清单的子集。 - [ ] 领域提交、envelope、delivery 行在同一个 UnitOfWork。缺 `environment` 或 `project_id` 时 fail closed,禁止写死 `"dev"` / `0`。 - [ ] 消费者有幂等键;重复投递测试证明只有一个可见结果。 - [ ] `./scripts/arch-check.sh --mode block` 与 registry parity 测试通过。 ### 3.3 稳定词汇 无行为的枚举和值对象才可以进共享词汇。带解析、哈希、路径拼接的函数留在所属模块(现例:`internal/doccontent`),禁止塞进 vocabulary。 跨包状态/角色/哨兵错误使用 `internal/contracts/vocabulary`(批次 C 决策,见 [short-term](../ops/short-term/2026-09-22-docgov-vocabulary-convergence.md))。不要新增对 `docgov.State*` / `docgov.Role*` / `docgov.Err*` 的跨包引用;`docgov` 仅保留领域别名与结构化错误类型。 ## 4. 故障隔离红线 1. **表唯一写者**。`INSERT/UPDATE/DELETE` 只能出现在 YAML `table_owners` 指定的包。跨表原子性由用例打开的 UnitOfWork 提供,不是每个模块各开事务。 2. **解包事务的包有名单**。只有登记过的属主可以从 `uow.TxScope` 取出 `pgx.Tx`。server 与 chat 不在此列。 3. **领域实现互不 import**。需要对方能力时定义端口,由 `internal/app` 注入。用子包绕开编译环但保留反向 import,视为未解耦。 4. **持锁期间不做外部 IO**。LLM、PI Runner、对象存储、HTTP、SSE、大文件复制放在提交之后,由 outbox 驱动。 5. **事件消费者 fail closed**。授权失败、环境不符、schema 未知 → quarantine,不当成瞬时错误重试。FAV 消费者保持禁用。 6. **HTTP 不修投影**。GET 不得写数据库或覆盖 head。修复是独立命令并写审计。 7. **单文件变更面**。新生产 Go 文件不超过 800 行。已有超过 1000 行的文件(见审计热点 5)只允许在对应整改批次里拆,不借无关功能 PR 顺手重排。 ## 5. 新增功能怎么落 按顺序做。跳过门禁视为未完成,即使功能在本地可用。 ### 5.1 新模块 1. 在 short-term 写明唯一写入所有权(表、文件或无)。 2. 在 `module-boundaries.yaml` 的 `imports` 里只放允许依赖的包。默认可依赖 config、稳定词汇、本模块 repository。不可依赖另一个领域实现。 3. 组合根负责构造。server 若需要该能力,只能通过已有用例端口。 4. FEATURE-MATRIX 加行,mutex 与 CURRENT 认领一致。 ### 5.2 新表 1. Goose 迁移只 additive。 2. YAML `table_owners` 登记唯一写者。 3. 跨表不变量写在用例的一个 UnitOfWork 里,并写明锁顺序:project/workflow → 聚合根 → version/turn → task run → outbox。 4. 不允许 server handler 出现该表的 SQL。 ### 5.3 新事件或新消费者 走 §3.2。消费者状态是 `off | shadow | live`,按环境 + 消费者配置,不靠单个进程的环境变量偷偷打开。shadow 只写对账,不写业务副作用。FAV 不在此流程里放量。 ### 5.4 改已有模块 1. 先看 [耦合审计](../audit/2026-09-20-module-coupling-audit.md) 该包是否已有 P1/P2。有则优先缩小依赖,而不是再增加一个具体类型字段。 2. 影响面守则(partial-deferred §2.2)仍然有效:深模块、禁止新增跨模块 import、schema additive、一能力一 flag、事件双登记、改声明范围要留 before/after。 3. 例外若必须保留,写入 YAML `exceptions` 时带 owner、原因、范围、到期日和移除里程碑。禁止无到期日的例外。本轮不新增例外来掩盖热点 1–4。 ## 6. 与现有文档的分工 | 问题 | 读这里 | 不要在本文件找 | |------|--------|----------------| | 为什么是模块化单体 | ADR | 实施波次 | | 事务、锁、FAV、授权模型 | [架构设计](./2026-09-14-module-decoupling-architecture.md) | 今日残留热点 | | 事件状态机与 Track E/S/P | [Phase 2 计划](../remediation/2026-09-15-module-decoupling-ipc-phase2-remediation-plan.md) | 新批次 A–G | | 改功能时如何收敛影响面 | [partial-deferred §2](../remediation/2026-09-16-partial-deferred-completion-plan.md) | 端口字段清单 | | 今天还有哪些耦合 | [耦合审计](../audit/2026-09-20-module-coupling-audit.md) | 发布门禁 | | 接下来哪一批先改 | [分批整改](../remediation/2026-09-20-module-decoupling-batch-plan.md) | 代码 | ## 7. 提交前自检 ```bash ./scripts/arch-check.sh --mode block go vet ./... go test -short ./... ``` 改了事件契约时,额外确认 `TestRegistryParityWithYAML` 在跑到的包里通过。普通工程文档变更按 [验证契约](../ops/2026-09-21-validation-contract.md) 检查差异、链接和状态;不运行无关 Go/PI 套件。运行模板、提示词与代码契约变更按受影响范围运行上述门禁,防止索引或生成物被误改。