Files
disknext/routers/api/v1/admin/user/__init__.py
于小丘 a99091ea7a feat: embed permission claims in JWT and add captcha verification
- Add GroupClaims model for JWT permission snapshots
- Add JWTPayload model for typed JWT decoding
- Refactor auth middleware: jwt_required (no DB) -> admin_required (no DB) -> auth_required (DB)
- Add UserBanStore for instant ban enforcement via Redis + memory fallback
- Fix status check bug: StrEnum is always truthy, use explicit != ACTIVE
- Shorten access_token expiry from 3h to 1h
- Add CaptchaScene enum and verify_captcha_if_needed service
- Add require_captcha dependency injection factory
- Add CLA document and new default settings
- Update all tests for new JWT API

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 19:07:48 +08:00

261 lines
8.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException
from loguru import logger as l
from sqlalchemy import func
from middleware.auth import admin_required
from middleware.dependencies import SessionDep, TableViewRequestDep, UserFilterParamsDep
from service.redis.user_ban_store import UserBanStore
from sqlmodels import (
User, ResponseBase, UserPublic, ListResponse,
Group, Object, ObjectType, Setting, SettingsType,
BatchDeleteRequest,
)
from sqlmodels.user import (
UserAdminCreateRequest, UserAdminUpdateRequest, UserCalibrateResponse, UserStatus,
)
from utils import Password, http_exceptions
admin_user_router = APIRouter(
prefix="/user",
tags=["admin", "admin_user"],
)
@admin_user_router.get(
path='/info/{user_id}',
summary='获取用户信息',
description='Get user information by ID',
dependencies=[Depends(admin_required)],
)
async def router_admin_get_user(session: SessionDep, user_id: UUID) -> UserPublic:
"""
根据用户ID获取用户信息包括用户名、邮箱、注册时间等。
Args:
session(SessionDep): 数据库会话依赖项。
user_id (UUID): 用户ID。
Returns:
ResponseBase: 包含用户信息的响应模型。
"""
user = await User.get_exist_one(session, user_id)
return user.to_public()
@admin_user_router.get(
path='/list',
summary='获取用户列表',
description='Get user list',
dependencies=[Depends(admin_required)],
)
async def router_admin_get_users(
session: SessionDep,
table_view: TableViewRequestDep,
filter_params: UserFilterParamsDep,
) -> ListResponse[UserPublic]:
"""
获取用户列表,支持分页、排序、时间筛选和用户筛选。
:param session: 数据库会话依赖项
:param table_view: 分页排序参数依赖
:param filter_params: 用户筛选参数(用户组、用户名、昵称、状态)
:return: 分页用户列表
"""
result = await User.get_with_count(session, filter_params=filter_params, table_view=table_view, load=User.group)
return ListResponse(
items=[user.to_public() for user in result.items],
count=result.count,
)
@admin_user_router.post(
path='/create',
summary='创建用户',
description='Create a new user',
dependencies=[Depends(admin_required)],
)
async def router_admin_create_user(
session: SessionDep,
request: UserAdminCreateRequest,
) -> UserPublic:
"""
创建一个新的用户,设置邮箱、密码、用户组等信息。
:param session: 数据库会话
:param request: 创建用户请求 DTO
:return: 创建结果
"""
existing_user = await User.get(session, User.email == request.email)
if existing_user:
raise HTTPException(status_code=409, detail="该邮箱已被注册")
# 验证用户组存在
group = await Group.get(session, Group.id == request.group_id)
if not group:
raise HTTPException(status_code=400, detail="目标用户组不存在")
user = User(
email=request.email,
password=Password.hash(request.password),
nickname=request.nickname,
group_id=request.group_id,
status=request.status,
)
user = await user.save(session)
return user.to_public()
@admin_user_router.patch(
path='/{user_id}',
summary='更新用户信息',
description='Update user information by ID',
dependencies=[Depends(admin_required)],
status_code=204
)
async def router_admin_update_user(
session: SessionDep,
user_id: UUID,
request: UserAdminUpdateRequest,
) -> None:
"""
根据用户ID更新用户信息。
:param session: 数据库会话
:param user_id: 用户UUID
:param request: 更新请求
:return: 更新结果
"""
user = await User.get(session, User.id == user_id)
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
# 默认管理员不允许更改用户组(通过 Setting 中的 default_admin_id 识别)
default_admin_setting = await Setting.get(
session,
(Setting.type == SettingsType.AUTH) & (Setting.name == "default_admin_id")
)
if (request.group_id
and default_admin_setting
and default_admin_setting.value == str(user_id)
and request.group_id != user.group_id):
http_exceptions.raise_forbidden("默认管理员不允许更改用户组")
# 如果更新用户组,验证新组存在
if request.group_id:
group = await Group.get(session, Group.id == request.group_id)
if not group:
raise HTTPException(status_code=400, detail="目标用户组不存在")
# 如果更新密码,需要加密
update_data = request.model_dump(exclude_unset=True)
if 'password' in update_data and update_data['password']:
update_data['password'] = Password.hash(update_data['password'])
elif 'password' in update_data:
del update_data['password'] # 空密码不更新
# 验证两步验证密钥格式(如果提供了值且不为 None长度必须为 32
if 'two_factor' in update_data and update_data['two_factor'] is not None:
if len(update_data['two_factor']) != 32:
raise HTTPException(status_code=400, detail="两步验证密钥必须为32位字符串")
# 记录旧 status 以便检测变更
old_status = user.status
# 更新字段
for key, value in update_data.items():
setattr(user, key, value)
user = await user.save(session)
# 封禁状态变更 → 更新 BanStore
new_status = user.status
if old_status == UserStatus.ACTIVE and new_status != UserStatus.ACTIVE:
await UserBanStore.ban(str(user_id))
elif old_status != UserStatus.ACTIVE and new_status == UserStatus.ACTIVE:
await UserBanStore.unban(str(user_id))
l.info(f"管理员更新了用户: {request.email}")
@admin_user_router.delete(
path='/',
summary='删除用户(支持批量)',
description='Delete users by ID list',
dependencies=[Depends(admin_required)],
status_code=204,
)
async def router_admin_delete_users(
session: SessionDep,
request: BatchDeleteRequest,
) -> None:
"""
批量删除用户及其所有数据。
注意: 这是一个危险操作,会级联删除用户的所有文件、分享、任务等。
:param session: 数据库会话
:param request: 批量删除请求,包含待删除用户的 UUID 列表
:return: 删除结果(已删除数 / 总请求数)
"""
deleted = 0
for uid in request.ids:
user = await User.get(session, User.id == uid)
if user:
await User.delete(session, user)
l.info(f"管理员删除了用户: {user.email}")
@admin_user_router.post(
path='/calibrate/{user_id}',
summary='校准用户存储容量',
description='Calibrate the user storage.',
dependencies=[Depends(admin_required)]
)
async def router_admin_calibrate_storage(
session: SessionDep,
user_id: UUID,
) -> UserCalibrateResponse:
"""
重新计算用户的已用存储空间。
流程:
1. 获取用户所有文件的大小总和
2. 更新用户的 storage 字段
3. 返回校准结果
:param session: 数据库会话
:param user_id: 用户UUID
:return: 校准结果
"""
user = await User.get(session, User.id == user_id)
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
previous_storage = user.storage
# 计算实际存储量 - 使用 SQL 聚合
from sqlmodel import select
result = await session.execute(
select(func.sum(Object.size), func.count(Object.id)).where(
(Object.owner_id == user_id) & (Object.type == ObjectType.FILE)
)
)
row = result.one()
actual_storage = row[0] or 0
file_count = row[1] or 0
# 更新用户存储量
user.storage = actual_storage
user = await user.save(session)
response = UserCalibrateResponse(
user_id=user_id,
previous_storage=previous_storage,
current_storage=actual_storage,
difference=actual_storage - previous_storage,
file_count=file_count,
)
l.info(f"管理员校准了用户存储: {user.email}, 差值: {actual_storage - previous_storage}")
return response