
去年秋天有个做语言培训的客户找到我们说要搞一个语言考试信息报名系统覆盖考试公告、在线报名、资料审核、费用缴纳、准考证下载、成绩查询这一整个链条。当时团队内部很快就敲定了技术栈Java SpringBoot 做后端Vue3 做前端MyBatis 负责数据库持久层MySQL 存数据前后端完全分离。项目看着不算复杂但真正动手之后才发现报名系统这种“看起来就是CRUD”的业务里面埋着很多并发、状态流转、权限控制的坑。这篇文章我把整个系统的设计与实现过程完整复盘一遍包括数据库表结构、后端核心代码、前端页面逻辑以及我调试过程中踩过的一堆坑希望能给准备做同类系统的同学一个能直接参考的路线。不管你是正在学Java想要一个完整的实战项目还是工作中接到类似“考试报名”“活动报名”的需求这篇文章都值得看完我会把这些课题拆开揉碎来讲。1. 整体架构设计与技术选型思路1.1 为什么选前后端分离我最早接触这类系统时还很习惯用 Thymeleaf 这类服务端模板渲染一个 SpringBoot 应用把页面和接口全包了。但换到这个项目时我毫不犹豫选了前后端分离原因很实际第一报名系统有明确的“管理后台”和“用户前台”两个场景管理员需要考试计划配置、报名审核、订单管理、数据统计考生需要信息填写、支付、准考证下载两边的界面逻辑差异极大混在一个工程里后期会非常痛苦。第二需求方后来果然提了一堆前端交互优化比如报名步骤条、实时校验身份证号、倒计时提示、移动端适配。前后端分离之后前端改动完全不影响后端接口并行开发效率高非常多。第三部署上也灵活。后端打包成 jar 部署到服务器前端打包成静态文件挂到 Nginx或者说扔到对象存储都行。后面就算要扩容前端静态资源也能直接套 CDN很省事。这套系统用的核心栈是后端SpringBoot 2.7.x MyBatis MySQL 8.0 Redis前端Vue3 Vite Pinia Vue Router Element Plus接口文档Swagger / Knife4j权限认证JWT 拦截器为什么 SpringBoot 版本选 2.7.x 而不是 3.x这里我多说一句SpringBoot 3.x 要求 JDK17 起步而且底层是 Jakarta EE很多老版本的第三方依赖不兼容。我当时接手项目时客户服务器是 JDK8团队的开发机器也是 JDK8所以选了 2.7.x。如果你的环境已经是 JDK17那直接上 SpringBoot 3.x 也完全没问题但要注意 MyBatis、PageHelper 这些依赖的版本都得换成对应的新版本不然启动时会报一堆 ClassNotFound 之类的错。1.2 为什么选择 MyBatis 而不是 JPA很多 Java 开发者喜欢用 Spring Data JPA因为它快、省事表结构一建好CRUD 接口基本不用写。但在这个项目里我很坚持用 MyBatis原因有三点一是报名系统的查询场景特别杂。后台要按考试名称、报名状态、时间段、身份证号模糊查询前台要根据考试类型筛选、按热门程度排序这种多条件动态 SQLMyBatis 的if、where标签写起来简直是天生契合而在 JPA 里你会被一堆 Specification 和 QueryMethod 绕晕。二是性能可控。MyBatis 的 SQL 完全由自己编写要加索引提示、要改 SQL 执行计划、要针对大表做分页优化全部手写最直接。JPA 自动生成的 SQL 在复杂关联查询时容易出现性能问题而且排查起来你还要先去理解它生成逻辑太浪费时间。三是这个项目里我计划用 MyBatis 拦截器做数据权限控制和自动填充字段MyBatis 对 SQL 层面的拦截非常灵活用起来很顺手。后面我会专门讲到。MySQL 方面这个项目选 8.0 版本。客户原本还在用 5.7但我们做字符集排序规则时发现 8.0 对 utf8mb4、对窗口函数、对 JSON 类型的支持都好很多而且 MySQL 8.0 默认字符集就是 utf8mb4省去一堆乱码问题。数据库驱动也要注意com.mysql.cj.jdbc.Driver是新版的驱动类名别再用com.mysql.jdbc.Driver了8.0 里这个老类名已经废弃。1.3 系统功能模块怎么拆动手写代码之前我先把需求拆成了下面几个模块用户模块考生注册、登录、个人信息维护、密码找回考试信息模块考试计划发布、考试类型管理、公告通知报名模块选择考试计划、填写报名信息、生成报名记录、资格校验订单/支付模块待支付订单、模拟支付回调、退款处理审核模块管理员审核报名资料通过/驳回准考证模块审核通过后生成准考证号考生下载准考证成绩模块成绩导入、考生查询、成绩单打印如果只是做个演示 Demo那模块可以精简成 4-5 个。但如果要真正上线给培训机构用这几块是缺一不可的。拆分的好处是后期好分工前端一个同学负责用户端一个同学负责管理端后端按模块分包互不干扰。2. 数据库设计与核心表结构2.1 核心表设计报名系统看起来简单但表结构设计上有很多细节值得琢磨。我直接把当时设计的核心表列出来你可以参考。用户表sys_userCREATE TABLE sys_user ( id bigint NOT NULL AUTO_INCREMENT, username varchar(64) NOT NULL COMMENT 登录名, password varchar(128) NOT NULL COMMENT BCrypt加密后的密码, real_name varchar(64) DEFAULT NULL COMMENT 真实姓名, id_card varchar(32) DEFAULT NULL COMMENT 身份证号, phone varchar(20) DEFAULT NULL COMMENT 手机号, email varchar(128) DEFAULT NULL COMMENT 邮箱, role varchar(20) NOT NULL DEFAULT USER COMMENT 角色: USER/ADMIN, status tinyint NOT NULL DEFAULT 1 COMMENT 状态: 1正常 0禁用, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username), KEY idx_id_card (id_card) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表;考试计划表exam_planCREATE TABLE exam_plan ( id bigint NOT NULL AUTO_INCREMENT, exam_name varchar(128) NOT NULL COMMENT 考试名称, exam_type varchar(32) NOT NULL COMMENT 考试类型: CET4/CET6/TOEFL/IELTS..., register_start_time datetime NOT NULL COMMENT 报名开始时间, register_end_time datetime NOT NULL COMMENT 报名结束时间, exam_time datetime NOT NULL COMMENT 考试时间, total_quota int NOT NULL COMMENT 总名额, remain_quota int NOT NULL COMMENT 剩余名额, fee decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT 报名费用, status tinyint NOT NULL DEFAULT 0 COMMENT 状态: 0未发布 1报名中 2已结束, description text COMMENT 考试说明, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_type_status (exam_type, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT考试计划表;报名表exam_registration是核心中的核心CREATE TABLE exam_registration ( id bigint NOT NULL AUTO_INCREMENT, registration_no varchar(64) NOT NULL COMMENT 报名流水号, user_id bigint NOT NULL COMMENT 用户ID, plan_id bigint NOT NULL COMMENT 考试计划ID, exam_number varchar(64) DEFAULT NULL COMMENT 准考证号, status tinyint NOT NULL DEFAULT 0 COMMENT 状态: 0待支付 1已支付待审核 2已审核通过 3已驳回 4已取消 5已退款, pay_amount decimal(10,2) DEFAULT NULL COMMENT 支付金额, pay_time datetime DEFAULT NULL COMMENT 支付时间, check_remark varchar(255) DEFAULT NULL COMMENT 审核备注, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_user_plan (user_id, plan_id), UNIQUE KEY uk_registration_no (registration_no), KEY idx_plan_status (plan_id, status), KEY idx_user (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT考试报名表;注意这里我加了两个唯一索引uk_user_plan防止同一个用户对同一个考试计划重复报名uk_registration_no保证报名流水号唯一。这比在代码里先 select 再 insert 判断要靠谱得多原因后面讲并发的时候详细说。订单表pay_orderCREATE TABLE pay_order ( id bigint NOT NULL AUTO_INCREMENT, order_no varchar(64) NOT NULL COMMENT 订单号, registration_id bigint NOT NULL COMMENT 报名ID, user_id bigint NOT NULL COMMENT 用户ID, plan_id bigint NOT NULL COMMENT 考试计划ID, amount decimal(10,2) NOT NULL COMMENT 订单金额, pay_status tinyint NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已取消, pay_time datetime DEFAULT NULL, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_user (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT支付订单表;成绩表exam_score我单独建因为成绩是考试结束后管理员导入的和报名流程相对独立。2.2 状态机设计报名状态的流转报名状态这块我建议你在设计阶段就画清楚状态流转图不然后期前后端联调时一定会出现“状态对不上”的扯皮问题。我这套系统的状态流转是这样的0待支付用户提交报名信息生成报名记录此时锁定名额用户需要在30分钟内完成支付1已支付待审核支付成功后进入审核队列2已审核通过管理员审核通过生成准考证号3已驳回管理员驳回用户可以修改信息重新提交4已取消用户主动取消或超时未支付系统自动取消5已退款管理员取消报名并退款核心原则是状态只能按箭头方向流转不允许跳转或者倒退。比如待支付只能走到已支付待审核或已取消不能直接跳到已审核通过。为了实现这个约束后端 Service 层我封装了一个状态变更方法先查出当前状态再用枚举判断是否允许目标状态不允许就抛异常。不要图省事直接写UPDATE ... SET status 1 WHERE id ?一旦业务规则变化这种代码就变成定时炸弹。2.3 索引设计与并发控制根据报名的实际流程查询入口主要是用户查自己的报名记录、管理员按状态查报名列表、按考试类型筛选考试计划。所以索引设计基本覆盖以上三类查询。这个系统最核心的并发问题是“抢名额”假设一个考试计划只剩 10 个名额但同一时刻有 100 个人提交报名怎么保证不会超卖我的做法分两层第一层数据库层面的防超卖。扣减名额时使用条件更新UPDATE exam_plan SET remain_quota remain_quota - 1 WHERE id #{planId} AND remain_quota 0如果影响行数为 1说明扣减成功影响行数为 0说明名额已经没了。这个原子操作比先 select 再 update 安全得多。第二层唯一索引兜底。就算扣减名额成功同一个用户重复提交报名uk_user_planuser_id plan_id也会直接报 DuplicateKey 异常我们捕获这个异常后返回“您已报名该考试”。这两层配合起来我没有用 Redis 分布式锁也能保证基础场景不超卖。当然如果报名量级特别大比如几千人瞬间抢几百个名额那建议你把remain_quota放到 Redis 里用 Lua 脚本做原子扣减再异步同步到数据库。但那种量级对语言考试报名来说一般用不到。3. 后端核心模块实现SpringBoot MyBatis3.1 工程结构与分层后端工程我用的标准 Maven 结构包名按业务模块划分com.example.exam ├── common // 通用结果封装、异常处理、工具类 ├── config // 配置类MyBatis、Redis、Cors、Swagger ├── controller // 接口层 ├── service // 业务层 ├── mapper // MyBatis Mapper接口 ├── entity // 数据库实体 ├── dto // 请求/响应对象 ├── vo // 视图对象 └── interceptor // JWT登录拦截器、MyBatis拦截器很多新手喜欢把 entity、dto、vo 混在一起用项目小的时候没感觉一旦需求复杂了就会出现“返回给前端的字段里带着 password”、或者“请求参数里混着不可修改的字段”这种问题。我建议从一开始就分开entity 对应数据库表dto 接收前端参数vo 返回前端数据。3.2 MyBatis 使用要点XML、分页插件、缓存MyBatis 有两种写 SQL 的方式注解和 XML。在这个项目里复杂 SQL 我全部写在 XML 里简单查询用注解。这样做的理由很简单动态 SQL 写在注解字符串里可读性极差而且 XML 修改后可以热加载调试效率高很多。先看一下分页插件的配置。这个系统里面管理后台的报名列表、考试计划列表、用户列表都需要分页我用的是 PageHelper。SpringBoot 里引入依赖dependency groupIdcom.github.pagehelper/groupId artifactIdpagehelper-spring-boot-starter/artifactId version1.4.7/version /dependency然后在 application.yml 里配置pagehelper: helper-dialect: mysql reasonable: true support-methods-arguments: true params: countcountSql关键参数解释一下helper-dialect指定数据库方言PageHelper 会自动生成 MySQL 的LIMIT分页语句reasonable设为 true 后如果你请求的页码超过总页数它会自动归到最后一页防止前端传一个 pageNum999 把数据库查崩support-methods-arguments支持从方法的参数中获取分页参数使用方式也很简单在 Mapper 查询前调用PageHelper.startPage(pageNum, pageSize)紧接着的第一条查询就会被拦截并自动分页Service public class RegistrationServiceImpl implements RegistrationService { Autowired private ExamRegistrationMapper registrationMapper; public PageInfoRegistrationVO pageRegistrations(int pageNum, int pageSize, String status) { PageHelper.startPage(pageNum, pageSize); ListRegistrationVO list registrationMapper.selectByCondition(status); return new PageInfo(list); } }这里有个很经典的坑PageHelper.startPage必须紧跟 Mapper 查询方法中间不能有其他查询否则分页会作用到别的 SQL 上。比如你在 startPage 之后先查了一个用户列表那分页就跑到用户列表上去了报名列表反而全量返回。这种情况在代码 review 时经常被忽略我建议你在 Service 实现里保证 startPage 和查询之间只隔一行。再说 MyBatis 缓存。MyBatis 默认一级缓存是 SqlSession 级别的同一个 SqlSession 中相同 SQL 第二次查询会命中缓存。但是 Spring 管理的 Mapper 每次操作都会新建 SqlSession所以一级缓存基本等于失效。二级缓存默认是关闭的如果要开启在 XML 里加cache/标签即可。但我要特别提醒报名系统这类数据实时性要求高的业务千万不要轻易开二级缓存。比如你查“剩余名额”第一次查是 10缓存了第二次查命中缓存还是 10但实际上已经被抢走 5 个了。这种脏数据对用户来说就是灾难。MyBatis 缓存适合配置类、字典表之类的低频变动数据不适合余额、库存、名额这类高并发更新数据。如果你确实想优化热点查询建议用 Redis 做短时间缓存并且设置精确的失效策略。3.3 使用 MyBatis 拦截器做公共字段自动填充后台管理列表经常需要记录创建时间、更新时间、操作人这种公共字段。以前每次 insert 都要手动 set 几个值太枯燥。我这次用了 MyBatis 的拦截器来自动填充。实现思路是写一个实现了Interceptor接口的类拦截Executor的 update 方法在 SQL 执行前根据 SQL 类型反射填充字段Component Intercepts({ Signature(type Executor.class, method update, args {MappedStatement.class, Object.class}) }) public class AutoFillInterceptor implements Interceptor { Override public Object intercept(Invocation invocation) throws Throwable { MappedStatement mappedStatement (MappedStatement) invocation.getArgs()[0]; Object parameter invocation.getArgs()[1]; SqlCommandType sqlCommandType mappedStatement.getSqlCommandType(); if (parameter ! null sqlCommandType SqlCommandType.INSERT) { // 反射获取实体对象的 createTime/updateTime 字段如果为 null 则填充当前时间 } else if (parameter ! null sqlCommandType SqlCommandType.UPDATE) { // 反射填充 updateTime } return invocation.proceed(); } }这个方案比在每个 Service 里手动 set 要优雅得多。不过要注意几点拦截器只对实体参数生效如果你的 insert 方法传的是一个 Map反射就不会处理拦截器会影响所有 Mapper包括那些没有公共字段的表所以反射前要先判断字段是否存在否则会报错。3.4 登录认证与权限控制用户端和管理端共用一个登录接口通过role字段区分身份。登录成功后会生成 JWT前端把 token 存在本地每次请求放到 Header 的Authorization里。后端写一个 HandlerInterceptor拦截 /api/** 下除登录注册之外的所有请求从 token 里解析用户信息然后放到 ThreadLocal 里供后续使用。JWT 我建议只存id、username、role三个字段不要存太多敏感信息因为 JWT 只是签名不是加密payload 是能被解开的。密码校验用的是 BCryptPasswordEncoder不要用 MD5原因不用多说了。权限控制上管理员接口我用了一个简单的RequireAdmin注解配合拦截器只允许 role 为 ADMIN 的用户访问。如果你想要更精细的权限控制比如按钮级别的权限可以去集成 Spring Security 或者 Sa-Token但报名系统这种业务角色就两个手写拦截器完全够用。3.5 接口设计与联调规范接口命名我坚持 R ESTful 风格比如GET /api/exam/plan- 查询考试计划列表POST /api/registration- 提交报名POST /api/registration/{id}/pay- 模拟支付GET /api/registration/my- 查看我的报名列表POST /api/admin/registration/{id}/approve- 管理员审核通过响应结果统一封装成ResultT{ code: 200, message: success, data: { ... } }前端用 axios 拦截器统一处理code ! 200的情况弹错误提示这个约定简单直接比把错误信息裸放在 HTTP 状态码里要方便处理很多。4. Vue3 前端从工程搭建到核心业务页面4.1 Vite 工程搭建与关键依赖前端用 Vite 搭建 Vue3 工程命令很简单npm create vitelatest exam-frontend -- --template vueVite 启动速度比 Webpack 快很多开发体验好得多。进入项目后安装路由、状态管理和 UI 库npm install vue-router pinia element-plus axiosVue3 推荐使用组合式 API页面里大量使用script setup语法代码非常简洁。示例环境下我用了 Element Plus 作为 UI 组件库表格、表单、日期选择器、步骤条都有现成组件开发后台管理页面非常快。4.2 用户端核心页面逻辑用户端最重要的页面是考试列表和报名流程。考试列表页展示所有已发布的考试计划考生可以看到考试名称、考试时间、报名截止时间、费用、剩余名额。剩余名额我做了实时刷新每 10 秒调一次接口虽然有点粗暴但报名高峰时用户就要看到最新名额这个交互不能省。报名流程用 Element Plus 的el-steps组件做了三步引导确认考试信息填写报名信息提交并支付报名表单里包含姓名、身份证号、手机号、照片上传等字段。身份证号在前端要做格式校验用简单的正则 加权校验算法避免明显错误的数据进入后端。提交报名后后端会返回报名记录 ID 和订单号前端跳转到支付确认页。这个项目我接的是模拟支付没有真正对接支付宝/微信但支付的接口流程和真实的一致生成订单 - 用户确认支付 - 后端模拟支付成功回调 - 更新报名状态。这里有个体验细节要注意提交报名后名额就锁定了但用户可能放弃支付。所以前端支付页面要显示支付倒计时比如 30 分钟倒计时结束未支付的话后端定时任务会把过期未支付订单取消并释放名额。4.3 管理后台核心页面逻辑管理后台用 Vue3 Element Plus 搭了一套常见的后台布局左侧菜单、顶部导航、内容区域用路由嵌套来实现。考试计划管理页面支持新增、编辑、发布、下线考试计划。发布时表单里要校验报名开始时间不能晚于报名结束时间考试时间不能早于报名结束时间。这些校验前端做一遍后端 Service 层再做一遍防止绕过前端直接调接口。报名审核页面是后台使用频率最高的页面列表展示所有已支付待审核的报名记录管理员可以查看报名详情、通过审核、或填写驳回理由。审核通过时后端会生成准考证号这个号码我设计为“考试类型代码 年份 序号”比如CET4-2025-000123保证唯一即可。成绩管理页面支持管理员按照考试计划导入成绩我用的是 Excel 导入后端用 EasyExcel 解析导入后考生端就能查到成绩了。4.4 Pinia 状态管理与路由守卫用户登录信息我放在 Pinia 里管理。用户登录成功后前端把用户信息存到 Pinia store并用 localStorage 持久化刷新页面也能恢复登录状态。路由守卫很简单router.beforeEach 里判断router.beforeEach((to, from, next) { const userStore useUserStore() if (to.meta.requiresAuth !userStore.isLogin) { next(/login) } else if (to.meta.requiresAdmin userStore.role ! ADMIN) { next(/403) } else { next() } })axios 请求拦截器里统一添加 token响应拦截器里统一处理 401 状态码并跳转登录页service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config })这里比较容易踩坑的是Vue3 里使用reactive定义对象如果你直接解构出来赋值给局部变量会丢失响应式。要用storeToRefs才能解构 Pinia 中的 state我当时就是因为这个报错排查了半天。5. 常见问题与排查技巧实录5.1 MyBatis dynamic SQL 相关的坑动态 SQL 是 MyBatis 的王牌但写不好也是 Bug 温床。我这边最典型的问题出现在多条件查询里where标签其实能自动处理多余的 AND/OR但如果你在if外面自己拼了WHERE 11虽然能跑但 SQL 状态很差也很难看。正确写法select idselectByCondition resultTypecom.example.exam.vo.RegistrationVO SELECT r.*, p.exam_name, p.exam_type, u.real_name FROM exam_registration r LEFT JOIN exam_plan p ON r.plan_id p.id LEFT JOIN sys_user u ON r.user_id u.id where if teststatus ! null AND r.status #{status} /if if testexamType ! null and examType ! AND p.exam_type #{examType} /if if testkeyword ! null and keyword ! AND (u.real_name LIKE CONCAT(%, #{keyword}, %) OR u.id_card LIKE CONCAT(%, #{keyword}, %)) /if /where ORDER BY r.create_time DESC /select注意#{keyword}是预编译参数不能直接写成%${keyword}%${}会做字符串拼接有 SQL 注入风险。除非你是要动态传入表名、字段名否则一律用#{}。5.2 MyBatis Update 执行慢的排查有一次测试反馈更新报名状态特别慢审计日志拿过来一看一条 update 语句耗了 1.2 秒。当时第一反应是 SQL 没走索引通过 EXPLAIN 查看执行计划发现确实走了主键索引但耗时依然很高而且 CPU 占用非常高。后来定位发现问题出在更新操作导致的锁等待。报名高峰时同一考试计划的行会被多个报名请求修改特别是UPDATE exam_plan SET remain_quota remain_quota - 1 WHERE id #{planId} AND remain_quota 0这一条并发一高后面的 update 都在等锁单条 SQL 执行时间自然变长。解决办法有两个方向一是把更新拆成两个阶段先在内存里做预扣减再异步批量落库二是用更细粒度的锁比如改成按用户维度加锁同一考试计划下不同用户不互相等待。对这个项目来说报名量没有夸张到必须上异步所以最终保留了数据库的原子更新但把事务范围缩小了避免在事务里做太多无关操作。5.3 排查 MyBatis 缓存导致的数据不一致有次管理后台改了考试计划的报名截止时间结果前端用户看到的还是旧时间刷新也没用。一开始以为是前端有缓存后来查后端日志发现是 MyBatis 二级缓存没关导致同一 Mapper 的查询结果被缓存了。确认关掉二级缓存后再查就正常了。这里提醒一句排查数据不一致的时候第一件事就是看缓存。MyBatis 的一级缓存、二级缓存、Redis 缓存、浏览器缓存每一层都可能出问题。建议开发环境把 MyBatis 的 SQL 日志打印出来配置很简单logging: level: com.example.exam.mapper: debug这样控制台会打印每条 SQL 和参数排查问题效率翻倍。5.4 并发报名时的重复提交某个考试计划开放报名时有用户反馈系统提示“报名失败请重试”但数据库里实际已经有一条报名记录了。原因就是用户点了多次提交按钮第一次请求成功了第二次请求被唯一索引拦截我们捕获重复键异常后返回了通用失败提示。解决方式是双管齐下前端提交按钮在请求期间禁用防止用户重复点击后端在捕获 DuplicateKeyException 时先查这条已有报名记录的状态如果状态正常就直接返回“您已报名成功”而不是报错。这样用户体验就一致了。5.5 SpringBoot 配置和依赖版本问题项目开发过程中有个同事本地跑不起来报一堆奇怪的依赖冲突最后发现是 SpringBoot 版本不一致有人用了 2.7.5有人用了 3.0.2。我建议项目一开始就用 Maven 的 dependencyManagement 统一管理版本所有模块引入同一个父 POM。你自己做项目时也建议直接指定 SpringBoot 父依赖避免出现“我机器上能跑你机器上跑不了”的鬼故事。还有一次部署到服务器时报错说找不到数据源驱动类查看 jar 包发现 MySQL 驱动依赖被排除了。原因是项目里引入了多个数据源相关依赖Maven 的依赖仲裁把mysql-connector-java排掉了。解决办法是显式声明 MySQL 驱动版本不要再依赖 SpringBoot 的自动管理。5.6 Vue3 前端的常见问题Vue3 版本迭代很快我在项目初期就踩过一次createApp写法的问题官方文档和视频教程用的版本不一致导致挂载方法都变了。建议以官网文档为准npm 安装时看清楚版本号。还有一个很常见的坑是vue-router版本Vue3 要用 4.xVue2 对应 3.x很多老教程写的是new VueRouter在 Vue3 里就成了createRouter。如果你拿到一个项目报VueRouter is not a constructor基本就是版本不匹配。组件通信方面Vue3 组合式 API 里建议优先用defineProps、defineEmits和provide/inject不要再纠结$emit和.sync那些老写法。如果你引入了 TypeScript还要注意泛型组件的写法这个在若依等开源项目里也经常有人问 ts 报错的问题多半是tsconfig.json的路径别名没配好。6. 部署上线与性能优化要点6.1 前端构建与 Nginx 配置前端代码开发完成后执行npm run build生成dist目录把里的静态文件传到服务器 Nginx 的站点目录即可。一个关键配置是所有/api的请求要反向代理到后端服务避免前端跨域location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }这里注意proxy_pass后面有没有/的区别http://127.0.0.1:8080表示不替换路径请求/api/exam/plan会转发到后端/api/exam/plan如果写成http://127.0.0.1:8080/就会变成/exam/plan很容易踩坑。6.2 后端打包与启动后端用 Maven 打包mvn clean package -DskipTests生成的可执行 jar 包可以直接跑nohup java -jar exam-system.jar --server.port8080 生产环境建议加 JVM 参数比如-Xms512m -Xmx1024m根据服务器内存来。需要读取外部配置就用--spring.config.location/path/to/application.yml不要把数据库密码这类敏感配置打进 jar 包里。6.3 性能优化上线第一轮压测时报名高峰时接口 TPS 不高瓶颈主要在数据库连接数上。我把 HikariCP 连接池的最大连接数从默认的 10 调到了 30并且给热点查询加了 Redis 缓存考试计划列表缓存 10 秒公告缓存 5 分钟压测结果好了不少。如果你的报名并发确实高可以考虑把扣减名额的操作前置到 Redis 用 Lua 脚本然后异步同步数据库。不过还是要泼一盆冷水技术上不要过度设计。语言考试报名系统量级再大也多不到电商秒杀那一步先把数据库表结构设计好、索引建对、事务范围控制好性能不会差到哪里去。7. 项目复用与扩展建议如果你现在正打算做一个类似的“考试报名系统”或者“活动报名系统”我建议你把上面的表结构和代码框架直接复制过去改能省不少时间。扩展方向上最重要的两个需求一般是在线支付和短信通知。在线支付对接支付宝/微信核心就是下单、回调、验签三个环节报名系统里把模拟支付那部分替换成真实支付 SDK 即可。短信通知可以用阿里云短信或者腾讯云短信在报名成功、审核通过、考试提醒这几个节点触发。另外一个容易被忽略的点是数据统计页面管理后台最好加一个面板展示报名总人数、待审核数、考试通过率这些指标。统计 SQL 不复杂用 GROUP BY COUNT 就行但配合报表图表组件客户体验会明显提升。这个项目做完之后我对 SpringBoot MyBatis 这套组合在中小型管理系统中的掌控力又加深了不少。特别是并发控制和状态机设计这一块很多问题不是业务复杂而是你在一开始就没想到边界情况。建议所有做报名类系统的同学开工前先把表结构和状态流转图画出来这会省掉后面至少一半的麻烦。