@@ -169,11 +169,15 @@ type CatalogProductOutcome struct {
|
||||
|
||||
// UpsertCatalogShopeeProduct 独立保护主图和店铺字段,绝不覆盖人工 PDD 关联。
|
||||
func UpsertCatalogShopeeProduct(q Execer, in CatalogShopeeProductInput) (out CatalogProductOutcome, err error) {
|
||||
var oldObserved, imageURL, shopName, imageSource, imageObserved, shopSource, shopObserved sql.NullString
|
||||
var oldObserved, oldShopID, imageURL, shopName, imageSource, imageObserved, shopSource, shopObserved sql.NullString
|
||||
var imageManual, shopManual int
|
||||
err = q.QueryRow(`SELECT source_observed_at,image_url,shopee_shop_name,image_source,image_observed_at,image_is_manual,shop_name_source,shop_name_observed_at,shop_name_is_manual FROM shopee_products WHERE goods_id=?`, in.GoodsID).Scan(&oldObserved, &imageURL, &shopName, &imageSource, &imageObserved, &imageManual, &shopSource, &shopObserved, &shopManual)
|
||||
shopID, err := FindShopIDByAlias(q, "shopee", in.ShopName)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
err = q.QueryRow(`SELECT source_observed_at,shop_id,image_url,shopee_shop_name,image_source,image_observed_at,image_is_manual,shop_name_source,shop_name_observed_at,shop_name_is_manual FROM shopee_products WHERE goods_id=?`, in.GoodsID).Scan(&oldObserved, &oldShopID, &imageURL, &shopName, &imageSource, &imageObserved, &imageManual, &shopSource, &shopObserved, &shopManual)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
_, err = q.Exec(`INSERT INTO shopee_products(goods_id,title,shopee_status,main_sku_code,image_url,shopee_shop_name,image_source,image_observed_at,image_is_manual,shop_name_source,shop_name_observed_at,shop_name_is_manual,source,source_observed_at,created_at,updated_at) VALUES(?,?,NULLIF(?,''),NULLIF(?,''),NULLIF(?,''),NULLIF(?,''),CASE WHEN TRIM(?)='' THEN NULL ELSE ? END,CASE WHEN TRIM(?)='' THEN NULL ELSE ? END,0,CASE WHEN TRIM(?)='' THEN NULL ELSE ? END,CASE WHEN TRIM(?)='' THEN NULL ELSE ? END,0,'api',?,?,?)`, in.GoodsID, in.Title, in.Status, in.MainSKU, in.ImageURL, in.ShopName, in.ImageURL, in.Source, in.ImageURL, in.ObservedAt, in.ShopName, in.Source, in.ShopName, in.ObservedAt, in.ObservedAt, in.Now, in.Now)
|
||||
_, err = q.Exec(`INSERT INTO shopee_products(goods_id,shop_id,title,shopee_status,main_sku_code,image_url,shopee_shop_name,image_source,image_observed_at,image_is_manual,shop_name_source,shop_name_observed_at,shop_name_is_manual,source,source_observed_at,created_at,updated_at) VALUES(?,NULLIF(?,''),?,NULLIF(?,''),NULLIF(?,''),NULLIF(?,''),NULLIF(?,''),CASE WHEN TRIM(?)='' THEN NULL ELSE ? END,CASE WHEN TRIM(?)='' THEN NULL ELSE ? END,0,CASE WHEN TRIM(?)='' THEN NULL ELSE ? END,CASE WHEN TRIM(?)='' THEN NULL ELSE ? END,0,'api',?,?,?)`, in.GoodsID, shopID, in.Title, in.Status, in.MainSKU, in.ImageURL, in.ShopName, in.ImageURL, in.Source, in.ImageURL, in.ObservedAt, in.ShopName, in.Source, in.ShopName, in.ObservedAt, in.ObservedAt, in.Now, in.Now)
|
||||
out.Created = err == nil
|
||||
return out, err
|
||||
}
|
||||
@@ -225,10 +229,22 @@ func UpsertCatalogShopeeProduct(q Execer, in CatalogShopeeProductInput) (out Cat
|
||||
}
|
||||
imageChanged = apply(in.ImageURL, &newImage, imageSource, imageObserved, imageManual)
|
||||
shopChanged = apply(in.ShopName, &newShop, shopSource, shopObserved, shopManual)
|
||||
if !baseUpdate && !imageChanged && !shopChanged {
|
||||
resolvedShopID := oldShopID.String
|
||||
shopAssociationChanged := false
|
||||
if shopChanged {
|
||||
resolvedShopID, err = FindShopIDByAlias(q, "shopee", newShop)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
shopAssociationChanged = resolvedShopID != oldShopID.String
|
||||
} else if resolvedShopID == "" && shopID != "" && strings.TrimSpace(newShop) == strings.TrimSpace(in.ShopName) {
|
||||
resolvedShopID = shopID
|
||||
shopAssociationChanged = true
|
||||
}
|
||||
if !baseUpdate && !imageChanged && !shopChanged && !shopAssociationChanged {
|
||||
return out, nil
|
||||
}
|
||||
_, err = q.Exec(`UPDATE shopee_products SET title=CASE WHEN ? THEN ? ELSE title END,shopee_status=CASE WHEN ? THEN NULLIF(?,'') ELSE shopee_status END,main_sku_code=CASE WHEN ? THEN NULLIF(?,'') ELSE main_sku_code END,source=CASE WHEN ? THEN 'api' ELSE source END,source_observed_at=CASE WHEN ? THEN ? ELSE source_observed_at END,image_url=NULLIF(?,''),shopee_shop_name=NULLIF(?,''),image_source=CASE WHEN ? THEN ? ELSE image_source END,image_observed_at=CASE WHEN ? THEN ? ELSE image_observed_at END,shop_name_source=CASE WHEN ? THEN ? ELSE shop_name_source END,shop_name_observed_at=CASE WHEN ? THEN ? ELSE shop_name_observed_at END,updated_at=? WHERE goods_id=?`, baseUpdate, in.Title, baseUpdate, in.Status, baseUpdate, in.MainSKU, baseUpdate, baseUpdate, in.ObservedAt, newImage, newShop, imageChanged, in.Source, imageChanged, in.ObservedAt, shopChanged, in.Source, shopChanged, in.ObservedAt, in.Now, in.GoodsID)
|
||||
_, err = q.Exec(`UPDATE shopee_products SET title=CASE WHEN ? THEN ? ELSE title END,shopee_status=CASE WHEN ? THEN NULLIF(?,'') ELSE shopee_status END,main_sku_code=CASE WHEN ? THEN NULLIF(?,'') ELSE main_sku_code END,source=CASE WHEN ? THEN 'api' ELSE source END,source_observed_at=CASE WHEN ? THEN ? ELSE source_observed_at END,shop_id=CASE WHEN ? THEN NULLIF(?,'') ELSE shop_id END,image_url=NULLIF(?,''),shopee_shop_name=NULLIF(?,''),image_source=CASE WHEN ? THEN ? ELSE image_source END,image_observed_at=CASE WHEN ? THEN ? ELSE image_observed_at END,shop_name_source=CASE WHEN ? THEN ? ELSE shop_name_source END,shop_name_observed_at=CASE WHEN ? THEN ? ELSE shop_name_observed_at END,updated_at=? WHERE goods_id=?`, baseUpdate, in.Title, baseUpdate, in.Status, baseUpdate, in.MainSKU, baseUpdate, baseUpdate, in.ObservedAt, shopAssociationChanged, resolvedShopID, newImage, newShop, imageChanged, in.Source, imageChanged, in.ObservedAt, shopChanged, in.Source, shopChanged, in.ObservedAt, in.Now, in.GoodsID)
|
||||
out.Updated = err == nil
|
||||
return out, err
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
"cmautobuy/admin/spec"
|
||||
)
|
||||
|
||||
const mysqlSchemaVersion = 17
|
||||
const mysqlSchemaVersion = 18
|
||||
|
||||
// OpenMySQL 打开生产 MySQL 8 数据库。错误信息绝不包含完整 DSN 或密码。
|
||||
func OpenMySQL(cfg config.DatabaseConfig) (*sql.DB, error) {
|
||||
@@ -628,6 +628,18 @@ func MigrateMySQL(db *sql.DB) error {
|
||||
if _, err := db.Exec(`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`, 17, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
|
||||
return fmt.Errorf("记录 MySQL schema v17 失败: %w", err)
|
||||
}
|
||||
current = 17
|
||||
}
|
||||
if current < 18 {
|
||||
if err := migrateMySQLV18(db); err != nil {
|
||||
return fmt.Errorf("执行 MySQL schema v18 失败: %w", err)
|
||||
}
|
||||
if err := checkMySQLV18Shape(db); err != nil {
|
||||
return fmt.Errorf("MySQL schema v18 自检失败,未记录版本: %w", err)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`, 18, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
|
||||
return fmt.Errorf("记录 MySQL schema v18 失败: %w", err)
|
||||
}
|
||||
}
|
||||
return CheckMySQLSchema(db)
|
||||
}
|
||||
@@ -675,6 +687,114 @@ func migrateMySQLV17(db *sql.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateMySQLV18 建立跨渠道店铺身份,保留旧 SYB 准入表作为回退依据。
|
||||
func migrateMySQLV18(db *sql.DB) error {
|
||||
statements := []string{
|
||||
`CREATE TABLE IF NOT EXISTS shops (
|
||||
shop_id VARCHAR(191) COLLATE utf8mb4_bin PRIMARY KEY,
|
||||
display_name VARCHAR(500) NOT NULL,
|
||||
normalized_name VARCHAR(500) COLLATE utf8mb4_bin NOT NULL,
|
||||
enabled TINYINT NOT NULL DEFAULT 1,
|
||||
created_by_user_id VARCHAR(191) COLLATE utf8mb4_bin NOT NULL,
|
||||
created_at VARCHAR(35) NOT NULL,
|
||||
updated_at VARCHAR(35) NOT NULL,
|
||||
UNIQUE KEY uq_shops_name (normalized_name),
|
||||
KEY idx_shops_enabled (enabled,normalized_name),
|
||||
CONSTRAINT fk_shops_user FOREIGN KEY (created_by_user_id) REFERENCES users(user_id),
|
||||
CONSTRAINT chk_shops_enabled CHECK (enabled IN (0,1))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||||
`CREATE TABLE IF NOT EXISTS shop_channel_aliases (
|
||||
alias_id VARCHAR(191) COLLATE utf8mb4_bin PRIMARY KEY,
|
||||
shop_id VARCHAR(191) COLLATE utf8mb4_bin NOT NULL,
|
||||
channel VARCHAR(16) COLLATE utf8mb4_bin NOT NULL,
|
||||
alias_name VARCHAR(500) NOT NULL,
|
||||
normalized_alias VARCHAR(500) COLLATE utf8mb4_bin NOT NULL,
|
||||
enabled TINYINT NOT NULL DEFAULT 1,
|
||||
created_at VARCHAR(35) NOT NULL,
|
||||
updated_at VARCHAR(35) NOT NULL,
|
||||
UNIQUE KEY uq_shop_channel_alias (channel,normalized_alias),
|
||||
KEY idx_shop_alias_shop (shop_id,channel,enabled),
|
||||
CONSTRAINT fk_shop_alias_shop FOREIGN KEY (shop_id) REFERENCES shops(shop_id) ON DELETE CASCADE,
|
||||
CONSTRAINT chk_shop_alias_channel CHECK (channel IN ('syb','shopee')),
|
||||
CONSTRAINT chk_shop_alias_enabled CHECK (enabled IN (0,1))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||||
}
|
||||
for _, statement := range statements {
|
||||
if _, err := db.Exec(statement); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, column := range []struct{ table, name, ddl string }{
|
||||
{"syb_orders", "shop_id", `ALTER TABLE syb_orders ADD COLUMN shop_id VARCHAR(191) COLLATE utf8mb4_bin NULL AFTER order_no`},
|
||||
{"shopee_products", "shop_id", `ALTER TABLE shopee_products ADD COLUMN shop_id VARCHAR(191) COLLATE utf8mb4_bin NULL AFTER goods_id`},
|
||||
} {
|
||||
exists, err := mysqlColumnExists(db, column.table, column.name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
if _, err := db.Exec(column.ddl); err != nil {
|
||||
return fmt.Errorf("增加 %s.%s 失败: %w", column.table, column.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, index := range []struct{ table, name, ddl string }{
|
||||
{"syb_orders", "idx_syb_orders_shop_id", `ALTER TABLE syb_orders ADD INDEX idx_syb_orders_shop_id (shop_id)`},
|
||||
{"shopee_products", "idx_shopee_products_shop_id", `ALTER TABLE shopee_products ADD INDEX idx_shopee_products_shop_id (shop_id)`},
|
||||
} {
|
||||
exists, err := mysqlIndexExists(db, index.table, index.name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
if _, err := db.Exec(index.ddl); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, fk := range []struct{ table, name, ddl string }{
|
||||
{"syb_orders", "fk_syb_orders_shop", `ALTER TABLE syb_orders ADD CONSTRAINT fk_syb_orders_shop FOREIGN KEY (shop_id) REFERENCES shops(shop_id) ON DELETE SET NULL`},
|
||||
{"shopee_products", "fk_shopee_products_shop", `ALTER TABLE shopee_products ADD CONSTRAINT fk_shopee_products_shop FOREIGN KEY (shop_id) REFERENCES shops(shop_id) ON DELETE SET NULL`},
|
||||
} {
|
||||
exists, err := mysqlConstraintExists(db, fk.table, fk.name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
if _, err := db.Exec(fk.ddl); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
// 旧准入项一对一迁移成业务店铺;旧 enabled 只控制 SYB 渠道,不停用业务店铺。
|
||||
if _, err := db.Exec(`INSERT INTO shops(shop_id,display_name,normalized_name,enabled,created_by_user_id,created_at,updated_at)
|
||||
SELECT shop_id,shop_name,normalized_name,1,created_by_user_id,created_at,updated_at FROM syb_allowed_shops
|
||||
ON DUPLICATE KEY UPDATE display_name=VALUES(display_name),updated_at=VALUES(updated_at)`); err != nil {
|
||||
return fmt.Errorf("迁移旧同步店铺失败: %w", err)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO shop_channel_aliases(alias_id,shop_id,channel,alias_name,normalized_alias,enabled,created_at,updated_at)
|
||||
SELECT CONCAT('legacy-syb-',shop_id),shop_id,'syb',shop_name,normalized_name,enabled,created_at,updated_at FROM syb_allowed_shops
|
||||
ON DUPLICATE KEY UPDATE shop_id=VALUES(shop_id),alias_name=VALUES(alias_name),enabled=VALUES(enabled),updated_at=VALUES(updated_at)`); err != nil {
|
||||
return fmt.Errorf("迁移 SYB 店铺别名失败: %w", err)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO shop_channel_aliases(alias_id,shop_id,channel,alias_name,normalized_alias,enabled,created_at,updated_at)
|
||||
SELECT CONCAT('legacy-shopee-',shop_id),shop_id,'shopee',shop_name,normalized_name,1,created_at,updated_at FROM syb_allowed_shops
|
||||
ON DUPLICATE KEY UPDATE shop_id=VALUES(shop_id),alias_name=VALUES(alias_name),updated_at=VALUES(updated_at)`); err != nil {
|
||||
return fmt.Errorf("迁移蝦皮店铺别名失败: %w", err)
|
||||
}
|
||||
if _, err := db.Exec(`UPDATE syb_orders so JOIN shop_channel_aliases a
|
||||
ON a.channel='syb' AND a.normalized_alias=TRIM(so.shop_name) COLLATE utf8mb4_bin
|
||||
SET so.shop_id=a.shop_id WHERE so.shop_id IS NULL`); err != nil {
|
||||
return fmt.Errorf("回填顺运宝业务店铺失败: %w", err)
|
||||
}
|
||||
if _, err := db.Exec(`UPDATE shopee_products sp JOIN shop_channel_aliases a
|
||||
ON a.channel='shopee' AND a.normalized_alias=TRIM(sp.shopee_shop_name) COLLATE utf8mb4_bin
|
||||
SET sp.shop_id=a.shop_id WHERE sp.shop_id IS NULL`); err != nil {
|
||||
return fmt.Errorf("回填蝦皮业务店铺失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateMySQLV14 增加顺运宝同步店铺准入表和审计统计。
|
||||
func migrateMySQLV14(db *sql.DB) error {
|
||||
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS syb_allowed_shops (
|
||||
@@ -1667,7 +1787,10 @@ func CheckMySQLSchema(db *sql.DB) error {
|
||||
if err := checkMySQLV16Shape(db); err != nil {
|
||||
return err
|
||||
}
|
||||
return checkMySQLV17Shape(db)
|
||||
if err := checkMySQLV17Shape(db); err != nil {
|
||||
return err
|
||||
}
|
||||
return checkMySQLV18Shape(db)
|
||||
}
|
||||
|
||||
func checkMySQLV15Shape(db *sql.DB) error {
|
||||
@@ -1713,6 +1836,39 @@ func checkMySQLV17Shape(db *sql.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkMySQLV18Shape(db *sql.DB) error {
|
||||
if err := checkMySQLSchema(db, []string{"shops", "shop_channel_aliases"}); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, column := range []struct{ table, name string }{{"syb_orders", "shop_id"}, {"shopee_products", "shop_id"}} {
|
||||
if err := checkMySQLVarcharColumn(db, column.table, column.name, 191, true, "utf8mb4_bin", ""); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, item := range []struct{ table, name string }{
|
||||
{"shops", "uq_shops_name"}, {"shops", "idx_shops_enabled"},
|
||||
{"shop_channel_aliases", "uq_shop_channel_alias"}, {"shop_channel_aliases", "idx_shop_alias_shop"},
|
||||
{"syb_orders", "idx_syb_orders_shop_id"}, {"shopee_products", "idx_shopee_products_shop_id"},
|
||||
} {
|
||||
exists, err := mysqlIndexExists(db, item.table, item.name)
|
||||
if err != nil || !exists {
|
||||
return fmt.Errorf("店铺索引 %s.%s 缺失: %v", item.table, item.name, err)
|
||||
}
|
||||
}
|
||||
for _, item := range []struct{ table, name string }{
|
||||
{"shops", "fk_shops_user"}, {"shops", "chk_shops_enabled"},
|
||||
{"shop_channel_aliases", "fk_shop_alias_shop"}, {"shop_channel_aliases", "chk_shop_alias_channel"},
|
||||
{"shop_channel_aliases", "chk_shop_alias_enabled"}, {"syb_orders", "fk_syb_orders_shop"},
|
||||
{"shopee_products", "fk_shopee_products_shop"},
|
||||
} {
|
||||
exists, err := mysqlConstraintExists(db, item.table, item.name)
|
||||
if err != nil || !exists {
|
||||
return fmt.Errorf("店铺约束 %s.%s 缺失: %v", item.table, item.name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkMySQLV14Shape(db *sql.DB) error {
|
||||
if err := checkMySQLSchema(db, []string{"syb_allowed_shops"}); err != nil {
|
||||
return err
|
||||
|
||||
@@ -875,6 +875,57 @@ func TestMySQLMigrate_V14升级V17回填元数据规格并增加软删除(t *tes
|
||||
}
|
||||
}
|
||||
|
||||
func TestMySQLMigrate_V17升级V18迁移全局店铺并精确回填(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)
|
||||
}
|
||||
|
||||
// 退回真实 v17 形状,再放入旧准入配置和两侧历史数据。
|
||||
mustExec(t, db, `ALTER TABLE shopee_products DROP FOREIGN KEY fk_shopee_products_shop`)
|
||||
mustExec(t, db, `ALTER TABLE syb_orders DROP FOREIGN KEY fk_syb_orders_shop`)
|
||||
mustExec(t, db, `ALTER TABLE shopee_products DROP COLUMN shop_id`)
|
||||
mustExec(t, db, `ALTER TABLE syb_orders DROP COLUMN shop_id`)
|
||||
mustExec(t, db, `DROP TABLE shop_channel_aliases`)
|
||||
mustExec(t, db, `DROP TABLE shops`)
|
||||
mustExec(t, db, `DELETE FROM schema_migrations WHERE version=18`)
|
||||
now := "2026-08-13T02:00:00Z"
|
||||
mustExec(t, db, `INSERT INTO users(user_id,username,password_hash,role,status,password_changed_at,created_at,updated_at)
|
||||
VALUES('V18-USER','v18-user','x','admin','active',?,?,?)`, now, now, now)
|
||||
mustExec(t, db, `INSERT INTO syb_allowed_shops(shop_id,shop_name,normalized_name,enabled,created_by_user_id,created_at,updated_at)
|
||||
VALUES('V18-SHOP','精确店铺','精确店铺',1,'V18-USER',?,?)`, now, now)
|
||||
mustExec(t, db, `INSERT INTO syb_orders(syb_id,order_no,shop_name,title,quantity,syb_data,created_at,updated_at)
|
||||
VALUES('V18-ORDER','O-V18','精确店铺','商品',1,'{}',?,?)`, now, now)
|
||||
mustExec(t, db, `INSERT INTO shopee_products(goods_id,title,shopee_shop_name,source,created_at,updated_at)
|
||||
VALUES('V18-PRODUCT','商品','精确店铺','api',?,?),('V18-UNLINKED','商品','其他店铺','api',?,?)`, now, now, now, now)
|
||||
|
||||
if err := MigrateMySQL(db); err != nil {
|
||||
t.Fatalf("v17 升级 v18 失败: %v", err)
|
||||
}
|
||||
if err := MigrateMySQL(db); err != nil {
|
||||
t.Fatalf("v18 重放失败: %v", err)
|
||||
}
|
||||
if err := checkMySQLV18Shape(db); err != nil {
|
||||
t.Fatalf("v18 结构错误: %v", err)
|
||||
}
|
||||
for _, query := range []string{
|
||||
`SELECT shop_id FROM syb_orders WHERE syb_id='V18-ORDER'`,
|
||||
`SELECT shop_id FROM shopee_products WHERE goods_id='V18-PRODUCT'`,
|
||||
} {
|
||||
var shopID string
|
||||
if err := db.QueryRow(query).Scan(&shopID); err != nil || shopID != "V18-SHOP" {
|
||||
t.Fatalf("历史精确回填错误: shop=%q err=%v", shopID, err)
|
||||
}
|
||||
}
|
||||
var unlinked sql.NullString
|
||||
if err := db.QueryRow(`SELECT shop_id FROM shopee_products WHERE goods_id='V18-UNLINKED'`).Scan(&unlinked); err != nil || unlinked.Valid {
|
||||
t.Fatalf("无法确认的店铺不应猜测关联: %+v err=%v", unlinked, err)
|
||||
}
|
||||
}
|
||||
|
||||
func openMySQLMigrationTestDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
if os.Getenv("CMAUTOBUY_MYSQL_TEST") != "1" {
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"cmautobuy/admin/model"
|
||||
"github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrShopNameExists = errors.New("店铺名称已经存在")
|
||||
ErrShopAliasExists = errors.New("渠道店铺名称已经关联")
|
||||
ErrShopHasReferences = errors.New("店铺仍有关联数据")
|
||||
)
|
||||
|
||||
// ListShops 返回管理页的一店一行汇总;首版每个渠道维护一个主别名。
|
||||
func ListShops(q Execer) ([]model.Shop, error) {
|
||||
rows, err := q.Query(`SELECT s.shop_id,s.display_name,s.normalized_name,s.enabled,
|
||||
COALESCE(MAX(CASE WHEN a.channel='syb' THEN a.alias_name END),''),
|
||||
COALESCE(MAX(CASE WHEN a.channel='syb' THEN a.enabled END),0),
|
||||
COALESCE(MAX(CASE WHEN a.channel='shopee' THEN a.alias_name END),''),
|
||||
COUNT(DISTINCT sp.goods_id),s.created_by_user_id,s.created_at,s.updated_at
|
||||
FROM shops s
|
||||
LEFT JOIN shop_channel_aliases a ON a.shop_id=s.shop_id
|
||||
LEFT JOIN shopee_products sp ON sp.shop_id=s.shop_id AND sp.deleted_at IS NULL
|
||||
GROUP BY s.shop_id ORDER BY s.enabled DESC,s.normalized_name`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询店铺列表失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var result []model.Shop
|
||||
for rows.Next() {
|
||||
var item model.Shop
|
||||
var enabled, sybEnabled int
|
||||
if err := rows.Scan(&item.ShopID, &item.DisplayName, &item.NormalizedName, &enabled,
|
||||
&item.SybAlias, &sybEnabled, &item.ShopeeAlias, &item.ShopeeProductCount,
|
||||
&item.CreatedByUserID, &item.CreatedAt, &item.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.Enabled = enabled == 1
|
||||
item.SybSyncEnabled = sybEnabled == 1
|
||||
result = append(result, item)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// ListShopOptions 返回筛选器使用的全部业务店铺,停用项仍保留以便查历史数据。
|
||||
func ListShopOptions(q Execer) ([]model.Shop, error) {
|
||||
rows, err := q.Query(`SELECT shop_id,display_name,normalized_name,enabled,created_by_user_id,created_at,updated_at
|
||||
FROM shops ORDER BY enabled DESC,normalized_name`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询店铺选项失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var result []model.Shop
|
||||
for rows.Next() {
|
||||
var item model.Shop
|
||||
var enabled int
|
||||
if err := rows.Scan(&item.ShopID, &item.DisplayName, &item.NormalizedName, &enabled,
|
||||
&item.CreatedByUserID, &item.CreatedAt, &item.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.Enabled = enabled == 1
|
||||
result = append(result, item)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func GetShop(q Execer, shopID string) (model.Shop, bool, error) {
|
||||
var item model.Shop
|
||||
var enabled int
|
||||
err := q.QueryRow(`SELECT shop_id,display_name,normalized_name,enabled,created_by_user_id,created_at,updated_at
|
||||
FROM shops WHERE shop_id=?`, shopID).Scan(&item.ShopID, &item.DisplayName,
|
||||
&item.NormalizedName, &enabled, &item.CreatedByUserID, &item.CreatedAt, &item.UpdatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return model.Shop{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return model.Shop{}, false, fmt.Errorf("查询店铺失败: %w", err)
|
||||
}
|
||||
item.Enabled = enabled == 1
|
||||
return item, true, nil
|
||||
}
|
||||
|
||||
func GetShopAliasEnabled(q Execer, shopID, channel string) (bool, bool, error) {
|
||||
var enabled int
|
||||
err := q.QueryRow(`SELECT enabled FROM shop_channel_aliases WHERE shop_id=? AND channel=? LIMIT 1`,
|
||||
shopID, channel).Scan(&enabled)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, false, fmt.Errorf("查询渠道店铺状态失败: %w", err)
|
||||
}
|
||||
return enabled == 1, true, nil
|
||||
}
|
||||
|
||||
// ListEnabledSybShopMappings 返回“SYB 原始精确名称 → 业务店铺 ID”。
|
||||
func ListEnabledSybShopMappings(q Execer) (map[string]string, error) {
|
||||
rows, err := q.Query(`SELECT a.normalized_alias,a.shop_id FROM shop_channel_aliases a
|
||||
JOIN shops s ON s.shop_id=a.shop_id WHERE a.channel='syb' AND a.enabled=1 AND s.enabled=1
|
||||
ORDER BY a.normalized_alias`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询启用的 SYB 店铺配置失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
result := map[string]string{}
|
||||
for rows.Next() {
|
||||
var name, shopID string
|
||||
if err := rows.Scan(&name, &shopID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[name] = shopID
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func FindShopIDByAlias(q Execer, channel, rawName string) (string, error) {
|
||||
name := strings.TrimSpace(rawName)
|
||||
if name == "" {
|
||||
return "", nil
|
||||
}
|
||||
var shopID string
|
||||
err := q.QueryRow(`SELECT shop_id FROM shop_channel_aliases
|
||||
WHERE channel=? AND normalized_alias=?`, channel, name).Scan(&shopID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("按 %s 店铺名称查业务店铺失败: %w", channel, err)
|
||||
}
|
||||
return shopID, nil
|
||||
}
|
||||
|
||||
func InsertShop(q Execer, item model.Shop) error {
|
||||
_, err := q.Exec(`INSERT INTO shops(shop_id,display_name,normalized_name,enabled,created_by_user_id,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?)`, item.ShopID, item.DisplayName, item.NormalizedName, item.Enabled,
|
||||
item.CreatedByUserID, item.CreatedAt, item.UpdatedAt)
|
||||
if err != nil {
|
||||
var mysqlErr *mysql.MySQLError
|
||||
if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 {
|
||||
return ErrShopNameExists
|
||||
}
|
||||
return fmt.Errorf("新增店铺失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func InsertShopAlias(q Execer, item model.ShopChannelAlias) error {
|
||||
_, err := q.Exec(`INSERT INTO shop_channel_aliases(alias_id,shop_id,channel,alias_name,normalized_alias,enabled,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?)`, item.AliasID, item.ShopID, item.Channel, item.AliasName,
|
||||
item.NormalizedAlias, item.Enabled, item.CreatedAt, item.UpdatedAt)
|
||||
if err != nil {
|
||||
var mysqlErr *mysql.MySQLError
|
||||
if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 {
|
||||
return ErrShopAliasExists
|
||||
}
|
||||
return fmt.Errorf("新增渠道店铺名称失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func UpdateShopName(q Execer, shopID, displayName, normalizedName, updatedAt string) (bool, error) {
|
||||
result, err := q.Exec(`UPDATE shops SET display_name=?,normalized_name=?,updated_at=? WHERE shop_id=?`,
|
||||
displayName, normalizedName, updatedAt, shopID)
|
||||
if err != nil {
|
||||
var mysqlErr *mysql.MySQLError
|
||||
if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 {
|
||||
return false, ErrShopNameExists
|
||||
}
|
||||
return false, fmt.Errorf("更新店铺名称失败: %w", err)
|
||||
}
|
||||
n, err := result.RowsAffected()
|
||||
return n == 1, err
|
||||
}
|
||||
|
||||
func SetShopEnabled(q Execer, shopID string, enabled bool, updatedAt string) (bool, error) {
|
||||
result, err := q.Exec(`UPDATE shops SET enabled=?,updated_at=? WHERE shop_id=?`, enabled, updatedAt, shopID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("更新店铺状态失败: %w", err)
|
||||
}
|
||||
n, err := result.RowsAffected()
|
||||
return n == 1, err
|
||||
}
|
||||
|
||||
func SetShopSybEnabled(q Execer, shopID string, enabled bool, updatedAt string) (bool, error) {
|
||||
result, err := q.Exec(`UPDATE shop_channel_aliases SET enabled=?,updated_at=?
|
||||
WHERE shop_id=? AND channel='syb'`, enabled, updatedAt, shopID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("更新 SYB 同步状态失败: %w", err)
|
||||
}
|
||||
n, err := result.RowsAffected()
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
func ReplaceShopAlias(q Execer, aliasID, shopID, channel, aliasName, updatedAt string, enabled bool) error {
|
||||
if _, err := q.Exec(`DELETE FROM shop_channel_aliases WHERE shop_id=? AND channel=?`, shopID, channel); err != nil {
|
||||
return fmt.Errorf("移除旧渠道店铺名称失败: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(aliasName) == "" {
|
||||
return nil
|
||||
}
|
||||
return InsertShopAlias(q, model.ShopChannelAlias{AliasID: aliasID, ShopID: shopID, Channel: channel,
|
||||
AliasName: aliasName, NormalizedAlias: strings.TrimSpace(aliasName), Enabled: enabled,
|
||||
CreatedAt: updatedAt, UpdatedAt: updatedAt})
|
||||
}
|
||||
|
||||
// RebuildShopAssociations 按渠道原始名称精确重建一个店铺的派生关联。
|
||||
func RebuildShopAssociations(q Execer, shopID string) error {
|
||||
if _, err := q.Exec(`UPDATE syb_orders SET shop_id=NULL WHERE shop_id=?`, shopID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := q.Exec(`UPDATE shopee_products SET shop_id=NULL WHERE shop_id=?`, shopID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := q.Exec(`UPDATE syb_orders so JOIN shop_channel_aliases a
|
||||
ON a.shop_id=? AND a.channel='syb' AND a.normalized_alias=TRIM(so.shop_name) COLLATE utf8mb4_bin
|
||||
SET so.shop_id=a.shop_id`, shopID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := q.Exec(`UPDATE shopee_products sp JOIN shop_channel_aliases a
|
||||
ON a.shop_id=? AND a.channel='shopee' AND a.normalized_alias=TRIM(sp.shopee_shop_name) COLLATE utf8mb4_bin
|
||||
SET sp.shop_id=a.shop_id`, shopID); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func DeleteDisabledShop(q Execer, shopID string) (bool, error) {
|
||||
var references int
|
||||
if err := q.QueryRow(`SELECT (SELECT COUNT(*) FROM syb_orders WHERE shop_id=?)+
|
||||
(SELECT COUNT(*) FROM shopee_products WHERE shop_id=?)`, shopID, shopID).Scan(&references); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if references > 0 {
|
||||
return false, ErrShopHasReferences
|
||||
}
|
||||
result, err := q.Exec(`DELETE FROM shops WHERE shop_id=? AND enabled=0`, shopID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("删除停用店铺失败: %w", err)
|
||||
}
|
||||
n, err := result.RowsAffected()
|
||||
return n == 1, err
|
||||
}
|
||||
|
||||
func CountUnlinkedShopeeShops(q Execer) (int, error) {
|
||||
var count int
|
||||
err := q.QueryRow(`SELECT COUNT(*) FROM shopee_products WHERE deleted_at IS NULL AND shop_id IS NULL`).Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
+25
-12
@@ -111,12 +111,13 @@ func UpsertShopeeSKU(q Execer, skuID, goodsID, specRaw, color, size, advice stri
|
||||
// NULL 表示这个蝦皮商品还没填 PDD 链接(LEFT JOIN 没查到对应行)。
|
||||
type ShopeeProductRow struct {
|
||||
model.ShopeeProduct
|
||||
ColorCount int
|
||||
SizeCount int
|
||||
SKUCount int
|
||||
PendingCount int
|
||||
CollectStatus sql.NullString
|
||||
CollectMsg sql.NullString
|
||||
BusinessShopName string
|
||||
ColorCount int
|
||||
SizeCount int
|
||||
SKUCount int
|
||||
PendingCount int
|
||||
CollectStatus sql.NullString
|
||||
CollectMsg sql.NullString
|
||||
}
|
||||
|
||||
// ShopeeFilter 是蝦皮商品列表页支持的筛选条件,四项都可以为空。
|
||||
@@ -136,6 +137,7 @@ type ShopeeFilter struct {
|
||||
Status string
|
||||
Shop string // has / missing / 空(全部)
|
||||
Image string // has / missing / 空(全部)
|
||||
StoreID string // 业务店铺 ID / unlinked / 空(全部)
|
||||
Deleted bool // true 只看软删除数据;false 只看正常数据
|
||||
}
|
||||
|
||||
@@ -186,6 +188,14 @@ func shopeeFilterClause(filter ShopeeFilter) (string, []any) {
|
||||
case "missing":
|
||||
clauses = append(clauses, `(sp.image_url IS NULL OR TRIM(sp.image_url) = '')`)
|
||||
}
|
||||
switch strings.TrimSpace(filter.StoreID) {
|
||||
case "":
|
||||
case "unlinked":
|
||||
clauses = append(clauses, `sp.shop_id IS NULL`)
|
||||
default:
|
||||
clauses = append(clauses, `sp.shop_id=?`)
|
||||
args = append(args, strings.TrimSpace(filter.StoreID))
|
||||
}
|
||||
|
||||
return " WHERE " + strings.Join(clauses, " AND "), args
|
||||
}
|
||||
@@ -200,7 +210,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, sp.image_url,sp.shopee_shop_name,sp.image_source,sp.image_observed_at,sp.image_is_manual,sp.shop_name_source,sp.shop_name_observed_at,sp.shop_name_is_manual,sp.source,
|
||||
SELECT sp.goods_id,sp.shop_id,COALESCE(MAX(bs.display_name),''), sp.title, sp.shopee_status, sp.main_sku_code, sp.image_url,sp.shopee_shop_name,sp.image_source,sp.image_observed_at,sp.image_is_manual,sp.shop_name_source,sp.shop_name_observed_at,sp.shop_name_is_manual,sp.source,
|
||||
sp.pdd_goods_url, sp.pdd_goods_id,sp.deleted_at,sp.deleted_by_user_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,
|
||||
@@ -208,6 +218,7 @@ func ListShopeeProducts(q Execer, filter ShopeeFilter, limit, offset int) ([]Sho
|
||||
COUNT(CASE WHEN sk.parse_ok = 0 THEN 1 END) AS pending_count,
|
||||
pp.collect_status, pp.collect_msg
|
||||
FROM shopee_products sp
|
||||
LEFT JOIN shops bs ON bs.shop_id=sp.shop_id
|
||||
LEFT JOIN shopee_skus sk ON sk.goods_id = sp.goods_id
|
||||
LEFT JOIN pdd_products pp
|
||||
ON pp.goods_id = sp.pdd_goods_id AND pp.deleted_at IS NULL` +
|
||||
@@ -223,10 +234,10 @@ func ListShopeeProducts(q Execer, filter ShopeeFilter, limit, offset int) ([]Sho
|
||||
var list []ShopeeProductRow
|
||||
for rows.Next() {
|
||||
var r ShopeeProductRow
|
||||
var title, shopeeStatus, mainSKUCode, imageURL, shopName, imageSource, imageObserved, shopSource, shopObserved, source, pddGoodsURL, pddGoodsID, deletedAt, deletedBy sql.NullString
|
||||
var shopID, title, shopeeStatus, mainSKUCode, imageURL, shopName, imageSource, imageObserved, shopSource, shopObserved, source, pddGoodsURL, pddGoodsID, deletedAt, deletedBy sql.NullString
|
||||
var imageManual, shopManual int
|
||||
if err := rows.Scan(
|
||||
&r.GoodsID, &title, &shopeeStatus, &mainSKUCode, &imageURL, &shopName, &imageSource, &imageObserved, &imageManual, &shopSource, &shopObserved, &shopManual, &source,
|
||||
&r.GoodsID, &shopID, &r.BusinessShopName, &title, &shopeeStatus, &mainSKUCode, &imageURL, &shopName, &imageSource, &imageObserved, &imageManual, &shopSource, &shopObserved, &shopManual, &source,
|
||||
&pddGoodsURL, &pddGoodsID, &deletedAt, &deletedBy, &r.CreatedAt, &r.UpdatedAt,
|
||||
&r.ColorCount, &r.SizeCount, &r.SKUCount, &r.PendingCount,
|
||||
&r.CollectStatus, &r.CollectMsg,
|
||||
@@ -234,6 +245,7 @@ func ListShopeeProducts(q Execer, filter ShopeeFilter, limit, offset int) ([]Sho
|
||||
return nil, fmt.Errorf("读取蝦皮商品列表失败: %w", err)
|
||||
}
|
||||
r.Title = title.String
|
||||
r.ShopID = shopID.String
|
||||
r.ShopeeStatus = shopeeStatus.String
|
||||
r.MainSKUCode = mainSKUCode.String
|
||||
r.ImageURL = imageURL.String
|
||||
@@ -273,13 +285,13 @@ 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, imageURL, shopName, imageSource, imageObserved, shopSource, shopObserved, source, pddGoodsURL, pddGoodsID, deletedAt, deletedBy sql.NullString
|
||||
var shopID, title, shopeeStatus, mainSKUCode, imageURL, shopName, imageSource, imageObserved, shopSource, shopObserved, source, pddGoodsURL, pddGoodsID, deletedAt, deletedBy sql.NullString
|
||||
var imageManual, shopManual int
|
||||
err := q.QueryRow(`
|
||||
SELECT goods_id, title, shopee_status, main_sku_code,image_url,shopee_shop_name,image_source,image_observed_at,image_is_manual,shop_name_source,shop_name_observed_at,shop_name_is_manual, source,
|
||||
SELECT goods_id,shop_id, title, shopee_status, main_sku_code,image_url,shopee_shop_name,image_source,image_observed_at,image_is_manual,shop_name_source,shop_name_observed_at,shop_name_is_manual, source,
|
||||
pdd_goods_url, pdd_goods_id,deleted_at,deleted_by_user_id, created_at, updated_at
|
||||
FROM shopee_products WHERE goods_id = ? AND deleted_at IS NULL`, goodsID).Scan(
|
||||
&p.GoodsID, &title, &shopeeStatus, &mainSKUCode, &imageURL, &shopName, &imageSource, &imageObserved, &imageManual, &shopSource, &shopObserved, &shopManual, &source,
|
||||
&p.GoodsID, &shopID, &title, &shopeeStatus, &mainSKUCode, &imageURL, &shopName, &imageSource, &imageObserved, &imageManual, &shopSource, &shopObserved, &shopManual, &source,
|
||||
&pddGoodsURL, &pddGoodsID, &deletedAt, &deletedBy, &p.CreatedAt, &p.UpdatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
@@ -288,6 +300,7 @@ func GetShopeeProductByGoodsID(q Execer, goodsID string) (*model.ShopeeProduct,
|
||||
return nil, fmt.Errorf("查询蝦皮商品 %s 失败: %w", goodsID, err)
|
||||
}
|
||||
p.Title = title.String
|
||||
p.ShopID = shopID.String
|
||||
p.ShopeeStatus = shopeeStatus.String
|
||||
p.MainSKUCode = mainSKUCode.String
|
||||
p.ImageURL = imageURL.String
|
||||
|
||||
+24
-99
@@ -12,100 +12,10 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/go-sql-driver/mysql"
|
||||
|
||||
"cmautobuy/admin/model"
|
||||
"cmautobuy/admin/spec"
|
||||
)
|
||||
|
||||
var ErrSybAllowedShopExists = errors.New("顺运宝允许店铺已经存在")
|
||||
|
||||
// ListSybAllowedShops 返回全部准入项;管理页需要同时看到已停用项。
|
||||
func ListSybAllowedShops(q Execer) ([]model.SybAllowedShop, error) {
|
||||
rows, err := q.Query(`SELECT shop_id,shop_name,normalized_name,enabled,created_by_user_id,created_at,updated_at
|
||||
FROM syb_allowed_shops ORDER BY enabled DESC,normalized_name`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询顺运宝允许店铺失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var result []model.SybAllowedShop
|
||||
for rows.Next() {
|
||||
var item model.SybAllowedShop
|
||||
var enabled int
|
||||
if err := rows.Scan(&item.ShopID, &item.ShopName, &item.NormalizedName, &enabled,
|
||||
&item.CreatedByUserID, &item.CreatedAt, &item.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("读取顺运宝允许店铺失败: %w", err)
|
||||
}
|
||||
item.Enabled = enabled == 1
|
||||
result = append(result, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("读取顺运宝允许店铺失败: %w", err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func ListEnabledSybShopNames(q Execer) ([]string, error) {
|
||||
rows, err := q.Query(`SELECT normalized_name FROM syb_allowed_shops WHERE enabled=1 ORDER BY normalized_name`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询启用的顺运宝店铺失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var names []string
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
return nil, fmt.Errorf("读取启用的顺运宝店铺失败: %w", err)
|
||||
}
|
||||
names = append(names, name)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("读取启用的顺运宝店铺失败: %w", err)
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
func InsertSybAllowedShop(q Execer, item model.SybAllowedShop) error {
|
||||
_, err := q.Exec(`INSERT INTO syb_allowed_shops
|
||||
(shop_id,shop_name,normalized_name,enabled,created_by_user_id,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?)`, item.ShopID, item.ShopName, item.NormalizedName, item.Enabled,
|
||||
item.CreatedByUserID, item.CreatedAt, item.UpdatedAt)
|
||||
if err != nil {
|
||||
var mysqlErr *mysql.MySQLError
|
||||
if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 {
|
||||
return ErrSybAllowedShopExists
|
||||
}
|
||||
return fmt.Errorf("新增顺运宝允许店铺失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func SetSybAllowedShopEnabled(q Execer, shopID string, enabled bool, updatedAt string) (bool, error) {
|
||||
result, err := q.Exec(`UPDATE syb_allowed_shops SET enabled=?,updated_at=? WHERE shop_id=?`, enabled, updatedAt, shopID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("更新顺运宝允许店铺失败: %w", err)
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("确认顺运宝允许店铺更新结果失败: %w", err)
|
||||
}
|
||||
return affected == 1, nil
|
||||
}
|
||||
|
||||
// DeleteDisabledSybAllowedShop 只删除提交瞬间仍处于停用状态的配置项。
|
||||
// enabled 条件是最终并发保护,不能只依赖管理页隐藏启用项的删除按钮。
|
||||
func DeleteDisabledSybAllowedShop(q Execer, shopID string) (bool, error) {
|
||||
result, err := q.Exec(`DELETE FROM syb_allowed_shops WHERE shop_id=? AND enabled=0`, shopID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("删除已停用的顺运宝店铺失败: %w", err)
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("确认顺运宝店铺删除结果失败: %w", err)
|
||||
}
|
||||
return affected == 1, nil
|
||||
}
|
||||
|
||||
// ---------- 会话缓存 ----------
|
||||
|
||||
// SaveSybSession 写入或更新顺运宝登录会话缓存(按用户名 upsert)。
|
||||
@@ -333,6 +243,12 @@ func UpsertSybOrder(q Execer, o model.SybOrder) (created bool, err error) {
|
||||
if o.SybID == "" {
|
||||
return false, fmt.Errorf("syb_id 不能为空")
|
||||
}
|
||||
if strings.TrimSpace(o.ShopID) == "" && strings.TrimSpace(o.ShopName) != "" {
|
||||
o.ShopID, err = FindShopIDByAlias(q, "syb", o.ShopName)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("解析顺运宝明细 %s 的业务店铺失败: %w", o.SybID, err)
|
||||
}
|
||||
}
|
||||
var specKey any
|
||||
if strings.TrimSpace(o.ProductSpec) != "" {
|
||||
key, keyErr := spec.SpecKey(o.ProductSpec)
|
||||
@@ -353,17 +269,18 @@ func UpsertSybOrder(q Execer, o model.SybOrder) (created bool, err error) {
|
||||
|
||||
now := model.NowISO()
|
||||
if strings.TrimSpace(o.ShopeeGoodsID) != "" {
|
||||
if err := upsertSybShopeeProduct(q, o.ShopeeGoodsID, o.Title, o.ShopName, o.ImageURL, now); err != nil {
|
||||
if err := upsertSybShopeeProduct(q, o.ShopeeGoodsID, o.ShopID, o.Title, o.ShopName, o.ImageURL, now); err != nil {
|
||||
return false, fmt.Errorf("为顺运宝明细 %s 补建蝦皮商品骨架失败: %w", o.SybID, err)
|
||||
}
|
||||
}
|
||||
_, err = q.Exec(`
|
||||
INSERT INTO syb_orders
|
||||
(syb_id, order_no, shop_name, title, product_spec, spec_key, shopee_goods_id,
|
||||
(syb_id, order_no, shop_id, shop_name, title, product_spec, spec_key, shopee_goods_id,
|
||||
quantity, price_twd_cent, image_url, syb_data, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, NULLIF(?,''), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
order_no = VALUES(order_no),
|
||||
shop_id = VALUES(shop_id),
|
||||
shop_name = VALUES(shop_name),
|
||||
title = VALUES(title),
|
||||
product_spec = VALUES(product_spec),
|
||||
@@ -374,7 +291,7 @@ func UpsertSybOrder(q Execer, o model.SybOrder) (created bool, err error) {
|
||||
image_url = VALUES(image_url),
|
||||
syb_data = VALUES(syb_data),
|
||||
updated_at = VALUES(updated_at)`,
|
||||
o.SybID, o.OrderNo, nullableText(o.ShopName), o.Title, nullableText(o.ProductSpec), specKey, nullableText(o.ShopeeGoodsID),
|
||||
o.SybID, o.OrderNo, strings.TrimSpace(o.ShopID), nullableText(o.ShopName), o.Title, nullableText(o.ProductSpec), specKey, nullableText(o.ShopeeGoodsID),
|
||||
o.Quantity, o.PriceTwdCent, nullableText(o.ImageURL),
|
||||
o.SybData, now, now)
|
||||
if err != nil {
|
||||
@@ -387,20 +304,24 @@ func UpsertSybOrder(q Execer, o model.SybOrder) (created bool, err error) {
|
||||
//
|
||||
// 店铺和图片是字段级低优先级数据:只能补空值,或更新原本同样来自 syb 的值;
|
||||
// 人工字段和商品目录等权威来源永远不被顺运宝覆盖。空值也不能清除已有内容。
|
||||
func upsertSybShopeeProduct(q Execer, goodsID, title, shopName, imageURL, observedAt string) error {
|
||||
func upsertSybShopeeProduct(q Execer, goodsID, shopID, title, shopName, imageURL, observedAt string) error {
|
||||
_, err := q.Exec(`
|
||||
INSERT INTO shopee_products
|
||||
(goods_id,title,image_url,shopee_shop_name,
|
||||
(goods_id,shop_id,title,image_url,shopee_shop_name,
|
||||
image_source,image_observed_at,image_is_manual,
|
||||
shop_name_source,shop_name_observed_at,shop_name_is_manual,
|
||||
source,source_observed_at,created_at,updated_at)
|
||||
VALUES (?, ?, NULLIF(TRIM(?),''), NULLIF(TRIM(?),''),
|
||||
VALUES (?, NULLIF(TRIM(?),''), ?, NULLIF(TRIM(?),''), NULLIF(TRIM(?),''),
|
||||
CASE WHEN TRIM(?)='' THEN NULL ELSE 'syb' END,
|
||||
CASE WHEN TRIM(?)='' THEN NULL ELSE ? END, 0,
|
||||
CASE WHEN TRIM(?)='' THEN NULL ELSE 'syb' END,
|
||||
CASE WHEN TRIM(?)='' THEN NULL ELSE ? END, 0,
|
||||
'syb', ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
shop_id = CASE
|
||||
WHEN VALUES(shop_id) IS NOT NULL AND (shop_id IS NULL OR shop_name_source='syb') THEN VALUES(shop_id)
|
||||
ELSE shop_id
|
||||
END,
|
||||
title = CASE
|
||||
WHEN source='syb' AND TRIM(VALUES(title))<>'' THEN VALUES(title)
|
||||
ELSE title
|
||||
@@ -445,7 +366,7 @@ func upsertSybShopeeProduct(q Execer, goodsID, title, shopName, imageURL, observ
|
||||
OR (shop_name_source='syb' AND TRIM(VALUES(shopee_shop_name))<>'') THEN VALUES(updated_at)
|
||||
ELSE updated_at
|
||||
END`,
|
||||
goodsID, title, imageURL, shopName,
|
||||
goodsID, shopID, title, imageURL, shopName,
|
||||
imageURL, imageURL, observedAt,
|
||||
shopName, shopName, observedAt,
|
||||
observedAt, observedAt, observedAt)
|
||||
@@ -490,7 +411,11 @@ func backfillSybProductMetadata(db *sql.DB) error {
|
||||
return fmt.Errorf("读取历史顺运宝图片失败: %w", err)
|
||||
}
|
||||
for goodsID, item := range items {
|
||||
if err := upsertSybShopeeProduct(db, goodsID, "", item.shopName, item.imageURL, item.observedAt); err != nil {
|
||||
shopID, err := FindShopIDByAlias(db, "syb", item.shopName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("解析历史顺运宝店铺失败: %w", err)
|
||||
}
|
||||
if err := upsertSybShopeeProduct(db, goodsID, shopID, "", item.shopName, item.imageURL, item.observedAt); err != nil {
|
||||
return fmt.Errorf("回填蝦皮商品 %s 的顺运宝元数据失败: %w", goodsID, err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user