diff --git a/app/admin/apis/shopee_product_spec.go b/app/admin/apis/shopee_product_spec.go new file mode 100644 index 0000000..0c7d2f5 --- /dev/null +++ b/app/admin/apis/shopee_product_spec.go @@ -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 +} diff --git a/app/admin/apis/shopee_product_spec_test.go b/app/admin/apis/shopee_product_spec_test.go new file mode 100644 index 0000000..851000c --- /dev/null +++ b/app/admin/apis/shopee_product_spec_test.go @@ -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) + } +} diff --git a/app/admin/router/shopee_product.go b/app/admin/router/shopee_product.go index b8153de..c1c0345 100644 --- a/app/admin/router/shopee_product.go +++ b/app/admin/router/shopee_product.go @@ -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) diff --git a/http-client-cli_file/shopee_product_spec.http b/http-client-cli_file/shopee_product_spec.http new file mode 100644 index 0000000..b789c97 --- /dev/null +++ b/http-client-cli_file/shopee_product_spec.http @@ -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}} diff --git a/tickets/LOCAL-015-shopee-product-spec-api.md b/tickets/LOCAL-015-shopee-product-spec-api.md new file mode 100644 index 0000000..36642d1 --- /dev/null +++ b/tickets/LOCAL-015-shopee-product-spec-api.md @@ -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`。 + +## 状态 + +已完成。