diff --git a/docs/03-目标架构设计.md b/docs/03-目标架构设计.md index 4865a99..fb3b662 100644 --- a/docs/03-目标架构设计.md +++ b/docs/03-目标架构设计.md @@ -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-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`。查询失败、创建失败、更新失败均不切换另一种写操作。 当前幂等接口是带 owner token 的 `Acquire/Complete/Release` 契约,完成和释放必须匹配租约 token,防止过期 worker 改写新租约;T-213 只用假实现验证占用和完成语义,Redis/文件持久化、租约超时与恢复属于阶段 3。报告与 PHIS 状态同样仅定义端口,真实回写由 T-214 实现。CHIS 已明确写入成功后,必须先完成幂等记录再发布 `done`;完成失败时保留业务结果 `done`、返回基础设施错误且不发送成功通知,调用方不得据此重放 CHIS 写入。副作用失败通过 `NotificationError` 分别暴露 `PriorErr/ReportErr/StatusErr`,并只携带失败端的补偿载荷,补偿时不重新执行 upsert。 diff --git a/docs/07-本项目HTTP接口.md b/docs/07-本项目HTTP接口.md index 84c6d61..6e7cfcf 100644 --- a/docs/07-本项目HTTP接口.md +++ b/docs/07-本项目HTTP接口.md @@ -1,6 +1,6 @@ # 07 · 本项目 HTTP 接口 -`chis_osi` **server 模式**对外提供的 HTTP 查询接口——供前端/运维/PHIS 侧调用。 +`chis_osi` **server 模式**对外提供的 HTTP 查询与健康档案 upsert 接口——供前端/运维/PHIS 侧调用。 > 这是本项目**自己**对外的接口的单一事实来源;调用**上游厂家 OSI** 的接口契约看 `docs/01`、`docs/04`。 > 新增/修改端点后同步本文(与 `handler/`、`server.go` 保持一致)。 @@ -25,8 +25,9 @@ go run . -mode server -addr 127.0.0.1:9000 # 指定地址 | 项 | 说明 | | --- | --- | -| 方法 | 一律 `GET`,查询条件走 query string | -| 成功响应 | **直接回写平台完整 JSON**(`{code,message,data}`,不裁字段),`Content-Type: application/json` | +| 查询方法 | `GET`,查询条件走 query string | +| Upsert 方法 | `POST`,请求体为 PHIS 健康档案响应信封,最大 1 MiB | +| 查询成功响应 | **直接回写平台完整 JSON**(`{code,message,data}`,不裁字段),`Content-Type: application/json` | | 平台成功码 | `code="01"`(字符串,见 docs/01 §1)——本服务不改写,原样透传 | | 参数错误 | `400`,体为 `{"error":"..."}`(如缺必填标识符) | | 上游失败/网络错误 | `502`,体为 `{"error":"..."}` | @@ -34,6 +35,8 @@ go run . -mode server -addr 127.0.0.1:9000 # 指定地址 > 设计取舍:端点**原样回写平台响应**(同 `Result.Raw`),不做字段裁剪/转换——保证平台未建模字段也能拿到,便于查看完整档案/体检。 +健康档案 upsert 不透传 OSI 原文,只返回脱敏的结构化结果。其运行时先做无网络字段/码表预校验,再按档案 `manaUnitId` 查询责任医生和机构主数据,最后调用 T-213 的 query-first 应用服务。当前 HTTP 模式只使用**进程内并发租约**:可防同进程同时投递,成功后立即释放,后续版本仍会重新查询并更新;它不提供跨重启完成记录,持久化幂等仍属于路线图阶段 3。 + --- ## 端点清单 @@ -183,6 +186,35 @@ GET /api/elderly/self-care - 上游:`LNRZLPG00002` `/auto/lnr/query`(厂家文档契约,待真实联调确认) - 返回:`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/orgs?organizCode=<机构码>&parentId=<上级机构ID>" 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 "@" ``` --- ## 尚未提供(后续) -- 写入类端点(档案/体检 create/update):`handler` 侧 `/api/health-record/save` 等待 T-204/T-206 及厂家写入授权。 +- 体检等其他业务写入端点:待对应 PHIS 转换、upsert 编排及厂家写入授权。 - 老年人中医体质辨识、中医指导查询端点:待对应 OSI serviceId/字段契约(docs/06 B4)。 - 鉴权、访问日志、限流:当前是本机联调工具形态,对外前必须补齐。 diff --git a/docs/current-state.md b/docs/current-state.md index 81f55a8..1f2d292 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -6,11 +6,11 @@ ## 当前快照 - 日期: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 四个公开查询薄封装 -- 生产代码:`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 未跑真实请求**,避免在没有安全测试居民/写入授权时污染平台档案 -- 测试:`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` - 标准验证路径:`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 @@ -46,6 +46,9 @@ | `handler/public.go` | 已有 | 公开查询端点 `GET /api/dictionaries/grid-addresses`、`/doctors`、`/drugs`、`/orgs` | | `server.go` `osi_client.go` | 已有 | server 模式起 HTTP 服务、注册路由;`buildOSIClient` 共用构造(verify 与 server 复用) | | `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/drugs?pageNo=1&ypmc=<药品名>" # 药品目录(pageNo 必填) 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 "@" # 未获写入授权时禁止用真实居民 # 本地 Python 联调查询(脚本硬编码凭据,勿提交) python3 scripts/query_health_record.py @@ -100,10 +104,9 @@ python3 scripts/query_health_record.py > 2026-07-15 已把本地能力与真实验收拆开:T-206 标记本地 DONE,T-215 承接授权后的真实写入验收。 -1. **T-213**:用假 OSI、幂等、report、PHIS 回写接口完成 upsert 应用编排;不等待写入授权。 -2. **T-204**:复用 T-213 暴露 `POST /api/health-record/upsert`,handler 不复制业务逻辑。 -3. 并行催厂家:写入授权与可写测试档案、更新目标标识、逐档案 `operateUser` 最终规则。 -4. 授权到位后执行 T-215 真实 create→query→update→query 验收。 +1. 并行催厂家:写入授权与可写测试档案、更新目标标识、逐档案 `operateUser` 最终规则。 +2. 授权到位后执行 T-215 真实 create→query→update→query 验收。 +3. 若授权继续等待,则把路线图阶段 3 的持久化幂等、报告和补偿调度拆成独立任务后实施。 ## 维护规则 diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 9102ecc..ecba909 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1,8 +1,8 @@ openapi: 3.0.3 info: title: chis_osi Server API - version: 0.1.0 - description: 'chis_osi server 模式对外提供的本机 HTTP 查询接口。 + version: 0.2.0 + description: 'chis_osi server 模式对外提供的本机 HTTP 查询与健康档案 upsert 接口。 本文档描述的是本项目自己的 HTTP API,不是上游 CHIS OSI 平台接口。 @@ -21,7 +21,7 @@ servers: description: 本地默认 server 模式地址 tags: - name: health-record - description: 健康档案查询 + description: 健康档案查询与 upsert - name: health-check description: 健康体检查询 - name: dictionaries @@ -29,6 +29,72 @@ tags: - name: elderly description: 老年人业务查询 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: get: tags: @@ -585,6 +651,113 @@ components: value: error: empty response from OSI 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: type: object required: diff --git a/handler/health_record_upsert.go b/handler/health_record_upsert.go new file mode 100644 index 0000000..6d29980 --- /dev/null +++ b/handler/health_record_upsert.go @@ -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) +} diff --git a/handler/health_record_upsert_test.go b/handler/health_record_upsert_test.go new file mode 100644 index 0000000..e26ede7 --- /dev/null +++ b/handler/health_record_upsert_test.go @@ -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"}}}` +} diff --git a/health_record_upsert_runtime.go b/health_record_upsert_runtime.go new file mode 100644 index 0000000..dce0db7 --- /dev/null +++ b/health_record_upsert_runtime.go @@ -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 } diff --git a/health_record_upsert_runtime_test.go b/health_record_upsert_runtime_test.go new file mode 100644 index 0000000..2a05b2a --- /dev/null +++ b/health_record_upsert_runtime_test.go @@ -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 +} diff --git a/mapping/health_record.go b/mapping/health_record.go index 26e3189..f9a731c 100644 --- a/mapping/health_record.go +++ b/mapping/health_record.go @@ -246,6 +246,34 @@ func MapHealthRecordTask(task source.HealthRecordTask, ctx MapContext) (contract 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 { if lookup == nil { return []ValidationError{{Field: "masterData", Reason: "CHIS dictionary snapshot is required"}} diff --git a/pipeline/health_record_upsert.go b/pipeline/health_record_upsert.go index 84fc60a..f14ed5f 100644 --- a/pipeline/health_record_upsert.go +++ b/pipeline/health_record_upsert.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "strings" + "time" "chis_osi/contract" "chis_osi/mapping" @@ -32,12 +33,12 @@ const ( ) type Outcome struct { - Status Status - Action Action - Reason string - ServiceID string - ResponseCode string - PHRIDHint string + Status Status `json:"status"` + Action Action `json:"action"` + Reason string `json:"reason,omitempty"` + ServiceID string `json:"serviceId,omitempty"` + ResponseCode string `json:"responseCode,omitempty"` + PHRIDHint string `json:"phrIdHint,omitempty"` } type UpsertEvent struct { @@ -135,6 +136,8 @@ type HealthRecordUpsertService struct { deps Dependencies } +const idempotencyCleanupTimeout = 5 * time.Second + func NewHealthRecordUpsertService(deps Dependencies) *HealthRecordUpsertService { 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) { - 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 s.finish(ctx, task, outcome) } 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) { diff --git a/pipeline/health_record_upsert_test.go b/pipeline/health_record_upsert_test.go index da3dd69..da54841 100644 --- a/pipeline/health_record_upsert_test.go +++ b/pipeline/health_record_upsert_test.go @@ -22,8 +22,8 @@ func TestHealthRecordUpsertCreatesWhenQueryIsEmpty(t *testing.T) { if err != nil || outcome.Status != StatusDone || outcome.Action != ActionCreate { t.Fatalf("outcome=%#v err=%v", outcome, err) } - if client.findCalls != 1 || client.createCalls != 1 || client.updateCalls != 0 || !store.marked { - t.Fatalf("calls find=%d create=%d update=%d marked=%v", client.findCalls, client.createCalls, client.updateCalls, 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 deadline=%v", client.findCalls, client.createCalls, client.updateCalls, store.marked, store.completeHasDeadline) } assertNotifications(t, reports, statuses, StatusDone, ActionCreate) } @@ -86,7 +86,7 @@ func TestHealthRecordUpsertNeverCreatesAfterQueryFailure(t *testing.T) { if retryable { 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) } } @@ -247,6 +247,7 @@ func (f *fakeHealthRecordClient) UpdateHealthRecord(_ context.Context, req contr type fakeIdempotencyStore struct { completed, inProgress, marked, released bool + completeHasDeadline, releaseHasDeadline bool completeErr error } @@ -259,14 +260,16 @@ func (f *fakeIdempotencyStore) Acquire(context.Context, string) (IdempotencyLeas } 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" { return errors.New("stale lease") } f.marked = true 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" { return errors.New("stale lease") } diff --git a/pipeline/memory_idempotency.go b/pipeline/memory_idempotency.go new file mode 100644 index 0000000..0d2df4b --- /dev/null +++ b/pipeline/memory_idempotency.go @@ -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 +} diff --git a/pipeline/memory_idempotency_test.go b/pipeline/memory_idempotency_test.go new file mode 100644 index 0000000..522e9df --- /dev/null +++ b/pipeline/memory_idempotency_test.go @@ -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") + } +} diff --git a/progress.md b/progress.md index ca5d195..a8e0809 100644 --- a/progress.md +++ b/progress.md @@ -361,3 +361,14 @@ - 安全与边界:普通报告事件只含哈希 `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 单独验证。 - 下一步: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 的持久化幂等和补偿任务。 diff --git a/server.go b/server.go index 1da4498..c819a4f 100644 --- a/server.go +++ b/server.go @@ -6,32 +6,20 @@ import ( "chis_osi/config" "chis_osi/handler" + "chis_osi/osi" ) -// runServer 启动 server 模式 HTTP 服务,暴露查询端点。 +// runServer 启动 server 模式 HTTP 服务。 func runServer(cfg config.Config, addr string) error { client, err := buildOSIClient(cfg) if err != nil { return fmt.Errorf("build osi client: %w", err) } - hr := handler.NewHealthRecordHandler(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) + mux := newServerMux(client, newServerHealthRecordUpserter(client)) 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/crowd?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} 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 +} diff --git a/tasks.md b/tasks.md index 08d348a..910c0fd 100644 --- a/tasks.md +++ b/tasks.md @@ -68,7 +68,7 @@ | 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-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 · 其余业务线查询(读先行)