ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于Django的选课系统设计与实现:权限、并发与数据库实战解析

基于Django的选课系统设计与实现:权限、并发与数据库实战解析

每个学期开学那几天,搞选课系统崩溃的新闻总能看到,教务处、辅导员、学生三方都在盯着同一个系统,并发一上来,页面转圈圈,数据库锁死,甚至选完课一查学分不对,整个人都麻了。做过这类系统的人都知道,学生选课管理的难点从来不是"写个增删改查",而是权限怎么分、业务规则怎么落地、并发抢课怎么不超选、数据怎么保证一致。

这篇博文要拆的就是一个基于 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
课程表Coursecourse_code, name, credit, course_type, capacitycourse_type 区分必修/选修
教师表Teacheruser, teacher_no, title, departmentOneToOne 关联用户
学生表Studentuser, student_no, major, gradeOneToOne 关联用户
开课表CourseSchedulecourse, teacher, semester, time_slot, location, selected_count同一课程不同教师/时段视为不同开课记录
选课表Enrollmentstudent, schedule, status, select_time唯一约束防重复选课
学期表Semestername, 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_data

5.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 代码,而是没想清楚"选课的本质是有约束的资源分配问题",于是写出来的系统处处是漏洞。建议你在动手写代码之前,花大把时间把表结构、状态流转、权限矩阵用一张图画清楚,后面会少踩很多坑。选课系统虽然是个老掉牙的题目,但把并发控制、权限设计、业务约束这些基本功练扎实了,去做任何管理系统、订单系统、预约系统,思路都是通的。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进