按 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>
146 lines
5.1 KiB
Python
146 lines
5.1 KiB
Python
"""网关 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"),
|
|
)
|