feat: 续费提醒告警(Telegram/邮件多渠道+续费检查+每日定时+前端控制)
This commit is contained in:
@@ -7,3 +7,25 @@ APP_NAME=VPS 资产管理系统
|
|||||||
# 留空 = 不校验(适用于纯 Tailscale 内网环境)
|
# 留空 = 不校验(适用于纯 Tailscale 内网环境)
|
||||||
# 配置后,所有写操作需在请求头携带 X-API-Key: <此密钥>
|
# 配置后,所有写操作需在请求头携带 X-API-Key: <此密钥>
|
||||||
API_KEY=
|
API_KEY=
|
||||||
|
|
||||||
|
# Agent 上报鉴权密钥(可选)
|
||||||
|
AGENT_KEY=
|
||||||
|
|
||||||
|
# 凭证加密主密钥(Fernet,必填以加密 API Key/SSH 凭证)
|
||||||
|
# 生成:python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())'
|
||||||
|
MASTER_KEY=
|
||||||
|
|
||||||
|
# ---- 通知渠道:Telegram(续费提醒)----
|
||||||
|
TELEGRAM_BOT_TOKEN=
|
||||||
|
TELEGRAM_CHAT_ID=
|
||||||
|
|
||||||
|
# ---- 通知渠道:邮件(SMTP)----
|
||||||
|
SMTP_HOST=
|
||||||
|
SMTP_PORT=465
|
||||||
|
SMTP_USER=
|
||||||
|
SMTP_PASSWORD=
|
||||||
|
SMTP_FROM=
|
||||||
|
SMTP_TO=
|
||||||
|
|
||||||
|
# ---- 续费提醒阈值(天)----
|
||||||
|
RENEWAL_THRESHOLD_DAYS=30
|
||||||
|
|||||||
@@ -24,5 +24,20 @@ class Settings:
|
|||||||
# 凭证加密主密钥(Fernet);生成:python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())'
|
# 凭证加密主密钥(Fernet);生成:python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())'
|
||||||
MASTER_KEY: str = os.getenv("MASTER_KEY", "")
|
MASTER_KEY: str = os.getenv("MASTER_KEY", "")
|
||||||
|
|
||||||
|
# ---- 通知渠道:Telegram ----
|
||||||
|
TELEGRAM_BOT_TOKEN: str = os.getenv("TELEGRAM_BOT_TOKEN", "")
|
||||||
|
TELEGRAM_CHAT_ID: str = os.getenv("TELEGRAM_CHAT_ID", "")
|
||||||
|
|
||||||
|
# ---- 通知渠道:邮件(SMTP)----
|
||||||
|
SMTP_HOST: str = os.getenv("SMTP_HOST", "")
|
||||||
|
SMTP_PORT: int = int(os.getenv("SMTP_PORT", "465"))
|
||||||
|
SMTP_USER: str = os.getenv("SMTP_USER", "")
|
||||||
|
SMTP_PASSWORD: str = os.getenv("SMTP_PASSWORD", "")
|
||||||
|
SMTP_FROM: str = os.getenv("SMTP_FROM", "")
|
||||||
|
SMTP_TO: str = os.getenv("SMTP_TO", "")
|
||||||
|
|
||||||
|
# ---- 续费提醒 ----
|
||||||
|
RENEWAL_THRESHOLD_DAYS: int = int(os.getenv("RENEWAL_THRESHOLD_DAYS", "30"))
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|||||||
+2
-1
@@ -19,7 +19,7 @@ from sqlmodel import Session
|
|||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.seed import seed_providers
|
from app.core.seed import seed_providers
|
||||||
from app.database import assets_engine, init_db
|
from app.database import assets_engine, init_db
|
||||||
from app.routers import agent, assets, monitor, providers, stats, sync
|
from app.routers import agent, assets, monitor, notify, providers, stats, sync
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
STATIC_DIR = BASE_DIR / "static"
|
STATIC_DIR = BASE_DIR / "static"
|
||||||
@@ -54,6 +54,7 @@ app.include_router(stats.router)
|
|||||||
app.include_router(agent.router)
|
app.include_router(agent.router)
|
||||||
app.include_router(monitor.router)
|
app.include_router(monitor.router)
|
||||||
app.include_router(sync.router)
|
app.include_router(sync.router)
|
||||||
|
app.include_router(notify.router)
|
||||||
|
|
||||||
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""通知与续费提醒路由"""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
from app.core.security import require_api_key
|
||||||
|
from app.database import get_session
|
||||||
|
from app.services import notification_service, renewal_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/notify", tags=["notify"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/channels", summary="已配置的通知渠道")
|
||||||
|
def channels() -> dict:
|
||||||
|
return {"channels": notification_service.configured_channels()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/test",
|
||||||
|
summary="测试通知渠道",
|
||||||
|
dependencies=[Depends(require_api_key)],
|
||||||
|
)
|
||||||
|
def test_notify() -> dict:
|
||||||
|
results = notification_service.notify(
|
||||||
|
"✅ 这是一条来自 vps-manager 的测试通知", subject="vps-manager 测试"
|
||||||
|
)
|
||||||
|
return {"notifications": results}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/renewals",
|
||||||
|
summary="检查续费并发送提醒",
|
||||||
|
dependencies=[Depends(require_api_key)],
|
||||||
|
)
|
||||||
|
def check_renewals(
|
||||||
|
threshold: Optional[int] = Query(default=None, description="提醒阈值天数,默认取配置"),
|
||||||
|
send: bool = Query(default=True, description="是否实际发送(false 仅预览)"),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> dict:
|
||||||
|
return renewal_service.check_renewals(session, threshold_days=threshold, send=send)
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# 续费提醒检查服务(oneshot,由 timer 触发)
|
||||||
|
# 部署位置:/etc/systemd/system/vps-renewal-check.service
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=Check asset renewals and send notifications
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
WorkingDirectory=/opt/vps-manager
|
||||||
|
EnvironmentFile=-/opt/vps-manager/.env
|
||||||
|
ExecStart=/opt/vps-manager/.venv/bin/python /opt/vps-manager/scripts/check_renewals.py
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# 续费提醒每日检查定时器(每天早上 9 点)
|
||||||
|
# 部署位置:/etc/systemd/system/vps-renewal-check.timer
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=Daily renewal check and notification
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* 09:00:00
|
||||||
|
Persistent=true
|
||||||
|
Unit=vps-renewal-check.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""定时检查续费并发送提醒(供 systemd timer 调用)
|
||||||
|
|
||||||
|
用法:python scripts/check_renewals.py [threshold_days]
|
||||||
|
依赖 .env 中的通知渠道配置(TELEGRAM_*/SMTP_*)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# 确保能导入 app 包(脚本位于 scripts/ 子目录)
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
from app.database import assets_engine, init_db
|
||||||
|
from app.services import renewal_service
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
threshold = int(sys.argv[1]) if len(sys.argv) > 1 else None
|
||||||
|
init_db()
|
||||||
|
with Session(assets_engine) as session:
|
||||||
|
result = renewal_service.check_renewals(session, threshold_days=threshold)
|
||||||
|
print(
|
||||||
|
f"[续费检查] 阈值 {result['threshold_days']} 天 | 即将到期 {result['expiring_count']} 项"
|
||||||
|
)
|
||||||
|
for item in result["expiring"]:
|
||||||
|
print(f" - {item['name']} [{item['provider']}] {item['days']} 天后到期")
|
||||||
|
for n in result.get("notifications", []):
|
||||||
|
status = "成功" if n["ok"] else f"失败:{n['message']}"
|
||||||
|
print(f" 通知[{n['channel']}]: {status}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
+37
-1
@@ -550,6 +550,18 @@ const SettingsView = {
|
|||||||
<button @click="save" class="text-sm px-4 py-2 rounded-lg bg-blue-600 text-white hover:bg-blue-700">{{ settings.saved ? '已保存' : '保存' }}</button>
|
<button @click="save" class="text-sm px-4 py-2 rounded-lg bg-blue-600 text-white hover:bg-blue-700">{{ settings.saved ? '已保存' : '保存' }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
|
||||||
|
<h3 class="font-semibold text-sm mb-2">通知与续费提醒</h3>
|
||||||
|
<div class="text-xs text-slate-500 mb-3">已配置渠道:
|
||||||
|
<span v-if="notifyChannels.length" class="text-emerald-600 dark:text-emerald-400">{{ notifyChannels.join('、') }}</span>
|
||||||
|
<span v-else class="text-slate-400">未配置(在 .env 设置 TELEGRAM_*/SMTP_*)</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 flex-wrap">
|
||||||
|
<button @click="testNotify" class="text-sm px-3 py-1.5 rounded-lg border border-slate-300 dark:border-slate-700 hover:bg-slate-50 dark:hover:bg-slate-800">测试通知</button>
|
||||||
|
<button @click="checkRenewals" class="text-sm px-3 py-1.5 rounded-lg bg-blue-600 text-white hover:bg-blue-700">检查续费并提醒</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="notifyResult" class="mt-3 text-xs px-3 py-2 rounded-lg bg-slate-50 dark:bg-slate-800/50 whitespace-pre-wrap break-words">{{ notifyResult }}</div>
|
||||||
|
</div>
|
||||||
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
|
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
|
||||||
<h3 class="font-semibold text-sm mb-2">外观</h3>
|
<h3 class="font-semibold text-sm mb-2">外观</h3>
|
||||||
<label class="flex items-center gap-2 text-sm">
|
<label class="flex items-center gap-2 text-sm">
|
||||||
@@ -557,7 +569,31 @@ const SettingsView = {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>`,
|
</div>`,
|
||||||
setup() { return { store, settings, save: saveSettings, applyDark }; },
|
setup() {
|
||||||
|
const notifyChannels = ref([]);
|
||||||
|
const notifyResult = ref('');
|
||||||
|
onMounted(async () => {
|
||||||
|
try { const r = await Api.get('/notify/channels'); notifyChannels.value = r.channels || []; } catch (e) { /* ignore */ }
|
||||||
|
});
|
||||||
|
async function testNotify() {
|
||||||
|
notifyResult.value = '发送中…';
|
||||||
|
try {
|
||||||
|
const r = await Api.post('/notify/test', {});
|
||||||
|
notifyResult.value = r.notifications.map(n => '[' + n.channel + '] ' + (n.ok ? '成功' : '失败:' + n.message)).join('\n');
|
||||||
|
} catch (e) { notifyResult.value = '失败:' + e.message; }
|
||||||
|
}
|
||||||
|
async function checkRenewals() {
|
||||||
|
notifyResult.value = '检查中…';
|
||||||
|
try {
|
||||||
|
const r = await Api.post('/notify/renewals?send=true', {});
|
||||||
|
let text = '即将到期 ' + r.expiring_count + ' 项(阈值 ' + r.threshold_days + ' 天)';
|
||||||
|
if (r.expiring.length) text += '\n' + r.expiring.map(i => '• ' + i.name + ' [' + i.provider + '] ' + i.days + ' 天后').join('\n');
|
||||||
|
if (r.notifications && r.notifications.length) text += '\n\n通知:' + r.notifications.map(n => '[' + n.channel + '] ' + (n.ok ? '成功' : '失败:' + n.message)).join(',');
|
||||||
|
notifyResult.value = text;
|
||||||
|
} catch (e) { notifyResult.value = '失败:' + e.message; }
|
||||||
|
}
|
||||||
|
return { store, settings, save: saveSettings, applyDark, notifyChannels, notifyResult, testNotify, checkRenewals };
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ================= 资产编辑模态框 ================= */
|
/* ================= 资产编辑模态框 ================= */
|
||||||
|
|||||||
Reference in New Issue
Block a user