跳转到主要内容

FSD m03 · 双语咨询(chat)

本册覆盖代码域 internal/domain/chat。全局规则见 00-总则 §2(SSE 聊天专项见 tech-design §9);直传三步通用规则 §2.5。

m03 功能节目录

ID名称路由/入口
F3.01创建匿名咨询会话POST /pub/v1/chat/session
F3.02SSE 流与心跳重连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.06B 端会话管理与回复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_idbody 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 优先,query access_token 兜底——EventSource 无法带 Authorization 头)。
3 输入与校验
参数说明
access_tokenquery(或 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/stream404(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 输入与校验
字段类型校验
contentstringValidateContent 纯函数:trim 后非空、≤2000 字符(MaxContentLen)
src_langstring访客恒 ’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 崩溃残留 PENDING5min 扫表重投(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/presignmime域白名单收紧 image/jpeg, image/png(存储层白名单更宽含 pdf——聊天域收紧);10MB
POST …/chat/messageskey + 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 对齐)
实际类型不符/超 10MB400
会话已绑定(访客路径)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_idbody 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/messagesafter_id会话存在性(404)
POST /api/chat/sessions/:id/messagescontentValidateContent(≤2000);src_lang 恒 ‘zh’
POST /api/chat/sessions/:id/readup_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/messagesafter_idscope 由 patient_id 解析;无软上限语义
POST /pt/chat/messagescontentValidateContent;src_lang 恒 ’en’、sender_id=0(会话归属即身份)
POST /pt/chat/messages/readup_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 设计红利)。