feat: 支持无SKU目录导入策略 (#141)
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user