add Shopee product spec endpoint
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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`。
|
||||
|
||||
## 状态
|
||||
|
||||
已完成。
|
||||
Reference in New Issue
Block a user