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:
@@ -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(),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user