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>
This commit is contained in:
chengma
2026-08-06 17:04:49 +08:00
co-authored by Claude Opus 5
parent 6ae768463f
commit 7ad82b7795
10 changed files with 983 additions and 45 deletions
+58 -39
View File
@@ -10,6 +10,8 @@ package api
import (
"database/sql"
"encoding/json"
"errors"
"io"
"log"
"net/http"
@@ -163,15 +165,36 @@ func taskPayload(t *model.Task) gin.H {
// - 不得因为任务已取消而拒绝;
// - 不得因为任务已重派给别的客户端而拒绝;
// - 必须能接受同一任务来自多个客户端的多份结果;
// - 只有**从未分配给该客户端**的任务才返回 403。
// - 只有**从未领过**这个任务的客户端才返回 403。
//
// 原因:Client 中途不查任务状态,所以它必然会提交一些
// "Admin 这边已经不要了"的结果。而它可能真的已经下单了,
// 这些数据必须能交上来留痕。
//
// accepted: true 的意思是"我收到并存下了",不代表任务还算数。
// 原因见 service.SubmitResult 的注释。
func (h *Handler) SubmitResult(c *gin.Context) {
h.handleSubmit(c, service.SubmitResult, "task_result_received")
}
// SubmitFailure 接收失败或需人工处理的结果。
//
// Client 报告的 status 映射见 docs/admin/04-client-api.md §5。
// §4.1 的无条件接受规则同样适用。
func (h *Handler) SubmitFailure(c *gin.Context) {
h.handleSubmit(c, service.SubmitFailure, "task_failure_received")
}
// submitFunc 是两个提交接口共用的处理函数形状。
type submitFunc func(db *sql.DB, taskID, clientID, idemKey string, rawBody []byte) (string, error)
// handleSubmit 把两个提交接口共同的部分抽出来:
// 取参数、读原始请求体、调 service、把业务错误翻译成 HTTP 状态码。
func (h *Handler) handleSubmit(c *gin.Context, submit submitFunc, event string) {
taskID := c.Param("task_id")
clientID := c.GetHeader("X-Client-Id")
if clientID == "" {
apiError(c, http.StatusBadRequest, "MISSING_CLIENT_ID",
"缺少 X-Client-Id 请求头", false)
return
}
idemKey := c.GetHeader("Idempotency-Key")
if idemKey == "" {
apiError(c, http.StatusBadRequest, "MISSING_IDEMPOTENCY_KEY",
@@ -179,43 +202,39 @@ func (h *Handler) SubmitResult(c *gin.Context) {
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,不刷新会被误判成离线)。
// 读**原始字节**而不是解析后再序列化——幂等要比对请求内容的哈希,
// 重新序列化会因为字段顺序、空格不同而算出不一样的哈希。
rawBody, err := io.ReadAll(c.Request.Body)
if err != nil {
apiError(c, http.StatusBadRequest, "INVALID_BODY", "读取请求体失败", false)
return
}
_ = taskID
apiError(c, http.StatusNotImplemented, "NOT_IMPLEMENTED",
"提交结果接口尚未实现", false)
}
respJSON, err := submit(h.db, taskID, clientID, idemKey, rawBody)
switch {
case err == nil:
log.Printf("%s client_id=%s task_id=%s", event, clientID, taskID)
c.Data(http.StatusOK, "application/json; charset=utf-8", []byte(respJSON))
// 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")
case errors.Is(err, service.ErrTaskNotFound):
apiError(c, http.StatusNotFound, "TASK_NOT_FOUND",
"任务不存在", false)
// TODO(骨架): 同 SubmitResult 的幂等处理;
// 采集任务失败时同步把 shopee_products.collect_status 置 failed,
// 错误信息写 collect_error,界面上要看得见。
case errors.Is(err, service.ErrNeverClaimed):
// 注意:只有**从没领过**才会走到这里。
// 任务已取消、已重派,都不会拒绝。
apiError(c, http.StatusForbidden, "TASK_NOT_ASSIGNED",
"该任务从未分配给这个客户端", false)
_ = taskID
apiError(c, http.StatusNotImplemented, "NOT_IMPLEMENTED",
"提交失败接口尚未实现", false)
case errors.Is(err, service.ErrIdempotencyConflict):
apiError(c, http.StatusConflict, "IDEMPOTENCY_CONFLICT",
"相同幂等键提交了不同内容。内容变了应该用新的 attempt_id 生成新键", false)
default:
log.Printf("%s_failed client_id=%s task_id=%s err=%v", event, clientID, taskID, err)
apiError(c, http.StatusInternalServerError, "SUBMIT_FAILED",
"保存结果失败,请稍后重试", true)
}
}
// apiError 按契约格式返回错误。