Files
cmautobuy/admin/handler/web/shopee.go
T
chengmaandClaude Opus 5 e29d6683ad feat: 蝦皮 Excel 报表导入 (#38)
蝦皮数据模块此前整个是骨架,四个入口全返回 501,excelize 连依赖都没装。
本工单做导入和列表,Save/Delete/Collect 保持 501。

真实样本实测:5195 个商品 / 6092 个 SKU,23 行解析失败。

upsert 白名单式,pdd_goods_url / pdd_goods_id 既不在 INSERT 列清单里
也不在 DO UPDATE SET 里。报表没有这两列,写进去就是写空值——操作员
攒几周的 PDD 链接会被一次导入洗光,而且不报错,等到建采购任务才发现。
有独立测试守着:往 DO UPDATE SET 里加回这一行,测试立刻变红。

按 sheet 名字取「最佳表現商品」,不用第 0 个。工作簿有 5 个 sheet,
另外 4 个是广告报表,连 商品規格ID 列都没有;蝦皮调顺序时按下标
会静默导入一张完全不相干的表。

按列名找索引。40 列里 32 列是统计指标,蝦皮加一列所有列号就错位,
而且不报错——会把「點擊率」当成「商品規格」存进去。

规格解析三种格式:含【】53.4%、逗号+空格 26.4%、只有逗号 20.2%,
只认【】会漏掉 46.6%。括号不配对(21 行)和右半整个被【】包住(2 行)
一律判整体失败,不逐段硬凑——错法不统一,针对每种错法写规则等于在猜,
而且没有人工核对过的正确答案可验证。失败时 color/size/advice 必须全空,
原文存进 spec_raw 交人工补。

不写 DELETE + INSERT 的全量替换,将来手动新增的 SKU 会被删掉。

excelize v2.9.1 的 go 指令正好是 1.23.0,与项目固定版本一致。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 09:57:12 +08:00

178 lines
7.0 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"
"os"
"path/filepath"
"github.com/gin-gonic/gin"
"cmautobuy/admin/config"
"cmautobuy/admin/service"
)
// ShopeeList 渲染蝦皮数据列表页。
//
// 表格按 SKU 展开显示,数据来自 shopee_products 和 shopee_skus 联查。
// 列定义见 docs/admin/05-ui-specification.md §4.2。
func (h *Handler) ShopeeList(c *gin.Context) {
h.renderShopeeList(c, c.Query("goods_id"), c.Query("msg"), nil)
}
// 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))
}
h.renderShopeeList(c, "", msg, result.Failures)
}
// renderShopeeList 是 ShopeeList 和 ShopeeImport 共用的渲染逻辑。
func (h *Handler) renderShopeeList(c *gin.Context, keyword, msg string, failures []service.ImportFailure) {
result, err := service.ListShopeeSKUs(h.db, keyword)
if err != nil {
fail(c, http.StatusInternalServerError,
"读取蝦皮数据失败,数据没有被改动。刷新页面重试;一直失败请把这句话报给维护者。")
return
}
status := msg
if status == "" {
status = fmt.Sprintf("共 %d 个商品", result.Total)
if result.IsFiltered {
status = fmt.Sprintf("筛选出 %d 条 / %s", len(result.Rows), status)
}
}
c.HTML(http.StatusOK, "shopee/list", page(c, "shopee", "蝦皮数据", gin.H{
"Keyword": keyword,
"Rows": result.Rows,
"Status": status,
"HasAnyProducts": result.Total > 0,
"IsFiltered": result.IsFiltered,
"Failures": failures,
}))
}
// 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, "采集功能尚未实现。")
}