feat(contract): 建立 JKDA 查询响应契约(T-201)

This commit is contained in:
ila
2026-07-07 09:35:03 +08:00
parent 801fa543d1
commit 332683eee6
6 changed files with 253 additions and 8 deletions
+99
View File
@@ -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"`
}
+137
View File
@@ -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)
}
}
+2 -1
View File
@@ -27,7 +27,8 @@
## 阶段 2 · 健康档案闭环(第一条业务线) ## 阶段 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。 - [ ] `osi/jkda.go`:Create/Update/Find/FindRqbj。
- [ ] `handler` + `router`:暴露 `/api/health-record/save`(server 模式联调用)。 - [ ] `handler` + `router`:暴露 `/api/health-record/save`(server 模式联调用)。
- [ ] 用 01 文档样例 + 联调样本写映射单测。 - [ ] 用 01 文档样例 + 联调样本写映射单测。
+7 -6
View File
@@ -5,12 +5,12 @@
## 当前快照 ## 当前快照
- 日期:2026-07-06 - 日期:2026-07-07
- 阶段:**Phase 0 已完成**;T-001~T-006 已验收,下一步进入 Phase Q 的 T-201 - 阶段:**Phase Q 查询契约已起步**;T-001~T-006、T-201 已验收,下一步进入 T-203
- 技术栈:Go 1.24 单二进制;`main.go -mode server|deliver`;配置读取使用 viper,支持环境变量覆盖;OSI 客户端已具备签名、信封、传输、基础判码与 JKDA00002 验证入口 - 技术栈: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` - 联调现实:**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` - 标准启动路径:`./init.sh` 已配置三步:依赖下载、`go test ./...`、`go run . -mode server -config config.yaml.example`
- 标准验证路径:`go test ./...`、`go build ./...` - 标准验证路径:`go test ./...`、`go build ./...`
- 当前 blocker:无硬 blocker。软限制:当前机器从 Git Bash 启动 Go 会出现标准库路径/构建缓存权限异常,`init.sh` 无法完整跑完;PowerShell 下等价 Go 命令和真实请求通过。厂家侧 B1/B2/B4/B5 契约缺口只影响阶段 4,不阻塞 Phase Q/M/D 的当前路径 - 当前 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` 读取) | | `main.go` | 已有 | 单入口,解析 `-mode server|deliver`、`-config`,并提供 `-verify-jkda` 联调验证入口(身份证从 `OSI_VERIFY_ID_CARD` 读取) |
| `config/` | 已有 | viper 配置加载,支持 `OSI_*` 等环境变量覆盖 | | `config/` | 已有 | viper 配置加载,支持 `OSI_*` 等环境变量覆盖 |
| `contract/envelope.go` | 已有 | OSI 通用信封与 `manageInfo` 结构 | | `contract/envelope.go` | 已有 | OSI 通用信封与 `manageInfo` 结构 |
| `contract/jkda.go` | 已有 | JKDA00002 查询响应结构体;创建/更新请求结构体留到 T-206 |
| `osi/sign.go` | 已有 | OSI 请求头 MD5 签名与 headers 组装 | | `osi/sign.go` | 已有 | OSI 请求头 MD5 签名与 headers 组装 |
| `osi/transport.go` | 已有 | OSI JSON POST 传输层;原始 HTTP/1.1 写请求以保留头名大小写;支持超时与可选 SOCKS5 | | `osi/transport.go` | 已有 | OSI JSON POST 传输层;原始 HTTP/1.1 写请求以保留头名大小写;支持超时与可选 SOCKS5 |
| `osi/codes.go` | 已有 | serviceId 路由与成功/可重试判码 | | `osi/codes.go` | 已有 | serviceId 路由与成功/可重试判码 |
@@ -68,8 +69,8 @@ python3 scripts/query_health_record.py
## 下一步 ## 下一步
1. T-201:`contract/jkda.go`,按 `docs/04 §8` 实测契约建立 JKDA00002 查询响应结构体。 1. T-203:实现 `osi/jkda.go` 的 Find / FindRqbj,使用 `contract.FindHealthRecord` 承接 `data` 数组。
2. T-203:在结构体就绪后实现 `osi/jkda.go` 的 Find / FindRqbj。 2. T-102:T-203 后进入码表基线(按看板顺序)。
## 维护规则 ## 维护规则
+7
View File
@@ -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 均已补交、不再漂移。 - 复评结论: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。 - 决策:https 是否阻断取决于生产地址协议(挂钩 docs/06 A10);沙箱 http 不阻塞当前开发,故 T-007 置于 Backlog 而非当前 Phase。
- 下一步:T-201。 - 下一步: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)。
+1 -1
View File
@@ -42,7 +42,7 @@
| ID | 任务 | 依赖 | 验收要点 | 状态 | | 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 | | T-203 | `osi/jkda.go`:**Find + FindRqbj** | T-201 | 用 JKDA00002 真实请求打通,拿到 `code="01"` 与档案数据,与 Python 脚本结果一致 | TODO |
## Phase M · 映射层(码表 + 纯函数,注入假字典快照) ## Phase M · 映射层(码表 + 纯函数,注入假字典快照)