feat: sync shopee product before detail

This commit is contained in:
QiuSW
2026-09-14 08:49:53 +08:00
parent 69b20aa38e
commit 8491c5bfef
3 changed files with 148 additions and 0 deletions
+29
View File
@@ -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"), "商品详情请求失败")
+82
View File
@@ -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 秒,
+37
View File
@@ -0,0 +1,37 @@
# LOCAL-007:同步 Shopee 商品后获取最新详情
- 状态:待验收
- 来源:用户需求(2026-09-14)及 `payloads/sync_shope_product.har`
- 前置:LOCAL-002、LOCAL-006
## 目标
查询商品详情前,先调用货憨憨的 Shopee 商品同步接口,等待异步同步任务完成,再重新定位商品并获取最新 detail。
## 范围
- 使用首次 `getPage(itemIds=<Shopee ID>)` 返回的店铺 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` 数据,并确认失败时原有数据库记录仍然存在。