☰
可直接编译的MFC选课系统:ADO+SQL Server事务与存储过程
2026/11/4 23:52:10 网站建设 项目流程

简介:面向高校课程设计与MFC入门进阶者的增进版学生选课系统,定位于提供一套可直接参考的Windows桌面选课管理实现,帮助解决传统选课操作繁琐、响应慢、功能分散等问题。压缩包共85个文件、约3.11MB,包含h与cpp源代码、obj/pdb等编译调试文件、bmp/ico界面素材、mdb数据库、rc资源脚本以及dsp/dsw工程和exe,便于从界面、业务到数据层完整梳理。系统覆盖学生、教师、管理员多角色权限,课程信息维护、预选正选补选退选流程、选课统计、消息通知与权限验证等模块,并借助MFC改善界面交互。当前已有257人学习,适合课程设计、毕业设计选题参考及MFC项目练手,读者可据此理解选课业务建模、数据库表关系与工程组织方式,也能借鉴其功能拆分和排错思路。

1. 一套能直接编译的 MFC 选课系统:从教务老师那句「名额怎么又超了」说起

去年选课季,某高校教务处的老师拿着一张 Excel 表来找人帮忙,说某门选修课容量是 60,汇总出来却有 73 个学号,还有 3 个人名字重复。问题不在于表格,而在于整个流程:学生把选课信息报给班长,班长再发到群里,最后手工汇总,没有任何一处做过人数校验。

这套增进版 MFC 选课系统,解决的就是这一段流程。它是一个纯 C++ 的 MFC 对话框程序,底层通过 ADO 连接 SQL Server,分成学生、教师、管理员三种角色:学生选课退课查课表,教师录成绩看名单,管理员维护课程、学生和时间窗口。课程容量、上课时间冲突、重复选课这些容易出问题的地方,全部放进存储过程,并用事务控制。

适合三类人:正在找能跑起来的 MFC 课程设计或毕设项目的人;想把 C++ 桌面端和数据库事务串起来练一遍的人;以及真的需要给教研室做个小工具的人。哪怕你只是想搞懂「MFC 那套消息循环和 ADO 到底怎么配合」,把它当个能断点调试的样本,也比翻文档快。

2. 技术栈与工程骨架:MFC 对话框 + ADO + SQL Server 这三块怎么咬合

先说整体架构,否则后面看代码容易失去方向感。整个程序是单进程的 C++ 桌面应用,没有服务端,没有 Web 层,所有逻辑都在客户端。这在今天听起来很「老派」,但对一套选课系统来说反而合适:使用范围就一个机房、几十到几百人,装一次就能用,不需要谁去维护服务器和域名。

UI 层是 MFC 的对话框(CDialogEx),每个业务界面一个独立对话框资源;数据访问层是一个手写的 CDBHelper 单例,封装了 ADO 的 _ConnectionPtr;业务规则层不在 C++ 里,而是尽量下沉到存储过程和 SQL 语句。这个分法有个很实际的好处:规则改了不用重新编译 exe,改完存储过程直接生效,发给机房的包不用换。

2.1 三层各管一段:界面、数据访问、数据落地

界面层负责的事比想象中少。MFC 的对话框类只做三件事:收集用户输入、把参数传给数据访问层、拿到结果后刷新 CListCtrl 或 CComboBox。它不做校验,也不判断名额够不够,因为这些判断必须和写库在同一个事务里,放在客户端判断一定会出现并发问题。

数据访问层是 CDBHelper,一个进程内单例。为什么不每个对话框各开一条连接?因为 ADO 的 _ConnectionPtr 创建成本不低,而且连接一多,SQL Server 那边的会话数就上去了,机房机器配置一般,容易出现连接超时。常见做法是整个进程共用一条连接,用完不 Close,只在程序退出时释放。

数据落地是 SQL Server,建一个库、四张表加两个存储过程。有人会问能不能用 Access,可以,把连接串里的 Provider 换成 Microsoft.ACE.OLEDB.12.0,其余代码基本不用动。但 Access 在并发写的时候锁表行为比较玄学,真到几十个人同时点选课,你会看到一堆「数据库或对象为只读」的报错。所以默认还是走 SQL Server。

// DBHelper.h —— 全局唯一的数据库连接封装 class CDBHelper { public: static CDBHelper& Instance(); // 单例,全进程共用一条连接 bool Connect(LPCTSTR szConnStr); // 传入连接串,失败返回 false void Close(); _ConnectionPtr GetConn() const { return m_pConn; } _RecordsetPtr Query(LPCTSTR szSQL); // 只读查询,调用方负责 Close long Execute(LPCTSTR szSQL); // 返回受影响行数,用于 UPDATE / INSERT private: CDBHelper() {} CDBHelper(const CDBHelper&); CDBHelper& operator=(const CDBHelper&); _ConnectionPtr m_pConn; };

这段是整个工程的地基。Instance()用函数内静态对象实现,C++11 之后这种写法自带线程安全,不用自己加锁。Connect里调m_pConn->Open(...),参数顺序是连接串、用户名、密码、adConnectUnspecified,走 Windows 身份验证时中间两个传空。Query用adOpenStatic + adLockReadOnly打开记录集,这样游标可以前后移动,也方便统计行数。

连接串一般长这样,写在注册表或者同目录的 ini 里,不要硬编码:

// InitInstance 里,初始化 COM 之后再连库 if (!AfxOleInit()) // 必须!不调这句 ADO 直接抛 _com_error { AfxMessageBox(_T("OLE 初始化失败")); return FALSE; } CString strConn = _T("Provider=SQLOLEDB.1;Server=.\\SQLEXPRESS;") _T("Database=CourseSys;Trusted_Connection=Yes;"); if (!CDBHelper::Instance().Connect(strConn)) { AfxMessageBox(_T("数据库连接失败,请检查实例名和库名")); return FALSE; }

AfxOleInit()是最容易漏的一行。漏了它的典型表现是程序一启动就弹一个「尚未调用 CoInitialize」。连接串里Server=.\\SQLEXPRESS的.代表本机,\\是 C++ 字符串里的转义,实际传给 ADO 的是.\SQLEXPRESS。如果装的是默认实例,就直接写Server=localhost或者机器名。Trusted_Connection=Yes走 Windows 身份验证,省掉在代码里存明文密码的麻烦;要用 SQL 账号就把这段换成User Id=sa;Password=***;。

提示:连接串里加Connect Timeout=5,网络不通的时候默认要等十几秒才报错,加了之后界面不会卡那么久。

2.2 工程目录与对话框资源清单

工程用 Visual Studio 建,模板选「基于对话框的 MFC 应用」,不要选「单文档」。SDI 那套文档/视图结构对这套系统是纯负担,而且初学者很容易在 Doc/View 的消息传递里绕晕。基于对话框的工程,主界面就是一个 CDialog,所有子界面弹模态或非模态窗口,逻辑线清楚得多。

目录结构大致是这样:

StudentCourseSystem/ ├── StudentCourseSystem.sln ├── StudentCourseSystem/ │ ├── StudentCourseSystem.cpp/.h // CWinApp,InitInstance 在这里 │ ├── DBHelper.cpp/.h // ADO 封装 │ ├── Session.cpp/.h // 登录后的会话状态(当前学号、角色) │ ├── LoginDlg.cpp/.h // 登录 │ ├── AdminDlg.cpp/.h // 管理员主界面 │ ├── TeacherDlg.cpp/.h // 教师主界面 │ ├── StudentDlg.cpp/.h // 学生主界面 │ ├── SelectCourseDlg.cpp/.h // 选课 │ ├── MyCourseDlg.cpp/.h // 我的课表 / 退课 │ ├── GradeDlg.cpp/.h // 成绩录入与查询 │ ├── CourseEditDlg.cpp/.h // 课程增删改 │ └── res/ // 图标、对话框资源 └── sql/ ├── 01_schema.sql // 建表 ├── 02_proc_select.sql // 选课存储过程 └── 03_seed.sql // 测试数据

Session这个类很多网上能找到的同类项目里没有,登录完直接把学号存成全局变量CString g_strStuNo。这种写法撑不过三个界面:一旦学生开了两个窗口,或者在选课界面和课表界面之间来回切,全局变量就乱了。用一个CSession单例把StuNo、UserName、Role存起来,谁要用谁去取,改起来只改一处。

资源这边有两点要注意。一是对话框的 ID 命名统一前缀,比如IDD_LOGIN、IDD_STUDENT_MAIN、IDD_SELECT_COURSE,几百个控件混在一起时你会感谢自己。二是 CListCtrl 一律设成Report风格,加LVS_EX_FULLROWSELECT | LVS_EX_GRIDLINES扩展样式,不然选中一行的效果只有第一列变色,学生用起来会觉得没选中。

2.3 四张核心表:字段、主键与那两列容易漏的冗余

表设计决定了后面所有 SQL 的复杂度,值得单独拿出来说。核心就四张:用户表、课程表、选课关系表、配置表。

表名关键字段说明
UserUserNo(PK),UserName,Pwd,RoleRole 用 0/1/2 区分管理员/教师/学生,一张表管三种角色
CourseCourseNo(PK),CourseName,TeacherNo,Credit,Capacity,SelectedCount,DayOfWeek,SectionStart,SectionEnd,Term后五个字段是排课信息与名额,冲突检测全靠它们
SCStuNo+CourseNo(联合PK),Score,SelectTime选课关系,Score 为 NULL 表示还没出成绩
ConfigCfgKey(PK),CfgValue选课开始/结束时间、当前学期,避免改代码

Course.SelectedCount这一列是刻意的冗余。严格规范化的话,已选人数应该每次COUNT(*) FROM SC算出来,但选课界面一打开就要列出几十门课,每门课一句 COUNT,页面加载能卡两秒。所以加一列计数,选课时在同一个事务里+1,退课时-1。

代价是这列必须和SC表永远一致。血泪经验是:只要有人手工去数据库里删了一行 SC 记录,这个计数就飘了,界面上显示 59/60,实际已经满了。所以在sql/目录里备一条校准语句,怀疑数据不对的时候跑一下:

-- 校准 Course.SelectedCount,和 SC 表实际行数对齐 UPDATE c SET c.SelectedCount = (SELECT COUNT(*) FROM SC WHERE SC.CourseNo = c.CourseNo) FROM Course c;

DayOfWeek存 1~7,SectionStart/SectionEnd存节次(1~12)。用整数存节次而不是存时间点,是因为冲突判断要判断区间是否重叠,整数比较好写条件,也避免了「上午三四节究竟算 10:00 还是 10:05」这种扯不清的问题。

3. 登录与角色分流:一次点击怎么决定你看到哪个界面

登录看着简单,只有两个编辑框加一个按钮,但它是整个系统里唯一一个「未认证用户也能触发数据库访问」的入口,写漏一处就会出问题。很多同类项目会在这里写一句strSQL.Format("... WHERE UserNo='%s' AND Pwd='%s'", ...),输入框里打一个' OR '1'='1就能以管理员身份进去。这不是危言耸听,做过代码审计的人都知道这是最常见的入门级漏洞。

3.1 CLoginDlg 的账号校验:把密码从字符串拼接里拿出来

正确的做法是用 ADO 的参数化命令。占位符用?,参数按顺序 Append,值通过CreateParameter传进去,SQL 文本里一个引号都不出现,注入就没有着力点了。

// CLoginDlg::VerifyUser —— 返回是否通过,nRole 带出角色,strName 带出姓名 bool CLoginDlg::VerifyUser(const CString& strNo, const CString& strPwd, int& nRole, CString& strName) { _CommandPtr pCmd; pCmd.CreateInstance(__uuidof(Command)); pCmd->ActiveConnection = CDBHelper::Instance().GetConn(); // 注意:占位符是 ?,参数顺序必须和 SQL 里出现的顺序完全一致 pCmd->CommandText = _T("SELECT UserName, Role FROM [User] WHERE UserNo=? AND Pwd=?"); pCmd->Parameters->Append(pCmd->CreateParameter( _T("no"), adVarChar, adParamInput, 20, (_bstr_t)strNo)); pCmd->Parameters->Append(pCmd->CreateParameter( _T("pwd"), adVarChar, adParamInput, 32, (_bstr_t)strPwd)); _RecordsetPtr pRs = pCmd->Execute(NULL, NULL, adCmdText); if (pRs->adoEOF) // 查不到就是账号或密码错,不区分提示,防止撞库 { pRs->Close(); return false; } strName = (LPCTSTR)(_bstr_t)pRs->Fields->GetItem(_T("UserName"))->Value; nRole = (long)pRs->Fields->GetItem(_T("Role"))->Value; pRs->Close(); return true; }

几个细节值得说。CreateParameter的五个参数依次是参数名、数据类型、方向、长度、值。adVarChar定长 20 对应UserNo的字段长度,长度写小了会被截断、写大了无所谓但没必要。adParamInput表示入参,如果是存储过程的 OUTPUT 参数就要写adParamOutput。

pRs->adoEOF这个属性名是被改过的。文件顶部#import的时候要写rename("EOF", "adoEOF"),因为 MFC 的CFile里已经有一个EOF宏,不改名会编译报一堆莫名其妙的错。

// stdafx.h / pch.h 里,导入 ADO 类型库 #import "C:\Program Files\Common Files\System\ado\msado15.dll" \ no_namespace rename("EOF", "adoEOF") rename("BOF", "adoBOF")

登录失败时统一提示「账号或密码错误」,不提示「该用户不存在」。这是个习惯问题,但值得养成:分开提示等于免费送出一个用户名枚举接口。

3.2 角色分流:三套对话框还是一个主框架加菜单

Role拿到之后怎么走?有两种做法。一种是一个主界面,按角色动态隐藏菜单项和按钮;另一种是三个独立的对话框类,登录完直接DoModal对应那个。这套项目选的是后者。

原因很实在:管理员要管课程、管学生、管选课时间窗,教师要看名单、录成绩,学生要选课、退课、查课表,三套功能的重合度几乎为零。塞进一个对话框,OnInitDialog里会变成一长串if (nRole == 0) m_btnXxx.ShowWindow(SW_HIDE);,改一个权限要翻十几处。分开之后每个类的代码量都小,出问题定位也快。

// CLoginDlg::OnBnClickedBtnLogin —— 登录按钮的响应 void CLoginDlg::OnBnClickedBtnLogin() { UpdateData(TRUE); // 把控件内容同步到成员变量 int nRole = -1; CString strName; if (!VerifyUser(m_strUserNo, m_strPwd, nRole, strName)) { AfxMessageBox(_T("账号或密码错误")); return; } // 写进会话单例,后面所有界面都从这里取值 CSession::Instance().SetUser(m_strUserNo, strName, nRole); EndDialog(IDOK); // 先关掉登录框 // 再按角色拉对应的主界面,用 DoModal 保证窗口栈只有一个入口 if (nRole == 0) { CAdminDlg dlg; dlg.DoModal(); } else if (nRole == 1) { CTeacherDlg dlg; dlg.DoModal(); } else { CStudentDlg dlg; dlg.DoModal(); } }

UpdateData(TRUE)是把控件里的值写回成员变量,方向别搞反,FALSE是往控件写。EndDialog(IDOK)必须先调,否则登录框不关,新弹的主界面会被压在后面,用户点不出来。

3.3 会话状态:别再用全局变量传学号

CSession的逻辑很简单,就是一个懒汉单例存三个字段,但它的存在解决了一个非常具体的问题:学生选完课返回主界面、又进课表界面,这条链上要传多少次学号?如果靠参数一层层传,中间漏一个就是空值,查出来的课表是空的,还不报错,排查起来很痛苦。

// Session.h —— 登录后的上下文,全进程唯一 class CSession { public: static CSession& Instance() { static CSession s_inst; return s_inst; } void SetUser(const CString& strNo, const CString& strName, int nRole) { m_strNo = strNo; m_strName = strName; m_nRole = nRole; } CString GetStuNo() const { return m_strNo; } // 教师和管理员登录时这里放工号 CString GetName() const { return m_strName; } int GetRole() const { return m_nRole; } bool IsStudent() const { return m_nRole == 2; } private: CSession() : m_nRole(-1) {} CString m_strNo, m_strName; int m_nRole; };

注意GetStuNo这个名字。学生登录放的是学号,教师登录放的是工号,两者都是User.UserNo,所以在数据访问层可以统一当成主键用。如果你希望语义更清楚,就改叫GetUserNo(),别在代码里造出两套字段。

4. 选课主链路拆解:按钮按下之后的那 200 毫秒发生了什么

这一章是整套系统的核心。选课按钮的响应函数看起来只有几十行,但它串起了 UI、ADO、存储过程、事务、锁和并发控制。把这条链路搞清楚,很多同类系统你就都能看懂了。

4.1 一次选课从点击到落库,中间经过五步

拆开看是这么五步:第一,从 CListCtrl 里取出当前选中的行,拿到课程号,取不到就提示「请先选课」,直接返回;第二,构造一个 ADO 的_Command对象,指定命令类型为adCmdStoredProc;第三,按顺序 Append 三个参数,两个入参一个出参;第四,执行并读回出参;第五,按出参的值分别提示,成功就刷新「我的课表」。

关键在于,这五步里没有任何一处自行判断「名额够不够」或「时间冲突没有」。所有判断都在数据库里完成。原因很直接:客户端判断和写库之间有时间差,两个人同时看到「还剩 1 个名额」,都判断通过,都去写,结果就超了。这个窗口期可能只有几十毫秒,但只要招生规模上到几百人,它一定会被撞上。

// CSelectCourseDlg::OnBnClickedBtnSelect —— 选课按钮 void CSelectCourseDlg::OnBnClickedBtnSelect() { POSITION pos = m_listCourse.GetFirstSelectedItemPosition(); if (pos == nullptr) { AfxMessageBox(_T("请先在列表里选中一门课程")); return; } int nItem = m_listCourse.GetNextSelectedItem(pos); CString strCourseNo = m_listCourse.GetItemText(nItem, 0); // 第 0 列存课程号 _CommandPtr pCmd; pCmd.CreateInstance(__uuidof(Command)); pCmd->ActiveConnection = CDBHelper::Instance().GetConn(); pCmd->CommandType = adCmdStoredProc; // 明确告诉 ADO 这是存储过程 pCmd->CommandText = _T("usp_SelectCourse"); pCmd->Parameters->Append(pCmd->CreateParameter( _T("@StuNo"), adVarChar, adParamInput, 20, (_bstr_t)CSession::Instance().GetStuNo())); pCmd->Parameters->Append(pCmd->CreateParameter( _T("@CourseNo"), adVarChar, adParamInput, 20, (_bstr_t)strCourseNo)); pCmd->Parameters->Append(pCmd->CreateParameter( _T("@Result"), adInteger, adParamOutput, 4, 0)); try { pCmd->Execute(NULL, NULL, adCmdStoredProc); long nRet = (long)pCmd->Parameters->GetItem(_T("@Result"))->Value; switch (nRet) { case 0: AfxMessageBox(_T("选课成功")); RefreshMyCourses(); break; case -1: AfxMessageBox(_T("课程不存在,可能已被管理员删除")); break; case -2: AfxMessageBox(_T("该课程名额已满")); break; case -3: AfxMessageBox(_T("你已经选过这门课了")); break; case -4: AfxMessageBox(_T("与已选课程上课时间冲突")); break; default: AfxMessageBox(_T("选课失败,请稍后重试")); break; } } catch (_com_error& e) { // e.Description() 里是数据库返回的原始错误,调试期先打印出来 AfxMessageBox((LPCTSTR)e.Description()); } }

参数部分值得逐条对一遍:@StuNo和@CourseNo是adVarChar入参,长度 20 和表字段一致;@Result是adInteger,方向adParamOutput,长度 4 字节,初值随便给。取回参数用GetItem(_T("@Result")),名字要和存储过程里声明的完全一致,包括@符号,写错会得到一个空 VARIANT,转换的时候抛异常。

catch (_com_error& e)这一段不能省。数据库连接断了、存储过程被删了、字段名改了,都会抛到这里。没有 try-catch 的话,异常会一路冒到 MFC 的消息循环里,表现是程序直接闪退,什么提示都没有,很难查。

4.2 容量扣减与时间冲突:用事务把「查-改-写」锁成一步

存储过程是整个系统里最该仔细读的一段代码。

-- usp_SelectCourse:选课的唯一入口 CREATE PROCEDURE usp_SelectCourse @StuNo VARCHAR(20), @CourseNo VARCHAR(20), @Result INT OUTPUT -- 0 成功,-1 课程不存在,-2 名额满,-3 重复选,-4 时间冲突 AS BEGIN SET NOCOUNT ON; SET XACT_ABORT ON; -- 任何错误自动回滚,省掉一堆手写 ROLLBACK BEGIN TRANSACTION; IF NOT EXISTS (SELECT 1 FROM Course WHERE CourseNo = @CourseNo) BEGIN SET @Result = -1; ROLLBACK TRANSACTION; RETURN; END IF EXISTS (SELECT 1 FROM SC WHERE StuNo = @StuNo AND CourseNo = @CourseNo) BEGIN SET @Result = -3; ROLLBACK TRANSACTION; RETURN; END -- 关键一行:UPDLOCK + HOLDLOCK 把这一行锁到事务结束 -- 不加的话,两个连接会同时读到 59/60,都判断通过,最后变成 61/60 DECLARE @Cap INT, @Cnt INT; SELECT @Cap = Capacity, @Cnt = SelectedCount FROM Course WITH (UPDLOCK, HOLDLOCK) WHERE CourseNo = @CourseNo; IF @Cnt >= @Cap BEGIN SET @Result = -2; ROLLBACK TRANSACTION; RETURN; END -- 时间冲突:同一学期、同一天、节次区间有交集 IF EXISTS ( SELECT 1 FROM SC sc JOIN Course oc ON sc.CourseNo = oc.CourseNo -- 已选的课 JOIN Course nc ON nc.CourseNo = @CourseNo -- 想选的课 WHERE sc.StuNo = @StuNo AND oc.Term = nc.Term AND oc.DayOfWeek = nc.DayOfWeek AND oc.SectionStart <= nc.SectionEnd AND nc.SectionStart <= oc.SectionEnd ) BEGIN SET @Result = -4; ROLLBACK TRANSACTION; RETURN; END INSERT INTO SC(StuNo, CourseNo, SelectTime) VALUES(@StuNo, @CourseNo, GETDATE()); UPDATE Course SET SelectedCount = SelectedCount + 1 WHERE CourseNo = @CourseNo; COMMIT TRANSACTION; SET @Result = 0; END

WITH (UPDLOCK, HOLDLOCK)这两行是这个存储过程的关键部分,也是很多同类项目直接漏掉的。UPDLOCK是更新锁,读的时候就带上「我准备改」的意图;HOLDLOCK相当于把这个锁的范围扩到事务结束。两个一起用,效果是:第一个连接进来读到@Cnt = 59,这一行就被锁住,第二个连接的 SELECT 会阻塞等待,直到第一个连接 COMMIT 把计数改成 60 之后,第二个才读到 60,然后被@Cnt >= @Cap挡回去。

不加锁的表现很有迷惑性:平时测试怎么点都正常,一到选课高峰期,名额就会超一两个。而且你去看日志,每个请求返回的都是成功,没有任何报错,就是数字对不上。

时间冲突那段 SQL 的连接条件看着绕,其实就是区间重叠判断。两个节次区间[a1, a2]和[b1, b2]有交集,等价于a1 <= b2 AND b1 <= a2。这个公式比一堆 if-else 可靠得多,也不会漏掉「完全包含」这种情况:比如已选 1~4 节,想选 2~3 节,用 if-else 逐段比较很容易写漏。

SET XACT_ABORT ON这一句也别省。它让运行时错误自动回滚整个事务,不然一个除零或者类型转换失败,事务会挂在那里占着锁,后面所有人排队等,界面上就是「点不动了」。

4.3 已选列表刷新:CListCtrl 绑定数据的正确姿势

选课成功之后要刷新「我的课表」,退课之后也要刷新,这两个地方共用同一个函数。

// CSelectCourseDlg::RefreshMyCourses —— 重新查一遍已选课程并填进列表 void CSelectCourseDlg::RefreshMyCourses() { m_listMyCourse.DeleteAllItems(); // 每次全清,别想着做增量更新 CString strSQL; strSQL.Format(_T("SELECT c.CourseNo, c.CourseName, c.TeacherNo, c.Credit, ") _T(" c.DayOfWeek, c.SectionStart, c.SectionEnd ") _T("FROM SC sc JOIN Course c ON sc.CourseNo = c.CourseNo ") _T("WHERE sc.StuNo = '%s' ORDER BY c.DayOfWeek, c.SectionStart"), CSession::Instance().GetStuNo()); _RecordsetPtr pRs = CDBHelper::Instance().Query(strSQL); int nRow = 0; while (!pRs->adoEOF) { m_listMyCourse.InsertItem(nRow, (LPCTSTR)(_bstr_t)pRs->Fields->GetItem(_T("CourseNo"))->Value); m_listMyCourse.SetItemText(nRow, 1, (LPCTSTR)(_bstr_t)pRs->Fields->GetItem(_T("CourseName"))->Value); m_listMyCourse.SetItemText(nRow, 2, (LPCTSTR)(_bstr_t)pRs->Fields->GetItem(_T("TeacherNo"))->Value); m_listMyCourse.SetItemText(nRow, 3, (LPCTSTR)(_bstr_t)pRs->Fields->GetItem(_T("Credit"))->Value); m_listMyCourse.SetItemText(nRow, 4, (LPCTSTR)(_bstr_t)pRs->Fields->GetItem(_T("DayOfWeek"))->Value); pRs->MoveNext(); ++nRow; } pRs->Close(); // 必须关,不关连接上的游标会越积越多 m_staticCount.SetWindowText(...); // 顺带更新「已选 X 门 / 共 Y 学分」 }

DeleteAllItems()后重新填,看起来笨,但它避免了一大类 UI 和数据不同步的 bug。增量更新要判断哪一行是新增、哪一行被删、索引怎么挪,写起来麻烦还容易出错,而课表最多也就十几门课,重填一次的开销可以忽略。

(LPCTSTR)(_bstr_t)这个双重转换是 ADO 取值的规定动作。Fields->GetItem(...)->Value返回的是_variant_t,直接转CString在某些字段类型上会得到乱码,先转_bstr_t(宽字符 BSTR)再转LPCTSTR就稳定了。数字字段其实也可以直接(long)转,但为了代码统一,全都走字符串。

pRs->Close()千万别忘。忘记关记录集的典型症状是用着用着开始报「连接繁忙,结果未知」,重启程序又好,过一会再犯。这是连接上的记录集没释放,ADO 认为还有未完成的查询。

5. 上机排查:这套 MFC 选课系统编译和运行时的五个高频坑

代码看懂了不等于能跑起来。这一章把最容易卡住人的几处单独拎出来,尤其是环境层面,MFC 这套东西对工具链的版本和字符集非常敏感,一个设置不对就是几百行报错。

5.1 环境清单与第一次编译的顺序

需要的环境:Visual Studio 2017 或更高版本,安装时勾选「使用 C++ 的桌面开发」,并且在工作负载的右侧详情里确认「MFC 和 ATL 支持」被勾上,默认是不勾的。SQL Server Express 任意较新版本都行,装完用 SSMS 或者 Azure Data Studio 连上去执行sql/目录下的三个脚本。

顺序上建议这样:先建库建表跑完01_schema.sql,再跑02_proc_select.sql建存储过程,最后跑03_seed.sql灌几条测试数据,包括一个管理员、一个教师、一个学生和五六门课。别跳过种子数据,很多人直接开程序看到列表全空,以为代码有问题,其实就是库里没数据。

工程这边检查三个设置:项目属性里「字符集」用「使用 Unicode 字符集」,和代码里的_T()保持一致;「C/C++ → 语言 → 符合模式」如果报一堆C4996,把SDL 检查关掉;链接器要能找得到msado15.dll,一般不用手加,#import会自动生成.tlh和.tli文件。

5.2 五条踩坑记录:现象、原因与解决

坑一:编译报「无法打开源文件 afxwin.h」

现象是新建的工程一编译就一堆红,提示找不到 MFC 的头文件。原因是安装 VS 的时候没勾 MFC 组件,只装了 C++ 桌面开发的核心部分。解决办法是打开 Visual Studio Installer,点「修改」,在「单个组件」里搜 MFC,勾上对应的「适用于最新 v143 生成工具的 C++ MFC」,装完重启 VS 就好。

坑二:中文全是乱码,或者 MessageBox 弹出来是方块

现象是数据库里存的中文,读出来显示成????或者一堆问号方块。原因通常是两头不一致:工程用的是 Unicode 字符集,但连接串没指定编码,或者数据库字段建成了VARCHAR而代码里传的是宽字符。解决办法是数据库字段统一用NVARCHAR,SQL 语句里的中文字面量前面加N(N'高等数学'),连接串保持默认,不要加CharSet参数。如果是旧库改不了,就在读出来之后用CStringA转一次,但那只是遮盖问题。

坑三:程序启动就闪退,没有任何提示

现象是双击 exe,窗口一闪就没了。原因八成是InitInstance里某个地方返回了 FALSE,但没有弹框,或者弹框之后流程继续往下走访问了空指针。解决办法是在InitInstance的每一步后面加OutputDebugString,用 VS 附加进程或者直接 F5 调试运行,看输出窗口停在哪一行。最常见的两处:AfxOleInit()之前就用了 ADO 对象,以及连库失败之后没有return FALSE就继续DoModal。

坑四:CListCtrl 点了一行,但 GetItemText 取到的是空的

现象是用户明明选中了一行,点选课按钮却提示「请先选中一门课程」。原因是没加LVS_EX_FULLROWSELECT扩展样式,只有第一列能真正触发选中,用户点在其他列上等于没选中。解决办法是在OnInitDialog里加:

m_listCourse.SetExtendedStyle( LVS_EX_FULLROWSELECT | LVS_EX_GRIDLINES); // 整行选中 + 网格线

还有一种情况是InsertItem只插了第 0 列,后面的列压根没调SetItemText,取出来当然是空的。

坑五:多人同时选课,名额超了,但每个人收到的都是「选课成功」

现象是名单导出后发现某门课 62/60。原因不在 C++ 代码,在存储过程里少了WITH (UPDLOCK, HOLDLOCK),两个并发请求都读到 59,都判断通过。解决办法就是 4.2 节写的那样,在读取Capacity和SelectedCount的那句 SELECT 上加上锁提示。改完之后压力测试一下:开两个 SSMS 窗口同时执行usp_SelectCourse,看第二个是不是等第一个提交后才返回,能等就说明锁生效了。

6. 二次开发:把它改成能交给教研室用的小工具

拿到这套代码之后,最值得先做的一件改造不是加功能,而是把「选课时间窗口」做成可配置的。原来的逻辑是学生随时能选课,真上线那天你会发现有人半夜三点在选、有人在补考周还在改课表。Config表已经预留了字段,加两行判断就行:

-- 选课前校验时间窗,加在 usp_SelectCourse 开头 IF EXISTS (SELECT 1 FROM Config WHERE CfgKey = 'SelectOpen' AND CAST(CfgValue AS DATETIME) > GETDATE()) BEGIN SET @Result = -5; -- 未到选课时间 ROLLBACK TRANSACTION; RETURN; END

第二个改造是导课表。教务老师最想要的不是界面,是一张能直接打开的表格。给MyCourseDlg加一个「导出」按钮,把 CListCtrl 里的内容写成 CSV,注意加 UTF-8 BOM,否则 Excel 打开中文列头全是乱码:

// 导出当前课表为 CSV,Excel 可直接打开 void CMyCourseDlg::OnBnClickedBtnExport() { CFileDialog dlg(FALSE, _T("csv"), _T("我的课表.csv"), OFN_OVERWRITEPROMPT, _T("CSV 文件|*.csv||")); if (dlg.DoModal() != IDOK) return; CFile file; if (!file.Open(dlg.GetPathName(), CFile::modeCreate | CFile::modeWrite)) return; const BYTE bom[] = { 0xEF, 0xBB, 0xBF }; // 关键:不加 BOM,Excel 会把中文读成乱码 file.Write(bom, sizeof(bom)); CString strLine = _T("课程号,课程名,教师,学分,上课时间\r\n"); for (int i = 0; i < m_listMyCourse.GetItemCount(); ++i) { for (int col = 0; col < 5; ++col) strLine += m_listMyCourse.GetItemText(i, col) + (col == 4 ? _T("\r\n") : _T(",")); } CT2CA utf8(strLine, CP_UTF8); // 按 UTF-8 编码写出 file.Write(utf8, (UINT)strlen(utf8)); file.Close(); }

0xEF 0xBB 0xBF这三个字节是 UTF-8 BOM,Excel 看到它才知道这是 UTF-8 编码,否则就按本地编码猜,中文必然花。CT2CA(strLine, CP_UTF8)的第二个参数是代码页,不写默认走 ANSI。

第三个改造方向是补一列「是否已出成绩」和分段绩点。现在SC.Score是空的,学生看不到自己的绩点,教师录完成绩也没地方汇总。加个视图vw_StuGPA,按学分加权算一遍,教师端直接查视图,比在 C++ 里循环算省事,也方便以后接导出。

改造的时候有个习惯我后来一直保持:任何改动先在sql/目录下留一个编号脚本,别直接去 SSMS 里改完就算完。这套系统我见过不止一次「开发机上好好的,拷到机房就报字段不存在」,就是因为存储过程的修改只存在于某个人的本地库。每次改完,拿一份干净的 SQL Server 实例,从01到0N顺序跑一遍,跑不通就说明还差东西。这个验证流程看着笨,但能省掉后面无数扯皮。希望帮到你。

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

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

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

立即咨询