Django校园选课系统实战:从并发控制到权限管理
2026/9/24 22:38:17 网站建设 项目流程

1. 项目概述与核心价值

1.1 大学选课有多痛,这个系统就解决多少问题

每年开学季,高校教务系统被挤爆几乎是保留节目。学生熬夜守在电脑前抢课,教师手动核对选课名单,教务员在Excel里来回筛选汇总,三方都在重复劳动。校园选修课程管理系统要解决的,正是这套流程里最烦人的三个问题:选课冲突、容量超限、成绩统计混乱。

这个基于Django框架的校园选修课程管理系统,算是一个比较典型的Web开发练手项目,但它又不只是练手。整套系统覆盖了用户认证、角色权限控制、课程信息管理、学生选课退课、成绩录入与查询、公告发布等完整业务闭环,几乎把Django框架的核心知识点都串了一遍。对于想系统学习Django的人来说,把这个项目从头到尾跟一遍,比零散看十篇教程都管用;对于有真实业务需求的高校或培训机构,改改数据库字段和页面样式,也能直接跑起来用。

我拿到这套源码的第一感觉是:代码结构非常规整,没有为了炫技堆砌复杂功能,而是老老实实按照Django的MTV模式把每一层都理得很清楚。这对于新手理解Django的请求处理流程特别有帮助,因为你能清清楚楚看到浏览器发来的请求是怎么经过URL路由、视图函数、模型交互,最后渲染成页面的。

1.2 这套系统到底做了什么

从功能模块看,系统分成三个主要角色,每个角色看到和操作的界面完全不一样:

  • 学生端:浏览所有可选课程,查看课程详情(包括授课教师、上课时间、剩余名额),在线选课和退课,查看自己已选课程和成绩
  • 教师端:维护自己开设的课程,填写课程容量、上课时间和地点,录入和修改学生成绩
  • 管理员端:管理所有用户账号,审核教师提交的课程,维护公告信息,查看全站选课统计数据

技术上最亮眼的部分是选课时的并发处理。选课系统和秒杀系统的逻辑在本质上是一样的,都存在超卖问题:课程容量只有30人,第31个学生提交选课请求时,数据库怎么判断没有名额了?如果只是简单地在视图函数里查一下selected_count再判断,高并发下绝对会出问题。这套源码用的是Django的select_for_update配合事务处理,把选课记录行锁住再判断容量,从根源上杜绝超选。

另外一个值得仔细看的地方是权限控制。Django自带的auth系统提供了用户认证和基础的group权限,但这个项目在此基础上做了自定义权限装饰器,区分了普通学生的选课权限和教师的课程管理权限,这也是实际业务开发中最常见的权限设计模式。

2. 技术栈选型与系统架构拆解

2.1 为什么是Django而不是Flask

很多初学者在选Python Web框架时会纠结Django和Flask。我先直接说结论:做这类管理系统,Django是最省心的选择,没有之一。

核心原因在于Django自带的管理后台。当你的系统里需要管理用户、课程、公告这些数据模型时,Django Admin能直接提供一个可视化的CRUD界面,省掉了写后台管理页面的时间。这个项目里教师和管理员的课程审核操作,很大一部分就是基于Django Admin二次开发的。

再一个就是Django的ORM。校园选课系统里至少有四张核心表:用户表、课程表、选课记录表、公告表,它们之间存在外键关系。比如选课记录关联了学生和课程,查询某个学生的所有选课信息时,如果用原生SQL要写JOIN语句,而Django ORM只需要一行:

# 查询某个学生所有已选课程及其名称 enrollments = Enrollment.objects.filter(student=request.user).select_related('course')

这种开发效率上的优势,在业务逻辑越复杂的时候越明显。

还有一点,这个项目采用的是MTV模式,和传统的MVC有些区别。简单理解:M(Model)模型层负责和数据库打交道,T(Template)模板层负责页面展示,V(View)视图层负责业务逻辑。关键在于Django的View对应的是MVC里的Controller,而Django的Template对应的是MVC里的View。很多新人刚接触时会被这个概念绕晕,其实你只要记住:URL路由找到正确的View函数,View函数操作Model取出数据,再交给Template渲染成HTML返回给浏览器,这个流程就通了。

2.2 数据库模型设计是整套系统的地基

拿到源码后我第一件事就是看models.py,因为数据库设计决定了业务逻辑怎么写。这套系统的模型设计走的是经典关联模式,核心是六张表:

用户模型没有直接改Django自带的User表,而是用Profile模型通过OneToOneField一对一关联,用来存学生学号和教师工号:

class UserProfile(models.Model): user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile') role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='student') student_id = models.CharField(max_length=20, blank=True, null=True) teacher_id = models.CharField(max_length=20, blank=True, null=True) phone = models.CharField(max_length=11, blank=True)

这种设计比直接继承AbstractUser灵活。因为如果以后要接入学校统一身份认证,或者增加新的用户角色,不需要动数据库里的用户表,只扩展Profile表就行。

课程模型是核心中的核心,字段设计覆盖了选课所需的全部信息:

class Course(models.Model): course_code = models.CharField(max_length=20, unique=True, verbose_name='课程编号') course_name = models.CharField(max_length=100, verbose_name='课程名称') teacher = models.ForeignKey(User, on_delete=models.CASCADE, related_name='courses') course_type = models.CharField(max_length=20, choices=COURSE_TYPE, verbose_name='课程类型') credits = models.DecimalField(max_digits=3, decimal_places=1, verbose_name='学分') capacity = models.IntegerField(verbose_name='课程容量') selected_count = models.IntegerField(default=0, verbose_name='已选人数') schedule = models.CharField(max_length=100, verbose_name='上课时间') location = models.CharField(max_length=100, verbose_name='上课地点') status = models.CharField(max_length=20, choices=COURSE_STATUS, default='pending', verbose_name='审核状态') description = models.TextField(blank=True, verbose_name='课程简介')

特别注意capacity(课程容量)和selected_count(已选人数)这两个字段,它们是选课判断能否成功的关键。selected_count到底是直接存字段值还是通过关联查询统计数量,这个设计决策直接影响性能。这套源码选择直接存字段,选课成功就+1,退课就-1,查询时不用COUNT聚合,速度快很多,代价是必须用事务保证数据一致性。

选课记录模型是学生和课程之间的桥梁表:

class Enrollment(models.Model): student = models.ForeignKey(User, on_delete=models.CASCADE, related_name='enrollments') course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name='enrollment_set') enroll_time = models.DateTimeField(auto_now_add=True) grade = models.DecimalField(max_digits=5, decimal_places=2, null=True, blank=True, verbose_name='成绩') class Meta: unique_together = ('student', 'course')

unique_together这个约束非常重要,它从数据库层面保证了同一学生同一门课只能有一条选课记录,就算业务代码里漏了判断,数据库也会拦住重复选课。

2.3 目录结构怎么看

拿到源码先不急着运行,先把目录结构过一遍。这个项目遵循Django最标准的布局:

django_course_system/ ├── manage.py ├── requirements.txt ├── course_system/ # 全局配置目录 │ ├── settings.py # 项目配置 │ ├── urls.py # 全局路由 │ ├── wsgi.py │ └── asgi.py ├── apps/ │ ├── users/ # 用户认证模块 │ ├── courses/ # 课程与选课模块 │ └── notices/ # 公告模块 ├── static/ # 静态文件 ├── media/ # 用户上传文件 └── templates/ # 全局模板

把业务模块拆成多个app是Django工程化的最佳实践。很多新手喜欢把所有models.py、views.py都写在同一个app里,项目一大了就变成一坨。这个项目按照业务边界拆成users、courses、notices三个app,每个app只负责自己的事,代码维护起来舒服得多。

从调试角度说,拆分app还有一个好处:出Bug时定位快。如果选课功能报错,直接进courses/views.py找,不用在几千行代码里翻。

3. 核心功能模块实现细节

3.1 角色权限控制

这个系统里有三种角色:学生、教师、管理员。不可能让学生访问教师的管理页面,也不可能让教师给学生录入成绩以外的人操作。Django自带的认证系统提供了登录、登出和session管理,但角色权限要自己做。

这套源码的做法是写一个自定义装饰器,本质上是查看当前登录用户的profile里的role字段:

from django.contrib.auth.decorators import login_required from django.core.exceptions import PermissionDenied def role_required(allowed_roles): def decorator(view_func): @login_required def wrapper(request, *args, **kwargs): if request.user.profile.role in allowed_roles: return view_func(request, *args, **kwargs) raise PermissionDenied return wrapper return decorator

使用的时候直接加在视图函数上:

@role_required(['teacher', 'admin']) def course_manage(request, course_id): # 只有教师和管理员能访问

最容易被忽略的是权限控制只做在界面上的话,只能挡住普通用户,挡不住直接构造请求的人。比如学生选课的接口,如果只在前端隐藏选课按钮,学生直接往URL里拼参数请求,后端不校验身份照样能选课。好在这套源码在视图中做了用户身份判断,在选课函数里先检查request.user.profile.role == 'student'。

但从源码能看到一个可以优化的点:课程管理的视图里只判断了角色是teacher还是admin,但没有判断这个课程是不是属于当前登录教师。也就是说教师A如果手动改URL的课程ID,理论上能操作教师B的课程信息。我在实际项目中基于这套源码升级时,加了一层归属校验:

course = get_object_or_404(Course, pk=course_id) if request.user.profile.role != 'admin' and course.teacher != request.user: raise PermissionDenied

3.2 选课退课的事务处理

这是整个项目最有含金量的部分。先看选课的核心代码逻辑:

from django.db import transaction @login_required def enroll_course(request, course_id): if request.method == 'POST': course = Course.objects.select_for_update().get(pk=course_id) # 检查课程是否通过审核 if course.status != 'approved': return JsonResponse({'code': 1, 'msg': '课程未通过审核'}) # 检查是否已经选过 if Enrollment.objects.filter(student=request.user, course=course).exists(): return JsonResponse({'code': 1, 'msg': '你已选过该课程'}) # 检查课程容量 if course.selected_count >= course.capacity: return JsonResponse({'code': 1, 'msg': '课程名额已满'}) # 创建选课记录并更新已选人数 with transaction.atomic(): Enrollment.objects.create(student=request.user, course=course) course.selected_count += 1 course.save(update_fields=['selected_count']) return JsonResponse({'code': 0, 'msg': '选课成功'})

这里最关键的三个细节:

第一,select_for_update()。这是数据库行级锁,执行这条语句后,这一行课程记录会被锁定,直到当前事务结束。如果有两个学生同时提交选课请求,数据库会让他们排队执行,后执行的只能等前一个commit之后才能拿到这行数据。没有这个锁的话,两个请求同时读到selected_count=29,然后都判断还可以选,最后都执行+1,变成31人,超员了。

第二,transaction.atomic()。它把创建选课记录和更新已选人数绑定在同一个事务里,要么都成功,要么都失败。如果创建记录成功但更新人数失败,事务会自动回滚,不会出现选了课但人数没变的脏数据。

第三,选课前先检查Enrollment是否存在,这是业务层面的校验,而unique_together是数据库层面的双保险。两层校验是必要的,业务层负责给用户友好的提示信息,数据库约束负责兜底防错。

退课逻辑大同小异,区别是判断减少selected_count:

with transaction.atomic(): enrollment = Enrollment.objects.select_for_update().get(student=request.user, course=course) enrollment.delete() course.selected_count = F('selected_count') - 1 course.save(update_fields=['selected_count'])

退课这里有个细节容易被忽略。如果用户重复点击退课按钮,第二次请求时Enrollment对象已经不存在了,直接.get()会抛出DoesNotExist异常。源码里对这个异常做了处理,返回了友善的提示信息。这种边界情况只有在真实使用中才会暴露出来。

3.3 课程信息的增删改查

课程管理分成两条线:教师维护自己的课程,管理员审核课程。

教师创建课程时,status字段默认是'pending',表示待审核。课程创建后不会立刻出现在学生端的可选列表中,必须等管理员在后台审核通过。这个流程设计很贴近实际场景——高校里开新课本来就需要教务处审批。

教师端课程管理的视图用了Django的类视图(Class-Based View)来写,逻辑更清晰:

class TeacherCourseListView(LoginRequiredMixin, ListView): model = Course template_name = 'courses/teacher_course_list.html' context_object_name = 'courses' paginate_by = 10 def get_queryset(self): return Course.objects.filter(teacher=self.request.user).order_by('-created_at')

ListView自动处理了列表查询、分页的逻辑,不用自己写GET请求的判断,代码量省了不少。Django从3.x开始对类视图的支持非常成熟,新项目我一般优先考虑类视图,函数视图更适合逻辑特别简单的页面。

这里有一个值得说的点:LoginRequiredMixin放在继承列表的第一个位置,它的作用是拦截未登录用户。LoginRequiredMixin的实现原理是检查request.user.is_authenticated,未登录的话直接重定向到登录页。类视图的装饰器不能直接加在类上,要用这种Mixin方式实现,这个和函数视图的@login_required用法不一样,新人常常在这里卡一下。

3.4 前端页面的模板与静态文件

模板部分这套源码用Bootstrap布局,没有引入复杂的前端框架。对于管理系统来说,Bootstrap这种服务端渲染方案是够用的——不追求极致的交互体验,重点是表格数据展示和表单提交。Django模板的核心语法在这里都有体现:

{% for course in courses %} <tr> <td>{{ course.course_code }}</td> <td>{{ course.course_name }}</td> <td>{{ course.teacher.profile.name }}</td> <td> {% if course.selected_count >= course.capacity %} <span class="badge badge-danger">已满</span> {% else %} <span class="badge badge-success">可选</span> {% endif %} </td> <td> {% if user.profile.role == 'student' %} <button class="btn btn-primary btn-sm enroll-btn">STATIC_URL = '/static/' STATICFILES_DIRS = [ BASE_DIR / 'static', ]

很多人配置完发现页面样式加载不出来,大部分原因是STATICFILES_DIRS指向的目录和实际放静态文件的目录不一致。这个项目的目录结构很规范,static目录放在项目根目录下,模板里用{% load static %}加载静态文件:

{% load static %} <link rel="stylesheet" href="{% static 'css/bootstrap.min.css' %}">

4. 环境部署与运行指南

4.1 本地开发环境准备

先列一下环境要求:

  • Python 3.8以上
  • Django 3.2或4.x(源码用的应该是Django 3.2)
  • MySQL或SQLite(本地调试用SQLite最方便,零配置)

我把全套步骤走一遍。假设你已经装了Python,打开终端,按顺序执行:

# 1. 创建虚拟环境 python -m venv venv # 2. 激活虚拟环境 # Windows: venv\Scripts\activate # macOS/Linux: source venv/bin/activate # 3. 安装依赖 pip install -r requirements.txt # 4. 数据库迁移 python manage.py makemigrations python manage.py migrate # 5. 创建超级管理员 python manage.py createsuperuser # 6. 启动开发服务器 python manage.py runserver

然后浏览器访问http://127.0.0.1:8000就能看到系统首页。用你刚才创建的超级管理员账号登录,访问http://127.0.0.1:8000/admin进入Django后台,在后台可以创建教师用户和学生用户。

有一件事千万记得:正式部署到服务器之前,settings.py里的DEBUG必须改成False。DEBUG=True时Django会暴露详细的错误堆栈和配置信息,攻击者可以利用这些信息找到系统漏洞。改完DEBUG之后,还要配置ALLOWED_HOSTS:

ALLOWED_HOSTS = ['your-domain.com', 'www.your-domain.com']

4.2 生成测试数据

系统刚初始化没有任何数据,直接登录看不到效果。这套源码里我发现自带了一个seed脚本(或者你可以在Django shell里手动添加),用来生成基础测试数据:

python manage.py shell

在shell里输入:

from django.contrib.auth.models import User from apps.users.models import UserProfile from apps.courses.models import Course # 创建教师账号 teacher = User.objects.create_user(username='teacher01', password='123456') UserProfile.objects.create(user=teacher, role='teacher', name='张老师') # 创建学生账号 student = User.objects.create_user(username='student01', password='123456') UserProfile.objects.create(user=student, role='student', name='李同学') # 创建课程 Course.objects.create( course_code='CS101', course_name='Python程序设计', teacher=teacher, course_type='专业选修', credits=2.0, capacity=30, schedule='周一 3-4节', location='教学楼A301', status='approved', description='Python基础与实战' )

这个脚本的逻辑是用户表先用create_user创建,因为Django的User模型对密码字段有特殊的加密处理,直接create的话密码不会经过hash,会导致登录时认证失败。所以源码里如果是批量生成测试数据,切记要走create_user流程。

4.3 从SQLite切换到MySQL

本地开发用SQLite没问题,正式上线我还是建议切到MySQL或PostgreSQL。原因很简单:SQLite在高并发写入场景下性能扛不住,而且它不支持select_for_update()在并发情况下的行级锁效果(SQLite整个数据库只允许一个写锁)。

settings.py里改数据库配置:

DATABASES = { 'default': { 'ENGINE': 'django.db.backends.mysql', 'NAME': 'course_system', 'USER': 'root', 'PASSWORD': 'your_password', 'HOST': '127.0.0.1', 'PORT': '3306', 'OPTIONS': { 'init_command': "SET sql_mode='STRICT_TRANS_TABLES'", }, } }

切换数据库之后重新执行makemigrations和migrate,数据需要重新导入。生产环境不建议直接迁移SQLite里的数据,因为字段类型和约束都有差异,靠谱的做法是写数据导入脚本。

4.4 用Waitress和Nginx部署上线

开发环境下runserver的性能很差,而且Django官方明确说runserver不适合生产环境。不用复杂的Gunicorn和Docker,用Waitress加Nginx就能搭一个够用的生产环境。

Waitress是纯Python实现的WSGI服务器,Windows和Linux都能跑,不用像Gunicorn那样依赖C扩展。安装:

pip install waitress

然后启动命令:

waitress-serve --listen=127.0.0.1:8000 course_system.wsgi:application

这样Waitress就在8000端口跑Django应用了。接下来用Nginx做反向代理,监听80端口,把请求转发给Waitress:

server { listen 80; server_name your-domain.com; # 静态文件直接由Nginx服务 location /static/ { alias /path/to/project/static/; } location /media/ { alias /path/to/project/media/; } # 其他请求转发给Django location / { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }

这里有一个深坑:使用Nginx反代后,Django的request.user能正常获取,但request.META['REMOTE_ADDR']获取到的IP会变成127.0.0.1(也就是Nginx的地址),而不是真实客户端IP。解决办法是借助django-x-forwarded-for中间件,或者在settings.py里加:

USE_X_FORWARDED_HOST = True

另外,部署后记得执行python manage.py collectstatic,这个命令会把所有app里的静态文件复制到STATIC_ROOT指定目录,Nginx的alias路径就指向这里。不执行这条命令的话,页面样式会全部丢失。

5. 项目扩展点子

如果照着源码跑通了一遍,想继续往深处做点不一样的东西,我强烈建议在下面几个方向上扩展:

增加选课时间窗口。高校的真实选课通常按年级分批开放,比如大三先选、大二后选。这个系统的选课功能是全时段开放的,你可以给Course模型加一个enroll_start_timeenroll_end_time字段,在选课逻辑里加时间判断,这样就能模拟真实的选课批次。

加入选课志愿模式。有些学校不用抢课的方式,而是填志愿然后系统统一抽签。这需要把选课流程改成两阶段:先提交志愿,再定期跑一个抽签脚本。Django的manage.py命令可以自定义,写一个抽签命令配合cron定时执行,就是一个很高级的方向。

增加课程评价功能。选课结束后学生可以给课程打分和写评价,教师端能查看评价结果。这会涉及一对多的模型设计:一个课程有多个评价,一个学生在一个课程下只能有一个评价。顺着这个功能练一下Django的聚合查询(aggregate),也是查漏补缺。

接入缓存。系统跑一段时间后,课程列表页会是最常访问的页面,每次都查数据库没必要。用Django的cache框架,把课程列表缓存5分钟,能有效减少数据库压力。Redis是首选,配置也不复杂:

CACHES = { 'default': { 'BACKEND': 'django.core.cache.backends.redis.RedisCache', 'LOCATION': 'redis://127.0.0.1:6379/1', } }

然后用cache_page装饰器就能给视图加缓存:

from django.views.decorators.cache import cache_page @cache_page(60 * 5) def course_list(request): ...

6. 常见问题与避坑指南

做完这套项目,我把容易踩坑的点集中整理了一下。很多人运行源码时遇到的问题其实就那几个,排错思路是相通的。

问题一:运行后静态文件加载不出来

页面能打开但完全没有样式,说明CSS没加载。按三步排查:

  • 看浏览器开发者工具的Network标签,确认404的请求URL是什么
  • 确认settings.py里STATIC_URL和STATICFILES_DIRS配置正确
  • 模板里有没有写{% load static %},注意要写在文件最开头

问题二:选课点击没反应,或者报500错误

500错误优先看后端日志。在settings.py里配置日志输出,或者临时打开DEBUG看详细错误堆栈。最常见的两个原因:

  • 数据库表还没migrate,选课操作往不存在的表写数据
  • 视图里用了request.user.profile,但该用户的UserProfile对象没创建。新创建的用户如果没有同步创建UserProfile,访问profile必然报错

这个问题的根源在于UserProfile和User不是同步创建的。建议在User模型的post_save信号里自动创建UserProfile:

from django.db.models.signals import post_save from django.dispatch import receiver @receiver(post_save, sender=User) def create_user_profile(sender, instance, created, **kwargs): if created: UserProfile.objects.create(user=instance)

问题三:csrf verification failed

Django默认开启CSRF防护,所有POST请求的表单里都需要加{% csrf_token %}模板标签。如果你用JavaScript的fetch发POST请求,需要先从cookie里取csrftoken,放到请求头里:

fetch('/enroll/1/', { method: 'POST', headers: { 'X-CSRFToken': getCookie('csrftoken'), }, })

刚接触Django的人很容易在这里卡很久,我见过不少人直接把CSRF中间件注释掉图省事。千万别这么干,CSRF防护是安全底线,绕过它等于把系统暴露给跨站请求伪造攻击。

问题四:两个学生同时选最后一个名额,会不会超员

会,如果你没有用select_for_update的话。现在的源码已经处理了这个问题,但如果你自己改动过逻辑,要确保事务和行锁保留。

这里顺便提一个进阶技巧:在高并发场景下,更新selected_count时也可以用Django的F表达式做原子操作:

course.selected_count = F('selected_count') + 1 course.save(update_fields=['selected_count'])

F表达式让数据库在SQL层面完成UPDATE course SET selected_count = selected_count + 1,不经过Python层的读改写,天然避免并发覆盖问题。

问题五:进入admin后台没有样式

这基本可以确定是静态文件服务的问题。开发环境下Django会自动处理admin的静态文件,但如果你的项目设置了自定义STATIC_ROOT或者在调试时加了自己的URL规则,可能会覆盖掉默认行为。最快的排查方法:直接访问http://127.0.0.1:8000/static/admin/css/base.css,如果404了,检查settings.py中的INSTALLED_APPS里有没有django.contrib.admin。

注意事项:生产环境务必关闭DEBUG,不要用runserver直接对外服务。

源码跑通了之后,我建议你自己动手改一些东西,比如增加搜索功能,比如给课程列表加上分页。改代码的时候多想想每条逻辑背后的业务诉求,比单纯把代码跑起来有价值得多。这个项目的数据库设计和权限模型稍微改改就能复用做教室预约系统、实验室设备管理系统之类的东西,Django这套框架的底层思路是通用的,学会一个就能迁移到别的场景去。

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

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

立即咨询