client.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214
  1. """多业务共享连接池,各请求显式携带平台身份。"""
  2. from dataclasses import dataclass
  3. import hashlib
  4. import json
  5. import mimetypes
  6. from pathlib import Path
  7. from urllib.parse import unquote
  8. import httpx
  9. from extensions.shaanxi.settings import GatewaySettings
  10. from runtime.business_models import RemoteFile
  11. class GatewayError(RuntimeError):
  12. def __init__(self, operation, status=0, *, retryable=False, retry_after=None):
  13. self.status, self.retryable = status, retryable
  14. self.retry_after = retry_after
  15. super().__init__(f"{operation}失败(状态 {status})")
  16. class GatewayStopped(GatewayError):
  17. pass
  18. @dataclass(frozen=True)
  19. class GatewayEvent:
  20. topic: str
  21. message_id: str
  22. payload: dict
  23. def relative_path(path):
  24. decoded = path
  25. for _ in range(3):
  26. decoded = unquote(decoded)
  27. 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:
  28. raise ValueError("必须使用安全的平台站内相对路径,参数通过 params 传递")
  29. return path
  30. class GatewayClient:
  31. def __init__(self, settings: GatewaySettings, *, transport=None, file_transport=None):
  32. self.settings = settings
  33. self.http = httpx.AsyncClient(timeout=settings.timeout, trust_env=False,
  34. follow_redirects=False, transport=transport)
  35. self.files = httpx.AsyncClient(timeout=settings.timeout, trust_env=False,
  36. follow_redirects=False, transport=file_transport or transport)
  37. async def close(self):
  38. await self.http.aclose()
  39. await self.files.aclose()
  40. def headers(self, agent_code):
  41. if not agent_code:
  42. raise ValueError("请求必须指定平台 agentCode")
  43. return {"Authorization": f"Bearer {self.settings.bearer}", "X-Agent-Code": agent_code}
  44. @staticmethod
  45. def _body(resp, operation):
  46. try:
  47. body = resp.json()
  48. except ValueError:
  49. body = {}
  50. if resp.status_code in (403, 500) and isinstance(body, dict) and "已停止" in str(body.get("detail", "")):
  51. raise GatewayStopped(operation, resp.status_code)
  52. if resp.status_code >= 400:
  53. try:
  54. retry_after = max(0, float(resp.headers["Retry-After"]))
  55. if not retry_after < float("inf"):
  56. retry_after = None
  57. except (KeyError, ValueError):
  58. retry_after = None
  59. raise GatewayError(operation, resp.status_code,
  60. retryable=resp.status_code in {408, 429, 500, 502, 503, 504}, retry_after=retry_after)
  61. if not isinstance(body, dict) or str(body.get("code")) != "200" or body.get("success") is False:
  62. raise GatewayError(operation, body.get("code", 0) if isinstance(body, dict) else 0)
  63. return body
  64. async def call(self, method, path, agent_code, **kwargs):
  65. try:
  66. response = await self.http.request(method, self.settings.base_url + path,
  67. headers=self.headers(agent_code), **kwargs)
  68. except httpx.RequestError:
  69. raise GatewayError(path, retryable=True) from None
  70. return self._body(response, path)
  71. async def register(self, business):
  72. binding = business.gateway
  73. if not binding.check_type_code:
  74. raise ValueError(f"{business.name} 尚未配置平台 checkTypeCode")
  75. return await self.call("POST", "/internal/agents/register", binding.agent_code, json={
  76. "agentCode": binding.agent_code, "name": business.display_name,
  77. "sensorTypeList": binding.sensor_type_list, "topics": list(binding.topics),
  78. "checkTypeCode": binding.check_type_code, "enabled": True})
  79. async def query(self, agent_code, source, resource, params=None):
  80. return await self.call("POST", "/internal/data/query", agent_code,
  81. json={"source": source, "resource": resource, "params": params or {}})
  82. async def proxy(self, agent_code, path, *, method="GET", params=None, json_body=None):
  83. method = method.upper()
  84. if method not in {"GET", "POST", "PUT", "DELETE"}:
  85. raise ValueError("网关代理不支持该请求方法")
  86. body = await self.call("POST", "/internal/monitor/proxy", agent_code,
  87. json={"method": method, "path": relative_path(path),
  88. "params": params or {}, "json_body": json_body})
  89. data = body.get("data") or {}
  90. status = data.get("status_code", 0)
  91. if not isinstance(status, int) or not 200 <= status < 300:
  92. raise GatewayError("监管平台代理", status, retryable=status in {429, 500, 502, 503, 504})
  93. upstream = data.get("body")
  94. if isinstance(upstream, dict) and (upstream.get("success") is False or
  95. ("code" in upstream and str(upstream["code"]) not in {"0", "200"})):
  96. raise GatewayError("监管平台业务请求", upstream.get("code", 0))
  97. return upstream
  98. async def token(self, agent_code):
  99. data = (await self.call("GET", "/internal/monitor/token", agent_code)).get("data") or {}
  100. if not data.get("access_token") or not data.get("client_id"):
  101. raise GatewayError("监管平台凭证下发")
  102. return data
  103. async def push(self, agent_code, payload, *, channel="ai_platform"):
  104. if channel not in {"ai_platform", "monitor_platform"}:
  105. raise ValueError("未配置的推送通道")
  106. result = await self.call("POST", f"/internal/push/{channel}", agent_code, json=payload)
  107. if channel == "ai_platform":
  108. data = result.get("data")
  109. if not isinstance(data, dict) or not data.get("checkId") or not isinstance(data.get("clueIds"), list):
  110. raise GatewayError("平台未返回完整研判回执")
  111. return result
  112. async def upload(self, agent_code, path: Path):
  113. if path.stat().st_size > self.settings.max_file_bytes:
  114. raise GatewayError("证据文件超过大小上限")
  115. with path.open("rb") as stream:
  116. response = await self.call("POST", "/internal/monitor/upload", agent_code,
  117. params={"path": self.settings.upload_path},
  118. files={"file": (path.name, stream, mimetypes.guess_type(path.name)[0] or "application/octet-stream")})
  119. platform = response.get("data") or {}
  120. data = platform.get("data", platform)
  121. if not isinstance(data, dict) or data.get("status") != "successed" or not data.get("path"):
  122. raise GatewayError("证据文件上传")
  123. return data["path"]
  124. async def download(self, agent_code, source: RemoteFile, target: Path):
  125. if not self.settings.file_base_url:
  126. raise ValueError("下载文件需要对方提供 SHANXI_FILE_BASE_URL 和文件接口")
  127. url = self.settings.file_base_url + relative_path(source.path)
  128. token = await self.token(agent_code)
  129. target.parent.mkdir(parents=True, exist_ok=True)
  130. partial = target.with_name(target.name + ".part")
  131. try:
  132. for attempt in range(2):
  133. async with self.files.stream("GET", url, params=source.params, headers={
  134. "Authorization": f"Bearer {token['access_token']}", "clientid": token["client_id"]}) as resp:
  135. if resp.status_code in (401, 403) and attempt == 0:
  136. new_token = await self.token(agent_code)
  137. if new_token["access_token"] == token["access_token"]:
  138. raise GatewayError("文件凭证已失效且网关未下发新凭证", 401)
  139. token = new_token
  140. continue
  141. if resp.status_code != 200:
  142. raise GatewayError("文件下载", resp.status_code)
  143. if "json" in resp.headers.get("content-type", "").lower():
  144. raise GatewayError("下载接口返回 JSON,未获得文件")
  145. digest, size = hashlib.sha256(), 0
  146. with partial.open("wb") as output:
  147. async for chunk in resp.aiter_bytes():
  148. size += len(chunk)
  149. if size > self.settings.max_file_bytes:
  150. raise GatewayError("下载文件超过大小上限")
  151. digest.update(chunk)
  152. output.write(chunk)
  153. if size == 0:
  154. raise GatewayError("下载接口返回空文件")
  155. partial.replace(target)
  156. return {"file_id": source.file_id, "version": source.version,
  157. "filename": source.filename, "local_path": str(target),
  158. "sha256": digest.hexdigest(), "size": size}
  159. except httpx.RequestError:
  160. raise GatewayError("文件下载", retryable=True) from None
  161. finally:
  162. partial.unlink(missing_ok=True)
  163. async def events(self, agent_code):
  164. """连接成功先产出 None;空 topics 也可用连接校验感知逻辑启停。"""
  165. async with self.http.stream("GET", self.settings.base_url + "/internal/events/stream",
  166. params={"agentCode": agent_code}, headers=self.headers(agent_code),
  167. timeout=httpx.Timeout(self.settings.timeout, read=None)) as resp:
  168. if resp.status_code == 403:
  169. raise GatewayStopped("事件订阅", 403)
  170. if resp.status_code != 200:
  171. raise GatewayError("事件订阅", resp.status_code, retryable=True)
  172. yield None
  173. fields, size = {}, 0
  174. async for line in resp.aiter_lines():
  175. if not line:
  176. if fields.get("data"):
  177. payload = json.loads("\n".join(fields["data"]))
  178. if not isinstance(payload, dict):
  179. raise GatewayError("事件载荷必须为 JSON 对象")
  180. yield GatewayEvent(fields.get("event", ""), fields.get("id", ""), payload)
  181. fields, size = {}, 0
  182. elif not line.startswith(":"):
  183. size += len(line)
  184. if size > 1024 * 1024:
  185. raise GatewayError("事件帧超过大小上限")
  186. key, _, value = line.partition(":")
  187. if key == "data":
  188. fields.setdefault("data", []).append(value.lstrip())
  189. elif key in {"event", "id"}:
  190. fields[key] = value.strip()