diff --git a/.env.example b/.env.example index 70e2275..250549f 100644 --- a/.env.example +++ b/.env.example @@ -52,7 +52,3 @@ CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 # Upload Directory UPLOAD_DIR=./uploads - -# ESA Human Verification -VITE_ESA_PREFIX= -VITE_ESA_SCENE_ID= diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 57a2c32..e2687f0 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -1,4 +1,4 @@ -name: Build and Publish Docker Image +name: Build and Publish Docker Images on: push: @@ -18,6 +18,15 @@ jobs: permissions: contents: read packages: write + strategy: + matrix: + include: + - image_suffix: backend + context: ./backend + file: ./backend/Dockerfile + - image_suffix: frontend + context: ./web + file: ./web/Dockerfile steps: - name: Checkout repository @@ -37,7 +46,7 @@ jobs: id: meta uses: docker/metadata-action@v5 with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-${{ matrix.image_suffix }} tags: | type=ref,event=branch type=semver,pattern={{version}} @@ -45,16 +54,17 @@ jobs: type=raw,value=latest,enable={{is_default_branch}} - name: Build and push Docker image + id: build uses: docker/build-push-action@v5 with: - context: . - file: ./Dockerfile + context: ${{ matrix.context }} + file: ${{ matrix.file }} push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha - cache-to: type=gha,mode=max + cache-to: type=gha,mode=max,scope=${{ matrix.image_suffix }} platforms: linux/amd64,linux/arm64 - name: Image digest - run: echo "Image pushed with digest ${{ steps.build-and-push.outputs.digest }}" + run: echo "Image pushed with digest ${{ steps.build.outputs.digest }}" diff --git a/.gitignore b/.gitignore index 4f510a9..3d49bab 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,9 @@ yarn-error.log # Build frontend/build/ frontend/dist/ +.next/ +web/.next/ +web/out/ # Testing .coverage diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..6c99ff3 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,34 @@ +# Repository Guidelines + +## Project Structure & Module Organization +- `backend/`: FastAPI API. Keep HTTP entrypoints in `routers/`, reusable business logic in `services/`, database definitions in `models.py`, request/response schemas in `schemas.py`, and migrations in `alembic/`. +- `frontend/`: React 18 + Vite client. Put route screens in `src/pages/`, shared UI in `src/components/`, auth state in `src/context/`, API wrappers in `src/api/`, and helpers in `src/utils/`. +- `docs/` holds deployment and architecture notes, `scripts/run_local.sh` bootstraps local Linux/macOS development, `test_data/` contains sample question files, and `.github/workflows/docker-publish.yml` publishes container images. + +## Build, Test, and Development Commands +- `docker compose up -d --build`: start MySQL, backend on `:8000`, and frontend on `:3000`. +- `docker compose -f docker-compose-single.yml up -d --build`: start the single-container SQLite deployment. +- `cd backend && pip install -r requirements.txt && alembic upgrade head && uvicorn main:app --reload --host 0.0.0.0 --port 8000`: run the API locally. +- `cd frontend && npm install && npm run dev`: start the Vite dev server. +- `cd frontend && npm run build`: create a production frontend bundle. + +## Coding Style & Naming Conventions +- Python uses 4-space indentation, `snake_case` for modules/functions, and `PascalCase` for ORM or Pydantic classes. +- React files use `PascalCase.jsx` for pages/components and `camelCase` for state, helpers, and API wrappers. +- Keep route handlers thin: validation in schemas, orchestration in routers, reusable logic in `backend/services/`. +- No formatter or lint script is enforced today, so match surrounding style before making broad formatting changes. + +## Testing Guidelines +- The repository currently has no committed automated test suite or coverage gate. +- Before opening a PR, smoke-test auth, exam creation/upload, parsing progress, quiz playback, mistake review, and admin settings. +- Use `test_data/sample_questions*.txt` for parser and import checks. +- If you add tests, place backend tests under `backend/tests/test_*.py` and frontend tests under `frontend/src/__tests__/`. + +## Commit & Pull Request Guidelines +- Recent history favors short, focused subjects, often imperative and sometimes Chinese, such as `安全修复和管理员账号密码自定义`. +- Keep each commit scoped to one change. PRs should include a summary, affected areas, config or migration notes, linked issues, and UI screenshots or GIFs for frontend changes. + +## Security & Configuration Tips +- Copy `.env.example` to `.env`; never commit real API keys or passwords. +- `SECRET_KEY` must be at least 32 characters, and `ADMIN_PASSWORD` at least 12. +- Update `.env.example` and relevant docs whenever configuration keys or security-sensitive defaults change. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..edd3fe7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 handsomezhuzhu + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 76e5e56..8deef42 100644 --- a/README.md +++ b/README.md @@ -1,281 +1,186 @@ -# QQuiz - 智能刷题与题库管理平台 +# QQuiz -QQuiz 是一个支持 Docker/源码双模部署的智能刷题平台,核心功能包括多文件上传、自动去重、异步解析、断点续做和错题本管理。 +QQuiz 是一个面向题库管理与刷题训练的全栈应用,支持文档导入、异步解析、题目去重、断点续做、错题本与管理员配置。 -## 功能特性 +## 界面预览 -- 📚 **多文件上传与去重**: 支持向同一题库追加文档,自动识别并过滤重复题目 -- 🤖 **AI 智能解析**: 支持 Google Gemini (推荐) / OpenAI / Anthropic / Qwen 多种 AI 提供商 -- 📄 **原生 PDF 理解**: Gemini 支持直接处理 PDF(最多1000页),完整保留图片、表格、公式等内容 -- 🎓 **AI 参考答案**: 对于没有提供答案的题目,自动生成 AI 参考答案 -- 📊 **断点续做**: 自动记录刷题进度,随时继续 -- ❌ **错题本管理**: 自动收集错题,支持手动添加/移除 -- 🎯 **多题型支持**: 单选、多选、判断、简答 -- 🔐 **权限管理**: 管理员配置、用户隔离 -- 📱 **移动端优先**: 完美适配手机端 + + +## 核心能力 + +- 多文件导入与题目去重 +- 异步解析进度回传 +- 单选、多选、判断、简答题统一管理 +- 刷题进度保存与继续作答 +- 错题本与错题练习 +- 管理员用户管理与系统配置 +- 支持 Gemini / OpenAI / Anthropic / Qwen + +## 当前架构 + +- `backend/`:FastAPI + SQLAlchemy + Alembic +- `web/`:当前主前端,Next.js App Router + TypeScript + Tailwind CSS +- `frontend/`:保留中的 legacy Vite 前端,用于单容器兼容路径 + +说明: + +- 分离部署优先使用 `web/` +- 单容器镜像当前仍复用 `frontend/` 构建静态资源 ## 快速开始 -### 使用预构建镜像(最快) +### 1. 分离部署,推荐 -直接使用 GitHub 自动构建的镜像,无需等待本地构建: +前端运行在 `3000`,后端运行在 `8000`。 ```bash -# 1. 拉取最新镜像 -docker pull ghcr.io/handsomezhuzhu/qquiz:latest - -# 2. 配置环境变量 cp .env.example .env -# 编辑 .env,填入你的 API Key -# 3. 运行容器 -docker run -d \ - --name qquiz \ - -p 8000:8000 \ - -v $(pwd)/qquiz_data:/app/qquiz_data \ - --env-file .env \ - ghcr.io/handsomezhuzhu/qquiz:latest - -# 4. 访问应用: http://localhost:8000 +docker compose up -d --build ``` -**镜像说明:** -- **镜像地址**: `ghcr.io/handsomezhuzhu/qquiz:latest` -- **构建来源**: GitHub Actions 自动构建,每次 push 到 main 分支自动更新 -- **架构支持**: linux/amd64, linux/arm64(支持树莓派、Apple Silicon 等) -- **大小**: 约 400-500MB(包含前后端完整运行环境) -- **标签说明**: - - `latest`: 最新主分支版本 - - `v1.0.0`: 特定版本号(如果有 tag) +访问地址: -**数据持久化:** 使用 `-v` 参数挂载 `qquiz_data` 目录,包含 SQLite 数据库和上传文件,确保数据不会丢失。 +- 前端:`http://localhost:3000` +- 后端:`http://localhost:8000` +- 后端健康检查:`http://localhost:8000/health` -### 单容器部署(自行构建) - -从源码构建,一个容器包含前后端和 SQLite 数据库: +### 2. 分离部署 + MySQL ```bash -# 1. 配置环境变量(必须提供强密码和密钥) cp .env.example .env -# 编辑 .env,填入至少 32 位的 SECRET_KEY 和至少 12 位的 ADMIN_PASSWORD(建议使用随机生成值) -# 2. 启动服务(未设置强密码/密钥会直接报错终止) -SECRET_KEY=$(openssl rand -base64 48) \ -ADMIN_PASSWORD=$(openssl rand -base64 16) \ -docker-compose -f docker-compose-single.yml up -d - -# 3. 访问应用: http://localhost:8000 -# API 文档: http://localhost:8000/docs +docker compose -f docker-compose.yml -f docker-compose.mysql.yml up -d --build ``` -### 传统部署(3 个容器) +### 3. 单容器部署 -前后端分离 + MySQL: +单容器模式会把前端静态资源集成到后端服务中,统一通过 `8000` 提供。 ```bash -# 启动服务(建议直接在命令行生成强密钥和管理员密码) -SECRET_KEY=$(openssl rand -base64 48) \ -ADMIN_PASSWORD=$(openssl rand -base64 16) \ -docker-compose up -d +cp .env.example .env -# 前端: http://localhost:3000 -# 后端: http://localhost:8000 +docker compose -f docker-compose-single.yml up -d --build ``` -### 方式二:本地运行 +访问地址: + +- 应用:`http://localhost:8000` +- API 文档:`http://localhost:8000/docs` + +## 本地开发 + +### 后端 + +```bash +cd backend +pip install -r requirements.txt +alembic upgrade head +uvicorn main:app --reload --host 0.0.0.0 --port 8000 +``` + +### 新前端 + +```bash +cd web +npm install +npm run dev +``` + +### 旧前端 + +仅在兼容或迁移场景下需要: + +```bash +cd frontend +npm install +npm run dev +``` + +## 运行要求 -#### 前置要求 - Python 3.11+ - Node.js 18+ -- MySQL 8.0+ 或 Docker (用于运行 MySQL) +- Docker / Docker Compose -**Linux/macOS 用户:** -```bash -# 1. 配置环境变量 -cp .env.example .env -# 编辑 .env,修改 DATABASE_URL 为本地数据库地址 +## 关键环境变量 -# 2. 启动 MySQL -# macOS: brew services start mysql -# Linux: sudo systemctl start mysql +| 变量 | 说明 | +| --- | --- | +| `DATABASE_URL` | 数据库连接字符串 | +| `SECRET_KEY` | JWT 密钥,至少 32 位 | +| `ADMIN_PASSWORD` | 默认管理员密码,至少 12 位 | +| `AI_PROVIDER` | `gemini` / `openai` / `anthropic` / `qwen` | +| `GEMINI_API_KEY` | Gemini API Key | +| `OPENAI_API_KEY` | OpenAI API Key | +| `OPENAI_BASE_URL` | OpenAI 或兼容网关地址 | +| `ALLOW_REGISTRATION` | 是否允许注册 | +| `MAX_UPLOAD_SIZE_MB` | 单次上传大小限制 | +| `MAX_DAILY_UPLOADS` | 每日上传次数限制 | -# 3. 运行启动脚本 -chmod +x scripts/run_local.sh -./scripts/run_local.sh -``` +更多示例见 [`.env.example`](.env.example)。 -**MySQL 安装指南:** 详见 [docs/MYSQL_SETUP.md](docs/MYSQL_SETUP.md) +## 目录结构 -## GitHub Actions 自动构建设置 - -如果你 fork 了本项目并想启用自动构建 Docker 镜像功能: - -1. **启用 GitHub Actions**: - - 进入你的仓库 Settings → Actions → General - - 确保 "Actions permissions" 设置为 "Allow all actions" - -2. **启用 Packages 写入权限**: - - Settings → Actions → General - - 找到 "Workflow permissions" - - 选择 "Read and write permissions" - - 勾选 "Allow GitHub Actions to create and approve pull requests" - -3. **触发构建**: - - 推送代码到 `main` 分支会自动触发构建 - - 或者创建 tag(如 `v1.0.0`)会构建带版本号的镜像 - - 也可以在 Actions 页面手动触发 "Build and Publish Docker Image" workflow - -4. **查看构建的镜像**: - - 构建完成后,镜像会自动发布到 `ghcr.io/handsomezhuzhu/qquiz` - - 在仓库主页右侧 "Packages" 可以看到已发布的镜像 - - 镜像默认是私有的,如需公开:进入 Package 页面 → Package settings → Change visibility - -**镜像地址:** -```bash -# 拉取最新镜像 -docker pull ghcr.io/handsomezhuzhu/qquiz:latest -``` - -## 默认账户 - -**管理员账户:** -- 用户名: `admin` -- 密码: 取自环境变量 `ADMIN_PASSWORD`(必须至少 12 位,建议随机生成) - -⚠️ **重要**: 在部署前就必须设置强管理员密码;如果需要轮换密码,请更新环境变量后重启服务。 - -## 项目结构 - -``` +```text QQuiz/ -├── backend/ # FastAPI 后端 -│ ├── alembic/ # 数据库迁移 -│ ├── routers/ # API 路由 -│ ├── services/ # 业务逻辑 -│ ├── models.py # 数据模型 -│ ├── database.py # 数据库配置 -│ ├── main.py # 应用入口 -│ └── requirements.txt # Python 依赖 -├── frontend/ # React 前端 -│ ├── src/ -│ │ ├── api/ # API 客户端 -│ │ ├── pages/ # 页面组件 -│ │ ├── components/ # 通用组件 -│ │ └── App.jsx # 应用入口 -│ ├── package.json # Node 依赖 -│ └── vite.config.js # Vite 配置 -├── scripts/ # 部署和启动脚本 -│ └── run_local.sh # Linux/macOS 启动脚本 -├── docs/ # 文档目录 -│ ├── MYSQL_SETUP.md # MySQL 安装配置指南 -│ └── PROJECT_STRUCTURE.md # 项目架构详解 -├── test_data/ # 测试数据 -│ └── sample_questions.txt # 示例题目 -├── docker-compose.yml # Docker 编排 -├── .env.example # 环境变量模板 -└── README.md # 项目说明 +├─ backend/ FastAPI 后端 +│ ├─ alembic/ 数据库迁移 +│ ├─ routers/ API 路由 +│ ├─ services/ 业务服务 +│ ├─ models.py ORM 模型 +│ ├─ schemas.py Pydantic Schema +│ └─ main.py 应用入口 +├─ web/ Next.js 前端 +│ ├─ src/app/ App Router 页面与 API Route +│ ├─ src/components/ UI 与业务组件 +│ └─ src/lib/ 前端 API、鉴权、工具 +├─ frontend/ Legacy Vite 前端 +├─ docs/ 部署、审计与截图 +├─ test_data/ 示例题库文件 +├─ docker-compose.yml 前后端分离部署 +├─ docker-compose.mysql.yml MySQL overlay +├─ docker-compose-single.yml 单容器部署 +└─ Dockerfile 单容器镜像构建 ``` -## 核心业务流程 - -### 1. 创建题库 -用户首次上传文档时,创建新的 Exam (题库容器) - -### 2. 追加文档 -在已有题库详情页点击 "添加题目文档",上传新文件 - -### 3. 去重逻辑 -- 对题目内容进行标准化处理 (去空格、标点、转小写) -- 计算 MD5 Hash -- 仅在当前题库范围内查询去重 -- 仅插入 Hash 不存在的题目 - -### 4. 异步处理 -- 后台任务处理 AI 解析 -- 状态: `pending` → `processing` → `ready` / `failed` -- 前端轮询状态,自动刷新 - -### 5. 刷题体验 -- 基于 `current_index` 实现断点续做 -- 错题自动加入错题本 -- 简答题 AI 评分 - -## 环境变量说明 - -| 变量 | 说明 | 默认值 | -|------|------|--------| -| `DATABASE_URL` | 数据库连接字符串 | - | -| `SECRET_KEY` | JWT 密钥(必须至少 32 位随机字符串) | - | -| `ADMIN_PASSWORD` | 默认管理员密码(必须至少 12 位,建议随机生成) | - | -| `AI_PROVIDER` | AI 提供商 (gemini/openai/anthropic/qwen) | gemini | -| `GEMINI_API_KEY` | Google Gemini API 密钥 | - | -| `GEMINI_BASE_URL` | Gemini API 地址(可选,支持代理) | https://generativelanguage.googleapis.com | -| `GEMINI_MODEL` | Gemini 模型 | gemini-2.0-flash-exp | -| `OPENAI_API_KEY` | OpenAI API 密钥 | - | -| `OPENAI_BASE_URL` | OpenAI API 地址 | https://api.openai.com/v1 | -| `OPENAI_MODEL` | OpenAI 模型 | gpt-4o-mini | -| `ANTHROPIC_API_KEY` | Anthropic API 密钥 | - | -| `ANTHROPIC_MODEL` | Anthropic 模型 | claude-3-haiku-20240307 | -| `QWEN_API_KEY` | 通义千问 API 密钥 | - | -| `QWEN_BASE_URL` | 通义千问 API 地址 | https://dashscope.aliyuncs.com/compatible-mode/v1 | -| `QWEN_MODEL` | 通义千问模型 | qwen-plus | -| `ALLOW_REGISTRATION` | 是否允许注册 | true | -| `MAX_UPLOAD_SIZE_MB` | 最大上传文件大小 (MB) | 10 | -| `MAX_DAILY_UPLOADS` | 每日上传次数限制 | 20 | - -### AI 提供商对比 - -| 提供商 | PDF 原生支持 | 文本解析 | 推荐度 | 说明 | -|--------|--------------|----------|--------|------| -| **Google Gemini** | ✅ | ✅ | ⭐⭐⭐⭐⭐ | 支持原生 PDF(最多1000页),保留图片、表格、公式 | -| OpenAI | ❌ | ✅ | ⭐⭐⭐⭐ | 仅文本提取,PDF 会丢失格式和图片 | -| Anthropic | ❌ | ✅ | ⭐⭐⭐⭐ | 仅文本提取,PDF 会丢失格式和图片 | -| Qwen (通义千问) | ❌ | ✅ | ⭐⭐⭐ | 仅文本提取,PDF 会丢失格式和图片 | - -**推荐使用 Gemini**:如果你的题库包含 PDF 文件(特别是含有图片、公式、表格的学科试卷),强烈推荐使用 Gemini。 - -### 如何获取 API Key - -- **Google Gemini**: https://aistudio.google.com/apikey (免费额度充足) -- **OpenAI**: https://platform.openai.com/api-keys -- **Anthropic**: https://console.anthropic.com/settings/keys -- **Qwen (通义千问)**: https://dashscope.console.aliyun.com/apiKey - -### AI 配置方式 - -QQuiz 支持两种配置方式: - -1. **环境变量配置** (`.env` 文件):适合 Docker 部署和开发环境 -2. **数据库配置** (管理员后台):适合生产环境,支持在线修改,无需重启服务 - -**推荐流程**:首次部署使用环境变量,部署成功后通过管理员后台修改配置。 - -**Gemini 自定义代理**: 如果需要使用 Key 轮训服务或代理,可以在管理员后台配置 `GEMINI_BASE_URL`,支持自定义 Gemini API 地址。 - ## 技术栈 -**后端:** -- FastAPI - 现代化 Python Web 框架 -- SQLAlchemy 2.0 - 异步 ORM -- Alembic - 数据库迁移 -- MySQL 8.0 - 数据库 -- aiomysql - MySQL 异步驱动 -- Pydantic - 数据验证 +### 后端 -**前端:** -- React 18 - UI 框架 -- Vite - 构建工具 -- Tailwind CSS - 样式框架 -- React Router - 路由 -- Axios - HTTP 客户端 +- FastAPI +- SQLAlchemy 2.x +- Alembic +- SQLite / MySQL +- httpx +- OpenAI / Anthropic SDK -## 开发进度 +### 前端 -- [x] **Step 1**: Foundation & Models ✅ -- [ ] **Step 2**: Backend Core Logic -- [ ] **Step 3**: Frontend Config & API -- [ ] **Step 4**: Frontend Complex UI +- Next.js 14 App Router +- React 18 +- TypeScript +- Tailwind CSS +- TanStack Query +- Radix UI / shadcn 风格组件 -## License +## 构建检查 -MIT +常用检查命令: + +```bash +cd web && npm run build +docker compose build backend frontend +``` + +仓库当前没有完整自动化测试套件,提交前至少建议手动验证: + +- 登录 / 退出 +- 创建题库 / 上传文档 / 查看解析进度 +- 刷题与错题加入 +- 管理员用户管理与系统设置 +- 大数据量列表分页 + +## 开源协议 + +本项目采用 [MIT License](LICENSE)。 diff --git a/backend/Dockerfile b/backend/Dockerfile index 01cb0d5..27a876e 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,24 +1,35 @@ +FROM python:3.11-slim AS builder + +WORKDIR /app + +# Install build dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + libmagic1 \ + && rm -rf /var/lib/apt/lists/* + +RUN python -m venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + FROM python:3.11-slim WORKDIR /app -# Install system dependencies (gcc for compiling Python packages) RUN apt-get update && apt-get install -y --no-install-recommends \ - gcc \ + libmagic1 \ && rm -rf /var/lib/apt/lists/* -# Copy requirements and install Python dependencies -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt +ENV PATH="/opt/venv/bin:$PATH" +ENV PYTHONUNBUFFERED=1 -# Copy application code +COPY --from=builder /opt/venv /opt/venv COPY . . -# Create uploads directory RUN mkdir -p uploads -# Expose port EXPOSE 8000 -# Run database migrations and start server CMD alembic upgrade head && uvicorn main:app --host 0.0.0.0 --port 8000 diff --git a/backend/routers/admin.py b/backend/routers/admin.py index 2bf2b82..81e7a53 100644 --- a/backend/routers/admin.py +++ b/backend/routers/admin.py @@ -2,7 +2,7 @@ Admin Router - 完备的管理员功能模块 参考 OpenWebUI 设计 """ -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, status, Query from fastapi.responses import StreamingResponse, FileResponse from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func, and_, or_, desc @@ -16,7 +16,8 @@ from database import get_db, engine from models import User, SystemConfig, Exam, Question, UserMistake, ExamStatus from schemas import ( SystemConfigUpdate, SystemConfigResponse, - UserResponse, UserCreate, UserUpdate, UserListResponse + UserResponse, UserCreate, UserUpdate, UserListResponse, + UserPasswordResetRequest, AdminUserSummary ) from services.auth_service import get_current_admin_user @@ -36,6 +37,11 @@ async def get_default_admin_id(db: AsyncSession) -> Optional[int]: return None +async def get_admin_count(db: AsyncSession) -> int: + result = await db.execute(select(func.count(User.id)).where(User.is_admin == True)) + return result.scalar() or 0 + + @router.get("/config", response_model=SystemConfigResponse) async def get_system_config( current_admin: User = Depends(get_current_admin_user), @@ -84,6 +90,9 @@ async def update_system_config( update_data = config_update.dict(exclude_unset=True) for key, value in update_data.items(): + if key.endswith("_api_key") and isinstance(value, str) and "..." in value: + continue + result = await db.execute( select(SystemConfig).where(SystemConfig.key == key) ) @@ -108,9 +117,9 @@ async def update_system_config( @router.get("/users", response_model=UserListResponse) async def get_users( - skip: int = 0, - limit: int = 50, - search: Optional[str] = None, + skip: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=100), + search: Optional[str] = Query(None, min_length=1, max_length=50), current_admin: User = Depends(get_current_admin_user), db: AsyncSession = Depends(get_db) ): @@ -121,10 +130,11 @@ async def get_users( - search: 搜索关键词(用户名) """ query = select(User) + normalized_search = search.strip() if search else None # 搜索过滤 - if search: - query = query.where(User.username.ilike(f"%{search}%")) + if normalized_search: + query = query.where(User.username.ilike(f"%{normalized_search}%")) # 统计总数 count_query = select(func.count()).select_from(query.subquery()) @@ -136,34 +146,41 @@ async def get_users( result = await db.execute(query) users = result.scalars().all() - # 为每个用户添加统计信息 + user_ids = [user.id for user in users] + exam_count_map = {} + mistake_count_map = {} + + if user_ids: + exam_count_result = await db.execute( + select(Exam.user_id, func.count(Exam.id)) + .where(Exam.user_id.in_(user_ids)) + .group_by(Exam.user_id) + ) + exam_count_map = {user_id: count for user_id, count in exam_count_result.all()} + + mistake_count_result = await db.execute( + select(UserMistake.user_id, func.count(UserMistake.id)) + .where(UserMistake.user_id.in_(user_ids)) + .group_by(UserMistake.user_id) + ) + mistake_count_map = { + user_id: count for user_id, count in mistake_count_result.all() + } + user_list = [] for user in users: - # 统计用户的题库数 - exam_count_query = select(func.count(Exam.id)).where(Exam.user_id == user.id) - exam_result = await db.execute(exam_count_query) - exam_count = exam_result.scalar() + user_list.append( + AdminUserSummary( + id=user.id, + username=user.username, + is_admin=user.is_admin, + created_at=user.created_at, + exam_count=exam_count_map.get(user.id, 0), + mistake_count=mistake_count_map.get(user.id, 0) + ) + ) - # 统计用户的错题数 - mistake_count_query = select(func.count(UserMistake.id)).where(UserMistake.user_id == user.id) - mistake_result = await db.execute(mistake_count_query) - mistake_count = mistake_result.scalar() - - user_list.append({ - "id": user.id, - "username": user.username, - "is_admin": user.is_admin, - "created_at": user.created_at, - "exam_count": exam_count, - "mistake_count": mistake_count - }) - - return { - "users": user_list, - "total": total, - "skip": skip, - "limit": limit - } + return UserListResponse(users=user_list, total=total, skip=skip, limit=limit) @router.post("/users", response_model=UserResponse) @@ -215,7 +232,19 @@ async def update_user( detail="User not found" ) + if user_data.username and user_data.username != user.username: + existing_user_result = await db.execute( + select(User).where(User.username == user_data.username) + ) + existing_user = existing_user_result.scalar_one_or_none() + if existing_user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Username already exists" + ) + protected_admin_id = await get_default_admin_id(db) + admin_count = await get_admin_count(db) # 不允许修改默认管理员的管理员状态 if protected_admin_id and user.id == protected_admin_id and user_data.is_admin is not None: @@ -224,6 +253,12 @@ async def update_user( detail="Cannot modify default admin user's admin status" ) + if user.is_admin and user_data.is_admin is False and admin_count <= 1: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="At least one admin user must remain" + ) + # 更新字段 update_data = user_data.dict(exclude_unset=True) if "password" in update_data: @@ -238,6 +273,29 @@ async def update_user( return user +@router.post("/users/{user_id}/reset-password") +async def reset_user_password( + user_id: int, + payload: UserPasswordResetRequest, + current_admin: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_db) +): + """重置用户密码(仅管理员)""" + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found" + ) + + user.hashed_password = pwd_context.hash(payload.new_password) + await db.commit() + + return {"message": "Password reset successfully"} + + @router.delete("/users/{user_id}") async def delete_user( user_id: int, @@ -255,6 +313,7 @@ async def delete_user( ) protected_admin_id = await get_default_admin_id(db) + admin_count = await get_admin_count(db) # 不允许删除默认管理员 if protected_admin_id and user.id == protected_admin_id: @@ -270,6 +329,12 @@ async def delete_user( detail="Cannot delete yourself" ) + if user.is_admin and admin_count <= 1: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="At least one admin user must remain" + ) + await db.delete(user) await db.commit() diff --git a/backend/routers/exam.py b/backend/routers/exam.py index 9223841..b0dac2a 100644 --- a/backend/routers/exam.py +++ b/backend/routers/exam.py @@ -4,7 +4,7 @@ Exam Router - Handles exam creation, file upload, and deduplication from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form, BackgroundTasks, Request from fastapi.responses import StreamingResponse from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, func, and_ +from sqlalchemy import select, func, and_, case from typing import List, Optional from datetime import datetime, timedelta import os @@ -17,7 +17,7 @@ from database import get_db from models import User, Exam, Question, ExamStatus, SystemConfig from schemas import ( ExamCreate, ExamResponse, ExamListResponse, - ExamUploadResponse, ParseResult, QuizProgressUpdate + ExamUploadResponse, ParseResult, QuizProgressUpdate, ExamSummaryResponse ) from services.auth_service import get_current_user from services.document_parser import document_parser @@ -684,6 +684,57 @@ async def get_user_exams( return ExamListResponse(exams=exams, total=total) +@router.get("/summary", response_model=ExamSummaryResponse) +async def get_exam_summary( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Get aggregated exam statistics for current user.""" + + summary_query = select( + func.count(Exam.id), + func.coalesce(func.sum(Exam.total_questions), 0), + func.coalesce(func.sum(Exam.current_index), 0), + func.coalesce( + func.sum( + case((Exam.status == ExamStatus.PROCESSING, 1), else_=0) + ), + 0 + ), + func.coalesce( + func.sum( + case((Exam.status == ExamStatus.READY, 1), else_=0) + ), + 0 + ), + func.coalesce( + func.sum( + case((Exam.status == ExamStatus.FAILED, 1), else_=0) + ), + 0 + ) + ).where(Exam.user_id == current_user.id) + + result = await db.execute(summary_query) + ( + total_exams, + total_questions, + completed_questions, + processing_exams, + ready_exams, + failed_exams + ) = result.one() + + return ExamSummaryResponse( + total_exams=total_exams or 0, + total_questions=total_questions or 0, + completed_questions=completed_questions or 0, + processing_exams=processing_exams or 0, + ready_exams=ready_exams or 0, + failed_exams=failed_exams or 0 + ) + + @router.get("/{exam_id}", response_model=ExamResponse) async def get_exam_detail( exam_id: int, diff --git a/backend/schemas.py b/backend/schemas.py index 16f8f74..e4d3abb 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -38,6 +38,11 @@ class UserLogin(BaseModel): password: str +class PasswordChangeRequest(BaseModel): + old_password: str = Field(..., min_length=1) + new_password: str = Field(..., min_length=6) + + class Token(BaseModel): access_token: str token_type: str = "bearer" @@ -53,14 +58,23 @@ class UserResponse(BaseModel): from_attributes = True +class AdminUserSummary(UserResponse): + exam_count: int = 0 + mistake_count: int = 0 + + class UserListResponse(BaseModel): """用户列表响应(包含分页信息)""" - users: List[dict] # 包含额外统计信息的用户列表 + users: List[AdminUserSummary] total: int skip: int limit: int +class UserPasswordResetRequest(BaseModel): + new_password: str = Field(..., min_length=6) + + # ============ System Config Schemas ============ class SystemConfigUpdate(BaseModel): allow_registration: Optional[bool] = None @@ -124,6 +138,15 @@ class ExamListResponse(BaseModel): total: int +class ExamSummaryResponse(BaseModel): + total_exams: int + total_questions: int + completed_questions: int + processing_exams: int + ready_exams: int + failed_exams: int + + class ExamUploadResponse(BaseModel): exam_id: int title: str @@ -140,19 +163,31 @@ class ParseResult(BaseModel): # ============ Question Schemas ============ -class QuestionBase(BaseModel): +class QuestionPublicBase(BaseModel): content: str type: QuestionType options: Optional[List[str]] = None - answer: str analysis: Optional[str] = None +class QuestionBase(QuestionPublicBase): + answer: str + + class QuestionCreate(QuestionBase): exam_id: int -class QuestionResponse(QuestionBase): +class QuestionResponse(QuestionPublicBase): + id: int + exam_id: int + created_at: datetime + + class Config: + from_attributes = True + + +class QuestionWithAnswerResponse(QuestionBase): id: int exam_id: int created_at: datetime @@ -194,7 +229,7 @@ class MistakeResponse(BaseModel): id: int user_id: int question_id: int - question: QuestionResponse + question: QuestionWithAnswerResponse created_at: datetime class Config: diff --git a/backend/services/llm_service.py b/backend/services/llm_service.py index 5554e3d..2411e98 100644 --- a/backend/services/llm_service.py +++ b/backend/services/llm_service.py @@ -15,6 +15,25 @@ from utils import calculate_content_hash class LLMService: """Service for interacting with various LLM providers""" + @staticmethod + def _normalize_openai_base_url(base_url: Optional[str], default: str) -> str: + normalized = (base_url or default).rstrip("/") + if normalized.endswith("/v1"): + return normalized + + if normalized.count("/") <= 2: + return f"{normalized}/v1" + + return normalized + + @staticmethod + def _openai_compat_headers() -> Dict[str, str]: + """ + Some OpenAI-compatible gateways block the default OpenAI SDK user agent. + Use a neutral UA so requests behave like a generic HTTP client. + """ + return {"User-Agent": "QQuiz/1.0"} + def __init__(self, config: Optional[Dict[str, str]] = None): """ Initialize LLM Service with optional configuration. @@ -28,7 +47,10 @@ class LLMService: if self.provider == "openai": api_key = (config or {}).get("openai_api_key") or os.getenv("OPENAI_API_KEY") - base_url = (config or {}).get("openai_base_url") or os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1") + base_url = self._normalize_openai_base_url( + (config or {}).get("openai_base_url") or os.getenv("OPENAI_BASE_URL"), + "https://api.openai.com/v1" + ) self.model = (config or {}).get("openai_model") or os.getenv("OPENAI_MODEL", "gpt-4o-mini") if not api_key: @@ -37,6 +59,7 @@ class LLMService: self.client = AsyncOpenAI( api_key=api_key, base_url=base_url, + default_headers=self._openai_compat_headers(), timeout=120.0, # 增加超时时间到 120 秒 max_retries=3 # 自动重试 3 次 ) @@ -69,6 +92,7 @@ class LLMService: self.client = AsyncOpenAI( api_key=api_key, base_url=base_url, + default_headers=self._openai_compat_headers(), timeout=120.0, # 增加超时时间到 120 秒 max_retries=3 # 自动重试 3 次 ) diff --git a/docker-compose-single.yml b/docker-compose-single.yml index 17a096d..799ee1a 100644 --- a/docker-compose-single.yml +++ b/docker-compose-single.yml @@ -16,6 +16,7 @@ services: environment: # 数据库配置(SQLite 默认,使用持久化卷) - DATABASE_URL=sqlite+aiosqlite:////app/data/qquiz.db + - UPLOAD_DIR=/app/uploads volumes: # 持久化数据卷 @@ -25,7 +26,7 @@ services: restart: unless-stopped healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + test: ["CMD", "python", "-c", "import sys, urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=5); sys.exit(0)"] interval: 30s timeout: 10s retries: 3 diff --git a/docker-compose.mysql.yml b/docker-compose.mysql.yml new file mode 100644 index 0000000..08ebb43 --- /dev/null +++ b/docker-compose.mysql.yml @@ -0,0 +1,29 @@ +services: + mysql: + image: mysql:8.0 + container_name: qquiz_mysql + environment: + MYSQL_ROOT_PASSWORD: root_password + MYSQL_DATABASE: qquiz_db + MYSQL_USER: qquiz + MYSQL_PASSWORD: qquiz_password + volumes: + - mysql_data:/var/lib/mysql + ports: + - "3306:3306" + command: --default-authentication-plugin=mysql_native_password --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "qquiz", "-pqquiz_password"] + interval: 10s + timeout: 5s + retries: 5 + + backend: + environment: + - DATABASE_URL=mysql+aiomysql://qquiz:qquiz_password@mysql:3306/qquiz_db + depends_on: + mysql: + condition: service_healthy + +volumes: + mysql_data: diff --git a/docker-compose.yml b/docker-compose.yml index 535a97e..c21d5c2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,61 +1,47 @@ services: - mysql: - image: mysql:8.0 - container_name: qquiz_mysql - environment: - MYSQL_ROOT_PASSWORD: root_password - MYSQL_DATABASE: qquiz_db - MYSQL_USER: qquiz - MYSQL_PASSWORD: qquiz_password - volumes: - - mysql_data:/var/lib/mysql - ports: - - "3306:3306" - command: --default-authentication-plugin=mysql_native_password --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci - healthcheck: - test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "qquiz", "-pqquiz_password"] - interval: 10s - timeout: 5s - retries: 5 - backend: build: context: ./backend dockerfile: Dockerfile container_name: qquiz_backend environment: - - DATABASE_URL=mysql+aiomysql://qquiz:qquiz_password@mysql:3306/qquiz_db + - DATABASE_URL=sqlite+aiosqlite:////app/data/qquiz.db - SECRET_KEY=${SECRET_KEY:?Set SECRET_KEY to a random string of at least 32 characters} - ADMIN_PASSWORD=${ADMIN_PASSWORD:?Set ADMIN_PASSWORD to a strong password of at least 12 characters} + - UPLOAD_DIR=/app/uploads env_file: - .env volumes: - - ./backend:/app + - sqlite_data:/app/data - upload_files:/app/uploads ports: - "8000:8000" - depends_on: - mysql: - condition: service_healthy - command: uvicorn main:app --host 0.0.0.0 --port 8000 --reload + healthcheck: + test: ["CMD", "python", "-c", "import sys, urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=5); sys.exit(0)"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 40s frontend: build: - context: ./frontend + context: ./web dockerfile: Dockerfile container_name: qquiz_frontend - volumes: - - ./frontend:/app - - /app/node_modules ports: - "3000:3000" environment: - - VITE_API_URL=/api - - REACT_APP_API_URL=http://backend:8000 + - API_BASE_URL=http://backend:8000 depends_on: - - backend - command: npm start + backend: + condition: service_started + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/login').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 40s volumes: - mysql_data: + sqlite_data: upload_files: diff --git a/docs/PLAN.md b/docs/PLAN.md new file mode 100644 index 0000000..3c11c36 --- /dev/null +++ b/docs/PLAN.md @@ -0,0 +1,86 @@ +# QQuiz Execution Plan + +更新时间:2026-04-17 + +## 目标 + +把当前项目推进到可以持续开发和稳定验收的状态,重点落实: + +1. 默认 SQLite,兼容 MySQL +2. 前端界面简洁、可直接操作、少说明文字 +3. 用户管理达到可上市产品的基础要求 +4. push 后 GitHub 自动构建镜像 +5. 逐步完成旧 Vite 前端到新 Next 前端的替换 + +## 已完成的代码级工作 + +- 默认 Docker 拓扑已切到 SQLite +- MySQL 兼容拓扑已拆到 `docker-compose.mysql.yml` +- 新前端容器已接入并替换 Docker 默认前端 +- 管理员用户管理已经接入真实接口 +- 管理员设置页已经接入真实配置接口 +- 侧边栏选中态 bug 已修复 +- 新前端色彩已收敛为更简洁的产品风格 + +## 当前代码扫描后的主要问题 + +### 后端 + +1. 数据库迁移体系仍然不完整 +2. `LLMService` 仍存在启动期副作用 +3. 文档解析任务仍依赖进程内后台任务 +4. 题库导入并发与去重约束还没彻底补完 +5. 管理模块还缺用户状态、审计、批量能力 + +### 前端 + +1. Exam detail / Question / Mistake / Quiz 仍是占位或半占位页面 +2. 部分页面仍有旧迁移骨架内容,需要继续清理 +3. 确认类交互还没统一替换为更正式的对话框方案 +4. 视觉层还需要统一列表、表单、状态、分页组件 + +### 部署与文档 + +1. Compose 仍缺 dev/prod 分离 +2. 文档体系仍需把运行方式统一到 SQLite 默认 + MySQL 兼容 +3. CI 只有主干镜像构建,还缺 PR 验证与 smoke test + +## 后续执行顺序 + +### 第一阶段:后端稳定性 + +1. Alembic 基线迁移 +2. 去掉 `create_all` 正式职责 +3. 去掉 LLM import side effect +4. 统一事务边界 +5. 补用户状态字段与审计日志模型 + +### 第二阶段:前端业务页 + +1. 接通 `exams/[examId]` +2. 接通 `questions` +3. 接通 `mistakes` +4. 接通 `quiz/[examId]` +5. 接通 `mistake-quiz` + +### 第三阶段:用户管理产品化 + +1. 用户状态管理 +2. 审计日志 +3. 批量操作 +4. 更完整的密码与安全策略 + +### 第四阶段:工程化 + +1. Compose dev/prod 分离 +2. PR workflow +3. SQLite/MySQL 双栈 smoke +4. 文档统一 + +## 前端视觉要求 + +1. 主色:深蓝,作为动作与选中态 +2. 背景:浅灰蓝,不用大面积高饱和装饰 +3. 卡片:白底、细边框、轻阴影 +4. 状态色:成功绿、警告橙、错误红 +5. 页面信息结构:标题、数据、动作优先,减少解释文字 diff --git a/docs/TASKS.md b/docs/TASKS.md new file mode 100644 index 0000000..bbd5a13 --- /dev/null +++ b/docs/TASKS.md @@ -0,0 +1,92 @@ +# QQuiz Task Checklist + +更新时间:2026-04-17 + +## P0 运行基线 + +- [x] 默认 Docker 拓扑切回 SQLite +- [x] 保留 MySQL 兼容 Compose 覆盖文件 +- [x] 前后端容器可启动并完成最小探活 +- [x] GitHub Actions 改成 push 后自动构建 backend/frontend 镜像 +- [ ] 补开发/生产分离 Compose +- [ ] 补 PR 级别 build/smoke workflow +- [ ] 清理根目录 Docker 文档漂移 + +## P1 后端稳定性 + +- [x] 管理员配置接口忽略打码后的密钥回写 +- [x] 用户列表返回改为强类型 +- [x] 用户列表统计去掉 N+1 查询 +- [x] 最后一个管理员保护 +- [x] 管理员密码重置接口 +- [ ] 去掉启动期 `create_all` 作为正式迁移方式 +- [ ] 建 Alembic 初始迁移 +- [ ] 去掉 `LLMService` import side effect +- [ ] 收敛事务边界 +- [ ] 修 ingestion 并发与唯一约束 +- [ ] 规范健康检查和错误模型 + +## P2 用户管理 + +- [x] 用户搜索 +- [x] 创建用户 +- [x] 编辑用户 +- [x] 重置密码 +- [x] 删除用户 +- [ ] 用户状态字段(启用/禁用/锁定) +- [ ] 审计日志 +- [ ] 批量操作 +- [ ] 密码强度与重置流程优化 +- [ ] 默认管理员保护策略文档化 + +## P3 新前端基础层 + +- [x] Next.js App Router 骨架 +- [x] BFF 登录/登出/`/me` 代理 +- [x] 同源 API 代理 +- [x] SSE 代理入口 +- [x] 移除旧前端 ESA 人机验证 +- [ ] 中间件与服务端守卫完善 +- [ ] 错误页/空状态统一 +- [ ] URL 状态策略统一 + +## P4 页面迁移 + +### 已接入真实数据 + +- [x] Dashboard +- [x] Exams list +- [x] Exam detail +- [x] Questions list +- [x] Mistakes list +- [x] Quiz player +- [x] Mistake quiz +- [x] Admin user management +- [x] Admin settings + +### 待继续 + +- [ ] 上传/进度/失败重试链路 + +## P5 前端视觉与交互 + +- [x] 侧边栏选中态修复 +- [x] 新前端配色收敛为更简洁的产品风格 +- [x] 去掉大段迁移说明文案 +- [ ] 统一表格、表单、按钮、状态徽标 +- [ ] 清理页面中的占位内容 +- [ ] 替换 `window.confirm` 为统一对话框 +- [ ] 移动端布局细化 + +## P6 测试与验收 + +- [x] 旧前端构建通过 +- [x] 新前端构建通过 +- [x] Docker 最小登录链路验证 +- [x] 管理员配置、用户管理、上传解析、题目、错题、刷题链路验证 +- [x] 管理员与普通用户登录验证 +- [x] PowerShell smoke 脚本固化全流程验证 +- [ ] 后端集成测试 +- [ ] 前端 E2E 烟测 +- [ ] SQLite / MySQL 双栈验证 +- [ ] 用户管理回归用例 diff --git a/docs/audit/architecture.md b/docs/audit/architecture.md new file mode 100644 index 0000000..5abbf18 --- /dev/null +++ b/docs/audit/architecture.md @@ -0,0 +1,70 @@ +# QQuiz Architecture Audit + +## Scope + +This document records the current system shape and the approved target +direction for the ongoing refactor. + +Audit date: 2026-04-17 + +## Current Architecture + +### Backend + +- Runtime: FastAPI + SQLAlchemy async +- Database access: direct ORM session injection per request +- Task execution: in-process `BackgroundTasks` +- Progress streaming: in-memory `ProgressService` +- Schema management: mixed `create_all()` and Alembic placeholders + +### Frontend + +- Runtime: React 18 + Vite SPA +- Routing: `react-router-dom` +- Auth state: client-only `localStorage` token + context +- API transport: axios interceptor with browser redirects +- Styling: Tailwind CSS with page-local utility classes + +### Deployment + +- `docker-compose.yml`: development-oriented split stack +- `docker-compose-single.yml`: monolith container with SQLite +- `Dockerfile`: FastAPI serves the built SPA as static assets + +## Target Architecture + +### Backend + +- Keep FastAPI as the system API boundary +- Move heavy router logic into typed services +- Use Alembic as the only schema migration path +- Introduce durable ingestion execution semantics +- Replace implicit transaction patterns with explicit service-level boundaries + +### Frontend + +- New app in `web/` +- Stack: Next.js App Router + TypeScript + Tailwind + shadcn/ui +- Auth: `HttpOnly` session cookie mediated by Next route handlers +- Data fetching: `fetch` wrappers for server/client usage +- Streaming: Next proxy route for exam progress SSE + +### Deployment + +- Split deployment becomes the primary production shape +- Monolith mode remains secondary compatibility mode +- Development and production Compose files must be separated + +## Core Constraints + +1. Do not overwrite existing uncommitted user changes in the legacy frontend. +2. Keep the legacy `frontend/` app available until the new `web/` app reaches functional parity. +3. Preserve backend API contracts where possible during the frontend migration. +4. Fix deployment/documentation drift before treating new frontend work as production-ready. + +## Immediate Workstreams + +1. Remove abandoned ESA captcha wiring from the legacy frontend. +2. Write audit documents and freeze the migration backlog. +3. Scaffold the new `web/` frontend without disturbing the legacy app. +4. Fix first-order deployment issues such as health checks and documented mount paths. diff --git a/docs/audit/backend-findings.md b/docs/audit/backend-findings.md new file mode 100644 index 0000000..f13b814 --- /dev/null +++ b/docs/audit/backend-findings.md @@ -0,0 +1,86 @@ +# Backend Findings + +## Critical Findings + +### Schema lifecycle is unsafe + +- App startup still calls `create_all()` +- Alembic metadata exists but the migration chain is effectively empty +- This prevents controlled upgrades and rollbacks + +Files: + +- `backend/main.py` +- `backend/database.py` +- `backend/alembic/versions/.gitkeep` + +### Parsing tasks are not durable + +- Document ingestion runs inside FastAPI `BackgroundTasks` +- Progress state lives in-process only +- Process restarts or horizontal scaling can strand exams in `pending` or `processing` + +Files: + +- `backend/routers/exam.py` +- `backend/services/progress_service.py` + +### Transaction boundaries are inconsistent + +- `get_db()` performs commit/rollback automatically +- Routers and background tasks also call `commit()` directly +- SSE endpoints keep a database dependency open for long-lived streams + +Files: + +- `backend/database.py` +- `backend/routers/exam.py` + +## High-Priority Bugs + +### Admin config can destroy secrets + +- `GET /api/admin/config` masks API keys +- `PUT /api/admin/config` persists whatever the frontend sends back +- A round-trip save can replace the real secret with the masked placeholder + +Files: + +- `backend/routers/admin.py` + +### LLM service has import-time side effects + +- `LLMService()` is instantiated at module import time +- Missing environment variables can break startup before DB-backed config is loaded + +Files: + +- `backend/services/llm_service.py` + +### Ingestion deduplication is race-prone + +- No unique DB constraint on `(exam_id, content_hash)` +- Multiple append operations can race and insert duplicates + +Files: + +- `backend/models.py` +- `backend/routers/exam.py` + +### Answer checking degrades incorrectly on infra failure + +- Short-answer grading failures are converted into zero scores +- User mistake data can be polluted by provider outages or config errors + +Files: + +- `backend/services/llm_service.py` +- `backend/routers/question.py` + +## Refactor Order + +1. Replace runtime schema creation with Alembic-first migrations. +2. Move ingestion, config, and answer checking into service classes. +3. Introduce explicit transaction boundaries and idempotent ingestion rules. +4. Add durable task execution and real status/error semantics. +5. Add integration tests for config round-trips, ingestion races, and answer normalization. diff --git a/docs/audit/deployment-findings.md b/docs/audit/deployment-findings.md new file mode 100644 index 0000000..c47b0f4 --- /dev/null +++ b/docs/audit/deployment-findings.md @@ -0,0 +1,49 @@ +# Deployment Findings + +## Current Problems + +### Monolith persistence documentation is wrong + +- Existing `docker run` examples mounted the wrong path +- SQLite and upload persistence must target `/app/data` and `/app/uploads` + +### Monolith health check was broken + +- `docker-compose-single.yml` used `curl` +- The image does not guarantee `curl` exists +- The health check has been switched to Python stdlib HTTP probing + +### Split Compose is development-oriented + +- Source mounts are enabled +- Backend runs with `uvicorn --reload` +- Frontend runs a dev server +- This is not a production deployment model + +### Security posture is weak + +- Compose contains hard-coded MySQL credentials +- MySQL is exposed on `3306` +- Environment guidance is inconsistent across README, Compose, and `.env.example` + +## Approved Direction + +1. Treat split deployment as the default production topology. +2. Keep monolith deployment as a compatibility target only. +3. Separate development assets from production assets. +4. Validate all release images with smoke checks before publishing. + +## Backlog + +### Short term + +- Create `compose.dev.yml` and `compose.prod.yml` +- Remove dev-server assumptions from production documentation +- Add backend runtime dependencies explicitly to image builds +- Align README with actual mount paths and health checks + +### Medium term + +- Add PR build, typecheck, lint, and smoke-test workflows +- Publish separate images for API and Next web app +- Document rollback by image tag and Compose profile diff --git a/docs/audit/frontend-migration.md b/docs/audit/frontend-migration.md new file mode 100644 index 0000000..1632258 --- /dev/null +++ b/docs/audit/frontend-migration.md @@ -0,0 +1,70 @@ +# Frontend Migration Plan + +## Decision + +The legacy Vite SPA remains in `frontend/` as a fallback. + +The new frontend is being built in `web/` with: + +- Next.js App Router +- TypeScript +- Tailwind CSS +- shadcn/ui component model + +The abandoned ESA captcha integration has been removed from the legacy login page. + +## Why a Rewrite Instead of an In-Place Port + +The legacy frontend mixes too many browser-only assumptions into core runtime +boundaries: + +- token storage in `localStorage` +- `window.location` redirects inside transport code +- client-only route protection +- SSE token passing in query strings + +Those patterns do not map cleanly onto Next App Router and server-first auth. + +## New Runtime Model + +### Auth + +- Login goes through Next route handlers +- Backend JWT is stored in an `HttpOnly` cookie +- Browser code never reads the raw token + +### Data + +- Server pages use server-side fetch helpers +- Client mutations use browser-side fetch helpers against Next proxy routes +- URL state is used for pagination and filters + +### Streaming + +- Browser connects to a same-origin Next progress route +- The route reads the session cookie and proxies backend SSE +- Backend URL tokens are hidden from the browser + +## Directory Map + +```text +web/ + src/app/ + src/components/ + src/lib/ + src/middleware.ts +``` + +## Migration Order + +1. Auth shell, layouts, middleware, and proxy routes +2. Dashboard, exams list, questions list, and admin overview +3. Exam detail upload and progress streaming +4. Quiz and mistake-practice flows +5. Cutover, smoke testing, and legacy frontend retirement + +## Non-Goals for This First Slice + +- No immediate removal of the legacy `frontend/` +- No backend contract rewrite yet +- No server actions as the primary data mutation layer diff --git a/docs/cover.png b/docs/cover.png new file mode 100644 index 0000000..438d4d5 Binary files /dev/null and b/docs/cover.png differ diff --git a/frontend/index.html b/frontend/index.html index 2915a45..4182b3c 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -6,14 +6,6 @@
完成度
-{isProcessing ? progress : quizProgress}%
+{completionProgress}%