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
+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()