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:
+64
-20
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user