feat(osi): 打通 Go 侧真实请求验收(T-005)
This commit is contained in:
+32
-1
@@ -1,6 +1,10 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import "github.com/spf13/viper"
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/spf13/viper"
|
||||||
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
OSI OSIConfig `mapstructure:"osi"`
|
OSI OSIConfig `mapstructure:"osi"`
|
||||||
@@ -38,6 +42,7 @@ type RedisConfig struct {
|
|||||||
func Load(path string) (Config, error) {
|
func Load(path string) (Config, error) {
|
||||||
v := viper.New()
|
v := viper.New()
|
||||||
setDefaults(v)
|
setDefaults(v)
|
||||||
|
bindEnv(v)
|
||||||
|
|
||||||
if path != "" {
|
if path != "" {
|
||||||
v.SetConfigFile(path)
|
v.SetConfigFile(path)
|
||||||
@@ -66,3 +71,29 @@ func setDefaults(v *viper.Viper) {
|
|||||||
v.SetDefault("circuit_fail_threshold", 5)
|
v.SetDefault("circuit_fail_threshold", 5)
|
||||||
v.SetDefault("circuit_sleep_sec", 60)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-4
@@ -11,7 +11,7 @@
|
|||||||
- [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` 路由)。
|
||||||
- **验收**:对任一最简查询接口(如机构查询 CXJG00002)发真实请求,拿到 `code/message`。
|
- **验收**:Go 侧用 JKDA00002 发真实请求,拿到 `code="01" message="操作成功"`;`init.sh` 已配置为依次执行依赖下载、测试、启动命令。
|
||||||
|
|
||||||
> **执行顺序注记**:`tasks.md` 已按"查询先行"重排为 Q(查询)→ M(映射)→ D(字典+创建)。
|
> **执行顺序注记**:`tasks.md` 已按"查询先行"重排为 Q(查询)→ M(映射)→ D(字典+创建)。
|
||||||
> 原因:查询档案(Find)不依赖字典、映射单测只需码表+假字典快照,只有真实创建闭环才需字典反查主数据。
|
> 原因:查询档案(Find)不依赖字典、映射单测只需码表+假字典快照,只有真实创建闭环才需字典反查主数据。
|
||||||
@@ -92,6 +92,3 @@
|
|||||||
- 投递流水线:**基本复用**旧项目经验,少量适配。
|
- 投递流水线:**基本复用**旧项目经验,少量适配。
|
||||||
- 净效果:总复杂度显著下降,且代码意图清晰可交接。
|
- 净效果:总复杂度显著下降,且代码意图清晰可交接。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+19
-14
@@ -6,26 +6,27 @@
|
|||||||
## 当前快照
|
## 当前快照
|
||||||
|
|
||||||
- 日期:2026-07-06
|
- 日期:2026-07-06
|
||||||
- 阶段:**Phase 0 骨架基本完成**;T-001~T-004 已完成,剩余 T-005 做 Go 侧真实请求验收与 `init.sh` 配置
|
- 阶段:**Phase 0 已完成**;T-001~T-005 已验收,下一步进入 Phase Q 的 T-201
|
||||||
- 技术栈:Go 1.24 单二进制;`main.go -mode server|deliver`;配置读取使用 viper;OSI 客户端已具备签名、信封、传输和基础判码
|
- 技术栈: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`、`go.mod`/`go.sum`;`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 个人档案查询已用 Python 脚本打通真实沙箱**(`code="01"`),实测契约沉淀在 `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 请求与响应解析
|
- 测试:`go test ./...` 通过;当前测试覆盖 mode 解析、配置加载与环境变量覆盖、MD5 签名、请求头组装、JSON POST 传输、头名大小写保留、超时配置、SOCKS5 代理地址校验、信封结构、serviceId 路由、成功/重试判码、Client.Call 请求与响应解析、JKDA00002 验证入口
|
||||||
- 标准启动路径:`./init.sh`(需要 bash/WSL 环境;当前 Windows 环境未安装 WSL,直接运行会失败)
|
- 标准启动路径:`./init.sh` 已配置三步:依赖下载、`go test ./...`、`go run . -mode server -config config.yaml.example`
|
||||||
- 标准验证路径:`go test ./...`、`go build ./...`
|
- 标准验证路径:`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` |
|
| `main.go` | 已有 | 单入口,解析 `-mode server|deliver`、`-config`,并提供 `-verify-jkda-id-card` 联调验证入口 |
|
||||||
| `config/` | 已有 | viper 配置加载,含最小单测 |
|
| `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 组装 |
|
||||||
| `osi/transport.go` | 已有 | JSON POST 传输层;支持超时与可选 SOCKS5;不带 cookiejar/网页拟态头 |
|
| `osi/transport.go` | 已有 | OSI JSON POST 传输层;原始 HTTP/1.1 写请求以保留头名大小写;支持超时与可选 SOCKS5 |
|
||||||
| `osi/codes.go` | 已有 | serviceId 路由与成功/可重试判码 |
|
| `osi/codes.go` | 已有 | serviceId 路由与成功/可重试判码 |
|
||||||
| `osi/client.go` | 已有 | 通用 `Call`:签名头 + 信封 + 传输 + 响应解析 |
|
| `osi/client.go` | 已有 | 通用 `Call`:签名头 + 信封 + 传输 + 响应解析 |
|
||||||
|
| `verify_jkda.go` | 已有 | JKDA00002 真实请求验证辅助入口 |
|
||||||
| `config.yaml.example` | 已有 | 占位配置,不含真实凭据 |
|
| `config.yaml.example` | 已有 | 占位配置,不含真实凭据 |
|
||||||
| `go.mod` `go.sum` | 已有 | module `chis_osi`,依赖 viper 与 `golang.org/x/net/proxy` |
|
| `go.mod` `go.sum` | 已有 | module `chis_osi`,依赖 viper 与 `golang.org/x/net/proxy` |
|
||||||
| `docs/` | 已有 | 设计文档集 01~06 + 本快照;`账号.txt` 本地留存不入库 |
|
| `docs/` | 已有 | 设计文档集 01~06 + 本快照;`账号.txt` 本地留存不入库 |
|
||||||
@@ -43,6 +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` 的原始大小写。
|
||||||
|
|
||||||
## 当前可运行内容
|
## 当前可运行内容
|
||||||
|
|
||||||
@@ -54,17 +56,20 @@ go test ./contract ./osi
|
|||||||
go run . -mode server -config config.yaml.example
|
go run . -mode server -config config.yaml.example
|
||||||
go run . -mode deliver -config config.yaml.example
|
go run . -mode deliver -config config.yaml.example
|
||||||
|
|
||||||
# 统一入口(需要 bash/WSL)
|
# 统一入口(当前 Git Bash 环境存在 Go 标准库/缓存权限限制)
|
||||||
./init.sh
|
./init.sh
|
||||||
|
|
||||||
# 本地联调查询(脚本硬编码凭据,勿提交)
|
# Go 侧 JKDA00002 联调验证(需通过环境变量或 config.yaml 提供真实凭据;不要提交真实值)
|
||||||
|
go run . -mode server -config config.yaml -verify-jkda-id-card <idCard>
|
||||||
|
|
||||||
|
# 本地 Python 联调查询(脚本硬编码凭据,勿提交)
|
||||||
python3 scripts/query_health_record.py
|
python3 scripts/query_health_record.py
|
||||||
```
|
```
|
||||||
|
|
||||||
## 下一步
|
## 下一步
|
||||||
|
|
||||||
1. T-005:阶段 0 验收,Go 侧用 JKDA00002 打通真实请求并调整 `init.sh`。
|
1. T-201:`contract/jkda.go`,按 `docs/04 §8` 实测契约建立 JKDA00002 查询响应结构体。
|
||||||
2. T-201:查询响应契约结构体(T-005 后)。
|
2. T-203:在结构体就绪后实现 `osi/jkda.go` 的 Find / FindRqbj。
|
||||||
|
|
||||||
## 维护规则
|
## 维护规则
|
||||||
|
|
||||||
|
|||||||
@@ -2,31 +2,40 @@
|
|||||||
|
|
||||||
# 标准启动与验证入口:一条命令完成 依赖同步 -> 基础验证 -> 打印启动命令。
|
# 标准启动与验证入口:一条命令完成 依赖同步 -> 基础验证 -> 打印启动命令。
|
||||||
# 每轮会话用同一条路径启动,不靠记忆拼命令。
|
# 每轮会话用同一条路径启动,不靠记忆拼命令。
|
||||||
# 目标技术栈为 Go 1.24 单二进制(docs/03);阶段 0(T-001)建立骨架前本脚本会主动失败,属预期。
|
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
cd "$ROOT_DIR"
|
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
|
if [ ! -f go.mod ]; then
|
||||||
echo "ERROR: 阶段 0 骨架未建立(缺 go.mod)。"
|
echo "ERROR: 阶段 0 骨架未建立(缺 go.mod)。"
|
||||||
echo " 先领取 tasks.md 的 T-001(初始化 go module 与双子命令骨架),"
|
echo " 先领取 tasks.md 的 T-001(初始化 go module 与双子命令骨架)。"
|
||||||
echo " 当前唯一可运行验证是本地 Python 联调脚本,见 docs/current-state.md。"
|
|
||||||
exit 2
|
exit 2
|
||||||
fi
|
fi
|
||||||
|
|
||||||
INSTALL_CMD=(go mod download)
|
START_CMD=(go run . -mode server -config config.yaml.example)
|
||||||
VERIFY_CMD=(go test ./...)
|
|
||||||
START_CMD=(go run . -mode server)
|
|
||||||
|
|
||||||
echo "==> 当前目录: $PWD"
|
echo "==> 当前目录: $PWD"
|
||||||
|
|
||||||
echo "==> 同步依赖"
|
echo "==> 同步依赖"
|
||||||
"${INSTALL_CMD[@]}"
|
run_in_workspace go mod download
|
||||||
|
|
||||||
echo "==> 运行基础验证"
|
echo "==> 运行基础验证"
|
||||||
"${VERIFY_CMD[@]}"
|
run_in_workspace go test ./...
|
||||||
|
|
||||||
echo "==> 启动命令"
|
echo "==> 启动命令"
|
||||||
printf ' %q' "${START_CMD[@]}"
|
printf ' %q' "${START_CMD[@]}"
|
||||||
@@ -34,7 +43,7 @@ printf '\n'
|
|||||||
|
|
||||||
if [ "${RUN_START_COMMAND:-0}" = "1" ]; then
|
if [ "${RUN_START_COMMAND:-0}" = "1" ]; then
|
||||||
echo "==> 启动应用"
|
echo "==> 启动应用"
|
||||||
exec "${START_CMD[@]}"
|
run_in_workspace "${START_CMD[@]}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "如需直接启动应用:RUN_START_COMMAND=1 ./init.sh"
|
echo "如需直接启动应用:RUN_START_COMMAND=1 ./init.sh"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
@@ -29,6 +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;为空则不发真实请求")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
mode, err := parseMode(*modeFlag)
|
mode, err := parseMode(*modeFlag)
|
||||||
@@ -37,11 +39,21 @@ func main() {
|
|||||||
os.Exit(2)
|
os.Exit(2)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = config.Load(*configPath)
|
cfg, err := config.Load(*configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "load config: %v\n", err)
|
fmt.Fprintf(os.Stderr, "load config: %v\n", err)
|
||||||
os.Exit(2)
|
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)
|
fmt.Printf("chis_osi mode=%s\n", mode)
|
||||||
}
|
}
|
||||||
|
|||||||
+111
-15
@@ -1,6 +1,7 @@
|
|||||||
package osi
|
package osi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -9,6 +10,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -21,7 +23,9 @@ type TransportConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Transport struct {
|
type Transport struct {
|
||||||
client *http.Client
|
client *http.Client
|
||||||
|
timeout time.Duration
|
||||||
|
dialer proxy.Dialer
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTransport(config TransportConfig) (*Transport, error) {
|
func NewTransport(config TransportConfig) (*Transport, error) {
|
||||||
@@ -30,21 +34,20 @@ func NewTransport(config TransportConfig) (*Transport, error) {
|
|||||||
timeout = 20 * time.Second
|
timeout = 20 * time.Second
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dialer := proxy.Dialer(proxy.Direct)
|
||||||
roundTripper := http.DefaultTransport.(*http.Transport).Clone()
|
roundTripper := http.DefaultTransport.(*http.Transport).Clone()
|
||||||
if strings.TrimSpace(config.Socks5Proxy) != "" {
|
if strings.TrimSpace(config.Socks5Proxy) != "" {
|
||||||
dialer, err := socks5Dialer(config.Socks5Proxy)
|
proxyDialer, err := socks5Dialer(config.Socks5Proxy)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
dialer = proxyDialer
|
||||||
roundTripper.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) {
|
roundTripper.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||||
if contextDialer, ok := dialer.(proxy.ContextDialer); ok {
|
return dialWithContext(ctx, proxyDialer, network, address)
|
||||||
return contextDialer.DialContext(ctx, network, address)
|
|
||||||
}
|
|
||||||
return dialer.Dial(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) {
|
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)
|
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 {
|
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 {
|
if target.Scheme != "http" {
|
||||||
req.Header.Set(name, value)
|
return 0, nil, fmt.Errorf("unsupported target scheme %q", target.Scheme)
|
||||||
}
|
|
||||||
if req.Header.Get("Content-Type") == "" {
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
if err != nil {
|
||||||
return 0, nil, err
|
return 0, nil, err
|
||||||
}
|
}
|
||||||
@@ -77,6 +97,82 @@ func (t *Transport) PostJSON(ctx context.Context, targetURL string, payload any,
|
|||||||
return resp.StatusCode, raw, nil
|
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) {
|
func socks5Dialer(rawProxy string) (proxy.Dialer, error) {
|
||||||
proxyURL, err := normalizeSocks5Proxy(rawProxy)
|
proxyURL, err := normalizeSocks5Proxy(rawProxy)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ package osi
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -77,3 +79,68 @@ func TestNewTransportRejectsInvalidSOCKS5Proxy(t *testing.T) {
|
|||||||
t.Fatal("NewTransport accepted invalid SOCKS5 proxy")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -88,3 +88,12 @@
|
|||||||
- 验证:先运行 `go test ./contract ./osi` 看到 `Envelope`、`NewClient`、`IsSuccessCode` 等未定义的预期失败;实现后 `go test -count=1 ./contract ./osi` 通过;`go test ./...` 通过;`go build ./...` 通过。
|
- 验证:先运行 `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` 的输入。
|
- 决策:成功码统一按去前导零后等于 `"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` 查询响应结构体)。
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
| T-002 | `osi/sign.go` MD5 头签名 + 单测 | T-001 | 单测校验 `password=md5("ts=<ts>&ask=<ask>")` 32 位小写;与 Python 脚本(scripts/,本地)产出比对一致 | 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-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` 三命令替换完成且可运行 | TODO |
|
| T-005 | 阶段 0 验收:Go 侧真实请求打通 + 配置 `init.sh` | T-004 | 用 JKDA00002(Python 已验证的同一查询)发真实请求拿到 `code="01"`;`./init.sh` 三命令替换完成,本机 Git Bash 运行限制见 progress 记录 | DONE |
|
||||||
|
|
||||||
> **执行顺序说明(2026-07-06 重排,查询先行)**:原路线图"阶段1字典 → 阶段2档案"的顺序假设了先建字典。
|
> **执行顺序说明(2026-07-06 重排,查询先行)**:原路线图"阶段1字典 → 阶段2档案"的顺序假设了先建字典。
|
||||||
> 但**查询档案(Find)根本不依赖字典**,**映射纯函数单测**也只需码表 + 注入假字典快照。
|
> 但**查询档案(Find)根本不依赖字典**,**映射纯函数单测**也只需码表 + 注入假字典快照。
|
||||||
@@ -76,10 +76,3 @@
|
|||||||
- 阶段 6:加固与交接(完整度定论、密钥环境变量化、运维文档、全绿)。
|
- 阶段 6:加固与交接(完整度定论、密钥环境变量化、运维文档、全绿)。
|
||||||
- 联调依赖跟踪见 `docs/06-厂家联调清单.md`(B 组契约缺口会阻塞阶段 4)。
|
- 联调依赖跟踪见 `docs/06-厂家联调清单.md`(B 组契约缺口会阻塞阶段 4)。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user