From 103f48e38bab6574bc09cffd2e53595114bb967f Mon Sep 17 00:00:00 2001 From: gouki Date: Mon, 26 Oct 2026 09:16:45 +0000 Subject: [PATCH] =?UTF-8?q?feat(adapters):=20=E6=8E=A5=E5=85=A5=20CloudCon?= =?UTF-8?q?e/Zeabur/Dynadot/=E6=96=B0=E7=BD=91/Agnes=20=E4=BA=94=E4=B8=AA?= =?UTF-8?q?=E5=B9=B3=E5=8F=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 线上平台列表里 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。 --- VERSION | 2 +- app/adapters/ai.py | 25 +++ app/adapters/base.py | 2 + app/adapters/cloudcone.py | 107 ++++++++++ app/adapters/dynadot.py | 127 ++++++++++++ app/adapters/registry.py | 5 + app/adapters/xinnet.py | 122 ++++++++++++ app/adapters/zeabur.py | 119 +++++++++++ app/core/seed.py | 15 +- app/database.py | 17 +- app/services/sync_service.py | 1 + static/js/modals.js | 15 +- tests/test_platform_adapters.py | 325 +++++++++++++++++++++++++++++++ tests/test_provider_alignment.py | 42 ++-- 14 files changed, 892 insertions(+), 32 deletions(-) create mode 100644 app/adapters/cloudcone.py create mode 100644 app/adapters/dynadot.py create mode 100644 app/adapters/xinnet.py create mode 100644 app/adapters/zeabur.py create mode 100644 tests/test_platform_adapters.py diff --git a/VERSION b/VERSION index 4b9fcbe..a918a2a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.1 +0.6.0 diff --git a/app/adapters/ai.py b/app/adapters/ai.py index c97b5b7..6c7d3d2 100644 --- a/app/adapters/ai.py +++ b/app/adapters/ai.py @@ -103,6 +103,31 @@ class OpenAIAdapter(_AIBase): # 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") class MinimaxAdapter(_AIBase): """Minimax 适配器(余额查询) diff --git a/app/adapters/base.py b/app/adapters/base.py index 05b774c..5d18770 100644 --- a/app/adapters/base.py +++ b/app/adapters/base.py @@ -228,6 +228,8 @@ class BaseAdapter(ABC): #: 适配器所需的配置字段名(如 ["api_key"] 或 ["access_key_id", "access_key_secret"]) required_config: list = [] + #: required_config 中不属于密钥的字段(前端按明文输入,避免代理号/域名被遮住) + plain_config: list = [] def __init__(self, config: Optional[dict] = None): self.config = config or {} diff --git a/app/adapters/cloudcone.py b/app/adapters/cloudcone.py new file mode 100644 index 0000000..831a035 --- /dev/null +++ b/app/adapters/cloudcone.py @@ -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", "重启") diff --git a/app/adapters/dynadot.py b/app/adapters/dynadot.py new file mode 100644 index 0000000..8fdf344 --- /dev/null +++ b/app/adapters/dynadot.py @@ -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, + ) diff --git a/app/adapters/registry.py b/app/adapters/registry.py index 8faf0a9..fb27ca0 100644 --- a/app/adapters/registry.py +++ b/app/adapters/registry.py @@ -29,13 +29,17 @@ def load_all() -> None: ai, aliyun, aws, + cloudcone, cloudflare, digitalocean, + dynadot, linode, namesilo, tencent, vultr, whmcs, + xinnet, + zeabur, ) @@ -84,6 +88,7 @@ def adapter_meta() -> list: "class": cls.__name__, "sdk_types": getattr(cls, "sdk_types", [sdk_type]), "required_config": getattr(cls, "required_config", []), + "plain_config": getattr(cls, "plain_config", []), } ) return result diff --git a/app/adapters/xinnet.py b/app/adapters/xinnet.py new file mode 100644 index 0000000..fc451ab --- /dev/null +++ b/app/adapters/xinnet.py @@ -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 diff --git a/app/adapters/zeabur.py b/app/adapters/zeabur.py new file mode 100644 index 0000000..4d1324a --- /dev/null +++ b/app/adapters/zeabur.py @@ -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}"} diff --git a/app/core/seed.py b/app/core/seed.py index d385de7..f525403 100644 --- a/app/core/seed.py +++ b/app/core/seed.py @@ -39,25 +39,24 @@ PRESET_PROVIDERS = [ {"slug": "tothost", "name": "TotHost", "name_en": "TotHost", "category": "vps", "services": "vps", "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", "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", "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", "services": "domain,dns", "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", "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", "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 服务商 ---- {"slug": "openai", "name": "OpenAI", "name_en": "OpenAI", "category": "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"}, {"slug": "agnes", "name": "Agnes", "name_en": "Agnes", "category": "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", "services": "ai_agent", "website": "https://deepseek.com", "console_url": "https://platform.deepseek.com", "sdk_type": "deepseek-api"}, diff --git a/app/database.py b/app/database.py index a32e8ec..22265a2 100644 --- a/app/database.py +++ b/app/database.py @@ -189,13 +189,13 @@ def _align_preset_providers(conn) -> None: (新增适配器、废弃从未实现的 sdk_type)必须在这里落地,否则线上会长期 停留在旧声明上。 - - sdk_type:库里声明的值不在适配器注册表内就清空。留着会让平台卡片显示 - 「API 对接: xxx」,用户建好账号点同步才撞「暂不支持的 SDK 类型」—— - 声明支持却不支持,比明说不支持更糟。 + - sdk_type:库里声明了但注册表没有的值要清空(声明支持却不支持,比明说不支持 + 更糟);库里为空而种子已给出且已实现的要补上——否则新增适配器上线后, + 线上平台卡片仍停留在「未对接」,用户永远点不到同步。 - services:只增量补标签,不删除。用户可能手动加过标签(线上 aliyun-intl 就多了 ai_agent/other),按种子值整体覆盖会丢用户配置。 - 只处理 slug 命中 PRESET_PROVIDERS 的行,用户自建平台(如 tothost)不动。 + 只处理 slug 命中 PRESET_PROVIDERS 的行,用户自建平台不动。 """ import sqlalchemy as sa from app.adapters import registry @@ -213,12 +213,19 @@ def _align_preset_providers(conn) -> None: if not data: continue - declared = row[2] + declared = (row[2] or "").strip() if declared and declared not in supported: conn.execute( sa.text("UPDATE providers SET sdk_type = NULL WHERE id = :id"), {"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()] if not seed_services: diff --git a/app/services/sync_service.py b/app/services/sync_service.py index dff9912..d65bf6e 100644 --- a/app/services/sync_service.py +++ b/app/services/sync_service.py @@ -395,6 +395,7 @@ _AI_SDK_MAP = { "kimi": "moonshot-api", "moonshot": "moonshot-api", "minimax": "minimax-api", + "agnes": "agnes-api", } diff --git a/static/js/modals.js b/static/js/modals.js index 5a36107..a04a0c5 100644 --- a/static/js/modals.js +++ b/static/js/modals.js @@ -226,7 +226,7 @@ const AccountModal = {
API 配置(供 SDK 同步,加密存储)
+

修改账号标识后,引用该账号的资产会自动同步更新。

@@ -244,12 +244,15 @@ const AccountModal = { try { const r = await Api.get('/providers/adapters'); adapterMeta.value = r.adapters || []; } catch (e) { /* ignore */ } }); // 按账号所属平台的 sdk_type 动态渲染所需凭证字段 - const currentFields = Vue.computed(() => { + const currentAdapter = Vue.computed(() => { const p = store.providers.find(x => x.slug === (f.value && f.value.platform)); - if (!p || !p.sdk_type) return []; - const m = adapterMeta.value.find(x => (x.sdk_types || []).includes(p.sdk_type)); - return m ? (m.required_config || []) : []; + if (!p || !p.sdk_type) return null; + return adapterMeta.value.find(x => (x.sdk_types || []).includes(p.sdk_type)) || null; }); + 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) => { 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) : ''; saveAccount(); } - return { store, f, currentFields, apiFields, save }; + return { store, f, currentFields, apiFields, isPlain, save }; }, }; diff --git a/tests/test_platform_adapters.py b/tests/test_platform_adapters.py new file mode 100644 index 0000000..2c03fd4 --- /dev/null +++ b/tests/test_platform_adapters.py @@ -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"} diff --git a/tests/test_provider_alignment.py b/tests/test_provider_alignment.py index b7405f7..ef1c7f8 100644 --- a/tests/test_provider_alignment.py +++ b/tests/test_provider_alignment.py @@ -26,8 +26,9 @@ from app.core.seed import PRESET_PROVIDERS, seed_providers from app.database import _align_preset_providers from app.models.provider import Provider -#: 线上 GET /api/providers 抓下来的真实状态(2026-10-11), -#: 用它做 fixture 才能保证迁移验证的是生产形态而不是理想形态 +#: 线上 GET /api/providers 抓下来的真实状态(2026-10-26), +#: 用它做 fixture 才能保证迁移验证的是生产形态而不是理想形态。 +#: cloudcone/zeabur/dynadot/xinwang/agnes 上线前都是 None,靠启动迁移补 sdk_type。 LIVE_PROVIDERS = [ ("agnes", None, "ai_agent"), ("deepseek", "deepseek-api", "ai_agent"), @@ -35,19 +36,19 @@ LIVE_PROVIDERS = [ ("minimax", "minimax-api", "ai_agent"), ("openai", "openai-api", "ai_agent"), ("cloudflare", "cloudflare-api", "cloudflare,dns,ssl_cert,cdn"), - ("dynadot", "dynadot-api", "domain,dns"), + ("dynadot", None, "domain,dns"), ("namesilo", "namesilo-api", "domain,dns"), ("xinwang", None, "domain,dns"), ("aliyun", "aliyun-sdk", "vps,domain,ssl_cert,cdn,dns"), ("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"), ("linode", "linode-api", "vps"), ("tencent", "tencent-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"), - ("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): - """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) snap = _snapshot(live_db) - for slug in ("cloudcone", "zeabur", "dynadot"): - assert snap[slug][0] is None, f"{slug}.sdk_type 未清空:{snap[slug][0]!r}" + for slug, expected in ( + ("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): @@ -155,10 +170,13 @@ def test_migration_adds_missing_service_tags(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) - assert _snapshot(live_db)["tothost"] == before + assert _snapshot(live_db)["myhost"] == before def test_migration_is_idempotent(live_db):