Files
vps-manager/app/adapters/dynadot.py
T
gouki 103f48e38b 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。
2026-10-26 09:16:45 +00:00

128 lines
4.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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,
)