diff --git a/app.go b/app.go
index b3488a4..b56434e 100644
--- a/app.go
+++ b/app.go
@@ -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)
}
diff --git a/frontend/src/views/ProductListView.vue b/frontend/src/views/ProductListView.vue
index 28b1755..116e020 100644
--- a/frontend/src/views/ProductListView.vue
+++ b/frontend/src/views/ProductListView.vue
@@ -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(() => {
- 下载数据
+ 下载数据
DownloadVideos(checkedIds))">下载视频
UploadVideos(checkedIds))">上传数据
运行日志
diff --git a/internal/huohanhan/product.go b/internal/huohanhan/product.go
new file mode 100644
index 0000000..9114ebb
--- /dev/null
+++ b/internal/huohanhan/product.go
@@ -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
+}
diff --git a/internal/huohanhan/product_test.go b/internal/huohanhan/product_test.go
new file mode 100644
index 0000000..b675e20
--- /dev/null
+++ b/internal/huohanhan/product_test.go
@@ -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())
+ }
+}
diff --git a/internal/huohanhan/shop.go b/internal/huohanhan/shop.go
new file mode 100644
index 0000000..9d628a6
--- /dev/null
+++ b/internal/huohanhan/shop.go
@@ -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
+}
diff --git a/internal/huohanhan/shop_test.go b/internal/huohanhan/shop_test.go
new file mode 100644
index 0000000..027dc01
--- /dev/null
+++ b/internal/huohanhan/shop_test.go
@@ -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)
+ }
+ }
+}