feat: 建立商品颜色映射数据模型 (#293)

This commit is contained in:
chengma
2026-08-23 23:55:33 +08:00
parent 4ffa4e070a
commit f06ded8a0a
7 changed files with 776 additions and 2 deletions
+34
View File
@@ -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
+104
View File
@@ -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
}
+100 -2
View File
@@ -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()
+409
View File
@@ -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 = &copy
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
}
+87
View File
@@ -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("旧上下文版本必须拒绝整批保存")
}
}
+20
View File
@@ -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` 的旧值、新值、操作账号、
上下文版本和时间。清除操作只删除当前态,不删除审计;两张表都不保存提示词、模型响应或凭据。