简介:基于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 就能还原现场,而不是只看到程序退出。
本文还有配套的精品资源,点击获取