package web import ( "fmt" "io" "net/http" "net/url" "os" "path/filepath" "strconv" "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「双击行打开详情弹窗」)。 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, "CSRFToken": csrfToken(c), "Keyword": c.Query("goods_id"), "StatusFilter": c.Query("status"), "CurrentPage": service.ParsePage(c.Query("page")), }) } // 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 共用的渲染逻辑。 // // `[必须]` 分页控件用 纯 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()), "DetailURL": "/shopee/detail?" + detailValuesForShopee(values, result.Page), })) } func detailValuesForShopee(values url.Values, pageNum int) string { detailValues := url.Values{} for key, entries := range values { detailValues[key] = append([]string(nil), entries...) } detailValues.Set("page", strconv.Itoa(pageNum)) return detailValues.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 商品。 func (h *Handler) ShopeeSave(c *gin.Context) { goodsID, err := service.AssociateShopeePdd(h.db, c.PostForm("shopee_goods_id"), c.PostForm("pdd_url"), c.PostForm("confirm_replace") == "1") if err != nil { h.shopeeRedirect(c, "PDD 关联未保存:"+err.Error()+"。请核对链接后重试。") return } h.shopeeRedirect(c, fmt.Sprintf("已关联 PDD 商品 %s,可继续创建采集任务", goodsID)) } // 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) { result, err := service.CreateShopeePddCollectTaskForUser(h.db, currentUser(c), c.PostForm("shopee_goods_id")) if err != nil { h.shopeeRedirect(c, "采集任务未创建:"+err.Error()) return } h.shopeeRedirect(c, service.FormatCollectTaskMessage(result)) } func (h *Handler) shopeeRedirect(c *gin.Context, msg string) { params := url.Values{} if value := strings.TrimSpace(c.PostForm("list_goods_id")); value != "" { params.Set("goods_id", value) } if value := strings.TrimSpace(c.PostForm("status")); value != "" { params.Set("status", service.ParseShopeeStatus(value)) } if value := strings.TrimSpace(c.PostForm("page")); value != "" { params.Set("page", value) } if msg != "" { params.Set("msg", msg) } c.Redirect(http.StatusSeeOther, "/shopee?"+params.Encode()) }