Files
cmautobuy/admin/repository/task.go
T
chengmaandClaude Opus 5 6ae768463f feat: 实现客户端注册与任务领取接口
Admin 的第一个业务功能。选它打头是因为它是穿透所有层的最薄一条竖切
(HTTP → handler/api → service → repository → SQLite → handler/web → 页面),
一个工单把分层模式立起来,后面四个模块照抄;同时它是与 Client 联调的接口,
能解锁另一条并行的工作线。

实现
- POST /tasks/claim:注册 + 领取。注册就在这里做,没有单独的注册接口,
  也没有心跳(理由见 docs/admin/04-client-api.md §3)
- 领取用条件更新 + 检查影响行数防并发,SQLite 没有 SELECT FOR UPDATE
- 客户端列表页:查询、按名称搜索、批量删除
- 在线状态是**算出来的**(last_seen_at 在 10 分钟内),数据库里没有该字段
- CSRF 中间件:双提交 Cookie,手写 82 行不引依赖。
  **只挂页面路由**,/api/v1/client/* 不能加——Client 不是浏览器、没有 Cookie
- 14 个单元测试

修复一个真 bug:PRAGMA 必须写进 DSN
并发领取测试报 database is locked (SQLITE_BUSY)。根因是
PRAGMA busy_timeout 每连接生效,而 database/sql 是连接池——
db.Exec("PRAGMA ...") 只作用于当时那条连接,池子新开的连接没执行过。
单线程正常、一并发就炸。改成 DSN 传参后并发测试跑 20 次全过。
这个坑已写进 docs/admin/03-data-model.md §2.1。

与工单的两处差异
- 去掉 name_is_custom 列后,"人工改的名字不被覆盖"改用更简单的做法:
  ON CONFLICT DO UPDATE SET 里不含 name,即只在首次注册时写入。
  效果相同,零额外字段、零迁移。已同步 04 §3
- 验收项"不向 dry_run 客户端分配真实下单任务"**未实现**:
  tasks 表没有字段标记任务是否需要真实下单。当前真实下单开关默认关闭、
  MVP 全是演练模式,暂不出问题,但开真实下单前必须补该字段,需另开工单

已验证(Go 1.23.0)
- go vet / gofmt / go test 全过,并发测试重复 20 次稳定通过
- 端到端:无任务 claim 204;插入任务后 claim 200 且 payload 含
  goods_url/goods_id/options/quantity/max_price_cent、无租约无 Admin 状态;
  重复 claim 204;缺 X-Client-Id 400;POST 无 CSRF token 403;
  列表页两台客户端在线状态与统计正确

说明:Gitea 尚未配置,本次无对应工单号。
submit_result / submit_failure 及其幂等处理留给下一个工单。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 16:56:18 +08:00

128 lines
3.9 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 // 被别的客户端抢先了,换下一条
}
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
}