281 lines
9.2 KiB
Go
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
|
|
}
|