单容器重构

This commit is contained in:
2025-12-12 22:36:25 +08:00
parent 31de3a94a6
commit 62cb6d18b0
14 changed files with 767 additions and 261 deletions

63
.dockerignore Normal file
View File

@@ -0,0 +1,63 @@
# Git
.git
.gitignore
# Python
__pycache__
*.py[cod]
*$py.class
*.so
.Python
env/
venv/
ENV/
.venv
*.egg-info/
dist/
build/
# Node
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# IDE
.vscode/
.idea/
*.swp
*.swo
*.swn
# 测试和临时文件
*.log
.pytest_cache/
.coverage
htmlcov/
test_data/
# 数据库
*.db
*.sqlite
*.sqlite3
# 上传文件
uploads/
# Docker
Dockerfile*
docker-compose*.yml
.dockerignore
# 文档
docs/
README.md
*.md
# 环境变量
.env
.env.*
# 其他
.DS_Store
Thumbs.db

View File

@@ -1,7 +1,11 @@
# Database Configuration # Database Configuration
# SQLite (推荐,默认): 单文件数据库,部署简单
DATABASE_URL=sqlite+aiosqlite:///./qquiz.db
# MySQL (可选): 适合高并发场景
# For Docker: mysql+aiomysql://qquiz:qquiz_password@mysql:3306/qquiz_db # For Docker: mysql+aiomysql://qquiz:qquiz_password@mysql:3306/qquiz_db
# For Local: mysql+aiomysql://qquiz:qquiz_password@localhost:3306/qquiz_db # For Local: mysql+aiomysql://qquiz:qquiz_password@localhost:3306/qquiz_db
DATABASE_URL=mysql+aiomysql://qquiz:qquiz_password@localhost:3306/qquiz_db # DATABASE_URL=mysql+aiomysql://qquiz:qquiz_password@localhost:3306/qquiz_db
# JWT Secret (Please change this in production!) # JWT Secret (Please change this in production!)
SECRET_KEY=your-super-secret-key-change-in-production-minimum-32-characters SECRET_KEY=your-super-secret-key-change-in-production-minimum-32-characters

51
Dockerfile Normal file
View File

@@ -0,0 +1,51 @@
# ==================== 多阶段构建:前后端整合单容器 ====================
# Stage 1: 构建前端
FROM node:18-slim AS frontend-builder
WORKDIR /frontend
# 复制前端依赖文件
COPY frontend/package*.json ./
# 安装依赖
RUN npm ci
# 复制前端源代码
COPY frontend/ ./
# 构建前端(生成静态文件到 dist 目录)
RUN npm run build
# Stage 2: 构建后端并整合前端
FROM python:3.11-slim
WORKDIR /app
# 安装系统依赖
RUN apt-get update && apt-get install -y \
build-essential \
&& rm -rf /var/lib/apt/lists/*
# 复制后端依赖文件
COPY backend/requirements.txt ./
# 安装 Python 依赖
RUN pip install --no-cache-dir -r requirements.txt
# 复制后端代码
COPY backend/ ./
# 从前端构建阶段复制静态文件到后端 static 目录
COPY --from=frontend-builder /frontend/build ./static
# 创建上传目录
RUN mkdir -p ./uploads
# 暴露端口
EXPOSE 8000
# 设置环境变量
ENV PYTHONUNBUFFERED=1
# 启动命令
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -16,24 +16,32 @@ QQuiz 是一个支持 Docker/源码双模部署的智能刷题平台,核心功
## 快速开始 ## 快速开始
### 方式一Docker Compose (推荐) ### 单容器部署(推荐
一个容器包含前后端和 SQLite 数据库:
```bash ```bash
# 1. 克隆项目 # 1. 配置环境变量
git clone <repository-url>
cd QQuiz
# 2. 配置环境变量
cp .env.example .env cp .env.example .env
# 编辑 .env填入你的 API Key 等配置 # 编辑 .env填入你的 API Key
# 3. 启动服务 # 2. 启动服务
docker-compose -f docker-compose-single.yml up -d
# 3. 访问应用: http://localhost:8000
# API 文档: http://localhost:8000/docs
```
### 传统部署3 个容器)
前后端分离 + MySQL
```bash
# 启动服务
docker-compose up -d docker-compose up -d
# 4. 访问应用
# 前端: http://localhost:3000 # 前端: http://localhost:3000
# 后端: http://localhost:8000 # 后端: http://localhost:8000
# API 文档: http://localhost:8000/docs
``` ```
### 方式二:本地运行 ### 方式二:本地运行

View File

@@ -1,32 +0,0 @@
# Dockerfile with China mirrors for faster builds
# Usage: docker build -f Dockerfile.china -t qquiz-backend .
FROM python:3.11-slim
WORKDIR /app
# Use Alibaba Cloud mirror for faster apt-get
RUN sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list || true
# Install system dependencies (gcc for compiling Python packages)
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements and install Python dependencies
COPY requirements.txt .
# Use Tsinghua PyPI mirror for faster pip install
RUN pip install --no-cache-dir -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt
# Copy application code
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

@@ -1,10 +1,13 @@
""" """
QQuiz FastAPI Application QQuiz FastAPI Application - 单容器模式(前后端整合)
""" """
from fastapi import FastAPI from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import HTMLResponse, FileResponse
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
import os import os
from pathlib import Path
from dotenv import load_dotenv from dotenv import load_dotenv
from database import init_db, init_default_config, get_db_context from database import init_db, init_default_config, get_db_context
@@ -58,22 +61,6 @@ app.add_middleware(
) )
@app.get("/")
async def root():
"""Root endpoint"""
return {
"message": "Welcome to QQuiz API",
"version": "1.0.0",
"docs": "/docs"
}
@app.get("/health")
async def health_check():
"""Health check endpoint"""
return {"status": "healthy"}
# Import and include routers # Import and include routers
from routers import auth, exam, question, mistake, admin from routers import auth, exam, question, mistake, admin
@@ -82,3 +69,50 @@ app.include_router(exam.router, prefix="/api/exams", tags=["Exams"])
app.include_router(question.router, prefix="/api/questions", tags=["Questions"]) app.include_router(question.router, prefix="/api/questions", tags=["Questions"])
app.include_router(mistake.router, prefix="/api/mistakes", tags=["Mistakes"]) app.include_router(mistake.router, prefix="/api/mistakes", tags=["Mistakes"])
app.include_router(admin.router, prefix="/api/admin", tags=["Admin"]) app.include_router(admin.router, prefix="/api/admin", tags=["Admin"])
# API 健康检查
@app.get("/health")
async def health_check():
"""Health check endpoint"""
return {"status": "healthy"}
# ============ 静态文件服务(前后端整合) ============
# 检查静态文件目录是否存在
STATIC_DIR = Path(__file__).parent / "static"
if STATIC_DIR.exists():
# 挂载静态资源JS、CSS、图片等
app.mount("/assets", StaticFiles(directory=str(STATIC_DIR / "assets")), name="static_assets")
# 前端应用的所有路由SPA路由
@app.get("/{full_path:path}")
async def serve_frontend(full_path: str):
"""
服务前端应用
- API 路由已在上面定义,优先匹配
- 其他所有路由返回 index.htmlSPA 单页应用)
"""
index_file = STATIC_DIR / "index.html"
if index_file.exists():
return FileResponse(index_file)
else:
return {
"message": "Frontend not built yet",
"hint": "Run 'cd frontend && npm run build' to build the frontend"
}
else:
print("⚠️ 静态文件目录不存在,前端功能不可用")
print("提示:请先构建前端应用或使用开发模式")
# 如果没有静态文件,显示 API 信息
@app.get("/")
async def root():
"""Root endpoint"""
return {
"message": "Welcome to QQuiz API",
"version": "1.0.0",
"docs": "/docs",
"note": "Frontend not built. Please build frontend or use docker-compose."
}

View File

@@ -1,6 +1,7 @@
fastapi==0.109.0 fastapi==0.109.0
uvicorn[standard]==0.27.0 uvicorn[standard]==0.27.0
sqlalchemy==2.0.25 sqlalchemy==2.0.25
aiosqlite==0.19.0
aiomysql==0.2.0 aiomysql==0.2.0
pymysql==1.1.0 pymysql==1.1.0
alembic==1.13.1 alembic==1.13.1

View File

@@ -1,16 +1,27 @@
""" """
Admin Router Admin Router - 完备的管理员功能模块
参考 OpenWebUI 设计
""" """
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import StreamingResponse, FileResponse
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select, func, and_, or_, desc
from typing import List, Dict, Any, Optional
from datetime import datetime, timedelta
from passlib.context import CryptContext
import io
import json
from database import get_db from database import get_db, engine
from models import User, SystemConfig from models import User, SystemConfig, Exam, Question, UserMistake, ExamStatus
from schemas import SystemConfigUpdate, SystemConfigResponse from schemas import (
SystemConfigUpdate, SystemConfigResponse,
UserResponse, UserCreate, UserUpdate, UserListResponse
)
from services.auth_service import get_current_admin_user from services.auth_service import get_current_admin_user
router = APIRouter() router = APIRouter()
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
@router.get("/config", response_model=SystemConfigResponse) @router.get("/config", response_model=SystemConfigResponse)
@@ -79,3 +90,362 @@ async def update_system_config(
# Return updated config # Return updated config
return await get_system_config(current_admin, db) return await get_system_config(current_admin, db)
# ==================== 用户管理模块 ====================
@router.get("/users", response_model=UserListResponse)
async def get_users(
skip: int = 0,
limit: int = 50,
search: Optional[str] = None,
current_admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_db)
):
"""
获取用户列表(分页、搜索)
- skip: 跳过的记录数
- limit: 返回的最大记录数
- search: 搜索关键词(用户名)
"""
query = select(User)
# 搜索过滤
if search:
query = query.where(User.username.ilike(f"%{search}%"))
# 统计总数
count_query = select(func.count()).select_from(query.subquery())
result = await db.execute(count_query)
total = result.scalar()
# 分页查询
query = query.order_by(desc(User.created_at)).offset(skip).limit(limit)
result = await db.execute(query)
users = result.scalars().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()
# 统计用户的错题数
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
}
@router.post("/users", response_model=UserResponse)
async def create_user(
user_data: UserCreate,
current_admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_db)
):
"""创建新用户(仅管理员)"""
# 检查用户名是否已存在
result = await db.execute(select(User).where(User.username == user_data.username))
existing_user = result.scalar_one_or_none()
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already exists"
)
# 创建新用户
hashed_password = pwd_context.hash(user_data.password)
new_user = User(
username=user_data.username,
hashed_password=hashed_password,
is_admin=user_data.is_admin
)
db.add(new_user)
await db.commit()
await db.refresh(new_user)
return new_user
@router.put("/users/{user_id}", response_model=UserResponse)
async def update_user(
user_id: int,
user_data: UserUpdate,
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"
)
# 不允许修改默认管理员的管理员状态
if user.username == "admin" and user_data.is_admin is not None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot modify default admin user's admin status"
)
# 更新字段
update_data = user_data.dict(exclude_unset=True)
if "password" in update_data:
update_data["hashed_password"] = pwd_context.hash(update_data.pop("password"))
for key, value in update_data.items():
setattr(user, key, value)
await db.commit()
await db.refresh(user)
return user
@router.delete("/users/{user_id}")
async def delete_user(
user_id: int,
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"
)
# 不允许删除默认管理员
if user.username == "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot delete default admin user"
)
# 不允许管理员删除自己
if user.id == current_admin.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot delete yourself"
)
await db.delete(user)
await db.commit()
return {"message": "User deleted successfully"}
# ==================== 系统统计模块 ====================
@router.get("/statistics")
async def get_system_statistics(
current_admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_db)
):
"""
获取系统统计信息
- 用户总数
- 题库总数
- 题目总数
- 今日活跃用户数
- 今日上传数
"""
# 用户统计
user_count_result = await db.execute(select(func.count(User.id)))
total_users = user_count_result.scalar()
admin_count_result = await db.execute(select(func.count(User.id)).where(User.is_admin == True))
admin_users = admin_count_result.scalar()
# 题库统计
exam_count_result = await db.execute(select(func.count(Exam.id)))
total_exams = exam_count_result.scalar()
exam_status_query = select(Exam.status, func.count(Exam.id)).group_by(Exam.status)
exam_status_result = await db.execute(exam_status_query)
exam_by_status = {row[0].value: row[1] for row in exam_status_result.all()}
# 题目统计
question_count_result = await db.execute(select(func.count(Question.id)))
total_questions = question_count_result.scalar()
question_type_query = select(Question.type, func.count(Question.id)).group_by(Question.type)
question_type_result = await db.execute(question_type_query)
questions_by_type = {row[0].value: row[1] for row in question_type_result.all()}
# 今日统计
today_start = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
today_uploads_result = await db.execute(
select(func.count(Exam.id)).where(Exam.created_at >= today_start)
)
today_uploads = today_uploads_result.scalar()
# 活跃用户(今日创建过题库的用户)
today_active_users_result = await db.execute(
select(func.count(func.distinct(Exam.user_id))).where(Exam.created_at >= today_start)
)
today_active_users = today_active_users_result.scalar()
# 最近7天趋势
seven_days_ago = datetime.utcnow() - timedelta(days=7)
recent_exams_query = select(
func.date(Exam.created_at).label("date"),
func.count(Exam.id).label("count")
).where(Exam.created_at >= seven_days_ago).group_by(func.date(Exam.created_at))
recent_exams_result = await db.execute(recent_exams_query)
upload_trend = [{"date": str(row[0]), "count": row[1]} for row in recent_exams_result.all()]
return {
"users": {
"total": total_users,
"admins": admin_users,
"regular_users": total_users - admin_users
},
"exams": {
"total": total_exams,
"by_status": exam_by_status,
"today_uploads": today_uploads,
"upload_trend": upload_trend
},
"questions": {
"total": total_questions,
"by_type": questions_by_type
},
"activity": {
"today_active_users": today_active_users,
"today_uploads": today_uploads
}
}
# ==================== 系统监控模块 ====================
@router.get("/health")
async def get_system_health(
current_admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_db)
):
"""
系统健康检查
- 数据库连接状态
- 数据库大小SQLite
- 系统信息
"""
import os
import sys
import platform
health_status = {
"status": "healthy",
"timestamp": datetime.utcnow().isoformat(),
"system": {
"platform": platform.platform(),
"python_version": sys.version,
"database_url": os.getenv("DATABASE_URL", "").split("://")[0] if os.getenv("DATABASE_URL") else "unknown"
},
"database": {
"connected": True
}
}
# 检查数据库大小(仅 SQLite
try:
db_url = os.getenv("DATABASE_URL", "")
if "sqlite" in db_url:
# 提取数据库文件路径
db_path = db_url.split("///")[-1] if "///" in db_url else None
if db_path and os.path.exists(db_path):
db_size = os.path.getsize(db_path)
health_status["database"]["size_mb"] = round(db_size / (1024 * 1024), 2)
health_status["database"]["path"] = db_path
except Exception as e:
health_status["database"]["size_error"] = str(e)
return health_status
# ==================== 数据导出模块 ====================
@router.get("/export/users")
async def export_users_csv(
current_admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_db)
):
"""导出用户列表为 CSV"""
result = await db.execute(select(User).order_by(User.id))
users = result.scalars().all()
# 创建 CSV 内容
csv_content = "ID,Username,Is Admin,Created At\n"
for user in users:
csv_content += f"{user.id},{user.username},{user.is_admin},{user.created_at}\n"
# 返回文件流
return StreamingResponse(
io.StringIO(csv_content),
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=users.csv"}
)
@router.get("/export/statistics")
async def export_statistics_json(
current_admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_db)
):
"""导出系统统计信息为 JSON"""
stats = await get_system_statistics(current_admin, db)
json_content = json.dumps(stats, indent=2, ensure_ascii=False, default=str)
return StreamingResponse(
io.StringIO(json_content),
media_type="application/json",
headers={"Content-Disposition": "attachment; filename=statistics.json"}
)
# ==================== 日志模块 ====================
@router.get("/logs/recent")
async def get_recent_logs(
limit: int = 100,
level: Optional[str] = None,
current_admin: User = Depends(get_current_admin_user)
):
"""
获取最近的日志(暂时返回模拟数据,实际需要接入日志系统)
TODO: 接入实际日志系统如文件日志、ELK、Loki等
"""
# 这是一个占位实现,实际应该从日志文件或日志系统读取
return {
"message": "日志功能暂未完全实现,建议使用 Docker logs 或配置外部日志系统",
"suggestion": "可以使用: docker logs qquiz_backend --tail 100",
"logs": []
}

View File

@@ -11,6 +11,7 @@ from models import ExamStatus, QuestionType
class UserCreate(BaseModel): class UserCreate(BaseModel):
username: str = Field(..., min_length=3, max_length=50) username: str = Field(..., min_length=3, max_length=50)
password: str = Field(..., min_length=6) password: str = Field(..., min_length=6)
is_admin: bool = False # 支持管理员创建用户时指定角色
@validator('username') @validator('username')
def username_alphanumeric(cls, v): def username_alphanumeric(cls, v):
@@ -19,6 +20,19 @@ class UserCreate(BaseModel):
return v return v
class UserUpdate(BaseModel):
"""用户更新 Schema所有字段可选"""
username: Optional[str] = Field(None, min_length=3, max_length=50)
password: Optional[str] = Field(None, min_length=6)
is_admin: Optional[bool] = None
@validator('username')
def username_alphanumeric(cls, v):
if v is not None and not v.replace('_', '').replace('-', '').isalnum():
raise ValueError('Username must be alphanumeric (allows _ and -)')
return v
class UserLogin(BaseModel): class UserLogin(BaseModel):
username: str username: str
password: str password: str
@@ -39,6 +53,14 @@ class UserResponse(BaseModel):
from_attributes = True from_attributes = True
class UserListResponse(BaseModel):
"""用户列表响应(包含分页信息)"""
users: List[dict] # 包含额外统计信息的用户列表
total: int
skip: int
limit: int
# ============ System Config Schemas ============ # ============ System Config Schemas ============
class SystemConfigUpdate(BaseModel): class SystemConfigUpdate(BaseModel):
allow_registration: Optional[bool] = None allow_registration: Optional[bool] = None

View File

@@ -118,47 +118,83 @@ class LLMService:
""" """
prompt = """你是一个专业的试题解析专家。请仔细分析下面的文档内容,提取其中的所有试题。 prompt = """你是一个专业的试题解析专家。请仔细分析下面的文档内容,提取其中的所有试题。
请注意 **识别规则**
- 文档中可能包含中文或英文题目 - 文档中可能包含中文或英文题目
- 题目可能有多种格式,请灵活识别 - 题目可能有多种格式,请灵活识别
- 即使格式不标准,也请尽量提取题目内容 - 即使格式不标准,也请尽量提取题目内容
- 如果文档只是普通文章而没有题目,请返回空数组 [] - 如果文档只是普通文章而没有题目,请返回空数组 []
对于每道题目,请识别 **题目类型识别** (严格使用以下4种类型之一)
1. 题目内容 (完整的题目文字) 1. **single** - 单选题:只有一个正确答案的选择题
2. 题目类型(**只能**使用以下4种类型之一 2. **multiple** - 多选题有多个正确答案的选择题答案格式如AB、ABC、ACD等
- single单选题 3. **judge** - 判断题:对错/是非/True False题目
- multiple多选 4. **short** - 简答题:包括问答、计算、证明、填空、编程等所有非选择
- judge判断题
- short简答题包括问答题、计算题、证明题、填空题等所有非选择题
3. 选项 (仅针对选择题,格式: ["A. 选项1", "B. 选项2", ...])
4. 正确答案 (请仔细查找文档中的答案。如果确实没有答案,可以填 null)
5. 解析/说明 (如果有的话)
**重要**:题目类型必须是 single、multiple、judge、short 之一,不要使用其他类型名称! **多选题识别关键词**
- 明确标注"多选""多项选择""Multiple Choice"
- 题干中包含"可能""正确的有""包括"
- 答案是多个字母组合ABC、BD、ABCD
返回格式:请**只返回** JSON 数组,不要有任何其他文字或 markdown 代码块 **每道题目提取字段**
1. **content**: 完整的题目文字(去除题号)
2. **type**: 题目类型single/multiple/judge/short
3. **options**: 选项数组(仅选择题,格式: ["A. 选项1", "B. 选项2", ...]
4. **answer**: 正确答案
- 单选题: 单个字母 (如 "A""B")
- 多选题: 多个字母无空格 (如 "AB""ABC""BD")
- 判断题: ""/"""正确"/"错误""True"/"False"
- 简答题: 完整答案文本,如果没有答案填 null
5. **analysis**: 解析说明(如果有)
**JSON 格式要求**
- 必须返回一个完整的 JSON 数组 (以 [ 开始,以 ] 结束)
- 不要包含 markdown 代码块标记 (```json 或 ```)
- 不要包含任何解释性文字
- 字符串中的特殊字符必须正确转义(换行用 \\n引号用 \\",反斜杠用 \\\\
- 不要在字符串值中使用未转义的控制字符
**返回格式示例**
[ [
{{ {{
"content": "题目内容", "content": "下列关于Python的描述正确的是",
"type": "single", "type": "single",
"options": ["A. 选项1", "B. 选项2", "C. 选项3", "D. 选项4"], "options": ["A. Python是编译型语言", "B. Python支持面向对象编程", "C. Python不支持函数式编程", "D. Python只能用于Web开发"],
"answer": "A", "answer": "B",
"analysis": "解析说明" "analysis": "Python是解释型语言支持多种编程范式"
}}, }},
... {{
"content": "以下哪些是Python的优点多选",
"type": "multiple",
"options": ["A. 语法简洁", "B. 库丰富", "C. 执行速度最快", "D. 易于学习"],
"answer": "ABD",
"analysis": "Python优点是语法简洁、库丰富、易学但执行速度不是最快的"
}},
{{
"content": "Python是一种高级编程语言",
"type": "judge",
"options": [],
"answer": "",
"analysis": null
}},
{{
"content": "请解释Python中的装饰器是什么",
"type": "short",
"options": [],
"answer": "装饰器是Python中一种特殊的函数用于修改其他函数的行为...",
"analysis": null
}}
] ]
文档内容: **文档内容**
--- ---
{content} {content}
--- ---
重要提示 **最后提醒**
- 仔细阅读文档内容 - 仔细识别多选题(看题干、看答案格式)
- 识别所有看起来像试题的内容 - 单选和多选容易混淆,请特别注意区分
- 如果文档中没有题目(比如只是普通文章),返回 [] - 如果文档中没有题目,返回 []
- **只返回 JSON 数组**,不要包含 ```json 或其他标记""" - 只返回 JSON 数组,不要有任何其他内容"""
try: try:
if self.provider == "anthropic": if self.provider == "anthropic":
@@ -242,6 +278,14 @@ class LLMService:
result = result.strip() result = result.strip()
# Additional cleanup: fix common JSON issues
# 1. Remove trailing commas before closing brackets
import re
result = re.sub(r',(\s*[}\]])', r'\1', result)
# 2. Fix unescaped quotes in string values (basic attempt)
# This is tricky and may not catch all cases, but helps with common issues
# Log the cleaned result for debugging # Log the cleaned result for debugging
print(f"[LLM Cleaned JSON] Length: {len(result)} chars") print(f"[LLM Cleaned JSON] Length: {len(result)} chars")
print(f"[LLM Cleaned JSON] First 300 chars:\n{result[:300]}") print(f"[LLM Cleaned JSON] First 300 chars:\n{result[:300]}")
@@ -377,42 +421,61 @@ class LLMService:
prompt = """你是一个专业的试题解析专家。请仔细分析这个 PDF 文档,提取其中的所有试题。 prompt = """你是一个专业的试题解析专家。请仔细分析这个 PDF 文档,提取其中的所有试题。
请注意 **识别规则**
- PDF 中可能包含中文或英文题目 - PDF 中可能包含中文或英文题目、图片、表格、公式
- 题目可能有多种格式,请灵活识别 - 题目可能有多种格式,请灵活识别
- 即使格式不标准,也请尽量提取题目内容 - 即使格式不标准,也请尽量提取题目内容
- 题目内容如果包含代码或换行,请将换行符替换为空格或\\n - 题目内容如果包含代码或换行,请将换行符替换为\\n
- 图片中的文字也要识别并提取
对于每道题目,请识别 **题目类型识别** (严格使用以下4种类型之一)
1. 题目内容 (完整的题目文字,如果有代码请保持在一行或用\\n表示换行) 1. **single** - 单选题:只有一个正确答案的选择题
2. 题目类型(**只能**使用以下4种类型之一 2. **multiple** - 多选题有多个正确答案的选择题答案格式如AB、ABC、ACD等
- single单选题 3. **judge** - 判断题:对错/是非/True False题目
- multiple多选 4. **short** - 简答题:包括问答、计算、证明、填空、编程等所有非选择
- judge判断题
- short简答题包括问答题、计算题、证明题、填空题等所有非选择题
3. 选项 (仅针对选择题,格式: ["A. 选项1", "B. 选项2", ...])
4. 正确答案 (请仔细查找文档中的答案。如果确实没有答案,可以填 null)
5. 解析/说明 (如果有的话)
**重要**:题目类型必须是 single、multiple、judge、short 之一,不要使用其他类型名称! **多选题识别关键词**
- 明确标注"多选""多项选择""Multiple Choice"
- 题干中包含"可能""正确的有""包括"
- 答案是多个字母组合ABC、BD、ABCD
返回格式要求 **每道题目提取字段**
1. **content**: 完整的题目文字(去除题号,换行用\\n表示
2. **type**: 题目类型single/multiple/judge/short
3. **options**: 选项数组(仅选择题,格式: ["A. 选项1", "B. 选项2", ...]
4. **answer**: 正确答案
- 单选题: 单个字母 (如 "A""B")
- 多选题: 多个字母无空格 (如 "AB""ABC""BD")
- 判断题: ""/"""正确"/"错误""True"/"False"
- 简答题: 完整答案文本,如果没有答案填 null
5. **analysis**: 解析说明(如果有)
**JSON 格式要求**
1. **必须**返回一个完整的 JSON 数组(以 [ 开始,以 ] 结束) 1. **必须**返回一个完整的 JSON 数组(以 [ 开始,以 ] 结束)
2. **不要**返回 JSONL 格式(每行一个 JSON 对象) 2. **不要**返回 JSONL 格式(每行一个 JSON 对象)
3. **不要**包含 markdown 代码块标记(```json 或 ``` 3. **不要**包含 markdown 代码块标记(```json 或 ```
4. **不要**包含任何解释性文字 4. **不要**包含任何解释性文字
5. 字符串中的特殊字符必须正确转义(换行用 \\n引号用 \\",反斜杠用 \\\\
6. 不要在字符串值中使用未转义的控制字符
正确的格式示例: **返回格式示例**
[ [
{{ {{
"content": "题目内容", "content": "下列关于Python的描述正确的是",
"type": "single", "type": "single",
"options": ["A. 选项1", "B. 选项2", "C. 选项3", "D. 选项4"], "options": ["A. Python是编译型语言", "B. Python支持面向对象编程", "C. Python不支持函数式编程", "D. Python只能用于Web开发"],
"answer": "A", "answer": "B",
"analysis": "解析说明" "analysis": "Python是解释型语言支持多种编程范式"
}}, }},
{{ {{
"content": "第二道题", "content": "以下哪些是Python的优点多选",
"type": "multiple",
"options": ["A. 语法简洁", "B. 库丰富", "C. 执行速度最快", "D. 易于学习"],
"answer": "ABD",
"analysis": "Python优点是语法简洁、库丰富、易学但执行速度不是最快的"
}},
{{
"content": "Python是一种高级编程语言",
"type": "judge", "type": "judge",
"options": [], "options": [],
"answer": "", "answer": "",
@@ -420,9 +483,10 @@ class LLMService:
}} }}
] ]
重要提示 **最后提醒**
- 请仔细查看 PDF 的每一页 - 请仔细查看 PDF 的每一页
- 识别所有看起来像试题的内容 - 仔细识别多选题(看题干、看答案格式)
- 单选和多选容易混淆,请特别注意区分
- 如果找不到明确的选项,可以根据上下文推断题目类型 - 如果找不到明确的选项,可以根据上下文推断题目类型
- 题目内容中的换行请用\\n或空格替换确保 JSON 格式正确 - 题目内容中的换行请用\\n或空格替换确保 JSON 格式正确
- **只返回一个 JSON 数组**,不要包含其他任何内容""" - **只返回一个 JSON 数组**,不要包含其他任何内容"""
@@ -501,6 +565,11 @@ class LLMService:
result = result.strip() result = result.strip()
# Additional cleanup: fix common JSON issues
# 1. Remove trailing commas before closing brackets
import re
result = re.sub(r',(\s*[}\]])', r'\1', result)
# Log the cleaned result for debugging # Log the cleaned result for debugging
print(f"[LLM Cleaned JSON] Length: {len(result)} chars", flush=True) print(f"[LLM Cleaned JSON] Length: {len(result)} chars", flush=True)
print(f"[LLM Cleaned JSON] First 300 chars:\n{result[:300]}", flush=True) print(f"[LLM Cleaned JSON] First 300 chars:\n{result[:300]}", flush=True)

62
docker-compose-single.yml Normal file
View File

@@ -0,0 +1,62 @@
# ==================== 单容器部署配置 ====================
# 使用方法docker-compose -f docker-compose-single.yml up -d
version: '3.8'
services:
qquiz:
build:
context: .
dockerfile: Dockerfile
container_name: qquiz
ports:
- "8000:8000"
environment:
# 数据库配置SQLite 默认)
- DATABASE_URL=sqlite+aiosqlite:////app/data/qquiz.db
# JWT 密钥(生产环境请修改)
- SECRET_KEY=your-super-secret-key-change-in-production-minimum-32-characters
# AI 提供商配置
- AI_PROVIDER=gemini
- GEMINI_API_KEY=${GEMINI_API_KEY}
- GEMINI_BASE_URL=${GEMINI_BASE_URL:-https://generativelanguage.googleapis.com}
- GEMINI_MODEL=${GEMINI_MODEL:-gemini-2.0-flash-exp}
# OpenAI 配置(可选)
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
- OPENAI_BASE_URL=${OPENAI_BASE_URL:-https://api.openai.com/v1}
- OPENAI_MODEL=${OPENAI_MODEL:-gpt-4o-mini}
# Anthropic 配置(可选)
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
- ANTHROPIC_MODEL=${ANTHROPIC_MODEL:-claude-3-haiku-20240307}
# Qwen 配置(可选)
- QWEN_API_KEY=${QWEN_API_KEY:-}
- QWEN_BASE_URL=${QWEN_BASE_URL:-https://dashscope.aliyuncs.com/compatible-mode/v1}
- QWEN_MODEL=${QWEN_MODEL:-qwen-plus}
# 系统配置
- ALLOW_REGISTRATION=true
- MAX_UPLOAD_SIZE_MB=10
- MAX_DAILY_UPLOADS=20
volumes:
# 持久化数据卷
- qquiz_data:/app/data # 数据库文件
- qquiz_uploads:/app/uploads # 上传文件
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
volumes:
qquiz_data:
qquiz_uploads:

View File

@@ -1,124 +0,0 @@
# 中国镜像加速指南
如果你在中国大陆Docker 构建速度可能很慢。我们提供了使用国内镜像的可选 Dockerfile。
## 使用方法
### 方法一:使用中国镜像版 Dockerfile推荐
```bash
# 构建后端(使用中国镜像)
cd backend
docker build -f Dockerfile.china -t qquiz-backend .
# 构建前端(使用中国镜像)
cd ../frontend
docker build -f Dockerfile.china -t qquiz-frontend .
# 或者一次性构建所有服务
docker-compose build
```
### 方法二:临时使用 Docker Compose 覆盖
创建 `docker-compose.override.yml`(已在 .gitignore 中):
```yaml
version: '3.8'
services:
backend:
build:
context: ./backend
dockerfile: Dockerfile.china
frontend:
build:
context: ./frontend
dockerfile: Dockerfile.china
```
然后正常运行:
```bash
docker-compose up -d --build
```
### 方法三:配置 Docker Hub 镜像加速
编辑 Docker 配置文件:
- **Windows**: Docker Desktop → Settings → Docker Engine
- **Linux**: `/etc/docker/daemon.json`
添加以下内容:
```json
{
"registry-mirrors": [
"https://docker.mirrors.ustc.edu.cn",
"https://hub-mirror.c.163.com",
"https://mirror.baidubce.com"
]
}
```
重启 Docker 服务。
## 镜像源说明
### Dockerfile.china 使用的镜像源:
- **apt-get**: 阿里云镜像 (mirrors.aliyun.com)
- **pip**: 清华大学镜像 (pypi.tuna.tsinghua.edu.cn)
- **npm**: 淘宝镜像 (registry.npmmirror.com)
### 其他可选镜像源:
**Python PyPI:**
- 清华https://pypi.tuna.tsinghua.edu.cn/simple
- 阿里云https://mirrors.aliyun.com/pypi/simple/
- 中科大https://pypi.mirrors.ustc.edu.cn/simple/
**Node.js npm:**
- 淘宝https://registry.npmmirror.com
- 华为云https://repo.huaweicloud.com/repository/npm/
**Debian/Ubuntu apt:**
- 阿里云mirrors.aliyun.com
- 清华mirrors.tuna.tsinghua.edu.cn
- 中科大mirrors.ustc.edu.cn
## 注意事项
⚠️ **不要提交 docker-compose.override.yml 到 Git**
⚠️ **Dockerfile.china 仅供中国大陆用户使用**
⚠️ **国际用户请使用默认的 Dockerfile**
## 速度对比
| 构建步骤 | 默认源 | 中国镜像 | 加速比 |
|---------|--------|---------|--------|
| apt-get update | 30-60s | 5-10s | 3-6x |
| pip install | 3-5min | 30-60s | 3-5x |
| npm install | 2-4min | 30-60s | 2-4x |
| **总计** | **5-10min** | **1-3min** | **3-5x** |
## 故障排除
### 如果镜像源失效
1. 尝试其他镜像源(见上方"其他可选镜像源"
2. 检查镜像源是否可访问:
```bash
# 测试 PyPI 镜像
curl -I https://pypi.tuna.tsinghua.edu.cn/simple
# 测试 npm 镜像
curl -I https://registry.npmmirror.com
```
3. 如果所有镜像都不可用,使用默认的 Dockerfile
### 如果构建仍然很慢
1. 检查 Docker Desktop 内存分配(建议 ≥ 4GB
2. 清理 Docker 缓存:`docker system prune -a`
3. 使用 BuildKit`DOCKER_BUILDKIT=1 docker-compose build`

View File

@@ -1,22 +0,0 @@
# Dockerfile with China mirrors for faster builds
# Usage: docker build -f Dockerfile.china -t qquiz-frontend .
FROM node:18-alpine
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies using Taobao npm registry
RUN npm config set registry https://registry.npmmirror.com && \
npm install
# Copy application code
COPY . .
# Expose port
EXPOSE 3000
# Start development server
CMD ["npm", "start"]

View File

@@ -6,7 +6,7 @@ import toast from 'react-hot-toast'
// Create axios instance // Create axios instance
const api = axios.create({ const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8000', baseURL: import.meta.env.VITE_API_URL || '/api',
timeout: 30000, timeout: 30000,
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'