feat(ai-customer-service): add gate readiness verification and handoff docs

This commit is contained in:
Your Name
2026-05-06 09:39:33 +08:00
parent 087de4e102
commit 6c3474e23b
25 changed files with 2322 additions and 400 deletions

1
.gitnexusignore Normal file
View File

@@ -0,0 +1 @@
llm-gateway-competitors/

View File

@@ -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 设计已完成,运行时观察面尚未真正上线。**

View File

@@ -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 灰度门禁:未通过**
因此当前唯一允许的结论是:
> **可以继续做共享预生产验证和灰度准备,但还不能进入真实灰度放量。**

View File

@@ -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. 最小告警路由
| 事件 | 通知对象 | 方式 | 时限 |
|------|----------|------|------|
| P0DB 异常 / 5xx > 5% | 值班工程师 + TechLead | 电话 + 飞书 | 5 分钟内响应 |
| P15xx > 1% / audit 失败 / readiness 异常 | 值班工程师 | 飞书 + 工单 | 15 分钟内响应 |
| P2handoff 异常升高 / 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 |
| 磁盘满(审计日志) | — | 立即告警,人工清理 |

View File

@@ -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 → 200postgres 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 灰度放量:未通过**

View File

@@ -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 脚本与本地/容器化联调证据已经建立并通过,但还不能把这直接等同于“真实预生产环境已经放行”。**

View File

@@ -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%灰度稳定性

View File

@@ -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 宰相*

View File

@@ -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

View File

@@ -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。

View File

@@ -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()
}

View File

@@ -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)
}
}

View File

@@ -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"])
}
}

View File

@@ -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))
}

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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) {

View File

@@ -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
- **下次审查**:灰度/回滚机制正式落地后
> **现在可以说“灰度门禁框架已补齐”,但还不能说“灰度已经可执行上线”。**

View File

@@ -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
- **下次审查**:灰度第一周期结束后

View File

@@ -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 工单

View File

@@ -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"

336
scripts/verify_preprod_gate_b.sh Executable file
View File

@@ -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"

View File

@@ -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 服务不可用 |

View File

@@ -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-fastmemory 非 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 回滚演练:通过**
- **真实共享预生产门禁:未通过**
- **生产放行门禁:未通过**
---

View File

@@ -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"])
}
}