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 }