学生时代最头疼的事之一,就是去听一场热门讲座。我记得那时候,学术报告厅门口经常堵成一锅粥,工作人员拿着纸质名单挨个核对,有人提前一小时去占座,有人临时来却发现座位已满。后来我帮学校信息化中心做了一个高校学习讲座预约系统,用的就是标题里这套组合:Java + Vue + SpringBoot。项目代号我习惯叫它“讲座通”,整体做下来前后端加部署差不多三周时间。
这篇文章我从头到尾拆一遍这套系统的设计与实现,包括数据库怎么建、预约并发怎么处理、Vue端路由守卫怎么搞、打包部署有哪些坑,以及我在实际开发过程中踩过的几个典型问题。不管你是准备做毕业设计,还是想在公司内部搞一个活动预约平台,这套思路都可以直接参考。
1. 项目整体设计与技术选型
1.1 讲座预约这个场景到底在解决什么
先理清业务逻辑。高校讲座预约的核心,不是“预约”本身,而是资源分配和消息触达。
在传统模式下,讲座信息通过海报、QQ群转发来通知,到场后人工签到。这种模式有三个痛点:一是讲座信息分散,学生不知道近期有什么讲座;二是到场人数不可控,热门讲座爆满、冷门讲座空场;三是签到和学分认定依赖纸质记录,统计麻烦。
所以这个系统要解决的事情,就是三件:信息集中展示、预约行为在线化、后台数据可统计。学生可以看到所有待开始的讲座,提前预约;管理员可以发布讲座、设置名额、导出预约名单;讲座开始前,系统能推送给已预约用户提醒。整体就是个标准的信息管理加轻量级营销系统。
1.2 为什么是SpringBoot + Vue 而不是别的组合
我在做这个项目之前,其实还纠结过一套单体JSP方案。毕竟高校内部系统,以前很多都是JSP+Servlet那种老架构。但后来放弃掉了,主要原因是维护成本。
JSP那套方案,前后端代码混在一起,讲师想改个首页轮播图,都得找后端开发改代码。而前后端分离之后,前端跑在Nginx上,后端就是纯粹的API服务,任何一端有改动,只要接口契约不变,就不会互相影响。这对学生团队或者少数人维护的项目来说非常友好。
SpringBoot这块没太多好争的,它已经是Java后端事实上的标准框架,内嵌Tomcat、自动配置、不需要打WAR包丢容器,一个java -jar直接跑起来。Vue这边,我选的是Vue 2 + Element UI。可能有人问,2024年你还在用Vue 2?我也知道Vue 3是趋势,但这个系统是给学校内部用的,稳定大于一切,Element UI的生态在Vue 2下最成熟,坑最少。如果你自己做新项目,上Vue 3 + Element Plus完全没问题,但如果是维护已有系统,Vue 2还能再战很多年。
1.3 系统角色与功能模块划分
我先花了一天梳理了系统角色和功能,画了两张角色用例图,这一步千万别省。最终梳理出三种角色:
- 学生用户(前端):注册登录、浏览讲座列表、查看讲座详情、预约讲座、取消预约、个人中心查看我的预约、讲座留言。
- 管理员(后台):讲座发布与管理、分类管理、预约记录查看与导出、用户管理、留言审核、统计数据。
- 超级管理员:管理员账号管理,主要是分配不同权限的管理账号,比如学术部账号只能管讲座,团委账号只能看数据。
功能模块上就清晰了,前端有首页(轮播图+最新讲座)、讲座列表(支持分类筛选、关键字搜索)、讲座详情、预约页、个人中心;后端有用户模块、讲座模块、预约模块、留言模块、文件上传模块、数据统计模块。
整个系统的功能需求就是围绕“发布—查看—预约—管理”这条主线展开的,别在前期把功能想太复杂,先跑通主流程,再逐步加东西。
2. 核心数据模型设计:一口气建好这6张表
2.1 数据库建模的思路
数据库设计是整个项目的地基。我见过太多人一上来就写代码,写着写着发现缺字段、缺关联,回头改表结构,牵一发动全身。我建议先花半天时间把表建好,后面会轻松很多。
梳理下来,这个项目至少需要6张核心表:
user用户表lecture讲座信息表lecture_category讲座分类表reservation预约记录表lecture_comment留言评论表admin管理员表
其中lecture_category和lecture是一对多关系,user和lecture是典型的多对多关系,通过reservation这个中间表关联。一个学生可以预约多场讲座,一场讲座可以被多个学生预约,这就是最典型的选课场景,只不过把课程换成了讲座。
2.2 核心表结构逐张拆解
先看用户表。我考虑到有些系统要对接学校统一身份认证,所以在表里预留了student_no字段,暂时不强制唯一,后续如果要对接教务处数据可以直接用这个字段关联。
CREATE TABLE `user` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键', `username` varchar(50) NOT NULL COMMENT '用户名', `password` varchar(128) NOT NULL COMMENT '加密后密码', `real_name` varchar(50) DEFAULT NULL COMMENT '真实姓名', `student_no` varchar(50) DEFAULT NULL COMMENT '学号', `phone` varchar(20) DEFAULT NULL COMMENT '手机号', `avatar` varchar(255) DEFAULT NULL COMMENT '头像地址', `role` tinyint(4) DEFAULT 0 COMMENT '角色:0-学生 1-管理员', `status` tinyint(4) DEFAULT 1 COMMENT '状态:1-正常 0-禁用', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;密码这里我用了BCrypt加密,这是Spring Security自带的加密器,但它不是每次加密结果都一样,你猜为什么?
BCrypt加密有个特点:每次加密同一个密码,得到的hash值都不同,但是校验的时候都能够通过。因为它把随机盐值直接拼在了hash里。这比MD5安全得多,因为同一个密码不会产生同样的密文,字典攻击很难奏效。我专门试过,同一个“admin123”,连续加密两次结果完全不同,校验却都能通过,这个细节面试很容易被问到。
讲座信息表是核心中的核心,字段比较多,我直接贴简化版本:
CREATE TABLE `lecture` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `title` varchar(200) NOT NULL COMMENT '讲座标题', `speaker` varchar(50) DEFAULT NULL COMMENT '主讲人', `speaker_intro` varchar(500) DEFAULT NULL COMMENT '主讲人简介', `category_id` bigint(20) DEFAULT NULL COMMENT '分类ID', `location` varchar(100) DEFAULT NULL COMMENT '讲座地点', `start_time` datetime DEFAULT NULL COMMENT '开始时间', `end_time` datetime DEFAULT NULL COMMENT '结束时间', `total_seats` int(11) DEFAULT 100 COMMENT '总名额', `reserved_count` int(11) DEFAULT 0 COMMENT '已预约人数', `cover` varchar(255) DEFAULT NULL COMMENT '封面图', `content` text COMMENT '讲座详情', `status` tinyint(4) DEFAULT 0 COMMENT '状态:0-未开始 1-进行中 2-已结束', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;total_seats和reserved_count这两个字段非常关键。很多人设计时容易只做一个total_seats,预约人数用count(*)去实时统计,在小数据量下没毛病,但一旦数据量大起来,每次查询都要count一次,性能堪忧。
所以我在讲座表里直接维护了一个reserved_count,每成功预约一次就+1,取消预约就-1。这种空间换时间的思路,在这个场景下非常实用。需要注意的一点是,如果系统对数据一致性要求极高,比如涉及收费或严肃的考勤登记,那必须用专用计数表,不能用这种冗余字段。
预约记录表要特别注意唯一约束的问题:
CREATE TABLE `reservation` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `user_id` bigint(20) NOT NULL COMMENT '用户ID', `lecture_id` bigint(20) NOT NULL COMMENT '讲座ID', `reserve_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '预约时间', `check_status` tinyint(4) DEFAULT 0 COMMENT '签到状态:0-未签到 1-已签到', `cancel_flag` tinyint(4) DEFAULT 0 COMMENT '是否取消:0-否 1-是', PRIMARY KEY (`id`), UNIQUE KEY `uk_user_lecture` (`user_id`, `lecture_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;uk_user_lecture这个唯一约束,在代码层面就是user_id和lecture_id的双重校验。没有这个约束,就有可能出现用户反复点击预约按钮,产生了多条预约记录,从而绕过“每人只能约一次”的业务规则。数据库层面的约束是最后一道防线,必须加上。
其余的留言表、分类表、管理员表都比较常规,留言表无非就是lecture_id关联讲座、user_id关联用户、content存内容、status做审核状态;管理员表我单独建了一张,没有和用户表混在一起,因为管理员字段(比如最后登录IP、权限等级)和学生字段差别较大,拆开更清晰。
2.3 一个关于时间字段的小提醒
讲座时间的存储,我是用的是datetime类型,但是在后端Java代码里对应的字段用的是LocalDateTime,而不是java.util.Date。
原因很简单,LocalDateTime操作更安全,不容易出现时区问题,而且配合Jackson序列化非常顺畅。你只要做好格式化配置:
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8这样就避免前端收到2024-06-01T10:30:00这种“T”字母的ISO格式,显示出来是2024-06-01 10:30:00,干净利落。
3. 后端核心模块实现与避坑细节
3.1 项目初始化与依赖管理
我建SpringBoot项目用的是Spring Initializr,版本选了Spring Boot 2.7.x,没有选3.x。这里有个很重要的原因:Spring Boot 3要求JDK 17+,而且包名从javax.servlet改成了jakarta.servlet,很多老资料和第三方组件都要适配。
用2.7.x的话,JDK 8或者11都能跑,网上搜到的教程大部分也都能直接照着用。我们给学校做的系统,没有特殊需求,2.7.18这个版本是最稳妥的选择。顺带一提,热词里提到“springboot版本太高”的问题,大概率就是Spring Boot 3的jakarta包名变更导致的。
pom.xml里核心依赖就这六个:
spring-boot-starter-web:Web基础mybatis-plus-boot-starter:MyBatis-Plus,简化CRUDmysql-connector-java:MySQL驱动lombok:省去写getter/setterjjwt:JWT工具包,做tokenhutool-all:工具库,包含各种好用的Java工具方法
3.2 登录认证与JWT Token的完整实现
登录认证我用的方案是JWT + 拦截器。相比Session方案,JWT天然适合前后端分离:后端不需要维护会话状态,用户登录后拿到一个token,每次请求带上,后端验签就行。这在分布式环境下非常友好,因为不需要会话复制。
JWT的结构分三段:Header(头部)、Payload(载荷)、Signature(签名)。头尾两端都是Base64编码,中间那段载荷,放着用户id、用户名和过期时间。最后用密钥对整个前两段做HMAC SHA-256签名,防止内容被篡改。
实际应用中,我会在用户登录成功后,生成一个有效期12小时的token:
String token = Jwts.builder() .setSubject(user.getUsername()) .claim("userId", user.getId()) .claim("role", user.getRole()) .setExpiration(new Date(System.currentTimeMillis() + 12 * 60 * 60 * 1000)) .signWith(SignatureAlgorithm.HS256, secretKey) .compact();前端拿到token后存储到localStorage,在请求拦截器里统一加上请求头:
// axios拦截器 service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers['Authorization'] = 'Bearer ' + token } return config })后端对应的拦截器会去解析这个token,解析时注意捕捉异常。token过期、被篡改、签名不匹配,都会抛出异常。我的处理方式是写一个AuthInterceptor,从请求头取出token,然后调用JWT工具类解析,解析失败直接返回401状态码,前端统一跳回登录页。
这里分享一个我踩过的坑:JWT在生成时,不要把密码或手机号放入Payload,因为Payload只是Base64编码,谁都能解码出来看。我只放userId和role,其他敏感信息一律不发。
3.3 预约并发控制——如何避免“超卖”问题
预约系统最经典的坑是并发超卖。想象一个场景:讲座只有100个座位,第99个人预约成功后,第100和第101个人同时提交预约请求,没有控制的话,系统可能让两个人都预约成功,导致实际到场人数超过名额。
我用的方案是双层校验,核心就是“先查后改”加“条件更新”。先查一下讲座状态和当前预约人数是否已满,如果满了就直接拒绝;若没满,执行更新时在SQL层面再做一次条件判断:
UPDATE lecture SET reserved_count = reserved_count + 1 WHERE id = #{lectureId} AND reserved_count < total_seats这条SQL能保证,只有当前预约数小于总名额时,更新才会成功。受影响行数如果是1,说明抢座成功;如果是0,说明在这一瞬间名额已经满了,就拒绝请求。
同时用户维度的防重复预约,靠的是数据库的唯一约束。在插入预约记录前先查一次,插入时数据库再拦一遍。这个逻辑写在线程同步、悲观锁之前,已经能挡住绝大多数并发问题。
如果你想做得更严谨,还可以加Redis分布式锁。在大学校园这个用户规模下,MySQL本身的行锁加上唯一约束已经非常稳了,我实测过用JMeter并发100个请求,最后预约记录和讲座计数完全一致,没有超卖。
3.4 统一返回类与全局异常处理
前后端分离项目,接口返回格式必须统一。我定义了一个Result类,包含code、message、data三个字段。成功返回code=200,业务失败返回code=4xx,未登录返回code=401。
没有统一返回格式的时候,前端每个接口都要判断返回结构,代码写得极其痛苦。统一之后,前端封装一个返回值工具函数,拿到code不为200的直接弹出错误提示,非常清爽。
全局异常处理也很关键。我写了一个@RestControllerAdvice切面,专门捕获业务异常、参数校验异常和系统异常。其中一个不起眼但很实用的细节是:用@RestControllerAdvice做异常拦截时,一定要在@ExceptionHandler里区分异常类型,不能一网打尽,否则会给前端返回一个巨大的堆栈信息,既不安全也不友好。
参数校验这里我用的是@Validated注解加实体类上的校验注解,比如@NotBlank、@Email,专门处理那些必须传的参数。一开始我也是自己在代码里一个个判断,写多了实在烦,后来全部改成注解校验,代码量减半,而且格式很统一。
4. 前端Vue项目实现:从零搭到能跑
4.1 初始化Vue项目与目录结构规划
我用Vue CLI 5创建的项目,基于Vue 2.6。创建命令没什么好说的,就一条vue create lecture-front。重点在于创建之后,目录结构一定要按照模块划分,而不是一把梭把组件全丢到views里。
我的目录结构是这样的:
src/ api/ # 接口请求封装,按模块拆文件 assets/ # 静态资源 components/ # 公共组件 router/ # 路由配置 store/ # Vuex状态管理 views/ # 页面组件 admin/ # 后台管理页面 lecture/ # 讲座列表、详情、预约页面 user/ # 用户登录、注册、个人中心 home/ # 首页 utils/ # 工具函数,axios封装就在这里api目录下按业务模块拆文件,每个文件里导出一个函数对应一个后端接口,比如api/lecture.js里就是getLectureList、getLectureDetail、reserveLecture、cancelReservation等。页面组件里不直接出现请求地址,这样后期改接口路径只需要改一个地方。
4.2 Axios封装与拦截器
axios封装是Vue项目里必做的一件事。我创建一个utils/request.js,负责统一配置基础URL、超时时间、请求拦截和响应处理。
几个关键点:
- 基础URL配置为
baseURL: '/api',开发环境下通过Vue CLI的代理转发请求到后端,生产环境由Nginx做同样的代理,这样前端代码完全不需要感知后端地址。 - 请求拦截器统一添加token。
- 响应拦截器统一判断状态码。
code===200直接返回response.data.data,前端调用的时候少一层嵌套;code===401说明token失效,清除本地登录态并跳转登录页;code===500弹错误提示。
超时时间我设置的是10秒,有些接口涉及文件上传,可能需要更长的时间,再单独传入配置覆盖默认值。这个小细节如果不在封装时考虑进去,后期遇到大图片上传就会很尴尬。
4.3 路由配置与登录守卫
路由方面,我用的是Vue Router的路由懒加载,每个页面组件都是一个动态导入,大大减少首屏加载时间。
前端路由分两块:普通用户的路由和后台管理的路由。后台管理的所有组件都放在views/admin/目录下,路由路径以/admin开头。
对应的路由守卫逻辑是:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') const role = localStorage.getItem('role') if (to.meta.requiresAuth && !token) { next('/login') return } if (to.path.startsWith('/admin') && role !== '1') { next('/') return } next() })有两个细节值得注意:第一,判断登录状态不能只靠localStorage里有没有token,理论上token可能已过期。但前端再精确的判断都已经没有意义,后端会拦截,前端只是做体验优化。第二,后台管理需要双重判断,既要有token,还要角色是管理员,否则直接跳回首页。这属于前端路由级权限控制,真正安全的后端接口校验是必不可少的。
4.4 讲座列表与预约页面的实现细节
讲座列表页是整个系统访问量最大的页面,我做了分类筛选和搜索。分类和搜索都挂到URL的query参数上,比如/lecture/list?keyword=AI&categoryId=3,这样刷新页面后状态还在,也方便分享链接给同学。
列表数据通过watch监听路由变化后重新请求。这里有个小坑:直接监听$route对象时,如果只变了query参数,组件实例是复用的,不会触发created钩子,必须在watch里加上handler。我用的方案是:
watch: { '$route.query': { handler() { this.fetchList() }, immediate: true } }预约按钮的逻辑,核心是前置判断。如果用户没登录,点击预约跳转到登录页;如果讲座已满,按钮置灰显示“已满员”;如果该用户已经预约过,按钮显示“已预约”,并且点击可以跳转到取消预约确认框。
卡时间点这件事,在预约按钮上也要处理。系统当前时间如果已经晚于讲座开始时间,就不允许预约了。这个判断在后端也做了,防止有人绕过前端直接调接口预约已开始或已结束的讲座。两边都做校验,前端方便用户快速感知,后端保证数据安全。
4.5 后台管理页面的表格与弹窗处理
后台管理页面几乎全是表格。我基于Element UI的el-table来搭,每张表配一个搜索区域、一个新增/编辑弹窗、一个删除确认操作。
管理讲座时,新增和编辑共用一个弹窗组件,用editType字段区分是新增还是编辑。弹窗里用到el-form,表单校验规则绑定在rules属性上,比如讲座标题必填、开始时间必填、总名额必须大于0。
上传封面图用的是Element UI的el-upload组件,action指向后端的/api/upload接口。后端处理上传的关键是限制文件大小和类型,防止有人上传恶意文件。我限制的是图片类型jpeg/png/jpg/webp,单张不超过5MB。
5. 部署上线:从打包到Nginx配置
5.1 后端打包与启动
后端打包需要先确保测试通过,然后执行mvn clean package -DskipTests。跳过测试是为了打包速度快,但前提是你确信代码没问题。打包出来的jar包通常在target/目录下,大小约60-80MB。
启动之前要确认好生产环境的数据库连接配置和Redis配置。我习惯用Spring的多环境配置:
spring: profiles: active: prodapplication-prod.yml里放生产环境的数据库地址、端口、密钥等信息。这样开发环境、测试环境、生产环境的配置完全隔离,切换环境只需要改一个active。
后台启动用nohup java -jar lecture-admin.jar > app.log 2>&1 &,这个命令前台直接执行会占用终端,关闭终端服务就挂了。生产环境更正式的做法是用systemd托管,但对于学校内部系统,nohup基本够用。
5.2 前端打包与Nginx部署
前端打包执行npm run build,产物在dist/目录。这个时候有个常见问题:打包后布局异常、图片访问404。