选课系统这种项目,我在课设和毕设里见过太多版本了,但绝大多数都是"看起来能跑"的演示品,真正能拿去答辩、能演示完整业务闭环的其实不多。今天这篇就围绕一个基于微信小程序和Spring Boot的在线选课系统展开,从需求拆解、表结构设计、核心接口逻辑到部署上线,把一套能实际交付的源码讲透,顺便把我踩过的坑也一并交代清楚。
这个项目解决的是高校教务场景里最典型的痛点:学生选课靠抢、教师排课靠Excel、管理员统计靠人工。用微信小程序做前端,学生扫码即用不用装App;后端用Spring Boot扛住选课高峰期的并发请求。整套系统适合三类人看:正在做Java课程设计的大学生、准备毕业设计但时间紧张的应届生,以及想入门小程序全栈开发、想搞明白前后端怎么对接的开发者。
1. 项目全貌:这套选课系统到底在做什么
先花几分钟把业务边界捋清楚。很多人拿到源码第一步就急着跑起来,结果数据库初始化脚本都没执行,登录页面白屏半天,其实问题大多出在没搞明白系统是给谁用的、每个角色能看到什么。
1.1 传统选课的痛点与这个项目的解法
在没做信息化之前,很多学校的选课流程是这样的:教务老师把课程表发到班级群,学生用Excel填志愿,班委汇总后交给老师,老师再手工排冲突、调名额。这个过程最折磨人的是三个问题,第一个是课程冲突没人提前预警,学生会同时选两门时间重叠的课,最后上课时才发现撞了时间;第二个是热门课超员严重,往往一个名额有几十个人报,老师只能靠运气筛选;第三个是退课换选特别麻烦,每次调整都要重新走一遍邮件确认流程。
这套微信小程序选课系统要解决的,就是把上面这个流程全部线上化。学生端能看课程列表、按时间段筛选、一键选课退课、查看课表;教师端能发布课程、设置容量、导出选课名单;管理员端负责账号管理、学期切换、数据统计。核心逻辑是:系统在选课环节自动校验时间冲突和容量限制,数据库层面用唯一索引加事务保证不超卖,前端小程序做到秒级响应。
1.2 三种角色的权限边界与功能差异
角色权限的设计是这类系统的地基,权限没掰扯清楚,后面加多少功能都是乱的。我按实际使用频率把三个角色的核心功能做了个划分:
| 角色 | 核心功能 | 数据操作范围 |
|---|---|---|
| 学生 | 浏览课程、选课、退课、查看个人课表、修改资料 | 仅限本人选课记录 |
| 教师 | 发布课程、维护课程信息、查看选课名单、录入成绩 | 仅限本人创建的课程 |
| 管理员 | 用户管理、学院/专业维护、学期切换、全局统计 | 全部数据,拥有最高权限 |
这里有个设计上的细节值得注意:教师的课程创建权限要不要管理员审核?我见过两种方案,一种是教师提交后直接生效,简单但容易出错;另一种是教师提交后管理员审核通过才开放选课,多一步流程但可控性强。这个项目采用的是后者,好处是管理员能在选课开始前统一检查课程数据完整性,避免学生选到信息缺漏的课。如果不加这层审核,万一教师忘记填上课地点,学生就得满楼找教室。
学生的权限边界更要严格卡死,学生只能操作自己的选课记录,不能看到其他学生的课表,更不能修改课程容量。这些限制在后端接口层面就要做校验,不能指望前端隐藏按钮就算完事,因为小程序的request是可以被抓包的。
2. 技术选型与架构设计:为什么是微信小程序+Spring Boot
说实话,光看"微信小程序+Spring Boot"这个组合,很多人第一反应是"这是课设标配吧",确实,这个组合在高校项目里出现频率极高,但频率高不代表它没道理。小程序天然适配校园场景,学生不用额外装App,微信扫一扫就能进;Spring Boot则让后端开发效率拉满,生态成熟,遇到问题随便一搜就有解决方案。
2.1 前后端分离架构的组装逻辑
这套系统的架构属于典型的互联网分层架构,没有微服务那些花架子,单体应用足够撑起一个学期的选课需求。完整调用链路是:微信小程序端发起HTTPS请求,经过Nginx反向代理转发到Spring Boot后端,后端用Spring MVC接收请求并做参数校验,然后调用Service层处理业务逻辑,Service层通过MyBatis操作MySQL数据库,认证信息用JWT存续会话状态。
有人会问,为什么要用Nginx而不让小程序直接访问Spring Boot端口?两个原因,第一个是微信小程序对HTTPS证书要求很严格,request合法域名必须是HTTPS,Nginx能统一做SSL卸载,否则得在Spring Boot里配证书;第二个是高峰选课时Nginx可以做负载均衡和静态资源缓存,即使以后要扩展到多台后端服务器,也不用改小程序端的任何代码。
数据传输格式上,目前主流做法是小程序端用wx.request发送JSON格式数据,后端的Controller接收后统一封装为Result对象返回,格式是{code: 200, message: "success", data: {...}}。小程序端拿到响应后先判断code,再处理data里的业务数据,这套约定必须前后端都遵守,否则联调时会出现各种"莫名其妙"的解析错误。
2.2 数据库建模的关键表与关系设计
整套系统最核心的表有四张:用户表、课程表、选课记录表、学期表。很多新手设计表的时候会漏掉学期这个维度,结果就是跨学期的数据混在一起,查历史选课记录时全部乱套。这四张表的字段设计我按实际项目经验梳理一下。
用户表的核心字段包括主键id、用户名、密码(BCrypt加密存储)、角色(student/teacher/admin)、姓名、学号/工号、学院id、专业id。角色字段用字符串更直观,用数字枚举也行,但要注意别在代码里写魔法数字,最好定义常量类统一管理。
课程表的核心字段包括主键id、课程名称、课程代码、授课教师id、学期id、上课时间(JSON格式或字符串)、上课地点、总容量、已选人数、学分、课程性质(必修/选修)、状态(待审核/已发布/已结束)。这里最关键的是上课时间和总容量这两个字段。上课时间我用的是JSON字符串存储,比如[{"week": "周一", "period": "3-4节"}, {"week": "周三", "period": "1-2节"}],这样解析起来灵活,数据库也不用来回拼接字符串。
选课记录表的核心字段包括主键id、学生id、课程id、选课时间、退课时间、状态(已选/已退)。
这四张表之间的关系是:用户表和课程表是多对多,通过选课记录表关联;学期表作为维度表挂在课程表上。需要特别注意的是选课记录表要建联合唯一索引(student_id, course_id, semester_id),这是防止重复选课的最后一道防线。很多教程里会忽略这个索引,导致高并发下学生重复提交选课请求时产生两条记录,业务上又没法判断哪条是"真"的。
2.3 认证与权限控制方案
小程序的登录认证和普通网页登录有个本质区别:小程序拿不到传统意义上完全由自己控制的 Cookie,虽然能用wx.setStorage模拟,但安全性不够。更通用的做法是使用 JWT(JSON Web Token),登录成功后把 token 存到小程序的本地缓存里,每次请求在 header 中携带Authorization: Bearer <token>。
在后端,我实现了一个拦截器来处理每个请求的token验证,拦截器核心逻辑是先取header里的token,然后解析token获取userId和角色信息,再放行请求让Controller继续处理。需要说明的是,拦截器只能验证"用户是否已登录",至于"登录用户有没有权限操作这个数据",必须在Service层根据业务逻辑判断。举例来说,学生A调用"删除选课记录"接口时,即使传的是学生B的选课记录id,Service层也要校验当前登录用户和记录所属用户是否一致,因为token里存的信息可以被伪造篡改,不能盲目信任客户端传来的任何id。
JWT的过期时间建议设成7天,学期中间频繁登录很烦人,7天基本能覆盖学生日常使用场景。如果后续要做"记住我"功能,可以把过期时间拉长到30天,但要注意安全性会相应降低。小程序端的token失效后,后端返回code: 401,小程序拦截到后跳转登录页重新授权,这个链路要提前测一遍,别等到答辩时才现找问题。
3. 核心模块实操:从接口到页面的完整落地
前面把架构和表结构讲清楚了,这一部分进入正题,按业务优先级把核心模块过一遍。选课系统最敏感的就是选课和退课两个操作,这两块逻辑写不对,其他功能做得再花哨也白搭。
3.1 选课主流程:并发扣减与冲突校验
选课接口是整套系统并发压力最大的接口,热门课一开放选课就可能涌入几百上千个请求。选课的核心业务流程分三步:第一步校验课程状态是否已发布,第二步校验学生是否已选过该课程,第三步校验课程容量是否已满,第四步校验时间是否与其他已选课程冲突,最后执行插入选课记录并更新课程已选人数。
校验和插入这两步之间天然存在并发窗口。举个例子,一门课容量只剩1个名额,两个学生同时发起选课,两个请求都通过了容量判断,结果数据库里插入了两条记录,而课程容量变成负数了。解决这个问题的最经典做法是在update语句的条件里带上已选人数 < 总容量:
UPDATE course SET selected_count = selected_count + 1 WHERE id = #{courseId} AND selected_count < total_capacity这条SQL执行后返回影响行数,如果返回0,说明容量已满,选课失败;如果返回1,说明扣减成功,再执行插入选课记录。用数据库层面的原子操作来兜底,比在代码里加锁更可靠。这里最关键的一步是,更新课程人数和插入选课记录必须在同一个事务里执行,否则会出现选课记录插进去了但人数没加上去的幽灵数据。
时间冲突校验的逻辑其实不复杂,先把学生已选的所有课程时间字符串解析出来,和当前待选课程的时间字符串做两两比对,判断是否有重叠。我在项目里写了一个时间解析工具类,把"周一3-4节"这种格式解析成具体的周几和节次集合,然后用集合的contains方法判断是否冲突。这个方案性能足够,每学期选课记录不会超过20条,遍历一次的耗时可以忽略不计。
3.2 退课与换选的边界处理
退课的逻辑比选课简单,但有一个边界条件特别容易忽略:选课截止时间。很多学校会规定选课截止日期,过了这个时间就只能加课不能退课。实现方案是给课程表加一个end_time(截止时间)字段,退课接口进入后先判断当前时间是否在截止时间之前,不在则直接返回"已过退课截止时间"。
退课的SQL事务包含两步:从选课记录表删除记录,课程表的已选人数减1。这两步同样需要在同一个事务中执行,顺序颠倒会出现人数对不上的数据问题。有同学追问为什么要先删除记录再减少人数、而不是反过来,其实只要两条SQL都在同一事务里,谁先谁后不影响最终结果,关键还是事务不能拆散。不过实际写法我习惯先删选课记录再更新课程人数,因为删除操作如果失败,后续步骤就不必执行。
换选课的业务其实就是退课加选课的组合操作,但要注意的是不能拆成两个独立请求让学生自己点,要提供一个replaceCourse接口,内部先退旧课再选新课,并且整体放在一个事务里。这样做的目的是防止"先退后选"两步之间课程被其他人抢走,导致学生两头落空。虽然事务不能锁住"其他学生选课"这个操作(因为那是别人的事务),但至少保证了同一个学生操作的原子性。
3.3 管理端课程发布与名额控制
管理员端的课程发布流程,我设计成了"教师提交、管理员审核"的两段式。教师在教师端创建课程时,状态默认为PENDING,此时学生在小程序端看不到这门课。管理员在管理后台通过审核后,状态更新为PUBLISHED,学生才能检索和选课。
这个设计有一个隐藏的好处:管理员可以批量审核课程,并在审核通过的同时给课程设置统一的选课开放时段。比如管理员下午2点批量通过20门课,这些课随即进入可选的开放状态,学生2点整就能选,这比教师每门课各自上架更可控,碰到高峰也不用担心课程陆续出现导致学生反复刷新。名额控制上,管理员可以对每门课单独修改总容量,我在修改接口里加了一个限制:修改后的容量不能小于当前已选人数。这听起来是常识,但真的有人把容量从60改成50,而课程里已经选了55个学生,导致数据库出现脏数据。
3.4 小程序端页面的关键交互
小程序端我用的是原生微信小程序框架,没有引入uni-app或Taro,原因很简单,原生框架足够应付这类表单+列表型应用,而且编译调试快,不用踩跨端框架的边界问题。页面结构上,核心页面有五个:登录页、课程列表页(首页)、课程详情页、我的课表页、个人中心页。
课程列表页是整套系统的门面,学生打开小程序第一个看到的就是它。这里有一个交互细节值得品:课程列表不要一次全部加载,要按条件筛选。我在列表页顶部放了学期选择器、课程性质筛选框、关键字搜索框,配合后端的分页接口,每页10条数据,用onReachBottom实现上拉加载更多。分页参数用pageNum和pageSize,返回格式是{total, list},total给前端算总页数用。
我的课表页用了一个很笨但好用的小技巧:按周一到周日固定七行,把已选课程的节次映射到对应行里,左侧显示第1-2节、3-4节这些时间段,右侧用颜色块标出课程名称。这个页面其实不需要任何复杂组件,纯用 CSS Flex 布局加wx:for循环就能画出满意的课表,关键点是课程时间解析要在后端做好,小程序端只负责渲染,不要在setData里做大量的字符串切割运算,会卡顿。
4. 踩坑实录:常见问题与排查技巧
这部分按真实开发中遇到的高频问题整理,从后端到前端再到底层数据,能给正在写代码或者调试的你省不少时间。
4.1 跨域拦截与请求头丢失
小程序发请求不存在浏览器跨域的问题,但开发调试时如果你用微信开发者工具的本地模式,且后端接口没跑在DNS上,会碰到https://域名白名单不过的报错。解决办法是在微信开发者工具中勾选"不校验合法域名",但要注意这只是开发期的临时方案,真机预览时必须配置HTTPS域名并到微信公众平台添加白名单,否则手机上打不开接口。
后端跨域问题的重灾区其实是自定义Filter或拦截器里对OPTIONS请求的处理。如果你在小程序端自定义了header,比如Authorization,后端在CORS配置里必须显式声明allowedHeaders("*")或者把Authorization加进去,否则部分网络框架会拦截掉预检请求,前端表现为"请求失败"而不是"401"。
4.2 登录态失效与token刷新
小程序端的token过期是最常见的隐性故障,表面上看是"用户突然被登出",实际是后端拦截器返回401后,小程序端没有统一处理这个状态码,而是直接展示错误提示。我的建议是在小程序端的request工具函数里做全局拦截:如果响应码是401,清空本地缓存,跳转到登录页重新授权。这个处理要在封装层解决,不能在每一个具体请求里重复写,否则代码会非常碎片化。后端做定时登录态清理时也能节省大量存储空间。
4.3 并发选课超卖问题
超卖问题的根源是"检查与扣减分离",代码里先用select查了一波数量,再执行update扣减,两步之间产生了空隙。我曾经把容量校验和更新写成了两步操作,测试时单线程没事,一到并发就出问题,用Jmeter压了50个并发线程,超卖率几乎100%。后来改成前面提到的"条件更新",把selected_count < total_capacity放进update的where条件里,压测数据立刻归零。这个问题的解法思路是通用的,凡是涉及库存扣减的场景都可以套用。
4.4 前后端时间格式不一致
时间格式的问题看起来鸡毛蒜皮,真遇到了能让你排查一晚上。后端返回的时间类型默认是yyyy-MM-dd HH:mm:ss,但如果用@JsonFormat没配好,可能会变成一串时间戳数字。小程序端对new Date(时间戳)的处理没问题,但如果你把字符串直接渲染到页面上,展示的就是难以阅读的格式。我的方案是后端统一返回标准字符串格式,在实体类的日期字段上加@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8"),避免小程序的时区偏移导致页面时间显示差8小时。
5. 从源码到上线:部署步骤与扩展建议
很多同学拿到源码后第一句话就是"怎么跑起来",这一部分我把从零到能访问的完整步骤列一遍,顺便把部署到服务器上的注意点讲透。
5.1 本地环境快速跑通
跑通这套系统需要的软件环境如下:JDK 1.8及以上、Maven 3.6+、MySQL 5.7或8.0、微信开发者工具最新稳定版、Nginx(可选,本地调试不需要)。
第一步,创建数据库并导入项目里的sql目录下的初始化脚本,里面有建库建表和默认数据的SQL,默认管理员账号密码在脚本里有标注。第二步,修改application.yml里的数据源配置,把数据库地址、账号、密码改成你自己的。第三步,在项目根目录执行mvn spring-boot:run,看到端口启动成功的日志就说明后端起来了。第四步,用微信开发者工具导入小程序端目录,在app.js或utils/config.js中把接口请求地址改成http://localhost:8080,如果开发工具提示域名不合法,在详情勾选不校验合法域名。最后一步,编译小程序,用管理员账号登录管理后台,审核发布几门课程,再用学生账号登录小程序,选课流程基本就能跑通了。
这里有一条重要提示:默认数据库中的数据是配套好的,不要随意删减核心表的数据,否则联调时容易缺字段。很多初学者一上来就把管理员账号删了,结果后台进不去只能重置数据库。
5.2 服务器部署注意点
如果要把项目部署到云服务器上,有几个点比本地跑通更需要注意。MySQL数据库要设置合理的编码,建库时指定utf8mb4,否则课程名称里的特殊字符会乱码。Spring Boot打的jar包执行时需要指定环境变量或外部配置文件,建议用java -jar xxx.jar --spring.profiles.active=prod的方式隔离生产配置,不要把密码硬编码在代码里。小程序端发起的HTTPS请求需要域名备案,并且域名必须配置SSL证书,这是上线前最大的一个坎。
部署到服务器后还有一个容易忽略的问题:防火墙和安全组策略,默认要放行8080端口,否则外部请求全部超时。我用Nginx做反向代理时,把/api/前缀的请求转发到Spring Boot的8080端口,小程序端实际请求的是https://你的域名/api/xxx,这样后端服务对外完全屏蔽,也能统一处理静态资源缓存和Gzip压缩。
5.3 后续扩展方向
源码基础上想加亮点功能的话,优先级最高的有三个方向,第一是消息推送,选课成功后通过小程序的订阅消息向学生推送通知,需要到微信公众平台申请消息模板并配置模板ID;第二是课表导出,可以把已选课程导出成iCal格式,方便学生导入到手机日历,这个功能在答辩时特别容易引起老师兴趣;第三是选课时间分批次开放,比如研究生和本科生分批开放选课,在课程表里加一个select_start_time字段就能实现,学生端到点才能看到该课程的可选状态。
我个人在实际操作中最大的体会是:这类系统真正的难点往往不在于某个技术栈多深奥,而在于把业务边界想清楚,表结构能不能撑住需求变化,接口设计有没有预留扩展点。拿到源码后别急着炫技改架构,先把选课流程全线走通,观察数据在每个环节的变化,理解每个字段存在的必要性,然后再谈优化和重构。你在跑通流程过程中踩到的每一个坑,都会比抄十遍代码更值钱。