Files
vps-manager/app/services/action_service.py
T
gouki 46faadbcae 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
2026-10-12 00:43:37 +00:00

283 lines
11 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.
"""云资源管理动作服务
把「资产 → 账号 → 平台适配器」解析成一次真实的云端写操作。
与只读同步的关键区别:这里的每个动作都会改变云端真实资源——关机会中断线上服务、
续费会真实扣款。因此三条硬约束:
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,
}