mirror of
https://github.com/handsomezhuzhu/QQuiz.git
synced 2026-02-20 20:10:14 +00:00
单容器重构
This commit is contained in:
63
.dockerignore
Normal file
63
.dockerignore
Normal 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
|
||||||
@@ -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
51
Dockerfile
Normal 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"]
|
||||||
28
README.md
28
README.md
@@ -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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 方式二:本地运行
|
### 方式二:本地运行
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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.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."
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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": []
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
62
docker-compose-single.yml
Normal 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:
|
||||||
@@ -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`
|
|
||||||
@@ -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"]
|
|
||||||
@@ -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'
|
||||||
|
|||||||
Reference in New Issue
Block a user