资讯动态

选课系统开发实战:Python+Vue全栈实现并发控制与前后端分离

发布时间:2026/10/14 17:34:00 来源:尧图企业网站定制
每年到毕设季朋友圈里总有一批人在选课系统这个经典题目上铩羽而归。我见过用纯Django写服务端渲染的见过用Flask搭个接口就往前端丢的也见过Vue写得风生水起结果被选课并发撞得满头包的。这个题目之所以经典是因为它踩中了Web开发里几乎所有关键考点用户角色权限、一对多/多对多关系建模、事务与并发控制、前后端分离联调、跨域配置还顺便能考一下部署。本文就用PythonVue这套组合从需求拆解到前后端实现再到联调踩坑和部署完整聊一遍我对这个题目的大实话。选课系统不是“CRUD堆一堆就完事”的东西。它的核心难点不在增删改查而在于选课这个动作背后藏着一套业务规则课程有容量上限、学生选课有时间冲突校验、退课需要释放名额、同时多人抢最后一门课的时候不能超卖。如果你只是把课程表和选课记录表建出来然后提交按钮一绑那最后演示的时候大概率会被老师问住。所以这篇我先把业务盘清楚再讲框架选型接着给后端模型和接口设计、前端页面与交互最后把联调阶段的跨域、序列化、并发这几个坑一一拆开讲每一段都是我自己调试时真实遇到过的。如果你是第一次做这类管理系统或者Django/Flask基础只停留在教程级别这篇文章可以直接当你的项目脚手架参考。即便你已经做完一轮把联调那章读一遍也能明白之前为什么莫名其妙崩出几个跨域错误。1. 先把业务边界定清楚这系统到底要管什么1.1 三种角色和一条核心链路学生选课管理系统从名称就能看出主角是“选课”但一个真正能交付的系统至少要覆盖三种角色的完整闭环学生浏览课程列表、按条件筛选、选课、退课、查看个人课表。教师维护自己讲授的课程、设定容量和上课时间、查看选课名单。管理员管理学生和教师账号、审核课程信息、处理异常选课记录。核心链路是一条教师开课 → 课程进入列表 → 学生选课 → 名额扣减 → 生成选课记录 → 退课时释放名额。这条链路上任何一个环节断掉系统都谈不上“可用”。比如很多初学者只做了学生端和管理端教师端直接砍掉课程全靠管理员手工录入这其实已经偏离了“选课管理”的语义。我建议第一版就做完整的三端角色哪怕教师端界面简单一点也要把“教师开课、学生选课、管理员审核”这个三角关系跑通。演示的时候这三种角色来回切换说明你对业务逻辑是有整体认知的。1.2 必须想清楚的规则约束选课业务最有价值的不是“能选”而是“不能乱选”。以下几个约束我在需求和代码里都会反复遇到建议你动手前就写进设计文档容量约束课程有总容量已选人数达到上限后学生不能再选。时间冲突约束同一学生的两门课上课时间不能重叠。时间字段怎么建模直接影响校验难度。退课释放约束退课后已选人数减一其他人可以补选。重复选课约束同一学生同一门课只能有一条有效选课记录。学分上限可选每学期选课总学分受限。这些约束看着简单落到代码里就有细节了。容量约束如果不在“写库的那一刻”做检查依靠前端按钮置灰那并发选课瞬间就会超卖。时间冲突校验需要先把课程时间字段定义得足够结构化而不是存一个“周一三四”这种字符串。所以我的建议是第一个里程碑先跑通无约束版本让它能登录、能浏览、能选、能退。第二个里程碑在选课接口里逐步加约束校验。一上来就全上并发控制很容易把自己绕晕。1.3 项目结构上的直观设计我习惯在动手写代码之前先把一个可运行的最小闭环列出来登录、课程列表、选课、我的课表、退课。这五个功能串起来就是一个完整的用户故事。教师端和管理端属于同一套数据模型上的扩展慢慢补上去即可。对应到目录我通常会切成前端和后端两个独立项目course-frontend/ # Vue 项目 src/ views/ # 页面组件 api/ # axios 请求封装 router/ # 路由与守卫 course-backend/ # 后端项目 apps/ users/ # 用户登录、角色 courses/ # 课程管理、选课逻辑 ...前后端分离的好处是后端只管提供JSON接口前端只管渲染交互两边可以同时开发联调时出问题也能快速定位是接口问题还是页面问题。2. 技术栈别再纠结了Django、Flask和Pycharm该怎么定位2.1 Django和Flask是选择题不是组合题标题里同时出现django和flask这是新手常见的困惑。实际的回答很简单这两个只能选一个当后端Django和Flask是两条平行的路线。就算技术再硬也不可能在一个项目里同时用两个Web框架承担同一份职责那不是灵活是给自己找麻烦。怎么选我直接说大实话Django自带ORM、Admin后台、用户认证、表单校验、迁移机制。选课系统涉及多种角色、外键关系、后台维护数据Django的ORM在管理关系数据和做聚合查询时非常顺手Admin后台甚至可以直接当管理员的半成品界面用。Flask轻量、灵活一切靠扩展拼装。如果你的项目很小或者你特别喜欢自由组合SQLAlchemy与JWT的方案用Flask会更清爽。但代价是很多Django内置的东西需要自己搭包括用户认证和后台管理。我自己最终选的是Django。原因很朴素这个题目里最耗时间的不是写接口而是把多对多关系、用户角色、后台维护这些琐碎环节理顺。Django的User模型可以扩展、Admin后台可以直接维护课程数据、ORM的select_related能少写很多关联查询代码。如果你偏好Flask也不是不行。等会讲到选课并发那一节无论哪个框架数据库锁和事务都是躲不开的。2.2 前端框架为什么选Vue选Vue不光是它简单更核心的是它天然的响应式数据绑定非常适合这类表单和列表交互密集的管理系统。课程列表的筛选、选课按钮的禁用状态、课表网格的渲染在Vue里都是“数据变了视图跟着变”的事。具体到配套我用Vue 3 Element Plus。Element Plus提供现成的表格、表单、弹窗、标签页能省掉大量样式工作。课程列表用el-table选课弹窗用el-dialog筛选表单用el-form整套界面做出来很规整也很像实际业务系统。2.3 Pycharm在项目里的真实定位Pycharm是我们写Python代码的IDE它对这个项目的帮助主要在几个地方虚拟环境管理直接在IDE里创建venv装依赖、锁定版本都比较清晰。Django配置Pycharm对Django有专门支持可以直接配置运行Django服务器。调试体验在选课接口上打断点看request对象和ORM查询排查问题效率高很多。前端联调前端在VSCode里写后端在Pycharm里跑通过代理把两者串起来。有几个同学问我“Pycharm能直接跑Vue吗”当然不行Pycharm是Python IDEVue项目请用VSCode或者WebStorm或者至少在Pycharm里配好Node环境后再打开前端目录实际上还是借用IDE的Node插件在跑体验不如专业前端工具。最舒服的组合是VSCode干前端Pycharm干后端。3. 后端核心实现数据模型、认证和选课接口3.1 数据模型是怎么设计出来的实体关系梳理清楚表结构就好设计了。我这里给出实际项目里用的一组精简模型然后逐字段解释它为什么这么设计from django.contrib.auth.models import AbstractUser from django.db import models class User(AbstractUser): ROLE_CHOICES ( (student, 学生), (teacher, 教师), (admin, 管理员), ) role models.CharField(max_length10, choicesROLE_CHOICES, defaultstudent) student_no models.CharField(max_length20, blankTrue, nullTrue) class Course(models.Model): name models.CharField(max_length100, verbose_name课程名称) teacher models.ForeignKey(User, on_deletemodels.CASCADE, related_namecourses, verbose_name授课教师) credit models.FloatField(default2.0, verbose_name学分) capacity models.IntegerField(default30, verbose_name容量) selected_count models.IntegerField(default0, verbose_name已选人数) schedule models.CharField(max_length100, verbose_name上课时间) location models.CharField(max_length100, blankTrue, nullTrue, verbose_name上课地点) class Enrollment(models.Model): student models.ForeignKey(User, on_deletemodels.CASCADE, related_nameenrollments, verbose_name学生) course models.ForeignKey(Course, on_deletemodels.CASCADE, related_nameenrollments, verbose_name课程) created_at models.DateTimeField(auto_now_addTrue, verbose_name选课时间) class Meta: unique_together (student, course)这套模型我认为有三个点是必须解释清楚的。第一为什么User扩展自AbstractUser而不是单独建一个UserProfile。因为在选课系统里角色字段是业务逻辑的核心登录之后要根据角色决定返回哪套菜单、允许调哪个接口。直接在User模型上加role字段认证和授权都顺理成章。第二selected_count为什么要存一个冗余字段。这看起来违反了规范化原则因为通过Enrollment表统计也能得到已选人数。但在选课这个场景下每次选课都要判断“是否已满”如果每次判断都现统计Enrollment表的记录数课程列表需要显示所有课程的已选人数时会带来额外的聚合开销。存一个整数每次选课成功后原子更新查询速度快很多。第三为什么用unique_together约束重复选课。业务上可以做一次filter判断但数据库约束才是兜底。万一并发请求同时通过判断数据库约束能把重复数据挡在最后一层。3.2 登录认证与权限控制的落地方案Django自带的auth模块可以帮我们完成用户名密码校验登录后会颁发一个会话标识。前后端分离项目里常用JWT方案我建议用djangorestframework-simplejwt配置简单给前端返回access和refresh两个token前端把access token存起来每次请求放进Header的Authorization字段。配置上在settings.py里指定认证类REST_FRAMEWORK { DEFAULT_AUTHENTICATION_CLASSES: ( rest_framework_simplejwt.authentication.JWTAuthentication, ), }权限控制我用的方式是视图上标注IsAuthenticated然后在视图内部根据request.user.role判断角色能做什么。比如选课接口只有student角色能调用开课接口只有teacher角色能调用管理员的课程审核接口只允许admin。如果你在找“到底用DRF还是自己写JsonResponse”的答案我的建议是直接用DRFDjango REST Framework。它的序列化器、权限类、分页组件都是现成的尤其是把ORM对象转JSON的时候序列化器能省掉非常多的手工拼接。3.3 选课接口的核心代码与事务边界选课是这套系统的重头戏我贴一段我实际写出来的接口逻辑重点不是代码本身而是它如何覆盖所有约束from django.db import transaction from django.db.models import F from rest_framework.decorators import api_view, permission_classes from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from .models import Course, Enrollment api_view([POST]) permission_classes([IsAuthenticated]) def enroll_course(request, course_id): if request.user.role ! student: return Response({detail: 只有学生可以选课}, status403) try: course Course.objects.select_for_update().get(pkcourse_id) except Course.DoesNotExist: return Response({detail: 课程不存在}, status404) if Enrollment.objects.filter(studentrequest.user, coursecourse).exists(): return Response({detail: 不能重复选课}, status400) if course.selected_count course.capacity: return Response({detail: 课程已满}, status400) # 这里省略真实的时间冲突校验逻辑 conflict check_time_conflict(request.user, course.schedule) if conflict: return Response({detail: 上课时间冲突}, status400) with transaction.atomic(): Enrollment.objects.create(studentrequest.user, coursecourse) Course.objects.filter(pkcourse.id).update(selected_countF(selected_count) 1) return Response({detail: 选课成功}, status201)这段代码里有几个地方我想着重解释一下因为它们是并发问题的关键。select_for_update()的作用选课操作的第一步是读出课程此时如果两个学生同时请求同一门课程两个线程都读到selected_count29然后各自判断“未满”各自插入选课记录结果容量30的课程被选成了31人。select_for_update()会对这个课程行加锁第二个请求会等第一个请求的事务提交后再读取这时候看到的selected_count已经更新了。为什么更新用F(selected_count) 1而不是重新赋值F()表达式是在数据库层面做原子自增而不是先查出当前值传给Python再写回。这能避免“读改写”时序中一致性问题也是防止超卖的一个保障。事务包住两个写操作先创建Enrollment记录再更新selected_count两者要么都成功、要么都回滚避免出现“选课记录存在但名额没扣”或相反的情况。退课接口是镜像逻辑先删记录再用F(selected_count) - 1原子减一。这里特别要注意的是退课后释放名额返回给学生的课程列表要能实时看到“已满”变成“可选”的状态所以前端在退课后要主动刷新课程数据而不是依赖本地残留状态。3.4 时间冲突校验怎么建模才不坑课程表里我写的是schedule字段存字符串比如“周一 10:00-12:00”。为什么不用结构化字段因为很多课程设计里时间格式五花八门强行结构化反而增加开发量。不过字符串存储会直接导致时间冲突校验变得很麻烦。我的做法是约定一种统一的格式week,start,end比如1,10,12表示周一10点到12点。存到数据库里是个短字符串读取后解析成三段即可。冲突校验时先取出该学生的所有已选课程的schedule再逐条解析比较def check_time_conflict(student, new_schedule): week, start, end parse_schedule(new_schedule) enrolled Enrollment.objects.filter(studentstudent).select_related(course) for enrollment in enrolled: w, s, e parse_schedule(enrollment.course.schedule) if w week and start e and s end: return True return False这段逻辑本质上就是判断两条时间线段是否重叠新课程的开始时间早于已选课程的结束时间且新课程的结束时间晚于已选课程开始时间就是重叠。不需要用什么花哨算法一个select_related查完所有已选课程逐条比一下就够了。这样的schedule不是一个可读的展示字符串所以前端显示时还需要做一次格式化。写接口时我会返回两个字段schedule存库格式和schedule_display展示格式前端直接用展示格式渲染后端负责转换。4. 前端页面与交互Vue路由、Axios封装和选课设计4.1 页面结构和路由守卫Vue前端我按角色拆成了三个模块路由结构大概是这样的const routes [ { path: /login, component: Login }, { path: /student, component: StudentLayout, children: [ { path: , component: CourseList }, { path: my-schedule, component: MySchedule }, ], }, { path: /teacher, component: TeacherLayout, children: [ { path: , component: MyCourses }, { path: course-add, component: CourseAdd }, ], }, { path: /admin, component: AdminLayout, children: [ { path: users, component: UserManage }, { path: courses, component: CourseReview }, ], }, ]路由守卫写在全局beforeEach里未登录跳转登录页已登录但角色和路由不匹配的跳转到对应角色的首页。这里要留意的是role信息在登录返回数据里就有存到本地一份刷新页面后路由守卫也能正常判断不用每次跳转都向后端请求。4.2 Axios封装统一token和错误处理前端和Django通信我做过一个简单的request.js把这个封装做好能少掉后续一大半联调烦恼import axios from axios import router from /router const request axios.create({ baseURL: /api, timeout: 5000, }) request.interceptors.request.use(config { const token localStorage.getItem(access_token) if (token) { config.headers.Authorization Bearer ${token} } return config }) request.interceptors.response.use( response response.data, error { if (error.response error.response.status 401) { localStorage.removeItem(access_token) router.push(/login) } return Promise.reject(error) } )前端请求时token有效期过期后端会返回401这里统一拦截再跳登录页用户感受就是“登录状态失效了需要重新登录”。refresh token的自动刷新逻辑可以做但如果你这只是毕设或课程设计我建议先不做保持简单。4.3 课程列表里的“实时可选”设计选课列表页是整个前端交互最密的地方。每一个课程行都要展示课程名、教师、学分、容量、已选人数、上课时间以及一个“选课”或“已满”按钮。按钮状态我直接用两个判断条件已选人数 容量 → 按钮禁用文案显示“已满”。该课程已经在当前学生的选课记录里 → 按钮禁用文案显示“已选”。这两个状态从哪来第一个来自课程数据的selected_count和capacity第二个来自后端返回的课程字段里加一个enrolled布尔值这是后端序列化时通过Enrollment表查出来的。前端不要自己保存“我选了哪些课”的清单因为页面一刷新就会丢。让后端告诉你真相是最稳的做法。选课提交后前端要做三件事弹成功提示、更新当前行的已选人数、把那门课的按钮置为“已选”。这三件事都不能靠刷新整个页面完成因为用户可能正在筛选条件下浏览整体刷新会丢失筛选状态。我实际实现时是保留当前课程列表的数据对象拿到接口成功响应后直接用本地reactive更新对应行页面局部变化筛选条件原样保留。我的课表页面更简单拿到该学生的选课记录后按星期网格渲染一张表格。横向是星期一到星期日纵向是节次。网格交叉点的内容就是课程卡片。这里有个小细节后端返回课程数据时要顺带返回schedule_display前端不需要关心存库格式直接把这个字符串放进格子就行。5. 联调阶段才真正开始踩坑跨域、日期序列化和并发测试5.1 CORS跨域具体怎么配置前端的Vue跑在http://localhost:5173后端的Django跑在http://localhost:8000这俩端口不一样前端发请求就会被浏览器的同源策略拦下来。最直接的解决方案是后端加CORSCross-Origin Resource Sharing响应头。Django里用django-cors-headers这个库配置三行INSTALLED_APPS [corsheaders] MIDDLEWARE [corsheaders.middleware.CorsMiddleware] CORS_ALLOW_ALL_ORIGINS True # 开发阶段开发阶段直接放开所有来源没问题但如果是部署到线上建议收紧为CORS_ALLOWED_ORIGINS只填自己的前端域名。除了后端加CORS前端也可以配合Vite的代理配置在vite.config.js里把/api转发到8000端口。这样前端请求走同源地址Django端仍然允许来自本机跨域请求两边都能少报几个错。5.2 Django日期序列化导致的两小时排查第一次跑通前端课表时我遇到一个特别隐蔽的坑created_at字段返回的是2025-01-05T12:00:00.123456Z这样的格式前端直接用new Date()解析能成功但展示出来的时间是UTC时间和本地时间差了8个小时。这类问题比较容易出现在你返回了DateTimeField数据、前端又要展示具体选课时间的场景里。我的解决方式很简单DRF序列化器设置日期格式为%Y-%m-%d %H:%M:%S时区用Asia/Shanghaiclass EnrollmentSerializer(serializers.ModelSerializer): course_name serializers.CharField(sourcecourse.name, read_onlyTrue) schedule_display serializers.CharField(sourcecourse.schedule, read_onlyTrue) created_at serializers.DateTimeField(format%Y-%m-%d %H:%M:%S) class Meta: model Enrollment fields [id, course, course_name, schedule_display, created_at]让后端在JSON里直接给一个“人类可读的本地时间字符串”前端不需要做任何时区换算。这也是一种“后端多做一点前端少错一点”的接口设计思路。5.3 并发选课一开课就被抢爆的现场并发问题发生在课程容量只有5个人但30个学生同时点选课时。前端按钮即使显示“已满”只要有人连续点了很多次或者多个用户同时发起请求都会触达后端。如果没有数据库锁和原子更新超卖几乎是必然的。我自己验证并发是否正确的方式很简单写一个循环脚本用requests库模拟多个并发选课看最后selected_count会不会超过capacity。import threading import requests def do_enroll(student_id): token get_token(student_id) requests.post( http://localhost:8000/api/courses/1/enroll/, headers{Authorization: fBearer {token}} ) for i in range(10): threading.Thread(targetdo_enroll, args(i,)).start()容量5的课程循环里发10个并发请求如果最后没有超过5条选课记录说明select_for_update()和事务逻辑是有效的如果超过了要么是锁没生效要么是更新selected_count的时候没用F()。这种压力测试虽然粗糙但对这种体量的课程设计项目足够了。5.4 外键序列化的常见误区第三种角色要展示的选课名单中需要显示课程信息而课程里又关联了教师信息。新手经常犯的错是直接返回teacher_id前端还得再发一次请求去查教师姓名。更省事的方式是在序列化器里嵌套或者用sourceclass CourseSerializer(serializers.ModelSerializer): teacher_name serializers.CharField(sourceteacher.username, read_onlyTrue) enrolled_count serializers.SerializerMethodField() class Meta: model Course fields [id, name, teacher_name, credit, capacity, selected_count, enrolled_count] def get_enrolled_count(self, obj): request self.context.get(request) if request and request.user.is_authenticated: return Enrollment.objects.filter(courseobj, studentrequest.user).exists() return False接口一次返回课程列表的全部展示数据包括教师姓名和当前学生是否已选。前端拿到之后只管渲染不用再做额外请求。6. 项目落地构建部署与后续还能怎么扩展6.1 Django后端部署思路部署这步我的做法是前端先npm run build生成dist静态文件然后让Django把这个目录挂载为静态文件路径或者用Nginx托管前端静态文件并反向代理/api请求到Django的gunicorn服务。如果是学校里的课程设计一般有一台Linux服务器就够。部署流程大致是# 后端 pip install gunicorn gunicorn course_backend.wsgi:application --bind 0.0.0.0:8000 # 前端构建 npm run build # 将 dist 目录配到 Nginx 的 root 下用Nginx同时处理前端静态文件和/api反向代理是性价比最高的部署方式。别上来就搞Docker、K8s那些东西对这个小项目是过度设计。6.2 数据初始化注意点这个项目正式跑起来之前你一定会需要一批测试数据。直接用Django Admin后台手工添加太慢建议准备一个seed.py脚本用脚本创建用户和课程。创建这样一套数据本身就相当于一次完整的逻辑梳理。漏掉这步的代价是你在演示时所有学生共用同一个账号或者课程表上空空如也老师看一眼就知道你没有认真准备测试数据。我吃过这个亏第二次重做项目时专门花一个上午写好seed脚本后面所有调试都靠它。6.3 后续可以扩展的方向基础版跑通之后如果还想让项目更有亮点有几个方向是安全且实用的选课轮次控制区分预选和正选阶段不同阶段规则不同。Redis缓存热门课程列表课程列表页面查询频率高加一层Redis缓存显著降低数据库压力。成绩录入与绩点计算教师端在选课之外录入成绩学生端查看成绩单。Excel导出选课名单教师端一键导出名单很符合实际使用场景。复杂排课约束用算法自动排课避免教师时间冲突和教室冲突。这些扩展里最推荐的是“选课轮次控制”因为它能直接用上现有的Enrollment模型只加一个阶段字段和对应判断业务逻辑不乱演示效果却会明显上一个档次。另外一个经验任何扩展功能都先回到业务闭环里去想——这个功能让哪种角色在哪个页面、用哪个按钮、产生什么变化。只要这条链路想通了技术实现其实都是常规操作。这套系统我从头到尾做完大概花了两周真正耗时最多的不是写代码而是踩并发和联调的坑。如果你现在也卡在“接口能通、逻辑也写了、可就是数据对不上”的状态建议先把浏览器开发者工具的Network面板打开一个一个接口看响应很多联调问题一眼就能看出来。做完这个项目你对Web开发的前后端配合、数据建模和业务规则设计会有一个非常完整的理解这类管理系统以后再做新题基本就是换皮。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑