Files
goauto/server/app/goauto/task/spec_sync.go
T
QiuSWandClaude Opus 5 5b3c8df3bc feat(server): 同步虾皮完整颜色尺码并一次性匹配 (#290)
图搜采集成功并自动关联后,拉取该虾皮商品的完整颜色尺码清单合并进档案,
再触发一次匹配。此后同一虾皮商品的其它颜色 SYB 订单无需再采集、再点匹配。

- 新增 shopeespec 叶子包,凭据只从 GOAUTO_ERPGO_BASE_URL/GOAUTO_ERPGO_APIKEY
  读取;错误文本不携带 URL,避免 apikey 流进日志。
- 写入档案前经 sybspec.StripAnnotations 剥离 【...】,与 SYB 明细同源,
  否则 confirmedMappings 查不到、映射全部落空。
- 标记记录同步时对着哪个 PDD 商品(spec_sync_pdd_product_id),不是布尔值,
  重新关联时自动失效。
- 已完整同步的商品再次图搜时返回 IMAGE_SEARCH_SPEC_SYNCED。
- 迁移只加列不回填:既有档案仍是从 SYB 明细增量累积的,不能假装已同步。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-16 10:33:53 +08:00

113 lines
4.1 KiB
Go

package task
import (
"context"
"log"
"time"
"go-admin/app/goauto/models"
"go-admin/app/goauto/shopeeproduct"
"go-admin/app/goauto/shopeespec"
"go-admin/app/goauto/sybspec"
"gorm.io/gorm"
)
// syncShopeeSpecs pulls the Shopee product's complete colour and size list and
// folds it into the archive, so every future SYB order already has a value to
// map (#290).
//
// `[必须]` 接口返回的是虾皮原文(`香芋紫 【雙梅花】純棉`),而 SYB 明细经
// sybspec.Parse 剥离 【...】 后是 `香芋紫 純棉`。档案里的键必须与 SYB 送来的
// 一致,否则 confirmedMappings 查不到,映射全部落空、等于白做。因此写入前要
// 走一遍同样的解析。
//
// `[必须]` 失败只记录不冒泡。采集结果已经提交完成,不能因为这个可选增强让
// SubmitResult 失败、让 Agent 以为采集没成功。接口未配置时直接跳过。
func (service *Service) syncShopeeSpecs(ctx context.Context, shopeeProductID uint64, pddProductID uint64) {
var product models.ShopeeProduct
if err := service.DB.WithContext(ctx).First(&product, shopeeProductID).Error; err != nil {
return
}
spec, err := shopeespec.NewClient().Fetch(ctx, product.ShopeeItemID)
if err != nil {
if err != shopeespec.ErrNotConfigured {
log.Printf("shopee spec sync failed for shopee product %d: %v", shopeeProductID, err)
}
return
}
incoming := incomingDimensions(spec)
if len(incoming) == 0 {
return
}
if err := service.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var current models.ShopeeProduct
if err := tx.First(&current, shopeeProductID).Error; err != nil {
return err
}
// 关联在拉取期间被改动时放弃:映射要对着当前关联的 PDD 商品建立。
if current.PDDProductID == nil || *current.PDDProductID != pddProductID {
return nil
}
existing, err := shopeeproduct.Unmarshal(current.SpecsJSON)
if err != nil {
return err
}
// Merge 保留既有映射,只补新值(见 shopeeproduct.Merge)。
merged := shopeeproduct.Merge(existing, incoming)
if err := shopeeproduct.Validate(merged); err != nil {
return err
}
specsJSON, err := shopeeproduct.Marshal(merged)
if err != nil {
return err
}
now := time.Now().UTC()
return tx.Model(&models.ShopeeProduct{}).Where("id = ?", shopeeProductID).
Updates(map[string]any{
"specs_json": specsJSON,
"spec_sync_pdd_product_id": pddProductID,
"spec_sync_at": now,
}).Error
}); err != nil {
log.Printf("shopee spec sync write failed for shopee product %d: %v", shopeeProductID, err)
}
}
// incomingDimensions converts the platform's raw values into archive values,
// applying exactly the parsing SYB details go through.
func incomingDimensions(spec shopeespec.Spec) []shopeeproduct.SpecDimension {
colors := parsedValues(spec.Colors, sybspec.StripAnnotations)
sizes := parsedValues(spec.Sizes, sybspec.StripAnnotations)
dimensions := make([]shopeeproduct.SpecDimension, 0, 2)
if len(colors) > 0 {
dimensions = append(dimensions, shopeeproduct.SpecDimension{Name: "颜色", Role: shopeeproduct.RoleColor, Values: colors})
}
if len(sizes) > 0 {
dimensions = append(dimensions, shopeeproduct.SpecDimension{Name: "尺码", Role: shopeeproduct.RoleSize, Values: sizes})
}
return dimensions
}
func parsedValues(raws []string, parse func(string) string) []shopeeproduct.SpecValue {
values := make([]shopeeproduct.SpecValue, 0, len(raws))
seen := map[string]bool{}
for _, raw := range raws {
name := parse(raw)
if name == "" || seen[name] {
// 空值跳过;重名说明两个虾皮规格剥离后塌缩,这里只写一个值,
// 具体的歧义由 #289 的塌缩检测在采购侧拦截。
continue
}
seen[name] = true
values = append(values, shopeeproduct.SpecValue{Name: name, Source: shopeeproduct.ValueSourceImport})
}
return values
}
// specSyncCoversCurrentLink reports whether the recorded spec sync still
// applies to the product's current PDD link (#290).
func specSyncCoversCurrentLink(syncedWith, current *uint64) bool {
return syncedWith != nil && current != nil && *syncedWith == *current
}