fix: 允许已采集 PDD 商品重新采集 (#121)

This commit is contained in:
chengma
2026-08-10 19:38:40 +08:00
parent 352d861425
commit 546d9ef7e2
9 changed files with 250 additions and 7 deletions
+27 -3
View File
@@ -101,11 +101,17 @@ func (h *Handler) PddDetail(c *gin.Context) {
fail(c, http.StatusNotFound, "这个商品不存在或已被删除,请刷新页面。") fail(c, http.StatusNotFound, "这个商品不存在或已被删除,请刷新页面。")
return 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{ c.HTML(http.StatusOK, "pdd/edit_modal", gin.H{
"D": detail, "D": detail,
"CSRFToken": csrfToken(c), "CSRFToken": csrfToken(c),
"Keyword": c.Query("q"), "StatusFilter": c.Query("status"), "AssignableClients": assignableClients,
"Keyword": c.Query("q"), "StatusFilter": c.Query("status"),
"CurrentPage": service.ParsePage(c.Query("page")), "CurrentPage": service.ParsePage(c.Query("page")),
}) })
} }
@@ -233,6 +239,24 @@ func (h *Handler) PddCollect(c *gin.Context) {
h.pddRedirect(c, service.FormatCollectTaskMessage(result)) 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 批量软删除。 // PddDelete 批量软删除。
func (h *Handler) PddDelete(c *gin.Context) { func (h *Handler) PddDelete(c *gin.Context) {
ids := c.PostFormArray("ids") ids := c.PostFormArray("ids")
+1
View File
@@ -68,6 +68,7 @@ func Register(r *gin.Engine, db *sql.DB, onlineThreshold time.Duration) {
pages.POST("/pdd/import", h.PddImport) 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/recollect", h.PddRecollect)
pages.POST("/pdd/delete", h.PddDelete) pages.POST("/pdd/delete", h.PddDelete)
// 3. 顺运宝数据 // 3. 顺运宝数据
+19
View File
@@ -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) { func TestTemplatesAndRoutesCanBeBuiltWithoutDatabaseConnection(t *testing.T) {
if _, err := newRouter(nil); err != nil { if _, err := newRouter(nil); err != nil {
t.Fatalf("模板或路由组装失败: %v", err) t.Fatalf("模板或路由组装失败: %v", err)
+28
View File
@@ -430,6 +430,34 @@ func MarkCollecting(q Execer, pddGoodsID string) (bool, error) {
return n == 1, nil 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 软删除。 // SoftDeletePddProduct 软删除。
// //
// 不硬删是因为 spec_mappings 指向它,硬删会把人工攒了很久的匹配成果一起带走。 // 不硬删是因为 spec_mappings 指向它,硬删会把人工攒了很久的匹配成果一起带走。
+69
View File
@@ -589,6 +589,75 @@ func CreatePddCollectTasksForUser(db *sql.DB, actor *model.User, goodsIDs []stri
return createPddCollectTasks(db, goodsIDs, strings.TrimSpace(clientID), visibleUserID) 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) { func createPddCollectTasks(db *sql.DB, goodsIDs []string, clientID, visibleUserID string) (CollectTaskResult, error) {
var result CollectTaskResult var result CollectTaskResult
+84
View File
@@ -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) { func TestCreatePddCollectTasks_按商品去重(t *testing.T) {
db := newTestDB(t) db := newTestDB(t)
createProduct(t, db, "737116531267") createProduct(t, db, "737116531267")
+12 -2
View File
@@ -13,9 +13,9 @@
{{/* 「重新采集」自己一个表单,只提交这一个商品。 {{/* 「重新采集」自己一个表单,只提交这一个商品。
HTML 不允许表单套表单,所以它写在保存表单外面, HTML 不允许表单套表单,所以它写在保存表单外面,
按钮靠 form="pdd-recollect-form" 挂过来。 */}} 按钮靠 form="pdd-recollect-form" 挂过来。 */}}
<form id="pdd-recollect-form" method="post" action="/pdd/collect" hidden> <form id="pdd-recollect-form" method="post" action="/pdd/recollect" hidden>
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"> <input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<input type="hidden" name="ids" value="{{.GoodsID}}"> <input type="hidden" name="goods_id" value="{{.GoodsID}}">
<input type="hidden" name="status" value="{{$.StatusFilter}}"> <input type="hidden" name="status" value="{{$.StatusFilter}}">
<input type="hidden" name="q" value="{{$.Keyword}}"> <input type="hidden" name="q" value="{{$.Keyword}}">
<input type="hidden" name="page" value="{{$.CurrentPage}}"> <input type="hidden" name="page" value="{{$.CurrentPage}}">
@@ -29,6 +29,16 @@
<input type="hidden" name="page" value="{{$.CurrentPage}}"> <input type="hidden" name="page" value="{{$.CurrentPage}}">
<div class="modal-body"> <div class="modal-body">
<div class="field">
<label for="recollect-client">重新采集的执行客户端</label>
<select id="recollect-client" name="client_id" form="pdd-recollect-form">
<option value="">不指定(任意客户端可领取)</option>
{{range $.AssignableClients}}
<option value="{{.ClientID}}">{{.Name}}({{.ClientID}},{{.Status}})</option>
{{end}}
</select>
<p class="hint">通常保持默认即可。重新采集期间会保留当前详情,成功后再用新结果覆盖。</p>
</div>
<dl class="detail"> <dl class="detail">
<dt>商品 ID</dt><dd>{{.GoodsID}}<small>(只读,由链接解析得出,不能改)</small></dd> <dt>商品 ID</dt><dd>{{.GoodsID}}<small>(只读,由链接解析得出,不能改)</small></dd>
+7 -2
View File
@@ -180,8 +180,13 @@ PDD 商品之所以单独一个模块,是因为它在数据上就是**独立
**创建采集任务:** **创建采集任务:**
- `[必须]` **不指定客户端**,谁领到就在领取时标记谁。采集是纯读取操作, - 普通批量创建仍跳过已采集商品;商品详情中的“重新采集”是独立的明确操作,
哪台机器跑都一样,指定了反而会在那台机器关着的时候干等。 允许为已采集商品创建一条新任务。
- 重新采集开始时只把状态改为采集中,不清空旧标题、店铺、规格、价格和采集时间;
Client 成功提交后再覆盖旧结果。未超时的采集中商品不得重复建任务。
- `[必须]` 默认**不指定客户端**,谁领到就在领取时标记谁;采购员也可以明确指定
当前账号可见的客户端,指定后只等待该客户端领取,界面必须提示离线等待风险。
- `[必须]` 按 `goods_id` 去重;`collecting` 状态的跳过,并在结果里说明跳过了几个。 - `[必须]` 按 `goods_id` 去重;`collecting` 状态的跳过,并在结果里说明跳过了几个。
- `[必须]` 按钮文案用「**创建采集任务**」,不要用「采集」。它做的是建一个任务, - `[必须]` 按钮文案用「**创建采集任务**」,不要用「采集」。它做的是建一个任务,
不是立刻去采——真正的采集要等 Client 来领、去手机上跑,可能几秒也可能几分钟。 不是立刻去采——真正的采集要等 Client 来领、去手机上跑,可能几秒也可能几分钟。
+3
View File
@@ -350,6 +350,9 @@ M 个 SKU,K 行解析失败"),覆盖掉正常的统计文案,并且回
└─────────────────────────────────────────────┘ └─────────────────────────────────────────────┘
``` ```
“重新采集”使用独立入口,可选择执行客户端,默认不指定。它允许已采集商品再次创建
采集任务,但任务完成前保留当前详情;未超时的采集中商品再次点击时只提示等待,不重复建任务。
`[必须]` **规格和价格必须显示。** 这是采集结果的全部价值所在—— `[必须]` **规格和价格必须显示。** 这是采集结果的全部价值所在——
不显示的话操作员没法确认"采得对不对、是不是我要的那个商品"。 不显示的话操作员没法确认"采得对不对、是不是我要的那个商品"。