ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

C++外卖管理系统源码解析:从结构体建模到订单状态机设计

C++外卖管理系统源码解析:从结构体建模到订单状态机设计 简介基于C的外卖管理系统是一份用于课程设计或期末项目的完整源码包面向具备一定C基础的学生与开发者帮助理解面向对象编程、文件/控制台交互及餐饮订单业务流程。包内共44个文件含8个cpp源文件、6个头文件、9个exe可运行程序及项目工程文件压缩包约6.37MB源码与编译产物齐全可直接运行或二次修改。系统实现餐厅管理员登录、菜品增删改查与模糊/价格范围查询、顾客注册登录与下单/改单/取消/确认收货并支持订单评价和出单管理覆盖外卖场景主要功能。已有281人学习下载适合作为课程设计参考、期末答辩演示或C综合练习。资源附带README说明与项目布局文件便于快速梳理结构、定位各模块代码。1. 这份C外卖管理系统源码拆开能学到什么第一次把这份基于C的外卖管理系统源码包解开时我先看了 Debug 目录里的 exe 能不能直接跑再把 DeliverySys.cpp 拖进编辑器过了一遍。它的体量不大却把外卖场景里最关键的几个环节都覆盖了管理者维护菜品、顾客注册登录、下单后先待出单再确认收货每个状态之间还有明确的权限边界。和纯增删改查的“学生管理系统”相比它多了角色区分和订单生命周期管理这对想搞懂 C 结构体、STL 容器、字符串处理和文件持久化怎么配合的人来说是很好的切入点。适合把 C 语法学完但还没做过完整项目的读者也适合准备 C 面试时想拿一个能讲清楚的需求的人。2. 数据建模与存储先定实体再写代码任何一个管理类程序第一步都是想清楚数据怎么组织。很多初学的人一上来就写菜单和输入输出写到一半发现订单里要装多个菜品又开始回头拆结构体。正确的顺序是先完成对象建模再考虑用什么容器存、怎么持久化最后才是界面和交互。2.1 把现实对象翻译成结构体和类这个项目里最小的数据单位是菜品核心是订单。订单又包含一项或多项菜品因此不能把菜品直接塞进订单字段里而要拆出订单项。我按常规做法整理成下面的定义struct Dish { int id; // 菜品编号 std::string name; // 菜名 double price; // 单价 int stock; // 剩余数量 }; struct OrderItem { int dish_id; // 下单时的菜品编号 int quantity; // 购买数量 double price; // 下单时的单价快照 }; struct Order { int order_id; // 订单号 std::string customer; // 订餐人 std::string phone; // 联系电话 std::vectorOrderItem items; // 菜品列表 double total; // 合计价格 std::string order_time; // 点餐时间 std::string confirm_time; // 确认收货时间 int status; // 0待出单 1已出单 2已确认 };为什么会把 price 同时放在 Dish 和 OrderItem 里因为菜品价格可能被管理者修改订单里必须保存顾客下单那一刻的单价快照。如果不做快照订单出单时重新读菜品价格历史订单金额就会被改动后面核对账目时会对不上。这是实际业务里容易忽略的细节。结构体和类在这个场景里没有绝对界限。项目源码里用的是结构体核心原因是这些对象主要用来承载数据行为逻辑都写在操作函数里。如果你愿意也可以把 Order 封装成类把“修改订单”“取消订单”“确认收货”写成成员函数。不过成员函数一多类的职责会慢慢膨胀我一般偏向让结构体只存状态行为交给独立的服务函数这样单测也好写。2.2 容器选型vector、map 与链表怎么挑订单里的菜品列表一定是用vectorOrderItem这没有悬念。但“全局存什么容器”值得琢磨。常见做法是std::vectorDish dish_list; // 菜品列表 std::vectorOrder order_list; // 订单列表 std::mapstd::string, std::string accounts; // 账号 - 密码vector 连续内存、支持随机访问遍历时对 CPU 缓存友好。菜品数量和订单数量在个人项目里不会超过几十万线性遍历完全可以接受。map 用于账号查找键是用户名值是密码登录时用find就是 O(log n)比每次遍历 vector 直观得多。有些教材会强调链表适合频繁插入删除但那是理论上的。实际在这个系统里修改订单用的是订单号定位删除用的是按索引或按迭代器移除vector 在序列中间插入删除确实要搬移元素但订单删除大多从尾部或任意位置且频率不高性能差距可以忽略。在 C 面试里“vector 和 list 怎么选”是高频题。你可以借这个项目这样回答如果容器规模小、遍历为主、随机访问多选 vector如果元素很大、需要频繁在头部或中间插入删除再考虑 list。这里的订单和菜品都符合前者。2.3 文件持久化按行拆分还是二进制快照程序退出后数据要能留到下回启动最稳的方案是每次数据变更后立刻写文件启动时一次性读入。写入格式我用管道符|分隔字段订单项之间用逗号订单之间用横线---分隔void save_orders(const std::vectorOrder order_list, const std::string file) { std::ofstream ofs(file, std::ios::out | std::ios::trunc); if (!ofs) return; for (const auto o : order_list) { ofs o.order_id | o.customer | o.phone | o.total | o.status | o.order_time \n; for (const auto item : o.items) { ofs item.dish_id , item.quantity , item.price \n; } ofs ---\n; } }读取时先用getline按行读遇到---说明一条订单结束订单行有 6 个字段后面跟随的每一行都是订单项std::vectorstd::string split(const std::string s, char delim) { std::vectorstd::string parts; std::string cur; std::istringstream ss(s); while (std::getline(ss, cur, delim)) { parts.push_back(cur); } return parts; } std::vectorOrder load_orders(const std::string file) { std::vectorOrder orders; std::ifstream ifs(file); if (!ifs) return orders; Order cur; std::string line; while (std::getline(ifs, line)) { if (line ---) { orders.push_back(cur); cur Order{}; } else { std::vectorstd::string parts split(line, |); if (parts.size() 6) { cur.order_id std::stoi(parts[0]); cur.customer parts[1]; cur.phone parts[2]; cur.total std::stod(parts[3]); cur.status std::stoi(parts[4]); cur.order_time parts[5]; } else { std::vectorstd::string item split(line, ,); if (item.size() 3) { OrderItem oi; oi.dish_id std::stoi(item[0]); oi.quantity std::stoi(item[1]); oi.price std::stod(item[2]); cur.items.push_back(oi); } } } } return orders; }s是 std::stoi的缩写表示 string to intstd::stod表示 string to double。注意这两个函数在遇到空字符串时会抛异常所以我在拆分后判断了 parts.size()确保格式异常的行不会让整个程序崩溃。文本格式可读性好出错时能直接打开文件定位缺点是字段里如果出现|或,解析就会错位。处理办法是做转义或者在程序里限制输入不得包含这些字符课程设计阶段用后者就够了。提示文件路径尽量用相对路径并固定工作目录否则 Debug 目录下启动的 exe 会找不到数据文件程序却不报错只是显示空数据。3. 角色、菜单与订单状态机核心流程怎么跑通数据模型定好后接下来是流程。外卖系统的难点不在单个函数而在角色权限和订单状态之间的约束关系。这里我按“入口判断 → 数据操作 → 状态迁移”的顺序展开。3.1 登录注册的校验逻辑项目里有两个角色餐厅管理员和顾客。管理员账号可以预置在文件里顾客账号由注册产生。注册时最容易被忽略的是账号重复校验bool register_user(const std::string name, const std::string pwd) { if (accounts.find(name) ! accounts.end()) { std::cout 用户名已存在 std::endl; return false; } if (name.empty() || pwd.size() 6) { std::cout 用户名不能为空密码至少 6 位 std::endl; return false; } accounts[name] pwd; save_accounts(accounts.txt); return true; } bool login(const std::string name, const std::string pwd) { auto it accounts.find(name); if (it accounts.end()) return false; return it-second pwd; }accounts.find(name)返回的迭代器指向的是键值对it-second取密码。这里有个细节map 的operator[]在键不存在时会自动插入一个空值所以在登录这种只读判断里应该用find不要用[]否则会污染账号表。密码明文存文件在演示项目里问题不大但如果你想往简历上写至少要做一次简单的哈希比如保存时把密码做一次移位或异或。管理员角色可以用一个单独的admin.txt判断或者在账号文件里加一个角色字段读取时区分。3.2 菜品管理的联动约束菜品信息的添加、修改、删除是管理员菜单的核心。添加菜品时要注意编号不能重复删除菜品时要注意“是否还有未出单的订单引用这个菜品”。如果不做检查会出现订单里的菜品编号根本查不到的情况bool delete_dish(int id, std::vectorDish dish_list, const std::vectorOrder order_list) { for (const auto o : order_list) { if (o.status 0) { // 待出单 for (const auto item : o.items) { if (item.dish_id id) { std::cout 该菜品仍有待出单订单不能删除 std::endl; return false; } } } } dish_list.erase(std::remove_if(dish_list.begin(), dish_list.end(), [id](const Dish d) { return d.id id; }), dish_list.end()); return true; }erase和remove_if配合是 C 里移除满足条件元素的惯用法。remove_if只负责把符合条件的元素移到容器末尾并把逻辑大小前移真正释放元素要靠erase。很多初学者只写一个remove_if后遍历发现元素还在就是没搞懂这两个函数的分工。修改菜品时除了改名称和价格还要考虑库存变化。顾客下单时检查过库存出单时还要再检查一次因为从下单到出单之间可能有别的订单先把库存买光。所以管理员在修改菜品数量时如果新库存小于当前待出单订单中该菜品的总需求量应该给出警告而不是直接覆盖。3.3 订单状态流转的条件边界订单状态是这个项目里最值得讲的部分。我把它看成一个小型状态机状态含义谁可操作允许的迁移0待出单顾客、管理员顾客可修改/取消管理员可出单1已出单顾客、管理员顾客可确认收货2已确认管理员查看不可再变更对应到代码里每个操作前先做状态判断void customer_action(Order o, int action) { if (o.status 0) { if (action 1) modify_order(o); // 修改订单 if (action 2) cancel_order(o); // 取消订单 } else if (o.status 1 action 3) { confirm_receipt(o); // 确认收货 } else { std::cout 当前状态不允许该操作 std::endl; } }这个 if-else 看起来很直白但它保证了“没出单之前顾客可以改和退出单后只能确认”。真正会写歪的地方在管理员侧管理员查看“未出单订单”时如果订单很多要按下单时间排序订单里的order_time是字符串如果格式统一为YYYY-MM-DD HH:MM:SS可以直接按字典序比较如果格式是MM/DD/YYYY字典序就会出错。出单操作本身要做的额外事情是扣减菜品库存扣减后要重新计算订单总价保证和历史快照一致。修改订单和取消订单的界限也要写清楚顾客修改自己的订单时只能修改“菜品列表”和“份数”不能直接篡改单价。正确的做法是修改后按当前菜品单价重新生成 OrderItem 快照再累加 totalvoid modify_order(Order o, const std::vectorDish dish_list) { o.items.clear(); double total 0.0; int dish_id; int qty; // 逐项输入重新校验库存 std::cout 输入菜品编号和数量输入 0 结束 std::endl; while (std::cin dish_id) { if (dish_id 0) break; std::cin qty; for (const auto d : dish_list) { if (d.id dish_id d.stock qty) { OrderItem item; item.dish_id d.id; item.quantity qty; item.price d.price; o.items.push_back(item); total d.price * qty; break; } } } o.total total; o.order_time current_time_str(); // 每次修改刷新时间 }这里的current_time_str()是常用封装内部用std::chrono::system_clock::now()取当前时间转成格式化字符串。注意修改订单会刷新下单时间这在业务上是合理的因为“按下单时间顺序查看未出单订单”时修改过的订单会被排到新位置避免一直在队首堵住出单流程。4. 查询与排序进阶模糊匹配、价格区间与稳定排序管理员的菜品列表、顾客的点餐界面都需要支持按名称模糊查询、按价格范围查询、按价格升降序显示。这几点统计学得会但写起来经常出边界问题。4.1 按菜品名称模糊查询的正确姿势模糊查询的核心是把“用户输入的关键字”和“菜品名”做包含匹配。C 里最直接的是std::string::findstd::vectorDish fuzzy_search(const std::vectorDish dish_list, const std::string keyword) { std::vectorDish result; for (const auto d : dish_list) { if (d.name.find(keyword) ! std::string::npos) { result.push_back(d); } } return result; }find返回子串第一次出现的位置查不到时返回npos。这里有两个坑。第一npos是一个极大的无符号数如果用 0或 -1判断在部分编译器上会出警告或不相等。标准写法就是! std::string::npos。第二find区分大小写。用户搜“黄焖鸡”没问题但搜“huangmenji”就查不到。如果要支持不区分大小写常见做法是先把菜名和关键字都转成小写再查找std::string to_lower(std::string s) { std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return std::tolower(c); }); return s; }注意tolower的参数要显式转成unsigned char否则遇到扩展 ASCII 字符时是未定义行为。这是一个很细的 C 考点很多人栽在中文菜名上因为中文字符的每个字节都大于 127直接用char会变成负数tolower的行为就不可控了。4.2 价格区间查询与比较器设计价格范围查询可以写成带上下限的函数。如果未指定下限就设成 0未指定上限就设成一个很大的数。也可以定义一个PriceRange结构体让调用更清晰struct PriceRange { double min_price; double max_price; }; std::vectorDish filter_by_price(const std::vectorDish dish_list, const PriceRange range) { std::vectorDish result; for (const auto d : dish_list) { if (d.price range.min_price d.price range.max_price) { result.push_back(d); } } return result; }实际使用时组合查询可以先模糊过滤再按价格范围过滤std::vectorDish result filter_by_price( fuzzy_search(dish_list, keyword), PriceRange{10.0, 30.0});这样写的好处是每一步都能独立测试后续想加“按分类过滤”只需要再包一层。排序部分升序降序可以用同一个比较器加一个方向标志位也可以用两个 lambda。我习惯用下面这种“严格弱序”写法std::sort(dish_list.begin(), dish_list.end(), [](const Dish a, const Dish b) { if (a.price ! b.price) return a.price b.price; return a.id b.id; // 价格相同时按编号排序 });这个比较器的逻辑是先比价格价格相同再比编号。如果你写的是return a.price b.pricestd::sort在严格弱序不成立时可能崩溃或产生未定义行为。因为当两个元素相等时a b和b a都为真这破坏了等价类的排他性。降序时把换成并且保证相等时返回false即可。升序降序交替使用、且不需要保持元素相对顺序时用std::sort没问题如果要求价格相同的菜按原始顺序保留就要改用std::stable_sort。这个细节在 C 面试里常被问到答出“稳定排序保留等价元素相对顺序”会加分。4.3 让主菜单循环可扩展管理菜单和顾客菜单通常长这样显示选项、读入选择、调用对应函数、回到循环。很多人会写一大串 if-else 嵌套。更好的做法是把每个操作封装成函数用函数指针表分发using CommandFn void(*)(); CommandFn admin_menu[] { add_dish, modify_dish, delete_dish, list_dishes, view_orders, ship_order }; int choice; std::cin choice; if (choice 1 choice 6) { admin_menu[choice - 1](); }函数指针表把“菜单项”和“函数地址”一一对应新增一个功能时往数组里加一个函数名就行不用动 switch 结构。需要注意数组下标越界检查这比单独写六个 case 更考验功底。菜单循环里另一个常见问题是输入失败。用户输入非数字时cin 会进入错误状态后面所有都会直接失效程序表现为“按什么都没反应”std::cin choice; if (std::cin.fail()) { std::cin.clear(); // 恢复流状态 std::cin.ignore(1024, \n); // 清空缓冲区残留字符 std::cout 输入无效请重新输入 std::endl; continue; }clear恢复流状态ignore清掉缓冲区里的残留字符。如果你用的是std::getline读整行再解析就不会遇到这个问题但要自己写字符串转数字的解析两种风格各有利弊。5. 评价、扩展与验证把这个系统打磨到能演示前面的核心功能跑通后评价模块和细节增强是拉开差距的地方。5.1 评价数据和订单解耦题目要求“顾客对订单中的菜品信息进行评价”如果你的设计里只允许对已确认收货的订单评价代码会简单很多但要注意数据归属struct Review { int order_id; int dish_id; int rating; // 1-5 星 std::string comment; }; std::vectorReview review_list;评价表用order_id dish_id做自然键避免一个订单对同一个菜品重复评价。管理员查看评价时按dish_id聚合就能统计每个菜品的平均分double average_rating(int dish_id, const std::vectorReview reviews) { int sum 0; int count 0; for (const auto r : reviews) { if (r.dish_id dish_id) { sum r.rating; count; } } return count ? static_castdouble(sum) / count : 0.0; }顾客评价前先判断订单状态必须为 2已确认否则提示“还没确认收货不能评价”。这里的static_castdouble是显式类型转换避免整数除法把sum / count截断成 0这在统计平均分时是很容易踩的坑。5.2 加一个按时间倒序的订单列表管理员的“查看订单”如果只按插入顺序显示订单一多就不好用。订单时间order_time如果固定为YYYY-MM-DD HH:MM:SS格式倒序排序可以直接用字符串比较std::sort(order_list.begin(), order_list.end(), [](const Order a, const Order b) { return a.order_time b.order_time; });注意order_time必须统一格式。点餐时间在代码里如果存的是time(nullptr)得到的秒数那就要按整数比较如果显示时格式化为可读字符串存文件时就不要混存数字。我的建议是统一用字符串因为文件内容直接可读排错时不用再换算时间戳。5.3 验证与排错技巧验证场景预期结果常见错误注册重复账号提示用户名已存在没查重直接覆盖顾客删除待出单订单订单消失没判断状态把已出单订单也删了删除仍有订单的菜品禁止删除删了之后历史订单菜品查不到输入非数字菜单选项重新提示cin进入错误状态导致死循环编译报错stod找不到检查是否包含string没有包含必要头文件我在验证这类项目时会先写一组最小用例注册两个账号管理员添加三个菜品其中两个价格相同来测稳定排序顾客下一单修改一次再下一单取消一单管理员出单另一个顾客确认收货最后看保存的文件内容和重新启动后的恢复情况。如果Project.exe直接在 Debug 目录里跑注意工作目录和accounts.txt的相对路径路径不对会导致启动时读不到文件程序却不会报错只是显示空数据。用 Dev-C 打开Project.dev后如果中文乱码把编辑器编码切成 GBK 或 UTF-8 再重新编译如果在 VSCode 里配置 C/C 环境自己拉工程记得在tasks.json里加上-stdc11或更高标准否则范围 for 和 Lambda 会直接编译失败。验证完这些路径这份源码才算真正被你跑通而不只是解压后看一眼 exe。本文还有配套的精品资源点击获取
返回列表