SpringBoot+Vue在线错题管理系统:从数据库设计到部署实践
2026/9/14 19:40:00 网站建设 项目流程

简介:这是一套面向毕业设计、期末大作业或课程设计场景的在线错题管理系统,后端采用SpringBoot框架,前端基于Vue与HTML构建,前后端代码完整且附带注释,适合正在做JavaWeb项目的学生和快速搭建完整系统的入门开发者。压缩包内共4个文件,整体大小约41.61MB,其中包含项目源码压缩包、RAR格式的代码归档、SQL数据库脚本以及TXT部署说明,SQL可导入MySQL,部署说明能辅助完成环境配置。系统功能完善、界面美观、操作便利,已经过严格调试确保可运行,目前已有70人学习下载。开发环境建议使用IDEA、MySQL 5.7和Navicat,部署时结合Maven并选择Tomcat 7.x/8.x即可;通过这套资料,可完整了解数据库表设计、SpringBoot后端接口、Vue前端页面之间的衔接方式,对于毕业设计答辩、期末大作业或课程设计的代码讲解和功能演示都很有帮助。整套内容也便于后续二次开发,实用性和参考价值较强。

1. 在线错题管理系统为什么值得用 SpringBoot 和 Vue 重做一遍

错题管理的本质不是“把做错的题存下来”,而是建立一条“记录 — 重做 — 巩固 — 遗忘判定”的闭环。纸质错题本的问题是索引成本太高:按科目翻找、按错因归类、按掌握程度回看,几乎都是手工活。换成在线系统后,最常见的诉求有四个:按科目和标签快速筛选、记录错题原文与答案分析、统计某道题的正确率、在临近考试时只列出“还未完全掌握”的错题。这套逻辑放在 Excel 里也能勉强实现,但多人使用、移动端提交、权限区分这些需求一出现,就必须落到前后端分离的 Web 系统上。

选择 SpringBoot 加 Vue,不是因为它们“流行”,而是因为它们各自解决了这个场景里最难的两个问题:SpringBoot 负责把多表关联、分页查询和权限校验收敛成稳定接口;Vue 负责把筛选条件、错题列表和重做交互做成低延迟的单页体验。系统最核心的价值不在页面有多好看,而在“一道错题从录入到彻底掌握”这条状态流转是否严谨,以及在高频写入时数据库是否还能保持查询效率。下面按数据库、后端接口、前端页面、部署验证四个环节展开,每一段都给出可以直接对照实现的关键代码和参数。

2. 数据库设计:错题核心表怎么建,索引布在哪几个字段上

2.1 三类基础表和一张业务主表的字段划分

先明确一个原则:错题管理系统不是把所有信息塞进一张大宽表,而是把“用户”“科目”“题库”“错题记录”拆成四张物理表,再用外键逻辑关联。宽表在初期查起来快,但一旦要扩展“一道错题属于多个标签”或者“一道题被两个用户重复收录”,改造成本就很高。常见做法是把用户和科目做成基础表,题库表和错题记录表分开,原因很简单:同一道题可能被不同用户在错题场景下关联,记录的是“谁在哪次考试中做错了这道题”,而不是题目本身。

这里给出最常用的四张表:sys_user 存储账号和昵称,subject 存储科目名称和排序值,question_bank 存储题干、正确答案、解析和难度,wrong_record 是业务主表,记录错题来源、错误答案、错误原因、掌握状态和重做次数。业务主表不要直接引用用户填写的原始文本,而是用 user_id 和 question_id 作为关联键,这样后续做统计时只需要在 wrong_record 表上聚合,不需要回查题干表。

CREATE TABLE wrong_record ( id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id BIGINT NOT NULL, question_id BIGINT NOT NULL, subject_id BIGINT NOT NULL, wrong_answer TEXT, wrong_reason VARCHAR(255), mastery_status TINYINT DEFAULT 0, redo_count INT DEFAULT 0, last_redo_time DATETIME, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted TINYINT DEFAULT 0, KEY idx_user_time (user_id, create_time), KEY idx_subject (user_id, subject_id, mastery_status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这段 DDL 里需要重点说明三个参数。mastery_status 用 TINYINT 而不是 VARCHAR,是为了在状态流转时方便用数值比较,0 代表未掌握,1 代表基本掌握,2 代表已掌握,后续扩展也能直接加枚举值。last_redo_time 记录最近一次重做时间,做“超过 7 天未重做”的提醒时直接查这个字段,不需要回看历史操作表。deleted 字段是逻辑删除标记,错题记录的删除操作不走 DELETE 语句,而是把 deleted 置为 1,目的是保留历史统计数据的完整性。

2.2 错题列表页的慢查询隐患和索引排列顺序

列表页最常见的筛选项是科目、掌握状态、错误原因、时间范围,对应到 SQL 就是 WHERE user_id = ? AND subject_id = ? AND mastery_status = ? ORDER BY create_time DESC。如果不加索引,数据量到 10 万条时这个查询会明显变慢。索引的排列顺序不是随意放的,核心原则是“等值条件在前,范围条件在后”。user_id 永远是等值条件,subject_id 和 mastery_status 也是等值条件,create_time 用于排序,所以组合索引应该按照 user_id、subject_id、mastery_status、create_time 这个顺序创建。

另一个常见误用是给每个字段单独建单列索引。MySQL 在遇到多条件查询时通常只会选择其中一个索引,其余字段回表过滤,效率反而下降。如果业务上经常需要单独按科目统计错题数量,可以额外建一个 (user_id, subject_id) 的组合索引,但不要为 mastery_status 单独建索引,因为这个字段的基数只有 0、1、2 三种值,选择性太低。

查询时还要注意一个细节:如果 wrong_answer 和题干 content 都用了 TEXT 类型,禁止直接放进 GROUP BY 或 ORDER BY 子句。TEXT 字段不能设置默认值,也不能直接参与排序,否则会触发磁盘临时表。遇到这种情况,通常的做法是把题干拆成 question_bank 表,错误答案单独存,列表查询只返回 id、subject_id、mastery_status 这些短字段,详情页再回表取 TEXT 内容。

2.3 软删除和用户数据隔离的约定

在线系统必须考虑一个边界条件:两个用户同时访问同一条错题记录时,不应该看到对方的解题思路。数据隔离不靠前端隐藏按钮,而是在所有查询语句的 WHERE 条件里强制带上 user_id。为了减少遗漏,可以在 MyBatis 的拦截器层统一拼接这个条件,也可以在各 Service 方法里显式传入 SecurityUtils.getCurrentUserId()。显式传入更直白,出问题时也更容易定位。

软删除字段的约定要统一:所有主表的查询视图都加一层 WHERE deleted = 0。如果只在一部分查询里加了,统计接口会把已删除的记录也算进去,导致“错题总数 100,点进列表只有 95 条”这类诡异问题。另一个约定是 subject_id 冗余到 wrong_record 表。虽然通过 question_id 关联也能查到科目,但列表页筛选科目时每次都要 JOIN,性能开销大。冗余字段的代价是写入时要多维护一次,但在这个场景里,科目名称极少变更,收益远大于成本。

3. SpringBoot 后端的接口分层与掌握状态流转

3.1 后端目录怎么分,依赖引到什么粒度

后端工程不需要过度设计,常见做法就是 controller、service、mapper、entity、common 五个包。entity 对应数据库表结构,mapper 只做单表操作,复杂的跨表组装放在 service 层。这个分层在错题管理中尤其重要,因为“记录一道错题”这个动作可能要同时写 wrong_record 表和 record_tag 关联表,如果直接写在 controller 里,事务边界很难控制。

依赖方面,基础项目引入 Spring Web、Spring Data JPA 或者 MyBatis-Plus、MySQL Driver、Lombok 就够了。如果选择 MyBatis-Plus,通用分页插件和逻辑删除插件是两个必须配置的组件。SpringBoot 版本这一项容易踩坑:如果你的 SpringBoot 是 3.x,原来的 javax 命名空间全部换成了 jakarta,MyBatis-Plus 必须用适配新版的分页插件,否则启动时会直接报 ClassNotFoundException。

@Entity @Table(name = "wrong_record") @TableLogic public class WrongRecord { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private Long userId; private Long questionId; private Long subjectId; private String wrongAnswer; private String wrongReason; private Integer masteryStatus; private Integer redoCount; private LocalDateTime lastRedoTime; private LocalDateTime createTime; }

这段实体类里,@TableLogic 是逻辑删除的关键。一旦加上这个注解,MyBatis-Plus 的所有内置查询都会自动追加 deleted = 0,手动写的 UPDATE 语句不会受影响。这里容易忽略的是 LocalDateTime 和数据库 DATETIME 类型的映射,MySQL 驱动版本低于 8.0 时默认会把 DATETIME 映射成 Timestamp,导致 Jackson 序列化后返回给前端的时间格式多出 .0。解决方案是连接串上加 serverTimezone=Asia/Shanghai,实体字段统一用 LocalDateTime。

3.2 分页查询的入参校验与条件构造器写法

分页接口是前端列表页的核心依赖,入参至少包含 page、size、subjectId、masteryStatus、wrongReason、beginTime、endTime。这些参数里只有 page 和 size 是必填的,其他都是可选条件。如果设计成每个可选参数都写一个 if 判断,代码会非常啰嗦,最常见的高效写法是用 MyBatis-Plus 的 LambdaQueryWrapper 动态拼接。

public PageResult list(Long userId, int page, int size, Long subjectId, Integer masteryStatus, String wrongReason, LocalDateTime beginTime, LocalDateTime endTime) { Page<WrongRecord> p = new Page<>(page, size); LambdaQueryWrapper<WrongRecord> wrapper = Wrappers.lambdaQuery(); wrapper.eq(WrongRecord::getUserId, userId); wrapper.eq(subjectId != null, WrongRecord::getSubjectId, subjectId); wrapper.eq(masteryStatus != null, WrongRecord::getMasteryStatus, masteryStatus); wrapper.like(StringUtils.hasText(wrongReason), WrongRecord::getWrongReason, wrongReason); wrapper.ge(beginTime != null, WrongRecord::getCreateTime, beginTime); wrapper.le(endTime != null, WrongRecord::getCreateTime, endTime); wrapper.orderByDesc(WrongRecord::getCreateTime); Page<WrongRecord> result = wrongRecordMapper.selectPage(p, wrapper); PageResult pr = new PageResult(); pr.setTotal(result.getTotal()); pr.setRecords(result.getRecords()); pr.setPage(page); pr.setSize(size); return pr; }

这段代码的要点在 wrapper.eq 的第一个参数是布尔值。当 subjectId 为 null 时,这个条件自动跳过,SQL 里就不会出现多余的 AND 子句。使用这种写法时要注意,eq 的布尔条件是“当值为真时才拼接”,不是“当值为假时拼接”,方向写反会导致过滤条件永远不生效。分页参数 page 和 size 在前端也要做兜底校验,page 必须大于等于 1,size 建议限制在 1 到 50 之间,防止有人恶意传入 size=10000 拖垮数据库。

接口返回的 PageResult 是专门定义的分页响应结构,包含 total、records、page、size 四个字段。records 里不要直接返回实体类,因为 WrongRecord 里有 wrongAnswer 这种长文本字段,列表页根本不需要,每次传输都在浪费带宽。常见做法是定义 WrongRecordVO,只包含 id、科目名称、掌握状态、重做次数、最近重做时间这些列表用得到的字段,详情接口再返回完整数据。

3.3 重做、掌握、遗忘:三个状态接口的实现方式

错题管理的业务核心是状态流转,不是简单的新增和删除。最基本的三个操作是:标记为已重做、调整掌握程度、把掌握状态回退为未掌握。这三个操作都落在 wrong_record 表的三个字段上,但更新逻辑略有不同。

标记已重做时,redo_count 要自增,last_redo_time 要更新为当前时间,mastery_status 是否变化取决于前端传入的目标状态。注意,表结构里不能没有初始掌握状态,新增错题时如果不传 mastery_status,就应该走数据库默认值 0。如果数据库表已经建成且默认值缺失,需要在插入语句里显式指定 setMasteryStatus(0),否则 Java 侧得到 null,后面的状态比较全部失效。

@Transactional public void redo(Long userId, Long recordId, Integer targetStatus) { LambdaUpdateWrapper<WrongRecord> wrapper = Wrappers.lambdaUpdate(); wrapper.eq(WrongRecord::getId, recordId); wrapper.eq(WrongRecord::getUserId, userId); wrapper.set(WrongRecord::getRedoCount, redoCount + 1); wrapper.set(WrongRecord::getLastRedoTime, LocalDateTime.now()); if (targetStatus != null) { wrapper.set(WrongRecord::getMasteryStatus, targetStatus); } wrongRecordMapper.update(null, wrapper); }

为什么重做之后还要考虑“遗忘判定”?因为传统的错题系统只有两种状态,会做和不会做,导致很多学生三天前刚重做过一遍,三天后又忘光了。这类系统的进阶做法是设置遗忘周期:如果一道错题标记为“已掌握”超过 N 天没有再次重做,系统自动把它移回“未掌握”。这个场景不需要一个常驻定时任务,在列表查询时动态计算即可,判定条件就是 last_redo_time 加上 N 天小于当前时间。所谓“7 天未重做就遗忘”,本质是在 SQL 里增加一个 OR 条件,让 last_redo_time 超期的记录强制显示在待复习列表中。

4. Vue 前端的列表页筛选与错题长列表渲染

4.1 Vue 项目的路由组织和页面模块划分

前端部分采用 Vue 3 加 Vite 是当前最稳妥的搭配,因为 Vite 的冷启动速度对开发期体验提升非常明显。页面模块按业务划分,错题列表、错题详情、错题统计、科目管理四个视图分别放在 views/errorRecord 下的四个目录,路由采用懒加载方式,避免首屏加载时把整个系统的代码全部下载下来。创建 Vue 项目之后第一件事不是写页面,而是配置路由和状态管理。

const routes = [ { path: "/", redirect: "/errors" }, { path: "/errors", name: "ErrorList", component: () => import("@/views/errorRecord/ErrorList.vue") }, { path: "/errors/:id", name: "ErrorDetail", component: () => import("@/views/errorRecord/ErrorDetail.vue") }, { path: "/stats", name: "ErrorStats", component: () => import("@/views/errorRecord/ErrorStats.vue") } ];

路由配置里的动态段 :id 对应错题详情页,详情页通过 route.params.id 调详情接口。这里需要说明一个经验:不要在详情页的 onMounted 里直接读取 params.id 调接口,因为同一组件被复用(例如从列表 A 跳详情再切到列表 B 的另一个详情)时,Vue 不会重新触发 onMounted。解决办法是 watch 路由参数变化,或者给 router-view 加 :key="$route.fullPath"。前者更通用,只要在 watch 里重新加载详情数据即可。

4.2 axios 请求封装和 token 失效的统一处理

Vue 项目里每个页面都直接 import axios 就会产生重复代码,最基础的做法是封装一个 request 实例,统一处理 baseURL、请求头携带 token、响应拦截器里的业务码判断。做了这个封装之后,后续所有接口调用都只需要关心业务数据本身,不需要重复写错误弹窗和跳转登录的逻辑。

import axios from "axios"; const request = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || "/api", timeout: 15000 }); request.interceptors.request.use((config) => { const token = localStorage.getItem("token"); if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; }); request.interceptors.response.use( (response) => { const res = response.data; if (res.code === 200) { return res; } if (res.code === 401) { localStorage.removeItem("token"); window.location.href = "/login"; return Promise.reject(new Error(res.message)); } return Promise.reject(new Error(res.message)); }, (error) => { return Promise.reject(error); } ); export default request;

这段拦截器的逻辑重点是 code === 401 时的处理。401 要不要自动跳登录页,取决于你的 token 是否设置了过期时间,如果用的是 JWT 并且设置了 24 小时有效期,拦截器里做统一跳转非常省事。timeout 参数设置成 15000 毫秒是考虑错题详情接口要查多张表,但又不希望页面卡太久,实际生产环境可以根据接口耗时的 p95 值调整,不建议低于 10 秒,也不建议高于 30 秒。

4.3 筛选联动和 v-model 导致的条件残留问题

错题列表页最核心的交互是筛选区加列表区。筛选区一般包含科目下拉框、掌握状态下拉框、错误原因输入框、时间选择器、查询和重置两个按钮。这里的坑在重置按钮:很多同学直接用 Object.assign(this.filter, {}) 清空数据,但页面上的下拉框选项还是上一次的值,因为下拉框绑定的可能是另一个数据源。正确做法是把筛选条件单独封装成一个对象,重置时显式给每个字段赋初始值。

列表区使用 el-table 或原生 table 都可以,关注点是长列表渲染。当一次查出 500 条错题时,Vue 渲染 500 个点击事件监听器并不会造成明显性能问题,真正的问题是详情数据的懒加载。不要在列表项里把错题全文和答案解析全部渲染,列表只需要显示题干的前 50 个字符,做法是后端在 VO 里加一个 content 截断字段,或者前端的插值表达式里做 slice。展示侧还有一个细节:markdown 格式的题干不要用 v-html 直接渲染,因为用户录入的解析可能包含未转义的脚本片段,存在 XSS 风险。安全做法是引入 marked 库并配置 sanitize,或者只渲染纯文本。

5. 打包部署到 Linux 的关键步骤和三个验证动作

5.1 部署方式选型和跨域配置

部署方案最常用的是购买一台 2C4G 的 Linux 服务器,前端打成的静态文件由 Nginx 托管,后端 SpringBoot 以 jar 包方式用 systemd 守护运行。MySQL 单独跑在服务器上还是用云数据库,取决于团队运维能力,个人项目直接装在本地环境最简单。生产环境的跨域问题不需要靠后端加 CorsFilter 解决,因为 Nginx 把 /api 路径反向代理到 localhost:8080,浏览器看到的始终是同源的请求,自然不会触发跨域。

# 前端构建 npm install npm run build # 后端构建 mvn clean package -DskipTests # 部署后端 scp target/error-book.jar user@your-server:/opt/error-book/

前端构建时注意 Vite 的 base 配置,如果部署在域名根路径,base 用默认值 / 即可;如果部署在子路径(例如 http://server/errors/),必须把 base 设置为 /errors/,否则静态资源全部 404。后端 jar 启动前先把连接数据库的参数确认一遍,数据库账号密码不要写死在 application.yml 里,用 JVM 启动参数覆盖是更安全的方式。

5.2 Nginx 反向代理配置中容易配错的三个字段

Nginx 配置的核心是把 / 指向前端 dist 目录,把 /api/ 反向代理到后端服务。最容易配错的是 alias 和 try_files。dist 目录的路径如果写错,页面能打开但是样式加载失败。try_files 必须放在 location / 里,否则前端路由在刷新页面时会报 404,这是 SPA 部署的经典问题。

server { listen 80; server_name your-domain.com; root /opt/error-book/dist; index index.html; location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location / { try_files $uri $uri/ /index.html; } location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ { expires 7d; access_log off; } }

每台服务器的内存和磁盘大小决定 Nginx 需要调哪些参数,最低配置下只需要调整 client_max_body_size,避免上传错题图片时出现 413 错误。这里的 proxy_pass 后面有没有斜杠,含义完全不同。proxy_pass http://127.0.0.1:8080; 不带斜杠是保留完整路径,也就是 /api/errors 会转发到 http://127.0.0.1:8080/api/errors;如果写成 http://127.0.0.1:8080/,路径中的 /api 前缀会被剥掉,后端接口路径就对不上了。如果是给已有项目做性能排查,可以先关掉 access_log 看错误日志,再逐步调大 worker_processes。

5.3 部署后必做的三个验证动作

验证的第一步是检查后端服务状态。使用 systemd 管理 jar 包时,启动失败多半是配置文件里数据库密码带特殊字符导致,此时优先看 systemd 的错误日志,再确认 MySQL 的 max_connections 是否被占满。第二步是验证前端接口调用,在浏览器打开页面后,如果列表接口返回 500,直接查看后端日志中的 SQL 语句,重点检查 DDL 里是否有 not null 字段没有被插入语句覆盖。第三步是验证生产环境的时区显示是否一致,java 应用的 serverTimezone 参数缺失会导致时间比实际时间差 8 个小时。

最后给一个具体技巧:jar 包运行时加上 -Dspring.profiles.active=prod 参数,把生产环境的数据库配置放到 application-prod.yml 中,这样本地测试环境和生产环境的配置能完全隔离,排查问题时不会因为配置串环境而误判。配合启动时的 --spring.datasource.url 覆盖参数,可以在不修改 JVM 参数的情况下快速切换不同环境的数据库连接。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询