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:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 是弹窗规格表的一行。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user