跳转到主要内容

1 - PRD 分区地图 —— MediLink Global 海外来华就医服务平台

本目录是需求的唯一源(md),交互原型是 UX 提案附件。技术设计见 ../docs/tech-design.md。

文档索引

文档状态说明
srs-medilink-v1.mdDRAFT系统需求规格书 v1.0(MVP)。唯一权威源,由同名 docx 转换(2026-10-07),docx 仅存档不再更新

原型索引(prd/workspace/)

纯静态 HTML,每页自包含,演示件不代表真实性能方案:

原型覆盖需求说明
index.htmlF-HOS-001/002产品门户:样例医院列表 + 6 步服务流程,事实上的分区地图页
chat.htmlF-CHAT-001/002/003双语聊天(患者/顾问双视角),原文+译文双行气泡是消息展示的 UX 基准
register.htmlF-REG-001/002/003患者注册 + 病历上传,成功页展示工单号并引导查看进度
progress.htmlF-TICK-003 / F-CARE-003患者端进度跟踪,8 步进度条是「口径 B」的证据(见下)
dashboard.htmlF-ADMIN-001/002/003运营后台工单看板,状态词与 SRS §6.2 一致(「口径 A」)

状态口径裁决记录

SRS 与原型存在三套进度口径,技术设计已裁决(详见 ../docs/tech-design.md §7 双进度模型):

口径内容裁决身份
ASRS §6.2 工单 8 状态:新建→待分配→预诊断中→方案确认中→待支付→待出行→就医中→已完成状态机唯一权威
CSRS F-CARE-001 诊疗 8 阶段:出发准备→到达接待→初诊→检查→确诊→治疗→康复→返程独立字段轴(care_stage),仅就医中推进
Bprogress.html 患者端 8 步:Consultation→Registration→Pre-diagnosis→Plan Confirmed→Paid→In Treatment→Recovery→Completed纯派生展示视图(纯函数映射 A+C+事实),不得反灌状态机

修订约定

  • 需求变更直接改 srs-medilink-v1.md 并递增版本;差异点在技术设计文档中以 ⚠ DIF-<批次>-<序号> 注记并登记其附录差异台账
  • §8.1 五项待确认事项(目标市场/首批医院/定金与退款/翻译供应商/ICP 资质)每确认一项即回写正文并从待确认清单移除

2 - MediLink Global —— 海外来华就医服务平台 系统需求规格书(SRS)

本文件由 海外来华就医平台-系统需求规格书(1).docx 转换而来(2026-10-07,pandoc + 人工整理),内容忠实于原 docx。 本 md 是需求的唯一权威源;docx 仅作存档,后续修订直接改本文件。

元信息内容
版本v1.1(MVP 最轻量商用版;v1.1 2026-10-07 文件存储选型收敛为 MinIO(S3 兼容),见 §5)
编制日期2026-08-27
状态DRAFT(含 5 项待确认事项,见 §8.1)
配套原型workspace/ 下 5 个 HTML 交互原型(索引见 README.md)
技术设计../docs/tech-design.md

1. 引言

1.1 编写目的

本文档为海外来华就医服务平台的最轻量商用版本(MVP)提供完整的系统需求定义,作为开发、测试及验收的基准依据。

1.2 项目背景

本项目面向海外患者提供来华就医的一站式服务,连接国内三甲医院国际部资源,通过数字化平台实现从咨询、预诊断、方案制定到就医安排的全流程管理。

1.3 术语定义

术语定义
MVPMinimum Viable Product,最小可行产品
工单患者注册后生成的服务跟踪单,贯穿整个服务周期
预诊断正式就诊前,由合作医生对病历资料进行初步评估
国际部三甲医院面向外籍/海外患者的特需医疗服务部门

2. 总体描述

2.1 系统愿景

构建一个连接海外患者与国内三甲医院国际部的数字化服务平台,实现双语沟通、在线预诊、行程管理和诊疗跟踪的闭环。

2.2 MVP 范围界定

包含范围:医院国际部信息展示、双语实时聊天咨询、患者注册与病历上传、工单生成与流转、医生预诊断分配、诊疗方案与时间确认、在线定金支付、酒店/交通安排协助、诊疗阶段进度跟踪。

暂不包含:完整的电子病历系统(EMR)、医保结算对接、多医院 API 直连、视频问诊、术后康复长期跟踪。

2.3 用户角色

角色描述核心诉求
海外患者需要来华就医的海外人士了解医院、顺畅沟通、掌握进度
顾问客服平台服务顾问,负责对接客户高效获取信息、跟进工单
预诊断医生合作医院的医生,负责预诊评估查看病历、提供专业意见
运营人员平台后台管理人员管理医院、监控工单

2.4 运行环境

端技术栈建议
患者端响应式 Web(H5),支持微信内打开
顾问/医生端Web 管理后台
运营后台Web 管理后台
移动端适配患者端优先移动端体验

3. 功能需求

3.1 医院国际部展示模块

  • F-HOS-001:医院列表展示(名称、等级、城市、特色科室、筛选)
  • F-HOS-002:医院详情页(国际部介绍、特色服务、科室列表、就诊流程、咨询入口)

3.2 双语咨询模块

  • F-CHAT-001:聊天入口(匿名咨询、顾问在线状态、常见问题快捷入口)
  • F-CHAT-002:实时双语聊天(患者英文/顾问中文、自动翻译、文本+图片、已读状态、本地缓存)
  • F-CHAT-003:注册登记推送(顾问发送注册卡片、患者点击进入、完成后自动通知)

3.3 患者注册与病历管理模块

  • F-REG-001:患者注册(护照姓名、国籍、年龄、性别、联系方式、症状描述、期望时间/城市、保险信息)
  • F-REG-002:病历资料上传(PDF/JPG/PNG,20MB/文件,最多 10 个,标签描述,拖拽上传,进度显示)
  • F-REG-003:注册完成确认(成功页面、工单编号、确认邮件、自动通知顾问)

3.4 工单管理模块

  • F-TICK-001:工单自动生成(8 个状态、关联信息、自动通知)
  • F-TICK-002:工单分配与流转(分配顾问、提交预诊断请求、医生确认/拒绝、操作日志)
  • F-TICK-003:工单状态跟踪(患者进度条、后台详细日志)

3.5 预诊断模块

  • F-DIAG-001:医生工作台(待诊断列表、查看病历、在线预览、填写预诊断意见、推荐科室和专家、预估时间和费用)
  • F-DIAG-002:诊疗方案确认(患者通知、方案内容、在线确认、进入支付)

3.6 支付模块

  • F-PAY-001:定金支付(明细展示、Visa/Mastercard/PayPal/支付宝国际版、支付凭证 PDF、状态更新)

3.7 出行协助模块

  • F-TRIP-001:酒店安排(推荐合作酒店、距离/价格/翻译服务、预订需求、状态记录)
  • F-TRIP-002:交通安排(机场接送、就医期间交通、信息同步)

3.8 诊疗过程管理模块

  • F-CARE-001:8 个标准阶段定义(出发准备→到达接待→初诊→检查→确诊→治疗→康复→返程)
  • F-CARE-002:阶段信息上传(文件上传、文字说明、自动归类、版本管理)
  • F-CARE-003:患者进度同步(可视化进度条、阶段详情、待办事项、推送通知、报告下载)

3.9 后台运营模块

  • F-ADMIN-001:医院信息管理(增删改、图片上传、科室专家管理)
  • F-ADMIN-002:用户管理(账号创建/编辑/禁用、权限设置)
  • F-ADMIN-003:工单监控(总览看板、超时提醒、搜索筛选)

4. 非功能需求

4.1 性能需求

指标要求
页面加载时间首屏 < 2 秒(3G 网络)
聊天消息送达< 500ms
文件上传支持断点续传,显示实时进度
并发用户支持 100 人同时在线(MVP 阶段)

4.2 安全需求

需求说明
数据加密敏感数据传输使用 HTTPS,静态数据加密存储
身份认证所有管理后台需登录,支持双因素认证
访问控制基于角色的权限控制(RBAC)
审计日志关键操作记录日志
隐私合规遵守 GDPR 和数据跨境传输相关法规

4.3 可用性需求

患者端默认英文,可选繁体中文、日文、韩文。响应式设计,患者端完美适配手机浏览器。基础无障碍访问支持。网络异常时友好提示。

4.4 可靠性需求

每日自动备份,保留 7 天。单点故障不影响核心功能。聊天消息持久化,离线消息同步。

5. 接口需求

翻译服务:集成第三方翻译 API(Google Translate / DeepL / Azure Translator),支持中英文实时互译,医学术语优化。

支付接口:Stripe / PayPal(国际卡),支付宝国际版,微信支付。

通知接口:邮件服务(SendGrid / AWS SES),短信服务(Twilio / 国内短信服务商),站内消息推送。

文件存储:对象存储采用 MinIO(S3 兼容协议)——dev/e2e 实例 192.168.50.190:9000;生产可零代码切换任意 S3 兼容云(含 AWS S3),阿里云 OSS 经 storage driver 层一个实现文件切换(2026-10-07 v1.1 裁决,原「AWS S3 / 阿里云 OSS」二选一收敛)。CDN 加速图片和文档预览。

6. 数据需求

6.1 核心实体

患者(Patient):基本信息(ID、姓名、国籍、年龄、性别、邮箱、手机),就诊信息(主诉、期望时间、目标城市、保险信息),账户信息。

工单(Ticket):编号、患者 ID、顾问 ID、医生 ID、当前状态、状态历史、医院/科室/诊断结果/治疗方案、定金金额/支付状态、酒店/交通安排。

病历文件(MedicalDocument):ID、工单 ID、文件名、类型、大小、标签、上传阶段、存储路径。

聊天消息(ChatMessage):ID、会话 ID、发送者、接收者、原文、译文、语言代码、发送时间、读取状态。

用户(User):ID、姓名、角色、邮箱、手机、所属医院/负责区域、账户状态。

6.2 工单状态流转

新建 → 待分配 → 预诊断中 → 方案确认中 → 待支付 → 待出行 → 就医中 → 已完成

7. MVP 裁剪策略

功能策略
翻译服务首期接入标准翻译 API,不做术语库优化
支付方式首期仅支持 PayPal 和信用卡(Stripe)
酒店交通首期人工协助,系统仅做记录,不自动对接 OTA
医生端首期通过 Web 端实现,不做独立 App
报告预览首期提供下载,不做在线 PDF 渲染
通知渠道首期邮件+站内消息,短信可选
语言支持首期仅英文和中文

8. 附录

8.1 待确认事项

  • 目标市场优先级(东南亚?中东?欧美?)影响语言和支付方式
  • 合作医院数量和首批上线医院名单
  • 定金比例和退款政策
  • 翻译服务预算和供应商选择
  • 是否需要 ICP 备案和医疗相关资质

8.2 参考文档

三甲医院国际部服务标准、跨境医疗数据合规要求、各目标国家的医疗旅游政策。

3 - 功能规格说明书(FSD)

按模块拆分的实现规格 as-is 单一事实源——00-总则 + m01~m10 + 99-附录,50 功能节固定 8 小节。

阅读顺序(侧边栏按权重 = 阅读序):

3.1 - MediLink Global 功能规格说明书(FSD)v1.0 · 总则

上游文档:prd/srs-medilink-v1.md(v1.1,需求唯一权威源)。本文档以代码实现为事实基准,还原系统真实运转规则;与 SRS 的差异以 ⚠ DIF 注记标出并登记 99-附录 差异台账。 功能 ID 规则:F<m>.<两位序号>(m = 模块号 1~10,与代码域包对应)。ID 全局唯一、只增不改号。 技术设计(为什么这样实现)见 docs/tech-design.md(15 节);本文只记 as-is 行为。

0. 修订记录

版本日期对应 SRS 版本修订说明作者
v1.02026-10-09srs-medilink-v1.1首版:按代码实现还原全模块功能规格。P0 总则 + 50 功能 ID 分配冻结—

0.1 文档定位

  • SRS 管 to-be 契约(需求意图、验收口径唯一权威);FSD 记 as-is 实现(系统当前真实行为)。
  • 每个 FSD 功能节标注「对应 SRS 需求 ID」;实现与 SRS 不一致处行内 > ⚠ DIF-F<m>-<序号> 注记,并登记 99-附录差异台账(现状 file:line / SRS 期望 / 影响面 / 处置建议)。注:正文另有 DIF-M<n> ① 引用系 tech-design 里程碑批次差异编号(权威源 tech-design §14/开发任务总表),与 DIF-F 两套并存、不换算——见 99-附录 B 头注。
  • SRS 有而未实现的功能照常占 ID,状态列标 未实现;代码有而 SRS 无的功能标 代码先行(此类隐性规则正是本档最值钱部分)。MVP 已收口(M1~M12),当前全集为 实现。
  • 分册结构:功能详述按代码域拆分为 m01~m10 共 10 册 + 99-附录,F<m>.<nn> 位于 m<m>-*.md;跨文件一律以 F-ID 纯文本引用,锚点不受拆分影响。
  • 行内引用的技术背景(「为什么」)指 docs/tech-design.md §n,本文不重复论证。

1. 术语与数据字典

1.1 术语对照(SRS 叫法 / 本文统一叫法 = 代码字段名)

收录判据:SRS、界面文案、代码命名三方叫法不一致的词必收。

SRS/口语本文统一叫法代码字段/表
工单工单ticket(域内模型 ticket.Ticket)
8 个状态 / 流程状态A 轴(办理环节)ticket.status(8 态 + 3 回退边,见 2.2)
诊疗 8 阶段C 轴(医疗旅程)ticket.care_stage(8 阶段,见 2.2)
患者进度条 8 步派生八步DerivePatientSteps 纯函数(跨 A+C 投影,不反灌状态机)
匿名咨询访客会话chat_session + guest JWT claim gsid
注册后转正绑定(guest→patient)chat_session.patient_id CAS 谓词更新(0=未绑定哨兵)
两段式登录 / 2FATOTP 两段式POST /api/auth/login → mfa_token → POST /api/auth/mfa;user_account.totp_status 三态
病历直传 / 三步协议直传三步presign → PUT 直传 → confirm(HeadObject 复核,见 2.5)
支付残留单残留单payment_order.status='PENDING' 且未回调的孤儿行(人工关单)
注册卡片注册卡片chat_message.msg_type='CARD',content={"type":"register"} 服务端构造
站内通知 / 待办INBOXnotification_outbox.channel='INBOX'(read_at 列标记已读,status 恒 PENDING)
邮件队列outbox(EMAIL 通道)notification_outbox.channel='EMAIL'(指数退避 3 败 FAILED)
重新翻译重译POST .../messages/:id/retranslate
译文补推译文回填SSE 事件 msgTranslated(worker 异步回填 chat_msg_ext)
推荐专家专家引用ticket.expert_id(选人)+ ticket.expert 文本快照(不可变)
科室锚定dept 锚医生选人按 hospital_dept.id(dept 全局唯一隐含院区)
阶段资料版本版本组medical_document.doc_group / version_no / is_current
权限码权限码permission.code(14 个,<domain>:<action>)
生命周期视图双日志轨entity_lifecycle_event(状态/阶段时间线)+ operation_log(操作审计)

1.2 数据字典(按功能模块归组的表清单)

DDL 权威:medilink/migrations/full/medilink_full.sql(19 表,本文不复制字段,只给「表 → 功能节」挂点;字段级规则在各功能节 3 记录;全表逐表展开见 docs/数据库总览.md)。

模块核心表
m01 认证与权限user_account permission role_permission
m02 医院主数据hospital hospital_dept hospital_expert
m03 双语咨询chat_session chat_message(日志)chat_msg_ext
m04 注册与病历user_account patient_profile consent_record(日志)medical_document
m05 工单与看板ticket
m06 预诊断(无新表——写 ticket 列)
m07 支付payment_order payment_webhook_event(技术)
m08 诊疗过程medical_document(版本组复用)
m09 出行协助trip_record(detail JSON)
m10 系统与审计entity_lifecycle_event(日志)operation_log(日志)notification_outbox(技术)

tech-design §5.1 记「21 张」为设计期口径,实施收敛为 19 张(translation_cache 改 Redis 实装不建表、care_stage_record/trip_arrangement 合并为 trip_record + lifecycle 行集——DIF-M3 ⑥/DIF-M7 ①),以 full.sql 实测为准,差异台账见 99-附录 B。

2. 全局规则

每批发现新全局规则必须回填本章,正文只引用不重复。

2.1 权限模型

  • 三 issuer JWT 互不认(自实现 HMAC-SHA256,gateway.Signer):/api 面 iss=bo(claims uid/role/pv,exp 12h);/pt 面 iss=pt(exp 7d);/pub 面 iss=guest(claim gsid,exp 7d);另有第 4 issuer mfa(5min、仅 uid,无业务权限——结构性过不了 bo 面必填 claim 校验)。
  • 4 角色:user_account.role = ADMIN / CONSULTANT / DOCTOR / PATIENT。PATIENT 不走权限码表——靠 /pt 路由面物理隔离 + service 层「只准读写自己数据」强制谓词;权限码只管 B 端三角色。
  • 14 个权限码(permission 字典表 seed 幂等,gateway.NewDBPermissionSource 消费):chat:read(1) chat:send(2) ticket:read(3) ticket:assign(4) ticket:transition(5) diag:write(6) hospital:manage(7) patient:manage(8) user:manage(9) payment:read(10) care:write(11) trip:write(12) ops:read(13) ops:manage(14)。
  • 守卫方式:路由级 gateway.RequirePermission(code, permSrc, rdb, dbname, log) 中间件(main.go 逐条挂载,模式 C);redis 权限集缓存 60s TTL(key rbac:<库名>:perm:<role>:pv<pv>,JWT claim pv 隔离版本);DB 错误 fail-closed 返回空集;未挂守卫的端点 = 登录即可(如 /api/inbox——通知消费按角色谓词隔离,无权限码)。
  • 「角色 × 权限码」矩阵(权威源 scripts/dev/seed.sql:127-141;语义:chat:send 属顾问操盘职责、diag:write 属医生临床行为,ADMIN 不代写不代发):
角色权限码
CONSULTANTchat:read chat:send ticket:read ticket:assign ticket:transition payment:read care:write trip:write ops:read(9 码——全程操盘)
DOCTORchat:read ticket:read diag:write(3 码——只读 + 写预诊)
ADMIN全 14 码 − {chat:send, diag:write}(12 码)
PATIENT无权限码(/pt 面 + 谓词隔离)
  • 登录快照(⚠ DIF-M8 ⑤):LoginResult.permissions[] 登录时直查字典表一次(不走 redis 缓存);web 前端 src/authz.ts 纯函数 has(code) 做菜单/按钮显隐——权限漂移靠重新登录刷新,PV 不变。

2.2 状态机总览

各业务对象状态机的权威枚举汇总于此(共 8 组:ticket/payment/care_stage/trip + hospital·dept·expert 三同构计 7 组为 transition 白名单机,经 pure.go 转移表代码化+守卫测试锁定;user_account.status/TOTP 三态/translation_status 为 CAS 直写非白名单机,在 transition() 射程外);每个功能节 6 只画本功能触及的迁移,引用本章。

工单 ticket.status(A 轴)(ticket/pure.go:92,8 态 + 3 回退边):

状态含义
CREATED已创建(注册事务链开单)
PENDING_ASSIGN待分配(CREATED 自动流转至此)
PREDIAGNOSING预诊断中(已分配医生)
PLAN_CONFIRMING方案确认中(医生已提交预诊)
PENDING_PAYMENT待支付(患者进入支付)
PENDING_DEPARTURE待出行(支付成功回调)
IN_TREATMENT就医中(C 轴自此推进)
COMPLETED已完成(终态)
stateDiagram-v2
    CREATED --> PENDING_ASSIGN: 自动开单流转
    PENDING_ASSIGN --> PREDIAGNOSING: 分配医生(ticket:assign)
    PREDIAGNOSING --> PLAN_CONFIRMING: 医生提交预诊(diag:write)
    PREDIAGNOSING --> PENDING_ASSIGN: 回退边3——医生拒绝重派(diag:write)
    PLAN_CONFIRMING --> PENDING_PAYMENT: 患者创建支付单
    PENDING_PAYMENT --> PENDING_DEPARTURE: 支付成功回调
    PENDING_PAYMENT --> PLAN_CONFIRMING: 回退边1——支付失败重谈
    PENDING_DEPARTURE --> IN_TREATMENT: 顾问流转
    PENDING_DEPARTURE --> PLAN_CONFIRMING: 回退边2——改方案
    IN_TREATMENT --> COMPLETED: 完成
  • COMPLETED 终态不可迁;三条回退边是设计补充(边 1 由支付失败回调驱动、边 3 由医生拒绝驱动,边 2 手动触发权限待产品确认——tech-design §15 风险 3);迁移只准走 TransitionInTx(status 谓词 CAS + lifecycle + op_log 同事务)。

支付单 payment_order.status(payment/pure.go:15):

stateDiagram-v2
    PENDING --> SUCCEEDED: webhook 支付成功(CAS)
    PENDING --> FAILED: 失败/人工关单
  • SUCCEEDED→REFUNDED 不入白名单(MVP 无自动退款,人工线下改库必走 operation_log 带 reason);FAILED 不复活——重谈后新建支付单(PENDING 复用谓词:同工单已有 PENDING 单则复用重新 CreateCheckout,防残留单堆积,⚠ DIF-M8 ⑦)。

诊疗阶段 ticket.care_stage(C 轴)(care 域 stageTransitions,8 阶段单向链,无回退边;推进前置 status=IN_TREATMENT 由 service 层校验):

stateDiagram-v2
    NONE --> PREP_DEPARTURE
    PREP_DEPARTURE --> ARRIVAL
    ARRIVAL --> FIRST_VISIT
    FIRST_VISIT --> EXAM
    EXAM --> DIAGNOSIS
    DIAGNOSIS --> TREATMENT
    TREATMENT --> RECOVERY
    RECOVERY --> DEPARTURE

出行记录 trip_record.status(trip 域 tripTransitions):PLANNED → {BOOKED, CANCELLED};BOOKED → {COMPLETED, CANCELLED}。CANCELLED/COMPLETED 终态不可迁。

账号 user_account.status(SetUserStatusInTx):ACTIVE ⇄ DISABLED 双向(禁用/启用)。

医院/科室/专家 status(三处同构,hospital/pure.go:99/116/133):DRAFT → PUBLISHED → OFFLINE → PUBLISHED 单向环(无回 DRAFT——已发布语义不可撤销为草稿)。

TOTP user_account.totp_status 三态(user 域,⚠ DIF-M8 ①):NONE → PENDING(setup 生成密钥)→ ENABLED(验首码转正);ENABLED → NONE(disable 验现码解绑 / ADMIN 重置清密钥);PENDING 态不触发两段式登录(否则 setup 与登录互相锁死)。

chat_msg_ext.translation_status(非 transition 封装、worker CAS 回写):NONE(不投翻译:CARD/IMAGE)→ PENDING → DONE / FAILED(FAILED 可 retranslate 重投)。

2.3 通用校验与一致性(全系统不变量)

  1. 单库 DB 事务 + version CAS(tech-design §1 降级裁决,比模板三层补偿更强更简):写方法 service 层 WithTx 包事务;状态表必带 version 列,状态变更走状态谓词 CAS(WHERE id=? AND status=?,SET version=version+1——from 漂移即 ErrVersionConflict 409)。
  2. 禁裸 UPDATE 状态列(AST 守卫强制):状态列 UPDATE 的字符串字面量只准出现在 transition 封装内;白名单函数全集 = TransitionInTx(ticket)/ TransitionOrderInTx(payment)/ TransitionStageInTx(care_stage)/ TransitionTripInTx(trip)/ SetUserStatusInTx(user)/ TransitionHospitalInTx / TransitionDeptStatusInTx / TransitionExpertStatusInTx(hospital 三同构);行内注释 ast-status-guard:exempt 申报豁免(非状态列回填如 ticket_no/receipt_object_key)。
  3. 迁移-日志同路径:每次状态变更 = transition 封装内同事务写 entity_lifecycle_event(from/to)+ operation_log(actor/reason)——无独立日志通道。
  4. 读路径绝不加锁:全部业务读直查 dbmap,无任何串行化设施。
  5. 幂等:payment_webhook_event(provider+event_id UNIQUE,INSERT 冲突=重复投递直接 200);注册绑定 CAS 谓词(WHERE patient_id=0)天然幂等;seed 幂等 INSERT IGNORE。
  6. SSE 推送在事务 Commit 后(⚠ DIF-M5 ③):事务内推则回滚放假事件;失败仅 warn 不反压业务。
  7. NOT NULL + 哨兵值(原则六):全表基础类型列 NOT NULL + 哨兵(''/0/'NONE',ENUM 必须显式 DEFAULT);NULL 豁免仅限「尚未发生」语义列(paid_at/read_at/prediagnosis_summary/plan/translated_text 等)+ 守卫白名单两条(user_account.totp_secret、payment_order.refund_amount);not_null_guard / pointer_field_guard 双守卫防 stale。
  8. 守卫总表:9 类机器守卫全表见 tech-design §13.2(check_consistency / not_null / pointer_field / lifecycle 入口覆盖 / 迁移-日志 AST / 状态机白名单 / 翻译覆盖锁 / SSE 事件注册表 / e2e 十段断言)。

2.4 错误响应约定(纯 HTTP 状态码 + 哨兵错误映射)

  • 机制:与模板的字符串错误码不同,本项目 handler 统一返回 echo.NewHTTPError(status) 裸 HTTP 状态码(全仓零 ErrResp 调用——grep 盘点实证);业务语义由 service 层哨兵错误(errors.New 包级单例)+ handler mapErr 的 errors.Is 映射承载。响应体不携带业务码字符串,前端按状态码分流。
  • 状态码分布(grep 实测全仓 NewHTTPError 计数,domain+gateway):400×87 / 500×17 / 409×13 / 404×12 / 401×7 / 429×1 / 503×2 / 403×2。
  • 哨兵错误 → 状态码映射惯例(权威映射在各域 handler mapErr;正面样例):
    • 400:bind/校验失败、ErrWeakPassword 族、ErrBadMIME/ErrTooLarge、ErrInvalidStageTransition 例外见下、ErrNotRetranslatab、ErrReasonRequired;
    • 401:auth.ErrInvalidCredentials(三端登录统一——防枚举);403:ticket.ErrForbidden(归属谓词);
    • 404:ErrNotFound 族(ticket/patient/chat.ErrNotFound、ErrOrderNotFound、ErrExpertNotFound、ErrTicketNotFound——归属不符与不存在同语义不泄露存在性);
    • 409:ErrVersionConflict(CAS)、ErrInvalidTransition(白名单)、ErrSessionBound、ErrEmailTaken、ErrDocQuota、ErrDocOwnership、ErrNotPayable、ErrReceiptNotReady、ErrNotInTreatment、ErrTOTPAlreadyEnabled、ErrDocGroupConflict、ErrInvalidHospitalTransition、ErrNotPatient;
    • 503:可选依赖未配置(jwt.secret 空→受保护面 401 例外、webhook_secret 空、master_key 空的 2FA 路径)。
  • 收敛方向(tech-design §2.4 同族):资源不存在=404、CAS 冲突/业务拒绝=409、可判定校验=400——现状 gorp 层错误多归 500 族(127 处中 15 处),是已知待收敛项不扩面。
  • 哨兵错误清单全表:见 99-附录 C。

2.5 直传三步协议通用规则(病历 / 聊天图 / 医院图 / 阶段资料四处同构)

  1. 三步:①POST .../presign(校验登录态+配额+size/mime 申报)→ 签发带 policy 的预签名 PUT URL(content-length-range 0..N MB + mime 白名单 + 15min)——policy 签名在存储侧硬性拦截超限;②浏览器直传(不占后端带宽);③POST .../confirm:服务端 HeadObject 复核实际 size/mime(不信任客户端申报,第二道拦截)→ 配额复核 → 落库。
  2. 限制的执行点在服务端两道,存储只是执行器不是规则源;配额=第一道 presign 前 COUNT + confirm 二次 COUNT(⚠ DIF-M4 ⑭①:confirm 复核带 ticket_id=0 谓词,已绑定历史文档不占新配额)。
  3. mime 白名单:病历 pdf/jpg/png 20MB;聊天图 jpg/png 10MB(mini 两步);医院图/阶段资料同病历口径。
  4. 对象存储:storage.Store 接口隔离——dev/e2e=minio S3 兼容(LAN 192.168.50.190:9000),单测=localfs 不连外部服务;S3 的「对象 mime」=上传方 Content-Type 元数据(presign 已锁死申报),真内容校验随生产 OSS driver 加服务端嗅探(⚠ DIF-M4 ① 已知局限)。

2.6 双日志轨与可选依赖降级

  1. 双日志轨分工:entity_lifecycle_event(对象全时间线,A/C 两轴事件同表靠值区分)服务「对象经历了什么」;operation_log(标准列 actor_id/reason/trace_id/changes JSON)服务「谁在什么时候改了什么」——/api 全部写方法经中间件统一记录 + 豁免注册表,关键操作 service 内显式补记带 reason。actor 落 actor_id BIGINT(0=系统/匿名)——GDPR 匿名化自动传播到全部历史日志(⚠ DIF-M1)。
  2. 可选依赖降级矩阵(缺省=功能降级可见,不阻塞起服):jwt.secret 空→受保护面一律 401;master_key 空→TOTP 相关返 ErrKeyNotConfigured;webhook_secret 空→/webhooks/stripe 一律 503;redis 缺→限流回落内存内核、RBAC 直查 DB、翻译缓存直查(不降级放行也不误杀);receiptIO 未注入→凭证生成跳过、下载 409;mailer=log 档(日志即送达)。
  3. 通知双通道:EMAIL(dispatcher 30s 扫描指数退避、3 败 FAILED 可人工重发)/ INBOX(常驻 PENDING + read_at 列,dispatcher 不消费——已读语义独立于投递语义)。
  4. SSE hub 单实例(进程内 map[sessionKey]map[connID]conn,buffer 8 慢消费者丢弃+失步标记,25s 心跳);多实例扩容换 Redis pub/sub,接口不变(tech-design §15 风险 7)。

3. 功能模块清单(ID 冻结 v1.0)

状态列:实现 / 实现(代码先行)(代码有而 SRS 无)。 代码域 ↔ 功能模块映射:auth+gateway→m01;hospital→m02;chat→m03;patient→m04;ticket→m04(直传三步)+m05;diagnosis→m06;payment→m07;care→m08;trip→m09;user+audit+infra/notify→m10。

m01 账号认证与权限

功能 ID名称对应 SRS状态
F1.01B 端两段式登录(密码+TOTP)SRS §4.2(机制细化)实现(代码先行)
F1.02患者登录(email+密码)—(DIF-001 待产品确认)实现(代码先行)
F1.03访客会话签发 guest-session—(获客漏斗前置件)实现(代码先行)
F1.04JWT 五面鉴权与路由矩阵SRS §4.2实现(代码先行)
F1.05RBAC 权限码字典与路由守卫SRS §4.2实现(代码先行)
F1.06/pub IP 限流(内存/Redis 双内核)SRS §4.1(防滥用)实现(代码先行)

m02 医院主数据与展示

功能 ID名称对应 SRS状态
F2.01公开医院列表F-HOS-001实现
F2.02公开医院详情F-HOS-002实现
F2.03B 端医院维护与状态流转F-ADMIN-001实现
F2.04医院封面图直传F-ADMIN-001实现
F2.05科室管理F-ADMIN-001实现
F2.06专家库管理—(M10 扩展批次)实现(代码先行)

m03 双语咨询

功能 ID名称对应 SRS状态
F3.01创建匿名咨询会话F-CHAT-001实现
F3.02SSE 流与心跳重连F-CHAT-002实现
F3.03发消息与异步翻译回填F-CHAT-002实现
F3.04图片消息F-CHAT-002实现
F3.05已读回执F-CHAT-002实现
F3.06B 端会话管理与回复F-CHAT-002实现
F3.07注册卡片推送F-CHAT-003实现
F3.08患者端聊天(/pt 转正+重译)F-CHAT-002实现

m04 患者注册与病历

功能 ID名称对应 SRS状态
F4.01注册事务链(建账号+绑会话+开单+通知)F-REG-001 / F-TICK-001实现
F4.02病历直传三步协议F-REG-002实现
F4.03注册完成通知(确认邮件+顾问 INBOX)F-REG-003实现
F4.04患者资料与进度派生(/pt/me、/pt/progress)F-TICK-003(患者侧)实现
F4.05GDPR 导出与抹除SRS §4.2(落地为 ADMIN 直触发)实现(细节代码先行)

m05 工单与看板

功能 ID名称对应 SRS状态
F5.01工单自动生成F-TICK-001实现
F5.02工单分配与流转F-TICK-002实现
F5.03状态机 transition() 与三条回退边F-TICK-002(状态机)实现(机制代码先行)
F5.04病历下载与版本组读F-CARE-002(就医前部分)实现(部分代码先行)
F5.05运营看板(聚合+三超时+对账 tab)F-ADMIN-003实现

m06 预诊断与方案

功能 ID名称对应 SRS状态
F6.01医生工作台(预诊+专家选人 dept 锚)F-DIAG-001实现(专家选人代码先行)
F6.02拒绝与重派F-TICK-002(医生确认/拒绝)实现(代码先行)
F6.03患者方案确认F-DIAG-002实现

m07 支付与对账

功能 ID名称对应 SRS状态
F7.01定金支付(Stripe Checkout)F-PAY-001实现
F7.02webhook 验签与幂等F-PAY-001实现
F7.03支付凭证 PDFF-PAY-001实现(代码先行)
F7.04后台支付管理与对账F-PAY-001(状态更新)实现(对账部分代码先行)
F7.05残留单人工关单—(对账兜底)实现(代码先行)

m08 诊疗过程

功能 ID名称对应 SRS状态
F8.01C 轴 8 阶段推进F-CARE-001实现
F8.02阶段资料版本组F-CARE-002实现
F8.03患者进度同步F-CARE-003实现

m09 出行协助

功能 ID名称对应 SRS状态
F9.01酒店安排F-TRIP-001实现
F9.02交通安排F-TRIP-002实现
F9.03患者只读拉取(/pt/trips)F-TRIP(患者可见)实现(代码先行)

m10 系统管理与审计

功能 ID名称对应 SRS状态
F10.01用户管理(CRUD/禁用/改角色/重密)F-ADMIN-002实现
F10.02TOTP 2FA 管理(三态机+重置)SRS §4.2(细化)实现(代码先行)
F10.03生命周期时间线查询F-TICK-003(后台日志)实现(代码先行)
F10.04操作日志审计页SRS §4.2实现(代码先行)
F10.05INBOX 待办与邮件对账重发F-REG-003(扩展)实现(代码先行)
F10.06outbox 投递与重试—(基础设施)实现(代码先行)

4. 功能详述

一功能一节(#### F<n>.<nn> 名称),固定 8 小节:1 功能定义 / 2 触发条件与前置状态 / 3 输入与校验 / 4 处理流程 / 5 输出与结果状态 / 6 状态流转 / 7 边界与异常 / 8 权限与数据规则。 节头元数据行:> 对应 SRS:… | 实现落点:file:line | 操作入口:…(操作入口 = 客户端词表 + 页面路由 + 关键操作;无 UI 节标注 API/定时任务/SSE)。 各节按模块分册:m01 · m02 · m03 · m04 · m05 · m06 · m07 · m08 · m09 · m10 · 99-附录

3.2 - FSD m01 · 账号认证与权限(auth + gateway)

本册覆盖代码域 internal/domain/auth 与横切包 internal/gateway。全局规则见 00-总则 §2(权限矩阵 §2.1、状态机 §2.2、一致性 §2.3)。

m01 功能节目录

ID名称路由/入口
F1.01B 端两段式登录(密码+TOTP)POST /api/auth/login + POST /api/auth/mfa
F1.02患者登录POST /pub/v1/login
F1.03访客会话签发 guest-sessionPOST /pub/v1/guest-session
F1.04JWT 五面鉴权与路由矩阵全部路由面(中间件)
F1.05RBAC 权限码字典与路由守卫/api 面业务路由(中间件)
F1.06/pub IP 限流/pub 面组级中间件

F1.01 B 端两段式登录(密码+TOTP)

对应 SRS:§4.2 后台账号安全(机制细化) | 实现落点:internal/domain/auth/service.go:70(Login)/:114(MFA)/handler.go:22-27(路由) | 操作入口:web /admin/login 登录页两段表单;API curl

1 功能定义

B 端员工(ADMIN/CONSULTANT/DOCTOR)以 email+密码完成第一段认证;启用了 TOTP 2FA 的账号(totp_status='ENABLED')追加第二段:mfa_token + 6 位动态码换正式 bo JWT。登录成功响应携带 RBAC 字典快照 permissions[](前端菜单/按钮显隐唯一事实源,⚠ DIF-M8 ⑤)。

2 触发条件与前置状态
  • 第一段:任意时刻可调(登录即取 token 入口,路由裸挂 /api 组无 JWT 中间件)。
  • 第二段:仅当第一段返回 mfa_required=true 且持有未过期的 mfa_token(iss=mfa,TTL 5min,仅含 uid 无业务权限)。
  • 账号前置:user_account.status='ACTIVE'(DISABLED 一律 401);totp_status='ENABLED' 才触发两段(PENDING/NONE 直发 token——PENDING 态触发两段会 setup 与登录互相锁死,⚠ DIF-M8 ①)。
3 输入与校验
字段端点类型校验
emailloginstring非空;NormalizeEmail 归一化(trim+lower)后查询
passwordloginstring非空;bcrypt 比对
mfa_tokenmfastring非空;Verify(token, IssuerMFA) 验签+issuer+exp
codemfastring非空;TOTP ±1 时间窗校验(cryptoutil.TOTPValidate)

bind 失败或缺字段 → 400。

4 处理流程
sequenceDiagram
    participant C as 客户端
    participant H as auth.Handler
    participant S as auth.Service
    participant DB as MySQL
    participant R as Redis
    participant SG as gateway.Signer
    C->>H: POST /api/auth/login (email, password)
    H->>S: Login(ctx, email, password)
    S->>DB: SELECT user_account WHERE email=?
    alt 账号不存在 / 密码错 / 非 ACTIVE
        S-->>H: ErrInvalidCredentials → 401(统一,防枚举)
    end
    S->>DB: UPDATE last_login_at(失败仅 warn)
    alt totp_status = ENABLED
        S->>SG: IssueMFA(uid)(iss=mfa, 5min, 仅 uid)
        H-->>C: 200 {mfa_required:true, mfa_token}
    else
        S->>SG: IssueBO(uid, role, pv=1) + permissionsOf 直查字典
        H-->>C: 200 {token, expires_in:43200, role, pv, permissions[]}
    end
    C->>H: POST /api/auth/mfa (mfa_token, code)
    H->>S: MFA(ctx, mfaToken, code)
    S->>SG: Verify(mfa_token, iss=mfa)
    S->>DB: SELECT 账号(须 ACTIVE)
    S->>S: AES-GCM 解密 totp_secret → base32 解码
    S->>S: TOTPValidate(secret, code, now)(±1 窗)
    S->>R: SETNX totp:<db>:<uid>:<code> EX 90(防重放)
    alt redis 错误
        S->>S: 放行 + Warn(第二因子可用性优先)
    else 键已存在(90s 内重放)
        S-->>H: ErrInvalidCredentials → 401
    end
    S->>SG: IssueBO + permissionsOf
    H-->>C: 200 {token, role, pv, permissions[]}
5 输出与结果状态
  • 第一段(需 2FA):{mfa_required: true, mfa_token}。
  • 直发 / 第二段成功:{token, expires_in: 43200, role, pv: 1, permissions: [...]}——permissions 为字典表直查快照(零授予归一空数组 [] 非 null)。
  • last_login_at 已更新。
6 状态流转

登录本身不改状态;totp_status 三态机(NONE→PENDING→ENABLED,见总则 §2.2)由 F10.02 自助 2FA 端点驱动,NeedsMFA 唯一判据 totp_status=='ENABLED'。

7 边界与异常
场景行为
邮箱不存在/密码错/账号 DISABLED一律 401(不区分——防账号枚举),细节仅进日志
mfa_token 过期(>5min)/伪造/issuer 不符401
TOTP 码错误(含 ±1 窗外)401
同一码 90s 内重复使用401(redis SETNX 防重放;e2e 重跑睡到下一 30s 边界重算)
redis 不可用防重放跳过 + Warn(放行,可用性优先)
master_key 未配置 / 密文解不开503 语义错误(2FA 不可用,功能降级可见)
e2e 注意dev seed TOTP 密文预生成写死(dev-only);base32 必须无 padding 且长度 %8==0(双端约束,⚠ DIF-M5 ⑦)
8 权限与数据规则
  • 路由裸挂 /api(登录即取 token 入口);TOTP 自助三端点(setup/enable/disable)挂 bo JWT 子组,登录即可、无权限码。
  • 凭据存储:bcrypt(cost 10) 哈希;totp_secret 落库 = AES-256-GCM(base32(secret)),master_key 加密(master_key 生产必换)。
  • 审计:登录失败仅日志(zap Warn),不进 operation_log(无业务状态变更)。

F1.02 患者登录

对应 SRS:—(DIF-001 患者登录机制 SRS 未定义,已裁决 email+密码,tech-design §15 风险 1) | 实现落点:internal/domain/patient/service.go:180(Login)/handler.go:29(路由) | 操作入口:app H5 登录页;API curl

1 功能定义

患者以 email+密码登录换取 pt JWT(iss=pt,exp 7d)。service 层强制 role='PATIENT'——B 端账号用同凭据登录返回 401,防串面。

2 触发条件与前置状态
  • POST /pub/v1/login 挂 /pub 限流组、不挂 JWT(同 guest-session 先例:登录即取 token 入口)。
  • 账号前置:role='PATIENT' 且 status='ACTIVE'(注册事务链 F4.01 创建)。
3 输入与校验
字段类型校验
emailstringValidateLogin 纯函数(非空+格式);NormalizeEmail 归一化
passwordstring非空;bcrypt 比对
4 处理流程
sequenceDiagram
    participant C as 患者 H5
    participant H as patient.Handler
    participant S as patient.Service
    participant DB as MySQL
    C->>H: POST /pub/v1/login (email, password)
    H->>S: Login(ctx, email, password)
    S->>S: ValidateLogin(纯函数校验)
    S->>DB: SELECT * FROM user_account WHERE email=?
    alt 不存在 / bcrypt 失败 / role≠PATIENT / 非 ACTIVE
        S-->>H: auth.ErrInvalidCredentials → 401
    end
    S->>DB: UPDATE last_login_at(失败仅 warn)
    S->>S: IssuePT(uid)(iss=pt, 7d)
    H-->>C: 200 {token, expires_in:604800, patient_id}
5 输出与结果状态

{token, expires_in: 604800, patient_id};last_login_at 更新。前端双存 medilink-pt-token + cookie 下划线变体(⚠ DIF-M4 ④),旧 guest token 语义作废(app 按 hasPtToken 切 /pt 面 + 服务端绑定守卫双闸)。

6 状态流转

无状态变更(user_account.status 不动;禁用走 F10.01)。

7 边界与异常
场景行为
B 端账号凭据正确但 role≠PATIENT401(三条件与 bcrypt 同一表达式,日志不区分)
账号被禁用401
email 格式非法400(ValidateLogin)
/pub 限流窗口内超 60 次429(F1.06,登录入口共享限流预算)
8 权限与数据规则
  • 无 JWT 即可调(挂 /pub 限流组);密码 bcrypt;统一 401 防枚举。
  • pt token 无 role/pv claim(issuerProfiles 仅要求 Sub>0)——患者权限由 /pt 面物理隔离 + service 谓词保证。

F1.03 访客会话签发 guest-session

对应 SRS:—(获客漏斗前置件,⚠ DIF-M2 ① 提前至 M2) | 实现落点:internal/gateway/guest.go:16、cmd/server/main.go:106 | 操作入口:app H5 自动调用(打开即签发);API curl

1 功能定义

匿名访客自助签发 guest JWT(claim gsid = 16 字节随机数 hex 32 位,exp 7d,无 DB 写入)。/pub 面的 bootstrap 端点:先有 token 才能调任何受保护 /pub 路由(医院列表/聊天/注册)。

2 触发条件与前置状态

任意时刻可调;POST /pub/v1/guest-session 挂限流组、不挂 JWT。无前置状态。

3 输入与校验

无请求体。

4 处理流程
sequenceDiagram
    participant C as 访客浏览器
    participant G as gateway.GuestSession
    participant SG as gateway.Signer
    C->>G: POST /pub/v1/guest-session
    G->>G: crypto/rand 16B → hex 32 位 gsid
    G->>SG: IssueGuest(gsid)(iss=guest, 7d)
    alt jwt.secret 未配置
        SG-->>G: ErrNotConfigured → 503
    end
    G-->>C: 200 {token, expires_in:604800}
5 输出与结果状态

{token, expires_in: 604800}。前端 localStorage + cookie 双存(抗 webview 清理);gsid 后续作为 chat_session 绑定键(F3.01)与病历文档所有权锚(F4.02)。

6 状态流转

无状态变更(无 DB 写入;gsid 与 chat_session 的关联在建会话时落库)。

7 边界与异常
场景行为
jwt.secret 未配置503(功能降级可见;401 语义保留给验签失败不混用)
随机数源失败500
重复调用签发全新 gsid/token(旧 token 仍有效至过期——访客多标签页各持各的会话)
8 权限与数据规则
  • 匿名可调(/pub bootstrap);不绑 IP/UA(移动网络切换是常态,绑了是假安全——安全边界在 service 层会话隔离 + 限流)。
  • gsid 全局唯一由 16B 随机保证(碰撞概率可忽略)。

F1.04 JWT 五面鉴权与路由矩阵

对应 SRS:§4.2 | 实现落点:internal/gateway/jwt.go:69(issuerProfiles)、:133(Verify);中间件挂载 cmd/server/main.go:93-324 | 操作入口:—(基础设施,无 UI)

1 功能定义

一服务五面路由(/api /pt /pub /webhooks SSE),四种 issuer JWT(bo/pt/guest/mfa)互不认;组级 RequireJWT(iss) 中间件把 issuer 绑定到整面,SSE 端点用 RequireJWTQuery(query access_token 兜底、header 优先——EventSource 无法带 Authorization 头)。

2 触发条件与前置状态

受保护面每个请求。/webhooks 面不挂 JWT(供应商验签即鉴权,F7.02)。

3 输入与校验
面issuerTTL必填 claim(结构性校验)
/apibo12hSub>0 ∧ Role≠"" ∧ PV>0
/ptpt7dSub>0
/pubguest7dGSID≠""
mfa_tokenmfa5minSub>0(无 role/pv——结构性过不了 bo 面必填校验)

签名 HMAC-SHA256 自实现(~60 行,无算法降级风险);issuer 不匹配一律拒(ErrIssuerMismatch)。

4 处理流程
flowchart LR
    REQ[请求] --> MW{组级 RequireJWT}
    MW -->|缺头/坏 token/issuer 不符/claim 缺失| R401[401]
    MW -->|通过| CLAIMS[Claims 注入 context]
    CLAIMS --> NEXT[业务 handler]
    SSE[SSE 请求] --> Q{RequireJWTQuery<br/>header 优先 query 兜底}
    Q -->|通过| CLAIMS
5 输出与结果状态

通过后 claims 经 gateway.ClaimsFrom(c) 注入 handler context;失败统一 401 → 前端登出跳登录。

6 状态流转

无状态变更。

7 边界与异常
场景行为
jwt.secret 未配置NewSigner(nil) 起服成功,受保护面 Verify 全部 ErrNotConfigured→401(可选缺省模式)
mfa_token 打 bo 面401(无 Role/PV claim 过不了必填校验——构造保证非约定)
过期 token401
SSE 重连带 Last-Event-ID鉴权同普通请求(query token 每次重连重新携带)
8 权限与数据规则
  • 鉴权(Authentication)与本册 F1.05 授权(Authorization)分离:本面只验「你是谁」,权限码验「你能做什么」。
  • 患者 JWT 7d vs B 端 12h:患者低频回访长时效换体验;B 端内部权限面短时效控风险。

F1.05 RBAC 权限码字典与路由守卫

对应 SRS:§4.2 | 实现落点:internal/gateway/rbac.go:82(RequirePermission)、rbac_db.go(NewDBPermissionSource)、scripts/dev/seed.sql:103-141(字典与授予) | 操作入口:—(基础设施);web 菜单显隐消费登录快照

1 功能定义

路由级权限码守卫:RequirePermission(code, ...) 中间件按角色从字典表(permission + role_permission)查权限集,角色不含该码即 403。redis 60s 缓存加速;DB 错误 fail-closed 返回空集(宁可误杀不可错放)。

2 触发条件与前置状态

/api 面业务路由逐条挂载(模式 C,main.go 全景);未挂守卫的端点 = 登录即可(如 /api/inbox 通知消费——按角色谓词隔离,无权限码)。

3 输入与校验

权限码全集 14 个(语义与角色矩阵见总则 §2.1);JWT claims 携带 pv(权限版本)参与缓存键隔离。

4 处理流程
flowchart LR
    REQ[请求] --> JWT[RequireJWT bo 已通过]
    JWT --> CACHE{redis rbac:db:perm:role:pv}
    CACHE -->|命中| CHECK
    CACHE -->|未命中| DB[(字典表直查)] --> SET[回写缓存 TTL 60s] --> CHECK
    DB -->|DB 错误| FAILCLOSED[返回空集 fail-closed] --> R403[403]
    CHECK{role 含 code?} -->|否| R403
    CHECK -->|是| NEXT[handler]
5 输出与结果状态

放行或 403;权限快照在登录响应透出(permissions[]),web 端 authz.ts 纯函数 has(code) 做菜单/按钮显隐——权限漂移靠重新登录刷新(PV 不变,⚠ DIF-M8 ⑤)。

6 状态流转

无业务状态变更;字典表变更仅经 seed(幂等 INSERT IGNORE)或人工 SQL,重启/缓存过期后生效。

7 边界与异常
场景行为
redis 不可用直查 DB(不降级放行也不误杀)
DB 查询错误fail-closed 空集 → 403
权限变更后旧 token60s 内可能命中旧缓存;登录快照不变(重新登录刷新)
新增权限码seed 重跑(INSERT IGNORE 幂等)+ 重启即生效
8 权限与数据规则
  • PATIENT 角色不走权限码表(/pt 面物理隔离 + service 谓词);权限码只管 B 端三角色。
  • 静态源 NewStaticPermissionSource 保留作 parity 测试基线(⚠ DIF-M4 ⑦)。

F1.06 /pub IP 限流(内存/Redis 双内核)

对应 SRS:§4.1(匿名面防滥用) | 实现落点:internal/gateway/ratelimit.go:34(内存内核)、ratelimit_redis.go(Redis 内核)、cmd/server/main.go:100-105(装配) | 操作入口:—(基础设施)

1 功能定义

/pub 面组级 IP 限流:固定窗口 60 次/分钟,超限 429。双内核:单实例内存实现(dev 缺省,假时钟可注入测试)与 Redis 实现(键 rl:<库名>:<ip>:<bucket>,dev/e2e 同实例互不串扰)。挂在限流先于验签——洪水不消耗 HMAC。

2 触发条件与前置状态

/pub 面所有请求(含 guest-session bootstrap)。装配:redis 可用→Redis 内核(fail-open),否则内存内核。

3 输入与校验

限流键 = 客户端 IP;窗口 60s;上限 60 次。

4 处理流程
flowchart LR
    REQ[请求 /pub/*] --> RL{RateLimit 中间件<br/>先于 RequireJWT}
    RL -->|Allow=true| NEXT[JWT 验签 → handler]
    RL -->|Allow=false| R429[429]
5 输出与结果状态

放行或 429 Too Many Requests。

6 状态流转

无业务状态变更(计数窗口内存/redis 状态)。

7 边界与异常
场景行为
redis 错误fail-open 放行 + Warn(限流是防护不是业务,可用性优先——与 TOTP 防重放同哲学)
dev/e2e 同 redis 实例键带库名命名空间隔离(⚠ DIF-M3 ⑧);e2e 限流段放最后 + 冒烟前清 rl:*
代理后真实 IP取 echo 客户端 IP(MVP 单 nginx 直连部署无 XFF 链问题)
8 权限与数据规则
  • 上限值上线前压测定(tech-design §15 风险 9:刷聊天气费翻译费);软上限(每会话 50 条消息提示注册)在 chat 域 F3.03。

3.3 - FSD m02 · 医院主数据与展示(hospital)

本册覆盖代码域 internal/domain/hospital。全局规则见 00-总则 §2;直传三步通用规则见 §2.5;医院/科室/专家三同构状态机见 §2.2。

m02 功能节目录

ID名称路由/入口
F2.01公开医院列表GET /pub/v1/hospitals
F2.02公开医院详情GET /pub/v1/hospitals/:id
F2.03B 端医院维护与状态流转POST/PUT /api/hospitals、POST /api/hospitals/:id/status
F2.04医院封面图直传与回显POST /api/hospitals/:id/image/presign + /confirm;读侧 GET /pub/v1/hospitals/:id/image + GET /api/hospitals/:id/image(M15)
F2.05科室管理POST /api/hospitals/:id/depts、PUT /api/hospitals/depts/:id、POST .../status
F2.06专家库管理POST /api/hospitals/:id/experts、GET /api/hospitals/:id/experts、PUT /api/hospitals/experts/:id、POST .../status

F2.01 公开医院列表

对应 SRS:F-HOS-001 | 实现落点:internal/domain/hospital/service.go:65(ListHospitals)/handler.go:22(路由) | 操作入口:app H5 #/pages/hospital/list 医院列表页

1 功能定义

匿名访客按城市/等级筛选、分页浏览 PUBLISHED 状态医院卡片(中英文名/等级/城市/地址/科室标签/特色服务)。科室标签由 dept_summary 列拆分派生,不 join hospital_dept 表(防 N+1,⚠ DIF-M2 ④)。

2 触发条件与前置状态

app 首页列表加载;前置 = 至少一所 status='PUBLISHED' 医院(seed 预置 4 所)。

3 输入与校验
参数类型校验
cityquery string可选;等值过滤
gradequery string可选;等值过滤(如 三甲)
page / page_sizequery string可选;ParseIntOr 解析 + NormalizePage 归一(缺省/非法回落默认页)

无 bind body;参数解析在 service(handler 零逻辑)。

4 处理流程
sequenceDiagram
    participant A as app H5
    participant H as hospital.Handler
    participant S as hospital.Service
    participant DB as MySQL
    A->>H: GET /pub/v1/hospitals?city=&grade=&page=
    H->>S: ListHospitals(ctx, ListQuery)
    S->>S: NormalizePage + BuildListWhere(纯函数)
    S->>DB: SELECT COUNT(*) WHERE status='PUBLISHED' [+city+grade]
    S->>DB: SELECT 列表列 ORDER BY id LIMIT ? OFFSET ?
    S->>S: 每行 SplitDeptTags(dept_summary) + SplitListText(services)
    H-->>A: 200 {items[], total, page, page_size}
5 输出与结果状态

{items: [{id, name_zh, name_en, grade, city, address, dept_tags[], features[]}], total, page, page_size};空数组归一 [] 非 null(NonNilStrings)。

6 状态流转

只读;只出 PUBLISHED(DRAFT/OFFLINE 对匿名不可见)。索引 idx_status_city(status, city)(⚠ DIF-M2 ⑧ W0 修复:city 在后的组合才走得通默认路径)。

7 边界与异常
场景行为
无匹配医院200 空列表(items=[] total=0)
dept_summary 为空串dept_tags=[](哨兵语义)
分页越界空页(OFFSET 超总数据量)
DB 错误500
8 权限与数据规则
  • guest JWT(/pub 面 RequireJWT)+ IP 限流 60/min。
  • 列表列不含 intro_intl/visit_process(详情才返回,减载荷);运营中内容(非 PUBLISHED)不可见。

F2.02 公开医院详情

对应 SRS:F-HOS-002 | 实现落点:internal/domain/hospital/service.go:101(GetHospital)/handler.go:46(Get) | 操作入口:app H5 #/pages/hospital/detail 医院详情页

1 功能定义

匿名访客查看单所 PUBLISHED 医院完整详情:国际部介绍、特色服务、就诊流程、PUBLISHED 科室列表(中英文,按 sort 排序)+ 咨询入口(前端引导建会话 F3.01)。

2 触发条件与前置状态

列表点入或直链;前置 = 医院 status='PUBLISHED'。

3 输入与校验
参数类型校验
:idpath int64>0,解析失败 400
4 处理流程
sequenceDiagram
    participant A as app H5
    participant H as hospital.Handler
    participant S as hospital.Service
    participant DB as MySQL
    A->>H: GET /pub/v1/hospitals/:id
    H->>S: GetHospital(ctx, id)
    S->>DB: SELECT 详情列 WHERE id=? AND status='PUBLISHED'
    alt 无行(不存在或非 PUBLISHED)
        S-->>H: ErrNotFound → 404
    end
    S->>DB: SELECT id,name_zh,name_en FROM hospital_dept WHERE hospital_id=? AND status='PUBLISHED' ORDER BY sort,id
    H-->>A: 200 {…, dept_tags[], intro_intl, services[], visit_process[], depts[], image_url}
5 输出与结果状态

{id, name_zh, name_en, grade, city, address, dept_tags[], intro_intl, services[], visit_process[], depts: [{id, name_zh, name_en}], image_url}。image_url(M15 封面回显):image_object_key 非空时派生相对路径 /pub/v1/hospitals/{id}/image(<img src> 免鉴权直用),''=未上传前端不渲染。

6 状态流转

只读;医院与科室状态独立流转(科室可先于院区 PUBLISHED,公开详情只显示 PUBLISHED 科室)。

7 边界与异常
场景行为
id 不存在 或非 PUBLISHED一律 404(ErrNotFound 二者不可区分——不向匿名暴露运营中内容的存在性,service.go:15 注释)
未上传封面image_url=''(前端不渲染头图,渐变头维持)
科室全 DRAFTdepts=[]
DB 错误500
8 权限与数据规则

guest JWT + 限流;详情列含富文本(intro_intl TEXT);科室排序 sort, id。


F2.03 B 端医院维护与状态流转

对应 SRS:F-ADMIN-001(医院信息管理) | 实现落点:internal/domain/hospital/service_write.go:61(Create)/:82(Update)/:111(TransitionStatus)/in_tx.go:15(TransitionHospitalInTx)/handler_bo.go:37-105 | 操作入口:web /admin/hospitals 医院管理页(新建/编辑/上架按钮)

1 功能定义

B 端运营维护医院主数据:建院(INSERT 恒 DRAFT)、编辑非状态列(version CAS)、状态流转(DRAFT→PUBLISHED→OFFLINE→PUBLISHED 单向环白名单)。管理列表出全态(含 DRAFT/OFFLINE)。

2 触发条件与前置状态
  • 建院/编辑/流转:hospital:manage 权限(仅 ADMIN,见总则 §2.1 矩阵)。
  • 上架前置:富文本三列与封面图可空(运营后补——建院即可流转 PUBLISHED)。
3 输入与校验
端点字段校验
POST /api/hospitalsname_zh 必填;name_en/grade/city/address/dept_summary/reason;intro_intl/services/visit_process *string 可选,提交即落库(M14 DIF-F2-3 修复前 handler 不收三列致表单值静默丢弃)name_zh 空 → 400
PUT /api/hospitals/:id同上 + version 必填;富文本三列 *string 三态:缺省(null/不传)=保留现值、空串=显式置空、有值=更新(SQL COALESCE(?, col) 兜底);封面图不进本端点(走 F2.04 三步协议)version CAS 不符 → 409
POST /api/hospitals/:id/statusfrom/to/reason 三必填白名单外迁移 → 409;from 与库内现状不符 → 409
4 处理流程
sequenceDiagram
    participant W as web 医院管理页
    participant H as hospital.Handler(BO)
    participant S as hospital.Service
    participant DB as MySQL
    W->>H: POST /api/hospitals {name_zh,…}
    H->>S: Create
    S->>DB: INSERT hospital(status='DRAFT', version=1)
    S->>DB: op_log(hospital.create)(失败仅 warn)
    H-->>W: 201 {id}
    W->>H: PUT /api/hospitals/:id {…, version}
    H->>S: Update(非状态列 version CAS)
    S->>DB: UPDATE … WHERE id=? AND version=?(命中 0 行→409)
    H-->>W: 204
    W->>H: POST /api/hospitals/:id/status {from,to,reason}
    H->>S: TransitionStatus(白名单前置)
    S->>DB: WithTx: status 谓词 CAS → lifecycle + op_log 同事务
    H-->>W: 204 / 409
5 输出与结果状态

建院 201 {id};编辑/流转 204;AdminList 200 {items: [{id, name_zh, name_en, grade, city, address, dept_summary, intro_intl, services, visit_process, status, version, updated_at}]}(富文本三列 COALESCE(col,'') 下发空串非 null——NULL 不可扫入非指针 string,且前端不下发 null)。

6 状态流转

DRAFT → PUBLISHED → OFFLINE → PUBLISHED(单向环,无回 DRAFT;白名单 hospital/pure.go:99,守卫测试锁定)。流转 = TransitionHospitalInTx 唯一入口:status 谓词 CAS + lifecycle(from/to)+ op_log(reason) 同事务(迁移-日志同路径)。

stateDiagram-v2
    DRAFT --> PUBLISHED: 上架
    PUBLISHED --> OFFLINE: 下架
    OFFLINE --> PUBLISHED: 重新上架
7 边界与异常
场景行为
并发编辑(version 过期)409 ErrVersionConflict
非法迁移(如 DRAFT→OFFLINE)409 ErrInvalidHospitalTransition(白名单前置拦截)
from 与库内现状不符409(CAS 谓词命中 0 行)
op_log 写失败仅 Warn 不回滚(审计尽力而为;流转路径例外——同事务强一致)
⚠ DIF-F2-1(疑似缺陷) 已修复(2026-10-09 用户裁决完整修复)原状:Update SQL 硬编码置空富文本三列与封面图(service_write.go 传 nil/"" 占位),web 无编辑入口。修复(M13):三列改 *string 三态(缺省=保留,SQL COALESCE(?, col) 兜底防「不传即清空」);封面图从编辑语句剔除(只走 F2.04 三步协议);web 编辑 Modal 九列回填+全量提交(Hospitals.tsx)。回归锁:service_write_test.go 三态表驱动 + e2e F-ADMIN「编辑保留富文本与封面键」断言
8 权限与数据规则
  • hospital:manage(路由级 RequirePermission);op_log 带 actor_id/actor_role/reason。
  • reason 建院/编辑可不传、状态流转必填(敏感操作审计纪律)。

F2.04 医院封面图直传与回显

对应 SRS:F-ADMIN-001(图片上传) | 实现落点:写侧 internal/domain/hospital/service_write.go:287(PresignHospitalImage)/:301(ConfirmHospitalImage)+handler_bo.go:118(presign)/:136(confirm);读侧(M15 回显)service.go:151(GetHospitalImage)+handler.go:29(RegisterPublic)→:79(PublicImage)+handler_bo.go:156(HospitalImageBO) | 操作入口:写=web /admin/hospitals 操作列「封面」按钮 → 独立 Modal(选图本地预览→直传三步;M14 前按钮不存在、头注失实;confirm 会 version+1 故独立于编辑表单);读=同 Modal 顶部「当前封面」回显(B 端全状态)+ app H5 医院详情页头图(pub 仅 PUBLISHED)

1 功能定义

医院封面图经直传三步协议(总则 §2.5)上传:presign(mime 白名单前置 + 5MB policy)→ 浏览器直传 MinIO → confirm(对象复核 + image_object_key 回填)。与病历协议的差异:mime 必须 image/*、5MB 上限、回填 hospital 列。

回显读侧(M15)为后端代理(否决 presigned GET:localfs 驱动无直链能力且 nginx 不暴露 MinIO 端点,代理沿 DownloadDocument 先例):

  • pub GET /pub/v1/hospitals/:id/image——免 JWT(仅享 /pub 组限流):<img src> 带不了 Authorization 头;PUBLISHED-only 谓词(DRAFT/OFFLINE/未上传/不存在一律 404,存在性不泄露)。
  • B 端 GET /api/hospitals/:id/image——JWT + hospital:manage,不限状态(运营上传后 DRAFT 期即可在 Modal 验证)。
  • 公开详情(F2.02)派生 image_url 相对路径,前端按空串判断是否渲染。
2 触发条件与前置状态

hospital:manage;医院行已存在(confirm 按 id 回填)。

3 输入与校验
端点字段校验
POST …/image/presignmimeIsImageMime 白名单前置(非 image/* → 400 ErrBadImageMime);5MB 上限由 PresignPut policy 执行
POST …/image/confirmkey非空;HeadObject 复核实际 size/mime(第二道拦截)
GET /pub/v1/hospitals/:id/image:id>0;须 PUBLISHED 且 image_object_key 非空(否则 404);免 JWT 仅限流
GET /api/hospitals/:id/image:id>0;image_object_key 非空(否则 404);不限状态
4 处理流程
sequenceDiagram
    participant W as web 管理页
    participant H as hospital.Handler(BO)
    participant S as hospital.Service
    participant OS as MinIO
    participant DB as MySQL
    W->>H: POST /api/hospitals/:id/image/presign {mime}
    H->>S: PresignHospitalImage(IsImageMime 前置)
    S->>OS: PresignPut(key=hospital/*, 5MB policy)
    H-->>W: 201 {key, upload_url, method, headers}
    W->>OS: PUT 文件(直传)
    W->>H: POST /api/hospitals/:id/image/confirm {key}
    H->>S: ConfirmHospitalImage
    S->>OS: Confirm(HeadObject 复核 size/mime)
    S->>DB: SELECT version(服务端内部查——客户端不持版本)
    S->>DB: UPDATE image_object_key WHERE id=? AND version=?(CAS)
    H-->>W: 204

回显读侧(M15):

sequenceDiagram
    participant A as app H5 / web
    participant H as hospital.Handler
    participant S as hospital.Service
    participant OS as MinIO
    participant DB as MySQL
    A->>H: GET /pub/v1/hospitals/:id/image(免 JWT,仅限流)
    H->>S: GetHospitalImage(ctx, id, publishedOnly=true)
    S->>DB: SELECT status, image_object_key WHERE id=?
    alt 非 PUBLISHED / 未上传('') / 不存在
        S-->>H: ErrNotFound → 404(存在性不泄露)
    end
    S->>OS: Get(key)(代理出字节,≤5MB)
    H-->>A: 200 image/* + Cache-Control: public, max-age=300
5 输出与结果状态

presign 201 {key, upload_url, method, headers};confirm 204;hospital.image_object_key 已回填、version+1、op_log(hospital.image)。读侧(M15):200 image/* 字节(mime 取对象元数据)+ Cache-Control: public, max-age=300;web 封面 Modal 打开即拉 blob 回显「当前封面」,app 详情按 image_url 渲染头图。

6 状态流转

无状态变更(image_object_key 非状态列;version CAS 防并发覆盖)。

7 边界与异常
场景行为
mime 非 image/*400(第一道前置)
实际 size 超限/类型不符(伪装申报)400(confirm 第二道 storage.ErrBadMIME/ErrTooLarge)
key 对应对象不存在404(sql.ErrNoRows 映射 ErrHospitalNotFound——mapErrBO 同族)
hospital id 不存在404
confirm 与状态流转并发409(服务端内部 version CAS,M8 核查 B2:客户端不传 version——状态流转后客户端版本必然过期)
pub 读侧遇 DRAFT/OFFLINE/未上传/不存在一律 404(PUBLISHED-only 谓词,存在性不泄露——同 F2.02 口径);B 端读侧不限状态
8 权限与数据规则

hospital:manage;对象 key 由服务端生成(storage.NewKey("hospital"))不可客户端指定路径。读侧(M15):pub 免 JWT 仅 /pub 组 IP 限流 60/min(封面是 PUBLISHED 医院的公开内容,<img src> 带不了鉴权头);B 端读侧走 hospital:manage。


F2.05 科室管理

对应 SRS:F-ADMIN-001(科室专家管理) | 实现落点:internal/domain/hospital/service_write.go:142-180(CreateDept/UpdateDept)/in_tx.go:40(TransitionDeptStatusInTx)/handler_bo.go:144-205 | 操作入口:web /admin/hospitals 科室管理

1 功能定义

B 端维护医院下属科室:建科室(INSERT 恒 DRAFT)、编辑(version CAS + 院区归属谓词防跨院改写)、状态流转(与院区同形单向环)。公开面只出 PUBLISHED 科室。

2 触发条件与前置状态

hospital:manage;科室状态随院区独立流转(科室可先于院区 PUBLISHED)。

3 输入与校验
端点字段校验
POST /api/hospitals/:id/deptsname_zh 必填;name_en/sortname_zh 空 → 400
PUT /api/hospitals/depts/:idname_zh/hospital_id/version 必填归属谓词(hospital_id 不符)或 CAS 不符 → 409
POST /api/hospitals/depts/:id/statusfrom/to/reason 三必填白名单外/现状不符 → 409
4 处理流程

与 F2.03 同构(INSERT DRAFT / version CAS UPDATE / 白名单前置 + TransitionDeptStatusInTx 双日志同事务),差异仅在归属谓词 WHERE id=? AND hospital_id=? AND version=?。

5 输出与结果状态

201 {id} / 204;公开详情(F2.02)内嵌 depts[](PUBLISHED,ORDER BY sort,id)。

6 状态流转

DRAFT → PUBLISHED → OFFLINE → PUBLISHED(dept/pure.go:116 同形白名单);lifecycle entity_type=hospital_dept。

7 边界与异常
场景行为
hospital_id 与科室实际归属不符409(CAS 命中 0 行——归属谓词防跨院改写)
非法迁移/并发409(同 F2.03)
删除无删除端点——科室只软下架(OFFLINE),历史数据保全
8 权限与数据规则

hospital:manage;name_en 实落 NOT NULL DEFAULT ''(not_null 守卫下自觉偏离计划「可空」口径,⚠ DIF-M2 ⑧);建科室 op_log reason 为空串(低敏操作)。


F2.06 专家库管理

对应 SRS:—(M10 扩展批次,SRS 无对应条目) | 实现落点:internal/domain/hospital/service_write.go:199(CreateExpert)/:214(UpdateExpert)/:265(ListExperts)/in_tx.go:66(TransitionExpertStatusInTx)/handler_bo.go:218-302 | 操作入口:web /admin/hospitals 专家管理;医生选人消费面见 F6.01

1 功能定义

B 端维护医院科室下的出诊专家(姓名/职称/专长/简介/排序),供预诊断阶段医生锚定科室选人(F6.01)。与 dept 完全同构(状态机/双日志/归属谓词),M10 落地(delta/0010 加表)。

2 触发条件与前置状态

hospital:manage;专家挂 hospital_id + dept_id 双归属;状态随院区/科室独立流转。

3 输入与校验
端点字段校验
POST /api/hospitals/:id/expertsdept_id/name/title/specialty/sort 必填;intro 可空指针name 空 → 400
GET /api/hospitals/:id/expertsdept_id query 可选(0=不限)管理面出全态(含 DRAFT)
PUT /api/hospitals/experts/:iddept_id/name/title/specialty/sort/hospital_id/version 必填;intro *string 三态同 F2.03(缺省=保留/空串=置空/有值=更新,COALESCE(?, intro))归属+version CAS → 409
POST /api/hospitals/experts/:id/statusfrom/to/reason 三必填白名单 → 409
4 处理流程

与 F2.05 同构;ListExperts 的 WHERE 装配单点 BuildExpertListWhere(onlyPublished, deptID) 纯函数——管理面 onlyPublished=false,医生选人面(diagnosis 域独立 SQL,F6.01)onlyPublished=true 仅 dept_id+PUBLISHED 两谓词(⚠ DIF-M10 ⑤:不共享本函数,dept 锚下 hospitalID 参数无意义)。

5 输出与结果状态

201 {id} / 200 {items: [{id, hospital_id, dept_id, name, title, specialty, sort, status, version}]} / 204。

6 状态流转

DRAFT → PUBLISHED → OFFLINE → PUBLISHED(expert/pure.go:133 同形白名单);lifecycle entity_type=hospital_expert;TransitionExpertStatusInTx 为 AST 守卫白名单点名函数(M10 扩射程)。

7 边界与异常
场景行为
专家改名/下架ticket.expert 文本快照不回写(医疗记录语义,F6.01)
跨院改写409(hospital_id 归属谓词)
简介编辑不可改(原 DIF-F2-2 观察项) 已修复(2026-10-09 随 DIF-F2-1 一并)UpdateExpert 补 intro = COALESCE(?, intro) 三态(service_write.go:214);建专家表单补简介输入。专家列表/独立编辑 UI 仍未立项(「只建不列」口径不变——API 层已可编辑)
8 权限与数据规则
  • 管理 4 端点挂既有 hospital:manage(14 码矩阵不扩——⚠ DIF-M10 ⑤ 权限面不蔓延)。
  • 无删除端点(只软下架);排序 sort, id。

3.4 - 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 处理流程
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_tokenquery(或 Authorization 头)
after_id / Last-Event-ID断线游标(query 优先,兼容 Last-Event-ID 头——webview 支持不全)
4 处理流程
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/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 处理流程
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 崩溃残留 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 处理流程
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 对齐)
实际类型不符/超 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 处理流程
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/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 处理流程
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/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 设计红利)。

3.5 - 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.05GDPR 导出与抹除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_namestring拼接非空(FullName 单空格规范化)
emailstring格式 + NormalizeEmail 归一;唯一(409 ErrEmailTaken)
passwordstring8~72 字符(bcryptMaxPasswordLen 硬限不静默截断)
nationalitystringISO 3166-1 alpha-2(入库大写)
genderenumMALE / FEMALE / OTHER
ageint1~120
phonestringtrim 非空
chief_complaintstringtrim 非空、≤2000 rune(→ ticket.chief_complaint)
expect_city / expect_windowstringcity 必填;window 可选
insurance_infostring可选
consentbool必须 true(ErrConsentRequired)
document_ids[]int64可选;presign/confirm 已落库文档 id,事务内归属对账
4 处理流程
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=false400
事务任一步失败整链回滚(无半注册状态)
孤儿文档(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/presignmimepdf/jpeg/png 白名单(PresignPut policy 20MB + 15min)
POST /pub/v1/documents/confirmkey/label/file_nameConfirm 复核实际 size/mime(第二道);配额复核带 ticket_id=0 谓词(已绑定历史文档不占新配额,⚠ DIF-M4 ⑭①);label/file_name 截断 255
4 处理流程
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 处理流程
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 处理流程
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 + reasonreason 空 → 400;账号不存在 → 404;非 PATIENT → 409;version CAS 不符 → 409
4 处理流程
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 分表理由)。

3.6 - FSD m05 · 工单与看板(ticket)

本册覆盖代码域 internal/domain/ticket 的 B 端面(直传三步/注册链部分在 m04)。全局规则见 00-总则 §2(A 轴状态机 §2.2、AST 守卫 §2.3)。

m05 功能节目录

ID名称路由/入口
F5.01工单自动生成(F4.01 事务链内 CreateInTx)
F5.02工单分配与流转POST /api/tickets/:id/assign(-doctor)、POST /api/tickets/:id/transition
F5.03状态机 transition() 与三条回退边(F5.02 内核;守卫测试锁定)
F5.04病历下载与版本组读GET /api/documents/:id/download、GET /api/documents/:id/versions
F5.05运营看板(聚合+三超时+对账 tab)GET /api/board

F5.01 工单自动生成

对应 SRS:F-TICK-001 | 实现落点:internal/domain/ticket/service.go:236(CreateInTx,F4.01 注册事务链内调用) | 操作入口:—(系统自动,随注册发生)

1 功能定义

注册事务内自动创建工单:INSERT(status=CREATED, care_stage=NONE)→ 事务内回填 ticket_no='T'+yyyyMMdd+LPAD(id,4,'0')(INSERT(’’)→UPDATE,非状态列豁免申报,uk 兜底唯一)→ lifecycle(‘CREATED’) + op_log(SYSTEM register) 同事务(迁移-日志同路径)。

2 触发条件与前置状态

F4.01 注册事务链;无人工触发路径(MVP 一人一单)。

3 输入与校验

patient_id / expect_city / expect_window / chief_complaint(来自注册表单);其余列哨兵默认(consultant_id=0/doctor_id=0/hospital_id=0/dept_id=0——M10 起hospital_id 在专家确认时回填,F6.01)。

4 处理流程

见 F4.01 §4 时序图开单段;CreateInTx 内部三步(INSERT → 回填 ticket_no → 双日志)。

5 输出与结果状态

ticket 行(CREATED/NONE/version=1)+ ticket_no 唯一编号;响应透出 {id, ticket_no}。

6 状态流转

→ CREATED(lifecycle event=‘CREATED’);后续 PENDING_ASSIGN 由 F5.02/F6.01 流转。

7 边界与异常
场景行为
同日多单ticket_no 序号=LPAD(id)——id 自增无计数器状态(uk 兜底)
回填失败事务回滚(整链)
patient_id 关联0 哨兵不可能(注册事务内已有 uid)
8 权限与数据规则

系统触发(actor=SYSTEM);lifecycle ActorID=patient_id。


F5.02 工单分配与流转

对应 SRS:F-TICK-002 | 实现落点:internal/domain/ticket/service_bo.go:171(Assign)/:216(Transition)/:244-268(NotifyTransition/pushTransition)/handler_bo.go:77-136 | 操作入口:web /admin/tickets 列表→详情(分配顾问/分配医生/流转按钮)

1 功能定义

顾问分配工单(给顾问改派/给医生)与手动流转状态;每次流转 Commit 后三 key SSE 同推(consultant:all 列表刷新 / ticket:<id> 详情 / s:<sessID> 患者流)。

2 触发条件与前置状态
  • 分配/流转:ticket:assign / ticket:transition(CONSULTANT 持有;DOCTOR 均无——只读+写预诊)。
  • 流转前置:当前状态在白名单内(F5.03);reason 必填。
3 输入与校验
端点输入校验
POST /api/tickets/:id/assignassignee_idfield 白名单(consultant_id/doctor_id 服务端选定,非客户端输入直达列名);version CAS
POST /api/tickets/:id/assign-doctorassignee_id同上
POST /api/tickets/:id/transitionto + reason白名单校验(ErrInvalidTransition 409);reason 必填 400;status 谓词 CAS
4 处理流程
sequenceDiagram
    participant W as web 工单详情
    participant H as ticket.Handler(BO)
    participant S as ticket.Service
    participant DB as MySQL
    participant HUB as sse.Hub
    W->>H: POST /api/tickets/:id/assign {assignee_id}
    H->>S: Assign(field 白名单)
    S->>DB: WithTx: UPDATE consultant_id/doctor_id (version CAS) + lifecycle(ASSIGN_*) + op_log
    H-->>W: 204 / 409
    W->>H: POST /api/tickets/:id/transition {to, reason}
    H->>S: Transition
    S->>DB: WithTx: TransitionInTx(白名单→status 谓词 CAS→lifecycle+op_log)
    S->>HUB: Commit 后三 key 同推 ticket 事件
    H-->>W: 200 工单 JSON / 409
    HUB-->>A: 患者端实时进度刷新
5 输出与结果状态

分配 204;流转 200 返回更新后工单;SSE ticket 事件 {id, ticket_no, status, care_stage, updated_at, version}(web/app 前端注册表消费——SSE 事件注册表守卫对齐)。

6 状态流转

A 轴全图见总则 §2.2;分配列(consultant_id/doctor_id)非状态机列——version CAS 申报豁免(沿 BindPatientInTx 惯例),lifecycle event=ASSIGN_CONSULTANT/ASSIGN_DOCTOR(from/to 填旧/新 assignee id 串,可回放)。

7 边界与异常
场景行为
并发流转(from 漂移)409 ErrVersionConflict
白名单外迁移409 ErrInvalidTransition
推送失败仅 warn——SSE 是通知不是事实源,客户端重连 resync;三 key 相互独立不短路(M5 核查 A3:StaffKey 失败不得吞掉 TicketKey)
> ⚠ DIF-F5-1(观察项)分配不校验 assignee 存在性/角色:现状未校验(id 由前端用户列表提供,service_bo.go:171 仅 version CAS 写列)——登记 99-附录 B 观察项
8 权限与数据规则

ticket:assign/ticket:transition 路由级;分配与流转均双日志同事务(W4 补齐——M5 曾遗留分配无审计缺口)。


F5.03 状态机 transition() 与三条回退边

对应 SRS:F-TICK-002(状态机机制,SRS 只述 8 状态未述回退) | 实现落点:internal/domain/ticket/pure.go:92(ticketTransitions)/:103(CanTransition)/service.go:267(TransitionInTx)/guard/ast_status_guard_test.go(AST 守卫) | 操作入口:—(机制层;实际触发见 F5.02/F6.02/F7.02)

1 功能定义

状态机唯一权威的代码化:白名单 map(8 状态 7 出边 + 3 回退边)→ CanTransition 纯函数 → TransitionInTx 封装(白名单前置→status 谓词 CAS→lifecycle+op_log 同事务)。三条回退边是设计补充(SRS 未定义):边 1 支付失败重谈(PENDING_PAYMENT→PLAN_CONFIRMING)、边 2 改方案(PENDING_DEPARTURE→PLAN_CONFIRMING)、边 3 医生拒绝重派(PREDIAGNOSING→PENDING_ASSIGN)。

2 触发条件与前置状态

一切 ticket.status 变更的唯一合法路径;绕过即 AST 守卫测试红(guard/ast_status_guard_test.go:状态列字符串字面量只准出现在白名单函数)。

3 输入与校验

(ticketID, from, to, reason, actorID, actorRole);from 漂移(并发)→ ErrVersionConflict。

4 处理流程
flowchart LR
    A[TransitionInTx] --> B{CanTransition from→to<br/>白名单纯函数}
    B -->|否| E409a[ErrInvalidTransition 409]
    B -->|是| C["UPDATE ticket SET status=?, version=version+1<br/>WHERE id=? AND status=?"]
    C -->|affected=0| E409b[ErrVersionConflict 409]
    C -->|成功| D["lifecycle(from→to) + op_log(reason)<br/>同事务"]
5 输出与结果状态

状态列已迁移、version+1、双日志落库;守卫测试断言 ticketTransitions 与 8 状态+3 回退边逐一对应(防静默增删)。

6 状态流转

即本节本体(总则 §2.2 A 轴图);回退边 1 由支付回调驱动(F7.02)、边 3 由医生拒绝驱动(F6.02)、边 2 手动触发权限待产品确认(tech-design §15 风险 3——UI 不出按钮,白名单内 API 可达)。

7 边界与异常
场景行为
COMPLETED 迁出409(终态无出边)
同状态自迁409(白名单无自环)
AST 守卫边界动态拼接 SQL 扫不到——守卫是兜底不是完备证明,拼接绕过须 review 把关(⚠ DIF-M5 ② 申报)
8 权限与数据规则

reason 必填(关键操作审计纪律);payment/trip/user/hospital 族状态机同构封装(TransitionOrderInTx 等 8 白名单函数,见总则 §2.3-2)。


F5.04 病历下载与版本组读

对应 SRS:F-CARE-002(版本管理读闭环,就医前部分) | 实现落点:internal/domain/ticket/service_bo.go:271(DocumentFile)/:307(PatientDocument)/:347(DocumentVersions)/handler_bo.go:137-180 | 操作入口:web 工单详情文档列表(下载/版本);app 进度页文档下载

1 功能定义

B 端代理下载病历/阶段资料(服务端 blob 转发——<a> 带不了 Authorization)、查询同组版本历史;患者端下载走归属谓词双轨(已归属按 ticket.patient_id;未归属按绑定会话 gsid 锚)。

2 触发条件与前置状态
  • B 端:ticket:read;viewer 须为该工单顾问/医生/ADMIN(否则 403 ErrForbidden)。
  • 患者端:pt JWT;归属谓词双轨(PatientDocument)。
3 输入与校验

:docId path;未归属文档(ticket_id=0)B 端不可见(404——guest 所有权锚阶段)。

4 处理流程
flowchart LR
    A[GET /api/documents/:id/download] --> B{文档存在且已归属?}
    B -->|否/未归属| E404[404 ErrNotFound]
    B -->|是| C{viewer=顾问/医生/ADMIN?}
    C -->|否| E403[403 ErrForbidden]
    C -->|是| D["store.Get(objectKey) blob 代理<br/>mime 以实际内容为准"]
    P[GET /pt/documents/:id/download] --> Q{已归属?}
    Q -->|是| R{ticket.patient_id==me?}
    Q -->|否| S{session_gsid==本人绑定会话?}
    R -->|是| D
    S -->|是| D
    R -->|否| E403
    S -->|否| E404
5 输出与结果状态

文件 blob(Content-Type=实际 mime);版本端点返回同组全版本(version_no 降序;doc_group=0 历史散件返回单行自身)。

6 状态流转

无状态变更(读路径);版本替换的写路径在 F8.02。

7 边界与异常
场景行为
未归属文档 B 端访问404(不可见性——不暴露 guest 阶段产物)
跨工单访问403
患者未绑定会话查未归属文档404(无法证明所有权)
对象已删(GDPR 后)500/404 视存储实现(文档行已随抹除链处理)
8 权限与数据规则

ticket:read(B 端)/ pt JWT 谓词(患者);≤20MB 内存载(MVP 量级);患者端下载 M8 兑现(DIF-M7 ⑦)。


F5.05 运营看板(聚合+三超时+对账 tab)

对应 SRS:F-ADMIN-003(工单监控:总览看板、超时提醒、搜索筛选) | 实现落点:internal/domain/ticket/service_bo.go:415(Board)/pure.go:137-154(BoardThresholds/IsTimedOut)/main.go:150-154(config 注入) | 操作入口:web /admin/board 运营看板页

1 功能定义

单端点被动展示聚合(裁决 8——无主动通知/定时器):status/care_stage 两条 GROUP BY 计数 + 三个等待态(PENDING_ASSIGN/PREDIAGNOSING/PENDING_PAYMENT)超时工单表(阈值 config board.* 三键注入,内存 IsTimedOut 纯函数过滤)。

2 触发条件与前置状态

ops:read(CONSULTANT+ADMIN);页面刷新触发(被动展示口径)。

3 输入与校验

无入参;阈值来自 config(timeout_pending_assign_hours / timeout_prediagnosing_hours / timeout_pending_payment_hours)。

4 处理流程
flowchart LR
    A["GET /api/board"] --> B["GROUP BY status → status_counts"]
    A --> C["GROUP BY care_stage → stage_counts"]
    A --> D["SELECT 等待态全量(3 状态)"]
    D --> E["IsTimedOut 纯函数内存过滤(updated_at+阈值)"]
    E --> F["timeout_tickets[](含 hours_in_status)"]
5 输出与结果状态

{status_counts: {CREATED: n,...}, stage_counts: {...}, timeout_tickets: [{id, ticket_no, status, care_stage, patient_name, updated_at, hours_in_status}]}。

6 状态流转

只读聚合;不建看板物化表(单日百级工单实时聚合足够——tech-design §5.1 裁决)。

7 边界与异常
场景行为
超时阈值未配零值阈值=永超时为假(IsTimedOut 语义——config 缺省值兜底)
工单量大等待态全量拉取内存过滤——MVP 量级充分,量级证明需要再物化
搜索筛选列表页(F5.02 List)承担 status/city 筛选;看板只做总览
8 权限与数据规则

ops:read;对账 tab(支付残留单 GET /api/payments/pending + FAILED 邮件 GET /api/notifications/failed)在 m07/m10 各节,看板页聚合入口。

3.7 - FSD m06 · 预诊断与方案(diagnosis)

本册覆盖代码域 internal/domain/diagnosis(无新表——医生输出落 ticket 列,状态变更走 ticket.TransitionInTx 唯一入口)。全局规则见 00-总则 §2。

m06 功能节目录

ID名称路由/入口
F6.01医生工作台(预诊+专家选人 dept 锚)GET /api/diagnosis/pending、GET /api/diagnosis/experts、POST /api/diagnosis/:ticketId
F6.02拒绝与重派POST /api/diagnosis/:ticketId/reject
F6.03患者方案确认GET /pt/plan、POST /pt/plan/confirm

F6.01 医生工作台(预诊+专家选人 dept 锚)

对应 SRS:F-DIAG-001(专家选人为 M10 扩展,SRS 未细化) | 实现落点:internal/domain/diagnosis/service.go:47(PendingList)/:78(Submit)/:229(ExpertOptions)/main.go:259-262(路由) | 操作入口:web /admin/diagnosis 医生待诊页(列表→病历预览 Modal→填意见/选专家→提交)

1 功能定义

医生查看 PREDIAGNOSING 工单待诊列表、在线预览病历(复用 F5.04 代理 blob 前端渲染)、按科室锚定查询 PUBLISHED 专家并选人(或手填)、提交预诊意见/方案/推荐科室/预估天数与费用。提交=写 ticket 列(version CAS)→ 流转 PLAN_CONFIRMING → 患者邮件(plan_ready)同事务 → Commit 后三 key SSE。

2 触发条件与前置状态
  • diag:write(仅 DOCTOR);工单须处于 PREDIAGNOSING(白名单前置校验+事务内兜底双重)。
  • 病历在线预览为纯前端(Modal 内 img/pdf 原生渲染,后端零改动——⚠ DIF-M6 ⑨)。
3 输入与校验
字段类型校验
prediagnosis_summarystring必填语义(医生意见)
planstring治疗方案文本
dept_idint64推荐科室
expert_idint640=手填/存量哨兵;>0 时事务内一查两用(校验归属+上架态,取「姓名(职称)」快照)
expertstring手填推荐专家(’’ 允许;ExpertID>0 时被快照覆盖)
estimate_daysint预估停留天数(0 允许)
estimate_amountfloat64预估总费用(0=未填哨兵 → 定金回落固定额,payment.DepositCents 消费)
estimate_currencystring缺省服务端兜底 USD

GET /api/diagnosis/experts?dept_id=:dept_id<=0 → 空列表;仅 status='PUBLISHED' 专家,ORDER BY sort,id。

4 处理流程
sequenceDiagram
    participant DW as web 医生工作台
    participant H as diagnosis.Handler
    participant S as diagnosis.Service
    participant DB as MySQL
    participant OS as MinIO
    DW->>H: GET /api/diagnosis/pending
    H-->>DW: PREDIAGNOSING 工单列表(患者名/主诉/期望城市)
    DW->>H: GET /api/documents/:id/download(病历预览)
    H->>OS: store.Get → blob 代理
    DW->>H: GET /api/diagnosis/experts?dept_id=
    H-->>DW: PUBLISHED 专家选项(id/name/title/specialty)
    DW->>H: POST /api/diagnosis/:ticketId {意见, dept_id, expert_id, 预估…}
    H->>S: Submit
    alt expert_id>0
        S->>DB: SELECT 专家(404 ErrExpertNotFound / 归属或非 PUBLISHED 409)
        S->>S: ResolveExpertSnapshot 快照「姓名(职称)」;hospital_id 回填专家院区
    end
    S->>DB: WithTx: UPDATE ticket 列集(version CAS)→ TransitionInTx(PREDIAGNOSING→PLAN_CONFIRMING) → outbox(plan_ready)
    S->>S: Commit 后三 key SSE
    H-->>DW: 204
5 输出与结果状态

待诊列表/专家选项 JSON;提交 204;患者收 plan_ready 邮件 + /pt/plan 可见方案(F6.03)。

6 状态流转

PREDIAGNOSING → PLAN_CONFIRMING(A 轴正向边);ticket.hospital_id 自 M4 建单以来首次有写路径(选专家=确认院区,工单已绑院且与专家归属不符 → 409——ValidateExpertSelection 纯函数)。

7 边界与异常
场景行为
专家不存在404 ErrExpertNotFound(⚠ DIF-M10 ③ M10 核查 A3 收口:原 400 改 404 与 ErrOrderNotFound 同形,mapErr 同映)
专家非 PUBLISHED / 归属院区与工单不符409(ValidateExpertSelection)
工单非 PREDIAGNOSING409 ErrInvalidTransition(白名单前置)
version 竞态409(写列 CAS 命中 0 行)
专家快照不可变——专家改名/下架不回写历史方案(医疗记录语义,⚠ DIF-M10 ④)
estimate_amount=0定金回落 config 固定额(M6 单测断言 200.0 兼容证明)
8 权限与数据规则

diag:write(DOCTOR 专属);医生选人端点同挂 diag:write(14 码矩阵不扩——⚠ DIF-M10 ⑤ 权限面不蔓延);dept 锚设计根据=ticket.hospital_id 预诊前恒 0 不可作 JOIN 锚(M10 核查 B3/B5)。


F6.02 拒绝与重派

对应 SRS:F-TICK-002(「医生确认/拒绝」,M6 裁决 4 补齐) | 实现落点:internal/domain/diagnosis/service.go:154(Reject)/main.go:262(路由) | 操作入口:web /admin/diagnosis 待诊详情「拒绝」按钮

1 功能定义

医生拒绝预诊请求:状态机回退边 3(PREDIAGNOSING→PENDING_ASSIGN)+ INBOX 通知顾问重新分配(doctor_rejected 模板,同事务)。零写路径(拒绝只流转不写列)。

2 触发条件与前置状态

diag:write;工单须 PREDIAGNOSING;reason 必填。

3 输入与校验
字段类型校验
reasonstring必填(400);拼入 op_log reason「医生拒绝预诊请求:+reason」
4 处理流程
sequenceDiagram
    participant DW as web 医生工作台
    participant H as diagnosis.Handler
    participant S as diagnosis.Service
    participant DB as MySQL
    DW->>H: POST /api/diagnosis/:ticketId/reject {reason}
    H->>S: Reject
    S->>DB: WithTx: TransitionInTx(PREDIAGNOSING→PENDING_ASSIGN) + outbox(INBOX doctor_rejected → role:CONSULTANT)
    S->>S: Commit 后三 key SSE
    H-->>DW: 204
5 输出与结果状态

204;顾问 INBOX 收到重派待办(payload={ticket_no, reason});工单回到待分配。

6 状态流转

回退边 3(白名单内唯一由 DOCTOR 触发的回退边);lifecycle from/to 记录回退轨迹。

7 边界与异常
场景行为
工单非 PREDIAGNOSING409(白名单前置)
reason 空400
version 竞态409
8 权限与数据规则

diag:write;INBOX recipient=role:CONSULTANT(角色谓词待办)。


F6.03 患者方案确认

对应 SRS:F-DIAG-002 | 实现落点:internal/domain/diagnosis/service.go:190(ConfirmByPatient)/:260(PatientPlan)/main.go:263-264(路由) | 操作入口:app H5 #/pages/progress 方案卡片(查看→确认)

1 功能定义

患者在确认前查看方案(/pt/plan——F-DIAG-002「确认方案前看方案」语义闭环,DIF-M7 ⑤ 补齐)、确认后进入支付(PLAN_CONFIRMING→PENDING_PAYMENT,「进入支付」,支付本身 M6 接续)。

2 触发条件与前置状态

pt JWT;本人最新工单(直查口径 WHERE patient_id=? ORDER BY id DESC LIMIT 1——越权不可能 claims 即身份,⚠ DIF-M6 ⑤);确认须 PLAN_CONFIRMING。

3 输入与校验
端点输入校验
GET /pt/plan无无工单 404;预诊前字段零值/nil(前端按 status 渲染)
POST /pt/plan/confirm无TransitionInTx 白名单(非 PLAN_CONFIRMING 409)
4 处理流程
sequenceDiagram
    participant A as app 方案页
    participant H as diagnosis.Handler
    participant S as diagnosis.Service
    participant DB as MySQL
    A->>H: GET /pt/plan
    H->>S: PatientPlan(直查本人最新工单)
    H-->>A: 200 {ticket_no, status, prediagnosis_summary, plan, expert, estimate_*}
    A->>H: POST /pt/plan/confirm
    H->>S: ConfirmByPatient
    S->>DB: WithTx: TransitionInTx(PLAN_CONFIRMING→PENDING_PAYMENT)
    S->>S: Commit 后三 key SSE
    H-->>A: 204(前端引导创建支付单 F7.01)
5 输出与结果状态

方案视图 JSON(含专家快照/预估天数/费用/币种);确认 204;SSE ticket 事件双端刷新。

6 状态流转

PLAN_CONFIRMING → PENDING_PAYMENT(A 轴正向边;actor=PATIENT id,op_log reason「患者在线确认诊疗方案」)。

7 边界与异常
场景行为
无工单404
非 PLAN_CONFIRMING(重复确认/未到阶段)409
回退边 1 触发后(支付失败重谈)工单回 PLAN_CONFIRMING,患者可再次确认
8 权限与数据规则

pt JWT + patient_id 直查(不走权限码);方案文本 prediagnosis_summary/plan 为 NULL 豁免列(「尚未发生」语义)。

3.8 - FSD m07 · 支付与对账(payment)

本册覆盖代码域 internal/domain/payment(两表:payment_order 状态表 + payment_webhook_event 技术表)。全局规则见 00-总则 §2(支付单状态机 §2.2)。

m07 功能节目录

ID名称路由/入口
F7.01定金支付(Stripe Checkout)POST /pt/payments、GET /pt/payments
F7.02webhook 验签与幂等POST /webhooks/stripe
F7.03支付凭证 PDFGET /pt/payments/:id/receipt、GET /api/payments/:id/receipt
F7.04后台支付管理与对账GET /api/payments、GET /api/payments/pending
F7.05残留单人工关单POST /api/payments/:id/close

F7.01 定金支付(Stripe Checkout)

对应 SRS:F-PAY-001 | 实现落点:internal/domain/payment/service.go:103(CreateForPatient)/:493(ListMine)/pure.go(DepositCents/AmountToCents 纯函数)/main.go:297-298(路由) | 操作入口:app H5 #/pages/pay 支付页(确认方案后创建)

1 功能定义

患者为最新工单创建定金支付单:前置谓词工单须 PENDING_PAYMENT → 定金计算(estimate_amount>0 按 deposit_percent 比例四舍五入;=0 回落 config 固定额——DIF-M6 ④)→ INSERT PENDING 单(PENDING 复用谓词:同工单已有 PENDING 单复用重新 CreateCheckout 保 URL 活性,防残留单堆积——⚠ DIF-M8 ⑦)→ 返回 Stripe Checkout 跳转 URL。

2 触发条件与前置状态

pt JWT;工单 status='PENDING_PAYMENT'(否则 409 ErrNotPayable);provider=config(stripe 真供应商 / fake 本地假 URL 供 dev/e2e——装配期校验非法值 fatal)。

3 输入与校验

无业务入参(patient_id=claims.Sub 直查最新工单,越权不可能——DIF-M6 ⑤ 直查口径)。

4 处理流程
sequenceDiagram
    participant A as app 支付页
    participant H as payment.Handler
    participant S as payment.Service
    participant DB as MySQL
    participant ST as Stripe/Fake Provider
    A->>H: POST /pt/payments
    H->>S: CreateForPatient
    S->>DB: SELECT ticket WHERE patient_id ORDER BY id DESC LIMIT 1
    alt 无工单 / 非 PENDING_PAYMENT
        S-->>A: 404 / 409 ErrNotPayable
    end
    S->>DB: WithTx: qPendingReuse 查同工单 PENDING 单
    alt 已有 PENDING 单
        S->>S: 复用(reused=true)
    else
        S->>DB: INSERT payment_order(PENDING, amount=DepositCents(...))
    end
    S->>ST: CreateCheckout(order, AmountToCents)
    S->>DB: UPDATE provider_ref=checkout session id(非状态列回填)
    S->>DB: COMMIT
    H-->>A: 200 {order_id, ticket_id, checkout_url, amount, currency}
    A->>ST: 跳转完成支付(回调走 F7.02)
5 输出与结果状态

{order_id, ticket_id, checkout_url, amount, currency};payment_order 行(PENDING,provider_ref=checkout session id 作 webhook 回单锚)。

6 状态流转

→ PENDING(新单或复用单重新签 URL);后续迁移见 F7.02;FAILED 单不阻塞重建(失败回退重谈语义——新单)。

7 边界与异常
场景行为
无工单404 ErrTicketNotFound
工单不在 PENDING_PAYMENT409 ErrNotPayable
已有 PENDING 残留单复用+重新 CreateCheckout(W4 根因修复——多次进入支付页不再堆单)
estimate_amount=0(未填/存量单)定金=固定额(M6 兼容证明:单测断言 200.0 保持绿)
CreateCheckout 失败事务回滚(复用单 URL 不变)
金额精度AmountToCents 纯函数转 Stripe 分单位;比例计算禁 float64 直接乘(四舍五入纯函数)
8 权限与数据规则

pt JWT + patient_id 直查;定金比例 config 化(payment.deposit_percent 缺省 20——DIF-005 产品未定关联);PayPal/支付宝国际版=provider 枚举预留未实现(MVP Stripe 单供应商,用户裁决——DIF-M6 ③)。


F7.02 webhook 验签与幂等

对应 SRS:F-PAY-001(状态更新) | 实现落点:internal/domain/payment/service.go:166(HandleWebhook)/:188(applyWebhook)/pure.go(ClassifyEvent/ExtractOrderAnchor)/handler_webhook.go | 操作入口:—(Stripe 服务器回调)

1 功能定义

Stripe 回调全路径:验签(stripe-go webhook.ConstructEvent 离线 HMAC,300s 容差官方校验)→ 事件类型白名单分类(IGNORE/SUCCESS/FAILURE)→ TryClaim(INSERT payment_webhook_event,uk_provider_event 冲突=重复投递直接 200)→ 同事务业务(订单 CAS + 工单流转 + outbox 邮件 all-or-nothing——比「Claim 分离+5min 扫表」更简,无悬挂态,崩溃靠供应商重投——DIF-M6 ②)→ Commit 后 SSE + 凭证补偿。

2 触发条件与前置状态

POST /webhooks/stripe(零 JWT——供应商验签即鉴权);webhook_secret 空=一律 503(可选缺省降级可见)。

3 输入与校验
输入校验
payload + Stripe-Signature 头验签失败 400 ErrBadSignature
event.type白名单 3 类:checkout.session.completed→SUCCESS;失败类→FAILURE;白名单外→200 忽略零副作用(防供应商加事件类型打挂我们)
订单锚ExtractOrderAnchor(session.id 或 metadata.order_id);无锚/无对应单→200 忽略+Warn
4 处理流程
sequenceDiagram
    participant ST as Stripe
    participant H as webhook.Handler
    participant S as payment.Service
    participant DB as MySQL(单一事务)
    participant T as ticket.Service
    ST->>H: POST /webhooks/stripe (event_id + signature)
    H->>S: HandleWebhook
    alt secret 未配置
        S-->>ST: 503
    else 验签失败
        S-->>ST: 400
    end
    S->>S: ClassifyEvent 白名单
    S->>DB: TryClaim: INSERT payment_webhook_event(uk_provider_event)
    alt 冲突(重复投递)
        S-->>ST: 200 幂等零副作用
    end
    S->>DB: 回单锚(provider_ref / id)→ 支付单
    alt 订单 CAS PENDING→SUCCEEDED/FAILED
        S->>DB: TransitionOrderInTx(双日志含回调报文摘要)
    else CAS 冲突(已处理)
        S-->>ST: 200 幂等忽略
    end
    alt 工单在 PENDING_PAYMENT
        S->>DB: ticket.TransitionInTx(成功→PENDING_DEPARTURE / 失败→回退边1 PLAN_CONFIRMING)
        S->>DB: outbox 患者邮件(payment_succeeded/payment_failed)
    else 工单已不在(重谈后旧单迟到)
        S->>S: 跳过工单轴+邮件,订单轴照常落(人工对账)
    end
    S->>DB: COMMIT
    S->>T: NotifyTransition 三 key SSE(仅工单轴移动时)
    S->>S: 成功→Commit 后补偿生成凭证 PDF(失败仅 Warn 不反压)
    S-->>ST: 200
5 输出与结果状态

HTTP 200(幂等路径与成功路径同码);订单/工单状态迁移+双日志;患者邮件 outbox;凭证对象落桶+receipt_object_key 回填(幂等谓词 AND receipt_object_key='')。

6 状态流转

订单 PENDING → SUCCEEDED / FAILED;工单 PENDING_PAYMENT → PENDING_DEPARTURE(成功)/ → PLAN_CONFIRMING(失败=回退边 1);paid_at 非状态列回填(AND paid_at IS NULL 谓词幂等)。

7 边界与异常
场景行为
重复投递(同 event_id)200 零副作用(TryClaim uk 冲突)
订单 CAS 冲突(已前进)200 幂等忽略
工单已不在 PENDING_PAYMENT跳过工单轴与邮件、订单轴照常(Warn 留痕,人工对账归 F7.04)
无订单锚/锚无对应单200 忽略 + Warn
崩溃窗口事务 all-or-nothing,靠 Stripe 重投(无扫表 worker——DIF-M6 ② 裁决)
白名单外事件类型200 忽略零副作用(不落库)
8 权限与数据规则

供应商验签即鉴权;op_log reason 含回调报文截断 512 字符(tech-design §5.2「日志含回调报文」落点);actor=0/SYSTEM。


F7.03 支付凭证 PDF

对应 SRS:F-PAY-001(支付凭证) | 实现落点:internal/domain/payment/service.go:360(Receipt)/:317(generateReceipt)/receipt.go(BuildReceiptPDF 手写最小 PDF 生成器)/main.go:299-301(路由) | 操作入口:app #/pages/progress 凭证下载按钮;web 工单详情支付节点凭证按钮

1 功能定义

支付成功后生成一页纯英文凭证 PDF(手写最小生成器:Helvetica 标准基字体零嵌入、纯函数+golden 字节断言+xref 自洽校验——零外部依赖,DIF-M10 ①)。生成时机双轨:webhook Commit 后补偿 + 下载侧惰性自愈(key 空且 SUCCEEDED 现场生成回填,覆盖补偿失败窗口)。

2 触发条件与前置状态
  • 患者:GET /pt/payments/:id/receipt(归属谓词 JOIN ticket.patient_id——DIF-M6 ⑤ 直查口径)。
  • B 端:GET /api/payments/:id/receipt(payment:read 对账/纠纷查证面)。
  • 非-SUCCEEDED 或能力未装配(receiptIO nil)→ 409 ErrReceiptNotReady。
3 输入与校验

:orderId;归属不符与不存在同语义 404(不泄露存在性)。数据源 GET /pt/payments 列表(本人全部支付单,凭证按钮取最新 SUCCEEDED 单)。

4 处理流程
flowchart LR
    A["GET .../receipt"] --> B{归属谓词通过?}
    B -->|否| E404[404]
    B -->|是| C{SUCCEEDED 且 key 或能力齐?}
    C -->|否| E409[409 ErrReceiptNotReady]
    C -->|key 空| D["惰性自愈:现场 BuildReceiptPDF → Put → 回填(幂等谓词)"]
    C -->|key 有| G[store.Get]
    D --> G
    G --> H["200 application/pdf(文件名 receipt-ticketNo-orderId.pdf)"]
5 输出与结果状态

PDF blob(application/pdf);receipt_object_key 已回填(惰性路径);文件名 ReceiptFilename(ticketNo, orderID)。

6 状态流转

无状态变更(凭证键非状态列回填,AST 射程外——provider_ref/paid_at 先例)。

7 边界与异常
场景行为
订单非 SUCCEEDED409 ErrReceiptNotReady(409 快路径零多余查询)
webhook 补偿生成失败仅 Warn 不反压 200——下载侧惰性自愈兜底(零重试端点/零扫表 worker)
receiptIO 未装配409(降级可见,沿 webhookSecret 缺省模式)
惰性自愈并发双写回填幂等谓词只留先到者
中文 PDFMVP 不做(需求出现再引 gopdf——receipt.go 单文件隔离零外溢)
8 权限与数据规则

患者本人(JOIN 谓词)/ payment:read(B 端);PDF 内容=订单号/工单号/供应商/金额/交易 ref/时间(纯英文)。


F7.04 后台支付管理与对账

对应 SRS:F-PAY-001(状态更新)/F-ADMIN-003(对账 tab) | 实现落点:internal/domain/payment/service.go:339(ListByTicket)/:456(ListPendingOrders)/main.go:300-303(路由) | 操作入口:web 工单详情支付节点;/admin/board 对账 tab

1 功能定义

B 端查询工单支付单列表(web 详情内嵌支付节点)与残留单对账列表(PENDING 单 JOIN 工单状态+账龄小时数,倒序 LIMIT 100)——识别「进了支付页没付/回调丢失」的孤儿单。

2 触发条件与前置状态

payment:read(CONSULTANT+ADMIN);对账查询 ops:read。

3 输入与校验

:ticketId / 无参(pending 列表);账龄为 service 侧整数小时截断(展示语义,纯计算不进 SQL)。

4 处理流程
flowchart LR
    A["GET /api/payments?ticket_id"] --> B["SELECT * WHERE ticket_id ORDER BY id DESC"]
    C["GET /api/payments/pending"] --> D["PENDING 单 JOIN ticket.status → {…, age_hours}"]
5 输出与结果状态

订单数组(json tag snake_case 整形后——DIF-M8 ⑧);对账行含 ticket_status 交叉视角。

6 状态流转

只读;关单写路径在 F7.05。

7 边界与异常
场景行为
工单无支付单空数组
残留单 >100只出最新 100(量级 MVP)
旧单迟到(重谈后)对账列表可见其 FAILED/SUCCEEDED 终态,工单轴已跳过——人工核对场景
8 权限与数据规则

payment:read / ops:read 双码分层;webhook 路径 actor=0/“SYSTEM” 与人工操作区分(TransitionOrderInTx 扩 actor 入参——DIF-M8 ⑦)。


F7.05 残留单人工关单

对应 SRS:—(对账兜底,SRS 无对应) | 实现落点:internal/domain/payment/service.go:476(ClosePendingOrder)/main.go:304(路由) | 操作入口:web /admin/board 对账 tab「关单」按钮

1 功能定义

ADMIN 对确认作废的 PENDING 残留单人工关单:PENDING → FAILED(orderTransitions 既有边,零白名单改动),TransitionOrderInTx 双日志同事务,actor=操作管理员。

2 触发条件与前置状态

ops:manage(仅 ADMIN——读写分离双码,DIF-M8 ④ 裁决否决 payment:read 兼职写操作);reason 必填。

3 输入与校验
字段校验
:orderId单须存在且 PENDING(CAS 命中 0 行 409)
reason必填(400 ErrReasonRequired)
4 处理流程
flowchart LR
    A["POST /api/payments/:id/close {reason}"] --> B{reason 非空?}
    B -->|否| E400[400]
    B -->|是| C["WithTx: TransitionOrderInTx(PENDING→FAILED)<br/>双日志同事务"]
    C -->|CAS 命中 0| E409[409]
    C -->|成功| OK[204]
5 输出与结果状态

204;订单 FAILED(终态不复活——重谈走新建单);lifecycle+op_log 记录操作者与原因。

6 状态流转

PENDING → FAILED(白名单既有边);FAILED 不复活(pure.go:15 注释申报)。

7 边界与异常
场景行为
单已 SUCCEEDED/FAILED409(白名单无出边)
并发(回调与关单竞态)CAS 先到者赢,后到 409
reason 空400
8 权限与数据规则

ops:manage 仅 ADMIN;审计 reason 必填与 GDPR 抹除/用户禁用同纪律(敏感操作审计)。

3.9 - FSD m08 · 诊疗过程(care)

本册覆盖代码域 internal/domain/care(无新表——care_stage 落 ticket 列 TransitionStageInTx 唯一入口,阶段历史=lifecycle 行集,不建阶段记录表——DIF-M7 ①)。全局规则见 00-总则 §2(C 轴状态机 §2.2)。

m08 功能节目录

ID名称路由/入口
F8.01C 轴 8 阶段推进POST /api/care/:ticketId/stage
F8.02阶段资料版本组POST /api/care/:ticketId/documents/presign + /confirm
F8.03患者进度同步(F4.04 派生 + SSE ticket 事件)

F8.01 C 轴 8 阶段推进

对应 SRS:F-CARE-001 | 实现落点:internal/domain/care/service.go:50(AdvanceStage)/ticket/service.go(TransitionStageInTx)/ticket/pure.go:115(stageTransitions)/main.go:311(路由) | 操作入口:web 工单详情「阶段推进」操作(顾问)

1 功能定义

顾问推进患者医疗旅程 8 阶段(出发准备→到达接待→初诊→检查→确诊→治疗→康复→返程):C 轴白名单单向链推进,前置 status=IN_TREATMENT 严格校验;推进=care_stage 谓词 CAS + lifecycle(from/to 填 stage 值——A/C 两轴事件同表靠值区分) + op_log 同事务 → Commit 后三 key SSE。

2 触发条件与前置状态

care:write(CONSULTANT);工单 status='IN_TREATMENT'(否则 409 ErrNotInTreatment);COMPLETED 后查询可见但不推进。

3 输入与校验
字段类型校验
tostringstageTransitions 白名单(回退/跳段 409 ErrInvalidStageTransition)
reasonstring必填(关键操作审计)
4 处理流程
sequenceDiagram
    participant W as web 工单详情
    participant H as care.Handler
    participant S as care.Service
    participant DB as MySQL
    participant T as ticket.Service
    W->>H: POST /api/care/:ticketId/stage {to, reason}
    H->>S: AdvanceStage
    S->>DB: SELECT 工单(status≠IN_TREATMENT → 409)
    S->>S: CanTransitionStage 白名单前置
    S->>DB: WithTx: TransitionStageInTx(care_stage 谓词 CAS + lifecycle + op_log)
    S->>T: Commit 后 NotifyTransition(Status 不变 CareStage=to——两轴正交)
    H-->>W: 204
5 输出与结果状态

204;ticket.care_stage 已推进;患者进度页(F4.04 派生)对应步点亮;SSE ticket 事件双端刷新。

6 状态流转

C 轴单向链(NONE→PREP_DEPARTURE→…→DEPARTURE,无回退边——SRS 无回退语义);推进前置 status=IN_TREATMENT 由 service 层校验(白名单外-1:C 轴仅在就医中推进,tech-design §6.2 正交性)。

7 边界与异常
场景行为
工单不在 IN_TREATMENT409 ErrNotInTreatment(前置不满足)
回退/跳段409 ErrInvalidStageTransition
DEPARTURE 再推进409(终态无出边)
阶段历史查询lifecycle 行集(GET /api/lifecycle/ticket/:id 统一端点,from/to=stage 值)
8 权限与数据规则

care:write(CONSULTANT 专属——患者只读 F8.03);无阶段记录表(历史=lifecycle 行集,双日志轨兑现「状态表必配日志表」六原则——DIF-M7 ①)。


F8.02 阶段资料版本组

对应 SRS:F-CARE-002(文件上传、文字说明、自动归类、版本管理) | 实现落点:internal/domain/care/service.go:71(PresignStageDocument)/:81(ConfirmStageDocument)/ticket/service.go:164(InsertStageDocumentInTx)/ReplaceDocumentVersionInTx | 操作入口:web 工单详情「上传阶段资料」(选阶段+文件+说明,可替换既有组)

1 功能定义

顾问上传阶段资料(检查报告/处方等):直传两道(B 端内部操作不走 pub 面 gsid 配额——20MB/mime 由 Store 执行);stage 列落上传阶段(自动归类);版本组语义:缺省=新组首传(doc_group 事务内回填=id);显式 doc_group=替换(旧行 is_current=0 CAS + INSERT version_no+1 同事务)。

2 触发条件与前置状态

care:write;工单归属存在(替换时组须属于本单)。

3 输入与校验
端点/字段校验
presign {mime}pdf/jpg/png(Store 白名单)20MB
confirm {key, file_name, label, stage, doc_group?}Confirm 复核实际 size/mime;doc_group=nil=新组;显式 0=历史散件不可替换(400 ErrDocGroupInvalid);组不存在/不属于本单 400;替换并发 409 ErrDocGroupConflict
4 处理流程
sequenceDiagram
    participant W as web 工单详情
    participant H as care.Handler
    participant S as care.Service
    participant OS as MinIO
    participant DB as MySQL
    W->>H: POST /api/care/:ticketId/documents/presign {mime}
    S->>OS: PresignPut(key=care/*, 20MB)
    H-->>W: 201 {key, upload_url}
    W->>OS: PUT 文件
    W->>H: POST .../documents/confirm {key, label, stage, doc_group?}
    S->>OS: Confirm(HeadObject 复核)
    alt doc_group 缺省(新组首传)
        S->>DB: WithTx: INSERT(doc_group=0) → 回填 doc_group=id → 双日志(DOC_UPLOAD)
    else doc_group 显式(替换)
        S->>DB: SELECT 现行行(组归属+is_current=1;不过→400)
        S->>DB: WithTx: 旧行 is_current=0 CAS → INSERT version_no+1 → 双日志
    end
    H-->>W: 200 文档 JSON
5 输出与结果状态

文档 JSON(doc_group/version_no/is_current/stage/label…);同组下 current 唯一由事务保证;历史版本只软置 is_current=0 不物理删(审计要求)。

6 状态流转

medical_document 版本翻转(is_current 1→0 / 新行 1);doc_group=0=历史散件哨兵(M4 注册直传行恒 0,语义不可替换);lifecycle event=DOC_REPLACE(替换时)。

7 边界与异常
场景行为
doc_group=0 显式传入400(历史散件组不可作为替换目标——语义裁断)
组不存在/不属于本单400 ErrDocGroupInvalid
并发替换(is_current CAS 命中 0 行)409 ErrDocGroupConflict(回滚零副作用)
对象不存在/超限/mime 不符404/400(Confirm 第二道)
存量 doc_group=0 行不回填迁移(DIF-M7 ③ 申报——语义=不可替换历史散件)
8 权限与数据规则

care:write;uploaded_by=actor_id;版本读端点=共享 F5.04(GET /api/documents/:id/versions,归属谓词同款)。


F8.03 患者进度同步

对应 SRS:F-CARE-003(可视化进度条、阶段详情、待办事项、推送通知、报告下载) | 实现落点:派生视图 ticket/pure.go:68(DerivePatientSteps,消费在 F4.04)+ SSE ticket 事件(F8.01 Commit 后三 key) | 操作入口:app H5 #/pages/progress 进度页(实时刷新)

1 功能定义

患者端进度的同步机制=三层组合:①SSE 实时推送(ticket 事件携 status/care_stage);②REST 兜底(/pt/progress 派生重算);③派生八步投影(纯函数跨 A+C 两轴)。注意与 SRS 的差异:待办事项不做(MVP 无该实体——登记 DIF),推送通知=EMAIL outbox 仅注册/方案/支付三节点(无阶段级邮件——DIF-M7 ⑤ app 只读拉取口径)。

2 触发条件与前置状态

pt JWT;F8.01 推进后 SSE 自动到达;重连/打开页面走 /pt/progress 全量重算。

3 输入与校验

无业务入参(F4.04 同源)。

4 处理流程
flowchart LR
    A["F8.01 Commit 后"] --> B["SSE ticket 事件 → s:会话 key(患者流)"]
    B --> C[app 进度页就地刷新]
    D["断线/打开页"] --> E["GET /pt/progress<br/>DerivePatientSteps 全量重算"]
    E --> F[进度条/阶段详情渲染]
    G["报告下载"] --> H["GET /pt/documents/:id/download<br/>F5.04 患者双轨归属谓词"]
5 输出与结果状态

进度步数组(done 标记)+ 阶段详情(ticket 列+版本组文档)+ 出行记录(F9.03)同页可见。

6 状态流转

只读消费;不反灌状态机(口径 B 投影纪律)。

7 边界与异常
场景行为
SSE 丢失事件重连补拉 + REST 全量重算兜底
待办事项未实现(SRS F-CARE-003 列出,MVP 裁剪——登记 99-附录差异台账 DIF-F8-1)
阶段级推送通知未实现(EMAIL 仅注册/方案/支付三节点;trip 无 SSE——DIF-M7 ⑦ 明确不做清单)
8 权限与数据规则

pt JWT + patient_id 谓词;SSE 事件经会话 key 定向(不广播他人数据)。

3.10 - FSD m09 · 出行协助(trip)

本册覆盖代码域 internal/domain/trip(单表 trip_record,detail JSON 列承载酒店/交通字段差异)。全局规则见 00-总则 §2(trip 状态机 §2.2)。

m09 功能节目录

ID名称路由/入口
F9.01酒店安排POST /api/trips(kind=HOTEL)、POST /api/trips/:id/transition
F9.02交通安排POST /api/trips(kind=TRANSPORT)、POST /api/trips/:id/transition
F9.03患者只读拉取GET /pt/trips

F9.01 酒店安排

对应 SRS:F-TRIP-001 | 实现落点:internal/domain/trip/service.go:60(Create)/Transition(tripTransitions 白名单)/main.go:319-321(路由) | 操作入口:web 工单详情出行协助区「新增记录」(顾问)

1 功能定义

顾问为工单创建酒店安排记录(推荐合作酒店/距离价格翻译服务说明/预订需求/状态记录):kind='HOTEL',字段差异进 detail JSON 列(服务端透传不校验内部结构——SRS §7「首期人工协助,系统仅做记录,不自动对接 OTA」)。状态机 PLANNED→BOOKED→COMPLETED/CANCELLED。

2 触发条件与前置状态

trip:write(CONSULTANT);工单存在(防挂空单——Create 归属校验 404)。

3 输入与校验
字段类型校验
ticket_idint64工单存在性(404 ErrTicketNotFound)
kindenumHOTEL / TRANSPORT(值域见 entity; kind 白名单校验)
titlestring记录标题
scheduled_atdatetime 可空预定时间
detailJSON明细对象(服务端不校验内部结构——零字段级查询 MVP)
notestring 可空备注

JSONBytes 类型:Scan/Marshal 双实现(json.RawMessage 无 sql.Scanner——活体 SELECT * 即 500 的「双处登记」教训型修正,⚠ DIF-M8 ⑧)。

4 处理流程
sequenceDiagram
    participant W as web 工单详情
    participant H as trip.Handler
    participant S as trip.Service
    participant DB as MySQL
    W->>H: POST /api/trips {ticket_id, kind=HOTEL, title, detail…}
    H->>S: Create
    S->>DB: SELECT 工单(不存在 → 404)
    S->>DB: WithTx: INSERT trip_record(PLANNED) + lifecycle(CREATED) + op_log
    H-->>W: 201 记录 JSON
    W->>H: POST /api/trips/:id/transition {to=BOOKED, reason}
    H->>S: Transition(白名单前置→status 谓词 CAS→双日志同事务)
    H-->>W: 200 / 409
5 输出与结果状态

记录 JSON(id/ticket_id/kind/title/status/scheduled_at/detail/note/version…——json tag snake_case,DIF-M8 ⑧ 整形);患者端 /pt/trips 可见(F9.03)。

6 状态流转

PLANNED → {BOOKED, CANCELLED};BOOKED → {COMPLETED, CANCELLED};CANCELLED/COMPLETED 终态不可迁(tripTransitions 白名单+守卫测试锁定);lifecycle entity_type=‘trip’(无专属 trip_log 表——双日志轨兑现六原则,DIF-M7 ①)。

7 边界与异常
场景行为
工单不存在404
白名单外流转(如 PLANNED→COMPLETED)409 ErrInvalidTripTransition
并发流转409(status 谓词 CAS)
OTA 对接不做(SRS §7 明示人工协助——枚举与 detail 结构为未来演进留位)
8 权限与数据规则

trip:write 创建与流转;ticket:read 工单聚合读(web 详情);detail JSON 原生存储无注入面(JSONBytes 序列化边界)。


F9.02 交通安排

对应 SRS:F-TRIP-002 | 实现落点:同 F9.01(kind='TRANSPORT' 同一 Create/Transition 路径) | 操作入口:web 工单详情出行协助区「新增记录」

1 功能定义

顾问创建交通安排记录(机场接送/就医期间交通/信息同步):与 F9.01 完全同构,仅 kind='TRANSPORT';detail JSON 承载航班号/接送点等字段差异。

2 触发条件与前置状态

同 F9.01(trip:write + 工单存在)。

3 输入与校验

同 F9.01(kind 值域差异)。

4 处理流程

同 F9.01 §4。

5 输出与结果状态

同 F9.01;工单时间线出行节点内嵌展示。

6 状态流转

同 F9.01。

7 边界与异常

同 F9.01(kind 与状态机正交——状态机不区分酒店/交通)。

8 权限与数据规则

同 F9.01。


F9.03 患者只读拉取(/pt/trips)

对应 SRS:F-TRIP(患者可见信息同步) | 实现落点:internal/domain/trip/service.go:113(ListByPatient)/main.go:322(路由) | 操作入口:app H5 #/pages/progress 出行信息区

1 功能定义

患者拉取本人最新工单的出行协助记录(只读):直查口径沿 DIF-M6 ⑤(patient_id→最新工单→ListByTicket)。无 SSE 推送(患者拉取式——DIF-M7 ⑦ 明确不做清单:SSE 注册表守卫不扩面)。

2 触发条件与前置状态

pt JWT;无工单返回空列表(非 404——进度页语义)。

3 输入与校验

无入参(patient_id=claims.Sub)。

4 处理流程
flowchart LR
    A["GET /pt/trips"] --> B["SELECT 最新工单 WHERE patient_id"]
    B -->|无工单| C["空列表(非 404)"]
    B -->|有| D["SELECT * FROM trip_record WHERE ticket_id ORDER BY id"]
    D --> E["记录数组(只读)"]
5 输出与结果状态

记录数组(同 F9.01 JSON 形态)。

6 状态流转

只读;患者不可创建/流转出行记录。

7 边界与异常
场景行为
无工单200 空列表
SSE 实时性无——打开进度页时拉取(拉取式口径)
多工单最新一单的记录(一人一单 MVP 语义)
8 权限与数据规则

pt JWT + patient_id 直查(不走权限码);记录全量可见(无字段级脱敏——出行信息非敏感 PII)。

3.11 - FSD m10 · 系统管理与审计(user + audit + infra/notify)

本册覆盖代码域 internal/domain/user、横切包 internal/audit 与 internal/infra/notify。全局规则见 00-总则 §2(双日志轨 §2.6)。

m10 功能节目录

ID名称路由/入口
F10.01用户管理(CRUD/禁用/改角色/重密)GET/POST /api/users、`POST /api/users/:id/disable
F10.02TOTP 2FA 管理(三态机+重置)`POST /api/auth/totp/setup
F10.03生命周期时间线查询GET /api/lifecycle/:type/:id
F10.04操作日志审计页GET /api/operation-logs
F10.05INBOX 待办与邮件对账重发GET /api/inbox、POST /api/inbox/:id/read、GET /api/notifications/failed、POST /api/notifications/:id/retry
F10.06outbox 投递与重试(dispatcher 定时任务)

F10.01 用户管理(CRUD/禁用/改角色/重密)

对应 SRS:F-ADMIN-002 | 实现落点:internal/domain/user/service.go:110(Create)/:159(SetStatus)/:176(SetRole)/:196(ResetPassword)/user/in_tx.go:19(SetUserStatusInTx)/:53(SetUserRoleInTx)/main.go:248-254(路由) | 操作入口:web /admin/users 用户管理页

1 功能定义

ADMIN 管理 B 端账号(B 端三角色+患者查看):建号(bcrypt 真算+邮箱唯一)、列表/详情(敏感列零透出——password_hash/totp_secret 不在 SELECT 列集)、禁用/启用(ACTIVE⇄DISABLED 双向白名单机)、改角色(「权限设置」=改 role,RBAC 仍 role 级不建每用户覆盖表)、重置密码(reason 必填——敏感操作审计纪律)。

2 触发条件与前置状态

user:manage(仅 ADMIN);目标账号存在(404)。

3 输入与校验
端点字段校验
POST /api/usersrole/name/email/password/phone?/hospital_id?/region?ValidateCreateInput 纯函数(role 白名单/名字非空/邮箱格式/密码强度);邮箱唯一(409 ErrEmailTaken)
GET /api/usersrole/status/分页过滤可选
POST /api/users/:id/disablereason 必填status 谓词 CAS+version 双谓词(SetUserStatusInTx)
POST /api/users/:id/enablereason 必填同上
POST /api/users/:id/rolerole+reason 必填role 白名单;version CAS(SetUserRoleInTx)
POST /api/users/:id/passwordnew_password+reason 必填密码强度校验;version 服务端内部查询 CAS
4 处理流程
sequenceDiagram
    participant W as web 用户管理页
    participant H as user.Handler
    participant S as user.Service
    participant DB as MySQL
    W->>H: POST /api/users {role,name,email,password,…}
    H->>S: Create(ValidateCreateInput + 邮箱唯一 + bcrypt)
    S->>DB: INSERT user_account(ACTIVE, totp_status='NONE') + op_log(user.create)
    H-->>W: 201 {id}
    W->>H: POST /api/users/:id/disable {reason}
    H->>S: SetStatus(DISABLED)
    S->>DB: WithTx: SetUserStatusInTx(status+version 双谓词 CAS → lifecycle + op_log)
    H-->>W: 204 / 409
5 输出与结果状态

201 {id} / 列表/详情 JSON(敏感列零透出) / 204;被禁用账号下次登录 401(F1.01 三条件之一)。

6 状态流转

user_account.status: ACTIVE⇄DISABLED(SetUserStatusInTx AST 白名单函数);role 变更经 SetUserRoleInTx(lifecycle event=ROLE_CHANGE,from/to 填旧/新角色);SetStatus 拆 Disable/Enable 双 handler(去 c.Path() 路由字面量耦合——M8 核查 B6)。

7 边界与异常
场景行为
邮箱已存在409 ErrEmailTaken
非法角色/弱密码400
并发管理操作409(status+version 双谓词)
重置密码无 reason400(M9 ⑤ A1 收口——敏感操作审计纪律对齐 disable)
> ⚠ DIF-F10-1(观察项)自禁/自改角色现状未拦(无自我保护谓词,user/service.go:159/176)——登记 99-附录 B 观察项
8 权限与数据规则

user:manage(ADMIN);全端点 op_log 带 actor/reason;密码 bcrypt;GDPR 抹除走 F4.05(患者数据链与员工停用链分流——ErrNotPatient 409)。


F10.02 TOTP 2FA 管理(三态机+重置)

对应 SRS:§4.2(后台 2FA 机制细化) | 实现落点:internal/domain/auth/service.go(TOTPSetup/TOTPEnable/TOTPDisable)/internal/domain/user/service.go:242(ResetTOTP)/main.go:24-26(自助三端点)/main.go:254(ADMIN 重置) | 操作入口:web /admin/settings 安全设置(自助绑定/解绑);用户管理页「重置 2FA」

1 功能定义

B 端员工自助管理 TOTP:setup 生成密钥(落 PENDING,返 secret+otpauth_url 供 Google Authenticator 扫码)→ enable 验首码转正(ENABLED)→ disable 验现码解绑(回 NONE)。ADMIN 可重置他人(清 secret+NONE+op_log——无法验码正是重置语义)。

2 触发条件与前置状态
  • 自助三端点:bo JWT 登录即可(无权限码)。
  • setup 前置:totp_status='NONE'(已 ENABLED 拒绝 409);enable 前置:PENDING。
  • 三态机:NONE → PENDING → ENABLED;PENDING 态不触发两段式登录(NeedsMFA 唯一判据 totp_status=='ENABLED'——否则 setup 与登录互相锁死,⚠ DIF-M8 ①)。
3 输入与校验
端点字段校验
POST /api/auth/totp/setup无状态谓词(NONE 才可)
POST /api/auth/totp/enablecode(6 位)长度 400;TOTPValidate ±1 窗;PENDING 谓词
POST /api/auth/totp/disablecode(6 位)ENABLED 谓词;验现码
POST /api/users/:id/totp/resetreason 必填user:manage;version 服务端内部查询 CAS
4 处理流程
stateDiagram-v2
    NONE --> PENDING: setup 生成密钥(AES-GCM 落库)
    PENDING --> ENABLED: enable 验首码
    ENABLED --> NONE: disable 验现码 / ADMIN 重置

setup 生成 20 字节随机密钥 → base32 无 padding 且长度 %8==0(双端约束,⚠ DIF-M5 ⑦)→ AES-256-GCM 加密落库(master_key)→ 返回明文 secret+otpauth URL(仅此一次可见)。enable 校验通过后状态谓词 CAS 转正。

5 输出与结果状态

setup 200 {secret, otpauth_url};enable/disable 204;reset 204;ENABLED 后该账号 F1.01 登录走两段式。

6 状态流转

见 §4 三态图;totp_status 列非 AST 守卫词边界命中(\bstatus\b 不匹配 totp_status——⚠ DIF-M8 ⑥ 扩面注记);RESET 同时清 totp_secret(NULL=未启用哨兵,not_null_guard 白名单第 1 条)。

7 边界与异常
场景行为
已 ENABLED 再 setup409
PENDING 直接 enable 未 setup409(状态谓词)
码错误401/400(按端点语义)
master_key 未配置503(2FA 不可用降级可见)
ADMIN 重置后该账号回到 NONE,需重新 setup(原密钥作废不可恢复)
8 权限与数据规则

自助=登录即可(本人 uid);重置=user:manage;密文存储 AES-GCM(master_key 生产必换);op_log user.totp_reset 留痕。


F10.03 生命周期时间线查询

对应 SRS:F-TICK-003(后台详细日志) | 实现落点:internal/audit/lifecycle_query.go:11(ListByEntity)/audit/handler.go:20/main.go:210(路由挂 ticket:read) | 操作入口:web 工单详情时间线 tab;web 审计页

1 功能定义

统一生命周期端点:按 (entity_type, entity_id) 查 entity_lifecycle_event 全时间线(ORDER BY id)。entity_type 值域=ticket / payment_order / trip / hospital / hospital_dept / hospital_expert / user_account(lifecycle 入口覆盖率守卫:对象类型 ⊆ 已挂时间线入口的类型 ∪ 白名单)。

2 触发条件与前置状态

ticket:read(CONSULTANT/DOCTOR/ADMIN 均可——统一端点设计);A/C 两轴事件同表靠值区分(stage 值与状态枚举天然不重叠——DIF-M7 ①)。

3 输入与校验
参数校验
:type实体类型(白名单外空结果)
:id正整数
4 处理流程
flowchart LR
    A["GET /api/lifecycle/ticket/123"] --> B["SELECT * FROM entity_lifecycle_event<br/>WHERE entity_type+entity_id ORDER BY id"]
    B --> C["时间线数组(event/from_value/to_value/actor_id/at)"]
5 输出与结果状态

事件数组(创建/流转/分配/阶段/文档上传替换全轨迹);工单详情内嵌同数据源单点(Detail.Timeline 与本端点同调 ListByEntity)。

6 状态流转

只读;写入路径全部在 transition 封装内(迁移-日志同路径——无旁路日志)。

7 边界与异常
场景行为
类型/id 无事件空数组
跨类型查询按 type 隔离(不跨实体聚合)
挂点覆盖web vitest lifecycle 入口覆盖守卫(对象类型 ⊆ 挂线类型 ∪ 白名单)
8 权限与数据规则

ticket:read;actor_id 语义(0=系统)+ GDPR 匿名自动传播(F4.05)。


F10.04 操作日志审计页

对应 SRS:§4.2(审计日志) | 实现落点:internal/audit/operation_log.go:29(Append)/operation_log_query.go:21(QueryOperationLogs)/audit/handler.go:35/main.go:212(ops:read) | 操作入口:web /admin 审计页(op_log 倒序)

1 功能定义

操作日志查询(审计页数据源):/api 全部写方法经中间件统一记录(actor_id/actor_role/action/entity/reason/trace_id/changes JSON/at,标准列)+ 关键操作 service 内显式补记;查询端点参数化(entity/actor/action)倒序。

2 触发条件与前置状态

ops:read(CONSULTANT+ADMIN);中间件豁免注册表(读与心跳豁免——AST 守卫检查全部写 handler 命中审计路由组)。

3 输入与校验
参数说明
entity_type/entity_id过滤
actor_id / action过滤
分页缺省倒序
4 处理流程
flowchart LR
    A["写请求 /api/*"] --> B["audit 中间件统一 Append<br/>(豁免注册表:读/心跳)"]
    C["关键操作 service 内"] --> D["显式 Append 带 reason/changes"]
    B --> E[(operation_log append-only)]
    D --> E
    F["GET /api/operation-logs"] --> G["QueryOperationLogs 参数化倒序"]
    E --> G
5 输出与结果状态

日志数组;changes JSON 载字段级 diff(Update 带 audit.Diff——如分配 id 变化/scrubbed 条数)。

6 状态流转

只读;operation_log append-only 永不 UPDATE(GDPR 匿名传播设计依赖 id 引用而非名字)。

7 边界与异常
场景行为
op_log 写失败(非事务路径)仅 Warn 不回滚业务(尽力而为——transition 路径例外同事务强一致)
trace_id中间件注入(链路追踪衔接)
回调报文payment 路径 reason 含报文截断 512(F7.02)
8 权限与数据规则

ops:read;日志含敏感操作全留痕(gdpr.erase/user.totp_reset/ticket.assign/payment.transition…)。


F10.05 INBOX 待办与邮件对账重发

对应 SRS:F-REG-003(自动通知顾问,扩展) | 实现落点:internal/infra/notify/inbox.go:20(ListInbox)/:40(MarkInboxRead)/outbox.go:48(RetryFailed)/:60(ListFailedEmails)/notify/handler.go/main.go:238-243(路由) | 操作入口:web 待办铃铛(INBOX 列表/标读);看板对账 tab(FAILED 邮件重发)

1 功能定义

INBOX 站内待办:按 recipient='role:<ROLE>' 角色谓词隔离列表(register_notify/doctor_rejected 等),已读=回写 read_at(CAS 谓词防重,status 恒 PENDING——投递语义与已读语义分离,⚠ DIF-M5 ⑥)。邮件对账:FAILED 列表查询(ops:read)+ 人工重发(ops:manage:重置 PENDING/retry_count=0,dispatcher 30s 内再投)。

2 触发条件与前置状态
  • INBOX:登录即可(角色谓词隔离,无权限码——通知消费不需要权限码)。
  • 对账/重发:ops:read / ops:manage 双码分层。
3 输入与校验
端点输入校验
GET /api/inboxunread_only/limitrecipient=claims role 谓词
POST /api/inbox/:id/read:idread_at IS NULL CAS(幂等)
GET /api/notifications/failed无channel=EMAIL+status=FAILED
POST /api/notifications/:id/retry:id须 EMAIL+FAILED(qRetryFailed 谓词,否则空操作)
4 处理流程
flowchart LR
    A["GET /api/inbox"] --> B["recipient='role:'+claims.role 谓词<br/>read_at IS NULL 可过滤"]
    C["POST /api/inbox/:id/read"] --> D["UPDATE read_at WHERE read_at IS NULL(幂等)"]
    E["GET /api/notifications/failed"] --> F["EMAIL+FAILED 列表"]
    G["POST /api/notifications/:id/retry"] --> H["重置 PENDING/retry_count=0 → dispatcher 30s 再投"]
5 输出与结果状态

待办数组/标读条数/FAILED 列表/重发 204;重发后 dispatcher 投递 SENT(dev/e2e log 档日志即送达)。

6 状态流转

INBOX:status 恒 PENDING(dispatcher 只扫 EMAIL 零改动——退避/终态语义不被 INBOX 借用);EMAIL:FAILED→PENDING→SENT。

7 边界与异常
场景行为
重复标读幂等(CAS 命中 0 行)
重发非 FAILED 单空操作(谓词不命中)
重发再失败退避重试 3 败再 FAILED(可再重发)
8 权限与数据规则

角色谓词=天然行级隔离;ops 双码与 F7.05 同纪律(读写分离)。


F10.06 outbox 投递与重试

对应 SRS:—(基础设施) | 实现落点:internal/infra/notify/dispatcher.go:45-123(Dispatcher/Tick/dispatchOne)/outbox.go:26(EnqueueInTx)/pure.go(NextRetryAt/Exhausted)/sendgrid.go | 操作入口:—(30s 定时任务,main.go 装配 Start/Stop)

1 功能定义

EMAIL 通道投递器:dispatcher 每 30s 扫描到期 PENDING 单 → Mailer 投递(provider 分流:log=日志即送达 dev/e2e 恒 log;sendgrid=HTTP API,缺 api_key/from 装配期显式失败)→ 发送后回写 CAS(WHERE status='PENDING'——防回写互相覆盖,不防双实例重复投递,单实例边界注释申报——⚠ DIF-M4 ⑭③);失败指数退避(NextRetryAt 纯函数:30s 起翻倍)3 败 FAILED。

2 触发条件与前置状态

服务启动 disp.Start();优雅停机排空(停机序:HTTP 停 → worker 排空 → dispatcher → hub → redis)。

3 输入与校验

扫描谓词:channel='EMAIL' AND status='PENDING' AND next_retry_at<=now;Exhausted(retry_count, max_retries) 纯函数判定终态。

4 处理流程
flowchart LR
    A[每 30s Tick] --> B["SELECT 到期 PENDING(EMAIL)"]
    B --> C["dispatchOne:Mailer.Send"]
    C -->|成功| D["回写 CAS SENT(WHERE status='PENDING')"]
    C -->|失败| E["retry_count+1 + next_retry_at=NextRetryAt(指数退避)"]
    E -->|Exhausted 3 败| F[FAILED(人工重发 F10.05)]
    E -->|未耗尽| B
5 输出与结果状态

outbox 行终态 SENT/FAILED;last_error 截断留痕。

6 状态流转

PENDING → SENT / FAILED(retry_count 随退避递增);INBOX 行不进此路径(channel 过滤)。

7 边界与异常
场景行为
SendGrid 未配 api_key/from装配期 fatal(配置错误启动即暴露——沿 payment.provider 先例)
缺省(log 档)日志即送达(dev/e2e)
双实例部署不防重复投递(单实例边界申报)——回写 CAS 只防互相覆盖
发送后回写前崩溃PENDING 残留下轮重投(at-least-once;收件方幂等由业务语义保证)
8 权限与数据规则

系统任务(无 actor);按患者语言选模板 i18n(tech-design §10);payload JSON 透传(各业务节点构造)。

3.12 - FSD 99 · 附录

A. SRS 需求 ID → FSD 功能节 权威对照表

SRS §3 共 22 个功能 ID;一个 SRS 功能可拆多个 FSD 节(实现粒度),一个 FSD 节可承载多个 SRS 功能(事务链)。FSD 50 节中 28 节直接对应 SRS,22 节为代码先行(基础设施/扩展批次)。

SRS IDSRS 功能名FSD 功能节
F-HOS-001医院列表展示F2.01
F-HOS-002医院详情页F2.02
F-CHAT-001聊天入口F3.01
F-CHAT-002实时双语聊天F3.02 / F3.03 / F3.04 / F3.05 / F3.06 / F3.08
F-CHAT-003注册登记推送F3.07(卡片)+ F4.03(完成通知)
F-REG-001患者注册F4.01
F-REG-002病历资料上传F4.02
F-REG-003注册完成确认F4.03(通知)+ F4.01 响应(成功页/工单号语义)
F-TICK-001工单自动生成F5.01(F4.01 事务链内)
F-TICK-002工单分配与流转F5.02 / F5.03 / F6.02(医生拒绝)
F-TICK-003工单状态跟踪F4.04(患者进度)+ F5.05(后台看板)+ F10.03(时间线)
F-DIAG-001医生工作台F6.01
F-DIAG-002诊疗方案确认F6.03
F-PAY-001定金支付F7.01 / F7.02 / F7.03 / F7.04
F-TRIP-001酒店安排F9.01
F-TRIP-002交通安排F9.02
F-CARE-0018 个标准阶段定义F8.01
F-CARE-002阶段信息上传F8.02(就医前部分=F5.04/F4.02)
F-CARE-003患者进度同步F8.03
F-ADMIN-001医院信息管理F2.03 / F2.04 / F2.05 / F2.06
F-ADMIN-002用户管理F10.01 / F10.02
F-ADMIN-003工单监控F5.05

代码先行 22 节(SRS 无对应条目):F1.01-F1.06(认证与权限基础设施)、F2.06(专家库 M10 扩展)、F6.01 专家选人部分、F7.03(凭证 PDF)/F7.05(关单)、F9.03(患者只读)、F10.02(2FA 三态细化)/F10.04(审计页)/F10.05(INBOX 对账)/F10.06(outbox 基础设施)。

B. 实现差异与疑似缺陷登记

编号规则 DIF-F<m>-<序号>(正文行内注记同号;DIF-全局-1 口径差类仅登记于本台账)——与 DIF-M<n> ① 体系的关系:DIF-M 系 tech-design 里程碑批次差异编号(权威源 docs/tech-design.md §14 与 docs/开发任务总表.md,正文各册大量行内引用),DIF-F 系本 FSD 自有编号(权威源本台账),两套并存不换算。「疑似缺陷」= 实现与注释/常识意图不符且无裁决记录者——修复需用户裁决,本文档只登记现状。

编号类型现状(file:line)SRS/意图期望影响面处置建议
DIF-F2-1已修复(2026-10-09 用户裁决完整修复,M13)原状:医院编辑置空富文本——Update SQL 硬编码 intro_intl=NULL, services=NULL, visit_process=NULL, image_object_key='',且 web 无编辑入口;UpdateHospitalInput 注释「保留现值」与实现矛盾F-ADMIN-001 编辑应保留未提交字段;富文本应有维护入口修复:三列 *string 三态(缺省=保留,SQL COALESCE(?, col) 兜底)+封面图剔除出编辑语句(只走三步协议)+web 编辑 Modal 九列回填(service_write.go:82 / Hospitals.tsx);回归锁 service_write_test.go 三态表驱动+e2e F-ADMIN 断言已落地
DIF-F2-2已修复(2026-10-09 随 DIF-F2-1 一并,M13)原状:专家简介不可改(UpdateExpertInput 无 intro 字段,注释「简介编辑走后续需求」)主数据编辑完备性修复:UpdateExpert 补 intro = COALESCE(?, intro) 三态+建专家表单补简介输入(service_write.go:214);专家列表/独立编辑 UI 仍未立项(「只建不列」口径不变,API 层已可编辑)API 已落地;UI 待需求
DIF-F2-3已修复(2026-10-10 M14 /review-plan 核查发现即修)原状:建院 POST 链路富文本三列断链——web 建院表单提交 intro_intl/services/visit_process,但 CreateHospital handler req struct 不收三列、透传 CreateHospitalInput 亦缺(handler_bo.go),表单值被静默丢弃;F2.03③ 输入表早列三列属「文档先行于实现」F-ADMIN-001 建院应保留表单提交字段修复:handler req+透传补三列 *string(与 PUT 三态语义一致,handler_bo.go);回归锁 TestHandler_CreateHospital_BindsRichTextPtr(带三列→指针透传/缺省→nil)+e2e F-ADMIN 建院真库回查+web POST payload 锁已落地
DIF-F3-1已修复(2026-10-10 M15 用户裁决顺手修)原状:app 医院详情页「咨询」按钮点击抛 ReferenceError——onConsult 引用未定义变量 hospital(应为 detail),F3.01 详情页咨询入口不可用(detail/index.tsx)F-CHAT-001 操作入口(m03:22「app H5 医院详情页『咨询』按钮」)修复:hospital.id → detail.id(null 守卫防御);回归锁 app/__tests__/detail.test.tsx「点 Consult Now → navigateTo 带 hospital_id」用例(先红:ReferenceError 实证)已落地
DIF-F5-1观察项分配不校验 assignee 存在性/角色:Assign 仅 version CAS 写列(service_bo.go:171),assignee_id 可为不存在 id 或患者 idF-TICK-002 分配语义健全性错误 id 可写入(前端用户列表正常使用时不触发)service 层补 assignee 存在性+角色校验
DIF-F8-1未实现患者待办事项:SRS F-CARE-003 列出,无对应实体与端点F-CARE-003患者进度页无待办区块产品裁决后立项
DIF-F10-1观察项用户管理无自我保护谓词:ADMIN 可禁用自己/改自己角色(user/service.go:159/176)后台管理安全性最后一个 ADMIN 可能被自禁(seed 有 ADMIN 兜底)禁自禁/自降权谓词
DIF-全局-1口径差tech-design §5.1 记「21 张表」,实施收敛为 19 张(translation_cache→Redis 不建表 DIF-M3 ⑥;care_stage_record/trip_arrangement 合并为 trip_record+lifecycle 行集 DIF-M7 ①)设计文档与实现一致文档口径本 FSD 以 full.sql 实测为准(P3 数据库总览对账)

C. 哨兵错误 → HTTP 状态码注册表

机制见总则 §2.4(纯状态码+errors.Is 映射,无业务码字符串)。按域盘点(映射落点=各域 handler mapErr)。

auth / gateway

哨兵错误状态码场景
auth.ErrInvalidCredentials401三端登录统一(邮箱不存在/密码错/禁用/role 不符/码错——防枚举)
ErrTOTPAlreadyEnabled409setup 时已 ENABLED
ErrNotConfigured(jwt.secret 空)401(受保护面)/ 503(guest-session)可选缺省降级
限流超窗429gateway/ratelimit.go:84

patient / chat / ticket

哨兵错误状态码场景
ErrEmailTaken409注册/建号邮箱唯一
ErrDocOwnership409注册文档归属对账 affected 不符
patient.ErrNotFound404/pt/me 无账号
chat.ErrNotFound404会话/消息不可见(含跨 gsid 统一不可见)
chat.ErrSessionBound409绑定后 guest 语义作废 / 绑定竞态
ErrNotRetranslatab400DONE/NONE 重译拒绝
ticket.ErrDocQuota409第 11 份文档
ticket.ErrInvalidTransition409A 轴白名单外
ticket.ErrInvalidStageTransition409C 轴回退/跳段
ticket.ErrVersionConflict409CAS 命中 0 行(流转/分配/写列/墓碑)
ticket.ErrNotFound / ErrForbidden404 / 403文档不可见 / 归属谓词不过
ticket.ErrDocGroupConflict409版本替换并发

hospital / diagnosis / payment / care / trip / user / notify

哨兵错误状态码场景
hospital.ErrNotFound(含公开面非 PUBLISHED)404不存在与运营中不可区分
hospital.ErrInvalidHospitalTransition409三同构状态机白名单外
hospital.ErrVersionConflict409编辑/流转 CAS
hospital.ErrBadImageMime400封面图 mime 前置
ErrExpertNotFound404医生选人查无此专家(M10 核查 A3 收口)
ValidateExpertSelection 失败409专家非 PUBLISHED/院区不符
payment.ErrOrderNotFound404支付单不存在/归属不符(同语义)
payment.ErrTicketNotFound404患者无工单
payment.ErrNotPayable409工单不在 PENDING_PAYMENT
payment.ErrBadSignature400webhook 验签失败
payment.ErrReceiptNotReady409非 SUCCEEDED/能力未装配
payment.ErrReasonRequired400关单无 reason
payment.ErrInvalidOrderTransition409支付轴白名单外
care.ErrNotInTreatment409C 轴推进前置不满足
care.ErrDocGroupInvalid400组 0/组不属于本单
trip.ErrTicketNotFound404挂空单
trip.ErrInvalidTripTransition409出行轴白名单外
user.ErrEmailTaken 等409/400建号冲突/校验
patient.ErrNotPatient409GDPR 抹除目标非患者
storage ErrBadMIME/ErrTooLarge/os.ErrNotExist400/404confirm 第二道/对象缺失

D. 与 tech-design 的已知口径差(以实现为准)

设计期口径实现现状依据
§5.1 「21 张表」19 张DIF-M3 ⑥ / DIF-M7 ①(见 B-全局-1)
§4.2 「7 个域包」10 个域包(+patient 独立、+trip 独立、care 独立)domain/ 目录实测
§2 仓库树 docker-compose.yml无(DIF-M1 ② 取消)scripts/ 实测
§3.1 「gopdf v0.38」手写最小 PDF 生成器DIF-M10 ①
域包三文件铁律三文件+按职责拆分(worker/scrub/card/image/patient/gdpr 同包分文件)DIF-M3 ⑬ 放宽口径