493 lines
17 KiB
Go
493 lines
17 KiB
Go
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
|
||
}
|