| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214 |
- """多业务共享连接池,各请求显式携带平台身份。"""
- from dataclasses import dataclass
- import hashlib
- import json
- import mimetypes
- from pathlib import Path
- from urllib.parse import unquote
- import httpx
- from extensions.shaanxi.settings import GatewaySettings
- from runtime.business_models import RemoteFile
- class GatewayError(RuntimeError):
- def __init__(self, operation, status=0, *, retryable=False, retry_after=None):
- self.status, self.retryable = status, retryable
- self.retry_after = retry_after
- super().__init__(f"{operation}失败(状态 {status})")
- class GatewayStopped(GatewayError):
- pass
- @dataclass(frozen=True)
- class GatewayEvent:
- topic: str
- message_id: str
- payload: dict
- def relative_path(path):
- decoded = path
- for _ in range(3):
- decoded = unquote(decoded)
- if not path.startswith("/") or decoded.startswith("//") or "://" in decoded or ".." in decoded or "\\" in decoded or "?" in decoded or "#" in decoded or "\x00" in decoded:
- raise ValueError("必须使用安全的平台站内相对路径,参数通过 params 传递")
- return path
- class GatewayClient:
- def __init__(self, settings: GatewaySettings, *, transport=None, file_transport=None):
- self.settings = settings
- self.http = httpx.AsyncClient(timeout=settings.timeout, trust_env=False,
- follow_redirects=False, transport=transport)
- self.files = httpx.AsyncClient(timeout=settings.timeout, trust_env=False,
- follow_redirects=False, transport=file_transport or transport)
- async def close(self):
- await self.http.aclose()
- await self.files.aclose()
- def headers(self, agent_code):
- if not agent_code:
- raise ValueError("请求必须指定平台 agentCode")
- return {"Authorization": f"Bearer {self.settings.bearer}", "X-Agent-Code": agent_code}
- @staticmethod
- def _body(resp, operation):
- try:
- body = resp.json()
- except ValueError:
- body = {}
- if resp.status_code in (403, 500) and isinstance(body, dict) and "已停止" in str(body.get("detail", "")):
- raise GatewayStopped(operation, resp.status_code)
- if resp.status_code >= 400:
- try:
- retry_after = max(0, float(resp.headers["Retry-After"]))
- if not retry_after < float("inf"):
- retry_after = None
- except (KeyError, ValueError):
- retry_after = None
- raise GatewayError(operation, resp.status_code,
- retryable=resp.status_code in {408, 429, 500, 502, 503, 504}, retry_after=retry_after)
- if not isinstance(body, dict) or str(body.get("code")) != "200" or body.get("success") is False:
- raise GatewayError(operation, body.get("code", 0) if isinstance(body, dict) else 0)
- return body
- async def call(self, method, path, agent_code, **kwargs):
- try:
- response = await self.http.request(method, self.settings.base_url + path,
- headers=self.headers(agent_code), **kwargs)
- except httpx.RequestError:
- raise GatewayError(path, retryable=True) from None
- return self._body(response, path)
- async def register(self, business):
- binding = business.gateway
- if not binding.check_type_code:
- raise ValueError(f"{business.name} 尚未配置平台 checkTypeCode")
- return await self.call("POST", "/internal/agents/register", binding.agent_code, json={
- "agentCode": binding.agent_code, "name": business.display_name,
- "sensorTypeList": binding.sensor_type_list, "topics": list(binding.topics),
- "checkTypeCode": binding.check_type_code, "enabled": True})
- async def query(self, agent_code, source, resource, params=None):
- return await self.call("POST", "/internal/data/query", agent_code,
- json={"source": source, "resource": resource, "params": params or {}})
- async def proxy(self, agent_code, path, *, method="GET", params=None, json_body=None):
- method = method.upper()
- if method not in {"GET", "POST", "PUT", "DELETE"}:
- raise ValueError("网关代理不支持该请求方法")
- body = await self.call("POST", "/internal/monitor/proxy", agent_code,
- json={"method": method, "path": relative_path(path),
- "params": params or {}, "json_body": json_body})
- data = body.get("data") or {}
- status = data.get("status_code", 0)
- if not isinstance(status, int) or not 200 <= status < 300:
- raise GatewayError("监管平台代理", status, retryable=status in {429, 500, 502, 503, 504})
- upstream = data.get("body")
- if isinstance(upstream, dict) and (upstream.get("success") is False or
- ("code" in upstream and str(upstream["code"]) not in {"0", "200"})):
- raise GatewayError("监管平台业务请求", upstream.get("code", 0))
- return upstream
- async def token(self, agent_code):
- data = (await self.call("GET", "/internal/monitor/token", agent_code)).get("data") or {}
- if not data.get("access_token") or not data.get("client_id"):
- raise GatewayError("监管平台凭证下发")
- return data
- async def push(self, agent_code, payload, *, channel="ai_platform"):
- if channel not in {"ai_platform", "monitor_platform"}:
- raise ValueError("未配置的推送通道")
- result = await self.call("POST", f"/internal/push/{channel}", agent_code, json=payload)
- if channel == "ai_platform":
- data = result.get("data")
- if not isinstance(data, dict) or not data.get("checkId") or not isinstance(data.get("clueIds"), list):
- raise GatewayError("平台未返回完整研判回执")
- return result
- async def upload(self, agent_code, path: Path):
- if path.stat().st_size > self.settings.max_file_bytes:
- raise GatewayError("证据文件超过大小上限")
- with path.open("rb") as stream:
- response = await self.call("POST", "/internal/monitor/upload", agent_code,
- params={"path": self.settings.upload_path},
- files={"file": (path.name, stream, mimetypes.guess_type(path.name)[0] or "application/octet-stream")})
- platform = response.get("data") or {}
- data = platform.get("data", platform)
- if not isinstance(data, dict) or data.get("status") != "successed" or not data.get("path"):
- raise GatewayError("证据文件上传")
- return data["path"]
- async def download(self, agent_code, source: RemoteFile, target: Path):
- if not self.settings.file_base_url:
- raise ValueError("下载文件需要对方提供 SHANXI_FILE_BASE_URL 和文件接口")
- url = self.settings.file_base_url + relative_path(source.path)
- token = await self.token(agent_code)
- target.parent.mkdir(parents=True, exist_ok=True)
- partial = target.with_name(target.name + ".part")
- try:
- for attempt in range(2):
- async with self.files.stream("GET", url, params=source.params, headers={
- "Authorization": f"Bearer {token['access_token']}", "clientid": token["client_id"]}) as resp:
- if resp.status_code in (401, 403) and attempt == 0:
- new_token = await self.token(agent_code)
- if new_token["access_token"] == token["access_token"]:
- raise GatewayError("文件凭证已失效且网关未下发新凭证", 401)
- token = new_token
- continue
- if resp.status_code != 200:
- raise GatewayError("文件下载", resp.status_code)
- if "json" in resp.headers.get("content-type", "").lower():
- raise GatewayError("下载接口返回 JSON,未获得文件")
- digest, size = hashlib.sha256(), 0
- with partial.open("wb") as output:
- async for chunk in resp.aiter_bytes():
- size += len(chunk)
- if size > self.settings.max_file_bytes:
- raise GatewayError("下载文件超过大小上限")
- digest.update(chunk)
- output.write(chunk)
- if size == 0:
- raise GatewayError("下载接口返回空文件")
- partial.replace(target)
- return {"file_id": source.file_id, "version": source.version,
- "filename": source.filename, "local_path": str(target),
- "sha256": digest.hexdigest(), "size": size}
- except httpx.RequestError:
- raise GatewayError("文件下载", retryable=True) from None
- finally:
- partial.unlink(missing_ok=True)
- async def events(self, agent_code):
- """连接成功先产出 None;空 topics 也可用连接校验感知逻辑启停。"""
- async with self.http.stream("GET", self.settings.base_url + "/internal/events/stream",
- params={"agentCode": agent_code}, headers=self.headers(agent_code),
- timeout=httpx.Timeout(self.settings.timeout, read=None)) as resp:
- if resp.status_code == 403:
- raise GatewayStopped("事件订阅", 403)
- if resp.status_code != 200:
- raise GatewayError("事件订阅", resp.status_code, retryable=True)
- yield None
- fields, size = {}, 0
- async for line in resp.aiter_lines():
- if not line:
- if fields.get("data"):
- payload = json.loads("\n".join(fields["data"]))
- if not isinstance(payload, dict):
- raise GatewayError("事件载荷必须为 JSON 对象")
- yield GatewayEvent(fields.get("event", ""), fields.get("id", ""), payload)
- fields, size = {}, 0
- elif not line.startswith(":"):
- size += len(line)
- if size > 1024 * 1024:
- raise GatewayError("事件帧超过大小上限")
- key, _, value = line.partition(":")
- if key == "data":
- fields.setdefault("data", []).append(value.lstrip())
- elif key in {"event", "id"}:
- fields[key] = value.strip()
|