# FSD m02 · 医院主数据与展示（hospital）

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

---

> 本册覆盖代码域 `internal/domain/hospital`。全局规则见 [00-总则](/prd/fsd-medilink/00-%E6%80%BB%E5%88%99/) §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 处理流程

```mermaid
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 处理流程

```mermaid
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 处理流程

```mermaid
sequenceDiagram
    participant W as web 医院管理页
    participant H as hospital.Handler(BO)
    participant S as hospital.Service
    participant DB as MySQL
    W->>H: POST /api/hospitals {name_zh,…}
    H->>S: Create
    S->>DB: INSERT hospital(status='DRAFT', version=1)
    S->>DB: op_log(hospital.create)（失败仅 warn）
    H-->>W: 201 {id}
    W->>H: PUT /api/hospitals/:id {…, version}
    H->>S: Update（非状态列 version CAS）
    S->>DB: UPDATE … WHERE id=? AND version=?（命中 0 行→409）
    H-->>W: 204
    W->>H: POST /api/hospitals/:id/status {from,to,reason}
    H->>S: TransitionStatus（白名单前置）
    S->>DB: WithTx: status 谓词 CAS → lifecycle + op_log 同事务
    H-->>W: 204 / 409
```

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

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

##### 6 状态流转

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

```mermaid
stateDiagram-v2
    DRAFT --> PUBLISHED: 上架
    PUBLISHED --> OFFLINE: 下架
    OFFLINE --> PUBLISHED: 重新上架
```

##### 7 边界与异常

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

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

- `hospital:manage`（路由级 RequirePermission）；op_log 带 actor_id/actor_role/reason。
- `reason` 建院/编辑可不传、状态流转必填（敏感操作审计纪律）。

---

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

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

##### 1 功能定义

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

回显读侧（M15）为**后端代理**（否决 presigned GET：localfs 驱动无直链能力且 nginx 不暴露 MinIO 端点，代理沿 DownloadDocument 先例）：
- pub `GET /pub/v1/hospitals/:id/image`——**免 JWT**（仅享 `/pub` 组限流）：`<img src>` 带不了 Authorization 头；PUBLISHED-only 谓词（DRAFT/OFFLINE/未上传/不存在一律 404，存在性不泄露）。
- B 端 `GET /api/hospitals/:id/image`——JWT + `hospital:manage`，**不限状态**（运营上传后 DRAFT 期即可在 Modal 验证）。
- 公开详情（F2.02）派生 `image_url` 相对路径，前端按空串判断是否渲染。

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

`hospital:manage`；医院行已存在（confirm 按 id 回填）。

##### 3 输入与校验

| 端点 | 字段 | 校验 |
|---|---|---|
| POST .../image/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 处理流程

```mermaid
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）：

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

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

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

##### 6 状态流转

无状态变更（`image_object_key` 非状态列；version CAS 防并发覆盖）。

##### 7 边界与异常

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

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

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

---

#### F2.05 科室管理

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

##### 1 功能定义

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

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

`hospital:manage`；科室状态随院区独立流转（科室可先于院区 PUBLISHED）。

##### 3 输入与校验

| 端点 | 字段 | 校验 |
|---|---|---|
| POST /api/hospitals/:id/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 归属谓词） |
| ~~简介编辑不可改（原 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`。

---

反链：

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