feat(osi): 实现 JKDA 查询方法(T-203)

This commit is contained in:
ila
2026-07-07 10:14:40 +08:00
parent 332683eee6
commit cfbad76e6e
8 changed files with 249 additions and 14 deletions
+2 -1
View File
@@ -29,7 +29,8 @@
- [x] `contract/jkda.go` 查询响应结构体(T-201;创建/更新请求结构体留到 T-206)。 - [x] `contract/jkda.go` 查询响应结构体(T-201;创建/更新请求结构体留到 T-206)。
- [ ] `mapping/health_record.go` + `mapping/checkid.go`。 - [ ] `mapping/health_record.go` + `mapping/checkid.go`。
- [ ] `osi/jkda.go`:Create/Update/Find/FindRqbj。 - [x] `osi/jkda.go`:Find/FindRqbj(T-203;Find 已真实请求验收,FindRqbj 已按文档契约单测覆盖)。
- [ ] `osi/jkda.go`:Create/Update(T-206,依赖映射与字典缓存)。
- [ ] `handler` + `router`:暴露 `/api/health-record/save`(server 模式联调用)。 - [ ] `handler` + `router`:暴露 `/api/health-record/save`(server 模式联调用)。
- [ ] 用 01 文档样例 + 联调样本写映射单测。 - [ ] 用 01 文档样例 + 联调样本写映射单测。
- **验收**:一条 PHIS 档案 → 映射 → create → 平台返回成功码(实测 `"01"`)与 `phrId`;重复投递被幂等跳过。 - **验收**:一条 PHIS 档案 → 映射 → create → 平台返回成功码(实测 `"01"`)与 `phrId`;重复投递被幂等跳过。
+7 -6
View File
@@ -6,11 +6,11 @@
## 当前快照 ## 当前快照
- 日期:2026-07-07 - 日期:2026-07-07
- 阶段:**Phase Q 查询契约已起步**;T-001~T-006、T-201 已验收,下一步进入 T-203 - 阶段:**Phase Q 查询档案已打通**;T-001~T-006、T-201、T-203 已验收,下一步进入 Phase M 的 T-102
- 技术栈:Go 1.24 单二进制;`main.go -mode server|deliver`;配置读取使用 viper,支持环境变量覆盖;OSI 客户端已具备签名、信封、传输、基础判码与 JKDA00002 验证入口 - 技术栈:Go 1.24 单二进制;`main.go -mode server|deliver`;配置读取使用 viper,支持环境变量覆盖;OSI 客户端已具备签名、信封、传输、基础判码、JKDA00002 Find 与 JKDA00005 FindRqbj
- 生产代码:已有 `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/` 等业务模块仍待后续任务建立 - 生产代码:已有 `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 查询响应契约;`osi/jkda.go` 已实现 Find/FindRqbj;`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 查询响应契约、JKDA00002 验证入口 - 测试:`go test ./...` 通过;当前测试覆盖 mode 解析、配置加载与环境变量覆盖、MD5 签名、请求头组装、JSON POST 传输、头名大小写保留、identity 响应编码声明、超时配置、SOCKS5 代理地址校验、信封结构、serviceId 路由、成功/重试判码、Client.Call 请求与响应解析、JKDA00002 查询响应契约、JKDA00002 Find、JKDA00005 FindRqbj、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 的当前路径
@@ -27,6 +27,7 @@
| `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 路由与成功/可重试判码 |
| `osi/client.go` | 已有 | 通用 `Call`:签名头 + 信封 + 传输 + 响应解析 | | `osi/client.go` | 已有 | 通用 `Call`:签名头 + 信封 + 传输 + 响应解析 |
| `osi/jkda.go` | 已有 | JKDA00002 Find 与 JKDA00005 FindRqbj |
| `verify_jkda.go` | 已有 | JKDA00002 真实请求验证辅助入口 | | `verify_jkda.go` | 已有 | JKDA00002 真实请求验证辅助入口 |
| `config.yaml.example` | 已有 | 占位配置,不含真实凭据 | | `config.yaml.example` | 已有 | 占位配置,不含真实凭据 |
| `go.mod` `go.sum` | 已有 | module `chis_osi`,依赖 viper 与 `golang.org/x/net/proxy` | | `go.mod` `go.sum` | 已有 | module `chis_osi`,依赖 viper 与 `golang.org/x/net/proxy` |
@@ -69,8 +70,8 @@ python3 scripts/query_health_record.py
## 下一步 ## 下一步
1. T-203:实现 `osi/jkda.go` 的 Find / FindRqbj,使用 `contract.FindHealthRecord` 承接 `data` 数组。 1. T-102:`mapping/dict.go` 全量码表(含 56 项民族)。
2. T-102:T-203 后进入码表基线(按看板顺序)。 2. T-202:码表完成后进入健康档案映射与 checkId。
## 维护规则 ## 维护规则
+2
View File
@@ -7,12 +7,14 @@ import (
const ( const (
ServiceIDJKDAFind = "JKDA00002" ServiceIDJKDAFind = "JKDA00002"
ServiceIDJKDAFindRqbj = "JKDA00005"
) )
const codeRetryableTimeout = "405" const codeRetryableTimeout = "405"
var servicePaths = map[string]string{ var servicePaths = map[string]string{
ServiceIDJKDAFind: "/auto/jkda/find", ServiceIDJKDAFind: "/auto/jkda/find",
ServiceIDJKDAFindRqbj: "/jkda/findrqbj",
} }
func PathOf(serviceID string) (string, error) { func PathOf(serviceID string) (string, error) {
+81
View File
@@ -0,0 +1,81 @@
package osi
import (
"context"
"fmt"
"chis_osi/contract"
)
type FindHealthRecordQuery struct {
IDCard string
EMPIID string
PHRID string
PersonName string
}
type FindRqbjQuery struct {
IDCard string
PHRID string
}
type PersonSignResult struct {
PersonSign string `json:"personSign"`
}
func (c *Client) FindHealthRecord(ctx context.Context, query FindHealthRecordQuery) ([]contract.FindHealthRecord, Result, error) {
baseInfo, err := query.baseInfo()
if err != nil {
return nil, Result{}, err
}
var records []contract.FindHealthRecord
result, err := c.Call(ctx, ServiceIDJKDAFind, baseInfo, &records)
return records, result, err
}
func (c *Client) FindRqbj(ctx context.Context, query FindRqbjQuery) (PersonSignResult, Result, error) {
baseInfo, err := query.baseInfo()
if err != nil {
return PersonSignResult{}, Result{}, err
}
var personSign PersonSignResult
result, err := c.Call(ctx, ServiceIDJKDAFindRqbj, baseInfo, &personSign)
return personSign, result, err
}
func (q FindHealthRecordQuery) baseInfo() (map[string]string, error) {
fields := []queryField{
{name: "idCard", value: q.IDCard},
{name: "empiId", value: q.EMPIID},
{name: "phrid", value: q.PHRID},
{name: "personName", value: q.PersonName},
}
return singleBaseInfo("find health record", fields)
}
func (q FindRqbjQuery) baseInfo() (map[string]string, error) {
fields := []queryField{
{name: "idCard", value: q.IDCard},
{name: "phrid", value: q.PHRID},
}
return singleBaseInfo("find rqbj", fields)
}
type queryField struct {
name string
value string
}
func singleBaseInfo(operation string, fields []queryField) (map[string]string, error) {
baseInfo := make(map[string]string, 1)
for _, field := range fields {
if field.value == "" {
continue
}
baseInfo[field.name] = field.value
}
if len(baseInfo) != 1 {
return nil, fmt.Errorf("%s requires exactly one query key", operation)
}
return baseInfo, nil
}
+145
View File
@@ -0,0 +1,145 @@
package osi
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestFindHealthRecordCallsJKDA00002WithBaseInfo(t *testing.T) {
var seenPath string
var seenPayload map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seenPath = r.URL.Path
if err := json.NewDecoder(r.Body).Decode(&seenPayload); err != nil {
t.Fatalf("decode request: %v", err)
}
_, _ = w.Write([]byte(`{"code":"01","message":"操作成功","data":[{"healthRecord":{"phrId":"PHR001","idCard":"440000********1234","adressNumber":"101号"}}]}`))
}))
defer server.Close()
client := newTestJKDAClient(t, server.URL)
records, result, err := client.FindHealthRecord(context.Background(), FindHealthRecordQuery{IDCard: "440000********1234"})
if err != nil {
t.Fatalf("FindHealthRecord: %v", err)
}
if !result.Success || result.Code != "01" {
t.Fatalf("result = %#v", result)
}
if seenPath != "/osi/api/auto/jkda/find" {
t.Fatalf("path = %q", seenPath)
}
if seenPayload["serviceId"] != ServiceIDJKDAFind {
t.Fatalf("serviceId = %v", seenPayload["serviceId"])
}
baseInfo := seenPayload["uploadinfo"].(map[string]any)["baseInfo"].(map[string]any)
if baseInfo["idCard"] != "440000********1234" {
t.Fatalf("baseInfo = %#v", baseInfo)
}
if len(records) != 1 || records[0].HealthRecord.PhrID != "PHR001" || records[0].HealthRecord.AdressNumber != "101号" {
t.Fatalf("records = %#v", records)
}
}
func TestFindHealthRecordSupportsEMPIAndPHRIDQueries(t *testing.T) {
tests := []struct {
name string
q FindHealthRecordQuery
key string
want string
}{
{name: "empi", q: FindHealthRecordQuery{EMPIID: "empi-001"}, key: "empiId", want: "empi-001"},
{name: "phrid", q: FindHealthRecordQuery{PHRID: "phr-001"}, key: "phrid", want: "phr-001"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var seenPayload map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&seenPayload); err != nil {
t.Fatalf("decode request: %v", err)
}
_, _ = w.Write([]byte(`{"code":"01","message":"操作成功","data":[]}`))
}))
defer server.Close()
client := newTestJKDAClient(t, server.URL)
if _, _, err := client.FindHealthRecord(context.Background(), tt.q); err != nil {
t.Fatalf("FindHealthRecord: %v", err)
}
baseInfo := seenPayload["uploadinfo"].(map[string]any)["baseInfo"].(map[string]any)
if baseInfo[tt.key] != tt.want {
t.Fatalf("baseInfo = %#v", baseInfo)
}
})
}
}
func TestFindHealthRecordRejectsEmptyOrAmbiguousQuery(t *testing.T) {
client := NewClient(ClientConfig{})
for _, q := range []FindHealthRecordQuery{
{},
{IDCard: "440000********1234", PHRID: "phr-001"},
} {
if _, _, err := client.FindHealthRecord(context.Background(), q); err == nil {
t.Fatalf("FindHealthRecord accepted query %#v", q)
}
}
}
func TestFindRqbjCallsJKDA00005AndDecodesPersonSign(t *testing.T) {
var seenPath string
var seenPayload map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seenPath = r.URL.Path
if err := json.NewDecoder(r.Body).Decode(&seenPayload); err != nil {
t.Fatalf("decode request: %v", err)
}
_, _ = w.Write([]byte(`{"code":"01","message":"操作成功","data":{"personSign":"PU,LAO"}}`))
}))
defer server.Close()
client := newTestJKDAClient(t, server.URL)
personSign, result, err := client.FindRqbj(context.Background(), FindRqbjQuery{PHRID: "phr-001"})
if err != nil {
t.Fatalf("FindRqbj: %v", err)
}
if !result.Success || result.Code != "01" {
t.Fatalf("result = %#v", result)
}
if seenPath != "/osi/api/jkda/findrqbj" {
t.Fatalf("path = %q", seenPath)
}
if seenPayload["serviceId"] != ServiceIDJKDAFindRqbj {
t.Fatalf("serviceId = %v", seenPayload["serviceId"])
}
baseInfo := seenPayload["uploadinfo"].(map[string]any)["baseInfo"].(map[string]any)
if baseInfo["phrid"] != "phr-001" {
t.Fatalf("baseInfo = %#v", baseInfo)
}
if personSign.PersonSign != "PU,LAO" {
t.Fatalf("personSign = %#v", personSign)
}
}
func newTestJKDAClient(t *testing.T, baseURL string) *Client {
t.Helper()
transport, err := NewTransport(TransportConfig{Timeout: time.Second})
if err != nil {
t.Fatalf("NewTransport: %v", err)
}
return NewClient(ClientConfig{
BaseURL: baseURL,
OrgCode: "12441625456962881G",
UserName: "dyytgw",
Ask: "secret-key",
OperateUser: "712041",
OperateUnit: "12441625456962881G",
Transport: transport,
NowMillis: func() string {
return "1700000000123"
},
})
}
+7
View File
@@ -119,3 +119,10 @@
- 验证:按 TDD 先看到 `go test ./contract -run TestFindHealthRecordDeserializesMeasuredJKDA00002Data -count=1` 因 `FindHealthRecord` 未定义失败;补结构体后通过。随后看到 `go test ./contract -run TestFindHealthRecordResponseDeserializesDataArray -count=1` 因 `FindHealthRecordResponse` 未定义失败;补响应结构体后通过。最终 `go test ./contract -count=1` 通过,`go test ./...` 通过。 - 验证:按 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-201 只建查询响应契约;创建/更新请求结构体仍按看板留到 T-206,避免提前把未联调字段固化。
- 下一步:T-203(`osi/jkda.go`:Find + FindRqbj)。 - 下一步:T-203(`osi/jkda.go`:Find + FindRqbj)。
## 2026-07-07 T-203 JKDA Find / FindRqbj
- 状态:DONE
- 变更:新增 `osi/jkda.go` 与 `osi/jkda_test.go`;实现 `Client.FindHealthRecord`(JKDA00002 `/auto/jkda/find`)和 `Client.FindRqbj`(JKDA00005 `/jkda/findrqbj`);查询入参按单一 key 校验,支持 `idCard`、`empiId`、`phrid`、`personName`(Find)以及 `idCard`/`phrid`(FindRqbj);`verify_jkda.go` 改为复用正式 Find 方法。
- 验证:按 TDD 先运行 `go test ./osi -run "TestFind(HealthRecord|Rqbj)" -count=1`,因 `FindHealthRecord`/`FindRqbj` 与查询类型未定义失败;实现后目标测试通过。随后 `go test . -run TestRunJKDAFindCheckUsesConfiguredOSIClient -count=1` 通过,`go test ./...` 通过;使用本地环境变量发起正式 Go Find 真实请求,返回 `JKDA00002 code=01 message=操作成功 data_count=1`。
- 决策:FindRqbj 已有 serviceId/path 与 `personSign` 契约,先以单测覆盖;当前只对 JKDA00002 Find 做真实请求验收,因为本地已有可查档案样本。
- 下一步:T-102(`mapping/dict.go` 全量码表)。
+1 -1
View File
@@ -43,7 +43,7 @@
| ID | 任务 | 依赖 | 验收要点 | 状态 | | ID | 任务 | 依赖 | 验收要点 | 状态 |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| T-201 | `contract/jkda.go`:以 docs/04 §8 实测契约建**查询响应**结构体 | T-004 | Find 响应能完整反序列化本地联调样本(`data` 数组、null 字段用指针/omitempty、`adressNumber` 坑点拼写);创建请求结构体延到 T-206 补 | DONE | | 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 脚本结果一致 | DONE |
## Phase M · 映射层(码表 + 纯函数,注入假字典快照) ## Phase M · 映射层(码表 + 纯函数,注入假字典快照)
+2 -4
View File
@@ -2,7 +2,6 @@ package main
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"time" "time"
@@ -39,9 +38,8 @@ func runJKDAFindCheck(ctx context.Context, cfg config.Config, idCard string) (jk
Transport: transport, Transport: transport,
}) })
var data []json.RawMessage records, result, err := client.FindHealthRecord(ctx, osi.FindHealthRecordQuery{IDCard: idCard})
result, err := client.Call(ctx, osi.ServiceIDJKDAFind, map[string]string{"idCard": idCard}, &data) check := jkdaFindCheckResult{Code: result.Code, Message: result.Message, DataCount: len(records)}
check := jkdaFindCheckResult{Code: result.Code, Message: result.Message, DataCount: len(data)}
if err != nil { if err != nil {
return check, err return check, err
} }