feat: 续费提醒告警(Telegram/邮件多渠道+续费检查+每日定时+前端控制)
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
"""通知服务:Telegram / 邮件多渠道发送
|
||||
|
||||
根据 .env 配置的渠道发送通知。未配置的渠道自动跳过。
|
||||
"""
|
||||
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
def send_telegram(message: str) -> dict:
|
||||
"""通过 Telegram Bot 发送消息"""
|
||||
if not settings.TELEGRAM_BOT_TOKEN or not settings.TELEGRAM_CHAT_ID:
|
||||
return {"ok": False, "channel": "telegram", "message": "未配置 Telegram"}
|
||||
url = f"https://api.telegram.org/bot{settings.TELEGRAM_BOT_TOKEN}/sendMessage"
|
||||
try:
|
||||
resp = httpx.post(
|
||||
url,
|
||||
json={
|
||||
"chat_id": settings.TELEGRAM_CHAT_ID,
|
||||
"text": message,
|
||||
"parse_mode": "HTML",
|
||||
"disable_web_page_preview": True,
|
||||
},
|
||||
timeout=15,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return {"ok": True, "channel": "telegram", "message": "发送成功"}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"ok": False, "channel": "telegram", "message": str(e)}
|
||||
|
||||
|
||||
def send_email(message: str, subject: str = "资产续费提醒") -> dict:
|
||||
"""通过 SMTP 发送邮件"""
|
||||
if not settings.SMTP_HOST or not settings.SMTP_USER:
|
||||
return {"ok": False, "channel": "email", "message": "未配置邮件"}
|
||||
try:
|
||||
msg = MIMEText(message, "plain", "utf-8")
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = settings.SMTP_FROM or settings.SMTP_USER
|
||||
msg["To"] = settings.SMTP_TO or settings.SMTP_USER
|
||||
with smtplib.SMTP_SSL(settings.SMTP_HOST, settings.SMTP_PORT, timeout=15) as server:
|
||||
server.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
|
||||
server.send_message(msg)
|
||||
return {"ok": True, "channel": "email", "message": "发送成功"}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"ok": False, "channel": "email", "message": str(e)}
|
||||
|
||||
|
||||
def notify(message: str, subject: str = "资产续费提醒") -> list:
|
||||
"""发送到所有已配置的渠道,返回各渠道结果"""
|
||||
results = []
|
||||
if settings.TELEGRAM_BOT_TOKEN:
|
||||
results.append(send_telegram(message))
|
||||
if settings.SMTP_HOST:
|
||||
results.append(send_email(message, subject))
|
||||
if not results:
|
||||
results.append({"ok": False, "channel": "none", "message": "未配置任何通知渠道"})
|
||||
return results
|
||||
|
||||
|
||||
def configured_channels() -> list:
|
||||
"""返回已配置的通知渠道列表"""
|
||||
channels = []
|
||||
if settings.TELEGRAM_BOT_TOKEN and settings.TELEGRAM_CHAT_ID:
|
||||
channels.append("telegram")
|
||||
if settings.SMTP_HOST and settings.SMTP_USER:
|
||||
channels.append("email")
|
||||
return channels
|
||||
@@ -0,0 +1,75 @@
|
||||
"""续费检查服务
|
||||
|
||||
检查即将到期的资产,生成提醒消息并通过通知渠道发送。
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.asset import Asset, AssetStatus
|
||||
from app.services import notification_service
|
||||
|
||||
# 已注销的资产不参与续费提醒
|
||||
_SKIP_STATUS = {AssetStatus.CANCELLED}
|
||||
|
||||
|
||||
def get_expiring_assets(session: Session, threshold_days: int) -> list:
|
||||
"""返回 threshold_days 天内到期的资产列表 [(asset, days), ...],按剩余天数升序"""
|
||||
today = date.today()
|
||||
assets = session.exec(select(Asset).where(Asset.expiry_date.is_not(None))).all()
|
||||
expiring = []
|
||||
for asset in assets:
|
||||
if asset.status in _SKIP_STATUS:
|
||||
continue
|
||||
days = (asset.expiry_date - today).days
|
||||
if 0 <= days <= threshold_days:
|
||||
expiring.append((asset, days))
|
||||
expiring.sort(key=lambda x: x[1])
|
||||
return expiring
|
||||
|
||||
|
||||
def build_renewal_message(expiring: list, threshold_days: int) -> str:
|
||||
"""生成续费提醒消息(纯文本,兼容 Telegram/邮件)"""
|
||||
if not expiring:
|
||||
return ""
|
||||
lines = [f"⏰ 资产续费提醒({threshold_days} 天内到期,共 {len(expiring)} 项)", ""]
|
||||
for asset, days in expiring:
|
||||
tag = "(已过期)" if days == 0 else f"({days} 天后)"
|
||||
lines.append(f"• {asset.name} [{asset.provider}] {tag} 到期 {asset.expiry_date}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def check_renewals(session: Session, threshold_days: int = None, send: bool = True) -> dict:
|
||||
"""检查续费并(可选)发送提醒
|
||||
|
||||
threshold_days: 提醒阈值天数,默认取配置 RENEWAL_THRESHOLD_DAYS
|
||||
send: 是否实际发送通知(False 时仅返回预览)
|
||||
"""
|
||||
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)
|
||||
|
||||
result = {
|
||||
"threshold_days": threshold_days,
|
||||
"expiring_count": len(expiring),
|
||||
"expiring": [
|
||||
{
|
||||
"name": asset.name,
|
||||
"provider": asset.provider,
|
||||
"days": days,
|
||||
"expiry_date": str(asset.expiry_date),
|
||||
}
|
||||
for asset, days in expiring
|
||||
],
|
||||
"message": message,
|
||||
}
|
||||
|
||||
if send and message:
|
||||
result["notifications"] = notification_service.notify(message)
|
||||
else:
|
||||
result["notifications"] = []
|
||||
return result
|
||||
Reference in New Issue
Block a user