add Shopee product spec endpoint

This commit is contained in:
QiuSW
2026-09-16 09:38:18 +08:00
parent acffa42d3b
commit 8dbb98e002
5 changed files with 215 additions and 0 deletions
+146
View File
@@ -0,0 +1,146 @@
package apis
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"go-admin/internal/huohanhan"
)
type productSpec struct {
Color []string `json:"color"`
Size []string `json:"size"`
}
type productVariation struct {
VariationName string `json:"variationName"`
VariationOptionList []struct {
VariationOptionName string `json:"variationOptionName"`
} `json:"variationOptionList"`
}
// GetSpecByShopeeID reads the current Huohanhan detail without asking
// Huohanhan to synchronize the product from Shopee first.
func (e ShopeeProduct) GetSpecByShopeeID(c *gin.Context) {
e.MakeContext(c)
want := strings.TrimSpace(c.Param("shopeeId"))
if want == "" {
e.Error(http.StatusBadRequest, fmt.Errorf("shopeeId is required"), "参数错误")
return
}
if !ValidateAPIKey(c) {
e.Error(http.StatusUnauthorized, fmt.Errorf("invalid api key"), "未授权")
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 120*time.Second)
defer cancel()
client, err := e.newHuohanhanClient()
if err != nil {
e.Error(http.StatusInternalServerError, err, "客户端初始化失败")
return
}
page, err := client.GetProductPage(ctx, huohanhan.ProductPageParams{
Size: 1, Current: 1, ItemIDs: want, IncludeAllStatuses: true,
})
if err != nil {
e.Error(http.StatusBadGateway, err, "货憨憨请求失败")
return
}
internalID := ""
for i := range page.Records {
if strings.TrimSpace(page.Records[i].ItemID) == want {
internalID = strings.TrimSpace(page.Records[i].ID)
break
}
}
if internalID == "" {
e.Error(http.StatusNotFound, fmt.Errorf("product not found"), "未找到商品")
return
}
detail, err := client.GetProductDetail(ctx, internalID)
if err != nil {
e.Error(http.StatusBadGateway, err, "货憨憨商品详情请求失败")
return
}
itemID, err := detail.ItemID()
if err != nil || strings.TrimSpace(itemID) != want {
if err == nil {
err = fmt.Errorf("商品详情 ID 与查询条件不一致")
}
e.Error(http.StatusBadGateway, err, "商品详情格式错误")
return
}
itemInfo, err := detail.ItemInfo()
if err != nil {
e.Error(http.StatusBadGateway, err, "商品详情格式错误")
return
}
spec, err := extractProductSpec(itemInfo)
if err != nil {
e.Error(http.StatusBadGateway, err, "商品规格格式错误")
return
}
// The endpoint intentionally exposes only the two requested spec lists.
c.JSON(http.StatusOK, gin.H{"data": spec})
}
func extractProductSpec(itemInfo json.RawMessage) (productSpec, error) {
spec := productSpec{Color: make([]string, 0), Size: make([]string, 0)}
var fields map[string]json.RawMessage
decoder := json.NewDecoder(bytes.NewReader(itemInfo))
decoder.UseNumber()
if err := decoder.Decode(&fields); err != nil || fields == nil {
if err == nil {
err = fmt.Errorf("itemInfo 不是对象")
}
return spec, err
}
raw := fields["standardiseTierVariation"]
if len(bytes.TrimSpace(raw)) == 0 || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
return spec, nil
}
var variations []productVariation
if err := json.Unmarshal(raw, &variations); err != nil {
return spec, fmt.Errorf("standardiseTierVariation 格式不正确:%w", err)
}
colorSeen := make(map[string]struct{})
sizeSeen := make(map[string]struct{})
for _, variation := range variations {
name := strings.ToLower(strings.TrimSpace(variation.VariationName))
var target *[]string
var seen map[string]struct{}
switch {
case strings.Contains(name, "颜色"), strings.Contains(name, "顏色"), strings.Contains(name, "color"):
target, seen = &spec.Color, colorSeen
case strings.Contains(name, "尺码"), strings.Contains(name, "尺碼"), strings.Contains(name, "size"):
target, seen = &spec.Size, sizeSeen
default:
continue
}
for _, option := range variation.VariationOptionList {
value := strings.TrimSpace(option.VariationOptionName)
if value == "" {
continue
}
if _, exists := seen[value]; exists {
continue
}
seen[value] = struct{}{}
*target = append(*target, value)
}
}
return spec, nil
}
@@ -0,0 +1,42 @@
package apis
import (
"encoding/json"
"reflect"
"testing"
)
func TestExtractProductSpec(t *testing.T) {
raw := json.RawMessage(`{
"standardiseTierVariation": [
{"variationName":"顏色分類","variationOptionList":[
{"variationOptionName":"黑色 微喇褲夏款"},
{"variationOptionName":"黑色 微喇褲夏款"}
]},
{"variationName":"尺碼","variationOptionList":[
{"variationOptionName":"4XL 77.5-87.5公斤"}
]}
]
}`)
got, err := extractProductSpec(raw)
if err != nil {
t.Fatalf("extractProductSpec() error = %v", err)
}
if want := []string{"黑色 微喇褲夏款"}; !reflect.DeepEqual(got.Color, want) {
t.Fatalf("color = %#v, want %#v", got.Color, want)
}
if want := []string{"4XL 77.5-87.5公斤"}; !reflect.DeepEqual(got.Size, want) {
t.Fatalf("size = %#v, want %#v", got.Size, want)
}
}
func TestExtractProductSpecWithoutVariationsReturnsEmptyArrays(t *testing.T) {
got, err := extractProductSpec(json.RawMessage(`{"itemId":"24720421377"}`))
if err != nil {
t.Fatalf("extractProductSpec() error = %v", err)
}
if got.Color == nil || got.Size == nil || len(got.Color) != 0 || len(got.Size) != 0 {
t.Fatalf("expected non-nil empty arrays, got %#v", got)
}
}
+1
View File
@@ -14,6 +14,7 @@ func init() {
func registerShopeeProductRouter(v1 *gin.RouterGroup) {
a := apis.ShopeeProduct{}
v1.GET("/shopee/products/:shopeeId", a.GetByShopeeID)
v1.GET("/shopee/product/spec/:shopeeId", a.GetSpecByShopeeID)
v1.POST("/shopee/products/:shopeeId/sync", a.SyncByShopeeID)
v1.POST("/shopee/products/:shopeeId/sync-wait", a.SyncWaitByShopeeID)
v1.POST("/shopee/products/:shopeeId/sync-if-stale", a.SyncIfStaleByShopeeID)
@@ -0,0 +1,8 @@
### 查询商品颜色和尺码(不会触发 Shopee 同步)
GET http://127.0.0.1:8020/api/v1/shopee/product/spec/24720421377?apikey={{apiKey}}
Accept: application/json
### 使用请求头传递 API Key
GET http://127.0.0.1:8020/api/v1/shopee/product/spec/24720421377
Accept: application/json
X-API-Key: {{apiKey}}
@@ -0,0 +1,18 @@
# LOCAL-015:Shopee 商品颜色尺码接口
## 目标
增加 `GET /api/v1/shopee/product/spec/{shopeeId}`,直接读取 Huohanhan 商品详情,不触发 Shopee 同步,只返回颜色和尺码数组。
## 验收标准
- 支持通过 `X-API-Key` 请求头或 `apikey` 查询参数认证。
- 使用 Shopee ID 定位 Huohanhan 商品并读取详情。
- 不调用 Shopee 商品同步接口。
- 从 `itemInfo.standardiseTierVariation` 提取颜色和尺码,去重并保留原顺序。
- 成功响应只包含 `data.color` 和 `data.size`。
- 缺少规格时返回空数组,不返回 `null`。
## 状态
已完成。