ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

考试信息报名系统实战:SpringBoot+Vue+MySQL全链路开发与并发控制

考试信息报名系统实战:SpringBoot+Vue+MySQL全链路开发与并发控制 每年毕业设计选题的时候SpringBoot、Vue、MySQL这三个词几乎成了计算机类专业本科毕设的标配组合。考试信息报名系统更是在各种选题清单里反复出现因为它足够贴近真实业务有用户登录、有角色权限、有列表查询、有数据写入更重要的是有并发边界。多数人以为这是最稳妥的“安全牌”结果交上来的系统只有三层——用户注册、考试列表、报名按钮答辩老师随便追问一句“同一场考试名额只剩一个时两个学生同时点报名会发生什么”就答不上来。这篇文章不是从零教你怎么定义一个实体类而是把我自己从选题、建表、写接口、写页面到联调部署的完整过程梳理出来重点说清楚哪些设计决定了系统能不能撑住真实使用哪些坑是我实际踩过、后来在答辩场合反而成了加分项的。内容适合正在做教务管理类毕设的同学也适合刚工作不久、想补一补前后端分离项目完整链路的新人。1. 选型背后的三道分水岭为什么同样用这套组合有人能答辩有人只能演示1.1 这套组合考察的不是三个框架而是它们之间的衔接很多同学以为毕业设计选SpringBootVueMySQL是因为“网上资料多、好抄”这个想法大错特错。我做完这个项目之后最大的体会是单看每一个技术点都很基础SpringBoot的Controller怎么写、Vue的组件怎么挂载、MySQL的建表语句怎么敲这些课程设计阶段就已经练过了。但把它们拼在一起之后问题就变得完全不基础了。比如后端的接口数据格式怎么设计前端才能少写一堆if else登录之后的用户身份怎么在每次请求里传递总不能每次都查一遍数据库前端页面跳转时怎么判断当前用户有没有权限进入管理员页面这些问题既不属于SpringBoot也不属于Vue更不属于MySQL而是三者之间的衔接层。毕业设计真正考察的就是这个衔接层而不是某个框架本身用过没有。这个考试信息报名系统恰好把衔接层的关键点全部覆盖了用户登录涉及到Token的签发与校验角色权限涉及到管理员接口和普通学生接口的隔离考试列表涉及到分页查询的数据格式约定报名操作涉及到事务和并发控制。把这几个环节打通了哪怕界面做得朴素一些答辩老师也能看出你是真的理解了前后端分离项目的运转方式。1.2 选型前必须想清楚的三个错误心态我在带毕设小组的过程中见过三类特别典型的心态基本可以提前预判答辩结果错误心态表面做法实际后果“能跑就行”一个登录页所有用户登录后看到的功能完全一样答辩老师必问角色权限三句话就卡壳“表能存数据就行”考试信息和报名信息全部挂在一个字段里用逗号分隔数据无法统计接口逻辑越写越乱“数据库用什么无所谓”为了部署方便用SQLite替代MySQL避开了数据库设计的核心考察点老师不认可选题的工程价值这三个心态本质上是同一个问题把毕业设计当成“交作业”而不是当成“做项目”。考试信息报名系统虽然技术上不复杂但它具备一个真实系统该有的全部要素用户体系、权限体系、核心业务、数据一致性、前端状态同步。如果你在选型阶段就把这些要素当成分内事后面的实现思路会清晰非常多。1.3 版本选择上落地的建议版本选型曾经是个大坑不是最新版就一定好用。我现在做这个项目的标准搭配是后端SpringBoot 2.7.x JDK 8 或 JDK 17两者兼容性都很好数据库MySQL 8.0注意驱动用com.mysql.cj.jdbc.Driver前端Vue 3 Vite 5 Element Plus Pinia不要再用Vue 2ORMMyBatis-Plus版本用3.5.x自带分页插件SpringBoot 3.x 也可以但Java版本要求17以上如果你的JDK还没升级用2.7.x不会在答辩时减分。重要的是把核心逻辑做对而不是追新版本。2. 数据库是地基考试报名系统的表结构与字段设计细节2.1 用户表一张表解决三种角色而不是拆三张表考试报名系统里至少有学生、教师/管理员两类角色有些系统还可能加一个超级管理员。很多同学的直觉是“角色不同就应该建不同的表”这在真实企业项目里往往是错的在这个毕设项目里更是平白增加复杂度。我的设计是只建一张sys_user表用一个role字段区分角色值分别是1管理员、2教师、3学生。为什么一张表就够了因为这些用户的核心属性是重合的账号、密码、姓名、学号/工号、创建时间。拆成三张表意味着登录接口要查询三张表再合并权限逻辑要写三套分支完全是给自己挖坑。CREATE TABLE sys_user ( id bigint(20) NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL COMMENT 登录账号, password varchar(100) NOT NULL COMMENT BCrypt加密后的密码, real_name varchar(50) DEFAULT NULL COMMENT 真实姓名, student_no varchar(30) DEFAULT NULL COMMENT 学号/工号, role tinyint(4) NOT NULL DEFAULT 3 COMMENT 角色1管理员2教师3学生, create_time datetime DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_username (username), UNIQUE KEY uk_student_no (student_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里有两个字段细节特别容易踩坑。第一是password字段长度不要建varchar(20)我用BCrypt加密密码加密结果固定60个字符建小了密码根本存不进去只能换回明文存储。第二是username必须加唯一索引这是登录功能的第一道防线防止数据库层面出现重复账号。role字段用tinyint而不是字符串看起来可读性差一点但查询效率高、前后端枚举映射方便加个注释就清楚了。2.2 考试表容量字段直接决定并发报名逻辑怎么写考试信息表是整个系统的业务核心。除了考试名称、考试时间、考试地点这些常规字段之外有两个字段决定了报名的并发逻辑能不能成立total_quota和registered_count。CREATE TABLE exam ( id bigint(20) NOT NULL AUTO_INCREMENT, exam_name varchar(100) NOT NULL COMMENT 考试名称, exam_date datetime DEFAULT NULL COMMENT 考试时间, location varchar(200) DEFAULT NULL COMMENT 考试地点, total_quota int(11) NOT NULL DEFAULT 0 COMMENT 总名额, registered_count int(11) NOT NULL DEFAULT 0 COMMENT 已报名人数, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 状态0未发布1报名中2已结束, description text COMMENT 考试说明, create_time datetime DEFAULT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;很多同学会把“已报名人数”设计成每次查询时count(registration表)来统计这在数据量小的时候完全没问题但到了并发场景就成了性能瓶颈。考试人数本身就是一条高频更新的数据直接冗余在考试表里报名操作加一、取消报名减一查询考试列表时连表都不需要一条SQL就带出来了。status字段建议用数字枚举0表示未发布、1表示报名中、2表示已结束列表页根据这个字段决定显示哪些考试。2.3 报名表唯一索引是防重复报名的最后底线报名表是连接用户和考试的桥梁表结构看起来简单但设计不好会出大问题。CREATE TABLE registration ( id bigint(20) NOT NULL AUTO_INCREMENT, user_id bigint(20) NOT NULL COMMENT 用户ID, exam_id bigint(20) NOT NULL COMMENT 考试ID, register_time datetime DEFAULT NULL COMMENT 报名时间, status tinyint(4) NOT NULL DEFAULT 1 COMMENT 状态1正常0已取消, PRIMARY KEY (id), UNIQUE KEY uk_user_exam (user_id, exam_id), KEY idx_exam_id (exam_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;重点在UNIQUE KEY uk_user_exam (user_id, exam_id)这一行。业务代码里无论你怎么做校验都拦不住两个请求同时到达的情况。数据库的唯一索引是最后一道物理屏障同一个用户对同一场考试的报名请求哪怕同时发过来两个也只会有一条插入成功另一条直接抛DuplicateKeyException。我见过太多项目把这个约束漏掉然后在Service里写一堆if判断看起来逻辑正确实际上并发下全是漏洞。2.4 字符集、逻辑删除和时间字段的统一约定建表的时候统一用utf8mb4字符集不要问WhyMySQL 8.0默认就是utf8mb4但如果你直接复制了老项目的建表语句可能还是utf8会导致有些中文和表情符号存不进去。时间字段统一用datetime配合后端的LocalDateTime使用避免timestamp在2038年之后出问题虽然毕设演示不会等到那一年但面试时可以提一嘴。3. 后端核心SpringBoot里报名功能的正确写法3.1 统一返回体和全局异常处理先省下一半联调时间做前后端分离项目第一个要定的不是接口名而是接口返回的数据格式。我用的统一返回结构是{ code: 200, message: success, data: {} }前端不管请求成功还是失败只用看code。如果业务层抛出了异常全局异常处理器把它转成对应的code和message前端统一弹提示。这样前端不用在每个接口里各自处理错误分支后端也不用在Controller里写大量try catch。Data public class ResultT { private Integer code; private String message; private T data; public static T ResultT ok(T data) { ResultT r new Result(); r.setCode(200); r.setMessage(success); r.setData(data); return r; } public static T ResultT error(Integer code, String message) { ResultT r new Result(); r.setCode(code); r.setMessage(message); return r; } }全局异常处理用RestControllerAdvice核心是捕获BizException业务异常和DuplicateKeyException唯一索引冲突。报名接口里最怕的就是唯一索引冲突异常裸奔到前端前端拿到一堆看不懂的英文报错。在全局异常里专门处理它返回“请勿重复报名”体验一下子就正常了。3.2 JWT登录与ThreadLocal用户上下文一个过滤器贯穿全流程考试报名系统的登录方案我选了JWT不使用Session。原因很简单前后端分离部署时后端接口可能被多个前端调用网页端、管理端Session天然不友好而JWT是无状态的前端每次请求在请求头带上Token后端验签通过就知道是谁。登录接口的核心逻辑很简单查用户、验密码、生成Token。public String login(String username, String password) { SysUser user userMapper.selectByUsername(username); if (user null) { throw new BizException(400, 用户不存在); } if (!BCrypt.checkpw(password, user.getPassword())) { throw new BizException(400, 密码错误); } // 生成JWT有效期2小时 return JwtUtil.generateToken(user.getId(), user.getRole()); }Token生成之后前端每次请求带上Authorization: Bearer xxx后端用一个拦截器统一解析Token。public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String token request.getHeader(Authorization); if (token ! null token.startsWith(Bearer )) { token token.substring(7); Claims claims JwtUtil.parseToken(token); if (claims ! null) { UserContext.setUserId(claims.get(userId, Long.class)); UserContext.setRole(claims.get(role, Integer.class)); } } return true; } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { UserContext.clear(); } }UserContext本质是一个ThreadLocal请求进来放用户信息请求结束清除。这样做的好处是Controller和Service里到处都能拿当前登录用户ID不用每次从Token里解析。但注意afterCompletion一定要清除否则线程池复用时会串号这是个隐蔽的坑。3.3 报名接口事务、乐观控制、唯一索引的三层组合拳报名是整个系统最核心的接口也是答辩老师几乎必问的地方。先看错误写法Transactional public ResultVoid register(Long examId) { Exam exam examMapper.selectById(examId); if (exam.getRegisteredCount() exam.getTotalQuota()) { throw new BizException(考试名额已满); } registrationMapper.insert(new Registration(userId, examId)); exam.setRegisteredCount(exam.getRegisteredCount() 1); examMapper.updateById(exam); return Result.ok(); }这段代码在单用户、低并发场景下完全没问题但两个学生同时点报名时必然出错。两个请求同时执行selectById都看到名额还剩1都通过检查都执行insert都执行update最终已报名人数变成满额1。正确写法是先使用数据库原子操作扣减名额根据受影响行数判断是否满额再插入报名记录Transactional public ResultVoid register(Long examId) { Long userId UserContext.getUserId(); int updated examMapper.deductQuota(examId); if (updated 0) { throw new BizException(考试名额已满); } try { registrationMapper.insert(new Registration(userId, examId)); } catch (DuplicateKeyException e) { throw new BizException(请勿重复报名); } return Result.ok(); }对应的SQL语句是UPDATE exam SET registered_count registered_count 1 WHERE id #{examId} AND registered_count total_quota这条UPDATE语句是原子的数据库行锁保证同一时间只有一个请求能把registered_count加一。如果registered_count total_quota不成立受影响行数为0事务回滚就不会出现超卖。接下来插入报名记录时唯一索引兜底防止同一用户重复报名。3.4 考试列表分页与“我是否已报名”的数据联想考试列表接口的难点不在于分页而在于每条数据要告诉前端“当前登录用户是否已经报过这场考试”。最直接的做法是查出考试列表后再查一遍当前用户的所有报名记录用SetLong记录已报名的考试ID遍历列表打标记。public PageResultExamVO page(int pageNum, int pageSize, String keyword) { PageExam page new Page(pageNum, pageSize); LambdaQueryWrapperExam wrapper new LambdaQueryWrapper(); wrapper.like(StringUtils.isNotBlank(keyword), Exam::getExamName, keyword) .orderByDesc(Exam::getExamDate); examMapper.selectPage(page, wrapper); Long userId UserContext.getUserId(); ListExamVO voList page.getRecords().stream().map(exam - { ExamVO vo new ExamVO(); BeanUtils.copyProperties(exam, vo); int count registrationMapper.countByUserIdAndExamId(userId, exam.getId()); vo.setRegistered(count 0); vo.setFull(exam.getRegisteredCount() exam.getTotalQuota()); return vo; }).collect(Collectors.toList()); return new PageResult(voList, page.getTotal()); }这里用countByUserIdAndExamId而不是直接查询列表是因为数量级小的时候这样写最简单直观而且不会出现内存中列表和数据库脱节的问题。4. 前端落地Vue3项目管理、路由守卫和报名状态联动4.1 Vite Vue3的项目结构与依赖安装前端我用了Vite创建Vue3项目Node版本建议16以上否则Vite 5跑不起来。创建命令是npm create vitelatest exam-frontend -- --template vue进入项目后安装依赖npm install npm install vue-router4 pinia axios element-plus目录结构按功能模块划分而不是按文件类型堆在一起src/ ├── api/ # 接口请求封装 │ ├── auth.js # 登录相关接口 │ ├── exam.js # 考试相关接口 │ └── registration.js # 报名相关接口 ├── views/ # 页面组件 │ ├── Login.vue │ ├── ExamList.vue │ ├── AdminExam.vue │ └── Profile.vue ├── router/ # 路由配置 ├── stores/ # Pinia状态管理 ├── utils/ # axios封装、工具函数 └── App.vue这个结构的好处是每个页面涉及的API、状态、组件都在离它最近的地方不会出现api目录里几百个文件互相找不到的情况。毕设项目代码量不大但结构清爽在答辩时很加分。4.2 Axios封装Token注入、统一错误提示一个都不能少前端请求后端接口最基础的要求是每个请求自动带上Token返回401时自动跳转登录页。我在utils/request.js里封装了axios实例import axios from axios import { ElMessage } from element-plus import router from ../router const request axios.create({ baseURL: /api, timeout: 10000 }) 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) { ElMessage.error(res.message) return Promise.reject(new Error(res.message)) } return res }, error { if (error.response?.status 401) { localStorage.removeItem(token) router.push(/login) } ElMessage.error(网络请求失败) return Promise.reject(error) } )一个关键约定是开发环境下baseURL写/api不要写http://localhost:8080。这样配合Vite代理才能绕开跨域问题具体原理我在后面的排错环节详细讲。4.3 考试列表页与报名按钮的状态联动考试列表页面最核心的逻辑是按钮的显示状态要跟后端数据保持一致而且不能每次点完都重新刷新整个页面。我的做法是维护三个响应式变量examList考试列表、registeredExamIds已报名的考试ID集合、loading加载状态。const examList ref([]) const registeredExamIds ref(new Set()) const loading ref(false) const loadExamList async () { loading.value true const res await examApi.getPage({ pageNum: 1, pageSize: 10 }) examList.value res.data.list const regs await registrationApi.getMyRegisteredIds() registeredExamIds.value new Set(regs.data) loading.value false } const handleRegister async (examId) { await registrationApi.register(examId) registeredExamIds.value.add(examId) ElMessage.success(报名成功) }模板层怎么做按钮状态el-button v-if!registeredExamIds.has(item.id) !item.full typeprimary clickhandleRegister(item.id) 立即报名/el-button el-button v-else-ifregisteredExamIds.has(item.id) disabled已报名/el-button el-button v-else disabled名额已满/el-button这里没有在报名成功后重新请求列表而是直接更新本地的Set。这样页面上所有引用registeredExamIds的地方都会自动更新。如果你在报名成功后loadExamList()重新拉全量数据在网络稍慢的情况下报名按钮会闪烁一下体验很差。4.4 路由守卫与管理员权限控制前端路由守卫的作用是拦截未登录用户和没有权限的用户。Vue Router 4里用beforeEach实现router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.meta.requiresAuth !token) { next(/login) return } const role Number(localStorage.getItem(role)) if (to.meta.roles !to.meta.roles.includes(role)) { next(/403) return } next() })路由配置里对应的写法是{ path: /admin/exam, component: AdminExam, meta: { requiresAuth: true, roles: [1, 2] } }有一个细节localStorage里存的角色信息在用户手动修改后前端权限就失效了。所以管理员相关的接口后端还必须再校验一次JWT里的角色。前端路由守卫只是用户体验层面的控制真正的安全边界永远在后端。5. 联调排错实录跨域、时区和重复提交三个典型事故5.1 跨域问题Vite代理配置对了为什么还是报跨域联调阶段遇到的第一个问题就是跨域。我的开发环境前端跑在http://localhost:5173后端跑在http://localhost:8080。浏览器看到请求源不同就会拦截。很多同学遇到跨域第一反应是去后端配CORS过滤器这没问题但在这个项目里更推荐用Vite代理。Vite代理的思路是前端请求不是直接发给后端而是发给Vite的开发服务器由开发服务器转发给后端。这样浏览器看到的始终是同源请求根本没有跨域。// vite.config.js export default defineConfig({ server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })这里最常见的坑是配置了代理但前端请求里写的是完整地址http://localhost:8080/api/login代理完全没生效因为代理只匹配以/api开头的相对地址。正确写法是request.post(/login)axios实例里的baseURL会自动拼成/api/login。排查这个问题的时候我记得当时开了浏览器Network面板看了半天请求状态又去后端看日志最后才发现是前端自己的URL写错了代理根本没接管。记住一个原则开发环境统一走代理生产环境统一走Nginx反向代理后端CORS过滤器只在特殊场景才用。5.2 数据库时间慢了8小时一个配置两个位置都要改第二个典型的坑是时间问题。前端页面显示create_time总是比实际时间少8小时查了数据库里的数据发现是对的那问题一定出在数据传输链路上。一步步排查下来发现两个位置都要配置。第一MySQL连接URL必须指定时区spring: datasource: url: jdbc:mysql://localhost:3306/exam?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiMySQL 8.0驱动默认时区是UTC如果不指定serverTimezone查询出来直接按UTC时间返回换算到东八区就差了8小时。第二Jackson序列化LocalDateTime时也要指定时区spring: jackson: time-zone: Asia/Shanghai date-format: yyyy-MM-dd HH:mm:ss这个坑的隐蔽性在于你单独看数据库、单独看后端日志时间都是对的但返回给前端就错了。其实是因为后端在把LocalDateTime对象转成JSON字符串时用了JVM默认时区而有的云服务器默认时区是UTC。统一把这两处配置配好不要去前端写什么补丁函数强行加8小时那种写法在业务复杂的项目里迟早出乱子。5.3 并发重复报名为什么事务没挡住唯一索引却挡住了最后一个也是最重要的排错经历用两个浏览器同时登录不同账号在同一场考试还剩最后一个名额时同时点报名发现数据库里registered_count变成了满额加一。这明显是超卖了。我当时的排查链路是这样的第一步先看后端日志确认两个请求确实同时进入了Service方法。第二步看执行的SQL发现两个请求都执行了UPDATE exam SET registered_count registered_count 1 WHERE id ?没有带registered_count total_quota条件。原因是早期版本我写的是selectById查一次名额再updateById覆盖更新两个请求都查到了满额前的数据然后各自加一写回最后一条写回把前一条覆盖了。第三步就是改成前面提到的原子UPDATE方式。这条SQL天然带资格校验数据库行锁保证同一时间只有一个请求能更新成功。第四步加上唯一索引防止同一用户在同一毫秒内发两次请求产生的重复记录。这个问题的根因不在事务而在于“先查后写”的竞态。事务保证的是要么全成功要么全失败但没办法阻止两个并发事务读到相同值。数据库的原子更新和唯一索引才是真正解决并发的底层机制。这个排查经历非常典型也成了我答辩时的核心亮点。6. 答辩前的代码自查与演示节奏安排6.1 答辩老师最爱追问的三个问题怎么答答辩环节老师一般不会让你当场写代码而是围绕你项目里的关键设计追问。考试报名系统最常被问到的问题有这三个第一“一个考生重复提交报名系统怎么防”回答思路前后端双重校验前端通过已报名状态禁用按钮后端用数据库唯一索引兜底重复插入时捕获异常并返回友好提示。第二“名额只剩一个时两个考生同时报名会发生什么”回答思路这是并发下的超卖问题。如果先查再写会超卖所以直接用带条件的UPDATE原子扣减名额受影响行数为0就说明满额事务回滚。第三“管理员新增考试后学生端什么时候能看到”回答思路考试表有status字段管理员录入时默认未发布点击发布后状态变成报名中学生端的列表页只查status1的考试通过状态字段控制可见性而不是通过延迟或刷新机制。这三个问题答好了哪怕其他细节答辩时有点小瑕疵分数也不会差。6.2 代码提交前的六项自查清单每次让学弟学妹提交毕设前我建议按这个清单过一遍检查项正确做法常见错误密码存储BCrypt加密明文存储或MD5参数校验Validated 注解Controller里自己写一串if异常处理全局异常处理器每个Controller单独 try catch数据库并发原子UPDATE 唯一索引select再update前端请求统一Axios实例每个页面新建axiosSQL注入MyBatis-Plus预编译字符串拼接SQL第六项特别容易忽略。用MyBatis-Plus的LambdaQueryWrapper默认预编译基本没有SQL注入风险但如果自己在XML里写了${}拼接答辩老师一旦问到你搜索功能怎么实现的就可能暴露风险。6.3 演示数据准备与操作节奏演示环节翻车的人太多了基本都是因为没提前准备数据。我当时准备了两个账号一个管理员账号、一个学生账号加上一组特意构造的考试数据——其中一场名额设为1专门用来演示并发下的满额提示。演示顺序建议先用管理员登录发布一场新考试设置名额为3换学生账号登录进入考试列表页报名观察按钮变成“已报名”再换另一个学生账号把名额报满第三个学生点击时看到满额提示最后切回管理员在后台看到报名人数和报名明细。这个流程不到五分钟但把登录、权限、列表、报名、状态变化、数据统计全部串起来了。写在最后的一点个人体会做完这个考试信息报名系统我最大的感觉是毕业设计的技术难度从来不是瓶颈瓶颈在于有没有把每个环节之间的衔接想明白。数据库设计没想好接口写起来就难受接口格式没约定好前端就到处打补丁并发问题没想清楚演示阶段就可能当场翻车。这恰恰是这一类选题最值得投入时间的地方。如果时间充裕你还可以在这个基础上扩展成绩查询、准考证下载、数据统计导出等功能每个扩展点都能成为答辩时的加分项。希望这篇实战记录能帮你少走一些我走过的弯路。
返回列表