feat: 支持批量导入 PDD 商品链接 (#104)
This commit is contained in:
@@ -7,6 +7,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"cmautobuy/admin/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CSRF 防护,用的是「双提交 Cookie」这个最简单的方案:
|
// CSRF 防护,用的是「双提交 Cookie」这个最简单的方案:
|
||||||
@@ -57,6 +59,17 @@ func CSRFMiddleware() gin.HandlerFunc {
|
|||||||
return
|
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)
|
submitted := c.PostForm(csrfFieldName)
|
||||||
if submitted == "" {
|
if submitted == "" {
|
||||||
submitted = c.GetHeader("X-CSRF-Token")
|
submitted = c.GetHeader("X-CSRF-Token")
|
||||||
|
|||||||
@@ -2,12 +2,15 @@ package web
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"cmautobuy/admin/config"
|
||||||
"cmautobuy/admin/service"
|
"cmautobuy/admin/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -18,9 +21,13 @@ import (
|
|||||||
|
|
||||||
// PddList 渲染 PDD 商品列表页。
|
// PddList 渲染 PDD 商品列表页。
|
||||||
func (h *Handler) PddList(c *gin.Context) {
|
func (h *Handler) PddList(c *gin.Context) {
|
||||||
keyword := c.Query("q")
|
h.renderPddList(c, c.Query("q"), c.Query("status"), c.Query("page"), "", nil)
|
||||||
status := service.ParseCollectStatus(c.Query("status"))
|
}
|
||||||
pageNum := service.ParsePage(c.Query("page"))
|
|
||||||
|
// 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)
|
result, err := service.ListPddProducts(h.db, keyword, status, pageNum)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -38,8 +45,11 @@ func (h *Handler) PddList(c *gin.Context) {
|
|||||||
// 底部状态条平时显示统计,刚做完操作时先显示操作结果。
|
// 底部状态条平时显示统计,刚做完操作时先显示操作结果。
|
||||||
// 操作结果是通过跳转带过来的 msg 参数传的(见 pddRedirect)。
|
// 操作结果是通过跳转带过来的 msg 参数传的(见 pddRedirect)。
|
||||||
statusLine := result.StatusLine()
|
statusLine := result.StatusLine()
|
||||||
if msg := c.Query("msg"); msg != "" {
|
if msg := c.Query("msg"); msg != "" && message == "" {
|
||||||
statusLine = msg + " · " + statusLine
|
message = msg
|
||||||
|
}
|
||||||
|
if message != "" {
|
||||||
|
statusLine = message + " · " + statusLine
|
||||||
}
|
}
|
||||||
|
|
||||||
values := url.Values{}
|
values := url.Values{}
|
||||||
@@ -66,6 +76,7 @@ func (h *Handler) PddList(c *gin.Context) {
|
|||||||
"Pagination": service.NewPaginationView(result.Page, result.TotalPages, values.Encode()),
|
"Pagination": service.NewPaginationView(result.Page, result.TotalPages, values.Encode()),
|
||||||
"DetailURL": "/pdd/detail?" + detailValues.Encode(),
|
"DetailURL": "/pdd/detail?" + detailValues.Encode(),
|
||||||
"AssignableClients": assignableClients,
|
"AssignableClients": assignableClients,
|
||||||
|
"ImportResult": importResult,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,6 +123,73 @@ func (h *Handler) PddCreate(c *gin.Context) {
|
|||||||
h.pddRedirect(c, fmt.Sprintf("已保存商品 %s,现在可以勾选它创建采集任务", goodsID))
|
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 保存弹窗里改过的链接。
|
// PddSave 保存弹窗里改过的链接。
|
||||||
func (h *Handler) PddSave(c *gin.Context) {
|
func (h *Handler) PddSave(c *gin.Context) {
|
||||||
id, err := strconv.ParseInt(c.PostForm("id"), 10, 64)
|
id, err := strconv.ParseInt(c.PostForm("id"), 10, 64)
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ func Register(r *gin.Engine, db *sql.DB, onlineThreshold time.Duration) {
|
|||||||
pages.GET("/pdd", h.PddList)
|
pages.GET("/pdd", h.PddList)
|
||||||
pages.GET("/pdd/detail", h.PddDetail) // 双击行时前端来取弹窗内容
|
pages.GET("/pdd/detail", h.PddDetail) // 双击行时前端来取弹窗内容
|
||||||
pages.POST("/pdd/create", h.PddCreate)
|
pages.POST("/pdd/create", h.PddCreate)
|
||||||
|
pages.POST("/pdd/import", h.PddImport)
|
||||||
pages.POST("/pdd/save", h.PddSave)
|
pages.POST("/pdd/save", h.PddSave)
|
||||||
pages.POST("/pdd/collect", h.PddCollect)
|
pages.POST("/pdd/collect", h.PddCollect)
|
||||||
pages.POST("/pdd/delete", h.PddDelete)
|
pages.POST("/pdd/delete", h.PddDelete)
|
||||||
|
|||||||
@@ -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) {
|
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 {
|
if err := service.CreatePurchaser(db, adminUser, "buyer", "buyer-password", "buyer-password", time.Now()); err != nil {
|
||||||
t.Fatalf("准备测试采购员失败: %v", err)
|
t.Fatalf("准备测试采购员失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: "<script>alert(1)</script>", Reason: "不是拼多多网址",
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := tmpl.ExecuteTemplate(&output, "pdd/list", data); err != nil {
|
||||||
|
t.Fatalf("渲染 PDD 导入结果失败: %v", err)
|
||||||
|
}
|
||||||
|
body := output.String()
|
||||||
|
for _, want := range []string{
|
||||||
|
"本次导入结果", "总行数 <strong>25</strong>",
|
||||||
|
`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, "<script>alert(1)</script>") {
|
||||||
|
t.Error("Excel 原始内容没有经过 html/template 自动转义")
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
-9
@@ -47,6 +47,16 @@ func scanPddProduct(s rowScanner, extra ...any) (*model.PddProduct, error) {
|
|||||||
return &p, nil
|
return &p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EnsurePddProductOutcome 说明 EnsurePddProductWithOutcome 实际做了什么。
|
||||||
|
// 批量导入用它给采购员显示准确统计;普通单条创建只关心最终商品。
|
||||||
|
type EnsurePddProductOutcome string
|
||||||
|
|
||||||
|
const (
|
||||||
|
PddProductCreated EnsurePddProductOutcome = "created"
|
||||||
|
PddProductExisting EnsurePddProductOutcome = "existing"
|
||||||
|
PddProductRevived EnsurePddProductOutcome = "revived"
|
||||||
|
)
|
||||||
|
|
||||||
// EnsurePddProduct 保证 goods_id 对应的 PDD 商品存在,返回它。
|
// EnsurePddProduct 保证 goods_id 对应的 PDD 商品存在,返回它。
|
||||||
//
|
//
|
||||||
// 操作员在货运单或蝦皮商品上填 PDD 链接、点保存时调它。三种情况:
|
// 操作员在货运单或蝦皮商品上填 PDD 链接、点保存时调它。三种情况:
|
||||||
@@ -58,16 +68,23 @@ func scanPddProduct(s rowScanner, extra ...any) (*model.PddProduct, error) {
|
|||||||
// 复活是必要的:goods_id 上有 UNIQUE 约束,软删除的行还占着那个值,
|
// 复活是必要的:goods_id 上有 UNIQUE 约束,软删除的行还占着那个值,
|
||||||
// 不复活就会插入冲突,操作员会看到一个莫名其妙的错误。
|
// 不复活就会插入冲突,操作员会看到一个莫名其妙的错误。
|
||||||
func EnsurePddProduct(q Execer, goodsID, url string) (*model.PddProduct, error) {
|
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 == "" {
|
if goodsID == "" {
|
||||||
return nil, fmt.Errorf("goods_id 不能为空")
|
return nil, "", fmt.Errorf("goods_id 不能为空")
|
||||||
}
|
}
|
||||||
if url == "" {
|
if url == "" {
|
||||||
return nil, fmt.Errorf("url 不能为空")
|
return nil, "", fmt.Errorf("url 不能为空")
|
||||||
}
|
}
|
||||||
|
|
||||||
existing, err := GetPddProductByGoodsID(q, goodsID)
|
existing, err := GetPddProductByGoodsID(q, goodsID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
now := model.NowISO()
|
now := model.NowISO()
|
||||||
@@ -78,14 +95,14 @@ func EnsurePddProduct(q Execer, goodsID, url string) (*model.PddProduct, error)
|
|||||||
VALUES (?, ?, 'pending', ?, ?)`,
|
VALUES (?, ?, 'pending', ?, ?)`,
|
||||||
goodsID, url, now, now)
|
goodsID, url, now, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("新建 PDD 商品 %s 失败: %w", goodsID, err)
|
return nil, "", fmt.Errorf("新建 PDD 商品 %s 失败: %w", goodsID, err)
|
||||||
}
|
}
|
||||||
id, _ := res.LastInsertId()
|
id, _ := res.LastInsertId()
|
||||||
return &model.PddProduct{
|
return &model.PddProduct{
|
||||||
ID: id, GoodsID: goodsID, URL: url,
|
ID: id, GoodsID: goodsID, URL: url,
|
||||||
CollectStatus: model.CollectPending,
|
CollectStatus: model.CollectPending,
|
||||||
CreatedAt: now, UpdatedAt: now,
|
CreatedAt: now, UpdatedAt: now,
|
||||||
}, nil
|
}, PddProductCreated, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if existing.IsDeleted() {
|
if existing.IsDeleted() {
|
||||||
@@ -103,9 +120,10 @@ func EnsurePddProduct(q Execer, goodsID, url string) (*model.PddProduct, error)
|
|||||||
WHERE goods_id = ?`,
|
WHERE goods_id = ?`,
|
||||||
url, now, goodsID)
|
url, now, goodsID)
|
||||||
if err != nil {
|
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(
|
if _, err := q.Exec(
|
||||||
`UPDATE pdd_products SET url = ?, updated_at = ? WHERE goods_id = ?`,
|
`UPDATE pdd_products SET url = ?, updated_at = ? WHERE goods_id = ?`,
|
||||||
url, now, goodsID); err != nil {
|
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
|
existing.URL = url
|
||||||
}
|
}
|
||||||
return existing, nil
|
return existing, PddProductExisting, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPddProductByGoodsID 按 goods_id 查,**包括已软删除的**。
|
// GetPddProductByGoodsID 按 goods_id 查,**包括已软删除的**。
|
||||||
|
|||||||
@@ -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])
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -175,6 +175,41 @@ tr.empty small { color: #aaa; }
|
|||||||
margin: 12px 2px;
|
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 {
|
.statusbar {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
+28
-8
@@ -189,15 +189,19 @@
|
|||||||
|
|
||||||
/* PDD 批量采集弹窗只同步已选数量并提供提交反馈。
|
/* PDD 批量采集弹窗只同步已选数量并提供提交反馈。
|
||||||
客户端权限和任务初始状态始终由服务端决定。 */
|
客户端权限和任务初始状态始终由服务端决定。 */
|
||||||
function setupCollectModal() {
|
function setupCollectModals() {
|
||||||
var openButton = document.querySelector("[data-collect-open]");
|
document.querySelectorAll("[data-collect-open]").forEach(function (openButton) {
|
||||||
var count = document.querySelector("[data-collect-count]");
|
var formID = openButton.getAttribute("data-collect-form") || "pdd-form";
|
||||||
var form = document.getElementById("pdd-form");
|
var form = document.getElementById(formID);
|
||||||
var submitButton = document.querySelector("[data-collect-submit]");
|
var modal = document.getElementById(openButton.getAttribute("data-modal-open"));
|
||||||
if (!openButton || !count || !form || !submitButton) return;
|
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 () {
|
openButton.addEventListener("click", function () {
|
||||||
count.textContent = checkedCount();
|
var fixedCount = openButton.getAttribute("data-collect-count-value");
|
||||||
|
count.textContent = fixedCount === null ? checkedCount() : fixedCount;
|
||||||
});
|
});
|
||||||
form.addEventListener("submit", function (event) {
|
form.addEventListener("submit", function (event) {
|
||||||
if (event.submitter !== submitButton) return;
|
if (event.submitter !== submitButton) return;
|
||||||
@@ -205,6 +209,21 @@
|
|||||||
submitButton.setAttribute("aria-busy", "true");
|
submitButton.setAttribute("aria-busy", "true");
|
||||||
submitButton.textContent = "创建中…";
|
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();
|
setupUserResetModal();
|
||||||
setupClientAssignmentModal();
|
setupClientAssignmentModal();
|
||||||
setupPurchaseModal();
|
setupPurchaseModal();
|
||||||
setupCollectModal();
|
setupCollectModals();
|
||||||
|
setupUploadFeedback();
|
||||||
setupModals();
|
setupModals();
|
||||||
setupRowDetail();
|
setupRowDetail();
|
||||||
setupCaptchaRefresh();
|
setupCaptchaRefresh();
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
{{/* 文案写全「添加 PDD 商品」:这条工具条上还有「创建采集任务」,
|
{{/* 文案写全「添加 PDD 商品」:这条工具条上还有「创建采集任务」,
|
||||||
两个都以「创建」开头的话,第一次用的人分不清哪个是加商品、哪个是发起采集。 */}}
|
两个都以「创建」开头的话,第一次用的人分不清哪个是加商品、哪个是发起采集。 */}}
|
||||||
<button type="button" data-modal-open="create-modal">添加 PDD 商品</button>
|
<button type="button" data-modal-open="create-modal">添加 PDD 商品</button>
|
||||||
|
<button type="button" data-modal-open="import-modal">批量导入</button>
|
||||||
|
|
||||||
{{/* 「创建采集任务」和「删除」用的是**同一个表单**,靠按钮上的
|
{{/* 「创建采集任务」和「删除」用的是**同一个表单**,靠按钮上的
|
||||||
formaction 决定提交到哪个地址。这样表格里的勾选框只需要挂一份,
|
formaction 决定提交到哪个地址。这样表格里的勾选框只需要挂一份,
|
||||||
@@ -21,6 +22,7 @@
|
|||||||
{{/* 文案是「创建采集任务」不是「采集」:点完只是排了个队,
|
{{/* 文案是「创建采集任务」不是「采集」:点完只是排了个队,
|
||||||
真正去手机上采要等客户端来领,可能几秒也可能几分钟 */}}
|
真正去手机上采要等客户端来领,可能几秒也可能几分钟 */}}
|
||||||
<button type="button" data-modal-open="collect-modal" data-collect-open
|
<button type="button" data-modal-open="collect-modal" data-collect-open
|
||||||
|
data-collect-form="pdd-form"
|
||||||
data-need-checked>创建采集任务</button>
|
data-need-checked>创建采集任务</button>
|
||||||
|
|
||||||
<form class="inline grow" method="get" action="/pdd">
|
<form class="inline grow" method="get" action="/pdd">
|
||||||
@@ -44,6 +46,61 @@
|
|||||||
>删除</button>
|
>删除</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{/* 导入结果紧跟工具条:采购员上传后先核对统计和错误,再决定是否发采集任务。 */}}
|
||||||
|
{{with .ImportResult}}
|
||||||
|
<section class="import-result" aria-labelledby="pdd-import-result-title" aria-live="polite">
|
||||||
|
<div class="import-result-head">
|
||||||
|
<div>
|
||||||
|
<h2 id="pdd-import-result-title">本次导入结果</h2>
|
||||||
|
<p>
|
||||||
|
总行数 <strong>{{.TotalRows}}</strong> ·
|
||||||
|
新建 <strong>{{.CreatedCount}}</strong> ·
|
||||||
|
已存在 <strong>{{.ExistingCount}}</strong> ·
|
||||||
|
恢复 <strong>{{.RevivedCount}}</strong> ·
|
||||||
|
文件内重复 <strong>{{.DuplicateCount}}</strong> ·
|
||||||
|
失败 <strong>{{len .Failures}}</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{{if .GoodsIDs}}
|
||||||
|
<button type="button" class="primary" data-modal-open="import-collect-modal"
|
||||||
|
data-collect-open data-collect-form="pdd-import-collect-form"
|
||||||
|
data-collect-count-value="{{len .GoodsIDs}}">
|
||||||
|
为本次导入商品创建采集任务
|
||||||
|
</button>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<p class="hint">
|
||||||
|
上传只建立商品档案,不会自动采集。创建任务时,已采集或正在采集的商品仍按现有规则跳过。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form id="pdd-import-collect-form" method="post" action="/pdd/collect" hidden>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||||
|
{{range .GoodsIDs}}<input type="hidden" name="ids" value="{{.}}">{{end}}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{{if .Failures}}
|
||||||
|
<div class="table-wrap import-failure-table" role="region" aria-label="PDD 导入失败明细" tabindex="0">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Excel 行号</th><th>原始内容</th><th>失败原因与处理建议</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Failures}}
|
||||||
|
<tr class="row-warn">
|
||||||
|
<td>{{.Row}}</td>
|
||||||
|
<td class="import-raw">{{.Raw}}</td>
|
||||||
|
<td class="import-reason">{{.Reason}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<p class="import-success">所有非空数据行均已处理,没有失败行。</p>
|
||||||
|
{{end}}
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
{{/* 第二段:表格。双击一行打开弹窗,见 static/js/app.js */}}
|
{{/* 第二段:表格。双击一行打开弹窗,见 static/js/app.js */}}
|
||||||
<div class="table-wrap" id="pddProductPage">
|
<div class="table-wrap" id="pddProductPage">
|
||||||
<table>
|
<table>
|
||||||
@@ -92,7 +149,7 @@
|
|||||||
<small>换个采集状态或清空搜索词再试。<a href="/pdd">查看全部</a></small>
|
<small>换个采集状态或清空搜索词再试。<a href="/pdd">查看全部</a></small>
|
||||||
{{else}}
|
{{else}}
|
||||||
还没有 PDD 商品。<br>
|
还没有 PDD 商品。<br>
|
||||||
<small>点左上角「创建」,粘贴一个拼多多商品链接就行,其余信息采集后自动回填。</small>
|
<small>点左上角「添加 PDD 商品」粘贴单条链接,或用「批量导入」上传 Excel。</small>
|
||||||
{{end}}
|
{{end}}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -139,6 +196,35 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{/* ── 批量导入弹窗 ─────────────────────────────── */}}
|
||||||
|
<div class="modal-backdrop" id="import-modal" hidden>
|
||||||
|
<div class="modal modal-narrow" role="dialog" aria-modal="true" aria-labelledby="import-modal-title">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h2 id="import-modal-title">批量导入 PDD 链接</h2>
|
||||||
|
<button type="button" class="modal-x" data-modal-close aria-label="关闭">×</button>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="/pdd/import" enctype="multipart/form-data" data-upload-form>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="field">
|
||||||
|
<label for="pdd-import-file">Excel 文件</label>
|
||||||
|
<input id="pdd-import-file" type="file" name="file" accept=".xlsx" required autofocus
|
||||||
|
aria-describedby="pdd-import-help">
|
||||||
|
</div>
|
||||||
|
<p class="hint" id="pdd-import-help">
|
||||||
|
仅支持不超过 10MB 的 <code>.xlsx</code>。第一个非空工作表第一列的表头必须是
|
||||||
|
“拼多多链接”,每行填写一个带 <code>goods_id=</code> 的完整链接,最多 5000 条;
|
||||||
|
空工作表和空行会自动忽略。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-foot">
|
||||||
|
<button type="button" data-modal-close>取消</button>
|
||||||
|
<button type="submit" class="primary" data-upload-submit>开始导入</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{{/* ── 批量采集确认弹窗 ─────────────────────────
|
{{/* ── 批量采集确认弹窗 ─────────────────────────
|
||||||
客户端是可选项,默认留空。服务端仍会按当前账号重新校验候选范围。 */}}
|
客户端是可选项,默认留空。服务端仍会按当前账号重新校验候选范围。 */}}
|
||||||
<div class="modal-backdrop" id="collect-modal" hidden>
|
<div class="modal-backdrop" id="collect-modal" hidden>
|
||||||
@@ -170,6 +256,39 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{/* ── 本次导入商品的采集确认弹窗 ─────────────────
|
||||||
|
IDs 来自刚完成的服务端导入结果,不受当前页分页影响。 */}}
|
||||||
|
{{with .ImportResult}}{{if .GoodsIDs}}
|
||||||
|
<div class="modal-backdrop" id="import-collect-modal" hidden>
|
||||||
|
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="import-collect-modal-title">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h2 id="import-collect-modal-title">为本次导入商品创建采集任务</h2>
|
||||||
|
<button type="button" class="modal-x" data-modal-close aria-label="关闭">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p>将按现有状态规则处理本次导入的 <strong data-collect-count>{{len .GoodsIDs}}</strong> 个商品。</p>
|
||||||
|
<div class="field">
|
||||||
|
<label for="import-collect-client">执行客户端</label>
|
||||||
|
<select id="import-collect-client" name="client_id" form="pdd-import-collect-form" autofocus>
|
||||||
|
<option value="">不指定(任意客户端可领取)</option>
|
||||||
|
{{range $.AssignableClients}}
|
||||||
|
<option value="{{.ClientID}}">{{.Name}}({{.ClientID}},{{.Status}})</option>
|
||||||
|
{{end}}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<p class="hint">
|
||||||
|
上传本身没有创建任务。确认后才会建任务;已采集、正在采集和重复项会分别统计并跳过。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-foot">
|
||||||
|
<button type="button" data-modal-close>取消</button>
|
||||||
|
<button type="submit" form="pdd-import-collect-form" class="primary"
|
||||||
|
data-collect-submit>确认创建</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}{{end}}
|
||||||
|
|
||||||
{{/* ── 编辑弹窗的壳子 ───────────────────────────
|
{{/* ── 编辑弹窗的壳子 ───────────────────────────
|
||||||
里面的内容双击行时由 /pdd/detail 返回,前端只负责放进来和显示。 */}}
|
里面的内容双击行时由 /pdd/detail 返回,前端只负责放进来和显示。 */}}
|
||||||
<div class="modal-backdrop" id="detail-modal" data-detail-url="{{.DetailURL}}" hidden>
|
<div class="modal-backdrop" id="detail-modal" data-detail-url="{{.DetailURL}}" hidden>
|
||||||
|
|||||||
Vendored
BIN
Binary file not shown.
@@ -134,7 +134,8 @@ PDD 商品之所以单独一个模块,是因为它在数据上就是**独立
|
|||||||
维护拼多多商品档案,并发起采集。这条链路**不依赖蝦皮和顺运宝的任何数据**,
|
维护拼多多商品档案,并发起采集。这条链路**不依赖蝦皮和顺运宝的任何数据**,
|
||||||
可以单独跑通:建商品 → 建采集任务 → Client 领走执行 → 提交结果 → 页面显示已采集。
|
可以单独跑通:建商品 → 建采集任务 → Client 领走执行 → 提交结果 → 页面显示已采集。
|
||||||
|
|
||||||
**顶部工具条:** 创建按钮、创建采集任务按钮、采集状态筛选、商品 ID/链接搜索框、搜索按钮、删除按钮。
|
**顶部工具条:** 添加 PDD 商品、批量导入、创建采集任务、采集状态筛选、
|
||||||
|
商品 ID/链接搜索框、搜索和删除。
|
||||||
|
|
||||||
**中间表格:**
|
**中间表格:**
|
||||||
|
|
||||||
@@ -158,6 +159,15 @@ PDD 商品之所以单独一个模块,是因为它在数据上就是**独立
|
|||||||
不接受短链接——`goods_id` 上有 UNIQUE 约束,防重全靠它,
|
不接受短链接——`goods_id` 上有 UNIQUE 约束,防重全靠它,
|
||||||
拿不到就没法查重,同一个商品会存成好几行。
|
拿不到就没法查重,同一个商品会存成好几行。
|
||||||
|
|
||||||
|
**批量导入:** 接受第一列标题为“拼多多链接”的一列式 `.xlsx`,读取第一个
|
||||||
|
非空工作表,忽略空工作表和空行,一次最多 5000 条。逐行复用单条创建的链接解析、
|
||||||
|
去重和软删除恢复规则;已存在有效商品保留采集结果。无效行不阻塞其他合法行,
|
||||||
|
但必须显示 Excel 行号和原因;数据库异常时合法行整体回滚。
|
||||||
|
|
||||||
|
上传只建立商品档案,**不得自动创建采集任务**。导入结果保留本次合法且去重后的
|
||||||
|
完整商品集合,采购员显式确认后才能为这一集合批量创建任务;该集合不受列表分页限制,
|
||||||
|
客户端选择、权限、状态跳过和任务去重继续使用现有规则。
|
||||||
|
|
||||||
**双击行打开弹窗:** 查看商品信息,可编辑链接,并显示采回来的规格和价格。
|
**双击行打开弹窗:** 查看商品信息,可编辑链接,并显示采回来的规格和价格。
|
||||||
|
|
||||||
- `[必须]` **规格和价格必须显示**,这是采集结果的全部价值所在——
|
- `[必须]` **规格和价格必须显示**,这是采集结果的全部价值所在——
|
||||||
|
|||||||
@@ -235,13 +235,14 @@ M 个 SKU,K 行解析失败"),覆盖掉正常的统计文案,并且回
|
|||||||
### 5.1 工具条
|
### 5.1 工具条
|
||||||
|
|
||||||
```text
|
```text
|
||||||
[添加 PDD 商品] [创建采集任务] 采集状态[全部▾] 商品ID/链接[___] [搜索] [删除]
|
[添加 PDD 商品] [批量导入] [创建采集任务] 采集状态[全部▾] 商品ID/链接[___] [搜索] [删除]
|
||||||
```
|
```
|
||||||
|
|
||||||
- **添加 PDD 商品** → 弹窗,只填 PDD 链接。
|
- **添加 PDD 商品** → 弹窗,只填 PDD 链接。
|
||||||
`[必须]` 文案要写全,**不能简写成「创建」**:同一条工具条上还有
|
`[必须]` 文案要写全,**不能简写成「创建」**:同一条工具条上还有
|
||||||
「创建采集任务」,两个都以「创建」开头,第一次用的人分不清
|
「创建采集任务」,两个都以「创建」开头,第一次用的人分不清
|
||||||
哪个是加商品、哪个是发起采集。
|
哪个是加商品、哪个是发起采集。
|
||||||
|
- **批量导入** → 弹窗选择一列式 `.xlsx`;上传只建档,不自动发采集任务。
|
||||||
- **创建采集任务** → 勾选多行后可用。
|
- **创建采集任务** → 勾选多行后可用。
|
||||||
- **采集状态筛选** → 全部 / 未采集 / 采集中 / 已采集 / 采集失败。
|
- **采集状态筛选** → 全部 / 未采集 / 采集中 / 已采集 / 采集失败。
|
||||||
`[必须]` 这个筛选是**刚需**,不是锦上添花:这个页面的主要用途是维护
|
`[必须]` 这个筛选是**刚需**,不是锦上添花:这个页面的主要用途是维护
|
||||||
@@ -305,7 +306,28 @@ M 个 SKU,K 行解析失败"),覆盖掉正常的统计文案,并且回
|
|||||||
`[必须]` 报错要说清**下一步怎么办**,例如"请在拼多多 App 的商品页点
|
`[必须]` 报错要说清**下一步怎么办**,例如"请在拼多多 App 的商品页点
|
||||||
「分享」→「复制链接」,拿到带 `goods_id=` 的完整链接"。光说"链接无效"操作员不知道改什么。
|
「分享」→「复制链接」,拿到带 `goods_id=` 的完整链接"。光说"链接无效"操作员不知道改什么。
|
||||||
|
|
||||||
### 5.4 编辑弹窗(双击行打开)
|
### 5.4 批量导入弹窗与结果
|
||||||
|
|
||||||
|
弹窗有可见“Excel 文件”标签和持续格式说明:只接受不超过 10MB 的 `.xlsx`;
|
||||||
|
读取第一个非空工作表,第一列标题必须为“拼多多链接”,每行一个带 `goods_id=` 的
|
||||||
|
完整链接,最多 5000 条。空工作表、空行忽略,短链接继续拒绝。
|
||||||
|
|
||||||
|
上传期间提交按钮显示“导入中…”并禁用,防止重复提交。完成后在工具条下方显示:
|
||||||
|
|
||||||
|
```text
|
||||||
|
本次导入结果
|
||||||
|
总行数 N · 新建 N · 已存在 N · 恢复 N · 文件内重复 N · 失败 N
|
||||||
|
[为本次导入商品创建采集任务]
|
||||||
|
```
|
||||||
|
|
||||||
|
- 新建、已存在、软删除恢复和文件内重复必须分开统计;
|
||||||
|
- 失败明细完整显示 Excel 行号、原始内容和可操作原因,长链接允许换行;
|
||||||
|
- 结果不能只靠颜色表达,并使用可访问的文字标题和区域标签;
|
||||||
|
- 本次导入的合法商品超过当前分页时,仍能由结果按钮一次提交完整集合;
|
||||||
|
- 点击结果按钮才打开现有客户端选择确认流程,默认不指定客户端;
|
||||||
|
- 上传完成本身不创建任务,已采集/采集中等跳过规则在最终确认后执行。
|
||||||
|
|
||||||
|
### 5.5 编辑弹窗(双击行打开)
|
||||||
|
|
||||||
```text
|
```text
|
||||||
┌─────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────┐
|
||||||
|
|||||||
@@ -24,6 +24,8 @@
|
|||||||
再加解析失败的用例(确认 `parse_ok=0` 且不瞎猜);
|
再加解析失败的用例(确认 `parse_ok=0` 且不瞎猜);
|
||||||
- **Excel 导入分行**:商品汇总行 vs SKU 行的判断;
|
- **Excel 导入分行**:商品汇总行 vs SKU 行的判断;
|
||||||
- **upsert 不覆盖人工字段**:先填 PDD 链接,再导入一次,断言链接还在;
|
- **upsert 不覆盖人工字段**:先填 PDD 链接,再导入一次,断言链接还在;
|
||||||
|
- **PDD 链接批量导入**:空工作表、正确/错误表头、两个允许域名、短链/非 PDD
|
||||||
|
链接、空行、5000 条边界、文件内重复、新建/复用/复活分类和重复导入不覆盖采集结果;
|
||||||
- 任务创建校验:商品、采集结果、有效映射、数量、人民币价格上限、客户端可见范围逐条覆盖;
|
- 任务创建校验:商品、采集结果、有效映射、数量、人民币价格上限、客户端可见范围逐条覆盖;
|
||||||
- 采购任务重复提交保护、部分业务失败、PDD 换品隔离和采集选项失效;
|
- 采购任务重复提交保护、部分业务失败、PDD 换品隔离和采集选项失效;
|
||||||
- 金额换算显示(分 ↔ 元),边界值 0 和大额;
|
- 金额换算显示(分 ↔ 元),边界值 0 和大额;
|
||||||
@@ -106,6 +108,9 @@
|
|||||||
**禁止用 `template.HTML` 包裹用户可控内容**——商品名、订单号都来自外部。
|
**禁止用 `template.HTML` 包裹用户可控内容**——商品名、订单号都来自外部。
|
||||||
- `[必须]` 上传的 Excel 限制**大小和扩展名**,解析失败要返回明确错误,
|
- `[必须]` 上传的 Excel 限制**大小和扩展名**,解析失败要返回明确错误,
|
||||||
不能 panic 把整个进程带崩。
|
不能 panic 把整个进程带崩。
|
||||||
|
- `[必须]` PDD 链接导入还要校验 xlsx 文件头和 5000 条非空数据行上限;
|
||||||
|
文件结构错误时不写库,行内容错误时导入其他合法行并列出全部失败明细,
|
||||||
|
数据库异常时本批合法行整体回滚。
|
||||||
- `[必须]` 文件名不得直接用于拼路径(路径穿越),落盘时用自己生成的名字。
|
- `[必须]` 文件名不得直接用于拼路径(路径穿越),落盘时用自己生成的名字。
|
||||||
- `[建议]` MVP 只监听 `127.0.0.1`,不对外暴露。要给内网用再单独评估。
|
- `[建议]` MVP 只监听 `127.0.0.1`,不对外暴露。要给内网用再单独评估。
|
||||||
- `[必须]` 不提供固定默认密码和公开注册;第一位管理员由用户首次初始化。
|
- `[必须]` 不提供固定默认密码和公开注册;第一位管理员由用户首次初始化。
|
||||||
|
|||||||
Reference in New Issue
Block a user