# 技术方案(正式)— 选项选择持久化 v1.1 > **REQ 编号**: REQ-通用-005 > **版本**: v1.1 — 正式方案(基于设计稿 + 用户 5 决策) > **日期**: 2026-08-02 > **作者**: 高见远(架构师 · Bob) > **状态**: ✅ 可执行(无需再评审修复路径) > **基线**: v1.0 技术方案(已部署生产,35 条用例验收通过;本次只增不删) > **输入**: > - PRD v1.1 增量草案:`docs/01-产品文档/00-产品规划/PRD-REQ-通用-005-选项选择持久化-v1.1-增量草案.md`(许清楚,2026-08-02) > - 设计稿:本文档前序章节(v1.1 — 方案设计.md,2026-08-02) > - 用户 5 决策(2026-08-02 18:10 接收): > 1. 修复顺序:**Bug 6 → Bug 4 → Bug 5 → Req 7** > 2. Dify v3 导入:**2026-08-09 前导入**(用户承诺) > 3. Req 7 时延指标:**< 100ms 端到端**(员工→坐席) > 4. Bug 5 视觉分组:**方案 1**(程序计算分组,不拉 UX) > 5. BackendObserver 监控埋点:**✅ 同意作为 Step 4 强制交付** --- ## §0 变更记录(v1.1 正式方案相对设计稿的差异) | # | 项 | 设计稿(v1.1 设计) | v1.1 正式方案 | 原因 | |---|---|---|---|---| | 1 | 修复顺序 | P0 优先级按"风险/价值"建议:Bug 6 → Bug 4 → Bug 5 → Req 7 | **采纳设计稿建议 + 用户正式确认** | 决策 1 | | 2 | 任务粒度 | 5 步(Step 1~5),粒度偏粗 | 4 任务(Task 1~4),按"功能模块 + 依赖"合并,符合 P0 5 任务上限 | 工程派工更清晰 | | 3 | Bug 4 修复点 | 3 个文件(ws.py:407 / conversation.ts:1395 / h5_ai_task.py:443) | 同设计稿 | 已 read/grep 验证 | | 4 | Bug 5 视觉分组 | 方案 1(程序计算分组) | **采纳方案 1**,不加 UX 流程 | 决策 4 | | 5 | Req 7 监控 | "建议"扩展 BackendObserver | **强制交付**:BackendObserver 单例 + 2 个指标 + E2E puppeteer 脚本 | 决策 5 | | 6 | Req 7 时延预算 | 单 worker < 50ms;跨 worker 需 Redis Pub/Sub | **端到端 < 100ms**(员工点选项→坐席渲染 ✓) | 决策 3 | | 7 | H5 mask 漏洞 | 设计稿未提及 | 顺手修复(h5.py:1025)补 v1.0 §4.8 要求 | Bug 6 根因分析 A 场景已暴露 | | 8 | Dify 依赖 | "若 1 周内不导入则先发 B6+Req7" | **Dify 2026-08-09 前导入**,4 Task 时间窗内可用 | 决策 2 | | 9 | dist 强校验 | 4 证据链(dist 关键字 + 产物 hash + HTTP 200 + 浏览器实测) | 同设计稿,但**强制作为合并门禁** | 历史踩坑(v0.5/v1.0 P0-B/P0-C) | --- ## §1 架构概览(v1.0 + v1.1 增量点) ### 1.1 v1.0 基线(不重复,仅作参照) v1.0 已部署生产的稳定架构:员工点击 → `sendOptionSelect`(conversation.ts:1621-1737)生成 UUID + 6 字段 → `_handle_option_select`(ws.py:254-448)走 `pg_advisory_xact_lock` + 5s 幂等 → `messages` 表 INSERT → `ws_manager.broadcast` 给坐席(ws.py:407) → `process_h5_ai_reply`(h5_ai_task.py:443)推 `ai_reply` 给员工。Dify 5 字段 inputs、敏感词 16 位中间 4 位 mask、转人工 `selected_options` 快照、5 重 UUID 守卫(conversation.ts:1662-1680)皆在 v1.0 验收通过。 ### 1.2 v1.1 增量点(4 处补丁,不改变 v1.0 数据契约) ``` v1.0 → v1.1 增量架构图(增量点用 ★ 标注) 员工 → H5 ChatPanel 【★ 1】MessageBubble.vue:332 派生计算(不依赖内存 ref) ↓ 【★ 5】useH5WebSocket.ts:174 计算 RTT+上报 WS → ws._handle_option_select ├─ DB (commit) ─┐ │ ├─【★ 2a】ws.py:407 同时 broadcast_to_employees([employee_id]) │ │ (同步把员工的 option_select 推给员工端) ├─ ws_manager.broadcast (坐席) 【★ 4a】BackendObserver 落库 commit_ts vs 广播 send_ts └─ process_h5_ai_reply └─【★ 2b】h5_ai_task.py:443 broadcast_to_employees ai_reply (现状 OK) └─【★ 2c】h5_ai_task.py:443 在 Dify still_thinking 15s 兜底再广播 option_select 确认 【★ 3】ChatPanel.vue:96 视觉分组(option_select 按 question_id 折叠) Dify Workflow → ai_thinking → ai_reply → 选项气泡 ✓ ``` ### 1.3 v1.1 关键不变量(与 v1.0 兼容) | 不变量 | v1.1 行为 | |---|---| | 6 字段 inputs | ❌ 不变(PRD v1.0 §4.6) | | 5 重 UUID 守卫 | ❌ 不变(conversation.ts:1662-1680) | | msg_type 枚举 | ❌ 不变:`option_select / text / ai_structured / ...` | | extra_data 字段 | ❌ 不变:`question_id / option_id / option_value / client_msg_id / selected_from_message_id / option_label_masked` | | observer 指标命名规范 | 🆕 见 §7:`option_select_*` 前缀 | | `--workers 1` | ❌ 不变(deploy-server/docker-compose.yml:115) | --- ## §2 任务分解(按决策 1 顺序:Bug 6 → Bug 4 → Bug 5 → Req 7) > **任务粒度原则**:每个任务 ≥ 3 个相关文件;按"功能模块 + 依赖"合并;T01 是基础设施。 ### Task T01:项目基础设施(强制前置) | 字段 | 内容 | |---|---| | **目标** | 确认依赖、安装脚本、CI 强校验关卡 ready | | **文件清单** | `package.json`(H5 + Agent)、`pnpm-workspace.yaml`、`deploy-server/docker-compose.yml`、`scripts/build-and-verify.sh`(新增 4 证据链) | | **行数估算** | < 30 | | **验证标准** | `pnpm install` 通过;`docker compose config` 校验通过;`scripts/build-and-verify.sh` 4 证据链脚本可执行 | | **强校验证据** | ① pnpm-lock.yaml hash;② docker-compose config OK;③ 4 证据链脚本存在;④ CI pipeline green | ### Task T02:修复 Bug 6 — 刷新后选项 ✓ 视觉丢失(前置 / 解锁 QA) | 字段 | 内容 | |---|---| | **目标** | `isOptionSelected` 从 store 内存 ref 改为派生自 `messages`(刷新生效) | | **文件清单** | (a) `src/frontend-h5/src/components/chat/MessageBubble.vue:332-343`(`isOptionSelected` 重写,从 `messages` 过滤 `msg_type==='option_select'`);(b) `src/frontend-h5/src/stores/conversation.ts:241`(MessageBubble 调用点,从 `selectedOptionLabels` 切到 `computed` 暴露的 `selectedOptionIdsFromHistory`);(c) `src/backend/app/api/h5.py:1025`(顺手修复 v1.0 §4.8 mask 漏洞,对 `option_select` 调用 `mask_sensitive_text(content)`) | | **行数估算** | < 60 | | **验证标准** | ① H5 选选项 → ✓ 显示;② 浏览器刷新 → ✓ 依然显示;③ 长会话 (≥ 60 条) 滚动到顶部 → 早期选项 ✓ 依然显示;④ REST 返回的 option_select content 已 mask | | **强校验证据** | ① dist 含 `filteredOptionIdsFromHistory` 派生关键字;② dist 含 `h5_mask_v1_1` 函数调用痕迹;③ `pytest src/backend/tests/test_h5_mask_option_select.py -v` 通过;④ 浏览器实测截图 + dist hash | ### Task T03:修复 Bug 4 — 员工端 AI 回答延迟显示(核心痛点) | 字段 | 内容 | |---|---| | **目标** | 员工选选项后立即看到 ✓ 气泡;Dify 回 ai_reply 即使 conversation_id 微偏差也能落 UI | | **文件清单** | (a) `src/backend/app/api/ws.py:407-421`(在 `ws_manager.broadcast` 给坐席**之后**追加 `ws_manager.broadcast_to_employees([employee_id], {...})` 同步推自己的 `option_select`,确保员工端先看到 ✓);(b) `src/frontend-h5/src/stores/conversation.ts:1395-1401`(`handleAiReply` 软校验:conversation_id 不匹配时 `console.warn` + 仍尝试落 UI,仅当 message_id 已处理才 return);(c) `src/backend/app/tasks/h5_ai_task.py:443-458`(在 `broadcast_to_employees ai_reply` **之前**,增加 `broadcast_to_employees option_select` 确认事件,作为 still_thinking 兜底) | | **行数估算** | < 80 | | **验证标准** | ① 模拟弱网(puppeteer throttle 1Mbps + 100ms RTT)→ 选项后 < 500ms 看到 ✓;② 会话切换竞态 → 切换前选项仍在 UI;③ 强校验 4 证据链 | | **强校验证据** | ① dist 含 `broadcast_to_employees_option_select` 关键字;② dist 含 `soft_match_fallback` 关键字;③ ws.py 走 `grep "broadcast_to_employees"` 命中 2 处(ws.py + h5_ai_task.py);④ BackendObserver `option_select_broadcast_latency_ms` 指标可见 | ### Task T04:修复 Bug 5 + Req 7 — 视觉分组 + 时延监控埋点(合并任务) > 注:决策 1 顺序为 Bug 5 → Req 7;本任务按"功能聚合"合并,因 Req 7 监控依赖 dist 与 BackendObserver 基础设施,与 Bug 5 视觉分组在同一前端 release 周期内。决策 5 强制 Req 7 监控,故两者绑定。 | 字段 | 内容 | |---|---| | **目标** | (a) Bug 5:按 `question_id` 折叠视觉分组(方案 1,不拉 UX);(b) Req 7:BackendObserver 埋点 + 端到端测量脚本 | | **文件清单** | (a) `src/frontend-h5/src/components/chat/MessageBubble.vue:174-179`(`option_select` 模板加 `data-question-id` 属性 + `collapsed` class);(b) `src/frontend-h5/src/components/chat/ChatPanel.vue:96-112`(`store.messages` 改 computed `groupedMessagesByQuestion`,连续同 question_id 折叠为 1 组);(c) `src/backend/app/services/backend_observer.py`(**新增**单例,含 `record_event(metric_name, value, tags)` + 4 个指标:`option_select_persist_latency_ms` / `option_select_broadcast_latency_ms` / `option_select_e2e_latency_ms` / `option_select_dify_timeout_count`);(d) `src/backend/app/api/ws.py:407` + `src/backend/app/tasks/h5_ai_task.py:443`(嵌入 BackendObserver 计时点);(e) `src/frontend-h5/scripts/measure-option-latency.mjs`(**新增** puppeteer 脚本:员工点击 → WS 收到 → 坐席渲染 ✓,输出 p50/p95 + BackendObserver 查询 URL) | | **行数估算** | < 200 | | **验证标准** | ① Bug 5:选同一 question 不同 option → 上次选项灰底折叠,新选项高亮,新 AI 答案紧随;② Req 7:测量脚本 p95 < 100ms;BackendObserver GET /api/backend-observer/metrics 返回 4 指标 | | **强校验证据** | ① dist 含 `collapsed-question-group` class 关键字;② dist 含 `BackendObserver.record_event` 调用痕迹;③ puppeteer 脚本测量日志 p95 < 100ms;④ BackendObserver `/metrics` HTTP 200 + 含 4 指标 | ### 2.5 任务依赖图 ```mermaid graph TD T01[T01: 项目基础设施] --> T02[T02: Bug 6 — 刷新 ✓ 派生] T01 --> T03[T03: Bug 4 — 员工端 AI 延迟] T01 --> T04[T04: Bug 5 视觉分组 + Req 7 监控] T02 --> T04 T03 --> T04 ``` > **顺序备注**:T02(Bug 6)可在 T01 后**立即并行**开发,因只影响 H5 端。T03(Bug 4)跨前后端,但与 T02 无文件冲突,可并行。T04(Bug 5 + Req 7)依赖 T02/T03 落地后集成测试。决策 1 是上线顺序,不是开发顺序。 --- ## §3 数据结构和接口(类图) ```mermaid classDiagram direction LR %% ====== 数据模型(已存在) ====== class Message { +UUID id +UUID conversation_id +string sender_type +string sender_id +string content +string msg_type +dict extra_data +datetime created_at +string status } class OptionSelectExtraData { +string question_id +string option_id +string option_value +string client_msg_id +string selected_from_message_id +string option_label_masked +string option_label_original (DB only) } OptionSelectExtraData --o Message : embedded in extra_data JSONB %% ====== 服务类(已存在 + v1.1 新增) ====== class OptionSelectHandler { +__init__(session_factory) +handle(payload, employee_id) Message -acquire_advisory_lock(db, key) -find_duplicate(db, payload) -persist(db, payload, employee_id) } class ConnectionManager { +dict active_connections +dict employee_connections +__init__() +broadcast(data) +broadcast_to_employees(ids, data) } OptionSelectHandler --> ConnectionManager : broadcast + broadcast_to_employees (v1.1) %% ★ 新增 v1.1 class BackendObserver { <> +list events +record_event(metric_name, value, tags) void +get_metrics(name_filter) list +metric_names$ list~string~ } ConnectionManager ..> BackendObserver : 触发时延记录 OptionSelectHandler ..> BackendObserver : commit_ts 记录 class SensitiveMask { <> +mask_sensitive_text(text) string +mask_sensitive_value(value) string } %% ★ H5 REST 端点 v1.1 修复:调用 mask class H5MessageEndpoint { +h5_get_messages(limit, before) -_mask_outbound(messages) list } H5MessageEndpoint --> SensitiveMask : masks option_select content %% ====== 前端 store / component ====== class ConversationStore { +Message[] messages +bool wsConnected +Ref~string[]~ selectedOptionLabels (deprecated v1.1) +Ref~string[]~ selectedOptionIdsFromHistory (v1.1 ★) +sendOptionSelect(value,label,sourceId,qId,oId) void +handleNewMessage(data) void +handleAiReply(data) void [v1.1 软校验] +groupedMessagesByQuestion() Message[] (v1.1 ★) +latestSelectionForQuestion(qId) string|null } ConversationStore o-- Message : owns class MessageBubble { +Message msg +bool isLatestInGroup +bool isOptionSelectedComputed (v1.1 ★) +computed data-question-id (v1.1 ★) } MessageBubble --> ConversationStore : reads messages + isOptionSelectedComputed MessageBubble --> Message : renders class ChatPanel { +computed groupedMessages (v1.1 ★) +render bubbles } ChatPanel --> ConversationStore : groupedMessagesByQuestion ChatPanel --> MessageBubble : iter class UseH5WebSocket { +Ref wsConnected +computed rttMs (v1.1 ★) +onmessage handler } UseH5WebSocket --> BackendObserver : 上报 e2e latency (HTTP) UseH5WebSocket --> ConversationStore : dispatches handleNewMessage / handleAiReply class MeasureOptionLatencyScript { <> +run() Report -employeeClick() timestamp -agentRender() timestamp -diff() ms } MeasureOptionLatencyScript ..> BackendObserver : queries /metrics ``` ### 3.1 v1.1 新增 / 修改的对外契约 | 接口 | 位置 | 变更 | |---|---|---| | `BackendObserver.record_event(name, value, tags)` | `src/backend/app/services/backend_observer.py` | **新增** | | `BackendObserver.get_metrics(name_filter)` | 同上 | **新增**,供运维/前端查询 | | `GET /api/backend-observer/metrics?name=option_select_*` | 新增路由 | **新增**(只读,最近 N 条,JSON) | | `ConversationStore.selectedOptionIdsFromHistory` | `conversation.ts` 新增 computed | **新增**,替换 `selectedOptionLabels` 的视觉判定 | | `ConversationStore.groupedMessagesByQuestion` | 同上 | **新增**(Bug 5) | | `WSManager.broadcast_to_employees([employee_id], data)` | 已有,**新增调用点** | ws.py:407 之后 | | `h5_get_messages` 返回值对 `option_select.content` 做 mask | `h5.py:1025` | **修复**(v1.0 §4.8) | --- ## §4 程序调用流程(4 个时序) ### 时序 ① — 点选-广播-落库(Bug 6 主链路,含 T02 + T03) ```mermaid sequenceDiagram autonumber actor Emp as 员工 participant MB as MessageBubble participant Store as ConversationStore participant WS as ws._handle_option_select participant DB as PostgreSQL participant WSM as ws_manager participant Obs as BackendObserver participant Task as process_h5_ai_reply Emp->>MB: 点击 option MB->>Store: sendOptionSelect(value,label,...) Note over Store: lastSentOptionContent=label
optionSubmitting=true
5 重 UUID 守卫判定 Store->>WS: option_select(6 字段 + client_msg_id) WS->>WS: UUID 校验 WS->>DB: BEGIN + pg_advisory_xact_lock WS->>DB: SELECT 5s 内同 UUID alt 5s 内重复 DB-->>WS: 命中 dup WS-->>Store: 无副作用,结束 else 首次 WS->>DB: INSERT Message(msg_type=option_select) DB-->>WS: commit_ts = T1 %% ★ v1.1 T03: 同步推员工端 WS->>WSM: broadcast_to_employees([employee_id], new_message) WSM-->>Store: new_message (employee 端) Store->>Store: trackProcessedMessageId + push message Note over Store: 员工立即看到 ✓ 气泡
(不再依赖 Dify 兜底) %% 既有的坐席广播 WS->>WSM: broadcast(new_message) (坐席) WSM-->>Obs: 记录 broadcast_ts = T2 Obs-->>Obs: option_select_broadcast_latency_ms = T2 - T1 WS->>Task: create_task(process_h5_ai_reply) end Note over Obs,T1: ★ v1.1 Req 7:T2-T1 应 < 50ms(单 worker) ``` ### 时序 ② — AI 回复回流(T03 Bug 4 软校验 + T02 派生) ```mermaid sequenceDiagram autonumber participant Dify as Dify Workflow participant Task as process_h5_ai_reply participant DB as PostgreSQL participant WSM as ws_manager participant WS as H5 useH5WebSocket participant Store as ConversationStore participant MB as MessageBubble (员工) Dify-->>Task: 返回结构化 reply Task->>DB: INSERT Message(ai_text) DB-->>Task: commit %% ★ v1.1 T03: still_thinking 兜底 option_select 确认(若 Dify 超时) Task->>WSM: broadcast_to_employees(option_select confirm) [if still_thinking] WSM->>WS: ai_reply { type: "ai_reply", data: {...} } WS->>Store: handleAiReply(data) Note over Store: ★ v1.1 Bug 4 软校验:
conversation_id 不匹配 → console.warn
但不再 return,继续 push
(仅 message_id 已去重才 return) Store->>Store: trackProcessedMessageId Store->>Store: messages.value.push(finalMessage) Store-->>MB: reactive 触发重渲 MB->>MB: 显示 AI 答案气泡(打字机逐字) Note over MB: ★ v1.1 T02 派生:
isOptionSelected 改为从 messages 过滤
msg_type==='option_select' by question_id
不再依赖内存 ref MB->>MB: 渲染 ✓ (历史选项) + AI 答案(新) ``` ### 时序 ③ — 视觉分组(T04 Bug 5) ```mermaid sequenceDiagram autonumber actor Emp as 员工 participant Store as ConversationStore participant Panel as ChatPanel participant MB as MessageBubble Note over Store: messages 序列:
[ai_structured Q1]
[option_select Q1.optA] <-- 第 1 次
[ai_text 回复1]
[option_select Q1.optB] <-- 第 2 次(重选)
[ai_text 回复2] Emp->>Store: 选 Q1.optB Store->>Store: sendOptionSelect → WS Note over Store: groupedMessagesByQuestion (v1.1 ★)
按 question_id 相邻聚合
折叠:Q1.optA 标 collapsed class
高亮:Q1.optB 当前选项 Panel->>MB: v-for msg in groupedMessages MB->>MB: option_select 分支加 data-question-id="Q1" alt collapsed (历史同题选项) MB->>MB: class="message-bubble--option-select collapsed"
背景灰底 + 折叠图标 ▾ else current (本次选项) MB->>MB: class="message-bubble--option-select active"
蓝底 + ✓ end MB->>MB: ai_text 紧随其后,按时间顺序独立出现 Note over MB: ★ 顺序:① ✓当前 ② AI 思考占位 ③ AI 答案 ``` ### 时序 ④ — 时延监控埋点与端到端测量(T04 Req 7) ```mermaid sequenceDiagram autonumber actor Emp as 员工 (Chromium A) actor Agent as 坐席 (Chromium B) participant H5Emp as H5 useH5WebSocket (Emp) participant H5Agt as H5 useH5WebSocket (Agt) participant WS as ws._handle_option_select participant WSM as ws_manager participant Obs as BackendObserver participant Script as measure-option-latency.mjs Script->>Script: 启动双 context + login Script->>Script: t0 = performance.now() Emp->>H5Emp: click option H5Emp->>WS: option_select payload H5Emp->>H5Emp: 记录 client_send_ts = T0 WS->>WSM: broadcast_to_employees + broadcast WSM->>H5Agt: new_message(option_select) H5Agt->>H5Agt: 渲染 ✓ H5Agt->>Script: postMessage { event: 'rendered', ts: T_agent } H5Emp->>H5Emp: 收到自己的 new_message (员工端) H5Emp->>Obs: POST /api/backend-observer/record
{metric: 'option_select_e2e_latency_ms',
value: T_agent - T0} WSM->>Obs: 记录 option_select_persist_latency_ms
option_select_broadcast_latency_ms Script->>Script: t1 = performance.now() (员工点击到坐席渲染) Script->>Obs: GET /api/backend-observer/metrics?name=option_select_* Obs-->>Script: 4 个指标 Script->>Script: p50/p95 计算 + 告警 (>100ms warning) Script->>Script: 输出 report.json ``` --- ## §5 依赖包列表 | 包 | 版本约束 | 用途 | 是否新增 | |---|---|---|---| | puppeteer | ^23.0.0(devDependencies) | Req 7 端到端时延测量脚本 | 🆕 新增 | | Promise.withResolvers | 浏览器原生(Chrome 119+) | useH5WebSocket RTT 计算(无新依赖) | ❌ 沿用 | | Vue 3 + Pinia + Vite | 已存在 | 全部前端 | ❌ 不变 | | FastAPI + SQLAlchemy Async | 已存在 | 后端 | ❌ 不变 | | BackendObserver | 自研单例模块(无外部依赖) | 监控埋点 | 🆕 新模块,无第三方包 | > **结论**:v1.1 仅新增 `puppeteer`(devDependency,仅供 `scripts/measure-option-latency.mjs` 使用)。生产构建产物体积不受影响。BackendObserver 为自研文件,不引入 Prometheus 等重型依赖,符合"轻量观测"原则。 --- ## §6 共享知识(跨文件约定) ### 6.1 msg_type 枚举(不变) | 值 | 出处 | v1.1 行为 | |---|---|---| | `option_select` | 选项确认(员工点选) | ❌ 不变 | | `text` | 普通文本 | ❌ 不变 | | `ai_structured` | AI 结构化(含 options) | ❌ 不变 | | `ai_text` | AI 纯文本 | ❌ 不变 | | `file / image / voice` | 媒体 | ❌ 不变 | ### 6.2 extra_data 字段规范(option_select 类) ```jsonc { "question_id": "stable-question-uuid-or-slug", // 必填 "option_id": "stable-option-id", // 必填 "option_value": "backend-value", // 必填 "client_msg_id": "uuid-v4", // 必填(幂等键) "selected_from_message_id": "msg-uuid", // 必填(归属题目的消息) "option_label_masked": "*** **** 5678", // 仅外发 "option_label_original": "..." // 仅 DB 存,外部不外发 } ``` > v1.1 不引入新字段;仅在 `h5_get_messages` 输出时确保 `content`(即 `option_label`)被 masked。 ### 6.3 BackendObserver 指标命名规范(🆕 v1.1) | 指标名 | 类型 | 单位 | 标签 | 触发点 | |---|---|---|---|---| | `option_select_persist_latency_ms` | histogram | ms | `conv_id` | DB commit 后 | | `option_select_broadcast_latency_ms` | histogram | ms | `conv_id` | ws_manager.broadcast 前 | | `option_select_e2e_latency_ms` | histogram | ms | `conv_id` | 员工端 useH5WebSocket 计算 | | `option_select_dify_timeout_count` | counter | integer | `conv_id` | h5_ai_task.py:443 still_thinking 触发时 | **命名约定**:`{feature}_{aspect}_{metric}_{unit}`,全小写 + 下划线。**禁止**用驼峰或简写。 ### 6.4 强校验 4 证据链(dist 发布门禁) 每次 Bugfix 合并前必须出具: 1. **dist 关键字**:`grep` 产物包含功能特征字符串(见各 Task 强校验列) 2. **产物 hash**:`sha256sum dist/**/*.{js,css}` 输出 ≥ 1 行变化 3. **HTTP 200**:`curl -I https://h5.example.com/` 返回 200/304 4. **浏览器实测**:puppeteer 截图 + 控制台无 ERROR ### 6.5 WS 事件类型(不变,但 v1.1 增加 1 个新) | type | 方向 | 说明 | |---|---|---| | `new_message` | 双通道(坐席 + 员工 v1.1) | 通用消息,含 option_select | | `ai_thinking` | 双通道 | AI 思考占位 | | `ai_reply` | 仅员工 | AI 终态 | | 🆕 `option_select_confirm` | 仅员工(still_thinking 兜底) | Dify 超时 15s 后由后端主动补发 | --- ## §7 测试用例增量(基于决策 5 BackendObserver 指标 + 4 步 E2E) ### 7.1 BackendObserver 指标验收(新增 P0 用例) | TC ID | 名称 | 步骤 | 预期 | 强校验 | |---|---|---|---|---| | TC-v1.1-001 | BackendObserver `option_select_persist_latency_ms` 存在 | 启动后端 + 触发 option_select → GET /api/backend-observer/metrics?name=option_select_persist_latency_ms | 返回 ≥ 1 条样本,p50 < 30ms | curl 返回 JSON 含 metric + samples | | TC-v1.1-002 | `option_select_broadcast_latency_ms` p95 < 50ms | 同上,连续触发 100 次 | p95 < 50ms(单 worker) | report.json p95 字段 | | TC-v1.1-003 | `option_select_e2e_latency_ms` p95 < 100ms | puppeteer 脚本 100 次 | p95 < 100ms(端到端) | measure-option-latency.mjs 输出 | | TC-v1.1-004 | `option_select_dify_timeout_count` 累加 | 模拟 Dify 30s 超时 | counter +1 | Obs metrics | | TC-v1.1-005 | metrics 端点鉴权 | 直接 GET(无 token) | 401 Unauthorized | curl 状态码 | ### 7.2 4 步 E2E(覆盖决策 1 全链路) | TC ID | 步骤 | 期望 | 关联 Task | |---|---|---|---| | TC-v1.1-101 | **Bug 6 验收**:选选项 → 浏览器刷新 → ✓ 仍在 | H5 ✓ 显示,且选项按 server_timestamp 升序 | T02 | | TC-v1.1-102 | **Bug 6 验收**:长会话 (>50 条) → 滚动顶 → 早期选项可见 | REST limit=50 触发翻页 `before`,✓ 可见 | T02 | | TC-v1.1-103 | **Bug 6 验收**:H5 REST 返回 option_select content 已 mask | 16 位数字中间 4 位 `****` | T02 | | TC-v1.1-201 | **Bug 4 验收**:员工端弱网(throttle 1Mbps + 100ms RTT)下选选项 | < 500ms 看到 ✓ | T03 | | TC-v1.1-202 | **Bug 4 验收**:会话切换竞态 → 切换前选项仍在 UI | 可见(软校验 fallback) | T03 | | TC-v1.1-203 | **Bug 4 验收**:Dify 30s 超时 → still_thinking 触发 option_select_confirm | 员工端收到 confirm 事件 | T03 | | TC-v1.1-301 | **Bug 5 验收**:选同一 question 不同 option → 上次折叠 + 新选项高亮 + AI 答案紧随 | 3 个 UI 元素独立出现 | T04 | | TC-v1.1-302 | **Bug 5 验收**:不同 question 的 option 不互相折叠 | 视觉分组按 question_id 边界 | T04 | | TC-v1.1-401 | **Req 7 验收**:在线坐席端 p95 < 100ms | measure-option-latency.mjs 通过 | T04 | | TC-v1.1-402 | **Req 7 验收**:坐席离线 → 重连 REST → option_select 在历史中 | curl GET 含 option_select 行 | T04 | | TC-v1.1-403 | **Req 7 验收**:弱网 (502) → 3s 轮询 fallback 可见 | 轮询触发,含 option_select | T04 | ### 7.3 回归用例(v1.0 TC-REQ-通用-005 必须全过) - AC-01 / AC-03 / AC-04 / AC-10(35 条用例)全部通过 - 6 项 v1.0 决策不变性测试(撤回/重选、5s UUID、跨卡归属、敏感词、Dify inputs、转人工快照) --- ## §8 风险与部署 checklist ### 8.1 风险矩阵 | 风险 | 等级 | 触发条件 | 缓解 | |---|:--:|---|---| | **Dify v3 仍未导入(决策 2)** | 中 | 用户 2026-08-09 前未导入 → Bug 4/5 修复用户感知不到 | v1.1 Task T04 时已 deadline;超出则跳过 Bug 4/5,先发 T02(Bug 6 不依赖 Dify) | | **dist 缓存复用(v0.5/v1.0 P0-B 踩坑)** | 中 | 浏览器命中旧 dist | 4 证据链 + 强制加 `?v=` + nginx `Cache-Control: no-cache` | | **H5 `processedMessageIds` 误伤(v1.0 P0-C 修复后)** | 中 | conversation 切换时未清空 | T03 修复时核对该集合 `clear()` 时机(store `setActiveConversation` 处) | | **BackendObserver 单进程内存累积** | 低 | 长时间运行 events 列表增长 | v1.1 用 `collections.deque(maxlen=10000)`;v1.2+ 落 Prometheus | | **`--workers 1` 单例** | 低 | v1.0 已固定,本次不变 | deploy-server/docker-compose.yml:115 强校验 | | **`isOptionSelected` 派生性能** | 低 | 长会话消息过滤 O(n) | O(n) 在 store 的 computed 内 Vue 自动缓存;单 component 重渲才重算 | | **mask 函数调用埋点失败导致 500** | 低 | h5.py:1025 引入 mask 后若函数抛错 | `mask_sensitive_text` 已 try/except;fail-open 返回原 content | ### 8.2 部署 checklist(含 dist 强校验 4 证据链) ``` □ Step 0 前置:Dify v3 已导入(决策 2 deadline 2026-08-09) □ Step 1 T01 基础设施:pnpm install / docker compose config OK / 4 证据链脚本就绪 □ Step 2 T02 Bug 6: □ 2.1 后端 rebuild image → push private registry □ 2.2 H5 dist build → 上传 CDN 强校验证据链 ①:grep "filteredOptionIdsFromHistory" dist/**/*.js ✓ 强校验证据链 ②:sha256sum dist/**/*.{js,css} 与上一版有差异 ✓ 强校验证据链 ③:curl -I https://h5.example.com/ → 200 ✓ 强校验证据链 ④:puppeteer 刷新 → ✓ 仍显示 + 截图 ✓ □ 2.3 后端 container restart(仍 --workers 1) □ 2.4 验收:TC-v1.1-101 / 102 / 103 通过 □ Step 3 T03 Bug 4: □ 3.1 后端 patch:ws.py:407 + h5_ai_task.py:443 → rebuild □ 3.2 H5 patch:conversation.ts:1395 软校验 → dist rebuild 强校验证据链 ①:grep "broadcast_to_employees.*option_select" dist/**/*.js ✓ 强校验证据链 ②:sha256sum 输出有新行 ✓ 强校验证据链 ③:curl 主入口 200 ✓ 强校验证据链 ④:puppeteer 弱网 throttle 测试 → < 500ms 看到 ✓ □ 3.3 验收:TC-v1.1-201 / 202 / 203 通过 □ Step 4 T04 Bug 5 + Req 7(合并发布): □ 4.1 BackendObserver 单例部署 + /metrics 路由上线 □ 4.2 dist rebuild(视觉分组 + RTT 上报) □ 4.3 measure-option-latency.mjs 跑 100 次 → p95 < 100ms 强校验证据链 ①:grep "collapsed-question-group" dist/**/*.js ✓ 强校验证据链 ②:BackendObserver metrics 4 个指标均存在 ✓ 强校验证据链 ③:curl /metrics → 200 + JSON ✓ 强校验证据链 ④:puppeteer + report.json p95 < 100ms ✓ □ 4.4 验收:TC-v1.1-301 / 302 / 401 / 402 / 403 通过 □ Step 5 全量回归:v1.0 35 条 TC-REQ-通用-005 用例全过 □ Step 6 灰度:H5 灰度 10% → 50% → 100%(坐席端无灰度,因 WS 改动向后兼容) □ Step 7 监控观察 24h:BackendObserver p95 + 上报成功率 ≥ 99% □ Step 8 收尾:废弃 selectedOptionLabels 引用(v1.1 标记 deprecated,v1.2 删除) ``` ### 8.3 回滚策略 | 场景 | 回滚动作 | |---|---| | T02 发布后 Bug 6 未解或引入新 bug | 回滚 H5 dist 到上一版(CDN 一键)+ 后端 image tag 回滚 | | T03 发布后员工端选项不显示 | 后端 ws.py:407 `broadcast_to_employees` 一行注释即可关闭 | | T04 发布后 p95 > 100ms | BackendObserver 持续告警;保留埋点但回滚 dist 视觉分组 | | 任一步骤导致坐席端 regress | 坐席端 v1.0 代码**未被本次任何 Task 修改**,回滚不影响坐席 | --- ## §9 附录:v1.1 与 v1.0 决策一致性自检 | v1.0 决策 | v1.1 是否触动 | 证据 | |---|:--:|---| | 撤回/重选(追加新行) | ❌ | T02/T03/T04 均不动 INSERT 逻辑 | | 5s UUID 幂等 | ❌ | ws.py:316-323 幂等 SQL 未改 | | 跨卡归属(question_id + option_id) | ❌ | T04 Bug 5 视觉分组按 question_id 折叠,正是该决策的强化 | | 敏感词 16 位中间 4 位 mask | ✅(修复漏洞) | h5.py:1025 补 mask → 由 mask 漏洞转一致 | | Dify 5 字段 inputs | ❌ | ws.py:433-439 feedback_context 未改 | | 转人工 selected_options 快照 | ❌ | 不在本次范围 | | 5 重 UUID 守卫 | ❌ | conversation.ts:1662-1680 视为 T02/T03 修复前提 | --- ## §10 变更记录 | 版本 | 日期 | 变更内容 | 变更人 | |---|---|---|---| | v1.0 | 2026-07-29 | 初版技术方案(523 行,11 章节) | 高见远、宋献 | | v1.1 设计稿 | 2026-08-02 | 4 问题根因 + 修复路径(不进开发) | 高见远、宋献 | | **v1.1 正式方案** | **2026-08-02** | **基于设计稿 + 用户 5 决策正式化**;4 任务(含合并 T04);类图 + 4 时序;BackendObserver 强制交付;dist 4 证据链;2026-08-09 Dify 导入门槛 | **高见远** | --- > **下一步**:方案冻结 → 工程师寇豆码按 T01→T02→T03→T04 顺序派工 → QA 在 v1.0 TC-REQ-通用-005 基础上扩 TC-v1.1-001~403(13 条新用例) → Dify v3 2026-08-09 前必须导入 → 灰度上线。