mirror of
https://github.com/handsomezhuzhu/QQuiz.git
synced 2026-04-18 14:32:54 +00:00
完善文档与前端迁移,补充开源协议
This commit is contained in:
@@ -1,24 +1,35 @@
|
||||
FROM python:3.11-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install build dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
libmagic1 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN python -m venv /opt/venv
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies (gcc for compiling Python packages)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
libmagic1 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements and install Python dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# Copy application code
|
||||
COPY --from=builder /opt/venv /opt/venv
|
||||
COPY . .
|
||||
|
||||
# Create uploads directory
|
||||
RUN mkdir -p uploads
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
# Run database migrations and start server
|
||||
CMD alembic upgrade head && uvicorn main:app --host 0.0.0.0 --port 8000
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -38,6 +38,11 @@ class UserLogin(BaseModel):
|
||||
password: str
|
||||
|
||||
|
||||
class PasswordChangeRequest(BaseModel):
|
||||
old_password: str = Field(..., min_length=1)
|
||||
new_password: str = Field(..., min_length=6)
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
@@ -53,14 +58,23 @@ class UserResponse(BaseModel):
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class AdminUserSummary(UserResponse):
|
||||
exam_count: int = 0
|
||||
mistake_count: int = 0
|
||||
|
||||
|
||||
class UserListResponse(BaseModel):
|
||||
"""用户列表响应(包含分页信息)"""
|
||||
users: List[dict] # 包含额外统计信息的用户列表
|
||||
users: List[AdminUserSummary]
|
||||
total: int
|
||||
skip: int
|
||||
limit: int
|
||||
|
||||
|
||||
class UserPasswordResetRequest(BaseModel):
|
||||
new_password: str = Field(..., min_length=6)
|
||||
|
||||
|
||||
# ============ System Config Schemas ============
|
||||
class SystemConfigUpdate(BaseModel):
|
||||
allow_registration: Optional[bool] = None
|
||||
@@ -124,6 +138,15 @@ class ExamListResponse(BaseModel):
|
||||
total: int
|
||||
|
||||
|
||||
class ExamSummaryResponse(BaseModel):
|
||||
total_exams: int
|
||||
total_questions: int
|
||||
completed_questions: int
|
||||
processing_exams: int
|
||||
ready_exams: int
|
||||
failed_exams: int
|
||||
|
||||
|
||||
class ExamUploadResponse(BaseModel):
|
||||
exam_id: int
|
||||
title: str
|
||||
@@ -140,19 +163,31 @@ class ParseResult(BaseModel):
|
||||
|
||||
|
||||
# ============ Question Schemas ============
|
||||
class QuestionBase(BaseModel):
|
||||
class QuestionPublicBase(BaseModel):
|
||||
content: str
|
||||
type: QuestionType
|
||||
options: Optional[List[str]] = None
|
||||
answer: str
|
||||
analysis: Optional[str] = None
|
||||
|
||||
|
||||
class QuestionBase(QuestionPublicBase):
|
||||
answer: str
|
||||
|
||||
|
||||
class QuestionCreate(QuestionBase):
|
||||
exam_id: int
|
||||
|
||||
|
||||
class QuestionResponse(QuestionBase):
|
||||
class QuestionResponse(QuestionPublicBase):
|
||||
id: int
|
||||
exam_id: int
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class QuestionWithAnswerResponse(QuestionBase):
|
||||
id: int
|
||||
exam_id: int
|
||||
created_at: datetime
|
||||
@@ -194,7 +229,7 @@ class MistakeResponse(BaseModel):
|
||||
id: int
|
||||
user_id: int
|
||||
question_id: int
|
||||
question: QuestionResponse
|
||||
question: QuestionWithAnswerResponse
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
|
||||
@@ -15,6 +15,25 @@ from utils import calculate_content_hash
|
||||
class LLMService:
|
||||
"""Service for interacting with various LLM providers"""
|
||||
|
||||
@staticmethod
|
||||
def _normalize_openai_base_url(base_url: Optional[str], default: str) -> str:
|
||||
normalized = (base_url or default).rstrip("/")
|
||||
if normalized.endswith("/v1"):
|
||||
return normalized
|
||||
|
||||
if normalized.count("/") <= 2:
|
||||
return f"{normalized}/v1"
|
||||
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _openai_compat_headers() -> Dict[str, str]:
|
||||
"""
|
||||
Some OpenAI-compatible gateways block the default OpenAI SDK user agent.
|
||||
Use a neutral UA so requests behave like a generic HTTP client.
|
||||
"""
|
||||
return {"User-Agent": "QQuiz/1.0"}
|
||||
|
||||
def __init__(self, config: Optional[Dict[str, str]] = None):
|
||||
"""
|
||||
Initialize LLM Service with optional configuration.
|
||||
@@ -28,7 +47,10 @@ class LLMService:
|
||||
|
||||
if self.provider == "openai":
|
||||
api_key = (config or {}).get("openai_api_key") or os.getenv("OPENAI_API_KEY")
|
||||
base_url = (config or {}).get("openai_base_url") or os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1")
|
||||
base_url = self._normalize_openai_base_url(
|
||||
(config or {}).get("openai_base_url") or os.getenv("OPENAI_BASE_URL"),
|
||||
"https://api.openai.com/v1"
|
||||
)
|
||||
self.model = (config or {}).get("openai_model") or os.getenv("OPENAI_MODEL", "gpt-4o-mini")
|
||||
|
||||
if not api_key:
|
||||
@@ -37,6 +59,7 @@ class LLMService:
|
||||
self.client = AsyncOpenAI(
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
default_headers=self._openai_compat_headers(),
|
||||
timeout=120.0, # 增加超时时间到 120 秒
|
||||
max_retries=3 # 自动重试 3 次
|
||||
)
|
||||
@@ -69,6 +92,7 @@ class LLMService:
|
||||
self.client = AsyncOpenAI(
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
default_headers=self._openai_compat_headers(),
|
||||
timeout=120.0, # 增加超时时间到 120 秒
|
||||
max_retries=3 # 自动重试 3 次
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user