diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fb6a069 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.env.example b/.env.example index 60eb4eb..47c2e41 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,11 @@ # Database Configuration +# SQLite (推荐,默认): 单文件数据库,部署简单 +DATABASE_URL=sqlite+aiosqlite:///./qquiz.db + +# MySQL (可选): 适合高并发场景 # For Docker: mysql+aiomysql://qquiz:qquiz_password@mysql: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!) SECRET_KEY=your-super-secret-key-change-in-production-minimum-32-characters diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..264a2ad --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index 2097fd7..a552afc 100644 --- a/README.md +++ b/README.md @@ -16,24 +16,32 @@ QQuiz 是一个支持 Docker/源码双模部署的智能刷题平台,核心功 ## 快速开始 -### 方式一:Docker Compose (推荐) +### 单容器部署(推荐) + +一个容器包含前后端和 SQLite 数据库: ```bash -# 1. 克隆项目 -git clone -cd QQuiz - -# 2. 配置环境变量 +# 1. 配置环境变量 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 -# 4. 访问应用 # 前端: http://localhost:3000 # 后端: http://localhost:8000 -# API 文档: http://localhost:8000/docs ``` ### 方式二:本地运行 diff --git a/backend/Dockerfile.china b/backend/Dockerfile.china deleted file mode 100644 index bfbc4b3..0000000 --- a/backend/Dockerfile.china +++ /dev/null @@ -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 diff --git a/backend/main.py b/backend/main.py index 53d8093..c2d0051 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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.staticfiles import StaticFiles +from fastapi.responses import HTMLResponse, FileResponse from contextlib import asynccontextmanager import os +from pathlib import Path from dotenv import load_dotenv 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 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(mistake.router, prefix="/api/mistakes", tags=["Mistakes"]) 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.html(SPA 单页应用) + """ + 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." + } diff --git a/backend/requirements.txt b/backend/requirements.txt index 04442ac..42efdb2 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,6 +1,7 @@ fastapi==0.109.0 uvicorn[standard]==0.27.0 sqlalchemy==2.0.25 +aiosqlite==0.19.0 aiomysql==0.2.0 pymysql==1.1.0 alembic==1.13.1 diff --git a/backend/routers/admin.py b/backend/routers/admin.py index b1b881e..f9b04c6 100644 --- a/backend/routers/admin.py +++ b/backend/routers/admin.py @@ -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 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 models import User, SystemConfig -from schemas import SystemConfigUpdate, SystemConfigResponse +from database import get_db, engine +from models import User, SystemConfig, Exam, Question, UserMistake, ExamStatus +from schemas import ( + SystemConfigUpdate, SystemConfigResponse, + UserResponse, UserCreate, UserUpdate, UserListResponse +) from services.auth_service import get_current_admin_user router = APIRouter() +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") @router.get("/config", response_model=SystemConfigResponse) @@ -79,3 +90,362 @@ async def update_system_config( # Return updated config 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": [] + } diff --git a/backend/schemas.py b/backend/schemas.py index 2f7d9cc..16f8f74 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -11,6 +11,7 @@ from models import ExamStatus, QuestionType class UserCreate(BaseModel): username: str = Field(..., min_length=3, max_length=50) password: str = Field(..., min_length=6) + is_admin: bool = False # 支持管理员创建用户时指定角色 @validator('username') def username_alphanumeric(cls, v): @@ -19,6 +20,19 @@ class UserCreate(BaseModel): 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): username: str password: str @@ -39,6 +53,14 @@ class UserResponse(BaseModel): from_attributes = True +class UserListResponse(BaseModel): + """用户列表响应(包含分页信息)""" + users: List[dict] # 包含额外统计信息的用户列表 + total: int + skip: int + limit: int + + # ============ System Config Schemas ============ class SystemConfigUpdate(BaseModel): allow_registration: Optional[bool] = None diff --git a/backend/services/llm_service.py b/backend/services/llm_service.py index a667704..3595bd8 100644 --- a/backend/services/llm_service.py +++ b/backend/services/llm_service.py @@ -118,47 +118,83 @@ class LLMService: """ prompt = """你是一个专业的试题解析专家。请仔细分析下面的文档内容,提取其中的所有试题。 -请注意: +**识别规则**: - 文档中可能包含中文或英文题目 - 题目可能有多种格式,请灵活识别 - 即使格式不标准,也请尽量提取题目内容 - 如果文档只是普通文章而没有题目,请返回空数组 [] -对于每道题目,请识别: -1. 题目内容 (完整的题目文字) -2. 题目类型(**只能**使用以下4种类型之一): - - single:单选题 - - multiple:多选题 - - judge:判断题 - - short:简答题(包括问答题、计算题、证明题、填空题等所有非选择题) -3. 选项 (仅针对选择题,格式: ["A. 选项1", "B. 选项2", ...]) -4. 正确答案 (请仔细查找文档中的答案。如果确实没有答案,可以填 null) -5. 解析/说明 (如果有的话) +**题目类型识别** (严格使用以下4种类型之一): +1. **single** - 单选题:只有一个正确答案的选择题 +2. **multiple** - 多选题:有多个正确答案的选择题(答案格式如:AB、ABC、ACD等) +3. **judge** - 判断题:对错/是非/True False题目 +4. **short** - 简答题:包括问答、计算、证明、填空、编程等所有非选择题 -**重要**:题目类型必须是 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", - "options": ["A. 选项1", "B. 选项2", "C. 选项3", "D. 选项4"], - "answer": "A", - "analysis": "解析说明" + "options": ["A. Python是编译型语言", "B. Python支持面向对象编程", "C. Python不支持函数式编程", "D. Python只能用于Web开发"], + "answer": "B", + "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} --- -重要提示: -- 仔细阅读文档内容 -- 识别所有看起来像试题的内容 -- 如果文档中没有题目(比如只是普通文章),返回 [] -- **只返回 JSON 数组**,不要包含 ```json 或其他标记""" +**最后提醒**: +- 仔细识别多选题(看题干、看答案格式) +- 单选和多选容易混淆,请特别注意区分 +- 如果文档中没有题目,返回 [] +- 只返回 JSON 数组,不要有任何其他内容""" try: if self.provider == "anthropic": @@ -242,6 +278,14 @@ class LLMService: 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 print(f"[LLM Cleaned JSON] Length: {len(result)} chars") print(f"[LLM Cleaned JSON] First 300 chars:\n{result[:300]}") @@ -377,42 +421,61 @@ class LLMService: prompt = """你是一个专业的试题解析专家。请仔细分析这个 PDF 文档,提取其中的所有试题。 -请注意: -- PDF 中可能包含中文或英文题目 +**识别规则**: +- PDF 中可能包含中文或英文题目、图片、表格、公式 - 题目可能有多种格式,请灵活识别 - 即使格式不标准,也请尽量提取题目内容 -- 题目内容如果包含代码或换行,请将换行符替换为空格或\\n +- 题目内容如果包含代码或换行,请将换行符替换为\\n +- 图片中的文字也要识别并提取 -对于每道题目,请识别: -1. 题目内容 (完整的题目文字,如果有代码请保持在一行或用\\n表示换行) -2. 题目类型(**只能**使用以下4种类型之一): - - single:单选题 - - multiple:多选题 - - judge:判断题 - - short:简答题(包括问答题、计算题、证明题、填空题等所有非选择题) -3. 选项 (仅针对选择题,格式: ["A. 选项1", "B. 选项2", ...]) -4. 正确答案 (请仔细查找文档中的答案。如果确实没有答案,可以填 null) -5. 解析/说明 (如果有的话) +**题目类型识别** (严格使用以下4种类型之一): +1. **single** - 单选题:只有一个正确答案的选择题 +2. **multiple** - 多选题:有多个正确答案的选择题(答案格式如:AB、ABC、ACD等) +3. **judge** - 判断题:对错/是非/True False题目 +4. **short** - 简答题:包括问答、计算、证明、填空、编程等所有非选择题 -**重要**:题目类型必须是 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 数组(以 [ 开始,以 ] 结束) 2. **不要**返回 JSONL 格式(每行一个 JSON 对象) 3. **不要**包含 markdown 代码块标记(```json 或 ```) 4. **不要**包含任何解释性文字 +5. 字符串中的特殊字符必须正确转义(换行用 \\n,引号用 \\",反斜杠用 \\\\) +6. 不要在字符串值中使用未转义的控制字符 -正确的格式示例: +**返回格式示例**: [ {{ - "content": "题目内容", + "content": "下列关于Python的描述,正确的是", "type": "single", - "options": ["A. 选项1", "B. 选项2", "C. 选项3", "D. 选项4"], - "answer": "A", - "analysis": "解析说明" + "options": ["A. Python是编译型语言", "B. Python支持面向对象编程", "C. Python不支持函数式编程", "D. Python只能用于Web开发"], + "answer": "B", + "analysis": "Python是解释型语言,支持多种编程范式" }}, {{ - "content": "第二道题", + "content": "以下哪些是Python的优点(多选)", + "type": "multiple", + "options": ["A. 语法简洁", "B. 库丰富", "C. 执行速度最快", "D. 易于学习"], + "answer": "ABD", + "analysis": "Python优点是语法简洁、库丰富、易学,但执行速度不是最快的" + }}, + {{ + "content": "Python是一种高级编程语言", "type": "judge", "options": [], "answer": "对", @@ -420,9 +483,10 @@ class LLMService: }} ] -重要提示: +**最后提醒**: - 请仔细查看 PDF 的每一页 -- 识别所有看起来像试题的内容 +- 仔细识别多选题(看题干、看答案格式) +- 单选和多选容易混淆,请特别注意区分 - 如果找不到明确的选项,可以根据上下文推断题目类型 - 题目内容中的换行请用\\n或空格替换,确保 JSON 格式正确 - **只返回一个 JSON 数组**,不要包含其他任何内容""" @@ -501,6 +565,11 @@ class LLMService: 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 print(f"[LLM Cleaned JSON] Length: {len(result)} chars", flush=True) print(f"[LLM Cleaned JSON] First 300 chars:\n{result[:300]}", flush=True) diff --git a/docker-compose-single.yml b/docker-compose-single.yml new file mode 100644 index 0000000..d9a36ff --- /dev/null +++ b/docker-compose-single.yml @@ -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: diff --git a/docs/CHINA_MIRROR_GUIDE.md b/docs/CHINA_MIRROR_GUIDE.md deleted file mode 100644 index 0c5cacb..0000000 --- a/docs/CHINA_MIRROR_GUIDE.md +++ /dev/null @@ -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` diff --git a/frontend/Dockerfile.china b/frontend/Dockerfile.china deleted file mode 100644 index 91700be..0000000 --- a/frontend/Dockerfile.china +++ /dev/null @@ -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"] diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index bf76721..ae13cd0 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -6,7 +6,7 @@ import toast from 'react-hot-toast' // Create axios instance const api = axios.create({ - baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8000', + baseURL: import.meta.env.VITE_API_URL || '/api', timeout: 30000, headers: { 'Content-Type': 'application/json'