feat(t225): freeze Go ERP protocol contract
This commit is contained in:
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user