facc04aa65
本提交为 .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-*/
605 lines
32 KiB
Markdown
605 lines
32 KiB
Markdown
# 技术方案(正式)— 选项选择持久化 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 {
|
||
<<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)
|
||
|
||
```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<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 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 软校验:<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)
|
||
|
||
```mermaid
|
||
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)
|
||
|
||
```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<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.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 前必须导入 → 灰度上线。
|