Django毕业设计选课系统源码拆解:MTV架构与事务并发实现
简介一份基于 Python 与 Django 框架的学生教务选课系统毕业设计源码案例面向计算机相关专业毕业生、Web 开发初学者及需要快速搭建教务管理原型的技术人员。系统涵盖学生、教师、管理员三类角色的身份验证与权限控制支持课程信息维护、在线选课、已选课程查看及选课数据统计等核心功能可用于课程设计、毕业设计或学习 Django MTV 架构、ORM 数据操作与安全防护机制的实战参考。压缩包内共 2000 个文件以 JavaScript1632 个、HTML250 个、CSS54 个前端资源为主辅以少量 Python 源码、JSON/XML 配置及说明文档整体体积约 5.66MB结构便于按前端页面、脚本、配置与后端代码分类查阅。目前已有 182 人学习下载。通过该项目读者可以理解从需求分析、数据库设计到权限管理、业务编码的完整流程借鉴实际开发中的目录组织方式和接口实现细节为独立完成同类系统设计提供直接参考。1. 为什么说教务选课系统是 Django 毕业设计的“标准答案”如果你正在为毕业设计选型发愁或者是接手了别人留下的“基于Django的学生教务选课系统”源码包却不知道怎么跑起来这个资源值得先花半小时拆一遍。选课系统这个题目在毕业设计里出现频率极高不是因为它简单而是因为它在“能演示”和“有技术含量”之间找到了一个极好的平衡点一套完整的 MTV 架构、ORM 多表关联、用户权限分级、事务与并发控制、模板复用全部压在四个数据表和三个角色里工作量可控但讲技术点时有话可说。我用一个下午把源码完整过了一遍跑通、改参数、踩坑、写测试下面这六章按“架构理解 → 核心逻辑 → 页面复用 → 问题排查 → 上线验证”的顺序把这份资源里真正值钱的部分全部拆开讲。2. 数据模型设计把教务业务拆成四张表2.1 为什么学生和教师都挂在 User 上而不是单独建表拿到源码第一步先看models.py。这套系统的模型设计没有走“自己写一套用户表”的路子而是直接复用 Django 内置的auth.User用它来承载账号密码、登录状态、权限分组这些通用能力同时通过 OneToOne 外键扩展出Student和Teacher这两张业务表。这个设计比重新写一张用户表高明在三个地方密码加密、会话管理、权限系统全部由框架托管不用自己造轮子后面要用装饰器做角色控制时request.user.student和request.user.teacher的判断路径非常自然数据迁移时不用动内置表的结构升级 Django 版本也不会撞车。# models.py from django.contrib.auth.models import User from django.db import models class Student(models.Model): # OneToOne 而不是 ForeignKey一个账号只能对应一个学生档案 user models.OneToOneField(User, on_deletemodels.CASCADE, related_namestudent) student_no models.CharField(学号, max_length20, uniqueTrue) major models.CharField(专业, max_length64) class_name models.CharField(班级, max_length64) enroll_year models.IntegerField(入学年份, default2022) def __str__(self): return f{self.student_no} - {self.user.get_full_name() or self.user.username} class Teacher(models.Model): user models.OneToOneField(User, on_deletemodels.CASCADE, related_nameteacher) teacher_no models.CharField(工号, max_length20, uniqueTrue) title models.CharField(职称, max_length32, blankTrue) # 讲师/副教授/教授 def __str__(self): return f{self.teacher_no} - {self.user.get_full_name() or self.user.username}这里有个细节值得注意related_name写的是student和teacher不是默认的student_set。这意味着在视图里可以直接写request.user.student拿到当前登录用户的学生档案少一次查询读起来也干净。on_deletemodels.CASCADE的设置意味着删除 User 账号时对应的学生或教师档案会被连带删除——这在毕业生离校清理账号时是合理的但实际业务里如果是保留历史选课记录的场景这个值要改成PROTECT才对后面避坑章节会详细说。2.2 Course 和 Selection为什么不直接用 ManyToManyField课程和学生的关系很多人第一反应是加一个ManyToManyField字段一省事。但这份源码没有这么做而是建了一张独立的Selection表。原因在于选课记录不只是“谁选了哪门课”这个关系还带着“什么时间选的、当前状态是什么、退课记录怎么留痕”这些附加信息。ManyToManyField默认生成的中间表只存两个外键想加字段就得改中间表模型绕一圈不如直接显式建模。class Course(models.Model): name models.CharField(课程名称, max_length128) code models.CharField(课程编号, max_length32, uniqueTrue) credit models.DecimalField(学分, max_digits3, decimal_places1, default2.0) capacity models.PositiveIntegerField(容量, default50) teacher models.ForeignKey(Teacher, on_deletemodels.PROTECT, related_namecourses) schedule models.CharField(上课时间, max_length64, help_text格式周几,节次如 1,3-4 表示周一第3-4节) semester models.CharField(开课学期, max_length32, default2024-2025-1) class Meta: ordering [semester, code] class Selection(models.Model): STATUS_CHOICES ( (selected, 已选), (dropped, 已退), ) student models.ForeignKey(Student, on_deletemodels.CASCADE, related_nameselections) course models.ForeignKey(Course, on_deletemodels.CASCADE, related_nameselections) status models.CharField(状态, max_length10, choicesSTATUS_CHOICES, defaultselected) selected_at models.DateTimeField(选课时间, auto_now_addTrue) class Meta: # 核心约束同一学生对同一门课只能有一条记录 unique_together (student, course)on_deletemodels.PROTECT用在Course.teacher上很有讲究教师如果被删除了但这个老师名下还有课程数据库会直接报错拒绝删除避免了“教师没了课程还在”这种脏数据。schedule字段存的是格式化字符串比如1,3-4表示周一第 3-4 节这种设计牺牲了一点结构化查询的能力但换来了代码里解析和展示的灵活性。判断课程冲突时读出来解析比对即可不需要数据库层做复杂的时段匹配。2.3 四张表的关系画成图是什么样把上面四个模型的关系理清楚Student和Teacher分别 OneToOne 指向UserCourse多对一指向TeacherSelection同时多对一指向Student和Course。也就是说一条选课记录同时带着“谁选的、选的哪门课、选的老师是谁”这三层信息通过外键链路一次查询就能全部拿到。实际写视图的时候用select_related把外键关系一次性查出来可以避免 N1 查询问题——比如在选课记录列表页每条记录都要显示课程名和教师名不加select_related的话每行都要多查两次数据库数据量一上来页面明显变慢。3. 选课核心流程事务、并发与权限控制的三道防线3.1 角色权限装饰器把三类用户的入口分开这套系统里有学生、教师、管理员三类角色但底层都是同一个User表。源码的处理方式是在视图层的入口处做了三层拦截第一层login_required判断是否登录第二层自定义装饰器判断当前用户是不是学生第三层在教师和管理员的视图里再做相应的角色判断。装饰器不写在views.py里而是拆到了decorators.py保持视图函数本身干净。# decorators.py from django.contrib.auth.decorators import login_required from django.core.exceptions import PermissionDenied from functools import wraps def student_required(view_func): wraps(view_func) login_required def _wrapped_view(request, *args, **kwargs): # 判断当前登录用户是否关联了学生档案 if not hasattr(request.user, student): raise PermissionDenied(仅限学生访问) return view_func(request, *args, **kwargs) return _wrapped_view def teacher_required(view_func): wraps(view_func) login_required def _wrapped_view(request, *args, **kwargs): if not hasattr(request.user, teacher): raise PermissionDenied(仅限教师访问) return view_func(request, *args, **kwargs) return _wrapped_view这个写法的判断逻辑值得细看不是看request.user.role这种自定义字段而是检查hasattr。因为学生和教师都是通过 OneToOne 挂到 User 上的登录用户有没有对应的档案直接决定了能不能访问。这个判断方式的好处是即使管理员账号也是 User 实例但因为它没有student或teacher关联天然被拦在外面。注意权限判断的顺序student_required返回的_wrapped_view内部先过login_required这个嵌套顺序决定了“未登录用户重定向到登录页”而“已登录但权限不足的用户看到 403”两个分支不会串。3.2 选课事务容量校验、冲突检测、幂等保障选课是这套系统里最有含金量的逻辑没有之一。源码里把选课操作封装在services.py而不是直接写在视图里这是个很好的分层习惯。整个流程拆成四步校验课程是否存在、校验容量是否已满、校验时间是否和已有课程冲突、写入选课记录。前两步是读操作第三步是写操作其中第一次读和最后一次写之间如果被并发插入就会出现“明明显示还剩一个名额结果两个人同时选上”的事故。解决这个问题的关键在事务和行锁。# services.py from django.db import transaction from django.core.exceptions import ValidationError from .models import Course, Selection MAX_CREDITS 25 # 每学期最大学分上限 transaction.atomic def select_course(student, course_id): # select_for_update 对课程行加写锁直到事务结束才释放 course Course.objects.select_for_update().get(pkcourse_id) # 重复选课检查同一学生对同一课程已存在有效记录则拒绝 if Selection.objects.filter(studentstudent, coursecourse, statusselected).exists(): raise ValidationError(你已经选过这门课了) # 容量检查统计当前已选人数 selected_count Selection.objects.filter(coursecourse, statusselected).count() if selected_count course.capacity: raise ValidationError(f课程 [{course.name}] 容量已满) # 学分校验累加本学期已选学分 current_credits sum( s.course.credit for s in Selection.objects.filter( studentstudent, statusselected ).select_related(course) ) if current_credits course.credit MAX_CREDITS: raise ValidationError(f超过学分上限 {MAX_CREDITS} 分) # 时间冲突校验逐一比对已有课程的 schedule 字段 for sel in Selection.objects.filter(studentstudent, statusselected).select_related(course): if _is_schedule_conflict(course.schedule, sel.course.schedule): raise ValidationError(f与课程 [{sel.course.name}] 上课时间冲突) # 全部校验通过创建选课记录 Selection.objects.create(studentstudent, coursecourse, statusselected) return course这段代码里select_for_update()是防并发的核心。在 PostgreSQL 和 MySQL InnoDB 引擎下它会锁住course这一行数据直到整个事务提交或回滚。A 学生选了课还没提交B 学生再查这条课程记录时会被阻塞等 A 提交后 B 拿到的就是更新后的容量数据。没有这把锁两个请求同时通过容量校验再同时写记录超员就发生了。另外transaction.atomic保证了“校验失败抛异常就整体回滚校验通过才统一提交”不会出现校验成功但写入失败的半截状态。_is_schedule_conflict是一个解析schedule字符串并判断两个时间段是否有重叠的辅助函数逻辑不难但建议你对它加几个单元测试因为这是选课系统里最容易因为格式解析出错的地方。3.3 退课逻辑软删除比硬删除多留一条后悔药退课操作在源码里没有直接delete()掉选课记录而是把status从selected改成dropped。这个细节很多人会忽略但它是教务系统里一个重要的业务决策选课记录是学生的学业档案一部分直接删除会让后续的统计报表、成绩录入、学分审核失去依据。保留dropped状态后你可以随时统计某一门课有多少人选过但退了甚至可以实现“退课后重新选”时直接复用原记录更新状态而不是再插入一条新记录。transaction.atomic def drop_course(student, course_id): course Course.objects.select_for_update().get(pkcourse_id) rec Selection.objects.filter( studentstudent, coursecourse, statusselected ).first() if not rec: raise ValidationError(你没有选这门课无法退课) rec.status dropped rec.save(update_fields[status]) return courseupdate_fields[status]这行有实际价值它告诉 ORM 只更新status这一个字段避免写入时把其他字段的值也重刷一遍。如果你的Selection表里后续加了score、comment等字段这个写法能防止退课操作不小心把成绩或教师评语覆盖掉。4. 页面与交互模板继承、动态导航和表单回显的复用套路4.1 三块页面的公共骨架是怎么抽出来的Django 的模板系统里{% extends %}和{% block %}是减少重复代码的两个基本动作。这套源码把公共骨架抽到了base.html顶部导航栏、左侧角色信息栏、右下角的消息提示区都是统一的每个具体页面只需要填{% block content %}和{% block title %}这两块。更有意思的是导航栏根据当前用户角色动态渲染——学生看到“选课中心”“我的课表”“成绩查询”教师看到“我的课程”“选课名单”这是通过模板里对request.user的hasattr判断实现的。!-- templates/base.html -- !DOCTYPE html html langzh-cn head meta charsetUTF-8 title{% block title %}教务选课系统{% endblock %}/title link hrefhttps://cdn.jsdelivr.net/npm/bootstrap5.3.0/dist/css/bootstrap.min.css relstylesheet /head body nav classnavbar navbar-expand-lg navbar-dark bg-primary div classcontainer a classnavbar-brand href/教务选课系统/a div classnavbar-nav ms-auto {% if request.user.is_authenticated %} {% if request.user.student %} a classnav-link href{% url student:course_list %}选课中心/a a classnav-link href{% url student:my_courses %}我的课表/a {% elif request.user.teacher %} a classnav-link href{% url teacher:my_courses %}我的课程/a {% endif %} span classnavbar-text ms-3{{ request.user.get_full_name|default:request.user.username }}/span a classnav-link href{% url logout %}退出/a {% else %} a classnav-link href{% url login %}登录/a {% endif %} /div /div /nav div classcontainer mt-4 {% if messages %} {% for message in messages %} div classalert alert-{{ message.tags }} alert-dismissible fade show{{ message }}/div {% endfor %} {% endif %} {% block content %}{% endblock %} /div /body /html上面这段代码里有几处值得直接抄走。第一{% if request.user.student %}的判空逻辑和视图层的hasattr判断一脉相承——User 没有关联学生档案时这个属性在模板里取到的是空值走elif分支。第二message.tags直接对应 Djangomessages框架里的success、error、warning标签配合 Bootstrap 的alert-success、alert-danger等样式类一条通知消息从头到尾不需要写任何自定义 CSS。4.2 选课页面的表单提交Django Form 校验比手写 request.POST 更稳系统里选课不通过 GET 请求直接改数据而是把课程 ID 放在 POST 表单或请求体里再由视图层解析。源码里用 Django Form 类统一管理表单字段和校验而不是在视图里散落着一堆request.POST.get(...)。这个习惯值得养成Form 类把“字段是否存在、格式是否正确、值是否合法”三件事全部收拢到一处视图拿到form.is_valid()通过后的cleaned_data直接用代码结构清晰得多。4.3 列表页的分页与筛选带参数翻页不丢条件选课列表页和我的课表页都涉及分页。Django 内置的Paginator很够用真正的难点在于“筛选条件 分页页码”同时出现在 URL 查询参数里时翻页后筛选条件不能丢。源码里的处理方式是把查询参数拼接逻辑封装成模板里的小段代码把request.GET里除了page之外的参数全部带上下一页链接。# views/student_views.py from django.core.paginator import Paginator from django.db.models import Q def course_list(request): queryset Course.objects.select_related(teacher).filter( semester2024-2025-1 ) kw request.GET.get(kw, ).strip() if kw: # Q 对象实现课程名和教师姓名的模糊搜索 queryset queryset.filter( Q(name__icontainskw) | Q(teacher__user__username__icontainskw) ) paginator Paginator(queryset, 10) # 每页 10 条 page_obj paginator.get_page(request.GET.get(page)) return render(request, student/course_list.html, { page_obj: page_obj, kw: kw, })模板里翻页链接的写法是把kw拼进page参数之外的所有链接里。比如第二页的 URL 是?page2kw算法而不是?page2。这个细节如果漏了用户搜索完点第二页就会发现自己搜的结果全没了是个非常典型的翻车点。分页组件本身用 Bootstrap 的pagination样式page_obj.has_previous和has_next控制前后页按钮的显隐page_obj.number和paginator.num_pages显示当前页码和总页数。5. 避坑与排查Django 版本差异、并发与迁移的三个“血泪”现场5.1 Django 版本差异导致 admin 后台打不开现象照着教程跑python manage.py runserver访问/admin直接报ModuleNotFoundError: No module named django.urls.url或者显示 404。原因这套源码最早是在 Django 2.x 时代写的老代码里用的是from django.conf.urls import url配合正则表达式路由而 Django 3.x 和 4.x 已经彻底移除了这个导入路径。网上很多教程和毕业设计源码都存在这个版本断层。解决把根路由urls.py里的url(r^admin/, admin.site.urls)统一改成path(admin/, admin.site.urls)子路由同理。改完之后用python manage.py check验证没有语法错误再重启开发服务器。这个坑在导入任何老项目时都值得先排查一遍。5.2 选课容量超员但数据库没报错现象课程容量设成 50实际选课成功的人数却有 51 甚至更多数据层面还没有违反任何约束。原因容量校验是“先 SELECT 再 INSERT”的流程两个请求同时读到已选人数 49都认为自己是第 50 个先后 INSERT 成功。没有行锁也没有数据库层面的容量约束兜底这个问题靠加个 unique 约束解决不了——因为约束只能防重复选同一门课防不了并发穿透容量上限。解决在select_course里给课程行加select_for_update()让容量校验和写入在同一事务里串行化。如果用的数据库是 SQLite注意select_for_update在 SQLite 下是空操作测试时得换成 PostgreSQL 或 MySQL 才能真正验证并发行为。5.3 选课记录的时间比本地时间晚了八个小时现象数据库里selected_at显示的选课时间比实际时间少了 8 小时恰好是 UTC 和北京时间时差。原因Django 默认USE_TZ True存入数据库的时间统一转成 UTC。如果你的settings.py里TIME_ZONE还写着UTC或者没设在Asia/Shanghai模板渲染出来的时间就会偏。解决在settings.py里设置TIME_ZONE Asia/Shanghai且USE_TZ True。注意USE_TZ不要改成False——那样虽然显示的本地时间对了但 Django 的时区处理机制会被绕过后续如果部署到国外服务器会完全错乱。速查逻辑是USE_TZ TrueTIME_ZONE Asia/Shanghai两者配合而不是互斥。5.4 教师删除时被外键卡住报 PROTECT 错误现象管理员在后台删除一个教师账号页面报错说无法删除外键约束限制了这个操作。原因Course.teacher设置了on_deletemodels.PROTECT这是有意为之的保护机制——这位教师名下还关联着课程贸然删除会留下课程没有授课教师的脏数据。这个报错是好事说明数据完整性守住了不是系统故障。解决确认教师名下的课程是否已结课或已清理确认后再删教师。或者把PROTECT改成CASCADE让课程连带删除——但在教务场景里这个操作要非常慎重因为课程被连带删除意味着所有学生的选课记录也一起被删会损坏历史档案。6. 上线前最后一公里用 Django Test Client 做一轮冒烟验证收到了这个源码包之后别急着写业务代码先跑起来跑通了再做二次开发。但“跑通了”不能只靠肉眼在浏览器里点两下建议你用 Django 自带的test.Client把核心链路过一遍比手工点击靠谱得多。test.Client是 Django 内置的测试客户端模拟浏览器的 GET 和 POST 请求不需要真实启动服务器就能跑。写测试代码前先准备一个测试用数据库——python manage.py test会自动创建一个独立的测试数据库不会污染你的开发数据。# tests/test_selection.py from django.test import TestCase from django.contrib.auth.models import User from django.urls import reverse from app.models import Student, Teacher, Course class SelectionFlowTest(TestCase): def setUp(self): # 创建学生账号和档案 student_user User.objects.create_user(usernamestu01, passwordtest123) Student.objects.create(userstudent_user, student_no20240001, major计算机) # 创建教师账号和课程 teacher_user User.objects.create_user(usernametea01, passwordtest123) teacher Teacher.objects.create(userteacher_user, teacher_noT001, title副教授) Course.objects.create( name数据结构, codeCS201, credit3.0, capacity2, teacherteacher, schedule1,3-4, semester2024-2025-1 ) self.client.login(usernamestu01, passwordtest123) def test_select_course_success(self): course Course.objects.get(codeCS201) resp self.client.post(reverse(student:select_course, args[course.id])) self.assertEqual(resp.status_code, 302) # 成功选课后重定向 self.assertTrue( Selection.objects.filter(student__user__usernamestu01, statusselected).exists() ) def test_select_course_when_full(self): # 先把两个名额占满第一个学生选课 course Course.objects.get(codeCS201) Selection.objects.create( studentStudent.objects.get(student_no20240001), coursecourse, statusselected ) # 模拟第二个学生登录再选同一门课 self.client.logout() user2 User.objects.create_user(usernamestu02, passwordtest123) Student.objects.create(useruser2, student_no20240002, major软件工程) self.client.login(usernamestu02, passwordtest123) resp self.client.post(reverse(student:select_course, args[course.id])) # 容量满时不该成功的判断重定向到列表页且出现错误消息 self.assertIn(容量已满, resp.content.decode())setUp里每次跑测试都会新建用户和相关档案测试结束自动清理不会污染开发用的真实数据。上面test_select_course_when_full里我先手动插入一条选课记录把容量占掉一条再用第二个学生账号尝试选课验证容量满时的拦截逻辑。assertIn检查的“容量已满”字符串位置需要注意——如果你走的是重定向加消息提示的路子response.content里不一定有这段文字要改成assertRedirects后再followTrue检查。跑一遍python manage.py test如果这三个用例全过说明用户认证、课程创建、选课事务、容量校验这条主链路是通的。我个人的习惯是拿到任何一份 Django 开源项目源码第一件事就是补上这样一组冒烟测试——不是要测得多全而是通过测试代码反推整个系统最核心的“数据怎么进来、怎么被改、怎么被校验”的全过程比翻视图代码快得多。如果连测试都懒得跑至少手动走一遍“学生注册 → 选课 → 退课 → 再选课”这四条路径确认没有报错再接手改代码。希望帮到你。本文还有配套的精品资源点击获取