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()
720 lines
25 KiB
Markdown
720 lines
25 KiB
Markdown
# 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 NOTHING,TTL 通过 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 API(supply-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 个 Pod(10% 流量)
|
||
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 未写入 audit(audit_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 技术设计与漂移检测全部完成* |