feat: 完善 SYB 蝦皮主数据与软删除 (#203 #204 #205)

This commit is contained in:
chengma
2026-08-13 10:39:21 +08:00
parent 3af9bf245a
commit ad1ccc2c60
23 changed files with 864 additions and 90 deletions
+1 -1
View File
@@ -19,7 +19,7 @@ func newCatalogTestDB(t *testing.T) *sql.DB {
}
db.SetMaxOpenConns(1)
statements := []string{
`CREATE TABLE shopee_products(goods_id TEXT PRIMARY KEY,title TEXT NOT NULL,shopee_status TEXT,main_sku_code TEXT,image_url TEXT,shopee_shop_name TEXT,image_source TEXT,image_observed_at TEXT,image_is_manual INTEGER DEFAULT 0,shop_name_source TEXT,shop_name_observed_at TEXT,shop_name_is_manual INTEGER DEFAULT 0,source TEXT,source_observed_at TEXT,pdd_goods_url TEXT,pdd_goods_id TEXT,created_at TEXT,updated_at TEXT)`,
`CREATE TABLE shopee_products(goods_id TEXT PRIMARY KEY,title TEXT NOT NULL,shopee_status TEXT,main_sku_code TEXT,image_url TEXT,shopee_shop_name TEXT,image_source TEXT,image_observed_at TEXT,image_is_manual INTEGER DEFAULT 0,shop_name_source TEXT,shop_name_observed_at TEXT,shop_name_is_manual INTEGER DEFAULT 0,source TEXT,source_observed_at TEXT,pdd_goods_url TEXT,pdd_goods_id TEXT,deleted_at TEXT,deleted_by_user_id TEXT,created_at TEXT,updated_at TEXT)`,
`CREATE TABLE shopee_skus(sku_id TEXT PRIMARY KEY,shopee_sku_id TEXT UNIQUE,goods_id TEXT NOT NULL,spec_raw TEXT,spec_key TEXT,color TEXT,size TEXT,advice TEXT,parse_ok INTEGER,sku_code TEXT,is_manual INTEGER,source TEXT,field_sources TEXT,field_observed_at TEXT,source_observed_at TEXT,created_at TEXT,updated_at TEXT,UNIQUE(goods_id,spec_key))`,
`CREATE TABLE pdd_products(id INTEGER PRIMARY KEY AUTOINCREMENT,goods_id TEXT UNIQUE,url TEXT,title TEXT,shop_name TEXT,skus_json TEXT,collect_status TEXT,collect_msg TEXT,artifact_ref TEXT,collected_at TEXT,deleted_at TEXT,source TEXT,source_observed_at TEXT,created_at TEXT,updated_at TEXT)`,
`CREATE TABLE catalog_import_runs(source TEXT,batch_id TEXT,request_hash TEXT,status TEXT,request_count INTEGER,conflict_count INTEGER,observed_at TEXT,update_policy TEXT DEFAULT 'fill_missing',last_request_at TEXT,last_conflict_at TEXT,shopee_created INTEGER DEFAULT 0,shopee_updated INTEGER DEFAULT 0,shopee_fields_filled INTEGER DEFAULT 0,shopee_fields_same_source_updated INTEGER DEFAULT 0,shopee_fields_manual_skipped INTEGER DEFAULT 0,shopee_fields_stale_skipped INTEGER DEFAULT 0,sku_created INTEGER DEFAULT 0,sku_updated INTEGER DEFAULT 0,sku_filled INTEGER DEFAULT 0,sku_same_source_updated INTEGER DEFAULT 0,sku_skipped INTEGER DEFAULT 0,sku_manual_skipped INTEGER DEFAULT 0,sku_stale_skipped INTEGER DEFAULT 0,pdd_created INTEGER DEFAULT 0,pdd_updated INTEGER DEFAULT 0,association_created INTEGER DEFAULT 0,association_unchanged INTEGER DEFAULT 0,failure_count INTEGER DEFAULT 0,error_summary TEXT,response_body TEXT,created_at TEXT,finished_at TEXT,PRIMARY KEY(source,batch_id))`,
+79
View File
@@ -0,0 +1,79 @@
package service
import (
"database/sql"
"fmt"
"sort"
"strings"
"time"
"cmautobuy/admin/model"
"cmautobuy/admin/repository"
)
func normalizeShopeeIDs(ids []string) []string {
seen := map[string]struct{}{}
result := make([]string, 0, len(ids))
for _, id := range ids {
id = strings.TrimSpace(id)
if id == "" {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
result = append(result, id)
}
sort.Strings(result)
return result
}
// DeleteShopeeProducts 由管理员把商品移入可恢复的删除状态。
func DeleteShopeeProducts(db *sql.DB, actor *model.User, ids []string) (int64, error) {
if actor == nil || !actor.IsAdmin() {
return 0, ErrAdminRequired
}
ids = normalizeShopeeIDs(ids)
if len(ids) == 0 {
return 0, invalidInput("没有勾选任何蝦皮商品")
}
tx, err := db.Begin()
if err != nil {
return 0, err
}
defer tx.Rollback()
busy, err := repository.ShopeeProductsWithActiveTasks(tx, ids)
if err != nil {
return 0, err
}
if len(busy) > 0 {
return 0, invalidInput(fmt.Sprintf("商品 %s 仍有进行中的采集或采购任务,不能删除", strings.Join(busy, "、")))
}
count, err := repository.SetShopeeProductsDeleted(tx, ids, actor.UserID, time.Now().UTC().Format(model.TimeLayout))
if err != nil {
return 0, err
}
return count, tx.Commit()
}
// RestoreShopeeProducts 由管理员恢复软删除商品,原 SKU 和 PDD 关联保持不变。
func RestoreShopeeProducts(db *sql.DB, actor *model.User, ids []string) (int64, error) {
if actor == nil || !actor.IsAdmin() {
return 0, ErrAdminRequired
}
ids = normalizeShopeeIDs(ids)
if len(ids) == 0 {
return 0, invalidInput("没有勾选任何蝦皮商品")
}
tx, err := db.Begin()
if err != nil {
return 0, err
}
defer tx.Rollback()
count, err := repository.SetShopeeProductsDeleted(tx, ids, actor.UserID, "")
if err != nil {
return 0, err
}
return count, tx.Commit()
}
+69
View File
@@ -0,0 +1,69 @@
package service
import (
"testing"
"cmautobuy/admin/model"
"cmautobuy/admin/repository"
)
func TestShopeeSoftDelete_保留关联且可恢复(t *testing.T) {
db := newTestDB(t)
now := model.NowISO()
admin := &model.User{UserID: "ADMIN-DELETE", Username: "delete-admin", PasswordHash: "test", Role: model.RoleAdmin, Status: model.UserActive, PasswordChangedAt: now, CreatedAt: now, UpdatedAt: now}
if err := repository.CreateUser(db, *admin); err != nil {
t.Fatal(err)
}
if _, err := db.Exec(`INSERT INTO pdd_products(goods_id,url,collect_status,created_at,updated_at) VALUES('P-DEL','https://example.com/p','collected',?,?)`, now, now); err != nil {
t.Fatal(err)
}
if _, err := db.Exec(`INSERT INTO shopee_products(goods_id,title,pdd_goods_id,pdd_goods_url,source,created_at,updated_at) VALUES('S-DEL','商品','P-DEL','https://example.com/p','report',?,?)`, now, now); err != nil {
t.Fatal(err)
}
if _, err := db.Exec(`INSERT INTO shopee_skus(sku_id,goods_id,spec_raw,spec_key,parse_ok,is_manual,source,created_at,updated_at) VALUES('SKU-DEL','S-DEL','黑色,M','黑色,M',1,0,'report',?,?)`, now, now); err != nil {
t.Fatal(err)
}
count, err := DeleteShopeeProducts(db, admin, []string{"S-DEL", "S-DEL"})
if err != nil || count != 1 {
t.Fatalf("软删除失败:count=%d err=%v", count, err)
}
if product, _ := repository.GetShopeeProductByGoodsID(db, "S-DEL"); product != nil {
t.Fatal("默认查询不应返回已删除商品")
}
var skuCount int
if err := db.QueryRow(`SELECT COUNT(*) FROM shopee_skus WHERE goods_id='S-DEL'`).Scan(&skuCount); err != nil || skuCount != 1 {
t.Fatalf("软删除不应删除 SKU:count=%d err=%v", skuCount, err)
}
count, err = RestoreShopeeProducts(db, admin, []string{"S-DEL"})
if err != nil || count != 1 {
t.Fatalf("恢复失败:count=%d err=%v", count, err)
}
product, err := repository.GetShopeeProductByGoodsID(db, "S-DEL")
if err != nil || product == nil || product.PddGoodsID != "P-DEL" {
t.Fatalf("恢复后关联丢失:product=%+v err=%v", product, err)
}
}
func TestShopeeSoftDelete_进行中任务阻止整批删除(t *testing.T) {
db := newTestDB(t)
now := model.NowISO()
admin := &model.User{UserID: "ADMIN-BUSY", Username: "busy-admin", PasswordHash: "test", Role: model.RoleAdmin, Status: model.UserActive, PasswordChangedAt: now, CreatedAt: now, UpdatedAt: now}
if err := repository.CreateUser(db, *admin); err != nil {
t.Fatal(err)
}
for _, id := range []string{"S-BUSY", "S-FREE"} {
if _, err := db.Exec(`INSERT INTO shopee_products(goods_id,title,source,created_at,updated_at) VALUES(?,?,'report',?,?)`, id, id, now, now); err != nil {
t.Fatal(err)
}
}
if _, err := db.Exec(`INSERT INTO tasks(task_id,task_type,status,goods_id,pdd_goods_url,created_at,updated_at) VALUES('cg999','purchase','pending','S-BUSY','https://example.com/p',?,?)`, now, now); err != nil {
t.Fatal(err)
}
if _, err := DeleteShopeeProducts(db, admin, []string{"S-BUSY", "S-FREE"}); err == nil || !IsValidationError(err) {
t.Fatalf("有进行中任务时应阻止整批删除,实际 err=%v", err)
}
var deleted int
if err := db.QueryRow(`SELECT COUNT(*) FROM shopee_products WHERE deleted_at IS NOT NULL`).Scan(&deleted); err != nil || deleted != 0 {
t.Fatalf("整批删除应回滚:deleted=%d err=%v", deleted, err)
}
}
+3 -1
View File
@@ -44,6 +44,7 @@ type ShopeeProductView struct {
StatusText string
UpdatedAt string
Deleted bool
}
// ShopeeListResult 是列表页要的全部数据。
@@ -100,7 +101,7 @@ func ListShopeeProducts(db *sql.DB, filter repository.ShopeeFilter, page int) (*
Rows: make([]ShopeeProductView, 0, len(rows)),
Total: total,
HasAnyProducts: hasAny > 0,
IsFiltered: strings.TrimSpace(filter.Keyword) != "" || strings.TrimSpace(filter.ShopName) != "" || filter.Status != "" || filter.Shop != "" || filter.Image != "",
IsFiltered: strings.TrimSpace(filter.Keyword) != "" || strings.TrimSpace(filter.ShopName) != "" || filter.Status != "" || filter.Shop != "" || filter.Image != "" || filter.Deleted,
Page: page,
PageSize: PageSize,
TotalPages: totalPages,
@@ -117,6 +118,7 @@ func ListShopeeProducts(db *sql.DB, filter repository.ShopeeFilter, page int) (*
PendingCount: r.PendingCount,
SourceText: shopeeSourceText(r.Source),
UpdatedAt: formatLocalTime(r.UpdatedAt),
Deleted: r.IsDeleted(),
}
if r.PddGoodsID == "" {
+3
View File
@@ -902,6 +902,9 @@ func writeStockDetail(db *sql.DB, baseURL string, stockRow syb.StockRow, detail
if err != nil {
return err
}
if _, err := repository.UpsertSybShopeeSKU(tx, order.ShopeeGoodsID, order.ProductSpec, model.NowISO()); err != nil {
return fmt.Errorf("写入顺运宝规格观测失败: %w", err)
}
if created {
report.Created++
} else {