feat: 建立商品颜色映射数据模型 (#293)
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user