每个学期开学那几天,搞选课系统崩溃的新闻总能看到,教务处、辅导员、学生三方都在盯着同一个系统,并发一上来,页面转圈圈,数据库锁死,甚至选完课一查学分不对,整个人都麻了。做过这类系统的人都知道,学生选课管理的难点从来不是"写个增删改查",而是权限怎么分、业务规则怎么落地、并发抢课怎么不超选、数据怎么保证一致。
这篇博文要拆的就是一个基于 Python + Django 的学生选课管理系统,完整包含源码、数据库设计和配套文档。我会从项目角度把核心设计思路、表结构、关键代码实现、实操过程、踩坑实录全部讲透,适合正在做课程设计、毕业设计,或者想系统学习 Django 实战的新手开发者参考。看完你能明白这类系统"为什么这么设计",也能直接上手复现和改造。
1. 项目核心拆解:选课系统到底在解决什么问题
很多初学者拿到这种题目,上来就建一堆表,然后往 views 里写代码,结果写到一半发现:学生能改别人的课、老师能选课、选课冲突了没人管、退课之后容量不恢复……全是设计阶段没想清楚埋下的坑。选课系统的本质是"在学生、课程、教师之间维护一种有约束关系的动态状态",核心逻辑其实就三件事:谁能做什么、什么操作合法、操作之后怎么保持一致。
1.1 角色与权限模型设计
代码里能跑通是一回事,权限模型清晰是另一回事。这个项目的用户模型基于 Django 自带的auth.User扩展,添加role字段区分三种角色:学生、教师、管理员。
- 学生:浏览课程、选课、退课、查看个人课表
- 教师:查看自己讲授的课程、维护课程基本信息、查看选课学生名单
- 管理员:管理所有用户、课程、学期,处理特殊调课需求
这里要重点说的是,权限控制不能只靠前端按钮隐藏,后端每个接口都必须校验身份。Django 在这方面的标准做法是装饰器 + Mixin 结合使用。
# views.py 权限控制示例 from django.contrib.auth.decorators import login_required, user_passes_test from django.views.generic import ListView from django.contrib.auth.mixins import UserPassesTestMixin def is_student(user): return user.is_authenticated and user.role == 'student' @login_required @user_passes_test(is_student) def course_select(request, course_id): # 只允许学生角色执行 pass class StudentCourseListView(UserPassesTestMixin, ListView): def test_func(self): return is_student(self.request.user)函数视图用装饰器,类视图用 Mixin,这是 Django 里比较规整的权限写法。很多项目把权限判断写在视图函数内部用 if 包一层,不是说不行,但接口一多就很容易漏判,真正的项目里还是推荐统一处理。
1.2 选课场景的业务逻辑约束
选课最核心的业务逻辑是"约束"——不把约束落进代码里,系统就是摆设。我归纳了这套系统必须处理的几条硬性规则:
- 同一课程,学生不能重复选课
- 已选课程与待选课程不能时间冲突
- 选课人数不得超过课程容量
- 退课后容量立刻释放,其他人可补选
- 学分数需在学期可选范围内(通常默认上限,如 28 学分)
- 必修课与选修课分类管理
这些约束看起来简单,但叠加在一起很容易出 bug。举例来说,A 学生选了周一 3-4 节的"数据库原理",又想选同一时间段的"计算机网络",后端必须在选课请求处理时检测时间冲突,而不是只查"这个课满了没有"。这个逻辑我在后面的代码解析里会说。
2. 表结构设计:数据模型是系统的骨架,这几张表决定成败
Django 项目里数据模型就是models.py,表的字段设计直接影响后面所有业务逻辑的实现难度。这套系统的表结构是经过几次重构后稳定下来的,核心是 7 张表。
2.1 数据表清单与字段设计
| 表名 | 模型类 | 核心字段 | 说明 |
|---|---|---|---|
| 用户表 | User (扩展) | username, password, role, student_id, email | 继承 AbstractUser |
| 课程表 | Course | course_code, name, credit, course_type, capacity | course_type 区分必修/选修 |
| 教师表 | Teacher | user, teacher_no, title, department | OneToOne 关联用户 |
| 学生表 | Student | user, student_no, major, grade | OneToOne 关联用户 |
| 开课表 | CourseSchedule | course, teacher, semester, time_slot, location, selected_count | 同一课程不同教师/时段视为不同开课记录 |
| 选课表 | Enrollment | student, schedule, status, select_time | 唯一约束防重复选课 |
| 学期表 | Semester | name, start_date, end_date, is_active | 判断是否处于选课窗口 |
这是一套典型的"课程—开课—选课"三层模型。为什么需要开课表(CourseSchedule)?因为同一门课"数据库原理",可能由两个老师分别开在周一和周三。如果只关联 Course 和 Student,根本无法表达同一门课不同的时间、地点、教师,会出现容量互相覆盖的问题。
2.2 关键取舍:为什么继承 AbstractUser 而不是单独建 Profile
Django 官方文档里给出了扩展用户模型的几种方式,AbstractUser继承是其中比较折中、也最适合这种教学系统的方法。直接保持默认 User 不动、再建一个 StudentProfile 表关联也行,但每次取学生的学号、专业都要跨表查一次,视图代码会啰嗦不少。
from django.contrib.auth.models import AbstractUser from django.db import models class User(AbstractUser): ROLE_CHOICES = ( ('admin', '管理员'), ('teacher', '教师'), ('student', '学生'), ) role = models.CharField(max_length=10, choices=ROLE_CHOICES, default='student') email = models.EmailField(unique=True) class Meta: db_table = 'auth_user'注意:继承 AbstractUser 必须在第一次
migrate之前就配置好,否则中途切换会引发数据库迁移错乱的麻烦。这一点我在踩坑部分会具体讲。
选课表 Enrollment 上要设置联合唯一约束,这是防止重复选课的兜底手段。
class Enrollment(models.Model): student = models.ForeignKey(Student, on_delete=models.CASCADE) schedule = models.ForeignKey(CourseSchedule, on_delete=models.CASCADE) status = models.CharField(max_length=10, default='selected') select_time = models.DateTimeField(auto_now_add=True) class Meta: db_table = 'course_enrollment' unique_together = ('student', 'schedule')unique_together在数据库层面生成联合唯一索引,就算代码逻辑万一漏写了判断,数据库也会直接拒绝重复插入,双保险。
3. 核心功能模块设计与关键代码解析
设计完表结构,接下来是业务代码的编写。不要急着写视图,先把核心的"服务层逻辑"想清楚。我在这个项目里把选课的核心操作单独抽出来放在services.py中,不直接写在 views 里,这样代码好测试,逻辑也好复用。
3.1 选课流程:事务与并发控制是成败关键
选课的流程是:前端传 schedule_id 过来,后端做一系列校验,通过后写入 Enrollment 并给 CourseSchedule 的 selected_count 加一。这个过程必须保证原子性,否则多人同时抢最后一个名额就会出现超选。
from django.db import transaction from django.core.exceptions import ValidationError @transaction.atomic def enroll_course(student, schedule_id): schedule = CourseSchedule.objects.select_for_update().get(pk=schedule_id) # 校验1:是否已选过 if Enrollment.objects.filter(student=student, schedule=schedule).exists(): raise ValidationError('你已选择该课程') # 校验2:课程容量 if schedule.selected_count >= schedule.capacity: raise ValidationError('课程容量已满') # 校验3:时间冲突 time_conflict = Enrollment.objects.filter( student=student, schedule__time_slot=schedule.time_slot, status='selected' ).exists() if time_conflict: raise ValidationError('所选课程时间冲突') # 全部通过后写入 Enrollment.objects.create(student=student, schedule=schedule) schedule.selected_count = schedule.selected_count + 1 schedule.save(update_fields=['selected_count'])这里有一个值得反复强调的点:必须用select_for_update()锁定这条开课记录。普通查询在高并发下会读出同一个旧值,然后各自拿着旧值去更新,把最终人数覆盖掉。select_for_update()会给这行记录加上行锁,其他事务必须等当前事务提交后才能读取,有效避免超卖。
如果不方便在代码里加事务,也可以用 Django 的F()表达式做原子更新,配合update()的返回值判断是否更新成功:
from django.db.models import F # 原子容量扣减,返回0表示没扣上(容量已满) updated = CourseSchedule.objects.filter( pk=schedule_id, selected_count__lt=F('capacity') ).update(selected_count=F('selected_count') + 1) if updated == 0: raise ValidationError('课程容量已满') # 再插入选课记录这种写法在高并发下更稳,但要注意先更新后插入的顺序与失败后的回滚补偿。
3.2 退课流程与容量释放
退课在业务上比选课简单,但有一个常见 bug:退课成功后容量释放了,但学生课表里还显示这门课。原因往往是退课只删了 Enrollment 记录,没有同步更新selected_count。正确做法是用信号(signal)或者直接在同一个事务内完成删除与更新。
@transaction.atomic def drop_course(student, schedule_id): deleted, _ = Enrollment.objects.filter( student=student, schedule_id=schedule_id ).delete() if deleted == 0: raise ValidationError('未找到选课记录') CourseSchedule.objects.filter(pk=schedule_id).update( selected_count=F('selected_count') - 1 )退课时的selected_count减一有个前提:不能减到负数。实际操作中只要保证删除成功才执行扣减,并且扣减操作本身在事务里,一般不会出现负数。但极端情况下如果有人并发退同一门课,delete()只会有一个人成功,另一个 delet 计数为 0 直接抛异常,不会影响容量值。
3.3 课程时间冲突检测的细节
时间冲突检测是选课系统里最容易被低质量实现坑死的点。很多系统只判断"同一个星期几、同节次"是否重复,但现实中还有跨周课程、单双周课程,简单判断会误伤。
这套系统为了控制复杂度,time_slot设计为一个整数字段,比如1表示周一 1-2 节,2表示周一 3-4 节,以此类推。同值即冲突,逻辑简单可靠。如果要做更细的周次判断,就需要引入"周次字符串"或"星期 + 开始节次 + 持续节数"的组合字段,扩展思路是改CourseSchedule的表结构,而不是塞进逻辑代码。
时间冲突检测还有一个隐藏需求:退课后再选同一时间另一门课,系统应该放行。上面的代码里用了status='selected'过滤,就是避免把已退课程也算进冲突里。
3.4 视图层实现:Class-Based Views 是省事利器
项目里视图尽量用基于类的视图(CBV),配合 Django 自带的ListView、DetailView、FormView能节省大量重复代码。以学生查看可选课程列表为例:
from django.views.generic import ListView from .models import CourseSchedule class AvailableCourseListView(ListView): model = CourseSchedule template_name = 'course_list.html' context_object_name = 'schedules' paginate_by = 10 def get_queryset(self): # 只显示当前学期的开课记录 qs = CourseSchedule.objects.filter(semester__is_active=True) # 排除已选课程 student = Student.objects.get(user=self.request.user) enrolled_ids = Enrollment.objects.filter( student=student, status='selected' ).values_list('schedule_id', flat=True) return qs.exclude(pk__in=enrolled_ids)这个列表页有一个设计巧思:默认排除掉自己已经选过的课程。页面呈现上更友好,学生不会反复看到自己已选的课,也减少了误操作。
3.5 信号量的应用:选课后自动更新学生学分
学分计算属于典型"选课成功后要同步做的事",我建议放在 Django signal 里,负责业务逻辑的代码不动,订阅事件自动执行。
from django.db.models.signals import post_save from django.dispatch import receiver @receiver(post_save, sender=Enrollment) def update_student_credits(sender, instance, created, **kwargs): if not created: return student = instance.student semester = instance.schedule.semester # 计算当前学期总学分(简化示例) total_credit = Enrollment.objects.filter( student=student, schedule__semester=semester, status='selected' ).aggregate(total=models.Sum('schedule__course__credit'))['total'] or 0 if total_credit > MAX_CREDIT_PER_SEMESTER: # 如果超学分,删除该选课记录 instance.delete() raise ValidationError('学期学分超限')信号的好处是解耦,但坏处是出问题时排查链路变长。初学者如果把握不好,也可以直接把学分计算写进enroll_course服务里,逻辑更直观。我的建议是:项目规模小,优先追求可读性,信号谨慎使用。
4. 完整实操过程:从环境搭建到项目落地
光讲设计不给实操过程,读者很难真正复现。下面把从零搭建这套系统的每一步写清楚,包括环境、配置、命令、数据初始化等关键动作。
4.1 环境准备与项目初始化
首先保证本地已安装 Python 3.8+,推荐用虚拟环境隔离依赖执行下面的命令。
# 创建虚拟环境 python -m venv venv source venv/bin/activate # Windows 下为 venv\Scripts\activate # 安装 Django pip install django==4.2 # 安装数据库驱动(以 MySQL 为例,也可以先用自带 SQLite) pip install mysqlclient # 创建项目和应用 django-admin startproject course_system cd course_system python manage.py startapp course python manage.py startapp users项目结构建议按功能拆分 app,不要所有模块堆在一个文件里。这里拆成users(用户、权限)和course(课程、选课)两个 app,规则清晰。
4.2 数据库配置与迁移
默认配置使用的是 SQLite,零配置、跑起来最省事。如果要切换到 MySQL,需要在settings.py中修改数据库配置。
DATABASES = { 'default': { 'ENGINE': 'django.db.backends.mysql', 'NAME': 'course_system', 'USER': 'root', 'PASSWORD': 'yourpassword', 'HOST': '127.0.0.1', 'PORT': '3306', 'OPTIONS': { 'charset': 'utf8mb4', } } }配置完成后,执行迁移命令生成数据表:
python manage.py makemigrations python manage.py migrate注意:
makemigrations和migrate是两个不同的命令。前者根据模型生成迁移文件,后者将迁移文件实际应用到数据库。很多新手搞混,改完模型不执行迁移,页面一直报错说 "no such column"。
4.3 数据初始化:超级管理员与测试数据
系统上线前需要预置超级管理员账户。Django 提供createsuperuser命令,在终端中执行后按提示输入用户名、邮箱、密码即可。
python manage.py createsuperuser如果不想一步一答,也可以写一个数据初始化脚本,在开发调试时一键灌入模拟数据。用data_init.py配合python manage.py shell执行:
from users.models import User, Student, Teacher from course.models import Course, CourseSchedule, Semester # 创建学期 sem = Semester.objects.create( name='2024-2025-1', start_date='2024-09-01', end_date='2025-01-15', is_active=True ) # 创建学生用户 user = User.objects.create_user( username='2023001', password='123456', email='stu1@test.com', role='student' ) student = Student.objects.create(user=user, student_no='2023001', major='计算机科学') # 创建教师用户 t_user = User.objects.create_user( username='t001', password='123456', role='teacher' ) teacher = Teacher.objects.create(user=t_user, teacher_no='T001', department='信息学院') # 创建课程与开课记录 course = Course.objects.create( course_code='CS101', name='程序设计基础', credit=3.5, course_type='必修', capacity=40 ) schedule = CourseSchedule.objects.create( course=course, teacher=teacher, semester=sem, time_slot=1, location='A-101' ) print('初始化完成')有了这些基础数据,之前写的列表页、选课接口、管理后台就都有东西可看了。
4.4 Django Admin 后台配置
Django 自带的后台管理是这类管理系统"最不需要写代码"的亮点,注册模型后在/admin/路径直接管理所有数据。
# course/admin.py from django.contrib import admin from .models import Course, CourseSchedule, Enrollment @admin.register(Course) class CourseAdmin(admin.ModelAdmin): list_display = ['course_code', 'name', 'credit', 'course_type', 'capacity'] search_fields = ['name', 'course_code'] @admin.register(CourseSchedule) class CourseScheduleAdmin(admin.ModelAdmin): list_display = ['course', 'teacher', 'semester', 'time_slot', 'location', 'selected_count'] @admin.register(Enrollment) class EnrollmentAdmin(admin.ModelAdmin): list_display = ['student', 'schedule', 'status', 'select_time'] list_filter = ['status']后台配置的第一个好处是管理员的日常管理可以完全通过界面完成,不写一行代码;第二个好处是调试阶段数据可视化,比如可以直接在后台看某门课的容量和已选人数是否一致。
5. 常见问题排查与踩坑实录
实操过程中踩过的坑,往往比成功路径更有参考价值。下面把这类 Django 选课系统最常见的问题和对应排查方法整理成速查表。
5.1 常见问题速查表
| 问题现象 | 可能原因 | 排查思路 |
|---|---|---|
| 页面报 "no such table" | 未执行migrate | 检查数据库表是否存在,执行迁移 |
| 字段显示 Unknown column | 模型改动后未重新迁移 | 重新执行 makemigrations + migrate |
| 多人同时选课人数超容量 | 未处理并发 | 检查是否使用select_for_update() |
| 选了 A 课显示 B 课在课表 | 关联查询写错 | 检查 QuerySet 里 filter/exclude 条件 |
| 退课后容量不恢复 | 只删选课记录未更新容量 | 检查事务里是否有容量扣减 |
| 部署后静态文件 404 | 未配置 STATIC_ROOT | 执行collectstatic并配置 Web 服务器 |
5.2 排课冲突的隐蔽细节
排课冲突是个非常细的活,很多初学者在这里栽跟头。举例:老师 A 在周一 1-2 节教"操作系统",同时又在周一 1-2 节教"计算机组成原理"——这在现实中是绝对不允许的,但很多系统根本没检查教师的时间冲突,导致排课数据本身就有问题。
课表数据的"源头脏了",后面学生选课再检查冲突也无济于事。建议在管理端的开课表单里做教师时间冲突校验,创建开课记录时就拦掉冲突数据。
from django import forms class CourseScheduleForm(forms.ModelForm): class Meta: model = CourseSchedule fields = '__all__' def clean(self): cleaned_data = super().clean() teacher = cleaned_data.get('teacher') time_slot = cleaned_data.get('time_slot') semester = cleaned_data.get('semester') conflict = CourseSchedule.objects.filter( teacher=teacher, time_slot=time_slot, semester=semester ).exclude(pk=self.instance.pk if self.instance else None).exists() if conflict: raise forms.ValidationError('该教师在所选时间段已有课程安排') return cleaned_data5.3 管理后台"直接改数据"带来的脏数据问题
Django Admin 默认情况下可以直接编辑关联对象,比如管理员在后台把一个课程 Schedule 的容量从 30 改成 50,但 Enrollment 表里的记录不会自动做任何同步。如果已经选了 40 个人,现在改成 50,没问题;但改成 20,就有 20 条选课数据处于"超容量"状态。
我的经验是:管理后台应该在关键模型上覆盖save_model或重写clean方法做保护,不允许后台把容量改得比已选人数还小。这算是一个容易被忽略的健壮性设计。
5.4 文档编写的实用建议
这类系统标配的"文档"通常包括:需求分析、概要设计、数据库设计、接口说明、测试报告、部署说明。很多人在编码完了才开始写文档,结果写出来的东西和代码对不上。更省力的做法是在开发过程中同步维护一个简单的 README,把最终可用的接口和表结构变更记录下来,最后再扩展成完整文档。
我建议文档中必须包含一张"权限矩阵表",列出每个角色能访问的 URL 和能执行的操作。这张表看起来简单,但写的时候能逼你把系统的权限边界彻底理清,评审的时候也方便。
5.5 测试与保命技巧
一套课程设计级别的系统,不要求 100% 覆盖率,但核心流程必须有测试。几个必写的测试用例:
- 正常选课流程能成功
- 重复选课被拦截
- 容量已满时选课失败
- 选课时间冲突被拦截
- 退课后容量减少且学生课表消失
- 非学生角色访问选课接口被拒绝
# course/tests.py 核心用例示例 from django.test import TestCase from django.core.exceptions import ValidationError from .services import enroll_course, drop_course class EnrollCourseTest(TestCase): def setUp(self): # 准备学生、课程数据 pass def test_enroll_success(self): student = self.create_student() schedule = self.create_schedule() self.enroll_ok = enroll_course(student, schedule.id) self.assertIsNotNone(self.enroll_ok) def test_duplicate_enroll_rejected(self): student = self.create_student() schedule = self.create_schedule() enroll_course(student, schedule.id) with self.assertRaises(ValidationError): enroll_course(student, schedule.id)测试的意义不光是证明代码能跑,更是在后面改造重构时让你敢下手。
6. 扩展思路:这套系统还能往哪个方向升级
做完基础版本之后,如果你想让项目更有亮点、或者在答辩时有更多可说的点,这几个方向值得考虑。
6.1 缓存层与抢课性能优化
选课高峰期的性能瓶颈绝大部分在数据库查询和行锁等待上。可以考虑引入 Redis 做两层缓存:热门课程列表缓存到 Redis,选课计数先写 Redis 再异步落库。更稳妥的架构是"Redis 预占名额 + 消息队列异步写入数据库",但这套体系复杂度高很多,学习成本不低。
实际项目中我推荐一个折中方案:热门课程列表用 Django 的 cache 框架加 Redis,选课计数保留数据库原子更新。既提升了列表页的响应速度,又保证了数据可靠。
6.2 选课时间窗口与批次控制
真实学校的选课往往分批次,比如大四先选、大三第二、新生最后。现有 Semester 模型只能控制整个学期的开闭,不能控制细粒度选课窗口。可以在CourseSchedule上增加select_start_time和select_end_time字段,enroll_course服务里加入时间判断。
from django.utils import timezone now = timezone.now() if now < schedule.select_start_time or now > schedule.select_end_time: raise ValidationError('当前不在选课时间窗口内')这个功能做出来后很实用,也是答辩时能主动讲的"业务亮点"。
6.3 成绩管理模块
选课系统做完了,自然延伸就是成绩管理。给 Enrollment 增加score字段,教师可以录入成绩,学生可以查看成绩。但要注意:成绩录入不能让教师改所有人的,要通过teacher__user关联判断这门课是不是该教师教的。
def get_teacher_courses(self, teacher_user): return CourseSchedule.objects.filter(teacher__user=teacher_user)成绩模块加进来后,系统的完整性更强,也能顺带把"教师角色"的权限做深一层。
写在最后的一点个人体会
这类管理系统做下来,我最深的感触是:技术上的东西都可以查文档解决,真正难的是把业务规则想周全。很多人在选课系统上翻车,不是因为不会写 Django 代码,而是没想清楚"选课的本质是有约束的资源分配问题",于是写出来的系统处处是漏洞。建议你在动手写代码之前,花大把时间把表结构、状态流转、权限矩阵用一张图画清楚,后面会少踩很多坑。选课系统虽然是个老掉牙的题目,但把并发控制、权限设计、业务约束这些基本功练扎实了,去做任何管理系统、订单系统、预约系统,思路都是通的。