Files
debt-manager/backend/app/api/loans.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

153 lines
5.1 KiB
Python

from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from datetime import datetime
from pydantic import BaseModel
from app.core.database import get_db
from app.models.models import User, Debt
from app.schemas.schemas import LoanCreate, LoanResponse
from app.core.deps import get_current_user
router = APIRouter(prefix="/loans", tags=["借款管理"])
METHOD_MAP = {"e": "equal_installment", "p": "interest_first", "i": "lump_sum"}
METHOD_REV = {"equal_installment": "e", "interest_first": "p", "lump_sum": "i"}
class PaidUpdate(BaseModel):
paid: list[int]
def _debt_to_loan(d: Debt, owner_name: str = "") -> LoanResponse:
return LoanResponse(
id=d.id,
name=d.creditor_name,
date=d.borrow_date.strftime("%Y-%m-%d") if d.borrow_date else "",
amount=d.amount / 100,
rate=d.interest_rate,
periods=d.period_count,
method=METHOD_REV.get(d.repayment_method, "e"),
purpose=d.purpose or "",
schedule=d.schedule_data or [],
paid=d.paid_periods or [],
user_id=d.user_id,
owner_name=owner_name,
)
async def _get_visible_user_ids(user: User, db: AsyncSession) -> list[int]:
if user.role == "admin":
result = await db.execute(select(User.id))
return [r[0] for r in result.all()]
if user.group_id:
result = await db.execute(select(User.id).where(User.group_id == user.group_id))
ids = [r[0] for r in result.all()]
if user.id not in ids:
ids.append(user.id)
return ids
return [user.id]
@router.get("", response_model=list[LoanResponse])
async def list_loans(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
visible_ids = await _get_visible_user_ids(user, db)
result = await db.execute(select(Debt).where(Debt.user_id.in_(visible_ids)).order_by(Debt.created_at.desc()))
debts = result.scalars().all()
users_result = await db.execute(select(User.id, User.nickname))
users_map = {uid: (nick or "") for uid, nick in users_result.all()}
return [_debt_to_loan(d, users_map.get(d.user_id, "")) for d in debts]
@router.post("", response_model=LoanResponse, status_code=201)
async def create_loan(
req: LoanCreate,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
debt = Debt(
user_id=user.id,
creditor_name=req.name,
amount=int(req.amount * 100),
borrow_date=datetime.strptime(req.date, "%Y-%m-%d"),
interest_rate=req.rate,
rate_type="annual",
repayment_method=METHOD_MAP.get(req.method, "equal_installment"),
period_count=req.periods,
period_unit="month",
purpose=req.purpose,
schedule_data=req.schedule,
paid_periods=req.paid,
)
db.add(debt)
await db.commit()
await db.refresh(debt)
return _debt_to_loan(debt, user.nickname or "")
@router.put("/{loan_id}", response_model=LoanResponse)
async def update_loan(
loan_id: int,
req: LoanCreate,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Debt).where(Debt.id == loan_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="无权修改")
debt.creditor_name = req.name
debt.amount = int(req.amount * 100)
debt.borrow_date = datetime.strptime(req.date, "%Y-%m-%d")
debt.interest_rate = req.rate
debt.repayment_method = METHOD_MAP.get(req.method, "equal_installment")
debt.period_count = req.periods
debt.purpose = req.purpose
debt.schedule_data = req.schedule
debt.paid_periods = req.paid
await db.commit()
await db.refresh(debt)
return _debt_to_loan(debt, user.nickname or "")
@router.delete("/{loan_id}")
async def delete_loan(
loan_id: int,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Debt).where(Debt.id == loan_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="无权删除")
await db.delete(debt)
await db.commit()
return {"message": "已删除"}
@router.patch("/{loan_id}/toggle-paid")
async def toggle_paid(
loan_id: int,
req: PaidUpdate,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Debt).where(Debt.id == loan_id))
debt = result.scalar_one_or_none()
if not debt:
raise HTTPException(status_code=404, detail="借款不存在")
if debt.user_id != user.id:
raise HTTPException(status_code=403, detail="只能标记自己的还款")
debt.paid_periods = sorted(req.paid)
await db.commit()
return {"paid": debt.paid_periods}