# 架构决策记录:QA 交叉验证发现的架构级遗漏 > 日期:2026-05-11 > 决策人:TechLead > 代码基线:ee3a31e > 关联文档:docs/REMEDIATION_TASK_BOARD_2026-05-06.md --- ## 0. 决策总览 | 编号 | 问题 | 选择方案 | 优先级 | |---|---|---|---| | D-01 | callback_target 契约漂移 | ① 删除 CallbackTarget 字段及其使用 | P1 | | D-02 | outbox 并发 claim 缺失 | ① 增加行级锁(UPDATE RETURNING 实现) | P1 | | D-03 | platform webhook 非严格事务外盒 | ② 在文档/代码中明确标注"最终一致性"边界 | P1 | | D-04 | E2E 稳定性根因 | QA 分析 → TechLead 确认方向 → Engineer 修复 | P1 | --- ## 1. D-01:callback_target 契约漂移 ### 1.1 现状 - `internal/service/platformevents/builder.go:31-46` 从 `meta.CallbackTarget` 读取并写入 `event.CallbackTarget` - `internal/domain/platformevent/event.go:41` 定义 `CallbackTarget string` 字段,`Validate()` 强制非空 - `internal/platformadapter/types.go:25` `PlatformInboundMeta` 定义 `CallbackTarget string` - `internal/service/platformdelivery/worker.go:111` 投递时直接使用固定的 `w.CallbackURL`,**完全不消费** `event.CallbackTarget` - `internal/app/app.go:169-171` worker 初始化按平台只配一个 `CallbackBaseURL`,无多目标路由模型 ### 1.2 方案选择:① 删除 CallbackTarget 字段及其使用 #### 理由 当前平台配置模型(`config.PlatformAdapterProfileConfig`)是**每平台一个固定回调地址**,且没有多目标回调的产品需求证据。保留一个"模型字段存在、校验强制非空、但运行时零消费"的伪能力,会持续造成: 1. 数据模型与运行时行为不一致(契约漂移) 2. 新成员阅读代码时的认知负担 3. 未来重构时误以为已支持多目标路由 删除该字段是**低成本、可回退**的清理动作。若未来确有同一平台多目标回调需求,届时必然伴随配置模型重构(从单 URL 到路由表),同步恢复字段即可。 #### 风险 - 回滚成本:需从 git 历史恢复字段,改动面可控。 - 数据兼容性:DB 中已有 `callback_target` 列,本次只清理 Go 层代码,DB 列暂不删除(避免对已部署环境做破坏性 schema 变更),后续通过独立 migration 清理。 #### 回退策略 若产品侧在本周内提出多目标回调需求,立即中止删除,改为方案②(worker 按 CallbackTarget 路由到配置表 `cs_platform_callbacks`)。 ### 1.3 文件级落点 | 文件 | 位置 | 动作 | |---|---|---| | `internal/domain/platformevent/event.go` | L41 | 删除 `CallbackTarget string` 字段 | | `internal/domain/platformevent/event.go` | L63-65 | 删除 `Validate()` 中 `CallbackTarget` 非空校验 | | `internal/platformadapter/types.go` | L25 | 删除 `PlatformInboundMeta.CallbackTarget` | | `internal/service/platformevents/builder.go` | L15 | 删除 `defaultCallbackTarget` 常量 | | `internal/service/platformevents/builder.go` | L31-34 | 删除 `callbackTarget` 读取与兜底逻辑 | | `internal/service/platformevents/builder.go` | L46 | 删除 `CallbackTarget` 赋值 | | `internal/store/postgres/platform_event_store.go` | L52,58 | 删除 `INSERT` 中 `callback_target` 列与参数 | | `internal/store/postgres/platform_event_store.go` | L80,106 | 删除 `SELECT` 中 `callback_target` 列与扫描 | | `internal/store/postgres/platform_event_store.go` | L185-186 | 删除 `dead_letter` 插入中 `callback_target` | | `internal/service/platformevents/builder_test.go` | 各 `CallbackTarget: "default"` | 删除相关断言与字段 | | `internal/store/postgres/platform_event_store_test.go` | 各 `CallbackTarget: "default"` | 删除相关字段 | | `internal/service/platformdelivery/worker_test.go` | 各 `CallbackTarget: "default"` | 删除相关字段 | | `internal/domain/platformevent/event_test.go` | 各 `CallbackTarget: "default"` | 删除相关字段 | > **注意**:`db/migration/0002_platform_event_outbox.up.sql` 本次不修改;DB 列保留留空运行,待后续专门做 schema 清理 migration。 ### 1.4 QA 可验证项 1. `grep -r "CallbackTarget" --include="*.go" internal/` 返回空(除可能存在的 vendor 外) 2. `go test ./internal/... -count=1` 全部通过 3. `go build ./...` 通过 --- ## 2. D-02:outbox 并发 claim 缺失 ### 2.1 现状 - `internal/store/postgres/platform_event_store.go:78-86` `ListDue` 使用普通 `SELECT ... ORDER BY ... LIMIT` - 无 `FOR UPDATE SKIP LOCKED`,多实例部署时多个 worker 会读到同一批事件,导致**重复投递** - `MarkDelivered` / `MarkRetry` / `MarkDeadLetter` 是独立 SQL,存在竞态窗口 ### 2.2 方案选择:① 增加行级锁(UPDATE RETURNING 实现) #### 理由 显式声明"单实例约束"(方案②)会限制架构扩展性,与生产级目标不符。纯 `SELECT ... FOR UPDATE SKIP LOCKED` 若不在事务内保持锁则无意义(`ListDue` 返回后事务结束,锁释放)。 最简生产级实现是把 `ListDue` 改造为**事务内 claim 模式**: ```sql UPDATE cs_platform_event_outbox SET status = 'claiming', updated_at = NOW() WHERE id IN ( SELECT id FROM cs_platform_event_outbox WHERE platform = $1 AND status IN ('pending','retrying') AND next_attempt_at <= $2 ORDER BY next_attempt_at ASC, occurred_at ASC, created_at ASC, id ASC LIMIT $3 FOR UPDATE SKIP LOCKED ) RETURNING * ``` - 一行 SQL 完成"筛选 + 加锁 + 状态变更 + 返回" - 其他实例并发时因 `SKIP LOCKED` 自动跳过已锁行 - worker 投递完成后,再调用 `MarkDelivered` / `MarkRetry` 将状态从 `claiming` 迁移到终态 #### 风险 - **claim 悬挂**:worker 在 `claiming` 后 crash,事件会永远卡在 `claiming`。需要新增**claim 超时回收机制**(如每 30 秒把超过 5 分钟的 `claiming` 重置为 `retrying`)。 - 新增 `claiming` 状态需要修改 DB CHECK 约束,需新增 migration。 #### 回退策略 若实现周期超过 2 人日,可先合并 `claiming` 状态与超时回收逻辑,但保留单实例部署注释作为兜底声明。 ### 2.3 文件级落点 | 文件 | 位置 | 动作 | |---|---|---| | `db/migration/0003_outbox_claiming_status.up.sql` | 新增 | 新增 migration:将 `'claiming'` 加入 `chk_cs_platform_event_outbox_status`;建议同时加 `idx_cs_platform_event_outbox_claiming_timeout` 索引 (`status, updated_at`) | | `internal/domain/platformevent/event.go` | L12-16 | 新增 `StatusClaiming Status = "claiming"`;`Validate()` 中允许该状态 | | `internal/store/postgres/platform_event_store.go` | L78-86 | `ListDue` 改为事务内执行上述 `UPDATE ... RETURNING` 模式 | | `internal/store/postgres/platform_event_store.go` | 新增方法 | 新增 `ReleaseStaleClaims(ctx, timeout time.Duration) (int, error)`:将超时的 `claiming` 重置为 `retrying` | | `internal/service/platformdelivery/worker.go` | L60-80 `Start` | 在 `Start` 中新增一个额外 ticker(如每 30 秒),调用 `ReleaseStaleClaims` | | `internal/service/platformdelivery/worker.go` | L93-102 `RunOnce` | 保持现有 `ListDue` 调用方式(接口名不变,内部实现已改),投递成功后正常 `MarkDelivered`/`MarkRetry` | ### 2.4 QA 可验证项 1. **并发无重复投递**:在测试中启动两个 worker(两个 goroutine 或两个进程),注入 100 条事件,验证 callback server 收到的 event_id 无重复。 2. **claim 回收**:模拟 worker crash(在 `ListDue` 后、投递前 panic),等待 claim 超时后,验证事件被重新置为 `retrying` 并最终被成功投递。 3. `go test ./internal/service/platformdelivery/... -count=1` 通过。 --- ## 3. D-03:platform webhook 非严格事务外盒 ### 3.1 现状 - `internal/http/handlers/platform_webhook_handler.go:86` 调用 `h.dialog.Process` - `internal/http/handlers/platform_webhook_handler.go:100` 调用 `h.eventWriter.InsertPendingBatch` - 两者**不在同一事务** - `dialog.Process` 成功但 outbox 写入失败时,业务状态(Session/Ticket/Audit)已持久化,但下游收不到事件 ### 3.2 方案选择:② 在文档/代码中明确标注"最终一致性"边界 #### 理由 当前 `dialog.Process` **内部本身也没有事务外盒**: - `SessionRepository.GetOrCreate`(独立 SQL) - `DedupRepository.TryRecord`(独立 SQL) - `TicketRepository.Create`(独立 SQL) - `SessionRepository.Save`(独立 SQL) - `AuditRepository.Add`(独立 SQL) 各 repository 调用均为独立连接操作。单独把 outbox 写入拉进 dialog 事务是**局部优化**,不能解决 dialog 内部一致性问题。要真正做到严格事务外盒,需要重构整个 repository 层,引入 `UnitOfWork` 或 `TxProvider`,改动面大、风险高、与当前稳定性优先目标不符。 当前阶段更务实的选择是: 1. **明确承认最终一致性边界** 2. 利用现有机制降低风险:outbox 写入失败会返回 HTTP 500,平台方可重试;`Dedup.TryRecord` 可防止重复处理导致的业务状态重复变更 #### 风险 - 小概率场景:dialog 成功(Session/Ticket 已变)+ outbox 失败 → 下游无事件。但该场景会返回 500,平台 webhook 调用方可重试;重试时 dedup 保证业务状态不再变更,outbox 会重新写入。 - 若平台方不重试,则出现状态与事件不一致。需在 API 文档中明确告知平台方必须处理 500 重试。 #### 回退策略 未来当 repository 层统一引入 `UnitOfWork / sql.Tx` 支持后,可将 dialog 全链路 + outbox 纳入同一事务。届时从文档和代码注释中移除"最终一致性"标注。 ### 3.3 文件级落点 | 文件 | 位置 | 动作 | |---|---|---| | `internal/http/handlers/platform_webhook_handler.go` | L86-103 之间 | 增加注释块,说明 `dialog.Process` 与 `outbox.InsertPendingBatch` 不在同一事务,架构定位为最终一致性;outbox 失败返回 500,由调用方重试 | | `internal/service/dialog/service.go` | 文件头部或 `Process` 上方 | 增加注释,说明当前 repository 层无统一事务外盒,各存储操作为最终一致性 | | 本文档 | - | 作为正式架构决策记录留存 | ### 3.4 QA 可验证项 1. 阅读 `platform_webhook_handler.go` 与 `dialog/service.go` 注释,确认存在"最终一致性"说明。 2. 无需要运行的专属测试(本决策为架构声明,非代码功能变更)。 --- ## 4. D-04:E2E 稳定性根因分析与修复路径 ### 4.1 现状 - `test/e2e/sub2api_callback_flow_test.go` 反复出现: - 事件顺序异常:`reply.generated` 先于 `message.received` - `sql: database is closed` ### 4.2 根因分析(TechLead 预评估,需 QA 确认) #### 根因 A:`sql: database is closed` - 测试函数内 `defer db.Close()`(L150)在 `t.Cleanup(application.Shutdown)`(L102-104)之前执行 - Go 执行顺序:函数体 defer(LIFO)→ `t.Cleanup`(LIFO) - 结果:DB 先关闭 → application worker 仍在运行 → worker 访问已关闭的 DB → `database is closed` #### 根因 B:事件顺序异常 - `platformdelivery.Worker.RunOnce` 是**串行** `for` 循环逐个投递(worker.go:98-102) - 但 callback server 是 `httptest.Server`,对每个 HTTP 请求启动**独立 goroutine** - 测试中的 callback handler 用 `mu.Lock` 保护 `received` 切片,但**锁的获取顺序 ≠ HTTP 请求发起顺序** - 结果:`message.processing` 的请求 handler 可能先拿到锁,先于 `message.received` 被 append ### 4.3 修复方向(TechLead 确认) | 根因 | 修复方向 | |---|---| | `database is closed` | 调整测试生命周期:将 `db.Close` 从 `defer` 移入 `t.Cleanup`,并确保其在 `application.Shutdown` **之后**执行;或让 `application` 复用测试创建的 `sql.DB` 并由 `Shutdown` 统一关闭 | | 事件顺序异常 | **方案一(推荐)**:callback server 改为同步队列模式(channel + 单 goroutine 消费),保证收到的顺序与 worker 发出顺序一致;**方案二**:断言逻辑改为按语义排序后比较(不依赖 received 原始顺序) | ### 4.4 执行路径 1. **QA 负责**:复跑测试并抓取完整日志,确认上述两个根因与预评估一致;输出根因分析报告(含时间线、goroutine 竞争证据)。 2. **TechLead 负责**:审核 QA 根因报告,确认修复方向(本 ADR 已预先确认方向如上)。 3. **Engineer 负责**:按确认后的方向修改测试代码;若 QA 发现新的根因,TechLead 重新评估方向。 ### 4.5 文件级落点 | 文件 | 位置 | 动作 | |---|---|---| | `test/e2e/sub2api_callback_flow_test.go` | L149-150 | 移除 `defer db.Close()`;改为在 `t.Cleanup` 中关闭,且注册顺序晚于 `application.Shutdown` 的 Cleanup | | `test/e2e/sub2api_callback_flow_test.go` | L157-166 | callback server handler 改为同步队列:用带缓冲 channel 收集事件,断言侧从 channel 按顺序读取 | | `test/e2e/sub2api_callback_flow_test.go` | L209-230 | `waitForSessionEvents` / 断言逻辑适配同步队列模式 | ### 4.6 QA 可验证项 1. `go test ./test/e2e/... -run TestSub2APICallbackFlow -count=10` 连续 10 次全部通过 2. 无 `database is closed` 报错 3. 无事件顺序失败 --- ## 5. 与旧 Remediation Board 的兼容性评估 | 本 ADR 编号 | Remediation Board 对应项 | 兼容性 | |---|---|---| | D-01 | D-03 / I-03(callback_target 契约漂移) | **兼容并细化**。Board 要求"删除伪能力或落地多目标路由",本 ADR 明确选择删除,给出完整文件级落点。 | | D-02 | D-04 / I-04(outbox 并发 claim) | **兼容并升级**。Board 要求"至少明确单实例约束,或设计 claim/skip locked 方案",本 ADR 选择生产级 claim 方案(UPDATE RETURNING),比最低要求更进取,但落点可控。 | | D-03 | I-05(transactional outbox) | **兼容**。Board 允许"评估并落地更严格方案,或明确记录当前非强一致边界",本 ADR 选择后者并给出理由(dialog 内部本身无事务)。 | | D-04 | V-02(E2E 稳定性复跑) | **兼容并细化**。Board 要求"形成重复执行证据",本 ADR 给出明确根因假设、修复方向和 QA/Engineer 分工。 | **结论**:本 ADR 的所有决策均可在不推翻 Remediation Board 的前提下执行,是对 Board 中设计段/实现段任务的具体技术收口。 --- ## 6. 阶段门控结论 | 检查项 | 结论 | 说明 | |---|---|---| | D-01 设计是否可进入实现 | **通过** | 删除方向明确,文件级落点已枚举,回退策略清晰 | | D-02 设计是否可进入实现 | **通过** | UPDATE RETURNING 方案已确定,需 Engineer 评估 claim 超时回收实现复杂度(预计不超过 1 人日) | | D-03 设计是否可进入实现 | **通过** | 仅代码注释与文档标注,无功能代码变更,风险最低 | | D-04 是否可进入 Engineer 修复 | **条件通过** | 需 QA 先输出根因分析报告(预计 0.5 人日),TechLead 二次确认后 Engineer 执行 | | 整体是否可进入实现段 | **通过** | D-01/D-02/D-03 可并行启动;D-04 按"QA 分析 → TechLead 确认 → Engineer 修复"串行执行 | --- ## 7. 最短执行顺序建议 1. **并行启动**: - Engineer A:D-01(删除 CallbackTarget) - Engineer B:D-03(最终一致性注释)+ D-02(claim 机制) 2. **QA 同步启动**:D-04 根因分析(跑测试、抓日志、确认根因) 3. **QA 产出根因报告后**:TechLead 10 分钟内确认方向 4. **Engineer B 接着做**:D-04 测试修复 5. **全部完成后**:QA 跑 V-02 稳定性复跑(`go test ./... -count=5`)作为阶段门禁