ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

订单模块核心链路:微信支付、事务控制与Spring Task定时关单实战解析

2026/9/7 17:30:59 拓冰建站 浏览量
订单模块核心链路:微信支付、事务控制与Spring Task定时关单实战解析 如果你正在刷苍穹外卖这套经典的Java实战项目那到了day08基本就等于进入整个项目最核心的业务闭环了。前面几天你把员工端、菜品、套餐、缓存都搞定了但从这天开始项目才真正从“管理后台能用”变成“用户真的能下单、能支付”的完整系统。day08的核心关键词就三个用户下单、微信支付、订单状态流转。顺带还会用上Spring Task做定时关单。这篇文章我会按自己过这一天的实际顺序把设计思路、代码怎么写、容易踩的坑全部过一遍给正在肝项目的你一条能直接照着走的路。day08最适合两类人一类是刚学完Spring Boot基础、想通过一个完整项目把知识串起来的初学者另一类是准备把苍穹外卖写进简历、想搞懂订单模块背后业务逻辑的求职者。不管你是哪一类核心目标都一样跑通“用户加购物车 - 提交订单 - 扫码支付 - 回调改状态 - 超时自动关单”这条链路。下文全程干货没有任何废话。1. day08到底在做什么先理清业务全貌1.1 day08在整个项目中的定位苍穹外卖这套项目分两条业务线一条是管理端也就是店员和老板用的后台系统负责菜品管理、分类管理、套餐管理、员工管理另一条是用户端也就是外卖小程序用户真实面对的部分负责浏览菜品、加入购物车、下单支付、查看订单。day01到day07做的几乎全是管理端的功能。到了day08业务视角会完全切换到用户端。这不是简单地加几个接口而是整个项目从“半成品”转向“可对外交付”的关键节点。因为你只有把单子真正下出去、钱真正收进来业务才算闭环项目在面试官眼里才算完整。day08当天的学习任务通常集中在三个模块购物车数据沉淀成订单、对接微信支付完成付款、未付款订单定时取消。这三个模块前后咬合前面写不好后面必然乱。很多人这一天会卡住不是因为代码难而是因为脑子里没有一张完整的业务流程图就开始写写到事务和回调就懵了。1.2 day08核心业务链路拆解我们可以把day08的业务链路拆成下面几段每一段对应一套代码。第一段是“提交订单”。用户确认购物车里的菜品后点击去结算后端接收一个包含地址ID、备注、预计送达时间等信息的对象。系统要做的事很多校验购物车有没有东西、校验地址是否存在、计算订单总金额、把订单主数据写入订单表、把每个菜品明细写入订单明细表、清空购物车。这一步最考验事务控制能力。第二段是“支付”。订单提交成功后前端拿到订单编号向后端发起支付请求。后端调用微信支付的统一下单接口拿到一个支付链接前端把这个链接转成二维码展示给用户。用户扫码完成支付后微信服务器会异步回调后端接口后端收到回调后更新订单状态。第三段是“超时关单”。用户下单后如果一直不付款订单就永远悬在那里既占资源又影响统计。所以需要一个定时任务每隔一分钟扫描一次待付款订单超过15分钟未支付就自动取消。这三段链路合起来就是day08的全部内容。你不需要一次全部理解但心里要有这张图下单 - 支付 - 回调 - 关单每一步都有明确的输入输出和状态变化。1.3 涉及的技术栈与前置知识day08涉及的技术点很杂但每一项都是Java后端面试的高频内容。Spring Boot的声明式事务是基础下单这种多表写入操作必须保证原子性Transactional怎么用、哪些情况下会失效这一天必须搞明白。MyBatis的批量插入、动态SQL也会用到比如一次性往订单明细表插入多条记录。微信支付Native支付是这一天的重头戏。它的核心逻辑是后端调用微信支付接口微信返回一个code_url前端用这个URL生成二维码用户扫码后微信服务器异步通知你的回调接口。这个过程涉及签名、回调验签、金额单位换算元转分等细节每一步都有坑。Spring Task定时任务用来做超时关单Scheduled注解加一个cron表达式就能跑起来但要注意并发重复执行的问题。还有ThreadLocal用户信息传递苍穹外卖用ThreadLocal存当前登录用户IDday08下单时要从这里取用户。学完前面的内容再来做day08你会发现它其实是把之前所有技术点综合运用起来的一次实战。所以如果你在前几天有没吃透的地方比如Redis缓存、MyBatis操作建议先回头补一补不然写到一半会被各种前置问题打断思路。2. 核心设计思路订单模块为什么这么设计2.1 订单表结构设计背后的考量先看订单相关的两张核心表订单表和订单明细表。订单表存的是“一次下单”的整体信息包括订单号、用户ID、地址ID、订单金额、订单状态、支付状态、下单时间、结账时间等。订单明细表存的是“这一单里具体包含哪些菜品”包括菜品名称、图片、价格、数量、小计金额。这里有一个非常重要的设计细节订单明细表里存了菜品名称、价格、图片这些冗余字段。为什么要冗余因为菜品价格和名称是会变的。商家在管理端改了一个菜的价格历史订单里的价格不能跟着变否则对账的时候全乱。所以下单那一刻必须把菜品的名称、价格、图片这些信息快照下来存到明细表里。这也解释了为什么订单查询接口返回的商品信息是直接从明细表查而不是去关联菜品表。订单号的设计也值得多说一句。订单号不是简单的主键ID它需要具备一定的业务含义比如包含时间信息方便后续按时间维度排查问题。苍穹外卖里的实现是直接用当前时间戳作为订单号简单够用。但真实项目中更推荐用雪花算法既能保证全局唯一又带时间信息。金额字段用的是BigDecimal而不是double。这一点必须强调任何涉及金额的计算都不能用浮点型否则会出精度问题。前期你也许觉得无所谓但一旦做对账、计算营业额精度问题会直接导致账不平。2.2 下单操作为什么要加事务下单这个动作表面上就是前端传一个订单对象后端插一条数据。但实际上一个下单操作涉及多次数据库写入查询购物车、查询地址、插入订单主表、批量插入订单明细表、清空购物车。这五个步骤任何一个失败都不能让其他步骤的数据残留。比如订单主表插入成功了但明细表插入失败如果没有事务就会出现一个没有明细的“幽灵订单”。所以submitOrder这个方法必须加Transactional让这五个操作处于同一个事务中。很多人对Transactional的理解停留在“加了就行”但这里有个关键细节事务默认只对RuntimeException回滚对Exception受检异常不回滚。所以如果方法里抛的是自定义业务异常这个自定义异常最好继承RuntimeException或者在注解上显式声明rollbackFor Exception.class。我自己习惯是两种都做既让业务异常继承RuntimeException又在注解上写明rollbackFor双保险。事务的粒度也要注意。下单方法内部如果调用了微信支付接口这个网络请求不能包含在事务里。因为支付是异步的、耗时的事务应该只覆盖数据库操作网络调用的失败不能导致前面保存的订单数据回滚。正确做法是事务里只做订单、明细、购物车这些数据库操作事务提交成功后再发起支付请求。2.3 订单状态机与支付状态管理订单状态是整个订单模块的中枢。苍穹外卖里订单状态用数字表示1待付款、2待接单已付款、3已接单、4派送中、5已完成、6已取消。这几个状态的流转方向要理清楚。用户下单后状态是1支付成功变成2商家接单变成3骑手取餐配送变成4用户确认收货或系统自动确认变成5如果支付前超时取消或者支付后用户取消/商家拒单变成6。支付状态单独用一个字段管理方便区分“订单被取消但钱还没退”和“订单正常但未支付”这两种情况。苍穹外卖里支付状态相对简单0未支付、1已支付、2退款但理解这个字段的意义很重要订单状态和支付状态是两个维度不能混为一谈。设计状态机的好处是后续所有业务逻辑都能围绕状态判断展开。比如定时关单只处理状态为1的订单商家接单只处理状态为2的订单退款只处理已支付的订单。如果状态设计得混乱后面写任何业务逻辑都会到处打补丁。这也是面试官经常问“订单状态怎么设计”的原因答案是状态字段 状态流转约束。我第一天做这个模块的时候没有用状态机思维直接在代码里到处写if (order.getStatus() 1)后来加需求的时候改得崩溃。所以建议你在这个阶段就给自己定一个规矩凡是会改变订单状态的代码都通过统一的方法去处理并打日志记录状态变化。这个习惯会帮你省下大量排查问题的时间。3. 核心环节实操从下单到支付全流程实现3.1 用户下单接口实现用户下单接口的路径是/user/order/submit接收的参数是OrdersSubmitDTO里面包含地址簿ID、备注、预计送达时间等信息。我们先看Controller层代码RestController(userOrderController) RequestMapping(/user/order) Api(tags C端-订单接口) Slf4j public class OrderController { Autowired private OrderService orderService; PostMapping(/submit) ApiOperation(用户下单) public ResultOrderSubmitVO submit(RequestBody OrdersSubmitDTO ordersSubmitDTO) { log.info(用户下单参数{}, ordersSubmitDTO); OrderSubmitVO orderSubmitVO orderService.submitOrder(ordersSubmitDTO); return Result.success(orderSubmitVO); } }重点是Service层实现核心逻辑分五步每一部都不能省。第一步从BaseContextThreadLocal封装类拿当前登录用户ID。第二步查购物车如果购物车为空直接抛业务异常提示用户先去选菜。第三步校验地址拿地址簿ID去查地址是否存在。第四步也是最核心的一步组装订单数据设置订单号、状态为待付款、设置用户ID和地址ID、计算金额、设置下单时间。主表插入成功后遍历购物车列表把每个商品组装成订单明细对象批量插入明细表。第五步清空当前用户的购物车。Transactional(rollbackFor Exception.class) public OrderSubmitVO submitOrder(OrdersSubmitDTO ordersSubmitDTO) { // 1. 获取当前登录用户ID Long userId BaseContext.getCurrentId(); // 2. 查询当前用户购物车数据 ShoppingCart shoppingCart new ShoppingCart(); shoppingCart.setUserId(userId); ListShoppingCart shoppingCartList shoppingCartMapper.list(shoppingCart); if (shoppingCartList null || shoppingCartList.isEmpty()) { throw new BusinessException(购物车为空不能下单); } // 3. 校验地址是否存在 AddressBook addressBook addressBookMapper.getById(ordersSubmitDTO.getAddressBookId()); if (addressBook null) { throw new BusinessException(地址信息不存在); } // 4. 组装订单主表数据并插入 Orders orders new Orders(); orders.setNumber(String.valueOf(System.currentTimeMillis())); orders.setStatus(Orders.PENDING_PAYMENT); orders.setUserId(userId); orders.setAddressBookId(addressBook.getId()); orders.setOrderTime(LocalDateTime.now()); orders.setPayStatus(Orders.UN_PAID); orders.setPhone(addressBook.getPhone()); orders.setConsignee(addressBook.getConsignee()); orders.setAmount(calculateAmount(shoppingCartList)); orders.setRemark(ordersSubmitDTO.getRemark()); orderMapper.insert(orders); // 5. 组装订单明细数据并批量插入 ListOrderDetail orderDetailList new ArrayList(); for (ShoppingCart cart : shoppingCartList) { OrderDetail orderDetail new OrderDetail(); orderDetail.setOrderId(orders.getId()); orderDetail.setDishId(cart.getDishId()); orderDetail.setSetmealId(cart.getSetmealId()); orderDetail.setName(cart.getName()); orderDetail.setImage(cart.getImage()); orderDetail.setNumber(cart.getNumber()); orderDetail.setAmount(cart.getAmount()); orderDetailList.add(orderDetail); } orderDetailMapper.insertBatch(orderDetailList); // 6. 清空购物车 shoppingCartMapper.cleanByUserId(userId); return OrderSubmitVO.builder() .id(orders.getId()) .orderNumber(orders.getNumber()) .orderAmount(orders.getAmount()) .orderTime(orders.getOrderTime()) .build(); }这里有个很容易被忽略的坑当你从购物车往订单明细表插数据时判断一个商品到底是菜品还是套餐要看dishId和setmealId哪个有值。因为购物车表里菜品和套餐是混着存的下单的时候如果只处理了菜品套餐商品就会丢失。处理方式是在遍历时两个字段都判断分别设置到对应的明细字段上。还有一个细节是setNumber方法。用System.currentTimeMillis()作为订单号在单机开发时没问题但并发高时有可能重复。如果你想让项目看起来更专业一点可以换用UUID.replace(-, )截取一部分或者引入雪花算法生成ID。面试时提到这一点会比直接用时间戳更有说服力。3.2 微信支付Native支付流程对接下单后就是支付。苍穹外卖用的是微信支付的Native支付方式它的核心流程是后端调用微信支付接口微信返回一个code_url前端拿着这个URL生成二维码用户扫码完成支付后微信服务器回调后端配置的notify_url。后端在收到前端支付请求后要构建一个微信支付请求对象包含订单号、订单金额单位分、商品描述、回调地址等参数然后发送POST请求到微信支付的统一下单接口。注意金额单位微信支付要求金额以“分”为单位默认保留两位小数所以元转分要乘以100再转成字符串。实际开发中不会让你从零写微信支付SDK一般用现成的依赖或者工具类。但底层逻辑必须清楚调用统一下单接口时需要商户号、API密钥、证书等信息微信返回成功后从响应的code_url字段里取出支付链接返回给前端。联调回调接口是当天最麻烦的一步。因为回调接口需要公网能够访问到你本地的服务才能收到微信服务器的通知。常规做法是用内网穿透工具把本地端口暴露到一个公网地址比如启动一个内网穿透服务将本地的8080端口映射成https://xxx.xxx.xxx然后把回调地址配置成这个公网地址加上回调路径。配好之后微信支付成功后就会请求这个地址。3.3 支付回调处理与幂等设计支付回调接口是微信支付成功后的“最后一公里”它做得对不对直接决定订单状态会不会错乱。原理是用户扫码支付成功微信服务器向你的notify_url发送一个POST请求请求体是XML格式的支付结果通知里面包含订单号、交易号、支付金额等信息。后端收到后要先验签确认通知确实是微信发的再处理业务逻辑。回调处理的代码骨架如下PostMapping(/notify) public String notify(HttpServletRequest request, HttpServletResponse response) { // 1. 读取回调报文并验签 // 2. 解析订单号、支付结果 // 3. 根据订单号查询本地订单 // 4. 幂等判断如果订单状态已是“已支付”直接返回成功应答 // 5. 更新订单状态和支付状态 // 6. 返回成功应答给微信服务器 }幂等处理是回调里最容易被忽略的点。微信服务器为了保证通知送达会多次发送同一个支付结果通知如果后端不做幂等订单状态可能会被覆盖甚至触发重复的后续业务操作比如重复发货。做法是在更新状态前先查询订单当前状态如果已经是已支付就直接返回成功应答不再执行更新逻辑。或者用数据库的唯一约束配合状态判断双重保证。返回给微信服务器的应答格式也有讲究。处理成功要返回XML字符串xmlreturn_code![CDATA[SUCCESS]]/return_code/xml告诉微信“我收到了”。如果返回失败微信会按照一定策略重新通知你直到收到成功应答为止。所以回调方法里不能因为一点小问题就抛异常正确的做法是能处理就处理并返回成功不能处理就记日志并返回失败让微信继续重试。3.4 定时关单Spring Task实战超时订单怎么处理有两种常见方案。一种是用消息队列的延迟消息比如RabbitMQ的延迟插件下单后发一条延迟消息时间到了再消费。另一种就是苍穹外卖采用的方案定时任务轮询扫描数据库。定时任务用Spring Task实现非常简单。在启动类加上EnableScheduling然后写一个定时任务类用Scheduled注解标注方法配置cron表达式每分钟扫描一次待付款且超过15分钟的订单。Component Slf4j public class OrderTask { Autowired private OrderMapper orderMapper; Scheduled(cron 0 0/1 * * * ?) public void processTimeoutOrder() { LocalDateTime time LocalDateTime.now().minusMinutes(15); ListOrders ordersList orderMapper.getByStatusAndOrderTimeBefore(Orders.PENDING_PAYMENT, time); for (Orders orders : ordersList) { orders.setStatus(Orders.CANCELLED); orderMapper.update(orders); log.info(定时取消订单{}, orders.getNumber()); } } }这段代码在本地跑没问题但放到生产环境会有一个隐患如果服务部署了多个实例同一个订单可能被多个实例同时扫描到触发重复更新。处理手段也不难更新时加上状态条件写成UPDATE orders SET status 6 WHERE id ? AND status 1如果影响行数为0说明订单已经被其他实例处理过了跳过即可。更进一步可以采用分布式锁比如Redisson的tryLock但这个属于进阶内容面试能说出来就是加分项。定时关单的扫描频率也要合理。每分钟扫一次每次只扫15分钟之前的订单数据库压力适中。如果订单量大可以加索引优化比如在status和order_time上建联合索引避免慢查询拖垮数据库。4. 常见问题与排查技巧实录4.1 事务失效的三种典型情况做完下单接口第一次自测通过并不代表事务真的生效。Spring事务失效是最隐蔽的问题常见的有三种。第一种是同类内部调用。比如在OrderServiceImpl里有一个methodA调用了同一个类里的methodBmethodB上标注了Transactional但这个方法上的事务不会生效。原因是Spring事务基于代理机制同类调用不会经过代理对象。解决方法很简单把methodB放到另一个Service里或者自己注入自己不推荐。第二种是异常被catch掉了。事务方法里如果你用try-catch把异常吞掉事务无法感知异常发生自然不会回滚。所以事务方法里尽量让异常抛出或者手动调用TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()回滚。第三种是方法不是public的。Transactional只能标注在public方法上才生效这在Spring官方文档里写得很明确。如果你在private方法上加注解编译器不报错但运行时不生效。注意事项下单方法的事务一定要测试过“任意一步失败都能回滚”才合格。我测试的方式是在插入明细后手动抛一个异常然后查数据库看订单主表和明细是不是都没写进去。这个测试能在开发阶段就避免大量线上事故。4.2 金额计算精度问题金额计算是订单模块最容易出隐性bug的地方。使用double计算金额0.1 0.2不等于0.3这在涉及金额的场景是不可接受的。下单时计算订单总金额正确做法是把购物车列表中每个商品的单价amount乘以数量number累加所有计算用BigDecimal。注意BigDecimal的构造方法要用字符串不要直接传double比如new BigDecimal(0.1)而不是new BigDecimal(0.1)后者一样有精度问题。另一个经常出问题的点是支付金额。下单计算出来的总金额支付时要原样传给微信支付不要在后端重新计算。如果前后端计算逻辑不一致就会出现用户付的钱和后端订单金额对不上对账的时候很痛苦。4.3 支付回调验签失败或收不到回调回调相关的坑我按排查顺序整理了一下。收不到回调第一反应是看回调地址是否公网可达。你可以用浏览器直接访问回调地址如果返回错误页说明地址本身访问不通。第二是看日志确认请求是否真的到达了后端。如果根本没到达大概率是内网穿透服务断了或者路径配错了。验签失败常见原因是签名串拼接顺序不对或者XML报文解析时丢掉了标签内的CDATA。建议调试时把收到的原始报文完整打印到日志里对照官方文档逐项检查。还有一种情况是商户号配置错误检查一下配置文件的商户号是否和证书一致。回调处理完成后记得确认响应格式。返回给微信的应答必须是XML且return_code为SUCCESS如果你返回了JSON或空字符串微信会认为处理失败继续重复回调。重复回调通常没问题但如果你的回调逻辑没有做幂等就会出问题。4.4 定时任务重复执行与漏单定时任务最怕两件事重复执行和漏单。重复执行是因为服务部署了多个实例解决方法是更新时带状态条件或使用分布式锁。漏单则容易发生在单机版本如果定时任务扫描的订单量很大一次执行超过1分钟cron表达式又设定每分钟执行一次下一次触发时可能和上一次重叠。虽然Spring默认单线程执行Scheduled任务但如果任务执行时间过长会影响后续扫描的时效性。优化方向有两个一是给任务方法加分布式锁保证同一时间只有一个实例在跑二是用Scheduled(fixedDelay 60000)等待上一次执行完成后再开始下一次。这两种方案各有利弊按自己的业务量选就行。我还遇到过一个比较特殊的情况定时任务把已支付的订单也取消了。排查后发现是状态判断写反了把status 1写成status ! 1。这类问题很难定位因为订单量大的时候错误状态的数据会被其他逻辑掩盖。所以定时任务在更新订单状态前一定要在日志里打印订单号、当前状态、目标状态方便事后回溯。5. 关于day08的复盘与简历亮点提炼5.1 代码组织与复盘建议day08跑通之后不建议急着进入day09。花点时间复盘这一天的代码你会有完全不一样的收获。先看Controller层确认每个接口有没有做参数校验返回的VO字段是否够用。再看Service层确认事务边界是否清晰状态流转变更是否集中在一个方法里。然后看Mapper层确认批量插入的SQL有没有配置useGeneratedKeys保证插入后能拿到主键。交易链路的核心日志一定要补全。下单时打印订单号和金额支付回调时打印回调参数定时关单时打印被取消的订单号。日志不只是给运维看的更是你调试时的“黑匣子”。我当时花了一个多小时排查一个重复回调问题最后就是靠日志里每次回调的订单号和时间戳才定位到问题。我建议你把day08的代码自己重写一遍不要只跟着视频敲。把视频关掉只凭接口文档从零开始实现下单和支付回调写不出来再回看。这个过程会暴露出大量你以为会了但其实没掌握的知识点在这些才是你真正需要补的地方。5.2 简历上如何描述这个模块如果你打算把苍穹外卖写进简历day08这个模块是最值得展开的部分。不要只写“实现用户下单和支付功能”要写出技术深度。一个比较有说服力的描述方向是“负责外卖项目订单模块开发设计订单与订单明细表结构实现用户下单事务控制对接微信支付Native支付处理支付回调幂等与签名验证通过Spring Task实现超时订单自动取消解决多实例下的重复执行问题。”这样写的亮点在于体现了表结构设计能力、事务控制意识、第三方接口对接经验、幂等设计思想还点出了分布式场景下的思考。面试官大概率会追问“怎么解决回调幂等”“事务失效怎么避免”这些内容你在day08都已经实践过回答起来不会心虚。如果你还有余力可以再往前一步把定时关单里的“带状态条件的更新SQL”扩展到“基于Redisson的分布式锁实现”把下单里的“时间戳订单号”替换成“雪花算法订单号”。这两个小改动会再拉高一点技术上限但前提是你真的理解了背后的原理而不是为了面试临时背概念。经过这一天的完整实践我自己最深的体会是订单模块是整个项目里最能体现工程素养的部分它逼着你去思考数据一致性、接口幂等、异常恢复这些真实生产环境才有的问题。代码写出来只是第一步跑通、复盘、改掉隐藏的坑才是把day08真正变成你自己能力的关键。希望这篇能帮你少走一点弯路但更重要的是你自己动手把代码跑起来改一改、断点调一调那些细节才会真正长在你身上。