Files
cmautobuy/admin/repository/task.go
T
chengmaandClaude Opus 5 7ad82b7795 feat: 实现结果提交接口与幂等处理
补齐 submit_result / submit_failure,Admin 侧的三个接口全部可用,
Client 的完整一圈(领取 → 执行 → 提交)现在能走通了。

实现
- 幂等:idempotency_keys 表。同键同内容返回上次的响应且不重复落库,
  同键不同内容返回 409。幂等记录与业务写入在**同一事务**,
  分开写的话业务成功但幂等没记上,重试会被重复处理
- 无条件接受(契约 §4.1,最容易写错的一条):
  任务已取消、已重派给别人,都照样接受结果——客户端中途不查任务状态,
  必然会提交"Admin 这边已经不要了"的结果,而它可能真的已经下过单,
  这些数据必须留痕
- 采集任务的结果落到商品级 shopee_products.pdd_data 并置 collected;
  失败则置 failed 并把原因写进 collect_error,操作员才看得见
- 失败状态映射:retry_wait→assigned,其余同名
- 三个接口都刷新 last_seen_at

新增 task_claims 表(migrations v2)
契约要求"只有从未分配给该客户端的任务才返回 403",但 assigned_client
只记当前归属,重派后就查不出原来那台领过——而契约又要求那种情况必须接受。
没有这张表这条规则根本没法判断。顺带得到一份审计记录。

修复第二个并发 bug:事务必须 BEGIN IMMEDIATE
并发提交报 SQLITE_BUSY。根因是 Go 的 db.Begin() 默认发 BEGIN DEFERRED,
事务开始时不拿写锁,多个事务各自先读再想升级成写就互相卡死,
这种情况 busy_timeout 救不了。DSN 加 _txlock=immediate 后事务一开始
就排队拿锁。实测 6 个并发事务:默认失败 5/6,加参数后 0/6。
已写进 docs/admin/03-data-model.md §2.1。

已验证(Go 1.23.0)
- 30 个单元测试全过,并发用例重复 20 次稳定通过
- 端到端:claim 200 → 提交 200 → 重复提交返回完全相同的响应 →
  同键不同内容 409 → 没领过的客户端 403 → 任务不存在 404 →
  缺 Idempotency-Key 400;库里 task=succeeded、幂等 1 条、领取历史 1 条

说明:Gitea 尚未配置,本次无对应工单号。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 17:04:49 +08:00

231 lines
7.4 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。
//
// 防并发的做法是**条件更新 + 检查影响行数**:先查出候选,
// 再用 `WHERE task_id = ? AND status = 'assigned'` 去更新,
// 影响行数为 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'`
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)
}
}
query += ` ORDER BY 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 {
res, err := db.Exec(`
UPDATE tasks SET status = 'claimed', claimed_at = ?, updated_at = ?
WHERE task_id = ? AND status = 'assigned'`,
now, now, taskID)
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
}
// TaskExists 判断任务在不在,并返回它的类型和关联的蝦皮商品编号。
func TaskExists(q Execer, taskID string) (exists bool, taskType model.TaskType, goodsID string, err error) {
var gid sql.NullString
e := q.QueryRow(
`SELECT task_type, goods_id FROM tasks WHERE task_id = ?`,
taskID).Scan(&taskType, &gid)
if e == sql.ErrNoRows {
return false, "", "", nil
}
if e != nil {
return false, "", "", fmt.Errorf("查询任务 %s 失败: %w", taskID, e)
}
return true, taskType, gid.String, 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
}