线上选课系统设计与实现:从SSM到Django的完整实践
2026/9/20 3:49:09 网站建设 项目流程

这是一个很典型的选题:线上选课系统。我最近刚完整做了一套,而且同时用 Java+SSM 和 Django 各实现了一版。很多同学一听到“选课系统”就觉得是教务那种庞然大物,其实拆开来看,核心就是用户、课程、选课记录这几张表,再加上权限控制和冲突检测。难度卡在“规则怎么落地”和“并发时怎么不出错”,而不是页面多炫。

我写这篇,就是把我的设计思路、表结构、核心代码、部署调试过程完整梳理一遍。你是正在做课程设计、毕业设计,还是想自己搭个选课平台,照着这套逻辑走基本不会跑偏。

1. 选课系统到底在解决什么问题

1.1 教务场景里的选课痛点

我先说一个我在实际沟通中遇到的场景。某个二级学院,三千多名学生,每学期开课一百多门。以前选课靠什么?靠班长汇总,靠教务员手动录入,再把名单贴出来让同学核对。痛点很直接:课程容量超了没人知道,上课时间撞了也没人管,最后汇总成 Excel 表格耗时一两天,中间一改课表整个文件就崩。

线上选课系统要解决的,就是把这套流程数字化:学生在规定时间内自主选课,系统实时校验容量、检查上课时间冲突,教师能查看自己课程的学生名单,管理员能统一维护课程和选课状态。选课高峰那几分钟,还要扛住并发请求,不能多选、不能超选。

1.2 需求边界:不是教务系统,是“选课管理工具”

我第一次拿到这类需求时,对方张口就是要实现“像学校教务系统那样的完整平台”,我直接给劝退了。线上选课系统的范围应该很克制,核心就三个角色加三条链路:

  • 学生端:登录、查看可选课程、选课、退课、查看已选课程。
  • 教师端:登录、查看自己名下的课、查看选课名单、录入成绩。
  • 管理员端:课程管理、学生和教师账号管理、选课状态管理。

技术上的核心难点集中在两块:一是角色权限怎么控制,学生只能操作学生该操作的功能,老师不能去选课;二是选课规则校验的完整性和并发情况下的可靠性。搞清楚这两个点,整个系统的脉络就清晰了。

我在设计时还额外注重了一点:功能边界要可视、可解释。这也直接影响了后面的数据库设计和接口设计。

2. 为什么我同时用 SSM 和 Django 各写了一套

2.1 SSM 和 Django 分别是什么定位

SSM 是 Java Web 里非常经典的组合:Spring 管对象和事务,SpringMVC 管请求分发,MyBatis 管数据库操作。它的特点是分层清晰、控制灵活,适合有一定 Java 基础的人学习和扩展。

Django 是 Python 生态里的重量级 Web 框架,自带 ORM、Admin 后台、认证体系和表单处理。它的特点是“约定大于配置”,很多功能开箱即用,开发效率明显更快。

其实两套技术栈在能力上完全对等:都能做登录认证、都能操作数据库、都能做复杂的业务校验。差别在于写代码的组织方式和生态习惯。下面是我整理的一个对照表:

对比维度SSM(Java)Django(Python)
请求处理SpringMVC 的 ControllerView 或 DRF 的 ViewSet
数据库操作MyBatis 手写 SQLORM 模型,支持复杂查询
模板页面JSP / ThymeleafDjango Templates
认证授权自己写拦截器或整合 ShiroDjango Auth + 装饰器
管理后台自己开发页面Admin 二次开发
部署环境Tomcat + JDKGunicorn / Nginx + Python
学习曲线中间件配置多,偏陡上手快,但深入要理解 ORM

2.2 我这边的实际选型思路

同一个项目写两套,不是炫技,而是因为在课程设计的场景里,组员的技术基础不一样。有的人 Java 课程学了一学期,SSM 顺手;有的小组打算用 Python 方向,Django 更容易在短时间内做出效果。

我的做法是先固定一个功能清单,再按技术栈分别落地。前端尽量用简单的 Bootstrap 或原生页面,后端逻辑两边分别实现,这样每个人都能看到自己熟悉技术下的完整数据流:登录认证、请求拦截、业务校验、数据库写入、页面反馈。

这里有一个很实用的经验:两套系统共享一份数据库结构文档,但代码完全各自独立。不要试图在 Java 里调用 Python 代码,也不要把业务逻辑混在页面里。边界清晰,调试才轻松。

2.3 两套实现如何保持功能一致

我的做法是先列一张功能对照表,再开始写代码。表里每一行是一个用户操作,比如“学生提交选课”,后端要做的动作就全部写清楚:校验登录、校验选课时间、校验课程容量、校验时间冲突、写入选课表。

这份功能清单既是开发依据,也是后续写调试文档时的目录。如果一个功能在 SSM 里做了、在 Django 里没做,对照表一眼就能看到。正因为有这一步,后期两边联调基本没出现“一个系统能退课另一个不能”的问题。

3. 数据库设计与核心表结构

3.1 面向需求设计表关系

选课系统的核心表我一般拆成五张:用户表、课程表、开课安排表、选课记录表、学期表。有的系统把学生信息和教师信息单独建表,我不太建议做过度拆分,课程设计这个体量下,一张用户表加类型字段就够用,查询更简单。

表关系是这样的:

  • 课程表与开课安排表是 1 对 N:同一门课可以安排多个教学班。
  • 开课安排表与选课记录表是 1 对 N:一个教学班对应多条选课记录。
  • 用户表与选课记录表也是 1 对 N:一个学生可以有多条选课记录,但同一学期同一门课只能选一次。

为了控制边界,我还在开课安排表里加了容量字段,在选课记录表里加了联合唯一约束。这个约束后面细说。

3.2 关键表结构说明

下面是我实际使用的核心建表 SQL,做了简化,便于阅读:

CREATE TABLE sys_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, real_name VARCHAR(50) NOT NULL, user_type TINYINT NOT NULL COMMENT '1-学生 2-教师 3-管理员', grade VARCHAR(20), major VARCHAR(50), create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE course ( id INT PRIMARY KEY AUTO_INCREMENT, course_name VARCHAR(100) NOT NULL, course_code VARCHAR(30) NOT NULL UNIQUE, credit DECIMAL(3,1) NOT NULL, teacher_id INT NOT NULL COMMENT '关联 sys_user.id,教师用户', capacity INT NOT NULL DEFAULT 60, selected_count INT NOT NULL DEFAULT 0, schedule_time VARCHAR(100) NOT NULL COMMENT '如 周一1-2节 或 JSON 结构', semester VARCHAR(20) NOT NULL COMMENT '如 2024-2025-1', status TINYINT NOT NULL DEFAULT 0 COMMENT '0-未开始 1-选课中 2-已结束' ); CREATE TABLE selection ( id INT PRIMARY KEY AUTO_INCREMENT, student_id INT NOT NULL, course_id INT NOT NULL, selected_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, score DECIMAL(5,1), UNIQUE KEY uk_student_course (student_id, course_id) );

选课记录表里没直接存 student_name 和 course_name,只存 id。这样设计的理由是减少冗余,改名字不用到处同步。但是查询时就要 JOIN 两张表,对新手来说是一个需要习惯的地方。

3.3 联合唯一约束的意义

我在很多同学的代码里看到过这种情况:选课前先查一次,发现没选过,然后执行 insert。这在单用户操作时没问题,一旦两个请求同时进来,就可能两个都通过查询,两个都插入成功,同一个学生选了同一门课两遍。

数据库层面的联合唯一约束就是兜底方案。哪怕后端代码写得再糙,数据库也会阻止重复插入。这类约束的优先级非常高,很多时候“数据库约束比代码判断可靠”不是一句空话。

4. 登录、角色权限与请求拦截

4.1 三种角色对应的权限拓扑

权限控制我采用最直白的思路:登录后将用户对象和角色类型写入 Session。学生只能访问 /student/** 路径,教师只能访问 /teacher/,管理员只能访问 /admin/。后端每个 Controller 都放在对应的路径前缀下。

这种方式虽然没有 Spring Security 或 Shiro 那么强的细粒度权限管理,但在课程设计的体量下完全够用,而且更容易给答辩老师讲清楚。

理解 RBAC 有一个很朴素的例子:门禁卡只决定你能进哪几栋楼,刷开之后每一层的具体房间再有自己的权限。学生卡进不了教师办公室,教师卡进不了机房管理间。

4.2 SSM 拦截器的写法与配置

SSM 里我用一个拦截器实现白名单之外的路径校验:

public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session = request.getSession(); Object loginUser = session.getAttribute("loginUser"); if (loginUser == null) { response.sendRedirect(request.getContextPath() + "/login"); return false; } String uri = request.getRequestURI(); User user = (User) loginUser; if (uri.startsWith("/admin/") && user.getUserType() != 3) { response.sendError(HttpServletResponse.SC_FORBIDDEN); return false; } if (uri.startsWith("/teacher/") && user.getUserType() != 2) { response.sendError(HttpServletResponse.SC_FORBIDDEN); return false; } return true; } }

Spring 配置里这样注册拦截器,需要说明的是里面的排除路径要写全,否则登录页本身也会被拦,引起死循环:

<mvc:interceptors> <mvc:interceptor> <mvc:mapping path="/**"/> <mvc:exclude-mapping path="/login"/> <mvc:exclude-mapping path="/static/**"/> <bean class="com.example.interceptor.LoginInterceptor"/> </mvc:interceptor> </mvc:interceptors>

4.3 Django 这边的权限实现

Django 自带的认证体系帮我省了很多事,核心模型继承 AbstractUser,再增加 user_type 字段。视图装饰器做权限控制:

from functools import wraps from django.http import HttpResponseForbidden from django.shortcuts import redirect def role_required(user_type): def decorator(view_func): @wraps(view_func) def wrapper(request, *args, **kwargs): if not request.user.is_authenticated: return redirect('/login/') if request.user.user_type != user_type: return HttpResponseForbidden("无权访问") return view_func(request, *args, **kwargs) return wrapper return decorator

使用的时候:

@login_required def student_dashboard(request): return render(request, 'student/dashboard.html')

两套实现其实在思想上是一样的:先确认登录身份,再确认角色权限。只是 SSM 用的是拦截器注册,Django 用的是装饰器。答辩时如果能把这个对应关系讲清楚,老师对你的印象分会有明显提升。

5. 选课与退课的核心逻辑实现

5.1 选课业务规则拆解

选课不是简单往表里插一条记录,要按顺序验证好几条规则。我把它写成伪代码,无论在 SSM 还是 Django 里都能直接翻译:

1. 用户必须已登录且角色是学生 2. 当前时间必须在课程允许的选课窗口内 3. 该学生没选过同一门课(唯一约束兜底) 4. 课程剩余名额大于 0 5. 该学生选的所有课程中,不存在上课时间冲突 6. 满足以上条件后,事务内插入选课记录并更新课程已选人数

前三步都是前置判断,真正的并发难点在第四步到第六步。这一步需要把“检查剩余名额”和“更新已选人数”做成原子操作。

5.2 时间冲突检测的两种实现

时间冲突检测是选课系统里最容易出 bug 的地方。我采用最简单、也最容易讲解的约定:课程表里的上课时间用“周几+节次”保存,比如“周一3-4节”。选课前取出学生所有已选课程的时间字段,再做集合比较。

在 Java 里可以这样比较:

public boolean hasTimeConflict(List<String> existedTimes, String newTime) { Set<String> timeSet = new HashSet<>(existedTimes); return timeSet.contains(newTime); }

在 Django 里可以用 ORM 查询:

def has_time_conflict(student, new_course): selected_courses = Course.objects.filter( selection__student=student, selection__course__semester=new_course.semester ) for c in selected_courses: if c.schedule_time == new_course.schedule_time: return True return False

严格来说,真实教务系统里“周一3-4节”和“周一5-6节”可能跨中午算不算冲突,需要考虑节次计算。课程设计里我建议把所有时间段精确到“周几+第几节+单双周”,然后统一比较,就能避免绝大多数的细节坑。

5.3 并发选课后端如何兜底

选课高峰时最怕“超卖”,也就是只剩最后一个名额,几十个人同时抢,最后系统选了多人。我在 SSM 版本里用条件更新解决:

interface CourseMapper { @Update("UPDATE course SET selected_count = selected_count + 1 " + "WHERE id = #{courseId} AND selected_count < capacity") int increaseSelectedCount(@Param("courseId") Integer courseId); }

这段 SQL 的意思是:只有“当前已选人数小于容量”时,才把数量加 1。MyBatis 执行后返回受影响的行数,如果是 0,说明名额已被抢完,这时直接回滚事务,不给学生选课成功。

Django 版思路一样,用 ORM 下的原子更新:

from django.db import transaction from django.db.models import F @transaction.atomic def select_course(request, course_id): course = Course.objects.select_for_update().get(pk=course_id) if course.selected_count >= course.capacity: return JsonResponse({"code": 400, "msg": "课程名额已满"}) course.selected_count = F('selected_count') + 1 course.save() Selection.objects.create(student=request.user, course=course)

select_for_update 是对数据库行加锁,事务结束释放。这里的代价是并发性能受影响,但选课系统的真实并发量远不到要把锁拆除的程度,稳定比性能重要。

5.4 退课与捡漏机制

退课的规则和选课相反:删除选课记录,同时课程已选人数减一,释放名额。同样要放在事务里,不能只删选课记录、忘了更新数量。

我在这里加了一个比较实用的小设计:学生退课后,名额不会立刻在页面上显示“可抢”,而是等 Redis 缓存刷新或页面刷新时才变化。这样做主要是避免学生反复退选刷接口,给数据库造成不必要的压力。课程设计阶段如果没有 Redis,可以在退课接口里加一个简单的每分钟请求次数限制,比如用 Session 记录最近操作时间。

另外要注意一点:退课要校验学期状态。如果课程已经结束进入归档状态,就不能再允许退课,否则成绩和名单都会乱掉。

6. 课程管理与统计报表模块

6.1 课程的完整生命周期

课程不是创建完就能一直选。我把课程状态分成四个:草稿、选课中、已结束、已归档。管理员创建课程后先处于草稿状态,发布时间进入“选课中”,选课截止后变为“已结束”,成绩录入完成后归档。

这个状态流转严格来说应该做一张状态机配置表,但课程设计里我建议用一个 int 字段配合 Enum 判断就够了。状态变化要对用户可见,比如学生端不能看到“草稿”状态的课程,教师端不能对未发布的课程添加学生名单。

Django 里的状态管理我直接用了 IntegerChoices:

class CourseStatus(models.IntegerChoices): DRAFT = 0, '草稿' SELECTING = 1, '选课中' FINISHED = 2, '已结束' ARCHIVED = 3, '已归档'

6.2 选课名单导出

这个功能虽然简单,但答辩时很容易被问到。用 POI 在 Java 里导出 Excel 有点繁琐,Django 可以直接用 csv 模块生成 CSV,也可以用 openpyxl 生成 xlsx。

我用 POI 的 SXSSFWorkbook 做过一次导出,关键代码是:

SXSSFWorkbook workbook = new SXSSFWorkbook(100); Sheet sheet = workbook.createSheet("选课名单"); Row header = sheet.createRow(0); header.createCell(0).setCellValue("学号"); header.createCell(1).setCellValue("姓名"); header.createCell(2).setCellValue("专业");

导出前先设置响应的 content type:

response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setHeader("Content-Disposition", "attachment; filename=selection.xlsx");

细节提示:文件名最好做一下 URL 编码,否则浏览器下载时中文文件名会乱码。

6.3 选课率统计与热门课程

统计模块是很多人会忽略但答辩时很加分的部分。管理员首页可以展示:当前学期选课总人次、课程选满率、各学院选课率排行。这些数据用 SQL 的 GROUP BY 就能写:

SELECT c.id, c.course_name, c.capacity, c.selected_count, ROUND(c.selected_count * 100.0 / c.capacity, 1) AS fill_rate FROM course c WHERE c.semester = '2024-2025-1' ORDER BY fill_rate DESC;

Django 里对应写法:

courses = Course.objects.filter(semester=current_semester) \ .annotate(fill_rate=ExpressionWrapper(F('selected_count') * 100.0 / F('capacity'), output_field=FloatField())) \ .order_by('-fill_rate')

统计页面我建议用后端渲染的简单 HTML 表格,不要强上图表库,课程设计的核心在业务逻辑,不是前端炫技。

7. 项目部署与调试文档编写

7.1 SSM 版本部署步骤

SSM 版本最终打成 war 包部署在 Tomcat,我的步骤记录如下:

  1. 安装 JDK 8 并配置 JAVA_HOME。
  2. 安装 Maven,配置阿里云镜像,保证依赖下载速度。
  3. 创建 MySQL 数据库,执行项目里的 init.sql。
  4. 修改 db.properties 或 application.yml 中的数据库用户名密码。
  5. 在项目根目录执行 mvn clean package,生成 war 包。
  6. 将 war 包复制到 Tomcat 的 webapps 目录,启动 Tomcat。
  7. 访问 http://localhost:8080/项目名/login 验证效果。

这里的坑点主要集中在第二步和第四步。Maven 不配置国内镜像,依赖下载可能慢到让人崩溃;数据库账号密码配置错,页面经常是空白或报 500。

7.2 Django 版本部署步骤

Django 版我推荐用虚拟环境加 Gunicorn 加 Nginx 的组合,步骤也很固定:

python3 -m venv venv source venv/bin/activate pip install -r requirements.txt python manage.py migrate python manage.py collectstatic gunicorn config.wsgi:application --bind 0.0.0.0:8000

Nginx 配置反向代理和静态文件路径:

location /static/ { alias /path/to/staticfiles/; } location / { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }

很多人部署 Django 会遇到静态文件 404 的问题,根本原因是没有执行 collectstatic,或者 Nginx 的 alias 路径没有写对,排查时先打开浏览器 F12 看静态文件请求路径,再检查文件是否真实存在。

7.3 我的调试文档写作思路

调试文档不是为了应付检查,而是为了让一个没有接触过项目的人能在半小时内把项目跑起来。我写的调试文档包含五个部分:环境要求、数据库初始化、启动步骤、默认账号、常见报错处理。

环境要求里一定要写清楚 JDK 版本、Python 版本、MySQL 版本,不要只写“JDK 1.8”。数据库初始化要说明该执行哪个 SQL 文件、顺序是什么。默认账号非常关键,我一般预备三个账号:管理员 admin、教师 teacher、学生 student,密码都初始化为 123456,方便演示。常见报错部分,我会把我在部署过程中真的遇到的几个问题写进去,而不是编造。

写文档有一个原则:自己亲手跑通一遍之后再写,不要凭记忆写。很多同学写文档时项目还没跑通,最后文档里的命令自己都无法复现。

8. 常见问题与排查技巧实录

问题现象可能原因排查与解决
Tomcat 启动后 404war 包没部署成功或访问路径不对查看 Tomcat 日志和 webapps 目录,确认路径大小写
数据库中文乱码数据库连接没有指定 UTF-8在 JDBC URL 加 useUnicode=true&characterEncoding=utf8
MyBatis 映射不到实体属性数据库下划线与实体驼峰未转换开启 mapUnderscoreToCamelCase=true
Django 静态文件 404未执行 collectstatic 或 Nginx 路径配置错误执行 collectstatic,检查 alias 绝对路径
学生选课重复插入缺少唯一约束或事务未提交建表加上 UNIQUE KEY,并为选课方法加 @Transactional
选课超卖先查后插,无原子条件更新改为 UPDATE ... WHERE selected_count < capacity 或 select_for_update
登录后跳转死循环拦截器没有排除登录接口和静态资源检查拦截器 exclude 配置
系统时间与选课窗口对不上服务器时区未设置JVM 加 -Duser.timezone=Asia/Shanghai,Django 设置 USE_TZ=False

排查问题我有一条自己的方法论:先看日志,再查代码,最后才怀疑环境。很多同学报错后第一反应就是改代码,结果浪费大量时间。Spring Boot 和 Django 的控制台日志已经把异常堆栈打印得很清楚了,定位到具体行号,问题基本就能解决一半。

结束语:做完这套系统,我最大的几个体会

这是我做这类选课系统做得比较多之后,印象最深的三条经验,写出来希望对你有帮助。

第一,不要急着写代码,先把“哪些角色能用哪些功能”画出来。我见过太多项目写到一半,发现学生页面能访问教师的选课名单接口,最后只能加班补权限。这个步骤在纸面上只需要半小时,却能在开发和答辩时省下大量时间。

第二,数据库的约束比 Service 层代码更可靠。联合唯一索引、容量限制、外键关系,这些能在数据库层面解决的,就不要只依赖代码校验。回到选课场景,唯一约束哪怕在极端并发下也能阻止重复选课,这是代码判断永远比不上的“最后一道防线”。

第三,调试文档不是给老师看的,是给一个月后的自己看的。你写完项目放一个月再去部署,大概率会忘掉某个环境配置或者某个启动顺序。一份完整的调试文档,能让人在半小时内重新跑通整个项目,这比任何华丽的 README 都更实用。

如果你正在做类似的选题,希望这篇能帮你少走一些弯路。有问题也可以直接按这个思路去实现,遇到卡住的地方,多半出在权限、并发和部署这三块,逐个击破就好。

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

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

立即咨询