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:
QiuSW
2026-09-16 10:33:53 +08:00
co-authored by Claude Opus 5
parent a27db5f960
commit 5b3c8df3bc
10 changed files with 546 additions and 4 deletions
+36
View File
@@ -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
}
+9
View File
@@ -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.
+119
View File
@@ -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")
}
}
+11
View File
@@ -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, ""))
}
+11 -1
View File
@@ -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,已跳过")
}
+11 -3
View File
@@ -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 {
+112
View File
@@ -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(&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
}
+105
View File
@@ -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
})
}