ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Django校园选课系统实战:从并发控制到权限管理

Django校园选课系统实战:从并发控制到权限管理 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(studentrequest.user).select_related(course)这种开发效率上的优势在业务逻辑越复杂的时候越明显。还有一点这个项目采用的是MTV模式和传统的MVC有些区别。简单理解MModel模型层负责和数据库打交道TTemplate模板层负责页面展示VView视图层负责业务逻辑。关键在于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_deletemodels.CASCADE, related_nameprofile) role models.CharField(max_length20, choicesROLE_CHOICES, defaultstudent) student_id models.CharField(max_length20, blankTrue, nullTrue) teacher_id models.CharField(max_length20, blankTrue, nullTrue) phone models.CharField(max_length11, blankTrue)这种设计比直接继承AbstractUser灵活。因为如果以后要接入学校统一身份认证或者增加新的用户角色不需要动数据库里的用户表只扩展Profile表就行。课程模型是核心中的核心字段设计覆盖了选课所需的全部信息class Course(models.Model): course_code models.CharField(max_length20, uniqueTrue, verbose_name课程编号) course_name models.CharField(max_length100, verbose_name课程名称) teacher models.ForeignKey(User, on_deletemodels.CASCADE, related_namecourses) course_type models.CharField(max_length20, choicesCOURSE_TYPE, verbose_name课程类型) credits models.DecimalField(max_digits3, decimal_places1, verbose_name学分) capacity models.IntegerField(verbose_name课程容量) selected_count models.IntegerField(default0, verbose_name已选人数) schedule models.CharField(max_length100, verbose_name上课时间) location models.CharField(max_length100, verbose_name上课地点) status models.CharField(max_length20, choicesCOURSE_STATUS, defaultpending, verbose_name审核状态) description models.TextField(blankTrue, verbose_name课程简介)特别注意capacity课程容量和selected_count已选人数这两个字段它们是选课判断能否成功的关键。selected_count到底是直接存字段值还是通过关联查询统计数量这个设计决策直接影响性能。这套源码选择直接存字段选课成功就1退课就-1查询时不用COUNT聚合速度快很多代价是必须用事务保证数据一致性。选课记录模型是学生和课程之间的桥梁表class Enrollment(models.Model): student models.ForeignKey(User, on_deletemodels.CASCADE, related_nameenrollments) course models.ForeignKey(Course, on_deletemodels.CASCADE, related_nameenrollment_set) enroll_time models.DateTimeField(auto_now_addTrue) grade models.DecimalField(max_digits5, decimal_places2, nullTrue, blankTrue, 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, pkcourse_id) if request.user.profile.role ! admin and course.teacher ! request.user: raise PermissionDenied3.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(pkcourse_id) # 检查课程是否通过审核 if course.status ! approved: return JsonResponse({code: 1, msg: 课程未通过审核}) # 检查是否已经选过 if Enrollment.objects.filter(studentrequest.user, coursecourse).exists(): return JsonResponse({code: 1, msg: 你已选过该课程}) # 检查课程容量 if course.selected_count course.capacity: return JsonResponse({code: 1, msg: 课程名额已满}) # 创建选课记录并更新已选人数 with transaction.atomic(): Enrollment.objects.create(studentrequest.user, coursecourse) course.selected_count 1 course.save(update_fields[selected_count]) return JsonResponse({code: 0, msg: 选课成功})这里最关键的三个细节第一select_for_update()。这是数据库行级锁执行这条语句后这一行课程记录会被锁定直到当前事务结束。如果有两个学生同时提交选课请求数据库会让他们排队执行后执行的只能等前一个commit之后才能拿到这行数据。没有这个锁的话两个请求同时读到selected_count29然后都判断还可以选最后都执行1变成31人超员了。第二transaction.atomic()。它把创建选课记录和更新已选人数绑定在同一个事务里要么都成功要么都失败。如果创建记录成功但更新人数失败事务会自动回滚不会出现选了课但人数没变的脏数据。第三选课前先检查Enrollment是否存在这是业务层面的校验而unique_together是数据库层面的双保险。两层校验是必要的业务层负责给用户友好的提示信息数据库约束负责兜底防错。退课逻辑大同小异区别是判断减少selected_countwith transaction.atomic(): enrollment Enrollment.objects.select_for_update().get(studentrequest.user, coursecourse) 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(teacherself.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 classbadge badge-danger已满/span {% else %} span classbadge badge-success可选/span {% endif %} /td td {% if user.profile.role student %} button classbtn btn-primary btn-sm enroll-btn>STATIC_URL /static/ STATICFILES_DIRS [ BASE_DIR / static, ]很多人配置完发现页面样式加载不出来大部分原因是STATICFILES_DIRS指向的目录和实际放静态文件的目录不一致。这个项目的目录结构很规范static目录放在项目根目录下模板里用{% load static %}加载静态文件{% load static %} link relstylesheet href{% static css/bootstrap.min.css %}4. 环境部署与运行指南4.1 本地开发环境准备先列一下环境要求Python 3.8以上Django 3.2或4.x源码用的应该是Django 3.2MySQL或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。DEBUGTrue时Django会暴露详细的错误堆栈和配置信息攻击者可以利用这些信息找到系统漏洞。改完DEBUG之后还要配置ALLOWED_HOSTSALLOWED_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(usernameteacher01, password123456) UserProfile.objects.create(userteacher, roleteacher, name张老师) # 创建学生账号 student User.objects.create_user(usernamestudent01, password123456) UserProfile.objects.create(userstudent, rolestudent, name李同学) # 创建课程 Course.objects.create( course_codeCS101, course_namePython程序设计, teacherteacher, course_type专业选修, credits2.0, capacity30, schedule周一 3-4节, location教学楼A301, statusapproved, descriptionPython基础与实战 )这个脚本的逻辑是用户表先用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_modeSTRICT_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 --listen127.0.0.1:8000 course_system.wsgi:application这样Waitress就在8000端口跑Django应用了。接下来用Nginx做反向代理监听80端口把请求转发给Waitressserver { 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_time和enroll_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信号里自动创建UserProfilefrom django.db.models.signals import post_save from django.dispatch import receiver receiver(post_save, senderUser) def create_user_profile(sender, instance, created, **kwargs): if created: UserProfile.objects.create(userinstance)问题三csrf verification failedDjango默认开启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这套框架的底层思路是通用的学会一个就能迁移到别的场景去。
返回列表