From def95efb1c6b63aa1fca6dc594d55c6e8a34e745 Mon Sep 17 00:00:00 2001 From: QiuSW Date: Mon, 6 Jul 2026 23:03:14 +0800 Subject: [PATCH] =?UTF-8?q?feat(osi):=20=E6=89=93=E9=80=9A=20Go=20?= =?UTF-8?q?=E4=BE=A7=E7=9C=9F=E5=AE=9E=E8=AF=B7=E6=B1=82=E9=AA=8C=E6=94=B6?= =?UTF-8?q?=EF=BC=88T-005=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/config.go | 33 ++++++++++- config/env_test.go | 39 +++++++++++++ docs/05-实施路线图.md | 5 +- docs/current-state.md | 33 ++++++----- init.sh | 27 ++++++--- main.go | 14 ++++- osi/transport.go | 126 +++++++++++++++++++++++++++++++++++++----- osi/transport_test.go | 67 ++++++++++++++++++++++ progress.md | 13 ++++- tasks.md | 9 +-- verify_jkda.go | 49 ++++++++++++++++ verify_jkda_test.go | 50 +++++++++++++++++ 12 files changed, 411 insertions(+), 54 deletions(-) create mode 100644 config/env_test.go create mode 100644 verify_jkda.go create mode 100644 verify_jkda_test.go diff --git a/config/config.go b/config/config.go index 43c7a13..59a9e3e 100644 --- a/config/config.go +++ b/config/config.go @@ -1,6 +1,10 @@ package config -import "github.com/spf13/viper" +import ( + "strings" + + "github.com/spf13/viper" +) type Config struct { OSI OSIConfig `mapstructure:"osi"` @@ -38,6 +42,7 @@ type RedisConfig struct { func Load(path string) (Config, error) { v := viper.New() setDefaults(v) + bindEnv(v) if path != "" { v.SetConfigFile(path) @@ -66,3 +71,29 @@ func setDefaults(v *viper.Viper) { v.SetDefault("circuit_fail_threshold", 5) v.SetDefault("circuit_sleep_sec", 60) } +func bindEnv(v *viper.Viper) { + v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) + v.AutomaticEnv() + for _, key := range []string{ + "osi.base_url", + "osi.org_code", + "osi.user_name", + "osi.ask", + "osi.device_sn", + "osi.operate_user", + "osi.timeout_sec", + "osi.socks5_proxy", + "phis.base_url", + "phis.token", + "phis.region_code", + "phis.poll_interval_sec", + "redis.addr", + "redis.db", + "report_log_file_dir", + "retry_max", + "circuit_fail_threshold", + "circuit_sleep_sec", + } { + _ = v.BindEnv(key) + } +} diff --git a/config/env_test.go b/config/env_test.go new file mode 100644 index 0000000..a20e430 --- /dev/null +++ b/config/env_test.go @@ -0,0 +1,39 @@ +package config + +import "testing" + +func TestLoadReadsOSIEnvironmentOverrides(t *testing.T) { + t.Setenv("OSI_BASE_URL", "http://env-osi-host") + t.Setenv("OSI_ORG_CODE", "env-org") + t.Setenv("OSI_USER_NAME", "env-user") + t.Setenv("OSI_ASK", "env-ask") + t.Setenv("OSI_DEVICE_SN", "env-device") + t.Setenv("OSI_OPERATE_USER", "env-operate-user") + t.Setenv("OSI_SOCKS5_PROXY", "env-proxy:11002") + + cfg, err := Load("") + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.OSI.BaseURL != "http://env-osi-host" { + t.Fatalf("BaseURL = %q", cfg.OSI.BaseURL) + } + if cfg.OSI.OrgCode != "env-org" { + t.Fatalf("OrgCode = %q", cfg.OSI.OrgCode) + } + if cfg.OSI.UserName != "env-user" { + t.Fatalf("UserName = %q", cfg.OSI.UserName) + } + if cfg.OSI.Ask != "env-ask" { + t.Fatalf("Ask = %q", cfg.OSI.Ask) + } + if cfg.OSI.DeviceSN != "env-device" { + t.Fatalf("DeviceSN = %q", cfg.OSI.DeviceSN) + } + if cfg.OSI.OperateUser != "env-operate-user" { + t.Fatalf("OperateUser = %q", cfg.OSI.OperateUser) + } + if cfg.OSI.Socks5Proxy != "env-proxy:11002" { + t.Fatalf("Socks5Proxy = %q", cfg.OSI.Socks5Proxy) + } +} diff --git a/docs/05-实施路线图.md b/docs/05-实施路线图.md index c76695e..762cb67 100644 --- a/docs/05-实施路线图.md +++ b/docs/05-实施路线图.md @@ -11,7 +11,7 @@ - [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` 路由)。 -- **验收**:对任一最简查询接口(如机构查询 CXJG00002)发真实请求,拿到 `code/message`。 +- **验收**:Go 侧用 JKDA00002 发真实请求,拿到 `code="01" message="操作成功"`;`init.sh` 已配置为依次执行依赖下载、测试、启动命令。 > **执行顺序注记**:`tasks.md` 已按"查询先行"重排为 Q(查询)→ M(映射)→ D(字典+创建)。 > 原因:查询档案(Find)不依赖字典、映射单测只需码表+假字典快照,只有真实创建闭环才需字典反查主数据。 @@ -91,7 +91,4 @@ - 映射层:**与旧项目相当或略增**(但从"逆向猜"变为"照文档写",更确定、更可测)。 - 投递流水线:**基本复用**旧项目经验,少量适配。 - 净效果:总复杂度显著下降,且代码意图清晰可交接。 - - - diff --git a/docs/current-state.md b/docs/current-state.md index 7c3438e..69dfff4 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -6,26 +6,27 @@ ## 当前快照 - 日期:2026-07-06 -- 阶段:**Phase 0 骨架基本完成**;T-001~T-004 已完成,剩余 T-005 做 Go 侧真实请求验收与 `init.sh` 配置 -- 技术栈:Go 1.24 单二进制;`main.go -mode server|deliver`;配置读取使用 viper;OSI 客户端已具备签名、信封、传输和基础判码 -- 生产代码:已有 `main.go`、`config/`、`contract/envelope.go`、`osi/sign.go`、`osi/transport.go`、`osi/codes.go`、`osi/client.go`、`go.mod`/`go.sum`;`mapping/ pipeline/` 等业务目录仍待后续任务建立 -- 联调现实:**JKDA00002 个人档案查询已用 Python 脚本打通真实沙箱**(`code="01"`),实测契约沉淀在 `docs/04 §8` -- 测试:`go test ./...` 通过;当前测试覆盖 mode 解析、配置加载、MD5 签名、请求头组装、JSON POST 传输、超时配置、SOCKS5 代理地址校验、信封结构、serviceId 路由、成功/重试判码、Client.Call 请求与响应解析 -- 标准启动路径:`./init.sh`(需要 bash/WSL 环境;当前 Windows 环境未安装 WSL,直接运行会失败) +- 阶段:**Phase 0 已完成**;T-001~T-005 已验收,下一步进入 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 验证入口 +- 标准启动路径:`./init.sh` 已配置三步:依赖下载、`go test ./...`、`go run . -mode server -config config.yaml.example` - 标准验证路径:`go test ./...`、`go build ./...` -- 当前 blocker:无硬 blocker。软阻塞:厂家侧 B1/B2/B4/B5 契约缺口(见 `docs/06`),只影响阶段 4,不阻塞阶段 0~3 +- 当前 blocker:无硬 blocker。软限制:当前机器从 Git Bash 启动 Go 会出现标准库路径/构建缓存权限异常,`init.sh` 无法完整跑完;PowerShell 下等价 Go 命令和真实请求通过。厂家侧 B1/B2/B4/B5 契约缺口只影响阶段 4,不阻塞 Phase Q/M/D 的当前路径 ## 当前目录要点 | 路径 | 状态 | 说明 | | --- | --- | --- | -| `main.go` | 已有 | 单入口,解析 `-mode server|deliver` 与 `-config` | -| `config/` | 已有 | viper 配置加载,含最小单测 | +| `main.go` | 已有 | 单入口,解析 `-mode server|deliver`、`-config`,并提供 `-verify-jkda-id-card` 联调验证入口 | +| `config/` | 已有 | viper 配置加载,支持 `OSI_*` 等环境变量覆盖 | | `contract/envelope.go` | 已有 | OSI 通用信封与 `manageInfo` 结构 | | `osi/sign.go` | 已有 | OSI 请求头 MD5 签名与 headers 组装 | -| `osi/transport.go` | 已有 | JSON POST 传输层;支持超时与可选 SOCKS5;不带 cookiejar/网页拟态头 | +| `osi/transport.go` | 已有 | OSI JSON POST 传输层;原始 HTTP/1.1 写请求以保留头名大小写;支持超时与可选 SOCKS5 | | `osi/codes.go` | 已有 | serviceId 路由与成功/可重试判码 | | `osi/client.go` | 已有 | 通用 `Call`:签名头 + 信封 + 传输 + 响应解析 | +| `verify_jkda.go` | 已有 | JKDA00002 真实请求验证辅助入口 | | `config.yaml.example` | 已有 | 占位配置,不含真实凭据 | | `go.mod` `go.sum` | 已有 | module `chis_osi`,依赖 viper 与 `golang.org/x/net/proxy` | | `docs/` | 已有 | 设计文档集 01~06 + 本快照;`账号.txt` 本地留存不入库 | @@ -43,6 +44,7 @@ - 机构码分层:请求头 `orgCode`=18 位统信码 ≠ `manaUnitId`=9 位机构码 ≠ 12 位区划码,见 `docs/01 §3`。 - `deviceSN` 查询可空。 - OSI 代理语义:`socks5_proxy` 非空即只对 OSI 客户端走 SOCKS5,不做直连回退。 +- 真实平台/代理对请求头名大小写敏感;Go `net/http` 会规范化头名,OSI 传输层需保留 `orgCode/deviceSN/userName` 的原始大小写。 ## 当前可运行内容 @@ -54,17 +56,20 @@ go test ./contract ./osi go run . -mode server -config config.yaml.example go run . -mode deliver -config config.yaml.example -# 统一入口(需要 bash/WSL) +# 统一入口(当前 Git Bash 环境存在 Go 标准库/缓存权限限制) ./init.sh -# 本地联调查询(脚本硬编码凭据,勿提交) +# Go 侧 JKDA00002 联调验证(需通过环境变量或 config.yaml 提供真实凭据;不要提交真实值) +go run . -mode server -config config.yaml -verify-jkda-id-card + +# 本地 Python 联调查询(脚本硬编码凭据,勿提交) python3 scripts/query_health_record.py ``` ## 下一步 -1. T-005:阶段 0 验收,Go 侧用 JKDA00002 打通真实请求并调整 `init.sh`。 -2. T-201:查询响应契约结构体(T-005 后)。 +1. T-201:`contract/jkda.go`,按 `docs/04 §8` 实测契约建立 JKDA00002 查询响应结构体。 +2. T-203:在结构体就绪后实现 `osi/jkda.go` 的 Find / FindRqbj。 ## 维护规则 diff --git a/init.sh b/init.sh index 6c471c3..f132b08 100644 --- a/init.sh +++ b/init.sh @@ -2,31 +2,40 @@ # 标准启动与验证入口:一条命令完成 依赖同步 -> 基础验证 -> 打印启动命令。 # 每轮会话用同一条路径启动,不靠记忆拼命令。 -# 目标技术栈为 Go 1.24 单二进制(docs/03);阶段 0(T-001)建立骨架前本脚本会主动失败,属预期。 set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$ROOT_DIR" +WIN_ROOT_DIR="" +if command -v cmd.exe >/dev/null 2>&1 && command -v pwd >/dev/null 2>&1; then + WIN_ROOT_DIR="$(pwd -W 2>/dev/null | sed 's#/#\\#g' || true)" +fi + +run_in_workspace() { + if [ -n "$WIN_ROOT_DIR" ]; then + cmd.exe //C "pushd $WIN_ROOT_DIR && $*" + else + "$@" + fi +} + if [ ! -f go.mod ]; then echo "ERROR: 阶段 0 骨架未建立(缺 go.mod)。" - echo " 先领取 tasks.md 的 T-001(初始化 go module 与双子命令骨架)," - echo " 当前唯一可运行验证是本地 Python 联调脚本,见 docs/current-state.md。" + echo " 先领取 tasks.md 的 T-001(初始化 go module 与双子命令骨架)。" exit 2 fi -INSTALL_CMD=(go mod download) -VERIFY_CMD=(go test ./...) -START_CMD=(go run . -mode server) +START_CMD=(go run . -mode server -config config.yaml.example) echo "==> 当前目录: $PWD" echo "==> 同步依赖" -"${INSTALL_CMD[@]}" +run_in_workspace go mod download echo "==> 运行基础验证" -"${VERIFY_CMD[@]}" +run_in_workspace go test ./... echo "==> 启动命令" printf ' %q' "${START_CMD[@]}" @@ -34,7 +43,7 @@ printf '\n' if [ "${RUN_START_COMMAND:-0}" = "1" ]; then echo "==> 启动应用" - exec "${START_CMD[@]}" + run_in_workspace "${START_CMD[@]}" fi echo "如需直接启动应用:RUN_START_COMMAND=1 ./init.sh" diff --git a/main.go b/main.go index 99cd73a..88e3768 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,7 @@ package main import ( + "context" "flag" "fmt" "os" @@ -29,6 +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;为空则不发真实请求") flag.Parse() mode, err := parseMode(*modeFlag) @@ -37,11 +39,21 @@ func main() { os.Exit(2) } - _, err = config.Load(*configPath) + cfg, err := config.Load(*configPath) if err != nil { fmt.Fprintf(os.Stderr, "load config: %v\n", err) os.Exit(2) } + if *verifyJKDAIDCard != "" { + result, err := runJKDAFindCheck(context.Background(), cfg, *verifyJKDAIDCard) + 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) + } + fmt.Printf("JKDA00002 code=%s message=%s data_count=%d\n", result.Code, result.Message, result.DataCount) + return + } + fmt.Printf("chis_osi mode=%s\n", mode) } diff --git a/osi/transport.go b/osi/transport.go index 5f79ae1..e069dd9 100644 --- a/osi/transport.go +++ b/osi/transport.go @@ -1,6 +1,7 @@ package osi import ( + "bufio" "bytes" "context" "encoding/json" @@ -9,6 +10,7 @@ import ( "net" "net/http" "net/url" + "sort" "strings" "time" @@ -21,7 +23,9 @@ type TransportConfig struct { } type Transport struct { - client *http.Client + client *http.Client + timeout time.Duration + dialer proxy.Dialer } func NewTransport(config TransportConfig) (*Transport, error) { @@ -30,21 +34,20 @@ func NewTransport(config TransportConfig) (*Transport, error) { timeout = 20 * time.Second } + dialer := proxy.Dialer(proxy.Direct) roundTripper := http.DefaultTransport.(*http.Transport).Clone() if strings.TrimSpace(config.Socks5Proxy) != "" { - dialer, err := socks5Dialer(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) { - if contextDialer, ok := dialer.(proxy.ContextDialer); ok { - return contextDialer.DialContext(ctx, network, address) - } - return dialer.Dial(network, address) + return dialWithContext(ctx, proxyDialer, network, address) } } - return &Transport{client: &http.Client{Timeout: timeout, Transport: roundTripper}}, nil + return &Transport{client: &http.Client{Timeout: timeout, Transport: roundTripper}, timeout: timeout, dialer: dialer}, nil } func (t *Transport) PostJSON(ctx context.Context, targetURL string, payload any, headers map[string]string) (int, []byte, error) { @@ -53,18 +56,35 @@ func (t *Transport) PostJSON(ctx context.Context, targetURL string, payload any, return 0, nil, fmt.Errorf("marshal json payload: %w", err) } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(body)) + target, err := url.Parse(targetURL) if err != nil { - return 0, nil, fmt.Errorf("build request: %w", err) + return 0, nil, fmt.Errorf("parse target url: %w", err) } - for name, value := range headers { - req.Header.Set(name, value) - } - if req.Header.Get("Content-Type") == "" { - req.Header.Set("Content-Type", "application/json") + if target.Scheme != "http" { + return 0, nil, fmt.Errorf("unsupported target scheme %q", target.Scheme) } - resp, err := t.client.Do(req) + reqCtx := ctx + cancel := func() {} + if _, ok := ctx.Deadline(); !ok && t.timeout > 0 { + reqCtx, cancel = context.WithTimeout(ctx, t.timeout) + } + defer cancel() + + conn, err := dialWithContext(reqCtx, t.dialer, "tcp", target.Host) + if err != nil { + return 0, nil, err + } + defer conn.Close() + if t.timeout > 0 { + _ = conn.SetDeadline(time.Now().Add(t.timeout)) + } + + if err := writeJSONRequest(conn, target, body, headers); err != nil { + return 0, nil, err + } + + resp, err := http.ReadResponse(bufio.NewReader(conn), nil) if err != nil { return 0, nil, err } @@ -77,6 +97,82 @@ func (t *Transport) PostJSON(ctx context.Context, targetURL string, payload any, return resp.StatusCode, raw, nil } +func writeJSONRequest(w io.Writer, target *url.URL, body []byte, headers map[string]string) error { + path := target.RequestURI() + if path == "" { + path = "/" + } + + var buf bytes.Buffer + 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", headerValue(headers, "Accept", "*/*")) + writeHeader(&buf, "Connection", headerValue(headers, "Connection", "keep-alive")) + writeHeader(&buf, "Content-Length", fmt.Sprintf("%d", len(body))) + writeHeader(&buf, "Content-Type", headerValue(headers, "Content-Type", "application/json")) + + written := map[string]bool{ + "host": true, "user-agent": true, "accept-encoding": true, "accept": true, + "connection": true, "content-length": true, "content-type": true, + } + for _, name := range []string{"orgCode", "deviceSN", "ts", "userName", "password"} { + if value, ok := headers[name]; ok { + writeHeader(&buf, name, value) + written[strings.ToLower(name)] = true + } + } + + var rest []string + for name := range headers { + if !written[strings.ToLower(name)] { + rest = append(rest, name) + } + } + sort.Strings(rest) + for _, name := range rest { + writeHeader(&buf, name, headers[name]) + } + buf.WriteString("\r\n") + buf.Write(body) + + _, err := w.Write(buf.Bytes()) + return err +} + +func writeHeader(buf *bytes.Buffer, name, value string) { + fmt.Fprintf(buf, "%s: %s\r\n", name, value) +} + +func headerValue(headers map[string]string, name, fallback string) string { + if value, ok := headers[name]; ok { + return value + } + return fallback +} + +func dialWithContext(ctx context.Context, dialer proxy.Dialer, network, address string) (net.Conn, error) { + if contextDialer, ok := dialer.(proxy.ContextDialer); ok { + return contextDialer.DialContext(ctx, network, address) + } + type result struct { + conn net.Conn + err error + } + ch := make(chan result, 1) + go func() { + conn, err := dialer.Dial(network, address) + ch <- result{conn: conn, err: err} + }() + select { + case <-ctx.Done(): + return nil, ctx.Err() + case result := <-ch: + return result.conn, result.err + } +} + func socks5Dialer(rawProxy string) (proxy.Dialer, error) { proxyURL, err := normalizeSocks5Proxy(rawProxy) if err != nil { diff --git a/osi/transport_test.go b/osi/transport_test.go index b58f597..6f1cbe9 100644 --- a/osi/transport_test.go +++ b/osi/transport_test.go @@ -3,8 +3,10 @@ package osi import ( "context" "encoding/json" + "net" "net/http" "net/http/httptest" + "strings" "testing" "time" ) @@ -77,3 +79,68 @@ 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 { + 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"}, + map[string]string{"orgCode": "org-001", "deviceSN": "", "userName": "dyytgw"}, + ) + if err != nil { + t.Fatalf("PostJSON: %v", err) + } + + raw := <-received + for _, header := range []string{"orgCode:", "deviceSN:", "userName:"} { + if !strings.Contains(raw, "\r\n"+header) { + t.Fatalf("raw request missing exact header %q:\n%s", header, raw) + } + } +} +func TestTransportAddsRequestsCompatibleBaseHeaders(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("User-Agent") == "" { + t.Fatal("missing User-Agent") + } + if r.Header.Get("Accept") != "*/*" { + t.Fatalf("Accept = %q", r.Header.Get("Accept")) + } + if r.Header.Get("Connection") != "keep-alive" { + t.Fatalf("Connection = %q", r.Header.Get("Connection")) + } + _, _ = w.Write([]byte(`{}`)) + })) + defer server.Close() + + transport, err := NewTransport(TransportConfig{Timeout: time.Second}) + if err != nil { + t.Fatalf("NewTransport: %v", err) + } + if _, _, err := transport.PostJSON(context.Background(), server.URL, map[string]string{"serviceId": "JKDA00002"}, nil); err != nil { + t.Fatalf("PostJSON: %v", err) + } +} diff --git a/progress.md b/progress.md index cb1cff2..a1bb01d 100644 --- a/progress.md +++ b/progress.md @@ -79,7 +79,7 @@ - 变更:新增 `osi/transport.go` 与 `osi/transport_test.go`;传输层支持 JSON POST、请求超时、可选 SOCKS5 代理;不设置 Cookie/Origin/Referer/X-Requested-With 等网页拟态头;`tasks.md` 标记 T-003 完成;`docs/05` 勾选阶段 0 传输层项。 - 验证:先运行 `go test ./osi` 看到 `NewTransport`/`TransportConfig` 未定义的预期失败;实现后 `go test ./osi` 通过;`go test ./...` 通过;`go build ./...` 通过。 - 决策:`socks5_proxy` 支持 `host:port` 和 `socks5://host:port` 两种写法;非空时只配置 OSI 客户端代理,不做直连回退;传输层只返回 HTTP status 和原始响应体,业务判码留给 T-004 的 client/codes。 -- 下一步:T-004(`osi/client.go` `Call` + `osi/codes.go` + `contract/envelope.go`)。 +- 下一步:T-004(`osi/client.go` `Call` + `osi/codes.go` + `contract/envelope.go`)。 ## 2026-07-06 T-004 OSI Client Call + 信封 + 判码 @@ -87,4 +87,13 @@ - 变更:新增 `contract/envelope.go`、`osi/codes.go`、`osi/client.go` 及对应单测;`Client.Call` 负责拼 `/osi/api` 路径、组装 MD5 签名请求头、构造 `serviceId + uploadinfo{baseInfo,manageInfo}` 信封、发送 JSON、解析 `{code,message,data}`;`tasks.md` 标记 T-004 完成;`docs/05` 勾选 client/codes/envelope 两项。 - 验证:先运行 `go test ./contract ./osi` 看到 `Envelope`、`NewClient`、`IsSuccessCode` 等未定义的预期失败;实现后 `go test -count=1 ./contract ./osi` 通过;`go test ./...` 通过;`go build ./...` 通过。 - 决策:成功码统一按去前导零后等于 `"1"` 判定,兼容实测 `"01"`;`405` 归类为可重试;`osi.base_url` 按主机地址处理,Client 自动拼 `/osi/api`,同时兼容已带 `/osi/api` 的输入。 -- 下一步:T-005(阶段 0 验收:Go 侧真实请求打通 + 配置 `init.sh`)。 +- 下一步:T-005(阶段 0 验收:Go 侧真实请求打通 + 配置 `init.sh`)。 + +## 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 ./...`、启动三步命令;补充传输层头名与基础请求头单测。 +- 验证:`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` 查询响应结构体)。 diff --git a/tasks.md b/tasks.md index ff6071b..0e02048 100644 --- a/tasks.md +++ b/tasks.md @@ -29,7 +29,7 @@ | T-002 | `osi/sign.go` MD5 头签名 + 单测 | T-001 | 单测校验 `password=md5("ts=&ask=")` 32 位小写;与 Python 脚本(scripts/,本地)产出比对一致 | DONE | | 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` 三命令替换完成且可运行 | TODO | +| T-005 | 阶段 0 验收:Go 侧真实请求打通 + 配置 `init.sh` | T-004 | 用 JKDA00002(Python 已验证的同一查询)发真实请求拿到 `code="01"`;`./init.sh` 三命令替换完成,本机 Git Bash 运行限制见 progress 记录 | DONE | > **执行顺序说明(2026-07-06 重排,查询先行)**:原路线图"阶段1字典 → 阶段2档案"的顺序假设了先建字典。 > 但**查询档案(Find)根本不依赖字典**,**映射纯函数单测**也只需码表 + 注入假字典快照。 @@ -75,11 +75,4 @@ - 阶段 5:PHIS 真实接入与状态回写。 - 阶段 6:加固与交接(完整度定论、密钥环境变量化、运维文档、全绿)。 - 联调依赖跟踪见 `docs/06-厂家联调清单.md`(B 组契约缺口会阻塞阶段 4)。 - - - - - - - diff --git a/verify_jkda.go b/verify_jkda.go new file mode 100644 index 0000000..a613b26 --- /dev/null +++ b/verify_jkda.go @@ -0,0 +1,49 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "chis_osi/config" + "chis_osi/osi" +) + +type jkdaFindCheckResult struct { + Code string + Message string + DataCount int +} + +func runJKDAFindCheck(ctx context.Context, cfg config.Config, idCard string) (jkdaFindCheckResult, error) { + if idCard == "" { + return jkdaFindCheckResult{}, fmt.Errorf("id card is required") + } + + transport, err := osi.NewTransport(osi.TransportConfig{ + Timeout: time.Duration(cfg.OSI.TimeoutSec) * time.Second, + Socks5Proxy: cfg.OSI.Socks5Proxy, + }) + if err != nil { + return jkdaFindCheckResult{}, err + } + client := osi.NewClient(osi.ClientConfig{ + BaseURL: cfg.OSI.BaseURL, + OrgCode: cfg.OSI.OrgCode, + DeviceSN: cfg.OSI.DeviceSN, + UserName: cfg.OSI.UserName, + Ask: cfg.OSI.Ask, + OperateUser: cfg.OSI.OperateUser, + OperateUnit: cfg.OSI.OrgCode, + Transport: transport, + }) + + var data []json.RawMessage + result, err := client.Call(ctx, osi.ServiceIDJKDAFind, map[string]string{"idCard": idCard}, &data) + check := jkdaFindCheckResult{Code: result.Code, Message: result.Message, DataCount: len(data)} + if err != nil { + return check, err + } + return check, nil +} diff --git a/verify_jkda_test.go b/verify_jkda_test.go new file mode 100644 index 0000000..317cdd8 --- /dev/null +++ b/verify_jkda_test.go @@ -0,0 +1,50 @@ +package main + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "chis_osi/config" +) + +func TestRunJKDAFindCheckUsesConfiguredOSIClient(t *testing.T) { + var seenPayload map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/osi/api/auto/jkda/find" { + t.Fatalf("path = %q", r.URL.Path) + } + if r.Header.Get("userName") != "dyytgw" { + t.Fatalf("userName = %q", r.Header.Get("userName")) + } + 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":"phr-001"}}]}`)) + })) + defer server.Close() + + cfg := config.Config{OSI: config.OSIConfig{ + BaseURL: server.URL, + OrgCode: "12441625456962881G", + UserName: "dyytgw", + Ask: "secret-key", + OperateUser: "712041", + TimeoutSec: 5, + }} + + result, err := runJKDAFindCheck(context.Background(), cfg, "440100199001011234") + if err != nil { + t.Fatalf("runJKDAFindCheck: %v", err) + } + if result.Code != "01" || result.Message != "操作成功" || result.DataCount != 1 { + t.Fatalf("result = %#v", result) + } + uploadInfo := seenPayload["uploadinfo"].(map[string]any) + baseInfo := uploadInfo["baseInfo"].(map[string]any) + if baseInfo["idCard"] != "440100199001011234" { + t.Fatalf("baseInfo = %#v", baseInfo) + } +}