简介:这份增进版学生选课系统基于MFC框架开发,面向计算机专业学生、课程设计开发者及需要教学案例的高校教师,用于解决传统选课系统操作复杂、响应慢、功能不全等痛点。资源包共85个文件,约3.11MB,以h头文件与cpp源文件为核心,配合obj编译产物、bmp与ico界面素材、mdb数据库及dsp/dsw工程文件,构成一套可直接编译运行的VC6工程。系统覆盖用户管理、课程管理、选课流程优化、数据统计、消息推送与权限验证等模块,学生、教师、管理员分角色操作,支持预选、正选、补选与退选等阶段。已有255人学习下载,适合作为MFC课程设计、数据库编程与Windows应用开发的实战参考,读者可从中获取完整工程结构、类设计思路与选课业务逻辑实现,便于二次开发与功能扩展。
1. 增进版学生选课系统:从 MFC 对话框骨架到能扛住两百人同时抢课
如果你手头有一个“增进版学生选课系统”的课程设计或小团队内部工具,要求用 MFC 做,大概率会遇到一个尴尬局面:网上能搜到的 MFC 选课系统源码,九成是拖几个编辑框、连一个 Access 数据库、点按钮就INSERT的玩具。单机跑没问题,一旦放到机房让两百个学生同时点“选课”,要么卡死,要么出现同一门课被选超人数。这篇笔记不讲“MFC 是什么”,而是把一套能真正跑起来、能解释清楚数据一致性怎么保证的 MFC 选课系统拆开:界面用 MFC 对话框程序,数据层用 SQLite 或 SQL Server,核心难点在选课事务和余量控制。适合正在做课程设计、想拿高分,或者需要给内部做一个小型选课工具的人。下面从工程结构开始,一步步把“增进版”这三个字落到实处。
2. 工程骨架与数据层选型:为什么不用 Access 而用 SQLite
2.1 MFC 对话框程序的四类文件与选课系统的映射
MFC 对话框程序常见结构是App、Dlg、stdafx、targetver这几类文件。放到选课系统里,我一般这样切:
CSelectionApp:只负责启动和初始化数据库连接,不写业务。CLoginDlg:登录窗口,区分学生和教师角色。CStudentDlg:学生主界面,左侧课程列表,右侧已选列表。CTeacherDlg:教师主界面,发布课程、查看选课名单。CDbHelper:单例类,封装所有 SQL 执行,不把CDatabase散落在各个对话框里。
这样切的好处是,后面加“退课”“导出名单”“选课时间控制”时,不用去动界面代码。很多课程设计翻车就翻在把 SQL 语句直接写在按钮响应函数里,改一个字段要翻五个文件。
2.2 用 SQLite 替代 Access 的三个实际理由
Access 在 MFC 里通过 ODBC 连接,单机没问题,但并发一上来就锁库。SQLite 同样轻量,但支持 WAL 模式,读多写少的选课场景更稳。具体差异:
| 对比项 | Access + ODBC | SQLite + 原生 API |
|---|---|---|
| 并发写 | 容易整库锁 | WAL 模式下读写不互斥 |
| 部署 | 需要装驱动 | 一个 dll 加一个 db 文件 |
| 事务 | 支持但慢 | 支持且快 |
| 余量控制 | 靠代码判断 | 可用UPDATE ... WHERE remaining > 0原子操作 |
我一般会选 SQLite,因为选课系统的核心矛盾是“同一时刻大量学生抢同一门课”,需要数据库层面帮我们做原子扣减,而不是在 C++ 里先查再改。
2.3 建表语句与余量字段的设计
课程表里不要只存“已选人数”,要存“容量”和“已选人数”两个字段,余量用计算列或查询时算。但为了原子扣减,我习惯直接加一个remaining字段,初始等于容量,每次选课成功减一。
-- 课程表:容量、余量、选课开关分开 CREATE TABLE course ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, teacher TEXT NOT NULL, capacity INTEGER NOT NULL DEFAULT 0, remaining INTEGER NOT NULL DEFAULT 0, is_open INTEGER NOT NULL DEFAULT 1, -- 1 可选,0 关闭 created_at TEXT DEFAULT (datetime('now', 'localtime')) ); -- 选课记录表:学生和课程唯一约束,防止重复选 CREATE TABLE selection ( id INTEGER PRIMARY KEY AUTOINCREMENT, student_no TEXT NOT NULL, course_id INTEGER NOT NULL, selected_at TEXT DEFAULT (datetime('now', 'localtime')), UNIQUE(student_no, course_id) );remaining字段是“增进版”的关键。普通版本只查COUNT(*)判断是否满员,并发下两个线程同时查到 99 人,都以为还能选,结果变成 101。有了remaining,扣减时用UPDATE course SET remaining = remaining - 1 WHERE id = ? AND remaining > 0,数据库保证只有一行被更新,返回受影响行数为 1 才算选课成功。
2.4 在 MFC 中初始化 SQLite 连接
MFC 本身不直接带 SQLite,需要把sqlite3.h、sqlite3.c和sqlite3.dll放进工程。我一般把sqlite3.c直接加入项目编译,避免 dll 版本问题。
// DbHelper.h #pragma once #include "sqlite3.h" #include <string> class CDbHelper { public: static CDbHelper& Instance(); bool Open(const std::string& dbPath); void Close(); sqlite3* GetDb() { return m_db; } private: CDbHelper() : m_db(nullptr) {} sqlite3* m_db; }; // DbHelper.cpp CDbHelper& CDbHelper::Instance() { static CDbHelper inst; return inst; } bool CDbHelper::Open(const std::string& dbPath) { int rc = sqlite3_open(dbPath.c_str(), &m_db); if (rc != SQLITE_OK) { return false; } // 开启 WAL 模式,提升并发读性能 sqlite3_exec(m_db, "PRAGMA journal_mode=WAL;", nullptr, nullptr, nullptr); // 设置忙等待,避免瞬时锁冲突直接失败 sqlite3_busy_timeout(m_db, 3000); return true; }PRAGMA journal_mode=WAL让读写可以同时进行,busy_timeout设 3000 毫秒,意思是如果数据库被写锁占用,等 3 秒再重试,而不是立刻报错。这两个参数在单机选课系统里能挡掉大部分“database is locked”的玄学问题。
3. 选课核心逻辑:原子扣减与事务边界
3.1 选课按钮背后的完整事务流程
学生点“选课”按钮,不能只执行一条 INSERT。完整流程是:
- 检查课程是否开放(
is_open = 1)。 - 检查该学生是否已经选过这门课(靠 UNIQUE 约束兜底)。
- 原子扣减余量:
UPDATE course SET remaining = remaining - 1 WHERE id = ? AND remaining > 0 AND is_open = 1。 - 如果上一步受影响行数为 1,再插入选课记录。
- 如果插入失败(比如唯一约束冲突),回滚余量。
这五步必须在一个事务里。SQLite 默认每条语句自动提交,要用BEGIN IMMEDIATE手动开事务。
bool SelectCourse(const std::string& studentNo, int courseId, std::string& errMsg) { sqlite3* db = CDbHelper::Instance().GetDb(); char* err = nullptr; // 开启立即事务,获取写锁,避免并发下先读后写 if (sqlite3_exec(db, "BEGIN IMMEDIATE;", nullptr, nullptr, &err) != SQLITE_OK) { errMsg = err ? err : "begin failed"; sqlite3_free(err); return false; } // 第一步:原子扣减余量 std::string sqlUpdate = "UPDATE course SET remaining = remaining - 1 " "WHERE id = ? AND remaining > 0 AND is_open = 1;"; sqlite3_stmt* stmt = nullptr; sqlite3_prepare_v2(db, sqlUpdate.c_str(), -1, &stmt, nullptr); sqlite3_bind_int(stmt, 1, courseId); int rc = sqlite3_step(stmt); int affected = sqlite3_changes(db); sqlite3_finalize(stmt); if (rc != SQLITE_DONE || affected != 1) { sqlite3_exec(db, "ROLLBACK;", nullptr, nullptr, nullptr); errMsg = "课程已满或未开放"; return false; } // 第二步:插入选课记录 std::string sqlInsert = "INSERT INTO selection(student_no, course_id) VALUES(?, ?);"; sqlite3_prepare_v2(db, sqlInsert.c_str(), -1, &stmt, nullptr); sqlite3_bind_text(stmt, 1, studentNo.c_str(), -1, SQLITE_STATIC); sqlite3_bind_int(stmt, 2, courseId); rc = sqlite3_step(stmt); sqlite3_finalize(stmt); if (rc != SQLITE_DONE) { // 插入失败,回滚余量 sqlite3_exec(db, "ROLLBACK;", nullptr, nullptr, nullptr); errMsg = "重复选课或数据异常"; return false; } sqlite3_exec(db, "COMMIT;", nullptr, nullptr, nullptr); return true; }BEGIN IMMEDIATE和普通BEGIN的区别在于,它立刻尝试获取写锁,而不是等到第一条写语句才获取。这样两个并发选课请求进来,一个拿到写锁,另一个直接等待或失败,不会出现两个都读到remaining = 1然后都去扣减的情况。sqlite3_changes返回上一条 UPDATE 实际修改的行数,只有等于 1 才说明扣减成功。
3.2 退课时的余量回补与事务处理
退课逻辑比选课简单,但也要事务。先删除选课记录,如果删除成功,再把余量加回去。顺序不能反,否则先加余量再删记录,删记录失败就多出一个名额。
bool DropCourse(const std::string& studentNo, int courseId, std::string& errMsg) { sqlite3* db = CDbHelper::Instance().GetDb(); sqlite3_exec(db, "BEGIN IMMEDIATE;", nullptr, nullptr, nullptr); // 先删记录 std::string sqlDel = "DELETE FROM selection WHERE student_no = ? AND course_id = ?;"; sqlite3_stmt* stmt = nullptr; sqlite3_prepare_v2(db, sqlDel.c_str(), -1, &stmt, nullptr); sqlite3_bind_text(stmt, 1, studentNo.c_str(), -1, SQLITE_STATIC); sqlite3_bind_int(stmt, 2, courseId); sqlite3_step(stmt); int affected = sqlite3_changes(db); sqlite3_finalize(stmt); if (affected != 1) { sqlite3_exec(db, "ROLLBACK;", nullptr, nullptr, nullptr); errMsg = "未找到选课记录"; return false; } // 再回补余量,但不能超过容量 std::string sqlUp = "UPDATE course SET remaining = remaining + 1 " "WHERE id = ? AND remaining < capacity;"; sqlite3_prepare_v2(db, sqlUp.c_str(), -1, &stmt, nullptr); sqlite3_bind_int(stmt, 1, courseId); sqlite3_step(stmt); sqlite3_finalize(stmt); sqlite3_exec(db, "COMMIT;", nullptr, nullptr, nullptr); return true; }remaining < capacity这个条件防止教师手动改过容量后,退课把余量加超。如果教师把容量从 100 改成 80,而已经选了 90 人,退课时余量不应该超过 80。
3.3 用 MFC 列表控件展示课程与已选状态
学生界面一般用两个CListCtrl:左边所有课程,右边已选课程。初始化时用LVS_REPORT样式,插入列。
// 初始化课程列表 m_courseList.InsertColumn(0, _T("课程编号"), LVCFMT_LEFT, 80); m_courseList.InsertColumn(1, _T("课程名称"), LVCFMT_LEFT, 150); m_courseList.InsertColumn(2, _T("教师"), LVCFMT_LEFT, 100); m_courseList.InsertColumn(3, _T("余量"), LVCFMT_LEFT, 60); m_courseList.SetExtendedStyle(LVS_EX_FULLROWSELECT | LVS_EX_GRIDLINES); // 查询并填充 std::string sql = "SELECT id, name, teacher, remaining FROM course WHERE is_open = 1;"; sqlite3_stmt* stmt = nullptr; sqlite3_prepare_v2(db, sql.c_str(), -1, &stmt, nullptr); int row = 0; while (sqlite3_step(stmt) == SQLITE_ROW) { int id = sqlite3_column_int(stmt, 0); const unsigned char* name = sqlite3_column_text(stmt, 1); const unsigned char* teacher = sqlite3_column_text(stmt, 2); int remaining = sqlite3_column_int(stmt, 3); m_courseList.InsertItem(row, CString(id)); m_courseList.SetItemText(row, 1, CString(name)); m_courseList.SetItemText(row, 2, CString(teacher)); m_courseList.SetItemText(row, 3, CString(remaining)); row++; } sqlite3_finalize(stmt);LVS_EX_FULLROWSELECT让整行可选中,LVS_EX_GRIDLINES显示网格线。填充时注意sqlite3_column_text返回的是 UTF-8,如果 MFC 工程用 Unicode 字符集,需要转成CString时指定编码,否则中文会乱码。我一般写一个Utf8ToCString辅助函数,用MultiByteToWideChar转。
4. 避坑与排查:MFC 选课系统最常见的五个翻车点
4.1 现象:选课按钮点下去没反应,界面卡死
原因通常是 SQLite 的busy_timeout没设,或者事务没提交就退出函数。如果BEGIN IMMEDIATE之后某条语句失败,没有执行ROLLBACK或COMMIT,数据库连接会一直持有写锁,后续所有写操作都卡住。
解决:每个事务分支都要有明确的ROLLBACK或COMMIT。我习惯用一个小 RAII 类,构造时BEGIN,析构时如果没提交就ROLLBACK。另外sqlite3_busy_timeout设 3000 毫秒,给并发一点缓冲。
4.2 现象:中文课程名显示成乱码
MFC 默认 Unicode 工程,SQLite 存的是 UTF-8。直接CString(sqlite3_column_text(...))会按 ANSI 解释,中文变问号。
解决:写转换函数。
CString Utf8ToCString(const char* utf8) { int len = MultiByteToWideChar(CP_UTF8, 0, utf8, -1, nullptr, 0); wchar_t* buf = new wchar_t[len]; MultiByteToWideChar(CP_UTF8, 0, utf8, -1, buf, len); CString str(buf); delete[] buf; return str; }插入中文时反过来,用CStringA转 UTF-8,或者直接用sqlite3_bind_text绑 UTF-8 字节。
4.3 现象:同一门课被同一个学生选了两次
原因是没有加 UNIQUE 约束,或者加了但插入时没检查返回码。SQLite 在违反 UNIQUE 时sqlite3_step返回SQLITE_CONSTRAINT,但很多代码只判断SQLITE_DONE,把约束冲突当成成功。
解决:建表时加UNIQUE(student_no, course_id),插入后判断rc != SQLITE_DONE就回滚。这样即使界面层重复点击,数据库也能兜住。
4.4 现象:教师关闭课程后,学生还能选
原因是在扣减余量的 SQL 里没加is_open = 1条件,或者界面刷新不及时。学生列表是打开时查的,教师中途关闭课程,学生那边还是旧数据。
解决:扣减 SQL 必须带AND is_open = 1,并且学生点选课时重新查一次课程状态。更彻底的做法是选课成功后发一个自定义消息刷新列表,但单机场景下重新查询已经够用。
4.5 现象:程序退出时数据库文件被锁,无法删除
MFC 对话框程序如果没在OnDestroy或PostNcDestroy里调用sqlite3_close,进程退出后文件句柄可能还被占用。尤其是调试运行时,VS 可能持有文件。
解决:在CDbHelper::Close里调用sqlite3_close,并在CSelectionApp::ExitInstance里调用。如果还是锁,检查是否有未finalize的sqlite3_stmt,每个prepare都要对应一个finalize。
5. 进阶技巧:用触发器自动维护余量一致性
5.1 为什么还要触发器:防止绕过应用层的直接改库
即使应用层写了原子扣减,如果有人直接用 SQLite 命令行改selection表,course.remaining就会和实际选课人数不一致。触发器可以在数据库层面兜底。
-- 插入选课记录时自动扣减余量 CREATE TRIGGER trg_selection_insert AFTER INSERT ON selection BEGIN UPDATE course SET remaining = remaining - 1 WHERE id = NEW.course_id AND remaining > 0; END; -- 删除选课记录时自动回补余量 CREATE TRIGGER trg_selection_delete AFTER DELETE ON selection BEGIN UPDATE course SET remaining = remaining + 1 WHERE id = OLD.course_id AND remaining < capacity; END;有了触发器,应用层的UPDATE course SET remaining = remaining - 1就可以去掉,只保留INSERT INTO selection。但注意,触发器里的UPDATE如果因为remaining > 0不满足而没执行,INSERT仍然会成功,导致选课记录存在但余量没扣。所以更稳妥的做法是:应用层仍然做原子扣减,触发器只作为数据修复手段,不依赖它做业务控制。
5.2 用事务嵌套保存点处理批量选课
如果学生一次选多门课,比如勾选了三门,要么全成功要么全失败。SQLite 支持SAVEPOINT,可以在一个大事务里对每门课设保存点。
bool BatchSelect(const std::string& studentNo, const std::vector<int>& courseIds, std::string& errMsg) { sqlite3* db = CDbHelper::Instance().GetDb(); sqlite3_exec(db, "BEGIN IMMEDIATE;", nullptr, nullptr, nullptr); for (int cid : courseIds) { std::string sp = "SAVEPOINT sp_" + std::to_string(cid) + ";"; sqlite3_exec(db, sp.c_str(), nullptr, nullptr, nullptr); std::string err; if (!SelectCourseInner(studentNo, cid, err)) { // 回滚到保存点,继续尝试下一门?还是全部回滚? // 业务上一般全部回滚 sqlite3_exec(db, "ROLLBACK;", nullptr, nullptr, nullptr); errMsg = "课程 " + std::to_string(cid) + " 选课失败:" + err; return false; } sqlite3_exec(db, "RELEASE sp_" + std::to_string(cid) + ";", nullptr, nullptr, nullptr); } sqlite3_exec(db, "COMMIT;", nullptr, nullptr, nullptr); return true; }SelectCourseInner是不带BEGIN/COMMIT的版本,只做扣减和插入。外层用BEGIN IMMEDIATE包住,内层用SAVEPOINT标记。任何一门失败就整体ROLLBACK,保证批量选课的原子性。
5.3 验证余量一致性的检查 SQL
上线前跑一遍这个查询,如果返回任何行,说明余量和实际选课人数对不上。
SELECT c.id, c.name, c.capacity, c.remaining, COUNT(s.id) AS actual FROM course c LEFT JOIN selection s ON s.course_id = c.id GROUP BY c.id HAVING c.remaining != (c.capacity - COUNT(s.id));正常情况应该返回 0 行。如果有行,用下面的语句修复:
UPDATE course SET remaining = capacity - ( SELECT COUNT(*) FROM selection WHERE selection.course_id = course.id );我一般把这个检查放在教师界面的“数据校验”按钮里,点一下就能看到有没有异常。做课程设计时,这个功能很加分,因为它说明你考虑到了数据一致性,而不是只堆界面。
5.4 最后说一个习惯
我做完每个 MFC 数据库项目,都会在退出前手动执行一次PRAGMA integrity_check;,确认数据库文件没有损坏。选课系统虽然小,但并发写加上强制退出,SQLite 文件偶尔会出问题。这个习惯帮我省过好几次“后悔药”。希望帮到你。
本文还有配套的精品资源,点击获取