FSD m04 · 患者注册与病历(patient + ticket 直传)
本册覆盖代码域
internal/domain/patient与internal/domain/ticket的直传三步/注册事务链部分。全局规则见 00-总则 §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 单空格规范化) |
| 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 处理流程
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 处理流程
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 处理流程
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 处理流程
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 处理流程
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 分表理由)。