《苍穹外卖》做到第9天算是真正摸到外卖系统的命门了。前8天我们把管理端的员工、分类、菜品、套餐都处理完用户端也打通了微信登录、菜品浏览、购物车、地址簿和下单。但下单做完之后我心里始终悬着一件事用户在小程序里付了钱我的服务端怎么才能第一时间知道支付成功之后商家的订单状态又怎么自动流转用户等急了想催单总不能靠打电话吧day9的核心就是把“用户下单”这条链路的收尾工作全部补上——微信支付、支付结果通知、订单状态更新、用户催单、超时未支付自动取消。这篇文章记录一下我在这一天的完整实现过程包含核心代码、配置逻辑以及真正联调时踩过的几个坑。正在跟这个项目的同学可以直接对照着排查想通过经典外卖项目学习Spring Boot、微信支付对接、WebSocket实时通信的同学看完这篇应该能对这几块有个整体且落地的认识。1. day9要打通的三条链路支付、催单、超时关单1.1 前8天做到了哪一步聊day9之前有必要先把项目进度摆清楚。苍穹外卖这个项目分管理端和用户端管理端跑在Spring Boot里用户端是微信小程序。前8天做完的东西大致是这样管理端员工登录、员工管理、分类管理、菜品管理、套餐管理、店铺营业状态。用户端微信登录、用户端分类与菜品浏览、购物车、地址簿、用户下单。到这个节点其实已经有一个很完整的“用户能下单”的雏形了用户浏览小程序、加购物车、填地址、提交订单后端生成一条状态为“待付款”的订单记录。但从业务闭环看这是断的。订单停在“待付款”状态出不来因为用户在小程序里根本没有完成支付动作后端也收不到任何“钱到了”的通知。换句话说这还是一个只存在于数据库里的订单不是一个真正会被商家接单的外卖单。day9要做的就是让订单状态真正活起来。1.2 三条链路各自的业务闭环day9的内容看起来又是支付又是WebSocket又是定时任务好像很杂其实把它拆成三条业务链路就清楚了。第一条是支付闭环。用户发起支付后端调用微信支付接口生成支付参数小程序调起微信收银台用户输入密码完成支付微信服务器回调我们的接口后端验签之后把订单从“待付款”改成“待接单”。这中间任何一步断了用户的钱和订单就对不上。第二条是催单闭环。用户下单后等了半天没人接单点一下“催单”这个动作要实时触达商家端。传统做法是小程序轮询但这既浪费资源又有延迟这里用的是WebSocket全双工通信由服务端把催单消息主动推给商家后台页面。第三条是超时关单。用户提交订单后一直不支付如果系统不管这张订单会永远占着一个“待付款”状态商家看不到、库存被占、订单列表也乱。这里用Spring Task定时任务定期扫描超时未支付的订单自动取消并释放资源。三条链路合起来才是完整的“用户下单到商家接单”流程。下面按实际开发顺序展开。2. 微信支付接入前的准备这些配置少一个都调不通很多人第一步就卡在微信支付的准备工作上不是因为代码难而是因为搞不清那一堆id、密钥、证书到底谁管谁。我先说结论微信支付APIv3这套体系里最关键的是两个签名方向和一份加密密钥。2.1 必要的商户配置清单接入微信支付先要去微信商户平台申请商户号拿到下面这些信息。我列个表建议直接存在项目配置里对应的地方配置项含义主要用途appid小程序应用ID标识是哪个小程序在收款mchid微信支付商户号标识是哪个商户在收款APIv3密钥32位字符串密钥解密支付回调中的敏感数据自己设置商户API证书包含商户私钥的证书文件请求微信支付接口时给请求签名微信支付平台证书微信官方公钥证书验证微信回调消息是不是真的来自微信notify_url支付结果回调地址微信支付成功后通知我们后端的地方其中最容易混淆的是商户API证书和微信支付平台证书。简单说商户API证书里装的是我们自己的私钥用来证明“这个请求是苍穹外卖这个商户发出去的”微信支付平台证书装的是微信的公钥用来证明“这个回调消息是微信发过来的”。两把钥匙方向不同千万别用反。申请完商户号之后还要设置APIv3密钥这个密钥不参与请求签名而是用来解密微信回调报文里的密文。我一开始以为它也是签名用的后来调试回调解密一直报错才意识到它只负责AES-256-GCM的解密。这两个体系的职责要记清楚。2.2 内网穿透工具解决本地回调地址问题微信支付有个绕不开的约束notify_url必须是公网可以访问的HTTPS地址。但我们本地开发时项目跑在localhost:8080微信服务器不可能访问到。解决思路是用内网穿透工具把本机的8080端口映射成一个临时公网HTTPS地址。常见工具是cpolar或者natapp配置方式类似。我用的是cpolar一条命令就能把本地端口暴露出去cpolar http 8080启动后工具会生成一个类似https://xxxx.cpolar.cn的临时域名这个地址就是微信能访问到我们本机服务的入口。把这个域名拼上项目的回调接口路径填到notify_url里就行。这里有个非常坑的细节隧道工具生成的临时域名每次重启都会变。如果重启了cpolar就必须同步修改项目配置文件里的回调地址否则微信回调会一直打到旧地址上表现为“支付成功了但订单状态死活不更新”。后面联调踩坑部分我会再展开。配置拿到之后在项目的application.yml里加入如下配置sky: wechat: appid: 你的小程序appid secret: 你的小程序secret mchid: 你的商户号 mch-serial-no: 商户API证书序列号 private-key-file: classpath:apiclient_key.pem api-v3-key: 你的APIv3密钥 platform-cert-file: classpath:wechatpay_platform.pem notify-url: https://你的临时域名/user/order/pay/notify这里把证书文件放到resources目录下项目启动时会加载。证书序列号可以在商户平台证书管理页面看到它就是请求签名时Authorization头里serial_no要用的值。3. 服务端统一下单APIV3签名与JSAPI参数生成准备工作做完开始写核心代码。day9里的微信支付采用的是JSAPI方式也就是在小程序内拉起微信收银台。整体步骤是后端调用微信支付统一下单接口拿到prepay_id然后用prepay_id生成小程序端调起支付所需的参数小程序拿到这些参数后调wx.requestPayment用户确认支付微信异步回调我们的notify_url。3.1 下单接口的请求参数与业务校验先看下单接口。用户在小程序里点“去支付”前端把订单id传过来后端要做的第一件事不是调微信而是查库、校验。PostMapping(/user/order/pay) public ResultOrderPayVO pay(RequestBody OrderPayDTO orderPayDTO) { // 根据订单id查询订单 Orders order orderMapper.getById(orderPayDTO.getOrderId()); // 校验订单是否存在、是否属于当前用户、状态是否为待付款 if (order null || !order.getUserId().equals(currentUserId())) { return Result.error(订单不存在); } if (order.getStatus() ! Orders.PENDING_PAYMENT) { return Result.error(订单状态异常无法支付); } // 支付金额、订单号以数据库为准不信任前端传值 String orderNumber order.getNumber(); Integer amount order.getAmount().intValue(); // 单位分 // 调用微信统一下单 ... }这里有一个原则所有关键数据都从数据库读取绝对不依赖前端传过来的金额、订单号。原因很简单支付接口涉及真金白银如果前端能改金额那整个系统就是漏水的。下单接口还有一个前置条件需要有用户的openid。苍穹外卖在微信登录时已经把openid存进了用户表这里通过当前登录用户的id查出openid用于JSAPI支付时标记支付人。3.2 请求签名与发起支付请求微信支付APIv3接口时每个请求都要带签名。签名规则是把请求方法、请求路径、时间戳、随机串、请求体拼接起来用商户私钥做SHA256withRSA签名再把签名信息放进Authorization请求头。我封装了一个工具类核心代码是这样的public String buildAuthorization(HttpMethodEnum method, String urlPath, String body) { // 生成时间戳和随机串 String timestamp String.valueOf(System.currentTimeMillis() / 1000); String nonceStr UUID.randomUUID().toString().replace(-, ).substring(0, 32); // 构造待签名串HTTP方法、路径、时间戳、随机串、请求体每项以换行分隔 String message method.name() \n urlPath \n timestamp \n nonceStr \n body \n; // 使用商户私钥做SHA256withRSA签名 Signature signature Signature.getInstance(SHA256withRSA); signature.initSign(privateKey); signature.update(message.getBytes(StandardCharsets.UTF_8)); byte[] signBytes signature.sign(); String sign Base64.getEncoder().encodeToString(signBytes); // 拼接Authorization头 return WECHATPAY2-SHA256-RSA2048 mchid\ mchId \, nonce_str\ nonceStr \, timestamp\ timestamp \, signature\ sign \, serial_no\ mchSerialNo \; }这里容易写错的地方有两个。一是待签名串的最后一定要保留最后一个换行符微信对这一行的判断很严格二是签名算法名称是SHA256withRSA有的人写成RSA或者SHA1withRSA都会验签失败。签名构造好之后用HttpClient向统一下单接口发送POST请求String urlPath /v3/pay/transactions/jsapi; String body {\n \appid\: \ appid \,\n \mchid\: \ mchid \,\n \description\: \苍穹外卖订单\,\n \out_trade_no\: \ orderNumber \,\n \notify_url\: \ notifyUrl \,\n \amount\: { \total\: amount , \currency\: \CNY\ },\n \payer\: { \openid\: \ openid \ }\n }\n; HttpPost httpPost new HttpPost(https://api.mch.weixin.qq.com urlPath); httpPost.addHeader(Authorization, buildAuthorization(HttpMethodEnum.POST, urlPath, body)); httpPost.addHeader(Content-Type, application/json); httpPost.setEntity(new StringEntity(body, UTF-8)); String result httpClient.execute(httpPost, response - { String respBody EntityUtils.toString(response.getEntity(), UTF-8); if (response.getStatusLine().getStatusCode() 200) { return respBody; } throw new RuntimeException(微信支付下单失败 respBody); });成功之后响应体里会有prepay_id字段它是一个预支付交易会话标识后续生成小程序支付参数全靠它。3.3 返回给小程序端的调起支付参数后端拿到prepay_id之后还不能直接返回给前端。小程序调wx.requestPayment需要的是timeStamp、nonceStr、package、signType、paySign这五个参数其中paySign同样需要后端用商户私钥签名。签名的内容是构造出来的规则和请求签名类似String packageStr prepay_id prepayId; String message appid \n timestamp \n nonceStr \n packageStr \n; // 使用商户私钥SHA256withRSA签名结果Base64编码 String paySign sign(message); OrderPayVO vo new OrderPayVO(); vo.setTimeStamp(timestamp); vo.setNonceStr(nonceStr); vo.setPackageStr(packageStr); vo.setSignType(RSA); vo.setPaySign(paySign);这个paySign经常有人漏写或者写错会导致小程序调起收银台时报“支付签名验证失败”。它和请求签名用的是同一把私钥但待签名串内容完全不同千万不要把上一段的签名逻辑直接搬过来。返回给前端后小程序端代码大致是这样wx.requestPayment({ timeStamp: res.data.timeStamp, nonceStr: res.data.nonceStr, package: res.data.packageStr, signType: res.data.signType, paySign: res.data.paySign, success: () { // 用户支付成功这里可以做本地提示但最终以服务端回调为准 } });这里有个理念要强调小程序端的success回调只是表示用户完成了支付动作绝对不能直接拿它来更新订单状态。因为前端可以被伪造、被断点跳过真正可信的是微信服务器发给我们后端notify_url的那个异步回调。4. 支付结果通知回调验签与订单状态更新微信支付的核心流程是异步回调驱动的。用户在微信上确认支付后微信服务器会向notify_url发送一个POST请求告诉我们这笔订单的支付结果。这个回调才是订单状态更新的权威信源。4.1 回调接口的验签逻辑回调接口的第一个动作永远不是解析业务数据而是验签。验签的目的是回答一个问题这条通知真的来自微信支付而不是别人伪造的回调请求头里带了timestamp、nonce、serial_no、signature四个字段验签就是用微信支付平台证书里的公钥对timestamp \n nonce \n body \n这个串做SHA256withRSA验签。PostMapping(/user/order/pay/notify) public String payNotify(HttpServletRequest request) throws Exception { // 读取请求头中的签名信息 String timestamp request.getHeader(Wechatpay-Timestamp); String nonce request.getHeader(Wechatpay-Nonce); String signature request.getHeader(Wechatpay-Signature); // 读取请求体 String body request.getReader().lines().collect(Collectors.joining()); // 用微信支付平台公钥验签 String message timestamp \n nonce \n body \n; Signature sig Signature.getInstance(SHA256withRSA); sig.initVerify(platformPublicKey); sig.update(message.getBytes(StandardCharsets.UTF_8)); boolean ok sig.verify(Base64.getDecoder().decode(signature)); if (!ok) { // 验签失败直接返回失败微信会认为通知失败并重新发送 return failResponse(); } // 验签通过后再解密业务数据 ... }验签通过只能说明消息确实是微信发的但报文体里的内容还是密文。微信回调的body格式大致是这样的{ id: 回调通知id, event_type: TRANSACTION.SUCCESS, resource_type: encrypt-resource, resource: { ciphertext: 加密后的数据, nonce: 加密使用的随机串, associated_data: 附加数据 } }resource里的ciphertext需要用APIv3密钥做AES-256-GCM解密才能看到明文解密后的内容包含out_trade_no、trade_state、transaction_id等关键字段。这就是我之前说的两套体系公钥验签证明来源可信APIv3密钥解密拿到业务详情两者缺一不可。4.2 解密回调内容并更新订单状态AES-256-GCM解密在Java里的实现比较固定直接封装一个方法private String decryptResource(JSONObject resource) { String ciphertext resource.getString(ciphertext); String nonce resource.getString(nonce); String associatedData resource.getString(associated_data); // 微信回调密文的实际结构nonce(12字节) 密文 认证标签(16字节) byte[] cipherBytes Base64.getDecoder().decode(ciphertext); byte[] nonceBytes nonce.getBytes(StandardCharsets.UTF_8); byte[] associatedBytes associatedData.getBytes(StandardCharsets.UTF_8); Cipher cipher Cipher.getInstance(AES/GCM/NoPadding); SecretKeySpec keySpec new SecretKeySpec(apiV3Key.getBytes(StandardCharsets.UTF_8), AES); GCMParameterSpec gcmSpec new GCMParameterSpec(128, nonceBytes); cipher.init(Cipher.DECRYPT_MODE, keySpec, gcmSpec); cipher.updateAAD(associatedBytes); byte[] plainBytes cipher.doFinal(cipherBytes); return new String(plainBytes, StandardCharsets.UTF_8); }解密拿到明文后取出订单号out_trade_no和支付结果trade_state判断trade_state是SUCCESS再更新订单JSONObject plain JSON.parseObject(plainText); String outTradeNo plain.getString(out_trade_no); String tradeState plain.getString(trade_state); if (SUCCESS.equals(tradeState)) { // 根据订单号查询订单 Orders order orderMapper.getByNumber(outTradeNo); // 原子更新只有待付款状态才允许改成待接单 Integer updated orderMapper.updateStatusIfPending( order.getId(), Orders.TO_BE_CONFIRMED, Orders.PENDING_PAYMENT); // 更新支付相关信息 order.setPayStatus(Orders.PAID); order.setPayTime(LocalDateTime.now()); order.setPayMethod(Orders.WECHAT); order.setStatus(Orders.TO_BE_CONFIRMED); orderMapper.update(order); }4.3 幂等处理与重复通知退回微信回调有一个特性如果接收方返回给微信的响应不是成功状态微信会按策略重复推送最多可能推送好几次。所以回调接口必须保证幂等——同样的通知来几次订单状态也只能被正确更新一次。最简单的幂等做法就是状态判断订单当前状态是“待付款”才允许更新为“待接单”如果已经更新过了第二次回调进来发现状态已经不是待付款就直接返回成功不再重复处理。这样既保证状态不倒退又能让微信停止重试。这里还容易踩一个坑更新状态和更新支付时间最好放在同一个数据库事务里不要先查一遍、判断一遍、再改一遍多个步骤之间出现并发时会互相覆盖。update语句里加上where status 1这种条件比在应用层加锁要靠谱得多。处理完成后要给微信返回一个成功响应格式是固定的{ code: SUCCESS, message: 成功 }如果返回其他内容微信会视为失败并继续重试。回调接口一定不要轻易吞异常该返回成功就返回成功该返回失败就返回失败按照状态机的规则来处理。5. 用户催单WebSocket实时推送的实现与连接管理支付闭环做完之后用户下单到支付成功的链路算是通了。这时候业务上又冒出新的需求用户下单很久商家没接单用户想催一催。催单功能的本质是“实时把消息从服务端推到商家端”这就要用到WebSocket。5.1 为什么选择WebSocket可能有人会问为什么不直接在商家后台写个轮询接口前端每秒钟请求一次“有没有催单消息”轮询的思路在最简单的场景下能跑通但有两个问题一是请求大部分时间是空的白白消耗服务器资源和带宽二是实时性不好轮询间隔设短了服务器压力大设长了用户催单的消息被延迟展示体验很差。WebSocket是服务端主动推送的方案客户端和后端建立一条持久的TCP连接后端有消息随时可以推过去既实时又不浪费资源。对于外卖这种“催单”“来新单”场景它是最自然的选型。5.2 服务端集成WebSocket苍穹外卖这个项目用的是Spring Boot嵌入的WebSocket支持实现方式是用ServerEndpoint注解开发服务端端点。先加依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-websocket/artifactId /dependency然后注入ServerEndpointExporter让Spring Boot能识别ServerEndpoint注解的类Configuration public class WebSocketConfiguration { Bean public ServerEndpointExporter serverEndpointExporter() { return new ServerEndpointExporter(); } }接着写WebSocket服务端的处理器。这个类的作用是维护所有在线的WebSocket连接并提供群发消息的方法Component ServerEndpoint(/ws/{sid}) public class WebSocketServer { private Session session; private String sid; private static MapString, Session sessionMap new ConcurrentHashMap(); OnOpen public void onOpen(Session session, PathParam(sid) String sid) { this.session session; this.sid sid; sessionMap.put(sid, session); } OnMessage public void onMessage(String message) { // 收到客户端消息时的处理 } OnClose public void onClose() { sessionMap.remove(sid); } OnError public void onError(Throwable error) { error.printStackTrace(); } // 向所有在线客户端推送消息 public static void sendMessageToAll(String message) { for (Map.EntryString, Session entry : sessionMap.entrySet()) { try { entry.getValue().getBasicRemote().sendText(message); } catch (Exception e) { e.printStackTrace(); } } } }这里有几个经验点。sessionMap用ConcurrentHashMap是必须的因为WebSocket的onOpen、onClose可能被多个线程同时触发普通HashMap会出现安全问题。另外onClose的时候一定要移除session否则连接早就断开了sessionMap里还留着无效连接消息推送时会报错。5.3 催单消息的推送与展示催单业务的后端逻辑不复杂。用户在小程序里点“催单”请求后端接口后端先查询订单确认订单存在然后组织一条消息推给商家端。GetMapping(/user/order/reminder/{id}) public Result reminder(PathVariable Long id) { Orders order orderMapper.getById(id); if (order null) { return Result.error(订单不存在); } // 组装催单消息 MapString, Object map new HashMap(); map.put(type, 2); // 1表示来单提醒2表示催单 map.put(orderId, order.getId()); map.put(content, 客户催单请尽快处理); // 向商家后台推送 WebSocketServer.sendMessageToAll(JSON.toJSONString(map)); return Result.success(); }商家后台的管理员页面通过WebSocket连到同一个端点前端在监听到type为2的消息时弹出一个“有客户催单”的提示并播放提示音。这样就完成了整个催单闭环。实际调WebSocket时需要注意公司的内网环境或Nginx代理可能会拦截WebSocket升级请求。如果商家后台部署在Nginx后面需要在location配置里加上Upgrade相关的请求头透传否则商家端会一直处于连接失败状态。本地联调没有这个问题一上测试环境就出这个坑我们后面专门说。6. 超时未支付订单Spring Task定时扫描与取消支付闭环和催单闭环做完之后day9还剩最后一条闭环超时未支付订单的处理。用户提交订单后如果一直不付钱系统必须有个兜底机制把这笔订单取消掉否则“待付款”的脏数据会越积越多。6.1 定时任务框架Spring Boot自带Spring Task使用非常简单。在启动类上加上EnableScheduling注解然后编写一个普通的Spring组件在方法上标注Scheduled并配置cron表达式这个方法就会按规则定时执行。EnableScheduling SpringBootApplication public class SkyApplication { public static void main(String[] args) { SpringApplication.run(SkyApplication.class, args); } }定时任务组件Component public class OrderTask { Autowired private OrderMapper orderMapper; Scheduled(cron 0 * * * * ?) // 每分钟执行一次 public void processTimeoutOrder() { // 扫描超时订单并取消 } }cron表达式是定时任务里最容易出错的点。0 * * * * ?表示每秒的第0秒开始每分钟执行一次如果想要比如“每30秒执行一次”可以用0/30 * * * * ?。不用死记硬背每次写完可以先用在线cron工具验证再上线。6.2 扫描超时订单的实现以超时15分钟未支付自动取消为例。逻辑分两步第一步查出所有“待付款”且创建时间早于当前时间15分钟的订单第二步把这些订单的状态改成“已取消”同时记录取消原因和取消时间。Component public class OrderTask { Autowired private OrderMapper orderMapper; Scheduled(cron 0 * * * * ?) public void processTimeoutOrder() { LocalDateTime now LocalDateTime.now(); LocalDateTime timeoutTime now.minusMinutes(15); ListOrders timeoutOrders orderMapper.selectByStatusAndCreateTime( Orders.PENDING_PAYMENT, timeoutTime); if (timeoutOrders ! null !timeoutOrders.isEmpty()) { for (Orders order : timeoutOrders) { order.setStatus(Orders.CANCELLED); order.setCancelReason(超时未支付系统自动取消); order.setCancelTime(LocalDateTime.now()); orderMapper.update(order); } } } }对应的Mapper查询语句用MyBatis写select idselectByStatusAndCreateTime resultTypecom.sky.entity.Orders select * from orders where status #{status} and create_time lt; #{time} /select这里稍微提醒一下订单金额、超时时间这种指标各个项目都不一样苍穹外卖课程里用的是分钟级扫描测试的时候可以把cron改成间隔10秒再把超时时间临时改成2分钟方便快速看到效果。正式环境一定要把参数做成配置项不要硬编码在代码里。6.3 cron表达式与任务幂等定时任务上线之前必须想清楚一个问题这个任务会不会重复执行在单机场景下Spring Task不会出现同一个任务并发执行的情况因为默认是同步串行的。但如果项目将来拆成多实例部署两台机器同时跑定时任务就会发生同一个订单被扫两次、更新两次的尴尬情况。多实例场景下的通用解法是引入分布式锁比如用Redis的setnx命令抢锁抢到的实例执行任务抢不到的实例直接跳过。代码如下Scheduled(cron 0 * * * * ?) public void processTimeoutOrder() { // Redis分布式锁key用任务名防止多实例重复执行 boolean locked redisTemplate.opsForValue() .setIfAbsent(task:processTimeoutOrder, 1, Duration.ofMinutes(1)); if (!locked) { return; } try { // 原来的业务逻辑 } finally { redisTemplate.delete(task:processTimeoutOrder); } }用Redis做定时任务锁锁的过期时间要大于任务的最大执行时间否则任务还没跑完锁就过期了另一个节点进来又跑一遍。这个细节在单机部署时感觉不到但属于必须知道的分布式经验。另外定时任务尽量安排在业务低峰期执行特别是全表扫描类的任务避免在中午、傍晚外卖高峰期抢数据库资源。7. 联调实测中遇到的四个典型坑day9这一天真正写代码的时间可能只占一半另一半全在联调排查。我记录一下自己踩过、也看到别人踩的几个典型问题提前避开能省不少时间。7.1 回调地址与隧道配置不一致这是最典型的“支付成功但订单状态不更新”的原因。现象是这样小程序里钱付成功了用户能看到微信支付成功页面但后台数据库里的订单一直停在“待付款”。排查链路很清晰先看后端日志有没有收到微信的POST请求。如果完全没有请求说明微信根本没找到我们的回调地址。用cpolar或者natapp之后临时域名是变化的一旦工具重启旧域名失效配置文件里的notify_url指向的还是旧地址微信自然打不进来。解决办法是下单接口和回调接口统一从一个配置项读取notify_url不要在下单代码里写死地址。每次隧道地址变化后全局改一处即可。另外回调接口尽量也把请求日志打出来收到回调后记录一条日志联调会发现这个习惯极其救命。7.2 金额单位转换微信支付接口里金额单位是分数据库里苍穹外卖项目的金额字段精度运算也可能直接用分存储但前端展示和用户输入通常都是元。两边如果不统一会出现支付金额比实际少100倍的严重事故。比如用户点了一个30元的套餐后端金额计算正确应该是3000分如果中间有个地方把30元当成3000元去请求微信收银台会让用户付3000元这绝对是生产事故级别的bug。实践上建议在DTO和VO的转换阶段就做统一用Long类型存储分转前端VO时再除以100转成BigDecimal元。所有金额字段都不要用double或float精度问题会坑死人。7.3 回调并发与重复更新微信支付的通知不是只发一次的网络异常时会重试。如果不做幂等一个订单可能会被更新两次甚至出现先更新的内容被后更新的旧数据覆盖。我见过一种写法是回调里先select订单判断status然后update。这种先查后改的操作在并发情况下不是线程安全的——两次回调同时查都查到status1然后都去update虽然最终结果可能还是对的但如果中间再有别的状态变更结果就乱了。正确做法是把状态判断写进update的where条件里update orders set status #{toStatus}, pay_time #{payTime} where id #{id} and status #{fromStatus}通过受影响行数判断这次更新是否生效如果affected为0说明这个订单已经被处理过了直接返回成功即可。这种方式既简单又从根本上避免并发覆盖。7.4 签名与证书的报错阅读微信支付APIv3的报错信息相对完整但新手经常忽略响应头里的Wechatpay-Serial等信息导致定位问题慢。常见签名错误有两类第一类是请求签名报错比如返回401或签名无效。这时候优先检查三点商户私钥有没有加载对证书序列号和商户证书是不是一套待签名串的格式有没有问题。私钥文件是apiclient_key.pem千万别和小程序secret搞混。第二类是回调验签失败返回内容是“验签失败”。这时候优先检查微信支付平台证书是不是最新的微信会定期轮换平台证书如果本地存的是旧证书验签就过不去。代码里最好做成自动更新平台证书的逻辑或者至少定期手动替换。这里再强调一个阅读报错的经验微信支付的错误响应不仅body里有message请求头里还带有Wechatpay-Serial和Wechatpay-Signature如果你发现body说验签失败先去看这个头对应的证书序列号是不是本地存的那个十次有八次是证书对不上。做支付模块的焦虑感很大程度来源于“我看不见钱到底走没走到”的不确定性。day9这一套做完之后我最大的体会是凡是涉及外部系统对接的模块永远要信服务端的回调不信前端的回调永远要用状态机约束订单的流转而不是靠散落的判断逻辑到处修改状态。苍穹外卖这个项目把这些经典问题都浓缩在了一起做完一遍再去看其他电商系统的订单支付模块基本都能看懂了。如果后面继续做day10的工作台统计你会发现Spring Task还能用来做营业数据的定时汇总WebSocket也能用来做“来单提醒”的实时推送。技术点都是通的重要的是先把支付这条链路跑稳。