diff --git a/.env.example b/.env.example index f7526d2..1fca2c3 100644 --- a/.env.example +++ b/.env.example @@ -7,3 +7,25 @@ APP_NAME=VPS 资产管理系统 # 留空 = 不校验(适用于纯 Tailscale 内网环境) # 配置后,所有写操作需在请求头携带 X-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 diff --git a/app/core/config.py b/app/core/config.py index 457b3e9..1d3683d 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -24,5 +24,20 @@ class Settings: # 凭证加密主密钥(Fernet);生成:python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())' 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() diff --git a/app/main.py b/app/main.py index e3e63d9..5f4ac58 100644 --- a/app/main.py +++ b/app/main.py @@ -19,7 +19,7 @@ from sqlmodel import Session from app.core.config import settings from app.core.seed import seed_providers 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 STATIC_DIR = BASE_DIR / "static" @@ -54,6 +54,7 @@ app.include_router(stats.router) app.include_router(agent.router) app.include_router(monitor.router) app.include_router(sync.router) +app.include_router(notify.router) app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") diff --git a/app/routers/notify.py b/app/routers/notify.py new file mode 100644 index 0000000..579f3eb --- /dev/null +++ b/app/routers/notify.py @@ -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) diff --git a/app/services/notification_service.py b/app/services/notification_service.py new file mode 100644 index 0000000..28f2119 --- /dev/null +++ b/app/services/notification_service.py @@ -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 diff --git a/app/services/renewal_service.py b/app/services/renewal_service.py new file mode 100644 index 0000000..5381a99 --- /dev/null +++ b/app/services/renewal_service.py @@ -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 diff --git a/deploy/vps-renewal-check.service b/deploy/vps-renewal-check.service new file mode 100644 index 0000000..0fc24a2 --- /dev/null +++ b/deploy/vps-renewal-check.service @@ -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 diff --git a/deploy/vps-renewal-check.timer b/deploy/vps-renewal-check.timer new file mode 100644 index 0000000..f8eb7f2 --- /dev/null +++ b/deploy/vps-renewal-check.timer @@ -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 diff --git a/scripts/check_renewals.py b/scripts/check_renewals.py new file mode 100644 index 0000000..4c51004 --- /dev/null +++ b/scripts/check_renewals.py @@ -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()) diff --git a/static/js/app.js b/static/js/app.js index 70e4820..a42a05c 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -550,6 +550,18 @@ const SettingsView = { +