Files
cmautobuy/admin/service/spec_backfill.go
T

281 lines
9.2 KiB
Go

package service
import (
"database/sql"
"encoding/json"
"fmt"
"sort"
"strings"
"time"
"cmautobuy/admin/repository"
)
const (
SpecBackfillCandidate = "candidate"
SpecBackfillUnlinked = "unlinked"
SpecBackfillPDDMissing = "pdd_missing_or_deleted"
SpecBackfillShopeeMissing = "shopee_formal_specs_unavailable"
SpecBackfillPDDExisting = "pdd_specs_already_exist"
SpecBackfillPDDBackfilled = "pdd_specs_backfilled"
SpecBackfillPDDInvalid = "pdd_json_invalid"
SpecBackfillAmbiguousShared = "ambiguous_shared_pdd"
)
// SpecBackfillDecision 是报告中的商品级判定,不暴露规格原文。
type SpecBackfillDecision struct {
ShopeeGoodsID string `json:"shopee_goods_id"`
PDDGoodsID string `json:"pdd_goods_id,omitempty"`
Status string `json:"status"`
SpecCount int `json:"spec_count"`
}
// SpecBackfillCandidateItem 是一个待更新的 PDD 商品。新旧 JSON 只供写库和本机备份。
type SpecBackfillCandidateItem struct {
PDDGoodsID string `json:"pdd_goods_id"`
ShopeeGoodsIDs []string `json:"shopee_goods_ids"`
SpecCount int `json:"spec_count"`
OriginalSKUsJSON string `json:"-"`
NewSKUsJSON string `json:"-"`
}
// SpecBackfillPlan 是默认 dry-run 输出,也可经 ApplySpecBackfill 执行。
type SpecBackfillPlan struct {
Shop string `json:"shop"`
GeneratedAt string `json:"generated_at"`
SourceProductCount int `json:"source_product_count"`
CandidatePDDCount int `json:"candidate_pdd_count"`
BackfilledPDDCount int `json:"backfilled_pdd_count"`
Summary map[string]int `json:"summary"`
Products []SpecBackfillDecision `json:"products"`
Candidates []SpecBackfillCandidateItem `json:"candidates"`
}
type specBackfillGroupedSource struct {
source repository.ShopSpecSource
set map[string]struct{}
}
// BuildSpecBackfillPlan 只在内存中生成安全候选,不写数据库。
func BuildSpecBackfillPlan(shop string, sources []repository.ShopSpecSource, now time.Time) SpecBackfillPlan {
plan := SpecBackfillPlan{
Shop: strings.TrimSpace(shop), GeneratedAt: now.UTC().Format(time.RFC3339),
SourceProductCount: len(sources), Summary: map[string]int{},
}
groups := make(map[string][]specBackfillGroupedSource)
for _, source := range sources {
set := formalShopeeSpecSet(source.ShopeeSpecs)
switch {
case strings.TrimSpace(source.PDDGoodsID) == "":
plan.addDecision(source, SpecBackfillUnlinked, len(set))
case !source.PDDExists || source.PDDDeleted:
plan.addDecision(source, SpecBackfillPDDMissing, len(set))
default:
groups[source.PDDGoodsID] = append(groups[source.PDDGoodsID], specBackfillGroupedSource{source: source, set: set})
}
}
for _, pddGoodsID := range sortedGroupKeys(groups) {
group := groups[pddGoodsID]
if groupHasDifferentSets(group) {
for _, item := range group {
plan.addDecision(item.source, SpecBackfillAmbiguousShared, len(item.set))
}
continue
}
set := group[0].set
if len(set) == 0 {
for _, item := range group {
plan.addDecision(item.source, SpecBackfillShopeeMissing, 0)
}
continue
}
newRaw, eligibility, err := makePDDSpecSkeleton(group[0].source.PDDSKUsJSON, pddGoodsID, set, now)
if err != nil {
eligibility = SpecBackfillPDDInvalid
}
if eligibility != SpecBackfillCandidate {
for _, item := range group {
plan.addDecision(item.source, eligibility, len(set))
}
if eligibility == SpecBackfillPDDBackfilled {
plan.BackfilledPDDCount++
}
continue
}
candidate := SpecBackfillCandidateItem{
PDDGoodsID: pddGoodsID, SpecCount: len(set),
OriginalSKUsJSON: group[0].source.PDDSKUsJSON, NewSKUsJSON: newRaw,
}
for _, item := range group {
candidate.ShopeeGoodsIDs = append(candidate.ShopeeGoodsIDs, item.source.ShopeeGoodsID)
plan.addDecision(item.source, SpecBackfillCandidate, len(set))
}
plan.Candidates = append(plan.Candidates, candidate)
}
sort.Slice(plan.Products, func(i, j int) bool { return plan.Products[i].ShopeeGoodsID < plan.Products[j].ShopeeGoodsID })
plan.CandidatePDDCount = len(plan.Candidates)
return plan
}
func (p *SpecBackfillPlan) addDecision(source repository.ShopSpecSource, status string, count int) {
p.Products = append(p.Products, SpecBackfillDecision{
ShopeeGoodsID: source.ShopeeGoodsID, PDDGoodsID: source.PDDGoodsID,
Status: status, SpecCount: count,
})
p.Summary[status]++
}
func sortedGroupKeys[T any](groups map[string]T) []string {
keys := make([]string, 0, len(groups))
for key := range groups {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
func groupHasDifferentSets(group []specBackfillGroupedSource) bool {
base := group[0].set
for _, item := range group[1:] {
if !sameStringSet(base, item.set) {
return true
}
}
return false
}
func makePDDSpecSkeleton(raw, pddGoodsID string, set map[string]struct{}, now time.Time) (string, string, error) {
root := make(map[string]any)
if strings.TrimSpace(raw) != "" {
if err := json.Unmarshal([]byte(raw), &root); err != nil || root == nil {
return "", SpecBackfillPDDInvalid, fmt.Errorf("PDD 规格不是合法 JSON 对象")
}
if value, exists := root["skus"]; exists && value != nil {
items, ok := value.([]any)
if !ok {
return "", SpecBackfillPDDInvalid, fmt.Errorf("PDD skus 不是数组")
}
if len(items) > 0 {
if root["spec_source"] == "shopee_backfill" && backfilledSkeletonMatches(items, set) {
return "", SpecBackfillPDDBackfilled, nil
}
return "", SpecBackfillPDDExisting, nil
}
}
}
type combination struct{ color, size string }
combinations := make([]combination, 0, len(set))
hasColor, hasSize := false, false
for key := range set {
var values []string
if err := json.Unmarshal([]byte(key), &values); err != nil || len(values) != 2 {
return "", SpecBackfillPDDInvalid, fmt.Errorf("蝦皮正式规格键无效")
}
combinations = append(combinations, combination{values[0], values[1]})
hasColor = hasColor || values[0] != ""
hasSize = hasSize || values[1] != ""
}
sort.Slice(combinations, func(i, j int) bool {
if combinations[i].color != combinations[j].color {
return combinations[i].color < combinations[j].color
}
return combinations[i].size < combinations[j].size
})
dimensions := make([]map[string]string, 0, 2)
if hasColor {
dimensions = append(dimensions, map[string]string{"key": "color", "name": "颜色"})
}
if hasSize {
dimensions = append(dimensions, map[string]string{"key": "size", "name": "尺码"})
}
skus := make([]map[string]any, 0, len(combinations))
for _, combination := range combinations {
options := make(map[string]string)
if combination.color != "" {
options["color"] = combination.color
}
if combination.size != "" {
options["size"] = combination.size
}
skus = append(skus, map[string]any{
"options": options, "price_cent": nil, "list_price_cent": nil,
"availability_status": "unknown",
})
}
if _, exists := root["schema_version"]; !exists {
root["schema_version"] = 1
}
if _, exists := root["goods_id"]; !exists {
root["goods_id"] = pddGoodsID
}
root["dimensions"] = dimensions
root["skus"] = skus
root["spec_source"] = "shopee_backfill"
root["spec_backfilled_at"] = now.UTC().Format(time.RFC3339)
encoded, err := json.Marshal(root)
if err != nil {
return "", SpecBackfillPDDInvalid, fmt.Errorf("生成 PDD 规格骨架失败: %w", err)
}
return string(encoded), SpecBackfillCandidate, nil
}
func backfilledSkeletonMatches(items []any, expected map[string]struct{}) bool {
actual := make(map[string]struct{}, len(items))
for _, value := range items {
item, ok := value.(map[string]any)
price, hasPrice := item["price_cent"]
listPrice, hasListPrice := item["list_price_cent"]
if !ok || !hasPrice || price != nil || !hasListPrice || listPrice != nil || item["availability_status"] != "unknown" {
return false
}
if _, claimsAvailability := item["available"]; claimsAvailability {
return false
}
optionsValue, ok := item["options"].(map[string]any)
if !ok {
return false
}
color, colorOK := optionsValue["color"].(string)
size, sizeOK := optionsValue["size"].(string)
if !colorOK {
color = ""
}
if !sizeOK {
size = ""
}
key, valid := comparableSpecKey(color, size)
if !valid {
return false
}
actual[key] = struct{}{}
}
return sameStringSet(actual, expected)
}
// ApplySpecBackfill 在单个事务中应用候选;任一目标已变化就整批回滚。
func ApplySpecBackfill(db *sql.DB, plan SpecBackfillPlan, now time.Time) (int, error) {
tx, err := db.Begin()
if err != nil {
return 0, fmt.Errorf("开始规格回填事务失败: %w", err)
}
defer tx.Rollback()
updated := 0
for _, candidate := range plan.Candidates {
ok, err := repository.ReplaceEmptyPDDSpecs(tx, candidate.PDDGoodsID,
candidate.OriginalSKUsJSON, candidate.NewSKUsJSON, now.UTC().Format(time.RFC3339))
if err != nil {
return 0, err
}
if !ok {
return 0, fmt.Errorf("PDD 商品 %s 在 dry-run 后发生变化,已回滚整批回填", candidate.PDDGoodsID)
}
updated++
}
if err := tx.Commit(); err != nil {
return 0, fmt.Errorf("提交规格回填事务失败: %w", err)
}
return updated, nil
}