这是本节的多页打印视图。 .
需求文档
- 1: PRD 分区地图 —— MediLink Global 海外来华就医服务平台
- 2: MediLink Global —— 海外来华就医服务平台 系统需求规格书(SRS)
- 3: 功能规格说明书(FSD)
- 3.1: MediLink Global 功能规格说明书(FSD)v1.0 · 总则
- 3.2: FSD m01 · 账号认证与权限(auth + gateway)
- 3.3: FSD m02 · 医院主数据与展示(hospital)
- 3.4: FSD m03 · 双语咨询(chat)
- 3.5: FSD m04 · 患者注册与病历(patient + ticket 直传)
- 3.6: FSD m05 · 工单与看板(ticket)
- 3.7: FSD m06 · 预诊断与方案(diagnosis)
- 3.8: FSD m07 · 支付与对账(payment)
- 3.9: FSD m08 · 诊疗过程(care)
- 3.10: FSD m09 · 出行协助(trip)
- 3.11: FSD m10 · 系统管理与审计(user + audit + infra/notify)
- 3.12: FSD 99 · 附录
1 - PRD 分区地图 —— MediLink Global 海外来华就医服务平台
本目录是需求的唯一源(md),交互原型是 UX 提案附件。技术设计见 ../docs/tech-design.md。
文档索引
| 文档 | 状态 | 说明 |
|---|---|---|
| srs-medilink-v1.md | DRAFT | 系统需求规格书 v1.0(MVP)。唯一权威源,由同名 docx 转换(2026-10-07),docx 仅存档不再更新 |
原型索引(prd/workspace/)
纯静态 HTML,每页自包含,演示件不代表真实性能方案:
| 原型 | 覆盖需求 | 说明 |
|---|---|---|
| index.html | F-HOS-001/002 | 产品门户:样例医院列表 + 6 步服务流程,事实上的分区地图页 |
| chat.html | F-CHAT-001/002/003 | 双语聊天(患者/顾问双视角),原文+译文双行气泡是消息展示的 UX 基准 |
| register.html | F-REG-001/002/003 | 患者注册 + 病历上传,成功页展示工单号并引导查看进度 |
| progress.html | F-TICK-003 / F-CARE-003 | 患者端进度跟踪,8 步进度条是「口径 B」的证据(见下) |
| dashboard.html | F-ADMIN-001/002/003 | 运营后台工单看板,状态词与 SRS §6.2 一致(「口径 A」) |
状态口径裁决记录
SRS 与原型存在三套进度口径,技术设计已裁决(详见 ../docs/tech-design.md §7 双进度模型):
| 口径 | 内容 | 裁决身份 |
|---|---|---|
| A | SRS §6.2 工单 8 状态:新建→待分配→预诊断中→方案确认中→待支付→待出行→就医中→已完成 | 状态机唯一权威 |
| C | SRS F-CARE-001 诊疗 8 阶段:出发准备→到达接待→初诊→检查→确诊→治疗→康复→返程 | 独立字段轴(care_stage),仅就医中推进 |
| B | progress.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 术语定义
| 术语 | 定义 |
|---|---|
| MVP | Minimum 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)
阅读顺序(侧边栏按权重 = 阅读序):
- 00-总则——修订记录 / 文档定位 / 术语与数据字典 / 全局规则 / 功能模块清单
- m01 账号认证与权限 · m02 医院主数据与展示 · m03 双语咨询 · m04 患者注册与病历
- m05 工单与看板 · m06 预诊断与方案 · m07 支付与对账 · m08 诊疗过程
- m09 出行协助 · m10 系统管理与审计
- 99-附录——需求溯源 / 守卫总表 / DIF 差异台账
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.0 | 2026-10-09 | srs-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=未绑定哨兵) |
| 两段式登录 / 2FA | TOTP 两段式 | 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"} 服务端构造 |
| 站内通知 / 待办 | INBOX | notification_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 issuermfa(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(keyrbac:<库名>:perm:<role>:pv<pv>,JWT claimpv隔离版本);DB 错误 fail-closed 返回空集;未挂守卫的端点 = 登录即可(如/api/inbox——通知消费按角色谓词隔离,无权限码)。 - 「角色 × 权限码」矩阵(权威源
scripts/dev/seed.sql:127-141;语义:chat:send 属顾问操盘职责、diag:write 属医生临床行为,ADMIN 不代写不代发):
| 角色 | 权限码 |
|---|---|
CONSULTANT | chat:read chat:send ticket:read ticket:assign ticket:transition payment:read care:write trip:write ops:read(9 码——全程操盘) |
DOCTOR | chat: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 通用校验与一致性(全系统不变量)
- 单库 DB 事务 + version CAS(tech-design §1 降级裁决,比模板三层补偿更强更简):写方法 service 层
WithTx包事务;状态表必带version列,状态变更走状态谓词 CAS(WHERE id=? AND status=?,SET version=version+1——from 漂移即ErrVersionConflict409)。 - 禁裸 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)。 - 迁移-日志同路径:每次状态变更 = transition 封装内同事务写
entity_lifecycle_event(from/to)+operation_log(actor/reason)——无独立日志通道。 - 读路径绝不加锁:全部业务读直查 dbmap,无任何串行化设施。
- 幂等:
payment_webhook_event(provider+event_id UNIQUE,INSERT 冲突=重复投递直接 200);注册绑定 CAS 谓词(WHERE patient_id=0)天然幂等;seed 幂等INSERT IGNORE。 - SSE 推送在事务 Commit 后(⚠ DIF-M5 ③):事务内推则回滚放假事件;失败仅 warn 不反压业务。
- 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。 - 守卫总表: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包级单例)+ handlermapErr的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 路径)。
- 400:bind/校验失败、
- 收敛方向(tech-design §2.4 同族):资源不存在=404、CAS 冲突/业务拒绝=409、可判定校验=400——现状 gorp 层错误多归 500 族(127 处中 15 处),是已知待收敛项不扩面。
- 哨兵错误清单全表:见 99-附录 C。
2.5 直传三步协议通用规则(病历 / 聊天图 / 医院图 / 阶段资料四处同构)
- 三步:①
POST .../presign(校验登录态+配额+size/mime 申报)→ 签发带 policy 的预签名 PUT URL(content-length-range 0..N MB+ mime 白名单 + 15min)——policy 签名在存储侧硬性拦截超限;②浏览器直传(不占后端带宽);③POST .../confirm:服务端 HeadObject 复核实际 size/mime(不信任客户端申报,第二道拦截)→ 配额复核 → 落库。 - 限制的执行点在服务端两道,存储只是执行器不是规则源;配额=第一道 presign 前 COUNT + confirm 二次 COUNT(⚠ DIF-M4 ⑭①:confirm 复核带
ticket_id=0谓词,已绑定历史文档不占新配额)。 - mime 白名单:病历 pdf/jpg/png 20MB;聊天图 jpg/png 10MB(mini 两步);医院图/阶段资料同病历口径。
- 对象存储:
storage.Store接口隔离——dev/e2e=minio S3 兼容(LAN 192.168.50.190:9000),单测=localfs 不连外部服务;S3 的「对象 mime」=上传方 Content-Type 元数据(presign 已锁死申报),真内容校验随生产 OSS driver 加服务端嗅探(⚠ DIF-M4 ① 已知局限)。
2.6 双日志轨与可选依赖降级
- 双日志轨分工:
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)。 - 可选依赖降级矩阵(缺省=功能降级可见,不阻塞起服):jwt.secret 空→受保护面一律 401;master_key 空→TOTP 相关返
ErrKeyNotConfigured;webhook_secret 空→/webhooks/stripe一律 503;redis 缺→限流回落内存内核、RBAC 直查 DB、翻译缓存直查(不降级放行也不误杀);receiptIO 未注入→凭证生成跳过、下载 409;mailer=log 档(日志即送达)。 - 通知双通道:EMAIL(dispatcher 30s 扫描指数退避、3 败 FAILED 可人工重发)/ INBOX(常驻 PENDING + read_at 列,dispatcher 不消费——已读语义独立于投递语义)。
- 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.01 | B 端两段式登录(密码+TOTP) | SRS §4.2(机制细化) | 实现(代码先行) |
| F1.02 | 患者登录(email+密码) | —(DIF-001 待产品确认) | 实现(代码先行) |
| F1.03 | 访客会话签发 guest-session | —(获客漏斗前置件) | 实现(代码先行) |
| F1.04 | JWT 五面鉴权与路由矩阵 | SRS §4.2 | 实现(代码先行) |
| F1.05 | RBAC 权限码字典与路由守卫 | 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.03 | B 端医院维护与状态流转 | 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.02 | SSE 流与心跳重连 | F-CHAT-002 | 实现 |
| F3.03 | 发消息与异步翻译回填 | F-CHAT-002 | 实现 |
| F3.04 | 图片消息 | F-CHAT-002 | 实现 |
| F3.05 | 已读回执 | F-CHAT-002 | 实现 |
| F3.06 | B 端会话管理与回复 | 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.05 | GDPR 导出与抹除 | 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.02 | webhook 验签与幂等 | F-PAY-001 | 实现 |
| F7.03 | 支付凭证 PDF | F-PAY-001 | 实现(代码先行) |
| F7.04 | 后台支付管理与对账 | F-PAY-001(状态更新) | 实现(对账部分代码先行) |
| F7.05 | 残留单人工关单 | —(对账兜底) | 实现(代码先行) |
m08 诊疗过程
| 功能 ID | 名称 | 对应 SRS | 状态 |
|---|---|---|---|
| F8.01 | C 轴 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.02 | TOTP 2FA 管理(三态机+重置) | SRS §4.2(细化) | 实现(代码先行) |
| F10.03 | 生命周期时间线查询 | F-TICK-003(后台日志) | 实现(代码先行) |
| F10.04 | 操作日志审计页 | SRS §4.2 | 实现(代码先行) |
| F10.05 | INBOX 待办与邮件对账重发 | F-REG-003(扩展) | 实现(代码先行) |
| F10.06 | outbox 投递与重试 | —(基础设施) | 实现(代码先行) |
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.01 | B 端两段式登录(密码+TOTP) | POST /api/auth/login + POST /api/auth/mfa |
| F1.02 | 患者登录 | POST /pub/v1/login |
| F1.03 | 访客会话签发 guest-session | POST /pub/v1/guest-session |
| F1.04 | JWT 五面鉴权与路由矩阵 | 全部路由面(中间件) |
| F1.05 | RBAC 权限码字典与路由守卫 | /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 输入与校验
| 字段 | 端点 | 类型 | 校验 |
|---|---|---|---|
| login | string | 非空;NormalizeEmail 归一化(trim+lower)后查询 | |
| password | login | string | 非空;bcrypt 比对 |
| mfa_token | mfa | string | 非空;Verify(token, IssuerMFA) 验签+issuer+exp |
| code | mfa | string | 非空;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 输入与校验
| 字段 | 类型 | 校验 |
|---|---|---|
| string | ValidateLogin 纯函数(非空+格式);NormalizeEmail 归一化 | |
| password | string | 非空;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≠PATIENT | 401(三条件与 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 输入与校验
| 面 | issuer | TTL | 必填 claim(结构性校验) |
|---|---|---|---|
/api | bo | 12h | Sub>0 ∧ Role≠"" ∧ PV>0 |
/pt | pt | 7d | Sub>0 |
/pub | guest | 7d | GSID≠"" |
| mfa_token | mfa | 5min | Sub>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 -->|通过| CLAIMS5 输出与结果状态
通过后 claims 经 gateway.ClaimsFrom(c) 注入 handler context;失败统一 401 → 前端登出跳登录。
6 状态流转
无状态变更。
7 边界与异常
| 场景 | 行为 |
|---|---|
| jwt.secret 未配置 | NewSigner(nil) 起服成功,受保护面 Verify 全部 ErrNotConfigured→401(可选缺省模式) |
| mfa_token 打 bo 面 | 401(无 Role/PV claim 过不了必填校验——构造保证非约定) |
| 过期 token | 401 |
| 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 |
| 权限变更后旧 token | 60s 内可能命中旧缓存;登录快照不变(重新登录刷新) |
| 新增权限码 | 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.03 | B 端医院维护与状态流转 | 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 输入与校验
| 参数 | 类型 | 校验 |
|---|---|---|
| city | query string | 可选;等值过滤 |
| grade | query string | 可选;等值过滤(如 三甲) |
| page / page_size | query 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 输入与校验
| 参数 | 类型 | 校验 |
|---|---|---|
| :id | path 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=''(前端不渲染头图,渐变头维持) |
| 科室全 DRAFT | depts=[] |
| 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/hospitals | name_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/status | from/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 / 4095 输出与结果状态
建院 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 不回滚(审计尽力而为;流转路径例外——同事务强一致) |
原状: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/presign | mime | IsImageMime 白名单前置(非 image/* → 400 ErrBadImageMime);5MB 上限由 PresignPut policy 执行 |
| POST …/image/confirm | key | 非空;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=3005 输出与结果状态
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/depts | name_zh 必填;name_en/sort | name_zh 空 → 400 |
| PUT /api/hospitals/depts/:id | name_zh/hospital_id/version 必填 | 归属谓词(hospital_id 不符)或 CAS 不符 → 409 |
| POST /api/hospitals/depts/:id/status | from/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/experts | dept_id/name/title/specialty/sort 必填;intro 可空指针 | name 空 → 400 |
| GET /api/hospitals/:id/experts | dept_id query 可选(0=不限) | 管理面出全态(含 DRAFT) |
| PUT /api/hospitals/experts/:id | dept_id/name/title/specialty/sort/hospital_id/version 必填;intro *string 三态同 F2.03(缺省=保留/空串=置空/有值=更新,COALESCE(?, intro)) | 归属+version CAS → 409 |
| POST /api/hospitals/experts/:id/status | from/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 归属谓词) |
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.02 | SSE 流与心跳重连 | GET /pub/stream、GET /pt/stream、GET /api/stream |
| F3.03 | 发消息与异步翻译回填 | POST /pub/v1/chat/messages |
| F3.04 | 图片消息 | POST .../chat/images/presign + 发消息 |
| F3.05 | 已读回执 | POST .../chat/messages/read |
| F3.06 | B 端会话管理与回复 | GET /api/chat/sessions、POST /api/chat/sessions/:id/messages 等 |
| F3.07 | 注册卡片推送 | POST /api/chat/sessions/:id/card |
| F3.08 | 患者端聊天(/pt 转正+重译) | GET /pt/chat/session、POST /pt/chat/messages 等 |
F3.01 创建匿名咨询会话
对应 SRS:F-CHAT-001 | 实现落点:
internal/domain/chat/service.go:96(EnsureSession)/handler.go:25(路由) | 操作入口:app H5 医院详情页「咨询」按钮 / 聊天页首次进入(详情页按钮曾因onConsult引用未定义变量点击必抛 ReferenceError——⚠ DIF-F3-1 已修复 M15,回归锁 detail.test.tsx)
1 功能定义
访客以 gsid 幂等创建咨询会话(可带 hospitalID 标记发起页,0=列表页发起);返回会话供后续收发。同一 gsid 重复调用回读既有行(幂等)。
2 触发条件与前置状态
前置 = 持有 guest JWT(F1.03 签发)。一个 gsid 对应一个会话(uk_gsid 唯一键)。
3 输入与校验
| 字段 | 类型 | 校验 |
|---|---|---|
| hospital_id | body int64 | 可选;0=列表页发起(哨兵语义,M3 广播制未分配顾问 consultant_id 恒 0) |
4 处理流程
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 会话 JSON5 输出与结果状态
会话 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 优先,queryaccess_token兜底——EventSource 无法带 Authorization 头)。
3 输入与校验
| 参数 | 说明 |
|---|---|
| access_token | query(或 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/stream | 404(SessionByPatient ErrNotFound) |
| 心跳周期内无活动 | 25s 心跳 < 最短网元超时(微信 webview/NAT 30~60s);同周期清扫防 fd 泄漏(判活=ping 塞不进 chan 即关——⚠ DIF-M3 ⑬ 口径) |
| 事件丢失(崩溃窗口) | 5min 扫表重投兜底翻译;消息本体 MySQL 单一事实源 + REST 补拉 |
| 跨事件类型 | ticket 事件由 ticket 域 F5.02 三 key 同推(Commit 后) |
8 权限与数据规则
- 访客/患者:gsid/patient_id 谓词隔离;B 端广播制全员可见(MVP 无顾问分配)。
- Nginx 反代四要点(proxy_buffering off 等)见 deploy/nginx.conf;缺一即「迟到的批投递」。
F3.03 发消息与异步翻译回填
对应 SRS:F-CHAT-002 | 实现落点:
internal/domain/chat/service.go:140(Send)/:245(insertMsgTx)/service_worker.go:115(translateOne)/pure.go(校验纯函数) | 操作入口:app 聊天页输入框;API curl
1 功能定义
访客发送文本消息:同步落库(消息 + 扩展行 PENDING)→ SSE 推原文(P95<500ms 目标=原文送达,⚠ DIF-002 口径)→ 异步投翻译任务;worker 翻译完成后 CAS 回填译文并推 msgTranslated 补挂气泡。访客侧 50 条软上限(提示注册不阻断)。
2 触发条件与前置状态
guest JWT + 会话存在;会话已绑定患者后 guest 语义作废(发送/补拉均 409 ErrSessionBound——⚠ DIF-M4 裁决 3);未达软上限不阻断。
3 输入与校验
| 字段 | 类型 | 校验 |
|---|---|---|
| content | string | ValidateContent 纯函数:trim 后非空、≤2000 字符(MaxContentLen) |
| src_lang | string | 访客恒 ’en’(DstLang 纯函数→‘zh’) |
4 处理流程
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_reached5 输出与结果状态
200 {id, session_id, sender_type, content, src_lang, msg_type:'TEXT', translated_text:null, translation_status:'PENDING', read_at:null, soft_limit_reached:bool};译文异步经 SSE msgTranslated 补推。
6 状态流转
chat_msg_ext.translation_status:PENDING → DONE / FAILED(worker CAS;FAILED 可 F3.08/F3.03 重译回 PENDING);NONE 仅 CARD/IMAGE(不投翻译)。缓存命中时发送路径内联同步带译文(省一次异步往返)。
7 边界与异常
| 场景 | 行为 |
|---|---|
| content 空/超 2000 字符 | 400 |
| 会话已绑定患者 | 409 ErrSessionBound(guest 语义作废) |
| 会话不存在 | 404 |
| 翻译超时(3s)/熔断(连续 5 败开 60s) | FAILED 可重译;熔断期内新消息直接标 FAILED 不打 API |
| worker 崩溃残留 PENDING | 5min 扫表重投(LIMIT 100;重投幂等——翻译幂等+缓存去重) |
| ≥50 条访客消息 | soft_limit_reached=true(提示注册,不阻断——tech-design §9.5 上线前压测定值) |
| 软上限计数 | idx_session_id 前缀 COUNT(不加热路径状态列——⚠ DIF-M3 ④) |
| 版本竞态(并发 retranslate/已读) | CAS 命中 0 行即放弃(翻译路径不受影响) |
8 权限与数据规则
guest JWT + 限流;消息 append-only(chat_message 永不 UPDATE——译文/已读在 chat_msg_ext 拆表,version 只保护每消息至多两次低频更新);GDPR 抹除时 TEXT 正文脱敏(F4.05,ScrubSessionMessagesInTx 显式合规豁免)。
F3.04 图片消息
对应 SRS:F-CHAT-002(文本+图片) | 实现落点:
internal/domain/chat/service_image.go(PresignChatImage/SendImage/sendImageOnSession)/pure.go:76(IsImageMsgType) | 操作入口:app 聊天页图片按钮;API curl
1 功能定义
聊天图片 mini 两步直传:presign(jpg/png 10MB)→ 浏览器 PUT → 发消息请求内联 Store.Confirm(对象复核与消息落库绑死,无孤儿态)。msg_type='IMAGE',content 复用存 object_key;不投翻译。
2 触发条件与前置状态
访客(gsid 会话未绑定)或患者(/pt 面);前置 = 图片已 PUT 成功(发消息时 confirm 复核对象事实)。
3 输入与校验
| 端点 | 字段 | 校验 |
|---|---|---|
| POST …/chat/images/presign | mime | 域白名单收紧 image/jpeg, image/png(存储层白名单更宽含 pdf——聊天域收紧);10MB |
| POST …/chat/messages | key + msg_type=‘IMAGE’ | Confirm 复核实际 size/mime;白名单外实际类型 400 |
4 处理流程
sequenceDiagram
participant A as 访客/患者
participant H as chat.Handler
participant S as chat.Service
participant OS as MinIO
A->>H: POST .../chat/images/presign {mime}
H->>S: PresignChatImage(域白名单→NewKey("chat"))
S-->>A: {key, upload_url, headers}
A->>OS: PUT 图片(直传)
A->>H: POST .../chat/messages {key, msg_type:"IMAGE"}
H->>S: SendImage
S->>OS: Confirm(HeadObject 复核)
alt 对象不存在/超限/类型不符
S-->>A: 404 / 400
end
S->>S: WithTx 双 INSERT(content=key, ext NONE)
S->>S: pushMsg(SessionKey + StaffKey)
H-->>A: 200 消息 JSON(translation_status='NONE')5 输出与结果状态
消息 JSON(msg_type=‘IMAGE’,content=object_key,translation_status=‘NONE’);对象与消息无孤儿态(confirm 失败不落库)。
6 状态流转
不投翻译(ext 恒 NONE——worker/扫表只捞 PENDING 天然跳过,⚠ DIF-M4 裁决 6)。
7 边界与异常
| 场景 | 行为 |
|---|---|
| mime 白名单外(presign) | 400 ErrBadMIME |
| 对象不存在(confirm) | 404(os.ErrNotExist——⚠ DIF-M4 ⑭② 与病历域 404 对齐) |
| 实际类型不符/超 10MB | 400 |
| 会话已绑定(访客路径) | 409 |
| 已知局限 | S3 对象 mime=上传方 Content-Type 元数据(presign 锁死申报),伪装内容类型拦不住(DIF-M4 ①,文档仅在受信上下文渲染) |
8 权限与数据规则
guest/pt JWT + 限流;10MB 独立于病历 20MB×10 配额(聊天图不是病历);GDPR 抹除删对象本体、key 留作结构审计(F4.05)。
F3.05 已读回执
对应 SRS:F-CHAT-002(已读状态) | 实现落点:
internal/domain/chat/service.go:212(MarkRead)/:276(markReadExec)/handler.go:28(路由) | 操作入口:app/web 聊天页(收到消息自动上报或打开页面时)
1 功能定义
批量标记会话内 ≤upToID 的消息已读:read_at IS NULL 谓词幂等 UPDATE + 广播 read 事件(气泡变已读)。MVP 单 read_at 语义(被对侧读过的最早时刻,不区分 reader)。
2 触发条件与前置状态
访客(gsid)/患者(patient_id)/顾问(sessionID 路由参数)三入口共享内核;顾问侧额外带 StaffKey 回显。
3 输入与校验
| 字段 | 类型 | 校验 |
|---|---|---|
| up_to_id | body int64 | 消息 id 上界(幂等:NULL 谓词天然防重) |
4 处理流程
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<=? AND read_at IS NULL"]
C --> D[推送 read 事件<br/>SessionKey + extraKeys]5 输出与结果状态
200 {marked: n}(本次实际标记条数);SSE read 事件 {session_id, up_to_id}。
6 状态流转
chat_msg_ext.read_at NULL → 时间戳(幂等写,豁免逐行 CAS——申报:非 ticket 状态机路径)。
7 边界与异常
| 场景 | 行为 |
|---|---|
| 重复上报 | marked=0(幂等) |
| up_to_id 超过实际消息 | 按实际命中行数 |
| 会话不存在/已绑定(访客路径) | 404 / 409 |
8 权限与数据规则
三面各自鉴权;read_at 是 NULL 豁免列(「尚未发生」语义)。
F3.06 B 端会话管理与回复
对应 SRS:F-CHAT-002(顾问侧) | 实现落点:
internal/domain/chat/service.go:409-463(ListSessions/ConsultantMessages/ConsultantSend/ConsultantMarkRead)/main.go:192-196(路由+权限码) | 操作入口:web/admin/chat会话工作台(列表→会话详情→回复/推送卡片/标读)
1 功能定义
顾问在 B 端工作台查看全部会话(广播制全员可见,100 条封顶)、补拉消息、以中文回复(zh→en 翻译方向)、标记已读。推送双 key:患者会话 key(对侧收 msg)+ StaffKey(B 端自身回显)。
2 触发条件与前置状态
bo JWT + chat:read(列表/补拉)/ chat:send(回复/标读/卡片);会话存在性校验。
3 输入与校验
| 端点 | 输入 | 校验 |
|---|---|---|
| GET /api/chat/sessions | 无 | 登录即可见全部(广播制) |
| GET /api/chat/sessions/:id/messages | after_id | 会话存在性(404) |
| POST /api/chat/sessions/:id/messages | content | ValidateContent(≤2000);src_lang 恒 ‘zh’ |
| POST /api/chat/sessions/:id/read | up_to_id | 幂等 |
4 处理流程
与 F3.03 同构(insertMsgTx 共享内核),差异:sender_type=‘CONSULTANT’、sender_id=uid、翻译方向 zh→en、推送加 StaffKey。
5 输出与结果状态
列表 {items: [{id, patient_id, hospital_id, status, msg_count, created_at}]}(msg_count 子查询实时 COUNT);消息/已读同 F3.03/F3.05。
6 状态流转
无会话级状态变更(consultant_id 恒 0——广播制不写分配,列先行免 M5 delta)。
7 边界与异常
| 场景 | 行为 |
|---|---|
| 会话不存在 | 404 |
| 列表 >100 会话 | 只出最新 100 条(量级 MVP) |
| 权限不足 | 403(chat:read/chat:send 路由级) |
8 权限与数据规则
chat:read/chat:send(CONSULTANT 持有;DOCTOR 持 chat:read 只读);广播制=MVP 无顾问-会话分配语义。
F3.07 注册卡片推送
对应 SRS:F-CHAT-003(注册登记推送) | 实现落点:
internal/domain/chat/service_card.go:26(SendCard)/main.go:196(路由) | 操作入口:web/admin/chat会话详情「推送注册」按钮
1 功能定义
顾问向会话推送结构化注册卡片:msg_type='CARD'、content 服务端构造 {"type":"register"}(客户端零参数防伪造)、不投翻译。患者端渲染卡片气泡,点击直达注册页(Taro hash 路由 #/pages/register)。
2 触发条件与前置状态
bo JWT + chat:send;会话存在(已绑定患者亦可推——转正后患者仍可收卡片)。
3 输入与校验
无业务入参(sessionID 路由参数;content 服务端构造——防伪造)。
4 处理流程
sequenceDiagram
participant W as web 会话工作台
participant H as chat.Handler
participant S as chat.Service
participant DB as MySQL
participant HUB as sse.Hub
W->>H: POST /api/chat/sessions/:id/card
H->>S: SendCard(sessionByID 存在性)
S->>DB: WithTx 双 INSERT(CARD / content={"type":"register"} / ext NONE)
S->>HUB: pushMsg(SessionKey + StaffKey)
H-->>W: 200 消息 JSON
HUB-->>A: 患者端卡片气泡(点击跳注册页)5 输出与结果状态
消息 JSON(msg_type=‘CARD’,translation_status=‘NONE’);患者端可见卡片并可一键转注册(衔接 F4.01)。
6 状态流转
无状态变更;卡片语义仅「去注册」一种(CardRegisterJSON 单点,后续类型在此扩展)。
7 边界与异常
| 场景 | 行为 |
|---|---|
| 会话不存在 | 404 |
| 重复推送 | 允许(每条卡片都是独立消息) |
| 翻译 | 不投(ext=‘NONE’,沿 IMAGE 先例——结构化 UI 不走文本翻译) |
8 权限与数据规则
chat:send;卡片完成后的注册完成通知走 F4.03(outbox),SSE 通知由 F4.01 事务链触发。
F3.08 患者端聊天(/pt 转正+重译)
对应 SRS:F-CHAT-002 | 实现落点:
internal/domain/chat/service_patient.go(SessionByPatient/PatientMessages/PatientSend/PatientMarkRead/PatientRetranslate)/handler.go:279-285(RegisterPatient) | 操作入口:app 聊天页(登录态自动切 /pt 面);API curl
1 功能定义
患者注册转正后(F4.01 绑定 patient_id)经 /pt 面继续同一会话:查自己的会话、补拉、发消息(英文→中文翻译方向)、已读、重译。scope 一律由 patient_id 解析(SessionByPatient 单点:WHERE patient_id=? ORDER BY id DESC LIMIT 1——多会话预留演进点)。
2 触发条件与前置状态
pt JWT(iss=pt);会话已绑定该患者(无绑定 404——注册页直达未绑定场景走 guest 面或无会话)。
3 输入与校验
| 端点 | 输入 | 校验 |
|---|---|---|
| GET /pt/chat/session | 无 | 返回绑定会话(404=未绑定) |
| GET /pt/chat/messages | after_id | scope 由 patient_id 解析;无软上限语义 |
| POST /pt/chat/messages | content | ValidateContent;src_lang 恒 ’en’、sender_id=0(会话归属即身份) |
| POST /pt/chat/messages/read | up_to_id | 幂等 |
| POST /pt/chat/messages/:id/retranslate | :id | 归属校验(不归属统一 404 不可见) |
4 处理流程
与 F3.03/F3.05 同构共享内核(insertMsgTx/markReadExec/retranslateMsg);差异:scope=SessionByPatient、推送加 StaffKey(B 端回显)、无软上限(注册转化目标已达成)。
5 输出与结果状态
同构 F3.03/F3.05;重译 202(FAILED CAS 置 PENDING 重投;PENDING 幂等 202;DONE/NONE 400 ErrNotRetranslatab)。
6 状态流转
translation_status 同 F3.03;绑定关系(patient_id)由 F4.01 事务链一次性写入。
7 边界与异常
| 场景 | 行为 |
|---|---|
| 未绑定会话 | 404 |
| 重译不归属消息 | 404(统一不可见——不暴露他人消息存在性) |
| 重译 DONE/NONE 消息 | 400(不可重试语义) |
| guest token 仍有效 | 服务端绑定守卫双闸:/pub 发送/补拉 409(F3.03)+ app 按 hasPtToken 切面(⚠ DIF-M4 ④) |
8 权限与数据规则
pt JWT 面隔离 + patient_id 谓词(不走权限码);历史聊天经 session.patient_id 自动归属(消息表不需要回填——F4.01 设计红利)。
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.05 | GDPR 导出与抹除 | GET /pt/me/export、POST /api/patients/:id/erase |
F4.01 注册事务链(建账号+绑会话+开单+通知)
对应 SRS:F-REG-001 / F-TICK-001(开单腿) | 实现落点:
internal/domain/patient/service.go:70(Register)/chat.BindPatientInTx(chat/service.go:480)/ticket.CreateInTx(ticket/service.go:236)/ticket.BindDocumentsInTx(ticket/service.go:126)/handler.go:30(路由) | 操作入口:app H5#/pages/register(聊天卡片点击或直达)
1 功能定义
访客提交注册表单,单一事务完成:建 user_account(PATIENT) + patient_profile → CAS 绑定聊天会话(三态语义)→ 开工单(INSERT + ticket_no 回填 + lifecycle + op_log)→ 病历文档归属对账回填 → outbox×2(确认邮件 + 顾问 INBOX)→ consent 留痕。任一步失败回滚整链。响应发 pt JWT(旧 guest token 语义作废)。
2 触发条件与前置状态
- guest JWT(gsid 识别访客;注册页直达无会话 = 合法主路径,跳过绑定不报错)。
- 前置校验:email 未注册(预检 COUNT + 事务内 uk 1062 兜底同映射 409);表单过
ValidateRegister纯函数。
3 输入与校验
| 字段 | 类型 | 校验(patient/pure.go:64 ValidateRegister,返回首个错误 400) |
|---|---|---|
| first_name / last_name | string | 拼接非空(FullName 单空格规范化) |
| string | 格式 + NormalizeEmail 归一;唯一(409 ErrEmailTaken) | |
| password | string | 8~72 字符(bcryptMaxPasswordLen 硬限不静默截断) |
| nationality | string | ISO 3166-1 alpha-2(入库大写) |
| gender | enum | MALE / FEMALE / OTHER |
| age | int | 1~120 |
| phone | string | trim 非空 |
| chief_complaint | string | trim 非空、≤2000 rune(→ ticket.chief_complaint) |
| expect_city / expect_window | string | city 必填;window 可选 |
| insurance_info | string | 可选 |
| consent | bool | 必须 true(ErrConsentRequired) |
| document_ids | []int64 | 可选;presign/confirm 已落库文档 id,事务内归属对账 |
4 处理流程
sequenceDiagram
participant A as app 注册页
participant H as patient.Handler
participant S as patient.Service
participant DB as MySQL(单一事务)
A->>H: POST /pub/v1/register(guest JWT + 表单)
H->>S: Register(ctx, gsid, in, ip)
S->>S: ValidateRegister 纯函数
S->>DB: 预检 COUNT(email)(>0 → 409)
S->>DB: INSERT user_account(PATIENT, bcrypt, totp_status='NONE')
S->>DB: INSERT patient_profile
S->>DB: BindPatientInTx: UPDATE chat_session SET patient_id WHERE gsid=? AND patient_id=0
Note over S,DB: affected=0 二次复核:无会话=合法跳过;被他人绑定=409 回滚
S->>DB: CreateInTx: INSERT ticket(CREATED) → 回填 ticket_no='T'+yyyyMMdd+LPAD(id,4) → lifecycle + op_log
S->>DB: BindDocumentsInTx: UPDATE medical_document SET ticket_id WHERE gsid+ticket_id=0+id IN(…)
Note over S,DB: affected≠len(document_ids) → 409 ErrDocOwnership 回滚
S->>DB: outbox×2(EMAIL register_confirm / INBOX register_notify → role:CONSULTANT)
S->>DB: INSERT consent_record(REGISTER, v1, ip)
S->>DB: COMMIT
S->>S: IssuePT(uid)(iss=pt, 7d)
H-->>A: 200 {token, patient_id, ticket:{id, ticket_no}}5 输出与结果状态
{token, expires_in:604800, patient_id, ticket: {id, ticket_no}};历史聊天经 session.patient_id 自动归属(消息表零回填);前端切 /pt 面 + 双存 pt token。
6 状态流转
chat_session.patient_id:0 → uid(CAS 谓词,天然幂等——非状态机列,AST 守卫申报豁免)。ticket.status:→ CREATED(创建即 lifecycle ‘CREATED’ 事件);下一迁移 PENDING_ASSIGN 归 F5.01。- consent_record 追加 REGISTER 行(append-only)。
7 边界与异常
| 场景 | 行为 |
|---|---|
| email 已注册 | 409 ErrEmailTaken(预检+uk 双兜底) |
| 会话已被其他 gsid 绑定 | 409 ErrSessionBound(回滚整链) |
| document_ids 含他人/不存在文档 | 409 ErrDocOwnership(affected 对账) |
| 表单任一校验失败 | 400(返回首个错误哨兵) |
| consent=false | 400 |
| 事务任一步失败 | 整链回滚(无半注册状态) |
| 孤儿文档(confirm 后未 register) | 不对账放任(⚠ DIF-M4 ⑪ 申报:对象存在性非事务资源,随 gsid 生命周期) |
8 权限与数据规则
- guest JWT + /pub 限流;密码 bcrypt(cost 10)。
- consent_record 记录版本(v1)/时间/IP——数据出境告知(医疗数据存储于中国境内,SRS §4.2)。
- op_log actor=SYSTEM(register 动作);lifecycle ActorID=patient_id。
F4.02 病历直传三步协议
对应 SRS:F-REG-002 | 实现落点:
internal/domain/ticket/service.go:70(PresignDocument)/:91(ConfirmDocument)/handler.go:24-25(路由,实现在 ticket 域 Register(pubV)) | 操作入口:app 注册页上传区(拖拽+XHR 进度条,⚠ DIF-M5 ⑧)
1 功能定义
患者注册前上传病历文件(PDF/JPG/PNG,20MB/文件,每 gsid 上限 10 份,自填标签描述):presign 签发带 policy 的 PUT URL → 浏览器直传 → confirm 服务端复核落库(ticket_id=0 哨兵 + session_gsid 所有权锚),注册时 F4.01 归属回填。
2 触发条件与前置状态
guest JWT;配额第一道(presign 前 COUNT 未绑定文档,第 11 份 409)。
3 输入与校验
| 端点 | 字段 | 校验 |
|---|---|---|
| POST /pub/v1/documents/presign | mime | pdf/jpeg/png 白名单(PresignPut policy 20MB + 15min) |
| POST /pub/v1/documents/confirm | key/label/file_name | Confirm 复核实际 size/mime(第二道);配额复核带 ticket_id=0 谓词(已绑定历史文档不占新配额,⚠ DIF-M4 ⑭①);label/file_name 截断 255 |
4 处理流程
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 文档 JSON5 输出与结果状态
文档 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 + reason | reason 空 → 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: 2045 输出与结果状态
导出: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/assign | assignee_id | field 白名单(consultant_id/doctor_id 服务端选定,非客户端输入直达列名);version CAS |
| POST /api/tickets/:id/assign-doctor | assignee_id | 同上 |
| POST /api/tickets/:id/transition | to + 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 -->|否| E4045 输出与结果状态
文件 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_summary | string | 必填语义(医生意见) |
| plan | string | 治疗方案文本 |
| dept_id | int64 | 推荐科室 |
| expert_id | int64 | 0=手填/存量哨兵;>0 时事务内一查两用(校验归属+上架态,取「姓名(职称)」快照) |
| expert | string | 手填推荐专家(’’ 允许;ExpertID>0 时被快照覆盖) |
| estimate_days | int | 预估停留天数(0 允许) |
| estimate_amount | float64 | 预估总费用(0=未填哨兵 → 定金回落固定额,payment.DepositCents 消费) |
| estimate_currency | string | 缺省服务端兜底 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: 2045 输出与结果状态
待诊列表/专家选项 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) |
| 工单非 PREDIAGNOSING | 409 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 输入与校验
| 字段 | 类型 | 校验 |
|---|---|---|
| reason | string | 必填(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: 2045 输出与结果状态
204;顾问 INBOX 收到重派待办(payload={ticket_no, reason});工单回到待分配。
6 状态流转
回退边 3(白名单内唯一由 DOCTOR 触发的回退边);lifecycle from/to 记录回退轨迹。
7 边界与异常
| 场景 | 行为 |
|---|---|
| 工单非 PREDIAGNOSING | 409(白名单前置) |
| 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.02 | webhook 验签与幂等 | POST /webhooks/stripe |
| F7.03 | 支付凭证 PDF | GET /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_PAYMENT | 409 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: 2005 输出与结果状态
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 边界与异常
| 场景 | 行为 |
|---|---|
| 订单非 SUCCEEDED | 409 ErrReceiptNotReady(409 快路径零多余查询) |
| webhook 补偿生成失败 | 仅 Warn 不反压 200——下载侧惰性自愈兜底(零重试端点/零扫表 worker) |
| receiptIO 未装配 | 409(降级可见,沿 webhookSecret 缺省模式) |
| 惰性自愈并发双写 | 回填幂等谓词只留先到者 |
| 中文 PDF | MVP 不做(需求出现再引 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/FAILED | 409(白名单无出边) |
| 并发(回调与关单竞态) | 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.01 | C 轴 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 输入与校验
| 字段 | 类型 | 校验 |
|---|---|---|
| to | string | stageTransitions 白名单(回退/跳段 409 ErrInvalidStageTransition) |
| reason | string | 必填(关键操作审计) |
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: 2045 输出与结果状态
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_TREATMENT | 409 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 文档 JSON5 输出与结果状态
文档 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_id | int64 | 工单存在性(404 ErrTicketNotFound) |
| kind | enum | HOTEL / TRANSPORT(值域见 entity; kind 白名单校验) |
| title | string | 记录标题 |
| scheduled_at | datetime 可空 | 预定时间 |
| detail | JSON | 明细对象(服务端不校验内部结构——零字段级查询 MVP) |
| note | string 可空 | 备注 |
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 / 4095 输出与结果状态
记录 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.02 | TOTP 2FA 管理(三态机+重置) | `POST /api/auth/totp/setup |
| F10.03 | 生命周期时间线查询 | GET /api/lifecycle/:type/:id |
| F10.04 | 操作日志审计页 | GET /api/operation-logs |
| F10.05 | INBOX 待办与邮件对账重发 | GET /api/inbox、POST /api/inbox/:id/read、GET /api/notifications/failed、POST /api/notifications/:id/retry |
| F10.06 | outbox 投递与重试 | (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/users | role/name/email/password/phone?/hospital_id?/region? | ValidateCreateInput 纯函数(role 白名单/名字非空/邮箱格式/密码强度);邮箱唯一(409 ErrEmailTaken) |
| GET /api/users | role/status/分页 | 过滤可选 |
| POST /api/users/:id/disable | reason 必填 | status 谓词 CAS+version 双谓词(SetUserStatusInTx) |
| POST /api/users/:id/enable | reason 必填 | 同上 |
| POST /api/users/:id/role | role+reason 必填 | role 白名单;version CAS(SetUserRoleInTx) |
| POST /api/users/:id/password | new_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 / 4095 输出与结果状态
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 双谓词) |
| 重置密码无 reason | 400(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/enable | code(6 位) | 长度 400;TOTPValidate ±1 窗;PENDING 谓词 |
| POST /api/auth/totp/disable | code(6 位) | ENABLED 谓词;验现码 |
| POST /api/users/:id/totp/reset | reason 必填 | 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 再 setup | 409 |
| PENDING 直接 enable 未 setup | 409(状态谓词) |
| 码错误 | 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 --> G5 输出与结果状态
日志数组;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/inbox | unread_only/limit | recipient=claims role 谓词 |
| POST /api/inbox/:id/read | :id | read_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 -->|未耗尽| B5 输出与结果状态
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 ID | SRS 功能名 | 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-001 | 8 个标准阶段定义 | 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 或患者 id | F-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.ErrInvalidCredentials | 401 | 三端登录统一(邮箱不存在/密码错/禁用/role 不符/码错——防枚举) |
ErrTOTPAlreadyEnabled | 409 | setup 时已 ENABLED |
ErrNotConfigured(jwt.secret 空) | 401(受保护面)/ 503(guest-session) | 可选缺省降级 |
| 限流超窗 | 429 | gateway/ratelimit.go:84 |
patient / chat / ticket
| 哨兵错误 | 状态码 | 场景 |
|---|---|---|
ErrEmailTaken | 409 | 注册/建号邮箱唯一 |
ErrDocOwnership | 409 | 注册文档归属对账 affected 不符 |
patient.ErrNotFound | 404 | /pt/me 无账号 |
chat.ErrNotFound | 404 | 会话/消息不可见(含跨 gsid 统一不可见) |
chat.ErrSessionBound | 409 | 绑定后 guest 语义作废 / 绑定竞态 |
ErrNotRetranslatab | 400 | DONE/NONE 重译拒绝 |
ticket.ErrDocQuota | 409 | 第 11 份文档 |
ticket.ErrInvalidTransition | 409 | A 轴白名单外 |
ticket.ErrInvalidStageTransition | 409 | C 轴回退/跳段 |
ticket.ErrVersionConflict | 409 | CAS 命中 0 行(流转/分配/写列/墓碑) |
ticket.ErrNotFound / ErrForbidden | 404 / 403 | 文档不可见 / 归属谓词不过 |
ticket.ErrDocGroupConflict | 409 | 版本替换并发 |
hospital / diagnosis / payment / care / trip / user / notify
| 哨兵错误 | 状态码 | 场景 |
|---|---|---|
hospital.ErrNotFound(含公开面非 PUBLISHED) | 404 | 不存在与运营中不可区分 |
hospital.ErrInvalidHospitalTransition | 409 | 三同构状态机白名单外 |
hospital.ErrVersionConflict | 409 | 编辑/流转 CAS |
hospital.ErrBadImageMime | 400 | 封面图 mime 前置 |
ErrExpertNotFound | 404 | 医生选人查无此专家(M10 核查 A3 收口) |
ValidateExpertSelection 失败 | 409 | 专家非 PUBLISHED/院区不符 |
payment.ErrOrderNotFound | 404 | 支付单不存在/归属不符(同语义) |
payment.ErrTicketNotFound | 404 | 患者无工单 |
payment.ErrNotPayable | 409 | 工单不在 PENDING_PAYMENT |
payment.ErrBadSignature | 400 | webhook 验签失败 |
payment.ErrReceiptNotReady | 409 | 非 SUCCEEDED/能力未装配 |
payment.ErrReasonRequired | 400 | 关单无 reason |
payment.ErrInvalidOrderTransition | 409 | 支付轴白名单外 |
care.ErrNotInTreatment | 409 | C 轴推进前置不满足 |
care.ErrDocGroupInvalid | 400 | 组 0/组不属于本单 |
trip.ErrTicketNotFound | 404 | 挂空单 |
trip.ErrInvalidTripTransition | 409 | 出行轴白名单外 |
user.ErrEmailTaken 等 | 409/400 | 建号冲突/校验 |
patient.ErrNotPatient | 409 | GDPR 抹除目标非患者 |
storage ErrBadMIME/ErrTooLarge/os.ErrNotExist | 400/404 | confirm 第二道/对象缺失 |
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 ⑬ 放宽口径 |