package service import ( "crypto/sha256" "database/sql" "encoding/hex" "encoding/json" "fmt" "sort" "strings" "unicode/utf8" "cmautobuy/admin/model" "cmautobuy/admin/repository" "cmautobuy/admin/spec" ) // ProductColorMappingRulesVersion 进入上下文版本,规则变化会让旧页面整体失效。 const ProductColorMappingRulesVersion = "color-rules-v1" // ProductColorSource 汇总同一颜色在两类可靠数据源中的出现次数。 type ProductColorSource struct { Key, Raw string FormalCount, SybCount int } func (s ProductColorSource) SourceText() string { parts := make([]string, 0, 2) if s.FormalCount > 0 { parts = append(parts, fmt.Sprintf("蝦皮 SKU %d", s.FormalCount)) } if s.SybCount > 0 { parts = append(parts, fmt.Sprintf("顺运宝 %d", s.SybCount)) } return strings.Join(parts, " · ") } // PddColorCandidate 是当前可购买完整规格组合按颜色聚合后的候选。 // 金额始终使用整数分;没有可靠价格时 HasPrice=false。 type PddColorCandidate struct { DimensionKey string Value string MinPriceCent int64 MaxPriceCent int64 HasPrice bool SKUCount int } // ProductColorMappingRow 把颜色来源、当前映射及目标有效性放在同一行。 type ProductColorMappingRow struct { Color ProductColorSource Mapping *model.ProductColorMapping MappingValid bool } // ProductColorMappingContext 是页面和后续规则复用共同依赖的只读快照。 type ProductColorMappingContext struct { ShopeeGoodsID, ShopeeTitle string PddGoodsID, PddTitle string PddDimensionKey string Rows []ProductColorMappingRow Candidates []PddColorCandidate ContextVersion string UnavailableReason string } // ProductColorMappingUpdate 是一次局部保存。TargetValue 为空表示清除当前映射。 type ProductColorMappingUpdate struct { ShopeeColorKey string TargetValue string } // NormalizeProductColorKey 只折叠首尾及连续空白,不做同义词、繁简或大小写推断。 func NormalizeProductColorKey(raw string) string { return strings.Join(strings.Fields(raw), " ") } // GetProductColorMappingContext 返回一个商品的当前颜色映射上下文。 // 商品不存在返回 (nil, nil);未关联、未采集或维度不明确通过 UnavailableReason 表达。 func GetProductColorMappingContext(db *sql.DB, goodsID string) (*ProductColorMappingContext, error) { return loadProductColorMappingContext(db, strings.TrimSpace(goodsID)) } func loadProductColorMappingContext(q repository.Execer, goodsID string) (*ProductColorMappingContext, error) { product, err := repository.GetShopeeProductByGoodsID(q, goodsID) if err != nil || product == nil { return nil, err } result := &ProductColorMappingContext{ ShopeeGoodsID: product.GoodsID, ShopeeTitle: product.Title, PddGoodsID: product.PddGoodsID, } colors, err := collectProductColors(q, goodsID) if err != nil { return nil, err } result.Rows = make([]ProductColorMappingRow, len(colors)) for i, color := range colors { result.Rows[i].Color = color } if product.PddGoodsID == "" { result.UnavailableReason = "当前蝦皮商品尚未关联 PDD 商品" result.ContextVersion = colorMappingContextVersion(result, "", "") return result, nil } pdd, err := repository.GetPddProductByGoodsID(q, product.PddGoodsID) if err != nil { return nil, err } if pdd == nil || pdd.IsDeleted() { result.UnavailableReason = "当前关联的 PDD 商品不存在或已删除" result.ContextVersion = colorMappingContextVersion(result, "", "") return result, nil } result.PddTitle = pdd.Title if pdd.CollectStatus != model.CollectCollected || strings.TrimSpace(pdd.SkusJSON) == "" { result.UnavailableReason = "当前 PDD 商品尚未完成采集" result.ContextVersion = colorMappingContextVersion(result, pdd.UpdatedAt, pdd.SkusJSON) return result, nil } dimensionKey, candidates, err := aggregatePddColorCandidates(pdd.SkusJSON) if err != nil { result.UnavailableReason = err.Error() result.ContextVersion = colorMappingContextVersion(result, pdd.UpdatedAt, pdd.SkusJSON) return result, nil } result.PddDimensionKey, result.Candidates = dimensionKey, candidates mappings, err := repository.ListProductColorMappings(q, goodsID, product.PddGoodsID) if err != nil { return nil, err } mappingByColor := make(map[string]model.ProductColorMapping, len(mappings)) for _, mapping := range mappings { mappingByColor[mapping.ShopeeColorKey] = mapping } validTargets := make(map[string]bool, len(candidates)) for _, candidate := range candidates { validTargets[candidate.Value] = true } for i := range result.Rows { mapping, ok := mappingByColor[result.Rows[i].Color.Key] if !ok { continue } copy := mapping result.Rows[i].Mapping = © result.Rows[i].MappingValid = mapping.PddDimensionKey == dimensionKey && validTargets[mapping.PddColorValue] } result.ContextVersion = colorMappingContextVersion(result, pdd.UpdatedAt, pdd.SkusJSON) return result, nil } func collectProductColors(q repository.Execer, goodsID string) ([]ProductColorSource, error) { type accumulator struct { raw string formalCount, sybCount int } byKey := map[string]*accumulator{} add := func(raw string, formal bool) { key := NormalizeProductColorKey(raw) if key == "" { return } item := byKey[key] if item == nil { item = &accumulator{raw: strings.TrimSpace(raw)} byKey[key] = item } else if candidate := strings.TrimSpace(raw); candidate < item.raw { item.raw = candidate } if formal { item.formalCount++ } else { item.sybCount++ } } skus, err := repository.ListShopeeSKUsByGoodsID(q, goodsID) if err != nil { return nil, err } for _, sku := range skus { add(sku.Color, true) } observations, err := repository.ListSybSpecObservations(q, goodsID) if err != nil { return nil, err } for _, observation := range observations { if parsed, ok := spec.ParseShopeeSpec(observation.SpecRaw); ok { add(parsed.Color, false) } } result := make([]ProductColorSource, 0, len(byKey)) for key, item := range byKey { result = append(result, ProductColorSource{ Key: key, Raw: item.raw, FormalCount: item.formalCount, SybCount: item.sybCount, }) } sort.Slice(result, func(i, j int) bool { return result[i].Key < result[j].Key }) return result, nil } func aggregatePddColorCandidates(raw string) (string, []PddColorCandidate, error) { collected, err := parseCollected(raw) if err != nil { return "", nil, fmt.Errorf("PDD 采集规格无法解析:%w", err) } colorKeys := map[string]bool{} for _, dimension := range collected.Dimensions { if isExplicitPddColorDimension(dimension.Key) || isExplicitPddColorDimension(dimension.Name) { colorKeys[dimension.Key] = true } } if len(colorKeys) == 0 { for _, sku := range collected.SKUs { for key := range sku.Options { if isExplicitPddColorDimension(key) { colorKeys[key] = true } } } } if len(colorKeys) != 1 { return "", nil, fmt.Errorf("PDD 颜色维度不明确:识别到 %d 个候选维度", len(colorKeys)) } var dimensionKey string for key := range colorKeys { dimensionKey = key } byValue := map[string]*PddColorCandidate{} for _, sku := range collected.SKUs { if !sku.Available || len(sku.Options) == 0 { continue } value := strings.TrimSpace(sku.Options[dimensionKey]) if value == "" { return "", nil, fmt.Errorf("可购买规格组合缺少颜色维度 %s", dimensionKey) } item := byValue[value] if item == nil { item = &PddColorCandidate{DimensionKey: dimensionKey, Value: value} byValue[value] = item } item.SKUCount++ if sku.PriceCent != nil && *sku.PriceCent > 0 { if !item.HasPrice || *sku.PriceCent < item.MinPriceCent { item.MinPriceCent = *sku.PriceCent } if !item.HasPrice || *sku.PriceCent > item.MaxPriceCent { item.MaxPriceCent = *sku.PriceCent } item.HasPrice = true } } if len(byValue) == 0 { return "", nil, fmt.Errorf("PDD 最新采集结果没有可购买的颜色候选") } result := make([]PddColorCandidate, 0, len(byValue)) for _, item := range byValue { result = append(result, *item) } sort.Slice(result, func(i, j int) bool { return result[i].Value < result[j].Value }) return dimensionKey, result, nil } func isExplicitPddColorDimension(raw string) bool { value := strings.ToLower(strings.Join(strings.Fields(strings.TrimSpace(raw)), "")) switch value { case "color", "colour", "color_family", "颜色", "颜色分类", "颜色選擇", "颜色选择", "顏色", "顏色分類", "顏色選擇": return true default: return false } } func colorMappingContextVersion(context *ProductColorMappingContext, pddUpdatedAt, pddSKUsJSON string) string { type contextColor struct { Key, Raw string FormalCount, SybCount int } colors := make([]contextColor, 0, len(context.Rows)) for _, row := range context.Rows { colors = append(colors, contextColor{row.Color.Key, row.Color.Raw, row.Color.FormalCount, row.Color.SybCount}) } pddHash := sha256.Sum256([]byte(pddSKUsJSON)) payload := struct { RulesVersion string ShopeeID string PddID string PddUpdatedAt string PddHash string Colors []contextColor }{ProductColorMappingRulesVersion, context.ShopeeGoodsID, context.PddGoodsID, pddUpdatedAt, hex.EncodeToString(pddHash[:]), colors} raw, _ := json.Marshal(payload) sum := sha256.Sum256(raw) return hex.EncodeToString(sum[:]) } // SaveProductColorMappings 在一个事务中校验完整上下文,并保存本次变化的颜色。 // 任何一行过期或无效都会使整批回滚。 func SaveProductColorMappings(db *sql.DB, actor *model.User, goodsID, expectedContextVersion string, updates []ProductColorMappingUpdate) (int, error) { if actor == nil { return 0, ErrUnauthenticated } if actor.Status != model.UserActive { return 0, invalidInput("当前账号不是正常状态,不能保存颜色映射") } if len(updates) == 0 { return 0, invalidInput("没有需要保存的颜色变化") } tx, err := db.Begin() if err != nil { return 0, fmt.Errorf("开始保存颜色映射事务失败: %w", err) } defer tx.Rollback() context, err := loadProductColorMappingContext(tx, strings.TrimSpace(goodsID)) if err != nil { return 0, err } if context == nil { return 0, invalidInput("蝦皮商品不存在或已删除") } if context.UnavailableReason != "" { return 0, invalidInput(context.UnavailableReason) } if expectedContextVersion == "" || context.ContextVersion != strings.TrimSpace(expectedContextVersion) { return 0, invalidInput("商品关联、规格或颜色来源已变化,请刷新后重新核对") } colors := make(map[string]ProductColorSource, len(context.Rows)) for _, row := range context.Rows { colors[row.Color.Key] = row.Color } targets := make(map[string]bool, len(context.Candidates)) for _, candidate := range context.Candidates { targets[candidate.Value] = true } seen := map[string]bool{} changed, now := 0, model.NowISO() for _, update := range updates { colorKey := NormalizeProductColorKey(update.ShopeeColorKey) if colorKey == "" || seen[colorKey] { return 0, invalidInput("颜色变化中包含空值或重复项") } seen[colorKey] = true color, ok := colors[colorKey] if !ok { return 0, invalidInput("蝦皮颜色已不存在,请刷新后重试") } old, err := repository.GetProductColorMapping(tx, context.ShopeeGoodsID, colorKey, context.PddGoodsID) if err != nil { return 0, err } target := strings.TrimSpace(update.TargetValue) if target == "" { if old == nil { continue } if _, err := repository.DeleteProductColorMapping(tx, context.ShopeeGoodsID, colorKey, context.PddGoodsID); err != nil { return 0, err } if err := repository.InsertProductColorMappingAudit(tx, model.ProductColorMappingAudit{ ShopeeGoodsID: context.ShopeeGoodsID, ShopeeColorKey: colorKey, ShopeeColorRaw: color.Raw, PddGoodsID: context.PddGoodsID, Action: "clear", OldDimensionKey: old.PddDimensionKey, OldColorValue: old.PddColorValue, ContextVersion: context.ContextVersion, ActorUserID: actor.UserID, CreatedAt: now, }); err != nil { return 0, err } changed++ continue } if !targets[target] { return 0, invalidInput("所选 PDD 颜色已不存在或不可购买,请刷新后重试") } if utf8.RuneCountInString(colorKey) > 191 || utf8.RuneCountInString(color.Raw) > 500 || utf8.RuneCountInString(context.PddDimensionKey) > 191 || utf8.RuneCountInString(target) > 500 { return 0, invalidInput("颜色名称超过数据库允许长度,未保存") } if old != nil && old.PddDimensionKey == context.PddDimensionKey && old.PddColorValue == target { continue } item := model.ProductColorMapping{ ShopeeGoodsID: context.ShopeeGoodsID, ShopeeColorKey: colorKey, ShopeeColorRaw: color.Raw, PddGoodsID: context.PddGoodsID, PddDimensionKey: context.PddDimensionKey, PddColorValue: target, ContextVersion: context.ContextVersion, MappedBy: actor.UserID, MappedAt: now, } if err := repository.UpsertProductColorMapping(tx, item); err != nil { return 0, err } audit := model.ProductColorMappingAudit{ ShopeeGoodsID: context.ShopeeGoodsID, ShopeeColorKey: colorKey, ShopeeColorRaw: color.Raw, PddGoodsID: context.PddGoodsID, Action: "upsert", NewDimensionKey: item.PddDimensionKey, NewColorValue: item.PddColorValue, ContextVersion: context.ContextVersion, ActorUserID: actor.UserID, CreatedAt: now, } if old != nil { audit.OldDimensionKey, audit.OldColorValue = old.PddDimensionKey, old.PddColorValue } if err := repository.InsertProductColorMappingAudit(tx, audit); err != nil { return 0, err } changed++ } if _, err := DeriveProductColorSpecMappings(tx, context.ShopeeGoodsID); err != nil { return 0, fmt.Errorf("颜色映射已校验但派生完整规格失败,整批未保存: %w", err) } if err := tx.Commit(); err != nil { return 0, fmt.Errorf("提交颜色映射事务失败: %w", err) } return changed, nil }