# ai-customer-service 生产上线修复方案与技术任务拆解

> 生成日期：2026-05-11  
> 基线 commit：`67922c5` (HEAD)  
> 技术负责人：TechLead  
> 对应 Review 报告：`docs/CODE_REVIEW_REPORT_SYSTEMATIC_2026-05-11.md`

---

## 1. 设计范围

### 1.1 本次覆盖

| Review 报告项 | 优先级 | 本次是否覆盖 |
|---|---|---|
| P0-1 Dirty worktree 收口 | P0 | ✅ 覆盖 |
| P0-2 Makefile test 目标缺少 `-p 1` | P0 | ✅ 覆盖 |
| P1-1 ticket_handler.List 覆盖率 0% | P1 | ✅ 覆盖 |
| P1-2 newapi_adapter.BuildIngressAck 覆盖率 0% | P1 | ✅ 覆盖 |
| P1-3 Authz header 伪造风险未文档化 | P1 | ✅ 覆盖 |
| P1-4 RateLimiter GC 压力 | P1 | ✅ 覆盖 |
| P1-5 IPv6 地址在 rate limit key 中被错误截断 | P1 | ✅ 覆盖 |
| P2-1 配置解析失败静默回退 | P2 | ✅ 覆盖 |
| P2-2 callback worker 无连接池限制 | P2 | ✅ 覆盖 |
| P2-3 缺少 SQLite/内存测试回退 | P2 | ✅ 覆盖 |
| P2-4 worker 缺少优雅关闭等待 | P2 | ✅ 覆盖 |

### 1.2 明确不做

- **不引入 pgx 替换 lib/pq**：review 报告建议项，但当前无功能缺口，不属于阻塞问题。
- **不实现 newapi 完整 ingress 逻辑**：旧 remediation board（D-01/I-01）已覆盖该设计缺口，本次仅补充 `BuildIngressAck` 的单元测试（与 review P1-2 对应），不改变 newapi 仍为 501 占位的事实。
- **不引入 testcontainers-go**：P2-3 仅做 skip 回退与文档标注，不做完整容器化测试基础设施。
- **不改写 outbox 并发 claim / transactional outbox**：属于旧 remediation board（I-04/I-05）范围，本次不做。

### 1.3 与 review 报告对应关系

| 本方案章节 | Review 报告章节 | 问题编号 |
|---|---|---|
| 3.1 P0 修复 | 5. P0 — 阻塞级 | P0-1, P0-2 |
| 3.2 P1 修复 | 5. P1 — 必须修复 | P1-1 ~ P1-5 |
| 3.3 P2 修复 | 5. P2 — 建议修复 | P2-1 ~ P2-4 |

---

## 2. 修复方案总览

| 问题 | 技术方案概述 |
|---|---|
| **P0-1 Dirty worktree** | 分 2 批 commit：① 文档文件（8 modified + 4 untracked docs）② 代码文件（3 modified internal）。commit 后打 tag `v0.9.1-pre`。 |
| **P0-2 Makefile test** | `Makefile:2` 改为 `go test ./... -count=1 -p 1`，与 README/CI 保持一致。 |
| **P1-1 ticket_handler.List 0%** | 在 `ticket_handler_test.go` 补充 `List` 的成功与错误分支单元测试，使用现有 `mockTicketService` + `memory.TicketStore`。 |
| **P1-2 newapi_adapter.BuildIngressAck 0%** | 新建 `internal/platformadapter/newapi_adapter_test.go`，覆盖 `BuildIngressAck(meta=nil)` 与 `meta!=nil` 两个分支。 |
| **P1-3 Authz 伪造风险** | 新建 `docs/SECURITY_BOUNDARY.md`，明确 `RequireRoles` 的信任边界；在 `authz.go` 函数注释中标注“依赖上游网关完成真实身份验证”。 |
| **P1-4 RateLimiter GC 压力** | `limits.go:67-73` 将 `var valid []time.Time` 新分配改为原地双指针过滤，复用 `sw.tokens` 底层数组。 |
| **P1-5 IPv6 截断** | `limits.go:110-114` 将手动 `lastIndexByte(addr, ':')` 替换为 `net.SplitHostPort`，正确提取 IPv6 host；补充 IPv6 单元测试。 |
| **P2-1 配置静默回退** | `config.go` 新增 `mustGetEnvInt` / `mustGetEnvBool`（解析失败返回 error）；在 `Load()` 生产模式下对关键数值型配置启用严格解析。 |
| **P2-2 worker 连接池** | `app.go:172` 将裸 `&http.Client{Timeout: ...}` 替换为显式配置 `Transport.MaxIdleConns` / `MaxIdleConnsPerHost` 的 client。 |
| **P2-3 测试回退** | `test/e2e/*_test.go` 与 `test/integration/*_test.go` 在 `TestMain` 或每个 `Test*` 开头增加 PostgreSQL 连通性检测，不通则 `t.Skip`。 |
| **P2-4 worker 优雅关闭** | `app.go` 在 startWorker 中引入 `sync.WaitGroup`：`wg.Add(1)` + goroutine `defer wg.Done()`；closer 中 `cancel()` 后执行 `wg.Wait()`（带 5s 超时兜底）。 |

---

## 3. 任务拆解表

> 粒度约束：每个任务 2-5 分钟，必须有具体文件路径和函数名。

### 3.1 P0 — 阻塞级

| 任务 ID | 文件路径 | 函数/位置 | 预期变更 | 估计耗时 |
|---|---|---|---|---|
| **P0-1a** | 全仓库 | `git status` | 确认 dirty 文件清单，按 "docs" / "code" 分组 | 2 min |
| **P0-1b** | 全仓库 | `git commit` | 批次 1：提交 12 个 docs 文件（modified + untracked）；消息 `docs: sync review reports, runbooks, and checklists` | 3 min |
| **P0-1c** | 全仓库 | `git commit` | 批次 2：提交 3 个 internal/ 文件；消息 `fix: platform event store and builder drift` | 3 min |
| **P0-1d** | 全仓库 | `git tag` | 打 tag `v0.9.1-pre`；推送 tag | 2 min |
| **P0-2a** | `Makefile:2` | `test` target | 将 `go test ./...` 改为 `go test ./... -count=1 -p 1` | 2 min |

### 3.2 P1 — 必须修复

| 任务 ID | 文件路径 | 函数/位置 | 预期变更 | 估计耗时 |
|---|---|---|---|---|
| **P1-1a** | `internal/http/handlers/ticket_handler_test.go` | `TestTicketHandlerList_Success` | 新增测试：向 `mockTicketService.tickets` Create 2 条 ticket，调用 `h.List`，断言返回 200 且 `items` 数组长度为 2 | 3 min |
| **P1-1b** | `internal/http/handlers/ticket_handler_test.go` | `TestTicketHandlerList_ServiceError` | 新增测试：注入一个返回 error 的 `TicketService` mock（或改 `ListOpen` 返回 `errors.New("db down")`），断言返回 500 且 error code 为 `CS_SYS_5002` | 3 min |
| **P1-2a** | `internal/platformadapter/newapi_adapter_test.go` | `TestNewAPIAdapter_BuildIngressAck_NilMeta` | 新建文件与测试：`adapter.BuildIngressAck(nil, nil)` 断言返回 `map[string]any{"accepted":false,"platform":"newapi"}` | 3 min |
| **P1-2b** | `internal/platformadapter/newapi_adapter_test.go` | `TestNewAPIAdapter_BuildIngressAck_WithMeta` | 同上文件：传入 `&PlatformInboundMeta{EventID:"evt-1"}`，断言返回包含 `event_id:"evt-1"` | 2 min |
| **P1-3a** | `docs/SECURITY_BOUNDARY.md` | — | 新建文档：明确标注 `internal/http/middleware/authz.go:RequireRoles` 仅做 RBAC 白名单校验，不验证 header 真实性；生产部署必须前置 API Gateway / JWT 验证 | 3 min |
| **P1-3b** | `internal/http/middleware/authz.go:42` | `RequireRoles` | 在函数注释头增加 `// SECURITY: This middleware trusts the upstream gateway to authenticate the actor headers.` | 2 min |
| **P1-4a** | `internal/platform/httpx/limits.go:67-73` | `RateLimiter.Allow` | 将 `var valid []time.Time` + `append` 循环改为原地双指针过滤：`n := 0; for _, t := range sw.tokens { if t.After(cutoff) { sw.tokens[n] = t; n++ } }; sw.tokens = sw.tokens[:n]` | 3 min |
| **P1-4b** | `internal/platform/httpx/limits_test.go` | 现有测试 | 运行 `go test -race ./internal/platform/httpx/...`，确认零 DATA RACE | 2 min |
| **P1-5a** | `internal/platform/httpx/limits.go:98-115` | `rateLimitKey` | 导入 `net`；将 `lastIndexByte(addr, ':')` 截断逻辑替换为 `host, _, err := net.SplitHostPort(addr)`，err==nil 则返回 host，否则返回原值 | 3 min |
| **P1-5b** | `internal/platform/httpx/limits.go:117-124` | `lastIndexByte` | 删除 `lastIndexByte` 函数（若已无其他引用） | 2 min |
| **P1-5c** | `internal/platform/httpx/limits_test.go` | `TestRateLimitKey_IPv6` | 新增测试：`rateLimitKey` 对 `req.RemoteAddr = "[::1]:8080"` 应返回 `"::1"`；对 `"[2001:db8::1]:8080"` 应返回 `"2001:db8::1"` | 3 min |

### 3.3 P2 — 建议修复

| 任务 ID | 文件路径 | 函数/位置 | 预期变更 | 估计耗时 |
|---|---|---|---|---|
| **P2-1a** | `internal/config/config.go:201-255` | `getEnvInt` / `getEnvBool` | 新增 `mustGetEnvInt(key string) (int, error)` 与 `mustGetEnvBool(key string) (bool, error)`：解析失败时返回 `fmt.Errorf` 而非静默 fallback | 3 min |
| **P2-1b** | `internal/config/config.go:66-148` | `Load()` | 生产模式下，对 `AI_CS_WEBHOOK_MAX_SKEW_SECONDS` 等关键数值配置若环境变量存在但解析失败，返回 error（可选：仅替换最危险的 2-3 个字段以控制范围） | 4 min |
| **P2-2a** | `internal/app/app.go:172` | `startWorker` 内 client 创建 | 将 `&http.Client{Timeout: ...}` 替换为 `&http.Client{Timeout: ..., Transport: &http.Transport{MaxIdleConns: 100, MaxIdleConnsPerHost: 10}}` | 3 min |
| **P2-3a** | `test/e2e/*_test.go` | `TestMain` 或首个 Test | 增加 `pgCheck()`：尝试 `sql.Open("postgres", dsn).Ping()`，失败则 `t.Skip("PostgreSQL not available")` | 3 min |
| **P2-3b** | `test/integration/*_test.go` | `TestMain` 或首个 Test | 同上增加 skip 逻辑 | 3 min |
| **P2-4a** | `internal/app/app.go:158-188` | `startWorker` 闭包 | 在 `New` 函数内声明 `var workerWg sync.WaitGroup`；`startWorker` 中 `workerWg.Add(1)`；goroutine 内 `defer workerWg.Done()`；`worker.Start(workerCtx)` | 3 min |
| **P2-4b** | `internal/app/app.go:164-167` | worker closer | 将 closer 改为：`cancel(); done := make(chan struct{}); go func() { workerWg.Wait(); close(done) }(); select { case <-done: return nil; case <-time.After(5 * time.Second): return fmt.Errorf("worker %s shutdown timeout", platform) }` | 4 min |
| **P2-4c** | `internal/app/app.go` | `import` | 确认新增 `sync` 和 `time`（time 通常已有）导入 | 2 min |

---

## 4. 风险与保护

### 4.1 风险清单

| 风险 ID | 来源任务 | 风险描述 | 等级 |
|---|---|---|---|
| R-01 | P0-1b/c | 批量 commit 可能将未评审代码带入基线 | 🟡 中 |
| R-02 | P1-4a | 限流器原地过滤改动若索引越界，可能 panic 核心路径 | 🔴 高 |
| R-03 | P1-5a | `net.SplitHostPort` 对 IPv6 兼容但可能改变 IPv4 行为（实际不会，但需验证） | 🟡 中 |
| R-04 | P2-1b | 严格配置解析可能破坏现有开发/测试环境（如 `AI_CS_MAX_BODY_BYTES=1MB` 拼写错误导致启动失败） | 🟡 中 |
| R-05 | P2-4a/b | `sync.WaitGroup` 使用不当可能导致 `Shutdown` 死锁或 panic（如 `wg.Add` 在 goroutine 启动后调用） | 🔴 高 |
| R-06 | 全量 | 任何代码改动引入 race condition | 🟡 中 |

### 4.2 降级策略

| 风险 ID | 降级策略 |
|---|---|
| R-01 | commit 前执行 `git diff --cached` 复核；docs 与代码分开 commit，一旦有问题可单独 revert。 |
| R-02 | ① 改前通读 `limits_test.go` 确保有覆盖；② 改后必跑 `go test -race ./internal/platform/httpx/...`；③ 若发现异常，立即回滚到 `var valid []time.Time` 方案。 |
| R-03 | 在 `limits_test.go` 中保留原有 IPv4 用例并追加 IPv6 用例；若 CI 失败，回滚到字符串处理但修复 IPv6 专用分支。 |
| R-04 | P2-1b 仅对生产模式 (`cfg.Runtime.Env == "production"`) 生效；开发/测试环境保持静默回退。若生产启动失败，工程师可立即切回旧 `getEnvInt`。 |
| R-05 | ① `wg.Add(1)` 必须紧接在 `go func()` 之前（同一线程）；② closer 中 `wg.Wait()` 必须带 `time.After` 超时；③ 改后运行 `go test ./internal/app/...` 并手动发送 SIGTERM 验证无死锁。 |
| R-06 | 全量代码任务完成后统一执行 `go test -race ./internal/... -count=1 -p 1`；任何 race 报告阻塞合并。 |

---

## 5. QA 交接与实施约束

### 5.1 编码后漂移检查点（QA 可验证）

| 检查点 ID | 验证命令 / 步骤 | 通过标准 |
|---|---|---|
| CP-01 | `cd /home/long/project/ai-customer-service && git status --short` | 零 modified / 零 untracked（或仅有本次计划外的新 review 报告） |
| CP-02 | `make test` | 等价于 `go test ./... -count=1 -p 1`，零失败（postgres/e2e/integration skip 属于预期行为） |
| CP-03 | `go test -race ./internal/... -count=1 -p 1` | 24/24 pass，零 DATA RACE |
| CP-04 | `go test ./internal/http/handlers/... -coverprofile=/tmp/handlers.out && go tool cover -func=/tmp/handlers.out \| grep ticket_handler.go` | `List` 函数覆盖率 > 0% |
| CP-05 | `go test ./internal/platformadapter/... -coverprofile=/tmp/pa.out && go tool cover -func=/tmp/pa.out \| grep newapi_adapter.go` | `BuildIngressAck` 覆盖率 > 0% |
| CP-06 | `go test ./internal/platform/httpx/...` | 全部通过，包括新增 IPv6 用例 |
| CP-07 | `ls docs/SECURITY_BOUNDARY.md && head -n 20 docs/SECURITY_BOUNDARY.md` | 文件存在，且首段包含 "RequireRoles" 和 "upstream gateway" 关键词 |
| CP-08 | `grep -n "sync.WaitGroup\|workerWg" internal/app/app.go` | 至少出现 `workerWg.Add(1)`、`defer workerWg.Done()`、`workerWg.Wait()` 三处 |
| CP-09 | `grep -n "MaxIdleConns" internal/app/app.go` | 出现 `MaxIdleConns` 与 `MaxIdleConnsPerHost` |
| CP-10 | `go vet ./...` | 零警告 |

### 5.2 必查真实调用链路

| 链路 | 验证方式 |
|---|---|
| **RateLimiter 核心路径** | `TestRateLimiter_WithRateLimit` 必须实际触发 `Allow` 并通过；QA 可单步确认 `rateLimitKey` 返回预期值。 |
| **Authz 信任边界** | QA 手动阅读 `docs/SECURITY_BOUNDARY.md` 与 `authz.go` 注释，确认两者口径一致。 |
| **Worker Graceful Shutdown** | QA 本地启动服务后发送 SIGTERM（`kill -TERM <pid>`），观察日志确认 worker 在 5s 内完成退出，无 `shutdown timeout` error。 |
| **Config 严格模式** | QA 设置 `AI_CS_RUNTIME_ENV=production` + `AI_CS_WEBHOOK_MAX_SKEW_SECONDS=not_a_number`，启动服务应报错并退出。 |

---

## 6. Engineer 实施说明

### 6.1 文件级落点

| 目标文件 | 落点行号 | 改动性质 |
|---|---|---|
| `Makefile` | 第 2 行 | 替换 |
| `internal/http/handlers/ticket_handler_test.go` | 文件末尾 | 追加 2 个 Test 函数 |
| `internal/platformadapter/newapi_adapter_test.go` | 新建 | 2 个 Test 函数 |
| `docs/SECURITY_BOUNDARY.md` | 新建 | Markdown 文档 |
| `internal/http/middleware/authz.go` | 第 42 行上方 | 添加注释块 |
| `internal/platform/httpx/limits.go` | 第 67-73 行 | 替换为原地过滤 |
| `internal/platform/httpx/limits.go` | 第 98-124 行 | 替换 `rateLimitKey` + 删除 `lastIndexByte` |
| `internal/platform/httpx/limits_test.go` | 文件末尾 | 追加 IPv6 Test 函数 |
| `internal/config/config.go` | 第 201-255 行之后 | 追加 `mustGetEnvInt` / `mustGetEnvBool` |
| `internal/config/config.go` | 第 66-148 行 | 条件性替换部分 `getEnvInt` 调用 |
| `internal/app/app.go` | 第 158-188 行 | 重构 startWorker 闭包 |
| `internal/app/app.go` | 第 172 行 | 替换 http.Client 创建 |
| `test/e2e/*_test.go` | 首个 Test 或 TestMain | 追加 skip 逻辑 |
| `test/integration/*_test.go` | 首个 Test 或 TestMain | 追加 skip 逻辑 |

### 6.2 最小验证项（Engineer 每完成一个 P1/P2 任务必须自测）

1. `go build ./...` 零错误。
2. `go vet ./...` 零警告。
3. 涉及改动的包：`go test -race ./<changed_pkg>/...` 通过。
4. 若修改了 exported 函数签名，确认调用方编译通过。

---

## 7. 阶段门控结论

### 7.1 当前状态

- **设计完整性**：本方案已覆盖 review 报告全部 P0/P1/P2 项，任务粒度 <= 5 分钟，文件路径与函数名已精确锁定。
- **风险可控性**：P1-4/P2-4 有较高风险，但已设计明确的降级策略（超时兜底 + race 检测 + 回滚路径）。
- **与旧 remediation board 兼容性**：
  - 本次 P1-2（newapi_adapter 测试）与旧 board 的 I-01（newapi 假接通）正交：本次仅补测试覆盖，不改变 newapi 仍为 501 占位的事实。
  - 本次不涉及旧 board 的 D-01/D-02/D-03/D-04（平台能力矩阵、callback_target 契约、outbox 并发 claim），这些仍按旧 board 排期执行。

### 7.2 结论

**✅ 可进入 Engineer 实现。**

前提条件：Engineer 必须严格按照本方案第 3 章的任务拆解顺序执行，禁止自行扩大范围（如顺带重写 newapi adapter 或引入 pgx）。

---

## 8. 下游执行约束摘要

### 8.1 Engineer 禁止偏离

- **禁止**在修复 P1-2 时顺带实现 newapi 完整 ingress 逻辑（仍保持 501 占位）。
- **禁止**改动 `lib/pq` 为 `pgx`。
- **禁止**修改任何不属于本方案列出的文件，除非发现编译阻断。
- **禁止**跳过 `go test -race` 自测。
- P0-1 必须分 2 批 commit（docs / code），禁止一次性混提交。

### 8.2 QA 必查链路

- `make test` 行为与 `-p 1` 一致性。
- `go test -race ./internal/... -count=1 -p 1` 零 race。
- ticket_handler.List 与 newapi_adapter.BuildIngressAck 的覆盖率从 0% 提升到 > 0%。
- `docs/SECURITY_BOUNDARY.md` 与 `authz.go` 注释口径一致。
- IPv6 rate limit key 的正确性（通过单元测试）。
- Worker graceful shutdown 的 SIGTERM 手动验证。

### 8.3 XL（TechLead / 负责人）必补门控

- P0-1 commit 后复核 `git log --oneline -5` 与 `git status`，确认 worktree 已清。
- P0-1d 打 tag 后，XL 必须亲自确认 tag 存在：`git describe --tags`。
- 全量任务完成后，XL 执行一次 `go test ./... -count=1 -p 1` 并留存输出截图/文本作为最终证据。
- 旧 remediation board 中与本方案无冲突的项（D-01 ~ I-05）继续保留，不得因本次方案而关闭或删除。

---

## 自检清单（返回时显式列出打勾状态）

- [x] 架构设计覆盖 review 报告所有 P0/P1 项
- [x] 每个任务 < 5分钟，有明确文件路径
- [x] 风险评估完整
- [x] 降级策略已设计
- [x] 实施漂移检测点已定义
- [x] 已明确标记是否可进入 Engineer 实现
- [x] 已给出 Engineer / QA / XL 的下游执行约束摘要
