Files
cmautobuy/admin/repository/shopee.go
T
chengmaandClaude Opus 5 edfe39cc35 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>
2026-08-08 11:40:07 +08:00

312 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package repository
import (
"database/sql"
"errors"
"fmt"
"strings"
"cmautobuy/admin/model"
)
// UpsertShopeeProduct 写入或更新一条蝦皮商品汇总行。
//
// `[必须]` DO UPDATE SET 里绝不允许出现 pdd_goods_url / pdd_goods_id。
// 蝦皮报表里没有这两列,写进去就是写空值——操作员可能攒了几周的
// PDD 链接,导入一次就被静默洗掉,而且不报错,等到建采购任务时
// 才会发现,那时已经找不回来了。见工单 #38、admin/AGENTS.md
// 「Excel 导入只做 upsert,绝不允许先清空再导入」。
func UpsertShopeeProduct(q Execer, goodsID, title, shopeeStatus, mainSKUCode string) error {
if goodsID == "" {
return fmt.Errorf("goods_id 不能为空")
}
now := model.NowISO()
_, err := q.Exec(`
INSERT INTO shopee_products (goods_id, title, shopee_status, main_sku_code, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(goods_id) DO UPDATE SET
title = excluded.title,
shopee_status = excluded.shopee_status,
main_sku_code = excluded.main_sku_code,
updated_at = excluded.updated_at`,
// 注意:pdd_goods_url / pdd_goods_id 两列不在 INSERT 的列清单里,
// 也不在 DO UPDATE SET 里——新建时它们是 NULL(未填链接),
// 已存在时它们完全不受这条语句影响。
goodsID, title, shopeeStatus, mainSKUCode, now, now)
if err != nil {
return fmt.Errorf("写入蝦皮商品 %s 失败: %w", goodsID, err)
}
return nil
}
// UpsertShopeeSKU 写入或更新一条蝦皮 SKU 行。
//
// `[必须]` is_manual 不在 DO UPDATE SET 里,已存在的行导入时保持原值不变。
// 人工新增的 SKU(is_manual=1)就是靠这个不被导入覆盖成 0——
// 本工单不做手动新增功能,但这个口子现在就要留好。
func UpsertShopeeSKU(q Execer, skuID, goodsID, specRaw, color, size, advice string, parseOK bool, skuCode string) error {
if skuID == "" {
return fmt.Errorf("sku_id 不能为空")
}
if goodsID == "" {
return fmt.Errorf("goods_id 不能为空")
}
parseOKInt := 0
if parseOK {
parseOKInt = 1
}
now := model.NowISO()
_, err := q.Exec(`
INSERT INTO shopee_skus
(sku_id, goods_id, spec_raw, color, size, advice, parse_ok, sku_code,
is_manual, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)
ON CONFLICT(sku_id) DO UPDATE SET
goods_id = excluded.goods_id,
spec_raw = excluded.spec_raw,
color = excluded.color,
size = excluded.size,
advice = excluded.advice,
parse_ok = excluded.parse_ok,
sku_code = excluded.sku_code,
updated_at = excluded.updated_at`,
// is_manual 不在上面的 SET 列表里,SQLite 对没提到的列保持原值不变。
skuID, goodsID, specRaw, color, size, advice, parseOKInt, skuCode, now, now)
if err != nil {
return fmt.Errorf("写入蝦皮 SKU %s 失败: %w", skuID, err)
}
return nil
}
// ShopeeProductRow 是列表页要的一行:**商品级**聚合数据,联查出采集状态。
//
// `pdd_goods_url` / `pdd_goods_id` 挂在 shopee_products 上,是商品级字段——
// 一行对应一个商品,不是一个 SKU,见工单 #41。
//
// 颜色数 / 尺码数只统计 parse_ok = 1 的 SKU(`[必须]`,见 #41):
// 把 parse_ok = 0 的空颜色/空尺码算进 DISTINCT 会多出一个"空值"选项。
// SKUCount 是这个商品报表里出现过的全部 SKU 数(不管解析成功与否)——
// 蝦皮报表只含有销售成绩的 SKU,这个数天然会小于"颜色数 × 尺码数",
// 加这一列就是为了防止操作员把两者乘出来的数当成实际规格数(见 #41)。
// PendingCount 是 parse_ok = 0 的 SKU 数,即"待补",> 0 时列表整行标黄。
//
// 采集状态挂在 pdd_products 上,不在蝦皮这边,见
// docs/admin/01-requirements.md §6.1。CollectStatus / CollectMsg 为
// NULL 表示这个蝦皮商品还没填 PDD 链接(LEFT JOIN 没查到对应行)。
type ShopeeProductRow struct {
model.ShopeeProduct
ColorCount int
SizeCount int
SKUCount int
PendingCount int
CollectStatus sql.NullString
CollectMsg sql.NullString
}
// 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)。
//
// `[必须]` 分页用 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,
COUNT(DISTINCT CASE WHEN sk.parse_ok = 1 THEN sk.color END) AS color_count,
COUNT(DISTINCT CASE WHEN sk.parse_ok = 1 THEN sk.size END) AS size_count,
COUNT(sk.sku_id) AS sku_count,
COUNT(CASE WHEN sk.parse_ok = 0 THEN 1 END) AS pending_count,
pp.collect_status, pp.collect_msg
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` +
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 {
return nil, fmt.Errorf("查询蝦皮商品列表失败: %w", err)
}
defer rows.Close()
var list []ShopeeProductRow
for rows.Next() {
var r ShopeeProductRow
var title, shopeeStatus, mainSKUCode, pddGoodsURL, pddGoodsID sql.NullString
if err := rows.Scan(
&r.GoodsID, &title, &shopeeStatus, &mainSKUCode,
&pddGoodsURL, &pddGoodsID, &r.CreatedAt, &r.UpdatedAt,
&r.ColorCount, &r.SizeCount, &r.SKUCount, &r.PendingCount,
&r.CollectStatus, &r.CollectMsg,
); err != nil {
return nil, fmt.Errorf("读取蝦皮商品列表失败: %w", err)
}
r.Title = title.String
r.ShopeeStatus = shopeeStatus.String
r.MainSKUCode = mainSKUCode.String
r.PddGoodsURL = pddGoodsURL.String
r.PddGoodsID = pddGoodsID.String
list = append(list, r)
}
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) {
var p model.ShopeeProduct
var title, shopeeStatus, mainSKUCode, pddGoodsURL, pddGoodsID sql.NullString
err := q.QueryRow(`
SELECT goods_id, title, shopee_status, main_sku_code,
pdd_goods_url, pdd_goods_id, created_at, updated_at
FROM shopee_products WHERE goods_id = ?`, goodsID).Scan(
&p.GoodsID, &title, &shopeeStatus, &mainSKUCode,
&pddGoodsURL, &pddGoodsID, &p.CreatedAt, &p.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("查询蝦皮商品 %s 失败: %w", goodsID, err)
}
p.Title = title.String
p.ShopeeStatus = shopeeStatus.String
p.MainSKUCode = mainSKUCode.String
p.PddGoodsURL = pddGoodsURL.String
p.PddGoodsID = pddGoodsID.String
return &p, nil
}
// GetShopeeCollectStatus 查一个 PDD 商品的采集状态和失败原因。
// pddGoodsID 为空(蝦皮商品还没填链接)或查不到对应的 pdd_products 行时,
// 返回的两个 sql.NullString 都是 Valid=false,调用方按"未填链接"/"PDD 商品缺失"处理。
func GetShopeeCollectStatus(q Execer, pddGoodsID string) (status, msg sql.NullString, err error) {
if pddGoodsID == "" {
return status, msg, nil
}
err = q.QueryRow(`
SELECT collect_status, collect_msg FROM pdd_products
WHERE goods_id = ? AND deleted_at IS NULL`, pddGoodsID).Scan(&status, &msg)
if errors.Is(err, sql.ErrNoRows) {
return sql.NullString{}, sql.NullString{}, nil
}
if err != nil {
return status, msg, fmt.Errorf("查询 PDD 商品 %s 采集状态失败: %w", pddGoodsID, err)
}
return status, msg, nil
}
// ListShopeeSKUsByGoodsID 查一个商品的完整规格表,**含解析失败的行**。
//
// 弹窗要显示全部规格(不管待补与否),所以这里不按 parse_ok 过滤,
// 见 #41「弹窗里待补的行必须显示 spec_raw 原文」。
func ListShopeeSKUsByGoodsID(q Execer, goodsID string) ([]model.ShopeeSKU, error) {
rows, err := q.Query(`
SELECT sku_id, goods_id, spec_raw, color, size, advice, parse_ok, sku_code,
is_manual, created_at, updated_at
FROM shopee_skus
WHERE goods_id = ?
ORDER BY sku_id`, goodsID)
if err != nil {
return nil, fmt.Errorf("查询蝦皮商品 %s 的规格失败: %w", goodsID, err)
}
defer rows.Close()
var list []model.ShopeeSKU
for rows.Next() {
var sk model.ShopeeSKU
var color, size, advice, skuCode sql.NullString
var parseOK, isManual int
if err := rows.Scan(
&sk.SKUID, &sk.GoodsID, &sk.SpecRaw, &color, &size, &advice,
&parseOK, &skuCode, &isManual, &sk.CreatedAt, &sk.UpdatedAt,
); err != nil {
return nil, fmt.Errorf("读取蝦皮商品 %s 的规格失败: %w", goodsID, err)
}
sk.Color = color.String
sk.Size = size.String
sk.Advice = advice.String
sk.SKUCode = skuCode.String
sk.ParseOK = parseOK != 0
sk.IsManual = isManual != 0
list = append(list, sk)
}
return list, rows.Err()
}
// CountShopeeProducts 统计蝦皮商品总数,供列表页判断"是否已导入过任何数据"。
func CountShopeeProducts(q Execer) (int, error) {
var n int
if err := q.QueryRow(`SELECT COUNT(*) FROM shopee_products`).Scan(&n); err != nil {
return 0, fmt.Errorf("统计蝦皮商品数量失败: %w", err)
}
return n, nil
}