feat(handler): 暴露健康档案 upsert 接口(T-204)

This commit is contained in:
ila
2026-07-16 02:00:56 +08:00
parent 3a30ebdc45
commit 48263d8b9a
16 changed files with 852 additions and 44 deletions
+2
View File
@@ -161,6 +161,8 @@ type ManageInfo struct { DSFMC, OperateUnit, OperateUser string }
T-213 已落地可复用的 `HealthRecordUpsertService`。它接收 `source.HealthRecordTask`,通过注入的 `Converter`、`HealthRecordClient`、`IdempotencyStore`、`ReportSink` 和 `PHISStatusWriter` 完成单条健康档案编排;HTTP handler 与未来 worker 必须复用该服务,不复制分支规则。 T-213 已落地可复用的 `HealthRecordUpsertService`。它接收 `source.HealthRecordTask`,通过注入的 `Converter`、`HealthRecordClient`、`IdempotencyStore`、`ReportSink` 和 `PHISStatusWriter` 完成单条健康档案编排;HTTP handler 与未来 worker 必须复用该服务,不复制分支规则。
T-204 已通过 `POST /api/health-record/upsert` 接入该服务。HTTP 运行时先执行无网络字段/码表预校验,再按请求中的 `manaUnitId` 加载责任医生和机构字典,映射层仍执行真实主数据 membership 校验;同步接口使用进程内 `MemoryIdempotencyStore` 只防同进程并发,成功后释放租约,避免稳定 `checkId` 阻断后续合法更新。HTTP 模式不执行 PHIS 回调,调用方直接消费结构化 outcome;持久化幂等和真实 PHIS 状态回写仍分别由阶段 3、T-214 承接。
健康档案采用 query-first:映射校验通过后,以稳定 `checkId + idCard` 的哈希键原子获取幂等租约,再按身份证调用 JKDA00002。明确 0 条才调用 JKDA00001;恰好 1 条且身份证、状态、机构、责任医生、`phrId` 均安全时调用 JKDA00003;多条或任一授权条件不满足均转 `manual_review`。查询失败、创建失败、更新失败均不切换另一种写操作。 健康档案采用 query-first:映射校验通过后,以稳定 `checkId + idCard` 的哈希键原子获取幂等租约,再按身份证调用 JKDA00002。明确 0 条才调用 JKDA00001;恰好 1 条且身份证、状态、机构、责任医生、`phrId` 均安全时调用 JKDA00003;多条或任一授权条件不满足均转 `manual_review`。查询失败、创建失败、更新失败均不切换另一种写操作。
当前幂等接口是带 owner token 的 `Acquire/Complete/Release` 契约,完成和释放必须匹配租约 token,防止过期 worker 改写新租约;T-213 只用假实现验证占用和完成语义,Redis/文件持久化、租约超时与恢复属于阶段 3。报告与 PHIS 状态同样仅定义端口,真实回写由 T-214 实现。CHIS 已明确写入成功后,必须先完成幂等记录再发布 `done`;完成失败时保留业务结果 `done`、返回基础设施错误且不发送成功通知,调用方不得据此重放 CHIS 写入。副作用失败通过 `NotificationError` 分别暴露 `PriorErr/ReportErr/StatusErr`,并只携带失败端的补偿载荷,补偿时不重新执行 upsert。 当前幂等接口是带 owner token 的 `Acquire/Complete/Release` 契约,完成和释放必须匹配租约 token,防止过期 worker 改写新租约;T-213 只用假实现验证占用和完成语义,Redis/文件持久化、租约超时与恢复属于阶段 3。报告与 PHIS 状态同样仅定义端口,真实回写由 T-214 实现。CHIS 已明确写入成功后,必须先完成幂等记录再发布 `done`;完成失败时保留业务结果 `done`、返回基础设施错误且不发送成功通知,调用方不得据此重放 CHIS 写入。副作用失败通过 `NotificationError` 分别暴露 `PriorErr/ReportErr/StatusErr`,并只携带失败端的补偿载荷,补偿时不重新执行 upsert。
+37 -4
View File
@@ -1,6 +1,6 @@
# 07 · 本项目 HTTP 接口 # 07 · 本项目 HTTP 接口
`chis_osi` **server 模式**对外提供的 HTTP 查询接口——供前端/运维/PHIS 侧调用。 `chis_osi` **server 模式**对外提供的 HTTP 查询与健康档案 upsert 接口——供前端/运维/PHIS 侧调用。
> 这是本项目**自己**对外的接口的单一事实来源;调用**上游厂家 OSI** 的接口契约看 `docs/01`、`docs/04`。 > 这是本项目**自己**对外的接口的单一事实来源;调用**上游厂家 OSI** 的接口契约看 `docs/01`、`docs/04`。
> 新增/修改端点后同步本文(与 `handler/`、`server.go` 保持一致)。 > 新增/修改端点后同步本文(与 `handler/`、`server.go` 保持一致)。
@@ -25,8 +25,9 @@ go run . -mode server -addr 127.0.0.1:9000 # 指定地址
| 项 | 说明 | | 项 | 说明 |
| --- | --- | | --- | --- |
| 方法 | 一律 `GET`,查询条件走 query string | | 查询方法 | `GET`,查询条件走 query string |
| 成功响应 | **直接回写平台完整 JSON**(`{code,message,data}`,不裁字段),`Content-Type: application/json` | | Upsert 方法 | `POST`,请求体为 PHIS 健康档案响应信封,最大 1 MiB |
| 查询成功响应 | **直接回写平台完整 JSON**(`{code,message,data}`,不裁字段),`Content-Type: application/json` |
| 平台成功码 | `code="01"`(字符串,见 docs/01 §1)——本服务不改写,原样透传 | | 平台成功码 | `code="01"`(字符串,见 docs/01 §1)——本服务不改写,原样透传 |
| 参数错误 | `400`,体为 `{"error":"..."}`(如缺必填标识符) | | 参数错误 | `400`,体为 `{"error":"..."}`(如缺必填标识符) |
| 上游失败/网络错误 | `502`,体为 `{"error":"..."}` | | 上游失败/网络错误 | `502`,体为 `{"error":"..."}` |
@@ -34,6 +35,8 @@ go run . -mode server -addr 127.0.0.1:9000 # 指定地址
> 设计取舍:端点**原样回写平台响应**(同 `Result.Raw`),不做字段裁剪/转换——保证平台未建模字段也能拿到,便于查看完整档案/体检。 > 设计取舍:端点**原样回写平台响应**(同 `Result.Raw`),不做字段裁剪/转换——保证平台未建模字段也能拿到,便于查看完整档案/体检。
健康档案 upsert 不透传 OSI 原文,只返回脱敏的结构化结果。其运行时先做无网络字段/码表预校验,再按档案 `manaUnitId` 查询责任医生和机构主数据,最后调用 T-213 的 query-first 应用服务。当前 HTTP 模式只使用**进程内并发租约**:可防同进程同时投递,成功后立即释放,后续版本仍会重新查询并更新;它不提供跨重启完成记录,持久化幂等仍属于路线图阶段 3。
--- ---
## 端点清单 ## 端点清单
@@ -183,6 +186,35 @@ GET /api/elderly/self-care
- 上游:`LNRZLPG00002` `/auto/lnr/query`(厂家文档契约,待真实联调确认) - 上游:`LNRZLPG00002` `/auto/lnr/query`(厂家文档契约,待真实联调确认)
- 返回:`data` 为**数组**,每条含进餐/梳洗/穿衣/如厕/活动及总评的原始值、等级和评分;完整字段原样回写 - 返回:`data` 为**数组**,每条含进餐/梳洗/穿衣/如厕/活动及总评的原始值、等级和评分;完整字段原样回写
### 11. 创建或更新个人健康档案
```
POST /api/health-record/upsert
Content-Type: application/json
```
请求体使用 PHIS 健康档案响应信封:顶层 `code` 必须为 `200`,`data.archId` 必填;`data.doctor` 和 `data.record` 字段结构见 `source.HealthRecordTask`。医生账号、密码即使出现在原始 JSON 中也会被解码器忽略,不进入领域模型和 OSI 请求。
处理规则:按身份证查询 CHIS,明确 0 条时创建,1 条且身份证、状态、机构、责任医生和 `phrId` 均安全时更新;多条或不安全目标转人工处理。查询失败绝不降级创建。
| HTTP 状态 | pipeline 状态 | 说明 |
| --- | --- | --- |
| `200` | `done` | 创建、更新或幂等跳过完成 |
| `409` | `manual_review` | 多档案、跨机构、跨医生、状态不可更新等,禁止自动写入 |
| `422` | `failed` | PHIS 字段、必填、格式、码表或主数据校验失败 |
| `502` | `failed` 或内部错误 | CHIS/字典上游失败,或写入成功后的本地收尾失败 |
| `503` | `retry` | 查询/写入暂时失败或同一档案正在处理 |
正常响应示例:
```json
{"status":"done","action":"update","serviceId":"JKDA00003","responseCode":"01","phrIdHint":"****1234"}
```
内部错误响应包含 `retrySafe`。若 CHIS 已明确写入成功但幂等完成或通知失败,会保留 `outcome.status="done"` 且返回 `retrySafe=false`,调用方不得重放整个 upsert,只能按错误补偿后置动作。
> 当前未完成 T-215 真实写入验收。没有安全测试档案和明确写入授权时,不得用真实居民调用此端点。
--- ---
## 调用示例 ## 调用示例
@@ -198,12 +230,13 @@ curl "http://127.0.0.1:8080/api/dictionaries/doctors?manaUnitId=<机构码>&oper
curl "http://127.0.0.1:8080/api/dictionaries/drugs?pageNo=1&pageSize=10&ypmc=<药品名>&pym=<拼音码>" # pageNo 必填 curl "http://127.0.0.1:8080/api/dictionaries/drugs?pageNo=1&pageSize=10&ypmc=<药品名>&pym=<拼音码>" # pageNo 必填
curl "http://127.0.0.1:8080/api/dictionaries/orgs?organizCode=<机构码>&parentId=<上级机构ID>" curl "http://127.0.0.1:8080/api/dictionaries/orgs?organizCode=<机构码>&parentId=<上级机构ID>"
curl "http://127.0.0.1:8080/api/elderly/self-care?idCard=<身份证>&phrId=<档案号>&checkId=<检查主键>" curl "http://127.0.0.1:8080/api/elderly/self-care?idCard=<身份证>&phrId=<档案号>&checkId=<检查主键>"
curl -X POST "http://127.0.0.1:8080/api/health-record/upsert" -H "Content-Type: application/json" --data-binary "@<phis-health-record.json>"
``` ```
--- ---
## 尚未提供(后续) ## 尚未提供(后续)
- 写入类端点(档案/体检 create/update):`handler` 侧 `/api/health-record/save` 等待 T-204/T-206 及厂家写入授权。 - 体检等其他业务写入端点:待对应 PHIS 转换、upsert 编排及厂家写入授权。
- 老年人中医体质辨识、中医指导查询端点:待对应 OSI serviceId/字段契约(docs/06 B4)。 - 老年人中医体质辨识、中医指导查询端点:待对应 OSI serviceId/字段契约(docs/06 B4)。
- 鉴权、访问日志、限流:当前是本机联调工具形态,对外前必须补齐。 - 鉴权、访问日志、限流:当前是本机联调工具形态,对外前必须补齐。
+10 -7
View File
@@ -6,11 +6,11 @@
## 当前快照 ## 当前快照
- 日期:2026-07-16 - 日期:2026-07-16
- 阶段:**T-212 转换器与 T-213 健康档案 upsert 应用编排已完成;下一步 T-204 暴露 HTTP upsert,真实写入验收由 T-215 单独受阻**;T-303 老年人生活自理能力评估查询代码完成、真实联调受阻 - 阶段:**T-212 转换器、T-213 upsert 编排和 T-204 HTTP 适配已完成;健康档案本地写入链路就绪,真实写入验收由 T-215 单独受阻**;T-303 老年人生活自理能力评估查询代码完成、真实联调受阻
- 技术栈:Go 1.24 单二进制;`main.go -mode server|deliver`;配置读取使用 viper,支持环境变量覆盖;OSI 客户端已具备签名、信封、传输、基础判码、JKDA00002 Find、JKDA00005 FindRqbj(人群分类,已按实测 auto 路径校准)、JKDA00001 Create、JKDA00003 Update、LNRZLPG00002 老年人生活自理能力查询(待真实联调),以及 WGDZ/ZRYS/YPML/CXJG 四个公开查询薄封装 - 技术栈:Go 1.24 单二进制;`main.go -mode server|deliver`;配置读取使用 viper,支持环境变量覆盖;OSI 客户端已具备签名、信封、传输、基础判码、JKDA00002 Find、JKDA00005 FindRqbj(人群分类,已按实测 auto 路径校准)、JKDA00001 Create、JKDA00003 Update、LNRZLPG00002 老年人生活自理能力查询(待真实联调),以及 WGDZ/ZRYS/YPML/CXJG 四个公开查询薄封装
- 生产代码:`source/health_record.go` 承接 PHIS 档案 DTO 并剥离医生凭据;`mapping/health_record.go` 把 PHIS 主体、既往史和生活环境转换为 JKDA 写入请求;`pipeline/health_record_upsert.go` 已实现可注入、可被 HTTP/worker 复用的 query-first upsert,覆盖原子幂等占用、创建/更新/人工处理/失败分类和脱敏事件 - 生产代码:`source/health_record.go` 承接 PHIS 档案 DTO 并剥离医生凭据;`mapping/health_record.go` 转换 JKDA 写入请求;`pipeline/health_record_upsert.go` 实现 query-first upsert;`POST /api/health-record/upsert` 接收 PHIS 信封,运行时加载医生/机构主数据并复用同一服务,返回脱敏结构化 outcome
- 联调现实:**JKDA00002 个人档案查询已用 Go 侧真实请求打通**,返回 `code="01" message="操作成功" data_count=1`;**公开查询 WGDZ00001/ZRYS00001/CXJG00002 已用真实档案主数据验证通过**,均返回 `code="01"` 且数组非空;药品目录 YPML00001 已完成客户端封装和单测,尚未做真实药品关键字样本验证;**JKDA00001 create 未跑真实请求**,避免在没有安全测试居民/写入授权时污染平台档案 - 联调现实:**JKDA00002 个人档案查询已用 Go 侧真实请求打通**,返回 `code="01" message="操作成功" data_count=1`;**公开查询 WGDZ00001/ZRYS00001/CXJG00002 已用真实档案主数据验证通过**,均返回 `code="01"` 且数组非空;药品目录 YPML00001 已完成客户端封装和单测,尚未做真实药品关键字样本验证;**JKDA00001 create 未跑真实请求**,避免在没有安全测试居民/写入授权时污染平台档案
- 测试:`go test ./...` 通过;当前测试覆盖 mode 解析、配置加载与环境变量覆盖、MD5 签名、请求头组装、JSON POST 传输、头名大小写保留、identity 响应编码声明、超时配置、SOCKS5 代理地址校验、信封结构、serviceId 路由、成功/重试判码、Client.Call 请求与响应解析、JKDA00002 查询响应契约、JKDA00001/00003 创建更新请求契约与客户端方法、JKDA00002 Find、JKDA00005 FindRqbj(personSign/idCard/phrId)、公开查询四接口、公开查询 HTTP API、字典缓存快照与持久化失败不阻断、映射码表双向查找、民族 01~56 完整性、健康档案映射必填/码表校验、主数据名称反查、创建请求组装、checkId 确定性、docx/联调风格映射样本基线、JKDA00002 验证入口 - 测试:`go test ./...` 通过;除既有查询、契约、映射与 OSI 客户端测试外,已覆盖 upsert create/update/manual_review/retry/failed/error HTTP 映射、成功后收尾错误的 `retrySafe=false`、路由注册、运行时主数据加载,以及进程内租约 acquire/release/complete/stale token
- 标准启动路径:`./init.sh` 已配置三步:依赖下载、`go test ./...`、`go run . -mode server -config config.yaml.example` - 标准启动路径:`./init.sh` 已配置三步:依赖下载、`go test ./...`、`go run . -mode server -config config.yaml.example`
- 标准验证路径:`go test ./...`、`go build ./...` - 标准验证路径:`go test ./...`、`go build ./...`
- 当前 blocker:T-215 真实 create/update 验收缺少可写入沙箱的安全测试居民资料或明确写入授权;PHIS 源键已在 T-212 固化为 `archId`,`addressNumber`、`createDate` 和逐档案 `operateUser` 仍需 T-215 写入后回查确认。软限制:当前机器从 Git Bash 启动 Go 会出现标准库路径/构建缓存权限异常,`init.sh` 无法完整跑完;PowerShell 下等价 Go 命令和真实查询通过。厂家侧 B1/B2/B4/B5 契约缺口只影响阶段 4 - 当前 blocker:T-215 真实 create/update 验收缺少可写入沙箱的安全测试居民资料或明确写入授权;PHIS 源键已在 T-212 固化为 `archId`,`addressNumber`、`createDate` 和逐档案 `operateUser` 仍需 T-215 写入后回查确认。软限制:当前机器从 Git Bash 启动 Go 会出现标准库路径/构建缓存权限异常,`init.sh` 无法完整跑完;PowerShell 下等价 Go 命令和真实查询通过。厂家侧 B1/B2/B4/B5 契约缺口只影响阶段 4
@@ -46,6 +46,9 @@
| `handler/public.go` | 已有 | 公开查询端点 `GET /api/dictionaries/grid-addresses`、`/doctors`、`/drugs`、`/orgs` | | `handler/public.go` | 已有 | 公开查询端点 `GET /api/dictionaries/grid-addresses`、`/doctors`、`/drugs`、`/orgs` |
| `server.go` `osi_client.go` | 已有 | server 模式起 HTTP 服务、注册路由;`buildOSIClient` 共用构造(verify 与 server 复用) | | `server.go` `osi_client.go` | 已有 | server 模式起 HTTP 服务、注册路由;`buildOSIClient` 共用构造(verify 与 server 复用) |
| `pipeline/health_record_upsert.go` | 已有 | T-213 query-first upsert 应用服务;外部客户端、原子幂等、报告与 PHIS 状态均接口注入,持久化待阶段 3 | | `pipeline/health_record_upsert.go` | 已有 | T-213 query-first upsert 应用服务;外部客户端、原子幂等、报告与 PHIS 状态均接口注入,持久化待阶段 3 |
| `handler/health_record_upsert.go` | 已有 | `POST /api/health-record/upsert` 薄适配;1 MiB 限制、PHIS 信封解码、状态码映射、内部错误脱敏 |
| `health_record_upsert_runtime.go` | 已有 | server 模式加载责任医生/机构字典并组装 T-213 服务;HTTP 模式不执行 PHIS 回调 |
| `pipeline/memory_idempotency.go` | 已有 | T-204 同步 HTTP 的进程内 owner-token 并发租约;成功即释放,不缓存 completed,非阶段 3 持久化实现 |
## 已验证事实(写代码时直接依赖) ## 已验证事实(写代码时直接依赖)
@@ -91,6 +94,7 @@ curl "http://127.0.0.1:8080/api/dictionaries/grid-addresses?parentCode=<区划
curl "http://127.0.0.1:8080/api/dictionaries/doctors?manaUnitId=<机构码>" # 责任医生 curl "http://127.0.0.1:8080/api/dictionaries/doctors?manaUnitId=<机构码>" # 责任医生
curl "http://127.0.0.1:8080/api/dictionaries/drugs?pageNo=1&ypmc=<药品名>" # 药品目录(pageNo 必填) curl "http://127.0.0.1:8080/api/dictionaries/drugs?pageNo=1&ypmc=<药品名>" # 药品目录(pageNo 必填)
curl "http://127.0.0.1:8080/api/dictionaries/orgs?parentId=<上级机构ID>" # 机构查询 curl "http://127.0.0.1:8080/api/dictionaries/orgs?parentId=<上级机构ID>" # 机构查询
curl -X POST "http://127.0.0.1:8080/api/health-record/upsert" -H "Content-Type: application/json" --data-binary "@<phis-health-record.json>" # 未获写入授权时禁止用真实居民
# 本地 Python 联调查询(脚本硬编码凭据,勿提交) # 本地 Python 联调查询(脚本硬编码凭据,勿提交)
python3 scripts/query_health_record.py python3 scripts/query_health_record.py
@@ -100,10 +104,9 @@ python3 scripts/query_health_record.py
> 2026-07-15 已把本地能力与真实验收拆开:T-206 标记本地 DONE,T-215 承接授权后的真实写入验收。 > 2026-07-15 已把本地能力与真实验收拆开:T-206 标记本地 DONE,T-215 承接授权后的真实写入验收。
1. **T-213**:用假 OSI、幂等、report、PHIS 回写接口完成 upsert 应用编排;不等待写入授权。 1. 并行催厂家:写入授权与可写测试档案、更新目标标识、逐档案 `operateUser` 最终规则。
2. **T-204**:复用 T-213 暴露 `POST /api/health-record/upsert`,handler 不复制业务逻辑。 2. 授权到位后执行 T-215 真实 create→query→update→query 验收。
3. 并行催厂家:写入授权与可写测试档案、更新目标标识、逐档案 `operateUser` 最终规则。 3. 若授权继续等待,则把路线图阶段 3 的持久化幂等、报告和补偿调度拆成独立任务后实施。
4. 授权到位后执行 T-215 真实 create→query→update→query 验收。
## 维护规则 ## 维护规则
+176 -3
View File
@@ -1,8 +1,8 @@
openapi: 3.0.3 openapi: 3.0.3
info: info:
title: chis_osi Server API title: chis_osi Server API
version: 0.1.0 version: 0.2.0
description: 'chis_osi server 模式对外提供的本机 HTTP 查询接口。 description: 'chis_osi server 模式对外提供的本机 HTTP 查询与健康档案 upsert 接口。
本文档描述的是本项目自己的 HTTP API,不是上游 CHIS OSI 平台接口。 本文档描述的是本项目自己的 HTTP API,不是上游 CHIS OSI 平台接口。
@@ -21,7 +21,7 @@ servers:
description: 本地默认 server 模式地址 description: 本地默认 server 模式地址
tags: tags:
- name: health-record - name: health-record
description: 健康档案查询 description: 健康档案查询与 upsert
- name: health-check - name: health-check
description: 健康体检查询 description: 健康体检查询
- name: dictionaries - name: dictionaries
@@ -29,6 +29,72 @@ tags:
- name: elderly - name: elderly
description: 老年人业务查询 description: 老年人业务查询
paths: paths:
/api/health-record/upsert:
post:
tags:
- health-record
summary: 创建或更新个人健康档案
description: '接收 PHIS 健康档案响应信封,复用 query-first upsert 应用服务。
只有 JKDA00002 明确返回非 nil 空数组时才创建;单条安全目标更新,多条或跨机构等转人工。
当前使用成功即释放的进程内并发租约,不保存跨请求完成记录;真实写入验收仍由 T-215 阻断。
'
operationId: upsertHealthRecord
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/PHISHealthRecordEnvelope'
responses:
'200':
description: 创建、更新或幂等跳过完成。
content:
application/json:
schema:
$ref: '#/components/schemas/HealthRecordUpsertOutcome'
'400':
$ref: '#/components/responses/BadRequest'
'409':
description: 目标档案必须人工处理,未执行写入。
content:
application/json:
schema:
$ref: '#/components/schemas/HealthRecordUpsertOutcome'
'413':
description: 请求体超过 1 MiB。
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'422':
description: PHIS 字段、格式、码表或主数据校验失败。
content:
application/json:
schema:
$ref: '#/components/schemas/HealthRecordUpsertOutcome'
'502':
description: CHIS/字典上游失败或 upsert 后置动作失败。
content:
application/json:
schema:
oneOf:
- $ref: '#/components/schemas/HealthRecordUpsertOutcome'
- $ref: '#/components/schemas/HealthRecordUpsertError'
'503':
description: 暂时失败可重试,或同一档案正在处理中。
content:
application/json:
schema:
$ref: '#/components/schemas/HealthRecordUpsertOutcome'
'405':
description: 仅支持 POST。
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/health-record/find: /api/health-record/find:
get: get:
tags: tags:
@@ -585,6 +651,113 @@ components:
value: value:
error: empty response from OSI error: empty response from OSI
schemas: schemas:
PHISHealthRecordEnvelope:
type: object
required:
- code
- data
properties:
code:
type: integer
enum:
- 200
msg:
type: string
compress:
type: boolean
data:
type: object
required:
- archId
- record
properties:
archId:
type: string
description: PHIS 健康档案稳定源主键。
businessId:
type: string
description: PHIS 业务追踪/回写标识,不参与 checkId 主键选择。
empiId:
type: string
phrId:
type: string
doctor:
type: object
properties:
doctorId:
type: string
realName:
type: string
additionalProperties: true
description: sxtAccount/sxtPassword 等凭据会被服务忽略。
record:
type: object
required:
- idCard
- manaDoctorId
- manaUnitId
- regionCode
properties:
idCard:
type: string
description: 身份证件号,普通日志不得记录原文。
personName:
type: string
manaDoctorId:
type: string
manaUnitId:
type: string
regionCode:
type: string
additionalProperties: true
description: 完整 PHIS record 字段及映射规则见 docs/04 §14。
additionalProperties: false
HealthRecordUpsertOutcome:
type: object
required:
- status
- action
properties:
status:
type: string
enum:
- done
- retry
- failed
- manual_review
action:
type: string
enum:
- none
- create
- update
- skip
reason:
type: string
serviceId:
type: string
example: JKDA00003
responseCode:
type: string
example: '01'
phrIdHint:
type: string
description: 脱敏档案编号,仅保留末四位。
example: '****1234'
HealthRecordUpsertError:
type: object
required:
- error
- retrySafe
properties:
error:
type: string
example: health record upsert failed
retrySafe:
type: boolean
description: false 表示 CHIS 可能已完成写入,禁止重放整个 upsert。
outcome:
$ref: '#/components/schemas/HealthRecordUpsertOutcome'
ErrorResponse: ErrorResponse:
type: object type: object
required: required:
+96
View File
@@ -0,0 +1,96 @@
package handler
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"chis_osi/pipeline"
"chis_osi/source"
)
const maxHealthRecordUpsertBody = 1 << 20
type HealthRecordUpserter interface {
Upsert(context.Context, source.HealthRecordTask) (pipeline.Outcome, error)
}
type HealthRecordUpsertHandler struct {
upserter HealthRecordUpserter
}
func NewHealthRecordUpsertHandler(upserter HealthRecordUpserter) *HealthRecordUpsertHandler {
return &HealthRecordUpsertHandler{upserter: upserter}
}
func (h *HealthRecordUpsertHandler) Upsert(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", http.MethodPost)
writeJSONError(w, http.StatusMethodNotAllowed, "only POST is supported")
return
}
if h.upserter == nil {
writeJSONError(w, http.StatusServiceUnavailable, "health record upsert is unavailable")
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxHealthRecordUpsertBody)
raw, err := io.ReadAll(r.Body)
if err != nil {
var maxBytesErr *http.MaxBytesError
if errors.As(err, &maxBytesErr) {
writeJSONError(w, http.StatusRequestEntityTooLarge, "request body exceeds 1 MiB")
return
}
writeJSONError(w, http.StatusBadRequest, "read request body failed")
return
}
task, err := source.DecodeHealthRecordTask(raw)
if err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid PHIS health record envelope")
return
}
outcome, err := h.upserter.Upsert(r.Context(), task)
if err != nil {
response := healthRecordUpsertErrorResponse{Error: "health record upsert failed", RetrySafe: outcome.Status == ""}
if outcome.Status != "" {
response.Outcome = &outcome
}
writeJSON(w, http.StatusBadGateway, response)
return
}
writeJSON(w, outcomeHTTPStatus(outcome), outcome)
}
type healthRecordUpsertErrorResponse struct {
Error string `json:"error"`
RetrySafe bool `json:"retrySafe"`
Outcome *pipeline.Outcome `json:"outcome,omitempty"`
}
func outcomeHTTPStatus(outcome pipeline.Outcome) int {
switch outcome.Status {
case pipeline.StatusDone:
return http.StatusOK
case pipeline.StatusManualReview:
return http.StatusConflict
case pipeline.StatusRetry:
return http.StatusServiceUnavailable
case pipeline.StatusFailed:
if outcome.ServiceID != "" {
return http.StatusBadGateway
}
return http.StatusUnprocessableEntity
default:
return http.StatusInternalServerError
}
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
+114
View File
@@ -0,0 +1,114 @@
package handler
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"chis_osi/pipeline"
"chis_osi/source"
)
func TestHealthRecordUpsertReturnsStructuredOutcome(t *testing.T) {
tests := []struct {
name string
outcome pipeline.Outcome
wantStatus int
}{
{name: "create", outcome: pipeline.Outcome{Status: pipeline.StatusDone, Action: pipeline.ActionCreate, ServiceID: "JKDA00001", ResponseCode: "01", PHRIDHint: "****0001"}, wantStatus: http.StatusOK},
{name: "update", outcome: pipeline.Outcome{Status: pipeline.StatusDone, Action: pipeline.ActionUpdate, ServiceID: "JKDA00003", ResponseCode: "01", PHRIDHint: "****0001"}, wantStatus: http.StatusOK},
{name: "manual review", outcome: pipeline.Outcome{Status: pipeline.StatusManualReview, Action: pipeline.ActionNone, Reason: "multiple CHIS health records found"}, wantStatus: http.StatusConflict},
{name: "retry", outcome: pipeline.Outcome{Status: pipeline.StatusRetry, Action: pipeline.ActionNone, Reason: "CHIS query failed"}, wantStatus: http.StatusServiceUnavailable},
{name: "validation failed", outcome: pipeline.Outcome{Status: pipeline.StatusFailed, Action: pipeline.ActionNone, Reason: "mapping validation failed: idCard"}, wantStatus: http.StatusUnprocessableEntity},
{name: "upstream failed", outcome: pipeline.Outcome{Status: pipeline.StatusFailed, Action: pipeline.ActionCreate, Reason: "CHIS create failed", ServiceID: "JKDA00001"}, wantStatus: http.StatusBadGateway},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
upserter := &fakeHealthRecordUpserter{outcome: tt.outcome}
h := NewHealthRecordUpsertHandler(upserter)
req := httptest.NewRequest(http.MethodPost, "/api/health-record/upsert", strings.NewReader(testPHISEnvelope()))
rec := httptest.NewRecorder()
h.Upsert(rec, req)
if rec.Code != tt.wantStatus {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, tt.wantStatus, rec.Body.String())
}
var got pipeline.Outcome
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("decode outcome: %v; body=%s", err, rec.Body.String())
}
if got.Status != tt.outcome.Status || got.Action != tt.outcome.Action || upserter.task.ArchID != "ARCH-HTTP-1" {
t.Fatalf("outcome=%#v task=%#v", got, upserter.task)
}
})
}
}
func TestHealthRecordUpsertHidesInternalError(t *testing.T) {
h := NewHealthRecordUpsertHandler(&fakeHealthRecordUpserter{err: errors.New("proxy secret detail")})
req := httptest.NewRequest(http.MethodPost, "/api/health-record/upsert", strings.NewReader(testPHISEnvelope()))
rec := httptest.NewRecorder()
h.Upsert(rec, req)
if rec.Code != http.StatusBadGateway || strings.Contains(rec.Body.String(), "secret") {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestHealthRecordUpsertPreservesDoneOutcomeOnFinalizationError(t *testing.T) {
h := NewHealthRecordUpsertHandler(&fakeHealthRecordUpserter{
outcome: pipeline.Outcome{Status: pipeline.StatusDone, Action: pipeline.ActionCreate, ServiceID: "JKDA00001", ResponseCode: "01"},
err: errors.New("idempotency complete failed"),
})
req := httptest.NewRequest(http.MethodPost, "/api/health-record/upsert", strings.NewReader(testPHISEnvelope()))
rec := httptest.NewRecorder()
h.Upsert(rec, req)
if rec.Code != http.StatusBadGateway || !strings.Contains(rec.Body.String(), `"retrySafe":false`) || !strings.Contains(rec.Body.String(), `"status":"done"`) {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestHealthRecordUpsertRejectsInvalidEnvelopeAndMethod(t *testing.T) {
t.Run("invalid envelope", func(t *testing.T) {
h := NewHealthRecordUpsertHandler(&fakeHealthRecordUpserter{})
req := httptest.NewRequest(http.MethodPost, "/api/health-record/upsert", strings.NewReader(`{"code":500,"msg":"failed"}`))
rec := httptest.NewRecorder()
h.Upsert(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
})
t.Run("method", func(t *testing.T) {
h := NewHealthRecordUpsertHandler(nil)
req := httptest.NewRequest(http.MethodGet, "/api/health-record/upsert", nil)
rec := httptest.NewRecorder()
h.Upsert(rec, req)
if rec.Code != http.StatusMethodNotAllowed || rec.Header().Get("Allow") != http.MethodPost {
t.Fatalf("status=%d allow=%q", rec.Code, rec.Header().Get("Allow"))
}
})
}
type fakeHealthRecordUpserter struct {
outcome pipeline.Outcome
err error
task source.HealthRecordTask
}
func (f *fakeHealthRecordUpserter) Upsert(_ context.Context, task source.HealthRecordTask) (pipeline.Outcome, error) {
f.task = task
return f.outcome, f.err
}
func testPHISEnvelope() string {
return `{"code":200,"msg":"ok","compress":false,"data":{"archId":"ARCH-HTTP-1","businessId":"BUS-HTTP-1","record":{"idCard":"440000********1234"}}}`
}
+80
View File
@@ -0,0 +1,80 @@
package main
import (
"context"
"fmt"
"chis_osi/cache"
"chis_osi/contract"
"chis_osi/mapping"
"chis_osi/osi"
"chis_osi/pipeline"
"chis_osi/source"
)
type healthRecordRuntimeClient interface {
QueryDoctors(context.Context, osi.DoctorQuery) ([]osi.Doctor, osi.Result, error)
QueryOrgs(context.Context, osi.OrgQuery) ([]osi.Org, osi.Result, error)
FindHealthRecord(context.Context, osi.FindHealthRecordQuery) ([]contract.FindHealthRecord, osi.Result, error)
CreateHealthRecord(context.Context, contract.HealthRecordCreate) (contract.HealthRecordSaveResult, osi.Result, error)
UpdateHealthRecord(context.Context, contract.HealthRecordCreate) (contract.HealthRecordSaveResult, osi.Result, error)
}
type serverHealthRecordUpserter struct {
client healthRecordRuntimeClient
idempotency *pipeline.MemoryIdempotencyStore
}
func newServerHealthRecordUpserter(client healthRecordRuntimeClient) *serverHealthRecordUpserter {
return &serverHealthRecordUpserter{client: client, idempotency: pipeline.NewMemoryIdempotencyStore()}
}
func (u *serverHealthRecordUpserter) Upsert(ctx context.Context, task source.HealthRecordTask) (pipeline.Outcome, error) {
if validationErrors := mapping.ValidateHealthRecordTask(task); len(validationErrors) > 0 {
return pipeline.Outcome{
Status: pipeline.StatusFailed,
Action: pipeline.ActionNone,
Reason: "mapping validation failed: " + validationErrors[0].Field,
}, nil
}
doctors, result, err := u.client.QueryDoctors(ctx, osi.DoctorQuery{
ManaUnitID: task.Record.ManaUnitID,
OperateUser: task.Record.ManaDoctorID,
})
if err != nil || !result.Success {
return pipeline.Outcome{}, fmt.Errorf("load CHIS doctor dictionary: %w", firstRuntimeError(err, result))
}
orgs, result, err := u.client.QueryOrgs(ctx, osi.OrgQuery{
OrganizCode: task.Record.ManaUnitID,
ParentID: "",
})
if err != nil || !result.Success {
return pipeline.Outcome{}, fmt.Errorf("load CHIS organization dictionary: %w", firstRuntimeError(err, result))
}
snapshot := cache.NewDictionarySnapshot(nil, doctors, orgs)
service := pipeline.NewHealthRecordUpsertService(pipeline.Dependencies{
Converter: pipeline.ConverterFunc(func(task source.HealthRecordTask) (contract.HealthRecordCreate, []mapping.ValidationError) {
return mapping.MapHealthRecordTask(task, mapping.MapContext{Dicts: mapping.DefaultDictSnapshot(), MasterData: snapshot})
}),
Client: u.client,
Idempotency: u.idempotency,
Reports: discardUpsertReports{},
PHISStatuses: discardPHISStatuses{},
})
return service.Upsert(ctx, task)
}
func firstRuntimeError(err error, result osi.Result) error {
if err != nil {
return err
}
return fmt.Errorf("code=%s message=%s", result.Code, result.Message)
}
type discardUpsertReports struct{}
func (discardUpsertReports) Publish(context.Context, pipeline.UpsertEvent) error { return nil }
type discardPHISStatuses struct{}
func (discardPHISStatuses) WriteStatus(context.Context, pipeline.PHISStatusUpdate) error { return nil }
+124
View File
@@ -0,0 +1,124 @@
package main
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"chis_osi/contract"
"chis_osi/osi"
"chis_osi/pipeline"
"chis_osi/source"
)
func TestServerHealthRecordUpserterLoadsMasterDataAndCreates(t *testing.T) {
task := loadRuntimeHealthRecordTask(t)
client := &fakeRuntimeHealthRecordClient{
doctors: []osi.Doctor{{PersonID: task.Record.ManaDoctorID, PersonName: "测试医生"}},
orgs: []osi.Org{{OrganizCode: task.Record.ManaUnitID, OrganizName: "测试机构"}},
records: []contract.FindHealthRecord{},
}
upserter := newServerHealthRecordUpserter(client)
outcome, err := upserter.Upsert(context.Background(), task)
if err != nil || outcome.Status != pipeline.StatusDone || outcome.Action != pipeline.ActionCreate {
t.Fatalf("outcome=%#v err=%v", outcome, err)
}
if client.doctorQueries != 1 || client.orgQueries != 1 || client.createCalls != 1 {
t.Fatalf("doctorQueries=%d orgQueries=%d create=%d", client.doctorQueries, client.orgQueries, client.createCalls)
}
}
func TestServerHealthRecordUpserterStopsWhenMasterDataFails(t *testing.T) {
task := loadRuntimeHealthRecordTask(t)
client := &fakeRuntimeHealthRecordClient{masterErr: errors.New("dictionary unavailable")}
upserter := newServerHealthRecordUpserter(client)
_, err := upserter.Upsert(context.Background(), task)
if err == nil || client.findCalls != 0 || client.createCalls != 0 {
t.Fatalf("err=%v find=%d create=%d", err, client.findCalls, client.createCalls)
}
}
func TestServerHealthRecordUpserterValidatesBeforeMasterDataCalls(t *testing.T) {
client := &fakeRuntimeHealthRecordClient{masterErr: errors.New("must not be called")}
upserter := newServerHealthRecordUpserter(client)
outcome, err := upserter.Upsert(context.Background(), source.HealthRecordTask{ArchID: "ARCH-INVALID"})
if err != nil || outcome.Status != pipeline.StatusFailed || client.doctorQueries != 0 || client.orgQueries != 0 {
t.Fatalf("outcome=%#v err=%v doctorQueries=%d orgQueries=%d", outcome, err, client.doctorQueries, client.orgQueries)
}
}
func TestServerMuxRegistersHealthRecordUpsert(t *testing.T) {
upserter := &fakeServerUpserter{outcome: pipeline.Outcome{Status: pipeline.StatusDone, Action: pipeline.ActionCreate}}
mux := newServerMux(nil, upserter)
raw, err := os.ReadFile("source/testdata/health_record.json")
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/health-record/upsert", strings.NewReader(string(raw)))
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK || upserter.calls != 1 {
t.Fatalf("status=%d calls=%d body=%s", rec.Code, upserter.calls, rec.Body.String())
}
}
func loadRuntimeHealthRecordTask(t *testing.T) source.HealthRecordTask {
t.Helper()
raw, err := os.ReadFile("source/testdata/health_record.json")
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
task, err := source.DecodeHealthRecordTask(raw)
if err != nil {
t.Fatalf("DecodeHealthRecordTask: %v", err)
}
return task
}
type fakeRuntimeHealthRecordClient struct {
doctors []osi.Doctor
orgs []osi.Org
records []contract.FindHealthRecord
masterErr error
doctorQueries, orgQueries int
findCalls, createCalls int
}
func (f *fakeRuntimeHealthRecordClient) QueryDoctors(context.Context, osi.DoctorQuery) ([]osi.Doctor, osi.Result, error) {
f.doctorQueries++
return f.doctors, osi.Result{Success: f.masterErr == nil, Code: "01"}, f.masterErr
}
func (f *fakeRuntimeHealthRecordClient) QueryOrgs(context.Context, osi.OrgQuery) ([]osi.Org, osi.Result, error) {
f.orgQueries++
return f.orgs, osi.Result{Success: f.masterErr == nil, Code: "01"}, f.masterErr
}
func (f *fakeRuntimeHealthRecordClient) FindHealthRecord(context.Context, osi.FindHealthRecordQuery) ([]contract.FindHealthRecord, osi.Result, error) {
f.findCalls++
return f.records, osi.Result{Success: true, Code: "01"}, nil
}
func (f *fakeRuntimeHealthRecordClient) CreateHealthRecord(context.Context, contract.HealthRecordCreate) (contract.HealthRecordSaveResult, osi.Result, error) {
f.createCalls++
return contract.HealthRecordSaveResult{PhrID: "PHR-RUNTIME"}, osi.Result{Success: true, Code: "01"}, nil
}
func (f *fakeRuntimeHealthRecordClient) UpdateHealthRecord(context.Context, contract.HealthRecordCreate) (contract.HealthRecordSaveResult, osi.Result, error) {
return contract.HealthRecordSaveResult{}, osi.Result{Success: true, Code: "01"}, nil
}
type fakeServerUpserter struct {
outcome pipeline.Outcome
calls int
}
func (f *fakeServerUpserter) Upsert(context.Context, source.HealthRecordTask) (pipeline.Outcome, error) {
f.calls++
return f.outcome, nil
}
+28
View File
@@ -246,6 +246,34 @@ func MapHealthRecordTask(task source.HealthRecordTask, ctx MapContext) (contract
return BuildHealthRecordCreate(mapped), errs return BuildHealthRecordCreate(mapped), errs
} }
// ValidateHealthRecordTask performs all local mapping validation without
// claiming that the supplied doctor or organization exists in CHIS.
func ValidateHealthRecordTask(task source.HealthRecordTask) []ValidationError {
_, errs := MapHealthRecordTask(task, MapContext{
Dicts: DefaultDictSnapshot(),
MasterData: submittedHealthRecordMasterData{
doctorID: task.Record.ManaDoctorID,
unitID: task.Record.ManaUnitID,
},
})
return errs
}
type submittedHealthRecordMasterData struct {
doctorID string
unitID string
}
func (m submittedHealthRecordMasterData) RegionCodeByName(string) (string, bool) { return "", false }
func (m submittedHealthRecordMasterData) DoctorIDByName(string) (string, bool) { return "", false }
func (m submittedHealthRecordMasterData) ManaUnitIDByName(string) (string, bool) { return "", false }
func (m submittedHealthRecordMasterData) DoctorIDExists(id string) bool {
return strings.TrimSpace(id) != "" && strings.TrimSpace(id) == strings.TrimSpace(m.doctorID)
}
func (m submittedHealthRecordMasterData) ManaUnitIDExists(id string) bool {
return strings.TrimSpace(id) != "" && strings.TrimSpace(id) == strings.TrimSpace(m.unitID)
}
func validateMasterData(src PHISHealthRecord, lookup MasterDataLookup) []ValidationError { func validateMasterData(src PHISHealthRecord, lookup MasterDataLookup) []ValidationError {
if lookup == nil { if lookup == nil {
return []ValidationError{{Field: "masterData", Reason: "CHIS dictionary snapshot is required"}} return []ValidationError{{Field: "masterData", Reason: "CHIS dictionary snapshot is required"}}
+19 -8
View File
@@ -6,6 +6,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
"time"
"chis_osi/contract" "chis_osi/contract"
"chis_osi/mapping" "chis_osi/mapping"
@@ -32,12 +33,12 @@ const (
) )
type Outcome struct { type Outcome struct {
Status Status Status Status `json:"status"`
Action Action Action Action `json:"action"`
Reason string Reason string `json:"reason,omitempty"`
ServiceID string ServiceID string `json:"serviceId,omitempty"`
ResponseCode string ResponseCode string `json:"responseCode,omitempty"`
PHRIDHint string PHRIDHint string `json:"phrIdHint,omitempty"`
} }
type UpsertEvent struct { type UpsertEvent struct {
@@ -135,6 +136,8 @@ type HealthRecordUpsertService struct {
deps Dependencies deps Dependencies
} }
const idempotencyCleanupTimeout = 5 * time.Second
func NewHealthRecordUpsertService(deps Dependencies) *HealthRecordUpsertService { func NewHealthRecordUpsertService(deps Dependencies) *HealthRecordUpsertService {
return &HealthRecordUpsertService{deps: deps} return &HealthRecordUpsertService{deps: deps}
} }
@@ -216,14 +219,22 @@ func (s *HealthRecordUpsertService) update(ctx context.Context, task source.Heal
} }
func (s *HealthRecordUpsertService) complete(ctx context.Context, task source.HealthRecordTask, key, token string, outcome Outcome) (Outcome, error) { func (s *HealthRecordUpsertService) complete(ctx context.Context, task source.HealthRecordTask, key, token string, outcome Outcome) (Outcome, error) {
if err := s.deps.Idempotency.Complete(ctx, key, token); err != nil { cleanupCtx, cancel := idempotencyCleanupContext(ctx)
defer cancel()
if err := s.deps.Idempotency.Complete(cleanupCtx, key, token); err != nil {
return outcome, fmt.Errorf("persist successful CHIS write before notification: %w", err) return outcome, fmt.Errorf("persist successful CHIS write before notification: %w", err)
} }
return s.finish(ctx, task, outcome) return s.finish(ctx, task, outcome)
} }
func (s *HealthRecordUpsertService) finishAcquired(ctx context.Context, task source.HealthRecordTask, key, token string, outcome Outcome) (Outcome, error) { func (s *HealthRecordUpsertService) finishAcquired(ctx context.Context, task source.HealthRecordTask, key, token string, outcome Outcome) (Outcome, error) {
return s.finish(ctx, task, outcome, s.deps.Idempotency.Release(ctx, key, token)) cleanupCtx, cancel := idempotencyCleanupContext(ctx)
defer cancel()
return s.finish(ctx, task, outcome, s.deps.Idempotency.Release(cleanupCtx, key, token))
}
func idempotencyCleanupContext(ctx context.Context) (context.Context, context.CancelFunc) {
return context.WithTimeout(context.WithoutCancel(ctx), idempotencyCleanupTimeout)
} }
func (s *HealthRecordUpsertService) finish(ctx context.Context, task source.HealthRecordTask, outcome Outcome, prior ...error) (Outcome, error) { func (s *HealthRecordUpsertService) finish(ctx context.Context, task source.HealthRecordTask, outcome Outcome, prior ...error) (Outcome, error) {
+8 -5
View File
@@ -22,8 +22,8 @@ func TestHealthRecordUpsertCreatesWhenQueryIsEmpty(t *testing.T) {
if err != nil || outcome.Status != StatusDone || outcome.Action != ActionCreate { if err != nil || outcome.Status != StatusDone || outcome.Action != ActionCreate {
t.Fatalf("outcome=%#v err=%v", outcome, err) t.Fatalf("outcome=%#v err=%v", outcome, err)
} }
if client.findCalls != 1 || client.createCalls != 1 || client.updateCalls != 0 || !store.marked { if client.findCalls != 1 || client.createCalls != 1 || client.updateCalls != 0 || !store.marked || !store.completeHasDeadline {
t.Fatalf("calls find=%d create=%d update=%d marked=%v", client.findCalls, client.createCalls, client.updateCalls, store.marked) t.Fatalf("calls find=%d create=%d update=%d marked=%v deadline=%v", client.findCalls, client.createCalls, client.updateCalls, store.marked, store.completeHasDeadline)
} }
assertNotifications(t, reports, statuses, StatusDone, ActionCreate) assertNotifications(t, reports, statuses, StatusDone, ActionCreate)
} }
@@ -86,7 +86,7 @@ func TestHealthRecordUpsertNeverCreatesAfterQueryFailure(t *testing.T) {
if retryable { if retryable {
want = StatusRetry want = StatusRetry
} }
if err != nil || outcome.Status != want || client.createCalls != 0 || client.updateCalls != 0 || !store.released { if err != nil || outcome.Status != want || client.createCalls != 0 || client.updateCalls != 0 || !store.released || !store.releaseHasDeadline {
t.Fatalf("retryable=%v outcome=%#v err=%v", retryable, outcome, err) t.Fatalf("retryable=%v outcome=%#v err=%v", retryable, outcome, err)
} }
} }
@@ -247,6 +247,7 @@ func (f *fakeHealthRecordClient) UpdateHealthRecord(_ context.Context, req contr
type fakeIdempotencyStore struct { type fakeIdempotencyStore struct {
completed, inProgress, marked, released bool completed, inProgress, marked, released bool
completeHasDeadline, releaseHasDeadline bool
completeErr error completeErr error
} }
@@ -259,14 +260,16 @@ func (f *fakeIdempotencyStore) Acquire(context.Context, string) (IdempotencyLeas
} }
return IdempotencyLease{State: IdempotencyAcquired, Token: "lease-1"}, nil return IdempotencyLease{State: IdempotencyAcquired, Token: "lease-1"}, nil
} }
func (f *fakeIdempotencyStore) Complete(_ context.Context, _, token string) error { func (f *fakeIdempotencyStore) Complete(ctx context.Context, _, token string) error {
_, f.completeHasDeadline = ctx.Deadline()
if token != "lease-1" { if token != "lease-1" {
return errors.New("stale lease") return errors.New("stale lease")
} }
f.marked = true f.marked = true
return f.completeErr return f.completeErr
} }
func (f *fakeIdempotencyStore) Release(_ context.Context, _, token string) error { func (f *fakeIdempotencyStore) Release(ctx context.Context, _, token string) error {
_, f.releaseHasDeadline = ctx.Deadline()
if token != "lease-1" { if token != "lease-1" {
return errors.New("stale lease") return errors.New("stale lease")
} }
+73
View File
@@ -0,0 +1,73 @@
package pipeline
import (
"context"
"fmt"
"sync"
"sync/atomic"
)
type memoryIdempotencyEntry struct {
state IdempotencyState
token string
}
// MemoryIdempotencyStore only protects concurrent deliveries in one process.
// Complete releases the lease so a later version with the same stable checkId can update.
type MemoryIdempotencyStore struct {
mu sync.Mutex
entries map[string]memoryIdempotencyEntry
next atomic.Uint64
}
func NewMemoryIdempotencyStore() *MemoryIdempotencyStore {
return &MemoryIdempotencyStore{entries: make(map[string]memoryIdempotencyEntry)}
}
func (s *MemoryIdempotencyStore) Acquire(ctx context.Context, key string) (IdempotencyLease, error) {
if err := ctx.Err(); err != nil {
return IdempotencyLease{}, err
}
if key == "" {
return IdempotencyLease{}, fmt.Errorf("idempotency key is required")
}
s.mu.Lock()
defer s.mu.Unlock()
if entry, ok := s.entries[key]; ok {
if entry.state == IdempotencyAcquired {
return IdempotencyLease{State: IdempotencyInProgress}, nil
}
return IdempotencyLease{State: entry.state}, nil
}
token := fmt.Sprintf("lease-%d", s.next.Add(1))
s.entries[key] = memoryIdempotencyEntry{state: IdempotencyAcquired, token: token}
return IdempotencyLease{State: IdempotencyAcquired, Token: token}, nil
}
func (s *MemoryIdempotencyStore) Complete(ctx context.Context, key, token string) error {
if err := ctx.Err(); err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
entry, ok := s.entries[key]
if !ok || entry.state != IdempotencyAcquired || entry.token != token {
return fmt.Errorf("idempotency lease is missing or stale")
}
delete(s.entries, key)
return nil
}
func (s *MemoryIdempotencyStore) Release(ctx context.Context, key, token string) error {
if err := ctx.Err(); err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
entry, ok := s.entries[key]
if !ok || entry.state != IdempotencyAcquired || entry.token != token {
return fmt.Errorf("idempotency lease is missing or stale")
}
delete(s.entries, key)
return nil
}
+47
View File
@@ -0,0 +1,47 @@
package pipeline
import (
"context"
"testing"
)
func TestMemoryIdempotencyStoreLeaseLifecycle(t *testing.T) {
store := NewMemoryIdempotencyStore()
ctx := context.Background()
first, err := store.Acquire(ctx, "key-1")
if err != nil || first.State != IdempotencyAcquired || first.Token == "" {
t.Fatalf("first lease=%#v err=%v", first, err)
}
second, err := store.Acquire(ctx, "key-1")
if err != nil || second.State != IdempotencyInProgress {
t.Fatalf("second lease=%#v err=%v", second, err)
}
if err := store.Release(ctx, "key-1", "stale-token"); err == nil {
t.Fatal("stale release succeeded")
}
if err := store.Release(ctx, "key-1", first.Token); err != nil {
t.Fatalf("release: %v", err)
}
reacquired, err := store.Acquire(ctx, "key-1")
if err != nil || reacquired.State != IdempotencyAcquired || reacquired.Token == first.Token {
t.Fatalf("reacquired lease=%#v err=%v", reacquired, err)
}
if err := store.Complete(ctx, "key-1", reacquired.Token); err != nil {
t.Fatalf("complete: %v", err)
}
afterComplete, err := store.Acquire(ctx, "key-1")
if err != nil || afterComplete.State != IdempotencyAcquired || afterComplete.Token == reacquired.Token {
t.Fatalf("lease after complete=%#v err=%v", afterComplete, err)
}
}
func TestMemoryIdempotencyStoreHonorsCanceledContext(t *testing.T) {
store := NewMemoryIdempotencyStore()
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := store.Acquire(ctx, "key-1"); err == nil {
t.Fatal("Acquire accepted canceled context")
}
}
+11
View File
@@ -361,3 +361,14 @@
- 安全与边界:普通报告事件只含哈希 `trace_id`、业务状态、serviceId、响应码及脱敏 `phrId`,原始源标识只交给受控 PHIS 回写端口;`data:null`/缺失不得视为 0 条;幂等完成失败不发送 `done`,副作用失败通过 `NotificationError` 区分租约/报告/PHIS 回写错误并只暴露失败端补偿数据。持久化、重试调度、HTTP 和 PHIS 真实回写不在 T-213 实现。 - 安全与边界:普通报告事件只含哈希 `trace_id`、业务状态、serviceId、响应码及脱敏 `phrId`,原始源标识只交给受控 PHIS 回写端口;`data:null`/缺失不得视为 0 条;幂等完成失败不发送 `done`,副作用失败通过 `NotificationError` 区分租约/报告/PHIS 回写错误并只暴露失败端补偿数据。持久化、重试调度、HTTP 和 PHIS 真实回写不在 T-213 实现。
- 验证:`go test ./pipeline -count=1`、`go test ./... -count=1`、`go build ./...`、`go vet ./...`、`git diff --check` 均通过。`bash ./init.sh` 在当前 PowerShell 调起的 Bash 环境失败(`go: command not found`),未误报通过;其内部等价 Go 命令已在 PowerShell 单独验证。 - 验证:`go test ./pipeline -count=1`、`go test ./... -count=1`、`go build ./...`、`go vet ./...`、`git diff --check` 均通过。`bash ./init.sh` 在当前 PowerShell 调起的 Bash 环境失败(`go: command not found`),未误报通过;其内部等价 Go 命令已在 PowerShell 单独验证。
- 下一步:T-204 `POST /api/health-record/upsert` HTTP 适配;T-215 继续等待安全测试档案和写入授权。 - 下一步:T-204 `POST /api/health-record/upsert` HTTP 适配;T-215 继续等待安全测试档案和写入授权。
## 2026-07-16 T-204 健康档案 upsert HTTP 适配
- 状态:DONE(真实 curl 写入验收仍归 T-215,未使用真实居民调用)。
- RED:`go test ./handler -run HealthRecordUpsert -count=1` 初次因 `NewHealthRecordUpsertHandler` 不存在而编译失败;`go test ./pipeline -run MemoryIdempotency -count=1` 初次因无进程内租约实现失败;根包运行时测试初次因 `newServerHealthRecordUpserter/newServerMux` 不存在失败。
- GREEN:新增 `POST /api/health-record/upsert`,接收最大 1 MiB 的 PHIS 健康档案响应信封,复用 T-213 服务并按 `done/manual_review/retry/failed` 返回 200/409/503/422 或上游 502;内部错误不回显代理/平台细节。
- 运行时:先做无网络字段/码表预校验,再按 `manaUnitId` 查询责任医生和机构字典并注入 mapping;新增 owner-token 进程内并发租约,防同进程并发和 stale token,成功后释放而不缓存 completed,避免稳定 `checkId` 阻断后续更新;HTTP 模式不做 PHIS 状态回调。
- 安全:若 CHIS 已成功但幂等完成/通知失败,502 响应保留 `outcome.status=done` 且 `retrySafe=false`,调用方不得重放整个 upsert;普通响应只含脱敏 `phrIdHint`。
- 文档:更新 `docs/03`、`docs/07` 和 `docs/openapi.yaml`,OpenAPI 版本升至 0.2.0,增加 PHIS 请求信封、outcome、错误与 HTTP 状态定义。
- 验证:`go test ./... -count=1`、`go build ./...`、`go vet ./...`、OpenAPI YAML 解析与端点断言、`git diff --check` 均通过;`go test -race ./pipeline ./handler -count=1` 因当前 Go 环境未启用 CGO 无法执行;`bash ./init.sh` 在当前 Bash 环境因找不到 `go` 失败,其内部等价 Go 命令已在 PowerShell 验证,均未误报通过。
- 下一步:取得安全写入授权后做 T-215;否则拆分路线图阶段 3 的持久化幂等和补偿任务。
+26 -16
View File
@@ -6,32 +6,20 @@ import (
"chis_osi/config" "chis_osi/config"
"chis_osi/handler" "chis_osi/handler"
"chis_osi/osi"
) )
// runServer 启动 server 模式 HTTP 服务,暴露查询端点。 // runServer 启动 server 模式 HTTP 服务。
func runServer(cfg config.Config, addr string) error { func runServer(cfg config.Config, addr string) error {
client, err := buildOSIClient(cfg) client, err := buildOSIClient(cfg)
if err != nil { if err != nil {
return fmt.Errorf("build osi client: %w", err) return fmt.Errorf("build osi client: %w", err)
} }
hr := handler.NewHealthRecordHandler(client) mux := newServerMux(client, newServerHealthRecordUpserter(client))
hc := handler.NewHealthCheckHandler(client)
elderly := handler.NewElderlyHandler(client)
pub := handler.NewPublicHandler(client)
mux := http.NewServeMux()
mux.HandleFunc("/api/health-record/find", hr.Find)
mux.HandleFunc("/api/health-record/crowd", hr.Crowd)
mux.HandleFunc("/api/health-check/last", hc.Last)
mux.HandleFunc("/api/health-check/all", hc.All)
mux.HandleFunc("/api/health-check/list", hc.List)
mux.HandleFunc("/api/elderly/self-care", elderly.SelfCare)
mux.HandleFunc("/api/dictionaries/grid-addresses", pub.GridAddresses)
mux.HandleFunc("/api/dictionaries/doctors", pub.Doctors)
mux.HandleFunc("/api/dictionaries/drugs", pub.Drugs)
mux.HandleFunc("/api/dictionaries/orgs", pub.Orgs)
fmt.Printf("chis_osi server listening on %s\n", addr) fmt.Printf("chis_osi server listening on %s\n", addr)
fmt.Printf(" POST http://%s/api/health-record/upsert\n", addr)
fmt.Printf(" GET http://%s/api/health-record/find?idCard=<身份证>\n", addr) fmt.Printf(" GET http://%s/api/health-record/find?idCard=<身份证>\n", addr)
fmt.Printf(" GET http://%s/api/health-record/crowd?idCard=<身份证>\n", addr) fmt.Printf(" GET http://%s/api/health-record/crowd?idCard=<身份证>\n", addr)
fmt.Printf(" GET http://%s/api/health-check/last?idCard=<身份证>\n", addr) fmt.Printf(" GET http://%s/api/health-check/last?idCard=<身份证>\n", addr)
@@ -46,3 +34,25 @@ func runServer(cfg config.Config, addr string) error {
server := &http.Server{Addr: addr, Handler: mux} server := &http.Server{Addr: addr, Handler: mux}
return server.ListenAndServe() return server.ListenAndServe()
} }
func newServerMux(client *osi.Client, upserter handler.HealthRecordUpserter) *http.ServeMux {
hr := handler.NewHealthRecordHandler(client)
hu := handler.NewHealthRecordUpsertHandler(upserter)
hc := handler.NewHealthCheckHandler(client)
elderly := handler.NewElderlyHandler(client)
pub := handler.NewPublicHandler(client)
mux := http.NewServeMux()
mux.HandleFunc("/api/health-record/upsert", hu.Upsert)
mux.HandleFunc("/api/health-record/find", hr.Find)
mux.HandleFunc("/api/health-record/crowd", hr.Crowd)
mux.HandleFunc("/api/health-check/last", hc.Last)
mux.HandleFunc("/api/health-check/all", hc.All)
mux.HandleFunc("/api/health-check/list", hc.List)
mux.HandleFunc("/api/elderly/self-care", elderly.SelfCare)
mux.HandleFunc("/api/dictionaries/grid-addresses", pub.GridAddresses)
mux.HandleFunc("/api/dictionaries/doctors", pub.Doctors)
mux.HandleFunc("/api/dictionaries/drugs", pub.Drugs)
mux.HandleFunc("/api/dictionaries/orgs", pub.Orgs)
return mux
}
+1 -1
View File
@@ -68,7 +68,7 @@
| T-210 | 公开查询 HTTP API(人群分类、网格地址、责任医生、药品目录、机构) | T-101, T-209 | server 模式暴露人群分类与四类公开查询 HTTP API;原样回写平台 JSON;同步 `docs/07` 与 `docs/openapi.yaml`;按内网部署场景明确绑定地址/鉴权边界 | DONE | | T-210 | 公开查询 HTTP API(人群分类、网格地址、责任医生、药品目录、机构) | T-101, T-209 | server 模式暴露人群分类与四类公开查询 HTTP API;原样回写平台 JSON;同步 `docs/07` 与 `docs/openapi.yaml`;按内网部署场景明确绑定地址/鉴权边界 | DONE |
| T-211 | 药品目录查询按分页契约修正 YPML00001 | T-210 | 依 docx:`pageNo` 必填(去 `omitempty`+handler 缺则 400)、补 `pageSize`;`docs/07 §8`+`openapi.yaml` 标 pageNo 必填并补 pageSize;docx 契约未联调,注明待厂家样本核对(尤其 pageNo 是否真必填、响应 `ypxh/ypjl/ycjl` 字段)| DONE | | T-211 | 药品目录查询按分页契约修正 YPML00001 | T-210 | 依 docx:`pageNo` 必填(去 `omitempty`+handler 缺则 400)、补 `pageSize`;`docs/07 §8`+`openapi.yaml` 标 pageNo 必填并补 pageSize;docx 契约未联调,注明待厂家样本核对(尤其 pageNo 是否真必填、响应 `ypxh/ypjl/ycjl` 字段)| DONE |
| T-206 | `osi/jkda.go`:**Create/Update 本地能力** + `contract/jkda.go` 写入请求结构体 | T-201, T-202, T-103 | JKDA00001/00003 serviceId、路径、uploadinfo 同级节点、响应解码和映射组装均有单测;`go test ./contract ./osi ./mapping` 通过 | DONE(真实写入验收拆至 T-215) | | T-206 | `osi/jkda.go`:**Create/Update 本地能力** + `contract/jkda.go` 写入请求结构体 | T-201, T-202, T-103 | JKDA00001/00003 serviceId、路径、uploadinfo 同级节点、响应解码和映射组装均有单测;`go test ./contract ./osi ./mapping` 通过 | DONE(真实写入验收拆至 T-215) |
| T-204 | `handler`+`router`:`POST /api/health-record/upsert` | T-213 | server 模式接收 PHIS 档案 DTO,调用同一 upsert 应用服务并返回结构化结果;handler 假依赖测试覆盖 create/update/manual_review/error,默认仍只绑定本机 | TODO(真实 curl 验收待 T-215) | | T-204 | `handler`+`router`:`POST /api/health-record/upsert` | T-213 | server 模式接收 PHIS 档案 DTO,调用同一 upsert 应用服务并返回结构化结果;handler 假依赖测试覆盖 create/update/manual_review/error,默认仍只绑定本机 | DONE(真实 curl 验收待 T-215) |
## Phase Q2 · 其余业务线查询(读先行) ## Phase Q2 · 其余业务线查询(读先行)