审计发现两处「已声明但实际用不了」的缺口: - 阿里云/腾讯云国际版与国内版共用适配器,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。
291 lines
11 KiB
Python
291 lines
11 KiB
Python
"""云厂商 SDK 适配层 — 抽象基类与标准化数据结构
|
||
|
||
各云厂商适配器继承 BaseAdapter,实现统一接口,将平台特定的 API 返回
|
||
转换为标准化的资产结构(NormalizedVPS / NormalizedDomain / AccountInfo),
|
||
供同步服务写入数据库。这样上层逻辑无需关心各平台 API 差异。
|
||
"""
|
||
|
||
import re
|
||
from abc import ABC, abstractmethod
|
||
from dataclasses import asdict, dataclass, field
|
||
from datetime import date, datetime
|
||
from typing import Any, Optional
|
||
|
||
import httpx
|
||
|
||
|
||
def parse_iso_date(value: Any) -> Optional[date]:
|
||
"""把到期日归一化为 date 对象(接受 date / datetime / ISO 字符串,解析失败返回 None)
|
||
|
||
各厂商返回形态不一('2027-03-15'、'2027-03-15T00:00:00Z'、'2027-03-15T00:00:00+00:00')。
|
||
必须统一成 date 再交给上层:expiry_date 是 SQLAlchemy Date 列,
|
||
直接塞字符串会在写库时抛 TypeError,导致整批同步在循环末尾的 commit 上全量失败。
|
||
"""
|
||
if value is None:
|
||
return None
|
||
if isinstance(value, datetime):
|
||
return value.date()
|
||
if isinstance(value, date):
|
||
return value
|
||
text = str(value).strip()
|
||
if not text:
|
||
return None
|
||
try:
|
||
return date.fromisoformat(text[:10])
|
||
except ValueError:
|
||
return None
|
||
|
||
|
||
# 状态码 -> 排查提示。厂商 body 有时只给一个 code 不给原因(AWS 的 403、
|
||
# Vultr 的 401 就常这样),这时至少告诉用户往哪个方向查。
|
||
_STATUS_HINTS = {
|
||
401: "凭证无效或已过期",
|
||
403: "凭证权限不足",
|
||
404: "资源不存在(实例 ID 或域名可能已变更)",
|
||
429: "触发平台限流,请稍后重试",
|
||
}
|
||
|
||
# 厂商放错误原因的键名(各家不统一,按此顺序找第一个可读字符串)
|
||
_ERROR_KEYS = ("message", "msg", "reason", "error", "errormsg", "detail", "description")
|
||
|
||
|
||
def _scan_error_text(node: Any, depth: int = 0) -> Optional[str]:
|
||
"""在嵌套 JSON 里递归找第一个像错误原因的字符串"""
|
||
if depth > 4:
|
||
return None
|
||
if isinstance(node, dict):
|
||
for key in _ERROR_KEYS:
|
||
for k, v in node.items():
|
||
if k.lower() == key and isinstance(v, str) and v.strip():
|
||
return v.strip()
|
||
for v in node.values():
|
||
found = _scan_error_text(v, depth + 1)
|
||
if found:
|
||
return found
|
||
elif isinstance(node, list):
|
||
for v in node:
|
||
found = _scan_error_text(v, depth + 1)
|
||
if found:
|
||
return found
|
||
return None
|
||
|
||
|
||
def http_error_message(e: httpx.HTTPStatusError) -> str:
|
||
"""把云厂商的 HTTP 错误压成一句人话
|
||
|
||
直接 str(e) 拿到的是 httpx 的多行文本,末尾还附一段 MDN 链接,
|
||
显示在弹窗里既难看也没法排查。各厂商把原因放的位置又都不一样
|
||
(Linode 在 body.errors[0].reason、Cloudflare 在 body.errors[0].message、
|
||
腾讯云在 Response.Error.Message、AWS 在 XML 里),所以统一在这里提取。
|
||
"""
|
||
resp = e.response
|
||
code = resp.status_code
|
||
detail = None
|
||
try:
|
||
detail = _scan_error_text(resp.json())
|
||
except Exception: # noqa: BLE001
|
||
# AWS 等返回 XML,退化成抓 <Message>
|
||
found = re.search(r"<Message>(.*?)</Message>", resp.text or "", re.S)
|
||
if found:
|
||
detail = found.group(1).strip()
|
||
parts = [f"HTTP {code}"]
|
||
if detail:
|
||
parts.append(detail[:200])
|
||
hint = _STATUS_HINTS.get(code)
|
||
if hint and (not detail or hint not in detail):
|
||
parts.append(hint)
|
||
return ":".join(parts)
|
||
|
||
|
||
def cloud_error_text(e: Exception) -> str:
|
||
"""云端异常 -> 一句可排查的话
|
||
|
||
HTTP 错误交给 http_error_message 提取厂商 body 里的原因;
|
||
其余(超时、DNS 解析失败、连接被重置)本身就短,直接用 str(e)。
|
||
"""
|
||
if isinstance(e, httpx.HTTPStatusError):
|
||
return http_error_message(e)
|
||
return str(e)
|
||
|
||
|
||
@dataclass
|
||
class NormalizedVPS:
|
||
"""标准化的 VPS 实例"""
|
||
|
||
external_id: str
|
||
name: str
|
||
ip_address: Optional[str] = None
|
||
region: Optional[str] = None
|
||
os: Optional[str] = None
|
||
cpu_cores: Optional[int] = None
|
||
memory_gb: Optional[float] = None
|
||
disk_gb: Optional[int] = None
|
||
status: str = "active"
|
||
#: 月度费用。只有 Vultr / DO 的列表接口直接返回价格,其余厂商(阿里云、腾讯云、
|
||
#: AWS、Linode)的 DescribeInstances 一律没有价格字段——要拿到得另调计价 API,
|
||
#: 而那是挂牌价,不等于用户实付(折扣、备份、IPv4、额外硬盘都会改变金额)。
|
||
#: 台账里 Asset.cost 的语义是「单次续费费用」,属于用户自己维护的账目值;
|
||
#: 用一个看似合理的挂牌价去覆盖它,比留空更糟。所以这里刻意留 None,
|
||
#: 前端把 cost=0 显示为「未填」而非「0 USD」,总览页则报出未填项数。
|
||
monthly_cost: Optional[float] = None
|
||
currency: str = "USD"
|
||
#: 预付费实例的到期日;后付费(按量/按小时)平台恒为 None。
|
||
#: 缺了它,云端同步进来的 VPS 永远进不了续费提醒——这是台账的核心用途。
|
||
expiry_date: Optional[date] = None
|
||
#: 计费方式,用于前端区分「预付费会到期」与「后付费不会到期」
|
||
charge_type: Optional[str] = None
|
||
raw: dict = field(default_factory=dict)
|
||
|
||
def to_dict(self) -> dict:
|
||
d = asdict(self)
|
||
if d.get("expiry_date") is not None:
|
||
d["expiry_date"] = d["expiry_date"].isoformat()
|
||
return d
|
||
|
||
|
||
@dataclass
|
||
class NormalizedDNSRecord:
|
||
"""标准化的 DNS 解析记录"""
|
||
|
||
record_id: str
|
||
zone_id: str
|
||
name: str
|
||
type: str
|
||
content: str
|
||
ttl: Optional[int] = None
|
||
proxied: Optional[bool] = None
|
||
priority: Optional[int] = None
|
||
comment: Optional[str] = None
|
||
raw: dict = field(default_factory=dict)
|
||
|
||
def to_dict(self) -> dict:
|
||
return asdict(self)
|
||
|
||
|
||
@dataclass
|
||
class NormalizedDomain:
|
||
"""标准化的域名"""
|
||
|
||
external_id: str
|
||
domain_name: str
|
||
registrar: Optional[str] = None
|
||
expiry_date: Optional[date] = None # 到期日 date;厂商原始值请过 parse_iso_date
|
||
status: str = "active"
|
||
raw: dict = field(default_factory=dict)
|
||
|
||
def to_dict(self) -> dict:
|
||
d = asdict(self)
|
||
# date 不可直接 JSON 序列化,转回 ISO 字符串(对外形态与旧契约一致)
|
||
if d.get("expiry_date") is not None:
|
||
d["expiry_date"] = d["expiry_date"].isoformat()
|
||
return d
|
||
|
||
|
||
@dataclass
|
||
class AccountInfo:
|
||
"""标准化的账户信息"""
|
||
|
||
balance: Optional[float] = None
|
||
currency: str = "USD"
|
||
pending_charges: Optional[float] = None
|
||
raw: dict = field(default_factory=dict)
|
||
|
||
def to_dict(self) -> dict:
|
||
return asdict(self)
|
||
|
||
|
||
def _overridden(instance: "BaseAdapter", method_name: str) -> bool:
|
||
"""判断子类是否重写了某方法(用于能力探测)
|
||
|
||
比较按 MRO 解析到的实现与 BaseAdapter 的默认实现:只查 type(instance).__dict__
|
||
的话,由中间层基类(如 ai._AIBase)实现的能力会被误判为「未实现」。
|
||
"""
|
||
impl = getattr(type(instance), method_name, None)
|
||
return impl is not None and impl is not getattr(BaseAdapter, method_name, None)
|
||
|
||
|
||
#: 只读能力(同步用)
|
||
READ_CAPABILITIES = ("list_vps", "list_domains", "get_account")
|
||
#: 实例管理动作(写操作,前端须二次确认)
|
||
INSTANCE_ACTIONS = ("start_instance", "stop_instance", "reboot_instance", "renew_instance")
|
||
#: DNS 记录管理动作(写操作)
|
||
DNS_ACTIONS = ("list_dns_records", "create_dns_record", "update_dns_record", "delete_dns_record")
|
||
|
||
|
||
class BaseAdapter(ABC):
|
||
"""云厂商适配器抽象基类
|
||
|
||
子类需:
|
||
- 通过 @register("xxx-sdk") 注册到注册表
|
||
- 设置 required_config(所需凭证字段,供前端表单提示)
|
||
- 实现 test_connection;按需重写只读能力与动作方法
|
||
|
||
能力分三层,全部通过 capabilities() 如实上报,前端据此决定按钮可用性:
|
||
1. 只读清单:list_vps / list_domains / get_account
|
||
2. 实例动作:start / stop / reboot / renew_instance(写操作)
|
||
3. DNS 动作:list / create / update / delete_dns_record(写操作)
|
||
"""
|
||
|
||
#: 适配器所需的配置字段名(如 ["api_key"] 或 ["access_key_id", "access_key_secret"])
|
||
required_config: list = []
|
||
#: 可选配置字段(如区域),缺省时适配器用内置默认值;不填不阻塞保存/同步
|
||
optional_config: list = []
|
||
#: required_config 中不属于密钥的字段(前端按明文输入,避免代理号/域名被遮住)
|
||
plain_config: list = []
|
||
|
||
def __init__(self, config: Optional[dict] = None):
|
||
self.config = config or {}
|
||
|
||
@abstractmethod
|
||
def test_connection(self) -> dict:
|
||
"""测试连接 / 凭证有效性,返回 {"ok": bool, "message": str, ...}"""
|
||
|
||
# ---------------- 只读清单 ----------------
|
||
|
||
def list_vps(self) -> list:
|
||
raise NotImplementedError(f"{type(self).__name__} 未实现 list_vps")
|
||
|
||
def list_domains(self) -> list:
|
||
raise NotImplementedError(f"{type(self).__name__} 未实现 list_domains")
|
||
|
||
def get_account(self) -> AccountInfo:
|
||
raise NotImplementedError(f"{type(self).__name__} 未实现 get_account")
|
||
|
||
# ---------------- 实例管理动作 ----------------
|
||
# 统一返回 {"ok": bool, "message": str, "raw": Any},与 test_connection 同构,
|
||
# 让路由层不必区分「厂商返回成功」与「适配器不支持」。
|
||
|
||
def start_instance(self, external_id: str) -> dict:
|
||
raise NotImplementedError(f"{type(self).__name__} 未实现 start_instance")
|
||
|
||
def stop_instance(self, external_id: str) -> dict:
|
||
raise NotImplementedError(f"{type(self).__name__} 未实现 stop_instance")
|
||
|
||
def reboot_instance(self, external_id: str) -> dict:
|
||
raise NotImplementedError(f"{type(self).__name__} 未实现 reboot_instance")
|
||
|
||
def renew_instance(self, external_id: str, period_months: int = 1) -> dict:
|
||
"""续费预付费实例(后付费平台无此概念,保持未实现即可)"""
|
||
raise NotImplementedError(f"{type(self).__name__} 未实现 renew_instance")
|
||
|
||
# ---------------- DNS 记录管理 ----------------
|
||
|
||
def list_dns_records(self, zone_id: str) -> list:
|
||
raise NotImplementedError(f"{type(self).__name__} 未实现 list_dns_records")
|
||
|
||
def create_dns_record(self, zone_id: str, record: dict) -> dict:
|
||
raise NotImplementedError(f"{type(self).__name__} 未实现 create_dns_record")
|
||
|
||
def update_dns_record(self, zone_id: str, record_id: str, record: dict) -> dict:
|
||
raise NotImplementedError(f"{type(self).__name__} 未实现 update_dns_record")
|
||
|
||
def delete_dns_record(self, zone_id: str, record_id: str) -> dict:
|
||
raise NotImplementedError(f"{type(self).__name__} 未实现 delete_dns_record")
|
||
|
||
# ---------------- 能力探测 ----------------
|
||
|
||
def capabilities(self) -> dict:
|
||
"""返回适配器实际支持的能力(是否重写了相应方法)"""
|
||
names = READ_CAPABILITIES + INSTANCE_ACTIONS + DNS_ACTIONS
|
||
return {name: _overridden(self, name) for name in names}
|