From 8491c5bfefdd36f0629693f33f26bcb18e9dd150 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Mon, 14 Sep 2026 08:49:53 +0800 Subject: [PATCH] feat: sync shopee product before detail --- app/admin/apis/shopee_product.go | 29 +++++++++ internal/huohanhan/product.go | 82 ++++++++++++++++++++++++ tickets/LOCAL-007-shopee-product-sync.md | 37 +++++++++++ 3 files changed, 148 insertions(+) create mode 100644 tickets/LOCAL-007-shopee-product-sync.md diff --git a/app/admin/apis/shopee_product.go b/app/admin/apis/shopee_product.go index 6334a84..2946270 100644 --- a/app/admin/apis/shopee_product.go +++ b/app/admin/apis/shopee_product.go @@ -7,6 +7,7 @@ import ( "fmt" "strconv" "strings" + "time" "github.com/gin-gonic/gin" "github.com/go-admin-team/go-admin-core/sdk" @@ -92,6 +93,34 @@ func (e ShopeeProduct) getByShopeeID(c *gin.Context, requireAPIKey bool) { e.Error(404, fmt.Errorf("product not found"), "未找到商品") return } + if _, err := client.SyncShopProduct(c.Request.Context(), *record); err != nil { + e.Error(502, err, "Shopee 商品同步失败") + return + } + if _, err := client.WaitForProductSync(c.Request.Context(), record.ID, time.Second); err != nil { + e.Error(502, err, "Shopee 商品同步失败") + return + } + // The sync task updates the ERP asynchronously. Locate the record again so + // getDetail uses the current Huohanhan internal ID and metadata. + page, err = client.GetProductPage(c.Request.Context(), huohanhan.ProductPageParams{ + Size: 1, Current: 1, ItemIDs: want, IncludeAllStatuses: true, + }) + if err != nil { + e.Error(502, err, "同步后读取商品失败") + return + } + record = nil + for i := range page.Records { + if strings.TrimSpace(page.Records[i].ItemID) == want { + record = &page.Records[i] + break + } + } + if record == nil { + e.Error(404, fmt.Errorf("product not found after sync"), "同步后未找到商品") + return + } internalID := strings.TrimSpace(record.ID) if internalID == "" { e.Error(502, fmt.Errorf("商品列表没有返回货憨憨内部商品 ID"), "商品详情请求失败") diff --git a/internal/huohanhan/product.go b/internal/huohanhan/product.go index 7f5ec8c..400288e 100644 --- a/internal/huohanhan/product.go +++ b/internal/huohanhan/product.go @@ -8,10 +8,92 @@ import ( "net/url" "strconv" "strings" + "time" "go-admin/internal/store" ) +type ProductSyncTask struct{ TaskID string } + +type ProductSyncStatus struct { + Status int `json:"status"` + Processing int `json:"processing"` + Tip string `json:"tip"` + Success int `json:"success"` + Fail int `json:"fail"` + FailShopErrMsg []string `json:"failShopErrMsg"` +} + +// SyncShopProduct requests a fresh import from Shopee. The returned task is +// asynchronous; callers should wait with WaitForProductSync. +func (c *Client) SyncShopProduct(ctx context.Context, record ProductRecord) (ProductSyncTask, error) { + payload := map[string]any{"platform": "0", "platformShopIds": strings.TrimSpace(record.PlatformShopID), "productIds": strings.TrimSpace(record.ID), "itemIds": strings.TrimSpace(record.ItemID), "isGlobal": false} + body, err := json.Marshal(payload) + if err != nil { + return ProductSyncTask{}, err + } + resp, err := c.Request(ctx, http.MethodPost, "product/common-product/syncShopProduct", body, "application/json") + if err != nil { + return ProductSyncTask{}, fmt.Errorf("发起 Shopee 商品同步失败:%w", err) + } + defer resp.Body.Close() + var result struct { + Code json.RawMessage `json:"code"` + Message string `json:"message"` + Bean string `json:"bean"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return ProductSyncTask{}, fmt.Errorf("同步响应格式错误:%w", err) + } + if stringFromRawJSON(result.Code) != "200" || strings.TrimSpace(result.Bean) == "" { + return ProductSyncTask{}, fmt.Errorf("发起 Shopee 商品同步失败:%s", result.Message) + } + return ProductSyncTask{TaskID: result.Bean}, nil +} + +func (c *Client) GetProductSyncStatus(ctx context.Context, taskID string) (ProductSyncStatus, error) { + form := url.Values{"taskId": {strings.TrimSpace(taskID)}} + resp, err := c.Request(ctx, http.MethodPost, "product/product-task/getProductTask", []byte(form.Encode()), "application/x-www-form-urlencoded;charset=UTF-8") + if err != nil { + return ProductSyncStatus{}, fmt.Errorf("读取商品同步状态失败:%w", err) + } + defer resp.Body.Close() + var status ProductSyncStatus + if err := json.NewDecoder(resp.Body).Decode(&status); err != nil { + return ProductSyncStatus{}, fmt.Errorf("同步状态格式错误:%w", err) + } + return status, nil +} + +// WaitForProductSync polls the asynchronous task until it completes. +func (c *Client) WaitForProductSync(ctx context.Context, taskID string, interval time.Duration) (ProductSyncStatus, error) { + if strings.TrimSpace(taskID) == "" { + return ProductSyncStatus{}, fmt.Errorf("同步任务 ID 不能为空") + } + if interval <= 0 { + interval = time.Second + } + for { + status, err := c.GetProductSyncStatus(ctx, taskID) + if err != nil { + return ProductSyncStatus{}, err + } + if status.Processing == 0 { + if status.Status != 2 || status.Fail > 0 { + return status, fmt.Errorf("Shopee 商品同步失败:%s", status.Tip) + } + return status, nil + } + timer := time.NewTimer(interval) + select { + case <-ctx.Done(): + timer.Stop() + return ProductSyncStatus{}, ctx.Err() + case <-timer.C: + } + } +} + const ( // 每页条数。实测该接口 size=500 也能返回,但 200 是速度与响应体积的 // 平衡点:某个 4304 商品的店铺,size=20 要 216 页约 108 秒, diff --git a/tickets/LOCAL-007-shopee-product-sync.md b/tickets/LOCAL-007-shopee-product-sync.md new file mode 100644 index 0000000..b3e70d2 --- /dev/null +++ b/tickets/LOCAL-007-shopee-product-sync.md @@ -0,0 +1,37 @@ +# LOCAL-007:同步 Shopee 商品后获取最新详情 + +- 状态:待验收 +- 来源:用户需求(2026-09-14)及 `payloads/sync_shope_product.har` +- 前置:LOCAL-002、LOCAL-006 + +## 目标 + +查询商品详情前,先调用货憨憨的 Shopee 商品同步接口,等待异步同步任务完成,再重新定位商品并获取最新 detail。 + +## 范围 + +- 使用首次 `getPage(itemIds=)` 返回的店铺 ID、货憨憨内部商品 ID和 Shopee ID 发起同步; +- 调用 `POST /api/product/common-product/syncShopProduct`; +- 轮询 `POST /api/product/product-task/getProductTask`,直到同步完成; +- 同步完成后重新调用 `getPage`,再调用 `getDetail` 并保存完整响应; +- 同步失败、任务失败、认证失败和超时返回明确错误。 + +## 验收 + +- 有效 Shopee ID 查询时,网络顺序为:首次定位、同步、任务状态轮询、再次定位、detail; +- detail 数据来自同步后的记录,仍包含 `bean.itemInfo`; +- 同步失败不会覆盖已有本地 detail; +- 轮询遵守请求上下文,取消或超时可以结束; +- 相关 Go 测试通过,抓包文件不提交到 Git。 + +## 实现记录 + +- `SyncShopProduct` 使用 JSON 请求体,返回异步任务 ID; +- `GetProductSyncStatus` 使用表单请求读取任务状态; +- `WaitForProductSync` 默认每秒轮询,只有 `status=2` 且 `processing=0`、无失败项才成功; +- Admin 和 API Key 查询路径共用同步流程。 + +## 待验收 + +使用一个可访问的 Shopee ID 查询,确认同步接口和任务轮询完成后,详情窗口显示最新的 +`bean.itemInfo` 数据,并确认失败时原有数据库记录仍然存在。