微信支付订单闭环实战:表设计、事务管理与回调验签防重
发布时间:2026/10/1 10:49:41来源:尧图网络
第一次把苍穹外卖的下单和支付模块联调完我信心满满地提交了一单测试订单结果盯着微信支付回调日志等了三分钟订单状态还是“待付款”。后来才发现问题出在回调接口的响应上——微信要求的是纯 XML 成功应答我返回了 JSON微信那边直接判定通知失败重试两次就放弃了。类似的坑我在“用户下单、订单支付”这两个环节里踩了一箩筐所以想把整个模块从表设计到回调兜底完整梳理一遍。这篇文章不打算从头复述整个苍穹外卖项目只聚焦用户下单和订单支付这条核心闭环订单表怎么设计、下单事务怎么写、金额计算有什么讲究、微信支付怎么接入、回调怎么验签防重、超时未支付订单怎么兜底。适合正在做苍穹外卖这类点餐系统、或者第一次接触支付回调后端逻辑的同学参考。我把每个关键决定背后的理由也一并讲清楚方便你直接抄作业同时也知道为什么要这么写。1. 下单到支付的完整闭环先把这个链路画清楚1.1 订单模块在苍穹外卖里的位置苍穹外卖从用户视角看核心链路其实很短打开小程序浏览菜品 → 加入购物车 → 选地址提交订单 → 微信支付 → 等商家出餐配送。商家端要处理接单、制作、派单、送达。而用户下单和订单支付就是串起前后两端最关键的一个环节。我一开始犯的错误是只盯着“下单接口”和“支付回调”两个点以为把接口写出来就算完事。结果测试时发现下单要把购物车清空、要把菜品快照存进订单明细、要算清楚金额支付回调要更新订单状态、要防止重复通知、要处理用户刚好取消订单的冲突。这些关联逻辑全是散在的不把链路理清楚写出来的代码一定是补丁摞补丁。所以动手之前一定要先明确模块边界下单负责“把购物车变成订单”支付负责“把订单从待付款推进到待接单”其余的都是这两种职责的延伸。1.2 一次下单支付请求数据到底走了哪些节点拿一次标准的用户下单来说数据流动大概是这样的用户在小程序购物车页点击“去结算”携带 addressBookId地址簿ID、payMethod支付方式、remarks备注等参数请求POST /user/order/submit。后端校验地址存在、购物车不为空然后把购物车里的每条记录菜品ID、套餐ID、口味、数量取出来组装成订单明细快照。按明细计算菜品金额小计叠加打包费、配送费得出订单总金额。在同一个事务里插入orders主表和order_detail明细表然后清空该用户的购物车。前端拿到订单号后调用支付相关接口后端向微信支付发起 JSAPI 下单拿到prepay_id并生成前端支付参数。小程序端wx.requestPayment拉起收银台用户输入密码完成支付。微信服务器异步通知后端回调接口后端验签后更新订单状态为“待接单”同时前端主动轮询订单状态刷新界面。这条链路上的每一个节点都有各自的坑事务没包好会留下脏数据金额单位不统一会算错钱回调不验签会被伪造通知回调不做幂等会被重复处理。下面我按模块一个个拆开讲。2. 订单主表与明细表设计为什么必须拆开存2.1 orders 主表状态机才是灵魂订单表是整个外卖系统的核心表苍穹外卖的orders表字段大致如下字段类型说明idbigint主键自增numbervarchar(50)订单号业务唯一statusint订单状态1~7user_idbigint下单用户IDaddress_book_idbigint地址簿IDorder_timedatetime下单时间checkout_timedatetime支付/结账时间pay_methodint支付方式1微信 2支付宝pay_statustinyint支付状态0未支付 1已支付 2退款amountdecimal(10,2)订单总金额packaging_amountdecimal(10,2)打包费delivery_amountdecimal(10,2)配送费remarksvarchar(255)备注注意一个容易被新手忽略的设计表里把收货人姓名、手机号、详细地址都冗余了一份而不是只存address_book_id。我当时觉得这是重复存储后来在真实场景里才想明白——地址簿里的地址用户可以随时修改但订单必须定格在下单那一刻的收货信息否则商家照最新地址发货用户人都不在那个地方了。这就是快照思想跟明细表里存菜品名称、价格是同一个道理。2.2 订单状态机不是所有状态都能互相跳status字段是订单模块的灵魂。苍穹外卖常见的状态定义如下1 待付款2 待接单3 已接单/待配送4 配送中5 已完成6 已取消7 退款状态迁移是有方向的。比如“待付款”只能到“待接单”支付成功或者“已取消”超时/用户主动取消“待接单”只能到“已接单”“配送中”只能到“已完成”。我见过有同事在回调里直接无条件把 status 改成 2结果用户取消订单之后的支付回调一到又把状态改回去了商家都准备出餐了才发现订单“复活”了。这就是典型的状态机缺失。后续所有更新状态的 SQL都应该带AND status 原状态这个条件。2.3 order_detail 明细表菜品快照的价值order_detail表记录订单里的每一份菜品关键字段如下字段类型说明idbigint主键namevarchar(32)菜品名称快照imagevarchar(255)菜品图片快照order_idbigint所属订单IDdish_idbigint菜品ID可为空setmeal_idbigint套餐ID可为空dish_flavorvarchar(50)口味规格如“微辣、去冰”numberint数量amountdecimal(10,2)单价注意是单价不是小计为什么要冗余name、image、amount因为菜品表里的价格和名称随时可能被商家修改。如果订单详情只存dish_id用户一个月后翻历史订单看到的可能就是改版后的新价格和新菜名菜品下架了甚至直接显示异常。下单那一刻把菜品信息复制一份进订单明细订单就永远忠实于用户购买时的真实情况。另外dish_id和setmeal_id至少有一个为 NULL因为一份明细要么是单个菜品要么是套餐不可能同时两者都是。这个业务约束数据库层面没有强制完全靠 service 代码保证写的时候要小心别两个都填了。2.4 索引设计别等压测再补订单表的查询场景非常明确用户查自己的订单WHERE user_id ? AND order_time BETWEEN ...、商家查店铺订单按店铺加状态、超时扫描WHERE status 1 AND order_time ...。所以至少建三个索引number唯一索引订单号唯一防重复联合索引user_id order_time用户订单列表常用联合索引status order_time给后面超时取消的定时任务用我一开始只建了主键结果定时任务一跑就是全表扫描订单量几百条的时候没感觉压测到几十万条直接暴露这个第 7 节再细说。3. 用户下单核心流程实现事务里的每一步都不能省3.1 下单入参与前置校验用户提交订单的 DTO 大致长这样Data public class OrdersSubmitDTO { private Long addressBookId; // 地址簿ID private Integer payMethod; // 支付方式 1微信 2支付宝 private String remarks; // 备注 private Integer estimatedDeliveryTime; // 预计送达时间分钟 }很多初学会忽略的是下单前必须校验地址存在、购物车非空。地址不存在会导致后续物流信息全是空的购物车为空说明用户可能刚清空了购物车但没刷新页面这时候创建一份空订单毫无意义。校验逻辑放在 service 层抛业务异常由全局异常处理器统一转换提示信息返回给前端。3.2 金额计算BigDecimal 和“分”的执念这是整篇文章我最想强调的一点。金额计算必须用BigDecimal严禁使用float和double。外卖订单金额由三部分组成菜品金额 Σ单价 × 数量打包费 按订单份数或固定金额计算配送费 按距离或固定金额计算我的习惯是数据库字段用decimal(10,2)Java 代码里全程BigDecimal任何加减乘都用方法调用不做隐式类型转换。将来对接支付渠道时支付平台的金额单位通常是“分”整数转换用amount.multiply(new BigDecimal(100)).longValue()防止精度丢失。为什么这么较真外卖客单价低带小数的情况非常常见比如 0.5 元的打包费、3.9 元的饮料。float的二进制表示并不能精确表达 0.1累加几次之后就会冒出0.30000000000000004这种鬼值。轻则展示不对重则支付金额和订单金额对不上对账的时候非常狼狈。3.3 订单号生成与事务内的操作顺序订单号生成我见过几种方案时间戳加随机数、UUID、雪花算法。苍穹外卖这种体量用时间戳加用户ID加随机数就够了但要注意订单号在表里有唯一索引极端情况下随机数碰撞会导致插入失败所以要捕获唯一键冲突重试或者直接用雪花算法。我自己后来统一用了类似的分布式 ID 生成思路省心。下单事务里的操作顺序也很关键查询并校验地址查询购物车带出菜品/套餐信息组装订单主表数据计算金额组装订单明细列表插入订单主表拿到自增 ID遍历插入订单明细表清空购物车这七步必须包在同一个Transactional里。为什么如果插入了订单但清购物车失败用户下次看到购物车东西还在再点一次就重复下单了如果清了购物车但订单插入失败用户的东西就凭空消失了。事务保证要么全成功要么全回滚。3.4 下单接口的代码骨架Service public class OrderServiceImpl implements OrderService { Autowired private OrderMapper orderMapper; Autowired private OrderDetailMapper orderDetailMapper; Autowired private ShoppingCartMapper shoppingCartMapper; Autowired private AddressBookMapper addressBookMapper; Override Transactional public Orders submit(OrdersSubmitDTO submitDTO, Long userId) { // 1. 校验地址 AddressBook address addressBookMapper.getById(submitDTO.getAddressBookId()); if (address null) { throw new OrderBusinessException(地址不存在); } // 2. 查询购物车 ListShoppingCart cartList shoppingCartMapper.listByUserId(userId); if (cartList null || cartList.isEmpty()) { throw new OrderBusinessException(购物车为空); } // 3. 组装明细并计算金额 ListOrderDetail details new ArrayList(); BigDecimal amount BigDecimal.ZERO; for (ShoppingCart cart : cartList) { OrderDetail detail new OrderDetail(); BeanUtils.copyProperties(cart, detail); detail.setOrderId(null); // 主表插入后回填 details.add(detail); amount amount.add( cart.getAmount().multiply(BigDecimal.valueOf(cart.getNumber())) ); } // 4. 组装主表 Orders order new Orders(); order.setNumber(OrderNumberGenerator.generate()); order.setStatus(Orders.PENDING_PAYMENT); order.setUserId(userId); // 地址、金额、打包费、配送费省略赋值 // 5. 插入主表useGeneratedKeys 回填 ID orderMapper.insert(order); details.forEach(d - d.setOrderId(order.getId())); // 6. 插入明细 orderDetailMapper.insertBatch(details); // 7. 清空购物车 shoppingCartMapper.deleteByUserId(userId); return order; } }代码本身不复杂但每一步都有业务含义。特别提醒主表插入后 MyBatis 需要配置useGeneratedKeystrue和keyPropertyid才能回填自增 ID。这个配置漏了明细表外键就全串了因为拿到的都是默认值 0。4. 微信支付接入JSAPI 下单和拉起支付的那些参数4.1 为什么小程序端只能用 JSAPI 支付苍穹外卖的用户端是微信小程序小程序里拉起微信支付只能用 JSAPI 支付方式也常被叫小程序支付。Native 支付是 PC 扫码用的App 支付是移动应用用的H5 支付是公众号网页用的。选错了支付方式微信会直接报“该商户号不支持此支付方式”。JSAPI 支付要求每个用户必须有 openid。这个 openid 是用户在小程序里授权登录时后端调微信登录接口code2Session换到的需要存起来或者放 Redis。很多同学下单时报“支付参数异常”排查半天发现是 openid 没传或者传错了。我自己的做法是登录成功后把 openid 放进 Rediskey 用 userId支付下单时直接取。4.2 后端统一下单的完整链路微信支付 JSAPI 下单的请求要点请求地址POST https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi需要商户号、商户证书、APIv3 密钥请求体包含appid、mchid、description、out_trade_no、amount.total、payer.openid、notify_url等字段后端流程大致如下// 1. 构建请求体 MapString, Object payload new HashMap(); payload.put(appid, wxPayProperties.getAppId()); payload.put(mchid, wxPayProperties.getMchId()); payload.put(description, 苍穹外卖-订单 order.getNumber()); payload.put(out_trade_no, order.getNumber()); payload.put(notify_url, wxPayProperties.getNotifyUrl()); MapString, Object amount new HashMap(); amount.put(total, order.getAmount().multiply(new BigDecimal(100)).longValue()); amount.put(currency, CNY); payload.put(amount, amount); payload.put(payer, Collections.singletonMap(openid, openid)); // 2. 使用商户证书发请求接收返回结果 // 3. 拿到 prepay_id // 4. 用 APIv3 密钥二次签名返回给前端这里有一个核心概念后端请求微信用的是商户 API 证书做双向 TLS 认证请求体里有appid、mchid等参数但返回给小程序端让前端拉起支付的参数需要后端用 APIv3 密钥再做一次签名。生成appId、timeStamp、nonceStr、package值就是prepay_idxxx、signType、paySign这六个参数。很多教程只讲了前三步没讲二次签名。没做这一步前端wx.requestPayment会一直报签名错误。我当时就卡在这上面对着文档核对到凌晨最后发现只是忘了把 prepay_id 拼成package参数的标准格式。4.3 前端拉起支付与本地轮询兜底小程序端拿到六个参数后调用wx.requestPayment({ timeStamp: res.data.timeStamp, nonceStr: res.data.nonceStr, package: res.data.package, signType: RSA, paySign: res.data.paySign, success: (res) { /* 收银台流程结束 */ }, fail: (err) { /* 用户取消或支付失败 */ } });注意一个体验细节wx.requestPayment的success回调只代表微信收银台流程走完了并不代表钱已经到账。所以我在前端同时起一个定时轮询每 2 秒查一次订单状态直到订单变成“待接单”或者轮询超时。因为支付回调是异步的可能延迟几秒甚至几十秒光靠回调更新状态用户界面会一直停留在“未支付”白白流失订单。5. 支付回调处理验签、幂等、并发一个都不能少5.1 回调报文解析与验签微信支付成功后微信服务器会向notify_url发一个 POST 请求。回调报文是加密的需要用 APIv3 密钥解密得到明文 JSON明文里有out_trade_no、transaction_id、trade_state、amount等字段。验签是必须做的第一步。为什么要验签因为notify_url是公网地址任何人都可以往这个地址发请求。不验签黑客伪造一个“支付成功”通知你的订单就“被支付”了商家傻乎乎出了餐最后对账才发现钱根本没到账。微信支付 v3 的签名算法是WECHATPAY2-SHA256-RSA2048验签需要微信支付平台证书官方 Java SDK 里都封装好了千万别自己写正则去抠签名头拼接字符串容易拼错。验签通过之后还要做一次二次确认用out_trade_no调微信支付“查询订单”接口核对查询结果确实是SUCCESS、金额也一致再更新本地订单状态。这是防御“假回调”最有效的手段因为查询接口走的是双向认证黑客伪造不了。5.2 重复回调与并发更新的防御微信支付回调有个特点可能重复通知。如果后端处理成功但没有按微信要求返回 200 加成功报文微信会在几秒、几分钟后重试。所以回调逻辑必须幂等。我的做法是进入回调后先查订单如果订单已经是“待接单”状态直接返回成功应答不再重复更新。如果订单还是“待付款”才去执行业务更新。同时更新 SQL 里加上状态条件把“符合条件才更新”做进 SQL 里UPDATE orders SET status 2, pay_status 1, checkout_time NOW() WHERE id #{id} AND status 1返回影响行数为 0说明订单状态已经不是待付款不需要再处理。这种条件更新的方式比在 Java 层加锁更简洁可靠能天然挡住并发问题。我再强调一次状态流转永远带AND 原状态这是整个订单模块最重要的编码习惯。5.3 支付成功但订单超时取消的冲突处理这是整个模块里最恶心的边界情况用户下单后一直不支付到了 15 分钟超时任务把订单取消status6结果用户刚好在最后一刻付了款。微信回调到了发现订单已取消怎么办正确姿势是回调里发现订单是“已取消”状态不能直接改回去否则商家看不到单但钱已经扣了对账必出问题。应该核对回调金额和订单金额如果一致且订单已取消就发起退款把钱退回用户同时把订单的pay_status改成退款状态。这也是为什么订单表里必须完整保留金额信息退款要按原始金额退。我见过有同事图省事在回调里无条件 UPDATE 订单状态结果超时取消和支付回调并发时订单状态一会儿取消一会儿待接单商家端疯狂报警。状态机加条件更新是真的能救命的。6. 超时未支付订单自动取消轮询方案与边界处理6.1 定时任务扫描的取舍未支付订单不能一直占着业务上一般定 15 分钟超时。实现方案有三种Spring Task 定时扫描订单表苍穹外卖项目最常用RabbitMQ 延迟消息队列Redis 过期键监听苍穹外卖这种体量用 Spring Task 就够了。每分钟跑一次扫描 SQL把status 1且order_time NOW() - 15 分钟的订单批量捞出来逐条改成已取消。代码大致如下Component public class OrderTimeoutTask { Autowired private OrderMapper orderMapper; Scheduled(cron 0 * * * * ?) // 每分钟执行一次 public void processTimeoutOrder() { LocalDateTime threshold LocalDateTime.now().minusMinutes(15); ListOrders timeoutOrders orderMapper.getByStatusAndOrderTime(Orders.PENDING_PAYMENT, threshold); for (Orders order : timeoutOrders) { orderMapper.updateStatusWithCondition( order.getId(), Orders.PENDING_PAYMENT, Orders.CANCELLED ); } } }注意updateStatusWithCondition同样带状态条件防止和支付回调抢状态。Spring Task 的瑕疵是可能存在扫描延迟但 15 分钟的超时窗口下晚个几秒完全可接受。6.2 取消逻辑里最容易漏掉的细节超时取消有三个细节非常容易漏。一是取消后要不要给用户发通知。我的建议是发至少在小程序里让用户看到“订单已超时取消”的状态不然用户还以为订单下成功了等半天没动静。二是被取消的订单如果用户重新下单购物车要不要恢复。苍穹外卖的做法是下单时购物车就清空了超时取消后购物车不会自动恢复但用户能在历史订单里看到订单详情和取消原因。这个产品逻辑需要前后端对齐写进接口文档里别让前端猜。三是定时任务的时间阈值要覆盖“下单时刻”而不是“当前时间”。SQL 里用order_time threshold不能写成order_time threshold更不能用当前时间去减 15 分钟再比对当前时间这个属于 SQL 基本功但确实有同事写错过扫描出来的全是当天所有订单。7. 实测复盘这一模块最容易翻车的几个地方7.1 浮点精度在金额上的经典翻车我在联调时亲自踩过三个菜品单价分别是 3.9、4.5、8.8用double求和后展示给用户的金额是17.199999999999996前端显示成 17.2 看着还行但传给微信支付的“分”是 1719数据库里decimal是 17.20一分钱对不上。虽然一分钱不至于出大事但到了月末对账这种误差会被财务揪出来改起来还特别痛苦因为脏数据已经落库了。解决方案就是第 3 节说的全链路BigDecimal精确到分再计算任何一步都不允许出现double。这个规范要从一开始就定下来团队里每个人都要遵守。7.2 索引缺失让超时扫描变成全表扫第一次功能测试订单量才几万条定时任务跑得飞快。压测到几十万条后发现每分钟的定时任务要跑好几秒数据库 CPU 直接飙高。EXPLAIN一看status order_time没有索引全表扫描加文件排序。加了联合索引之后单次扫描从 2 秒降到 10 毫秒。这个教训提醒我任何定时任务要查询的筛选条件都必须在建表时就考虑好对应索引而不是等数据量上来了再去补救。7.3 用户重复提交与前端按钮置灰用户在结算页面快速点两下“提交订单”后端可能收到两个一模一样的请求产生两条订单。光靠前端按钮置灰不够因为网络重试、页面刷新都会造成重复提交。我在下单服务入口用 Redis 做了简单的防重用userId 订单号前缀做 key如果 key 存在就直接拒绝。也可以用订单号唯一索引兜底但订单号是每次生成的兜不住重复提交所以还是得靠 Redis 锁或分布式锁。关于支付测试还有一个强烈建议微信支付有沙箱和模拟环境开发联调阶段不要用真实商户号反复扣款哪怕金额再小也会影响商户信誉和结算。先用项目里的模拟支付接口或微信支付沙箱跑通全流程再切真实支付做验收。我当时连接真实支付后发现一个问题——模拟支付和真实支付的回调报文结构有差异解密逻辑要写成兼容两种格式的不然切环境的时候又会踩一遍坑。
网站建设高端定制企业官网