fix: 恢复并发 SKU 规格唯一键冲突 (#283)

This commit is contained in:
chengma
2026-08-21 09:16:22 +08:00
parent 8652f36b2a
commit 05b04e0349
2 changed files with 111 additions and 2 deletions
+28 -2
View File
@@ -336,10 +336,23 @@ func findCatalogSKUBySpec(q Execer, goodsID, specKey string) (*catalogSKUStored,
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 findCatalogSKUBySpecForUpdate(q Execer, goodsID, specKey string) (*catalogSKUStored, error) {
// 规格唯一键冲突后必须使用当前读。普通 SELECT 在 MySQL 默认的
// REPEATABLE READ 事务中会继续使用第一次查询的快照,看不到竞争方
// 刚刚提交的 SKU,导致无法恢复本来可幂等的并发写入。
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=? FOR UPDATE`, 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))
}
func isShopeeSKUSpecUniqueConflict(err error) bool {
var mysqlErr *mysql.MySQLError
return errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 &&
strings.Contains(mysqlErr.Message, "uq_shopee_skus_spec")
}
// UpsertCatalogShopeeSKU 用真实 ID 或商品规格身份定位同一内部记录,人工行永不覆盖。
func UpsertCatalogShopeeSKU(q Execer, in CatalogShopeeSKUInput) (CatalogSKUOutcome, error) {
bySpec, err := findCatalogSKUBySpec(q, in.GoodsID, in.SpecKey)
@@ -372,10 +385,23 @@ func UpsertCatalogShopeeSKU(q Execer, in CatalogShopeeSKUInput) (CatalogSKUOutco
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 {
if err == nil {
return CatalogSKUCreated, nil
}
if !isShopeeSKUSpecUniqueConflict(err) {
return "", err
}
return CatalogSKUCreated, nil
// 目录导入和 SYB 同步可以同时写同一个规格。唯一键已经证明竞争方
// 已提交该业务身份;重新读取后沿用下面原有的来源优先级规则,不能
// 把它当成同步失败,也不能用 INSERT IGNORE 静默跳过字段补全。
current, err = findCatalogSKUBySpecForUpdate(q, in.GoodsID, in.SpecKey)
if err != nil {
return "", fmt.Errorf("规格唯一键冲突后重读商品 %s 的 SKU 失败: %w", in.GoodsID, err)
}
if current == nil {
return "", fmt.Errorf("规格唯一键冲突后未找到商品 %s 的规格 %s", in.GoodsID, in.SpecKey)
}
}
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)