简介:基于MFC框架的增进版学生选课系统,面向高校、培训机构的教务选课场景,也适合MFC初学者、C++课程设计与毕业设计参考。系统在传统选课基础上优化了操作流程,涵盖用户角色管理、课程信息发布、选课退课、数据统计、消息通知与权限验证等模块,能缓解选课冲突、高峰期响应慢等问题。资源包为rar压缩格式,共85个文件、约3.11MB;核心是20个头文件与19个cpp源文件,另含1个可直接运行的exe、1个mdb数据库、7个位图与2个图标,以及dsp/dsw等VC6.0工程文件,目录结构清晰,便于加载编译和二次开发。目前已有256人学习下载。阅读源码可掌握MFC文档视图架构、数据库连接与用户权限控制等实现细节,配合界面资源和数据库文件,能快速梳理选课系统的完整业务流程;系统设计采用模块化思路,按登录、课程、学生、教师等业务类拆分,适合作为课程设计、期末项目或MFC入门练习的参考资料。
1. 增进版学生选课系统到底值不值得做:先认清它解决的是“交差”而不是“炫技”
老读者应该都见过这种场景:某高校的数据库课程设计答辩现场,前面几位同学打开网页版选课系统,卡在登录页半天进不去;轮到某同学双击一个 exe,三秒进主界面,课程列表、余量、已选课程清清楚楚,点一下选课按钮,余量当场减一,台下老师直接点头。这个 exe 就是传说中口碑两极分化的“增进版学生选课系统 MFC 选课系统”。不做 C++ 的人觉得它老气,做过的人知道它其实是最稳的课程设计选题之一。MFC 选课系统本质是一个基于微软基础类库的 Windows 桌面程序,配合 Access 或 SQLite 数据库完成学生登录、课程浏览、选课退课、管理员维护课程这些闭环操作。这篇文章不吹不黑,只讲清楚它怎么做、参数怎么调、坑在哪,以及它为什么至今仍是“能交差”的代名词。
2. 先把系统拆清楚:需求边界、角色权限与四张表的取舍
2.1 需求拆解:三张表的系统只能做演示,四张表才能谈“改进”
很多人拿到“学生选课系统”这个题目,第一反应是建三张表:学生表、课程表、选课记录表。学生登录,看到全部课程,点选课,记录表里插一行,完事。这种三表模型在演示时确实跑得通,但答辩老师随口问一句“怎么防止同一门课选两次”或者“管理员怎么修改课程信息”,当场就卡壳。
增进版选课系统的核心增量在“管理端”。我做完这个系统的第一版后,回头加了一张管理员表,把“维护课程数据”和“查看统计数据”从学生视角里拆出去,整个程序的逻辑才真正闭合。四张表的结构是:学生表存账号密码和基本信息,管理员表独立存放并且单独加密,课程表带容量和已选人数字段,选课记录表负责学生和课程的关联。这一层拆完之后,“谁能干什么”就清楚了:学生只能看课、选课、退课、看自己的课表;管理员能增删改课程、查选课统计、重置学生密码。这个边界一旦明确,代码里所有的权限判断都变得有据可依。
2.2 数据库设计:建表语句与关键字段约束
无论你最后用 Access 还是 SQLite,表结构是一样的。我习惯先用 SQL 把表建好,再在 MFC 里用 ODBC 连过去。下面这套建表语句是经过多次课程设计验证的骨架,字段命名简单直接,方便在 CRecordset 里映射。
-- 学生表 CREATE TABLE student ( stu_id TEXT PRIMARY KEY, -- 学号,登录账号 stu_name TEXT NOT NULL, -- 姓名 stu_pwd TEXT NOT NULL, -- 登录密码 stu_major TEXT -- 专业 ); -- 管理员表 CREATE TABLE admin ( admin_id TEXT PRIMARY KEY, admin_pwd TEXT NOT NULL ); -- 课程表 CREATE TABLE course ( course_id TEXT PRIMARY KEY, -- 课程编号 course_name TEXT NOT NULL, -- 课程名称 teacher TEXT, -- 授课教师 credit REAL DEFAULT 2.0, -- 学分 capacity INTEGER NOT NULL, -- 课程容量 selected INTEGER DEFAULT 0 -- 已选人数,需要手动维护 ); -- 选课记录表 CREATE TABLE enroll ( enroll_id INTEGER PRIMARY KEY AUTOINCREMENT, stu_id TEXT NOT NULL, course_id TEXT NOT NULL, enroll_time TEXT DEFAULT (datetime('now','localtime')), UNIQUE(stu_id, course_id) -- 防止同一学生重复选同一门课 );这里有一个值得说明的设计决策:课程表里的 selected 字段是“冗余字段”。明明选课记录表能统计出已选人数,为什么还要在课程表里存一个?因为选课系统最常见的操作是“看所有课程并显示余量”,如果每次刷新列表都去 enroll 表做 GROUP BY 统计,课程多的时候界面会明显变慢。selected 字段带回来的问题是数据一致性,这个在第 4 章讲事务时会专门处理。另一个重点是 UNIQUE 约束,它是数据库层面防止重复选课的底线,哪怕代码里漏判了,数据库也会报错兜底。
2.3 表关系与业务约束:谁管数据、谁只做操作
四张表的关系其实只有一条主线:enroll 表通过 stu_id 和 course_id 分别关联 student 表和 course 表。开发者最容易忽略的是删除课程时的关联处理。管理员删除一门课时,如果 enroll 表里还有记录,要么先删 enroll 再删 course,要么强制提示“该课程已有学生选课,不能删除”。我第一版就是没处理这个,直接删 course 导致程序崩溃,后来才明白这叫外键约束缺失的连锁反应。Access 和 SQLite 默认都不强开外键,MFC 程序里更不会自动帮你管,所以业务逻辑必须自己做。
再往细看,“增进版”还应该包含一个学分上限的约束:比如每个学生最多选 20 学分,超过就不能再选。这个约束放哪最合适?放数据库层可以通过触发器实现,但 MFC 课程设计里用触发器调试不方便,我一般放在程序逻辑里:选课前先查该生已选课程的学分总和,加上新课程学分,超过上限就弹窗拒绝。这个判断写在业务层的好处是错误信息可以给得很友好,比如“你已选 18 学分,这门课 4 学分,超过 20 学分上限”,答辩时这种细节非常加分。
3. 从空窗口到主界面:MFC 框架选型、ODBC 连接与课程列表加载
3.1 框架选型:为什么 MFC 课程设计最稳的是“登录框 + 对话框主界面”
MFC 程序可以做成单文档、多文档、基于对话框三种形态。选课系统这种界面固定、操作密集的应用,我最推荐基于对话框。原因很简单:单文档的 Document/View 架构会把“课程列表、已选列表、操作按钮”拆到多个视图里,切换逻辑写起来繁琐,而且 MFC 的文档序列化机制对数据库程序几乎是多余的。对话框程序则是一块画布,左边放一个树控件或按钮组做导航,右边放列表控件显示课程数据,逻辑清晰直观。登录单独用一个模态对话框,密码校验通过后再进入主对话框,这个模式也是实际项目里最常见的组合。
主对话框的控件排布我给出一个经过多次调整的参考布局:左侧用 CTreeCtrl 做导航,节点是“全部课程”“我的课表”“个人信息”;中间是一个 CListCtrl 报表视图,列依次是课程编号、课程名称、教师、学分、容量、已选、余量;底部放两个按钮,“选课”和“退课”,再加一个“刷新”按钮。这个布局的好处是拿不到界面上新,但功能一目了然,答辩时老师扫一眼就知道系统完整。
3.2 用 ODBC 连接 Access 数据库:连接代码与参数说明
数据库连接是整个系统的命门。Access 数据库文件(.mdb)通过 ODBC 驱动连接是最常见做法,因为 Visual Studio 自带的数据源工具对 Access 支持最好,而且不需要额外安装数据库服务。连接代码通常封装在一个全局函数里,程序启动时调用一次:
#include <afxdb.h> CDatabase g_db; // 全局数据库连接对象 BOOL InitDatabase(LPCTSTR lpszFilePath) { // 构造 ODBC 连接字符串,指定驱动和数据库文件路径 CString strConn; strConn.Format(_T("DRIVER={Microsoft Access Driver (*.mdb, *.accdb)};DBQ=%s"), lpszFilePath); // 打开数据库;第二个参数为 FALSE 表示不显示 ODBC 连接对话框 // 第三个参数是打开选项:只读模式用 CDatabase::openReadOnly BOOL bOK = g_db.OpenEx(strConn, CDatabase::noOdbcDialog); if (!bOK) { AfxMessageBox(_T("数据库连接失败,请检查 student.mdb 是否位于程序目录")); return FALSE; } return TRUE; }参数说明:OpenEx 的第一个参数是完整连接字符串,驱动名称必须和本机安装的 ODBC 驱动一致,64 位系统装 64 位 Office 时驱动名是“Microsoft Access Driver (*.mdb,.accdb)”,32 位程序配 32 位驱动则更早版本的驱动名可能是“Microsoft Access Driver (.mdb)”。如果你的程序死活连不上,先查这个驱动名,这是新手最容易翻车的地方。第二个参数 noOdbcDialog 一定要给,否则程序跑起来会弹一个 ODBC 配置窗口,答辩现场非常尴尬。连接字符串里的 DBQ 是 Access 文件的绝对路径,建议把数据库文件和 exe 放同一目录,程序启动时用 GetModuleFileName 获取当前目录再拼接文件名,避免硬编码路径。
3.3 把课程表装进 CListCtrl:加载、排序与刷新
连接上数据库之后,第一个要实现的交互就是课程列表的展示。CListCtrl 的用法有几个固定的坑,这里给出一个加载课程列表的完整函数,我在多个项目里都是这个模板:
void CMainDlg::LoadCourseList(CListCtrl& listCtrl, LPCTSTR lpszFilter) { // 1. 刷新前清空旧数据,否则二次加载会残留上一次的行 listCtrl.DeleteAllItems(); // 2. 打开记录集,查询条件由外部传入,支持搜索 CRecordset rs(&g_db); CString strSQL; if (lpszFilter == NULL || _tcslen(lpszFilter) == 0) strSQL = _T("SELECT course_id, course_name, teacher, credit, capacity, selected FROM course ORDER BY course_id"); else strSQL.Format(_T("SELECT course_id, course_name, teacher, credit, capacity, selected FROM course WHERE course_name LIKE '%%%s%%' ORDER BY course_id"), lpszFilter); rs.Open(CRecordset::forwardOnly, strSQL); // 3. 逐行读取记录,插入列表控件;InsertItem 后 SetItemText 填充各列 int nIndex = 0; while (!rs.IsEOF()) { CString id, name, teacher, credit, cap, sel; rs.GetFieldValue(_T("course_id"), id); rs.GetFieldValue(_T("course_name"), name); rs.GetFieldValue(_T("teacher"), teacher); rs.GetFieldValue(_T("credit"), credit); rs.GetFieldValue(_T("capacity"), cap); rs.GetFieldValue(_T("selected"), sel); // 余量字段动态计算:容量 - 已选 int nCap = _ttoi(cap), nSel = _ttoi(sel); CString remain; remain.Format(_T("%d"), nCap - nSel); listCtrl.InsertItem(nIndex, id); listCtrl.SetItemText(nIndex, 1, name); listCtrl.SetItemText(nIndex, 2, teacher); listCtrl.SetItemText(nIndex, 3, credit); listCtrl.SetItemText(nIndex, 4, cap); listCtrl.SetItemText(nIndex, 5, sel); listCtrl.SetItemText(nIndex, 6, remain); nIndex++; rs.MoveNext(); } // 4. 关闭记录集,释放数据库游标 rs.Close(); }这段代码里有三个要点值得反复看。第一,DeleteAllItems 必须放在最前面,否则第二次刷新时数据会叠在旧数据下面,这是 CListCtrl 最典型的“数据看着像没更新”问题。第二,GetFieldValue 拿到的都是 CString,数字字段需要转成 int 再算余量,直接拿字符串拼接会导致余量显示成“12-5”这种诡异格式。第三,forwardOnly 类型的记录集只能向前移动,所以必须用 MoveNext 循环,不能用随机定位。如果你需要实现点击列头排序,就不能再用 forwardOnly,要改成 dynaset 并把列表数据先装进内存数组。
4. 选课与退课的核心逻辑:事务、余量判断与冲突检测
4.1 选课前要做哪三道检查,顺序不能乱
选课按钮的逻辑是整个系统里最容易写漏的。很多新手直接写一句“INSERT INTO enroll VALUES(...)”就完事,然后出现一人选多门相同课、课程爆满还能选进去、选完发现学分超限。我在第三版系统里总结出了一个固定的检查顺序,按这个顺序踩过所有坑:
第一查重复选课:先查 enroll 表里是否已有该生该课的记录,有就直接提示,不再往下走。第二查余量:把课程表里的 capacity 和 selected 拿出来比较,selected >= capacity 就提示课程已满。第三查学分上限:把该生已选课程的学分累加,加上当前课程学分,超过设定阈值就拒绝。这三步顺序不能乱,因为它们的代价是递增的:查重复是最快的单条 SELECT,查余量需要读课程表,查学分上限需要 JOIN 多张表,先做便宜的检查再做贵的检查,逻辑上更合理。
4.2 用一行 UPDATE 做余量扣减和防超选
查完三道检查后,真正的选课操作包含两个步骤:往 enroll 表插入记录,以及把 course 表的 selected 字段加一。这两个操作必须放在一个事务里,否则插入成功但余量没扣,下次选课就会把课程撑爆。更关键的是,余量扣减不能写成“先 SELECT selected,在代码里加一,再 UPDATE”,因为两个窗口同时操作同一门课时会出现竞态条件,也就是经典的“最后一席被两个人同时抢到”。
正确的做法是用一条带条件的 UPDATE 原子完成扣减,让数据库来判断余量是否足够:
BOOL EnrollCourse(LPCTSTR stuId, LPCTSTR courseId) { // 事务开始前先检查重复选课(此处省略查重代码) // ... CString strSQL; // 原子扣减:只有当 selected < capacity 时才会更新成功 // 使用 CDatabase::ExecuteSQL 直接执行,不走记录集 strSQL.Format(_T("UPDATE course SET selected = selected + 1 WHERE course_id = '%s' AND selected < capacity"), courseId); // 开始事务 g_db.BeginTrans(); BOOL bOK = TRUE; try { // 第一步:扣减余量,检查影响行数 // CDatabase 没有直接返冄影响行数的接口,这里用 ExecuteSQL // 更可靠的做法是用 CRecordset 的 GetRowCount,但需要驱动支持 g_db.ExecuteSQL(strSQL); // 第二步:插入选课记录 strSQL.Format(_T("INSERT INTO enroll (stu_id, course_id, enroll_time) VALUES ('%s', '%s', datetime('now','localtime'))"), stuId, courseId); g_db.ExecuteSQL(strSQL); // 都成功再提交事务 g_db.CommitTrans(); bOK = TRUE; } catch (CDBException* e) { // 任何一步失败都回滚,保证余量和记录一致 g_db.RollbackTrans(); e->ReportError(); e->Delete(); bOK = FALSE; } return bOK; }这段代码的核心技巧是把“判断余量”和“扣减余量”合并进同一条 UPDATE 的 WHERE 条件里。selected < capacity 既是一个业务判断,也是一个并发锁:数据库执行 UPDATE 时会对该行加锁,两个并发请求只有一个能成功,另一个更新影响行数为 0,程序据此判定选课失败。这就绕开了 MFC 里 CRecordset 做并发控制的复杂问题,原理不复杂但非常实用。事务的边界要圈住“扣减余量”和“插入记录”两个操作,任何一个失败都回滚,这样就不会出现 enroll 有记录但 course 余量不动,或者余量扣了但选课记录没写进去的脏数据。
4.3 退课与“后悔药”:恢复余量、释放记录
退课是选课的逆操作,同样需要事务保护。逻辑上很简单:从 enroll 表删除记录,同时把 course 表的 selected 减一。删除前要确认这条记录确实存在,而且只能由选课人本人操作。这里有一个细节:如果用户已经因为超学分被限制选课,退课时同样要先算余量——这门课本来就满的,selected 减一之后不能变成负数。虽然正常情况不会发生,但做一下边界判断总归更稳。
退课的代码结构跟选课完全对称,区别在于 UPDATE 的方向是递减,WHERE 条件里加一个 selected > 0 防止负值。另外退课完成后,主界面的课程列表和“我的课表”都要联动刷新,否则界面还显示旧状态,用户会以为退课失败。刷新时机放在事务提交成功之后,不要在回滚路径里刷新。
5. MFC 选课系统避坑:五个血泪经验换来的排查清单
5.1 乱码:列表控件里中文显示成问号
现象:程序运行后,课程名称和教师姓名在 CListCtrl 里显示成一串“???”或者乱码。原因:项目字符集设置和源文件编码不一致,最常见的是工程用了多字节字符集,但代码文件里写了带中文的字符串常量,或者数据库里的中文字段读取时编码不匹配。解决:打开项目属性,把“字符集”从“使用多字节字符集”改成“使用 Unicode 字符集”,同时把所有 CString 相关的格式化函数统一用 _T() 宏包住字符串。如果已经改了 Unicode 还乱码,检查 Access 数据库表字段的类型是不是“文本”,有些同学建表时不小心把字段设成了“备注”,读取时编码处理会异常。这个坑在课程设计里出现频率极高,十个人里至少三个中招。
5.2 数据库连接失败:驱动名不一致,玄学的另一半是位数问题
现象:程序在开发机上跑得好好的,换一台电脑就弹“数据库连接失败”。原因:目标机器没有安装对应的 Access 数据库引擎,或者 Office 版本从 32 位换成了 64 位,导致 ODBC 驱动名对不上。解决:程序发布时,把 Access 数据库引擎安装包一起带上,并且在连接字符串里动态检测驱动。检测方法很简单,用 SQLGetInstalledDrivers 遍历本机已安装的 ODBC 驱动,选包含“Access Driver”的那个驱动名拼进连接字符串。另一个常见情况是程序编译成 32 位,但机器上只有 64 位的 Access 驱动,二者不匹配。我一般会把整个解决方案平台改成 x86 发布,因为 Access 驱动在 32 位下兼容性最好,不要用 Any CPU。
5.3 刷新列表后数据“叠罗汉”:旧行不消失
现象:点刷新按钮后,列表里新数据下面还留着旧的课程行,甚至能看到重复记录。原因:LoadCourseList 函数里忘了 DeleteAllItems,或者调用时机不对。解决:把 DeleteAllItems 放在函数最前面,确保数据源重新加载前旧数据已经清空。还有一个隐蔽情况,如果 CListCtrl 设置了“整行选中”属性,DeleteAllItems 后表头还在,但行数据必须重新 InsertItem,不要直接在已有行上 SetItemText。另外子项文本的列索引从 0 开始,InsertItem 是第一列,SetItemText 从第二列开始填充,这个顺序别搞反。
5.4 选课卡死:长时间无响应,原因是记录集没关闭
现象:选课或刷新操作偶发卡死,程序转圈,只能强制结束进程。原因:CRecordset 对象打开后没有及时 Close,数据库游标被占用,后续的 SQL 操作被阻塞。MFC 的 CRecordset 在析构时会自动关闭,但如果你在循环里频繁 Open 而不 Close,在 Access 这种文件型数据库上很容易撞上文件锁。解决:养成习惯,每次用完记录集显式调用 Close,把 rs 对象的作用域缩到最小,尽量在一个函数内完成打开、读取、关闭的全过程。事务操作也一样,BeginTrans 之后一定要有 CommitTrans 或 RollbackTrans 与之配对,漏掉一个就会让数据库进入悬挂事务状态。
5.5 卸不掉的黑匣子:Debug 运行正常,Release 发布就报错
现象:在 Visual Studio 里按 F5 一切正常,双击 Release 版 exe 却闪退或者弹错误。原因:最常见的是初始化 MFC 控件库的代码被裁剪。对话框程序里的 CListCtrl、CTreeCtrl 属于公共控件,MFC 在初始化时需要通过 InitCommonControlsEx 注册这些控件类,Debug 模式下库会自动加载,Release 下有时会失效。解决:在应用程序类的 InitInstance 最前面调用 AfxOleInit 和 InitCommonControlsEx,并把 _AFXDLL 和 _UNICODE 等宏的链接选项检查一遍。顺带一提,Release 版闪退还有一个高频原因是局部变量没初始化,特别是指针类型的控件变量,Debug 下内存自动清零掩盖了问题,Release 下随机值直接让程序崩溃。代码里所有控件变量的指针声明后立即置 NULL。
6. 进阶:让老师舍得给分的三个改进点,和一个演示技巧
改进点一,给列表加搜索过滤。在课程列表上方放一个编辑框,用户输入课程名关键字,LoadCourseList 函数的 lpszFilter 参数传入关键字,SQL 里用 LIKE 模糊匹配。这一步改动量不大,但把“系统只能看全部课程”升级成了“系统支持检索课程”,答辩话术立刻多了一个可讲点。改进点二,做一份选课统计报表。用一个独立的对话框,列出每门课的选课人数、余量、选课率,数据从 enroll 表 GROUP BY 后 JOIN course 表获取,展示方式可以用另一个 CListCtrl 或者简单画一个柱状图——MFC 里用 CStatic 控件自绘也不难。改进点三,把密码改成 MD5 存储。数据库里 admin_pwd 和 student 表的 stu_pwd 不存明文,登录时对输入密码做 MD5 后比对。MFC 里没有现成的 MD5 封装,写一个几十行的工具函数即可。三个点做完,系统的完整度和专业感会明显上一个台阶。
演示技巧讲一个最实用的:提前准备一份“压力测试”脚本。答辩时老师通常会问“多人同时选课会怎样”,你不能真拿两台电脑演示,但可以在数据库里预先造几百条学生数据,再用一个循环快速执行选课操作。写一个 Debug 用的按钮,批量给同一名学生选 50 门课,故意触发学分上限和重复选课的拦截提示。现场演示时点这个按钮,程序连续弹出“重复选课”“学分超限”“课程已满”三个对话框,比口头解释有说服力得多。我在某次答辩前灵机一动加了这个小功能,当场演示后老师只追问了一个问题:“这个事务隔离级别是怎么设的?”——能问到这个问题,就说明他已经默认这套系统是完整可用的了。希望这篇拆解能帮你把 MFC 选课系统做扎实,少踩几个我当年踩过的坑,祝答辩顺利。
本文还有配套的精品资源,点击获取