diff --git a/contract/envelope.go b/contract/envelope.go index e80da9c..9ad7867 100644 --- a/contract/envelope.go +++ b/contract/envelope.go @@ -3,7 +3,6 @@ package contract type Envelope struct { ServiceID string `json:"serviceId"` UploadInfo any `json:"uploadinfo,omitempty"` - BaseInfo any `json:"baseInfo,omitempty"` } type UploadInfo struct { diff --git a/contract/envelope_test.go b/contract/envelope_test.go index 5f7aea5..e91facb 100644 --- a/contract/envelope_test.go +++ b/contract/envelope_test.go @@ -2,6 +2,7 @@ package contract import ( "encoding/json" + "reflect" "testing" ) @@ -27,3 +28,9 @@ func TestEnvelopeUsesUploadInfoWithBaseAndManageInfo(t *testing.T) { t.Fatalf("envelope JSON = %s", raw) } } + +func TestEnvelopeDoesNotExposeTopLevelBaseInfo(t *testing.T) { + if _, ok := reflect.TypeOf(Envelope{}).FieldByName("BaseInfo"); ok { + t.Fatal("Envelope exposes obsolete top-level BaseInfo") + } +} diff --git a/docs/03-目标架构设计.md b/docs/03-目标架构设计.md index 7e580f0..8d41976 100644 --- a/docs/03-目标架构设计.md +++ b/docs/03-目标架构设计.md @@ -124,8 +124,11 @@ func (c *Client) Call(ctx context.Context, serviceID string, body any, out any) ```go type Envelope struct { ServiceID string `json:"serviceId"` - UploadInfo any `json:"uploadinfo,omitempty"` // 创建/更新 - BaseInfo any `json:"baseInfo,omitempty"` // 查询 + UploadInfo any `json:"uploadinfo,omitempty"` // 查询/创建/更新统一信封 +} +type UploadInfo struct { + BaseInfo any `json:"baseInfo,omitempty"` + ManageInfo ManageInfo `json:"manageInfo"` } type ManageInfo struct { DSFMC, OperateUnit, OperateUser string } ``` diff --git a/docs/05-实施路线图.md b/docs/05-实施路线图.md index 762cb67..593d8cf 100644 --- a/docs/05-实施路线图.md +++ b/docs/05-实施路线图.md @@ -11,7 +11,8 @@ - [x] 移植旧项目 `transport.go`/`http_client.go` → `osi/transport.go`(保留 SOCKS5/超时,去 cookiejar 与拟态头)。 - [x] `osi/client.go` 的 `Call(serviceId, body, out)`:注入头+信封+发送+判码(成功码实测 `"01"`,按去前导零判定;`405` 可重试,见 docs/01 §1)。 - [x] `contract/envelope.go` + `osi/codes.go`(serviceId 常量 + `pathOf` 路由)。 -- **验收**:Go 侧用 JKDA00002 发真实请求,拿到 `code="01" message="操作成功"`;`init.sh` 已配置为依次执行依赖下载、测试、启动命令。 +- [x] Phase 0 review hardening:传输层默认 Accept-Encoding: identity、移除未使用 http.Client 残留、联调身份证改走 OSI_VERIFY_ID_CARD、删除 Envelope 顶层冗余 BaseInfo。 +- **验收**:Go 侧用 JKDA00002 发真实请求,拿到 `code="01" message="操作成功"`;`init.sh` 已配置为依次执行依赖下载、测试、启动命令;review hardening 后 `go test ./...`、`go build ./...` 通过。 > **执行顺序注记**:`tasks.md` 已按"查询先行"重排为 Q(查询)→ M(映射)→ D(字典+创建)。 > 原因:查询档案(Find)不依赖字典、映射单测只需码表+假字典快照,只有真实创建闭环才需字典反查主数据。 diff --git a/docs/current-state.md b/docs/current-state.md index 69dfff4..58c4ade 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -6,11 +6,11 @@ ## 当前快照 - 日期:2026-07-06 -- 阶段:**Phase 0 已完成**;T-001~T-005 已验收,下一步进入 Phase Q 的 T-201 +- 阶段:**Phase 0 已完成**;T-001~T-006 已验收,下一步进入 Phase Q 的 T-201 - 技术栈: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/` 等业务模块仍待后续任务建立 - 联调现实:**JKDA00002 个人档案查询已用 Go 侧真实请求打通**,返回 `code="01" message="操作成功" data_count=1`;Python 联调样本和实测契约沉淀在 `docs/04 §8` -- 测试:`go test ./...` 通过;当前测试覆盖 mode 解析、配置加载与环境变量覆盖、MD5 签名、请求头组装、JSON POST 传输、头名大小写保留、超时配置、SOCKS5 代理地址校验、信封结构、serviceId 路由、成功/重试判码、Client.Call 请求与响应解析、JKDA00002 验证入口 +- 测试:`go test ./...` 通过;当前测试覆盖 mode 解析、配置加载与环境变量覆盖、MD5 签名、请求头组装、JSON POST 传输、头名大小写保留、identity 响应编码声明、超时配置、SOCKS5 代理地址校验、信封结构、serviceId 路由、成功/重试判码、Client.Call 请求与响应解析、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 的当前路径 @@ -19,7 +19,7 @@ | 路径 | 状态 | 说明 | | --- | --- | --- | -| `main.go` | 已有 | 单入口,解析 `-mode server|deliver`、`-config`,并提供 `-verify-jkda-id-card` 联调验证入口 | +| `main.go` | 已有 | 单入口,解析 `-mode server|deliver`、`-config`,并提供 `-verify-jkda` 联调验证入口(身份证从 `OSI_VERIFY_ID_CARD` 读取) | | `config/` | 已有 | viper 配置加载,支持 `OSI_*` 等环境变量覆盖 | | `contract/envelope.go` | 已有 | OSI 通用信封与 `manageInfo` 结构 | | `osi/sign.go` | 已有 | OSI 请求头 MD5 签名与 headers 组装 | @@ -44,7 +44,7 @@ - 机构码分层:请求头 `orgCode`=18 位统信码 ≠ `manaUnitId`=9 位机构码 ≠ 12 位区划码,见 `docs/01 §3`。 - `deviceSN` 查询可空。 - OSI 代理语义:`socks5_proxy` 非空即只对 OSI 客户端走 SOCKS5,不做直连回退。 -- 真实平台/代理对请求头名大小写敏感;Go `net/http` 会规范化头名,OSI 传输层需保留 `orgCode/deviceSN/userName` 的原始大小写。 +- 真实平台/代理对请求头名大小写敏感;Go `net/http` 会规范化头名,OSI 传输层需保留 `orgCode/deviceSN/userName` 的原始大小写。裸写传输层默认请求 `Accept-Encoding: identity`,避免收到未解压压缩响应。 ## 当前可运行内容 @@ -60,7 +60,7 @@ go run . -mode deliver -config config.yaml.example ./init.sh # Go 侧 JKDA00002 联调验证(需通过环境变量或 config.yaml 提供真实凭据;不要提交真实值) -go run . -mode server -config config.yaml -verify-jkda-id-card +OSI_VERIFY_ID_CARD= go run . -mode server -config config.yaml -verify-jkda # 本地 Python 联调查询(脚本硬编码凭据,勿提交) python3 scripts/query_health_record.py diff --git a/main.go b/main.go index 88e3768..f59e89a 100644 --- a/main.go +++ b/main.go @@ -30,7 +30,7 @@ func parseMode(raw string) (appMode, error) { func main() { modeFlag := flag.String("mode", string(modeServer), "运行模式:server 或 deliver") configPath := flag.String("config", "config.yaml", "配置文件路径") - verifyJKDAIDCard := flag.String("verify-jkda-id-card", "", "阶段0联调验收:按身份证查询 JKDA00002;为空则不发真实请求") + verifyJKDA := flag.Bool("verify-jkda", false, "阶段0联调验收:从 OSI_VERIFY_ID_CARD 读取身份证并查询 JKDA00002") flag.Parse() mode, err := parseMode(*modeFlag) @@ -45,8 +45,13 @@ func main() { os.Exit(2) } - if *verifyJKDAIDCard != "" { - result, err := runJKDAFindCheck(context.Background(), cfg, *verifyJKDAIDCard) + if *verifyJKDA { + idCard, err := resolveVerifyIDCard(*verifyJKDA, os.Getenv) + if err != nil { + fmt.Fprintf(os.Stderr, "verify JKDA00002: %v\n", err) + os.Exit(2) + } + result, err := runJKDAFindCheck(context.Background(), cfg, idCard) if err != nil { fmt.Fprintf(os.Stderr, "verify JKDA00002 failed: code=%s message=%s err=%v\n", result.Code, result.Message, err) os.Exit(1) @@ -57,3 +62,14 @@ func main() { fmt.Printf("chis_osi mode=%s\n", mode) } + +func resolveVerifyIDCard(enabled bool, getenv func(string) string) (string, error) { + if !enabled { + return "", nil + } + idCard := getenv("OSI_VERIFY_ID_CARD") + if idCard == "" { + return "", fmt.Errorf("OSI_VERIFY_ID_CARD is required when -verify-jkda is set") + } + return idCard, nil +} diff --git a/main_test.go b/main_test.go index 4438dbd..3ddc6bb 100644 --- a/main_test.go +++ b/main_test.go @@ -19,3 +19,24 @@ func TestParseModeRejectsUnknownMode(t *testing.T) { t.Fatal("parseMode accepted unknown mode") } } + +func TestResolveVerifyIDCardReadsEnvironment(t *testing.T) { + got, err := resolveVerifyIDCard(true, func(key string) string { + if key == "OSI_VERIFY_ID_CARD" { + return "440100199001011234" + } + return "" + }) + if err != nil { + t.Fatalf("resolveVerifyIDCard: %v", err) + } + if got != "440100199001011234" { + t.Fatalf("idCard = %q", got) + } +} + +func TestResolveVerifyIDCardRequiresEnvironment(t *testing.T) { + if _, err := resolveVerifyIDCard(true, func(string) string { return "" }); err == nil { + t.Fatal("resolveVerifyIDCard accepted missing OSI_VERIFY_ID_CARD") + } +} diff --git a/osi/transport.go b/osi/transport.go index e069dd9..3026e44 100644 --- a/osi/transport.go +++ b/osi/transport.go @@ -23,7 +23,6 @@ type TransportConfig struct { } type Transport struct { - client *http.Client timeout time.Duration dialer proxy.Dialer } @@ -35,19 +34,15 @@ func NewTransport(config TransportConfig) (*Transport, error) { } dialer := proxy.Dialer(proxy.Direct) - roundTripper := http.DefaultTransport.(*http.Transport).Clone() if strings.TrimSpace(config.Socks5Proxy) != "" { proxyDialer, err := socks5Dialer(config.Socks5Proxy) if err != nil { return nil, err } dialer = proxyDialer - roundTripper.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) { - return dialWithContext(ctx, proxyDialer, network, address) - } } - return &Transport{client: &http.Client{Timeout: timeout, Transport: roundTripper}, timeout: timeout, dialer: dialer}, nil + return &Transport{timeout: timeout, dialer: dialer}, nil } func (t *Transport) PostJSON(ctx context.Context, targetURL string, payload any, headers map[string]string) (int, []byte, error) { @@ -107,7 +102,7 @@ func writeJSONRequest(w io.Writer, target *url.URL, body []byte, headers map[str fmt.Fprintf(&buf, "POST %s HTTP/1.1\r\n", path) writeHeader(&buf, "Host", target.Host) writeHeader(&buf, "User-Agent", headerValue(headers, "User-Agent", "python-requests/2.32.4")) - writeHeader(&buf, "Accept-Encoding", headerValue(headers, "Accept-Encoding", "gzip, deflate, br")) + writeHeader(&buf, "Accept-Encoding", headerValue(headers, "Accept-Encoding", "identity")) writeHeader(&buf, "Accept", headerValue(headers, "Accept", "*/*")) writeHeader(&buf, "Connection", headerValue(headers, "Connection", "keep-alive")) writeHeader(&buf, "Content-Length", fmt.Sprintf("%d", len(body))) diff --git a/osi/transport_test.go b/osi/transport_test.go index 6f1cbe9..eab5f03 100644 --- a/osi/transport_test.go +++ b/osi/transport_test.go @@ -69,8 +69,8 @@ func TestNewTransportKeepsTimeout(t *testing.T) { if err != nil { t.Fatalf("NewTransport: %v", err) } - if transport.client.Timeout != 3*time.Second { - t.Fatalf("timeout = %s, want 3s", transport.client.Timeout) + if transport.timeout != 3*time.Second { + t.Fatalf("timeout = %s, want 3s", transport.timeout) } } @@ -79,6 +79,7 @@ func TestNewTransportRejectsInvalidSOCKS5Proxy(t *testing.T) { t.Fatal("NewTransport accepted invalid SOCKS5 proxy") } } + func TestTransportPreservesHeaderNameCasing(t *testing.T) { listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { @@ -121,6 +122,7 @@ func TestTransportPreservesHeaderNameCasing(t *testing.T) { } } } + func TestTransportAddsRequestsCompatibleBaseHeaders(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("User-Agent") == "" { @@ -144,3 +146,39 @@ func TestTransportAddsRequestsCompatibleBaseHeaders(t *testing.T) { t.Fatalf("PostJSON: %v", err) } } + +func TestTransportRequestsIdentityEncoding(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + defer listener.Close() + + received := make(chan string, 1) + go func() { + conn, err := listener.Accept() + if err != nil { + received <- "" + return + } + defer conn.Close() + buf := make([]byte, 4096) + n, _ := conn.Read(buf) + received <- string(buf[:n]) + _, _ = conn.Write([]byte("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}")) + }() + + transport, err := NewTransport(TransportConfig{Timeout: time.Second}) + if err != nil { + t.Fatalf("NewTransport: %v", err) + } + _, _, err = transport.PostJSON(context.Background(), "http://"+listener.Addr().String(), map[string]string{"serviceId": "JKDA00002"}, nil) + if err != nil { + t.Fatalf("PostJSON: %v", err) + } + + raw := <-received + if !strings.Contains(raw, "\r\nAccept-Encoding: identity\r\n") { + t.Fatalf("raw request missing identity Accept-Encoding:\n%s", raw) + } +} diff --git a/progress.md b/progress.md index a1bb01d..9e44a4a 100644 --- a/progress.md +++ b/progress.md @@ -92,8 +92,15 @@ ## 2026-07-06 T-005 阶段 0 验收:Go 侧真实请求打通 + init.sh - 状态:DONE -- 变更:新增 `verify_jkda.go`/`verify_jkda_test.go`,`main.go` 增加 `-verify-jkda-id-card` 验证入口;`config` 支持 `OSI_*` 等环境变量覆盖;`osi/transport.go` 改为按原始 HTTP/1.1 写请求以保留 `orgCode/deviceSN/userName` 头名大小写;`init.sh` 配置依赖下载、`go test ./...`、启动三步命令;补充传输层头名与基础请求头单测。 +- 变更:新增 `verify_jkda.go`/`verify_jkda_test.go`,`main.go` 增加 JKDA00002 验证入口(T-006 后改为 `-verify-jkda` + `OSI_VERIFY_ID_CARD`);`config` 支持 `OSI_*` 等环境变量覆盖;`osi/transport.go` 改为按原始 HTTP/1.1 写请求以保留 `orgCode/deviceSN/userName` 头名大小写;`init.sh` 配置依赖下载、`go test ./...`、启动三步命令;补充传输层头名与基础请求头单测。 - 验证:`go test ./...` 通过;`go build ./...` 通过;使用本地环境变量发起 Go 版 JKDA00002 真实请求,返回 `JKDA00002 code=01 message=操作成功 data_count=1`。 - 验证补充:`init.sh` 三命令已替换;当前机器从 Git Bash 启动 Go 会出现标准库路径/构建缓存权限异常,导致脚本运行环境未能完整验收。等价 PowerShell 下直接执行 `go test ./...`、`go build ./...` 与 Go 真实请求均通过。 - 决策:真实平台/代理对请求头大小写敏感,Go `net/http` 会规范化头名并触发 EOF;传输层因此对 OSI 调用使用原始 HTTP/1.1 写入,保留 Python requests 已验证的头名大小写与基础头语义。 - 下一步:T-201(`contract/jkda.go` 查询响应结构体)。 +## 2026-07-06 T-006 Phase 0 review hardening + +- 状态:DONE +- 变更:`osi/transport.go` 默认 `Accept-Encoding` 改为 `identity`,避免声明 gzip/br 却不解压;删除传输层未使用的 `http.Client`/roundTripper 残留;`main.go` 验证入口改为 `-verify-jkda` 并从 `OSI_VERIFY_ID_CARD` 读取身份证;删除 `contract.Envelope` 顶层 `BaseInfo`,保留 `uploadinfo.baseInfo`;同步 `docs/03`、`docs/current-state.md` 与 `tasks.md`。 +- 验证:按 TDD 先看到 `TestTransportRequestsIdentityEncoding`、`TestEnvelopeDoesNotExposeTopLevelBaseInfo`、`TestResolveVerifyIDCard*` 失败;实现后目标测试通过;`go test ./...` 通过;`go build ./...` 通过;`git diff --check` 通过。 +- 决策:Phase 0 先避免压缩响应风险,不实现 gzip/br 解压;https 支持仍按当前沙箱事实 fail-fast,生产若切 https 需另起任务处理 TLS 下的头名保留策略。 +- 下一步:T-201(`contract/jkda.go` 查询响应结构体)。 diff --git a/tasks.md b/tasks.md index 0e02048..164b1e6 100644 --- a/tasks.md +++ b/tasks.md @@ -30,6 +30,7 @@ | T-003 | `osi/transport.go`:从 chis_upload 移植传输层 | T-001 | 保留 SOCKS5/超时;去掉 cookiejar 与网页拟态头;单测或最小连通验证 | DONE | | T-004 | `osi/client.go` `Call` + `osi/codes.go` + `contract/envelope.go` | T-002, T-003 | 信封为 `serviceId`+`uploadinfo{baseInfo,manageInfo,...}`(docs/04 §8);成功码按去前导零 == `"1"` 判定(实测 `"01"`,docs/01 §1);`405` 归类可重试 | DONE | | T-005 | 阶段 0 验收:Go 侧真实请求打通 + 配置 `init.sh` | T-004 | 用 JKDA00002(Python 已验证的同一查询)发真实请求拿到 `code="01"`;`./init.sh` 三命令替换完成,本机 Git Bash 运行限制见 progress 记录 | DONE | +| T-006 | Phase 0 review hardening | T-005 | 处理审核指出的 gzip 声明、传输层死代码、命令行 PII、Envelope 冗余;`go test ./...` 通过 | DONE | > **执行顺序说明(2026-07-06 重排,查询先行)**:原路线图"阶段1字典 → 阶段2档案"的顺序假设了先建字典。 > 但**查询档案(Find)根本不依赖字典**,**映射纯函数单测**也只需码表 + 注入假字典快照。