From 2a77a4dbc1f220fc305bd18fd6d1d51762fa82f4 Mon Sep 17 00:00:00 2001 From: QiuSW Date: Mon, 6 Jul 2026 22:31:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(osi):=20=E5=AE=9E=E7=8E=B0=E9=80=9A?= =?UTF-8?q?=E7=94=A8=20Call=20=E4=B8=8E=E5=93=8D=E5=BA=94=E5=88=A4?= =?UTF-8?q?=E7=A0=81=EF=BC=88T-004=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- contract/envelope.go | 18 +++++ contract/envelope_test.go | 29 ++++++++ docs/05-实施路线图.md | 5 +- docs/current-state.md | 20 +++--- osi/client.go | 136 +++++++++++++++++++++++++++++++++++++ osi/client_test.go | 137 ++++++++++++++++++++++++++++++++++++++ osi/codes.go | 34 ++++++++++ osi/codes_test.go | 33 +++++++++ progress.md | 8 +++ tasks.md | 4 +- 10 files changed, 413 insertions(+), 11 deletions(-) create mode 100644 contract/envelope.go create mode 100644 contract/envelope_test.go create mode 100644 osi/client.go create mode 100644 osi/client_test.go create mode 100644 osi/codes.go create mode 100644 osi/codes_test.go diff --git a/contract/envelope.go b/contract/envelope.go new file mode 100644 index 0000000..e80da9c --- /dev/null +++ b/contract/envelope.go @@ -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"` +} diff --git a/contract/envelope_test.go b/contract/envelope_test.go new file mode 100644 index 0000000..5f7aea5 --- /dev/null +++ b/contract/envelope_test.go @@ -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) + } +} diff --git a/docs/05-实施路线图.md b/docs/05-实施路线图.md index cd575eb..c76695e 100644 --- a/docs/05-实施路线图.md +++ b/docs/05-实施路线图.md @@ -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 @@ + diff --git a/docs/current-state.md b/docs/current-state.md index 932c0eb..7c3438e 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -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=")` 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 后)。 ## 维护规则 diff --git a/osi/client.go b/osi/client.go new file mode 100644 index 0000000..2bff7ba --- /dev/null +++ b/osi/client.go @@ -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 +} diff --git a/osi/client_test.go b/osi/client_test.go new file mode 100644 index 0000000..bc2c9a5 --- /dev/null +++ b/osi/client_test.go @@ -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) + } +} diff --git a/osi/codes.go b/osi/codes.go new file mode 100644 index 0000000..ca7bd68 --- /dev/null +++ b/osi/codes.go @@ -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 +} diff --git a/osi/codes_test.go b/osi/codes_test.go new file mode 100644 index 0000000..bc3a1dd --- /dev/null +++ b/osi/codes_test.go @@ -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") + } +} diff --git a/progress.md b/progress.md index 14b8c21..cb1cff2 100644 --- a/progress.md +++ b/progress.md @@ -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`)。 diff --git a/tasks.md b/tasks.md index 25121ce..ff6071b 100644 --- a/tasks.md +++ b/tasks.md @@ -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=&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 @@ + +