Files
wecom_it_smart_desk/docs/02-技术文档/技术架构/技术方案-REQ-通用-005-选项选择持久化-v1.1-正式方案.md
T
Simon facc04aa65 chore: docs 结构整改 + compose 双目录对齐(合并重建提交)
本提交为 .git 对象库损坏后的重建提交,内容等价于原先三个本地提交
(5e2fd4c2 / 57a53c98 / 5d7e1873)的累积结果,未做任何额外改动。

一、docs 结构整改(整改 #14)
根因:重构时新结构为 untracked 文件,执行 git stash(未带 -u)未纳入,
随后 git reset 拉回 HEAD 旧 tracked 树,导致旧树复活、新旧两棵目录
树并存于 docs/,共 791 文件、双分类体系冲突。

修复动作:
- b2 同名异主题文件改名迁移保全 9 个
- C 类 39 个孤立文件按主题正确归类
- A/B1 类 222 个重复文件删除(新结构已有内容副本)
- 9 个旧独有空目录删除
- 270 处内部引用按 verified 映射改写
- 整改记录 #14 登记于 04-运维文档/部署运维

结果:docs 791 → 569 文件,顶层仅规范 8 类 + 治理文件,单树恢复。
残留:约 20 处指向从未存在文件的陈旧死链,归入独立文档卫生任务。

二、compose 双目录对齐(消除踩坑 A)
- docker-compose.yml:nginx 前端挂载全部由根目录 frontend-*/dist
  改为 src/frontend-*/dist(h5 / agent / admin / terminal)
- docker-compose.dev.yml:dev 服务 build context 与卷同步改 src/
- 效果:本地 docker compose up 不再把根目录 stale dist 挂回,
  与线上一致,分叉隐患消除(已 docker compose config 校验通过)

防复发铁律:
- 重构须提交;仓库修复须 git stash -u 或先 commit
- 新结构须 git add 并提交,避免再次 untracked 复活
- H5 改动只动 src/frontend-h5/,禁改根目录遗留 frontend-*/
2026-08-07 22:31:32 +08:00

32 KiB
Raw Blame History

技术方案(正式)— 选项选择持久化 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 — 方案设计.md2026-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 已部署生产的稳定架构:员工点击 → sendOptionSelectconversation.ts:1621-1737)生成 UUID + 6 字段 → _handle_option_selectws.py:254-448)走 pg_advisory_xact_lock + 5s 幂等 → messages 表 INSERT → ws_manager.broadcast 给坐席(ws.py:407process_h5_ai_replyh5_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 指标命名规范 🆕 见 §7option_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.jsonH5 + Agent)、pnpm-workspace.yamldeploy-server/docker-compose.ymlscripts/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-343isOptionSelected 重写,从 messages 过滤 msg_type==='option_select');(b) src/frontend-h5/src/stores/conversation.ts:241MessageBubble 调用点,从 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-1401handleAiReply 软校验: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 7BackendObserver 埋点 + 端到端测量脚本
文件清单 (a) src/frontend-h5/src/components/chat/MessageBubble.vue:174-179option_select 模板加 data-question-id 属性 + collapsed class);(b) src/frontend-h5/src/components/chat/ChatPanel.vue:96-112store.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 < 100msBackendObserver 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 任务依赖图

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

顺序备注T02Bug 6)可在 T01 后立即并行开发,因只影响 H5 端。T03(Bug 4)跨前后端,但与 T02 无文件冲突,可并行。T04Bug 5 + Req 7)依赖 T02/T03 落地后集成测试。决策 1 是上线顺序,不是开发顺序。


§3 数据结构和接口(类图)

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 {
        <<singleton>>
        +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 {
        <<utility>>
        +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 {
        <<puppeteer script, v1.1 ★>>
        +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

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<br/>optionSubmitting=true<br/>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: 员工立即看到 ✓ 气泡<br/>(不再依赖 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 7T2-T1 应 < 50ms(单 worker

时序 ② — AI 回复回流(T03 Bug 4 软校验 + T02 派生)

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 软校验:<br/>conversation_id 不匹配 → console.warn<br/>但不再 return,继续 push<br/>(仅 message_id 已去重才 return
    Store->>Store: trackProcessedMessageId
    Store->>Store: messages.value.push(finalMessage)
    Store-->>MB: reactive 触发重渲
    MB->>MB: 显示 AI 答案气泡(打字机逐字)
    Note over MB: ★ v1.1 T02 派生:<br/>isOptionSelected 改为从 messages 过滤<br/>msg_type==='option_select' by question_id<br/>不再依赖内存 ref

    MB->>MB: 渲染 ✓ (历史选项) + AI 答案(新)

时序 ③ — 视觉分组(T04 Bug 5)

sequenceDiagram
    autonumber
    actor Emp as 员工
    participant Store as ConversationStore
    participant Panel as ChatPanel
    participant MB as MessageBubble

    Note over Store: messages 序列:<br/>[ai_structured Q1]<br/>[option_select Q1.optA] <-- 第 1 次<br/>[ai_text 回复1]<br/>[option_select Q1.optB] <-- 第 2 次(重选)<br/>[ai_text 回复2]

    Emp->>Store: 选 Q1.optB
    Store->>Store: sendOptionSelect → WS
    Note over Store: groupedMessagesByQuestion (v1.1 ★)<br/>按 question_id 相邻聚合<br/>折叠:Q1.optA 标 collapsed class<br/>高亮: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"<br/>背景灰底 + 折叠图标 ▾
    else current (本次选项)
        MB->>MB: class="message-bubble--option-select active"<br/>蓝底 + ✓
    end
    MB->>MB: ai_text 紧随其后,按时间顺序独立出现
    Note over MB: ★ 顺序:① ✓当前 ② AI 思考占位 ③ AI 答案

时序 ④ — 时延监控埋点与端到端测量(T04 Req 7)

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<br/>{metric: 'option_select_e2e_latency_ms',<br/>value: T_agent - T0}
    WSM->>Obs: 记录 option_select_persist_latency_ms<br/>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.0devDependencies Req 7 端到端时延测量脚本 🆕 新增
Promise.withResolvers 浏览器原生(Chrome 119+ useH5WebSocket RTT 计算(无新依赖) 沿用
Vue 3 + Pinia + Vite 已存在 全部前端 不变
FastAPI + SQLAlchemy Async 已存在 后端 不变
BackendObserver 自研单例模块(无外部依赖) 监控埋点 🆕 新模块,无第三方包

结论v1.1 仅新增 puppeteerdevDependency,仅供 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 类)

{
  "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. 产物 hashsha256sum dist/**/*.{js,css} 输出 ≥ 1 行变化
  3. HTTP 200curl -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-1035 条用例)全部通过
  • 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,先发 T02Bug 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/exceptfail-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 后端 patchws.py:407 + h5_ai_task.py:443 → rebuild
    □ 3.2 H5 patchconversation.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  监控观察 24hBackendObserver p95 + 上报成功率 ≥ 99%
□ Step 8  收尾:废弃 selectedOptionLabels 引用(v1.1 标记 deprecatedv1.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~40313 条新用例) → Dify v3 2026-08-09 前必须导入 → 灰度上线。