v2.1: 新增通知提醒功能和安全增强
功能: - 通知提醒系统: 还款日提醒、逾期提醒、系统消息横幅、登录时通知 - 通知中心UI: 铃铛图标 + 下拉列表 + 未读徽章 - 定时任务: APScheduler 每小时检查即将到期的还款 - 邮件通知: SMTP 发送还款提醒和安全警告 - 异常登录通知: 3/5/10次失败触发网页和邮件通知 安全: - 登录安全告警: 异常登录次数达到阈值时发送通知 - 密码重置链接: 一次性使用, 5分钟有效期 - 登录频率限制: IP级和账户级限制 技术: - 后端: FastAPI + SQLAlchemy + PostgreSQL - 前端: 纯 HTML/CSS/JS - 部署: Docker Compose (新增 build 配置)
This commit is contained in:
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
79
backend/app/services/calculator.py
Normal file
79
backend/app/services/calculator.py
Normal file
@@ -0,0 +1,79 @@
|
||||
from datetime import datetime, timedelta
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
|
||||
def calculate_monthly_rate(interest_rate: float, rate_type: str) -> float:
|
||||
if rate_type == "annual":
|
||||
return interest_rate / 100 / 12
|
||||
return interest_rate / 100
|
||||
|
||||
|
||||
def generate_equal_installment_plan(
|
||||
amount: int, interest_rate: float, rate_type: str,
|
||||
period_count: int, start_date: datetime
|
||||
) -> list[dict]:
|
||||
mr = calculate_monthly_rate(interest_rate, rate_type)
|
||||
if mr == 0:
|
||||
principal_each = amount // period_count
|
||||
plans = []
|
||||
for i in range(period_count):
|
||||
due = start_date + relativedelta(months=i + 1)
|
||||
p = principal_each if i < period_count - 1 else amount - principal_each * (period_count - 1)
|
||||
plans.append({"due_date": due, "principal_due": p, "interest_due": 0, "total_due": p})
|
||||
return plans
|
||||
|
||||
mp = amount * mr * (1 + mr) ** period_count / ((1 + mr) ** period_count - 1)
|
||||
plans = []
|
||||
remaining = amount
|
||||
for i in range(period_count):
|
||||
due = start_date + relativedelta(months=i + 1)
|
||||
interest = int(remaining * mr)
|
||||
principal = int(mp) - interest
|
||||
if i == period_count - 1:
|
||||
principal = remaining
|
||||
interest = int(remaining * mr)
|
||||
remaining -= principal
|
||||
total = principal + interest
|
||||
plans.append({"due_date": due, "principal_due": principal, "interest_due": interest, "total_due": total})
|
||||
return plans
|
||||
|
||||
|
||||
def generate_interest_first_plan(
|
||||
amount: int, interest_rate: float, rate_type: str,
|
||||
period_count: int, start_date: datetime
|
||||
) -> list[dict]:
|
||||
mr = calculate_monthly_rate(interest_rate, rate_type)
|
||||
plans = []
|
||||
for i in range(period_count):
|
||||
due = start_date + relativedelta(months=i + 1)
|
||||
interest = int(amount * mr)
|
||||
principal = amount if i == period_count - 1 else 0
|
||||
total = principal + interest
|
||||
plans.append({"due_date": due, "principal_due": principal, "interest_due": interest, "total_due": total})
|
||||
return plans
|
||||
|
||||
|
||||
def generate_lump_sum_plan(
|
||||
amount: int, interest_rate: float, rate_type: str,
|
||||
period_count: int, period_unit: str, start_date: datetime
|
||||
) -> list[dict]:
|
||||
if period_unit == "year":
|
||||
total_interest = int(amount * interest_rate / 100 * period_count)
|
||||
else:
|
||||
total_interest = int(amount * interest_rate / 100 / 12 * period_count)
|
||||
due = start_date + relativedelta(months=period_count) if period_unit == "month" else start_date + relativedelta(years=period_count)
|
||||
return [{"due_date": due, "principal_due": amount, "interest_due": total_interest, "total_due": amount + total_interest}]
|
||||
|
||||
|
||||
def generate_repayment_plan(
|
||||
amount: int, interest_rate: float, rate_type: str,
|
||||
repayment_method: str, period_count: int, period_unit: str,
|
||||
start_date: datetime
|
||||
) -> list[dict]:
|
||||
if repayment_method == "equal_installment":
|
||||
return generate_equal_installment_plan(amount, interest_rate, rate_type, period_count, start_date)
|
||||
elif repayment_method == "interest_first":
|
||||
return generate_interest_first_plan(amount, interest_rate, rate_type, period_count, start_date)
|
||||
elif repayment_method == "lump_sum":
|
||||
return generate_lump_sum_plan(amount, interest_rate, rate_type, period_count, period_unit, start_date)
|
||||
return []
|
||||
233
backend/app/services/email_service.py
Normal file
233
backend/app/services/email_service.py
Normal file
@@ -0,0 +1,233 @@
|
||||
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)
|
||||
41
backend/app/services/notification_service.py
Normal file
41
backend/app/services/notification_service.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.models.models import Notification, NotificationType, User
|
||||
|
||||
|
||||
async def create_notification(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
title: str,
|
||||
message: str,
|
||||
notification_type: str = NotificationType.SYSTEM.value,
|
||||
related_debt_id: int | None = None,
|
||||
related_plan_id: int | None = None,
|
||||
) -> Notification:
|
||||
n = Notification(
|
||||
user_id=user_id,
|
||||
title=title,
|
||||
message=message,
|
||||
type=notification_type,
|
||||
related_debt_id=related_debt_id,
|
||||
related_plan_id=related_plan_id,
|
||||
)
|
||||
db.add(n)
|
||||
return n
|
||||
|
||||
|
||||
async def create_system_broadcast(
|
||||
db: AsyncSession,
|
||||
title: str,
|
||||
message: str,
|
||||
) -> int:
|
||||
users = await db.execute(select(User).where(User.status == "active"))
|
||||
count = 0
|
||||
for user in users.scalars().all():
|
||||
db.add(Notification(
|
||||
user_id=user.id, title=title, message=message,
|
||||
type=NotificationType.SYSTEM.value,
|
||||
))
|
||||
count += 1
|
||||
await db.commit()
|
||||
return count
|
||||
105
backend/app/services/scheduler.py
Normal file
105
backend/app/services/scheduler.py
Normal file
@@ -0,0 +1,105 @@
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from sqlalchemy import select, and_
|
||||
from datetime import datetime, timedelta
|
||||
from app.core.database import async_session
|
||||
from app.core.config import get_settings
|
||||
from app.models.models import (
|
||||
Debt, Notification, NotificationType, User, DebtStatus
|
||||
)
|
||||
from app.services.notification_service import create_notification
|
||||
from app.services.email_service import send_email, build_repayment_reminder_html
|
||||
|
||||
settings = get_settings()
|
||||
scheduler = AsyncIOScheduler()
|
||||
|
||||
|
||||
async def check_upcoming_repayments():
|
||||
async with async_session() as db:
|
||||
now = datetime.utcnow()
|
||||
reminder_days = settings.REMINDER_DAYS_BEFORE
|
||||
check_window = now + timedelta(days=reminder_days)
|
||||
|
||||
result = await db.execute(
|
||||
select(Debt).where(
|
||||
and_(
|
||||
Debt.status == DebtStatus.ACTIVE.value,
|
||||
Debt.schedule_data.isnot(None),
|
||||
)
|
||||
)
|
||||
)
|
||||
debts = result.scalars().all()
|
||||
|
||||
for debt in debts:
|
||||
schedule = debt.schedule_data or []
|
||||
paid_periods = debt.paid_periods or []
|
||||
user_result = await db.execute(select(User).where(User.id == debt.user_id))
|
||||
user = user_result.scalar_one_or_none()
|
||||
if not user:
|
||||
continue
|
||||
|
||||
for item in schedule:
|
||||
p = item.get("p")
|
||||
if p in paid_periods:
|
||||
continue
|
||||
|
||||
try:
|
||||
due_date = datetime.strptime(item["date"], "%Y-%m-%d")
|
||||
except (ValueError, KeyError):
|
||||
continue
|
||||
|
||||
if due_date > check_window:
|
||||
continue
|
||||
|
||||
is_overdue = due_date < now
|
||||
amount_yuan = item.get("pay", 0)
|
||||
days_left = (due_date - now).days
|
||||
|
||||
if is_overdue:
|
||||
ntype = NotificationType.REPAYMENT_OVERDUE.value
|
||||
title = f"逾期提醒:{debt.creditor_name} 第{p}期还款已逾期"
|
||||
msg = f"您在 {debt.creditor_name} 的第{p}期 ¥{amount_yuan:,.2f} 还款已于 {item['date']} 逾期,请尽快处理。"
|
||||
else:
|
||||
ntype = NotificationType.REPAYMENT_DUE.value
|
||||
title = f"还款提醒:{debt.creditor_name} 第{p}期将在 {days_left} 天后到期"
|
||||
msg = f"您在 {debt.creditor_name} 的第{p}期 ¥{amount_yuan:,.2f} 将于 {item['date']} 到期,请按时还款。"
|
||||
|
||||
existing = await db.execute(
|
||||
select(Notification).where(
|
||||
and_(
|
||||
Notification.user_id == user.id,
|
||||
Notification.related_debt_id == debt.id,
|
||||
Notification.title == title,
|
||||
)
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
continue
|
||||
|
||||
await create_notification(
|
||||
db, user.id, title, msg, ntype,
|
||||
related_debt_id=debt.id,
|
||||
)
|
||||
|
||||
if user.email and "@" in user.email:
|
||||
html = build_repayment_reminder_html(
|
||||
user.nickname or user.email,
|
||||
debt.creditor_name,
|
||||
amount_yuan,
|
||||
item["date"],
|
||||
days_left,
|
||||
is_overdue,
|
||||
)
|
||||
await send_email(user.email, title, html)
|
||||
|
||||
await db.commit()
|
||||
|
||||
|
||||
def start_scheduler():
|
||||
scheduler.add_job(
|
||||
check_upcoming_repayments,
|
||||
"interval",
|
||||
minutes=settings.NOTIFICATION_CHECK_INTERVAL_MINUTES,
|
||||
id="repayment_check",
|
||||
replace_existing=True,
|
||||
)
|
||||
scheduler.start()
|
||||
Reference in New Issue
Block a user