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

28 KiB
Raw Blame 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 组件图

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.ymlsrc/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.pysrc/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.tssrc/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}/messagessrc/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_messagecreate_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-80text/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 option.value
selected_from_message_id 当前 ai_structuredmsg.message_id
client_msg_id 首次点击 crypto.randomUUID();同一 pending 请求重试复用
question_id msg.extra_data.question_id,兼容读取 option.question_id
option_id `option.id

重试不放在全局 axios interceptorWS 不是 axios 请求),而在 store 保存 pendingOptionSelect = {clientMsgId,payload,attempts};仅网络重连/发送失败重发该 payload。收到带相同 extra_data.client_msg_idnew_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_difyget_structured_reply 增加可选 feedback_context: dict | None,普通文本默认 Noneoption_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=escalatinghit=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:115src/backend/app/main.pysrc/backend/requirements.txtsrc/frontend-h5/package.jsonsrc/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-449src/backend/app/api/messages.py:89-166src/backend/app/utils/sensitive.pysrc/backend/app/models/message.py:104-115src/backend/app/schemas/message.py:14-18,86-136src/backend/tests/test_option_select.pysrc/backend/tests/test_sensitive.py
  • 改动摘要:1ws.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-1624src/backend/app/services/ai_service.py:241-278,338-374src/backend/app/utils/sensitive.pysrc/backend/tests/test_option_select.py
  • 改动摘要:1)可选 feedback context 透传 Dify inputs(约 15 行);(2feedback_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-1539src/frontend-h5/src/components/chat/MessageBubble.vue:383-396src/frontend-h5/src/utils/sensitive.tssrc/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-386src/frontend-agent/src/stores/conversation.ts:1151-1169,1230-1248src/frontend-agent/src/composables/useWebSocket.ts:313-331src/frontend-agent/src/utils/sensitive.tssrc/frontend-agent/src/components/chat/MessageBubble.spec.tssrc/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 任务依赖图

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 恢复,不新增列。