Files
cmautobuy/admin/service/shopee_list.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

277 lines
8.5 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.
// 蝦皮数据列表页的查询和界面文字组装。
//
// 分成单独文件是因为它和 shopee_import.go 职责不同:这里只读,不写库。
package service
import (
"database/sql"
"strings"
"cmautobuy/admin/model"
"cmautobuy/admin/repository"
)
// ShopeeProductView 是列表页一行要显示的全部内容,全部已经是字符串,
// 模板里不做判断和格式化。
//
// 一行对应一个**商品**,不是一个 SKU——见工单 #41。
type ShopeeProductView struct {
GoodsID string
Title string
// ColorCount / SizeCount 只统计 parse_ok = 1 的 SKU(`[必须]`,见 #41)。
ColorCount int
SizeCount int
// SKUCount 是这个商品报表里实际出现过的 SKU 数(不管解析成功与否)。
// `[必须]` 必须单独展示:蝦皮报表只含有销售成绩的 SKU,
// 颜色数 × 尺码数经常大于 SKUCount,只显示前两者会严重误导操作员,
// 让人以为要匹配的规格比实际多得多(实测最悬殊相差 2.7 倍,见 #41)。
SKUCount int
// PendingCount 是 parse_ok = 0 的 SKU 数,即"待补"。
// `[必须]` > 0 时整行标黄(沿用 .row-warn)——聚合成商品级之后,
// "部分失败"的商品数字看起来完全正常,坏数据会被吃掉,见 #41。
PendingCount int
// PddURL 为空且 PddMissing 为 true 时,模板显示"未填写"并标红——
// 这是最需要操作员注意的状态,见 §4.2。
PddURL string
PddMissing bool
StatusText string
UpdatedAt string
}
// ShopeeListResult 是列表页要的全部数据。
type ShopeeListResult struct {
Rows []ShopeeProductView
// Total 是**当前筛选条件下**的商品总数,不是本页行数。
// `[必须]` 底部状态条必须显示这个,不能显示本页行数(20),
// 否则操作员会以为总共就这么多,见工单 #43。
Total int
// HasAnyProducts 是 shopee_products 的全库总数是否大于 0,不受筛选影响,
// 只用于区分空状态文案:"从没导入过" vs "筛选没结果"。
HasAnyProducts bool
IsFiltered bool
Page int
PageSize int
TotalPages int
}
// ListShopeeProducts 按筛选条件分页查蝦皮商品列表(商品级一行)并把每一行翻成界面文字。
//
// 采集状态来自 pdd_products(联查得到),不是蝦皮自己的字段,
// 见 docs/admin/01-requirements.md §6.1:
//
// shopee_products.pdd_goods_id 为空 -> "未填链接"
// pdd_products.collect_status -> 未采集 / 采集中 / 已采集 / 采集失败
//
// `[必须]` 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
}
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,
HasAnyProducts: hasAny > 0,
IsFiltered: strings.TrimSpace(filter.Keyword) != "" || filter.Status != "",
Page: page,
PageSize: PageSize,
TotalPages: totalPages,
}
for _, r := range rows {
v := ShopeeProductView{
GoodsID: r.GoodsID,
Title: r.Title,
ColorCount: r.ColorCount,
SizeCount: r.SizeCount,
SKUCount: r.SKUCount,
PendingCount: r.PendingCount,
UpdatedAt: formatLocalTime(r.UpdatedAt),
}
if r.PddGoodsID == "" {
v.PddMissing = true
v.PddURL = "未填写"
v.StatusText = "未填链接"
} else {
v.PddURL = r.PddGoodsURL
if r.CollectStatus.Valid {
v.StatusText = collectStatusText(model.CollectStatus(r.CollectStatus.String))
} else {
// pdd_goods_id 有值但 pdd_products 里查不到对应行
// (比如那条 PDD 商品被软删除了),如实说明,不要装作"未采集"。
v.StatusText = "PDD 商品缺失"
}
}
result.Rows = append(result.Rows, v)
}
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 是弹窗规格表的一行。
type ShopeeSpecView struct {
SKUID string
Color string
Size string
Advice string
// StatusText 是"✓"或"待补",`[必须]` 要有文字,不能只靠颜色区分(见 #41)。
StatusText string
// Pending 为 true 时模板整行标黄,并在下面加一行显示 SpecRaw 原文。
Pending bool
// SpecRaw 只在 Pending 为 true 时才有意义。
// `[必须]` 待补的行必须显示规格原文——不显示的话人工不知道该填什么,
// 这正是保留 spec_raw 这个设计的全部意义,见 #41。
SpecRaw string
}
// ShopeeProductDetail 是双击弹窗要显示的全部内容。
//
// `[必须]` 这个弹窗只读,不含可提交的表单——本工单不实现保存,
// ShopeeSave 仍是 501,见 #41。
type ShopeeProductDetail struct {
GoodsID string
Title string
ShopeeStatus string
MainSKUCode string
PddURL string
PddMissing bool
StatusText string
SKUs []ShopeeSpecView
PendingCount int
}
// GetShopeeProductDetail 读一个蝦皮商品的详情(商品信息 + 完整规格表)。
// 商品不存在返回 (nil, nil)。
func GetShopeeProductDetail(db *sql.DB, goodsID string) (*ShopeeProductDetail, error) {
p, err := repository.GetShopeeProductByGoodsID(db, goodsID)
if err != nil || p == nil {
return nil, err
}
d := &ShopeeProductDetail{
GoodsID: p.GoodsID,
Title: p.Title,
ShopeeStatus: p.ShopeeStatus,
MainSKUCode: p.MainSKUCode,
}
if d.Title == "" {
d.Title = placeholder
}
if d.ShopeeStatus == "" {
d.ShopeeStatus = placeholder
}
if d.MainSKUCode == "" {
d.MainSKUCode = placeholder
}
if p.PddGoodsID == "" {
d.PddMissing = true
d.PddURL = "未填写"
d.StatusText = "未填链接"
} else {
d.PddURL = p.PddGoodsURL
status, _, err := repository.GetShopeeCollectStatus(db, p.PddGoodsID)
if err != nil {
return nil, err
}
if status.Valid {
d.StatusText = collectStatusText(model.CollectStatus(status.String))
} else {
d.StatusText = "PDD 商品缺失"
}
}
skus, err := repository.ListShopeeSKUsByGoodsID(db, goodsID)
if err != nil {
return nil, err
}
d.SKUs = make([]ShopeeSpecView, 0, len(skus))
for _, sk := range skus {
row := ShopeeSpecView{SKUID: sk.SKUID}
if sk.ParseOK {
row.Color, row.Size, row.Advice = sk.Color, sk.Size, sk.Advice
if row.Advice == "" {
row.Advice = placeholder
}
row.StatusText = "✓"
} else {
row.Color, row.Size, row.Advice = placeholder, placeholder, placeholder
row.StatusText = "待补"
row.Pending = true
row.SpecRaw = sk.SpecRaw
d.PendingCount++
}
d.SKUs = append(d.SKUs, row)
}
return d, nil
}