去年带一个学弟做完“基于微信小程序的高校学生选课系统毕业设计”,前前后后折腾了三个月。这个选题在毕业设计里属于“经典中的经典”——看起来满大街都是,但真正能把选课并发、时间冲突、微信登录态、容量控制这些点讲清楚、做利索的,答辩时其实少之又少。正好最近又有几个同学在问,我把当时的技术方案、设计思路、踩坑记录整理成文,从需求拆分到数据库设计,从前端页面到后端接口,再到真机调试的常见坑,一次说透。适合正在做这个课题的本科生、想用小程序做管理系统的同学,以及打算从零手写一个完整全栈项目的开发者参考。
1. 项目方案选型与整体设计思路
1.1 为什么选微信小程序而不是Web网页或原生App
高校选课系统面向的是学生和教务管理人员,使用场景高度集中在校园内、课间、宿舍,对“随时打开看一眼课表/抢课”的需求远大于对复杂数据处理的需求。微信小程序最大的优势是免安装、触达率高、分享方便,学生扫个码就能用。相比Web网页,小程序在移动端的适配成本低得多,不用考虑浏览器兼容;相比原生App,又省掉了安装包、签名、上架审核的整套流程,对毕业设计这种短周期项目非常友好。
官方开发工具自带调试器、真机预览、云开发,环境搭建半小时内搞定。另外小程序有统一的登录体系(微信授权),省去了自己设计账号密码注册的麻烦。当然也有代价,比如必须走HTTPS合法域名、包体不能太大、部分API有审核门槛,这些在设计之初就要留出余地。
1.2 后端与数据库选型对比
后端我推荐用 Spring Boot + MyBatis-Plus + MySQL,理由很直接:选课系统核心是“数据关系”和“事务并发”,Spring Boot生态成熟、资料多、答辩时老师也认;MyBatis-Plus提供单表CRUD和条件构造器,写业务代码效率极高;MySQL则完全够用,没必要上PostgreSQL或Oracle。如果你不熟悉Java,用Node.js(Express/Koa)+ MongoDB也能实现,但在毕业设计里,“技术主流度和可解释性”往往比“技术新潮”更重要。
下表是我当时对比的几个组合:
| 组合方案 | 优点 | 缺点 | 适合人群 |
|---|---|---|---|
| Spring Boot + MyBatis-Plus + MySQL | 资料最多,事务控制成熟,答辩友好度高 | 环境配置相对重 | 有Java基础,想求稳 |
| Node.js + Express + MySQL | 轻量,前后端都用JS | 事务和并发控制要自己多留意 | 前端转全栈 |
| Django + SQLite/MySQL | 自带admin后台,快速 | Python环境部署稍麻烦 | Python熟练 |
| 小程序云开发 | 无需自己搭服务器,免域名 | 学生旧版选课需求复杂时不好写事务 | 纯前端、想最短路径完成 |
我最终选了第一种。选课这个场景对事务和并发的要求比较典型,如果拿云开发写,数据库触发器那套反而更绕。
1.3 系统架构设计的四个层次
整个系统我拆成了四层:小程序前端、后端接口层、业务逻辑层、数据存储层。前端只负责页面展示和用户交互,所有业务判断都放在后端。
- 小程序前端:页面抽象为“首页/选课大厅/我的课表/成绩/个人中心”五个Tab页,加上登录页。
- 后端接口层:提供RESTful接口,统一返回格式为
{ code, message, data }。 - 业务逻辑层:处理选课/退课的事务、课表冲突判断、容量判断、权限校验。
- 数据存储层:MySQL,使用InnoDB引擎。
这样分层的核心理由是“小程序端不进数据库、不做核心判断”。如果把容量判断写在前端,只要有人请求先发后改,或者伪造请求绕过页面,系统分分钟被选爆。毕业设计的功能未必多复杂,但架构上的正确性要体现出来,这也是答辩时最容易加分的地方。
另外一个容易忽略的点是统一返回结构。我见过不少同学直接返回一个JSON对象,前端拿到再猜字段。我当时的做法是:
{ "code": 0, "message": "success", "data": { ... } }code非0就代表失败。前端封装一个request函数统一处理,遇到code不为0直接弹toast,省去大量重复逻辑。这个统一契约一定要先定好,否则前后端联调会非常痛苦。
2. 核心功能模块与数据库设计细节
2.1 功能模块拆解:学生端和教务端
选课系统按用户角色分,学生端、教师端、管理员端。毕业设计通常重点做学生端,教师和管理员可以简化。我当时把学生端作为核心,教师端只做了“查看我教的课和选课名单”,管理员端只做了“开设课程/教学班、维护学生名单”。
学生端功能清单:
- 微信登录绑定学号
- 浏览可选课程(按学院、分类筛选)
- 选课(有容量限制,有时间冲突检测)
- 退课(在退课截止时间内)
- 我的课表(按周显示)
- 成绩查询(按学期显示)
- 个人信息维护
这里有一个很重要的认知:选课系统不等于简单的“课程CRUD”。难点在于一门课程往往有多个教学班(不同老师、不同时间、不同地点),学生选的是教学班,不是课程本身。所以数据库设计时不能只建一张“课程表”,必须拆出“课程表”和“教学班表”。
2.2 数据表设计:七张核心表的字段与关系
我的数据库最终设计了7张表:学生表、教师表、课程表、教学班表、选课记录表、成绩表、学期表。逐一说字段。
学生表(student)
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| student_no | varchar(20) | 学号,唯一 |
| name | varchar(50) | 姓名 |
| major | varchar(50) | 专业 |
| grade | varchar(10) | 年级 |
| openid | varchar(64) | 微信openid |
| phone | varchar(20) | 联系方式 |
openid字段一开始就留好,否则后面接微信登录会发现无从下手。openid也可以建唯一索引,防止一个微信反复绑定多个学号。
课程表(course)
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| course_code | varchar(20) | 课程编号 |
| course_name | varchar(100) | 课程名称 |
| credit | decimal(3,1) | 学分 |
| course_type | varchar(20) | 必修/选修 |
| department | varchar(50) | 开课学院 |
教学班表(course_section)
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| course_id | bigint | 课程id,外键 |
| teacher_id | bigint | 教师id |
| section_name | varchar(50) | 教学班名称,如“高等数学A-01班” |
| capacity | int | 容量 |
| selected_count | int | 已选人数 |
| semester | varchar(20) | 学期,如“2024-2025-1” |
| week_start | int | 开始周 |
| week_end | int | 结束周 |
| day_of_week | tinyint | 星期几,1-7 |
| start_section | tinyint | 开始节次 |
| end_section | tinyint | 结束节次 |
| classroom | varchar(50) | 教室 |
选课时间冲突判断全靠week_start、week_end、day_of_week、start_section、end_section这几个字段。不要为每一天、每一节重复建记录,那样数据量巨大且无法优雅判断交叉。用“周几+节次区间+周次区间”描述一个教学班的排课信息。
一个教学班可能有多个时间段吗?比如周一第1节到第2节在A楼,周三第3节到第4节在B楼。如果这样,就应该拆成多条“上课时间记录”,不要为了省事放一个字段里。我一开始简单设计了单个时间段,后来发现很多课一周两次,只能重构。所以教学班表应该拆成“教学班主表 + 教学班时间表”,时间表记录section_id, week_start, week_end, day_of_week, start_section, end_section, classroom。下面是教学班时间表:
教学班时间表(section_time)
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| section_id | bigint | 教学班id |
| week_start | int | 开始周 |
| week_end | int | 结束周 |
| day_of_week | tinyint | 周几 |
| start_section | tinyint | 开始节次 |
| end_section | tinyint | 结束节次 |
| classroom | varchar(50) | 教室 |
选课记录表(course_selection)
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| student_id | bigint | 学生id |
| section_id | bigint | 教学班id |
| selection_time | datetime | 选课时间 |
| status | tinyint | 0-退课,1-正常,2-已删除 |
这张表一定要加唯一索引:(student_id, section_id)。否则同一个学生可以重复插入同一条选课记录,后续统计就全乱了。最关键的是,status设计成“状态标记”而不是直接删记录,保留选课历史,对日后查问题很关键。
成绩表(score)
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| student_id | bigint | 学生id |
| course_id | bigint | 课程id |
| score | decimal(4,1) | 成绩 |
| semester | varchar(20) | 学期 |
教师表、学期表不赘述,学期表就是存当前学期名和选课起止时间,方便系统判断“是否在选课时间段内”。
2.3 容量与时间冲突的边界条件
容量控制的逻辑大家都懂:教学班的selected_count < capacity就能选。但有个容易忽略的边界:退课之后再选,同一时刻只允许一个学生在一个教学班有一条status=1的记录。还有开学第一周允许退课、之后锁定,这种业务规则最好做成接口参数或配置文件,不要写死在SQL里。
时间冲突判断必须考虑跨周次的情况:如果已选课程是1-8周,新选课程是9-16周,虽然都是周一第1-2节,但实际并不冲突。所以不能用简单的“同日同时段”就判定冲突,必须比较周次区间是否有交集。我写的判断SQL逻辑如下(伪代码):
SELECT COUNT(*) FROM course_selection cs JOIN section_time st ON cs.section_id = st.section_id WHERE cs.student_id = #{studentId} AND cs.status = 1 AND '#{newWeekStart}' <= st.week_end AND '#{newWeekEnd}' >= st.week_start AND st.day_of_week = #{newDayOfWeek} AND '#{newStartSection}' <= st.end_section AND '#{newEndSection}' >= st.start_section这个条件本质上就是“两个区间相交”的判断:新课程周次不早于已选课程的结束周、新课程结束周不晚于已选课程的开始周,同时星期相同、节次区间相交。这样能覆盖部分重叠、完全包含、跨周等场景。
3. 实操过程与核心功能实现
3.1 微信登录与学号绑定流程
小程序登录不是直接拿用户名密码,而是通过wx.login()获取临时code,后端拿这个code去微信接口换openid和session_key。然后后端自己生成一个业务token返回给前端。为什么不能直接把openid当token?因为openid是敏感标识,且固定不变,如果小程序包被反编译,存在本地存储里的openid会被冒用。用缓存token,过期时间设为2小时,刷新后重新签发,安全得多。
小程序端代码:
// 登录页 wx.login({ success: async (res) => { const code = res.code const result = await request.post('/auth/login', { code, studentNo: this.data.studentNo, name: this.data.realName }) if (result.code === 0) { wx.setStorageSync('token', result.data.token) wx.switchTab({ url: '/pages/index/index' }) } else { wx.showToast({ title: result.message, icon: 'none' }) } } })后端处理:
@PostMapping("/auth/login") public Result login(@RequestBody LoginDTO dto) { // 1. 用 code 调用微信 auth.code2Session 接口 // 2. 根据返回的 openid 查询 student 表 // 3. 如果该 openid 还没绑定学号,则用 dto.studentNo + dto.name 绑定 // 4. 生成随机 token,存入 redis 或内存缓存,返回前端 }这里要注意顺序:先用openid查学生,如果查不到,再让用户输入学号和姓名进行绑定。不要让用户直接注册成新账号,否则一个学校可以出现几百个“张三”且无法管理。绑定后,学号和openid就一一对应。
3.2 选课接口的并发与事务控制
选课是最容易出并发问题的地方。想象一下,一个热门课程容量200人,开选瞬间2000人同时点击“选课”,如果代码写成:
UPDATE course_section SET selected_count = selected_count + 1 WHERE id = ? AND selected_count < capacity;MySQL会自动对这条更新加行锁,只要判断条件为真才更新,能挡住大部分超卖。但如果后面又有插入选课记录,就不能保证原子性了,必须用事务保证“更新计数”和“插入选课记录”同成功同失败。
我更推荐方式是这样:
- 在事务中
SELECT * FROM course_section WHERE id = ? FOR UPDATE,查询当前已选人数。 - 判断
selectedCount < capacity,不满足直接抛异常回滚。 - 更新
selected_count = selected_count + 1。 - 插入选课记录。
- 提交事务。
用FOR UPDATE把该教学班的行锁住,其他请求等待。注意必须保证事务的隔离级别至少是“读已提交”,否则两个事务同时读到旧值会造成超选。在Spring Boot里加@Transactional注解,配合SELECT FOR UPDATE是稳的。
核心代码片段:
@Transactional public void selectCourse(Long studentId, Long sectionId) { CourseSection section = courseSectionMapper.selectForUpdate(sectionId); if (section == null) { throw new BusinessException("教学班不存在"); } if (section.getSelectedCount() >= section.getCapacity()) { throw new BusinessException("该教学班已满员"); } // 检查时间冲突 if (checkTimeConflict(studentId, section)) { throw new BusinessException("时间冲突,请重新选择"); } // 更新人数 courseSectionMapper.increaseSelectedCount(sectionId); // 插入选课记录 courseSelectionMapper.insert(new CourseSelection(studentId, sectionId, 1)); }selectForUpdate对应的Mapper写法:
<select id="selectForUpdate" resultType="com.example.entity.CourseSection"> SELECT * FROM course_section WHERE id = #{id} FOR UPDATE </select>实际测试过,当并发量在每秒百次以内时,这个方案完全没有问题,对于课程容量通常几十到几百的高校选课场景足够。真要面对全校同时抢课的高并发,得引入Redis预扣减和消息队列,但毕业设计不用写到那一步,逻辑和事务处理好已经能说明问题。
3.3 课表的动态生成与前端展示
课表展示有一个常见误区:直接把后端查出来的数据平铺到页面上。正确做法是根据“周次”动态生成,比如第一周周一第1节有课,第二周周一没课。前端用一个二维数组[week][day][section],展示一个表格。
我后端接口返回的是学生已选教学班的信息,包括课程名称、教师、教室、周次区间、节次区间,前端再裁剪成“1-16周的单双周”效果:
function isWeekActive(week, start, end) { return week >= start && week <= end }然后渲染时循环20周,每周的每一天、每个节次判断是否有课。如果某个教学班单双周不同(比如单周上、双周不上),可以用weekInterval字段表示间隔,比如1-15表示每周,1-13但步长为2表示单周。设计中加一个week_step字段就能支持。这是很多同学忽略的细节,建议一开始就纳入设计。
前端课表页面用<view>网格布局,横向是“周一”到“周日”,纵向是节次,每节课用绝对定位的色块展示。对初学者来说,不熟悉绝对定位的话,用最简单的表格布局也够用,只需保证数据准确。
3.4 个人成绩查询与信息展示
成绩查询相对简单,就是按学期查选课记录和成绩表联表。需要注意,如果学生选了课但还没出成绩,成绩字段为null,前端要显示“暂无”而不是空字符串或0。这里我踩过一个坑:后端直接返回0,前端显示0分,不少同学来问“我是不是挂科了”。后端的空值判断不要省。
3.5 教师端与管理端的轻量实现
毕业设计时间有限,教师端可以只做“查看教学班学生名单、录入成绩”。管理员端只做“开课、设定教学班时间地点、调整容量”。我当时的做法是复用同一套登录体系,通过学生表的role字段区分角色,小程序端根据角色显示不同页面。这个办法简单、不用单独做后台管理系统,答辩演示时也够直观。
如果你想要一个更完整的后台,也可以单独用一个Vue管理端,但那相当于又做了一个中大型前端项目。除非时间充裕,否则建议控制范围。
4. 常见问题与排坑实录
4.1 request合法域名与真机调试验证
本地调试时可以在开发者工具里勾选“不校验合法域名、web-view(业务域名)、TLS版本以及HTTPS证书”,但真机预览就不行了。真机要求所有请求地址必须是HTTPS,且在微信公众平台后台配置了request合法域名。毕业设计最常见的卡点是:电脑上能跑,手机上一请求就报错fail url not in domain list。
解决办法:申请一个云服务器,绑定备案过的域名(或者用已经备案的),配好Nginx反向代理和SSL证书,把后端服务挂上去。如果不想备案,可以临时用云开发环境或者内网穿透做演示,但答辩现场存在不稳定性,最好有备案域名。
我当时的做法是在后端接口测试阶段用IP+端口调试,把“不校验合法域名”勾上,等最后部署阶段再切到正式域名。注意:小程序体验版也受真实域名限制,不能拿IP直接访问。
4.2 微信授权弹窗与用户拒绝授权
很多同学喜欢一进小程序就弹授权框要头像昵称,结果用户拒绝后就死循环。正确做法是先让用户进入系统,等到真正需要时才调用授权。我采用的策略是:第一次打开显示一个“绑定学号”的引导页,用户主动输入学号姓名绑定,全程不强制弹任何授权框。头像和昵称通过button open-type="chooseAvatar"让用户主动触发,而不是启动时自动弹。
getUserProfile接口已经调整,无法直接获取用户的头像和昵称,用open-data又限制很多。所以最稳妥的方案就是让用户手动填写或选择头像。不要在这个问题上钻牛角尖,选课系统根本不需要真实头像昵称。
4.3 选课并发测试时发现超选
我写完选课接口后,用JMeter模拟50个并发同时选一个容量为10的教学班,结果选出了13条记录。排查后发现是事务没有生效,原因是我在同一个类内部的调用selectCourse(this, ...)绕过了Spring事务代理。解决办法是注入自己的Service代理,或者把内部调用改成另一个Service调用,保证事务注解生效。
还有一个坑:SELECT FOR UPDATE必须用在“事务内”才有效。如果外层没加@Transactional,那锁会在语句执行完的瞬间释放,等于没有锁。我刚开始就是这样,看了半天MyBatis日志也没发现问题。后来加了一层Service,事务边界正确,重新压测,结果稳定在10条。
4.4 时间冲突判断的周次重叠问题
有同学反映“同一时间选了1-8周的高数和9-16周的大物,系统判定冲突”。原因就是代码只比较了星期和节次,忽略周次区间。上面给出的相交判断公式要写进SQL或Java代码里,而不是用简单的等于比较。我当时的测试用例里专门加了几组边界数据:
- 已选1-8周,新选9-16周:应该不冲突
- 已选1-8周,新选8-16周:应该冲突(第8周重叠)
- 已选1-8周,新选1-8周:冲突
- 已选1-8周,新选2-7周:冲突(新课程被包含)
用自动化测试把这些用例跑一遍,能有效避免答辩现场翻车。
4.5 小程序包大小与图片资源优化
小程序主包不能超过2MB,超过就没法直接上传。选课系统本身不大,但如果后端返回的课程图片或图标过多,或者把图片Base64塞进WXML里,很容易爆。我的建议是:
- 课程封面图和图标使用云存储或OSS链接,不在包里放本地图。
- 所有列表接口采用分页,
pageSize一般设20。 - 公共组件代码及时抽取,减少重复。
编译时留意开发者工具右上角“代码包”一栏,如果接近2MB,优先压缩图片,删除无用代码,最后再考虑分包加载。
4.6 缓存策略与请求封装
为了让选课列表响应快,我设置了wx.setStorage缓存课程分类基础数据,每5分钟刷新一次。但要注意:选课时不能用缓存的“已选人数”做任何判断,必须实时请求后端。因为缓存数据是旧的,页面显示“已满”其实是上次加载时的状态。我在前端把“查看详情”和“选课按钮”区分开,点击选课时重新拉取最新接口。
请求封装方面,我写了一个request.js,统一包一层:
const request = (url, method, data) => { return new Promise((resolve, reject) => { wx.request({ url: API_BASE + url, method, data, header: { 'Content-Type': 'application/json', 'Authorization': wx.getStorageSync('token') }, success: (res) => { if (res.data.code === 401) { wx.navigateTo({ url: '/pages/login/login' }) reject(new Error('未登录')) } else if (res.data.code === 0) { resolve(res.data.data) } else { wx.showToast({ title: res.data.message, icon: 'none' }) reject(new Error(res.data.message)) } }, fail: (err) => { wx.showToast({ title: '网络异常', icon: 'none' }) reject(err) } }) }) }统一处理token过期和网络错误,项目联调时省下的时间非常可观。
4.7 答辩演示前的三个检查项
最后提醒几点答辩演示的坑:
- 手机预览时,确保后端服务已部署到服务器,且手机和服务器网络通畅。不要依赖本机
localhost。 - 演示前把“体验版”二维码提前准备好,不要现场扫码等加载。
- 数据要准备一个“有课表、有已选课程、有成绩”的演示账号,避免现场注册新账号没有数据可看。
我当时演示就吃过亏:现场手机扫完码,首页是空的,因为测试账号忘了预处理。后来我写了一个initData接口,传入学生学号,自动给他预置几门课程和历史成绩,演示前点一下就搞定。
写在最后的一点点经验
这个项目做完,我最大的体会是:选课系统的难点从来不在“功能多”,而在“边界到底有没有想清楚”。容量、时间冲突、并发、登录态,每一个点拆开都不难,合在一起就考验工程思维了。如果你正在做这个课题,尽量不要急着堆功能,先花一天把表结构和业务规则理清楚,比多写十个页面都管用。
我自己在踩过超选和时间冲突的坑之后,后来设计任何带状态的系统,都会先梳理事务边界和唯一约束。这可能是毕业设计除了一篇论文之外,最能留下来的一点东西。希望这篇记录能帮你少走几段弯路。