
这是一个很典型的选题线上选课系统。我最近刚完整做了一套而且同时用 JavaSSM 和 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 后台、认证体系和表单处理。它的特点是“约定大于配置”很多功能开箱即用开发效率明显更快。其实两套技术栈在能力上完全对等都能做登录认证、都能操作数据库、都能做复杂的业务校验。差别在于写代码的组织方式和生态习惯。下面是我整理的一个对照表对比维度SSMJavaDjangoPython请求处理SpringMVC 的 ControllerView 或 DRF 的 ViewSet数据库操作MyBatis 手写 SQLORM 模型支持复杂查询模板页面JSP / ThymeleafDjango Templates认证授权自己写拦截器或整合 ShiroDjango Auth 装饰器管理后台自己开发页面Admin 二次开发部署环境Tomcat JDKGunicorn / Nginx Python学习曲线中间件配置多偏陡上手快但深入要理解 ORM2.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 classcom.example.interceptor.LoginInterceptor/ /mvc:interceptor /mvc:interceptors4.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(ListString existedTimes, String newTime) { SetString timeSet new HashSet(existedTimes); return timeSet.contains(newTime); }在 Django 里可以用 ORM 查询def has_time_conflict(student, new_course): selected_courses Course.objects.filter( selection__studentstudent, selection__course__semesternew_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(pkcourse_id) if course.selected_count course.capacity: return JsonResponse({code: 400, msg: 课程名额已满}) course.selected_count F(selected_count) 1 course.save() Selection.objects.create(studentrequest.user, coursecourse)select_for_update 是对数据库行加锁事务结束释放。这里的代价是并发性能受影响但选课系统的真实并发量远不到要把锁拆除的程度稳定比性能重要。5.4 退课与捡漏机制退课的规则和选课相反删除选课记录同时课程已选人数减一释放名额。同样要放在事务里不能只删选课记录、忘了更新数量。我在这里加了一个比较实用的小设计学生退课后名额不会立刻在页面上显示“可抢”而是等 Redis 缓存刷新或页面刷新时才变化。这样做主要是避免学生反复退选刷接口给数据库造成不必要的压力。课程设计阶段如果没有 Redis可以在退课接口里加一个简单的每分钟请求次数限制比如用 Session 记录最近操作时间。另外要注意一点退课要校验学期状态。如果课程已经结束进入归档状态就不能再允许退课否则成绩和名单都会乱掉。6. 课程管理与统计报表模块6.1 课程的完整生命周期课程不是创建完就能一直选。我把课程状态分成四个草稿、选课中、已结束、已归档。管理员创建课程后先处于草稿状态发布时间进入“选课中”选课截止后变为“已结束”成绩录入完成后归档。这个状态流转严格来说应该做一张状态机配置表但课程设计里我建议用一个 int 字段配合 Enum 判断就够了。状态变化要对用户可见比如学生端不能看到“草稿”状态的课程教师端不能对未发布的课程添加学生名单。Django 里的状态管理我直接用了 IntegerChoicesclass 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 typeresponse.setContentType(application/vnd.openxmlformats-officedocument.spreadsheetml.sheet); response.setHeader(Content-Disposition, attachment; filenameselection.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(semestercurrent_semester) \ .annotate(fill_rateExpressionWrapper(F(selected_count) * 100.0 / F(capacity), output_fieldFloatField())) \ .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 启动后 404war 包没部署成功或访问路径不对查看 Tomcat 日志和 webapps 目录确认路径大小写数据库中文乱码数据库连接没有指定 UTF-8在 JDBC URL 加 useUnicodetruecharacterEncodingutf8MyBatis 映射不到实体属性数据库下划线与实体驼峰未转换开启 mapUnderscoreToCamelCasetrueDjango 静态文件 404未执行 collectstatic 或 Nginx 路径配置错误执行 collectstatic检查 alias 绝对路径学生选课重复插入缺少唯一约束或事务未提交建表加上 UNIQUE KEY并为选课方法加 Transactional选课超卖先查后插无原子条件更新改为 UPDATE ... WHERE selected_count capacity 或 select_for_update登录后跳转死循环拦截器没有排除登录接口和静态资源检查拦截器 exclude 配置系统时间与选课窗口对不上服务器时区未设置JVM 加 -Duser.timezoneAsia/ShanghaiDjango 设置 USE_TZFalse排查问题我有一条自己的方法论先看日志再查代码最后才怀疑环境。很多同学报错后第一反应就是改代码结果浪费大量时间。Spring Boot 和 Django 的控制台日志已经把异常堆栈打印得很清楚了定位到具体行号问题基本就能解决一半。结束语做完这套系统我最大的几个体会这是我做这类选课系统做得比较多之后印象最深的三条经验写出来希望对你有帮助。第一不要急着写代码先把“哪些角色能用哪些功能”画出来。我见过太多项目写到一半发现学生页面能访问教师的选课名单接口最后只能加班补权限。这个步骤在纸面上只需要半小时却能在开发和答辩时省下大量时间。第二数据库的约束比 Service 层代码更可靠。联合唯一索引、容量限制、外键关系这些能在数据库层面解决的就不要只依赖代码校验。回到选课场景唯一约束哪怕在极端并发下也能阻止重复选课这是代码判断永远比不上的“最后一道防线”。第三调试文档不是给老师看的是给一个月后的自己看的。你写完项目放一个月再去部署大概率会忘掉某个环境配置或者某个启动顺序。一份完整的调试文档能让人在半小时内重新跑通整个项目这比任何华丽的 README 都更实用。如果你正在做类似的选题希望这篇能帮你少走一些弯路。有问题也可以直接按这个思路去实现遇到卡住的地方多半出在权限、并发和部署这三块逐个击破就好。