From f06ded8a0ad7f9162b2077d0b7966d1cc2b49411 Mon Sep 17 00:00:00 2001 From: chengma Date: Sun, 23 Aug 2026 23:55:33 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=BB=BA=E7=AB=8B=E5=95=86=E5=93=81?= =?UTF-8?q?=E9=A2=9C=E8=89=B2=E6=98=A0=E5=B0=84=E6=95=B0=E6=8D=AE=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=20(#293)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/model/model.go | 34 ++ admin/repository/color_mapping.go | 104 +++++ admin/repository/mysql_db.go | 102 ++++- admin/repository/mysql_db_integration_test.go | 22 + admin/service/color_mapping.go | 409 ++++++++++++++++++ admin/service/color_mapping_test.go | 87 ++++ docs/admin/03-data-model.md | 20 + 7 files changed, 776 insertions(+), 2 deletions(-) create mode 100644 admin/repository/color_mapping.go create mode 100644 admin/service/color_mapping.go create mode 100644 admin/service/color_mapping_test.go diff --git a/admin/model/model.go b/admin/model/model.go index ce65719..5371688 100644 --- a/admin/model/model.go +++ b/admin/model/model.go @@ -388,6 +388,40 @@ type SpecMapping struct { ContextVersion string } +// ProductColorMapping 保存一个蝦皮商品颜色到当前 PDD 商品颜色值的人工映射。 +// +// 尺码不属于这里:采购仍以完整 spec_mappings 为准。PDD 商品发生替换时, +// PddGoodsID 会把旧映射自然隔离并保留,避免误用到新商品。 +type ProductColorMapping struct { + ShopeeGoodsID string + ShopeeColorKey string + ShopeeColorRaw string + PddGoodsID string + PddDimensionKey string + PddColorValue string + ContextVersion string + MappedBy string + MappedAt string +} + +// ProductColorMappingAudit 是颜色映射的追加式审计记录。 +// 清除映射只删除当前态,旧值仍保留在审计表中。 +type ProductColorMappingAudit struct { + ID int64 + ShopeeGoodsID string + ShopeeColorKey string + ShopeeColorRaw string + PddGoodsID string + Action string + OldDimensionKey string + OldColorValue string + NewDimensionKey string + NewColorValue string + ContextVersion string + ActorUserID string + CreatedAt string +} + type SpecMappingDecision struct { ShopeeGoodsID, SpecKey, PddGoodsID string RulesVersion, SuggestedOptionKey, ChosenOptionKey string diff --git a/admin/repository/color_mapping.go b/admin/repository/color_mapping.go new file mode 100644 index 0000000..5c72321 --- /dev/null +++ b/admin/repository/color_mapping.go @@ -0,0 +1,104 @@ +package repository + +import ( + "database/sql" + "errors" + "fmt" + + "cmautobuy/admin/model" +) + +// ListProductColorMappings 只读取指定蝦皮/PDD 商品对的当前颜色映射。 +// 旧 PDD 商品的映射会继续留库,但不会混进当前页面或采购链路。 +func ListProductColorMappings(q Execer, shopeeGoodsID, pddGoodsID string) ([]model.ProductColorMapping, error) { + rows, err := q.Query(`SELECT shopee_goods_id,shopee_color_key,shopee_color_raw,pdd_goods_id, + pdd_dimension_key,pdd_color_value,context_version,mapped_by,mapped_at + FROM product_color_mappings + WHERE shopee_goods_id=? AND pdd_goods_id=? + ORDER BY shopee_color_key`, shopeeGoodsID, pddGoodsID) + if err != nil { + return nil, fmt.Errorf("查询商品颜色映射失败: %w", err) + } + defer rows.Close() + var result []model.ProductColorMapping + for rows.Next() { + var item model.ProductColorMapping + if err := rows.Scan(&item.ShopeeGoodsID, &item.ShopeeColorKey, &item.ShopeeColorRaw, + &item.PddGoodsID, &item.PddDimensionKey, &item.PddColorValue, + &item.ContextVersion, &item.MappedBy, &item.MappedAt); err != nil { + return nil, fmt.Errorf("读取商品颜色映射失败: %w", err) + } + result = append(result, item) + } + return result, rows.Err() +} + +// GetProductColorMapping 读取单个当前映射;不存在时返回 (nil, nil)。 +func GetProductColorMapping(q Execer, shopeeGoodsID, shopeeColorKey, pddGoodsID string) (*model.ProductColorMapping, error) { + var item model.ProductColorMapping + err := q.QueryRow(`SELECT shopee_goods_id,shopee_color_key,shopee_color_raw,pdd_goods_id, + pdd_dimension_key,pdd_color_value,context_version,mapped_by,mapped_at + FROM product_color_mappings + WHERE shopee_goods_id=? AND shopee_color_key=? AND pdd_goods_id=?`, + shopeeGoodsID, shopeeColorKey, pddGoodsID).Scan( + &item.ShopeeGoodsID, &item.ShopeeColorKey, &item.ShopeeColorRaw, + &item.PddGoodsID, &item.PddDimensionKey, &item.PddColorValue, + &item.ContextVersion, &item.MappedBy, &item.MappedAt) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("读取商品颜色映射失败: %w", err) + } + return &item, nil +} + +// UpsertProductColorMapping 写入当前态。调用方必须在同一事务内追加审计。 +func UpsertProductColorMapping(q Execer, item model.ProductColorMapping) error { + _, err := q.Exec(`INSERT INTO product_color_mappings + (shopee_goods_id,shopee_color_key,shopee_color_raw,pdd_goods_id,pdd_dimension_key, + pdd_color_value,context_version,mapped_by,mapped_at) + VALUES(?,?,?,?,?,?,?,?,?) + ON DUPLICATE KEY UPDATE + shopee_color_raw=VALUES(shopee_color_raw),pdd_dimension_key=VALUES(pdd_dimension_key), + pdd_color_value=VALUES(pdd_color_value),context_version=VALUES(context_version), + mapped_by=VALUES(mapped_by),mapped_at=VALUES(mapped_at)`, + item.ShopeeGoodsID, item.ShopeeColorKey, item.ShopeeColorRaw, item.PddGoodsID, + item.PddDimensionKey, item.PddColorValue, item.ContextVersion, item.MappedBy, item.MappedAt) + if err != nil { + return fmt.Errorf("保存商品颜色映射失败: %w", err) + } + return nil +} + +// DeleteProductColorMapping 删除当前态;审计记录不删除。 +func DeleteProductColorMapping(q Execer, shopeeGoodsID, shopeeColorKey, pddGoodsID string) (bool, error) { + result, err := q.Exec(`DELETE FROM product_color_mappings + WHERE shopee_goods_id=? AND shopee_color_key=? AND pdd_goods_id=?`, + shopeeGoodsID, shopeeColorKey, pddGoodsID) + if err != nil { + return false, fmt.Errorf("清除商品颜色映射失败: %w", err) + } + n, err := result.RowsAffected() + if err != nil { + return false, fmt.Errorf("读取商品颜色映射清除结果失败: %w", err) + } + return n > 0, nil +} + +// InsertProductColorMappingAudit 追加一条不允许覆盖的映射审计。 +func InsertProductColorMappingAudit(q Execer, item model.ProductColorMappingAudit) error { + _, err := q.Exec(`INSERT INTO product_color_mapping_audits + (shopee_goods_id,shopee_color_key,shopee_color_raw,pdd_goods_id,action, + old_dimension_key,old_color_value,new_dimension_key,new_color_value, + context_version,actor_user_id,created_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`, + item.ShopeeGoodsID, item.ShopeeColorKey, item.ShopeeColorRaw, item.PddGoodsID, + item.Action, nullableText(item.OldDimensionKey), nullableText(item.OldColorValue), + nullableText(item.NewDimensionKey), nullableText(item.NewColorValue), + item.ContextVersion, item.ActorUserID, item.CreatedAt) + if err != nil { + return fmt.Errorf("记录商品颜色映射审计失败: %w", err) + } + return nil +} diff --git a/admin/repository/mysql_db.go b/admin/repository/mysql_db.go index ebf97eb..18177e4 100644 --- a/admin/repository/mysql_db.go +++ b/admin/repository/mysql_db.go @@ -20,7 +20,7 @@ import ( "cmautobuy/admin/spec" ) -const mysqlSchemaVersion = 29 +const mysqlSchemaVersion = 30 // OpenMySQL 打开生产 MySQL 8 数据库。错误信息绝不包含完整 DSN 或密码。 func OpenMySQL(cfg config.DatabaseConfig) (*sql.DB, error) { @@ -770,9 +770,64 @@ func MigrateMySQL(db *sql.DB) error { return fmt.Errorf("记录 MySQL schema v29 失败: %w", err) } } + if current < 30 { + if err := migrateMySQLV30(db); err != nil { + return fmt.Errorf("执行 MySQL schema v30 失败: %w", err) + } + if err := checkMySQLV30Shape(db); err != nil { + return fmt.Errorf("MySQL schema v30 自检失败,未记录版本: %w", err) + } + if _, err := db.Exec(`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`, 30, time.Now().UTC().Format(time.RFC3339Nano)); err != nil { + return fmt.Errorf("记录 MySQL schema v30 失败: %w", err) + } + } return CheckMySQLSchema(db) } +// migrateMySQLV30 建立商品级颜色映射当前态和追加式审计。 +// PDD 商品 ID 属于映射身份的一部分,换商品后旧映射仍保留但不会被当前链路读取。 +func migrateMySQLV30(db *sql.DB) error { + statements := []string{ + `CREATE TABLE IF NOT EXISTS product_color_mappings ( + shopee_goods_id VARCHAR(191) COLLATE utf8mb4_bin NOT NULL, + shopee_color_key VARCHAR(191) COLLATE utf8mb4_bin NOT NULL, + shopee_color_raw VARCHAR(500) COLLATE utf8mb4_bin NOT NULL, + pdd_goods_id VARCHAR(191) COLLATE utf8mb4_bin NOT NULL, + pdd_dimension_key VARCHAR(191) COLLATE utf8mb4_bin NOT NULL, + pdd_color_value VARCHAR(500) COLLATE utf8mb4_bin NOT NULL, + context_version CHAR(64) COLLATE ascii_bin NOT NULL, + mapped_by VARCHAR(191) COLLATE utf8mb4_bin NOT NULL, + mapped_at VARCHAR(35) NOT NULL, + PRIMARY KEY (shopee_goods_id,shopee_color_key,pdd_goods_id), + KEY idx_product_color_mappings_pdd (pdd_goods_id,pdd_dimension_key,pdd_color_value) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS product_color_mapping_audits ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + shopee_goods_id VARCHAR(191) COLLATE utf8mb4_bin NOT NULL, + shopee_color_key VARCHAR(191) COLLATE utf8mb4_bin NOT NULL, + shopee_color_raw VARCHAR(500) COLLATE utf8mb4_bin NOT NULL, + pdd_goods_id VARCHAR(191) COLLATE utf8mb4_bin NOT NULL, + action VARCHAR(16) COLLATE ascii_bin NOT NULL, + old_dimension_key VARCHAR(191) COLLATE utf8mb4_bin NULL, + old_color_value VARCHAR(500) COLLATE utf8mb4_bin NULL, + new_dimension_key VARCHAR(191) COLLATE utf8mb4_bin NULL, + new_color_value VARCHAR(500) COLLATE utf8mb4_bin NULL, + context_version CHAR(64) COLLATE ascii_bin NOT NULL, + actor_user_id VARCHAR(191) COLLATE utf8mb4_bin NOT NULL, + created_at VARCHAR(35) NOT NULL, + CONSTRAINT chk_product_color_mapping_audit_action CHECK (action IN ('upsert','clear')), + KEY idx_product_color_mapping_audits_identity + (shopee_goods_id,shopee_color_key,pdd_goods_id,created_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + } + for i, statement := range statements { + if _, err := db.Exec(statement); err != nil { + return fmt.Errorf("执行颜色映射建表语句 %d 失败: %w", i+1, err) + } + } + return nil +} + // migrateMySQLV29 修复曾由 v28 中间版本按表默认排序规则创建的原始 SKU 字段。 // 已经是最终结构时直接返回,兼容空库、v27 升级和已记录 v28 的数据库。 func migrateMySQLV29(db *sql.DB) error { @@ -2327,6 +2382,7 @@ func CheckMySQLSchema(db *sql.DB) error { "ai_spec_match_decisions", "ai_match_batches", "ai_match_batch_items", "purchase_spec_resolutions", + "product_color_mappings", "product_color_mapping_audits", } if err := checkMySQLSchema(db, mysqlRequiredTables); err != nil { return err @@ -2409,7 +2465,49 @@ func CheckMySQLSchema(db *sql.DB) error { if err := checkMySQLV28Shape(db); err != nil { return err } - return checkMySQLV29Shape(db) + if err := checkMySQLV29Shape(db); err != nil { + return err + } + return checkMySQLV30Shape(db) +} + +func checkMySQLV30Shape(db *sql.DB) error { + if err := checkMySQLSchema(db, []string{"product_color_mappings", "product_color_mapping_audits"}); err != nil { + return err + } + for table, columns := range map[string][]string{ + "product_color_mappings": { + "shopee_goods_id", "shopee_color_key", "shopee_color_raw", "pdd_goods_id", + "pdd_dimension_key", "pdd_color_value", "context_version", "mapped_by", "mapped_at", + }, + "product_color_mapping_audits": { + "id", "shopee_goods_id", "shopee_color_key", "shopee_color_raw", "pdd_goods_id", + "action", "old_dimension_key", "old_color_value", "new_dimension_key", "new_color_value", + "context_version", "actor_user_id", "created_at", + }, + } { + for _, column := range columns { + exists, err := mysqlColumnExists(db, table, column) + if err != nil || !exists { + return fmt.Errorf("商品颜色映射表 %s 字段 %s 缺失: %v", table, column, err) + } + } + } + for _, item := range []struct{ table, name string }{ + {"product_color_mappings", "PRIMARY"}, + {"product_color_mappings", "idx_product_color_mappings_pdd"}, + {"product_color_mapping_audits", "idx_product_color_mapping_audits_identity"}, + } { + exists, err := mysqlIndexExists(db, item.table, item.name) + if err != nil || !exists { + return fmt.Errorf("商品颜色映射索引 %s.%s 缺失: %v", item.table, item.name, err) + } + } + exists, err := mysqlConstraintExists(db, "product_color_mapping_audits", "chk_product_color_mapping_audit_action") + if err != nil || !exists { + return fmt.Errorf("商品颜色映射审计动作约束缺失: %v", err) + } + return nil } func checkMySQLV29Shape(db *sql.DB) error { diff --git a/admin/repository/mysql_db_integration_test.go b/admin/repository/mysql_db_integration_test.go index 01e0e39..677f9c9 100644 --- a/admin/repository/mysql_db_integration_test.go +++ b/admin/repository/mysql_db_integration_test.go @@ -1282,6 +1282,28 @@ func TestMySQLMigrate_V28升级V29修复原始SKU排序规则(t *testing.T) { } } +func TestMySQLMigrate_V29升级V30建立商品颜色映射与审计(t *testing.T) { + db := openMySQLMigrationTestDB(t) + defer db.Close() + cleanMySQLTestSchema(t, db) + defer cleanMySQLTestSchema(t, db) + if err := MigrateMySQL(db); err != nil { + t.Fatal(err) + } + mustExec(t, db, `DROP TABLE product_color_mapping_audits`) + mustExec(t, db, `DROP TABLE product_color_mappings`) + mustExec(t, db, `DELETE FROM schema_migrations WHERE version=30`) + if err := MigrateMySQL(db); err != nil { + t.Fatalf("v29 升级 v30 失败: %v", err) + } + if err := MigrateMySQL(db); err != nil { + t.Fatalf("v30 重复迁移失败: %v", err) + } + if err := checkMySQLV30Shape(db); err != nil { + t.Fatal(err) + } +} + func TestUpsertInnerCodeImportRow_软删除记录按状态安全恢复(t *testing.T) { db := openMySQLMigrationTestDB(t) defer db.Close() diff --git a/admin/service/color_mapping.go b/admin/service/color_mapping.go new file mode 100644 index 0000000..8308145 --- /dev/null +++ b/admin/service/color_mapping.go @@ -0,0 +1,409 @@ +package service + +import ( + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "fmt" + "sort" + "strings" + "unicode/utf8" + + "cmautobuy/admin/model" + "cmautobuy/admin/repository" + "cmautobuy/admin/spec" +) + +// ProductColorMappingRulesVersion 进入上下文版本,规则变化会让旧页面整体失效。 +const ProductColorMappingRulesVersion = "color-rules-v1" + +// ProductColorSource 汇总同一颜色在两类可靠数据源中的出现次数。 +type ProductColorSource struct { + Key, Raw string + FormalCount, SybCount int +} + +func (s ProductColorSource) SourceText() string { + parts := make([]string, 0, 2) + if s.FormalCount > 0 { + parts = append(parts, fmt.Sprintf("蝦皮 SKU %d", s.FormalCount)) + } + if s.SybCount > 0 { + parts = append(parts, fmt.Sprintf("顺运宝 %d", s.SybCount)) + } + return strings.Join(parts, " · ") +} + +// PddColorCandidate 是当前可购买完整规格组合按颜色聚合后的候选。 +// 金额始终使用整数分;没有可靠价格时 HasPrice=false。 +type PddColorCandidate struct { + DimensionKey string + Value string + MinPriceCent int64 + MaxPriceCent int64 + HasPrice bool + SKUCount int +} + +// ProductColorMappingRow 把颜色来源、当前映射及目标有效性放在同一行。 +type ProductColorMappingRow struct { + Color ProductColorSource + Mapping *model.ProductColorMapping + MappingValid bool +} + +// ProductColorMappingContext 是页面和后续规则复用共同依赖的只读快照。 +type ProductColorMappingContext struct { + ShopeeGoodsID, ShopeeTitle string + PddGoodsID, PddTitle string + PddDimensionKey string + Rows []ProductColorMappingRow + Candidates []PddColorCandidate + ContextVersion string + UnavailableReason string +} + +// ProductColorMappingUpdate 是一次局部保存。TargetValue 为空表示清除当前映射。 +type ProductColorMappingUpdate struct { + ShopeeColorKey string + TargetValue string +} + +// NormalizeProductColorKey 只折叠首尾及连续空白,不做同义词、繁简或大小写推断。 +func NormalizeProductColorKey(raw string) string { + return strings.Join(strings.Fields(raw), " ") +} + +// GetProductColorMappingContext 返回一个商品的当前颜色映射上下文。 +// 商品不存在返回 (nil, nil);未关联、未采集或维度不明确通过 UnavailableReason 表达。 +func GetProductColorMappingContext(db *sql.DB, goodsID string) (*ProductColorMappingContext, error) { + return loadProductColorMappingContext(db, strings.TrimSpace(goodsID)) +} + +func loadProductColorMappingContext(q repository.Execer, goodsID string) (*ProductColorMappingContext, error) { + product, err := repository.GetShopeeProductByGoodsID(q, goodsID) + if err != nil || product == nil { + return nil, err + } + result := &ProductColorMappingContext{ + ShopeeGoodsID: product.GoodsID, ShopeeTitle: product.Title, PddGoodsID: product.PddGoodsID, + } + colors, err := collectProductColors(q, goodsID) + if err != nil { + return nil, err + } + result.Rows = make([]ProductColorMappingRow, len(colors)) + for i, color := range colors { + result.Rows[i].Color = color + } + if product.PddGoodsID == "" { + result.UnavailableReason = "当前蝦皮商品尚未关联 PDD 商品" + result.ContextVersion = colorMappingContextVersion(result, "", "") + return result, nil + } + pdd, err := repository.GetPddProductByGoodsID(q, product.PddGoodsID) + if err != nil { + return nil, err + } + if pdd == nil || pdd.IsDeleted() { + result.UnavailableReason = "当前关联的 PDD 商品不存在或已删除" + result.ContextVersion = colorMappingContextVersion(result, "", "") + return result, nil + } + result.PddTitle = pdd.Title + if pdd.CollectStatus != model.CollectCollected || strings.TrimSpace(pdd.SkusJSON) == "" { + result.UnavailableReason = "当前 PDD 商品尚未完成采集" + result.ContextVersion = colorMappingContextVersion(result, pdd.UpdatedAt, pdd.SkusJSON) + return result, nil + } + dimensionKey, candidates, err := aggregatePddColorCandidates(pdd.SkusJSON) + if err != nil { + result.UnavailableReason = err.Error() + result.ContextVersion = colorMappingContextVersion(result, pdd.UpdatedAt, pdd.SkusJSON) + return result, nil + } + result.PddDimensionKey, result.Candidates = dimensionKey, candidates + mappings, err := repository.ListProductColorMappings(q, goodsID, product.PddGoodsID) + if err != nil { + return nil, err + } + mappingByColor := make(map[string]model.ProductColorMapping, len(mappings)) + for _, mapping := range mappings { + mappingByColor[mapping.ShopeeColorKey] = mapping + } + validTargets := make(map[string]bool, len(candidates)) + for _, candidate := range candidates { + validTargets[candidate.Value] = true + } + for i := range result.Rows { + mapping, ok := mappingByColor[result.Rows[i].Color.Key] + if !ok { + continue + } + copy := mapping + result.Rows[i].Mapping = © + result.Rows[i].MappingValid = mapping.PddDimensionKey == dimensionKey && validTargets[mapping.PddColorValue] + } + result.ContextVersion = colorMappingContextVersion(result, pdd.UpdatedAt, pdd.SkusJSON) + return result, nil +} + +func collectProductColors(q repository.Execer, goodsID string) ([]ProductColorSource, error) { + type accumulator struct { + raw string + formalCount, sybCount int + } + byKey := map[string]*accumulator{} + add := func(raw string, formal bool) { + key := NormalizeProductColorKey(raw) + if key == "" { + return + } + item := byKey[key] + if item == nil { + item = &accumulator{raw: strings.TrimSpace(raw)} + byKey[key] = item + } else if candidate := strings.TrimSpace(raw); candidate < item.raw { + item.raw = candidate + } + if formal { + item.formalCount++ + } else { + item.sybCount++ + } + } + skus, err := repository.ListShopeeSKUsByGoodsID(q, goodsID) + if err != nil { + return nil, err + } + for _, sku := range skus { + add(sku.Color, true) + } + observations, err := repository.ListSybSpecObservations(q, goodsID) + if err != nil { + return nil, err + } + for _, observation := range observations { + if parsed, ok := spec.ParseShopeeSpec(observation.SpecRaw); ok { + add(parsed.Color, false) + } + } + result := make([]ProductColorSource, 0, len(byKey)) + for key, item := range byKey { + result = append(result, ProductColorSource{ + Key: key, Raw: item.raw, FormalCount: item.formalCount, SybCount: item.sybCount, + }) + } + sort.Slice(result, func(i, j int) bool { return result[i].Key < result[j].Key }) + return result, nil +} + +func aggregatePddColorCandidates(raw string) (string, []PddColorCandidate, error) { + collected, err := parseCollected(raw) + if err != nil { + return "", nil, fmt.Errorf("PDD 采集规格无法解析:%w", err) + } + colorKeys := map[string]bool{} + for _, dimension := range collected.Dimensions { + if isExplicitPddColorDimension(dimension.Key) || isExplicitPddColorDimension(dimension.Name) { + colorKeys[dimension.Key] = true + } + } + if len(colorKeys) == 0 { + for _, sku := range collected.SKUs { + for key := range sku.Options { + if isExplicitPddColorDimension(key) { + colorKeys[key] = true + } + } + } + } + if len(colorKeys) != 1 { + return "", nil, fmt.Errorf("PDD 颜色维度不明确:识别到 %d 个候选维度", len(colorKeys)) + } + var dimensionKey string + for key := range colorKeys { + dimensionKey = key + } + byValue := map[string]*PddColorCandidate{} + for _, sku := range collected.SKUs { + if !sku.Available || len(sku.Options) == 0 { + continue + } + value := strings.TrimSpace(sku.Options[dimensionKey]) + if value == "" { + return "", nil, fmt.Errorf("可购买规格组合缺少颜色维度 %s", dimensionKey) + } + item := byValue[value] + if item == nil { + item = &PddColorCandidate{DimensionKey: dimensionKey, Value: value} + byValue[value] = item + } + item.SKUCount++ + if sku.PriceCent != nil && *sku.PriceCent > 0 { + if !item.HasPrice || *sku.PriceCent < item.MinPriceCent { + item.MinPriceCent = *sku.PriceCent + } + if !item.HasPrice || *sku.PriceCent > item.MaxPriceCent { + item.MaxPriceCent = *sku.PriceCent + } + item.HasPrice = true + } + } + if len(byValue) == 0 { + return "", nil, fmt.Errorf("PDD 最新采集结果没有可购买的颜色候选") + } + result := make([]PddColorCandidate, 0, len(byValue)) + for _, item := range byValue { + result = append(result, *item) + } + sort.Slice(result, func(i, j int) bool { return result[i].Value < result[j].Value }) + return dimensionKey, result, nil +} + +func isExplicitPddColorDimension(raw string) bool { + value := strings.ToLower(strings.Join(strings.Fields(strings.TrimSpace(raw)), "")) + switch value { + case "color", "colour", "color_family", "颜色", "颜色分类", "颜色選擇", "颜色选择", + "顏色", "顏色分類", "顏色選擇": + return true + default: + return false + } +} + +func colorMappingContextVersion(context *ProductColorMappingContext, pddUpdatedAt, pddSKUsJSON string) string { + type contextColor struct { + Key, Raw string + FormalCount, SybCount int + } + colors := make([]contextColor, 0, len(context.Rows)) + for _, row := range context.Rows { + colors = append(colors, contextColor{row.Color.Key, row.Color.Raw, row.Color.FormalCount, row.Color.SybCount}) + } + pddHash := sha256.Sum256([]byte(pddSKUsJSON)) + payload := struct { + RulesVersion string + ShopeeID string + PddID string + PddUpdatedAt string + PddHash string + Colors []contextColor + }{ProductColorMappingRulesVersion, context.ShopeeGoodsID, context.PddGoodsID, pddUpdatedAt, hex.EncodeToString(pddHash[:]), colors} + raw, _ := json.Marshal(payload) + sum := sha256.Sum256(raw) + return hex.EncodeToString(sum[:]) +} + +// SaveProductColorMappings 在一个事务中校验完整上下文,并保存本次变化的颜色。 +// 任何一行过期或无效都会使整批回滚。 +func SaveProductColorMappings(db *sql.DB, actor *model.User, goodsID, expectedContextVersion string, updates []ProductColorMappingUpdate) (int, error) { + if actor == nil { + return 0, ErrUnauthenticated + } + if actor.Status != model.UserActive { + return 0, invalidInput("当前账号不是正常状态,不能保存颜色映射") + } + if len(updates) == 0 { + return 0, invalidInput("没有需要保存的颜色变化") + } + tx, err := db.Begin() + if err != nil { + return 0, fmt.Errorf("开始保存颜色映射事务失败: %w", err) + } + defer tx.Rollback() + context, err := loadProductColorMappingContext(tx, strings.TrimSpace(goodsID)) + if err != nil { + return 0, err + } + if context == nil { + return 0, invalidInput("蝦皮商品不存在或已删除") + } + if context.UnavailableReason != "" { + return 0, invalidInput(context.UnavailableReason) + } + if expectedContextVersion == "" || context.ContextVersion != strings.TrimSpace(expectedContextVersion) { + return 0, invalidInput("商品关联、规格或颜色来源已变化,请刷新后重新核对") + } + colors := make(map[string]ProductColorSource, len(context.Rows)) + for _, row := range context.Rows { + colors[row.Color.Key] = row.Color + } + targets := make(map[string]bool, len(context.Candidates)) + for _, candidate := range context.Candidates { + targets[candidate.Value] = true + } + seen := map[string]bool{} + changed, now := 0, model.NowISO() + for _, update := range updates { + colorKey := NormalizeProductColorKey(update.ShopeeColorKey) + if colorKey == "" || seen[colorKey] { + return 0, invalidInput("颜色变化中包含空值或重复项") + } + seen[colorKey] = true + color, ok := colors[colorKey] + if !ok { + return 0, invalidInput("蝦皮颜色已不存在,请刷新后重试") + } + old, err := repository.GetProductColorMapping(tx, context.ShopeeGoodsID, colorKey, context.PddGoodsID) + if err != nil { + return 0, err + } + target := strings.TrimSpace(update.TargetValue) + if target == "" { + if old == nil { + continue + } + if _, err := repository.DeleteProductColorMapping(tx, context.ShopeeGoodsID, colorKey, context.PddGoodsID); err != nil { + return 0, err + } + if err := repository.InsertProductColorMappingAudit(tx, model.ProductColorMappingAudit{ + ShopeeGoodsID: context.ShopeeGoodsID, ShopeeColorKey: colorKey, ShopeeColorRaw: color.Raw, + PddGoodsID: context.PddGoodsID, Action: "clear", OldDimensionKey: old.PddDimensionKey, + OldColorValue: old.PddColorValue, ContextVersion: context.ContextVersion, + ActorUserID: actor.UserID, CreatedAt: now, + }); err != nil { + return 0, err + } + changed++ + continue + } + if !targets[target] { + return 0, invalidInput("所选 PDD 颜色已不存在或不可购买,请刷新后重试") + } + if utf8.RuneCountInString(colorKey) > 191 || utf8.RuneCountInString(color.Raw) > 500 || + utf8.RuneCountInString(context.PddDimensionKey) > 191 || utf8.RuneCountInString(target) > 500 { + return 0, invalidInput("颜色名称超过数据库允许长度,未保存") + } + if old != nil && old.PddDimensionKey == context.PddDimensionKey && old.PddColorValue == target { + continue + } + item := model.ProductColorMapping{ + ShopeeGoodsID: context.ShopeeGoodsID, ShopeeColorKey: colorKey, ShopeeColorRaw: color.Raw, + PddGoodsID: context.PddGoodsID, PddDimensionKey: context.PddDimensionKey, + PddColorValue: target, ContextVersion: context.ContextVersion, + MappedBy: actor.UserID, MappedAt: now, + } + if err := repository.UpsertProductColorMapping(tx, item); err != nil { + return 0, err + } + audit := model.ProductColorMappingAudit{ + ShopeeGoodsID: context.ShopeeGoodsID, ShopeeColorKey: colorKey, ShopeeColorRaw: color.Raw, + PddGoodsID: context.PddGoodsID, Action: "upsert", NewDimensionKey: item.PddDimensionKey, + NewColorValue: item.PddColorValue, ContextVersion: context.ContextVersion, + ActorUserID: actor.UserID, CreatedAt: now, + } + if old != nil { + audit.OldDimensionKey, audit.OldColorValue = old.PddDimensionKey, old.PddColorValue + } + if err := repository.InsertProductColorMappingAudit(tx, audit); err != nil { + return 0, err + } + changed++ + } + if err := tx.Commit(); err != nil { + return 0, fmt.Errorf("提交颜色映射事务失败: %w", err) + } + return changed, nil +} diff --git a/admin/service/color_mapping_test.go b/admin/service/color_mapping_test.go new file mode 100644 index 0000000..f300bdc --- /dev/null +++ b/admin/service/color_mapping_test.go @@ -0,0 +1,87 @@ +package service + +import ( + "testing" + + "cmautobuy/admin/model" + "cmautobuy/admin/repository" +) + +func TestNormalizeProductColorKey_只折叠空白(t *testing.T) { + if got := NormalizeProductColorKey(" 寵粉\t誘惑-E01 "); got != "寵粉 誘惑-E01" { + t.Fatalf("折叠空白结果=%q", got) + } + if got := NormalizeProductColorKey("寵粉誘惑-E01"); got != "寵粉誘惑-E01" { + t.Fatalf("不应做繁简或同义词转换,实际=%q", got) + } +} + +func TestAggregatePddColorCandidates_只统计可购买组合和整数价格区间(t *testing.T) { + raw := `{"dimensions":[{"key":"color","name":"颜色分类"},{"key":"size","name":"尺码"}],"skus":[` + + `{"options":{"color":"黑色","size":"M"},"price_cent":1180,"available":true},` + + `{"options":{"color":"黑色","size":"L"},"price_cent":1280,"available":true},` + + `{"options":{"color":"白色","size":"M"},"price_cent":999,"available":false}]}` + key, candidates, err := aggregatePddColorCandidates(raw) + if err != nil { + t.Fatal(err) + } + if key != "color" || len(candidates) != 1 { + t.Fatalf("颜色维度或候选错误 key=%q candidates=%+v", key, candidates) + } + got := candidates[0] + if got.Value != "黑色" || got.SKUCount != 2 || !got.HasPrice || got.MinPriceCent != 1180 || got.MaxPriceCent != 1280 { + t.Fatalf("颜色聚合错误:%+v", got) + } +} + +func TestAggregatePddColorCandidates_拒绝多个显式颜色维度(t *testing.T) { + raw := `{"dimensions":[{"key":"color","name":"颜色"},{"key":"style","name":"顏色分類"}],` + + `"skus":[{"options":{"color":"黑色","style":"标准"},"available":true}]}` + if _, _, err := aggregatePddColorCandidates(raw); err == nil { + t.Fatal("多个显式颜色维度必须阻止自动选择") + } +} + +func TestSaveProductColorMappings_保存审计并用上下文版本防过期(t *testing.T) { + db := newTestDB(t) + seedShopeeProduct(t, db, "S-COLOR", "颜色映射商品") + seedShopeeSKU(t, db, "SKU-COLOR", "S-COLOR", "黑色,M", "黑色", "M", "", true) + if _, err := repository.EnsurePddProduct(db, "P-COLOR", "https://mobile.yangkeduo.com/goods.html?goods_id=P-COLOR"); err != nil { + t.Fatal(err) + } + raw := `{"dimensions":[{"key":"color","name":"颜色分类"},{"key":"size","name":"尺码"}],` + + `"skus":[{"options":{"color":"曜石黑","size":"M"},"price_cent":1180,"available":true}]}` + if _, err := db.Exec(`UPDATE pdd_products SET collect_status='collected',skus_json=?,updated_at=? WHERE goods_id='P-COLOR'`, raw, "2026-08-23T01:00:00Z"); err != nil { + t.Fatal(err) + } + setShopeePddLink(t, db, "S-COLOR", "P-COLOR", "https://mobile.yangkeduo.com/goods.html?goods_id=P-COLOR") + + context, err := GetProductColorMappingContext(db, "S-COLOR") + if err != nil { + t.Fatal(err) + } + if context.UnavailableReason != "" || len(context.Rows) != 1 || len(context.Candidates) != 1 { + t.Fatalf("颜色上下文不完整:%+v", context) + } + actor := &model.User{UserID: "U-COLOR", Status: model.UserActive} + changed, err := SaveProductColorMappings(db, actor, "S-COLOR", context.ContextVersion, + []ProductColorMappingUpdate{{ShopeeColorKey: "黑色", TargetValue: "曜石黑"}}) + if err != nil || changed != 1 { + t.Fatalf("保存颜色映射 changed=%d err=%v", changed, err) + } + mapping, err := repository.GetProductColorMapping(db, "S-COLOR", "黑色", "P-COLOR") + if err != nil || mapping == nil || mapping.PddColorValue != "曜石黑" || mapping.MappedBy != actor.UserID { + t.Fatalf("颜色映射读取错误 mapping=%+v err=%v", mapping, err) + } + var audits int + if err := db.QueryRow(`SELECT COUNT(*) FROM product_color_mapping_audits WHERE shopee_goods_id='S-COLOR'`).Scan(&audits); err != nil || audits != 1 { + t.Fatalf("审计记录=%d err=%v", audits, err) + } + if _, err := db.Exec(`UPDATE pdd_products SET updated_at=? WHERE goods_id='P-COLOR'`, "2026-08-23T02:00:00Z"); err != nil { + t.Fatal(err) + } + if _, err := SaveProductColorMappings(db, actor, "S-COLOR", context.ContextVersion, + []ProductColorMappingUpdate{{ShopeeColorKey: "黑色"}}); err == nil { + t.Fatal("旧上下文版本必须拒绝整批保存") + } +} diff --git a/docs/admin/03-data-model.md b/docs/admin/03-data-model.md index 3fab05b..7db7e62 100644 --- a/docs/admin/03-data-model.md +++ b/docs/admin/03-data-model.md @@ -1163,3 +1163,23 @@ CREATE TABLE purchase_spec_resolutions ( - `original_options_json`、`candidates_json` 和 `resolved_options_json` 只保存接口定义的 颜色/尺码字符串。不得保存原始无障碍 XML、截图、订单号、收货信息、Cookie、Token、 密码或 API Key;时间统一保存为 UTC ISO 8601。 + +## 19. 商品级颜色映射与审计(MySQL v30) + +`product_color_mappings` 保存一个蝦皮商品颜色到当前 PDD 商品颜色维度值的人工映射。 +主键是 `(shopee_goods_id, shopee_color_key, pdd_goods_id)`:PDD 商品更换后,旧商品映射 +仍留库备查,但当前页面和采购链路只读取当前关联商品的映射,避免静默套用。 + +- `shopee_color_key` 只折叠首尾和连续空白,不做同义词、繁简或大小写猜测; + `shopee_color_raw` 保留用于人工核对的来源文字。 +- PDD 颜色维度只能由采集结果中唯一的显式颜色维度确定,目标值只能来自当前 + `available=true` 的完整规格组合。候选价格区间以整数分聚合。 +- `context_version` 同时包含 PDD 关联、PDD 采集快照、蝦皮颜色集合和规则版本;保存任一行前 + 都要在事务中重算,批次内任何上下文过期都会整体拒绝。 +- 当前映射的有效性由最新候选动态判断。PDD 重采集后目标消失时保留旧映射并显示失效, + 不自动改成另一个相似颜色。 +- 本表只建立颜色层的人工关系,不直接表示可采购。采购仍必须得到唯一、完整且当前可购买的 + `spec_mappings` 规格组合。 + +`product_color_mapping_audits` 是只追加审计表,记录 `upsert/clear` 的旧值、新值、操作账号、 +上下文版本和时间。清除操作只删除当前态,不删除审计;两张表都不保存提示词、模型响应或凭据。