实现下单任务网关与本地 worker
按 docs/order-gateway.md 落地:第三个部署单元 app.gateway(:31109)承担任务队列 + 状态镜像;本地 worker 在 app.trading.worker 内,按 RAKUTEN_ORDER_GATEWAY_URL 决定是否启动。规格 §5 最关键约束已守:租约过期绝不自动重投,恢复只能 reclaim, worker 收到 lease_count>1 时先核对站点订单。 站点交互(加购/下单/付款/订单列表反查)按规格 §10 留接口缝,site_interact.py 全部 NotImplementedError,verify.py 恒返回 unknown——等真实账号实测后再填, 不写猜测的提交逻辑。 310 个测试全绿,覆盖规格 §9 验收清单 12 条;架构测试守住三方互不 import。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
"""网关 HTTP 客户端:出站长轮询领任务、回报状态、续租、恢复领取
|
||||
|
||||
worker 与网关之间是出站单向通信。所有方法都包装成「成功 → data,失败 → 抛
|
||||
AppError」的形式,runner 拿到 AppError 直接打日志或转报 needs_human。
|
||||
|
||||
请求与响应严格使用 shared.api 的 ApiResponse 信封;网关侧错误码(6xxx)原样上抛,
|
||||
不在这里翻译。鉴权头从 settings.bearer_token 取,与抓取/交易服务共用同一份。
|
||||
|
||||
**不 import app.gateway**:worker 只看 HTTP 响应 JSON,本地用 LeaseTask 表达领到的
|
||||
任务,与网关侧的 Pydantic 模型解耦。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.shared.errors import AppError
|
||||
from app.shared.task_state import OrderState, TaskStatus
|
||||
from app.trading.worker.models import LeaseTask
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GatewayClient:
|
||||
"""网关 HTTP 客户端
|
||||
|
||||
一份 AsyncClient 实例贯穿 worker 整个生命周期,连接池由 httpx 管理。
|
||||
"""
|
||||
|
||||
def __init__(self, base_url: str, bearer_token: str, *, timeout: float = 60.0):
|
||||
# 末尾去斜杠,避免 base + "/api/..." 拼出双斜杠
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._client = httpx.AsyncClient(
|
||||
base_url=self._base_url,
|
||||
headers={"Authorization": f"Bearer {bearer_token}"},
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
async def aclose(self) -> None:
|
||||
await self._client.aclose()
|
||||
|
||||
# ---- 基础封装 ----
|
||||
|
||||
async def _request(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]:
|
||||
"""发起请求并解信封。失败(success=False)抛 AppError"""
|
||||
response = await self._client.request(method, path, **kwargs)
|
||||
try:
|
||||
body = response.json()
|
||||
except ValueError as exc:
|
||||
raise AppError(
|
||||
message=f"网关响应不是合法 JSON:HTTP {response.status_code}",
|
||||
code="GATEWAY_BAD_BODY",
|
||||
err_code=3001,
|
||||
retryable=True,
|
||||
) from exc
|
||||
|
||||
if not body.get("success"):
|
||||
raise AppError(
|
||||
message=body.get("msg", "网关返回失败"),
|
||||
code="GATEWAY_ERROR",
|
||||
err_code=int(body.get("code", 1500)),
|
||||
retryable=False,
|
||||
status_code=response.status_code,
|
||||
)
|
||||
return body
|
||||
|
||||
# ---- 接口 ----
|
||||
|
||||
async def lease(
|
||||
self, worker_id: str, *, wait: int = 30, site: str | None = None
|
||||
) -> LeaseTask | None:
|
||||
"""长轮询领取。无可领任务时返回 None"""
|
||||
params: dict[str, Any] = {"worker_id": worker_id, "wait": wait}
|
||||
if site:
|
||||
params["site"] = site
|
||||
body = await self._request("GET", "/api/orders/lease", params=params)
|
||||
data = body.get("data")
|
||||
if not data:
|
||||
return None
|
||||
return LeaseTask(
|
||||
task_id=data["task_id"],
|
||||
site=data["site"],
|
||||
intent=data.get("intent") or {},
|
||||
lease_expires_at=data.get("lease_expires_at", ""),
|
||||
lease_count=data.get("lease_count", 1),
|
||||
known_state=data.get("known_state"),
|
||||
)
|
||||
|
||||
async def renew(self, task_id: str, worker_id: str) -> dict[str, Any]:
|
||||
"""续租,返回网关响应里的 data 字段"""
|
||||
body = await self._request(
|
||||
"POST",
|
||||
f"/api/orders/{task_id}/renew",
|
||||
json={"worker_id": worker_id},
|
||||
)
|
||||
return body["data"]
|
||||
|
||||
async def report(
|
||||
self,
|
||||
task_id: str,
|
||||
worker_id: str,
|
||||
*,
|
||||
state: OrderState,
|
||||
payable_yen: int | None = None,
|
||||
pay_deadline: str | None = None,
|
||||
site_order_id: str | None = None,
|
||||
evidence_ref: str | None = None,
|
||||
detail: str = "",
|
||||
terminal: bool = False,
|
||||
terminal_status: TaskStatus | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""回报状态,返回网关响应里的 data 字段"""
|
||||
payload: dict[str, Any] = {
|
||||
"worker_id": worker_id,
|
||||
"state": state.value,
|
||||
"payable_yen": payable_yen,
|
||||
"pay_deadline": pay_deadline,
|
||||
"site_order_id": site_order_id,
|
||||
"evidence_ref": evidence_ref,
|
||||
"detail": detail,
|
||||
"terminal": terminal,
|
||||
}
|
||||
if terminal_status is not None:
|
||||
payload["terminal_status"] = terminal_status.value
|
||||
body = await self._request("POST", f"/api/orders/{task_id}/report", json=payload)
|
||||
return body["data"]
|
||||
|
||||
async def reclaim(self, task_id: str, worker_id: str) -> LeaseTask:
|
||||
"""恢复领取。返回的 LeaseTask 必然 lease_count > 1"""
|
||||
body = await self._request(
|
||||
"POST",
|
||||
f"/api/orders/{task_id}/reclaim",
|
||||
json={"worker_id": worker_id},
|
||||
)
|
||||
data = body["data"]
|
||||
return LeaseTask(
|
||||
task_id=data["task_id"],
|
||||
site=data["site"],
|
||||
intent=data.get("intent") or {},
|
||||
lease_expires_at=data.get("lease_expires_at", ""),
|
||||
lease_count=data.get("lease_count", 1),
|
||||
known_state=data.get("known_state"),
|
||||
)
|
||||
Reference in New Issue
Block a user