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:
QiuSW
2026-09-02 17:16:41 +08:00
co-authored by Claude Opus 5
parent d8e90789a2
commit 5524fe958c
6 changed files with 562 additions and 24 deletions
+54 -19
View File
@@ -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)
}
+12 -5
View File
@@ -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>
+218
View File
@@ -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
}
+123
View File
@@ -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())
}
}
+60
View File
@@ -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
}
+95
View File
@@ -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)
}
}
}