feat: 支持无SKU目录导入策略 (#141)
This commit is contained in:
@@ -57,7 +57,7 @@ func TestCatalogBatchAPI_按批次查询摘要(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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 {
|
||||
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"`) {
|
||||
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") {
|
||||
t.Fatal("查询响应不应暴露请求哈希")
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ func (h *Handler) GetBatch(c *gin.Context) {
|
||||
integrationError(c, 500, "INTERNAL_ERROR", "查询批次失败", true, nil)
|
||||
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) {
|
||||
|
||||
@@ -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) {
|
||||
router, err := newRouter(nil)
|
||||
if err != nil {
|
||||
|
||||
@@ -24,6 +24,12 @@ type CatalogImportRun struct {
|
||||
ShopeeUpdated int
|
||||
SKUCreated int
|
||||
SKUUpdated int
|
||||
UpdatePolicy string
|
||||
SKUFilled int
|
||||
SKUSameSourceUpdated int
|
||||
SKUSkipped int
|
||||
SKUManualSkipped int
|
||||
SKUStaleSkipped int
|
||||
PddCreated int
|
||||
PddUpdated int
|
||||
AssociationCreated int
|
||||
|
||||
@@ -181,7 +181,8 @@ type ShopeeProduct struct {
|
||||
// **永远原样保留**。Color/Size/Advice 是尽力解析的结果,
|
||||
// 解析失败时留空并把 ParseOK 置 false,不要瞎猜。
|
||||
type ShopeeSKU struct {
|
||||
SKUID string
|
||||
RecordID string // 系统内部记录主键
|
||||
SKUID string // 可空的真实蝦皮 SKU ID,不得用内部主键冒充
|
||||
GoodsID string
|
||||
SpecRaw string
|
||||
Color string
|
||||
|
||||
@@ -2,6 +2,7 @@ package repository
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
@@ -21,9 +22,9 @@ var (
|
||||
func InsertCatalogImportRun(q Execer, run model.CatalogImportRun) error {
|
||||
_, err := q.Exec(`INSERT INTO catalog_import_runs (
|
||||
source,batch_id,request_hash,status,request_count,conflict_count,observed_at,
|
||||
last_request_at,created_at
|
||||
) VALUES (?,?,?,?,1,0,NULLIF(?,''),?,?)`, run.Source, run.BatchID, run.RequestHash,
|
||||
run.Status, run.ObservedAt, run.LastRequestAt, run.CreatedAt)
|
||||
update_policy,last_request_at,created_at
|
||||
) VALUES (?,?,?,?,1,0,NULLIF(?,''),?,?,?)`, run.Source, run.BatchID, run.RequestHash,
|
||||
run.Status, run.ObservedAt, run.UpdatePolicy, run.LastRequestAt, run.CreatedAt)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -42,13 +43,15 @@ func GetCatalogImportRun(q Execer, source, batchID string) (model.CatalogImportR
|
||||
var run model.CatalogImportRun
|
||||
var observedAt, lastConflictAt, errorSummary, responseBody, finishedAt sql.NullString
|
||||
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,
|
||||
sku_updated,pdd_created,pdd_updated,association_created,association_unchanged,failure_count,
|
||||
observed_at,update_policy,last_request_at,last_conflict_at,shopee_created,shopee_updated,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,response_body,created_at,finished_at
|
||||
FROM catalog_import_runs WHERE source=? AND batch_id=?`, source, batchID).Scan(
|
||||
&run.Source, &run.BatchID, &run.RequestHash, &run.Status, &run.RequestCount, &run.ConflictCount,
|
||||
&observedAt, &run.LastRequestAt, &lastConflictAt, &run.ShopeeCreated, &run.ShopeeUpdated,
|
||||
&run.SKUCreated, &run.SKUUpdated, &run.PddCreated, &run.PddUpdated, &run.AssociationCreated,
|
||||
&observedAt, &run.UpdatePolicy, &run.LastRequestAt, &lastConflictAt, &run.ShopeeCreated, &run.ShopeeUpdated,
|
||||
&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)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return model.CatalogImportRun{}, ErrCatalogImportRunNotFound
|
||||
@@ -147,10 +150,12 @@ func RecordCatalogImportConflict(q Execer, source, batchID, requestedAt string)
|
||||
// CompleteCatalogImportRun 把处理结果摘要和安全的响应 JSON 固化,供幂等重放。
|
||||
func CompleteCatalogImportRun(q Execer, run model.CatalogImportRun) error {
|
||||
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(?,''),
|
||||
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.FinishedAt, run.Source, run.BatchID)
|
||||
return catalogRunUpdateResult(result, err, "完成商品目录批次")
|
||||
@@ -177,34 +182,194 @@ func UpsertCatalogShopeeProduct(q Execer, goodsID, title, status, mainSKU, obser
|
||||
return false, err == nil, err
|
||||
}
|
||||
|
||||
// UpsertCatalogShopeeSKU 拒绝把同一 SKU 静默挪到另一商品,并保留 is_manual。
|
||||
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
|
||||
var oldObserved sql.NullString
|
||||
err = q.QueryRow(`SELECT goods_id,source_observed_at FROM shopee_skus WHERE sku_id=?`, skuID).Scan(&oldGoods, &oldObserved)
|
||||
parse := 0
|
||||
if parseOK {
|
||||
parse = 1
|
||||
}
|
||||
type CatalogSKUOutcome string
|
||||
|
||||
const (
|
||||
CatalogSKUCreated CatalogSKUOutcome = "created"
|
||||
CatalogSKUFilled CatalogSKUOutcome = "filled"
|
||||
CatalogSKUSameSourceUpdated CatalogSKUOutcome = "same_source_updated"
|
||||
CatalogSKUSkipped CatalogSKUOutcome = "skipped"
|
||||
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) {
|
||||
_, err = q.Exec(`INSERT INTO shopee_skus(sku_id,goods_id,spec_raw,color,size,advice,parse_ok,sku_code,
|
||||
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
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
return nil, err
|
||||
}
|
||||
if oldGoods != goodsID {
|
||||
return false, false, fmt.Errorf("SKU %s 已属于蝦皮商品 %s", skuID, oldGoods)
|
||||
return &row, 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))
|
||||
}
|
||||
|
||||
func findCatalogSKUByExternalID(q Execer, skuID 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 shopee_sku_id=?`, skuID))
|
||||
}
|
||||
|
||||
// UpsertCatalogShopeeSKU 用真实 ID 或商品规格身份定位同一内部记录,人工行永不覆盖。
|
||||
func UpsertCatalogShopeeSKU(q Execer, in CatalogShopeeSKUInput) (CatalogSKUOutcome, error) {
|
||||
bySpec, err := findCatalogSKUBySpec(q, in.GoodsID, in.SpecKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if oldObserved.Valid && oldObserved.String > observedAt {
|
||||
return false, false, nil
|
||||
var byID *catalogSKUStored
|
||||
if in.ShopeeSKUID != "" {
|
||||
byID, err = findCatalogSKUByExternalID(q, in.ShopeeSKUID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
_, 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=?`,
|
||||
specRaw, color, size, advice, parse, skuCode, observedAt, now, skuID)
|
||||
return false, err == nil, 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), ×)
|
||||
return sources, times
|
||||
}
|
||||
|
||||
// UpsertCatalogPddProduct 写入结构化 PDD 数据转换后的规范 JSON;空规格不清除既有采集结果。
|
||||
|
||||
@@ -19,9 +19,9 @@ func TestCatalogImportRun_登记查询重放和冲突(t *testing.T) {
|
||||
defer db.Close()
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
"cmautobuy/admin/spec"
|
||||
)
|
||||
|
||||
const mysqlSchemaVersion = 7
|
||||
const mysqlSchemaVersion = 8
|
||||
|
||||
// OpenMySQL 打开生产 MySQL 8 数据库。错误信息绝不包含完整 DSN 或密码。
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
// 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 (
|
||||
source VARCHAR(64) 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 {
|
||||
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 {
|
||||
|
||||
@@ -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 {
|
||||
t.Helper()
|
||||
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')`)
|
||||
}
|
||||
|
||||
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) {
|
||||
t.Helper()
|
||||
if _, err := db.Exec(query, args...); err != nil {
|
||||
|
||||
+33
-18
@@ -2,11 +2,13 @@ package repository
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"cmautobuy/admin/model"
|
||||
"cmautobuy/admin/spec"
|
||||
)
|
||||
|
||||
// UpsertShopeeProduct 写入或更新一条蝦皮商品汇总行。
|
||||
@@ -57,22 +59,35 @@ func UpsertShopeeSKU(q Execer, skuID, goodsID, specRaw, color, size, advice stri
|
||||
parseOKInt = 1
|
||||
}
|
||||
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
|
||||
(sku_id, goods_id, spec_raw, color, size, advice, parse_ok, sku_code,
|
||||
is_manual, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)
|
||||
(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, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 'report', ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
goods_id = VALUES(goods_id),
|
||||
spec_raw = VALUES(spec_raw),
|
||||
color = VALUES(color),
|
||||
size = VALUES(size),
|
||||
advice = VALUES(advice),
|
||||
parse_ok = VALUES(parse_ok),
|
||||
sku_code = VALUES(sku_code),
|
||||
updated_at = VALUES(updated_at)`,
|
||||
// is_manual 不在上面的 SET 列表里,SQLite 对没提到的列保持原值不变。
|
||||
skuID, goodsID, specRaw, color, size, advice, parseOKInt, skuCode, now, now)
|
||||
goods_id = CASE WHEN is_manual=1 THEN goods_id ELSE VALUES(goods_id) END,
|
||||
color = CASE WHEN is_manual=1 THEN color ELSE VALUES(color) END,
|
||||
size = CASE WHEN is_manual=1 THEN size ELSE VALUES(size) END,
|
||||
advice = CASE WHEN is_manual=1 THEN advice ELSE VALUES(advice) END,
|
||||
parse_ok = CASE WHEN is_manual=1 THEN parse_ok ELSE VALUES(parse_ok) END,
|
||||
sku_code = CASE WHEN is_manual=1 THEN sku_code ELSE VALUES(sku_code) END,
|
||||
field_sources = CASE WHEN is_manual=1 THEN field_sources ELSE VALUES(field_sources) END,
|
||||
field_observed_at = CASE WHEN is_manual=1 THEN field_observed_at ELSE VALUES(field_observed_at) END,
|
||||
updated_at = CASE WHEN is_manual=1 THEN updated_at ELSE VALUES(updated_at) END`,
|
||||
// 人工行的业务字段和更新时间均由 CASE 保留。
|
||||
skuID, skuID, goodsID, specRaw, specKey, color, size, advice, parseOKInt, skuCode, string(fieldSourcesJSON), string(fieldTimesJSON), now, now)
|
||||
if err != nil {
|
||||
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 原文」。
|
||||
func ListShopeeSKUsByGoodsID(q Execer, goodsID string) ([]model.ShopeeSKU, error) {
|
||||
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
|
||||
FROM shopee_skus
|
||||
WHERE goods_id = ?
|
||||
@@ -309,7 +324,7 @@ func ListShopeeSKUsByGoodsID(q Execer, goodsID string) ([]model.ShopeeSKU, error
|
||||
var color, size, advice, skuCode sql.NullString
|
||||
var parseOK, isManual int
|
||||
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,
|
||||
); err != nil {
|
||||
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 parseOK, isManual int
|
||||
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
|
||||
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,
|
||||
)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
|
||||
@@ -5,11 +5,14 @@ import (
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cmautobuy/admin/spec"
|
||||
)
|
||||
|
||||
// SQLiteMigrationSummary 只包含表名和数量,可以安全写入终端或工单。
|
||||
@@ -117,6 +120,9 @@ func MigrateSQLiteToMySQL(source, target *sql.DB, dryRun bool) (*SQLiteMigration
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := normalizeMigratedShopeeSKUs(tx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, &SQLiteMigrationError{Stage: "无法提交 MySQL 导入事务", Cause: err}
|
||||
}
|
||||
@@ -126,6 +132,52 @@ func MigrateSQLiteToMySQL(source, target *sql.DB, dryRun bool) (*SQLiteMigration
|
||||
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 比较逐表数量和全部迁移列的确定性摘要,不输出业务内容。
|
||||
func VerifySQLiteToMySQL(source, target *sql.DB) (*SQLiteMigrationSummary, error) {
|
||||
if err := validateLegacySQLite(source); err != nil {
|
||||
|
||||
@@ -11,9 +11,11 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"cmautobuy/admin/model"
|
||||
"cmautobuy/admin/repository"
|
||||
"cmautobuy/admin/spec"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -63,6 +65,7 @@ type CatalogBatchRequest struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
BatchID string `json:"batch_id"`
|
||||
ObservedAt string `json:"observed_at"`
|
||||
UpdatePolicy string `json:"update_policy"`
|
||||
ShopeeProducts []CatalogShopeeProduct `json:"shopee_products"`
|
||||
ShopeeSKUs []CatalogShopeeSKU `json:"shopee_skus"`
|
||||
PddProducts []CatalogPddProduct `json:"pdd_products"`
|
||||
@@ -73,6 +76,11 @@ type CatalogCounts struct {
|
||||
ShopeeUpdated int `json:"shopee_updated"`
|
||||
SKUCreated int `json:"sku_created"`
|
||||
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"`
|
||||
PddUpdated int `json:"pdd_updated"`
|
||||
AssociationCreated int `json:"association_created"`
|
||||
@@ -108,13 +116,16 @@ func ValidateCatalogBatch(req CatalogBatchRequest) error {
|
||||
if _, err := time.Parse(time.RFC3339Nano, req.ObservedAt); err != 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 {
|
||||
return catalogInvalid("TOO_MANY_PRODUCTS", "每批商品总数不能超过 500", nil)
|
||||
}
|
||||
if len(req.ShopeeSKUs) > CatalogMaxSKUs {
|
||||
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 {
|
||||
p.GoodsID = strings.TrimSpace(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
|
||||
}
|
||||
for i, s := range req.ShopeeSKUs {
|
||||
if strings.TrimSpace(s.SKUID) == "" || strings.TrimSpace(s.GoodsID) == "" || strings.TrimSpace(s.SpecRaw) == "" || seenSKUs[s.SKUID] {
|
||||
return catalogInvalid("INVALID_SHOPEE_SKU", "蝦皮 SKU ID、商品 ID、spec_raw 必填且 SKU 不能重复", map[string]any{"index": i, "sku_id": s.SKUID})
|
||||
key, keyErr := spec.SpecKey(s.SpecRaw)
|
||||
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 {
|
||||
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)
|
||||
req.ObservedAt = observed.UTC().Format(model.TimeLayout)
|
||||
policy, _ := normalizeCatalogUpdatePolicy(req.UpdatePolicy)
|
||||
req.UpdatePolicy = policy
|
||||
h := sha256.Sum256(raw)
|
||||
hash := hex.EncodeToString(h[:])
|
||||
now := model.NowISO()
|
||||
@@ -160,7 +179,7 @@ func ImportCatalogBatch(db *sql.DB, source string, req CatalogBatchRequest, raw
|
||||
if err != nil {
|
||||
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 {
|
||||
tx.Rollback()
|
||||
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 {
|
||||
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 {
|
||||
return fail(&CatalogError{Status: 409, Code: "SKU_OWNERSHIP_CONFLICT", Message: e.Error()})
|
||||
}
|
||||
if c {
|
||||
switch outcome {
|
||||
case repository.CatalogSKUCreated:
|
||||
counts.SKUCreated++
|
||||
}
|
||||
if u {
|
||||
case repository.CatalogSKUFilled:
|
||||
counts.SKUFilled++
|
||||
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 {
|
||||
@@ -247,6 +280,11 @@ func ImportCatalogBatch(db *sql.DB, source string, req CatalogBatchRequest, raw
|
||||
run.ShopeeUpdated = counts.ShopeeUpdated
|
||||
run.SKUCreated = counts.SKUCreated
|
||||
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.PddUpdated = counts.PddUpdated
|
||||
run.AssociationCreated = counts.AssociationCreated
|
||||
@@ -260,6 +298,22 @@ func ImportCatalogBatch(db *sql.DB, source string, req CatalogBatchRequest, raw
|
||||
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) {
|
||||
run, err := repository.GetCatalogImportRun(db, source, batchID)
|
||||
if err != nil {
|
||||
|
||||
@@ -20,9 +20,9 @@ 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,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 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 {
|
||||
if _, err := db.Exec(statement); err != nil {
|
||||
@@ -110,4 +110,153 @@ func TestValidateCatalogBatch_拒绝旧版超限和负金额(t *testing.T) {
|
||||
if err := ValidateCatalogBatch(req); err == nil {
|
||||
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 @@
|
||||
{{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}}
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
<button type="submit">筛选</button>
|
||||
</form>
|
||||
</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>
|
||||
{{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>
|
||||
<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>{{.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>
|
||||
<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>
|
||||
|
||||
@@ -429,6 +429,15 @@ CREATE INDEX idx_syb_orders_list ON syb_orders(updated_at DESC, syb_id DESC);
|
||||
仍存在于最新 `skus_json`。
|
||||
- `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_id` 行计数,累计数量求和,最近一行提供历史台币售价和图片。
|
||||
该读模型不写入 `shopee_skus`。只有同一商品下恰好一条正式 SKU 的 `spec_raw`
|
||||
|
||||
@@ -19,11 +19,12 @@ Token 通过 `CMAUTOBUY_CATALOG_TOKEN` 或未提交的 `config.yaml` 配置。
|
||||
"schema_version": 1,
|
||||
"batch_id": "source-file-20260811-001",
|
||||
"observed_at": "2026-08-11T10:00:00+08:00",
|
||||
"update_policy": "fill_missing",
|
||||
"shopee_products": [
|
||||
{"goods_id": "S-1", "title": "蝦皮上衣", "status": "NORMAL", "main_sku_code": "A01"}
|
||||
],
|
||||
"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": [
|
||||
{
|
||||
@@ -42,16 +43,25 @@ Token 通过 `CMAUTOBUY_CATALOG_TOKEN` 或未提交的 `config.yaml` 配置。
|
||||
金额单位固定为人民币分,不接受小数金额。`spec_raw` 必须原样提交。PDD 规格必须用
|
||||
结构化 `dimensions` 和 `skus`,第三方不能提交数据库内部的 `skus_json`。
|
||||
|
||||
`sku_id` 是可选的真实蝦皮 SKU ID;来源文件没有该值时省略、传 `null` 或空字符串,
|
||||
不得自行生成。系统用内部主键和 `goods_id + spec_key` 保持记录身份,后续取得真实 ID
|
||||
会补到原记录,不会重复新增。
|
||||
|
||||
## 3. 写入与幂等规则
|
||||
|
||||
- 整个批次在一个事务中写入:实体、SKU、关联任一步失败就全部回滚。
|
||||
- `(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` 不被接口改写。
|
||||
- 空关联可以建立、相同关联不重复写;已有不同 PDD 关联返回 409,绝不静默替换。
|
||||
- 批次缺少某条记录不表示删除,接口没有“全量覆盖”语义。
|
||||
|
||||
成功响应包含批次状态和各类新增、更新、关联计数。相同请求重放时
|
||||
成功响应包含批次状态以及 SKU 新增、补空、同来源更新、人工跳过、旧数据跳过等计数。相同请求重放时
|
||||
`replayed=true`。
|
||||
|
||||
## 4. 错误格式
|
||||
@@ -70,3 +80,12 @@ Token 通过 `CMAUTOBUY_CATALOG_TOKEN` 或未提交的 `config.yaml` 配置。
|
||||
|
||||
调用方应按 `code` 处理,不要解析中文 `message`。校验错误通常是 400/422,业务冲突
|
||||
是 409,临时服务错误是 500/503。响应和导入记录都不会回显 Token 或完整请求体。
|
||||
|
||||
## 5. schema v8 发布与回退
|
||||
|
||||
发布前先备份生产库,并在 MySQL 8.4、库名以 `_test` 结尾的测试库演练 v7→v8。
|
||||
迁移只增加列和唯一索引,既有 `sku_id` 继续作为内部主键,因此历史引用不变;若检测
|
||||
到同商品重复 `spec_key`,迁移会停止且不记录 v8,必须人工确认,不能自动合并。
|
||||
|
||||
旧版 Admin 不理解无真实 SKU ID 的新记录。应用回退前应暂停目录接口并确认没有此类
|
||||
记录;不要删除 v8 列或把内部主键复制成伪造的外部 SKU ID。
|
||||
|
||||
Reference in New Issue
Block a user