news 2026/9/13 13:21:00

C++外卖管理系统实战:对象模型、持久化与状态机设计

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C++外卖管理系统实战:对象模型、持久化与状态机设计

简介:基于C++实现的外卖点餐管理系统源码包,面向C/C++课程设计或实训项目学习者,覆盖顾客端与管理员端核心业务流程。整套程序包含菜品信息维护、多条件查询排序、顾客下单/改单/取消、管理员出单、订单确认收货与评价等模块,适合作为综合项目参考或二次开发基础。压缩包共44个文件,以cpp源文件、h头文件、exe可执行程序为主,另含txt说明文档、Dev-C++工程文件及界面图标等,整体6.37MB,源码与编译输出均已整理。目前已有281人学习下载,适用于需要快速理解外卖系统逻辑并落地运行的开发者。资源内提供完整工程结构与可直接运行的exe程序,可对照源码查看下单状态流转、菜品库存判断和价格汇总等关键实现,README与项目配置文件有助快速导入开发环境,节省从零搭建时间。

1. 先聊清楚:C++ 外卖管理系统到底解决什么问题

"基于c++的外卖管理系统"这类题目,常年挂在 c++源码、c++学习 的检索前排。它没有前端和数据库,目标只是用控制台跑通外卖的最小闭环:管理员维护菜品,顾客浏览下单,订单按"待接单→已接单→配送中→已送达"推进。对刚学完语法的读者,它是把类、STL、文件流串起来的一份完整练习;对写过几年代码的人,它也值得回看——状态机建模、持久化选型、库存扣减的边界,正是 c++面试 里常见追问的落点。

这套系统的分量不在业务本身,而在三类通用问题:多实体关系怎么组织、程序退出后数据怎么不丢、一次操作里多处变更怎么保持一致。下文按我处理这类项目的路线,用 C++17 从对象模型写到文件存储,再到业务流与防御写法,零第三方依赖。

2. 外卖管理系统的对象模型:把类拆对,后面的功能才顺

2.1 类的划分:菜品、订单、用户各管一摊

写这类管理系统最容易犯的错,是把所有字段堆进一个"万能"结构:菜名、数量、电话全塞在同一个数组里,后面加需求就到处改判断。我一般先按领域角色拆实体:User 存顾客信息,Dish 表示菜品,Order 表示一张订单;商家在最小系统里通常只有一个,但模型上保留 merchantId 不写死,将来扩展多商家只动数据,不动结构。

菜品、用户、订单三个实体可以这样定义:

// model.h —— 实体定义统一放这里,方便其他模块 include #pragma once #include <string> #include <vector> #include <utility> #include <ctime> struct Dish { int id = 0; std::string name; double price = 0.0; int stock = 0; // 可售库存,下单时扣减 int category = 0; // 0=热菜 1=凉菜 2=主食 3=饮品 }; struct User { int id = 0; std::string name; std::string phone; }; struct Order { int id = 0; int customerId = 0; int merchantId = 0; std::vector<std::pair<int, int>> items; // {菜品id, 数量} double totalPrice = 0.0; int state = 0; // 对应 OrderState,见 2.2 std::time_t createTime = 0; std::time_t updateTime = 0; };

有几个设计点值得展开。items 用 vector<pair<int,int>> 而不是单独建 OrderItem 类,是因为最简单的外卖系统里一张订单只需要"哪个菜、几份"两个字段,pair 足够表达;将来要记录下单时的价格快照、折扣、备注,再把元素类型换成 OrderItem 结构体,业务调用方基本不用改。全部用 struct 而不是 class,是因为这些对象只是数据的载体,没有需要隐藏的内部状态,成员默认 public 反而省去一堆 getter 样板。

价格用 double 是大多数课设的默认做法,但浮点有误差:三个 6.66 元的菜累加可能得到 19.979999,直接显示给用户很难看。我自己的处理口径是:展示层用 double 方便输出,内部金额计算一律按"分"转成 long long,最后一步再转回元。这个口径在结算模块里统一收口,散落在各处算钱的地方就不会对不上。

2.2 订单状态流转:用枚举而不是魔法数字

订单状态是全系统最核心的状态机。如果放任 0、1、2、3 散写在各个模块里,后面加"退款中""已完成评价"这类状态时,改起来就是在全场做替换。先用 enum class 把状态定义出来,同时把"合法流转关系"收敛到一张静态表里:

// order_state.h #pragma once #include <map> #include <vector> #include <algorithm> enum class OrderState : int { PENDING = 0, // 待接单:顾客已下单,等商家确认 ACCEPTED = 1, // 已接单:商家开始备餐 DELIVERING = 2, // 配送中:骑手取餐后在路上 COMPLETED = 3, // 已送达:终态 CANCELED = 4 // 已取消:终态 }; bool canTransit(OrderState from, OrderState to) { static const std::map<OrderState, std::vector<OrderState>> table = { { OrderState::PENDING, { OrderState::ACCEPTED, OrderState::CANCELED } }, { OrderState::ACCEPTED, { OrderState::DELIVERING, OrderState::CANCELED } }, { OrderState::DELIVERING, { OrderState::COMPLETED } }, { OrderState::COMPLETED, {} }, { OrderState::CANCELED, {} } }; auto it = table.find(from); if (it == table.end()) return false; return std::find(it->second.begin(), it->second.end(), to) != it->second.end(); }

canTransit 的作用是让"非法流转在入口处被拦住":已完成或已取消的订单不可能再回到配送中,待接单的订单也不可能直接变成已送达。用 std::map 而不是 switch-case,是因为状态多了以后,表驱动比堆判断分支更好审查——新增一个状态只改表,调用点零改动。调用方拿到的只是一个 bool,真正的改状态动作仍然留给业务层,之后要加"流转时写日志、发通知",只动业务函数一处。

注意:enum class 和普通 enum 最大的区别是不能隐式转 int。凡是把 state 写文件、从文件读回来,都要显式 static_cast<int>() 和 static_cast<OrderState>(),这两个方向各做一次,漏了编译期就会报错,不会留到运行期。

2.3 内存数据怎么放:vector 与 map 的取舍

实体定义好了,剩下的是容纳它们的容器。这个系统的数据量撑死几百条订单,不需要数据库,选型只看三点:按 id 随机查找是否方便、遍历输出是否有序、数据量变大后替换成本高不高。下表是我的默认选择。

容器按 id 查找遍历顺序本系统落点
std::vector<T>线性扫描 O(n)保插入序订单列表、历史记录
std::map<int, T>O(log₂n)按键有序菜品表、用户表
std::unordered_map<int, T>O(1)无序数据量过万再考虑

订单放 vector 因为下单永远是尾插,列表输出希望按时间序;菜品放 map 因为点餐要按 id 精准定位并扣库存,O(log n) 在这个量级下可以忽略。真正要注意的是把容器封装在 Manager 类后面,对外只暴露 Add、FindById、Remove 接口,而不是让业务代码直接操作容器。某天想把 map 换成 unordered_map,只改封装内部,业务调用方一行不动。

3. 用 fstream 实现外卖菜品与订单持久化:文本格式与写入顺序

3.1 文本文件还是二进制:简单系统不要碰二进制

持久化要解决的就一件事:程序重启后,菜品和订单还在。但格式选错,坑都在后面。最典型的错误是把 Order 整个结构体 write 进二进制文件——结构体里有 std::string,而 string 内部存的是指向堆内存的指针,落盘的是地址而不是字符,下次启动读回来,指针指向的地址早就失效,不崩是不可能的。

文本格式没有这个问题。每个字段用分隔符逐行写,加载时逐行解析,坏一行跳过一行。下表是两种方式的对比。

维度文本格式(CSV/自定义分隔)二进制(fwrite/fread 结构体)
可读性编辑器直接打开看乱码,只能靠工具
string 字段天然支持必须手工序列化
加字段旧文件赋默认值偏移量错位,全废
性能低,但几百条无感知
排障成本低,砍掉坏行即可需要知道结构体精确布局

结论很直接:这个量级用文本。担心用户手改文件?那是另一个层面的问题,后面用校验字段或迁到 SQLite 解决,现在不引入额外依赖。

3.2 菜品表 CSV 读写:一个能直接抄的起点

菜品的字段全是标量,CSV 是最合适的载体。下面的代码把 vector<Dish> 全量写到 dishes.csv,再从相同格式读回:

// storage.cpp —— 菜品 CSV 读写,文件统一 UTF-8 编码 #include <fstream> #include <sstream> #include <algorithm> #include <iostream> #include "model.h" bool saveDishes(const std::vector<Dish>& dishes, const std::string& path) { std::ofstream out(path, std::ios::trunc); // 全量重写 if (!out.is_open()) { std::cerr << "[saveDishes] 无法打开 " << path << "\n"; return false; } for (const Dish& d : dishes) { out << d.id << ',' << d.name << ',' << d.price << ',' << d.stock << ',' << d.category << '\n'; } return out.good(); } bool loadDishes(std::vector<Dish>& dishes, const std::string& path) { std::ifstream in(path); if (!in.is_open()) return false; // 文件不存在按空库处理 dishes.clear(); std::string line; while (std::getline(in, line)) { if (line.empty()) continue; std::replace(line.begin(), line.end(), ',', ' '); std::stringstream ss(line); Dish d; if (!(ss >> d.id >> d.name >> d.price >> d.stock >> d.category)) { std::cerr << "[loadDishes] 坏行跳过: " << line << "\n"; continue; } dishes.push_back(std::move(d)); } return true; }

这段代码里有三个参数和习惯值得记住。path 传相对路径,并让程序在 data/ 目录下运行,或者启动时用 std::filesystem::create_directory("data") 保证目录存在,避免换机器的绝对路径问题。解析用"逗号替换成空格再走 >> 流式读取",是 CSV 解析里最省事的一招,代价是菜品名里不能出现逗号;真遇到带逗号的名称,就要改成逐个字符解析或用引号包裹。最后是坏行处理:跳过并打印,而不是让整个启动流程中断,磁盘文件被外部改坏一行,不应该让系统起不来。

编码是这段代码里最容易翻车的地方。Linux 下源文件和运行时都是 UTF-8,没差别;Windows 控制台默认是 GBK,文件用 UTF-8 写、控制台按 GBK 读,中文就乱码。最简单的处理是统一"文件内 UTF-8,Windows 下启动时切换控制台代码页",或者文件里只保存拼音标识,中文只在内存里展示。

3.3 订单落盘:全量重写加临时文件替换

订单和菜品不同:列表只增不减(简单系统不做删除),状态会反复变更。最省事的方案是每次变更后全量重写 orders.txt,几百单时耗时可以忽略。真正要防的是写一半断电、程序崩溃导致文件截断,标准的廉价做法是"临时文件 + 原子重命名":

// storage.cpp —— 订单落盘,临时文件 + rename 两步提交 #include <cstdio> bool saveOrders(const std::vector<Order>& orders, const std::string& path) { std::string tmp = path + ".tmp"; { std::ofstream out(tmp, std::ios::trunc); if (!out) return false; for (const Order& o : orders) { out << o.id << '|' << o.customerId << '|' << o.merchantId << '|' << o.state << '|' << o.totalPrice << '|' << o.createTime << '|'; for (const auto& item : o.items) { out << item.first << ':' << item.second << ';'; } out << '\n'; } } // 离开作用域,out 析构,文件保证关闭 if (std::rename(tmp.c_str(), path.c_str()) != 0) { std::cerr << "[saveOrders] 重命名失败\n"; return false; } return true; }

订单文件用竖线做主分隔符,因为 items 字段内部还用冒号和分号,竖线可以把"主字段"和"子字段"两个层次分开。加载时按 '|' 切出前 6 个主字段,第 7 段再按 ';' 切明细、按 ':' 切 id 与数量。rename 在同一个磁盘分区内是原子的,旧文件要么是完整的旧版本、要么是完整的新版本,不会出现半截文件。保存的调用时机放在业务操作成功之后,顺序固定为:先改内存,再写磁盘,最后通知用户。

4. 外卖系统的核心业务流:点餐校验、库存扣减与配送状态推进

4.1 点餐的两段式流程:先校验再执行,不做回滚

下单是系统里唯一同时改动多处数据的地方:菜品库存、订单列表、订单金额,三处必须保持一致。最常见的 bug 是库存扣了但订单没建成,或者反过来。为了避免写回滚代码,我统一用"两段式":第一阶段只读校验,任何一项不过直接返回;第二阶段才动手改数据,此时保证全部前置条件已满足。

// order_service.cpp —— 下单主流程,返回订单号,失败返回 -1 #include <iostream> #include <cmath> #include <ctime> #include "model.h" #include "order_state.h" int placeOrder(int customerId, const std::vector<std::pair<int,int>>& cart, std::map<int, Dish>& dishes, std::vector<Order>& orders) { if (cart.empty()) { std::cout << "购物车为空\n"; return -1; } // 阶段一:校验,不修改任何数据 for (const auto& [dishId, qty] : cart) { auto it = dishes.find(dishId); if (it == dishes.end()) { std::cout << "菜品 " << dishId << " 不存在\n"; return -1; } if (qty <= 0 || it->second.stock < qty) { std::cout << "菜品 " << it->second.name << " 库存不足\n"; return -1; } } // 阶段二:先扣库存,再生成订单,顺序固定 double total = 0.0; for (const auto& [dishId, qty] : cart) { Dish& d = dishes.at(dishId); d.stock -= qty; total += d.price * qty; } Order o; o.id = orders.empty() ? 1 : orders.back().id + 1; o.customerId = customerId; o.items = cart; o.state = static_cast<int>(OrderState::PENDING); o.createTime = std::time(nullptr); o.updateTime = o.createTime; o.totalPrice = std::round(total * 100.0) / 100.0; orders.push_back(std::move(o)); return o.id; }

这段代码里有三个面试常问的细节。第一是校验与执行分离,等价于"事务里先做所有检查再提交",失败时内存零改动,调用方不需要补偿逻辑。第二是金额计算先乘 100 做 round 再除 100,解决 double 累加误差,三个 6.66 元会出现 19.979999,round 之后才是 19.98。第三是订单号用"当前最大 id + 1",删除过订单会出现空缺但不会重复,单线程课设完全够用;以后要并发,把 id 改成原子计数器或直接交给数据库。

落盘调用放在 placeOrder 返回之后,由外层统一执行 saveDishes 和 saveOrders。顺序固定为先内存、再磁盘、后提示,颠倒就会出现"界面显示成功但重启后订单消失"的诡异问题。

4.2 配送状态推进:先查表,再改状态,最后写日志

配送流程由管理员或骑手触发,输入只有订单号和目标状态。canTransit 在这里起到闸门作用,业务函数只负责"查得到再改":

// order_service.cpp —— 状态推进,配合 5.2 的 logEvent 使用 bool advanceOrder(int orderId, OrderState target, std::vector<Order>& orders) { auto it = std::find_if(orders.begin(), orders.end(), [orderId](const Order& o) { return o.id == orderId; }); if (it == orders.end()) { std::cout << "订单 " << orderId << " 不存在\n"; return false; } if (!canTransit(static_cast<OrderState>(it->state), target)) { std::cout << "非法流转: " << static_cast<int>(it->state) << " -> " << static_cast<int>(target) << "\n"; return false; } it->state = static_cast<int>(target); it->updateTime = std::time(nullptr); logEvent("order " + std::to_string(orderId) + " -> " + std::to_string(static_cast<int>(target))); return true; }

find_if 搭配 lambda 在订单量不大时没有性能压力,订单上万后把 orders 换成 map<int, Order> 按键查找即可,函数体不用动。注意推进函数里改了状态之后立刻打日志,这比事后追查高效得多,后面 5.2 会给出 logEvent 的实现。canTransit 只回答"能不能",改不改由业务层决定,这样将来要在流转时做价格快照、通知骑手,只动这一处。

4.3 主循环的命令设计:把操作暴露成可扩展的菜单

最后是控制台入口。数字菜单(1 点什么、2 点什么)实现最简单,但扩展差;我习惯用"动词 + 参数"的三段式命令,比如 order 1 2 3 表示"顾客 1 购买菜品 2 共 3 份"。这样每个命令对应一个处理分支,加功能只加分支不加判断层级。下面的表是这套系统的最小命令集。

命令参数动作
list列出全部菜品与库存
order顾客id 菜品id 数量下单并立即落盘
orders打印全部订单与状态
progress订单id 目标状态码推进订单状态
exit退出程序

对应的主循环骨架如下:

// main.cpp —— 命令分发循环 void runRepl(std::map<int, Dish>& dishes, std::vector<Order>& orders) { std::string cmd; while ((std::cout << "> ") && (std::cin >> cmd)) { if (cmd == "exit") break; else if (cmd == "list") { for (const auto& [id, d] : dishes) { std::cout << id << ' ' << d.name << ' ' << d.price << " 库存:" << d.stock << '\n'; } } else if (cmd == "order") { int uid, dishId, qty; if (std::cin >> uid >> dishId >> qty) { int oid = placeOrder(uid, {{dishId, qty}}, dishes, orders); if (oid > 0) { saveDishes(dishes, "data/dishes.csv"); saveOrders(orders, "data/orders.txt"); std::cout << "下单成功, 订单号 " << oid << "\n"; } } } else if (cmd == "progress") { int oid, state; if (std::cin >> oid >> state) { advanceOrder(oid, static_cast<OrderState>(state), orders); saveOrders(orders, "data/orders.txt"); } } else { std::cout << "未知命令\n"; } } }

{{dishId, qty}} 依赖 C++11 的初始化列表语法,会自动构造成 vector<pair<int,int>> 的单元素临时量。编译这一层也顺手提醒:上面的代码用了结构化绑定、std::round、初始化列表等 C++17 特性,编译命令是 g++ -std=c++17 model.cpp order_state.cpp storage.cpp order_service.cpp main.cpp -o food_delivery。如果你在 VSCode 里配置的 C/C++ 环境报错,先检查 tasks.json 里是否带了 -std=c++17,这是这类 c++源码 包最常见的编译失败原因。菜单每处理一条命令立即落盘,而不是退出时统一保存,Ctrl+C 强杀也最多丢当前命令的数据。

5. 给外卖系统加固:输入校验、运行日志与自检断言

5.1 安全的数字读取:别让一个字母打崩整个 REPL

控制台程序崩溃的头号原因不是业务逻辑,而是输入。用户敲一个非数字,operator>> 读 int 直接置 failbit,之后所有读取全部失效,程序行为变得不可预测。稳妥做法是整行读入 string,再用带位置信息的 stoi 手工转换。

// input_util.h #include <iostream> #include <string> int readInt(const std::string& prompt) { std::string line; std::cout << prompt; while (std::getline(std::cin, line)) { if (line.empty()) continue; try { size_t pos = 0; int v = std::stoi(line, &pos); if (pos == line.size()) return v; } catch (...) { /* 溢出、抛异常等统一按非法处理 */ } std::cout << "输入无效,请重新输入: "; } return -1; // EOF 或 Ctrl+D,调用方据此退出 }

stoi 第二参 pos 是关键。它记录"解析消耗了多少字符",如果 pos 不等于整行长度,说明后面还有残留,比如 "12ab" 会解析出 12 但留下 "ab",这种输入必须拒绝。把主循环里所有裸 >> 换成 readInt 后,随便敲什么都不会死循环或崩溃。

5.2 运行日志:把状态流转和异常落到文件里

简单系统没有调试器的容错空间,运行时日志是最便宜的排错手段。不需要日志库,一条"时间戳 + 事件"追加写就够用,在三个位置打点:下单成功、状态推进、文件读写失败。

// log_util.h #include <fstream> #include <ctime> void logEvent(const std::string& msg) { std::time_t now = std::time(nullptr); char buf[32] = {}; std::strftime(buf, sizeof(buf), "%F %T", std::localtime(&now)); std::ofstream log("data/runtime.log", std::ios::app); log << "[" << buf << "] " << msg << "\n"; }

调用示例:logEvent("order 12 created total=58.8")。追加模式打开,文件不存在自动创建,不影响主流程。日志级别可以简化为 FATAL 和 WARN 两种:文件打不开记 FATAL,单行解析失败记 WARN,排查时先看 FATAL 再看 WARN,定位顺序就清楚了。

5.3 自检断言:启动时拦下脏数据

最后是启动自检。assert 适合表达"理论上不可能、发生即 bug"的约束,比如订单状态越界、订单引用了不存在的菜品、库存被扣成负数。程序启动后先跑一遍自检,能提前拦住数据文件被外部改坏的情况:

// self_check.h —— 需要 <cassert> #include <cassert> void selfCheck(const std::vector<Order>& orders, const std::map<int, Dish>& dishes) { for (const Order& o : orders) { assert(o.state >= static_cast<int>(OrderState::PENDING) && o.state <= static_cast<int>(OrderState::CANCELED)); for (const auto& [dishId, qty] : o.items) { assert(dishes.count(dishId) > 0); // 订单里的菜必须存在 assert(qty > 0); } } }

断言失败时程序打印文件、行号和表达式后终止,这比带脏数据继续运行要安全。注意 NDEBUG 宏会整体移除 assert,发布版要保留这项检查,就改成 if 加返回错误码。更实用的习惯是配合 5.2 的日志:自检发现问题先写 FATAL 日志再 abort,这样线上拿到 runtime.log 就能还原现场,而不是只看到程序退出。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/13 13:19:05

51单片机Proteus仿真:LCD1602电池电压与温度显示设计

简介&#xff1a;基于LCD1602液晶显示、DS18B20温度传感与TLC549模数转换三大模块&#xff0c;这份单片机仿真设计实现了电池电压和温度的实时监测与显示&#xff0c;适合学习51单片机、入门电池管理系统或进行毕设课题参考。压缩包共29个文件&#xff0c;整体大小仅282KB&…

作者头像 李华
网站建设 2026/9/13 13:16:33

Linux常见问题复盘:解压乱码、DNS、WSL磁盘与Python管理

这期内容原本应该顺着 Linux 安装的话题继续往下写&#xff0c;但最近收到的实操问题实在太多&#xff0c;从 ZIP 解压乱码到 WSL 磁盘爆满&#xff0c;再到 Python 版本管理&#xff0c;几乎每一个都让我重新翻了一遍文档。所以我把第 10 期的上半部分直接做成一个“问题复盘集…

作者头像 李华
网站建设 2026/9/13 13:16:15

CoNMF高光谱解混:协同稀疏约束与sunsal初始化工程实践

简介&#xff1a;面向高光谱遥感解混研究的一份CoNMF算法资源包&#xff0c;适合遥感图像处理、目标检测与环境监测方向的科研人员及相关专业学生。内容包括约束非负矩阵分解的完整实现与演示流程&#xff0c;覆盖端元提取、丰度估计和混合像元分解等关键环节。压缩包共30个文件…

作者头像 李华
网站建设 2026/9/13 13:15:09

数字化经营指标解析与实施指南

1. 数字化经营指标的本质解析"数字化经营指标"这个看似高大上的术语&#xff0c;其实质就是企业运用数据来指导和优化经营决策的方式。就像老练的厨师不再凭感觉放盐&#xff0c;而是用精准的秤量控制调味料的用量。数字化经营的核心&#xff0c;在于将传统的经验驱动…

作者头像 李华
网站建设 2026/9/13 13:10:16

UniApp教培中台源码:双端同构+插件化运营解决方案

简介&#xff1a;这是一套面向教育培训行业开发者的微信小程序与公众号双端源码解决方案&#xff0c;专为中小型培训机构、在线教育机构及教育类创业团队设计&#xff0c;解决课程管理、营销转化与用户运营一体化难题。资源包为77.27MB的ZIP压缩文件&#xff0c;含完整前后端代…

作者头像 李华