首页 / 资讯中心 / 文章详情

Linux+Qt+C++点餐系统实战:架构设计、信号槽与SQLite事务全解析

Linux+Qt+C++点餐系统实战:架构设计、信号槽与SQLite事务全解析 ★ FEATURED ARTICLE
简介这是一份基于 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.10.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 QListOrderItem items() const { return m_items; } signals: void cartChanged(int totalCount); private: QListOrderItem 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 QListOrderItem 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-charsetUTF-8 -fexec-charsetUTF-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[]) { qRegisterMetaTypeOrderItem(OrderItem); // ... }注册之后跨线程信号槽传递自定义类型才有效。同线程直接连接不注册也能跑但代码一旦重构、窗口改到子线程问题就冒出来所以项目一开始就注册最省事。排查“信号没反应”时我习惯先检查连接方式再用QMetaType::type(OrderItem)打印验证返回值非零才说明注册成功。4.5 systemd 自启后找不到数据库工作目录悄悄变了现象手动在终端里执行程序一切正常配置开机自启后启动失败或提示找不到数据库文件、菜品图片加载不出来。原因systemd服务默认的工作目录是根目录“/”程序里用相对路径“data/order.db”在手动运行时指向当前终端目录在自启场景下指向根目录自然找不到文件。日志里往往只有SQLite的“unable to open database file”没有更多提示。解决在.service文件里显式指定工作目录。[Unit] DescriptionOrdering System Afternetwork.target [Service] Typesimple WorkingDirectory/opt/ordering ExecStart/opt/ordering/ordering_app Restarton-failure [Install] WantedBymulti-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文件再重建空库。回看这套系统的整体价值不在于用了多新的技术而在于它把门店点餐这个每天都在发生的场景做得足够稳。数据在本地、逻辑在自己手里、坏境能自己控制这比追逐任何框架都重要。最后分享一个我的习惯每交付一套点餐系统都会在新机器上连续运行三天第四天特意只做一件事——把当天订单全部导出来与手写开台单逐行对账。对不上就回去查代码对上了才敢交钥匙。这个习惯来自一次对账失败后的深刻教训从那以后我觉得多花这三天非常值得也希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站