feat: 建立商品颜色映射数据模型 (#293)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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("旧上下文版本必须拒绝整批保存")
|
||||
}
|
||||
}
|
||||
@@ -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` 的旧值、新值、操作账号、
|
||||
上下文版本和时间。清除操作只删除当前态,不删除审计;两张表都不保存提示词、模型响应或凭据。
|
||||
|
||||
Reference in New Issue
Block a user