From cf1d0ab49717c0275c3de092b50f704901a0fa36 Mon Sep 17 00:00:00 2001 From: chengma Date: Wed, 12 Aug 2026 18:16:33 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E5=BA=97=E9=93=BA?= =?UTF-8?q?=E8=A7=84=E6=A0=BC=E5=8F=AA=E8=AF=BB=E5=AF=B9=E6=AF=94=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=20(#193)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/cmd/compare-shop-specs/main.go | 55 ++++++++ admin/repository/spec_compare.go | 73 +++++++++++ admin/service/spec_compare.go | 180 +++++++++++++++++++++++++++ admin/service/spec_compare_test.go | 54 ++++++++ 4 files changed, 362 insertions(+) create mode 100644 admin/cmd/compare-shop-specs/main.go create mode 100644 admin/repository/spec_compare.go create mode 100644 admin/service/spec_compare.go create mode 100644 admin/service/spec_compare_test.go diff --git a/admin/cmd/compare-shop-specs/main.go b/admin/cmd/compare-shop-specs/main.go new file mode 100644 index 0000000..a1dba90 --- /dev/null +++ b/admin/cmd/compare-shop-specs/main.go @@ -0,0 +1,55 @@ +// compare-shop-specs 生成指定蝦皮店铺与关联 PDD 商品的只读规格对比报告。 +// 它只执行 SELECT,没有写库或 --apply 功能。 +package main + +import ( + "encoding/json" + "flag" + "fmt" + "log" + "os" + "strings" + "time" + + "cmautobuy/admin/config" + "cmautobuy/admin/repository" + "cmautobuy/admin/service" +) + +func main() { + shop := flag.String("shop", "", "要对比的蝦皮店铺名(精确匹配,必填)") + output := flag.String("output", "", "可选:把 JSON 报告写入本机文件;不填则输出到终端") + 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) + } + report := service.CompareShopSpecs(*shop, sources, time.Now()) + raw, err := json.MarshalIndent(report, "", " ") + if err != nil { + log.Fatalf("生成 JSON 报告失败: %v", err) + } + raw = append(raw, '\n') + if strings.TrimSpace(*output) == "" { + fmt.Print(string(raw)) + return + } + if err := os.WriteFile(*output, raw, 0o600); err != nil { + log.Fatalf("写入本机报告失败: %v", err) + } + fmt.Printf("只读对比完成:%d 件商品,报告已写入 %s\n", report.Total, *output) +} diff --git a/admin/repository/spec_compare.go b/admin/repository/spec_compare.go new file mode 100644 index 0000000..4f49fd1 --- /dev/null +++ b/admin/repository/spec_compare.go @@ -0,0 +1,73 @@ +package repository + +import ( + "database/sql" + "fmt" + "strings" +) + +// ComparableShopeeSpec 是一条可用于只读对比的蝦皮正式规格。 +type ComparableShopeeSpec struct { + Color string + Size string + ParseOK bool +} + +// ShopSpecSource 汇总一件蝦皮商品及其关联 PDD 商品的原始对比数据。 +type ShopSpecSource struct { + ShopeeGoodsID string + PDDGoodsID string + PDDExists bool + PDDDeleted bool + PDDSKUsJSON string + ShopeeSpecs []ComparableShopeeSpec +} + +// ListShopSpecSources 按店铺名精确读取规格对比数据。本函数只执行 SELECT。 +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 + 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)) + if err != nil { + return nil, fmt.Errorf("读取店铺规格对比数据失败: %w", err) + } + defer rows.Close() + + var list []ShopSpecSource + var current *ShopSpecSource + 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 { + 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, + }) + } + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("遍历店铺规格对比数据失败: %w", err) + } + return list, nil +} diff --git a/admin/service/spec_compare.go b/admin/service/spec_compare.go new file mode 100644 index 0000000..98f5da1 --- /dev/null +++ b/admin/service/spec_compare.go @@ -0,0 +1,180 @@ +package service + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + "time" + + "cmautobuy/admin/repository" +) + +const ( + SpecCompareUnlinked = "unlinked" + SpecComparePDDMissing = "pdd_missing_or_deleted" + SpecCompareShopeeUnavailable = "shopee_formal_specs_unavailable" + SpecComparePDDUnavailable = "pdd_specs_unavailable" + SpecCompareExact = "exact" + SpecComparePDDMissingSpecs = "pdd_missing_specs" + SpecComparePDDExtraSpecs = "pdd_extra_specs" + SpecCompareDifferent = "different_specs" + SpecCompareAmbiguousShared = "ambiguous_shared_pdd" +) + +// ShopSpecComparison 是 dry-run 报告中的单件商品结果,不包含标题、规格原文和价格。 +type ShopSpecComparison struct { + ShopeeGoodsID string `json:"shopee_goods_id"` + PDDGoodsID string `json:"pdd_goods_id,omitempty"` + Status string `json:"status"` + ShopeeSpecCount int `json:"shopee_spec_count"` + PDDSpecCount int `json:"pdd_spec_count"` + MissingCount int `json:"missing_count"` + ExtraCount int `json:"extra_count"` +} + +// ShopSpecReport 是指定店铺的只读规格对比报告。 +type ShopSpecReport struct { + Shop string `json:"shop"` + GeneratedAt string `json:"generated_at"` + Total int `json:"total"` + Summary map[string]int `json:"summary"` + Products []ShopSpecComparison `json:"products"` +} + +type pddComparableData struct { + SKUs []struct { + Options map[string]string `json:"options"` + } `json:"skus"` +} + +// CompareShopSpecs 只在内存中比较规格集合,不写数据库。 +func CompareShopSpecs(shop string, sources []repository.ShopSpecSource, now time.Time) ShopSpecReport { + report := ShopSpecReport{Shop: strings.TrimSpace(shop), GeneratedAt: now.UTC().Format(time.RFC3339), Summary: map[string]int{}} + setsByShopee := make(map[string]map[string]struct{}, len(sources)) + indexesByPDD := make(map[string][]int) + for _, source := range sources { + shopeeSet := formalShopeeSpecSet(source.ShopeeSpecs) + setsByShopee[source.ShopeeGoodsID] = shopeeSet + item := compareOne(source, shopeeSet) + report.Products = append(report.Products, item) + if source.PDDGoodsID != "" { + indexesByPDD[source.PDDGoodsID] = append(indexesByPDD[source.PDDGoodsID], len(report.Products)-1) + } + } + + // 多个蝦皮商品共用同一个 PDD 时,只有正式规格集合完全相同才可作为后续回填候选。 + for _, indexes := range indexesByPDD { + if len(indexes) < 2 { + continue + } + base := setsByShopee[report.Products[indexes[0]].ShopeeGoodsID] + for _, index := range indexes[1:] { + if !sameStringSet(base, setsByShopee[report.Products[index].ShopeeGoodsID]) { + for _, ambiguousIndex := range indexes { + report.Products[ambiguousIndex].Status = SpecCompareAmbiguousShared + } + break + } + } + } + + sort.Slice(report.Products, func(i, j int) bool { return report.Products[i].ShopeeGoodsID < report.Products[j].ShopeeGoodsID }) + report.Total = len(report.Products) + for _, item := range report.Products { + report.Summary[item.Status]++ + } + return report +} + +func compareOne(source repository.ShopSpecSource, shopeeSet map[string]struct{}) ShopSpecComparison { + item := ShopSpecComparison{ShopeeGoodsID: source.ShopeeGoodsID, PDDGoodsID: source.PDDGoodsID, ShopeeSpecCount: len(shopeeSet)} + switch { + case strings.TrimSpace(source.PDDGoodsID) == "": + item.Status = SpecCompareUnlinked + return item + case !source.PDDExists || source.PDDDeleted: + item.Status = SpecComparePDDMissing + return item + case len(shopeeSet) == 0: + item.Status = SpecCompareShopeeUnavailable + return item + } + pddSet, err := comparablePDDSpecSet(source.PDDSKUsJSON) + if err != nil || len(pddSet) == 0 { + item.Status = SpecComparePDDUnavailable + return item + } + item.PDDSpecCount = len(pddSet) + item.MissingCount = differenceCount(shopeeSet, pddSet) + item.ExtraCount = differenceCount(pddSet, shopeeSet) + switch { + case item.MissingCount == 0 && item.ExtraCount == 0: + item.Status = SpecCompareExact + case item.MissingCount > 0 && item.ExtraCount == 0: + item.Status = SpecComparePDDMissingSpecs + case item.MissingCount == 0 && item.ExtraCount > 0: + item.Status = SpecComparePDDExtraSpecs + default: + item.Status = SpecCompareDifferent + } + return item +} + +func formalShopeeSpecSet(specs []repository.ComparableShopeeSpec) map[string]struct{} { + set := make(map[string]struct{}) + for _, spec := range specs { + if !spec.ParseOK { + continue + } + if key, ok := comparableSpecKey(spec.Color, spec.Size); ok { + set[key] = struct{}{} + } + } + return set +} + +func comparablePDDSpecSet(raw string) (map[string]struct{}, error) { + if strings.TrimSpace(raw) == "" { + return nil, fmt.Errorf("PDD 规格为空") + } + var data pddComparableData + if err := json.Unmarshal([]byte(raw), &data); err != nil { + return nil, err + } + set := make(map[string]struct{}) + for _, sku := range data.SKUs { + for key := range sku.Options { + if key != "color" && key != "size" { + return nil, fmt.Errorf("存在无法比较的 PDD 规格维度 %q", key) + } + } + if key, ok := comparableSpecKey(sku.Options["color"], sku.Options["size"]); ok { + set[key] = struct{}{} + } + } + return set, nil +} + +func comparableSpecKey(color, size string) (string, bool) { + color, size = strings.TrimSpace(color), strings.TrimSpace(size) + if color == "" && size == "" { + return "", false + } + encoded, _ := json.Marshal([]string{color, size}) + return string(encoded), true +} + +func differenceCount(left, right map[string]struct{}) int { + count := 0 + for value := range left { + if _, exists := right[value]; !exists { + count++ + } + } + return count +} + +func sameStringSet(left, right map[string]struct{}) bool { + return len(left) == len(right) && differenceCount(left, right) == 0 +} diff --git a/admin/service/spec_compare_test.go b/admin/service/spec_compare_test.go new file mode 100644 index 0000000..29f83f0 --- /dev/null +++ b/admin/service/spec_compare_test.go @@ -0,0 +1,54 @@ +package service + +import ( + "testing" + "time" + + "cmautobuy/admin/repository" +) + +func TestCompareShopSpecsClassifiesDifferences(t *testing.T) { + sources := []repository.ShopSpecSource{ + {ShopeeGoodsID: "S1", PDDGoodsID: "P1", PDDExists: true, + ShopeeSpecs: []repository.ComparableShopeeSpec{{Color: "黑", Size: "M", ParseOK: true}}, + PDDSKUsJSON: `{"skus":[{"options":{"color":"黑","size":"M"}}]}`}, + {ShopeeGoodsID: "S2"}, + {ShopeeGoodsID: "S3", PDDGoodsID: "P3", PDDExists: true, + ShopeeSpecs: []repository.ComparableShopeeSpec{{Color: "白", Size: "L", ParseOK: true}}, + PDDSKUsJSON: `{"skus":[{"options":{"color":"白","size":"M"}}]}`}, + } + + report := CompareShopSpecs(" shop ", sources, time.Date(2026, 8, 12, 0, 0, 0, 0, time.UTC)) + if report.Shop != "shop" || report.Total != 3 { + t.Fatalf("报告基本信息错误: %+v", report) + } + if report.Summary[SpecCompareExact] != 1 || report.Summary[SpecCompareUnlinked] != 1 || report.Summary[SpecCompareDifferent] != 1 { + t.Fatalf("分类统计错误: %+v", report.Summary) + } +} + +func TestCompareShopSpecsRejectsAmbiguousSharedPDD(t *testing.T) { + sources := []repository.ShopSpecSource{ + {ShopeeGoodsID: "S1", PDDGoodsID: "P1", PDDExists: true, + ShopeeSpecs: []repository.ComparableShopeeSpec{{Color: "黑", Size: "M", ParseOK: true}}, + PDDSKUsJSON: `{"skus":[{"options":{"color":"黑","size":"M"}}]}`}, + {ShopeeGoodsID: "S2", PDDGoodsID: "P1", PDDExists: true, + ShopeeSpecs: []repository.ComparableShopeeSpec{{Color: "白", Size: "L", ParseOK: true}}, + PDDSKUsJSON: `{"skus":[{"options":{"color":"黑","size":"M"}}]}`}, + } + report := CompareShopSpecs("shop", sources, time.Now()) + if report.Summary[SpecCompareAmbiguousShared] != 2 { + t.Fatalf("共用 PDD 且规格不同时应标记歧义: %+v", report) + } +} + +func TestCompareShopSpecsDoesNotExposeRawSpecs(t *testing.T) { + source := repository.ShopSpecSource{ShopeeGoodsID: "S1", PDDGoodsID: "P1", PDDExists: true, + ShopeeSpecs: []repository.ComparableShopeeSpec{{Color: "秘密颜色", Size: "秘密尺码", ParseOK: true}}, + PDDSKUsJSON: `{"skus":[{"options":{"color":"别的颜色","size":"别的尺码"},"price_cent":999}]}`} + report := CompareShopSpecs("shop", []repository.ShopSpecSource{source}, time.Now()) + item := report.Products[0] + if item.ShopeeSpecCount != 1 || item.PDDSpecCount != 1 || item.MissingCount != 1 || item.ExtraCount != 1 { + t.Fatalf("报告只应输出计数: %+v", item) + } +}