这是一个很典型的选题:线上选课系统。我最近刚完整做了一套,而且同时用 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 的 Controller | View 或 DRF 的 ViewSet |
| 数据库操作 | MyBatis 手写 SQL | ORM 模型,支持复杂查询 |
| 模板页面 | JSP / Thymeleaf | Django Templates |
| 认证授权 | 自己写拦截器或整合 Shiro | Django Auth + 装饰器 |
| 管理后台 | 自己开发页面 | Admin 二次开发 |
| 部署环境 | Tomcat + JDK | Gunicorn / 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,我的步骤记录如下:
- 安装 JDK 8 并配置 JAVA_HOME。
- 安装 Maven,配置阿里云镜像,保证依赖下载速度。
- 创建 MySQL 数据库,执行项目里的 init.sql。
- 修改 db.properties 或 application.yml 中的数据库用户名密码。
- 在项目根目录执行 mvn clean package,生成 war 包。
- 将 war 包复制到 Tomcat 的 webapps 目录,启动 Tomcat。
- 访问 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:8000Nginx 配置反向代理和静态文件路径:
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 启动后 404 | war 包没部署成功或访问路径不对 | 查看 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 都更实用。
如果你正在做类似的选题,希望这篇能帮你少走一些弯路。有问题也可以直接按这个思路去实现,遇到卡住的地方,多半出在权限、并发和部署这三块,逐个击破就好。