feat(ai-customer-service): add gate readiness verification and handoff docs
This commit is contained in:
1
.gitnexusignore
Normal file
1
.gitnexusignore
Normal file
@@ -0,0 +1 @@
|
|||||||
|
llm-gateway-competitors/
|
||||||
90
docs/GRAY_DASHBOARD_MINIMUM.md
Normal file
90
docs/GRAY_DASHBOARD_MINIMUM.md
Normal 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 设计已完成,运行时观察面尚未真正上线。**
|
||||||
|
|
||||||
122
docs/GRAY_LAUNCH_CHECKLIST.md
Normal file
122
docs/GRAY_LAUNCH_CHECKLIST.md
Normal 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 灰度门禁:未通过**
|
||||||
|
|
||||||
|
因此当前唯一允许的结论是:
|
||||||
|
|
||||||
|
> **可以继续做共享预生产验证和灰度准备,但还不能进入真实灰度放量。**
|
||||||
@@ -1,126 +1,133 @@
|
|||||||
# DO-P1-1:最小监控与告警闭环
|
# DO-P1-1:最小监控与告警闭环
|
||||||
|
|
||||||
> 状态:✅ 已交付
|
> 状态:✅ 已定义,待在真实共享预生产/灰度环境接入
|
||||||
> 负责人:DevOps(宰相代填)
|
> 负责人:TechLead / DevOps
|
||||||
> 基准:P0 完成 Gate B 预生产验证
|
> 基准:Gate B 已完成本地/容器化预演,Gate C 前必须落地最小观察面
|
||||||
> 日期:2026-05-04
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 一、监控覆盖矩阵
|
## 1. 目标
|
||||||
|
|
||||||
| 告警项 | 监控端点 | 阈值/判定条件 | 动作 |
|
生产一期灰度阶段不追求“全量可观测平台一次到位”,只要求有一套**最小、可执行、能支持放量/回滚决策**的监控闭环。
|
||||||
|--------|----------|---------------|------|
|
|
||||||
| **5xx 错误激增** | `GET /actuator/health` 中 status≠UP,或日志 level=ERROR | 5xx 占比 > 5% 持续 1min | 触发 PagerDuty / 日志告警 |
|
本轮最小监控集只覆盖 8 个指标:
|
||||||
| **签名拒绝** | 业务日志中 `CS_AUTH_4031/4033/4034` code 出现 | 10 次 / 5min 窗口 | 记录安全事件,暂不阻塞 |
|
|
||||||
| **Handoff 异常** | `GET /api/v1/customer-service/webhook` 返回 `handoff=true` 率 | handoff=true 突增 3x 历史均值 | 记录人工介入事件 |
|
1. `webhook 5xx`
|
||||||
| **Ticket 未创建** | refund intent 触发后 10s 内 cs_tickets 无对应记录 | refund intent 但 ticket_id="" | 告警并记录异常 |
|
2. `webhook reject 数`
|
||||||
| **Audit 未写入** | ticket 创建后 5s 内 cs_audit_logs 无 `object_type=ticket` 记录 | audit_count 增量=0 | 告警 DB 写入问题 |
|
3. `ticket 创建量`
|
||||||
| **PostgreSQL 不可用** | `GET /ready` 中 postgres check ≠UP | postgres status= DOWN | 立即告警,影响 ready |
|
4. `handoff 比率`
|
||||||
| **服务未就绪** | `GET /ready` 返回 non-200 或超时 3s | ready != 200 | 服务 restart 触发 |
|
5. `audit 写入失败数`
|
||||||
| **服务挂了** | `GET /live` 返回 non-200 或超时 3s | live != 200 | K8s/Supervisor restart |
|
6. `readiness down 次数`
|
||||||
|
7. `postgres 连接异常`
|
||||||
|
8. `单实例重启次数`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 二、监控接入方式
|
## 2. 最小指标定义
|
||||||
|
|
||||||
### 2.1 Kubernetes Probe(存活 + 就绪)
|
| 指标 | 定义 | 最低数据来源 | 说明 |
|
||||||
|
|------|------|--------------|------|
|
||||||
```yaml
|
| Webhook 5xx | `POST /api/v1/customer-service/webhook*` 返回 5xx 的比例 | API 网关/Ingress 访问日志或应用日志 | 灰度放量的首要阻断指标 |
|
||||||
livenessProbe:
|
| Webhook reject 数 | 因签名、时间戳、非法 body 被拒绝的请求数 | `CS_AUTH_4031/4032/4033/4034`、`CS_REQ_*` 日志或审计 | 区分“攻击/误配置”和“服务不可用” |
|
||||||
httpGet:
|
| Ticket 创建量 | 每 5 分钟新建工单数 | `cs_tickets` 表或应用埋点 | 与 handoff 比率配合判断主链健康 |
|
||||||
path: /live
|
| Handoff 比率 | `handoff=true` 会话数 / 总 webhook 请求数 | webhook 结果日志、审计或 DB | 反映机器人有效性与故障降级情况 |
|
||||||
port: 8080
|
| Audit 写入失败数 | audit 写入失败事件数 | 应用 ERROR 日志 | 任一增长都需要关注 |
|
||||||
initialDelaySeconds: 5
|
| Readiness down 次数 | `ready` 探针失败次数 | K8s probe / LB 健康检查 / 外部探测 | 用于摘流与自动回滚判断 |
|
||||||
periodSeconds: 10
|
| PostgreSQL 连接异常 | DB ping/query error 次数 | `ready` 检查、应用 ERROR、连接池错误 | Phase 1 的核心依赖告警 |
|
||||||
|
| 单实例重启次数 | 单个实例在窗口期内重启次数 | K8s event / systemd / 容器平台 | 判断二进制稳定性和资源问题 |
|
||||||
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"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 三、告警阈值配置(Prometheus AlertManager 风格)
|
## 3. 告警阈值与动作
|
||||||
|
|
||||||
```yaml
|
### 3.1 必须可执行的阈值
|
||||||
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%"
|
|
||||||
|
|
||||||
- alert: PostgresDown
|
| 指标 | 阈值 | 持续时间 | 级别 | 动作 |
|
||||||
expr: ai_cs_postgres_errors_total > 0
|
|------|------|----------|------|------|
|
||||||
for: 30s
|
| Webhook 5xx | `> 1%` | 5 分钟 | P1 | 立即停止继续放量,触发回滚评估 |
|
||||||
labels:
|
| Webhook 5xx | `> 5%` | 5 分钟 | P0 | 立即回滚当前灰度版本 |
|
||||||
severity: critical
|
| 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
|
### 3.2 放量前置条件
|
||||||
expr: rate(ai_cs_tickets_created_total[5m]) == 0 AND rate(ai_cs_webhook_requests_total[5m]) > 0.1
|
|
||||||
for: 2m
|
|
||||||
labels:
|
|
||||||
severity: warning
|
|
||||||
|
|
||||||
- alert: AuditLogWriteFailure
|
进入下一个灰度档位前,必须同时满足:
|
||||||
expr: increase(ai_cs_audit_logs_written_total[5m]) == 0 AND increase(ai_cs_tickets_created_total[5m]) > 0
|
|
||||||
for: 1m
|
1. 最近一个观察窗口内 `webhook 5xx <= 0.5%`
|
||||||
labels:
|
2. `audit 写入失败数 = 0`
|
||||||
severity: critical
|
3. `postgres 连接异常 = 0`
|
||||||
```
|
4. 没有实例因 `readiness down` 被持续摘流
|
||||||
|
5. `ticket 创建量` 与 `handoff 比率` 没有出现异常偏移
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 四、最小化监控检查清单(部署时必检)
|
## 4. 指标落地方式
|
||||||
|
|
||||||
- [ ] **就绪探针**:`curl http://localhost:8080/ready` → 200 + `postgres:UP`
|
当前仓库还没有 Prometheus 指标端点,因此本轮按“两层实现”定义:
|
||||||
- [ ] **存活探针**:`curl http://localhost:8080/live` → 200
|
|
||||||
- [ ] **日志告警**:ERROR level 日志出现时触发监控告警
|
### 4.1 Gate C 前最低可接受方案
|
||||||
- [ ] **PG 连接**:每分钟 check `/ready` 中 postgres status
|
|
||||||
- [ ] **Handoff 率**:每 5 分钟比对 `webhook_count` vs `handoff_count`
|
- Ingress / API Gateway access log 统计:
|
||||||
- [ ] **Ticket 漏单**:refund intent 触发后 10s 内查 DB 确认 ticket 存在
|
- webhook 请求总量
|
||||||
- [ ] **Audit 漏写**:ticket 创建后 5s 内查 `cs_audit_logs` 确认记录
|
- 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 |
|
|
||||||
| 磁盘满(审计日志) | — | 立即告警,人工清理 |
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> 来源:`docs/RECTIFICATION_REVIEW_REPORT_V2.md`
|
> 来源:`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-1 | P1 | 统一 PM/TechLead/QA/DevOps 交付模板 | 小龙 | 角色交付模板 | 每份角色输出均含结论、证据、阻塞、下一阶段条件 | XL-P0-1 | 未开始 |
|
||||||
| XL-P1-2 | P1 | 增加关键修复后的实施漂移复核点 | 小龙 | 复核流程 | 每次关键修复后都有测试复跑、配置复核、状态更新 | XL-P0-2 | 已完成 |
|
| 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 | 已完成 |
|
| 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 | 未开始 |
|
| TL-P1-2 | P1 | 补多实例与恢复场景验证设计 | TechLead | 设计文档 / 测试计划 | 覆盖 dedup、多实例、重启一致性、migration 幂等 | TL-P0-2 | 未开始 |
|
||||||
| QA-P1-1 | P1 | 建立文档漂移检测检查项 | QA | QA 模板/报告更新 | 每次审查都校对代码 vs 文档 vs 测试状态 | QA-P0-1 | 已完成 |
|
| 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-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 | ✅ 已完成 |
|
| 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 | 进行中 |
|
| TL-P2-2 | P2 | 提升 store/app 关键层测试覆盖 | TechLead | 测试与覆盖率报告 | store/app 关键层覆盖明显提升并覆盖异常场景 | TL-P1-2 | 进行中 |
|
||||||
| QA-P2-1 | P2 | 建立长期质量回归基线 | QA | 回归清单 | 关键链路、关键控制点形成常规回归项 | QA-P1-2 | 未开始 |
|
| QA-P2-1 | P2 | 建立长期质量回归基线 | QA | 回归清单 | 关键链路、关键控制点形成常规回归项 | QA-P1-2 | 未开始 |
|
||||||
| PM-P2-1 | P2 | 完善数据保留、审计、运营复盘口径 | PM | 产品/运营文档 | 有保留策略、失败判定、复盘节奏 | PM-P1-1 | 未开始 |
|
| 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 | 未开始 |
|
| XL-P2-1 | P2 | 将整改执行纳入长期阶段复盘机制 | 小龙 | 复盘模板 | 每个阶段都有事实校准、漂移回收、责任追踪 | XL-P1-2 | 未开始 |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -80,7 +80,7 @@
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| PM-P0-1 | 修正文档上线口径 | P0 | 已完成 |
|
| PM-P0-1 | 修正文档上线口径 | P0 | 已完成 |
|
||||||
| PM-P0-2 | 明确 memory/dev/prod 约束 | P0 | 已完成 |
|
| PM-P0-2 | 明确 memory/dev/prod 约束 | P0 | 已完成 |
|
||||||
| PM-P1-1 | 补运营观察指标与失败线 | P1 | 未开始 |
|
| PM-P1-1 | 补运营观察指标与失败线 | P1 | 已完成 |
|
||||||
| PM-P1-2 | 统一环境变量文档契约 | P1 | 已完成 |
|
| PM-P1-2 | 统一环境变量文档契约 | P1 | 已完成 |
|
||||||
| PM-P2-1 | 完善审计/保留/复盘口径 | P2 | 未开始 |
|
| PM-P2-1 | 完善审计/保留/复盘口径 | P2 | 未开始 |
|
||||||
|
|
||||||
@@ -90,7 +90,7 @@
|
|||||||
| TL-P0-1 | 禁止 prod fallback 到 memory | P0 | 已完成 |
|
| TL-P0-1 | 禁止 prod fallback 到 memory | P0 | 已完成 |
|
||||||
| TL-P0-2 | 收紧 readiness | P0 | 已完成 |
|
| TL-P0-2 | 收紧 readiness | P0 | 已完成 |
|
||||||
| TL-P0-3 | 配置契约基线 | P0 | 已完成 |
|
| TL-P0-3 | 配置契约基线 | P0 | 已完成 |
|
||||||
| TL-P1-1 | 后台接口鉴权设计 | P1 | 未开始 |
|
| TL-P1-1 | 后台接口鉴权设计 | P1 | 已完成 |
|
||||||
| TL-P1-2 | 多实例/恢复验证设计 | P1 | 未开始 |
|
| TL-P1-2 | 多实例/恢复验证设计 | P1 | 未开始 |
|
||||||
| TL-P2-1 | 完整威胁建模 | P2 | 未开始 |
|
| TL-P2-1 | 完整威胁建模 | P2 | 未开始 |
|
||||||
| TL-P2-2 | 提升关键层覆盖率 | P2 | 进行中 |
|
| TL-P2-2 | 提升关键层覆盖率 | P2 | 进行中 |
|
||||||
@@ -101,7 +101,7 @@
|
|||||||
| QA-P0-1 | 重做 QA 门禁文档 | P0 | 已完成 |
|
| QA-P0-1 | 重做 QA 门禁文档 | P0 | 已完成 |
|
||||||
| QA-P0-2 | 将核心风险列为 Critical | P0 | 已完成 |
|
| QA-P0-2 | 将核心风险列为 Critical | P0 | 已完成 |
|
||||||
| QA-P1-1 | 增加文档漂移检测 | P1 | 已完成 |
|
| QA-P1-1 | 增加文档漂移检测 | P1 | 已完成 |
|
||||||
| QA-P1-2 | 增加真实环境前置门禁 | P1 | 未开始 |
|
| QA-P1-2 | 增加真实环境前置门禁 | P1 | ✅ 本地容器化通过(30+25 PASS) |
|
||||||
| QA-P2-1 | 建立长期回归基线 | P2 | 未开始 |
|
| QA-P2-1 | 建立长期回归基线 | P2 | 未开始 |
|
||||||
|
|
||||||
### 4.5 DevOps
|
### 4.5 DevOps
|
||||||
@@ -111,7 +111,7 @@
|
|||||||
| DO-P0-2 | 关键配置 fail-fast 部署标准 | P0 | ✅ 已完成 |
|
| DO-P0-2 | 关键配置 fail-fast 部署标准 | P0 | ✅ 已完成 |
|
||||||
| DO-P1-1 | 最小监控与告警闭环 | P1 | ✅ 已完成 |
|
| DO-P1-1 | 最小监控与告警闭环 | P1 | ✅ 已完成 |
|
||||||
| DO-P1-2 | 运行与回滚 runbook | 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] audit / ticket 入库成功(实测:webhook → session → handoff → ticket → audit 全链路)
|
||||||
- [x] ready/live 符合预期(/actuator/health/ready → 200,postgres checker → UP)
|
- [x] ready/live 符合预期(/actuator/health/ready → 200,postgres checker → UP)
|
||||||
- [x] 最小监控已接通(✅ `docs/MONITORING_ALERTING.md` 已交付,覆盖 8 项监控 + Prometheus 告警配置)
|
- [x] 最小监控已接通(✅ `docs/MONITORING_ALERTING.md` 已交付,覆盖 8 项监控 + Prometheus 告警配置)
|
||||||
|
- [ ] 共享预生产环境已复跑 Gate B 并留痕
|
||||||
|
|
||||||
### Gate C:生产灰度通过
|
### Gate C:生产灰度通过
|
||||||
|
- [x] 灰度指标、阈值、回滚条件清晰
|
||||||
|
- [x] 一页式灰度放行清单已建立
|
||||||
|
- [x] 本地/容器化回滚演练已通过
|
||||||
|
- [ ] 共享预生产/灰度环境监控接线完成
|
||||||
- [ ] 5% 灰度稳定
|
- [ ] 5% 灰度稳定
|
||||||
- [ ] handoff / ticket / audit 指标正常
|
- [ ] handoff / ticket / audit 指标正常
|
||||||
- [ ] 无异常 5xx / reject 激增
|
- [ ] 无异常 5xx / reject 激增
|
||||||
- [ ] 回滚演练通过
|
- [ ] 真实共享预生产/灰度环境回滚演练通过
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -154,6 +159,12 @@
|
|||||||
- `go vet ./...`
|
- `go vet ./...`
|
||||||
3. 验证结果:
|
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. 每完成一项,必须更新状态和证据
|
2. 每完成一项,必须更新状态和证据
|
||||||
3. QA 不能在 P0 未清零前给出生产放行结论
|
3. QA 不能在 P0 未清零前给出生产放行结论
|
||||||
4. 小龙负责最终事实校准,不接受“口头完成”
|
4. 小龙负责最终事实校准,不接受“口头完成”
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 当前最小结论
|
||||||
|
|
||||||
|
当前可以接受的唯一发布口径:
|
||||||
|
|
||||||
|
1. **代码级:通过**
|
||||||
|
2. **本地/容器化 Gate B:通过**
|
||||||
|
3. **共享预生产 Gate B:进行中**
|
||||||
|
4. **本地/容器化 Gate C 回滚演练:通过**
|
||||||
|
5. **Gate C 灰度放量:未通过**
|
||||||
|
|||||||
107
docs/PREPROD_VERIFICATION_RECORD.md
Normal file
107
docs/PREPROD_VERIFICATION_RECORD.md
Normal 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 脚本与本地/容器化联调证据已经建立并通过,但还不能把这直接等同于“真实预生产环境已经放行”。**
|
||||||
|
|
||||||
@@ -23,6 +23,36 @@
|
|||||||
2. **不具备直接生产上线条件。**
|
2. **不具备直接生产上线条件。**
|
||||||
3. **更适合被定义为“Phase 1 后端骨架 + 最小工单闭环”,距离生产上线至少还差 3 个阶段。**
|
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. 本次实际验证
|
## 2. 本次实际验证
|
||||||
|
|
||||||
本次实际执行并确认了以下检查:
|
本次实际执行并确认了以下检查:
|
||||||
@@ -365,3 +395,33 @@ PRD 的 in-scope 能力包含:
|
|||||||
- **距离完整规划设计完成:约 25%**
|
- **距离完整规划设计完成:约 25%**
|
||||||
- **距离生产可灰度上线:约 75% 的关键工作仍未闭环**
|
- **距离生产可灰度上线:约 75% 的关键工作仍未闭环**
|
||||||
- **距离 PRD 全量目标上线:约 70%~80% 的业务能力仍未落地**
|
- **距离 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%灰度稳定性
|
||||||
|
|||||||
127
docs/ROLLBACK_DRILL_RECORD.md
Normal file
127
docs/ROLLBACK_DRILL_RECORD.md
Normal 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 宰相*
|
||||||
@@ -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)
|
## 一、部署前检查清单(Pre-flight)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
339
docs/SHARED_PREPROD_ACCESS_HANDOFF_CHECKLIST.md
Normal file
339
docs/SHARED_PREPROD_ACCESS_HANDOFF_CHECKLIST.md
Normal 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。
|
||||||
@@ -2,10 +2,10 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/bridge/ai-customer-service/internal/domain/audit"
|
"github.com/bridge/ai-customer-service/internal/domain/audit"
|
||||||
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AuditRecorder interface {
|
type AuditRecorder interface {
|
||||||
@@ -13,5 +13,5 @@ type AuditRecorder interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newAuditID(prefix string, now time.Time) string {
|
func newAuditID(prefix string, now time.Time) string {
|
||||||
return fmt.Sprintf("%s-%d", prefix, now.UnixNano())
|
return uuid.NewString()
|
||||||
}
|
}
|
||||||
|
|||||||
15
internal/http/handlers/audit_helper_test.go
Normal file
15
internal/http/handlers/audit_helper_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -450,3 +450,64 @@ func TestTicketHandlerAssign_RejectsWhenActorOnlyProvidedByQuery(t *testing.T) {
|
|||||||
t.Fatalf("status = %d, want 403", resp.Code)
|
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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/bridge/ai-customer-service/internal/domain/error/cserrors"
|
||||||
"github.com/bridge/ai-customer-service/internal/domain/ticket"
|
"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()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
for i := range s.tickets {
|
for i := range s.tickets {
|
||||||
if s.tickets[i].ID == ticketID && s.tickets[i].Status == ticket.StatusOpen {
|
if s.tickets[i].ID != ticketID {
|
||||||
s.tickets[i].AssignedTo = agentID
|
continue
|
||||||
s.tickets[i].Status = ticket.StatusAssigned
|
|
||||||
s.tickets[i].UpdatedAt = now
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
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 {
|
func (s *TicketStore) Resolve(_ context.Context, ticketID, resolution, _, _ string, now time.Time) error {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
for i := range s.tickets {
|
for i := range s.tickets {
|
||||||
if s.tickets[i].ID == ticketID {
|
if s.tickets[i].ID != ticketID {
|
||||||
resolvedAt := now
|
continue
|
||||||
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].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 {
|
func (s *TicketStore) Close(_ context.Context, ticketID, resolution, _, _ string, now time.Time) error {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
for i := range s.tickets {
|
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) {
|
if s.tickets[i].ID != ticketID {
|
||||||
resolvedAt := now
|
continue
|
||||||
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].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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package memory
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -41,8 +42,8 @@ func TestTicketStore_Assign(t *testing.T) {
|
|||||||
|
|
||||||
// Create an open ticket
|
// Create an open ticket
|
||||||
store.Create(ctx, &ticket.Ticket{
|
store.Create(ctx, &ticket.Ticket{
|
||||||
ID: "t1",
|
ID: "t1",
|
||||||
Status: ticket.StatusOpen,
|
Status: ticket.StatusOpen,
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
UpdatedAt: now,
|
UpdatedAt: now,
|
||||||
})
|
})
|
||||||
@@ -91,11 +92,11 @@ func TestTicketStore_Resolve(t *testing.T) {
|
|||||||
|
|
||||||
// Create an assigned ticket
|
// Create an assigned ticket
|
||||||
store.Create(ctx, &ticket.Ticket{
|
store.Create(ctx, &ticket.Ticket{
|
||||||
ID: "t1",
|
ID: "t1",
|
||||||
Status: ticket.StatusAssigned,
|
Status: ticket.StatusAssigned,
|
||||||
AssignedTo: "agent1",
|
AssignedTo: "agent1",
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
UpdatedAt: now,
|
UpdatedAt: now,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Resolve it
|
// 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) {
|
func TestTicketStore_Close(t *testing.T) {
|
||||||
store := NewTicketStore()
|
store := NewTicketStore()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -153,8 +178,8 @@ func TestTicketStore_Close_NotResolved(t *testing.T) {
|
|||||||
|
|
||||||
// Create an open ticket (not resolved)
|
// Create an open ticket (not resolved)
|
||||||
store.Create(ctx, &ticket.Ticket{
|
store.Create(ctx, &ticket.Ticket{
|
||||||
ID: "t1",
|
ID: "t1",
|
||||||
Status: ticket.StatusOpen,
|
Status: ticket.StatusOpen,
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
UpdatedAt: now,
|
UpdatedAt: now,
|
||||||
})
|
})
|
||||||
@@ -164,4 +189,29 @@ func TestTicketStore_Close_NotResolved(t *testing.T) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("Close() on non-resolved ticket should return error")
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/bridge/ai-customer-service/internal/domain/audit"
|
"github.com/bridge/ai-customer-service/internal/domain/audit"
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/bridge/ai-customer-service/internal/domain/ticket"
|
"github.com/bridge/ai-customer-service/internal/domain/ticket"
|
||||||
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TicketWorkflowStore composes TicketStore with AuditStore for workflow operations.
|
// TicketWorkflowStore composes TicketStore with AuditStore for workflow operations.
|
||||||
@@ -37,14 +37,14 @@ func (s *TicketWorkflowStore) writeAudit(ctx context.Context, ticketID, action,
|
|||||||
}
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
event := audit.Event{
|
event := audit.Event{
|
||||||
ID: uuid.New().String(),
|
ID: uuid.New().String(),
|
||||||
Type: "ticket_state_changed",
|
Type: "ticket_state_changed",
|
||||||
Action: action,
|
Action: action,
|
||||||
TicketID: ticketID,
|
TicketID: ticketID,
|
||||||
ActorID: actorID,
|
ActorID: actorID,
|
||||||
SourceIP: sourceIP,
|
SourceIP: sourceIP,
|
||||||
AfterState: afterState,
|
AfterState: afterState,
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
}
|
}
|
||||||
if err := s.audit.Add(ctx, event); err != nil {
|
if err := s.audit.Add(ctx, event); err != nil {
|
||||||
if s.log != nil {
|
if s.log != nil {
|
||||||
@@ -134,10 +134,10 @@ func (s *TicketWorkflowStore) Resolve(ctx context.Context, ticketID, resolution,
|
|||||||
if currentStatus == "" {
|
if currentStatus == "" {
|
||||||
return fmt.Errorf("CS_TICKET_4001:ticket not found")
|
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")
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -166,10 +166,10 @@ func (s *TicketWorkflowStore) Close(ctx context.Context, ticketID, resolution, a
|
|||||||
if currentStatus == "" {
|
if currentStatus == "" {
|
||||||
return fmt.Errorf("CS_TICKET_4001:ticket not found")
|
return fmt.Errorf("CS_TICKET_4001:ticket not found")
|
||||||
}
|
}
|
||||||
if currentStatus == "closed" {
|
if currentStatus != "resolved" {
|
||||||
return fmt.Errorf("CS_TICKET_4093:ticket close conflict")
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package postgres
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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) {
|
func TestTicketWorkflowStore_Close(t *testing.T) {
|
||||||
db := openDBForTest(t)
|
db := openDBForTest(t)
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
@@ -260,6 +296,42 @@ func TestTicketWorkflowStore_Close_NotResolved(t *testing.T) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("Close() on non-resolved ticket should return error")
|
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) {
|
func TestTicketWorkflowStore_NilDB(t *testing.T) {
|
||||||
|
|||||||
@@ -1,152 +1,144 @@
|
|||||||
# 灰度发布与回滚 Runbook
|
# 灰度发布与回滚 Runbook
|
||||||
|
|
||||||
> 版本:v1.0 | 状态:初稿(待 TechLead 补充部署部分)
|
> 版本:v1.1
|
||||||
> 关联:PRODUCTION_EXECUTION_PLAN.md、PRODUCTION_PHASE1_STATUS.md
|
> 状态:灰度门禁已定义,本地/容器化回滚演练已通过,待真实共享预生产/灰度环境演练
|
||||||
|
> 关联:`docs/MONITORING_ALERTING.md`、`docs/GRAY_DASHBOARD_MINIMUM.md`、`docs/PREPROD_VERIFICATION_RECORD.md`、`docs/ROLLBACK_DRILL_RECORD.md`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. 灰度发布策略
|
## 1. 前提
|
||||||
|
|
||||||
### 1.1 灰度阶段定义
|
开始任何灰度放量前,必须满足:
|
||||||
|
|
||||||
| 阶段 | 流量比例 | 持续时间 | 通过条件 |
|
1. Gate B 已通过
|
||||||
|------|----------|----------|----------|
|
当前状态:**本地/容器化预演已通过,真实共享预生产环境待复跑**
|
||||||
| 灰度 5% | 5% 新版本 / 95% 老版本 | 1-2 天 | 错误率 < 1%,无 P0/P1 问题 |
|
2. 最小鉴权已落地
|
||||||
| 灰度 20% | 20% 新版本 / 80% 老版本 | 2-3 天 | 错误率 < 0.5%,SLA 指标达标 |
|
3. 工单闭环语义已收口
|
||||||
| 灰度 100% | 100% 新版本 | - | 灰度 20% 稳定 48h 后全量 |
|
4. 最小监控指标和阈值已定义
|
||||||
|
|
||||||
### 1.2 灰度切换方式
|
|
||||||
|
|
||||||
**当前实现状态**:生产一期**灰度发布能力未落地**,尚无配置化灰度开关。
|
|
||||||
|
|
||||||
**临时方案**:通过 Kubernetes `Deployment` 副本数控制:
|
|
||||||
- 灰度 5%:新版本 1 副本,老版本 19 副本
|
|
||||||
- 灰度 20%:新版本 4 副本,老版本 16 副本
|
|
||||||
- 全量:新版本 20 副本,老版本 0 副本
|
|
||||||
|
|
||||||
**正式方案(待实现)**:
|
|
||||||
- 引入 feature flag 服务(LD / Apollo)
|
|
||||||
- 按用户 ID、渠道、地区等维度灰度
|
|
||||||
- 支持热开关,无需重启
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. 灰度发布检查单
|
## 2. 灰度放量节奏
|
||||||
|
|
||||||
### 2.1 发布前检查
|
默认节奏如下:
|
||||||
|
|
||||||
- [ ] 所有 P0/P1 缺陷已关闭
|
| 档位 | 流量占比 | 最短观察时间 | 进入条件 | 回退条件 |
|
||||||
- [ ] 上一节 8 个 PM 文档已全部建立
|
|------|----------|--------------|----------|----------|
|
||||||
- [ ] 审计日志可查询、可追溯
|
| Stage 1 | 5% | 30 分钟 | Gate B 通过,部署稳定,核心指标全绿 | 任一 P0/P1 指标触发 |
|
||||||
- [ ] PostgreSQL migration 已执行,数据完整
|
| Stage 2 | 20% | 2 小时 | Stage 1 稳定,`5xx <= 0.5%`,`audit fail = 0` | 5xx > 1%、audit fail > 0、DB 异常 |
|
||||||
- [ ] 运营后台可看到工单列表/统计
|
| Stage 3 | 50% | 半天 | Stage 2 稳定,handoff 比率无异常升高 | 指标明显劣化或人工链路承压 |
|
||||||
- [ ] health/readiness 检查通过
|
| 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% | 大量请求失败 |
|
| Webhook 5xx `> 5%` 持续 5 分钟 | 服务主链不可接受 |
|
||||||
| P0 安全漏洞被触发 | 如签名校验被绕过 |
|
| PostgreSQL 异常导致 `ready` 持续失败 | 核心依赖异常 |
|
||||||
| PostgreSQL 数据损坏 | 审计/工单写入失败 |
|
| Audit 写入失败数 `> 0` 且持续 5 分钟 | 合规/追溯链路断裂 |
|
||||||
| 100% 请求返回 5xx | 服务完全不可用 |
|
| Ticket 创建链路断裂 | 人工服务主链损坏 |
|
||||||
| 错误率 > 5% | 持续 5min 以上 |
|
| 全量 readiness down 或实例反复重启 | 当前版本不稳定 |
|
||||||
|
|
||||||
### 3.2 建议回滚的条件
|
---
|
||||||
|
|
||||||
满足以下条件时,技术负责人评估是否回滚:
|
## 6. 建议回滚条件
|
||||||
|
|
||||||
| 条件 | 说明 |
|
出现以下情况时,停止继续放量并由 TechLead 决策是否回滚:
|
||||||
|
|
||||||
|
| 条件 | 处理 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 错误率 > 2% 持续 10min | 异常但未达必须回滚阈值 |
|
| Webhook 5xx `> 1%` 持续 5 分钟 | 冻结当前档位,评估回滚 |
|
||||||
| 特定渠道全部失败 | 如 Telegram webhook 全部报错 |
|
| Handoff 比率高于基线 `2x` | 判断意图识别/降级是否异常 |
|
||||||
| SLA 指标连续劣化 | 响应时间 P95 > 10s |
|
| Reject 数持续高于 20% | 检查上游签名或渠道配置 |
|
||||||
|
| 单实例重启次数过高 | 排查资源、崩溃或配置问题 |
|
||||||
### 3.3 不需要回滚的条件
|
|
||||||
|
|
||||||
- 边缘渠道偶发超时(< 0.5%)
|
|
||||||
- 非核心功能(如 knowledge base 搜索偶发无结果)
|
|
||||||
- 新版本 warning 日志增加(不影响功能)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. 回滚操作流程
|
## 7. 回滚动作
|
||||||
|
|
||||||
### 4.1 当前状态
|
### 7.1 立即动作
|
||||||
|
|
||||||
生产一期**自动回滚机制未落地**,依赖人工执行。
|
1. 停止继续放量
|
||||||
|
2. 将灰度比例回退到上一个稳定档位
|
||||||
|
3. 若当前档位无稳定状态,直接回退到旧版本
|
||||||
|
|
||||||
### 4.2 手动回滚步骤(当前临时方案)
|
### 7.2 回滚后必须检查
|
||||||
|
|
||||||
```bash
|
- [ ] `live` 正常
|
||||||
# 1. 确认当前版本和历史版本
|
- [ ] `ready` 正常
|
||||||
kubectl rollout history deployment/ai-customer-service
|
- [ ] signed webhook 再次联调通过
|
||||||
|
- [ ] ticket 创建恢复
|
||||||
# 2. 查看当前版本状态
|
- [ ] audit 写入恢复
|
||||||
kubectl get pods -l app=customer-service
|
- [ ] PostgreSQL 无新错误
|
||||||
|
|
||||||
# 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 正常工作
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. 故障恢复后的重新发布
|
## 8. 演练要求
|
||||||
|
|
||||||
当回滚后问题修复,需重新走灰度流程:
|
Gate C 前至少完成一次回滚演练,且留下证据:
|
||||||
|
|
||||||
1. 问题根因分析完成
|
1. 演练时间
|
||||||
2. 修复方案经过代码 review
|
2. 演练版本
|
||||||
3. 在 staging/预发布环境验证
|
3. 触发条件
|
||||||
4. 从灰度 5% 重新开始,不允许跳阶段
|
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
|
|
||||||
- **下次审查**:灰度/回滚机制正式落地后
|
|
||||||
|
|||||||
@@ -1,126 +1,91 @@
|
|||||||
# 客服 SLA 与升级响应规范
|
# 客服 SLA 与升级响应规范
|
||||||
|
|
||||||
> 版本:v1.0 | 状态:已生效
|
> 版本:v1.1
|
||||||
> 关联:tech/INTERFACE.md、PRODUCTION_PHASE1_STATUS.md
|
> 状态:已更新为灰度门禁口径
|
||||||
|
> 关联:`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% | 成功接收渠道消息的比率 |
|
| Webhook 成功率 | `>= 99%`(5 分钟窗口) | 是否允许继续放量 |
|
||||||
| 首次响应时间(机器人) | ≤ 5s | 从收到消息到发出首字的时间(P95) |
|
| Webhook 5xx | `< 1%`(5 分钟窗口) | 超阈值即停止放量 |
|
||||||
| 机器人回答准确率 | ≥ 85% | FAQ 命中且用户未点"不满意" |
|
| Readiness 可用率 | `>= 99.5%` | 实例是否稳定接流量 |
|
||||||
| 转人工率 | ≤ 15% | 需要人工介入的会话比例 |
|
| PostgreSQL 依赖异常 | `= 0`(5 分钟窗口) | 关键依赖门禁 |
|
||||||
| 工单响应时间 | ≤ 30min | 从创建到客服接单的时间(P95) |
|
| Audit 写入失败数 | `= 0`(5 分钟窗口) | 合规/追溯门禁 |
|
||||||
| 工单解决时间 | ≤ 4h | 从创建到解决的时间(P95) |
|
| Handoff 比率 | `<= 25%` 或不高于基线 `2x` | 判断机器人能力是否异常退化 |
|
||||||
|
| 工单接单时间 P95 | `<= 30 分钟` | 人工链路可承载性 |
|
||||||
|
| 工单解决时间 P95 | `<= 4 小时` | 最小服务能力 |
|
||||||
|
|
||||||
> **注**:上述指标为生产一期目标值,实际值需在灰度阶段采集并调整基线。
|
### 1.2 灰度期分级
|
||||||
|
|
||||||
### 1.2 SLA 优先级定义
|
| 级别 | 定义 | 响应时间 | 恢复目标 |
|
||||||
|
|------|------|----------|----------|
|
||||||
| 优先级 | 定义 | 响应时间 | 解决时间 |
|
| P0 | 数据库不可用、全量 5xx、审计主链断裂 | 5 分钟 | 30 分钟内恢复或回滚 |
|
||||||
|--------|------|----------|----------|
|
| P1 | 5xx > 1%、连续 readiness down、ticket 主链异常 | 15 分钟 | 1 小时内恢复或回滚 |
|
||||||
| P1 | 机器人完全不可用(所有消息报错) | 15min | 1h |
|
| P2 | reject 异常升高、handoff 比率异常、重启抖动 | 30 分钟 | 4 小时内恢复 |
|
||||||
| P2 | 核心能力降级(签名/幂等失效、频繁 5xx) | 30min | 2h |
|
|
||||||
| P3 | 非核心功能异常(部分渠道失败、偶发报错) | 2h | 8h |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. 升级响应规范
|
## 2. 升级与通知规则
|
||||||
|
|
||||||
### 2.1 升级链路
|
| 触发条件 | 等级 | 通知 |
|
||||||
|
|----------|------|------|
|
||||||
```
|
| Webhook 5xx `> 5%` 持续 5 分钟 | P0 | 电话 + 飞书,立即回滚 |
|
||||||
告警/故障发现 → P3 处理(值班工程师) → 若恶化升级 P2 → 若继续恶化升级 P1
|
| PostgreSQL 连接异常导致 `ready` 失败 | P0 | 电话 + 飞书,立即冻结放量 |
|
||||||
```
|
| Audit 写入失败数 `> 0` 持续 5 分钟 | P1 | 飞书,立即停止继续放量 |
|
||||||
|
| Handoff 比率 `> 25%` 或高于基线 `2x` | P2 | 飞书,需人工研判 |
|
||||||
### 2.2 告警触发条件
|
| 单实例 10 分钟内重启 `> 2` 次 | P2 | 飞书,冻结当前档位 |
|
||||||
|
|
||||||
| 条件 | 级别 | 通知方式 |
|
|
||||||
|------|------|----------|
|
|
||||||
| 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 异常:记录在运营日志,下周一回溯复盘
|
|
||||||
- 所有故障必须在下一灰度周期前完成根因分析
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. 当前阶段说明
|
## 3. 当前实现与 SLA 的关系
|
||||||
|
|
||||||
### 3.1 可用性现状
|
### 3.1 已有支撑
|
||||||
|
|
||||||
| 能力 | 当前状态 | 备注 |
|
- `live` / `ready` 探针已具备
|
||||||
|------|----------|------|
|
- PostgreSQL readiness 检查已接入
|
||||||
| Webhook 可用率监控 | 未完成 | P1 缺口,metrics/tracing 未落地 |
|
- webhook HMAC / timestamp / dedup 已具备
|
||||||
| 错误率监控 | 未完成 | 同上 |
|
- ticket / audit / dedup 本地/容器化 Gate B 已证据化通过
|
||||||
| PostgreSQL 连接监控 | ✅ 已完成 | `/ready` 含 PostgreSQL 依赖检查 |
|
|
||||||
| 工单积压监控 | 未完成 | 无定时任务扫描 open 工单 |
|
|
||||||
| 安全拒绝事件审计 | ✅ 已完成 | `webhook_security.go` 的 `auditReject` 写入审计 |
|
|
||||||
| 工单状态流转审计 | ✅ 已完成 | `TicketWorkflowStore.writeAudit` 在 assign/resolve/close 时调用 |
|
|
||||||
|
|
||||||
### 3.2 接口级 SLA(当前代码能力)
|
### 3.2 仍待落地
|
||||||
|
|
||||||
以下为代码中已实现的接口响应时间基准(本地压测数据,待灰度验证):
|
- 真实共享预生产环境上的统一指标采集
|
||||||
|
- 告警平台接入
|
||||||
|
- 灰度阶段的自动统计和 dashboard
|
||||||
|
|
||||||
| 接口 | 目标延迟 | 当前状态 |
|
因此当前 SLA 结论应当理解为:
|
||||||
|------|----------|----------|
|
|
||||||
| `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 |
|
|
||||||
|
|
||||||
> **注**:当前压测数据为本地单实例,未经过真实渠道流量验证。
|
> **门槛已定义,但真实共享预生产和灰度环境的观测接线仍需补齐。**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. 错误码与 SLA 映射
|
## 4. 与放量门禁的绑定
|
||||||
|
|
||||||
错误码定义见 `tech/INTERFACE.md`,与 SLA 相关联的快速参考:
|
进入下一灰度档位前,必须满足:
|
||||||
|
|
||||||
| 错误码 | 含义 | SLA 影响 |
|
1. 最近一个观察窗口 `webhook 5xx <= 0.5%`
|
||||||
|--------|------|----------|
|
2. `audit 写入失败数 = 0`
|
||||||
| `CS_SES_4001` | 会话不存在 | 返回 404,用户可重试 |
|
3. `postgres 连接异常 = 0`
|
||||||
| `CS_SES_4002` | 消息频率过高 | 返回 429,触发限流逻辑 |
|
4. 无连续 `readiness down`
|
||||||
| `CS_TKT_4001` | 工单不存在 | 返回 404 |
|
5. handoff / ticket 指标没有异常飙升
|
||||||
| `CS_TKT_4002` | 工单已被分配 | 返回 409,幂等性保证 |
|
|
||||||
| `CS_LLM_5001` | LLM 服务不可用 | 触发转人工,SLA 降级 |
|
任一条件不满足:
|
||||||
| `CS_LLM_5002` | LLM 超时 | 同上 |
|
|
||||||
|
- 不允许继续放量
|
||||||
|
- 必要时触发回滚
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. 持续改进
|
## 5. 当前版本状态
|
||||||
|
|
||||||
SLA 基线在灰度第一周期(建议 2 周)后复盘,根据真实数据调整:
|
- 文档版本:`v1.1`
|
||||||
- 若机器人响应时间 P95 > 5s,需优化 LLM 调用链路
|
- 本次更新日期:`2026-05-04`
|
||||||
- 若转人工率 > 20%,需复盘意图识别准确率
|
- 下次审查:灰度第一轮结束后
|
||||||
- 若工单解决时间 P95 > 4h,需增加客服人力或优化分流策略
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 当前版本状态
|
|
||||||
|
|
||||||
- **本文档版本**:v1.0
|
|
||||||
- **生效日期**:2026-04-30
|
|
||||||
- **下次审查**:灰度第一周期结束后
|
|
||||||
|
|||||||
@@ -9,11 +9,11 @@
|
|||||||
|
|
||||||
```
|
```
|
||||||
用户触发转人工
|
用户触发转人工
|
||||||
→ [待落地] 工单创建(含排队位置)
|
→ 工单创建
|
||||||
→ 客服接单(assign)
|
→ 客服接单(assign)
|
||||||
→ 客服处理
|
→ 客服处理
|
||||||
→ 客服解决(resolve)
|
→ 客服解决(resolve)
|
||||||
→ [待明确] 工单关闭(close?)
|
→ 客服/主管关闭(close)
|
||||||
→ 用户满意度反馈(可选)
|
→ 用户满意度反馈(可选)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -25,8 +25,17 @@
|
|||||||
|------|------|----------|--------------|
|
|------|------|----------|--------------|
|
||||||
| `open` | 待接单 | 转人工触发工单创建 | ✅ 已落地 |
|
| `open` | 待接单 | 转人工触发工单创建 | ✅ 已落地 |
|
||||||
| `assigned` | 已分配 | 客服主动接单或系统分配 | ✅ 已落地 |
|
| `assigned` | 已分配 | 客服主动接单或系统分配 | ✅ 已落地 |
|
||||||
| `resolved` | 已解决 | 客服处理完毕 | ✅ 已落地 |
|
| `resolved` | 已给出处理结论,等待最终归档 | 已分配工单处理完毕后调用 `resolve` | ✅ 已落地 |
|
||||||
| `closed` | 已关闭 | 显式调用 close 接口 | ✅ 已落地(`TicketWorkflowStore.Close`) |
|
| `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}`
|
**接口**:`POST /api/v1/customer-service/tickets/{id}/resolve?resolution={resolution}`
|
||||||
|
|
||||||
**流程**:
|
**流程**:
|
||||||
1. 客服处理完毕后调用 resolve
|
1. 已接单工单处理完毕后调用 resolve
|
||||||
2. 更新 ticket.status = `resolved`,ticket.resolution = resolution
|
2. 更新 ticket.status = `resolved`,ticket.resolution = resolution
|
||||||
3. 写入审计日志(✅ 已落地:调用 `TicketWorkflowStore.writeAudit`)
|
3. 写入审计日志(✅ 已落地:调用 `TicketWorkflowStore.writeAudit`)
|
||||||
|
4. `resolved` 表示已经有处理结论,但尚未最终关闭
|
||||||
**缺失项**:
|
|
||||||
- 工单状态流转审计 ✅ 已落地(`TicketWorkflowStore.writeAudit` 在 resolve 时调用)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -117,14 +124,28 @@
|
|||||||
**已落地**:`TicketWorkflowStore.Close` 接口已实现,支持显式关闭工单。
|
**已落地**:`TicketWorkflowStore.Close` 接口已实现,支持显式关闭工单。
|
||||||
|
|
||||||
**语义定义**:
|
**语义定义**:
|
||||||
- `resolve` = 客服确认问题已解决,工单进入 `resolved` 状态
|
- `resolve` = 客服确认已给出处理结论,工单进入 `resolved`
|
||||||
- `close` = 工单正式关闭,进入 `closed` 状态(resolved 后可选调用)
|
- `close` = 对 `resolved` 工单做最终归档,工单进入 `closed`
|
||||||
- 已解决工单(resolved)可直接 close;未解决工单也可强制 close
|
- 未 `resolved` 的工单不能直接 `close`
|
||||||
|
- `closed` 工单不能再次 `resolve`
|
||||||
|
|
||||||
|
### 7.2 返回语义
|
||||||
|
|
||||||
|
| 场景 | HTTP | 错误码 |
|
||||||
|
|------|------|--------|
|
||||||
|
| 工单不存在 | `404` | `CS_TICKET_4001` |
|
||||||
|
| 非法 `resolve` 状态流转 | `409` | `CS_TICKET_4092` |
|
||||||
|
| 非法 `close` 状态流转 | `409` | `CS_TICKET_4093` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. 客服工作台操作规范(API 层)
|
## 8. 客服工作台操作规范(API 层)
|
||||||
|
|
||||||
|
受保护接口必须携带:
|
||||||
|
|
||||||
|
- `X-CS-Actor-ID`
|
||||||
|
- `X-CS-Actor-Role`
|
||||||
|
|
||||||
### 8.1 班次开始
|
### 8.1 班次开始
|
||||||
|
|
||||||
1. 调用 `GET /api/v1/customer-service/tickets?status=open` 查看当前待接单工单
|
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={解决说明}"
|
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
|
```bash
|
||||||
# 查看所有 open 工单
|
# 查看所有 open 工单
|
||||||
|
|||||||
391
scripts/verify_gate_c_rollback.sh
Normal file
391
scripts/verify_gate_c_rollback.sh
Normal 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
336
scripts/verify_preprod_gate_b.sh
Executable 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"
|
||||||
@@ -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}` | 获取工单 |
|
| GET | `/api/v1/customer-service/tickets/{id}` | 获取工单 |
|
||||||
| POST | `/api/v1/customer-service/tickets/{id}/assign` | 分配工单 |
|
| POST | `/api/v1/customer-service/tickets/{id}/assign?agent_id={agent_id}` | 将 `open` 工单分配给客服 |
|
||||||
| POST | `/api/v1/customer-service/tickets/{id}/resolve` | 解决工单 |
|
| POST | `/api/v1/customer-service/tickets/{id}/resolve?resolution={resolution}` | 将 `assigned`/`processing` 工单标记为 `resolved` |
|
||||||
| POST | `/api/v1/customer-service/tickets/{id}/close` | 关闭工单 |
|
| POST | `/api/v1/customer-service/tickets/{id}/close?resolution={resolution}` | 将 `resolved` 工单最终关闭为 `closed` |
|
||||||
| GET | `/api/v1/customer-service/tickets/stats` | 工单统计 |
|
| 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_SES_4003` | 403 | 身份校验已锁定 |
|
||||||
| `CS_IDT_4001` | 400 | 身份信息不匹配 |
|
| `CS_IDT_4001` | 400 | 身份信息不匹配 |
|
||||||
| `CS_IDT_4002` | 400 | 验证码错误 |
|
| `CS_IDT_4002` | 400 | 验证码错误 |
|
||||||
| `CS_TKT_4001` | 404 | 工单不存在 |
|
| `CS_TICKET_4001` | 404 | 工单不存在 |
|
||||||
| `CS_TKT_4002` | 409 | 工单已被分配 |
|
| `CS_TKT_4002` | 409 | 工单已被分配 |
|
||||||
|
| `CS_TICKET_4092` | 409 | 工单状态不允许 resolve |
|
||||||
|
| `CS_TICKET_4093` | 409 | 工单状态不允许 close |
|
||||||
| `CS_KB_4001` | 404 | 知识库条目不存在 |
|
| `CS_KB_4001` | 404 | 知识库条目不存在 |
|
||||||
| `CS_KB_4002` | 409 | 条目名称已存在 |
|
| `CS_KB_4002` | 409 | 条目名称已存在 |
|
||||||
| `CS_LLM_5001` | 503 | LLM 服务不可用 |
|
| `CS_LLM_5001` | 503 | LLM 服务不可用 |
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
## 0. 阶段门控结论
|
## 0. 阶段门控结论
|
||||||
|
|
||||||
- **当前结论:CONDITIONAL_PASS(代码级) / REQUEST_CHANGES(预生产与生产门禁)**
|
- **当前结论:CONDITIONAL_PASS(代码级 + 本地/容器化 Gate B 预演 + 本地/容器化 Gate C 回滚演练) / REQUEST_CHANGES(真实预生产与生产放量门禁)**
|
||||||
- **是否可进入下一阶段(按“生产可直接上线”口径放行):否**
|
- **是否可进入下一阶段(按“生产可直接上线”口径放行):否**
|
||||||
- **是否可进入预生产整改 / 灰度准备:是,但前提是继续完成剩余 P0/P1 真实环境项**
|
- **是否可进入预生产整改 / 灰度准备:是,但前提是继续完成剩余 P0/P1 真实环境项**
|
||||||
|
|
||||||
@@ -23,8 +23,8 @@
|
|||||||
4. **配置契约、执行板、QA 文档已同步回写**
|
4. **配置契约、执行板、QA 文档已同步回写**
|
||||||
|
|
||||||
当前剩余阻断已收敛到:
|
当前剩余阻断已收敛到:
|
||||||
1. **真实环境门禁(DB / migration / webhook 联调 / 入库验证)未闭环**
|
1. **真实共享预生产环境门禁未闭环**(本地/容器化 Gate B 已通过,但真实预生产环境尚未用同脚本复跑)
|
||||||
2. **部署侧 fail-fast / 监控 / 回滚基线仍未落地**
|
2. **真实共享预生产/灰度环境监控与回滚证据仍未闭环**
|
||||||
3. **代码级通过 ≠ 预生产通过 ≠ 生产可放量,仍需严格分层门禁**
|
3. **代码级通过 ≠ 预生产通过 ≠ 生产可放量,仍需严格分层门禁**
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -49,6 +49,8 @@
|
|||||||
go test ./internal/config ./internal/app ./test/integration -count=1
|
go test ./internal/config ./internal/app ./test/integration -count=1
|
||||||
go test ./... -count=1
|
go test ./... -count=1
|
||||||
go vet ./...
|
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 关键事实校准
|
### 1.4 关键事实校准
|
||||||
@@ -58,6 +60,12 @@ go vet ./...
|
|||||||
- 新的 readiness 语义:
|
- 新的 readiness 语义:
|
||||||
- **production 缺关键配置/缺 Postgres:启动失败,不进入 ready**
|
- **production 缺关键配置/缺 Postgres:启动失败,不进入 ready**
|
||||||
- **非 production 的 memory 模式:可正常 ready,不再被误判为 DOWN**
|
- **非 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
|
- prod memory fallback 已收紧并 fail-fast
|
||||||
- runtime env 契约已明确,兼容旧变量名并补齐测试
|
- runtime env 契约已明确,兼容旧变量名并补齐测试
|
||||||
- readiness 语义已收紧且校准,不再对非 prod memory 场景误伤
|
- readiness 语义已收紧且校准,不再对非 prod memory 场景误伤
|
||||||
|
- `scripts/verify_preprod_gate_b.sh` 已建立并通过本地/容器化联调验证
|
||||||
|
|
||||||
### 2.2 未通过项
|
### 2.2 未通过项
|
||||||
- 真实环境 DB / migration / webhook / audit / ticket 入库验证缺证据
|
- 真实共享预生产环境 DB / migration / webhook / audit / ticket 入库验证仍缺同脚本复跑证据
|
||||||
- 部署侧关键配置 fail-fast、监控、回滚 runbook 未闭环
|
- 真实共享预生产/灰度环境监控接线与回滚演练仍缺真实环境证据
|
||||||
- 生产放行仍缺 Gate B / Gate C 证据
|
- 生产放行仍缺 Gate B / Gate C 证据
|
||||||
|
|
||||||
### 2.3 结论
|
### 2.3 结论
|
||||||
@@ -100,7 +109,7 @@ go vet ./...
|
|||||||
| 测试覆盖状态 | PASS | 本轮新增约束已有单测/集成测试覆盖,且全量 Go 测试与 vet 通过 |
|
| 测试覆盖状态 | PASS | 本轮新增约束已有单测/集成测试覆盖,且全量 Go 测试与 vet 通过 |
|
||||||
| readiness / 运行门禁 | PASS(代码级) | prod fail-fast;memory 非 prod 场景 ready 语义恢复正确 |
|
| readiness / 运行门禁 | PASS(代码级) | prod fail-fast;memory 非 prod 场景 ready 语义恢复正确 |
|
||||||
| 上线状态文档 | PASS(当前基线) | 已回写执行板与 QA / checklist 文档 |
|
| 上线状态文档 | PASS(当前基线) | 已回写执行板与 QA / checklist 文档 |
|
||||||
| 日志/监控/运行闭环 | PARTIAL | 代码未覆盖真实部署监控与回滚基线 |
|
| 日志/监控/运行闭环 | PARTIAL | Gate B 预演已脚本化,但真实部署监控与回滚基线未闭环 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -114,7 +123,9 @@ go vet ./...
|
|||||||
| 生产运行约束 | PASS(代码级) | prod 下要求 Postgres 且禁止 memory fallback |
|
| 生产运行约束 | PASS(代码级) | prod 下要求 Postgres 且禁止 memory fallback |
|
||||||
| readiness 真实性 | PASS(代码级) | 配置错误走启动失败;非 prod memory 正常 ready |
|
| readiness 真实性 | PASS(代码级) | 配置错误走启动失败;非 prod memory 正常 ready |
|
||||||
| 配置契约一致性 | PASS | 文档与代码变量名已对齐 |
|
| 配置契约一致性 | 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 已同步 |
|
| 文档状态一致性 | PASS | 当前 QA / board / checklist 已同步 |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -122,13 +133,13 @@ go vet ./...
|
|||||||
## 5. 当前问题清单
|
## 5. 当前问题清单
|
||||||
|
|
||||||
### Critical
|
### Critical
|
||||||
1. **真实环境验证闭环缺证据**
|
1. **真实共享预生产环境验证闭环缺证据**
|
||||||
- 影响:无法证明 Gate B 已满足
|
- 影响:无法证明共享预生产环境已满足 Gate B
|
||||||
- 建议:补预生产验证记录(真实 DB / migration / webhook / audit / ticket)
|
- 建议:在共享预生产环境复跑 `scripts/verify_preprod_gate_b.sh` 并补同结构记录
|
||||||
|
|
||||||
2. **部署侧 fail-fast 与运行基线未闭环**
|
2. **真实共享预生产/灰度环境运行证据未闭环**
|
||||||
- 影响:代码已具备门禁,但部署入口仍可能绕过或缺失运行保障
|
- 影响:本地脚本化演练不能替代真实共享预生产/灰度环境的放量与回滚证据
|
||||||
- 建议:补 DevOps 基线、监控、回滚 runbook
|
- 建议:在真实共享预生产环境复跑 Gate B,并在同环境执行一次回滚演练留痕
|
||||||
|
|
||||||
### Important
|
### Important
|
||||||
1. **代码级通过与生产放行边界仍需持续防漂移**
|
1. **代码级通过与生产放行边界仍需持续防漂移**
|
||||||
@@ -146,7 +157,9 @@ go vet ./...
|
|||||||
因此 QA 当前给出的正式门禁结论是:
|
因此 QA 当前给出的正式门禁结论是:
|
||||||
|
|
||||||
- **代码级门禁:通过**
|
- **代码级门禁:通过**
|
||||||
- **预生产门禁:未通过**
|
- **本地/容器化 Gate B 预演:通过**
|
||||||
|
- **本地/容器化 Gate C 回滚演练:通过**
|
||||||
|
- **真实共享预生产门禁:未通过**
|
||||||
- **生产放行门禁:未通过**
|
- **生产放行门禁:未通过**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -69,7 +69,8 @@ func setActorHeaders(req *http.Request, actorID, role string) {
|
|||||||
// 1. Webhook triggers handoff → ticket created
|
// 1. Webhook triggers handoff → ticket created
|
||||||
// 2. Ticket is assigned to an agent
|
// 2. Ticket is assigned to an agent
|
||||||
// 3. Ticket is resolved by the 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) {
|
func TestFullTicketFlow_E2E(t *testing.T) {
|
||||||
application := newTestAppE2E(t)
|
application := newTestAppE2E(t)
|
||||||
server := httptest.NewServer(application.Server.Handler)
|
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"])
|
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)
|
getURL := fmt.Sprintf("%s/api/v1/customer-service/tickets/%s", baseURL, ticketID)
|
||||||
getReq, err := http.NewRequest(http.MethodGet, getURL, nil)
|
getReq, err := http.NewRequest(http.MethodGet, getURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -186,14 +216,14 @@ func TestFullTicketFlow_E2E(t *testing.T) {
|
|||||||
t.Fatalf("decode ticket response error = %v", err)
|
t.Fatalf("decode ticket response error = %v", err)
|
||||||
}
|
}
|
||||||
tkt := ticketPayload["ticket"].(map[string]any)
|
tkt := ticketPayload["ticket"].(map[string]any)
|
||||||
if tkt["status"] != "resolved" {
|
if tkt["status"] != "closed" {
|
||||||
t.Fatalf("[step4] ticket status = %v, want resolved", tkt["status"])
|
t.Fatalf("[step5] ticket status = %v, want closed", tkt["status"])
|
||||||
}
|
}
|
||||||
if tkt["assigned_to"] != "agent-e2e-001" {
|
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" {
|
if tkt["resolution"] != "refund processed and confirmed" {
|
||||||
t.Fatalf("[step4] resolution = %v, want 'refund processed and closed'", tkt["resolution"])
|
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)
|
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
|
// Final state check: proves all audit writes succeeded in order
|
||||||
getURL := fmt.Sprintf("%s/api/v1/customer-service/tickets/%s", baseURL, ticketID)
|
getURL := fmt.Sprintf("%s/api/v1/customer-service/tickets/%s", baseURL, ticketID)
|
||||||
getReq, err := http.NewRequest(http.MethodGet, getURL, nil)
|
getReq, err := http.NewRequest(http.MethodGet, getURL, nil)
|
||||||
@@ -627,13 +674,13 @@ func TestFullTicketFlow_StateTransitionAuditOrder(t *testing.T) {
|
|||||||
}
|
}
|
||||||
tkt := finalPayload["ticket"].(map[string]any)
|
tkt := finalPayload["ticket"].(map[string]any)
|
||||||
|
|
||||||
if tkt["status"] != "resolved" {
|
if tkt["status"] != "closed" {
|
||||||
t.Fatalf("final status = %v, want resolved", tkt["status"])
|
t.Fatalf("final status = %v, want closed", tkt["status"])
|
||||||
}
|
}
|
||||||
if tkt["assigned_to"] != "agent-order-1" {
|
if tkt["assigned_to"] != "agent-order-1" {
|
||||||
t.Fatalf("final assigned_to = %v, want agent-order-1", tkt["assigned_to"])
|
t.Fatalf("final assigned_to = %v, want agent-order-1", tkt["assigned_to"])
|
||||||
}
|
}
|
||||||
if tkt["resolution"] != "handled" {
|
if tkt["resolution"] != "confirmed" {
|
||||||
t.Fatalf("final resolution = %v, want handled", tkt["resolution"])
|
t.Fatalf("final resolution = %v, want confirmed", tkt["resolution"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user