feat(t225): freeze Go ERP protocol contract

This commit is contained in:
QiuSW
2026-07-29 09:21:32 +08:00
parent 11e480773a
commit c2f340d067
13 changed files with 836 additions and 31 deletions
@@ -0,0 +1,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"
}
]
}
}
]
}