From 5524fe958c305b8d3a6c068d74dc3b76313da93e Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Wed, 2 Sep 2026 17:16:41 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=BA=97=E9=93=BA=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E4=B8=8E=E5=95=86=E5=93=81=E6=95=B0=E6=8D=AE=E4=B8=8B=E8=BD=BD?= =?UTF-8?q?=20(#8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 由 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 Claude-Session: https://claude.ai/code/session_01LbdtsD3ohhSMy3KPoCgARq --- app.go | 73 ++++++--- frontend/src/views/ProductListView.vue | 17 +- internal/huohanhan/product.go | 218 +++++++++++++++++++++++++ internal/huohanhan/product_test.go | 123 ++++++++++++++ internal/huohanhan/shop.go | 60 +++++++ internal/huohanhan/shop_test.go | 95 +++++++++++ 6 files changed, 562 insertions(+), 24 deletions(-) create mode 100644 internal/huohanhan/product.go create mode 100644 internal/huohanhan/product_test.go create mode 100644 internal/huohanhan/shop.go create mode 100644 internal/huohanhan/shop_test.go 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(() => {
- 下载数据 + 下载数据 下载视频 上传数据 运行日志 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) + } + } +}