Files
cmautobuy/admin/repository/task.go
T
chengmaandClaude Opus 5 f0d7da37cf feat: 采集采购页,采集与采购统一展示 (#19)
原来的采购任务页是骨架:var rows []gin.H 从不查库,页面永远为空;
TODO 写的还是只查 task_type = 'purchase'。所以 #18 建出来的采集任务
在界面上哪儿都看不到——用户点完「创建采集任务」只能盯着
collect_status 猜。

模块名定为「采集采购」(用户指定),直接点出这页装的是哪两类任务,
比泛称「任务」更能让人一眼知道点进去看什么。路由 /tasks 不变,
改路由会让已有书签和文档链接全失效,没有收益。

列不按类型并列——两种任务字段完全不同,并列会让采集任务行一半是空列。
改成固定列 + 一列「目标」把业务信息概括成一句话:
  采集  PDD 737116531267
  采购  SO-001 · M/黑色 · 2件 · ≤¥42.00
拼接逻辑在 service 层,模板只负责显示。

统计和列表共用同一个筛选条件拼装函数。分开写的话总有一天会忘了
给统计也加条件,数字和表格对不上,操作员会以为页面坏了。

无主任务的客户端列显示「—」。#17 之后采集任务默认无主,这列会大量为空。

详情弹窗只读,采购专有字段(数量、价格上限、目标规格)在采集任务里
整段不出现,不显示空行。复用 #18 的弹窗机制,app.js 无需改动。

顺带清掉 PDD 页加进来之后一直没跟上的模块计数:多处「四个模块/四个页面」
改成五个。其中 06-quality-security.md 那两处是验证清单,
照着做的人只会测四个页面,PDD 页永远不在回归范围里。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 15:16:05 +08:00

447 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package repository
import (
"database/sql"
"fmt"
"strings"
"cmautobuy/admin/model"
)
// claimCandidateLimit 是一次最多尝试抢多少条。
// 抢不到说明被别的客户端拿走了,再试下一条;都抢不到就当作没任务。
const claimCandidateLimit = 10
// ClaimNextTask 为指定客户端领取一个任务。
//
// 没有可领的任务时返回 (nil, nil) —— 调用方据此返回 204。
//
// # 两种任务都能领
//
// 指定给本机的 assigned_client = 我 且 status = 'assigned'
// 无主的 assigned_client 为空 且 status = 'pending'
//
// 采集任务创建时不指定客户端(浏览商品页没有副作用,哪台设备采都一样),
// 所以必须支持第二种,否则那些任务永远没人能领。
// 采购任务可以指定也可以留空——涉及钱和账号时应当显式分配。
//
// **指定给本机的优先。** 显式分配是人为决定,应当先兑现;
// 无主任务谁抢都一样,可以等。
//
// # 防并发
//
// 做法是**条件更新 + 检查影响行数**:先查出候选,再带着状态条件去更新,
// 影响行数为 0 就说明被别人抢先了,换下一条。
// 不用 SELECT ... FOR UPDATE,SQLite 没有那个。
func ClaimNextTask(db *sql.DB, clientID string, supportedTypes []string) (*model.Task, error) {
if clientID == "" {
return nil, fmt.Errorf("client_id 不能为空")
}
query := `SELECT task_id FROM tasks
WHERE ( (assigned_client = ? AND status = 'assigned')
OR (assigned_client IS NULL AND status = 'pending') )`
args := []any{clientID}
// 客户端只声明支持某些类型时,不要给它别的类型
if len(supportedTypes) > 0 {
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(supportedTypes)), ",")
query += ` AND task_type IN (` + placeholders + `)`
for _, t := range supportedTypes {
args = append(args, t)
}
}
// (assigned_client IS NULL) 为 0/1,0 排前面 —— 指定给本机的优先于无主的
query += ` ORDER BY (assigned_client IS NULL), priority DESC, created_at LIMIT ?`
args = append(args, claimCandidateLimit)
rows, err := db.Query(query, args...)
if err != nil {
return nil, fmt.Errorf("查询可领任务失败: %w", err)
}
var candidates []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
rows.Close()
return nil, fmt.Errorf("读取候选任务失败: %w", err)
}
candidates = append(candidates, id)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
now := model.NowISO()
for _, taskID := range candidates {
// 两种情况合成一条语句:对"指定给我的"那种,写 assigned_client
// 是写同一个值,无副作用;对无主的,这一步就是"谁领到就标记谁"。
res, err := db.Exec(`
UPDATE tasks
SET status = 'claimed', assigned_client = ?,
claimed_at = ?, updated_at = ?
WHERE task_id = ?
AND ( (status = 'assigned' AND assigned_client = ?)
OR (status = 'pending' AND assigned_client IS NULL) )`,
clientID, now, now, taskID, clientID)
if err != nil {
return nil, fmt.Errorf("领取任务 %s 失败: %w", taskID, err)
}
n, err := res.RowsAffected()
if err != nil {
return nil, err
}
if n == 0 {
continue // 被别的客户端抢先了,换下一条
}
// 记一笔领取历史。提交结果时要靠它判断这台客户端有没有领过——
// 任务重派后 assigned_client 会变,只看它就查不出来了。
if err := RecordClaim(db, taskID, clientID, now); err != nil {
return nil, err
}
return GetTask(db, taskID)
}
return nil, nil // 没有可领的任务
}
// GetTask 按编号读一条任务。
func GetTask(db *sql.DB, taskID string) (*model.Task, error) {
var t model.Task
var assigned, claimedAt, sybID, orderNo, goodsID, skuID sql.NullString
var pddGoodsID, pddOptions, resultData, errCode, errMsg, finishedAt sql.NullString
var quantity, maxPrice sql.NullInt64
err := db.QueryRow(`
SELECT task_id, task_type, status, version, priority,
assigned_client, claimed_at,
syb_id, order_no, goods_id, shopee_sku_id,
pdd_goods_url, pdd_goods_id, pdd_options,
quantity, max_price_cent,
result_data, error_code, error_message, finished_at,
created_at, updated_at
FROM tasks WHERE task_id = ?`, taskID).Scan(
&t.TaskID, &t.TaskType, &t.Status, &t.Version, &t.Priority,
&assigned, &claimedAt,
&sybID, &orderNo, &goodsID, &skuID,
&t.PddGoodsURL, &pddGoodsID, &pddOptions,
&quantity, &maxPrice,
&resultData, &errCode, &errMsg, &finishedAt,
&t.CreatedAt, &t.UpdatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("读取任务 %s 失败: %w", taskID, err)
}
t.AssignedClient = assigned.String
t.ClaimedAt = claimedAt.String
t.SybID = sybID.String
t.OrderNo = orderNo.String
t.GoodsID = goodsID.String
t.ShopeeSKUID = skuID.String
t.PddGoodsID = pddGoodsID.String
t.PddOptions = pddOptions.String
t.Quantity = int(quantity.Int64)
t.MaxPriceCent = maxPrice.Int64
t.ResultData = resultData.String
t.ErrorCode = errCode.String
t.ErrorMessage = errMsg.String
t.FinishedAt = finishedAt.String
return &t, nil
}
// RecordClaim 记一笔"某客户端领过某任务"。
//
// 同一台客户端重复领同一个任务时只更新时间,不报错。
func RecordClaim(q Execer, taskID, clientID, claimedAt string) error {
_, err := q.Exec(`
INSERT INTO task_claims (task_id, client_id, claimed_at)
VALUES (?, ?, ?)
ON CONFLICT(task_id, client_id) DO UPDATE SET claimed_at = excluded.claimed_at`,
taskID, clientID, claimedAt)
if err != nil {
return fmt.Errorf("记录领取历史失败 task=%s client=%s: %w", taskID, clientID, err)
}
return nil
}
// HasEverClaimed 判断这台客户端**曾经**领过这个任务。
//
// 用于提交结果时的权限判断:只有从没领过的才拒绝(403)。
// 任务已取消、已重派给别人,都**不影响**这个判断——
// 契约要求那些情况仍然要接受结果,见 docs/admin/04-client-api.md §4.1。
func HasEverClaimed(q Execer, taskID, clientID string) (bool, error) {
var one int
err := q.QueryRow(
`SELECT 1 FROM task_claims WHERE task_id = ? AND client_id = ?`,
taskID, clientID).Scan(&one)
if err == sql.ErrNoRows {
return false, nil
}
if err != nil {
return false, fmt.Errorf("查询领取历史失败: %w", err)
}
return true, nil
}
// TaskInfo 是提交结果时需要知道的任务基本信息。
type TaskInfo struct {
TaskType model.TaskType
// GoodsID 是关联的**蝦皮**商品编号。
GoodsID string
// PddGoodsID 是要采集/购买的**拼多多**商品编号。
// 采集结果落到 pdd_products 时用的是它,不是 GoodsID —— 被采集的是 PDD 商品。
PddGoodsID string
}
// GetTaskInfo 查任务的类型和关联商品。任务不存在时返回 (nil, nil)。
func GetTaskInfo(q Execer, taskID string) (*TaskInfo, error) {
var info TaskInfo
var gid, pddGID sql.NullString
err := q.QueryRow(
`SELECT task_type, goods_id, pdd_goods_id FROM tasks WHERE task_id = ?`,
taskID).Scan(&info.TaskType, &gid, &pddGID)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("查询任务 %s 失败: %w", taskID, err)
}
info.GoodsID = gid.String
info.PddGoodsID = pddGID.String
return &info, nil
}
// MarkTaskSucceeded 记录成功结果。
//
// `[必须]` **不检查任务当前状态**。任务已取消、已重派给别的客户端,
// 都照样接受——客户端可能真的已经下单了,这些数据必须能留痕。
// 理由见 docs/admin/04-client-api.md §4.1。
func MarkTaskSucceeded(q Execer, taskID, resultData string) error {
now := model.NowISO()
_, err := q.Exec(`
UPDATE tasks
SET status = 'succeeded', result_data = ?, finished_at = ?, updated_at = ?
WHERE task_id = ?`,
resultData, now, now, taskID)
if err != nil {
return fmt.Errorf("标记任务 %s 成功失败: %w", taskID, err)
}
return nil
}
// MarkTaskFailure 记录失败/需人工处理的结果。
//
// status 由客户端报告,映射规则见 docs/admin/04-client-api.md §5:
//
// retry_wait -> assigned(放回去等它再来领)
// manual_review -> manual_review
// failed -> failed
// cancelled -> cancelled
//
// 同样**不检查任务当前状态**,理由同 MarkTaskSucceeded。
func MarkTaskFailure(q Execer, taskID string, newStatus model.TaskStatus, errCode, errMsg string) error {
now := model.NowISO()
// 放回待领取的话不算结束,finished_at 保持为空
finishedAt := any(now)
if newStatus == model.TaskAssigned {
finishedAt = nil
}
_, err := q.Exec(`
UPDATE tasks
SET status = ?, error_code = ?, error_message = ?,
finished_at = ?, updated_at = ?
WHERE task_id = ?`,
newStatus, errCode, errMsg, finishedAt, now, taskID)
if err != nil {
return fmt.Errorf("标记任务 %s 失败状态出错: %w", taskID, err)
}
return nil
}
// ---------- 采集采购列表(#19) ----------
// TaskFilter 是「采集采购」列表页支持的筛选条件,三项都可以为空。
type TaskFilter struct {
Type model.TaskType
Status model.TaskStatus
Keyword string // 同时匹配任务编号、订单号、PDD 商品 ID
}
// TaskListRow 是列表一行要用到的原始字段,还没翻成界面文字——
// 那是 service 层的事(尤其是「目标」列的拼接,见 #19)。
type TaskListRow struct {
TaskID string
TaskType model.TaskType
Status model.TaskStatus
AssignedClient string // 空表示无主任务
OrderNo string
PddGoodsID string
PddOptions string
Quantity int
MaxPriceCent int64
UpdatedAt string
// PddTitle 是 join pdd_products 拿到的标题,**可能为空**:
// 没有对应商品、或商品已被软删除时都是空。
// `[必须]` service 层退回显示 PddGoodsID,不能显示空,见 #19。
PddTitle string
}
// taskFilterClause 把三项筛选条件拼成 WHERE 子句,供 ListTasks 和
// CountTasksByStatus 共用——两处筛选逻辑必须完全一致,
// 否则底部统计会跟表格对不上(见 #19 的验收要求)。
func taskFilterClause(filter TaskFilter) (string, []any) {
var clauses []string
var args []any
if filter.Type != "" {
clauses = append(clauses, "t.task_type = ?")
args = append(args, string(filter.Type))
}
if filter.Status != "" {
clauses = append(clauses, "t.status = ?")
args = append(args, string(filter.Status))
}
if kw := strings.TrimSpace(filter.Keyword); kw != "" {
pattern := "%" + escapeLike(kw) + "%"
clauses = append(clauses,
"(t.task_id LIKE ? ESCAPE '\\' OR t.order_no LIKE ? ESCAPE '\\' OR t.pdd_goods_id LIKE ? ESCAPE '\\')")
args = append(args, pattern, pattern, pattern)
}
if len(clauses) == 0 {
return "", args
}
return " WHERE " + strings.Join(clauses, " AND "), args
}
// ListTasks 查采集采购列表,按更新时间倒序。
//
// `[必须]` 目标列所需字段全在 tasks 表上,不需要 join。
// `[建议]` 这里额外 LEFT JOIN 了 pdd_products 取标题,纯粹是为了让采集任务的
// 「目标」列更好读;join 条件里的 `deleted_at IS NULL` 让"join 不到"和
// "商品已软删除"这两种情况都自然落到 PddTitle 为空,service 层退回显示
// PddGoodsID 即可,不用在 service 里再判断一次已删除。
func ListTasks(q Execer, filter TaskFilter) ([]TaskListRow, error) {
where, args := taskFilterClause(filter)
sqlText := `
SELECT t.task_id, t.task_type, t.status, t.assigned_client,
t.order_no, t.pdd_goods_id, t.pdd_options, t.quantity, t.max_price_cent,
t.updated_at, p.title
FROM tasks t
LEFT JOIN pdd_products p
ON p.goods_id = t.pdd_goods_id AND p.deleted_at IS NULL` +
where + ` ORDER BY t.updated_at DESC, t.task_id DESC`
rows, err := q.Query(sqlText, args...)
if err != nil {
return nil, fmt.Errorf("查询任务列表失败: %w", err)
}
defer rows.Close()
list := make([]TaskListRow, 0, 16)
for rows.Next() {
var r TaskListRow
var assigned, orderNo, pddGoodsID, pddOptions, title sql.NullString
var quantity, maxPrice sql.NullInt64
if err := rows.Scan(
&r.TaskID, &r.TaskType, &r.Status, &assigned,
&orderNo, &pddGoodsID, &pddOptions, &quantity, &maxPrice,
&r.UpdatedAt, &title,
); err != nil {
return nil, fmt.Errorf("读取任务列表失败: %w", err)
}
r.AssignedClient = assigned.String
r.OrderNo = orderNo.String
r.PddGoodsID = pddGoodsID.String
r.PddOptions = pddOptions.String
r.Quantity = int(quantity.Int64)
r.MaxPriceCent = maxPrice.Int64
r.PddTitle = title.String
list = append(list, r)
}
return list, rows.Err()
}
// CountTasksByStatus 按当前筛选统计各状态的任务数。
//
// `[必须]` **用和 ListTasks 完全相同的筛选条件**——统计要跟随当前筛选,
// 筛了「采集」就只统计采集任务,否则底部数字和表格对不上,见 #19。
func CountTasksByStatus(q Execer, filter TaskFilter) (map[model.TaskStatus]int, error) {
where, args := taskFilterClause(filter)
sqlText := `SELECT t.status, COUNT(*) FROM tasks t` + where + ` GROUP BY t.status`
rows, err := q.Query(sqlText, args...)
if err != nil {
return nil, fmt.Errorf("统计任务状态失败: %w", err)
}
defer rows.Close()
counts := map[model.TaskStatus]int{}
for rows.Next() {
var status string
var n int
if err := rows.Scan(&status, &n); err != nil {
return nil, err
}
counts[model.TaskStatus(status)] = n
}
return counts, rows.Err()
}
// DeleteTasks 按任务编号批量删除,返回实际删掉的条数。
//
// `[必须]` 硬删,不是软删除:tasks 表本来就没有 deleted_at 列,
// 加一列属于数据库结构变更,不在本工单范围内(见 #19「不做」清单)。
// 遗留下来的 task_claims 记录不删——它没有外键约束,留着不影响任何查询,
// 之后想清理是可以独立做的小事,不值得为它扩大这次改动的范围。
func DeleteTasks(q Execer, taskIDs []string) (int64, error) {
if len(taskIDs) == 0 {
return 0, nil
}
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(taskIDs)), ",")
args := make([]any, len(taskIDs))
for i, id := range taskIDs {
args[i] = id
}
res, err := q.Exec(`DELETE FROM tasks WHERE task_id IN (`+placeholders+`)`, args...)
if err != nil {
return 0, fmt.Errorf("批量删除任务失败: %w", err)
}
return res.RowsAffected()
}
// InsertCollectTask 建一条采集任务。
//
// `[必须]` 采集任务**不指定客户端**:assigned_client 为 NULL、status 为 pending,
// 谁领到就在领取时标记谁(见 #17 和 ClaimNextTask)。采集是纯读取操作,
// 哪台机器跑都一样,指定了反而会在那台机器关着的时候干等。
//
// goodsURL 必填 —— Client 契约里 pdd_goods_url 是 NOT NULL,
// 没有它客户端拿到任务也不知道去哪采。
func InsertCollectTask(q Execer, taskID, goodsID, goodsURL string) error {
if goodsID == "" || goodsURL == "" {
return fmt.Errorf("采集任务的商品 ID 和链接都不能为空")
}
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)
if err != nil {
return fmt.Errorf("创建商品 %s 的采集任务失败: %w", goodsID, err)
}
return nil
}