fix(osi): 加固 Phase 0 验收实现(T-006)

This commit is contained in:
ila
2026-07-06 23:20:42 +08:00
parent def95efb1c
commit 9db2e84476
11 changed files with 110 additions and 22 deletions
-1
View File
@@ -3,7 +3,6 @@ package contract
type Envelope struct { type Envelope struct {
ServiceID string `json:"serviceId"` ServiceID string `json:"serviceId"`
UploadInfo any `json:"uploadinfo,omitempty"` UploadInfo any `json:"uploadinfo,omitempty"`
BaseInfo any `json:"baseInfo,omitempty"`
} }
type UploadInfo struct { type UploadInfo struct {
+7
View File
@@ -2,6 +2,7 @@ package contract
import ( import (
"encoding/json" "encoding/json"
"reflect"
"testing" "testing"
) )
@@ -27,3 +28,9 @@ func TestEnvelopeUsesUploadInfoWithBaseAndManageInfo(t *testing.T) {
t.Fatalf("envelope JSON = %s", raw) 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")
}
}
+5 -2
View File
@@ -124,8 +124,11 @@ func (c *Client) Call(ctx context.Context, serviceID string, body any, out any)
```go ```go
type Envelope struct { type Envelope struct {
ServiceID string `json:"serviceId"` ServiceID string `json:"serviceId"`
UploadInfo any `json:"uploadinfo,omitempty"` // 创建/更新 UploadInfo any `json:"uploadinfo,omitempty"` // 查询/创建/更新统一信封
BaseInfo any `json:"baseInfo,omitempty"` // 查询 }
type UploadInfo struct {
BaseInfo any `json:"baseInfo,omitempty"`
ManageInfo ManageInfo `json:"manageInfo"`
} }
type ManageInfo struct { DSFMC, OperateUnit, OperateUser string } type ManageInfo struct { DSFMC, OperateUnit, OperateUser string }
``` ```
+2 -1
View File
@@ -11,7 +11,8 @@
- [x] 移植旧项目 `transport.go`/`http_client.go` → `osi/transport.go`(保留 SOCKS5/超时,去 cookiejar 与拟态头)。 - [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] `osi/client.go` 的 `Call(serviceId, body, out)`:注入头+信封+发送+判码(成功码实测 `"01"`,按去前导零判定;`405` 可重试,见 docs/01 §1)。
- [x] `contract/envelope.go` + `osi/codes.go`(serviceId 常量 + `pathOf` 路由)。 - [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(字典+创建)。 > **执行顺序注记**:`tasks.md` 已按"查询先行"重排为 Q(查询)→ M(映射)→ D(字典+创建)。
> 原因:查询档案(Find)不依赖字典、映射单测只需码表+假字典快照,只有真实创建闭环才需字典反查主数据。 > 原因:查询档案(Find)不依赖字典、映射单测只需码表+假字典快照,只有真实创建闭环才需字典反查主数据。
+5 -5
View File
@@ -6,11 +6,11 @@
## 当前快照 ## 当前快照
- 日期:2026-07-06 - 日期: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 验证入口 - 技术栈: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`、`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 传输、头名大小写保留、超时配置、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` - 标准启动路径:`./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 的当前路径
@@ -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_*` 等环境变量覆盖 | | `config/` | 已有 | viper 配置加载,支持 `OSI_*` 等环境变量覆盖 |
| `contract/envelope.go` | 已有 | OSI 通用信封与 `manageInfo` 结构 | | `contract/envelope.go` | 已有 | OSI 通用信封与 `manageInfo` 结构 |
| `osi/sign.go` | 已有 | OSI 请求头 MD5 签名与 headers 组装 | | `osi/sign.go` | 已有 | OSI 请求头 MD5 签名与 headers 组装 |
@@ -44,7 +44,7 @@
- 机构码分层:请求头 `orgCode`=18 位统信码 ≠ `manaUnitId`=9 位机构码 ≠ 12 位区划码,见 `docs/01 §3`。 - 机构码分层:请求头 `orgCode`=18 位统信码 ≠ `manaUnitId`=9 位机构码 ≠ 12 位区划码,见 `docs/01 §3`。
- `deviceSN` 查询可空。 - `deviceSN` 查询可空。
- OSI 代理语义:`socks5_proxy` 非空即只对 OSI 客户端走 SOCKS5,不做直连回退。 - 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 ./init.sh
# Go 侧 JKDA00002 联调验证(需通过环境变量或 config.yaml 提供真实凭据;不要提交真实值) # Go 侧 JKDA00002 联调验证(需通过环境变量或 config.yaml 提供真实凭据;不要提交真实值)
go run . -mode server -config config.yaml -verify-jkda-id-card <idCard> OSI_VERIFY_ID_CARD=<idCard> go run . -mode server -config config.yaml -verify-jkda
# 本地 Python 联调查询(脚本硬编码凭据,勿提交) # 本地 Python 联调查询(脚本硬编码凭据,勿提交)
python3 scripts/query_health_record.py python3 scripts/query_health_record.py
+19 -3
View File
@@ -30,7 +30,7 @@ func parseMode(raw string) (appMode, error) {
func main() { func main() {
modeFlag := flag.String("mode", string(modeServer), "运行模式:server 或 deliver") modeFlag := flag.String("mode", string(modeServer), "运行模式:server 或 deliver")
configPath := flag.String("config", "config.yaml", "配置文件路径") 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() flag.Parse()
mode, err := parseMode(*modeFlag) mode, err := parseMode(*modeFlag)
@@ -45,8 +45,13 @@ func main() {
os.Exit(2) os.Exit(2)
} }
if *verifyJKDAIDCard != "" { if *verifyJKDA {
result, err := runJKDAFindCheck(context.Background(), cfg, *verifyJKDAIDCard) 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 { if err != nil {
fmt.Fprintf(os.Stderr, "verify JKDA00002 failed: code=%s message=%s err=%v\n", result.Code, result.Message, err) fmt.Fprintf(os.Stderr, "verify JKDA00002 failed: code=%s message=%s err=%v\n", result.Code, result.Message, err)
os.Exit(1) os.Exit(1)
@@ -57,3 +62,14 @@ func main() {
fmt.Printf("chis_osi mode=%s\n", mode) 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
}
+21
View File
@@ -19,3 +19,24 @@ func TestParseModeRejectsUnknownMode(t *testing.T) {
t.Fatal("parseMode accepted unknown mode") 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")
}
}
+2 -7
View File
@@ -23,7 +23,6 @@ type TransportConfig struct {
} }
type Transport struct { type Transport struct {
client *http.Client
timeout time.Duration timeout time.Duration
dialer proxy.Dialer dialer proxy.Dialer
} }
@@ -35,19 +34,15 @@ func NewTransport(config TransportConfig) (*Transport, error) {
} }
dialer := proxy.Dialer(proxy.Direct) dialer := proxy.Dialer(proxy.Direct)
roundTripper := http.DefaultTransport.(*http.Transport).Clone()
if strings.TrimSpace(config.Socks5Proxy) != "" { if strings.TrimSpace(config.Socks5Proxy) != "" {
proxyDialer, err := socks5Dialer(config.Socks5Proxy) proxyDialer, err := socks5Dialer(config.Socks5Proxy)
if err != nil { if err != nil {
return nil, err return nil, err
} }
dialer = proxyDialer 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) { 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) fmt.Fprintf(&buf, "POST %s HTTP/1.1\r\n", path)
writeHeader(&buf, "Host", target.Host) writeHeader(&buf, "Host", target.Host)
writeHeader(&buf, "User-Agent", headerValue(headers, "User-Agent", "python-requests/2.32.4")) 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, "Accept", headerValue(headers, "Accept", "*/*"))
writeHeader(&buf, "Connection", headerValue(headers, "Connection", "keep-alive")) writeHeader(&buf, "Connection", headerValue(headers, "Connection", "keep-alive"))
writeHeader(&buf, "Content-Length", fmt.Sprintf("%d", len(body))) writeHeader(&buf, "Content-Length", fmt.Sprintf("%d", len(body)))
+40 -2
View File
@@ -69,8 +69,8 @@ func TestNewTransportKeepsTimeout(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("NewTransport: %v", err) t.Fatalf("NewTransport: %v", err)
} }
if transport.client.Timeout != 3*time.Second { if transport.timeout != 3*time.Second {
t.Fatalf("timeout = %s, want 3s", transport.client.Timeout) t.Fatalf("timeout = %s, want 3s", transport.timeout)
} }
} }
@@ -79,6 +79,7 @@ func TestNewTransportRejectsInvalidSOCKS5Proxy(t *testing.T) {
t.Fatal("NewTransport accepted invalid SOCKS5 proxy") t.Fatal("NewTransport accepted invalid SOCKS5 proxy")
} }
} }
func TestTransportPreservesHeaderNameCasing(t *testing.T) { func TestTransportPreservesHeaderNameCasing(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0") listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil { if err != nil {
@@ -121,6 +122,7 @@ func TestTransportPreservesHeaderNameCasing(t *testing.T) {
} }
} }
} }
func TestTransportAddsRequestsCompatibleBaseHeaders(t *testing.T) { func TestTransportAddsRequestsCompatibleBaseHeaders(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("User-Agent") == "" { if r.Header.Get("User-Agent") == "" {
@@ -144,3 +146,39 @@ func TestTransportAddsRequestsCompatibleBaseHeaders(t *testing.T) {
t.Fatalf("PostJSON: %v", err) 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)
}
}
+8 -1
View File
@@ -92,8 +92,15 @@
## 2026-07-06 T-005 阶段 0 验收:Go 侧真实请求打通 + init.sh ## 2026-07-06 T-005 阶段 0 验收:Go 侧真实请求打通 + init.sh
- 状态:DONE - 状态: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`。 - 验证:`go test ./...` 通过;`go build ./...` 通过;使用本地环境变量发起 Go 版 JKDA00002 真实请求,返回 `JKDA00002 code=01 message=操作成功 data_count=1`。
- 验证补充:`init.sh` 三命令已替换;当前机器从 Git Bash 启动 Go 会出现标准库路径/构建缓存权限异常,导致脚本运行环境未能完整验收。等价 PowerShell 下直接执行 `go test ./...`、`go build ./...` 与 Go 真实请求均通过。 - 验证补充:`init.sh` 三命令已替换;当前机器从 Git Bash 启动 Go 会出现标准库路径/构建缓存权限异常,导致脚本运行环境未能完整验收。等价 PowerShell 下直接执行 `go test ./...`、`go build ./...` 与 Go 真实请求均通过。
- 决策:真实平台/代理对请求头大小写敏感,Go `net/http` 会规范化头名并触发 EOF;传输层因此对 OSI 调用使用原始 HTTP/1.1 写入,保留 Python requests 已验证的头名大小写与基础头语义。 - 决策:真实平台/代理对请求头大小写敏感,Go `net/http` 会规范化头名并触发 EOF;传输层因此对 OSI 调用使用原始 HTTP/1.1 写入,保留 Python requests 已验证的头名大小写与基础头语义。
- 下一步:T-201(`contract/jkda.go` 查询响应结构体)。 - 下一步: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` 查询响应结构体)。
+1
View File
@@ -30,6 +30,7 @@
| T-003 | `osi/transport.go`:从 chis_upload 移植传输层 | T-001 | 保留 SOCKS5/超时;去掉 cookiejar 与网页拟态头;单测或最小连通验证 | 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-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-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档案"的顺序假设了先建字典。 > **执行顺序说明(2026-07-06 重排,查询先行)**:原路线图"阶段1字典 → 阶段2档案"的顺序假设了先建字典。
> 但**查询档案(Find)根本不依赖字典**,**映射纯函数单测**也只需码表 + 注入假字典快照。 > 但**查询档案(Find)根本不依赖字典**,**映射纯函数单测**也只需码表 + 注入假字典快照。