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
+63 -9
View File
@@ -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)
+85 -8
View File
@@ -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(),
}
+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()