毕业设计年年都有人做教务系统,但大部分交上来的东西都停留在“能跑”的阶段——后端怼一堆接口,前端套个模板,老师演示时手动加几条数据糊弄过去。今天这篇就从一个常见的题目说起:“python基于vue的教务选课考试成绩系统的设计与实现”,从需求拆解到具体落地,把选课、考试、成绩这三个核心业务链路完整梳理一遍。无论你准备用django还是flask,用pycharm还是vscode,这篇都能给你一条可以直接抄作业的路径。
适合看这篇的人有三类:正在做毕业设计、需要交系统演示的学生;想用Python+vue做第一个完整全栈项目的自学开发者;以及工作中突然接到“内部选课/考试管理系统”这类需求、想快速搭个原型的同学。我会把选课模块的并发处理思路、成绩录入的权限控制、考试安排的数据设计都讲清楚,附带实际踩过的坑和排查方法,争取让你少走几个星期的弯路。
1. 项目整体设计与需求拆解
1.1 教务选课考试系统的核心业务链路
先别急着写代码,把业务想明白再做,后面至少会顺一半。教务系统的核心链路其实只有三条:学生选课、教师录成绩、管理员管一切。围绕这三条链路,我们把角色和权限拆开看。
- 学生端核心功能:查看可选课程列表、选课/退课、查看已选课程表、查看考试成绩。
- 教师端核心功能:查看自己负责的课程、维护考试安排、录入/修改成绩、导出成绩单。
- 管理员端核心功能:管理学生/教师账号、管理课程信息、管理选课时间窗口、查看全校选课统计与成绩统计。
业务上还有几个容易忽视但毕业答辩老师一定会问的点:选课时间冲突怎么处理,一门课在选课截止后能否退课,成绩提交后教师能否修改(还是需要管理员审批)。这些在设计阶段就要定清楚,不然后面改起来全是眼泪。
1.2 为什么前端选Vue、后端选Django/Flask
这套组合基本是当前国内毕业设计和中小型项目的标配,原因很直接:Python写后端相对简单好调试,Vue做交互界面很顺手,两者通过RESTful API沟通,前后端分离之后,交给老师演示的时候还可以“一台电脑同时起两个服务”,互不干扰。
后端框架在Django和Flask之间的选择,很多人纠结。我的看法是:
- 如果项目里有复杂的权限体系、后台管理界面、ORM想开箱即用,选Django。
- 如果就想保持轻量、想灵活安排目录结构,或者你未来准备转FastAPI方向,选Flask。
我这次讲的时候会以Django为主,但会在具体环节把Flask写法也点一下。选Django的另一个实际原因是:admin自带后台,你做管理员功能时可以少写一半的页面。哪怕你用Flask,我也建议用Flask-SQLAlchemy做ORM,别直接写原生SQL,后面改需求时省心太多。
1.3 前后端分离架构下的数据流设计
前后端分离之后,数据是怎么走的,这个想清楚,接口设计才不会乱。
Vue页面(选课列表) → 调用 /api/courses/ → Django/Flask路由 → 视图函数处理 → ORM查询数据库 → 返回 JSON → Vue渲染表格在这个流程里,后端只负责提供数据和校验规则,前端负责渲染与交互。所以我们要先设计好统一的JSON返回格式,我建议至少包含三个字段:code(状态码)、message(提示信息)、data(业务数据)。所有接口都按这个格式返回,前端写起来特别省事,一个axios拦截器全部搞定。
比如成绩查询接口的返回格式,可以是这样:
{ "code": 200, "message": "id: 1 => name: 张三 => score: 85", "data": { "student_id": 1, "student_name": "张三", "course_name": "数据结构", "score": 85 } }2. 数据库设计与核心模型拆解
2.1 教务系统涉及的六张核心表
教务系统数据模型是整个项目的根基。根子歪了,后面写接口就是给烂地基糊墙。我总结下来,最精简但能覆盖完整业务流程的表是这六张:用户表(含学生/教师/管理员)、课程表、选课记录表、考试安排表、成绩记录表、学期表。
用Django的模型写法来说,大概是这样:
from django.db import models from django.contrib.auth.models import AbstractUser # 1. 用户表扩展 class UserProfile(AbstractUser): ROLE_CHOICES = ( ('student', '学生'), ('teacher', '教师'), ('admin', '管理员'), ) role = models.CharField(max_length=10, choices=ROLE_CHOICES, default='student') student_id = models.CharField(max_length=20, blank=True, null=True) teacher_id = models.CharField(max_length=20, blank=True, null=True) # 2. 课程表 class Course(models.Model): name = models.CharField(max_length=100, verbose_name='课程名称') code = models.CharField(max_length=20, unique=True, verbose_name='课程编号') teacher = models.ForeignKey(UserProfile, on_delete=models.CASCADE, limit_choices_to={'role': 'teacher'}) credit = models.FloatField(default=2.0, verbose_name='学分') capacity = models.IntegerField(default=50, verbose_name='容量') selected_count = models.IntegerField(default=0, verbose_name='已选人数') semester = models.CharField(max_length=20, verbose_name='学期', default='2024-2025-1')这里有个细节值得注意:课程表的selected_count字段,这个字段是冗余计数,也就是每次选课后把它加1。为什么不用选课记录表.count()现查?因为选课高峰期这条SQL会被疯狂执行,统计一次大表非常消耗数据库性能。用冗余字段,一次事务直接锁行更新,性能好得多。
2.2 选课、考试、成绩三张关联表的逻辑关系
选课记录表是整个系统的枢纽,同时关联用户和课程。这里最需要关注的是唯一约束,防止同一个人重复选同一门课。
class Enrollment(models.Model): student = models.ForeignKey(UserProfile, on_delete=models.CASCADE, related_name='enrollments') course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name='enrollments') enroll_time = models.DateTimeField(auto_now_add=True) status = models.CharField(max_length=10, choices=(('active', '正常'), ('dropped', '已退课')), default='active') class Meta: # 同一个学生在同一门课中只能有一条有效记录 unique_together = ('student', 'course')考试安排表要单独建,不要挂在成绩表上。因为考试在先、成绩在后,可能学生选完课但教师还没安排考试,也可能考完试但还没录分。考试表先记录时间地点,成绩表再引用考试记录,这样逻辑最顺。
成绩记录表设计时有一个容易被问到的点:平时成绩和期末成绩怎么处理?我的建议是拆成两个字段:regular_score(平时成绩)和final_score(期末成绩),总分在接口层面计算。拆开的好处是教师录入时可以只改平时分,不影响期末分。如果你想更灵活,可以再加一个JSON字段扩展平时成绩明细,比如regular_detail = models.JSONField(default=list)。
2.3 选课冲突与容量校验的数据库级处理
选课高峰期的并发问题,如果只靠前端限制,一定是防不住的。后端必须在事务里做两层校验:容量校验 + 时间冲突校验。
容量校验的核心是这个事务逻辑:
from django.db import transaction from django.db.models import F def enroll_course(request, course_id): course = Course.objects.select_for_update().get(id=course_id) if course.selected_count >= course.capacity: return {'code': 400, 'message': '课程已满'} Enrollment.objects.create(student=request.user, course=course, status='active') Course.objects.filter(id=course_id).update(selected_count=F('selected_count') + 1) return {'code': 200, 'message': '选课成功'}注意select_for_update()会把这一行锁住,直到事务结束。如果不用行锁,两个学生同时选最后一门课时,两个请求都读到selected_count=49,然后都执行加1,最后实际选课人数变成51,课程的容量形同虚设。用行锁之后,第二个请求必须等第一个事务提交后才能读这行,这时候它读到的已经是50,就会直接走到"课程已满"分支。这是我建议每个做选课系统的人都必须注意的点。
时间冲突校验不建议放在事务里做复杂的重叠区间判断,更简单有效的方案是:在选课时查出学生已有的所有有效选课记录,取这些课程的time_slot字段,然后跟当前课程的time_slot做遍历比较。如果time_slot设计成星期几列表加节次列表,比对就是数组求交集,几行代码的事。
3. 后端接口开发与核心业务实现
3.1 用Pycharm创建一个干净的Django项目
Pycharm在写Python项目时确实有自己的优势:调试模式直观,模板补全好用,每次写完接口按一下Debug就能看请求上下文。这里是创建一个标准Django项目的流程:
# 在Pycharm的Terminal里执行: mkvirtualenv exam_system -p python3.10 pip install django djangorestframework corsheaders django-admin startproject exam_system . python manage.py startapp users python manage.py startapp courses python manage.py startapp exams项目目录结构建议按业务拆app,不要所有代码都塞在一个app里。拆开后:users/app管登录注册权限,courses/app管课程和选课,exams/app管考试安排和成绩录入。这样后面查问题、改需求,定位特别快。
创建完app后,记得在settings.py里把自己写的app以及rest_framework和corsheaders都注册上,具体是放在INSTALLED_APPS列表里。
INSTALLED_APPS = [ 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', 'rest_framework', 'corsheaders', 'users', 'courses', 'exams', ]3.2 基于DRF实现登录注册模块(含JWT)
登录注册是所有系统都绕不开的第一关。Django本身有django.contrib.auth,但给前端接口用不太方便,一般会配合djangorestframework-simplejwt实现JWT登录。
安装并配置JWT的关键步骤:
pip install djangorestframework-simplejwt在settings.py里配置认证类:
REST_FRAMEWORK = { 'DEFAULT_AUTHENTICATION_CLASSES': [ 'rest_framework_simplejwt.authentication.JWTAuthentication', ], }然后到urls.py中注册登录接口:
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView urlpatterns = [ path('api/auth/login/', TokenObtainPairView.as_view(), name='token_obtain_pair'), path('api/auth/refresh/', TokenRefreshView.as_view(), name='token_refresh'), ]访问登录接口时,前端只需要传username和password,就会返回access和refresh两个字段。access是短时有效的令牌,一般设为2小时;refresh是用来刷新令牌的长时效令牌。前端拿到access之后,每次请求在请求头里带上:
Authorization: Bearer <access_token>后端就能通过JWT识别出当前请求的用户是谁,无需每次查session。这是目前前后端分离项目最主流的认证方案。
3.3 选课模块:课程列表与选课事务实现
课程列表接口比较简单,主要是把课程表和教师信息做连表查询。注意要把教师姓名通过related_field拿到,不要只返回teacher_id,否则前端还得二次请求。
from rest_framework import serializers, viewsets from .models import Course class CourseSerializer(serializers.ModelSerializer): teacher_name = serializers.CharField(source='teacher.username', read_only=True) has_enrolled = serializers.SerializerMethodField() class Meta: model = Course fields = ['id', 'name', 'code', 'credit', 'capacity', 'selected_count', 'teacher_name', 'has_enrolled'] def get_has_enrolled(self, obj): user = self.context['request'].user if user.is_authenticated and user.role == 'student': return obj.enrollments.filter(student=user, status='active').exists() return False class CourseViewSet(viewsets.ReadOnlyModelViewSet): queryset = Course.objects.all() serializer_class = CourseSerializer def get_queryset(self): # 只返回当前学期的课程 semester = self.request.query_params.get('semester', '2024-2025-1') return self.queryset.filter(semester=semester)has_enrolled这个字段很实用,前端用它来控制“选课”按钮变成“退课”按钮,学生不需要再额外请求一个已选课程接口来判断状态。
选课的操作接口用自定义的action实现:
from rest_framework.decorators import action from rest_framework.response import Response from django.db import transaction class CourseViewSet(viewsets.ViewSet): @action(detail=True, methods=['post']) def enroll(self, request, pk=None): # 事务 + 行锁,防止超选 return Response({'message': '选课成功'})如果你用的是Flask,这套逻辑同样能平移,核心区别只是把Django的ORM换成Flask-SQLAlchemy,把select_for_update()换成with_for_update(),其他思路是一样的。
3.4 成绩录入与导出:教师端的核心体验
成绩录入是教师用得最多的功能,体验直接决定这个系统好不好用。教师端至少要有两个接口:一是查看某门课程的学生列表及当前成绩,二是批量提交成绩。
批量提交成绩是典型的“一键导入”场景。Django的bulk_update方法能大幅优化性能,但要注意成绩唯一性校验。具体实现逻辑:
from django.db import transaction def bulk_update_scores(course_id, score_list): with transaction.atomic(): enrollments = Enrollment.objects.select_for_update().filter(course_id=course_id) for score_item in score_list: enrollment = enrollments.get(student_id=score_item['student_id']) enrollment.regular_score = score_item.get('regular_score') enrollment.final_score = score_item.get('final_score') enrollment.score_status = 'submitted' Enrollment.objects.bulk_update(enrollments, ['regular_score', 'final_score', 'score_status'])这里的安全点在于:Enrollment.objects.select_for_update()锁定了所有选课记录,避免了成绩提交过程中有人退课导致数据错乱。
分数校验绝对不能只靠前端,后端必须做两件事:分数范围校验(0-100)和重复提交校验。我做过一个系统,老师连点两次提交,后端的接口幂等性没做好,批量写入会导致成绩重复翻倍。后来在接口里加了状态判断,只有score_status='draft'状态的记录才允许更新,问题才彻底解决。
考试成绩导出这块,用openpyxl库生成xlsx文件,再通过HttpResponse返回给前端下载:
from openpyxl import Workbook from django.http import HttpResponse def export_scores(request, course_id): wb = Workbook() ws = wb.active ws.title = '成绩表' ws.append(['学号', '姓名', '平时成绩', '期末成绩', '总分']) for enrollment in Enrollment.objects.filter(course_id=course_id): ws.append([...]) response = HttpResponse(content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') response['Content-Disposition'] = 'attachment; filename=scores.xlsx' wb.save(response) return response生成Excel时有个小坑:如果文件名是中文,浏览器下载时经常乱码。解决办法是给文件名做URL编码:
from urllib.parse import quote filename = quote('数据结构成绩单.xlsx') response['Content-Disposition'] = f'attachment; filename*=utf-8\'\'{filename}'4. 前端Vue核心设计与开发实录
4.1 用Vue脚手架初始化项目并配置路由
前端这层建议直接用Vue3 + Vite + vue-router + pinia这套组合。Vite比webpack的启动速度快太多,配置也简单,现在基本是主流选项。
初始化项目:
npm create vite@latest web_front -- --template vue cd web_front npm install vue-router@4 pinia axios element-pluselement-plus是Vue3对应的组件库,表格、表单、弹窗这些都有现成的,做管理类系统效率很高。如果你是新手,别自己从头画表格样式,直接用组件库至少省一个月时间。
路由配置要注意权限控制。我的做法是:在路由meta里存requiresAuth和roles字段,然后在router.beforeEach里检查登录状态和角色。
import { createRouter, createWebHistory } from 'vue-router' const router = createRouter({ history: createWebHistory(), routes: [ { path: '/login', component: () => import('../views/LoginView.vue') }, { path: '/courses', component: () => import('../views/CourseListView.vue'), meta: { requiresAuth: true, roles: ['student'] } }, { path: '/admin/scores', component: () => import('../views/AdminScoreView.vue'), meta: { requiresAuth: true, roles: ['admin', 'teacher'] } } ] }) router.beforeEach((to, from, next) => { const token = localStorage.getItem('access_token') if (to.meta.requiresAuth && !token) { next('/login') } else { next() } })4.2 封装axios请求器与动态权限菜单
axios请求器的封装是一切请求调试的地基。把所有通用逻辑集中在一个文件里,后续根本不用每个页面重复写token处理。
// src/utils/request.js import axios from 'axios' import { ElMessage } from 'element-plus' import router from '../router' const request = axios.create({ baseURL: '/api', timeout: 10000 }) 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') ElMessage.error('登录已过期,请重新登录') } return Promise.reject(error) } ) export default request动态权限菜单的思路是:登录成功后,后端根据当前用户的角色返回菜单列表;前端拿到菜单列表之后动态添加路由。这个过程用router.addRoute来实现,代码量不大但很能体现你对项目的理解。毕业答辩时把这个讲透,评委会觉得你的系统提供了登录之后的权限设计方案。
4.3 选课业务的前端核心页面实现
选课页面是整个系统前端里交互逻辑最复杂的一个。学生需要看到:课程列表(含余量/已选状态)、已选课程、退课操作、选课时间窗口倒计时提示。
部分关键代码:
<template> <el-table :data="courses" stripe> <el-table-column prop="name" label="课程名称" /> <el-table-column prop="credit" label="学分" /> <el-table-column prop="teacher_name" label="授课教师" /> <el-table-column label="剩余名额"> <template #default="scope"> <span>{{ scope.row.capacity - scope.row.selected_count }}</span> </template> </el-table-column> <el-table-column label="操作"> <template #default="scope"> <el-button v-if="!scope.row.has_enrolled" type="primary" @click="handleEnroll(scope.row)" :disabled="!canEnroll" >选课</el-button> <el-button v-else type="danger" @click="handleDrop(scope.row)" >退课</el-button> </template> </el-table-column> </el-table> </template> <script setup> import { ref, onMounted } from 'vue' import request from '../utils/request' const courses = ref([]) const canEnroll = ref(true) async function fetchCourses() { const res = await request.get('/courses/') courses.value = res.data } async function handleEnroll(course) { const res = await request.post(`/courses/${course.id}/enroll/`) if (res.code === 200) { ElMessage.success(res.message) fetchCourses() } } </script>写完选课页之后,我强烈建议你测一下这个场景:选课时网络卡顿,前端发出请求后迟迟没有响应,学生着急又点了一次。如果没有做按钮的loading状态,就会发出两次请求。项目答辩的时候老师特别喜欢看这种细节。处理办法很简单,用loading变量控制按钮状态:
<el-button :loading="enrollingId === scope.row.id" @click="handleEnroll(scope.row)">选课</el-button>4.4 成绩可视化与图表展示
成绩系统的前端不能光做几个表格交差,加上可视化图表会让整个项目提高一个档次。Element Plus自身的表格够用,但图表需要用到echarts。安装方式:
npm install echarts --save实现一个简单的成绩分布柱状图:
<template> <div ref="chartRef" style="height: 350px"></div> </template> <script setup> import * as echarts from 'echarts' import { ref, onMounted, watch } from 'vue' import request from '../utils/request' const chartRef = ref(null) async function renderChart(courseId) { const res = await request.get(`/courses/${courseId}/score-distribution/`) const chart = echarts.init(chartRef.value) chart.setOption({ title: { text: '成绩分布(分段统计)' }, xAxis: { type: 'category', data: ['0-59', '60-69', '70-79', '80-89', '90-100'] }, yAxis: { type: 'value' }, series: [{ type: 'bar', data: res.data, itemStyle: { color: '#409EFF' } }] }) } </script>成绩分布图的接口在后端实现很简单:用Django的Count函数配合Case表达式,把所有成绩按分数段分组统计:
from django.db.models import Count, Case, When, IntegerField stats = Enrollment.objects.filter(course_id=course_id, score_status='submitted').aggregate( fail=Count(Case(When(avg_score__lt=60, then=1), output_field=IntegerField())), pass_60=Count(Case(When(avg_score__gte=60, avg_score__lt=70, then=1), output_field=IntegerField())), # ... 其他分数段 )有了图表之后,整个系统的“考试成绩管理”才真正具有了可看性——不只是一堆数据躺在表格里,而是能直观看到优秀率、及格率等关键教学指标。这部分在答辩时也是一个加分项。
5. 环境配置与部署环节的避坑指南
5.1 Pycharm中配置Python解释器与依赖管理
Pycharm环境配置是新手最容易卡住的地方。很多人项目代码没错,结果跑不起来,问题出在解释器选错了。Pycharm右下角可以看到当前解释器,务必确认选的是你的虚拟环境,而不是全局的Python。
建议在项目根目录维护一个requirements.txt,把依赖一次性固定下来:
Django==4.2.7 djangorestframework==3.14.0 djangorestframework-simplejwt==5.3.0 django-cors-headers==4.3.1 openpyxl==3.1.2以后换电脑、给老师跑演示,直接:
pip install -r requirements.txt能省出大把时间。Pycharm还自带一个很实用的功能:在Terminal底部面板里可以直接看到虚拟环境前缀(类似(venv)),如果没看到这个前缀,说明解释器没配对。
5.2 跨域配置:前后端端口不一致怎么处理
开发阶段前端Vite默认跑在5173端口,后端Django/Flask跑在8000端口,两个端口不一样就一定会遇到跨域问题。配置CORS的办法在Django里是这样的:
# settings.py INSTALLED_APPS = [ 'corsheaders', ] MIDDLEWARE = [ 'corsheaders.middleware.CorsMiddleware', ] CORS_ALLOW_ALL_ORIGINS = True # 开发阶段先全部放开Flask那边稍微简单一点,用flask-cors库:
from flask_cors import CORS CORS(app)跨域配置放开到CORS_ALLOW_ALL_ORIGINS = True只适用于开发阶段。如果项目要部署上线,建议改为指定域名白名单:
CORS_ALLOWED_ORIGINS = [ "http://localhost:5173", "http://127.0.0.1:5173", ]这里有个很常见的坑:前端请求能通,但带不了cookies或自定义请求头。如果你的JWT是通过请求头Authorization传递的话,默认情况没问题;但如果有跨域请求附带自定义头,需要在后端加入允许的请求头配置:
CORS_ALLOW_HEADERS = [ 'content-type', 'authorization', ]5.3 Flask版本的核心接口写法对照
虽然我主推Django,但既然题目里也提到了Flask,就把核心接口的Flask版本写出来,方便对比选择。
Flask项目结构一般是这样:
exam_system_flask/ ├── app.py ├── models.py ├── extend.py └── requirements.txt核心接口示例:
from flask import Flask, request, jsonify from flask_sqlalchemy import SQLAlchemy from flask_jwt_extended import JWTManager, jwt_required, get_jwt_identity app = Flask(__name__) app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///exam.db' app.config['JWT_SECRET_KEY'] = 'your-secret-key' db = SQLAlchemy(app) jwt = JWTManager(app) @app.route('/api/courses', methods=['GET']) @jwt_required() def list_courses(): courses = Course.query.all() return jsonify([c.to_dict() for c in courses]) @app.route('/api/courses/<int:course_id>/enroll', methods=['POST']) @jwt_required() def enroll_course(course_id): user_id = get_jwt_identity() # 容量校验 + 唯一约束处理 # ... return jsonify({'code': 200, 'message': '选课成功'}) if __name__ == '__main__': app.run(debug=True, port=8000)Flask的自主性更强,目录结构可以完全自己掌控;Django则自带了很多约束和规范。我个人建议新手直接学Django,因为Django的约束会帮你规避很多低级错误,比如默认帮你处理了SQL注入、CSRF等问题。
5.4 部署上线的最小可行方案
如果项目需要用腾讯云、阿里云的服务器做演示,部署方案有很多,但毕业设计场景最简单可靠的是:后端用Gunicorn起服务,前端打包成静态文件交给Nginx托管。
Django部署的关键步骤:
pip install gunicorn # 收集静态文件 python manage.py collectstatic # 迁移数据库到生产环境 python manage.py migrate # 启动Gunicorn服务 gunicorn exam_system.wsgi:application --bind 0.0.0.0:8000 --workers 3前端Vue项目打包:
npm run build # 会在dist目录生成静态文件,将整个dist目录上传到服务器Nginx配置里最关键的一处是把API请求反代到后端:
server { listen 80; server_name your_domain.com; root /var/www/exam_front; 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; } location / { try_files $uri $uri/ /index.html; } }最后那句try_files $uri $uri/ /index.html;特别重要。Vue是单页应用,路由跳转后页面刷新会直接404,需要靠它把请求全部引回index.html,再让Vue路由接管。
6. 真实开发中常见的五个大坑与排查思路
6.1 选课并发写库导致数据错乱
这个坑我上面提过,但值得单独再讲一下。我第一版做选课系统时没有用行锁,上线当天就出问题了。不止是超选问题,更隐蔽的是selected_count字段在并发下变成负数。
排查这类问题的最快方法:直接看MySQL慢查询日志,或者在后端加日志记录请求的耗时和结果。如果发现两个选课请求几乎同时进入,就基本可以断定是事务隔离等级和锁的机制没处理好。解决办法就是select_for_update()加事务,确实有效。
6.2 Django的DateTimeField时区问题
这个坑出现频率极高:本地开发时时间显示正常,部署到服务器后发现所有时间都差了8小时。原因在于Django默认时区是UTC,而中国是东八区。
解决办法在settings.py中:
TIME_ZONE = 'Asia/Shanghai' USE_TZ = True如果数据库里已经存了错误的时间数据,恢复起来比较麻烦。我自己踩过的泥坑是:项目开始时就该写好时区配置,数据库里的旧数据如果不是用Django ORM写入的,修复时手工加8小时核对就行。
6.3 Vue中m3u8视频播放问题
有的学校会把课程录像放到系统里让学生看,有些场景会遇到播放m3u8视频的需求,主要是在线播放教学视频。严格说这不是教务选课系统的核心模块,但如果老师提了要求,就得能处理。
m3u8是HLS流媒体协议的视频分段索引文件,普通<video>标签默认不支持直接播放。解决方案是用hls.js库,同时配合后端把视频文件通过静态路由提供。在Vue项目里安装:
npm install hls.js前端播放组件的核心逻辑是判断用户浏览器是否原生支持HLS(Safari原生支持),不支持的话就用hls.js接管:
import Hls from 'hls.js' function playVideo(videoElement, videoUrl) { if (videoElement.canPlayType('application/vnd.apple.mpegurl')) { // Safari等原生支持HLS的浏览器 videoElement.src = videoUrl } else if (Hls.isSupported()) { // 其他浏览器用hls.js解析 const hls = new Hls() hls.loadSource(videoUrl) hls.attachMedia(videoElement) } else { console.error('当前浏览器不支持HLS播放') } }注意的竟然是后端。后端返回m3u8和ts分片时,响应头的Content-Type必须正确:m3u8文件要返回application/vnd.apple.mpegurl,ts分片要返回video/mp2t。Django的静态文件服务默认可能处理不了,建议用一个简单的视图函数来响应请求。
6.4 前后端联调时接口404的排查方法
联调阶段最常遇到的问题就是后端接口明明存在,但前端访问却404。排查思路一定是从内到外:
- 用
curl或Postman直接请求后端接口,看是否正常。 - 确认后端服务是否在运行、端口是否正确。
- 检查urls.py中的路由是否有层级错误。
- 检查前端是否配置了正确的代理地址。
如果用的是Vite开发环境,推荐在vite.config.js里配置开发代理,把/api请求转发到后端,能避免大部分跨域问题:
export default defineConfig({ server: { proxy: { '/api': { target: 'http://localhost:8000', changeOrigin: true } } } })配置这个代理之后,前端请求/api/courses/时,实际上会被转发到http://localhost:8000/api/courses/,解决了开发环境的跨域问题。
6.5 Flask + sqlite生产环境并发差的问题
用Flask开发时为了方便,默认用的往往是SQLite数据库。单机开发没问题,但部署到服务器、同时处理多个选课请求时,SQLite的写锁问题就会暴露出来,表现为高并发时接口超时甚至卡死。
解决方案两个:一是换用MySQL,基本一劳永逸;二是如果项目很简单不想引入MySQL,至少把SQLite的隔离级别和同步模式调整一下。但我的建议是,既然都已经做毕业设计或正式项目了,直接用MySQL就好,安装不复杂,而且能顺带熟悉生产环境配置。
Windows环境装MySQL之后,Flask的数据库URI改成:
app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql+pymysql://root:yourpass@localhost/exam_system?charset=utf8mb4'注意charset=utf8mb4这个参数,如果用默认的utf8,存emoji或特殊符号会报错。
7. 一套可直接使用的开发顺序与时间排期
7.1 从零开始的两个月节奏规划
做教务系统这类项目,最大的坑是需求蔓延。一开始觉得功能很简单,做着做着发现“老师想把整个OA都搬进来”,结果两年都做不完。我给一个能在一两个月内完成、并真正达到“学校教务系统”核心能力的功能集合:
第一阶段(第1周):需求确认 + 数据库设计。把所有核心表建好,把业务理清楚。这阶段不写业务代码,只写模型定义和数据库迁移。
第二阶段(第2-4周):后端核心接口开发。先做登录注册和用户权限,再做课程管理和选课接口,最后做成绩录入与统计。
第三阶段(第5-6周):前端页面开发。按功能拆页面:登录页、课程列表页、选课操作页、我的课表页、成绩查询页、后台管理页。
第四阶段(第7周):前后端联调。这个阶段主要是改bug,接口参数对不上、状态码处理不一致、边界情况没考虑,都会在这里集中暴露。
第五阶段(第8周):部署演示环境 + 写文档。别小看这步,答辩时不光看系统功能,还看文档和演示流畅度。
7.2 开发时推荐遵循的敏捷节奏
很多独自开发的同学有个误区:憋大招,猛写两周代码然后一次性测试。结果往往是一堆bug堆积在一起,根本没法排。我的习惯是每天都要保证系统处于可运行状态:
- 早上确定今天要完成的一个功能点,比如“完成选课接口”。
- 写完后立刻起服务,从前端页面点一遍,确认正常。
- 下班前用git提交一次,commit message写清楚:“完成选课事务处理逻辑”。
这样每周末都能给老师演示一个阶段版本,过程是平滑的、可持续的。如果哪次做砸了,就是哪天的事,定位很容易,不用从一大团乱麻里找bug。
7.3 Flask与Django的取舍补充
最后再补充一点关于框架选型的个人经验。如果你问我现在给自己做新项目选Django还是Flask,我的答案很明确:业务复杂、模块多、需要后台管理的选Django;工具型网页、三五天出活的项目用Flask完全可以。但两者在教务选课这种场景里都能胜任,区别主要是开发者的熟悉度和生态依赖。
还有一个新东西值得留意:FastAPI现在越来越流行,尤其适合做纯API服务。如果你不打算用Django的自带admin和模板系统,又想体验新版本Python异步特性,FastAPI + SQLAlchemy + Vue也是一条走得通的路。但毕业设计和入门项目的话,资料最多的还是Django,这也是我把主要篇幅放在Django上的原因。
最后分享一点个人感悟
做了快十年的开发,我见过太多类似项目半路烂尾的情况。不是说技术难,而是需求不明确、没坚持、代码越写越乱。教务选课考试系统这个题目其实很适合作为综合练习:它包含用户权限、事务并发、前端组件、数据可视化、文件导出、部署上线,几乎把一个完整Web系统能遇到的问题覆盖了个遍。
你做完这个项目之后,简历上有东西写了,面试能聊的内容也丰富了。以后换其他系统,核心思路完全可迁移。比如把课程换成商品就是电商系统,把考试成绩换成订单就是订单管理系统。选课系统做的不是“选课”,是业务建模能力和工程实操能力。
如果你正在做类似系统,遇到问题卡住了,回头看看这篇里的几个大坑:选课并发记得加行锁、时区记得改成上海、跨域不要放太开、前端按钮注意防重复点击、SQLite别用在生产环境。把这些坑避开,一个合格的教务系统就成功了一半。剩下的一半,就是坐下来一行一行写代码而已。