☰
Qt+MySQL教务系统实战:防超卖选课事务与三权分立架构设计
2026/9/26 14:25:33 网站建设 项目流程

简介:这是一份面向计算机相关专业毕业设计场景的教务系统完整源码包,采用Qt框架开发、MySQL作为后台数据库,围绕学生、教师、管理员三类身份构建了登录、选课、成绩与信息管理等核心业务模块,适合正在准备毕设或需要Qt桌面项目练手的同学参考与二次开发。压缩包共34个文件,约97KB,以cpp源文件与h头文件为主体,配合ui界面文件、qrc资源文件、pro工程文件及sql建库脚本,另附README说明与少量图片素材,结构清晰、便于按模块阅读。项目源码均经本地编译验证,按文档配置好Qt与MySQL环境即可运行,难度适中,内容经过助教老师审定,能够满足学习与使用需求。目前已有258人学习关注,下载后遇到问题也可私信作者获取解答,对需要完整毕设方案与排错思路的读者具有一定参考价值。

1. 从一张课程表说起:Qt+MySQL 教务系统到底要解决什么

选课季的机房总有人盯着屏幕骂人:课程余量显示还有 3 个,点进去提交却提示已满;教师那边成绩录到一半,客户端直接闪退,重开发现录了半截的数据全没了。这类问题的根子往往不在界面,而在「界面和数据库之间那层逻辑」没设计好。基于 Qt 的教务系统设计与实现,配上 MySQL 数据库,身份分学生、教师、管理员三种,本质就是做一个带权限控制的桌面端增删改查系统,把选课、成绩、排课、用户管理这些事务用一套可靠的数据流串起来。

它适合谁?正在做数据库课程设计或毕设的本科生,需要一份能跑起来、能答辩、能讲清楚架构的项目;也适合刚接触 Qt 桌面开发、想找一个完整业务场景练手的工程师。难点从来不是「怎么连数据库」,而是并发选课怎么不超卖、三种角色的权限怎么在代码层收口、Qt 的信号槽怎么和 SQL 事务配合。下面按「先立住架构、再动手复现、最后讲坑」的顺序拆开讲,代码和参数都能直接抄。

2. 架构先立住:三张核心表怎么撑起三种身份

2.1 为什么用 Qt 客户端 + MySQL 服务端,而不是单机 SQLite

很多人第一反应是「毕设而已,用 SQLite 单文件多省事」。但教务系统的核心矛盾是多客户端并发写同一份数据:几十个学生同时抢一门课,教师同时提交成绩,管理员同时改培养方案。SQLite 的写锁是库级的,一个写事务会阻塞其他所有写操作,选课高峰期直接卡死。MySQL 支持行级锁和真正的事务隔离,配合 InnoDB 引擎能把「查余量—扣余量—写选课记录」放进一个事务里,这是 SQLite 给不了的。

Qt 这边用QSqlDatabase+QSqlQuery走 MySQL 驱动,界面层用QTableView绑QSqlTableModel或自定义QAbstractTableModel。选型上,如果只是展示和简单编辑,QSqlTableModel省事;但选课这种带业务校验的操作,必须绕开模型直接写QSqlQuery并手动transaction(),否则模型会自动提交,事务控制不住。

提示:Qt 官方发行版默认不带 MySQL 驱动,需要自己编译qsqlmysql插件,或者用已编译好的libqsqlmysql.so/qsqlmysql.dll。这是新手第一个翻车点,后面避坑章细说。

2.2 三张核心表 + 两张关联表的最小 schema

身份分三种,但不要建三张用户表。正确做法是一张user表存公共字段,用role字段区分,再各自挂扩展表。核心表设计如下:

表名作用关键字段说明
user所有账号id, username, password_hash, role, real_namerole 取值 student/teacher/admin
course课程id, course_code, name, teacher_id, capacity, enrolled, creditenrolled 是已选人数,冗余字段
course_selection选课记录id, student_id, course_id, select_time, statusstatus 区分正常/退选
score成绩id, student_id, course_id, score, term与选课记录分离,便于补考
schedule排课id, course_id, weekday, start_slot, end_slot, classroom冲突检测靠它

course.enrolled是冗余字段,有人会问「为什么不每次COUNT(*)算」。因为选课事务里要SELECT ... FOR UPDATE锁住课程行再判断余量,如果每次都去 count 选课表,锁的范围和性能都不可控。冗余一个计数字段,在同一个事务里UPDATE course SET enrolled = enrolled + 1 WHERE id = ? AND enrolled < capacity,靠这条带条件的 UPDATE 的 affected rows 是否为 1 来判断选课成功,是最稳的防超卖写法。

2.3 权限控制放在哪一层:别只藏按钮

新手最容易犯的错是「学生登录后把管理按钮setVisible(false)」就以为做了权限。用户拿个 Qt Designer 改不了二进制,但随便一个 SQL 注入或者直接调接口就绕过去了。权限必须落在数据访问层:每个角色的数据库操作封装成独立的 DAO 类,学生 DAO 里根本没有「删除用户」这个方法,从代码结构上就杜绝越权。

// 权限收口示例:不同角色拿到不同的 DAO 接口 class IUserDao { public: virtual ~IUserDao() = default; virtual QList<User> listUsers() = 0; }; class AdminUserDao : public IUserDao { public: QList<User> listUsers() override { QSqlQuery q; q.exec("SELECT id, username, role, real_name FROM user"); // 管理员才能看到全部用户 ... } bool deleteUser(int id); // 只有管理员 DAO 有删除 }; class StudentUserDao : public IUserDao { public: QList<User> listUsers() override { // 学生只能看到自己 QSqlQuery q; q.prepare("SELECT id, username, role, real_name FROM user WHERE id = ?"); q.addBindValue(currentUserId_); ... } // 没有 deleteUser,编译期就挡住了 };

逻辑说明:用抽象接口 + 角色实现类,把「能做什么」固化在类型系统里,而不是运行时 if 判断。参数上currentUserId_在登录成功后注入,所有查询强制带这个条件。这样即使界面层出 bug 把按钮露出来,点下去也只会查到自己。

注意:密码绝对不要明文存。用QCryptographicHash::hash(pwd + salt, QCryptographicHash::Sha256)加盐哈希,salt 每个用户随机生成存库。毕设答辩时老师十有八九会问这个。

3. 动手复现:从建库到选课事务跑通

3.1 MySQL 建库建表与初始数据脚本

先确保 MySQL 8.x 装好、服务起来。命令行登录后执行下面这段,建库、建表、插一条管理员账号。字符集用utf8mb4,否则学生姓名里的生僻字会变问号。

CREATE DATABASE edu_sys DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE edu_sys; CREATE TABLE user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password_hash CHAR(64) NOT NULL, salt CHAR(16) NOT NULL, role ENUM('student','teacher','admin') NOT NULL, real_name VARCHAR(50), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB; CREATE TABLE course ( id INT PRIMARY KEY AUTO_INCREMENT, course_code VARCHAR(20) NOT NULL UNIQUE, name VARCHAR(100) NOT NULL, teacher_id INT, capacity INT NOT NULL DEFAULT 50, enrolled INT NOT NULL DEFAULT 0, credit DECIMAL(3,1) DEFAULT 2.0, FOREIGN KEY (teacher_id) REFERENCES user(id) ) ENGINE=InnoDB; CREATE TABLE course_selection ( id INT PRIMARY KEY AUTO_INCREMENT, student_id INT NOT NULL, course_id INT NOT NULL, select_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, status TINYINT DEFAULT 1, -- 1 正常 0 已退选 UNIQUE KEY uk_stu_course (student_id, course_id), FOREIGN KEY (student_id) REFERENCES user(id), FOREIGN KEY (course_id) REFERENCES course(id) ) ENGINE=InnoDB; -- 初始管理员,密码是 123456 的 sha256 加盐示例(实际由程序生成) INSERT INTO user (username, password_hash, salt, role, real_name) VALUES ('admin', 'e10adc3949ba59abbe56e057f20f883e', 'abcdef1234567890', 'admin', '系统管理员');

参数说明:uk_stu_course唯一索引是关键,它保证同一个学生不能对同一门课插两条记录,即使程序逻辑漏了判断,数据库层也会拒绝。enrolled默认 0,capacity默认 50,这两个值在选课事务里配合使用。status用 TINYINT 而不是布尔,方便以后扩展「待审核」等状态。

3.2 Qt 连接 MySQL 与驱动加载的完整代码

连接代码要处理三件事:加载驱动、设置连接参数、检查错误。很多人卡在「QMYSQL driver not loaded」,先看下面代码里的错误打印。

#include <QSqlDatabase> #include <QSqlError> #include <QDebug> bool initDatabase() { QSqlDatabase db = QSqlDatabase::addDatabase("QMYSQL"); db.setHostName("127.0.0.1"); db.setPort(3306); db.setDatabaseName("edu_sys"); db.setUserName("root"); db.setPassword("your_password"); // 关键:设置连接选项,避免中文乱码和超时 db.setConnectOptions("MYSQL_OPT_CONNECT_TIMEOUT=10;MYSQL_OPT_RECONNECT=1"); if (!db.open()) { qCritical() << "DB open failed:" << db.lastError().text(); qCritical() << "Available drivers:" << QSqlDatabase::drivers(); return false; } qDebug() << "MySQL connected, driver:" << db.driverName(); return true; }

逻辑说明:addDatabase("QMYSQL")里的字符串必须和已安装的驱动名完全一致,用QSqlDatabase::drivers()打印出来核对。setConnectOptions里MYSQL_OPT_RECONNECT=1让连接断开后自动重连,避免长时间空闲后第一次操作失败。如果open()失败,lastError().text()会给出具体原因,比如密码错、库不存在、socket 路径不对。

参数说明:127.0.0.1比localhost更可靠,因为localhost在 MySQL 里可能走 Unix socket 而不是 TCP,报错信息会变成Can't connect to local MySQL server through socket。端口默认 3306,如果改过要同步。生产环境别用 root,建一个只有edu_sys权限的专用账号。

3.3 选课事务:防超卖的核心 SQL 与 Qt 事务封装

这是整个系统最值得写进论文的一节。选课要同时做三件事:判断余量、扣减余量、插入选课记录。三步必须原子。

bool selectCourse(int studentId, int courseId) { QSqlDatabase db = QSqlDatabase::database(); if (!db.transaction()) { // 开启事务 qWarning() << "begin transaction failed"; return false; } QSqlQuery q(db); // 第一步:带条件的原子扣减,affected rows 为 1 才算抢到 q.prepare("UPDATE course SET enrolled = enrolled + 1 " "WHERE id = ? AND enrolled < capacity"); q.addBindValue(courseId); if (!q.exec() || q.numRowsAffected() != 1) { db.rollback(); qWarning() << "course full or not exist"; return false; // 余量为 0 或课程不存在 } // 第二步:插入选课记录,唯一索引兜底防重复选 q.prepare("INSERT INTO course_selection (student_id, course_id) VALUES (?, ?)"); q.addBindValue(studentId); q.addBindValue(courseId); if (!q.exec()) { db.rollback(); // 重复选课会触发唯一索引冲突 qWarning() << "insert selection failed:" << q.lastError().text(); return false; } if (!db.commit()) { // 提交 db.rollback(); return false; } return true; }

逻辑说明:核心是第一条 UPDATE 的WHERE enrolled < capacity。数据库在执行这条语句时会锁住该课程行,判断和自增在一条语句里完成,天然原子。如果两个学生同时抢最后一个名额,InnoDB 行锁会让它们串行执行,第二个的enrolled < capacity不成立,numRowsAffected()返回 0,直接回滚。这比「先 SELECT 查余量再 UPDATE」可靠得多,后者在两步之间有竞态窗口。

参数说明:numRowsAffected()在 UPDATE 里返回实际修改的行数,条件不满足时是 0。db.transaction()和db.commit()必须成对,任何一步失败都要rollback(),否则连接会一直挂着未提交事务,后续操作全被阻塞。退选就是反向操作:UPDATE course SET enrolled = enrolled - 1 WHERE id = ? AND enrolled > 0,再把选课记录status置 0。

3.4 用 QSqlTableModel 快速搭出成绩录入界面

成绩录入是教师端高频操作,用QSqlTableModel能省掉大量样板代码,但要注意它默认自动提交。

void TeacherScoreWidget::setupModel() { model_ = new QSqlTableModel(this, QSqlDatabase::database()); model_->setTable("score"); // 只显示本教师所授课程的成绩,用 filter 收口 model_->setFilter(QString("course_id IN " "(SELECT id FROM course WHERE teacher_id = %1)").arg(currentTeacherId_)); model_->setEditStrategy(QSqlTableModel::OnManualSubmit); // 手动提交 model_->select(); ui->tableView->setModel(model_); ui->tableView->setSelectionBehavior(QAbstractItemView::SelectRows); // 隐藏主键列 ui->tableView->hideColumn(0); } void TeacherScoreWidget::onSaveClicked() { if (model_->submitAll()) { model_->database().commit(); } else { QMessageBox::warning(this, "保存失败", model_->lastError().text()); model_->revertAll(); // 回滚未提交的修改 } }

逻辑说明:setEditStrategy(OnManualSubmit)让所有编辑先缓存在模型里,点保存才一次性提交,避免改一格就写一次库。setFilter用子查询把教师权限收口到「自己教的课」,这是数据层权限的又一体现。submitAll()失败时revertAll()把界面恢复成数据库里的值,防止用户以为改成功了。

参数说明:currentTeacherId_登录时注入。hideColumn(0)隐藏自增主键,防止用户误改。如果成绩表数据量大,setFilter的子查询会拖慢,可以改成先查出课程 id 列表再拼IN (...)。

4. 避坑与排查:那些让答辩当场卡壳的问题

4.1 QMYSQL driver not loaded:驱动缺失或版本不匹配

现象:程序启动连库时报QMYSQL driver not loaded,QSqlDatabase::drivers()里根本没有 QMYSQL。

原因:Qt 官方安装包出于许可考虑不带 MySQL 驱动,需要自己编译qsqlmysql插件,或者驱动版本和 Qt 版本、MySQL 客户端库版本对不上。热词里那个cannot mix incompatible qt library也是同类问题,混用了不同版本编译的库。

解决:先确认sqldrivers目录下有没有qsqlmysql.dll(Windows)或libqsqlmysql.so(Linux)。没有就用qmake编译 Qt 源码里qtbase/src/plugins/sqldrivers/mysql,编译时指定 MySQL 的 include 和 lib 路径。编译出来的插件必须放到 Qt 安装目录的plugins/sqldrivers下,且依赖的libmysqlclient要在系统库路径里。Linux 下用ldd libqsqlmysql.so检查依赖是否齐全。

4.2 中文乱码:连接字符集没设对

现象:学生姓名、课程名在界面显示成问号或方块。

原因:MySQL 服务端、库、表、连接四层字符集不一致。常见是库建成了latin1,或者 Qt 连接没声明utf8mb4。

解决:建库时DEFAULT CHARACTER SET utf8mb4,连接后立刻执行SET NAMES utf8mb4。Qt 侧在setConnectOptions里加MYSQL_OPT_CONNECT_TIMEOUT之外,可以在open()成功后跑一条QSqlQuery("SET NAMES utf8mb4")。源文件保存为 UTF-8,QString到QByteArray转换用toUtf8()而不是toLatin1()。

4.3 选课超卖:先查后改的经典竞态

现象:课程容量 50,最后显示选了 51 个人。

原因:代码写成「先SELECT enrolled, capacity判断,再UPDATE enrolled = enrolled + 1」,两步之间其他事务插进来,都读到 49 然后各自加 1。

解决:用 3.3 节那种带条件的原子 UPDATE,把判断和修改合并成一条语句,靠numRowsAffected()判断结果。或者用SELECT ... FOR UPDATE显式加行锁,但要注意锁的范围和死锁风险,不如条件 UPDATE 简洁。

4.4 事务没提交导致后续操作全阻塞

现象:某次选课失败后,其他所有数据库操作都卡住,界面无响应。

原因:db.transaction()开启后,某条分支return false前忘了rollback(),未提交事务一直占着行锁,其他连接等锁等到超时。

解决:用 RAII 封装事务,或者严格保证每个return前都有rollback()。更稳的写法是写一个TransactionGuard类,析构时如果没提交就自动回滚。排查时在 MySQL 里执行SHOW ENGINE INNODB STATUS看有没有长时间运行的事务,或者SELECT * FROM information_schema.innodb_trx。

4.5 密码明文与 SQL 注入:答辩必问的两个安全点

现象:老师翻代码看到WHERE username = '" + name + "'这种拼接,直接问「这能不能注入」。

原因:用字符串拼接构造 SQL,用户输入' OR '1'='1就能绕过登录。

解决:所有带用户输入的查询一律用QSqlQuery::prepare+addBindValue参数绑定,绝不拼接。密码存QCryptographicHash的 SHA256 加盐哈希,登录时把用户输入同样哈希后比对。这两点做到,安全相关的追问基本能接住。

5. 让系统更像「真教务」:两个进阶技巧与验证方法

基础功能跑通后,答辩想拿高分得有点超出预期的设计。第一个技巧是排课冲突检测。教师排课时,同一教室同一时间段不能有两门课,同一班级同一时间段也不能冲突。用一条 SQL 就能查出来:

-- 检测某教室在某时间段是否已被占用 SELECT COUNT(*) FROM schedule WHERE classroom = ? AND weekday = ? AND start_slot < ? AND end_slot > ?; -- 区间重叠判断

区间重叠的判断条件是已有.start < 新.end AND 已有.end > 新.start,这是经典写法,比逐个 slot 比对高效。插入排课前先跑这条,返回大于 0 就提示冲突。班级冲突同理,把classroom换成class_id即可。

第二个技巧是用视图给管理员做统计看板。与其在 Qt 里写一堆循环算选课率,不如在数据库建视图:

CREATE VIEW v_course_stat AS SELECT c.id, c.name, c.capacity, c.enrolled, ROUND(c.enrolled / c.capacity * 100, 1) AS fill_rate, u.real_name AS teacher_name FROM course c LEFT JOIN user u ON c.teacher_id = u.id;

管理员界面直接SELECT * FROM v_course_stat ORDER BY fill_rate DESC,选课率最高的课一目了然。视图把计算逻辑放在数据库层,Qt 端只负责展示,代码干净且统计口径统一。

验证方法上,我一般会做三件事:一是用 MySQL 的EXPLAIN看选课事务那条 UPDATE 有没有走主键索引,避免全表扫描;二是写个简单的并发测试脚本,用多线程同时调selectCourse,看最终enrolled是否超过capacity;三是把course_selection的唯一索引故意去掉再跑并发,确认会出现重复选课,以此证明唯一索引的必要性——这个对比实验写进论文很有说服力。

血泪经验是:别等到答辩前一天才在老师电脑上装环境。Qt 驱动、MySQL 版本、字符集这些环境问题,换台机器就可能复现。提前把建库脚本、驱动文件、依赖库打包成一个可复现的清单,比多写两个界面功能有用得多。希望帮到你。

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

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

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

立即咨询