本提交为 .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-*/
12 KiB
PRD - 选项选择持久化
REQ 编号: REQ-通用-005 版本: v1.0 日期: 2026-07-29 作者: 许清楚(PM)+ 宋献(Simon) 状态: ✅ 已批准 关联:
- 技术方案:docs/02-技术文档/技术架构/技术方案-REQ-通用-005-选项选择持久化-v1.0.md(待架构师出)
- 测试用例:docs/03-测试文档/03-功能测试用例/TC-REQ-通用-005-选项选择持久化-v1.0.md(待 QA 出)
- 代码真相:src/backend/app/api/ws.py + src/backend/app/services/h5_ai_task.py
- 相关 PRD:PRD-REQ-用户-006-智能推荐重构-v1.0.md(衍生关系)
1. 问题陈述
Dify 工作流可输出 quick reply / 选择题形式的“选项消息”。AI 选项消息已以 msg_type="ai_structured" 和 extra_data.options 持久化,但用户点选后,后端 _handle_option_select 当前故意不写 messages 表,仅进行瞬时 WebSocket 广播并将 option_label 传给 Dify(src/backend/app/api/ws.py:277-281)。坐席端的 selectedOptionLabels 又是纯内存集合,重连即失效,导致选择动作无法追溯。
| 优先级 | 业务影响 | 真实场景 | 可量化后果 |
|---|---|---|---|
| P0 | 合规与审计留痕缺失 | 金融/政府客户复盘“员工选择了哪一项”时,管理端无记录 | 操作链不完整,无法满足可追溯要求 |
| P0 | 坐席分诊效率下降 | 紧急报修员工已选“网络中断”,坐席进会话后看不到,只能重问 | 会话时长预计增加约 20%,存在 SLA 风险 |
| P1 | AI 推荐反馈闭环断裂 | 智能推荐卡 A/B/C 中用户选 C,但系统无结构化选择数据 | A/B/C 效果不可归因,AI 投入 ROI 难评估 |
| P1 | 会话连续性中断 | 员工刷新或退出 H5 后,历史中缺少已选内容 | 断点续聊上下文不完整,Dify 将选择误识别为普通发言 |
| P2 | 员工体验不一致 | 员工看不到“我之前选了什么” | 重复操作、降低信任感 |
目标:以零数据库迁移方式,将每次选择作为 employee 消息持久化,并在 H5、坐席、管理审计、Dify 反馈链和转人工上下文中形成一致、可追溯的数据闭环。
2. 用户故事
| 角色 | 现状 | 期望用户故事 |
|---|---|---|
| 员工 H5 | 刷新/退出后已选内容消失 | 作为员工,我希望每次选择都进入消息历史,以便刷新或续聊时仍能确认我选过什么 |
| 坐席 PC | 仅实时内存可见,进入晚或重连后不可见 | 作为坐席,我希望实时及历史消息中看到“✓ 选项”,并识别最新选择,以便无需重复询问即可分诊 |
| 管理端 | 无选择记录可供审计、复盘 | 作为管理员,我希望按会话追溯选择时间、题目和选项,以便满足合规审计和投诉复盘 |
| AI 训练/运营 | 选择被当作普通自然语言,且缺少选项归属 | 作为 AI 训练人员,我希望获得带 question_id、option_id 和 feedback_type 的反馈,以便准确评估推荐效果 |
3. 范围
3.1 In-scope(v1.0 MVP)
| # | 范围项 | 优先级 |
|---|---|---|
| 1 | _handle_option_select 插入 msg_type="option_select" 的员工消息(src/backend/app/api/ws.py:270-281) |
P0 |
| 2 | 选择成功后使用标准 new_message 事件广播(src/backend/app/api/ws.py:284-293) |
P0 |
| 3 | 坐席 MessageBubble.vue 渲染“✓ {content}”灰色徽标,最新选择高亮(src/frontend-agent/src/components/chat/MessageBubble.vue:52-77) |
P0 |
| 4 | H5 sendOptionSelect 补齐来源消息、客户端幂等及题目/选项标识(src/frontend-h5/src/stores/conversation.ts:1486) |
P0 |
| 5 | 客户端生成 UUID,服务端按会话与客户端消息 ID 执行 5 秒去重 | P0 |
| 6 | 回归“发送→点选→坐席实时→坐席重连→REST 历史仍可见”完整链路 | P0 |
| 7 | Dify inputs 注入 feedback_type=option_select |
P1 |
| 8 | 转人工时注入 selected_options 快照 |
P1 |
| 9 | 选项展示遵循敏感信息中间四位脱敏 | P0 |
3.2 Out-of-scope
- 方案 B:新增
conversation_selections表及数据库迁移。 - 方案 C:仅向
ai_structured.extra_data追加selected_options。 - 多卡嵌套、父子题、条件题等复杂语义编排。
- 跨会话选择聚合、BI 看板与推荐效果报表。
4. 功能需求
4.1 后端落库 Schema
服务端收到合法选择后,必须先完成幂等判断,再向现有 messages 表追加一行;不得覆盖原选项消息或历史选择。实现位置为 _handle_option_select(src/backend/app/api/ws.py:270-281)。
| 字段 | 类型/示例 | 必填 | 规则 |
|---|---|---|---|
msg_type |
"option_select" |
是 | 新增文档化取值;现有字段 String(20),零迁移 |
sender_type |
"employee" |
是 | 表示员工操作 |
content |
"网络中断" |
是 | 保存原始 option_label,展示时再脱敏 |
extra_data.option_value |
"network_down" |
是 | 传给业务/Dify 的选项值 |
extra_data.selected_from_message_id |
消息 ID | 是 | 关联产生选项的 ai_structured 消息 |
extra_data.client_msg_id |
UUID | 是 | 幂等键 |
extra_data.question_id |
"fault_type" |
是 | 题目稳定标识,支持跨卡归属 |
extra_data.option_id |
"network_down" |
是 | 选项稳定标识,禁止仅靠 label 归属 |
持久化成功后,REST 历史接口必须按现有消息排序规则返回该记录;失败时不得向 Dify提交“已成功选择”的假状态。
4.2 WS 事件改造
当前瞬时专用广播必须改为标准 new_message 事件(src/backend/app/api/ws.py:284-293),事件 payload 应复用持久化后的消息对象,至少包含消息 ID、会话 ID、发送方、消息类型、内容、extra_data、创建时间。坐席实时态与重连后的 REST 历史态必须使用同一数据模型。
4.3 坐席端渲染规范
src/frontend-agent/src/components/chat/MessageBubble.vue:52-77必须增加msg_type === "option_select"分支。- 徽标位于员工消息流原选择发生的时间位置,文案为
✓ {mask(content)},采用灰色次要信息样式,不渲染为普通气泡。 - 同一
question_id多次选择全部保留;当前最新一条使用主色描边或浅色背景高亮,旧选择降级为灰色。 - 最新判定按服务端消息时间与消息 ID 稳定排序,不依赖
selectedOptionLabels内存集合。
4.4 H5 端字段补全
sendOptionSelect(src/frontend-h5/src/stores/conversation.ts:1486-1518)必须发送:option_label、option_value、selected_from_message_id、client_msg_id、question_id、option_id。client_msg_id 在首次点击时生成 UUID;同一次请求重试必须复用,用户主动重选必须生成新 UUID。
4.5 幂等与去重规则
- 服务端幂等键:
(conversation_id, client_msg_id)。 - 去重窗口:首次受理后 5 秒;窗口内重复请求只返回首次成功结果,不新增消息、不重复广播、不重复调用 Dify。
- 5 秒后相同 ID 仍不得被客户端主动复用;服务端可记录告警并拒绝,以避免历史重复。
- 不同
client_msg_id即视为撤回后的重选/再次选择,追加新行。
4.6 Dify 集成
传入 Dify Workflow 的 inputs 必须新增 feedback_type="option_select",并同时传递 question_id、option_id、option_value、脱敏后的 option_label;接入点由技术方案基于现有 Dify 调用链定位(当前调用见 src/backend/app/tasks/h5_ai_task.py:1505-1514,任务入口见 src/backend/app/tasks/h5_ai_task.py:1617-1624)。Dify 必须据此区分“用户选择了 X”与“用户自然语言说了 X”,且不破坏现有普通文本消息链路。
4.7 转人工快照
触发转人工时,系统必须按每个 question_id 取最新一条有效选择,组成 selected_options 注入坐席上下文;每项至少包含 question_id、option_id、脱敏 label、选择消息 ID、选择时间。快照仅用于快速接续,审计真相仍以 messages 表全部追加记录为准。
4.8 敏感词 Mask
选项含账号、身份证号等敏感数字串时,数据库保存原始值以满足审计权限场景;H5、坐席普通视图、WS 普通 payload、Dify inputs 和转人工快照必须将数字串中间连续四位替换为 ****。不足 4 位的敏感值全部掩码;脱敏不得改变 question_id、option_id 的匹配与最新选择判定。
5. 验收标准
| 编号 | 对应需求 | 验收用例与通过标准 |
|---|---|---|
| AC-01 | 4.1 | 点选后 messages 新增 1 行:msg_type=option_select、sender_type=employee,5 个 extra_data 字段完整;REST 重拉仍存在 |
| AC-02 | 4.1 | 连续重选两次形成两行,不覆盖首次记录,均可按时间追溯 |
| AC-03 | 4.2 | 坐席在线时收到标准 new_message;断线重连后从 REST 得到相同消息 ID 与内容 |
| AC-04 | 4.3 | 坐席显示“✓ 选项”灰色徽标;同一 question_id 仅最新一条高亮,旧记录仍可见 |
| AC-05 | 4.4 | H5 每次主动选择生成合法 UUID,并携带来源消息、题目、选项标识;重试复用 UUID |
| AC-06 | 4.5 | 5 秒内用相同 (conversation_id, client_msg_id) 重发 3 次,仅落库、广播、调用 Dify 各 1 次 |
| AC-07 | 4.6 | Dify 收到 feedback_type=option_select 及题目/选项字段;普通文本仍沿用原语义 |
| AC-08 | 4.7 | 员工选择后立即转人工,坐席上下文包含各 question_id 最新选择;坐席无需重问 |
| AC-09 | 4.8 | 选项包含账号/身份证示例时,各普通展示及 Dify 输入中间四位为 ****,数据库审计原值不变 |
| AC-10 | 全链路 | 完成“AI 发选项→员工点选→坐席实时 ✓→坐席重连→REST 历史仍可见”,全程无重复记录 |
6. 边界场景
| 场景 | 产品规则 | 预期结果 |
|---|---|---|
| 撤回/重选 | 不改旧行,使用新 client_msg_id 追加记录 |
全历史可见;同题最新一条高亮并进入快照 |
| 弱网重发 | 同一请求复用 client_msg_id,5 秒窗口去重 |
只落库、广播、调用 Dify 一次 |
| 跨卡归属 | 必须联合 question_id + option_id,label 不作为唯一键 |
多卡存在同名 label 时仍准确归属 |
| 敏感词 | 存储原值,展示与外发链路 mask 中间四位 | 审计可追溯,普通使用方不暴露敏感值 |
| 转人工 | 每题取最新选择生成 selected_options |
人工坐席获取完整断点上下文,历史行不丢失 |
7. 非目标
- 不新增选择专表、不执行 Alembic 迁移。
- 不把选择状态回写到原
ai_structured.extra_data,避免撤回/重选语义丢失。 - 不建设选项编辑、撤销按钮;v1.0 的“撤回”通过再次选择表达。
- 不定义多卡嵌套题、跨题依赖和选择有效期。
- 不建设跨会话 BI、推荐转化率报表或模型自动训练流水线。
- 不改造所有历史
recommend_event数据,仅保证新链路兼容。
8. 风险
| 风险点 | 等级 | 说明 | 缓解/验证 |
|---|---|---|---|
ws_manager 单例状态依赖 |
高 | 单进程内存去重或广播状态在重启后丢失 | 幂等以消息持久化查询为准,内存仅作加速;补充重启回归 |
| 多 worker 并发竞态 | 高 | 两个 worker 同时处理同一 UUID,5 秒内可能双写 | 技术方案必须定义原子去重策略;并发压测验证仅生成一条消息 |
| Dify 反馈链语义变化 | 中 | 新增 feedback_type 后,旧工作流节点可能忽略或误用字段 |
字段向后兼容、灰度开启;验证普通文本和选项两条链 |
recommend_event 兼容性 |
中 | 现有智能推荐事件仍可能依赖旧 payload 或 label | 保留旧必要字段,新增字段只增不删;覆盖单卡、多卡与转人工回归 |
9. 变更记录
| 版本 | 日期 | 变更内容 | 变更人 | 变更原因 |
|---|---|---|---|---|
| v1.0 | 2026-07-29 | 创建 PRD,固化方案 A、6 项产品决策与 MVP 验收范围 | 许清楚、宋献 | 修复选项选择不落库导致的数据完整性问题 |