413 lines
14 KiB
Go
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 = ©
|
|
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
|
|
}
|