Django教务选课系统实战:从模型关联到并发超选控制与部署
2026/9/12 5:28:30 网站建设 项目流程

简介:基于Python+Django开发的学生教务选课系统毕业设计资源,面向计算机相关专业毕业生、课程设计学生及Django初学者,完整覆盖注册登录、课程发布、在线选课、成绩录入、新闻公告管理等教务场景,从学生端到管理员端形成功能闭环。压缩包内共2000个文件,包含1632个JavaScript、250个HTML、54个CSS等前端资源,辅以5个Python源码文件、JSON配置及数据库脚本,整体约5.62MB,结构符合典型Web项目组织方式。系统涉及学院、专业、班级、学生等实体ER设计,内置可运行的Python完整源码与数据库脚本,可在PyCharm + Django2.2 + Python3.6 + MySQL5.6环境中直接部署,便于二次开发与毕业答辩演示。已有355人学习或浏览,适合需要快速搭建同类课题、梳理Django业务开发流程或扩展功能模块的读者参考。

1. 为什么这个选课系统值得拆开看一次

要说毕业设计里最容易“做完发现什么都没学会”的方向,教务选课系统绝对是前几名,因为它表面上只有“学生选课、老师录分、管理员发通知”三件事。但真正动笔时你会发现:选课是要在多个学生同时操作时保证不超选的,成绩是要和学生、课程、教师三方数据正确关联的,管理员看的统计页是要跨五张表联查的——这些才是Django开发里真正值钱的部分。这个基于Python3.6+Django2.2+PyCharm搭建的学生教务选课系统,源码和数据库脚本都整理得很完整,关键是有学院、专业、班级、学生、课程、选课记录这样完整的ER关系,特别适合用来补全“模型关联 + 事务 + 权限”这三块实战盲区。不管是拿它做毕业设计底稿,还是想快速演练一个多角色业务系统,都比空读文档有意义。

2. 环境初始化与Django项目骨架:版本钉子先钉死

2.1 为什么是Python3.6 + Django2.2 + MySQL5.6这套组合

先说环境,这不是偏保守,而是这套组合在2024年的今天依然有实际理由。Django2.2是最后一个支持Python3.5+的LTS版本,官方安全支持持续到2022年4月,很多老项目至今还在上面跑。Python3.6则保证你在PyCharm里跑旧源码时不会遇到f-string语法不兼容(Django2.2的内置代码大量使用f-string,Python3.5以下直接崩)。MySQL5.6则是和Django2.2的django.db.backends.mysql适配最顺的版本,用全新的MySQL8.0会撞上caching_sha2_password认证插件不兼容问题,连接时报Authentication plugin 'caching_sha2_password' cannot be loaded,处理起来非常麻烦。

环境版本对照如下:

组件版本说明
Python3.6.x源码兼容性的安全线
Django2.2.xLTS版,ORM和Admin足够成熟
PyCharm任意较新版本主要用于调试和虚拟环境管理
MySQL5.6 / 5.7避开MySQL8的认证插件问题

如果你本机装的是Python3.10以上,最稳妥的做法是用pyenv或者PyCharm里配置虚拟环境时手动指定Python3.6解释器路径,别硬用高版本Python去跑项目依赖,后面迁移模型时会出现一堆TypeError

2.2 从零创建项目和数据库

拿到源码后,第一步不是直接开跑,而是先建立隔离的虚拟环境,避免和机器上其他项目的包起冲突。在PyCharm的Terminal里执行:

# 创建虚拟环境(Python3.6解释器路径按实际版本改) python3.6 -m venv venv source venv/bin/activate # 安装依赖 pip install django==2.2.28 mysqlclient==2.0.3

依赖装完后,再看项目目录结构。源码包里如果没有manage.py这个文件,说明你拿到的可能是纯代码包,需要自己用命令补上项目骨架:

# 创建项目,名称按你解压的目录名改 django-admin startproject course_system . # 创建核心应用 python manage.py startapp student python manage.py startapp course python manage.py startapp news

上面的结构里,student负责学生档案和认证,course负责课程和选课事务,news负责公告轮播。三应用分离的好处是权限边界清晰,可以分别给不同管理员分组授权。创建完应用后,编辑course_system/settings.py,把三个应用注册进去,同时配置数据库连接:

# course_system/settings.py INSTALLED_APPS = [ 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', 'student', # 学生档案与扩展 'course', # 课程发布与选课 'news', # 公告管理 ] # MySQL连接配置 DATABASES = { 'default': { 'ENGINE': 'django.db.backends.mysql', 'NAME': 'course_system', # 数据库名,需先在MySQL中创建 'USER': 'root', 'PASSWORD': 'your_password', 'HOST': '127.0.0.1', 'PORT': '3306', 'OPTIONS': { 'charset': 'utf8mb4', # 支持中文和emoji 'init_command': "SET sql_mode='STRICT_TRANS_TABLES'", }, } }

这里有几个坑要重点说出来。第一,MySQL里建库时必须指定字符集,否则存中文乱码:

CREATE DATABASE course_system DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;

第二,mysqlclient2.0.3在Python3.6下编译时要确保系统已装libmysqlclient-dev,否则会报EnvironmentError: mysql_config not found,Ubuntu/Debian上执行sudo apt-get install default-libmysqlclient-dev可以解决。第三,OPTIONS里的init_command不能省,Django2.2在MySQL5.6下不设置sql_mode会在插入数据时静默截断字符串,导致数据长度检查失效。

2.3 静态文件与前端资源接管

源码包里的bootstrap.cssanimate.cssfont-awesome.cssbootstrap-datetimepicker.css这些前端文件,对应的就是bootstrap3风格的模板页面。Django2.2对静态文件的处理方式是:每个应用下建static目录,顶层的项目也建一个static目录做公共资源池。

把解压出来的CSS和JS文件放到course_system/static/下,然后在settings.py里补上:

STATIC_URL = '/static/' STATICFILES_DIRS = [ os.path.join(BASE_DIR, 'static') ]

注意STATICFILES_DIRS是列表,多个路径时依次写。模板里引用静态文件的正确姿势是:

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

别直接用/static/css/bootstrap.css硬编码,虽然本地能跑,但部署到Nginx后就失效了。前端资源的问题解决掉,后面做模型迁移和页面联调才不会分心去排查404。

3. 选课核心事务:ER模型落成ORM与并发超选控制

3.1 从ER实体到Django模型的字段映射

这个项目的实体关系比较完整:学院(College)含名称、成立日期、院长、电话;专业(Major)挂在学院下;班级(ClassInfo)挂在专业下;学生(Student)通过外键挂班级。课程和选课记录之间是多对多关系,选课记录本身还有成绩字段。这一层映射代码质量直接决定后期联查复杂度。

student/models.py里:

from django.db import models from django.contrib.auth.models import User class College(models.Model): name = models.CharField('学院名称', max_length=64, unique=True) established = models.DateField('成立日期') dean = models.CharField('院长姓名', max_length=32) phone = models.CharField('联系电话', max_length=20) def __str__(self): return self.name class Major(models.Model): name = models.CharField('专业名称', max_length=64) college = models.ForeignKey(College, verbose_name='所在学院', on_delete=models.PROTECT) contact = models.CharField('联系人', max_length=32) class ClassInfo(models.Model): name = models.CharField('班级名称', max_length=32) major = models.ForeignKey(Major, verbose_name='所属专业', on_delete=models.PROTECT) head_teacher = models.CharField('班主任', max_length=16) class Student(models.Model): user = models.OneToOneField(User, on_delete=models.CASCADE) sno = models.CharField('学号', max_length=20, unique=True) gender = models.CharField('性别', max_length=2, choices=(('男','男'),('女','女'))) klass = models.ForeignKey(ClassInfo, verbose_name='所在班级', on_delete=models.PROTECT) phone = models.CharField('联系电话', max_length=20) email = models.EmailField('邮箱')

on_delete=models.PROTECT这个选择值得展开说。Django2.0以后on_delete是必填参数,很多人图省事写成CASCADE,删除学院时会把专业、班级、学生全带走,在真实教务场景里这是严重数据事故。选课系统的数据是终生存档的,PROTECT会在删除被关联对象时抛ProtectedError,从数据结构层面防住误删。

课程和选课记录在course/models.py里:

class Course(models.Model): name = models.CharField('课程名', max_length=64) teacher = models.CharField('授课教师', max_length=32) credit = models.DecimalField('学分', max_digits=2, decimal_places=1) capacity = models.PositiveIntegerField('容量', default=50) selected = models.PositiveIntegerField('已选人数', default=0) schedule = models.CharField('上课时间', max_length=128) is_active = models.BooleanField('开放选课', default=True) def __str__(self): return f'{self.name} - {self.teacher}' class Enrollment(models.Model): student = models.ForeignKey('student.Student', on_delete=models.CASCADE, verbose_name='选课学生') course = models.ForeignKey(Course, on_delete=models.PROTECT, verbose_name='课程') score = models.DecimalField('成绩', max_digits=4, decimal_places=1, null=True, blank=True) created_at = models.DateTimeField('选课时间', auto_now_add=True) class Meta: unique_together = ('student', 'course')

unique_together这个联合唯一约束相当于数据库层的最后一道保险,即使后面业务代码有bug,也没法给同一个学生插入两条相同课程记录。

3.2 用事务和行级锁解决超选问题

学生选课这个动作,放在并发场景下是典型的“检查后写”竞争问题。不加保护的话,两个学生同时提交选同一门只剩一个名额的课程,两个人查到的selected都是49 < 50,然后各自插入记录,最后总人数变成51,超选了。

解法是事务加select_for_update行锁:

from django.db import transaction from django.http import JsonResponse from django.contrib.auth.decorators import login_required @login_required def enroll_course(request): if request.method != 'POST': return JsonResponse({'code': 400, 'msg': '只支持POST请求'}) course_id = request.POST.get('course_id') student = Student.objects.get(user=request.user) with transaction.atomic(): course = Course.objects.select_for_update().get(pk=course_id) if not course.is_active: return JsonResponse({'code': 403, 'msg': '课程未开放选课'}) if course.selected >= course.capacity: return JsonResponse({'code': 403, 'msg': '课程已满员'}) if Enrollment.objects.filter(student=student, course=course).exists(): return JsonResponse({'code': 403, 'msg': '重复选课'}) Enrollment.objects.create(student=student, course=course) course.selected += 1 course.save(update_fields=['selected']) return JsonResponse({'code': 200, 'msg': '选课成功'})

核心逻辑在select_for_update()这一行,它会对命中的课程行加写锁,直到整个事务结束才释放。当A学生锁住这行时,B学生的查询会阻塞等待,等A的事务提交后B再读取,此时selected已经是更新后的值,超选的条件判断自然失效。

调用这段视图的模板表单:

<form method="post" action="{% url 'enroll_course' %}"> {% csrf_token %} <input type="hidden" name="course_id" value="{{ course.id }}"> <button type="submit" class="btn btn-primary">选这门课</button> </form>

3.3 选课页面的数据联查

学生登录后看到的是自己的选课和成绩信息,这需要跨EnrollmentCourseStudent三张表查询:

from django.shortcuts import render @login_required def my_courses(request): student = Student.objects.get(user=request.user) enrollments = (Enrollment.objects .filter(student=student) .select_related('course') .order_by('-created_at')) context = { 'enrollments': enrollments, 'student': student, # 统计选课学分 'total_credit': sum([e.course.credit for e in enrollments]) } return render(request, 'student/my_courses.html', context)

select_related('course')的意思是让Django在SQL层用JOIN一次性把关联课程取回来,而不是先查选课记录再逐条查课程(N+1查询问题)。选课列表页如果不用它,30条记录会产生31条SQL,页面响应直接飙到几百毫秒。

模板里遍历时直接取关联字段:

{% for e in enrollments %} <tr> <td>{{ e.course.name }}</td> <td>{{ e.course.teacher }}</td> <td>{{ e.course.credit }}</td> <td>{% if e.score %}{{ e.score }}{% else %}未出分{% endif %}</td> </tr> {% endfor %}

模板里e.score为空时的分支判断很实用,老师没录分之前显示“未出分”,录分后自动切换为具体分数,这是教务系统的默认行为预期。

4. 教务端角色权限与后台数据管理

4.1 三级角色的权限边界设计

这个系统的角色分三档:学生(前台注册)、教师(课程发布与成绩登记)、管理员(全员信息管理)。处理权限最稳妥的方式是复用Django自带的auth.User,然后给每个用户分组。具体做法:

from django.contrib.auth.models import Group, Permission # 创建三个分组 admin_group, _ = Group.objects.get_or_create(name='教务管理员') teacher_group, _ = Group.objects.get_or_create(name='教师') student_group, _ = Group.objects.get_or_create(name='学生')

后台管理界面里,把课程管理权限分配给教师组:

# course/admin.py from django.contrib import admin from django.contrib.auth.models import Group from .models import Course, Enrollment class EnrollmentInline(admin.TabularInline): model = Enrollment extra = 0 readonly_fields = ('student', 'score') @admin.register(Course) class CourseAdmin(admin.ModelAdmin): list_display = ['name', 'teacher', 'credit', 'capacity', 'selected', 'is_active'] list_filter = ['is_active', 'teacher'] search_fields = ['name', 'teacher'] actions = ['open_course', 'close_course'] inlines = [EnrollmentInline] def open_course(self, request, queryset): queryset.update(is_active=True) open_course.short_description = '开放选课' def close_course(self, request, queryset): queryset.update(is_active=False) close_course.short_description = '关闭选课'

EnrollmentInline放在CourseAdmin里,管理员点开设课课程时,可以直接看到已选学生列表。actions自定义的“开放选课/关闭选课”批量操作,比一个一个点进去改效率高出不少。

4.2 学生注册时的密码处理

学生在前台注册时,最常犯的错误是直接User.objects.create(username=..., password=...),这样存进去的是明文密码,Django自带的登录校验永远无法匹配。正确姿势是调用create_user或者set_password

from django.contrib.auth.models import User from django.shortcuts import redirect, render from .models import Student def register(request): if request.method == 'POST': username = request.POST.get('username') password = request.POST.get('password') sno = request.POST.get('sno') # ... # create_user会对密码做hash加密 user = User.objects.create_user(username=username, password=password) student = Student.objects.create(user=user, sno=sno, ...) return redirect('login') return render(request, 'register.html')

密码是Django的PBKDF2WithHmacSHA256算法加过盐的,Django自身登录视图拿用户输入的明文去做同一个hash运算,匹配才放行。

4.3 成绩登记的直接提交与防重复

老师端的成绩录入可以采用POST直接提交更新,但要处理一个边界:重复提交时不能新增第二条记录,只能更新原记录的成绩字段:

@login_required def save_score(request, enrollment_id): if request.method == 'POST': score = request.POST.get('score') try: enrollment = Enrollment.objects.select_for_update().get(pk=enrollment_id) except Enrollment.DoesNotExist: return JsonResponse({'code': 404, 'msg': '选课记录不存在'}) enrollment.score = score # 只更新score字段,避免覆盖其他字段 enrollment.save(update_fields=['score']) return JsonResponse({'code': 200, 'msg': '成绩已保存'})

update_fields限制只更新score这一列,相当于SQL层的UPDATE enrollment SET score = ... WHERE id = ...,避免把created_at等字段意外覆盖。

5. 部署与二次开发:uWSGI上线和几个值得动手的增强点

5.1 用uWSGI + Nginx 把系统跑起来

开发环境里python manage.py runserver只适合调试,并发能力和安全性都不行。部署时按常规做法先用uWSGI托起Django应用:

# 安装uWSGI(Python3.6版本对应) pip install uwsgi==2.0.21 # 启动uWSGI,监听8000端口 uwsgi --http :8000 \ --chdir /path/to/course_system \ --wsgi-file course_system/wsgi.py \ --master --processes 4 --threads 2 \ --stats :9191 \ --vacuum --max-requests 5000

参数说明:--processes 4表示开启4个worker进程,--threads 2表示每个进程开2个线程;--max-requests 5000让worker处理完5000个请求后自动回收,防止内存泄漏;--vacuum在进程退出时清理unix socket文件。选课高并发场景下这个配置能撑住上百并发。

前端静态文件交给Nginx处理效率更高:

server { listen 80; server_name your.domain.com; location /static/ { alias /path/to/course_system/static/; } location / { uwsgi_pass 127.0.0.1:8000; include /etc/nginx/uwsgi_params; } }

5.2 二次开发建议:批量导入与公告扩展

如果想让系统更接近生产可用,最值得动手的两个增强是:用openpyxl做学生信息的Excel批量导入,以及给新闻公告模型加上cover_image字段显示轮播图。批量导入的代码骨架:

import openpyxl def import_students(file_path): wb = openpyxl.load_workbook(file_path) ws = wb.active count = 0 for row in ws.iter_rows(min_row=2, values_only=True): sno, name, gender, class_name, phone = row[:5] # 判断班级是否存在,不存在则自动创建 klass, _ = ClassInfo.objects.get_or_create(name=class_name) user = User.objects.create_user(username=sno, password=sno[-6:]) Student.objects.create(user=user, sno=sno, name=name, gender=gender, klass=klass, phone=phone) count += 1 return count

5.3 验证并发选课是否真的不超选

最后给一个验证手法:用ab(ApacheBench)工具模拟并发请求,测试同一门只剩少量空位的课程。

# 并发20个请求选同一门课 ab -n 20 -c 20 -T 'application/x-www-form-urlencoded' \ -p enroll.txt http://127.0.0.1:8000/course/enroll/

压测后去MySQL确认:

SELECT course_id, COUNT(*) FROM course_enrollment GROUP BY course_id; SELECT selected, capacity FROM course_course WHERE id = 1;

只要selected不超过capacity,且enrollment表人数与selected字段一致,说明行锁生效。如果出现不一致,优先检查视图函数里是否真的用了transaction.atomic()包裹,以及select_for_update()是否写在了锁范围内的第一条查询上。

本文还有配套的精品资源,点击获取

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

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

立即咨询