feat: 回填店铺关联PDD空规格 (#194)
This commit is contained in:
@@ -0,0 +1,97 @@
|
|||||||
|
// backfill-shop-pdd-specs 把指定蝦皮店铺的正式规格补成关联 PDD 的空规格骨架。
|
||||||
|
// 默认只预览;只有显式传入 --apply 才写数据库。
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"cmautobuy/admin/config"
|
||||||
|
"cmautobuy/admin/repository"
|
||||||
|
"cmautobuy/admin/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
type backupFile struct {
|
||||||
|
Shop string `json:"shop"`
|
||||||
|
GeneratedAt string `json:"generated_at"`
|
||||||
|
Items []backupItem `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type backupItem struct {
|
||||||
|
PDDGoodsID string `json:"pdd_goods_id"`
|
||||||
|
SKUsJSON string `json:"skus_json"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
shop := flag.String("shop", "", "要处理的蝦皮店铺名(精确匹配,必填)")
|
||||||
|
apply := flag.Bool("apply", false, "实际写入;不传时只做 dry-run")
|
||||||
|
flag.Parse()
|
||||||
|
if strings.TrimSpace(*shop) == "" {
|
||||||
|
log.Fatal("必须通过 --shop 指定店铺名")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.LoadDatabase()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("读取 MySQL 配置失败: %v", err)
|
||||||
|
}
|
||||||
|
db, err := repository.OpenMySQL(cfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("连接 MySQL 失败: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
sources, err := repository.ListShopSpecSources(db, *shop)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
plan := service.BuildSpecBackfillPlan(*shop, sources, now)
|
||||||
|
if !*apply {
|
||||||
|
printReport(plan, 0, "dry-run")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
backupPath, err := writeBackup(plan)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("创建本机回退备份失败,未写数据库: %v", err)
|
||||||
|
}
|
||||||
|
updated, err := service.ApplySpecBackfill(db, plan, now)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("规格回填失败: %v;本机备份位于 %s", err, backupPath)
|
||||||
|
}
|
||||||
|
printReport(plan, updated, "applied")
|
||||||
|
fmt.Printf("本机回退备份:%s\n", backupPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func printReport(plan service.SpecBackfillPlan, updated int, mode string) {
|
||||||
|
summary, _ := json.Marshal(plan.Summary)
|
||||||
|
fmt.Printf("模式:%s\n店铺:%s\n蝦皮商品:%d\n候选 PDD:%d\n已回填 PDD:%d\n本次更新 PDD:%d\n分类:%s\n",
|
||||||
|
mode, plan.Shop, plan.SourceProductCount, plan.CandidatePDDCount,
|
||||||
|
plan.BackfilledPDDCount, updated, summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeBackup(plan service.SpecBackfillPlan) (string, error) {
|
||||||
|
file, err := os.CreateTemp("", "cmautobuy_spec_backfill_backup_*.json")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
path := file.Name()
|
||||||
|
defer file.Close()
|
||||||
|
if err := file.Chmod(0o600); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
backup := backupFile{Shop: plan.Shop, GeneratedAt: time.Now().UTC().Format(time.RFC3339)}
|
||||||
|
for _, candidate := range plan.Candidates {
|
||||||
|
backup.Items = append(backup.Items, backupItem{PDDGoodsID: candidate.PDDGoodsID, SKUsJSON: candidate.OriginalSKUsJSON})
|
||||||
|
}
|
||||||
|
encoder := json.NewEncoder(file)
|
||||||
|
encoder.SetIndent("", " ")
|
||||||
|
if err := encoder.Encode(backup); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
@@ -27,30 +27,24 @@ type ShopSpecSource struct {
|
|||||||
func ListShopSpecSources(q Execer, shopName string) ([]ShopSpecSource, error) {
|
func ListShopSpecSources(q Execer, shopName string) ([]ShopSpecSource, error) {
|
||||||
rows, err := q.Query(`
|
rows, err := q.Query(`
|
||||||
SELECT sp.goods_id, sp.pdd_goods_id,
|
SELECT sp.goods_id, sp.pdd_goods_id,
|
||||||
pp.goods_id, pp.deleted_at, pp.skus_json,
|
pp.goods_id, pp.deleted_at, pp.skus_json
|
||||||
sk.color, sk.size, sk.parse_ok
|
|
||||||
FROM shopee_products sp
|
FROM shopee_products sp
|
||||||
LEFT JOIN shopee_skus sk ON sk.goods_id = sp.goods_id
|
|
||||||
LEFT JOIN pdd_products pp ON pp.goods_id = sp.pdd_goods_id
|
LEFT JOIN pdd_products pp ON pp.goods_id = sp.pdd_goods_id
|
||||||
WHERE sp.shopee_shop_name = ?
|
WHERE sp.shopee_shop_name = ?
|
||||||
ORDER BY sp.goods_id, sk.sku_id`, strings.TrimSpace(shopName))
|
ORDER BY sp.goods_id`, strings.TrimSpace(shopName))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("读取店铺规格对比数据失败: %w", err)
|
return nil, fmt.Errorf("读取店铺规格对比数据失败: %w", err)
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var list []ShopSpecSource
|
var list []ShopSpecSource
|
||||||
var current *ShopSpecSource
|
indexByGoodsID := make(map[string]int)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var shopeeID string
|
var shopeeID string
|
||||||
var pddID, existingPDDID, deletedAt, skusJSON sql.NullString
|
var pddID, existingPDDID, deletedAt, skusJSON sql.NullString
|
||||||
var color, size sql.NullString
|
if err := rows.Scan(&shopeeID, &pddID, &existingPDDID, &deletedAt, &skusJSON); err != nil {
|
||||||
var parseOK sql.NullInt64
|
|
||||||
if err := rows.Scan(&shopeeID, &pddID, &existingPDDID, &deletedAt,
|
|
||||||
&skusJSON, &color, &size, &parseOK); err != nil {
|
|
||||||
return nil, fmt.Errorf("解析店铺规格对比数据失败: %w", err)
|
return nil, fmt.Errorf("解析店铺规格对比数据失败: %w", err)
|
||||||
}
|
}
|
||||||
if current == nil || current.ShopeeGoodsID != shopeeID {
|
|
||||||
list = append(list, ShopSpecSource{
|
list = append(list, ShopSpecSource{
|
||||||
ShopeeGoodsID: shopeeID,
|
ShopeeGoodsID: shopeeID,
|
||||||
PDDGoodsID: pddID.String,
|
PDDGoodsID: pddID.String,
|
||||||
@@ -58,16 +52,61 @@ func ListShopSpecSources(q Execer, shopName string) ([]ShopSpecSource, error) {
|
|||||||
PDDDeleted: strings.TrimSpace(deletedAt.String) != "",
|
PDDDeleted: strings.TrimSpace(deletedAt.String) != "",
|
||||||
PDDSKUsJSON: skusJSON.String,
|
PDDSKUsJSON: skusJSON.String,
|
||||||
})
|
})
|
||||||
current = &list[len(list)-1]
|
indexByGoodsID[shopeeID] = len(list) - 1
|
||||||
}
|
|
||||||
if parseOK.Valid {
|
|
||||||
current.ShopeeSpecs = append(current.ShopeeSpecs, ComparableShopeeSpec{
|
|
||||||
Color: color.String, Size: size.String, ParseOK: parseOK.Int64 == 1,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
return nil, fmt.Errorf("遍历店铺规格对比数据失败: %w", err)
|
return nil, fmt.Errorf("遍历店铺规格对比数据失败: %w", err)
|
||||||
}
|
}
|
||||||
|
rows.Close()
|
||||||
|
|
||||||
|
// PDD JSON 可能很大,不能和 SKU JOIN 后重复传输。商品和规格分两次查询,
|
||||||
|
// 每份 PDD JSON 只从数据库读取一次。
|
||||||
|
skuRows, err := q.Query(`
|
||||||
|
SELECT sk.goods_id, sk.color, sk.size, sk.parse_ok
|
||||||
|
FROM shopee_skus sk
|
||||||
|
JOIN shopee_products sp ON sp.goods_id = sk.goods_id
|
||||||
|
WHERE sp.shopee_shop_name = ?
|
||||||
|
ORDER BY sk.goods_id, sk.sku_id`, strings.TrimSpace(shopName))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("读取店铺正式规格失败: %w", err)
|
||||||
|
}
|
||||||
|
defer skuRows.Close()
|
||||||
|
for skuRows.Next() {
|
||||||
|
var goodsID string
|
||||||
|
var color, size sql.NullString
|
||||||
|
var parseOK int64
|
||||||
|
if err := skuRows.Scan(&goodsID, &color, &size, &parseOK); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析店铺正式规格失败: %w", err)
|
||||||
|
}
|
||||||
|
index, exists := indexByGoodsID[goodsID]
|
||||||
|
if !exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
list[index].ShopeeSpecs = append(list[index].ShopeeSpecs, ComparableShopeeSpec{
|
||||||
|
Color: color.String, Size: size.String, ParseOK: parseOK == 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := skuRows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("遍历店铺正式规格失败: %w", err)
|
||||||
|
}
|
||||||
return list, nil
|
return list, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReplaceEmptyPDDSpecs 用生成的规格骨架替换仍保持预期原值的 PDD 规格。
|
||||||
|
// expectedRaw 是乐观锁:读取后若被其他进程改过,更新返回 false,不覆盖新数据。
|
||||||
|
func ReplaceEmptyPDDSpecs(q Execer, pddGoodsID, expectedRaw, newRaw, updatedAt string) (bool, error) {
|
||||||
|
result, err := q.Exec(`
|
||||||
|
UPDATE pdd_products
|
||||||
|
SET skus_json = ?, updated_at = ?
|
||||||
|
WHERE goods_id = ? AND deleted_at IS NULL
|
||||||
|
AND COALESCE(skus_json, '') = ?`,
|
||||||
|
newRaw, updatedAt, strings.TrimSpace(pddGoodsID), expectedRaw)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("回填 PDD 商品 %s 的规格失败: %w", pddGoodsID, err)
|
||||||
|
}
|
||||||
|
rows, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("读取 PDD 商品 %s 的回填结果失败: %w", pddGoodsID, err)
|
||||||
|
}
|
||||||
|
return rows == 1, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"database/sql"
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestReplaceEmptyPDDSpecsUsesExpectedRawAsLock(t *testing.T) {
|
||||||
|
db, err := sql.Open("sqlite", ":memory:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
if _, err := db.Exec(`CREATE TABLE pdd_products(goods_id TEXT PRIMARY KEY, skus_json TEXT, deleted_at TEXT, updated_at TEXT)`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(`INSERT INTO pdd_products(goods_id,skus_json) VALUES('P1','')`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ok, err := ReplaceEmptyPDDSpecs(db, "P1", "", `{"skus":[]}`, "2026-08-12T00:00:00Z")
|
||||||
|
if err != nil || !ok {
|
||||||
|
t.Fatalf("首次更新应成功: ok=%v err=%v", ok, err)
|
||||||
|
}
|
||||||
|
ok, err = ReplaceEmptyPDDSpecs(db, "P1", "", `{"skus":[1]}`, "2026-08-12T00:00:01Z")
|
||||||
|
if err != nil || ok {
|
||||||
|
t.Fatalf("原值已变化时必须跳过: ok=%v err=%v", ok, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"cmautobuy/admin/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
SpecBackfillCandidate = "candidate"
|
||||||
|
SpecBackfillUnlinked = "unlinked"
|
||||||
|
SpecBackfillPDDMissing = "pdd_missing_or_deleted"
|
||||||
|
SpecBackfillShopeeMissing = "shopee_formal_specs_unavailable"
|
||||||
|
SpecBackfillPDDExisting = "pdd_specs_already_exist"
|
||||||
|
SpecBackfillPDDBackfilled = "pdd_specs_backfilled"
|
||||||
|
SpecBackfillPDDInvalid = "pdd_json_invalid"
|
||||||
|
SpecBackfillAmbiguousShared = "ambiguous_shared_pdd"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SpecBackfillDecision 是报告中的商品级判定,不暴露规格原文。
|
||||||
|
type SpecBackfillDecision struct {
|
||||||
|
ShopeeGoodsID string `json:"shopee_goods_id"`
|
||||||
|
PDDGoodsID string `json:"pdd_goods_id,omitempty"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
SpecCount int `json:"spec_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SpecBackfillCandidateItem 是一个待更新的 PDD 商品。新旧 JSON 只供写库和本机备份。
|
||||||
|
type SpecBackfillCandidateItem struct {
|
||||||
|
PDDGoodsID string `json:"pdd_goods_id"`
|
||||||
|
ShopeeGoodsIDs []string `json:"shopee_goods_ids"`
|
||||||
|
SpecCount int `json:"spec_count"`
|
||||||
|
OriginalSKUsJSON string `json:"-"`
|
||||||
|
NewSKUsJSON string `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SpecBackfillPlan 是默认 dry-run 输出,也可经 ApplySpecBackfill 执行。
|
||||||
|
type SpecBackfillPlan struct {
|
||||||
|
Shop string `json:"shop"`
|
||||||
|
GeneratedAt string `json:"generated_at"`
|
||||||
|
SourceProductCount int `json:"source_product_count"`
|
||||||
|
CandidatePDDCount int `json:"candidate_pdd_count"`
|
||||||
|
BackfilledPDDCount int `json:"backfilled_pdd_count"`
|
||||||
|
Summary map[string]int `json:"summary"`
|
||||||
|
Products []SpecBackfillDecision `json:"products"`
|
||||||
|
Candidates []SpecBackfillCandidateItem `json:"candidates"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type specBackfillGroupedSource struct {
|
||||||
|
source repository.ShopSpecSource
|
||||||
|
set map[string]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildSpecBackfillPlan 只在内存中生成安全候选,不写数据库。
|
||||||
|
func BuildSpecBackfillPlan(shop string, sources []repository.ShopSpecSource, now time.Time) SpecBackfillPlan {
|
||||||
|
plan := SpecBackfillPlan{
|
||||||
|
Shop: strings.TrimSpace(shop), GeneratedAt: now.UTC().Format(time.RFC3339),
|
||||||
|
SourceProductCount: len(sources), Summary: map[string]int{},
|
||||||
|
}
|
||||||
|
groups := make(map[string][]specBackfillGroupedSource)
|
||||||
|
for _, source := range sources {
|
||||||
|
set := formalShopeeSpecSet(source.ShopeeSpecs)
|
||||||
|
switch {
|
||||||
|
case strings.TrimSpace(source.PDDGoodsID) == "":
|
||||||
|
plan.addDecision(source, SpecBackfillUnlinked, len(set))
|
||||||
|
case !source.PDDExists || source.PDDDeleted:
|
||||||
|
plan.addDecision(source, SpecBackfillPDDMissing, len(set))
|
||||||
|
default:
|
||||||
|
groups[source.PDDGoodsID] = append(groups[source.PDDGoodsID], specBackfillGroupedSource{source: source, set: set})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, pddGoodsID := range sortedGroupKeys(groups) {
|
||||||
|
group := groups[pddGoodsID]
|
||||||
|
if groupHasDifferentSets(group) {
|
||||||
|
for _, item := range group {
|
||||||
|
plan.addDecision(item.source, SpecBackfillAmbiguousShared, len(item.set))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
set := group[0].set
|
||||||
|
if len(set) == 0 {
|
||||||
|
for _, item := range group {
|
||||||
|
plan.addDecision(item.source, SpecBackfillShopeeMissing, 0)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
newRaw, eligibility, err := makePDDSpecSkeleton(group[0].source.PDDSKUsJSON, pddGoodsID, set, now)
|
||||||
|
if err != nil {
|
||||||
|
eligibility = SpecBackfillPDDInvalid
|
||||||
|
}
|
||||||
|
if eligibility != SpecBackfillCandidate {
|
||||||
|
for _, item := range group {
|
||||||
|
plan.addDecision(item.source, eligibility, len(set))
|
||||||
|
}
|
||||||
|
if eligibility == SpecBackfillPDDBackfilled {
|
||||||
|
plan.BackfilledPDDCount++
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
candidate := SpecBackfillCandidateItem{
|
||||||
|
PDDGoodsID: pddGoodsID, SpecCount: len(set),
|
||||||
|
OriginalSKUsJSON: group[0].source.PDDSKUsJSON, NewSKUsJSON: newRaw,
|
||||||
|
}
|
||||||
|
for _, item := range group {
|
||||||
|
candidate.ShopeeGoodsIDs = append(candidate.ShopeeGoodsIDs, item.source.ShopeeGoodsID)
|
||||||
|
plan.addDecision(item.source, SpecBackfillCandidate, len(set))
|
||||||
|
}
|
||||||
|
plan.Candidates = append(plan.Candidates, candidate)
|
||||||
|
}
|
||||||
|
sort.Slice(plan.Products, func(i, j int) bool { return plan.Products[i].ShopeeGoodsID < plan.Products[j].ShopeeGoodsID })
|
||||||
|
plan.CandidatePDDCount = len(plan.Candidates)
|
||||||
|
return plan
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *SpecBackfillPlan) addDecision(source repository.ShopSpecSource, status string, count int) {
|
||||||
|
p.Products = append(p.Products, SpecBackfillDecision{
|
||||||
|
ShopeeGoodsID: source.ShopeeGoodsID, PDDGoodsID: source.PDDGoodsID,
|
||||||
|
Status: status, SpecCount: count,
|
||||||
|
})
|
||||||
|
p.Summary[status]++
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedGroupKeys[T any](groups map[string]T) []string {
|
||||||
|
keys := make([]string, 0, len(groups))
|
||||||
|
for key := range groups {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
func groupHasDifferentSets(group []specBackfillGroupedSource) bool {
|
||||||
|
base := group[0].set
|
||||||
|
for _, item := range group[1:] {
|
||||||
|
if !sameStringSet(base, item.set) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func makePDDSpecSkeleton(raw, pddGoodsID string, set map[string]struct{}, now time.Time) (string, string, error) {
|
||||||
|
root := make(map[string]any)
|
||||||
|
if strings.TrimSpace(raw) != "" {
|
||||||
|
if err := json.Unmarshal([]byte(raw), &root); err != nil || root == nil {
|
||||||
|
return "", SpecBackfillPDDInvalid, fmt.Errorf("PDD 规格不是合法 JSON 对象")
|
||||||
|
}
|
||||||
|
if value, exists := root["skus"]; exists && value != nil {
|
||||||
|
items, ok := value.([]any)
|
||||||
|
if !ok {
|
||||||
|
return "", SpecBackfillPDDInvalid, fmt.Errorf("PDD skus 不是数组")
|
||||||
|
}
|
||||||
|
if len(items) > 0 {
|
||||||
|
if root["spec_source"] == "shopee_backfill" && backfilledSkeletonMatches(items, set) {
|
||||||
|
return "", SpecBackfillPDDBackfilled, nil
|
||||||
|
}
|
||||||
|
return "", SpecBackfillPDDExisting, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type combination struct{ color, size string }
|
||||||
|
combinations := make([]combination, 0, len(set))
|
||||||
|
hasColor, hasSize := false, false
|
||||||
|
for key := range set {
|
||||||
|
var values []string
|
||||||
|
if err := json.Unmarshal([]byte(key), &values); err != nil || len(values) != 2 {
|
||||||
|
return "", SpecBackfillPDDInvalid, fmt.Errorf("蝦皮正式规格键无效")
|
||||||
|
}
|
||||||
|
combinations = append(combinations, combination{values[0], values[1]})
|
||||||
|
hasColor = hasColor || values[0] != ""
|
||||||
|
hasSize = hasSize || values[1] != ""
|
||||||
|
}
|
||||||
|
sort.Slice(combinations, func(i, j int) bool {
|
||||||
|
if combinations[i].color != combinations[j].color {
|
||||||
|
return combinations[i].color < combinations[j].color
|
||||||
|
}
|
||||||
|
return combinations[i].size < combinations[j].size
|
||||||
|
})
|
||||||
|
dimensions := make([]map[string]string, 0, 2)
|
||||||
|
if hasColor {
|
||||||
|
dimensions = append(dimensions, map[string]string{"key": "color", "name": "颜色"})
|
||||||
|
}
|
||||||
|
if hasSize {
|
||||||
|
dimensions = append(dimensions, map[string]string{"key": "size", "name": "尺码"})
|
||||||
|
}
|
||||||
|
skus := make([]map[string]any, 0, len(combinations))
|
||||||
|
for _, combination := range combinations {
|
||||||
|
options := make(map[string]string)
|
||||||
|
if combination.color != "" {
|
||||||
|
options["color"] = combination.color
|
||||||
|
}
|
||||||
|
if combination.size != "" {
|
||||||
|
options["size"] = combination.size
|
||||||
|
}
|
||||||
|
skus = append(skus, map[string]any{
|
||||||
|
"options": options, "price_cent": nil, "list_price_cent": nil,
|
||||||
|
"availability_status": "unknown",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if _, exists := root["schema_version"]; !exists {
|
||||||
|
root["schema_version"] = 1
|
||||||
|
}
|
||||||
|
if _, exists := root["goods_id"]; !exists {
|
||||||
|
root["goods_id"] = pddGoodsID
|
||||||
|
}
|
||||||
|
root["dimensions"] = dimensions
|
||||||
|
root["skus"] = skus
|
||||||
|
root["spec_source"] = "shopee_backfill"
|
||||||
|
root["spec_backfilled_at"] = now.UTC().Format(time.RFC3339)
|
||||||
|
encoded, err := json.Marshal(root)
|
||||||
|
if err != nil {
|
||||||
|
return "", SpecBackfillPDDInvalid, fmt.Errorf("生成 PDD 规格骨架失败: %w", err)
|
||||||
|
}
|
||||||
|
return string(encoded), SpecBackfillCandidate, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func backfilledSkeletonMatches(items []any, expected map[string]struct{}) bool {
|
||||||
|
actual := make(map[string]struct{}, len(items))
|
||||||
|
for _, value := range items {
|
||||||
|
item, ok := value.(map[string]any)
|
||||||
|
price, hasPrice := item["price_cent"]
|
||||||
|
listPrice, hasListPrice := item["list_price_cent"]
|
||||||
|
if !ok || !hasPrice || price != nil || !hasListPrice || listPrice != nil || item["availability_status"] != "unknown" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if _, claimsAvailability := item["available"]; claimsAvailability {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
optionsValue, ok := item["options"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
color, colorOK := optionsValue["color"].(string)
|
||||||
|
size, sizeOK := optionsValue["size"].(string)
|
||||||
|
if !colorOK {
|
||||||
|
color = ""
|
||||||
|
}
|
||||||
|
if !sizeOK {
|
||||||
|
size = ""
|
||||||
|
}
|
||||||
|
key, valid := comparableSpecKey(color, size)
|
||||||
|
if !valid {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
actual[key] = struct{}{}
|
||||||
|
}
|
||||||
|
return sameStringSet(actual, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplySpecBackfill 在单个事务中应用候选;任一目标已变化就整批回滚。
|
||||||
|
func ApplySpecBackfill(db *sql.DB, plan SpecBackfillPlan, now time.Time) (int, error) {
|
||||||
|
tx, err := db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("开始规格回填事务失败: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
updated := 0
|
||||||
|
for _, candidate := range plan.Candidates {
|
||||||
|
ok, err := repository.ReplaceEmptyPDDSpecs(tx, candidate.PDDGoodsID,
|
||||||
|
candidate.OriginalSKUsJSON, candidate.NewSKUsJSON, now.UTC().Format(time.RFC3339))
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return 0, fmt.Errorf("PDD 商品 %s 在 dry-run 后发生变化,已回滚整批回填", candidate.PDDGoodsID)
|
||||||
|
}
|
||||||
|
updated++
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return 0, fmt.Errorf("提交规格回填事务失败: %w", err)
|
||||||
|
}
|
||||||
|
return updated, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"cmautobuy/admin/repository"
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildSpecBackfillPlanCreatesUnknownSkeleton(t *testing.T) {
|
||||||
|
now := time.Date(2026, 8, 12, 12, 0, 0, 0, time.UTC)
|
||||||
|
sources := []repository.ShopSpecSource{{
|
||||||
|
ShopeeGoodsID: "S1", PDDGoodsID: "P1", PDDExists: true,
|
||||||
|
ShopeeSpecs: []repository.ComparableShopeeSpec{
|
||||||
|
{Color: "黑", Size: "M", ParseOK: true},
|
||||||
|
{Color: "白", Size: "L", ParseOK: true},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
plan := BuildSpecBackfillPlan(" shop ", sources, now)
|
||||||
|
if plan.Shop != "shop" || plan.CandidatePDDCount != 1 || plan.Summary[SpecBackfillCandidate] != 1 {
|
||||||
|
t.Fatalf("候选统计错误: %+v", plan)
|
||||||
|
}
|
||||||
|
var skeleton struct {
|
||||||
|
SpecSource string `json:"spec_source"`
|
||||||
|
SKUs []struct {
|
||||||
|
Options map[string]string `json:"options"`
|
||||||
|
PriceCent *int64 `json:"price_cent"`
|
||||||
|
AvailabilityStatus string `json:"availability_status"`
|
||||||
|
Available *bool `json:"available"`
|
||||||
|
} `json:"skus"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(plan.Candidates[0].NewSKUsJSON), &skeleton); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if skeleton.SpecSource != "shopee_backfill" || len(skeleton.SKUs) != 2 {
|
||||||
|
t.Fatalf("规格骨架错误: %+v", skeleton)
|
||||||
|
}
|
||||||
|
for _, sku := range skeleton.SKUs {
|
||||||
|
if sku.PriceCent != nil || sku.AvailabilityStatus != "unknown" || sku.Available != nil {
|
||||||
|
t.Fatalf("价格和库存必须保持未知: %+v", sku)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildSpecBackfillPlanDoesNotOverwriteExistingOrInvalid(t *testing.T) {
|
||||||
|
specs := []repository.ComparableShopeeSpec{{Color: "黑", Size: "M", ParseOK: true}}
|
||||||
|
plan := BuildSpecBackfillPlan("shop", []repository.ShopSpecSource{
|
||||||
|
{ShopeeGoodsID: "S1", PDDGoodsID: "P1", PDDExists: true, ShopeeSpecs: specs,
|
||||||
|
PDDSKUsJSON: `{"skus":[{"options":{"color":"黑","size":"M"}}]}`},
|
||||||
|
{ShopeeGoodsID: "S2", PDDGoodsID: "P2", PDDExists: true, ShopeeSpecs: specs,
|
||||||
|
PDDSKUsJSON: `{坏数据`},
|
||||||
|
}, time.Now())
|
||||||
|
if plan.CandidatePDDCount != 0 || plan.Summary[SpecBackfillPDDExisting] != 1 || plan.Summary[SpecBackfillPDDInvalid] != 1 {
|
||||||
|
t.Fatalf("不应覆盖已有或异常数据: %+v", plan)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildSpecBackfillPlanSkipsAmbiguousSharedPDD(t *testing.T) {
|
||||||
|
plan := BuildSpecBackfillPlan("shop", []repository.ShopSpecSource{
|
||||||
|
{ShopeeGoodsID: "S1", PDDGoodsID: "P1", PDDExists: true,
|
||||||
|
ShopeeSpecs: []repository.ComparableShopeeSpec{{Color: "黑", Size: "M", ParseOK: true}}},
|
||||||
|
{ShopeeGoodsID: "S2", PDDGoodsID: "P1", PDDExists: true,
|
||||||
|
ShopeeSpecs: []repository.ComparableShopeeSpec{{Color: "白", Size: "L", ParseOK: true}}},
|
||||||
|
}, time.Now())
|
||||||
|
if plan.CandidatePDDCount != 0 || plan.Summary[SpecBackfillAmbiguousShared] != 2 {
|
||||||
|
t.Fatalf("多对一歧义必须跳过: %+v", plan)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMakePDDSpecSkeletonPreservesExistingTopLevelData(t *testing.T) {
|
||||||
|
set := map[string]struct{}{`["黑","M"]`: {}}
|
||||||
|
raw, status, err := makePDDSpecSkeleton(`{"title":"保留标题","skus":[]}`, "P1", set, time.Now())
|
||||||
|
if err != nil || status != SpecBackfillCandidate {
|
||||||
|
t.Fatalf("生成失败: status=%s err=%v", status, err)
|
||||||
|
}
|
||||||
|
var root map[string]any
|
||||||
|
json.Unmarshal([]byte(raw), &root)
|
||||||
|
if root["title"] != "保留标题" {
|
||||||
|
t.Fatalf("应保留原有顶层数据: %s", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildSpecBackfillPlanRecognizesVerifiedBackfill(t *testing.T) {
|
||||||
|
specs := []repository.ComparableShopeeSpec{{Color: "黑", Size: "M", ParseOK: true}}
|
||||||
|
raw := `{"spec_source":"shopee_backfill","skus":[{"options":{"color":"黑","size":"M"},"price_cent":null,"list_price_cent":null,"availability_status":"unknown"}]}`
|
||||||
|
plan := BuildSpecBackfillPlan("shop", []repository.ShopSpecSource{{
|
||||||
|
ShopeeGoodsID: "S1", PDDGoodsID: "P1", PDDExists: true,
|
||||||
|
ShopeeSpecs: specs, PDDSKUsJSON: raw,
|
||||||
|
}}, time.Now())
|
||||||
|
if plan.BackfilledPDDCount != 1 || plan.Summary[SpecBackfillPDDBackfilled] != 1 || plan.CandidatePDDCount != 0 {
|
||||||
|
t.Fatalf("应识别为已安全回填: %+v", plan)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplySpecBackfillRollsBackWholeBatchWhenOneTargetChanged(t *testing.T) {
|
||||||
|
db, err := sql.Open("sqlite", ":memory:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
db.SetMaxOpenConns(1)
|
||||||
|
if _, err := db.Exec(`CREATE TABLE pdd_products(goods_id TEXT PRIMARY KEY, skus_json TEXT, deleted_at TEXT, updated_at TEXT)`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(`INSERT INTO pdd_products(goods_id,skus_json) VALUES('P1',''),('P2','已变化')`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
plan := SpecBackfillPlan{Candidates: []SpecBackfillCandidateItem{
|
||||||
|
{PDDGoodsID: "P1", OriginalSKUsJSON: "", NewSKUsJSON: `{"skus":[1]}`},
|
||||||
|
{PDDGoodsID: "P2", OriginalSKUsJSON: "", NewSKUsJSON: `{"skus":[2]}`},
|
||||||
|
}}
|
||||||
|
if _, err := ApplySpecBackfill(db, plan, time.Now()); err == nil {
|
||||||
|
t.Fatal("第二件商品原值变化时应返回错误")
|
||||||
|
}
|
||||||
|
var raw string
|
||||||
|
if err := db.QueryRow(`SELECT skus_json FROM pdd_products WHERE goods_id='P1'`).Scan(&raw); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if raw != "" {
|
||||||
|
t.Fatalf("整批事务应回滚,P1 实际为 %q", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user