feat(adapters): 接入 CloudCone/Zeabur/Dynadot/新网/Agnes 五个平台
线上平台列表里 cloudcone、zeabur、xinwang、dynadot、agnes 的 sdk_type 仍为空, 平台卡片只能显示「未对接」,用户无法同步。按各自真实 API 能力补齐: - CloudCone:实例清单 + 开机/关机/重启;无公开账单接口,不声明余额与续费 - Zeabur:服务器清单 + 重启;毫核/MB 归一化为核/GB,未采集时不伪造规格 - Dynadot:域名清单(含到期日)+ 账户余额;set_dns2 位置参数无法安全 CRUD - 新网:代理 API 按域名逐个查到期日;无列表接口,需在账号里填 domains 清单 - Agnes:/models 校验 Key;无余额接口,如实标记不支持 配套改动: - _align_preset_providers 为库中为空的 sdk_type 回填种子值(启动即生效), 避免新适配器上线后线上仍停留在「未对接」 - 适配器可声明 plain_config,代理号/域名等非密钥字段前端按明文输入 - PRESET_PROVIDERS 五条记录填上对应 sdk_type 测试:445 passed(新增 tests/test_platform_adapters.py),vitest 62 passed。
This commit is contained in:
@@ -103,6 +103,31 @@ class OpenAIAdapter(_AIBase):
|
|||||||
# get_account 为 False,刷新余额时会如实提示「暂不支持余额查询」。
|
# get_account 为 False,刷新余额时会如实提示「暂不支持余额查询」。
|
||||||
|
|
||||||
|
|
||||||
|
@register("agnes-api")
|
||||||
|
class AgnesAdapter(_AIBase):
|
||||||
|
"""Agnes 适配器(OpenAI 兼容接口,Bearer Key)
|
||||||
|
|
||||||
|
官方 Base URL:https://api.agnes-ai.cn/v1(国际站为 api.agnes-ai.com)
|
||||||
|
只做 Key 有效性验证与可用模型查询;官方没有公开余额接口,
|
||||||
|
因此不实现 get_account——否则余额刷新会显示「已更新」但数值永远不变。
|
||||||
|
"""
|
||||||
|
|
||||||
|
BASE = "https://api.agnes-ai.cn/v1"
|
||||||
|
|
||||||
|
def _base_url(self) -> str:
|
||||||
|
return (self.config.get("base_url") or self.BASE).rstrip("/")
|
||||||
|
|
||||||
|
def test_connection(self) -> dict:
|
||||||
|
try:
|
||||||
|
data = self._get(self._base_url() + "/models")
|
||||||
|
count = len(data.get("data", []))
|
||||||
|
return {"ok": True, "message": f"连接成功,可用模型 {count} 个"}
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
return self._http_error(e)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
return {"ok": False, "message": str(e)}
|
||||||
|
|
||||||
|
|
||||||
@register("minimax-api")
|
@register("minimax-api")
|
||||||
class MinimaxAdapter(_AIBase):
|
class MinimaxAdapter(_AIBase):
|
||||||
"""Minimax 适配器(余额查询)
|
"""Minimax 适配器(余额查询)
|
||||||
|
|||||||
@@ -228,6 +228,8 @@ class BaseAdapter(ABC):
|
|||||||
|
|
||||||
#: 适配器所需的配置字段名(如 ["api_key"] 或 ["access_key_id", "access_key_secret"])
|
#: 适配器所需的配置字段名(如 ["api_key"] 或 ["access_key_id", "access_key_secret"])
|
||||||
required_config: list = []
|
required_config: list = []
|
||||||
|
#: required_config 中不属于密钥的字段(前端按明文输入,避免代理号/域名被遮住)
|
||||||
|
plain_config: list = []
|
||||||
|
|
||||||
def __init__(self, config: Optional[dict] = None):
|
def __init__(self, config: Optional[dict] = None):
|
||||||
self.config = config or {}
|
self.config = config or {}
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"""CloudCone 适配器(REST API v2)
|
||||||
|
|
||||||
|
API 文档:https://cloudcone-api.readme.io/reference
|
||||||
|
所需配置:{"api_key": "...", "api_hash": "..."}
|
||||||
|
认证请求头:App-Secret = API Key,Hash = API Hash
|
||||||
|
|
||||||
|
覆盖:实例清单、开机/关机/重启。
|
||||||
|
CloudCone 没有公开的账单/到期/余额接口,故不声明 get_account,
|
||||||
|
也不提供续费动作——拿不到到期日就别假装能核对。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.adapters.base import BaseAdapter, NormalizedVPS
|
||||||
|
from app.adapters.registry import register
|
||||||
|
|
||||||
|
#: CloudCone state.name -> 台账状态
|
||||||
|
_STATUS_MAP = {
|
||||||
|
"active": "active",
|
||||||
|
"starting": "active",
|
||||||
|
"rebooting": "active",
|
||||||
|
"stopped": "stopped",
|
||||||
|
"shutdown": "stopped",
|
||||||
|
"suspended": "stopped",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@register("cloudcone-api")
|
||||||
|
class CloudConeAdapter(BaseAdapter):
|
||||||
|
required_config = ["api_key", "api_hash"]
|
||||||
|
BASE = "https://api.cloudcone.com/api/v2"
|
||||||
|
|
||||||
|
def _headers(self) -> dict:
|
||||||
|
return {
|
||||||
|
"App-Secret": self.config.get("api_key", ""),
|
||||||
|
"Hash": self.config.get("api_hash", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _request(self, method: str, path: str) -> dict:
|
||||||
|
with httpx.Client(timeout=30) as client:
|
||||||
|
resp = client.request(method, self.BASE + path, headers=self._headers())
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json() if resp.content else {}
|
||||||
|
# CloudCone 用 status=1 表示成功,0 时 message 里才有可读原因
|
||||||
|
if isinstance(data, dict) and data.get("status") not in (1, "1", None):
|
||||||
|
raise RuntimeError(data.get("message") or "CloudCone 接口返回失败")
|
||||||
|
return data
|
||||||
|
|
||||||
|
def _get(self, path: str) -> dict:
|
||||||
|
return self._request("GET", path)
|
||||||
|
|
||||||
|
def _post(self, path: str) -> dict:
|
||||||
|
return self._request("POST", path)
|
||||||
|
|
||||||
|
def test_connection(self) -> dict:
|
||||||
|
try:
|
||||||
|
instances = self.list_vps()
|
||||||
|
return {"ok": True, "message": f"连接成功,读取到 {len(instances)} 台实例"}
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
return {"ok": False, "message": f"HTTP {e.response.status_code}:API Key/Hash 无效或权限不足"}
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
return {"ok": False, "message": str(e)}
|
||||||
|
|
||||||
|
def list_vps(self) -> list:
|
||||||
|
data = self._get("/compute/instances")
|
||||||
|
raw_instances = (data.get("__data") or {}).get("instances") or []
|
||||||
|
result = []
|
||||||
|
for inst in raw_instances:
|
||||||
|
state = (inst.get("state") or {}).get("name") or "unknown"
|
||||||
|
ips = str(inst.get("ips") or "").strip()
|
||||||
|
result.append(
|
||||||
|
NormalizedVPS(
|
||||||
|
external_id=str(inst.get("id")),
|
||||||
|
name=inst.get("hostname") or inst.get("nodename") or str(inst.get("id")),
|
||||||
|
ip_address=ips.split(",")[0].strip() if ips else None,
|
||||||
|
region=inst.get("nodename"),
|
||||||
|
os=inst.get("distro"),
|
||||||
|
cpu_cores=inst.get("cpu"),
|
||||||
|
memory_gb=inst.get("ram"),
|
||||||
|
disk_gb=inst.get("disk"),
|
||||||
|
status=_STATUS_MAP.get(state.lower(), "unknown"),
|
||||||
|
currency="USD",
|
||||||
|
charge_type="prepaid",
|
||||||
|
raw=inst,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
# ---------------- 实例管理动作 ----------------
|
||||||
|
|
||||||
|
def _instance_action(self, external_id: str, verb: str, label: str) -> dict:
|
||||||
|
try:
|
||||||
|
self._post(f"/compute/{external_id}/{verb}")
|
||||||
|
return {"ok": True, "message": f"{label}指令已下发"}
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
return {"ok": False, "message": f"HTTP {e.response.status_code}:{label}失败"}
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
return {"ok": False, "message": f"{label}失败:{e}"}
|
||||||
|
|
||||||
|
def start_instance(self, external_id: str) -> dict:
|
||||||
|
return self._instance_action(external_id, "boot", "开机")
|
||||||
|
|
||||||
|
def stop_instance(self, external_id: str) -> dict:
|
||||||
|
return self._instance_action(external_id, "shutdown", "关机")
|
||||||
|
|
||||||
|
def reboot_instance(self, external_id: str) -> dict:
|
||||||
|
return self._instance_action(external_id, "reboot", "重启")
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
"""Dynadot 适配器(Domain API v3,API Key 查询参数)
|
||||||
|
|
||||||
|
API 文档:https://www.dynadot.com/domain/api-commands
|
||||||
|
所需配置:{"api_key": "..."}
|
||||||
|
|
||||||
|
覆盖:注册域名清单(含到期日)、账户余额。
|
||||||
|
Dynadot 的 DNS 记录通过 set_dns2 的 main_record0..19 位置参数写入,
|
||||||
|
无法安全地做「按记录增删改」,故不声明 DNS 能力。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date, datetime, timezone
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.adapters.base import (
|
||||||
|
AccountInfo,
|
||||||
|
BaseAdapter,
|
||||||
|
NormalizedDomain,
|
||||||
|
parse_iso_date,
|
||||||
|
)
|
||||||
|
from app.adapters.registry import register
|
||||||
|
|
||||||
|
|
||||||
|
def _epoch_ms_to_date(value) -> date | None:
|
||||||
|
"""Dynadot 的 Expiration 是 Unix 毫秒字符串;兼容秒级和 ISO 字符串"""
|
||||||
|
if value in (None, ""):
|
||||||
|
return None
|
||||||
|
text = str(value).strip()
|
||||||
|
try:
|
||||||
|
num = int(float(text))
|
||||||
|
except ValueError:
|
||||||
|
return parse_iso_date(text)
|
||||||
|
if num > 10_000_000_000: # 毫秒
|
||||||
|
num //= 1000
|
||||||
|
try:
|
||||||
|
return datetime.fromtimestamp(num, tz=timezone.utc).date()
|
||||||
|
except (ValueError, OSError, OverflowError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _as_list(value) -> list:
|
||||||
|
if isinstance(value, list):
|
||||||
|
return value
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return [value]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
@register("dynadot-api")
|
||||||
|
class DynadotAdapter(BaseAdapter):
|
||||||
|
required_config = ["api_key"]
|
||||||
|
BASE = "https://api.dynadot.com/api3.json"
|
||||||
|
|
||||||
|
def _call(self, command: str, params: dict | None = None) -> dict:
|
||||||
|
query = {"key": self.config.get("api_key", ""), "command": command}
|
||||||
|
if params:
|
||||||
|
query.update({k: v for k, v in params.items() if v is not None})
|
||||||
|
with httpx.Client(timeout=30) as client:
|
||||||
|
resp = client.get(self.BASE, params=query)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
# 每个命令的根键不同(ListDomainInfoResponse / GetAccountBalanceResponse…)
|
||||||
|
root = next(
|
||||||
|
(v for k, v in data.items() if isinstance(v, dict) and k.endswith("Response")),
|
||||||
|
data if isinstance(data, dict) else {},
|
||||||
|
)
|
||||||
|
code = root.get("ResponseCode", root.get("SuccessCode"))
|
||||||
|
status_text = str(root.get("Status") or "").lower()
|
||||||
|
if str(code) != "0" or status_text == "error":
|
||||||
|
reason = root.get("Error") or root.get("Message") or root.get("Status") or "未知错误"
|
||||||
|
raise RuntimeError(f"Dynadot {command} 失败(code={code}):{reason}")
|
||||||
|
return root
|
||||||
|
|
||||||
|
def test_connection(self) -> dict:
|
||||||
|
try:
|
||||||
|
acc = self.get_account()
|
||||||
|
if acc.balance is None:
|
||||||
|
return {"ok": True, "message": "连接成功(账户未返回余额)"}
|
||||||
|
return {"ok": True, "message": f"连接成功,账户余额 {acc.balance} {acc.currency}"}
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
return {"ok": False, "message": f"HTTP {e.response.status_code}:API Key 无效"}
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
return {"ok": False, "message": str(e)}
|
||||||
|
|
||||||
|
# ---------------- 只读清单 ----------------
|
||||||
|
|
||||||
|
def list_domains(self) -> list:
|
||||||
|
root = self._call("list_domain")
|
||||||
|
result = []
|
||||||
|
for entry in _as_list(root.get("MainDomains")):
|
||||||
|
# JSON 里可能是 {"Domain": {...}},也可能直接把字段摊平
|
||||||
|
node = entry.get("Domain") if isinstance(entry.get("Domain"), dict) else entry
|
||||||
|
name = node.get("Name") or entry.get("Name")
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
expiry = _epoch_ms_to_date(node.get("Expiration") or entry.get("Expiration"))
|
||||||
|
status = str(node.get("Status") or entry.get("Status") or "active").lower()
|
||||||
|
result.append(
|
||||||
|
NormalizedDomain(
|
||||||
|
external_id=str(name),
|
||||||
|
domain_name=str(name),
|
||||||
|
registrar="dynadot",
|
||||||
|
expiry_date=expiry,
|
||||||
|
status=status,
|
||||||
|
raw=entry,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def get_account(self) -> AccountInfo:
|
||||||
|
root = self._call("get_account_balance")
|
||||||
|
balances = _as_list(root.get("BalanceList"))
|
||||||
|
chosen = next(
|
||||||
|
(b for b in balances if str(b.get("Currency", "")).upper() == "USD"),
|
||||||
|
balances[0] if balances else {},
|
||||||
|
)
|
||||||
|
amount = chosen.get("Amount")
|
||||||
|
try:
|
||||||
|
balance = float(str(amount).replace(",", "")) if amount not in (None, "") else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
balance = None
|
||||||
|
return AccountInfo(
|
||||||
|
balance=balance,
|
||||||
|
currency=str(chosen.get("Currency") or "USD").upper(),
|
||||||
|
raw=root,
|
||||||
|
)
|
||||||
@@ -29,13 +29,17 @@ def load_all() -> None:
|
|||||||
ai,
|
ai,
|
||||||
aliyun,
|
aliyun,
|
||||||
aws,
|
aws,
|
||||||
|
cloudcone,
|
||||||
cloudflare,
|
cloudflare,
|
||||||
digitalocean,
|
digitalocean,
|
||||||
|
dynadot,
|
||||||
linode,
|
linode,
|
||||||
namesilo,
|
namesilo,
|
||||||
tencent,
|
tencent,
|
||||||
vultr,
|
vultr,
|
||||||
whmcs,
|
whmcs,
|
||||||
|
xinnet,
|
||||||
|
zeabur,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -84,6 +88,7 @@ def adapter_meta() -> list:
|
|||||||
"class": cls.__name__,
|
"class": cls.__name__,
|
||||||
"sdk_types": getattr(cls, "sdk_types", [sdk_type]),
|
"sdk_types": getattr(cls, "sdk_types", [sdk_type]),
|
||||||
"required_config": getattr(cls, "required_config", []),
|
"required_config": getattr(cls, "required_config", []),
|
||||||
|
"plain_config": getattr(cls, "plain_config", []),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"""新网(Xinnet)代理 API 适配器
|
||||||
|
|
||||||
|
API 文档:新网「域名代理 API」(api.xinnet.com/domain/api.gb)
|
||||||
|
所需配置:{"client": "代理号", "password": "API 密码", "domains": "a.com,b.com"}
|
||||||
|
|
||||||
|
限制(必须如实告知,不能假装能一键同步):
|
||||||
|
- 只有新网代理账号能调用,且必须在代理后台登记调用来源 IP,API 每年需年审;
|
||||||
|
- 接口没有「列出全部域名」的方法,必须由用户提供要核对的域名清单;
|
||||||
|
- 新网零售客户区登录带腾讯验证码,无法用面板抓取替代。
|
||||||
|
|
||||||
|
覆盖:按配置的域名逐个查询到期日(GetProductKey / keyname=ExpireDate)。
|
||||||
|
响应不是 JSON,而是 URL 编码的 key=value 串,成功时形如
|
||||||
|
``name=example.com&key=2028-03-01``,失败时形如 ``ret=0&err=client-noexist``。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import re
|
||||||
|
from urllib.parse import parse_qsl
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.adapters.base import BaseAdapter, NormalizedDomain, parse_iso_date
|
||||||
|
from app.adapters.registry import register
|
||||||
|
|
||||||
|
_DOMAIN_SPLIT_RE = re.compile(r"[,,;;\s]+")
|
||||||
|
|
||||||
|
|
||||||
|
@register("xinnet-api")
|
||||||
|
class XinnetAdapter(BaseAdapter):
|
||||||
|
required_config = ["client", "password", "domains"]
|
||||||
|
#: 代理号和域名清单不是密钥,前端按明文输入
|
||||||
|
plain_config = ["client", "domains"]
|
||||||
|
BASE = "https://api.xinnet.com/domain/api.gb"
|
||||||
|
|
||||||
|
def _sign(self, method: str, parts: list) -> str:
|
||||||
|
raw = method + str(self.config.get("client") or "") + str(
|
||||||
|
self.config.get("password") or ""
|
||||||
|
) + "".join(parts)
|
||||||
|
return hashlib.md5(raw.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
def _post(self, method: str, params: dict, sign_parts: list) -> dict:
|
||||||
|
payload = {
|
||||||
|
"client": self.config.get("client") or "",
|
||||||
|
**params,
|
||||||
|
"checksum": self._sign(method, sign_parts),
|
||||||
|
}
|
||||||
|
with httpx.Client(timeout=30) as client:
|
||||||
|
resp = client.post(
|
||||||
|
self.BASE,
|
||||||
|
params={"method": method, "charset": "utf-8"},
|
||||||
|
data=payload,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = dict(parse_qsl(resp.text.strip(), keep_blank_values=True))
|
||||||
|
|
||||||
|
ret = data.get("ret")
|
||||||
|
if ret is not None and str(ret) != "100":
|
||||||
|
reason = data.get("err") or data.get("info") or "未知错误"
|
||||||
|
raise RuntimeError(f"新网 {method} 失败(ret={ret}):{reason}")
|
||||||
|
return data
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _enc(domain: str) -> str:
|
||||||
|
return "G" if any(ord(ch) > 127 for ch in domain) else "E"
|
||||||
|
|
||||||
|
def _domains(self) -> list:
|
||||||
|
raw = str(self.config.get("domains") or "")
|
||||||
|
seen = set()
|
||||||
|
result = []
|
||||||
|
for item in _DOMAIN_SPLIT_RE.split(raw):
|
||||||
|
domain = item.strip().lower()
|
||||||
|
if domain and domain not in seen:
|
||||||
|
seen.add(domain)
|
||||||
|
result.append(domain)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _expiry(self, domain: str) -> str | None:
|
||||||
|
enc = self._enc(domain)
|
||||||
|
data = self._post(
|
||||||
|
"GetProductKey",
|
||||||
|
{"name": domain, "enc": enc, "keyname": "ExpireDate"},
|
||||||
|
[domain, enc],
|
||||||
|
)
|
||||||
|
return data.get("key") or data.get("ExpireDate")
|
||||||
|
|
||||||
|
def test_connection(self) -> dict:
|
||||||
|
domains = self._domains()
|
||||||
|
if not domains:
|
||||||
|
return {"ok": False, "message": "请填写要核对的新网域名(逗号分隔)"}
|
||||||
|
try:
|
||||||
|
expiry = self._expiry(domains[0])
|
||||||
|
if not expiry:
|
||||||
|
return {"ok": False, "message": f"接口已连通,但未返回 {domains[0]} 的到期日"}
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"message": f"连接成功,{domains[0]} 到期 {expiry}(共 {len(domains)} 个域名)",
|
||||||
|
}
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"message": f"HTTP {e.response.status_code}:请确认代理号、API 密码与来源 IP 白名单",
|
||||||
|
}
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
return {"ok": False, "message": str(e)}
|
||||||
|
|
||||||
|
def list_domains(self) -> list:
|
||||||
|
result = []
|
||||||
|
for domain in self._domains():
|
||||||
|
expiry_text = self._expiry(domain)
|
||||||
|
if not expiry_text:
|
||||||
|
raise RuntimeError(f"新网未返回 {domain} 的到期日,请确认该域名属于此代理账号")
|
||||||
|
result.append(
|
||||||
|
NormalizedDomain(
|
||||||
|
external_id=domain,
|
||||||
|
domain_name=domain,
|
||||||
|
registrar="xinnet",
|
||||||
|
expiry_date=parse_iso_date(expiry_text),
|
||||||
|
status="active",
|
||||||
|
raw={"domain": domain, "expiry": expiry_text},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
"""Zeabur 适配器(GraphQL Public API,Bearer Token)
|
||||||
|
|
||||||
|
API 文档:https://zeabur.com/docs/zh-CN/developer/public-api
|
||||||
|
端点:https://api.zeabur.com/graphql
|
||||||
|
所需配置:{"api_token": "..."}
|
||||||
|
|
||||||
|
覆盖:团队/个人名下的服务器清单、重启服务器。
|
||||||
|
服务器列表返回 CPU(毫核)、内存/磁盘(MB)与在线状态;
|
||||||
|
Zeabur 的计费走用量结算,没有统一的「到期日/余额」查询,故不声明
|
||||||
|
get_account,也不提供续费。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.adapters.base import BaseAdapter, NormalizedVPS
|
||||||
|
from app.adapters.registry import register
|
||||||
|
|
||||||
|
_SERVERS_QUERY = """
|
||||||
|
query Servers {
|
||||||
|
servers {
|
||||||
|
_id
|
||||||
|
name
|
||||||
|
ip
|
||||||
|
country
|
||||||
|
city
|
||||||
|
provisioningStatus
|
||||||
|
providerInfo { code name }
|
||||||
|
hasK3s
|
||||||
|
status {
|
||||||
|
isOnline
|
||||||
|
totalCPU
|
||||||
|
totalMemory
|
||||||
|
totalDisk
|
||||||
|
vmStatus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@register("zeabur-api")
|
||||||
|
class ZeaburAdapter(BaseAdapter):
|
||||||
|
required_config = ["api_token"]
|
||||||
|
BASE = "https://api.zeabur.com/graphql"
|
||||||
|
|
||||||
|
def _graphql(self, query: str, variables: dict | None = None) -> dict:
|
||||||
|
payload = {"query": query}
|
||||||
|
if variables:
|
||||||
|
payload["variables"] = variables
|
||||||
|
with httpx.Client(timeout=30) as client:
|
||||||
|
resp = client.post(
|
||||||
|
self.BASE,
|
||||||
|
headers={"Authorization": f"Bearer {self.config.get('api_token', '')}"},
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
errors = data.get("errors") or []
|
||||||
|
if errors:
|
||||||
|
message = errors[0].get("message") if isinstance(errors[0], dict) else str(errors[0])
|
||||||
|
raise RuntimeError(f"Zeabur GraphQL 错误:{message}")
|
||||||
|
return data.get("data") or {}
|
||||||
|
|
||||||
|
def test_connection(self) -> dict:
|
||||||
|
try:
|
||||||
|
data = self._graphql("query Me { me { username } }")
|
||||||
|
username = (data.get("me") or {}).get("username") or "未知账户"
|
||||||
|
return {"ok": True, "message": f"连接成功,令牌属于 {username}"}
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
return {"ok": False, "message": f"HTTP {e.response.status_code}:API Token 无效或权限不足"}
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
return {"ok": False, "message": str(e)}
|
||||||
|
|
||||||
|
def list_vps(self) -> list:
|
||||||
|
servers = self._graphql(_SERVERS_QUERY).get("servers") or []
|
||||||
|
result = []
|
||||||
|
for server in servers:
|
||||||
|
status = server.get("status") or {}
|
||||||
|
online = bool(status.get("isOnline"))
|
||||||
|
provider = (server.get("providerInfo") or {}).get("name")
|
||||||
|
location = ", ".join(
|
||||||
|
part for part in (server.get("city"), server.get("country")) if part
|
||||||
|
)
|
||||||
|
total_cpu = int(status.get("totalCPU") or 0)
|
||||||
|
result.append(
|
||||||
|
NormalizedVPS(
|
||||||
|
external_id=str(server.get("_id")),
|
||||||
|
name=server.get("name") or server.get("ip") or str(server.get("_id")),
|
||||||
|
ip_address=server.get("ip") or None,
|
||||||
|
region=location or provider,
|
||||||
|
# hasK3s==False 才是纯净 Ubuntu;nil 是旧机器,默认装了 ZeaburOS
|
||||||
|
os="Ubuntu" if server.get("hasK3s") is False else "ZeaburOS",
|
||||||
|
# totalCPU 单位是毫核(4000 = 4 核),台账列是整数核
|
||||||
|
cpu_cores=max(1, round(total_cpu / 1000)) if total_cpu else None,
|
||||||
|
memory_gb=round(int(status.get("totalMemory") or 0) / 1024, 1) or None,
|
||||||
|
disk_gb=round(int(status.get("totalDisk") or 0) / 1024) or None,
|
||||||
|
status="active" if online else "stopped",
|
||||||
|
currency="USD",
|
||||||
|
charge_type="postpaid",
|
||||||
|
raw=server,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def reboot_instance(self, external_id: str) -> dict:
|
||||||
|
mutation = """
|
||||||
|
mutation RebootServer($id: ObjectID!) {
|
||||||
|
rebootServer(_id: $id)
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = self._graphql(mutation, {"id": external_id})
|
||||||
|
if not data.get("rebootServer"):
|
||||||
|
return {"ok": False, "message": "重启请求未被接受"}
|
||||||
|
return {"ok": True, "message": "重启指令已下发"}
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
return {"ok": False, "message": f"HTTP {e.response.status_code}:重启失败"}
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
return {"ok": False, "message": f"重启失败:{e}"}
|
||||||
+7
-8
@@ -39,25 +39,24 @@ PRESET_PROVIDERS = [
|
|||||||
{"slug": "tothost", "name": "TotHost", "name_en": "TotHost", "category": "vps",
|
{"slug": "tothost", "name": "TotHost", "name_en": "TotHost", "category": "vps",
|
||||||
"services": "vps",
|
"services": "vps",
|
||||||
"website": "https://www.tothost.vn", "console_url": "https://billing.tothost.vn/clientarea.php", "sdk_type": "whmcs-panel"},
|
"website": "https://www.tothost.vn", "console_url": "https://billing.tothost.vn/clientarea.php", "sdk_type": "whmcs-panel"},
|
||||||
# 以下平台暂无适配器:sdk_type 必须留空。
|
|
||||||
# 填了不存在的 sdk_type 会让平台卡片显示「API 对接: xxx」,用户建账号点同步才撞
|
|
||||||
# 「暂不支持的 SDK 类型」——声明支持却不支持,比明说不支持更糟。
|
|
||||||
{"slug": "cloudcone", "name": "CloudCone", "name_en": "CloudCone", "category": "vps",
|
{"slug": "cloudcone", "name": "CloudCone", "name_en": "CloudCone", "category": "vps",
|
||||||
"services": "vps",
|
"services": "vps",
|
||||||
"website": "https://cloudcone.com", "console_url": "https://app.cloudcone.com", "sdk_type": None},
|
"website": "https://cloudcone.com", "console_url": "https://app.cloudcone.com", "sdk_type": "cloudcone-api"},
|
||||||
{"slug": "zeabur", "name": "Zeabur", "name_en": "Zeabur", "category": "vps",
|
{"slug": "zeabur", "name": "Zeabur", "name_en": "Zeabur", "category": "vps",
|
||||||
"services": "vps,domain",
|
"services": "vps,domain",
|
||||||
"website": "https://zeabur.com", "console_url": "https://dash.zeabur.com", "sdk_type": None},
|
"website": "https://zeabur.com", "console_url": "https://dash.zeabur.com", "sdk_type": "zeabur-api"},
|
||||||
# ---- 域名注册商 ----
|
# ---- 域名注册商 ----
|
||||||
{"slug": "namesilo", "name": "Namesilo", "name_en": "Namesilo", "category": "domain",
|
{"slug": "namesilo", "name": "Namesilo", "name_en": "Namesilo", "category": "domain",
|
||||||
"services": "domain,dns",
|
"services": "domain,dns",
|
||||||
"website": "https://namesilo.com", "console_url": "https://www.namesilo.com/account_domains.php", "sdk_type": "namesilo-api"},
|
"website": "https://namesilo.com", "console_url": "https://www.namesilo.com/account_domains.php", "sdk_type": "namesilo-api"},
|
||||||
|
# 新网只有代理账号能调 API,且没有「列出全部域名」的接口:需在账号里
|
||||||
|
# 填写 domains 清单;零售客户区有验证码,无法用面板抓取替代。
|
||||||
{"slug": "xinwang", "name": "新网", "name_en": "Xinnet", "category": "domain",
|
{"slug": "xinwang", "name": "新网", "name_en": "Xinnet", "category": "domain",
|
||||||
"services": "domain,dns",
|
"services": "domain,dns",
|
||||||
"website": "https://xinnet.com", "console_url": "https://www.xinnet.com", "sdk_type": None},
|
"website": "https://xinnet.com", "console_url": "https://www.xinnet.com", "sdk_type": "xinnet-api"},
|
||||||
{"slug": "dynadot", "name": "Dynadot", "name_en": "Dynadot", "category": "domain",
|
{"slug": "dynadot", "name": "Dynadot", "name_en": "Dynadot", "category": "domain",
|
||||||
"services": "domain,dns",
|
"services": "domain,dns",
|
||||||
"website": "https://dynadot.com", "console_url": "https://www.dynadot.com/account/domains", "sdk_type": None},
|
"website": "https://dynadot.com", "console_url": "https://www.dynadot.com/account/domains", "sdk_type": "dynadot-api"},
|
||||||
# ---- AI 服务商 ----
|
# ---- AI 服务商 ----
|
||||||
{"slug": "openai", "name": "OpenAI", "name_en": "OpenAI", "category": "ai_agent",
|
{"slug": "openai", "name": "OpenAI", "name_en": "OpenAI", "category": "ai_agent",
|
||||||
"services": "ai_agent",
|
"services": "ai_agent",
|
||||||
@@ -70,7 +69,7 @@ PRESET_PROVIDERS = [
|
|||||||
"website": "https://moonshot.cn", "console_url": "https://platform.moonshot.cn", "sdk_type": "moonshot-api"},
|
"website": "https://moonshot.cn", "console_url": "https://platform.moonshot.cn", "sdk_type": "moonshot-api"},
|
||||||
{"slug": "agnes", "name": "Agnes", "name_en": "Agnes", "category": "ai_agent",
|
{"slug": "agnes", "name": "Agnes", "name_en": "Agnes", "category": "ai_agent",
|
||||||
"services": "ai_agent",
|
"services": "ai_agent",
|
||||||
"website": None, "console_url": None, "sdk_type": None},
|
"website": None, "console_url": None, "sdk_type": "agnes-api"},
|
||||||
{"slug": "deepseek", "name": "DeepSeek", "name_en": "DeepSeek", "category": "ai_agent",
|
{"slug": "deepseek", "name": "DeepSeek", "name_en": "DeepSeek", "category": "ai_agent",
|
||||||
"services": "ai_agent",
|
"services": "ai_agent",
|
||||||
"website": "https://deepseek.com", "console_url": "https://platform.deepseek.com", "sdk_type": "deepseek-api"},
|
"website": "https://deepseek.com", "console_url": "https://platform.deepseek.com", "sdk_type": "deepseek-api"},
|
||||||
|
|||||||
+12
-5
@@ -189,13 +189,13 @@ def _align_preset_providers(conn) -> None:
|
|||||||
(新增适配器、废弃从未实现的 sdk_type)必须在这里落地,否则线上会长期
|
(新增适配器、废弃从未实现的 sdk_type)必须在这里落地,否则线上会长期
|
||||||
停留在旧声明上。
|
停留在旧声明上。
|
||||||
|
|
||||||
- sdk_type:库里声明的值不在适配器注册表内就清空。留着会让平台卡片显示
|
- sdk_type:库里声明了但注册表没有的值要清空(声明支持却不支持,比明说不支持
|
||||||
「API 对接: xxx」,用户建好账号点同步才撞「暂不支持的 SDK 类型」——
|
更糟);库里为空而种子已给出且已实现的要补上——否则新增适配器上线后,
|
||||||
声明支持却不支持,比明说不支持更糟。
|
线上平台卡片仍停留在「未对接」,用户永远点不到同步。
|
||||||
- services:只增量补标签,不删除。用户可能手动加过标签(线上 aliyun-intl
|
- services:只增量补标签,不删除。用户可能手动加过标签(线上 aliyun-intl
|
||||||
就多了 ai_agent/other),按种子值整体覆盖会丢用户配置。
|
就多了 ai_agent/other),按种子值整体覆盖会丢用户配置。
|
||||||
|
|
||||||
只处理 slug 命中 PRESET_PROVIDERS 的行,用户自建平台(如 tothost)不动。
|
只处理 slug 命中 PRESET_PROVIDERS 的行,用户自建平台不动。
|
||||||
"""
|
"""
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from app.adapters import registry
|
from app.adapters import registry
|
||||||
@@ -213,12 +213,19 @@ def _align_preset_providers(conn) -> None:
|
|||||||
if not data:
|
if not data:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
declared = row[2]
|
declared = (row[2] or "").strip()
|
||||||
if declared and declared not in supported:
|
if declared and declared not in supported:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
sa.text("UPDATE providers SET sdk_type = NULL WHERE id = :id"),
|
sa.text("UPDATE providers SET sdk_type = NULL WHERE id = :id"),
|
||||||
{"id": row[0]},
|
{"id": row[0]},
|
||||||
)
|
)
|
||||||
|
elif not declared:
|
||||||
|
seed_sdk = (data.get("sdk_type") or "").strip()
|
||||||
|
if seed_sdk and seed_sdk in supported:
|
||||||
|
conn.execute(
|
||||||
|
sa.text("UPDATE providers SET sdk_type = :sdk WHERE id = :id"),
|
||||||
|
{"sdk": seed_sdk, "id": row[0]},
|
||||||
|
)
|
||||||
|
|
||||||
seed_services = [s.strip() for s in (data.get("services") or "").split(",") if s.strip()]
|
seed_services = [s.strip() for s in (data.get("services") or "").split(",") if s.strip()]
|
||||||
if not seed_services:
|
if not seed_services:
|
||||||
|
|||||||
@@ -395,6 +395,7 @@ _AI_SDK_MAP = {
|
|||||||
"kimi": "moonshot-api",
|
"kimi": "moonshot-api",
|
||||||
"moonshot": "moonshot-api",
|
"moonshot": "moonshot-api",
|
||||||
"minimax": "minimax-api",
|
"minimax": "minimax-api",
|
||||||
|
"agnes": "agnes-api",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+9
-6
@@ -226,7 +226,7 @@ const AccountModal = {
|
|||||||
<div v-if="currentFields.length" class="border border-slate-200 dark:border-slate-700 rounded-lg p-3 space-y-2">
|
<div v-if="currentFields.length" class="border border-slate-200 dark:border-slate-700 rounded-lg p-3 space-y-2">
|
||||||
<div class="text-xs font-medium text-slate-600 dark:text-slate-300">API 配置(供 SDK 同步,加密存储)</div>
|
<div class="text-xs font-medium text-slate-600 dark:text-slate-300">API 配置(供 SDK 同步,加密存储)</div>
|
||||||
<label v-for="field in currentFields" :key="field" class="block"><span class="text-xs text-slate-400">{{ field }}</span>
|
<label v-for="field in currentFields" :key="field" class="block"><span class="text-xs text-slate-400">{{ field }}</span>
|
||||||
<input v-model="apiFields[field]" type="password" :placeholder="store.accountModal.editing ? '留空不修改' : '填写 ' + field" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
|
<input v-model="apiFields[field]" :type="isPlain(field) ? 'text' : 'password'" :placeholder="store.accountModal.editing ? '留空不修改' : '填写 ' + field" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="store.accountModal.editing" class="text-xs text-slate-400">修改账号标识后,引用该账号的资产会自动同步更新。</p>
|
<p v-if="store.accountModal.editing" class="text-xs text-slate-400">修改账号标识后,引用该账号的资产会自动同步更新。</p>
|
||||||
<div class="flex justify-end gap-2">
|
<div class="flex justify-end gap-2">
|
||||||
@@ -244,12 +244,15 @@ const AccountModal = {
|
|||||||
try { const r = await Api.get('/providers/adapters'); adapterMeta.value = r.adapters || []; } catch (e) { /* ignore */ }
|
try { const r = await Api.get('/providers/adapters'); adapterMeta.value = r.adapters || []; } catch (e) { /* ignore */ }
|
||||||
});
|
});
|
||||||
// 按账号所属平台的 sdk_type 动态渲染所需凭证字段
|
// 按账号所属平台的 sdk_type 动态渲染所需凭证字段
|
||||||
const currentFields = Vue.computed(() => {
|
const currentAdapter = Vue.computed(() => {
|
||||||
const p = store.providers.find(x => x.slug === (f.value && f.value.platform));
|
const p = store.providers.find(x => x.slug === (f.value && f.value.platform));
|
||||||
if (!p || !p.sdk_type) return [];
|
if (!p || !p.sdk_type) return null;
|
||||||
const m = adapterMeta.value.find(x => (x.sdk_types || []).includes(p.sdk_type));
|
return adapterMeta.value.find(x => (x.sdk_types || []).includes(p.sdk_type)) || null;
|
||||||
return m ? (m.required_config || []) : [];
|
|
||||||
});
|
});
|
||||||
|
const currentFields = Vue.computed(() => (currentAdapter.value ? (currentAdapter.value.required_config || []) : []));
|
||||||
|
// 代理号、域名清单等非密钥字段按明文显示,否则用户无法核对输入
|
||||||
|
const plainFields = Vue.computed(() => (currentAdapter.value ? (currentAdapter.value.plain_config || []) : []));
|
||||||
|
function isPlain(field) { return plainFields.value.includes(field); }
|
||||||
// 每次打开清空残留输入,避免串号
|
// 每次打开清空残留输入,避免串号
|
||||||
Vue.watch(() => store.accountModal.show, (show) => {
|
Vue.watch(() => store.accountModal.show, (show) => {
|
||||||
if (show) { for (const k of Object.keys(apiFields)) delete apiFields[k]; }
|
if (show) { for (const k of Object.keys(apiFields)) delete apiFields[k]; }
|
||||||
@@ -260,7 +263,7 @@ const AccountModal = {
|
|||||||
f.value.api_config = Object.keys(cfg).length ? JSON.stringify(cfg) : '';
|
f.value.api_config = Object.keys(cfg).length ? JSON.stringify(cfg) : '';
|
||||||
saveAccount();
|
saveAccount();
|
||||||
}
|
}
|
||||||
return { store, f, currentFields, apiFields, save };
|
return { store, f, currentFields, apiFields, isPlain, save };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,325 @@
|
|||||||
|
"""补齐平台适配器测试:CloudCone / Zeabur / Dynadot / Agnes / 新网
|
||||||
|
|
||||||
|
这五家此前 sdk_type 为空,平台卡片只能显示「未对接」。测试重点:
|
||||||
|
- 归一化后的字段直接决定台账展示,不能把 MB 当 GB、毫核当核;
|
||||||
|
- 到期日必须是 date,否则写库时整批 commit 抛 TypeError;
|
||||||
|
- 余额/到期拿不到时如实报告不支持,不能伪造 0;
|
||||||
|
- 新网响应是 URL 编码文本且 checksum 有严格拼接顺序,错一位就是 auth-failure。
|
||||||
|
|
||||||
|
运行:.venv/bin/python -m pytest tests/test_platform_adapters.py -v
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from datetime import date
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.adapters import registry
|
||||||
|
from app.adapters.ai import AgnesAdapter
|
||||||
|
from app.adapters.cloudcone import CloudConeAdapter
|
||||||
|
from app.adapters.dynadot import DynadotAdapter, _epoch_ms_to_date
|
||||||
|
from app.adapters.xinnet import XinnetAdapter
|
||||||
|
from app.adapters.zeabur import ZeaburAdapter
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------- CloudCone ----------------------------- #
|
||||||
|
def test_cloudcone_headers_use_documented_names():
|
||||||
|
adapter = CloudConeAdapter({"api_key": "KEY", "api_hash": "HASH"})
|
||||||
|
assert adapter._headers() == {"App-Secret": "KEY", "Hash": "HASH"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_cloudcone_list_vps_normalization():
|
||||||
|
adapter = CloudConeAdapter({"api_key": "k", "api_hash": "h"})
|
||||||
|
payload = {
|
||||||
|
"status": 1,
|
||||||
|
"__data": {
|
||||||
|
"instances": [
|
||||||
|
{
|
||||||
|
"id": 236441,
|
||||||
|
"nodename": "S64",
|
||||||
|
"state": {"name": "active"},
|
||||||
|
"ips": "10.10.10.1,10.10.10.2",
|
||||||
|
"hostname": "web-01",
|
||||||
|
"distro": "ubuntu",
|
||||||
|
"ram": 3,
|
||||||
|
"cpu": 1,
|
||||||
|
"disk": 30,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"nodename": "S53",
|
||||||
|
"state": {"name": "stopped"},
|
||||||
|
"ips": "10.10.10.3",
|
||||||
|
"hostname": "off-01",
|
||||||
|
"ram": 7.5,
|
||||||
|
"cpu": 8,
|
||||||
|
"disk": 150,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
with patch.object(CloudConeAdapter, "_get", return_value=payload):
|
||||||
|
vps = adapter.list_vps()
|
||||||
|
|
||||||
|
assert [v.external_id for v in vps] == ["236441", "2"]
|
||||||
|
assert vps[0].name == "web-01"
|
||||||
|
assert vps[0].ip_address == "10.10.10.1" # 多 IP 取第一个
|
||||||
|
assert vps[0].memory_gb == 3 # ram 已经是 GB,不能再 /1024
|
||||||
|
assert vps[0].disk_gb == 30
|
||||||
|
assert vps[0].cpu_cores == 1
|
||||||
|
assert vps[0].status == "active"
|
||||||
|
assert vps[1].status == "stopped"
|
||||||
|
assert vps[0].charge_type == "prepaid"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cloudcone_non_success_status_raises():
|
||||||
|
"""status=0 时响应体是 HTTP 200,只能在 _request 里翻译成异常"""
|
||||||
|
adapter = CloudConeAdapter({"api_key": "k", "api_hash": "h"})
|
||||||
|
resp = httpx.Response(
|
||||||
|
200,
|
||||||
|
request=httpx.Request("GET", "https://api.cloudcone.com/api/v2/compute/instances"),
|
||||||
|
json={"status": 0, "message": "Invalid hash"},
|
||||||
|
)
|
||||||
|
with patch("httpx.Client.request", return_value=resp):
|
||||||
|
with pytest.raises(RuntimeError) as ei:
|
||||||
|
adapter.list_vps()
|
||||||
|
assert "Invalid hash" in str(ei.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_cloudcone_power_actions_hit_documented_paths():
|
||||||
|
adapter = CloudConeAdapter({"api_key": "k", "api_hash": "h"})
|
||||||
|
with patch.object(CloudConeAdapter, "_post", return_value={"status": 1}) as m:
|
||||||
|
assert adapter.start_instance("42")["ok"] is True
|
||||||
|
assert m.call_args.args[0] == "/compute/42/boot"
|
||||||
|
assert adapter.stop_instance("42")["ok"] is True
|
||||||
|
assert m.call_args.args[0] == "/compute/42/shutdown"
|
||||||
|
assert adapter.reboot_instance("42")["ok"] is True
|
||||||
|
assert m.call_args.args[0] == "/compute/42/reboot"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cloudcone_has_no_balance_or_renew():
|
||||||
|
caps = CloudConeAdapter({"api_key": "k", "api_hash": "h"}).capabilities()
|
||||||
|
assert caps["list_vps"] is True
|
||||||
|
assert caps["get_account"] is False # 没有公开账单接口
|
||||||
|
assert caps["renew_instance"] is False # 拿不到到期日就不提供续费
|
||||||
|
assert caps["start_instance"] is True
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------- Zeabur ----------------------------- #
|
||||||
|
def test_zeabur_list_vps_normalization():
|
||||||
|
adapter = ZeaburAdapter({"api_token": "t"})
|
||||||
|
data = {
|
||||||
|
"servers": [
|
||||||
|
{
|
||||||
|
"_id": "s1",
|
||||||
|
"name": "hk-01",
|
||||||
|
"ip": "1.2.3.4",
|
||||||
|
"country": "HK",
|
||||||
|
"city": "Hong Kong",
|
||||||
|
"providerInfo": {"name": "Tencent"},
|
||||||
|
"hasK3s": False,
|
||||||
|
"status": {"isOnline": True, "totalCPU": 4000, "totalMemory": 8192, "totalDisk": 102400},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": "s2",
|
||||||
|
"name": "legacy",
|
||||||
|
"ip": "5.6.7.8",
|
||||||
|
"hasK3s": None,
|
||||||
|
"status": {"isOnline": False, "totalCPU": 0, "totalMemory": 0, "totalDisk": 0},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
with patch.object(ZeaburAdapter, "_graphql", return_value=data):
|
||||||
|
vps = adapter.list_vps()
|
||||||
|
|
||||||
|
assert [v.external_id for v in vps] == ["s1", "s2"]
|
||||||
|
assert vps[0].region == "Hong Kong, HK"
|
||||||
|
assert vps[0].cpu_cores == 4 # 毫核 -> 核
|
||||||
|
assert vps[0].memory_gb == 8.0 # MB -> GB
|
||||||
|
assert vps[0].disk_gb == 100
|
||||||
|
assert vps[0].status == "active"
|
||||||
|
assert vps[0].os == "Ubuntu"
|
||||||
|
assert vps[1].status == "stopped"
|
||||||
|
assert vps[1].os == "ZeaburOS" # hasK3s=None 是旧机器,不能标成纯净 Ubuntu
|
||||||
|
assert vps[1].cpu_cores is None # 未采集到规格时为 None,不能伪造 1 核
|
||||||
|
|
||||||
|
|
||||||
|
def test_zeabur_graphql_error_is_readable():
|
||||||
|
adapter = ZeaburAdapter({"api_token": "t"})
|
||||||
|
with patch.object(ZeaburAdapter, "_graphql", side_effect=RuntimeError("Zeabur GraphQL 错误:unauthorized")):
|
||||||
|
res = adapter.test_connection()
|
||||||
|
assert res["ok"] is False
|
||||||
|
assert "unauthorized" in res["message"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_zeabur_reboot_mutation():
|
||||||
|
adapter = ZeaburAdapter({"api_token": "t"})
|
||||||
|
with patch.object(ZeaburAdapter, "_graphql", return_value={"rebootServer": True}) as m:
|
||||||
|
res = adapter.reboot_instance("s1")
|
||||||
|
assert res["ok"] is True
|
||||||
|
assert "rebootServer" in m.call_args.args[0]
|
||||||
|
assert m.call_args.args[1] == {"id": "s1"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_zeabur_capabilities():
|
||||||
|
caps = ZeaburAdapter({"api_token": "t"}).capabilities()
|
||||||
|
assert caps["list_vps"] is True
|
||||||
|
assert caps["reboot_instance"] is True
|
||||||
|
assert caps["start_instance"] is False
|
||||||
|
assert caps["get_account"] is False
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------- Dynadot ----------------------------- #
|
||||||
|
def test_dynadot_epoch_millis_to_date():
|
||||||
|
assert _epoch_ms_to_date("1798761600000") == date(2027, 1, 1)
|
||||||
|
assert _epoch_ms_to_date("1798761600") == date(2027, 1, 1)
|
||||||
|
assert _epoch_ms_to_date("2027-01-01") == date(2027, 1, 1)
|
||||||
|
assert _epoch_ms_to_date(None) is None
|
||||||
|
assert _epoch_ms_to_date("garbage") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_dynadot_list_domains_nested_shape():
|
||||||
|
adapter = DynadotAdapter({"api_key": "k"})
|
||||||
|
root = {
|
||||||
|
"MainDomains": [
|
||||||
|
{
|
||||||
|
"Domain": {
|
||||||
|
"Name": "example.com",
|
||||||
|
"Expiration": "1798761600000",
|
||||||
|
"Status": "active",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
with patch.object(DynadotAdapter, "_call", return_value=root):
|
||||||
|
domains = adapter.list_domains()
|
||||||
|
assert len(domains) == 1
|
||||||
|
assert domains[0].domain_name == "example.com"
|
||||||
|
assert isinstance(domains[0].expiry_date, date) # 写库前必须是 date
|
||||||
|
assert domains[0].expiry_date == date(2027, 1, 1)
|
||||||
|
assert domains[0].registrar == "dynadot"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dynadot_list_domains_flat_shape():
|
||||||
|
"""JSON 版本可能把字段摊平到 MainDomains 元素上"""
|
||||||
|
adapter = DynadotAdapter({"api_key": "k"})
|
||||||
|
root = {"MainDomains": [{"Name": "flat.com", "Expiration": "1798761600000", "Status": "active"}]}
|
||||||
|
with patch.object(DynadotAdapter, "_call", return_value=root):
|
||||||
|
assert adapter.list_domains()[0].domain_name == "flat.com"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dynadot_balance_prefers_usd():
|
||||||
|
adapter = DynadotAdapter({"api_key": "k"})
|
||||||
|
root = {"BalanceList": [{"Currency": "CNY", "Amount": "100.00"}, {"Currency": "USD", "Amount": "300.00"}]}
|
||||||
|
with patch.object(DynadotAdapter, "_call", return_value=root):
|
||||||
|
acc = adapter.get_account()
|
||||||
|
assert acc.balance == 300.0
|
||||||
|
assert acc.currency == "USD"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dynadot_error_code_raises():
|
||||||
|
adapter = DynadotAdapter({"api_key": "bad"})
|
||||||
|
resp = httpx.Response(
|
||||||
|
200,
|
||||||
|
request=httpx.Request("GET", "https://api.dynadot.com/api3.json"),
|
||||||
|
json={"ListDomainInfoResponse": {"ResponseCode": -1, "Status": "error", "Error": "invalid key"}},
|
||||||
|
)
|
||||||
|
client = httpx.Client()
|
||||||
|
with patch("httpx.Client.get", return_value=resp):
|
||||||
|
with pytest.raises(RuntimeError) as ei:
|
||||||
|
adapter._call("list_domain")
|
||||||
|
assert "invalid key" in str(ei.value)
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_dynadot_capabilities_domain_only():
|
||||||
|
caps = DynadotAdapter({"api_key": "k"}).capabilities()
|
||||||
|
assert caps["list_domains"] is True
|
||||||
|
assert caps["get_account"] is True
|
||||||
|
assert caps["list_vps"] is False
|
||||||
|
assert caps["list_dns_records"] is False # set_dns2 位置参数无法安全做单条增删改
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------- Agnes ----------------------------- #
|
||||||
|
def test_agnes_test_connection_lists_models():
|
||||||
|
adapter = AgnesAdapter({"api_key": "k"})
|
||||||
|
with patch.object(AgnesAdapter, "_get", return_value={"data": [{"id": "agnes-3.0-flash"}]}):
|
||||||
|
res = adapter.test_connection()
|
||||||
|
assert res["ok"] is True
|
||||||
|
assert "1" in res["message"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_agnes_has_no_balance_api():
|
||||||
|
caps = AgnesAdapter({"api_key": "k"}).capabilities()
|
||||||
|
assert caps["get_account"] is False
|
||||||
|
assert caps["list_vps"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_agnes_base_url_override():
|
||||||
|
adapter = AgnesAdapter({"api_key": "k", "base_url": "https://api.agnes-ai.com/v1/"})
|
||||||
|
assert adapter._base_url() == "https://api.agnes-ai.com/v1"
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------- 新网 ----------------------------- #
|
||||||
|
def test_xinnet_checksum_order():
|
||||||
|
"""checksum=MD5("GetProductKey"+client+password+dn+enc),顺序错一位就是 auth-failure"""
|
||||||
|
adapter = XinnetAdapter({"client": "agent1", "password": "secret", "domains": "example.com"})
|
||||||
|
expected = hashlib.md5("GetProductKeyagent1secretexample.comE".encode()).hexdigest()
|
||||||
|
assert adapter._sign("GetProductKey", ["example.com", "E"]) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_xinnet_domain_list_parsing_dedupes():
|
||||||
|
adapter = XinnetAdapter({"client": "c", "password": "p", "domains": "a.com, b.com;a.com\nc.com"})
|
||||||
|
assert adapter._domains() == ["a.com", "b.com", "c.com"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_xinnet_list_domains_uses_expire_date():
|
||||||
|
adapter = XinnetAdapter({"client": "c", "password": "p", "domains": "a.com"})
|
||||||
|
with patch.object(XinnetAdapter, "_post", return_value={"name": "a.com", "key": "2028-03-01"}) as m:
|
||||||
|
domains = adapter.list_domains()
|
||||||
|
assert domains[0].expiry_date == date(2028, 3, 1)
|
||||||
|
assert domains[0].registrar == "xinnet"
|
||||||
|
params = m.call_args.args[1]
|
||||||
|
assert params["name"] == "a.com"
|
||||||
|
assert params["keyname"] == "ExpireDate"
|
||||||
|
|
||||||
|
|
||||||
|
def test_xinnet_error_ret_raises_with_reason():
|
||||||
|
"""新网失败时 HTTP 仍是 200,只靠响应体里的 ret/err 区分"""
|
||||||
|
adapter = XinnetAdapter({"client": "c", "password": "p", "domains": "a.com"})
|
||||||
|
resp = httpx.Response(
|
||||||
|
200,
|
||||||
|
request=httpx.Request("POST", "https://api.xinnet.com/domain/api.gb"),
|
||||||
|
text="ret=0&err=auth-failure",
|
||||||
|
)
|
||||||
|
with patch("httpx.Client.post", return_value=resp):
|
||||||
|
with pytest.raises(RuntimeError) as ei:
|
||||||
|
adapter.list_domains()
|
||||||
|
assert "auth-failure" in str(ei.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_xinnet_capabilities_domain_only():
|
||||||
|
caps = XinnetAdapter({"client": "c", "password": "p", "domains": "a.com"}).capabilities()
|
||||||
|
assert caps["list_domains"] is True
|
||||||
|
assert caps["list_vps"] is False
|
||||||
|
assert caps["list_dns_records"] is False
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------- 注册表 ----------------------------- #
|
||||||
|
def test_new_platform_sdk_types_are_registered():
|
||||||
|
for sdk_type in (
|
||||||
|
"cloudcone-api",
|
||||||
|
"zeabur-api",
|
||||||
|
"dynadot-api",
|
||||||
|
"agnes-api",
|
||||||
|
"xinnet-api",
|
||||||
|
):
|
||||||
|
assert registry.is_supported(sdk_type), f"{sdk_type} 未注册"
|
||||||
|
|
||||||
|
|
||||||
|
def test_adapter_meta_exposes_plain_config():
|
||||||
|
meta = {m["sdk_types"][0]: m for m in registry.adapter_meta()}
|
||||||
|
assert meta["xinnet-api"]["required_config"] == ["client", "password", "domains"]
|
||||||
|
assert set(meta["xinnet-api"]["plain_config"]) == {"client", "domains"}
|
||||||
@@ -26,8 +26,9 @@ from app.core.seed import PRESET_PROVIDERS, seed_providers
|
|||||||
from app.database import _align_preset_providers
|
from app.database import _align_preset_providers
|
||||||
from app.models.provider import Provider
|
from app.models.provider import Provider
|
||||||
|
|
||||||
#: 线上 GET /api/providers 抓下来的真实状态(2026-10-11),
|
#: 线上 GET /api/providers 抓下来的真实状态(2026-10-26),
|
||||||
#: 用它做 fixture 才能保证迁移验证的是生产形态而不是理想形态
|
#: 用它做 fixture 才能保证迁移验证的是生产形态而不是理想形态。
|
||||||
|
#: cloudcone/zeabur/dynadot/xinwang/agnes 上线前都是 None,靠启动迁移补 sdk_type。
|
||||||
LIVE_PROVIDERS = [
|
LIVE_PROVIDERS = [
|
||||||
("agnes", None, "ai_agent"),
|
("agnes", None, "ai_agent"),
|
||||||
("deepseek", "deepseek-api", "ai_agent"),
|
("deepseek", "deepseek-api", "ai_agent"),
|
||||||
@@ -35,19 +36,19 @@ LIVE_PROVIDERS = [
|
|||||||
("minimax", "minimax-api", "ai_agent"),
|
("minimax", "minimax-api", "ai_agent"),
|
||||||
("openai", "openai-api", "ai_agent"),
|
("openai", "openai-api", "ai_agent"),
|
||||||
("cloudflare", "cloudflare-api", "cloudflare,dns,ssl_cert,cdn"),
|
("cloudflare", "cloudflare-api", "cloudflare,dns,ssl_cert,cdn"),
|
||||||
("dynadot", "dynadot-api", "domain,dns"),
|
("dynadot", None, "domain,dns"),
|
||||||
("namesilo", "namesilo-api", "domain,dns"),
|
("namesilo", "namesilo-api", "domain,dns"),
|
||||||
("xinwang", None, "domain,dns"),
|
("xinwang", None, "domain,dns"),
|
||||||
("aliyun", "aliyun-sdk", "vps,domain,ssl_cert,cdn,dns"),
|
("aliyun", "aliyun-sdk", "vps,domain,ssl_cert,cdn,dns"),
|
||||||
("aliyun-intl", "alibabacloud-sdk", "vps,domain,ssl_cert,cdn,dns,ai_agent,other"),
|
("aliyun-intl", "alibabacloud-sdk", "vps,domain,ssl_cert,cdn,dns,ai_agent,other"),
|
||||||
("cloudcone", "cloudcone-api", "vps"),
|
("cloudcone", None, "vps"),
|
||||||
("digitalocean", "do-api", "vps,domain"),
|
("digitalocean", "do-api", "vps,domain"),
|
||||||
("linode", "linode-api", "vps"),
|
("linode", "linode-api", "vps"),
|
||||||
("tencent", "tencent-sdk", "vps,domain,ssl_cert,cdn,dns"),
|
("tencent", "tencent-sdk", "vps,domain,ssl_cert,cdn,dns"),
|
||||||
("tencent-intl", "tencent-intl-sdk", "vps,domain,ssl_cert,cdn,dns"),
|
("tencent-intl", "tencent-intl-sdk", "vps,domain,ssl_cert,cdn,dns"),
|
||||||
("tothost", None, "vps"),
|
("tothost", "whmcs-panel", "vps"),
|
||||||
("vultr", "vultr-api", "vps"),
|
("vultr", "vultr-api", "vps"),
|
||||||
("zeabur", "zeabur-api", "vps,domain"),
|
("zeabur", None, "vps,domain"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -118,11 +119,25 @@ def test_seed_providers_is_idempotent(live_db):
|
|||||||
|
|
||||||
# ----------------------------- 迁移行为 ----------------------------- #
|
# ----------------------------- 迁移行为 ----------------------------- #
|
||||||
def test_migration_clears_bogus_sdk_type(live_db):
|
def test_migration_clears_bogus_sdk_type(live_db):
|
||||||
"""cloudcone/zeabur/dynadot 声明的适配器从未实现,必须清空"""
|
"""库里声明了注册表没有的 sdk_type(旧版本残留)必须清空"""
|
||||||
|
with live_db.begin() as conn:
|
||||||
|
conn.execute(sa.text("UPDATE providers SET sdk_type = 'vultr-v1-removed' WHERE slug = 'vultr'"))
|
||||||
|
_run_migration(live_db)
|
||||||
|
assert _snapshot(live_db)["vultr"][0] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_backfills_missing_sdk_type(live_db):
|
||||||
|
"""新增适配器上线后,库里 sdk_type 为空的预设平台必须补上,否则卡片永远不可同步"""
|
||||||
_run_migration(live_db)
|
_run_migration(live_db)
|
||||||
snap = _snapshot(live_db)
|
snap = _snapshot(live_db)
|
||||||
for slug in ("cloudcone", "zeabur", "dynadot"):
|
for slug, expected in (
|
||||||
assert snap[slug][0] is None, f"{slug}.sdk_type 未清空:{snap[slug][0]!r}"
|
("cloudcone", "cloudcone-api"),
|
||||||
|
("zeabur", "zeabur-api"),
|
||||||
|
("dynadot", "dynadot-api"),
|
||||||
|
("xinwang", "xinnet-api"),
|
||||||
|
("agnes", "agnes-api"),
|
||||||
|
):
|
||||||
|
assert snap[slug][0] == expected, f"{slug}.sdk_type 未回填:{snap[slug][0]!r}"
|
||||||
|
|
||||||
|
|
||||||
def test_migration_keeps_valid_sdk_type(live_db):
|
def test_migration_keeps_valid_sdk_type(live_db):
|
||||||
@@ -155,10 +170,13 @@ def test_migration_adds_missing_service_tags(live_db):
|
|||||||
|
|
||||||
|
|
||||||
def test_migration_ignores_user_created_providers(live_db):
|
def test_migration_ignores_user_created_providers(live_db):
|
||||||
"""tothost 不在种子里,一个字都不能动"""
|
"""用户自建平台不在种子里,一个字都不能动"""
|
||||||
before = _snapshot(live_db)["tothost"]
|
with Session(live_db) as s:
|
||||||
|
s.add(Provider(slug="myhost", name="MyHost", category="vps", services="vps"))
|
||||||
|
s.commit()
|
||||||
|
before = _snapshot(live_db)["myhost"]
|
||||||
_run_migration(live_db)
|
_run_migration(live_db)
|
||||||
assert _snapshot(live_db)["tothost"] == before
|
assert _snapshot(live_db)["myhost"] == before
|
||||||
|
|
||||||
|
|
||||||
def test_migration_is_idempotent(live_db):
|
def test_migration_is_idempotent(live_db):
|
||||||
|
|||||||
Reference in New Issue
Block a user