导入真实样本后 /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>
312 lines
12 KiB
Go
312 lines
12 KiB
Go
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
|
||
}
|