feat(gateway): 下单任务支持 callback_url 终结类事件异步通知

- POST /api/orders 新增可选 callback_url(仅 http/https,其余 422)
- 仅终结类事件各通知一次:terminal report 推入终态(succeeded/failed/
  needs_human)、租约过期被 sweep 置 stale;中间态与终结后的监控上报不通知
- 幂等重发不更新既有任务的回调地址;投递 best-effort 单次尝试,失败只记日志
- CallbackNotifier 发后不管(create_task + 在途任务强引用),关停等在途发完;
  生产路径按回调地址逐次构造客户端,满足统一出站代理策略(test_proxy.py)
- tasks 表加 callback_url 列,GatewayDB.start() 内置迁移兼容既有库
- 新增 RAKUTEN_CALLBACK_TIMEOUT_SECONDS(默认 10);文档补 §4.8;
  openapi.json 重新导出(gitignore 未跟踪);新增 17 条测试,全量 492 通过
This commit is contained in:
2026-08-17 01:05:37 +08:00
parent d54af141eb
commit 48c6f2a28f
10 changed files with 649 additions and 28 deletions
+48 -2
View File
@@ -74,6 +74,7 @@ CREATE TABLE tasks (
task_id TEXT PRIMARY KEY, -- 幂等键,见 §4.2
site TEXT NOT NULL, -- rakuten(交易服务仅覆盖乐天市场)
intent_json TEXT NOT NULL, -- 下单意图原文,gateway 不解释内容
callback_url TEXT, -- 终结类事件通知地址(可选),见 §4.8
status TEXT NOT NULL, -- 见 §3
lease_owner TEXT, -- worker_id
lease_expires_at TEXT, -- ISO8601 UTC
@@ -116,14 +117,16 @@ CREATE TABLE workers (
"variant_id": "...", // 多规格商品必填
"options": {},
"max_total_yen": 30000 // 可选,覆盖本次的金额上限
}
},
"callback_url": "https://upstream.example.com/hooks/rakuten-order" // 可选,终结类事件通知,见 §4.8
}
```
响应 `data``{"task_id": "...", "status": "queued", "created": true}`
**幂等**:同一个 `task_id` 重复提交不新建任务,返回既有任务且 `created=false`
上游重发不会变成两单。
上游重发不会变成两单。注意 `callback_url` 只在首次创建时写入,幂等重发不更新
既有任务的任何字段——要换通知地址必须用新 `task_id`
### 4.3 GET /api/orders/lease — 本地长轮询领取
@@ -198,6 +201,48 @@ CREATE TABLE workers (
付款期限监控**不在 gateway**:本地机 7×24 在线,那套逻辑放本地(§6),
gateway 不重复实现一遍定时器。
### 4.8 回调通知(callback_url,2026-08-16)
上游提交任务时可带 `callback_url`(http/https 绝对 URL,其余提交时 422)。
登记后网关在**终结类事件**发生时向该地址 POST 一条 JSON 通知,上游免去轮询。
触发时机只有两类,每类一次:
1. **任务到达终态**`succeeded` / `failed` / `needs_human`):worker 的
`terminal=true` report 真正把任务推入终态的那一次。中间态 report
(in_cart / ordered / awaiting_payment 非 terminal)不通知;任务已终结后的
后续 report(如付款后监控的 shipped/delivered)也不重复通知。
2. **任务被置 stale**:租约过期被 sweep 标记(见 §5)。上游收到后应走人工
排查 + reclaim 流程。
终态事件的 payload(无信封,直接是 JSON 对象):
```jsonc
{
"task_id": "po-20260727-0001",
"site": "rakuten",
"event": "terminal",
"status": "succeeded", // 终态:succeeded / failed / needs_human
"state": "paid", // 本次 report 的订单状态
"payable_yen": 12800,
"pay_deadline": "2026-07-30T14:59:00Z",
"site_order_id": "266123-20260727-0001234",
"evidence_ref": "po-20260727-0001/05-payment",
"detail": "付款完成",
"reported_at": "2026-07-27T09:15:00Z"
}
```
stale 事件的 payload:`{"task_id", "site", "event": "stale", "status": "stale",
"detail"}`
投递语义是 **best-effort 单次尝试**:超时(`RAKUTEN_CALLBACK_TIMEOUT_SECONDS`
默认 10 秒)、非 2xx、连接错误都只记网关日志,不重试、不阻塞 report/sweep
主流程。回调只是「省轮询」的提示,可能丢失——**权威状态仍以
`GET /api/orders/{task_id}` 为准**,上游应保留对账轮询(可降低频率)。
回调出站沿用统一代理策略(`app/shared/proxy.py`,按目标 host 判定 bypass);
上游如需鉴权,可把 token 编进 callback_url 的 query 里自行校验。
## 5. 租约过期:绝不自动重投(本文最关键的一条)
常规任务队列在租约超时后会把任务放回队列重新分发。**这里必须禁止**:本地可能已经
@@ -280,6 +325,7 @@ gateway 侧:
- `RAKUTEN_LEASE_TTL_SECONDS`(默认 300)
- `RAKUTEN_LEASE_MAX_WAIT_SECONDS`(默认 60)
- `RAKUTEN_WORKER_OFFLINE_ALERT_SECONDS`(默认 300)
- `RAKUTEN_CALLBACK_TIMEOUT_SECONDS`(默认 10,回调通知单次 HTTP 超时,见 §4.8)
trading 侧新增: