feat: 重构顺运宝规格主链路 (#88)
This commit is contained in:
+19
-19
@@ -8,18 +8,18 @@ import (
|
||||
"cmautobuy/admin/model"
|
||||
)
|
||||
|
||||
// GetSKUMapping 读取某个蝦皮 SKU 在指定 PDD 商品下的映射。
|
||||
func GetSKUMapping(q Execer, shopeeSKUID, pddGoodsID string) (*model.SKUMapping, error) {
|
||||
var m model.SKUMapping
|
||||
// GetSpecMapping 读取某个蝦皮商品的顺运宝规格在指定 PDD 商品下的映射。
|
||||
func GetSpecMapping(q Execer, shopeeGoodsID, specKey, pddGoodsID string) (*model.SpecMapping, error) {
|
||||
var m model.SpecMapping
|
||||
var mappedBy sql.NullString
|
||||
err := q.QueryRow(`
|
||||
SELECT shopee_sku_id, pdd_goods_id, pdd_option_key, pdd_options,
|
||||
goods_id, mapped_at, mapped_by
|
||||
FROM sku_mappings
|
||||
WHERE shopee_sku_id = ? AND pdd_goods_id = ?`,
|
||||
shopeeSKUID, pddGoodsID).Scan(
|
||||
&m.ShopeeSKUID, &m.PddGoodsID, &m.PddOptionKey, &m.PddOptions,
|
||||
&m.GoodsID, &m.MappedAt, &mappedBy)
|
||||
SELECT shopee_goods_id, spec_key, pdd_goods_id, pdd_option_key,
|
||||
pdd_options, spec_raw, mapped_at, mapped_by
|
||||
FROM spec_mappings
|
||||
WHERE shopee_goods_id = ? AND spec_key = ? AND pdd_goods_id = ?`,
|
||||
shopeeGoodsID, specKey, pddGoodsID).Scan(
|
||||
&m.ShopeeGoodsID, &m.SpecKey, &m.PddGoodsID, &m.PddOptionKey,
|
||||
&m.PddOptions, &m.SpecRaw, &m.MappedAt, &mappedBy)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -30,21 +30,21 @@ func GetSKUMapping(q Execer, shopeeSKUID, pddGoodsID string) (*model.SKUMapping,
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
// UpsertSKUMapping 保存可复用规格映射,唯一键包含当前 PDD 商品。
|
||||
func UpsertSKUMapping(q Execer, m model.SKUMapping) error {
|
||||
// UpsertSpecMapping 保存可复用规格映射,唯一键包含当前 PDD 商品。
|
||||
func UpsertSpecMapping(q Execer, m model.SpecMapping) error {
|
||||
_, err := q.Exec(`
|
||||
INSERT INTO sku_mappings
|
||||
(shopee_sku_id, pdd_goods_id, pdd_option_key, pdd_options,
|
||||
goods_id, mapped_at, mapped_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO spec_mappings
|
||||
(shopee_goods_id, spec_key, pdd_goods_id, pdd_option_key,
|
||||
pdd_options, spec_raw, mapped_at, mapped_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
pdd_option_key = VALUES(pdd_option_key),
|
||||
pdd_options = VALUES(pdd_options),
|
||||
goods_id = VALUES(goods_id),
|
||||
spec_raw = VALUES(spec_raw),
|
||||
mapped_at = VALUES(mapped_at),
|
||||
mapped_by = VALUES(mapped_by)`,
|
||||
m.ShopeeSKUID, m.PddGoodsID, m.PddOptionKey, m.PddOptions,
|
||||
m.GoodsID, m.MappedAt, nullableText(m.MappedBy))
|
||||
m.ShopeeGoodsID, m.SpecKey, m.PddGoodsID, m.PddOptionKey,
|
||||
m.PddOptions, m.SpecRaw, m.MappedAt, nullableText(m.MappedBy))
|
||||
if err != nil {
|
||||
return fmt.Errorf("保存规格映射失败: %w", err)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"crypto/x509"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -14,9 +15,11 @@ import (
|
||||
"github.com/go-sql-driver/mysql"
|
||||
|
||||
"cmautobuy/admin/config"
|
||||
"cmautobuy/admin/model"
|
||||
"cmautobuy/admin/spec"
|
||||
)
|
||||
|
||||
const mysqlSchemaVersion = 2
|
||||
const mysqlSchemaVersion = 3
|
||||
|
||||
// OpenMySQL 打开生产 MySQL 8 数据库。错误信息绝不包含完整 DSN 或密码。
|
||||
func OpenMySQL(cfg config.DatabaseConfig) (*sql.DB, error) {
|
||||
@@ -370,6 +373,20 @@ var mysqlSchemaV2 = []string{
|
||||
ON DUPLICATE KEY UPDATE id = VALUES(id)`,
|
||||
}
|
||||
|
||||
const mysqlSchemaV3SpecMappings = `CREATE TABLE IF NOT EXISTS spec_mappings (
|
||||
shopee_goods_id VARCHAR(191) COLLATE utf8mb4_bin NOT NULL,
|
||||
spec_key VARCHAR(191) COLLATE utf8mb4_bin NOT NULL,
|
||||
pdd_goods_id VARCHAR(191) COLLATE utf8mb4_bin NOT NULL,
|
||||
pdd_option_key VARCHAR(191) COLLATE utf8mb4_bin NOT NULL,
|
||||
pdd_options LONGTEXT NOT NULL,
|
||||
spec_raw TEXT NOT NULL,
|
||||
mapped_at VARCHAR(35) NOT NULL,
|
||||
mapped_by VARCHAR(191),
|
||||
PRIMARY KEY (shopee_goods_id, spec_key, pdd_goods_id),
|
||||
KEY idx_spec_mappings_goods (shopee_goods_id),
|
||||
KEY idx_spec_mappings_pdd (pdd_goods_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`
|
||||
|
||||
// MigrateMySQL 建立或升级 MySQL schema。生产迁移只能在这里追加新版本。
|
||||
func MigrateMySQL(db *sql.DB) error {
|
||||
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
@@ -406,21 +423,274 @@ func MigrateMySQL(db *sql.DB) error {
|
||||
return fmt.Errorf("执行 MySQL schema v2 第 %d 条失败: %w", i+1, err)
|
||||
}
|
||||
}
|
||||
if err := CheckMySQLSchema(db); err != nil {
|
||||
mysqlRequiredTablesV2 := append(append([]string{}, requiredTables...), "admin_initialization_lock")
|
||||
if err := checkMySQLSchema(db, mysqlRequiredTablesV2); err != nil {
|
||||
return fmt.Errorf("MySQL schema v2 自检失败,未记录版本: %w", err)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`,
|
||||
2, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
|
||||
return fmt.Errorf("记录 MySQL schema v2 失败: %w", err)
|
||||
}
|
||||
current = 2
|
||||
}
|
||||
if current < 3 {
|
||||
if err := migrateMySQLV3(db); err != nil {
|
||||
return fmt.Errorf("执行 MySQL schema v3 失败: %w", err)
|
||||
}
|
||||
if err := CheckMySQLSchema(db); err != nil {
|
||||
return fmt.Errorf("MySQL schema v3 自检失败,未记录版本: %w", err)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`,
|
||||
3, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
|
||||
return fmt.Errorf("记录 MySQL schema v3 失败: %w", err)
|
||||
}
|
||||
}
|
||||
return CheckMySQLSchema(db)
|
||||
}
|
||||
|
||||
// CheckMySQLSchema 确认所有业务表和关键追加列存在。
|
||||
// migrateMySQLV3 把采购规格身份从蝦皮 SKU 改为顺运宝商品规格原文。
|
||||
// 每一步都可重放:DDL 已提交但版本尚未记录时,再启动仍会收敛。
|
||||
func migrateMySQLV3(db *sql.DB) error {
|
||||
if err := ensureMySQLV3Columns(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(mysqlSchemaV3SpecMappings); err != nil {
|
||||
return fmt.Errorf("建立 spec_mappings 失败: %w", err)
|
||||
}
|
||||
if err := backfillSybSpecKeys(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureSybShopeeSkeletons(db); err != nil {
|
||||
return err
|
||||
}
|
||||
return convertLegacySKUMappings(db)
|
||||
}
|
||||
|
||||
func ensureMySQLV3Columns(db *sql.DB) error {
|
||||
exists, err := mysqlColumnExists(db, "syb_orders", "spec_key")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
if _, err := db.Exec(`ALTER TABLE syb_orders ADD COLUMN spec_key VARCHAR(191) COLLATE utf8mb4_bin NULL AFTER product_spec`); err != nil {
|
||||
return fmt.Errorf("增加 syb_orders.spec_key 失败: %w", err)
|
||||
}
|
||||
} else if _, err := db.Exec(`ALTER TABLE syb_orders MODIFY COLUMN spec_key VARCHAR(191) COLLATE utf8mb4_bin NULL`); err != nil {
|
||||
return fmt.Errorf("校正 syb_orders.spec_key 结构失败: %w", err)
|
||||
}
|
||||
|
||||
exists, err = mysqlColumnExists(db, "shopee_products", "source")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
if _, err := db.Exec(`ALTER TABLE shopee_products ADD COLUMN source VARCHAR(16) COLLATE utf8mb4_bin NOT NULL DEFAULT 'report' AFTER main_sku_code`); err != nil {
|
||||
return fmt.Errorf("增加 shopee_products.source 失败: %w", err)
|
||||
}
|
||||
} else {
|
||||
// 兼容“列已加但还是可空”的 DDL 中断点。先回填旧行,
|
||||
// 再收紧 NOT NULL,否则带 NULL 的存量数据会让 ALTER 失败。
|
||||
if _, err := db.Exec(`UPDATE shopee_products SET source='report' WHERE source IS NULL OR source=''`); err != nil {
|
||||
return fmt.Errorf("回填 shopee_products.source 失败: %w", err)
|
||||
}
|
||||
if _, err := db.Exec(`ALTER TABLE shopee_products MODIFY COLUMN source VARCHAR(16) COLLATE utf8mb4_bin NOT NULL DEFAULT 'report'`); err != nil {
|
||||
return fmt.Errorf("校正 shopee_products.source 结构失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
constraintExists, err := mysqlConstraintExists(db, "shopee_products", "chk_shopee_products_source")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !constraintExists {
|
||||
if _, err := db.Exec(`ALTER TABLE shopee_products ADD CONSTRAINT chk_shopee_products_source CHECK (source IN ('report','syb'))`); err != nil {
|
||||
return fmt.Errorf("增加 shopee_products.source CHECK 失败: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mysqlColumnExists(db *sql.DB, table, column string) (bool, error) {
|
||||
var count int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema=DATABASE() AND table_name=? AND column_name=?`, table, column).Scan(&count); err != nil {
|
||||
return false, fmt.Errorf("检查 MySQL 数据表 %s.%s 失败: %w", table, column, err)
|
||||
}
|
||||
return count == 1, nil
|
||||
}
|
||||
|
||||
func mysqlConstraintExists(db *sql.DB, table, constraint string) (bool, error) {
|
||||
var count int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM information_schema.table_constraints
|
||||
WHERE constraint_schema=DATABASE() AND table_name=? AND constraint_name=?`, table, constraint).Scan(&count); err != nil {
|
||||
return false, fmt.Errorf("检查 MySQL 约束 %s.%s 失败: %w", table, constraint, err)
|
||||
}
|
||||
return count == 1, nil
|
||||
}
|
||||
|
||||
func backfillSybSpecKeys(db *sql.DB) error {
|
||||
const batchSize = 500
|
||||
cursor := ""
|
||||
for {
|
||||
rows, err := db.Query(`SELECT syb_id, product_spec FROM syb_orders
|
||||
WHERE syb_id > ? AND spec_key IS NULL AND product_spec IS NOT NULL
|
||||
AND TRIM(product_spec) <> '' ORDER BY syb_id LIMIT ?`, cursor, batchSize)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取待回填顺运宝规格失败: %w", err)
|
||||
}
|
||||
type item struct{ id, raw, key string }
|
||||
items := make([]item, 0, batchSize)
|
||||
for rows.Next() {
|
||||
var value item
|
||||
if err := rows.Scan(&value.id, &value.raw); err != nil {
|
||||
rows.Close()
|
||||
return fmt.Errorf("读取待回填顺运宝规格失败: %w", err)
|
||||
}
|
||||
value.key, err = spec.SpecKey(value.raw)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return fmt.Errorf("顺运宝明细 %s 的规格不能生成身份键: %w", value.id, err)
|
||||
}
|
||||
items = append(items, value)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return fmt.Errorf("关闭顺运宝规格回填结果失败: %w", err)
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("开始顺运宝规格回填事务失败: %w", err)
|
||||
}
|
||||
for _, value := range items {
|
||||
if _, err := tx.Exec(`UPDATE syb_orders SET spec_key=? WHERE syb_id=? AND spec_key IS NULL`, value.key, value.id); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("回填顺运宝明细 %s 的规格键失败: %w", value.id, err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("提交顺运宝规格回填失败: %w", err)
|
||||
}
|
||||
cursor = items[len(items)-1].id
|
||||
}
|
||||
}
|
||||
|
||||
func ensureSybShopeeSkeletons(db *sql.DB) error {
|
||||
now := model.NowISO()
|
||||
_, err := db.Exec(`INSERT INTO shopee_products
|
||||
(goods_id, title, source, created_at, updated_at)
|
||||
SELECT shopee_goods_id, MAX(COALESCE(title,'')), 'syb', ?, ?
|
||||
FROM syb_orders
|
||||
WHERE shopee_goods_id IS NOT NULL AND shopee_goods_id <> ''
|
||||
GROUP BY shopee_goods_id
|
||||
ON DUPLICATE KEY UPDATE goods_id=VALUES(goods_id)`, now, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("按顺运宝数据补建蝦皮商品骨架失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func convertLegacySKUMappings(db *sql.DB) error {
|
||||
exists, err := mysqlTableExists(db, "sku_mappings")
|
||||
if err != nil || !exists {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS sku_mappings_v3_backup LIKE sku_mappings`); err != nil {
|
||||
return fmt.Errorf("建立旧规格映射备份表失败: %w", err)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT IGNORE INTO sku_mappings_v3_backup SELECT * FROM sku_mappings`); err != nil {
|
||||
return fmt.Errorf("备份旧规格映射失败: %w", err)
|
||||
}
|
||||
|
||||
rows, err := db.Query(`SELECT m.shopee_sku_id, m.pdd_goods_id, m.pdd_option_key,
|
||||
m.pdd_options, m.mapped_at, m.mapped_by, sk.goods_id, sk.spec_raw
|
||||
FROM sku_mappings m LEFT JOIN shopee_skus sk ON sk.sku_id=m.shopee_sku_id`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取旧规格映射失败: %w", err)
|
||||
}
|
||||
type legacy struct {
|
||||
skuID, pddID, optionKey, options, mappedAt string
|
||||
mappedBy, goodsID, raw sql.NullString
|
||||
}
|
||||
var records []legacy
|
||||
for rows.Next() {
|
||||
var value legacy
|
||||
if err := rows.Scan(&value.skuID, &value.pddID, &value.optionKey, &value.options,
|
||||
&value.mappedAt, &value.mappedBy, &value.goodsID, &value.raw); err != nil {
|
||||
rows.Close()
|
||||
return fmt.Errorf("读取旧规格映射失败: %w", err)
|
||||
}
|
||||
records = append(records, value)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return fmt.Errorf("关闭旧规格映射结果失败: %w", err)
|
||||
}
|
||||
|
||||
matched, noCurrentMatch, unable := 0, 0, 0
|
||||
for _, value := range records {
|
||||
if !value.goodsID.Valid || !value.raw.Valid {
|
||||
unable++
|
||||
log.Printf("mysql_v3_mapping_unconvertible shopee_sku_id=%s pdd_goods_id=%s", value.skuID, value.pddID)
|
||||
continue
|
||||
}
|
||||
key, err := spec.SpecKey(value.raw.String)
|
||||
if err != nil {
|
||||
unable++
|
||||
log.Printf("mysql_v3_mapping_unconvertible shopee_sku_id=%s pdd_goods_id=%s reason=invalid_spec_key", value.skuID, value.pddID)
|
||||
continue
|
||||
}
|
||||
if err := UpsertSpecMapping(db, model.SpecMapping{
|
||||
ShopeeGoodsID: value.goodsID.String, SpecKey: key, SpecRaw: value.raw.String,
|
||||
PddGoodsID: value.pddID, PddOptionKey: value.optionKey, PddOptions: value.options,
|
||||
MappedAt: value.mappedAt, MappedBy: value.mappedBy.String,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("转换旧规格映射 %s 失败: %w", value.skuID, err)
|
||||
}
|
||||
var hit int
|
||||
if err := db.QueryRow(`SELECT EXISTS(SELECT 1 FROM syb_orders WHERE shopee_goods_id=? AND spec_key=?)`,
|
||||
value.goodsID.String, key).Scan(&hit); err != nil {
|
||||
return fmt.Errorf("验证旧规格映射 %s 命中失败: %w", value.skuID, err)
|
||||
}
|
||||
if hit == 1 {
|
||||
matched++
|
||||
} else {
|
||||
noCurrentMatch++
|
||||
log.Printf("mysql_v3_mapping_no_current_hit shopee_sku_id=%s pdd_goods_id=%s", value.skuID, value.pddID)
|
||||
}
|
||||
}
|
||||
if matched+noCurrentMatch+unable != len(records) {
|
||||
return fmt.Errorf("旧规格映射迁移计数不守恒: 总数=%d 命中=%d 无命中=%d 无法转换=%d",
|
||||
len(records), matched, noCurrentMatch, unable)
|
||||
}
|
||||
log.Printf("mysql_v3_mapping_summary total=%d matched=%d no_current_hit=%d unconvertible=%d backup_table=sku_mappings_v3_backup",
|
||||
len(records), matched, noCurrentMatch, unable)
|
||||
if _, err := db.Exec(`DROP TABLE sku_mappings`); err != nil {
|
||||
return fmt.Errorf("删除已备份的旧规格映射表失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mysqlTableExists(db *sql.DB, table string) (bool, error) {
|
||||
var count int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM information_schema.tables
|
||||
WHERE table_schema=DATABASE() AND table_name=? AND table_type='BASE TABLE'`, table).Scan(&count); err != nil {
|
||||
return false, fmt.Errorf("检查 MySQL 数据表 %s 失败: %w", table, err)
|
||||
}
|
||||
return count == 1, nil
|
||||
}
|
||||
|
||||
// CheckMySQLSchema 确认所有业务表、关键追加列和采购身份结构完整。
|
||||
func CheckMySQLSchema(db *sql.DB) error {
|
||||
mysqlRequiredTables := append(append([]string{}, requiredTables...), "admin_initialization_lock")
|
||||
return checkMySQLSchema(db, mysqlRequiredTables)
|
||||
mysqlRequiredTables := []string{
|
||||
"shopee_products", "shopee_skus", "pdd_products", "syb_orders", "spec_mappings",
|
||||
"tasks", "clients", "idempotency_keys", "task_claims", "syb_session", "syb_sync_state",
|
||||
"users", "web_sessions", "client_user_assignments", "syb_sync_runs", "admin_initialization_lock",
|
||||
}
|
||||
if err := checkMySQLSchema(db, mysqlRequiredTables); err != nil {
|
||||
return err
|
||||
}
|
||||
return checkMySQLV3Shape(db)
|
||||
}
|
||||
|
||||
func checkMySQLSchema(db *sql.DB, tables []string) error {
|
||||
@@ -465,3 +735,76 @@ func checkMySQLSchema(db *sql.DB, tables []string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkMySQLV3Shape(db *sql.DB) error {
|
||||
rows, err := db.Query(`SELECT column_name FROM information_schema.key_column_usage
|
||||
WHERE table_schema=DATABASE() AND table_name='spec_mappings'
|
||||
AND constraint_name='PRIMARY' ORDER BY ordinal_position`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("检查 spec_mappings 主键失败: %w", err)
|
||||
}
|
||||
var primary []string
|
||||
for rows.Next() {
|
||||
var column string
|
||||
if err := rows.Scan(&column); err != nil {
|
||||
rows.Close()
|
||||
return fmt.Errorf("检查 spec_mappings 主键失败: %w", err)
|
||||
}
|
||||
primary = append(primary, column)
|
||||
}
|
||||
rows.Close()
|
||||
if strings.Join(primary, ",") != "shopee_goods_id,spec_key,pdd_goods_id" {
|
||||
return fmt.Errorf("spec_mappings 主键不正确,实际为 (%s)", strings.Join(primary, ","))
|
||||
}
|
||||
for _, column := range []string{"shopee_goods_id", "spec_key", "pdd_goods_id"} {
|
||||
if err := checkMySQLVarcharColumn(db, "spec_mappings", column, 191, false, "utf8mb4_bin", ""); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := checkMySQLVarcharColumn(db, "syb_orders", "spec_key", 191, true, "utf8mb4_bin", ""); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkMySQLVarcharColumn(db, "shopee_products", "source", 16, false, "utf8mb4_bin", "report"); err != nil {
|
||||
return err
|
||||
}
|
||||
var enforced, checkClause string
|
||||
if err := db.QueryRow(`SELECT tc.enforced, cc.check_clause
|
||||
FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.check_constraints cc
|
||||
ON cc.constraint_schema=tc.constraint_schema AND cc.constraint_name=tc.constraint_name
|
||||
WHERE tc.constraint_schema=DATABASE() AND tc.table_name='shopee_products'
|
||||
AND tc.constraint_name='chk_shopee_products_source' AND tc.constraint_type='CHECK'`).Scan(&enforced, &checkClause); err != nil {
|
||||
return fmt.Errorf("shopee_products.source CHECK 缺失或不可读: %w", err)
|
||||
}
|
||||
if enforced != "YES" {
|
||||
return fmt.Errorf("shopee_products.source CHECK 未启用")
|
||||
}
|
||||
normalizedClause := strings.NewReplacer("`", "", " ", "", "(", "", ")", "", "_utf8mb4", "", `\`, "").Replace(strings.ToLower(checkClause))
|
||||
if !strings.Contains(normalizedClause, "sourcein'report','syb'") {
|
||||
return fmt.Errorf("shopee_products.source CHECK 表达式不正确")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkMySQLVarcharColumn(db *sql.DB, table, column string, length int64, nullable bool, collation, defaultValue string) error {
|
||||
var dataType, isNullable string
|
||||
var actualLength sql.NullInt64
|
||||
var actualCollation, actualDefault sql.NullString
|
||||
if err := db.QueryRow(`SELECT data_type, character_maximum_length, is_nullable, collation_name, column_default
|
||||
FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name=? AND column_name=?`,
|
||||
table, column).Scan(&dataType, &actualLength, &isNullable, &actualCollation, &actualDefault); err != nil {
|
||||
return fmt.Errorf("检查 MySQL 列 %s.%s 结构失败: %w", table, column, err)
|
||||
}
|
||||
wantNullable := "NO"
|
||||
if nullable {
|
||||
wantNullable = "YES"
|
||||
}
|
||||
if dataType != "varchar" || !actualLength.Valid || actualLength.Int64 != length ||
|
||||
isNullable != wantNullable || actualCollation.String != collation {
|
||||
return fmt.Errorf("MySQL 列 %s.%s 结构不正确", table, column)
|
||||
}
|
||||
if defaultValue != "" && (!actualDefault.Valid || actualDefault.String != defaultValue) {
|
||||
return fmt.Errorf("MySQL 列 %s.%s 默认值不正确", table, column)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"cmautobuy/admin/config"
|
||||
"cmautobuy/admin/spec"
|
||||
)
|
||||
|
||||
// TestMySQLMigrate_真实MySQL8 只在显式提供隔离测试库时运行。
|
||||
@@ -46,6 +47,180 @@ func TestMySQLMigrate_真实MySQL8(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMySQLMigrate_V2升级V3并转换真实主链路(t *testing.T) {
|
||||
db := openMySQLMigrationTestDB(t)
|
||||
defer db.Close()
|
||||
cleanMySQLTestSchema(t, db)
|
||||
defer cleanMySQLTestSchema(t, db)
|
||||
prepareMySQLV2(t, db)
|
||||
now := "2026-08-10T03:00:00Z"
|
||||
mustExec(t, db, `INSERT INTO shopee_products (goods_id,title,created_at,updated_at) VALUES ('REPORT-1','报表商品',?,?)`, now, now)
|
||||
mustExec(t, db, `INSERT INTO shopee_skus (sku_id,goods_id,spec_raw,created_at,updated_at) VALUES ('SKU-1','REPORT-1','黑色, M',?,?)`, now, now)
|
||||
mustExec(t, db, `INSERT INTO sku_mappings (shopee_sku_id,pdd_goods_id,pdd_option_key,pdd_options,goods_id,mapped_at) VALUES ('SKU-1','PDD-1','color=黑色&size=M','color=黑色&size=M','REPORT-1',?)`, now)
|
||||
mustExec(t, db, `INSERT INTO syb_orders (syb_id,order_no,title,shopee_goods_id,product_spec,quantity,syb_data,created_at,updated_at) VALUES
|
||||
('SYB-1','ORDER-1','报表商品','REPORT-1',' 黑色, M ',1,'{}',?,?),
|
||||
('SYB-2','ORDER-2','骨架商品','SKELETON-1','白色,L',1,'{}',?,?),
|
||||
('SYB-3','ORDER-3','空规格','EMPTY-1',NULL,1,'{}',?,?)`, now, now, now, now, now, now)
|
||||
|
||||
if err := MigrateMySQL(db); err != nil {
|
||||
t.Fatalf("v2→v3 失败: %v", err)
|
||||
}
|
||||
var key string
|
||||
if err := db.QueryRow(`SELECT spec_key FROM syb_orders WHERE syb_id='SYB-1'`).Scan(&key); err != nil || key != "黑色, M" {
|
||||
t.Fatalf("规格回填=%q err=%v", key, err)
|
||||
}
|
||||
var empty sql.NullString
|
||||
if err := db.QueryRow(`SELECT spec_key FROM syb_orders WHERE syb_id='SYB-3'`).Scan(&empty); err != nil || empty.Valid {
|
||||
t.Fatalf("空规格必须保持 NULL: %+v err=%v", empty, err)
|
||||
}
|
||||
var source string
|
||||
if err := db.QueryRow(`SELECT source FROM shopee_products WHERE goods_id='SKELETON-1'`).Scan(&source); err != nil || source != "syb" {
|
||||
t.Fatalf("骨架来源=%q err=%v", source, err)
|
||||
}
|
||||
var mappingCount int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM spec_mappings WHERE shopee_goods_id='REPORT-1' AND spec_key='黑色, M' AND pdd_goods_id='PDD-1'`).Scan(&mappingCount); err != nil || mappingCount != 1 {
|
||||
t.Fatalf("旧映射转换数量=%d err=%v", mappingCount, err)
|
||||
}
|
||||
if exists, _ := mysqlTableExists(db, "sku_mappings"); exists {
|
||||
t.Fatal("旧 sku_mappings 应已删除")
|
||||
}
|
||||
if exists, _ := mysqlTableExists(db, "sku_mappings_v3_backup"); !exists {
|
||||
t.Fatal("删除旧表前必须保留受限备份表")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMySQLMigrate_V3三个中断点重跑收敛(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
prepare func(*testing.T, *sql.DB)
|
||||
}{
|
||||
{"DDL完成回填未开始", func(t *testing.T, db *sql.DB) {
|
||||
if err := ensureMySQLV3Columns(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustExec(t, db, mysqlSchemaV3SpecMappings)
|
||||
}},
|
||||
{"回填到一半", func(t *testing.T, db *sql.DB) {
|
||||
if err := ensureMySQLV3Columns(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustExec(t, db, mysqlSchemaV3SpecMappings)
|
||||
key, _ := spec.SpecKey("黑色,M")
|
||||
mustExec(t, db, `UPDATE syb_orders SET spec_key=? WHERE syb_id='SYB-A'`, key)
|
||||
}},
|
||||
{"全部完成版本未记录", func(t *testing.T, db *sql.DB) {
|
||||
if err := migrateMySQLV3(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
db := openMySQLMigrationTestDB(t)
|
||||
defer db.Close()
|
||||
cleanMySQLTestSchema(t, db)
|
||||
defer cleanMySQLTestSchema(t, db)
|
||||
prepareMySQLV2(t, db)
|
||||
now := "2026-08-10T03:00:00Z"
|
||||
mustExec(t, db, `INSERT INTO syb_orders (syb_id,order_no,title,shopee_goods_id,product_spec,quantity,syb_data,created_at,updated_at) VALUES ('SYB-A','A','A','G-A','黑色,M',1,'{}',?,?),('SYB-B','B','B','G-B','白色,L',1,'{}',?,?)`, now, now, now, now)
|
||||
tc.prepare(t, db)
|
||||
if err := MigrateMySQL(db); err != nil {
|
||||
t.Fatalf("重跑失败: %v", err)
|
||||
}
|
||||
if err := MigrateMySQL(db); err != nil {
|
||||
t.Fatalf("再次重跑失败: %v", err)
|
||||
}
|
||||
var count int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM syb_orders WHERE spec_key IS NOT NULL`).Scan(&count); err != nil || count != 2 {
|
||||
t.Fatalf("回填未收敛 count=%d err=%v", count, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMySQLMigrate_V3字段存在但约束缺失可收敛(t *testing.T) {
|
||||
db := openMySQLMigrationTestDB(t)
|
||||
defer db.Close()
|
||||
cleanMySQLTestSchema(t, db)
|
||||
defer cleanMySQLTestSchema(t, db)
|
||||
prepareMySQLV2(t, db)
|
||||
mustExec(t, db, `INSERT INTO shopee_products(goods_id,title,created_at,updated_at) VALUES ('EXISTING-1','存量商品','2026-08-10T00:00:00Z','2026-08-10T00:00:00Z')`)
|
||||
mustExec(t, db, `ALTER TABLE shopee_products ADD COLUMN source VARCHAR(16) NULL`)
|
||||
if err := MigrateMySQL(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var existingSource string
|
||||
if err := db.QueryRow(`SELECT source FROM shopee_products WHERE goods_id='EXISTING-1'`).Scan(&existingSource); err != nil || existingSource != "report" {
|
||||
t.Fatalf("可空 source 中断点的存量行未收敛: source=%q err=%v", existingSource, err)
|
||||
}
|
||||
mustExec(t, db, `INSERT INTO shopee_products(goods_id,title,source,created_at,updated_at) VALUES ('CHECK-1','检查','report','2026-08-10T00:00:00Z','2026-08-10T00:00:00Z')`)
|
||||
if _, err := db.Exec(`UPDATE shopee_products SET source='typo'`); err == nil {
|
||||
t.Fatal("source CHECK 必须拒绝非法值")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMySQLMigrate_V3形状自检失败不记版本(t *testing.T) {
|
||||
db := openMySQLMigrationTestDB(t)
|
||||
defer db.Close()
|
||||
cleanMySQLTestSchema(t, db)
|
||||
defer cleanMySQLTestSchema(t, db)
|
||||
prepareMySQLV2(t, db)
|
||||
mustExec(t, db, `CREATE TABLE spec_mappings (
|
||||
shopee_goods_id VARCHAR(191) NOT NULL,
|
||||
spec_key VARCHAR(191) NOT NULL,
|
||||
pdd_goods_id VARCHAR(191) NOT NULL,
|
||||
pdd_option_key VARCHAR(191) NOT NULL,
|
||||
pdd_options LONGTEXT NOT NULL,
|
||||
spec_raw TEXT NOT NULL,
|
||||
mapped_at VARCHAR(35) NOT NULL,
|
||||
PRIMARY KEY (shopee_goods_id, pdd_goods_id, spec_key)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`)
|
||||
if err := MigrateMySQL(db); err == nil {
|
||||
t.Fatal("错误主键顺序必须让 v3 形状自检失败")
|
||||
}
|
||||
var count int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM schema_migrations WHERE version=3`).Scan(&count); err != nil || count != 0 {
|
||||
t.Fatalf("自检失败时不得记录 v3: count=%d err=%v", count, err)
|
||||
}
|
||||
}
|
||||
|
||||
func openMySQLMigrationTestDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
if os.Getenv("CMAUTOBUY_MYSQL_TEST") != "1" {
|
||||
t.Skip("未启用真实 MySQL 8 集成测试")
|
||||
}
|
||||
cfg, err := config.LoadDatabaseFromEnv()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasSuffix(cfg.Name, "_test") {
|
||||
t.Fatalf("拒绝使用非 _test 数据库 %q", cfg.Name)
|
||||
}
|
||||
db, err := OpenMySQL(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func prepareMySQLV2(t *testing.T, db *sql.DB) {
|
||||
t.Helper()
|
||||
mustExec(t, db, `CREATE TABLE schema_migrations (version INT PRIMARY KEY, applied_at VARCHAR(35) NOT NULL) ENGINE=InnoDB`)
|
||||
for _, statement := range mysqlSchemaV1 {
|
||||
mustExec(t, db, statement)
|
||||
}
|
||||
for _, statement := range mysqlSchemaV2 {
|
||||
mustExec(t, db, statement)
|
||||
}
|
||||
mustExec(t, db, `INSERT INTO schema_migrations(version,applied_at) VALUES (1,'2026-08-10T00:00:00Z'),(2,'2026-08-10T00:00:00Z')`)
|
||||
}
|
||||
|
||||
func mustExec(t *testing.T, db *sql.DB, query string, args ...any) {
|
||||
t.Helper()
|
||||
if _, err := db.Exec(query, args...); err != nil {
|
||||
t.Fatalf("执行 SQL 失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func cleanMySQLTestSchema(t *testing.T, db *sql.DB) {
|
||||
t.Helper()
|
||||
rows, err := db.Query(`SELECT table_name FROM information_schema.tables
|
||||
|
||||
@@ -194,7 +194,7 @@ func pddListFilter(keyword string, status model.CollectStatus) (string, []any) {
|
||||
// ListPddProducts 查一页列表和当前筛选下的总数。
|
||||
// keyword 匹配 goods_id 或链接,status 为空表示不筛选。
|
||||
//
|
||||
// 只返回未删除的。软删除的记录还在库里(sku_mappings 指着它),
|
||||
// 只返回未删除的。软删除的记录还在库里(spec_mappings 仍会引用它),
|
||||
// 但界面上不该看见。
|
||||
func ListPddProducts(q Execer, keyword string, status model.CollectStatus, limit, offset int) ([]PddProductRow, int, error) {
|
||||
where, args := pddListFilter(keyword, status)
|
||||
@@ -414,7 +414,7 @@ func MarkCollecting(q Execer, pddGoodsID string) (bool, error) {
|
||||
|
||||
// SoftDeletePddProduct 软删除。
|
||||
//
|
||||
// 不硬删是因为 sku_mappings 指向它,硬删会把人工攒了很久的匹配成果一起带走。
|
||||
// 不硬删是因为 spec_mappings 指向它,硬删会把人工攒了很久的匹配成果一起带走。
|
||||
// 删除后界面上不再显示,但记录和映射都还在;
|
||||
// 操作员重新填同一个链接时会被 EnsurePddProduct 复活。
|
||||
func SoftDeletePddProduct(q Execer, pddGoodsID string) error {
|
||||
|
||||
@@ -22,12 +22,13 @@ func UpsertShopeeProduct(q Execer, goodsID, title, shopeeStatus, mainSKUCode str
|
||||
}
|
||||
now := model.NowISO()
|
||||
_, err := q.Exec(`
|
||||
INSERT INTO shopee_products (goods_id, title, shopee_status, main_sku_code, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO shopee_products (goods_id, title, shopee_status, main_sku_code, source, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 'report', ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
title = VALUES(title),
|
||||
shopee_status = VALUES(shopee_status),
|
||||
main_sku_code = VALUES(main_sku_code),
|
||||
source = 'report',
|
||||
updated_at = VALUES(updated_at)`,
|
||||
// 注意:pdd_goods_url / pdd_goods_id 两列不在 INSERT 的列清单里,
|
||||
// 也不在 DO UPDATE SET 里——新建时它们是 NULL(未填链接),
|
||||
@@ -162,7 +163,7 @@ func shopeeFilterClause(filter ShopeeFilter) (string, []any) {
|
||||
func ListShopeeProducts(q Execer, filter ShopeeFilter, limit, offset int) ([]ShopeeProductRow, error) {
|
||||
where, args := shopeeFilterClause(filter)
|
||||
sqlText := `
|
||||
SELECT sp.goods_id, sp.title, sp.shopee_status, sp.main_sku_code,
|
||||
SELECT sp.goods_id, sp.title, sp.shopee_status, sp.main_sku_code, sp.source,
|
||||
sp.pdd_goods_url, sp.pdd_goods_id, sp.created_at, sp.updated_at,
|
||||
COUNT(DISTINCT CASE WHEN sk.parse_ok = 1 THEN sk.color END) AS color_count,
|
||||
COUNT(DISTINCT CASE WHEN sk.parse_ok = 1 THEN sk.size END) AS size_count,
|
||||
@@ -185,9 +186,9 @@ func ListShopeeProducts(q Execer, filter ShopeeFilter, limit, offset int) ([]Sho
|
||||
var list []ShopeeProductRow
|
||||
for rows.Next() {
|
||||
var r ShopeeProductRow
|
||||
var title, shopeeStatus, mainSKUCode, pddGoodsURL, pddGoodsID sql.NullString
|
||||
var title, shopeeStatus, mainSKUCode, source, pddGoodsURL, pddGoodsID sql.NullString
|
||||
if err := rows.Scan(
|
||||
&r.GoodsID, &title, &shopeeStatus, &mainSKUCode,
|
||||
&r.GoodsID, &title, &shopeeStatus, &mainSKUCode, &source,
|
||||
&pddGoodsURL, &pddGoodsID, &r.CreatedAt, &r.UpdatedAt,
|
||||
&r.ColorCount, &r.SizeCount, &r.SKUCount, &r.PendingCount,
|
||||
&r.CollectStatus, &r.CollectMsg,
|
||||
@@ -197,6 +198,7 @@ func ListShopeeProducts(q Execer, filter ShopeeFilter, limit, offset int) ([]Sho
|
||||
r.Title = title.String
|
||||
r.ShopeeStatus = shopeeStatus.String
|
||||
r.MainSKUCode = mainSKUCode.String
|
||||
r.Source = source.String
|
||||
r.PddGoodsURL = pddGoodsURL.String
|
||||
r.PddGoodsID = pddGoodsID.String
|
||||
list = append(list, r)
|
||||
@@ -223,12 +225,12 @@ func CountShopeeProductsFiltered(q Execer, filter ShopeeFilter) (int, error) {
|
||||
// 弹窗组装商品信息时用。查不到返回 (nil, nil)。
|
||||
func GetShopeeProductByGoodsID(q Execer, goodsID string) (*model.ShopeeProduct, error) {
|
||||
var p model.ShopeeProduct
|
||||
var title, shopeeStatus, mainSKUCode, pddGoodsURL, pddGoodsID sql.NullString
|
||||
var title, shopeeStatus, mainSKUCode, source, pddGoodsURL, pddGoodsID sql.NullString
|
||||
err := q.QueryRow(`
|
||||
SELECT goods_id, title, shopee_status, main_sku_code,
|
||||
SELECT goods_id, title, shopee_status, main_sku_code, source,
|
||||
pdd_goods_url, pdd_goods_id, created_at, updated_at
|
||||
FROM shopee_products WHERE goods_id = ?`, goodsID).Scan(
|
||||
&p.GoodsID, &title, &shopeeStatus, &mainSKUCode,
|
||||
&p.GoodsID, &title, &shopeeStatus, &mainSKUCode, &source,
|
||||
&pddGoodsURL, &pddGoodsID, &p.CreatedAt, &p.UpdatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
@@ -239,6 +241,7 @@ func GetShopeeProductByGoodsID(q Execer, goodsID string) (*model.ShopeeProduct,
|
||||
p.Title = title.String
|
||||
p.ShopeeStatus = shopeeStatus.String
|
||||
p.MainSKUCode = mainSKUCode.String
|
||||
p.Source = source.String
|
||||
p.PddGoodsURL = pddGoodsURL.String
|
||||
p.PddGoodsID = pddGoodsID.String
|
||||
return &p, nil
|
||||
|
||||
+42
-64
@@ -11,6 +11,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"cmautobuy/admin/model"
|
||||
"cmautobuy/admin/spec"
|
||||
)
|
||||
|
||||
// ---------- 会话缓存 ----------
|
||||
@@ -224,11 +225,8 @@ func CountSybSyncRuns(q Execer) (int, error) {
|
||||
|
||||
// UpsertSybOrder 写入或更新一条顺运宝货运单明细行。
|
||||
//
|
||||
// `[必须]` DO UPDATE SET 里绝不允许出现 shopee_sku_id。它是规格匹配的
|
||||
// 结果(人工确认或自动匹配产生),顺运宝那边根本没有这个值——写进去
|
||||
// 就是写 NULL,把人工攒的匹配成果洗掉,而且不报错。这和 #38 里
|
||||
// pdd_goods_url 不能被 Excel 导入覆盖是同一类问题,见工单 #46、
|
||||
// docs/admin/08-顺运宝接口.md §6.2。
|
||||
// `[必须]` shopee_sku_id 已退出采购主链路,INSERT 和 UPDATE 都不再写它。
|
||||
// 旧列只为只追加迁移兼容保留。
|
||||
//
|
||||
// `[必须]` shopee_goods_id **可以**被覆盖——它就是顺运宝
|
||||
// detail.productId,来自顺运宝,不是人工填的。
|
||||
@@ -238,6 +236,14 @@ func UpsertSybOrder(q Execer, o model.SybOrder) (created bool, err error) {
|
||||
if o.SybID == "" {
|
||||
return false, fmt.Errorf("syb_id 不能为空")
|
||||
}
|
||||
var specKey any
|
||||
if strings.TrimSpace(o.ProductSpec) != "" {
|
||||
key, keyErr := spec.SpecKey(o.ProductSpec)
|
||||
if keyErr != nil {
|
||||
return false, fmt.Errorf("顺运宝明细 %s 的规格不能生成身份键: %w", o.SybID, keyErr)
|
||||
}
|
||||
specKey = key
|
||||
}
|
||||
|
||||
var exists int
|
||||
err = q.QueryRow(`SELECT 1 FROM syb_orders WHERE syb_id = ?`, o.SybID).Scan(&exists)
|
||||
@@ -249,26 +255,33 @@ func UpsertSybOrder(q Execer, o model.SybOrder) (created bool, err error) {
|
||||
}
|
||||
|
||||
now := model.NowISO()
|
||||
if strings.TrimSpace(o.ShopeeGoodsID) != "" {
|
||||
if _, err := q.Exec(`
|
||||
INSERT INTO shopee_products (goods_id, title, source, created_at, updated_at)
|
||||
VALUES (?, ?, 'syb', ?, ?)
|
||||
ON DUPLICATE KEY UPDATE goods_id=VALUES(goods_id)`,
|
||||
o.ShopeeGoodsID, o.Title, now, now); err != nil {
|
||||
return false, fmt.Errorf("为顺运宝明细 %s 补建蝦皮商品骨架失败: %w", o.SybID, err)
|
||||
}
|
||||
}
|
||||
_, err = q.Exec(`
|
||||
INSERT INTO syb_orders
|
||||
(syb_id, order_no, title, product_spec, shopee_goods_id, shopee_sku_id,
|
||||
(syb_id, order_no, title, product_spec, spec_key, shopee_goods_id,
|
||||
quantity, price_twd_cent, image_url, syb_data, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
order_no = VALUES(order_no),
|
||||
title = VALUES(title),
|
||||
product_spec = VALUES(product_spec),
|
||||
spec_key = VALUES(spec_key),
|
||||
shopee_goods_id = VALUES(shopee_goods_id),
|
||||
quantity = VALUES(quantity),
|
||||
price_twd_cent = VALUES(price_twd_cent),
|
||||
image_url = VALUES(image_url),
|
||||
syb_data = VALUES(syb_data),
|
||||
updated_at = VALUES(updated_at)`,
|
||||
// 注意:shopee_sku_id 只出现在 INSERT 的列清单里(新建行时写 o.ShopeeSKUID,
|
||||
// 同步永远传空字符串),完全不出现在 DO UPDATE SET 里——已存在的行
|
||||
// 这一列不受本语句影响,见上面的函数注释。
|
||||
o.SybID, o.OrderNo, o.Title, nullableText(o.ProductSpec), nullableText(o.ShopeeGoodsID),
|
||||
nullableText(o.ShopeeSKUID), o.Quantity, o.PriceTwdCent, nullableText(o.ImageURL),
|
||||
o.SybID, o.OrderNo, o.Title, nullableText(o.ProductSpec), specKey, nullableText(o.ShopeeGoodsID),
|
||||
o.Quantity, o.PriceTwdCent, nullableText(o.ImageURL),
|
||||
o.SybData, now, now)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("写入顺运宝货运单明细 %s 失败: %w", o.SybID, err)
|
||||
@@ -301,14 +314,12 @@ func sybOrderFilterClause(filter SybOrderFilter) (string, []any) {
|
||||
args = append(args, like, like)
|
||||
}
|
||||
switch filter.Stage {
|
||||
case "missing_shopee":
|
||||
clauses = append(clauses, `sp.goods_id IS NULL`)
|
||||
case "sku_pending":
|
||||
clauses = append(clauses, `sp.goods_id IS NOT NULL AND so.shopee_sku_id IS NULL`)
|
||||
case "spec_missing":
|
||||
clauses = append(clauses, `so.spec_key IS NULL`)
|
||||
case "pdd_missing":
|
||||
clauses = append(clauses, `so.shopee_sku_id IS NOT NULL AND (sp.pdd_goods_id IS NULL OR sp.pdd_goods_id = '' OR pp.goods_id IS NULL)`)
|
||||
clauses = append(clauses, `so.spec_key IS NOT NULL AND (sp.pdd_goods_id IS NULL OR sp.pdd_goods_id = '' OR pp.goods_id IS NULL)`)
|
||||
case "pdd_pending", "pdd_collecting", "pdd_failed", "pdd_collected":
|
||||
clauses = append(clauses, `so.shopee_sku_id IS NOT NULL AND pp.collect_status = ?`)
|
||||
clauses = append(clauses, `so.spec_key IS NOT NULL AND pp.collect_status = ?`)
|
||||
args = append(args, strings.TrimPrefix(filter.Stage, "pdd_"))
|
||||
}
|
||||
if len(clauses) == 0 {
|
||||
@@ -321,8 +332,9 @@ const sybOrderContextFrom = `
|
||||
FROM syb_orders so
|
||||
LEFT JOIN shopee_products sp ON sp.goods_id = so.shopee_goods_id
|
||||
LEFT JOIN pdd_products pp ON pp.goods_id = sp.pdd_goods_id AND pp.deleted_at IS NULL
|
||||
LEFT JOIN sku_mappings sm
|
||||
ON sm.shopee_sku_id = so.shopee_sku_id AND sm.pdd_goods_id = sp.pdd_goods_id`
|
||||
LEFT JOIN spec_mappings sm
|
||||
ON sm.shopee_goods_id = so.shopee_goods_id
|
||||
AND sm.spec_key = so.spec_key AND sm.pdd_goods_id = sp.pdd_goods_id`
|
||||
|
||||
// SybOrderContext 是顺运宝明细及其当前蝦皮/PDD 处理上下文。
|
||||
// 处理阶段由 service 计算,Repository 只提供数据库事实。
|
||||
@@ -341,14 +353,14 @@ type SybOrderContext struct {
|
||||
|
||||
func scanSybOrderContext(s rowScanner) (SybOrderContext, error) {
|
||||
var c SybOrderContext
|
||||
var title, productSpec, shopeeGoodsID, shopeeSKUID, imageURL sql.NullString
|
||||
var title, productSpec, specKey, shopeeGoodsID, imageURL sql.NullString
|
||||
var priceCent sql.NullInt64
|
||||
var shopeeExists int
|
||||
var pddGoodsID, pddGoodsURL, collectStatus, collectMsg, skusJSON sql.NullString
|
||||
var mappingKey, mappingOptions sql.NullString
|
||||
var hasActiveTask int
|
||||
err := s.Scan(
|
||||
&c.Order.SybID, &c.Order.OrderNo, &title, &productSpec, &shopeeGoodsID, &shopeeSKUID,
|
||||
&c.Order.SybID, &c.Order.OrderNo, &title, &productSpec, &specKey, &shopeeGoodsID,
|
||||
&c.Order.Quantity, &priceCent, &imageURL, &c.Order.SybData,
|
||||
&c.Order.CreatedAt, &c.Order.UpdatedAt, &shopeeExists,
|
||||
&pddGoodsID, &pddGoodsURL, &collectStatus, &collectMsg, &skusJSON,
|
||||
@@ -356,8 +368,8 @@ func scanSybOrderContext(s rowScanner) (SybOrderContext, error) {
|
||||
)
|
||||
c.Order.Title = title.String
|
||||
c.Order.ProductSpec = productSpec.String
|
||||
c.Order.SpecKey = specKey.String
|
||||
c.Order.ShopeeGoodsID = shopeeGoodsID.String
|
||||
c.Order.ShopeeSKUID = shopeeSKUID.String
|
||||
c.Order.PriceTwdCent = priceCent.Int64
|
||||
c.Order.ImageURL = imageURL.String
|
||||
c.ShopeeExists = shopeeExists != 0
|
||||
@@ -376,8 +388,8 @@ func scanSybOrderContext(s rowScanner) (SybOrderContext, error) {
|
||||
func ListSybOrderContexts(q Execer, filter SybOrderFilter, limit, offset int) ([]SybOrderContext, error) {
|
||||
where, args := sybOrderFilterClause(filter)
|
||||
query := `
|
||||
SELECT so.syb_id, so.order_no, so.title, so.product_spec,
|
||||
so.shopee_goods_id, so.shopee_sku_id, so.quantity,
|
||||
SELECT so.syb_id, so.order_no, so.title, so.product_spec, so.spec_key,
|
||||
so.shopee_goods_id, so.quantity,
|
||||
so.price_twd_cent, so.image_url, so.syb_data, so.created_at, so.updated_at,
|
||||
CASE WHEN sp.goods_id IS NULL THEN 0 ELSE 1 END,
|
||||
sp.pdd_goods_id, sp.pdd_goods_url, pp.collect_status, pp.collect_msg,
|
||||
@@ -410,8 +422,8 @@ func ListSybOrderContexts(q Execer, filter SybOrderFilter, limit, offset int) ([
|
||||
// GetSybOrderContext 按明细 ID 读取一条处理上下文。
|
||||
func GetSybOrderContext(q Execer, sybID string) (*SybOrderContext, error) {
|
||||
row, err := scanSybOrderContext(q.QueryRow(`
|
||||
SELECT so.syb_id, so.order_no, so.title, so.product_spec,
|
||||
so.shopee_goods_id, so.shopee_sku_id, so.quantity,
|
||||
SELECT so.syb_id, so.order_no, so.title, so.product_spec, so.spec_key,
|
||||
so.shopee_goods_id, so.quantity,
|
||||
so.price_twd_cent, so.image_url, so.syb_data, so.created_at, so.updated_at,
|
||||
CASE WHEN sp.goods_id IS NULL THEN 0 ELSE 1 END,
|
||||
sp.pdd_goods_id, sp.pdd_goods_url, pp.collect_status, pp.collect_msg,
|
||||
@@ -429,45 +441,11 @@ func GetSybOrderContext(q Execer, sybID string) (*SybOrderContext, error) {
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
// SetSybShopeeSKUIfEmpty 只为尚未确认规格的明细写入自动识别结果。
|
||||
func SetSybShopeeSKUIfEmpty(q Execer, sybID, skuID string) (bool, error) {
|
||||
result, err := q.Exec(`
|
||||
UPDATE syb_orders
|
||||
SET shopee_sku_id = ?, updated_at = ?
|
||||
WHERE syb_id = ? AND shopee_sku_id IS NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM shopee_skus sk
|
||||
WHERE sk.sku_id = ? AND sk.goods_id = syb_orders.shopee_goods_id
|
||||
)`, skuID, model.NowISO(), sybID, skuID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("自动确认顺运宝明细 %s 的蝦皮规格失败: %w", sybID, err)
|
||||
}
|
||||
n, err := result.RowsAffected()
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
// SetSybShopeeSKU 在调用方完成商品归属校验后保存人工选择。
|
||||
func SetSybShopeeSKU(q Execer, sybID, skuID string) error {
|
||||
result, err := q.Exec(`UPDATE syb_orders SET shopee_sku_id = ?, updated_at = ? WHERE syb_id = ?`,
|
||||
skuID, model.NowISO(), sybID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("保存顺运宝明细 %s 的蝦皮规格失败: %w", sybID, err)
|
||||
}
|
||||
n, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return fmt.Errorf("顺运宝明细 %s 不存在", sybID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListSybOrders 按筛选条件分页查货运单明细列表,按更新时间倒序。
|
||||
func ListSybOrders(q Execer, filter SybOrderFilter, limit, offset int) ([]model.SybOrder, error) {
|
||||
where, args := sybOrderFilterClause(filter)
|
||||
sqlText := `
|
||||
SELECT so.syb_id, so.order_no, so.title, so.product_spec, so.shopee_goods_id, so.shopee_sku_id,
|
||||
SELECT so.syb_id, so.order_no, so.title, so.product_spec, so.spec_key, so.shopee_goods_id,
|
||||
so.quantity, so.price_twd_cent, so.image_url, so.syb_data, so.created_at, so.updated_at` +
|
||||
sybOrderContextFrom + where + `
|
||||
ORDER BY so.updated_at DESC, so.syb_id DESC LIMIT ? OFFSET ?`
|
||||
@@ -482,18 +460,18 @@ func ListSybOrders(q Execer, filter SybOrderFilter, limit, offset int) ([]model.
|
||||
var list []model.SybOrder
|
||||
for rows.Next() {
|
||||
var o model.SybOrder
|
||||
var title, productSpec, shopeeGoodsID, shopeeSKUID, imageURL sql.NullString
|
||||
var title, productSpec, specKey, shopeeGoodsID, imageURL sql.NullString
|
||||
var priceCent sql.NullInt64
|
||||
if err := rows.Scan(
|
||||
&o.SybID, &o.OrderNo, &title, &productSpec, &shopeeGoodsID, &shopeeSKUID,
|
||||
&o.SybID, &o.OrderNo, &title, &productSpec, &specKey, &shopeeGoodsID,
|
||||
&o.Quantity, &priceCent, &imageURL, &o.SybData, &o.CreatedAt, &o.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("读取顺运宝货运单列表失败: %w", err)
|
||||
}
|
||||
o.Title = title.String
|
||||
o.ProductSpec = productSpec.String
|
||||
o.SpecKey = specKey.String
|
||||
o.ShopeeGoodsID = shopeeGoodsID.String
|
||||
o.ShopeeSKUID = shopeeSKUID.String
|
||||
o.PriceTwdCent = priceCent.Int64
|
||||
o.ImageURL = imageURL.String
|
||||
list = append(list, o)
|
||||
|
||||
@@ -167,6 +167,25 @@ func TestUpsertSybOrder_重复同步同一订单行数不翻倍(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertSybOrder_骨架失败时同事务不写订单(t *testing.T) {
|
||||
db := newSybTestDB(t)
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := UpsertSybOrder(tx, model.SybOrder{
|
||||
SybID: "SYB-REJECT", OrderNo: "ORDER-REJECT", Title: "测试",
|
||||
ShopeeGoodsID: strings.Repeat("G", 192), ProductSpec: "黑色,M", Quantity: 1, SybData: "{}",
|
||||
}); err == nil {
|
||||
t.Fatal("骨架失败必须让订单写入失败")
|
||||
}
|
||||
tx.Rollback()
|
||||
var count int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM syb_orders WHERE syb_id='SYB-REJECT'`).Scan(&count); err != nil || count != 0 {
|
||||
t.Fatalf("失败后不应留下订单 count=%d err=%v", count, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSybSession_保存读取和清除(t *testing.T) {
|
||||
db := newSybTestDB(t)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user