本提交为 .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-*/
28 KiB
技术方案 - 选项选择持久化
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 组件图
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 核心时序
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 关键对象类图
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 为次排序键 |
{
"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。
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 示意:
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:
{"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 推导“最近一次”,存在跨卡同名和重连丢失问题。
改造规则:
- 在文本分支前新增
message.msg_type === 'option_select'分支,渲染✓ {mask(content)}灰色次要徽标,不使用普通气泡视觉。 - store 中将
selectedOptionLabels改为基于messages.filter(msg_type==='option_select')的 computed 派生;归属以question_id + option_id + selected_from_message_id判断,不用 label。 - 同一
question_id按(created_at, id)稳定倒序,第一条isLatest=true使用主色描边/浅色背景,旧行保持灰色。 - 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 |
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 |
重试不放在全局 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 位数字基础规则:(?<!\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 传入:
{"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 取最新。每项为:
{"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改为 maskednew_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 任务依赖图
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 待工程师确认点
- 生产 PostgreSQL 版本需支持
hashtextextended;若版本过低,改用两个 32 位pg_advisory_xact_lock参数,不改变方案。 - Dify 原生工作流需先声明 5 个 inputs;dify2openai fallback 是否透传
inputs需联调确认。 Conversation.tags是否已在坐席会话详情响应透传;若未透传,仍以conversation_updated.selected_options + messages恢复,不新增列。