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/api/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
429
backend/app/api/admin.py
Normal file
429
backend/app/api/admin.py
Normal 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
337
backend/app/api/auth.py
Normal 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
172
backend/app/api/debts.py
Normal 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
152
backend/app/api/loans.py
Normal 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}
|
||||
91
backend/app/api/notifications.py
Normal file
91
backend/app/api/notifications.py
Normal 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": "已删除"}
|
||||
101
backend/app/api/repayments.py
Normal file
101
backend/app/api/repayments.py
Normal 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],
|
||||
)
|
||||
Reference in New Issue
Block a user