Qt+MySQL教务系统毕设实战:三身份选课冲突与并发避坑指南
简介这是一套基于Qt框架与MySQL数据库开发的教务系统毕业设计源码面向计算机相关专业的在校学生、教师及企业员工适合作为毕设、课程设计、大作业或项目初期立项演示的参考方案。系统按学生、教师、管理员三种身份划分功能模块涵盖登录、学生页面、教师页面、管理员页面及单页教师管理等核心界面采用C编写配合Qt Designer的ui文件与qrc资源文件组织界面与图片素材。压缩包共34个文件包含9个cpp源文件、8个h头文件、7个ui界面文件以及sql建库脚本、pro工程文件、md说明文档和若干图片资源整体约95KB结构紧凑便于阅读。目前已有87人学习下载。代码经过实际运行测试答辩评审平均分达96分读者可从中获取完整的数据库设计、多角色权限划分与Qt界面交互实现思路也可在此基础上修改扩展用于自己的毕设或课设项目。1. 教务系统毕设选型为什么 Qt MySQL 是稳妥牌每年到毕设选题季总有一批同学在「做 Web 还是做桌面」之间反复横跳。我见过太多人一上来就冲 Vue Spring Boot结果光环境配置就耗掉两周最后功能没写几个答辩时被问「你这个排课冲突检测怎么实现的」直接卡壳。如果你拿到的题目是「基于 Qt 的教务系统设计与实现MySQL 数据库身份分学生、教师、管理员」那我要说这个组合是毕设里少有的「能按时交、能讲清楚、能演示」的稳妥牌。Qt 做桌面端教务系统核心优势在于 C 的强类型和 Qt 的信号槽机制让界面与业务逻辑天然解耦MySQL 负责持久化三张核心表用户、课程、选课记录就能撑起整个业务闭环。三种身份对应三套权限视图学生选课查成绩、教师录成绩看课表、管理员管用户开课程这套需求边界清晰、工作量可控答辩时也容易讲出层次。这篇笔记就按「建库建表 → Qt 工程搭建 → 三身份功能落地 → 排课冲突与并发选课避坑 → 打包交付」的顺序把每一步的参数和坑都摊开讲。2. 数据库先行三张核心表怎么设计才不返工很多同学写 Qt 界面写得飞起结果数据库表结构一改再改界面跟着重写血泪经验就是先把 ER 图和数据字典定死再动 Qt 代码。教务系统的数据模型其实不复杂但字段类型和约束如果一开始没想清楚后面选课冲突检测、成绩统计都会翻车。2.1 用户表、课程表、选课表的最小字段集用户表要容纳三种身份常见做法是用一个 role 字段区分而不是建三张表。建三张表看起来清晰但登录逻辑要写三套Qt 里信号槽连接也会重复得不偿失。课程表要预留容量字段选课表用联合主键防止重复选课。-- 用户表三种身份共用role 区分 CREATE TABLE t_user ( user_id VARCHAR(20) PRIMARY KEY, -- 学号/工号业务主键 user_name VARCHAR(50) NOT NULL, password VARCHAR(64) NOT NULL, -- 存 SHA256 摘要不存明文 role TINYINT NOT NULL, -- 1学生 2教师 3管理员 class_name VARCHAR(50) DEFAULT NULL, -- 学生所属班级 create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 课程表capacity 用于选课人数上限校验 CREATE TABLE t_course ( course_id VARCHAR(20) PRIMARY KEY, course_name VARCHAR(80) NOT NULL, teacher_id VARCHAR(20) NOT NULL, credit DECIMAL(3,1) NOT NULL, capacity INT NOT NULL DEFAULT 60, selected INT NOT NULL DEFAULT 0, -- 已选人数冗余字段 course_time VARCHAR(40), -- 如 周一3-4节 FOREIGN KEY (teacher_id) REFERENCES t_user(user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 选课表联合主键天然防重复选课 CREATE TABLE t_enroll ( student_id VARCHAR(20) NOT NULL, course_id VARCHAR(20) NOT NULL, score DECIMAL(5,1) DEFAULT NULL, -- 成绩NULL 表示未录入 enroll_time DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (student_id, course_id), FOREIGN KEY (student_id) REFERENCES t_user(user_id), FOREIGN KEY (course_id) REFERENCES t_course(course_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;逻辑说明t_user用 role 单表存三种身份登录时一次查询就能拿到角色Qt 端根据 role 决定加载哪个主窗口。t_course里的selected是冗余字段目的是选课时不用每次COUNT(*)统计直接UPDATE ... SET selected selected 1配合容量判断性能更好。t_enroll的联合主键让「同一学生同一课程」在数据库层面就无法插入第二条这是防重复选课最省事的做法。参数说明utf8mb4而不是utf8因为课程名可能含生僻字或特殊符号password长度 64 对应 SHA256 十六进制摘要credit用DECIMAL(3,1)支持 0.5 学分score用DECIMAL(5,1)支持 100.0 这种三位整数一位小数。2.2 索引与约束选课高峰期不锁表的细节选课功能在答辩演示时往往是多人同时点如果没加索引t_enroll表在几千条数据时查询就会明显变慢。常见做法是在t_enroll的course_id上建索引因为教师查「这门课有哪些学生」是高频操作。-- 教师端按课程查学生名单走这个索引 CREATE INDEX idx_enroll_course ON t_enroll(course_id); -- 学生端按学号查已选课程 CREATE INDEX idx_enroll_student ON t_enroll(student_id); -- 课程表按教师查课 CREATE INDEX idx_course_teacher ON t_course(teacher_id);逻辑说明联合主键(student_id, course_id)已经隐含了 student_id 的前缀索引所以学生端查询不用额外建。教师端按 course_id 查是高频且不在联合主键前缀里必须单独建。这三个索引覆盖了 90% 的查询场景。注意MySQL 的 InnoDB 在UPDATE t_course SET selected selected 1 WHERE course_id ? AND selected capacity这种写法下会对该行加排他锁并发选同一门课时会串行化这是正确的不要为了「性能」改成先查后更新那会超卖。3. Qt 工程搭建从 .pro 到数据库连接池Qt 连 MySQL 的坑十个里有八个卡在驱动上。Qt 默认不带 MySQL 驱动需要自己编译或者用已编译好的qsqlmysql.dll。这一章把工程文件、驱动加载、连接封装的步骤写清楚照着做能少走半天弯路。3.1 .pro 文件与 QSqlDatabase 的正确引入方式新建 Qt Widgets Application 后第一件事是改.pro文件加上 sql 模块。很多人忘了这步编译时报「QSqlDatabase: No such file or directory」然后去翻 Qt 安装目录其实只是没加模块。QT core gui sql greaterThan(QT_MAJOR_VERSION, 4): QT widgets TARGET EduAdmin TEMPLATE app CONFIG c17 SOURCES main.cpp mainwindow.cpp logindialog.cpp \ studentwindow.cpp teacherwindow.cpp adminwindow.cpp HEADERS mainwindow.h logindialog.h \ studentwindow.h teacherwindow.h adminwindow.h逻辑说明QT sql是必须的它链接 QtSql 库。CONFIG c17是为了用上std::optional之类的特性处理成绩为 NULL 的情况更方便。源文件按身份拆成三个窗口类登录对话框单独一个类职责清晰。参数说明TARGET是生成的 exe 名答辩时别用默认的untitled改成有意义的英文名。TEMPLATE app表示生成应用程序而非库。3.2 数据库连接封装单例还是每次新建Qt 里QSqlDatabase::addDatabase如果多次调用同名连接会警告常见做法是封装一个单例类全局只建一个连接。但单例在多线程下不安全毕设场景单线程足够用单例最省事。// dbhelper.h class DbHelper { public: static DbHelper instance(); QSqlDatabase db() { return m_db; } bool connect(const QString host, const QString dbName, const QString user, const QString pwd); private: DbHelper() default; QSqlDatabase m_db; }; // dbhelper.cpp DbHelper DbHelper::instance() { static DbHelper inst; return inst; } bool DbHelper::connect(const QString host, const QString dbName, const QString user, const QString pwd) { m_db QSqlDatabase::addDatabase(QMYSQL); // 驱动名固定 m_db.setHostName(host); m_db.setDatabaseName(dbName); m_db.setUserName(user); m_db.setPassword(pwd); m_db.setConnectOptions(MYSQL_OPT_RECONNECT1); // 断线自动重连 if (!m_db.open()) { qDebug() connect failed: m_db.lastError().text(); return false; } return true; }逻辑说明instance()用 C11 的局部静态变量实现线程安全的懒汉单例比双检锁简洁。addDatabase(QMYSQL)的驱动名必须是QMYSQL不是MYSQL写错会提示驱动未加载。MYSQL_OPT_RECONNECT1让连接在空闲超时后自动重连避免演示到一半掉线。参数说明host一般填127.0.0.1dbName填建库时的库名user和pwd是 MySQL 账号。如果连不上先看lastError().text()常见的是「Access denied」密码错或「Cant connect」服务没启动。3.3 登录逻辑一次查询拿到角色并跳转登录是三种身份的分流点写法上不要先查用户再查角色一条 SQL 搞定。密码比对在 C 端做 SHA256不要用 MySQL 的PASSWORD()函数那个在不同版本行为不一致。void LoginDialog::onLoginClicked() { QString uid ui-editUser-text().trimmed(); QString pwd ui-editPwd-text(); QByteArray hash QCryptographicHash::hash( pwd.toUtf8(), QCryptographicHash::Sha256).toHex(); QSqlQuery q(DbHelper::instance().db()); q.prepare(SELECT role, user_name FROM t_user WHERE user_id ? AND password ?); q.addBindValue(uid); q.addBindValue(QString(hash)); if (!q.exec() || !q.next()) { QMessageBox::warning(this, 登录失败, 账号或密码错误); return; } int role q.value(role).toInt(); QString name q.value(user_name).toString(); // 根据 role 打开对应窗口 if (role 1) { auto w new StudentWindow(uid, name); w-show(); } else if (role 2) { auto w new TeacherWindow(uid, name); w-show(); } else { auto w new AdminWindow(uid, name); w-show(); } this-accept(); // 关闭登录框 }逻辑说明prepareaddBindValue是防 SQL 注入的标准写法别用字符串拼接。QCryptographicHash算 SHA256 并转十六进制和建表时存的格式一致。q.value(role)按列名取值比按索引稳加字段时不会错位。参数说明trimmed()去掉学号前后的空格用户复制粘贴常带空格。toUtf8()保证中文密码也能正确哈希。accept()让对话框返回QDialog::Accepted主函数里据此决定是否进入事件循环。4. 三身份功能落地学生选课、教师录分、管理员开课三种身份的功能量差异很大学生端最重选课、退课、查成绩教师端中等查课表、录成绩管理员端最杂用户增删改、课程增删改。这一章按身份拆开讲每个功能给关键代码和参数。4.1 学生选课容量校验与冲突检测的 SQL 写法选课是教务系统的核心也是最容易出 bug 的地方。两个约束必须同时满足课程未满、时间不冲突。容量校验用带条件的 UPDATE冲突检测用一条查询。bool StudentWindow::enrollCourse(const QString sid, const QString cid) { QSqlDatabase db DbHelper::instance().db(); db.transaction(); // 开启事务保证扣容量和插记录原子 // 1. 容量校验并占位selected capacity 才更新 QSqlQuery up(db); up.prepare(UPDATE t_course SET selected selected 1 WHERE course_id ? AND selected capacity); up.addBindValue(cid); if (!up.exec() || up.numRowsAffected() 0) { db.rollback(); QMessageBox::warning(this, 选课失败, 课程已满); return false; } // 2. 时间冲突检测查该生已选课程中是否有同时间段 QSqlQuery cf(db); cf.prepare(SELECT COUNT(*) FROM t_enroll e JOIN t_course c ON e.course_id c.course_id WHERE e.student_id ? AND c.course_time (SELECT course_time FROM t_course WHERE course_id ?)); cf.addBindValue(sid); cf.addBindValue(cid); cf.exec(); cf.next(); if (cf.value(0).toInt() 0) { db.rollback(); QMessageBox::warning(this, 选课失败, 时间冲突); return false; } // 3. 插入选课记录 QSqlQuery ins(db); ins.prepare(INSERT INTO t_enroll(student_id, course_id) VALUES(?, ?)); ins.addBindValue(sid); ins.addBindValue(cid); if (!ins.exec()) { db.rollback(); QMessageBox::warning(this, 选课失败, 重复选课); return false; } db.commit(); return true; }逻辑说明三步都在一个事务里任何一步失败就回滚保证selected计数和t_enroll记录一致。容量校验用UPDATE ... WHERE selected capacity而不是先 SELECT 再判断是因为 UPDATE 会加行锁并发时不会超卖。冲突检测用子查询拿目标课程的时间段再和已选课程比对course_time存成「周一3-4节」这种字符串直接等值比较即可。参数说明numRowsAffected() 0表示 UPDATE 没匹配到行即容量已满。db.transaction()和db.commit()成对出现中间任何return false前都要rollback()。如果course_time要支持更细的冲突判断比如单双周需要拆成weekday、start_section、end_section三个字段这里为了毕设工作量用字符串简化。4.2 教师录成绩批量更新与 NULL 处理教师端录成绩通常是先查名单再逐个填分数最后批量提交。关键点是成绩可以为空未录入Qt 的QLineEdit空字符串要转成 SQL 的 NULL不能存成 0。void TeacherWindow::saveScores() { QSqlDatabase db DbHelper::instance().db(); db.transaction(); QSqlQuery q(db); q.prepare(UPDATE t_enroll SET score ? WHERE student_id ? AND course_id ?); for (int row 0; row ui-table-rowCount(); row) { QString sid ui-table-item(row, 0)-text(); QString txt ui-table-item(row, 2)-text().trimmed(); QVariant score; if (txt.isEmpty()) { score QVariant(QVariant::Double); // 空 - NULL } else { bool ok; double v txt.toDouble(ok); if (!ok || v 0 || v 100) { QMessageBox::warning(this, 错误, QString(学号 %1 成绩非法).arg(sid)); db.rollback(); return; } score v; } q.addBindValue(score); q.addBindValue(sid); q.addBindValue(m_courseId); q.exec(); } db.commit(); QMessageBox::information(this, 完成, 成绩已保存); }逻辑说明QVariant(QVariant::Double)构造一个类型为 double 但值为 NULL 的变体绑定到 SQL 就是 NULL。成绩范围校验在 C 端做避免脏数据入库。整个循环在一个事务里要么全成功要么全回滚。参数说明m_courseId是当前教师选中的课程 ID作为成员变量保存。toDouble(ok)的ok标志判断是否合法数字。如果成绩允许小数DECIMAL(5,1)对应一位小数界面输入时可以用QDoubleValidator限制。4.3 管理员开课外键约束下的删除顺序管理员端最麻烦的是删除操作。删用户时如果该用户有选课记录或授课记录外键会阻止删除。常见做法是先删关联记录再删主记录或者用ON DELETE CASCADE。毕设里我一般建议手动控制删除顺序逻辑更清晰。bool AdminWindow::deleteUser(const QString uid) { QSqlDatabase db DbHelper::instance().db(); db.transaction(); QSqlQuery q(db); // 先删该用户的选课记录学生或授课关联教师 q.prepare(DELETE FROM t_enroll WHERE student_id ?); q.addBindValue(uid); q.exec(); // 教师授课的课程不能直接删先检查 q.prepare(SELECT COUNT(*) FROM t_course WHERE teacher_id ?); q.addBindValue(uid); q.exec(); q.next(); if (q.value(0).toInt() 0) { db.rollback(); QMessageBox::warning(this, 无法删除, 该教师还有授课课程请先转移或删除课程); return false; } q.prepare(DELETE FROM t_user WHERE user_id ?); q.addBindValue(uid); if (!q.exec()) { db.rollback(); return false; } db.commit(); return true; }逻辑说明删除学生时先清t_enroll因为选课记录是附属数据。删除教师时不能直接清课程因为课程还有学生选需要管理员先处理课程归属。这种「先检查再删」的顺序避免了外键报错也给了用户明确的提示。参数说明q.value(0).toInt()取 COUNT 结果。事务保证删除操作要么全成要么全不动。如果课程也要级联删除需要先删该课程的t_enroll记录再删t_course顺序不能反。5. 避坑与排查驱动加载、中文乱码、事务失效这一章是我带毕设时被问得最多的几个问题每个都按「现象 → 原因 → 解决」写清楚。这些问题不解决功能写得再好也演示不了。5.1 QMYSQL driver not loaded 的三层排查现象程序启动后db.open()返回 falselastError().text()显示「QMYSQL driver not loaded」。原因Qt 安装时默认不编译 MySQL 驱动需要手动编译或下载对应版本的qsqlmysql.dll和libmysql.dll。三层排查第一层看 Qt 安装目录下plugins/sqldrivers/有没有qsqlmysql.dll第二层看该 dll 依赖的libmysql.dll是否在 PATH 或 exe 同目录第三层看位数是否匹配64 位 Qt 必须配 64 位驱动。解决用QSqlDatabase::drivers()打印可用驱动列表如果没有 QMYSQL就去 Qt 安装目录的Sources/qtbase/src/plugins/sqldrivers/mysql下用 qmake 编译或者找与 Qt 版本、编译器、位数都一致的预编译驱动。把qsqlmysql.dll放到plugins/sqldrivers/libmysql.dll放到 exe 同目录。5.2 中文乱码从建库到 Qt 编码的全链路现象学生姓名、课程名在界面显示成问号或方块。原因链路上任何一环编码不一致都会乱码。建库时用了latin1或者 Qt 源文件保存成了 GBK或者连接字符集没设。解决建库建表统一utf8mb4Qt 源文件用 UTF-8 无 BOM 保存在main.cpp里设置QTextCodec::setCodecForLocale(QTextCodec::codecForName(UTF-8))连接后执行SET NAMES utf8mb4。四步都做基本不会乱码。// main.cpp 里加这两行 QTextCodec::setCodecForLocale(QTextCodec::codecForName(UTF-8)); // 连接成功后 QSqlQuery q(db); q.exec(SET NAMES utf8mb4);5.3 事务没生效MyISAM 引擎的隐形坑现象选课失败后selected计数还是加了或者插入了一半数据。原因MySQL 默认引擎在旧版本可能是 MyISAMMyISAM 不支持事务transaction()和rollback()都是空操作。解决建表时显式指定ENGINEInnoDB或者用ALTER TABLE t_course ENGINEInnoDB转换。用SHOW TABLE STATUS查看引擎。InnoDB 才支持行锁和事务这是选课并发正确性的基础。5.4 选课超卖先查后更新的经典翻车现象容量 60 的课两个人同时选最后selected变成 61。原因用了「先 SELECT 判断是否小于容量再 UPDATE 加一」的写法两个线程都查到 59都判断通过都加一。解决改成UPDATE t_course SET selected selected 1 WHERE course_id ? AND selected capacity用numRowsAffected()判断是否成功。这条 UPDATE 在 InnoDB 下会加行锁第二个请求会等第一个提交后再执行此时selected已是 60条件不满足更新 0 行正确拒绝。5.5 成绩录入后学生端不刷新现象教师录完成绩学生端查成绩还是空的。原因学生端窗口打开时查了一次数据之后没重新查询。Qt 的 Model/View 不会自动感知数据库变化。解决在教师端保存成功后发一个自定义信号学生端收到后重新执行查询或者学生端加一个「刷新」按钮。毕设里最简单的做法是每次切换到成绩页时重新查一次用QStackedWidget的currentChanged信号触发。6. 打包交付与答辩演示让 exe 在别人电脑上跑起来毕设最后一步是打包很多同学在自己电脑上跑得好好的拷到答辩教室的电脑就报错。这一章讲怎么用windeployqt打包以及答辩演示的几个实用技巧。6.1 windeployqt 打包与驱动文件的手动补齐Qt 提供了windeployqt工具自动拷贝依赖的 dll。在 Release 模式下编译出 exe 后打开 Qt 命令行切到 exe 目录执行windeployqt EduAdmin.exe --release --no-translations逻辑说明--release表示按 Release 模式分析依赖--no-translations跳过翻译文件减小体积。执行后会生成platforms/、sqldrivers/等目录。但windeployqt不会自动拷贝 MySQL 驱动需要手动把qsqlmysql.dll放进sqldrivers/把libmysql.dll放到 exe 同目录。参数说明如果目标电脑没装 MySQL还需要把 MySQL 服务端也带上或者改成连远程数据库。答辩教室一般有网络连自己电脑开的 MySQL 更省事但要确保防火墙放行 3306 端口。6.2 答辩演示的数据库准备与账号预设演示前把数据库脚本跑一遍插入几条测试数据至少 2 个学生、2 个教师、1 个管理员3 门课程其中一门容量设成 2 方便演示「选课已满」。账号密码统一设成简单值比如123456避免演示时输错。身份账号密码用途管理员admin123456演示开课、管用户教师T001123456演示录成绩学生S001123456演示选课、查成绩学生S002123456演示容量已满演示顺序建议管理员登录开一门新课 → 学生 S001 登录选课 → 学生 S002 登录选同一门课触发「已满」提示 → 教师 T001 登录录成绩 → 学生 S001 查成绩。这条链路覆盖三种身份和核心功能五分钟能讲完。6.3 一个让答辩加分的小技巧操作日志表如果时间充裕加一张t_log表记录关键操作答辩时能体现「可追溯」的设计意识。字段就四个log_id自增、user_id、action、log_time。在选课、录成绩、删用户的地方插一条记录。演示时打开日志窗口能看到「S001 选了课程 C001」这样的记录比干讲功能有说服力。CREATE TABLE t_log ( log_id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id VARCHAR(20), action VARCHAR(200), log_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;我自己的习惯是毕设代码可以简单但演示链路一定要顺数据库脚本和测试数据提前备好打包后在另一台电脑上完整跑一遍再上答辩。别等到站在讲台上才发现驱动没拷那时候没有后悔药。希望帮到你。本文还有配套的精品资源点击获取