From 546d9ef7e23e9e67a336d3cd7beafd84466169e7 Mon Sep 17 00:00:00 2001 From: chengma Date: Mon, 10 Aug 2026 19:38:40 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=85=81=E8=AE=B8=E5=B7=B2=E9=87=87?= =?UTF-8?q?=E9=9B=86=20PDD=20=E5=95=86=E5=93=81=E9=87=8D=E6=96=B0=E9=87=87?= =?UTF-8?q?=E9=9B=86=20(#121)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/handler/web/pdd.go | 30 +++++++++-- admin/handler/web/web.go | 1 + admin/main_test.go | 19 +++++++ admin/repository/pdd.go | 28 ++++++++++ admin/service/pdd.go | 69 ++++++++++++++++++++++++ admin/service/pdd_page_test.go | 84 +++++++++++++++++++++++++++++ admin/templates/pdd/edit_modal.html | 14 ++++- docs/admin/01-requirements.md | 9 +++- docs/admin/05-ui-specification.md | 3 ++ 9 files changed, 250 insertions(+), 7 deletions(-) diff --git a/admin/handler/web/pdd.go b/admin/handler/web/pdd.go index e29ebc2..f246793 100644 --- a/admin/handler/web/pdd.go +++ b/admin/handler/web/pdd.go @@ -101,11 +101,17 @@ func (h *Handler) PddDetail(c *gin.Context) { fail(c, http.StatusNotFound, "这个商品不存在或已被删除,请刷新页面。") return } + assignableClients, err := service.ListAssignableClients(h.db, currentUser(c), h.onlineThreshold) + if err != nil { + fail(c, http.StatusInternalServerError, "读取可选客户端失败,数据没有被改动。刷新页面重试。") + return + } c.HTML(http.StatusOK, "pdd/edit_modal", gin.H{ - "D": detail, - "CSRFToken": csrfToken(c), - "Keyword": c.Query("q"), "StatusFilter": c.Query("status"), + "D": detail, + "CSRFToken": csrfToken(c), + "AssignableClients": assignableClients, + "Keyword": c.Query("q"), "StatusFilter": c.Query("status"), "CurrentPage": service.ParsePage(c.Query("page")), }) } @@ -233,6 +239,24 @@ func (h *Handler) PddCollect(c *gin.Context) { h.pddRedirect(c, service.FormatCollectTaskMessage(result)) } +// PddRecollect 处理商品详情里操作员明确点击的“重新采集”。 +// 它与批量创建任务分开,避免普通批量入口意外重采所有已完成商品。 +func (h *Handler) PddRecollect(c *gin.Context) { + result, err := service.RecollectPddProductForUser( + h.db, currentUser(c), c.PostForm("goods_id"), c.PostForm("client_id")) + if err != nil { + status := http.StatusInternalServerError + message := "创建重新采集任务失败,商品和任务均未改动。请稍后重试。" + if service.IsValidationError(err) { + status = http.StatusBadRequest + message = err.Error() + "。没有创建任务。" + } + fail(c, status, message) + return + } + h.pddRedirect(c, service.FormatCollectTaskMessage(result)) +} + // PddDelete 批量软删除。 func (h *Handler) PddDelete(c *gin.Context) { ids := c.PostFormArray("ids") diff --git a/admin/handler/web/web.go b/admin/handler/web/web.go index e67989c..3372516 100644 --- a/admin/handler/web/web.go +++ b/admin/handler/web/web.go @@ -68,6 +68,7 @@ func Register(r *gin.Engine, db *sql.DB, onlineThreshold time.Duration) { pages.POST("/pdd/import", h.PddImport) pages.POST("/pdd/save", h.PddSave) pages.POST("/pdd/collect", h.PddCollect) + pages.POST("/pdd/recollect", h.PddRecollect) pages.POST("/pdd/delete", h.PddDelete) // 3. 顺运宝数据 diff --git a/admin/main_test.go b/admin/main_test.go index 8946b0a..f3826fb 100644 --- a/admin/main_test.go +++ b/admin/main_test.go @@ -65,6 +65,25 @@ func TestPurchaseModal_未选规格行保持隐藏且不提交(t *testing.T) { } } +func TestPddDetailRecollect_使用独立入口并可选客户端(t *testing.T) { + content, err := os.ReadFile("templates/pdd/edit_modal.html") + if err != nil { + t.Fatal(err) + } + page := string(content) + for _, want := range []string{ + `action="/pdd/recollect"`, `name="goods_id"`, `name="client_id"`, + "不指定(任意客户端可领取)", "成功后再用新结果覆盖", + } { + if !strings.Contains(page, want) { + t.Errorf("PDD 详情重新采集缺少 %q", want) + } + } + if strings.Contains(page, `id="pdd-recollect-form" method="post" action="/pdd/collect"`) { + t.Error("重新采集不能继续复用会跳过已采集商品的普通批量入口") + } +} + func TestTemplatesAndRoutesCanBeBuiltWithoutDatabaseConnection(t *testing.T) { if _, err := newRouter(nil); err != nil { t.Fatalf("模板或路由组装失败: %v", err) diff --git a/admin/repository/pdd.go b/admin/repository/pdd.go index e072afa..2979d9b 100644 --- a/admin/repository/pdd.go +++ b/admin/repository/pdd.go @@ -430,6 +430,34 @@ func MarkCollecting(q Execer, pddGoodsID string) (bool, error) { return n == 1, nil } +// MarkRecollecting 为详情页明确发起的“重新采集”原子占用商品。 +// +// 它与 MarkCollecting 的唯一区别是允许 collected:普通批量采集仍应跳过 +// 已采集商品,只有操作员在详情页明确点击“重新采集”时才能走这里。 +// 本方法只改状态和更新时间,不清空旧标题、店铺、规格或采集时间;新结果成功 +// 提交前,旧数据仍可用于诊断和查看。 +func MarkRecollecting(q Execer, pddGoodsID string) (bool, error) { + now := time.Now() + nowISO := now.UTC().Format(model.TimeLayout) + staleBefore := now.Add(-model.CollectStaleAfter).UTC().Format(model.TimeLayout) + + res, err := q.Exec(` + UPDATE pdd_products + SET collect_status = 'collecting', updated_at = ? + WHERE goods_id = ? AND deleted_at IS NULL + AND ( collect_status IN ('pending', 'failed', 'collected') + OR (collect_status = 'collecting' AND updated_at < ?) )`, + nowISO, pddGoodsID, staleBefore) + if err != nil { + return false, fmt.Errorf("标记 PDD 商品 %s 重新采集中失败: %w", pddGoodsID, err) + } + n, err := res.RowsAffected() + if err != nil { + return false, err + } + return n == 1, nil +} + // SoftDeletePddProduct 软删除。 // // 不硬删是因为 spec_mappings 指向它,硬删会把人工攒了很久的匹配成果一起带走。 diff --git a/admin/service/pdd.go b/admin/service/pdd.go index ac05c13..da4f960 100644 --- a/admin/service/pdd.go +++ b/admin/service/pdd.go @@ -589,6 +589,75 @@ func CreatePddCollectTasksForUser(db *sql.DB, actor *model.User, goodsIDs []stri return createPddCollectTasks(db, goodsIDs, strings.TrimSpace(clientID), visibleUserID) } +// RecollectPddProductForUser 为详情页一次明确的重新采集创建单商品任务。 +// 普通批量入口仍跳过 collected;本入口允许 collected,但保留旧采集数据, +// 等 Client 成功提交新结果后再由既有提交逻辑覆盖。 +func RecollectPddProductForUser(db *sql.DB, actor *model.User, goodsID, clientID string) (CollectTaskResult, error) { + var result CollectTaskResult + goodsID = strings.TrimSpace(goodsID) + clientID = strings.TrimSpace(clientID) + if goodsID == "" { + return result, invalidInput("商品 ID 不能为空") + } + + visibleUserID, err := visibleClientUserID(actor) + if err != nil { + return result, err + } + tx, err := db.Begin() + if err != nil { + return result, fmt.Errorf("开始事务失败: %w", err) + } + defer tx.Rollback() + + if clientID != "" { + visible, err := repository.ClientVisibleToUser(tx, clientID, visibleUserID) + if err != nil { + return result, err + } + if !visible { + return result, invalidInput("所选客户端不存在或不在当前账号可见范围") + } + } + + p, err := repository.GetPddProductByGoodsID(tx, goodsID) + if err != nil { + return result, err + } + if p == nil || p.IsDeleted() { + result.SkippedDeleted = 1 + if err := tx.Commit(); err != nil { + return CollectTaskResult{}, fmt.Errorf("提交事务失败: %w", err) + } + return result, nil + } + + ok, err := repository.MarkRecollecting(tx, goodsID) + if err != nil { + return result, err + } + if !ok { + result.SkippedCollecting = 1 + if wait, ok := retryWaitFor(p.UpdatedAt); ok { + result.RetryWaitText = formatRetryWait(wait) + } + if err := tx.Commit(); err != nil { + return CollectTaskResult{}, fmt.Errorf("提交事务失败: %w", err) + } + return result, nil + } + + if err := repository.InsertCollectTaskForClient( + tx, newCollectTaskID(), p.GoodsID, p.URL, clientID); err != nil { + return result, err + } + result.Created = 1 + if err := tx.Commit(); err != nil { + return CollectTaskResult{}, fmt.Errorf("提交事务失败: %w", err) + } + return result, nil +} + func createPddCollectTasks(db *sql.DB, goodsIDs []string, clientID, visibleUserID string) (CollectTaskResult, error) { var result CollectTaskResult diff --git a/admin/service/pdd_page_test.go b/admin/service/pdd_page_test.go index c4bc0b6..fc97a72 100644 --- a/admin/service/pdd_page_test.go +++ b/admin/service/pdd_page_test.go @@ -720,6 +720,90 @@ func TestCreatePddCollectTasksForUser_不指定时保持无主待领(t *testing. } } +func TestRecollectPddProductForUser_已采集商品保留旧结果并创建任务(t *testing.T) { + db := newTestDB(t) + admin := prepareAdminUser(t, db, time.Date(2026, 8, 10, 1, 0, 0, 0, time.UTC)) + createProduct(t, db, "737116531267") + if err := repository.SetCollectResult( + db, "737116531267", "旧标题", "旧店铺", sampleSkusJSON); err != nil { + t.Fatal(err) + } + before, _ := repository.GetPddProductByGoodsID(db, "737116531267") + + result, err := RecollectPddProductForUser(db, admin, "737116531267", "") + if err != nil || result.Created != 1 || result.Skipped() != 0 { + t.Fatalf("已采集商品应该能明确重采: result=%+v err=%v", result, err) + } + after, _ := repository.GetPddProductByGoodsID(db, "737116531267") + if after.CollectStatus != model.CollectCollecting { + t.Errorf("重采后状态应为 collecting,实际 %s", after.CollectStatus) + } + if after.Title != before.Title || after.ShopName != before.ShopName || + after.SkusJSON != before.SkusJSON || after.CollectedAt != before.CollectedAt { + t.Errorf("重采开始时不应清空旧结果,before=%+v after=%+v", before, after) + } + status, assigned, _, ok := collectTaskOf(t, db, "737116531267") + if !ok || status != "pending" || assigned != "" { + t.Fatalf("应该创建无主待领任务: status=%q assigned=%q ok=%t", status, assigned, ok) + } +} + +func TestRecollectPddProductForUser_连续点击只创建一条任务(t *testing.T) { + db := newTestDB(t) + admin := prepareAdminUser(t, db, time.Date(2026, 8, 10, 1, 0, 0, 0, time.UTC)) + createProduct(t, db, "737116531267") + if err := repository.SetCollectResult(db, "737116531267", "已采集", "", sampleSkusJSON); err != nil { + t.Fatal(err) + } + + first, err := RecollectPddProductForUser(db, admin, "737116531267", "") + if err != nil || first.Created != 1 { + t.Fatalf("第一次重采失败: result=%+v err=%v", first, err) + } + second, err := RecollectPddProductForUser(db, admin, "737116531267", "") + if err != nil || second.Created != 0 || second.SkippedCollecting != 1 { + t.Fatalf("第二次应识别正在采集: result=%+v err=%v", second, err) + } + var taskCount int + if err := db.QueryRow(`SELECT COUNT(*) FROM tasks WHERE task_type='collect' AND pdd_goods_id=?`, + "737116531267").Scan(&taskCount); err != nil { + t.Fatal(err) + } + if taskCount != 1 { + t.Fatalf("连续点击只能创建一条任务,实际 %d", taskCount) + } +} + +func TestRecollectPddProductForUser_不可见客户端时完整回滚(t *testing.T) { + db := newTestDB(t) + admin, buyerA, buyerB := prepareClientAssignmentUsers(t, db) + RegisterClient(db, model.Client{ClientID: "client-b", Name: "B 的机器"}, true) + if _, _, err := AssignClient(db, admin, "client-b", buyerB.UserID, + time.Date(2026, 8, 10, 1, 0, 0, 0, time.UTC)); err != nil { + t.Fatal(err) + } + createProduct(t, db, "737116531267") + if err := repository.SetCollectResult(db, "737116531267", "旧标题", "", sampleSkusJSON); err != nil { + t.Fatal(err) + } + + _, err := RecollectPddProductForUser(db, buyerA, "737116531267", "client-b") + if !IsValidationError(err) { + t.Fatalf("不可见客户端应返回表单错误,实际 %v", err) + } + p, _ := repository.GetPddProductByGoodsID(db, "737116531267") + if p.CollectStatus != model.CollectCollected { + t.Errorf("拒绝后商品仍应为 collected,实际 %s", p.CollectStatus) + } + var taskCount int + if err := db.QueryRow(`SELECT COUNT(*) FROM tasks WHERE task_type='collect'`).Scan(&taskCount); err != nil { + t.Fatal(err) + } + if taskCount != 0 { + t.Fatalf("拒绝后不应创建任务,实际 %d", taskCount) + } +} + func TestCreatePddCollectTasks_按商品去重(t *testing.T) { db := newTestDB(t) createProduct(t, db, "737116531267") diff --git a/admin/templates/pdd/edit_modal.html b/admin/templates/pdd/edit_modal.html index dda6c04..0a4ce86 100644 --- a/admin/templates/pdd/edit_modal.html +++ b/admin/templates/pdd/edit_modal.html @@ -13,9 +13,9 @@ {{/* 「重新采集」自己一个表单,只提交这一个商品。 HTML 不允许表单套表单,所以它写在保存表单外面, 按钮靠 form="pdd-recollect-form" 挂过来。 */}} -