ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

SpringBoot+Vue在线错题管理系统:从数据库设计到部署实践

2026/9/14 19:40:03 拓冰建站 浏览量
SpringBoot+Vue在线错题管理系统:从数据库设计到部署实践 简介这是一套面向毕业设计、期末大作业或课程设计场景的在线错题管理系统后端采用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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这段 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 命名空间全部换成了 jakartaMyBatis-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。解决方案是连接串上加 serverTimezoneAsia/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) { PageWrongRecord p new Page(page, size); LambdaQueryWrapperWrongRecord 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); PageWrongRecord 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 必须大于等于 1size 建议限制在 1 到 50 之间防止有人恶意传入 size10000 拖垮数据库。接口返回的 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) { LambdaUpdateWrapperWrongRecord 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 useryour-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.activeprod 参数把生产环境的数据库配置放到 application-prod.yml 中这样本地测试环境和生产环境的配置能完全隔离排查问题时不会因为配置串环境而误判。配合启动时的 --spring.datasource.url 覆盖参数可以在不修改 JVM 参数的情况下快速切换不同环境的数据库连接。本文还有配套的精品资源点击获取