diff --git a/.gitnexusignore b/.gitnexusignore new file mode 100644 index 0000000..a69232e --- /dev/null +++ b/.gitnexusignore @@ -0,0 +1 @@ +llm-gateway-competitors/ diff --git a/docs/GRAY_DASHBOARD_MINIMUM.md b/docs/GRAY_DASHBOARD_MINIMUM.md new file mode 100644 index 0000000..0adf24a --- /dev/null +++ b/docs/GRAY_DASHBOARD_MINIMUM.md @@ -0,0 +1,90 @@ +# 灰度阶段最小 Dashboard + +> 状态:已定义 +> 用途:灰度 5% / 20% / 50% / 100% 放量时,值班工程师和 TechLead 必须看的单页观察面 + +--- + +## 1. 必须展示的 8 个指标 + +1. `Webhook 5xx 比例` +2. `Webhook reject 数` +3. `Ticket 创建量` +4. `Handoff 比率` +5. `Audit 写入失败数` +6. `Readiness down 次数` +7. `PostgreSQL 连接异常` +8. `单实例重启次数` + +--- + +## 2. 推荐布局 + +### 第一行:放量门禁 + +- Webhook 5xx 比例 +- Audit 写入失败数 +- PostgreSQL 连接异常 +- Readiness down 次数 + +这些指标用于判断:**是否必须停止放量或立即回滚** + +### 第二行:业务链路健康 + +- Ticket 创建量 +- Handoff 比率 +- Webhook reject 数 + +这些指标用于判断:**是否出现隐性降级或业务异常漂移** + +### 第三行:实例稳定性 + +- 单实例重启次数 +- 当前灰度比例 +- 当前版本 +- 最近一次 Gate B / 回滚演练记录链接 + +--- + +## 3. 颜色规则 + +| 指标 | 绿色 | 黄色 | 红色 | +|------|------|------|------| +| Webhook 5xx | `<= 0.5%` | `0.5% ~ 1%` | `> 1%` | +| Webhook reject 数 | 在预期基线内 | 高于基线但 <20% | `>= 20%` | +| Ticket 创建量 | 与 handoff 基本匹配 | 明显下降 | handoff 存在但 ticket 持续为 0 | +| Handoff 比率 | `<= 15%` 或接近基线 | `15% ~ 25%` | `> 25%` 或高于基线 `2x` | +| Audit 写入失败数 | `0` | 短时抖动 | `> 0` 持续 5 分钟 | +| Readiness down 次数 | `0` | 偶发 | 连续 3 次 | +| PostgreSQL 连接异常 | `0` | 短时抖动 | 持续异常 | +| 单实例重启次数 | `0` | `1~2 / 10min` | `>2 / 10min` | + +--- + +## 4. Dashboard 直接用途 + +值班期间,只允许做三类决策: + +1. **继续放量** + 前提:所有门禁指标为绿色,且观察窗口已满足 + +2. **冻结当前档位** + 前提:出现黄色趋势,但未触发红色门禁 + +3. **立即回滚** + 前提:任一核心门禁指标变红 + +--- + +## 5. 当前状态 + +这份 dashboard 文档已经定义完成,但真实共享预生产/灰度环境还需要补: + +- 指标来源接线 +- 展示面板 +- 告警路由 + +在这些接线完成前,只能说: + +> **Dashboard 设计已完成,运行时观察面尚未真正上线。** + diff --git a/docs/GRAY_LAUNCH_CHECKLIST.md b/docs/GRAY_LAUNCH_CHECKLIST.md new file mode 100644 index 0000000..3033967 --- /dev/null +++ b/docs/GRAY_LAUNCH_CHECKLIST.md @@ -0,0 +1,122 @@ +# AI-Customer-Service 灰度放行清单 + +> 版本:v1.0 +> 状态:灰度放行总门禁 +> 用途:作为一页式放行清单,统一判断“是否允许进入灰度、是否允许继续放量、是否必须回滚” + +--- + +## 1. 使用规则 + +- 任一 `阻断项` 未通过:**不得进入灰度** +- 任一 `回滚项` 触发:**立即回滚** +- 任一 `观察项` 异常:**冻结当前档位,不继续放量** +- 本清单的结论优先级高于口头判断 + +--- + +## 2. 代码级门禁 + +- [x] `go test ./... -count=1` 通过 +- [x] `go test -race ./...` 通过 +- [x] `go vet ./...` 通过 +- [x] production 禁止 memory fallback +- [x] readiness 语义已与真实依赖对齐 +- [x] 工单闭环语义已收口 +- [x] 后台接口最小鉴权已启用 + +说明: +- 当前这些门禁已通过,属于**进入灰度准备的必要非充分条件** + +--- + +## 3. Gate B 预生产门禁 + +- [x] `scripts/verify_preprod_gate_b.sh` 已建立 +- [x] 本地/容器化 Gate B 预演通过 +- [x] 真实 PostgreSQL migration 成功 +- [x] signed webhook 联调通过 +- [x] ticket / audit / dedup 入库可验证 +- [x] `live` / `ready` 探针符合预期 +- [x] 有验证记录:`docs/PREPROD_VERIFICATION_RECORD.md` +- [ ] 真实共享预生产环境已复跑同一脚本并留痕 + +阻断结论: +- **最后一项未完成前,不得宣称“真实预生产门禁已通过”** + +--- + +## 4. Gate C 灰度门禁 + +- [x] 最小监控指标已定义 +- [x] 告警阈值已定义 +- [x] 灰度放量节奏已定义 +- [x] 回滚触发条件已定义 +- [x] 最小 dashboard 已定义 +- [x] `scripts/verify_gate_c_rollback.sh` 已建立 +- [x] 本地/容器化回滚演练已通过 +- [x] 有验证记录:`docs/ROLLBACK_DRILL_RECORD.md` +- [ ] 真实共享预生产/灰度环境监控接线完成 +- [ ] 真实共享预生产/灰度环境回滚演练完成并留痕 +- [ ] 值班通知链路已确认 + +阻断结论: +- **最后三项未完成前,不得进入真实灰度放量** + +--- + +## 5. 灰度放量节奏 + +| 阶段 | 流量比例 | 最短观察时间 | 进入条件 | +|------|----------|--------------|----------| +| Stage 1 | 5% | 30 分钟 | Gate B 已通过,核心门禁全绿 | +| Stage 2 | 20% | 2 小时 | Stage 1 稳定,5xx / audit / DB 指标正常 | +| Stage 3 | 50% | 半天 | Stage 2 稳定,handoff / ticket 指标正常 | +| Stage 4 | 100% | 次日 | Stage 3 稳定跨工作日,无新增 P0/P1 | + +--- + +## 6. 继续放量判定 + +进入下一档前,必须同时满足: + +- [ ] `webhook 5xx <= 0.5%` +- [ ] `webhook reject` 无异常升高 +- [ ] `audit 写入失败数 = 0` +- [ ] `postgres 连接异常 = 0` +- [ ] `readiness down` 未持续发生 +- [ ] `单实例重启次数 <= 2 / 10 分钟` +- [ ] `handoff 比率 <= 25%` 或未高于基线 `2x` +- [ ] `ticket 创建量` 与人工承载能力匹配 + +任一不满足: +- **冻结当前档位** + +--- + +## 7. 立即回滚判定 + +满足任一项,立即回滚: + +- [ ] `webhook 5xx > 5%` 持续 5 分钟 +- [ ] PostgreSQL 异常导致 `ready` 持续失败 +- [ ] `audit 写入失败数 > 0` 持续 5 分钟 +- [ ] ticket 创建链路断裂 +- [ ] 全量 readiness down +- [ ] 实例反复重启且影响服务 + +--- + +## 8. 当前总判定 + +当前状态: + +- **代码级门禁:通过** +- **本地/容器化 Gate B:通过** +- **真实共享预生产 Gate B:未通过** +- **本地/容器化 Gate C 回滚演练:通过** +- **Gate C 灰度门禁:未通过** + +因此当前唯一允许的结论是: + +> **可以继续做共享预生产验证和灰度准备,但还不能进入真实灰度放量。** diff --git a/docs/MONITORING_ALERTING.md b/docs/MONITORING_ALERTING.md index 2840a90..261838c 100644 --- a/docs/MONITORING_ALERTING.md +++ b/docs/MONITORING_ALERTING.md @@ -1,126 +1,133 @@ # DO-P1-1:最小监控与告警闭环 -> 状态:✅ 已交付 -> 负责人:DevOps(宰相代填) -> 基准:P0 完成 Gate B 预生产验证 -> 日期:2026-05-04 +> 状态:✅ 已定义,待在真实共享预生产/灰度环境接入 +> 负责人:TechLead / DevOps +> 基准:Gate B 已完成本地/容器化预演,Gate C 前必须落地最小观察面 --- -## 一、监控覆盖矩阵 +## 1. 目标 -| 告警项 | 监控端点 | 阈值/判定条件 | 动作 | -|--------|----------|---------------|------| -| **5xx 错误激增** | `GET /actuator/health` 中 status≠UP,或日志 level=ERROR | 5xx 占比 > 5% 持续 1min | 触发 PagerDuty / 日志告警 | -| **签名拒绝** | 业务日志中 `CS_AUTH_4031/4033/4034` code 出现 | 10 次 / 5min 窗口 | 记录安全事件,暂不阻塞 | -| **Handoff 异常** | `GET /api/v1/customer-service/webhook` 返回 `handoff=true` 率 | handoff=true 突增 3x 历史均值 | 记录人工介入事件 | -| **Ticket 未创建** | refund intent 触发后 10s 内 cs_tickets 无对应记录 | refund intent 但 ticket_id="" | 告警并记录异常 | -| **Audit 未写入** | ticket 创建后 5s 内 cs_audit_logs 无 `object_type=ticket` 记录 | audit_count 增量=0 | 告警 DB 写入问题 | -| **PostgreSQL 不可用** | `GET /ready` 中 postgres check ≠UP | postgres status= DOWN | 立即告警,影响 ready | -| **服务未就绪** | `GET /ready` 返回 non-200 或超时 3s | ready != 200 | 服务 restart 触发 | -| **服务挂了** | `GET /live` 返回 non-200 或超时 3s | live != 200 | K8s/Supervisor restart | +生产一期灰度阶段不追求“全量可观测平台一次到位”,只要求有一套**最小、可执行、能支持放量/回滚决策**的监控闭环。 + +本轮最小监控集只覆盖 8 个指标: + +1. `webhook 5xx` +2. `webhook reject 数` +3. `ticket 创建量` +4. `handoff 比率` +5. `audit 写入失败数` +6. `readiness down 次数` +7. `postgres 连接异常` +8. `单实例重启次数` --- -## 二、监控接入方式 +## 2. 最小指标定义 -### 2.1 Kubernetes Probe(存活 + 就绪) - -```yaml -livenessProbe: - httpGet: - path: /live - port: 8080 - initialDelaySeconds: 5 - periodSeconds: 10 - -readinessProbe: - httpGet: - path: /ready - port: 8080 - initialDelaySeconds: 5 - periodSeconds: 5 - failureThreshold: 3 -``` - -### 2.2 Prometheus 指标暴露(可选,v1.1+) - -``` -# 暴露端点 -GET /metrics - -# 关键指标 -ai_cs_webhook_requests_total{status="success|reject|5xx"} -ai_cs_tickets_created_total -ai_cs_audit_logs_written_total -ai_cs_handoff_total -ai_cs_postgres_errors_total -ai_cs_session_active_gauge -``` - -### 2.3 日志聚合(ELK/Loki) - -关键日志字段抓取: -``` -level=ERROR AND msg="webhook request rejected" -level=ERROR AND msg="audit log write failed" -level=WARN AND msg="handoff ticket missing" -``` +| 指标 | 定义 | 最低数据来源 | 说明 | +|------|------|--------------|------| +| Webhook 5xx | `POST /api/v1/customer-service/webhook*` 返回 5xx 的比例 | API 网关/Ingress 访问日志或应用日志 | 灰度放量的首要阻断指标 | +| Webhook reject 数 | 因签名、时间戳、非法 body 被拒绝的请求数 | `CS_AUTH_4031/4032/4033/4034`、`CS_REQ_*` 日志或审计 | 区分“攻击/误配置”和“服务不可用” | +| Ticket 创建量 | 每 5 分钟新建工单数 | `cs_tickets` 表或应用埋点 | 与 handoff 比率配合判断主链健康 | +| Handoff 比率 | `handoff=true` 会话数 / 总 webhook 请求数 | webhook 结果日志、审计或 DB | 反映机器人有效性与故障降级情况 | +| Audit 写入失败数 | audit 写入失败事件数 | 应用 ERROR 日志 | 任一增长都需要关注 | +| Readiness down 次数 | `ready` 探针失败次数 | K8s probe / LB 健康检查 / 外部探测 | 用于摘流与自动回滚判断 | +| PostgreSQL 连接异常 | DB ping/query error 次数 | `ready` 检查、应用 ERROR、连接池错误 | Phase 1 的核心依赖告警 | +| 单实例重启次数 | 单个实例在窗口期内重启次数 | K8s event / systemd / 容器平台 | 判断二进制稳定性和资源问题 | --- -## 三、告警阈值配置(Prometheus AlertManager 风格) +## 3. 告警阈值与动作 -```yaml -groups: - - name: ai-customer-service - rules: - - alert: HighErrorRate - expr: rate(ai_cs_webhook_requests_total{status="5xx"}[1m]) / rate(ai_cs_webhook_requests_total[1m]) > 0.05 - for: 1m - labels: - severity: critical - annotations: - summary: "AI-CS 5xx 错误率超过 5%" +### 3.1 必须可执行的阈值 - - alert: PostgresDown - expr: ai_cs_postgres_errors_total > 0 - for: 30s - labels: - severity: critical +| 指标 | 阈值 | 持续时间 | 级别 | 动作 | +|------|------|----------|------|------| +| Webhook 5xx | `> 1%` | 5 分钟 | P1 | 立即停止继续放量,触发回滚评估 | +| Webhook 5xx | `> 5%` | 5 分钟 | P0 | 立即回滚当前灰度版本 | +| Webhook reject 数 | `> 5%` 且以 `4031/4034` 为主 | 10 分钟 | P2 | 检查上游签名配置,不自动回滚 | +| Webhook reject 数 | `> 20%` | 10 分钟 | P1 | 暂停放量,升级为渠道接入故障 | +| Ticket 创建量 | 灰度期内 handoff 明显存在,但连续 10 分钟 `ticket 创建量 = 0` | 10 分钟 | P1 | 判定工单主链异常,停止放量 | +| Handoff 比率 | `> 25%` 或高于过去 24h 基线 `2x` | 30 分钟 | P2 | 检查意图识别/依赖故障/降级路径 | +| Audit 写入失败数 | `> 0` | 5 分钟 | P1 | 停止放量,优先排查审计链路 | +| Readiness down 次数 | 单实例连续 3 次失败 | 3 个探针周期 | P1 | 从灰度池摘流量 | +| PostgreSQL 连接异常 | `> 0` 且影响 ready | 1 分钟 | P0 | 立即停止放量,必要时回滚 | +| 单实例重启次数 | 单实例 `> 2` 次 | 10 分钟 | P2 | 冻结当前比例,排查资源/崩溃问题 | - - alert: TicketCreationDrop - expr: rate(ai_cs_tickets_created_total[5m]) == 0 AND rate(ai_cs_webhook_requests_total[5m]) > 0.1 - for: 2m - labels: - severity: warning +### 3.2 放量前置条件 - - alert: AuditLogWriteFailure - expr: increase(ai_cs_audit_logs_written_total[5m]) == 0 AND increase(ai_cs_tickets_created_total[5m]) > 0 - for: 1m - labels: - severity: critical -``` +进入下一个灰度档位前,必须同时满足: + +1. 最近一个观察窗口内 `webhook 5xx <= 0.5%` +2. `audit 写入失败数 = 0` +3. `postgres 连接异常 = 0` +4. 没有实例因 `readiness down` 被持续摘流 +5. `ticket 创建量` 与 `handoff 比率` 没有出现异常偏移 --- -## 四、最小化监控检查清单(部署时必检) +## 4. 指标落地方式 -- [ ] **就绪探针**:`curl http://localhost:8080/ready` → 200 + `postgres:UP` -- [ ] **存活探针**:`curl http://localhost:8080/live` → 200 -- [ ] **日志告警**:ERROR level 日志出现时触发监控告警 -- [ ] **PG 连接**:每分钟 check `/ready` 中 postgres status -- [ ] **Handoff 率**:每 5 分钟比对 `webhook_count` vs `handoff_count` -- [ ] **Ticket 漏单**:refund intent 触发后 10s 内查 DB 确认 ticket 存在 -- [ ] **Audit 漏写**:ticket 创建后 5s 内查 `cs_audit_logs` 确认记录 +当前仓库还没有 Prometheus 指标端点,因此本轮按“两层实现”定义: + +### 4.1 Gate C 前最低可接受方案 + +- Ingress / API Gateway access log 统计: + - webhook 请求总量 + - webhook 5xx +- 应用日志统计: + - `CS_AUTH_403*` + - `audit write failed` + - `webhook process failed` + - `postgres` 相关错误 +- 数据库 SQL 统计: + - `cs_tickets` 新增量 + - `cs_audit_logs` 指定 action 数量 + - `cs_message_dedup` 去重记录数 +- 探针统计: + - `live` + - `ready` + +### 4.2 推荐目标方案 + +后续在不改变本轮门禁的前提下,可以升级为: + +- Prometheus metrics +- Alertmanager 路由 +- Grafana 灰度大盘 +- Loki / ELK 日志聚合 --- -## 五、故障自愈策略 +## 5. 最小告警路由 + +| 事件 | 通知对象 | 方式 | 时限 | +|------|----------|------|------| +| P0:DB 异常 / 5xx > 5% | 值班工程师 + TechLead | 电话 + 飞书 | 5 分钟内响应 | +| P1:5xx > 1% / audit 失败 / readiness 异常 | 值班工程师 | 飞书 + 工单 | 15 分钟内响应 | +| P2:handoff 异常升高 / reject 异常 | 值班工程师 + 产品/运营 | 飞书 | 30 分钟内响应 | + +--- + +## 6. 当前落地状态 + +| 项目 | 当前状态 | 结论 | +|------|----------|------| +| 指标定义 | 已完成 | ✅ | +| 告警阈值 | 已完成 | ✅ | +| Grafana/Prometheus 接入 | 未完成 | ⚠️ Gate C 前需至少完成最低可接受方案 | +| 真共享预生产环境监控联调 | 未完成 | ⚠️ | +| 回滚联动门禁 | 已定义,未演练 | ⚠️ | + +--- + +## 7. 与灰度放量的关系 + +这份文档不是泛化监控说明,而是**灰度放量门禁文档**。 +任何放量决策都必须引用: + +- [GRAY_DASHBOARD_MINIMUM.md](/home/long/project/立交桥/projects/ai-customer-service/docs/GRAY_DASHBOARD_MINIMUM.md) +- [SERVICE_SLA.md](/home/long/project/立交桥/projects/ai-customer-service/prd/SERVICE_SLA.md) +- [GRAY_RELEASE_ROLLBACK_RUNBOOK.md](/home/long/project/立交桥/projects/ai-customer-service/prd/GRAY_RELEASE_ROLLBACK_RUNBOOK.md) -| 故障 | 自动处理 | 人工介入 | -|------|----------|----------| -| `/ready` 失败 3 次 | K8s 重启 Pod | 如果 5min 内仍失败,发告警 | -| PG 连接断开 | 服务 graceful shutdown,等待 PG 恢复后自动重连 | 若 >10min 无自动恢复,发告警 | -| OOM / 内存泄漏 | OOMKiller 杀掉后,K8s 重启 | 分析 heap profile | -| 磁盘满(审计日志) | — | 立即告警,人工清理 | \ No newline at end of file diff --git a/docs/P0_P1_P2_RECTIFICATION_EXECUTION_BOARD.md b/docs/P0_P1_P2_RECTIFICATION_EXECUTION_BOARD.md index 5393aba..2998c8d 100644 --- a/docs/P0_P1_P2_RECTIFICATION_EXECUTION_BOARD.md +++ b/docs/P0_P1_P2_RECTIFICATION_EXECUTION_BOARD.md @@ -2,7 +2,7 @@ > 来源:`docs/RECTIFICATION_REVIEW_REPORT_V2.md` > 用途:按角色推动整改执行、跟踪状态、做阶段门禁验收 -> 当前总状态:**第5件事已完成;代码侧 P0 技术阻断已闭环,项目可进入预生产整改与联调阶段,但仍禁止按“生产可直接上线”口径放行** +> 当前总状态:**Task 1~7 已推进至“灰度门禁已定义”阶段;代码级、本地/容器化 Gate B、本地/容器化 Gate C 回滚演练已通过,但真实共享预生产 Gate B 与真实灰度环境演练仍未闭环,禁止按“可直接灰度上线”口径放行** --- @@ -40,12 +40,12 @@ |---|---|---|---|---|---|---|---| | XL-P1-1 | P1 | 统一 PM/TechLead/QA/DevOps 交付模板 | 小龙 | 角色交付模板 | 每份角色输出均含结论、证据、阻塞、下一阶段条件 | XL-P0-1 | 未开始 | | XL-P1-2 | P1 | 增加关键修复后的实施漂移复核点 | 小龙 | 复核流程 | 每次关键修复后都有测试复跑、配置复核、状态更新 | XL-P0-2 | 已完成 | -| PM-P1-1 | P1 | 补上线运营观察指标与失败判定线 | PM | 文档更新 | 含 handoff、ticket、audit、ready、重启后数据等观察项 | PM-P0-1 | 未开始 | +| PM-P1-1 | P1 | 补上线运营观察指标与失败判定线 | PM | 文档更新 | 含 handoff、ticket、audit、ready、重启后数据等观察项 | PM-P0-1 | 已完成 | | PM-P1-2 | P1 | 统一环境变量文档契约 | PM | 文档更新 | 仅使用代码真实变量名,不再写泛化别名 | TL-P0-3 | 已完成 | -| TL-P1-1 | P1 | 补 ticket/session 后台接口鉴权设计 | TechLead | 设计文档 | actor 来源不可伪造,接口 auth 模式明确 | TL-P0-3 | 未开始 | +| TL-P1-1 | P1 | 补 ticket/session 后台接口鉴权设计 | TechLead | 设计文档 | actor 来源不可伪造,接口 auth 模式明确 | TL-P0-3 | 已完成 | | TL-P1-2 | P1 | 补多实例与恢复场景验证设计 | TechLead | 设计文档 / 测试计划 | 覆盖 dedup、多实例、重启一致性、migration 幂等 | TL-P0-2 | 未开始 | | QA-P1-1 | P1 | 建立文档漂移检测检查项 | QA | QA 模板/报告更新 | 每次审查都校对代码 vs 文档 vs 测试状态 | QA-P0-1 | 已完成 | -| QA-P1-2 | P1 | 增加真实环境前置门禁 | QA | 预生产验证记录 | 启动、ready、migration、webhook、入库验证完成 | DO-P0-1, DO-P0-2 | 未开始 | +| QA-P1-2 | P1 | 增加真实环境前置门禁 | QA | 预生产验证记录 | 启动、ready、migration、webhook、入库验证完成 | DO-P0-1, DO-P0-2 | ✅ 本地容器化通过(30+25 PASS) | | DO-P1-1 | P1 | 补最小监控与告警闭环 | DevOps | 告警配置/监控清单 | 覆盖 5xx、reject、handoff、ticket、audit、DB、ready | DO-P0-1 | ✅ 已完成 | | DO-P1-2 | P1 | 补运行与回滚 runbook | DevOps | runbook 文档 | 覆盖启动失败、migration 失败、DB 不可用、auth 联调失败 | DO-P0-1 | ✅ 已完成 | @@ -59,7 +59,7 @@ | TL-P2-2 | P2 | 提升 store/app 关键层测试覆盖 | TechLead | 测试与覆盖率报告 | store/app 关键层覆盖明显提升并覆盖异常场景 | TL-P1-2 | 进行中 | | QA-P2-1 | P2 | 建立长期质量回归基线 | QA | 回归清单 | 关键链路、关键控制点形成常规回归项 | QA-P1-2 | 未开始 | | PM-P2-1 | P2 | 完善数据保留、审计、运营复盘口径 | PM | 产品/运营文档 | 有保留策略、失败判定、复盘节奏 | PM-P1-1 | 未开始 | -| DO-P2-1 | P2 | 细化容量与可观测性建设 | DevOps | 容量规划与监控扩展文档 | 有容量阈值、趋势指标、扩容策略 | DO-P1-1 | 未开始 | +| DO-P2-1 | P2 | 细化容量与可观测性建设 | DevOps | 容量规划与监控扩展文档 | 有容量阈值、趋势指标、扩容策略 | DO-P1-1 | 进行中 | | XL-P2-1 | P2 | 将整改执行纳入长期阶段复盘机制 | 小龙 | 复盘模板 | 每个阶段都有事实校准、漂移回收、责任追踪 | XL-P1-2 | 未开始 | --- @@ -80,7 +80,7 @@ |---|---|---|---| | PM-P0-1 | 修正文档上线口径 | P0 | 已完成 | | PM-P0-2 | 明确 memory/dev/prod 约束 | P0 | 已完成 | -| PM-P1-1 | 补运营观察指标与失败线 | P1 | 未开始 | +| PM-P1-1 | 补运营观察指标与失败线 | P1 | 已完成 | | PM-P1-2 | 统一环境变量文档契约 | P1 | 已完成 | | PM-P2-1 | 完善审计/保留/复盘口径 | P2 | 未开始 | @@ -90,7 +90,7 @@ | TL-P0-1 | 禁止 prod fallback 到 memory | P0 | 已完成 | | TL-P0-2 | 收紧 readiness | P0 | 已完成 | | TL-P0-3 | 配置契约基线 | P0 | 已完成 | -| TL-P1-1 | 后台接口鉴权设计 | P1 | 未开始 | +| TL-P1-1 | 后台接口鉴权设计 | P1 | 已完成 | | TL-P1-2 | 多实例/恢复验证设计 | P1 | 未开始 | | TL-P2-1 | 完整威胁建模 | P2 | 未开始 | | TL-P2-2 | 提升关键层覆盖率 | P2 | 进行中 | @@ -101,7 +101,7 @@ | QA-P0-1 | 重做 QA 门禁文档 | P0 | 已完成 | | QA-P0-2 | 将核心风险列为 Critical | P0 | 已完成 | | QA-P1-1 | 增加文档漂移检测 | P1 | 已完成 | -| QA-P1-2 | 增加真实环境前置门禁 | P1 | 未开始 | +| QA-P1-2 | 增加真实环境前置门禁 | P1 | ✅ 本地容器化通过(30+25 PASS) | | QA-P2-1 | 建立长期回归基线 | P2 | 未开始 | ### 4.5 DevOps @@ -111,7 +111,7 @@ | DO-P0-2 | 关键配置 fail-fast 部署标准 | P0 | ✅ 已完成 | | DO-P1-1 | 最小监控与告警闭环 | P1 | ✅ 已完成 | | DO-P1-2 | 运行与回滚 runbook | P1 | ✅ 已完成 | -| DO-P2-1 | 容量与可观测性细化 | P2 | 未开始 | +| DO-P2-1 | 容量与可观测性细化 | P2 | 进行中 | --- @@ -131,12 +131,17 @@ - [x] audit / ticket 入库成功(实测:webhook → session → handoff → ticket → audit 全链路) - [x] ready/live 符合预期(/actuator/health/ready → 200,postgres checker → UP) - [x] 最小监控已接通(✅ `docs/MONITORING_ALERTING.md` 已交付,覆盖 8 项监控 + Prometheus 告警配置) +- [ ] 共享预生产环境已复跑 Gate B 并留痕 ### Gate C:生产灰度通过 +- [x] 灰度指标、阈值、回滚条件清晰 +- [x] 一页式灰度放行清单已建立 +- [x] 本地/容器化回滚演练已通过 +- [ ] 共享预生产/灰度环境监控接线完成 - [ ] 5% 灰度稳定 - [ ] handoff / ticket / audit 指标正常 - [ ] 无异常 5xx / reject 激增 -- [ ] 回滚演练通过 +- [ ] 真实共享预生产/灰度环境回滚演练通过 --- @@ -154,6 +159,12 @@ - `go vet ./...` 3. 验证结果: - 上述命令本轮均已通过 +4. 灰度门禁文件: + - `docs/GRAY_LAUNCH_CHECKLIST.md` + - `docs/MONITORING_ALERTING.md` + - `docs/GRAY_DASHBOARD_MINIMUM.md` + - `prd/GRAY_RELEASE_ROLLBACK_RUNBOOK.md` + - `docs/ROLLBACK_DRILL_RECORD.md` --- @@ -163,3 +174,15 @@ 2. 每完成一项,必须更新状态和证据 3. QA 不能在 P0 未清零前给出生产放行结论 4. 小龙负责最终事实校准,不接受“口头完成” + +--- + +## 8. 当前最小结论 + +当前可以接受的唯一发布口径: + +1. **代码级:通过** +2. **本地/容器化 Gate B:通过** +3. **共享预生产 Gate B:进行中** +4. **本地/容器化 Gate C 回滚演练:通过** +5. **Gate C 灰度放量:未通过** diff --git a/docs/PREPROD_VERIFICATION_RECORD.md b/docs/PREPROD_VERIFICATION_RECORD.md new file mode 100644 index 0000000..409d758 --- /dev/null +++ b/docs/PREPROD_VERIFICATION_RECORD.md @@ -0,0 +1,107 @@ +# PREPROD_VERIFICATION_RECORD.md + +> 状态:已建立 +> 最近一次更新:2026-05-04 +> 目标:沉淀 Gate B 预生产验证的可复跑证据,而不是口头结论 + +--- + +## 1. 验证范围 + +本记录对应 Task 5 的 Gate B 验证脚本: + +- [scripts/verify_preprod_gate_b.sh](/home/long/project/立交桥/projects/ai-customer-service/scripts/verify_preprod_gate_b.sh) + +脚本覆盖的检查项: + +1. 环境变量完整性与 production 约束 +2. PostgreSQL 连通性 +3. migration 账本与基线版本检查 +4. 当前源码构建与服务启动 +5. `/actuator/health/live` +6. `/actuator/health/ready` +7. 带签名 webhook 请求 +8. dedup 入库与重复消息抑制 +9. ticket 创建 / 分配 / 解决 / 关闭 +10. audit 入库验证 + +--- + +## 2. 最近一次实测记录 + +- 时间:2026-05-04 18:50 CST +- 环境:本机容器化/本地 PostgreSQL 联调环境 +- 基线提交:`65e48bc` +- 说明:本次验证基于当前工作区源码重新编译执行,不依赖仓库内旧二进制 +- 运行 ID:`gateb-20260504185024` +- 产物目录:`/tmp/ai-customer-service-preprod-gate-b/gateb-20260504185024` + +执行命令: + +```bash +AI_CS_RUNTIME_ENV=production \ +AI_CS_ADDR=127.0.0.1:18080 \ +AI_CS_POSTGRES_ENABLED=true \ +AI_CS_POSTGRES_DSN='host=localhost port=5434 user=ai_cs password=ai_cs_secret dbname=ai_customer_service sslmode=disable' \ +AI_CS_POSTGRES_MIGRATION_DIR='/home/long/project/立交桥/projects/ai-customer-service/db/migration' \ +AI_CS_WEBHOOK_SECRET='gate-b-secret-20260504' \ +AI_CS_WEBHOOK_TIMESTAMP_HEADER='X-CS-Timestamp' \ +AI_CS_WEBHOOK_SIGNATURE_HEADER='X-CS-Signature' \ +AI_CS_WEBHOOK_MAX_SKEW_SECONDS=300 \ +scripts/verify_preprod_gate_b.sh +``` + +结果摘要: + +- PASS 总数:`30` +- FAIL 总数:`0` +- 生成 ticket:`0806e91f-f50a-4942-b263-f14a4ed5285e` +- 生成 session:`9a468320-81c3-44fb-9707-9819dba16e94` +- 验证 message_id:`gateb-20260504185024-message` +- 服务日志:`/tmp/ai-customer-service-preprod-gate-b/gateb-20260504185024/service.log` + +关键通过项: + +1. 当前源码可成功构建并启动为 production + postgres 模式 +2. `live` / `ready` 探针均返回成功 +3. 带 HMAC 签名的 webhook 请求返回 `200` +4. 首次 webhook 成功创建 `ticket` 与 `message_processed audit` +5. 相同 `message_id` 的重复 webhook 被 dedup,且 dedup 表中仅保留一条记录 +6. `assign -> resolve -> close` 工单闭环在 PostgreSQL 中成功落库 +7. `assign / resolve / close` 两层 audit 都成功入库 + +--- + +## 3. 本次验证中暴露并修复的问题 + +在脚本首次联调过程中,暴露并修复了两个真实问题: + +1. Gate B 脚本最初使用仓库内旧二进制,无法代表当前源码行为 + 已修复为:脚本默认先构建当前源码,再启动服务。 + +2. handler 层 audit 事件 ID 不是合法 UUID,导致 PostgreSQL audit 写入静默失败 + 已修复文件: + - [audit_helper.go](/home/long/project/立交桥/projects/ai-customer-service/internal/http/handlers/audit_helper.go) + - [audit_helper_test.go](/home/long/project/立交桥/projects/ai-customer-service/internal/http/handlers/audit_helper_test.go) + +这两项修复后,Gate B 本地/容器化预演已全部通过。 + +--- + +## 4. 当前结论 + +### 已确认 + +- **本地/容器化 Gate B 预演:通过** +- **脚本化验证入口:已建立** +- **ticket / audit / dedup / health / migration:已有可复跑证据** + +### 仍未确认 + +- **真实共享预生产环境 Gate B:尚未执行同脚本复跑** +- **Gate C 灰度监控 / 回滚演练:未完成** + +因此当前正确结论是: + +> **Gate B 脚本与本地/容器化联调证据已经建立并通过,但还不能把这直接等同于“真实预生产环境已经放行”。** + diff --git a/docs/REVIEW_REPORT_2026-05-04.md b/docs/REVIEW_REPORT_2026-05-04.md index e21c782..cafc39a 100644 --- a/docs/REVIEW_REPORT_2026-05-04.md +++ b/docs/REVIEW_REPORT_2026-05-04.md @@ -23,6 +23,36 @@ 2. **不具备直接生产上线条件。** 3. **更适合被定义为“Phase 1 后端骨架 + 最小工单闭环”,距离生产上线至少还差 3 个阶段。** +### 1.1 整改后状态更新(2026-05-04 当日追加) + +在本次 review 之后,已继续完成并验证: + +1. 文档口径与配置契约收口 +2. 后台最小鉴权落地 +3. 工单 `assign -> resolve -> close` 语义收口 +4. Gate B 预生产验证脚本建立并完成本地/容器化实测 +5. 灰度最小监控、阈值、放量与回滚门禁文档建立 +6. 一页式灰度放行清单建立 + +这意味着项目状态已经从“只有代码级可运行”提升到了: + +> **代码级门禁通过 + 本地/容器化 Gate B 通过 + Gate C 门禁已定义,但真实共享预生产与真实灰度放量仍未通过。** + +相应地,这份报告中的“生产放量准备度”需要更新为: + +| 维度 | 初始判断 | 当前更新判断 | +|---|---:|---:| +| 代码级可信度 | 45% | 60% | +| 预生产可验证度 | 20% | 55% | +| 灰度放量准备度 | 20% | 40% | + +但这仍然**不构成“允许灰度上线”**。当前主要剩余阻断是: + +1. 共享预生产环境尚未复跑 Gate B 脚本 +2. 共享预生产/灰度环境监控接线未完成 +3. 回滚演练未完成 +4. 首轮 5% 灰度稳定性尚无证据 + ## 2. 本次实际验证 本次实际执行并确认了以下检查: @@ -365,3 +395,33 @@ PRD 的 in-scope 能力包含: - **距离完整规划设计完成:约 25%** - **距离生产可灰度上线:约 75% 的关键工作仍未闭环** - **距离 PRD 全量目标上线:约 70%~80% 的业务能力仍未落地** + +--- + +## 9. 2026-05-05 实测更新 + +### Gate B 本地/容器化验证(实测通过) + +| 项目 | 值 | +|------|------| +| 运行 ID | `gateb-20260505101654` | +| PASS/FAIL | **30/0** | +| 验证范围 | postgres连通、migration账本、live/ready、webhook签名、dedup、ticket全链路(assign/resolve/close)、audit入库 | + +### Gate C 回滚演练本地验证(实测通过) + +| 项目 | 值 | +|------|------| +| 运行 ID | `gatec-rollback-20260505101646` | +| PASS/FAIL | **25/0** | +| 验证范围 | 源码构建、baseline启动、broken release退出、回滚重启、主链路恢复、dedup/audit/ticket验证 | + +### 结论升级 + +| 维度 | 更新前 | 更新后 | +|------|--------|--------| +| 代码级可信度 | 60% | **75%** | +| 预生产可验证度 | 55% | **70%** | +| 灰度放量准备度 | 40% | **50%** | + +**仍需线下验证**:真实共享预生产环境 Gate B + 灰度监控接线 + 5%灰度稳定性 diff --git a/docs/ROLLBACK_DRILL_RECORD.md b/docs/ROLLBACK_DRILL_RECORD.md new file mode 100644 index 0000000..ba117d5 --- /dev/null +++ b/docs/ROLLBACK_DRILL_RECORD.md @@ -0,0 +1,127 @@ +# ROLLBACK_DRILL_RECORD.md + +> 状态:✅ 已完成实测 +> 最近一次更新:2026-05-05 +> 目标:沉淀 Gate C 回滚演练的可复跑证据,而不是只保留 runbook 描述 + +--- + +## 1. 验证范围 + +本记录对应 Gate C 回滚演练脚本: + +- [scripts/verify_gate_c_rollback.sh](/home/long/project/立交桥/projects/ai-customer-service/scripts/verify_gate_c_rollback.sh) + +脚本覆盖的检查项: + +1. 当前源码重新构建与 baseline 启动 +2. baseline `live` / `ready` 探针成功 +3. baseline signed webhook 联调成功 +4. 模拟错误发布导致服务无法 ready +5. 立即回滚到 baseline 配置并重启 +6. 回滚后 `live` / `ready` 恢复成功 +7. 回滚后 signed webhook / dedup / ticket / audit 主链恢复成功 + +--- + +## 2. 实测记录(2026-05-05) + +- 时间:2026-05-05 10:16 CST +- 环境:本机容器化 + 本地 PostgreSQL(端口 5434) +- 基线提交:当前工作区最新源码 +- 运行 ID:`gatec-rollback-20260505101646` +- 产物目录:`/tmp/ai-customer-service-gate-c-rollback/gatec-rollback-20260505101646` + +执行命令: + +```bash +AI_CS_RUNTIME_ENV=production \ +AI_CS_ADDR=127.0.0.1:18081 \ +AI_CS_POSTGRES_ENABLED=true \ +AI_CS_POSTGRES_DSN='host=localhost port=5434 user=ai_cs password=ai_cs_secret dbname=ai_customer_service sslmode=disable' \ +AI_CS_POSTGRES_MIGRATION_DIR='/home/long/project/立交桥/projects/ai-customer-service/db/migration' \ +AI_CS_WEBHOOK_SECRET='gate-c-secret-20260505' \ +AI_CS_WEBHOOK_TIMESTAMP_HEADER='X-CS-Timestamp' \ +AI_CS_WEBHOOK_SIGNATURE_HEADER='X-CS-Signature' \ +AI_CS_WEBHOOK_MAX_SKEW_SECONDS=300 \ +scripts/verify_gate_c_rollback.sh +``` + +结果摘要: + +| 指标 | 值 | +|------|------| +| PASS 总数 | **25** | +| FAIL 总数 | **0** | +| baseline message_id | `gatec-rollback-20260505101646-baseline-message` | +| rollback message_id | `gatec-rollback-20260505101646-rollback-message` | +| rollback ticket_id | `a2307c4f-0a2c-406c-ad19-e9ebfe927d40` | +| rollback session_id | `79447f0d-6ca4-4d3f-99ee-e0a6df311731` | +| baseline 日志 | `/tmp/ai-customer-service-gate-c-rollback/gatec-rollback-20260505101646/baseline-service.log` | +| broken release 日志 | `/tmp/ai-customer-service-gate-c-rollback/gatec-rollback-20260505101646/broken-service.log` | +| rolled-back 日志 | `/tmp/ai-customer-service-gate-c-rollback/gatec-rollback-20260505101646/rolled-back-service.log` | + +关键通过项(25/25): + +1. ✅ 当前源码成功构建 +2. ✅ baseline 服务启动(pid=`2064155`) +3. ✅ baseline `live` + `ready` 探针通过 +4. ✅ baseline signed webhook HTTP 200 +5. ✅ baseline webhook response `received=true` +6. ✅ baseline webhook response `handoff=true` +7. ✅ baseline 服务正常停止 +8. ✅ broken release 进程启动(模拟错误发布) +9. ✅ broken release 进程按预期退出(never became ready) +10. ✅ 回滚重启后服务启动(pid=`2064338`) +11. ✅ 回滚后 `live` + `ready` 探针通过 +12. ✅ 回滚后 signed webhook HTTP 200 +13. ✅ 回滚后 webhook response `received=true` +14. ✅ 回滚后 webhook response `handoff=true` +15. ✅ 回滚后 webhook 返回 `ticket_id` + `session_id` +16. ✅ 回滚后 webhook 创建 `open` 状态工单 +17. ✅ 回滚后 dedup 行持久化 +18. ✅ 回滚后 `message_processed` audit 持久化 +19. ✅ 回滚后工单关联 session 验证通过 +20. ✅ gate-c rollback drill 整体通过 + +--- + +## 3. Gate B 实测记录(2026-05-05 同轮) + +- 时间:2026-05-05 10:16 CST +- 运行 ID:`gateb-20260505101654` +- 产物目录:`/tmp/ai-customer-service-preprod-gate-b/gateb-20260505101654` + +| 指标 | 值 | +|------|------| +| PASS 总数 | **30** | +| FAIL 总数 | **0** | +| ticket_id | `b183631d-e551-47c5-a719-f0f0f3d1adba` | +| session_id | `41bcaf30-4ac8-48cb-844c-a87a582e9429` | +| message_id | `gateb-20260505101654-message` | + +关键通过项(30/30):构建、postgres 连通、migration 账本、live/ready、webhook 签名、dedup、ticket assign/resolve/close 全链路、audit 入库。 + +--- + +## 4. 当前结论 + +### ✅ 已确认 + +- **本地/容器化 Gate B:通过(30/30 PASS)** +- **本地/容器化 Gate C 回滚演练:通过(25/25 PASS)** +- **真实 PostgreSQL 工单闭环(assign → resolve → close):已验证** +- **审计日志多层持久化(workflow store + handler):已验证** +- **回滚后主链路完全恢复**:已验证 + +### ⚠️ 仍未确认 + +- **真实共享预生产环境 Gate B:尚未执行同脚本复跑** +- **真实共享预生产/灰度环境监控接线:未完成** +- **5% 灰度稳定性:未执行** + +> 本次结论已从"脚本已建立"升级为"本地/容器化实测通过"。但真实共享预生产和灰度环境仍需单独验证,不能混淆为同一结论。 + +--- + +*最后更新:2026-05-05 by 宰相* diff --git a/docs/RUNBOOK.md b/docs/RUNBOOK.md index bd162db..9578e65 100644 --- a/docs/RUNBOOK.md +++ b/docs/RUNBOOK.md @@ -7,6 +7,49 @@ --- +## 0. Gate B 推荐入口 + +预生产 Gate B 不再建议靠零散手工命令拼接验证。优先使用: + +- [scripts/verify_preprod_gate_b.sh](/home/long/project/立交桥/projects/ai-customer-service/scripts/verify_preprod_gate_b.sh) +- 最近一次实测记录:[PREPROD_VERIFICATION_RECORD.md](/home/long/project/立交桥/projects/ai-customer-service/docs/PREPROD_VERIFICATION_RECORD.md) +- Gate C 回滚演练入口:[scripts/verify_gate_c_rollback.sh](/home/long/project/立交桥/projects/ai-customer-service/scripts/verify_gate_c_rollback.sh) +- 最近一次回滚演练记录:[ROLLBACK_DRILL_RECORD.md](/home/long/project/立交桥/projects/ai-customer-service/docs/ROLLBACK_DRILL_RECORD.md) + +脚本会完成: + +1. 环境变量完整性检查 +2. PostgreSQL 连通性检查 +3. migration 基线检查 +4. 当前源码构建与服务启动 +5. `live` / `ready` 探针检查 +6. signed webhook 联调 +7. dedup 入库验证 +8. ticket / audit 入库闭环验证 + +推荐执行方式: + +```bash +AI_CS_RUNTIME_ENV=production \ +AI_CS_ADDR=127.0.0.1:18080 \ +AI_CS_POSTGRES_ENABLED=true \ +AI_CS_POSTGRES_DSN='host=localhost port=5434 user=ai_cs password=ai_cs_secret dbname=ai_customer_service sslmode=disable' \ +AI_CS_POSTGRES_MIGRATION_DIR="$PWD/db/migration" \ +AI_CS_WEBHOOK_SECRET='replace-with-real-secret' \ +AI_CS_WEBHOOK_TIMESTAMP_HEADER='X-CS-Timestamp' \ +AI_CS_WEBHOOK_SIGNATURE_HEADER='X-CS-Signature' \ +AI_CS_WEBHOOK_MAX_SKEW_SECONDS=300 \ +scripts/verify_preprod_gate_b.sh +``` + +通过标准: + +- 脚本退出码为 `0` +- 输出末尾出现 `summary: pass=... fail=0` +- 产物目录中保留 `summary.txt`、`service.log`、`webhook_response.json` + +--- + ## 一、部署前检查清单(Pre-flight) ```bash diff --git a/docs/SHARED_PREPROD_ACCESS_HANDOFF_CHECKLIST.md b/docs/SHARED_PREPROD_ACCESS_HANDOFF_CHECKLIST.md new file mode 100644 index 0000000..2a845db --- /dev/null +++ b/docs/SHARED_PREPROD_ACCESS_HANDOFF_CHECKLIST.md @@ -0,0 +1,339 @@ +# 共享预生产入口交接清单 + +> 状态:待共享预生产环境提供方回填 +> 最近更新:2026-05-06 +> 适用项目:`projects/ai-customer-service` +> 目标:确保“真实共享预生产 Gate B 复跑”和“真实共享预生产/灰度环境 Gate C 回滚演练”具备可执行入口,而不是停留在口头说明 + +--- + +## 1. 这份清单解决什么问题 + +当前项目已经具备: + +1. 代码级门禁通过 +2. 本地/容器化 Gate B 通过 +3. 本地/容器化 Gate C 回滚演练通过 + +当前仍然缺失的是: + +1. **真实共享预生产环境 Gate B 复跑入口** +2. **真实共享预生产/灰度环境 Gate C 回滚演练入口** + +这里的“入口”不是一个 URL,也不是一句“环境已经有了”,而是: + +> **从当前执行机器出发,能真实操作共享预生产环境的运维通道。** + +必须能够支持: + +1. 启动/重启服务 +2. 查看日志 +3. 访问 health probe +4. 访问真实 PostgreSQL +5. 获取真实环境变量来源 +6. 在该环境执行 Gate B 验证 +7. 在该环境执行 Gate C 回滚演练 +8. 留下可复核证据 + +--- + +## 2. 合格入口类型 + +满足以下任一类型即可: + +### 2.1 SSH 主机入口 + +提供: + +- 主机地址 +- 用户名 +- 登录方式 +- 项目目录 +- 启动/重启命令 +- 日志路径 +- 服务访问地址 + +适用场景: + +- systemd 服务 +- 直接运行二进制 +- Docker / Podman 单机部署 + +### 2.2 Kubernetes 入口 + +提供: + +- `kubectl` 可用 +- `kubeconfig` 或 context +- namespace +- deployment / service 名称 +- 查看日志权限 +- rollout / undo 权限 + +适用场景: + +- Kubernetes Deployment +- StatefulSet +- 多副本灰度切换 + +### 2.3 CI/CD 或发布平台入口 + +提供: + +- 预生产部署流水线入口 +- 环境变量/Secret 查看或确认方式 +- 服务日志查看入口 +- 重启/回滚入口 +- 部署版本与提交号映射 + +适用场景: + +- GitOps +- 平台托管部署 +- 云上控制台发布 + +--- + +## 3. 不算合格入口的情况 + +以下情况都不够: + +1. 只有共享预生产 URL +2. 只有数据库只读账号 +3. 只有监控只读面板 +4. 只有截图、文档或口头说明 +5. 只能“看状态”,不能“重启/回滚/留痕” + +原因很直接: + +> Gate B / Gate C 都要求可操作性,不只是可观察性。 + +--- + +## 4. 入口必须满足的规范要求 + +### 4.1 部署对象明确 + +必须明确服务部署对象: + +- systemd service 名称 +- Docker / Podman 容器名称 +- Kubernetes deployment / rollout 对象 + +不能只说“服务在那台机器上”,必须能回答: + +1. 由谁启动 +2. 怎么重启 +3. 怎么回滚 +4. 日志在哪 + +### 4.2 环境变量来源明确 + +必须明确共享预生产如何注入这些变量: + +- `AI_CS_RUNTIME_ENV` +- `AI_CS_ADDR` +- `AI_CS_POSTGRES_ENABLED` +- `AI_CS_POSTGRES_DSN` +- `AI_CS_POSTGRES_MIGRATION_DIR` +- `AI_CS_WEBHOOK_SECRET` +- `AI_CS_WEBHOOK_TIMESTAMP_HEADER` +- `AI_CS_WEBHOOK_SIGNATURE_HEADER` +- `AI_CS_WEBHOOK_MAX_SKEW_SECONDS` + +基线文档: + +- [CONFIG_CONTRACT_BASELINE.md](/home/long/project/立交桥/projects/ai-customer-service/docs/CONFIG_CONTRACT_BASELINE.md) + +必须至少能回答: + +1. 变量值从哪里来 +2. 谁负责维护 +3. 如何在不泄露明文 secret 的前提下确认其已正确注入 + +### 4.3 数据库必须是共享预生产真实库 + +不能使用: + +- 本地测试库 +- 临时容器库 +- 开发库 + +必须使用共享预生产 PostgreSQL,才能证明: + +1. migration 基线真实可用 +2. ticket 入库真实可用 +3. audit 入库真实可用 +4. dedup 入库真实可用 + +### 4.4 必须具备最小操作权限 + +入口必须允许执行以下动作: + +1. 启动或重启当前版本 +2. 查看最近日志 +3. 访问 `/actuator/health/live` +4. 访问 `/actuator/health/ready` +5. 读取当前部署版本/镜像/tag/commit +6. 执行回滚动作 +7. 验证回滚后主链恢复 + +### 4.5 必须可留痕 + +至少保留以下证据: + +1. `summary.txt` +2. 服务日志路径 +3. 部署版本 / 提交号 +4. 健康检查结果 +5. Gate B / Gate C 执行命令 +6. 回滚前后版本信息 +7. 必要时数据库验证摘要 + +--- + +## 5. Gate B 所需最小入口要求 + +如果当前只想完成“真实共享预生产 Gate B 复跑”,入口最少要具备: + +1. 共享预生产服务启动权限 +2. 共享预生产 PostgreSQL 可连 +3. 真实 `AI_CS_*` 环境变量可确认 +4. 服务地址可访问 +5. 日志可读 + +执行入口: + +- [scripts/verify_preprod_gate_b.sh](/home/long/project/立交桥/projects/ai-customer-service/scripts/verify_preprod_gate_b.sh) + +对应证据模板: + +- [PREPROD_VERIFICATION_RECORD.md](/home/long/project/立交桥/projects/ai-customer-service/docs/PREPROD_VERIFICATION_RECORD.md) + +--- + +## 6. Gate C 所需额外入口要求 + +如果要完成“真实共享预生产/灰度环境 Gate C 回滚演练”,除 Gate B 外还必须额外明确: + +1. **坏发布怎么制造** + - 错误配置 + - 错误 DSN + - 错误 Secret + - 错误镜像/tag +2. **回滚对象是谁** + - systemd service + - container + - deployment +3. **标准回滚动作是什么** + - `systemctl restart ...` + - `docker/podman restart ...` + - `kubectl rollout undo ...` +4. **恢复完成如何判定** + - `live` / `ready` 恢复 + - signed webhook 重新返回 `200` + - ticket / audit / dedup 重新恢复写入 + +执行入口: + +- [scripts/verify_gate_c_rollback.sh](/home/long/project/立交桥/projects/ai-customer-service/scripts/verify_gate_c_rollback.sh) + +对应证据模板: + +- [ROLLBACK_DRILL_RECORD.md](/home/long/project/立交桥/projects/ai-customer-service/docs/ROLLBACK_DRILL_RECORD.md) + +--- + +## 7. 共享预生产入口交接模板 + +请环境提供方至少按下面模板回填: + +```text +共享预生产入口类型: +- SSH / Kubernetes / CI-CD + +如果是 SSH: +- 主机地址: +- 用户名: +- 登录方式: +- 项目目录: +- 服务启动命令: +- 服务重启命令: +- 服务停止命令: +- 日志路径: +- 服务访问地址: +- 环境变量来源文件或注入方式: + +如果是 Kubernetes: +- kubeconfig/context: +- namespace: +- deployment 名称: +- service 名称: +- ingress / 访问地址: +- 查看日志命令: +- 重启命令: +- 回滚命令: +- Secret / ConfigMap 名称: + +如果是 CI/CD: +- 平台名称: +- 流水线入口: +- 发布目标环境名称: +- 当前部署版本查看方式: +- 日志查看入口: +- 回滚入口: + +数据库: +- 是否为共享预生产 PostgreSQL: +- DSN 获取方式: +- migration 目录所在位置: + +Gate B 执行责任人: +- 负责人: +- 计划时间: + +Gate C 回滚演练责任人: +- 负责人: +- 计划时间: + +证据归档位置: +- summary.txt: +- service.log: +- 版本信息: +- 回滚记录: +``` + +--- + +## 8. 当前项目的真实阻断 + +截至 2026-05-06,当前执行机器上已确认: + +1. **没有 `kubectl`** +2. **没有 `~/.kube/config`** +3. **没有共享预生产专用 `AI_CS_*` 环境** +4. **仓库内没有共享预生产部署清单** + +因此当前阻断不是: + +- Gate B/Gate C 脚本缺失 +- 本地演练能力缺失 +- 门禁文档缺失 + +而是: + +> **真实共享预生产环境运维入口未交接。** + +--- + +## 9. 当前结论 + +当前可以准确表达为: + +1. **代码级门禁:通过** +2. **本地/容器化 Gate B:通过** +3. **本地/容器化 Gate C 回滚演练:通过** +4. **真实共享预生产 Gate B:待共享预生产入口交接后执行** +5. **真实共享预生产/灰度环境 Gate C:待共享预生产入口交接后执行** + +> 没有入口,不应宣称“真实共享预生产已验证”;有入口后,才可以继续执行真实 Gate B / Gate C。 diff --git a/internal/http/handlers/audit_helper.go b/internal/http/handlers/audit_helper.go index 33595ea..784d55b 100644 --- a/internal/http/handlers/audit_helper.go +++ b/internal/http/handlers/audit_helper.go @@ -2,10 +2,10 @@ package handlers import ( "context" - "fmt" "time" "github.com/bridge/ai-customer-service/internal/domain/audit" + "github.com/google/uuid" ) type AuditRecorder interface { @@ -13,5 +13,5 @@ type AuditRecorder interface { } func newAuditID(prefix string, now time.Time) string { - return fmt.Sprintf("%s-%d", prefix, now.UnixNano()) + return uuid.NewString() } diff --git a/internal/http/handlers/audit_helper_test.go b/internal/http/handlers/audit_helper_test.go new file mode 100644 index 0000000..dddf76b --- /dev/null +++ b/internal/http/handlers/audit_helper_test.go @@ -0,0 +1,15 @@ +package handlers + +import ( + "testing" + "time" + + "github.com/google/uuid" +) + +func TestNewAuditID_ReturnsValidUUID(t *testing.T) { + id := newAuditID("audit", time.Now()) + if _, err := uuid.Parse(id); err != nil { + t.Fatalf("newAuditID() = %q, want valid UUID: %v", id, err) + } +} diff --git a/internal/http/handlers/ticket_handler_test.go b/internal/http/handlers/ticket_handler_test.go index c9b42f7..da02067 100644 --- a/internal/http/handlers/ticket_handler_test.go +++ b/internal/http/handlers/ticket_handler_test.go @@ -450,3 +450,64 @@ func TestTicketHandlerAssign_RejectsWhenActorOnlyProvidedByQuery(t *testing.T) { t.Fatalf("status = %d, want 403", resp.Code) } } + +func TestTicketHandlerResolve_ReturnsNotFoundForMissingTicket(t *testing.T) { + auditRecorder := &ticketAuditRecorder{} + svc := newMockTicketService(auditRecorder) + h := NewTicketHandler(svc, auditRecorder) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/tickets/missing-ticket/resolve?resolution=handled", nil) + req = withActor(req, "agent-404", "agent") + resp := httptest.NewRecorder() + h.Resolve(resp, req) + + if resp.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404", resp.Code) + } + + var payload map[string]any + if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil { + t.Fatalf("json decode error = %v", err) + } + errPayload := payload["error"].(map[string]any) + if errPayload["code"] != "CS_TICKET_4001" { + t.Fatalf("error code = %v, want CS_TICKET_4001", errPayload["code"]) + } +} + +func TestTicketHandlerClose_ReturnsConflictWhenTicketNotResolved(t *testing.T) { + auditRecorder := &ticketAuditRecorder{} + svc := newMockTicketService(auditRecorder) + now := time.Date(2026, 4, 29, 21, 0, 0, 0, time.UTC) + if err := svc.tickets.Create(context.Background(), &ticket.Ticket{ + ID: "ticket-close-conflict-1", + SessionID: "session-close-conflict-1", + Priority: ticket.PriorityP1, + Status: ticket.StatusAssigned, + AssignedTo: "agent-1", + HandoffReason: "refund", + CreatedAt: now, + UpdatedAt: now, + }); err != nil { + t.Fatalf("Create() error = %v", err) + } + h := NewTicketHandler(svc, auditRecorder) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/tickets/ticket-close-conflict-1/close?resolution=user+confirmed", nil) + req = withActor(req, "supervisor-1", "supervisor") + resp := httptest.NewRecorder() + h.Close(resp, req) + + if resp.Code != http.StatusConflict { + t.Fatalf("status = %d, want 409", resp.Code) + } + + var payload map[string]any + if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil { + t.Fatalf("json decode error = %v", err) + } + errPayload := payload["error"].(map[string]any) + if errPayload["code"] != "CS_TICKET_4093" { + t.Fatalf("error code = %v, want CS_TICKET_4093", errPayload["code"]) + } +} diff --git a/internal/store/memory/ticket_workflow.go b/internal/store/memory/ticket_workflow.go index dbff157..88e5463 100644 --- a/internal/store/memory/ticket_workflow.go +++ b/internal/store/memory/ticket_workflow.go @@ -5,6 +5,7 @@ import ( "fmt" "time" + "github.com/bridge/ai-customer-service/internal/domain/error/cserrors" "github.com/bridge/ai-customer-service/internal/domain/ticket" ) @@ -30,46 +31,58 @@ func (s *TicketStore) Assign(_ context.Context, ticketID, agentID, _, _ string, s.mu.Lock() defer s.mu.Unlock() for i := range s.tickets { - if s.tickets[i].ID == ticketID && s.tickets[i].Status == ticket.StatusOpen { - s.tickets[i].AssignedTo = agentID - s.tickets[i].Status = ticket.StatusAssigned - s.tickets[i].UpdatedAt = now - return nil + if s.tickets[i].ID != ticketID { + continue } + if s.tickets[i].Status != ticket.StatusOpen { + return fmt.Errorf("%s:%s", cserrors.CS_TKT_4002, cserrors.ErrorMsg(cserrors.CS_TKT_4002)) + } + s.tickets[i].AssignedTo = agentID + s.tickets[i].Status = ticket.StatusAssigned + s.tickets[i].UpdatedAt = now + return nil } - return fmt.Errorf("ticket not assignable") + return fmt.Errorf("%s:%s", cserrors.CS_TICKET_4001, cserrors.ErrorMsg(cserrors.CS_TICKET_4001)) } func (s *TicketStore) Resolve(_ context.Context, ticketID, resolution, _, _ string, now time.Time) error { s.mu.Lock() defer s.mu.Unlock() for i := range s.tickets { - if s.tickets[i].ID == ticketID { - resolvedAt := now - s.tickets[i].Resolution = resolution - s.tickets[i].Status = ticket.StatusResolved - s.tickets[i].ResolvedAt = &resolvedAt - s.tickets[i].UpdatedAt = now - return nil + if s.tickets[i].ID != ticketID { + continue } + if s.tickets[i].Status != ticket.StatusAssigned && s.tickets[i].Status != ticket.StatusProcessing { + return fmt.Errorf("%s:%s", cserrors.CS_TICKET_4092, cserrors.ErrorMsg(cserrors.CS_TICKET_4092)) + } + resolvedAt := now + s.tickets[i].Resolution = resolution + s.tickets[i].Status = ticket.StatusResolved + s.tickets[i].ResolvedAt = &resolvedAt + s.tickets[i].UpdatedAt = now + return nil } - return fmt.Errorf("ticket not resolvable") + return fmt.Errorf("%s:%s", cserrors.CS_TICKET_4001, cserrors.ErrorMsg(cserrors.CS_TICKET_4001)) } func (s *TicketStore) Close(_ context.Context, ticketID, resolution, _, _ string, now time.Time) error { s.mu.Lock() defer s.mu.Unlock() for i := range s.tickets { - if s.tickets[i].ID == ticketID && (s.tickets[i].Status == ticket.StatusResolved || s.tickets[i].Status == ticket.StatusAssigned || s.tickets[i].Status == ticket.StatusProcessing) { - resolvedAt := now - s.tickets[i].Resolution = resolution - s.tickets[i].Status = ticket.StatusClosed - if s.tickets[i].ResolvedAt == nil { - s.tickets[i].ResolvedAt = &resolvedAt - } - s.tickets[i].UpdatedAt = now - return nil + if s.tickets[i].ID != ticketID { + continue } + if s.tickets[i].Status != ticket.StatusResolved { + return fmt.Errorf("%s:%s", cserrors.CS_TICKET_4093, cserrors.ErrorMsg(cserrors.CS_TICKET_4093)) + } + resolvedAt := now + s.tickets[i].Resolution = resolution + s.tickets[i].Status = ticket.StatusClosed + if s.tickets[i].ResolvedAt == nil { + s.tickets[i].ResolvedAt = &resolvedAt + } + s.tickets[i].UpdatedAt = now + return nil } - return fmt.Errorf("ticket not closable") + return fmt.Errorf("%s:%s", cserrors.CS_TICKET_4001, cserrors.ErrorMsg(cserrors.CS_TICKET_4001)) } diff --git a/internal/store/memory/ticket_workflow_test.go b/internal/store/memory/ticket_workflow_test.go index ab60ee8..7db1c38 100644 --- a/internal/store/memory/ticket_workflow_test.go +++ b/internal/store/memory/ticket_workflow_test.go @@ -2,6 +2,7 @@ package memory import ( "context" + "strings" "testing" "time" @@ -41,8 +42,8 @@ func TestTicketStore_Assign(t *testing.T) { // Create an open ticket store.Create(ctx, &ticket.Ticket{ - ID: "t1", - Status: ticket.StatusOpen, + ID: "t1", + Status: ticket.StatusOpen, CreatedAt: now, UpdatedAt: now, }) @@ -91,11 +92,11 @@ func TestTicketStore_Resolve(t *testing.T) { // Create an assigned ticket store.Create(ctx, &ticket.Ticket{ - ID: "t1", - Status: ticket.StatusAssigned, + ID: "t1", + Status: ticket.StatusAssigned, AssignedTo: "agent1", - CreatedAt: now, - UpdatedAt: now, + CreatedAt: now, + UpdatedAt: now, }) // Resolve it @@ -117,6 +118,30 @@ func TestTicketStore_Resolve(t *testing.T) { } } +func TestTicketStore_Resolve_ClosedTicketConflict(t *testing.T) { + store := NewTicketStore() + ctx := context.Background() + now := time.Now().Truncate(time.Second) + resolvedAt := now.Add(-30 * time.Minute) + + store.Create(ctx, &ticket.Ticket{ + ID: "t-closed", + Status: ticket.StatusClosed, + Resolution: "done", + ResolvedAt: &resolvedAt, + CreatedAt: now, + UpdatedAt: now, + }) + + err := store.Resolve(ctx, "t-closed", "retry", "admin", "127.0.0.1", now) + if err == nil { + t.Fatal("Resolve() on closed ticket should return error") + } + if !strings.HasPrefix(err.Error(), "CS_TICKET_4092") { + t.Fatalf("Resolve() error = %v, want CS_TICKET_4092 prefix", err) + } +} + func TestTicketStore_Close(t *testing.T) { store := NewTicketStore() ctx := context.Background() @@ -153,8 +178,8 @@ func TestTicketStore_Close_NotResolved(t *testing.T) { // Create an open ticket (not resolved) store.Create(ctx, &ticket.Ticket{ - ID: "t1", - Status: ticket.StatusOpen, + ID: "t1", + Status: ticket.StatusOpen, CreatedAt: now, UpdatedAt: now, }) @@ -164,4 +189,29 @@ func TestTicketStore_Close_NotResolved(t *testing.T) { if err == nil { t.Fatal("Close() on non-resolved ticket should return error") } + if !strings.HasPrefix(err.Error(), "CS_TICKET_4093") { + t.Fatalf("Close() error = %v, want CS_TICKET_4093 prefix", err) + } +} + +func TestTicketStore_Close_AssignedTicketConflict(t *testing.T) { + store := NewTicketStore() + ctx := context.Background() + now := time.Now().Truncate(time.Second) + + store.Create(ctx, &ticket.Ticket{ + ID: "t-assigned", + Status: ticket.StatusAssigned, + AssignedTo: "agent1", + CreatedAt: now, + UpdatedAt: now, + }) + + err := store.Close(ctx, "t-assigned", "premature close", "admin", "127.0.0.1", now) + if err == nil { + t.Fatal("Close() on assigned ticket should return error") + } + if !strings.HasPrefix(err.Error(), "CS_TICKET_4093") { + t.Fatalf("Close() error = %v, want CS_TICKET_4093 prefix", err) + } } diff --git a/internal/store/postgres/ticket_workflow.go b/internal/store/postgres/ticket_workflow.go index c2b02e3..7145a0f 100644 --- a/internal/store/postgres/ticket_workflow.go +++ b/internal/store/postgres/ticket_workflow.go @@ -9,8 +9,8 @@ import ( "time" "github.com/bridge/ai-customer-service/internal/domain/audit" - "github.com/google/uuid" "github.com/bridge/ai-customer-service/internal/domain/ticket" + "github.com/google/uuid" ) // TicketWorkflowStore composes TicketStore with AuditStore for workflow operations. @@ -37,14 +37,14 @@ func (s *TicketWorkflowStore) writeAudit(ctx context.Context, ticketID, action, } now := time.Now() event := audit.Event{ - ID: uuid.New().String(), - Type: "ticket_state_changed", - Action: action, - TicketID: ticketID, - ActorID: actorID, - SourceIP: sourceIP, + ID: uuid.New().String(), + Type: "ticket_state_changed", + Action: action, + TicketID: ticketID, + ActorID: actorID, + SourceIP: sourceIP, AfterState: afterState, - CreatedAt: now, + CreatedAt: now, } if err := s.audit.Add(ctx, event); err != nil { if s.log != nil { @@ -134,10 +134,10 @@ func (s *TicketWorkflowStore) Resolve(ctx context.Context, ticketID, resolution, if currentStatus == "" { return fmt.Errorf("CS_TICKET_4001:ticket not found") } - if currentStatus == "resolved" || currentStatus == "closed" { + if currentStatus != "assigned" && currentStatus != "processing" { return fmt.Errorf("CS_TICKET_4092:ticket resolve conflict") } - result, err := s.db.ExecContext(ctx, `UPDATE cs_tickets SET resolution = NULLIF($2,''), status = 'resolved', resolved_at = $3, updated_at = $3 WHERE id = $1::uuid AND status IN ('assigned','processing','open')`, ticketID, resolution, now) + result, err := s.db.ExecContext(ctx, `UPDATE cs_tickets SET resolution = NULLIF($2,''), status = 'resolved', resolved_at = $3, updated_at = $3 WHERE id = $1::uuid AND status IN ('assigned','processing')`, ticketID, resolution, now) if err != nil { return err } @@ -166,10 +166,10 @@ func (s *TicketWorkflowStore) Close(ctx context.Context, ticketID, resolution, a if currentStatus == "" { return fmt.Errorf("CS_TICKET_4001:ticket not found") } - if currentStatus == "closed" { + if currentStatus != "resolved" { return fmt.Errorf("CS_TICKET_4093:ticket close conflict") } - result, err := s.db.ExecContext(ctx, `UPDATE cs_tickets SET resolution = NULLIF($2,''), status = 'closed', resolved_at = COALESCE(resolved_at, $3), updated_at = $3 WHERE id = $1::uuid AND status IN ('resolved','assigned','processing')`, ticketID, resolution, now) + result, err := s.db.ExecContext(ctx, `UPDATE cs_tickets SET resolution = NULLIF($2,''), status = 'closed', resolved_at = COALESCE(resolved_at, $3), updated_at = $3 WHERE id = $1::uuid AND status = 'resolved'`, ticketID, resolution, now) if err != nil { return err } diff --git a/internal/store/postgres/ticket_workflow_test.go b/internal/store/postgres/ticket_workflow_test.go index 810b60a..ebc1a8f 100644 --- a/internal/store/postgres/ticket_workflow_test.go +++ b/internal/store/postgres/ticket_workflow_test.go @@ -2,6 +2,7 @@ package postgres import ( "context" + "strings" "testing" "time" @@ -191,6 +192,41 @@ func TestTicketWorkflowStore_Resolve(t *testing.T) { } } +func TestTicketWorkflowStore_Resolve_ClosedTicketConflict(t *testing.T) { + db := openDBForTest(t) + defer db.Close() + + sessionStore := NewSessionStore(db) + ticketStore := NewTicketStore(db) + auditStore := NewAuditStore(db) + workflowStore := NewTicketWorkflowStore(db, auditStore) + ctx := context.Background() + now := time.Now().Truncate(time.Second) + resolvedTime := now.Add(-1 * time.Hour) + + sess, _ := sessionStore.GetOrCreate(ctx, "widget", uniqueID("user"), now) + tkt := &ticket.Ticket{ + ID: uniqueID("tick"), + SessionID: sess.ID, + UserID: "user1", + Priority: ticket.PriorityP1, + Status: ticket.StatusClosed, + Resolution: "done", + ResolvedAt: &resolvedTime, + CreatedAt: now, + UpdatedAt: now, + } + ticketStore.Create(ctx, tkt) + + err := workflowStore.Resolve(ctx, tkt.ID, "retry", "admin", "127.0.0.1", now) + if err == nil { + t.Fatal("Resolve() on closed ticket should return error") + } + if !strings.HasPrefix(err.Error(), "CS_TICKET_4092") { + t.Fatalf("Resolve() error = %v, want CS_TICKET_4092 prefix", err) + } +} + func TestTicketWorkflowStore_Close(t *testing.T) { db := openDBForTest(t) defer db.Close() @@ -260,6 +296,42 @@ func TestTicketWorkflowStore_Close_NotResolved(t *testing.T) { if err == nil { t.Fatal("Close() on non-resolved ticket should return error") } + if !strings.HasPrefix(err.Error(), "CS_TICKET_4093") { + t.Fatalf("Close() error = %v, want CS_TICKET_4093 prefix", err) + } +} + +func TestTicketWorkflowStore_Close_AssignedTicketConflict(t *testing.T) { + db := openDBForTest(t) + defer db.Close() + + sessionStore := NewSessionStore(db) + ticketStore := NewTicketStore(db) + auditStore := NewAuditStore(db) + workflowStore := NewTicketWorkflowStore(db, auditStore) + ctx := context.Background() + now := time.Now().Truncate(time.Second) + + sess, _ := sessionStore.GetOrCreate(ctx, "widget", uniqueID("user"), now) + tkt := &ticket.Ticket{ + ID: uniqueID("tick"), + SessionID: sess.ID, + UserID: "user1", + Priority: ticket.PriorityP1, + Status: ticket.StatusAssigned, + AssignedTo: "agent-001", + CreatedAt: now, + UpdatedAt: now, + } + ticketStore.Create(ctx, tkt) + + err := workflowStore.Close(ctx, tkt.ID, "premature close", "admin", "127.0.0.1", now) + if err == nil { + t.Fatal("Close() on assigned ticket should return error") + } + if !strings.HasPrefix(err.Error(), "CS_TICKET_4093") { + t.Fatalf("Close() error = %v, want CS_TICKET_4093 prefix", err) + } } func TestTicketWorkflowStore_NilDB(t *testing.T) { diff --git a/prd/GRAY_RELEASE_ROLLBACK_RUNBOOK.md b/prd/GRAY_RELEASE_ROLLBACK_RUNBOOK.md index 84447a0..8aaf12d 100644 --- a/prd/GRAY_RELEASE_ROLLBACK_RUNBOOK.md +++ b/prd/GRAY_RELEASE_ROLLBACK_RUNBOOK.md @@ -1,152 +1,144 @@ # 灰度发布与回滚 Runbook -> 版本:v1.0 | 状态:初稿(待 TechLead 补充部署部分) -> 关联:PRODUCTION_EXECUTION_PLAN.md、PRODUCTION_PHASE1_STATUS.md +> 版本:v1.1 +> 状态:灰度门禁已定义,本地/容器化回滚演练已通过,待真实共享预生产/灰度环境演练 +> 关联:`docs/MONITORING_ALERTING.md`、`docs/GRAY_DASHBOARD_MINIMUM.md`、`docs/PREPROD_VERIFICATION_RECORD.md`、`docs/ROLLBACK_DRILL_RECORD.md` --- -## 1. 灰度发布策略 +## 1. 前提 -### 1.1 灰度阶段定义 +开始任何灰度放量前,必须满足: -| 阶段 | 流量比例 | 持续时间 | 通过条件 | -|------|----------|----------|----------| -| 灰度 5% | 5% 新版本 / 95% 老版本 | 1-2 天 | 错误率 < 1%,无 P0/P1 问题 | -| 灰度 20% | 20% 新版本 / 80% 老版本 | 2-3 天 | 错误率 < 0.5%,SLA 指标达标 | -| 灰度 100% | 100% 新版本 | - | 灰度 20% 稳定 48h 后全量 | - -### 1.2 灰度切换方式 - -**当前实现状态**:生产一期**灰度发布能力未落地**,尚无配置化灰度开关。 - -**临时方案**:通过 Kubernetes `Deployment` 副本数控制: -- 灰度 5%:新版本 1 副本,老版本 19 副本 -- 灰度 20%:新版本 4 副本,老版本 16 副本 -- 全量:新版本 20 副本,老版本 0 副本 - -**正式方案(待实现)**: -- 引入 feature flag 服务(LD / Apollo) -- 按用户 ID、渠道、地区等维度灰度 -- 支持热开关,无需重启 +1. Gate B 已通过 + 当前状态:**本地/容器化预演已通过,真实共享预生产环境待复跑** +2. 最小鉴权已落地 +3. 工单闭环语义已收口 +4. 最小监控指标和阈值已定义 --- -## 2. 灰度发布检查单 +## 2. 灰度放量节奏 -### 2.1 发布前检查 +默认节奏如下: -- [ ] 所有 P0/P1 缺陷已关闭 -- [ ] 上一节 8 个 PM 文档已全部建立 -- [ ] 审计日志可查询、可追溯 -- [ ] PostgreSQL migration 已执行,数据完整 -- [ ] 运营后台可看到工单列表/统计 -- [ ] health/readiness 检查通过 +| 档位 | 流量占比 | 最短观察时间 | 进入条件 | 回退条件 | +|------|----------|--------------|----------|----------| +| Stage 1 | 5% | 30 分钟 | Gate B 通过,部署稳定,核心指标全绿 | 任一 P0/P1 指标触发 | +| Stage 2 | 20% | 2 小时 | Stage 1 稳定,`5xx <= 0.5%`,`audit fail = 0` | 5xx > 1%、audit fail > 0、DB 异常 | +| Stage 3 | 50% | 半天 | Stage 2 稳定,handoff 比率无异常升高 | 指标明显劣化或人工链路承压 | +| Stage 4 | 100% | 次日 | Stage 3 稳定跨工作日,无新增 P0/P1 | 任一核心门禁不满足 | -### 2.2 发布后检查(每阶段完成后) +说明: -- [ ] Webhook 可用率 ≥ 99.5%(当前无 metrics,**需补齐 P1**) -- [ ] 错误率 < 0.5%(同上) -- [ ] 转人工率 ≤ 15% -- [ ] 工单创建/分配/解决链路可正常工作 -- [ ] 审计日志正常写入 -- [ ] 无新增 P0/P1 问题 +- **最短观察时间不是建议,是门禁** +- 任意阶段都不允许跳级放量 +- 任意阶段出现 P0/P1 指标时,不继续放量 --- -## 3. 回滚触发条件 +## 3. 放量前检查单 -### 3.1 必须立即回滚的条件 +- [ ] 共享预生产环境已复跑 Gate B 脚本 +- [ ] 最近一次部署产物与验证记录关联清晰 +- [ ] `live` / `ready` 探针正常 +- [ ] PostgreSQL migration 版本与目标一致 +- [ ] webhook signed request 联调已通过 +- [ ] ticket / audit / dedup 验证通过 +- [ ] 灰度 dashboard 可查看 8 个最小指标 -满足以下任意条件,立即启动回滚,无需审批: +--- -| 条件 | 说明 | +## 4. 继续放量的判定条件 + +每个档位进入下一档前,必须满足: + +1. `webhook 5xx <= 0.5%` +2. `webhook reject` 没有异常升高 +3. `audit 写入失败数 = 0` +4. `postgres 连接异常 = 0` +5. `readiness down 次数 = 0` 或未影响流量池 +6. `单实例重启次数 <= 2 / 10 分钟` +7. `handoff 比率 <= 25%` 或未高于基线 `2x` +8. ticket 创建量与人工处理能力匹配,没有积压失控 + +--- + +## 5. 立即回滚条件 + +满足以下任意条件,立即回滚当前灰度版本: + +| 条件 | 原因 | |------|------| -| Webhook 可用率 < 95% | 大量请求失败 | -| P0 安全漏洞被触发 | 如签名校验被绕过 | -| PostgreSQL 数据损坏 | 审计/工单写入失败 | -| 100% 请求返回 5xx | 服务完全不可用 | -| 错误率 > 5% | 持续 5min 以上 | +| Webhook 5xx `> 5%` 持续 5 分钟 | 服务主链不可接受 | +| PostgreSQL 异常导致 `ready` 持续失败 | 核心依赖异常 | +| Audit 写入失败数 `> 0` 且持续 5 分钟 | 合规/追溯链路断裂 | +| Ticket 创建链路断裂 | 人工服务主链损坏 | +| 全量 readiness down 或实例反复重启 | 当前版本不稳定 | -### 3.2 建议回滚的条件 +--- -满足以下条件时,技术负责人评估是否回滚: +## 6. 建议回滚条件 -| 条件 | 说明 | +出现以下情况时,停止继续放量并由 TechLead 决策是否回滚: + +| 条件 | 处理 | |------|------| -| 错误率 > 2% 持续 10min | 异常但未达必须回滚阈值 | -| 特定渠道全部失败 | 如 Telegram webhook 全部报错 | -| SLA 指标连续劣化 | 响应时间 P95 > 10s | - -### 3.3 不需要回滚的条件 - -- 边缘渠道偶发超时(< 0.5%) -- 非核心功能(如 knowledge base 搜索偶发无结果) -- 新版本 warning 日志增加(不影响功能) +| Webhook 5xx `> 1%` 持续 5 分钟 | 冻结当前档位,评估回滚 | +| Handoff 比率高于基线 `2x` | 判断意图识别/降级是否异常 | +| Reject 数持续高于 20% | 检查上游签名或渠道配置 | +| 单实例重启次数过高 | 排查资源、崩溃或配置问题 | --- -## 4. 回滚操作流程 +## 7. 回滚动作 -### 4.1 当前状态 +### 7.1 立即动作 -生产一期**自动回滚机制未落地**,依赖人工执行。 +1. 停止继续放量 +2. 将灰度比例回退到上一个稳定档位 +3. 若当前档位无稳定状态,直接回退到旧版本 -### 4.2 手动回滚步骤(当前临时方案) +### 7.2 回滚后必须检查 -```bash -# 1. 确认当前版本和历史版本 -kubectl rollout history deployment/ai-customer-service - -# 2. 查看当前版本状态 -kubectl get pods -l app=customer-service - -# 3. 回滚到上一版本 -kubectl rollout undo deployment/ai-customer-service - -# 4. 确认回滚成功 -kubectl rollout status deployment/ai-customer-service - -# 5. 确认旧版本 pod 运行正常 -kubectl get pods -l app=customer-service -``` - -### 4.3 回滚后检查 - -- [ ] `/actuator/health` 返回 `{"status":"up"}` -- [ ] `/actuator/ready` 返回 `{"status":"up"}` -- [ ] 手动测试 webhook 消息接收 -- [ ] 确认审计日志正常写入 -- [ ] 确认工单 API 正常工作 +- [ ] `live` 正常 +- [ ] `ready` 正常 +- [ ] signed webhook 再次联调通过 +- [ ] ticket 创建恢复 +- [ ] audit 写入恢复 +- [ ] PostgreSQL 无新错误 --- -## 5. 故障恢复后的重新发布 +## 8. 演练要求 -当回滚后问题修复,需重新走灰度流程: +Gate C 前至少完成一次回滚演练,且留下证据: -1. 问题根因分析完成 -2. 修复方案经过代码 review -3. 在 staging/预发布环境验证 -4. 从灰度 5% 重新开始,不允许跳阶段 +1. 演练时间 +2. 演练版本 +3. 触发条件 +4. 回滚动作 +5. 回滚后验证结果 + +没有演练记录,不得宣称“可安全灰度放量”。 + +推荐入口: + +- [scripts/verify_gate_c_rollback.sh](/home/long/project/立交桥/projects/ai-customer-service/scripts/verify_gate_c_rollback.sh) +- 最近一次本地/容器化记录:[ROLLBACK_DRILL_RECORD.md](/home/long/project/立交桥/projects/ai-customer-service/docs/ROLLBACK_DRILL_RECORD.md) --- -## 6. 灰度期间监控(待实现) +## 9. 当前状态结论 -| 指标 | 当前状态 | 目标 | -|------|----------|------| -| Webhook 成功率 | 未监控 | P1 缺口 | -| API 错误率 | 未监控 | P1 缺口 | -| PostgreSQL 查询延迟 | 未监控 | P1 缺口 | -| 工单未关闭积压 | 未监控 | P1 缺口 | -| 签名校验失败率 | 未监控 | P1 缺口 | +当前正确口径: -> **说明**:metrics/tracing/SLO 属于 P1 缺口,灰度前必须补齐,否则无法客观评估灰度质量。 +- **灰度门禁:已定义** +- **本地/容器化 Gate B:已通过** +- **本地/容器化 Gate C 回滚演练:已通过** +- **真实共享预生产环境 Gate B:待复跑** +- **Gate C 灰度监控与回滚演练:待完成** ---- +因此: -## 7. 当前版本状态 - -- **本文档版本**:v1.0 -- **生效日期**:2026-04-30 -- **下次审查**:灰度/回滚机制正式落地后 +> **现在可以说“灰度门禁框架已补齐”,但还不能说“灰度已经可执行上线”。** diff --git a/prd/SERVICE_SLA.md b/prd/SERVICE_SLA.md index 9d65ad7..c9409d7 100644 --- a/prd/SERVICE_SLA.md +++ b/prd/SERVICE_SLA.md @@ -1,126 +1,91 @@ # 客服 SLA 与升级响应规范 -> 版本:v1.0 | 状态:已生效 -> 关联:tech/INTERFACE.md、PRODUCTION_PHASE1_STATUS.md +> 版本:v1.1 +> 状态:已更新为灰度门禁口径 +> 关联:`docs/MONITORING_ALERTING.md`、`docs/GRAY_DASHBOARD_MINIMUM.md`、`prd/GRAY_RELEASE_ROLLBACK_RUNBOOK.md` --- -## 1. 客服 SLA 定义 +## 1. Phase 1 灰度期 SLA -### 1.1 核心 SLA 指标 +灰度期的 SLA 不是最终商业承诺,而是**是否继续放量**的门槛。 -| 指标 | 目标值 | 说明 | +### 1.1 核心灰度门槛 + +| 指标 | 目标值 | 用途 | |------|--------|------| -| Webhook 可用率 | ≥ 99.5% | 成功接收渠道消息的比率 | -| 首次响应时间(机器人) | ≤ 5s | 从收到消息到发出首字的时间(P95) | -| 机器人回答准确率 | ≥ 85% | FAQ 命中且用户未点"不满意" | -| 转人工率 | ≤ 15% | 需要人工介入的会话比例 | -| 工单响应时间 | ≤ 30min | 从创建到客服接单的时间(P95) | -| 工单解决时间 | ≤ 4h | 从创建到解决的时间(P95) | +| Webhook 成功率 | `>= 99%`(5 分钟窗口) | 是否允许继续放量 | +| Webhook 5xx | `< 1%`(5 分钟窗口) | 超阈值即停止放量 | +| Readiness 可用率 | `>= 99.5%` | 实例是否稳定接流量 | +| PostgreSQL 依赖异常 | `= 0`(5 分钟窗口) | 关键依赖门禁 | +| Audit 写入失败数 | `= 0`(5 分钟窗口) | 合规/追溯门禁 | +| Handoff 比率 | `<= 25%` 或不高于基线 `2x` | 判断机器人能力是否异常退化 | +| 工单接单时间 P95 | `<= 30 分钟` | 人工链路可承载性 | +| 工单解决时间 P95 | `<= 4 小时` | 最小服务能力 | -> **注**:上述指标为生产一期目标值,实际值需在灰度阶段采集并调整基线。 +### 1.2 灰度期分级 -### 1.2 SLA 优先级定义 - -| 优先级 | 定义 | 响应时间 | 解决时间 | -|--------|------|----------|----------| -| P1 | 机器人完全不可用(所有消息报错) | 15min | 1h | -| P2 | 核心能力降级(签名/幂等失效、频繁 5xx) | 30min | 2h | -| P3 | 非核心功能异常(部分渠道失败、偶发报错) | 2h | 8h | +| 级别 | 定义 | 响应时间 | 恢复目标 | +|------|------|----------|----------| +| P0 | 数据库不可用、全量 5xx、审计主链断裂 | 5 分钟 | 30 分钟内恢复或回滚 | +| P1 | 5xx > 1%、连续 readiness down、ticket 主链异常 | 15 分钟 | 1 小时内恢复或回滚 | +| P2 | reject 异常升高、handoff 比率异常、重启抖动 | 30 分钟 | 4 小时内恢复 | --- -## 2. 升级响应规范 +## 2. 升级与通知规则 -### 2.1 升级链路 - -``` -告警/故障发现 → P3 处理(值班工程师) → 若恶化升级 P2 → 若继续恶化升级 P1 -``` - -### 2.2 告警触发条件 - -| 条件 | 级别 | 通知方式 | -|------|------|----------| -| Webhook 可用率 < 99% 持续 5min | P2 | 飞书群 + 电话 | -| 错误率 > 5% 持续 5min | P2 | 飞书群 | -| PostgreSQL 连接失败 | P1 | 电话 + 飞书群 | -| 签名校验失败率 > 20% 持续 10min | P3 | 飞书群 | -| 工单积压 > 50 个 open 状态 | P3 | 飞书群 | - -> **注**:告警系统(metrics/tracing/SLO)属于 P1 缺口,**当前未落地**,告警触发依赖人工巡检。生产一期灰度阶段需补齐可观测性基础设施。 - -### 2.3 升级决策人 - -| 级别 | 第一响应人 | 升级对象 | -|------|------------|----------| -| P3 | 值班工程师 | Team Lead | -| P2 | Team Lead | 技术总监 | -| P1 | 技术总监 | 小龙/业务负责人 | - -### 2.4 故障处理要求 - -- P1/P2 故障:故障清除后 24h 内提交故障报告 -- P3 异常:记录在运营日志,下周一回溯复盘 -- 所有故障必须在下一灰度周期前完成根因分析 +| 触发条件 | 等级 | 通知 | +|----------|------|------| +| Webhook 5xx `> 5%` 持续 5 分钟 | P0 | 电话 + 飞书,立即回滚 | +| PostgreSQL 连接异常导致 `ready` 失败 | P0 | 电话 + 飞书,立即冻结放量 | +| Audit 写入失败数 `> 0` 持续 5 分钟 | P1 | 飞书,立即停止继续放量 | +| Handoff 比率 `> 25%` 或高于基线 `2x` | P2 | 飞书,需人工研判 | +| 单实例 10 分钟内重启 `> 2` 次 | P2 | 飞书,冻结当前档位 | --- -## 3. 当前阶段说明 +## 3. 当前实现与 SLA 的关系 -### 3.1 可用性现状 +### 3.1 已有支撑 -| 能力 | 当前状态 | 备注 | -|------|----------|------| -| Webhook 可用率监控 | 未完成 | P1 缺口,metrics/tracing 未落地 | -| 错误率监控 | 未完成 | 同上 | -| PostgreSQL 连接监控 | ✅ 已完成 | `/ready` 含 PostgreSQL 依赖检查 | -| 工单积压监控 | 未完成 | 无定时任务扫描 open 工单 | -| 安全拒绝事件审计 | ✅ 已完成 | `webhook_security.go` 的 `auditReject` 写入审计 | -| 工单状态流转审计 | ✅ 已完成 | `TicketWorkflowStore.writeAudit` 在 assign/resolve/close 时调用 | +- `live` / `ready` 探针已具备 +- PostgreSQL readiness 检查已接入 +- webhook HMAC / timestamp / dedup 已具备 +- ticket / audit / dedup 本地/容器化 Gate B 已证据化通过 -### 3.2 接口级 SLA(当前代码能力) +### 3.2 仍待落地 -以下为代码中已实现的接口响应时间基准(本地压测数据,待灰度验证): +- 真实共享预生产环境上的统一指标采集 +- 告警平台接入 +- 灰度阶段的自动统计和 dashboard -| 接口 | 目标延迟 | 当前状态 | -|------|----------|----------| -| `POST /webhook` | < 200ms P99 | HMAC 校验 + 幂等检查开销约 5-10ms | -| `GET /tickets` | < 300ms P99 | PostgreSQL 查询,无索引优化 | -| `POST /tickets/{id}/assign` | < 200ms P99 | 单条 UPDATE | -| `POST /tickets/{id}/resolve` | < 200ms P99 | 单条 UPDATE | -| `GET /actuator/health` | < 50ms | 依赖 PostgreSQL | +因此当前 SLA 结论应当理解为: -> **注**:当前压测数据为本地单实例,未经过真实渠道流量验证。 +> **门槛已定义,但真实共享预生产和灰度环境的观测接线仍需补齐。** --- -## 4. 错误码与 SLA 映射 +## 4. 与放量门禁的绑定 -错误码定义见 `tech/INTERFACE.md`,与 SLA 相关联的快速参考: +进入下一灰度档位前,必须满足: -| 错误码 | 含义 | SLA 影响 | -|--------|------|----------| -| `CS_SES_4001` | 会话不存在 | 返回 404,用户可重试 | -| `CS_SES_4002` | 消息频率过高 | 返回 429,触发限流逻辑 | -| `CS_TKT_4001` | 工单不存在 | 返回 404 | -| `CS_TKT_4002` | 工单已被分配 | 返回 409,幂等性保证 | -| `CS_LLM_5001` | LLM 服务不可用 | 触发转人工,SLA 降级 | -| `CS_LLM_5002` | LLM 超时 | 同上 | +1. 最近一个观察窗口 `webhook 5xx <= 0.5%` +2. `audit 写入失败数 = 0` +3. `postgres 连接异常 = 0` +4. 无连续 `readiness down` +5. handoff / ticket 指标没有异常飙升 + +任一条件不满足: + +- 不允许继续放量 +- 必要时触发回滚 --- -## 5. 持续改进 +## 5. 当前版本状态 -SLA 基线在灰度第一周期(建议 2 周)后复盘,根据真实数据调整: -- 若机器人响应时间 P95 > 5s,需优化 LLM 调用链路 -- 若转人工率 > 20%,需复盘意图识别准确率 -- 若工单解决时间 P95 > 4h,需增加客服人力或优化分流策略 +- 文档版本:`v1.1` +- 本次更新日期:`2026-05-04` +- 下次审查:灰度第一轮结束后 ---- - -## 6. 当前版本状态 - -- **本文档版本**:v1.0 -- **生效日期**:2026-04-30 -- **下次审查**:灰度第一周期结束后 diff --git a/prd/TICKET_OPERATIONS_SOP.md b/prd/TICKET_OPERATIONS_SOP.md index 7092e62..2b8dbcc 100644 --- a/prd/TICKET_OPERATIONS_SOP.md +++ b/prd/TICKET_OPERATIONS_SOP.md @@ -9,11 +9,11 @@ ``` 用户触发转人工 - → [待落地] 工单创建(含排队位置) + → 工单创建 → 客服接单(assign) → 客服处理 → 客服解决(resolve) - → [待明确] 工单关闭(close?) + → 客服/主管关闭(close) → 用户满意度反馈(可选) ``` @@ -25,8 +25,17 @@ |------|------|----------|--------------| | `open` | 待接单 | 转人工触发工单创建 | ✅ 已落地 | | `assigned` | 已分配 | 客服主动接单或系统分配 | ✅ 已落地 | -| `resolved` | 已解决 | 客服处理完毕 | ✅ 已落地 | -| `closed` | 已关闭 | 显式调用 close 接口 | ✅ 已落地(`TicketWorkflowStore.Close`) | +| `resolved` | 已给出处理结论,等待最终归档 | 已分配工单处理完毕后调用 `resolve` | ✅ 已落地 | +| `closed` | 已最终关闭,不允许再继续流转 | 仅 `resolved` 工单可调用 `close` | ✅ 已落地 | + +### 2.1 状态流转规则 + +| 当前状态 | 允许动作 | 下一个状态 | 不允许动作 | +|----------|----------|------------|------------| +| `open` | `assign` | `assigned` | `resolve` / `close` | +| `assigned` | `resolve` | `resolved` | 重复 `assign` / 直接 `close` | +| `resolved` | `close` | `closed` | 重复 `resolve` / 重复 `assign` | +| `closed` | 无 | 无 | `assign` / `resolve` / `close` | --- @@ -101,12 +110,10 @@ **接口**:`POST /api/v1/customer-service/tickets/{id}/resolve?resolution={resolution}` **流程**: -1. 客服处理完毕后调用 resolve +1. 已接单工单处理完毕后调用 resolve 2. 更新 ticket.status = `resolved`,ticket.resolution = resolution 3. 写入审计日志(✅ 已落地:调用 `TicketWorkflowStore.writeAudit`) - -**缺失项**: -- 工单状态流转审计 ✅ 已落地(`TicketWorkflowStore.writeAudit` 在 resolve 时调用) +4. `resolved` 表示已经有处理结论,但尚未最终关闭 --- @@ -117,14 +124,28 @@ **已落地**:`TicketWorkflowStore.Close` 接口已实现,支持显式关闭工单。 **语义定义**: -- `resolve` = 客服确认问题已解决,工单进入 `resolved` 状态 -- `close` = 工单正式关闭,进入 `closed` 状态(resolved 后可选调用) -- 已解决工单(resolved)可直接 close;未解决工单也可强制 close +- `resolve` = 客服确认已给出处理结论,工单进入 `resolved` +- `close` = 对 `resolved` 工单做最终归档,工单进入 `closed` +- 未 `resolved` 的工单不能直接 `close` +- `closed` 工单不能再次 `resolve` + +### 7.2 返回语义 + +| 场景 | HTTP | 错误码 | +|------|------|--------| +| 工单不存在 | `404` | `CS_TICKET_4001` | +| 非法 `resolve` 状态流转 | `409` | `CS_TICKET_4092` | +| 非法 `close` 状态流转 | `409` | `CS_TICKET_4093` | --- ## 8. 客服工作台操作规范(API 层) +受保护接口必须携带: + +- `X-CS-Actor-ID` +- `X-CS-Actor-Role` + ### 8.1 班次开始 1. 调用 `GET /api/v1/customer-service/tickets?status=open` 查看当前待接单工单 @@ -144,7 +165,17 @@ curl -X POST "https://{host}/api/v1/customer-service/tickets/{ticket_id}/assign? curl -X POST "https://{host}/api/v1/customer-service/tickets/{ticket_id}/resolve?resolution={解决说明}" ``` -### 8.4 工单列表查询 +成功后工单状态变为 `resolved`。 + +### 8.4 最终关闭 + +```bash +curl -X POST "https://{host}/api/v1/customer-service/tickets/{ticket_id}/close?resolution={最终结论}" +``` + +只有 `resolved` 工单可以执行该操作,成功后状态变为 `closed`。 + +### 8.5 工单列表查询 ```bash # 查看所有 open 工单 diff --git a/scripts/verify_gate_c_rollback.sh b/scripts/verify_gate_c_rollback.sh new file mode 100644 index 0000000..d17a169 --- /dev/null +++ b/scripts/verify_gate_c_rollback.sh @@ -0,0 +1,391 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +RUN_ID="${RUN_ID:-gatec-rollback-$(date +%Y%m%d%H%M%S)}" +ARTIFACT_DIR="${ARTIFACT_DIR:-/tmp/ai-customer-service-gate-c-rollback/$RUN_ID}" +GO_HELPER_DIR="$ROOT_DIR/.tmp/verify_gate_c_rollback/$RUN_ID" +SUMMARY_FILE="$ARTIFACT_DIR/summary.txt" +BASELINE_LOG_FILE="$ARTIFACT_DIR/baseline-service.log" +BROKEN_LOG_FILE="$ARTIFACT_DIR/broken-service.log" +ROLLED_BACK_LOG_FILE="$ARTIFACT_DIR/rolled-back-service.log" +DEFAULT_APP_BIN="$ARTIFACT_DIR/ai-customer-service" +APP_BIN="${APP_BIN:-$DEFAULT_APP_BIN}" + +mkdir -p "$ARTIFACT_DIR" +mkdir -p "$GO_HELPER_DIR" + +PASS_COUNT=0 +FAIL_COUNT=0 +APP_PID="" +BASE_URL="" +BROKEN_AI_CS_POSTGRES_DSN="${BROKEN_AI_CS_POSTGRES_DSN:-}" + +log() { + printf '%s\n' "$*" | tee -a "$SUMMARY_FILE" +} + +pass() { + PASS_COUNT=$((PASS_COUNT + 1)) + log "[PASS] $*" +} + +fail() { + FAIL_COUNT=$((FAIL_COUNT + 1)) + log "[FAIL] $*" + exit 1 +} + +require_cmd() { + if ! command -v "$1" >/dev/null 2>&1; then + fail "missing command: $1" + fi +} + +require_env() { + local key="$1" + if [[ -z "${!key:-}" ]]; then + fail "missing required env: $key" + fi +} + +stop_service() { + if [[ -n "$APP_PID" ]] && kill -0 "$APP_PID" >/dev/null 2>&1; then + kill "$APP_PID" >/dev/null 2>&1 || true + wait "$APP_PID" >/dev/null 2>&1 || true + fi + APP_PID="" +} + +cleanup() { + stop_service +} + +trap cleanup EXIT + +extract_base_url() { + local addr="$1" + local host="" + local port="" + if [[ "$addr" == :* ]]; then + host="127.0.0.1" + port="${addr#:}" + else + host="${addr%:*}" + port="${addr##*:}" + if [[ -z "$host" || "$host" == "$addr" ]]; then + fail "AI_CS_ADDR must be host:port or :port, got: $addr" + fi + if [[ "$host" == "0.0.0.0" ]]; then + host="127.0.0.1" + fi + fi + printf 'http://%s:%s' "$host" "$port" +} + +derive_broken_dsn() { + python3 - "$AI_CS_POSTGRES_DSN" <<'PY' +import re +import sys +dsn = sys.argv[1] +if dsn.startswith("postgres://") or dsn.startswith("postgresql://"): + if re.search(r":\d+/", dsn): + print(re.sub(r":\d+/", ":1/", dsn, count=1), end="") + else: + print(dsn, end="") +elif "port=" in dsn: + print(re.sub(r"port=\d+", "port=1", dsn, count=1), end="") +else: + print(f"{dsn} port=1", end="") +PY +} + +DB_QUERY_HELPER="$GO_HELPER_DIR/db_query.go" + +cat >"$DB_QUERY_HELPER" <<'EOF' +package main + +import ( + "database/sql" + "fmt" + "os" + + _ "github.com/lib/pq" +) + +func main() { + dsn := os.Getenv("DB_DSN") + query := os.Getenv("SQL_QUERY") + if dsn == "" || query == "" { + fmt.Fprintln(os.Stderr, "DB_DSN and SQL_QUERY are required") + os.Exit(2) + } + db, err := sql.Open("postgres", dsn) + if err != nil { + fmt.Fprintln(os.Stderr, err.Error()) + os.Exit(2) + } + defer db.Close() + + if err := db.Ping(); err != nil { + fmt.Fprintln(os.Stderr, err.Error()) + os.Exit(2) + } + + var value string + if err := db.QueryRow(query).Scan(&value); err != nil { + fmt.Fprintln(os.Stderr, err.Error()) + os.Exit(2) + } + fmt.Print(value) +} +EOF + +db_value() { + local sql="$1" + DB_DSN="$AI_CS_POSTGRES_DSN" SQL_QUERY="$sql" go run "$DB_QUERY_HELPER" +} + +assert_eq() { + local actual="$1" + local expected="$2" + local message="$3" + if [[ "$actual" != "$expected" ]]; then + fail "$message (got=$actual want=$expected)" + fi + pass "$message" +} + +assert_nonzero_count() { + local actual="$1" + local message="$2" + if [[ "$actual" =~ ^[1-9][0-9]*$ ]]; then + pass "$message" + return + fi + fail "$message (got=$actual want>=1)" +} + +start_service_with_env() { + local dsn="$1" + local log_file="$2" + stop_service + ( + cd "$ROOT_DIR" + AI_CS_RUNTIME_ENV="$AI_CS_RUNTIME_ENV" \ + AI_CS_ADDR="$AI_CS_ADDR" \ + AI_CS_POSTGRES_ENABLED="$AI_CS_POSTGRES_ENABLED" \ + AI_CS_POSTGRES_DSN="$dsn" \ + AI_CS_POSTGRES_MIGRATION_DIR="$AI_CS_POSTGRES_MIGRATION_DIR" \ + AI_CS_WEBHOOK_SECRET="$AI_CS_WEBHOOK_SECRET" \ + AI_CS_WEBHOOK_TIMESTAMP_HEADER="$AI_CS_WEBHOOK_TIMESTAMP_HEADER" \ + AI_CS_WEBHOOK_SIGNATURE_HEADER="$AI_CS_WEBHOOK_SIGNATURE_HEADER" \ + AI_CS_WEBHOOK_MAX_SKEW_SECONDS="$AI_CS_WEBHOOK_MAX_SKEW_SECONDS" \ + "$APP_BIN" + ) >"$log_file" 2>&1 & + APP_PID=$! +} + +wait_ready() { + local log_file="$1" + local ready_ok="" + for _ in $(seq 1 30); do + if curl -fsS "$BASE_URL/actuator/health/live" >/dev/null 2>&1 && curl -fsS "$BASE_URL/actuator/health/ready" >/dev/null 2>&1; then + ready_ok="yes" + break + fi + sleep 1 + done + if [[ "$ready_ok" != "yes" ]]; then + tail -100 "$log_file" | tee -a "$SUMMARY_FILE" >/dev/null || true + fail "service did not become live+ready" + fi +} + +wait_broken_startup() { + local log_file="$1" + for _ in $(seq 1 12); do + if [[ -n "$APP_PID" ]] && ! kill -0 "$APP_PID" >/dev/null 2>&1; then + pass "broken release process exited as expected" + return + fi + if curl -fsS "$BASE_URL/actuator/health/ready" >/dev/null 2>&1; then + tail -100 "$log_file" | tee -a "$SUMMARY_FILE" >/dev/null || true + fail "broken release unexpectedly became ready" + fi + sleep 1 + done + if curl -fsS "$BASE_URL/actuator/health/ready" >/dev/null 2>&1; then + tail -100 "$log_file" | tee -a "$SUMMARY_FILE" >/dev/null || true + fail "broken release unexpectedly became ready after timeout" + fi + pass "broken release never became ready" +} + +send_signed_webhook() { + local message_id="$1" + local open_id="$2" + local response_file="$3" + local body_file="$ARTIFACT_DIR/${message_id}.json" + MESSAGE_ID="$message_id" OPEN_ID="$open_id" python3 >"$body_file" <<'PY' +import json +import os +import sys + +payload = { + "message_id": os.environ["MESSAGE_ID"], + "channel": "widget", + "open_id": os.environ["OPEN_ID"], + "content": "我要退款", +} +sys.stdout.write(json.dumps(payload, ensure_ascii=False, separators=(",", ":"))) +PY + + local ts + ts="$(date +%s)" + local sig + sig="$(python3 - "$ts" "$body_file" "$AI_CS_WEBHOOK_SECRET" <<'PY' +import hashlib +import hmac +import sys + +timestamp, body_path, secret = sys.argv[1], sys.argv[2], sys.argv[3] +with open(body_path, "rb") as fh: + body = fh.read() +payload = timestamp.encode("utf-8") + b"." + body +print(hmac.new(secret.encode("utf-8"), payload, hashlib.sha256).hexdigest(), end="") +PY +)" + + curl -sS -o "$response_file" -w '%{http_code}' \ + -X POST "$BASE_URL/api/v1/customer-service/webhook" \ + -H "Content-Type: application/json" \ + -H "$AI_CS_WEBHOOK_TIMESTAMP_HEADER: $ts" \ + -H "$AI_CS_WEBHOOK_SIGNATURE_HEADER: $sig" \ + --data-binary "@$body_file" +} + +extract_response_field() { + local response_file="$1" + local field="$2" + python3 - "$response_file" "$field" <<'PY' +import json +import sys +with open(sys.argv[1], "r", encoding="utf-8") as fh: + data = json.load(fh) +value = data.get(sys.argv[2], "") +if isinstance(value, bool): + print(str(value).lower(), end="") +else: + print(value, end="") +PY +} + +log "# verify_gate_c_rollback.sh" +log "run_id=$RUN_ID" +log "artifact_dir=$ARTIFACT_DIR" +log "root_dir=$ROOT_DIR" + +require_cmd curl +require_cmd go +require_cmd openssl +require_cmd python3 +pass "required commands available" + +require_env AI_CS_RUNTIME_ENV +require_env AI_CS_ADDR +require_env AI_CS_POSTGRES_ENABLED +require_env AI_CS_POSTGRES_DSN +require_env AI_CS_POSTGRES_MIGRATION_DIR +require_env AI_CS_WEBHOOK_SECRET + +AI_CS_WEBHOOK_TIMESTAMP_HEADER="${AI_CS_WEBHOOK_TIMESTAMP_HEADER:-X-CS-Timestamp}" +AI_CS_WEBHOOK_SIGNATURE_HEADER="${AI_CS_WEBHOOK_SIGNATURE_HEADER:-X-CS-Signature}" +AI_CS_WEBHOOK_MAX_SKEW_SECONDS="${AI_CS_WEBHOOK_MAX_SKEW_SECONDS:-300}" +BASE_URL="$(extract_base_url "$AI_CS_ADDR")" + +if [[ -z "$BROKEN_AI_CS_POSTGRES_DSN" ]]; then + BROKEN_AI_CS_POSTGRES_DSN="$(derive_broken_dsn)" +fi + +assert_eq "$AI_CS_RUNTIME_ENV" "production" "runtime env is production" +assert_eq "$AI_CS_POSTGRES_ENABLED" "true" "postgres mode enabled for rollback drill" + +if [[ ! -d "$AI_CS_POSTGRES_MIGRATION_DIR" ]]; then + fail "migration dir not found: $AI_CS_POSTGRES_MIGRATION_DIR" +fi +pass "migration dir exists: $AI_CS_POSTGRES_MIGRATION_DIR" + +if [[ "$APP_BIN" == "$DEFAULT_APP_BIN" ]]; then + ( + cd "$ROOT_DIR" + go build -o "$APP_BIN" ./cmd/ai-customer-service + ) + pass "built current source into rollback drill app binary: $APP_BIN" +elif [[ ! -x "$APP_BIN" ]]; then + fail "app binary is not executable: $APP_BIN" +else + pass "using provided executable app binary: $APP_BIN" +fi + +if [[ -n "$(db_value "SELECT '1'")" ]]; then + pass "postgres connectivity check passed" +else + fail "postgres connectivity check returned empty result" +fi + +BASELINE_MESSAGE_ID="${RUN_ID}-baseline-message" +BASELINE_OPEN_ID="${RUN_ID}-baseline-open" +BASELINE_RESP_FILE="$ARTIFACT_DIR/baseline_webhook_response.json" + +start_service_with_env "$AI_CS_POSTGRES_DSN" "$BASELINE_LOG_FILE" +pass "baseline service process started (pid=$APP_PID)" +wait_ready "$BASELINE_LOG_FILE" +pass "baseline service live and ready probes passed" + +HTTP_CODE="$(send_signed_webhook "$BASELINE_MESSAGE_ID" "$BASELINE_OPEN_ID" "$BASELINE_RESP_FILE")" +assert_eq "$HTTP_CODE" "200" "baseline signed webhook request returned HTTP 200" +assert_eq "$(extract_response_field "$BASELINE_RESP_FILE" "received")" "true" "baseline webhook response received=true" +assert_eq "$(extract_response_field "$BASELINE_RESP_FILE" "handoff")" "true" "baseline webhook response handoff=true" + +stop_service +pass "baseline service stopped before broken release" + +start_service_with_env "$BROKEN_AI_CS_POSTGRES_DSN" "$BROKEN_LOG_FILE" +pass "broken release process started (pid=$APP_PID)" +wait_broken_startup "$BROKEN_LOG_FILE" + +start_service_with_env "$AI_CS_POSTGRES_DSN" "$ROLLED_BACK_LOG_FILE" +pass "rollback restart process started (pid=$APP_PID)" +wait_ready "$ROLLED_BACK_LOG_FILE" +pass "rolled-back service live and ready probes passed" + +ROLLED_BACK_MESSAGE_ID="${RUN_ID}-rollback-message" +ROLLED_BACK_OPEN_ID="${RUN_ID}-rollback-open" +ROLLED_BACK_RESP_FILE="$ARTIFACT_DIR/rolled_back_webhook_response.json" +HTTP_CODE="$(send_signed_webhook "$ROLLED_BACK_MESSAGE_ID" "$ROLLED_BACK_OPEN_ID" "$ROLLED_BACK_RESP_FILE")" +assert_eq "$HTTP_CODE" "200" "rolled-back signed webhook request returned HTTP 200" +assert_eq "$(extract_response_field "$ROLLED_BACK_RESP_FILE" "received")" "true" "rolled-back webhook response received=true" +assert_eq "$(extract_response_field "$ROLLED_BACK_RESP_FILE" "handoff")" "true" "rolled-back webhook response handoff=true" + +ROLLED_BACK_TICKET_ID="$(extract_response_field "$ROLLED_BACK_RESP_FILE" "ticket_id")" +ROLLED_BACK_SESSION_ID="$(extract_response_field "$ROLLED_BACK_RESP_FILE" "session_id")" +if [[ -z "$ROLLED_BACK_TICKET_ID" || -z "$ROLLED_BACK_SESSION_ID" ]]; then + fail "rolled-back webhook response missing ticket_id or session_id" +fi +pass "rolled-back webhook response returned ticket_id and session_id" + +assert_eq "$(db_value "SELECT status FROM cs_tickets WHERE id = '$ROLLED_BACK_TICKET_ID'::uuid")" "open" "rolled-back webhook created open ticket" +assert_nonzero_count "$(db_value "SELECT COUNT(*)::text FROM cs_message_dedup WHERE channel = 'widget' AND message_id = '$ROLLED_BACK_MESSAGE_ID'")" "rolled-back webhook persisted dedup row" +assert_nonzero_count "$(db_value "SELECT COUNT(*)::text FROM cs_audit_logs WHERE object_type = 'message_processed' AND action = 'process' AND actor_id = '$ROLLED_BACK_OPEN_ID'")" "rolled-back webhook persisted message_processed audit" +assert_nonzero_count "$(db_value "SELECT COUNT(*)::text FROM cs_tickets t JOIN cs_sessions s ON s.id = t.session_id WHERE s.channel = 'widget' AND s.open_id = '$ROLLED_BACK_OPEN_ID'")" "rolled-back webhook persisted ticket linked to session" + +pass "gate-c rollback drill completed successfully" +log "baseline_message_id=$BASELINE_MESSAGE_ID" +log "rolled_back_message_id=$ROLLED_BACK_MESSAGE_ID" +log "rolled_back_ticket_id=$ROLLED_BACK_TICKET_ID" +log "rolled_back_session_id=$ROLLED_BACK_SESSION_ID" +log "baseline_log_file=$BASELINE_LOG_FILE" +log "broken_log_file=$BROKEN_LOG_FILE" +log "rolled_back_log_file=$ROLLED_BACK_LOG_FILE" +log "summary: pass=$PASS_COUNT fail=$FAIL_COUNT" diff --git a/scripts/verify_preprod_gate_b.sh b/scripts/verify_preprod_gate_b.sh new file mode 100755 index 0000000..2e01439 --- /dev/null +++ b/scripts/verify_preprod_gate_b.sh @@ -0,0 +1,336 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +RUN_ID="${RUN_ID:-gateb-$(date +%Y%m%d%H%M%S)}" +ARTIFACT_DIR="${ARTIFACT_DIR:-/tmp/ai-customer-service-preprod-gate-b/$RUN_ID}" +GO_HELPER_DIR="$ROOT_DIR/.tmp/verify_preprod_gate_b/$RUN_ID" +LOG_FILE="$ARTIFACT_DIR/service.log" +WEBHOOK_BODY_FILE="$ARTIFACT_DIR/webhook_body.json" +WEBHOOK_RESP_FILE="$ARTIFACT_DIR/webhook_response.json" +WEBHOOK_HEADERS_FILE="$ARTIFACT_DIR/webhook_headers.txt" +DEDUP_RESP_FILE="$ARTIFACT_DIR/dedup_response.json" +SUMMARY_FILE="$ARTIFACT_DIR/summary.txt" +DEFAULT_APP_BIN="$ARTIFACT_DIR/ai-customer-service" +APP_BIN="${APP_BIN:-$DEFAULT_APP_BIN}" + +mkdir -p "$ARTIFACT_DIR" +mkdir -p "$GO_HELPER_DIR" + +PASS_COUNT=0 +FAIL_COUNT=0 +APP_PID="" +TICKET_ID="" +SESSION_ID="" +MESSAGE_ID="" +OPEN_ID="" + +log() { + printf '%s\n' "$*" | tee -a "$SUMMARY_FILE" +} + +pass() { + PASS_COUNT=$((PASS_COUNT + 1)) + log "[PASS] $*" +} + +fail() { + FAIL_COUNT=$((FAIL_COUNT + 1)) + log "[FAIL] $*" + exit 1 +} + +require_cmd() { + if ! command -v "$1" >/dev/null 2>&1; then + fail "missing command: $1" + fi +} + +require_env() { + local key="$1" + if [[ -z "${!key:-}" ]]; then + fail "missing required env: $key" + fi +} + +cleanup() { + if [[ -n "$APP_PID" ]] && kill -0 "$APP_PID" >/dev/null 2>&1; then + kill "$APP_PID" >/dev/null 2>&1 || true + wait "$APP_PID" >/dev/null 2>&1 || true + fi +} + +trap cleanup EXIT + +extract_base_url() { + local addr="$1" + local host="" + local port="" + if [[ "$addr" == :* ]]; then + host="127.0.0.1" + port="${addr#:}" + else + host="${addr%:*}" + port="${addr##*:}" + if [[ -z "$host" || "$host" == "$addr" ]]; then + fail "AI_CS_ADDR must be host:port or :port, got: $addr" + fi + if [[ "$host" == "0.0.0.0" ]]; then + host="127.0.0.1" + fi + fi + printf 'http://%s:%s' "$host" "$port" +} + +DB_QUERY_HELPER="$GO_HELPER_DIR/db_query.go" + +cat >"$DB_QUERY_HELPER" <<'EOF' +package main + +import ( + "database/sql" + "fmt" + "os" + + _ "github.com/lib/pq" +) + +func main() { + dsn := os.Getenv("DB_DSN") + query := os.Getenv("SQL_QUERY") + if dsn == "" || query == "" { + fmt.Fprintln(os.Stderr, "DB_DSN and SQL_QUERY are required") + os.Exit(2) + } + db, err := sql.Open("postgres", dsn) + if err != nil { + fmt.Fprintln(os.Stderr, err.Error()) + os.Exit(2) + } + defer db.Close() + + if err := db.Ping(); err != nil { + fmt.Fprintln(os.Stderr, err.Error()) + os.Exit(2) + } + + var value string + if err := db.QueryRow(query).Scan(&value); err != nil { + fmt.Fprintln(os.Stderr, err.Error()) + os.Exit(2) + } + fmt.Print(value) +} +EOF + +db_value() { + local sql="$1" + DB_DSN="$AI_CS_POSTGRES_DSN" SQL_QUERY="$sql" go run "$DB_QUERY_HELPER" +} + +assert_eq() { + local actual="$1" + local expected="$2" + local message="$3" + if [[ "$actual" != "$expected" ]]; then + fail "$message (got=$actual want=$expected)" + fi + pass "$message" +} + +log "# verify_preprod_gate_b.sh" +log "run_id=$RUN_ID" +log "artifact_dir=$ARTIFACT_DIR" +log "root_dir=$ROOT_DIR" + +require_cmd curl +require_cmd go +require_cmd openssl +require_cmd python3 +pass "required commands available" + +require_env AI_CS_RUNTIME_ENV +require_env AI_CS_ADDR +require_env AI_CS_POSTGRES_ENABLED +require_env AI_CS_POSTGRES_DSN +require_env AI_CS_POSTGRES_MIGRATION_DIR +require_env AI_CS_WEBHOOK_SECRET + +AI_CS_WEBHOOK_TIMESTAMP_HEADER="${AI_CS_WEBHOOK_TIMESTAMP_HEADER:-X-CS-Timestamp}" +AI_CS_WEBHOOK_SIGNATURE_HEADER="${AI_CS_WEBHOOK_SIGNATURE_HEADER:-X-CS-Signature}" +AI_CS_WEBHOOK_MAX_SKEW_SECONDS="${AI_CS_WEBHOOK_MAX_SKEW_SECONDS:-300}" +BASE_URL="$(extract_base_url "$AI_CS_ADDR")" + +assert_eq "$AI_CS_RUNTIME_ENV" "production" "runtime env is production" +assert_eq "$AI_CS_POSTGRES_ENABLED" "true" "postgres mode enabled for gate-b validation" + +if [[ ! -d "$AI_CS_POSTGRES_MIGRATION_DIR" ]]; then + fail "migration dir not found: $AI_CS_POSTGRES_MIGRATION_DIR" +fi +pass "migration dir exists: $AI_CS_POSTGRES_MIGRATION_DIR" + +if [[ "$APP_BIN" == "$DEFAULT_APP_BIN" ]]; then + ( + cd "$ROOT_DIR" + go build -o "$APP_BIN" ./cmd/ai-customer-service + ) + pass "built current source into gate-b app binary: $APP_BIN" +elif [[ ! -x "$APP_BIN" ]]; then + fail "app binary is not executable: $APP_BIN" +else + pass "using provided executable app binary: $APP_BIN" +fi + +if [[ -n "$(db_value "SELECT '1'")" ]]; then + pass "postgres connectivity check passed" +else + fail "postgres connectivity check returned empty result" +fi + +if [[ "$(db_value "SELECT CASE WHEN EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema='public' AND table_name='cs_schema_migrations') THEN 'true' ELSE 'false' END")" != "true" ]]; then + fail "cs_schema_migrations table missing" +fi +pass "migration bookkeeping table exists" + +if [[ "$(db_value "SELECT CASE WHEN EXISTS (SELECT 1 FROM cs_schema_migrations WHERE version='0001_init') THEN 'true' ELSE 'false' END")" != "true" ]]; then + fail "required migration version 0001_init not recorded" +fi +pass "migration version 0001_init is recorded" + +( + cd "$ROOT_DIR" + "$APP_BIN" +) >"$LOG_FILE" 2>&1 & +APP_PID=$! +pass "service process started (pid=$APP_PID)" + +ready_ok="" +for _ in $(seq 1 30); do + if curl -fsS "$BASE_URL/actuator/health/live" >/dev/null 2>&1 && curl -fsS "$BASE_URL/actuator/health/ready" >/dev/null 2>&1; then + ready_ok="yes" + break + fi + sleep 1 +done +if [[ "$ready_ok" != "yes" ]]; then + tail -100 "$LOG_FILE" | tee -a "$SUMMARY_FILE" >/dev/null + fail "service did not become live+ready" +fi +pass "service live and ready probes passed" + +MESSAGE_ID="${RUN_ID}-message" +OPEN_ID="${RUN_ID}-open" +export MESSAGE_ID OPEN_ID +python3 >"$WEBHOOK_BODY_FILE" <<'PY' +import json +import os +import sys + +payload = { + "message_id": os.environ["MESSAGE_ID"], + "channel": "widget", + "open_id": os.environ["OPEN_ID"], + "content": "我要退款", +} +sys.stdout.write(json.dumps(payload, ensure_ascii=False, separators=(",", ":"))) +PY + +TS="$(date +%s)" +SIG="$(python3 - "$TS" "$WEBHOOK_BODY_FILE" "$AI_CS_WEBHOOK_SECRET" <<'PY' +import hashlib +import hmac +import sys + +timestamp, body_path, secret = sys.argv[1], sys.argv[2], sys.argv[3] +with open(body_path, "rb") as fh: + body = fh.read() +payload = timestamp.encode("utf-8") + b"." + body +print(hmac.new(secret.encode("utf-8"), payload, hashlib.sha256).hexdigest(), end="") +PY +)" +HTTP_CODE="$(curl -sS -o "$WEBHOOK_RESP_FILE" -D "$WEBHOOK_HEADERS_FILE" -w '%{http_code}' \ + -X POST "$BASE_URL/api/v1/customer-service/webhook" \ + -H "Content-Type: application/json" \ + -H "$AI_CS_WEBHOOK_TIMESTAMP_HEADER: $TS" \ + -H "$AI_CS_WEBHOOK_SIGNATURE_HEADER: $SIG" \ + --data-binary "@$WEBHOOK_BODY_FILE")" +assert_eq "$HTTP_CODE" "200" "signed webhook request returned HTTP 200" + +readarray -t WEBHOOK_PARSED < <(python3 - "$WEBHOOK_RESP_FILE" <<'PY' +import json +import sys + +with open(sys.argv[1], "r", encoding="utf-8") as fh: + data = json.load(fh) +print(str(data.get("received", False)).lower()) +print(str(data.get("handoff", False)).lower()) +print(data.get("ticket_id", "")) +print(data.get("session_id", "")) +print(data.get("reply", "")) +PY +) + +assert_eq "${WEBHOOK_PARSED[0]}" "true" "webhook response received=true" +assert_eq "${WEBHOOK_PARSED[1]}" "true" "webhook response handoff=true" +TICKET_ID="${WEBHOOK_PARSED[2]}" +SESSION_ID="${WEBHOOK_PARSED[3]}" +if [[ -z "$TICKET_ID" || -z "$SESSION_ID" ]]; then + fail "webhook response missing ticket_id or session_id" +fi +pass "webhook response returned ticket_id and session_id" + +assert_eq "$(db_value "SELECT status FROM cs_tickets WHERE id = '$TICKET_ID'::uuid")" "open" "ticket inserted in postgres with open status" +assert_eq "$(db_value "SELECT COUNT(*)::text FROM cs_audit_logs WHERE object_type = 'message_processed' AND object_id = '$SESSION_ID' AND action = 'process' AND after_state->>'ticket_id' = '$TICKET_ID'")" "1" "message_processed audit row persisted with ticket_id" +assert_eq "$(db_value "SELECT COUNT(*)::text FROM cs_message_dedup WHERE channel = 'widget' AND message_id = '$MESSAGE_ID'")" "1" "dedup row created for first webhook message" + +HTTP_CODE="$(curl -sS -o "$DEDUP_RESP_FILE" -w '%{http_code}' \ + -X POST "$BASE_URL/api/v1/customer-service/webhook" \ + -H "Content-Type: application/json" \ + -H "$AI_CS_WEBHOOK_TIMESTAMP_HEADER: $TS" \ + -H "$AI_CS_WEBHOOK_SIGNATURE_HEADER: $SIG" \ + --data-binary "@$WEBHOOK_BODY_FILE")" +assert_eq "$HTTP_CODE" "200" "duplicate webhook request returned HTTP 200" + +DEDUP_REPLY="$(python3 - "$DEDUP_RESP_FILE" <<'PY' +import json +import sys + +with open(sys.argv[1], "r", encoding="utf-8") as fh: + data = json.load(fh) +print(data.get("reply", "")) +PY +)" +assert_eq "$DEDUP_REPLY" "duplicate message ignored" "duplicate webhook request is deduplicated" +assert_eq "$(db_value "SELECT COUNT(*)::text FROM cs_message_dedup WHERE channel = 'widget' AND message_id = '$MESSAGE_ID'")" "1" "dedup table still contains exactly one row for duplicate message" + +HTTP_CODE="$(curl -sS -o /dev/null -w '%{http_code}' \ + -X POST "$BASE_URL/api/v1/customer-service/tickets/$TICKET_ID/assign?agent_id=gate-b-agent" \ + -H "X-CS-Actor-ID: gate-b-supervisor" \ + -H "X-CS-Actor-Role: supervisor")" +assert_eq "$HTTP_CODE" "200" "ticket assign API returned HTTP 200" +assert_eq "$(db_value "SELECT status FROM cs_tickets WHERE id = '$TICKET_ID'::uuid")" "assigned" "ticket status becomes assigned after assign" + +HTTP_CODE="$(curl -sS -o /dev/null -w '%{http_code}' \ + -X POST "$BASE_URL/api/v1/customer-service/tickets/$TICKET_ID/resolve?resolution=handled-by-gate-b" \ + -H "X-CS-Actor-ID: gate-b-agent" \ + -H "X-CS-Actor-Role: agent")" +assert_eq "$HTTP_CODE" "200" "ticket resolve API returned HTTP 200" +assert_eq "$(db_value "SELECT status FROM cs_tickets WHERE id = '$TICKET_ID'::uuid")" "resolved" "ticket status becomes resolved after resolve" + +HTTP_CODE="$(curl -sS -o /dev/null -w '%{http_code}' \ + -X POST "$BASE_URL/api/v1/customer-service/tickets/$TICKET_ID/close?resolution=confirmed-by-gate-b" \ + -H "X-CS-Actor-ID: gate-b-supervisor" \ + -H "X-CS-Actor-Role: supervisor")" +assert_eq "$HTTP_CODE" "200" "ticket close API returned HTTP 200" +assert_eq "$(db_value "SELECT status FROM cs_tickets WHERE id = '$TICKET_ID'::uuid")" "closed" "ticket status becomes closed after close" + +assert_eq "$(db_value "SELECT COUNT(*)::text FROM cs_audit_logs WHERE object_type = 'ticket' AND object_id = '$TICKET_ID' AND action = 'assign'")" "2" "assign audit persisted in both workflow store and handler layers" +assert_eq "$(db_value "SELECT COUNT(*)::text FROM cs_audit_logs WHERE object_type = 'ticket' AND object_id = '$TICKET_ID' AND action = 'resolve'")" "2" "resolve audit persisted in both workflow store and handler layers" +assert_eq "$(db_value "SELECT COUNT(*)::text FROM cs_audit_logs WHERE object_type = 'ticket' AND object_id = '$TICKET_ID' AND action = 'close'")" "2" "close audit persisted in both workflow store and handler layers" + +pass "gate-b verification completed successfully" +log "ticket_id=$TICKET_ID" +log "session_id=$SESSION_ID" +log "message_id=$MESSAGE_ID" +log "log_file=$LOG_FILE" +log "summary: pass=$PASS_COUNT fail=$FAIL_COUNT" diff --git a/tech/INTERFACE.md b/tech/INTERFACE.md index 1359a8f..4910d7e 100644 --- a/tech/INTERFACE.md +++ b/tech/INTERFACE.md @@ -271,13 +271,28 @@ type LLMOptions struct { | 方法 | 路径 | 描述 | |------|------|------| -| GET | `/api/v1/customer-service/tickets` | 列表工单 | +| GET | `/api/v1/customer-service/tickets` | 列表 open / assigned / processing 工单 | | GET | `/api/v1/customer-service/tickets/{id}` | 获取工单 | -| POST | `/api/v1/customer-service/tickets/{id}/assign` | 分配工单 | -| POST | `/api/v1/customer-service/tickets/{id}/resolve` | 解决工单 | -| POST | `/api/v1/customer-service/tickets/{id}/close` | 关闭工单 | +| POST | `/api/v1/customer-service/tickets/{id}/assign?agent_id={agent_id}` | 将 `open` 工单分配给客服 | +| POST | `/api/v1/customer-service/tickets/{id}/resolve?resolution={resolution}` | 将 `assigned`/`processing` 工单标记为 `resolved` | +| POST | `/api/v1/customer-service/tickets/{id}/close?resolution={resolution}` | 将 `resolved` 工单最终关闭为 `closed` | | GET | `/api/v1/customer-service/tickets/stats` | 工单统计 | +#### 工单状态机 + +| 当前状态 | 允许动作 | 目标状态 | +|----------|----------|----------| +| `open` | `assign` | `assigned` | +| `assigned` | `resolve` | `resolved` | +| `processing` | `resolve` | `resolved` | +| `resolved` | `close` | `closed` | +| `closed` | 无 | 无 | + +受保护工单接口使用请求头鉴权: + +- `X-CS-Actor-ID` +- `X-CS-Actor-Role` + #### 知识库 | 方法 | 路径 | 描述 | @@ -307,8 +322,10 @@ type LLMOptions struct { | `CS_SES_4003` | 403 | 身份校验已锁定 | | `CS_IDT_4001` | 400 | 身份信息不匹配 | | `CS_IDT_4002` | 400 | 验证码错误 | -| `CS_TKT_4001` | 404 | 工单不存在 | +| `CS_TICKET_4001` | 404 | 工单不存在 | | `CS_TKT_4002` | 409 | 工单已被分配 | +| `CS_TICKET_4092` | 409 | 工单状态不允许 resolve | +| `CS_TICKET_4093` | 409 | 工单状态不允许 close | | `CS_KB_4001` | 404 | 知识库条目不存在 | | `CS_KB_4002` | 409 | 条目名称已存在 | | `CS_LLM_5001` | 503 | LLM 服务不可用 | diff --git a/test/QA_GATE_STATUS.md b/test/QA_GATE_STATUS.md index b219f89..7e8f8ce 100644 --- a/test/QA_GATE_STATUS.md +++ b/test/QA_GATE_STATUS.md @@ -9,7 +9,7 @@ ## 0. 阶段门控结论 -- **当前结论:CONDITIONAL_PASS(代码级) / REQUEST_CHANGES(预生产与生产门禁)** +- **当前结论:CONDITIONAL_PASS(代码级 + 本地/容器化 Gate B 预演 + 本地/容器化 Gate C 回滚演练) / REQUEST_CHANGES(真实预生产与生产放量门禁)** - **是否可进入下一阶段(按“生产可直接上线”口径放行):否** - **是否可进入预生产整改 / 灰度准备:是,但前提是继续完成剩余 P0/P1 真实环境项** @@ -23,8 +23,8 @@ 4. **配置契约、执行板、QA 文档已同步回写** 当前剩余阻断已收敛到: -1. **真实环境门禁(DB / migration / webhook 联调 / 入库验证)未闭环** -2. **部署侧 fail-fast / 监控 / 回滚基线仍未落地** +1. **真实共享预生产环境门禁未闭环**(本地/容器化 Gate B 已通过,但真实预生产环境尚未用同脚本复跑) +2. **真实共享预生产/灰度环境监控与回滚证据仍未闭环** 3. **代码级通过 ≠ 预生产通过 ≠ 生产可放量,仍需严格分层门禁** --- @@ -49,6 +49,8 @@ go test ./internal/config ./internal/app ./test/integration -count=1 go test ./... -count=1 go vet ./... +AI_CS_RUNTIME_ENV=production ... scripts/verify_preprod_gate_b.sh +AI_CS_RUNTIME_ENV=production ... scripts/verify_gate_c_rollback.sh ``` ### 1.4 关键事实校准 @@ -58,6 +60,12 @@ go vet ./... - 新的 readiness 语义: - **production 缺关键配置/缺 Postgres:启动失败,不进入 ready** - **非 production 的 memory 模式:可正常 ready,不再被误判为 DOWN** +- 本地/容器化 Gate B 预演: + - **已通过**,记录见 `docs/PREPROD_VERIFICATION_RECORD.md` + - **ticket / audit / dedup / health / migration** 均已有脚本化证据 +- 本地/容器化 Gate C 回滚演练: + - **已通过**,记录见 `docs/ROLLBACK_DRILL_RECORD.md` + - **坏发布阻断 -> 回滚恢复 -> webhook / dedup / ticket / audit 恢复** 均已有脚本化证据 - 旧的“可以直接按生产上线口径放行”结论:**仍不成立** --- @@ -76,10 +84,11 @@ go vet ./... - prod memory fallback 已收紧并 fail-fast - runtime env 契约已明确,兼容旧变量名并补齐测试 - readiness 语义已收紧且校准,不再对非 prod memory 场景误伤 +- `scripts/verify_preprod_gate_b.sh` 已建立并通过本地/容器化联调验证 ### 2.2 未通过项 -- 真实环境 DB / migration / webhook / audit / ticket 入库验证缺证据 -- 部署侧关键配置 fail-fast、监控、回滚 runbook 未闭环 +- 真实共享预生产环境 DB / migration / webhook / audit / ticket 入库验证仍缺同脚本复跑证据 +- 真实共享预生产/灰度环境监控接线与回滚演练仍缺真实环境证据 - 生产放行仍缺 Gate B / Gate C 证据 ### 2.3 结论 @@ -100,7 +109,7 @@ go vet ./... | 测试覆盖状态 | PASS | 本轮新增约束已有单测/集成测试覆盖,且全量 Go 测试与 vet 通过 | | readiness / 运行门禁 | PASS(代码级) | prod fail-fast;memory 非 prod 场景 ready 语义恢复正确 | | 上线状态文档 | PASS(当前基线) | 已回写执行板与 QA / checklist 文档 | -| 日志/监控/运行闭环 | PARTIAL | 代码未覆盖真实部署监控与回滚基线 | +| 日志/监控/运行闭环 | PARTIAL | Gate B 预演已脚本化,但真实部署监控与回滚基线未闭环 | --- @@ -114,7 +123,9 @@ go vet ./... | 生产运行约束 | PASS(代码级) | prod 下要求 Postgres 且禁止 memory fallback | | readiness 真实性 | PASS(代码级) | 配置错误走启动失败;非 prod memory 正常 ready | | 配置契约一致性 | PASS | 文档与代码变量名已对齐 | -| 真实环境门禁 | FAIL | DB/migration/webhook/入库闭环未完成证据化验证 | +| 本地/容器化 Gate B 预演 | PASS | `scripts/verify_preprod_gate_b.sh` 已通过,见 `docs/PREPROD_VERIFICATION_RECORD.md` | +| 本地/容器化 Gate C 回滚演练 | PASS | `scripts/verify_gate_c_rollback.sh` 已通过,见 `docs/ROLLBACK_DRILL_RECORD.md` | +| 真实共享预生产门禁 | FAIL | 尚未在真实共享预生产环境复跑同一脚本并留痕 | | 文档状态一致性 | PASS | 当前 QA / board / checklist 已同步 | --- @@ -122,13 +133,13 @@ go vet ./... ## 5. 当前问题清单 ### Critical -1. **真实环境验证闭环缺证据** - - 影响:无法证明 Gate B 已满足 - - 建议:补预生产验证记录(真实 DB / migration / webhook / audit / ticket) +1. **真实共享预生产环境验证闭环缺证据** + - 影响:无法证明共享预生产环境已满足 Gate B + - 建议:在共享预生产环境复跑 `scripts/verify_preprod_gate_b.sh` 并补同结构记录 -2. **部署侧 fail-fast 与运行基线未闭环** - - 影响:代码已具备门禁,但部署入口仍可能绕过或缺失运行保障 - - 建议:补 DevOps 基线、监控、回滚 runbook +2. **真实共享预生产/灰度环境运行证据未闭环** + - 影响:本地脚本化演练不能替代真实共享预生产/灰度环境的放量与回滚证据 + - 建议:在真实共享预生产环境复跑 Gate B,并在同环境执行一次回滚演练留痕 ### Important 1. **代码级通过与生产放行边界仍需持续防漂移** @@ -146,7 +157,9 @@ go vet ./... 因此 QA 当前给出的正式门禁结论是: - **代码级门禁:通过** -- **预生产门禁:未通过** +- **本地/容器化 Gate B 预演:通过** +- **本地/容器化 Gate C 回滚演练:通过** +- **真实共享预生产门禁:未通过** - **生产放行门禁:未通过** --- diff --git a/test/e2e/full_ticket_flow_test.go b/test/e2e/full_ticket_flow_test.go index 871ded6..137a8e1 100644 --- a/test/e2e/full_ticket_flow_test.go +++ b/test/e2e/full_ticket_flow_test.go @@ -69,7 +69,8 @@ func setActorHeaders(req *http.Request, actorID, role string) { // 1. Webhook triggers handoff → ticket created // 2. Ticket is assigned to an agent // 3. Ticket is resolved by the agent -// 4. Ticket is retrieved and verified in final resolved state +// 4. Ticket is explicitly closed +// 5. Ticket is retrieved and verified in final closed state func TestFullTicketFlow_E2E(t *testing.T) { application := newTestAppE2E(t) server := httptest.NewServer(application.Server.Handler) @@ -161,7 +162,36 @@ func TestFullTicketFlow_E2E(t *testing.T) { t.Fatalf("[step3] resolved = %v, want true", resolvePayload["resolved"]) } - // ── Step 4: Verify ticket is retrievable in final resolved state ────── + // ── Step 4: Close the ticket explicitly ─────────────────────────────── + closeURL := fmt.Sprintf("%s/api/v1/customer-service/tickets/%s/close?resolution=refund+processed+and+confirmed", baseURL, ticketID) + closeReq, err := http.NewRequest(http.MethodPost, closeURL, nil) + if err != nil { + t.Fatalf("new close request error = %v", err) + } + setActorHeaders(closeReq, "supervisor-e2e", "supervisor") + closeReq.RemoteAddr = "192.168.1.3:65432" + closeResp, err := http.DefaultClient.Do(closeReq) + if err != nil { + t.Fatalf("close POST error = %v", err) + } + closeBody, err := io.ReadAll(closeResp.Body) + closeResp.Body.Close() + if err != nil { + t.Fatalf("read close body error = %v", err) + } + if closeResp.StatusCode != http.StatusOK { + t.Fatalf("[step4 close] status = %d, want 200; body: %s", closeResp.StatusCode, string(closeBody)) + } + + var closePayload map[string]any + if err := json.Unmarshal(closeBody, &closePayload); err != nil { + t.Fatalf("decode close response error = %v", err) + } + if closePayload["closed"] != true { + t.Fatalf("[step4] closed = %v, want true", closePayload["closed"]) + } + + // ── Step 5: Verify ticket is retrievable in final closed state ─────── getURL := fmt.Sprintf("%s/api/v1/customer-service/tickets/%s", baseURL, ticketID) getReq, err := http.NewRequest(http.MethodGet, getURL, nil) if err != nil { @@ -186,14 +216,14 @@ func TestFullTicketFlow_E2E(t *testing.T) { t.Fatalf("decode ticket response error = %v", err) } tkt := ticketPayload["ticket"].(map[string]any) - if tkt["status"] != "resolved" { - t.Fatalf("[step4] ticket status = %v, want resolved", tkt["status"]) + if tkt["status"] != "closed" { + t.Fatalf("[step5] ticket status = %v, want closed", tkt["status"]) } if tkt["assigned_to"] != "agent-e2e-001" { - t.Fatalf("[step4] assigned_to = %v, want agent-e2e-001", tkt["assigned_to"]) + t.Fatalf("[step5] assigned_to = %v, want agent-e2e-001", tkt["assigned_to"]) } - if tkt["resolution"] != "refund processed and closed" { - t.Fatalf("[step4] resolution = %v, want 'refund processed and closed'", tkt["resolution"]) + if tkt["resolution"] != "refund processed and confirmed" { + t.Fatalf("[step5] resolution = %v, want 'refund processed and confirmed'", tkt["resolution"]) } } @@ -601,6 +631,23 @@ func TestFullTicketFlow_StateTransitionAuditOrder(t *testing.T) { t.Fatalf("resolve status = %d, want 200", resolveResp.StatusCode) } + // Close (audit event: close) + closeURL := fmt.Sprintf("%s/api/v1/customer-service/tickets/%s/close?resolution=confirmed", baseURL, ticketID) + closeReq, err := http.NewRequest(http.MethodPost, closeURL, nil) + if err != nil { + t.Fatalf("new close request error = %v", err) + } + setActorHeaders(closeReq, "supervisor-order", "supervisor") + closeResp, err := http.DefaultClient.Do(closeReq) + if err != nil { + t.Fatalf("close POST error = %v", err) + } + io.ReadAll(closeResp.Body) + closeResp.Body.Close() + if closeResp.StatusCode != http.StatusOK { + t.Fatalf("close status = %d, want 200", closeResp.StatusCode) + } + // Final state check: proves all audit writes succeeded in order getURL := fmt.Sprintf("%s/api/v1/customer-service/tickets/%s", baseURL, ticketID) getReq, err := http.NewRequest(http.MethodGet, getURL, nil) @@ -627,13 +674,13 @@ func TestFullTicketFlow_StateTransitionAuditOrder(t *testing.T) { } tkt := finalPayload["ticket"].(map[string]any) - if tkt["status"] != "resolved" { - t.Fatalf("final status = %v, want resolved", tkt["status"]) + if tkt["status"] != "closed" { + t.Fatalf("final status = %v, want closed", tkt["status"]) } if tkt["assigned_to"] != "agent-order-1" { t.Fatalf("final assigned_to = %v, want agent-order-1", tkt["assigned_to"]) } - if tkt["resolution"] != "handled" { - t.Fatalf("final resolution = %v, want handled", tkt["resolution"]) + if tkt["resolution"] != "confirmed" { + t.Fatalf("final resolution = %v, want confirmed", tkt["resolution"]) } }