refactor(t228): remove Python ERP connector

This commit is contained in:
QiuSW
2026-07-29 10:14:30 +08:00
parent a822edd1b2
commit 13a1355e0d
28 changed files with 104 additions and 2394 deletions
+1 -2
View File
@@ -44,9 +44,8 @@ coverage.out
captcha.jpg
captcha.png
freight_detail.json
erp-connector/.env*
# Python connector outputs
# Python local-tool outputs
__pycache__/
*.py[cod]
.pytest_cache/
+43 -100
View File
@@ -19,46 +19,40 @@ const (
ClaimLeaseEnvironment = "CMROUBAO_CLAIM_LEASE"
RunningLeaseEnvironment = "CMROUBAO_RUNNING_LEASE"
ReadinessTTLEnvironment = "CMROUBAO_READINESS_TTL"
ERPConnectorURLEnvironment = "CMROUBAO_ERP_CONNECTOR_URL"
ERPConnectorAPIKeyEnvironment = "CMROUBAO_ERP_CONNECTOR_API_KEY"
ShunyunbaoURLEnvironment = "CMROUBAO_SHUNYUNBAO_URL"
ShunyunbaoUsernameEnvironment = "CMROUBAO_SHUNYUNBAO_USERNAME"
ShunyunbaoPasswordEnvironment = "CMROUBAO_SHUNYUNBAO_PASSWORD"
defaultHTTPAddress = "127.0.0.1:8080"
defaultDatabasePath = "var/cmroubao.db"
defaultAssetDirectory = "var/assets"
defaultClaimLease = 10 * time.Minute
defaultRunningLease = 30 * time.Minute
defaultReadinessTTL = 2 * time.Minute
defaultERPConnectorURL = "http://127.0.0.1:8091"
defaultShunyunbaoURL = "https://www.shunyunbaoerp.com"
defaultHTTPAddress = "127.0.0.1:8080"
defaultDatabasePath = "var/cmroubao.db"
defaultAssetDirectory = "var/assets"
defaultClaimLease = 10 * time.Minute
defaultRunningLease = 30 * time.Minute
defaultReadinessTTL = 2 * time.Minute
defaultShunyunbaoURL = "https://www.shunyunbaoerp.com"
)
type LookupEnvironment func(string) (string, bool)
type Config struct {
HTTPAddress string
DatabasePath string
AssetDirectory string
TLSCertificate string
TLSPrivateKey string
ReadHeaderTimeout time.Duration
ReadTimeout time.Duration
WriteTimeout time.Duration
IdleTimeout time.Duration
ShutdownTimeout time.Duration
MaxHeaderBytes int
ClaimLease time.Duration
RunningLease time.Duration
ReadinessTTL time.Duration
ERPConnectorURL string
ERPConnectorAPIKey string
ERPConnectorTimeout time.Duration
ShunyunbaoURL string
ShunyunbaoUsername string
ShunyunbaoPassword string
ShunyunbaoTimeout time.Duration
HTTPAddress string
DatabasePath string
AssetDirectory string
TLSCertificate string
TLSPrivateKey string
ReadHeaderTimeout time.Duration
ReadTimeout time.Duration
WriteTimeout time.Duration
IdleTimeout time.Duration
ShutdownTimeout time.Duration
MaxHeaderBytes int
ClaimLease time.Duration
RunningLease time.Duration
ReadinessTTL time.Duration
ShunyunbaoURL string
ShunyunbaoUsername string
ShunyunbaoPassword string
ShunyunbaoTimeout time.Duration
}
func Load(lookup LookupEnvironment) (Config, error) {
@@ -152,27 +146,6 @@ func Load(lookup LookupEnvironment) (Config, error) {
if err != nil {
return Config{}, err
}
erpConnectorURL, err := environmentValue(
lookup,
ERPConnectorURLEnvironment,
defaultERPConnectorURL,
)
if err != nil {
return Config{}, err
}
if err := validateLoopbackURL(erpConnectorURL); err != nil {
return Config{}, err
}
erpConnectorAPIKey := ""
if value, exists := lookup(ERPConnectorAPIKeyEnvironment); exists {
erpConnectorAPIKey = strings.TrimSpace(value)
if len([]byte(erpConnectorAPIKey)) < 32 {
return Config{}, errors.New(
ERPConnectorAPIKeyEnvironment +
" must contain at least 32 UTF-8 bytes",
)
}
}
shunyunbaoURL, err := environmentValue(
lookup,
ShunyunbaoURLEnvironment,
@@ -206,57 +179,27 @@ func Load(lookup LookupEnvironment) (Config, error) {
}
return Config{
HTTPAddress: httpAddress,
DatabasePath: filepath.Clean(databasePath),
AssetDirectory: assetDirectory,
TLSCertificate: cleanOptionalPath(tlsCertificate),
TLSPrivateKey: cleanOptionalPath(tlsPrivateKey),
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
ShutdownTimeout: 10 * time.Second,
MaxHeaderBytes: 1 << 20,
ClaimLease: claimLease,
RunningLease: runningLease,
ReadinessTTL: readinessTTL,
ERPConnectorURL: strings.TrimRight(erpConnectorURL, "/"),
ERPConnectorAPIKey: erpConnectorAPIKey,
ERPConnectorTimeout: 90 * time.Second,
ShunyunbaoURL: strings.TrimRight(shunyunbaoURL, "/"),
ShunyunbaoUsername: shunyunbaoUsername,
ShunyunbaoPassword: shunyunbaoPassword,
ShunyunbaoTimeout: 30 * time.Second,
HTTPAddress: httpAddress,
DatabasePath: filepath.Clean(databasePath),
AssetDirectory: assetDirectory,
TLSCertificate: cleanOptionalPath(tlsCertificate),
TLSPrivateKey: cleanOptionalPath(tlsPrivateKey),
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
ShutdownTimeout: 10 * time.Second,
MaxHeaderBytes: 1 << 20,
ClaimLease: claimLease,
RunningLease: runningLease,
ReadinessTTL: readinessTTL,
ShunyunbaoURL: strings.TrimRight(shunyunbaoURL, "/"),
ShunyunbaoUsername: shunyunbaoUsername,
ShunyunbaoPassword: shunyunbaoPassword,
ShunyunbaoTimeout: 30 * time.Second,
}, nil
}
func validateLoopbackURL(value string) error {
parsed, err := url.Parse(value)
if err != nil || parsed.Scheme != "http" || parsed.User != nil ||
parsed.RawQuery != "" || parsed.Fragment != "" ||
(parsed.Path != "" && parsed.Path != "/") {
return errors.New(
ERPConnectorURLEnvironment +
" must be an http loopback origin without credentials or path",
)
}
host := parsed.Hostname()
ip := net.ParseIP(host)
if !strings.EqualFold(host, "localhost") &&
(ip == nil || !ip.IsLoopback()) {
return errors.New(
ERPConnectorURLEnvironment + " must use a loopback host",
)
}
port, err := strconv.Atoi(parsed.Port())
if err != nil || port < 1 || port > 65535 {
return errors.New(
ERPConnectorURLEnvironment + " must include a valid port",
)
}
return nil
}
func validateHTTPSOrigin(value, environment string) error {
parsed, err := url.Parse(value)
if err != nil || parsed.Scheme != "https" || parsed.Host == "" ||
@@ -42,16 +42,6 @@ func TestLoadUsesSafeDefaults(t *testing.T) {
if cfg.ShutdownTimeout > 30*time.Second {
t.Fatalf("ShutdownTimeout = %s", cfg.ShutdownTimeout)
}
if cfg.ERPConnectorURL != "http://127.0.0.1:8091" ||
cfg.ERPConnectorAPIKey != "" ||
cfg.ERPConnectorTimeout != 90*time.Second {
t.Fatalf(
"ERP connector defaults = %q / %q / %s",
cfg.ERPConnectorURL,
cfg.ERPConnectorAPIKey,
cfg.ERPConnectorTimeout,
)
}
if cfg.ShunyunbaoURL != "https://www.shunyunbaoerp.com" ||
cfg.ShunyunbaoUsername != "" || cfg.ShunyunbaoPassword != "" ||
cfg.ShunyunbaoTimeout != 30*time.Second {
@@ -75,8 +65,6 @@ func TestLoadAcceptsExplicitConfiguration(t *testing.T) {
ClaimLeaseEnvironment: "15m",
RunningLeaseEnvironment: "45m",
ReadinessTTLEnvironment: "3m",
ERPConnectorURLEnvironment: "http://localhost:18091",
ERPConnectorAPIKeyEnvironment: "12345678901234567890123456789012",
ShunyunbaoURLEnvironment: "https://erp.example.test:8443",
ShunyunbaoUsernameEnvironment: "service-user",
ShunyunbaoPasswordEnvironment: " pass with spaces ",
@@ -114,14 +102,6 @@ func TestLoadAcceptsExplicitConfiguration(t *testing.T) {
cfg.ReadinessTTL,
)
}
if cfg.ERPConnectorURL != values[ERPConnectorURLEnvironment] ||
cfg.ERPConnectorAPIKey != values[ERPConnectorAPIKeyEnvironment] {
t.Fatalf(
"ERP connector = %q / %q",
cfg.ERPConnectorURL,
cfg.ERPConnectorAPIKey,
)
}
if cfg.ShunyunbaoURL != values[ShunyunbaoURLEnvironment] ||
cfg.ShunyunbaoUsername != values[ShunyunbaoUsernameEnvironment] ||
cfg.ShunyunbaoPassword != values[ShunyunbaoPasswordEnvironment] {
@@ -134,24 +114,6 @@ func TestLoadRejectsUnsafeOrInvalidValues(t *testing.T) {
name string
values map[string]string
}{
{
name: "non-loopback ERP connector",
values: map[string]string{
ERPConnectorURLEnvironment: "http://192.0.2.10:8091",
},
},
{
name: "ERP connector path",
values: map[string]string{
ERPConnectorURLEnvironment: "http://127.0.0.1:8091/private",
},
},
{
name: "short ERP connector key",
values: map[string]string{
ERPConnectorAPIKeyEnvironment: "short",
},
},
{
name: "non HTTPS shunyunbao URL",
values: map[string]string{
@@ -1,148 +0,0 @@
package erpconnector
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"strings"
"time"
"cmroubao/backend-api/internal/domain"
)
var (
// Deprecated aliases keep the temporary Connector behavior compatible while
// the source-neutral Go ERP implementation is introduced in T-225 to T-228.
ErrNotConfigured = domain.ErrFreightSourceNotConfigured
ErrSessionRequired = domain.ErrFreightSourceSessionNeeded
ErrNotFound = domain.ErrFreightSourceNotFound
ErrUnavailable = domain.ErrFreightSourceUnavailable
ErrProtocol = domain.ErrFreightSourceProtocol
)
const maxResponseBytes = 4 << 20
type Client struct {
baseURL string
apiKey string
http *http.Client
}
func New(baseURL, apiKey string, timeout time.Duration) (*Client, error) {
if strings.TrimSpace(baseURL) == "" {
baseURL = "http://127.0.0.1:8091"
}
if timeout <= 0 {
timeout = 90 * time.Second
}
if timeout <= 0 {
return nil, errors.New("ERP connector client configuration is invalid")
}
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
apiKey: strings.TrimSpace(apiKey),
http: &http.Client{
Timeout: timeout,
CheckRedirect: func(
_ *http.Request,
_ []*http.Request,
) error {
return http.ErrUseLastResponse
},
},
}, nil
}
func (client *Client) QueryOrder(
ctx context.Context,
orderNumber string,
) (domain.FreightSourceBatch, error) {
return client.query(ctx, map[string]string{
"mode": domain.FreightSyncOrderNumber,
"order_number": orderNumber,
}, domain.FreightSyncOrderNumber, "", "")
}
func (client *Client) QueryCreatedRange(
ctx context.Context,
createdFrom, createdTo string,
) (domain.FreightSourceBatch, error) {
return client.query(ctx, map[string]string{
"mode": domain.FreightSyncCreatedRange,
"created_from": createdFrom,
"created_to": createdTo,
}, domain.FreightSyncCreatedRange, createdFrom, createdTo)
}
func (client *Client) query(
ctx context.Context,
payload map[string]string,
expectedMode, expectedFrom, expectedTo string,
) (domain.FreightSourceBatch, error) {
if client.apiKey == "" {
return domain.FreightSourceBatch{}, ErrNotConfigured
}
body, err := json.Marshal(payload)
if err != nil {
return domain.FreightSourceBatch{}, ErrProtocol
}
request, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
client.baseURL+"/v1/freight/query",
bytes.NewReader(body),
)
if err != nil {
return domain.FreightSourceBatch{}, ErrUnavailable
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-API-Key", client.apiKey)
response, err := client.http.Do(request)
if err != nil {
return domain.FreightSourceBatch{}, ErrUnavailable
}
defer response.Body.Close()
limited := io.LimitReader(response.Body, maxResponseBytes+1)
content, err := io.ReadAll(limited)
if err != nil || len(content) > maxResponseBytes {
return domain.FreightSourceBatch{}, ErrUnavailable
}
if response.StatusCode != http.StatusOK {
switch response.StatusCode {
case http.StatusUnauthorized:
return domain.FreightSourceBatch{}, ErrSessionRequired
case http.StatusNotFound:
return domain.FreightSourceBatch{}, ErrNotFound
default:
return domain.FreightSourceBatch{}, ErrUnavailable
}
}
var result domain.FreightSourceBatch
decoder := json.NewDecoder(bytes.NewReader(content))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&result); err != nil {
return domain.FreightSourceBatch{}, ErrProtocol
}
var extra any
if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
return domain.FreightSourceBatch{}, ErrProtocol
}
if result.SchemaVersion != 1 || result.Query.Mode != expectedMode ||
result.Orders == nil {
return domain.FreightSourceBatch{}, ErrProtocol
}
if expectedMode == domain.FreightSyncOrderNumber {
if result.Query.CreatedFrom != nil || result.Query.CreatedTo != nil {
return domain.FreightSourceBatch{}, ErrProtocol
}
} else if result.Query.CreatedFrom == nil ||
result.Query.CreatedTo == nil ||
*result.Query.CreatedFrom != expectedFrom ||
*result.Query.CreatedTo != expectedTo {
return domain.FreightSourceBatch{}, ErrProtocol
}
return result, nil
}
@@ -1,223 +0,0 @@
package erpconnector
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestQueryOrderAcceptsAllowlistResponse(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(
writer http.ResponseWriter,
request *http.Request,
) {
if request.Header.Get("X-API-Key") != "12345678901234567890123456789012" {
t.Fatal("service key was not forwarded")
}
writer.Header().Set("Content-Type", "application/json")
_, _ = writer.Write([]byte(`{
"schema_version":1,
"query":{"mode":"ORDER_NUMBER"},
"orders":[{
"external_stock_id":"12",
"source_code":"SOURCE-12",
"platform_order_no":null,
"shop_name":"测试店铺",
"source_created_at":"2026-07-28 08:00:00",
"order_status":"0",
"purchase_status":"1",
"is_canceled":false,
"items":[{
"external_item_id":"88",
"title":"商品",
"product_spec":"黑色,L",
"sku":"BLACK-L",
"quantity":2,
"product_thumb_ref":"190",
"purchase_status":"0"
}]
}]
}`))
}))
defer server.Close()
client, err := New(
server.URL,
"12345678901234567890123456789012",
time.Second,
)
if err != nil {
t.Fatalf("New() error = %v", err)
}
result, err := client.QueryOrder(context.Background(), "SOURCE-12")
if err != nil {
t.Fatalf("QueryOrder() error = %v", err)
}
if len(result.Orders) != 1 || len(result.Orders[0].Items) != 1 ||
result.Orders[0].Items[0].SKU != "BLACK-L" {
t.Fatalf("result = %+v", result)
}
}
func TestQueryCreatedRangeUsesStrictContract(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(
writer http.ResponseWriter,
request *http.Request,
) {
var body map[string]string
if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
t.Fatalf("decode request: %v", err)
}
if body["mode"] != "CREATED_RANGE" ||
body["created_from"] != "2026-07-22" ||
body["created_to"] != "2026-07-28" ||
len(body) != 3 {
t.Fatalf("request body = %#v", body)
}
writer.Header().Set("Content-Type", "application/json")
_, _ = writer.Write([]byte(`{
"schema_version":1,
"query":{
"mode":"CREATED_RANGE",
"created_from":"2026-07-22",
"created_to":"2026-07-28"
},
"orders":[]
}`))
}))
defer server.Close()
client, _ := New(
server.URL,
"12345678901234567890123456789012",
time.Second,
)
result, err := client.QueryCreatedRange(
context.Background(),
"2026-07-22",
"2026-07-28",
)
if err != nil || result.Query.CreatedFrom == nil ||
*result.Query.CreatedFrom != "2026-07-22" {
t.Fatalf("QueryCreatedRange() = %+v, %v", result, err)
}
}
func TestQueryCreatedRangeRejectsMismatchedResponseWindow(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(
writer http.ResponseWriter,
_ *http.Request,
) {
writer.Header().Set("Content-Type", "application/json")
_, _ = writer.Write([]byte(`{
"schema_version":1,
"query":{
"mode":"CREATED_RANGE",
"created_from":"2026-07-21",
"created_to":"2026-07-28"
},
"orders":[]
}`))
}))
defer server.Close()
client, _ := New(
server.URL,
"12345678901234567890123456789012",
time.Second,
)
_, err := client.QueryCreatedRange(
context.Background(),
"2026-07-22",
"2026-07-28",
)
if !errors.Is(err, ErrProtocol) {
t.Fatalf("QueryCreatedRange() error = %v", err)
}
}
func TestQueryOrderRejectsUnexpectedPIIField(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(
writer http.ResponseWriter,
_ *http.Request,
) {
writer.Header().Set("Content-Type", "application/json")
_, _ = writer.Write([]byte(`{
"schema_version":1,
"query":{"mode":"ORDER_NUMBER"},
"orders":[],
"receiverTel":"private"
}`))
}))
defer server.Close()
client, _ := New(server.URL, "12345678901234567890123456789012", time.Second)
_, err := client.QueryOrder(context.Background(), "SOURCE-12")
if !errors.Is(err, ErrProtocol) {
t.Fatalf("QueryOrder() error = %v, want protocol error", err)
}
}
func TestQueryOrderMapsStableErrorsWithoutReadingDetails(t *testing.T) {
tests := []struct {
status int
want error
}{
{http.StatusUnauthorized, ErrSessionRequired},
{http.StatusNotFound, ErrNotFound},
{http.StatusBadGateway, ErrUnavailable},
}
for _, test := range tests {
t.Run(http.StatusText(test.status), func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(
writer http.ResponseWriter,
_ *http.Request,
) {
writer.WriteHeader(test.status)
_, _ = writer.Write([]byte(`{"detail":"private upstream text"}`))
}))
defer server.Close()
client, _ := New(
server.URL,
"12345678901234567890123456789012",
time.Second,
)
_, err := client.QueryOrder(context.Background(), "SOURCE-12")
if !errors.Is(err, test.want) {
t.Fatalf("QueryOrder() error = %v, want %v", err, test.want)
}
})
}
}
func TestQueryOrderDoesNotFollowRedirects(t *testing.T) {
followed := false
target := httptest.NewServer(http.HandlerFunc(func(
http.ResponseWriter,
*http.Request,
) {
followed = true
}))
defer target.Close()
redirect := httptest.NewServer(http.HandlerFunc(func(
writer http.ResponseWriter,
_ *http.Request,
) {
writer.Header().Set("Location", target.URL)
writer.WriteHeader(http.StatusTemporaryRedirect)
}))
defer redirect.Close()
client, _ := New(
redirect.URL,
"12345678901234567890123456789012",
time.Second,
)
_, err := client.QueryOrder(context.Background(), "SOURCE-12")
if !errors.Is(err, ErrUnavailable) || followed {
t.Fatalf("redirect error/followed = %v / %t", err, followed)
}
}
@@ -214,7 +214,7 @@ func TestFreightDateSyncAdvancesWatermarkOnlyOnWholeBatchSuccess(
if err := store.FailFreightSync(
ctx,
failed.ID,
"ERP_CONNECTOR_UNAVAILABLE",
"ERP_UNAVAILABLE",
now.Add(time.Hour+time.Minute),
); err != nil {
t.Fatalf("FailFreightSync() error = %v", err)
+4 -3
View File
@@ -62,9 +62,10 @@ T-205 原子领取/租约状态机、T-206 Android 登录/有限离线、T-207
结构化人工理由和修订历史也已完成。T-214 商品持久身份和重新定位指纹、T-215
Admin 候选确认、不可变待投递授权、T-216 设备命令可靠投递及 T-217 已授权商品
重新定位与订单 dry-run、T-218 单次订单提交围栏和订单回读,以及 T-219 Admin
待付款提醒与端到端验收均已完成。采购任务来源现扩展为顺运宝 ERP:T-220 至
T-224 将依次完成安全字段契约、精确单号 Connector、货运信息存储/Admin、待采购
需求生成和日期增量同步;完成后再进入 T-301 P0 UI 完整交互验收。
待付款提醒与端到端验收均已完成。采购任务来源已扩展为顺运宝 ERP:T-220 至 T-224
完成字段契约、货运信息存储/Admin、采购需求生成和日期增量同步;T-225 至 T-228 已将
验证码登录和查询统一迁移到 Go API 进程,删除 Python 服务、loopback 端口和共享 API Key。
完成后再进入 T-301 P0 UI 完整交互验收。
不得直接把候选链接或列表 ordinal 当成授权。
手机从管理后端领取任务并回传结果,VLM、拼多多自动化和人工确认在 App 本地完成。
T-206 增加有限离线执行;T-207 已复用 Roubao 端上 OpenAI 兼容适配器并加密本地 Key。
+6 -9
View File
@@ -21,7 +21,7 @@
| 数据访问 | 标准库 `database/sql` | MVP 已定 | 领域层通过仓储接口访问,避免先引入 ORM 和代码生成复杂度。 |
| 数据迁移 | Goose v3.26.0,使用嵌入式 SQL migration | 已验证 | v3.26.0 是已核实仍声明 Go 1.23.0 的最高版本;v3.27.x 要求 Go 1.25。 |
| 管理 Web | Gin + `html/template` + `embed` + 少量原生 JS/CSS | MVP 已定 | 不单独引入 SPA 工程,模板和静态资源随服务构建。 |
| ERP 直连适配 | Go 标准库 `net/http`、`net/http/cookiejar` | T-225 契约已冻结,尚未切换运行时 | Go 侧固定顺运宝请求、分页、详情批量和最小字段归一化;当前 Python loopback Connector 仅在 T-228 前临时保留。 |
| ERP 直连适配 | Go 标准库 `net/http`、`net/http/cookiejar` | T-227 已验证 | Go 侧固定顺运宝验证码、登录、会话校验、分页、详情批量和最小字段归一化;不需要 Python 服务或共享 API Key。 |
| 数据库 | SQLite | MVP 已定 | 单服务、单设备验证足够;多实例或并发提升前迁移 PostgreSQL。 |
| 图片/截图 | 后端受控本地文件目录 + `golang.org/x/image` v0.28.0 | 已验证 | JPEG/PNG/WebP 真解码后白底缩放并编码为 JPEG;数据库只存元数据和随机相对键。 |
| 管理鉴权 | bcrypt + 8 小时 opaque 服务端会话 Cookie | T-204 已验证 | `authctl` 预置 ADMIN;数据库只存密码 hash 与 session SHA-256,完整 RBAC 为 V2。 |
@@ -122,12 +122,10 @@ backend-api/
internal/config/ # 环境变量和安全默认
internal/platform/database/ # SQLite 打开、pragma 和生命周期
internal/platform/migration/ # Goose provider 封装
internal/platform/shunyunbao/ # ERP 会话、协议、直连查询与 allowlist
internal/transport/httpapi/ # Gin 路由、健康检查和 HTTP Server
migrations/ # 嵌入式 Goose SQL migration
var/ # 本地运行数据,必须忽略
erp-connector/
src/shunyunbaoerp/ # 顺运宝登录、查询、规范化与内部 HTTP 适配
tests/ # 伪响应和脱敏结构测试,不访问线上 ERP
docs/
```
@@ -167,8 +165,7 @@ T-205 已在该分层上增加独立 `LifecycleService` 与 SQLite immediate tra
PowerShell 等价环境变量,确保依赖没有暗中要求更高 Go 版本。
- handler 不直接写 SQL,repository 不依赖 Gin,domain/usecase 不导入具体数据库驱动。
- 密钥和设备令牌通过环境变量或本地忽略配置注入,不进入 Git。
- T-225 后 Go `internal/platform/shunyunbao` 固定同一套脱敏协议契约;T-226 以前不装配
直接会话,运行时仍使用临时 Connector。T-226 的会话只在进程内,不引入 Redis。
- ERP 账号、密码、Cookie、JWT、验证码和 Connector 服务密钥只通过环境变量/受控秘密
注入;不得进入 SQLite、浏览器、VLM、fixture 或普通日志。Go 适配器不得记录完整
响应或 PII。
- Go `internal/platform/shunyunbao` 固定同一套脱敏协议契约,并在 API 进程内装配受锁
会话与 `FreightSource`;会话只在进程内,不引入 Redis。
- ERP 账号、密码、Cookie、JWT 和验证码只通过后端环境变量或受控人工输入流转;不得进入
SQLite、浏览器、VLM、fixture 或普通日志。Go 适配器不得记录完整响应或 PII。
+2 -2
View File
@@ -161,8 +161,8 @@ T-225 冻结了 `internal/platform/shunyunbao` 的协议常量、请求 header
启动环境读取。T-227 将此会话直接作为 `FreightSource`:每次同步先校验会话,再以最多
100 条、每页 20 条和每批最多 100 个详情 ID 查询;响应不完整、身份冲突或会话失效均使
整批失败。进程重启即失去会话,不用 Redis 或持久化 Cookie。货运用例只识别来源中立的
“未配置、会话失效、未找到、协议异常、暂时不可用”错误。T-228 再删除未参与运行的旧
Python Connector、loopback 端口和服务密钥。
“未配置、会话失效、未找到、协议异常、暂时不可用”错误。T-228 已删除旧 Python
Connector、loopback 端口和服务密钥;仓库运行时仅保留 Go API 进程。
模式在 execution 开始时固定并写入结果;AI 失败后只能由人员明确切换,不能静默降级。
App 同时固定 provider ID、model、prompt/schema version 和证据 SHA-256,作为非秘密
+2 -1
View File
@@ -113,7 +113,8 @@
recovery 不得把 Authorization、Cookie、panic 或请求正文写普通日志。
- SQLite 数据文件必须位于被忽略目录,启用 foreign keys、有限 busy timeout 和
WAL;连接由进程入口显式关闭,migration 使用固定版本和受控 SQL 文件。
- ERP adapter 只能把版本化 allowlist 归一化对象交给 `FreightSource`;收件人、电话、
- ERP adapter 只能把版本化 allowlist 归一化对象交给 `FreightSource`;其 HTTP 会话只在
Go API 进程内。收件人、电话、
地址、完整响应、Cookie、JWT、账号、密码和验证码不得进入领域对象、错误、日志、
fixture、SQLite、浏览器或 VLM。验证码必须由人员输入,禁止 OCR、猜测或重放。
+1 -2
View File
@@ -276,8 +276,7 @@ T-227 的后台 worker 使用当前 Go 内存会话,按 `listTotal -> list 分
查询。每次先校验会话;列表最多 100 条、每页 20 条,详情每批最多 100 个外部 stock ID。
未配置、未登录、找不到货运单、响应协议错误和暂时不可用分别落为
`ERP_NOT_CONFIGURED`、`ERP_SESSION_REQUIRED`、`ERP_FREIGHT_NOT_FOUND`、
`ERP_RESPONSE_INVALID` 和 `ERP_UNAVAILABLE`,不返回 ERP 原始错误 body。旧 Python
Connector 仅在仓库中等待 T-228 删除,不再是运行时依赖。
`ERP_RESPONSE_INVALID` 和 `ERP_UNAVAILABLE`,不返回 ERP 原始错误 body。
### `POST /api/v1/freight-items/{item_id}/procurement-request`
+16 -16
View File
@@ -5,7 +5,7 @@
## 当前快照
- 日期:2026-07-29
- 阶段:T-227 Go 直连 ERP 查询已完成;T-228 待删除遗留 Python Connector
- 阶段:T-228 已完成单 Go 后端 ERP 切换;等待真实凭证下的人工验证码登录 smoke
- Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 至 T-219
均按文档提交、实现提交的顺序纳入历史
- 生产代码:`android-buyer/` 已接入 Roubao Android 源码
@@ -22,7 +22,8 @@
已增加受锁保护的 Go 内存 Cookie jar、验证码 ticket、登录和用户校验,以及 ADMIN 的
`/erp` 页面/API;账号密码仅由 `CMROUBAO_SHUNYUNBAO_*` 启动环境读取,重启后需人工
重新登录。T-227 已将其作为 `FreightSource`,查询先校验会话、再执行有界分页/详情
批量并返回 allowlist;未访问真实 ERP。旧 Python Connector 仅待 T-228 删除。
批量并返回 allowlist;T-228 已删除旧 Python Connector、loopback 端口和共享 API Key;
未访问真实 ERP。
- ERP 增量同步:v14 支持 Asia/Shanghai 创建日期闭区间和“同步至现在”,source 单窗
最多 7 天,后端对较长水位范围切窗并从成功水位前 10 分钟所在自然日回看。
货运落库、同步成功和水位推进同事务完成;失败与较旧范围成功不推进水位。Admin
@@ -34,12 +35,12 @@
- Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建
- 测试:T-219 Android Debug/Release 单元测试与构建和根 `init.ps1` 通过;
Debug APK `1.4.16 (21)` 已覆盖安装到 PKG110
- 后端测试:T-226 已运行 `go test ./...`、`go test -race ./...`、`go vet ./...` 和三个 Go
入口构建;T-227 增加 Go source 的伪 ERP 会话预检、完整单号、日期分页去重、详情
allowlist 和稳定错误码覆盖,均未访问真实 ERP;
- 后端测试:T-226 至 T-228 已运行 `go test ./...`、`go test -race ./...`、`go vet ./...`
和三个 Go 入口构建;T-227 增加 Go source 的伪 ERP 会话预检、完整单号、日期分页去重、
详情 allowlist 和稳定错误码覆盖;根 `init.ps1` 的 Android 测试/Debug APK 与 Go 标准
验证也通过,均未访问真实 ERP;
覆盖 v14 上下迁移、7 天切窗、水位重叠、中途失败、空窗口、重复页、来源 revision、
水位事务/不回退、Admin API/SSR 和旧 Connector 严格响应窗口;Python 22 项伪响应
测试保留至 T-228 删除前,未访问真实 ERP
水位事务/不回退、Admin API/SSR 和 Go source 严格响应窗口;未访问真实 ERP
- 原型:4 个管理 Web 页面和 7 个 Android 页面均可离线独立打开;Playwright
以 1440×900、390×844、360×800 验证 36 个页面/视口组合,无页面横向溢出、
脚本错误或外部请求,Android 可见交互控件均不小于 44px
@@ -103,10 +104,9 @@
对账和唯一对账成功;成功态展示完整订单号、平台时间、授权 SKU/数量/金额及受控
证据,只提醒采购员打开拼多多人工核对付款。Roubao 显示相同终态;Web/App 均没有
付款、重复提交或自动支付动作。隔离数据库真实 SSR 已通过三个响应式视口验收。
- T-220/T-221 ERP 来源:字段契约已固定 stock/detail/item 外部身份、PII 最小化和
缺图边界;Python Connector 只监听 loopback,服务密钥鉴权,受控验证码登录后按
完整单号读取全部商品并输出 allowlist schema。16 项伪响应测试和真实 HTTP 健康
smoke 通过,未访问线上 ERP。
- T-220 至 T-228 ERP 来源:字段契约已固定 stock/detail/item 外部身份、PII 最小化和
缺图边界;Go API 以受锁内存会话完成受控验证码登录,并直接按完整单号或日期范围查询
allowlist schema。旧 Python Connector、8091 端口和共享服务密钥已删除;未访问线上 ERP。
- Workflow:纯 Kotlin runner 已支持步骤 timeout、最多 3 次 retry、安全阻塞、
用户停止和单 runner 并发拒绝;T-102 已接入搜索加有界候选采集五步
- TaskSource:严格 CLI 已生成并验证真实私有 ProbeTask;默认 APK 不含私有 fixture
@@ -182,23 +182,23 @@
| `docs/tasks/T-225.md` | DONE | 冻结 Go 直连 ERP 协议与安全边界 |
| `docs/tasks/T-226.md` | DONE | Go ERP 会话、验证码登录与 Admin 连接页 |
| `docs/tasks/T-227.md` | DONE | Go 直连顺运宝查询接入货运同步 |
| `docs/tasks/T-228.md` | DONE | 移除 Python Connector 并完成 Go 切换 |
| `docs/design/` | 已确认 | T-202 原型索引、4 个管理页和 7 个 Android 页面 |
| `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 |
| `android-buyer/` | 已有 | Roubao `main` 固定 commit 的 Android 基线 |
| `android-buyer/task-contract/` | 已有 | Android/CLI 共享 ProbeTask 与 TaskSource |
| `android-buyer/tools/shopee-importer/` | 已有 | 开发机私有 fixture 导入 CLI |
| `backend-api/` | 已有 | Go-Gin 任务 API、SQLite、图片存储、管理 Web 和 migration |
| `erp-connector/` | 已有 | 顺运宝 loopback Python Connector 与无网络伪响应测试 |
| `init.ps1` / `init.sh` | 已验证/待跨平台 | Windows 同时验证 Android/Go;Unix 入口待 Linux/WSL 复核 |
## 任务摘要
- 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-219。
- 已完成:另含 T-220 至 T-227 ERP 契约、货运存储、采购需求生成、日期增量同步、Go
直连协议、人工验证码会话和直连 `FreightSource`。
- 已完成:另含 T-220 至 T-228 ERP 契约、货运存储、采购需求生成、日期增量同步、Go
直连协议、人工验证码会话、直连 `FreightSource` 和旧 Connector 清理。
- 进行中:无。
- 下一步:T-228 删除旧 Python Connector、loopback 端口/服务密钥及其文档;真实 ERP
上线前仍需确认开放 API、数据使用权限并由人员完成验证码登录。
- 下一步:真实 ERP 上线前确认开放 API、数据使用权限,并由人员在 `/erp` 完成验证码
登录后以受控单号执行一次不记录订单内容的 smoke。
## 当前可运行内容
+5 -5
View File
@@ -23,22 +23,22 @@
## Go 直连迁移契约(T-225)
T-225 在 `backend-api/internal/platform/shunyunbao` 用脱敏 fixture 固定以下内容,尚未
改变当前 Python Connector 运行时路径:
T-225 至 T-227 在 `backend-api/internal/platform/shunyunbao` 用脱敏 fixture 固定以下
内容,并由 API 进程内的 Go source 直接执行:
- 基础请求 header 固定 `Accept`、`Origin`、`Referer`、`User-Agent` 和
`X-Requested-With`;base URL 必须是无 userinfo、query、fragment 或路径的 origin。
- 单号条件为 `t_stock.allcode`、`op=6`、`type=0`、`optType=1`;日期范围为
`t_stock.created`、`op=0`、`type=3`、`optType=0`,单个 ERP 窗口最多 7 个自然日。
- `listTotal`、`list` 共享 `history=0`、`length`、`start`、`pageIndex`、`store=false`、
已验证 columns 和单一 `queries`;每页最多 500 条。
已验证 columns 和单一 `queries`;运行时每页固定 20 条、单次最多 100 条。
- `listByStock?hist=0` 每批最多 100 个去重后的正整数 stock id,详情 `id` 必须与列表
`stock.id` 一致。
- 所有结果必须经 Go allowlist 归一化;fixture 专门含收件信息、Cookie/JWT 标记值,
测试断言它们不会出现在输出或错误里。
Go 直连的验证码、登录和 Cookie jar 属于 T-226。不得用本地 OCR 或 Redis 取代人工
验证码流程;真实线上请求不属于自动化测试。
Go 直连的验证码、登录、Cookie jar 和查询 source 都在 API 进程内。不得用本地 OCR 或
Redis 取代人工验证码流程;真实线上请求不属于自动化测试。
## 身份和规范字段
+23 -6
View File
@@ -4,7 +4,7 @@ title: 移除 Python ERP Connector 并完成 Go 切换
phase: 2
deps:
- T-227
status: TODO
status: DONE
created: 2026-07-29
context_ref: d62a4af
work_branch: null
@@ -18,11 +18,13 @@ write_paths:
- docs/api.md
- docs/current-state.md
- docs/integrations/**
- .gitignore
- README.md
- backend-api/README.md
- backend-api/cmd/api/**
- backend-api/internal/config/**
- backend-api/internal/platform/erpconnector/**
- backend-api/internal/repository/sqlite/**
- erp-connector/**
- scripts/start-erp-connector.bat
- start-backend.bat
@@ -55,11 +57,11 @@ Go 直连 source 经 T-227 验证后,保留 Python Connector、8091 loopback
## 验收要点
- [ ] 仓库不再包含 Python Connector、8091 监听、Connector API Key 或其启动命令。
- [ ] Go 后端在 ERP 凭证缺失、未登录和伪 ERP 成功路径下均返回稳定、可操作状态。
- [ ] `rg` 证明已跟踪文件无 Connector 运行时引用、真实凭证、Cookie、JWT 或订单样本。
- [ ] `go test ./...`、`go test -race ./...`、`go vet ./...` 和根验证通过。
- [ ] 文档只说明单 Go 后端、人工验证码和外部 ERP 风险;真实线上 smoke 结果明确记录。
- [x] 仓库不再包含 Python Connector、8091 监听、Connector API Key 或其启动命令。
- [x] Go 后端在 ERP 凭证缺失、未登录和伪 ERP 成功路径下均返回稳定、可操作状态。
- [x] `rg` 证明已跟踪文件无 Connector 运行时引用、真实凭证、Cookie、JWT 或订单样本。
- [x] `go test ./...`、`go test -race ./...`、`go vet ./...` 和根验证通过。
- [x] 文档只说明单 Go 后端、人工验证码和外部 ERP 风险;真实线上 smoke 结果明确记录。
## 边界
@@ -70,3 +72,18 @@ Go 直连 source 经 T-227 验证后,保留 Python Connector、8091 loopback
## 执行记录
- 2026-07-29:由 T-227 依赖创建,等待 Go 查询链路验收后进行删除。
- 2026-07-29:开始实施;T-227 已由 `a822edd` 完成。确认外部原始研究脚本不在本次
删除范围内,清理仅作用于仓库内旧 Connector、端口、共享服务密钥及其文档。
- 2026-07-29:删除 `erp-connector/`、`internal/platform/erpconnector/` 和
`scripts/start-erp-connector.bat`,同时移除 `CMROUBAO_ERP_CONNECTOR_*`、8091 默认值、
共享 API Key 兼容和旧错误码。外部
`D:\chengma\shunyunbaoerp\shunyunbaoerp_single.py` 未读取、未修改、未删除。
- 2026-07-29:`start-backend.bat` 只启动 Go 后端;ERP 未配置时 `/erp` 给出可修复状态,
同步 run 使用 `ERP_NOT_CONFIGURED` 或 `ERP_SESSION_REQUIRED`,不再依赖第二进程。
- 验证:在 `backend-api/` 执行 `$env:GOTOOLCHAIN='local'; go test ./...; go test -race ./...;`
`go vet ./...; go build ./cmd/api; go build ./cmd/migrate; go build ./cmd/authctl`,全部通过。
根目录执行 `./init.ps1`,Android 测试/Debug APK 和 Go 标准验证全部通过。
- 扫描:排除历史任务执行记录后,`rg` 未找到 Connector 运行时引用、8091、共享 API Key、
真实凭证、Cookie、JWT 或订单样本;工作区内 `erp-connector/` 已不存在。
- 未运行:未配置真实 ERP 凭证,未执行线上验证码/受控单号 smoke;上线前仍需人员完成
`/erp` 人工验证码登录,并确认厂商开放 API、数据使用和个人信息处理授权。
-67
View File
@@ -1,67 +0,0 @@
# 顺运宝 ERP Connector
内部只读适配器:保持顺运宝验证码/Cookie 会话,按完整单号或最多 7 个自然日的
创建日期闭区间查询货运列表和详情,再输出不含收件人、电话、地址、Cookie、JWT
或原始响应的规范化 JSON。
协议客户端基于同一开发机上已经用本地 HAR 验证的 `shunyunbaoerp 0.1.0` 代码收敛;
本目录不包含 HAR、真实响应、账号、密码或订单号。
## 安装和测试
```powershell
Set-Location erp-connector
python -m pip install -e ".[api,dev]"
pytest
```
测试只使用伪响应,不访问线上 ERP。
## 启动
```powershell
$env:SHUNYUNBAO_USERNAME = "your_username"
$env:SHUNYUNBAO_PASSWORD = "your_password"
$env:SHUNYUNBAO_SERVICE_API_KEY = "at-least-32-utf8-bytes"
..\scripts\start-erp-connector.bat
```
服务固定监听 `127.0.0.1:8091` 并关闭访问日志。不要把环境变量写入脚本、Git 或命令行
参数;生产前应改用受控秘密注入。
## 受控会话
1. `GET /v1/session/captcha`,请求头携带 `X-API-Key`。
2. 人员查看验证码后调用 `POST /v1/session/login`:
```json
{"captcha_code":"abcd"}
```
3. 调用 `POST /v1/freight/query`:
```json
{"mode":"ORDER_NUMBER","order_number":"完整单号"}
```
日期查询严格使用 Asia/Shanghai 的 `YYYY-MM-DD`:
```json
{
"mode":"CREATED_RANGE",
"created_from":"2026-07-22",
"created_to":"2026-07-28"
}
```
Connector 不自动 OCR 绕过验证码。服务重启或 ERP 会话过期后需重新执行前两步。
## 输出边界
响应只包含:
- `external_stock_id`、受控来源单号、店铺、ERP 时间和原始状态。
- 全部商品的 `external_item_id`、标题、规格、SKU、数量、`productThumb` 引用和状态。
`productThumb` 当前可能是数字引用,不是公开图片 URL。下游必须保持 `NEEDS_IMAGE`,
不能拼接 URL 或抓取未知主机。
-38
View File
@@ -1,38 +0,0 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "shunyunbaoerp"
version = "0.1.0"
description = "顺运宝 ERP 货运信息查询客户端"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"requests>=2.31,<3",
]
[project.optional-dependencies]
api = [
"fastapi>=0.110,<1",
"uvicorn[standard]>=0.27,<1",
]
redis = [
"redis>=5,<7",
]
dev = [
"pytest>=8,<9",
]
[project.scripts]
shunyunbaoerp = "shunyunbaoerp.cli:main"
[tool.setuptools]
package-dir = {"" = "src"}
[tool.setuptools.packages.find]
where = ["src"]
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-q"
@@ -1,25 +0,0 @@
"""顺运宝 ERP 客户端。"""
from .client import ERPClient
from .errors import (
ERPAPIError,
ERPAuthenticationError,
ERPError,
ERPNotFoundError,
ERPProtocolError,
ERPTransportError,
)
from .normalizer import normalize_freight_result
__all__ = [
"ERPClient",
"ERPError",
"ERPTransportError",
"ERPProtocolError",
"ERPAPIError",
"ERPAuthenticationError",
"ERPNotFoundError",
"normalize_freight_result",
]
__version__ = "0.1.0"
-167
View File
@@ -1,167 +0,0 @@
"""可选 FastAPI 包装层,供其他项目通过 HTTP 调用。"""
from __future__ import annotations
import os
import secrets
import threading
from typing import Annotated
from fastapi import Depends, FastAPI, Header, HTTPException, Response, status
from pydantic import BaseModel, Field
from .client import ERPClient
from .errors import (
ERPAPIError,
ERPAuthenticationError,
ERPNotFoundError,
ERPProtocolError,
ERPTransportError,
)
from .normalizer import normalize_freight_result
app = FastAPI(
title="顺运宝 ERP 货运查询服务",
version="0.1.0",
description="单租户、受控会话的 ERP 查询适配层",
)
_client: ERPClient | None = None
_client_lock = threading.Lock()
class LoginRequest(BaseModel):
captcha_code: str = Field(min_length=1, max_length=16)
class FreightQueryRequest(BaseModel):
mode: str = Field(default="ORDER_NUMBER", max_length=32)
order_number: str | None = Field(default=None, min_length=1, max_length=128)
created_from: str | None = Field(default=None, min_length=10, max_length=10)
created_to: str | None = Field(default=None, min_length=10, max_length=10)
def get_client() -> ERPClient:
global _client
with _client_lock:
if _client is None:
_client = ERPClient(
os.getenv(
"SHUNYUNBAO_BASE_URL",
"https://www.shunyunbaoerp.com",
)
)
return _client
def require_api_key(
x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
) -> None:
expected = os.getenv("SHUNYUNBAO_SERVICE_API_KEY")
if not expected or len(expected.encode("utf-8")) < 32:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="CONNECTOR_API_KEY_NOT_CONFIGURED",
)
if not x_api_key or not secrets.compare_digest(x_api_key, expected):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="CONNECTOR_API_KEY_INVALID",
)
APIKeyDependency = Annotated[None, Depends(require_api_key)]
@app.get("/health")
def health() -> dict[str, object]:
client = get_client()
expires = client.token_expires_at
return {
"status": "ok",
"loggedIn": client.is_logged_in,
"tokenExpiresAt": expires.isoformat() if expires else None,
}
@app.get("/v1/session/captcha")
def captcha(_: APIKeyDependency) -> Response:
try:
client = get_client()
content = client.fetch_captcha()
return Response(
content=content,
media_type=client.last_captcha_content_type,
headers={"Cache-Control": "no-store"},
)
except (ERPTransportError, ERPProtocolError) as exc:
raise HTTPException(
status_code=502,
detail="ERP_CAPTCHA_UNAVAILABLE",
) from exc
@app.post("/v1/session/login")
def login(body: LoginRequest, _: APIKeyDependency) -> dict[str, object]:
username = os.getenv("SHUNYUNBAO_USERNAME")
password = os.getenv("SHUNYUNBAO_PASSWORD")
if not username or not password:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="ERP_CREDENTIALS_NOT_CONFIGURED",
)
try:
get_client().login(username, password, body.captcha_code)
return {"status": "ok"}
except ERPAuthenticationError as exc:
raise HTTPException(status_code=401, detail="ERP_LOGIN_FAILED") from exc
except (ERPTransportError, ERPProtocolError) as exc:
raise HTTPException(
status_code=502,
detail="ERP_UPSTREAM_UNAVAILABLE",
) from exc
@app.post("/v1/freight/query")
def query_freight(
body: FreightQueryRequest,
_: APIKeyDependency,
) -> dict[str, object]:
try:
if body.mode == "ORDER_NUMBER" and body.order_number:
if body.created_from is not None or body.created_to is not None:
raise ValueError("ORDER_NUMBER 不能包含日期范围")
result = get_client().get_freight_details(body.order_number)
elif (
body.mode == "CREATED_RANGE"
and body.order_number is None
and body.created_from
and body.created_to
):
result = get_client().get_freight_details_by_created_range(
body.created_from,
body.created_to,
)
else:
raise ValueError("查询模式与参数不匹配")
return normalize_freight_result(result)
except ERPAuthenticationError as exc:
raise HTTPException(
status_code=401,
detail="ERP_SESSION_REQUIRED",
) from exc
except ERPNotFoundError as exc:
raise HTTPException(
status_code=404,
detail="ERP_FREIGHT_NOT_FOUND",
) from exc
except ValueError as exc:
raise HTTPException(
status_code=422,
detail="ERP_QUERY_INVALID",
) from exc
except (ERPTransportError, ERPProtocolError, ERPAPIError) as exc:
raise HTTPException(
status_code=502,
detail="ERP_UPSTREAM_UNAVAILABLE",
) from exc
-99
View File
@@ -1,99 +0,0 @@
"""命令行入口,用于人工验证码登录和查询验证。"""
from __future__ import annotations
import argparse
import getpass
import json
import os
import sys
import webbrowser
from pathlib import Path
from .client import ERPClient
from .errors import ERPError
from .normalizer import normalize_freight_result
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="顺运宝 ERP 货运查询")
parser.add_argument(
"--base-url",
default=os.getenv("SHUNYUNBAO_BASE_URL", "https://www.shunyunbaoerp.com"),
help="ERP 根地址",
)
parser.add_argument(
"--username",
default=os.getenv("SHUNYUNBAO_USERNAME"),
help="ERP 用户名;默认读取 SHUNYUNBAO_USERNAME",
)
parser.add_argument(
"--captcha-file",
type=Path,
default=Path(".local/shunyunbao/captcha.png"),
help="验证码图片保存路径",
)
parser.add_argument(
"--open-captcha",
action="store_true",
help="保存后使用系统默认图片查看器打开验证码",
)
subparsers = parser.add_subparsers(dest="command", required=True)
subparsers.add_parser("login", help="验证登录链路")
query = subparsers.add_parser("query", help="按单号查询货运详情")
query.add_argument("order_number", help="ERP 页面“全部单号”查询框中的单号")
query.add_argument(
"--compact",
action="store_true",
help="输出紧凑 JSON",
)
return parser
def _credentials(args: argparse.Namespace) -> tuple[str, str]:
username = args.username or input("ERP 用户名: ").strip()
password = os.getenv("SHUNYUNBAO_PASSWORD") or getpass.getpass("ERP 密码: ")
if not username or not password:
raise ValueError("用户名和密码不能为空")
return username, password
def _interactive_login(client: ERPClient, args: argparse.Namespace) -> None:
image = client.fetch_captcha()
args.captcha_file.parent.mkdir(parents=True, exist_ok=True)
args.captcha_file.write_bytes(image)
absolute_path = args.captcha_file.resolve()
print(f"验证码已保存到: {absolute_path}", file=sys.stderr)
if args.open_captcha:
webbrowser.open(absolute_path.as_uri())
username, password = _credentials(args)
code = input("验证码: ").strip()
client.login(username, password, code)
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
with ERPClient(args.base_url) as client:
_interactive_login(client, args)
if args.command == "login":
expires = client.token_expires_at
suffix = f",JWT 提示过期时间 {expires.isoformat()}" if expires else ""
print(f"登录成功{suffix}")
return 0
result = normalize_freight_result(
client.get_freight_details(args.order_number)
)
indent = None if args.compact else 2
print(json.dumps(result, ensure_ascii=False, indent=indent, default=str))
return 0
except (ERPError, ValueError, OSError) as exc:
print(f"错误: {exc}", file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())
-558
View File
@@ -1,558 +0,0 @@
"""顺运宝 ERP 登录与货运详情查询客户端。"""
from __future__ import annotations
import base64
import json
import random
import threading
import time
from collections.abc import Iterable, Mapping
from datetime import date, datetime, timedelta, timezone
from typing import Any
from urllib.parse import urljoin, urlparse
import requests
from .constants import (
CAPTCHA_PATH,
LOGIN_PATH,
STOCK_DETAIL_PATH,
STOCK_LIST_PATH,
STOCK_LIST_TOTAL_PATH,
created_range_query,
order_number_query,
stock_columns,
)
from .errors import (
ERPAPIError,
ERPAuthenticationError,
ERPNotFoundError,
ERPProtocolError,
ERPTransportError,
)
class ERPClient:
"""保持 Cookie 会话的同步客户端。
一个实例对应一个 ERP 登录会话。requests.Session 不是线程安全的,
因此所有会话操作都由实例锁串行化。
"""
RETRYABLE_STATUS_CODES = frozenset({429, 502, 503, 504})
def __init__(
self,
base_url: str = "https://www.shunyunbaoerp.com",
*,
timeout: tuple[float, float] = (5.0, 30.0),
max_retries: int = 2,
page_size: int = 20,
max_matches: int = 100,
session: requests.Session | None = None,
allow_insecure_http: bool = False,
) -> None:
self.base_url = base_url.rstrip("/") + "/"
parsed = urlparse(self.base_url)
is_local = parsed.hostname in {"localhost", "127.0.0.1", "::1"}
if parsed.scheme != "https" and not (allow_insecure_http or is_local):
raise ValueError("ERP 登录包含口令,非本地地址必须使用 HTTPS")
if not parsed.netloc:
raise ValueError("base_url 必须是完整 URL")
if any(value <= 0 for value in timeout):
raise ValueError("timeout 必须为正数")
if max_retries < 0:
raise ValueError("max_retries 不能小于 0")
if not 1 <= page_size <= 500:
raise ValueError("page_size 必须在 1..500 之间")
if max_matches < 1:
raise ValueError("max_matches 必须大于 0")
self.timeout = timeout
self.max_retries = max_retries
self.page_size = page_size
self.max_matches = max_matches
self.session = session or requests.Session()
origin = f"{parsed.scheme}://{parsed.netloc}"
self.session.headers.update(
{
"Accept": "application/json, text/plain, */*",
"Origin": origin,
"Referer": urljoin(self.base_url, "sys/login"),
"User-Agent": "shunyunbaoerp-client/0.1",
"X-Requested-With": "XMLHttpRequest",
}
)
self._lock = threading.RLock()
self._captcha_fetched = False
self._token: str | None = None
self._token_claims: dict[str, Any] = {}
self._user: dict[str, Any] | None = None
self.last_captcha_content_type = "image/png"
@property
def user(self) -> dict[str, Any] | None:
"""登录用户的副本,不包含口令。"""
return dict(self._user) if self._user else None
@property
def is_logged_in(self) -> bool:
"""本地是否持有登录结果;最终有效性仍由服务端会话决定。"""
return self._user is not None
@property
def token_expires_at(self) -> datetime | None:
"""JWT 中的提示性过期时间;不用于验证签名或替代 Cookie。"""
exp = self._token_claims.get("exp")
if not isinstance(exp, (int, float)):
return None
try:
return datetime.fromtimestamp(exp, tz=timezone.utc)
except (OverflowError, OSError, ValueError):
return None
def fetch_captcha(self) -> bytes:
"""获取验证码图片,并在当前 Session 中保留服务端 Cookie。"""
with self._lock:
url = self._url(CAPTCHA_PATH)
try:
response = self.session.get(
url,
params={"_": int(time.time() * 1000)},
timeout=self.timeout,
)
response.raise_for_status()
except requests.RequestException as exc:
raise ERPTransportError(f"获取验证码失败: {exc}") from exc
content_type = response.headers.get("Content-Type", "").split(";", 1)[0].strip()
if not content_type.startswith("image/"):
raise ERPProtocolError(
f"验证码接口返回了非图片内容: {content_type or 'unknown'}"
)
if not response.content:
raise ERPProtocolError("验证码接口返回了空图片")
self.last_captcha_content_type = content_type
self._captcha_fetched = True
return response.content
def login(
self,
username: str,
password: str,
captcha_code: str,
*,
require_fetched_captcha: bool = True,
) -> dict[str, Any]:
"""使用同一 Session 中取得的验证码登录。
登录失败后验证码通常失效,调用方应重新 fetch_captcha。
"""
username = username.strip()
captcha_code = captcha_code.strip()
if not username:
raise ValueError("username 不能为空")
if not password:
raise ValueError("password 不能为空")
if not captcha_code:
raise ValueError("captcha_code 不能为空")
with self._lock:
if require_fetched_captcha and not self._captcha_fetched:
raise ERPProtocolError("必须先用同一个 ERPClient 实例获取验证码")
try:
data = self._request_json(
"POST",
LOGIN_PATH,
json_body={
"username": username,
"password": password,
"code": captcha_code,
},
retryable=False,
authentication_request=True,
)
finally:
# 验证码按一次性使用处理,无论成功失败都要求重新获取。
self._captcha_fetched = False
if not isinstance(data, Mapping):
raise ERPProtocolError("登录成功响应缺少 data 对象")
user = data.get("user")
token = data.get("token")
if not isinstance(user, Mapping):
raise ERPProtocolError("登录成功响应缺少 user 对象")
self._user = dict(user)
self._token = token if isinstance(token, str) else None
self._token_claims = self._decode_jwt_claims(self._token)
return dict(self._user)
def query_stock_by_order_number(self, order_number: str) -> list[dict[str, Any]]:
"""按 HAR 中的“全部单号”条件查询货运列表。
注意:HAR 样本的输入值匹配返回字段 code,而不是 orderCode。
"""
normalized = self._validate_order_number(order_number)
return self._query_stock(order_number_query(normalized))
def query_stock_by_created_range(
self,
created_from: date | str,
created_to: date | str,
) -> list[dict[str, Any]]:
"""按 Asia/Shanghai 自然日创建时间闭区间查询货运列表。"""
start, end = self._validate_created_range(created_from, created_to)
return self._query_stock(created_range_query(start.isoformat(), end.isoformat()))
def _query_stock(self, query: Mapping[str, Any]) -> list[dict[str, Any]]:
with self._lock:
first_payload = self._stock_payload(query, start=0, page_index=1)
total_raw = self._request_json(
"POST",
STOCK_LIST_TOTAL_PATH,
json_body=first_payload,
retryable=True,
)
try:
total = int(total_raw or 0)
except (TypeError, ValueError) as exc:
raise ERPProtocolError("listTotal 返回值不是整数") from exc
if total < 0:
raise ERPProtocolError("listTotal 返回了负数")
if total == 0:
return []
if total > self.max_matches:
raise ERPProtocolError(
f"单号查询返回 {total} 条,超过安全上限 {self.max_matches};"
"请确认查询条件或显式调高 max_matches"
)
rows: list[dict[str, Any]] = []
for start in range(0, total, self.page_size):
page_index = start // self.page_size + 1
payload = self._stock_payload(
query,
start=start,
page_index=page_index,
)
page_data = self._request_json(
"POST",
STOCK_LIST_PATH,
json_body=payload,
retryable=True,
)
page_rows = self._extract_list(page_data, endpoint=STOCK_LIST_PATH)
rows.extend(page_rows)
if not page_rows:
break
# 服务端分页变化时避免重复记录,并将数量严格限制在已报告总数内。
unique: list[dict[str, Any]] = []
seen_ids: set[object] = set()
for row in rows:
row_id = row.get("id")
key = row_id if row_id is not None else json.dumps(row, sort_keys=True, default=str)
if key not in seen_ids:
seen_ids.add(key)
unique.append(row)
return unique[:total]
def get_stock_details(self, stock_ids: Iterable[int]) -> list[dict[str, Any]]:
"""批量查询货运详情;HAR 证明详情 id 与列表 id 一致。"""
normalized_ids: list[int] = []
seen: set[int] = set()
for value in stock_ids:
if isinstance(value, bool):
raise ValueError("stock id 必须是正整数")
try:
stock_id = int(value)
except (TypeError, ValueError) as exc:
raise ValueError("stock id 必须是正整数") from exc
if stock_id <= 0:
raise ValueError("stock id 必须是正整数")
if stock_id not in seen:
seen.add(stock_id)
normalized_ids.append(stock_id)
if not normalized_ids:
return []
details: list[dict[str, Any]] = []
with self._lock:
for offset in range(0, len(normalized_ids), 100):
chunk = normalized_ids[offset : offset + 100]
data = self._request_json(
"POST",
STOCK_DETAIL_PATH,
params={"hist": 0},
json_body={"ids": chunk},
retryable=True,
)
details.extend(self._extract_list(data, endpoint=STOCK_DETAIL_PATH))
return details
def get_freight_details(self, order_number: str) -> dict[str, Any]:
"""查询单号并合并列表概要和详情,返回可直接 JSON 序列化的对象。"""
normalized = self._validate_order_number(order_number)
with self._lock:
stocks = self.query_stock_by_order_number(normalized)
if not stocks:
raise ERPNotFoundError("未找到对应货运记录")
return self._join_freight_details(
stocks,
{
"mode": "ORDER_NUMBER",
"orderNumber": normalized,
"matchField": "allcode",
},
)
def get_freight_details_by_created_range(
self,
created_from: date | str,
created_to: date | str,
) -> dict[str, Any]:
"""查询一个最多七天的创建日期窗口并合并详情。"""
start, end = self._validate_created_range(created_from, created_to)
with self._lock:
stocks = self.query_stock_by_created_range(start, end)
return self._join_freight_details(
stocks,
{
"mode": "CREATED_RANGE",
"createdFrom": start.isoformat(),
"createdTo": end.isoformat(),
},
)
def _join_freight_details(
self,
stocks: list[dict[str, Any]],
query: Mapping[str, Any],
) -> dict[str, Any]:
ids = [row.get("id") for row in stocks if row.get("id") is not None]
if len(ids) != len(stocks):
raise ERPProtocolError("货运列表存在缺少 id 的记录")
details = self.get_stock_details(ids)
details_by_id: dict[object, dict[str, Any]] = {}
for item in details:
item_id = item.get("id")
if item_id is None:
raise ERPProtocolError("货运详情存在缺少 id 的记录")
existing = details_by_id.get(item_id)
if existing is not None and existing != item:
raise ERPProtocolError("货运详情存在冲突的重复 id")
details_by_id[item_id] = item
records = [
{
"stock": stock,
"detail": details_by_id.get(stock["id"]),
}
for stock in stocks
]
return {
"query": dict(query),
"count": len(records),
"records": records,
}
def close(self) -> None:
self.session.close()
def __enter__(self) -> "ERPClient":
return self
def __exit__(self, *_: object) -> None:
self.close()
def _stock_payload(
self,
query: Mapping[str, Any],
*,
start: int,
page_index: int,
) -> dict[str, Any]:
return {
"history": 0,
"length": self.page_size,
"start": start,
"pageTotal": 0,
"pageIndex": page_index,
"store": False,
"columns": stock_columns(),
"queries": [dict(query)],
}
def _request_json(
self,
method: str,
path: str,
*,
params: Mapping[str, Any] | None = None,
json_body: Mapping[str, Any] | None = None,
retryable: bool,
authentication_request: bool = False,
) -> Any:
url = self._url(path)
attempts = self.max_retries + 1 if retryable else 1
response: requests.Response | None = None
for attempt in range(attempts):
try:
response = self.session.request(
method,
url,
params=params,
json=json_body,
timeout=self.timeout,
)
except requests.RequestException as exc:
if attempt + 1 >= attempts:
raise ERPTransportError(f"ERP 请求失败: {exc}") from exc
self._backoff(attempt, retry_after=None)
continue
if response.status_code in self.RETRYABLE_STATUS_CODES and attempt + 1 < attempts:
self._backoff(attempt, retry_after=response.headers.get("Retry-After"))
continue
try:
response.raise_for_status()
except requests.RequestException as exc:
raise ERPTransportError(
f"ERP 返回 HTTP {response.status_code}: {path}"
) from exc
break
if response is None:
raise ERPTransportError("ERP 请求未产生响应")
refreshed_token = response.headers.get("X-Requested-With")
if refreshed_token and refreshed_token.count(".") == 2:
self._token = refreshed_token
self._token_claims = self._decode_jwt_claims(refreshed_token)
try:
envelope = response.json()
except (requests.JSONDecodeError, ValueError) as exc:
raise ERPProtocolError(f"ERP 返回了非 JSON 内容: {path}") from exc
if not isinstance(envelope, Mapping):
raise ERPProtocolError(f"ERP JSON 顶层不是对象: {path}")
if envelope.get("status") is not True:
code = envelope.get("code")
message = str(envelope.get("msg") or "ERP 请求失败")
is_auth_error = (
authentication_request
or str(code) == "-2"
or "未登录" in message
or "登录过期" in message
)
error_type = ERPAuthenticationError if is_auth_error else ERPAPIError
if not authentication_request and is_auth_error:
self._user = None
self._token = None
self._token_claims = {}
raise error_type(message, code=code)
if "data" not in envelope:
raise ERPProtocolError(f"ERP 成功响应缺少 data: {path}")
return envelope["data"]
@staticmethod
def _extract_list(data: Any, *, endpoint: str) -> list[dict[str, Any]]:
if not isinstance(data, Mapping):
raise ERPProtocolError(f"{endpoint} 的 data 不是对象")
values = data.get("list")
if not isinstance(values, list):
raise ERPProtocolError(f"{endpoint} 的 data.list 不是数组")
if not all(isinstance(value, Mapping) for value in values):
raise ERPProtocolError(f"{endpoint} 的 data.list 含非对象元素")
return [dict(value) for value in values]
def _url(self, path: str) -> str:
return urljoin(self.base_url, path.lstrip("/"))
@staticmethod
def _validate_order_number(value: str) -> str:
if not isinstance(value, str):
raise ValueError("order_number 必须是字符串")
normalized = value.strip()
if not normalized:
raise ValueError("order_number 不能为空")
if len(normalized) > 128:
raise ValueError("order_number 不能超过 128 个字符")
if any(ord(char) < 32 or ord(char) == 127 for char in normalized):
raise ValueError("order_number 不能包含控制字符")
return normalized
@staticmethod
def _validate_created_range(
created_from: date | str,
created_to: date | str,
) -> tuple[date, date]:
def parse(value: date | str, field: str) -> date:
if isinstance(value, datetime):
raise ValueError(f"{field} 必须是 YYYY-MM-DD 日期")
if isinstance(value, date):
return value
if not isinstance(value, str):
raise ValueError(f"{field} 必须是 YYYY-MM-DD 日期")
try:
parsed = date.fromisoformat(value)
except ValueError as exc:
raise ValueError(f"{field} 必须是 YYYY-MM-DD 日期") from exc
if parsed.isoformat() != value:
raise ValueError(f"{field} 必须是 YYYY-MM-DD 日期")
return parsed
start = parse(created_from, "created_from")
end = parse(created_to, "created_to")
if end < start:
raise ValueError("created_to 不能早于 created_from")
if end - start > timedelta(days=6):
raise ValueError("创建日期闭区间不能超过 7 天")
return start, end
@staticmethod
def _decode_jwt_claims(token: str | None) -> dict[str, Any]:
"""仅解码 JWT payload 供过期时间展示,不验证其真实性。"""
if not token:
return {}
parts = token.split(".")
if len(parts) != 3:
return {}
payload = parts[1] + "=" * (-len(parts[1]) % 4)
try:
decoded = base64.urlsafe_b64decode(payload.encode("ascii"))
claims = json.loads(decoded.decode("utf-8"))
except (ValueError, UnicodeDecodeError, json.JSONDecodeError):
return {}
return claims if isinstance(claims, dict) else {}
@staticmethod
def _backoff(attempt: int, *, retry_after: str | None) -> None:
delay: float
if retry_after:
try:
delay = min(max(float(retry_after), 0.0), 10.0)
except ValueError:
delay = 0.0
else:
delay = 0.0
if delay == 0.0:
delay = min(0.5 * (2**attempt) + random.uniform(0.0, 0.25), 5.0)
time.sleep(delay)
@@ -1,131 +0,0 @@
"""从 HAR 提取的稳定接口常量和列表列定义。"""
from __future__ import annotations
from typing import Any
CAPTCHA_PATH = "/api/p/code1"
LOGIN_PATH = "/am/auth/login"
STOCK_LIST_TOTAL_PATH = "/am/stock/listTotal"
STOCK_LIST_PATH = "/am/stock/list"
STOCK_DETAIL_PATH = "/am/stock/detail/listByStock"
# (tableName, colName, fieldName, hasAlias, tableAlias)
_STOCK_COLUMN_SPECS = (
("t_stock", "created", "created", 0, "t"),
("t_stock", "order_code", "orderCode", 0, "t"),
("t_stock", "printer", "printer", 0, "t"),
("t_stock", "weight_time", "weightTime", 0, "t"),
("t_stock", "weight_inputer", "weightInputer", 0, "t"),
("t_stock", "pkg_time", "pkgTime", 0, "t"),
("t_stock", "code", "code", 0, "t"),
("t_stock", "status", "status", 0, "t"),
("t_stock", "order_status", "orderStatus", 0, "t"),
("t_stock", "purchase_status", "purchaseStatus", 0, "t"),
("t_stock", "exp_code", "expCode", 0, "t"),
("t_stock", "exp_page_code", "expPageCode", 0, "t"),
("t_stock", "exp_allow_print", "expAllowPrint", 0, "t"),
("t_stock", "exp_page_status", "expPageStatus", 0, "t"),
("t_stock", "page_id", "pageId", 0, "t"),
("t_stock", "upload_time", "uploadTime", 0, "t"),
("t_stock", "pay_time", "payTime", 0, "t"),
("t_stock", "shop_day_to_ship", "shopDayToShip", 0, "t"),
("t_stock", "remark9", "tsremark9", 1, "t"),
("t_stock", "remark9", "remark9", 0, "t"),
("t_stock", "detail_qty", "detailQty", 0, "t"),
("t_stock", "order_qty", "orderQty", 0, "t"),
("t_stock_detail", "inner_exp_code", "innerExpCode", 0, "t7"),
("t_stock_detail", "shelf_code", "shelfCode", 0, "t7"),
("t_stock", "shelf_code", "tsshelfCode", 1, "t"),
("t_stock", "store_type", "storeType", 0, "t"),
("t_stock", "order_bag_code", "orderBagCode", 0, "t"),
("t_stock", "weight_cust_pkg", "weightCustPkg", 0, "t"),
("t_stock", "weight_consign", "weightConsign", 0, "t"),
("t_stock", "amt_order", "amtOrder", 0, "t"),
("t_stock_append", "offline_amount", "offlineAmount", 0, "t8"),
("t_stock_append", "escrow_amount", "escrowAmount", 0, "t8"),
("t_stock", "exp_cod", "expCod", 0, "t"),
("t_stock", "exp_company", "expCompany", 0, "t"),
("t_stock", "transport", "transport", 0, "t"),
("t_stock", "order_origin", "orderOrigin", 0, "t"),
("t_stock", "exp_out_type", "expOutType", 0, "t"),
("t_stock", "order_platform", "orderPlatform", 0, "t"),
("t_stock", "exp_pkg_type", "expPkgType", 0, "t"),
("t_stock", "exp_pkg_code", "expPkgCode", 0, "t"),
("t_stock", "exp_batch", "expBatch", 0, "t"),
("t_stock", "exp_ti_huo", "expTiHuo", 0, "t"),
("t_stock", "print_time", "printTime", 0, "t"),
("t_stock", "receiver", "receiver", 0, "t"),
("t_stock", "receiver_tel", "receiverTel", 0, "t"),
("t_stock", "receiver_addr", "receiverAddr", 0, "t"),
("t_stock", "receiver_shop_name", "receiverShopName", 0, "t"),
("t_stock", "receiver_shop_code", "receiverShopCode", 0, "t"),
("t_stock", "product_name", "productName", 0, "t"),
("t_stock", "is_cancel", "isCancel", 0, "t"),
("t_stock", "err_msg", "errMsg", 0, "t"),
("t_stock", "remark2", "remark2", 0, "t"),
("t_stock", "remark1", "remark1", 0, "t"),
("t_stock", "note", "note", 0, "t"),
("t_store", "name", "name", 0, "t1"),
("sys_user", "fullname", "sufullname", 1, "t3"),
("sys_user", "dept_label_path", "deptLabelPath", 0, "t3"),
("t_stock", "shop_name", "shopName", 0, "t"),
("t_shop", "shop_id", "shopId", 0, "t6"),
("t_stock", "remark4", "remark4", 0, "t"),
("t_stock", "remark7", "remark7", 0, "t"),
("t_stock", "package_time", "packageTime", 0, "t"),
("t_stock", "packer", "packer", 0, "t"),
("t_stock", "pack_type", "packType", 0, "t"),
("t_stock", "track_status", "trackStatus", 0, "t"),
("t_stock", "track_desc", "trackDesc", 0, "t"),
("t_stock", "err_status", "errStatus", 0, "t"),
("t_stock", "err_time", "errTime", 0, "t"),
("t_stock", "err_msg", "tserrMsg", 1, "t"),
("t_stock", "remark2", "tsremark2", 1, "t"),
("t_stock", "remark1", "tsremark1", 1, "t"),
("t_stock", "product_volume_str", "productVolumeStr", 0, "t"),
)
def stock_columns() -> list[dict[str, Any]]:
"""返回新的列定义列表,避免调用方修改全局模板。"""
return [
{
"tableName": table_name,
"colName": column_name,
"fieldName": field_name,
"hasAlias": has_alias,
"tableAlias": table_alias,
}
for table_name, column_name, field_name, has_alias, table_alias in _STOCK_COLUMN_SPECS
]
def order_number_query(order_number: str) -> dict[str, Any]:
"""生成 HAR 中“全部单号”精确查询条件。"""
return {
"dvalue": order_number,
"tableName": "t_stock",
"colName": "allcode",
"op": 6,
"type": 0,
"tableAlias": "t",
"optType": 1,
}
def created_range_query(created_from: str, created_to: str) -> dict[str, Any]:
"""生成 HAR 中“创建时间”闭区间查询条件。"""
return {
"dvalue": f"{created_from},{created_to}",
"tableName": "t_stock",
"colName": "created",
"op": 0,
"type": 3,
"tableAlias": "t",
"optType": 0,
}
-29
View File
@@ -1,29 +0,0 @@
"""客户端异常定义。"""
class ERPError(RuntimeError):
"""所有顺运宝客户端异常的基类。"""
class ERPTransportError(ERPError):
"""网络、超时或 HTTP 状态异常。"""
class ERPProtocolError(ERPError):
"""ERP 响应不符合预期协议。"""
class ERPAPIError(ERPError):
"""ERP 返回 status=false。"""
def __init__(self, message: str, *, code: object = None) -> None:
super().__init__(message)
self.code = code
class ERPAuthenticationError(ERPAPIError):
"""登录失败、验证码错误或会话过期。"""
class ERPNotFoundError(ERPError):
"""未查到对应货运记录。"""
@@ -1,179 +0,0 @@
"""把 ERP 原始对象缩减为下游允许保存的货运字段。"""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from .errors import ERPProtocolError
def normalize_freight_result(result: Mapping[str, Any]) -> dict[str, Any]:
normalized_query = _normalize_query(result.get("query"))
records = result.get("records")
if not isinstance(records, list):
raise ERPProtocolError("货运查询结果缺少 records 数组")
orders: list[dict[str, Any]] = []
seen_orders: dict[str, dict[str, Any]] = {}
for record in records:
if not isinstance(record, Mapping):
raise ERPProtocolError("货运查询结果包含非对象记录")
stock = record.get("stock")
detail = record.get("detail")
if not isinstance(stock, Mapping) or not isinstance(detail, Mapping):
raise ERPProtocolError("货运记录缺少 stock 或 detail 对象")
stock_id = _external_id(stock.get("id"), "货运单")
detail_id = _external_id(detail.get("id"), "货运详情")
if stock_id != detail_id:
raise ERPProtocolError("货运列表和详情身份不一致")
raw_items = detail.get("details")
if not isinstance(raw_items, list):
raise ERPProtocolError("货运详情缺少 details 数组")
item_by_id: dict[str, dict[str, Any]] = {}
for raw_item in raw_items:
if not isinstance(raw_item, Mapping):
raise ERPProtocolError("货运商品明细包含非对象元素")
item = _normalize_item(raw_item)
existing = item_by_id.get(item["external_item_id"])
if existing is not None and existing != item:
raise ERPProtocolError("货运商品明细存在冲突的重复身份")
item_by_id[item["external_item_id"]] = item
order = {
"external_stock_id": stock_id,
"source_code": _text(stock.get("code")),
"platform_order_no": _optional_text(stock.get("orderCode")),
"shop_name": _optional_text(
detail.get("shopName")
if _text(detail.get("shopName"))
else stock.get("shopName")
),
"source_created_at": _optional_text(
detail.get("created")
if _text(detail.get("created"))
else stock.get("created")
),
"order_status": _optional_scalar(
stock.get("orderStatus")
if stock.get("orderStatus") is not None
else detail.get("status")
),
"purchase_status": _optional_scalar(stock.get("purchaseStatus")),
"is_canceled": _optional_bool(stock.get("isCancel")),
"items": sorted(
item_by_id.values(),
key=lambda value: _identity_sort_key(
value["external_item_id"]
),
),
}
existing_order = seen_orders.get(stock_id)
if existing_order is not None and existing_order != order:
raise ERPProtocolError("货运查询结果存在冲突的重复货运单")
seen_orders[stock_id] = order
orders.extend(
sorted(
seen_orders.values(),
key=lambda value: _identity_sort_key(
value["external_stock_id"]
),
)
)
return {
"schema_version": 1,
"query": normalized_query,
"orders": orders,
}
def _normalize_query(value: Any) -> dict[str, Any]:
if not isinstance(value, Mapping):
raise ERPProtocolError("货运查询结果缺少 query 对象")
mode = value.get("mode", "ORDER_NUMBER")
if mode == "ORDER_NUMBER":
return {"mode": "ORDER_NUMBER"}
if mode != "CREATED_RANGE":
raise ERPProtocolError("货运查询模式无效")
created_from = _text(value.get("createdFrom"))
created_to = _text(value.get("createdTo"))
if len(created_from) != 10 or len(created_to) != 10:
raise ERPProtocolError("货运日期范围无效")
return {
"mode": "CREATED_RANGE",
"created_from": created_from,
"created_to": created_to,
}
def _normalize_item(item: Mapping[str, Any]) -> dict[str, Any]:
title = _text(item.get("productTitle"))
if not title:
title = _text(item.get("detailProductName"))
sku = _text(item.get("sku"))
if not sku:
sku = _text(item.get("variationSku"))
return {
"external_item_id": _external_id(item.get("id"), "货运商品"),
"title": title,
"product_spec": _text(item.get("productSpec")),
"sku": sku,
"quantity": _positive_int_or_none(item.get("productQty")),
"product_thumb_ref": _optional_scalar(item.get("productThumb")),
"purchase_status": _optional_scalar(item.get("purchaseStatus")),
}
def _external_id(value: Any, label: str) -> str:
if isinstance(value, bool):
raise ERPProtocolError(f"{label}外部身份无效")
try:
normalized = int(value)
except (TypeError, ValueError) as exc:
raise ERPProtocolError(f"{label}外部身份无效") from exc
if normalized <= 0:
raise ERPProtocolError(f"{label}外部身份无效")
return str(normalized)
def _positive_int_or_none(value: Any) -> int | None:
if isinstance(value, bool):
return None
try:
normalized = int(value)
except (TypeError, ValueError):
return None
return normalized if normalized > 0 else None
def _text(value: Any) -> str:
return value.strip() if isinstance(value, str) else ""
def _optional_text(value: Any) -> str | None:
normalized = _text(value)
return normalized or None
def _optional_scalar(value: Any) -> str | None:
if value is None or isinstance(value, (dict, list, tuple, set)):
return None
normalized = str(value).strip()
return normalized or None
def _optional_bool(value: Any) -> bool | None:
if isinstance(value, bool):
return value
if value in (0, "0"):
return False
if value in (1, "1"):
return True
return None
def _identity_sort_key(value: str) -> tuple[int, str]:
return (int(value), value)
-111
View File
@@ -1,111 +0,0 @@
from __future__ import annotations
import json
import pytest
from fastapi import HTTPException
from shunyunbaoerp import api
from test_normalizer import raw_result
VALID_KEY = "k" * 32
class FakeClient:
is_logged_in = True
token_expires_at = None
def get_freight_details(self, order_number: str) -> dict:
assert order_number == "SOURCE-12"
return raw_result()
def get_freight_details_by_created_range(
self,
created_from: str,
created_to: str,
) -> dict:
assert (created_from, created_to) == ("2026-07-22", "2026-07-28")
result = raw_result()
result["query"] = {
"mode": "CREATED_RANGE",
"createdFrom": created_from,
"createdTo": created_to,
}
return result
def test_query_requires_configured_service_key(monkeypatch) -> None:
monkeypatch.delenv("SHUNYUNBAO_SERVICE_API_KEY", raising=False)
with pytest.raises(HTTPException) as raised:
api.require_api_key(VALID_KEY)
assert raised.value.status_code == 503
assert raised.value.detail == "CONNECTOR_API_KEY_NOT_CONFIGURED"
def test_query_rejects_wrong_service_key(monkeypatch) -> None:
monkeypatch.setenv("SHUNYUNBAO_SERVICE_API_KEY", VALID_KEY)
with pytest.raises(HTTPException) as raised:
api.require_api_key("x" * 32)
assert raised.value.status_code == 401
assert raised.value.detail == "CONNECTOR_API_KEY_INVALID"
def test_query_returns_only_normalized_fields(monkeypatch) -> None:
monkeypatch.setenv("SHUNYUNBAO_SERVICE_API_KEY", VALID_KEY)
monkeypatch.setattr(api, "get_client", lambda: FakeClient())
api.require_api_key(VALID_KEY)
response = api.query_freight(
api.FreightQueryRequest(order_number="SOURCE-12"),
None,
)
assert len(response["orders"][0]["items"]) == 2
encoded = json.dumps(response, ensure_ascii=False)
assert "receiverTel" not in encoded
assert "receiverAddr" not in encoded
assert "PRIVATE-QUERY" not in encoded
def test_created_range_query_returns_allowlisted_range(monkeypatch) -> None:
monkeypatch.setenv("SHUNYUNBAO_SERVICE_API_KEY", VALID_KEY)
monkeypatch.setattr(api, "get_client", lambda: FakeClient())
response = api.query_freight(
api.FreightQueryRequest(
mode="CREATED_RANGE",
created_from="2026-07-22",
created_to="2026-07-28",
),
None,
)
assert response["query"] == {
"mode": "CREATED_RANGE",
"created_from": "2026-07-22",
"created_to": "2026-07-28",
}
def test_created_range_rejects_mixed_mode_parameters(monkeypatch) -> None:
monkeypatch.setenv("SHUNYUNBAO_SERVICE_API_KEY", VALID_KEY)
monkeypatch.setattr(api, "get_client", lambda: FakeClient())
with pytest.raises(HTTPException) as raised:
api.query_freight(
api.FreightQueryRequest(
mode="CREATED_RANGE",
order_number="SOURCE-12",
created_from="2026-07-22",
created_to="2026-07-28",
),
None,
)
assert raised.value.status_code == 422
assert raised.value.detail == "ERP_QUERY_INVALID"
-242
View File
@@ -1,242 +0,0 @@
from __future__ import annotations
import base64
import json
from datetime import date
from urllib.parse import urlparse
import pytest
import requests
from shunyunbaoerp import (
ERPAuthenticationError,
ERPClient,
ERPNotFoundError,
ERPProtocolError,
)
class FakeResponse:
def __init__(
self,
payload=None,
*,
status_code: int = 200,
content: bytes = b"",
headers: dict[str, str] | None = None,
) -> None:
self._payload = payload
self.status_code = status_code
self.content = content
self.headers = headers or {"Content-Type": "application/json"}
def json(self):
return self._payload
def raise_for_status(self) -> None:
if self.status_code >= 400:
raise requests.HTTPError(str(self.status_code))
class FakeSession:
def __init__(self, responses: list[FakeResponse]) -> None:
self.responses = list(responses)
self.headers: dict[str, str] = {}
self.calls: list[dict[str, object]] = []
self.closed = False
def get(self, url: str, **kwargs):
self.calls.append({"method": "GET", "url": url, **kwargs})
return self.responses.pop(0)
def request(self, method: str, url: str, **kwargs):
self.calls.append({"method": method, "url": url, **kwargs})
return self.responses.pop(0)
def close(self) -> None:
self.closed = True
def envelope(data, *, status=True, msg="成功", code=None):
return {"status": status, "msg": msg, "data": data, "code": code}
def fake_jwt(exp: int = 2_000_000_000) -> str:
encode = lambda value: base64.urlsafe_b64encode(
json.dumps(value, separators=(",", ":")).encode()
).decode().rstrip("=")
return f"{encode({'alg': 'none'})}.{encode({'exp': exp, 'username': 'demo'})}.signature"
def test_login_keeps_captcha_and_login_in_same_session() -> None:
session = FakeSession(
[
FakeResponse(
content=b"image",
headers={"Content-Type": "image/png", "Set-Cookie": "omitted"},
),
FakeResponse(
envelope(
{
"user": {"id": 1, "username": "demo"},
"token": fake_jwt(),
}
)
),
]
)
client = ERPClient(session=session)
assert client.fetch_captcha() == b"image"
user = client.login("demo", "secret", "abcd")
assert user["id"] == 1
assert client.is_logged_in is True
assert client.token_expires_at is not None
assert urlparse(session.calls[0]["url"]).path == "/api/p/code1"
assert urlparse(session.calls[1]["url"]).path == "/am/auth/login"
assert session.calls[1]["json"] == {
"username": "demo",
"password": "secret",
"code": "abcd",
}
def test_login_requires_captcha_from_same_client() -> None:
client = ERPClient(session=FakeSession([]))
with pytest.raises(ERPProtocolError):
client.login("demo", "secret", "abcd")
def test_query_and_detail_reproduce_har_contract() -> None:
stock = {
"id": 99001122,
"code": "FREIGHT-001",
"orderCode": "PLATFORM-001",
}
detail = {
"id": 99001122,
"code": "FREIGHT-001",
"details": [{"id": 2, "productQty": 1}],
}
session = FakeSession(
[
FakeResponse(envelope(1)),
FakeResponse(envelope({"total": 1, "list": [stock]})),
FakeResponse(envelope({"total": 1, "list": [detail]})),
]
)
client = ERPClient(session=session)
result = client.get_freight_details("FREIGHT-001")
assert result["count"] == 1
assert result["records"][0] == {"stock": stock, "detail": detail}
assert [urlparse(call["url"]).path for call in session.calls] == [
"/am/stock/listTotal",
"/am/stock/list",
"/am/stock/detail/listByStock",
]
query = session.calls[0]["json"]["queries"][0]
assert query == {
"dvalue": "FREIGHT-001",
"tableName": "t_stock",
"colName": "allcode",
"op": 6,
"type": 0,
"tableAlias": "t",
"optType": 1,
}
assert len(session.calls[0]["json"]["columns"]) == 72
assert session.calls[2]["params"] == {"hist": 0}
assert session.calls[2]["json"] == {"ids": [99001122]}
def test_created_range_query_reproduces_har_contract_and_deduplicates() -> None:
stock = {"id": 99001122, "code": "FREIGHT-001"}
detail = {"id": 99001122, "details": []}
session = FakeSession(
[
FakeResponse(envelope(2)),
FakeResponse(envelope({"total": 2, "list": [stock, stock]})),
FakeResponse(envelope({"total": 1, "list": [detail]})),
]
)
client = ERPClient(session=session)
result = client.get_freight_details_by_created_range(
date(2026, 7, 22),
date(2026, 7, 28),
)
assert result["count"] == 1
assert result["query"] == {
"mode": "CREATED_RANGE",
"createdFrom": "2026-07-22",
"createdTo": "2026-07-28",
}
assert session.calls[0]["json"]["queries"] == [
{
"dvalue": "2026-07-22,2026-07-28",
"tableName": "t_stock",
"colName": "created",
"op": 0,
"type": 3,
"tableAlias": "t",
"optType": 0,
}
]
def test_created_range_rejects_more_than_seven_inclusive_days() -> None:
client = ERPClient(session=FakeSession([]))
with pytest.raises(ValueError, match="7 天"):
client.query_stock_by_created_range("2026-07-21", "2026-07-28")
def test_empty_created_range_does_not_request_details() -> None:
session = FakeSession([FakeResponse(envelope(0))])
client = ERPClient(session=session)
result = client.get_freight_details_by_created_range(
"2026-07-28",
"2026-07-28",
)
assert result["records"] == []
assert len(session.calls) == 1
def test_not_found_stops_before_detail_request() -> None:
session = FakeSession([FakeResponse(envelope(0))])
client = ERPClient(session=session)
with pytest.raises(ERPNotFoundError):
client.get_freight_details("missing")
assert len(session.calls) == 1
def test_expired_session_becomes_authentication_error() -> None:
session = FakeSession(
[
FakeResponse(
envelope(None, status=False, msg="未登录或登录过期", code="-2")
)
]
)
client = ERPClient(session=session)
with pytest.raises(ERPAuthenticationError):
client.query_stock_by_order_number("FREIGHT-001")
def test_rejects_unexpected_bulk_match() -> None:
session = FakeSession([FakeResponse(envelope(101))])
client = ERPClient(session=session, max_matches=100)
with pytest.raises(ERPProtocolError, match="安全上限"):
client.query_stock_by_order_number("too-broad")
-160
View File
@@ -1,160 +0,0 @@
from __future__ import annotations
import json
import pytest
from shunyunbaoerp import ERPProtocolError, normalize_freight_result
def raw_result() -> dict:
return {
"count": 1,
"query": {
"orderNumber": "PRIVATE-QUERY",
"matchField": "allcode",
},
"records": [
{
"stock": {
"id": 12,
"code": "SOURCE-12",
"orderCode": "PLATFORM-12",
"shopName": "来源店铺",
"created": "2026-07-28 10:00:00",
"orderStatus": 2,
"purchaseStatus": 0,
"isCancel": 0,
"receiver": "不得返回",
"receiverTel": "不得返回",
"receiverAddr": "不得返回",
},
"detail": {
"id": 12,
"shopName": "来源店铺",
"created": "2026-07-28 10:00:00",
"details": [
{
"id": 102,
"productTitle": "第二件商品",
"productSpec": "蓝色,L",
"sku": "",
"variationSku": "BLUE-L",
"productQty": 2,
"productThumb": 190000002,
"purchaseStatus": 0,
"cost": "不得返回",
},
{
"id": 101,
"productTitle": "第一件商品",
"productSpec": "灰色,2XL",
"sku": "GRAY-2XL",
"variationSku": "IGNORED",
"productQty": 1,
"productThumb": 190000001,
"purchaseStatus": 0,
},
],
"receiver": "不得返回",
},
}
],
}
def test_normalizes_all_items_and_excludes_private_fields() -> None:
normalized = normalize_freight_result(raw_result())
assert normalized["schema_version"] == 1
assert normalized["query"] == {"mode": "ORDER_NUMBER"}
assert len(normalized["orders"]) == 1
order = normalized["orders"][0]
assert order["external_stock_id"] == "12"
assert [item["external_item_id"] for item in order["items"]] == [
"101",
"102",
]
assert order["items"][1]["sku"] == "BLUE-L"
assert order["items"][1]["quantity"] == 2
assert order["items"][0]["product_thumb_ref"] == "190000001"
encoded = json.dumps(normalized, ensure_ascii=False)
for forbidden in (
"PRIVATE-QUERY",
"receiver",
"receiverTel",
"receiverAddr",
"cost",
"不得返回",
):
assert forbidden not in encoded
def test_invalid_procurement_fields_remain_reviewable() -> None:
source = raw_result()
item = source["records"][0]["detail"]["details"][0]
item["productTitle"] = None
item["detailProductName"] = None
item["sku"] = ""
item["variationSku"] = ""
item["productQty"] = 0
item["productThumb"] = None
normalized = normalize_freight_result(source)
result = normalized["orders"][0]["items"][1]
assert result["title"] == ""
assert result["sku"] == ""
assert result["quantity"] is None
assert result["product_thumb_ref"] is None
def test_normalizes_created_range_without_exposing_raw_query() -> None:
source = raw_result()
source["query"] = {
"mode": "CREATED_RANGE",
"createdFrom": "2026-07-22",
"createdTo": "2026-07-28",
"private": "must-not-leak",
}
normalized = normalize_freight_result(source)
assert normalized["query"] == {
"mode": "CREATED_RANGE",
"created_from": "2026-07-22",
"created_to": "2026-07-28",
}
assert "must-not-leak" not in json.dumps(normalized)
def test_conflicting_duplicate_item_is_rejected() -> None:
source = raw_result()
duplicate = dict(source["records"][0]["detail"]["details"][0])
duplicate["productTitle"] = "冲突内容"
source["records"][0]["detail"]["details"].append(duplicate)
with pytest.raises(ERPProtocolError, match="重复身份"):
normalize_freight_result(source)
@pytest.mark.parametrize(
"mutation",
[
lambda source: source["records"][0].update(detail=None),
lambda source: source["records"][0]["detail"].update(id=99),
lambda source: source["records"][0]["detail"].update(details=None),
lambda source: source["records"][0]["detail"]["details"][0].update(
id=None
),
],
)
def test_identity_or_detail_shape_failure_rejects_whole_batch(
mutation,
) -> None:
source = raw_result()
mutation(source)
with pytest.raises(ERPProtocolError):
normalize_freight_result(source)
-26
View File
@@ -1,26 +0,0 @@
@echo off
setlocal
if "%SHUNYUNBAO_USERNAME%"=="" (
echo Missing SHUNYUNBAO_USERNAME.
exit /b 1
)
if "%SHUNYUNBAO_PASSWORD%"=="" (
echo Missing SHUNYUNBAO_PASSWORD.
exit /b 1
)
if "%SHUNYUNBAO_SERVICE_API_KEY%"=="" (
echo Missing SHUNYUNBAO_SERVICE_API_KEY.
exit /b 1
)
set "CONNECTOR_ROOT=%~dp0..\erp-connector"
pushd "%CONNECTOR_ROOT%" || exit /b 1
python -m uvicorn shunyunbaoerp.api:app ^
--app-dir src ^
--host 127.0.0.1 ^
--port 8091 ^
--no-access-log
set "EXIT_CODE=%ERRORLEVEL%"
popd
exit /b %EXIT_CODE%
-6
View File
@@ -7,12 +7,6 @@ set "LOCAL_TLS_DIR=%PROJECT_ROOT%.local\cmroubao-tls"
set "LOCAL_TLS_CERT=%LOCAL_TLS_DIR%\server.crt"
set "LOCAL_TLS_KEY=%LOCAL_TLS_DIR%\server.key"
if not defined CMROUBAO_ERP_CONNECTOR_API_KEY (
if defined SHUNYUNBAO_SERVICE_API_KEY (
set "CMROUBAO_ERP_CONNECTOR_API_KEY=%SHUNYUNBAO_SERVICE_API_KEY%"
)
)
if not defined CMROUBAO_HTTP_ADDR (
if exist "%LOCAL_TLS_CERT%" if exist "%LOCAL_TLS_KEY%" (
set "CMROUBAO_HTTP_ADDR=0.0.0.0:8080"