feat(handler): 暴露健康档案 upsert 接口(T-204)
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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"}}}`
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"}}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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 的持久化幂等和补偿任务。
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 · 其余业务线查询(读先行)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user