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
+8 -1
View File
@@ -53,12 +53,19 @@ async def submit_order(
幂等:同一个 task_id 重复提交不新建任务,返回既有任务且 created=false——
上游重发不会变成两单。下单不可逆,这是防重复下单的第一道闸。
可带 callback_url:任务到达终态(succeeded / failed / needs_human)或被置
stale 时,网关向该地址 POST 一条 JSON 通知(best-effort 单次投递,§4.8)。
幂等重发不会更新既有任务的回调地址。
提交后任务为 queued。进度跟踪用 GET /api/orders/{task_id}(任务状态 +
完整状态历史);任务状态词汇表:queued / leased / running / succeeded /
failed / needs_human / stale。
"""
data = await container.task_queue.submit(
task_id=payload.task_id, site=payload.site, intent=payload.intent
task_id=payload.task_id,
site=payload.site,
intent=payload.intent,
callback_url=payload.callback_url,
)
return ApiResponse[SubmitOrderData](
success=True, msg="success", data=data, code=0
+119
View File
@@ -0,0 +1,119 @@
"""终结类事件回调:把任务结局 POST 到上游登记的通知地址
触发时机只有两类(docs/order-gateway.md §4.8):
- worker 的 terminal report 把任务推入终态(succeeded / failed / needs_human)
- 租约过期被 sweep 置 stale(任务卡住,需要人工 reclaim)
投递是 best-effort:单次尝试,失败只记日志、不重试。回调只是「省轮询」的提示,
权威状态仍以 GET /api/orders/{task_id} 为准。任何异常都不允许逃出发送路径,
不能影响 report / sweep 主流程。
出站遵循统一代理策略(app/shared/proxy.py):回调地址逐任务不同,而代理 bypass
是按目标 host 判定的,所以客户端按发送逐次构造,不能在建通知器时定死。
"""
from __future__ import annotations
import asyncio
import logging
from typing import TYPE_CHECKING, Any
import httpx
from app.shared.proxy import httpx_client_options
if TYPE_CHECKING:
from app.gateway.db import ReportRow, TaskRow
from app.shared.config import Settings
logger = logging.getLogger(__name__)
def terminal_payload(task: TaskRow, report: ReportRow, final_status: str) -> dict[str, Any]:
"""terminal report 触发的通知:任务终态 + 本次上报的字段(金额/单号/证据路径等)"""
return {
"task_id": task.task_id,
"site": task.site,
"event": "terminal",
"status": final_status,
"state": report.state,
"payable_yen": report.payable_yen,
"pay_deadline": report.pay_deadline,
"site_order_id": report.site_order_id,
"evidence_ref": report.evidence_ref,
"detail": report.detail,
"reported_at": report.reported_at,
}
def stale_payload(task: TaskRow) -> dict[str, Any]:
"""租约过期触发的通知:任务卡住,需人工 reclaim(绝不自动重投,见规格 §5)"""
return {
"task_id": task.task_id,
"site": task.site,
"event": "stale",
"status": "stale",
"detail": "租约过期,任务已置 stale,需人工 reclaim",
}
class CallbackNotifier:
"""把终结类事件 POST 到上游 callback_url(发后不管,失败只记日志)
`notify` 只做调度,真正的 HTTP 发送在后台任务里完成,调用方(report / sweep)
不被回调的耗时阻塞。在途任务用 `self._pending` 持有强引用,避免事件循环只持
弱引用导致任务被提前回收;`aclose` 时会等这些在途通知发完(各自有超时兜底)。
`client` 仅供测试注入(如 MockTransport);生产路径为 None,按回调地址
逐次构造带代理策略的临时客户端(回调是低频事件,代价可忽略)。
"""
def __init__(
self,
*,
settings: Settings,
timeout_seconds: float,
client: httpx.AsyncClient | None = None,
):
self._settings = settings
self._timeout = timeout_seconds
self._client = client
self._pending: set[asyncio.Task[None]] = set()
def notify(self, callback_url: str, payload: dict[str, Any]) -> None:
"""调度一次后台发送。调用方不等待,回调慢/挂不阻塞任务主流程"""
task = asyncio.create_task(self._send(callback_url, payload))
self._pending.add(task)
task.add_done_callback(self._pending.discard)
async def _send(self, callback_url: str, payload: dict[str, Any]) -> None:
task_id = payload.get("task_id")
try:
if self._client is not None:
response = await self._client.post(callback_url, json=payload)
else:
async with httpx.AsyncClient(
timeout=self._timeout,
**httpx_client_options(self._settings, target_url=callback_url),
) as client:
response = await client.post(callback_url, json=payload)
except Exception as exc: # noqa: BLE001
# 回调失败不允许影响任务主流程:记日志后丢弃,上游可靠轮询对账兜底
logger.warning(
"回调通知发送失败(已丢弃):task_id=%s url=%s err=%s", task_id, callback_url, exc
)
return
if response.status_code >= 400:
logger.warning(
"回调通知返回非成功状态(已丢弃):task_id=%s url=%s status=%s",
task_id, callback_url, response.status_code,
)
return
logger.info("回调通知已送达:task_id=%s url=%s", task_id, callback_url)
async def aclose(self) -> None:
"""关停:等在途通知发完(各次发送有超时兜底),再关 HTTP 客户端"""
if self._pending:
await asyncio.gather(*self._pending, return_exceptions=True)
if self._client is not None:
await self._client.aclose()
+2
View File
@@ -38,5 +38,7 @@ class GatewayContainer:
task_queue: TaskQueue
query_queue: QueryQueue
collector: object | None = field(default=None) # app.gateway.collector.OrderDiscoveryCollector
# 终结类事件回调(§4.8):有 callback_url 的任务到达终态/变 stale 时 POST 通知上游
notifier: object | None = field(default=None) # app.gateway.callback.CallbackNotifier
sweep_task: asyncio.Task | None = None
collector_task: asyncio.Task | None = None
+15 -2
View File
@@ -25,6 +25,7 @@ CREATE TABLE IF NOT EXISTS tasks (
task_id TEXT PRIMARY KEY,
site TEXT NOT NULL,
intent_json TEXT NOT NULL,
callback_url TEXT, -- 终结类事件通知地址,见 docs/order-gateway.md §4.8
status TEXT NOT NULL,
lease_owner TEXT,
lease_expires_at TEXT,
@@ -98,6 +99,7 @@ class TaskRow:
task_id: str
site: str
intent_json: str
callback_url: str | None
status: str
lease_owner: str | None
lease_expires_at: str | None
@@ -180,6 +182,7 @@ def _row_to_task(row: aiosqlite.Row) -> TaskRow:
task_id=row["task_id"],
site=row["site"],
intent_json=row["intent_json"],
callback_url=row["callback_url"],
status=row["status"],
lease_owner=row["lease_owner"],
lease_expires_at=row["lease_expires_at"],
@@ -256,9 +259,18 @@ class GatewayDB:
self._conn = await aiosqlite.connect(str(self._db_path))
self._conn.row_factory = aiosqlite.Row
await self._conn.executescript(SCHEMA)
await self._migrate()
await self._conn.commit()
logger.info("网关 DB 已就绪:%s", self._db_path)
async def _migrate(self) -> None:
"""给既有库补新列(CREATE TABLE IF NOT EXISTS 不会改老表)"""
async with self.conn.execute("PRAGMA table_info(tasks)") as cur:
columns = {row["name"] for row in await cur.fetchall()}
if "callback_url" not in columns:
await self.conn.execute("ALTER TABLE tasks ADD COLUMN callback_url TEXT")
logger.info("迁移:tasks 表补充 callback_url 列")
async def close(self) -> None:
if self._conn is not None:
await self._conn.close()
@@ -281,13 +293,14 @@ class GatewayDB:
"""插入新任务。返回 True=新建,False=task_id 已存在(幂等命中)"""
try:
await self.conn.execute(
"INSERT INTO tasks (task_id, site, intent_json, status, "
"INSERT INTO tasks (task_id, site, intent_json, callback_url, status, "
"lease_owner, lease_expires_at, lease_count, created_at, updated_at) "
"VALUES (?, ?, ?, ?, NULL, NULL, 0, ?, ?)",
"VALUES (?, ?, ?, ?, ?, NULL, NULL, 0, ?, ?)",
(
row.task_id,
row.site,
row.intent_json,
row.callback_url,
row.status,
row.created_at,
row.updated_at,
+11 -2
View File
@@ -18,6 +18,7 @@ from app.gateway.api.routes.account import router as account_router
from app.gateway.api.routes.health import router as health_router
from app.gateway.api.routes.orders import router as orders_router
from app.gateway.api.routes.queries import router as queries_router
from app.gateway.callback import CallbackNotifier
from app.gateway.collector import OrderDiscoveryCollector
from app.gateway.container import GatewayContainer
from app.gateway.db import GatewayDB
@@ -33,13 +34,18 @@ SWEEP_INTERVAL_SECONDS = 60
def build_container() -> GatewayContainer:
"""构建网关容器:DB + 下单任务队列 + 账号只读查询队列"""
"""构建网关容器:DB + 下单任务队列 + 账号只读查询队列 + 回调通知器"""
settings = get_settings()
db = GatewayDB(settings.gateway_db_path_resolved)
# 终结类事件回调(§4.8):best-effort 单次投递,失败只记日志
notifier = CallbackNotifier(
settings=settings, timeout_seconds=settings.callback_timeout_seconds
)
task_queue = TaskQueue(
db,
lease_ttl_seconds=settings.lease_ttl_seconds,
worker_offline_alert_seconds=settings.worker_offline_alert_seconds,
notifier=notifier,
)
query_queue = QueryQueue(
db,
@@ -53,7 +59,7 @@ def build_container() -> GatewayContainer:
)
return GatewayContainer(
settings=settings, db=db, task_queue=task_queue,
query_queue=query_queue, collector=collector,
query_queue=query_queue, collector=collector, notifier=notifier,
)
@@ -131,6 +137,9 @@ async def lifespan(app: FastAPI):
except asyncio.CancelledError:
pass
container.sweep_task = None
if container.notifier is not None:
# 等在途回调发完(各次发送有超时兜底),避免关停时静默丢通知
await container.notifier.aclose()
await container.db.close()
+23 -1
View File
@@ -7,8 +7,9 @@ intent 字段刻意保留成 `dict[str, Any]`——网关不解释下单意图
from __future__ import annotations
from typing import Any
from urllib.parse import urlsplit
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator
from app.shared.task_state import AccountQueryKind, OrderState, QueryStatus, TaskStatus
@@ -49,6 +50,24 @@ class SubmitOrderRequest(BaseModel):
"max_total_yen": 30000,
}],
)
callback_url: str | None = Field(
default=None,
description="终结类事件的异步通知地址(http/https)。任务到达终态(succeeded / "
"failed / needs_human)或被置 stale 时,网关向该地址 POST 一条 JSON 通知,"
"上游可免去轮询。注意:幂等重发(created=false)不会更新既有任务的回调地址",
examples=["https://upstream.example.com/hooks/rakuten-order"],
)
@field_validator("callback_url")
@classmethod
def _validate_callback_url(cls, value: str | None) -> str | None:
"""只接受 http/https 且带 host 的绝对 URL,其余当场 422"""
if value is None:
return None
parts = urlsplit(value)
if parts.scheme not in ("http", "https") or not parts.netloc:
raise ValueError("callback_url 必须是 http/https 绝对 URL")
return value
class SubmitOrderData(BaseModel):
@@ -194,6 +213,9 @@ class TaskDetail(BaseModel):
task_id: str = Field(description="任务 ID(幂等键)")
site: str = Field(description="站点标识(rakuten)")
intent: dict[str, Any] = Field(description="下单意图原文")
callback_url: str | None = Field(
default=None, description="提交时登记的终结类事件通知地址;未登记为 null"
)
status: TaskStatus = Field(
description="任务状态:queued / leased / running / succeeded / failed / needs_human / stale"
)
+64 -20
View File
@@ -25,6 +25,7 @@ from app.shared.task_state import (
TERMINAL_STATUSES,
TaskStatus,
)
from app.gateway.callback import CallbackNotifier, stale_payload, terminal_payload
from app.gateway.db import GatewayDB, ReportRow, TaskRow
from app.gateway.models import (
GatewayHealthData,
@@ -74,6 +75,7 @@ def _task_to_detail(task: TaskRow, latest_state: str | None, reports: list[Repor
task_id=task.task_id,
site=task.site,
intent=task.intent,
callback_url=task.callback_url,
status=TaskStatus(task.status),
lease_owner=task.lease_owner,
lease_expires_at=task.lease_expires_at,
@@ -92,17 +94,37 @@ class TaskQueue:
保护下;长轮询等待通过 `self._cond` 唤醒。
"""
def __init__(self, db: GatewayDB, *, lease_ttl_seconds: int, worker_offline_alert_seconds: int):
def __init__(
self,
db: GatewayDB,
*,
lease_ttl_seconds: int,
worker_offline_alert_seconds: int,
notifier: CallbackNotifier | None = None,
):
self._db = db
self._lease_ttl = lease_ttl_seconds
self._worker_offline_alert_seconds = worker_offline_alert_seconds
# 终结类事件通知(§4.8)。None 表示完全关闭回调;测试可替换为记录型假实现
self.notifier = notifier
self._lock = asyncio.Lock()
self._cond = asyncio.Condition(self._lock)
# ---- 提交 ----
async def submit(self, *, task_id: str | None, site: str, intent: dict) -> SubmitOrderData:
"""上游提交下单意图。task_id 缺省时服务端生成;重复提交幂等"""
async def submit(
self,
*,
task_id: str | None,
site: str,
intent: dict,
callback_url: str | None = None,
) -> SubmitOrderData:
"""上游提交下单意图。task_id 缺省时服务端生成;重复提交幂等
callback_url 只在首次创建时写入;幂等重发(created=false)返回既有任务,
不更新任何字段。
"""
import json
tid = task_id or _generate_task_id()
@@ -111,6 +133,7 @@ class TaskQueue:
task_id=tid,
site=site,
intent_json=json.dumps(intent, ensure_ascii=False),
callback_url=callback_url,
status=TaskStatus.QUEUED.value,
lease_owner=None,
lease_expires_at=None,
@@ -152,7 +175,7 @@ class TaskQueue:
async with self._lock:
# 先把过期租约扫到 stale,避免「卡死的任务」堵住新任务
await self._sweep_locked()
self._notify_stale(await self._sweep_locked())
await self._db.upsert_worker(worker_id, to_iso(utcnow()))
@@ -272,18 +295,17 @@ class TaskQueue:
)
now = to_iso(utcnow())
inserted = await self._db.upsert_report(
ReportRow(
task_id=task_id,
state=state,
payable_yen=payable_yen,
pay_deadline=pay_deadline,
site_order_id=site_order_id,
evidence_ref=evidence_ref,
detail=detail,
reported_at=now,
)
report_row = ReportRow(
task_id=task_id,
state=state,
payable_yen=payable_yen,
pay_deadline=pay_deadline,
site_order_id=site_order_id,
evidence_ref=evidence_ref,
detail=detail,
reported_at=now,
)
inserted = await self._db.upsert_report(report_row)
final_status = status.value
if terminal:
@@ -298,6 +320,10 @@ class TaskQueue:
)
# 任务终结可能让并发度 1 的闸门放开,唤醒等着的 lease
self._cond.notify_all()
# 终结类事件回调(§4.8):仅「本次真正把任务推入终态」才通知——
# 已终结任务的后续 report(如付款后监控的 shipped/delivered)不重复通知
if status not in TERMINAL_STATUSES:
self._notify_terminal(task, report_row, final_status)
return ReportData(task_id=task_id, status=TaskStatus(final_status), recorded=inserted)
@@ -355,13 +381,15 @@ class TaskQueue:
async def sweep(self) -> int:
"""把过期的 leased/running 任务置 stale。返回清扫条数"""
async with self._lock:
return await self._sweep_locked()
swept = await self._sweep_locked()
self._notify_stale(swept)
return len(swept)
async def _sweep_locked(self) -> int:
"""锁内执行 sweep。调用方必须持有 self._lock"""
async def _sweep_locked(self) -> list[TaskRow]:
"""锁内执行 sweep。调用方必须持有 self._lock。返回本次被置 stale 的任务"""
active = await self._db.list_tasks_in_statuses(tuple(s.value for s in ACTIVE_STATUSES))
now = utcnow()
swept = 0
swept: list[TaskRow] = []
for task in active:
if task.lease_expires_at and parse_iso(task.lease_expires_at) < now:
await self._db.update_task(
@@ -370,7 +398,7 @@ class TaskQueue:
clear_lease=True,
updated_at=to_iso(now),
)
swept += 1
swept.append(task)
logger.warning(
"租约过期,任务置 stale(不自动重投):task_id=%s site=%s",
task.task_id,
@@ -382,6 +410,22 @@ class TaskQueue:
self._cond.notify_all()
return swept
# ---- 终结类事件回调(§4.8)----
# notify 只是调度后台发送任务,持锁调用是安全的:真正的 HTTP 发送不碰这把锁
def _notify_terminal(self, task: TaskRow, report_row: ReportRow, final_status: str) -> None:
if self.notifier is not None and task.callback_url:
self.notifier.notify(
task.callback_url, terminal_payload(task, report_row, final_status)
)
def _notify_stale(self, swept: list[TaskRow]) -> None:
if self.notifier is None:
return
for task in swept:
if task.callback_url:
self.notifier.notify(task.callback_url, stale_payload(task))
# ---- 查询 ----
async def get_task_detail(self, task_id: str) -> TaskDetail:
+3
View File
@@ -150,6 +150,9 @@ class Settings(BaseSettings):
# worker 心跳超时阈值(秒)。网关 /health 据此判断 worker 是否失联:
# 正常 worker 每 30 秒来一次 lease,超过该阈值未来 lease 即视为异常。
worker_offline_alert_seconds: int = 300
# 终结类事件回调(callback_url)的单次 HTTP 超时(秒)。投递是 best-effort:
# 超时/失败只记日志不重试,权威状态以 GET /api/orders/{task_id} 为准。
callback_timeout_seconds: float = 10.0
# ---- 账号只读查询通道(网关 + worker 两侧共用,见 docs/order-gateway.md §11)----
# 查询单租约 TTL(秒)。worker 领走后必须在此时间内回结果,否则网关把它