前段时间某同学拿了一个毕设题目来问我,标题写的是“Java智能卤菜销售平台”。乍听之下我以为这不过又是一套“万能电商系统”——商品、购物车、订单、支付,套个模板就能交差。等陪他把需求从头到尾过了一遍,我才发现这个题目比想象中更有内容:卤菜是熟食,有时效要求;晚市销售高度集中,备货多了是损耗,备少了是业绩损失;有的门店按斤称重卖,有的按份卖;顾客可以自提,也可以等配送。这一堆线下生意规则,最后都要映射到数据库字段、订单状态机和接口逻辑上,这才是项目真正的难点。
这篇文章我会从一个做完类似项目的人的角度,把“卤菜销售平台”这类行业电商项目从0到1拆开讲一遍。不聊怎么去表单里领现成源码,那种代码拿回来往往改都无从下手,答辩时老师多问两句就露怯。反而把业务梳理清楚之后,你会发现代码量没有想象中那么大。文章适合准备拿这个题目做毕设的人,也适合想让简历里的项目不那么千篇一律的开发者。
1. 卤菜生意和普通电商的差别在哪:动手写代码前先把业务拆开
1.1 先认清系统里站着几种人
做管理系统的通病是一上来就画ER图、建表,但字段常常对不上业务。我的习惯是先不碰代码,像这样重新审一下“卤菜销售”这个场景。
一个正常的卤味连锁或者单店业态,系统里至少站着三种角色:
- 顾客。关心菜单、价格、分量,能不能在下班后买到想吃的,自提等多久,配送多久到。
- 门店/后厨。早上要根据预测备货,下午高峰前开始加工,订单进来以后按顺序制作,制作完成后通知顾客取餐,打烊后还要处理当日剩余库存。
- 店长/平台管理员。要看营业额、菜品销量排行、会员复购情况、库存损耗,必要时做折扣和套餐活动。
这三类角色对应到模块上,就是C端点餐端、门店工作台、总管理后台。很多毕设只做“用户端+管理端”,把中间最关键的“门店出餐”环节丢了,演示时订单直接变成已完成,业务链路就断了。所以哪怕功能简化,这三类角色的关系也要在需求文档里占一页。
1.2 卤菜经营的特殊约束:熟食时效、晚市高峰、按斤计价
想明白角色之后,再看卤菜和普通电商的几个本质差异。
第一,熟食有时效。今天的卤味卖不完,明天基本不能再卖,这就意味着库存不能只按“商品总数”管理,还要按“当日备货量”来管理。系统里如果只有一个 stock 字段,晚上打烊后剩余量怎么处理?第二天库存怎么重新变成计划量?这些都是要在数据设计阶段回答的问题。
第二,销售高峰集中且可预测。卤菜单量高峰通常在下午四点到晚上七点,周六日和平时的曲线还不一样。如果下午六点发现招牌菜已经售罄,补货根本来不及;如果备多了,晚上九点后的大批剩余只能当损耗。所以“智能”在这个系统里的第一个实际切入点,就是“根据历史销量预测今天的备货量”。
第三,计价方式多样。半只鸡按只卖,卤牛肉按斤称,鸭脖可以按根也可以按盒,还有套餐组合。商品表如果只放一个“单价”字段,购物车和订单明细的计算逻辑根本没法写。
第四,履约链路不同。普通电商是下单、支付、仓库发货、物流配送、签收;卤菜不同,它可能是“用户到店自提”,所以要出餐码;也可能是同城配送,要出制作完成的提醒。即使毕设里配送用模拟,订单状态也必须把这些环节表达出来。
1.3 技术栈可以换,业务约束不会变
标题关键词里除了 Java,还列了 Python、PHP、小程序APP、C#。老实说,同一套业务逻辑用哪个语言都能实现,差别主要在控制器那一层的写法。我对这类毕设项目的建议是:除非你已经很熟别的语言,否则默认走 Java(Spring Boot + MyBatis Plus + Redis + MySQL)最稳。原因是这个组合在网上的资料量、踩坑案例、答辩讲解视频都是最多的,遇到问题基本搜得到答案。
如果你确实更熟 Python Django/PHP Laravel,那也没问题。数据库设计、订单状态机、库存流水、接口文档这些是跟语言无关的,先把后面的表结构和状态流转看懂,换语言只是翻译语法。这一点我会在后文反复强调:项目差异度不在框架,在于业务规则是否真的被实现了。
2. 数据表不是随便建的:卤菜平台的核心表设计与两个加分项
2.1 商品表的字段取舍:把计价方式和备货模型提前定好
一个很常见的翻车现场:建表时只给商品表放了 id、name、price、stock 这四个字段,做的时候发现按斤卖没办法下单,又临时加字段,一改字段后面几十个接口都要跟着改。所以我建议把商品表一次性设计到位。参考结构如下:
CREATE TABLE `goods` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `category_id` BIGINT NOT NULL COMMENT '分类ID', `name` VARCHAR(64) NOT NULL COMMENT '菜品名称', `price_mode` TINYINT NOT NULL DEFAULT 1 COMMENT '1按份 2按斤称重 3套餐组合价', `price` DECIMAL(10,2) NOT NULL COMMENT '单价:按份为份价,按斤为每斤价', `unit_name` VARCHAR(16) NOT NULL DEFAULT '份' COMMENT '展示单位:份/斤/盒/只', `stock` INT NOT NULL DEFAULT 0 COMMENT '当前可售库存(每日开始时重置)', `daily_plan_stock` INT NOT NULL DEFAULT 0 COMMENT '当日计划备货量', `shelf_life_hours` INT NOT NULL DEFAULT 24 COMMENT '售卖窗口时长', `image` VARCHAR(255) DEFAULT '', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1上架 0下架', `create_time` DATETIME NOT NULL, `update_time` DATETIME NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='卤菜商品表';这个表里最值得解释的两个字段是 price_mode 和 daily_plan_stock。
price_mode 解决的是“怎么计价”:前端购物车加购时,mode 为1就输入数量,mode 为2就显示秤重输入框,mode 为3就当成一个组合包。商品展示页的“价格”文案也不同,比如“39.8元/斤”和“45元/只”是两种表达。这个字段看起来小,其实牵动购物车、订单明细、结算中心三个地方。
daily_plan_stock 解决的是“当天备了多少、能卖多少”的问题。每天早上开工时,stock 被定时任务重置为 daily_plan_stock;顾客下单扣的是 stock;晚上歇业后,stock 里的剩余量单独统计成损耗,第二天清零。这样一来“熟食不过夜”的业务才能落到数据上。
2.2 订单主表与订单明细表:为什么必须拆开
订单数据是后面所有统计报表的基础,最好从一开始就做成“订单主表 + 订单明细表”两张表。订单主表记录一次交易的公共信息,订单明细表记录这一单里的每个菜品。
订单主表大致字段:
- order_no 订单号、user_id、shop_id(如果只有单店可以省略)
- order_status、pay_status 或合成一个状态
- total_amount、pay_amount、discount_amount
- take_mode(自提/配送)、pickup_code(取餐码)
- create_time、pay_time、finish_time、cancel_time
订单明细表大致字段:
- id、order_no、goods_id、goods_name(下单时的名称快照)
- snapshot_price(下单时的单价快照)
- quantity、weight(按斤卖时记录斤数)、subtotal
为什么要多存一个 goods_name 和 snapshot_price?因为商品要经常调价、改名,如果不做快照,订单接口后面做统计时 select join 一下商品表,历史订单金额就跟着变了。这是数据建模里特别重要的一课:交易数据要保留动作发生那一刻的状态,不能依赖当前主数据。答辩时能把这个理由讲清楚,面试官对数据敏感度的评分会高不少。
2.3 容易被忽略的库存流水表,关键时候能救命
还有一张表很多人不做,但它几乎是“库存对不对”这个问题的最终裁判,那就是库存流水表(stock_log)。
先描述一个真实场景:上线几天后门店来反馈,有个用户没下单但库存少了5份,或者后台显示库存20但称重台那边已经卖完了。你如果没有流水表,只能靠日志猜;有了流水表,按 goods_id 一查,哪一分钟被哪个订单扣掉的,是支付扣减还是管理员误操作,一目了然。
stock_log 的建议字段:
- id、goods_id、change_type(1下单锁定 2支付确认 3取消回补 4手工调整 5每日重置)
- change_quantity(正数为增加,负数为减少)
- before_stock、after_stock
- order_no(关联来源订单,手工调整可以为空)
- operator_id、remark、create_time
任何一个库存变更动作都要先写流水再改库存,或者在同一事务里完成。字段多了不复杂,但后面排错、做每日损耗统计都靠它。这个表一加,项目在结构上就比一般“CRUD模板”高出半档。
2.4 两个可选设计:批次表和状态流转日志
如果想把项目做得更完整,可以再加两个可选设计。
一是“制作批次”概念。卤菜是一锅一锅卤出来的,商品表里如果只存一个可售库存,无法区分“刚出锅”和“已经放了5小时”的同一款卤味。更细的业态会做一个批次表:商品ID、生产时间、保质期、可售量、已售量。毕设里如果时间紧,可以只做商品表+每日重置,但如果想聊“门店管理”,批次表会是一个非常经得起追问的亮点。
二是订单状态流转日志表。每次订单状态变化,就往表里插一条记录:order_no、from_status、to_status、operator、time。这样做不是为了炫技,而是当用户说“我的订单怎么卡在制作中”的时候,可以清楚地查出来哪一步没走、是谁操作的。对于演示和答辩来说,这个表也很能体现工程意识。
3. 从下单到取餐:订单链路里必须死磕的三个环节
3.1 一个下单请求在事务里做了什么
很多代码写到下单接口就只干了一件事——往订单表INSERT一行,然后把库存表UPDATE一下。这样写平时看着没问题,并发一上来就会出现超卖。
建议的下单事务流程是:
- 校验商品是否上架、计价方式是否与购物车一致,数量或重量必须大于0。
- 在同一个事务里,对每个商品执行带条件的扣减语句。
- 只要有一个商品受影响行数为0,就抛异常回滚,整个订单创建失败。
- 用 BigDecimal 在服务端计算金额,前端传过来的金额只做展示参考。
- 写订单主表和明细表,提交事务。
核心扣减语句类似:
UPDATE goods SET stock = stock - #{quantity} WHERE id = #{goodsId} AND stock >= #{quantity}对应到Java里,就是在一个 @Transactional 方法中逐商品执行:
@Transactional public OrderVO createOrder(CreateOrderRequest req) { // 第1步:校验商品状态、数量 // 第2步:扣库存,受影响行数为0则抛出“库存不足” for (CartItem item : req.getItems()) { int rows = goodsMapper.deductStock(item.getGoodsId(), item.getQuantity()); if (rows == 0) { throw new BizException("菜品【" + item.getGoodsName() + "】库存不足"); } } // 第3步:服务端计算金额 // 第4步:写订单主表、明细表 }“先扣库存、后写订单”的顺序也有讲究。如果顺序反过来,两个并发请求都读到了库存还剩5份,都插入订单成功,然后再各自扣一遍库存,第6份就被卖出去了。数据库的条件更新让数据库去仲裁并发,是成本最低、也最好向答辩老师解释的方案。
还有一个小细节:用户把商品加入购物车、在购物车页停留了很久,下单时库存早被别人扣光了。此时不要把用户直接丢到一个“库存不足”的空白页,可以把仍可购买的同类菜品推荐出来,或者提示“该菜品已售罄,可更换同类”。这个交互在演示时很加分。
3.2 熟食订单的状态机:不能一路“待支付→已完成”
普通电商订单状态是“待付款、已付款、待发货、已发货、已完成”,卤菜订单如果照搬,会漏掉门店制作和取餐这两个核心环节。我建议的状态集如下:
- PENDING_PAY:待支付,库存已锁定。
- PAID:已支付,等待门店接单/制作。
- MAKING:制作中,后厨正在做。
- READY:待取餐/待配送。自提订单生成取餐码,配送订单等待骑手取货。
- FINISHED:已完成。顾客取走/签收后自动或手动结束。
- CANCELED:已取消。超时未支付或用户主动取消。
- REFUNDING:售后处理中。
这七个状态每一个都在对应页面有可见动作:管理员能看到待支付的超时倒计时,门店工作台能看到“制作中”的排队列表,用户端能看到“待取餐”和取餐码。哪怕配送只是模拟,中间过程也不能省。
状态变更尽量别散落在业务代码里到处改,建议收敛成一个“订单状态流转服务”。在策略里定义好哪个状态允许被哪个动作触发。比如“已支付”状态下,用户不能重复支付,后厨才能点击开始制作。这样做的好处是,以后要加一个“门店拒绝接单”的状态,不需要把七八个接口翻出来改。
附带可以做一个取餐码。最简单的方案:订单号后六位,门店维度查重,重复就随机补。取餐码不需要很复杂,但有了它,用户端“待取餐”页就不只是干巴巴的“请稍等”,而是能展示“取餐码 A307”,演示效果会好很多。
3.3 支付回调的幂等,绕不开的工程账
即使毕设用的是模拟支付,我也强烈建议按真实支付回调的幂等逻辑来写。真实场景里,支付平台因为网络抖动会重发回调,同一笔订单可能收到两三次通知。如果回调处理不幂等,就会出现一个订单被支付两遍、积分加两遍、库存扣两遍的严重事故。
“幂等”的核心做法:支付回调进来以后,先检查这条支付流水是不是已经处理过,已经处理过直接返回成功;没处理过时,尝试把订单从“待支付”更新为“已支付”,如果更新行数为0,说明订单状态已经有其他回调改过了,也直接返回成功;最后一次成功的才写入支付流水表。
简化逻辑可以这样:
public PayNotifyResult handleNotify(PayNotify notify) { if (payLogMapper.findByTransactionId(notify.getTransactionId()) != null) { return PayNotifyResult.success(); // 重复回调,直接返回成功 } int rows = orderMapper.updateStatusIfPengingPay(notify.getOrderNo(), OrderStatus.PAID); if (rows == 0) { return PayNotifyResult.success(); // 订单已被处理,幂等返回成功 } payLogMapper.insert(convertToPayLog(notify)); return PayNotifyResult.success(); }注意,不能只靠“先查询再更新”来判断,因为两个回调请求可能同时都查到“待支付”状态,然后都去更新。所以更新语句一定要在where里带订单状态条件,由数据库保证只有一行被改成功。把幂等建立在数据库约束上,而不是JVM锁或者缓存标记上,这才靠得住。
真实工程项目中,处理完回调以后还要发消息队列通知后厨、更新会员积分、更新营销活动数据,这些在毕设阶段不一定全做,但能说清楚“哪一个动作需要跟订单状态绑定”,足以说明你没把支付回调当摆设。
4. “智能”不靠玄学:库存预警、销量预测和推荐的朴素实现
4.1 先从“当日备货量”引出库存预警
如果项目题目不带“智能”两个字,可以只做后台管理。但既然写了“智能”,就不能只放一个单向列表。最接地气的智能功能,是把库存和销量历史结合起来做预警。
先定一个基础规则:当“剩余可售量”低于“预计接下来还会有多少人买”的时候,触发预警。计算“接下来还会有多少人买”需要一段销量预测,所以成品功能的形状就变成:
- 管理后台有个“库存预警”页面。
- 每个商品显示:今日计划备货量、已售量、剩余量、未来X小时预计销量、建议补货量。
- 低于阈值的商品红色高亮。
这个页面一出来,项目瞬间就有了“智能”的样子,而且背后的算法非常朴素。
4.2 销量预测:用最近N天同时段平均销量就够用
预测不需要上时间序列模型,理解门槛最低的是“同时段平均”。卤菜销售有明显时段性,所以不能把全天销量平均一下就去预测晚高峰,要按时间段统计。
例子:卤牛肉最近三天的16:00-19:00销量分别是35、42、38份,那未来16:00-19:00的预计销量就是:
预计销量 = (35 + 42 + 38) / 3 ≈ 38.3 份
再考虑一个安全系数,比如1.2,备货建议 = 38.3 × 1.2 - 当前剩余量。当前只剩12份时,约等于补34份。这样店长看到的不是一个冷冰冰的“库存不足”,而是“余量还要卖34份,请备货”。
具体实现可以先查询最近7天里每天同一时间段的订单明细,按商品分组统计平均值:
SELECT goods_id, AVG(cnt) AS avg_cnt FROM ( SELECT goods_id, DATE(order_time) AS d, COUNT(*) AS cnt FROM order_item WHERE order_time BETWEEN #{start} AND #{end} AND TIME(order_time) BETWEEN '16:00:00' AND '19:00:00' GROUP BY goods_id, DATE(order_time) ) t GROUP BY goods_id;写代码的时候要注意,店铺刚开业前几天的数据量不够,平均预测会失真,此时直接退化成“初始阈值预警”,比如低于5份就报警。给预测算法加一个“滑窗”而不是全量平均,效果也会更好。这些细节不一定写成功能,但答辩时能随口说出来,说明你真想过。
4.3 菜品推荐:粗粒度打分比协同过滤更靠谱
毕设里做推荐,最怕一上来就说“我用协同过滤算法”,然后代码写不出来。实际上,用用户的历史消费记录做一个粗粒度排序,演示效果就足够自然,而且很好解释。
推荐思路三步走:
- 统计用户历史订单里,消费次数最多的前两类商品分类,记为偏好分类集合。
- 从用户没买过的商品中,找出属于偏好分类的候选商品。
- 对候选项打分:偏好分类命中得高分,近7天销量高的再往上加一点分。
打分公式可以是:
得分 = 分类命中分(命中=1.0,未命中=0) × 0.6 + 商品近7天销量/该分类最高销量 × 0.4
这个公式不复杂,但已经明显比“随便弹几个商品”更有说服力。用户端首页展示“猜你喜欢”,每一行都要有逻辑来源:他是常买卤鸡类的,所以推荐卤鸡爪、卤鸡腿;他是常买卤牛肉的,就推荐卤牛腱。演示时如果有人问“推荐依据是什么”,你能直接回答“来自该用户最近三个月的分类偏好和实时销量热度”,这就足够撑起“智能”二字。
4.4 演示数据别靠手点,写脚本批量生成
没有数据的系统,看板和推荐全都是空的,演示效果会非常差。我最想提醒的一点是:别在演示前一晚上手动下几十个订单,那既浪费时间又不自然。
建议写一个测试数据生成脚本,用程序批量生成。关键要求是数据要“像真的”:
- 用户100个左右。
- 商品20个左右,价格分布在15到80元,覆盖按份、按斤、套餐三种模式。
- 订单时间集中在过去30天的16点到20点,工作日和周末销量有明显差异。
- 每单明细1到3项,客单价在20到60元之间。
- 随机让10%的订单处于取消状态,别让支付成功率达到100%。
这样一生成,库存预警、销量预测、菜品推荐、销售看板全都有数据可用,而且分布自然。至于标题里提到的爬虫、大数据,我个人不建议在毕设里去真实爬取点评平台数据,既涉及合规问题也不稳定,脚本模拟数据完全够用。等到自己做真实商业项目时,再考虑对接真实数据源。
5. 联调与演示阶段最容易翻车的四个现场
5.1 MySQL 8 时区差8小时,订单时间全乱了
这类项目大概一半的“时间不对”问题,根源不在代码,而在数据库连接的时区配置。现象很统一:后台查数据库,用数据库客户端看时间是对的;用Java查出来,所有订单时间都少了8小时。
排查思路是先确认是不是所有时间字段都乱,还是只有某个字段乱。如果只有通过Java查询的数据乱,十有八九是 JDBC 连接串没配时区。配置方法很简单,在数据库连接URL上加:
jdbc:mysql://127.0.0.1:3306/luwei_shop?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai顺手把MySQL自身时区也确认一下,执行 SQL 看 time_zone 和 system_time_zone,如果服务器是 UTC 也会出现同样的偏移。这种问题在演示前一天出现最容易让人慌,把它提前排查掉,后面所有时间统计都会稳。
5.2 double 算钱,金额变成 19.899999
联调阶段另一个高频问题:订单详情里明明写的是19.9元,结果接口返回的数值却是19.8999999999。这不是页面问题,是用了 float/double 来存储和计算金额。二进制浮点数在表示小数时会有精度丢损。
解决办法从三处同时入手:
- 数据库金额字段统一 DECIMAL(10,2)。
- Java实体类金额字段统一 BigDecimal,禁止用 Double。
- 接口返回给前端时,序列化也保留两位小数,或者转成字符串返回;前端不要自己再做加减运算,最终金额以后端为准。
顺便提醒一句,JS的浮点计算也有类似的精度问题,购物车页面如果反复在前端累加金额,也会出现 0.30000000000000004 这种诡异数字。所以下单金额计算一定放后端,前端只负责展示后端算好的值。
5.3 上传图片在本地能用,部署后全部404
本地开发时图片上传没问题,一打包部署到服务器,刷新页面图片就全裂了。这个问题十有八九是图片路径存的不是绝对路径。很多默认配置会把图片存到项目运行目录下的某个临时文件夹,比如 target/classes/static/upload,每次重启构建都被清掉。
通用解法是把上传目录设置成服务器固定磁盘路径。在 Spring Boot 里可以用配置文件把上传目录抽成变量:
upload.dir=/data/upload/然后虚拟路径映射:
@Configuration public class WebMvcConfig implements WebMvcConfigurer { @Value("${upload.dir}") private String uploadDir; @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/upload/**") .addResourceHandler("file:" + uploadDir); } }数据库里存的可以是相对路径 /upload/xxx.jpg,前端访问时拼接服务器域名访问。需要留意的是Windows和Linux路径分隔符不一样,配置里建议用变量区分,而不是写死。
5.4 跨域、请求方法和参数名三个小坑
联调到一半最常见的情况是:前端页面里点击“保存”,控制台显示接口调用成功,后端却什么都没收到,或者CORS报错。遇到这种情况不要先怀疑框架,按顺序排查:
- 先用接口调试工具直接调后端接口,确认后端本身没问题。
- 再确认前端请求方法是不是写错了,比如后端是POST,前端却用了GET。
- 再对参数名,后端是 @RequestParam("orderNo"),前端传的是 orderNo 还是 order_no,差一个字母都会拿不到。
- 最后看跨域配置。Spring Boot 里允许跨域可以加一个过滤器或者重写 addCorsMappings 方法。注意 allowedOrigins 要写前端实际域名,如果涉及携带 Cookie 就不能配成
*,否则前端拿不到会话。
如果后端给前端的返回体始终是统一结构,比如:
{ "code": 0, "message": "success", "data": {} }前端所有页面只用判断 code,联调阶段的沟通成本会少很多。这个约定要在写接口之前就定下来,不要在开发到一半再补。如果是小程序端,还要多注意一个东西:开发者工具里要勾选“不校验合法域名”,真机预览才不会被 “request: fail url not in domain list” 卡住。
做完这种“行业+电商”类型的项目后,我最大的体会是:代码本身不稀奇,稀奇的是能把一个卤菜店老板脑子里的规则翻译成数据结构和流程。真正让项目立起来的,是那些看起来不显眼的设计——库存流水表、订单快照字段、幂等处理、每日备货量与可售量分离。答辩或者面试的时候,与其背框架名头,不如把“我为什么要加这张表、为什么订单要拆两张表、为什么扣库存要带where条件”讲清楚,这几个问题一展开,项目含金量立刻不一样。
最后分享一个小技巧:演示前一定要完整跑一遍“浏览商品→加入购物车→下单→支付→出餐→取餐→完成”的闭环,把录屏存好。页面上的成功提示不用多,但是每个状态变化的背后都要能在数据库里查到记录。能给老师展示“你在系统里操作的每一步都有据可查”,才是这种管理类项目最有说服力的地方。