资讯详情

MFC选课系统源码:时间冲突、容量扣减与事务实现

📅 2026/11/4 20:58:18 | 华诺云谱 👁 阅读
MFC选课系统源码:时间冲突、容量扣减与事务实现
简介这是一套基于 MFC 框架开发的学生选课系统源码工程面向计算机专业学生、课程设计开发者及需要 Windows 桌面项目参考的开发者重点解决选课管理中角色权限、课程维护与选课流程实现等问题。工程按学生、教师、管理员三类角色组织功能包含课程信息展示、选课退课、课程发布、数据统计与消息通知等模块适合用作课程设计、毕业设计或 MFC 入门到进阶的完整案例。压缩包共 85 个文件约 3.11MB主要包括 20 个 h 头文件、19 个 cpp 源文件、19 个 obj 编译中间文件以及 bmp/ico 界面资源、rc 资源脚本、mdb 数据库和可执行 exe 等源码、界面素材与数据库文件相对齐全便于对照理解 VC6/MFC 工程结构。已有 257 人学习下载。读者可从中获得可运行的选课系统原型、数据库表关系与角色权限设计思路并参考课程管理、选课冲突处理和统计模块的具体实现用于二次开发或功能拆解学习。1. 从一份能编译的 MFC 选课客户端说起它到底补了哪几个坑资源站里带 MFC 字样的学生选课系统源码一抓一大把真正能编译过、还能顺手动两行代码的不到三成——大部分是只有界面壳子、选课按钮点下去只弹个 MessageBox 的作业残骸。这套增强版选课系统属于少数能跑通全链路的那类一个 MFC 桌面客户端配一份可直接导入的课程数据库登录后按角色分流学生端查课、选课、退课、看周课表管理端维护课程容量与上课时间段。适合两类人一是课程设计要交活的同学拿它当骨架改界面就能交二是想找一份完整增删改查加冲突校验样例练 MFC 的开发者。下面按工程骨架、选课核心逻辑、界面层、编译排查、二次开发五段拆重点讲选课那几行代码为什么非得那样写。2. 工程骨架MFC 文档视图结构怎么装下一套选课业务很多人拿到 MFC 工程第一反应是找 main 函数找不到就慌。MFC 的入口被框架藏在CWinApp::InitInstance里理解这一点之后剩下的问题就变成「业务代码往哪放」。这套选课系统的做法比较朴素单解决方案、单可执行工程内部按职责分文件夹文档类只做数据容器真正的查询逻辑全压在 DAO 层界面层只负责取数和展示。2.1 目录结构与工程划分CourseSelect/ ├─ CourseSelect.sln ├─ CourseSelect/ │ ├─ CourseSelect.cpp // CWinApp 派生类程序入口 │ ├─ MainFrm.cpp // 主框架窗口负责视图切换 │ ├─ CourseSelectDoc.cpp // 文档类持有当前登录用户与课程集合 │ ├─ CourseSelectView.cpp // 学生端视图 │ ├─ dlg/ // 登录、选课、课程维护等对话框 │ ├─ dao/ // 数据访问层所有 SQL 收敛在这里 │ ├─ model/ // 实体结构体Course、Student、Enrollment │ ├─ grid/ // 课表绘制控件 │ ├─ res/ // 图标、位图、对话框资源 │ └─ db/ // 数据库文件与建表脚本 └─ docs/这个划分的关键点在dao/这一层。血泪经验是MFC 新手最容易把SELECT语句直接写在按钮的响应函数里一个学期改下来同一个查询语句在五个对话框里出现五份不同版本改一个字段要翻遍全工程。把 SQL 收进 DAO界面层调CourseDao::QueryByStudent(id)就行后面换数据库、加缓存、加日志都只动一处。model/里放纯结构体而不是CRecordset派生类也是有意的。CRecordset绑定字段虽然省事但一旦要做多表联查、要在子线程里取数它的生命周期管理会变得很难看。结构体加std::vector的组合更直白也方便把数据丢给后台线程。2.2 数据表设计与字段含义选课系统的表不多但字段设计直接决定了后面冲突检测好不好写。核心四张表表名作用关键字段Student学生账号与身份stu_id、stu_name、password、majorCourse课程与容量course_id、course_name、teacher、credit、day_of_week、start_section、end_section、capacity、selectedEnrollment选课关系id、stu_id、course_id、select_time、statusAdmin管理员账号admin_id、password课程表里把上课时间拆成「星期几 起始节 结束节」三个整数字段而不是存一个字符串。这是整套代码里最值得抄的一个决定字符串存时间冲突判断就得先解析再比较而整数区间判断只需要两次比较。学分、容量同样用int避免浮点在比较时出现的意外。提示如果课程要跨多个时间段上课别急着往 Course 表加字段另开一张CourseSlot表存(course_id, day_of_week, start_section, end_section)一对多关系冲突检测时按课程聚合所有时段逐个比对。建表脚本建议单独放一个init.sql交付时让对方先执行再跑程序CREATE TABLE Course ( course_id VARCHAR(16) PRIMARY KEY, course_name VARCHAR(64) NOT NULL, teacher VARCHAR(32), credit INT DEFAULT 2, day_of_week INT NOT NULL, -- 1周一 ... 7周日 start_section INT NOT NULL, -- 第几节开始 end_section INT NOT NULL, -- 第几节结束 capacity INT NOT NULL DEFAULT 60, selected INT NOT NULL DEFAULT 0 );capacity是计划容量selected是已选人数两个字段分开存而不是每次COUNT(*)统计是为了让「选课」这个动作能压缩成一条带条件的 UPDATE后面第 3 章会细讲。代价是要保证两者一致所以退课、扩容、批量导入这几条路径上都得同步维护selected。2.3 文档类、视图类与对话框的职责边界MFC 的文档视图架构在教学里常被讲成「文档存数据、视图做展示」实际写业务时这条边界很容易糊。这套代码的约定是文档类只持有「当前登录用户」和「当前查询结果集」两个成员视图负责把结果集刷到控件上对话框负责收集输入并调用 DAO三者都不直接持有数据库连接。数据库连接用单例管起来初始化放在InitInstance里BOOL CCourseSelectApp::InitInstance() { CWinApp::InitInstance(); // 初始化 OLEADO 依赖 COM 环境 if (!AfxOleInit()) { AfxMessageBox(_T(OLE 初始化失败)); return FALSE; } // 全局连接只建一次后续 DAO 复用 if (!DbManager::Instance().Open(_T(ProviderMicrosoft.ACE.OLEDB.12.0; Data Sourcedb\\course.accdb;))) { AfxMessageBox(_T(数据库连接失败检查 db 目录)); return FALSE; } CMainFrame* pFrame new CMainFrame; m_pMainWnd pFrame; pFrame-LoadFrame(IDR_MAINFRAME); // 不直接进学生视图先弹登录框 pFrame-ShowLoginDialog(); return TRUE; }AfxOleInit()这一句不能省ADO 的_ConnectionPtr建立在 COM 之上不初始化会直接抛异常而且异常信息很难看。连接串里的Data Source用相对路径调试期方便但发版前必须换成基于GetModuleFileName拼出来的绝对路径否则用户双击 exe 时工作目录不对直接连不上库——这个坑第 5 章还会再提一次。ShowLoginDialog()返回后根据角色决定加载哪个视图管理端走CCourseAdminView学生端走CCourseSelectView。这样主框架只负责切换不掺业务判断后面加第三种角色比如教师端查选课名单时改动量很小。3. 选课核心逻辑时间冲突、容量扣减和事务边界界面做得再花选课系统的命门就三件事不能让学生选到时间冲突的课、不能超卖容量、退课之后状态要能正确回滚。这三件事写错任何一件答辩现场演示时都可能翻车。这套代码在这三处都做了明确处理逻辑不复杂但每一处的写法都有讲究。3.1 冲突检测把时间换算成整数区间再比冲突判断的本质是两个区间是否重叠。周几相同、节次区间有交集就算冲突。写成代码就是两次比较// 判断两门课在时间上是否冲突 // a、b 分别为已选课程和新课程的时段 bool IsTimeConflict(const CourseSlot a, const CourseSlot b) { if (a.day_of_week ! b.day_of_week) return false; // 不同天必然不冲突 // 区间重叠的经典判定开始早于对方结束且结束晚于对方开始 return (a.start_section b.end_section) (b.start_section a.end_section); }参数就是三个整数day_of_week取值 1 到 7start_section和end_section是节次编号。判定条件用的是半开区间思路——第 1 到 2 节和第 2 到 3 节算冲突因为第 2 节重叠了第 1 到 2 节和第 3 到 4 节不算。如果你的教务规则允许首尾相接不算冲突就把改成但一定要在需求里写清楚否则学生端和管理端的判断会不一致。调用侧的逻辑是先按学号查出该生所有status 1的选课记录关联出对应课程的时段逐个和待选课程比对。课程数量在几百门量级时这种 O(n) 遍历完全够用不需要上什么区间树。真要优化也是先按day_of_week分桶把比对范围缩小到同一天。注意跨周次的课比如单双周要在CourseSlot里补start_week、end_week两个字段冲突判定再加一层周次区间是否重叠。只判节次不判周次单周课和双周课会被误判成冲突。3.2 容量扣减一条带条件的 UPDATE 顶掉一把锁超卖是选课系统最经典的并发问题。两个人同时点选课都读到selected 59、capacity 60都判断「还有名额」然后都执行UPDATE ... SET selected 60结果 61 个人选了 60 个位置的课。单机 MFC 程序看起来不会并发但同一份数据库可能被多个客户端连或者管理端正在批量导入问题一样会出现。解决办法是把「判断」和「扣减」合并成一条原子语句// 尝试占一个名额返回 true 表示占位成功 bool CourseDao::TryOccupySeat(const CString courseId) { CString sql; sql.Format(_T(UPDATE Course SET selected selected 1 ) _T(WHERE course_id %s AND selected capacity), courseId); _variant_t affected; m_conn-Execute(_bstr_t(sql), affected, adCmdText); // 受影响行数为 0 说明名额已满或课程不存在 return (long)affected ! 0; }WHERE selected capacity这个条件就是全部的秘密数据库在执行 UPDATE 时会对目标行加锁判断和自增在同一个语句内完成不会给其他连接插入的机会。返回的受影响行数用_variant_t接收adCmdText告诉 ADO 这是一条文本命令不加这个参数在某些驱动下会报「参数不足」。选课的完整顺序是先查冲突通过后调TryOccupySeat返回成功再插Enrollment记录最后提交事务。三步的顺序不能反先插记录再扣容量的话扣减失败就得回滚记录逻辑更绕。3.3 退课与候补队列顺序与幂等处理退课不是简单地删一条记录它要连带把selected减回去还要处理候补队列。这套代码的候补表结构是(course_id, stu_id, queue_time)按queue_time升序取第一个人。事务边界这样划BEGIN TRANSACTION; -- 1. 退课只更新状态不物理删除方便审计 UPDATE Enrollment SET status 0 WHERE stu_id ? AND course_id ? AND status 1; -- 2. 释放名额 UPDATE Course SET selected selected - 1 WHERE course_id ? AND selected 0; -- 3. 取出候补队首 SELECT TOP 1 stu_id FROM WaitList WHERE course_id ? ORDER BY queue_time ASC; COMMIT;第 1 步用状态位而不是DELETE是为了留一条后悔药学生误退、教务要核查时status一改就能恢复物理删除就只剩翻备份了。第 2 步的selected 0是防御性条件防止因为历史脏数据把人数减成负数。第 3 步取出的候补学生实际补位动作交给下一轮选课流程去做而不是在退课事务里直接帮他选上——因为候补学生可能已经有时间冲突的课了得重新走一遍完整校验。幂等这块也踩过坑同一个学生快速双击选课按钮会发出两次请求。Enrollment表上对(stu_id, course_id, status)建唯一索引可以挡住重复记录但更友好的做法是在扣容量前先查一次「是否已选」已选就直接提示避免白白消耗一次 UPDATE。4. 界面层实现登录、课表绘制和列表控件的联动MFC 的界面代码写起来啰嗦但选课系统需要的控件其实就那么几个登录框、课程列表、周课表、几个按钮。难点不在控件本身在于「数据从库里取出来后怎么稳定地刷到界面上以及刷新时别把选中状态弄丢」。4.1 登录与角色路由登录对话框是个标准CDialogEx派生类两个编辑框加一个下拉框选身份。点确定时先做非空校验再按身份查不同的表void CLoginDlg::OnBnClickedOk() { UpdateData(TRUE); // 把控件内容同步到成员变量 if (m_stuId.IsEmpty() || m_pwd.IsEmpty()) { AfxMessageBox(_T(账号和密码不能为空)); return; } // 学生和管理员分表查询DAO 内部区分 int role UserDao::Instance().CheckLogin(m_stuId, m_pwd); if (role ROLE_NONE) { AfxMessageBox(_T(账号或密码错误)); return; } m_role role; CDialogEx::OnOK(); // 关闭对话框返回值给调用方 }UpdateData(TRUE)这个方向别写反TRUE是从控件到变量FALSE是变量到控件。写反了会出现「输入了新账号程序还在用旧值校验」的玄学现象查半天以为是数据库问题。主框架拿到m_role之后决定加载哪个视图。这里用CSplitterWnd或者直接CreateView都行关键是切换视图前把旧视图销毁干净否则两个视图同时响应菜单命令会出现「管理端菜单在学生端也能点」。4.2 课表网格CListCtrl 报表模式够用别一上来就自绘周课表看起来像要自绘其实用CListCtrl的报表模式就够了列是周一到周日行是节次单元格里填课程名。初始化代码如下void CCourseSelectView::InitScheduleCtrl() { // 报表模式禁止整行选中避免误操作 m_list.SetExtendedStyle(LVS_EX_FULLROWSELECT | LVS_EX_GRIDLINES); m_list.InsertColumn(0, _T(节次), LVCFMT_CENTER, 60); for (int i 1; i 7; i) { CString head; head.Format(_T(周%d), i); m_list.InsertColumn(i, head, LVCFMT_CENTER, 110); } // 每天最多 12 节先铺好空行后面按坐标填 for (int sec 1; sec 12; sec) { CString label; label.Format(_T(第%d节), sec); m_list.InsertItem(sec - 1, label); } }填数据时课程的start_section决定行号day_of_week决定列号SetItemText(行, 列, 课程名)一行就填进去了。跨节的课要在起始行填内容其余行填空字符串或者用LVS_EX_GRIDLINES加背景色区分。这个方案的好处是数据绑定极简单维护成本低坏处是单元格样式不可控想要圆角、渐变色这种效果再考虑自绘OnCustomDraw。刷新课表时有个细节容易忽略先DeleteAllItems()再重新插行会把用户的滚动位置和选中项全部重置。如果只是加了一门课更好的做法是只更新对应的那一个单元格滚动条保持不动。4.3 后台线程取数与 UI 线程刷新课程数据量上千条时如果直接在按钮响应里查库、填充控件界面会卡顿几百毫秒学生连点几下就假死。常规做法是开工作线程查库查完把结果通过PostMessage丢回 UI 线程填充UINT CCourseSelectView::LoadCourseThread(LPVOID pParam) { CCourseSelectView* pView (CCourseSelectView*)pParam; // 子线程里只碰数据库绝不碰控件 std::vectorCourse courses CourseDao::Instance().QueryAll(); // 堆上分配接收方负责 delete避免悬垂指针 auto* pResult new std::vectorCourse(std::move(courses)); pView-PostMessage(WM_COURSE_LOADED, (WPARAM)pResult, 0); return 0; }这里有三条纪律。第一子线程绝对不能直接调SetItemTextMFC 控件不是线程安全的偶发崩溃很难复现。第二new出来的是数据拷贝不能用栈变量地址或者视图成员地址传工作线程退出后指针就悬空了。第三WM_COURSE_LOADED的自定义消息处理函数里填完控件记得delete pResult否则每刷一次泄漏一块内存。看到程序跑一天内存涨到几百兆八成就是这里漏的。提示PostMessage和SendMessage的区别要分清。PostMessage是投递到消息队列后立即返回不阻塞工作线程SendMessage是同步等待窗口过程执行完在子线程里调SendMessage更新 UI一旦主线程正在等锁会直接死锁。5. 编译、运行与排查几个新手最容易翻车的点这套工程在别人机器上跑不起来九成不是代码问题而是环境、路径、字符集这三样没对齐。下面按「装什么 → 编不过怎么办 → 跑起来不对怎么查」的顺序排最后几条排查记录都是实际处理过的。5.1 环境与依赖清单项要求说明开发工具VS 2019 及以上安装时必须勾选「使用 C 的桌面开发」里的 MFC 组件字符集Unicode工程属性里设为「使用 Unicode 字符集」新代码统一用_T()包裹数据库驱动ACE OLEDBAccess 2007 及以上格式需要装对应驱动老版 Jet 驱动连不上 accdb运行库与编译选项一致Debug 版带着调试运行库发给别人要出 Release 版数据库文件放在 exe 同级 db 目录否则连接字符串找不到文件MFC 组件没装是最高频的问题报错形态是Cannot open include file: afxwin.h。这时候别急着改代码打开安装器补组件装完重启工具链才生效。5.2 编译期与链接期报错找不到 afxwin.hMFC 组件缺失补装即可和代码无关。LNK2019 无法解析的外部符号一般是只在头文件里声明了函数忘了写实现或者.cpp没加进工程。注意 MFC 的DECLARE_MESSAGE_MAP和BEGIN_MESSAGE_MAP必须成对出现漏了后者消息响应函数全部链接不上。C2664 无法将参数从 const char转换为 LPCTSTR*字符集不统一。项目设了 Unicode但某行代码里写了hello而不是_T(hello)编译器按多字节去匹配宽字符接口就报这个。最省事的修法是全工程统一加_T()别混着用L和。编译通过但 exe 起不来提示缺少 dllRelease 版依赖的运行库没装到目标机器要么让对方装运行库要么改成静态链接运行库工程属性 → C/C → 代码生成 → 运行库选/MT。5.3 运行期排查清单五条踩坑记录现象一双击 exe 直接弹「数据库连接失败」但在开发环境里跑得好好的。原因是连接串里写的是相对路径db\course.accdb工具链运行时的工作目录是工程目录双击 exe 时工作目录变成了 exe 所在目录两边不在一个地方。解决办法是在InitInstance里用GetModuleFileName拿到 exe 绝对路径再拼上db\course.accdb不依赖当前工作目录。现象二课程名、教师名全变成问号或者乱码。原因是数据库存的是 Unicode而连接串或者读取环节按多字节处理了。Access 的 ACE 驱动默认走 Unicode问题多出在CString赋值时用了char*中转。排查方法是把字段直接printf到调试输出看原始字节确认是读取环节还是显示环节坏的。修法是全程CString_T()中间不做char转换。现象三选课提示成功但课表列表里看不到新选的课。原因是只更新了数据库没有刷新内存里的结果集和控件。DAO 执行完 UPDATE 后视图层还拿着上一次查询的vector。解决方式是选课成功后重新查一次该生的课表并重填控件或者本地同步往vector里push_back一条再局部刷新那一行。前者简单可靠后者性能好看数据量选。现象四同一门课连点两次选课第二次报数据库唯一约束冲突。原因是没做幂等判断第一次插入了Enrollment记录第二次又插一条唯一索引挡住了但异常没捕获。修法是在扣容量之前先查一次「该生该课是否已有status 1的记录」有就直接提示「已选过」同时用try/catch包住_com_error把数据库异常转成友好提示。现象五Debug 版一切正常Release 版点几下就崩。这类问题十有八九是未初始化变量。Debug 下局部变量和成员变量会被填成0xCC之类的固定值凑巧能跑Release 下是随机栈内容指针一解引用就飞。排查方法是打开编译器的警告等级到/W4把「可能未初始化」的警告逐条清掉成员变量在构造函数初始化列表里全部给初值。6. 二次开发把单机版改成能扛住选课高峰的几点动手思路拿到这套代码后最常见的改造需求有三个换数据库、加功能、提升并发能力。前两个是体力活第三个才需要动结构。按我的经验改造顺序应该是先补日志、再换库、最后才碰并发反过来做会陷入「不知道哪里出错」的黑匣子状态。第一件事是给 DAO 层加执行日志。在DbManager里包一层Execute把 SQL 语句、参数、耗时、受影响行数写进按天滚动的日志文件。单机版出问题时你能直接开调试器一旦部署到机房多台机器上日志就是唯一的后悔药。第二件事是把 Access 换成 SQL Server。表结构基本不用动改的是连接串和几个类型VARCHAR换NVARCHAR、自增主键换IDENTITY。换完之后TryOccupySeat那条原子 UPDATE 依然有效而且在真正的并发下才体现出价值。第三件事才是并发优化。几个改造项对比一下改造项做法收益风险连接池用_ConnectionPtr池代替单例连接多客户端下减少握手开销连接泄漏难排查选课走存储过程冲突检测和扣容量合并进一个事务减少网络往返原子性更强逻辑分散到数据库侧课程列表加缓存内存缓存 定时刷新首屏加载从秒级降到毫秒级缓存与库不一致操作审计表记录每次选退课的操作人和时间纠纷可追溯表增长快要定期归档我的建议是只做存储过程和审计表这两项缓存和连接池留到确实有性能数据支撑时再上。见过太多项目一上来就堆缓存结果选课人数和实际记录对不上排查了两天才发现是缓存没失效。还有一个容易被忽略的改造点把课表导出成 Excel。教务那边经常要一份名单用CListCtrl的数据直接写 CSV 最省事注意中文用 UTF-8 带 BOM 写出不然对方用表格软件打开全是乱码。最后说一个习惯。我现在改这类老工程第一步永远是先把原始版本完整编译一遍、跑通选课退课流程、截个图存着然后再动第一行代码。从那以后我每次接二手 MFC 项目都强制走一遍「原版能跑」的验证因为一旦改崩了你至少知道是往回退到哪个状态而不是在一堆互相冲突的改动里猜。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑