简介:这份资源是一套基于Python开发的选课信息管理系统源码,面向教育机构信息化建设者、计算机专业学生及Python初学者,用于快速搭建学生选课与课程管理平台。压缩包共147个文件,约81.61MB,包含81个dll动态链接库、22个qm翻译文件、14个pyd与9个pyc编译模块、9个py源码,以及2个db数据库、2个jpg界面图片和qrc、ui、spec等资源与工程文件,整体结构完整,兼顾运行依赖与源码可读性。系统围绕学生选课主函数、显示、主界面、查询和教师端等模块展开,数据库文件承载课程表、选课记录与教师信息,界面图片与Qt相关组件共同支撑交互展示。目前已有306人学习下载,适合作为课程设计、毕业设计或教学演示的参考项目,读者可借此理解模块化分层设计、数据持久化与打包发布思路,并在此基础上进行二次开发与功能扩展。
1. 选课系统为什么总在高峰期崩:从标题拆出真实需求
每年选课季,教务系统被挤爆的新闻几乎成了固定节目。学生盯着倒计时狂点刷新,教务老师守着服务器冒汗,最后要么是「系统繁忙请稍后」,要么是某门课超选了几十人。这个标题——基于 Python 的选课信息管理系统设计源码——本质上要解决的就是这类问题:用一套自己能掌控的代码,把课程发布、学生选课、名额扣减、退课补位这条链路跑通。它适合两类人:一是正在做课程设计或毕业设计的同学,需要一个结构完整、能讲清楚设计思路的项目;二是刚学完 Python 基础、想找一个有真实业务逻辑的练手项目的人。选课系统看起来简单,但里面藏着并发扣名额、时间冲突检测、数据一致性这些真问题,比增删改查的图书管理有嚼头得多。热搜里「python入门」「python教程」「源码」这几个词反复出现,说明大量人卡在「知道语法但不知道怎么组织一个完整项目」这一步,这篇就按能复现的路子把它讲透。
2. 技术选型与数据模型:为什么用 Flask + SQLite 起步
2.1 框架选型:Flask、Django 还是 FastAPI
做选课系统,第一件事是选 Web 框架。Django 自带 Admin 和 ORM,功能全但偏重,对课程设计来说很多能力用不上,反而增加理解成本。FastAPI 性能好、异步支持强,但选课系统的瓶颈通常在数据库锁而不是框架吞吐,而且异步写法对新手不友好。Flask 是折中方案:核心小、扩展按需装、路由和视图函数一目了然,出问题容易定位。常见做法是 Flask + Flask-SQLAlchemy 做 ORM + Flask-Login 做会话管理,这套组合在课程设计里足够用,也方便答辩时讲清楚每一层在干什么。
数据库方面,SQLite 零配置、单文件、随项目走,适合开发和演示。但要注意:SQLite 的写操作是库级锁,高并发选课会排队。如果只是课程设计或小规模内部使用,SQLite 完全够;如果要模拟真实抢课压力,建议换成 MySQL 或 PostgreSQL,后面避坑章节会讲迁移时要注意什么。
2.2 数据表设计:四张核心表怎么定
选课系统的数据模型不复杂,但字段设计直接影响后面能不能防住超选。核心四张表:
| 表名 | 关键字段 | 说明 |
|---|---|---|
| student | id, student_no, name, password_hash | 学生信息,student_no 唯一 |
| course | id, course_no, name, teacher, capacity, selected_count, schedule | 课程信息,capacity 是容量上限 |
| enrollment | id, student_id, course_id, created_at | 选课记录,student_id + course_id 联合唯一 |
| course_schedule | id, course_id, weekday, start_slot, end_slot | 上课时间段,用于冲突检测 |
这里有两个设计点值得说。第一,course 表里放 selected_count 冗余字段,而不是每次 count enrollment 表。原因是选课时要快速判断是否满员,实时 count 在并发下既慢又容易读到中间状态。第二,enrollment 表加 (student_id, course_id) 联合唯一索引,从数据库层面防止同一学生重复选同一门课,比在应用层查一遍再插入可靠得多。
2.3 建表与初始化:可复现的代码
下面是用 SQLAlchemy 定义模型并建表的代码,直接可跑:
from flask_sqlalchemy import SQLAlchemy from datetime import datetime db = SQLAlchemy() class Student(db.Model): __tablename__ = 'student' id = db.Column(db.Integer, primary_key=True) student_no = db.Column(db.String(20), unique=True, nullable=False) name = db.Column(db.String(50), nullable=False) password_hash = db.Column(db.String(128), nullable=False) class Course(db.Model): __tablename__ = 'course' id = db.Column(db.Integer, primary_key=True) course_no = db.Column(db.String(20), unique=True, nullable=False) name = db.Column(db.String(100), nullable=False) teacher = db.Column(db.String(50)) capacity = db.Column(db.Integer, default=50) selected_count = db.Column(db.Integer, default=0) # schedule 存 JSON 字符串,如 '[{"weekday":1,"start":1,"end":2}]' schedule = db.Column(db.Text) class Enrollment(db.Model): __tablename__ = 'enrollment' id = db.Column(db.Integer, primary_key=True) student_id = db.Column(db.Integer, db.ForeignKey('student.id'), nullable=False) course_id = db.Column(db.Integer, db.ForeignKey('course.id'), nullable=False) created_at = db.Column(db.DateTime, default=datetime.utcnow) # 联合唯一约束,防止重复选课 __table_args__ = (db.UniqueConstraint('student_id', 'course_id', name='uq_student_course'),)这段代码里,selected_count默认 0,capacity默认 50,实际项目按学校规模调。schedule用 JSON 字符串存是为了简化,如果要做复杂的时间冲突查询,建议拆成独立的 schedule 表,用 weekday + slot 做索引。__table_args__里的联合唯一约束是防重复选课的第一道防线,别省。
初始化时调用db.create_all()即可建表。如果用的是已有数据库,记得先flask db init再 migrate,别直接 create_all 覆盖线上数据。
3. 选课核心逻辑:名额扣减与冲突检测怎么写
3.1 选课流程拆解:五步不能乱
一次选课请求,后端要按顺序做五件事:验证登录态、检查课程是否存在、检查是否已选过、检查时间冲突、扣减名额并写入记录。顺序不能乱,尤其是「检查已选」必须在「扣名额」之前,否则重复请求会把名额扣两次。常见做法是把这几步放在一个数据库事务里,任何一步失败就回滚。
时间冲突检测的逻辑是:取出该学生已选所有课程的 schedule,和新课程的 schedule 逐段比对,看是否有 weekday 相同且时间段重叠的情况。重叠判断用max(start1, start2) < min(end1, end2),这个公式比逐个 slot 比对简洁。
3.2 防超选:乐观锁还是悲观锁
这是选课系统最容易翻车的地方。两个学生同时看到还剩 1 个名额,同时点选课,如果代码是「先查 selected_count < capacity,再 update selected_count + 1」,中间没有锁,两个请求都可能通过检查,最后 selected_count 变成 capacity + 1。
解决办法有两种。悲观锁:在查询课程时加SELECT ... FOR UPDATE,把行锁住,其他请求排队。SQLite 不支持行级锁,MySQL/PostgreSQL 支持。乐观锁:在 course 表加 version 字段,更新时带WHERE version = 旧值,更新影响行数为 0 就说明被别人改了,重试或报错。
课程设计里我一般用乐观锁,因为实现简单、不依赖数据库特性,SQLite 也能跑。下面是对应的更新代码:
def enroll_course(student_id, course_id): # 开启事务 course = Course.query.get(course_id) if not course: return {'ok': False, 'msg': '课程不存在'} # 检查是否已选 exists = Enrollment.query.filter_by(student_id=student_id, course_id=course_id).first() if exists: return {'ok': False, 'msg': '已选过该课程'} # 检查名额 if course.selected_count >= course.capacity: return {'ok': False, 'msg': '名额已满'} # 乐观锁更新:带 version 条件 old_version = course.version updated = db.session.query(Course).filter( Course.id == course_id, Course.version == old_version, Course.selected_count < Course.capacity ).update({ 'selected_count': Course.selected_count + 1, 'version': old_version + 1 }, synchronize_session=False) if updated == 0: db.session.rollback() return {'ok': False, 'msg': '选课冲突,请重试'} # 写入选课记录 db.session.add(Enrollment(student_id=student_id, course_id=course_id)) db.session.commit() return {'ok': True, 'msg': '选课成功'}关键在update那一段:filter里同时带了version == old_version和selected_count < capacity,数据库执行时这两个条件是原子的,只有一个请求能更新成功。updated == 0说明条件不满足,回滚并提示重试。synchronize_session=False是告诉 SQLAlchemy 不要同步会话对象,避免额外查询,性能更好。
参数上,version字段需要在 Course 模型里加上version = db.Column(db.Integer, default=0)。如果不想加字段,也可以用selected_count本身做条件,但 version 更清晰。
3.3 退课与补位:名额怎么还回去
退课逻辑相对简单:删除 enrollment 记录,同时把 course.selected_count 减 1。但要注意两点。第一,减 1 也要用条件更新,防止减到负数:filter(Course.selected_count > 0).update({'selected_count': Course.selected_count - 1})。第二,如果有候补队列,退课时要触发补位。候补队列可以用一张 waitlist 表,按 created_at 排序,退课时取最早的一条自动选上。课程设计里候补不是必须,但加上去答辩会加分。
退课代码:
def drop_course(student_id, course_id): enrollment = Enrollment.query.filter_by(student_id=student_id, course_id=course_id).first() if not enrollment: return {'ok': False, 'msg': '未选该课程'} db.session.delete(enrollment) # 条件更新,防止减到负数 db.session.query(Course).filter( Course.id == course_id, Course.selected_count > 0 ).update({'selected_count': Course.selected_count - 1}, synchronize_session=False) db.session.commit() return {'ok': True, 'msg': '退课成功'}4. 接口与页面:把选课功能接到前端
4.1 路由设计:RESTful 还是传统路由
Flask 里路由写法很自由,但建议按 RESTful 风格组织,方便后面扩展和调试。核心接口:
| 方法 | 路径 | 功能 |
|---|---|---|
| POST | /api/login | 登录 |
| GET | /api/courses | 课程列表(带余量) |
| POST | /api/enroll | 选课 |
| POST | /api/drop | 退课 |
| GET | /api/my-courses | 我的课程 |
返回统一用 JSON,格式{"ok": true/false, "msg": "...", "data": ...}。这样前端处理成功失败逻辑一致,不用每个接口单独判断。
4.2 登录态与权限:别把接口裸奔
选课接口必须校验登录态,否则别人拿到 student_id 就能替人选课。Flask-Login 用 session 存用户 id,配合@login_required装饰器即可。如果做前后端分离,用 JWT 更合适,token 放 header 里。课程设计里 session 够用,但要注意 session 密钥别硬编码在代码里,用环境变量或配置文件。
权限上,学生只能选课退课查自己的课,管理员才能增删课程。用角色字段区分,接口里判断current_user.role。
4.3 前端最小实现:一个页面跑通选课
前端不用太复杂,一个 HTML 页面加 fetch 就能演示。课程列表渲染成表格,每行一个「选课」按钮,点击后调/api/enroll,根据返回的 ok 字段提示成功或失败。关键是失败提示要具体,比如「名额已满」和「时间冲突」要分开,方便调试也方便用户理解。
async function enroll(courseId) { const resp = await fetch('/api/enroll', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({course_id: courseId}) }); const result = await resp.json(); if (result.ok) { alert('选课成功'); location.reload(); } else { alert('选课失败:' + result.msg); } }这段代码里,course_id从按钮的 data 属性取,result.msg直接展示后端返回的原因。注意 fetch 默认不带 cookie,如果后端用 session 鉴权,要加credentials: 'same-origin'。
5. 避坑与排查:选课系统最容易翻车的五个地方
5.1 现象:名额扣了但选课记录没写进去
原因:扣名额和写 enrollment 不在同一个事务里,或者中间抛异常没回滚。解决:把两步放在一个db.session里,用 try/except 包住,异常时db.session.rollback()。Flask-SQLAlchemy 默认请求结束自动提交,但显式 commit 更可控。
5.2 现象:SQLite 下并发选课报 database is locked
原因:SQLite 写操作是库级锁,多个请求同时写会排队,超时后报错。解决:开发阶段可以设SQLALCHEMY_ENGINE_OPTIONS = {'connect_args': {'timeout': 15}}延长等待;生产环境换 MySQL/PostgreSQL。如果坚持用 SQLite,把选课请求串行化,比如用队列或文件锁,但这不是长久之计。
5.3 现象:时间冲突检测漏判,学生选上了撞课的课
原因:schedule 存成 JSON 字符串后,比对逻辑写错,比如只比了 weekday 没比时间段,或者时间段边界处理不对(end 是开区间还是闭区间没统一)。解决:统一用左闭右开区间,重叠判断写成max(start1, start2) < min(end1, end2),并且写单元测试覆盖相邻、包含、交叉三种情况。
5.4 现象:退课后名额没释放,或者释放多了
原因:退课更新 selected_count 时没加selected_count > 0条件,或者并发退课导致减多了。解决:更新语句带条件,并且用updated返回值判断是否真的减了。另外,退课和补位如果涉及候补队列,要在同一事务里完成。
5.5 现象:接口返回 500 但日志里看不到具体错误
原因:Flask 默认把异常吞了,只返回 500。解决:开发时设app.config['DEBUG'] = True,或者注册 errorhandler 把异常堆栈打到日志。生产环境用 logging 模块记录到文件,别只靠 print。
6. 压测与验证:用 locust 模拟抢课看系统扛不扛得住
写完代码不算完,得验证防超选逻辑真的有效。我一般用 locust 做并发压测,模拟 200 个学生同时抢 50 个名额的课,看最后 selected_count 是不是正好 50,enrollment 记录数是不是也是 50。
安装 locust 后写一个简单的压测脚本:
from locust import HttpUser, task, between class EnrollUser(HttpUser): wait_time = between(0.1, 0.5) def on_start(self): # 每个虚拟用户先登录,拿到 session self.client.post('/api/login', json={'student_no': '2024001', 'password': '123456'}) @task def enroll(self): self.client.post('/api/enroll', json={'course_id': 1})跑起来用locust -f locustfile.py --headless -u 200 -r 50 --run-time 30s,-u 200是 200 个并发用户,-r 50是每秒启动 50 个。跑完后查数据库:SELECT selected_count FROM course WHERE id=1和SELECT COUNT(*) FROM enrollment WHERE course_id=1,两个数应该相等且不超过 capacity。如果 selected_count 大于 capacity,说明乐观锁没生效,回去检查 update 语句的条件。
这里有个血泪经验:压测时登录接口也会被压,如果登录逻辑里有 bcrypt 这种慢哈希,会成为瓶颈,导致选课请求还没发出去就超时。解决办法是压测前先批量生成 session,或者把登录接口排除在压测范围外,单独压选课接口。
另一个验证点是时间冲突检测。写一个脚本,给一个学生选两门时间重叠的课,看第二门是否被拒绝。再选两门时间相邻但不重叠的课,看是否都能选上。这两个 case 过了,冲突检测基本没问题。
最后说一个我自己的习惯:每次改完选课逻辑,先跑一遍单元测试,再用 locust 压 30 秒,最后手动点一遍页面。三步都过了才提交代码。选课系统的坑大多在并发和边界条件上,光看代码看不出来,得让它跑起来、压一压才暴露。希望帮到你。
本文还有配套的精品资源,点击获取