Files
debt-manager/backend/app/services/email_service.py
OP ffca168dbe v2.1: 新增通知提醒功能和安全增强
功能:
- 通知提醒系统: 还款日提醒、逾期提醒、系统消息横幅、登录时通知
- 通知中心UI: 铃铛图标 + 下拉列表 + 未读徽章
- 定时任务: APScheduler 每小时检查即将到期的还款
- 邮件通知: SMTP 发送还款提醒和安全警告
- 异常登录通知: 3/5/10次失败触发网页和邮件通知

安全:
- 登录安全告警: 异常登录次数达到阈值时发送通知
- 密码重置链接: 一次性使用, 5分钟有效期
- 登录频率限制: IP级和账户级限制

技术:
- 后端: FastAPI + SQLAlchemy + PostgreSQL
- 前端: 纯 HTML/CSS/JS
- 部署: Docker Compose (新增 build 配置)
2026-06-28 18:06:56 +08:00

234 lines
9.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import aiosmtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from sqlalchemy import select
from app.core.database import async_session
from app.core.config import get_settings
from app.models.models import SystemSetting
settings = get_settings()
async def get_site_url() -> str:
async with async_session() as db:
result = await db.execute(select(SystemSetting).where(SystemSetting.key == "site_url"))
setting = result.scalar_one_or_none()
if setting and setting.value.strip():
return setting.value.strip().rstrip("/")
return "http://localhost:806"
async def get_smtp_config() -> dict:
async with async_session() as db:
result = await db.execute(
select(SystemSetting).where(
SystemSetting.key.in_([
"smtp_host", "smtp_port", "smtp_user",
"smtp_password", "smtp_from", "smtp_use_tls",
])
)
)
rows = {r.key: r.value for r in result.scalars().all()}
if rows.get("smtp_host") and rows.get("smtp_user"):
return {
"host": rows.get("smtp_host", ""),
"port": int(rows.get("smtp_port", "465")),
"user": rows.get("smtp_user", ""),
"password": rows.get("smtp_password", ""),
"from_addr": rows.get("smtp_from") or rows.get("smtp_user", ""),
"use_tls": rows.get("smtp_use_tls", "true") == "true",
}
return {
"host": settings.SMTP_HOST,
"port": settings.SMTP_PORT,
"user": settings.SMTP_USER,
"password": settings.SMTP_PASSWORD,
"from_addr": settings.SMTP_FROM or settings.SMTP_USER,
"use_tls": settings.SMTP_USE_TLS,
}
async def send_email(to_email: str, subject: str, body: str) -> bool:
smtp = await get_smtp_config()
if not smtp["host"] or not smtp["user"]:
return False
msg = MIMEMultipart("alternative")
msg["From"] = smtp["from_addr"]
msg["To"] = to_email
msg["Subject"] = subject
msg.attach(MIMEText(body, "html", "utf-8"))
try:
await aiosmtplib.send(
msg,
hostname=smtp["host"],
port=smtp["port"],
username=smtp["user"],
password=smtp["password"],
use_tls=smtp["use_tls"],
)
return True
except Exception:
return False
def build_repayment_reminder_html(
nickname: str, creditor: str, amount_yuan: float,
due_date: str, days_left: int, is_overdue: bool = False
) -> str:
status_text = f"已逾期 {abs(days_left)}" if is_overdue else f"还有 {days_left} 天到期"
color = "#ff3b30" if is_overdue else "#0071e3"
title = "逾期提醒" if is_overdue else "还款提醒"
return f"""
<div style="font-family:-apple-system,sans-serif;max-width:480px;margin:0 auto;padding:32px">
<h2 style="color:{color};margin-bottom:16px">{title}</h2>
<p>Hi {nickname}</p>
<p>您有一笔借款即将到期:</p>
<div style="background:#f5f5f7;border-radius:12px;padding:20px;margin:16px 0">
<p><strong>债权人:</strong>{creditor}</p>
<p><strong>金额:</strong>¥{amount_yuan:,.2f}</p>
<p><strong>到期日:</strong>{due_date}</p>
<p style="color:{color};font-weight:600">{status_text}</p>
</div>
<p style="color:#6e6e73;font-size:13px">—— 债务管理系统</p>
</div>
"""
_LOGIN_ALERT_CONFIG = {
3: {
"level": "注意",
"color": "#ff9500",
"bg": "rgba(255,149,0,.06)",
"border": "rgba(255,149,0,.15)",
"icon": "⚠️",
"desc": "您的账号出现了异常登录尝试。",
},
5: {
"level": "警告",
"color": "#ff6b35",
"bg": "rgba(255,107,53,.06)",
"border": "rgba(255,107,53,.15)",
"icon": "🔶",
"desc": "您的账号正遭受多次暴力破解尝试,若非本人操作请立即修改密码。",
},
10: {
"level": "严重",
"color": "#ff3b30",
"bg": "rgba(255,59,48,.08)",
"border": "rgba(255,59,48,.18)",
"icon": "🔴",
"desc": "您的账号已被暴力破解锁定 5 分钟。若非本人操作,请立即修改密码并检查账户安全。",
},
}
def _build_login_alert_html(nickname: str, count: int, threshold: int, ip: str, site_url: str, token: str, uid: int, ttl_minutes: int = 5) -> str:
cfg = _LOGIN_ALERT_CONFIG[threshold]
change_pwd_url = f"{site_url}/reset-password.html?token={token}&uid={uid}"
return f"""
<div style="font-family:-apple-system,sans-serif;max-width:480px;margin:0 auto;padding:32px">
<h2 style="color:{cfg['color']};margin-bottom:16px">{cfg['icon']} 登录安全{cfg['level']}</h2>
<p>Hi {nickname}</p>
<p>{cfg['desc']}</p>
<div style="background:{cfg['bg']};border:1px solid {cfg['border']};border-radius:12px;padding:20px;margin:16px 0">
<p><strong>异常登录次数:</strong><span style="color:{cfg['color']};font-weight:700">{count} 次</span></p>
<p><strong>触发来源 IP</strong>{ip}</p>
<p><strong>时间:</strong>{__import__('datetime').datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</p>
</div>
<div style="text-align:center;margin:24px 0">
<a href="{change_pwd_url}" style="display:inline-block;padding:12px 32px;background:#0071e3;color:#fff;border-radius:8px;text-decoration:none;font-weight:600">修改密码</a>
</div>
<p style="color:#6e6e73;font-size:12px">此链接 {ttl_minutes} 分钟内有效,仅可使用一次。</p>
<p style="color:#6e6e73;font-size:13px">如非本人操作,请忽略此邮件并尽快检查账户安全。</p>
<p style="color:#6e6e73;font-size:13px">—— 债务管理系统</p>
</div>
"""
async def send_login_alert_email(user, count: int, threshold: int, ip: str):
if not user.real_email:
return
import secrets
from datetime import datetime, timedelta
token = secrets.token_urlsafe(32)
ttl_minutes = 5
expires_at = (datetime.utcnow() + timedelta(minutes=ttl_minutes)).isoformat()
key = f"pwd_reset:{user.id}"
value = f"{token}|{expires_at}"
async with async_session() as db:
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
setting = result.scalar_one_or_none()
if setting:
setting.value = value
else:
db.add(SystemSetting(key=key, value=value))
await db.commit()
cfg = _LOGIN_ALERT_CONFIG[threshold]
site_url = await get_site_url()
html = _build_login_alert_html(user.nickname or user.email, count, threshold, ip, site_url, token, user.id, ttl_minutes)
subject = f"{cfg['level']}】账号安全提醒 - 异常登录 {count}"
await send_email(user.real_email, subject, html)
def _build_password_changed_html(nickname: str, action: str, operator: str = "") -> str:
from datetime import datetime
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
return f"""
<div style="font-family:-apple-system,sans-serif;max-width:480px;margin:0 auto;padding:32px">
<h2 style="color:#0071e3;margin-bottom:16px">🔐 密码{action}通知</h2>
<p>Hi {nickname}</p>
<p>您的账户密码已被{action}。</p>
<div style="background:#f5f5f7;border-radius:12px;padding:20px;margin:16px 0">
<p><strong>操作类型:</strong>密码{action}</p>
<p><strong>操作时间:</strong>{now}</p>
{"<p><strong>操作人:</strong>" + operator + "</p>" if operator else ""}
</div>
<p style="color:#6e6e73;font-size:13px">如非本人操作,请立即联系管理员并修改密码。</p>
<p style="color:#6e6e73;font-size:13px">—— 债务管理系统</p>
</div>
"""
async def send_password_changed_email(user, action: str, operator: str = ""):
if not user.real_email:
return
html = _build_password_changed_html(user.nickname or user.email, action, operator)
subject = f"【安全通知】您的密码已被{action}"
await send_email(user.real_email, subject, html)
def _build_password_reset_link_html(nickname: str, reset_url: str, ttl_hours: int, operator: str) -> str:
from datetime import datetime
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
return f"""
<div style="font-family:-apple-system,sans-serif;max-width:480px;margin:0 auto;padding:32px">
<h2 style="color:#0071e3;margin-bottom:16px">🔐 密码重置链接</h2>
<p>Hi {nickname}</p>
<p>管理员已为您发起密码重置,请点击下方按钮设置新密码。</p>
<div style="background:#f5f5f7;border-radius:12px;padding:20px;margin:16px 0">
<p><strong>操作人:</strong>{operator}</p>
<p><strong>发送时间:</strong>{now}</p>
<p><strong>有效期:</strong>{ttl_hours} 小时内有效</p>
</div>
<div style="text-align:center;margin:24px 0">
<a href="{reset_url}" style="display:inline-block;padding:14px 36px;background:#0071e3;color:#fff;border-radius:8px;text-decoration:none;font-weight:600;font-size:15px">重置密码</a>
</div>
<p style="color:#6e6e73;font-size:12px">如非本人操作,请忽略此邮件。</p>
<p style="color:#6e6e73;font-size:13px">—— 债务管理系统</p>
</div>
"""
async def send_password_reset_link_email(user, reset_url: str, ttl_hours: int, operator: str = ""):
if not user.real_email:
return
html = _build_password_reset_link_html(user.nickname or user.email, reset_url, ttl_hours, operator)
subject = "【密码重置】管理员为您重置了密码"
await send_email(user.real_email, subject, html)