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:
+73
-11
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user