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

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

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

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,

View File

@@ -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:

View File

@@ -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 次
)