智慧校园云端管理系统实战:Spring Boot 3 与 MyBatis 实现选课并发控制与权限数据域
发布时间:2026/10/10 3:03:14 锦皓数字建站

简介这份资源是面向高校计算机专业学生与Java开发学习者的智慧校园云端管理系统完整项目包基于Spring Boot后端与Vue前端构建配合Tomcat部署适合作为毕业设计、课程设计或企业级项目练手的参考方案。压缩包共437个文件约11.36MB涵盖38个Java源码与对应class文件、126个XML配置、24个JS与20个CSS前端资源以及PNG、JPG界面截图和SQL数据库脚本另附架构XMind图、实现流程Markdown与PPT文档结构完整、层次清晰。资源内含myzhxy源代码与zhxy_db.sql数据库覆盖学生、教师、管理员、班级、年级等核心模块并集成JWT鉴权与Swagger接口文档便于读者快速理解权限设计与接口规范。目前已有2299人学习下载可帮助读者掌握前后端分离架构、数据库建表与云端部署的完整思路。1. 智慧校园云端管理系统从一份课程设计到一个能跑起来的真实后端很多同学做「智慧校园云端管理系统」这个题目时第一反应是打开某宝搜源码或者直接套一个后台管理模板把菜单改一改就交差。结果答辩时被问一句「你的权限模型怎么设计的」「课表冲突检测放在哪一层」当场卡壳。这个标题真正要解决的不是「有没有界面」而是「一个校园场景下的多角色、多终端、多数据源系统怎么在云端把它拆开、跑通、并且能讲清楚每一层的边界」。它适合两类人一类是正在做课程设计或毕设、需要一套能自圆其说的完整方案的学生另一类是想练手微服务或云原生、但缺一个业务场景的开发者。校园这个场景的好处是业务边界清晰——学生、教师、教务、后勤四类角色选课、考勤、通知、报修几条主线足够撑起一个真实系统又不至于复杂到失控。2. 先把业务边界画清楚四类角色与三条数据主线2.1 为什么校园系统最容易死在「角色没分干净」我见过太多翻车的案例根子都在权限模型上。学生能看到的课表和教师能看到的课表字段其实不一样学生关心上课地点、任课教师、周次教师关心选课人数、教室容量、调课申请。如果一开始用一张user表加一个role字段糊过去后面每加一个功能就要写一堆if role student代码会迅速腐烂。常见做法是 RBAC基于角色的访问控制但校园场景我一般会再加一层「数据域」。角色决定你能访问哪些接口数据域决定你能看到哪些行。比如同样是查询成绩接口学生只能查自己的教师只能查自己任教课程的教务可以查全院的。这两层分开之后权限判断就从业务代码里抽出来了。-- 角色表只存角色定义不存具体人 CREATE TABLE sys_role ( id BIGINT PRIMARY KEY AUTO_INCREMENT, role_code VARCHAR(32) NOT NULL UNIQUE COMMENT 角色编码如 STUDENT/TEACHER/ACADEMIC/LOGISTICS, role_name VARCHAR(64) NOT NULL COMMENT 角色显示名, data_scope TINYINT NOT NULL DEFAULT 1 COMMENT 数据域1本人 2本班 3本院 4全校, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); -- 用户角色关联一个用户可以有多重身份比如研究生既是学生又是助教 CREATE TABLE sys_user_role ( user_id BIGINT NOT NULL, role_id BIGINT NOT NULL, PRIMARY KEY (user_id, role_id) );data_scope这个字段是整个权限体系的开关。拦截器拿到当前用户的角色后取最小的那个 scope 值作为最终数据域然后在 MyBatis 的拦截器里动态拼接WHERE条件。这样做的好处是业务代码里完全看不到权限判断新增一个查询接口只要挂上注解就自动生效。2.2 三条数据主线的表结构怎么落校园系统的数据主线我归纳为三条教学线课程、课表、选课、成绩、事务线通知、报修、审批、身份线用户、角色、组织。身份线是底座教学线和事务线都挂在上面。教学线里最容易设计错的是课表。很多人用一张schedule表存「星期几第几节」然后选课的时候去查冲突。问题是大学课表不是按周固定的有单双周、有调课、有临时教室变更。我一般会拆成两张表course_schedule存模板周次范围、星期、节次、教室schedule_adjustment存调课记录。选课冲突检测时先按模板算出候选时间段集合再叠加调课记录做修正。CREATE TABLE course_schedule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, course_id BIGINT NOT NULL, week_start TINYINT NOT NULL COMMENT 起始周1-20, week_end TINYINT NOT NULL COMMENT 结束周, week_type TINYINT NOT NULL DEFAULT 0 COMMENT 0每周 1单周 2双周, day_of_week TINYINT NOT NULL COMMENT 1-7, period_start TINYINT NOT NULL COMMENT 起始节次, period_end TINYINT NOT NULL COMMENT 结束节次, classroom VARCHAR(64) NOT NULL, INDEX idx_course (course_id) );事务线里通知表要注意的是「已读未读」的存储。新手常犯的错是给每个用户插一条通知记录一个通知发给全校两万人就是两万行。正确做法是通知主体一张表已读状态一张表查询时用LEFT JOIN判断。2.3 云端部署形态的选型单体还是微服务这是被问得最多的问题。我的建议很直接课程设计级别用模块化单体不要上微服务。原因有三个。第一微服务的收益在团队协作和独立伸缩一个人做项目这两点都不存在。第二服务拆开之后分布式事务、链路追踪、配置中心这些配套你得自己搭工作量翻三倍。第三答辩时老师问「为什么拆成八个服务」你答不上来反而扣分。模块化单体的做法是一个 Spring Boot 应用按modules分包模块之间只通过 Service 接口调用不直接跨模块访问 Mapper。这样将来真要拆把包一挪就是服务。部署上用一台云主机加 Docker Compose 就够了MySQL、Redis、Nginx、应用四个容器内存 4G 起步。3. 用 Spring Boot 加 MyBatis 把核心接口跑通3.1 项目骨架与依赖版本怎么定骨架我一般用 Maven 多模块父 POM 管版本子模块分common、system、teaching、affair、web。这样依赖不会乱。JDK 用 17Spring Boot 用 3.x 系列MyBatis-Plus 用配套版本。注意 Spring Boot 3 之后javax.*全部换成jakarta.*很多老教程直接抄会编译不过这是第一个坑。!-- 父 POM 里统一管理版本子模块不写 version -- properties java.version17/java.version spring-boot.version3.2.0/spring-boot.version mybatis-plus.version3.5.5/mybatis-plus.version /properties dependencyManagement dependencies dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-spring-boot3-starter/artifactId version${mybatis-plus.version}/version /dependency /dependencies /dependencyManagement这里要强调mybatis-plus-spring-boot3-starter这个 artifactIdSpring Boot 3 必须用带boot3的那个用错了启动直接报NoClassDefFoundError。3.2 选课接口的并发控制别让超卖发生在你身上选课是校园系统里唯一有真实并发压力的接口。一门课限选 60 人开放选课那一秒可能有几百个请求打进来。如果你写的是「先查人数再插入」必然超卖。我一般用 Redis 预扣加数据库唯一索引兜底。Redis 里存course:stock:{courseId}选课时用 Lua 脚本原子性地判断并扣减扣减成功再写数据库。数据库这边给course_selection表加(course_id, student_id)唯一索引防止重复选课。// 选课核心逻辑Redis 预扣 DB 唯一索引兜底 public SelectionResult selectCourse(Long studentId, Long courseId) { String stockKey course:stock: courseId; // Lua 脚本保证判断和扣减的原子性 Long remain redisTemplate.execute(SELECT_SCRIPT, Collections.singletonList(stockKey)); if (remain null || remain 0) { return SelectionResult.fail(名额已满); } try { CourseSelection cs new CourseSelection(); cs.setStudentId(studentId); cs.setCourseId(courseId); cs.setSelectTime(LocalDateTime.now()); selectionMapper.insert(cs); // 唯一索引冲突会抛异常 return SelectionResult.success(); } catch (DuplicateKeyException e) { // 重复选课把预扣的名额还回去 redisTemplate.opsForValue().increment(stockKey); return SelectionResult.fail(请勿重复选课); } }Lua 脚本的内容是local r redis.call(get, KEYS[1]); if r and tonumber(r) 0 then redis.call(decr, KEYS[1]); return tonumber(r) - 1 else return -1 end。这段脚本在 Redis 单线程模型下天然原子不需要额外加锁。参数上要注意两点。一是 Redis 里的库存初始化时机应该在选课开放前由定时任务从数据库同步而不是启动时加载一次。二是如果数据库插入失败但 Redis 已经扣减必须补偿否则名额会凭空消失。上面代码里的catch块就是补偿逻辑实际项目里还要加一个对账定时任务每天凌晨比对 Redis 和数据库的数量。3.3 课表查询接口一次查询解决周次和调课课表查询的性能瓶颈在「按周筛选」。学生查第 8 周课表你要从模板里筛出覆盖第 8 周的记录再叠加调课。如果每次都在 Java 里循环判断数据量大了会慢。我的做法是把周次判断下推到 SQL。用week_start #{week} AND week_end #{week}先筛模板再用week_type过滤单双周。调课记录用一次LEFT JOIN带出来在 Java 里做合并。SELECT cs.id, cs.course_id, c.course_name, cs.day_of_week, cs.period_start, cs.period_end, cs.classroom, sa.adjust_date, sa.new_classroom, sa.new_period_start FROM course_schedule cs JOIN course c ON c.id cs.course_id LEFT JOIN schedule_adjustment sa ON sa.course_id cs.course_id AND sa.adjust_week #{week} WHERE cs.week_start #{week} AND cs.week_end #{week} AND (cs.week_type 0 OR (cs.week_type 1 AND #{week} % 2 1) OR (cs.week_type 2 AND #{week} % 2 0)) ORDER BY cs.day_of_week, cs.period_start;week_type的判断逻辑是0 表示每周都上1 表示单周2 表示双周。用取模运算在 SQL 里完成比在 Java 里过滤快得多。调课记录如果存在new_classroom和new_period_start会覆盖模板值合并逻辑放在 Service 层注意判空。3.4 通知模块的已读状态怎么存才不炸前面提过不要给每个用户插一条通知。正确结构是三张表notice通知主体、notice_target通知范围可以是全体、某角色、某班级、notice_read已读记录只存读过的。-- 查询某用户未读通知数量 SELECT COUNT(*) FROM notice n JOIN notice_target nt ON nt.notice_id n.id LEFT JOIN notice_read nr ON nr.notice_id n.id AND nr.user_id #{userId} WHERE nr.id IS NULL AND n.publish_time NOW() AND (nt.target_type ALL OR (nt.target_type ROLE AND nt.target_value #{roleCode}) OR (nt.target_type CLASS AND nt.target_value #{classId}));这个查询的关键是LEFT JOIN ... WHERE nr.id IS NULL这是标准的「找差集」写法。notice_target表上要给(target_type, target_value)建联合索引否则通知一多全表扫描。4. 避坑与排查那些让我熬夜的报错4.1 现象选课接口压测时 Redis 连接池耗尽原因Lua 脚本执行和普通get共用连接池压测时连接被占满后续请求全部超时。解决给 Lua 脚本单独配一个LettuceConnectionFactory或者把连接池max-active从默认 8 调到 64同时设置max-wait为 200ms超时快速失败而不是无限等待。4.2 现象课表查询返回重复行原因LEFT JOIN schedule_adjustment时如果一门课在同一周有多条调课记录主查询会笛卡尔积。解决调课记录按course_id分组后在子查询里聚合或者用GROUP BY cs.id配合GROUP_CONCAT把调课信息拼成一列Java 里再拆。4.3 现象Spring Boot 3 启动报jakarta.servlet找不到原因引入了老版本的spring-boot-starter-web或者某个第三方库还在用javax.servlet。解决用mvn dependency:tree找出传递依赖排除掉老库或者找该库的 Jakarta 兼容版本。这个坑在引入老牌工具类库时特别常见。4.4 现象Docker Compose 里应用连不上 MySQL原因容器内localhost指向容器自己不是宿主机。解决application.yml里数据库地址写服务名mysql而不是127.0.0.1并且depends_on只保证启动顺序不保证 MySQL 就绪要加健康检查或者重试逻辑。4.5 现象权限拦截器把登录接口也拦了原因拦截器注册时addPathPatterns(/**)没排除白名单。解决excludePathPatterns(/auth/login, /auth/captcha, /error)注意/error一定要排除否则出错时会被二次拦截报错信息全丢。5. 进阶把系统做成能演示、能讲清楚的形态到这一步系统能跑了但答辩或面试时怎么讲是另一回事。我的经验是准备三个「可演示的纵深点」比堆功能有用得多。第一个点是选课的并发。你可以现场用wrk或ab压一下选课接口展示 Redis 预扣前后的 QPS 差异和最终数据一致性。命令很简单ab -n 1000 -c 100 -p select.json -T application/json http://localhost:8080/api/selection。压完查数据库确认选课人数不超过容量这就是硬证据。第二个点是权限的数据域。准备两个账号一个学生一个教师调同一个成绩查询接口展示返回的行数不同。这比讲十分钟 RBAC 理论有说服力。第三个点是课表的周次计算。查第 1 周和第 2 周展示单双周课程的出现和消失再插一条调课记录展示教室变更。这三个点串起来整个系统的核心逻辑就讲透了。验证方法上我习惯写一组集成测试用 Testcontainers 起一个真实的 MySQL 和 Redis跑完整流程。这样换台机器也能复现不依赖本地环境。Testcontainers SpringBootTest class SelectionIntegrationTest { Container static MySQLContainer? mysql new MySQLContainer(mysql:8.0) .withDatabaseName(campus); Container static GenericContainer? redis new GenericContainer(redis:7-alpine) .withExposedPorts(6379); Test void should_not_oversell_when_concurrent() throws Exception { int capacity 60; int threads 200; ExecutorService pool Executors.newFixedThreadPool(threads); CountDownLatch latch new CountDownLatch(threads); AtomicInteger success new AtomicInteger(); for (int i 0; i threads; i) { long studentId 1000L i; pool.submit(() - { try { if (selectionService.selectCourse(studentId, 1L).isSuccess()) { success.incrementAndGet(); } } finally { latch.countDown(); } }); } latch.await(); // 成功数必须等于容量多一个少一个都是 bug assertEquals(capacity, success.get()); } }这个测试的价值在于它把「并发安全」从口头承诺变成了可执行的断言。Testcontainers的版本要和 Spring Boot 的依赖管理对齐否则容器启动会报镜像拉取失败。CountDownLatch保证所有线程同时起跑AtomicInteger统计成功数最后断言等于容量。如果不等要么是超卖要么是 Redis 库存没初始化对。最后说个我自己的习惯每做完一个模块我会把「这个模块如果数据量涨十倍哪里先崩」写进 README。选课模块的答案是 Redis 单点课表模块的答案是调课表的 JOIN通知模块的答案是已读表的增长。想清楚这些系统才算是真的做完了而不是跑起来就完事。希望帮到你。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。