v2.1: 新增通知提醒功能和安全增强

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

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

技术:
- 后端: FastAPI + SQLAlchemy + PostgreSQL
- 前端: 纯 HTML/CSS/JS
- 部署: Docker Compose (新增 build 配置)
This commit is contained in:
OP
2026-06-28 18:06:56 +08:00
commit ffca168dbe
41 changed files with 4834 additions and 0 deletions

View File

@@ -0,0 +1,101 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from datetime import datetime, timedelta
from app.core.database import get_db
from app.models.models import User, Debt, RepaymentPlan, RepaymentRecord, PlanStatus, DebtStatus
from app.schemas.schemas import RepaymentRecordCreate, RepaymentRecordResponse, DashboardSummary
from app.core.deps import get_current_user
router = APIRouter(prefix="/repayments", tags=["还款管理"])
def _naive(dt: datetime) -> datetime:
if dt and dt.tzinfo:
return dt.replace(tzinfo=None)
return dt
@router.post("", response_model=RepaymentRecordResponse, status_code=201)
async def create_repayment(
req: RepaymentRecordCreate,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Debt).where(Debt.id == req.debt_id))
debt = result.scalar_one_or_none()
if not debt:
raise HTTPException(status_code=404, detail="债务不存在")
if debt.user_id != user.id and user.role != "admin":
raise HTTPException(status_code=403, detail="无权为此债务还款")
record = RepaymentRecord(
debt_id=req.debt_id, plan_id=req.plan_id,
amount=req.amount, paid_date=_naive(req.paid_date), notes=req.notes,
)
db.add(record)
if req.plan_id:
plan_result = await db.execute(select(RepaymentPlan).where(RepaymentPlan.id == req.plan_id))
plan = plan_result.scalar_one_or_none()
if plan:
plan.status = PlanStatus.PAID.value
total_paid_result = await db.execute(
select(func.coalesce(func.sum(RepaymentRecord.amount), 0)).where(RepaymentRecord.debt_id == req.debt_id)
)
total_paid = total_paid_result.scalar() + req.amount
if total_paid >= debt.amount:
debt.status = DebtStatus.PAID.value
await db.commit()
await db.refresh(record)
return record
@router.get("/dashboard", response_model=DashboardSummary)
async def get_dashboard(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
debts_result = await db.execute(select(Debt).where(Debt.status == DebtStatus.ACTIVE.value))
debts = debts_result.scalars().all()
total_debt = sum(d.amount for d in debts)
total_paid = 0
for d in debts:
pr = await db.execute(select(func.coalesce(func.sum(RepaymentRecord.amount), 0)).where(RepaymentRecord.debt_id == d.id))
total_paid += pr.scalar()
active_count = sum(1 for d in debts if d.status == DebtStatus.ACTIVE.value)
overdue_count = sum(1 for d in debts if d.status == DebtStatus.OVERDUE.value)
paid_all = await db.execute(select(Debt).where(Debt.status == DebtStatus.PAID.value))
paid_count = len(paid_all.scalars().all())
now = datetime.utcnow()
upcoming = []
for d in debts:
if d.status != DebtStatus.ACTIVE.value:
continue
plans_result = await db.execute(
select(RepaymentPlan).where(
RepaymentPlan.debt_id == d.id,
RepaymentPlan.status == PlanStatus.PENDING.value,
RepaymentPlan.due_date <= now + timedelta(days=7),
).order_by(RepaymentPlan.due_date)
)
for p in plans_result.scalars().all():
upcoming.append({
"debt_id": d.id, "creditor": d.creditor_name,
"due_date": p.due_date.isoformat(), "amount": p.total_due,
"overdue": p.due_date < now,
})
upcoming.sort(key=lambda x: x["due_date"])
return DashboardSummary(
total_debt=total_debt, total_paid=total_paid,
total_remaining=total_debt - total_paid,
active_count=active_count, overdue_count=overdue_count,
paid_count=paid_count, upcoming_payments=upcoming[:10],
)