diff --git a/contract/jkda.go b/contract/jkda.go new file mode 100644 index 0000000..f8ee234 --- /dev/null +++ b/contract/jkda.go @@ -0,0 +1,99 @@ +package contract + +type FindHealthRecordResponse struct { + Code string `json:"code"` + Message string `json:"message"` + Data []FindHealthRecord `json:"data"` +} + +// FindHealthRecord is one JKDA00002 data item: a health record aggregate returned by OSI. +type FindHealthRecord struct { + HealthRecord HealthRecord `json:"healthRecord"` + PastHistory *PastHistory `json:"pastHistory,omitempty"` + PastDiseases []PastDisease `json:"jwsjb,omitempty"` + PastSurgeries []PastSurgery `json:"jwsss,omitempty"` + PastTraumas []PastTrauma `json:"jwsws,omitempty"` + PastTransfusions []PastBloodTx `json:"jwssx,omitempty"` + FamilyMiddle *FamilyMiddle `json:"familyMiddle,omitempty"` +} + +type HealthRecord struct { + IDCard string `json:"idCard,omitempty"` + PersonName string `json:"personName,omitempty"` + SexCode string `json:"sexCode,omitempty"` + Birthday string `json:"birthday,omitempty"` + PhrID string `json:"phrId,omitempty"` + EmpiID string `json:"empiId,omitempty"` + CheckID *string `json:"checkId,omitempty"` + ManaUnitID string `json:"manaUnitId,omitempty"` + CreateUnit string `json:"createUnit,omitempty"` + ManaDoctorID string `json:"manaDoctorId,omitempty"` + CreateUser string `json:"createUser,omitempty"` + RegionCode string `json:"regionCode,omitempty"` + AddressCode string `json:"addressCode,omitempty"` + HomePlaceCode string `json:"homePlaceCode,omitempty"` + Address string `json:"address,omitempty"` + AdressNumber string `json:"adressNumber,omitempty"` + HomePlace string `json:"homePlace,omitempty"` + HomePlaceNumber string `json:"homePlaceNumber,omitempty"` + NationCode string `json:"nationCode,omitempty"` + EducationCode string `json:"educationCode,omitempty"` + WorkCode string `json:"workCode,omitempty"` + MaritalStatusCode string `json:"maritalStatusCode,omitempty"` + BloodTypeCode string `json:"bloodTypeCode,omitempty"` + RhBloodCode string `json:"rhBloodCode,omitempty"` + InsuranceCode string `json:"insuranceCode,omitempty"` + InsuranceType *string `json:"insuranceType,omitempty"` + PersonGroup string `json:"personGroup,omitempty"` + OtherPersonGroup *string `json:"otherPersonGroup,omitempty"` + IsFillSHHJ string `json:"isFillShhj,omitempty"` + Contact string `json:"contact,omitempty"` + ContactPhone string `json:"contactPhone,omitempty"` + MobileNumber string `json:"mobileNumber,omitempty"` + PhoneNumber string `json:"phoneNumber,omitempty"` + Status string `json:"status,omitempty"` + CreateDate string `json:"createDate,omitempty"` +} + +type PastHistory struct { + YWGMS string `json:"ywgms,omitempty"` + JWSJB string `json:"jwsjb,omitempty"` + BLS string `json:"bls,omitempty"` + YCBS string `json:"ycbs,omitempty"` + CJQK string `json:"cjqk,omitempty"` + JZSFQN string `json:"jzsfqn,omitempty"` + JZSMQ string `json:"jzsmq,omitempty"` + JZSZN string `json:"jzszn,omitempty"` + JZSXDJM string `json:"jzsxdjm,omitempty"` +} + +type PastDisease struct { + Code string `json:"jwsjbcode,omitempty"` + Name string `json:"jwsjbmc,omitempty"` + ConfirmedDate string `json:"jwsjbqzsj,omitempty"` +} + +type PastSurgery struct { + Code string `json:"jwssscode,omitempty"` + Name string `json:"jwsssmc,omitempty"` + ConfirmedDate string `json:"jwsssqzsj,omitempty"` +} + +type PastTrauma struct { + Code string `json:"jwswscode,omitempty"` + Name string `json:"jwswsmc,omitempty"` +} + +type PastBloodTx struct { + Code string `json:"jwssxcode,omitempty"` + Reason string `json:"jwssxyy,omitempty"` +} + +type FamilyMiddle struct { + WaterSourceCode string `json:"waterSourceCode,omitempty"` + FuelType string `json:"fuelType,omitempty"` + CookAirTool string `json:"cookAirTool,omitempty"` + Washroom string `json:"washroom,omitempty"` + LivestockColumn string `json:"livestockColumn,omitempty"` + IsFillSHHJ string `json:"isFillShhj,omitempty"` +} diff --git a/contract/jkda_test.go b/contract/jkda_test.go new file mode 100644 index 0000000..6c451f2 --- /dev/null +++ b/contract/jkda_test.go @@ -0,0 +1,137 @@ +package contract + +import ( + "encoding/json" + "testing" +) + +func TestFindHealthRecordDeserializesMeasuredJKDA00002Data(t *testing.T) { + raw := []byte(`[ + { + "healthRecord": { + "idCard": "440000********1234", + "personName": "测试人员", + "sexCode": "1", + "birthday": "1970-01-01", + "phrId": "PHR001", + "empiId": "0123456789abcdef0123456789abcdef", + "checkId": null, + "manaUnitId": "123456789", + "createUnit": "123456789", + "manaDoctorId": "D001", + "createUser": "U001", + "regionCode": "441625000000", + "addressCode": "441625000001", + "homePlaceCode": "441625000002", + "address": "现住址", + "adressNumber": "101号", + "homePlace": "户籍地", + "homePlaceNumber": "202号", + "nationCode": "01", + "educationCode": "30", + "workCode": "90", + "maritalStatusCode": "20", + "bloodTypeCode": "1", + "rhBloodCode": "1", + "insuranceCode": "01", + "insuranceType": null, + "personGroup": "01", + "otherPersonGroup": null, + "isFillShhj": "y", + "contact": "联系人", + "contactPhone": "13800000000", + "mobileNumber": "13900000000", + "phoneNumber": "02000000000", + "status": "1", + "createDate": "2026-07-06" + }, + "pastHistory": { + "ywgms": "2", + "jwsjb": "1", + "bls": "2", + "ycbs": "2", + "cjqk": "2", + "jzsfqn": "2", + "jzsmq": "2", + "jzszn": "2", + "jzsxdjm": "2" + }, + "jwsjb": [{"jwsjbcode":"2","jwsjbmc":"高血压","jwsjbqzsj":"2020-01-02"}], + "jwsss": [{"jwssscode":"1","jwsssmc":"手术史","jwsssqzsj":"2019-03-04"}], + "jwsws": [{"jwswscode":"1","jwswsmc":"外伤史"}], + "jwssx": [{"jwssxcode":"1","jwssxyy":"输血原因"}], + "familyMiddle": { + "waterSourceCode": "1", + "fuelType": "2", + "cookAirTool": "3", + "washroom": "4", + "livestockColumn": "5", + "isFillShhj": "y" + } + } + ]`) + + var records []FindHealthRecord + if err := json.Unmarshal(raw, &records); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if len(records) != 1 { + t.Fatalf("records length = %d", len(records)) + } + + record := records[0] + if record.HealthRecord.PhrID != "PHR001" { + t.Fatalf("phrId = %q", record.HealthRecord.PhrID) + } + if record.HealthRecord.AdressNumber != "101号" { + t.Fatalf("adressNumber = %q", record.HealthRecord.AdressNumber) + } + if record.HealthRecord.CheckID != nil { + t.Fatalf("checkId = %#v, want nil", record.HealthRecord.CheckID) + } + if record.HealthRecord.InsuranceType != nil { + t.Fatalf("insuranceType = %#v, want nil", record.HealthRecord.InsuranceType) + } + if record.HealthRecord.OtherPersonGroup != nil { + t.Fatalf("otherPersonGroup = %#v, want nil", record.HealthRecord.OtherPersonGroup) + } + if record.PastHistory == nil || record.PastHistory.JWSJB != "1" { + t.Fatalf("pastHistory = %#v", record.PastHistory) + } + if len(record.PastDiseases) != 1 || record.PastDiseases[0].Code != "2" || record.PastDiseases[0].ConfirmedDate != "2020-01-02" { + t.Fatalf("past diseases = %#v", record.PastDiseases) + } + if len(record.PastSurgeries) != 1 || record.PastSurgeries[0].Name != "手术史" { + t.Fatalf("past surgeries = %#v", record.PastSurgeries) + } + if len(record.PastTraumas) != 1 || record.PastTraumas[0].Name != "外伤史" { + t.Fatalf("past traumas = %#v", record.PastTraumas) + } + if len(record.PastTransfusions) != 1 || record.PastTransfusions[0].Reason != "输血原因" { + t.Fatalf("past transfusions = %#v", record.PastTransfusions) + } + if record.FamilyMiddle == nil || record.FamilyMiddle.IsFillSHHJ != "y" { + t.Fatalf("familyMiddle = %#v", record.FamilyMiddle) + } +} + +func TestFindHealthRecordResponseDeserializesDataArray(t *testing.T) { + raw := []byte(`{"code":"01","message":"操作成功","data":[{"healthRecord":{"phrId":"PHR001","checkId":null,"adressNumber":"101号"}}]}`) + + var response FindHealthRecordResponse + if err := json.Unmarshal(raw, &response); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if response.Code != "01" || response.Message != "操作成功" { + t.Fatalf("response = %#v", response) + } + if len(response.Data) != 1 { + t.Fatalf("data length = %d", len(response.Data)) + } + if response.Data[0].HealthRecord.PhrID != "PHR001" { + t.Fatalf("phrId = %q", response.Data[0].HealthRecord.PhrID) + } + if response.Data[0].HealthRecord.CheckID != nil { + t.Fatalf("checkId = %#v, want nil", response.Data[0].HealthRecord.CheckID) + } +} diff --git a/docs/05-实施路线图.md b/docs/05-实施路线图.md index 593d8cf..7a40115 100644 --- a/docs/05-实施路线图.md +++ b/docs/05-实施路线图.md @@ -27,7 +27,8 @@ ## 阶段 2 · 健康档案闭环(第一条业务线) -- [ ] `contract/jkda.go` + `mapping/health_record.go` + `mapping/checkid.go`。 +- [x] `contract/jkda.go` 查询响应结构体(T-201;创建/更新请求结构体留到 T-206)。 +- [ ] `mapping/health_record.go` + `mapping/checkid.go`。 - [ ] `osi/jkda.go`:Create/Update/Find/FindRqbj。 - [ ] `handler` + `router`:暴露 `/api/health-record/save`(server 模式联调用)。 - [ ] 用 01 文档样例 + 联调样本写映射单测。 diff --git a/docs/current-state.md b/docs/current-state.md index 58c4ade..0ba40ee 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -5,12 +5,12 @@ ## 当前快照 -- 日期:2026-07-06 -- 阶段:**Phase 0 已完成**;T-001~T-006 已验收,下一步进入 Phase Q 的 T-201 +- 日期:2026-07-07 +- 阶段:**Phase Q 查询契约已起步**;T-001~T-006、T-201 已验收,下一步进入 T-203 - 技术栈:Go 1.24 单二进制;`main.go -mode server|deliver`;配置读取使用 viper,支持环境变量覆盖;OSI 客户端已具备签名、信封、传输、基础判码与 JKDA00002 验证入口 -- 生产代码:已有 `main.go`、`config/`、`contract/envelope.go`、`osi/sign.go`、`osi/transport.go`、`osi/codes.go`、`osi/client.go`、`verify_jkda.go`、`go.mod`/`go.sum`;`contract/jkda.go`、`mapping/`、`pipeline/` 等业务模块仍待后续任务建立 +- 生产代码:已有 `main.go`、`config/`、`contract/envelope.go`、`osi/sign.go`、`osi/transport.go`、`osi/codes.go`、`osi/client.go`、`verify_jkda.go`、`go.mod`/`go.sum`;`contract/jkda.go` 已建立 JKDA00002 查询响应契约;`mapping/`、`pipeline/` 等业务模块仍待后续任务建立 - 联调现实:**JKDA00002 个人档案查询已用 Go 侧真实请求打通**,返回 `code="01" message="操作成功" data_count=1`;Python 联调样本和实测契约沉淀在 `docs/04 §8` -- 测试:`go test ./...` 通过;当前测试覆盖 mode 解析、配置加载与环境变量覆盖、MD5 签名、请求头组装、JSON POST 传输、头名大小写保留、identity 响应编码声明、超时配置、SOCKS5 代理地址校验、信封结构、serviceId 路由、成功/重试判码、Client.Call 请求与响应解析、JKDA00002 验证入口 +- 测试:`go test ./...` 通过;当前测试覆盖 mode 解析、配置加载与环境变量覆盖、MD5 签名、请求头组装、JSON POST 传输、头名大小写保留、identity 响应编码声明、超时配置、SOCKS5 代理地址校验、信封结构、serviceId 路由、成功/重试判码、Client.Call 请求与响应解析、JKDA00002 查询响应契约、JKDA00002 验证入口 - 标准启动路径:`./init.sh` 已配置三步:依赖下载、`go test ./...`、`go run . -mode server -config config.yaml.example` - 标准验证路径:`go test ./...`、`go build ./...` - 当前 blocker:无硬 blocker。软限制:当前机器从 Git Bash 启动 Go 会出现标准库路径/构建缓存权限异常,`init.sh` 无法完整跑完;PowerShell 下等价 Go 命令和真实请求通过。厂家侧 B1/B2/B4/B5 契约缺口只影响阶段 4,不阻塞 Phase Q/M/D 的当前路径 @@ -22,6 +22,7 @@ | `main.go` | 已有 | 单入口,解析 `-mode server|deliver`、`-config`,并提供 `-verify-jkda` 联调验证入口(身份证从 `OSI_VERIFY_ID_CARD` 读取) | | `config/` | 已有 | viper 配置加载,支持 `OSI_*` 等环境变量覆盖 | | `contract/envelope.go` | 已有 | OSI 通用信封与 `manageInfo` 结构 | +| `contract/jkda.go` | 已有 | JKDA00002 查询响应结构体;创建/更新请求结构体留到 T-206 | | `osi/sign.go` | 已有 | OSI 请求头 MD5 签名与 headers 组装 | | `osi/transport.go` | 已有 | OSI JSON POST 传输层;原始 HTTP/1.1 写请求以保留头名大小写;支持超时与可选 SOCKS5 | | `osi/codes.go` | 已有 | serviceId 路由与成功/可重试判码 | @@ -68,8 +69,8 @@ python3 scripts/query_health_record.py ## 下一步 -1. T-201:`contract/jkda.go`,按 `docs/04 §8` 实测契约建立 JKDA00002 查询响应结构体。 -2. T-203:在结构体就绪后实现 `osi/jkda.go` 的 Find / FindRqbj。 +1. T-203:实现 `osi/jkda.go` 的 Find / FindRqbj,使用 `contract.FindHealthRecord` 承接 `data` 数组。 +2. T-102:T-203 后进入码表基线(按看板顺序)。 ## 维护规则 diff --git a/progress.md b/progress.md index 742eb4d..271eef2 100644 --- a/progress.md +++ b/progress.md @@ -112,3 +112,10 @@ - 复评结论:T-006 认领的 4 项(gzip 声明/传输死代码/命令行 PII/Envelope 冗余)均已修复并补测;review 其余项中——P1-2 https 以 T-007 登记延期;P2-2(裸写根因抓包对照)、P3(nil-transport 静默兜底、UA 写死、init.sh 本机不通)仍未处理,作为后续小整改候选。P1-1(未提交即标 DONE)本轮 T-005/T-006 均已补交、不再漂移。 - 决策:https 是否阻断取决于生产地址协议(挂钩 docs/06 A10);沙箱 http 不阻塞当前开发,故 T-007 置于 Backlog 而非当前 Phase。 - 下一步:T-201。 +## 2026-07-07 T-201 JKDA00002 查询响应契约 + +- 状态:DONE +- 变更:新增 `contract/jkda.go` 与 `contract/jkda_test.go`;按 `docs/04 §8` 建立 `FindHealthRecordResponse`、`FindHealthRecord`、`HealthRecord`、`PastHistory`、既往疾病/手术/外伤/输血数组和 `FamilyMiddle` 查询响应结构体;保留响应侧错拼字段 `adressNumber`;`checkId`、`insuranceType`、`otherPersonGroup` 等 nullable 字段使用指针。 +- 验证:按 TDD 先看到 `go test ./contract -run TestFindHealthRecordDeserializesMeasuredJKDA00002Data -count=1` 因 `FindHealthRecord` 未定义失败;补结构体后通过。随后看到 `go test ./contract -run TestFindHealthRecordResponseDeserializesDataArray -count=1` 因 `FindHealthRecordResponse` 未定义失败;补响应结构体后通过。最终 `go test ./contract -count=1` 通过,`go test ./...` 通过。 +- 决策:T-201 只建查询响应契约;创建/更新请求结构体仍按看板留到 T-206,避免提前把未联调字段固化。 +- 下一步:T-203(`osi/jkda.go`:Find + FindRqbj)。 diff --git a/tasks.md b/tasks.md index a162070..75b5ea4 100644 --- a/tasks.md +++ b/tasks.md @@ -42,7 +42,7 @@ | ID | 任务 | 依赖 | 验收要点 | 状态 | | --- | --- | --- | --- | --- | -| T-201 | `contract/jkda.go`:以 docs/04 §8 实测契约建**查询响应**结构体 | T-004 | Find 响应能完整反序列化本地联调样本(`data` 数组、null 字段用指针/omitempty、`adressNumber` 坑点拼写);创建请求结构体延到 T-206 补 | TODO | +| T-201 | `contract/jkda.go`:以 docs/04 §8 实测契约建**查询响应**结构体 | T-004 | Find 响应能完整反序列化本地联调样本(`data` 数组、null 字段用指针/omitempty、`adressNumber` 坑点拼写);创建请求结构体延到 T-206 补 | DONE | | T-203 | `osi/jkda.go`:**Find + FindRqbj** | T-201 | 用 JKDA00002 真实请求打通,拿到 `code="01"` 与档案数据,与 Python 脚本结果一致 | TODO | ## Phase M · 映射层(码表 + 纯函数,注入假字典快照)