简介基于C与QT的二手书销售系统是一份面向计算机专业学生的完整项目资料适用于C课程设计或毕业设计阶段的实践学习。系统覆盖用户注册登录、书籍上架与检索、购物车与订单管理、评价反馈等核心业务模块并在实现中融入二叉搜索树、链表等数据结构采用MVC模式组织代码便于理解软件分层与业务流程。压缩包共72个文件以22个头文件、20个C源文件、5个Qt界面文件和5个文本说明文件为主附带图标、图片资源及工程配置文件整体大小约2.91MB目录结构清晰。作者还提供了README说明文档和用户、书籍、订单等数据文件方便快速搭建并查看系统设计思路。目前已有103人学习下载适合用作QT开发与C工程实践的参考范例也可在现有模块上继续扩展支付、权限或数据持久化功能提升完整度和工程能力。1. 拿到「基于C与QT的二手书销售系统.zip」先想清楚这三件事一个压缩包停在下载目录里名字写着二手书销售系统但真正放到你面前的是一道选择题是先解压看代码还是先决定自己要复刻还是接手。我的建议是不要急着双击 exe。先确认三件事包里适合你现有环境的 Qt 版本使用的构建方式是 qmake 还是 CMake以及数据库依赖是 SQLite 还是 MySQL。这套系统的典型技术栈是 C 负责业务对象与数据访问Qt Widgets 负责界面和事件分发SQLite 做本地存储。你搜索的关键词若带着课程设计、毕业设计、Qt 入门实践那这套组合足够覆盖「登录、图书展示、购物车、订单」四类主流程真实业务里还够扩展成多角色权限与库存事务。本文按这个标题会踩到的实际环节展开环境与工程结构、数据表与访问层、界面与业务解耦、买卖闭环和发布排错。读者可以是正在交作业的学生也可以是想把手头管理系统改造到 Qt 的技术人员下文给的代码与参数全部围绕这个标题可复现。2. 把 C 与 QT 的工程结构搭成能跑的样子2.1 Qt 工程里谁负责界面、谁负责业务这一节先解决「工程拆成什么样后面才不返工」。常见的二手书项目里有个隐形问题把数据库查询直接写在按钮的槽函数中。界面一多、查询一复杂每个窗口都持有自己的 QSqlQuery连接管理混乱改一个表名要全文搜索替换。正确边界是界面层只发信号、接受信号业务层封装成类数据层单独放 DAO 文件。一个可维护的工程目录长这样BookStore/ ├── CMakeLists.txt 或 BookStore.pro ├── src/ │ ├── main.cpp │ ├── MainWindow.h/.cpp │ ├── LoginDialog.h/.cpp │ ├── dao/ │ │ ├── DbManager.h/.cpp │ │ ├── BookDao.h/.cpp │ │ └── OrderDao.h/.cpp │ ├── models/ │ │ └── Book.h/.cpp │ └── widgets/ │ ├── BookCard.h/.cpp │ └── CartWidget.h/.cpp └── resources/ └── images/ (书籍封面、默认图)这个结构把 Qt 工程里的两类代码分开了继承 QWidget / QDialog 的类全部在界面层不直接操作 SQL 语句继承 QObject 的 DAO 类集中在数据层不依赖具体窗口。工程规模到一万行以内都不用引入更重的 MVC 框架依赖 Qt 自身的信号槽已经足够。2.2 从零建一个最小可编译的 Qt Widgets 工程标题里既然强调 C 与 QT构建脚本就直接决定代码能不能跑。Qt 5.15 之后官方更推荐 CMake但大量既有项目仍用 qmake。我这里给 CMake 版本因为它的 Qt6 兼容性更好也不容易被 Qt Creator 的套件切换问题卡住。先在任意目录创建以下文件cmake_minimum_required(VERSION 3.16) project(BookStore VERSION 1.0 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) # 自动处理 Q_OBJECT 的 moc 文件 set(CMAKE_AUTORCC ON) # 自动编译 .qrc 资源 find_package(Qt6 REQUIRED COMPONENTS Widgets Sql) # 如果你本机是 Qt 5.15改成 find_package(Qt5 5.15 REQUIRED COMPONENTS Widgets Sql) add_executable(BookStore src/main.cpp src/MainWindow.cpp src/LoginDialog.cpp src/dao/DbManager.cpp src/dao/BookDao.cpp src/models/Book.cpp ) target_link_libraries(BookStore PRIVATE Qt6::Widgets Qt6::Sql )CMAKE_AUTOMOC ON是 Qt6 的 CMake 工程最容易漏掉的一项。你的类里只要出现了Q_OBJECT宏就必须由 moc 工具处理CMake 里开了 AUTOMOC 才会在编译前自动生成对应源码。find_package里指定了Sql组件这是数据库访问的前提漏掉它代码里#include QSqlDatabase会直接报找不到头文件。对应的main.cpp保持最小入口#include QApplication #include QMessageBox #include MainWindow.h #include dao/DbManager.h int main(int argc, char *argv[]) { QApplication app(argc, argv); DbManager db; if (!db.open(bookstore.db)) { QMessageBox::critical(nullptr, 错误, 数据库打开失败: db.lastError()); return 1; } MainWindow w; w.show(); return app.exec(); }代码逻辑很清楚先初始化数据库再创建主窗口。这里有个常见误用把db.open()放到 MainWindow 构造函数里一旦打开失败错误弹窗因为父窗口还没构造完成界面表现会很奇怪。单独在main()里做前置校验失败就直接退出成功才进入 Qt 事件循环。QApplication是整个 GUI 程序唯一的应用对象app.exec()启动事件循环之后所有信号槽都在这个循环里分发。3. 数据模型先行二手书系统的 SQLite 表与 DAO 封装3.1 五张表把买卖闭环的字段定死界面可以后画表结构要先定。二手书销售系统区别于新书商城的关键字段在于图书品相、原价与售价差、卖家标识、商品上下架状态。一个典型的表设计如下CREATE TABLE users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, nickname TEXT, role INTEGER NOT NULL DEFAULT 0, -- 0 买家 1 卖家 2 管理员 created_at TEXT NOT NULL ); CREATE TABLE books ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, author TEXT NOT NULL, publisher TEXT, isbn TEXT UNIQUE, original_price REAL NOT NULL DEFAULT 0, sell_price REAL NOT NULL DEFAULT 0, book_condition INTEGER NOT NULL DEFAULT 2, cover_path TEXT, seller_id INTEGER NOT NULL REFERENCES users(id), status INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL ); CREATE TABLE orders ( id INTEGER PRIMARY KEY AUTOINCREMENT, order_no TEXT NOT NULL UNIQUE, buyer_id INTEGER NOT NULL REFERENCES users(id), total_amount REAL NOT NULL, status INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL ); CREATE TABLE order_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, order_id INTEGER NOT NULL REFERENCES orders(id), book_id INTEGER NOT NULL REFERENCES books(id), price REAL NOT NULL, quantity INTEGER NOT NULL DEFAULT 1 ); CREATE TABLE cart_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id), book_id INTEGER NOT NULL REFERENCES books(id), added_at TEXT NOT NULL, UNIQUE(user_id, book_id) );book_condition用整数而非字符串这是我在这类项目里常坚持的做法。字符串存「九成新」「有划线」在展示时友好但筛选和统计就会变成一串CASE WHEN。整数枚举配合界面映射表排序、过滤都方便。status字段在 books 表里标记「在售、已售、下架」下单前更新该状态后面会用它防止一单多卖。3.2 QSqlDatabase 连接管理别每个窗口各连一次Qt SQL 模块的常见坑是QSqlDatabase::addDatabase()重复调用会覆盖同名连接。正确做法是全局只注册一次然后在 DAO 里通过QSqlDatabase::database()获取连接句柄。先写一个极简管理类// DbManager.h #include QSqlDatabase #include QSqlError class DbManager { public: bool open(const QString path); QString lastError() const { return m_db.lastError().text(); } private: QSqlDatabase m_db; }; // DbManager.cpp #include DbManager.h #include QSqlQuery bool DbManager::open(const QString path) { m_db QSqlDatabase::addDatabase(QSQLITE); m_db.setDatabaseName(path); if (!m_db.open()) { return false; } QSqlQuery query(m_db); query.exec(PRAGMA foreign_keys ON); query.exec(PRAGMA journal_mode WAL); return true; }PRAGMA foreign_keys ON在 SQLite 里默认是关闭的而建表语句里写了REFERENCES不开这个外键约束形同虚设。WAL日志模式提升并发读性能界面操作时不会因为一个长事务阻塞列表刷新。注意addDatabase的连接名默认连接名是空字符串整个程序只调用这一次即可。3.3 BookDao 封装参数绑定与行映射界面层要拿一本书、一组书DAO 应该返回结构体或对象而不是裸的 QSqlQuery 让上层去解析。定义一个简单的Book结构体再把查询封装进BookDaostruct Book { int id; QString title; QString author; double sellPrice; int condition; QString coverPath; }; class BookDao { public: QVectorBook fetchOnSale(const QString keyword, int offset, int limit); bool markSold(int bookId); bool insert(const Book book, int sellerId); }; QVectorBook BookDao::fetchOnSale(const QString keyword, int offset, int limit) { QVectorBook books; QSqlQuery query; query.prepare(SELECT id, title, author, sell_price, book_condition, cover_path FROM books WHERE status 0 AND title LIKE :kw ORDER BY created_at DESC LIMIT :limit OFFSET :offset); query.bindValue(:kw, % keyword %); query.bindValue(:limit, limit); query.bindValue(:offset, offset); if (!query.exec()) { qWarning() 查询失败 query.lastError().text(); return books; } while (query.next()) { Book b; b.id query.value(0).toInt(); b.title query.value(1).toString(); b.author query.value(2).toString(); b.sellPrice query.value(3).toDouble(); b.condition query.value(4).toInt(); b.coverPath query.value(5).toString(); books.append(b); } return books; }核心习惯是prepare bindValue两个参数值得注意。:kw用%拼接后缀实现模糊查询但%只能拼在用户输入的外侧如果用户输入本身就带%或_通配符需要先做转义否则搜索结果会超出预期。:limit和:offset是分页参数SQLite 的 LIMIT 子句原本不支持参数占位但 Qt 的 SQLite 驱动做了转换可以放心绑定不过要注意bindValue绑定整数时如果从QSpinBox::value()拿值它是 int 类型不需要 toInt 转换直接用即可。4. 界面层与业务层解耦QT 信号槽的正确用法4.1 登录窗口的模态返回值设计登录窗口是 QT 信号槽最典型的教学场景也是这个系统里第一个「界面间通信」的范例。不要用全局变量保存当前登录用户也不要在 LoginDialog 里直接操作主窗口。用一个返回QDialog::Accepted的模式对话框配合QSettings记住上次用户名int LoginDialog::checkLogin(const QString username, const QString password) { QSqlQuery query; query.prepare(SELECT id, nickname, role FROM users WHERE username :u AND password_hash :p); query.bindValue(:u, username); query.bindValue(:p, hashPassword(password)); if (!query.exec() || !query.next()) { return -1; // 无匹配或执行失败 } m_userId query.value(0).toInt(); m_nickname query.value(1).toString(); m_role query.value(2).toInt(); return m_role; } // 在 MainWindow 里发起登录 void MainWindow::onLoginRequested() { LoginDialog dlg(this); if (dlg.exec() QDialog::Accepted) { setWindowTitle(QString(二手书销售系统 - %1).arg(dlg.nickname())); refreshBookList(); } }dlg.exec()启动模态事件循环返回值QDialog::Accepted表示用户点了确定。hashPassword一般用QCryptographicHash::hash(password.toUtf8(), QCryptographicHash::Sha256).toHex()注意加盐密码明文入库在该系统里是必须避免的。这个流程的边界是 LoginDialog 只校验账号、持有用户 ID不触碰主窗口的 UI 元素之后所有窗口通过这个 ID 关联数据。4.2 槽函数返回值和连接方式的边界标题相关热词里有「qt 槽函数 返回值」这里值得明确写一次槽函数可以声明返回值但通过connect信号触发时返回值会被 Qt 直接丢弃。如果有人试图在槽函数里return false来阻止界面跳转这是行不通的。拦截和校验必须在发射端完成或者在槽内部用 if-else 分支控制。例如「点击购买按钮先检查库存再关弹窗」// MainWindow.cpp connect(ui-buyButton, QPushButton::clicked, this, [this]() { int currentStock m_bookDao.stockOf(m_currentBookId); if (currentStock 0) { QMessageBox::warning(this, 缺货, 这本书已经售出或下架); return; // 通过提前 return 阻断流程 } proceedToCheckout(m_currentBookId); });另一个信号槽边界是连接类型。默认的Qt::AutoConnection在同线程内退化为直连跨线程才排队。二手书系统里 90% 的操作都在主线程不会触发线程问题但一旦你想用QtConcurrent::run或QRunnable做封面图片的缩略图处理就要把跨线程信号连接显式写成Qt::QueuedConnection否则可能在子线程里直接调用了 UI 对象的槽引发崩溃。4.3 用 QTableWidget 还是 QSqlQueryModel图书列表是界面的核心。常见两种写法是QTableWidget手动填行和QTableView QSqlQueryModel绑定查询。我的建议是数据量低于 2000 行且需要对单元格做合并、按钮等定制时用QTableWidget需要面向大数据量分页、希望表格自动跟随数据库变化时用QSqlQueryModel。以下表格给出两者的取舍维度QTableWidgetQTableView QSqlQueryModel内存占用每个单元格一个 QTableWidgetItem量大时偏高模型按需取数据内存较好自定义按钮/控件setCellWidget 可以直接放需要自定义 delegate代码量大排序筛选手动 sortItemssetSortRole 加 QSortFilterProxyModel行编辑item 直接改需要重写 setData 和 flags适合场景购物车、后台管理图书列表、订单明细这个系统里图书列表是需要封面的封面渲染用setCellWidget放一个QLabel QPixmap最直接所以图书列表我用QTableWidget。购物车和订单明细是纯数字与文本改用QTableView QSqlQueryModel反而简洁。两种混用不算架构混乱因为接口层都用BookDao换表格控件不影响业务。5. 完成买卖闭环上架、加购、下单的事务与状态机5.1 图书上架的表单校验与封面路径处理卖家的上架入口是一个QDialog字段包括书名、作者、ISBN、售价、原价、品相、封面图。接口层最容易出的 bug 是封面路径用户选的图片是一张 3MB 的原图直接存路径后界面每次加载都用QPixmap(path)缩放内存会明显上涨。正确做法是上架瞬间复制到应用数据目录并生成缩略图bool BookDao::insertWithCover(const Book book, int sellerId, const QString sourceCoverPath, QString *errMsg) { // 目标目录不存在则创建 QDir dir(QCoreApplication::applicationDirPath() /covers); if (!dir.exists()) { dir.mkpath(.); } QImage img(sourceCoverPath); if (img.isNull()) { *errMsg 封面图片无法读取; return false; } QImage thumb img.scaled(320, 420, Qt::KeepAspectRatio, Qt::SmoothTransformation); QString dest dir.filePath(QString(%1_%2.jpg) .arg(sellerId) .arg(QDateTime::currentMSecsSinceEpoch())); if (!thumb.save(dest, JPG, 85)) { *errMsg 封面缩略图保存失败; return false; } QSqlQuery query; query.prepare(INSERT INTO books(title, author, publisher, isbn, original_price, sell_price, book_condition, cover_path, seller_id) VALUES(:t, :a, :p, :i, :op, :sp, :c, :cover, :sid)); query.bindValue(:t, book.title); query.bindValue(:a, book.author); query.bindValue(:p, book.publisher); query.bindValue(:i, book.isbn); query.bindValue(:op, book.originalPrice); query.bindValue(:sp, book.sellPrice); query.bindValue(:c, book.condition); query.bindValue(:cover, dest); query.bindValue(:sid, sellerId); if (!query.exec()) { *errMsg query.lastError().text(); QFile::remove(dest); return false; } return true; }这段代码有两个容易被忽略的参数点。第一img.scaled的第三、四个参数Qt::KeepAspectRatio与Qt::SmoothTransformation前者保持宽高比后者做平滑缩放缺一不可若用Qt::IgnoreAspectRatio会直接拉伸变形。第二thumb.save(dest, JPG, 85)第三个参数是 JPG 压缩质量 0 到 10085 在清晰度和体积之间较均衡。缩略图文件名用时间戳毫秒避免同名覆盖但如果两次插入发生在同一毫秒极端情况下仍会冲突更保险的做法是加QUuid::createUuid().toString(QUuid::WithoutBraces)。5.2 购物车与订单事务保证不超卖从「加入购物车」到「生成订单」是这个系统里唯一需要数据库事务的环节。超卖的典型场景两个买家同时看到一本存量为 1 的书同时提交订单如果先更新 books.status 再插入 order_items两个事务互相不可见就可能两个订单都成功。SQLite 的默认事务是 DEFERRED解决方法是把「检查状态 → 更新状态 → 插入订单 → 插入明细」放在一个显式事务里bool OrderDao::createOrder(int buyerId, const QVectorint bookIds, QString *errMsg) { QSqlDatabase db QSqlDatabase::database(); db.transaction(); double total 0.0; QVectorQPairint, double items; // bookId, price for (int bookId : bookIds) { QSqlQuery query(db); query.prepare(SELECT sell_price, status FROM books WHERE id :id); query.bindValue(:id, bookId); if (!query.exec() || !query.next()) { db.rollback(); *errMsg QString(书籍 %1 不存在).arg(bookId); return false; } if (query.value(1).toInt() ! 0) { db.rollback(); *errMsg QString(书籍 %1 已售出或下架).arg(bookId); return false; } double price query.value(0).toDouble(); total price; items.append({bookId, price}); QSqlQuery update(db); update.prepare(UPDATE books SET status 1 WHERE id :id AND status 0); update.bindValue(:id, bookId); if (!update.exec() || update.numRowsAffected() ! 1) { db.rollback(); *errMsg QString(书籍 %1 状态更新失败).arg(bookId); return false; } } QSqlQuery insertOrder(db); insertOrder.prepare(INSERT INTO orders(order_no, buyer_id, total_amount, created_at) VALUES(:no, :bid, :total, :time)); insertOrder.bindValue(:no, generateOrderNo()); insertOrder.bindValue(:bid, buyerId); insertOrder.bindValue(:total, total); insertOrder.bindValue(:time, QDateTime::currentDateTime().toString(Qt::ISODate)); if (!insertOrder.exec()) { db.rollback(); *errMsg insertOrder.lastError().text(); return false; } int orderId insertOrder.lastInsertId().toInt(); for (const auto item : items) { QSqlQuery detail(db); detail.prepare(INSERT INTO order_items(order_id, book_id, price) VALUES(:oid, :bid, :price)); detail.bindValue(:oid, orderId); detail.bindValue(:bid, item.first); detail.bindValue(:price, item.second); if (!detail.exec()) { db.rollback(); *errMsg detail.lastError().text(); return false; } } db.commit(); return true; }这条事务里的关键语句是UPDATE books SET status 1 WHERE id :id AND status 0。SQLite 的写事务在同一时刻只有一个写者条件里带AND status 0会让第二个并发事务更新受影响行数为 0从而被numRowsAffected() ! 1拦截并回滚。单靠事务包裹而不加条件更新仍然无法防止超卖。QSqlQuery构造时显式传db保证全部 SQL 使用同一个连接这是事务边界不出错的底线。订单号generateOrderNo()里我一般拼QDateTime::currentMSecsSinceEpoch()与用户 ID 的哈希尾部保证可读且分布式环境基本不重复。注意lastInsertId()依赖AUTOINCREMENT字段若主键是普通INTEGER PRIMARY KEY也可以拿但前提是执行 INSERT 的 QSqlQuery 还没有复用。6. 打包发布与三个高频报错的处理6.1 windeployqt 一键补齐运行库开发环境编译运行正常把整个文件夹发给别人就打不开原因是缺 Qt 的 DLL。Windows 下标准做法是用windeployqt工具补齐依赖。命令如下mkdir build-deploy copy /y build\BookStore.exe build-deploy\ cd build-deploy C:\Qt\6.5.3\mingw_64\bin\windeployqt.exe BookStore.exewindeployqt会扫描 exe 的导入表自动复制所需的 Qt 库、platforms 插件、styles 插件。它不负责编译器和 C 运行库所以发布目录里还要包含libgcc_s_seh-1.dll、libstdc-6.dll和libwinpthread-1.dll如果是 MSVC 套件则需安装对应版本的Microsoft Visual C Redistributable。判断方法打开运行库一栏看 exe 用的是什么编译器。发布前把这本书的封面目录covers/也放进发布文件夹否则运行到缩略图加载会静默失败。6.2 数据库驱动缺失与平台插件路径错误这是这个标题下被搜烂的两个问题分别对应两种报错。第一种「QSqlDatabase: QSQLITE driver not loaded」。原因几乎总是把sqldrivers/qsqlite.dll漏在了发布目录外。默认插件安装路径在C:\Qt\6.5.3\mingw_64\plugins\sqldrivers拷贝到发布目录时要保持相对结构sqldrivers/与 exe 同级。第二种qt_qpa_platform_plugin_path报错提示找不到windows平台插件。原因是platforms/qwindows.dll缺失或路径不对。windeployqt若运行时没找到 Qt 安装目录会生成空的插件目录解决方式是手动从安装路径的plugins\platforms拷贝或者用--platform-plugins参数指定来源。6.3 中文乱码与 Qt 国际化的一步到位代码里大量出现中文提示语时建议从第一天就用tr()包裹。Windows 上 Qt Creator 源文件默认 GBK 而编译器按 UTF-8 解析会触发 C4819 警告或乱码。处理办法是代码统一 UTF-8并在main()里设置QTextCodec::setCodecForLocale(QTextCodec::codecForName(UTF-8)); // Qt 6 里这行不再必要UTF-8 是默认编码Qt 国际化在这一步也顺便落地把所有界面文案包进tr(...)运行lupdate生成.ts文件用 Qt Linguist 翻译后lrelease出.qm。加载翻译文件时注意路径不做死用QCoreApplication::applicationDirPath() /translations。这一套做完系统面向非中文用户时只需要替换翻译文件业务代码零改动。发布后的验证清单在干净虚拟机里双击 exe确认数据库文件和covers/目录能自动创建用一个 2GB 内存的机器跑一遍「登录 → 搜索 → 下单」全流程确认无插件缺失最后检查订单表里order_no的唯一性约束避免时间戳生成器在大批量下单时撞车。到这一步这个标题背后的项目才真正从「能编译」变成「可交付」。本文还有配套的精品资源点击获取