# 技术方案 - 选项选择持久化 > **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 风格,前端采用 MVVM:store 维护消息状态,`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 interceptor(WS 不是 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 位数字基础规则:`(?>'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、T04;Dify/转人工验收依赖 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 个 inputs;dify2openai fallback 是否透传 `inputs` 需联调确认。 3. `Conversation.tags` 是否已在坐席会话详情响应透传;若未透传,仍以 `conversation_updated.selected_options + messages` 恢复,不新增列。