# FSD m04 · 患者注册与病历（patient + ticket 直传）

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

---

> 本册覆盖代码域 `internal/domain/patient` 与 `internal/domain/ticket` 的直传三步/注册事务链部分。全局规则见 [00-总则](/prd/fsd-medilink/00-%E6%80%BB%E5%88%99/) §2；直传三步通用规则 §2.5。

## m04 功能节目录

| ID | 名称 | 路由/入口 |
|---|---|---|
| F4.01 | 注册事务链（建账号+绑会话+开单+通知） | `POST /pub/v1/register` |
| F4.02 | 病历直传三步协议 | `POST /pub/v1/documents/presign` + `/confirm` |
| F4.03 | 注册完成通知（确认邮件+顾问 INBOX） | outbox（事务链内入列） |
| F4.04 | 患者资料与进度派生 | `GET /pt/me`、`GET /pt/progress` |
| F4.05 | GDPR 导出与抹除 | `GET /pt/me/export`、`POST /api/patients/:id/erase` |

---

#### F4.01 注册事务链（建账号+绑会话+开单+通知）

> 对应 SRS：F-REG-001 / F-TICK-001（开单腿） ｜ 实现落点：`internal/domain/patient/service.go:70`（Register）/`chat.BindPatientInTx`（chat/service.go:480）/`ticket.CreateInTx`（ticket/service.go:236）/`ticket.BindDocumentsInTx`（ticket/service.go:126）/`handler.go:30`（路由） ｜ 操作入口：app H5 `#/pages/register`（聊天卡片点击或直达）

##### 1 功能定义

访客提交注册表单，单一事务完成：建 `user_account`(PATIENT) + `patient_profile` → CAS 绑定聊天会话（三态语义）→ 开工单（INSERT + ticket_no 回填 + lifecycle + op_log）→ 病历文档归属对账回填 → outbox×2（确认邮件 + 顾问 INBOX）→ consent 留痕。任一步失败回滚整链。响应发 pt JWT（旧 guest token 语义作废）。

##### 2 触发条件与前置状态

- guest JWT（gsid 识别访客；注册页直达无会话 = 合法主路径，跳过绑定不报错）。
- 前置校验：email 未注册（预检 COUNT + 事务内 uk 1062 兜底同映射 409）；表单过 `ValidateRegister` 纯函数。

##### 3 输入与校验

| 字段 | 类型 | 校验（`patient/pure.go:64` ValidateRegister，返回首个错误 400） |
|---|---|---|
| first_name / last_name | string | 拼接非空（FullName 单空格规范化） |
| email | string | 格式 + NormalizeEmail 归一；唯一（409 ErrEmailTaken） |
| password | string | 8~72 字符（bcryptMaxPasswordLen 硬限不静默截断） |
| nationality | string | ISO 3166-1 alpha-2（入库大写） |
| gender | enum | MALE / FEMALE / OTHER |
| age | int | 1~120 |
| phone | string | trim 非空 |
| chief_complaint | string | trim 非空、≤2000 rune（→ ticket.chief_complaint） |
| expect_city / expect_window | string | city 必填；window 可选 |
| insurance_info | string | 可选 |
| consent | bool | 必须 true（ErrConsentRequired） |
| document_ids | []int64 | 可选；presign/confirm 已落库文档 id，事务内归属对账 |

##### 4 处理流程

```mermaid
sequenceDiagram
    participant A as app 注册页
    participant H as patient.Handler
    participant S as patient.Service
    participant DB as MySQL（单一事务）
    A->>H: POST /pub/v1/register（guest JWT + 表单）
    H->>S: Register(ctx, gsid, in, ip)
    S->>S: ValidateRegister 纯函数
    S->>DB: 预检 COUNT(email)（>0 → 409）
    S->>DB: INSERT user_account(PATIENT, bcrypt, totp_status='NONE')
    S->>DB: INSERT patient_profile
    S->>DB: BindPatientInTx: UPDATE chat_session SET patient_id WHERE gsid=? AND patient_id=0
    Note over S,DB: affected=0 二次复核：无会话=合法跳过；被他人绑定=409 回滚
    S->>DB: CreateInTx: INSERT ticket(CREATED) → 回填 ticket_no='T'+yyyyMMdd+LPAD(id,4) → lifecycle + op_log
    S->>DB: BindDocumentsInTx: UPDATE medical_document SET ticket_id WHERE gsid+ticket_id=0+id IN(…)
    Note over S,DB: affected≠len(document_ids) → 409 ErrDocOwnership 回滚
    S->>DB: outbox×2（EMAIL register_confirm / INBOX register_notify → role:CONSULTANT）
    S->>DB: INSERT consent_record(REGISTER, v1, ip)
    S->>DB: COMMIT
    S->>S: IssuePT(uid)（iss=pt, 7d）
    H-->>A: 200 {token, patient_id, ticket:{id, ticket_no}}
```

##### 5 输出与结果状态

`{token, expires_in:604800, patient_id, ticket: {id, ticket_no}}`；历史聊天经 `session.patient_id` 自动归属（消息表零回填）；前端切 /pt 面 + 双存 pt token。

##### 6 状态流转

- `chat_session.patient_id`: `0 → uid`（CAS 谓词，天然幂等——非状态机列，AST 守卫申报豁免）。
- `ticket.status`: `→ CREATED`（创建即 lifecycle 'CREATED' 事件）；下一迁移 PENDING_ASSIGN 归 F5.01。
- consent_record 追加 REGISTER 行（append-only）。

##### 7 边界与异常

| 场景 | 行为 |
|---|---|
| email 已注册 | 409 ErrEmailTaken（预检+uk 双兜底） |
| 会话已被其他 gsid 绑定 | 409 ErrSessionBound（回滚整链） |
| document_ids 含他人/不存在文档 | 409 ErrDocOwnership（affected 对账） |
| 表单任一校验失败 | 400（返回首个错误哨兵） |
| consent=false | 400 |
| 事务任一步失败 | 整链回滚（无半注册状态） |
| 孤儿文档（confirm 后未 register） | 不对账放任（⚠ DIF-M4 ⑪ 申报：对象存在性非事务资源，随 gsid 生命周期） |

##### 8 权限与数据规则

- guest JWT + /pub 限流；密码 bcrypt(cost 10)。
- consent_record 记录版本(v1)/时间/IP——数据出境告知（医疗数据存储于中国境内，SRS §4.2）。
- op_log actor=SYSTEM（register 动作）；lifecycle ActorID=patient_id。

---

#### F4.02 病历直传三步协议

> 对应 SRS：F-REG-002 ｜ 实现落点：`internal/domain/ticket/service.go:70`（PresignDocument）/`:91`（ConfirmDocument）/`handler.go:24-25`（路由，实现在 ticket 域 Register(pubV)） ｜ 操作入口：app 注册页上传区（拖拽+XHR 进度条，⚠ DIF-M5 ⑧）

##### 1 功能定义

患者注册前上传病历文件（PDF/JPG/PNG，20MB/文件，每 gsid 上限 10 份，自填标签描述）：presign 签发带 policy 的 PUT URL → 浏览器直传 → confirm 服务端复核落库（ticket_id=0 哨兵 + session_gsid 所有权锚），注册时 F4.01 归属回填。

##### 2 触发条件与前置状态

guest JWT；配额第一道（presign 前 COUNT 未绑定文档，第 11 份 409）。

##### 3 输入与校验

| 端点 | 字段 | 校验 |
|---|---|---|
| POST /pub/v1/documents/presign | mime | pdf/jpeg/png 白名单（PresignPut policy 20MB + 15min） |
| POST /pub/v1/documents/confirm | key/label/file_name | Confirm 复核实际 size/mime（第二道）；配额复核带 `ticket_id=0` 谓词（已绑定历史文档不占新配额，⚠ DIF-M4 ⑭①）；label/file_name 截断 255 |

##### 4 处理流程

```mermaid
sequenceDiagram
    participant A as app 注册页
    participant H as ticket.Handler
    participant S as ticket.Service
    participant OS as MinIO
    participant DB as MySQL
    A->>H: POST /pub/v1/documents/presign {mime}
    H->>S: PresignDocument
    S->>DB: COUNT 未绑定文档（≥10 → 409 ErrDocQuota）
    S->>OS: PresignPut(key=medical/*, policy 20MB)
    H-->>A: 201 {key, upload_url, headers}
    A->>OS: PUT 文件（XHR onprogress 进度条）
    A->>H: POST /pub/v1/documents/confirm {key, label, file_name}
    H->>S: ConfirmDocument
    S->>OS: Confirm（HeadObject 复核 size/mime）
    S->>DB: COUNT 复核（ticket_id=0 谓词）→ INSERT medical_document(ticket_id=0, session_gsid, stage='NONE', version_no=1, is_current=1)
    H-->>A: 200 文档 JSON
```

##### 5 输出与结果状态

文档 JSON（id/object_key/file_name/label/mime/size_bytes/stage='NONE'/version_no=1/is_current=1）；对象与文档行落库，`ticket_id=0` 待 F4.01 回填。

##### 6 状态流转

无状态变更（medical_document 版本组三列 M4 恒 doc_group=0/version_no=1/is_current=1——版本语义 M7 阶段资料启用，F8.02）。

##### 7 边界与异常

| 场景 | 行为 |
|---|---|
| 第 11 份文档 | 409 ErrDocQuota（两道同谓词） |
| 对象不存在（confirm） | 404（os.ErrNotExist） |
| 实际超 20MB / mime 白名单外 | 400（ErrTooLarge/ErrBadMIME） |
| 并发竞态（COUNT+INSERT 非原子） | 上限可能多塞个位数，MVP 可接受（恶意损耗面小——service.go:90 注释申报） |
| ENUM 严格模式 | stage 显式 'NONE'（空串 500 教训，⚠ DIF-M4 ⑬） |
| UI 三项（标签描述/拖拽/进度） | M5 补齐（⚠ DIF-M5 ⑧ 用户裁决不砍） |

##### 8 权限与数据规则

guest JWT + 限流；对象 key 服务端生成（`storage.NewKey("medical")`）；SSE-AES 服务端加密 + 15min 预签名（tech-design §11.2 三层加密之一）。

---

#### F4.03 注册完成通知（确认邮件+顾问 INBOX）

> 对应 SRS：F-REG-003（确认邮件、自动通知顾问） ｜ 实现落点：`internal/domain/patient/service.go:139-152`（事务链内 EnqueueInTx×2）/`internal/infra/notify`（dispatcher 投递） ｜ 操作入口：—（系统自动；INBOX 消费见 F10.05）

##### 1 功能定义

注册事务链内同事务入列两条通知：患者确认邮件（EMAIL 通道，dispatcher 30s 扫描投递）+ 顾问站内通知（INBOX 通道，待办列表）。投递成功页面语义由 F4.01 响应直接承载（成功页+工单号），通知是异步补充。

##### 2 触发条件与前置状态

F4.01 事务 Commit（与业务同事务 all-or-nothing——业务失败通知必不入列）。

##### 3 输入与校验

payload = `{ticket_no, patient_name}`；recipient = 患者 email / `role:CONSULTANT`（角色谓词待办）。

##### 4 处理流程

```mermaid
flowchart LR
    A[F4.01 事务内] --> B["EnqueueInTx EMAIL register_confirm → recipient=email"]
    A --> C["EnqueueInTx INBOX register_notify → recipient=role:CONSULTANT"]
    B --> D[dispatcher 30s 扫描<br/>指数退避 3 败 FAILED]
    C --> E["/api/inbox 待办（read_at 标读）"]
```

##### 5 输出与结果状态

`notification_outbox` 两行（PENDING）；EMAIL 行被 dispatcher 投递后 SENT；INBOX 行常驻 PENDING 待顾问标读。

##### 6 状态流转

EMAIL：`PENDING → SENT / FAILED`（FAILED 可 F10.05 人工重发）；INBOX：status 恒 PENDING，已读只回写 `read_at`（⚠ DIF-M5 ⑥——投递语义与已读语义分离）。

##### 7 边界与异常

| 场景 | 行为 |
|---|---|
| dev/e2e（mailer=log 档） | 日志即送达（SendGrid 未配时降级可见） |
| SendGrid 投递失败 | 指数退避（30s 起翻倍）3 败 FAILED → 人工重发（F10.05） |
| 事务回滚 | 通知行随整链回滚（无孤儿通知） |

##### 8 权限与数据规则

按患者语言选模板（i18n，tech-design §10 邮件行）；payload JSON 透传。

---

#### F4.04 患者资料与进度派生（/pt/me、/pt/progress）

> 对应 SRS：F-TICK-003（患者进度条） ｜ 实现落点：`internal/domain/patient/service.go:249`（Me）/`:280`（Progress）/`ticket/pure.go`（DerivePatientSteps）/`handler.go:99-102`（路由） ｜ 操作入口：app H5 `#/pages/progress` 进度页；API curl

##### 1 功能定义

患者查本人账号+最新工单概要（/pt/me）与派生八步进度视图（/pt/progress）。进度 = `DerivePatientSteps` 纯函数跨 A+C 两轴投影（口径 B，tech-design §6.3）——前 2 步事实驱动（有会话/已注册），后 6 步投影 status/stage。

##### 2 触发条件与前置状态

pt JWT（iss=pt）；无工单时前 2 步仍可点亮（保持 CREATED/NONE 兜底）。

##### 3 输入与校验

无入参（patient_id = claims.Sub）。

##### 4 处理流程

```mermaid
flowchart LR
    A["GET /pt/progress"] --> B["COUNT chat_session WHERE patient_id（有会话?）"]
    A --> C["SELECT status,care_stage FROM ticket<br/>WHERE patient_id ORDER BY id DESC LIMIT 1"]
    B --> D["DerivePatientSteps(hasSession, true, status, stage) 纯函数"]
    C --> D
    D --> E["{steps:[8 步 key+done]}——M4 恒前 2 步点亮，状态推进自动点亮"]
```

##### 5 输出与结果状态

- /pt/me：`{patient_id, email, name, ticket: {id, ticket_no, status} | null}`。
- /pt/progress：`{steps: [PatientStep...]}`（派生视图，i18n key 按步骤组织——改 UX 词序不动领域模型）。

##### 6 状态流转

只读派生，不反灌状态机（口径 B 合法身份=投影——tech-design §6.1 三套口径裁决）。

##### 7 边界与异常

| 场景 | 行为 |
|---|---|
| 无工单 | ticket=null / steps 前 2 步按事实 |
| 多工单（未来） | ORDER BY id DESC 取最新（一人一单 MVP 语义，⚠ DIF-M6 ⑤ 直查口径） |
| 跨域直查 | 读路径直查 dbmap 申报（写边界仍由 BindPatientInTx/CreateInTx 收口） |

##### 8 权限与数据规则

pt JWT + patient_id 谓词（不走权限码）；M8 起 export 按钮入口（web TicketDetail 亦有）。

---

#### F4.05 GDPR 导出与抹除

> 对应 SRS：§4.2（数据可携/删除请求——落地为 ADMIN 直触发，⚠ DIF-M8 ③） ｜ 实现落点：`internal/domain/patient/service_gdpr.go:39`（EraseByAdmin）/`handler.go:124`（ExportMe）/`:134`（ErasePatient）/`chat/scrub.go:26`（ScrubSessionMessagesInTx） ｜ 操作入口：app/web 「导出我的数据」按钮；web `/admin/users` 或工单详情 GDPR 抹除入口（ADMIN）

##### 1 功能定义

②数据可携：`GET /pt/me/export` 聚合本人 8 表数据为 JSON 下载。③删除请求：ADMIN 执行 `EraseByAdmin` 单一主事务——账号墓碑化（email=`anon+<id>@anonymized.local`、phone 清空、name='已抹除'、DISABLED、totp 清）+ patient_profile 物理删 + chat 脱敏（TEXT 正文置 ''/译文 NULL/结构保留）+ consent WITHDRAWAL 留痕 + op_log(gdpr.erase) 同事务；OSS 对象 Commit 后循环删除（补偿语义）。

##### 2 触发条件与前置状态

- 导出：pt JWT（本人）。
- 抹除：`patient:manage`（仅 ADMIN）；目标须 `role='PATIENT'`（员工账号走离职停用链不走 GDPR，409 ErrNotPatient）；reason 必填。

##### 3 输入与校验

| 端点 | 输入 | 校验 |
|---|---|---|
| GET /pt/me/export | 无 | — |
| POST /api/patients/:id/erase | :id + reason | reason 空 → 400；账号不存在 → 404；非 PATIENT → 409；version CAS 不符 → 409 |

##### 4 处理流程

```mermaid
sequenceDiagram
    participant W as web ADMIN
    participant H as patient.Handler
    participant S as patient.Service
    participant DB as MySQL（主事务）
    participant OS as MinIO
    W->>H: POST /api/patients/:id/erase {reason}
    H->>S: EraseByAdmin
    S->>DB: SELECT 账号（404 / 非 PATIENT 409）
    S->>DB: 事务外收集 OSS 对象清单（medical_document.object_key）
    S->>DB: DELETE patient_profile
    S->>DB: UPDATE user_account 墓碑化（version CAS）
    S->>DB: ScrubSessionMessagesInTx（TEXT 正文=''、译文 NULL、结构保留）
    S->>DB: INSERT consent_record(WITHDRAWAL)
    S->>DB: op_log(gdpr.erase, changes={scrubbed, oss_objects})
    S->>DB: COMMIT
    loop Commit 后逐对象
        S->>OS: Delete(key)（失败仅收集）
    end
    alt 有失败对象
        S->>DB: 独立 op_log(gdpr.oss_cleanup) 记失败清单
    end
    H-->>W: 204
```

##### 5 输出与结果状态

导出：`application/json` Blob（8 表聚合）。抹除：204；库内已净（墓碑可查审计链），桶内对象尽力删。

##### 6 状态流转

`user_account.status: ACTIVE → DISABLED`（墓碑）；chat_message TEXT 正文置 ''（**append-only 纪律的显式合规豁免**——`ScrubSessionMessagesInTx` 头注申报，content 非状态列 AST 射程外）。

##### 7 边界与异常

| 场景 | 行为 |
|---|---|
| OSS 对象删除失败 | 不回滚主事务（库净桶脏）——独立 op_log(gdpr.oss_cleanup) 记失败清单，人工重试口径，不做自动重试 worker（⚠ DIF-M8 ③） |
| 历史日志中的个人信息 | actor 落 actor_id（0=系统）——匿名化自动传播全部历史日志（⚠ DIF-M1 设计红利） |
| IMAGE content（object_key） | 保留（对象本体已删，key 留作结构审计）；CARD 无 PII 保留 |
| 并发变更（version 漂移） | 409 |

##### 8 权限与数据规则

- 导出=本人；抹除=patient:manage（ADMIN）——ADMIN 操作本身即「人工审批」语义（op_log 同事务留痕即审计链，不另建工单流）。
- 抹除范围=医疗数据（profile/文档对象/聊天正文）；账号保留脱敏锚维持审计链（tech-design §7.1 分表理由）。

---

反链：

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