"""网关 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.config import Settings from app.shared.errors import AppError from app.shared.proxy import httpx_client_options 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, *, settings: Settings, 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, **httpx_client_options(settings, target_url=self._base_url), ) 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 get_task(self, task_id: str) -> dict[str, Any]: """任务详情,返回网关响应里的 data 字段(含 created_at) 供 verify.verify_on_site 恢复核对用:LeaseTask(lease/reclaim 的响应) 不带 created_at,规格 §5「按创建时间 ~ stale 之间」的窗口核对必须单独 查一次这个接口才能拿到。 """ body = await self._request("GET", f"/api/orders/{task_id}") 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"), )