☰
Linux+Qt+C++点餐系统实战:架构设计、信号槽与SQLite事务全解析
2026/10/20 10:04:13 网站建设 项目流程

简介:这是一份基于 Linux、QT、C++ 的点餐系统源码与数据库资源包,面向 Qt 开发者、C++ 学习者及餐饮管理系统设计者,可用于课程设计或小型项目二次开发。系统覆盖用户管理、菜单管理、订单处理、支付集成、库存管理与报表统计等典型模块,体现从 UI 到数据库的完整流程。压缩包共 8 个文件,大小仅 7KB,包含 7 个 CSV 数据文件与 1 个 SQL 脚本。CSV 对应账单、用户、菜单、饮品、账单详情、厨房、餐桌等业务数据,SQL 脚本负责建表及导入初始记录,便于快速初始化系统后台。该资源已有 446 人浏览学习。通过对照 CSV 与 SQL,可理清菜品分类、订单状态及用户权限等表结构关系;配合 Qt/C++ 客户端,还能验证数据库增删改查与界面交互,是练习数据建模和前后端联调的好素材。

1. 基于Linux、QT、C++的点餐系统:它到底在解决什么问题

基于Linux、QT、C++的点餐系统,说穿了就是在菜单、订单、支付、后厨之间维持一组状态:顾客加菜、改数量、下单、结账,任何一步状态错位,前台和后厨就对不上账。用这个技术组合做点餐系统,不是炫技,而是把界面渲染、业务逻辑和本地存储全部握在自己手里,不依赖浏览器和远程数据库,一台低配工控机就能跑出收银机的稳定性。这篇笔记适合三类人:打算做课程设计但想交付可运行产物的开发者、想扎实练好信号槽和数据持久化的C++新手、以及想自建点餐方案不受年费约束的餐饮门店。我会按从业者的习惯,从选型理由一路讲到架构、核心代码和多次实测才会遇到的翻车点。

2. 从零定架构:数据表、业务层与界面层先想清楚再写码

很多初学者拿到这个项目就急着拖按钮画界面,结果数据一多、窗口一多,代码就缠在一起。我自己的习惯是,第一步永远先确定存储模型和模块边界,界面放到最后。因为点餐系统的复杂度不在“长什么样”,而在“数据怎么流、状态怎么变、崩了之后怎么恢复”,这几件事想清楚了,界面只是把结果展示出来。

2.1 为什么是 Linux + Qt + C++:三个选型理由

先解决一个基本问题:市面上点餐方案很多,Web后台配平板、Electron打包桌面端、Java微信小程序,都是常见路线,为什么偏偏选这个组合?

第一个理由是部署环境。点餐系统的目标机通常是门店里的工控机、旧PC或者一体机,配置不高,而且要求开机自启、长期不关机。Linux无桌面环境时内存占用可以压到很低,程序开机自启只需要一个systemd单元,不会像某些带浏览器内核的方案那样出现“跑两天就卡死、需要重启”的玄学问题。

第二个理由是Qt的信号槽机制天然贴合点餐交互。界面上的每一次点击,本质都是“某件事发生了,请通知其他模块处理”。顾客把菜加入购物车,不需要购物车窗口持有菜单窗口的指针,只需要一个信号发出去,谁关心谁去接。两个人同时开发时,一个改界面,一个改订单逻辑,互不干扰。这个解耦价值在中期维护时体现最明显。

第三个理由是C++在这个场景里几乎没有额外负担。长时间运行的桌面服务最怕运行时漂移,C++没有GC停顿,也不会因为解释器版本不一致导致行为变化;配合SQLite,整个系统就是单进程模型,没有网络延迟,没有中间件。相比Electron动辄两三百MB的内存占用,C++版点餐系统在同样配置的机器上,内存可以控制在几十MB级别,代价是开发时需要自己管理好生命周期。

这里要特别说明为什么不用远程数据库。门店后厨、包间经常没有稳定信号,点餐高峰期网络一旦抖动,基于云端的方案要么排队等待,要么直接报错。点餐系统的核心诉求是“断网也要能点单”,数据最终落到本地才可靠,这也正是这个技术组合的立身之本。

2.2 三层架构与模块边界:界面层别碰 SQL

我一般会把代码分成三层,这个分层不是概念摆设,而是靠强制规则保证的。

界面层只负责显示与转发点击事件,里面不写SQL、不计算总价。业务层包含购物车、收银、订单状态机,接收界面信号之后调用数据层接口。数据层封装数据库访问,给业务层提供打开连接、执行查询、提交事务的入口。

边界规则只有两条,但必须严格执行:界面层不能直接new QSqlQuery,业务层不能include QMessageBox。第一条规则保证数据访问方式变更时只改一个文件;第二条规则保证业务代码可以被自动化测试直接调用,不受界面阻塞影响。实际开发中,我把每个业务模块设计成独立QObject,界面通过信号槽调用它们,模块之间没有任何指针耦合。

这个分层在写订单时会明显受益。订单涉及订单头和订单明细两张表,需要事务包裹,如果直接在界面按钮的槽函数里拼SQL,事务逻辑会跟界面刷新混在一起,一旦弹窗或界面刷新卡住,事务就可能被异常截断。分层的做法是把整个下单动作封装成一个业务方法,界面只管调用,成功与失败通过返回值判断。

2.3 先设计好数据表:菜单、订单、订单明细的建表语句

数据层的第一件事是建表。菜单是静态数据,订单是核心数据,订单明细必须冗余菜品名称和单价快照,防止菜单改价改名之后历史订单失真。价格字段我统一用整数存“分”,不用浮点,这是做收银系统积累下来的血泪经验:double算钱会在边界处出现0.1+0.2不等于0.3的误差,虽然只有几分钱,但月底对账时极其痛苦。

-- dish 菜品表:菜单的静态数据 CREATE TABLE IF NOT EXISTS dish ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, category TEXT NOT NULL, price INTEGER NOT NULL DEFAULT 0, status INTEGER NOT NULL DEFAULT 1 ); CREATE INDEX idx_dish_category ON dish(category); -- orders 订单表:一桌一次下单的订单头 CREATE TABLE IF NOT EXISTS orders ( id INTEGER PRIMARY KEY AUTOINCREMENT, table_no TEXT NOT NULL, total_amount INTEGER NOT NULL DEFAULT 0, status INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')), paid_at TEXT ); -- order_item 订单明细表:每道菜的下单数量与下单时价格快照 CREATE TABLE IF NOT EXISTS order_item ( id INTEGER PRIMARY KEY AUTOINCREMENT, order_id INTEGER NOT NULL, dish_id INTEGER NOT NULL, dish_name TEXT NOT NULL, quantity INTEGER NOT NULL DEFAULT 1, unit_price INTEGER NOT NULL, FOREIGN KEY (order_id) REFERENCES orders(id) ); CREATE INDEX idx_order_item_order ON order_item(order_id);

这套表结构的几个设计点值得说明。dish表不直接存图片路径到数据库,而是把图片文件名放在约定目录里,数据库保持轻量,备份也方便;category字段加索引,因为菜单界面需要按分类筛选。orders表的status是订单状态机的核心字段,值的含义要在业务层统一定义,不要把业务含义散落在界面判断里。order_item表把dish_name和unit_price做了冗余,一旦菜品改了名或调了价,历史订单依然能还原当时顾客看到的菜名和单价,这点在做对账单时价值极大。

需要提醒的是,SQLite默认外键约束是关闭的,需要在打开连接后执行一次PRAGMA foreign_keys = ON,否则外键只存语义不起作用。

3. 把菜单和订单跑起来:核心代码与关键参数落地

表结构设计好之后,下一步是让数据真正流动起来。这一章给出一套可以直接复用的代码骨架:一个全局唯一的数据库管理器、一个菜单列表显示方案、一个购物车与下单的事务流程。这些代码我都按实际项目的简化版写,去掉了具体业务编号规则,保留最核心的运行逻辑。

3.1 最小启动:DatabaseManager 单例与菜单加载

数据库管理器设计成单例,整个进程共享一个连接。SQLite同一时间只允许一个写事务,单连接配合串行化访问反而比多连接更可控。

// dbmanager.h #pragma once #include <QObject> #include <QSqlDatabase> class DatabaseManager : public QObject { Q_OBJECT public: static DatabaseManager *instance(); bool open(const QString &dbPath); QSqlDatabase db() const; private: explicit DatabaseManager(QObject *parent = nullptr); QSqlDatabase m_db; };
// dbmanager.cpp #include "dbmanager.h" #include <QSqlQuery> DatabaseManager *DatabaseManager::instance() { static DatabaseManager mgr; return &mgr; } bool DatabaseManager::open(const QString &dbPath) { m_db = QSqlDatabase::addDatabase("QSQLITE", "order_conn"); m_db.setDatabaseName(dbPath); if (!m_db.open()) return false; QSqlQuery q(m_db); q.exec("PRAGMA foreign_keys = ON"); q.exec("PRAGMA busy_timeout = 3000"); q.exec("PRAGMA journal_mode = WAL"); return true; } QSqlDatabase DatabaseManager::db() const { return m_db; }

这里有两个参数值得展开。busy_timeout设置为3000毫秒,意思是当另一个连接正在写库时,当前写操作最多等3秒,超过才报错,这在高峰期两个窗口同时下单时能避免直接弹“database is locked”。journal_mode设为WAL,显著提升并发读性能,菜单浏览和订单写入可以同时进行。连接名固定为“order_conn”,后续所有QSqlQuery都通过DatabaseManager::db()拿到这个连接,避免Qt多个无名连接之间的混乱。

菜单加载使用QSqlTableModel,它把数据库表直接映射为可显示的数据模型,省去手写Model。

QSqlTableModel *model = new QSqlTableModel(this, DatabaseManager::instance()->db()); model->setTable("dish"); model->setFilter("status = 1"); model->setSort(0, Qt::AscendingOrder); model->select(); m_tableView->setModel(model); m_tableView->setColumnHidden(0, false); m_tableView->setColumnHidden(4, true); model->setHeaderData(1, Qt::Horizontal, "菜名"); model->setHeaderData(2, Qt::Horizontal, "分类"); model->setHeaderData(3, Qt::Horizontal, "单价(分)");

setFilter接收的是SQL的WHERE子句内容,注意这里接收的是用户可见的筛选条件,如果之后要做搜索框,不能用字符串拼接用户输入,要用绑定值。setColumnHidden控制哪一列不显示,spacer列索引对应表结构字段顺序。价格以“分”为单位显示时需要在界面上转换,简单做法是通过自定义Delegate把分转成元,界面展示和业务计算分开处理。

3.2 加购、改数量与下单:信号槽串起各个模块

购物车是界面与业务层之间的桥梁。我定义一个CartManager继承QObject,它内部维护菜品ID到数量的映射,任何变化都发出cartChanged信号,主界面据此刷新购物车列表和总价。这样菜单窗口、购物车窗口、结算窗口互不持有对方指针。

struct OrderItem { int dishId; QString name; int unitPrice; int quantity = 1; }; Q_DECLARE_METATYPE(OrderItem)
class CartManager : public QObject { Q_OBJECT public: void addItem(const OrderItem &item) { m_items.append(item); emit cartChanged(m_items.size()); } void clear() { m_items.clear(); emit cartChanged(0); } const QList<OrderItem> &items() const { return m_items; } signals: void cartChanged(int totalCount); private: QList<OrderItem> m_items; };

在菜单窗口的“加入购物车”按钮槽里,只需要做一件事:从当前选中行取出菜品数据,构造OrderItem,交给CartManager。

void MenuWindow::onAddToCartClicked() { const QModelIndex idx = m_tableView->currentIndex(); if (!idx.isValid()) return; const int dishId = idx.sibling(idx.row(), 0).data().toInt(); const QString name = idx.sibling(idx.row(), 1).data().toString(); const int price = idx.sibling(idx.row(), 3).data().toInt(); OrderItem item; item.dishId = dishId; item.name = name; item.unitPrice = price; m_cartManager->addItem(item); }

信号槽在这种场景的价值在于“发出者不需要知道接收者是谁”。菜单窗口发出的请求被CartManager接住,CartManager再发出cartChanged,购物车窗口接收后刷新。三个窗口各自独立,新增一个“推荐菜”窗口时,只需要让它也调用addItem,零改动接入现有流程。

3.3 订单落库:事务、预处理与主键回取

下单是整个系统中数据一致性要求最高的操作,必须同时写订单头和订单明细。任一条明细失败,订单头都不应该存在,否则会出现“总金额100元但没有明细”的脏数据。

bool CashierManager::commitOrder(const QString &tableNo, const QList<OrderItem> &items, int totalAmount) { QSqlDatabase db = DatabaseManager::instance()->db(); if (!db.transaction()) return false; QSqlQuery head(db); head.prepare("INSERT INTO orders(table_no, total_amount, status)" " VALUES (?, ?, 1)"); head.addBindValue(tableNo); head.addBindValue(totalAmount); if (!head.exec()) { db.rollback(); return false; } const int orderId = head.lastInsertId().toInt(); QSqlQuery detail(db); detail.prepare("INSERT INTO order_item" "(order_id, dish_id, dish_name, quantity, unit_price)" " VALUES (?, ?, ?, ?, ?)"); for (const OrderItem &it : items) { detail.addBindValue(orderId); detail.addBindValue(it.dishId); detail.addBindValue(it.name); detail.addBindValue(it.quantity); detail.addBindValue(it.unitPrice); if (!detail.exec()) { db.rollback(); return false; } } return db.commit(); }

这段代码有三个关键点。第一是transaction必须紧接在连接上使用,不要在创建QSqlQuery之后再开启事务。第二是prepare配合addBindValue做参数绑定,避免手拼字符串带来的SQL注入和转义问题,菜品名称里有单引号时也不会破坏SQL语句。第三是lastInsertId在SQLite中返回的是自增主键值,必须在订单头插入成功后立刻读取,之后插入明细时用它关联两条表。

事务边界要尽可能短。不要在事务里做耗时操作,比如网络请求或文件写入。我见过一个真实案例:开发者把“下单后打印小票”也放进事务里,打印机缓冲导致事务挂起几秒,期间另一个窗口下单直接超时。正确的做法是事务只包裹数据库写操作,小票打印放在事务提交之后去处理。

4. 点餐系统常见问题与避坑:五处容易翻车的位置

这个项目在开发环境跑通很容易,真正考验人的是换机器、换分辨率、长时间运行、并发操作这些真实场景。以下五处都是我在不同项目里实际踩过的坑,按“现象到原因再到解决”的方式说明。

4.1 中文乱码:菜名显示成问号或方块

现象:开发机上一切正常,代码拷到另一台Linux机器重新编译后,界面里的菜名全部变成“?”或方块。

原因:源码文件是UTF-8编码,但老项目编译器默认按本地字符集处理字符串字面量;或者Qt4老项目升级到Qt5时遗留了toLocal8Bit的代码。另一个常见原因是SQLite连接没有显式设置编码,写入的数据本身已经乱掉,界面怎么修都没用。

解决:统一源码文件保存为UTF-8,在.pro文件里加QMAKE_CXXFLAGS += -finput-charset=UTF-8 -fexec-charset=UTF-8对编译器做双保险;打开数据库后执行PRAGMA encoding = 'UTF-8';对外部传入的字节串统一用QString::fromUtf8转换,不要用QString(str)直接构造。判断乱码根源有个习惯:先打开SQLite文件看存储的数据是否正常,库里的数据正常就修界面层,库里就不正常就修写入层,避免盲目改动。

4.2 高分屏下界面发虚:字体和布局整体错位

现象:程序在2K或4K显示器上运行时,字体发虚,控件挤在一起,按钮文字显示不全。

原因:Qt5默认不启用高分屏缩放,界面按逻辑坐标绘制,物理像素分辨率上去后清晰度立刻崩掉。Linux桌面环境各家缩放策略不一致,Gnome和KDE的处理方式不同,代码里如果不处理只能听天由命。

解决:在main函数创建QApplication之前设置环境变量。

#include <QApplication> int main(int argc, char *argv[]) { qputenv("QT_ENABLE_HIGHDPI_SCALING", "1"); qputenv("QT_SCALE_FACTOR", "1"); QApplication app(argc, argv); // 后续初始化代码 return app.exec(); }

Qt5.15之后两个属性默认开启,Qt6已移除这些属性,所以这段代码只对Qt5项目有意义。移植到Qt6时直接删除即可。高分屏还有一个连带问题:不同显示器的缩放比例不一致,程序无法确定当前屏幕的物理DPI,布局用像素写死会在缩放下错位。我的习惯是布局全部用Layout和SizePolicy,固定像素值只出现在边框和间距里。

4.3 SQLite 报 database is locked:两个窗口同时下单必现

现象:两个收银窗口同时提交订单,一个成功,另一个弹“database is locked”,重试又能成功。

原因:SQLite同一时刻只允许一个写事务。多个QSqlQuery在不同线程操作同一个连接,或者两个连接指向同一文件时,第二个写事务等待超过busy_timeout就会直接失败。根本原因是写操作没有串行化。

解决:最稳妥的做法是全进程共享一个写连接,所有写操作在业务层用QMutex包裹,保证同一时刻只有一个线程能进入事务。

QMutex g_orderMutex; bool CashierManager::commitOrder(...) { QMutexLocker locker(&g_orderMutex); // 原事务代码 }

另一个解法是让每个线程持有自己的数据库连接,线程间通过信号槽传递结果,这种方案在Qt里推荐与QueuedConnection配合使用。注意WAL模式只能缓解读写并发,不能解决写写冲突,所以锁不能省。如果程序里有些QSqlQuery是局部变量且没有显式传连接,Qt会默认创建一个无名连接,这会让并发问题更难排查,建议所有查询都显式传入连接。

4.4 自定义结构体走信号槽失效:接收方收不到消息

现象:连接和信号发射代码都写了,发射端没有报错,但接收方的槽函数就是不执行。

原因:自定义结构体OrderItem没有注册到Qt元类型系统,跨线程的队列连接在传递参数时需要元类型信息来拷贝对象,注册缺失时这条连接会被静默丢弃,不报任何错误。这是最迷惑人的一种“假连接”,排查半天发现代码没问题,只是类型没注册。

解决:在结构体定义后加Q_DECLARE_METATYPE宏,并在main函数中注册。

Q_DECLARE_METATYPE(OrderItem); int main(int argc, char *argv[]) { qRegisterMetaType<OrderItem>("OrderItem"); // ... }

注册之后,跨线程信号槽传递自定义类型才有效。同线程直接连接不注册也能跑,但代码一旦重构、窗口改到子线程,问题就冒出来,所以项目一开始就注册最省事。排查“信号没反应”时,我习惯先检查连接方式,再用QMetaType::type("OrderItem")打印验证,返回值非零才说明注册成功。

4.5 systemd 自启后找不到数据库:工作目录悄悄变了

现象:手动在终端里执行程序一切正常,配置开机自启后,启动失败或提示找不到数据库文件、菜品图片加载不出来。

原因:systemd服务默认的工作目录是根目录“/”,程序里用相对路径“data/order.db”在手动运行时指向当前终端目录,在自启场景下指向根目录,自然找不到文件。日志里往往只有SQLite的“unable to open database file”,没有更多提示。

解决:在.service文件里显式指定工作目录。

[Unit] Description=Ordering System After=network.target [Service] Type=simple WorkingDirectory=/opt/ordering ExecStart=/opt/ordering/ordering_app Restart=on-failure [Install] WantedBy=multi-user.target

更彻底的方案是在代码里不依赖工作目录,统一用QCoreApplication::applicationDirPath()拼绝对路径。

const QString dbPath = QCoreApplication::applicationDirPath() + "/data/order.db";

两种做法建议同时上:部署脚本里写死WorkingDirectory,代码里也做绝对路径拼接,双保险。手动跑正常、自启失败的问题,优先怀疑工作目录和用户权限,这两个因素占九成以上。

5. 断网也能点单:离线兜底与上线前的验证习惯

点单断网是常态,不是例外。这个认知决定了很多设计取舍:数据必须本地优先,外部服务只能锦上添花。我接手过的点餐系统里,凡是依赖云端接口做核心流程的,最终都在某个忙碌夜晚掉链子。

离线兜底的核心是把几个设计决策做进架构里。菜单数据只从本地SQLite读取,菜品图片放本地目录,程序完全不依赖外网启动。订单提交后状态先标记为“已下单”,支付方式如果是现金就本地记录,不做在线支付轮询。如果后面接扫码支付或云端报表,数据层留一个同步状态字段,网络恢复后由后台任务把本地数据推上去,推送失败也不影响本地营业。

换机迁移是另一个容易被忽视的场景。门店换一台工控机,数据库文件拷过去就行,但菜单数据往往需要手动重建。我在数据层加了一个菜单导出导入的JSON接口,每月备份一次。

QJsonDocument doc; QJsonArray arr; while (query.next()) { QJsonObject obj; obj["name"] = query.value("name").toString(); obj["category"] = query.value("category").toString(); obj["price"] = query.value("price").toInt(); arr.append(obj); } QFile file(path); file.write(doc.toJson(QJsonDocument::Indented));

这个接口有两个实际用途:换机时一键恢复菜单;门店做季度促销时备份原始菜单,改动后随时回退,后悔药随时有。

上线前的验证清单比功能本身更重要。我的固定习惯是列一张表逐项过,每一项都不含糊。

| 检查项 | 做法 | 预期结果 | | 开机自启 | 重启工控机,等待30秒 | 程序自动全屏启动,菜单可浏览 | | 断电恢复 | 下单事务执行中强制断电,重启 | 数据一致,无半截订单 | | 并发下单 | 同一时间两个终端同时提交订单 | 不出现 database is locked | | 离线点单 | 拔掉网线后完整走一遍点餐流程 | 全部功能正常,订单落在本地 | | 换机迁移 | 导出JSON菜单到新机器恢复 | 菜单完整,分类和价格一致 |

断电恢复这一项最容易出问题,我在一次模拟测试中发现事务还没提交就断电,重启后订单确实不存在,但SQLite的WAL文件可能残留,需要验证程序启动时能正常恢复。做法是每次启动后用PRAGMA integrity_check快速校验数据库完整性,检查结果异常时自动将当前库备份为bak文件,再重建空库。

回看这套系统的整体价值,不在于用了多新的技术,而在于它把门店点餐这个每天都在发生的场景,做得足够稳。数据在本地、逻辑在自己手里、坏境能自己控制,这比追逐任何框架都重要。最后分享一个我的习惯:每交付一套点餐系统,都会在新机器上连续运行三天,第四天特意只做一件事——把当天订单全部导出来与手写开台单逐行对账。对不上就回去查代码,对上了才敢交钥匙。这个习惯来自一次对账失败后的深刻教训,从那以后,我觉得多花这三天非常值得,也希望帮到你。

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

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

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

立即咨询