
如果你准备做或者正在做学生选课管理系统我先把话说在前面这个系统真正的难度不在增删改查而在选课冲突检测、退课约束和学分累计这三件事。很多课设、毕设项目做完后看着功能齐全一运行就发现两个人能同时选同一门课导致容量超限或者不及格的课程也累计了学分这些都是业务规则没落到位造成的。这个基于 Python Vue3 Django 的学生选课学分管理系统实现的就是学生、教师、管理员三类角色的完整业务闭环——管理员维护课程和用户教师录入成绩学生选课、退课、查成绩并按规则自动换算学分和绩点。技术栈选了 Django Vue3 MySQL 前后端分离方案适合正在做课程设计、毕业设计或者想系统学习一个完整全栈项目的读者参考。1. 为什么选这套技术栈系统边界与核心业务闭环1.1 三类角色的权限边界选课系统首先想清楚的必须是谁能干什么否则后面做接口、做页面都会反复返工。这个项目里角色分成三类管理员维护教师和学生账号、创建课程、设置学期、管理选课开关应对调课和补选等异常情况教师查看自己名下的课程、查看选课学生列表、录入和修改成绩学生浏览课程、选课、退课、查看个人成绩单和学分汇总权限这块用 Django REST Framework 自带的权限机制来实现。登录认证采用 SimpleJWT 提供 Token接口层用IsAuthenticated保证必须登录同时自定义了一个IsRole权限类在视图里校验request.user.role。这里有一个容易被忽略的点Django 内置的User模型没有role字段必须通过继承AbstractUser的方式扩展出一个CustomUser再加role、student_no或emp_no这类业务字段然后到settings.py里配置AUTH_USER_MODEL。这个配置必须在第一次执行数据库迁移之前做好否则后续改模型会非常痛苦。1.2 选课-成绩-学分的业务闭环拿学生的视角走一遍完整流程就明白这个系统的业务逻辑了。秋季学期开始前管理员在后台导入或创建课程设置好课程编号、名称、学分、学时、任课教师、容量和上课时间选课阶段学生浏览课程列表看到合适的课程发起选课系统先检查课程是否存在、容量是否已满、时间是否冲突、本学期是否已经选过这门课全部通过后写入选课记录学期结束后教师在后台录入成绩系统依据成绩档位自动计算是否获得该课程学分并实时汇总出已获得学分和平均学分绩点。这三步是一个完整闭环互相之间都有状态约束。我在设计数据库时就明确了选课记录的完整生命周期已选-已出分退课只能在未出分阶段执行一旦教师录了成绩选课记录就锁定不能再退。这个约束写死在服务端逻辑里前端只是在界面层做提示真正兜底的是后端接口。1.3 Django Vue3 为什么是合理组合有读者会问为什么不用 Flask 搭后端或者直接用 Django 模板渲染页面我的判断依据很简单Django 自带的 ORM 映射、Admin 后台、迁移工具、认证体系能让人把精力集中在选课规则和学分计算上而不是重新造轮子。Flask 灵活但太自由课程表、选课记录之间的外键关系、事务管理都要自己搭开发效率低不少。前端选 V3 则是因为这个系统交互上确实有复杂度。选课页面要同时呈现课程列表、筛选条件、已选列表、学分进度条还需要在学生选课冲突时做前端预警用 Vue3 的组合式 API 组织这些状态比选项式 API 清爽得多。项目配套的构建工具用的是 Vite配合 Element Plus 做界面开发体验和最终效果都比较稳定。2. 数据模型设计让学分计算规则在数据库层面就站得住脚2.1 六张核心表的关系拆解数据库我用的是 MySQL 5.7字符集设置为utf8mb4排序规则用utf8mb4_general_ci。整个系统的数据模型可以拆解成六张核心表真正需要手动创建的业务表只有四张另外两张是 Django 自带的用户认证表。表名核心字段作用userid, role, student_no, password, name扩展用户存储三类角色的共同信息courseid, course_code, name, credit, hours, teacher_id, capacity, selected_count, schedule, semester课程主表教师用外键关联selectionid, student_id, course_id, semester, score, status, selected_at选课记录连接学生和课程semesterid, name, start_date, end_date, is_active学期表用于区分不同学年的选课周期课程表和选课记录之间最关键的是一个联合唯一约束我写在selection表的Meta里class Selection(models.Model): student models.ForeignKey(CustomUser, on_deletemodels.CASCADE, related_nameselections) course models.ForeignKey(Course, on_deletemodels.CASCADE, related_nameselections) semester models.ForeignKey(Semester, on_deletemodels.CASCADE) score models.DecimalField(max_digits5, decimal_places2, nullTrue, blankTrue) status models.CharField(max_length20, defaultselected) selected_at models.DateTimeField(auto_now_addTrue) class Meta: constraints [ models.UniqueConstraint( fields[student, course, semester], nameunique_student_course_semester ) ]这个唯一约束配合后面的事务处理能从根本上杜绝同一学生在同一学期重复选择同一门课。实际开发中这个约束出现了两次重复数据排查后才发现是接口层校验通过后网络抖动导致前端重复提交数据库层面的唯一约束保住了数据的底。2.2 学分与绩点规则如何落到业务层学分计算是这个系统的核心规则之一。成绩与学分的关系我参考了普遍采用的方式按百分制分数划分等级90 到 100 分为优秀80 到 89 分为良好70 到 79 分为中等60 到 69 分为及格60 分以下为不及格。只有成绩达到 60 分及以上才能获得该门课程的全部学分不及格则获得 0 学分且成绩单上保留原始分数。绩点计算采用加权平均法每门课程的绩点乘以对应学分然后除以总学分from decimal import Decimal def get_grade_point(score: Decimal) - Decimal: if score is None or score 60: return Decimal(0) if score 90: return Decimal(4.0) if score 80: return Decimal(3.0) if score 70: return Decimal(2.0) return Decimal(1.0) def calculate_credits_and_gpa(student, semester): selections Selection.objects.filter( studentstudent, semestersemester, statusgraded, score__isnullFalse ).select_related(course) total_credit Decimal(0) total_points Decimal(0) total_courses 0 for sel in selections: credit sel.course.credit total_courses 1 if sel.score 60: total_credit credit total_points credit * get_grade_point(sel.score) gpa total_points / total_credit if total_credit else Decimal(0) return { total_courses: total_courses, obtained_credit: total_credit, gpa: gpa.quantize(Decimal(0.01)) }这里要提示一个重要细节学分的字段类型一定要用DecimalField不要用FloatField。浮点数在计算机内部是二进制近似存储多个学分相加再求平均时会出现 0.1 0.2 0.30000000000000004 这类问题。学分和绩点最终要展示给学生看任何精度误差都会在期末被学生找上门。2.3 Django Admin 的定制让后台真正能交差Django Admin 是管理员日常维护数据的入口但直接用它默认的列表页会让课程管理变得很难受。课程表和选课记录字段多默认展示不友好我做了几个方面的定制class CourseAdmin(admin.ModelAdmin): list_display (course_code, name, credit, teacher, capacity, selected_count, semester) list_filter (semester, credit) search_fields (course_code, name, teacher__name) raw_id_fields (teacher,)这样设置之后管理员进入课程列表页面就能直接看到课程容量和实时选课人数按学期筛选课程输入关键字搜索课程编号或教师姓名班主任核对课程表就方便多了。raw_id_fields的作用是把外键字段的下拉框换成搜索框当教师数量超过几百条时下拉框会加载得非常慢换成搜索框就解决了这个问题。在 Admin 模型里我重写了save_model方法当管理员新建课程时自动把selected_count初始化为 0避免空值导致前端展示异常。后台页面的标题和站点名称也做了定制在admin.site.site_header里设置成系统的实际名称这样管理员打开后台时看到的就不是陌生的 Django administration 默认标题。3. 选课与退课的后端实现冲突检测和事务边界3.1 选课的校验链路选课接口是整套系统最容易被并发场景击穿的地方。模拟 60 个学生同时抢一门容量为 50 的课程时如果在校验通过后才逐条写入数据毫无意外会超售。解决方案是采用事务加行锁的组合方式from django.db import transaction from django.core.exceptions import ValidationError transaction.atomic def select_course(student, course_id, semester_id): # 行级锁锁定课程记录防止并发下超卖容量 course Course.objects.select_for_update().get(idcourse_id) if course.semester_id ! semester_id: raise ValidationError(课程本学期不可选) if course.selected_count course.capacity: raise ValidationError(该课程容量已满) if Selection.objects.filter(studentstudent, coursecourse, semester_idsemester_id).exists(): raise ValidationError(本学期已选择该课程) if has_schedule_conflict(student, course): raise ValidationError(上课时间与已选课程冲突) Selection.objects.create(studentstudent, coursecourse, semester_idsemester_id) course.selected_count 1 course.save(update_fields[selected_count])select_for_update()是这里的关键它会把被查询的课程行锁住直到事务提交或回滚。其他事务的相同查询会被阻塞等当前事务结束时再去读取最新值。这样并发环境下容量判断和更新selected_count的操作就在同一个串行化流程里完成不会再出现超卖。时间冲突检测的实现需要合理设计课程表结构。每门课可以设置多个上课时间段我用 JSON 字段存储格式类似[{weekday: 1, start_period: 3, end_period: 4, location: A101}]获取学生本学期所有已选课程的上课时间逐一比对新课程的时间段判断是否有重叠。这里需要注意的是编程语言的索引偏移星期几在 JSON 里用 1 到 7 表示周一至周日不要把周日存成 0否则前端展示和后端检测容易错位。3.2 退课与成绩单的一致性退课接口的处理关键是状态管理。我已经在系统里定义了明确规则只有statusselected且score为空的选课记录才能退课。一旦教师录入了成绩选课记录变为statusgraded直接禁止退课操作。接口层判断逻辑如下transaction.atomic def drop_course(student, selection_id): selection Selection.objects.select_for_update().get(idselection_id, studentstudent) if selection.status graded: raise ValidationError(该课程已录入成绩不能退课) selection.delete() Course.objects.filter(idselection.course_id).update( selected_countF(selected_count) - 1 )删除选课记录时对应的课程selected_count要同步减一。我在早期版本里用course.selected_count - 1的方式直接更新但因为读取和更新之间没有原子保护高并发下出现了计数不准的问题。改成F()表达式让数据库在 SQL 层面完成自减后再也没出现过类似问题。还要考虑一个边界情况一旦选课结束进入成绩录入阶段管理员需要能手动关闭选课和退课入口。我在Semester模型里增加了is_active字段和allow_select布尔字段后端所有选课、退课接口在第一行校验这些开关而不是依赖前端隐藏按钮来约束行为。接口级限制才能保证规则真正生效。3.3 API 响应结构约定前后端分离项目最怕接口各写各的前端处理响应时要做大量兼容。这个项目从一开始就统一了响应结构所有接口返回固定格式{ code: 0, message: ok, data: {} }code为 0 表示成功非 0 表示业务错误码前端根据code而不是 HTTP 状态码来判断业务成功与否。这样做的一个理由是因为 Python 的 Django REST framework 默认会把校验错误渲染成 400 HTTP 状态码如果前端只靠 HTTP 状态码判断很多业务提示会丢失。统一格式后前端响应拦截器只需检查code字段即可。核心接口可以归纳为下面这张表方法路径功能备注POST/api/auth/login/登录返回 token 和角色GET/api/courses/课程列表student/teacher/admin 均可查看POST/api/courses/select/选课校验容量与时间冲突DELETE/api/selections/{id}/drop/退课仅未出分状态可退GET/api/teacher/courses/我的课程教师视角POST/api/teacher/grades/录入成绩校验是否本人课程4. Vue3 前端搭建路由、状态管理和选课页面的交互细节4.1 项目脚手架与工程目录前端用 Vite 创建项目和 Vue3 组合式 API 组织代码。初始化命令很简单但选模板时要注意选择vue而不是vue-ts如果项目不需要 TypeScript就别给自己增加额外的配置负担。实际开发时依赖装这几个核心库就够了vue-router做路由、pinia做状态管理、axios做 HTTP 请求、element-plus做界面组件。完整的模块目录规划大概是这样的src/ ├── api/ # 接口请求封装 │ ├── auth.js │ ├── course.js │ └── user.js ├── router/ # 路由配置 ├── stores/ # Pinia 状态 │ ├── auth.js │ └── course.js ├── views/ # 页面组件 │ ├── login.vue │ ├── student/ │ │ ├── CourseList.vue │ │ └── GradeList.vue │ ├── teacher/ │ │ └── TeacherCourse.vue │ └── admin/ │ └── Dashboard.vue └── utils/ # 通用工具request.js 封装 axios 拦截器这里每个文件职责明确后面定位问题基本靠目录结构就能导向。刚开始搭建的时候不要一股脑把所有页面塞进 components一定要按模块划分 views 目录否则项目一膨胀连自己都找不到组件文件。4.2 登录态与角色路由守卫前后端分离模式下前端负责登录页收集用户名密码提交到后端换取 Token。用户信息存到 Pinia 和localStorage中页面刷新时从本地存储恢复。路由守卫控制页面访问权限思路是读取路由配置里meta.roles字段与当前用户角色做匹配const router createRouter({ routes: [ { path: /student/courses, component: CourseList, meta: { roles: [student] } }, { path: /teacher/grades, component: TeacherCourse, meta: { roles: [teacher] } }, { path: /admin/semesters, component: AdminSemester, meta: { roles: [admin] } } ] }) router.beforeEach((to, from, next) { const store useAuthStore() if (to.meta.roles !to.meta.roles.includes(store.role)) { next(/login) return } next() })Axios 请求拦截器在每次请求时自动从localStorage读取 token 并加到请求头config.headers.Authorization Bearer token。响应拦截器统一处理后端返回的code字段如果返回 401 就清除本地存储并跳转登录页。路由守卫只是体验优化不是安全边界真正的权限校验必须以后端为准这一点在前端代码注释里写得很清楚每个人的角色是否允许访问某个接口最终是后端视图层决定的。4.3 选课页面的实现课程列表、已选列表与学分进度选课页是这个项目前端最复杂的页面因为要同时维护三个维度的数据全部课程列表、当前学生已选列表、累计学分进度。Element Plus 表格组件负责展示课程列字段包含课程编号、名称、学分、教师、容量、上课时间和操作按钮。操作按钮的状态随课程状态动态变化已满员显示已满且禁用已选过显示已选当前时间冲突显示冲突其他情况才允许点击选课。选课按钮点击后先调用选课接口成功后再刷新已选列表和课程列表失败则弹出后端返回的错误信息。这里我强调一个实践不要在点击按钮后乐观更新本地状态也就是不要前端先把已选状态画上去再等接口返回。选课涉及容量和时间冲突校验后端失败的概率不低乐观更新会让界面与真实数据不一致反而给用户造成困惑。学分进度的展示设计成一个卡片区域从成绩单接口获取已获得学分、GPA、已完成课程数三个核心指标用进度条展示当前学分与毕业要求学分通常是 160 学分的比例。页面上还加了筛选栏支持按课程名称关键字、学分范围、上课时间段过滤课程列表这些筛选逻辑全部在前端用组合式函数实现基于课程数据计算属性过滤不需要每次筛选都请求后端。5. 前后端联调与部署跨域、时间格式和 Nginx 发布5.1 跨域配置一场本地开发绕不过去的坑前端开发服务器默认跑在 5173 端口后端 Django 跑在 8000 端口这构成了典型的跨域场景。开发环境我用了两种方式解决最干净的是 Vite 的代理配置// vite.config.js export default defineConfig({ server: { proxy: { /api: { target: http://127.0.0.1:8000, changeOrigin: true } } } })这样前端请求/api/courses/会被 Vite 开发服务器转发到后端的 8000 端口没有经过浏览器自然也不会触发跨域策略。但在部署到生产环境后跨域问题依然可能以另一种形态出现因此生产环境仍建议在 Django 中配置跨域支持。安装django-cors-headers并加入INSTALLED_APPS在settings.py中配置CORS_ALLOWED_ORIGINS [ http://127.0.0.1:5173, https://your-domain.com, ] CORS_ALLOW_CREDENTIALS True不要把CORS_ALLOW_ALL_ORIGINS直接设为 True哪怕只是练手项目也建议维护一个白名单。这个安全习惯能避免很多不必要的风险。5.2 时间与时区、Decimal 精度的双重坑Django 的时区配置是一个经典隐藏坑。settings.py里我设置为LANGUAGE_CODE zh-hans和TIME_ZONE Asia/Shanghai同时保留了USE_TZ True。这带来的后果是数据库中存储的时间是 UTC 时间展示时必须在前端或者序列化时转换时区。我统一在后端序列化时用django.utils.timezone.localtime()转换成本地时间再返回前端拿到的是已经正确转换的字符串不再重复处理时区问题。选课时间字段在界面上显示的是用户所在的北京时间而不是比实际慢了 8 小时的 UTC 时间这一点在小项目里很容易被忽略到展示学期开始/结束日期时会发现明显不对。学分和绩点的精度问题在前面已经实际踩过坑。这里再强调一次不要因为偷懒把credit定义为FloatField。我最初为了快速建表用过 Float结果在算平均绩点时发现数据对不上。改成DecimalField后整个计算链路终于稳定下来。凡是涉及金额、学分、绩点这类业务数值必须用 Decimal 类型。5.3 Nginx 部署 Vue3 与 Django 的完整过程生产环境我采用 Nginx Gunicorn Django 的组合。前端 Vue3 项目执行npm run build后生成dist目录Nginx 把这个目录作为静态资源根目录后端 Django 用 Gunicorn 启动在127.0.0.1:8000Nginx 把/api/路径的请求转发过去。核心配置片段如下server { listen 80; server_name your-domain.com; root /data/course-system/dist; index index.html; location /api/ { 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; } location /assets/ { expires 7d; add_header Cache-Control public; } location / { try_files $uri $uri/ /index.html; } }最后一行try_files $uri $uri/ /index.html直接决定 Vue Router 的 history 模式能否正常工作。如果不用这行配置用户从首页跳转到/student/courses后刷新页面Nginx 会尝试查找对应的物理文件找不到就返回 404。加了这行配置之后Nginx 会把所有无法匹配到具体文件的请求都交给index.html由前端路由接管页面刷新才不会再丢失路径。后端 Django 的静态文件和媒体文件也要有一个明确的处理策略。Django 的 Admin 后台样式文件在开发环境由 Django 自己处理但生产环境下需要执行python manage.py collectstatic把静态文件收集到一个统一目录而 Nginx 的location /static/配置指到这个目录。关于这类项目的一些最终体会做完整个课程选课系统再回头看选课系统能不能稳定运行关键不在你也说不清楚到底哪里重要的界面好看上而在业务约束是否在正确的层面落地。校验权重从高到低分别是数据库约束、后端接口校验、前端交互限制。数据库的唯一约束可以在极端情况下兜底后端事务确保并发下的数据一致性前端提示只是改善体验不要指望着它能防住真正的并发问题。部署完成后给系统做了简单的压测。用并发工具模拟了 80 个请求同时选同一门容量 50 的课程最终选课记录刚好 50 条没有任何超卖整个过程的接口平均响应时间在 300 毫秒以内。这个结果正常说明行锁、事务、唯一约束配合得没有问题。如果你也在做类似的管理系统建议把数据库压力测试做一下远比你通过代码 review 检查逻辑要可靠得多。对部署在普通云服务器上的这个量级系统来说只要业务规则正确性能通常不是什么大问题真正需要反复打磨的是边界条件和异常场景的处理。