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

172
backend/app/api/debts.py Normal file
View File

@@ -0,0 +1,172 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from datetime import datetime
from app.core.database import get_db
from app.models.models import User, Debt, RepaymentPlan, RepaymentRecord, DebtStatus, PlanStatus
from app.schemas.schemas import (
DebtCreate, DebtUpdate, DebtResponse,
RepaymentPlanResponse, RepaymentRecordCreate, RepaymentRecordResponse,
)
from app.core.deps import get_current_user
from app.services.calculator import generate_repayment_plan
router = APIRouter(prefix="/debts", tags=["债务管理"])
def _naive(dt: datetime) -> datetime:
if dt and dt.tzinfo:
return dt.replace(tzinfo=None)
return dt
async def _enrich(db: AsyncSession, d: Debt) -> DebtResponse:
dr = DebtResponse.model_validate(d)
pr = await db.execute(select(func.coalesce(func.sum(RepaymentRecord.amount), 0)).where(RepaymentRecord.debt_id == d.id))
dr.total_paid = pr.scalar()
ir = await db.execute(select(func.coalesce(func.sum(RepaymentPlan.interest_due), 0)).where(RepaymentPlan.debt_id == d.id))
dr.total_interest = ir.scalar()
owner = await db.execute(select(User.nickname, User.email).where(User.id == d.user_id))
row = owner.first()
dr.owner_name = row[0] or row[1] if row else ""
return dr
@router.get("", response_model=list[DebtResponse])
async def list_debts(
status: str | None = None,
creditor: str | None = None,
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100),
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
q = select(Debt)
if status:
q = q.where(Debt.status == status)
if creditor:
q = q.where(Debt.creditor_name.ilike(f"%{creditor}%"))
q = q.order_by(Debt.created_at.desc()).offset((page - 1) * size).limit(size)
result = await db.execute(q)
debts = result.scalars().all()
return [await _enrich(db, d) for d in debts]
@router.post("", response_model=DebtResponse, status_code=201)
async def create_debt(
req: DebtCreate,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
debt = Debt(
user_id=user.id, creditor_name=req.creditor_name, amount=req.amount,
borrow_date=_naive(req.borrow_date), interest_rate=req.interest_rate,
rate_type=req.rate_type, repayment_method=req.repayment_method,
period_count=req.period_count, period_unit=req.period_unit, notes=req.notes,
)
db.add(debt)
await db.flush()
plans = generate_repayment_plan(
req.amount, req.interest_rate, req.rate_type,
req.repayment_method, req.period_count, req.period_unit,
_naive(req.borrow_date),
)
for p in plans:
db.add(RepaymentPlan(debt_id=debt.id, **p))
await db.commit()
await db.refresh(debt)
return await _enrich(db, debt)
@router.get("/{debt_id}", response_model=DebtResponse)
async def get_debt(
debt_id: int,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Debt).where(Debt.id == debt_id))
debt = result.scalar_one_or_none()
if not debt:
raise HTTPException(status_code=404, detail="债务不存在")
return await _enrich(db, debt)
@router.put("/{debt_id}", response_model=DebtResponse)
async def update_debt(
debt_id: int,
req: DebtUpdate,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Debt).where(Debt.id == 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="无权修改此债务")
if debt.status not in ("active",):
raise HTTPException(status_code=400, detail="该债务不可修改")
data = req.model_dump(exclude_unset=True)
for k, v in data.items():
setattr(debt, k, _naive(v) if k == "borrow_date" else v)
if any(k in data for k in ["amount", "interest_rate", "rate_type", "repayment_method", "period_count", "period_unit", "borrow_date"]):
await db.execute(select(RepaymentPlan).where(RepaymentPlan.debt_id == debt.id).delete())
plans = generate_repayment_plan(
debt.amount, debt.interest_rate, debt.rate_type,
debt.repayment_method, debt.period_count, debt.period_unit,
_naive(debt.borrow_date),
)
for p in plans:
db.add(RepaymentPlan(debt_id=debt.id, **p))
await db.commit()
await db.refresh(debt)
return await _enrich(db, debt)
@router.delete("/{debt_id}")
async def delete_debt(
debt_id: int,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Debt).where(Debt.id == 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="无权删除此债务")
has_records = await db.execute(select(func.count(RepaymentRecord.id)).where(RepaymentRecord.debt_id == debt.id))
if has_records.scalar() > 0:
debt.status = DebtStatus.CLOSED.value
await db.commit()
return {"message": "已有还款记录,债务已归档"}
await db.delete(debt)
await db.commit()
return {"message": "债务已删除"}
@router.get("/{debt_id}/plans", response_model=list[RepaymentPlanResponse])
async def get_plans(
debt_id: int,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Debt).where(Debt.id == debt_id))
if not result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="债务不存在")
pr = await db.execute(select(RepaymentPlan).where(RepaymentPlan.debt_id == debt_id).order_by(RepaymentPlan.due_date))
return pr.scalars().all()
@router.get("/{debt_id}/records", response_model=list[RepaymentRecordResponse])
async def get_records(
debt_id: int,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Debt).where(Debt.id == debt_id))
if not result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="债务不存在")
rr = await db.execute(select(RepaymentRecord).where(RepaymentRecord.debt_id == debt_id).order_by(RepaymentRecord.paid_date.desc()))
return rr.scalars().all()