跳转到主要内容

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

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

m02 功能节目录

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

F2.01 公开医院列表

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

1 功能定义

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

2 触发条件与前置状态

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

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

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

4 处理流程
5 输出与结果状态

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

6 状态流转

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

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

F2.02 公开医院详情

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

1 功能定义

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

2 触发条件与前置状态

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

3 输入与校验
参数类型校验
:idpath int64>0,解析失败 400
4 处理流程
5 输出与结果状态

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

6 状态流转

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

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

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


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

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

1 功能定义

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

2 触发条件与前置状态
  • 建院/编辑/流转:hospital:manage 权限(仅 ADMIN,见总则 §2.1 矩阵)。
  • 上架前置:富文本三列与封面图可空(运营后补——建院即可流转 PUBLISHED)。
3 输入与校验
端点字段校验
POST /api/hospitalsname_zh 必填;name_en/grade/city/address/dept_summary/reason;intro_intl/services/visit_process *string 可选,提交即落库(M14 DIF-F2-3 修复前 handler 不收三列致表单值静默丢弃)name_zh 空 → 400
PUT /api/hospitals/:id同上 + version 必填;富文本三列 *string 三态:缺省(null/不传)=保留现值、空串=显式置空、有值=更新(SQL COALESCE(?, col) 兜底);封面图不进本端点(走 F2.04 三步协议)version CAS 不符 → 409
POST /api/hospitals/:id/statusfrom/to/reason 三必填白名单外迁移 → 409;from 与库内现状不符 → 409
4 处理流程
5 输出与结果状态

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

6 状态流转

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

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

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

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

1 功能定义

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

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

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

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

3 输入与校验
端点字段校验
POST …/image/presignmimeIsImageMime 白名单前置(非 image/* → 400 ErrBadImageMime);5MB 上限由 PresignPut policy 执行
POST …/image/confirmkey非空;HeadObject 复核实际 size/mime(第二道拦截)
GET /pub/v1/hospitals/:id/image:id>0;须 PUBLISHED 且 image_object_key 非空(否则 404);免 JWT 仅限流
GET /api/hospitals/:id/image:id>0;image_object_key 非空(否则 404);不限状态
4 处理流程

回显读侧(M15):

5 输出与结果状态

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

6 状态流转

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

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

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


F2.05 科室管理

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

1 功能定义

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

2 触发条件与前置状态

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

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

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

5 输出与结果状态

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

6 状态流转

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

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

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


F2.06 专家库管理

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

1 功能定义

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

2 触发条件与前置状态

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

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

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

5 输出与结果状态

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

6 状态流转

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

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