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
This commit is contained in:
@@ -97,6 +97,31 @@ function Read-DatabaseConfig {
|
||||
}
|
||||
}
|
||||
|
||||
function Read-ErpGoConfig {
|
||||
param([string]$Path)
|
||||
|
||||
# Optional section. Absent or incomplete means the Shopee spec sync stays
|
||||
# off; the server treats that as "skip", not as an error (#290).
|
||||
$values = @{}
|
||||
$insideSection = $false
|
||||
foreach ($line in Get-Content -LiteralPath $Path) {
|
||||
if ($line -match '^\s*(#.*)?$') {
|
||||
continue
|
||||
}
|
||||
if ($line -match '^erpgo\s*:\s*$') {
|
||||
$insideSection = $true
|
||||
continue
|
||||
}
|
||||
if ($insideSection -and $line -match '^\S') {
|
||||
break
|
||||
}
|
||||
if ($insideSection -and $line -match '^\s+(baseUrl|apikey)\s*:\s*(.*?)\s*$') {
|
||||
$values[$Matches[1]] = ConvertFrom-YamlScalar $Matches[2]
|
||||
}
|
||||
}
|
||||
return $values
|
||||
}
|
||||
|
||||
function Read-PortConfig {
|
||||
param([string]$Path)
|
||||
|
||||
@@ -169,6 +194,7 @@ try {
|
||||
$ConfigPath = [IO.Path]::GetFullPath($ConfigPath)
|
||||
$databaseConfig = Read-DatabaseConfig $ConfigPath
|
||||
$portConfig = Read-PortConfig $ConfigPath
|
||||
$erpgoConfig = Read-ErpGoConfig $ConfigPath
|
||||
|
||||
if (-not $PSBoundParameters.ContainsKey('DatabaseHost')) {
|
||||
$DatabaseHost = $databaseConfig.Host
|
||||
@@ -219,6 +245,14 @@ try {
|
||||
$env:GOAUTO_DB_DRIVER = "mysql"
|
||||
$env:GOAUTO_DB_DSN = "${DatabaseUser}:${plainPassword}@tcp(${DatabaseHost}:${DatabasePort})/${DatabaseName}?charset=utf8mb4&parseTime=True&loc=Local&timeout=5s"
|
||||
$env:GOAUTO_SERVER_PORT = [string]$portConfig.Server
|
||||
# Shopee spec service. The key never reaches the repo: config.yaml is
|
||||
# gitignored and the server only ever reads the environment (#290).
|
||||
if ($erpgoConfig.ContainsKey('baseUrl') -and -not [string]::IsNullOrWhiteSpace([string]$erpgoConfig.baseUrl)) {
|
||||
$env:GOAUTO_ERPGO_BASE_URL = [string]$erpgoConfig.baseUrl
|
||||
}
|
||||
if ($erpgoConfig.ContainsKey('apikey') -and -not [string]::IsNullOrWhiteSpace([string]$erpgoConfig.apikey)) {
|
||||
$env:GOAUTO_ERPGO_APIKEY = [string]$erpgoConfig.apikey
|
||||
}
|
||||
|
||||
Push-Location $serverDirectory
|
||||
try {
|
||||
@@ -272,5 +306,7 @@ finally {
|
||||
Remove-Item Env:GOAUTO_DB_DRIVER -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:GOAUTO_SERVER_PORT -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:GOAUTO_CONFIG -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:GOAUTO_ERPGO_BASE_URL -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:GOAUTO_ERPGO_APIKEY -ErrorAction SilentlyContinue
|
||||
$plainPassword = $null
|
||||
}
|
||||
|
||||
@@ -420,6 +420,15 @@ type ShopeeProduct struct {
|
||||
// PDD product.
|
||||
PDDProductID *uint64 `json:"pddProductId" gorm:"index"`
|
||||
ImageSearchLinked bool `json:"imageSearchLinked" gorm:"not null;default:false;index"`
|
||||
// SpecSyncPDDProductID records which PDD product the full Shopee spec list
|
||||
// was synced and matched against (#290).
|
||||
//
|
||||
// `[必须]` 存 PDD 商品 id 而不是布尔值:映射是对着某个 PDD 商品的规格值建的,
|
||||
// 一旦重新关联到别的 PDD 商品,旧映射全部失效。跟踪具体是哪个商品,则跳过
|
||||
// 判据变成 SpecSyncPDDProductID == PDDProductID,重新关联时标记自动失效,
|
||||
// 不依赖“记得去清”。
|
||||
SpecSyncPDDProductID *uint64 `json:"specSyncPddProductId,omitempty" gorm:"index"`
|
||||
SpecSyncAt *time.Time `json:"specSyncAt,omitempty"`
|
||||
// ImageURL holds the SYB-provided reference image URL. It is written by the
|
||||
// #41 import and may be overridden manually; the product domain never joins
|
||||
// syb_products at read time.
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
// Package shopeespec fetches a Shopee product's complete colour and size list
|
||||
// from the ERP-Go side service (#290).
|
||||
//
|
||||
// 存在的理由:虾皮档案里的颜色尺码是从 SYB 明细增量累积的(sybimport.mergeParsedSpec),
|
||||
// SYB 送来什么才有什么,因此永远滞后于真实商品。新订单带来新组合时又变成“未匹配”,
|
||||
// 采购员得反复去点 AI 匹配。拉取完整清单后可以一次性匹配完,之后新订单直接可采购。
|
||||
package shopeespec
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// 返回体是颜色与尺码两个字符串数组,实测 462 字节;上限留足冗余即可。
|
||||
maxResponseBytes = 1 << 20
|
||||
requestTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
// ErrNotConfigured means the base URL or API key is absent, so the caller
|
||||
// should skip the sync instead of failing the surrounding operation.
|
||||
var ErrNotConfigured = errors.New("shopee spec service is not configured")
|
||||
|
||||
// Spec is the complete colour and size list of one Shopee product, exactly as
|
||||
// the platform spells them — 【...】 annotations included.
|
||||
type Spec struct {
|
||||
Colors []string
|
||||
Sizes []string
|
||||
}
|
||||
|
||||
// Client reads its endpoint and credential from the environment.
|
||||
//
|
||||
// `[必须]` 凭据只从环境变量读取,不接受参数传入、不落日志。本地由
|
||||
// scripts/start-server.ps1 从 config.yaml 的 erpgo 段转换;线上在
|
||||
// /etc/goauto/goauto.env 配置。
|
||||
type Client struct {
|
||||
HTTP *http.Client
|
||||
}
|
||||
|
||||
func NewClient() *Client { return &Client{HTTP: &http.Client{Timeout: requestTimeout}} }
|
||||
|
||||
// Fetch returns the full spec list for one Shopee item id.
|
||||
func (c *Client) Fetch(ctx context.Context, shopeeItemID string) (Spec, error) {
|
||||
shopeeItemID = strings.TrimSpace(shopeeItemID)
|
||||
if shopeeItemID == "" {
|
||||
return Spec{}, errors.New("shopee item id is empty")
|
||||
}
|
||||
base := strings.TrimSpace(os.Getenv("GOAUTO_ERPGO_BASE_URL"))
|
||||
key := strings.TrimSpace(os.Getenv("GOAUTO_ERPGO_APIKEY"))
|
||||
if base == "" || key == "" {
|
||||
return Spec{}, ErrNotConfigured
|
||||
}
|
||||
endpoint, err := url.Parse(strings.TrimRight(base, "/") + "/api/v1/shopee/product/spec/" + url.PathEscape(shopeeItemID))
|
||||
if err != nil {
|
||||
return Spec{}, err
|
||||
}
|
||||
query := endpoint.Query()
|
||||
query.Set("apikey", key)
|
||||
endpoint.RawQuery = query.Encode()
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
|
||||
if err != nil {
|
||||
return Spec{}, err
|
||||
}
|
||||
client := c.HTTP
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: requestTimeout}
|
||||
}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
// `[必须]` 不要把 err 直接往外带:net/url 的错误会把完整 URL(含 apikey)
|
||||
// 写进错误文本,那会让凭据流进日志和工单。
|
||||
return Spec{}, fmt.Errorf("shopee spec request failed for item %s", shopeeItemID)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return Spec{}, fmt.Errorf("shopee spec service returned %d for item %s", response.StatusCode, shopeeItemID)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(response.Body, maxResponseBytes))
|
||||
if err != nil {
|
||||
return Spec{}, fmt.Errorf("shopee spec response unreadable for item %s", shopeeItemID)
|
||||
}
|
||||
var payload struct {
|
||||
Data struct {
|
||||
Color []string `json:"color"`
|
||||
Size []string `json:"size"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return Spec{}, fmt.Errorf("shopee spec response is not decodable for item %s", shopeeItemID)
|
||||
}
|
||||
spec := Spec{Colors: clean(payload.Data.Color), Sizes: clean(payload.Data.Size)}
|
||||
if len(spec.Colors) == 0 && len(spec.Sizes) == 0 {
|
||||
return Spec{}, fmt.Errorf("shopee spec service returned no specs for item %s", shopeeItemID)
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
func clean(values []string) []string {
|
||||
result := make([]string, 0, len(values))
|
||||
seen := map[string]bool{}
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || seen[value] {
|
||||
continue
|
||||
}
|
||||
seen[value] = true
|
||||
result = append(result, value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package shopeespec
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const testKey = "unit-test-key-not-a-real-credential"
|
||||
|
||||
func configure(t *testing.T, base string) {
|
||||
t.Helper()
|
||||
t.Setenv("GOAUTO_ERPGO_BASE_URL", base)
|
||||
t.Setenv("GOAUTO_ERPGO_APIKEY", testKey)
|
||||
}
|
||||
|
||||
func TestFetchReturnsPlatformStringsVerbatim(t *testing.T) {
|
||||
var gotPath, gotKey string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath, gotKey = r.URL.Path, r.URL.Query().Get("apikey")
|
||||
w.Write([]byte(`{"data":{"color":["香芋紫 【雙梅花】純棉","",""],"size":["2XL 60.0-67.5公斤","S","S"]}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
configure(t, server.URL)
|
||||
|
||||
spec, err := NewClient().Fetch(context.Background(), " 56715929322 ")
|
||||
if err != nil {
|
||||
t.Fatalf("fetch: %v", err)
|
||||
}
|
||||
if gotPath != "/api/v1/shopee/product/spec/56715929322" {
|
||||
t.Fatalf("path = %q", gotPath)
|
||||
}
|
||||
if gotKey != testKey {
|
||||
t.Fatalf("apikey not sent")
|
||||
}
|
||||
// `[必须]` 客户端不做剥离——剥离规则属于 sybspec,必须与 SYB 明细同源。
|
||||
if len(spec.Colors) != 1 || spec.Colors[0] != "香芋紫 【雙梅花】純棉" {
|
||||
t.Fatalf("colors = %#v, want the platform string untouched", spec.Colors)
|
||||
}
|
||||
// 空值与重复值在这里去掉,避免下游把它们当成真实规格。
|
||||
if len(spec.Sizes) != 2 {
|
||||
t.Fatalf("sizes = %#v, want blanks and duplicates dropped", spec.Sizes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchSkipsWhenNotConfigured(t *testing.T) {
|
||||
t.Setenv("GOAUTO_ERPGO_BASE_URL", "")
|
||||
t.Setenv("GOAUTO_ERPGO_APIKEY", "")
|
||||
if _, err := NewClient().Fetch(context.Background(), "1"); err != ErrNotConfigured {
|
||||
t.Fatalf("err = %v, want ErrNotConfigured", err)
|
||||
}
|
||||
}
|
||||
|
||||
// `[必须]` 传输失败时 net/url 会把完整 URL(含 apikey)写进错误文本,而调用方
|
||||
// 会把这个错误打进日志。错误里绝不能出现凭据(#290)。
|
||||
func TestFetchErrorsNeverCarryTheCredential(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
}))
|
||||
base := server.URL
|
||||
server.Close() // 关掉,制造传输层失败
|
||||
|
||||
for _, item := range []struct {
|
||||
name string
|
||||
base string
|
||||
}{
|
||||
{"传输失败", base},
|
||||
{"URL 不合法", "http://[::1"},
|
||||
} {
|
||||
t.Run(item.name, func(t *testing.T) {
|
||||
configure(t, item.base)
|
||||
_, err := NewClient().Fetch(context.Background(), "56715929322")
|
||||
if err == nil {
|
||||
t.Fatal("expected an error")
|
||||
}
|
||||
if strings.Contains(err.Error(), testKey) || strings.Contains(err.Error(), "apikey") {
|
||||
t.Fatalf("error text leaks the credential: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchRejectsResponsesWithoutSpecs(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(`{"data":{"color":[],"size":[]}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
configure(t, server.URL)
|
||||
|
||||
// 空清单不能当成“该商品没有规格”而覆盖档案,只能算失败并跳过同步。
|
||||
if _, err := NewClient().Fetch(context.Background(), "1"); err == nil {
|
||||
t.Fatal("expected an error for an empty spec list")
|
||||
}
|
||||
}
|
||||
@@ -152,6 +152,17 @@ func Parse(raw string) ParseResult {
|
||||
return ParseResult{Color: colorPart, Size: sizePart, Status: models.SYBParseStatusSuccess}
|
||||
}
|
||||
|
||||
// StripAnnotations removes the 【...】 annotations exactly the way Parse does to
|
||||
// each half of a productSpec.
|
||||
//
|
||||
// `[必须]` 给虞皮完整规格清单用。接口返回的是虞皮原文,而档案里的键来自
|
||||
// SYB 明细经 Parse 剥离后的结果。两者必须用同一套规则,否则 confirmedMappings
|
||||
// 查不到,映射全部落空(#290)。
|
||||
//
|
||||
// 只能用剥离,不能用 Parse 拼一个假逗号去跑:单独的半边会走进无逗号分支,
|
||||
// 被空格规则再拆一次("2XL 60.0-67.5公斤" 会变成 "2XL"),与真实流程不同。
|
||||
func StripAnnotations(part string) string { return stripBrackets(part) }
|
||||
|
||||
func stripBrackets(part string) string {
|
||||
return strings.TrimSpace(bracketPattern.ReplaceAllString(part, ""))
|
||||
}
|
||||
|
||||
@@ -191,7 +191,7 @@ func (service *Service) BatchCreateImageSearch(ctx context.Context, request Imag
|
||||
}
|
||||
if item.Success {
|
||||
response.SuccessCount++
|
||||
} else if item.Code == "IMAGE_SEARCH_ALREADY_LINKED" {
|
||||
} else if item.Code == "IMAGE_SEARCH_ALREADY_LINKED" || item.Code == "IMAGE_SEARCH_SPEC_SYNCED" {
|
||||
response.SkippedCount++
|
||||
} else {
|
||||
response.FailureCount++
|
||||
@@ -261,6 +261,16 @@ func (service *Service) createImageSearch(ctx context.Context, request ImageSear
|
||||
if err := db.First(&shopee, item.ShopeeProductID).Error; err != nil {
|
||||
return 0, false, serviceError("SHOPEE_PRODUCT_NOT_FOUND", "蝦皮商品不存在")
|
||||
}
|
||||
// `[必须]` 已完整同步并匹配过规格的商品一律跳过,不看 OverwriteLinked。
|
||||
// 图搜的唯一产出是“找到并关联 PDD 商品”,这类商品已经有了;再搜一次只会
|
||||
// 拿另一个颜色的图找到另一个 PDD 商品并覆盖掉现有关联,让已建立的映射
|
||||
// 全部失效。要纠正关联应当是单个商品的显式操作,不是批量勾选的副作用(#290)。
|
||||
//
|
||||
// 判据比对的是“同步时对着哪个 PDD 商品”:重新关联后标记自动失效,不依赖
|
||||
// 记得去清。
|
||||
if specSyncCoversCurrentLink(shopee.SpecSyncPDDProductID, shopee.PDDProductID) {
|
||||
return 0, false, serviceError("IMAGE_SEARCH_SPEC_SYNCED", "商品已完成规格同步与匹配,无需再次图搜")
|
||||
}
|
||||
if shopee.PDDProductID != nil && !request.OverwriteLinked {
|
||||
return 0, false, serviceError("IMAGE_SEARCH_ALREADY_LINKED", "商品已关联 PDD,已跳过")
|
||||
}
|
||||
|
||||
@@ -18,8 +18,10 @@ import (
|
||||
// autoLinkedImageSearch 汇报本次是否确实写入了关联,以及关联覆盖了哪些 SYB 明细。
|
||||
// 只有确实写入时才值得触发后续的规格匹配(#287)。
|
||||
type autoLinkedImageSearch struct {
|
||||
Linked bool
|
||||
SYBProductIDs []uint64
|
||||
Linked bool
|
||||
SYBProductIDs []uint64
|
||||
ShopeeProductID uint64
|
||||
PDDProductID uint64
|
||||
}
|
||||
|
||||
func (service *Service) autoLinkImageSearch(ctx context.Context, taskID uint64) (autoLinkedImageSearch, error) {
|
||||
@@ -83,7 +85,10 @@ func (service *Service) autoLinkImageSearch(ctx context.Context, taskID uint64)
|
||||
if result.RowsAffected == 0 {
|
||||
return autoLinkedImageSearch{}, nil
|
||||
}
|
||||
return autoLinkedImageSearch{Linked: true, SYBProductIDs: snapshot.SYBProductIDs}, nil
|
||||
return autoLinkedImageSearch{
|
||||
Linked: true, SYBProductIDs: snapshot.SYBProductIDs,
|
||||
ShopeeProductID: product.ID, PDDProductID: *task.PDDProductID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// matchSpecsAfterImageSearch triggers the existing SYB batch spec match for the
|
||||
@@ -104,6 +109,9 @@ func (service *Service) matchSpecsAfterImageSearch(ctx context.Context, linked a
|
||||
if !linked.Linked || len(linked.SYBProductIDs) == 0 {
|
||||
return
|
||||
}
|
||||
// 先把该虾皮商品的完整颜色尺码补齐,再匹配:只匹配本次明细会留下空洞,
|
||||
// 新订单带来新组合时又得重来(#290)。
|
||||
service.syncShopeeSpecs(ctx, linked.ShopeeProductID, linked.PDDProductID)
|
||||
if _, err := purchase.NewService(service.DB).BatchSpecMatch(ctx, purchase.BatchSpecMatchRequest{
|
||||
SYBProductIDs: linked.SYBProductIDs,
|
||||
}); err != nil {
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
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(¤t, 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
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/shopeeproduct"
|
||||
"go-admin/app/goauto/shopeespec"
|
||||
)
|
||||
|
||||
// `[必须]` 接口返回虾皮原文,档案里的键来自 SYB 明细经 Parse 剥离后的结果。
|
||||
// 两者必须落到同一个字符串,否则 confirmedMappings 查不到,映射全部落空。
|
||||
// 样本取自线上真实数据与接口返回(2026-09-16)。
|
||||
func TestIncomingDimensionsMatchSYBKeys(t *testing.T) {
|
||||
spec := shopeespec.Spec{
|
||||
// 虾皮商品 56715929322 的返回。
|
||||
Colors: []string{"香芋紫 【雙梅花】純棉", "酒紅色 【雙梅花】純棉"},
|
||||
Sizes: []string{"2XL 60.0-67.5公斤", "3XL 67.5-80.0公斤"},
|
||||
}
|
||||
dimensions := incomingDimensions(spec)
|
||||
got := map[string][]string{}
|
||||
for _, dimension := range dimensions {
|
||||
for _, value := range dimension.Values {
|
||||
got[dimension.Name] = append(got[dimension.Name], value.Name)
|
||||
}
|
||||
}
|
||||
// SYB 对同一商品送来的正是这两个颜色与尺码。
|
||||
wantColors := []string{"香芋紫 純棉", "酒紅色 純棉"}
|
||||
wantSizes := []string{"2XL 60.0-67.5公斤", "3XL 67.5-80.0公斤"}
|
||||
assertValues(t, "颜色", got["颜色"], wantColors)
|
||||
assertValues(t, "尺码", got["尺码"], wantSizes)
|
||||
}
|
||||
|
||||
// `[必须]` 尺码不得被二次拆分。用 Parse 拼假逗号会让 "2XL 60.0-67.5公斤" 走进
|
||||
// 无逗号分支、被空格规则拆成 "2XL",与真实流程不符。
|
||||
func TestIncomingSizesAreNotSplitAgain(t *testing.T) {
|
||||
dimensions := incomingDimensions(shopeespec.Spec{Sizes: []string{"L 50.0-55.0公斤"}})
|
||||
if len(dimensions) != 1 || len(dimensions[0].Values) != 1 {
|
||||
t.Fatalf("expected one size value, got %+v", dimensions)
|
||||
}
|
||||
if dimensions[0].Values[0].Name != "L 50.0-55.0公斤" {
|
||||
t.Fatalf("size was re-split: %q", dimensions[0].Values[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
// 括号里是体重建议时剥离正确(虾皮 2065 的真实形态)。
|
||||
func TestIncomingStripsSizeAnnotations(t *testing.T) {
|
||||
dimensions := incomingDimensions(shopeespec.Spec{
|
||||
Sizes: []string{"S【建議40公斤以內】", "M【建議40-50公斤】"},
|
||||
})
|
||||
assertValues(t, "尺码", names(dimensions[0].Values), []string{"S", "M"})
|
||||
}
|
||||
|
||||
// 塌缩的值只写一个:歧义由 #289 在采购侧拦截,这里不假装能区分。
|
||||
func TestIncomingDeduplicatesCollapsedValues(t *testing.T) {
|
||||
dimensions := incomingDimensions(shopeespec.Spec{
|
||||
Colors: []string{"白色【207A】", "白色【209A】"},
|
||||
})
|
||||
assertValues(t, "颜色", names(dimensions[0].Values), []string{"白色"})
|
||||
}
|
||||
|
||||
func names(values []shopeeproduct.SpecValue) []string {
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
result = append(result, value.Name)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func assertValues(t *testing.T, label string, got, want []string) {
|
||||
t.Helper()
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("%s: got %v, want %v", label, got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("%s[%d]: got %q, want %q", label, i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// `[必须]` 跳过判据比对的是“同步时对着哪个 PDD 商品”,不是布尔值。重新关联到
|
||||
// 别的 PDD 商品后标记必须自动失效——映射是对着旧商品的规格值建的(#290)。
|
||||
func TestSpecSyncedSkipFollowsTheCurrentLink(t *testing.T) {
|
||||
linked := uint64(8482)
|
||||
other := uint64(9001)
|
||||
cases := []struct {
|
||||
name string
|
||||
syncedWith *uint64
|
||||
current *uint64
|
||||
wantSkip bool
|
||||
}{
|
||||
{"同步过且关联未变 → 跳过", &linked, &linked, true},
|
||||
{"重新关联到别的商品 → 标记失效", &linked, &other, false},
|
||||
{"从未同步 → 不跳过", nil, &linked, false},
|
||||
{"同步过但已解除关联 → 不跳过", &linked, nil, false},
|
||||
}
|
||||
for _, item := range cases {
|
||||
t.Run(item.name, func(t *testing.T) {
|
||||
got := specSyncCoversCurrentLink(item.syncedWith, item.current)
|
||||
if got != item.wantSkip {
|
||||
t.Fatalf("got skip=%v, want %v", got, item.wantSkip)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func init() {
|
||||
_, file, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(file), migrateShopeeSpecSyncMarker)
|
||||
}
|
||||
|
||||
// migrateShopeeSpecSyncMarker adds the marker recording which PDD product a
|
||||
// Shopee product's full spec list was synced and matched against (#290).
|
||||
//
|
||||
// `[必须]` 只加列,不回填。既有商品的档案仍然是从 SYB 明细增量累积来的,不能
|
||||
// 假装它们已经同步过完整规格——那会让它们被图搜跳过,而它们的规格其实并不完整。
|
||||
// 留空表示“未同步”,下次处理到时自然会拉取。
|
||||
func migrateShopeeSpecSyncMarker(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
for _, column := range []string{"SpecSyncPDDProductID", "SpecSyncAt"} {
|
||||
if tx.Migrator().HasColumn(&models.ShopeeProduct{}, column) {
|
||||
continue
|
||||
}
|
||||
if err := tx.Migrator().AddColumn(&models.ShopeeProduct{}, column); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user