diff --git a/scripts/start-server.ps1 b/scripts/start-server.ps1 index 904ebc9..6672a6a 100644 --- a/scripts/start-server.ps1 +++ b/scripts/start-server.ps1 @@ -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 } diff --git a/server/app/goauto/models/schema.go b/server/app/goauto/models/schema.go index 4d27ff9..5a4e6e1 100644 --- a/server/app/goauto/models/schema.go +++ b/server/app/goauto/models/schema.go @@ -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. diff --git a/server/app/goauto/shopeespec/client.go b/server/app/goauto/shopeespec/client.go new file mode 100644 index 0000000..fdaa6e5 --- /dev/null +++ b/server/app/goauto/shopeespec/client.go @@ -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 +} diff --git a/server/app/goauto/shopeespec/client_test.go b/server/app/goauto/shopeespec/client_test.go new file mode 100644 index 0000000..34791b2 --- /dev/null +++ b/server/app/goauto/shopeespec/client_test.go @@ -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") + } +} diff --git a/server/app/goauto/sybspec/parse.go b/server/app/goauto/sybspec/parse.go index e0730d4..cfa8156 100644 --- a/server/app/goauto/sybspec/parse.go +++ b/server/app/goauto/sybspec/parse.go @@ -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, "")) } diff --git a/server/app/goauto/task/image_search.go b/server/app/goauto/task/image_search.go index 887308e..a3a6e2f 100644 --- a/server/app/goauto/task/image_search.go +++ b/server/app/goauto/task/image_search.go @@ -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,已跳过") } diff --git a/server/app/goauto/task/image_search_link.go b/server/app/goauto/task/image_search_link.go index 6f1b8a2..67fe88a 100644 --- a/server/app/goauto/task/image_search_link.go +++ b/server/app/goauto/task/image_search_link.go @@ -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 { diff --git a/server/app/goauto/task/spec_sync.go b/server/app/goauto/task/spec_sync.go new file mode 100644 index 0000000..d1fc77e --- /dev/null +++ b/server/app/goauto/task/spec_sync.go @@ -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 +} diff --git a/server/app/goauto/task/spec_sync_test.go b/server/app/goauto/task/spec_sync_test.go new file mode 100644 index 0000000..a6b7037 --- /dev/null +++ b/server/app/goauto/task/spec_sync_test.go @@ -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) + } + }) + } +} diff --git a/server/cmd/migrate/migration/version-local/1789600000000_shopee_spec_sync_marker.go b/server/cmd/migrate/migration/version-local/1789600000000_shopee_spec_sync_marker.go new file mode 100644 index 0000000..7e2d0a0 --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1789600000000_shopee_spec_sync_marker.go @@ -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 + }) +}