"""资产真实状态核对服务 续费提醒发送前,尽可能从平台侧确认资产是否还在、是否已续费: - whmcs-panel:登录账单面板读取服务列表(TotHost 等) - 云厂商 API:复用 app/adapters 的只读清单能力 - 无可用通道:标记未核对,保留手工登记值并继续提醒(避免漏续费) 核对结果会回写 assets 的 expiry_date / status / external_id, 随后续费检查按更正后的数据重新筛选。 """ from collections import defaultdict from dataclasses import dataclass from datetime import date from typing import Optional from fastapi import HTTPException from sqlmodel import Session, select from app.adapters import registry from app.adapters.base import cloud_error_text, parse_iso_date from app.models.asset import ( Account, Asset, AssetStatus, AssetType, DomainDetail, VPSDetail, ) from app.models.provider import Provider from app.services import sync_service _WHMCS = "whmcs-panel" _STATUS_MAP = { "active": AssetStatus.ACTIVE, "expired": AssetStatus.EXPIRED, "stopped": AssetStatus.STOPPED, "cancelled": AssetStatus.CANCELLED, "unknown": AssetStatus.UNKNOWN, } @dataclass class VerificationResult: """单个资产的核对结果""" asset_id: int verified: bool source: str = "" status: Optional[AssetStatus] = None expiry_date: Optional[date] = None external_id: Optional[str] = None detail: str = "" def _error_text(e: Exception) -> str: if isinstance(e, HTTPException): return str(e.detail) return cloud_error_text(e) def _unverified(asset_id: int, detail: str) -> VerificationResult: return VerificationResult(asset_id=asset_id, verified=False, detail=detail) def _status(raw: Optional[str]) -> Optional[AssetStatus]: return _STATUS_MAP.get((raw or "").strip().lower()) def verify_assets(session: Session, assets: list) -> dict: """按账号分组核对资产,返回 {asset_id: VerificationResult}""" results: dict = {} if not assets: return results groups = defaultdict(list) for asset in assets: groups[(asset.provider_id, asset.account_id)].append(asset) cache: dict = {} for (provider_id, account_id), group in groups.items(): provider = session.get(Provider, provider_id) if provider_id else None account = session.get(Account, account_id) if account_id else None if not provider: for asset in group: results[asset.id] = _unverified(asset.id, "资产未关联平台记录") continue sdk = (provider.sdk_type or "").strip() if sdk == _WHMCS: ctx = _whmcs_context(session, provider, account, cache) for asset in group: results[asset.id] = _verify_whmcs_asset(session, provider, asset, group, ctx) elif sdk and registry.is_supported(sdk): if all(asset.asset_type == AssetType.AI_AGENT for asset in group): # AI 适配器只提供余额,没有统一的订阅到期查询,别白跑一次 API for asset in group: results[asset.id] = _unverified( asset.id, "AI 平台没有统一的订阅到期查询接口" ) continue ctx = _api_context(session, provider, account, cache) for asset in group: results[asset.id] = _verify_api_asset(session, asset, ctx) else: for asset in group: results[asset.id] = _unverified( asset.id, f"平台「{provider.name}」未接入自动核对" ) _apply(session, assets, results) return results # --------------------------------------------------------------------------- # # WHMCS 面板通道 # --------------------------------------------------------------------------- # def _whmcs_context(session: Session, provider: Provider, account: Optional[Account], cache: dict) -> dict: key = ("whmcs", provider.id, account.id if account else None) if key in cache: return cache[key] if not account: ctx = {"error": "资产未关联账号,无法确定用哪个面板账号核对"} else: try: _, adapter = sync_service._build_adapter_for_account(session, account) fetch = getattr(adapter, "fetch_services", None) if not fetch: raise RuntimeError("该平台适配器不支持面板服务列表") services = fetch(include_terminated=True) ctx = { "services": {str(s["id"]): s for s in services}, "active": [s for s in services if s["status"] == "active"], "source": f"{provider.name} 面板", } except Exception as e: # noqa: BLE001 ctx = {"error": f"面板核对失败:{_error_text(e)}"} cache[key] = ctx return ctx def _verify_whmcs_asset( session: Session, provider: Provider, asset: Asset, group: list, ctx: dict ) -> VerificationResult: if ctx.get("error"): return _unverified(asset.id, ctx["error"]) services = ctx["services"] service_id = (asset.external_id or "").strip() if service_id: service = services.get(service_id) if not service: return VerificationResult( asset_id=asset.id, verified=True, source=ctx["source"], status=AssetStatus.CANCELLED, detail="面板中已无该服务(可能已删除)", ) return _from_whmcs_service(asset, service, ctx["source"]) service = _match_whmcs_service(session, asset, services) if not service: # 账号下只有一个未关联资产、且面板只有一个在用服务时,可以安全地唯一对应 unlinked = [a for a in group if not (a.external_id or "").strip()] if len(unlinked) == 1 and len(ctx["active"]) == 1: service = ctx["active"][0] if not service: return _unverified( asset.id, "无法与面板服务对应:请设置平台实例 ID 或关联正确的账号" ) return _from_whmcs_service(asset, service, ctx["source"], external_id=str(service["id"])) def _match_whmcs_service(session: Session, asset: Asset, services: dict) -> Optional[dict]: detail = session.exec( select(VPSDetail).where(VPSDetail.asset_id == asset.id) ).first() ip = (detail.ip_address or "").strip().lower() if detail else "" name = (asset.name or "").strip().lower() for service in services.values(): domain = (service.get("domain") or "").strip().lower() label = (service.get("label") or "").strip().lower() if ip and domain and domain == ip: return service if name and (domain == name or label == name): return service if name and domain and len(domain) >= 4 and (domain in name or name in domain): return service return None def _from_whmcs_service( asset: Asset, service: dict, source: str, external_id: Optional[str] = None ) -> VerificationResult: detail = f"服务 {service['label']}:{service.get('status_text') or service.get('status_class')}" if service.get("expiry_date"): detail += f",下次到期 {service['expiry_date']}" return VerificationResult( asset_id=asset.id, verified=True, source=source, status=_status(service.get("status")), expiry_date=service.get("expiry_date"), external_id=external_id, detail=detail, ) # --------------------------------------------------------------------------- # # 云厂商 API 通道 # --------------------------------------------------------------------------- # def _api_context(session: Session, provider: Provider, account: Optional[Account], cache: dict) -> dict: key = ("api", provider.id, account.id if account else None) if key in cache: return cache[key] if not account: ctx = {"error": "资产未关联账号,无法使用云端 API 核对"} else: try: _, adapter = sync_service._build_adapter_for_account(session, account) caps = adapter.capabilities() ctx = {"source": f"{provider.name} API", "vps": {}, "domains": {}} if caps.get("list_vps"): ctx["vps"] = {str(v.external_id): v for v in adapter.list_vps()} if caps.get("list_domains"): ctx["domains"] = {str(d.external_id): d for d in adapter.list_domains()} if not caps.get("list_vps") and not caps.get("list_domains"): ctx = {"error": f"{provider.name} 适配器不支持到期信息查询"} except Exception as e: # noqa: BLE001 ctx = {"error": f"云端 API 核对失败:{_error_text(e)}"} cache[key] = ctx return ctx def _verify_api_asset(session: Session, asset: Asset, ctx: dict) -> VerificationResult: if ctx.get("error"): return _unverified(asset.id, ctx["error"]) if asset.asset_type == AssetType.VPS: items = ctx.get("vps", {}) item = items.get(str(asset.external_id)) if asset.external_id else None if item is None and not asset.external_id: item = _match_api_vps(session, asset, items.values()) if item is None: if asset.external_id: return VerificationResult( asset_id=asset.id, verified=True, source=ctx["source"], status=AssetStatus.CANCELLED, detail="云端列表中已无该实例(可能已删除)", ) return _unverified(asset.id, "无法与云端实例对应:请设置平台实例 ID") expiry = parse_iso_date(getattr(item, "expiry_date", None)) detail = f"实例 {getattr(item, 'name', asset.name)} 状态 {getattr(item, 'status', 'unknown')}" if expiry: detail += f",到期 {expiry}" return VerificationResult( asset_id=asset.id, verified=True, source=ctx["source"], status=sync_service._norm_status(getattr(item, "status", None)), expiry_date=expiry, external_id=str(getattr(item, "external_id", "")) or None, detail=detail, ) if asset.asset_type == AssetType.DOMAIN: items = ctx.get("domains", {}) item = items.get(str(asset.external_id)) if asset.external_id else None if item is None and not asset.external_id: item = _match_api_domain(session, asset, items.values()) if item is None: if asset.external_id: return VerificationResult( asset_id=asset.id, verified=True, source=ctx["source"], status=AssetStatus.CANCELLED, detail="云端列表中已无该域名(可能已删除或转出)", ) return _unverified(asset.id, "无法与云端域名对应:请设置平台实例 ID") expiry = parse_iso_date(getattr(item, "expiry_date", None)) detail = f"域名 {getattr(item, 'domain_name', asset.name)} 状态 {getattr(item, 'status', 'unknown')}" if expiry: detail += f",到期 {expiry}" return VerificationResult( asset_id=asset.id, verified=True, source=ctx["source"], status=sync_service._norm_status(getattr(item, "status", None)), expiry_date=expiry, external_id=str(getattr(item, "external_id", "")) or None, detail=detail, ) return _unverified(asset.id, "该资产类型没有云端到期查询") def _match_api_vps(session: Session, asset: Asset, items) -> Optional[object]: detail = session.exec( select(VPSDetail).where(VPSDetail.asset_id == asset.id) ).first() ip = (detail.ip_address or "").strip() if detail else "" name = (asset.name or "").strip().lower() for item in items: if ip and (getattr(item, "ip_address", "") or "").strip() == ip: return item if name and (getattr(item, "name", "") or "").strip().lower() == name: return item return None def _match_api_domain(session: Session, asset: Asset, items) -> Optional[object]: detail = session.exec( select(DomainDetail).where(DomainDetail.asset_id == asset.id) ).first() domain = (detail.domain_name or "").strip().lower() if detail else "" name = (asset.name or "").strip().lower() for item in items: candidate = (getattr(item, "domain_name", "") or "").strip().lower() if domain and candidate == domain: return item if name and candidate == name: return item return None # --------------------------------------------------------------------------- # # 回写 # --------------------------------------------------------------------------- # def _apply(session: Session, assets: list, results: dict) -> None: changed = False for asset in assets: result = results.get(asset.id) if not result or not result.verified: continue touched = False if result.expiry_date is not None and asset.expiry_date != result.expiry_date: asset.expiry_date = result.expiry_date touched = True if result.status is not None and asset.status != result.status: asset.status = result.status touched = True if result.external_id and not asset.external_id: asset.external_id = result.external_id touched = True if touched: session.add(asset) changed = True if changed: session.commit()