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:
@@ -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 按契约格式返回错误。
|
||||
|
||||
Reference in New Issue
Block a user