feat: 增加顺运宝店铺筛选 (#151)

This commit is contained in:
chengma
2026-08-11 14:39:13 +08:00
parent 30bdab3e2e
commit 9d4d6fc1e5
20 changed files with 313 additions and 56 deletions
+38 -1
View File
@@ -265,7 +265,7 @@ var migrations = [][]string{
// 背景见 #20:v1 曾经被原地改写而不是新增版本,导致已经建过库的机器
// (user_version 已经越过 v1)永远不会重跑改写后的语句,程序拿着一个
// 和代码对不上的库静默启动。
const schemaVersion = 8
const schemaVersion = 9
// migrationV4 给 PDD 商品增加店铺名。
//
@@ -380,6 +380,18 @@ var migrationV8 = []string{
ON syb_sync_runs(started_at DESC, run_id DESC);`,
}
// migrationV9 把顺运宝原始 JSON 里的货运单店铺名提升为可展示、可筛选的独立列。
// 原始 syb_data 永久保留;无效 JSON、缺字段和纯空白值都安全地保持 NULL。
var migrationV9 = []string{
`ALTER TABLE syb_orders ADD COLUMN shop_name TEXT;`,
`UPDATE syb_orders
SET shop_name = CASE
WHEN json_valid(syb_data) THEN NULLIF(TRIM(json_extract(syb_data, '$.stock.shopName')), '')
ELSE NULL
END
WHERE shop_name IS NULL;`,
}
// Migrate 把数据库升到最新版本。
// 已经是最新的就什么都不做,可以重复调用。
func Migrate(db *sql.DB) error {
@@ -466,6 +478,14 @@ func Migrate(db *sql.DB) error {
if err := runSQLMigration(db, 8, migrationV8); err != nil {
return err
}
reached = 8
}
// v9 增加顺运宝店铺列并从仍保留的原始 JSON 回填。
if reached < 9 {
if err := runSQLMigration(db, 9, migrationV9); err != nil {
return err
}
}
return nil
@@ -940,6 +960,12 @@ var requiredColumns = map[string][]string{
"syb_sync_runs": {"user_id", "date_from", "date_to", "status", "cursor_advanced", "started_at", "finished_at"},
}
// SQLite 当前测试库比 MySQL v1 多走一版迁移;这里单独检查,避免 MySQL
// 在执行 v1 自检时提前要求尚未由 v11 创建的列。
var sqliteOnlyRequiredColumns = map[string][]string{
"syb_orders": {"shop_name"},
}
// CheckSchema 在 Migrate 成功后调用,确认代码依赖的表都在。
//
// [必须] 缺表就返回错误,调用方要**拒绝启动**,不是打个警告继续跑。
@@ -996,5 +1022,16 @@ func CheckSchema(db *sql.DB) error {
}
}
}
for table, columns := range sqliteOnlyRequiredColumns {
existingColumns, err := tableColumnSet(db, table)
if err != nil {
return fmt.Errorf("检查数据表 %s 的列失败: %w", table, err)
}
for _, column := range columns {
if !existingColumns[column] {
return fmt.Errorf("数据库结构与本程序不匹配:数据表 %s 缺少列 %s", table, column)
}
}
}
return nil
}
+36 -1
View File
@@ -657,7 +657,7 @@ func TestMigrate_v8新增顺运宝同步记录表(t *testing.T) {
t.Fatal("v8 应该新增 syb_sync_runs 表")
}
var version int
if err := db.QueryRow(`PRAGMA user_version`).Scan(&version); err != nil || version != 8 {
if err := db.QueryRow(`PRAGMA user_version`).Scan(&version); err != nil || version != schemaVersion {
t.Fatalf("迁移版本错误: version=%d err=%v", version, err)
}
user := model.User{
@@ -683,6 +683,41 @@ func TestMigrate_v8新增顺运宝同步记录表(t *testing.T) {
}
}
func TestMigrate_v9回填顺运宝店铺名(t *testing.T) {
db := newPublishedVersionDB(t, 7)
if err := runSQLMigration(db, 8, migrationV8); err != nil {
t.Fatal(err)
}
now := model.NowISO()
for _, row := range []struct{ id, raw string }{
{"shop", `{"stock":{"shopName":" 测试店铺 "}}`},
{"blank", `{"stock":{"shopName":" "}}`},
{"missing", `{"stock":{}}`},
{"invalid", `{not-json`},
} {
if _, err := db.Exec(`INSERT INTO syb_orders
(syb_id,order_no,title,product_spec,quantity,syb_data,created_at,updated_at)
VALUES (?,?,'商品','黑色,M',1,?,?,?)`, row.id, row.id, row.raw, now, now); err != nil {
t.Fatal(err)
}
}
if err := Migrate(db); err != nil {
t.Fatalf("v8 升级 v9 失败: %v", err)
}
var shop sql.NullString
if err := db.QueryRow(`SELECT shop_name FROM syb_orders WHERE syb_id='shop'`).Scan(&shop); err != nil || !shop.Valid || shop.String != "测试店铺" {
t.Fatalf("店铺回填错误: %+v err=%v", shop, err)
}
for _, id := range []string{"blank", "missing", "invalid"} {
if err := db.QueryRow(`SELECT shop_name FROM syb_orders WHERE syb_id=?`, id).Scan(&shop); err != nil || shop.Valid {
t.Fatalf("%s 应保持 NULL: %+v err=%v", id, shop, err)
}
}
if err := Migrate(db); err != nil {
t.Fatalf("v9 重复迁移失败: %v", err)
}
}
func newPublishedVersionDB(t *testing.T, version int) *sql.DB {
t.Helper()
db, err := Open(t.TempDir())
+45 -2
View File
@@ -19,7 +19,7 @@ import (
"cmautobuy/admin/spec"
)
const mysqlSchemaVersion = 10
const mysqlSchemaVersion = 11
// OpenMySQL 打开生产 MySQL 8 数据库。错误信息绝不包含完整 DSN 或密码。
func OpenMySQL(cfg config.DatabaseConfig) (*sql.DB, error) {
@@ -543,10 +543,50 @@ func MigrateMySQL(db *sql.DB) error {
if _, err := db.Exec(`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`, 10, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
return fmt.Errorf("记录 MySQL schema v10 失败: %w", err)
}
current = 10
}
if current < 11 {
if err := migrateMySQLV11(db); err != nil {
return fmt.Errorf("执行 MySQL schema v11 失败: %w", err)
}
if err := checkMySQLV11Shape(db); err != nil {
return fmt.Errorf("MySQL schema v11 自检失败,未记录版本: %w", err)
}
if _, err := db.Exec(`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`, 11, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
return fmt.Errorf("记录 MySQL schema v11 失败: %w", err)
}
}
return CheckMySQLSchema(db)
}
// migrateMySQLV11 增加顺运宝店铺列,并从保留的原始 JSON 回填历史数据。
// DDL 和 UPDATE 都可重放:中断后再次启动只补缺列和仍为空的记录。
func migrateMySQLV11(db *sql.DB) error {
exists, err := mysqlColumnExists(db, "syb_orders", "shop_name")
if err != nil {
return err
}
if !exists {
if _, err := db.Exec(`ALTER TABLE syb_orders ADD COLUMN shop_name VARCHAR(500) NULL AFTER order_no`); err != nil {
return fmt.Errorf("增加 syb_orders.shop_name 失败: %w", err)
}
}
if _, err := db.Exec(`UPDATE syb_orders
SET shop_name=NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(
IF(JSON_VALID(syb_data),syb_data,'{}'),'$.stock.shopName'))),'')
WHERE shop_name IS NULL`); err != nil {
return fmt.Errorf("回填顺运宝店铺名失败: %w", err)
}
return nil
}
func checkMySQLV11Shape(db *sql.DB) error {
if err := checkMySQLVarcharColumn(db, "syb_orders", "shop_name", 500, true, "utf8mb4_0900_ai_ci", ""); err != nil {
return err
}
return checkMySQLNullDefault(db, "syb_orders", "shop_name")
}
// migrateMySQLV10 修复曾在 v8/v9 开发中间状态记录过版本的数据库。
// 两版迁移均逐项检查结构,v8 回填只处理空值,因此可安全重放;
// 历史 v8/v9 版本记录保持不变,完整自检通过后再追加 v10。
@@ -564,7 +604,10 @@ func checkMySQLV10Shape(db *sql.DB) error {
if err := checkMySQLV8Shape(db); err != nil {
return err
}
return checkMySQLV9Shape(db)
if err := checkMySQLV9Shape(db); err != nil {
return err
}
return nil
}
func migrateMySQLV9(db *sql.DB) error {
@@ -8,6 +8,7 @@ import (
"testing"
"cmautobuy/admin/config"
"cmautobuy/admin/model"
"cmautobuy/admin/spec"
)
@@ -550,6 +551,40 @@ func TestMySQLMigrate_V10执行后未记版本可继续收敛(t *testing.T) {
}
}
func TestMySQLMigrate_V11回填顺运宝店铺且可重放(t *testing.T) {
db := openMySQLMigrationTestDB(t)
defer db.Close()
cleanMySQLTestSchema(t, db)
defer cleanMySQLTestSchema(t, db)
if err := MigrateMySQL(db); err != nil {
t.Fatal(err)
}
mustExec(t, db, `DELETE FROM schema_migrations WHERE version=11`)
mustExec(t, db, `ALTER TABLE syb_orders DROP COLUMN shop_name`)
now := model.NowISO()
mustExec(t, db, `INSERT INTO syb_orders
(syb_id,order_no,title,product_spec,quantity,syb_data,created_at,updated_at) VALUES
('S1','O1','商品','黑色,M',1,'{"stock":{"shopName":" 测试店铺 "}}',?,?),
('S2','O2','商品','白色,L',1,'{not-json',?,?)`, now, now, now, now)
if err := MigrateMySQL(db); err != nil {
t.Fatalf("v10 升级 v11 失败: %v", err)
}
var shop sql.NullString
if err := db.QueryRow(`SELECT shop_name FROM syb_orders WHERE syb_id='S1'`).Scan(&shop); err != nil || !shop.Valid || shop.String != "测试店铺" {
t.Fatalf("MySQL 店铺回填错误: %+v err=%v", shop, err)
}
if err := db.QueryRow(`SELECT shop_name FROM syb_orders WHERE syb_id='S2'`).Scan(&shop); err != nil || shop.Valid {
t.Fatalf("无效 JSON 应保持 NULL: %+v err=%v", shop, err)
}
if err := MigrateMySQL(db); err != nil {
t.Fatalf("v11 重放失败: %v", err)
}
var count int
if err := db.QueryRow(`SELECT COUNT(*) FROM schema_migrations WHERE version=11`).Scan(&count); err != nil || count != 1 {
t.Fatalf("v11 版本记录错误: count=%d err=%v", count, err)
}
}
func openMySQLMigrationTestDB(t *testing.T) *sql.DB {
t.Helper()
if os.Getenv("CMAUTOBUY_MYSQL_TEST") != "1" {
+1 -1
View File
@@ -56,7 +56,7 @@ var sqliteMigrationTables = []sqliteMigrationTableSpec{
{name: "shopee_products", columns: []string{"goods_id", "title", "shopee_status", "main_sku_code", "pdd_goods_url", "pdd_goods_id", "created_at", "updated_at"}, primaryKey: []string{"goods_id"}},
{name: "pdd_products", columns: []string{"id", "goods_id", "url", "title", "shop_name", "skus_json", "collect_status", "collect_msg", "artifact_ref", "collected_at", "deleted_at", "created_at", "updated_at"}, primaryKey: []string{"id"}},
{name: "shopee_skus", columns: []string{"sku_id", "goods_id", "spec_raw", "color", "size", "advice", "parse_ok", "sku_code", "is_manual", "created_at", "updated_at"}, primaryKey: []string{"sku_id"}},
{name: "syb_orders", columns: []string{"syb_id", "order_no", "title", "shopee_goods_id", "shopee_sku_id", "product_spec", "quantity", "price_twd_cent", "image_url", "syb_data", "created_at", "updated_at"}, primaryKey: []string{"syb_id"}},
{name: "syb_orders", columns: []string{"syb_id", "order_no", "shop_name", "title", "shopee_goods_id", "shopee_sku_id", "product_spec", "quantity", "price_twd_cent", "image_url", "syb_data", "created_at", "updated_at"}, primaryKey: []string{"syb_id"}},
{name: "sku_mappings", columns: []string{"shopee_sku_id", "pdd_goods_id", "pdd_option_key", "pdd_options", "goods_id", "mapped_at", "mapped_by"}, primaryKey: []string{"shopee_sku_id", "pdd_goods_id"}},
{name: "tasks", columns: []string{"task_id", "task_type", "status", "version", "priority", "assigned_client", "claimed_at", "syb_id", "order_no", "goods_id", "shopee_sku_id", "pdd_goods_url", "pdd_goods_id", "pdd_options", "quantity", "max_price_cent", "result_data", "error_code", "error_message", "finished_at", "created_at", "updated_at"}, primaryKey: []string{"task_id"}},
{name: "task_claims", columns: []string{"task_id", "client_id", "claimed_at"}, primaryKey: []string{"task_id", "client_id"}},
+18 -10
View File
@@ -274,11 +274,12 @@ func UpsertSybOrder(q Execer, o model.SybOrder) (created bool, err error) {
}
_, err = q.Exec(`
INSERT INTO syb_orders
(syb_id, order_no, title, product_spec, spec_key, shopee_goods_id,
(syb_id, order_no, shop_name, title, product_spec, spec_key, shopee_goods_id,
quantity, price_twd_cent, image_url, syb_data, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
order_no = VALUES(order_no),
shop_name = VALUES(shop_name),
title = VALUES(title),
product_spec = VALUES(product_spec),
spec_key = VALUES(spec_key),
@@ -288,7 +289,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, o.Title, nullableText(o.ProductSpec), specKey, nullableText(o.ShopeeGoodsID),
o.SybID, o.OrderNo, 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 {
@@ -353,6 +354,7 @@ func nullableText(s string) any {
// SybOrderFilter 是货运单列表页支持的筛选条件。
type SybOrderFilter struct {
Keyword string // 匹配订单号或商品标题
Shop string // 店铺名模糊匹配;空字符串表示全部
Stage string // 处理阶段;空字符串表示全部
}
@@ -364,6 +366,10 @@ func sybOrderFilterClause(filter SybOrderFilter) (string, []any) {
clauses = append(clauses, `(so.order_no LIKE ? ESCAPE '!' OR so.title LIKE ? ESCAPE '!')`)
args = append(args, like, like)
}
if shop := strings.TrimSpace(filter.Shop); shop != "" {
clauses = append(clauses, `so.shop_name LIKE ? ESCAPE '!'`)
args = append(args, "%"+escapeLike(shop)+"%")
}
switch filter.Stage {
case "spec_missing":
clauses = append(clauses, `so.spec_key IS NULL`)
@@ -405,19 +411,20 @@ type SybOrderContext struct {
func scanSybOrderContext(s rowScanner) (SybOrderContext, error) {
var c SybOrderContext
var title, productSpec, specKey, shopeeGoodsID, imageURL sql.NullString
var shopName, title, productSpec, specKey, shopeeGoodsID, imageURL sql.NullString
var priceCent sql.NullInt64
var shopeeExists int
var pddGoodsID, pddGoodsURL, collectStatus, collectMsg, skusJSON, pddUpdatedAt sql.NullString
var mappingKey, mappingOptions sql.NullString
var hasActiveTask int
err := s.Scan(
&c.Order.SybID, &c.Order.OrderNo, &title, &productSpec, &specKey, &shopeeGoodsID,
&c.Order.SybID, &c.Order.OrderNo, &shopName, &title, &productSpec, &specKey, &shopeeGoodsID,
&c.Order.Quantity, &priceCent, &imageURL, &c.Order.SybData,
&c.Order.CreatedAt, &c.Order.UpdatedAt, &shopeeExists,
&pddGoodsID, &pddGoodsURL, &collectStatus, &collectMsg, &skusJSON, &pddUpdatedAt,
&mappingKey, &mappingOptions, &hasActiveTask,
)
c.Order.ShopName = shopName.String
c.Order.Title = title.String
c.Order.ProductSpec = productSpec.String
c.Order.SpecKey = specKey.String
@@ -441,7 +448,7 @@ 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.spec_key,
SELECT so.syb_id, so.order_no, so.shop_name, 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,
@@ -475,7 +482,7 @@ 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.spec_key,
SELECT so.syb_id, so.order_no, so.shop_name, 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,
@@ -498,7 +505,7 @@ func GetSybOrderContext(q Execer, sybID string) (*SybOrderContext, error) {
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.spec_key, so.shopee_goods_id,
SELECT so.syb_id, so.order_no, so.shop_name, 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 ?`
@@ -513,14 +520,15 @@ func ListSybOrders(q Execer, filter SybOrderFilter, limit, offset int) ([]model.
var list []model.SybOrder
for rows.Next() {
var o model.SybOrder
var title, productSpec, specKey, shopeeGoodsID, imageURL sql.NullString
var shopName, title, productSpec, specKey, shopeeGoodsID, imageURL sql.NullString
var priceCent sql.NullInt64
if err := rows.Scan(
&o.SybID, &o.OrderNo, &title, &productSpec, &specKey, &shopeeGoodsID,
&o.SybID, &o.OrderNo, &shopName, &title, &productSpec, &specKey, &shopeeGoodsID,
&o.Quantity, &priceCent, &imageURL, &o.SybData, &o.CreatedAt, &o.UpdatedAt,
); err != nil {
return nil, fmt.Errorf("读取顺运宝货运单列表失败: %w", err)
}
o.ShopName = shopName.String
o.Title = title.String
o.ProductSpec = productSpec.String
o.SpecKey = specKey.String
+14 -5
View File
@@ -346,17 +346,17 @@ func TestInterruptRunningSybSyncRuns_只中断未完成记录(t *testing.T) {
}
}
func TestListSybOrders_关键字筛选订单号和标题(t *testing.T) {
func TestListSybOrders_订单标题店铺组合筛选(t *testing.T) {
db := newSybTestDB(t)
mustUpsert := func(sybID, orderNo, title string) {
mustUpsert := func(sybID, orderNo, shop, title string) {
if _, err := UpsertSybOrder(db, model.SybOrder{
SybID: sybID, OrderNo: orderNo, Title: title, Quantity: 1, SybData: "{}",
SybID: sybID, OrderNo: orderNo, ShopName: shop, Title: title, Quantity: 1, SybData: "{}",
}); err != nil {
t.Fatalf("写入 %s 失败: %v", sybID, err)
}
}
mustUpsert("SYB-A", "260728AAA", "纯棉上衣")
mustUpsert("SYB-B", "260728BBB", "牛仔裤")
mustUpsert("SYB-A", "260728AAA", "台北服饰旗舰店", "纯棉上衣")
mustUpsert("SYB-B", "260728BBB", "台南生活馆", "牛仔裤")
rows, err := ListSybOrders(db, SybOrderFilter{Keyword: "AAA"}, 20, 0)
if err != nil {
@@ -373,4 +373,13 @@ func TestListSybOrders_关键字筛选订单号和标题(t *testing.T) {
if len(rows2) != 1 || rows2[0].SybID != "SYB-B" {
t.Fatalf("按标题筛选应该只查到 SYB-B,实际 %+v", rows2)
}
rows3, err := ListSybOrders(db, SybOrderFilter{Shop: " 服饰 "}, 20, 0)
if err != nil || len(rows3) != 1 || rows3[0].SybID != "SYB-A" || rows3[0].ShopName != "台北服饰旗舰店" {
t.Fatalf("按店铺模糊筛选错误:rows=%+v err=%v", rows3, err)
}
count, err := CountSybOrders(db, SybOrderFilter{Keyword: "牛仔", Shop: "台南"})
if err != nil || count != 1 {
t.Fatalf("标题与店铺组合 COUNT 错误:count=%d err=%v", count, err)
}
}