☰
Python+Django在线选课系统:从业务规则到并发控制的完整实践
2026/10/7 11:18:59 网站建设 项目流程

每年到了课程设计或者毕业设计的时候,我总能在各个技术社区里看到同一个高频关键词:python+在线选课系统。网上的开源版本一抓一大把,但我看过很多所谓的“选课系统”,本质就是一个课程信息管理页面加一个选课按钮,真正把选课规则做扎实的非常少。这次要说的项目,内部编号叫 hx4616,表面看也是一套基于 Python 的在线选课系统,但把它从零做到能扛住集中选课、不出业务事故,过程中踩到的坑和拆解出来的设计思路,我觉得比代码本身更值得拿出来聊聊。这篇文章适合两类人:一类是已经能用 Python 写脚本、想做一个完整 Web 项目放在简历上的进阶学习者;另一类是正在做课设或毕设、不想再做“表面选课”系统的同学。我会把技术选型、表结构设计、冲突检测、并发控制这些核心环节的真实做法的理由都讲清楚。

1. 为什么选课系统比想象中难:先看清楚它的真实复杂度

很多人第一次接选课系统时,都会下意识地把它归类为“标准的增删改查项目”。确实,从功能列表看,无非是课程管理、学生选课、退课、名单查询。但如果你真的愿意把它当成一个正经产品来做,而不是交差了事,你会发现选课系统的复杂度被严重低估了。它身上集中了 Web 后端开发中最典型的一批业务约束。

1.1 三个被低估的业务约束

第一是容量约束。每门课程有容量上限,选满就不能再选。听起来简单,但“选满”的判断时机和并发条件下的准确性,恰恰是整个系统最容易翻车的地方。第二是时间冲突约束。一个学生可以选多门课,但他的已选课程之间不能存在上课时间重叠。这意味着选课操作不是简单的“往关系表里插一行”,而是要先做一轮时间片的交集计算。第三是唯一性约束。同一学生不能重复选择同一门课程,这个用数据库的唯一索引能兜住,但前端和接口层如果没处理好,用户会看到 500 报错而不是友好提示。

1.2 还有两个隐性需求

除了上面三条硬规则,选课系统还有两个容易被忽略的隐性需求。一个是“先到先得”的公平性。学校选课往往在固定时间点开放,大量学生同时涌入,系统必须保证在名额只剩一个时,只有真正先提交请求的人能选上,而不是让两个人同时成功。另一个是管理端的统计需求。管理员不仅需要维护课程,通常还想看到每门课的选课人数、剩余名额、甚至哪些课最热门——这直接决定了你数据模型里是否需要一个冗余的计数字段,而不是每次查询都去 count 一遍。

1.3 它覆盖了 Web 开发的知识图谱

把上面这些需求往技术层面翻译一下,你会发现选课系统几乎串起了 Web 后端的主流知识点:用户认证与多角色权限(学生和管理员)、ORM 数据建模与关系设计、事务与并发控制、接口参数校验、以及最基本的前端交互。它不是那种“学了框架就能照抄”的玩具项目,而是那种做完之后,你对“业务规则如何翻译成代码”会有真正体感的项目。从学习路径上讲,放在“掌握 Python 基础语法、会用 Flask 或 Django 写简单路由”之后,是再合适不过的进阶练习。

2. 技术选型的真实理由:框架、数据库和部署

先回答一个必须面对的问题:选课系统这种量级的项目,为什么用 Python?直观地说,论并发性能,Java 和 Go 都比 Python 更擅长处理高并发。但学校选课系统的真实场景是——几千人同时抢课已经是峰值,持续几秒到几分钟,之后负载就降下来了。Python 加上合理的数据库锁机制,完全够用,而且开发效率高、代码量少、排查问题直观。对一个个人项目或者课设项目来说,开发效率和维护的省心程度,远比那点理论性能差距更重要。

2.1 Flask 还是 Django

这两个框架的选择,直接决定后面所有代码的写法。我在 hx4616 这个项目里用的是 Django,不是因为它比 Flask 更高级,而是因为选课系统天然需要这几样东西:自带的 ORM 和数据库迁移工具、自带的后台管理系统(Django Admin)、以及内置的用户认证体系。这三个能力如果都靠 Flask 自己拼,Flask-SQLAlchemy、Flask-Login、Flask-Migrate 一个个装,也不是不行,但项目越大,需要自己决策和配置的点就越多。

对比维度FlaskDjango
学习曲线平缓,适合简单项目快速上手略陡,概念多但不难理解
ORM需额外集成 SQLAlchemy内置,迁移和查询都很成熟
后台管理需自己写自带 Admin,开箱即用
用户认证需自己拼装内置完整会话与权限体系
适合场景小型 API、微服务、快速原型有后台管理的完整业务系统

如果你是做课设,选 Django 可以少写至少三分之一的后台代码;如果你是想练手 Flask 的灵活度,那也不反对,但下面的设计方案同样适用,只是需要把 Django ORM 换成 SQLAlchemy 的实现。

2.2 数据库:开发 SQLite,生产 MySQL

开发阶段我用的是 SQLite,原因很简单:零配置,文件即库,跑测试和本地调试非常舒服。但部署到服务器时,我会换成 MySQL。这里不是为了炫技,而是因为 SQLite 在并发写入上有明显的锁机制限制,选课高峰期多个写请求同时进来时容易报“database is locked”。MySQL 的 InnoDB 引擎支持行级锁,这才是做并发控制该有的基础。切换并不复杂,Django 的项目配置文件里改一下数据库连接即可:

# settings.py DATABASES = { 'default': { 'ENGINE': 'django.db.backends.mysql', 'NAME': 'course_select', 'USER': 'root', 'PASSWORD': 'your_password', 'HOST': '127.0.0.1', 'PORT': '3306', 'CONN_MAX_AGE': 60, 'OPTIONS': { 'charset': 'utf8mb4', }, } }

2.3 部署方案一句话说清

部署层面,hx4616 用的是最常见的组合:Gunicorn 跑 Django 应用,Nginx 反向代理并处理静态文件。这套组合的好处是社区资料极多,任何一个报错都能搜到解决方案。不要一开始就上 Docker 和 K8s,那个复杂度是另一个维度的,先把系统稳定跑起来再说。

3. 数据模型是地基:四张表的设计思路和代码

选课系统的业务规则,最终都要落到数据模型上。模型设计得不好,后面每写一个功能都是在跟自己的表结构较劲。hx4616 的数据模型,我最终收敛为四张核心表:用户表、课程表、课程时间表、选课记录表。下面拆开讲每一张表为什么要这样设计。

3.1 用户表:直接扩展 Django 自带用户

不要自己从头写用户表。Django 的AbstractUser已经处理了用户名、密码哈希、会话、权限等一堆基础能力,直接在它的基础上扩展业务字段是最省力的做法。选课系统里需要区分学生和管理员两种角色,所以我加了一个role字段。为了兼容一些学校要求记录学号的需求,又加了一个可选的student_id字段。

from django.contrib.auth.models import AbstractUser from django.db import models class User(AbstractUser): ROLE_CHOICES = ( ('student', '学生'), ('admin', '管理员'), ) role = models.CharField(max_length=10, choices=ROLE_CHOICES, default='student') student_id = models.CharField(max_length=20, blank=True, null=True, verbose_name='学号') major = models.CharField(max_length=50, blank=True, verbose_name='专业') def __str__(self): return f'{self.username}({self.get_role_display()})'

3.2 课程表与课程时间表:为什么上课时间要拆出来

课程表本身比较直观:课程名称、课程代码、教师、学分、容量、已选人数、开课学期、课程简介。容易想错的是上课时间的存储方式。一门课可能每周有多个上课时间段,比如周一 1-2 节、周三 3-4 节。如果把这些时间用逗号拼成一个字符串存在课程表里,后续的冲突检测会变成痛苦的正则解析。正确的做法是单独建一张课程时间表,一门课程对应多条时间记录,每条记录表达“第几周、星期几、第几节到第几节”这个最小粒度的时间片。

这样设计的直接好处有两个。第一,冲突检测的本质是“两个集合的时间片两两比较”,数据落在独立表里,用几条简单的查询就能把所有时间片取出来;第二,后续如果想做“按时间段查教室冲突”这类扩展,这种模型也能接住。

class Course(models.Model): name = models.CharField(max_length=100, verbose_name='课程名称') code = models.CharField(max_length=20, unique=True, verbose_name='课程代码') teacher = models.CharField(max_length=50, verbose_name='授课教师') credit = models.DecimalField(max_digits=3, decimal_places=1, verbose_name='学分') capacity = models.PositiveIntegerField(default=30, verbose_name='容量上限') selected_count = models.PositiveIntegerField(default=0, verbose_name='已选人数') semester = models.CharField(max_length=20, verbose_name='开课学期', default='2024-2025-1') description = models.TextField(blank=True, verbose_name='课程简介') def __str__(self): return f'{self.code} {self.name}' class CourseTime(models.Model): course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name='times') week_start = models.PositiveIntegerField(verbose_name='起始周') week_end = models.PositiveIntegerField(verbose_name='结束周') week_type = models.PositiveIntegerField( choices=((0, '每周'), (1, '单周'), (2, '双周')), default=0, verbose_name='周类型' ) weekday = models.PositiveIntegerField(choices=[(i, f'星期{i}') for i in range(1, 8)], verbose_name='星期几') start_section = models.PositiveIntegerField(verbose_name='开始节次') end_section = models.PositiveIntegerField(verbose_name='结束节次') def __str__(self): return f'{self.course.code} 周{self.weekday}-{self.start_section}~{self.end_section}节'

注意我在课程时间表里加了一个week_type字段,用来表示“每周都有”“单周才有”“双周才有”这三种情况。这个字段大有用处,因为很多学校确实存在单双周交替上课的课程,如果忽略它,冲突检测会误判。

3.3 选课记录表:核心是联合唯一约束

选课记录表记录的是“哪个学生选了哪门课、什么时候选的”。它天然是一个多对多关系表,但你必须加上两个关键点:一是UniqueConstraint,保证同一学生对同一门课只能有一条记录;二是created_at字段,记录选课时间,这是“先到先得”的依据。

class Selection(models.Model): student = models.ForeignKey(User, on_delete=models.CASCADE, related_name='selections') course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name='selections') created_at = models.DateTimeField(auto_now_add=True, verbose_name='选课时间') class Meta: constraints = [ models.UniqueConstraint(fields=['student', 'course'], name='unique_student_course') ]

这里还要提一个设计原则问题:为什么要在课程表里放一个selected_count冗余字段,而不去实时 count 选课记录?因为课程列表页需要展示每门课的“剩余名额”,如果每次请求都 count 一次,高峰期光这一个页面就会产生几万次查询。用一个整数字段在写入时维护计数,读取时零成本。这个“读多写少、冗余计数”的思路,在真实系统里非常常见。

3.4 模型设计阶段就给未来铺好路

数据模型定下来之后,后面的业务逻辑和接口开发就不会再返工了。这里我吃过一个亏:第一版设计里没有单独的课程时间表,而是把时间写成了一个字符串,结果做冲突检测的时候写了一堆解析逻辑还老出错。所以我的经验是:凡是需要在代码里做逻辑判断的数据,一定拆出独立字段,不要用字符串或 JSON 去糊弄。宁可多建一张表,也不要给后面留定时炸弹。

4. 选课核心流程:冲突检测与容量控制的落地

模型建好之后,重头戏就是选课操作本身。这个接口是整个系统的心脏,网上的很多版本只是简单插一条数据,完全不校验业务规则。hx4616 的选课流程是这样的,我用 Django 视图来描述。

4.1 选课接口的完整流程

一个正常的学生选课请求,服务端要做的事情按顺序排开是这样的:

  1. 确认当前登录用户是学生角色;
  2. 根据课程 ID 查出课程记录;
  3. 判断这门课是否还开放选课(开课学期、课程状态等);
  4. 判断学生是否已经选过这门课(联合唯一索引兜底,但这里要主动检查以返回友好提示);
  5. 判断课程是否已经满员(selected_count < capacity);
  6. 做时间冲突检测,把新课的所有时间片和学生已选课程的所有时间片逐一比对;
  7. 全部通过后,写入选课记录,同时将课程的selected_count加一。

这里最容易被初学者忽略的,是第 6 步。很多人会直接把容量校验和时间冲突检测的顺序倒过来,或者干脆不检测时间冲突。我们先把完整的代码骨架写出来,再细讲冲突检测的实现。

from django.db import transaction from django.http import JsonResponse from django.views.decorators.http import require_POST from django.contrib.auth.decorators import login_required @login_required @require_POST def enroll_course(request, course_id): if request.user.role != 'student': return JsonResponse({'code': 403, 'msg': '仅学生可选课'}) course = Course.objects.select_for_update().get(id=course_id) # 已选判断 if Selection.objects.filter(student=request.user, course=course).exists(): return JsonResponse({'code': 400, 'msg': '你已选过这门课'}) # 容量判断 if course.selected_count >= course.capacity: return JsonResponse({'code': 400, 'msg': '课程已满员'}) # 时间冲突检测 if has_time_conflict(request.user, course): return JsonResponse({'code': 400, 'msg': '与已选课程上课时间冲突'}) # 写入选课记录并更新计数 Selection.objects.create(student=request.user, course=course) course.selected_count = course.selected_count + 1 course.save(update_fields=['selected_count']) return JsonResponse({'code': 200, 'msg': '选课成功'})

注意第一步查课程记录时用了select_for_update(),这是行级锁,它的作用我在下一章详细讲,这里先记住:选课这种写操作,读课程记录时必须加锁。

4.2 时间冲突检测的区间重叠算法

时间冲突检测是整个系统最体现“会写业务代码”的地方。你可以把它简化成一个数学问题:两个时间段是否重叠。判断两个闭区间[a, b]和[c, d]是否存在交集,标准算法是:

def is_overlap(a_start, a_end, b_start, b_end): return a_start <= b_end and b_start <= a_end

放到选课场景里,“时间段”实际有三个维度:星期几、节次区间、周次区间。所以完整判断流程是:先判断星期几是否相同,再判断节次区间是否重叠,最后判断周次区间是否重叠。单双周的情况也要考虑进去。下面是完整的检测函数:

def has_time_conflict(student, new_course): new_times = list(new_course.times.all()) old_selections = Selection.objects.filter(student=student).select_related('course') old_courses = [sel.course for sel in old_selections] for old_course in old_courses: old_times = list(old_course.times.all()) for ot in old_times: for nt in new_times: if times_conflict(ot, nt): return True return False def times_conflict(t1, t2): if t1.weekday != t2.weekday: return False # 节次区间重叠 if not (t1.start_section <= t2.end_section and t2.start_section <= t1.end_section): return False # 周次区间有无交集,并考虑单双周 weeks1 = set(range(t1.week_start, t1.week_end + 1)) weeks2 = set(range(t2.week_start, t2.week_end + 1)) if t1.week_type == 1 and t1.week_start % 2 == 0: weeks1 = set(w for w in weeks1 if w % 2 == 0) elif t1.week_type == 1 and t1.week_start % 2 != 0: weeks1 = set(w for w in weeks1 if w % 2 != 0) if t2.week_type == 2 and t2.week_start % 2 == 0: weeks2 = set(w for w in weeks2 if w % 2 == 0) elif t2.week_type == 2 and t2.week_start % 2 != 0: weeks2 = set(w for w in weeks2 if w % 2 != 0) return len(weeks1 & weeks2) > 0

这个实现的优点是直观且可测试。每一层判断都可以单独写单测去覆盖。有人说直接用 SQL 去查数据库跨表找冲突会更高效,但我实测下来,在学生已选课程数量不大(通常不超过 20 门)的情况下,Python 内存里做集合运算已经完全够快,还省去了复杂 SQL 的维护成本。

4.3 容量校验:后端绝对不能信前端

容量判断的实现看起来只有一行——if course.selected_count >= course.capacity。但它真正的难点在于:前端展示的剩余名额只是一个参考值,任何时候都不能作为判断依据。我在项目里遇到过一种情况,前端页面加载时剩余名额显示还有 3 个,学生慢悠悠选完课程提交时,名额已经被别人抢走了。如果不做后端二次校验,这条选课记录就会变成超额选课。所以无论前端写了多少判断逻辑,这个容量校验必须原封不动地在后端再做一遍。

4.4 为什么要把校验都放进事务里

选课操作涉及两步写入:插入选课记录和更新已选人数。这两步必须保证原子性,否则会出现“记录插进去了但计数没涨”的不一致状态。Django 里用transaction.atomic()包裹即可。我在上面的代码里没有显式加,实际项目则是在视图函数上直接使用@transaction.atomic装饰器。记住一个原则:把锁、校验、写入放在同一个事务里,锁的持有时间才能尽可能短。不要在事务里做发邮件、调外部 API 这类耗时操作,否锁会一直被占用,高峰期很快就会出现锁等待。

5. 并发抢课:超选问题的三种方案与我的选择

选课系统最有挑战的场景就是集中选课那一瞬间。如果只有几十个人同时选课,前面那套校验逻辑完全够用;但如果是几千人同时抢一门热门课程,问题就暴露出来了。

5.1 问题复现:读出来、判断、写入的经典竞态

想象一门课容量 1,剩余名额 1。学生 A 和学生 B 同时提交选课请求。两个请求都读到了selected_count = 0,都判断“没满”,然后都插入记录,最后都执行selected_count = 1。结果就是这门课最终有 2 条选课记录,名额超了。这就是典型的并发竞态问题,教科书里叫“丢失更新”或“超卖”,在电商秒杀系统里也极其常见。

解决思路有三大类,下面逐一对比。

5.2 方案一:悲观锁,直接锁行

上面的代码里我写的select_for_update()就是悲观锁。它的含义是:在事务内查询课程记录时,直接对数据库中的这一行加上写锁,直到事务结束才释放。在这个锁的期间,其他任何想读取或修改这行记录的事务都会被阻塞。这样一来,学生 A 事务还没结束之前,学生 B 的select_for_update()会一直等待,等 A 提交后,B 拿到的已经是selected_count = 1的数据,然后判断“已满员”,选课失败。

from django.db import transaction @transaction.atomic def enroll_course(request, course_id): course = Course.objects.select_for_update().get(id=course_id) # ... 后面的校验和写入不变

悲观锁的优点是好理解、实现简单、绝对可靠;缺点是高并发下如果事务内操作过多,锁等待会让请求响应变慢。但在选课系统这种“短事务、高频次”的场景下,每个事务内只做几行查询和一次插入,耗时非常短,悲观锁完全够用。学校选课规模下,这已经是我最推荐的做法了。

5.3 方案二:乐观锁,用条件更新替代锁

如果不想让请求排队等待,可以用乐观锁的思路。核心思想是:不在读取时加锁,而是用一个带条件的更新语句去“抢”名额。

from django.db.models import F affected = Course.objects.filter( id=course_id, selected_count__lt=F('capacity') ).update(selected_count=F('selected_count') + 1) if affected == 0: return JsonResponse({'code': 400, 'msg': '课程已满员'}) Selection.objects.create(student=request.user, course_id=course_id)

这一行的关键在于UPDATE course SET selected_count = selected_count + 1 WHERE id = %s AND selected_count < capacity。数据库在更新时会检查行级条件,如果条件不成立,影响行数为 0,说明课程已经满了。因为整个判断和更新是数据库原子操作,所以不会出现两个请求同时通过的情况。乐观锁的优点是并发性能更好,不会阻塞;缺点是如果失败,需要结合“再查一次”来提示具体原因,代码逻辑稍微复杂一些。

我实测下来,对这个项目而言,用乐观锁也完全没有问题,而且在读多写少的退课场景下比悲观锁更轻快。但如果要严格保证先到先得和时间冲突检测的一致性,还是悲观锁更省心,因为更新计数和插入选课记录之间,悲观锁能保证整个过程是串行的。

5.4 方案三:Redis 预扣减

第三种方案是把容量迁移到 Redis 上用原子自减操作:

import redis r = redis.Redis(connection_pool=pool) remain = r.decr('course:capacity:1001') if remain < 0: # 回滚名额 r.incr('course:capacity:1001') return JsonResponse({'code': 400, 'msg': '课程已满员'})

这种方式性能最高,能扛住非常大的并发,但要处理缓存和数据库之间的一致性、以及失败回滚时的补偿逻辑,复杂度明显高一个量级。对课设项目或中小型系统来说属于典型的过度设计。我个人的建议是:除非你明确是为了在简历上写“使用 Redis 解决选课并发问题”而刻意引入,否则不要一上来就选它。

5.5 最终选择:按规模分档

我把最终的方案定了三层:开发环境直接乐观锁,代码简单且容易理解;测试和演示环境用悲观锁,行为最可预期;如果将来选课人数真的到了万人级别,我才会去引入 Redis 预扣减方案。锁的粒度一定要放在课程行上,而不是锁整个表,select_for_update()默认就是行级锁,别手滑写成对全表做select *后再锁,那就锁成表了。

6. 双角色权限与会话:从登录到管理后台

选课系统天然有两种使用者:学生和管理员。学生的核心操作是浏览课程、选课、退课、查看已选列表;管理员的职责是维护课程信息、查看选课名单、调整容量。这两类角色在代码和会话层面需要清晰地隔离。

6.1 登录与角色跳转

Django 内置的认证系统提供了authenticate()和login()两个函数,登录成功后根据用户角色的不同跳转到不同首页。这里要提醒一个细节:不要强依赖 Django Admin 去做学生用户的前端登录,学生登录走的是普通页面,管理员登录既然有 Admin 自带后台,就不用重复造轮子。

from django.contrib.auth import authenticate, login def user_login(request): if request.method == 'POST': username = request.POST.get('username') password = request.POST.get('password') user = authenticate(request, username=username, password=password) if user is not None: login(request, user) if user.role == 'admin': return redirect('/admin/') return redirect('/student/dashboard/') return render(request, 'login.html', {'error': '用户名或密码错误'}) return render(request, 'login.html')

6.2 自定义权限装饰器

Django 的@login_required保证登录,但要区分角色,需要自定义装饰器或者在视图里手动判断。写一个装饰器会让代码干净很多:

from functools import wraps from django.http import JsonResponse def role_required(role): def decorator(view_func): @wraps(view_func) def _wrapped(request, *args, **kwargs): if not request.user.is_authenticated: return JsonResponse({'code': 401, 'msg': '请先登录'}) if request.user.role != role: return JsonResponse({'code': 403, 'msg': '权限不足'}) return view_func(request, *args, **kwargs) return _wrapped return decorator

然后这样用:

@role_required('student') def my_courses(request): ...

6.3 Django Admin 加速管理员后台

这是我推荐用 Django 做这类项目的最强理由之一。管理员需要的课程 CRUD、选课名单查看、已选人数统计,Django Admin 几乎可以零代码搞定。你只需要在admin.py里注册模型,配置好list_display、list_filter、search_fields:

from django.contrib import admin from .models import Course, CourseTime, Selection class CourseTimeInline(admin.TabularInline): model = CourseTime extra = 1 @admin.register(Course) class CourseAdmin(admin.ModelAdmin): list_display = ['code', 'name', 'teacher', 'credit', 'capacity', 'selected_count', 'semester'] list_filter = ['semester', 'credit'] search_fields = ['code', 'name', 'teacher'] inlines = [CourseTimeInline] @admin.register(Selection) class SelectionAdmin(admin.ModelAdmin): list_display = ['student', 'course', 'created_at'] list_filter = ['course'] search_fields = ['student__username', 'course__name']

这个后台可以直接给管理员用,不需要再花时间写一套管理前端。如果你想让管理员看到更直观的统计,也可以在 Admin 里加一个只读字段显示“剩余名额 = capacity - selected_count”,或者直接用list_display里的方法列去展示。实测下来,管理员对网页端“选课名单导出”的需求通常也会接踵而至,Django Admin 的actions可以很方便地加一个导出 CSV 的动作,这里就不展开了。

6.4 Session 安全设置

Session 管理上,我做了两个基础设置。一是会话过期时间,学生选课场景通常希望关闭浏览器后自动失效,所以设置了SESSION_EXPIRE_AT_BROWSER_CLOSE = True;二是为防止 Session 被窃取,设置了 Cookie 的 HttpOnly 属性,这样前端 JavaScript 读不到 Session 值,能在一定程度上预防 XSS 窃取会话。对于课设项目这个级别,这两步就差不多了,不要过度追求安全加固而忘了核心功能。

7. 踩坑实录:六个让我返工过的细节

最后这部分,我把开发 hx4616 过程中真正让我返工过的细节集中列出来。这些坑在教程里基本看不到,但它们每一个都有可能导致系统在上线演示时当场翻车。

7.1 周次冲突检测漏了单双周

第一版时间冲突检测,我只比较了星期几和节次区间。结果有一门“单周周三 5-6 节”的课程和另一门“双周周三 5-6 节”的课程被判定为冲突,导致学生明明可以同时选这两门课却被系统拦住。后来引入week_type字段后,用“先算周集合、再取交集”的方式才彻底修好。写集合运算的时候,注意单双周的判定要和起始周的奇偶性一致,否则也会错。

7.2 双击提交导致 500 报错

这是一个非常高频的问题。学生在选课时连点两下选课按钮,两个请求先后到达后端。有了联合唯一约束,第二条数据插入会触发IntegrityError,如果没捕获,页面直接白屏报 500。修复分两层:前端在点击选课按钮后立刻禁用按钮并显示“处理中”,防止用户重复点击;后端捕获IntegrityError并返回友好的“你已选过该课程”提示。两层都要做,因为前端防的是普通用户,后端捕获防的是绕过前端的直接请求。

7.3 N+1 查询让课程列表页慢到崩溃

课程列表页要展示课程信息、上课时间、已选人数和剩余名额。第一版用模板直接循环course.times.all()和course.selections.count(),列表 50 门课,产生了上百条 SQL 查询,页面加载慢得离谱。修复方案是prefetch_related('times')和冗余计数字段,让列表页的查询数降到个位数。这里再说一次,这就是我在模型设计时坚持保留selected_count冗余字段的原因——没有它,列表页的每一次刷新都要做几十次聚合查询。

7.4 时区坑:选课时间显示差了 8 小时

Django 默认开启USE_TZ = True,数据库里存的是 UTC 时间。如果直接渲染created_at,中国用户看到的时间会慢 8 小时。排查了半天才发现不是数据库写错了,而是模板渲染时没有做时区转换。解决方案是模板里用{{ selection.created_at|localtime }}过滤器。如果项目不涉及多国部署,最简单的做法是直接在settings.py里设置USE_TZ = False,省掉这一整个类别的麻烦。

7.5 删除课程时的级联问题

管理员在后台删除一门已经有学生选课的课程时,Django 默认的on_delete=models.CASCADE会连带把选课记录也删掉。看起来很方便,但真实业务中这是一种高风险行为——学生悄无声息地丢了一门已选课程。我的处理方案是:不在管理后台直接开删除权限,而是给课程加一个“是否开放选课”的状态字段,用停用代替删除。这样历史选课记录被保留,学生也能看到“课程已停开”的提示。如果你确实需要物理删除,一定要先通知或清理相关选课记录,再执行删除。

7.6 给冲突检测写单元测试

时间冲突和并发控制这两块逻辑,是最值得写单元测试的。我给冲突检测写了几个典型用例,确保后续改动不会破坏已有约束:

def test_same_weekday_and_section_conflict(self): # 周一第1-2节 vs 周一第2-3节,冲突 self.assertTrue(times_conflict(t1, t2)) def test_same_weekday_different_section_no_conflict(self): # 周一第1-2节 vs 周一第5-6节,不冲突 self.assertFalse(times_conflict(t1, t2)) def test_same_time_different_week_no_conflict(self): # 单周周三5-6节 vs 双周周三5-6节,不冲突 self.assertFalse(times_conflict(t1, t2))

这类测试写起来成本很低,但能让你在重构时非常有底气。我在把冲突检测从“旧代码”重构成“集合运算版本”时,靠的就是这组用例,改完跑一遍全绿,才敢合入主分支。

选课系统的开发过程中,我最深的体会是:这类项目真正的价值不在框架用的多新、代码量多大,而在于你怎么把看似简单的业务规则翻译成严谨的程序逻辑。容量、冲突、并发、权限,每一个单独拿出来都不算难,但把它们放到同一个流程里还能不出错,这就是实打实的工程能力。最后再分享一个小技巧:在课程列表页用进度条展示每门课的剩余名额,在“我的课表”页面用表格按星期排列出已选课程的时间块,这两个前端小改动,会让整个系统在演示时的观感提升一个档次,而且实现成本非常低。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询