feat(renewal): 提醒前核对平台真实状态,消除手工数据导致的误报
容错策略:核对失败/无可用通道时标记「未核对」并继续按本地数据提醒, 避免因核对链路故障漏掉真正到期的资产;已停止资产仅在 auto_renew=true 时提醒(停止实例不等于停止扣费)。新增 WHMCS 面板只读适配器, 用于 TotHost 这类没有开放 API 的平台。
This commit is contained in:
@@ -1,25 +1,28 @@
|
||||
"""续费检查服务
|
||||
|
||||
检查即将到期的资产,生成提醒消息并通过通知渠道发送。
|
||||
发送前会先尝试从平台侧核对真实状态(见 verification_service),
|
||||
核对到的到期日/状态会回写本地记录,避免按过期的手工数据误报。
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import date, timedelta
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.asset import Asset, AssetStatus
|
||||
from app.services import notification_service
|
||||
from app.services import notification_service, verification_service
|
||||
|
||||
# 已注销的资产不参与续费提醒
|
||||
_SKIP_STATUS = {AssetStatus.CANCELLED}
|
||||
logger = logging.getLogger("vps-manager.renewal")
|
||||
|
||||
|
||||
def get_expiring_assets(session: Session, threshold_days: int) -> list:
|
||||
"""返回 threshold_days 天内到期(含已过期未处理)的资产列表 [(asset, days), ...],按剩余天数升序
|
||||
"""返回 threshold_days 天内到期(含已过期未处理)的资产列表 [(asset, days), ...]
|
||||
|
||||
到期日不设下界:过了续费日仍未注销/续费的资产,提醒不能就此消失。
|
||||
性能优化:日期范围与状态过滤均下推到 SQL 层。
|
||||
已注销资产直接排除;已停止资产仅在开启自动续费时提醒
|
||||
(停止实例不等于停止扣费,自动续费仍可能产生费用)。
|
||||
"""
|
||||
today = date.today()
|
||||
deadline = today + timedelta(days=threshold_days)
|
||||
@@ -27,9 +30,14 @@ def get_expiring_assets(session: Session, threshold_days: int) -> list:
|
||||
select(Asset).where(
|
||||
Asset.expiry_date.is_not(None),
|
||||
Asset.expiry_date <= deadline,
|
||||
Asset.status.notin_(_SKIP_STATUS),
|
||||
Asset.status != AssetStatus.CANCELLED,
|
||||
)
|
||||
).all()
|
||||
assets = [
|
||||
asset
|
||||
for asset in assets
|
||||
if not (asset.status == AssetStatus.STOPPED and not asset.auto_renew)
|
||||
]
|
||||
expiring = [(asset, (asset.expiry_date - today).days) for asset in assets]
|
||||
expiring.sort(key=lambda x: x[1])
|
||||
return expiring
|
||||
@@ -48,7 +56,7 @@ def day_text(days: int) -> str:
|
||||
return f"{days} 天后"
|
||||
|
||||
|
||||
def build_renewal_message(expiring: list, threshold_days: int) -> str:
|
||||
def build_renewal_message(expiring: list, threshold_days: int, verification: dict = None) -> str:
|
||||
"""生成续费提醒消息(纯文本,兼容 Telegram/邮件)"""
|
||||
if not expiring:
|
||||
return ""
|
||||
@@ -60,20 +68,52 @@ def build_renewal_message(expiring: list, threshold_days: int) -> str:
|
||||
lines = [head, ""]
|
||||
for asset, days in expiring:
|
||||
lines.append(f"• {asset.name} [{asset.provider}] ({day_text(days)}) 到期 {asset.expiry_date}")
|
||||
result = (verification or {}).get(getattr(asset, "id", None))
|
||||
if result is not None:
|
||||
if result.verified:
|
||||
lines.append(f" ↳ 已核对:{result.detail}")
|
||||
else:
|
||||
lines.append(f" ↳ 未核对:{result.detail},按手工登记日期提醒")
|
||||
|
||||
if verification:
|
||||
verified = sum(
|
||||
1 for asset, _ in expiring
|
||||
if getattr(verification.get(getattr(asset, "id", None)), "verified", False)
|
||||
)
|
||||
lines.append("")
|
||||
lines.append(f"核对:已核对 {verified} 项,未核对 {len(expiring) - verified} 项")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def check_renewals(session: Session, threshold_days: int = None, send: bool = True) -> dict:
|
||||
def check_renewals(
|
||||
session: Session,
|
||||
threshold_days: int = None,
|
||||
send: bool = True,
|
||||
verify: bool = True,
|
||||
) -> dict:
|
||||
"""检查续费并(可选)发送提醒
|
||||
|
||||
threshold_days: 提醒阈值天数,默认取配置 RENEWAL_THRESHOLD_DAYS
|
||||
send: 是否实际发送通知(False 时仅返回预览)
|
||||
verify: 发送前是否向平台核对真实状态(测试/排查时可关闭)
|
||||
"""
|
||||
if threshold_days is None:
|
||||
threshold_days = settings.RENEWAL_THRESHOLD_DAYS
|
||||
|
||||
expiring = get_expiring_assets(session, threshold_days)
|
||||
message = build_renewal_message(expiring, threshold_days)
|
||||
verification = {}
|
||||
if verify and expiring:
|
||||
try:
|
||||
verification = verification_service.verify_assets(
|
||||
session, [asset for asset, _ in expiring]
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
# 核对是增强项:核对服务整体异常时仍按本地数据提醒,不能因此不发消息
|
||||
logger.exception("续费核对异常,退回本地登记数据")
|
||||
# 核对可能已把到期日/状态改成最新值,重新筛选
|
||||
expiring = get_expiring_assets(session, threshold_days)
|
||||
|
||||
message = build_renewal_message(expiring, threshold_days, verification)
|
||||
|
||||
result = {
|
||||
"threshold_days": threshold_days,
|
||||
@@ -84,11 +124,25 @@ def check_renewals(session: Session, threshold_days: int = None, send: bool = Tr
|
||||
"provider": asset.provider,
|
||||
"days": days,
|
||||
"expiry_date": str(asset.expiry_date),
|
||||
"verified": getattr(verification.get(asset.id), "verified", None),
|
||||
"verify_source": getattr(verification.get(asset.id), "source", ""),
|
||||
"verify_detail": getattr(verification.get(asset.id), "detail", ""),
|
||||
}
|
||||
for asset, days in expiring
|
||||
],
|
||||
"message": message,
|
||||
}
|
||||
if verification:
|
||||
verified_count = sum(
|
||||
1 for asset, _ in expiring
|
||||
if getattr(verification.get(asset.id), "verified", False)
|
||||
)
|
||||
result["verification"] = {
|
||||
"verified": verified_count,
|
||||
"unverified": len(expiring) - verified_count,
|
||||
}
|
||||
else:
|
||||
result["verification"] = None
|
||||
|
||||
if send and message:
|
||||
result["notifications"] = notification_service.notify(message)
|
||||
|
||||
@@ -31,6 +31,7 @@ from app.models.asset import (
|
||||
DomainDetail,
|
||||
VPSDetail,
|
||||
)
|
||||
from app.models.credential import Credential
|
||||
from app.models.provider import Provider
|
||||
|
||||
_VALID_STATUS = {s.value for s in AssetStatus}
|
||||
@@ -47,6 +48,19 @@ def _load_account_config(account: Account) -> dict:
|
||||
return {}
|
||||
|
||||
|
||||
def _credential_login_config(session: Session, account: Account) -> dict:
|
||||
"""从凭据表取面板登录所需的用户名/密码(凭据表是唯一事实源)"""
|
||||
if not account.credential_id:
|
||||
return {}
|
||||
cred = session.get(Credential, account.credential_id)
|
||||
if not cred or not cred.password_encrypted:
|
||||
return {}
|
||||
password = crypto.decrypt(cred.password_encrypted)
|
||||
if not password:
|
||||
return {}
|
||||
return {"username": cred.username or account.name, "password": password}
|
||||
|
||||
|
||||
def _find_provider_by_platform(session: Session, platform: str) -> Provider:
|
||||
"""按账号的 platform(slug 或名称)定位平台"""
|
||||
provider = session.exec(select(Provider).where(Provider.slug == platform)).first()
|
||||
@@ -96,6 +110,16 @@ def _build_adapter_for_account(session: Session, account: Account):
|
||||
)
|
||||
provider = _find_provider_by_platform(session, account.platform)
|
||||
config = _load_account_config(account)
|
||||
if provider.sdk_type == "whmcs-panel":
|
||||
# 面板登录类适配器不重复存密码:用户名/密码取自关联凭据,
|
||||
# base_url 取自平台的管理面板地址。
|
||||
config = {**_credential_login_config(session, account), **config}
|
||||
config.setdefault("base_url", provider.console_url or provider.website or "")
|
||||
if not config.get("password"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="该平台通过账单面板登录核对,请为账号关联带密码的登录凭据",
|
||||
)
|
||||
adapter = _build_adapter(provider, config)
|
||||
return provider, adapter
|
||||
|
||||
@@ -121,14 +145,29 @@ def test_provider(session: Session, provider_id: int) -> dict:
|
||||
return test_account(session, account.id)
|
||||
|
||||
|
||||
def _account_has_credentials(session: Session, account: Account, provider: Provider) -> bool:
|
||||
"""账号是否具备该平台可用的同步凭证
|
||||
|
||||
普通云厂商看 api_config_encrypted;WHMCS 面板类平台看关联登录凭据。
|
||||
"""
|
||||
if account.api_config_encrypted:
|
||||
return True
|
||||
if provider.sdk_type == "whmcs-panel":
|
||||
return bool(_credential_login_config(session, account))
|
||||
return False
|
||||
|
||||
|
||||
def _first_account_with_config(session: Session, provider: Provider):
|
||||
"""找该平台下第一个配置了 API 凭证的账号(兼容旧入口)"""
|
||||
return session.exec(
|
||||
"""找该平台下第一个具备可用凭证的账号(兼容旧入口)"""
|
||||
accounts = session.exec(
|
||||
select(Account)
|
||||
.where(Account.platform == provider.slug)
|
||||
.where(Account.api_config_encrypted.is_not(None)) # type: ignore[union-attr]
|
||||
.order_by(Account.id.asc())
|
||||
).first()
|
||||
).all()
|
||||
for account in accounts:
|
||||
if _account_has_credentials(session, account, provider):
|
||||
return account
|
||||
return None
|
||||
|
||||
|
||||
def test_account(session: Session, account_id: int) -> dict:
|
||||
@@ -158,6 +197,32 @@ def _find_asset(session: Session, provider_id: int, external_id: str, asset_type
|
||||
).first()
|
||||
|
||||
|
||||
def _find_unlinked_vps(session: Session, provider: Provider, vps) -> Optional[Asset]:
|
||||
"""在手工登记、尚无 external_id 的同平台 VPS 中按 IP/名称找对应项
|
||||
|
||||
手工登记资产没有云端实例 ID,直接同步会重复建一条;
|
||||
能唯一对上时补写 external_id,把手工台账接进自动同步。
|
||||
"""
|
||||
candidates = session.exec(
|
||||
select(Asset).where(
|
||||
Asset.provider_id == provider.id,
|
||||
Asset.asset_type == AssetType.VPS,
|
||||
Asset.external_id.is_(None),
|
||||
)
|
||||
).all()
|
||||
vps_ip = (vps.ip_address or "").strip()
|
||||
vps_name = (vps.name or "").strip().lower()
|
||||
for asset in candidates:
|
||||
detail = session.exec(
|
||||
select(VPSDetail).where(VPSDetail.asset_id == asset.id)
|
||||
).first()
|
||||
if vps_ip and detail and (detail.ip_address or "").strip() == vps_ip:
|
||||
return asset
|
||||
if vps_name and (asset.name or "").strip().lower() == vps_name:
|
||||
return asset
|
||||
return None
|
||||
|
||||
|
||||
def _sync_vps(session: Session, provider: Provider, adapter: BaseAdapter, account_id: Optional[int] = None) -> dict:
|
||||
created = updated = 0
|
||||
for vps in adapter.list_vps():
|
||||
@@ -165,6 +230,10 @@ def _sync_vps(session: Session, provider: Provider, adapter: BaseAdapter, accoun
|
||||
# 漏了这一步,云端同步进来的 VPS 永远进不了续费提醒——而那是台账的核心用途。
|
||||
expiry = parse_iso_date(vps.expiry_date)
|
||||
existing = _find_asset(session, provider.id, vps.external_id, AssetType.VPS)
|
||||
if not existing:
|
||||
existing = _find_unlinked_vps(session, provider, vps)
|
||||
if existing:
|
||||
existing.external_id = vps.external_id
|
||||
if existing:
|
||||
existing.name = vps.name or existing.name
|
||||
existing.status = _norm_status(vps.status)
|
||||
@@ -460,7 +529,8 @@ def sync_all_assets(session: Session, max_workers: int = 5) -> dict:
|
||||
Session(SQLite Session 不能跨线程共享,WAL 模式下多连接读写安全)。
|
||||
|
||||
未配凭证或平台无适配器的账号计入 skipped 而非 failed——否则每次定时同步
|
||||
都会把手工登记的账号报成一堆错误。
|
||||
都会把手工登记的账号报成一堆错误。WHMCS 面板类账号也跳过:续费检查会
|
||||
按需登录核对,避免全量同步重命名手写资产或为历史服务新建台账。
|
||||
"""
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
@@ -470,11 +540,9 @@ def sync_all_assets(session: Session, max_workers: int = 5) -> dict:
|
||||
targets = []
|
||||
skipped_no_config = []
|
||||
skipped_no_adapter = []
|
||||
skipped_panel = []
|
||||
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:
|
||||
@@ -483,6 +551,14 @@ def sync_all_assets(session: Session, max_workers: int = 5) -> dict:
|
||||
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
|
||||
if provider.sdk_type == "whmcs-panel":
|
||||
# 面板类平台由续费检查按需核对:定时全量同步会把手写资产名改成面板名、
|
||||
# 并可能为面板里的历史服务新建台账,副作用大于收益。
|
||||
skipped_panel.append(label)
|
||||
continue
|
||||
if not _account_has_credentials(session, acc, provider):
|
||||
skipped_no_config.append(label)
|
||||
continue
|
||||
targets.append((acc.id, label))
|
||||
|
||||
def _sync_one(account_id: int) -> dict:
|
||||
@@ -531,6 +607,7 @@ def sync_all_assets(session: Session, max_workers: int = 5) -> dict:
|
||||
"updated": updated,
|
||||
"skipped_no_config": len(skipped_no_config),
|
||||
"skipped_no_adapter": len(skipped_no_adapter),
|
||||
"skipped_panel": len(skipped_panel),
|
||||
"errors": errors,
|
||||
"synced_at": utcnow().isoformat(),
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user