Files
cmautobuy/admin/handler/api/client_api.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

236 lines
7.8 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 api 是给 Client 用的 JSON 接口。
//
// **权威契约在 Client 那边**:docs/client/04-admin-api-contract.md。
// 本包只是实现,两边说法不一致时以 Client 契约为准,要改先走工单。
//
// 一共只有三个接口,**不得新增"让 Client 查询状态"类接口**,
// 也不得加回租约和心跳,理由见 docs/admin/04-client-api.md §1。
package api
import (
"database/sql"
"encoding/json"
"log"
"net/http"
"github.com/gin-gonic/gin"
"cmautobuy/admin/model"
"cmautobuy/admin/service"
)
// Handler 持有接口共用的依赖。
type Handler struct {
db *sql.DB
}
// Register 挂上三个接口。
func Register(r *gin.Engine, db *sql.DB) {
h := &Handler{db: db}
g := r.Group("/api/v1/client")
{
g.POST("/tasks/claim", h.Claim)
g.POST("/tasks/:task_id/result", h.SubmitResult)
g.POST("/tasks/:task_id/failure", h.SubmitFailure)
}
}
// ClaimRequest 是领取任务的请求体。
// 字段对应 docs/client/04-admin-api-contract.md §5。
type ClaimRequest struct {
Client struct {
// Name 是对 Client 契约的一处小扩展,需联合评审确认。
// **要容忍它缺失**:没有就用 X-Client-Id 当显示名。
Name string `json:"name"`
} `json:"client"`
SupportedTypes []string `json:"supported_types"`
Device struct {
Address string `json:"address"`
Platform string `json:"platform"`
PddPackage string `json:"pdd_package"`
} `json:"device"`
Capabilities struct {
PurchaseMode string `json:"purchase_mode"`
SchemaVersions []int `json:"schema_versions"`
} `json:"capabilities"`
}
// Claim 领取一个任务。
//
// 处理顺序(见 docs/admin/04-client-api.md §2):
// 1. 先做客户端注册/更新——**注册就在这里做,没有单独的注册接口**;
// 2. 查 assigned_client = X-Client-Id 且 status = 'assigned' 的任务,取一条;
// 3. 没有返回 204 No Content(不是 200 加空对象);
// 4. 有就用条件更新把状态改成 claimed,检查影响行数防并发。
//
// 响应**不含租约**,也不含 Admin 侧状态。
func (h *Handler) Claim(c *gin.Context) {
clientID := c.GetHeader("X-Client-Id")
if clientID == "" {
apiError(c, http.StatusBadRequest, "MISSING_CLIENT_ID",
"缺少 X-Client-Id 请求头", false)
return
}
var req ClaimRequest
if err := c.ShouldBindJSON(&req); err != nil {
apiError(c, http.StatusBadRequest, "INVALID_BODY",
"请求体不是合法 JSON", false)
return
}
// 1. 注册/更新客户端。**注册就在这里做,没有单独的注册接口。**
// capabilities 原样存起来,将来查问题时能看到客户端当时声明了什么。
caps, _ := json.Marshal(req.Capabilities)
err := service.RegisterClient(h.db, model.Client{
ClientID: clientID,
Name: req.Client.Name, // 为空时 repository 会用 clientID 兜底
DeviceAddress: req.Device.Address,
Platform: req.Device.Platform,
PddPackage: req.Device.PddPackage,
Capabilities: string(caps),
})
if err != nil {
log.Printf("client_register_failed client_id=%s err=%v", clientID, err)
apiError(c, http.StatusInternalServerError, "CLIENT_REGISTER_FAILED",
"登记客户端失败", true)
return
}
// 2. 领取一个任务,只会拿到分配给这个客户端的
task, err := service.ClaimNextTask(h.db, clientID, req.SupportedTypes)
if err != nil {
log.Printf("task_claim_failed client_id=%s err=%v", clientID, err)
apiError(c, http.StatusInternalServerError, "TASK_CLAIM_FAILED",
"领取任务失败", true)
return
}
// 3. 没有可领的任务 -> 204 No Content,**不是 200 加空对象**。
// 新客户端第一次来必然走到这里(还没人给它分配),是正常的。
if task == nil {
c.Status(http.StatusNoContent)
return
}
log.Printf("task_claimed client_id=%s task_id=%s type=%s",
clientID, task.TaskID, task.TaskType)
c.JSON(http.StatusOK, gin.H{"task": taskPayload(task)})
}
// taskPayload 把任务转成契约里的响应结构。
//
// **不含租约,也不含 Admin 侧状态**——Client 不关心这些,
// 见 docs/client/04-admin-api-contract.md §5。
func taskPayload(t *model.Task) gin.H {
payload := gin.H{
"goods_url": t.PddGoodsURL,
}
if t.PddGoodsID != "" {
payload["goods_id"] = t.PddGoodsID
}
if t.PddOptions != "" {
// pdd_options 存的是 JSON 字符串,要还原成对象再嵌进去,
// 否则 Client 拿到的是一个字符串而不是 {"color":...}
var options map[string]any
if err := json.Unmarshal([]byte(t.PddOptions), &options); err == nil {
payload["options"] = options
}
}
// 采购任务必须带数量和价格上限,这是 Client 的价格保护
if t.Quantity > 0 {
payload["quantity"] = t.Quantity
}
if t.MaxPriceCent > 0 {
payload["max_price_cent"] = t.MaxPriceCent
}
return gin.H{
"id": t.TaskID,
"type": t.TaskType,
"version": t.Version,
"priority": t.Priority,
"payload": payload,
"created_at": t.CreatedAt,
"updated_at": t.UpdatedAt,
}
}
// SubmitResult 接收成功结果。
//
// **最容易写错的一条**(docs/admin/04-client-api.md §4.1):
// - 不得因为任务已取消而拒绝;
// - 不得因为任务已重派给别的客户端而拒绝;
// - 必须能接受同一任务来自多个客户端的多份结果;
// - 只有**从未分配给该客户端**的任务才返回 403。
//
// 原因:Client 中途不查任务状态,所以它必然会提交一些
// "Admin 这边已经不要了"的结果。而它可能真的已经下单了,
// 这些数据必须能交上来留痕。
//
// accepted: true 的意思是"我收到并存下了",不代表任务还算数。
func (h *Handler) SubmitResult(c *gin.Context) {
taskID := c.Param("task_id")
idemKey := c.GetHeader("Idempotency-Key")
if idemKey == "" {
apiError(c, http.StatusBadRequest, "MISSING_IDEMPOTENCY_KEY",
"缺少 Idempotency-Key 请求头", false)
return
}
// TODO(骨架): 1. 查 idempotency_keys:
// 键同、内容哈希同 -> 直接返回上次的 response_body,不重复落库
// 键同、内容哈希不同 -> 409 IDEMPOTENCY_CONFLICT
// TODO(骨架): 2. 在**同一个事务**里:
// 写 tasks.result_data;
// 采集任务同时写 shopee_products.pdd_data 并置 collect_status = collected;
// tasks.status = 'succeeded';
// 写 idempotency_keys。
// TODO(骨架): 3. 刷新客户端 last_seen_at
// (长任务期间不调 claim,不刷新会被误判成离线)。
_ = taskID
apiError(c, http.StatusNotImplemented, "NOT_IMPLEMENTED",
"提交结果接口尚未实现", false)
}
// SubmitFailure 接收失败或需人工处理的结果。
//
// Client 报告的 status 只会是 retry_wait / manual_review / failed / cancelled,
// 按下表落地(docs/admin/04-client-api.md §5):
//
// retry_wait -> assigned(放回去,等它再来领)
// manual_review -> manual_review
// failed -> failed
// cancelled -> cancelled
//
// §4.1 的无条件接受规则同样适用于本接口。
func (h *Handler) SubmitFailure(c *gin.Context) {
taskID := c.Param("task_id")
// TODO(骨架): 同 SubmitResult 的幂等处理;
// 采集任务失败时同步把 shopee_products.collect_status 置 failed,
// 错误信息写 collect_error,界面上要看得见。
_ = taskID
apiError(c, http.StatusNotImplemented, "NOT_IMPLEMENTED",
"提交失败接口尚未实现", false)
}
// apiError 按契约格式返回错误。
//
// 接口出错返回 JSON,页面出错渲染错误页,两者不要混。
// 格式见 docs/client/04-admin-api-contract.md §3。
func apiError(c *gin.Context, status int, code, message string, retryable bool) {
c.JSON(status, gin.H{
"error": gin.H{
"code": code,
"message": message,
"retryable": retryable,
"request_id": c.GetHeader("X-Request-Id"),
"details": gin.H{},
},
})
}