feat(renewal): 提醒前核对平台真实状态,消除手工数据导致的误报

容错策略:核对失败/无可用通道时标记「未核对」并继续按本地数据提醒,
避免因核对链路故障漏掉真正到期的资产;已停止资产仅在 auto_renew=true
时提醒(停止实例不等于停止扣费)。新增 WHMCS 面板只读适配器,
用于 TotHost 这类没有开放 API 的平台。
This commit is contained in:
gouki
2026-10-25 23:05:39 +00:00
parent de8a3caedd
commit abd2fa8206
12 changed files with 1229 additions and 27 deletions
+359
View File
@@ -0,0 +1,359 @@
"""资产真实状态核对服务
续费提醒发送前,尽可能从平台侧确认资产是否还在、是否已续费:
- 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()