严重修复: - C1: JWT密钥生成64字节随机字符串替代硬编码占位符 - C2: 数据库密码改为随机强密码,移除宿主机端口映射 - C3: 管理员默认密码改为强密码 高危修复: - H1: 移除数据库54326端口映射,仅内部网络访问 - H3: 添加X-Frame-Options/CSP/XSS-Protection等安全头 - H4: 前端所有innerHTML拼接处添加escapeHtml转义防XSS - H5: SMTP密码在API响应中脱敏显示 - H6: 债务列表接口添加用户隔离,普通用户只能看自己的数据 - H7: 债务详情/计划/记录接口添加归属校验 - H8: 账户级暴力破解锁定(5次失败锁定5分钟) - H9: 速率限制数据增加过期清理机制防内存泄漏 中危修复: - M1: 注册/重置密码接口添加后端密码强度校验(最少8位) - M4: 添加全局异常处理器,500错误不再暴露堆栈 - M5: Docker容器改为非root用户运行 - M6: 添加.dockerignore排除pyc和敏感文件 - M7: 管理员操作写入审计日志 - M8: CORS限制为实际使用的HTTP方法和头 其他: - Nginx隐藏版本号(server_tokens off) - 管理员角色修改添加枚举校验 - 密码重置验证码接口返回统一错误信息
432 lines
15 KiB
Python
432 lines
15 KiB
Python
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:
|
|
if req.role not in ("user", "admin"):
|
|
raise HTTPException(status_code=400, detail="角色值无效")
|
|
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="***" if settings.get("smtp_password") else "",
|
|
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": "站点地址已保存"}
|