Files
cmautobuy/admin/service/color_mapping.go
T

413 lines
14 KiB
Go

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 = &copy
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
}