ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java Web实战:仿必胜客在线订餐系统全流程开发与避坑指南

Java Web实战:仿必胜客在线订餐系统全流程开发与避坑指南

1. 项目缘起与整体设计思路

做Web开发的朋友大概都有这种体会:CRUD写多了容易麻木,但真正能把一个业务闭环跑通的项目,往往能暴露出大量真实工程问题。这个“仿必胜客在线订餐系统”就是我在带几个新人练手时选定的题目——它看起来只是个点餐页面加购物车,但真动手做起来,涉及用户体系、菜单管理、订单状态机、库存扣减、支付模拟、后台运营等一整套链路,麻雀虽小五脏俱全。

先说清楚这个系统到底是什么。它是一个基于Java技术栈的B/S架构Web应用,前端面向普通顾客,提供浏览菜单、加购物车、下单、查看订单状态等能力;后端面向门店运营人员,提供菜品上下架、订单处理、简单数据统计等功能。技术上采用Servlet/JSP或Spring Boot + MyBatis的经典组合,数据库用MySQL,前端用HTML/CSS/JavaScript配合少量模板引擎。它解决的问题很直接:把“顾客点餐—系统记录—后厨处理—订单完成”这条线用代码串起来,而不是停留在单个页面的静态展示。

适合谁来参考?我认为有三类人。第一类是刚学完Java基础、Servlet和JDBC,想找一个完整项目练手的在校学生;第二类是准备面试、需要一个能讲清楚业务逻辑的项目写进简历的求职者;第三类是想快速搭一个点餐类Demo做二次开发的小团队。这个项目的价值不在于技术多高深,而在于它足够完整、足够真实,能让你在实现过程中踩到那些“只有做过才知道”的坑。

整体设计上,我坚持一个原则:先跑通主流程,再补细节。很多新手一上来就想着把界面做得多漂亮、功能做得多全,结果主流程还没通就卡在某个CSS样式上。我的做法是先定义清楚核心实体和它们之间的关系,再围绕“一次下单”这个动作把链路打通,最后才去优化界面和补充边缘功能。这个顺序很重要,因为它决定了你是在“搭房子”还是在“堆砖头”。

从架构分层来看,我采用的是典型的三层结构:表现层(Controller/Servlet)、业务逻辑层(Service)、数据访问层(DAO/Mapper)。这样分层的好处是职责清晰,改一处不影响全局。比如后来我想把库存扣减从“下单即扣”改成“支付成功才扣”,只需要动Service层的一小段逻辑,Controller和DAO几乎不用改。这就是分层带来的好处,也是我在带新人时反复强调的一点:不要把所有逻辑都塞进Servlet里,那样后期维护会让你怀疑人生。

数据库设计是这个项目的骨架。核心表包括用户表、菜品分类表、菜品表、订单主表、订单明细表、购物车表(或Redis缓存)、地址表等。表与表之间的关系要提前想清楚,比如订单主表和订单明细表是一对多,菜品和分类是多对一。这些关系如果一开始没设计好,后面写查询时会非常痛苦。我见过有人把订单明细直接塞成订单表里的一个JSON字段,短期看省事,但一旦要做“统计某菜品销量”这种需求就傻眼了。

2. 核心技术点拆解与选型考量

2.1 技术栈选择:为什么是这套组合

这个项目我主推两套技术路线,供不同基础的人选择。第一套是原生Servlet + JSP + JDBC,适合刚学完JavaWeb基础、想彻底搞懂请求响应流程的人。第二套是Spring Boot + MyBatis + Thymeleaf,适合已经有一定框架基础、想快速出成果的人。两套路线的业务逻辑是一样的,区别只在于开发效率和代码组织方式。

为什么推荐原生Servlet路线给新手?因为框架帮你屏蔽了太多细节,导致很多人用了半年Spring Boot还不知道一个HTTP请求是怎么从浏览器到Servlet再到数据库的。用原生Servlet写一遍,你会被迫理解doGet和doPost的区别、request和session的作用域、转发和重定向的差异。这些知识在面试和排查问题时极其重要。我试过让新人先用Servlet写一遍登录功能,再换成Spring Boot写,他们对框架的理解明显更深。

数据库选MySQL,理由很简单:免费、资料多、和Java生态配合成熟。连接池用Druid或HikariCP,前者自带监控页面,调试时能看到SQL执行情况,对新手很友好。前端不追求花哨,Bootstrap加原生JS足够,重点是把交互逻辑写清楚。如果你非要用Vue或React做前后端分离,那这个项目的复杂度会上升一个量级,建议先把单体版本做扎实再说。

2.2 核心业务实体与关系梳理

在动手写代码之前,我习惯先画一张实体关系草图。这个系统的核心实体有:用户(User)、菜品分类(Category)、菜品(Dish)、购物车项(CartItem)、订单(Order)、订单明细(OrderItem)、收货地址(Address)。它们之间的关系可以这样理解:一个用户可以拥有多个收货地址,一个用户可以有一个购物车(里面多个购物车项),一个用户可以下多个订单,一个订单包含多个订单明细,每个明细对应一个菜品。

这里有个容易踩的坑:购物车到底存哪里。存Session最简单,用户不登录也能用,但换设备就丢了;存数据库更持久,但每次操作都要读写数据库,性能差一些;存Redis是折中方案,但增加了部署复杂度。我的建议是:练手阶段存Session就够了,先把逻辑跑通。等你要做“登录后合并购物车”这种功能时,再考虑持久化。我当初就是先用Session实现,后来发现用户登录后购物车丢失的问题,才补上了合并逻辑,这个过程本身就是很好的学习经历。

订单状态的设计也很关键。我定义了六个状态:待支付、已支付、制作中、配送中、已完成、已取消。状态之间的流转必须严格约束,比如“已完成”不能回到“待支付”,“已取消”不能变成“配送中”。这个约束我建议放在Service层用状态机模式实现,而不是散落在各个Controller里。否则一旦业务变复杂,你会发现自己在一堆if-else里迷失方向。

2.3 关键技术难点预判

这个项目有几个技术点,新手做的时候大概率会卡住,我提前说一下。第一个是库存并发扣减。如果两个用户同时下单同一道菜,库存只有一份,怎么保证不超卖?简单做法是在SQL里用update dish set stock = stock - 1 where id = ? and stock > 0,靠数据库的行锁保证原子性。进阶做法是用乐观锁加版本号,或者用Redis做预扣减。练手项目用第一种就够了,但你要知道有这个问题存在。

第二个是订单号生成。不要用自增ID直接当订单号,那样会暴露业务量,而且分库分表时会出问题。常见做法是“时间戳 + 用户ID后几位 + 随机数”,或者用雪花算法。我一般用yyyyMMddHHmmss + 4位随机数,简单够用,注意加唯一索引防止重复。

第三个是金额计算精度。千万不要用double或float存金额,浮点数运算会有精度丢失,0.1 + 0.2不等于0.3这种事在金额上就是事故。统一用BigDecimal,数据库用decimal类型。这个坑我见过太多人踩,包括一些工作一两年的人。

3. 实操过程与核心环节实现

3.1 环境搭建与项目骨架

先说一下环境准备。JDK用1.8或11都行,我倾向11,因为长期支持且语法更现代。Maven管理依赖,Tomcat用9.x(如果你用Servlet路线)。IDE用IntelliJ IDEA社区版就够,数据库用MySQL 5.7或8.0。这些工具的安装网上教程很多,我不赘述,重点说项目骨架怎么搭。

如果用Maven创建Web项目,pom.xml里至少要引入这些依赖:javax.servlet-api(provided scope)、mysql-connector-java、druid或hikari、jstl(如果用JSP)、lombok(可选,减少getter/setter代码)。如果是Spring Boot项目,用Spring Initializr生成,勾选Web、MyBatis、MySQL Driver即可。

目录结构我建议这样组织:src/main/java下按controller、service、dao、entity、util分包;src/main/resources下放application.properties或db.properties、MyBatis的mapper XML;src/main/webapp下放JSP页面和静态资源。这个结构清晰,后期找文件方便。我见过有人把所有Java文件堆在一个包里的,超过二十个类之后基本没法维护。

数据库建表我给出核心几张表的DDL思路。用户表包含id、username、password(存加密后的)、phone、create_time。菜品表包含id、category_id、name、price、stock、image、status(上架/下架)、description。订单表包含id、order_no、user_id、total_amount、status、address_id、create_time、pay_time。订单明细表包含id、order_id、dish_id、dish_name(冗余存,防止菜品改名后历史订单显示错误)、price、quantity。注意dish_name和price在明细里冗余存储,这是有意为之,因为订单是历史快照,不能因为菜品后来改价就影响历史订单。

3.2 用户模块:注册登录与权限控制

用户模块看起来简单,但细节不少。注册时要做的校验包括:用户名是否已存在、密码强度、手机号格式。密码绝对不能明文存数据库,用MD5加盐或者BCrypt。我推荐BCrypt,因为MD5已经被证明不安全,而且BCrypt自带盐值,不用自己管理。Java里可以用spring-security-crypto的BCryptPasswordEncoder,即使你不用Spring Security也能单独引入这个工具类。

登录成功后,把用户信息存到Session里,key用currentUser。后续需要登录才能访问的页面,用一个Filter统一拦截,检查Session里有没有这个key,没有就重定向到登录页。这个Filter的配置要注意放行登录页、注册页、静态资源,否则会陷入死循环。我当初就犯过这个错,Filter把登录请求也拦了,结果永远登不进去,排查了半天。

权限控制分两种角色:普通用户和管理员。管理员能访问后台管理页面,普通用户不能。实现方式可以在用户表加一个role字段,Filter里判断角色。更优雅的做法是用注解加拦截器,但练手项目用简单判断就够了。这里要注意的是,前端隐藏菜单不等于权限控制,用户完全可以手动输入URL访问后台页面,所以后端必须校验。

3.3 菜单展示与购物车实现

菜单展示的核心是分类和菜品的查询。分类列表查出来,每个分类下挂对应的菜品。这里有个N+1查询问题:如果先查所有分类,再循环查每个分类的菜品,会产生大量SQL。正确做法是一次性查出所有菜品,在Java里按category_id分组,或者用一条JOIN语句查出来。数据量小的时候感觉不到差异,但这是好习惯,要养成。

购物车的实现我用Session存一个Map,key是菜品ID,value是购物车项对象(包含菜品信息和数量)。加购物车时判断Map里有没有这个菜品,有就数量加一,没有就新增。这里要注意库存校验:加购时数量不能超过库存,下单时还要再校验一次,因为加购到下单之间库存可能变了。这个双重校验很多人会漏掉第二次。

购物车页面要实时计算总价,用BigDecimal累加。数量增减用AJAX异步请求,不刷新页面。这里前端要处理的一个细节是:数量减到1时再减应该删除该项,或者禁用减号按钮。我选择的是减到1时再减就删除,并给用户一个确认提示。这种交互细节虽然小,但直接影响用户体验。

3.4 下单流程与订单状态流转

下单是整个系统最核心也最容易出问题的环节。完整流程是:校验登录状态 → 校验购物车非空 → 校验收货地址 → 校验库存 → 扣减库存 → 生成订单主记录 → 生成订单明细 → 清空购物车 → 返回订单号。这一串操作必须放在一个数据库事务里,任何一步失败都要回滚。否则可能出现库存扣了但订单没生成,或者订单生成了但购物车没清空的情况。

事务的实现,如果用Spring,直接在Service方法上加@Transactional注解。如果用原生JDBC,要手动setAutoCommit(false),成功后commit,异常时rollback,并且要在finally里关闭连接。我强烈建议新手至少手写一次原生JDBC事务,你会对“事务”这个概念有更深刻的理解,而不是把它当成一个注解魔法。

订单状态流转我用一个枚举加状态机来控制。每个状态定义允许的下一步操作,比如“待支付”可以转到“已支付”或“已取消”,“已支付”可以转到“制作中”。Service层在处理状态变更时先校验当前状态是否允许该操作。这样即使前端传了非法请求,后端也能拦住。我见过有人直接update order set status = ?,前端传什么就改什么,这是非常危险的。

支付环节练手项目用模拟支付就行,点击“支付”按钮直接把状态改成“已支付”,记录支付时间。真实项目要对接支付渠道,涉及签名、回调、对账等,复杂度高很多。但即使是模拟支付,也要考虑重复支付的问题:用户连点两次支付按钮,不能扣两次钱。解决办法是支付前先查订单状态,只有“待支付”才能支付,并且用乐观锁或行锁防止并发。

3.5 后台管理功能实现

后台管理包括菜品管理、分类管理、订单管理、简单统计。菜品管理就是增删改查,但要注意删除菜品时如果有关联的订单明细,不能物理删除,应该改成“下架”状态。这是软删除的思想,实际项目中很常见。分类删除同理,如果分类下有菜品,要么禁止删除,要么把菜品移到默认分类。

订单管理后台要能查看订单列表、按状态筛选、修改订单状态(比如手动标记为已完成)。这里要注意操作日志,谁在什么时候把哪个订单改成了什么状态,最好记录下来。练手项目可以简化,但你要知道真实系统里这是必须的。

简单统计可以做几个维度:今日订单数、今日营业额、菜品销量排行。SQL用group by和聚合函数就能实现。营业额统计要注意只算“已完成”的订单,不能把“已取消”的也算进去。这种业务口径问题,写代码前一定要想清楚,否则数据出来是错的。

4. 常见问题与排查技巧实录

4.1 中文乱码问题

中文乱码是JavaWeb项目的高频问题,涉及三个环节:请求编码、响应编码、数据库编码。请求编码在Servlet里用request.setCharacterEncoding("UTF-8"),但注意这行代码必须在读取任何参数之前执行,否则无效。响应编码用response.setContentType("text/html;charset=UTF-8")。数据库连接URL里加useUnicode=true&characterEncoding=utf8。三个地方都设置对了,基本就不会乱码。

如果用了Spring Boot,可以在application.properties里配置server.servlet.encoding.charset=UTF-8和server.servlet.encoding.force=true,一劳永逸。但如果你发现还是乱码,检查一下数据库表和字段的字符集是不是utf8mb4,有时候建表时默认成了latin1,后面怎么改代码都没用。

4.2 数据库连接泄漏

连接泄漏的表现是:系统运行一段时间后报“too many connections”,重启后恢复,过一会又出现。原因通常是DAO层获取了连接但没关闭,或者关闭代码没放在finally里。用连接池的话,连接用完必须close()归还,不是真正关闭物理连接。我建议用try-with-resources语法,自动关闭,不容易漏。

排查方法:在MySQL里执行show processlist,看有多少连接处于Sleep状态且时间很长。如果连接数持续增长不下降,基本就是泄漏了。Druid连接池有监控页面,能看到活跃连接数和等待线程数,调试时很有用。

4.3 订单重复提交

用户网络慢的时候连点两次提交按钮,可能生成两个订单。前端可以在点击后禁用按钮,但这不是根本解决办法,因为请求可能已经发出去了。后端要做幂等控制,常见方案是:下单前生成一个唯一token存在Session里,提交时带上这个token,后端校验并删除token,重复提交时token已不存在就拒绝。或者用订单号做唯一索引,重复插入会报错,捕获异常返回友好提示。

我当初用的是token方案,实现简单且有效。要注意token只能用一次,校验后立即删除,否则起不到防重作用。这个细节很多人会忽略。

4.4 库存超卖问题

前面提过,这里展开说排查和解决。如果你发现库存变成了负数,说明并发扣减没控制好。先检查SQL是不是update dish set stock = stock - 1 where id = ?,这种写法在并发下会超卖,因为两个线程可能同时读到stock为1,然后都执行减一。正确写法是update dish set stock = stock - 1 where id = ? and stock >= 1,靠数据库的行锁和条件判断保证原子性。执行后检查影响行数,如果为0说明库存不足,要回滚事务。

更严格的场景可以用select ... for update加行锁,但性能差一些。练手项目用条件更新就够了。记住一个原则:能在一条SQL里完成的原子操作,不要拆成两条。

4.5 常见问题速查表

问题现象可能原因排查方向解决方案
页面中文乱码编码未统一检查请求、响应、数据库三处编码统一设为UTF-8
连接数暴涨连接未关闭show processlist看Sleep连接try-with-resources自动关闭
订单重复无幂等控制检查是否有防重token或唯一索引加token校验或唯一约束
库存负数并发扣减检查扣减SQL是否带条件加stock >= 1条件
金额计算错误用了浮点数检查金额字段类型改用BigDecimal和decimal
登录后购物车丢失Session未合并检查登录逻辑登录时合并临时购物车
订单状态乱跳无状态校验检查状态变更逻辑加状态机约束
后台越权访问只做了前端隐藏检查后端权限校验Filter或拦截器校验角色

4.6 几个独家避坑心得

第一个心得:先写测试数据再写代码。很多人习惯先把功能写完再手动造数据测试,效率很低。我一般先把分类、菜品、用户这些基础数据用SQL脚本插进去,写代码时随时能查库验证,调试速度快很多。而且测试数据要覆盖边界情况,比如库存为0的菜品、已下架的菜品、金额为0的订单等。

第二个心得:日志要打够。新手往往只在出错的地方打日志,其实关键流程的入口出口都应该打。比如下单方法进来时打一条“用户X开始下单,购物车N件商品”,结束时打“订单号XXX生成成功,金额YYY”。出问题时看日志就能定位到哪一步断了,不用加断点重新跑。日志级别用INFO,别用System.out.println,那个在生产环境是灾难。

第三个心得:金额字段统一用分做单位。虽然BigDecimal能处理元,但有些场景下用整数分更省心,避免小数点问题。比如数据库存price_cent,展示时除以100。这个看个人习惯,但团队里要统一,不能有的用元有的用分。

第四个心得:接口返回格式要统一。我定义一个Result类,包含code、message、data三个字段,所有接口都返回这个格式。前端处理起来方便,成功判断code为0,失败取message展示。不要有的接口返回字符串,有的返回对象,有的返回布尔值,那样前端会疯掉。

5. 项目扩展方向与个人体会

这个项目做完基础版本后,有很多可以扩展的方向。比如接入Redis缓存菜品数据,减少数据库压力;用消息队列处理订单超时未支付自动取消;用定时任务统计每日销售报表;用WebSocket实现订单状态实时推送。这些扩展不需要全做,挑一两个感兴趣的深入,就能让项目上一个台阶。

我个人在实际操作中的体会是,做项目最怕的不是技术难,而是需求不清。比如“订单超时取消”这个功能,超时时间设多少?取消后库存要不要回滚?回滚时如果菜品已下架怎么办?这些问题不提前想清楚,写到一半就会卡住。我的习惯是动手前用纸笔把流程画一遍,把异常分支都列出来,写代码时就是翻译,效率高很多。

最后分享一个小技巧:如果你要拿这个项目去面试,不要只讲用了什么技术,要讲你遇到了什么问题、怎么解决的。比如“我遇到了库存超卖问题,一开始用普通update发现并发下会出错,后来改成带条件的原子更新解决了”,这种讲述方式比罗列技术栈有说服力得多。面试官想听的是你的思考过程,不是你的技术清单。

这个系统后续还可以往“多门店”方向扩展,每个门店有独立的菜单和库存,用户下单时选择就近门店。这就涉及到门店表、门店菜品关联表、按门店分库等设计,复杂度会明显上升,但也是很好的进阶练习。等你把单门店版本吃透了,再挑战多门店,会发现自己对系统设计的理解又深了一层。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进