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>
This commit is contained in:
+109
-24
@@ -1,9 +1,16 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"cmautobuy/admin/config"
|
||||
"cmautobuy/admin/service"
|
||||
)
|
||||
|
||||
// ShopeeList 渲染蝦皮数据列表页。
|
||||
@@ -11,18 +18,7 @@ import (
|
||||
// 表格按 SKU 展开显示,数据来自 shopee_products 和 shopee_skus 联查。
|
||||
// 列定义见 docs/admin/05-ui-specification.md §4.2。
|
||||
func (h *Handler) ShopeeList(c *gin.Context) {
|
||||
keyword := c.Query("goods_id")
|
||||
|
||||
// TODO(骨架): 调 service 查列表
|
||||
// rows, err := service.ListShopeeSKUs(h.db, keyword, page, size)
|
||||
// 注意:搜索要走数据库查询,不要一次查全量再在内存里过滤。
|
||||
var rows []gin.H
|
||||
|
||||
c.HTML(http.StatusOK, "shopee/list", page(c, "shopee", "蝦皮数据", gin.H{
|
||||
"Keyword": keyword,
|
||||
"Rows": rows,
|
||||
"Status": "尚未实现:导入后这里显示商品和规格",
|
||||
}))
|
||||
h.renderShopeeList(c, c.Query("goods_id"), c.Query("msg"), nil)
|
||||
}
|
||||
|
||||
// ShopeeImport 处理 Excel 上传导入。
|
||||
@@ -34,25 +30,108 @@ func (h *Handler) ShopeeList(c *gin.Context) {
|
||||
// - 规格原文两种格式各占一半,解析失败留空并把 parse_ok 置 0,不要猜;
|
||||
// - **只能 upsert,绝不允许先清空再导入**,
|
||||
// 否则人工填的 PDD 链接会被洗掉;
|
||||
// - upsert 的 DO UPDATE SET 里不得出现 pdd_goods_url / pdd_data / collect_status。
|
||||
// - upsert 的 DO UPDATE SET 里不得出现 pdd_goods_url / pdd_goods_id。
|
||||
//
|
||||
// 导入结果(含全部失败行)直接渲染在返回页面里,**不走 303 跳转**——
|
||||
// 失败行可能有几十条,塞进跳转用的 URL 查询参数既丑又有长度限制,
|
||||
// 没法满足"失败行要能全部看到"的要求(见工单 #38)。
|
||||
// 代价是这个页面刷新(F5)会弹出浏览器自带的"重新提交表单"确认,
|
||||
// 这是标准行为,比丢失失败行信息更容易接受。
|
||||
func (h *Handler) ShopeeImport(c *gin.Context) {
|
||||
file, err := c.FormFile("file")
|
||||
// 限制整个请求体大小: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, "没有收到文件,请重新选择 Excel 后再提交。")
|
||||
fail(c, http.StatusBadRequest,
|
||||
"没有收到文件(也可能是文件超过了 50MB 上限),请重新选择 Excel 后再提交。未写入数据库。")
|
||||
return
|
||||
}
|
||||
|
||||
// TODO(骨架): 校验大小和扩展名(只允许 .xlsx),
|
||||
// 落盘到 data/uploads/ 时用自己生成的文件名,
|
||||
// 不要直接用 file.Filename 拼路径(路径穿越)。
|
||||
_ = file
|
||||
src, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, "无法打开上传的文件,请重试。未写入数据库。")
|
||||
return
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
// TODO(骨架): 调 service.ImportShopeeExcel(path),
|
||||
// 返回 {商品数, SKU数, 解析失败行号列表} 并显示出来。
|
||||
// 失败行不要静默跳过。
|
||||
// 只读文件开头几个字节判断是不是真的 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
|
||||
}
|
||||
|
||||
fail(c, http.StatusNotImplemented,
|
||||
"导入功能尚未实现。已保留你选择的文件,未写入数据库。")
|
||||
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 链接、颜色、尺码、建议)。
|
||||
@@ -60,6 +139,8 @@ func (h *Handler) ShopeeImport(c *gin.Context) {
|
||||
// 这是**从蝦皮商品出发**录入 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 那一行。
|
||||
@@ -72,6 +153,8 @@ func (h *Handler) ShopeeSave(c *gin.Context) {
|
||||
}
|
||||
|
||||
// ShopeeDelete 批量删除勾选的行。
|
||||
//
|
||||
// `[不做]` 本工单(#38)明确不实现这个接口,保持 501。
|
||||
func (h *Handler) ShopeeDelete(c *gin.Context) {
|
||||
// TODO(骨架): 取 ids,二次确认已在前端做过,这里直接删
|
||||
fail(c, http.StatusNotImplemented, "删除功能尚未实现。")
|
||||
@@ -86,6 +169,8 @@ func (h *Handler) ShopeeDelete(c *gin.Context) {
|
||||
// - PDD 链接为空不允许发起;
|
||||
// - collect_status 已经是 collecting 的跳过并说明跳过了几个;
|
||||
// - 批量采集要**按商品去重**后再建任务。
|
||||
//
|
||||
// `[不做]` 本工单(#38)明确不实现这个接口,保持 501。
|
||||
func (h *Handler) ShopeeCollect(c *gin.Context) {
|
||||
// TODO(骨架): 调 service.CreateCollectTasks(goodsIDs, clientID)
|
||||
fail(c, http.StatusNotImplemented, "采集功能尚未实现。")
|
||||
|
||||
Reference in New Issue
Block a user