diff --git a/admin/handler/web/csrf.go b/admin/handler/web/csrf.go index 96251f8..9e81938 100644 --- a/admin/handler/web/csrf.go +++ b/admin/handler/web/csrf.go @@ -7,6 +7,8 @@ import ( "net/http" "github.com/gin-gonic/gin" + + "cmautobuy/admin/service" ) // CSRF 防护,用的是「双提交 Cookie」这个最简单的方案: @@ -57,6 +59,17 @@ func CSRFMiddleware() gin.HandlerFunc { return } + // multipart 表单会在下面 PostForm 时被解析,所以请求体上限必须在它之前设置。 + // 放到具体上传 Handler 里已经太晚:那时文件可能已经被读进临时目录。 + switch c.Request.URL.Path { + case "/pdd/import": + c.Request.Body = http.MaxBytesReader( + c.Writer, c.Request.Body, service.MaxPddUploadBytes+1<<20) + case "/shopee/import": + c.Request.Body = http.MaxBytesReader( + c.Writer, c.Request.Body, service.MaxShopeeUploadBytes+1<<20) + } + submitted := c.PostForm(csrfFieldName) if submitted == "" { submitted = c.GetHeader("X-CSRF-Token") diff --git a/admin/handler/web/pdd.go b/admin/handler/web/pdd.go index d17183c..e29ebc2 100644 --- a/admin/handler/web/pdd.go +++ b/admin/handler/web/pdd.go @@ -2,12 +2,15 @@ package web import ( "fmt" + "io" "net/http" "net/url" + "os" "strconv" "github.com/gin-gonic/gin" + "cmautobuy/admin/config" "cmautobuy/admin/service" ) @@ -18,9 +21,13 @@ import ( // PddList 渲染 PDD 商品列表页。 func (h *Handler) PddList(c *gin.Context) { - keyword := c.Query("q") - status := service.ParseCollectStatus(c.Query("status")) - pageNum := service.ParsePage(c.Query("page")) + h.renderPddList(c, c.Query("q"), c.Query("status"), c.Query("page"), "", nil) +} + +// renderPddList 统一渲染普通列表和导入结果页,避免两条入口逐渐长成两套页面。 +func (h *Handler) renderPddList(c *gin.Context, keyword, statusRaw, pageRaw, message string, importResult *service.PddImportResult) { + status := service.ParseCollectStatus(statusRaw) + pageNum := service.ParsePage(pageRaw) result, err := service.ListPddProducts(h.db, keyword, status, pageNum) if err != nil { @@ -38,8 +45,11 @@ func (h *Handler) PddList(c *gin.Context) { // 底部状态条平时显示统计,刚做完操作时先显示操作结果。 // 操作结果是通过跳转带过来的 msg 参数传的(见 pddRedirect)。 statusLine := result.StatusLine() - if msg := c.Query("msg"); msg != "" { - statusLine = msg + " · " + statusLine + if msg := c.Query("msg"); msg != "" && message == "" { + message = msg + } + if message != "" { + statusLine = message + " · " + statusLine } values := url.Values{} @@ -66,6 +76,7 @@ func (h *Handler) PddList(c *gin.Context) { "Pagination": service.NewPaginationView(result.Page, result.TotalPages, values.Encode()), "DetailURL": "/pdd/detail?" + detailValues.Encode(), "AssignableClients": assignableClients, + "ImportResult": importResult, })) } @@ -112,6 +123,73 @@ func (h *Handler) PddCreate(c *gin.Context) { h.pddRedirect(c, fmt.Sprintf("已保存商品 %s,现在可以勾选它创建采集任务", goodsID)) } +// PddImport 把一列式 xlsx 中的完整 PDD 链接批量导入商品档案。 +// 上传只建档,不会自动创建采集任务;导入结果页提供单独的确认入口。 +func (h *Handler) PddImport(c *gin.Context) { + fileHeader, err := c.FormFile("file") + if err != nil { + fail(c, http.StatusBadRequest, + "没有收到文件(也可能是文件超过了 10MB 上限)。请重新选择 Excel;没有写入任何商品。") + return + } + src, err := fileHeader.Open() + if err != nil { + fail(c, http.StatusBadRequest, "无法打开上传文件,请重新选择;没有写入任何商品。") + return + } + defer src.Close() + + head := make([]byte, 8) + n, _ := io.ReadFull(src, head) + if err := service.ValidatePddUpload(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 + } + tmp, err := os.CreateTemp(uploadDir, "pdd-*.xlsx") + if err != nil { + fail(c, http.StatusInternalServerError, "无法保存临时上传文件,请重试;没有写入任何商品。") + return + } + tmpPath := tmp.Name() + defer os.Remove(tmpPath) + if _, err := io.Copy(tmp, src); err != nil { + tmp.Close() + fail(c, http.StatusInternalServerError, "保存临时上传文件失败,请重试;没有写入任何商品。") + return + } + if err := tmp.Close(); err != nil { + fail(c, http.StatusInternalServerError, "保存临时上传文件失败,请重试;没有写入任何商品。") + return + } + + result, err := service.ImportPddExcel(h.db, tmpPath) + if err != nil { + if service.IsInvalidPddImport(err) { + fail(c, http.StatusBadRequest, err.Error()+"。请修正文件后重新导入;没有写入任何商品。") + return + } + fail(c, http.StatusInternalServerError, + "PDD 商品导入时数据库写入失败,本批合法行已经整体回滚。请稍后重试;一直失败请联系维护者。") + return + } + + message := fmt.Sprintf( + "批量导入完成:总行数 %d,新建 %d,已存在 %d,恢复 %d,文件内重复 %d,失败 %d", + result.TotalRows, result.CreatedCount, result.ExistingCount, result.RevivedCount, + result.DuplicateCount, len(result.Failures)) + h.renderPddList(c, "", "", "1", message, result) +} + // PddSave 保存弹窗里改过的链接。 func (h *Handler) PddSave(c *gin.Context) { id, err := strconv.ParseInt(c.PostForm("id"), 10, 64) diff --git a/admin/handler/web/web.go b/admin/handler/web/web.go index 1ba9000..e67989c 100644 --- a/admin/handler/web/web.go +++ b/admin/handler/web/web.go @@ -65,6 +65,7 @@ func Register(r *gin.Engine, db *sql.DB, onlineThreshold time.Duration) { pages.GET("/pdd", h.PddList) pages.GET("/pdd/detail", h.PddDetail) // 双击行时前端来取弹窗内容 pages.POST("/pdd/create", h.PddCreate) + pages.POST("/pdd/import", h.PddImport) pages.POST("/pdd/save", h.PddSave) pages.POST("/pdd/collect", h.PddCollect) pages.POST("/pdd/delete", h.PddDelete) diff --git a/admin/main_test.go b/admin/main_test.go index 46733b6..bb0dd79 100644 --- a/admin/main_test.go +++ b/admin/main_test.go @@ -36,6 +36,12 @@ func TestPaginationCSS_桌面右对齐且窄窗口不遮挡内容(t *testing.T) } } +func TestTemplatesAndRoutesCanBeBuiltWithoutDatabaseConnection(t *testing.T) { + if _, err := newRouter(nil); err != nil { + t.Fatalf("模板或路由组装失败: %v", err) + } +} + // 六个主页面都走一次真实路由和模板渲染。 // 这样模板字段写错或新增列漏接时,测试阶段就会失败,不必等人工点页面。 func TestMainPagesReturnOK(t *testing.T) { @@ -85,6 +91,20 @@ func TestMainPagesReturnOK(t *testing.T) { }) } + pddRequest := httptest.NewRequest(http.MethodGet, "/pdd", nil) + addAuth(pddRequest) + pddResponse := httptest.NewRecorder() + router.ServeHTTP(pddResponse, pddRequest) + for _, want := range []string{ + "批量导入", `action="/pdd/import"`, `enctype="multipart/form-data"`, + `id="pdd-import-file"`, `aria-describedby="pdd-import-help"`, + "拼多多链接", "最多 5000 条", `data-upload-form`, + } { + if !strings.Contains(pddResponse.Body.String(), want) { + t.Errorf("PDD 页面缺少批量导入界面 %q", want) + } + } + if err := service.CreatePurchaser(db, adminUser, "buyer", "buyer-password", "buyer-password", time.Now()); err != nil { t.Fatalf("准备测试采购员失败: %v", err) } diff --git a/admin/pdd_import_template_test.go b/admin/pdd_import_template_test.go new file mode 100644 index 0000000..60771bf --- /dev/null +++ b/admin/pdd_import_template_test.go @@ -0,0 +1,46 @@ +package main + +import ( + "bytes" + "html/template" + "strings" + "testing" + + "cmautobuy/admin/service" +) + +func TestPddImportResultTemplate_完整集合和失败明细安全渲染(t *testing.T) { + tmpl, err := template.ParseFS(templateFS, "templates/*/*.html") + if err != nil { + t.Fatal(err) + } + var output bytes.Buffer + data := map[string]any{ + "Title": "PDD 商品", "Active": "pdd", "CSRFToken": "test-csrf", + "ImportResult": &service.PddImportResult{ + TotalRows: 25, CreatedCount: 2, ExistingCount: 1, DuplicateCount: 1, + GoodsIDs: []string{"737116531267", "647453710994"}, + Failures: []service.PddImportFailure{{ + Row: 5, Raw: "", Reason: "不是拼多多网址", + }}, + }, + } + if err := tmpl.ExecuteTemplate(&output, "pdd/list", data); err != nil { + t.Fatalf("渲染 PDD 导入结果失败: %v", err) + } + body := output.String() + for _, want := range []string{ + "本次导入结果", "总行数 25", + `data-collect-form="pdd-import-collect-form"`, + `name="ids" value="737116531267"`, + `name="ids" value="647453710994"`, + "Excel 行号", "不是拼多多网址", "<script>alert(1)</script>", + } { + if !strings.Contains(body, want) { + t.Errorf("导入结果页面缺少 %q", want) + } + } + if strings.Contains(body, "") { + t.Error("Excel 原始内容没有经过 html/template 自动转义") + } +} diff --git a/admin/repository/pdd.go b/admin/repository/pdd.go index 702d87f..e072afa 100644 --- a/admin/repository/pdd.go +++ b/admin/repository/pdd.go @@ -47,6 +47,16 @@ func scanPddProduct(s rowScanner, extra ...any) (*model.PddProduct, error) { return &p, nil } +// EnsurePddProductOutcome 说明 EnsurePddProductWithOutcome 实际做了什么。 +// 批量导入用它给采购员显示准确统计;普通单条创建只关心最终商品。 +type EnsurePddProductOutcome string + +const ( + PddProductCreated EnsurePddProductOutcome = "created" + PddProductExisting EnsurePddProductOutcome = "existing" + PddProductRevived EnsurePddProductOutcome = "revived" +) + // EnsurePddProduct 保证 goods_id 对应的 PDD 商品存在,返回它。 // // 操作员在货运单或蝦皮商品上填 PDD 链接、点保存时调它。三种情况: @@ -58,16 +68,23 @@ func scanPddProduct(s rowScanner, extra ...any) (*model.PddProduct, error) { // 复活是必要的:goods_id 上有 UNIQUE 约束,软删除的行还占着那个值, // 不复活就会插入冲突,操作员会看到一个莫名其妙的错误。 func EnsurePddProduct(q Execer, goodsID, url string) (*model.PddProduct, error) { + p, _, err := EnsurePddProductWithOutcome(q, goodsID, url) + return p, err +} + +// EnsurePddProductWithOutcome 与 EnsurePddProduct 使用完全相同的建档规则, +// 额外返回新建、复用或复活分类,供批量导入统计使用。 +func EnsurePddProductWithOutcome(q Execer, goodsID, url string) (*model.PddProduct, EnsurePddProductOutcome, error) { if goodsID == "" { - return nil, fmt.Errorf("goods_id 不能为空") + return nil, "", fmt.Errorf("goods_id 不能为空") } if url == "" { - return nil, fmt.Errorf("url 不能为空") + return nil, "", fmt.Errorf("url 不能为空") } existing, err := GetPddProductByGoodsID(q, goodsID) if err != nil { - return nil, err + return nil, "", err } now := model.NowISO() @@ -78,14 +95,14 @@ func EnsurePddProduct(q Execer, goodsID, url string) (*model.PddProduct, error) VALUES (?, ?, 'pending', ?, ?)`, goodsID, url, now, now) if err != nil { - return nil, fmt.Errorf("新建 PDD 商品 %s 失败: %w", goodsID, err) + return nil, "", fmt.Errorf("新建 PDD 商品 %s 失败: %w", goodsID, err) } id, _ := res.LastInsertId() return &model.PddProduct{ ID: id, GoodsID: goodsID, URL: url, CollectStatus: model.CollectPending, CreatedAt: now, UpdatedAt: now, - }, nil + }, PddProductCreated, nil } if existing.IsDeleted() { @@ -103,9 +120,10 @@ func EnsurePddProduct(q Execer, goodsID, url string) (*model.PddProduct, error) WHERE goods_id = ?`, url, now, goodsID) if err != nil { - return nil, fmt.Errorf("复活 PDD 商品 %s 失败: %w", goodsID, err) + return nil, "", fmt.Errorf("复活 PDD 商品 %s 失败: %w", goodsID, err) } - return GetPddProductByGoodsID(q, goodsID) + p, err := GetPddProductByGoodsID(q, goodsID) + return p, PddProductRevived, err } // 已存在且有效:链接可能写法不同(带不带参数),更新一下原文, @@ -114,11 +132,11 @@ func EnsurePddProduct(q Execer, goodsID, url string) (*model.PddProduct, error) if _, err := q.Exec( `UPDATE pdd_products SET url = ?, updated_at = ? WHERE goods_id = ?`, url, now, goodsID); err != nil { - return nil, fmt.Errorf("更新 PDD 商品 %s 链接失败: %w", goodsID, err) + return nil, "", fmt.Errorf("更新 PDD 商品 %s 链接失败: %w", goodsID, err) } existing.URL = url } - return existing, nil + return existing, PddProductExisting, nil } // GetPddProductByGoodsID 按 goods_id 查,**包括已软删除的**。 diff --git a/admin/service/pdd_import.go b/admin/service/pdd_import.go new file mode 100644 index 0000000..34dca6f --- /dev/null +++ b/admin/service/pdd_import.go @@ -0,0 +1,228 @@ +// PDD 商品链接 Excel 批量导入。 +// +// 本文件只负责文件校验、逐行解析、业务去重和事务编排;SQL 仍然全部在 +// repository,HTTP 上传细节仍然全部在 handler/web。 +package service + +import ( + "bytes" + "database/sql" + "errors" + "fmt" + "path/filepath" + "strings" + + "github.com/xuri/excelize/v2" + + "cmautobuy/admin/repository" +) + +const ( + // MaxPddUploadBytes 足够容纳 5000 条单列链接,同时限制误传大文件。 + MaxPddUploadBytes = 10 * 1024 * 1024 + // MaxPddImportRows 限制一次导入的非空数据行,避免误发超大批次。 + MaxPddImportRows = 5000 + pddLinkHeader = "拼多多链接" +) + +var pddXLSXMagic = []byte{0x50, 0x4B, 0x03, 0x04} + +// ErrInvalidPddImport 区分“文件需要采购员修正”和“服务器写库失败”。 +// Handler 据此选择 400 或 500,避免把数据库内部错误直接显示到页面。 +var ErrInvalidPddImport = errors.New("PDD 导入文件无效") + +// IsInvalidPddImport 判断错误是否来自上传文件内容或结构。 +func IsInvalidPddImport(err error) bool { + return errors.Is(err, ErrInvalidPddImport) +} + +// PddImportFailure 是一条不能导入的 Excel 数据行。 +type PddImportFailure struct { + Row int + Raw string + Reason string +} + +// PddImportResult 是本次导入的完整统计。 +// GoodsIDs 保存全部合法且去重后的商品,供页面显式创建本批采集任务。 +type PddImportResult struct { + TotalRows int + CreatedCount int + ExistingCount int + RevivedCount int + DuplicateCount int + Failures []PddImportFailure + GoodsIDs []string +} + +// ValidatePddUpload 在落盘和解析前校验上传文件的基本安全边界。 +func ValidatePddUpload(filename string, size int64, head []byte) error { + if size <= 0 { + return fmt.Errorf("文件是空的") + } + if size > MaxPddUploadBytes { + return fmt.Errorf("文件超过 10MB 上限(当前约 %.1fMB)", float64(size)/1024/1024) + } + if !strings.EqualFold(filepath.Ext(filename), ".xlsx") { + return fmt.Errorf("只允许 .xlsx 文件,收到的是 %q", filepath.Ext(filename)) + } + if !bytes.HasPrefix(head, pddXLSXMagic) { + return fmt.Errorf("文件内容不像一个 xlsx(zip 格式)文件,可能是改了扩展名的其他文件") + } + return nil +} + +type pddImportEntry struct { + GoodsID string + URL string +} + +// ImportPddExcel 导入第一个非空工作表的“拼多多链接”列。 +// +// 格式错误只影响对应行,合法行仍会进入同一个数据库事务;数据库写入任一步 +// 失败则整体回滚,避免留下无法解释的半批数据。 +func ImportPddExcel(db *sql.DB, path string) (*PddImportResult, error) { + book, err := excelize.OpenFile(path) + if err != nil { + return nil, fmt.Errorf("%w:打开 Excel 失败(%v)", ErrInvalidPddImport, err) + } + defer book.Close() + + result, entries, err := parsePddImportWorkbook(book) + if err != nil { + return nil, fmt.Errorf("%w:%v", ErrInvalidPddImport, err) + } + if len(entries) == 0 { + return result, nil + } + + tx, err := db.Begin() + if err != nil { + return nil, fmt.Errorf("开始 PDD 导入事务失败: %w", err) + } + defer tx.Rollback() + + for _, entry := range entries { + _, outcome, err := repository.EnsurePddProductWithOutcome(tx, entry.GoodsID, entry.URL) + if err != nil { + return nil, fmt.Errorf("写入 PDD 商品 %s 失败: %w", entry.GoodsID, err) + } + switch outcome { + case repository.PddProductCreated: + result.CreatedCount++ + case repository.PddProductExisting: + result.ExistingCount++ + case repository.PddProductRevived: + result.RevivedCount++ + default: + return nil, fmt.Errorf("PDD 商品 %s 返回了未知导入结果 %q", entry.GoodsID, outcome) + } + } + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("提交 PDD 导入事务失败: %w", err) + } + return result, nil +} + +func parsePddImportWorkbook(book *excelize.File) (*PddImportResult, []pddImportEntry, error) { + for _, sheet := range book.GetSheetList() { + rows, err := book.Rows(sheet) + if err != nil { + return nil, nil, fmt.Errorf("读取工作表 %q 失败: %w", sheet, err) + } + result, entries, found, parseErr := parsePddImportRows(rows, sheet) + closeErr := rows.Close() + if parseErr != nil { + return nil, nil, parseErr + } + if closeErr != nil { + return nil, nil, fmt.Errorf("关闭工作表 %q 失败: %w", sheet, closeErr) + } + if found { + return result, entries, nil + } + } + return nil, nil, fmt.Errorf("Excel 中没有非空工作表,请把链接填在第一列并使用表头“%s”", pddLinkHeader) +} + +func parsePddImportRows(rows *excelize.Rows, sheet string) (*PddImportResult, []pddImportEntry, bool, error) { + result := &PddImportResult{} + entries := make([]pddImportEntry, 0, 64) + seen := make(map[string]struct{}, 64) + foundHeader := false + rowNumber := 0 + + for rows.Next() { + rowNumber++ + columns, err := rows.Columns() + if err != nil { + return nil, nil, false, fmt.Errorf("读取工作表 %q 第 %d 行失败: %w", sheet, rowNumber, err) + } + if rowIsBlank(columns) { + continue + } + if !foundHeader { + foundHeader = true + header := firstColumn(columns) + if header != pddLinkHeader { + return nil, nil, false, fmt.Errorf( + "工作表 %q 第一个非空行的第一列应为“%s”,实际是 %q", + sheet, pddLinkHeader, header) + } + continue + } + + result.TotalRows++ + if result.TotalRows > MaxPddImportRows { + return nil, nil, false, fmt.Errorf( + "非空数据超过 %d 条上限(在工作表 %q 第 %d 行发现超限),请拆成多个文件导入", + MaxPddImportRows, sheet, rowNumber) + } + + raw := firstColumn(columns) + if raw == "" { + result.Failures = append(result.Failures, PddImportFailure{ + Row: rowNumber, Raw: "(空)", Reason: "第一列“拼多多链接”不能为空", + }) + continue + } + goodsID, err := ParsePddGoodsID(raw) + if err != nil { + result.Failures = append(result.Failures, PddImportFailure{ + Row: rowNumber, Raw: raw, Reason: err.Error(), + }) + continue + } + if _, duplicate := seen[goodsID]; duplicate { + result.DuplicateCount++ + continue + } + seen[goodsID] = struct{}{} + entries = append(entries, pddImportEntry{GoodsID: goodsID, URL: raw}) + result.GoodsIDs = append(result.GoodsIDs, goodsID) + } + if err := rows.Error(); err != nil { + return nil, nil, false, fmt.Errorf("遍历工作表 %q 失败: %w", sheet, err) + } + if foundHeader && result.TotalRows == 0 { + return nil, nil, false, fmt.Errorf( + "工作表 %q 只有表头,下面没有 PDD 商品链接", sheet) + } + return result, entries, foundHeader, nil +} + +func rowIsBlank(columns []string) bool { + for _, value := range columns { + if strings.TrimSpace(value) != "" { + return false + } + } + return true +} + +func firstColumn(columns []string) string { + if len(columns) == 0 { + return "" + } + return strings.TrimSpace(columns[0]) +} diff --git a/admin/service/pdd_import_test.go b/admin/service/pdd_import_test.go new file mode 100644 index 0000000..65b6647 --- /dev/null +++ b/admin/service/pdd_import_test.go @@ -0,0 +1,264 @@ +package service + +import ( + "database/sql" + "fmt" + "path/filepath" + "strings" + "testing" + + "github.com/xuri/excelize/v2" + + "cmautobuy/admin/model" + "cmautobuy/admin/repository" +) + +func TestValidatePddUpload(t *testing.T) { + validHead := []byte{0x50, 0x4B, 0x03, 0x04, 0x14} + for _, tc := range []struct { + name string + filename string + size int64 + head []byte + wantErr bool + }{ + {"合法文件", "pdd.xlsx", 1024, validHead, false}, + {"扩展名大小写", "pdd.XLSX", 1024, validHead, false}, + {"空文件", "pdd.xlsx", 0, validHead, true}, + {"超过上限", "pdd.xlsx", MaxPddUploadBytes + 1, validHead, true}, + {"错误扩展名", "pdd.xls", 1024, validHead, true}, + {"伪造扩展名", "pdd.xlsx", 1024, []byte("not zip"), true}, + } { + t.Run(tc.name, func(t *testing.T) { + err := ValidatePddUpload(tc.filename, tc.size, tc.head) + if (err != nil) != tc.wantErr { + t.Fatalf("ValidatePddUpload() err=%v, wantErr=%t", err, tc.wantErr) + } + }) + } +} + +func TestParsePddImportWorkbook_忽略空表并逐行反馈(t *testing.T) { + book := excelize.NewFile() + t.Cleanup(func() { book.Close() }) + if err := book.SetSheetName("Sheet1", "空表"); err != nil { + t.Fatal(err) + } + dataSheet, err := book.NewSheet("链接") + if err != nil { + t.Fatal(err) + } + book.SetActiveSheet(dataSheet) + rows := [][]string{ + {pddLinkHeader}, + {"https://mobile.yangkeduo.com/goods.html?goods_id=737116531267"}, + {"https://mobile.pinduoduo.com/goods.html?goods_id=647453710994"}, + {"https://mobile.yangkeduo.com/goods.html?goods_id=737116531267&from=duplicate"}, + {"https://p.pinduoduo.com/short"}, + {"", "第二列误填内容"}, + } + setWorkbookRows(t, book, "链接", rows) + + result, entries, err := parsePddImportWorkbook(book) + if err != nil { + t.Fatalf("解析失败: %v", err) + } + if result.TotalRows != 5 || result.DuplicateCount != 1 || len(result.Failures) != 2 { + t.Fatalf("统计不对: %+v", result) + } + if len(entries) != 2 || len(result.GoodsIDs) != 2 { + t.Fatalf("合法商品应为 2 条: entries=%d ids=%v", len(entries), result.GoodsIDs) + } + if result.Failures[0].Row != 5 || result.Failures[1].Row != 6 { + t.Fatalf("失败行号不对: %+v", result.Failures) + } + if !strings.Contains(result.Failures[0].Reason, "goods_id") || + !strings.Contains(result.Failures[1].Reason, "不能为空") { + t.Fatalf("失败原因不可操作: %+v", result.Failures) + } +} + +func TestParsePddImportWorkbook_脱敏固定样本(t *testing.T) { + book, err := excelize.OpenFile(filepath.Join("..", "testdata", "pdd_links.xlsx")) + if err != nil { + t.Fatal(err) + } + defer book.Close() + result, entries, err := parsePddImportWorkbook(book) + if err != nil { + t.Fatalf("解析脱敏样本失败: %v", err) + } + if result.TotalRows != 5 || len(entries) != 3 || result.DuplicateCount != 1 || len(result.Failures) != 1 { + t.Fatalf("脱敏样本统计不对: result=%+v entries=%d", result, len(entries)) + } +} + +func TestParsePddImportWorkbook_错误表头和行数上限整体拒绝(t *testing.T) { + t.Run("错误表头", func(t *testing.T) { + book := excelize.NewFile() + defer book.Close() + setWorkbookRows(t, book, "Sheet1", [][]string{{"链接"}, {pddURL("737116531267")}}) + _, _, err := parsePddImportWorkbook(book) + if err == nil || !strings.Contains(err.Error(), pddLinkHeader) { + t.Fatalf("应明确提示正确表头,实际 %v", err) + } + }) + + t.Run("只有表头", func(t *testing.T) { + book := excelize.NewFile() + defer book.Close() + setWorkbookRows(t, book, "Sheet1", [][]string{{pddLinkHeader}}) + _, _, err := parsePddImportWorkbook(book) + if err == nil || !strings.Contains(err.Error(), "只有表头") { + t.Fatalf("应提示没有商品链接,实际 %v", err) + } + }) + + t.Run("超过5000条", func(t *testing.T) { + book := excelize.NewFile() + defer book.Close() + rows := make([][]string, 0, MaxPddImportRows+2) + rows = append(rows, []string{pddLinkHeader}) + for i := 0; i <= MaxPddImportRows; i++ { + rows = append(rows, []string{pddURL(fmt.Sprintf("%012d", 100000000000+i))}) + } + setWorkbookRows(t, book, "Sheet1", rows) + _, _, err := parsePddImportWorkbook(book) + if err == nil || !strings.Contains(err.Error(), "5000") || !strings.Contains(err.Error(), "拆成多个文件") { + t.Fatalf("超限提示不明确: %v", err) + } + }) +} + +func TestImportPddExcel_新建复用复活重复和失败可同时统计(t *testing.T) { + db := newPddImportSQLiteDB(t, false) + existingID := "647453710994" + revivedID := "753136429979" + + if _, err := repository.EnsurePddProduct(db, existingID, pddURL(existingID)); err != nil { + t.Fatal(err) + } + if err := repository.SetCollectResult(db, existingID, "已采集商品", "测试店铺", `{"skus":[1]}`); err != nil { + t.Fatal(err) + } + if _, err := repository.EnsurePddProduct(db, revivedID, pddURL(revivedID)); err != nil { + t.Fatal(err) + } + if err := repository.SetCollectResult(db, revivedID, "删除前商品", "旧店铺", `{"skus":[1]}`); err != nil { + t.Fatal(err) + } + if _, err := repository.SoftDeletePddProducts(db, []string{revivedID}); err != nil { + t.Fatal(err) + } + + path := filepath.Join("..", "testdata", "pdd_links.xlsx") + result, err := ImportPddExcel(db, path) + if err != nil { + t.Fatalf("导入失败: %v", err) + } + if result.TotalRows != 5 || result.CreatedCount != 1 || result.ExistingCount != 1 || + result.RevivedCount != 1 || result.DuplicateCount != 1 || len(result.Failures) != 1 { + t.Fatalf("导入统计不对: %+v", result) + } + if len(result.GoodsIDs) != 3 { + t.Fatalf("本次导入集合应跨分页保留全部 3 个 ID,实际 %v", result.GoodsIDs) + } + + existing, _ := repository.GetPddProductByGoodsID(db, existingID) + if existing.CollectStatus != model.CollectCollected || existing.SkusJSON == "" || existing.Title != "已采集商品" { + t.Fatalf("已存在商品的采集结果被覆盖: %+v", existing) + } + if !strings.Contains(existing.URL, "from=excel") { + t.Errorf("已存在商品应更新同 goods_id 的链接原文,实际 %q", existing.URL) + } + revived, _ := repository.GetPddProductByGoodsID(db, revivedID) + if revived.IsDeleted() || revived.CollectStatus != model.CollectPending || revived.SkusJSON != "" || revived.Title != "" { + t.Fatalf("复活商品未沿用现有清理规则: %+v", revived) + } + + second, err := ImportPddExcel(db, path) + if err != nil { + t.Fatalf("重复导入失败: %v", err) + } + if second.CreatedCount != 0 || second.ExistingCount != 3 || second.RevivedCount != 0 { + t.Fatalf("重复导入应全部复用,实际 %+v", second) + } + var count int + if err := db.QueryRow(`SELECT COUNT(*) FROM pdd_products`).Scan(&count); err != nil || count != 3 { + t.Fatalf("重复导入产生重复商品: count=%d err=%v", count, err) + } + var taskCount int + if err := db.QueryRow(`SELECT COUNT(*) FROM tasks`).Scan(&taskCount); err != nil || taskCount != 0 { + t.Fatalf("上传不应自动创建采集任务: count=%d err=%v", taskCount, err) + } +} + +func TestImportPddExcel_数据库中途失败时合法行整体回滚(t *testing.T) { + db := newPddImportSQLiteDB(t, true) + + _, err := ImportPddExcel(db, filepath.Join("..", "testdata", "pdd_links.xlsx")) + if err == nil || IsInvalidPddImport(err) { + t.Fatalf("应返回数据库写入错误,实际 %v", err) + } + var count int + if err := db.QueryRow(`SELECT COUNT(*) FROM pdd_products`).Scan(&count); err != nil { + t.Fatal(err) + } + if count != 0 { + t.Fatalf("第一条合法商品没有随第二条失败回滚,仍有 %d 条", count) + } +} + +func newPddImportSQLiteDB(t *testing.T, failSecondInsert bool) *sql.DB { + t.Helper() + dsn := fmt.Sprintf("file:pdd_import_%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_")) + db, err := sql.Open("sqlite", dsn) + if err != nil { + t.Fatal(err) + } + db.SetMaxOpenConns(1) + t.Cleanup(func() { db.Close() }) + if _, err := db.Exec(` + CREATE TABLE pdd_products ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + goods_id TEXT NOT NULL UNIQUE, + url TEXT NOT NULL, + title TEXT, shop_name TEXT, skus_json TEXT, + collect_status TEXT NOT NULL, + collect_msg TEXT, artifact_ref TEXT, collected_at TEXT, + deleted_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL + ); + CREATE TABLE tasks (task_id TEXT PRIMARY KEY); + `); err != nil { + t.Fatal(err) + } + if failSecondInsert { + if _, err := db.Exec(` + CREATE TRIGGER fail_second_pdd BEFORE INSERT ON pdd_products + WHEN NEW.goods_id = '647453710994' + BEGIN SELECT RAISE(ABORT, 'simulated write failure'); END; + `); err != nil { + t.Fatal(err) + } + } + return db +} + +func setWorkbookRows(t *testing.T, book *excelize.File, sheet string, rows [][]string) { + t.Helper() + for rowIndex, row := range rows { + for columnIndex, value := range row { + cell, err := excelize.CoordinatesToCellName(columnIndex+1, rowIndex+1) + if err != nil { + t.Fatal(err) + } + if err := book.SetCellValue(sheet, cell, value); err != nil { + t.Fatal(err) + } + } + } +} + +func pddURL(goodsID string) string { + return "https://mobile.yangkeduo.com/goods.html?goods_id=" + goodsID +} diff --git a/admin/static/css/app.css b/admin/static/css/app.css index 42447c2..0fbd6dc 100644 --- a/admin/static/css/app.css +++ b/admin/static/css/app.css @@ -175,6 +175,41 @@ tr.empty small { color: #aaa; } margin: 12px 2px; } +/* PDD 批量导入结果:统计、下一步和失败明细放在同一区域, + 采购员不需要从底部状态条猜本批数据发生了什么。 */ +.import-result { + margin-bottom: 12px; + padding: 12px; + background: #fff; + border: 1px solid #c9d8ee; + border-left: 4px solid #1f6feb; + border-radius: 4px; +} +.import-result-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.import-result h2 { margin: 0 0 6px; font-size: 16px; } +.import-result p { margin: 0; } +.import-result .hint { margin-top: 8px; } +.import-success { color: #176b36; font-weight: 600; } +.import-failure-table { + max-height: 280px; + margin-top: 10px; +} +.import-failure-table .import-raw, +.import-failure-table .import-reason { + min-width: 260px; + max-width: 520px; + white-space: normal; + overflow-wrap: anywhere; +} +@media (max-width: 760px) { + .import-result-head { align-items: flex-start; flex-direction: column; } +} + /* ── 第三段:底部状态条 ───────────────── */ .statusbar { position: fixed; diff --git a/admin/static/js/app.js b/admin/static/js/app.js index db3b116..f263a59 100644 --- a/admin/static/js/app.js +++ b/admin/static/js/app.js @@ -189,21 +189,40 @@ /* PDD 批量采集弹窗只同步已选数量并提供提交反馈。 客户端权限和任务初始状态始终由服务端决定。 */ - function setupCollectModal() { - var openButton = document.querySelector("[data-collect-open]"); - var count = document.querySelector("[data-collect-count]"); - var form = document.getElementById("pdd-form"); - var submitButton = document.querySelector("[data-collect-submit]"); - if (!openButton || !count || !form || !submitButton) return; + function setupCollectModals() { + document.querySelectorAll("[data-collect-open]").forEach(function (openButton) { + var formID = openButton.getAttribute("data-collect-form") || "pdd-form"; + var form = document.getElementById(formID); + var modal = document.getElementById(openButton.getAttribute("data-modal-open")); + if (!form || !modal) return; + var count = modal.querySelector("[data-collect-count]"); + var submitButton = modal.querySelector("[data-collect-submit]"); + if (!count || !submitButton) return; - openButton.addEventListener("click", function () { - count.textContent = checkedCount(); + openButton.addEventListener("click", function () { + var fixedCount = openButton.getAttribute("data-collect-count-value"); + count.textContent = fixedCount === null ? checkedCount() : fixedCount; + }); + form.addEventListener("submit", function (event) { + if (event.submitter !== submitButton) return; + submitButton.disabled = true; + submitButton.setAttribute("aria-busy", "true"); + submitButton.textContent = "创建中…"; + }); }); - form.addEventListener("submit", function (event) { - if (event.submitter !== submitButton) return; - submitButton.disabled = true; - submitButton.setAttribute("aria-busy", "true"); - submitButton.textContent = "创建中…"; + } + + /* Excel 解析可能需要几秒。浏览器校验通过并开始上传后立即锁住提交按钮, + 防止采购员因为没有反馈而重复提交同一批文件。 */ + function setupUploadFeedback() { + document.querySelectorAll("[data-upload-form]").forEach(function (form) { + var button = form.querySelector("[data-upload-submit]"); + if (!button) return; + form.addEventListener("submit", function () { + button.disabled = true; + button.setAttribute("aria-busy", "true"); + button.textContent = "导入中…"; + }); }); } @@ -423,7 +442,8 @@ setupUserResetModal(); setupClientAssignmentModal(); setupPurchaseModal(); - setupCollectModal(); + setupCollectModals(); + setupUploadFeedback(); setupModals(); setupRowDetail(); setupCaptchaRefresh(); diff --git a/admin/templates/pdd/list.html b/admin/templates/pdd/list.html index 47b3624..f5febcd 100644 --- a/admin/templates/pdd/list.html +++ b/admin/templates/pdd/list.html @@ -7,6 +7,7 @@ {{/* 文案写全「添加 PDD 商品」:这条工具条上还有「创建采集任务」, 两个都以「创建」开头的话,第一次用的人分不清哪个是加商品、哪个是发起采集。 */}} + {{/* 「创建采集任务」和「删除」用的是**同一个表单**,靠按钮上的 formaction 决定提交到哪个地址。这样表格里的勾选框只需要挂一份, @@ -21,6 +22,7 @@ {{/* 文案是「创建采集任务」不是「采集」:点完只是排了个队, 真正去手机上采要等客户端来领,可能几秒也可能几分钟 */}}