feat: 蝦皮数据页分页与状态筛选 (#43)

导入真实样本后 /shopee 一次吐 3.4MB / 5195 行。但只加分页会把问题从
「5195 行糊在一起」变成「260 页里藏着 6 个」——那 6 个待补规格的商品
仍然找不到。所以分页和状态筛选一起做。

HTML 3.4MB → 16.7KB。

状态条显示全量而不是本页:「共 5195 个商品 · 第 1/260 页」。
显示「共 20 个商品」会让操作员以为总共就 20 个。筛选后显示筛选结果
总数:「待补规格:6 个商品」。

列表查询和 COUNT 共用同一套筛选条件拼装。分开写两份 WHERE,迟早
有天忘了给 COUNT 也加条件,页码算错而且没人发现(#19 踩过一次)。

page 越界兜到最后一页而不是显示空表格——空表格会让操作员以为数据没了。
总数为 0 时显示「第 1/1 页」,不出现「第 1/0 页」。

「待补规格」用 EXISTS 不用 JOIN+DISTINCT:一个商品有多个失败 SKU 时
JOIN 会出重复行,DISTINCT 又让 LIMIT/OFFSET 的行为难推理。

分页控件是 <a href> 纯 GET,浏览器前进后退和书签都正常。首末页用
<span class="disabled"> 禁用,语义上不再是链接,不只靠颜色区分。

这是全项目第一个分页页面,通用逻辑单独放 service/pagination.go 供
后面四页复用,规则写进 05 §3.2 而不是蝦皮页那一节(#34 踩过这个错)。
05 §3 的每页条数从「建议 50」改为「统一 20」并写明理由。

实现踩到 html/template 的 URL 上下文转义:夹在字面量 & 中间的动态内容
会被整体当成一个参数值转义,?/= 变成 %3F/%3D 让链接失效。改为在 Go 里
把整段 URL 拼好,模板作为单个 pipeline 输出,并加了回归测试。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
chengma
2026-08-08 11:40:07 +08:00
co-authored by Claude Opus 5
parent 033bef0614
commit edfe39cc35
11 changed files with 734 additions and 42 deletions
+106
View File
@@ -0,0 +1,106 @@
// 分页的通用规则。
//
// `[必须]` 这是全项目第一个做分页的页面(蝦皮数据页,工单 #43),
// 这里定的规则和常量供后面四个页面照抄,不要各写一份——分开写的话,
// 每页条数、越界兜底这些规则五个页面迟早会不一致。
package service
import (
"strconv"
"strings"
)
// PageSize 是全项目统一的每页条数。
//
// `[必须]` 固定 20:见 docs/admin/05-ui-specification.md §3.2——
// 20 行在 1366×768 上正好一屏不用滚动。
const PageSize = 20
// ParsePage 解析 URL 上的 page 查询参数。
//
// `[必须]` 认不出来的一律当作第 1 页,不报错——用户手改地址栏或者用旧书签
// 不该把页面搞崩(工单 #43)。真正的越界(超过总页数)由 ClampPage 兜底,
// 这里只处理"不是正整数"的情况。
func ParsePage(s string) int {
n, err := strconv.Atoi(strings.TrimSpace(s))
if err != nil || n < 1 {
return 1
}
return n
}
// TotalPages 按 PageSize 把总数换算成总页数。
//
// `[必须]` 总数为 0 时返回 1,不是 0——页面要显示"第 1/1 页",
// 不能出现"第 1/0 页"(工单 #43)。
func TotalPages(total int) int {
if total <= 0 {
return 1
}
return (total + PageSize - 1) / PageSize
}
// ClampPage 把请求的页码限制在 [1, totalPages] 范围内。
//
// `[必须]` page 超过总页数时兜到最后一页(显示有数据的那一页),
// 不是显示空表格——操作员看到空表格会以为数据没了(工单 #43)。
func ClampPage(page, totalPages int) int {
if page < 1 {
return 1
}
if page > totalPages {
return totalPages
}
return page
}
// PaginationView 是分页控件要显示的全部内容,模板不做判断和算术。
//
// `[必须]` FirstURL/PrevURL/NextURL/LastURL 是**整段拼好的**相对链接
// (形如 "?status=no_link&page=3"),模板里必须整体作为单个 pipeline 输出
// (`<a href="{{.FirstURL}}">`),不要在模板里把筛选参数和 page 分开拼接。
// html/template 的上下文转义规则对"字面量 & 中间插一段动态内容"的情况,
// 会把动态内容当成单个参数值整体转义,问号和等号会被转成 %3F/%3D,
// 链接直接失效——这是本工单实测踩到的一个坑,见 pagination_test.go。
type PaginationView struct {
Page int
TotalPages int
HasPrev bool
HasNext bool
FirstURL string
PrevURL string
NextURL string
LastURL string
}
// NewPaginationView 根据当前页、总页数和筛选查询串组装分页控件视图。
//
// baseQuery 是不含 page 的查询串(例如 url.Values{"status": {"no_link"}}.Encode()
// 的结果),由调用方(handler)负责把当前筛选和关键词编码进去——
// 这样翻页时筛选和关键词才不会丢,见工单 #43。
func NewPaginationView(page, totalPages int, baseQuery string) PaginationView {
v := PaginationView{
Page: page,
TotalPages: totalPages,
HasPrev: page > 1,
HasNext: page < totalPages,
FirstURL: PaginationURL(baseQuery, 1),
LastURL: PaginationURL(baseQuery, totalPages),
}
if v.HasPrev {
v.PrevURL = PaginationURL(baseQuery, page-1)
}
if v.HasNext {
v.NextURL = PaginationURL(baseQuery, page+1)
}
return v
}
// PaginationURL 把筛选查询串和目标页码拼成一个完整的相对链接("?...")。
func PaginationURL(baseQuery string, page int) string {
q := "page=" + strconv.Itoa(page)
if baseQuery != "" {
q = baseQuery + "&" + q
}
return "?" + q
}
+111
View File
@@ -0,0 +1,111 @@
package service
import "testing"
func TestParsePage_非法值当作第1页(t *testing.T) {
cases := []string{"", "abc", "0", "-1", "-100", " "}
for _, c := range cases {
if got := ParsePage(c); got != 1 {
t.Errorf("ParsePage(%q) = %d,想要 1", c, got)
}
}
}
func TestParsePage_合法值原样返回(t *testing.T) {
cases := map[string]int{"1": 1, "2": 2, "9999": 9999, " 3 ": 3}
for in, want := range cases {
if got := ParsePage(in); got != want {
t.Errorf("ParsePage(%q) = %d,想要 %d", in, got, want)
}
}
}
func TestTotalPages_总数为0返回1不是0(t *testing.T) {
if got := TotalPages(0); got != 1 {
t.Errorf("TotalPages(0) = %d,想要 1(不能出现『第 1/0 页』)", got)
}
}
func TestTotalPages_按PageSize向上取整(t *testing.T) {
cases := map[int]int{
1: 1,
PageSize: 1,
PageSize + 1: 2,
PageSize * 2: 2,
5195: 260, // 工单 #43 实测样本量:5195 条 / 20 条一页 = 260 页
}
for total, want := range cases {
if got := TotalPages(total); got != want {
t.Errorf("TotalPages(%d) = %d,想要 %d", total, got, want)
}
}
}
func TestClampPage_越界兜到最后一页(t *testing.T) {
if got := ClampPage(9999, 260); got != 260 {
t.Errorf("ClampPage(9999, 260) = %d,想要 260(兜到最后一页,不是空表格)", got)
}
}
func TestClampPage_小于1当作第1页(t *testing.T) {
for _, p := range []int{0, -1, -100} {
if got := ClampPage(p, 260); got != 1 {
t.Errorf("ClampPage(%d, 260) = %d,想要 1", p, got)
}
}
}
func TestClampPage_合法范围原样返回(t *testing.T) {
if got := ClampPage(3, 260); got != 3 {
t.Errorf("ClampPage(3, 260) = %d,想要 3", got)
}
}
func TestNewPaginationView_首页禁用上一页和首页(t *testing.T) {
v := NewPaginationView(1, 260, "")
if v.HasPrev {
t.Error("第 1 页时 HasPrev 应为 false(首页/上一页要禁用)")
}
if !v.HasNext {
t.Error("第 1 页且总页数 > 1 时 HasNext 应为 true")
}
}
func TestNewPaginationView_末页禁用下一页和末页(t *testing.T) {
v := NewPaginationView(260, 260, "")
if v.HasPrev != true {
t.Error("最后一页时 HasPrev 应为 true(前面还有页)")
}
if v.HasNext {
t.Error("最后一页时 HasNext 应为 false(下一页/末页要禁用)")
}
}
func TestNewPaginationView_总数为0时首尾都禁用(t *testing.T) {
v := NewPaginationView(1, 1, "")
if v.HasPrev || v.HasNext {
t.Errorf("只有 1 页时前后都应禁用,HasPrev=%v HasNext=%v", v.HasPrev, v.HasNext)
}
}
func TestNewPaginationView_URL带上筛选条件(t *testing.T) {
v := NewPaginationView(2, 260, "status=no_link")
if v.PrevURL != "?status=no_link&page=1" {
t.Errorf("PrevURL = %q,想要 ?status=no_link&page=1", v.PrevURL)
}
if v.NextURL != "?status=no_link&page=3" {
t.Errorf("NextURL = %q,想要 ?status=no_link&page=3", v.NextURL)
}
if v.FirstURL != "?status=no_link&page=1" {
t.Errorf("FirstURL = %q,想要 ?status=no_link&page=1", v.FirstURL)
}
if v.LastURL != "?status=no_link&page=260" {
t.Errorf("LastURL = %q,想要 ?status=no_link&page=260", v.LastURL)
}
}
func TestPaginationURL_无筛选条件时只有page(t *testing.T) {
if got := PaginationURL("", 3); got != "?page=3" {
t.Errorf("PaginationURL(\"\", 3) = %q,想要 ?page=3", got)
}
}
+82 -9
View File
@@ -5,6 +5,7 @@ package service
import (
"database/sql"
"strings"
"cmautobuy/admin/model"
"cmautobuy/admin/repository"
@@ -42,32 +43,62 @@ type ShopeeProductView struct {
// ShopeeListResult 是列表页要的全部数据。
type ShopeeListResult struct {
Rows []ShopeeProductView
Total int // shopee_products 的总商品数(不受筛选影响),用于判断"是否已导入过"
Rows []ShopeeProductView
// Total 是**当前筛选条件下**的商品总数,不是本页行数。
// `[必须]` 底部状态条必须显示这个,不能显示本页行数(20),
// 否则操作员会以为总共就这么多,见工单 #43。
Total int
// HasAnyProducts 是 shopee_products 的全库总数是否大于 0,不受筛选影响,
// 只用于区分空状态文案:"从没导入过" vs "筛选没结果"。
HasAnyProducts bool
IsFiltered bool
Page int
PageSize int
TotalPages int
}
// ListShopeeProducts 查蝦皮商品列表(商品级一行)并把每一行翻成界面文字。
// ListShopeeProducts 按筛选条件分页查蝦皮商品列表(商品级一行)并把每一行翻成界面文字。
//
// 采集状态来自 pdd_products(联查得到),不是蝦皮自己的字段,
// 见 docs/admin/01-requirements.md §6.1:
//
// shopee_products.pdd_goods_id 为空 -> "未填链接"
// pdd_products.collect_status -> 未采集 / 采集中 / 已采集 / 采集失败
func ListShopeeProducts(db *sql.DB, keyword string) (*ShopeeListResult, error) {
rows, err := repository.ListShopeeProducts(db, keyword)
//
// `[必须]` Total 用和 ListShopeeProducts 同一套筛选条件的 COUNT
// (repository.CountShopeeProductsFiltered),分页页数据此算出,
// page 越界时兜到最后一页,见 service/pagination.go(工单 #43)。
func ListShopeeProducts(db *sql.DB, filter repository.ShopeeFilter, page int) (*ShopeeListResult, error) {
total, err := repository.CountShopeeProductsFiltered(db, filter)
if err != nil {
return nil, err
}
total, err := repository.CountShopeeProducts(db)
totalPages := TotalPages(total)
page = ClampPage(page, totalPages)
offset := (page - 1) * PageSize
rows, err := repository.ListShopeeProducts(db, filter, PageSize, offset)
if err != nil {
return nil, err
}
hasAny, err := repository.CountShopeeProducts(db)
if err != nil {
return nil, err
}
result := &ShopeeListResult{
Rows: make([]ShopeeProductView, 0, len(rows)),
Total: total,
IsFiltered: keyword != "",
Rows: make([]ShopeeProductView, 0, len(rows)),
Total: total,
HasAnyProducts: hasAny > 0,
IsFiltered: strings.TrimSpace(filter.Keyword) != "" || filter.Status != "",
Page: page,
PageSize: PageSize,
TotalPages: totalPages,
}
for _, r := range rows {
v := ShopeeProductView{
@@ -100,6 +131,48 @@ func ListShopeeProducts(db *sql.DB, keyword string) (*ShopeeListResult, error) {
return result, nil
}
// ---------- 状态筛选 ----------
// shopeeStatusTexts 是状态筛选下拉框每个取值对应的中文文字,
// 也用于底部状态条筛选后的前缀("待补规格:6 个商品"),见工单 #43。
var shopeeStatusTexts = map[string]string{
"pending_spec": "待补规格",
"no_link": "未填 PDD 链接",
"has_link": "已填链接",
}
// ShopeeStatusOption 是筛选下拉框的一项。
type ShopeeStatusOption struct {
Value string // 空串表示"全部"
Text string
}
// ShopeeStatusOptions 返回状态筛选下拉框的全部选项:全部 + 三个状态。
func ShopeeStatusOptions() []ShopeeStatusOption {
return []ShopeeStatusOption{
{"", "全部"},
{"pending_spec", shopeeStatusTexts["pending_spec"]},
{"no_link", shopeeStatusTexts["no_link"]},
{"has_link", shopeeStatusTexts["has_link"]},
}
}
// ParseShopeeStatus 校验筛选参数。认不出的一律当"全部",不报错——
// 地址栏参数是用户可以随便改的,不值得为它弹错误页(工单 #43)。
func ParseShopeeStatus(s string) string {
status := strings.TrimSpace(s)
if _, ok := shopeeStatusTexts[status]; ok {
return status
}
return ""
}
// ShopeeStatusLabel 返回状态筛选取值对应的中文文字,取值为空或认不出时
// 返回空串——调用方(handler 层组装状态条文案)据此判断要不要用作前缀。
func ShopeeStatusLabel(status string) string {
return shopeeStatusTexts[status]
}
// ---------- 弹窗 ----------
// ShopeeSpecView 是弹窗规格表的一行。
+168 -8
View File
@@ -2,6 +2,7 @@ package service
import (
"database/sql"
"fmt"
"testing"
"cmautobuy/admin/repository"
@@ -47,7 +48,7 @@ func TestListShopeeProducts_商品级一行(t *testing.T) {
seedShopeeSKU(t, db, "sku-2", "24420648774", "", "黑色", "L", "50-60公斤", true)
seedShopeeSKU(t, db, "sku-3", "24420648774", "", "白色", "M", "40-50公斤", true)
result, err := ListShopeeProducts(db, "")
result, err := ListShopeeProducts(db, repository.ShopeeFilter{}, 1)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
@@ -82,7 +83,7 @@ func TestListShopeeProducts_颜色尺码只统计parse_ok(t *testing.T) {
seedShopeeSKU(t, db, "s4", "26886533818", "紅色,3XL建議80-90公斤】", "", "", "", false)
seedShopeeSKU(t, db, "s5", "26886533818", "粉色,3XL【寬鬆版 82.5-92.5kg", "", "", "", false)
result, err := ListShopeeProducts(db, "")
result, err := ListShopeeProducts(db, repository.ShopeeFilter{}, 1)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
@@ -109,7 +110,7 @@ func TestListShopeeProducts_待补大于0整行标黄(t *testing.T) {
seedShopeeProduct(t, db, "1001", "商品A")
seedShopeeSKU(t, db, "s1", "1001", "坏数据", "", "", "", false)
result, err := ListShopeeProducts(db, "")
result, err := ListShopeeProducts(db, repository.ShopeeFilter{}, 1)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
@@ -123,7 +124,7 @@ func TestListShopeeProducts_PDD链接未填写(t *testing.T) {
db := newTestDB(t)
seedShopeeProduct(t, db, "1001", "商品A")
result, err := ListShopeeProducts(db, "")
result, err := ListShopeeProducts(db, repository.ShopeeFilter{}, 1)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
@@ -148,7 +149,7 @@ func TestListShopeeProducts_采集状态联查(t *testing.T) {
}
setShopeePddLink(t, db, "1001", "9001", "https://mobile.yangkeduo.com/goods.html?goods_id=9001")
result, err := ListShopeeProducts(db, "")
result, err := ListShopeeProducts(db, repository.ShopeeFilter{}, 1)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
@@ -168,7 +169,7 @@ func TestListShopeeProducts_关键字匹配商品ID和名称_不匹配颜色尺
seedShopeeSKU(t, db, "s1", "1001", "", "黑色", "M", "", true)
// 按商品 ID 匹配
result, err := ListShopeeProducts(db, "1001")
result, err := ListShopeeProducts(db, repository.ShopeeFilter{Keyword: "1001"}, 1)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
@@ -177,7 +178,7 @@ func TestListShopeeProducts_关键字匹配商品ID和名称_不匹配颜色尺
}
// 按商品名称匹配
result, err = ListShopeeProducts(db, "背心")
result, err = ListShopeeProducts(db, repository.ShopeeFilter{Keyword: "背心"}, 1)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
@@ -186,7 +187,7 @@ func TestListShopeeProducts_关键字匹配商品ID和名称_不匹配颜色尺
}
// 颜色/尺码不参与匹配:搜"黑色"应该搜不到任何商品。
result, err = ListShopeeProducts(db, "黑色")
result, err = ListShopeeProducts(db, repository.ShopeeFilter{Keyword: "黑色"}, 1)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
@@ -250,3 +251,162 @@ func TestGetShopeeProductDetail_商品不存在返回nil(t *testing.T) {
t.Fatalf("不存在的商品应该返回 nil,得到 %+v", detail)
}
}
// ── 分页(工单 #43) ──────────────────────────────────
func seedShopeeProducts(t *testing.T, db *sql.DB, n int) {
t.Helper()
for i := 0; i < n; i++ {
seedShopeeProduct(t, db, fmt.Sprintf("g%04d", i), fmt.Sprintf("商品%d", i))
}
}
func TestListShopeeProducts_每页固定20条(t *testing.T) {
db := newTestDB(t)
seedShopeeProducts(t, db, 25)
result, err := ListShopeeProducts(db, repository.ShopeeFilter{}, 1)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if len(result.Rows) != 20 {
t.Errorf("第 1 页行数 = %d,想要 20", len(result.Rows))
}
if result.Total != 25 {
t.Errorf("Total = %d,想要 25(全量,不是本页数)", result.Total)
}
if result.TotalPages != 2 {
t.Errorf("TotalPages = %d,想要 2", result.TotalPages)
}
result2, err := ListShopeeProducts(db, repository.ShopeeFilter{}, 2)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if len(result2.Rows) != 5 {
t.Errorf("第 2 页行数 = %d,想要 5(25 条剩下的)", len(result2.Rows))
}
}
func TestListShopeeProducts_page越界兜到最后一页(t *testing.T) {
db := newTestDB(t)
seedShopeeProducts(t, db, 25)
result, err := ListShopeeProducts(db, repository.ShopeeFilter{}, 9999)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if result.Page != 2 {
t.Errorf("Page = %d,想要兜到最后一页 2", result.Page)
}
if len(result.Rows) != 5 {
t.Errorf("越界后应显示最后一页的数据,行数 = %d,想要 5,不是空表格", len(result.Rows))
}
}
func TestListShopeeProducts_page小于1或非数字当作第1页(t *testing.T) {
db := newTestDB(t)
seedShopeeProducts(t, db, 3)
for _, p := range []int{0, -1, -100} {
result, err := ListShopeeProducts(db, repository.ShopeeFilter{}, p)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if result.Page != 1 {
t.Errorf("page=%d 时 Page = %d,想要 1", p, result.Page)
}
}
}
func TestListShopeeProducts_总数为0时第1页共1页(t *testing.T) {
db := newTestDB(t)
result, err := ListShopeeProducts(db, repository.ShopeeFilter{}, 1)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if result.TotalPages != 1 {
t.Errorf("总数为 0 时 TotalPages = %d,想要 1(不能出现『第 1/0 页』)", result.TotalPages)
}
if result.Page != 1 {
t.Errorf("Page = %d,想要 1", result.Page)
}
}
// ── 状态筛选(工单 #43) ──────────────────────────────
func TestListShopeeProducts_待补规格筛选_一个商品多个失败SKU只出现一行(t *testing.T) {
db := newTestDB(t)
// 同一商品两条解析失败的 SKU:用 JOIN + DISTINCT 会因为多行匹配 EXISTS
// 子查询而出重复,这里验证过滤用 EXISTS 时该商品只出现一次。
seedShopeeProduct(t, db, "1001", "待补商品")
seedShopeeSKU(t, db, "s1", "1001", "坏数据1", "", "", "", false)
seedShopeeSKU(t, db, "s2", "1001", "坏数据2", "", "", "", false)
seedShopeeProduct(t, db, "1002", "正常商品")
seedShopeeSKU(t, db, "s3", "1002", "", "黑色", "M", "", true)
result, err := ListShopeeProducts(db, repository.ShopeeFilter{Status: "pending_spec"}, 1)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if result.Total != 1 {
t.Fatalf("Total = %d,想要 1(不因为两条失败 SKU 出重复行)", result.Total)
}
if len(result.Rows) != 1 || result.Rows[0].GoodsID != "1001" {
t.Fatalf("筛选结果不对: %+v", result.Rows)
}
}
func TestListShopeeProducts_未填链接和已填链接筛选(t *testing.T) {
db := newTestDB(t)
seedShopeeProduct(t, db, "1001", "未填链接商品")
seedShopeeProduct(t, db, "1002", "已填链接商品")
setShopeePddLink(t, db, "1002", "9001", "https://mobile.yangkeduo.com/goods.html?goods_id=9001")
noLink, err := ListShopeeProducts(db, repository.ShopeeFilter{Status: "no_link"}, 1)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if noLink.Total != 1 || noLink.Rows[0].GoodsID != "1001" {
t.Fatalf("未填链接筛选结果不对: %+v", noLink.Rows)
}
hasLink, err := ListShopeeProducts(db, repository.ShopeeFilter{Status: "has_link"}, 1)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if hasLink.Total != 1 || hasLink.Rows[0].GoodsID != "1002" {
t.Fatalf("已填链接筛选结果不对: %+v", hasLink.Rows)
}
}
func TestListShopeeProducts_筛选参数认不出来当全部(t *testing.T) {
db := newTestDB(t)
seedShopeeProducts(t, db, 3)
result, err := ListShopeeProducts(db, repository.ShopeeFilter{Status: "not_a_real_status"}, 1)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
// repository 层不做校验,直接透传的话这个取值走不进 switch 的任何分支,
// 等价于不筛选——校验(认不出来当全部)在 service.ParseShopeeStatus,
// 这里验证 repository 端不会因为陌生取值报错或漏数据。
if result.Total != 3 {
t.Errorf("Total = %d,想要 3(陌生取值不应影响结果)", result.Total)
}
}
func TestParseShopeeStatus_认不出来当全部(t *testing.T) {
cases := []string{"", "not_a_real_status", " ", "PENDING_SPEC"}
for _, c := range cases {
if got := ParseShopeeStatus(c); got != "" {
t.Errorf("ParseShopeeStatus(%q) = %q,想要空串(当全部)", c, got)
}
}
for _, c := range []string{"pending_spec", "no_link", "has_link"} {
if got := ParseShopeeStatus(c); got != c {
t.Errorf("ParseShopeeStatus(%q) = %q,想要原样返回", c, got)
}
}
}