feat: 店铺列表与商品数据下载 (#8)
由 Codex (gpt-5.6-sol) 实施,Claude 审核。
实现以真实抓包为准,纠正了 Python 参考实现与接口文档的三处错误:
1. erp/shop/all 请求体是 userId=(空值),不是文档写的 type=1
2. product/shop/getPage 用 form 编码,不是 JSON
3. 两个接口的响应都没有 {type, message, code, bean} 包裹:
shop/all 返回裸数组,getPage 直接返回分页对象
internal/huohanhan/shop.go
- ListShops 解析裸数组,只保留 platform=="0" 的 Shopee 店铺,
店铺名去首尾空格后按名称排序
- Shop 结构体只声明界面需要的 8 个字段。货憨憨返回的
accessToken、refreshToken 是 Shopee OAuth 凭据,createUser 是手机号,
一律不解析、不记录、不存储、不返回前端;有测试用反射与 JSON
序列化双重断言
internal/huohanhan/product.go
- GetProductPage 发送完整表单,自定义 UnmarshalJSON 兼容
total/size/current/pages 既可能是数字也可能是字符串
- DownloadAllProducts 按页拉全量,带 200 页上限保护防止 pages
异常导致死循环,达上限记警告而非静默截断
app.go
- ListShops、DownloadProductData 换成真实实现
- 未选店铺时返回「请先选择店铺」。该账号有 19 个店铺,
单店铺 1256 个商品 63 页,默认拉全部会非常慢
前端
- 店铺加载失败不再静默吞掉,改为可见提示
- 「下载数据」传当前店铺 ID,完成后刷新列表
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LbdtsD3ohhSMy3KPoCgARq
This commit is contained in:
@@ -218,18 +218,39 @@ func (a *App) ExportLogs() (string, error) {
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 占位方法
|
||||
//
|
||||
// 下面这些是界面上已有按钮、但功能还没实现的方法。
|
||||
// 先返回明确的「还没实现」,比让按钮点了没反应好——
|
||||
// 同事至少知道是没做,而不是坏了。
|
||||
//
|
||||
// 实现顺序见 Wiki 需求总览:R1 下载数据 → R2/R3 淘宝登录与图搜
|
||||
// → R4 下载视频 → R5 上传数据。
|
||||
// ---------------------------------------------------------------- 商品操作
|
||||
|
||||
// DownloadProductData 从货憨憨拉取商品列表到本地(需求 R1)。
|
||||
func (a *App) DownloadProductData() error {
|
||||
return fmt.Errorf("「下载数据」还没实现,见工单 R1")
|
||||
func (a *App) DownloadProductData(platformShopID string) error {
|
||||
if a.db == nil {
|
||||
return fmt.Errorf("数据库未就绪,请查看运行日志")
|
||||
}
|
||||
if platformShopID == "" {
|
||||
return fmt.Errorf("请先选择店铺")
|
||||
}
|
||||
|
||||
client, err := a.newHuohanhanClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx := a.ctx
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
a.log.Info("开始下载所选店铺的商品数据")
|
||||
products, err := client.DownloadAllProducts(ctx, platformShopID, func(current, total int) {
|
||||
a.log.Info("已拉取 %d/%d 页", current, total)
|
||||
})
|
||||
if err != nil {
|
||||
a.log.Error("下载商品数据失败:%v", err)
|
||||
return err
|
||||
}
|
||||
if err := a.db.UpsertProducts(products, time.Now().Format("2006-01-02 15:04:05")); err != nil {
|
||||
a.log.Error("保存商品数据失败:%v", err)
|
||||
return err
|
||||
}
|
||||
a.log.Success("商品数据下载完成,共拉取 %d 条", len(products))
|
||||
return nil
|
||||
}
|
||||
|
||||
// DownloadVideos 对选中的商品搜同款并下载视频(需求 R3、R4)。
|
||||
@@ -243,15 +264,29 @@ func (a *App) UploadVideos(productIDs []string) error {
|
||||
}
|
||||
|
||||
// ListShops 返回当前账号可用的店铺(需求 R1)。
|
||||
//
|
||||
// 真正实现时要调用货憨憨的 erp/shop/all,不要在界面里写死店铺列表。
|
||||
func (a *App) ListShops() ([]Shop, error) {
|
||||
return nil, fmt.Errorf("店铺列表还没实现,见工单 R1")
|
||||
func (a *App) ListShops() ([]huohanhan.Shop, error) {
|
||||
if a.db == nil {
|
||||
return nil, fmt.Errorf("数据库未就绪,请查看运行日志")
|
||||
}
|
||||
client, err := a.newHuohanhanClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ctx := a.ctx
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
shops, err := client.ListShops(ctx)
|
||||
if err != nil {
|
||||
a.log.Error("加载店铺列表失败:%v", err)
|
||||
return nil, err
|
||||
}
|
||||
return shops, nil
|
||||
}
|
||||
|
||||
// Shop 是一个店铺,对应货憨憨 erp/shop/all 的返回。
|
||||
type Shop struct {
|
||||
PlatformShopID string `json:"platformShopId"` // 平台店铺 ID
|
||||
ShopName string `json:"shopName"` // 店铺名
|
||||
Region string `json:"region"` // 地区,例如 TW
|
||||
// newHuohanhanClient 使用当前配置创建业务客户端。
|
||||
// 每次创建可确保设置页刚保存的账号或网址立即生效。
|
||||
func (a *App) newHuohanhanClient() (*huohanhan.Client, error) {
|
||||
manager := huohanhan.NewAuthManager(a.cfg.Huohanhan, a.db, a.log, huohanhan.AuthOptions{})
|
||||
return huohanhan.NewClient(a.cfg.Huohanhan, manager, a.log, nil)
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ import { computed, onMounted, ref } from 'vue'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import {
|
||||
CountProducts,
|
||||
DownloadProductData,
|
||||
DownloadVideos,
|
||||
ListProducts,
|
||||
ListShops,
|
||||
@@ -66,8 +65,8 @@ async function loadShops() {
|
||||
value: s.platformShopId,
|
||||
}))
|
||||
} catch (err) {
|
||||
// R1 还没实现时这里必然失败,属于预期情况,不弹错误打扰使用者。
|
||||
shopOptions.value = []
|
||||
message.error(`加载店铺列表失败:${err}`)
|
||||
} finally {
|
||||
shopLoading.value = false
|
||||
}
|
||||
@@ -162,8 +161,6 @@ function reset() {
|
||||
}
|
||||
|
||||
// ---- 操作按钮 ----
|
||||
// R1~R5 还没实现,后端返回明确的「还没实现」,这里如实显示,
|
||||
// 不要假装成功,否则同事会以为点了有用。
|
||||
async function run(action, fn) {
|
||||
try {
|
||||
await fn()
|
||||
@@ -173,6 +170,16 @@ async function run(action, fn) {
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadProductData() {
|
||||
try {
|
||||
await DownloadProductData(query.value.platformShopId)
|
||||
await search(false)
|
||||
message.success(`下载完成,已拉取 ${total.value} 条商品数据`)
|
||||
} catch (err) {
|
||||
message.warning(`下载数据:${err}`)
|
||||
}
|
||||
}
|
||||
|
||||
const selectedCount = computed(() => checkedIds.value.length)
|
||||
|
||||
onMounted(() => {
|
||||
@@ -216,7 +223,7 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<n-button type="primary" @click="run('下载数据', DownloadProductData)">下载数据</n-button>
|
||||
<n-button type="primary" @click="downloadProductData">下载数据</n-button>
|
||||
<n-button @click="run('下载视频', () => DownloadVideos(checkedIds))">下载视频</n-button>
|
||||
<n-button @click="run('上传数据', () => UploadVideos(checkedIds))">上传数据</n-button>
|
||||
<n-button @click="emit('open-logs')">运行日志</n-button>
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
package huohanhan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"cmsp/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultProductPageSize = 20
|
||||
maximumProductPages = 200
|
||||
)
|
||||
|
||||
// ProductPageParams 是商品单页查询所需的可变参数。
|
||||
// 其它筛选字段由 GetProductPage 按真实网页请求补为空值。
|
||||
type ProductPageParams struct {
|
||||
Size int
|
||||
Current int
|
||||
PlatformShopID string
|
||||
}
|
||||
|
||||
// ProductRecord 是货憨憨商品响应中需要保存的字段白名单。
|
||||
type ProductRecord struct {
|
||||
ID string `json:"id"`
|
||||
ItemID string `json:"itemId"`
|
||||
ItemName string `json:"itemName"`
|
||||
MainImage string `json:"mainImage"`
|
||||
ShopName string `json:"shopName"`
|
||||
PlatformShopID string `json:"platformShopId"`
|
||||
Currency string `json:"currency"`
|
||||
MinSkuPrice float64 `json:"minSkuPrice"`
|
||||
ItemStatus string `json:"itemStatus"`
|
||||
CreateTime string `json:"createTime"`
|
||||
}
|
||||
|
||||
// ProductPage 对应货憨憨商品接口返回的裸分页对象。
|
||||
type ProductPage struct {
|
||||
Records []ProductRecord `json:"records"`
|
||||
Total int `json:"total"`
|
||||
Size int `json:"size"`
|
||||
Current int `json:"current"`
|
||||
Pages int `json:"pages"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON 兼容分页数字既可能是 JSON 数字、也可能是字符串的响应。
|
||||
func (p *ProductPage) UnmarshalJSON(data []byte) error {
|
||||
var raw struct {
|
||||
Records []ProductRecord `json:"records"`
|
||||
Total json.RawMessage `json:"total"`
|
||||
Size json.RawMessage `json:"size"`
|
||||
Current json.RawMessage `json:"current"`
|
||||
Pages json.RawMessage `json:"pages"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fields := []struct {
|
||||
name string
|
||||
raw json.RawMessage
|
||||
dest *int
|
||||
}{
|
||||
{"total", raw.Total, &p.Total},
|
||||
{"size", raw.Size, &p.Size},
|
||||
{"current", raw.Current, &p.Current},
|
||||
{"pages", raw.Pages, &p.Pages},
|
||||
}
|
||||
for _, field := range fields {
|
||||
value, err := parsePageInteger(field.raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("分页字段 %s 格式不正确:%w", field.name, err)
|
||||
}
|
||||
*field.dest = value
|
||||
}
|
||||
p.Records = raw.Records
|
||||
return nil
|
||||
}
|
||||
|
||||
func parsePageInteger(raw json.RawMessage) (int, error) {
|
||||
text := strings.TrimSpace(string(raw))
|
||||
if text == "" || text == "null" {
|
||||
return 0, nil
|
||||
}
|
||||
if len(text) >= 2 && text[0] == '"' && text[len(text)-1] == '"' {
|
||||
var decoded string
|
||||
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
text = decoded
|
||||
}
|
||||
return strconv.Atoi(text)
|
||||
}
|
||||
|
||||
// GetProductPage 按真实网页使用的 form 编码读取一页在售商品。
|
||||
func (c *Client) GetProductPage(ctx context.Context, params ProductPageParams) (ProductPage, error) {
|
||||
if params.Size <= 0 {
|
||||
params.Size = defaultProductPageSize
|
||||
}
|
||||
if params.Current <= 0 {
|
||||
params.Current = 1
|
||||
}
|
||||
|
||||
form := url.Values{
|
||||
"size": {strconv.Itoa(params.Size)},
|
||||
"current": {strconv.Itoa(params.Current)},
|
||||
"descs": {""},
|
||||
"ascs": {""},
|
||||
"itemStatus": {"NORMAL"},
|
||||
"marked": {""},
|
||||
"region": {""},
|
||||
"platform": {"0"},
|
||||
"platformShopId": {strings.TrimSpace(params.PlatformShopID)},
|
||||
"itemName": {""},
|
||||
"itemIds": {""},
|
||||
"itemSkus": {""},
|
||||
"modelSku": {""},
|
||||
"categoryId": {""},
|
||||
"hasSizeChart": {""},
|
||||
"sourceId": {""},
|
||||
"sourcePlatformCode": {""},
|
||||
"isPreOrder": {""},
|
||||
"nextDayArrive": {""},
|
||||
"createTimeStart": {""},
|
||||
"createTimeEnd": {""},
|
||||
"minSkuPrice": {""},
|
||||
"maxSkuPrice": {""},
|
||||
"minSale": {""},
|
||||
"maxSale": {""},
|
||||
"minViews": {""},
|
||||
"maxViews": {""},
|
||||
"minLikes": {""},
|
||||
"maxLikes": {""},
|
||||
"minCommentCount": {""},
|
||||
"maxCommentCount": {""},
|
||||
"minRatingStar": {""},
|
||||
"maxRatingStar": {""},
|
||||
"sortField": {"updateTime"},
|
||||
"sortType": {"desc"},
|
||||
"groupIds": {""},
|
||||
}
|
||||
response, err := c.Request(
|
||||
ctx,
|
||||
http.MethodPost,
|
||||
"product/shop/getPage",
|
||||
[]byte(form.Encode()),
|
||||
"application/x-www-form-urlencoded;charset=UTF-8",
|
||||
)
|
||||
if err != nil {
|
||||
return ProductPage{}, fmt.Errorf("读取商品第 %d 页失败:%w", params.Current, err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
var page ProductPage
|
||||
if err := json.NewDecoder(response.Body).Decode(&page); err != nil {
|
||||
return ProductPage{}, fmt.Errorf("商品分页返回的不是有效 JSON:%w", err)
|
||||
}
|
||||
return page, nil
|
||||
}
|
||||
|
||||
// DownloadAllProducts 逐页下载一个店铺的全部在售商品。
|
||||
//
|
||||
// 最多请求 200 页。服务端分页异常时返回已取得的数据并写警告日志,
|
||||
// 避免桌面程序陷入无法结束的循环。
|
||||
func (c *Client) DownloadAllProducts(ctx context.Context, platformShopID string, onProgress func(current, total int)) ([]store.Product, error) {
|
||||
platformShopID = strings.TrimSpace(platformShopID)
|
||||
if platformShopID == "" {
|
||||
return nil, fmt.Errorf("请先选择店铺")
|
||||
}
|
||||
|
||||
products := make([]store.Product, 0)
|
||||
lastCurrent := 0
|
||||
lastPages := 0
|
||||
for requestedPage := 1; requestedPage <= maximumProductPages; requestedPage++ {
|
||||
page, err := c.GetProductPage(ctx, ProductPageParams{
|
||||
Size: defaultProductPageSize,
|
||||
Current: requestedPage,
|
||||
PlatformShopID: platformShopID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, record := range page.Records {
|
||||
products = append(products, store.Product{
|
||||
ID: record.ID,
|
||||
ItemID: record.ItemID,
|
||||
ItemName: record.ItemName,
|
||||
MainImage: record.MainImage,
|
||||
ShopName: record.ShopName,
|
||||
PlatformShopID: record.PlatformShopID,
|
||||
Currency: record.Currency,
|
||||
MinSkuPrice: record.MinSkuPrice,
|
||||
ItemStatus: record.ItemStatus,
|
||||
CreatedAt: record.CreateTime,
|
||||
})
|
||||
}
|
||||
|
||||
lastCurrent = page.Current
|
||||
lastPages = page.Pages
|
||||
if onProgress != nil {
|
||||
onProgress(page.Current, page.Pages)
|
||||
}
|
||||
if page.Current >= page.Pages {
|
||||
return products, nil
|
||||
}
|
||||
}
|
||||
|
||||
if lastCurrent < lastPages {
|
||||
c.log.Warn("商品分页超过 %d 页上限,已停止拉取;服务端报告进度 %d/%d 页", maximumProductPages, lastCurrent, lastPages)
|
||||
}
|
||||
return products, nil
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package huohanhan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cmsp/internal/logx"
|
||||
)
|
||||
|
||||
func Test商品分页兼容字符串数字并发送完整表单(t *testing.T) {
|
||||
client := newBusinessTestClient(t, logx.New(100), func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/product/shop/getPage" {
|
||||
t.Errorf("商品接口路径不正确:%s", r.URL.Path)
|
||||
}
|
||||
if !strings.HasPrefix(r.Header.Get("Content-Type"), "application/x-www-form-urlencoded") {
|
||||
t.Errorf("商品请求必须使用 form 编码,实际 %q", r.Header.Get("Content-Type"))
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
t.Fatalf("解析商品请求表单失败:%v", err)
|
||||
}
|
||||
if r.Form.Get("platformShopId") != "1664202094" || r.Form.Get("current") != "2" || r.Form.Get("size") != "20" {
|
||||
t.Errorf("商品分页参数不正确:%v", r.Form)
|
||||
}
|
||||
if r.Form.Get("platform") != "0" || r.Form.Get("itemStatus") != "NORMAL" || r.Form.Get("sortField") != "updateTime" || r.Form.Get("sortType") != "desc" {
|
||||
t.Errorf("商品固定筛选参数不正确:%v", r.Form)
|
||||
}
|
||||
for _, name := range []string{"descs", "ascs", "marked", "region", "itemName", "itemIds", "itemSkus", "modelSku", "categoryId", "hasSizeChart", "sourceId", "sourcePlatformCode", "isPreOrder", "nextDayArrive", "createTimeStart", "createTimeEnd", "minSkuPrice", "maxSkuPrice", "minSale", "maxSale", "minViews", "maxViews", "minLikes", "maxLikes", "minCommentCount", "maxCommentCount", "minRatingStar", "maxRatingStar", "groupIds"} {
|
||||
if _, exists := r.Form[name]; !exists {
|
||||
t.Errorf("商品请求缺少空表单字段 %s", name)
|
||||
}
|
||||
}
|
||||
writeJSON(w, map[string]any{
|
||||
"records": []any{}, "total": "1256", "size": "20",
|
||||
"current": "2", "pages": "63",
|
||||
})
|
||||
})
|
||||
|
||||
page, err := client.GetProductPage(context.Background(), ProductPageParams{
|
||||
Size: 20, Current: 2, PlatformShopID: "1664202094",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("读取商品分页失败:%v", err)
|
||||
}
|
||||
if page.Total != 1256 || page.Size != 20 || page.Current != 2 || page.Pages != 63 {
|
||||
t.Fatalf("字符串分页数字解析不正确:%+v", page)
|
||||
}
|
||||
}
|
||||
|
||||
func Test商品下载拉完三页并正确转换主键(t *testing.T) {
|
||||
calls := 0
|
||||
client := newBusinessTestClient(t, logx.New(100), func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
t.Fatalf("解析商品表单失败:%v", err)
|
||||
}
|
||||
current, _ := strconv.Atoi(r.Form.Get("current"))
|
||||
calls++
|
||||
writeJSON(w, map[string]any{
|
||||
"records": []map[string]any{{
|
||||
"id": "hhh-" + strconv.Itoa(current),
|
||||
"itemId": "shopee-" + strconv.Itoa(current),
|
||||
"itemName": "商品", "mainImage": "https://example.invalid/image.jpg",
|
||||
"shopName": "测试店铺", "platformShopId": "1664202094",
|
||||
"currency": "TWD", "minSkuPrice": 88.5,
|
||||
"itemStatus": "NORMAL", "createTime": "2026-08-31 01:54:08",
|
||||
}},
|
||||
"total": 3, "size": 20, "current": current, "pages": 3,
|
||||
})
|
||||
})
|
||||
|
||||
var progress []int
|
||||
products, err := client.DownloadAllProducts(context.Background(), "1664202094", func(current, total int) {
|
||||
if total != 3 {
|
||||
t.Errorf("总页数应为 3,实际 %d", total)
|
||||
}
|
||||
progress = append(progress, current)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("下载全部商品失败:%v", err)
|
||||
}
|
||||
if calls != 3 || len(progress) != 3 || len(products) != 3 {
|
||||
t.Fatalf("应完整拉取 3 页,实际请求 %d 次、进度 %v、商品 %d 条", calls, progress, len(products))
|
||||
}
|
||||
first := products[0]
|
||||
if first.ID != "hhh-1" || first.ItemID != "shopee-1" {
|
||||
t.Fatalf("id 和 itemId 映射错误:ID=%q ItemID=%q", first.ID, first.ItemID)
|
||||
}
|
||||
if first.CreatedAt != "2026-08-31 01:54:08" || first.MinSkuPrice != 88.5 {
|
||||
t.Fatalf("商品字段转换不完整:%+v", first)
|
||||
}
|
||||
}
|
||||
|
||||
func Test商品分页超过二百页时警告并停止(t *testing.T) {
|
||||
calls := 0
|
||||
logger := logx.New(500)
|
||||
client := newBusinessTestClient(t, logger, func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
t.Fatalf("解析商品表单失败:%v", err)
|
||||
}
|
||||
current, _ := strconv.Atoi(r.Form.Get("current"))
|
||||
calls++
|
||||
writeJSON(w, map[string]any{
|
||||
"records": []any{}, "total": 99999, "size": 20,
|
||||
"current": current, "pages": 99999,
|
||||
})
|
||||
})
|
||||
|
||||
products, err := client.DownloadAllProducts(context.Background(), "1664202094", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("达到分页保护上限不应报错:%v", err)
|
||||
}
|
||||
if calls != maximumProductPages {
|
||||
t.Fatalf("最多应请求 %d 页,实际 %d 页", maximumProductPages, calls)
|
||||
}
|
||||
if len(products) != 0 {
|
||||
t.Fatalf("假服务未返回商品,实际得到 %d 条", len(products))
|
||||
}
|
||||
if !strings.Contains(logger.Text(), "超过 200 页上限") {
|
||||
t.Fatalf("达到上限必须写警告日志,实际日志:%s", logger.Text())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package huohanhan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Shop 是界面选择商品来源时需要的店铺信息。
|
||||
//
|
||||
// 货憨憨响应还包含 OAuth token 和手机号等敏感字段。这里刻意只声明
|
||||
// 界面需要的白名单字段,避免凭据进入内存模型、日志、SQLite 或前端。
|
||||
type Shop struct {
|
||||
ID string `json:"id"`
|
||||
ShopName string `json:"shopName"`
|
||||
ShopAlias string `json:"shopAlias"`
|
||||
Region string `json:"region"`
|
||||
RegionName string `json:"regionName"`
|
||||
Platform string `json:"platform"`
|
||||
PlatformShopID string `json:"platformShopId"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// ListShops 读取当前账号的 Shopee 店铺,并按店铺名排序。
|
||||
func (c *Client) ListShops(ctx context.Context) ([]Shop, error) {
|
||||
form := url.Values{"userId": {""}}
|
||||
response, err := c.Request(
|
||||
ctx,
|
||||
http.MethodPost,
|
||||
"erp/shop/all",
|
||||
[]byte(form.Encode()),
|
||||
"application/x-www-form-urlencoded",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取店铺列表失败:%w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
var payload []Shop
|
||||
if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
|
||||
return nil, fmt.Errorf("店铺列表返回的不是有效 JSON:%w", err)
|
||||
}
|
||||
|
||||
shops := make([]Shop, 0, len(payload))
|
||||
for _, shop := range payload {
|
||||
if shop.Platform != "0" {
|
||||
continue
|
||||
}
|
||||
shop.ShopName = strings.TrimSpace(shop.ShopName)
|
||||
shops = append(shops, shop)
|
||||
}
|
||||
sort.Slice(shops, func(i, j int) bool {
|
||||
return shops[i].ShopName < shops[j].ShopName
|
||||
})
|
||||
return shops, nil
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package huohanhan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cmsp/internal/logx"
|
||||
)
|
||||
|
||||
func newBusinessTestClient(t *testing.T, logger *logx.Logger, handler http.HandlerFunc) *Client {
|
||||
t.Helper()
|
||||
backend := newFakeLoginBackend(t)
|
||||
manager := newTestAuthManager(t, backend, newTestStore(t), logger, 3)
|
||||
if _, err := manager.ForceLogin(context.Background()); err != nil {
|
||||
t.Fatalf("准备测试认证失败:%v", err)
|
||||
}
|
||||
backend.businessHandler = handler
|
||||
client, err := NewClient(manager.cfg, manager, logger, backend.server.Client())
|
||||
if err != nil {
|
||||
t.Fatalf("创建测试业务客户端失败:%v", err)
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
func Test店铺列表解析裸数组并过滤排序(t *testing.T) {
|
||||
client := newBusinessTestClient(t, logx.New(100), func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/erp/shop/all" {
|
||||
t.Errorf("店铺接口路径不正确:%s", r.URL.Path)
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
t.Fatalf("解析店铺请求表单失败:%v", err)
|
||||
}
|
||||
if _, exists := r.Form["userId"]; !exists || r.Form.Get("userId") != "" {
|
||||
t.Errorf("店铺请求必须包含空 userId,实际表单:%v", r.Form)
|
||||
}
|
||||
if _, exists := r.Form["type"]; exists {
|
||||
t.Errorf("店铺请求不应包含 Python 版的 type 参数")
|
||||
}
|
||||
writeJSON(w, []map[string]any{
|
||||
{
|
||||
"id": "shop-2", "shopName": "B店铺 ", "shopAlias": "乙",
|
||||
"region": "TW", "regionName": "台湾", "platform": "0",
|
||||
"platformShopId": "200", "status": "NORMAL",
|
||||
"accessToken": "fake-oauth-access", "refreshToken": "fake-oauth-refresh",
|
||||
"createUser": "13000000000",
|
||||
},
|
||||
{
|
||||
"id": "other", "shopName": "其它平台", "platform": "1",
|
||||
"platformShopId": "999", "status": "NORMAL",
|
||||
},
|
||||
{
|
||||
"id": "shop-1", "shopName": "A店铺", "shopAlias": "甲",
|
||||
"region": "TW", "regionName": "台湾", "platform": "0",
|
||||
"platformShopId": "100", "status": "NORMAL",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
shops, err := client.ListShops(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("读取店铺失败:%v", err)
|
||||
}
|
||||
if len(shops) != 2 {
|
||||
t.Fatalf("应只保留 2 个 Shopee 店铺,实际 %d 个", len(shops))
|
||||
}
|
||||
if shops[0].ShopName != "A店铺" || shops[1].ShopName != "B店铺" {
|
||||
t.Fatalf("店铺应去掉尾部空格并按名称排序,实际:%v", shops)
|
||||
}
|
||||
}
|
||||
|
||||
func Test店铺结构不包含凭据和手机号字段(t *testing.T) {
|
||||
typ := reflect.TypeOf(Shop{})
|
||||
for _, forbidden := range []string{"accessToken", "refreshToken", "createUser"} {
|
||||
for i := 0; i < typ.NumField(); i++ {
|
||||
field := typ.Field(i)
|
||||
if field.Name == forbidden || strings.Split(field.Tag.Get("json"), ",")[0] == forbidden {
|
||||
t.Fatalf("Shop 不得声明敏感字段 %s", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
encoded, err := json.Marshal(Shop{ID: "fake-shop", ShopName: "测试店铺"})
|
||||
if err != nil {
|
||||
t.Fatalf("序列化店铺失败:%v", err)
|
||||
}
|
||||
for _, forbidden := range []string{"accessToken", "refreshToken", "createUser"} {
|
||||
if strings.Contains(string(encoded), forbidden) {
|
||||
t.Fatalf("店铺 JSON 不得包含敏感字段 %s:%s", forbidden, encoded)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user