628 lines
22 KiB
Python
628 lines
22 KiB
Python
"""
|
||
ApiClient - HTTP 通信层
|
||
|
||
职责:
|
||
- 封装所有 HTTP 通信逻辑
|
||
- 管理 httpx.AsyncClient,维护连接池和 session
|
||
- 生成请求签名(timestamp, nonce, sign)
|
||
- 统一请求入口,自动注入 uid
|
||
- 批量 Push Log 查询
|
||
"""
|
||
|
||
import hashlib
|
||
import hmac
|
||
import time
|
||
import uuid
|
||
import json as json_lib
|
||
import logging
|
||
import asyncio
|
||
from typing import Dict, List, Any, Optional
|
||
from collections import defaultdict, deque
|
||
from pathlib import Path
|
||
from datetime import datetime
|
||
import httpx
|
||
|
||
from schemas.common.push_system import PushIdsSchema
|
||
|
||
|
||
def _build_default_push_steps() -> List[Dict[str, str]]:
|
||
"""构造最小审批步骤字段,满足第三方 v2 push schema。"""
|
||
return []
|
||
|
||
|
||
class ApiClient:
|
||
"""API HTTP 客户端"""
|
||
_TRACE_TEXT_MAX_LEN = 1000
|
||
|
||
def __init__(
|
||
self,
|
||
base_url: str,
|
||
uid: str,
|
||
secret: str,
|
||
debug: bool = False,
|
||
log_file: Optional[Path] = None,
|
||
rate_limit_max_requests: int = 30,
|
||
rate_limit_window_seconds: float = 10.0,
|
||
):
|
||
"""
|
||
初始化 API 客户端
|
||
|
||
Args:
|
||
base_url: API 基础 URL(如 https://api.example.com)
|
||
uid: 用户标识
|
||
secret: 签名密钥
|
||
debug: 是否启用调试模式(打印请求详情)
|
||
log_file: 日志文件路径(可选)
|
||
rate_limit_max_requests: 限流窗口内最大请求数(<=0 表示禁用)
|
||
rate_limit_window_seconds: 限流窗口秒数(<=0 表示禁用)
|
||
"""
|
||
self._base_url = base_url.rstrip('/')
|
||
self._uid = uid
|
||
self._secret = secret
|
||
self._client: Optional[httpx.AsyncClient] = None
|
||
self._debug = debug
|
||
self._log_file = log_file
|
||
self._trace_seq = 0
|
||
self._trace_by_push_id: Dict[str, deque[Dict[str, Any]]] = defaultdict(deque)
|
||
self._trace_by_biz_id: Dict[str, deque[Dict[str, Any]]] = defaultdict(deque)
|
||
self._trace_by_op: Dict[str, deque[Dict[str, Any]]] = defaultdict(deque)
|
||
self._load_trace_by_item: Dict[tuple[str, str], deque[Dict[str, Any]]] = defaultdict(deque)
|
||
self._logger = logging.getLogger(__name__)
|
||
self._rate_limit_max_requests = int(rate_limit_max_requests)
|
||
self._rate_limit_window_seconds = float(rate_limit_window_seconds)
|
||
self._rate_limit_lock = asyncio.Lock()
|
||
self._rate_limit_timestamps: deque[float] = deque()
|
||
|
||
def _is_rate_limit_enabled(self) -> bool:
|
||
return self._rate_limit_max_requests > 0 and self._rate_limit_window_seconds > 0
|
||
|
||
async def _acquire_rate_limit_slot(self) -> None:
|
||
if not self._is_rate_limit_enabled():
|
||
return
|
||
|
||
while True:
|
||
wait_seconds = 0.0
|
||
async with self._rate_limit_lock:
|
||
now = time.monotonic()
|
||
window_start = now - self._rate_limit_window_seconds
|
||
while self._rate_limit_timestamps and self._rate_limit_timestamps[0] <= window_start:
|
||
self._rate_limit_timestamps.popleft()
|
||
|
||
if len(self._rate_limit_timestamps) < self._rate_limit_max_requests:
|
||
self._rate_limit_timestamps.append(now)
|
||
return
|
||
|
||
oldest = self._rate_limit_timestamps[0]
|
||
wait_seconds = max(0.001, self._rate_limit_window_seconds - (now - oldest))
|
||
|
||
await asyncio.sleep(wait_seconds)
|
||
|
||
async def initialize(self):
|
||
"""初始化 HTTP 客户端"""
|
||
if self._client is None:
|
||
# trust_env=False 阻止从环境变量读取代理设置(避免 localhost 走代理导致超时)
|
||
self._client = httpx.AsyncClient(timeout=300.0, trust_env=False)
|
||
|
||
async def close(self):
|
||
"""关闭 HTTP 客户端"""
|
||
if self._client:
|
||
await self._client.aclose()
|
||
self._client = None
|
||
|
||
def _log(self, message: str, level: str = "DEBUG"):
|
||
"""
|
||
统一日志输出
|
||
|
||
Args:
|
||
message: 日志消息
|
||
level: 日志级别(INFO/ERROR)
|
||
"""
|
||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||
log_line = f"[{timestamp}] [{level}] {message}"
|
||
|
||
level_num = logging.getLevelNamesMapping().get(level.upper(), logging.DEBUG)
|
||
self._logger.log(level_num, log_line)
|
||
|
||
# 文件输出
|
||
if self._log_file:
|
||
self._log_file.parent.mkdir(parents=True, exist_ok=True)
|
||
with open(self._log_file, 'a', encoding='utf-8') as f:
|
||
f.write(log_line + '\n')
|
||
|
||
def _get_client(self) -> httpx.AsyncClient:
|
||
"""获取 HTTP 客户端实例"""
|
||
if self._client is None:
|
||
raise RuntimeError("ApiClient not initialized. Call 'await client.initialize()' first.")
|
||
return self._client
|
||
|
||
def _derive_op_kind(self, method: str, endpoint: str) -> str:
|
||
endpoint_norm = endpoint.lower().strip()
|
||
if endpoint_norm == "/push/result":
|
||
return "poll"
|
||
method_upper = method.upper()
|
||
if method_upper == "GET":
|
||
if endpoint_norm.endswith("/list"):
|
||
return "load"
|
||
return "get"
|
||
if endpoint_norm.endswith("/create"):
|
||
return "create"
|
||
if endpoint_norm.endswith("/delete"):
|
||
return "delete"
|
||
if endpoint_norm.endswith("/update") or endpoint_norm.endswith("/plan"):
|
||
return "update"
|
||
if method_upper == "POST":
|
||
return "create"
|
||
if method_upper == "PUT":
|
||
return "update"
|
||
if method_upper == "DELETE":
|
||
return "delete"
|
||
return method.lower()
|
||
|
||
def _derive_node_type_from_list_endpoint(self, endpoint: str) -> Optional[str]:
|
||
endpoint_norm = endpoint.strip("/")
|
||
if not endpoint_norm.endswith("/list"):
|
||
return None
|
||
prefix = endpoint_norm[:-5]
|
||
if not prefix:
|
||
return None
|
||
return prefix.replace("/", "_")
|
||
|
||
def _record_trace(
|
||
self,
|
||
*,
|
||
method: str,
|
||
endpoint: str,
|
||
url: str,
|
||
request_params: Optional[Dict[str, Any]],
|
||
request_payload: Optional[Dict[str, Any]],
|
||
response: Optional[Dict[str, Any]],
|
||
elapsed_ms: int,
|
||
) -> None:
|
||
self._trace_seq += 1
|
||
op_kind = self._derive_op_kind(method, endpoint)
|
||
payload = request_payload or {}
|
||
response_view = self._build_trace_response_view(op_kind=op_kind, response=response)
|
||
params_text = self._to_trace_text(request_params or {}, max_len=self._TRACE_TEXT_MAX_LEN)
|
||
payload_text = self._to_trace_text(payload, max_len=self._TRACE_TEXT_MAX_LEN)
|
||
response_text = self._to_trace_text(response_view, max_len=self._TRACE_TEXT_MAX_LEN)
|
||
|
||
trace = {
|
||
"trace_id": self._trace_seq,
|
||
"op": op_kind,
|
||
"method": method.upper(),
|
||
"endpoint": endpoint,
|
||
"url": url,
|
||
"params": request_params or {},
|
||
"payload": payload,
|
||
"response": response_view,
|
||
"params_text": params_text,
|
||
"payload_text": payload_text,
|
||
"response_text": response_text,
|
||
"elapsed_ms": elapsed_ms,
|
||
"ts": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||
}
|
||
|
||
push_id = payload.get("push_id")
|
||
biz_id = payload.get("biz_id")
|
||
if isinstance(push_id, str) and push_id:
|
||
self._trace_by_push_id[push_id].append(trace)
|
||
if isinstance(biz_id, str) and biz_id:
|
||
self._trace_by_biz_id[biz_id].append(trace)
|
||
self._trace_by_op[op_kind].append(trace)
|
||
|
||
if op_kind == "load":
|
||
node_type = self._derive_node_type_from_list_endpoint(endpoint)
|
||
data = (response or {}).get("data") if isinstance(response, dict) else None
|
||
if isinstance(data, list):
|
||
items = data
|
||
elif isinstance(data, dict):
|
||
items = data.get("list", [])
|
||
else:
|
||
items = []
|
||
if node_type and isinstance(items, list):
|
||
for item in items:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
item_id = item.get("id") or item.get("_id")
|
||
if not item_id:
|
||
continue
|
||
self._load_trace_by_item[(node_type, str(item_id))].append(trace)
|
||
|
||
def _to_trace_text(self, value: Any, *, max_len: int) -> str:
|
||
try:
|
||
text = json_lib.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
||
except Exception:
|
||
text = str(value)
|
||
if len(text) > max_len:
|
||
return f"{text[:max_len]}...(已截断)"
|
||
return text
|
||
|
||
def _build_trace_response_view(self, *, op_kind: str, response: Optional[Dict[str, Any]]) -> Any:
|
||
return response or {}
|
||
|
||
def consume_traces_by_push_id(self, push_id: str) -> List[Dict[str, Any]]:
|
||
if not push_id:
|
||
return []
|
||
bucket = self._trace_by_push_id.get(push_id)
|
||
if not bucket:
|
||
return []
|
||
traces = list(bucket)
|
||
bucket.clear()
|
||
return traces
|
||
|
||
def consume_latest_trace_by_biz_id(self, biz_id: str, op_kind: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||
if not biz_id:
|
||
return None
|
||
bucket = self._trace_by_biz_id.get(biz_id)
|
||
if not bucket:
|
||
return None
|
||
if op_kind is None:
|
||
return bucket.pop() if bucket else None
|
||
for index in range(len(bucket) - 1, -1, -1):
|
||
trace = bucket[index]
|
||
if trace.get("op") == op_kind:
|
||
del bucket[index]
|
||
return trace
|
||
return None
|
||
|
||
def peek_latest_trace_by_biz_id(self, biz_id: str, op_kind: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||
if not biz_id:
|
||
return None
|
||
bucket = self._trace_by_biz_id.get(biz_id)
|
||
if not bucket:
|
||
return None
|
||
if op_kind is None:
|
||
return bucket[-1] if bucket else None
|
||
for index in range(len(bucket) - 1, -1, -1):
|
||
trace = bucket[index]
|
||
if trace.get("op") == op_kind:
|
||
return trace
|
||
return None
|
||
|
||
def consume_latest_trace_by_op(self, op_kind: str) -> Optional[Dict[str, Any]]:
|
||
bucket = self._trace_by_op.get(op_kind)
|
||
if not bucket:
|
||
return None
|
||
return bucket.pop() if bucket else None
|
||
|
||
def peek_latest_trace_by_op(self, op_kind: str) -> Optional[Dict[str, Any]]:
|
||
bucket = self._trace_by_op.get(op_kind)
|
||
if not bucket:
|
||
return None
|
||
return bucket[-1] if bucket else None
|
||
|
||
def consume_load_traces(self, node_type: str, data_id: str) -> List[Dict[str, Any]]:
|
||
key = (node_type, data_id)
|
||
bucket = self._load_trace_by_item.get(key)
|
||
if not bucket:
|
||
return []
|
||
traces = list(bucket)
|
||
bucket.clear()
|
||
return traces
|
||
|
||
def _generate_signature(self, params: Dict[str, Any]) -> Dict[str, str]:
|
||
"""
|
||
生成签名参数(兼容旧版签名算法)
|
||
|
||
Args:
|
||
params: 请求参数
|
||
|
||
Returns:
|
||
包含 timestamp, nonce, sign 的字典
|
||
"""
|
||
timestamp = int(time.time() * 1000) # 毫秒时间戳
|
||
nonce = uuid.uuid4().hex[:16] # 16位随机字符串
|
||
|
||
# 构建签名参数
|
||
sign_params = params.copy()
|
||
sign_params['timestamp'] = timestamp
|
||
sign_params['nonce'] = nonce
|
||
|
||
# 按key排序
|
||
sorted_items = sorted(sign_params.items())
|
||
|
||
# 构建签名字符串
|
||
items = []
|
||
for k, v in sorted_items:
|
||
if v is None:
|
||
v_str = "null"
|
||
elif isinstance(v, bool):
|
||
v_str = "true" if v else "false"
|
||
elif isinstance(v, str):
|
||
v_str = v
|
||
elif isinstance(v, (int, float)):
|
||
v_str = str(v)
|
||
elif isinstance(v, (list, dict)):
|
||
v_str = json_lib.dumps(v, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
||
else:
|
||
v_str = str(v)
|
||
items.append(f"{k}={v_str}")
|
||
|
||
sign_str = "&".join(items)
|
||
|
||
# 使用 HMAC-SHA256 生成签名
|
||
sign = hmac.new(self._secret.encode("utf-8"), sign_str.encode("utf-8"), hashlib.sha256).hexdigest()
|
||
|
||
return {
|
||
'timestamp': str(timestamp),
|
||
'nonce': nonce,
|
||
'sign': sign
|
||
}
|
||
|
||
async def request(
|
||
self,
|
||
method: str,
|
||
endpoint: str,
|
||
params: Optional[Dict[str, Any]] = None,
|
||
data: Optional[Dict[str, Any]] = None,
|
||
json_data: Optional[Dict[str, Any]] = None,
|
||
json: Optional[Dict[str, Any]] = None
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
统一请求入口,自动签名
|
||
|
||
Args:
|
||
method: HTTP 方法(GET/POST/PUT/DELETE)
|
||
endpoint: API 端点(如 /api/v2/project)
|
||
params: URL 查询参数
|
||
data: 请求体数据(form data)
|
||
json_data: JSON 请求体数据
|
||
json: JSON 请求体数据(兼容参数)
|
||
|
||
Returns:
|
||
响应 JSON 数据
|
||
|
||
Raises:
|
||
httpx.HTTPStatusError: HTTP 错误
|
||
"""
|
||
# 兼容json参数
|
||
if json is not None and json_data is None:
|
||
json_data = json
|
||
|
||
client = self._get_client()
|
||
url = f"{self._base_url}{endpoint}"
|
||
await self._acquire_rate_limit_slot()
|
||
|
||
# 记录请求信息
|
||
self._log(f"\n{'='*60}", level="DEBUG")
|
||
self._log(f"REQUEST: {method} {endpoint}", level="DEBUG")
|
||
self._log(f"Full URL: {url}", level="DEBUG")
|
||
if params:
|
||
self._log(f"Params: {json_lib.dumps(params, ensure_ascii=False, indent=2)}", level="DEBUG")
|
||
if data:
|
||
self._log(f"Data: {json_lib.dumps(data, ensure_ascii=False, indent=2)}", level="DEBUG")
|
||
if json_data:
|
||
self._log(f"JSON: {json_lib.dumps(json_data, ensure_ascii=False, indent=2)}", level="DEBUG")
|
||
|
||
# 准备签名参数
|
||
if method.upper() == 'GET':
|
||
# GET 请求:uid 放在 params 中
|
||
if params is None:
|
||
params = {}
|
||
params['uid'] = self._uid
|
||
|
||
# 转换所有参数值为字符串(除了 list/dict)
|
||
params = {k: str(v) if not isinstance(v, (list, dict)) else v for k, v in params.items()}
|
||
|
||
sign_params = params.copy()
|
||
# 生成签名并附加到 params
|
||
signature = self._generate_signature(sign_params)
|
||
params.update(signature)
|
||
else:
|
||
# POST/PUT/DELETE:uid 和签名放在 body 中(符合 v2 schema)
|
||
if data is None:
|
||
data = {}
|
||
if 'steps' not in data:
|
||
data['steps'] = _build_default_push_steps()
|
||
data['uid'] = self._uid
|
||
sign_params = data.copy()
|
||
if params:
|
||
sign_params.update(params)
|
||
signature = self._generate_signature(sign_params)
|
||
data.update(signature)
|
||
|
||
# 发送请求并增强异常处理
|
||
start_time = time.time()
|
||
try:
|
||
response = await client.request(
|
||
method=method,
|
||
url=url,
|
||
params=params,
|
||
json=data if method.upper() in ['POST', 'PUT'] else None
|
||
)
|
||
|
||
# 记录响应时间
|
||
elapsed = time.time() - start_time
|
||
self._log(f"Response Time: {elapsed:.3f}s", level="DEBUG")
|
||
|
||
# 检查 HTTP 状态
|
||
response.raise_for_status()
|
||
|
||
# 记录响应
|
||
result = response.json()
|
||
self._log(f"Status Code: {response.status_code}", level="DEBUG")
|
||
self._log(f"Response: {json_lib.dumps(result, ensure_ascii=False, indent=2)}", level="DEBUG")
|
||
self._log(f"{'='*60}\n", level="DEBUG")
|
||
|
||
self._record_trace(
|
||
method=method,
|
||
endpoint=endpoint,
|
||
url=url,
|
||
request_params=params,
|
||
request_payload=data if method.upper() in ['POST', 'PUT', 'DELETE'] else None,
|
||
response=result,
|
||
elapsed_ms=int(elapsed * 1000),
|
||
)
|
||
|
||
return result
|
||
|
||
except Exception as e:
|
||
import traceback
|
||
|
||
# 记录错误
|
||
elapsed = time.time() - start_time
|
||
error_msg = "\n========== [ApiClient HTTP Error] ==========\n"
|
||
error_msg += f"Request: {method} {url}\n"
|
||
error_msg += f"Params: {params}\n"
|
||
if method.upper() in ['POST', 'PUT']:
|
||
error_msg += f"JSON: {data}\n"
|
||
error_msg += f"Exception: {type(e).__name__}: {e}\n"
|
||
error_msg += f"Elapsed: {elapsed:.3f}s\n"
|
||
|
||
response_obj = e.response if isinstance(e, httpx.HTTPStatusError) else None
|
||
if response_obj is not None:
|
||
error_msg += f"Status Code: {response_obj.status_code}\n"
|
||
error_msg += f"Response Headers: {response_obj.headers}\n"
|
||
try:
|
||
error_msg += f"Response Text: {response_obj.text}\n"
|
||
except Exception:
|
||
pass
|
||
|
||
error_msg += traceback.format_exc()
|
||
error_msg += "============================================\n"
|
||
|
||
# 记录到日志文件
|
||
self._log(error_msg, level="ERROR")
|
||
|
||
raise
|
||
|
||
async def get(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||
"""
|
||
GET 请求
|
||
|
||
Args:
|
||
endpoint: API 端点
|
||
params: URL 查询参数
|
||
|
||
Returns:
|
||
响应 JSON 数据
|
||
"""
|
||
return await self.request('GET', endpoint, params=params)
|
||
|
||
async def post(self, endpoint: str, data: Dict[str, Any]) -> Dict[str, Any]:
|
||
"""
|
||
POST 请求,data 中已包含 push_id/biz_id(由 Handler 生成)
|
||
|
||
Args:
|
||
endpoint: API 端点
|
||
data: 请求体数据(包含业务数据和 push_id/biz_id)
|
||
|
||
Returns:
|
||
响应 JSON 数据
|
||
"""
|
||
return await self.request('POST', endpoint, data=data)
|
||
|
||
async def put(self, endpoint: str, data: Dict[str, Any]) -> Dict[str, Any]:
|
||
"""
|
||
PUT 请求,data 中已包含 push_id/biz_id
|
||
|
||
Args:
|
||
endpoint: API 端点
|
||
data: 请求体数据
|
||
|
||
Returns:
|
||
响应 JSON 数据
|
||
"""
|
||
return await self.request('PUT', endpoint, data=data)
|
||
|
||
async def delete(self, endpoint: str, data: Dict[str, Any]) -> Dict[str, Any]:
|
||
"""
|
||
DELETE 请求,data 中已包含 push_id/biz_id
|
||
|
||
Args:
|
||
endpoint: API 端点
|
||
data: 请求体数据
|
||
|
||
Returns:
|
||
响应 JSON 数据
|
||
"""
|
||
return await self.request('DELETE', endpoint, data=data)
|
||
|
||
async def get_push_logs(self, push_ids: List[str]) -> Dict[str, Dict[str, Any]]:
|
||
"""
|
||
批量查询 Push Log
|
||
|
||
注意:单个查询也传单元素列表
|
||
当前实现:用 for 循环模拟,后续等后端实现批量接口
|
||
|
||
Args:
|
||
push_ids: Push ID 列表
|
||
|
||
Returns:
|
||
{push_id: push_log_data} 的映射
|
||
"""
|
||
result: Dict[str, Dict[str, Any]] = {}
|
||
|
||
if not push_ids:
|
||
return result
|
||
|
||
try:
|
||
payload = PushIdsSchema(push_ids=','.join(push_ids)).model_dump(exclude_none=True)
|
||
response = await self.request(
|
||
method='POST',
|
||
endpoint='/push/result',
|
||
data=payload
|
||
)
|
||
except httpx.HTTPStatusError as e:
|
||
return {push_id: {'error': str(e)} for push_id in push_ids}
|
||
|
||
data_list: List[Dict[str, Any]] = []
|
||
if isinstance(response, dict):
|
||
raw_data = response.get('data')
|
||
if isinstance(raw_data, list):
|
||
data_list = [item for item in raw_data if isinstance(item, dict)]
|
||
elif isinstance(raw_data, dict):
|
||
data_list = [raw_data]
|
||
elif 'push_id' in response and 'status' in response:
|
||
data_list = [response]
|
||
|
||
for item in data_list:
|
||
push_id = item.get('push_id')
|
||
if not push_id:
|
||
continue
|
||
|
||
poll_trace = {
|
||
"trace_id": f"poll-{push_id}-{int(time.time() * 1000)}",
|
||
"op": "poll",
|
||
"method": "POST",
|
||
"endpoint": "/push/result",
|
||
"url": f"{self._base_url}/push/result",
|
||
"params": {},
|
||
"payload": {"push_ids": push_id},
|
||
"response": item,
|
||
"elapsed_ms": 0,
|
||
"ts": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||
}
|
||
self._trace_by_push_id[push_id].append(poll_trace)
|
||
|
||
status = (item.get('status') or '').lower()
|
||
error_msg = item.get('error') or ''
|
||
result_data = item.get('result') or {}
|
||
|
||
if status == 'success':
|
||
# result_data 的结构是 {"code": 200, "message": "OK", "data": {"biz_id": "..."}}
|
||
# 需要提取 result_data["data"] 作为最终的 data
|
||
inner_data = result_data.get('data', {}) if isinstance(result_data, dict) else {}
|
||
result[push_id] = {
|
||
'code': 200,
|
||
'status': 'success',
|
||
'data': inner_data
|
||
}
|
||
elif status in {'failed', 'not_find'}:
|
||
result[push_id] = {
|
||
'code': 500,
|
||
'status': 'failed',
|
||
'message': error_msg or status or 'Unknown error',
|
||
'error': error_msg or status or 'Unknown error'
|
||
}
|
||
else:
|
||
result[push_id] = {
|
||
'status': status or 'processing'
|
||
}
|
||
|
||
for push_id in push_ids:
|
||
if push_id not in result:
|
||
result[push_id] = {'status': 'processing'}
|
||
|
||
return result
|