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/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
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],
|
||||
)
|
||||
0
backend/app/core/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
32
backend/app/core/config.py
Normal file
32
backend/app/core/config.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
from functools import lru_cache
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
PROJECT_NAME: str = "债务管理系统"
|
||||
API_V1_PREFIX: str = "/api/v1"
|
||||
DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@db:5432/debt_manager"
|
||||
REDIS_URL: str = "redis://redis:6379/0"
|
||||
SECRET_KEY: str = "your-secret-key-change-in-production"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
||||
CORS_ORIGINS: list[str] = ["http://localhost", "http://localhost:80", "http://localhost:806"]
|
||||
ADMIN_EMAIL: str = "admin@example.com"
|
||||
ADMIN_PASSWORD: str = "admin123"
|
||||
|
||||
SMTP_HOST: str = ""
|
||||
SMTP_PORT: int = 465
|
||||
SMTP_USER: str = ""
|
||||
SMTP_PASSWORD: str = ""
|
||||
SMTP_FROM: str = ""
|
||||
SMTP_USE_TLS: bool = True
|
||||
NOTIFICATION_CHECK_INTERVAL_MINUTES: int = 60
|
||||
REMINDER_DAYS_BEFORE: int = 7
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def get_settings():
|
||||
return Settings()
|
||||
14
backend/app/core/database.py
Normal file
14
backend/app/core/database.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
engine = create_async_engine(settings.DATABASE_URL, echo=False)
|
||||
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
async def get_db():
|
||||
async with async_session() as session:
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
await session.close()
|
||||
31
backend/app/core/deps.py
Normal file
31
backend/app/core/deps.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.core.database import get_db
|
||||
from app.core.security import decode_token
|
||||
from app.models.models import User
|
||||
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> User:
|
||||
token = credentials.credentials
|
||||
payload = decode_token(token)
|
||||
if not payload or payload.get("type") != "access":
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid 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 or user.status == "disabled":
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found or disabled")
|
||||
return user
|
||||
|
||||
|
||||
async def require_admin(user: User = Depends(get_current_user)) -> User:
|
||||
if user.role != "admin":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin required")
|
||||
return user
|
||||
68
backend/app/core/rate_limit.py
Normal file
68
backend/app/core/rate_limit.py
Normal file
@@ -0,0 +1,68 @@
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from fastapi import Request, HTTPException
|
||||
|
||||
MAX_FAILURES = 10
|
||||
WINDOW_SECONDS = 300
|
||||
ALERT_THRESHOLDS = [3, 5, 10]
|
||||
|
||||
_login_attempts: dict[str, list[float]] = defaultdict(list)
|
||||
_account_failures: dict[str, list[float]] = defaultdict(list)
|
||||
_account_notified: dict[str, set[int]] = defaultdict(set)
|
||||
|
||||
|
||||
def record_login_failure(ip: str, account: str = ""):
|
||||
now = time.time()
|
||||
_login_attempts[ip] = [t for t in _login_attempts[ip] if now - t < WINDOW_SECONDS]
|
||||
_login_attempts[ip].append(now)
|
||||
if account:
|
||||
_account_failures[account] = [t for t in _account_failures[account] if now - t < WINDOW_SECONDS]
|
||||
_account_failures[account].append(now)
|
||||
|
||||
|
||||
def clear_login_failures(ip: str, account: str = ""):
|
||||
_login_attempts.pop(ip, None)
|
||||
if account:
|
||||
_account_failures.pop(account, None)
|
||||
_account_notified.pop(account, None)
|
||||
|
||||
|
||||
def get_remaining_seconds(ip: str) -> int:
|
||||
now = time.time()
|
||||
_login_attempts[ip] = [t for t in _login_attempts[ip] if now - t < WINDOW_SECONDS]
|
||||
if not _login_attempts[ip]:
|
||||
return 0
|
||||
oldest = _login_attempts[ip][0]
|
||||
remaining = int(WINDOW_SECONDS - (now - oldest))
|
||||
return max(remaining, 0)
|
||||
|
||||
|
||||
def get_account_failure_count(account: str) -> int:
|
||||
now = time.time()
|
||||
_account_failures[account] = [t for t in _account_failures[account] if now - t < WINDOW_SECONDS]
|
||||
return len(_account_failures[account])
|
||||
|
||||
|
||||
def get_unnotified_threshold(account: str) -> int | None:
|
||||
now = time.time()
|
||||
_account_failures[account] = [t for t in _account_failures[account] if now - t < WINDOW_SECONDS]
|
||||
count = len(_account_failures[account])
|
||||
notified = _account_notified[account]
|
||||
for threshold in ALERT_THRESHOLDS:
|
||||
if count >= threshold and threshold not in notified:
|
||||
notified.add(threshold)
|
||||
return threshold
|
||||
return None
|
||||
|
||||
|
||||
def is_ip_blocked(ip: str) -> bool:
|
||||
return get_remaining_seconds(ip) > 0 and len(_login_attempts[ip]) >= MAX_FAILURES
|
||||
|
||||
|
||||
async def ip_rate_limit_middleware(request: Request, call_next):
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
if is_ip_blocked(ip):
|
||||
remaining = get_remaining_seconds(ip)
|
||||
raise HTTPException(status_code=429, detail=f"登录失败次数过多,请 {remaining} 秒后再试")
|
||||
response = await call_next(request)
|
||||
return response
|
||||
39
backend/app/core/security.py
Normal file
39
backend/app/core/security.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
ALGORITHM = "HS256"
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
return pwd_context.verify(plain, hashed)
|
||||
|
||||
|
||||
def create_token(data: dict, expires_delta: timedelta | None = None) -> str:
|
||||
to_encode = data.copy()
|
||||
expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES))
|
||||
to_encode.update({"exp": expire})
|
||||
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def create_access_token(user_id: int) -> str:
|
||||
return create_token({"sub": str(user_id), "type": "access"}, timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES))
|
||||
|
||||
|
||||
def create_refresh_token(user_id: int) -> str:
|
||||
return create_token({"sub": str(user_id), "type": "refresh"}, timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS))
|
||||
|
||||
|
||||
def decode_token(token: str) -> dict | None:
|
||||
try:
|
||||
return jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
|
||||
except JWTError:
|
||||
return None
|
||||
151
backend/app/main.py
Normal file
151
backend/app/main.py
Normal file
@@ -0,0 +1,151 @@
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from contextlib import asynccontextmanager
|
||||
from app.core.config import get_settings
|
||||
from app.core.database import engine, async_session
|
||||
from app.models.models import Base, User, Debt, RepaymentPlan, RepaymentRecord, Group
|
||||
from app.core.security import hash_password
|
||||
from app.api import auth, debts, repayments, admin, loans, notifications
|
||||
from sqlalchemy import select, text
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
METHOD_REV = {"equal_installment": "e", "interest_first": "p", "lump_sum": "i"}
|
||||
|
||||
|
||||
async def seed_admin():
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
async with async_session() as db:
|
||||
result = await db.execute(select(User))
|
||||
existing = result.scalars().all()
|
||||
if not existing:
|
||||
admin_user = User(
|
||||
email="admin",
|
||||
password_hash=hash_password(settings.ADMIN_PASSWORD),
|
||||
nickname="管理员",
|
||||
role="admin",
|
||||
)
|
||||
db.add(admin_user)
|
||||
test_user = User(
|
||||
email="test",
|
||||
password_hash=hash_password("test123"),
|
||||
nickname="测试用户",
|
||||
role="user",
|
||||
)
|
||||
db.add(test_user)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def migrate_account_names():
|
||||
async with async_session() as db:
|
||||
result = await db.execute(select(User))
|
||||
users = result.scalars().all()
|
||||
for u in users:
|
||||
if "@" in u.email:
|
||||
new_email = u.email.split("@")[0]
|
||||
existing = await db.execute(select(User).where(User.email == new_email, User.id != u.id))
|
||||
if not existing.scalar_one_or_none():
|
||||
u.email = new_email
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def migrate_add_real_email():
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS real_email VARCHAR(255) DEFAULT ''"
|
||||
))
|
||||
|
||||
|
||||
async def migrate_debt_data():
|
||||
async with async_session() as db:
|
||||
result = await db.execute(select(Debt).where(Debt.schedule_data == None))
|
||||
debts = result.scalars().all()
|
||||
for debt in debts:
|
||||
plans_result = await db.execute(
|
||||
select(RepaymentPlan).where(RepaymentPlan.debt_id == debt.id).order_by(RepaymentPlan.due_date)
|
||||
)
|
||||
plans = plans_result.scalars().all()
|
||||
|
||||
schedule = []
|
||||
paid_periods = []
|
||||
for p in plans:
|
||||
item = {
|
||||
"p": p.principal_due / 100,
|
||||
"date": p.due_date.strftime("%Y-%m-%d") if p.due_date else "",
|
||||
"pay": p.total_due / 100,
|
||||
"pr": p.principal_due / 100,
|
||||
"it": p.interest_due / 100,
|
||||
"rp": 0,
|
||||
}
|
||||
schedule.append(item)
|
||||
if p.status == "paid":
|
||||
paid_periods.append(len(schedule))
|
||||
|
||||
remaining = debt.amount / 100
|
||||
for item in schedule:
|
||||
item["rp"] = round(remaining, 2)
|
||||
remaining -= item["pr"]
|
||||
|
||||
debt.schedule_data = schedule
|
||||
debt.paid_periods = paid_periods
|
||||
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def migrate_account_names():
|
||||
async with async_session() as db:
|
||||
result = await db.execute(select(User))
|
||||
users = result.scalars().all()
|
||||
for u in users:
|
||||
if "@" in u.email:
|
||||
u.email = u.email.split("@")[0]
|
||||
await db.commit()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
await seed_admin()
|
||||
await migrate_add_real_email()
|
||||
await migrate_account_names()
|
||||
await migrate_debt_data()
|
||||
from app.services.scheduler import start_scheduler, check_upcoming_repayments
|
||||
start_scheduler()
|
||||
await check_upcoming_repayments()
|
||||
yield
|
||||
from app.services.scheduler import scheduler
|
||||
scheduler.shutdown()
|
||||
|
||||
|
||||
app = FastAPI(title=settings.PROJECT_NAME, lifespan=lifespan)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.CORS_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def rate_limit_middleware(request: Request, call_next):
|
||||
from app.core.rate_limit import is_ip_blocked, get_remaining_seconds
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
if is_ip_blocked(ip):
|
||||
remaining = get_remaining_seconds(ip)
|
||||
return JSONResponse(status_code=429, content={"detail": f"登录失败次数过多,请 {remaining} 秒后再试", "retry_after": remaining})
|
||||
return await call_next(request)
|
||||
|
||||
app.include_router(auth.router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(loans.router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(debts.router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(repayments.router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(admin.router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(notifications.router, prefix=settings.API_V1_PREFIX)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
0
backend/app/models/__init__.py
Normal file
0
backend/app/models/__init__.py
Normal file
171
backend/app/models/models.py
Normal file
171
backend/app/models/models.py
Normal file
@@ -0,0 +1,171 @@
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey, Enum, Text, JSON
|
||||
from sqlalchemy.orm import relationship, DeclarativeBase
|
||||
import enum
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class UserRole(str, enum.Enum):
|
||||
USER = "user"
|
||||
ADMIN = "admin"
|
||||
|
||||
|
||||
class UserStatus(str, enum.Enum):
|
||||
ACTIVE = "active"
|
||||
PENDING = "pending"
|
||||
DISABLED = "disabled"
|
||||
|
||||
|
||||
class DebtStatus(str, enum.Enum):
|
||||
ACTIVE = "active"
|
||||
PAID = "paid"
|
||||
OVERDUE = "overdue"
|
||||
CLOSED = "closed"
|
||||
|
||||
|
||||
class RateType(str, enum.Enum):
|
||||
ANNUAL = "annual"
|
||||
MONTHLY = "monthly"
|
||||
|
||||
|
||||
class RepaymentMethod(str, enum.Enum):
|
||||
EQUAL_INSTALLMENT = "equal_installment"
|
||||
INTEREST_FIRST = "interest_first"
|
||||
LUMP_SUM = "lump_sum"
|
||||
|
||||
|
||||
class PlanStatus(str, enum.Enum):
|
||||
PENDING = "pending"
|
||||
PAID = "paid"
|
||||
OVERDUE = "overdue"
|
||||
|
||||
|
||||
class NotificationType(str, enum.Enum):
|
||||
REPAYMENT_DUE = "repayment_due"
|
||||
REPAYMENT_OVERDUE = "repayment_overdue"
|
||||
SYSTEM = "system"
|
||||
ACCOUNT = "account"
|
||||
|
||||
|
||||
class Group(Base):
|
||||
__tablename__ = "groups"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(100), nullable=False)
|
||||
description = Column(Text, default="")
|
||||
created_at = Column(DateTime, default=lambda: datetime.utcnow())
|
||||
|
||||
members = relationship("User", back_populates="group")
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
email = Column(String(255), unique=True, index=True, nullable=False)
|
||||
real_email = Column(String(255), default="")
|
||||
password_hash = Column(String(255), nullable=False)
|
||||
nickname = Column(String(100), default="")
|
||||
role = Column(String(20), default=UserRole.USER.value)
|
||||
status = Column(String(20), default=UserStatus.ACTIVE.value)
|
||||
group_id = Column(Integer, ForeignKey("groups.id"), nullable=True)
|
||||
created_at = Column(DateTime, default=lambda: datetime.utcnow())
|
||||
|
||||
group = relationship("Group", back_populates="members")
|
||||
debts = relationship("Debt", back_populates="user")
|
||||
audit_logs = relationship("AuditLog", back_populates="user")
|
||||
|
||||
|
||||
class Debt(Base):
|
||||
__tablename__ = "debts"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
creditor_name = Column(String(255), nullable=False)
|
||||
amount = Column(Integer, nullable=False)
|
||||
borrow_date = Column(DateTime, nullable=False)
|
||||
interest_rate = Column(Float, nullable=False)
|
||||
rate_type = Column(String(20), default=RateType.ANNUAL.value)
|
||||
repayment_method = Column(String(30), nullable=False)
|
||||
period_count = Column(Integer, nullable=False)
|
||||
period_unit = Column(String(10), default="month")
|
||||
status = Column(String(20), default=DebtStatus.ACTIVE.value)
|
||||
purpose = Column(String(255), default="")
|
||||
notes = Column(Text, default="")
|
||||
created_at = Column(DateTime, default=lambda: datetime.utcnow())
|
||||
schedule_data = Column(JSON, default=list)
|
||||
paid_periods = Column(JSON, default=list)
|
||||
|
||||
user = relationship("User", back_populates="debts")
|
||||
plans = relationship("RepaymentPlan", back_populates="debt", cascade="all, delete-orphan")
|
||||
records = relationship("RepaymentRecord", back_populates="debt", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class RepaymentPlan(Base):
|
||||
__tablename__ = "repayment_plans"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
debt_id = Column(Integer, ForeignKey("debts.id"), nullable=False)
|
||||
due_date = Column(DateTime, nullable=False)
|
||||
principal_due = Column(Integer, nullable=False)
|
||||
interest_due = Column(Integer, nullable=False)
|
||||
total_due = Column(Integer, nullable=False)
|
||||
status = Column(String(20), default=PlanStatus.PENDING.value)
|
||||
|
||||
debt = relationship("Debt", back_populates="plans")
|
||||
records = relationship("RepaymentRecord", back_populates="plan")
|
||||
|
||||
|
||||
class RepaymentRecord(Base):
|
||||
__tablename__ = "repayment_records"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
debt_id = Column(Integer, ForeignKey("debts.id"), nullable=False)
|
||||
plan_id = Column(Integer, ForeignKey("repayment_plans.id"), nullable=True)
|
||||
amount = Column(Integer, nullable=False)
|
||||
paid_date = Column(DateTime, nullable=False)
|
||||
notes = Column(Text, default="")
|
||||
created_at = Column(DateTime, default=lambda: datetime.utcnow())
|
||||
|
||||
debt = relationship("Debt", back_populates="records")
|
||||
plan = relationship("RepaymentPlan", back_populates="records")
|
||||
|
||||
|
||||
class SystemSetting(Base):
|
||||
__tablename__ = "system_settings"
|
||||
|
||||
key = Column(String(100), primary_key=True)
|
||||
value = Column(Text, nullable=False)
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
__tablename__ = "audit_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
action = Column(String(50), nullable=False)
|
||||
target_type = Column(String(50), default="")
|
||||
target_id = Column(Integer, nullable=True)
|
||||
details = Column(JSON, default=dict)
|
||||
created_at = Column(DateTime, default=lambda: datetime.utcnow())
|
||||
|
||||
user = relationship("User", back_populates="audit_logs")
|
||||
|
||||
|
||||
class Notification(Base):
|
||||
__tablename__ = "notifications"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
title = Column(String(255), nullable=False)
|
||||
message = Column(Text, nullable=False)
|
||||
type = Column(String(30), default=NotificationType.SYSTEM.value)
|
||||
is_read = Column(Integer, default=0)
|
||||
related_debt_id = Column(Integer, nullable=True)
|
||||
related_plan_id = Column(Integer, nullable=True)
|
||||
created_at = Column(DateTime, default=lambda: datetime.utcnow())
|
||||
|
||||
user = relationship("User", backref="notifications")
|
||||
0
backend/app/schemas/__init__.py
Normal file
0
backend/app/schemas/__init__.py
Normal file
256
backend/app/schemas/schemas.py
Normal file
256
backend/app/schemas/schemas.py
Normal file
@@ -0,0 +1,256 @@
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
account: str
|
||||
password: str
|
||||
nickname: str = ""
|
||||
group_id: int | None = None
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
account: str
|
||||
password: str
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class RefreshRequest(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
|
||||
class UserProfile(BaseModel):
|
||||
id: int
|
||||
account: str
|
||||
nickname: str
|
||||
real_email: str = ""
|
||||
role: str
|
||||
status: str
|
||||
group_id: int | None = None
|
||||
group_name: str = ""
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
old_password: str
|
||||
new_password: str
|
||||
|
||||
|
||||
class GroupCreate(BaseModel):
|
||||
name: str
|
||||
description: str = ""
|
||||
|
||||
|
||||
class GroupResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
description: str
|
||||
member_count: int = 0
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class GroupMemberAdd(BaseModel):
|
||||
user_id: int
|
||||
|
||||
|
||||
class LoanCreate(BaseModel):
|
||||
name: str
|
||||
date: str
|
||||
amount: float
|
||||
rate: float
|
||||
periods: int
|
||||
method: str
|
||||
purpose: str = ""
|
||||
schedule: list[dict] = []
|
||||
paid: list[int] = []
|
||||
|
||||
|
||||
class LoanResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
date: str
|
||||
amount: float
|
||||
rate: float
|
||||
periods: int
|
||||
method: str
|
||||
purpose: str
|
||||
schedule: list[dict]
|
||||
paid: list[int]
|
||||
user_id: int
|
||||
owner_name: str = ""
|
||||
|
||||
|
||||
class DebtCreate(BaseModel):
|
||||
creditor_name: str
|
||||
amount: int
|
||||
borrow_date: datetime
|
||||
interest_rate: float
|
||||
rate_type: str = "annual"
|
||||
repayment_method: str
|
||||
period_count: int
|
||||
period_unit: str = "month"
|
||||
notes: str = ""
|
||||
|
||||
|
||||
class DebtUpdate(BaseModel):
|
||||
creditor_name: str | None = None
|
||||
amount: int | None = None
|
||||
borrow_date: datetime | None = None
|
||||
interest_rate: float | None = None
|
||||
rate_type: str | None = None
|
||||
repayment_method: str | None = None
|
||||
period_count: int | None = None
|
||||
period_unit: str | None = None
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class DebtResponse(BaseModel):
|
||||
id: int
|
||||
user_id: int
|
||||
creditor_name: str
|
||||
amount: int
|
||||
borrow_date: datetime
|
||||
interest_rate: float
|
||||
rate_type: str
|
||||
repayment_method: str
|
||||
period_count: int
|
||||
period_unit: str
|
||||
status: str
|
||||
notes: str
|
||||
created_at: datetime
|
||||
total_paid: int = 0
|
||||
total_interest: int = 0
|
||||
owner_name: str = ""
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class RepaymentPlanResponse(BaseModel):
|
||||
id: int
|
||||
debt_id: int
|
||||
due_date: datetime
|
||||
principal_due: int
|
||||
interest_due: int
|
||||
total_due: int
|
||||
status: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class RepaymentRecordCreate(BaseModel):
|
||||
debt_id: int
|
||||
plan_id: int | None = None
|
||||
amount: int
|
||||
paid_date: datetime
|
||||
notes: str = ""
|
||||
|
||||
|
||||
class RepaymentRecordResponse(BaseModel):
|
||||
id: int
|
||||
debt_id: int
|
||||
plan_id: int | None
|
||||
amount: int
|
||||
paid_date: datetime
|
||||
notes: str
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class DashboardSummary(BaseModel):
|
||||
total_debt: int
|
||||
total_paid: int
|
||||
total_remaining: int
|
||||
active_count: int
|
||||
overdue_count: int
|
||||
paid_count: int
|
||||
upcoming_payments: list[dict]
|
||||
|
||||
|
||||
class AdminUserResponse(BaseModel):
|
||||
id: int
|
||||
account: str
|
||||
nickname: str
|
||||
real_email: str = ""
|
||||
role: str
|
||||
status: str
|
||||
group_id: int | None = None
|
||||
group_name: str = ""
|
||||
created_at: datetime
|
||||
debt_count: int = 0
|
||||
total_amount: int = 0
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class AdminDashboard(BaseModel):
|
||||
total_users: int
|
||||
active_debts: int
|
||||
total_amount: int
|
||||
overdue_count: int
|
||||
rate_distribution: dict
|
||||
status_distribution: dict
|
||||
|
||||
|
||||
class SystemSettingUpdate(BaseModel):
|
||||
value: str
|
||||
|
||||
|
||||
class NotificationResponse(BaseModel):
|
||||
id: int
|
||||
user_id: int
|
||||
title: str
|
||||
message: str
|
||||
type: str
|
||||
is_read: int
|
||||
related_debt_id: int | None = None
|
||||
related_plan_id: int | None = None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class NotificationListResponse(BaseModel):
|
||||
items: list[NotificationResponse]
|
||||
total: int
|
||||
unread: int
|
||||
|
||||
|
||||
class EmailServiceConfig(BaseModel):
|
||||
smtp_host: str = ""
|
||||
smtp_port: int = 465
|
||||
smtp_user: str = ""
|
||||
smtp_password: str = ""
|
||||
smtp_from: str = ""
|
||||
smtp_use_tls: bool = True
|
||||
|
||||
|
||||
class ForgotPasswordRequest(BaseModel):
|
||||
account: str
|
||||
|
||||
|
||||
class VerifyResetCodeRequest(BaseModel):
|
||||
account: str
|
||||
code: str
|
||||
|
||||
|
||||
class ResetPasswordRequest(BaseModel):
|
||||
account: str
|
||||
code: str
|
||||
new_password: str
|
||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
79
backend/app/services/calculator.py
Normal file
79
backend/app/services/calculator.py
Normal file
@@ -0,0 +1,79 @@
|
||||
from datetime import datetime, timedelta
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
|
||||
def calculate_monthly_rate(interest_rate: float, rate_type: str) -> float:
|
||||
if rate_type == "annual":
|
||||
return interest_rate / 100 / 12
|
||||
return interest_rate / 100
|
||||
|
||||
|
||||
def generate_equal_installment_plan(
|
||||
amount: int, interest_rate: float, rate_type: str,
|
||||
period_count: int, start_date: datetime
|
||||
) -> list[dict]:
|
||||
mr = calculate_monthly_rate(interest_rate, rate_type)
|
||||
if mr == 0:
|
||||
principal_each = amount // period_count
|
||||
plans = []
|
||||
for i in range(period_count):
|
||||
due = start_date + relativedelta(months=i + 1)
|
||||
p = principal_each if i < period_count - 1 else amount - principal_each * (period_count - 1)
|
||||
plans.append({"due_date": due, "principal_due": p, "interest_due": 0, "total_due": p})
|
||||
return plans
|
||||
|
||||
mp = amount * mr * (1 + mr) ** period_count / ((1 + mr) ** period_count - 1)
|
||||
plans = []
|
||||
remaining = amount
|
||||
for i in range(period_count):
|
||||
due = start_date + relativedelta(months=i + 1)
|
||||
interest = int(remaining * mr)
|
||||
principal = int(mp) - interest
|
||||
if i == period_count - 1:
|
||||
principal = remaining
|
||||
interest = int(remaining * mr)
|
||||
remaining -= principal
|
||||
total = principal + interest
|
||||
plans.append({"due_date": due, "principal_due": principal, "interest_due": interest, "total_due": total})
|
||||
return plans
|
||||
|
||||
|
||||
def generate_interest_first_plan(
|
||||
amount: int, interest_rate: float, rate_type: str,
|
||||
period_count: int, start_date: datetime
|
||||
) -> list[dict]:
|
||||
mr = calculate_monthly_rate(interest_rate, rate_type)
|
||||
plans = []
|
||||
for i in range(period_count):
|
||||
due = start_date + relativedelta(months=i + 1)
|
||||
interest = int(amount * mr)
|
||||
principal = amount if i == period_count - 1 else 0
|
||||
total = principal + interest
|
||||
plans.append({"due_date": due, "principal_due": principal, "interest_due": interest, "total_due": total})
|
||||
return plans
|
||||
|
||||
|
||||
def generate_lump_sum_plan(
|
||||
amount: int, interest_rate: float, rate_type: str,
|
||||
period_count: int, period_unit: str, start_date: datetime
|
||||
) -> list[dict]:
|
||||
if period_unit == "year":
|
||||
total_interest = int(amount * interest_rate / 100 * period_count)
|
||||
else:
|
||||
total_interest = int(amount * interest_rate / 100 / 12 * period_count)
|
||||
due = start_date + relativedelta(months=period_count) if period_unit == "month" else start_date + relativedelta(years=period_count)
|
||||
return [{"due_date": due, "principal_due": amount, "interest_due": total_interest, "total_due": amount + total_interest}]
|
||||
|
||||
|
||||
def generate_repayment_plan(
|
||||
amount: int, interest_rate: float, rate_type: str,
|
||||
repayment_method: str, period_count: int, period_unit: str,
|
||||
start_date: datetime
|
||||
) -> list[dict]:
|
||||
if repayment_method == "equal_installment":
|
||||
return generate_equal_installment_plan(amount, interest_rate, rate_type, period_count, start_date)
|
||||
elif repayment_method == "interest_first":
|
||||
return generate_interest_first_plan(amount, interest_rate, rate_type, period_count, start_date)
|
||||
elif repayment_method == "lump_sum":
|
||||
return generate_lump_sum_plan(amount, interest_rate, rate_type, period_count, period_unit, start_date)
|
||||
return []
|
||||
233
backend/app/services/email_service.py
Normal file
233
backend/app/services/email_service.py
Normal file
@@ -0,0 +1,233 @@
|
||||
import aiosmtplib
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from sqlalchemy import select
|
||||
from app.core.database import async_session
|
||||
from app.core.config import get_settings
|
||||
from app.models.models import SystemSetting
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
async def get_site_url() -> str:
|
||||
async with async_session() as db:
|
||||
result = await db.execute(select(SystemSetting).where(SystemSetting.key == "site_url"))
|
||||
setting = result.scalar_one_or_none()
|
||||
if setting and setting.value.strip():
|
||||
return setting.value.strip().rstrip("/")
|
||||
return "http://localhost:806"
|
||||
|
||||
|
||||
async def get_smtp_config() -> dict:
|
||||
async with async_session() as db:
|
||||
result = await db.execute(
|
||||
select(SystemSetting).where(
|
||||
SystemSetting.key.in_([
|
||||
"smtp_host", "smtp_port", "smtp_user",
|
||||
"smtp_password", "smtp_from", "smtp_use_tls",
|
||||
])
|
||||
)
|
||||
)
|
||||
rows = {r.key: r.value for r in result.scalars().all()}
|
||||
|
||||
if rows.get("smtp_host") and rows.get("smtp_user"):
|
||||
return {
|
||||
"host": rows.get("smtp_host", ""),
|
||||
"port": int(rows.get("smtp_port", "465")),
|
||||
"user": rows.get("smtp_user", ""),
|
||||
"password": rows.get("smtp_password", ""),
|
||||
"from_addr": rows.get("smtp_from") or rows.get("smtp_user", ""),
|
||||
"use_tls": rows.get("smtp_use_tls", "true") == "true",
|
||||
}
|
||||
return {
|
||||
"host": settings.SMTP_HOST,
|
||||
"port": settings.SMTP_PORT,
|
||||
"user": settings.SMTP_USER,
|
||||
"password": settings.SMTP_PASSWORD,
|
||||
"from_addr": settings.SMTP_FROM or settings.SMTP_USER,
|
||||
"use_tls": settings.SMTP_USE_TLS,
|
||||
}
|
||||
|
||||
|
||||
async def send_email(to_email: str, subject: str, body: str) -> bool:
|
||||
smtp = await get_smtp_config()
|
||||
if not smtp["host"] or not smtp["user"]:
|
||||
return False
|
||||
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["From"] = smtp["from_addr"]
|
||||
msg["To"] = to_email
|
||||
msg["Subject"] = subject
|
||||
msg.attach(MIMEText(body, "html", "utf-8"))
|
||||
|
||||
try:
|
||||
await aiosmtplib.send(
|
||||
msg,
|
||||
hostname=smtp["host"],
|
||||
port=smtp["port"],
|
||||
username=smtp["user"],
|
||||
password=smtp["password"],
|
||||
use_tls=smtp["use_tls"],
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def build_repayment_reminder_html(
|
||||
nickname: str, creditor: str, amount_yuan: float,
|
||||
due_date: str, days_left: int, is_overdue: bool = False
|
||||
) -> str:
|
||||
status_text = f"已逾期 {abs(days_left)} 天" if is_overdue else f"还有 {days_left} 天到期"
|
||||
color = "#ff3b30" if is_overdue else "#0071e3"
|
||||
title = "逾期提醒" if is_overdue else "还款提醒"
|
||||
return f"""
|
||||
<div style="font-family:-apple-system,sans-serif;max-width:480px;margin:0 auto;padding:32px">
|
||||
<h2 style="color:{color};margin-bottom:16px">{title}</h2>
|
||||
<p>Hi {nickname},</p>
|
||||
<p>您有一笔借款即将到期:</p>
|
||||
<div style="background:#f5f5f7;border-radius:12px;padding:20px;margin:16px 0">
|
||||
<p><strong>债权人:</strong>{creditor}</p>
|
||||
<p><strong>金额:</strong>¥{amount_yuan:,.2f}</p>
|
||||
<p><strong>到期日:</strong>{due_date}</p>
|
||||
<p style="color:{color};font-weight:600">{status_text}</p>
|
||||
</div>
|
||||
<p style="color:#6e6e73;font-size:13px">—— 债务管理系统</p>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
_LOGIN_ALERT_CONFIG = {
|
||||
3: {
|
||||
"level": "注意",
|
||||
"color": "#ff9500",
|
||||
"bg": "rgba(255,149,0,.06)",
|
||||
"border": "rgba(255,149,0,.15)",
|
||||
"icon": "⚠️",
|
||||
"desc": "您的账号出现了异常登录尝试。",
|
||||
},
|
||||
5: {
|
||||
"level": "警告",
|
||||
"color": "#ff6b35",
|
||||
"bg": "rgba(255,107,53,.06)",
|
||||
"border": "rgba(255,107,53,.15)",
|
||||
"icon": "🔶",
|
||||
"desc": "您的账号正遭受多次暴力破解尝试,若非本人操作请立即修改密码。",
|
||||
},
|
||||
10: {
|
||||
"level": "严重",
|
||||
"color": "#ff3b30",
|
||||
"bg": "rgba(255,59,48,.08)",
|
||||
"border": "rgba(255,59,48,.18)",
|
||||
"icon": "🔴",
|
||||
"desc": "您的账号已被暴力破解锁定 5 分钟。若非本人操作,请立即修改密码并检查账户安全。",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _build_login_alert_html(nickname: str, count: int, threshold: int, ip: str, site_url: str, token: str, uid: int, ttl_minutes: int = 5) -> str:
|
||||
cfg = _LOGIN_ALERT_CONFIG[threshold]
|
||||
change_pwd_url = f"{site_url}/reset-password.html?token={token}&uid={uid}"
|
||||
return f"""
|
||||
<div style="font-family:-apple-system,sans-serif;max-width:480px;margin:0 auto;padding:32px">
|
||||
<h2 style="color:{cfg['color']};margin-bottom:16px">{cfg['icon']} 登录安全{cfg['level']}</h2>
|
||||
<p>Hi {nickname},</p>
|
||||
<p>{cfg['desc']}</p>
|
||||
<div style="background:{cfg['bg']};border:1px solid {cfg['border']};border-radius:12px;padding:20px;margin:16px 0">
|
||||
<p><strong>异常登录次数:</strong><span style="color:{cfg['color']};font-weight:700">{count} 次</span></p>
|
||||
<p><strong>触发来源 IP:</strong>{ip}</p>
|
||||
<p><strong>时间:</strong>{__import__('datetime').datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</p>
|
||||
</div>
|
||||
<div style="text-align:center;margin:24px 0">
|
||||
<a href="{change_pwd_url}" style="display:inline-block;padding:12px 32px;background:#0071e3;color:#fff;border-radius:8px;text-decoration:none;font-weight:600">修改密码</a>
|
||||
</div>
|
||||
<p style="color:#6e6e73;font-size:12px">此链接 {ttl_minutes} 分钟内有效,仅可使用一次。</p>
|
||||
<p style="color:#6e6e73;font-size:13px">如非本人操作,请忽略此邮件并尽快检查账户安全。</p>
|
||||
<p style="color:#6e6e73;font-size:13px">—— 债务管理系统</p>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
async def send_login_alert_email(user, count: int, threshold: int, ip: str):
|
||||
if not user.real_email:
|
||||
return
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
token = secrets.token_urlsafe(32)
|
||||
ttl_minutes = 5
|
||||
expires_at = (datetime.utcnow() + timedelta(minutes=ttl_minutes)).isoformat()
|
||||
key = f"pwd_reset:{user.id}"
|
||||
value = f"{token}|{expires_at}"
|
||||
|
||||
async with async_session() as db:
|
||||
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()
|
||||
|
||||
cfg = _LOGIN_ALERT_CONFIG[threshold]
|
||||
site_url = await get_site_url()
|
||||
html = _build_login_alert_html(user.nickname or user.email, count, threshold, ip, site_url, token, user.id, ttl_minutes)
|
||||
subject = f"【{cfg['level']}】账号安全提醒 - 异常登录 {count} 次"
|
||||
await send_email(user.real_email, subject, html)
|
||||
|
||||
|
||||
def _build_password_changed_html(nickname: str, action: str, operator: str = "") -> str:
|
||||
from datetime import datetime
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
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">🔐 密码{action}通知</h2>
|
||||
<p>Hi {nickname},</p>
|
||||
<p>您的账户密码已被{action}。</p>
|
||||
<div style="background:#f5f5f7;border-radius:12px;padding:20px;margin:16px 0">
|
||||
<p><strong>操作类型:</strong>密码{action}</p>
|
||||
<p><strong>操作时间:</strong>{now}</p>
|
||||
{"<p><strong>操作人:</strong>" + operator + "</p>" if operator else ""}
|
||||
</div>
|
||||
<p style="color:#6e6e73;font-size:13px">如非本人操作,请立即联系管理员并修改密码。</p>
|
||||
<p style="color:#6e6e73;font-size:13px">—— 债务管理系统</p>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
async def send_password_changed_email(user, action: str, operator: str = ""):
|
||||
if not user.real_email:
|
||||
return
|
||||
html = _build_password_changed_html(user.nickname or user.email, action, operator)
|
||||
subject = f"【安全通知】您的密码已被{action}"
|
||||
await send_email(user.real_email, subject, html)
|
||||
|
||||
|
||||
def _build_password_reset_link_html(nickname: str, reset_url: str, ttl_hours: int, operator: str) -> str:
|
||||
from datetime import datetime
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
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:20px;margin:16px 0">
|
||||
<p><strong>操作人:</strong>{operator}</p>
|
||||
<p><strong>发送时间:</strong>{now}</p>
|
||||
<p><strong>有效期:</strong>{ttl_hours} 小时内有效</p>
|
||||
</div>
|
||||
<div style="text-align:center;margin:24px 0">
|
||||
<a href="{reset_url}" style="display:inline-block;padding:14px 36px;background:#0071e3;color:#fff;border-radius:8px;text-decoration:none;font-weight:600;font-size:15px">重置密码</a>
|
||||
</div>
|
||||
<p style="color:#6e6e73;font-size:12px">如非本人操作,请忽略此邮件。</p>
|
||||
<p style="color:#6e6e73;font-size:13px">—— 债务管理系统</p>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
async def send_password_reset_link_email(user, reset_url: str, ttl_hours: int, operator: str = ""):
|
||||
if not user.real_email:
|
||||
return
|
||||
html = _build_password_reset_link_html(user.nickname or user.email, reset_url, ttl_hours, operator)
|
||||
subject = "【密码重置】管理员为您重置了密码"
|
||||
await send_email(user.real_email, subject, html)
|
||||
41
backend/app/services/notification_service.py
Normal file
41
backend/app/services/notification_service.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.models.models import Notification, NotificationType, User
|
||||
|
||||
|
||||
async def create_notification(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
title: str,
|
||||
message: str,
|
||||
notification_type: str = NotificationType.SYSTEM.value,
|
||||
related_debt_id: int | None = None,
|
||||
related_plan_id: int | None = None,
|
||||
) -> Notification:
|
||||
n = Notification(
|
||||
user_id=user_id,
|
||||
title=title,
|
||||
message=message,
|
||||
type=notification_type,
|
||||
related_debt_id=related_debt_id,
|
||||
related_plan_id=related_plan_id,
|
||||
)
|
||||
db.add(n)
|
||||
return n
|
||||
|
||||
|
||||
async def create_system_broadcast(
|
||||
db: AsyncSession,
|
||||
title: str,
|
||||
message: str,
|
||||
) -> int:
|
||||
users = await db.execute(select(User).where(User.status == "active"))
|
||||
count = 0
|
||||
for user in users.scalars().all():
|
||||
db.add(Notification(
|
||||
user_id=user.id, title=title, message=message,
|
||||
type=NotificationType.SYSTEM.value,
|
||||
))
|
||||
count += 1
|
||||
await db.commit()
|
||||
return count
|
||||
105
backend/app/services/scheduler.py
Normal file
105
backend/app/services/scheduler.py
Normal file
@@ -0,0 +1,105 @@
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from sqlalchemy import select, and_
|
||||
from datetime import datetime, timedelta
|
||||
from app.core.database import async_session
|
||||
from app.core.config import get_settings
|
||||
from app.models.models import (
|
||||
Debt, Notification, NotificationType, User, DebtStatus
|
||||
)
|
||||
from app.services.notification_service import create_notification
|
||||
from app.services.email_service import send_email, build_repayment_reminder_html
|
||||
|
||||
settings = get_settings()
|
||||
scheduler = AsyncIOScheduler()
|
||||
|
||||
|
||||
async def check_upcoming_repayments():
|
||||
async with async_session() as db:
|
||||
now = datetime.utcnow()
|
||||
reminder_days = settings.REMINDER_DAYS_BEFORE
|
||||
check_window = now + timedelta(days=reminder_days)
|
||||
|
||||
result = await db.execute(
|
||||
select(Debt).where(
|
||||
and_(
|
||||
Debt.status == DebtStatus.ACTIVE.value,
|
||||
Debt.schedule_data.isnot(None),
|
||||
)
|
||||
)
|
||||
)
|
||||
debts = result.scalars().all()
|
||||
|
||||
for debt in debts:
|
||||
schedule = debt.schedule_data or []
|
||||
paid_periods = debt.paid_periods or []
|
||||
user_result = await db.execute(select(User).where(User.id == debt.user_id))
|
||||
user = user_result.scalar_one_or_none()
|
||||
if not user:
|
||||
continue
|
||||
|
||||
for item in schedule:
|
||||
p = item.get("p")
|
||||
if p in paid_periods:
|
||||
continue
|
||||
|
||||
try:
|
||||
due_date = datetime.strptime(item["date"], "%Y-%m-%d")
|
||||
except (ValueError, KeyError):
|
||||
continue
|
||||
|
||||
if due_date > check_window:
|
||||
continue
|
||||
|
||||
is_overdue = due_date < now
|
||||
amount_yuan = item.get("pay", 0)
|
||||
days_left = (due_date - now).days
|
||||
|
||||
if is_overdue:
|
||||
ntype = NotificationType.REPAYMENT_OVERDUE.value
|
||||
title = f"逾期提醒:{debt.creditor_name} 第{p}期还款已逾期"
|
||||
msg = f"您在 {debt.creditor_name} 的第{p}期 ¥{amount_yuan:,.2f} 还款已于 {item['date']} 逾期,请尽快处理。"
|
||||
else:
|
||||
ntype = NotificationType.REPAYMENT_DUE.value
|
||||
title = f"还款提醒:{debt.creditor_name} 第{p}期将在 {days_left} 天后到期"
|
||||
msg = f"您在 {debt.creditor_name} 的第{p}期 ¥{amount_yuan:,.2f} 将于 {item['date']} 到期,请按时还款。"
|
||||
|
||||
existing = await db.execute(
|
||||
select(Notification).where(
|
||||
and_(
|
||||
Notification.user_id == user.id,
|
||||
Notification.related_debt_id == debt.id,
|
||||
Notification.title == title,
|
||||
)
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
continue
|
||||
|
||||
await create_notification(
|
||||
db, user.id, title, msg, ntype,
|
||||
related_debt_id=debt.id,
|
||||
)
|
||||
|
||||
if user.email and "@" in user.email:
|
||||
html = build_repayment_reminder_html(
|
||||
user.nickname or user.email,
|
||||
debt.creditor_name,
|
||||
amount_yuan,
|
||||
item["date"],
|
||||
days_left,
|
||||
is_overdue,
|
||||
)
|
||||
await send_email(user.email, title, html)
|
||||
|
||||
await db.commit()
|
||||
|
||||
|
||||
def start_scheduler():
|
||||
scheduler.add_job(
|
||||
check_upcoming_repayments,
|
||||
"interval",
|
||||
minutes=settings.NOTIFICATION_CHECK_INTERVAL_MINUTES,
|
||||
id="repayment_check",
|
||||
replace_existing=True,
|
||||
)
|
||||
scheduler.start()
|
||||
Reference in New Issue
Block a user