feat: 云厂商SDK适配层(Vultr/DO/Cloudflare/阿里云/腾讯云)+ 同步服务 + 测试连接/同步接口与前端
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
"""云厂商同步服务
|
||||
|
||||
将适配器返回的标准化资产(NormalizedVPS / NormalizedDomain / AccountInfo)
|
||||
写入或更新到资产库。以 (provider_id, external_id) 作为去重键,
|
||||
已存在则更新状态/详情,不存在则新建资产。
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.adapters import registry
|
||||
from app.adapters.base import BaseAdapter
|
||||
from app.core import crypto
|
||||
from app.models.asset import (
|
||||
Asset,
|
||||
AssetStatus,
|
||||
AssetType,
|
||||
DomainDetail,
|
||||
VPSDetail,
|
||||
)
|
||||
from app.models.provider import Provider
|
||||
|
||||
_VALID_STATUS = {s.value for s in AssetStatus}
|
||||
|
||||
|
||||
def _load_config(provider: Provider) -> dict:
|
||||
"""解密平台的 API 配置 JSON"""
|
||||
plain = crypto.decrypt(provider.api_config_encrypted)
|
||||
if not plain:
|
||||
return {}
|
||||
try:
|
||||
return json.loads(plain)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return {}
|
||||
|
||||
|
||||
def _get_provider(session: Session, provider_id: int) -> Provider:
|
||||
provider = session.get(Provider, provider_id)
|
||||
if not provider:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="平台不存在")
|
||||
return provider
|
||||
|
||||
|
||||
def _build_adapter(provider: Provider) -> BaseAdapter:
|
||||
if not provider.sdk_type:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="该平台未配置 SDK 类型(sdk_type)"
|
||||
)
|
||||
if not registry.is_supported(provider.sdk_type):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"暂不支持的 SDK 类型:{provider.sdk_type}",
|
||||
)
|
||||
return registry.get_adapter(provider.sdk_type, _load_config(provider))
|
||||
|
||||
|
||||
def _norm_status(raw: Optional[str]) -> AssetStatus:
|
||||
return AssetStatus(raw) if raw in _VALID_STATUS else AssetStatus.UNKNOWN
|
||||
|
||||
|
||||
def _missing_config(adapter: BaseAdapter) -> list:
|
||||
"""检查适配器所需凭证是否已配置"""
|
||||
return [f for f in adapter.required_config if not adapter.config.get(f)]
|
||||
|
||||
|
||||
def test_provider(session: Session, provider_id: int) -> dict:
|
||||
"""测试平台连接 / 凭证有效性"""
|
||||
provider = _get_provider(session, provider_id)
|
||||
adapter = _build_adapter(provider)
|
||||
base = {"capabilities": adapter.capabilities(), "sdk_type": provider.sdk_type}
|
||||
missing = _missing_config(adapter)
|
||||
if missing:
|
||||
return {
|
||||
"ok": False,
|
||||
"message": f"缺少凭证配置:{', '.join(missing)}(请在平台编辑里填写 API 配置)",
|
||||
**base,
|
||||
}
|
||||
result = adapter.test_connection()
|
||||
result.update(base)
|
||||
return result
|
||||
|
||||
|
||||
def _find_asset(session: Session, provider_id: int, external_id: str, asset_type: AssetType):
|
||||
return session.exec(
|
||||
select(Asset).where(
|
||||
Asset.provider_id == provider_id,
|
||||
Asset.external_id == external_id,
|
||||
Asset.asset_type == asset_type,
|
||||
)
|
||||
).first()
|
||||
|
||||
|
||||
def _sync_vps(session: Session, provider: Provider, adapter: BaseAdapter) -> dict:
|
||||
created = updated = 0
|
||||
for vps in adapter.list_vps():
|
||||
existing = _find_asset(session, provider.id, vps.external_id, AssetType.VPS)
|
||||
if existing:
|
||||
existing.name = vps.name or existing.name
|
||||
existing.status = _norm_status(vps.status)
|
||||
if vps.monthly_cost is not None:
|
||||
existing.cost = vps.monthly_cost
|
||||
existing.currency = vps.currency
|
||||
session.add(existing)
|
||||
detail = session.exec(
|
||||
select(VPSDetail).where(VPSDetail.asset_id == existing.id)
|
||||
).first()
|
||||
if detail:
|
||||
detail.ip_address = vps.ip_address or detail.ip_address
|
||||
detail.region = vps.region or detail.region
|
||||
detail.os = vps.os or detail.os
|
||||
detail.cpu_cores = vps.cpu_cores or detail.cpu_cores
|
||||
detail.memory_gb = vps.memory_gb or detail.memory_gb
|
||||
detail.disk_gb = vps.disk_gb or detail.disk_gb
|
||||
session.add(detail)
|
||||
updated += 1
|
||||
else:
|
||||
asset = Asset(
|
||||
name=vps.name or vps.external_id,
|
||||
asset_type=AssetType.VPS,
|
||||
provider=provider.slug,
|
||||
provider_id=provider.id,
|
||||
external_id=vps.external_id,
|
||||
status=_norm_status(vps.status),
|
||||
cost=vps.monthly_cost or 0,
|
||||
currency=vps.currency,
|
||||
)
|
||||
session.add(asset)
|
||||
session.commit()
|
||||
session.refresh(asset)
|
||||
session.add(
|
||||
VPSDetail(
|
||||
asset_id=asset.id,
|
||||
ip_address=vps.ip_address or "",
|
||||
region=vps.region,
|
||||
os=vps.os,
|
||||
cpu_cores=vps.cpu_cores or 1,
|
||||
memory_gb=vps.memory_gb or 1,
|
||||
disk_gb=vps.disk_gb or 20,
|
||||
)
|
||||
)
|
||||
created += 1
|
||||
session.commit()
|
||||
return {"created": created, "updated": updated}
|
||||
|
||||
|
||||
def _sync_domains(session: Session, provider: Provider, adapter: BaseAdapter) -> dict:
|
||||
created = updated = 0
|
||||
for dom in adapter.list_domains():
|
||||
existing = _find_asset(session, provider.id, dom.external_id, AssetType.DOMAIN)
|
||||
if existing:
|
||||
existing.status = _norm_status(dom.status)
|
||||
if dom.expiry_date:
|
||||
existing.expiry_date = dom.expiry_date
|
||||
session.add(existing)
|
||||
detail = session.exec(
|
||||
select(DomainDetail).where(DomainDetail.asset_id == existing.id)
|
||||
).first()
|
||||
if detail:
|
||||
detail.domain_name = dom.domain_name or detail.domain_name
|
||||
detail.registrar = dom.registrar or detail.registrar
|
||||
session.add(detail)
|
||||
updated += 1
|
||||
else:
|
||||
asset = Asset(
|
||||
name=dom.domain_name,
|
||||
asset_type=AssetType.DOMAIN,
|
||||
provider=provider.slug,
|
||||
provider_id=provider.id,
|
||||
external_id=dom.external_id,
|
||||
status=_norm_status(dom.status),
|
||||
expiry_date=dom.expiry_date,
|
||||
)
|
||||
session.add(asset)
|
||||
session.commit()
|
||||
session.refresh(asset)
|
||||
session.add(
|
||||
DomainDetail(
|
||||
asset_id=asset.id,
|
||||
domain_name=dom.domain_name,
|
||||
registrar=dom.registrar or provider.slug,
|
||||
)
|
||||
)
|
||||
created += 1
|
||||
session.commit()
|
||||
return {"created": created, "updated": updated}
|
||||
|
||||
|
||||
def sync_provider(session: Session, provider_id: int) -> dict:
|
||||
"""同步平台资产到本地库"""
|
||||
provider = _get_provider(session, provider_id)
|
||||
adapter = _build_adapter(provider)
|
||||
missing = _missing_config(adapter)
|
||||
if missing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"缺少凭证配置:{', '.join(missing)}(请先在平台编辑里填写 API 配置)",
|
||||
)
|
||||
caps = adapter.capabilities()
|
||||
result = {"provider": provider.slug, "sdk_type": provider.sdk_type}
|
||||
|
||||
if caps["list_vps"]:
|
||||
try:
|
||||
result["vps"] = _sync_vps(session, provider, adapter)
|
||||
except Exception as e: # noqa: BLE001
|
||||
result["vps_error"] = str(e)
|
||||
if caps["list_domains"]:
|
||||
try:
|
||||
result["domains"] = _sync_domains(session, provider, adapter)
|
||||
except Exception as e: # noqa: BLE001
|
||||
result["domains_error"] = str(e)
|
||||
if caps["get_account"]:
|
||||
try:
|
||||
result["account"] = adapter.get_account().to_dict()
|
||||
except Exception as e: # noqa: BLE001
|
||||
result["account_error"] = str(e)
|
||||
|
||||
return result
|
||||
Reference in New Issue
Block a user