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

429
backend/app/api/admin.py Normal file
View File

@@ -0,0 +1,429 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, update
from pydantic import BaseModel
from app.core.database import get_db
from app.models.models import User, Debt, AuditLog, Group, SystemSetting
from app.schemas.schemas import (
AdminUserResponse, AdminDashboard, SystemSettingUpdate,
GroupCreate, GroupResponse, GroupMemberAdd, EmailServiceConfig,
)
from app.core.deps import require_admin
from app.core.security import hash_password
router = APIRouter(prefix="/admin", tags=["管理后台"])
@router.get("/dashboard", response_model=AdminDashboard)
async def admin_dashboard(
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
users_count = await db.execute(select(func.count(User.id)))
pending_count = await db.execute(select(func.count(User.id)).where(User.status == "pending"))
active_count = await db.execute(select(func.count(User.id)).where(User.status == "active"))
disabled_count = await db.execute(select(func.count(User.id)).where(User.status == "disabled"))
return AdminDashboard(
total_users=users_count.scalar(),
active_debts=0,
total_amount=0,
overdue_count=0,
rate_distribution={},
status_distribution={"pending": pending_count.scalar(), "active": active_count.scalar(), "disabled": disabled_count.scalar()},
)
@router.get("/users", response_model=list[AdminUserResponse])
async def list_users(
page: int = Query(1, ge=1),
size: int = Query(50, ge=1, le=200),
search: str = "",
status_filter: str = "",
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
q = select(User)
if search:
q = q.where(User.email.ilike(f"%{search}%") | User.nickname.ilike(f"%{search}%"))
if status_filter:
q = q.where(User.status == status_filter)
q = q.order_by(User.created_at.desc()).offset((page - 1) * size).limit(size)
result = await db.execute(q)
users = result.scalars().all()
groups_result = await db.execute(select(Group))
groups_map = {g.id: g.name for g in groups_result.scalars().all()}
out = []
for u in users:
dc = await db.execute(select(func.count(Debt.id)).where(Debt.user_id == u.id))
ta = await db.execute(select(func.coalesce(func.sum(Debt.amount), 0)).where(Debt.user_id == u.id))
ur = AdminUserResponse(
id=u.id, account=u.email, nickname=u.nickname,
real_email=u.real_email or "",
role=u.role, status=u.status, group_id=u.group_id,
created_at=u.created_at, debt_count=dc.scalar(),
total_amount=ta.scalar(), group_name=groups_map.get(u.group_id, ""),
)
out.append(ur)
return out
@router.put("/users/{user_id}/status")
async def set_user_status(
user_id: int,
status: str = "",
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
if status:
user.status = status
else:
user.status = "active" if user.status != "active" else "disabled"
await db.commit()
return {"message": f"用户状态已更新为 {user.status}"}
@router.delete("/users/{user_id}")
async def delete_user(
user_id: int,
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
if user.role == "admin":
raise HTTPException(status_code=400, detail="不能删除管理员")
debts_result = await db.execute(select(Debt).where(Debt.user_id == user_id))
for d in debts_result.scalars().all():
await db.delete(d)
await db.delete(user)
await db.commit()
return {"message": "用户已删除"}
@router.get("/users/{user_id}/debts")
async def view_user_debts(
user_id: int,
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Debt).where(Debt.user_id == user_id))
return [{"id": d.id, "creditor": d.creditor_name, "amount": d.amount, "status": d.status} for d in result.scalars().all()]
@router.get("/logs")
async def get_audit_logs(
page: int = Query(1, ge=1),
size: int = Query(50, ge=1, le=200),
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
q = select(AuditLog).order_by(AuditLog.created_at.desc()).offset((page - 1) * size).limit(size)
result = await db.execute(q)
return [{"id": l.id, "user_id": l.user_id, "action": l.action, "details": l.details, "created_at": l.created_at.isoformat()} for l in result.scalars().all()]
@router.get("/groups", response_model=list[GroupResponse])
async def list_groups(
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Group).order_by(Group.created_at.desc()))
groups = result.scalars().all()
out = []
for g in groups:
mc = await db.execute(select(func.count(User.id)).where(User.group_id == g.id))
gr = GroupResponse.model_validate(g)
gr.member_count = mc.scalar()
out.append(gr)
return out
@router.post("/groups", response_model=GroupResponse, status_code=201)
async def create_group(
req: GroupCreate,
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
group = Group(name=req.name, description=req.description)
db.add(group)
await db.commit()
await db.refresh(group)
gr = GroupResponse.model_validate(group)
gr.member_count = 0
return gr
@router.delete("/groups/{group_id}")
async def delete_group(
group_id: int,
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Group).where(Group.id == group_id))
group = result.scalar_one_or_none()
if not group:
raise HTTPException(status_code=404, detail="组不存在")
await db.execute(select(User).where(User.group_id == group_id).update({"group_id": None}))
await db.delete(group)
await db.commit()
return {"message": "组已删除"}
@router.post("/groups/{group_id}/members")
async def add_group_member(
group_id: int,
req: GroupMemberAdd,
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
group = await db.execute(select(Group).where(Group.id == group_id))
if not group.scalar_one_or_none():
raise HTTPException(status_code=404, detail="组不存在")
user = await db.execute(select(User).where(User.id == req.user_id))
u = user.scalar_one_or_none()
if not u:
raise HTTPException(status_code=404, detail="用户不存在")
u.group_id = group_id
await db.commit()
return {"message": f"用户 {u.email} 已加入组"}
@router.delete("/groups/{group_id}/members/{user_id}")
async def remove_group_member(
group_id: int,
user_id: int,
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(User).where(User.id == user_id, User.group_id == group_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="用户不在此组中")
user.group_id = None
await db.commit()
return {"message": f"用户 {user.email} 已从组中移除"}
class AdminPasswordChange(BaseModel):
new_password: str
class AdminUserCreate(BaseModel):
account: str
password: str
nickname: str = ""
real_email: str = ""
role: str = "user"
group_id: int | None = None
class AdminUserUpdate(BaseModel):
account: str | None = None
nickname: str | None = None
real_email: str | None = None
role: str | None = None
group_id: int | None = None
@router.post("/users", response_model=AdminUserResponse, status_code=201)
async def create_user(
req: AdminUserCreate,
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
account = req.account.strip().lower()
existing = await db.execute(select(User).where(User.email == account))
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="账号已存在")
user = User(
email=account, password_hash=hash_password(req.password),
nickname=req.nickname, real_email=req.real_email, role=req.role, status="active",
group_id=req.group_id,
)
db.add(user)
await db.commit()
await db.refresh(user)
return AdminUserResponse(
id=user.id, account=user.email, nickname=user.nickname,
role=user.role, status=user.status, group_id=user.group_id,
created_at=user.created_at, debt_count=0, total_amount=0, group_name="",
)
@router.put("/users/{user_id}")
async def update_user(
user_id: int,
req: AdminUserUpdate,
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
if req.account is not None:
account = req.account.strip().lower()
existing = await db.execute(select(User).where(User.email == account, User.id != user_id))
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="账号已存在")
user.email = account
if req.nickname is not None:
user.nickname = req.nickname
if req.real_email is not None:
user.real_email = req.real_email
if req.role is not None:
user.role = req.role
if req.group_id is not None or req.group_id == "":
user.group_id = req.group_id if req.group_id else None
await db.commit()
return {"message": "用户信息已更新"}
RESET_TOKEN_TTL_HOURS = 24
@router.put("/users/{user_id}/password")
async def admin_reset_password(
user_id: int,
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
import secrets
from datetime import datetime, timedelta
from app.models.models import SystemSetting
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
if not user.real_email:
raise HTTPException(status_code=400, detail="该用户未绑定邮箱,无法发送重置链接")
token = secrets.token_urlsafe(32)
expires_at = (datetime.utcnow() + timedelta(hours=RESET_TOKEN_TTL_HOURS)).isoformat()
key = f"pwd_reset:{user.id}"
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
setting = result.scalar_one_or_none()
value = f"{token}|{expires_at}"
if setting:
setting.value = value
else:
db.add(SystemSetting(key=key, value=value))
await db.commit()
from app.services.email_service import send_password_reset_link_email, get_site_url
site_url = await get_site_url()
reset_url = f"{site_url}/reset-password.html?token={token}&uid={user.id}"
await send_password_reset_link_email(user, reset_url, RESET_TOKEN_TTL_HOURS, f"管理员 {admin.email}")
return {"message": f"重置链接已发送至 {user.real_email}"}
SMTP_KEYS = ["smtp_host", "smtp_port", "smtp_user", "smtp_password", "smtp_from", "smtp_use_tls"]
@router.get("/email-config", response_model=EmailServiceConfig)
async def get_email_config(
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(SystemSetting).where(SystemSetting.key.in_(SMTP_KEYS)))
settings = {r.key: r.value for r in result.scalars().all()}
return EmailServiceConfig(
smtp_host=settings.get("smtp_host", ""),
smtp_port=int(settings.get("smtp_port", "465")),
smtp_user=settings.get("smtp_user", ""),
smtp_password=settings.get("smtp_password", ""),
smtp_from=settings.get("smtp_from", ""),
smtp_use_tls=settings.get("smtp_use_tls", "true") == "true",
)
@router.put("/email-config")
async def update_email_config(
req: EmailServiceConfig,
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
values = {
"smtp_host": req.smtp_host,
"smtp_port": str(req.smtp_port),
"smtp_user": req.smtp_user,
"smtp_password": req.smtp_password,
"smtp_from": req.smtp_from,
"smtp_use_tls": "true" if req.smtp_use_tls else "false",
}
for key, value in values.items():
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()
return {"message": "邮件服务配置已保存"}
class EmailTestRequest(BaseModel):
to_email: str
@router.post("/email-test")
async def test_email(
req: EmailTestRequest,
admin: User = Depends(require_admin),
):
from app.services.email_service import send_email
html = """
<div style="font-family:-apple-system,sans-serif;max-width:480px;margin:0 auto;padding:32px">
<h2 style="color:#16a34a;margin-bottom:16px">邮件服务测试</h2>
<p>您好,</p>
<p>这是一封债务管理系统的邮件服务测试邮件。</p>
<p>如果您收到此邮件,说明邮件服务器配置正确。</p>
<div style="background:#f5f5f7;border-radius:12px;padding:20px;margin:16px 0;text-align:center">
<p style="font-size:24px;margin:0">✅</p>
<p style="color:#16a34a;font-weight:600;margin:8px 0 0 0">邮件服务正常</p>
</div>
<p style="color:#6e6e73;font-size:13px">—— 债务管理系统</p>
</div>
"""
ok = await send_email(req.to_email, "【债务管理系统】邮件服务测试", html)
if ok:
return {"message": "发送成功,邮件服务器配置正确。"}
raise HTTPException(status_code=500, detail="邮件发送失败,请检查 SMTP 配置")
class SiteUrlUpdate(BaseModel):
site_url: str
@router.get("/site-url")
async def get_site_url_config(
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(SystemSetting).where(SystemSetting.key == "site_url"))
setting = result.scalar_one_or_none()
return {"site_url": setting.value if setting else ""}
@router.put("/site-url")
async def update_site_url(
req: SiteUrlUpdate,
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
url = req.site_url.strip().rstrip("/")
result = await db.execute(select(SystemSetting).where(SystemSetting.key == "site_url"))
setting = result.scalar_one_or_none()
if setting:
setting.value = url
else:
db.add(SystemSetting(key="site_url", value=url))
await db.commit()
return {"message": "站点地址已保存"}

337
backend/app/api/auth.py Normal file
View File

@@ -0,0 +1,337 @@
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from pydantic import BaseModel
from datetime import datetime, timedelta
import secrets
from app.core.database import get_db
from app.core.security import hash_password, verify_password, create_access_token, create_refresh_token, decode_token
from app.models.models import User, Group, SystemSetting
from app.schemas.schemas import (
RegisterRequest, LoginRequest, TokenResponse,
RefreshRequest, UserProfile, ChangePasswordRequest,
ForgotPasswordRequest, VerifyResetCodeRequest, ResetPasswordRequest,
)
from app.core.deps import get_current_user
router = APIRouter(prefix="/auth", tags=["认证"])
class RegisterResponse(BaseModel):
message: str
@router.post("/register", response_model=RegisterResponse)
async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
account = req.account.strip().lower()
result = await db.execute(select(User).where(User.email == account))
if result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="账号已注册")
user = User(
email=account, password_hash=hash_password(req.password),
nickname=req.nickname, status="pending", group_id=req.group_id,
)
db.add(user)
await db.commit()
return RegisterResponse(message="注册成功,等待管理员审批")
@router.post("/login", response_model=TokenResponse)
async def login(
req: LoginRequest,
request: Request,
db: AsyncSession = Depends(get_db),
):
from app.core.rate_limit import record_login_failure, clear_login_failures, get_account_failure_count, get_unnotified_threshold
ip = request.client.host if request.client else "unknown"
account = req.account.strip().lower()
result = await db.execute(select(User).where(User.email == account))
user = result.scalar_one_or_none()
if not user or not verify_password(req.password, user.password_hash):
record_login_failure(ip, account)
count = get_account_failure_count(account)
threshold = get_unnotified_threshold(account)
if threshold and user:
from app.services.email_service import send_login_alert_email
from app.services.notification_service import create_notification
from app.models.models import NotificationType
from app.core.database import async_session
level_map = {3: "注意", 5: "警告", 10: "严重"}
level = level_map.get(threshold, "注意")
title = f"【安全{level}】异常登录 {count}"
message = f"您的账号在 5 分钟内出现 {count} 次异常登录尝试,来源 IP: {ip}。若非本人操作,请立即修改密码。"
async with async_session() as notif_db:
await create_notification(
notif_db, user.id, title, message,
NotificationType.ACCOUNT.value,
)
await notif_db.commit()
if user.real_email:
await send_login_alert_email(user, count, threshold, ip)
raise HTTPException(status_code=401, detail="账号或密码错误")
if user.status == "pending":
raise HTTPException(status_code=403, detail="账户正在等待管理员审批")
if user.status == "disabled":
raise HTTPException(status_code=403, detail="账户已被禁用")
clear_login_failures(ip, account)
return TokenResponse(
access_token=create_access_token(user.id),
refresh_token=create_refresh_token(user.id),
)
@router.post("/refresh", response_model=TokenResponse)
async def refresh(req: RefreshRequest, db: AsyncSession = Depends(get_db)):
payload = decode_token(req.refresh_token)
if not payload or payload.get("type") != "refresh":
raise HTTPException(status_code=401, detail="Invalid refresh token")
user_id = int(payload["sub"])
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=401, detail="User not found")
if user.status == "pending":
raise HTTPException(status_code=403, detail="账户正在等待管理员审批")
if user.status == "disabled":
raise HTTPException(status_code=403, detail="账户已被禁用")
return TokenResponse(
access_token=create_access_token(user.id),
refresh_token=create_refresh_token(user.id),
)
@router.get("/profile", response_model=UserProfile)
async def get_profile(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
group_name = ""
if user.group_id:
result = await db.execute(select(Group.name).where(Group.id == user.group_id))
row = result.first()
if row:
group_name = row[0]
return UserProfile(
id=user.id, account=user.email, nickname=user.nickname,
real_email=user.real_email or "",
role=user.role, status=user.status, group_id=user.group_id,
group_name=group_name, created_at=user.created_at,
)
@router.get("/groups")
async def list_groups(db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Group).where(Group.id.isnot(None)))
return [{"id": g.id, "name": g.name} for g in result.scalars().all()]
class ProfileUpdate(BaseModel):
nickname: str | None = None
real_email: str | None = None
@router.put("/profile")
async def update_profile(
req: ProfileUpdate,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
if req.nickname is not None:
user.nickname = req.nickname
if req.real_email is not None:
user.real_email = req.real_email
await db.commit()
return {"message": "资料更新成功"}
@router.put("/password")
async def change_password(
req: ChangePasswordRequest,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
if not verify_password(req.old_password, user.password_hash):
raise HTTPException(status_code=400, detail="原密码错误")
user.password_hash = hash_password(req.new_password)
await db.commit()
from app.services.email_service import send_password_changed_email
await send_password_changed_email(user, "修改")
return {"message": "密码修改成功"}
RESET_CODE_TTL_MINUTES = 10
async def _find_user_by_account_or_email(db: AsyncSession, account: str) -> User | None:
account = account.strip().lower()
result = await db.execute(select(User).where(User.email == account))
user = result.scalar_one_or_none()
if user:
return user
result = await db.execute(select(User).where(User.real_email == account))
return result.scalar_one_or_none()
def _build_code_key(user_id: int) -> str:
return f"reset_code:{user_id}"
def _build_code_html(nickname: str, code: str) -> str:
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:24px;margin:16px 0;text-align:center">
<p style="font-size:36px;font-weight:700;letter-spacing:8px;color:#1d1d1f;margin:0">{code}</p>
<p style="color:#6e6e73;font-size:12px;margin:8px 0 0 0">{RESET_CODE_TTL_MINUTES} 分钟内有效</p>
</div>
<p style="color:#6e6e73;font-size:13px">如非本人操作,请忽略此邮件。</p>
<p style="color:#6e6e73;font-size:13px">—— 债务管理系统</p>
</div>
"""
@router.post("/forgot-password")
async def forgot_password(
req: ForgotPasswordRequest,
db: AsyncSession = Depends(get_db),
):
user = await _find_user_by_account_or_email(db, req.account)
if not user or not user.real_email:
return {"message": "如果该账号已绑定邮箱,验证码已发送"}
code = f"{secrets.randbelow(1000000):06d}"
key = _build_code_key(user.id)
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
setting = result.scalar_one_or_none()
value = f"{code}|{(datetime.utcnow() + timedelta(minutes=RESET_CODE_TTL_MINUTES)).isoformat()}"
if setting:
setting.value = value
else:
db.add(SystemSetting(key=key, value=value))
await db.commit()
from app.services.email_service import send_email
html = _build_code_html(user.nickname or user.email, code)
await send_email(user.real_email, "【债务管理系统】密码重置验证码", html)
return {"message": "如果该账号已绑定邮箱,验证码已发送"}
@router.post("/verify-reset-code")
async def verify_reset_code(
req: VerifyResetCodeRequest,
db: AsyncSession = Depends(get_db),
):
user = await _find_user_by_account_or_email(db, req.account)
if not user:
raise HTTPException(status_code=400, detail="账号不存在")
key = _build_code_key(user.id)
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
setting = result.scalar_one_or_none()
if not setting:
raise HTTPException(status_code=400, detail="验证码已过期,请重新获取")
parts = setting.value.split("|")
if len(parts) != 2 or parts[0] != req.code:
raise HTTPException(status_code=400, detail="验证码错误")
try:
expires_at = datetime.fromisoformat(parts[1])
except ValueError:
raise HTTPException(status_code=400, detail="验证码格式错误")
if datetime.utcnow() > expires_at:
raise HTTPException(status_code=400, detail="验证码已过期,请重新获取")
return {"message": "验证码正确"}
@router.post("/reset-password")
async def reset_password(
req: ResetPasswordRequest,
db: AsyncSession = Depends(get_db),
):
user = await _find_user_by_account_or_email(db, req.account)
if not user:
raise HTTPException(status_code=400, detail="账号不存在")
key = _build_code_key(user.id)
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
setting = result.scalar_one_or_none()
if not setting:
raise HTTPException(status_code=400, detail="验证码已过期,请重新获取")
parts = setting.value.split("|")
if len(parts) != 2 or parts[0] != req.code:
raise HTTPException(status_code=400, detail="验证码错误")
try:
expires_at = datetime.fromisoformat(parts[1])
except ValueError:
raise HTTPException(status_code=400, detail="验证码格式错误")
if datetime.utcnow() > expires_at:
raise HTTPException(status_code=400, detail="验证码已过期,请重新获取")
user.password_hash = hash_password(req.new_password)
await db.delete(setting)
await db.commit()
from app.services.email_service import send_password_changed_email
await send_password_changed_email(user, "重置")
return {"message": "密码重置成功"}
class ResetByTokenRequest(BaseModel):
token: str
uid: int
new_password: str
@router.post("/reset-by-token")
async def reset_by_token(
req: ResetByTokenRequest,
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(User).where(User.id == req.uid))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=400, detail="链接无效")
key = f"pwd_reset:{req.uid}"
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
setting = result.scalar_one_or_none()
if not setting:
raise HTTPException(status_code=400, detail="链接已失效,请联系管理员重新发送")
parts = setting.value.split("|")
if len(parts) != 2 or parts[0] != req.token:
raise HTTPException(status_code=400, detail="链接无效")
try:
expires_at = datetime.fromisoformat(parts[1])
except ValueError:
raise HTTPException(status_code=400, detail="链接格式错误")
if datetime.utcnow() > expires_at:
await db.delete(setting)
await db.commit()
raise HTTPException(status_code=400, detail="链接已过期,请联系管理员重新发送")
user.password_hash = hash_password(req.new_password)
await db.delete(setting)
await db.commit()
from app.services.email_service import send_password_changed_email
await send_password_changed_email(user, "重置")
return {"message": "密码重置成功"}
@router.get("/check-reset-token")
async def check_reset_token(
token: str,
uid: int,
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(User).where(User.id == uid))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=400, detail="链接无效")
key = f"pwd_reset:{uid}"
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
setting = result.scalar_one_or_none()
if not setting:
raise HTTPException(status_code=400, detail="链接已失效")
parts = setting.value.split("|")
if len(parts) != 2 or parts[0] != token:
raise HTTPException(status_code=400, detail="链接无效")
try:
expires_at = datetime.fromisoformat(parts[1])
except ValueError:
raise HTTPException(status_code=400, detail="链接格式错误")
if datetime.utcnow() > expires_at:
raise HTTPException(status_code=400, detail="链接已过期")
return {"valid": True, "nickname": user.nickname or user.email}

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()

152
backend/app/api/loans.py Normal file
View File

@@ -0,0 +1,152 @@
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}

View File

@@ -0,0 +1,91 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, update
from app.core.database import get_db
from app.core.deps import get_current_user
from app.models.models import User, Notification
from app.schemas.schemas import NotificationResponse, NotificationListResponse
router = APIRouter(prefix="/notifications", tags=["通知管理"])
@router.get("", response_model=NotificationListResponse)
async def list_notifications(
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100),
unread_only: bool = False,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
q = select(Notification).where(Notification.user_id == user.id)
if unread_only:
q = q.where(Notification.is_read == 0)
total_q = select(func.count(Notification.id)).where(Notification.user_id == user.id)
total = (await db.execute(total_q)).scalar()
q = q.order_by(Notification.created_at.desc()).offset((page - 1) * size).limit(size)
items = (await db.execute(q)).scalars().all()
unread = (await db.execute(
select(func.count(Notification.id)).where(
Notification.user_id == user.id, Notification.is_read == 0
)
)).scalar()
return NotificationListResponse(
items=[NotificationResponse.model_validate(n) for n in items],
total=total,
unread=unread,
)
@router.put("/{notification_id}/read")
async def mark_read(
notification_id: int,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(Notification).where(
Notification.id == notification_id,
Notification.user_id == user.id
)
)
n = result.scalar_one_or_none()
if not n:
raise HTTPException(status_code=404, detail="通知不存在")
n.is_read = 1
await db.commit()
return {"message": "已标记已读"}
@router.put("/read-all")
async def mark_all_read(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await db.execute(
update(Notification).where(
Notification.user_id == user.id,
Notification.is_read == 0
).values(is_read=1)
)
await db.commit()
return {"message": "全部已读"}
@router.delete("/{notification_id}")
async def delete_notification(
notification_id: int,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(Notification).where(
Notification.id == notification_id,
Notification.user_id == user.id
)
)
n = result.scalar_one_or_none()
if not n:
raise HTTPException(status_code=404, detail="通知不存在")
await db.delete(n)
await db.commit()
return {"message": "已删除"}

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],
)