diff --git a/admin/handler/web/pdd.go b/admin/handler/web/pdd.go index 26fa334..d17183c 100644 --- a/admin/handler/web/pdd.go +++ b/admin/handler/web/pdd.go @@ -28,6 +28,12 @@ func (h *Handler) PddList(c *gin.Context) { "读取 PDD 商品列表失败,数据没有被改动。刷新页面重试;一直失败请把这句话报给维护者。") return } + assignableClients, err := service.ListAssignableClients(h.db, currentUser(c), h.onlineThreshold) + if err != nil { + fail(c, http.StatusInternalServerError, + "读取可选客户端失败,数据没有被改动。刷新页面重试。") + return + } // 底部状态条平时显示统计,刚做完操作时先显示操作结果。 // 操作结果是通过跳转带过来的 msg 参数传的(见 pddRedirect)。 @@ -49,16 +55,17 @@ func (h *Handler) PddList(c *gin.Context) { } detailValues.Set("page", strconv.Itoa(result.Page)) c.HTML(http.StatusOK, "pdd/list", page(c, "pdd", "PDD 商品", gin.H{ - "Rows": result.Rows, - "Keyword": keyword, - "Status": statusLine, - "StatusFilter": string(status), - "StatusOptions": service.CollectStatusOptions(), - "HasAnyProducts": result.Total > 0, - "IsFiltered": result.IsFiltered, - "CurrentPage": result.Page, - "Pagination": service.NewPaginationView(result.Page, result.TotalPages, values.Encode()), - "DetailURL": "/pdd/detail?" + detailValues.Encode(), + "Rows": result.Rows, + "Keyword": keyword, + "Status": statusLine, + "StatusFilter": string(status), + "StatusOptions": service.CollectStatusOptions(), + "HasAnyProducts": result.Total > 0, + "IsFiltered": result.IsFiltered, + "CurrentPage": result.Page, + "Pagination": service.NewPaginationView(result.Page, result.TotalPages, values.Encode()), + "DetailURL": "/pdd/detail?" + detailValues.Encode(), + "AssignableClients": assignableClients, })) } @@ -131,10 +138,15 @@ func (h *Handler) PddCollect(c *gin.Context) { return } - result, err := service.CreatePddCollectTasks(h.db, ids) + result, err := service.CreatePddCollectTasksForUser(h.db, currentUser(c), ids, c.PostForm("client_id")) if err != nil { - fail(c, http.StatusInternalServerError, - "创建采集任务失败:"+err.Error()+"。这一批任务整体没有创建,可以直接重试。") + status := http.StatusInternalServerError + message := "创建采集任务失败,系统没有改动这一批数据。请稍后重试。" + if service.IsValidationError(err) { + status = http.StatusBadRequest + message = err.Error() + "。这一批任务整体没有创建,请重新选择。" + } + fail(c, status, message) return } diff --git a/admin/repository/task.go b/admin/repository/task.go index 748c282..c336744 100644 --- a/admin/repository/task.go +++ b/admin/repository/task.go @@ -21,8 +21,8 @@ const claimCandidateLimit = 10 // 指定给本机的 assigned_client = 我 且 status = 'assigned' // 无主的 assigned_client 为空 且 status = 'pending' // -// 采集任务创建时不指定客户端(浏览商品页没有副作用,哪台设备采都一样), -// 所以必须支持第二种,否则那些任务永远没人能领。 +// 采集任务默认不指定客户端(浏览商品页没有副作用,哪台设备采都一样), +// PDD 批量页面也允许人工指定,因此两种领取条件都必须保留。 // 采购任务可以指定也可以留空——涉及钱和账号时应当显式分配。 // // **指定给本机的优先。** 显式分配是人为决定,应当先兑现; @@ -422,24 +422,32 @@ func DeleteTasks(q Execer, taskIDs []string) (int64, error) { return res.RowsAffected() } -// InsertCollectTask 建一条采集任务。 -// -// `[必须]` 采集任务**不指定客户端**:assigned_client 为 NULL、status 为 pending, -// 谁领到就在领取时标记谁(见 #17 和 ClaimNextTask)。采集是纯读取操作, -// 哪台机器跑都一样,指定了反而会在那台机器关着的时候干等。 -// -// goodsURL 必填 —— Client 契约里 pdd_goods_url 是 NOT NULL, -// 没有它客户端拿到任务也不知道去哪采。 +// InsertCollectTask 建一条不指定客户端的采集任务。 +// 保留这个入口给蝦皮、顺运宝等现有流程使用,避免它们被 PDD 页的新选项影响。 func InsertCollectTask(q Execer, taskID, goodsID, goodsURL string) error { + return InsertCollectTaskForClient(q, taskID, goodsID, goodsURL, "") +} + +// InsertCollectTaskForClient 建一条采集任务。 +// assignedClient 为空时任务无主待领;有值时只等待指定客户端领取。 +// goodsURL 必填 —— Client 契约里 pdd_goods_url 是 NOT NULL。 +func InsertCollectTaskForClient(q Execer, taskID, goodsID, goodsURL, assignedClient string) error { if goodsID == "" || goodsURL == "" { return fmt.Errorf("采集任务的商品 ID 和链接都不能为空") } + assignedClient = strings.TrimSpace(assignedClient) + status := model.TaskPending + assigned := sql.NullString{} + if assignedClient != "" { + status = model.TaskAssigned + assigned = sql.NullString{String: assignedClient, Valid: true} + } now := model.NowISO() _, err := q.Exec(` INSERT INTO tasks (task_id, task_type, status, assigned_client, pdd_goods_url, pdd_goods_id, created_at, updated_at) - VALUES (?, 'collect', 'pending', NULL, ?, ?, ?, ?)`, - taskID, goodsURL, goodsID, now, now) + VALUES (?, 'collect', ?, ?, ?, ?, ?, ?)`, + taskID, status, assigned, goodsURL, goodsID, now, now) if err != nil { return fmt.Errorf("创建商品 %s 的采集任务失败: %w", goodsID, err) } diff --git a/admin/service/pdd.go b/admin/service/pdd.go index 6c46048..30ab742 100644 --- a/admin/service/pdd.go +++ b/admin/service/pdd.go @@ -558,7 +558,7 @@ func (r CollectTaskResult) Skipped() int { return r.SkippedCollecting + r.SkippedCollected + r.SkippedDeleted } -// CreatePddCollectTasks 为勾选的 PDD 商品创建采集任务。 +// CreatePddCollectTasks 为勾选的 PDD 商品创建不指定客户端的采集任务。 // // `[必须]` **不指定客户端**(assigned_client 为 NULL,status 为 pending), // 谁领到就在领取时标记谁,见 #17。采集是纯读取操作,哪台机器跑都一样, @@ -576,6 +576,20 @@ func (r CollectTaskResult) Skipped() int { // 返回的跳过分类必须显示给操作员。静默跳过的话, // 操作员会以为任务建好了,等半天没动静也不知道为什么。 func CreatePddCollectTasks(db *sql.DB, goodsIDs []string) (CollectTaskResult, error) { + return createPddCollectTasks(db, goodsIDs, "", "") +} + +// CreatePddCollectTasksForUser 为 PDD 批量页面创建可选客户端的采集任务。 +// 即使浏览器伪造 clientID,也必须重新按当前登录账号校验可见范围。 +func CreatePddCollectTasksForUser(db *sql.DB, actor *model.User, goodsIDs []string, clientID string) (CollectTaskResult, error) { + visibleUserID, err := visibleClientUserID(actor) + if err != nil { + return CollectTaskResult{}, err + } + return createPddCollectTasks(db, goodsIDs, strings.TrimSpace(clientID), visibleUserID) +} + +func createPddCollectTasks(db *sql.DB, goodsIDs []string, clientID, visibleUserID string) (CollectTaskResult, error) { var result CollectTaskResult goodsIDs = dedupe(goodsIDs) @@ -589,6 +603,16 @@ func CreatePddCollectTasks(db *sql.DB, goodsIDs []string) (CollectTaskResult, er } defer tx.Rollback() // 已提交的事务再 Rollback 是空操作,安全 + if clientID != "" { + visible, err := repository.ClientVisibleToUser(tx, clientID, visibleUserID) + if err != nil { + return CollectTaskResult{}, err + } + if !visible { + return CollectTaskResult{}, invalidInput("所选客户端不存在或不在当前账号可见范围") + } + } + // 跳过原因里"正在采集"的那些,各自还要等多久才超时可重试; // 取其中最快的一个,给操作员一个"下一步该等多久"的具体数字。 var ( @@ -632,8 +656,8 @@ func CreatePddCollectTasks(db *sql.DB, goodsIDs []string) (CollectTaskResult, er continue } - if err := repository.InsertCollectTask( - tx, newCollectTaskID(), p.GoodsID, p.URL); err != nil { + if err := repository.InsertCollectTaskForClient( + tx, newCollectTaskID(), p.GoodsID, p.URL, clientID); err != nil { return CollectTaskResult{}, err } result.Created++ diff --git a/admin/service/pdd_page_test.go b/admin/service/pdd_page_test.go index e9f08e5..0aa370f 100644 --- a/admin/service/pdd_page_test.go +++ b/admin/service/pdd_page_test.go @@ -597,8 +597,8 @@ func collectTaskOf(t *testing.T, db *sql.DB, goodsID string) (status, assigned, return status, a.String, url, true } -// `[必须]` 采集任务不指定客户端。采集是纯读取,哪台机器跑都一样, -// 指定了反而会在那台机器关着的时候干等。 +// `[必须]` 默认不指定客户端。常规采集哪台机器跑都一样, +// 保持无主待领可以避免某台机器关着时一直等待。 func TestCreatePddCollectTasks_不指定客户端(t *testing.T) { db := newTestDB(t) createProduct(t, db, "737116531267") @@ -651,6 +651,75 @@ func TestCreatePddCollectTasks_建出的任务能被任意客户端领走(t *tes } } +func TestCreatePddCollectTasksForUser_指定客户端时只等待该客户端领取(t *testing.T) { + db := newTestDB(t) + admin := prepareAdminUser(t, db, time.Date(2026, 8, 10, 1, 0, 0, 0, time.UTC)) + RegisterClient(db, model.Client{ClientID: "client-chosen", Name: "指定机器"}, true) + RegisterClient(db, model.Client{ClientID: "client-other", Name: "其他机器"}, true) + createProduct(t, db, "737116531267") + + result, err := CreatePddCollectTasksForUser(db, admin, []string{"737116531267"}, "client-chosen") + if err != nil || result.Created != 1 { + t.Fatalf("指定客户端建任务失败: result=%+v err=%v", result, err) + } + status, assigned, _, ok := collectTaskOf(t, db, "737116531267") + if !ok || status != "assigned" || assigned != "client-chosen" { + t.Fatalf("任务初始状态或客户端不对: status=%q assigned=%q ok=%t", status, assigned, ok) + } + otherTask, err := ClaimNextTask(db, "client-other", []string{"collect"}) + if err != nil || otherTask != nil { + t.Fatalf("其他客户端不应领到指定任务: task=%+v err=%v", otherTask, err) + } + chosenTask, err := ClaimNextTask(db, "client-chosen", []string{"collect"}) + if err != nil || chosenTask == nil { + t.Fatalf("指定客户端应该能领取任务: task=%+v err=%v", chosenTask, err) + } +} + +func TestCreatePddCollectTasksForUser_采购员不能指定别人客户端且整批回滚(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") + createProduct(t, db, "937122477375") + + _, err := CreatePddCollectTasksForUser(db, buyerA, + []string{"737116531267", "937122477375"}, "client-b") + if !IsValidationError(err) { + t.Fatalf("不可见客户端应返回表单错误,实际 %v", err) + } + 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) + } + for _, goodsID := range []string{"737116531267", "937122477375"} { + product, _ := repository.GetPddProductByGoodsID(db, goodsID) + if product.CollectStatus != model.CollectPending { + t.Errorf("拒绝后商品 %s 状态不应改变,实际 %s", goodsID, product.CollectStatus) + } + } +} + +func TestCreatePddCollectTasksForUser_不指定时保持无主待领(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 := CreatePddCollectTasksForUser(db, admin, []string{"737116531267"}, ""); err != nil { + t.Fatal(err) + } + status, assigned, _, _ := collectTaskOf(t, db, "737116531267") + if status != "pending" || assigned != "" { + t.Errorf("默认应保持无主待领,status=%q assigned=%q", status, assigned) + } +} + func TestCreatePddCollectTasks_按商品去重(t *testing.T) { db := newTestDB(t) createProduct(t, db, "737116531267") diff --git a/admin/static/js/app.js b/admin/static/js/app.js index 2e23827..7f05805 100644 --- a/admin/static/js/app.js +++ b/admin/static/js/app.js @@ -159,6 +159,26 @@ }); } + /* 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; + + openButton.addEventListener("click", function () { + count.textContent = checkedCount(); + }); + form.addEventListener("submit", function (event) { + if (event.submitter !== submitButton) return; + submitButton.disabled = true; + submitButton.setAttribute("aria-busy", "true"); + submitButton.textContent = "创建中…"; + }); + } + /* ── 弹窗 ────────────────────────────────── 弹窗**内容由服务端渲染**,这里只负责显示、隐藏和把内容取回来。 不要在这里拼业务数据——价格格式、规格顺序都是业务规则, @@ -375,6 +395,7 @@ setupUserResetModal(); setupClientAssignmentModal(); setupPurchaseModal(); + setupCollectModal(); setupModals(); setupRowDetail(); setupCaptchaRefresh(); diff --git a/admin/templates/pdd/list.html b/admin/templates/pdd/list.html index 83458e8..47b3624 100644 --- a/admin/templates/pdd/list.html +++ b/admin/templates/pdd/list.html @@ -20,7 +20,7 @@ {{/* 文案是「创建采集任务」不是「采集」:点完只是排了个队, 真正去手机上采要等客户端来领,可能几秒也可能几分钟 */}} -
@@ -139,6 +139,37 @@ +{{/* ── 批量采集确认弹窗 ───────────────────────── + 客户端是可选项,默认留空。服务端仍会按当前账号重新校验候选范围。 */}} + + {{/* ── 编辑弹窗的壳子 ─────────────────────────── 里面的内容双击行时由 /pdd/detail 返回,前端只负责放进来和显示。 */}}