Files
wecom_it_smart_desk/docs/02-技术文档/技术架构/技术方案-REQ-通用-005-选项选择持久化-v1.0.md
T

523 lines
28 KiB
Markdown
Raw Normal View History

# 技术方案 - 选项选择持久化
> **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` 恢复,不新增列。