feat: 支持无SKU目录导入策略 (#141)

This commit is contained in:
chengma
2026-08-11 11:03:18 +08:00
parent 6386d76488
commit 4259be0e55
17 changed files with 766 additions and 73 deletions
+4 -1
View File
@@ -57,7 +57,7 @@ func TestCatalogBatchAPI_按批次查询摘要(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
defer db.Close() defer db.Close()
_, err = db.Exec(`CREATE TABLE catalog_import_runs(source TEXT,batch_id TEXT,request_hash TEXT,status TEXT,request_count INTEGER,conflict_count INTEGER,observed_at TEXT,last_request_at TEXT,last_conflict_at TEXT,shopee_created INTEGER DEFAULT 0,shopee_updated INTEGER DEFAULT 0,sku_created INTEGER DEFAULT 0,sku_updated 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))`) _, err = db.Exec(`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,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))`)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -75,6 +75,9 @@ func TestCatalogBatchAPI_按批次查询摘要(t *testing.T) {
if resp.Code != 200 || !strings.Contains(resp.Body.String(), `"batch_id":"B-1"`) { if resp.Code != 200 || !strings.Contains(resp.Body.String(), `"batch_id":"B-1"`) {
t.Fatalf("status=%d body=%s", resp.Code, resp.Body.String()) t.Fatalf("status=%d body=%s", resp.Code, resp.Body.String())
} }
if !strings.Contains(resp.Body.String(), `"sku_filled":0`) || !strings.Contains(resp.Body.String(), `"update_policy"`) {
t.Fatal("批次摘要缺少更新策略统计")
}
if strings.Contains(resp.Body.String(), "request_hash") { if strings.Contains(resp.Body.String(), "request_hash") {
t.Fatal("查询响应不应暴露请求哈希") t.Fatal("查询响应不应暴露请求哈希")
} }
+1 -1
View File
@@ -37,7 +37,7 @@ func (h *Handler) GetBatch(c *gin.Context) {
integrationError(c, 500, "INTERNAL_ERROR", "查询批次失败", true, nil) integrationError(c, 500, "INTERNAL_ERROR", "查询批次失败", true, nil)
return return
} }
c.JSON(200, gin.H{"source": run.Source, "batch_id": run.BatchID, "status": run.Status, "request_count": run.RequestCount, "conflict_count": run.ConflictCount, "observed_at": run.ObservedAt, "created_at": run.CreatedAt, "finished_at": run.FinishedAt, "counts": gin.H{"shopee_created": run.ShopeeCreated, "shopee_updated": run.ShopeeUpdated, "sku_created": run.SKUCreated, "sku_updated": run.SKUUpdated, "pdd_created": run.PddCreated, "pdd_updated": run.PddUpdated, "association_created": run.AssociationCreated, "association_unchanged": run.AssociationUnchanged}, "failure_count": run.FailureCount, "error_summary": run.ErrorSummary}) c.JSON(200, gin.H{"source": run.Source, "batch_id": run.BatchID, "update_policy": run.UpdatePolicy, "status": run.Status, "request_count": run.RequestCount, "conflict_count": run.ConflictCount, "observed_at": run.ObservedAt, "created_at": run.CreatedAt, "finished_at": run.FinishedAt, "counts": gin.H{"shopee_created": run.ShopeeCreated, "shopee_updated": run.ShopeeUpdated, "sku_created": run.SKUCreated, "sku_updated": run.SKUUpdated, "sku_filled": run.SKUFilled, "sku_same_source_updated": run.SKUSameSourceUpdated, "sku_skipped": run.SKUSkipped, "sku_manual_skipped": run.SKUManualSkipped, "sku_stale_skipped": run.SKUStaleSkipped, "pdd_created": run.PddCreated, "pdd_updated": run.PddUpdated, "association_created": run.AssociationCreated, "association_unchanged": run.AssociationUnchanged}, "failure_count": run.FailureCount, "error_summary": run.ErrorSummary})
} }
func (h *Handler) CreateBatch(c *gin.Context) { func (h *Handler) CreateBatch(c *gin.Context) {
+17
View File
@@ -151,6 +151,23 @@ func TestShopeeDetail_区分顺运宝观测与正式SKU(t *testing.T) {
} }
} }
func TestCatalogHistory_展示更新策略和分级统计(t *testing.T) {
list, err := os.ReadFile("templates/catalog/list.html")
if err != nil {
t.Fatal(err)
}
detail, err := os.ReadFile("templates/catalog/detail_modal.html")
if err != nil {
t.Fatal(err)
}
content := string(list) + string(detail)
for _, want := range []string{"更新策略", "SKUFilled", "SKUSameSourceUpdated", "SKUManualSkipped", "SKUStaleSkipped"} {
if !strings.Contains(content, want) {
t.Errorf("目录记录缺少 %q", want)
}
}
}
func TestShopeeImportRoute_已移除(t *testing.T) { func TestShopeeImportRoute_已移除(t *testing.T) {
router, err := newRouter(nil) router, err := newRouter(nil)
if err != nil { if err != nil {
+6
View File
@@ -24,6 +24,12 @@ type CatalogImportRun struct {
ShopeeUpdated int ShopeeUpdated int
SKUCreated int SKUCreated int
SKUUpdated int SKUUpdated int
UpdatePolicy string
SKUFilled int
SKUSameSourceUpdated int
SKUSkipped int
SKUManualSkipped int
SKUStaleSkipped int
PddCreated int PddCreated int
PddUpdated int PddUpdated int
AssociationCreated int AssociationCreated int
+2 -1
View File
@@ -181,7 +181,8 @@ type ShopeeProduct struct {
// **永远原样保留**。Color/Size/Advice 是尽力解析的结果, // **永远原样保留**。Color/Size/Advice 是尽力解析的结果,
// 解析失败时留空并把 ParseOK 置 false,不要瞎猜。 // 解析失败时留空并把 ParseOK 置 false,不要瞎猜。
type ShopeeSKU struct { type ShopeeSKU struct {
SKUID string RecordID string // 系统内部记录主键
SKUID string // 可空的真实蝦皮 SKU ID,不得用内部主键冒充
GoodsID string GoodsID string
SpecRaw string SpecRaw string
Color string Color string
+195 -30
View File
@@ -2,6 +2,7 @@ package repository
import ( import (
"database/sql" "database/sql"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
@@ -21,9 +22,9 @@ var (
func InsertCatalogImportRun(q Execer, run model.CatalogImportRun) error { func InsertCatalogImportRun(q Execer, run model.CatalogImportRun) error {
_, err := q.Exec(`INSERT INTO catalog_import_runs ( _, err := q.Exec(`INSERT INTO catalog_import_runs (
source,batch_id,request_hash,status,request_count,conflict_count,observed_at, source,batch_id,request_hash,status,request_count,conflict_count,observed_at,
last_request_at,created_at update_policy,last_request_at,created_at
) VALUES (?,?,?,?,1,0,NULLIF(?,''),?,?)`, run.Source, run.BatchID, run.RequestHash, ) VALUES (?,?,?,?,1,0,NULLIF(?,''),?,?,?)`, run.Source, run.BatchID, run.RequestHash,
run.Status, run.ObservedAt, run.LastRequestAt, run.CreatedAt) run.Status, run.ObservedAt, run.UpdatePolicy, run.LastRequestAt, run.CreatedAt)
if err == nil { if err == nil {
return nil return nil
} }
@@ -42,13 +43,15 @@ func GetCatalogImportRun(q Execer, source, batchID string) (model.CatalogImportR
var run model.CatalogImportRun var run model.CatalogImportRun
var observedAt, lastConflictAt, errorSummary, responseBody, finishedAt sql.NullString var observedAt, lastConflictAt, errorSummary, responseBody, finishedAt sql.NullString
err := q.QueryRow(`SELECT source,batch_id,request_hash,status,request_count,conflict_count, err := q.QueryRow(`SELECT source,batch_id,request_hash,status,request_count,conflict_count,
observed_at,last_request_at,last_conflict_at,shopee_created,shopee_updated,sku_created, observed_at,update_policy,last_request_at,last_conflict_at,shopee_created,shopee_updated,sku_created,
sku_updated,pdd_created,pdd_updated,association_created,association_unchanged,failure_count, sku_updated,sku_filled,sku_same_source_updated,sku_skipped,sku_manual_skipped,sku_stale_skipped,
pdd_created,pdd_updated,association_created,association_unchanged,failure_count,
error_summary,response_body,created_at,finished_at error_summary,response_body,created_at,finished_at
FROM catalog_import_runs WHERE source=? AND batch_id=?`, source, batchID).Scan( FROM catalog_import_runs WHERE source=? AND batch_id=?`, source, batchID).Scan(
&run.Source, &run.BatchID, &run.RequestHash, &run.Status, &run.RequestCount, &run.ConflictCount, &run.Source, &run.BatchID, &run.RequestHash, &run.Status, &run.RequestCount, &run.ConflictCount,
&observedAt, &run.LastRequestAt, &lastConflictAt, &run.ShopeeCreated, &run.ShopeeUpdated, &observedAt, &run.UpdatePolicy, &run.LastRequestAt, &lastConflictAt, &run.ShopeeCreated, &run.ShopeeUpdated,
&run.SKUCreated, &run.SKUUpdated, &run.PddCreated, &run.PddUpdated, &run.AssociationCreated, &run.SKUCreated, &run.SKUUpdated, &run.SKUFilled, &run.SKUSameSourceUpdated, &run.SKUSkipped,
&run.SKUManualSkipped, &run.SKUStaleSkipped, &run.PddCreated, &run.PddUpdated, &run.AssociationCreated,
&run.AssociationUnchanged, &run.FailureCount, &errorSummary, &responseBody, &run.CreatedAt, &finishedAt) &run.AssociationUnchanged, &run.FailureCount, &errorSummary, &responseBody, &run.CreatedAt, &finishedAt)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return model.CatalogImportRun{}, ErrCatalogImportRunNotFound return model.CatalogImportRun{}, ErrCatalogImportRunNotFound
@@ -147,10 +150,12 @@ func RecordCatalogImportConflict(q Execer, source, batchID, requestedAt string)
// CompleteCatalogImportRun 把处理结果摘要和安全的响应 JSON 固化,供幂等重放。 // CompleteCatalogImportRun 把处理结果摘要和安全的响应 JSON 固化,供幂等重放。
func CompleteCatalogImportRun(q Execer, run model.CatalogImportRun) error { func CompleteCatalogImportRun(q Execer, run model.CatalogImportRun) error {
result, err := q.Exec(`UPDATE catalog_import_runs SET status=?,shopee_created=?,shopee_updated=?, result, err := q.Exec(`UPDATE catalog_import_runs SET status=?,shopee_created=?,shopee_updated=?,
sku_created=?,sku_updated=?,pdd_created=?,pdd_updated=?,association_created=?, sku_created=?,sku_updated=?,sku_filled=?,sku_same_source_updated=?,sku_skipped=?,
sku_manual_skipped=?,sku_stale_skipped=?,pdd_created=?,pdd_updated=?,association_created=?,
association_unchanged=?,failure_count=?,error_summary=NULLIF(?,''),response_body=NULLIF(?,''), association_unchanged=?,failure_count=?,error_summary=NULLIF(?,''),response_body=NULLIF(?,''),
finished_at=? WHERE source=? AND batch_id=?`, run.Status, run.ShopeeCreated, run.ShopeeUpdated, finished_at=? WHERE source=? AND batch_id=?`, run.Status, run.ShopeeCreated, run.ShopeeUpdated,
run.SKUCreated, run.SKUUpdated, run.PddCreated, run.PddUpdated, run.AssociationCreated, run.SKUCreated, run.SKUUpdated, run.SKUFilled, run.SKUSameSourceUpdated, run.SKUSkipped,
run.SKUManualSkipped, run.SKUStaleSkipped, run.PddCreated, run.PddUpdated, run.AssociationCreated,
run.AssociationUnchanged, run.FailureCount, run.ErrorSummary, run.ResponseBody, run.AssociationUnchanged, run.FailureCount, run.ErrorSummary, run.ResponseBody,
run.FinishedAt, run.Source, run.BatchID) run.FinishedAt, run.Source, run.BatchID)
return catalogRunUpdateResult(result, err, "完成商品目录批次") return catalogRunUpdateResult(result, err, "完成商品目录批次")
@@ -177,34 +182,194 @@ func UpsertCatalogShopeeProduct(q Execer, goodsID, title, status, mainSKU, obser
return false, err == nil, err return false, err == nil, err
} }
// UpsertCatalogShopeeSKU 拒绝把同一 SKU 静默挪到另一商品,并保留 is_manual。 type CatalogSKUOutcome string
func UpsertCatalogShopeeSKU(q Execer, skuID, goodsID, specRaw, color, size, advice string, parseOK bool, skuCode, observedAt, now string) (created, updated bool, err error) {
var oldGoods string const (
var oldObserved sql.NullString CatalogSKUCreated CatalogSKUOutcome = "created"
err = q.QueryRow(`SELECT goods_id,source_observed_at FROM shopee_skus WHERE sku_id=?`, skuID).Scan(&oldGoods, &oldObserved) CatalogSKUFilled CatalogSKUOutcome = "filled"
parse := 0 CatalogSKUSameSourceUpdated CatalogSKUOutcome = "same_source_updated"
if parseOK { CatalogSKUSkipped CatalogSKUOutcome = "skipped"
parse = 1 CatalogSKUManualSkipped CatalogSKUOutcome = "manual_skipped"
CatalogSKUStaleSkipped CatalogSKUOutcome = "stale_skipped"
)
type CatalogShopeeSKUInput struct {
RecordID, ShopeeSKUID, GoodsID, SpecRaw, SpecKey string
Color, Size, Advice, SKUCode string
ParseOK bool
Source, ObservedAt, Now, UpdatePolicy string
} }
type catalogSKUStored struct {
RecordID, GoodsID, SpecRaw, SpecKey string
ShopeeSKUID, Color, Size, Advice, SKUCode sql.NullString
ParseOK, IsManual int
Source, ObservedAt sql.NullString
FieldSources, FieldObservedAt sql.NullString
}
func scanCatalogSKU(dbRow *sql.Row) (*catalogSKUStored, error) {
var row catalogSKUStored
err := dbRow.Scan(&row.RecordID, &row.ShopeeSKUID, &row.GoodsID, &row.SpecRaw, &row.SpecKey, &row.Color, &row.Size, &row.Advice, &row.ParseOK, &row.SKUCode, &row.IsManual, &row.Source, &row.ObservedAt, &row.FieldSources, &row.FieldObservedAt)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
_, err = q.Exec(`INSERT INTO shopee_skus(sku_id,goods_id,spec_raw,color,size,advice,parse_ok,sku_code, return nil, nil
is_manual,source_observed_at,created_at,updated_at) VALUES(?,?,?,NULLIF(?,''),NULLIF(?,''),NULLIF(?,''),?,NULLIF(?,''),0,?,?,?)`,
skuID, goodsID, specRaw, color, size, advice, parse, skuCode, observedAt, now, now)
return err == nil, false, err
} }
if err != nil { if err != nil {
return false, false, err return nil, err
} }
if oldGoods != goodsID { return &row, nil
return false, false, fmt.Errorf("SKU %s 已属于蝦皮商品 %s", skuID, oldGoods)
} }
if oldObserved.Valid && oldObserved.String > observedAt {
return false, false, nil func findCatalogSKUBySpec(q Execer, goodsID, specKey string) (*catalogSKUStored, error) {
return scanCatalogSKU(q.QueryRow(`SELECT sku_id,shopee_sku_id,goods_id,spec_raw,spec_key,color,size,advice,parse_ok,sku_code,is_manual,source,source_observed_at,field_sources,field_observed_at FROM shopee_skus WHERE goods_id=? AND spec_key=?`, goodsID, specKey))
} }
_, err = q.Exec(`UPDATE shopee_skus SET spec_raw=?,color=NULLIF(?,''),size=NULLIF(?,''),advice=NULLIF(?,''),
parse_ok=?,sku_code=NULLIF(?,''),source_observed_at=?,updated_at=? WHERE sku_id=?`, func findCatalogSKUByExternalID(q Execer, skuID string) (*catalogSKUStored, error) {
specRaw, color, size, advice, parse, skuCode, observedAt, now, skuID) return scanCatalogSKU(q.QueryRow(`SELECT sku_id,shopee_sku_id,goods_id,spec_raw,spec_key,color,size,advice,parse_ok,sku_code,is_manual,source,source_observed_at,field_sources,field_observed_at FROM shopee_skus WHERE shopee_sku_id=?`, skuID))
return false, err == nil, err }
// UpsertCatalogShopeeSKU 用真实 ID 或商品规格身份定位同一内部记录,人工行永不覆盖。
func UpsertCatalogShopeeSKU(q Execer, in CatalogShopeeSKUInput) (CatalogSKUOutcome, error) {
bySpec, err := findCatalogSKUBySpec(q, in.GoodsID, in.SpecKey)
if err != nil {
return "", err
}
var byID *catalogSKUStored
if in.ShopeeSKUID != "" {
byID, err = findCatalogSKUByExternalID(q, in.ShopeeSKUID)
if err != nil {
return "", err
}
}
if byID != nil && (byID.GoodsID != in.GoodsID || byID.SpecKey != in.SpecKey) {
return "", fmt.Errorf("真实蝦皮 SKU %s 已属于其他商品或规格", in.ShopeeSKUID)
}
if byID != nil && bySpec != nil && byID.RecordID != bySpec.RecordID {
return "", fmt.Errorf("商品 %s 的规格 %s 已对应另一个真实蝦皮 SKU", in.GoodsID, in.SpecRaw)
}
current := byID
if current == nil {
current = bySpec
}
parse := 0
if in.ParseOK {
parse = 1
}
if current == nil {
fieldSources, fieldTimes := newCatalogFieldProvenance(in)
sourcesJSON, _ := json.Marshal(fieldSources)
timesJSON, _ := json.Marshal(fieldTimes)
_, err = q.Exec(`INSERT INTO shopee_skus(sku_id,shopee_sku_id,goods_id,spec_raw,spec_key,color,size,advice,parse_ok,sku_code,is_manual,source,field_sources,field_observed_at,source_observed_at,created_at,updated_at) VALUES(?,NULLIF(?,''),?,?,?,NULLIF(?,''),NULLIF(?,''),NULLIF(?,''),?,NULLIF(?,''),0,?,?,?,?,?,?)`, in.RecordID, in.ShopeeSKUID, in.GoodsID, in.SpecRaw, in.SpecKey, in.Color, in.Size, in.Advice, parse, in.SKUCode, in.Source, string(sourcesJSON), string(timesJSON), in.ObservedAt, in.Now, in.Now)
if err != nil {
return "", err
}
return CatalogSKUCreated, nil
}
if current.ShopeeSKUID.Valid && in.ShopeeSKUID != "" && current.ShopeeSKUID.String != in.ShopeeSKUID {
return "", fmt.Errorf("商品 %s 的规格 %s 已对应真实蝦皮 SKU %s", in.GoodsID, in.SpecRaw, current.ShopeeSKUID.String)
}
if in.UpdatePolicy == "insert_only" {
return CatalogSKUSkipped, nil
}
if current.IsManual != 0 {
return CatalogSKUManualSkipped, nil
}
external := current.ShopeeSKUID.String
externalAdded := external == "" && in.ShopeeSKUID != ""
if external == "" {
external = in.ShopeeSKUID
}
if in.UpdatePolicy == "fill_missing" {
color, size, advice, skuCode := current.Color.String, current.Size.String, current.Advice.String, current.SKUCode.String
sources, times := catalogFieldProvenance(current)
changed := external != current.ShopeeSKUID.String
if color == "" && strings.TrimSpace(in.Color) != "" {
color = in.Color
sources["color"], times["color"] = in.Source, in.ObservedAt
changed = true
}
if size == "" && strings.TrimSpace(in.Size) != "" {
size = in.Size
sources["size"], times["size"] = in.Source, in.ObservedAt
changed = true
}
if advice == "" && strings.TrimSpace(in.Advice) != "" {
advice = in.Advice
sources["advice"], times["advice"] = in.Source, in.ObservedAt
changed = true
}
if skuCode == "" && strings.TrimSpace(in.SKUCode) != "" {
skuCode = in.SKUCode
sources["sku_code"], times["sku_code"] = in.Source, in.ObservedAt
changed = true
}
if !changed {
return CatalogSKUSkipped, nil
}
sourcesJSON, _ := json.Marshal(sources)
timesJSON, _ := json.Marshal(times)
_, err = q.Exec(`UPDATE shopee_skus SET shopee_sku_id=NULLIF(?,''),color=NULLIF(?,''),size=NULLIF(?,''),advice=NULLIF(?,''),sku_code=NULLIF(?,''),parse_ok=CASE WHEN parse_ok=1 THEN 1 ELSE ? END,field_sources=?,field_observed_at=?,updated_at=? WHERE sku_id=?`, external, color, size, advice, skuCode, parse, string(sourcesJSON), string(timesJSON), in.Now, current.RecordID)
if err != nil {
return "", err
}
return CatalogSKUFilled, nil
}
sources, times := catalogFieldProvenance(current)
color, size, advice, skuCode := current.Color.String, current.Size.String, current.Advice.String, current.SKUCode.String
changed, stale := false, false
for _, field := range []struct {
name string
incoming string
current *string
}{{"color", in.Color, &color}, {"size", in.Size, &size}, {"advice", in.Advice, &advice}, {"sku_code", in.SKUCode, &skuCode}} {
if sources[field.name] != in.Source {
continue
}
if times[field.name] > in.ObservedAt {
stale = true
continue
}
*field.current = field.incoming
times[field.name] = in.ObservedAt
changed = true
}
if !changed {
if externalAdded {
_, err = q.Exec(`UPDATE shopee_skus SET shopee_sku_id=?,updated_at=? WHERE sku_id=?`, external, in.Now, current.RecordID)
if err != nil {
return "", err
}
return CatalogSKUFilled, nil
}
if stale {
return CatalogSKUStaleSkipped, nil
}
return CatalogSKUSkipped, nil
}
sourcesJSON, _ := json.Marshal(sources)
timesJSON, _ := json.Marshal(times)
_, err = q.Exec(`UPDATE shopee_skus SET shopee_sku_id=NULLIF(?,''),color=NULLIF(?,''),size=NULLIF(?,''),advice=NULLIF(?,''),parse_ok=?,sku_code=NULLIF(?,''),field_sources=?,field_observed_at=?,source_observed_at=?,updated_at=? WHERE sku_id=?`, external, color, size, advice, parse, skuCode, string(sourcesJSON), string(timesJSON), in.ObservedAt, in.Now, current.RecordID)
if err != nil {
return "", err
}
return CatalogSKUSameSourceUpdated, nil
}
func newCatalogFieldProvenance(in CatalogShopeeSKUInput) (map[string]string, map[string]string) {
sources, times := map[string]string{}, map[string]string{}
for name, value := range map[string]string{"color": in.Color, "size": in.Size, "advice": in.Advice, "sku_code": in.SKUCode} {
if strings.TrimSpace(value) != "" {
sources[name], times[name] = in.Source, in.ObservedAt
}
}
return sources, times
}
func catalogFieldProvenance(row *catalogSKUStored) (map[string]string, map[string]string) {
sources, times := map[string]string{}, map[string]string{}
_ = json.Unmarshal([]byte(row.FieldSources.String), &sources)
_ = json.Unmarshal([]byte(row.FieldObservedAt.String), &times)
return sources, times
} }
// UpsertCatalogPddProduct 写入结构化 PDD 数据转换后的规范 JSON;空规格不清除既有采集结果。 // UpsertCatalogPddProduct 写入结构化 PDD 数据转换后的规范 JSON;空规格不清除既有采集结果。
+2 -2
View File
@@ -19,9 +19,9 @@ func TestCatalogImportRun_登记查询重放和冲突(t *testing.T) {
defer db.Close() defer db.Close()
if _, err := db.Exec(`CREATE TABLE catalog_import_runs ( if _, err := db.Exec(`CREATE TABLE catalog_import_runs (
source TEXT NOT NULL,batch_id TEXT NOT NULL,request_hash TEXT NOT NULL,status TEXT NOT NULL, source TEXT NOT NULL,batch_id TEXT NOT NULL,request_hash TEXT NOT NULL,status TEXT NOT NULL,
request_count INTEGER NOT NULL,conflict_count INTEGER NOT NULL,observed_at TEXT,last_request_at TEXT NOT NULL, request_count INTEGER NOT NULL,conflict_count INTEGER NOT NULL,observed_at TEXT,update_policy TEXT NOT NULL DEFAULT 'fill_missing',last_request_at TEXT NOT NULL,
last_conflict_at TEXT,shopee_created INTEGER NOT NULL DEFAULT 0,shopee_updated INTEGER NOT NULL DEFAULT 0, last_conflict_at TEXT,shopee_created INTEGER NOT NULL DEFAULT 0,shopee_updated INTEGER NOT NULL DEFAULT 0,
sku_created INTEGER NOT NULL DEFAULT 0,sku_updated INTEGER NOT NULL DEFAULT 0,pdd_created INTEGER NOT NULL DEFAULT 0, sku_created INTEGER NOT NULL DEFAULT 0,sku_updated INTEGER NOT NULL DEFAULT 0,sku_filled INTEGER NOT NULL DEFAULT 0,sku_same_source_updated INTEGER NOT NULL DEFAULT 0,sku_skipped INTEGER NOT NULL DEFAULT 0,sku_manual_skipped INTEGER NOT NULL DEFAULT 0,sku_stale_skipped INTEGER NOT NULL DEFAULT 0,pdd_created INTEGER NOT NULL DEFAULT 0,
pdd_updated INTEGER NOT NULL DEFAULT 0,association_created INTEGER NOT NULL DEFAULT 0, pdd_updated INTEGER NOT NULL DEFAULT 0,association_created INTEGER NOT NULL DEFAULT 0,
association_unchanged INTEGER NOT NULL DEFAULT 0,failure_count INTEGER NOT NULL DEFAULT 0, association_unchanged INTEGER NOT NULL DEFAULT 0,failure_count INTEGER NOT NULL DEFAULT 0,
error_summary TEXT,response_body TEXT,created_at TEXT NOT NULL,finished_at TEXT, error_summary TEXT,response_body TEXT,created_at TEXT NOT NULL,finished_at TEXT,
+149 -2
View File
@@ -19,7 +19,7 @@ import (
"cmautobuy/admin/spec" "cmautobuy/admin/spec"
) )
const mysqlSchemaVersion = 7 const mysqlSchemaVersion = 8
// OpenMySQL 打开生产 MySQL 8 数据库。错误信息绝不包含完整 DSN 或密码。 // OpenMySQL 打开生产 MySQL 8 数据库。错误信息绝不包含完整 DSN 或密码。
func OpenMySQL(cfg config.DatabaseConfig) (*sql.DB, error) { func OpenMySQL(cfg config.DatabaseConfig) (*sql.DB, error) {
@@ -507,10 +507,115 @@ func MigrateMySQL(db *sql.DB) error {
if _, err := db.Exec(`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`, 7, time.Now().UTC().Format(time.RFC3339Nano)); err != nil { if _, err := db.Exec(`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`, 7, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
return fmt.Errorf("记录 MySQL schema v7 失败: %w", err) return fmt.Errorf("记录 MySQL schema v7 失败: %w", err)
} }
current = 7
}
if current < 8 {
if err := migrateMySQLV8(db); err != nil {
return fmt.Errorf("执行 MySQL schema v8 失败: %w", err)
}
if err := checkMySQLV8Shape(db); err != nil {
return fmt.Errorf("MySQL schema v8 自检失败,未记录版本: %w", err)
}
if _, err := db.Exec(`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`, 8, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
return fmt.Errorf("记录 MySQL schema v8 失败: %w", err)
}
} }
return CheckMySQLSchema(db) return CheckMySQLSchema(db)
} }
// migrateMySQLV8 保留 sku_id 作为内部主键,并增加可空的真实蝦皮 SKU ID。
// 每个 DDL 都先检查形状,支持 MySQL 隐式提交后的中断重放。
func migrateMySQLV8(db *sql.DB) error {
columns := []struct{ table, name, ddl string }{
{"shopee_skus", "shopee_sku_id", `ALTER TABLE shopee_skus ADD COLUMN shopee_sku_id VARCHAR(191) COLLATE utf8mb4_bin NULL AFTER sku_id`},
{"shopee_skus", "spec_key", `ALTER TABLE shopee_skus ADD COLUMN spec_key VARCHAR(191) COLLATE utf8mb4_bin NULL AFTER spec_raw`},
{"shopee_skus", "source", `ALTER TABLE shopee_skus ADD COLUMN source VARCHAR(64) COLLATE utf8mb4_bin NULL AFTER is_manual`},
{"shopee_skus", "field_sources", `ALTER TABLE shopee_skus ADD COLUMN field_sources JSON NULL AFTER source`},
{"shopee_skus", "field_observed_at", `ALTER TABLE shopee_skus ADD COLUMN field_observed_at JSON NULL AFTER field_sources`},
{"catalog_import_runs", "update_policy", `ALTER TABLE catalog_import_runs ADD COLUMN update_policy VARCHAR(32) COLLATE utf8mb4_bin NOT NULL DEFAULT 'fill_missing' AFTER observed_at`},
{"catalog_import_runs", "sku_filled", `ALTER TABLE catalog_import_runs ADD COLUMN sku_filled INT UNSIGNED NOT NULL DEFAULT 0 AFTER sku_updated`},
{"catalog_import_runs", "sku_same_source_updated", `ALTER TABLE catalog_import_runs ADD COLUMN sku_same_source_updated INT UNSIGNED NOT NULL DEFAULT 0 AFTER sku_filled`},
{"catalog_import_runs", "sku_skipped", `ALTER TABLE catalog_import_runs ADD COLUMN sku_skipped INT UNSIGNED NOT NULL DEFAULT 0 AFTER sku_same_source_updated`},
{"catalog_import_runs", "sku_manual_skipped", `ALTER TABLE catalog_import_runs ADD COLUMN sku_manual_skipped INT UNSIGNED NOT NULL DEFAULT 0 AFTER sku_skipped`},
{"catalog_import_runs", "sku_stale_skipped", `ALTER TABLE catalog_import_runs ADD COLUMN sku_stale_skipped INT UNSIGNED NOT NULL DEFAULT 0 AFTER sku_manual_skipped`},
}
for _, column := range columns {
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)
}
}
}
if _, err := db.Exec(`UPDATE shopee_skus SET shopee_sku_id=sku_id WHERE shopee_sku_id IS NULL AND sku_id NOT LIKE 'catalog:%'`); err != nil {
return fmt.Errorf("回填真实蝦皮 SKU ID 失败: %w", err)
}
if _, err := db.Exec(`UPDATE shopee_skus SET source='report' WHERE source IS NULL OR source=''`); err != nil {
return fmt.Errorf("回填 SKU 来源失败: %w", err)
}
if _, err := db.Exec(`UPDATE shopee_skus SET field_sources=JSON_OBJECT('color',IF(color IS NULL OR color='','',COALESCE(source,'report')),'size',IF(size IS NULL OR size='','',COALESCE(source,'report')),'advice',IF(advice IS NULL OR advice='','',COALESCE(source,'report')),'sku_code',IF(sku_code IS NULL OR sku_code='','',COALESCE(source,'report'))) WHERE field_sources IS NULL`); err != nil {
return fmt.Errorf("回填 SKU 字段来源失败: %w", err)
}
if _, err := db.Exec(`UPDATE shopee_skus SET field_observed_at=JSON_OBJECT('color',IF(color IS NULL OR color='','',COALESCE(source_observed_at,updated_at)),'size',IF(size IS NULL OR size='','',COALESCE(source_observed_at,updated_at)),'advice',IF(advice IS NULL OR advice='','',COALESCE(source_observed_at,updated_at)),'sku_code',IF(sku_code IS NULL OR sku_code='','',COALESCE(source_observed_at,updated_at))) WHERE field_observed_at IS NULL`); err != nil {
return fmt.Errorf("回填 SKU 字段观测时间失败: %w", err)
}
rows, err := db.Query(`SELECT sku_id,spec_raw FROM shopee_skus WHERE spec_key IS NULL OR spec_key=''`)
if err != nil {
return fmt.Errorf("读取待回填规格键失败: %w", err)
}
type pendingKey struct{ id, raw string }
var pending []pendingKey
for rows.Next() {
var row pendingKey
if err := rows.Scan(&row.id, &row.raw); err != nil {
rows.Close()
return err
}
pending = append(pending, row)
}
if err := rows.Close(); err != nil {
return err
}
for _, row := range pending {
key, keyErr := spec.SpecKey(row.raw)
if keyErr != nil {
return fmt.Errorf("SKU 内部记录 %s 无法生成规格键: %w", row.id, keyErr)
}
if len([]rune(key)) > 191 {
return fmt.Errorf("SKU 内部记录 %s 的规格键超过 191 个字符", row.id)
}
if _, err := db.Exec(`UPDATE shopee_skus SET spec_key=? WHERE sku_id=?`, key, row.id); err != nil {
return err
}
}
var duplicate string
err = db.QueryRow(`SELECT CONCAT(goods_id,' / ',spec_key) FROM shopee_skus GROUP BY goods_id,spec_key HAVING COUNT(*)>1 LIMIT 1`).Scan(&duplicate)
if err != nil && err != sql.ErrNoRows {
return err
}
if err == nil {
return fmt.Errorf("存在同商品重复规格 %s,请人工确认真实 SKU 后再迁移", duplicate)
}
for _, index := range []struct{ name, ddl string }{
{"uq_shopee_skus_external", `ALTER TABLE shopee_skus ADD UNIQUE KEY uq_shopee_skus_external (shopee_sku_id)`},
{"uq_shopee_skus_spec", `ALTER TABLE shopee_skus ADD UNIQUE KEY uq_shopee_skus_spec (goods_id,spec_key)`},
} {
exists, err := mysqlIndexExists(db, "shopee_skus", index.name)
if err != nil {
return err
}
if !exists {
if _, err := db.Exec(index.ddl); err != nil {
return fmt.Errorf("增加索引 %s 失败: %w", index.name, err)
}
}
}
return nil
}
const mysqlSchemaV7CatalogRuns = `CREATE TABLE IF NOT EXISTS catalog_import_runs ( const mysqlSchemaV7CatalogRuns = `CREATE TABLE IF NOT EXISTS catalog_import_runs (
source VARCHAR(64) COLLATE utf8mb4_bin NOT NULL, source VARCHAR(64) COLLATE utf8mb4_bin NOT NULL,
batch_id VARCHAR(191) COLLATE utf8mb4_bin NOT NULL, batch_id VARCHAR(191) COLLATE utf8mb4_bin NOT NULL,
@@ -948,7 +1053,49 @@ func CheckMySQLSchema(db *sql.DB) error {
if err := checkMySQLV6Shape(db); err != nil { if err := checkMySQLV6Shape(db); err != nil {
return err return err
} }
return checkMySQLV7Shape(db) if err := checkMySQLV7Shape(db); err != nil {
return err
}
return checkMySQLV8Shape(db)
}
func checkMySQLV8Shape(db *sql.DB) error {
for _, column := range []struct {
name string
length int64
nullable bool
collation, def string
}{
{"shopee_sku_id", 191, true, "utf8mb4_bin", ""},
{"spec_key", 191, true, "utf8mb4_bin", ""},
{"source", 64, true, "utf8mb4_bin", ""},
} {
if err := checkMySQLVarcharColumn(db, "shopee_skus", column.name, column.length, column.nullable, column.collation, column.def); err != nil {
return err
}
}
for _, name := range []string{"field_sources", "field_observed_at"} {
exists, err := mysqlColumnExists(db, "shopee_skus", name)
if err != nil || !exists {
return fmt.Errorf("蝦皮 SKU 字段 %s 缺失", name)
}
}
if err := checkMySQLVarcharColumn(db, "catalog_import_runs", "update_policy", 32, false, "utf8mb4_bin", "fill_missing"); err != nil {
return err
}
for _, name := range []string{"sku_filled", "sku_same_source_updated", "sku_skipped", "sku_manual_skipped", "sku_stale_skipped"} {
exists, err := mysqlColumnExists(db, "catalog_import_runs", name)
if err != nil || !exists {
return fmt.Errorf("商品目录导入记录列 %s 缺失", name)
}
}
for _, name := range []string{"uq_shopee_skus_external", "uq_shopee_skus_spec"} {
exists, err := mysqlIndexExists(db, "shopee_skus", name)
if err != nil || !exists {
return fmt.Errorf("蝦皮 SKU 唯一索引 %s 缺失", name)
}
}
return nil
} }
func checkMySQLV7Shape(db *sql.DB) error { func checkMySQLV7Shape(db *sql.DB) error {
@@ -382,6 +382,53 @@ func TestMySQLMigrate_V7形状错误不记版本(t *testing.T) {
} }
} }
func TestMySQLMigrate_V7升级V8保留内部引用且可重放(t *testing.T) {
db := openMySQLMigrationTestDB(t)
defer db.Close()
cleanMySQLTestSchema(t, db)
defer cleanMySQLTestSchema(t, db)
prepareMySQLV7(t, db)
now := "2026-08-11T00:00:00Z"
mustExec(t, db, `INSERT INTO shopee_products(goods_id,title,source,created_at,updated_at) VALUES('S-1','商品','report',?,?)`, now, now)
mustExec(t, db, `INSERT INTO shopee_skus(sku_id,goods_id,spec_raw,created_at,updated_at) VALUES('REAL-1','S-1',' 黑色, M ',?,?)`, now, now)
// 模拟全部 DDL/回填已提交,但 schema_migrations 尚未记录 v8。
if err := migrateMySQLV8(db); err != nil {
t.Fatal(err)
}
if err := MigrateMySQL(db); err != nil {
t.Fatal(err)
}
if err := MigrateMySQL(db); err != nil {
t.Fatalf("v8 重跑失败: %v", err)
}
var internalID, externalID, key string
if err := db.QueryRow(`SELECT sku_id,shopee_sku_id,spec_key FROM shopee_skus`).Scan(&internalID, &externalID, &key); err != nil {
t.Fatal(err)
}
if internalID != "REAL-1" || externalID != "REAL-1" || key != "黑色, M" {
t.Fatalf("历史身份未保留:internal=%q external=%q key=%q", internalID, externalID, key)
}
}
func TestMySQLMigrate_V8重复规格停止且不记版本(t *testing.T) {
db := openMySQLMigrationTestDB(t)
defer db.Close()
cleanMySQLTestSchema(t, db)
defer cleanMySQLTestSchema(t, db)
prepareMySQLV7(t, db)
now := "2026-08-11T00:00:00Z"
mustExec(t, db, `INSERT INTO shopee_products(goods_id,title,source,created_at,updated_at) VALUES('S-1','商品','report',?,?)`, now, now)
mustExec(t, db, `INSERT INTO shopee_skus(sku_id,goods_id,spec_raw,created_at,updated_at) VALUES('A','S-1','黑色,M',?,?),('B','S-1','黑色,M',?,?)`, now, now, now, now)
if err := MigrateMySQL(db); err == nil || !strings.Contains(err.Error(), "重复规格") {
t.Fatalf("应明确阻止冲突迁移:%v", err)
}
var count int
db.QueryRow(`SELECT COUNT(*) FROM schema_migrations WHERE version=8`).Scan(&count)
if count != 0 {
t.Fatal("冲突迁移不得记录 v8")
}
}
func openMySQLMigrationTestDB(t *testing.T) *sql.DB { func openMySQLMigrationTestDB(t *testing.T) *sql.DB {
t.Helper() t.Helper()
if os.Getenv("CMAUTOBUY_MYSQL_TEST") != "1" { if os.Getenv("CMAUTOBUY_MYSQL_TEST") != "1" {
@@ -436,6 +483,15 @@ func prepareMySQLV6(t *testing.T, db *sql.DB) {
VALUES (5,'2026-08-10T00:00:00Z'),(6,'2026-08-10T00:00:00Z')`) VALUES (5,'2026-08-10T00:00:00Z'),(6,'2026-08-10T00:00:00Z')`)
} }
func prepareMySQLV7(t *testing.T, db *sql.DB) {
t.Helper()
prepareMySQLV6(t, db)
if err := migrateMySQLV7(db); err != nil {
t.Fatal(err)
}
mustExec(t, db, `INSERT INTO schema_migrations(version,applied_at) VALUES (7,'2026-08-11T00:00:00Z')`)
}
func mustExec(t *testing.T, db *sql.DB, query string, args ...any) { func mustExec(t *testing.T, db *sql.DB, query string, args ...any) {
t.Helper() t.Helper()
if _, err := db.Exec(query, args...); err != nil { if _, err := db.Exec(query, args...); err != nil {
+33 -18
View File
@@ -2,11 +2,13 @@ package repository
import ( import (
"database/sql" "database/sql"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
"cmautobuy/admin/model" "cmautobuy/admin/model"
"cmautobuy/admin/spec"
) )
// UpsertShopeeProduct 写入或更新一条蝦皮商品汇总行。 // UpsertShopeeProduct 写入或更新一条蝦皮商品汇总行。
@@ -57,22 +59,35 @@ func UpsertShopeeSKU(q Execer, skuID, goodsID, specRaw, color, size, advice stri
parseOKInt = 1 parseOKInt = 1
} }
now := model.NowISO() now := model.NowISO()
_, err := q.Exec(` specKey, err := spec.SpecKey(specRaw)
if err != nil {
return fmt.Errorf("规格原文无效: %w", err)
}
fieldSources, fieldTimes := map[string]string{}, map[string]string{}
for name, value := range map[string]string{"color": color, "size": size, "advice": advice, "sku_code": skuCode} {
if strings.TrimSpace(value) != "" {
fieldSources[name], fieldTimes[name] = "report", now
}
}
fieldSourcesJSON, _ := json.Marshal(fieldSources)
fieldTimesJSON, _ := json.Marshal(fieldTimes)
_, err = q.Exec(`
INSERT INTO shopee_skus INSERT INTO shopee_skus
(sku_id, goods_id, spec_raw, color, size, advice, parse_ok, sku_code, (sku_id, shopee_sku_id, goods_id, spec_raw, spec_key, color, size, advice, parse_ok, sku_code,
is_manual, created_at, updated_at) is_manual, source, field_sources, field_observed_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 'report', ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
goods_id = VALUES(goods_id), goods_id = CASE WHEN is_manual=1 THEN goods_id ELSE VALUES(goods_id) END,
spec_raw = VALUES(spec_raw), color = CASE WHEN is_manual=1 THEN color ELSE VALUES(color) END,
color = VALUES(color), size = CASE WHEN is_manual=1 THEN size ELSE VALUES(size) END,
size = VALUES(size), advice = CASE WHEN is_manual=1 THEN advice ELSE VALUES(advice) END,
advice = VALUES(advice), parse_ok = CASE WHEN is_manual=1 THEN parse_ok ELSE VALUES(parse_ok) END,
parse_ok = VALUES(parse_ok), sku_code = CASE WHEN is_manual=1 THEN sku_code ELSE VALUES(sku_code) END,
sku_code = VALUES(sku_code), field_sources = CASE WHEN is_manual=1 THEN field_sources ELSE VALUES(field_sources) END,
updated_at = VALUES(updated_at)`, field_observed_at = CASE WHEN is_manual=1 THEN field_observed_at ELSE VALUES(field_observed_at) END,
// is_manual 不在上面的 SET 列表里,SQLite 对没提到的列保持原值不变。 updated_at = CASE WHEN is_manual=1 THEN updated_at ELSE VALUES(updated_at) END`,
skuID, goodsID, specRaw, color, size, advice, parseOKInt, skuCode, now, now) // 人工行的业务字段和更新时间均由 CASE 保留。
skuID, skuID, goodsID, specRaw, specKey, color, size, advice, parseOKInt, skuCode, string(fieldSourcesJSON), string(fieldTimesJSON), now, now)
if err != nil { if err != nil {
return fmt.Errorf("写入蝦皮 SKU %s 失败: %w", skuID, err) return fmt.Errorf("写入蝦皮 SKU %s 失败: %w", skuID, err)
} }
@@ -293,7 +308,7 @@ func GetShopeeCollectStatus(q Execer, pddGoodsID string) (status, msg sql.NullSt
// 见 #41「弹窗里待补的行必须显示 spec_raw 原文」。 // 见 #41「弹窗里待补的行必须显示 spec_raw 原文」。
func ListShopeeSKUsByGoodsID(q Execer, goodsID string) ([]model.ShopeeSKU, error) { func ListShopeeSKUsByGoodsID(q Execer, goodsID string) ([]model.ShopeeSKU, error) {
rows, err := q.Query(` rows, err := q.Query(`
SELECT sku_id, goods_id, spec_raw, color, size, advice, parse_ok, sku_code, SELECT sku_id, COALESCE(shopee_sku_id,''), goods_id, spec_raw, color, size, advice, parse_ok, sku_code,
is_manual, created_at, updated_at is_manual, created_at, updated_at
FROM shopee_skus FROM shopee_skus
WHERE goods_id = ? WHERE goods_id = ?
@@ -309,7 +324,7 @@ func ListShopeeSKUsByGoodsID(q Execer, goodsID string) ([]model.ShopeeSKU, error
var color, size, advice, skuCode sql.NullString var color, size, advice, skuCode sql.NullString
var parseOK, isManual int var parseOK, isManual int
if err := rows.Scan( if err := rows.Scan(
&sk.SKUID, &sk.GoodsID, &sk.SpecRaw, &color, &size, &advice, &sk.RecordID, &sk.SKUID, &sk.GoodsID, &sk.SpecRaw, &color, &size, &advice,
&parseOK, &skuCode, &isManual, &sk.CreatedAt, &sk.UpdatedAt, &parseOK, &skuCode, &isManual, &sk.CreatedAt, &sk.UpdatedAt,
); err != nil { ); err != nil {
return nil, fmt.Errorf("读取蝦皮商品 %s 的规格失败: %w", goodsID, err) return nil, fmt.Errorf("读取蝦皮商品 %s 的规格失败: %w", goodsID, err)
@@ -331,10 +346,10 @@ func GetShopeeSKUByID(q Execer, skuID string) (*model.ShopeeSKU, error) {
var color, size, advice, skuCode sql.NullString var color, size, advice, skuCode sql.NullString
var parseOK, isManual int var parseOK, isManual int
err := q.QueryRow(` err := q.QueryRow(`
SELECT sku_id, goods_id, spec_raw, color, size, advice, parse_ok, sku_code, SELECT sku_id, COALESCE(shopee_sku_id,''), goods_id, spec_raw, color, size, advice, parse_ok, sku_code,
is_manual, created_at, updated_at is_manual, created_at, updated_at
FROM shopee_skus WHERE sku_id = ?`, skuID).Scan( FROM shopee_skus WHERE sku_id = ?`, skuID).Scan(
&sk.SKUID, &sk.GoodsID, &sk.SpecRaw, &color, &size, &advice, &sk.RecordID, &sk.SKUID, &sk.GoodsID, &sk.SpecRaw, &color, &size, &advice,
&parseOK, &skuCode, &isManual, &sk.CreatedAt, &sk.UpdatedAt, &parseOK, &skuCode, &isManual, &sk.CreatedAt, &sk.UpdatedAt,
) )
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
+52
View File
@@ -5,11 +5,14 @@ import (
"crypto/sha256" "crypto/sha256"
"database/sql" "database/sql"
"encoding/binary" "encoding/binary"
"encoding/json"
"fmt" "fmt"
"path/filepath" "path/filepath"
"sort" "sort"
"strings" "strings"
"time" "time"
"cmautobuy/admin/spec"
) )
// SQLiteMigrationSummary 只包含表名和数量,可以安全写入终端或工单。 // SQLiteMigrationSummary 只包含表名和数量,可以安全写入终端或工单。
@@ -117,6 +120,9 @@ func MigrateSQLiteToMySQL(source, target *sql.DB, dryRun bool) (*SQLiteMigration
return nil, err return nil, err
} }
} }
if err := normalizeMigratedShopeeSKUs(tx); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {
return nil, &SQLiteMigrationError{Stage: "无法提交 MySQL 导入事务", Cause: err} return nil, &SQLiteMigrationError{Stage: "无法提交 MySQL 导入事务", Cause: err}
} }
@@ -126,6 +132,52 @@ func MigrateSQLiteToMySQL(source, target *sql.DB, dryRun bool) (*SQLiteMigration
return summary, nil return summary, nil
} }
// normalizeMigratedShopeeSKUs 补齐 MySQL v8 新增、历史 SQLite 没有的规格身份列。
func normalizeMigratedShopeeSKUs(tx *sql.Tx) error {
rows, err := tx.Query(`SELECT sku_id,spec_raw,color,size,advice,sku_code,updated_at FROM shopee_skus`)
if err != nil {
return &SQLiteMigrationError{Stage: "无法读取待转换 SKU", Table: "shopee_skus", Cause: err}
}
type item struct {
id, raw, updated string
color, size, advice, code sql.NullString
}
var list []item
for rows.Next() {
var row item
if err := rows.Scan(&row.id, &row.raw, &row.color, &row.size, &row.advice, &row.code, &row.updated); err != nil {
rows.Close()
return err
}
list = append(list, row)
}
if err := rows.Close(); err != nil {
return err
}
for _, row := range list {
key, err := spec.SpecKey(row.raw)
if err != nil {
return &SQLiteMigrationError{Stage: "历史 SKU 规格原文无效", Table: "shopee_skus", Cause: err}
}
if len([]rune(key)) > 191 {
return &SQLiteMigrationError{Stage: "历史 SKU 规格键过长", Table: "shopee_skus"}
}
sources, times := map[string]string{}, map[string]string{}
for name, value := range map[string]string{"color": row.color.String, "size": row.size.String, "advice": row.advice.String, "sku_code": row.code.String} {
if value != "" {
sources[name] = "report"
times[name] = row.updated
}
}
sourcesJSON, _ := json.Marshal(sources)
timesJSON, _ := json.Marshal(times)
if _, err := tx.Exec(`UPDATE shopee_skus SET shopee_sku_id=sku_id,spec_key=?,source='report',field_sources=?,field_observed_at=? WHERE sku_id=?`, key, string(sourcesJSON), string(timesJSON), row.id); err != nil {
return &SQLiteMigrationError{Stage: "无法补齐 MySQL v8 SKU 身份", Table: "shopee_skus", Cause: err}
}
}
return nil
}
// VerifySQLiteToMySQL 比较逐表数量和全部迁移列的确定性摘要,不输出业务内容。 // VerifySQLiteToMySQL 比较逐表数量和全部迁移列的确定性摘要,不输出业务内容。
func VerifySQLiteToMySQL(source, target *sql.DB) (*SQLiteMigrationSummary, error) { func VerifySQLiteToMySQL(source, target *sql.DB) (*SQLiteMigrationSummary, error) {
if err := validateLegacySQLite(source); err != nil { if err := validateLegacySQLite(source); err != nil {
+63 -9
View File
@@ -11,9 +11,11 @@ import (
"net/http" "net/http"
"strings" "strings"
"time" "time"
"unicode/utf8"
"cmautobuy/admin/model" "cmautobuy/admin/model"
"cmautobuy/admin/repository" "cmautobuy/admin/repository"
"cmautobuy/admin/spec"
) )
const ( const (
@@ -63,6 +65,7 @@ type CatalogBatchRequest struct {
SchemaVersion int `json:"schema_version"` SchemaVersion int `json:"schema_version"`
BatchID string `json:"batch_id"` BatchID string `json:"batch_id"`
ObservedAt string `json:"observed_at"` ObservedAt string `json:"observed_at"`
UpdatePolicy string `json:"update_policy"`
ShopeeProducts []CatalogShopeeProduct `json:"shopee_products"` ShopeeProducts []CatalogShopeeProduct `json:"shopee_products"`
ShopeeSKUs []CatalogShopeeSKU `json:"shopee_skus"` ShopeeSKUs []CatalogShopeeSKU `json:"shopee_skus"`
PddProducts []CatalogPddProduct `json:"pdd_products"` PddProducts []CatalogPddProduct `json:"pdd_products"`
@@ -73,6 +76,11 @@ type CatalogCounts struct {
ShopeeUpdated int `json:"shopee_updated"` ShopeeUpdated int `json:"shopee_updated"`
SKUCreated int `json:"sku_created"` SKUCreated int `json:"sku_created"`
SKUUpdated int `json:"sku_updated"` SKUUpdated int `json:"sku_updated"`
SKUFilled int `json:"sku_filled"`
SKUSameSourceUpdated int `json:"sku_same_source_updated"`
SKUSkipped int `json:"sku_skipped"`
SKUManualSkipped int `json:"sku_manual_skipped"`
SKUStaleSkipped int `json:"sku_stale_skipped"`
PddCreated int `json:"pdd_created"` PddCreated int `json:"pdd_created"`
PddUpdated int `json:"pdd_updated"` PddUpdated int `json:"pdd_updated"`
AssociationCreated int `json:"association_created"` AssociationCreated int `json:"association_created"`
@@ -108,13 +116,16 @@ func ValidateCatalogBatch(req CatalogBatchRequest) error {
if _, err := time.Parse(time.RFC3339Nano, req.ObservedAt); err != nil { if _, err := time.Parse(time.RFC3339Nano, req.ObservedAt); err != nil {
return catalogInvalid("INVALID_OBSERVED_AT", "observed_at 必须是带时区 ISO 8601", nil) return catalogInvalid("INVALID_OBSERVED_AT", "observed_at 必须是带时区 ISO 8601", nil)
} }
if _, err := normalizeCatalogUpdatePolicy(req.UpdatePolicy); err != nil {
return err
}
if len(req.ShopeeProducts)+len(req.PddProducts) > CatalogMaxProducts { if len(req.ShopeeProducts)+len(req.PddProducts) > CatalogMaxProducts {
return catalogInvalid("TOO_MANY_PRODUCTS", "每批商品总数不能超过 500", nil) return catalogInvalid("TOO_MANY_PRODUCTS", "每批商品总数不能超过 500", nil)
} }
if len(req.ShopeeSKUs) > CatalogMaxSKUs { if len(req.ShopeeSKUs) > CatalogMaxSKUs {
return catalogInvalid("TOO_MANY_SKUS", "每批 SKU 不能超过 5000", nil) return catalogInvalid("TOO_MANY_SKUS", "每批 SKU 不能超过 5000", nil)
} }
seenProducts, seenSKUs, seenPDD := map[string]bool{}, map[string]bool{}, map[string]bool{} seenProducts, seenSKUs, seenSpecs, seenPDD := map[string]bool{}, map[string]bool{}, map[string]bool{}, map[string]bool{}
for i, p := range req.ShopeeProducts { for i, p := range req.ShopeeProducts {
p.GoodsID = strings.TrimSpace(p.GoodsID) p.GoodsID = strings.TrimSpace(p.GoodsID)
if p.GoodsID == "" || strings.TrimSpace(p.Title) == "" || seenProducts[p.GoodsID] { if p.GoodsID == "" || strings.TrimSpace(p.Title) == "" || seenProducts[p.GoodsID] {
@@ -123,10 +134,16 @@ func ValidateCatalogBatch(req CatalogBatchRequest) error {
seenProducts[p.GoodsID] = true seenProducts[p.GoodsID] = true
} }
for i, s := range req.ShopeeSKUs { for i, s := range req.ShopeeSKUs {
if strings.TrimSpace(s.SKUID) == "" || strings.TrimSpace(s.GoodsID) == "" || strings.TrimSpace(s.SpecRaw) == "" || seenSKUs[s.SKUID] { key, keyErr := spec.SpecKey(s.SpecRaw)
return catalogInvalid("INVALID_SHOPEE_SKU", "蝦皮 SKU ID、商品 ID、spec_raw 必填且 SKU 不能重复", map[string]any{"index": i, "sku_id": s.SKUID}) externalID := strings.TrimSpace(s.SKUID)
identity := strings.TrimSpace(s.GoodsID) + "\x00" + key
if strings.TrimSpace(s.GoodsID) == "" || keyErr != nil || utf8.RuneCountInString(key) > 191 || seenSpecs[identity] || (externalID != "" && seenSKUs[externalID]) {
return catalogInvalid("INVALID_SHOPEE_SKU", "商品 ID、有效 spec_raw 必填,批内规格身份和非空 SKU ID 不能重复", map[string]any{"index": i, "sku_id": externalID})
}
seenSpecs[identity] = true
if externalID != "" {
seenSKUs[externalID] = true
} }
seenSKUs[s.SKUID] = true
} }
for i, p := range req.PddProducts { for i, p := range req.PddProducts {
if strings.TrimSpace(p.GoodsID) == "" || strings.TrimSpace(p.URL) == "" || seenPDD[p.GoodsID] { if strings.TrimSpace(p.GoodsID) == "" || strings.TrimSpace(p.URL) == "" || seenPDD[p.GoodsID] {
@@ -153,6 +170,8 @@ func ImportCatalogBatch(db *sql.DB, source string, req CatalogBatchRequest, raw
} }
observed, _ := time.Parse(time.RFC3339Nano, req.ObservedAt) observed, _ := time.Parse(time.RFC3339Nano, req.ObservedAt)
req.ObservedAt = observed.UTC().Format(model.TimeLayout) req.ObservedAt = observed.UTC().Format(model.TimeLayout)
policy, _ := normalizeCatalogUpdatePolicy(req.UpdatePolicy)
req.UpdatePolicy = policy
h := sha256.Sum256(raw) h := sha256.Sum256(raw)
hash := hex.EncodeToString(h[:]) hash := hex.EncodeToString(h[:])
now := model.NowISO() now := model.NowISO()
@@ -160,7 +179,7 @@ func ImportCatalogBatch(db *sql.DB, source string, req CatalogBatchRequest, raw
if err != nil { if err != nil {
return CatalogBatchResponse{}, err return CatalogBatchResponse{}, err
} }
run := model.CatalogImportRun{Source: source, BatchID: req.BatchID, RequestHash: hash, Status: model.CatalogImportProcessing, ObservedAt: req.ObservedAt, LastRequestAt: now, CreatedAt: now} run := model.CatalogImportRun{Source: source, BatchID: req.BatchID, RequestHash: hash, Status: model.CatalogImportProcessing, UpdatePolicy: policy, ObservedAt: req.ObservedAt, LastRequestAt: now, CreatedAt: now}
if err = repository.InsertCatalogImportRun(tx, run); err != nil { if err = repository.InsertCatalogImportRun(tx, run); err != nil {
tx.Rollback() tx.Rollback()
if errors.Is(err, repository.ErrCatalogImportRunExists) { if errors.Is(err, repository.ErrCatalogImportRunExists) {
@@ -215,15 +234,29 @@ func ImportCatalogBatch(db *sql.DB, source string, req CatalogBatchRequest, raw
} }
} }
for _, s := range req.ShopeeSKUs { for _, s := range req.ShopeeSKUs {
c, u, e := repository.UpsertCatalogShopeeSKU(tx, s.SKUID, s.GoodsID, s.SpecRaw, s.Color, s.Size, s.Advice, s.ParseOK, s.SKUCode, req.ObservedAt, now) key, _ := spec.SpecKey(s.SpecRaw)
internalID := catalogSKURecordID(s.GoodsID, key)
outcome, e := repository.UpsertCatalogShopeeSKU(tx, repository.CatalogShopeeSKUInput{RecordID: internalID, ShopeeSKUID: strings.TrimSpace(s.SKUID), GoodsID: strings.TrimSpace(s.GoodsID), SpecRaw: s.SpecRaw, SpecKey: key, Color: s.Color, Size: s.Size, Advice: s.Advice, ParseOK: s.ParseOK, SKUCode: s.SKUCode, Source: source, ObservedAt: req.ObservedAt, Now: now, UpdatePolicy: policy})
if e != nil { if e != nil {
return fail(&CatalogError{Status: 409, Code: "SKU_OWNERSHIP_CONFLICT", Message: e.Error()}) return fail(&CatalogError{Status: 409, Code: "SKU_OWNERSHIP_CONFLICT", Message: e.Error()})
} }
if c { switch outcome {
case repository.CatalogSKUCreated:
counts.SKUCreated++ counts.SKUCreated++
} case repository.CatalogSKUFilled:
if u { counts.SKUFilled++
counts.SKUUpdated++ counts.SKUUpdated++
case repository.CatalogSKUSameSourceUpdated:
counts.SKUSameSourceUpdated++
counts.SKUUpdated++
case repository.CatalogSKUManualSkipped:
counts.SKUManualSkipped++
counts.SKUSkipped++
case repository.CatalogSKUStaleSkipped:
counts.SKUStaleSkipped++
counts.SKUSkipped++
default:
counts.SKUSkipped++
} }
} }
for _, a := range req.Associations { for _, a := range req.Associations {
@@ -247,6 +280,11 @@ func ImportCatalogBatch(db *sql.DB, source string, req CatalogBatchRequest, raw
run.ShopeeUpdated = counts.ShopeeUpdated run.ShopeeUpdated = counts.ShopeeUpdated
run.SKUCreated = counts.SKUCreated run.SKUCreated = counts.SKUCreated
run.SKUUpdated = counts.SKUUpdated run.SKUUpdated = counts.SKUUpdated
run.SKUFilled = counts.SKUFilled
run.SKUSameSourceUpdated = counts.SKUSameSourceUpdated
run.SKUSkipped = counts.SKUSkipped
run.SKUManualSkipped = counts.SKUManualSkipped
run.SKUStaleSkipped = counts.SKUStaleSkipped
run.PddCreated = counts.PddCreated run.PddCreated = counts.PddCreated
run.PddUpdated = counts.PddUpdated run.PddUpdated = counts.PddUpdated
run.AssociationCreated = counts.AssociationCreated run.AssociationCreated = counts.AssociationCreated
@@ -260,6 +298,22 @@ func ImportCatalogBatch(db *sql.DB, source string, req CatalogBatchRequest, raw
return resp, nil return resp, nil
} }
func normalizeCatalogUpdatePolicy(raw string) (string, error) {
switch strings.TrimSpace(raw) {
case "", "fill_missing":
return "fill_missing", nil
case "insert_only", "overwrite_same_source":
return strings.TrimSpace(raw), nil
default:
return "", catalogInvalid("INVALID_UPDATE_POLICY", "update_policy 只支持 insert_only、fill_missing、overwrite_same_source", nil)
}
}
func catalogSKURecordID(goodsID, specKey string) string {
sum := sha256.Sum256([]byte(goodsID + "\x00" + specKey))
return "catalog:" + hex.EncodeToString(sum[:])
}
func replayCatalogBatch(db *sql.DB, source, batchID, hash, now string) (CatalogBatchResponse, error) { func replayCatalogBatch(db *sql.DB, source, batchID, hash, now string) (CatalogBatchResponse, error) {
run, err := repository.GetCatalogImportRun(db, source, batchID) run, err := repository.GetCatalogImportRun(db, source, batchID)
if err != nil { if err != nil {
+151 -2
View File
@@ -20,9 +20,9 @@ func newCatalogTestDB(t *testing.T) *sql.DB {
db.SetMaxOpenConns(1) db.SetMaxOpenConns(1)
statements := []string{ statements := []string{
`CREATE TABLE shopee_products(goods_id TEXT PRIMARY KEY,title TEXT NOT NULL,shopee_status TEXT,main_sku_code TEXT,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,source TEXT,source_observed_at TEXT,pdd_goods_url TEXT,pdd_goods_id TEXT,created_at TEXT,updated_at TEXT)`,
`CREATE TABLE shopee_skus(sku_id TEXT PRIMARY KEY,goods_id TEXT NOT NULL,spec_raw TEXT,color TEXT,size TEXT,advice TEXT,parse_ok INTEGER,sku_code TEXT,is_manual INTEGER,source_observed_at 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 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,last_request_at TEXT,last_conflict_at TEXT,shopee_created INTEGER DEFAULT 0,shopee_updated INTEGER DEFAULT 0,sku_created INTEGER DEFAULT 0,sku_updated 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))`, `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,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))`,
} }
for _, statement := range statements { for _, statement := range statements {
if _, err := db.Exec(statement); err != nil { if _, err := db.Exec(statement); err != nil {
@@ -110,4 +110,153 @@ func TestValidateCatalogBatch_拒绝旧版超限和负金额(t *testing.T) {
if err := ValidateCatalogBatch(req); err == nil { if err := ValidateCatalogBatch(req); err == nil {
t.Fatal("应拒绝负金额") t.Fatal("应拒绝负金额")
} }
req = validCatalogBatch()
req.UpdatePolicy = "force_overwrite"
if err := ValidateCatalogBatch(req); err == nil {
t.Fatal("应拒绝强制覆盖策略")
}
}
func TestImportCatalogBatch_无真实SKU并在后续补回同一记录(t *testing.T) {
db := newCatalogTestDB(t)
req := validCatalogBatch()
req.BatchID = "missing-id"
req.ShopeeSKUs[0].SKUID = ""
raw, _ := json.Marshal(req)
if got, err := ImportCatalogBatch(db, "script-a", req, raw); err != nil || got.Counts.SKUCreated != 1 {
t.Fatalf("首次导入失败:%+v %v", got, err)
}
var recordID string
if err := db.QueryRow(`SELECT sku_id FROM shopee_skus WHERE goods_id='S-1'`).Scan(&recordID); err != nil || recordID == "" {
t.Fatalf("内部记录缺失:%q %v", recordID, err)
}
req.BatchID = "fill-real-id"
req.ShopeeSKUs[0].SKUID = "REAL-1"
raw, _ = json.Marshal(req)
got, err := ImportCatalogBatch(db, "script-a", req, raw)
if err != nil || got.Counts.SKUFilled != 1 {
t.Fatalf("补真实 ID 失败:%+v %v", got, err)
}
var after, external string
_ = db.QueryRow(`SELECT sku_id,shopee_sku_id FROM shopee_skus WHERE goods_id='S-1'`).Scan(&after, &external)
if after != recordID || external != "REAL-1" {
t.Fatalf("不应新建记录:before=%q after=%q external=%q", recordID, after, external)
}
}
func TestImportCatalogBatch_三种策略和保护边界(t *testing.T) {
db := newCatalogTestDB(t)
req := validCatalogBatch()
req.ShopeeSKUs[0].Color = ""
raw, _ := json.Marshal(req)
if _, err := ImportCatalogBatch(db, "script-a", req, raw); err != nil {
t.Fatal(err)
}
fill := validCatalogBatch()
fill.BatchID = "fill"
fill.ShopeeSKUs[0].Color = "黑色"
fill.ShopeeSKUs[0].Size = "L"
raw, _ = json.Marshal(fill)
got, err := ImportCatalogBatch(db, "script-a", fill, raw)
if err != nil || got.Counts.SKUFilled != 1 {
t.Fatalf("补空失败:%+v %v", got, err)
}
var color, size string
_ = db.QueryRow(`SELECT color,size FROM shopee_skus`).Scan(&color, &size)
if color != "黑色" || size != "M" {
t.Fatalf("fill_missing 覆盖了非空字段:%q/%q", color, size)
}
insertOnly := validCatalogBatch()
insertOnly.BatchID = "insert"
insertOnly.UpdatePolicy = "insert_only"
insertOnly.ShopeeSKUs[0].Color = "白色"
raw, _ = json.Marshal(insertOnly)
got, err = ImportCatalogBatch(db, "script-a", insertOnly, raw)
if err != nil || got.Counts.SKUSkipped != 1 {
t.Fatalf("insert_only 失败:%+v %v", got, err)
}
overwrite := validCatalogBatch()
overwrite.BatchID = "overwrite"
overwrite.UpdatePolicy = "overwrite_same_source"
overwrite.ObservedAt = "2026-08-12T08:00:00+08:00"
overwrite.ShopeeSKUs[0].Color = "白色"
raw, _ = json.Marshal(overwrite)
got, err = ImportCatalogBatch(db, "script-a", overwrite, raw)
if err != nil || got.Counts.SKUSameSourceUpdated != 1 {
t.Fatalf("同来源覆盖失败:%+v %v", got, err)
}
_ = db.QueryRow(`SELECT color FROM shopee_skus`).Scan(&color)
if color != "白色" {
t.Fatalf("color=%q", color)
}
stale := validCatalogBatch()
stale.BatchID = "stale"
stale.UpdatePolicy = "overwrite_same_source"
stale.ObservedAt = "2026-08-11T12:00:00+08:00"
stale.ShopeeSKUs[0].Color = "旧红色"
raw, _ = json.Marshal(stale)
got, err = ImportCatalogBatch(db, "script-a", stale, raw)
if err != nil || got.Counts.SKUStaleSkipped != 1 {
t.Fatalf("旧观测保护失败:%+v %v", got, err)
}
cross := validCatalogBatch()
cross.BatchID = "cross-source"
cross.ObservedAt = "2026-08-13T08:00:00+08:00"
cross.ShopeeSKUs[0].Advice = "来源B建议"
raw, _ = json.Marshal(cross)
if _, err := ImportCatalogBatch(db, "script-b", cross, raw); err != nil {
t.Fatal(err)
}
protect := validCatalogBatch()
protect.BatchID = "protect-cross"
protect.UpdatePolicy = "overwrite_same_source"
protect.ObservedAt = "2026-08-14T08:00:00+08:00"
protect.ShopeeSKUs[0].Advice = "来源A不得覆盖"
raw, _ = json.Marshal(protect)
if _, err := ImportCatalogBatch(db, "script-a", protect, raw); err != nil {
t.Fatal(err)
}
var advice string
_ = db.QueryRow(`SELECT advice FROM shopee_skus`).Scan(&advice)
if advice != "来源B建议" {
t.Fatalf("跨来源字段被覆盖:%q", advice)
}
_, _ = db.Exec(`UPDATE shopee_skus SET is_manual=1`)
manual := validCatalogBatch()
manual.BatchID = "manual"
manual.ObservedAt = "2026-08-13T08:00:00+08:00"
manual.ShopeeSKUs[0].Color = "红色"
raw, _ = json.Marshal(manual)
got, err = ImportCatalogBatch(db, "script-a", manual, raw)
if err != nil || got.Counts.SKUManualSkipped != 1 {
t.Fatalf("人工保护失败:%+v %v", got, err)
}
}
func TestImportCatalogBatch_同规格不同真实SKU冲突并回滚(t *testing.T) {
db := newCatalogTestDB(t)
req := validCatalogBatch()
raw, _ := json.Marshal(req)
if _, err := ImportCatalogBatch(db, "script-a", req, raw); err != nil {
t.Fatal(err)
}
req.BatchID = "conflict"
req.ShopeeSKUs[0].SKUID = "SKU-2"
req.ShopeeProducts[0].Title = "不应提交"
raw, _ = json.Marshal(req)
_, err := ImportCatalogBatch(db, "script-a", req, raw)
var catalogErr *CatalogError
if !errors.As(err, &catalogErr) || catalogErr.Status != 409 {
t.Fatalf("应返回 409:%v", err)
}
var title string
_ = db.QueryRow(`SELECT title FROM shopee_products WHERE goods_id='S-1'`).Scan(&title)
if title != "蝦皮上衣" {
t.Fatalf("冲突批次未整体回滚:%q", title)
}
} }
+1 -1
View File
@@ -1 +1 @@
{{define "catalog/detail_modal"}}<div class="modal-body"><dl class="detail-grid"><dt>来源</dt><dd>{{.Run.Source}}</dd><dt>批次号</dt><dd>{{.Run.BatchID}}</dd><dt>状态</dt><dd>{{.Run.StatusText}}</dd><dt>上游观测时间</dt><dd>{{.Run.ObservedAt}}</dd><dt>请求/冲突次数</dt><dd>{{.Run.RequestCount}} / {{.Run.ConflictCount}}</dd><dt>失败数</dt><dd>{{.Run.FailureCount}}</dd><dt>错误码</dt><dd>{{if .Run.ErrorCode}}{{.Run.ErrorCode}}{{else}}—{{end}}</dd><dt>可重试</dt><dd>{{if .Run.Retryable}}是{{else}}否{{end}}</dd><dt>错误摘要</dt><dd>{{if .Run.ErrorSummary}}{{.Run.ErrorSummary}}{{else}}—{{end}}</dd><dt>定位信息</dt><dd>{{if .Run.ErrorDetails}}{{.Run.ErrorDetails}}{{else}}—{{end}}</dd></dl><p class="hint">定位信息只包含数组位置和业务 ID;这里不保存完整请求 JSON 或任何凭据。</p></div>{{end}} {{define "catalog/detail_modal"}}<div class="modal-body"><dl class="detail-grid"><dt>来源</dt><dd>{{.Run.Source}}</dd><dt>批次号</dt><dd>{{.Run.BatchID}}</dd><dt>更新策略</dt><dd>{{.Run.UpdatePolicy}}</dd><dt>状态</dt><dd>{{.Run.StatusText}}</dd><dt>上游观测时间</dt><dd>{{.Run.ObservedAt}}</dd><dt>SKU 新增/补空/同源更新</dt><dd>{{.Run.SKUCreated}} / {{.Run.SKUFilled}} / {{.Run.SKUSameSourceUpdated}}</dd><dt>SKU 跳过(人工/旧数据)</dt><dd>{{.Run.SKUSkipped}}({{.Run.SKUManualSkipped}} / {{.Run.SKUStaleSkipped}})</dd><dt>请求/冲突次数</dt><dd>{{.Run.RequestCount}} / {{.Run.ConflictCount}}</dd><dt>失败数</dt><dd>{{.Run.FailureCount}}</dd><dt>错误码</dt><dd>{{if .Run.ErrorCode}}{{.Run.ErrorCode}}{{else}}—{{end}}</dd><dt>可重试</dt><dd>{{if .Run.Retryable}}是{{else}}否{{end}}</dd><dt>错误摘要</dt><dd>{{if .Run.ErrorSummary}}{{.Run.ErrorSummary}}{{else}}—{{end}}</dd><dt>定位信息</dt><dd>{{if .Run.ErrorDetails}}{{.Run.ErrorDetails}}{{else}}—{{end}}</dd></dl><p class="hint">定位信息只包含数组位置和业务 ID;这里不保存完整请求 JSON 或任何凭据。</p></div>{{end}}
+2 -2
View File
@@ -7,8 +7,8 @@
<button type="submit">筛选</button> <button type="submit">筛选</button>
</form> </form>
</div> </div>
<div class="table-wrap"><table><thead><tr><th>来源</th><th>批次号</th><th>状态</th><th>请求</th><th>冲突</th><th>蝦皮 新/更</th><th>SKU 新/更</th><th>PDD 新/更</th><th>关联 新/同</th><th>首次接收</th><th>完成</th><th>耗时</th></tr></thead><tbody> <div class="table-wrap"><table><thead><tr><th>来源</th><th>批次号</th><th>策略</th><th>状态</th><th>请求</th><th>冲突</th><th>蝦皮 新/更</th><th>SKU 新/补/同源更/跳过</th><th>PDD 新/更</th><th>关联 新/同</th><th>首次接收</th><th>完成</th><th>耗时</th></tr></thead><tbody>
{{range .Rows}}<tr data-detail-id="{{.Source}}|{{.BatchID}}"><td>{{.Source}}</td><td>{{.BatchID}}</td><td>{{.StatusText}}</td><td>{{.RequestCount}}</td><td>{{.ConflictCount}}</td><td>{{.ShopeeCreated}} / {{.ShopeeUpdated}}</td><td>{{.SKUCreated}} / {{.SKUUpdated}}</td><td>{{.PddCreated}} / {{.PddUpdated}}</td><td>{{.AssociationCreated}} / {{.AssociationUnchanged}}</td><td>{{.CreatedText}}</td><td>{{.FinishedText}}</td><td>{{.DurationText}}</td></tr>{{else}}<tr class="empty"><td colspan="12">还没有符合条件的导入记录。</td></tr>{{end}}</tbody></table></div> {{range .Rows}}<tr data-detail-id="{{.Source}}|{{.BatchID}}"><td>{{.Source}}</td><td>{{.BatchID}}</td><td>{{.UpdatePolicy}}</td><td>{{.StatusText}}</td><td>{{.RequestCount}}</td><td>{{.ConflictCount}}</td><td>{{.ShopeeCreated}} / {{.ShopeeUpdated}}</td><td>{{.SKUCreated}} / {{.SKUFilled}} / {{.SKUSameSourceUpdated}} / {{.SKUSkipped}}</td><td>{{.PddCreated}} / {{.PddUpdated}}</td><td>{{.AssociationCreated}} / {{.AssociationUnchanged}}</td><td>{{.CreatedText}}</td><td>{{.FinishedText}}</td><td>{{.DurationText}}</td></tr>{{else}}<tr class="empty"><td colspan="13">还没有符合条件的导入记录。</td></tr>{{end}}</tbody></table></div>
<p class="hint">双击一行查看批次摘要;系统不会保存或展示 Token 和完整请求体。</p> <p class="hint">双击一行查看批次摘要;系统不会保存或展示 Token 和完整请求体。</p>
<div class="modal-backdrop" id="detail-modal" hidden><div class="modal" role="dialog" aria-modal="true"><div class="modal-head"><h2>导入批次详情</h2><button type="button" class="modal-x" data-modal-close>×</button></div><div id="detail-content"></div></div></div> <div class="modal-backdrop" id="detail-modal" hidden><div class="modal" role="dialog" aria-modal="true"><div class="modal-head"><h2>导入批次详情</h2><button type="button" class="modal-x" data-modal-close>×</button></div><div id="detail-content"></div></div></div>
<script>document.querySelectorAll('tr[data-detail-id]').forEach(function(row){row.addEventListener('dblclick',function(){var p=row.dataset.detailId.split('|');fetch('/integrations/catalog/detail?source='+encodeURIComponent(p[0])+'&batch_id='+encodeURIComponent(p[1])).then(function(r){return r.text()}).then(function(html){document.getElementById('detail-content').innerHTML=html;document.getElementById('detail-modal').hidden=false})})})</script> <script>document.querySelectorAll('tr[data-detail-id]').forEach(function(row){row.addEventListener('dblclick',function(){var p=row.dataset.detailId.split('|');fetch('/integrations/catalog/detail?source='+encodeURIComponent(p[0])+'&batch_id='+encodeURIComponent(p[1])).then(function(r){return r.text()}).then(function(html){document.getElementById('detail-content').innerHTML=html;document.getElementById('detail-modal').hidden=false})})})</script>
+9
View File
@@ -429,6 +429,15 @@ CREATE INDEX idx_syb_orders_list ON syb_orders(updated_at DESC, syb_id DESC);
仍存在于最新 `skus_json`。 仍存在于最新 `skus_json`。
- `shopee_sku_id` 只为兼容历史数据保留;顺运宝同步、弹窗、映射和建采购任务均不再读写它。 - `shopee_sku_id` 只为兼容历史数据保留;顺运宝同步、弹窗、映射和建采购任务均不再读写它。
MySQL schema v8 将 `shopee_skus.sku_id` 保留为系统内部记录主键(兼容历史引用),
新增可空且唯一的 `shopee_sku_id` 保存真实外部 ID,并用唯一
`(goods_id, spec_key)` 标识无真实 ID 的规格。既有行将真实 ID复制到新列;新记录没有
真实 ID 时只生成内部主键,页面和接口不得把内部值冒充真实 SKU ID。`source` 和
`source_observed_at` 用于限制同来源更新,`is_manual=1` 始终优先保护。
`field_sources` 与 `field_observed_at` 是只含颜色、尺码、建议和 SKU 编码的 JSON
来源表;`fill_missing` 补字段时分别登记,`overwrite_same_source` 只更新来源属于本次
调用方且观测时间不旧的字段,避免不同脚本各补一部分后互相覆盖。
蝦皮详情把 `syb_orders` 按 `(shopee_goods_id, spec_key)` 聚合为“顺运宝观测规格”: 蝦皮详情把 `syb_orders` 按 `(shopee_goods_id, spec_key)` 聚合为“顺运宝观测规格”:
订单数按唯一 `syb_id` 行计数,累计数量求和,最近一行提供历史台币售价和图片。 订单数按唯一 `syb_id` 行计数,累计数量求和,最近一行提供历史台币售价和图片。
该读模型不写入 `shopee_skus`。只有同一商品下恰好一条正式 SKU 的 `spec_raw` 该读模型不写入 `shopee_skus`。只有同一商品下恰好一条正式 SKU 的 `spec_raw`
+22 -3
View File
@@ -19,11 +19,12 @@ Token 通过 `CMAUTOBUY_CATALOG_TOKEN` 或未提交的 `config.yaml` 配置。
"schema_version": 1, "schema_version": 1,
"batch_id": "source-file-20260811-001", "batch_id": "source-file-20260811-001",
"observed_at": "2026-08-11T10:00:00+08:00", "observed_at": "2026-08-11T10:00:00+08:00",
"update_policy": "fill_missing",
"shopee_products": [ "shopee_products": [
{"goods_id": "S-1", "title": "蝦皮上衣", "status": "NORMAL", "main_sku_code": "A01"} {"goods_id": "S-1", "title": "蝦皮上衣", "status": "NORMAL", "main_sku_code": "A01"}
], ],
"shopee_skus": [ "shopee_skus": [
{"sku_id": "SKU-1", "goods_id": "S-1", "spec_raw": "黑色,M", "color": "黑色", "size": "M", "parse_ok": true, "sku_code": "A01-B-M"} {"sku_id": null, "goods_id": "S-1", "spec_raw": "黑色,M", "color": "黑色", "size": "M", "parse_ok": true, "sku_code": "A01-B-M"}
], ],
"pdd_products": [ "pdd_products": [
{ {
@@ -42,16 +43,25 @@ Token 通过 `CMAUTOBUY_CATALOG_TOKEN` 或未提交的 `config.yaml` 配置。
金额单位固定为人民币分,不接受小数金额。`spec_raw` 必须原样提交。PDD 规格必须用 金额单位固定为人民币分,不接受小数金额。`spec_raw` 必须原样提交。PDD 规格必须用
结构化 `dimensions` 和 `skus`,第三方不能提交数据库内部的 `skus_json`。 结构化 `dimensions` 和 `skus`,第三方不能提交数据库内部的 `skus_json`。
`sku_id` 是可选的真实蝦皮 SKU ID;来源文件没有该值时省略、传 `null` 或空字符串,
不得自行生成。系统用内部主键和 `goods_id + spec_key` 保持记录身份,后续取得真实 ID
会补到原记录,不会重复新增。
## 3. 写入与幂等规则 ## 3. 写入与幂等规则
- 整个批次在一个事务中写入:实体、SKU、关联任一步失败就全部回滚。 - 整个批次在一个事务中写入:实体、SKU、关联任一步失败就全部回滚。
- `(source, batch_id)` 是幂等键。请求字节相同则重放首次结果;同键不同内容返回 409。 - `(source, batch_id)` 是幂等键。请求字节相同则重放首次结果;同键不同内容返回 409。
- 较旧的 `observed_at` 不覆盖较新的接口数据。 - `update_policy` 可省略,默认 `fill_missing`:
- `insert_only`:只新增,已有规格不修改;
- `fill_missing`:只补颜色、尺码、建议、SKU 编码等空字段;
- `overwrite_same_source`:只有来源相同且 `observed_at` 更新时才覆盖来源字段。
- 较旧的 `observed_at` 不覆盖较新的接口数据,任何策略都不能覆盖 `is_manual=1`。
- `spec_raw` 和由它生成的规格身份不被更新;同商品同规格出现不同真实 SKU ID 返回 409。
- 蝦皮 upsert 不覆盖采购员维护的 PDD 链接;SKU 的 `is_manual` 不被接口改写。 - 蝦皮 upsert 不覆盖采购员维护的 PDD 链接;SKU 的 `is_manual` 不被接口改写。
- 空关联可以建立、相同关联不重复写;已有不同 PDD 关联返回 409,绝不静默替换。 - 空关联可以建立、相同关联不重复写;已有不同 PDD 关联返回 409,绝不静默替换。
- 批次缺少某条记录不表示删除,接口没有“全量覆盖”语义。 - 批次缺少某条记录不表示删除,接口没有“全量覆盖”语义。
成功响应包含批次状态和各类新增、更新、关联计数。相同请求重放时 成功响应包含批次状态以及 SKU 新增、补空、同来源更新、人工跳过、旧数据跳过等计数。相同请求重放时
`replayed=true`。 `replayed=true`。
## 4. 错误格式 ## 4. 错误格式
@@ -70,3 +80,12 @@ Token 通过 `CMAUTOBUY_CATALOG_TOKEN` 或未提交的 `config.yaml` 配置。
调用方应按 `code` 处理,不要解析中文 `message`。校验错误通常是 400/422,业务冲突 调用方应按 `code` 处理,不要解析中文 `message`。校验错误通常是 400/422,业务冲突
是 409,临时服务错误是 500/503。响应和导入记录都不会回显 Token 或完整请求体。 是 409,临时服务错误是 500/503。响应和导入记录都不会回显 Token 或完整请求体。
## 5. schema v8 发布与回退
发布前先备份生产库,并在 MySQL 8.4、库名以 `_test` 结尾的测试库演练 v7→v8。
迁移只增加列和唯一索引,既有 `sku_id` 继续作为内部主键,因此历史引用不变;若检测
到同商品重复 `spec_key`,迁移会停止且不记录 v8,必须人工确认,不能自动合并。
旧版 Admin 不理解无真实 SKU ID 的新记录。应用回退前应暂停目录接口并确认没有此类
记录;不要删除 v8 列或把内部主键复制成伪造的外部 SKU ID。