feat(osi): 实现通用 Call 与响应判码(T-004)
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
package contract
|
||||
|
||||
type Envelope struct {
|
||||
ServiceID string `json:"serviceId"`
|
||||
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 string `json:"DSFMC"`
|
||||
OperateUser string `json:"operateUser"`
|
||||
OperateUnit string `json:"operateUnit"`
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEnvelopeUsesUploadInfoWithBaseAndManageInfo(t *testing.T) {
|
||||
envelope := Envelope{
|
||||
ServiceID: "JKDA00002",
|
||||
UploadInfo: UploadInfo{
|
||||
BaseInfo: map[string]string{"idCard": "440100199001011234"},
|
||||
ManageInfo: ManageInfo{
|
||||
DSFMC: "dyytgw",
|
||||
OperateUser: "712041",
|
||||
OperateUnit: "12441625456962881G",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(envelope)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal: %v", err)
|
||||
}
|
||||
want := `{"serviceId":"JKDA00002","uploadinfo":{"baseInfo":{"idCard":"440100199001011234"},"manageInfo":{"DSFMC":"dyytgw","operateUser":"712041","operateUnit":"12441625456962881G"}}}`
|
||||
if string(raw) != want {
|
||||
t.Fatalf("envelope JSON = %s", raw)
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -9,8 +9,8 @@
|
||||
- [x] 初始化 Go module、单 `main.go`(`-mode server|deliver`)、`config/`(viper)。
|
||||
- [x] 落地 `osi/sign.go`(MD5 签名)+ 单测:用文档约定的 `ts/ask` 校验 `password` 形态(32 位小写)。
|
||||
- [x] 移植旧项目 `transport.go`/`http_client.go` → `osi/transport.go`(保留 SOCKS5/超时,去 cookiejar 与拟态头)。
|
||||
- [ ] `osi/client.go` 的 `Call(serviceId, body, out)`:注入头+信封+发送+判码(成功码实测 `"01"`,按去前导零判定;`405` 可重试,见 docs/01 §1)。
|
||||
- [ ] `contract/envelope.go` + `osi/codes.go`(serviceId 常量 + `pathOf` 路由)。
|
||||
- [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`。
|
||||
|
||||
> **执行顺序注记**:`tasks.md` 已按"查询先行"重排为 Q(查询)→ M(映射)→ D(字典+创建)。
|
||||
@@ -94,3 +94,4 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
+12
-8
@@ -6,11 +6,11 @@
|
||||
## 当前快照
|
||||
|
||||
- 日期:2026-07-06
|
||||
- 阶段:**Phase 0 进行中**;T-001/T-002/T-003 已完成,Go module、CLI/config 骨架、MD5 签名与 OSI 传输层已建立
|
||||
- 技术栈:Go 1.24 单二进制;`main.go -mode server|deliver`;配置读取使用 viper;OSI 传输支持超时和可选 SOCKS5 代理
|
||||
- 生产代码:已有 `main.go`、`config/`、`osi/sign.go`、`osi/transport.go`、`go.mod`/`go.sum`;`contract/ mapping/ pipeline/` 等业务目录仍待后续任务建立
|
||||
- 阶段:**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 解析、`config.yaml.example` 加载、MD5 签名、请求头组装、JSON POST 传输、超时配置和 SOCKS5 代理地址校验
|
||||
- 测试:`go test ./...` 通过;当前测试覆盖 mode 解析、配置加载、MD5 签名、请求头组装、JSON POST 传输、超时配置、SOCKS5 代理地址校验、信封结构、serviceId 路由、成功/重试判码、Client.Call 请求与响应解析
|
||||
- 标准启动路径:`./init.sh`(需要 bash/WSL 环境;当前 Windows 环境未安装 WSL,直接运行会失败)
|
||||
- 标准验证路径:`go test ./...`、`go build ./...`
|
||||
- 当前 blocker:无硬 blocker。软阻塞:厂家侧 B1/B2/B4/B5 契约缺口(见 `docs/06`),只影响阶段 4,不阻塞阶段 0~3
|
||||
@@ -21,21 +21,25 @@
|
||||
| --- | --- | --- |
|
||||
| `main.go` | 已有 | 单入口,解析 `-mode server|deliver` 与 `-config` |
|
||||
| `config/` | 已有 | viper 配置加载,含最小单测 |
|
||||
| `contract/envelope.go` | 已有 | OSI 通用信封与 `manageInfo` 结构 |
|
||||
| `osi/sign.go` | 已有 | OSI 请求头 MD5 签名与 headers 组装 |
|
||||
| `osi/transport.go` | 已有 | JSON POST 传输层;支持超时与可选 SOCKS5;不带 cookiejar/网页拟态头 |
|
||||
| `osi/codes.go` | 已有 | serviceId 路由与成功/可重试判码 |
|
||||
| `osi/client.go` | 已有 | 通用 `Call`:签名头 + 信封 + 传输 + 响应解析 |
|
||||
| `config.yaml.example` | 已有 | 占位配置,不含真实凭据 |
|
||||
| `go.mod` `go.sum` | 已有 | module `chis_osi`,依赖 viper 与 `golang.org/x/net/proxy` |
|
||||
| `docs/` | 已有 | 设计文档集 01~06 + 本快照;`账号.txt` 本地留存不入库 |
|
||||
| `tasks.md` `progress.md` | 已有 | 任务看板 / 执行流水(根目录) |
|
||||
| `scripts/` | 已有·不入库 | Python 联调脚本(硬编码真实凭据与身份证,勿提交) |
|
||||
| `config.yaml` | 已有·不入库 | 真实凭据;不要提交 |
|
||||
| `contract/` `mapping/` `pipeline/` 等 | 待建 | 路线图阶段 0~2 后续任务 |
|
||||
| `mapping/` `pipeline/` 等 | 待建 | 后续任务 |
|
||||
|
||||
## 已验证事实(写代码时直接依赖)
|
||||
|
||||
- 签名:`password = md5("ts=<13位毫秒ts>&ask=<ask>")` 32 位小写,与 Python `hashlib` 向量一致。
|
||||
- 信封:查询也走 `{"serviceId", "uploadinfo": {"baseInfo", "manageInfo"}}`,见 `docs/04 §8`。
|
||||
- 成功码:字符串 `"01"`(判定按去前导零 == `"1"`),见 `docs/01 §1`。
|
||||
- `405` 为平台服务调用超时,可重试。
|
||||
- 机构码分层:请求头 `orgCode`=18 位统信码 ≠ `manaUnitId`=9 位机构码 ≠ 12 位区划码,见 `docs/01 §3`。
|
||||
- `deviceSN` 查询可空。
|
||||
- OSI 代理语义:`socks5_proxy` 非空即只对 OSI 客户端走 SOCKS5,不做直连回退。
|
||||
@@ -46,7 +50,7 @@
|
||||
# Go 骨架验证
|
||||
go test ./...
|
||||
go build ./...
|
||||
go test ./osi
|
||||
go test ./contract ./osi
|
||||
go run . -mode server -config config.yaml.example
|
||||
go run . -mode deliver -config config.yaml.example
|
||||
|
||||
@@ -59,8 +63,8 @@ python3 scripts/query_health_record.py
|
||||
|
||||
## 下一步
|
||||
|
||||
1. T-004:`osi/client.go` `Call` + `osi/codes.go` + `contract/envelope.go`。
|
||||
2. T-201:查询响应契约结构体(T-004 后)。
|
||||
1. T-005:阶段 0 验收,Go 侧用 JKDA00002 打通真实请求并调整 `init.sh`。
|
||||
2. T-201:查询响应契约结构体(T-005 后)。
|
||||
|
||||
## 维护规则
|
||||
|
||||
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
package osi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"chis_osi/contract"
|
||||
)
|
||||
|
||||
type ClientConfig struct {
|
||||
BaseURL string
|
||||
OrgCode string
|
||||
DeviceSN string
|
||||
UserName string
|
||||
Ask string
|
||||
OperateUser string
|
||||
OperateUnit string
|
||||
Transport *Transport
|
||||
NowMillis func() string
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
config ClientConfig
|
||||
transport *Transport
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
HTTPStatus int
|
||||
Code string
|
||||
Message string
|
||||
Raw []byte
|
||||
Success bool
|
||||
Retryable bool
|
||||
}
|
||||
|
||||
type responseEnvelope struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
func NewClient(config ClientConfig) *Client {
|
||||
return &Client{config: config, transport: config.Transport}
|
||||
}
|
||||
|
||||
func (c *Client) Call(ctx context.Context, serviceID string, baseInfo any, out any) (Result, error) {
|
||||
if c.transport == nil {
|
||||
transport, err := NewTransport(TransportConfig{})
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
c.transport = transport
|
||||
}
|
||||
|
||||
path, err := PathOf(serviceID)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
payload := contract.Envelope{
|
||||
ServiceID: serviceID,
|
||||
UploadInfo: contract.UploadInfo{
|
||||
BaseInfo: baseInfo,
|
||||
ManageInfo: contract.ManageInfo{
|
||||
DSFMC: c.config.UserName,
|
||||
OperateUser: c.config.OperateUser,
|
||||
OperateUnit: c.operateUnit(),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
targetURL := c.urlFor(path)
|
||||
status, raw, err := c.transport.PostJSON(ctx, targetURL, payload, c.headers())
|
||||
result := Result{HTTPStatus: status, Raw: raw, Retryable: isRetryableHTTPStatus(status)}
|
||||
if err != nil {
|
||||
result.Retryable = true
|
||||
return result, err
|
||||
}
|
||||
|
||||
var resp responseEnvelope
|
||||
if err := json.Unmarshal(raw, &resp); err != nil {
|
||||
return result, fmt.Errorf("decode osi response: %w", err)
|
||||
}
|
||||
result.Code = resp.Code
|
||||
result.Message = resp.Message
|
||||
result.Success = IsSuccessCode(resp.Code)
|
||||
result.Retryable = result.Retryable || IsRetryableCode(resp.Code)
|
||||
|
||||
if !result.Success {
|
||||
return result, fmt.Errorf("osi call failed code=%s message=%s", resp.Code, resp.Message)
|
||||
}
|
||||
if out != nil && len(resp.Data) > 0 && string(resp.Data) != "null" {
|
||||
if err := json.Unmarshal(resp.Data, out); err != nil {
|
||||
return result, fmt.Errorf("decode osi data: %w", err)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *Client) headers() map[string]string {
|
||||
ts := strconv.FormatInt(time.Now().UnixMilli(), 10)
|
||||
if c.config.NowMillis != nil {
|
||||
ts = c.config.NowMillis()
|
||||
}
|
||||
return BuildHeaders(HeaderInput{
|
||||
OrgCode: c.config.OrgCode,
|
||||
DeviceSN: c.config.DeviceSN,
|
||||
UserName: c.config.UserName,
|
||||
Ask: c.config.Ask,
|
||||
TS: ts,
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Client) operateUnit() string {
|
||||
if c.config.OperateUnit != "" {
|
||||
return c.config.OperateUnit
|
||||
}
|
||||
return c.config.OrgCode
|
||||
}
|
||||
|
||||
func (c *Client) urlFor(path string) string {
|
||||
baseURL := strings.TrimRight(c.config.BaseURL, "/")
|
||||
if strings.HasSuffix(baseURL, "/osi/api") {
|
||||
return baseURL + path
|
||||
}
|
||||
return baseURL + "/osi/api" + path
|
||||
}
|
||||
|
||||
func isRetryableHTTPStatus(status int) bool {
|
||||
return status == http.StatusTooManyRequests || status >= 500
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package osi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"chis_osi/contract"
|
||||
)
|
||||
|
||||
func TestClientCallPostsSignedEnvelopeAndDecodesSuccess(t *testing.T) {
|
||||
var seenPath string
|
||||
var seenPayload map[string]any
|
||||
var seenHeaders http.Header
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
seenPath = r.URL.Path
|
||||
seenHeaders = r.Header.Clone()
|
||||
if err := json.NewDecoder(r.Body).Decode(&seenPayload); err != nil {
|
||||
t.Fatalf("decode request: %v", err)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"code":"01","message":"操作成功","data":{"phrId":"phr-001"}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
transport, err := NewTransport(TransportConfig{Timeout: time.Second})
|
||||
if err != nil {
|
||||
t.Fatalf("NewTransport: %v", err)
|
||||
}
|
||||
client := NewClient(ClientConfig{
|
||||
BaseURL: server.URL,
|
||||
OrgCode: "12441625456962881G",
|
||||
DeviceSN: "device-001",
|
||||
UserName: "dyytgw",
|
||||
Ask: "secret-key",
|
||||
OperateUser: "712041",
|
||||
OperateUnit: "12441625456962881G",
|
||||
Transport: transport,
|
||||
NowMillis: func() string {
|
||||
return "1700000000123"
|
||||
},
|
||||
})
|
||||
|
||||
var out struct {
|
||||
PHRID string `json:"phrId"`
|
||||
}
|
||||
result, err := client.Call(context.Background(), ServiceIDJKDAFind, map[string]string{"idCard": "440100199001011234"}, &out)
|
||||
if err != nil {
|
||||
t.Fatalf("Call: %v", err)
|
||||
}
|
||||
|
||||
if seenPath != "/osi/api/auto/jkda/find" {
|
||||
t.Fatalf("path = %q", seenPath)
|
||||
}
|
||||
if seenHeaders.Get("orgCode") != "12441625456962881G" {
|
||||
t.Fatalf("orgCode header = %q", seenHeaders.Get("orgCode"))
|
||||
}
|
||||
if seenHeaders.Get("password") != "008aceff8247cb42d2a99b2c48d0ac88" {
|
||||
t.Fatalf("password header = %q", seenHeaders.Get("password"))
|
||||
}
|
||||
if _, ok := seenPayload["uploadinfo"]; !ok {
|
||||
t.Fatalf("payload missing uploadinfo: %#v", seenPayload)
|
||||
}
|
||||
if seenPayload["serviceId"] != ServiceIDJKDAFind {
|
||||
t.Fatalf("serviceId = %v", seenPayload["serviceId"])
|
||||
}
|
||||
|
||||
uploadInfo := seenPayload["uploadinfo"].(map[string]any)
|
||||
baseInfo := uploadInfo["baseInfo"].(map[string]any)
|
||||
if baseInfo["idCard"] != "440100199001011234" {
|
||||
t.Fatalf("baseInfo.idCard = %v", baseInfo["idCard"])
|
||||
}
|
||||
manageInfo := uploadInfo["manageInfo"].(map[string]any)
|
||||
if manageInfo["DSFMC"] != "dyytgw" || manageInfo["operateUser"] != "712041" || manageInfo["operateUnit"] != "12441625456962881G" {
|
||||
t.Fatalf("manageInfo = %#v", manageInfo)
|
||||
}
|
||||
if !result.Success || result.Retryable {
|
||||
t.Fatalf("result = %#v", result)
|
||||
}
|
||||
if out.PHRID != "phr-001" {
|
||||
t.Fatalf("decoded phrId = %q", out.PHRID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientCallMarks405Retryable(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"code":"405","message":"服务调用超时"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
transport, err := NewTransport(TransportConfig{Timeout: time.Second})
|
||||
if err != nil {
|
||||
t.Fatalf("NewTransport: %v", err)
|
||||
}
|
||||
client := NewClient(ClientConfig{BaseURL: server.URL, UserName: "dyytgw", Ask: "secret-key", Transport: transport})
|
||||
|
||||
result, err := client.Call(context.Background(), ServiceIDJKDAFind, contract.UploadInfo{}, nil)
|
||||
if err == nil {
|
||||
t.Fatal("Call returned nil error for 405")
|
||||
}
|
||||
if !result.Retryable || result.Success {
|
||||
t.Fatalf("result = %#v", result)
|
||||
}
|
||||
}
|
||||
func TestClientCallGeneratesTimestampWhenClockNotInjected(t *testing.T) {
|
||||
var seenTS string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
seenTS = r.Header.Get("ts")
|
||||
_, _ = w.Write([]byte(`{"code":"01","message":"操作成功"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
transport, err := NewTransport(TransportConfig{Timeout: time.Second})
|
||||
if err != nil {
|
||||
t.Fatalf("NewTransport: %v", err)
|
||||
}
|
||||
client := NewClient(ClientConfig{BaseURL: server.URL, UserName: "dyytgw", Ask: "secret-key", Transport: transport})
|
||||
|
||||
if _, err := client.Call(context.Background(), ServiceIDJKDAFind, map[string]string{"idCard": "440100199001011234"}, nil); err != nil {
|
||||
t.Fatalf("Call: %v", err)
|
||||
}
|
||||
if len(seenTS) != 13 {
|
||||
t.Fatalf("ts header length = %d, want 13; ts=%q", len(seenTS), seenTS)
|
||||
}
|
||||
}
|
||||
func TestClientURLForAcceptsBaseURLWithAPIPath(t *testing.T) {
|
||||
client := NewClient(ClientConfig{BaseURL: "http://example.test/osi/api/"})
|
||||
got := client.urlFor("/auto/jkda/find")
|
||||
want := "http://example.test/osi/api/auto/jkda/find"
|
||||
if got != want {
|
||||
t.Fatalf("urlFor = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package osi
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
ServiceIDJKDAFind = "JKDA00002"
|
||||
)
|
||||
|
||||
const codeRetryableTimeout = "405"
|
||||
|
||||
var servicePaths = map[string]string{
|
||||
ServiceIDJKDAFind: "/auto/jkda/find",
|
||||
}
|
||||
|
||||
func PathOf(serviceID string) (string, error) {
|
||||
path, ok := servicePaths[serviceID]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("unknown serviceId %q", serviceID)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func IsSuccessCode(code string) bool {
|
||||
code = strings.TrimSpace(code)
|
||||
code = strings.TrimLeft(code, "0")
|
||||
return code == "1"
|
||||
}
|
||||
|
||||
func IsRetryableCode(code string) bool {
|
||||
return strings.TrimSpace(code) == codeRetryableTimeout
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package osi
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCodeClassificationNormalizesLeadingZeroes(t *testing.T) {
|
||||
for _, code := range []string{"1", "01", "001"} {
|
||||
if !IsSuccessCode(code) {
|
||||
t.Fatalf("IsSuccessCode(%q) = false", code)
|
||||
}
|
||||
}
|
||||
if IsSuccessCode("0") {
|
||||
t.Fatal("IsSuccessCode accepted zero")
|
||||
}
|
||||
if !IsRetryableCode("405") {
|
||||
t.Fatal("405 should be retryable")
|
||||
}
|
||||
if IsRetryableCode("01") {
|
||||
t.Fatal("success code should not be retryable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathOfKnownServiceID(t *testing.T) {
|
||||
path, err := PathOf(ServiceIDJKDAFind)
|
||||
if err != nil {
|
||||
t.Fatalf("PathOf: %v", err)
|
||||
}
|
||||
if path != "/auto/jkda/find" {
|
||||
t.Fatalf("path = %q", path)
|
||||
}
|
||||
if _, err := PathOf("UNKNOWN"); err == nil {
|
||||
t.Fatal("PathOf accepted unknown serviceId")
|
||||
}
|
||||
}
|
||||
@@ -80,3 +80,11 @@
|
||||
- 验证:先运行 `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`)。
|
||||
|
||||
## 2026-07-06 T-004 OSI Client Call + 信封 + 判码
|
||||
|
||||
- 状态:DONE
|
||||
- 变更:新增 `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`)。
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
| T-001 | 初始化 go module + `main.go` 双子命令骨架 + `config/`(viper) | - | `go build ./...` 通过;`-mode server\|deliver` 可解析;`config.yaml.example` 占位值就绪 | DONE |
|
||||
| T-002 | `osi/sign.go` MD5 头签名 + 单测 | T-001 | 单测校验 `password=md5("ts=<ts>&ask=<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` 归类可重试 | TODO |
|
||||
| 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 |
|
||||
|
||||
> **执行顺序说明(2026-07-06 重排,查询先行)**:原路线图"阶段1字典 → 阶段2档案"的顺序假设了先建字典。
|
||||
@@ -81,3 +81,5 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user