Files
rakuten-api/app/trading/worker/runner.py
T
q792602257 93c1a83406 实现租约恢复核对:verify_on_site 接真实订单列表反查
新增 SiteInteractor.list_recent_orders(分页拉 order.my.rakuten.co.jp 订单列表,
按商品 URL 反查)+ GatewayClient.get_task,替换掉恒返回 UNKNOWN 的桩。
NOT_ORDERED 分支目前只有逻辑验证、没有真实多单数据支撑,刻意仍路由到
needs_human,不自动重新下单。全程只读查询,未触发任何真实付款操作。
2026-08-13 23:58:54 +08:00

549 lines
22 KiB
Python

"""worker 主循环与每任务执行流
主循环(规格 §6):
while running:
task = await gateway.lease(worker_id, wait=30)
if task is None:
continue
if local_db.has_finished(task.task_id): # 本地幂等闸门
await gateway.report(...); continue
if task.lease_count > 1: # 恢复领取
verdict = await verify_on_site(task)
if verdict.already_ordered: await report(...); continue
if verdict.unknown: await report(needs_human); continue
async with renew_lease_every(60):
await execute(task)
每个动作的顺序固定为:动作 → 落证据 → 写本地 SQLite → 回报 gateway。
顺序不能颠倒——先落证据再回报,保证服务器上看到的状态一定有本地证据可查。
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
from typing import TYPE_CHECKING
from app.shared.errors import AppError, CheckoutBlockedError, OrderGuardError
from app.shared.task_state import OrderState, TaskStatus
from app.trading.worker import verify
from app.trading.worker.client import GatewayClient
from app.trading.worker.evidence import EvidenceStore
from app.trading.worker.local_db import LocalDB
from app.trading.worker.models import LeaseTask
from app.trading.worker.site_interact import SiteInteractor
if TYPE_CHECKING:
from app.shared.config import Settings
logger = logging.getLogger(__name__)
def _coerce_state(value: str | None) -> OrderState:
"""把网关返回的 state 字符串安全地包成 OrderState;None 或非法值回落到 CREATED"""
if not value:
return OrderState.CREATED
try:
return OrderState(value)
except ValueError:
logger.warning("未知的 order state 字符串,回落到 CREATED:%s", value)
return OrderState.CREATED
class WorkerRunner:
"""worker 主循环与执行流
持有 gateway 客户端、本地 DB、证据存储。`run()` 是常驻 asyncio 任务,
`handle()` 处理单个任务,`execute()` 是站点交互的实际调度(当前多数步骤未实现)。
"""
def __init__(
self,
*,
settings: "Settings",
gateway_client: GatewayClient,
local_db: LocalDB,
evidence: EvidenceStore,
site: SiteInteractor,
):
self._settings = settings
self._gateway = gateway_client
self._db = local_db
self._evidence = evidence
self._site = site
self._running = False
# 付款后监控后台任务:task_id → asyncio.Task。与主循环解耦(不阻塞领下一单),
# 详见 _spawn_monitor / _monitor_order。用 set 而非 list 是因为只需要成员管理,
# 不需要顺序。
self._monitor_tasks: set[asyncio.Task] = set()
def stop(self) -> None:
self._running = False
async def cancel_monitors(self) -> None:
"""服务关闭时调用:取消所有还在跑的付款后监控后台任务并等它们收尾
必须在 SiteInteractor.close() 之前调用——监控任务还在用同一个
Playwright context,先关 context 再取消会导致监控任务在 await 到一半时
踩上已关闭的资源,报一堆无意义的异常。
"""
tasks = list(self._monitor_tasks)
for t in tasks:
t.cancel()
for t in tasks:
with contextlib.suppress(asyncio.CancelledError):
await t
@property
def worker_id(self) -> str:
return self._settings.worker_id_effective
# ---- 主循环 ----
async def run(self) -> None:
"""常驻循环:长轮询领任务,按 §6 调度"""
self._running = True
logger.info("worker 启动:worker_id=%s", self.worker_id)
while self._running:
try:
task = await self._gateway.lease(self.worker_id, wait=30)
except AppError as exc:
logger.warning("lease 失败:%s (err=%s)", exc.message, exc.err_code)
await asyncio.sleep(5)
continue
except Exception: # noqa: BLE001
logger.exception("lease 异常")
await asyncio.sleep(5)
continue
if task is None:
continue
try:
await self.handle(task)
except Exception: # noqa: BLE001
# 任何未预期的异常都不应让 worker 退出
logger.exception("handle 任务异常:task_id=%s", task.task_id)
# ---- 单任务调度 ----
async def handle(self, task: LeaseTask) -> None:
"""单任务调度入口:本地幂等闸门 → 恢复核对 → 执行"""
# 本地幂等闸门:之前已完成过的任务不再执行
if await self._db.has_finished(task.task_id):
final_state = await self._db.final_state(task.task_id)
logger.info(
"本地已完成,补报终态:task_id=%s state=%s", task.task_id, final_state
)
await self._report_safe(
task,
state=_coerce_state(final_state),
detail="本地已完成,补报避免重复执行",
terminal=True,
)
return
# 恢复领取:lease_count > 1 表示 stale → reclaim,必须先核对站点订单
if task.lease_count > 1:
await self._handle_recovery(task)
return
# 常规执行
await self._execute_with_renewal(task)
async def _handle_recovery(self, task: LeaseTask) -> None:
"""lease_count > 1 的恢复路径:先核对,不直接执行"""
logger.warning(
"恢复领取(lease_count=%s),先核对站点订单:task_id=%s",
task.lease_count,
task.task_id,
)
verdict = await verify.verify_on_site(task, gateway=self._gateway, site=self._site)
if verdict.verdict == verify.VerifyVerdict.ALREADY_ORDERED:
await self._report_safe(
task,
state=OrderState.ORDERED,
site_order_id=verdict.site_order_id,
detail=f"核对结论:已下单({verdict.detail})",
terminal=True,
terminal_status=TaskStatus.SUCCEEDED,
)
await self._db.ensure_started(task.task_id, task.site, task.intent)
await self._db.mark_finished(task.task_id, OrderState.ORDERED.value)
return
# NOT_ORDERED 也走 needs_human(2026-08-13 verify_on_site 已实现,会真的
# 返回这个值,但刻意仍不自动重新执行):核对逻辑目前只有「账号 1 笔订单」
# 的真实数据支撑 ALREADY_ORDERED 分支,NOT_ORDERED 分支只验证过逻辑本身,
# 没有被真实多单场景跑过——在这条判断被更多真实数据验证之前,即使确认
# 未下单也交人工决定是否重新提交,不自动触发新的下单动作。这是刻意的
# 保守选择,不是遗漏;要不要放开需要显式决定,不在这里静默改。
await self._report_safe(
task,
state=_coerce_state(task.known_state),
detail=f"恢复核对结论({verdict.verdict.value}):{verdict.detail}",
terminal=True,
terminal_status=TaskStatus.NEEDS_HUMAN,
)
# ---- 常规执行 ----
async def _execute_with_renewal(self, task: LeaseTask) -> None:
"""在租约自动续期的上下文里执行任务"""
async with self._renew_lease_every(task, interval=60):
try:
await self.execute(task)
except NotImplementedError as exc:
# 站点交互未实现(规格 §10):上报 needs_human,不视为 worker 失败
logger.warning(
"站点交互未实现,转 needs_human:task_id=%s what=%s",
task.task_id,
exc,
)
await self._report_safe(
task,
state=_coerce_state(task.known_state),
detail=f"站点交互未实现:{exc}",
terminal=True,
terminal_status=TaskStatus.NEEDS_HUMAN,
)
except OrderGuardError as exc:
# 金额守卫等闸门拦截:上报 needs_human,站点侧无提交动作
logger.warning(
"下单闸门拦截,转 needs_human:task_id=%s msg=%s",
task.task_id,
exc.message,
)
await self._report_safe(
task,
state=_coerce_state(task.known_state),
detail=exc.message,
terminal=True,
terminal_status=TaskStatus.NEEDS_HUMAN,
)
except CheckoutBlockedError as exc:
# 站点风控拦截(session upgrade / 3DS 等):转 needs_human 交人工接管,
# 不当普通失败重试(规格 §10.1)
logger.warning(
"结算被站点风控拦截,转 needs_human:task_id=%s msg=%s",
task.task_id,
exc.message,
)
await self._report_safe(
task,
state=_coerce_state(task.known_state),
detail=exc.message,
terminal=True,
terminal_status=TaskStatus.NEEDS_HUMAN,
)
except AppError as exc:
logger.warning(
"执行失败:task_id=%s code=%s msg=%s",
task.task_id,
exc.err_code,
exc.message,
)
await self._report_safe(
task,
state=_coerce_state(task.known_state),
detail=exc.message,
terminal=True,
terminal_status=TaskStatus.FAILED,
)
async def execute(self, task: LeaseTask) -> None:
"""站点交互的实际调度:加购 → 校验 → 确认页 → 金额守卫 → 提交 → 付款
每一步的顺序:动作 → 落证据 → 写本地 SQLite → 回报 gateway。
站点交互当前未实现(site_interact 抛 NotImplementedError),第一步就会
转到 _execute_with_renewal 的 except 分支上报 needs_human。
"""
await self._db.ensure_started(task.task_id, task.site, task.intent)
# 交易服务只覆盖乐天市场。其他站点直接转人工
if task.site != "rakuten":
raise NotImplementedError(f"site={task.site} 暂不在交易服务范围内(仅 rakuten)")
# 步骤 1:加购
await self._run_step(
task, step_no=1, step_name="cart-add",
action=lambda: self._site.add_to_cart(task),
state=OrderState.IN_CART,
detail="已加入购物车",
)
# 步骤 2:校验购物车
await self._run_step(
task, step_no=2, step_name="cart-check",
action=lambda: self._site.verify_cart(task),
state=OrderState.IN_CART,
detail="购物车已校验",
)
# 步骤 3:进入下单确认页 + 金额守卫
checkout_html = await self._site.enter_checkout(task)
summary = await self._site.parse_checkout(checkout_html)
self._enforce_amount_guard(task, summary.payable_yen)
await self._run_step(
task, step_no=3, step_name="order-confirm",
action=self._noop(),
state=OrderState.CREATED,
detail=f"下单确认页已解析:应付 {summary.payable_yen} 円",
html=checkout_html,
evidence_meta={
"payable_yen": summary.payable_yen,
"site_order_id": summary.site_order_id,
"pay_deadline": summary.pay_deadline,
},
)
# 步骤 4:提交下单
site_order_id = await self._site.submit_order(task)
await self._run_step(
task, step_no=4, step_name="order-submit",
action=self._noop(),
state=OrderState.ORDERED,
site_order_id=site_order_id,
detail=f"已提交下单,站点订单号 {site_order_id}",
)
# 步骤 5:付款
await self._run_step(
task, step_no=5, step_name="payment",
action=lambda: self._site.pay(task, site_order_id),
state=OrderState.AWAITING_PAYMENT,
site_order_id=site_order_id,
payable_yen=summary.payable_yen,
pay_deadline=summary.pay_deadline,
detail="已进入付款流程",
)
await self._report_safe(
task,
state=OrderState.PAID,
site_order_id=site_order_id,
payable_yen=summary.payable_yen,
pay_deadline=summary.pay_deadline,
detail="付款完成",
terminal=True,
terminal_status=TaskStatus.SUCCEEDED,
)
await self._db.mark_finished(task.task_id, OrderState.PAID.value)
# 步骤 6:付款后监控——后台常驻轮询,不阻塞主循环领下一单(规格 §6)
self._spawn_monitor(task, site_order_id)
def _spawn_monitor(self, task: LeaseTask, site_order_id: str) -> None:
"""把 _monitor_order 起成独立的后台任务,登记到 _monitor_tasks 便于关服时收尾"""
monitor_task = asyncio.create_task(
self._monitor_order(task, site_order_id), name=f"monitor-{task.task_id}"
)
self._monitor_tasks.add(monitor_task)
monitor_task.add_done_callback(self._monitor_tasks.discard)
async def _monitor_order(self, task: LeaseTask, site_order_id: str) -> None:
"""付款后台轮询:订单配送阶段变化时继续 report(规格 §6「付款后监控」)
任务本身已经在 execute() 里上报过 succeeded 终态——这里的 report 都是
`terminal=False` 的追加上报(规格 §4.5:任务已 terminal 的仍可上报,
gateway 追加到 task_reports),监控本身的成败不影响已经完成的下单结果。
轮询间隔/次数上限见 settings.order_monitor_poll_interval_seconds /
order_monitor_max_checks;达到上限仍未看到「配達完了」不算失败,只是
停止追踪(记一条 warning)。单次探测异常(登录态失效、页面打不开)只记
日志、下一轮重试,不能把异常抛出去——这是后台任务,没有人等着 catch 它,
未捕获异常会被 asyncio 直接吞掉且只在垃圾回收时打一条难查的警告。
"""
last_state: OrderState | None = None
step_no = 6
interval = self._settings.order_monitor_poll_interval_seconds
max_checks = self._settings.order_monitor_max_checks
for attempt in range(1, max_checks + 1):
await asyncio.sleep(interval)
try:
snapshot = await self._site.check_order_status(site_order_id)
except Exception:
logger.warning(
"订单监控探测失败(第 %s/%s 次),下一轮继续重试:"
"task_id=%s site_order_id=%s",
attempt, max_checks, task.task_id, site_order_id,
exc_info=True,
)
continue
if not snapshot.found:
logger.info(
"订单监控:第 %s/%s 次仍未在详情页找到订单号,继续轮询:task_id=%s",
attempt, max_checks, task.task_id,
)
continue
if snapshot.order_state is None or snapshot.order_state == last_state:
continue
last_state = snapshot.order_state
step_name = f"monitor-{snapshot.order_state.value}"
detail = f"订单监控:进度「{snapshot.stage_label}」→ {snapshot.order_state.value}"
evidence_ref = self._evidence.write_step(
task.task_id, step_no, step_name,
html=snapshot.html,
meta={
"step": step_name,
"state": snapshot.order_state.value,
"stage_label": snapshot.stage_label,
},
)
await self._db.index_evidence(task.task_id, step_no, step_name, evidence_ref)
await self._db.record_event(
task.task_id, snapshot.order_state.value,
detail=detail, evidence_ref=evidence_ref,
)
await self._report_safe(
task,
state=snapshot.order_state,
detail=detail,
site_order_id=site_order_id,
)
step_no += 1
if snapshot.order_state == OrderState.DELIVERED:
logger.info("订单监控:已送达,停止轮询:task_id=%s", task.task_id)
return
logger.warning(
"订单监控达到最大轮询次数 %s 仍未看到「配達完了」,停止追踪:task_id=%s",
max_checks, task.task_id,
)
def _enforce_amount_guard(self, task: LeaseTask, payable_yen: int) -> None:
"""金额守卫:实际应付超过 intent.max_total_yen 或 RAKUTEN_ORDER_MAX_TOTAL_YEN 时拦截"""
intent = task.intent or {}
limit = intent.get("max_total_yen")
if limit is None or limit <= 0:
limit = self._settings.order_max_total_yen
if limit > 0 and payable_yen > limit:
raise OrderGuardError(
f"实际应付 {payable_yen} 円超过上限 {limit} 円"
f"(task_id={task.task_id})"
)
@staticmethod
def _noop() -> "callable":
async def _f() -> None:
return None
return _f
async def _run_step(
self,
task: LeaseTask,
*,
step_no: int,
step_name: str,
action: "callable",
state: OrderState,
detail: str,
site_order_id: str | None = None,
payable_yen: int | None = None,
pay_deadline: str | None = None,
evidence_meta: dict | None = None,
html: str | None = None,
) -> None:
"""单步执行:动作 → 落证据 → 写本地 → 回报 gateway
`action` 的返回值若是字符串,视为该步骤要落盘的页面 HTML(add_to_cart /
verify_cart 均已改为返回渲染后的页面);显式传入的 `html` 优先级更高,
供 step3 等已经单独拿到页面 HTML 的调用点直接使用。
"""
result = await action()
if html is None and isinstance(result, str):
html = result
meta = {
"step": step_name,
"state": state.value,
**(evidence_meta or {}),
}
evidence_ref = self._evidence.write_step(
task.task_id, step_no, step_name, html=html, meta=meta
)
await self._db.index_evidence(task.task_id, step_no, step_name, evidence_ref)
await self._db.record_event(
task.task_id, state.value, detail=detail, evidence_ref=evidence_ref
)
await self._gateway.report(
task.task_id,
self.worker_id,
state=state,
payable_yen=payable_yen,
pay_deadline=pay_deadline,
site_order_id=site_order_id,
evidence_ref=evidence_ref,
detail=detail,
)
async def _report_safe(
self,
task: LeaseTask,
*,
state: OrderState,
detail: str,
terminal: bool = False,
terminal_status: TaskStatus | None = None,
site_order_id: str | None = None,
payable_yen: int | None = None,
pay_deadline: str | None = None,
) -> None:
"""回报 gateway,失败只记日志不抛——主循环不能因为回报失败退出"""
try:
await self._gateway.report(
task.task_id,
self.worker_id,
state=state,
detail=detail,
terminal=terminal,
terminal_status=terminal_status,
site_order_id=site_order_id,
payable_yen=payable_yen,
pay_deadline=pay_deadline,
)
except Exception: # noqa: BLE001
logger.exception("回报 gateway 失败:task_id=%s state=%s", task.task_id, state)
@contextlib.asynccontextmanager
async def _renew_lease_every(self, task: LeaseTask, *, interval: int):
"""后台任务:每隔 interval 秒续租一次。退出时停掉"""
stop_event = asyncio.Event()
async def _loop() -> None:
while not stop_event.is_set():
try:
await asyncio.wait_for(stop_event.wait(), timeout=interval)
except asyncio.TimeoutError:
pass
if stop_event.is_set():
return
try:
await self._gateway.renew(task.task_id, self.worker_id)
except AppError as exc:
logger.warning(
"续租失败:task_id=%s code=%s msg=%s",
task.task_id,
exc.err_code,
exc.message,
)
return
except Exception: # noqa: BLE001
logger.exception("续租异常:task_id=%s", task.task_id)
return
renewal = asyncio.create_task(_loop(), name=f"renew-{task.task_id}")
try:
yield
finally:
stop_event.set()
with contextlib.suppress(asyncio.CancelledError):
await renewal