C++/Qt在线点餐系统:从TCP通信到SQLite持久化实战解析
简介面向计算机相关专业毕业设计、课程设计与期末大作业的完整实战项目基于C和Qt实现在线点餐系统客户端与服务端代码齐全曾作为大四毕业设计并取得98.5分的高分评价。压缩包共201个文件约27.28MB包含39个cpp源码、23个h头文件、9个ui界面文件、qrc资源与pro工程文件以及png截图、exe可执行程序和编译中间产物等结构清晰。目前已有107人学习使用适合需要参考优秀毕设结构或快速搭建课设演示的学生。源码覆盖点餐主界面、网络通信、文件收发等模块可借助qrc资源与ui文件快速理解界面布局通过cpp与h文件掌握业务逻辑也可直接运行exe辅助验证功能。1. 从菜单点单到TCP双端通信这个C/Qt在线点餐系统值得拆一遍带“在线”二字的点餐系统很多毕业设计只是把数据库和界面做在一个程序里换台机器就露馅。真正能拿高分的设计会把客户端和服务端彻底拆开客户端负责菜单展示、购物车、下单交互服务端负责连接管理、请求路由、订单持久化。这样一个基于C和Qt的在线点餐系统正好把TCP网络编程、Qt信号槽、SQLite存储串成一条完整链路也是答辩评委最容易追问“你怎么保证并发下单不丢单”的地方。评审分98.5分并不来自界面好看而是通信层和存储层的设计在逻辑上站得住。对正在准备毕业设计、课程设计或期末大作业的计算机专业学生来说这套东西比再刷一百道Qt控件题更有价值。2. 客户端与服务端拆开之后基于QTCPSocket和QTcpServer的通信架构在线点餐系统里客户端不能直接读数据库否则每个客户端都要持有数据库账号服务端的权限控制也会形同虚设。所以常见的做法是客户端通过QTcpSocket向服务端发命令服务端用QTcpServer监听、解析、返回结果。这个模型把“读菜单”和“下订单”都变成了一次网络请求后续要扩展会员、桌台、打印小票都只需要改服务端不需要动客户端界面。把通信协议先定好后面的界面代码只是怎么调用这套协议而已。2.1 客户端与服务端各自的职责边界先看两个模块要承担什么。客户端程序包含主窗口类mainscreen负责菜单表格、购物车列表、金额汇总和下单按钮另外要有一个负责收发网络报文的类项目文件里出现的sendfile和recvfile就是这个角色。服务端则包含监听连接、解析请求、读写数据库的完整后端逻辑。模块核心职责主要技术点客户端菜单展示、购物车、下单、订单状态展示QTabWidget、QStandardItemModel、QTcpSocket服务端监听连接、解析命令、菜单管理、订单落库QTcpServer、QThread、QSqlDatabase、QSqlQuery这个表格划分清楚后开发顺序就很明确了先做协议再写服务端收发包最后写客户端界面。如果先堆界面再回头补网络层界面代码会到处夹着socket读写后期非常难维护。我一般会把recvfile、sendfile单独抽成类不放进mainscreen里这样界面逻辑和网络逻辑互不污染。2.2 自定义报文帧先解决粘包和半包QTcpSocket的readyRead信号只告诉你“缓冲区里有数据”但不会告诉你数据是什么时候到齐的。经典的坑是客户端连续发送两条菜单请求服务端一次readAll读到两个请求拼在一起或者一条大请求被拆成多个TCP小包到达服务端读一半就尝试解析JSON然后崩溃。解决办法是自己定义消息帧在字节流上切出一个个完整报文。2字节魔数 4字节载荷长度 载荷 OD payload.len() JSON文本魔数用于快速对齐和丢弃坏数据长度字段用于判断完整帧是否到达载荷用紧凑JSON格式承载业务数据。下面是我常用的一套构造帧代码。// common/protocol.h #include QByteArray #include QJsonDocument #include QJsonObject #define FRAME_MAGIC OD #define FRAME_HEADER_SIZE 6 QByteArray buildFrame(const QJsonObject payload) { QByteArray json QJsonDocument(payload).toJson(QJsonDocument::Compact); QByteArray frame; frame.append(FRAME_MAGIC, 2); // 2字节魔数 // 用大端序写4字节长度避免小端机解析错位 quint32 len static_castquint32(json.size()); frame.append(static_castchar((len 24) 0xFF)); frame.append(static_castchar((len 16) 0xFF)); frame.append(static_castchar((len 8) 0xFF)); frame.append(static_castchar(len 0xFF)); frame.append(json); // 最后拼上JSON载荷 return frame; }这里有一个容易忽略的细节长度字段按大端序写入接收方也按大端序拼回这样客户端用Windows的MSVC编译、服务端用Linux交叉编译环境跑也不会出现字节序不一致的问题。如果直接在内存里写int再memcpy在不同平台上读出来的长度可能是乱的。服务端接收时不能简单readAll然后解析要把剩余数据保存在一个成员缓冲区里每次readyRead都追加进去再循环检查是否凑够一帧。// server/recvfile.cpp void RecvFile::onReadyRead() { m_buffer.append(m_socket-readAll()); // 追加本次到达的数据 while (m_buffer.size() FRAME_HEADER_SIZE) { if (m_buffer.left(2) ! FRAME_MAGIC) { // 帧头错乱向后找下一个魔数防止一条坏帧拖垮后续数据 int pos m_buffer.indexOf(FRAME_MAGIC, 2); if (pos 0 pos FRAME_HEADER_SIZE m_buffer.size()) { m_buffer.remove(0, pos); continue; } m_buffer.clear(); return; } // 从4字节长度字段中恢复载荷大小 quint32 len 0; for (int i 2; i FRAME_HEADER_SIZE; i) { len (len 8) | static_castunsigned char(m_buffer.at(i)); } if (m_buffer.size() FRAME_HEADER_SIZE static_castint(len)) { return; // 半包未到齐等待下一个readyRead } QByteArray body m_buffer.mid(FRAME_HEADER_SIZE, len); m_buffer.remove(0, FRAME_HEADER_SIZE len); parseRequest(QJsonDocument::fromJson(body).object()); } }这个循环的关键在于每次处理完一帧马上remove掉缓冲区就不会无限膨胀。一次readAll可能包含几个完整帧和半包交给while循环一个个切半包留在m_buffer里继续拼。如果JSON解析失败最好在parseRequest里加一个try或者QJsonParseError判断不要把异常抛到事件循环里。2.3 用JSON承载菜单与订单数据协议帧只是运输工具帧里的业务内容还要有约定。我在这套系统里用的是精简JSON字段越少越好便于答辩时解释。客户端请求服务端菜单时发送这样一条命令{ cmd: get_menu, table_no: 3 }服务端返回菜单列表code为0表示成功{ cmd: menu_list, code: 0, items: [ { id: 1, name: 宫保鸡丁, price: 28.0, category: 热菜 }, { id: 2, name: 米饭, price: 2.0, category: 主食 } ] }字段类型说明cmdstring指令名服务端按它路由到不同处理函数codeint0成功非0为错误码table_noint桌号用于区分哪个桌位下单itemsarray菜品数组客户端下单时回传id和数量totaldouble订单金额服务端应该以菜品单价重新计算菜品图片不建议用base64直接塞进JSON会显著增大报文网络差时UI会卡住。常见做法是把图片作为静态资源放在服务端可访问的目录下JSON里只存相对路径客户端再用QNetworkAccessManager去拉取。如果只是课程设计也可以先把图片放进qrc资源里客户端本地显示服务端只管菜单名称和价格。这里的参数约定不是拍脑袋定的。cmd设计成字符串扩展新功能时不需要改协议帧格式只加一个case分支比如后续要支持“催菜”“取消订单”新增两个cmd就行。把字段说明写清楚然后客户端和服务端共用同一个头文件定义命令常量和帧格式能避免两端拼错命令名的低级问题。3. 客户端高频交互背后的Qt Widgets实操菜单加载、购物车与下单客户端的界面结构很多人一上来就建一堆QPushButton然后手动摆放结果窗口放大缩小就乱。更稳的做法是用QTabWidget做主框架菜单页和购物车页各自独立再用QHBoxLayout、QVBoxLayout组合固定。主窗口命名为mainscreen这也是源码里出现moc_mainscreen.cpp的原因——mainscreen类里声明了Q_OBJECT宏需要moc编译生成元对象支持。3.1 用QTabWidget搭建主窗口骨架主窗口顶部放一个tab栏第一个Tab是菜单列表第二个Tab是购物车底部用一个QLabel显示当前服务端连接状态和总金额。这样一个界面只需要一次网络连接不需要每个页面都建一个socket。在mainscreen构造函数里要显式处理socket断线重连的逻辑否则服务端重启后客户端还停在已连接状态。我一般会在构造函数末尾加这样一段// MainScreen构造函数尾部 m_socket new QTcpSocket(this); connect(m_socket, QTcpSocket::connected, this, [this]() { m_statusLabel-setText(已连接服务端); }); connect(m_socket, QTcpSocket::disconnected, this, [this]() { m_statusLabel-setText(连接已断开); m_orderPending false; // 清理下单锁定状态 });这里把m_orderPending一并重置是为了避免断线后服务端没收到ack客户端这边状态一直锁住用户没法再次下单。这个标志位在下面下单流程里还会用到先记住它。3.2 菜单表格用QStandardItemModel延迟构建菜单页如果直接在QTableWidget里逐行addItem几百个菜品时界面会明显卡顿。更合适的做法是让QTableView配一个QStandardItemModel服务端返回菜单JSON后一次性填充model再交给view显示。这样数据与视图分离后续做搜索、按分类过滤也只用操作model不动一行表格代码。// MainScreen::loadMenu void MainScreen::loadMenu(QTableView *view, const QJsonArray items) { QStandardItemModel *model new QStandardItemModel(items.size(), 3, this); model-setHeaderData(0, Qt::Horizontal, 菜品名称); model-setHeaderData(1, Qt::Horizontal, 价格); model-setHeaderData(2, Qt::Horizontal, 桌位/数量); for (int i 0; i items.size(); i) { QJsonObject obj items.at(i).toObject(); model-setItem(i, 0, new QStandardItem(obj[name].toString())); model-setItem(i, 1, new QStandardItem( QString::number(obj[price].toDouble(), f, 2))); model-setItem(i, 2, new QStandardItem(QString::number(obj[id].toInt()))); } view-setModel(model); view-horizontalHeader()-setSectionResizeMode(QHeaderView::Stretch); }填充完成后第2列隐藏菜品真实id第1列显示菜品名称点击行时通过model的data方法读取id。这样不会把id直接暴露给用户也方便后面购物车统计时用id做key。如果后续要给菜单页加搜索框只需要把QSortFilterProxyModel套在这个model上按名称列过滤。3.3 购物车累计与下单状态锁购物车功能用两个QHash就够了一个存菜品id到单价一个存菜品id到数量。新增菜品时先看数量哈希里有没有这个id有则数量加1没有则插入。刷新购物车列表时先清空列表再按哈希重建。void MainScreen::addToCart(const QString dishId, double price) { int count m_cartCount.value(dishId, 0); if (count 0) { m_cartPrice[dishId] price; // 第一次加入才记录单价 } m_cartCount[dishId] count 1; m_cartList-clear(); double total 0.0; for (auto it m_cartCount.begin(); it ! m_cartCount.end(); it) { double p m_cartPrice.value(it.key(), 0.0); total p * it.value(); m_cartList-addItem(QString(%1 x%2 ¥%3) .arg(it.key()).arg(it.value()).arg(p * it.value(), 0, f, 2)); } m_totalLabel-setText(QString(合计¥%1).arg(total, 0, f, 2)); }这里把菜品id转成字符串作为key而不是直接用int是因为QHashQString,...在多个界面的信号槽传递时不容易出现隐式转换问题。刷新购物车用clear后再addItem在菜品数量几十个时完全够用如果将来要把购物车做成复杂表格再换成QStandardItemModel按行更新。这个步骤里没有写死任何菜品名所有数据都来自上一节loadMenu填充的菜单model所以客户端改动菜单只需要服务端改数据库客户端不用重新编译。下单发送是客户端最容易被扣分的地方。点击下单按钮后socket写入数据但如果用户手快点了三次服务端就会收到三份重复订单。解决方法是加一个m_orderPending标志位发送时置true收到ack后置false。void MainScreen::sendOrder() { if (m_orderPending) { m_statusLabel-setText(订单发送中请勿重复点击); return; } if (m_socket nullptr || m_socket-state() ! QAbstractSocket::ConnectedState) { m_statusLabel-setText(未连接服务端请先检查网络); return; } QJsonArray items; for (auto it m_cartCount.begin(); it ! m_cartCount.end(); it) { QJsonObject item; item[dish_id] it.key().toInt(); item[count] it.value(); items.append(item); } QJsonObject obj; obj[cmd] create_order; obj[table_no] m_tableNo; obj[items] items; obj[total] m_totalLabel-text().remove(合计¥).toDouble(); m_socket-write(buildFrame(obj)); m_orderPending true; }在下单请求里total这个字段只是给服务端做参考服务端还应该根据menu表里的真实单价重新计算一遍防止客户端篡改金额。这是个值得在答辩时讲出来的安全点。客户端收到服务端返回的order_ack后用QTimer单次触发清空购物车并恢复m_orderPending。如果用户下单后想查看订单状态还可以在第二个Tab下放一个QProgressBar把订单状态映射为进度值待处理0、制作中50、已完成100服务端主动推送status变化时更新进度条。这就是一个很直观的Qt自定义进度条应用比用文字弹窗高级得多。4. 服务端多线程连接与SQLite订单持久化从newConnection到写库服务端要同时服务多张桌子的客户端如果所有连接都在主线程处理一个客户端的数据量稍微大一点其他客户端就会被阻塞。所以在QTcpServer的newConnection信号里逐个处理是不够的必须把每个socket分发到独立线程或者使用线程池。这个项目源码里的recvfile类被moc编译生成moc_recvfile.cpp正说明它是一个QObject子类可以被moveToThread到工作线程。4.1 重写incomingConnection分发连接QTcpServer有一个虚函数incomingConnection会在新连接到达时把socketDescriptor传进来。默认实现是在服务器所在线程创建QTcpSocket但我们需要在子线程里管理收包所以重写它。// server/orderserver.cpp void OrderServer::incomingConnection(qintptr socketDescriptor) { QThread *thread new QThread(this); RecvFile *worker new RecvFile(socketDescriptor); worker-moveToThread(thread); connect(thread, QThread::started, worker, RecvFile::initSocket); connect(worker, RecvFile::finished, thread, QThread::quit); connect(worker, RecvFile::finished, worker, RecvFile::deleteLater); connect(thread, QThread::finished, thread, QThread::deleteLater); thread-start(); }这里的关键是顺序worker先moveToThread再connect线程started信号到worker的initSocket槽这样initSocket是在子线程中执行的。如果直接在OrderServer构造函数里new RecvFile再moveToThread但initSocket在构造时执行socket还是属于主线程对象连接信号会跑到主线程前面做的线程模型就全白费了。每个连接一个线程在食堂这种最多几十个终端的场景完全够用将来要支撑上千并发再从QThread换成线程池接口不用变。4.2 QSQLite连接名与订单表设计服务端存菜单和订单用数据库是必不可少的。SQLite对这种单机服务端最省事不需要安装数据库服务只需要在.pro里加上QT sql。CREATE TABLE IF NOT EXISTS menu ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, price REAL NOT NULL, category TEXT ); CREATE TABLE IF NOT EXISTS orders ( order_id INTEGER PRIMARY KEY AUTOINCREMENT, table_no INTEGER NOT NULL, items TEXT NOT NULL, total REAL NOT NULL, status INTEGER DEFAULT 0, created_at TEXT DEFAULT (datetime(now,localtime)) );orders表的items字段存的是一整段JSON数组文本比如[{dish_id:1,count:2}]。这样就不需要再建一张订单明细表课程设计层面够用查订单时直接解析JSON文本即可。在Qt里操作SQLite有一个非常常见的坑QSqlDatabase::addDatabase默认连接名是同一个如果多个线程各自调用addDatabase而不指定连接名后一个线程会得到“duplicate connection name”错误。每个线程初始化数据库时必须用独立连接名。// server/dbworker.cpp bool DbWorker::initDb(const QString dbPath) { QString connName QString(conn_%1) .arg(reinterpret_castquintptr(QThread::currentThread())); QSqlDatabase db QSqlDatabase::addDatabase(QSQLITE, connName); db.setDatabaseName(dbPath); if (!db.open()) { return false; } QSqlQuery query(db); return query.exec(CREATE TABLE IF NOT EXISTS orders (...);); }连接名里带上当前线程地址保证每个线程拿自己的连接对象。注意请勿在函数里用局部QSqlDatabase把连接释放掉后再让另一个线程用连接必须保存在线程生命周期内否则会出现“database is locked”或者“connection is not open”的随机错误。4.3 请求路由与订单落库服务端收到完整帧后把载荷解析成QJsonObject然后根据cmd字段分发到不同处理逻辑。这段代码看似简单却是整个资源项目的核心拼图。void RecvFile::parseRequest(const QJsonObject req) { QString cmd req.value(cmd).toString(); QJsonObject resp; resp[code] 0; if (cmd get_menu) { resp[cmd] menu_list; resp[items] DbWorker::loadMenu(); // 查询菜单 } else if (cmd create_order) { QJsonArray items req.value(items).toArray(); double serverTotal DbWorker::calcTotal(items); // 服务端重新计算金额 bool ok DbWorker::saveOrder( req.value(table_no).toInt(), QString::fromUtf8(QJsonDocument(items).toJson()), serverTotal); resp[cmd] order_ack; resp[code] ok ? 0 : 1; if (ok) { resp[order_id] DbWorker::lastInsertId(); } } else { resp[code] 2; resp[msg] unsupported cmd; } m_socket-write(buildFrame(resp)); // 回包给客户端 }saveOrder内部应该用事务包裹插入和更新操作避免写入一半时服务端崩溃造成脏数据bool DbWorker::saveOrder(int tableNo, const QString itemsJson, double total) { QSqlDatabase db QSqlDatabase::database(currentConnName()); QSqlQuery query(db); db.transaction(); query.prepare(INSERT INTO orders (table_no, items, total) VALUES (?, ?, ?)); query.addBindValue(tableNo); query.addBindValue(itemsJson); query.addBindValue(total); bool ok query.exec(); if (ok) { db.commit(); } else { db.rollback(); } return ok; }prepare配addBindValue的写法比直接拼SQL字符串安全菜品名里带单引号、中文引号都不会破坏语句结构。服务端calcTotal必须在service层重新根据menu表单价计算而不是信任客户端传过来的total这一点在答辩时最好主动提出来能明显拉开与其他学生的差距。订单状态更新的场景服务端还可以在status改变时主动向客户端推送一条JSON帧比如{cmd:order_status,order_id:12,status:1}。客户端收到后更新对应Tab里的进度条和文字。这样点餐系统就不仅仅是“下单后不管”而是有了实时跟踪能力。5. 从.pro.user到qrc_images.cpp这套Qt项目构建与部署的三个关键细节源码包里有几个很容易被人忽视的文件QTProject.pro.user、moc_recvfile.cpp、qrc_images.cpp。它们不是手写代码但理解它们的生成逻辑能少踩一堆Qt开发的环境坑。5.1 .pro.user是本机配置不是项目配置.pro.user文件由Qt Creator打开.pro时自动生成里面记录了当前机器上选择的编译器、Qt版本路径、构建目录。换一台电脑打开同一个项目如果不删除这个文件很可能提示找不到qmake或MSVC套件。真正的项目配置在.pro文件里.pro.user只是本机私有的“钥匙串”。提交源码前把.pro.user加入.gitignore。5.2 moc和qrc_images.cpp从哪里来moc_recvfile.cpp、moc_mainscreen.cpp是元对象编译器moc根据头文件里的Q_OBJECT宏自动生成的。如果编译报错找不到moc文件先检查对应头文件是否真的写了Q_OBJECT以及该头文件是否已加入HEADERS变量。qrc_images.cpp则由qrc资源文件编译而来qrc里引用图片时不要写绝对路径用相对于qrc文件的相对路径例如fileimages/logo.png/file代码里访问时写:/images/logo.png。5.3 发布运行时依赖客户端发布时单纯拷贝exe是跑不起来的Windows下最常见的报错是“could not find platform plugin windows”或者提示QT_QPA_PLATFORM_PLUGIN_PATH配置错误。需要在Qt命令行环境里执行windeployqt release/OrderClient.exe这个工具会把plugins、qwindows.dll、平台裁剪相关的dll自动拷到exe旁边。如果还是报platform plugin路径问题把Qt安装目录下plugins/platforms整个目录复制到发布目录再设置环境变量QT_QPA_PLATFORM_PLUGIN_PATH指向该目录。上面几个细节都属于“平时不起眼、答辩时机器一换就翻车”的点。我的习惯是清掉build目录和.pro.user后重新构建一遍能通过才算交付。qrc里的图片资源也建议按模块分目录UI图、菜品占位图、窗口图标分开管理这样客户端发布时资源裁剪和国际化替换都更容易定位。本文还有配套的精品资源点击获取