v2.1: 新增通知提醒功能和安全增强

功能:
- 通知提醒系统: 还款日提醒、逾期提醒、系统消息横幅、登录时通知
- 通知中心UI: 铃铛图标 + 下拉列表 + 未读徽章
- 定时任务: APScheduler 每小时检查即将到期的还款
- 邮件通知: SMTP 发送还款提醒和安全警告
- 异常登录通知: 3/5/10次失败触发网页和邮件通知

安全:
- 登录安全告警: 异常登录次数达到阈值时发送通知
- 密码重置链接: 一次性使用, 5分钟有效期
- 登录频率限制: IP级和账户级限制

技术:
- 后端: FastAPI + SQLAlchemy + PostgreSQL
- 前端: 纯 HTML/CSS/JS
- 部署: Docker Compose (新增 build 配置)
This commit is contained in:
OP
2026-06-28 18:06:56 +08:00
commit ffca168dbe
41 changed files with 4834 additions and 0 deletions

10
backend/Dockerfile Normal file
View File

@@ -0,0 +1,10 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

0
backend/app/__init__.py Normal file
View File

View File

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

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

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

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

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

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

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

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

View File

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

View File

@@ -0,0 +1,101 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from datetime import datetime, timedelta
from app.core.database import get_db
from app.models.models import User, Debt, RepaymentPlan, RepaymentRecord, PlanStatus, DebtStatus
from app.schemas.schemas import RepaymentRecordCreate, RepaymentRecordResponse, DashboardSummary
from app.core.deps import get_current_user
router = APIRouter(prefix="/repayments", tags=["还款管理"])
def _naive(dt: datetime) -> datetime:
if dt and dt.tzinfo:
return dt.replace(tzinfo=None)
return dt
@router.post("", response_model=RepaymentRecordResponse, status_code=201)
async def create_repayment(
req: RepaymentRecordCreate,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Debt).where(Debt.id == req.debt_id))
debt = result.scalar_one_or_none()
if not debt:
raise HTTPException(status_code=404, detail="债务不存在")
if debt.user_id != user.id and user.role != "admin":
raise HTTPException(status_code=403, detail="无权为此债务还款")
record = RepaymentRecord(
debt_id=req.debt_id, plan_id=req.plan_id,
amount=req.amount, paid_date=_naive(req.paid_date), notes=req.notes,
)
db.add(record)
if req.plan_id:
plan_result = await db.execute(select(RepaymentPlan).where(RepaymentPlan.id == req.plan_id))
plan = plan_result.scalar_one_or_none()
if plan:
plan.status = PlanStatus.PAID.value
total_paid_result = await db.execute(
select(func.coalesce(func.sum(RepaymentRecord.amount), 0)).where(RepaymentRecord.debt_id == req.debt_id)
)
total_paid = total_paid_result.scalar() + req.amount
if total_paid >= debt.amount:
debt.status = DebtStatus.PAID.value
await db.commit()
await db.refresh(record)
return record
@router.get("/dashboard", response_model=DashboardSummary)
async def get_dashboard(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
debts_result = await db.execute(select(Debt).where(Debt.status == DebtStatus.ACTIVE.value))
debts = debts_result.scalars().all()
total_debt = sum(d.amount for d in debts)
total_paid = 0
for d in debts:
pr = await db.execute(select(func.coalesce(func.sum(RepaymentRecord.amount), 0)).where(RepaymentRecord.debt_id == d.id))
total_paid += pr.scalar()
active_count = sum(1 for d in debts if d.status == DebtStatus.ACTIVE.value)
overdue_count = sum(1 for d in debts if d.status == DebtStatus.OVERDUE.value)
paid_all = await db.execute(select(Debt).where(Debt.status == DebtStatus.PAID.value))
paid_count = len(paid_all.scalars().all())
now = datetime.utcnow()
upcoming = []
for d in debts:
if d.status != DebtStatus.ACTIVE.value:
continue
plans_result = await db.execute(
select(RepaymentPlan).where(
RepaymentPlan.debt_id == d.id,
RepaymentPlan.status == PlanStatus.PENDING.value,
RepaymentPlan.due_date <= now + timedelta(days=7),
).order_by(RepaymentPlan.due_date)
)
for p in plans_result.scalars().all():
upcoming.append({
"debt_id": d.id, "creditor": d.creditor_name,
"due_date": p.due_date.isoformat(), "amount": p.total_due,
"overdue": p.due_date < now,
})
upcoming.sort(key=lambda x: x["due_date"])
return DashboardSummary(
total_debt=total_debt, total_paid=total_paid,
total_remaining=total_debt - total_paid,
active_count=active_count, overdue_count=overdue_count,
paid_count=paid_count, upcoming_payments=upcoming[:10],
)

View File

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

View 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
View 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

View 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

View 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
View 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"}

View File

View 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")

View File

View 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

View File

View 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 []

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

View 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

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

16
backend/requirements.txt Normal file
View File

@@ -0,0 +1,16 @@
fastapi==0.109.2
uvicorn[standard]==0.27.1
sqlalchemy==2.0.27
asyncpg==0.29.0
alembic==1.13.1
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
bcrypt==4.1.3
python-multipart==0.0.9
pydantic==2.6.1
pydantic-settings==2.1.0
redis==5.0.1
httpx==0.26.0
python-dateutil==2.8.2
apscheduler==3.10.4
aiosmtplib==3.0.1