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)
|
||||
|
||||
Reference in New Issue
Block a user