feat: 支持无SKU目录导入策略 (#141)

This commit is contained in:
chengma
2026-08-11 11:03:18 +08:00
parent 6386d76488
commit 4259be0e55
17 changed files with 766 additions and 73 deletions
+63 -9
View File
@@ -11,9 +11,11 @@ import (
"net/http"
"strings"
"time"
"unicode/utf8"
"cmautobuy/admin/model"
"cmautobuy/admin/repository"
"cmautobuy/admin/spec"
)
const (
@@ -63,6 +65,7 @@ type CatalogBatchRequest struct {
SchemaVersion int `json:"schema_version"`
BatchID string `json:"batch_id"`
ObservedAt string `json:"observed_at"`
UpdatePolicy string `json:"update_policy"`
ShopeeProducts []CatalogShopeeProduct `json:"shopee_products"`
ShopeeSKUs []CatalogShopeeSKU `json:"shopee_skus"`
PddProducts []CatalogPddProduct `json:"pdd_products"`
@@ -73,6 +76,11 @@ type CatalogCounts struct {
ShopeeUpdated int `json:"shopee_updated"`
SKUCreated int `json:"sku_created"`
SKUUpdated int `json:"sku_updated"`
SKUFilled int `json:"sku_filled"`
SKUSameSourceUpdated int `json:"sku_same_source_updated"`
SKUSkipped int `json:"sku_skipped"`
SKUManualSkipped int `json:"sku_manual_skipped"`
SKUStaleSkipped int `json:"sku_stale_skipped"`
PddCreated int `json:"pdd_created"`
PddUpdated int `json:"pdd_updated"`
AssociationCreated int `json:"association_created"`
@@ -108,13 +116,16 @@ func ValidateCatalogBatch(req CatalogBatchRequest) error {
if _, err := time.Parse(time.RFC3339Nano, req.ObservedAt); err != nil {
return catalogInvalid("INVALID_OBSERVED_AT", "observed_at 必须是带时区 ISO 8601", nil)
}
if _, err := normalizeCatalogUpdatePolicy(req.UpdatePolicy); err != nil {
return err
}
if len(req.ShopeeProducts)+len(req.PddProducts) > CatalogMaxProducts {
return catalogInvalid("TOO_MANY_PRODUCTS", "每批商品总数不能超过 500", nil)
}
if len(req.ShopeeSKUs) > CatalogMaxSKUs {
return catalogInvalid("TOO_MANY_SKUS", "每批 SKU 不能超过 5000", nil)
}
seenProducts, seenSKUs, seenPDD := map[string]bool{}, map[string]bool{}, map[string]bool{}
seenProducts, seenSKUs, seenSpecs, seenPDD := map[string]bool{}, map[string]bool{}, map[string]bool{}, map[string]bool{}
for i, p := range req.ShopeeProducts {
p.GoodsID = strings.TrimSpace(p.GoodsID)
if p.GoodsID == "" || strings.TrimSpace(p.Title) == "" || seenProducts[p.GoodsID] {
@@ -123,10 +134,16 @@ func ValidateCatalogBatch(req CatalogBatchRequest) error {
seenProducts[p.GoodsID] = true
}
for i, s := range req.ShopeeSKUs {
if strings.TrimSpace(s.SKUID) == "" || strings.TrimSpace(s.GoodsID) == "" || strings.TrimSpace(s.SpecRaw) == "" || seenSKUs[s.SKUID] {
return catalogInvalid("INVALID_SHOPEE_SKU", "蝦皮 SKU ID、商品 ID、spec_raw 必填且 SKU 不能重复", map[string]any{"index": i, "sku_id": s.SKUID})
key, keyErr := spec.SpecKey(s.SpecRaw)
externalID := strings.TrimSpace(s.SKUID)
identity := strings.TrimSpace(s.GoodsID) + "\x00" + key
if strings.TrimSpace(s.GoodsID) == "" || keyErr != nil || utf8.RuneCountInString(key) > 191 || seenSpecs[identity] || (externalID != "" && seenSKUs[externalID]) {
return catalogInvalid("INVALID_SHOPEE_SKU", "商品 ID、有效 spec_raw 必填,批内规格身份和非空 SKU ID 不能重复", map[string]any{"index": i, "sku_id": externalID})
}
seenSpecs[identity] = true
if externalID != "" {
seenSKUs[externalID] = true
}
seenSKUs[s.SKUID] = true
}
for i, p := range req.PddProducts {
if strings.TrimSpace(p.GoodsID) == "" || strings.TrimSpace(p.URL) == "" || seenPDD[p.GoodsID] {
@@ -153,6 +170,8 @@ func ImportCatalogBatch(db *sql.DB, source string, req CatalogBatchRequest, raw
}
observed, _ := time.Parse(time.RFC3339Nano, req.ObservedAt)
req.ObservedAt = observed.UTC().Format(model.TimeLayout)
policy, _ := normalizeCatalogUpdatePolicy(req.UpdatePolicy)
req.UpdatePolicy = policy
h := sha256.Sum256(raw)
hash := hex.EncodeToString(h[:])
now := model.NowISO()
@@ -160,7 +179,7 @@ func ImportCatalogBatch(db *sql.DB, source string, req CatalogBatchRequest, raw
if err != nil {
return CatalogBatchResponse{}, err
}
run := model.CatalogImportRun{Source: source, BatchID: req.BatchID, RequestHash: hash, Status: model.CatalogImportProcessing, ObservedAt: req.ObservedAt, LastRequestAt: now, CreatedAt: now}
run := model.CatalogImportRun{Source: source, BatchID: req.BatchID, RequestHash: hash, Status: model.CatalogImportProcessing, UpdatePolicy: policy, ObservedAt: req.ObservedAt, LastRequestAt: now, CreatedAt: now}
if err = repository.InsertCatalogImportRun(tx, run); err != nil {
tx.Rollback()
if errors.Is(err, repository.ErrCatalogImportRunExists) {
@@ -215,15 +234,29 @@ func ImportCatalogBatch(db *sql.DB, source string, req CatalogBatchRequest, raw
}
}
for _, s := range req.ShopeeSKUs {
c, u, e := repository.UpsertCatalogShopeeSKU(tx, s.SKUID, s.GoodsID, s.SpecRaw, s.Color, s.Size, s.Advice, s.ParseOK, s.SKUCode, req.ObservedAt, now)
key, _ := spec.SpecKey(s.SpecRaw)
internalID := catalogSKURecordID(s.GoodsID, key)
outcome, e := repository.UpsertCatalogShopeeSKU(tx, repository.CatalogShopeeSKUInput{RecordID: internalID, ShopeeSKUID: strings.TrimSpace(s.SKUID), GoodsID: strings.TrimSpace(s.GoodsID), SpecRaw: s.SpecRaw, SpecKey: key, Color: s.Color, Size: s.Size, Advice: s.Advice, ParseOK: s.ParseOK, SKUCode: s.SKUCode, Source: source, ObservedAt: req.ObservedAt, Now: now, UpdatePolicy: policy})
if e != nil {
return fail(&CatalogError{Status: 409, Code: "SKU_OWNERSHIP_CONFLICT", Message: e.Error()})
}
if c {
switch outcome {
case repository.CatalogSKUCreated:
counts.SKUCreated++
}
if u {
case repository.CatalogSKUFilled:
counts.SKUFilled++
counts.SKUUpdated++
case repository.CatalogSKUSameSourceUpdated:
counts.SKUSameSourceUpdated++
counts.SKUUpdated++
case repository.CatalogSKUManualSkipped:
counts.SKUManualSkipped++
counts.SKUSkipped++
case repository.CatalogSKUStaleSkipped:
counts.SKUStaleSkipped++
counts.SKUSkipped++
default:
counts.SKUSkipped++
}
}
for _, a := range req.Associations {
@@ -247,6 +280,11 @@ func ImportCatalogBatch(db *sql.DB, source string, req CatalogBatchRequest, raw
run.ShopeeUpdated = counts.ShopeeUpdated
run.SKUCreated = counts.SKUCreated
run.SKUUpdated = counts.SKUUpdated
run.SKUFilled = counts.SKUFilled
run.SKUSameSourceUpdated = counts.SKUSameSourceUpdated
run.SKUSkipped = counts.SKUSkipped
run.SKUManualSkipped = counts.SKUManualSkipped
run.SKUStaleSkipped = counts.SKUStaleSkipped
run.PddCreated = counts.PddCreated
run.PddUpdated = counts.PddUpdated
run.AssociationCreated = counts.AssociationCreated
@@ -260,6 +298,22 @@ func ImportCatalogBatch(db *sql.DB, source string, req CatalogBatchRequest, raw
return resp, nil
}
func normalizeCatalogUpdatePolicy(raw string) (string, error) {
switch strings.TrimSpace(raw) {
case "", "fill_missing":
return "fill_missing", nil
case "insert_only", "overwrite_same_source":
return strings.TrimSpace(raw), nil
default:
return "", catalogInvalid("INVALID_UPDATE_POLICY", "update_policy 只支持 insert_only、fill_missing、overwrite_same_source", nil)
}
}
func catalogSKURecordID(goodsID, specKey string) string {
sum := sha256.Sum256([]byte(goodsID + "\x00" + specKey))
return "catalog:" + hex.EncodeToString(sum[:])
}
func replayCatalogBatch(db *sql.DB, source, batchID, hash, now string) (CatalogBatchResponse, error) {
run, err := repository.GetCatalogImportRun(db, source, batchID)
if err != nil {