# FSD m03 · 双语咨询（chat）

LLMS 索引： [llms.txt](/llms.txt)

---

> 本册覆盖代码域 `internal/domain/chat`。全局规则见 [00-总则](/prd/fsd-medilink/00-%E6%80%BB%E5%88%99/) §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 处理流程

```mermaid
sequenceDiagram
    participant A as app H5
    participant H as chat.Handler
    participant S as chat.Service
    participant DB as MySQL
    A->>H: POST /pub/v1/chat/session {hospital_id?}
    H->>S: EnsureSession(ctx, gsid, hospitalID)
    S->>DB: SELECT chat_session WHERE gsid=?
    alt 已存在
        S-->>A: 回读既有会话（幂等）
    else 不存在
        S->>DB: INSERT chat_session(status='OPEN', version=1)
        alt 并发双击撞 uk_gsid 1062
            S->>DB: 回读既有行（竞态窗口安全）
        end
    end
    H-->>A: 200 会话 JSON
```

##### 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_token | query（或 Authorization 头） |
| after_id / Last-Event-ID | 断线游标（query 优先，兼容 Last-Event-ID 头——webview 支持不全） |

##### 4 处理流程

```mermaid
sequenceDiagram
    participant CL as 浏览器 EventSource
    participant H as chat.Handler
    participant HUB as sse.Hub
    participant DB as MySQL
    CL->>H: GET /pub/stream (token, after_id)
    H->>H: gsid → 会话（无会话 404）
    H->>HUB: Subscribe(SessionKey(sess.ID))
    H-->>CL: 200 text/event-stream（no-cache）
    H->>DB: 补拉 after_id 之后消息（≤50 条）
    H-->>CL: 补拉事件先于实时事件（同 conn 有序）
    loop 每 25s
        H-->>CL: ": ping" 注释行（穿透代理；同周期清扫死 conn）
    end
    Note over CL,H: 断线 → 指数退避重连（1s,2s,4s,上限 30s）带新游标 → 重复补拉
```

##### 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 处理流程

```mermaid
sequenceDiagram
    participant A as 访客
    participant H as chat.Handler
    participant S as chat.Service
    participant DB as MySQL
    participant HUB as sse.Hub
    participant W as 翻译 Worker×2
    participant TR as Translator（cache→timeout3s→breaker）
    A->>H: POST /pub/v1/chat/messages {content}
    H->>S: Send(ctx, gsid, "GUEST", …)
    S->>S: ValidateContent 纯函数校验
    S->>DB: SELECT 会话（patient_id≠0 → 409）
    S->>DB: COUNT 访客消息（软上限标记）
    S->>DB: WithTx: INSERT chat_message + chat_msg_ext(PENDING)
    S->>HUB: Commit 后推 msg 原文（SessionKey）
    HUB-->>A: event:msg（送达，目标 P95<500ms）
    S->>S: enqueueTranslate(msgID)（缓冲 chan 256 非阻塞）
    W->>TR: Translate(en→zh)（4000 字符截断告警）
    alt 成功
        W->>DB: CAS 回填 DONE+译文（version 谓词）
        W->>HUB: 推 msgTranslated
    else 失败
        W->>DB: CAS 标 FAILED
        W->>HUB: 推 msgTranslated(status=FAILED)
    end
    H-->>A: 200 消息 JSON + soft_limit_reached
```

##### 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 处理流程

```mermaid
sequenceDiagram
    participant A as 访客/患者
    participant H as chat.Handler
    participant S as chat.Service
    participant OS as MinIO
    A->>H: POST .../chat/images/presign {mime}
    H->>S: PresignChatImage（域白名单→NewKey("chat")）
    S-->>A: {key, upload_url, headers}
    A->>OS: PUT 图片（直传）
    A->>H: POST .../chat/messages {key, msg_type:"IMAGE"}
    H->>S: SendImage
    S->>OS: Confirm（HeadObject 复核）
    alt 对象不存在/超限/类型不符
        S-->>A: 404 / 400
    end
    S->>S: WithTx 双 INSERT（content=key, ext NONE）
    S->>S: pushMsg(SessionKey + StaffKey)
    H-->>A: 200 消息 JSON（translation_status='NONE'）
```

##### 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 处理流程

```mermaid
flowchart LR
    A[POST .../messages/read] --> B[解析 scope<br/>gsid/patient/sessionID 三入口]
    B --> C["UPDATE chat_msg_ext e JOIN chat_message m<br/>SET read_at=?, version=version+1<br/>WHERE session_id=? AND m.id&lt;=? AND read_at IS NULL"]
    C --> D[推送 read 事件<br/>SessionKey + extraKeys]
```

##### 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 处理流程

```mermaid
sequenceDiagram
    participant W as web 会话工作台
    participant H as chat.Handler
    participant S as chat.Service
    participant DB as MySQL
    participant HUB as sse.Hub
    W->>H: POST /api/chat/sessions/:id/card
    H->>S: SendCard（sessionByID 存在性）
    S->>DB: WithTx 双 INSERT（CARD / content={"type":"register"} / ext NONE）
    S->>HUB: pushMsg(SessionKey + StaffKey)
    H-->>W: 200 消息 JSON
    HUB-->>A: 患者端卡片气泡（点击跳注册页）
```

##### 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 设计红利）。

---

反链：

- [功能规格说明书(FSD)](/prd/fsd-medilink/)
- [MediLink Global 功能规格说明书（FSD）v1.0 · 总则](/prd/fsd-medilink/00-%E6%80%BB%E5%88%99/)
