feat(t225): freeze Go ERP protocol contract

This commit is contained in:
QiuSW
2026-07-29 09:21:32 +08:00
parent 11e480773a
commit c2f340d067
13 changed files with 836 additions and 31 deletions
@@ -0,0 +1,13 @@
package domain
import "errors"
// Freight source errors are intentionally transport-neutral. The use case maps
// them to stable sync outcomes without exposing ERP responses or credentials.
var (
ErrFreightSourceNotConfigured = errors.New("freight source is not configured")
ErrFreightSourceSessionNeeded = errors.New("freight source session is required")
ErrFreightSourceNotFound = errors.New("freight source order not found")
ErrFreightSourceUnavailable = errors.New("freight source is unavailable")
ErrFreightSourceProtocol = errors.New("freight source protocol is invalid")
)
@@ -14,11 +14,13 @@ import (
)
var (
ErrNotConfigured = errors.New("ERP connector is not configured")
ErrSessionRequired = errors.New("ERP session is required")
ErrNotFound = errors.New("ERP freight order not found")
ErrUnavailable = errors.New("ERP connector is unavailable")
ErrProtocol = errors.New("ERP connector protocol is invalid")
// 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
@@ -0,0 +1,322 @@
package shunyunbao
import (
"encoding/json"
"fmt"
"math"
"reflect"
"sort"
"strconv"
"strings"
"time"
"cmroubao/backend-api/internal/domain"
)
type RawQuery struct {
Mode string `json:"mode"`
CreatedFrom string `json:"createdFrom"`
CreatedTo string `json:"createdTo"`
}
type RawRecord struct {
Stock map[string]any `json:"stock"`
Detail map[string]any `json:"detail"`
}
type RawFreightResult struct {
Query RawQuery `json:"query"`
Records []RawRecord `json:"records"`
}
// NormalizeFreightResult reduces an ERP response to the domain allowlist.
// Values not explicitly copied below, including recipient PII and session data,
// cannot reach its result.
func NormalizeFreightResult(
query RawQuery,
records []RawRecord,
) (domain.FreightSourceBatch, error) {
normalizedQuery, err := normalizeQuery(query)
if err != nil {
return domain.FreightSourceBatch{}, domain.ErrFreightSourceProtocol
}
ordersByID := make(map[string]domain.FreightSourceOrder, len(records))
for _, record := range records {
order, err := normalizeOrder(record)
if err != nil {
return domain.FreightSourceBatch{}, domain.ErrFreightSourceProtocol
}
if existing, exists := ordersByID[order.ExternalStockID]; exists && !reflect.DeepEqual(existing, order) {
return domain.FreightSourceBatch{}, domain.ErrFreightSourceProtocol
}
ordersByID[order.ExternalStockID] = order
}
orders := make([]domain.FreightSourceOrder, 0, len(ordersByID))
for _, order := range ordersByID {
orders = append(orders, order)
}
sort.Slice(orders, func(left, right int) bool {
return externalIDLess(orders[left].ExternalStockID, orders[right].ExternalStockID)
})
return domain.FreightSourceBatch{
SchemaVersion: 1,
Query: normalizedQuery,
Orders: orders,
}, nil
}
func normalizeQuery(value RawQuery) (domain.FreightSourceQuery, error) {
switch value.Mode {
case "", domain.FreightSyncOrderNumber:
return domain.FreightSourceQuery{Mode: domain.FreightSyncOrderNumber}, nil
case domain.FreightSyncCreatedRange:
if !validDate(value.CreatedFrom) || !validDate(value.CreatedTo) {
return domain.FreightSourceQuery{}, errInvalidProtocolInput
}
return domain.FreightSourceQuery{
Mode: domain.FreightSyncCreatedRange,
CreatedFrom: stringPointer(value.CreatedFrom),
CreatedTo: stringPointer(value.CreatedTo),
}, nil
default:
return domain.FreightSourceQuery{}, errInvalidProtocolInput
}
}
func normalizeOrder(record RawRecord) (domain.FreightSourceOrder, error) {
if record.Stock == nil || record.Detail == nil {
return domain.FreightSourceOrder{}, errInvalidProtocolInput
}
stockID, err := externalID(record.Stock["id"])
if err != nil {
return domain.FreightSourceOrder{}, err
}
detailID, err := externalID(record.Detail["id"])
if err != nil || detailID != stockID {
return domain.FreightSourceOrder{}, errInvalidProtocolInput
}
rawItems, ok := record.Detail["details"].([]any)
if !ok {
return domain.FreightSourceOrder{}, errInvalidProtocolInput
}
itemsByID := make(map[string]domain.FreightSourceItem, len(rawItems))
for _, rawItem := range rawItems {
itemMap, ok := rawItem.(map[string]any)
if !ok {
return domain.FreightSourceOrder{}, errInvalidProtocolInput
}
item, err := normalizeItem(itemMap)
if err != nil {
return domain.FreightSourceOrder{}, err
}
if existing, exists := itemsByID[item.ExternalItemID]; exists && !reflect.DeepEqual(existing, item) {
return domain.FreightSourceOrder{}, errInvalidProtocolInput
}
itemsByID[item.ExternalItemID] = item
}
items := make([]domain.FreightSourceItem, 0, len(itemsByID))
for _, item := range itemsByID {
items = append(items, item)
}
sort.Slice(items, func(left, right int) bool {
return externalIDLess(items[left].ExternalItemID, items[right].ExternalItemID)
})
shopName := optionalText(record.Detail["shopName"])
if shopName == nil {
shopName = optionalText(record.Stock["shopName"])
}
createdAt := optionalText(record.Detail["created"])
if createdAt == nil {
createdAt = optionalText(record.Stock["created"])
}
orderStatus := optionalScalar(record.Stock["orderStatus"])
if orderStatus == nil {
orderStatus = optionalScalar(record.Detail["status"])
}
return domain.FreightSourceOrder{
ExternalStockID: stockID,
SourceCode: text(record.Stock["code"]),
PlatformOrderNo: optionalText(record.Stock["orderCode"]),
ShopName: shopName,
SourceCreatedAt: createdAt,
OrderStatus: orderStatus,
PurchaseStatus: optionalScalar(record.Stock["purchaseStatus"]),
IsCanceled: optionalBoolean(record.Stock["isCancel"]),
Items: items,
}, nil
}
func normalizeItem(value map[string]any) (domain.FreightSourceItem, error) {
externalItemID, err := externalID(value["id"])
if err != nil {
return domain.FreightSourceItem{}, err
}
title := text(value["productTitle"])
if title == "" {
title = text(value["detailProductName"])
}
sku := text(value["sku"])
if sku == "" {
sku = text(value["variationSku"])
}
return domain.FreightSourceItem{
ExternalItemID: externalItemID,
Title: title,
ProductSpec: text(value["productSpec"]),
SKU: sku,
Quantity: positiveInt(value["productQty"]),
ProductThumbRef: optionalScalar(value["productThumb"]),
PurchaseStatus: optionalScalar(value["purchaseStatus"]),
}, nil
}
func externalID(value any) (string, error) {
var normalized uint64
switch typed := value.(type) {
case string:
parsed, err := strconv.ParseUint(strings.TrimSpace(typed), 10, 64)
if err != nil {
return "", errInvalidProtocolInput
}
normalized = parsed
case json.Number:
parsed, err := strconv.ParseUint(string(typed), 10, 64)
if err != nil {
return "", errInvalidProtocolInput
}
normalized = parsed
case float64:
if typed != math.Trunc(typed) || typed <= 0 || typed > 1<<53 {
return "", errInvalidProtocolInput
}
normalized = uint64(typed)
case int:
if typed < 1 {
return "", errInvalidProtocolInput
}
normalized = uint64(typed)
case int64:
if typed < 1 {
return "", errInvalidProtocolInput
}
normalized = uint64(typed)
case uint64:
normalized = typed
default:
return "", errInvalidProtocolInput
}
if normalized == 0 {
return "", errInvalidProtocolInput
}
return strconv.FormatUint(normalized, 10), nil
}
func positiveInt(value any) *int {
var normalized int64
switch typed := value.(type) {
case json.Number:
parsed, err := strconv.ParseInt(string(typed), 10, 0)
if err != nil {
return nil
}
normalized = parsed
case float64:
if typed != math.Trunc(typed) || typed > float64(math.MaxInt) || typed < 1 {
return nil
}
normalized = int64(typed)
case int:
normalized = int64(typed)
case int64:
normalized = typed
case string:
parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 0)
if err != nil {
return nil
}
normalized = parsed
default:
return nil
}
if normalized < 1 || normalized > int64(math.MaxInt) {
return nil
}
result := int(normalized)
return &result
}
func optionalBoolean(value any) *bool {
switch typed := value.(type) {
case bool:
return &typed
case string:
if typed == "0" {
result := false
return &result
}
if typed == "1" {
result := true
return &result
}
case json.Number:
return optionalBoolean(string(typed))
case float64:
if typed == 0 || typed == 1 {
result := typed == 1
return &result
}
case int:
if typed == 0 || typed == 1 {
result := typed == 1
return &result
}
}
return nil
}
func text(value any) string {
typedValue, ok := value.(string)
if !ok {
return ""
}
return strings.TrimSpace(typedValue)
}
func optionalText(value any) *string {
normalized := text(value)
if normalized == "" {
return nil
}
return &normalized
}
func optionalScalar(value any) *string {
if value == nil {
return nil
}
switch value.(type) {
case map[string]any, []any, map[any]any, []string:
return nil
}
normalized := strings.TrimSpace(fmt.Sprint(value))
if normalized == "" {
return nil
}
return &normalized
}
func validDate(value string) bool {
parsed, err := time.Parse(time.DateOnly, value)
return err == nil && parsed.Format(time.DateOnly) == value
}
func externalIDLess(left, right string) bool {
leftValue, _ := strconv.ParseUint(left, 10, 64)
rightValue, _ := strconv.ParseUint(right, 10, 64)
return leftValue < rightValue
}
func stringPointer(value string) *string {
return &value
}
@@ -0,0 +1,237 @@
package shunyunbao
import (
"errors"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"unicode/utf8"
)
const (
CaptchaPath = "/api/p/code1"
LoginPath = "/am/auth/login"
UserPath = "/am/user/get"
StockListTotalPath = "/am/stock/listTotal"
StockListPath = "/am/stock/list"
StockDetailPath = "/am/stock/detail/listByStock"
maxPageSize = 500
maxDetailIDs = 100
maxOrderNumber = 128
maximumDateSpan = 6
)
var errInvalidProtocolInput = errors.New("invalid shunyunbao protocol input")
type Column struct {
TableName string `json:"tableName"`
ColumnName string `json:"colName"`
FieldName string `json:"fieldName"`
HasAlias int `json:"hasAlias"`
TableAlias string `json:"tableAlias"`
}
type QueryCondition struct {
Value string `json:"dvalue"`
TableName string `json:"tableName"`
ColumnName string `json:"colName"`
Operator int `json:"op"`
Type int `json:"type"`
TableAlias string `json:"tableAlias"`
OptionType int `json:"optType"`
}
var stockColumns = []Column{
{"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"},
}
func RequestHeaders(baseURL string) (http.Header, error) {
origin, err := originFor(baseURL)
if err != nil {
return nil, errInvalidProtocolInput
}
return http.Header{
"Accept": {"application/json, text/plain, */*"},
"Origin": {origin},
"Referer": {origin + "/sys/login"},
"User-Agent": {"shunyunbaoerp-client/0.1"},
"X-Requested-With": {"XMLHttpRequest"},
}, nil
}
func StockColumns() []Column {
return append([]Column(nil), stockColumns...)
}
func OrderNumberQuery(orderNumber string) (QueryCondition, error) {
orderNumber = strings.TrimSpace(orderNumber)
if orderNumber == "" || len([]byte(orderNumber)) > maxOrderNumber ||
!utf8.ValidString(orderNumber) || hasControl(orderNumber) {
return QueryCondition{}, errInvalidProtocolInput
}
return QueryCondition{
Value: orderNumber,
TableName: "t_stock",
ColumnName: "allcode",
Operator: 6,
Type: 0,
TableAlias: "t",
OptionType: 1,
}, nil
}
func CreatedRangeQuery(createdFrom, createdTo string) (QueryCondition, error) {
from, err := time.Parse(time.DateOnly, createdFrom)
if err != nil || from.Format(time.DateOnly) != createdFrom {
return QueryCondition{}, errInvalidProtocolInput
}
to, err := time.Parse(time.DateOnly, createdTo)
if err != nil || to.Format(time.DateOnly) != createdTo || to.Before(from) ||
int(to.Sub(from)/(24*time.Hour)) > maximumDateSpan {
return QueryCondition{}, errInvalidProtocolInput
}
return QueryCondition{
Value: createdFrom + "," + createdTo,
TableName: "t_stock",
ColumnName: "created",
Operator: 0,
Type: 3,
TableAlias: "t",
OptionType: 0,
}, nil
}
func StockListPayload(
query QueryCondition,
start, pageIndex, pageSize int,
) (map[string]any, error) {
if start < 0 || pageIndex < 1 || pageSize < 1 || pageSize > maxPageSize {
return nil, errInvalidProtocolInput
}
return map[string]any{
"history": 0,
"length": pageSize,
"start": start,
"pageTotal": 0,
"pageIndex": pageIndex,
"store": false,
"columns": StockColumns(),
"queries": []QueryCondition{query},
}, nil
}
func StockDetailPayload(externalStockIDs []string) (map[string]any, error) {
if len(externalStockIDs) == 0 || len(externalStockIDs) > maxDetailIDs {
return nil, errInvalidProtocolInput
}
ids := make([]uint64, 0, len(externalStockIDs))
seen := make(map[uint64]struct{}, len(externalStockIDs))
for _, value := range externalStockIDs {
parsed, err := strconv.ParseUint(strings.TrimSpace(value), 10, 64)
if err != nil || parsed == 0 {
return nil, errInvalidProtocolInput
}
if _, exists := seen[parsed]; exists {
continue
}
seen[parsed] = struct{}{}
ids = append(ids, parsed)
}
if len(ids) == 0 {
return nil, errInvalidProtocolInput
}
return map[string]any{"ids": ids}, nil
}
func originFor(baseURL string) (string, error) {
parsed, err := url.Parse(strings.TrimSpace(baseURL))
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") ||
parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" ||
parsed.Fragment != "" || (parsed.Path != "" && parsed.Path != "/") {
return "", errInvalidProtocolInput
}
return parsed.Scheme + "://" + parsed.Host, nil
}
func hasControl(value string) bool {
for _, character := range value {
if character < 32 || character == 127 {
return true
}
}
return false
}
@@ -0,0 +1,142 @@
package shunyunbao
import (
"bytes"
"encoding/json"
"errors"
"os"
"strings"
"testing"
"cmroubao/backend-api/internal/domain"
)
func TestProtocolBuildsVerifiedHeadersAndPayloads(t *testing.T) {
headers, err := RequestHeaders("https://www.shunyunbaoerp.com")
if err != nil {
t.Fatalf("RequestHeaders() error = %v", err)
}
for name, want := range map[string]string{
"Accept": "application/json, text/plain, */*",
"Origin": "https://www.shunyunbaoerp.com",
"Referer": "https://www.shunyunbaoerp.com/sys/login",
"User-Agent": "shunyunbaoerp-client/0.1",
"X-Requested-With": "XMLHttpRequest",
} {
if got := headers.Get(name); got != want {
t.Fatalf("header %s = %q, want %q", name, got, want)
}
}
query, err := OrderNumberQuery(" SANITIZED-CODE-12 ")
if err != nil {
t.Fatalf("OrderNumberQuery() error = %v", err)
}
if query.Value != "SANITIZED-CODE-12" || query.ColumnName != "allcode" ||
query.Operator != 6 || query.OptionType != 1 {
t.Fatalf("order query = %#v", query)
}
payload, err := StockListPayload(query, 20, 2, 20)
if err != nil {
t.Fatalf("StockListPayload() error = %v", err)
}
if payload["history"] != 0 || payload["length"] != 20 ||
payload["start"] != 20 || payload["pageIndex"] != 2 ||
payload["store"] != false {
t.Fatalf("list payload = %#v", payload)
}
columns, ok := payload["columns"].([]Column)
if !ok || len(columns) != 72 || columns[44].FieldName != "receiverTel" {
t.Fatalf("columns = %#v", payload["columns"])
}
detail, err := StockDetailPayload([]string{"12", "12", "99"})
if err != nil {
t.Fatalf("StockDetailPayload() error = %v", err)
}
ids, ok := detail["ids"].([]uint64)
if !ok || len(ids) != 2 || ids[0] != 12 || ids[1] != 99 ||
StockDetailPath != "/am/stock/detail/listByStock" {
t.Fatalf("detail payload/path = %#v / %q", detail, StockDetailPath)
}
rangeQuery, err := CreatedRangeQuery("2026-07-22", "2026-07-28")
if err != nil || rangeQuery.Value != "2026-07-22,2026-07-28" ||
rangeQuery.ColumnName != "created" || rangeQuery.Type != 3 {
t.Fatalf("CreatedRangeQuery() = %#v, %v", rangeQuery, err)
}
}
func TestNormalizeFreightFixtureUsesOnlyAllowlist(t *testing.T) {
content, err := os.ReadFile("testdata/freight-result.json")
if err != nil {
t.Fatalf("read fixture: %v", err)
}
var fixture RawFreightResult
decoder := json.NewDecoder(bytes.NewReader(content))
decoder.UseNumber()
if err := decoder.Decode(&fixture); err != nil {
t.Fatalf("decode fixture: %v", err)
}
result, err := NormalizeFreightResult(fixture.Query, fixture.Records)
if err != nil {
t.Fatalf("NormalizeFreightResult() error = %v", err)
}
if result.SchemaVersion != 1 || result.Query.Mode != domain.FreightSyncOrderNumber ||
len(result.Orders) != 1 || len(result.Orders[0].Items) != 1 {
t.Fatalf("result = %#v", result)
}
order := result.Orders[0]
if order.ExternalStockID != "12" || order.SourceCode != "SANITIZED-CODE-12" ||
order.ShopName == nil || *order.ShopName != "Sanitized shop" ||
order.Items[0].ExternalItemID != "88" || order.Items[0].SKU != "BLACK-L" ||
order.Items[0].Quantity == nil || *order.Items[0].Quantity != 2 {
t.Fatalf("allowlist result = %#v", order)
}
encoded, err := json.Marshal(result)
if err != nil {
t.Fatalf("marshal result: %v", err)
}
for _, forbidden := range []string{
"receiver", "phone-not-allowed", "address-not-allowed",
"cookie-not-allowed", "jwt-not-allowed", "account-not-allowed",
"password-not-allowed", "captcha-not-allowed", "item-phone-not-allowed",
} {
if strings.Contains(string(encoded), forbidden) {
t.Fatalf("normalized result contains forbidden value %q: %s", forbidden, encoded)
}
}
}
func TestNormalizeFreightNeverLeaksRawValuesInErrors(t *testing.T) {
_, err := NormalizeFreightResult(
RawQuery{Mode: domain.FreightSyncOrderNumber},
[]RawRecord{{
Stock: map[string]any{"id": "not-an-id-private"},
Detail: map[string]any{"id": "12", "details": []any{}},
}},
)
if !errors.Is(err, domain.ErrFreightSourceProtocol) {
t.Fatalf("NormalizeFreightResult() error = %v", err)
}
if strings.Contains(err.Error(), "not-an-id-private") {
t.Fatalf("error leaked raw ERP value: %v", err)
}
}
func TestNormalizeFreightRejectsConflictingDuplicateIdentity(t *testing.T) {
record := RawRecord{
Stock: map[string]any{"id": "12", "code": "SANITIZED"},
Detail: map[string]any{
"id": "12",
"details": []any{
map[string]any{"id": "88", "productTitle": "first"},
map[string]any{"id": "88", "productTitle": "second"},
},
},
}
_, err := NormalizeFreightResult(
RawQuery{Mode: domain.FreightSyncOrderNumber},
[]RawRecord{record},
)
if !errors.Is(err, domain.ErrFreightSourceProtocol) {
t.Fatalf("NormalizeFreightResult() error = %v", err)
}
}
@@ -0,0 +1,42 @@
{
"query": {"mode": "ORDER_NUMBER"},
"records": [
{
"stock": {
"id": 12,
"code": "SANITIZED-CODE-12",
"orderCode": "SANITIZED-PLATFORM-12",
"shopName": "Fallback shop",
"created": "2026-07-28 08:00:00",
"orderStatus": 0,
"purchaseStatus": 1,
"isCancel": 0,
"receiver": "recipient-not-allowed",
"receiverTel": "phone-not-allowed",
"receiverAddr": "address-not-allowed",
"cookie": "cookie-not-allowed",
"jwt": "jwt-not-allowed",
"username": "account-not-allowed",
"password": "password-not-allowed",
"captcha": "captcha-not-allowed"
},
"detail": {
"id": "12",
"shopName": "Sanitized shop",
"created": "2026-07-28 08:00:00",
"details": [
{
"id": 88,
"productTitle": "Sanitized product",
"productSpec": "Black,L",
"sku": "BLACK-L",
"productQty": 2,
"productThumb": 190,
"purchaseStatus": 0,
"receiverTel": "item-phone-not-allowed"
}
]
}
}
]
}
@@ -13,7 +13,6 @@ import (
"unicode/utf8"
"cmroubao/backend-api/internal/domain"
"cmroubao/backend-api/internal/platform/erpconnector"
)
const (
@@ -350,11 +349,11 @@ func (service *FreightService) queryCreatedRange(
}
start, err := parseFreightDate(run.CreatedFrom, location)
if err != nil {
return domain.FreightSourceBatch{}, erpconnector.ErrProtocol
return domain.FreightSourceBatch{}, domain.ErrFreightSourceProtocol
}
end, err := parseFreightDate(run.CreatedTo, location)
if err != nil || end.Before(start) {
return domain.FreightSourceBatch{}, erpconnector.ErrProtocol
return domain.FreightSourceBatch{}, domain.ErrFreightSourceProtocol
}
orders := make([]domain.FreightSourceOrder, 0)
seen := make(map[string]domain.FreightSourceOrder)
@@ -379,20 +378,20 @@ func (service *FreightService) queryCreatedRange(
batch.Query.CreatedTo == nil ||
*batch.Query.CreatedFrom != fromValue ||
*batch.Query.CreatedTo != toValue {
return domain.FreightSourceBatch{}, erpconnector.ErrProtocol
return domain.FreightSourceBatch{}, domain.ErrFreightSourceProtocol
}
for _, order := range batch.Orders {
existing, exists := seen[order.ExternalStockID]
if exists {
if hashJSON(existing) != hashJSON(order) {
return domain.FreightSourceBatch{}, erpconnector.ErrProtocol
return domain.FreightSourceBatch{}, domain.ErrFreightSourceProtocol
}
continue
}
seen[order.ExternalStockID] = order
orders = append(orders, order)
if len(orders) > maxFreightOrdersPerSync {
return domain.FreightSourceBatch{}, erpconnector.ErrProtocol
return domain.FreightSourceBatch{}, domain.ErrFreightSourceProtocol
}
}
windowStart = windowEnd.AddDate(0, 0, 1)
@@ -672,13 +671,13 @@ func daysInclusive(start, end time.Time) int {
func freightSourceErrorCode(err error) string {
switch {
case errors.Is(err, erpconnector.ErrNotConfigured):
case errors.Is(err, domain.ErrFreightSourceNotConfigured):
return "ERP_CONNECTOR_NOT_CONFIGURED"
case errors.Is(err, erpconnector.ErrSessionRequired):
case errors.Is(err, domain.ErrFreightSourceSessionNeeded):
return "ERP_SESSION_REQUIRED"
case errors.Is(err, erpconnector.ErrNotFound):
case errors.Is(err, domain.ErrFreightSourceNotFound):
return "ERP_FREIGHT_NOT_FOUND"
case errors.Is(err, erpconnector.ErrProtocol):
case errors.Is(err, domain.ErrFreightSourceProtocol):
return "ERP_RESPONSE_INVALID"
default:
return "ERP_CONNECTOR_UNAVAILABLE"
+6 -3
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 Connector | Python 3.10+、requests 2.31+;可选 FastAPI/uvicorn 内部服务 | T-220 至 T-222 已实现,T-224 扩展 | 复用已验证顺运宝协议客户端;只监听 loopback、服务密钥鉴权,ERP 凭证不进入 Go 后端。 |
| ERP 直连适配 | Go 标准库 `net/http`、`net/http/cookiejar` | T-225 契约已冻结,尚未切换运行时 | Go 侧固定顺运宝请求、分页、详情批量和最小字段归一化;当前 Python loopback Connector 仅在 T-228 前临时保留。 |
| 数据库 | 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。 |
@@ -167,5 +167,8 @@ T-205 已在该分层上增加独立 `LifecycleService` 与 SQLite immediate tra
PowerShell 等价环境变量,确保依赖没有暗中要求更高 Go 版本。
- handler 不直接写 SQL,repository 不依赖 Gin,domain/usecase 不导入具体数据库驱动。
- 密钥和设备令牌通过环境变量或本地忽略配置注入,不进入 Git。
- ERP 账号、密码、Cookie、JWT 和 Connector 服务密钥只通过环境变量/受控秘密注入;
Go 后端不保存 ERP 会话,Connector 不把完整响应或 PII 写普通日志。
- T-225 后 Go `internal/platform/shunyunbao` 固定同一套脱敏协议契约;T-226 以前不装配
直接会话,运行时仍使用临时 Connector。T-226 的会话只在进程内,不引入 Redis。
- ERP 账号、密码、Cookie、JWT、验证码和 Connector 服务密钥只通过环境变量/受控秘密
注入;不得进入 SQLite、浏览器、VLM、fixture 或普通日志。Go 适配器不得记录完整
响应或 PII。
+7 -1
View File
@@ -132,7 +132,7 @@ App 支持两个显式模式:
- `MANUAL_FIRST`:从原始标题/SKU 产生有界搜索词,由人员判断候选,不要求 VLM。
- `AI_ASSISTED`:使用 App 本地配置的 OpenAI 兼容 provider 做需求提取和候选评估。
### 2.4 顺运宝 ERP Connector
### 2.4 顺运宝 ERP 适配层
Connector 是外部系统防腐层,不属于采购任务状态机:
@@ -155,6 +155,12 @@ Admin 创建 sync run
- ERP 接口由页面协议观察得到,正式生产前需确认开放 API、服务账号、调用频率、
缓存和个人信息处理权限。
T-225 已在 `internal/platform/shunyunbao` 冻结直接 Go 实现的协议常量、请求 header、
完整单号/日期范围条件、分页、详情批量和 allowlist 归一化;它尚未接入运行时。货运
用例只识别来源中立的“未配置、会话失效、未找到、协议异常、暂时不可用”错误,不依赖
Connector 包。T-226 负责单进程内受锁保护的验证码会话,T-227 才将异步 sync worker
从 loopback Connector 切换到 Go source,T-228 删除旧 Python 进程和服务密钥。
模式在 execution 开始时固定并写入结果;AI 失败后只能由人员明确切换,不能静默降级。
App 同时固定 provider ID、model、prompt/schema version 和证据 SHA-256,作为非秘密
provenance 回传。Key、Authorization、完整 endpoint 和供应商原始响应正文不回传。
+5
View File
@@ -113,6 +113,9 @@
recovery 不得把 Authorization、Cookie、panic 或请求正文写普通日志。
- SQLite 数据文件必须位于被忽略目录,启用 foreign keys、有限 busy timeout 和
WAL;连接由进程入口显式关闭,migration 使用固定版本和受控 SQL 文件。
- ERP adapter 只能把版本化 allowlist 归一化对象交给 `FreightSource`;收件人、电话、
地址、完整响应、Cookie、JWT、账号、密码和验证码不得进入领域对象、错误、日志、
fixture、SQLite、浏览器或 VLM。验证码必须由人员输入,禁止 OCR、猜测或重放。
## 7. 安全与隐私
@@ -128,6 +131,8 @@
`private-fixtures/` 等被忽略目录;测试使用脱敏 fixture。
- 普通日志不得输出完整蝦皮订单号、店铺名或本机原图绝对路径。
- 日志默认脱敏,不记录 Authorization、Cookie、密码、设备令牌或完整地址。
- ERP 外部 URL 必须在配置层限制为完整 HTTPS origin;仅离线 `httptest` 可使用 HTTP。
Cookie jar 和 token 只能停留在受锁保护的进程内会话,进程重启后要求人工重新登录。
- 上传文件限制媒体类型、大小和解码结果,随机化服务端文件名。
- 不绕过第三方平台限制、验证码、风控或系统权限。
- 不可逆动作必须由明确需求、服务端授权、App 确认和幂等保护共同允许。
+12 -7
View File
@@ -4,8 +4,8 @@
## 当前快照
- 日期:2026-07-28
- 阶段:T-220 至 T-224 ERP 货运接入闭环已完成
- 日期:2026-07-29
- 阶段:T-225 Go 直连 ERP 协议与安全边界已完成;运行时切换待 T-226 至 T-228
- Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 至 T-219
均按文档提交、实现提交的顺序纳入历史
- 生产代码:`android-buyer/` 已接入 Roubao Android 源码
@@ -17,6 +17,10 @@
- ERP 货运:Go 后端通过仅限 loopback、服务密钥鉴权的 Python Connector 异步按
完整单号同步;v12 保存同步记录、货运头和全部明细,canonical hash 控制 revision,
Admin 已有 `/freight`、`/freight/import`、`/freight/{id}` 与对应 JSON API。
- ERP Go 迁移:T-225 已用脱敏 fixture 固定 `internal/platform/shunyunbao` 的 header、
单号/日期查询、分页、详情批量和字段 allowlist,并使货运用例依赖来源中立错误;尚未
发起真实 ERP 请求,也未切换 Python Connector 运行时。T-226 将加入单进程人工验证码
会话,T-227 才切换异步货运 source,T-228 删除旧 Connector。
- ERP 增量同步:v14 支持 Asia/Shanghai 创建日期闭区间和“同步至现在”,Connector
单窗最多 7 天,后端对较长水位范围切窗并从成功水位前 10 分钟所在自然日回看。
货运落库、同步成功和水位推进同事务完成;失败与较旧范围成功不推进水位。Admin
@@ -28,7 +32,8 @@
- Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建
- 测试:T-219 Android Debug/Release 单元测试与构建和根 `init.ps1` 通过;
Debug APK `1.4.16 (21)` 已覆盖安装到 PKG110
- 后端测试:T-224 运行 `go test ./...`、`go test -race ./...`、`go vet ./...`;
- 后端测试:T-225 运行 `go test ./...`、`go test -race ./...`、`go vet ./...` 和三个 Go
入口构建;
覆盖 v14 上下迁移、7 天切窗、水位重叠、中途失败、空窗口、重复页、来源 revision、
水位事务/不回退、Admin API/SSR 和 Connector 严格响应窗口;Python 22 项伪响应
测试通过,未访问真实 ERP
@@ -183,11 +188,11 @@
## 任务摘要
- 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-219。
- 已完成:另含 T-220 至 T-224 ERP 契约、Connector、货运存储、采购需求生成和
日期增量同步。
- 已完成:另含 T-220 至 T-225 ERP 契约、Connector、货运存储、采购需求生成、日期
增量同步和 Go 直连协议安全边界。
- 进行中:无。
- 下一步:T-225 至 T-228 将把顺运宝 Python Connector 收敛到 Go 后端;真实 ERP
上线前仍需确认开放 API、数据使用权限并由人员完成验证码登录。
- 下一步:T-226 建立 Go 后端内存会话和 Admin 人工验证码登录;真实 ERP 上线前仍需
确认开放 API、数据使用权限并由人员完成验证码登录。
## 当前可运行内容
+19
View File
@@ -21,6 +21,25 @@
接口来自 ERP 页面协议,不是已确认的开放 API。生产使用前必须确认厂商授权、请求
频率、服务账号和个人信息处理规则。
## Go 直连迁移契约(T-225)
T-225 在 `backend-api/internal/platform/shunyunbao` 用脱敏 fixture 固定以下内容,尚未
改变当前 Python Connector 运行时路径:
- 基础请求 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 条。
- `listByStock?hist=0` 每批最多 100 个去重后的正整数 stock id,详情 `id` 必须与列表
`stock.id` 一致。
- 所有结果必须经 Go allowlist 归一化;fixture 专门含收件信息、Cookie/JWT 标记值,
测试断言它们不会出现在输出或错误里。
Go 直连的验证码、登录和 Cookie jar 属于 T-226。不得用本地 OCR 或 Redis 取代人工
验证码流程;真实线上请求不属于自动化测试。
## 身份和规范字段
### 货运单
+15 -5
View File
@@ -4,7 +4,7 @@ title: 冻结 Go 直连 ERP 协议与安全边界
phase: 2
deps:
- T-224
status: TODO
status: DONE
created: 2026-07-29
context_ref: d62a4af
work_branch: null
@@ -14,6 +14,9 @@ write_paths:
- docs/04-architecture.md
- docs/05-coding-rules.md
- docs/integrations/shunyunbao-contract.md
- docs/current-state.md
- backend-api/internal/domain/freight_source_errors.go
- backend-api/internal/usecase/freight_service.go
- backend-api/internal/platform/shunyunbao/**
- backend-api/internal/platform/erpconnector/**
---
@@ -47,10 +50,10 @@ Python 环境、loopback 端口和与 Go 后端相同的服务密钥。当前 `E
## 验收要点
- [ ] Go fixture 测试固定请求 header、查询条件、分页、详情批量和 allowlist 归一化。
- [ ] 负向测试证明 PII、Cookie、JWT、账号、密码和验证码不会出现在输出或错误中。
- [ ] 取消 OCR、Redis 和 Python HTTP 服务作为 Go 第一版运行时依赖的设计。
- [ ] 不访问线上 ERP;现有 Python Connector 仍可继续运行,货运导入行为不改变。
- [x] Go fixture 测试固定请求 header、查询条件、分页、详情批量和 allowlist 归一化。
- [x] 负向测试证明 PII、Cookie、JWT、账号、密码和验证码不会出现在输出或错误中。
- [x] 第一版 Go 设计不依赖 OCR、Redis 或 Python HTTP 服务。
- [x] 未访问线上 ERP;现有 Python Connector 仍可继续运行,货运导入行为不改变。
## 边界
@@ -61,3 +64,10 @@ Python 环境、loopback 端口和与 Go 后端相同的服务密钥。当前 `E
## 执行记录
- 2026-07-29:由 `d62a4af` 创建,等待 T-224 后开始。
- 2026-07-29:开始实施;已核对现有脱敏 Python 契约和 T-224 异步导入边界,未访问线上 ERP。
- 2026-07-29:新增 Go 协议常量、header/payload 构造、脱敏 fixture 归一化与来源中立错误;
运行时仍装配 Python Connector,未改 Admin 页面、schema 或同步异步边界。
- 验证:在 `backend-api/` 执行 `$env:GOTOOLCHAIN='local'; go test ./...; go test -race ./...;`
`go vet ./...; go build ./cmd/api; go build ./cmd/migrate; go build ./cmd/authctl`,全部通过;
未访问线上 ERP。
- 未验证项:真实顺运宝登录和查询有意留给 T-226/T-227,并需要人员配置凭证和完成验证码。