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

This commit is contained in:
chengma
2026-08-13 10:39:21 +08:00
parent 3af9bf245a
commit ad1ccc2c60
23 changed files with 864 additions and 90 deletions
+171 -14
View File
@@ -5,7 +5,9 @@
package repository
import (
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"strings"
@@ -351,19 +353,7 @@ func UpsertSybOrder(q Execer, o model.SybOrder) (created bool, err error) {
now := model.NowISO()
if strings.TrimSpace(o.ShopeeGoodsID) != "" {
if _, err := q.Exec(`
INSERT INTO shopee_products (goods_id, title, source, created_at, updated_at)
VALUES (?, ?, 'syb', ?, ?)
ON DUPLICATE KEY UPDATE
title = CASE
WHEN source = 'syb' AND TRIM(VALUES(title)) <> '' THEN VALUES(title)
ELSE title
END,
updated_at = CASE
WHEN source = 'syb' AND TRIM(VALUES(title)) <> '' THEN VALUES(updated_at)
ELSE updated_at
END`,
o.ShopeeGoodsID, o.Title, now, now); err != nil {
if err := upsertSybShopeeProduct(q, o.ShopeeGoodsID, o.Title, o.ShopName, o.ImageURL, now); err != nil {
return false, fmt.Errorf("为顺运宝明细 %s 补建蝦皮商品骨架失败: %w", o.SybID, err)
}
}
@@ -393,6 +383,173 @@ func UpsertSybOrder(q Execer, o model.SybOrder) (created bool, err error) {
return created, nil
}
// upsertSybShopeeProduct 用顺运宝观测补建蝦皮商品骨架。
//
// 店铺和图片是字段级低优先级数据:只能补空值,或更新原本同样来自 syb 的值;
// 人工字段和商品目录等权威来源永远不被顺运宝覆盖。空值也不能清除已有内容。
func upsertSybShopeeProduct(q Execer, goodsID, title, shopName, imageURL, observedAt string) error {
_, err := q.Exec(`
INSERT INTO shopee_products
(goods_id,title,image_url,shopee_shop_name,
image_source,image_observed_at,image_is_manual,
shop_name_source,shop_name_observed_at,shop_name_is_manual,
source,source_observed_at,created_at,updated_at)
VALUES (?, ?, NULLIF(TRIM(?),''), NULLIF(TRIM(?),''),
CASE WHEN TRIM(?)='' THEN NULL ELSE 'syb' END,
CASE WHEN TRIM(?)='' THEN NULL ELSE ? END, 0,
CASE WHEN TRIM(?)='' THEN NULL ELSE 'syb' END,
CASE WHEN TRIM(?)='' THEN NULL ELSE ? END, 0,
'syb', ?, ?, ?)
ON DUPLICATE KEY UPDATE
title = CASE
WHEN source='syb' AND TRIM(VALUES(title))<>'' THEN VALUES(title)
ELSE title
END,
source_observed_at = CASE
WHEN source='syb' AND TRIM(VALUES(title))<>'' THEN VALUES(source_observed_at)
ELSE source_observed_at
END,
image_url = CASE
WHEN image_is_manual=0 AND TRIM(VALUES(image_url))<>''
AND (image_url IS NULL OR TRIM(image_url)='' OR image_source='syb') THEN VALUES(image_url)
ELSE image_url
END,
image_observed_at = CASE
WHEN image_is_manual=0 AND TRIM(VALUES(image_url))<>''
AND (image_url IS NULL OR TRIM(image_url)='' OR image_source='syb') THEN VALUES(image_observed_at)
ELSE image_observed_at
END,
image_source = CASE
WHEN image_is_manual=0 AND TRIM(VALUES(image_url))<>''
AND (image_url IS NULL OR TRIM(image_url)='' OR image_source='syb') THEN 'syb'
ELSE image_source
END,
shopee_shop_name = CASE
WHEN shop_name_is_manual=0 AND TRIM(VALUES(shopee_shop_name))<>''
AND (shopee_shop_name IS NULL OR TRIM(shopee_shop_name)='' OR shop_name_source='syb') THEN VALUES(shopee_shop_name)
ELSE shopee_shop_name
END,
shop_name_observed_at = CASE
WHEN shop_name_is_manual=0 AND TRIM(VALUES(shopee_shop_name))<>''
AND (shopee_shop_name IS NULL OR TRIM(shopee_shop_name)='' OR shop_name_source='syb') THEN VALUES(shop_name_observed_at)
ELSE shop_name_observed_at
END,
shop_name_source = CASE
WHEN shop_name_is_manual=0 AND TRIM(VALUES(shopee_shop_name))<>''
AND (shopee_shop_name IS NULL OR TRIM(shopee_shop_name)='' OR shop_name_source='syb') THEN 'syb'
ELSE shop_name_source
END,
updated_at = CASE
WHEN source='syb'
OR (image_source='syb' AND TRIM(VALUES(image_url))<>'')
OR (shop_name_source='syb' AND TRIM(VALUES(shopee_shop_name))<>'') THEN VALUES(updated_at)
ELSE updated_at
END`,
goodsID, title, imageURL, shopName,
imageURL, imageURL, observedAt,
shopName, shopName, observedAt,
observedAt, observedAt, observedAt)
return err
}
// backfillSybProductMetadata 把历史货运单中每个商品最新的非空店铺、图片补入商品主表。
// 重放是安全的:实际覆盖规则仍由 upsertSybShopeeProduct 统一执行。
func backfillSybProductMetadata(db *sql.DB) error {
type metadata struct{ shopName, imageURL, observedAt string }
items := map[string]metadata{}
readLatest := func(column string, assign func(*metadata, string)) error {
query := fmt.Sprintf(`SELECT shopee_goods_id, value_text, updated_at FROM (
SELECT shopee_goods_id, %s AS value_text, updated_at,
ROW_NUMBER() OVER (PARTITION BY shopee_goods_id ORDER BY updated_at DESC, syb_id DESC) AS row_no
FROM syb_orders
WHERE TRIM(COALESCE(shopee_goods_id,''))<>'' AND TRIM(COALESCE(%s,''))<>''
) ranked WHERE row_no=1`, column, column)
rows, err := db.Query(query)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var goodsID, value, observedAt string
if err := rows.Scan(&goodsID, &value, &observedAt); err != nil {
return err
}
item := items[goodsID]
assign(&item, value)
if observedAt > item.observedAt {
item.observedAt = observedAt
}
items[goodsID] = item
}
return rows.Err()
}
if err := readLatest("shop_name", func(item *metadata, value string) { item.shopName = value }); err != nil {
return fmt.Errorf("读取历史顺运宝店铺失败: %w", err)
}
if err := readLatest("image_url", func(item *metadata, value string) { item.imageURL = value }); err != nil {
return fmt.Errorf("读取历史顺运宝图片失败: %w", err)
}
for goodsID, item := range items {
if err := upsertSybShopeeProduct(db, goodsID, "", item.shopName, item.imageURL, item.observedAt); err != nil {
return fmt.Errorf("回填蝦皮商品 %s 的顺运宝元数据失败: %w", goodsID, err)
}
}
return nil
}
// UpsertSybShopeeSKU 把格式明确的 SYB 规格作为低优先级蝦皮 SKU 观测写入。
// 返回 parsed=false 时不写库,避免把猜测结果污染商品主数据。
func UpsertSybShopeeSKU(q Execer, goodsID, specRaw, observedAt string) (parsed bool, err error) {
parsedSpec, ok := spec.ParseShopeeSpec(specRaw)
if !ok || strings.TrimSpace(goodsID) == "" {
return false, nil
}
specKey, err := spec.SpecKey(specRaw)
if err != nil {
return false, nil
}
sum := sha256.Sum256([]byte(goodsID + "\x00" + specKey))
recordID := "syb:" + hex.EncodeToString(sum[:])
now := model.NowISO()
_, err = UpsertCatalogShopeeSKU(q, CatalogShopeeSKUInput{
RecordID: recordID, GoodsID: goodsID, SpecRaw: specRaw, SpecKey: specKey,
Color: parsedSpec.Color, Size: parsedSpec.Size, Advice: parsedSpec.Advice,
ParseOK: true, Source: "syb", ObservedAt: observedAt, Now: now, UpdatePolicy: "fill_missing",
})
return true, err
}
func backfillSybShopeeSKUs(db *sql.DB) error {
rows, err := db.Query(`SELECT shopee_goods_id,product_spec,updated_at FROM (
SELECT shopee_goods_id,product_spec,updated_at,
ROW_NUMBER() OVER (PARTITION BY shopee_goods_id,product_spec ORDER BY updated_at DESC,syb_id DESC) row_no
FROM syb_orders
WHERE TRIM(COALESCE(shopee_goods_id,''))<>'' AND TRIM(COALESCE(product_spec,''))<>''
) ranked WHERE row_no=1`)
if err != nil {
return fmt.Errorf("读取历史顺运宝规格失败: %w", err)
}
defer rows.Close()
type observation struct{ goodsID, raw, observedAt string }
var observations []observation
for rows.Next() {
var item observation
if err := rows.Scan(&item.goodsID, &item.raw, &item.observedAt); err != nil {
return err
}
observations = append(observations, item)
}
if err := rows.Err(); err != nil {
return err
}
for _, item := range observations {
if _, err := UpsertSybShopeeSKU(db, item.goodsID, item.raw, item.observedAt); err != nil {
return fmt.Errorf("回填蝦皮商品 %s 的顺运宝规格失败: %w", item.goodsID, err)
}
}
return nil
}
// SybSpecObservation 是顺运宝对某个蝦皮商品规格的历史观测汇总。
// 它不是正式蝦皮 SKU,不得写回 shopee_skus。
type SybSpecObservation struct {
@@ -482,7 +639,7 @@ func sybOrderFilterClause(filter SybOrderFilter) (string, []any) {
const sybOrderContextFrom = `
FROM syb_orders so
LEFT JOIN shopee_products sp ON sp.goods_id = so.shopee_goods_id
LEFT JOIN shopee_products sp ON sp.goods_id = so.shopee_goods_id AND sp.deleted_at IS NULL
LEFT JOIN pdd_products pp ON pp.goods_id = sp.pdd_goods_id AND pp.deleted_at IS NULL
LEFT JOIN spec_mappings sm
ON sm.shopee_goods_id = so.shopee_goods_id