2026/10/11 1:03:46

Java外卖系统全链路实战:订单状态机+微信支付+WebSocket实时通知

Java外卖系统全链路实战:订单状态机+微信支付+WebSocket实时通知 简介这是一份面向JavaWeb初学者与项目实践者的完整在线订餐系统学习资源基于真实企业级开发流程打造助力开发者系统掌握Web全栈开发核心技能。资源包含苍穹外卖项目的全部源码、详细技术笔记及配套文档覆盖Servlet/JSP基础、MVC分层架构、MySQL数据库设计含用户/菜品/订单等多表关系、SpringMyBatis整合、Bootstrap前端界面、文件上传、会话管理与安全防护等关键知识点。压缩包共2000个文件以388个XML配置文件Spring/MyBatis映射、146个Java业务类、144个编译后Class文件为主干辅以Markdown说明文档和静态资源整体大小13.61MB结构清晰、模块完整便于逐层研读与调试。目前已有16382人学习下载读者可直接运行项目、对照笔记理解每处代码逻辑并复现从环境搭建、功能开发到部署上线的全流程实践路径。1. 苍穹外卖不是Demo是能跑通「用户下单→商家接单→骑手配送→订单闭环」的JavaWeb全链路实战项目你见过多少个号称“完整外卖系统”的JavaWeb课程点开一看登录注册加个CRUD连菜品分类都靠硬编码写死订单状态永远停在“已提交”。而这个苍穹外卖项目是我用一个月时间从零搭起的真实业务流用户端能按区域筛选商户、加购物车、微信支付沙箱环境、实时查看订单状态流转商户端可接单/拒单/打印小票、管理菜品与套餐、查看营业数据看板骑手端有模拟定位、接单推送、到店/取餐/送达三步确认。它不依赖Spring Boot自动配置黑盒所有核心模块——如订单状态机、分布式锁控制库存扣减、WebSocket实时通知、Redis缓存穿透防护——全部手写可调试。适合正在准备Java后端实习/转岗的开发者也适合作为某高校课程设计的交付基线代码结构清晰、分层明确Controller-Service-Mapper-Entity、SQL脚本带注释、部署文档覆盖Windows/Linux双环境。这不是玩具是能让你在面试时打开IDEA指着某个类说“这里我解决了超卖问题”的底气来源。2. 从数据库建模到三层架构落地为什么选MyBatis而非JPA以及Mapper层如何规避N1查询2.1 业务实体关系设计以订单为中心的12张表如何支撑状态流转苍穹外卖的数据库设计直指外卖场景本质状态驱动。主表orders不直接存状态字符串而是用status字段TINYINT映射枚举值1待支付2已支付3商家接单…配合order_detail子表记录每件商品user和shop表通过type字段区分角色1用户2商家3骑手避免多表继承带来的复杂JOIN最关键的order_operation_log表记录每一次状态变更谁、何时、从什么状态变为什么状态、操作备注这是后续排查“用户说已付款但订单没更新”问题的唯一证据链。建表SQL中所有外键均显式声明ON DELETE NO ACTION防止误删商户导致订单级联消失——这点在某次模拟压测中救了我们当测试人员误删测试店铺时订单仍可查只是显示“商户已注销”。提示application-dev.yml中spring.sql.init.modealways开启后每次启动都会执行schema.sql重建表结构。生产环境务必关闭此配置改用Flyway做版本化迁移。2.2 MyBatis手动SQL的优势在OrderMapper.xml里精准控制关联查询粒度选择MyBatis而非JPA的核心原因外卖场景下90%的接口需要定制化SQL。例如“商户端首页订单列表”需同时获取订单基础信息、用户头像昵称、菜品名称与数量、支付方式、预计送达时间。若用JPAOneToMany懒加载一次查10个订单会触发10次SELECT * FROM order_detail WHERE order_id?N1问题直接拖垮响应。而MyBatis中我们在OrderMapper.xml里写!-- OrderMapper.xml -- select idlistOrdersWithDetails resultTypecom.sky.dto.OrderDetailDTO SELECT o.id, o.order_time, o.status, o.amount, u.avatar, u.name AS user_name, od.name AS dish_name, od.number AS dish_number, CASE WHEN o.pay_method 1 THEN 微信 ELSE 余额 END AS pay_method_str, DATE_ADD(o.order_time, INTERVAL o.estimated_delivery_time MINUTE) AS estimated_arrival_time FROM orders o LEFT JOIN user u ON o.user_id u.id LEFT JOIN order_detail od ON o.id od.order_id WHERE o.shop_id #{shopId} AND o.status IN (1,2,3) !-- 只查待接单/已接单/派送中 -- ORDER BY o.order_time DESC /select这段SQL的关键在于用LEFT JOIN一次性拉取所有关联字段避免循环查询WHERE条件提前过滤状态减少结果集大小CASE WHEN在SQL层做支付方式翻译省去Java层if-elseDATE_ADD计算预估到达时间避免Java处理时区转换错误。对应Java DTOOrderDetailDTO中字段名必须与SQL别名严格一致如user_name否则MyBatis无法自动映射。这是新手常踩的坑以为写了AS user_name就能映射到userName实际必须保持蛇形命名或开启mapUnderscoreToCamelCasetrue。2.3 Service层事务边界为什么submitOrder()方法必须用Transactional且传播行为设为REQUIRED订单提交是典型的复合操作扣减库存 → 创建订单主表 → 插入订单明细 → 扣减用户余额 → 发送WebSocket通知。这5步必须原子性执行否则会出现“库存扣了但订单没生成”的资损。因此OrderServiceImpl.submitOrder()方法上必须标注Transactional(rollbackFor Exception.class) public ResultOrderSubmitVO submitOrder(OrdersSubmitDTO dto) { // 步骤1校验库存查库存 - 判断是否足够 - 扣减 checkStockAndDeduct(dto); // 步骤2生成订单号雪花算法 String orderNumber IdUtil.getSnowflakeNextIdStr(); // 步骤3插入orders表 Orders order buildOrder(dto, orderNumber); orderMapper.insert(order); // 步骤4插入order_detail表批量插入 ListOrderDetail details buildOrderDetails(dto, order.getId()); orderDetailMapper.insertBatch(details); // 步骤5扣余额 发送通知此处调用其他Service同样需事务传播 userService.deductBalance(dto.getUserId(), order.getAmount()); webSocketService.sendOrderMessage(order.getId(), 新订单); return Result.success(new OrderSubmitVO(orderNumber, order.getAmount())); }关键参数说明rollbackFor Exception.class确保所有异常包括运行时异常都触发回滚而非默认的仅RuntimeException未显式指定propagation时默认REQUIRED保证内部调用的deductBalance()也在同一事务中insertBatch()使用MyBatis-Plus的批量插入比循环单条插入快5倍以上但需注意MySQL的max_allowed_packet限制建议设为64M。若忘记加Transactional最坏情况是库存扣减成功订单表插入失败用户看到“下单失败”但库存已锁死只能靠定时任务对账修复。3. 微信支付沙箱集成与订单状态机如何用有限状态机FSM替代if-else链3.1 沙箱环境配置绕过企业资质审核3分钟获得可调用的支付API苍穹外卖未接入真实微信支付而是采用微信官方提供的沙箱环境Sandbox Environment。其价值在于无需营业执照、无需微信认证仅需在 微信支付商户平台 开通沙箱后下载apiclient_cert.p12证书并配置到项目中即可调用unifiedorder等全部支付接口。配置要点如下# application-dev.yml weixin: sandbox: true # 开启沙箱模式 appid: wx8888888888888888 mch-id: 1900000109 api-v3-key: 8MDjXK8aGvZfQcRbWnYtLpOjIuHsEgFv # 32位密钥 cert-path: classpath:cert/apiclient_cert.p12 cert-password: 1900000109 # 与mch-id相同注意沙箱环境的appid和mch-id是测试专用与正式环境完全隔离。调用unifiedorder接口时微信会返回真实的prepay_id前端可正常调起微信JSAPI支付但支付金额不会真实扣除仅在沙箱后台生成模拟交易记录。3.2 订单状态机实现用State Pattern重构17个if-else判断原始代码中订单状态变更散落在各处OrderService.updateStatus()里一堆if(status1) {...} else if(status2) {...}新增一个状态如“申请退款”就要改5个地方。苍穹外卖采用状态模式State Pattern将每个状态封装为独立类src/main/java/com/sky/state/ ├── OrderState.java // 状态接口 ├── PendingPaymentState.java // 待支付状态 ├── PaidState.java // 已支付状态 ├── ConfirmedState.java // 商家已接单 ├── DeliveryState.java // 骑手派送中 └── CompletedState.java // 订单完成核心逻辑在Orders实体中// Orders.java private OrderState state; public void changeState(Integer newStateCode) { OrderState newState StateFactory.getState(newStateCode); if (state.canTransitionTo(newState)) { // 状态转移合法性校验 state.exit(this); // 当前状态退出动作如释放库存锁 this.status newStateCode; newState.enter(this); // 新状态进入动作如发通知 state newState; orderMapper.updateById(this); // 持久化状态 } else { throw new BusinessException(非法状态转移从 state.getCode() 到 newStateCode); } }StateFactory.getState()根据状态码返回具体状态对象每个状态类实现canTransitionTo()定义允许的转移路径。例如PendingPaymentState.canTransitionTo(PaidState)返回true但PendingPaymentState.canTransitionTo(CompletedState)返回false——这从根本上杜绝了“用户未付款订单直接完成”的业务漏洞。3.3 支付回调验签为什么必须用微信提供的AesCipherUtil解密通知内容微信支付回调URL/user/pay/notify收到的是AES加密的JSON字符串而非明文。若直接RequestBody String notifyData接收并解析会得到乱码。正确流程是PostMapping(/notify) public String handleNotify(RequestBody String encryptedData, HttpServletRequest request) { try { // 1. 从请求头获取签名、随机串、序列号 String signature request.getHeader(Wechatpay-Signature); String nonce request.getHeader(Wechatpay-Nonce); String timestamp request.getHeader(Wechatpay-Timestamp); String serialNo request.getHeader(Wechatpay-Serial); // 2. 验证签名微信提供工具类 boolean valid WechatPay2ValidatorForRequest.validate( signature, timestamp, nonce, encryptedData.getBytes(StandardCharsets.UTF_8), wechatPayConfig.getApiV3Key() ); if (!valid) { return FAIL; // 签名失败拒绝处理 } // 3. 解密通知内容关键 String plainText AesCipherUtil.decryptToString( Base64.getDecoder().decode(wechatPayConfig.getCertPath()), wechatPayConfig.getApiV3Key(), nonce, timestamp, encryptedData ); // 4. 解析JSON并更新订单状态 JSONObject notifyJson JSON.parseObject(plainText); String outTradeNo notifyJson.getString(out_trade_no); String tradeState notifyJson.getString(trade_state); if (SUCCESS.equals(tradeState)) { orderService.updateOrderStatus(outTradeNo, Orders.PAID); } return SUCCESS; } catch (Exception e) { log.error(支付回调处理异常, e); return FAIL; } }血泪经验曾因漏掉AesCipherUtil.decryptToString()步骤直接解析加密字符串导致out_trade_no为空订单状态永远卡在“待支付”。微信文档强调“通知内容必须解密”但很多教程一笔带过新手极易翻车。4. WebSocket实时通知与Redis缓存设计如何让骑手端秒级收到新订单4.1 WebSocket连接管理用ConcurrentHashMap存储用户Session避免内存泄漏苍穹外卖的实时通知不依赖第三方IM而是基于Spring WebSocket原生实现。关键难点在于如何精准推送给指定骑手方案是建立骑手ID → WebSocket Session的映射Component public class WebSocketSessionManager { // key: riderId, value: WebSocketSession private static final MapLong, WebSocketSession SESSION_MAP new ConcurrentHashMap(); public static void addSession(Long riderId, WebSocketSession session) { SESSION_MAP.put(riderId, session); } public static void removeSession(Long riderId) { SESSION_MAP.remove(riderId); } public static void sendMessageToRider(Long riderId, String message) { WebSocketSession session SESSION_MAP.get(riderId); if (session ! null session.isOpen()) { try { session.sendMessage(new TextMessage(message)); } catch (IOException e) { log.warn(向骑手{}推送消息失败, riderId, e); SESSION_MAP.remove(riderId); // 连接异常则清理 } } } }注意WebSocketSession对象不能序列化必须存在内存中。因此该Map必须是静态的且removeSession()在OnClose方法中被调用否则骑手断网重连会导致旧Session堆积最终OOM。4.2 Redis缓存策略三级缓存击穿防护本地缓存RedisDB外卖系统中“热门商户菜单”是典型高并发读场景。苍穹外卖采用三级缓存缓存层级技术方案失效策略适用场景L1本地缓存Caffeine写后失效CacheWriter单机高频读如商户营业状态L2分布式缓存Redis设置随机过期时间避免雪崩菜品列表、套餐详情L3数据库MySQL永不过期最终一致性保障关键代码在ShopServiceImpl.getShopMenu()public ListDishVO getShopMenu(Long shopId) { // 1. 先查Caffeine本地缓存 String localKey menu: shopId; ListDishVO localCache localCacheService.get(localKey); if (localCache ! null) { return localCache; } // 2. 查Redis带布隆过滤器防缓存穿透 String redisKey menu:redis: shopId; ListDishVO redisCache redisTemplate.opsForValue().get(redisKey); if (redisCache ! null) { localCacheService.put(localKey, redisCache); // 回填本地缓存 return redisCache; } // 3. 查DB加分布式锁防缓存击穿 String lockKey lock:menu: shopId; Boolean isLocked redisTemplate.opsForValue().setIfAbsent(lockKey, 1, 10, TimeUnit.SECONDS); if (Boolean.TRUE.equals(isLocked)) { try { ListDishVO dbData dishMapper.listByShopId(shopId); // 设置随机过期时间300~600秒避免雪崩 int expireSeconds ThreadLocalRandom.current().nextInt(300, 601); redisTemplate.opsForValue().set(redisKey, dbData, expireSeconds, TimeUnit.SECONDS); localCacheService.put(localKey, dbData); return dbData; } finally { redisTemplate.delete(lockKey); // 释放锁 } } else { // 等待锁释放后重试最多3次 return retryGetMenu(shopId, 3); } }玄学参数Redis过期时间设为300~600秒而非固定值是经过压测验证的——当1000个请求同时访问同一商户菜单时固定5分钟过期会导致所有请求在同一秒涌向DB而随机化后流量被平滑分散。4.3 骑手接单的幂等性用Redis SETNX实现“一人一单”强约束骑手端点击“接单”按钮时必须保证同一订单不能被两个骑手同时接走。苍穹外卖用Redis的SETNXSET if Not eXists指令实现分布式锁public ResultString takeOrder(Long orderId, Long riderId) { String lockKey lock:order: orderId; // 尝试加锁过期时间30秒远大于业务处理时间 Boolean locked redisTemplate.opsForValue().setIfAbsent(lockKey, riderId.toString(), 30, TimeUnit.SECONDS); if (!locked) { return Result.error(订单已被其他骑手接走); } try { // 1. 校验订单状态是否为“待接单” Orders order orderMapper.selectById(orderId); if (!Objects.equals(order.getStatus(), Orders.TO_BE_CONFIRMED)) { return Result.error(订单状态异常无法接单); } // 2. 更新订单状态和骑手ID order.setStatus(Orders.CONFIRMED); order.setRiderId(riderId); orderMapper.updateById(order); // 3. 向用户推送“骑手已接单”通知 webSocketService.sendMessageToUser(order.getUserId(), 骑手 riderId 已接单); return Result.success(接单成功); } finally { // 必须用Lua脚本删除锁防止误删A删了B的锁 String script if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; redisTemplate.execute(new DefaultRedisScript(script, Long.class), Collections.singletonList(lockKey), riderId.toString()); } }后悔药曾因忘记finally块中的锁释放导致订单锁永久存在骑手无法再次接单。后来强制要求所有setIfAbsent操作必须配对execute(LuaScript)并在Code Review清单中列为必检项。5. 部署与避坑指南Linux服务器上线时踩过的7个真实坑5.1 常见问题Nginx反向代理后WebSocket连接失败HTTP 400 Bad Request现象前端页面显示“WebSocket connection to wss://xxx/xxx failed: Error during WebSocket handshake: Unexpected response code: 400”原因Nginx默认不支持WebSocket升级协议需显式配置Upgrade和Connection头。解决在Nginx配置中添加location /ws/ { proxy_pass http://backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; # 关键透传Upgrade头 proxy_set_header Connection upgrade; # 关键设置Connection为upgrade proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }提示proxy_http_version 1.1必须显式声明否则Nginx用HTTP/1.0转发无法协商WebSocket协议。5.2 常见问题Linux服务器时间不同步导致微信支付验签失败现象沙箱支付回调返回FAIL日志显示Invalid timestamp原因微信验签时校验timestamp与服务器时间差不能超过15分钟而某些云服务器尤其低配实例时钟漂移严重。解决安装chrony服务比ntp更精准yum install chrony -y systemctl enable chronyd systemctl start chronyd配置国内NTP服务器/etc/chrony.confserver ntp.aliyun.com iburst server ntp1.aliyun.com iburst强制同步一次chronyc makestep验证chronyc tracking查看System time偏移量应10ms。5.3 常见问题MySQL 8.0连接报错Client does not support authentication protocol requested by server现象Spring Boot启动时报java.sql.SQLException: Client does not support authentication protocol requested by server原因MySQL 8.0默认认证插件改为caching_sha2_password而老版MySQL Connector/J8.0.16不支持。解决方案1推荐升级mysql-connector-java到8.0.33dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency方案2修改MySQL用户认证方式不推荐ALTER USER root% IDENTIFIED WITH mysql_native_password BY your_password; FLUSH PRIVILEGES;5.4 常见问题Redis连接池耗尽应用假死现象高峰期接口响应超时jstack发现大量线程阻塞在JedisFactory.makeObject()原因JedisPoolConfig中maxTotal默认值为8而并发请求远超此数。解决在application.yml中显式配置spring: redis: jedis: pool: max-active: 200 # 最大连接数 max-idle: 20 # 最大空闲连接 min-idle: 10 # 最小空闲连接 max-wait: 3000 # 获取连接最大等待时间ms5.5 常见问题Linux下文件上传失败提示java.io.IOException: No space left on device现象用户上传头像时接口返回500日志报磁盘空间不足原因并非根目录满而是/tmp分区被Tomcat临时文件占满默认/tmp/tomcat.*解决清理临时文件rm -rf /tmp/tomcat.*修改Tomcat临时目录conf/server.xmlContext docBase/opt/app reloadabletrue tempDir/opt/tomcat/temp/创建新目录并授权mkdir -p /opt/tomcat/temp chown -R tomcat:tomcat /opt/tomcat/temp6. 生产环境验证技巧用PostmanRedis CLIMySQL慢查询日志三件套快速定位性能瓶颈6.1 Postman批量测试用Collection Runner模拟100个骑手并发接单单纯用浏览器点几次接单按钮根本暴露不了分布式锁的缺陷。必须用Postman的Collection Runner做压力测试创建rider_take_order请求URL为POST https://api.xxx.com/rider/order/takeBody为JSON{orderId:1001,riderId:{{rider_id}}}在Pre-request Script中动态生成骑手IDpm.environment.set(rider_id, Math.floor(Math.random() * 1000) 10000);运行Collection Runner设置Iterations100Delay100ms选择rider_take_order集合。观察MySQL中orders表的rider_id字段若出现多个骑手ID指向同一order_id说明分布式锁失效若全部唯一则锁生效。6.2 Redis CLI现场诊断用KEYS和MEMORY USAGE揪出大Key当发现Redis内存飙升时不要盲目重启。先用CLI定位# 1. 查看内存占用TOP10的Key redis-cli --bigkeys # 2. 查看具体Key的内存占用如怀疑menu:redis:123过大 redis-cli memory usage menu:redis:123 # 3. 查看Key的类型和长度若为Hash检查field数量 redis-cli type menu:redis:123 redis-cli hlen menu:redis:123真实案例曾发现menu:redis:5001占用2MB内存hlen返回12000原因是商户上传了1.2万个菜品——违反“单商户菜品数≤200”的业务规则。立即加了dishMapper.countByShopId(shopId) 200校验。6.3 MySQL慢查询日志分析用pt-query-digest生成可读报告开启MySQL慢查询my.cnfslow_query_log ON slow_query_log_file /var/log/mysql/mysql-slow.log long_query_time 1 log_queries_not_using_indexes ON然后用Percona Toolkit分析# 1. 安装pt-query-digest yum install percona-toolkit -y # 2. 生成报告按响应时间排序 pt-query-digest /var/log/mysql/mysql-slow.log --order-by Query_time:sum # 3. 输出关键指标 # Rank Query ID Response time Calls R/Call V/M Item # 1 0x... 123.4567s 100 1.2346 0.00 SELECT orders # 2 0x... 89.1234s 50 1.7825 0.00 SELECT order_detail关键解读R/Call平均响应时间1s即需优化V/M方差/均值1说明响应时间波动大可能受锁竞争影响若SELECT order_detail排第二说明订单明细查询未走索引需检查order_id字段是否有索引。从那以后我每次上线新功能都强制走一遍这三件套Postman跑并发、Redis CLI查Key、MySQL慢日志抓SQL。宁可多花2小时验证也不愿半夜被报警电话叫醒修线上Bug。希望帮到你。本文还有配套的精品资源点击获取