""" 主账 HTTP 客户端:只打主账 API,不直连 TMS / MySQL。 正式写操作必须带幂等键;Graph 成功 ≠ 主账成功。 本文件提供可联调的 ping / JSON 请求骨架;不做六态业务分支。 """ from __future__ import annotations import logging from dataclasses import dataclass, field from typing import Any, Optional import httpx from agent.config import Settings, get_settings logger = logging.getLogger(__name__) @dataclass class LedgerResponse: """主账一次 HTTP 调用的结果摘要(不含密钥)。""" ok: bool status_code: int data: Any = None error: str = "" @dataclass class LedgerClient: """ 主账客户端。 base_url:BIZ_API_BASE_URL(新系统多为 http://127.0.0.1:8180/jeecgboot)。 超时:默认 10s,禁止无限阻塞 Worker 槽。 禁止:在本类拼 TMS HMAC 或写工单 SQL。 """ base_url: str timeout_seconds: float = 10.0 service_token: str = "" _client: Optional[httpx.Client] = field(default=None, repr=False) @classmethod def from_settings(cls, settings: Optional[Settings] = None) -> "LedgerClient": """从环境组装客户端。""" cfg = settings or get_settings() token = ( getattr(cfg, "biz_api_token", None) or getattr(cfg, "agent_callback_token", None) or getattr(cfg, "inquiry_agent_callback_token", None) or "" ) return cls( base_url=(cfg.biz_api_base_url or "").rstrip("/"), timeout_seconds=float(getattr(cfg, "biz_api_timeout_seconds", 10) or 10), service_token=str(token), ) def _http(self) -> httpx.Client: if self._client is None: headers = {"Accept": "application/json"} if self.service_token: # 回调令牌不是 Jeecg 登录 JWT。写入 X-Access-Token 会被 JwtFilter 验签失败并 401。 headers["X-Agent-Token"] = self.service_token self._client = httpx.Client( base_url=self.base_url, timeout=self.timeout_seconds, headers=headers, ) return self._client def close(self) -> None: """关闭连接池。""" if self._client is not None: self._client.close() self._client = None def health_url(self) -> str: """健康检查相对路径候选的完整 URL(日志用)。""" return f"{self.base_url}/" def ping(self) -> LedgerResponse: """ 探测主账是否可达。 尝试 GET / 或常见健康路径;4xx/5xx 记入 error,不抛给回调线程。 副作用:出站 HTTP。 """ if not self.base_url: return LedgerResponse(ok=False, status_code=0, error="BIZ_API_BASE_URL 未配置") paths = ["/", "/sys/randomImage/ping-check"] last: LedgerResponse = LedgerResponse(ok=False, status_code=0, error="no_attempt") for path in paths: try: resp = self._http().get(path) last = LedgerResponse( ok=resp.status_code < 500, status_code=resp.status_code, data={"path": path}, ) if resp.status_code < 500: logger.info( "ledger.ping ok status=%s path=%s", resp.status_code, path, ) return last except httpx.HTTPError as exc: last = LedgerResponse(ok=False, status_code=0, error=str(exc)) logger.warning("ledger.ping 失败 path=%s err=%s", path, exc) return last def request_json( self, method: str, path: str, *, json_body: Optional[dict[str, Any]] = None, params: Optional[dict[str, Any]] = None, idempotency_key: str = "", ) -> LedgerResponse: """ 通用 JSON 请求骨架。 写操作应传入 idempotency_key(写入头 X-Idempotency-Key)。 不做业务语义解析;调用方根据主账合同处理 result。 """ headers: dict[str, str] = {} if idempotency_key: from agent.schema.fingerprint import ascii_idempotency_key headers["X-Idempotency-Key"] = ascii_idempotency_key(idempotency_key) try: resp = self._http().request( method.upper(), path, json=json_body, params=params, headers=headers, ) data: Any try: data = resp.json() except ValueError: data = {"text": resp.text[:500]} ok = 200 <= resp.status_code < 300 if not ok: logger.warning( "ledger.request 非成功 status=%s path=%s", resp.status_code, path, ) return LedgerResponse(ok=ok, status_code=resp.status_code, data=data) except (httpx.HTTPError, UnicodeEncodeError, ValueError) as exc: logger.warning("ledger.request 异常 path=%s err=%s", path, exc) return LedgerResponse(ok=False, status_code=0, error=str(exc)) from agent.ledger.api_shell import LedgerApiShell # noqa: E402 __all__ = ["LedgerClient", "LedgerResponse", "LedgerApiShell"]