Files
cmautobuy/admin/handler/web/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

242 lines
9.7 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 web
import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
"cmautobuy/admin/config"
"cmautobuy/admin/repository"
"cmautobuy/admin/service"
)
// ShopeeList 渲染蝦皮数据列表页。
//
// **商品级一行**(不是 SKU 级),数据来自 shopee_products 和 shopee_skus
// 聚合联查。列定义见 docs/admin/05-ui-specification.md §4.2、工单 #41。
func (h *Handler) ShopeeList(c *gin.Context) {
h.renderShopeeList(c, c.Query("goods_id"), c.Query("status"), c.Query("page"), c.Query("msg"), nil)
}
// ShopeeDetail 渲染双击行弹出的那个弹窗的**内容**(不是整页)。
//
// 复用 #18 的弹窗机制:行上写 data-detail-id="{{.GoodsID}}",
// 前端拿 goods_id 拼到 data-detail-url 后面,以 "id" 作为查询参数名
// (固定写法,见 static/js/app.js「双击行打开详情弹窗」)。
//
// `[必须]` 这个弹窗只读,不含保存表单——ShopeeSave 仍是 501,见 #41。
func (h *Handler) ShopeeDetail(c *gin.Context) {
goodsID := strings.TrimSpace(c.Query("id"))
if goodsID == "" {
fail(c, http.StatusBadRequest, "商品编号不对,请刷新页面后重试。")
return
}
detail, err := service.GetShopeeProductDetail(h.db, goodsID)
if err != nil {
fail(c, http.StatusInternalServerError, "读取商品详情失败,数据没有被改动。")
return
}
if detail == nil {
fail(c, http.StatusNotFound, "这个商品不存在,请刷新页面。")
return
}
c.HTML(http.StatusOK, "shopee/detail_modal", gin.H{"D": detail})
}
// ShopeeImport 处理 Excel 上传导入。
//
// 关键规则(写错会丢数据,见 docs/admin/03-data-model.md §3.3):
// - 商品汇总行(商品規格ID 为 "-")进 shopee_products,
// SKU 行进 shopee_skus,两者要分开处理;
// - 按列名找索引,不要写死列号;
// - 规格原文两种格式各占一半,解析失败留空并把 parse_ok 置 0,不要猜;
// - **只能 upsert,绝不允许先清空再导入**,
// 否则人工填的 PDD 链接会被洗掉;
// - upsert 的 DO UPDATE SET 里不得出现 pdd_goods_url / pdd_goods_id。
//
// 导入结果(含全部失败行)直接渲染在返回页面里,**不走 303 跳转**——
// 失败行可能有几十条,塞进跳转用的 URL 查询参数既丑又有长度限制,
// 没法满足"失败行要能全部看到"的要求(见工单 #38)。
// 代价是这个页面刷新(F5)会弹出浏览器自带的"重新提交表单"确认,
// 这是标准行为,比丢失失败行信息更容易接受。
func (h *Handler) ShopeeImport(c *gin.Context) {
// 限制整个请求体大小:50MB 上限 + 给表单其它字段留一点余量。
// 放在 FormFile 之前,这样超限时请求体读到一半就会出错,
// 不会先把整个超大文件读进内存再拒绝。
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, service.MaxShopeeUploadBytes+1<<20)
fileHeader, err := c.FormFile("file")
if err != nil {
fail(c, http.StatusBadRequest,
"没有收到文件(也可能是文件超过了 50MB 上限),请重新选择 Excel 后再提交。未写入数据库。")
return
}
src, err := fileHeader.Open()
if err != nil {
fail(c, http.StatusBadRequest, "无法打开上传的文件,请重试。未写入数据库。")
return
}
defer src.Close()
// 只读文件开头几个字节判断是不是真的 xlsx(zip),不用整份读进内存。
head := make([]byte, 8)
n, _ := io.ReadFull(src, head)
if err := service.ValidateShopeeUpload(fileHeader.Filename, fileHeader.Size, head[:n]); err != nil {
fail(c, http.StatusBadRequest, err.Error()+"。未写入数据库。")
return
}
if _, err := src.Seek(0, io.SeekStart); err != nil {
fail(c, http.StatusInternalServerError, "读取上传文件失败,请重试。未写入数据库。")
return
}
uploadDir, err := config.SubDir("uploads")
if err != nil {
fail(c, http.StatusInternalServerError, "无法准备上传目录,请重试。未写入数据库。")
return
}
// `[必须]` 自己生成文件名,不用 fileHeader.Filename 拼路径——
// 用户可控的文件名里塞一个 "../../etc/passwd" 就是路径穿越。
savePath := filepath.Join(uploadDir, service.NewShopeeUploadFilename())
dst, err := os.Create(savePath)
if err != nil {
fail(c, http.StatusInternalServerError, "保存上传文件失败,请重试。未写入数据库。")
return
}
if _, err := io.Copy(dst, src); err != nil {
dst.Close()
fail(c, http.StatusInternalServerError, "保存上传文件失败,请重试。未写入数据库。")
return
}
if err := dst.Close(); err != nil {
fail(c, http.StatusInternalServerError, "保存上传文件失败,请重试。未写入数据库。")
return
}
result, err := service.ImportShopeeExcel(h.db, savePath)
if err != nil {
fail(c, http.StatusBadRequest,
"导入失败:"+err.Error()+"。这次导入整体没有生效(已回滚),可以修好文件后重新上传。")
return
}
msg := fmt.Sprintf("导入完成:%d 个商品 / %d 个 SKU", result.ProductCount, result.SKUCount)
if len(result.Failures) > 0 {
msg += fmt.Sprintf(",%d 行解析失败(见下方列表)", len(result.Failures))
}
// 导入完成后回到第 1 页、清空筛选——刚导入的数据从第一页就能看到,
// 沿用筛选反而可能让人以为导入没生效(筛出来的还是老的几条)。
h.renderShopeeList(c, "", "", "", msg, result.Failures)
}
// renderShopeeList 是 ShopeeList 和 ShopeeImport 共用的渲染逻辑。
//
// `[必须]` 分页控件用 <a href> 纯 GET 导航,翻页要保留 keyword/statusRaw,
// 所以这里把它们原样透传回模板去拼下一页的链接,不在这里丢掉(工单 #43)。
func (h *Handler) renderShopeeList(c *gin.Context, keyword, statusRaw, pageRaw, msg string, failures []service.ImportFailure) {
filter := repository.ShopeeFilter{
Keyword: keyword,
Status: service.ParseShopeeStatus(statusRaw),
}
// 不叫 page:本文件末尾要调用同名的 page(c, ...) 渲染辅助函数,
// 局部变量会把它遮住导致编译失败。
pageNum := service.ParsePage(pageRaw)
result, err := service.ListShopeeProducts(h.db, filter, pageNum)
if err != nil {
fail(c, http.StatusInternalServerError,
"读取蝦皮数据失败,数据没有被改动。刷新页面重试;一直失败请把这句话报给维护者。")
return
}
status := msg
if status == "" {
status = shopeeStatusLine(filter.Status, result)
}
values := url.Values{}
if keyword != "" {
values.Set("goods_id", keyword)
}
if filter.Status != "" {
values.Set("status", filter.Status)
}
c.HTML(http.StatusOK, "shopee/list", page(c, "shopee", "蝦皮数据", gin.H{
"Keyword": keyword,
"StatusFilter": filter.Status,
"StatusOptions": service.ShopeeStatusOptions(),
"Rows": result.Rows,
"Status": status,
"HasAnyProducts": result.HasAnyProducts,
"IsFiltered": result.IsFiltered,
"Failures": failures,
"Pagination": service.NewPaginationView(result.Page, result.TotalPages, values.Encode()),
}))
}
// shopeeStatusLine 组装底部状态条的默认文案(没有 msg 覆盖时)。
//
// `[必须]` 显示的是筛选后的**全量**总数(result.Total),不是本页行数,
// 筛选时前缀用状态文字("待补规格:6 个商品"),不筛选时用"共",
// 见工单 #43。
func shopeeStatusLine(statusFilter string, result *service.ShopeeListResult) string {
prefix := fmt.Sprintf("共 %d 个商品", result.Total)
if text := service.ShopeeStatusLabel(statusFilter); text != "" {
prefix = fmt.Sprintf("%s:%d 个商品", text, result.Total)
}
return fmt.Sprintf("%s · 第 %d/%d 页", prefix, result.Page, result.TotalPages)
}
// ShopeeSave 保存编辑弹窗里的内容(PDD 链接、颜色、尺码、建议)。
//
// 这是**从蝦皮商品出发**录入 PDD 链接的入口,
// 见 docs/admin/05-ui-specification.md §4.3。PDD 商品页(§5)也能录入,
// 两处并存;本页这个目前还是骨架,合并与否由「蝦皮↔PDD 关联入口」工单决定。
//
// `[不做]` 本工单(#38)明确不实现这个接口,保持 501。
func (h *Handler) ShopeeSave(c *gin.Context) {
// TODO(骨架): 保存 PDD 链接时调 repository.EnsurePddProduct,
// 由它去建 / 复用 / 复活 pdd_products 那一行。
//
// **采集状态不在蝦皮这边**:它属于 PDD 商品(pdd_products.collect_status)。
// "还没填链接"由 shopee_products.pdd_goods_id IS NULL 表达——
// 没填链接时 pdd_products 里根本不会有那一行。
// 不要写 no_link,那个值在 #16 已从 CHECK 约束里删掉了,写了会直接撞约束。
fail(c, http.StatusNotImplemented, "保存功能尚未实现。")
}
// ShopeeDelete 批量删除勾选的行。
//
// `[不做]` 本工单(#38)明确不实现这个接口,保持 501。
func (h *Handler) ShopeeDelete(c *gin.Context) {
// TODO(骨架): 取 ids,二次确认已在前端做过,这里直接删
fail(c, http.StatusNotImplemented, "删除功能尚未实现。")
}
// ShopeeCollect 发起采集任务。
//
// 入口在编辑弹窗里,紧挨 PDD 链接输入框——不要放到表格每一行,
// 一个商品有 N 个 SKU 行,放行上就是 N 个按钮干同一件事。
//
// 规则:
// - PDD 链接为空不允许发起;
// - collect_status 已经是 collecting 的跳过并说明跳过了几个;
// - 批量采集要**按商品去重**后再建任务。
//
// `[不做]` 本工单(#38)明确不实现这个接口,保持 501。
func (h *Handler) ShopeeCollect(c *gin.Context) {
// TODO(骨架): 调 service.CreateCollectTasks(goodsIDs, clientID)
fail(c, http.StatusNotImplemented, "采集功能尚未实现。")
}