完善文档与前端迁移,补充开源协议

This commit is contained in:
2026-04-17 19:48:13 +08:00
parent 466fa50aa8
commit 31916e68a6
94 changed files with 7019 additions and 480 deletions

View File

@@ -2,7 +2,7 @@
Admin Router - 完备的管理员功能模块
参考 OpenWebUI 设计
"""
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, status, Query
from fastapi.responses import StreamingResponse, FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_, or_, desc
@@ -16,7 +16,8 @@ from database import get_db, engine
from models import User, SystemConfig, Exam, Question, UserMistake, ExamStatus
from schemas import (
SystemConfigUpdate, SystemConfigResponse,
UserResponse, UserCreate, UserUpdate, UserListResponse
UserResponse, UserCreate, UserUpdate, UserListResponse,
UserPasswordResetRequest, AdminUserSummary
)
from services.auth_service import get_current_admin_user
@@ -36,6 +37,11 @@ async def get_default_admin_id(db: AsyncSession) -> Optional[int]:
return None
async def get_admin_count(db: AsyncSession) -> int:
result = await db.execute(select(func.count(User.id)).where(User.is_admin == True))
return result.scalar() or 0
@router.get("/config", response_model=SystemConfigResponse)
async def get_system_config(
current_admin: User = Depends(get_current_admin_user),
@@ -84,6 +90,9 @@ async def update_system_config(
update_data = config_update.dict(exclude_unset=True)
for key, value in update_data.items():
if key.endswith("_api_key") and isinstance(value, str) and "..." in value:
continue
result = await db.execute(
select(SystemConfig).where(SystemConfig.key == key)
)
@@ -108,9 +117,9 @@ async def update_system_config(
@router.get("/users", response_model=UserListResponse)
async def get_users(
skip: int = 0,
limit: int = 50,
search: Optional[str] = None,
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
search: Optional[str] = Query(None, min_length=1, max_length=50),
current_admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_db)
):
@@ -121,10 +130,11 @@ async def get_users(
- search: 搜索关键词(用户名)
"""
query = select(User)
normalized_search = search.strip() if search else None
# 搜索过滤
if search:
query = query.where(User.username.ilike(f"%{search}%"))
if normalized_search:
query = query.where(User.username.ilike(f"%{normalized_search}%"))
# 统计总数
count_query = select(func.count()).select_from(query.subquery())
@@ -136,34 +146,41 @@ async def get_users(
result = await db.execute(query)
users = result.scalars().all()
# 为每个用户添加统计信息
user_ids = [user.id for user in users]
exam_count_map = {}
mistake_count_map = {}
if user_ids:
exam_count_result = await db.execute(
select(Exam.user_id, func.count(Exam.id))
.where(Exam.user_id.in_(user_ids))
.group_by(Exam.user_id)
)
exam_count_map = {user_id: count for user_id, count in exam_count_result.all()}
mistake_count_result = await db.execute(
select(UserMistake.user_id, func.count(UserMistake.id))
.where(UserMistake.user_id.in_(user_ids))
.group_by(UserMistake.user_id)
)
mistake_count_map = {
user_id: count for user_id, count in mistake_count_result.all()
}
user_list = []
for user in users:
# 统计用户的题库数
exam_count_query = select(func.count(Exam.id)).where(Exam.user_id == user.id)
exam_result = await db.execute(exam_count_query)
exam_count = exam_result.scalar()
user_list.append(
AdminUserSummary(
id=user.id,
username=user.username,
is_admin=user.is_admin,
created_at=user.created_at,
exam_count=exam_count_map.get(user.id, 0),
mistake_count=mistake_count_map.get(user.id, 0)
)
)
# 统计用户的错题数
mistake_count_query = select(func.count(UserMistake.id)).where(UserMistake.user_id == user.id)
mistake_result = await db.execute(mistake_count_query)
mistake_count = mistake_result.scalar()
user_list.append({
"id": user.id,
"username": user.username,
"is_admin": user.is_admin,
"created_at": user.created_at,
"exam_count": exam_count,
"mistake_count": mistake_count
})
return {
"users": user_list,
"total": total,
"skip": skip,
"limit": limit
}
return UserListResponse(users=user_list, total=total, skip=skip, limit=limit)
@router.post("/users", response_model=UserResponse)
@@ -215,7 +232,19 @@ async def update_user(
detail="User not found"
)
if user_data.username and user_data.username != user.username:
existing_user_result = await db.execute(
select(User).where(User.username == user_data.username)
)
existing_user = existing_user_result.scalar_one_or_none()
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already exists"
)
protected_admin_id = await get_default_admin_id(db)
admin_count = await get_admin_count(db)
# 不允许修改默认管理员的管理员状态
if protected_admin_id and user.id == protected_admin_id and user_data.is_admin is not None:
@@ -224,6 +253,12 @@ async def update_user(
detail="Cannot modify default admin user's admin status"
)
if user.is_admin and user_data.is_admin is False and admin_count <= 1:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="At least one admin user must remain"
)
# 更新字段
update_data = user_data.dict(exclude_unset=True)
if "password" in update_data:
@@ -238,6 +273,29 @@ async def update_user(
return user
@router.post("/users/{user_id}/reset-password")
async def reset_user_password(
user_id: int,
payload: UserPasswordResetRequest,
current_admin: User = Depends(get_current_admin_user),
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=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
user.hashed_password = pwd_context.hash(payload.new_password)
await db.commit()
return {"message": "Password reset successfully"}
@router.delete("/users/{user_id}")
async def delete_user(
user_id: int,
@@ -255,6 +313,7 @@ async def delete_user(
)
protected_admin_id = await get_default_admin_id(db)
admin_count = await get_admin_count(db)
# 不允许删除默认管理员
if protected_admin_id and user.id == protected_admin_id:
@@ -270,6 +329,12 @@ async def delete_user(
detail="Cannot delete yourself"
)
if user.is_admin and admin_count <= 1:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="At least one admin user must remain"
)
await db.delete(user)
await db.commit()

View File

@@ -4,7 +4,7 @@ Exam Router - Handles exam creation, file upload, and deduplication
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form, BackgroundTasks, Request
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_
from sqlalchemy import select, func, and_, case
from typing import List, Optional
from datetime import datetime, timedelta
import os
@@ -17,7 +17,7 @@ from database import get_db
from models import User, Exam, Question, ExamStatus, SystemConfig
from schemas import (
ExamCreate, ExamResponse, ExamListResponse,
ExamUploadResponse, ParseResult, QuizProgressUpdate
ExamUploadResponse, ParseResult, QuizProgressUpdate, ExamSummaryResponse
)
from services.auth_service import get_current_user
from services.document_parser import document_parser
@@ -684,6 +684,57 @@ async def get_user_exams(
return ExamListResponse(exams=exams, total=total)
@router.get("/summary", response_model=ExamSummaryResponse)
async def get_exam_summary(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Get aggregated exam statistics for current user."""
summary_query = select(
func.count(Exam.id),
func.coalesce(func.sum(Exam.total_questions), 0),
func.coalesce(func.sum(Exam.current_index), 0),
func.coalesce(
func.sum(
case((Exam.status == ExamStatus.PROCESSING, 1), else_=0)
),
0
),
func.coalesce(
func.sum(
case((Exam.status == ExamStatus.READY, 1), else_=0)
),
0
),
func.coalesce(
func.sum(
case((Exam.status == ExamStatus.FAILED, 1), else_=0)
),
0
)
).where(Exam.user_id == current_user.id)
result = await db.execute(summary_query)
(
total_exams,
total_questions,
completed_questions,
processing_exams,
ready_exams,
failed_exams
) = result.one()
return ExamSummaryResponse(
total_exams=total_exams or 0,
total_questions=total_questions or 0,
completed_questions=completed_questions or 0,
processing_exams=processing_exams or 0,
ready_exams=ready_exams or 0,
failed_exams=failed_exams or 0
)
@router.get("/{exam_id}", response_model=ExamResponse)
async def get_exam_detail(
exam_id: int,