
苍穹外卖这个项目我是按天推进的。前面六天解决的是餐厅的基础运营员工登录、分类菜品、套餐、文件上传、地址簿和购物车。到了第七天任务明显重了起来——要把用户端真正的交易闭环打通也就是下单和支付。这一天做完整个项目就从“能点菜”推进到“能收钱”业务上才算是真正完整。下面是我第七天开发过程中的完整技术总结涉及下单事务、微信支付回调、订单状态机、超时关单定时任务以及联调时踩过的一堆坑。如果你也在做类似的项目或者正在学订单模块这篇应该能帮你少走不少弯路。1. 下单支付模块的整体设计1.1 为什么把下单和支付放在同一天做下单和支付虽然在代码上是两个独立接口但在业务上是一条完整的链路用户提交订单、系统生成待支付订单、前端拉起微信支付、支付平台异步回调、后端更新订单状态。如果拆开两天做第一天做完下单第二天接支付时还是要把下单的数据重新掏出来验证反而浪费调试时间。放在同一天推进可以把数据库、后端服务、小程序端、微信支付平台这四端一次性打通。从数据模型来看下单和支付也都围绕orders和order_detail这两张核心表。订单表的状态字段既要标识业务阶段比如待支付、待接单、派送中、已完成又要记录支付状态比如未支付、已支付。支付回调本质上就是订单状态从“待支付”变成“待接单”的触发点。状态机没理清楚就急着写代码很容易出现“先改状态还是先存支付流水”这种纠结。所以我习惯先把状态迁移关系列出来再动接口。1.2 订单模块的表结构与状态设计这一步我强烈建议先看数据库表设计再写代码。订单相关的表主要是orders和order_detail。orders存一次订单的汇总信息订单号、用户ID、地址簿ID、支付方式、订单金额、实收金额、备注、收货人、手机号、地址、预计送达时间、下单时间、支付时间、完成时间、取消时间、取消原因。order_detail存订单明细菜品ID或套餐ID、名称、口味、数量、单价快照、总金额快照。订单状态我用了整数常量来维护这样在Java代码里写起来比较直观状态值含义1待付款2待接单3已接单4派送中5已完成6已取消同时还有一个独立的支付状态字段0未支付、1已支付、2退款。可能有人觉得一个status就够用了实际上状态和支付状态是两个维度。一个订单完全可能处于“已取消”但“已支付”的状态这种订单下一步就需要走退款。苍穹外卖里退款功能虽然不全但数据结构上如果只用一个字段表示所有情况后面做对账和运营统计会非常痛苦。把状态拆开代码里判断逻辑反而更清晰。1.3 第七天要完成的接口清单列接口清单的过程就是把需求翻译成前后端约定。我当天的接口大致如下接口请求方式说明/user/order/submitPOST提交订单生成待支付订单/user/order/payPOST后端调用微信JSAPI下单返回小程序支付参数/notify/paySuccessPOST微信支付结果回调更新订单状态/user/order/orderDetailGET前端轮询订单详情确认支付结果Spring Task定时任务内部自动取消超时未支付订单接口不多但每个接口背后的逻辑都不轻松。submit要处理多表事务pay要和微信支付平台做交互notify要验签和解密。一天能把这几个接口完整跑通整个用户端交易主流程就算闭环了。2. 下单接口的工程落地2.1 入参校验和用户身份获取提交订单的DTO我设计成这样Data public class OrdersSubmitDTO { private Long addressBookId; private int payMethod; private String remark; private Long tablewareNumber; private Integer tablewareStatus; private Integer packingFee; private Integer deliveryFee; private BigDecimal amount; }这里的amount前端会传过来但后端不能直接用。原因很简单前端永远是不可信的数据源。你把金额交给前端决定用户抓个包改一下金额系统就会收到一笔远低于实际价格的订单。正确的做法是后端根据购物车和数据库里的菜品价格重新计算前端传的金额只做参考或者干脆忽略。用户身份我通过ThreadLocal拿。苍穹外卖项目在登录拦截器里已经解析了JWT把用户ID放进了ThreadLocal所以下单服务里直接BaseContext.getCurrentId()就能拿到当前登录用户ID。有一个细节要注意不能只从请求参数里拿地址簿ID还要校验这个地址簿确实是当前用户的。否则用户传一个别人的地址簿ID下单时地址信息就会泄露。查地址簿的时候带上用户ID条件越权问题就避免了。2.2 后端重算金额不让前端说了算金额重算是下单接口里最核心的步骤。我的实现思路是从shopping_cart表查出当前用户的所有购物车记录然后逐条去商品主表取实时价格。购物车表里虽然也存了金额但那只是展示用快照真正下单时要以dish表或者setmeal表的价格为准。ListShoppingCart cartList shoppingCartMapper.listByUserId(userId); ListOrderDetail orderDetails new ArrayList(); BigDecimal totalAmount BigDecimal.ZERO; for (ShoppingCart cart : cartList) { BigDecimal unitPrice; if (cart.getDishId() ! null) { Dish dish dishMapper.getById(cart.getDishId()); if (dish null || dish.getStatus() ! 1) { throw new BusinessException(商品已下架); } unitPrice dish.getPrice(); } else { Setmeal setmeal setmealMapper.getById(cart.getSetmealId()); if (setmeal null || setmeal.getStatus() ! 1) { throw new BusinessException(套餐已下架); } unitPrice setmeal.getPrice(); } BigDecimal itemAmount unitPrice.multiply(BigDecimal.valueOf(cart.getNumber())); totalAmount totalAmount.add(itemAmount); OrderDetail detail new OrderDetail(); detail.setDishId(cart.getDishId()); detail.setSetmealId(cart.getSetmealId()); detail.setName(cart.getName()); detail.setNumber(cart.getNumber()); detail.setAmount(itemAmount); orderDetails.add(detail); }这里我用BigDecimal而不是double是一个老生常谈但也有不少人会踩的坑。二进制浮点数算金额会出现0.1 0.2 0.30000000000000004这种结果。数据库金额字段我也全部是decimal(10,2)类型Java实体对应BigDecimal计算时统一setScale(2, RoundingMode.HALF_UP)。整套链路都用一种类型就不会出现前端显示19.9、库里存19.90、支付时却变成19.909999这种情况。2.3 订单与明细写入的事务边界下单涉及多个表的写入插入orders、批量插入order_detail、删除shopping_cart。这三步如果只成功了一半数据就坏了。所以服务方法一定要加事务注解Transactional(rollbackFor Exception.class) public OrdersSubmitVO submitOrder(OrdersSubmitDTO submitDTO) { // 校验地址簿 // 查询购物车 // 计算金额 // 插入订单 // 插入订单明细 // 清空购物车 }这里有两个关键点。第一rollbackFor一定要指定成Exception.class因为Spring默认只对RuntimeException和Error回滚如果你抛了一个自定义的检查型异常事务不会自动回滚订单主表就会落库明细却没了。第二方法内部千万不要自己try-catch把异常吞掉。我联调时就踩过这个坑订单主表写进去了明细因为一个空指针没插进去但异常被catch了还只打了日志事务根本没感知到要回滚最后排查半天才在数据库里发现脏数据。订单号生成我也简单处理了一下用当前时间yyyyMMddHHmmss拼接一个随机数再加用户ID的低四位。学习项目够用生产环境建议用雪花算法避免高并发下的唯一性问题。2.4 清空购物车与响应封装下单成功后清空购物车这一步看起来简单但要注意清空范围。当前订单是结算整个购物车所以直接按用户ID删除即可shoppingCartMapper.cleanByUserId(userId);如果把“只清除下单菜品”和“清除整个购物车”搞混用户下一次打开购物车会发现之前没打算下单的东西也没了。响应VO我返回了id、orderNumber、orderAmount、orderTime。这些信息在后面拉起微信支付时要直接使用特别是orderNumber它就是微信支付里的out_trade_no必须保证前后一致。3. 微信支付接入与回调处理3.1 接入前的准备别在配置上浪费时间微信支付APIv3的接入配置比业务代码更容易让人崩溃。需要准备商户号mchid、小程序AppID、商户API证书私钥、证书序列号、APIv3密钥。我在application.yml里维护了这些配置项然后创建了一个WeChatPayConfig来加载。实际操作中最常见的配置错误是把商户私钥当成APIv3密钥或者把证书序列号填错。这些值一旦错误调用微信接口会返回401或sign error但日志里看不到具体信息非常折磨人。调试时一定要先确认文件路径有没有放对私钥内容有没有包含BEGIN PRIVATE KEY完整头尾。3.2 使用JSAPI统一下单并获取prepay_id微信支付的流程是后端拿着订单信息调用微信平台生成预付单拿到prepay_id再到小程序端调用wx.requestPayment拉起收银台。所以后端必须先有一个“支付下单”的接口。对接微信支付V3我用了官方Java SDKwechatpay-java配置好RSAAutoCertificateConfig后调用JsapiService创建交易TransactionalService service new TransactionService.Builder().config(config).build(); Transaction transaction service.createTransaction( new JsapiCreateRequest() .setAppid(appId) .setMchid(mchId) .setDescription(苍穹外卖-订单 orderNumber) .setOutTradeNo(orderNumber) .setNotifyUrl(notifyUrl) .setAmount(new Amount().setTotal(totalAmount.intValue())) .setPayer(new Payer().setOpenid(openid)) ); String prepayId transaction.getPrepayId();这里有一个单位问题微信支付要求订单金额单位是“分”。数据库里我存的是“元”和BigDecimal所以调用支付接口前必须先multiply(100)再转整数比如totalAmount.multiply(new BigDecimal(100)).intValue()。如果你直接用BigDecimal的原值传给微信支付金额就会差100倍微信会直接报金额不一致。openid从哪里来在用户第一次通过微信小程序登录时后端已经通过code2Session保存了用户的openid。支付下单时把这个openid传给payer字段微信才知道是哪个用户发起的支付。3.3 给小程序端生成二次签名参数拿到prepay_id之后还不能直接把它返回给前端。小程序拉起微信支付需要一组参数其中最关键的是paySign。MapString, String payParams new HashMap(); // 注意 timeStamp 是秒不是毫秒 String timeStamp String.valueOf(System.currentTimeMillis() / 1000); String nonceStr UUID.randomUUID().toString().replace(-, ); payParams.put(appId, appId); payParams.put(timeStamp, timeStamp); payParams.put(nonceStr, nonceStr); payParams.put(package, prepay_id prepayId); payParams.put(signType, RSA); // 签名串注意最后有一个换行符 String signContent appId \n timeStamp \n nonceStr \n prepay_id prepayId \n; String paySign weChatPayUtil.sign(signContent); payParams.put(paySign, paySign);这个签名的私钥仍然是商户API私钥算法是SHA256withRSA。最容易错的两个地方一个是timeStamp用了毫秒导致签名串对不上另一个是package少写了prepay_id前缀只传了一个裸的prepay_id。前端一旦拿到不完整的参数拉起支付会报“invalid request”。3.4 支付回调验签与资源解密支付成功之后微信支付平台会异步请求我们配置的notify_url。回调的报文不是明文而是经过加密的。请求体大致是这样的结构{ id: EV-..., event_type: TRANSACTION.SUCCESS, resource_type: encrypt-resource, resource: { algorithm: AEAD_AES_256_GCM, ciphertext: base64密文, associated_data: , nonce: 随机数, original_type: transaction } }要处理回调第一步是验签第二步是解密。如果使用SDK可以用NotificationParser一步搞定验签和自动解密。为了把原理讲清楚我贴一下自己手写的解密代码当你没有SDK或者想排查问题时就知道问题出在哪private String decryptResource(String ciphertext, String nonce, String associatedData) throws Exception { SecretKeySpec keySpec new SecretKeySpec(apiV3Key.getBytes(StandardCharsets.UTF_8), AES); Cipher cipher Cipher.getInstance(AES/GCM/NoPadding); GCMParameterSpec gcmSpec new GCMParameterSpec(128, nonce.getBytes(StandardCharsets.UTF_8)); cipher.init(Cipher.DECRYPT_MODE, keySpec, gcmSpec); if (associatedData ! null !associatedData.isEmpty()) { cipher.updateAAD(associatedData.getBytes(StandardCharsets.UTF_8)); } byte[] plainBytes cipher.doFinal(Base64.getDecoder().decode(ciphertext)); return new String(plainBytes, StandardCharsets.UTF_8); }解密后的明文大致是{ out_trade_no: 订单号, transaction_id: 微信支付系统交易号, trade_state: SUCCESS, amount: { total: 100, payer_total: 100 } }这个out_trade_no对应我们下单时生成的订单号用它去数据库查订单然后更新状态。3.5 幂等处理和异常重试微信支付回调有一个特点它不保证只通知一次。网络抖动、程序处理超时、返回状态码不是200微信都会在短时间内重新回调。所以回调处理逻辑必须幂等。我的处理顺序是Orders order orderMapper.getByOrderNumber(outTradeNo); if (order null) { return errorResponse(); } // 已经支付成功的订单直接返回成功避免重复更新 if (order.getPayStatus() 1 order.getStatus() Orders.PENDING_CONFIRM) { return successResponse(); } order.setStatus(Orders.PENDING_CONFIRM); order.setPayStatus(Orders.PAID); order.setCheckoutTime(LocalDateTime.now()); orderMapper.updateByIdAndStatus(order);这里用了条件更新update orders set status 2, pay_status 1 where id #{id} and status 1防止并发情况下把订单从“支付成功”改成“待接单”或者反过来改错。也就是只有当前状态是“待付款”时才允许把订单推进到“待接单”否则直接忽略。这个思路在处理所有状态流转时都适用相当于用数据库的更新条件做乐观锁。回调的最后如果处理成功一定要返回HTTP 200响应体是{code: SUCCESS, message: 成功}如果返回500或者{code:FAIL}微信会持续重试。我见过有人没有返回正确格式结果回调接口被微信重试了几十次日志天天刷屏。这里顺便说一下回调接口的日志一定要详细记录event_type、out_trade_no、当前订单状态、处理结果线上排查支付问题全靠这些日志。4. 超时未支付订单的定时取消4.1 为什么要做超时关单用户下单了但迟迟不支付订单就一直挂在“待付款”。从产品上看这种订单随时可能被取消但从技术上来说它是无效订单。如果购物车里商品是热销菜品这种订单会让人误以为库存被占用。更关键的是微信支付平台的prepay_id有效期只有2小时。超过2小时之后即使用户想支付也没法拉起收银台。所以后端必须在订单创建一段时间后主动把它取消比如15分钟。这也是很多电商系统都会做的“超时关单”。在苍穹外卖里没有库存字段但我习惯把回补库存的注释写在取消逻辑里如果后面扩展库存模块这个位置就是兜底点。4.2 使用Spring Task实现定时取消Spring提供了一套轻量级定时任务使用起来非常简单。启动类上加上EnableScheduling然后创建一个定时任务类Component Slf4j public class OrderTask { Autowired private OrderMapper orderMapper; Scheduled(cron 0 0/1 * * * ?) public void cancelOverdueOrder() { LocalDateTime cancelTime LocalDateTime.now().minusMinutes(15); ListOrders overdueOrders orderMapper.getByStatusAndOrderTimeBefore( Orders.PENDING_PAYMENT, cancelTime); if (overdueOrders ! null !overdueOrders.isEmpty()) { for (Orders order : overdueOrders) { order.setStatus(Orders.CANCELLED); order.setCancelReason(超时未支付系统自动取消); order.setCancelTime(LocalDateTime.now()); orderMapper.update(order); // 如果扣减了库存这里要回补库存 } } } }对应的Mapper SQL我是用MyBatis XML写的select idgetByStatusAndOrderTimeBefore resultTypecom...Orders select * from orders where status #{status} and order_time lt; #{cancelTime} and pay_status 0 /select注意这里lt;不能直接写在XML文件里小于号必须转义成实体否则XML解析直接报错。4.3 定时任务容易踩的坑Spring Task的cron表达式是6位不是Quartz的7位。如果网上复制了一个7位的表达式项目启动会直接报错。0 0/1 * * * ?表示每分钟的第0秒执行一次够用。还有一个并发问题如果项目部署了多个实例每分钟每个实例都会执行一次这个任务同一批超时订单可能会被多个实例同时查到并同时更新。学习项目单实例部署没问题但如果是真实生产环境就需要引入分布式锁或者用select for update skip locked保证同一时刻只有一个实例处理同一批订单。我当时把这个问题写在代码注释里了等部署多实例时直接就能想起来。更不能忽视的是条件更新。取消订单的SQL最好也写成update orders set status 6 where id #{id} and status 1。因为有可能用户刚好在定时任务执行前一秒支付成功如果定时任务不加条件地直接把状态改成已取消就会出现“用户已付款订单却被系统取消”的事故。加一个status 1条件只有待付款的订单才会被取消已经支付的订单就安全了。5. 联调中的问题与排查实录5.1 金额显示出现10.99999999这个问题我在一天里碰到了两次。第一次是购物车金额计算第二次是对接微信支付金额入参。根本原因都是用了double或者float做运算。double在二进制表示下没办法精确表示0.1所以运算结果会出现无限小数。破解方法只有一条涉及金额的字段Java用BigDecimalMySQL用decimal微信支付入参用“分”单位的整数。中间任何一步都不要用double来接否则精度问题会沿着调用链一直传下去。5.2 微信支付回调解密一直失败这个问题的排查过程比较典型。一开始我以为是SDK使用不对后来发现自己把resource.nonce和请求头里的Wechatpay-Nonce搞混了。回调请求头里的nonce是签名用的随机串而resource里的nonce是解密用的随机串两者完全不同不能混用。另外还有一个细节associated_data为空字符串时调用updateAAD会报错所以代码里要先判断!associatedData.isEmpty()。密文也一定要先Base64解码再交给doFinal很多人直接传Base64字符串就会一直解不出来。5.3 下单方法事务没有生效我前面提到过这个坑。表现是订单主表插入成功但订单明细没有购物车也没有清空。排查后发现是方法内部写了try-catch异常被捕获后只打日志不再往外抛事务管理器根本没有收到异常信号自然就不会回滚。如果你也遇到事务不生效优先查这几个地方可能原因排查方式Transactional加在非public方法上确认方法是否public同类内部调用this.submit()改成从外部注入Service调用或通过代理调用方法里try-catch吞掉异常去掉catch或catch后重新throw没有指定rollbackFor Exception.class加上rollbackFor Exception.class5.4 拿到了prepay_id但小程序拉起支付失败有一次接口已经返回了prepay_id很兴奋地放到小程序里结果弹窗就是出不来。后来对比文档发现是signContent末尾少了一个\n。微信支付要求签名串格式为appId\ntimeStamp\nnonceStr\npackage\n最后那个换行符不能省。省了换行签名串和服务端生成的签名不一致前端自然校验不过。还有个小问题就是timeStamp必须是秒级我用System.currentTimeMillis()的时候忘记除以1000结果时间戳差了1000倍微信会判断签名串中时间戳不合法。5.5 联调问题速查表现象原因对策金额多位小数用了double计算统一BigDecimal微信返回金额不一致单位没有转换成分元乘100转分回调解密失败nonce混淆、密钥错误检查APIv3密钥区分resource.nonce事务不生效吞异常或同类调用去掉吞异常使用外部代理拉起支付失败签名串缺换行、时间戳毫秒按文档拼签名串秒级时间戳订单被重复取消定时任务没有条件更新更新时加status1条件6. 第七天开发的一点个人体会这一天做下来我心里最明显的感受是下单支付这种模块逻辑复杂度不算高但“外部交互”和“数据一致性”才是真正的难点。微信支付的回调是异步的它可能重复、可能延迟、可能失败不能把整个流程建立在“回调一定会成功一次”的假设上。所有状态更新都加上条件所有处理逻辑都保证幂等这两个习惯能帮你挡掉大部分生产事故。还有一点就是日志。支付回调进来到底请求了什么、微信返回了什么、订单当前状态是什么这些信息一定要完整地打出来。没有日志出了问题就只能靠猜效率极低。我自己就吃过这个亏后面把所有外部回调入口的日志都统一格式排查问题轻松很多。最后想分享一个小技巧做支付模块之前先不要急着写代码把状态机画出来——待付款能到哪些状态、已支付能到哪些状态、已取消能不能到已完成。状态机理清了写代码的时候就是填实现细节而不是一边写一边想逻辑写着写着把自己绕进去。这是第七天最有价值的部分。