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
+282
View File
@@ -0,0 +1,282 @@
"""云资源管理动作服务
把「资产 → 账号 → 平台适配器」解析成一次真实的云端写操作。
与只读同步的关键区别:这里的每个动作都会改变云端真实资源——关机会中断线上服务、
续费会真实扣款。因此三条硬约束:
1. 路由层强制 require_api_key(外部流量无 key 一律 401)
2. 每次动作都写 EventLog(source="action"),成功与失败都记,且用独立 Session 提交,
避免主流程回滚时把审计记录一起丢掉
3. 适配器不支持该动作时如实抛 400,绝不静默 no-op 让调用方以为成功了
"""
import logging
from fastapi import HTTPException, status
from sqlmodel import Session, select
from app.adapters import registry
from app.adapters.base import BaseAdapter, cloud_error_text
from app.core.timeutils import utcnow
from app.models.asset import Asset, AssetType, DomainDetail
from app.services.sync_service import _build_adapter_for_account, _missing_config
logger = logging.getLogger("vps-manager.action")
#: 动作名 -> 适配器方法名
INSTANCE_ACTIONS = {
"start": "start_instance",
"stop": "stop_instance",
"reboot": "reboot_instance",
"renew": "renew_instance",
}
#: 各动作的中文标签,用于审计日志与前端提示
ACTION_LABELS = {
"start": "开机",
"stop": "关机",
"reboot": "重启",
"renew": "续费",
}
def audit(asset_id: int | None, message: str, level: str = "info") -> None:
"""写审计日志(独立 Session,主流程回滚也不丢)
审计失败绝不能让业务动作失败——云端指令可能已经下发成功,
此时抛错会让调用方误判为「没执行」,比丢一条日志危险得多。
"""
from app.database import metrics_engine
from app.models.monitor import EventLog
try:
with Session(metrics_engine) as s:
s.add(EventLog(level=level, source="action", asset_id=asset_id, message=message))
s.commit()
except Exception as e: # noqa: BLE001
logger.warning("审计日志写入失败 asset_id=%s err=%s", asset_id, e)
def _resolve(session: Session, asset_id: int) -> tuple[Asset, BaseAdapter, str]:
"""解析资产对应的适配器,返回 (asset, adapter, provider_label)"""
asset = session.get(Asset, asset_id)
if not asset:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="资产不存在")
if not asset.account_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="该资产未关联账号,无法确定使用哪套云凭证执行动作",
)
from app.models.asset import Account
account = session.get(Account, asset.account_id)
if not account:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="资产关联的账号已不存在"
)
provider, adapter = _build_adapter_for_account(session, account)
missing = _missing_config(adapter)
if missing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"缺少凭证配置:{', '.join(missing)}(请先在账号编辑里填写 API 配置)",
)
return asset, adapter, provider.slug
def _require_capability(adapter: BaseAdapter, method_name: str, label: str) -> None:
if not adapter.capabilities().get(method_name):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"{type(adapter).__name__} 不支持{label}",
)
def perform_instance_action(
session: Session, asset_id: int, action: str, period_months: int = 1
) -> dict:
"""对 VPS 资产执行 开机/关机/重启/续费"""
if action not in INSTANCE_ACTIONS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"未知动作:{action}(可选 {'/'.join(INSTANCE_ACTIONS)})",
)
asset, adapter, provider_slug = _resolve(session, asset_id)
if asset.asset_type != AssetType.VPS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"仅 VPS 资产支持该动作(当前类型:{asset.asset_type.value})",
)
if not asset.external_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="该资产没有云端实例 ID(external_id),可能是手工登记的,无法下发指令",
)
method_name = INSTANCE_ACTIONS[action]
label = ACTION_LABELS[action]
_require_capability(adapter, method_name, label)
target = f"{provider_slug}/{asset.name}({asset.external_id})"
audit(asset_id, f"{label}动作下发:{target}" + (f" 周期={period_months}月" if action == "renew" else ""))
try:
method = getattr(adapter, method_name)
result = method(asset.external_id, period_months) if action == "renew" else method(asset.external_id)
except NotImplementedError:
audit(asset_id, f"{label}失败:适配器未实现 {target}", level="warning")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=f"{provider_slug} 暂不支持{label}"
) from None
except Exception as e: # noqa: BLE001
msg = cloud_error_text(e)
audit(asset_id, f"{label}异常:{target} err={msg}", level="error")
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY, detail=f"{label}失败:{msg}"
) from e
ok = bool(result.get("ok"))
audit(
asset_id,
f"{label}{'成功' if ok else '被拒'}:{target} msg={result.get('message')}",
level="info" if ok else "error",
)
return {
"ok": ok,
"action": action,
"asset_id": asset_id,
"provider": provider_slug,
"external_id": asset.external_id,
"message": result.get("message", ""),
"at": utcnow().isoformat(),
}
def _resolve_zone(session: Session, asset_id: int) -> tuple[Asset, BaseAdapter, str, str]:
"""解析域名资产的 DNS zone 标识,返回 (asset, adapter, provider_slug, zone_id)
zone_id 取 Asset.external_id:Cloudflare 是 zone uuid,阿里云/腾讯云/Vultr/DO 是域名字符串,
两者的 list_dns_records 都已按各自约定实现。
"""
asset, adapter, provider_slug = _resolve(session, asset_id)
if asset.asset_type != AssetType.DOMAIN:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"仅域名资产支持 DNS 记录管理(当前类型:{asset.asset_type.value})",
)
zone_id = asset.external_id
if not zone_id:
detail = session.exec(
select(DomainDetail).where(DomainDetail.asset_id == asset_id)
).first()
zone_id = (detail.domain_name if detail else None) or asset.name
if not zone_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无法确定该域名的 zone 标识(external_id 与 domain_name 均为空)",
)
return asset, adapter, provider_slug, zone_id
def list_dns_records(session: Session, asset_id: int) -> dict:
asset, adapter, provider_slug, zone_id = _resolve_zone(session, asset_id)
_require_capability(adapter, "list_dns_records", "DNS 记录查询")
try:
records = [r.to_dict() for r in adapter.list_dns_records(zone_id)]
except NotImplementedError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=f"{provider_slug} 暂不支持 DNS 记录查询"
) from None
except Exception as e: # noqa: BLE001
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY, detail=f"查询 DNS 记录失败:{cloud_error_text(e)}"
) from e
return {"asset_id": asset_id, "provider": provider_slug, "zone_id": zone_id, "records": records}
def _dns_write(
session: Session,
asset_id: int,
method_name: str,
label: str,
args: tuple,
) -> dict:
asset, adapter, provider_slug, zone_id = _resolve_zone(session, asset_id)
_require_capability(adapter, method_name, label)
target = f"{provider_slug}/{zone_id}"
audit(asset_id, f"DNS {label}下发:{target} args={args[1:]}")
try:
result = getattr(adapter, method_name)(*args)
except NotImplementedError:
audit(asset_id, f"DNS {label}失败:适配器未实现 {target}", level="warning")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=f"{provider_slug} 暂不支持{label}"
) from None
except Exception as e: # noqa: BLE001
msg = cloud_error_text(e)
audit(asset_id, f"DNS {label}异常:{target} err={msg}", level="error")
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY, detail=f"{label}失败:{msg}"
) from e
ok = bool(result.get("ok"))
audit(
asset_id,
f"DNS {label}{'成功' if ok else '被拒'}:{target} msg={result.get('message')}",
level="info" if ok else "error",
)
return {
"ok": ok,
"asset_id": asset_id,
"provider": provider_slug,
"zone_id": zone_id,
"record_id": result.get("record_id"),
"message": result.get("message", ""),
}
def create_dns_record(session: Session, asset_id: int, record: dict) -> dict:
_, _, _, zone_id = _resolve_zone(session, asset_id)
return _dns_write(session, asset_id, "create_dns_record", "记录创建", (zone_id, record))
def update_dns_record(session: Session, asset_id: int, record_id: str, record: dict) -> dict:
_, _, _, zone_id = _resolve_zone(session, asset_id)
return _dns_write(
session, asset_id, "update_dns_record", "记录更新", (zone_id, record_id, record)
)
def delete_dns_record(session: Session, asset_id: int, record_id: str) -> dict:
_, _, _, zone_id = _resolve_zone(session, asset_id)
return _dns_write(
session, asset_id, "delete_dns_record", "记录删除", (zone_id, record_id)
)
def asset_actions(session: Session, asset_id: int) -> dict:
"""返回某资产当前可执行的动作(前端据此渲染/禁用按钮)
手工登记、无 external_id、或适配器不支持的,一律标 False,
避免用户点了才收到 400。
"""
try:
asset, adapter, provider_slug = _resolve(session, asset_id)
except HTTPException as e:
return {"asset_id": asset_id, "available": False, "reason": e.detail, "actions": {}}
caps = adapter.capabilities()
is_vps = asset.asset_type == AssetType.VPS
is_domain = asset.asset_type == AssetType.DOMAIN
has_external = bool(asset.external_id)
actions = {
name: bool(is_vps and has_external and caps.get(method))
for name, method in INSTANCE_ACTIONS.items()
}
actions["dns_records"] = bool(is_domain and caps.get("list_dns_records"))
actions["dns_write"] = bool(is_domain and caps.get("create_dns_record"))
return {
"asset_id": asset_id,
"provider": provider_slug,
"sdk_type": getattr(type(adapter), "sdk_types", []),
"available": any(actions.values()),
"actions": actions,
}
+18 -4
View File
@@ -377,11 +377,15 @@ def import_assets(session: Session, assets_data: list) -> dict:
created = 0
updated = 0
errors = []
skip_fields = {"id", "created_at", "updated_at", "days_to_expiry", "provider_name"}
skip_fields = {"id", "created_at", "updated_at", "days_to_expiry", "provider_name", "account_name"}
try:
for item in assets_data:
try:
payload = {k: v for k, v in item.items() if k not in skip_fields}
# external_id 是 sync_service 的写入字段,AssetCreate/AssetUpdate 不收,
# 留在 payload 里会被 pydantic 静默丢弃;导出既然带了它(迁移用),
# 这里就必须落到行上,否则迁移后首次同步会把所有云端资产当新增再建一份。
external_id = payload.pop("external_id", None)
existing = session.exec(
select(Asset).where(
Asset.name == payload.get("name"),
@@ -390,11 +394,14 @@ def import_assets(session: Session, assets_data: list) -> dict:
)
).first()
if existing:
_update_asset_no_commit(session, existing.id, AssetUpdate(**payload))
asset = _update_asset_no_commit(session, existing.id, AssetUpdate(**payload))
updated += 1
else:
_create_asset_no_commit(session, AssetCreate(**payload))
asset = _create_asset_no_commit(session, AssetCreate(**payload))
created += 1
if external_id and not asset.external_id:
asset.external_id = external_id
session.add(asset)
except Exception as e: # noqa: BLE001
errors.append(f"{item.get('name', '?')}: {e}")
if errors:
@@ -462,6 +469,9 @@ def get_overview(session: Session) -> dict:
overdue = 0
year_cost = 0.0
month_cost = 0.0
# 同窗口内没填费用的资产数。合计只加得到填了的那部分,缺一项就少算一项,
# 界面必须把这个缺口说出来,否则「本年支出」看着像个准数、实际是下限。
year_cost_missing = 0
for asset_type, status_val, expiry_date, cost in rows:
tval = asset_type.value if hasattr(asset_type, "value") else asset_type
@@ -480,7 +490,10 @@ def get_overview(session: Session) -> dict:
elif days <= 30:
expiring_30 += 1
if expiry_date.year == today.year:
year_cost += cost
if cost:
year_cost += cost
else:
year_cost_missing += 1
if expiry_date.month == today.month:
month_cost += cost
@@ -493,6 +506,7 @@ def get_overview(session: Session) -> dict:
"overdue_count": overdue,
"year_cost": round(year_cost, 2),
"month_cost": round(month_cost, 2),
"year_cost_missing": year_cost_missing,
}
+95 -6
View File
@@ -19,7 +19,7 @@ from sqlmodel import Session, select
logger = logging.getLogger("vps-manager.sync")
from app.adapters import registry
from app.adapters.base import BaseAdapter, parse_iso_date
from app.adapters.base import BaseAdapter, cloud_error_text, parse_iso_date
from app.core import crypto
from app.core.timeutils import utcnow
from app.models.asset import (
@@ -161,6 +161,9 @@ def _find_asset(session: Session, provider_id: int, external_id: str, asset_type
def _sync_vps(session: Session, provider: Provider, adapter: BaseAdapter, account_id: Optional[int] = None) -> dict:
created = updated = 0
for vps in adapter.list_vps():
# 预付费实例(阿里云/腾讯云包年包月)带到期日,后付费平台为 None。
# 漏了这一步,云端同步进来的 VPS 永远进不了续费提醒——而那是台账的核心用途。
expiry = parse_iso_date(vps.expiry_date)
existing = _find_asset(session, provider.id, vps.external_id, AssetType.VPS)
if existing:
existing.name = vps.name or existing.name
@@ -168,6 +171,8 @@ def _sync_vps(session: Session, provider: Provider, adapter: BaseAdapter, accoun
if vps.monthly_cost is not None:
existing.cost = vps.monthly_cost
existing.currency = vps.currency
if expiry:
existing.expiry_date = expiry
if account_id:
existing.account_id = account_id
session.add(existing)
@@ -194,6 +199,7 @@ def _sync_vps(session: Session, provider: Provider, adapter: BaseAdapter, accoun
status=_norm_status(vps.status),
cost=vps.monthly_cost or 0,
currency=vps.currency,
expiry_date=expiry,
)
session.add(asset)
session.flush() # 获取 asset.id,统一在循环外提交
@@ -292,18 +298,18 @@ def sync_account(session: Session, account_id: int) -> dict:
# 写库失败会让 Session 进入 pending-rollback 状态,必须先回滚才能继续,
# 否则下面更新 last_synced_at 的 commit 会抛 PendingRollbackError → 接口 500
session.rollback()
result["vps_error"] = str(e)
result["vps_error"] = cloud_error_text(e)
if caps["list_domains"]:
try:
result["domains"] = _sync_domains(session, provider, adapter, account.id)
except Exception as e: # noqa: BLE001
session.rollback()
result["domains_error"] = str(e)
result["domains_error"] = cloud_error_text(e)
if caps["get_account"]:
try:
result["account_info"] = adapter.get_account().to_dict()
except Exception as e: # noqa: BLE001
result["account_info_error"] = str(e)
result["account_info_error"] = cloud_error_text(e)
account.last_synced_at = utcnow()
session.add(account)
@@ -380,7 +386,7 @@ def refresh_ai_balance(session: Session, asset_id: int) -> dict:
raise
except Exception as e: # noqa: BLE001
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY, detail=f"查询余额失败:{e}"
status_code=status.HTTP_502_BAD_GATEWAY, detail=f"查询余额失败:{cloud_error_text(e)}"
) from e
if acc.balance is not None:
@@ -436,7 +442,7 @@ def sync_all_ai_balances(session: Session, max_workers: int = 5) -> dict:
errors.append(f"{name}: {e.detail}")
except Exception as e: # noqa: BLE001
failed += 1
errors.append(f"{name}: {e}")
errors.append(f"{name}: {cloud_error_text(e)}")
return {
"total": len(asset_ids),
"success": success,
@@ -445,3 +451,86 @@ def sync_all_ai_balances(session: Session, max_workers: int = 5) -> dict:
"errors": errors,
"synced_at": utcnow().isoformat(),
}
def sync_all_assets(session: Session, max_workers: int = 5) -> dict:
"""并发同步所有已配凭证账号的云端资产(VPS / 域名)
与 sync_all_ai_balances 同构:单个账号失败不中断整体,每个 worker 用独立
Session(SQLite Session 不能跨线程共享,WAL 模式下多连接读写安全)。
未配凭证或平台无适配器的账号计入 skipped 而非 failed——否则每次定时同步
都会把手工登记的账号报成一堆错误。
"""
from concurrent.futures import ThreadPoolExecutor
from app.database import assets_engine
accounts = session.exec(select(Account)).all()
targets = []
skipped_no_config = []
skipped_no_adapter = []
for acc in accounts:
label = f"{acc.platform}/{acc.name}"
if not acc.api_config_encrypted:
skipped_no_config.append(label)
continue
try:
provider = _find_provider_by_platform(session, acc.platform)
except HTTPException:
skipped_no_adapter.append(f"{label}(平台不存在)")
continue
if not provider.sdk_type or not registry.is_supported(provider.sdk_type):
skipped_no_adapter.append(f"{label}({provider.sdk_type or '未配 sdk_type'})")
continue
targets.append((acc.id, label))
def _sync_one(account_id: int) -> dict:
with Session(assets_engine) as worker_session:
return sync_account(worker_session, account_id)
success = 0
failed = 0
errors = []
created = updated = 0
with ThreadPoolExecutor(max_workers=max_workers) as pool:
futures = {pool.submit(_sync_one, aid): label for aid, label in targets}
for future in futures:
label = futures[future]
try:
result = future.result()
created += (result.get("vps", {}) or {}).get("created", 0)
created += (result.get("domains", {}) or {}).get("created", 0)
updated += (result.get("vps", {}) or {}).get("updated", 0)
updated += (result.get("domains", {}) or {}).get("updated", 0)
# 局部失败(某一类资产同步抛错)不整体算失败,但必须上报
sub_error_keys = [
k for k in ("vps_error", "domains_error", "account_info_error") if result.get(k)
]
for key in sub_error_keys:
errors.append(f"{label} {key}: {result[key]}")
# 但「每一项尝试都失败」不能算成功:凭证过期时三类全 401,
# 若仍计成功,汇总行会显示「成功 1」,用户以为拉到了数据、实际一条没有。
attempted_any = any(result.get(k) is not None for k in ("vps", "domains", "account_info"))
if sub_error_keys and not attempted_any:
failed += 1
else:
success += 1
except HTTPException as e:
failed += 1
errors.append(f"{label}: {e.detail}")
except Exception as e: # noqa: BLE001
failed += 1
errors.append(f"{label}: {cloud_error_text(e)}")
return {
"total": len(targets),
"success": success,
"failed": failed,
"created": created,
"updated": updated,
"skipped_no_config": len(skipped_no_config),
"skipped_no_adapter": len(skipped_no_adapter),
"errors": errors,
"synced_at": utcnow().isoformat(),
}