Files
wecom_it_smart_desk/docs/02-技术文档/技术架构/技术方案-REQ-通用-005-选项选择持久化-v1.0.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

523 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 技术方案 - 选项选择持久化
> **REQ 编号**: REQ-通用-005
> **版本**: v1.0
> **日期**: 2026-07-29
> **作者**: 高见远(架构师)+ 宋献(Simon)
> **状态**: [待评审]
> **关联 PRD**: docs/01-产品文档/00-产品规划/PRD-REQ-通用-005-选项选择持久化-v1.0.md
> **代码真相**: src/backend/app/api/ws.py + src/backend/app/tasks/h5_ai_task.py + src/frontend-agent/src/components/chat/MessageBubble.vue + src/frontend-h5/src/stores/conversation.ts
---
## 1. 架构概览
### 1.1 实现方法与关键难点
本需求采用“**消息即事件流(events are first-class citizens**”模式:员工每次点选均先成为 `messages` 表中的不可变 `option_select` 消息,再由同一消息对象派生实时 WS、历史 REST、Dify 反馈和转人工快照。撤回/重选不更新旧行,只追加新行;展示态按 `question_id` 计算最新一条。
| 难点 | 方案 | 理由 |
|---|---|---|
| 零迁移下的原子幂等 | PostgreSQL transaction-level advisory lock + 5 秒窗口查询 | 不新增列/索引,跨 worker 生效,数据库一致性优先 |
| 实时态与历史态一致 | 落库并 `commit` 后广播标准 `new_message` | WS 与 REST 使用同一 `message_id`,杜绝瞬时事件失真 |
| 跨卡同名选项 | 全链路携带 `selected_from_message_id + question_id + option_id` | label 只用于展示,不承担归属与主键语义 |
| 原值审计与普通视图脱敏 | DB 保存原值;REST/WS/Dify/转人工及前端展示统一 mask | 审计真相与最小暴露兼得 |
| 单例 WS 与多 worker | v1.0 保持 `--workers 1`;幂等先做多 worker 安全,广播扩容前接 Redis Pub/Sub | 当前 `ConnectionManager` 是进程内单例,不能假设跨进程共享 |
后端延续 FastAPI + SQLAlchemy AsyncSession + PostgreSQL;前端延续 Vue 3 + Pinia;不引入新框架。后端采用分层 MVC/Service 风格,前端采用 MVVMstore 维护消息状态,`MessageBubble.vue` 仅做派生渲染。
### 1.2 组件图
```mermaid
flowchart LR
U[员工] --> H5[H5 Vue/Pinia]
H5 -->|option_select WS| API[FastAPI ws.py]
API --> IDEM[PG advisory lock + 幂等查询]
IDEM --> MSG[(messages 表)]
API --> MASK[Sensitive Mask]
MASK --> WSM[ws_manager]
WSM -->|new_message| AGENT[坐席 Vue/Pinia]
AGENT --> BUBBLE[MessageBubble]
AGENT -->|重连/历史| REST[GET conversations/id/messages]
REST --> MSG
REST --> MASK
API -->|commit 后 create_task| TASK[h5_ai_task.py]
TASK -->|feedback inputs| DIFY[Dify Workflow]
TASK -->|queued + selected_options| WSM
```
### 1.3 核心时序
```mermaid
sequenceDiagram
autonumber
actor User as 员工
participant H5 as H5 ConversationStore
participant WS as FastAPI ws.py
participant DB as PostgreSQL/messages
participant WSM as ws_manager
participant Agent as 坐席 Store/MessageBubble
participant Task as h5_ai_task.py
participant Dify as Dify Workflow
participant REST as messages.py
User->>H5: 点击选项
H5->>H5: 首次生成 client_msg_id(UUID)
H5->>WS: option_select(6字段)
WS->>DB: BEGIN + pg_advisory_xact_lock(key)
WS->>DB: 查询同会话同 client_msg_id
alt 5秒内重复
DB-->>WS: 返回首次 Message
WS-->>H5: 不重复落库/广播/Dify
else 首次受理
WS->>DB: INSERT Message(msg_type=option_select)
WS->>DB: COMMIT
WS->>WSM: broadcast new_message(masked message)
WSM-->>Agent: new_message
Agent->>Agent: 追加消息并按 question_id 计算最新
WS->>Task: create_task(feedback_context)
Task->>Dify: inputs{feedback_type, question_id, option_id...}
Dify-->>Task: 下一轮结构化回复
end
Agent-xWSM: 网络断开
Agent->>REST: GET /conversations/{id}/messages
REST->>DB: SELECT conversation_id ORDER BY created_at
DB-->>REST: 含 option_select 历史
REST-->>Agent: masked items
Agent->>Agent: 用 created_at + id 恢复最新高亮
```
### 1.4 关键对象类图
```mermaid
classDiagram
class OptionSelectPayload {
+string conversation_id
+string option_label
+string option_value
+string selected_from_message_id
+UUID client_msg_id
+string question_id
+string option_id
+validate() bool
}
class Message {
+string id
+string conversation_id
+string sender_type
+string sender_id
+string content
+string msg_type
+dict extra_data
+datetime created_at
+__init__(...)
}
class OptionSelectHandler {
+__init__(session_factory, ws_manager)
+handle(payload, employee_id) Message
-acquire_advisory_lock(db, key) void
-find_duplicate(db, payload) Message
-persist(db, payload, employee_id) Message
-to_message_dict(message, masked) dict
}
class SensitiveMask {
+mask_sensitive_text(text) string
+mask_sensitive_value(value) string
}
class DifyFeedbackContext {
+string feedback_type
+string question_id
+string option_id
+string option_value
+string option_label
+to_inputs() dict
}
class SelectedOptionsSnapshotBuilder {
+__init__(db)
+build(conversation_id) list
}
class ConnectionManager {
+dict active_connections
+dict employee_connections
+__init__()
+broadcast(data) void
+broadcast_to_employees(ids, data) void
}
class ConversationStore {
+Message[] messages
+sendOptionSelect(selection) void
+handleNewMessage(data) void
+latestSelection(question_id) Message
}
class MessageBubble {
+Message message
+isLatestSelection() bool
+maskedContent() string
}
OptionSelectHandler --> OptionSelectPayload : validates
OptionSelectHandler --> Message : creates/returns
OptionSelectHandler --> SensitiveMask : masks outbound data
OptionSelectHandler --> ConnectionManager : broadcasts
OptionSelectHandler --> DifyFeedbackContext : creates after commit
SelectedOptionsSnapshotBuilder --> Message : queries latest per question
ConversationStore o-- Message : stores
MessageBubble --> ConversationStore : derives latest
MessageBubble --> Message : renders
```
### 1.5 文件清单
| 层 | 相对路径 | 用途 |
|---|---|---|
| 配置/入口 | `deploy-server/docker-compose.yml``src/backend/app/main.py` | 核验 `--workers 1` 与路由入口,不改环境变量 |
| 后端 | `src/backend/app/api/ws.py` | 接收 6 字段、原子幂等、落库、标准广播、触发 Dify |
| 后端 | `src/backend/app/api/messages.py` | 历史查询对 `option_select` 自动覆盖并在普通 REST 输出脱敏 |
| 后端 | `src/backend/app/utils/sensitive.py`(新) | 服务端统一脱敏函数 |
| 后端 | `src/backend/app/tasks/h5_ai_task.py` | Dify 反馈上下文、转人工快照与 `queued` payload |
| 后端 | `src/backend/app/services/ai_service.py` | `get_structured_reply/_call_dify_native` 接收可选 `inputs` |
| 模型/Schema | `src/backend/app/models/message.py``src/backend/app/schemas/message.py` | 仅补文档化枚举/注释;表结构不变 |
| H5 | `src/frontend-h5/src/stores/conversation.ts` | 补齐 6 字段、UUID 生命周期与重试复用 |
| H5 | `src/frontend-h5/src/components/chat/MessageBubble.vue` | 从来源消息传 question/option 标识,展示脱敏 |
| H5 | `src/frontend-h5/src/utils/sensitive.ts`(新) | 前端防御性 mask |
| 坐席 | `src/frontend-agent/src/components/chat/MessageBubble.vue` | `option_select` 徽标与最新高亮 |
| 坐席 | `src/frontend-agent/src/stores/conversation.ts` | 删除纯内存选择真相,按消息派生 |
| 坐席 | `src/frontend-agent/src/composables/useWebSocket.ts` | 收敛到 `new_message`,灰度期兼容旧事件 |
| 坐席 | `src/frontend-agent/src/utils/sensitive.ts`(新) | 防御旧 payload 泄漏 |
| 测试 | `src/backend/tests/test_option_select.py`(新)、`src/backend/tests/test_sensitive.py`(新) | 幂等、并发、mask、快照、Dify inputs |
| 测试 | `src/frontend-h5/src/stores/conversation.spec.ts``src/frontend-agent/src/components/chat/MessageBubble.spec.ts` | UUID 与渲染测试 |
---
## 2. 数据模型
### 2.1 `messages` 中的 `option_select`
| 字段 | 值 | 说明 |
|---|---|---|
| `id` | UUID | 服务端生成,实时与历史共同标识 |
| `conversation_id` | UUID 字符串 | 会话归属 |
| `sender_type/sender_id` | `employee` / 企微 UserID | 操作发起者 |
| `content` | 原始 `option_label` | DB 原值;普通输出必须 mask |
| `msg_type` | `option_select` | 现有 `String(20)` 可容纳,无迁移 |
| `extra_data` | JSON | 5 个业务/幂等字段 |
| `created_at` | timezone datetime | 最新判定主排序键,`id` 为次排序键 |
```json
{
"option_value": "network_down",
"selected_from_message_id": "924a7668-79f9-48a1-a9bc-21780e89ea2c",
"client_msg_id": "8e71c122-1538-4e78-a57d-a6e5b6459dd3",
"question_id": "fault_type",
"option_id": "network_down"
}
```
### 2.2 索引策略
现有 `idx_messages_conversation_created(conversation_id, created_at)``src/backend/app/models/message.py:267-274`)已覆盖历史分页、5 秒窗口幂等扫描和按会话构造快照。单个会话消息量有限,追加 `(conversation_id, msg_type, created_at)` 的收益小,且会引入 Alembic 迁移,与 v1.0 零迁移目标冲突,**本期不新增索引**。若后续单会话达到万级消息,再以 `EXPLAIN ANALYZE` 为依据评估表达式索引。
### 2.3 持久化查询接口
`GET /conversations/{conversation_id}/messages``src/backend/app/api/messages.py:89-166`)只按 `conversation_id` 查询、无 `msg_type` 白名单或过滤,因此落库后自动返回 `option_select`。仅需在 `MessageResponse` 文档化新类型,并在普通响应序列化时对该类型 `content` 做 mask;审计原值仍留在 DB。
---
## 3. 核心算法与流程
### 3.1 `_handle_option_select` 改造
位置:`src/backend/app/api/ws.py:247-313`;接收位置:`:426-449`
```python
async def _handle_option_select(payload, employee_id):
validate_required_fields(payload) # 含 UUID 格式
async with session_factory() as db:
async with db.begin():
await advisory_xact_lock(db, conversation_id, client_msg_id)
duplicate = await find_same_id(db, within_seconds=5)
if duplicate:
return duplicate # 不广播、不调用 Dify
if await find_same_id_before_window(db):
raise ClientMessageIdReused # 告警并拒绝历史 UUID 复用
conversation = await db.get(Conversation, conversation_id)
if not conversation:
raise ConversationNotFound
message = Message(sender_type="employee", msg_type="option_select", ...)
db.add(message)
conversation.updated_at = utcnow()
await db.flush()
# db.begin 正常退出即 commit;异常自动 rollback
await ws_manager.broadcast({"type": "new_message", "data": masked(message)})
asyncio.create_task(process_h5_ai_reply(..., feedback_context=...))
return message
```
完整 PostgreSQL 示意:
```sql
BEGIN;
SELECT pg_advisory_xact_lock(
hashtextextended(:conversation_id || ':' || :client_msg_id, 0)
);
SELECT * FROM messages
WHERE conversation_id = :conversation_id
AND msg_type = 'option_select'
AND extra_data->>'client_msg_id' = :client_msg_id
AND created_at > NOW() - INTERVAL '5 seconds'
ORDER BY created_at DESC, id DESC LIMIT 1;
-- 未命中时再检查同 UUID 的历史记录;命中则拒绝客户端错误复用。
INSERT INTO messages
(id, conversation_id, sender_type, sender_id, sender_name, content, msg_type, extra_data, is_read, status, created_at)
VALUES
(:id, :conversation_id, 'employee', :employee_id, :employee_name,
:raw_option_label, 'option_select', CAST(:extra_data AS jsonb), FALSE, 'sent', NOW());
UPDATE conversations SET updated_at = NOW() WHERE id = :conversation_id;
COMMIT;
```
顺序必须是 **INSERT → commit → `new_message` → `create_task(Dify)`**。落库失败回滚并拒绝整次选择,不广播、不调用 Dify;Dify 失败仅记录失败/走既有降级,不反向删除已审计的选择消息。
### 3.2 多 worker 原子幂等
| 方案 | 原子性 | 零迁移 | 故障特性 | 结论 |
|---|---:|---:|---|---|
| PG advisory transaction lock | 强 | 是 | 随事务自动释放,DB 为单一真相 | **推荐** |
| UNIQUE 表达式索引 | 强 | 否 | 最简单可靠,但无法自然表达 5 秒窗口 | v1.0 禁用 |
| Redis `SET NX EX 5` | 中 | 是 | Redis 故障/淘汰时可能双写,需补偿 | 可作加速,不作真相 |
| 进程内 dict/set | 弱 | 是 | 重启丢失、跨 worker 无效 | 禁止作为幂等依据 |
推荐 PG advisory lock,理由是一致性优先且不需迁移。当前生产 `deploy-server/docker-compose.yml:115` 明确 `--workers 1`,v1.0 继续作为硬约束;方案本身已可防多 worker 双写,但 `ws_manager` 广播仍不能跨进程,升级 worker 前必须引入 Redis Pub/Sub 广播总线。
### 3.3 WS broadcast 改造
真实接口是 `ConnectionManager.broadcast(data: dict)``src/backend/app/services/ws_manager.py:140-165`),项目不存在 `broadcast_to_agents``_format_message`。本期不新建全局 formatter,复用 `MessageResponse.model_validate(message).model_dump()` 的字段语义,并在 `ws.py` 局部构造与现有 `h5_ai_task.py:648-663` 一致的 schema
```json
{"type":"new_message","data":{"message_id":"...","conversation_id":"...","sender_type":"employee","sender_id":"...","sender_name":"...","content":"6222****12345678","msg_type":"option_select","extra_data":{"question_id":"fault_type","option_id":"network_down","option_value":"network_down","selected_from_message_id":"...","client_msg_id":"..."},"created_at":"2026-07-29T10:00:00+08:00"}}
```
### 3.4 坐席 `MessageBubble` 渲染
现有 `src/frontend-agent/src/components/chat/MessageBubble.vue:52-80``text/ai_structured` 合并渲染,`ai_structured` 的 options 再通过 `selectedOptionLabels` 标记;`:359-386` 又以 label 推导“最近一次”,存在跨卡同名和重连丢失问题。
改造规则:
1. 在文本分支前新增 `message.msg_type === 'option_select'` 分支,渲染 `✓ {mask(content)}` 灰色次要徽标,不使用普通气泡视觉。
2. store 中将 `selectedOptionLabels` 改为基于 `messages.filter(msg_type==='option_select')` 的 computed 派生;归属以 `question_id + option_id + selected_from_message_id` 判断,不用 label。
3. 同一 `question_id``(created_at, id)` 稳定倒序,第一条 `isLatest=true` 使用主色描边/浅色背景,旧行保持灰色。
4. REST 重拉和 `new_message` 追加走同一计算,重连无需恢复内存 set。
### 3.5 H5 `sendOptionSelect` 字段补全
现有函数在 `src/frontend-h5/src/stores/conversation.ts:1486-1539`,只发 conversation/value/label,且 WS 失败会降级为普通文本,丢失结构化字段。改造 `MessageBubble.vue:383-396` 将来源消息一并传入 store
| 字段 | 填充方式 |
|---|---|
| `option_label` | `option.label || option.value` 原值;仅 UI/日志使用 masked 文本 |
| `option_value` | `option.value` |
| `selected_from_message_id` | 当前 `ai_structured``msg.message_id` |
| `client_msg_id` | 首次点击 `crypto.randomUUID()`;同一 pending 请求重试复用 |
| `question_id` | `msg.extra_data.question_id`,兼容读取 option.question_id |
| `option_id` | `option.id || option.option_id || option.value` |
重试不放在全局 axios interceptorWS 不是 axios 请求),而在 store 保存 `pendingOptionSelect = {clientMsgId,payload,attempts}`;仅网络重连/发送失败重发该 payload。收到带相同 `extra_data.client_msg_id``new_message` 后清除 pending。用户主动再选则创建新 UUID。HTTP 降级若不能携带完整结构化字段,应停止降级为普通文本并提示重试,避免制造不可审计的假选择。
### 3.6 敏感词 Mask
服务端权威工具放 `src/backend/app/utils/sensitive.py`,前端同名函数只作防御性展示。16 位数字基础规则:`(?<!\d)(\d{4})\d{4}(\d{8})(?!\d)`,替换为 `\1****\2`;已识别为敏感值且不足 4 位时全部替换为 `*`。不得对 `question_id/option_id/client_msg_id` 做 mask。
| 应用点 | 规则 |
|---|---|
| H5 | 选项按钮、本地状态和日志在发送前 mask;传输中的 canonical `option_label` 保留原值,供服务端落库 |
| DB | `Message.content` 保存原值 |
| WS/REST | `option_select.content` 输出前由后端 mask,前端再防御一次 |
| 坐席 | `MessageBubble` 只渲染 mask 后文本 |
| Dify | `inputs.option_label` 传 mask 值 |
| 转人工 | `selected_options[].label` 传 mask 值 |
“发送前 mask”不能改写唯一的 canonical 字段,否则无法满足 DB 保存原值;因此只 mask 可见 UI/日志,后端输出边界是强制安全控制点。
---
## 4. API 变更
### 4.1 HTTP
不新增端点。`GET /conversations/{id}/messages` 自动包含 `option_select`;普通接口返回 masked content。现有统一响应 `{code,data,message}` 不变。
### 4.2 WebSocket
| 项 | 旧 | 新 |
|---|---|---|
| 员工上行 | 3 字段 `option_select` | 6 个业务字段 + conversation_id |
| 坐席下行 | `type=option_selected` 瞬时 label | `type=new_message` 持久化消息对象 |
| 幂等确认 | 无 | H5 以回流消息中的 `client_msg_id` 确认 |
兼容发布顺序:先发布可识别 `option_select` 的坐席端,再发布后端。灰度期后端可同时发送一次 deprecated `option_selected` 给旧坐席,新坐席只以 `new_message` 入消息列表;确认旧版本清零后删除旧事件。若不做双发,旧端仍可由现有 REST 轮询/重连降级看到选择,但无即时徽标。
---
## 5. Dify 集成
真实调用链为 `src/backend/app/tasks/h5_ai_task.py:1505-1514` 调用、`:1617-1624` 任务入口;底层 `src/backend/app/services/ai_service.py:241-278,338-374` 当前把原生请求 `inputs` 写死为空。
改造:`process_h5_ai_reply``_step_call_dify``get_structured_reply` 增加可选 `feedback_context: dict | None`,普通文本默认 `None`option_select 传入:
```json
{"feedback_type":"option_select","question_id":"fault_type","option_id":"network_down","option_value":"network_down","option_label":"6222****12345678"}
```
`_call_dify_native` 使用 `payload.inputs = feedback_context or {}`;代理 fallback 需确认 dify2openai 是否透传 `inputs`,不支持时不得拼接未脱敏原文,可降级为 masked query 并记录指标。
Dify 工作流**需要同步变更**:声明上述五个 input variables,并在入口增加 `feedback_type == option_select` 分支;空值继续走普通文本分支。发布采用“先 Dify 兼容空/新字段,再后端灰度注入”,并分别验证普通文本与选项反馈。
---
## 6. 转人工快照
当前不是独立转人工 HTTP 端点:`src/backend/app/tasks/h5_ai_task.py:468-476,544-560` 在 Dify `diagnosis_stage=escalating``hit=False` 时将 `conversation.status` 置为 `queued`,随后通过 `conversation_updated` 通知坐席。
在状态切换为 `queued` 前构造快照:查询该会话全部 `option_select`,按 `question_id` 分组,使用 `row_number() over(partition by extra_data->>'question_id' order by created_at desc,id desc)=1` 取最新。每项为:
```json
{"question_id":"fault_type","option_id":"network_down","label":"网络中断","message_id":"...","selected_at":"2026-07-29T10:00:00+08:00"}
```
注入两处:`conversation.tags.selected_options`(无迁移、供重连读取)以及 `conversation_updated.data.selected_options`(实时坐席上下文)。label 必须 mask;审计仍以 messages 全量追加记录为准。
---
## 7. 部署、配置与依赖
### 7.1 Required Packages
- **无新增 Python/NPM 包**UUID 使用浏览器 `crypto.randomUUID()`,锁与 JSON 查询使用 PostgreSQL/SQLAlchemy 现有能力,mask 使用 Python/JavaScript 正则。
- 不修改环境变量,不执行 Alembic;`msg_type` 现有 `String(20)` 足够。
### 7.2 部署范围
| 端 | 是否构建/重启 | 说明 |
|---|---:|---|
| 后端 | 是 | 重启后生效;保持 `--workers 1` |
| H5 | 是 | 字段、UUID、来源归属与展示 mask 有改动 |
| 坐席 | 是 | 新消息类型与高亮逻辑有改动 |
| 管理端 | 否 | 历史接口自动覆盖,无专属 UI 改造 |
| 终端 | 否 | 不消费本事件 |
按规范需同步中文工作目录与 ASCII 构建目录;前端 build 后验证特征字符串、产物 hash 和部署 HTTP 三证据链。
---
## 8. 风险与缓解
| 风险 | 缓解 |
|---|---|
| `ws_manager` 进程内单例 | v1.0 固定单 worker;扩容前接 Redis Pub/Sub,不以 manager 内存做幂等 |
| 多 worker 同 UUID 双写 | PG transaction advisory lock + 并发压测;任何内存 set 仅可作加速 |
| Dify 新字段语义变化 | 先改工作流兼容分支,再灰度后端;普通文本 inputs 仍为空 |
| `recommend_event` 兼容 | 不改历史数据、不删除旧必要字段;新字段只增不删,按 question/option ID 归属 |
| mask 漏点 | 后端 REST/WS/Dify/快照统一工具为强制边界,前端仅第二道防线 |
| 广播失败 | 已 commit 的消息不回滚;坐席用 REST 轮询/重连恢复 |
---
## 9. 测试要点
| 类型 | 重点 |
|---|---|
| 单元 | UUID 校验;5 秒 SQL 边界;advisory key 稳定;16 位及短敏感值 mask;按 question_id 最新选择 |
| 集成 | 覆盖 PRD AC-01~AC-10:落库、重选追加、标准广播、重连 REST、Dify inputs、转人工快照、原值/脱敏分离 |
| 回归 | 智能推荐 `ai_structured/recommend_event`、坐席 WS 实时、普通文本 Dify、图片/文件消息 |
| 并发压测 | 多进程/多连接同 UUID 同时 20 次,仅 1 次 INSERT、1 次 broadcast、1 次 Dify;不同 UUID 均追加 |
| 故障 | Dify 超时但消息仍在;WS 广播失败后 REST 可见;DB 失败时无广播与 Dify |
最低 E2E 四场景:在线点选实时可见、坐席重连可见、同 UUID 弱网重试不重复、同题新 UUID 重选追加且仅最新高亮。
---
## 10. 变更记录
| 版本 | 日期 | 变更内容 | 变更人 |
|---|---|---|---|
| v1.0 | 2026-07-29 | 首版:方案 A、原子幂等、标准消息事件、Dify 反馈、转人工快照与 mask | 高见远、宋献 |
---
## 11. 任务分解清单
> 生产代码增量预计约 **175 行**(测试代码另计),任务按模块分组且不超过 5 个;配置核验与依赖声明统一放在首任务。
### T01 项目基础设施与协议基线
- **Source Files**: `deploy-server/docker-compose.yml:115``src/backend/app/main.py``src/backend/requirements.txt``src/frontend-h5/package.json``src/frontend-agent/package.json`
- **改动摘要**: 核验后端入口已注册 WS/REST、生产保持 `--workers 1`,声明无 Alembic、无新增 Python/NPM 包与环境变量;配置、入口、依赖一次性冻结。原则上仅注释/清单 0~3 行。
- **Dependencies**: 无
- **Priority**: P0
- **验证方法**: 后端启动与 `/health` 通过;两前端依赖安装无 lockfile 变化;部署命令仍为单 worker。
### T02 后端持久化、原子幂等、标准广播与 Mask
- **Source Files**: `src/backend/app/api/ws.py:247-313,426-449``src/backend/app/api/messages.py:89-166``src/backend/app/utils/sensitive.py``src/backend/app/models/message.py:104-115``src/backend/app/schemas/message.py:14-18,86-136``src/backend/tests/test_option_select.py``src/backend/tests/test_sensitive.py`
- **改动摘要**:1`ws.py` 解析 6 字段、advisory lock、5 秒查询、追加 Message(约 25 行);(2)把 `option_selected` 改为 masked `new_message`(约 5 行);(3)新增 mask 工具并接 REST/WS(约 25 行);(4)仅文档化 `option_select` 类型,无迁移。
- **Dependencies**: T01
- **Priority**: P0
- **验证方法**: 同 UUID 3 次仅一行/一次广播;重选新 UUID 两行;DB 原值、REST/WS masked;故障时事务回滚。
### T03 Dify 反馈与转人工快照
- **Source Files**: `src/backend/app/tasks/h5_ai_task.py:468-476,544-560,648-676,1505-1514,1617-1624``src/backend/app/services/ai_service.py:241-278,338-374``src/backend/app/utils/sensitive.py``src/backend/tests/test_option_select.py`
- **改动摘要**:1)可选 feedback context 透传 Dify inputs(约 15 行);(2`feedback_type/question/option/value/masked label` 注入(约 10 行);(3)转 `queued` 前按 question_id 构建最新快照,写 tags 并放入 `conversation_updated.data.selected_options`(约 20 行)。
- **Dependencies**: T02
- **Priority**: P1
- **验证方法**: 抓取 Dify 请求断言 5 字段;普通文本 inputs 为空;多题/重选快照仅含每题最新;Dify 失败不删除选择消息。
### T04 H5 六字段、UUID 重试与展示脱敏
- **Source Files**: `src/frontend-h5/src/stores/conversation.ts:403-475,1481-1539``src/frontend-h5/src/components/chat/MessageBubble.vue:383-396``src/frontend-h5/src/utils/sensitive.ts``src/frontend-h5/src/stores/conversation.spec.ts`
- **改动摘要**:(1)补齐 6 字段及来源消息传参(约 15 行);(2)pending payload 保存 UUID、失败/重连复用、主动重选换 UUID(约 15 行);(3)按钮/日志 mask,取消结构丢失的普通文本 HTTP 降级(约 15 行)。
- **Dependencies**: T01(可与 T02 并行开发,联调依赖 T02)
- **Priority**: P0
- **验证方法**: mock WS 断言合法 UUID 与 6 字段;两次网络重试 UUID 相同、主动重选不同;敏感 label UI 不出现原值。
### T05 坐席持久化渲染、最新高亮与全链路回归
- **Source Files**: `src/frontend-agent/src/components/chat/MessageBubble.vue:52-80,359-386``src/frontend-agent/src/stores/conversation.ts:1151-1169,1230-1248``src/frontend-agent/src/composables/useWebSocket.ts:313-331``src/frontend-agent/src/utils/sensitive.ts``src/frontend-agent/src/components/chat/MessageBubble.spec.ts``src/backend/tests/test_option_select.py`
- **改动摘要**:1)新增 `option_select` 徽标与样式(约 30 行);(2)删除内存 set 真相,按消息提取选择(约 20 行);(3)按 question_id + `(created_at,id)` 计算最新描边(约 15 行);(4)执行 4 场景及 AC-01~AC-10 回归。
- **Dependencies**: T02、T04Dify/转人工验收依赖 T03
- **Priority**: P0
- **验证方法**: 在线收到标准事件;重连 REST 消息 ID 相同;同题只最新高亮;同名跨卡不串;旧 `recommend_event` 正常。
### 11.1 任务依赖图
```mermaid
graph TD
T01[T01 项目基础设施与协议基线]
T02[T02 后端持久化/幂等/广播/Mask]
T03[T03 Dify反馈与转人工快照]
T04[T04 H5字段/UUID/Mask]
T05[T05 坐席渲染与全链路回归]
T01 --> T02
T01 --> T04
T02 --> T03
T02 --> T05
T04 --> T05
T03 --> T05
```
### 11.2 Shared Knowledge
- 普通 API 响应统一使用 `{code, data, message}`WS 使用 `{type, data}`
- `messages` 是审计单一真源;选项只追加、不更新、不删除,展示状态均由事件流派生。
- 幂等键为 `(conversation_id, client_msg_id)`;5 秒内重复不产生任何副作用,5 秒后复用同 UUID 告警并拒绝。
- 所有时间使用服务端带时区 ISO 8601;最新排序固定为 `created_at DESC, id DESC`
- DB 保存原始 label;普通 REST、WS、Dify、转人工及前端展示均 mask。
- `question_id + option_id` 是跨卡归属标识,label 绝不作为唯一键。
- v1.0 生产保持单 worker;升级多 worker 前必须先解决 WS 跨进程广播。
### 11.3 待工程师确认点
1. 生产 PostgreSQL 版本需支持 `hashtextextended`;若版本过低,改用两个 32 位 `pg_advisory_xact_lock` 参数,不改变方案。
2. Dify 原生工作流需先声明 5 个 inputsdify2openai fallback 是否透传 `inputs` 需联调确认。
3. `Conversation.tags` 是否已在坐席会话详情响应透传;若未透传,仍以 `conversation_updated.selected_options + messages` 恢复,不新增列。