feat(t222): add freight ingestion admin flow
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"cmroubao/backend-api/internal/domain"
|
||||
)
|
||||
|
||||
type FreightSource interface {
|
||||
QueryOrder(context.Context, string) (domain.FreightSourceBatch, error)
|
||||
}
|
||||
|
||||
type FreightRepository interface {
|
||||
CreateFreightSync(
|
||||
context.Context,
|
||||
domain.FreightSyncRun,
|
||||
string,
|
||||
string,
|
||||
) (domain.FreightSyncRun, bool, error)
|
||||
StartFreightSync(context.Context, string, time.Time) error
|
||||
CompleteFreightSync(
|
||||
context.Context,
|
||||
domain.FreightSyncRun,
|
||||
domain.FreightImportBatch,
|
||||
time.Time,
|
||||
) error
|
||||
FailFreightSync(context.Context, string, string, time.Time) error
|
||||
RecoverFreightSyncs(context.Context, time.Time) (int64, error)
|
||||
GetFreightSync(
|
||||
context.Context,
|
||||
string,
|
||||
string,
|
||||
) (domain.FreightSyncRun, error)
|
||||
ListFreightOrders(
|
||||
context.Context,
|
||||
string,
|
||||
int,
|
||||
) ([]domain.FreightOrder, error)
|
||||
GetFreightOrder(
|
||||
context.Context,
|
||||
string,
|
||||
string,
|
||||
) (domain.FreightOrderDetail, error)
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"cmroubao/backend-api/internal/domain"
|
||||
"cmroubao/backend-api/internal/platform/erpconnector"
|
||||
)
|
||||
|
||||
const (
|
||||
maxFreightOrdersPerSync = 100
|
||||
maxFreightItemsPerOrder = 1000
|
||||
)
|
||||
|
||||
type FreightService struct {
|
||||
repository FreightRepository
|
||||
source FreightSource
|
||||
clock Clock
|
||||
ids IDGenerator
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
type CreateFreightSyncCommand struct {
|
||||
CreatorSubject string
|
||||
ActorUserID string
|
||||
IdempotencyKey string
|
||||
OrderNumber string
|
||||
}
|
||||
|
||||
type CreateFreightSyncResult struct {
|
||||
Run domain.FreightSyncRun
|
||||
Replayed bool
|
||||
}
|
||||
|
||||
func NewFreightService(
|
||||
repository FreightRepository,
|
||||
source FreightSource,
|
||||
clock Clock,
|
||||
ids IDGenerator,
|
||||
timeout time.Duration,
|
||||
) (*FreightService, error) {
|
||||
if repository == nil || source == nil || clock == nil || ids == nil ||
|
||||
timeout <= 0 {
|
||||
return nil, errors.New("freight service dependencies are required")
|
||||
}
|
||||
return &FreightService{
|
||||
repository: repository,
|
||||
source: source,
|
||||
clock: clock,
|
||||
ids: ids,
|
||||
timeout: timeout,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (service *FreightService) CreateOrderSync(
|
||||
ctx context.Context,
|
||||
command CreateFreightSyncCommand,
|
||||
) (CreateFreightSyncResult, error) {
|
||||
command.CreatorSubject = strings.TrimSpace(command.CreatorSubject)
|
||||
command.ActorUserID = strings.TrimSpace(command.ActorUserID)
|
||||
command.IdempotencyKey = strings.TrimSpace(command.IdempotencyKey)
|
||||
command.OrderNumber = strings.TrimSpace(command.OrderNumber)
|
||||
fields := map[string]string{}
|
||||
if command.CreatorSubject == "" {
|
||||
fields["creator_subject"] = "is required"
|
||||
}
|
||||
if command.ActorUserID == "" {
|
||||
fields["actor_user_id"] = "is required"
|
||||
}
|
||||
if command.IdempotencyKey == "" || len([]byte(command.IdempotencyKey)) > 128 {
|
||||
fields["idempotency_key"] = "must contain 1 to 128 bytes"
|
||||
}
|
||||
if command.OrderNumber == "" ||
|
||||
len([]byte(command.OrderNumber)) > 128 ||
|
||||
hasControl(command.OrderNumber) {
|
||||
fields["order_number"] = "must contain 1 to 128 bytes without control characters"
|
||||
}
|
||||
if len(fields) > 0 {
|
||||
return CreateFreightSyncResult{}, invalidError(
|
||||
"FREIGHT_SYNC_INVALID",
|
||||
"freight sync request is invalid",
|
||||
fields,
|
||||
)
|
||||
}
|
||||
runID, err := service.ids.NewID()
|
||||
if err != nil {
|
||||
return CreateFreightSyncResult{}, wrapRepositoryError(err)
|
||||
}
|
||||
now := service.clock.Now().UTC()
|
||||
queryHash := hashJSON(struct {
|
||||
Mode string `json:"mode"`
|
||||
OrderNumber string `json:"order_number"`
|
||||
}{domain.FreightSyncOrderNumber, command.OrderNumber})
|
||||
requestHash := hashJSON(struct {
|
||||
OrderNumber string `json:"order_number"`
|
||||
}{command.OrderNumber})
|
||||
run, created, err := service.repository.CreateFreightSync(
|
||||
ctx,
|
||||
domain.FreightSyncRun{
|
||||
ID: runID,
|
||||
CreatorSubject: command.CreatorSubject,
|
||||
CreatedByUserID: command.ActorUserID,
|
||||
Mode: domain.FreightSyncOrderNumber,
|
||||
OrderNumber: command.OrderNumber,
|
||||
QuerySHA256: queryHash,
|
||||
Status: domain.FreightSyncPending,
|
||||
CreatedAt: now,
|
||||
},
|
||||
command.IdempotencyKey,
|
||||
requestHash,
|
||||
)
|
||||
if err != nil {
|
||||
return CreateFreightSyncResult{}, wrapRepositoryError(err)
|
||||
}
|
||||
if created {
|
||||
go service.execute(run)
|
||||
}
|
||||
return CreateFreightSyncResult{Run: run, Replayed: !created}, nil
|
||||
}
|
||||
|
||||
func (service *FreightService) execute(run domain.FreightSyncRun) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), service.timeout)
|
||||
defer cancel()
|
||||
now := service.clock.Now().UTC()
|
||||
if err := service.repository.StartFreightSync(ctx, run.ID, now); err != nil {
|
||||
return
|
||||
}
|
||||
source, err := service.source.QueryOrder(ctx, run.OrderNumber)
|
||||
if err != nil {
|
||||
_ = service.repository.FailFreightSync(
|
||||
ctx,
|
||||
run.ID,
|
||||
freightSourceErrorCode(err),
|
||||
service.clock.Now().UTC(),
|
||||
)
|
||||
return
|
||||
}
|
||||
batch, err := service.normalize(source)
|
||||
if err != nil {
|
||||
_ = service.repository.FailFreightSync(
|
||||
ctx,
|
||||
run.ID,
|
||||
"ERP_RESPONSE_INVALID",
|
||||
service.clock.Now().UTC(),
|
||||
)
|
||||
return
|
||||
}
|
||||
if err := service.repository.CompleteFreightSync(
|
||||
ctx,
|
||||
run,
|
||||
batch,
|
||||
service.clock.Now().UTC(),
|
||||
); err != nil {
|
||||
_ = service.repository.FailFreightSync(
|
||||
ctx,
|
||||
run.ID,
|
||||
"STORAGE_UNAVAILABLE",
|
||||
service.clock.Now().UTC(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (service *FreightService) RecoverInterrupted(
|
||||
ctx context.Context,
|
||||
) (int64, error) {
|
||||
count, err := service.repository.RecoverFreightSyncs(
|
||||
ctx,
|
||||
service.clock.Now().UTC(),
|
||||
)
|
||||
if err != nil {
|
||||
return 0, wrapRepositoryError(err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (service *FreightService) GetSync(
|
||||
ctx context.Context,
|
||||
creatorSubject, syncID string,
|
||||
) (domain.FreightSyncRun, error) {
|
||||
run, err := service.repository.GetFreightSync(
|
||||
ctx,
|
||||
strings.TrimSpace(creatorSubject),
|
||||
strings.TrimSpace(syncID),
|
||||
)
|
||||
if err != nil {
|
||||
return domain.FreightSyncRun{}, wrapRepositoryError(err)
|
||||
}
|
||||
return run, nil
|
||||
}
|
||||
|
||||
func (service *FreightService) ListOrders(
|
||||
ctx context.Context,
|
||||
creatorSubject string,
|
||||
limit int,
|
||||
) ([]domain.FreightOrder, error) {
|
||||
if limit == 0 {
|
||||
limit = 50
|
||||
}
|
||||
if limit < 1 || limit > 100 {
|
||||
return nil, invalidError(
|
||||
"FREIGHT_LIST_INVALID",
|
||||
"freight list filter is invalid",
|
||||
map[string]string{"limit": "must be between 1 and 100"},
|
||||
)
|
||||
}
|
||||
orders, err := service.repository.ListFreightOrders(
|
||||
ctx,
|
||||
strings.TrimSpace(creatorSubject),
|
||||
limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, wrapRepositoryError(err)
|
||||
}
|
||||
return orders, nil
|
||||
}
|
||||
|
||||
func (service *FreightService) GetOrder(
|
||||
ctx context.Context,
|
||||
creatorSubject, orderID string,
|
||||
) (domain.FreightOrderDetail, error) {
|
||||
detail, err := service.repository.GetFreightOrder(
|
||||
ctx,
|
||||
strings.TrimSpace(creatorSubject),
|
||||
strings.TrimSpace(orderID),
|
||||
)
|
||||
if err != nil {
|
||||
return domain.FreightOrderDetail{}, wrapRepositoryError(err)
|
||||
}
|
||||
return detail, nil
|
||||
}
|
||||
|
||||
func (service *FreightService) normalize(
|
||||
source domain.FreightSourceBatch,
|
||||
) (domain.FreightImportBatch, error) {
|
||||
if source.SchemaVersion != 1 ||
|
||||
source.Query.Mode != domain.FreightSyncOrderNumber ||
|
||||
len(source.Orders) > maxFreightOrdersPerSync {
|
||||
return domain.FreightImportBatch{}, errors.New("invalid source envelope")
|
||||
}
|
||||
seenOrders := map[string]struct{}{}
|
||||
result := domain.FreightImportBatch{
|
||||
Orders: make([]domain.FreightImportOrder, 0, len(source.Orders)),
|
||||
}
|
||||
for _, sourceOrder := range source.Orders {
|
||||
externalID, ok := validExternalID(sourceOrder.ExternalStockID)
|
||||
if !ok || len(sourceOrder.Items) > maxFreightItemsPerOrder {
|
||||
return domain.FreightImportBatch{}, errors.New("invalid freight order")
|
||||
}
|
||||
if _, exists := seenOrders[externalID]; exists {
|
||||
return domain.FreightImportBatch{}, errors.New("duplicate freight order")
|
||||
}
|
||||
seenOrders[externalID] = struct{}{}
|
||||
if !validBytes(sourceOrder.SourceCode, 256) ||
|
||||
!validOptional(sourceOrder.PlatformOrderNo, 256) ||
|
||||
!validOptional(sourceOrder.ShopName, 512) ||
|
||||
!validOptional(sourceOrder.OrderStatus, 128) ||
|
||||
!validOptional(sourceOrder.PurchaseStatus, 128) {
|
||||
return domain.FreightImportBatch{}, errors.New("invalid freight fields")
|
||||
}
|
||||
sourceCreatedAt, err := parseERPTime(sourceOrder.SourceCreatedAt)
|
||||
if err != nil {
|
||||
return domain.FreightImportBatch{}, err
|
||||
}
|
||||
orderID, err := service.ids.NewID()
|
||||
if err != nil {
|
||||
return domain.FreightImportBatch{}, err
|
||||
}
|
||||
order := domain.FreightImportOrder{
|
||||
ID: orderID,
|
||||
ExternalStockID: externalID,
|
||||
SourceCode: sourceOrder.SourceCode,
|
||||
PlatformOrderNo: cleanOptional(sourceOrder.PlatformOrderNo),
|
||||
ShopName: cleanOptional(sourceOrder.ShopName),
|
||||
SourceCreatedAt: sourceCreatedAt,
|
||||
OrderStatus: cleanOptional(sourceOrder.OrderStatus),
|
||||
PurchaseStatus: cleanOptional(sourceOrder.PurchaseStatus),
|
||||
IsCanceled: sourceOrder.IsCanceled,
|
||||
Items: make([]domain.FreightImportItem, 0, len(sourceOrder.Items)),
|
||||
}
|
||||
seenItems := map[string]struct{}{}
|
||||
for _, sourceItem := range sourceOrder.Items {
|
||||
itemExternalID, ok := validExternalID(sourceItem.ExternalItemID)
|
||||
if !ok {
|
||||
return domain.FreightImportBatch{}, errors.New("invalid freight item")
|
||||
}
|
||||
if _, exists := seenItems[itemExternalID]; exists {
|
||||
return domain.FreightImportBatch{}, errors.New("duplicate freight item")
|
||||
}
|
||||
seenItems[itemExternalID] = struct{}{}
|
||||
if !validBytes(sourceItem.Title, 2048) ||
|
||||
!validBytes(sourceItem.ProductSpec, 1024) ||
|
||||
!validBytes(sourceItem.SKU, 512) ||
|
||||
!validOptional(sourceItem.ProductThumbRef, 512) ||
|
||||
!validOptional(sourceItem.PurchaseStatus, 128) ||
|
||||
(sourceItem.Quantity != nil && *sourceItem.Quantity <= 0) {
|
||||
return domain.FreightImportBatch{}, errors.New("invalid freight item fields")
|
||||
}
|
||||
itemID, err := service.ids.NewID()
|
||||
if err != nil {
|
||||
return domain.FreightImportBatch{}, err
|
||||
}
|
||||
item := domain.FreightImportItem{
|
||||
ID: itemID,
|
||||
ExternalItemID: itemExternalID,
|
||||
Title: sourceItem.Title,
|
||||
ProductSpec: sourceItem.ProductSpec,
|
||||
SKU: sourceItem.SKU,
|
||||
Quantity: sourceItem.Quantity,
|
||||
ProductThumbRef: cleanOptional(sourceItem.ProductThumbRef),
|
||||
PurchaseStatus: cleanOptional(sourceItem.PurchaseStatus),
|
||||
}
|
||||
item.CanonicalSHA256 = hashJSON(struct {
|
||||
ExternalItemID string `json:"external_item_id"`
|
||||
Title string `json:"title"`
|
||||
ProductSpec string `json:"product_spec"`
|
||||
SKU string `json:"sku"`
|
||||
Quantity *int `json:"quantity"`
|
||||
ProductThumbRef *string `json:"product_thumb_ref"`
|
||||
PurchaseStatus *string `json:"purchase_status"`
|
||||
}{
|
||||
item.ExternalItemID, item.Title, item.ProductSpec, item.SKU,
|
||||
item.Quantity, item.ProductThumbRef, item.PurchaseStatus,
|
||||
})
|
||||
order.Items = append(order.Items, item)
|
||||
}
|
||||
sort.Slice(order.Items, func(i, j int) bool {
|
||||
left, _ := strconv.ParseUint(order.Items[i].ExternalItemID, 10, 64)
|
||||
right, _ := strconv.ParseUint(order.Items[j].ExternalItemID, 10, 64)
|
||||
return left < right
|
||||
})
|
||||
type canonicalItem struct {
|
||||
ExternalItemID string `json:"external_item_id"`
|
||||
CanonicalSHA256 string `json:"canonical_sha256"`
|
||||
}
|
||||
canonicalItems := make([]canonicalItem, 0, len(order.Items))
|
||||
for _, item := range order.Items {
|
||||
canonicalItems = append(canonicalItems, canonicalItem{
|
||||
ExternalItemID: item.ExternalItemID,
|
||||
CanonicalSHA256: item.CanonicalSHA256,
|
||||
})
|
||||
}
|
||||
order.CanonicalSHA256 = hashJSON(struct {
|
||||
ExternalStockID string `json:"external_stock_id"`
|
||||
SourceCode string `json:"source_code"`
|
||||
PlatformOrderNo *string `json:"platform_order_no"`
|
||||
ShopName *string `json:"shop_name"`
|
||||
SourceCreatedAt *time.Time `json:"source_created_at"`
|
||||
OrderStatus *string `json:"order_status"`
|
||||
PurchaseStatus *string `json:"purchase_status"`
|
||||
IsCanceled *bool `json:"is_canceled"`
|
||||
Items []canonicalItem `json:"items"`
|
||||
}{
|
||||
order.ExternalStockID, order.SourceCode, order.PlatformOrderNo,
|
||||
order.ShopName, order.SourceCreatedAt, order.OrderStatus,
|
||||
order.PurchaseStatus, order.IsCanceled, canonicalItems,
|
||||
})
|
||||
result.Orders = append(result.Orders, order)
|
||||
}
|
||||
sort.Slice(result.Orders, func(i, j int) bool {
|
||||
left, _ := strconv.ParseUint(result.Orders[i].ExternalStockID, 10, 64)
|
||||
right, _ := strconv.ParseUint(result.Orders[j].ExternalStockID, 10, 64)
|
||||
return left < right
|
||||
})
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func freightSourceErrorCode(err error) string {
|
||||
switch {
|
||||
case errors.Is(err, erpconnector.ErrNotConfigured):
|
||||
return "ERP_CONNECTOR_NOT_CONFIGURED"
|
||||
case errors.Is(err, erpconnector.ErrSessionRequired):
|
||||
return "ERP_SESSION_REQUIRED"
|
||||
case errors.Is(err, erpconnector.ErrNotFound):
|
||||
return "ERP_FREIGHT_NOT_FOUND"
|
||||
case errors.Is(err, erpconnector.ErrProtocol):
|
||||
return "ERP_RESPONSE_INVALID"
|
||||
default:
|
||||
return "ERP_CONNECTOR_UNAVAILABLE"
|
||||
}
|
||||
}
|
||||
|
||||
func validExternalID(value string) (string, bool) {
|
||||
value = strings.TrimSpace(value)
|
||||
number, err := strconv.ParseUint(value, 10, 64)
|
||||
return value, err == nil && number > 0 && strconv.FormatUint(number, 10) == value
|
||||
}
|
||||
|
||||
func validBytes(value string, maximum int) bool {
|
||||
return utf8.ValidString(value) && len([]byte(value)) <= maximum &&
|
||||
!hasControl(value)
|
||||
}
|
||||
|
||||
func validOptional(value *string, maximum int) bool {
|
||||
return value == nil || validBytes(strings.TrimSpace(*value), maximum)
|
||||
}
|
||||
|
||||
func cleanOptional(value *string) *string {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
trimmed := strings.TrimSpace(*value)
|
||||
if trimmed == "" {
|
||||
return nil
|
||||
}
|
||||
return &trimmed
|
||||
}
|
||||
|
||||
func hasControl(value string) bool {
|
||||
for _, character := range value {
|
||||
if character < 0x20 || character == 0x7f {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func parseERPTime(value *string) (*time.Time, error) {
|
||||
value = cleanOptional(value)
|
||||
if value == nil {
|
||||
return nil, nil
|
||||
}
|
||||
location, err := time.LoadLocation("Asia/Shanghai")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
layouts := []string{
|
||||
time.RFC3339Nano,
|
||||
"2006-01-02 15:04:05",
|
||||
"2006-01-02T15:04:05",
|
||||
}
|
||||
for _, layout := range layouts {
|
||||
var parsed time.Time
|
||||
if layout == time.RFC3339Nano {
|
||||
parsed, err = time.Parse(layout, *value)
|
||||
} else {
|
||||
parsed, err = time.ParseInLocation(layout, *value, location)
|
||||
}
|
||||
if err == nil {
|
||||
result := parsed.UTC()
|
||||
return &result, nil
|
||||
}
|
||||
}
|
||||
return nil, errors.New("invalid ERP source time")
|
||||
}
|
||||
|
||||
func hashJSON(value any) string {
|
||||
encoded, _ := json.Marshal(value)
|
||||
sum := sha256.Sum256(encoded)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"cmroubao/backend-api/internal/domain"
|
||||
)
|
||||
|
||||
func TestFreightNormalizationHashExcludesInternalIDs(t *testing.T) {
|
||||
source := validFreightSource()
|
||||
firstService := &FreightService{ids: &sequenceIDs{next: 10}}
|
||||
first, err := firstService.normalize(source)
|
||||
if err != nil {
|
||||
t.Fatalf("first normalize error = %v", err)
|
||||
}
|
||||
secondService := &FreightService{ids: &sequenceIDs{next: 100}}
|
||||
second, err := secondService.normalize(source)
|
||||
if err != nil {
|
||||
t.Fatalf("second normalize error = %v", err)
|
||||
}
|
||||
if first.Orders[0].ID == second.Orders[0].ID {
|
||||
t.Fatal("test IDs did not differ")
|
||||
}
|
||||
if first.Orders[0].CanonicalSHA256 !=
|
||||
second.Orders[0].CanonicalSHA256 {
|
||||
t.Fatalf(
|
||||
"canonical hash depends on internal ID: %s != %s",
|
||||
first.Orders[0].CanonicalSHA256,
|
||||
second.Orders[0].CanonicalSHA256,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreightNormalizationRejectsConflictingIdentityAndInvalidTime(
|
||||
t *testing.T,
|
||||
) {
|
||||
duplicate := validFreightSource()
|
||||
duplicate.Orders[0].Items = append(
|
||||
duplicate.Orders[0].Items,
|
||||
duplicate.Orders[0].Items[0],
|
||||
)
|
||||
service := &FreightService{ids: &sequenceIDs{}}
|
||||
if _, err := service.normalize(duplicate); err == nil {
|
||||
t.Fatal("duplicate item normalize error = nil")
|
||||
}
|
||||
|
||||
invalidTime := validFreightSource()
|
||||
value := "not-a-time"
|
||||
invalidTime.Orders[0].SourceCreatedAt = &value
|
||||
if _, err := service.normalize(invalidTime); err == nil {
|
||||
t.Fatal("invalid source time normalize error = nil")
|
||||
}
|
||||
}
|
||||
|
||||
func validFreightSource() domain.FreightSourceBatch {
|
||||
shop := "测试店铺"
|
||||
sourceCreatedAt := "2026-07-28 08:00:00"
|
||||
orderStatus := "0"
|
||||
purchaseStatus := "1"
|
||||
thumb := "190"
|
||||
itemPurchaseStatus := "0"
|
||||
quantity := 2
|
||||
canceled := false
|
||||
return domain.FreightSourceBatch{
|
||||
SchemaVersion: 1,
|
||||
Query: domain.FreightSourceQuery{
|
||||
Mode: domain.FreightSyncOrderNumber,
|
||||
},
|
||||
Orders: []domain.FreightSourceOrder{{
|
||||
ExternalStockID: "12",
|
||||
SourceCode: "SOURCE-12",
|
||||
ShopName: &shop,
|
||||
SourceCreatedAt: &sourceCreatedAt,
|
||||
OrderStatus: &orderStatus,
|
||||
PurchaseStatus: &purchaseStatus,
|
||||
IsCanceled: &canceled,
|
||||
Items: []domain.FreightSourceItem{{
|
||||
ExternalItemID: "88",
|
||||
Title: "商品",
|
||||
ProductSpec: "黑色,L",
|
||||
SKU: "BLACK-L",
|
||||
Quantity: &quantity,
|
||||
ProductThumbRef: &thumb,
|
||||
PurchaseStatus: &itemPurchaseStatus,
|
||||
}},
|
||||
}},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user