1. 项目缘起与整体设计思路1.1 为什么还在用 MFC 做登录界面先把话说在前头:MFC 不是什么新东西,但它在某些场景下依然有不可替代的位置。我接触过不少做工业控制、医疗设备上位机、实验室仪器配套软件的团队,他们的技术栈就是 VS2019 + MFC + MySQL 这套组合。原因很实在——存量代码多、界面响应快、部署简单、不依赖运行时环境,一个 exe 拷过去就能跑。登录界面是所有管理系统的第一道门。别看它简单,麻雀虽小五脏俱全:要有输入框、要有密码掩码、要有登录按钮的响应逻辑、要连接数据库做校验、还要处理各种异常情况。很多新手觉得“不就是个登录嘛”,结果一上手就卡在环境配置、字符集乱码、连接失败这些问题上,折腾一整天都跑不起来。这篇内容就是把我自己踩过的坑和验证过的流程完整梳理一遍。适合两类人看:一类是刚接触 MFC 的学生或者转岗开发者,需要快速跑通一个能用的登录 Demo;另一类是有一定基础但每次配 MySQL 连接都要重新查资料的老手,可以当作一份速查手册。核心关键词就三个:VS2019、MFC、MySQL 连接,围绕这三个词把登录界面从零到一讲透。1.2 整体架构与技术选型考量这个项目的架构非常简单,三层结构:界面层(MFC 对话框)→ 业务逻辑层(登录校验函数)→ 数据层(MySQL 数据库)。没有复杂的框架,没有 ORM 映射,就是最朴素的 C++ 调用 MySQL C API。为什么不用 ODBC?因为 ODBC 需要额外配置数据源,换一台机器就要重新配一遍,部署起来麻烦。而 MySQL C API 是直接链接库文件,把 libmysql.dll 和 libmysql.lib 一起打包就行,对单机部署的桌面程序来说更省心。为什么不用 MySQL Connector/C++?那个是基于 JDBC 风格的接口,用起来确实更“现代”,但它依赖的运行时库更多,而且不同版本之间的 ABI 兼容性经常出问题。对于 MFC 这种偏传统的技术栈,用最原始的 C API 反而最稳。数据库表的设计也很简单,一张用户表就够了:CREATE TABLE user_info ( id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;注意:密码字段这里先存明文,是为了让 Demo 跑通。实际项目里必须做哈希加盐处理,后面我会专门讲这块怎么改。2. 环境准备与依赖配置2.1 VS2019 安装时的关键选项VS2019 的安装器里,工作负载要勾选“使用 C++ 的桌面开发”。这个大类下面有几个组件必须确认勾上:MSVC v142 - VS 2019 C++ x64/x86 生成工具:这是编译器核心,不勾什么都干不了。Windows 10 SDK:选最新版本就行,MFC 的头文件依赖它。C++ MFC 最新 v142 生成工具:这个默认是不勾的,必须手动勾上,否则新建项目时找不到 MFC 模板。C++ ATL 最新 v142 生成工具:MFC 内部依赖 ATL 的部分组件,建议一起勾上。安装完之后,打开 VS2019 新建项目,在搜索框输入“MFC”,应该能看到“MFC 应用”模板。如果看不到,说明 MFC 组件没装好,回到安装器里补勾。2.2 MySQL 服务端与开发库的版本匹配MySQL 的版本选择有个坑:服务端版本和开发库版本必须匹配。我见过有人服务端装的是 MySQL 8.0,开发库却下载了 5.7 的,结果编译能过,运行时报“找不到指定模块”或者直接崩溃。推荐的做法是:服务端装什么版本,开发库就下什么版本。MySQL 官网提供两个东西:MySQL Installer:用来装服务端和 Workbench 管理工具。MySQL Connector/C:这是纯 C 语言的连接库,包含头文件和 lib 文件。下载 Connector/C 的时候注意选对平台。VS2019 默认编译的是 32 位程序(除非你手动改成 x64),所以要下 32 位的库。如果你打算编译 64 位程序,那就下 64 位的。两者不能混用。安装完 Connector/C 之后,目录结构大概是这样:C:\Program Files (x86)\MySQL\MySQL Connector C 6.1\ ├── include\ │ ├── mysql.h │ ├── mysql_com.h │ └── ... ├── lib\ │ ├── libmysql.lib │ ├── libmysql.dll │ └── ...记住这个路径,后面配置项目属性要用。2.3 项目属性配置的四个关键位置新建 MFC 项目之后,右键项目 → 属性,需要改四个地方。这里以 32 位 Debug 配置为例:第一处:C/C++ → 常规 → 附加包含目录添加 Connector/C 的 include 路径:C:\Program Files (x86)\MySQL\MySQL Connector C 6.1\include第二处:链接器 → 常规 → 附加库目录添加 lib 路径:C:\Program Files (x86)\MySQL\MySQL Connector C 6.1\lib第三处:链接器 → 输入 → 附加依赖项添加:libmysql.lib第四处:把 libmysql.dll 拷贝到输出目录这一步最容易忘。libmysql.lib 只是导入库,运行时真正加载的是 libmysql.dll。你需要把 Connector/C 的 lib 目录下的 libmysql.dll 拷贝到你的项目输出目录(通常是项目名\Debug\或项目名\x64\Debug\)。实操心得:我习惯在项目根目录建一个libs文件夹,把 include、lib、dll 都放进去,然后用相对路径配置。这样整个项目拷到别的机器上也能直接编译,不用重新配路径。3. 登录界面设计与核心代码实现3.1 对话框布局与控件命名在资源视图里打开主对话框,把默认的“TODO: 在此放置对话框控件”删掉,然后拖入以下控件:控件类型ID用途Static TextIDC_STATIC_USER显示“用户名:”Edit ControlIDC_EDIT_USER输入用户名Static TextIDC_STATIC_PWD显示“密码:”Edit ControlIDC_EDIT_PWD输入密码ButtonIDC_BTN_LOGIN登录按钮ButtonIDC_BTN_EXIT退出按钮密码输入框要设置一个属性:在属性面板里找到Password,设为True。这样输入的内容会显示为圆点,不会明文暴露。另外建议把对话框的System Menu设为 False,去掉标题栏的关闭按钮,防止用户直接点 X 绕过登录逻辑(虽然 Demo 里无所谓,但养成习惯是好的)。3.2 数据库连接类的封装直接在对话框类里写 MySQL 连接代码不是不行,但复用性差。我习惯封装一个简单的数据库工具类,叫CDbHelper。核心代码如下:// DbHelper.h #pragma once #include <mysql.h> #include <string> class CDbHelper { public: CDbHelper(); ~CDbHelper(); bool Connect(const std::string& host, const std::string& user, const std::string& password, const std::string& dbName, unsigned int port = 3306); void Disconnect(); bool CheckLogin(const std::string& username, const std::string& password); std::string GetLastError(); private: MYSQL* m_pConn; std::string m_lastError; };实现文件:// DbHelper.cpp #include "pch.h" #include "DbHelper.h" CDbHelper::CDbHelper() : m_pConn(nullptr) { m_pConn = mysql_init(nullptr); if (m_pConn) { // 设置字符集为 utf8mb4,防止中文乱码 mysql_options(m_pConn, MYSQL_SET_CHARSET_NAME, "utf8mb4"); } } CDbHelper::~CDbHelper() { Disconnect(); } bool CDbHelper::Connect(const std::string& host, const std::string& user, const std::string& password, const std::string& dbName, unsigned int port) { if (!m_pConn) { m_lastError = "mysql_init failed"; return false; } if (!mysql_real_connect(m_pConn, host.c_str(), user.c_str(), password.c_str(), dbName.c_str(), port, nullptr, 0)) { m_lastError = mysql_error(m_pConn); return false; } return true; } void CDbHelper::Disconnect() { if (m_pConn) { mysql_close(m_pConn); m_pConn = nullptr; } } bool CDbHelper::CheckLogin(const std::string& username, const std::string& password) { if (!m_pConn) return false; // 使用 mysql_real_escape_string 防止 SQL 注入 char escapedUser[256] = { 0 }; char escapedPwd[256] = { 0 }; mysql_real_escape_string(m_pConn, escapedUser, username.c_str(), username.length()); mysql_real_escape_string(m_pConn, escapedPwd, password.c_str(), password.length()); std::string sql = "SELECT id FROM user_info WHERE username='"; sql += escapedUser; sql += "' AND password='"; sql += escapedPwd; sql += "' LIMIT 1"; if (mysql_query(m_pConn, sql.c_str()) != 0) { m_lastError = mysql_error(m_pConn); return false; } MYSQL_RES* res = mysql_store_result(m_pConn); if (!res) { m_lastError = mysql_error(m_pConn); return false; } bool found = (mysql_num_rows(res) > 0); mysql_free_result(res); return found; } std::string CDbHelper::GetLastError() { return m_lastError; }这段代码有几个关键点值得展开说。第一,mysql_options 设置字符集。很多人连接数据库之后发现中文用户名查不到,就是因为字符集没设对。MySQL 8.0 默认是 utf8mb4,但 C API 默认可能是 latin1,两边不一致就会出问题。在 mysql_init 之后、mysql_real_connect 之前调用 mysql_options 设置字符集,是最稳妥的做法。第二,mysql_real_escape_string 防注入。登录框是 SQL 注入的重灾区。如果用户输入' OR '1'='1,拼接出来的 SQL 就变成了SELECT id FROM user_info WHERE username='' OR '1'='1' AND password='...',直接绕过校验。用 escape 函数把特殊字符转义掉,能挡住绝大部分注入攻击。第三,mysql_store_result 和 mysql_free_result 必须配对。每次查询完都要把结果集释放掉,否则内存会持续泄漏。在长时间运行的程序里,这个泄漏会累积到程序崩溃。3.3 登录按钮的响应逻辑在对话框类里添加一个成员变量CDbHelper m_db;,然后在 OnInitDialog 里初始化连接:BOOL CLoginDlg::OnInitDialog() { CDialogEx::OnInitDialog(); // 尝试连接数据库 if (!m_db.Connect("127.0.0.1", "root", "your_password", "test_db")) { CString errMsg; errMsg.Format(_T("数据库连接失败:%s"), CString(m_db.GetLastError().c_str())); AfxMessageBox(errMsg); } return TRUE; }登录按钮的响应函数:void CLoginDlg::OnBnClickedBtnLogin() { CString strUser, strPwd; GetDlgItemText(IDC_EDIT_USER, strUser); GetDlgItemText(IDC_EDIT_PWD, strPwd); // 空值校验 if (strUser.IsEmpty() || strPwd.IsEmpty()) { AfxMessageBox(_T("用户名和密码不能为空")); return; } // 长度校验 if (strUser.GetLength() > 50 || strPwd.GetLength() > 100) { AfxMessageBox(_T("输入长度超出限制")); return; } // 转换为 std::string(注意宽字符转窄字符) CT2A userA(strUser); CT2A pwdA(strPwd); if (m_db.CheckLogin(std::string(userA), std::string(pwdA))) { AfxMessageBox(_T("登录成功")); // 这里可以跳转到主界面 // CDialogEx::OnOK(); } else { AfxMessageBox(_T("用户名或密码错误")); // 清空密码框,方便重新输入 SetDlgItemText(IDC_EDIT_PWD, _T("")); GetDlgItem(IDC_EDIT_PWD)->SetFocus(); } }这里有个字符转换的细节:MFC 默认使用 Unicode 字符集,CString 是宽字符(wchar_t),而 MySQL C API 用的是窄字符(char)。所以需要转换。CT2A是 ATL 提供的转换宏,会自动处理编码转换。如果项目用的是多字节字符集,那就不需要转换,直接用strUser.GetBuffer()就行。注意:CT2A 转换后的字符串生命周期只在当前作用域内有效,不要把它保存到全局变量里。4. 常见问题排查与避坑指南4.1 连接失败的五种典型情况数据库连接失败是新手遇到最多的问题,没有之一。我把常见的失败原因整理成一张速查表:错误信息关键词可能原因解决方法Can't connect to MySQL serverMySQL 服务没启动在服务管理器里启动 MySQL 服务Access denied for user用户名或密码错误检查连接参数,确认 root 密码Unknown database数据库名写错或不存在在 Workbench 里确认数据库已创建Can't connect to MySQL server on '127.0.0.1'端口不对或被防火墙拦截确认端口是 3306,检查防火墙规则找不到 libmysql.dlldll 没拷贝到输出目录把 dll 拷到 exe 同级目录还有一种情况是编译时报“无法打开 libmysql.lib”,这说明附加库目录配错了,或者 lib 文件和当前编译平台不匹配(32 位项目用了 64 位的 lib)。4.2 中文乱码的根因分析中文乱码的本质是编码不一致。数据从输入框到数据库,中间经过了多个环节,每个环节都有自己的编码方式:MFC 输入框:Unicode(宽字符)CT2A 转换:取决于系统区域设置,中文系统默认是 GBKMySQL C API:默认 latin1,需要手动设为 utf8mb4MySQL 表字段:建表时指定的字符集MySQL 服务端:配置文件里的 character-set-server只要有一个环节不一致,就会出现乱码。最稳妥的做法是全部统一为 utf8mb4:建表时指定DEFAULT CHARSET=utf8mb4连接后执行SET NAMES utf8mb4或者在 mysql_options 里设置MYSQL_SET_CHARSET_NAME为utf8mb4CT2A 转换时指定代码页为 CP_UTF8:CT2A userA(strUser, CP_UTF8);我实测下来,最后这种组合最稳:数据库、表、连接全部 utf8mb4,CT2A 显式指定 CP_UTF8。4.3 密码明文存储的安全隐患Demo 里密码是明文存的,但实际项目绝对不能这么干。正确的做法是:注册时:生成一个随机盐值(salt),把password + salt做哈希(推荐 bcrypt 或 argon2),把哈希值和盐值一起存进数据库。登录时:根据用户名取出盐值,把用户输入的密码加盐哈希,和数据库里的哈希值比对。如果不想引入第三方哈希库,至少也要用 SHA-256 加盐。虽然 SHA-256 抗暴力破解能力不如 bcrypt,但比明文强太多了。// 伪代码示意 std::string salt = GenerateRandomSalt(); std::string hash = SHA256(password + salt); // 存库:INSERT INTO user_info (username, password, salt) VALUES (?, ?, ?)实操心得:盐值长度建议至少 16 字节,每个用户独立生成,不要用固定盐。固定盐等于没加盐,彩虹表照样能破。4.4 内存泄漏与资源释放MySQL C API 有几个资源需要手动释放:mysql_init返回的 MYSQL* 需要mysql_closemysql_store_result返回的 MYSQL_RES* 需要mysql_free_resultmysql_fetch_row返回的 MYSQL_ROW 不需要手动释放,它指向结果集内部的内存在 Debug 模式下,VS 会在程序退出时输出内存泄漏报告。如果看到Detected memory leaks!,大概率是某个 MYSQL_RES 忘了释放。我的习惯是在每个mysql_store_result之后立刻写mysql_free_result,中间不要插入其他逻辑,避免忘记。5. 功能扩展与优化方向5.1 记住用户名功能的实现“记住用户名”是个很实用的功能,实现起来也不复杂。思路是把用户名写进注册表或者配置文件,下次打开时自动填充。用注册表的方式:// 保存 HKEY hKey; RegCreateKeyEx(HKEY_CURRENT_USER, _T("Software\\MyLoginApp"), 0, nullptr, REG_OPTION_NON_VOLATILE, KEY_WRITE, nullptr, &hKey, nullptr); RegSetValueEx(hKey, _T("LastUser"), 0, REG_SZ, (const BYTE*)strUser.GetString(), (strUser.GetLength() + 1) * sizeof(TCHAR)); RegCloseKey(hKey); // 读取 TCHAR buf[256] = { 0 }; DWORD bufSize = sizeof(buf); DWORD type = REG_SZ; if (RegOpenKeyEx(HKEY_CURRENT_USER, _T("Software\\MyLoginApp"), 0, KEY_READ, &hKey) == ERROR_SUCCESS) { RegQueryValueEx(hKey, _T("LastUser"), nullptr, &type, (LPBYTE)buf, &bufSize); RegCloseKey(hKey); SetDlgItemText(IDC_EDIT_USER, buf); }用配置文件的方式更简单,直接读写 ini 文件:WritePrivateProfileString(_T("Login"), _T("LastUser"), strUser, _T(".\\config.ini")); GetPrivateProfileString(_T("Login"), _T("LastUser"), _T(""), buf, 256, _T(".\\config.ini"));两种方式各有优劣:注册表更“正规”,但卸载时容易残留;ini 文件更直观,但容易被用户误删。我个人偏好 ini 文件,因为调试方便,出问题了直接打开看就行。5.2 登录失败次数限制为了防止暴力破解,可以加一个失败次数限制:连续失败 3 次就锁定账号 5 分钟。实现思路是在数据库里加两个字段:ALTER TABLE user_info ADD COLUMN fail_count INT DEFAULT 0; ALTER TABLE user_info ADD COLUMN lock_until TIMESTAMP NULL;每次登录失败时fail_count + 1,达到阈值就设置lock_until = NOW() + INTERVAL 5 MINUTE。登录时先检查lock_until是否大于当前时间,如果是就拒绝登录。这个逻辑放在 SQL 里做比放在 C++ 里做更可靠,因为 SQL 是原子的,不会出现并发问题。5.3 从 Demo 到生产环境的差距这个 Demo 跑通之后,距离生产环境还有不少距离。我列几个必须补上的点:密码加密:前面说过了,必须做哈希加盐。连接池:Demo 里是单连接,并发高了会排队。生产环境要用连接池。日志记录:每次登录成功/失败都要记日志,方便审计和排查。异常处理:网络断开、数据库重启这些情况要有兜底逻辑,不能直接崩溃。界面美化:MFC 默认界面比较朴素,可以用自绘或者第三方库美化一下。不过话说回来,Demo 的价值在于跑通流程、理解原理。把这些基础打牢了,往上加功能就是水到渠成的事。6. 我个人在实际操作中的几点体会最后分享几个我在实际项目中总结的小经验,都是文档里不会写的。第一,连接字符串不要硬编码。我见过太多项目把数据库密码写死在代码里,换环境就要重新编译。正确的做法是放在配置文件里,程序启动时读取。哪怕是个 Demo,养成这个习惯也是好的。第二,编译平台要统一。32 位和 64 位的库不能混用,这个坑我踩过不止一次。建议在项目属性里明确指定目标平台,不要用“Any CPU”这种模糊的选项。第三,测试用例要覆盖边界情况。空用户名、超长用户名、特殊字符、SQL 注入尝试,这些都要测一遍。我习惯在数据库里预置几个测试账号,包括一个用户名为空的、一个密码为空的,专门用来验证边界逻辑。第四,libmysql.dll 的版本要和 lib 文件一致。有时候从不同地方下载的 dll 和 lib 版本不匹配,编译能过但运行时报错。最保险的做法是只用 Connector/C 安装目录里的那一套,不要从别处拷贝。这套流程我前后配过不下十次,每次换新机器都要重新走一遍。现在把它完整写下来,一方面是给自己留个备忘,另一方面也希望能帮到正在踩坑的人。MFC 虽然老,但把基础打扎实了,后面学 Qt 或者 Web 开发,很多概念是相通的。
1. 项目缘起与整体设计思路1.1 为什么还在用 MFC 做登录界面先把话说在前头:MFC 不是什么新东西,但它在某些场景下依然有不可替代的位置。我接触过不少做工业控制、医疗设备上位机、实验室仪器配套软件的团队,他们的技术栈就是 VS2019 + MFC + MySQL 这套组合。原因很实在——存量代码多、界面响应快、部署简单、不依赖运行时环境,一个 exe 拷过去就能跑。登录界面是所有管理系统的第一道门。别看它简单,麻雀虽小五脏俱全:要有输入框、要有密码掩码、要有登录按钮的响应逻辑、要连接数据库做校验、还要处理各种异常情况。很多新手觉得“不就是个登录嘛”,结果一上手就卡在环境配置、字符集乱码、连接失败这些问题上,折腾一整天都跑不起来。这篇内容就是把我自己踩过的坑和验证过的流程完整梳理一遍。适合两类人看:一类是刚接触 MFC 的学生或者转岗开发者,需要快速跑通一个能用的登录 Demo;另一类是有一定基础但每次配 MySQL 连接都要重新查资料的老手,可以当作一份速查手册。核心关键词就三个:VS2019、MFC、MySQL 连接,围绕这三个词把登录界面从零到一讲透。1.2 整体架构与技术选型考量这个项目的架构非常简单,三层结构:界面层(MFC 对话框)→ 业务逻辑层(登录校验函数)→ 数据层(MySQL 数据库)。没有复杂的框架,没有 ORM 映射,就是最朴素的 C++ 调用 MySQL C API。为什么不用 ODBC?因为 ODBC 需要额外配置数据源,换一台机器就要重新配一遍,部署起来麻烦。而 MySQL C API 是直接链接库文件,把 libmysql.dll 和 libmysql.lib 一起打包就行,对单机部署的桌面程序来说更省心。为什么不用 MySQL Connector/C++?那个是基于 JDBC 风格的接口,用起来确实更“现代”,但它依赖的运行时库更多,而且不同版本之间的 ABI 兼容性经常出问题。对于 MFC 这种偏传统的技术栈,用最原始的 C API 反而最稳。数据库表的设计也很简单,一张用户表就够了:CREATE TABLE user_info ( id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;注意:密码字段这里先存明文,是为了让 Demo 跑通。实际项目里必须做哈希加盐处理,后面我会专门讲这块怎么改。2. 环境准备与依赖配置2.1 VS2019 安装时的关键选项VS2019 的安装器里,工作负载要勾选“使用 C++ 的桌面开发”。这个大类下面有几个组件必须确认勾上:MSVC v142 - VS 2019 C++ x64/x86 生成工具:这是编译器核心,不勾什么都干不了。Windows 10 SDK:选最新版本就行,MFC 的头文件依赖它。C++ MFC 最新 v142 生成工具:这个默认是不勾的,必须手动勾上,否则新建项目时找不到 MFC 模板。C++ ATL 最新 v142 生成工具:MFC 内部依赖 ATL 的部分组件,建议一起勾上。安装完之后,打开 VS2019 新建项目,在搜索框输入“MFC”,应该能看到“MFC 应用”模板。如果看不到,说明 MFC 组件没装好,回到安装器里补勾。2.2 MySQL 服务端与开发库的版本匹配MySQL 的版本选择有个坑:服务端版本和开发库版本必须匹配。我见过有人服务端装的是 MySQL 8.0,开发库却下载了 5.7 的,结果编译能过,运行时报“找不到指定模块”或者直接崩溃。推荐的做法是:服务端装什么版本,开发库就下什么版本。MySQL 官网提供两个东西:MySQL Installer:用来装服务端和 Workbench 管理工具。MySQL Connector/C:这是纯 C 语言的连接库,包含头文件和 lib 文件。下载 Connector/C 的时候注意选对平台。VS2019 默认编译的是 32 位程序(除非你手动改成 x64),所以要下 32 位的库。如果你打算编译 64 位程序,那就下 64 位的。两者不能混用。安装完 Connector/C 之后,目录结构大概是这样:C:\Program Files (x86)\MySQL\MySQL Connector C 6.1\ ├── include\ │ ├── mysql.h │ ├── mysql_com.h │ └── ... ├── lib\ │ ├── libmysql.lib │ ├── libmysql.dll │ └── ...记住这个路径,后面配置项目属性要用。2.3 项目属性配置的四个关键位置新建 MFC 项目之后,右键项目 → 属性,需要改四个地方。这里以 32 位 Debug 配置为例:第一处:C/C++ → 常规 → 附加包含目录添加 Connector/C 的 include 路径:C:\Program Files (x86)\MySQL\MySQL Connector C 6.1\include第二处:链接器 → 常规 → 附加库目录添加 lib 路径:C:\Program Files (x86)\MySQL\MySQL Connector C 6.1\lib第三处:链接器 → 输入 → 附加依赖项添加:libmysql.lib第四处:把 libmysql.dll 拷贝到输出目录这一步最容易忘。libmysql.lib 只是导入库,运行时真正加载的是 libmysql.dll。你需要把 Connector/C 的 lib 目录下的 libmysql.dll 拷贝到你的项目输出目录(通常是项目名\Debug\或项目名\x64\Debug\)。实操心得:我习惯在项目根目录建一个libs文件夹,把 include、lib、dll 都放进去,然后用相对路径配置。这样整个项目拷到别的机器上也能直接编译,不用重新配路径。3. 登录界面设计与核心代码实现3.1 对话框布局与控件命名在资源视图里打开主对话框,把默认的“TODO: 在此放置对话框控件”删掉,然后拖入以下控件:控件类型ID用途Static TextIDC_STATIC_USER显示“用户名:”Edit ControlIDC_EDIT_USER输入用户名Static TextIDC_STATIC_PWD显示“密码:”Edit ControlIDC_EDIT_PWD输入密码ButtonIDC_BTN_LOGIN登录按钮ButtonIDC_BTN_EXIT退出按钮密码输入框要设置一个属性:在属性面板里找到Password,设为True。这样输入的内容会显示为圆点,不会明文暴露。另外建议把对话框的System Menu设为 False,去掉标题栏的关闭按钮,防止用户直接点 X 绕过登录逻辑(虽然 Demo 里无所谓,但养成习惯是好的)。3.2 数据库连接类的封装直接在对话框类里写 MySQL 连接代码不是不行,但复用性差。我习惯封装一个简单的数据库工具类,叫CDbHelper。核心代码如下:// DbHelper.h #pragma once #include <mysql.h> #include <string> class CDbHelper { public: CDbHelper(); ~CDbHelper(); bool Connect(const std::string& host, const std::string& user, const std::string& password, const std::string& dbName, unsigned int port = 3306); void Disconnect(); bool CheckLogin(const std::string& username, const std::string& password); std::string GetLastError(); private: MYSQL* m_pConn; std::string m_lastError; };实现文件:// DbHelper.cpp #include "pch.h" #include "DbHelper.h" CDbHelper::CDbHelper() : m_pConn(nullptr) { m_pConn = mysql_init(nullptr); if (m_pConn) { // 设置字符集为 utf8mb4,防止中文乱码 mysql_options(m_pConn, MYSQL_SET_CHARSET_NAME, "utf8mb4"); } } CDbHelper::~CDbHelper() { Disconnect(); } bool CDbHelper::Connect(const std::string& host, const std::string& user, const std::string& password, const std::string& dbName, unsigned int port) { if (!m_pConn) { m_lastError = "mysql_init failed"; return false; } if (!mysql_real_connect(m_pConn, host.c_str(), user.c_str(), password.c_str(), dbName.c_str(), port, nullptr, 0)) { m_lastError = mysql_error(m_pConn); return false; } return true; } void CDbHelper::Disconnect() { if (m_pConn) { mysql_close(m_pConn); m_pConn = nullptr; } } bool CDbHelper::CheckLogin(const std::string& username, const std::string& password) { if (!m_pConn) return false; // 使用 mysql_real_escape_string 防止 SQL 注入 char escapedUser[256] = { 0 }; char escapedPwd[256] = { 0 }; mysql_real_escape_string(m_pConn, escapedUser, username.c_str(), username.length()); mysql_real_escape_string(m_pConn, escapedPwd, password.c_str(), password.length()); std::string sql = "SELECT id FROM user_info WHERE username='"; sql += escapedUser; sql += "' AND password='"; sql += escapedPwd; sql += "' LIMIT 1"; if (mysql_query(m_pConn, sql.c_str()) != 0) { m_lastError = mysql_error(m_pConn); return false; } MYSQL_RES* res = mysql_store_result(m_pConn); if (!res) { m_lastError = mysql_error(m_pConn); return false; } bool found = (mysql_num_rows(res) > 0); mysql_free_result(res); return found; } std::string CDbHelper::GetLastError() { return m_lastError; }这段代码有几个关键点值得展开说。第一,mysql_options 设置字符集。很多人连接数据库之后发现中文用户名查不到,就是因为字符集没设对。MySQL 8.0 默认是 utf8mb4,但 C API 默认可能是 latin1,两边不一致就会出问题。在 mysql_init 之后、mysql_real_connect 之前调用 mysql_options 设置字符集,是最稳妥的做法。第二,mysql_real_escape_string 防注入。登录框是 SQL 注入的重灾区。如果用户输入' OR '1'='1,拼接出来的 SQL 就变成了SELECT id FROM user_info WHERE username='' OR '1'='1' AND password='...',直接绕过校验。用 escape 函数把特殊字符转义掉,能挡住绝大部分注入攻击。第三,mysql_store_result 和 mysql_free_result 必须配对。每次查询完都要把结果集释放掉,否则内存会持续泄漏。在长时间运行的程序里,这个泄漏会累积到程序崩溃。3.3 登录按钮的响应逻辑在对话框类里添加一个成员变量CDbHelper m_db;,然后在 OnInitDialog 里初始化连接:BOOL CLoginDlg::OnInitDialog() { CDialogEx::OnInitDialog(); // 尝试连接数据库 if (!m_db.Connect("127.0.0.1", "root", "your_password", "test_db")) { CString errMsg; errMsg.Format(_T("数据库连接失败:%s"), CString(m_db.GetLastError().c_str())); AfxMessageBox(errMsg); } return TRUE; }登录按钮的响应函数:void CLoginDlg::OnBnClickedBtnLogin() { CString strUser, strPwd; GetDlgItemText(IDC_EDIT_USER, strUser); GetDlgItemText(IDC_EDIT_PWD, strPwd); // 空值校验 if (strUser.IsEmpty() || strPwd.IsEmpty()) { AfxMessageBox(_T("用户名和密码不能为空")); return; } // 长度校验 if (strUser.GetLength() > 50 || strPwd.GetLength() > 100) { AfxMessageBox(_T("输入长度超出限制")); return; } // 转换为 std::string(注意宽字符转窄字符) CT2A userA(strUser); CT2A pwdA(strPwd); if (m_db.CheckLogin(std::string(userA), std::string(pwdA))) { AfxMessageBox(_T("登录成功")); // 这里可以跳转到主界面 // CDialogEx::OnOK(); } else { AfxMessageBox(_T("用户名或密码错误")); // 清空密码框,方便重新输入 SetDlgItemText(IDC_EDIT_PWD, _T("")); GetDlgItem(IDC_EDIT_PWD)->SetFocus(); } }这里有个字符转换的细节:MFC 默认使用 Unicode 字符集,CString 是宽字符(wchar_t),而 MySQL C API 用的是窄字符(char)。所以需要转换。CT2A是 ATL 提供的转换宏,会自动处理编码转换。如果项目用的是多字节字符集,那就不需要转换,直接用strUser.GetBuffer()就行。注意:CT2A 转换后的字符串生命周期只在当前作用域内有效,不要把它保存到全局变量里。4. 常见问题排查与避坑指南4.1 连接失败的五种典型情况数据库连接失败是新手遇到最多的问题,没有之一。我把常见的失败原因整理成一张速查表:错误信息关键词可能原因解决方法Can't connect to MySQL serverMySQL 服务没启动在服务管理器里启动 MySQL 服务Access denied for user用户名或密码错误检查连接参数,确认 root 密码Unknown database数据库名写错或不存在在 Workbench 里确认数据库已创建Can't connect to MySQL server on '127.0.0.1'端口不对或被防火墙拦截确认端口是 3306,检查防火墙规则找不到 libmysql.dlldll 没拷贝到输出目录把 dll 拷到 exe 同级目录还有一种情况是编译时报“无法打开 libmysql.lib”,这说明附加库目录配错了,或者 lib 文件和当前编译平台不匹配(32 位项目用了 64 位的 lib)。4.2 中文乱码的根因分析中文乱码的本质是编码不一致。数据从输入框到数据库,中间经过了多个环节,每个环节都有自己的编码方式:MFC 输入框:Unicode(宽字符)CT2A 转换:取决于系统区域设置,中文系统默认是 GBKMySQL C API:默认 latin1,需要手动设为 utf8mb4MySQL 表字段:建表时指定的字符集MySQL 服务端:配置文件里的 character-set-server只要有一个环节不一致,就会出现乱码。最稳妥的做法是全部统一为 utf8mb4:建表时指定DEFAULT CHARSET=utf8mb4连接后执行SET NAMES utf8mb4或者在 mysql_options 里设置MYSQL_SET_CHARSET_NAME为utf8mb4CT2A 转换时指定代码页为 CP_UTF8:CT2A userA(strUser, CP_UTF8);我实测下来,最后这种组合最稳:数据库、表、连接全部 utf8mb4,CT2A 显式指定 CP_UTF8。4.3 密码明文存储的安全隐患Demo 里密码是明文存的,但实际项目绝对不能这么干。正确的做法是:注册时:生成一个随机盐值(salt),把password + salt做哈希(推荐 bcrypt 或 argon2),把哈希值和盐值一起存进数据库。登录时:根据用户名取出盐值,把用户输入的密码加盐哈希,和数据库里的哈希值比对。如果不想引入第三方哈希库,至少也要用 SHA-256 加盐。虽然 SHA-256 抗暴力破解能力不如 bcrypt,但比明文强太多了。// 伪代码示意 std::string salt = GenerateRandomSalt(); std::string hash = SHA256(password + salt); // 存库:INSERT INTO user_info (username, password, salt) VALUES (?, ?, ?)实操心得:盐值长度建议至少 16 字节,每个用户独立生成,不要用固定盐。固定盐等于没加盐,彩虹表照样能破。4.4 内存泄漏与资源释放MySQL C API 有几个资源需要手动释放:mysql_init返回的 MYSQL* 需要mysql_closemysql_store_result返回的 MYSQL_RES* 需要mysql_free_resultmysql_fetch_row返回的 MYSQL_ROW 不需要手动释放,它指向结果集内部的内存在 Debug 模式下,VS 会在程序退出时输出内存泄漏报告。如果看到Detected memory leaks!,大概率是某个 MYSQL_RES 忘了释放。我的习惯是在每个mysql_store_result之后立刻写mysql_free_result,中间不要插入其他逻辑,避免忘记。5. 功能扩展与优化方向5.1 记住用户名功能的实现“记住用户名”是个很实用的功能,实现起来也不复杂。思路是把用户名写进注册表或者配置文件,下次打开时自动填充。用注册表的方式:// 保存 HKEY hKey; RegCreateKeyEx(HKEY_CURRENT_USER, _T("Software\\MyLoginApp"), 0, nullptr, REG_OPTION_NON_VOLATILE, KEY_WRITE, nullptr, &hKey, nullptr); RegSetValueEx(hKey, _T("LastUser"), 0, REG_SZ, (const BYTE*)strUser.GetString(), (strUser.GetLength() + 1) * sizeof(TCHAR)); RegCloseKey(hKey); // 读取 TCHAR buf[256] = { 0 }; DWORD bufSize = sizeof(buf); DWORD type = REG_SZ; if (RegOpenKeyEx(HKEY_CURRENT_USER, _T("Software\\MyLoginApp"), 0, KEY_READ, &hKey) == ERROR_SUCCESS) { RegQueryValueEx(hKey, _T("LastUser"), nullptr, &type, (LPBYTE)buf, &bufSize); RegCloseKey(hKey); SetDlgItemText(IDC_EDIT_USER, buf); }用配置文件的方式更简单,直接读写 ini 文件:WritePrivateProfileString(_T("Login"), _T("LastUser"), strUser, _T(".\\config.ini")); GetPrivateProfileString(_T("Login"), _T("LastUser"), _T(""), buf, 256, _T(".\\config.ini"));两种方式各有优劣:注册表更“正规”,但卸载时容易残留;ini 文件更直观,但容易被用户误删。我个人偏好 ini 文件,因为调试方便,出问题了直接打开看就行。5.2 登录失败次数限制为了防止暴力破解,可以加一个失败次数限制:连续失败 3 次就锁定账号 5 分钟。实现思路是在数据库里加两个字段:ALTER TABLE user_info ADD COLUMN fail_count INT DEFAULT 0; ALTER TABLE user_info ADD COLUMN lock_until TIMESTAMP NULL;每次登录失败时fail_count + 1,达到阈值就设置lock_until = NOW() + INTERVAL 5 MINUTE。登录时先检查lock_until是否大于当前时间,如果是就拒绝登录。这个逻辑放在 SQL 里做比放在 C++ 里做更可靠,因为 SQL 是原子的,不会出现并发问题。5.3 从 Demo 到生产环境的差距这个 Demo 跑通之后,距离生产环境还有不少距离。我列几个必须补上的点:密码加密:前面说过了,必须做哈希加盐。连接池:Demo 里是单连接,并发高了会排队。生产环境要用连接池。日志记录:每次登录成功/失败都要记日志,方便审计和排查。异常处理:网络断开、数据库重启这些情况要有兜底逻辑,不能直接崩溃。界面美化:MFC 默认界面比较朴素,可以用自绘或者第三方库美化一下。不过话说回来,Demo 的价值在于跑通流程、理解原理。把这些基础打牢了,往上加功能就是水到渠成的事。6. 我个人在实际操作中的几点体会最后分享几个我在实际项目中总结的小经验,都是文档里不会写的。第一,连接字符串不要硬编码。我见过太多项目把数据库密码写死在代码里,换环境就要重新编译。正确的做法是放在配置文件里,程序启动时读取。哪怕是个 Demo,养成这个习惯也是好的。第二,编译平台要统一。32 位和 64 位的库不能混用,这个坑我踩过不止一次。建议在项目属性里明确指定目标平台,不要用“Any CPU”这种模糊的选项。第三,测试用例要覆盖边界情况。空用户名、超长用户名、特殊字符、SQL 注入尝试,这些都要测一遍。我习惯在数据库里预置几个测试账号,包括一个用户名为空的、一个密码为空的,专门用来验证边界逻辑。第四,libmysql.dll 的版本要和 lib 文件一致。有时候从不同地方下载的 dll 和 lib 版本不匹配,编译能过但运行时报错。最保险的做法是只用 Connector/C 安装目录里的那一套,不要从别处拷贝。这套流程我前后配过不下十次,每次换新机器都要重新走一遍。现在把它完整写下来,一方面是给自己留个备忘,另一方面也希望能帮到正在踩坑的人。MFC 虽然老,但把基础打扎实了,后面学 Qt 或者 Web 开发,很多概念是相通的。