Files
vps-manager/app/adapters/namesilo.py
T
gouki 9e358fed6b fix(adapters): 补齐国际版 region 填写与 NameSilo 余额
审计发现两处「已声明但实际用不了」的缺口:

- 阿里云/腾讯云国际版与国内版共用适配器,region 只能靠代码默认值(cn-hangzhou /
  ap-guangzhou),账号弹窗里无处填写,海外账号查不到资源。新增 optional_config
  机制,region 按可选明文渲染;留空仍走原默认值,不阻塞保存与同步。
- NameSilo 有 getAccountBalance 接口但未实现,平台卡片不显示余额。补充余额
  与未出账消费(available_balance / unbilled_amount 分开报告)。

顺带修掉一个会丢凭证的隐患:编辑账号时前端只提交填写过的字段,后端原先整段
覆盖 api_config,只补 region 会把已存 AK/SK 抹掉;改为与已存 JSON 合并。

测试:449 passed(新增合并、余额、optional_config 用例),vitest 62 passed。
2026-10-26 12:05:48 +00:00

188 lines
7.3 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.
"""Namesilo 适配器(REST API v1,API Key 查询参数)
API 文档:https://www.namesilo.com/api_reference.php
所需配置:{"api_key": "..."}
覆盖:注册域名清单(含到期日)、DNS 记录增删改查。
Namesilo 是纯域名注册商,无 VPS,故不实现 list_vps / 实例动作。
响应约定:所有命令都返回 {"request": ..., "reply": {...}},
reply.code == 300 才是成功,其余(如 301 未授权、400 参数错)需读 reply.detail。
"""
import httpx
from app.adapters.base import (
AccountInfo,
BaseAdapter,
NormalizedDNSRecord,
NormalizedDomain,
parse_iso_date,
)
from app.adapters.registry import register
SUCCESS_CODE = 300
def _to_float(value):
try:
return float(str(value).replace(",", ""))
except (TypeError, ValueError):
return None
@register("namesilo-api")
class NamesiloAdapter(BaseAdapter):
required_config = ["api_key"]
BASE = "https://www.namesilo.com/api"
def _call(self, command: str, params: dict = None) -> dict:
query = {"version": "1", "type": "json", "key": self.config.get("api_key", "")}
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(f"{self.BASE}/{command}", params=query)
resp.raise_for_status()
data = resp.json()
reply = data.get("reply", {}) or {}
code = reply.get("code")
# Namesilo 的 code 可能是 int 也可能是 str,统一按 int 比较
try:
code = int(code)
except (TypeError, ValueError):
code = None
if code != SUCCESS_CODE:
raise RuntimeError(f"Namesilo {command} 失败(code={reply.get('code')}):{reply.get('detail')}")
return reply
def test_connection(self) -> dict:
try:
reply = self._call("checkApiKey")
return {
"ok": True,
"message": f"连接成功,令牌属于 {reply.get('username') or '未知账户'}",
}
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 get_account(self) -> AccountInfo:
"""账户余额(getAccountBalance)
available_balance 是可用余额,unbilled_amount 是尚未出账的消费,
两者分开返回,不能合并成一个数字——前者可花,后者会被扣走。
"""
reply = self._call("getAccountBalance")
return AccountInfo(
balance=_to_float(reply.get("available_balance")),
currency="USD",
pending_charges=_to_float(reply.get("unbilled_amount")),
raw=reply,
)
# ---------------- 只读清单 ----------------
def list_domains(self) -> list:
reply = self._call("listDomains")
result = []
for d in reply.get("domains", []) or []:
result.append(
NormalizedDomain(
external_id=d.get("domain"),
domain_name=d.get("domain"),
registrar="namesilo",
# expires 形如 "2027-01-01";Namesilo 也可能给 "Jan 01, 2027",解析失败归 None
expiry_date=parse_iso_date(d.get("expires")),
status="active",
raw=d,
)
)
return result
# ---------------- DNS 记录管理 ----------------
# zone_id 即域名字符串;record_id 用 Namesilo 的 record_id
def list_dns_records(self, zone_id: str) -> list:
reply = self._call("listDnsRecords", {"domain": zone_id})
result = []
for r in reply.get("dns_records", reply.get("resource_record", [])) or []:
host = r.get("host") or ""
result.append(
NormalizedDNSRecord(
record_id=str(r.get("record_id") or r.get("id") or ""),
zone_id=zone_id,
name=self._fqdn(host, zone_id),
type=r.get("type"),
content=r.get("value"),
ttl=int(r["ttl"]) if r.get("ttl") else None,
priority=int(r["distance"]) if r.get("distance") else None,
raw=r,
)
)
return result
@staticmethod
def _fqdn(host: str, zone_id: str) -> str:
"""Namesilo 的 host 是子域(空或 @ 表示裸域),拼成完整域名供台账展示"""
if not host or host == "@":
return zone_id
return f"{host}.{zone_id}"
@staticmethod
def _host(name: str, zone_id: str) -> str:
"""反向:把完整域名拆回子域(裸域用空串)"""
if not name or name == zone_id:
return ""
suffix = f".{zone_id}"
return name[: -len(suffix)] if name.endswith(suffix) else name
def create_dns_record(self, zone_id: str, record: dict) -> dict:
# Namesilo 用 dnsRecordUpdate 同时承担新增(不带 record_id)与修改(带 record_id)
params = {
"domain": zone_id,
"rr_type": record.get("type", "A"),
"rr_host": self._host(record.get("name", ""), zone_id),
"rr_value": record.get("content", ""),
"rr_ttl": record.get("ttl") or 3600,
}
if record.get("priority") is not None:
params["rr_distance"] = record["priority"]
try:
self._call("dnsRecordUpdate", params)
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}"}
def update_dns_record(self, zone_id: str, record_id: str, record: dict) -> dict:
params = {
"domain": zone_id,
"rr_id": record_id,
"rr_type": record.get("type", "A"),
"rr_host": self._host(record.get("name", ""), zone_id),
"rr_value": record.get("content", ""),
"rr_ttl": record.get("ttl") or 3600,
}
if record.get("priority") is not None:
params["rr_distance"] = record["priority"]
try:
self._call("dnsRecordUpdate", params)
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}"}
def delete_dns_record(self, zone_id: str, record_id: str) -> dict:
try:
self._call("dnsRecordDelete", {"domain": zone_id, "rr_id": record_id})
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}"}