
第一次接这个活时我以为就是做个网页版报名表。直到辅导员把需求清单发过来——不能重复预约、满员自动候补、现场核销防代签、按学院统计到场率我才意识到这其实是个业务约束密集的系统。最终落地的这个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内置分页插件、逻辑删除非常适合这种管理系统的开发节奏。数据库用 MySQLInnoDB 引擎既满足事务需求又有行锁支持并发扣减。这个组合最大的优点是“老师看得懂、答辩能说清”同时又不会因为太玩具而被质疑工程性。版本方面我建议如果是 2.7.x 就用 JDK 8 或 11完全够用。如果选 Spring Boot 3.xJDK 必须是 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、CheckinCodeUtiladmin 与 student 分开是我强烈建议的做法。它天然形成了权限边界拦截器按路径前缀做鉴权看到/api/admin/就校验管理员角色看到/api/student/就校验学生身份不需要在方法里写一堆 if else。2.3 统一返回、异常处理和登录拦截统一返回结构是系统开发里一定要做的事。没有它前端处理响应时会出现“有的接口返回{code:200,data:...}有的接口直接返回业务对象”这种混乱。Data public class ResultT { private int code; private String message; private T data; public static T ResultT success(T data) { ResultT r new Result(); r.code 0; r.message ok; r.data data; return r; } public static T ResultT error(String message) { ResultT 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_userCREATE 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表;为什么用sys_user而不是user虽然user在 MySQL 里不是严格保留字但某些数据库方言和可视化工具有时会出幺蛾子为了少踩坑直接加前缀。讲座表lectureCREATE 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT讲座表;reserved_count、checkin_count用了冗余计数。有人质疑说用 count 查询不就得了为什么冗余字段关键在于列表页要按讲座展示预约率、签到率如果每次都用SELECT COUNT(*) FROM reservation WHERE lecture_id?做子查询数据量一上来就很吃力。用冗余计数在预约、取消、签到的业务方法里同步维护查询时直接拿字段算即可。预约表reservationCREATE 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT预约表;这个表的uk_lecture_user唯一索引是整个系统防重复预约的兜底防线。业务代码写得再好也存在并发缝隙数据库索引是最后一道墙。3.2 状态字段业务状态机最好用整数常量定义讲座状态我用 0-4 五个整数表示。很多犹豫要不要用字符串枚举我的习惯是数据库存整数、Java 里用枚举类或者常量类做映射。整数省空间、查询快代码里写死了可读性也不差。状态值含义学生可操作管理员可操作0草稿不可见编辑、删除、发布1报名中预约、取消撤回、查看名单2进行中到场签到核销、查看名单3已结束查看记录导出统计4已取消不可操作恢复草稿可选预约状态同理状态值含义流转方向0已预约可取消为2、可签到的11已签到不可再流转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_count198判断 198 200通过。B 也查询到reserved_count198判断通过。A 执行 UPDATE 变成 199。B 执行 UPDATE 变成 200。如果再加一个 C 也在同时操作最后可能变成 201。这就是超卖——报名人数超过容量。第一版我天真地写成了“先查一下剩余名额够就插入预约记录”。线上环境一压测马上就穿了。这种“检查再更新”的模式两步之间有空窗天然不适合并发场景。4.2 正解条件更新 行锁一条SQL解决扣减正确做法是把“检查剩余名额”和“扣减数量”合并成一条 SQL利用 InnoDB 的UPDATE行锁保证原子性update iddeductReservedCount 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 idselectOldestWaiting resultTypeReservation SELECT * FROM reservation WHERE lecture_id #{lectureId} AND status 3 ORDER BY created_at ASC LIMIT 1 FOR UPDATE /selectFOR 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); }签到校验流程根据lectureId code查找预约记录。校验记录存在且状态为“已预约”。校验当前时间是否在签到窗口内讲座开始前后 30 分钟。通过后预约状态改为“已签到”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 里会返回 DECIMALROUND时如果分母为 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导出 ExcelGET/api/admin/stats/overview总览统计导出 Excel 我用的是 EasyExcel 这类工具直接把查询结果映射到导出 VO。注意文件流的关闭和响应头设置不然下载下来的文件会乱码或打不开。GetMapping(/{id}/export) public void export(PathVariable Long id, HttpServletResponse response) throws IOException { ListReservationExportVO 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.activeprod \ /opt/lecture/logs/app.log 21 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 几个真实踩过的坑时区问题。数据库连接串没带serverTimezoneAsia/Shanghai时插入的时间比北京时间慢了 8 小时。后端用LocalDateTime写入MySQL 的 DATETIME 本不该有时区概念但 JDBC 驱动处理时会把本地时间按默认时区转换。解决办法是连接串统一加上参数jdbc:mysql://127.0.0.1:3306/lecture_db?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/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虽然好用但一旦业务代码漏更新数字就会失真。我在管理后台加了一个“数据矫正”功能管理员可以一键根据预约表的实际记录重新计算所有讲座的计数。这个功能上线后救了我好几次。第二个是预约成功后的通知。学生预约成功后站内信或模板消息能明显减少“我到底约上没有”的咨询量。通知里带上讲座时间、地点、签到码现场签到效率会提高很多。很多高校同学到教室门口才发现签不了到往往是因为没看通知。做这类系统技术难度其实没有想象中高真正考验人的是对业务规则的拆解和对边界情况的处理。如果你也在做讲座预约、活动报名类似的系统建议先花两天时间把业务规则表格写清楚再动手敲代码。项目跑通的那一刻很快但项目好维护、好扩展靠的永远是前期那些“看不见”的设计。