feat(multicloud): 补齐上线前的多云管理缺口(适配器/动作层/DNS/定时同步)

评估发现项目只有台账能力,缺失真正的多云管理链路。本次按缺口逐项补齐:

适配层
- 新增 aws / linode / namesilo 适配器,registry 登记;阿里云、腾讯云补
  list_domains 与 get_account 余额
- NormalizedVPS 补 expiry_date + charge_type:此前云端同步进来的 VPS 拿不到
  到期日,永远进不了续费提醒——而续费提醒正是这套台账存在的理由
- base.py 统一云端错误可读化(http_error_message / cloud_error_text):各厂商
  把原因放的位置都不同(Linode errors[].reason、Cloudflare errors[].message、
  腾讯 Response.Error.Message、AWS XML),此前直接 str(e) 会把 httpx 的多行文本
  连同 MDN 链接弹给用户

动作层与 DNS
- 新增 /api/actions:实例启停/重启/续费,写操作全部二次确认 + 审计
- 新增 Cloudflare DNS 记录的增删改查

定时同步
- scripts/sync_all_assets.py + /api/sync/assets + vps-sync-assets.timer(08:30)
- 08:30 而非随机时刻:必须早于 09:00 的续费检查,否则提醒读到上一天的旧到期日
- sync_all_assets 修正计数:每一项子同步都失败时不再计为「成功」,否则凭证过期
  会显示「成功 1」,用户以为数据拉到了、实际一条没有

数据一致性
- AssetRead 输出 external_id(只出不进):前端「云操作」「DNS」按钮靠它渲染,
  缺了则所有按钮永远出不来
- import_assets 落库 external_id:导出带了去重键而导入丢弃,会让迁移后首次同步
  把所有云端资产当新增再建一份
- _align_preset_providers 取代 _backfill_provider_services:库里声明但注册表未
  实现的 sdk_type 清空(声明支持却不支持,比明说不支持更糟),services 只增量
  补标签不覆盖,用户自建平台不动

前端如实展示
- cost 为 0 统一显示「未填」而非「0 USD」:0 既可能是没填、也可能是云端不返回
  价格,而汇总处又把 0 当「无费用」排除,两处口径不一致
- 总览「本年支出」增加 year_cost_missing,把合计的覆盖缺口说出来
- DNS 弹窗补 MX/SRV 优先级与备注;列表请求失败时 provider 用资产自带 slug 兜底,
  否则 Cloudflare 代理勾选框永远不出现

测试:257 -> 309 passed
This commit is contained in:
gouki
2026-10-12 00:43:37 +00:00
parent 192d448a28
commit 46faadbcae
42 changed files with 5570 additions and 99 deletions
+251 -12
View File
@@ -3,8 +3,14 @@
API 文档:https://help.aliyun.com/document_detail/25484.html
所需配置:{"access_key_id": "...", "access_key_secret": "...", "region": "cn-hangzhou"}
国际版(alibabacloud)只需将 region 设为海外区域(如 ap-southeast-1),端点自动按区域构造。
说明:此处实现 RPC V1 签名(HMAC-SHA1),可直接调用 ECS OpenAPI;
生产环境也可替换为官方 alibabacloud-ecs SDK,接口契约保持不变。
RPC V1 签名对所有产品线一致,只有 endpoint 与 Version 不同,故 _call 接受这两个参数:
- ECS ecs.{region}.aliyuncs.com 2014-05-26 实例清单与启停/重启/续费
- Domain domain.aliyuncs.com 2018-01-29 注册域名清单(含到期日)
- BSS business.aliyuncs.com 2017-12-14 账户余额
- Alidns alidns.aliyuncs.com 2015-01-09 解析记录增删改查
DNS 的 zone_id 即域名本身(阿里云不用 zone uuid)。
"""
import base64
@@ -16,19 +22,36 @@ from urllib.parse import quote
import httpx
from app.adapters.base import BaseAdapter, NormalizedVPS
from app.adapters.base import (
AccountInfo,
BaseAdapter,
NormalizedDNSRecord,
NormalizedDomain,
NormalizedVPS,
parse_iso_date,
)
from app.adapters.registry import register
ECS_VERSION = "2014-05-26"
DOMAIN_VERSION = "2018-01-29"
BSS_VERSION = "2017-12-14"
ALIDNS_VERSION = "2015-01-09"
#: ECS InstanceChargeType -> 台账 charge_type
_CHARGE_TYPE_MAP = {"PrePaid": "prepaid", "PostPaid": "postpaid"}
#: ECS Status -> 台账状态
_STATUS_MAP = {"Running": "active", "Stopped": "stopped", "Stopping": "stopped", "Starting": "active"}
@register("aliyun-sdk", "alibabacloud-sdk")
class AliyunAdapter(BaseAdapter):
required_config = ["access_key_id", "access_key_secret"]
API_VERSION = "2014-05-26"
def _region(self) -> str:
return self.config.get("region") or "cn-hangzhou"
def _endpoint(self) -> str:
def _ecs_endpoint(self) -> str:
return f"https://ecs.{self._region()}.aliyuncs.com"
@staticmethod
@@ -45,23 +68,33 @@ class AliyunAdapter(BaseAdapter):
digest = hmac.new(key, string_to_sign.encode(), hashlib.sha1).digest()
return base64.b64encode(digest).decode()
def _call(self, action: str, biz_params: dict = None) -> dict:
def _call(
self,
action: str,
biz_params: dict = None,
endpoint: str = None,
version: str = ECS_VERSION,
with_region: bool = True,
) -> dict:
"""调用任一阿里云 RPC 产品;endpoint 缺省为 ECS 区域端点"""
params = {
"Format": "JSON",
"Version": self.API_VERSION,
"Version": version,
"AccessKeyId": self.config.get("access_key_id", ""),
"SignatureMethod": "HMAC-SHA1",
"Timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"SignatureVersion": "1.0",
"SignatureNonce": str(uuid.uuid4()),
"Action": action,
"RegionId": self._region(),
}
# BSS / Domain / Alidns 是中心化服务,传 RegionId 会被拒或忽略
if with_region:
params["RegionId"] = self._region()
if biz_params:
params.update(biz_params)
params.update({k: v for k, v in biz_params.items() if v is not None})
params["Signature"] = self._sign(params)
with httpx.Client(timeout=30) as client:
resp = client.get(self._endpoint(), params=params)
resp = client.get(endpoint or self._ecs_endpoint(), params=params)
resp.raise_for_status()
data = resp.json()
if data.get("Code"):
@@ -78,6 +111,8 @@ class AliyunAdapter(BaseAdapter):
except Exception as e: # noqa: BLE001
return {"ok": False, "message": str(e)}
# ---------------- 只读清单 ----------------
def list_vps(self) -> list:
# 分页拉取:单次最多 100 条,按 TotalCount 翻页,避免实例超 100 台时漏同步
result = []
@@ -89,7 +124,7 @@ class AliyunAdapter(BaseAdapter):
public_ips = inst.get("PublicIpAddress", {}).get("IpAddress", [])
eip = inst.get("EipAddress", {}).get("IpAddress")
mem_mb = inst.get("Memory") or 0
status_map = {"Running": "active", "Stopped": "stopped"}
charge = inst.get("InstanceChargeType")
result.append(
NormalizedVPS(
external_id=inst.get("InstanceId"),
@@ -99,8 +134,11 @@ class AliyunAdapter(BaseAdapter):
os=inst.get("OSName"),
cpu_cores=inst.get("Cpu"),
memory_gb=round(mem_mb / 1024, 1) if mem_mb else None,
status=status_map.get(inst.get("Status"), inst.get("Status") or "unknown"),
status=_STATUS_MAP.get(inst.get("Status"), inst.get("Status") or "unknown"),
currency="CNY",
# 预付费实例必须带上到期日,否则续费提醒对云端同步的机器完全失效
expiry_date=parse_iso_date(inst.get("ExpiredTime")),
charge_type=_CHARGE_TYPE_MAP.get(charge, charge),
raw=inst,
)
)
@@ -109,3 +147,204 @@ class AliyunAdapter(BaseAdapter):
break
page += 1
return result
def list_domains(self) -> list:
"""注册域名清单(domain.aliyuncs.com QueryDomainList)
与 DNS 托管是两回事:这里取的是「在阿里云注册的域名 + 到期日」,
正是续费提醒需要的数据。Token 无域名权限时抛错由上层捕获,不静默返回空。
"""
result = []
page = 1
while True:
data = self._call(
"QueryDomainList",
{"PageNum": str(page), "PageSize": "100"},
endpoint="https://domain.aliyuncs.com",
version=DOMAIN_VERSION,
with_region=False,
)
domains = data.get("Data", {}).get("Domain", [])
for d in domains:
result.append(
NormalizedDomain(
external_id=d.get("DomainName"),
domain_name=d.get("DomainName"),
registrar="aliyun",
expiry_date=parse_iso_date(d.get("ExpirationDate") or d.get("ExpirationDateLong")),
status="active" if not d.get("Premium") else "active",
raw=d,
)
)
total = int(data.get("TotalItemNum") or data.get("TotalCount") or 0)
if not domains or page * 100 >= total:
break
page += 1
return result
def get_account(self) -> AccountInfo:
"""账户余额(business.aliyuncs.com QueryAccountBalance)"""
data = self._call(
"QueryAccountBalance",
endpoint="https://business.aliyuncs.com",
version=BSS_VERSION,
with_region=False,
)
d = data.get("Data", {})
# AvailableAmount 返回字符串(如 "123.45"),转 float 失败时保持 None
balance = self._to_float(d.get("AvailableAmount"))
return AccountInfo(
balance=balance,
currency=d.get("Currency") or "CNY",
pending_charges=self._to_float(d.get("MybankCreditAmount")),
raw=d,
)
@staticmethod
def _to_float(value):
try:
return float(value)
except (TypeError, ValueError):
return None
# ---------------- 实例管理动作 ----------------
def _action(self, action: str, params: dict, label: str) -> dict:
try:
self._call(action, params)
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._action("StartInstance", {"InstanceId": external_id}, "开机")
def stop_instance(self, external_id: str) -> dict:
# ForceStop=false 走正常关机;强制断电有丢数据风险,不默认开启
return self._action(
"StopInstance", {"InstanceId": external_id, "ForceStop": "false"}, "关机"
)
def reboot_instance(self, external_id: str) -> dict:
return self._action(
"RebootInstance", {"InstanceId": external_id, "ForceStop": "false"}, "重启"
)
def renew_instance(self, external_id: str, period_months: int = 1) -> dict:
# RenewInstance 仅对预付费实例有效,后付费会返回 IncorrectInstanceStatus
return self._action(
"RenewInstance",
{"InstanceId": external_id, "Period": str(period_months)},
f"续费 {period_months} 个月",
)
# ---------------- DNS 记录管理 ----------------
def list_dns_records(self, zone_id: str) -> list:
result = []
page = 1
while True:
data = self._call(
"DescribeDomainRecords",
{"DomainName": zone_id, "PageNumber": str(page), "PageSize": "100"},
endpoint="https://alidns.aliyuncs.com",
version=ALIDNS_VERSION,
with_region=False,
)
records = data.get("DomainRecords", {}).get("Record", [])
for r in records:
result.append(
NormalizedDNSRecord(
record_id=str(r.get("RecordId")),
zone_id=zone_id,
name=self._fqdn(r.get("RR"), zone_id),
type=r.get("Type"),
content=r.get("Value"),
ttl=int(r.get("TTL")) if r.get("TTL") else None,
priority=int(r.get("Priority")) if r.get("Priority") else None,
comment=r.get("Remark"),
raw=r,
)
)
total = int(data.get("TotalCount") or 0)
if not records or page * 100 >= total:
break
page += 1
return result
@staticmethod
def _fqdn(rr: str, zone_id: str) -> str:
"""阿里云存的是主机记录(RR,如 www / @),拼成完整域名供台账展示"""
if not rr or rr == "@":
return zone_id
return f"{rr}.{zone_id}"
@staticmethod
def _rr(name: str, zone_id: str) -> str:
"""反向:把完整域名拆回主机记录"""
if 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:
params = {
"DomainName": zone_id,
"RR": self._rr(record.get("name", ""), zone_id),
"Type": record.get("type", "A"),
"Value": record.get("content", ""),
"TTL": str(record.get("ttl") or 600),
"Priority": str(record["priority"]) if record.get("priority") else None,
}
try:
data = self._call(
"AddDomainRecord",
params,
endpoint="https://alidns.aliyuncs.com",
version=ALIDNS_VERSION,
with_region=False,
)
return {
"ok": True,
"message": "记录已创建",
"record_id": str(data.get("RecordId", "")),
"raw": data,
}
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 = {
"RecordId": record_id,
"RR": self._rr(record.get("name", ""), zone_id),
"Type": record.get("type", "A"),
"Value": record.get("content", ""),
"TTL": str(record.get("ttl") or 600),
"Priority": str(record["priority"]) if record.get("priority") else None,
}
try:
data = self._call(
"UpdateDomainRecord",
params,
endpoint="https://alidns.aliyuncs.com",
version=ALIDNS_VERSION,
with_region=False,
)
return {"ok": True, "message": "记录已更新", "raw": data}
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:
data = self._call(
"DeleteDomainRecord",
{"RecordId": record_id},
endpoint="https://alidns.aliyuncs.com",
version=ALIDNS_VERSION,
with_region=False,
)
return {"ok": True, "message": "记录已删除", "raw": data}
except Exception as e: # noqa: BLE001
return {"ok": False, "message": f"删除失败:{e}"}