最近在社区里经常被问到想做一个能完整落地的Web项目练手到底选什么方向我的建议始终是——先做一套餐厅点餐管理系统。这个题材不算新但正因为常见它的需求边界清晰、业务链路完整、技术栈覆盖面广非常适合用来打通从需求分析到部署上线的全流程。今天我把这套基于Spring Boot的点餐管理系统从里到外拆开讲包含表结构设计、下单事务、登录权限、模拟支付、统计报表以及我在实际开发中踩过的坑和解决办法全部按实操记录复盘。1. 项目需求拆解与总体设计1.1 点餐管理系统的真实业务场景餐厅点餐系统不是一个简单的CRUD例子它背后对应着一个真实的餐饮门店经营场景。从顾客进店扫码点单到后厨接单出餐再到前台结账每一步都有明确的信息流转。如果只把系统理解成“菜品增删改查”那做出来的东西大概率只能停留在作业层面离真正能跑起来的门店系统还差很远。我在设计这套系统时首先把业务分成了两条主线第一条是顾客点餐链路包括浏览菜品、查看分类、加入购物车、提交订单、支付订单第二条是门店管理链路包括菜品上下架、分类维护、订单处理、销售数据统计。两条链路并非完全独立而是通过订单这个核心实体交织在一起。这种业务拆解方式的好处是功能边界很清楚前后端开发可以并行推进测试用例也好写。从用户角色来看系统至少要区分普通用户和管理员。普通用户对应C端顾客管理员对应门店老板或收银员。不同角色的操作权限差异很大比如顾客只能看到上架状态的菜品管理员却可以对菜品进行下架处理顾客不能查看其他订单管理员却需要看到全部订单并按状态筛选。权限设计如果只在页面层硬编码判断后期维护会很痛苦所以我推荐在拦截器或网关层面做统一鉴权后面会具体展开。1.2 为什么选Spring Boot而不是其他框架很多初学者问为什么偏偏是Spring Boot这个问题要放到项目所处的阶段来回答。对于中小型单体应用Spring Boot的价值不是“功能多”而是“成本低”。先从工程化角度看Spring Boot提供了自动化配置项目不需要维护一堆XML配置文件。团队协作时新成员拉下代码后只要配置好数据库连接和Redis就能启动大大降低了上手门槛。再从生态角度看Spring Boot能和MyBatis-Plus、Redis、Spring Security这些常用组件无缝整合食堂点餐这种业务量级的系统单体架构完全够用没必要过早引入微服务和容器编排那一套复杂度。在具体版本选择上我建议根据团队实际环境决定如果服务器环境是JDK 8选择Spring Boot 2.7.x版本最稳妥如果团队已经升级到JDK 17及以上可以直接上Spring Boot 3.x。需要特别注意的是Spring Boot 3.x基于Jakarta命名空间一些老教程里的javax包路径不兼容依赖版本也要跟着升级搬代码时别直接复制。1.3 功能模块与角色权限梳理完整的点餐系统至少包含以下模块。顾客端模块菜品列表展示、菜品分类筛选、菜品详情、加入购物车、购物车管理、提交订单、模拟支付、查看个人订单列表、订单详情。管理端模块管理员登录、菜品管理、菜品分类管理、订单管理、用户管理、销售统计报表。每个模块又可以细分出若干接口。比如菜品管理至少需要新增、编辑、上下架、批量删除等接口。订单管理则需要支持按订单号搜索、按状态筛选、订单发货或出餐操作。因为涉及角色权限我设置了用户表中的role字段用整数区分0表示普通用户1表示管理员。所有的管理员请求都在拦截器中校验登录状态和角色权限。虽然Spring Security也能做但对单体项目来说拦截器加Session的实现方式更轻量新手更容易看懂。2. 数据库设计与核心代码结构2.1 数据表设计思路与表关系数据库设计是整个系统最重要的环节表结构如果设计得不好后续写业务代码时会到处凑查询。我这套系统的表不算多但每一张表都踩过实际业务需求的“坑”最终沉淀为6张核心表。用户表字段包含id、用户名、密码、昵称、手机号、角色role、创建时间。密码存的是MD5或BCrypt加密后的密文绝对不能明文存放。菜品分类表id、分类名称、排序值、是否显示等。分类和菜品是一对多关系设计时要注意删除分类前需要校验其下是否存在菜品否则容易造成数据不一致。菜品表id、菜品名称、菜品图片、单价、描述、分类id、是否上架status、库存、销量。这里我特意加了一个status字段用0和1控制上下架状态避免删除菜品时丢失历史订单的展示数据。订单表id、订单号order_no、用户id、总金额、订单状态status、创建时间、支付时间、备注。订单状态我用int类型存储0待支付、1已支付、2已出餐、3已完成、4已取消。订单明细表id、订单id、菜品id、菜品名称、单价、数量。之所以要冗余一份菜品名称和单价是因为菜品价格可能会调整如果只关联菜品id历史订单的价格信息就会出错。这个细节在面试和答辩中经常被问到值得注意。购物车表id、用户id、菜品id、数量。如果不想建表也可以用Redis的Hash结构存储购物车但为了让整个系统纯依赖MySQL也能跑通我选了数据库方案后面会做对比分析。这些表之间的关系是用户与订单一对多订单与订单明细一对多分类与菜品一对多用户与购物车记录一对多。整体关系不复杂首屏阅读代码时比较友好。2.2 后端分层架构与统一返回格式代码结构如果乱糟糟功能再多也会变成灾难。我采用的是经典的四层结构。controller层负责接收参数、校验请求、调用service并返回结果service层处理业务逻辑比如计算订单金额、扣减库存、更新订单状态mapper层使用MyBatis-Plus操作数据库单表CRUD基本不用写SQLentity层定义实体对象与数据库表字段一一对应。为了让前端在处理结果时统一逻辑我还封装了一个通用返回类R包含三个字段code表示状态码、msg表示提示信息、data表示返回数据。比如成功时返回R.success(data)失败时返回R.error(xx失败)。这样无论是普通页面还是前后端分离的接口调用错误处理都能统一。2.3 下单流程的核心事务实现下单是整个系统最核心的业务流程涉及多个数据库操作。我在设计时把它放在一个事务方法里保证任何一步失败都会回滚不会出现订单主表保存成功而明细丢失的情况。Transactional(rollbackFor Exception.class) public Order createOrder(OrderCreateDTO dto) { // 1. 查询用户校验是否存在 // 2. 遍历购物车数据查询菜品状态和价格 // 3. 计算总金额生成唯一订单号 // 4. 保存订单主表数据 // 5. 保存订单明细列表 // 6. 更新菜品销量和库存 // 7. 清空当前用户的购物车 return order; }这里有一个细节事务注解不能加在接口上吗当然可以加在接口方法上但加了之后事务管理器实际作用于实现类调试时容易产生混淆。我习惯把Transactional写在实现类的具体方法上同时指定rollbackFor Exception.class。如果你不指定rollbackFor运行时异常会回滚但捕获后抛出的业务异常可能不会触发回滚这个问题很容易让人栽跟头。订单号生成也很讲究不能简单用自增id当作订单号否则暴露业务量且不利于合并支付。我采用的是时间戳加随机数的方案yyyyMMddHHmmss加四位随机数字。虽然在高并发下可能出现极小概率的重复但对门店点餐这个量级已经足够。如果对唯一性要求更严可以在方法内部用synchronized加锁或者引入雪花算法。2.4 菜品库存和状态校验的细节下单时最容易忽略的是菜品库存校验。我在购物车加入菜品时只校验了上架状态真正锁定库存要放到下单环节。这里需要注意的是不要把库存字段做成负数。我采用先查库存再扣减的方式并且在校验时加了库存是否足够判断避免出现超卖。在实际开发中我还遇到过用户提交订单后菜品被管理员下架的情况。此时订单明细中冗余的菜品名称和价格就发挥作用了即使菜品现在已经下架历史订单依然能正常显示商品信息不影响门店核对账单。3. 关键功能实现细节与前后端联调3.1 登录认证与权限拦截的实现用户登录我采用了Session方案登录成功后将用户信息存入Session。管理员和普通用户的界面入口不同通过登录后返回的role字段做区分。这里没有引入太复杂的Spring Security但并不是说可以不考虑安全拦截器还是必要的。我实现了一个LoginInterceptor拦截器继承HandlerInterceptor在preHandle方法里判断Session中是否存在登录用户。如果没有登录直接返回未登录提示或重定向到登录页如果登录了再根据请求路径判断是否需要管理员权限。这样做的好处是权限控制不需要散落在各个Controller里新增接口时只要在注册拦截器时配置好路径规则即可。public class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { User user (User) request.getSession().getAttribute(user); if (user null) { response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\msg\:\未登录\}); return false; } return true; } }需要特别提醒的是静态资源要放行。如果拦截器配置的是拦截所有路径会把CSS、JS、图片全部拦下来页面样式全丢失。我在WebMvcConfigurer里除了注册拦截器还要排除login页面、register页面以及static目录下的静态资源。密码存储也是很容易忽略的点。有些课程项目直接明文存储密码这在真实场景中是大忌。我采用的是MD5加盐处理虽然不是最强方案但比明文好得多。如果项目后续要做安全加固可以换成BCrypt算法Spring Security里面自带这个加密工具类迁移成本也不高。3.2 点餐与购物车模块的实现技巧购物车模块看起来简单实际操作中有几个容易出问题的地方商品重复加入商品数量修改总价计算。我的购物车表记录了用户id和菜品id当用户点击“加入购物车”时先判断该用户的购物车中是否存在同一菜品。如果已经存在则数量加1如果不存在则新增一条记录。public void addToCart(Long userId, Long dishId, Integer count) { Cart cart cartMapper.selectOne( new LambdaQueryWrapperCart() .eq(Cart::getUserId, userId) .eq(Cart::getDishId, dishId)); if (cart ! null) { cart.setCount(cart.getCount() count); cartMapper.updateById(cart); } else { Cart newCart new Cart(); newCart.setUserId(userId); newCart.setDishId(dishId); newCart.setCount(count); cartMapper.insert(newCart); } }加购成功后页面需要同步展示购物车数量和总价。我在前端用一个Badge显示购物车商品总数每次加购后重新加载购物车接口避免本地累加导致数据不一致。购物车是放Redis还是数据库这个问题我在项目中期专门做过对比。数据库方案实现简单、无需额外依赖适合学习和演示Redis方案适合生产环境但需要处理key过期、数据序列化和缓存一致性问题。考虑到项目目标是打通业务链路而不是追求极致的性能最终选择了数据库方案。在博客里我也建议读者如果是为了面试展示可以在简历上写“购物车支持Redis缓存优化”但实际代码要能讲清楚为什么要这样做。还有一个体验细节菜品列表页如果把所有菜品一次性返回数据量大会拖慢速度。我做了分页查询每页显示10条或12条分类切换时按分类id过滤。分页插件使用MyBatis-Plus自带的分页插件配置一个PaginationInnerInterceptor就能生效避免手写Limit参数带来的SQL注入风险。3.3 订单状态机与模拟支付的实现订单状态是整个系统的主心骨。我定义了一个状态机重点在于各个状态之间的转换条件。待支付状态下用户点击支付后进入已支付状态此时后厨可以看到新订单并准备出餐出餐完成后订单状态变为已出餐顾客取餐或确认后订单变为已完成。每个状态转换都对应一个明确的接口不能直接通过update语句随意改状态这样既方便日志追踪也防止业务逻辑被绕过。模拟支付是很多人在面试中都会被问到的点。真实项目中支付需要对接第三方支付平台涉及回调签名、订单金额二次校验等安全步骤。在这里我实现了一个模拟支付接口用户点击支付时前端把订单id传给后端后端校验订单状态为待支付后将订单状态改为已支付同时记录支付时间。虽然和真实支付差异较大但体现了状态流转的完整思路。超时未支付订单的处理也是实际业务中的常见需求。我实现了两种处理方式一种是用户主动取消另一种是后端定时任务扫描超过30分钟未支付的订单自动将状态置为已取消。定时任务我用的是Spring自带的Scheduled注解配置一个cron表达式每个整分钟跑一次扫单任务。这个方案简单直接不需要引入分布式任务调度框架。Scheduled(cron 0 */1 * * * *) public void cancelTimeoutOrders() { Date deadline new Date(System.currentTimeMillis() - 30 * 60 * 1000); ListOrder orderList orderMapper.selectList( new LambdaQueryWrapperOrder() .eq(Order::getStatus, 0) .lt(Order::getCreateTime, deadline)); for (Order order : orderList) { order.setStatus(4); orderMapper.updateById(order); // 可在此处恢复库存 } }这里有个容易忽视的问题就是取消订单后要不要恢复库存。我在生成订单时已经扣减了库存如果订单取消后库存不恢复下一次点餐时相同菜品就买不了了。所以我在取消订单的逻辑里为订单明细中的菜品重新增加库存量保证数据闭环。3.4 管理端销售统计报表的实现统计报表是管理员最关心的功能之一。我用一个统计接口返回三组数据今日营业额、今日订单数、菜品销量排行。营业额统计的是状态为已支付、已出餐和已完成的订单总金额因为待支付订单还没有真正入账。销量排行我用了MyBatis自定义SQL把订单明细表按菜品id分组统计数量总和后排序。这个SQL看起来简单但需要注意日期过滤条件。比如统计今日销量订单明细表本身没有下单时间字段需要join订单表或者在下单时将时间冗余到明细表中。我在设计时选择了join方式查询效率在数据量不大的情况下完全够用。select idselectTopDishes resultTypemap SELECT d.name AS dishName, SUM(od.count) AS totalCount FROM order_detail od JOIN orders o ON od.order_id o.id JOIN dish d ON od.dish_id d.id WHERE o.status IN (1, 2, 3) if teststartTime ! null AND o.create_time gt; #{startTime} /if if testendTime ! null AND o.create_time lt; #{endTime} /if GROUP BY od.dish_id ORDER BY totalCount DESC /select前端展示报表时我用的是简单柱状图读者如果想增加效果可以考虑接入图表库。统计功能放在独立的管理员菜单下不在顾客端暴露。4. 部署运行全流程与避坑实录4.1 环境准备与项目导入步骤从零开始运行这套系统需要准备好以下环境JDK 8或JDK 17、Maven 3.6以上、MySQL 5.7或8.0、IDEA。如果不想用IDEA用Eclipse或VS Code也可以但IDEA对Spring Boot项目的支持最顺手所以下面的步骤都以IDEA为例。整个导入运行流程是这样的先在MySQL中创建数据库将项目中的sql文件导入然后打开IDEA导入项目等待Maven下载依赖接着修改application.yml里的数据库账号密码最后运行主类启动项目浏览器访问本地地址。这里有一个最容易出错的点MySQL 8.0和5.7之间的驱动配置不一样。MySQL 5.7通常使用com.mysql.jdbc.DriverMySQL 8.0则需要使用com.mysql.cj.jdbc.Driver同时在JDBC URL后面要加上serverTimezoneAsia/Shanghai参数否则写入时间时会提示服务器时区无法识别。spring: datasource: url: jdbc:mysql://localhost:3306/restaurant?useUnicodetruecharacterEncodingUTF-8serverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver如果项目使用MyBatis-Plus还要检查配置文件中的Mapper路径是否扫到。我的项目在启动类上加了MapperScan指定包路径这样比在每一个Mapper接口上添加Mapper注解更省事。4.2 常见异常及排查速查说明很多人在部署这套系统时遇到的问题其实高度重复。我整理了一张排查清单按照出现的频率排序。问题之一端口被占用。Spring Boot默认为8080端如果本机已有程序占用启动日志会提示Address already in use。解决办法是在application.yml里修改server.port或者找到占用进程后杀掉我通常习惯直接改到8081避免影响其他开发服务。问题之二数据库连接失败。启动时提示Communications link failure首先要检查MySQL服务是否启动其次要确认账号密码是否有误最后检查URL是否漏掉了时区参数。还有一个常见错误是改完密码后没有重启Tomcat及对应连接池旧连接没有刷新。问题之三页面样式加载失败。这个问题在我第一次开发时就遇到过排查后发现是拦截器拦截了static目录下的静态资源。解决方法是新增WebMvcConfigurer配置类在addInterceptors中排除静态资源路径。问题之四中文乱码。过滤器的编码设置没有生效或者数据库本身字符集不是utf8mb4。我建库时统一使用utf8mb4字符集既支持中文又能兼容Emoji表情前端页面显示更友好。问题之五Transactional没有生效。这通常是因为方法被同类内部调用导致代理对象没有被使用。事务是通过AOP代理实现的同类内部直接调用不经过代理所以不会开启事务。解决办法是把需要事务的方法拆到另一个Service中或者通过AopContext拿到当前代理对象。除此之外还有一个容易踩的坑MyBatis-Plus分页插件记得注册为Bean如果不注册分页查询会导致SQL不规范而报错。配置方式是在配置类中新建MybatisPlusInterceptor加入PaginationInnerInterceptor即可。4.3 从课程项目到商用环境的升级建议很多人做完这套系统之后会问我如果真的拿去给一个小型餐厅用还需要补哪些功能我觉得至少要考虑以下几点。第一点是安全加固。目前使用MD5加盐存储密码虽然能满足学习演示的需求但生产环境建议更换为BCrypt加密并引入登录失败次数限制和验证码机制防止暴力破解。接口层面可以增加防重复提交校验防止用户连续点击支付按钮生成多笔订单。第二点是支付方式。模拟支付接口如果要升级为真实支付需要引入微信或支付宝的SDK同时要考虑回调地址配置、证书管理和异常对账。这笔工作量不小纯粹的实验室项目一般不用做到这一步但如果你在简历中写“对接了支付功能”就要把回调验签和订单幂等性讲清楚。第三点是前端展示。为了降低项目上手门槛目前的前端页面没有采用前后端分离架构模板引擎直接渲染页面。如果团队分工明确需要把后端接口抽象成纯JSON接口前端用Vue或React框架独立部署方便并行开发。改造难度不大因为后端接口已经全部封装了统一返回格式。第四点是部署方式。当前本地启动直接运行main方法如果部署到云服务器最好打成一个可执行jar包部署mvn clean package -DskipTests nohup java -jar restaurant-system.jar --spring.profiles.activeprod server.log 21 配置文件中根据环境切换数据库地址、Redis地址和文件上传路径生产环境不要使用默认密码。我个人把这种单体点餐系统视为Web开发的“综合练习题”它的价值不在于功能多炫而在于让你把需求分析、数据库设计、接口实现、权限控制、状态管理、统计查询这一整套流程走通。遇到报错不要慌90%的问题都能从启动日志里找到线索。调通一个复杂功能记得顺手记录很多经验放在笔记里比放在脑子里更持久。如果读者按照本文思路把这套系统完整做一遍我相信你对Spring Boot和数据库设计会有完全不一样的理解。