Files
cmautobuy/admin/repository/task.go
T

493 lines
17 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'
//
// 采集任务默认不指定客户端(浏览商品页没有副作用,哪台设备采都一样),
// PDD 批量页面也允许人工指定,因此两种领取条件都必须保留。
// 采购任务可以指定也可以留空——涉及钱和账号时应当显式分配。
//
// **指定给本机的优先。** 显式分配是人为决定,应当先兑现;
// 无主任务谁抢都一样,可以等。
//
// # 防并发
//
// 做法是**条件更新 + 检查影响行数**:先查出候选,再带着状态条件去更新,
// 影响行数为 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, limit, offset int) ([]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 LIMIT ? OFFSET ?`
args = append(args, limit, offset)
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 建一条不指定客户端的采集任务。
// 保留这个入口给蝦皮、顺运宝等现有流程使用,避免它们被 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', ?, ?, ?, ?, ?, ?)`,
taskID, status, assigned, goodsURL, goodsID, now, now)
if err != nil {
return fmt.Errorf("创建商品 %s 的采集任务失败: %w", goodsID, err)
}
return nil
}
// HasActivePurchaseTask 判断顺运宝明细是否已有尚未结束的采购任务。
func HasActivePurchaseTask(q Execer, sybID string) (bool, error) {
var count int
err := q.QueryRow(`
SELECT COUNT(*) FROM tasks
WHERE task_type = 'purchase' AND syb_id = ?
AND status IN ('pending', 'assigned', 'claimed')`, sybID).Scan(&count)
if err != nil {
return false, fmt.Errorf("检查顺运宝明细 %s 的进行中采购任务失败: %w", sybID, err)
}
return count > 0, nil
}
// InsertPurchaseTask 插入一条已分配、等待指定客户端领取的采购任务。
func InsertPurchaseTask(q Execer, task model.Task) error {
if task.TaskID == "" || task.AssignedClient == "" || task.SybID == "" ||
task.PddGoodsURL == "" || task.PddGoodsID == "" || task.PddOptions == "" ||
task.Quantity <= 0 || task.MaxPriceCent <= 0 {
return fmt.Errorf("采购任务缺少客户端、商品、规格、数量或人民币价格上限")
}
now := model.NowISO()
_, err := q.Exec(`
INSERT INTO tasks
(task_id, task_type, status, assigned_client,
syb_id, order_no, goods_id, shopee_sku_id,
pdd_goods_url, pdd_goods_id, pdd_options,
quantity, max_price_cent, created_at, updated_at)
VALUES (?, 'purchase', 'assigned', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
task.TaskID, task.AssignedClient, task.SybID, task.OrderNo,
task.GoodsID, task.ShopeeSKUID, task.PddGoodsURL, task.PddGoodsID,
task.PddOptions, task.Quantity, task.MaxPriceCent, now, now)
if err != nil {
return fmt.Errorf("创建顺运宝明细 %s 的采购任务失败: %w", task.SybID, err)
}
return nil
}