"""续费检查服务 检查即将到期的资产,生成提醒消息并通过通知渠道发送。 发送前会先尝试从平台侧核对真实状态(见 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