feat(t225): freeze Go ERP protocol contract
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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 和供应商原始响应正文不回传。
|
||||
|
||||
@@ -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
@@ -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、数据使用权限并由人员完成验证码登录。
|
||||
|
||||
## 当前可运行内容
|
||||
|
||||
|
||||
@@ -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
@@ -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,并需要人员配置凭证和完成验证码。
|
||||
|
||||
Reference in New Issue
Block a user