导入真实样本后 /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>
242 lines
9.7 KiB
Go
242 lines
9.7 KiB
Go
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, "采集功能尚未实现。")
|
||
}
|