基于Python和MySQL的选课系统:tkinter与pymysql实践
2026/9/16 17:23:41 网站建设 项目流程

简介:这是一个基于Python和MySQL的学生选课管理系统期末大作业项目,面向计算机相关专业正在完成课程设计、期末大作业或毕业设计的学生,也适合需要项目实战练习的入门学习者。项目经导师指导并认可,评审分98分,源码均本地编译调试通过,可稳定运行,能直接用于学习或二次开发。资源包共8个文件,包含4个Python源码文件(如登录窗口、课程管理、教师管理等模块)、1个SQL数据库脚本、1份数据库原理报告Word文档、1个README说明文件及1个gitattributes配置,压缩包仅1.11MB,结构紧凑,便于快速下载和查阅。其中SQL脚本按教材表12.1添加学生信息,配套报告内容完整,可帮助理解选课系统的数据库设计与Python实现思路。目前已有86人学习下载,适合作为课设模板或项目参考,性价比高。

1. 为什么期末大作业选课系统要自己从零搭一遍

期末大作业里十个人有八个交的是「图书管理系统」,剩下两个里有一个是「学生选课」。这套基于 Python 和 MySQL 的选课管理系统,少见的地方在于它没有用 Django 或 Flask 这种重框架,而是用原生 tkinter 写的桌面 GUI 程序,配合 pymysql 直连数据库。也就是说,你看到的每一个按钮、每一条 SQL 都是裸写出来的,没有任何框架替你兜底遮丑,导师查重、问原理的时候每一个点都能答得上,这是它最终拿到高分的关键原因。

整个项目围绕三个核心类展开:login_window.py负责登录鉴权和窗口跳转,course_class.py封装选课业务的增删改查,teacher_class.py处理教师端的成绩录入。数据库端按《数据库原理》教材的表 12.1 建 S 表(学生表),再扩展出课程表、选课表和教师表,一共 4 张表构成完整的第三范式结构。难度上比 CRUD 四张表的入门项目高半档,比动不动上 Redis 和微服务的伪需求低得多,恰好卡在课程设计要求的「有业务逻辑、有约束控制、有界面交互」这条线上。

这套项目适合两类人:一是正在做数据库课程设计、需要一份能跑通且能讲明白参考实现的在校生,二是想快速掌握 tkinter + pymysql 这套「最朴素数据库应用栈」的 Python 入门者。文件包里附带完整的《数据库原理》报告文档、建表 SQL 和已调试过的可运行源码,本地装好 MySQL 后改一下连接参数就能跑起来。

2. 数据库端设计:从教材表 12.1 到四张业务表

2.1 为什么选 MySQL 而不是 SQLite 或 Access

课程设计的评审老师关注的不是功能炫不炫,而是你能否把「关系完整性、事务、视图、存储过程」这些数据库原理课的核心概念落到实现里。SQLite 是嵌入式文件数据库,装完即用,但触发器、事务隔离级别、用户权限这些概念不好展开写;Access 更偏向桌面文件型数据库,跑在 Windows 上,和 Python 的连接方式老旧。MySQL 是目前生产环境使用率最高的开源关系型数据库,网上排错资料多,5 年以上经验的人也都绕不开它。

从课程设计的角度,MySQL 8.0 支持窗口函数、CTE(公共表表达式),在写报告时可以多写一章「基于窗口函数的学生选课排名查询」,这是一个很自然的加分点,不需要额外引入其他技术栈。

2.2 实体关系梳理与范式校验

根据业务需求,系统至少要管理四类实体:学生、教师、课程、选课记录。学生和课程之间是多对多关系,必须通过选课表解耦;教师和课程是一对多关系,一门课只有一个主讲教师,一个教师可以带多门课,所以教师的主键直接作为课程表的外键即可。

学生(学号, 姓名, 性别, 年龄, 系别) 教师(工号, 姓名, 职称, 系别) 课程(课程号, 课程名, 学分, 工号) 选课(学号, 课程号, 成绩)

选课(学号, 课程号)作为联合主键,满足第二范式(不存在部分函数依赖)和第三范式(不存在传递函数依赖)。成绩字段允许为空,表示已选课但尚未录入成绩,这是实际业务所必需的。

2.3 建表 SQL 与关键约束说明

文件包里的根据书上表12.1添加S表信息.sql对应教材的 S 表(学生表),我在实际复现时在其基础上补充了另外三张表。以下是最小可运行的完整建表脚本:

-- 学生表(S),对应教材表12.1 CREATE TABLE IF NOT EXISTS S ( sno CHAR(9) PRIMARY KEY, sname VARCHAR(20) NOT NULL, ssex CHAR(2) DEFAULT '男' CHECK (ssex IN ('男', '女')), sage TINYINT, sdept VARCHAR(20) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 教师表(T) CREATE TABLE IF NOT EXISTS T ( tno CHAR(6) PRIMARY KEY, tname VARCHAR(20) NOT NULL, title VARCHAR(10), tdept VARCHAR(20) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 课程表(C) CREATE TABLE IF NOT EXISTS C ( cno CHAR(6) PRIMARY KEY, cname VARCHAR(30) NOT NULL, credit DECIMAL(3,1), tno CHAR(6), FOREIGN KEY (tno) REFERENCES T(tno) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 选课表(SC) CREATE TABLE IF NOT EXISTS SC ( sno CHAR(9), cno CHAR(6), grade DECIMAL(5,1), PRIMARY KEY (sno, cno), FOREIGN KEY (sno) REFERENCES S(sno) ON DELETE CASCADE, FOREIGN KEY (cno) REFERENCES C(cno) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这里有几个关键设计决策值得解释。CHAR定长字符串用于学号、工号等固定长度字段,MySQL 对CHAR的检索速度优于VARCHAR,且学号前导零不会被截断;sageTINYINT而不是INT,节省存储空间,范围 0-255 足够;成绩字段用DECIMAL(5,1)而不是FLOAT,因为FLOAT的二进制浮点表示会导致 89.5 这类小数出现精度误差,而DECIMAL是字符串存储的定点数,计算成绩总分和平均分时不会出错。

外键约束上,课程表的tno外键设ON DELETE SET NULL,意思是教师离职后被删除,课程信息保留但主讲教师为空;选课表的两个外键都设ON DELETE CASCADE,即学生退学、课程取消时,对应的选课记录自动连带删除,避免出现「选了课但课没了」的脏数据。

2.4 初始化数据的正确姿势

根据书上表12.1添加S表信息.sql文件里包含了教材上的示例数据,也就是:《数据库原理》教材 12.1 节用到的那批学生记录(比如学号201215121的李勇、201215122的刘晨等经典样例数据)。实际运行时要先执行建表脚本,再执行数据插入脚本。插入数据时注意顺序:先插 S、T 表,再插 C 表(因为 C 表依赖 T 表的主键),最后插 SC 表,否则会违反外键约束直接报Cannot add or update a child row错误。

3. Python 端模块划分与 tkinter + pymysql 核心实现

3.1 为什么用 tkinter 而非 PyQt 或 Web 前端

选择 tkinter 不是因为它强大,而是因为它足够「裸」。tkinter 是 Python 标准库自带的 GUI 工具包,不需要额外安装,这在期末大作业答辩时有两点实际意义:第一,演示环境往往不是你自己配好的机器,可能是老师办公室的电脑或者机房机器,tkinter 省去了 PyQt5 那几百 MB 的依赖安装;第二,tkinter 的事件循环、控件变量绑定(StringVar/IntVar)机制能直观体现「事件驱动」的 GUI 编程思想,这在课程报告里可以展开写半页。

PyQt5 虽然界面更现代、控件更丰富,但对选课系统这种表单密集型应用来说是杀鸡用牛刀。你在课程设计的场景里只需要窗口、标签、输入框、按钮、表格(Treeview)、下拉框这几个控件,tkinter 足够覆盖。

3.2 数据库连接的正确封装方式

整个系统最容易被扣分的地方就是数据库连接。很多大作业的代码会在每个按钮事件里重复写pymysql.connect(...),连接用完不关,最后报Too many connections。我拆这个项目时重构了连接逻辑,用一个独立的数据库工具类管理连接:

# db_util.py import pymysql from dbutils.pooled_db import PooledDB POOL = PooledDB( creator=pymysql, maxconnections=10, mincached=2, maxcached=5, blocking=True, host='localhost', port=3306, user='root', password='123456', database='course_select', charset='utf8mb4', cursorclass=pymysql.cursors.DictCursor ) def query(sql, args=None): conn = POOL.connection() try: with conn.cursor() as cursor: cursor.execute(sql, args) return cursor.fetchall() finally: conn.close() def execute(sql, args=None): conn = POOL.connection() try: with conn.cursor() as cursor: rows = cursor.execute(sql, args) conn.commit() return rows except Exception: conn.rollback() raise finally: conn.close()

这段代码的逻辑重点在PooledDB连接池:maxconnections=10限制最大连接数,mincached=2表示启动时预先创建 2 个空闲连接,blocking=True表示连接池用尽后请求方阻塞等待而不是直接抛异常。查询操作走with语法确保游标释放,写操作(INSERT/UPDATE/DELETE)需要commit()提交事务,异常时rollback()回滚。

cursorclass=pymysql.cursors.DictCursor这个参数值得特别说明。默认的游标返回元组,取数据时要用row[0]按下标访问,代码可读性差且容易越界;改成DictCursor后每行是字典,可以通过row['sname']访问字段,代码维护成本降低一个量级。如果连接池不是自己搭的而是逐条connect(),那每个连接都需要手动conn.close(),一旦在业务逻辑里提前return就很容易漏关连接。

3.3 course_class.py 选课核心业务类

选课业务逻辑封装在course_class.py中,它对外暴露学生选课、退课、查看已选课程、查看可选课程四个方法。核心选课方法要考虑三重约束:课程容量、重复选课、选课时间窗口。

# course_class.py import pymysql from db_util import execute, query class CourseManager: # 学生选课 def select_course(self, sno, cno): # 1. 检查是否已选过该课程 selected = query("SELECT 1 FROM SC WHERE sno=%s AND cno=%s", (sno, cno)) if selected: raise ValueError("不能重复选课") # 2. 检查课程容量是否已满(假设 C 表有 capacity 字段) row = query( "SELECT capacity, (SELECT COUNT(*) FROM SC WHERE cno=%s) AS selected_num " "FROM C WHERE cno=%s", (cno, cno) ) if not row: raise ValueError("课程不存在") if row[0]['selected_num'] >= row[0]['capacity']: raise ValueError("课程容量已满") # 3. 执行插入,利用联合主键兜底并发重复 return execute( "INSERT INTO SC(sno, cno) VALUES(%s, %s) ON DUPLICATE KEY UPDATE cno=cno", (sno, cno) ) # 学生退课 def drop_course(self, sno, cno): return execute("DELETE FROM SC WHERE sno=%s AND cno=%s", (sno, cno)) # 查已选课程及成绩 def get_selected_courses(self, sno): sql = """ SELECT C.cno, C.cname, C.credit, SC.grade FROM SC JOIN C ON SC.cno = C.cno WHERE SC.sno = %s """ return query(sql, (sno,))

选课的三个步骤按业务优先级排序:先查重复再查容量最后落库。SQL 全部使用%s参数占位符而不是字符串拼接,这是防止 SQL 注入的第一道防线。ON DUPLICATE KEY UPDATE cno=cno这行是并发兜底——如果两个窗口同时提交同一学号同一课程号的选课请求,联合主键冲突会被这个语句吞掉而不是抛异常,实际效果等同于忽略重复插入。

SELECT 1而不是SELECT *是一个性能习惯:只需要知道「是否存在」,不需要取出整行数据,MySQL 碰到SELECT 1且命中主键索引时可以直接走索引覆盖扫描,不做回表查询,在大数据量下性能差距显著。

4. 登录窗口到业务流转:从 login_window.py 跑通全流程

4.1 登录窗口的三态设计(学生 / 教师 / 管理员)

login_window.py是系统的入口文件,它的设计决定了用户对系统的第一印象。课程设计里最常见的错误是登录窗口只有一个「用户名+密码」输入框,登录进去后所有功能平铺展开,不管你是学生还是老师,权限完全一样。这套系统把登录角色做成下拉框选择,共三种态:学生、教师、管理员,登录后跳转到不同的主窗口。

角色权限拆分是数据库课程设计答辩时高频被问的点,设计上按「最小权限原则」来:学生只能查自己的成绩和选课,教师只能看自己教的课程并录入成绩,管理员拥有全部权限。这一点在数据库层面还要配合视图来实现——后面第 5 章会讲。

登录窗口的按钮事件绑定和回车提交是 tkinter 里最容易出问题的两个点:

# login_window.py import tkinter as tk from tkinter import messagebox, ttk from db_util import query LOGIN_SQL = { 'student': "SELECT sno, sname FROM S WHERE sno=%s AND sname=%s", 'teacher': "SELECT tno, tname FROM T WHERE tno=%s AND tname=%s", 'admin': "SELECT 1 FROM admin WHERE username=%s AND password=%s" } def do_login(role_var, user_var, pwd_var, win): role = role_var.get() username = user_var.get().strip() password = pwd_var.get().strip() if not username or not password: messagebox.showwarning("输入错误", "用户名和密码不能为空") return sql = LOGIN_SQL[role] result = query(sql, (username, password)) if result: win.destroy() if role == 'student': open_student_window(result[0]['sno']) elif role == 'teacher': open_teacher_window(result[0]['tno']) else: open_admin_window() else: messagebox.showerror("登录失败", "账号或密码错误,请重试") # 绑定回车键提交 def bind_enter(entry, callback): entry.bind('<Return>', lambda event: callback())

不同角色查不同的表,登录 SQL 直接写在映射字典里维护,后续扩展角色(比如加一个助教角色)只需要增加一行配置和一个窗口跳转分支。.strip()去掉输入框首尾空格是个小细节,但经常被忽略——用户在输入框里复制账号时如果带了换行或空格,不 strip 就会「明明账号正确却登录失败」。

open_student_window(result[0]['sno'])登录成功后只传递学号,不在登录窗口对象里持有整个用户字典,避免后续子窗口把密码之类的敏感字段带到内存的其他角落。窗口销毁用win.destroy()而不是win.withdraw(),前者释放内存,后者只是隐藏窗口。

4.2 学生主窗口:选课流程的完整界面闭环

学生登录后进入选课主窗口,界面布局一般用tk.Frame分成两个区域:左侧是「已选课程」表格,右侧是「全部可选课程」表格,底部是「选课」「退课」「刷新」三个按钮。用ttk.Treeview做表格展示时,需要为每一列指定columnheading属性:

# student_window.py import tkinter as tk from tkinter import ttk from course_class import CourseManager from db_util import query class StudentWindow: def __init__(self, root, sno): self.manager = CourseManager() self.sno = sno # 已选课程表 self.selected_tree = ttk.Treeview(root, columns=('cno', 'cname', 'credit', 'grade'), show='headings', height=10) for col, title in [('cno', '课程号'), ('cname', '课程名'), ('credit', '学分'), ('grade', '成绩')]: self.selected_tree.heading(col, text=title) self.selected_tree.column(col, width=100, anchor='center') self.selected_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) # 给表格绑定双击事件:双击已选课程行 = 触发退课 self.selected_tree.bind('<Double-1>', self._on_double_click_drop) def _on_double_click_drop(self, event): item = self.selected_tree.selection()[0] cno = self.selected_tree.item(item, 'values')[0] self.manager.drop_course(self.sno, cno) self.refresh()

show='headings'参数让表格只显示列头,不显示最左侧的树形列;anchor='center'让每列内容居中,视觉效果比默认的左对齐好很多。双击退课是一个容易被忽略的交互细节,很多课程设计只做了按钮退课,用户必须先用鼠标选中一行再去点退课按钮;加上Double-1事件绑定后,双击行直接退课,交互流畅度提升明显。

4.3 教师端成绩录入的批处理优化

教师端功能相对简单,核心是查看「自己教的课」有哪些学生选,然后录入成绩。通常用一个下拉框选择课程,下面表格列出选课学生。成绩录入如果一次只能录一个学生,几十个学生的课操作起来非常痛苦,我一般会在表格里加一列「成绩输入框」,用tk.Entry放进每个单元格,教师填完后点「批量保存」一次性提交所有成绩:

-- 批量更新成绩:使用 CASE WHEN 语法一次更新多条记录 UPDATE SC SET grade = CASE WHEN sno = '201215121' AND cno = 'CS101' THEN 95.5 WHEN sno = '201215122' AND cno = 'CS101' THEN 88.0 WHEN sno = '201215123' AND cno = 'CS101' THEN 76.5 ELSE grade END WHERE cno = 'CS101' AND sno IN ('201215121', '201215122', '201215123');

逐条UPDATE需要发起 N 次网络往返,批量CASE WHEN语法一次搞定。MySQL 的CASE表达式在SET子句中按行匹配,匹配到对应WHEN分支就更新成指定值,ELSE grade保证不在列表内的行保持不变。会话层面开启事务可以保证这批更新的原子性:要么全部提交、要么全部回滚。

5. 报告与答辩:视图、存储过程加索引的实战验证

5.1 用视图收敛权限,答辩问不倒

课程设计报告里最值得写也最容易加分的部分是数据库安全性设计。直接用表的系统在答辩时会暴露一个问题:学生端的 Python 代码用的是一个高权限数据库账号,理论上学生可以绕过界面直接改自己的成绩。通过 MySQL 视图把学生可见的数据范围收敛,是一个能从「开发能力」上升到「设计能力」的亮点。

-- 学生成绩查询视图:学生只能看到自己本人在选课表里的记录 CREATE OR REPLACE VIEW v_student_grade AS SELECT S.sno, S.sname, C.cname, C.credit, SC.grade FROM S JOIN SC ON S.sno = SC.sno JOIN C ON SC.cno = C.cno WITH CHECK OPTION; -- 教师授课视图:教师只能看到与自己工号匹配的课程和选课学生 CREATE OR REPLACE VIEW v_teacher_teach AS SELECT T.tno, T.tname, C.cno, C.cname, SC.sno, S.sname FROM T JOIN C ON T.tno = C.tno JOIN SC ON C.cno = SC.cno JOIN S ON SC.sno = S.sno WHERE T.tno = SUBSTRING_INDEX(CURRENT_USER(), '@', 1);

WITH CHECK OPTION表示通过视图插入或更新的数据必须满足视图定义中的WHERE条件,这是视图安全性的关键。第二个视图的WHERE T.tno = SUBSTRING_INDEX(CURRENT_USER(), '@', 1)是视图自动按当前登录数据库用户(即tno)过滤数据的写法,这样教师执行SELECT * FROM v_teacher_teach时就天然只能看到自己的数据,无需在 SQL 里手动传教师工号。

5.2 存储过程跑通选课事务

选课操作的原子性可以通过存储过程在数据库端兜底。学生选课是一个「先查后插」的组合操作,Python 端先发起 SELECT 再发起 INSERT,两次交互之间如果恰好有并发请求,就会出现「两个人都查到课还没选满,同时插入都成功」的超出容量问题。存储过程把这两步放到数据库服务端执行,配合FOR UPDATE行锁在事务内锁定课程记录:

DELIMITER // CREATE PROCEDURE sp_select_course(IN p_sno CHAR(9), IN p_cno CHAR(6), OUT p_msg VARCHAR(50)) BEGIN DECLARE v_capacity INT; DECLARE v_selected INT; START TRANSACTION; -- 锁定课程行,防止并发选课超员 SELECT capacity INTO v_capacity FROM C WHERE cno = p_cno FOR UPDATE; IF v_capacity IS NULL THEN SET p_msg = '课程不存在'; ROLLBACK; ELSE SELECT COUNT(*) INTO v_selected FROM SC WHERE cno = p_cno; IF v_selected >= v_capacity THEN SET p_msg = '课程容量已满'; ROLLBACK; ELSE IF EXISTS (SELECT 1 FROM SC WHERE sno = p_sno AND cno = p_cno) THEN SET p_msg = '不可重复选课'; ROLLBACK; ELSE INSERT INTO SC(sno, cno) VALUES (p_sno, p_cno); SET p_msg = '选课成功'; COMMIT; END IF; END IF; END IF; END// DELIMITER ;

SELECT ... FOR UPDATE是 InnoDB 引擎的行级排他锁,锁住课程行后,其他事务对该行的SELECT ... FOR UPDATE操作会阻塞等待,直到本事务提交或回滚释放锁。这是典型的「悲观锁」思路,在并发量不高(课程设计的场景)时比乐观锁(版本号+重试)实现简单且可靠。OUT p_msg参数把执行结果以字符串返回给 Python 端,Python 端调用后直接弹窗提示,不需要再查一次数据库确认结果。

Python 端调用存储过程的方式很简单。

# 调用存储过程选课 import pymysql conn = pymysql.connect(host='localhost', user='root', password='123456', database='course_select') with conn.cursor() as cursor: cursor.callproc('sp_select_course', ('201215121', 'CS101', '')) results = cursor.fetchone() # 获取 OUT 参数 cursor.execute("SELECT @_sp_select_course_2") msg = cursor.fetchone()[0] conn.close() print(msg)

cursor.callproc('sp_select_course', ...)调用完成后,第三个 OUT 参数被存储到@_sp_select_course_2(前缀固定是@_存储过程名_第几个参数,从 0 开始计数),需要再执行一条 SELECT 才能读出来。这一步是很多人在调用存储过程时卡住的地方——fetchone()拿到的是存储过程内部 SELECT 的最后一个结果集,而不是 OUT 参数值。

5.3 索引验证:用 EXPLAIN 证明设计合理性

报告里写「我建了索引」不算数,要给出验证过程。MySQL 的EXPLAIN命令可以查看 SQL 的执行计划,重点看typerows两个字段。type从好到差依次是system > const > eq_ref > ref > range > index > ALLALL全表扫描是要避免的。

-- 查看选课查询的执行计划 EXPLAIN SELECT S.sname, C.cname, SC.grade FROM SC JOIN S ON SC.sno = S.sno JOIN C ON SC.cno = C.cno WHERE SC.sno = '201215121';

由于snocno在 SC 表中是联合主键,MySQL 会直接走主键索引找到对应选课记录,type显示为constrows为 1,说明查询只扫描了一行——这是最优情况。如果WHERE条件用的是S.sdept这种非索引列,type会退化为ALLrows会显示全表行数,这就是明显的性能隐患。把这个 EXPLAIN 结果截图放进报告,比写三页理论分析都有说服力。

5.4 运行前必查的三个环境问题

MySQL 8.0 的认证插件默认是caching_sha2_password,而 pymysql 旧版本只支持mysql_native_password。如果代码连接时报Authentication plugin 'caching_sha2_password' cannot be loaded,需要执行:

ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY '123456'; FLUSH PRIVILEGES;

另外两个高频坑,一个是连接 URL 里database=course_select必须与建表脚本中的库名完全一致,MySQL 对大小写敏感度取决于操作系统配置;另一个是建表时ENGINE=InnoDB不能省略,MyISAM 引擎不支持外键约束和外键级联删除,如果用的是默认 MyISAM,建表脚本里那三条 FOREIGN KEY 会直接报错跳过。跑通之前,SELECT VERSION()确认 MySQL 客户端与服务端版本一致,能省掉很多莫名其妙的兼容性报错。

本文还有配套的精品资源,点击获取

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

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

立即咨询