diff --git a/admin/cmd/backfill-shop-pdd-specs/main.go b/admin/cmd/backfill-shop-pdd-specs/main.go new file mode 100644 index 0000000..3d4a2a4 --- /dev/null +++ b/admin/cmd/backfill-shop-pdd-specs/main.go @@ -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 +} diff --git a/admin/repository/spec_compare.go b/admin/repository/spec_compare.go index 4f49fd1..35086df 100644 --- a/admin/repository/spec_compare.go +++ b/admin/repository/spec_compare.go @@ -27,47 +27,86 @@ type ShopSpecSource struct { func ListShopSpecSources(q Execer, shopName string) ([]ShopSpecSource, error) { rows, err := q.Query(` SELECT sp.goods_id, sp.pdd_goods_id, - pp.goods_id, pp.deleted_at, pp.skus_json, - sk.color, sk.size, sk.parse_ok + pp.goods_id, pp.deleted_at, pp.skus_json 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 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 { return nil, fmt.Errorf("读取店铺规格对比数据失败: %w", err) } defer rows.Close() var list []ShopSpecSource - var current *ShopSpecSource + indexByGoodsID := make(map[string]int) for rows.Next() { var shopeeID string var pddID, existingPDDID, deletedAt, skusJSON sql.NullString - var color, size sql.NullString - var parseOK sql.NullInt64 - if err := rows.Scan(&shopeeID, &pddID, &existingPDDID, &deletedAt, - &skusJSON, &color, &size, &parseOK); err != nil { + if err := rows.Scan(&shopeeID, &pddID, &existingPDDID, &deletedAt, &skusJSON); err != nil { return nil, fmt.Errorf("解析店铺规格对比数据失败: %w", err) } - if current == nil || current.ShopeeGoodsID != shopeeID { - list = append(list, ShopSpecSource{ - ShopeeGoodsID: shopeeID, - PDDGoodsID: pddID.String, - PDDExists: existingPDDID.Valid, - PDDDeleted: strings.TrimSpace(deletedAt.String) != "", - PDDSKUsJSON: skusJSON.String, - }) - current = &list[len(list)-1] - } - if parseOK.Valid { - current.ShopeeSpecs = append(current.ShopeeSpecs, ComparableShopeeSpec{ - Color: color.String, Size: size.String, ParseOK: parseOK.Int64 == 1, - }) - } + list = append(list, ShopSpecSource{ + ShopeeGoodsID: shopeeID, + PDDGoodsID: pddID.String, + PDDExists: existingPDDID.Valid, + PDDDeleted: strings.TrimSpace(deletedAt.String) != "", + PDDSKUsJSON: skusJSON.String, + }) + indexByGoodsID[shopeeID] = len(list) - 1 } if err := rows.Err(); err != nil { 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 } + +// 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 +} diff --git a/admin/repository/spec_compare_test.go b/admin/repository/spec_compare_test.go new file mode 100644 index 0000000..f295220 --- /dev/null +++ b/admin/repository/spec_compare_test.go @@ -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) + } +} diff --git a/admin/service/spec_backfill.go b/admin/service/spec_backfill.go new file mode 100644 index 0000000..080c4d8 --- /dev/null +++ b/admin/service/spec_backfill.go @@ -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 +} diff --git a/admin/service/spec_backfill_test.go b/admin/service/spec_backfill_test.go new file mode 100644 index 0000000..742d31a --- /dev/null +++ b/admin/service/spec_backfill_test.go @@ -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) + } +}