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
+73 -11
View File
@@ -103,12 +103,64 @@ type ShopeeProductRow struct {
CollectMsg sql.NullString
}
// ListShopeeProducts 按关键字查蝦皮商品列表(商品级一行),联查规格聚合数和采集状态。
// ShopeeFilter 是蝦皮商品列表页支持的筛选条件,两项都可以为空。
//
// Status 取值见 §「状态筛选的四个取值」(工单 #43):
//
// "" 不筛选
// "pending_spec" 有 parse_ok = 0 的 SKU(待补规格)
// "no_link" pdd_goods_url 为空(未填 PDD 链接)
// "has_link" pdd_goods_url 非空(已填链接)
//
// 认不出的取值一律当作 ""(不筛选),由 service 层的 ParseShopeeStatus 兜底,
// 这里不做校验——repository 只管拼 SQL。
type ShopeeFilter struct {
Keyword string
Status string
}
// shopeeFilterClause 把关键字和状态筛选拼成 WHERE 子句,供 ListShopeeProducts
// 和 CountShopeeProductsFiltered 共用——两处筛选逻辑必须完全一致,
// 否则底部统计会跟表格对不上(#19 踩过一次,见工单 #43)。
//
// 「待补规格」用 EXISTS 子查询,不用 JOIN + DISTINCT:一个商品有多个失败
// SKU 时 JOIN 会出重复行,DISTINCT 又会让外层 LIMIT/OFFSET 的行为难推理
// (见工单 #43)。
func shopeeFilterClause(filter ShopeeFilter) (string, []any) {
var clauses []string
var args []any
if kw := strings.TrimSpace(filter.Keyword); kw != "" {
like := "%" + escapeLike(kw) + "%"
clauses = append(clauses, `(sp.goods_id LIKE ? ESCAPE '\' OR sp.title LIKE ? ESCAPE '\')`)
args = append(args, like, like)
}
switch filter.Status {
case "pending_spec":
clauses = append(clauses,
`EXISTS (SELECT 1 FROM shopee_skus s WHERE s.goods_id = sp.goods_id AND s.parse_ok = 0)`)
case "no_link":
clauses = append(clauses, `(sp.pdd_goods_url IS NULL OR sp.pdd_goods_url = '')`)
case "has_link":
clauses = append(clauses, `(sp.pdd_goods_url IS NOT NULL AND sp.pdd_goods_url <> '')`)
}
if len(clauses) == 0 {
return "", args
}
return " WHERE " + strings.Join(clauses, " AND "), args
}
// ListShopeeProducts 按筛选条件分页查蝦皮商品列表(商品级一行),联查规格聚合数和采集状态。
//
// keyword 匹配商品 ID 或商品名称,**不匹配颜色/尺码**——
// 那是 SKU 级信息,商品级列表里搜出来没法定位到具体是哪个 SKU(见 #41)。
// keyword 为空表示不筛选。
func ListShopeeProducts(q Execer, keyword string) ([]ShopeeProductRow, error) {
//
// `[必须]` 分页用 LIMIT/OFFSET 在数据库里做,不把全量查出来在 Go 里切片
// (工单 #43:这正是改之前 HTML 一次 3.4MB 的成因)。
func ListShopeeProducts(q Execer, filter ShopeeFilter, limit, offset int) ([]ShopeeProductRow, error) {
where, args := shopeeFilterClause(filter)
sqlText := `
SELECT sp.goods_id, sp.title, sp.shopee_status, sp.main_sku_code,
sp.pdd_goods_url, sp.pdd_goods_id, sp.created_at, sp.updated_at,
@@ -120,14 +172,9 @@ func ListShopeeProducts(q Execer, keyword string) ([]ShopeeProductRow, error) {
FROM shopee_products sp
LEFT JOIN shopee_skus sk ON sk.goods_id = sp.goods_id
LEFT JOIN pdd_products pp
ON pp.goods_id = sp.pdd_goods_id AND pp.deleted_at IS NULL`
var args []any
if keyword = strings.TrimSpace(keyword); keyword != "" {
like := "%" + escapeLike(keyword) + "%"
sqlText += ` WHERE sp.goods_id LIKE ? ESCAPE '\' OR sp.title LIKE ? ESCAPE '\'`
args = append(args, like, like)
}
sqlText += ` GROUP BY sp.goods_id ORDER BY sp.goods_id`
ON pp.goods_id = sp.pdd_goods_id AND pp.deleted_at IS NULL` +
where + ` GROUP BY sp.goods_id ORDER BY sp.goods_id LIMIT ? OFFSET ?`
args = append(args, limit, offset)
rows, err := q.Query(sqlText, args...)
if err != nil {
@@ -157,6 +204,21 @@ func ListShopeeProducts(q Execer, keyword string) ([]ShopeeProductRow, error) {
return list, rows.Err()
}
// CountShopeeProductsFiltered 统计当前筛选条件下的商品总数。
//
// `[必须]` 用和 ListShopeeProducts **完全相同**的筛选条件(shopeeFilterClause)——
// 底部状态条和分页页数都靠它,写成两份 WHERE 迟早有一天会忘了同步改,
// 页码就会算错而且没人发现(#19 已经踩过一次,见工单 #43)。
func CountShopeeProductsFiltered(q Execer, filter ShopeeFilter) (int, error) {
where, args := shopeeFilterClause(filter)
sqlText := `SELECT COUNT(*) FROM shopee_products sp` + where
var n int
if err := q.QueryRow(sqlText, args...).Scan(&n); err != nil {
return 0, fmt.Errorf("统计蝦皮商品数量失败: %w", err)
}
return n, nil
}
// GetShopeeProductByGoodsID 按 goods_id 查一条蝦皮商品,不带聚合。
// 弹窗组装商品信息时用。查不到返回 (nil, nil)。
func GetShopeeProductByGoodsID(q Execer, goodsID string) (*model.ShopeeProduct, error) {