11
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
from .base import APIAdapterError, APIBusinessError, EndpointAdapter
|
||||
from .jx3api import JX3ApiAdapter
|
||||
from .jx3box import JX3BoxAdapter
|
||||
from .router import APIAdapterRouter
|
||||
|
||||
__all__ = [
|
||||
"APIAdapterError",
|
||||
"APIBusinessError",
|
||||
"EndpointAdapter",
|
||||
"JX3ApiAdapter",
|
||||
"JX3BoxAdapter",
|
||||
"APIAdapterRouter",
|
||||
]
|
||||
@@ -0,0 +1,85 @@
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from ..api_config import APIEndpointRegistry
|
||||
from ..request import APIClient, APIClientError, APIResponseFormatError
|
||||
|
||||
|
||||
class APIAdapterError(APIClientError):
|
||||
"""Adapter 层的结构化异常。"""
|
||||
|
||||
|
||||
class APIBusinessError(APIAdapterError):
|
||||
"""HTTP 请求成功,但上游接口返回业务错误。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
endpoint: str,
|
||||
code: Any = None,
|
||||
):
|
||||
super().__init__(message)
|
||||
self.endpoint = endpoint
|
||||
self.code = code
|
||||
|
||||
|
||||
class EndpointAdapter:
|
||||
"""把端点配置转换为 HTTP 请求;子类只处理各上游的协议差异。"""
|
||||
|
||||
def __init__(self, client: APIClient, registry: APIEndpointRegistry):
|
||||
self._client = client
|
||||
self._registry = registry
|
||||
|
||||
async def request(
|
||||
self,
|
||||
endpoint_name: str,
|
||||
*,
|
||||
params: Mapping[str, Any] | None = None,
|
||||
out_key: str | None = "data",
|
||||
path_params: Mapping[str, Any] | None = None,
|
||||
) -> Any:
|
||||
endpoint = self._registry.get(endpoint_name)
|
||||
request_params = self.prepare_params(endpoint, params)
|
||||
url = self._registry.resolve(endpoint_name, path_params)
|
||||
configured_method = str(endpoint.get("method", "GET")).upper()
|
||||
|
||||
if configured_method == "POST":
|
||||
payload = await self._client.post(
|
||||
url,
|
||||
data=request_params,
|
||||
retry=bool(endpoint.get("retry", False)),
|
||||
)
|
||||
else:
|
||||
payload = await self._client.get(
|
||||
url,
|
||||
params=request_params,
|
||||
retry=bool(endpoint.get("retry", True)),
|
||||
)
|
||||
|
||||
payload = self.validate_payload(endpoint_name, payload)
|
||||
return self.extract_payload(payload, out_key)
|
||||
|
||||
def prepare_params(
|
||||
self,
|
||||
endpoint: Mapping[str, Any],
|
||||
params: Mapping[str, Any] | None,
|
||||
) -> dict[str, Any]:
|
||||
request_params = dict(endpoint.get("params", {}))
|
||||
if params:
|
||||
request_params.update(params)
|
||||
return request_params
|
||||
|
||||
def validate_payload(self, endpoint_name: str, payload: Any) -> Any:
|
||||
return payload
|
||||
|
||||
def extract_payload(self, payload: Any, out_key: str | None) -> Any:
|
||||
if out_key in (None, ""):
|
||||
return payload
|
||||
if isinstance(payload, bytes):
|
||||
raise APIResponseFormatError("二进制响应无法提取 JSON 字段")
|
||||
if not isinstance(payload, Mapping):
|
||||
raise APIResponseFormatError(f"响应不是对象,无法提取字段: {out_key}")
|
||||
if out_key not in payload:
|
||||
raise APIResponseFormatError(f"响应缺少字段: {out_key}")
|
||||
return payload[out_key]
|
||||
@@ -0,0 +1,73 @@
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from ..api_config import APIEndpointRegistry
|
||||
from ..request import APIClient, APIResponseFormatError
|
||||
from .base import APIBusinessError, EndpointAdapter
|
||||
|
||||
|
||||
class JX3ApiAdapter(EndpointAdapter):
|
||||
"""适配 JX3API 的鉴权参数和业务响应协议。"""
|
||||
|
||||
SUCCESS_CODE = 200
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client: APIClient,
|
||||
registry: APIEndpointRegistry,
|
||||
*,
|
||||
token: str = "",
|
||||
ticket: str = "",
|
||||
):
|
||||
super().__init__(client, registry)
|
||||
self._credentials = {
|
||||
"token": token.strip(),
|
||||
"ticket": ticket.strip(),
|
||||
}
|
||||
|
||||
def prepare_params(
|
||||
self,
|
||||
endpoint: Mapping[str, Any],
|
||||
params: Mapping[str, Any] | None,
|
||||
) -> dict[str, Any]:
|
||||
request_params = super().prepare_params(endpoint, params)
|
||||
declared_params = endpoint.get("params", {})
|
||||
|
||||
# api_config.json 中出现 token/ticket 即表示此端点需要对应凭证。
|
||||
# 业务 Service 不再重复拼装,也不会把空凭证发送给上游。
|
||||
for name, value in self._credentials.items():
|
||||
if name not in declared_params:
|
||||
continue
|
||||
if value:
|
||||
request_params[name] = value
|
||||
else:
|
||||
request_params.pop(name, None)
|
||||
|
||||
return request_params
|
||||
|
||||
def validate_payload(self, endpoint_name: str, payload: Any) -> Any:
|
||||
if not isinstance(payload, Mapping):
|
||||
raise APIResponseFormatError("JX3API 响应必须是对象")
|
||||
if "code" not in payload:
|
||||
raise APIResponseFormatError("JX3API 响应缺少业务码: code")
|
||||
|
||||
code = payload.get("code")
|
||||
if code != self.SUCCESS_CODE:
|
||||
message = payload.get("msg") or "未知错误"
|
||||
raise APIBusinessError(
|
||||
f"JX3API 业务错误: code={code}, message={message}",
|
||||
endpoint=endpoint_name,
|
||||
code=code,
|
||||
)
|
||||
return payload
|
||||
|
||||
def extract_payload(self, payload: Any, out_key: str | None) -> Any:
|
||||
if out_key in (None, ""):
|
||||
return payload
|
||||
if not isinstance(payload, Mapping):
|
||||
raise APIResponseFormatError(
|
||||
f"JX3API 响应不是对象,无法提取字段: {out_key}"
|
||||
)
|
||||
if out_key not in payload:
|
||||
raise APIResponseFormatError(f"JX3API 响应缺少字段: {out_key}")
|
||||
return payload[out_key]
|
||||
@@ -0,0 +1,92 @@
|
||||
import hashlib
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from astrbot.api import logger
|
||||
|
||||
from ..request import APIResponseFormatError
|
||||
from .base import EndpointAdapter
|
||||
|
||||
|
||||
class JX3BoxAdapter(EndpointAdapter):
|
||||
"""适配 JX3BOX 响应结构,并集中实现其分页规则。"""
|
||||
|
||||
def extract_payload(self, payload: Any, out_key: str | None) -> Any:
|
||||
if out_key in (None, ""):
|
||||
return payload
|
||||
if not isinstance(payload, Mapping):
|
||||
raise APIResponseFormatError(
|
||||
f"JX3BOX 响应不是对象,无法提取字段: {out_key}"
|
||||
)
|
||||
if out_key not in payload:
|
||||
raise APIResponseFormatError(f"JX3BOX 响应缺少字段: {out_key}")
|
||||
return payload[out_key]
|
||||
|
||||
async def request_all_pages(
|
||||
self,
|
||||
endpoint_name: str,
|
||||
*,
|
||||
params: Mapping[str, Any] | None = None,
|
||||
out_key: str | None = None,
|
||||
path_params: Mapping[str, Any] | None = None,
|
||||
page_key: str = "page",
|
||||
list_key: str = "list",
|
||||
max_pages: int = 10,
|
||||
) -> list[Any]:
|
||||
if max_pages < 1:
|
||||
raise ValueError("max_pages 必须大于等于 1")
|
||||
|
||||
all_items: list[Any] = []
|
||||
request_params = dict(params or {})
|
||||
previous_page_hash: str | None = None
|
||||
|
||||
for current_page in range(1, max_pages + 1):
|
||||
request_params[page_key] = str(current_page)
|
||||
data = await self.request(
|
||||
endpoint_name,
|
||||
params=request_params,
|
||||
out_key=out_key,
|
||||
path_params=path_params,
|
||||
)
|
||||
page_items = self._extract_page_items(data, list_key)
|
||||
if not page_items:
|
||||
break
|
||||
|
||||
page_hash = hashlib.sha256(
|
||||
json.dumps(
|
||||
page_items,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
default=str,
|
||||
).encode("utf-8")
|
||||
).hexdigest()
|
||||
if page_hash == previous_page_hash:
|
||||
logger.warning(
|
||||
"JX3BOX 分页接口连续返回重复页面,已停止: "
|
||||
f"key={endpoint_name}, page={current_page}"
|
||||
)
|
||||
break
|
||||
|
||||
all_items.extend(page_items)
|
||||
previous_page_hash = page_hash
|
||||
|
||||
return all_items
|
||||
|
||||
@staticmethod
|
||||
def _extract_page_items(data: Any, list_key: str) -> list[Any]:
|
||||
if data is None:
|
||||
return []
|
||||
if isinstance(data, bytes):
|
||||
raise APIResponseFormatError("JX3BOX 分页接口返回了二进制内容")
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
if not isinstance(data, Mapping):
|
||||
raise APIResponseFormatError("JX3BOX 分页响应必须是列表或对象")
|
||||
if list_key not in data:
|
||||
raise APIResponseFormatError(f"JX3BOX 分页响应缺少字段: {list_key}")
|
||||
|
||||
items = data[list_key]
|
||||
if not isinstance(items, list):
|
||||
raise APIResponseFormatError(f"JX3BOX 分页字段 {list_key} 不是列表")
|
||||
return items
|
||||
@@ -0,0 +1,74 @@
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from ..api_config import APIConfigError, APIEndpointRegistry
|
||||
from ..request import APIClient
|
||||
from .base import EndpointAdapter
|
||||
from .jx3api import JX3ApiAdapter
|
||||
from .jx3box import JX3BoxAdapter
|
||||
|
||||
|
||||
class APIAdapterRouter:
|
||||
"""依据服务配置把端点请求路由到对应 Adapter。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client: APIClient,
|
||||
registry: APIEndpointRegistry,
|
||||
*,
|
||||
jx3api_token: str = "",
|
||||
jx3api_ticket: str = "",
|
||||
):
|
||||
self._registry = registry
|
||||
self._adapters: dict[str, EndpointAdapter] = {
|
||||
"generic": EndpointAdapter(client, registry),
|
||||
"jx3api": JX3ApiAdapter(
|
||||
client,
|
||||
registry,
|
||||
token=jx3api_token,
|
||||
ticket=jx3api_ticket,
|
||||
),
|
||||
"jx3box": JX3BoxAdapter(client, registry),
|
||||
}
|
||||
|
||||
def get_adapter(self, endpoint_name: str) -> EndpointAdapter:
|
||||
adapter_name = self._resolve_adapter_name(endpoint_name)
|
||||
try:
|
||||
return self._adapters[adapter_name]
|
||||
except KeyError as exc:
|
||||
raise APIConfigError(
|
||||
f"端点引用了不支持的 Adapter: {endpoint_name} -> {adapter_name}"
|
||||
) from exc
|
||||
|
||||
async def request(
|
||||
self,
|
||||
endpoint_name: str,
|
||||
*,
|
||||
params: Mapping[str, Any] | None = None,
|
||||
out_key: str | None = "data",
|
||||
path_params: Mapping[str, Any] | None = None,
|
||||
) -> Any:
|
||||
adapter = self.get_adapter(endpoint_name)
|
||||
return await adapter.request(
|
||||
endpoint_name,
|
||||
params=params,
|
||||
out_key=out_key,
|
||||
path_params=path_params,
|
||||
)
|
||||
|
||||
async def request_all_pages(
|
||||
self,
|
||||
endpoint_name: str,
|
||||
**kwargs,
|
||||
) -> list[Any]:
|
||||
adapter = self.get_adapter(endpoint_name)
|
||||
if not isinstance(adapter, JX3BoxAdapter):
|
||||
raise APIConfigError(
|
||||
f"端点不属于 JX3BOX,不能使用 JX3BOX 分页规则: {endpoint_name}"
|
||||
)
|
||||
return await adapter.request_all_pages(endpoint_name, **kwargs)
|
||||
|
||||
def _resolve_adapter_name(self, endpoint_name: str) -> str:
|
||||
endpoint = self._registry.get(endpoint_name)
|
||||
service = self._registry.get_service(str(endpoint["service"]))
|
||||
return str(service.get("adapter", "generic")).lower()
|
||||
Reference in New Issue
Block a user