diff --git a/backend-api/internal/domain/freight_source_errors.go b/backend-api/internal/domain/freight_source_errors.go new file mode 100644 index 0000000..17ff92c --- /dev/null +++ b/backend-api/internal/domain/freight_source_errors.go @@ -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") +) diff --git a/backend-api/internal/platform/erpconnector/client.go b/backend-api/internal/platform/erpconnector/client.go index 5249f97..4b2d512 100644 --- a/backend-api/internal/platform/erpconnector/client.go +++ b/backend-api/internal/platform/erpconnector/client.go @@ -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 diff --git a/backend-api/internal/platform/shunyunbao/normalizer.go b/backend-api/internal/platform/shunyunbao/normalizer.go new file mode 100644 index 0000000..cce0493 --- /dev/null +++ b/backend-api/internal/platform/shunyunbao/normalizer.go @@ -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 +} diff --git a/backend-api/internal/platform/shunyunbao/protocol.go b/backend-api/internal/platform/shunyunbao/protocol.go new file mode 100644 index 0000000..dd67fc6 --- /dev/null +++ b/backend-api/internal/platform/shunyunbao/protocol.go @@ -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 +} diff --git a/backend-api/internal/platform/shunyunbao/protocol_test.go b/backend-api/internal/platform/shunyunbao/protocol_test.go new file mode 100644 index 0000000..d04ec75 --- /dev/null +++ b/backend-api/internal/platform/shunyunbao/protocol_test.go @@ -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) + } +} diff --git a/backend-api/internal/platform/shunyunbao/testdata/freight-result.json b/backend-api/internal/platform/shunyunbao/testdata/freight-result.json new file mode 100644 index 0000000..b984e1b --- /dev/null +++ b/backend-api/internal/platform/shunyunbao/testdata/freight-result.json @@ -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" + } + ] + } + } + ] +} diff --git a/backend-api/internal/usecase/freight_service.go b/backend-api/internal/usecase/freight_service.go index 09f8d97..39fb013 100644 --- a/backend-api/internal/usecase/freight_service.go +++ b/backend-api/internal/usecase/freight_service.go @@ -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" diff --git a/docs/03-tech-stack.md b/docs/03-tech-stack.md index 973a3ea..41c4e04 100644 --- a/docs/03-tech-stack.md +++ b/docs/03-tech-stack.md @@ -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。 diff --git a/docs/04-architecture.md b/docs/04-architecture.md index f0e598a..16e7f77 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -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 和供应商原始响应正文不回传。 diff --git a/docs/05-coding-rules.md b/docs/05-coding-rules.md index 1f157c9..8f6ba68 100644 --- a/docs/05-coding-rules.md +++ b/docs/05-coding-rules.md @@ -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 确认和幂等保护共同允许。 diff --git a/docs/current-state.md b/docs/current-state.md index 52d6052..1cb9cdf 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -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、数据使用权限并由人员完成验证码登录。 ## 当前可运行内容 diff --git a/docs/integrations/shunyunbao-contract.md b/docs/integrations/shunyunbao-contract.md index 7cae96a..1755da4 100644 --- a/docs/integrations/shunyunbao-contract.md +++ b/docs/integrations/shunyunbao-contract.md @@ -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 取代人工 +验证码流程;真实线上请求不属于自动化测试。 + ## 身份和规范字段 ### 货运单 diff --git a/docs/tasks/T-225.md b/docs/tasks/T-225.md index e7cc7ba..2c1dc0f 100644 --- a/docs/tasks/T-225.md +++ b/docs/tasks/T-225.md @@ -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,并需要人员配置凭证和完成验证码。