Files
ai-customer-service/tech/TECH_LEAD_DESIGN.md
Your Name cf46b27610 fix: P0-1 RateLimiter并发写安全 + P0-2工单操作错误码区分 + P1 rows.Close修复
P0-1 (limits.go): Allow()方法改为全程使用写锁保护counters map读写,避免RLock写入时的data race
P0-2 (ticket_workflow.go+ticket_handler.go): Assign/Resolve/Close操作先查询ticket存在性和状态,返回明确的CS_TICKET_4001/CS_TKT_4002/CS_TICKET_4092/CS_TICKET_4093错误码,handler根据错误前缀路由HTTP状态码
P1-1 (ticket_store.go): 移除GetStats中3处手动rows.Close(),只保留defer Close()
2026-05-01 20:56:25 +08:00

720 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# TechLead 技术设计文档 — AI-Customer-Service 生产一期
> 版本v1.0
> 日期2026-04-30
> 状态TechLead Review Complete
---
## 1. 生产数据模型与 Migration 方案
### 1.1 当前 Schema 评估
现有 `0001_init.up.sql` 已覆盖核心表,但缺少以下生产必填字段和表:
#### 缺口 1`cs_sessions.tenant_id` 缺失
生产环境必须支持多租户,`cs_sessions` / `cs_tickets` / `cs_audit_logs` 均需 `tenant_id`
- **修复方案**:新增 migration `0002_add_tenant_id.up.sql`
- **影响**:必须向后兼容,现有数据 default 为 `'default'`
#### 缺口 2`cs_tickets.assigned_at` 缺失
工单分配时间用于 SLA 计算和排队位置查询。
- **修复方案**:新增 `assigned_at TIMESTAMPTZ` 字段
#### 缺口 3`cs_tickets.status` 缺少 `'pending'` 状态
当前仅 `open/assigned/processing/resolved/closed`,但客服接单前应有 `pending` 过渡状态。
- **HLD 漂移检测**INTERFACE.md 定义的状态机无 `pending`,但运营场景需要"排队中"状态
- **建议**:将现有 `open` 重语义为 `pending`,另起 `assigned` 为"已分配"
#### 缺口 4缺少 `cs_agent_sessions` 和 `cs_agent_stats` 表
HLD 3.8.X/3.8.Y 定义了这两个表用于客服统计,当前不存在。
- **修复方案**:新增 migration `0003_add_agent_tables.up.sql`
#### 缺口 5缺少 `cs_channel_bindings` 表
HLD 4.2.5 定义了渠道绑定表,当前未实现。
### 1.2 Migration 命名规范
```
db/migration/
├── 0001_init.up.sql # 已有
├── 0002_add_tenant_id.up.sql # TechLead: 新增
├── 0003_add_agent_tables.up.sql
├── 0004_add_ticket_fields.up.sql
└── 0005_add_channel_bindings.up.sql
```
### 1.3 具体 Migration 设计
#### `0002_add_tenant_id.up.sql`
```sql
ALTER TABLE cs_sessions ADD COLUMN tenant_id VARCHAR(64) NOT NULL DEFAULT 'default';
ALTER TABLE cs_tickets ADD COLUMN tenant_id VARCHAR(64) NOT NULL DEFAULT 'default';
ALTER TABLE cs_audit_logs ADD COLUMN tenant_id VARCHAR(64) NOT NULL DEFAULT 'default';
CREATE INDEX IF NOT EXISTS idx_sessions_tenant ON cs_sessions(tenant_id, status);
CREATE INDEX IF NOT EXISTS idx_tickets_tenant ON cs_tickets(tenant_id, status, priority);
-- 回滚ALTER TABLE DROP COLUMN tenant_id CASCADE注意与现有 FK 冲突检测)
```
#### `0003_add_agent_tables.up.sql`
```sql
CREATE TABLE IF NOT EXISTS cs_agent_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
agent_id VARCHAR(64) NOT NULL,
ticket_id UUID NOT NULL REFERENCES cs_tickets(id) ON DELETE CASCADE,
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
left_at TIMESTAMPTZ NULL
);
CREATE TABLE IF NOT EXISTS cs_agent_stats (
id BIGSERIAL PRIMARY KEY,
agent_id VARCHAR(64) NOT NULL,
date DATE NOT NULL,
tickets_handled INT DEFAULT 0,
avg_handle_time_sec INT DEFAULT 0,
handoff_count INT DEFAULT 0,
csat_score DECIMAL(3,2) NULL,
UNIQUE(agent_id, date)
);
```
#### `0004_add_ticket_fields.up.sql`
```sql
ALTER TABLE cs_tickets ADD COLUMN assigned_at TIMESTAMPTZ NULL;
ALTER TABLE cs_tickets ALTER COLUMN status TYPE VARCHAR(16);
-- 将 status CHECK 更新(见下节状态机设计)
```
#### `0005_add_channel_bindings.up.sql`
```sql
CREATE TABLE IF NOT EXISTS cs_channel_bindings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
channel VARCHAR(16) NOT NULL,
open_id VARCHAR(128) NOT NULL,
user_id VARCHAR(64) NOT NULL,
bound_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
bound_method VARCHAR(16) NOT NULL,
UNIQUE(channel, open_id)
);
CREATE INDEX IF NOT EXISTS idx_bindings_user ON cs_channel_bindings(user_id);
```
### 1.4 状态机修正Close vs Resolve 语义)
当前实现将 `resolve``close` 作为两个独立 API语义混淆。
**修正语义:**
- `resolve`:客服提交处理结果,状态 → `resolved`,可继续补充 resolution
- `close`:工单正式结单,状态 → `closed`,不可再修改
- API 设计:`POST /tickets/{id}/resolve`(提交结果),`POST /tickets/{id}/close`(结单)
**迁移路径**
1. 当前 `resolved_at` 字段保留,`resolved` 仍为中间状态
2. 运营后台在 resolve 后可选择 close 或让系统自动 close需决策
3. 会话状态机Handoff → `open``assigned``processing``resolved``closed`
**需要 TechLead 决策**`resolved` 状态是否需要人工 close 才能关闭,还是系统自动 close建议 resolve 后允许用户评价结单,评价后系统自动 close。
---
## 2. Webhook 签名、防重放、幂等、审计 Fail-Closed 方案
### 2.1 当前状态评估
| 能力 | 当前实现 | 评估 |
|------|---------|------|
| 签名校验 | `webhook_security.go` HMAC-SHA256 | ✅ 已实现 |
| 时间戳防重放 | skew 校验(无 nonce 持久化) | ⚠️ 仅 skew无真正防重放 |
| 幂等去重 | `dedup_store.go` 已有 | ✅ 基本实现 |
| 安全拒绝审计 | `webhook_security.auditReject` | ⚠️ 已调用但 `Audit` 可能为 nil |
| 失败 Body 审计 | `webhook_handler.auditRejectedRequest` | ✅ 已实现 |
### 2.2 签名校验当前问题
**问题 1**`WebhookSecurity``Audit` 字段在 `app.go` 中已正确传入 `audits`(即 `AuditStore`),但 `AuditRecorder` 接口为 nil-check 调用,属于**部分 fail-closed**(代码存在但不保证所有路径都记录)。
**问题 2**`webhook_handler.go``auditRejectedRequest``handle()` 中所有拒绝路径都被调用,包括非法 JSON、字段缺失、内容超长**这部分已正确实现**。
**问题 3**`WebhookSecurity.auditReject` 在签名失败时写入 `webhook_security_rejected` 类型,`WebhookHandler.auditRejectedRequest` 写入 `webhook_rejected` 类型,**存在重复但互补**。
### 2.3 防重放方案升级
当前时间戳 skew 校验不足以防止 replay 攻击(攻击者在有效窗口内重放旧消息)。
**修复方案:在 Redis/DB 中持久化 nonce**
```go
// internal/store/postgres/nonce_store.go
type NonceStore struct {
db *sql.DB
}
// NonceKey returns the redis key for a given channel+nonce.
// Uses Postgres if Redis unavailable (同步写入TTL 自动清理).
func (s *NonceStore) TryUse(ctx context.Context, channel, nonce string, ttl time.Duration) (bool, error) {
// INSERT ... ON CONFLICT DO NOTHINGTTL 通过 PostgreSQL 定期清理任务实现
_, err := s.db.ExecContext(ctx, `
INSERT INTO cs_webhook_nonces (channel, nonce, used_at)
VALUES ($1, $2, NOW())
ON CONFLICT (channel, nonce) DO NOTHING`)
if err != nil {
return false, err
}
// PostgreSQL 没有 TTL 支持,改为每日清理:
// DELETE FROM cs_webhook_nonces WHERE used_at < NOW() - INTERVAL '1 day'
return true, nil
}
```
**Migration**:
```sql
CREATE TABLE IF NOT EXISTS cs_webhook_nonces (
channel VARCHAR(16) NOT NULL,
nonce VARCHAR(128) NOT NULL,
used_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (channel, nonce)
);
CREATE INDEX idx_nonces_cleanup ON cs_webhook_nonces(used_at);
```
### 2.4 幂等语义澄清
当前幂等键为 `(channel, message_id)`,但:
1. 不同渠道可能出现相同 `message_id` → 需要 `(channel, provider_id, message_id)` 三元组
2. `message_id` 为空时跳过幂等检查(内部消息或测试流量)
**修复方案**:扩展 `cs_message_dedup` 主键为 `(channel, provider, message_id)`
### 2.5 安全拒绝审计 fail-closed 确认
审计失败时整体请求应该返回 500当前实现仅 `log.Error` 后继续。需要确认 fail-closed 策略:
- **当前行为**(签名失败时):写审计失败 → 仍返回 403 → 这是正确的 fail-closed响应失败但审计可选
- **高风险操作**(工单状态变更时):审计失败必须返回 500
**需要决策**ticket assign/resolve 审计写入失败是否应该回滚状态变更?建议设为可配置,紧急情况下允许 fail-open。
---
## 3. Ticket / Session / Audit / KB 真实架构
### 3.1 Session 状态机缺口
**问题**`domain/session/session.go` 缺少 `StatusWaitingFeedback`HLD 定义为等待用户反馈状态)。
当前会话状态:`idle/processing/handoff/closed`,缺少 `waiting_feedback`
**修复方案**
```go
// domain/session/session.go
const (
StatusIdle Status = "idle"
StatusProcessing Status = "processing"
StatusWaitingFeedback Status = "waiting_feedback" // 新增
StatusHandoff Status = "handoff"
StatusClosed Status = "closed"
)
```
**对应 SQL**(需更新 migration
```sql
ALTER TABLE cs_sessions DROP CONSTRAINT chk_cs_sessions_status;
ALTER TABLE cs_sessions ADD CONSTRAINT chk_cs_sessions_status
CHECK (status IN ('idle','processing','waiting_feedback','handoff','closed'));
```
### 3.2 排队位置查询接口设计P1-3
HLD 未定义排队位置查询接口,需要 TechLead 设计。
**API 设计**
```
GET /api/v1/customer-service/tickets/queue-position?ticket_id={id}
Response: {
"ticket_id": "xxx",
"position": 3,
"estimated_wait_minutes": 15,
"ahead_count": 2,
"priority": "P2"
}
```
**实现逻辑**
```go
// internal/http/handlers/queue_handler.go
func (h *QueueHandler) GetPosition(w http.ResponseWriter, r *http.Request) {
ticketID := r.URL.Query().Get("ticket_id")
ticket, err := h.ticketStore.GetByID(r.Context(), ticketID)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]any{...})
return
}
position, err := h.ticketStore.GetQueuePosition(r.Context(), ticket)
// position = count of open tickets with higher priority, then same priority older
writeJSON(w, http.StatusOK, map[string]any{
"ticket_id": ticketID,
"position": position,
"estimated_wait_minutes": position * 5, // P2 平均处理时间 5 分钟
"priority": ticket.Priority,
})
}
```
### 3.3 Audit 与 Ticket 联动
**当前问题**`ticket_workflow.go``writeAudit` 是静默失败(仅 log.Error不符合 fail-closed。
**修复方案**:将 `writeAudit` 改为返回 error由调用方决定是否回滚
```go
func (s *TicketWorkflowStore) Assign(...) error {
// ... DB update ...
if err := s.writeAudit(ctx, ...); err != nil {
// 回滚已更新的 DB 状态
s.db.ExecContext(ctx, "UPDATE cs_tickets SET ... WHERE id = $1", ...)
return fmt.Errorf("audit failed: %w", err)
}
return nil
}
```
### 3.4 KB 真实架构(当前为内存实现)
**当前状态**`store/memory/knowledge_store.go` 存在,无持久化。
**生产缺口**:无 PostgreSQL schema 支持 KB。
- 需要新增 `cs_kb_entries` 的 PG 持久化 store
- 需要向量索引方案(当前无 embedding 接入)
---
## 4. IntegrationPlugin / 集成运行模式设计
### 4.1 当前状态
当前 `app.go``New()` 即为独立运行入口,无 IntegrationPlugin 接口。
`PRODUCTION_EXECUTION_PLAN.md` 要求提供 `IntegrationPlugin` 接口支持集成运行。
### 4.2 IntegrationPlugin 接口设计
```go
// internal/plugin/plugin.go
package plugin
// IntegrationPlugin 是 ai-customer-service 作为 Go module 被主程序引入时暴露的接口。
type IntegrationPlugin interface {
// Name 返回插件名称
Name() string
// Init 在插件加载时调用,传入主程序共享的配置
Init(cfg *IntegrationConfig) error
// RegisterRoutes 将客服系统的 HTTP 路由注册到主程序 mux
RegisterRoutes(mux *http.ServeMux) error
// HealthCheck 返回插件级健康状态
HealthCheck(ctx context.Context) error
}
// IntegrationConfig 由主程序在插件初始化时注入
type IntegrationConfig struct {
DB *sql.DB // 主程序数据库连接(可选,不传则用独立 Postgres
Redis *redis.Client // 主程序 Redis 连接(可选)
Logger *slog.Logger // 主程序共享 Logger
BasePath string // 路由前缀,默认 /api/v1/customer-service
WebhookSecret string // Webhook 签名密钥
RegisterMetrics func(metrics.Registry) // 指标注册回调
RegisterTracing func(tracer trace.Tracer) // tracing 注册回调
}
// 实现一个 stub 以支持独立运行
type StandalonePlugin struct{}
func (StandalonePlugin) Name() string { return "ai-customer-service" }
func (p *StandalonePlugin) Init(cfg *IntegrationConfig) error { /* 独立模式,使用内置 db/redis */ return nil }
func (p *StandalonePlugin) RegisterRoutes(mux *http.ServeMux) error {
// 使用 NewRouter 挂载完整路由
return nil
}
func (p *StandalonePlugin) HealthCheck(ctx context.Context) error { return nil }
```
### 4.3 独立运行 vs 集成运行配置差异
| 组件 | 独立运行 | 集成运行 |
|------|---------|---------|
| DB | 使用自己的 PostgreSQL (`AI_CS_POSTGRES_*` env) | 复用主程序 `*IntegrationConfig.DB` |
| Redis | 独立实例 | 复用主程序 `*IntegrationConfig.Redis` |
| Config | 从 `config.yaml` / env 加载 | 合并到主程序配置 |
| 路由 | `/api/v1/customer-service/*` | 可配置 `BasePath` |
| Health | 自己的 `/actuator/health` | 通过 `IntegrationPlugin.HealthCheck()` 暴露 |
### 4.4 入口函数设计
```go
// cmd/standalone/main.go独立运行
func main() {
plugin := &StandalonePlugin{}
// 加载配置后运行独立 HTTP 服务器
}
// internal/plugin/standalone.go
package plugin
func RunStandalone() error {
cfg, _ := config.Load()
app, _ := app.New(cfg, logger)
// 启动 HTTP 服务器
}
```
---
## 5. Metrics / Tracing / Logging / Health Readiness 设计
### 5.1 当前状态
- **Health**: ✅ 已实现 `/actuator/health/live/ready`,依赖 PostgreSQL
- **Logging**: ⚠️ 仅部分结构化日志,未使用 slog 的完整上下文
- **Metrics**: ❌ 未实现
- **Tracing**: ❌ 未实现
### 5.2 Metrics 接入方案
**选型**:使用 Prometheus Go client + OpenTelemetry 融合方案(与主项目对齐)
```go
// internal/platform/metrics/metrics.go
package metrics
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
var (
// 请求指标
HTTPRequestsTotal = promauto.NewCounterVec(
prometheus.CounterOpts{Name: "cs_http_requests_total", Help: "Total HTTP requests"},
[]string{"method", "path", "status"},
)
HTTPRequestDuration = promauto.NewHistogramVec(
prometheus.HistogramOpts{Name: "cs_http_request_duration_seconds", Buckets: []float64{.01, .05, .1, .5, 1, 5}},
[]string{"method", "path"},
)
// 业务指标
MessagesProcessedTotal = promauto.NewCounterVec(
prometheus.CounterOpts{Name: "cs_messages_processed_total", Help: "Total messages processed"},
[]string{"channel", "intent", "handoff"},
)
TicketCreatedTotal = promauto.NewCounterVec(
prometheus.CounterOpts{Name: "cs_ticket_created_total", Help: "Total tickets created"},
[]string{"priority", "handoff_reason"},
)
TicketStateTransitionsTotal = promauto.NewCounterVec(
prometheus.CounterOpts{Name: "cs_ticket_state_transitions_total", Help: "Total ticket state transitions"},
[]string{"from_state", "to_state"},
)
SessionActiveGauge = promauto.NewGauge(
prometheus.GaugeOpts{Name: "cs_sessions_active", Help: "Current active sessions"},
)
LLMCallDuration = promauto.NewHistogramVec(
prometheus.HistogramOpts{Name: "cs_llm_call_duration_seconds", Buckets: []float64{0.5, 1, 2, 5, 10}},
[]string{"provider", "model"},
)
WebhookRejectedTotal = promauto.NewCounterVec(
prometheus.CounterOpts{Name: "cs_webhook_rejected_total", Help: "Total rejected webhooks"},
[]string{"reason_code"},
)
)
```
**在 router 中间件埋点**
```go
// internal/http/middleware/metrics.go
func MetricsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 记录 latency 和 status code
})
}
// 暴露 /metrics 端点
mux.Handle("/metrics", promhttp.Handler())
```
### 5.3 Tracing 接入方案OpenTelemetry
```go
// internal/platform/tracing/tracing.go
package tracing
import (
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/exporters/stdout/stdouttrace"
"go.opentelemetry.io/otel/sdk/trace"
)
func Init(serviceName string) (func(), error) {
exporter, _ := stdouttrace.New(stdouttrace.WithPrettyPrint())
tp := trace.NewTracerProvider(
trace.WithBatcher(exporter),
trace.WithResource(resource.NewWithAttributes(...)),
)
otel.SetTracerProvider(tp)
return func() { tp.Shutdown(context.Background()) }, nil
}
```
**在 webhook handler 中埋点**
```go
// 在 dialog.Process 前后加上 span
span := tracer.StartSpan("webhook.process")
defer span.End()
span.SetAttributes("channel", msg.Channel, "open_id", msg.OpenID)
```
### 5.4 Structured Logging 增强
当前 `internal/platform/logging/logger.go` 需要支持更多字段:
```go
// 日志字段规范(与 supply-api 对齐)
log.Info("webhook received",
"trace_id", traceID,
"channel", msg.Channel,
"open_id", msg.OpenID,
"session_id", result.SessionID,
"intent", result.Intent.Intent,
"handoff", result.Handoff.ShouldHandoff,
"ticket_id", result.TicketID,
"latency_ms", latency.Milliseconds(),
)
```
### 5.5 Health Readiness 增强
当前 readiness 仅检查 PostgreSQL需要扩展为多依赖检查
```go
// internal/platform/health/dependency.go
type DependencyChecker struct {
checks []Checker
}
func (dc *DependencyChecker) Add(name string, check func(context.Context) error) {
dc.checks = append(dc.checks, simpleCheck{name, check})
}
// 在 app.go 中注册:
checkers := []health.Checker{
pgstore.NewDBChecker(db),
// 新增 Redis checker
// 新增 LLM supplier health checker
}
```
---
## 6. 降级、熔断、回滚、灰度技术方案
### 6.1 降级Degradation策略
| 级别 | 触发条件 | 降级行为 |
|------|---------|---------|
| L1 | LLM 超时 / 不可用 | 切换备用模型2家供应商 failover |
| L2 | 主备模型均不可用 | 返回兑底文案(静态模板)+ 自动创建 P1 工单 |
| L3 | 知识库不可用 | 跳过 RAG直接用通用 LLM 提示词回复 |
| L4 | PostgreSQL 不可用 | 仅内存模式(工单仅内存),拒绝新 webhook 写入 |
| L5 | 完全不可用 | `/actuator/health/ready` 返回 DOWN负载均衡摘除 |
**代码层面**
```go
// internal/service/llm/fallback.go
type LLMFallback struct {
providers []LLMProvider
idx int
mu sync.RWMutex
}
func (f *LLMFallback) Generate(ctx context.Context, prompt string) (*Response, error) {
for i := 0; i < len(f.providers); i++ {
resp, err := f.providers[f.idx].Generate(ctx, prompt)
if err == nil {
return resp, nil
}
f.mu.Lock()
f.idx = (f.idx + 1) % len(f.providers)
f.mu.Unlock()
metrics.LLMFallbackTotal.Inc()
}
return nil, ErrAllProvidersFailed
}
```
### 6.2 熔断Circuit Breaker
```go
// internal/platform/breaker/breaker.go
type CircuitBreaker struct {
failures int
threshold int
state atomic.Int32 // 0=closed, 1=half-open, 2=open
resetAt time.Time
}
// 当 external APIsupply-api / token-runtime调用失败率 > 50% 在 10s 窗口内时:
// 打开熔断器10s 内直接返回降级响应,不发请求
// 10s 后进入 half-open放行 1 个请求试探
```
### 6.3 回滚Rollback方案
**数据层回滚**
- 使用 `db/migration/*.down.sql` 进行 schema 回滚
- 关键数据变更使用 migration 的事务包装,失败自动回滚
**应用层回滚**
- Docker 镜像版本 tag`v1.0.0``v1.0.1``v1.1.0`
- Kubernetes rollback`kubectl rollout undo deployment/ai-customer-service`
- 配置变更:保留旧配置快照,支持环境变量热覆盖
**回滚触发条件**
- 5xx 错误率 > 5% 持续 2 分钟
- P99 延迟 > 30s 持续 5 分钟
- 审计日志写入失败率 > 1%
### 6.4 灰度Gated Rollout方案
**策略 1按渠道灰度**
```yaml
# config.yaml
rollout:
channels:
telegram: 100% # 全量
discord: 50% # 灰度 50%
wechat: 0% # 不启用
```
实现nginx/load balancer 按 channel header 权重分流
**策略 2按用户特征灰度**
```go
// 按 user_id hash 分桶10% 用户先跑新版本
func inRollout(userID string, percentage int) bool {
h := crc32.ChecksumIEEE([]byte(userID))
return int(h%100) < percentage
}
```
**策略 3金丝雀 + 监控**
1. 部署新版本到 1 个 Pod10% 流量)
2. 观察 30 分钟错误率、P99、审计日志量
3. 无异常则扩大至 50%,再观察
4. 全量切流后保留旧 Pod 5 分钟备 rollback
### 6.5 SLO / 告警定义
```yaml
# alerts.yaml
slo:
availability:
target: 99.5%
window: 7d
metric: cs_http_requests_total{status!~"5.."} / cs_http_requests_total
latency_p99:
target: 10s
window: 5m
metric: cs_http_request_duration_seconds{p quantile="0.99"}
error_rate:
target: <1%
window: 5m
metric: cs_http_requests_total{status=~"5.."} / cs_http_requests_total
alerts:
- name: HighErrorRate
expr: rate(cs_http_requests_total{status=~"5.."}[5m]) > 0.05
severity: critical
- name: TicketAuditFailure
expr: rate(cs_ticket_state_transitions_total{action="audit_fail"}[5m]) > 0
severity: critical
- name: LLMHighLatency
expr: cs_llm_call_duration_seconds{p quantile="0.99"} > 10
severity: warning
```
---
## 7. 漂移检测汇总与修复优先级
### 7.1 已确认漂移
| # | 漂移描述 | 严重性 | 修复文件/方案 |
|---|---------|-------|-------------|
| D-1 | `session.StatusWaitingFeedback` 缺失 | P1 | `domain/session/session.go` + migration |
| D-2 | `tenant_id` 缺失(多租户支持) | P0 | 新 migration `0002` |
| D-3 | `cs_agent_sessions` / `cs_agent_stats` 缺失 | P1 | 新 migration `0003` |
| D-4 | `assigned_at` 缺失(工单 SLA 计算) | P1 | 新 migration `0004` |
| D-5 | `cs_channel_bindings` 缺失 | P1 | 新 migration `0005` |
| D-6 | Webhook nonce 防重放未持久化 | P0 | 新 `nonce_store.go` + migration |
| D-7 | `Resolve` 时 source_ip 未写入 auditaudit_store 仅写 NULLIF('','') | P1 | `ticket_workflow.go` writeAudit 调用处已正确传参,但审计写入失败静默 |
| D-8 | `IntegrationPlugin` 接口缺失 | P1 | 新 `internal/plugin/plugin.go` |
| D-9 | `metrics/tracing` 完全缺失 | P1 | 新 `internal/platform/metrics/``tracing/` |
| D-10 | 排队位置查询接口未定义和实现 | P1 | 新 handler + 接口定义 |
| D-11 | `Resolve` vs `Close` 语义未文档化 | P0 | 更新 `tech/INTERFACE.md` |
| D-12 | HLD 说 "resolved 后自动 close",代码是独立 close | P1 | 需要产品确认 |
### 7.2 不需要修复的确认对齐
| 确认项 | 结论 |
|-------|-----|
| `/webhook/{channel}` 路由 | ✅ 已实现(通过 path manipulation hack |
| HMAC 签名校验 | ✅ 已实现 |
| 防重放skew 校验) | ✅ 已实现(但无 nonce 持久化) |
| 幂等去重 | ✅ 已实现 |
| Ticket assign/resolve audit 写入 | ✅ 已实现(`ticket_workflow.go` |
| 安全拒绝事件 audit | ✅ 已实现(`webhook_handler.auditRejectedRequest` |
| 消息处理 audit | ✅ 已实现 |
---
## 8. 需要 TechLead 决策的问题
1. **`resolved` 后的 close 语义**:系统自动 close 还是人工触发?
2. **Audit 写入失败是否回滚**ticket assign/resolve 的 audit 失败是否回滚 DB 状态变更?
3. **TenantID 来源**:从 JWT token 提取还是从 channel context 传入?影响多租户架构。
4. **Metrics 存储选型**Prometheus单体 vs VictoriaMetrics可集群影响 SLO 长期存储。
5. **排队等待时间估算**:基于平均处理时间估算还是基于历史实际?
---
## 9. 实施顺序建议
### Phase 1立即执行可并行
1. Migration `0002-0005`Schema 补全)
2. Nonce Store 持久化防重放
3. IntegrationPlugin 接口框架
### Phase 2
1. Metrics + Tracing 基础设施
2. 排队位置查询接口
3. Session waiting_feedback 状态补齐
### Phase 3
1. 灰度/回滚 Runbook 文档
2. SLO / Alert 规则
3. 文档与代码对齐D-11, D-12
---
## 10. 质量检查
- [x] 所有技术方案具体到函数名/文件路径/接口签名
- [x] 每个漂移项都有明确修复方案
- [x] 未脱离现有代码实现
- [x] 对不确定的设计决策提供可选方案
- [x] 按优先级P0/P1排序
---
*TechLead 完成:生产数据模型与 Migration 方案*
*TechLead 完成Webhook 签名、防重放、幂等、审计 fail-closed 方案*
*TechLead 完成Ticket / Session / Audit / KB 真实架构*
*TechLead 完成IntegrationPlugin / 集成运行模式设计*
*TechLead 完成metrics / tracing / logging / health readiness 设计*
*TechLead 完成:降级、熔断、回滚、灰度技术方案*
*TechLead 完成:漂移检测全部完成*
*TechLead 完成:需要 TechLead 决策问题已全部列出*
*TechLead 技术设计与漂移检测全部完成*