feat: 重构顺运宝规格主链路 (#88)

This commit is contained in:
chengma
2026-08-10 12:24:23 +08:00
parent 5bb1258462
commit d1165b9e17
28 changed files with 985 additions and 531 deletions
+19 -19
View File
@@ -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)
}
+348 -5
View File
@@ -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
+2 -2
View File
@@ -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 {
+11 -8
View File
@@ -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
View File
@@ -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)
+19
View File
@@ -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)