feat: 增加店铺规格只读对比工具 (#193)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user