简介:基于Qt与C++实现的银行管理系统完整源码,面向需要完成毕业设计或学习C/S架构开发的计算机专业学生。系统在Linux环境下开发,服务器端采用epoll模型,配合MySQL数据库存储,代码中应用了单例模式、抽象工厂及对象动态创建等设计模式,具备开户、存款、取款、转账、余额查询、历史记录查询等完整业务功能。压缩包共121个文件,包含45个头文件、41个C++源文件、11个UI界面文件以及工程配置和Makefile,整体仅178KB,结构清晰便于阅读和学习。已有616人学习下载,适合作为课程设计或毕业设计的参考范本。资源不仅提供了可直接运行的BankServer程序,还附带了各功能模块的分离源码,帮助理解服务器网络编程、数据库交互与界面设计的整合思路。
1. 用 Qt 和 C++ 拆银行系统:先分清 C/S 两端在干什么
不少课设里的银行管理系统,界面和业务都堆在同一个进程里,数据库连接每次操作都重新建,服务端一开线程就再不管锁。这套代码不是那种写法:它在 Linux 下用 epoll 做服务端事件驱动,MySQL 存账户和流水,Qt 这边只有 LoginDialog、OpenDialog 这些界面层,网络分包、密码散列、业务逻辑分别独立。对准备答辩的人来说,最值得学的不是 Qt 控件有多漂亮,而是 C/S 形态下每个文件该承担什么角色。下面按拆机方式过一遍:网络层、存储层、客户端流程、业务事务,最后讲构建和调试里容易绊倒人的地方。
2. network.cpp 的 epoll 模型:把 socket 事件和业务线程分开
2.1 为什么先选 epoll 而不是一连接一线程
银行系统并不会像门户网站那样跑几十万并发,但 C/S 结构下仍然要面对“多个客户端同时开户、转账”的窗口期。如果服务端用阻塞式 accept,每来一个连接就new thread,数据库一条慢 SQL 就可能让几十个线程阻塞在那里;用 select 也有 1024 个 fd 的上限,每次还要遍历全部 socket。epoll 解决的问题是让单个线程知道哪些 fd 可读、可写,但它只负责通知,真正的数据库操作必须离开事件循环,否则一条查询就会拖住后面所有连接。
这个项目里network.cpp的核心就是三层结构:socket 事件采集、二进制分包解析、业务线程池分发。事件循环里只做epoll_wait和read;拿到一个完整请求包后,把它提交到线程池执行。这样即便某个账户的转账事务跑了几十毫秒,其他客户端的登录请求也能先被读取,不会被一个慢事务堵死。
这里需要明确一个边界:epoll 不是线程池替代品。统计连接数用 epoll,计算和数据库操作用固定线程池,两者配合才有意义。如果只开一个线程做 epoll 又直接在该线程里执行 SQL,那和单线程阻塞 server 没有本质区别。network.cpp里如果能找到ThreadPool或任务队列,就是理解了这一步。
2.2 事件循环里的读写边界和超时参数
下面这段是整理后的网络主循环,我在写类似服务端时习惯保持这个形状:
// network.cpp 核心事件循环 int epfd = ::epoll_create1(0); struct epoll_event ev; ev.events = EPOLLIN; // 水平触发,初版不容易漏事件 ev.data.fd = listen_fd; ::epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev); std::vector<epoll_event> ready(1024); while (isRunning()) { int n = ::epoll_wait(epfd, ready.data(), ready.size(), 50); for (int i = 0; i < n; ++i) { if (ready[i].data.fd == listen_fd) { handleAccept(epfd, listen_fd); continue; } Request req = handleReadFrom(ready[i].data.fd); if (!req.isNull()) { thread_pool.submit([this, req]() { dispatchBusiness(req); }); } } }这里epoll_wait的第四个参数是 50ms 超时,不是越小越好。设成 -1 会一直阻塞,程序退出信号没法及时响应;设成 0 会变成忙轮询,CPU 占用高。50ms 作为心跳间隔能兼顾连接超时检查和 epoll 事件响应。ready数组 1024 只是预分配大小,返回的n小于等于数组大小;如果同时就绪超过 1024,剩下的事件会在下一轮epoll_wait返回,不会丢。
handleReadFrom要自己维护每个 fd 的接收缓冲区。常见的错是把read出来的字节直接当完整包处理,但 TCP 是流协议,一个write可能被拆成两次到达,也可能两个包粘在一次到达。所以缓冲区必须累积数据,直到能拼出一个完整报文。每次read之后都要尝试解析,解析出一个包就返回一个Request,缓冲区里剩下的字节留到下次继续解析。
2.3 自定义二进制协议:长度、操作码和 body
项目中服务端和客户端约定了一个最简单的报文头:前 4 字节是 body 长度,按网络字节序(大端)存放;接着 2 字节是操作码;后面是 body。body 里用竖线分割字段,比如开户请求是账号|密码。这样拆包逻辑可以复用,不用为每个业务写不同的解析分支。
// 粘包/半包处理:成功解出一个包返回 true,同时移除已消费字节 bool tryDecodePacket(QByteArray &buf, Packet &p) { while (buf.size() >= 6) { int len = ((unsigned char)buf[0] << 24) | ((unsigned char)buf[1] << 16) | ((unsigned char)buf[2] << 8) | (unsigned char)buf[3]; if (buf.size() < len + 6) return false; // 半包,继续等 p.op = ((unsigned char)buf[4] << 8) | (unsigned char)buf[5]; p.body = buf.mid(6, len); buf.remove(0, len + 6); return true; } return false; }说明一下while而不是if的原因:缓冲区里可能同时有多个请求包,比如客户端点击转账后连发几条指令,一次readyRead就可能带来两包数据。只解一个包会留下半个包,等下一次事件又可能把后续顺序搞乱。每次解包后返回 true,循环继续;直到缓冲区不够一个完整头才停下来等新数据。操作码目前定义如下,客户端和服务端必须同一张表。
| 操作码 | 含义 | 对应文件/模块 |
|---|---|---|
| 0x1001 | 登录 | LoginDialog |
| 0x1002 | 开户 | OpenDialog / Open.cpp |
| 0x2001 | 存款 | 服务端交易处理 |
| 0x2002 | 取款 | Withdraw.cpp |
| 0x2003 | 转账 | Transfer.cpp |
| 0x2005 | 历史记录 | History.cpp |
协议解析这里要提一个容易忽略的点:不要用strlen或者QString::fromUtf8收到\0就截断,因为 body 可能包含二进制内容。长度字段必须严格按字节处理,否则转账金额或 md5 值会把后续包切错。调试时如果发现服务端和客户端显示的报文长度不一致,优先检查长度字段转成整型时是不是把有符号char做了符号扩展。
3. My_Sql.cpp 与 MD5.cpp:单例连接和密码散列的落地写法
3.1 建表与字段:DECIMAL 和索引是底线
数据库表结构决定了事务代码能不能简洁。账户表要能直接支撑余额更新和登录校验,流水表要能支撑历史记录分页。下面是整理后的建表语句:
CREATE TABLE IF NOT EXISTS account ( account_no VARCHAR(32) PRIMARY KEY, salt CHAR(16) NOT NULL, pass_md5 CHAR(32) NOT NULL, balance DECIMAL(18,2) NOT NULL DEFAULT 0.00, status TINYINT NOT NULL DEFAULT 1, created_at DATETIME NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS account_log ( id BIGINT AUTO_INCREMENT PRIMARY KEY, account_no VARCHAR(32) NOT NULL, op_type VARCHAR(16) NOT NULL, amount DECIMAL(18,2) NOT NULL, balance_after DECIMAL(18,2) NOT NULL, counterparty VARCHAR(32) NULL, created_at DATETIME NOT NULL, INDEX idx_account_time (account_no, created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;几个字段的取舍可以直接写进答辩材料:balance用DECIMAL(18,2)而不是FLOAT/DOUBLE,因为浮点数在二进制里不能精确表示 0.1,累计到第十次取款就可能出现 0.999999;DECIMAL(18,2)是定点数,正好满足两位小数的金额精度。account_no作为业务主键,是因为登录和转账都直接用卡号定位账户,省去一次索引回表;但字符串主键在 InnoDB 里是聚簇索引,写入时可能产生页分裂,课设数据量下不用担心。
account_log的idx_account_time是联合索引,顺序是account_no, created_at,这样查询某个账号历史记录时能用上索引。不要把account_no和created_at分开建两个普通索引,联合索引已经能覆盖这个查询场景,后续按操作类型过滤可以再扩展。
| 字段 | 类型 | 设计要点 |
|---|---|---|
| account.balance | DECIMAL(18,2) | 避免浮点误差 |
| account.salt | CHAR(16) | 开户随机生成,和MD5摘要一起存 |
| account_log.op_type | VARCHAR(16) | 不建枚举,方便扩展 |
| account_log.id | BIGINT AUTO_INCREMENT | 历史记录天然有序,便于游标分页 |
3.2 单例模式管理 MySQL 连接
服务端My_Sql.cpp里的数据库连接用一个单例持有。我之前在别的 Qt 项目里也这么写过,关键是把初始化和获取连接分开:
class MySql { public: static MySql& instance() { static MySql inst; // C++11 之后局部静态变量初始化线程安全 return inst; } bool init(const char* host, unsigned port, const char* user, const char* pass, const char* db) { conn_ = mysql_init(nullptr); return conn_ && mysql_real_connect(conn_, host, user, pass, db, port, nullptr, 0); } MYSQL* get() { return conn_; } private: MySql() = default; ~MySql() { if (conn_) mysql_close(conn_); } MySql(const MySql&) = delete; MySql& operator=(const MySql&) = delete; MYSQL* conn_ = nullptr; };这段代码把构造函数私有化,保证外部不能创建第二个连接。static MySql inst是 Meyers 单例,在 C++11 标准下是线程安全的,第一次调用instance()时其他线程会等初始化完成;之后调用get()拿到同一个MYSQL*。
但要注意一个边界:单例保证只有一份连接,不代表业务线程可以并发操作这个连接。MySQL C API 的mysql_real_query同一连接同时只能跑一条 SQL,两个线程交叉执行会造成结果错乱。项目里常见做法是给所有 SQL 操作加一个互斥锁。如果你要优化,把单例连接换成连接池,每个线程拿独立连接,再配合事务锁,吞吐量会好很多;课设里单例加锁足够,答辩时能讲清楚两者取舍就很好。
3.3 密码不是明文:加盐 MD5 的写法与边界
MD5.cpp在这个系统里主要做两件事:开户时生成密码摘要,登录时比对接到的摘要。只调用一次 MD5 在现在任何一个真实业务里都不够看,所以项目里应该在密码前拼一个随机 salt。开户时生成盐再入库,登录时先按账号查 salt,再算摘要:
std::string encodePassword(const std::string& salt, const std::string& password) { std::string hash = md5(salt + password); // 简单迭代,提高暴力破解成本 for (int i = 0; i < 128; ++i) { hash = md5(hash + salt); } return hash; }盐存放在account.salt字段,长度 16 字符,来源可以是QDateTime::currentMSecsSinceEpoch()+ 随机字符。这样即使用户密码是 123456,不同账号的摘要也不同,两张用户表摆在一起也看不出谁密码相同。
需要说明的是,MD5 是摘要算法而不是加密算法,速度太快,GPU 每秒能算几十亿次,所以正式生产系统应该换成 PBKDF2、bcrypt 或 Argon2。但毕业设计评委问“为什么用 MD5”时,能说清楚“加盐 + 迭代 + 数据库同步校验”已经比裸 MD5 好很多。另一个容易被忽略的点是 MySQL 8 默认认证插件caching_sha2_password,老版本 libmysqlclient 连不上时,服务端连接报Authentication plugin 'caching_sha2_password' cannot be loaded,这不是代码逻辑问题,需要在 MySQL 侧把用户切到mysql_native_password,或者在 Qt 端换用新版驱动。
4. LoginDialog 与 OpenDialog:Qt 客户端如何把信号槽用对
4.1 用 QTcpSocket 做异步连接,不阻塞 UI
客户端这边的LoginDialog拿到用户输入的账号密码后,不是直接waitForConnected(),而是用QTcpSocket的异步信号。银行登录按钮点了之后,界面可能会等几百毫秒,如果直接阻塞在这里,窗口会变成“未响应”,Mac 和 Windows 下甚至会被系统强制标记卡死。
正确做法是在构造函数里把 socket 的事件信号连给对话框自己的槽,点击登录时只负责connectToHost或直接发送数据。整理后的代码结构是这样:
// LoginDialog.cpp LoginDialog::LoginDialog(QWidget *parent) : QDialog(parent), socket_(new QTcpSocket(this)) { connect(socket_, &QTcpSocket::readyRead, this, &LoginDialog::onReadyRead); } void LoginDialog::onLoginClicked() { if (socket_->state() != QAbstractSocket::ConnectedState) { socket_->connectToHost(serverIp_, serverPort_); pendingLogin_ = true; // 连接建立后再发登录包 return; } sendLoginPacket(); } void LoginDialog::onReadyRead() { QByteArray incoming = socket_->readAll(); buffer_ += incoming; Packet p; while (tryDecodePacket(buffer_, p)) { if (p.op == 0x1001) { handleLoginResponse(p.body); } } }注意pendingLogin_这个标志位。connectToHost 是异步的,connected信号还没有到来,如果在这里就write登录包,数据会先进入发送缓冲等待连接建立,逻辑上也行得通,但如果你之后想发多条指令,最好在connected信号里用同一个入口发,避免用户重复点击按钮导致两个登录包叠在一起。按钮槽函数不要把loginResult_作为返回值返回,Qt 信号槽机制是异步派发,槽的返回类型是 void,状态只能通过成员变量保存,之后再转发给主窗口。
4.2 响应包解析要小心处理半包和错误码
客户端的onReadyRead和第二章的服务端共用同一个tryDecodePacket,但要注意readAll()拿到的数据同样可能是一个包的一半。所以客户端也要维护一个buffer_成员变量,每次readyRead先append,再循环解析,不能每次都把 buffer 清空。下面这段是登录响应的解析:
void LoginDialog::handleLoginResponse(const QByteArray &body) { QList<QByteArray> parts = body.split('|'); if (parts.size() == 3 && parts[0] == "ok") { account_ = QString::fromUtf8(parts[1]); serverTime_ = QString::fromUtf8(parts[2]); accept(); } else { QMessageBox::warning(this, "登录失败", QString::fromUtf8(parts.value(1))); } }这里 body 里返回了三段:状态、账号、服务器时间。把服务器时间也带回来,是方便客户端和服务端日志对时,排查问题时能看清一条请求到底在哪个环节耗时。注意parts.value(1)在数组越界时返回空 QByteArray,不会崩溃;但如果 body 格式被服务端写得不对,用户只能看到空提示,服务端日志里要把原始 body 打出来,否则这类问题很难发现。
响应包的op字段必须和服务端实现对应。如果服务端处理完业务后回同一个 op,客户端就按0x1001处理;如果所有响应都用0x0000固定码,客户端会越写越乱。这里建议每类请求固定一个请求/响应对,登录用0x1001,开户用0x1002,后续业务同理。不要为了省事把所有响应都混成一个 op。
4.3 开户对话框:前端校验只是辅助,服务端必须重查
OpenDialog的写法比登录多两层:前端要做二次密码校验,服务端要做唯一性校验。前端校验能够在用户输错时立刻提示,但永远不应该作为安全边界。原因很简单:抓包工具可以直接向服务端发送一条开户指令,绕过对话框。所以Open.cpp在插入账户前必须自己检查账号格式和密码非空。
// OpenDialog.cpp 按钮槽 void OpenDialog::onOpenClicked() { QString account = ui->accountEdit->text(); QString pass = ui->passEdit->text(); QString pass2 = ui->pass2Edit->text(); if (pass != pass2) { QMessageBox::warning(this, "开户失败", "两次输入的密码不一致"); return; } if (account.size() != 16 || !account[0].isDigit()) { QMessageBox::warning(this, "开户失败", "账号必须是16位数字"); return; } sendPacket(0x1002, account.toUtf8() + "|" + pass.toUtf8()); }服务端收到后要处理并发开户同一账号的情况。简单做法是先SELECT判断再INSERT,但两个客户端同时点开户时,两边都看到“不存在”,然后都执行插入,后提交的那个会撞上主键约束。所以Open.cpp里应该依赖数据库唯一索引兜底,INSERT时捕获ER_DUP_ENTRY,再把“账号已存在”翻译成业务错误码返回。这样即使用户反复点击,也不会出现重复数据。
5. Transfer、Withdraw 与 History 的事务串起来
5.1 取款和存款:条件 UPDATE 比先查后改安全
取款这类余额变更最忌讳的写法是“先 SELECT 取出 balance,程序判断够不够,再 UPDATE 回写”。两个客户端同时对同一个账号取款,都读到余额 100,都判断够,各自更新成错误值,最终结果就不是预期值。正确写法是把余额判断放进 UPDATE 语句,让数据库自己在行锁内判断:
// Withdraw.cpp 事务片段 mysql_autocommit(db.get(), 0); QString sql = QString( "UPDATE account SET balance = balance - %1 " "WHERE account_no = '%2' AND balance >= %1") .arg(QString::number(amount, 'f', 2)) .arg(accountNo); if (mysql_real_query(db.get(), sql.toUtf8().constData(), sql.toUtf8().size()) != 0) { mysql_rollback(db.get()); mysql_autocommit(db.get(), 1); return false; } if (mysql_affected_rows(db.get()) == 0) { // 没有行被更新,说明账号不存在或余额不足 mysql_rollback(db.get()); mysql_autocommit(db.get(), 1); setError("余额不足或账号不存在"); return false; } // 插入 account_log 流水... mysql_commit(db.get()); mysql_autocommit(db.get(), 1);这段 SQL 里没有把金额写死成变量,是为了让你看清条件:balance >= %1放在WHERE里,数据库更新时先对目标行加独占锁,再判断余额条件,不满足则影响行数为 0。mysql_affected_rows返回 0 时一定要回滚,不能继续插流水。mysql_autocommit在执行前后要恢复,很多同学忘记在函数退出前把 autocommit 设回 1,后续 SQL 会一直处于事务中,提交时机变得不可控。
这里还有一个细节:amount 用QString::number(amount, 'f', 2)转字符串,不要直接用QString::number(amount),否则 0.1+0.2 的结果可能变成 0.30000000000000004。正式场景应该用QSqlQuery的预处理语句绑定参数,绑定之后再传给服务端。
5.2 转账事务:按账户号排序规避死锁
转账是系统的复杂点。一次转账至少包含四个动作:扣转出账户、增加转入账户、写两条流水记录。这些动作必须在一个事务里执行,否则转账中途宕机,钱会凭空消失。逻辑顺序如下:
- 检查转出账户状态和余额。
UPDATE account SET balance = balance - ? WHERE account_no = ? AND balance >= ?。UPDATE account SET balance = balance + ? WHERE account_no = ?。- 插入两条流水,
counterparty字段记录对方账号。 - 提交事务;任一步失败则回滚。
死锁发生在两个转账请求方向相反时:事务 A 锁了账户 1 再锁账户 2,事务 B 锁了账户 2 再锁账户 1,两者互相等待。避免死锁的常规做法是让所有事务按同一个顺序加锁。Transfer.cpp里可以先比较两个账号的字典序,小的在前,大的在后,无论在业务参数里谁是转入方,执行的 SQL 都是先锁小账号再锁大账号。这样两个互转请求不会出现交叉等待。
5.3 历史记录查询:走索引、用游标分页
History.cpp负责查account_log。查询条件通常是账号和时间范围,页面上显示最近 30 条,用户滚动后再加载下一页。SQL 写成这样:
SELECT id, account_no, op_type, amount, balance_after, counterparty, created_at FROM account_log WHERE account_no = ? ORDER BY id DESC LIMIT 30 OFFSET ?;注意ORDER BY id DESC而不是ORDER BY created_at DESC。id是自增主键,天然代表写入顺序,用主键排序能直接走聚簇索引,不用额外排一次序。created_at上虽然有索引,但时间精度到秒,同一秒内多条记录顺序不确定,分页时可能出现重复。如果用户要按时间范围过滤,就把条件改成created_at >= ? AND created_at < ?,不要写DATE(created_at) = ?这种把字段包进函数的写法,否则索引会失效变成全表扫描。
分页 OFFSET 在深翻页时性能会变差,LIMIT 300000, 30仍然要扫描 300030 行再丢前 300000 行。交易流水更适合用游标分页:上一页返回的结果里带最后一个id,下一页查询条件加WHERE id < 上页最小id,只取 30 条。这样无论翻到第几页,查询范围都被控制在一个主键区间内。
| 问题 | 现象 | 处理 |
|---|---|---|
| 余额竞态 | 两笔取款同时通过 | 条件 UPDATE + affected_rows 校验 |
| 重复开户 | 插入主键冲突 | 捕获 ER_DUP_ENTRY 映射为业务错误 |
| 历史查询慢 | 深翻页扫描大量行 | 游标分页或WHERE id < ? |
6. 构建、调试和验证这套 C/S 系统的关键点
6.1 .pro 依赖和运行库
服务端BankServer.pro至少需要network、core模块,MySQL 连接直接链接 mysqlclient:
TEMPLATE = app CONFIG += console c++11 QT += core network LIBS += -lmysqlclient -lpthread SOURCES += BankServer.cpp network.cpp My_Sql.cpp MD5.cpp Transfer.cpp如果开发机是 Ubuntu 20.04,缺少 mysql 头文件时先装libmysqlclient-dev,否则#include <mysql.h>会报找不到文件。Windows 下用 Qt 5.15 msvc2019_64 编译时,程序启动崩溃先检查是不是没装对应版本的 Visual C++ Redistributable,事件查看器里能看到0xc000007b这类错误,十有八九是运行库位数不对。
6.2 几个高性价比的调试手法
先在服务端把每个包的 op、长度和 body 用qDebug()打印成十六进制,对比客户端发送记录,能快速定位分包错位。数据库事务卡住时,在 MySQL 里执行SHOW ENGINE INNODB STATUS\G看LATEST DETECTED DEADLOCK段落,里面会写出死锁涉及的表和行。Qt 端崩溃若发生在onReadyRead里,多半是跨线程修改了界面控件,把更新 UI 的代码放进信号槽而不是直接调用。
6.3 并发验证一个真实场景
验证系统是否可靠,开两个客户端对同一个账号同时做 50 次取款 1 元,初始余额 100 元,正确结果应是失败 50 次、余额 50 元。如果出现负余额,说明balance >= ?条件写回了错误的位置。跑完后再用mysql查询account_log,流水条数应该等于成功次数,不应该出现只扣款不记流水的情况。epoll_wait 的 timeout 保持 50ms,同时把 MySQL 的innodb_lock_wait_timeout调成 5 秒,观察日志时间戳就能看出两条并发请求在哪里互相等待锁。
本文还有配套的精品资源,点击获取