FSD m03 · 双语咨询(chat)
本册覆盖代码域
internal/domain/chat。全局规则见 00-总则 §2(SSE 聊天专项见 tech-design §9);直传三步通用规则 §2.5。
m03 功能节目录
| ID | 名称 | 路由/入口 |
|---|---|---|
| F3.01 | 创建匿名咨询会话 | POST /pub/v1/chat/session |
| F3.02 | SSE 流与心跳重连 | GET /pub/stream、GET /pt/stream、GET /api/stream |
| F3.03 | 发消息与异步翻译回填 | POST /pub/v1/chat/messages |
| F3.04 | 图片消息 | POST .../chat/images/presign + 发消息 |
| F3.05 | 已读回执 | POST .../chat/messages/read |
| F3.06 | B 端会话管理与回复 | GET /api/chat/sessions、POST /api/chat/sessions/:id/messages 等 |
| F3.07 | 注册卡片推送 | POST /api/chat/sessions/:id/card |
| F3.08 | 患者端聊天(/pt 转正+重译) | GET /pt/chat/session、POST /pt/chat/messages 等 |
F3.01 创建匿名咨询会话
对应 SRS:F-CHAT-001 | 实现落点:
internal/domain/chat/service.go:96(EnsureSession)/handler.go:25(路由) | 操作入口:app H5 医院详情页「咨询」按钮 / 聊天页首次进入(详情页按钮曾因onConsult引用未定义变量点击必抛 ReferenceError——⚠ DIF-F3-1 已修复 M15,回归锁 detail.test.tsx)
1 功能定义
访客以 gsid 幂等创建咨询会话(可带 hospitalID 标记发起页,0=列表页发起);返回会话供后续收发。同一 gsid 重复调用回读既有行(幂等)。
2 触发条件与前置状态
前置 = 持有 guest JWT(F1.03 签发)。一个 gsid 对应一个会话(uk_gsid 唯一键)。
3 输入与校验
| 字段 | 类型 | 校验 |
|---|---|---|
| hospital_id | body int64 | 可选;0=列表页发起(哨兵语义,M3 广播制未分配顾问 consultant_id 恒 0) |
4 处理流程
5 输出与结果状态
会话 JSON(id/gsid/patient_id=0/hospital_id/status=‘OPEN’/created_at);chat_session 行落库。
6 状态流转
chat_session.status='OPEN'(恒定,MVP 无关闭语义);patient_id=0(未绑定,F4.01 注册时 CAS 转正);consultant_id=0(广播制未分配)。
7 边界与异常
| 场景 | 行为 |
|---|---|
| 同 gsid 重复建 | 幂等回读(uk_gsid 兜底) |
| 并发双击 | 1062 → 回读既有行 |
| guest token 无效 | 401 |
8 权限与数据规则
guest JWT(/pub 面 + 限流);会话隔离=service 层强制 WHERE gsid = claim.gsid(不绑 IP/UA)。
F3.02 SSE 流与心跳重连
对应 SRS:F-CHAT-002(实时推送) | 实现落点:
internal/domain/chat/handler.go:197-267(Stream/streamEvents/writeEvent)、:251(StreamStaff)、:375(StreamPatient)、internal/infra/sse/hub.go| 操作入口:app/web 聊天页 EventSource;GET /pub/stream、GET /pt/stream、GET /api/stream
1 功能定义
三条 SSE 长连接:访客流(SessionKey(会话ID))、患者流(同 key,转正后)、B 端流(StaffKey=consultant:all 广播组)。事件全集 msg | msgTranslated | read | ticket | ping,每事件带 id:(chat_message.id)供断线续传。心跳 25s : ping 注释行。
2 触发条件与前置状态
- 访客流:会话已建(先查 gsid→会话,无会话 404)。
- 患者流:
patient_id已绑定会话(F4.01 后)。 - B 端流:bo JWT(
/api/stream,无权限码——登录即可订阅广播组)。 - 鉴权:
RequireJWTQuery(header 优先,queryaccess_token兜底——EventSource 无法带 Authorization 头)。
3 输入与校验
| 参数 | 说明 |
|---|---|
| access_token | query(或 Authorization 头) |
| after_id / Last-Event-ID | 断线游标(query 优先,兼容 Last-Event-ID 头——webview 支持不全) |
4 处理流程
5 输出与结果状态
SSE 帧流:id: <msgID>\nevent: <name>\ndata: <json>\n\n;ping 为注释行。慢消费者(send chan 满 8)丢弃本 conn + 失步标记——客户端重连全量 resync(宁可重连不能阻塞广播扇出)。
6 状态流转
无业务状态变更;hub 进程内 map[sessionKey]map[connID]*conn,单实例写死(扩容换 Redis pub/sub 接口不变)。
7 边界与异常
| 场景 | 行为 |
|---|---|
| 无会话(未建就订阅) | 404 |
| 患者未绑定就调 /pt/stream | 404(SessionByPatient ErrNotFound) |
| 心跳周期内无活动 | 25s 心跳 < 最短网元超时(微信 webview/NAT 30~60s);同周期清扫防 fd 泄漏(判活=ping 塞不进 chan 即关——⚠ DIF-M3 ⑬ 口径) |
| 事件丢失(崩溃窗口) | 5min 扫表重投兜底翻译;消息本体 MySQL 单一事实源 + REST 补拉 |
| 跨事件类型 | ticket 事件由 ticket 域 F5.02 三 key 同推(Commit 后) |
8 权限与数据规则
- 访客/患者:gsid/patient_id 谓词隔离;B 端广播制全员可见(MVP 无顾问分配)。
- Nginx 反代四要点(proxy_buffering off 等)见 deploy/nginx.conf;缺一即「迟到的批投递」。
F3.03 发消息与异步翻译回填
对应 SRS:F-CHAT-002 | 实现落点:
internal/domain/chat/service.go:140(Send)/:245(insertMsgTx)/service_worker.go:115(translateOne)/pure.go(校验纯函数) | 操作入口:app 聊天页输入框;API curl
1 功能定义
访客发送文本消息:同步落库(消息 + 扩展行 PENDING)→ SSE 推原文(P95<500ms 目标=原文送达,⚠ DIF-002 口径)→ 异步投翻译任务;worker 翻译完成后 CAS 回填译文并推 msgTranslated 补挂气泡。访客侧 50 条软上限(提示注册不阻断)。
2 触发条件与前置状态
guest JWT + 会话存在;会话已绑定患者后 guest 语义作废(发送/补拉均 409 ErrSessionBound——⚠ DIF-M4 裁决 3);未达软上限不阻断。
3 输入与校验
| 字段 | 类型 | 校验 |
|---|---|---|
| content | string | ValidateContent 纯函数:trim 后非空、≤2000 字符(MaxContentLen) |
| src_lang | string | 访客恒 ’en’(DstLang 纯函数→‘zh’) |
4 处理流程
5 输出与结果状态
200 {id, session_id, sender_type, content, src_lang, msg_type:'TEXT', translated_text:null, translation_status:'PENDING', read_at:null, soft_limit_reached:bool};译文异步经 SSE msgTranslated 补推。
6 状态流转
chat_msg_ext.translation_status:PENDING → DONE / FAILED(worker CAS;FAILED 可 F3.08/F3.03 重译回 PENDING);NONE 仅 CARD/IMAGE(不投翻译)。缓存命中时发送路径内联同步带译文(省一次异步往返)。
7 边界与异常
| 场景 | 行为 |
|---|---|
| content 空/超 2000 字符 | 400 |
| 会话已绑定患者 | 409 ErrSessionBound(guest 语义作废) |
| 会话不存在 | 404 |
| 翻译超时(3s)/熔断(连续 5 败开 60s) | FAILED 可重译;熔断期内新消息直接标 FAILED 不打 API |
| worker 崩溃残留 PENDING | 5min 扫表重投(LIMIT 100;重投幂等——翻译幂等+缓存去重) |
| ≥50 条访客消息 | soft_limit_reached=true(提示注册,不阻断——tech-design §9.5 上线前压测定值) |
| 软上限计数 | idx_session_id 前缀 COUNT(不加热路径状态列——⚠ DIF-M3 ④) |
| 版本竞态(并发 retranslate/已读) | CAS 命中 0 行即放弃(翻译路径不受影响) |
8 权限与数据规则
guest JWT + 限流;消息 append-only(chat_message 永不 UPDATE——译文/已读在 chat_msg_ext 拆表,version 只保护每消息至多两次低频更新);GDPR 抹除时 TEXT 正文脱敏(F4.05,ScrubSessionMessagesInTx 显式合规豁免)。
F3.04 图片消息
对应 SRS:F-CHAT-002(文本+图片) | 实现落点:
internal/domain/chat/service_image.go(PresignChatImage/SendImage/sendImageOnSession)/pure.go:76(IsImageMsgType) | 操作入口:app 聊天页图片按钮;API curl
1 功能定义
聊天图片 mini 两步直传:presign(jpg/png 10MB)→ 浏览器 PUT → 发消息请求内联 Store.Confirm(对象复核与消息落库绑死,无孤儿态)。msg_type='IMAGE',content 复用存 object_key;不投翻译。
2 触发条件与前置状态
访客(gsid 会话未绑定)或患者(/pt 面);前置 = 图片已 PUT 成功(发消息时 confirm 复核对象事实)。
3 输入与校验
| 端点 | 字段 | 校验 |
|---|---|---|
| POST …/chat/images/presign | mime | 域白名单收紧 image/jpeg, image/png(存储层白名单更宽含 pdf——聊天域收紧);10MB |
| POST …/chat/messages | key + msg_type=‘IMAGE’ | Confirm 复核实际 size/mime;白名单外实际类型 400 |
4 处理流程
5 输出与结果状态
消息 JSON(msg_type=‘IMAGE’,content=object_key,translation_status=‘NONE’);对象与消息无孤儿态(confirm 失败不落库)。
6 状态流转
不投翻译(ext 恒 NONE——worker/扫表只捞 PENDING 天然跳过,⚠ DIF-M4 裁决 6)。
7 边界与异常
| 场景 | 行为 |
|---|---|
| mime 白名单外(presign) | 400 ErrBadMIME |
| 对象不存在(confirm) | 404(os.ErrNotExist——⚠ DIF-M4 ⑭② 与病历域 404 对齐) |
| 实际类型不符/超 10MB | 400 |
| 会话已绑定(访客路径) | 409 |
| 已知局限 | S3 对象 mime=上传方 Content-Type 元数据(presign 锁死申报),伪装内容类型拦不住(DIF-M4 ①,文档仅在受信上下文渲染) |
8 权限与数据规则
guest/pt JWT + 限流;10MB 独立于病历 20MB×10 配额(聊天图不是病历);GDPR 抹除删对象本体、key 留作结构审计(F4.05)。
F3.05 已读回执
对应 SRS:F-CHAT-002(已读状态) | 实现落点:
internal/domain/chat/service.go:212(MarkRead)/:276(markReadExec)/handler.go:28(路由) | 操作入口:app/web 聊天页(收到消息自动上报或打开页面时)
1 功能定义
批量标记会话内 ≤upToID 的消息已读:read_at IS NULL 谓词幂等 UPDATE + 广播 read 事件(气泡变已读)。MVP 单 read_at 语义(被对侧读过的最早时刻,不区分 reader)。
2 触发条件与前置状态
访客(gsid)/患者(patient_id)/顾问(sessionID 路由参数)三入口共享内核;顾问侧额外带 StaffKey 回显。
3 输入与校验
| 字段 | 类型 | 校验 |
|---|---|---|
| up_to_id | body int64 | 消息 id 上界(幂等:NULL 谓词天然防重) |
4 处理流程
5 输出与结果状态
200 {marked: n}(本次实际标记条数);SSE read 事件 {session_id, up_to_id}。
6 状态流转
chat_msg_ext.read_at NULL → 时间戳(幂等写,豁免逐行 CAS——申报:非 ticket 状态机路径)。
7 边界与异常
| 场景 | 行为 |
|---|---|
| 重复上报 | marked=0(幂等) |
| up_to_id 超过实际消息 | 按实际命中行数 |
| 会话不存在/已绑定(访客路径) | 404 / 409 |
8 权限与数据规则
三面各自鉴权;read_at 是 NULL 豁免列(「尚未发生」语义)。
F3.06 B 端会话管理与回复
对应 SRS:F-CHAT-002(顾问侧) | 实现落点:
internal/domain/chat/service.go:409-463(ListSessions/ConsultantMessages/ConsultantSend/ConsultantMarkRead)/main.go:192-196(路由+权限码) | 操作入口:web/admin/chat会话工作台(列表→会话详情→回复/推送卡片/标读)
1 功能定义
顾问在 B 端工作台查看全部会话(广播制全员可见,100 条封顶)、补拉消息、以中文回复(zh→en 翻译方向)、标记已读。推送双 key:患者会话 key(对侧收 msg)+ StaffKey(B 端自身回显)。
2 触发条件与前置状态
bo JWT + chat:read(列表/补拉)/ chat:send(回复/标读/卡片);会话存在性校验。
3 输入与校验
| 端点 | 输入 | 校验 |
|---|---|---|
| GET /api/chat/sessions | 无 | 登录即可见全部(广播制) |
| GET /api/chat/sessions/:id/messages | after_id | 会话存在性(404) |
| POST /api/chat/sessions/:id/messages | content | ValidateContent(≤2000);src_lang 恒 ‘zh’ |
| POST /api/chat/sessions/:id/read | up_to_id | 幂等 |
4 处理流程
与 F3.03 同构(insertMsgTx 共享内核),差异:sender_type=‘CONSULTANT’、sender_id=uid、翻译方向 zh→en、推送加 StaffKey。
5 输出与结果状态
列表 {items: [{id, patient_id, hospital_id, status, msg_count, created_at}]}(msg_count 子查询实时 COUNT);消息/已读同 F3.03/F3.05。
6 状态流转
无会话级状态变更(consultant_id 恒 0——广播制不写分配,列先行免 M5 delta)。
7 边界与异常
| 场景 | 行为 |
|---|---|
| 会话不存在 | 404 |
| 列表 >100 会话 | 只出最新 100 条(量级 MVP) |
| 权限不足 | 403(chat:read/chat:send 路由级) |
8 权限与数据规则
chat:read/chat:send(CONSULTANT 持有;DOCTOR 持 chat:read 只读);广播制=MVP 无顾问-会话分配语义。
F3.07 注册卡片推送
对应 SRS:F-CHAT-003(注册登记推送) | 实现落点:
internal/domain/chat/service_card.go:26(SendCard)/main.go:196(路由) | 操作入口:web/admin/chat会话详情「推送注册」按钮
1 功能定义
顾问向会话推送结构化注册卡片:msg_type='CARD'、content 服务端构造 {"type":"register"}(客户端零参数防伪造)、不投翻译。患者端渲染卡片气泡,点击直达注册页(Taro hash 路由 #/pages/register)。
2 触发条件与前置状态
bo JWT + chat:send;会话存在(已绑定患者亦可推——转正后患者仍可收卡片)。
3 输入与校验
无业务入参(sessionID 路由参数;content 服务端构造——防伪造)。
4 处理流程
5 输出与结果状态
消息 JSON(msg_type=‘CARD’,translation_status=‘NONE’);患者端可见卡片并可一键转注册(衔接 F4.01)。
6 状态流转
无状态变更;卡片语义仅「去注册」一种(CardRegisterJSON 单点,后续类型在此扩展)。
7 边界与异常
| 场景 | 行为 |
|---|---|
| 会话不存在 | 404 |
| 重复推送 | 允许(每条卡片都是独立消息) |
| 翻译 | 不投(ext=‘NONE’,沿 IMAGE 先例——结构化 UI 不走文本翻译) |
8 权限与数据规则
chat:send;卡片完成后的注册完成通知走 F4.03(outbox),SSE 通知由 F4.01 事务链触发。
F3.08 患者端聊天(/pt 转正+重译)
对应 SRS:F-CHAT-002 | 实现落点:
internal/domain/chat/service_patient.go(SessionByPatient/PatientMessages/PatientSend/PatientMarkRead/PatientRetranslate)/handler.go:279-285(RegisterPatient) | 操作入口:app 聊天页(登录态自动切 /pt 面);API curl
1 功能定义
患者注册转正后(F4.01 绑定 patient_id)经 /pt 面继续同一会话:查自己的会话、补拉、发消息(英文→中文翻译方向)、已读、重译。scope 一律由 patient_id 解析(SessionByPatient 单点:WHERE patient_id=? ORDER BY id DESC LIMIT 1——多会话预留演进点)。
2 触发条件与前置状态
pt JWT(iss=pt);会话已绑定该患者(无绑定 404——注册页直达未绑定场景走 guest 面或无会话)。
3 输入与校验
| 端点 | 输入 | 校验 |
|---|---|---|
| GET /pt/chat/session | 无 | 返回绑定会话(404=未绑定) |
| GET /pt/chat/messages | after_id | scope 由 patient_id 解析;无软上限语义 |
| POST /pt/chat/messages | content | ValidateContent;src_lang 恒 ’en’、sender_id=0(会话归属即身份) |
| POST /pt/chat/messages/read | up_to_id | 幂等 |
| POST /pt/chat/messages/:id/retranslate | :id | 归属校验(不归属统一 404 不可见) |
4 处理流程
与 F3.03/F3.05 同构共享内核(insertMsgTx/markReadExec/retranslateMsg);差异:scope=SessionByPatient、推送加 StaffKey(B 端回显)、无软上限(注册转化目标已达成)。
5 输出与结果状态
同构 F3.03/F3.05;重译 202(FAILED CAS 置 PENDING 重投;PENDING 幂等 202;DONE/NONE 400 ErrNotRetranslatab)。
6 状态流转
translation_status 同 F3.03;绑定关系(patient_id)由 F4.01 事务链一次性写入。
7 边界与异常
| 场景 | 行为 |
|---|---|
| 未绑定会话 | 404 |
| 重译不归属消息 | 404(统一不可见——不暴露他人消息存在性) |
| 重译 DONE/NONE 消息 | 400(不可重试语义) |
| guest token 仍有效 | 服务端绑定守卫双闸:/pub 发送/补拉 409(F3.03)+ app 按 hasPtToken 切面(⚠ DIF-M4 ④) |
8 权限与数据规则
pt JWT 面隔离 + patient_id 谓词(不走权限码);历史聊天经 session.patient_id 自动归属(消息表不需要回填——F4.01 设计红利)。