Files
vps-manager/app/services/renewal_service.py
T
gouki abd2fa8206 feat(renewal): 提醒前核对平台真实状态,消除手工数据导致的误报
容错策略:核对失败/无可用通道时标记「未核对」并继续按本地数据提醒,
避免因核对链路故障漏掉真正到期的资产;已停止资产仅在 auto_renew=true
时提醒(停止实例不等于停止扣费)。新增 WHMCS 面板只读适配器,
用于 TotHost 这类没有开放 API 的平台。
2026-10-25 23:05:39 +00:00

152 lines
5.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""续费检查服务
检查即将到期的资产,生成提醒消息并通过通知渠道发送。
发送前会先尝试从平台侧核对真实状态(见 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, verification_service
logger = logging.getLogger("vps-manager.renewal")
def get_expiring_assets(session: Session, threshold_days: int) -> list:
"""返回 threshold_days 天内到期(含已过期未处理)的资产列表 [(asset, days), ...]
到期日不设下界:过了续费日仍未注销/续费的资产,提醒不能就此消失。
已注销资产直接排除;已停止资产仅在开启自动续费时提醒
(停止实例不等于停止扣费,自动续费仍可能产生费用)。
"""
today = date.today()
deadline = today + timedelta(days=threshold_days)
assets = session.exec(
select(Asset).where(
Asset.expiry_date.is_not(None),
Asset.expiry_date <= deadline,
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
def day_text(days: int) -> str:
"""单项到期文案(提醒消息与 check_renewals 脚本共用)
days 可以为负(已过期),直接拼成「{days} 天后到期」会得到「-3 天后到期」;
前端对应的统一文案是 Fmt.countdownText,两边保持一致。
"""
if days < 0:
return f"已过期 {-days} 天"
if days == 0:
return "今天到期"
return f"{days} 天后"
def build_renewal_message(expiring: list, threshold_days: int, verification: dict = None) -> str:
"""生成续费提醒消息(纯文本,兼容 Telegram/邮件)"""
if not expiring:
return ""
overdue = sum(1 for _, d in expiring if d < 0)
if overdue:
head = f"⏰ 资产续费提醒(已过期 {overdue} 项 + {threshold_days} 天内到期 {len(expiring) - overdue} 项)"
else:
head = f"⏰ 资产续费提醒({threshold_days} 天内到期,共 {len(expiring)} 项)"
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,
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)
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,
"expiring_count": len(expiring),
"expiring": [
{
"name": asset.name,
"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)
else:
result["notifications"] = []
return result