☰
SpringBoot高校讲座预约系统:并发控制与签到核销实战
2026/10/22 8:12:26 网站建设 项目流程

第一次接这个活时,我以为就是做个网页版报名表。直到辅导员把需求清单发过来——不能重复预约、满员自动候补、现场核销防代签、按学院统计到场率,我才意识到这其实是个业务约束密集的系统。最终落地的这个SpringBoot 高校学习讲座预约管理系统,包含学生预约、管理员发布、签到核销、数据统计四块核心能力,从数据库设计到并发控制,再到部署上线,踩了不少坑,也沉淀了一些值得复用的经验。

如果你正在做课程设计、毕业设计,或者单纯想给学院活动管理做个趁手工具,这篇文章可以直接当参考。我讲的不只是功能列表,而是这套系统从需求到实现背后的完整思路:为什么表要这么设计、为什么并发扣减要用一条SQL、为什么签到功能必须搭配统计逻辑才能闭环。

1. 需求梳理与业务边界:讲座预约不是做个增删改查

1.1 先搞清楚到底要解决哪些痛点

以前学院办讲座,靠的是班级群接龙。发通知的人喊一声“名额有限”,群里几十条“+1”刷过去,最后统计名单全靠班长手动整理。问题很快暴露出来:有人重复接龙占了两个名额,有人报名了不来也不说一声,讲座当天教室空了一半,签到时又是传一张纸挨个划名字,代签毫无成本。

这套系统要解决的核心问题,我归纳成四类:

  • 名额控制:一场讲座容量有限,预约的人多了怎么办,至少不能出现“报名人数超过座位数”的情况。
  • 冲突检测:一个学生不能在同一个时间段预约两场讲座,否则必然有一场缺席。
  • 签到防伪:线下到场怎么确认“来的确实是这个人”,并且能自动累计出到场数据。
  • 数据统计:管理员要能一眼看出哪场讲座预约率高、哪些学院参与度高、哪类讲座受欢迎。

你会发现,前三个是面向学生和现场管理的功能问题,第四个是面向管理决策的数据问题。设计系统时,如果只做“预约”和“后台管理”两张页面,那功能是残缺的。这四个维度从第一天就要写进需求文档里。

1.2 三种角色和两条主线流程

系统角色我一开始设了三个:学生、管理员、教师。后来做需求评审时发现,教师角色在实际使用中确实有存在感,但核心操作不多,我只是让他们能查看自己被预约的讲座概况,不涉及复杂审批。

角色核心能力边界
学生浏览讲座、预约、取消预约、签到、查看个人记录不能发布讲座、不能查看他人预约信息
管理员发布讲座、上下架、查看名单、导出数据、取消报名记录不能代替学生预约(特殊情况下可手动补录)
教师查看讲座预约人数、签到率不参与预约处理

两条主线流程,一条是学生侧:查看讲座 → 预约 → 到场签到 → 查看记录;另一条是管理侧:创建讲座 → 发布开放 → 关闭报名 → 现场核销 → 查看统计。这两条线通过预约记录表和数据统计SQL串起来。

1.3 必须提前钉死的业务规则

这类系统最怕需求不清。我做过太多项目,做到一半才发现“哦原来这里规则是那样的”,返工成本极高。以下是开工前就要定下来、并且写进接口文档的规则:

  • 同一学生对同一场讲座只能预约一次,预约后状态不可重复激活。
  • 讲座容量靠“已预约人数 < 容量”判断,达到上限后只能进入候补,不能强行预约。
  • 距离开讲不足 2 小时的预约不支持在线取消,避免管理者来不及释放名额。
  • 讲座只能在“报名中”状态被预约/取消,草稿、进行中、已结束状态不可操作。
  • 已签到的预约不能取消,已取消的预约不能签到。

这些规则看着简单,但对应到数据库约束和代码判断上,每一行都很关键。比如“不能重复预约”靠唯一索引兜底,“讲座只能报名中预约”靠状态字段过滤。

2. 技术选型与工程骨架:一套能跑完课程也能扛住毕设的架构

2.1 为什么选 Spring Boot + MyBatis Plus + MySQL 这套组合

技术选型我没有走激进路线。Spring Boot 是目前高校项目里最常见的后端框架,生态成熟、自动配置能力强,资料多,遇到问题基本能搜到答案。MyBatis Plus 在常规 CRUD 上能少写一堆 XML,内置分页插件、逻辑删除,非常适合这种管理系统的开发节奏。数据库用 MySQL,InnoDB 引擎,既满足事务需求,又有行锁支持并发扣减。这个组合最大的优点是“老师看得懂、答辩能说清”,同时又不会因为太玩具而被质疑工程性。

版本方面,我建议如果是 2.7.x 就用 JDK 8 或 11,完全够用。如果选 Spring Boot 3.x,JDK 必须是 17 以上。我这里用的是 2.7.6 + JDK 8 + MySQL 5.7,稳定性优先,后面部署到服务器也不折腾。

提示:MyBatis Plus 的分页插件需要手动配置 PaginationInnerInterceptor,不配置的话 Page 查询会直接查出全表数据且不分页,这是一个非常隐蔽的坑。

2.2 后端工程目录怎么划分

工程目录我坚持按“模块职责”划分,而不是按“技术层”划分。很多新手喜欢 controller、service、mapper 三个包平铺所有类,几百个类堆在一起没法看。我实践中更偏向先按业务模块分大包,再在包内做分层:

src/main/java/com/example/lecture/ ├── controller/ │ ├── admin/ # 管理端接口:讲座管理、名单管理、统计 │ └── student/ # 学生端接口:预约、取消、签到、列表 ├── service/ # 业务逻辑层 ├── mapper/ # MyBatis Plus 的 Mapper 接口 ├── entity/ # 数据库实体 ├── common/ # 统一返回、异常处理、常量 ├── config/ # MyBatisPlusConfig、CorsConfig、WebMvcConfig ├── interceptor/ # JWT 登录拦截器 └── util/ # JwtUtil、CheckinCodeUtil

admin 与 student 分开是我强烈建议的做法。它天然形成了权限边界:拦截器按路径前缀做鉴权,看到/api/admin/就校验管理员角色,看到/api/student/就校验学生身份,不需要在方法里写一堆 if else。

2.3 统一返回、异常处理和登录拦截

统一返回结构是系统开发里一定要做的事。没有它,前端处理响应时会出现“有的接口返回{code:200,data:...},有的接口直接返回业务对象”这种混乱。

@Data public class Result<T> { private int code; private String message; private T data; public static <T> Result<T> success(T data) { Result<T> r = new Result<>(); r.code = 0; r.message = "ok"; r.data = data; return r; } public static <T> Result<T> error(String message) { Result<T> r = new Result<>(); r.code = 500; r.message = message; return r; } }

业务异常统一用BusinessException抛出,再通过@RestControllerAdvice捕获。这样控制器里不会散落一堆 try-catch,代码读起来干净很多。

登录认证我用的 JWT:登录成功后签发 token,前端每次请求放在 Header。拦截器解析 token 后把 userId、role 放进ThreadLocal,业务方法随时可以取。有人说用 Shiro、Spring Security 更正规,但高校内部系统这种场景,一个 JWT 拦截器足够,引入重型安全框架反而把简单问题复杂化了。

3. 核心表结构与状态流转:把约束写进数据库而不是靠运气

3.1 三张核心表的字段拆解

这套系统的数据库表我设计为中心三张表:用户表、讲座表、预约表。没有多余的花活,但是每个字段都有讲究。

用户表sys_user:

CREATE TABLE `sys_user` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL COMMENT '登录名', `password` VARCHAR(255) NOT NULL COMMENT 'BCrypt加密存储', `real_name` VARCHAR(50) NOT NULL COMMENT '姓名', `student_no` VARCHAR(20) DEFAULT NULL COMMENT '学号,学生角色必填', `role` TINYINT NOT NULL DEFAULT 1 COMMENT '1学生 2教师 3管理员', `college` VARCHAR(100) DEFAULT NULL COMMENT '所属学院', `phone` VARCHAR(20) DEFAULT NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';

为什么用sys_user而不是user?虽然user在 MySQL 里不是严格保留字,但某些数据库方言和可视化工具有时会出幺蛾子,为了少踩坑直接加前缀。

讲座表lecture:

CREATE TABLE `lecture` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `title` VARCHAR(200) NOT NULL COMMENT '讲座标题', `speaker` VARCHAR(50) NOT NULL COMMENT '主讲人', `speaker_intro` VARCHAR(500) DEFAULT NULL COMMENT '主讲人简介', `location` VARCHAR(100) NOT NULL COMMENT '讲座地点', `start_time` DATETIME NOT NULL COMMENT '开始时间', `end_time` DATETIME NOT NULL COMMENT '结束时间', `capacity` INT NOT NULL DEFAULT 100 COMMENT '总容量', `reserved_count` INT NOT NULL DEFAULT 0 COMMENT '已预约人数', `waiting_count` INT NOT NULL DEFAULT 0 COMMENT '候补人数', `checkin_count` INT NOT NULL DEFAULT 0 COMMENT '已签到人数', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0草稿 1报名中 2进行中 3已结束 4已取消', `description` TEXT COMMENT '讲座详情', `created_by` BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_start_time` (`start_time`), KEY `idx_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='讲座表';

reserved_count、checkin_count用了冗余计数。有人质疑说用 count 查询不就得了,为什么冗余字段?关键在于列表页要按讲座展示预约率、签到率,如果每次都用SELECT COUNT(*) FROM reservation WHERE lecture_id=?做子查询,数据量一上来就很吃力。用冗余计数,在预约、取消、签到的业务方法里同步维护,查询时直接拿字段算即可。

预约表reservation:

CREATE TABLE `reservation` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `lecture_id` BIGINT UNSIGNED NOT NULL, `user_id` BIGINT UNSIGNED NOT NULL, `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0已预约 1已签到 2已取消 3候补中', `checkin_code` VARCHAR(10) DEFAULT NULL COMMENT '签到码', `checkin_time` DATETIME DEFAULT NULL COMMENT '实际签到时间', `cancel_reason` VARCHAR(255) DEFAULT NULL COMMENT '取消原因', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_lecture_user` (`lecture_id`, `user_id`), KEY `idx_user_id` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='预约表';

这个表的uk_lecture_user唯一索引是整个系统防重复预约的兜底防线。业务代码写得再好,也存在并发缝隙,数据库索引是最后一道墙。

3.2 状态字段:业务状态机最好用整数常量定义

讲座状态我用 0-4 五个整数表示。很多犹豫要不要用字符串枚举,我的习惯是数据库存整数、Java 里用枚举类或者常量类做映射。整数省空间、查询快,代码里写死了可读性也不差。

状态值含义学生可操作管理员可操作
0草稿不可见编辑、删除、发布
1报名中预约、取消撤回、查看名单
2进行中到场签到核销、查看名单
3已结束查看记录导出统计
4已取消不可操作恢复草稿(可选)

预约状态同理:

状态值含义流转方向
0已预约可取消为2、可签到的1
1已签到不可再流转
2已取消终点
3候补中可递补为0(自动)

我发现很多人做系统时状态字段随手一放,没有认真对待。实际上状态机是整个系统的“业务宪法”,一旦状态流转设计混乱,代码里全是“如果这个状态并且不是那个状态”的判断,后期维护会非常痛苦。

3.3 状态流转的业务代码与事务边界

以取消预约为例。取消不只是把预约记录改成“已取消”,还涉及讲座人数的回补、候补名单的递补,这是一个典型的多步骤操作,必须在同一个事务里完成:

@Transactional(rollbackFor = Exception.class) public void cancelReservation(Long reservationId, Long userId) { Reservation r = reservationMapper.selectById(reservationId); if (r == null || !r.getUserId().equals(userId)) { throw new BusinessException("预约记录不存在或无权操作"); } if (r.getStatus() != ReservationStatus.BOOKED) { throw new BusinessException("当前状态不可取消"); } Lecture lecture = lectureMapper.selectById(r.getLectureId()); long hours = ChronoUnit.HOURS.between(LocalDateTime.now(), lecture.getStartTime()); if (hours < 2) { throw new BusinessException("距离开讲不足2小时,无法在线取消"); } // 1. 预约记录置为已取消 r.setStatus(ReservationStatus.CANCELED); r.setCancelReason("用户自行取消"); reservationMapper.updateById(r); // 2. 讲座已预约人数回补 lectureMapper.decreaseReservedCount(lecture.getId()); // 3. 尝试从候补队列递补 promoteWaiting(lecture.getId()); }

promoteWaiting的逻辑我放在下一章详细讲,这里先记住一个原则:多人协同操作同一张预约表、同一张讲座表,必须用事务保证一致性,并且事务方法要从外部调用才能生效。同类内部this.cancelReservation()这种调用方式,事务会静默失效,这是无数人踩过的坑。

4. 并发预约的余量控制:从超卖到正确扣减的完整演进

4.1 超卖是怎么发生的:还原一个经典竞态

讲座开放报名的头几分钟是流量高峰。假设容量 200,当前已预约 198。两个学生 A、B 同时点击“预约”:

  • A 查询到reserved_count=198,判断 198 < 200,通过。
  • B 也查询到reserved_count=198,判断通过。
  • A 执行 UPDATE 变成 199。
  • B 执行 UPDATE 变成 200。

如果再加一个 C 也在同时操作,最后可能变成 201。这就是超卖——报名人数超过容量。

第一版我天真地写成了“先查一下剩余名额,够就插入预约记录”。线上环境一压测,马上就穿了。这种“检查再更新”的模式,两步之间有空窗,天然不适合并发场景。

4.2 正解:条件更新 + 行锁,一条SQL解决扣减

正确做法是把“检查剩余名额”和“扣减数量”合并成一条 SQL,利用 InnoDB 的UPDATE行锁保证原子性:

<update id="deductReservedCount"> UPDATE lecture SET reserved_count = reserved_count + 1 WHERE id = #{lectureId} AND status = 1 AND reserved_count &lt; capacity </update>

这条 SQL 的含义是:只有当讲座处于报名中、并且已预约人数小于容量时,才执行加一操作。InnoDB 在更新这一行时会加行级排他锁,第二个事务必须等第一个事务提交后才能执行,所以不会出现两个请求都扣成功的情况。

配套的 Java 代码:

public void reserve(Long lectureId, Long userId) { Lecture lecture = lectureMapper.selectById(lectureId); if (lecture == null || lecture.getStatus() != LectureStatus.REGISTERING) { throw new BusinessException("讲座不存在或当前不可预约"); } // 条件更新,affected rows 为 0 表示名额已满 int rows = lectureMapper.deductReservedCount(lectureId); if (rows == 0) { // 名额满了,进入候补逻辑,见 4.3 enterWaitingQueue(lectureId, userId); return; } // 插入预约记录,唯一索引兜底防重复 try { Reservation r = new Reservation(); r.setLectureId(lectureId); r.setUserId(userId); r.setStatus(ReservationStatus.BOOKED); r.setCheckinCode(generateCode(lectureId, userId)); reservationMapper.insert(r); } catch (DuplicateKeyException ex) { // 触发唯一索引冲突,说明同一个人重复预约,事务回滚 throw new BusinessException("你已经预约过这场讲座了"); } }

为什么“扣减成功之后再插入预约记录”的顺序是对的?因为扣减和插入在同一个事务里,如果插入失败,事务会整体回滚,reserved_count也会回退,不会出现“名额扣了但预约记录没生成”的中间状态。

拿生活中的例子类比:这就像电影院售票系统,选座时先把座位锁住,再生成订单,如果订单创建失败就释放座位。条件更新就是“锁座位”的动作,它本身是原子的。

4.3 候补队列与自动递补

满员之后怎么办?直接拒绝预约体验太差。讲座经常有人取消,候补机制就派上用场了。我的做法是:名额满时,预约记录以“候补中”状态插入,同时lecture.waiting_count + 1。

候补要控制上限,不能让候补队列无限膨胀。一般我会规定候补人数不超过容量的 20%,超过就提示“名额已满且候补人数较多”。

递补的逻辑核心在取消预约时被触发:

@Transactional(rollbackFor = Exception.class) public void promoteWaiting(Long lectureId) { // 找出最早的候补记录,FOR UPDATE 防止多个取消操作同时读到同一条候补 Reservation waiting = reservationMapper.selectOldestWaiting(lectureId); if (waiting == null) { return; } // 候补转正式预约 waiting.setStatus(ReservationStatus.BOOKED); reservationMapper.updateById(waiting); // 讲座已预约人数加回 lectureMapper.increaseReservedCount(lectureId); // 候补人数减一 lectureMapper.decreaseWaitingCount(lectureId); // 异步通知该学生预约成功(短信/站内信/公众号均可) notifyService.notifyReserveSuccess(waiting.getUserId(), lectureId); }

这里有个容易算错的地方:取消时reserved_count减一,递补成功后reserved_count又加一。从最终结果看,人数不变,变的是占位的人。如果你不加回,reserved_count就少了,下一场统计时预约率全部偏低。

selectOldestWaiting的 SQL 要用“先查最早创建的那条候补记录,并加锁”的方式:

<select id="selectOldestWaiting" resultType="Reservation"> SELECT * FROM reservation WHERE lecture_id = #{lectureId} AND status = 3 ORDER BY created_at ASC LIMIT 1 FOR UPDATE </select>

FOR UPDATE很关键。两个用户同时取消,如果都不加锁,可能同时读到同一条候补记录,导致同一个候补者被递补两次。加了行锁后,第二个取消操作必须等待第一个的递补事务提交,此时最早的候补记录已经被消费掉,它会拿到下一条候补记录或 null。

4.4 并发方案对比:到底要不要上 Redis 和消息队列

我见过不少同学把这个预约系统设计成“Redis 预扣库存 + 消息队列异步创建订单”,看着很高级,实际对高校讲座场景是过度设计。

方案原子性实现复杂度适用场景
先查再更新无低仅限单机低并发,不推荐
乐观锁 @Version有中适合写冲突较少的场景
条件更新(本方案)有低预约、秒杀等短时高并发写
Redis 预扣 + MQ有高万人级别抢课、大促秒杀

高校讲座的典型规模是几百到两三千人同时预约,MySQL 条件更新完全能扛住。引入 Redis 后,库存扣减成功但 DB 插入失败怎么办?Redis 宕机怎么办?这些一致性问题的排查成本远超收益。我的经验是:先把 MySQL 的并发控制做好,再去谈分布式工具,不要用架构的复杂度掩盖基本功的缺失。

5. 签到闭环、名额统计与管理接口:系统能否省心的关键

5.1 签到码设计与防冒签

很多讲座管理系统的签到环节做得特别敷衍——管理员手动勾选名单,或者学生自己在页面点“我到了”。这种行为等于没痕签到,数据没法用。

我的方案是签到码。学生预约成功后生成一个 6 位大写字母数字混合的短码,这个码与预约记录绑定。讲座现场,学生打开“我的预约”展示签到码,管理员用手机或后台输入这个码,系统核销。

public static String generateCode(Long lectureId, Long userId) { String raw = lectureId + ":" + userId + ":" + SALT; String md5 = DigestUtils.md5Hex(raw); String code = md5.substring(0, 6).toUpperCase(); // 去掉容易混淆的字符 0/O、1/I,提升人工输入成功率 return code.replace("0", "A") .replace("O", "B") .replace("1", "C") .replace("I", "D"); }

签到校验流程:

  1. 根据lectureId + code查找预约记录。
  2. 校验记录存在且状态为“已预约”。
  3. 校验当前时间是否在签到窗口内(讲座开始前后 30 分钟)。
  4. 通过后,预约状态改为“已签到”,checkin_time写入当前时间,lecture.checkin_count加一。

这个设计天然防冒签:签到码是预约时生成的短码,没有预约就没有码;一个码只能使用一次;签到记录带时间戳,不能补签。对高校内部讲座来说,这个防伪级别已经足够。

5.2 统计 SQL 与看板数据

讲座结束后的统计是管理员的刚需。我在管理端做了三个维度的统计:按讲座统计、按学院统计、按主讲人统计。

核心 SQL 是单表按讲座维度统计:

SELECT l.id, l.title, l.speaker, l.location, l.start_time, l.capacity, l.reserved_count AS booked_cnt, l.checkin_count AS checkin_cnt, ROUND(IF(l.reserved_count = 0, 0, l.checkin_count / l.reserved_count * 100), 2) AS checkin_rate, ROUND(l.reserved_count / l.capacity * 100, 2) AS fill_rate FROM lecture l WHERE l.status IN (2, 3) ORDER BY l.start_time DESC;

两个率的口径要分清:fill_rate是预约率,分母是容量,衡量讲座的吸引力;checkin_rate是签到率,分母是预约数,衡量实际到场情况。不少高校老师特别关注签到率,因为“预约了没来”的人数直接关系到讲座资源的浪费程度。

按学院统计:

SELECT u.college, COUNT(DISTINCT r.user_id) AS join_students, COUNT(r.id) AS join_times, SUM(CASE WHEN r.status = 1 THEN 1 ELSE 0 END) AS checkin_times, ROUND(SUM(CASE WHEN r.status = 1 THEN 1 ELSE 0 END) / COUNT(r.id) * 100, 2) AS avg_checkin_rate FROM reservation r JOIN sys_user u ON u.id = r.user_id WHERE r.status IN (0, 1) GROUP BY u.college ORDER BY join_times DESC;

这个 SQL 也踩过坑——SUM(CASE WHEN...)在 MySQL 里会返回 DECIMAL,ROUND时如果分母为 0 会报错。我加了IFNULL和条件判断后才稳。

统计页我会故意在主看板同时展示预约率、签到率、取消率三组指标。预约率高但签到率低,说明讲座宣传到位但实际吸引力不足;预约率和签到率都低,就要反思讲座主题和档期安排。这些数据是真实的运营反馈,也是这个系统区别于“玩具项目”的价值所在。

5.3 管理端的关键接口与名单导出

管理端接口我设计成一组 RESTful 风格的小接口:

接口说明
POST/api/admin/lectures创建讲座(草稿状态)
PUT/api/admin/lectures/{id}/publish发布讲座,状态置为报名中
PUT/api/admin/lectures/{id}/close提前关闭报名
PUT/api/admin/lectures/{id}/cancel取消整场讲座,批量释放预约
GET/api/admin/lectures/{id}/reservations分页查看预约名单
GET/api/admin/lectures/{id}/export导出 Excel
GET/api/admin/stats/overview总览统计

导出 Excel 我用的是 EasyExcel 这类工具,直接把查询结果映射到导出 VO。注意文件流的关闭和响应头设置,不然下载下来的文件会乱码或打不开。

@GetMapping("/{id}/export") public void export(@PathVariable Long id, HttpServletResponse response) throws IOException { List<ReservationExportVO> rows = reservationMapper.selectExportData(id); response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setCharacterEncoding("utf-8"); String fileName = URLEncoder.encode("预约名单", "UTF-8").replace("+", "%20"); response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + fileName + ".xlsx"); EasyExcel.write(response.getOutputStream(), ReservationExportVO.class) .sheet("预约名单") .doWrite(rows); }

管理端权限我用拦截器保证:所有/api/admin/**请求必须携带 token,且 token 中的role必须是管理员。学生 token 访问管理接口直接返回 403。权限控制在拦截器层做的好处是,业务代码不用反复检查“当前用户是不是管理员”。

6. 从开发环境到服务器:部署与踩坑记录

6.1 MVP 部署方案

项目开发完只是第一步,能部署上线被老师同学用起来才是完整体验。我的部署方案是典型的“一台服务器打天下”:MySQL 装在同一台机器上,后端打成可执行 Jar 包,前端构建静态文件后扔给 Nginx。

打包命令很简单:

mvn clean package -DskipTests

启动后端不要直接java -jar扔前台,要用 nohup,日志重定向到文件:

nohup java -jar lecture-system-1.0.0.jar --spring.profiles.active=prod \ > /opt/lecture/logs/app.log 2>&1 &

Nginx 配置核心是反向代理/api/前缀到后端端口,同时托管前端静态页面:

server { listen 80; server_name lecture.example.com; root /opt/lecture/frontend/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; } }

这里有个细节:proxy_pass结尾带不带/含义完全不同。proxy_pass http://127.0.0.1:8080;表示保留原始路径;如果写成proxy_pass http://127.0.0.1:8080/;会把/api前缀去掉。我一开始就因为这个前后端对不上接口,排查了半小时。

6.2 几个真实踩过的坑

时区问题。数据库连接串没带serverTimezone=Asia/Shanghai时,插入的时间比北京时间慢了 8 小时。后端用LocalDateTime写入,MySQL 的 DATETIME 本不该有时区概念,但 JDBC 驱动处理时会把本地时间按默认时区转换。解决办法是连接串统一加上参数:

jdbc:mysql://127.0.0.1:3306/lecture_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai

事务失效。我在一个 Service 里写了个方法,内部调用this.cancelReservation(),结果发现取消后reserved_count并没有回补。查了半天,原因是 Spring 的@Transactional基于代理实现,同类内部调用不会经过代理,事务根本不生效。解决办法是把取消和递补拆到不同 Service,或者注入ApplicationContext获取代理对象再调用。

数据库保留字踩坑。构建基础表时,有一个字段想叫desc(描述),写了 SQL 直接报语法错误。MySQL 里desc、order、rank都可能撞保留字。写作系统时,字段命名我统一避开保留字,结合项目语义命名,比如description、order_no。这是写 DDL 前的第一道自查。

跨域问题。本地开发时前端跑在localhost:5173,后端在8080,浏览器跨域拦截。我在后端加了一个CorsFilter。生产上 Nginx 反代后同源,这个配置就不需要了。开发期和生产期配置分开,application-dev.yml与application-prod.yml各管各的。

6.3 上线后值得做的几件事

系统上线后,不需要急着堆功能。先关注使用数据:

  • 看预约高峰是否集中在发布后 10 分钟,如果是,MySQL 条件更新是否能撑住。
  • 看取消率,如果某一类讲座取消率特别高,多半是时间安排或宣传信息不清晰。
  • 看签到异常,比如某场讲座签到率极低,需要回访是签到码不好用还是学生到场意愿低。

我把日志在服务器上按天切割,保留 30 天。预约失败、签到失败这类业务日志单独打印,排查问题时不用从一堆请求日志里反复翻。

最后再分享两个实用的收尾细节

第一个是表数据的一致性维护。冗余计数字段reserved_count和checkin_count虽然好用,但一旦业务代码漏更新,数字就会失真。我在管理后台加了一个“数据矫正”功能,管理员可以一键根据预约表的实际记录重新计算所有讲座的计数。这个功能上线后救了我好几次。

第二个是预约成功后的通知。学生预约成功后,站内信或模板消息能明显减少“我到底约上没有”的咨询量。通知里带上讲座时间、地点、签到码,现场签到效率会提高很多。很多高校同学到教室门口才发现签不了到,往往是因为没看通知。

做这类系统,技术难度其实没有想象中高,真正考验人的是对业务规则的拆解和对边界情况的处理。如果你也在做讲座预约、活动报名类似的系统,建议先花两天时间把业务规则表格写清楚,再动手敲代码。项目跑通的那一刻很快,但项目好维护、好扩展,靠的永远是前期那些“看不见”的设计。

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

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

立即咨询