This commit is contained in:
2026-07-26 17:57:44 +08:00
parent a555f785ba
commit 069f1bafe1
27 changed files with 4893 additions and 3846 deletions
+13
View File
@@ -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",
]
+85
View File
@@ -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]
+73
View File
@@ -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]
+92
View File
@@ -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
+74
View File
@@ -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()