Files
goauto/server/app/goauto/shopeespec/client.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

120 lines
4.0 KiB
Go

// 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
}