
1. 先把整条支付链路在脑子里跑一遍上周半夜有个朋友发消息过来说他那个手机网页上的立即支付按钮点下去控制台一片红后端日志却干干净净连个错误堆栈都没有。我让他把前端打印的参数贴过来一眼就看出问题——他直接把后端返回的一整串对象丢给了前端调起接口里面混着私钥字段。这种事故在 JSAPI 支付接入里太常见了前后端边界没划清楚谁都以为自己只做了一小部分结果整条链路谁也说不明白。这篇接着上一篇的环境准备往下走专门讲流程和代码。要接的是某第三方支付平台的 JSAPI 支付场景就是用户在手机浏览器或者应用内置的网页容器里点一下按钮唤起平台的收银台完成付款。Java 后端负责下单、签名、接收通知前端负责拿到支付参数后调起支付并处理结果。适合谁看写过 Spring Boot、能看懂 RSA 签名但没真正落地过支付的开发者尤其适合第一次把支付接进生产环境、心里没底的同学。看完至少能搞清楚三件事钱是从哪一步开始动的、哪个环节能信哪个环节不能信、出了问题该从哪查。1.1 五个角色和它们各自的职责边界把支付拆开看其实是五方在对话用户、商户前端页面、商户后端服务、平台开放网关、平台的异步通知服务。任何一方理解错自己的职责链路就断。用户只做一件事——确认付款。前端页面做两件事——把用户的支付意图传给后端、拿到后端给的参数去调起平台的收银台。后端服务做三件事——按平台规则组装并签名下单请求、把结果中能暴露给前端的部分裁剪出来、接收平台的异步通知并改自己数据库里的订单状态。平台网关做两件事——校验签名、生成交易单。平台的通知服务负责在交易状态变化后主动回调商户配置的地址。这里有个特别容易被忽略的点后端不只是转发它是唯一有权签名的一方。私钥只能待在服务器上前端拿到的应该是一段平台生成的、有过期时间的支付凭据字符串而不是原始签名材料。我见过有人图省事把签名逻辑写到 Node 中间层私钥打进前端包这个风险不用多说。另外一个边界是前端永远不知道订单最终是否支付成功。它能拿到的只有我调起收银台了用户点了取消收银台关了这类交互层面的信号。真正的支付结果只有后端收到异步通知才算数。这条边界划不清后面所有的对账逻辑都会崩。1.2 为什么一定要后端下单、前端只拿参数有人问平台不是提供了纯前端的调起方式吗为什么还要后端绕一圈原因有三个都很实在。第一是签名。下单请求需要应用私钥签名私钥放到浏览器里等于公开。哪怕你用代码混淆也只是提高一点成本抓包一样看得到。第二是金额。金额如果由前端传用户改一下请求体把 199 改成 0.01后端不校验就直接下单这就是白送。正确的做法是前端只传商品 ID 或订单号后端自己去数据库查价格查不到就拒绝。第三是订单号。平台要求商户订单号全局唯一如果前端生成重复提交、网络重试都可能撞号。后端生成时可以带业务前缀、日期和序列可控得多。所以标准姿势是前端调后端接口POST /api/pay/create后端生成内部订单、调用平台预下单、把平台返回的支付凭据裁成一个精简对象返回前端拿这个对象去调起收银台。这条链路里前端从头到尾没有碰过私钥也没有决定过金额。1.3 同步跳转和异步通知别搞混了这是新手最大的坑没有之一。用户在收银台付完款平台会做两件事一是把浏览器跳回你配置的同步返回地址二是在后台偷偷给你配置的异步通知地址发一条 POST 请求。这两件事长得像性质完全不同。同步跳转是给用户看的。它带着一些参数回到你的页面你可以用它来展示支付完成正在确认但它不能作为发货依据。原因很简单用户可以手动构造这个跳转 URL也可以在中途关掉浏览器导致跳转根本没发生。你要是信了它就等于把发货权限交给了浏览器地址栏。异步通知是给服务器看的。它是平台服务器直接 POST 到你的公网地址带着签名你可以验签确认来源。只有验签通过、金额匹配、状态正确之后才能改订单状态、发货、加积分。而且它可能重复发、延迟发、乱序发所以处理逻辑必须幂等。我在实际项目里的做法是同步跳转页面只负责轮询后端订单状态每 1.5 秒查一次最多查 10 次查到已支付就跳成功页查不到就提示支付结果确认中请稍后在订单列表查看。这样即使异步通知延迟几分钟用户体验也不会崩。2. 关键参数与签名机制拆解签名是整篇文章里最容易出错的部分也是排查成本最高的部分。我见过有人为了一个签名错误查了一整天最后发现是配置文件里私钥末尾多了一个换行。所以这块我讲细一点。2.1 一次预下单请求到底要带哪些字段平台的预下单接口字段不少但真正决定成败的就那么几个。我按重要性排一下字段是否必填说明常见错误应用标识 appId必填平台分配的应用唯一标识沙箱和生产的 appId 搞混商户号 merchantId必填收款账号与 appId 不匹配导致无权限商户订单号 outTradeNo必填全局唯一建议 32 位以内重复提交导致下单失败订单金额 totalAmount必填单位是分整数传了元或用浮点导致精度丢失商品标题 subject必填展示给用户看含特殊字符未编码异步通知地址 notifyUrl必填公网可达的 HTTPS 地址带了 query 参数或内网地址同步返回地址 returnUrl选填支付后跳回的页面用了 http 导致跳转被拦支付超时时间 timeoutExpress选填如 30m设得太短用户来不及付业务扩展参数选填透传给前端塞了敏感信息金额这个字段单独拎出来说。平台收的是分而业务系统里存的一般是元带两位小数。转换的时候千万别用double用BigDecimal的movePointRight(2)然后longValueExact()。为什么强调longValueExact因为如果业务数据里出现了19.999这种脏数据longValue()会静默截断成 1999而longValueExact()会直接抛异常让你在测试阶段就发现问题而不是上线后对账差一分钱。订单号的生成我一般用业务码 yyyyMMdd 数据库自增或雪花 ID比如ORD20250612加上 18 位数字。好处是看日志的时候一眼能定位到是哪天的单子排查效率高很多。2.2 RSA2 签名的生成与验签原理平台主流用的是 RSA2也就是SHA256withRSA。原理说起来简单你用应用私钥对一段规范化后的参数串做签名平台用你上传的应用公钥验证反过来平台用它自己的私钥对通知内容签名你用平台公钥验证。这是一对钥匙各管一个方向。打个比方私钥就像你家里的印章公钥是印出来的章样。你盖章别人拿章样比对能确认这东西确实是你盖的但别人拿着章样盖不出你的章。所以私钥必须自己收好公钥可以放心交给平台。签名的关键在规范化这四个字。流程是把除签名字段外的所有非空参数按参数名的 ASCII 码从小到大排序用keyvalue的形式用连起来得到一个待签串再对这个串做 SHA256 摘要并用私钥加密最后 Base64 编码。有几个细节必须注意空值参数要剔除。值为空字符串或者 null 的字段不参与签名但也不能传过去否则平台那边算出来的串和你不一样。value 用原始值签名不要提前做 URL 编码。签名算完再整体编码传输顺序反了必错。数字和字符串都当字符串处理。金额100和100在拼串时是一样的别自作聪明加引号。不要有多余空格。配置中心复制密钥时经常带上首尾空格肉眼看不出来。验签是反过来的拿到通知里的全部参数含 sign 和 signType剔除 sign按同样规则拼串用平台公钥验证 sign。这里有个容易漏的点——验签要用平台公钥不是应用公钥。两者都是公钥混用的话验签永远不过而报错信息只会告诉你验签失败。2.3 密钥怎么管别等出事再想密钥管理的原则就一条不进代码仓库不写死在代码里不打印到日志里。我的做法是把私钥放在配置中心或者服务器上的一个只有应用账号可读的文件里应用启动时读进内存转成PrivateKey对象缓存起来后续复用。配置文件里只放一个路径或者密文引用。生产、预发、沙箱三套密钥完全隔离文件名带上环境前缀避免手滑。日志脱敏也得说一句。签名串、私钥、平台返回的原始报文这些进日志的时候都得处理。我的习惯是写一个工具方法只打印参数名和值的长度比如outTradeNo(len26)既能看到请求发出去了又不会泄露内容。真出问题需要看原始报文时临时开 debug 级别的开关排查完立刻关掉。还有个小经验密钥文件统一用 PKCS8 格式的私钥公钥注意有些平台要 PKCS1、有些要 PKCS8转换的时候去掉-----BEGIN-----这种头尾标记和所有换行只保留 Base64 主体。这一步做错的表现就是启动时报InvalidKeySpecException或者签名时报IllegalArgumentException。3. 后端代码实现从下单到回调理论讲完进入能抄的部分。下面的代码我做了泛化处理类名和字段名按你的项目习惯改就行逻辑是通用的。3.1 依赖引入与客户端初始化Maven 里加两个依赖就够一个是平台的官方 SDK如果有另一个是通用的 HTTP 客户端。我倾向于不用 SDK 的自动签名自己拼参数自己签出问题好排查。dependency groupIdcom.example.pay/groupId artifactIdpay-sdk/artifactId version1.0.0/version /dependency客户端初始化必须是单例而且要在启动时完成校验。下面这段是核心Configuration public class PayClientConfig { Value(${pay.gateway.url}) private String gatewayUrl; Value(${pay.app.id}) private String appId; Value(${pay.app.private-key}) private String privateKeyContent; Bean public PayClient payClient() throws Exception { // 启动即解析私钥配置错误在启动阶段暴露而不是等用户点支付 PrivateKey key KeyLoader.loadPrivateKey(privateKeyContent); PayClient client new PayClient(gatewayUrl, appId, key); client.setConnectTimeout(3000); client.setReadTimeout(8000); return client; } }超时时间这两个数字不是随便写的。连接超时 3 秒读取超时 8 秒是因为下单接口本身很快正常在 300 毫秒内返回。如果超过 8 秒还没结果大概率是网络抖动或者网关限流这时候宁可快速失败让用户重试也不要让请求线程挂在那里。线程池被慢请求占满整个服务都会被拖垮这个坑我在一个促销活动里踩过一次印象极深。启动时解析私钥这个设计也很关键。配置文件写错、密钥格式不对应用直接起不来比上线后用户点支付才报错要好得多。同理应用启动后可以加一个健康检查定期探测一次网关连通性把它接到你的监控体系里。3.2 统一下单接口的实现与参数组装先定义请求和响应的数据结构。请求体里只放商品 ID 和数量价格由后端查。Data public class CreatePayRequest { private Long productId; private Integer quantity; private String clientIp; }然后是核心的下单逻辑Service public class PayOrderService { Resource private PayClient payClient; Resource private ProductMapper productMapper; Resource private OrderMapper orderMapper; Resource private IdGenerator idGenerator; public PrepayVO createPayOrder(CreatePayRequest req, Long userId) { // 1. 查商品价格以后端为准 Product product productMapper.selectById(req.getProductId()); if (product null || product.getStatus() ! 1) { throw new BizException(商品不存在或已下架); } int qty req.getQuantity() null ? 1 : req.getQuantity(); if (qty 0 || qty 99) { throw new BizException(购买数量不合法); } // 2. 金额换算单位分用 BigDecimal 防精度问题 BigDecimal amountYuan product.getPrice().multiply(BigDecimal.valueOf(qty)); long amountFen amountYuan.movePointRight(2).longValueExact(); // 3. 落本地订单状态为待支付 String orderNo idGenerator.next(ORD); Order order new Order(); order.setOrderNo(orderNo); order.setUserId(userId); order.setProductId(product.getId()); order.setQuantity(qty); order.setAmount(amountYuan); order.setStatus(OrderStatus.PENDING); order.setCreateTime(new Date()); orderMapper.insert(order); // 4. 组装预下单参数 MapString, String params new TreeMap(); params.put(appId, payClient.getAppId()); params.put(merchantId, payClient.getMerchantId()); params.put(outTradeNo, orderNo); params.put(totalAmount, String.valueOf(amountFen)); params.put(subject, truncate(product.getTitle(), 60)); params.put(notifyUrl, notifyUrl); params.put(returnUrl, returnUrl); params.put(timeoutExpress, 30m); params.put(signType, RSA2); // 5. 签名并调用 String sign SignUtils.sign(params, payClient.getPrivateKey()); params.put(sign, sign); PrepayResp resp payClient.post(/gateway/precreate, params); if (!10000.equals(resp.getCode())) { // 记录原始返回便于排查 log.warn(预下单失败 orderNo{} code{} msg{}, orderNo, resp.getCode(), resp.getMsg()); throw new BizException(下单失败请稍后重试); } // 6. 裁剪返回给前端的部分只给凭据 PrepayVO vo new PrepayVO(); vo.setOrderNo(orderNo); vo.setPayToken(resp.getPayToken()); vo.setExpireSeconds(1800); return vo; } }这段代码里有几个刻意的设计。第一步查商品再算价是防止前端改价格。第三步先落本地订单再调平台是为了保证每一笔平台单都能在本地找到对应记录哪怕平台调不通本地也有条待支付的脏数据方便后续对账清理。第六步裁剪是为了不把sign、merchantId这些中间态暴露给前端。还有一点truncate函数别省。商品标题超过平台限制会被直接拒绝而报错信息往往是参数错误看不出是哪个字段。加个截断省事。3.3 异步通知的验签、金额校验与幂等处理这个接口是全篇最重要的地方写错就会丢钱或者重复发货。完整的处理顺序是收原始报文 → 验签 → 校验身份 → 校验金额 → 幂等判断 → 改状态 → 返回成功。RestController RequestMapping(/api/pay) public class PayNotifyController { Resource private PayNotifyService payNotifyService; PostMapping(/notify) public String notify(RequestParam MapString, String params) { log.info(收到支付通知 orderNo{}, params.get(outTradeNo)); try { boolean ok payNotifyService.handle(params); return ok ? success : fail; } catch (Exception e) { log.error(通知处理异常, e); return fail; } } }注意返回值。平台判断你要不要重发通知看的就是响应体里那个约定的成功标识。返回别的、或者返回一段 JSON、或者抛 500平台都会认为你没收好然后按策略重试。所以千万不要在这里return R.ok()那种带包装的 JSON除非平台文档明确说要那样。处理逻辑Service public class PayNotifyService { public boolean handle(MapString, String params) throws Exception { // 1. 验签 String sign params.get(sign); MapString, String copy new TreeMap(params); copy.remove(sign); copy.remove(signType); boolean signOk SignUtils.verify(copy, sign, platformPublicKey); if (!signOk) { log.warn(通知验签失败 orderNo{}, params.get(outTradeNo)); return false; } // 2. 校验来源身份 if (!appId.equals(params.get(appId))) { log.warn(通知 appId 不匹配); return false; } // 3. 校验订单存在且金额一致 String orderNo params.get(outTradeNo); Order order orderMapper.selectByOrderNo(orderNo); if (order null) { log.warn(通知对应订单不存在 orderNo{}, orderNo); return true; // 订单不存在也返回成功避免平台无限重试 } long notifyFen Long.parseLong(params.get(totalAmount)); long localFen order.getAmount().movePointRight(2).longValueExact(); if (notifyFen ! localFen) { log.error(金额不一致 orderNo{} notify{} local{}, orderNo, notifyFen, localFen); return true; } // 4. 幂等已经是终态就直接返回成功 if (order.getStatus() OrderStatus.PAID) { return true; } if (order.getStatus() OrderStatus.CLOSED) { log.warn(已关闭订单收到支付通知 orderNo{}, orderNo); return true; } // 5. 状态流转用带条件的更新保证并发安全 int rows orderMapper.updateToPaid(orderNo, params.get(tradeNo)); if (rows 0) { log.warn(状态更新未生效可能已被并发处理 orderNo{}, orderNo); } return true; } }第 5 步的updateToPaid要写成UPDATE orders SET status PAID WHERE order_no ? AND status PENDING。为什么要带状态条件因为平台可能同时发多条通知两个线程都读到 PENDING都去发货就重复了。带上条件数据库行锁保证只有一个能更新成功rows返回值直接告诉你谁赢了。金额校验那一步别省。验签通过只能证明报文是平台发的不能证明金额和你的订单一致。虽然理论上平台不会搞错但万一你的订单号生成有 bug 撞号了金额校验就是最后一道防线。3.4 订单状态机与对账补偿状态机我一般设计成四个状态待支付、已支付、已关闭、退款中。允许的流转是待支付 → 已支付、待支付 → 已关闭、已支付 → 退款中。其他流转全部禁止用枚举加一个canTransferTo方法卡住。为什么要有已关闭因为预下单之后用户不付订单会一直挂着。我一般起一个定时任务扫描创建时间超过 2 小时且状态还是待支付的订单调用平台的关单接口成功后本地置为已关闭。这样订单表不会无限膨胀商品库存也能及时释放。对账补偿是兜底。异步通知理论上不会丢但网络、网关、你自己的服务重启都可能造成漏处理。我一般做两层一层是准实时的每 5 分钟扫一次近 24 小时内状态为待支付但平台侧显示已支付的订单主动查单补齐另一层是 T1 的每天凌晨拉前一天的平台账单和本地订单逐笔比对差异生成报表发到告警群。查单接口的实现在这里就不展开了逻辑和下单类似签名、发请求、解析。关键是要低频、串行、带限流别把对账任务写成压测。4. 前端调起支付与结果处理前端这部分代码量不大但坑不少。很多问题不是逻辑错是环境和时序的问题。4.1 拿到支付凭据之后怎么调起后端返回的payToken是平台生成的凭据前端要做的是把它交给平台提供的调起入口。在浏览器环境里一般是动态加载平台的 JS 文件然后调用约定的方法在应用内置容器里通常是通过容器注入的桥接对象来调用。async function startPay(orderNo) { const res await fetch(/api/pay/create, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ productId: currentProductId, quantity: 1 }) }); const data await res.json(); if (data.code ! 0) { toast(data.msg || 下单失败); return; } const { payToken } data.data; // 浏览器环境使用平台 JS 提供的调起方法 if (window.PayBridge typeof window.PayBridge.tradePay function) { window.PayBridge.tradePay({ token: payToken, success: () onPayReturn(orderNo), fail: (e) onPayReturn(orderNo, e) }); } else { // 兜底跳转到平台收银台页面 location.href buildCashierUrl(payToken); } }这里有个容易踩的细节tradePay的回调只是告诉你收银台界面关闭了不代表支付成功。所以两个回调我都指向同一个onPayReturn进去之后统一去轮询后端订单状态而不是在 success 里直接跳成功页。buildCashierUrl这个兜底也别省。有些容器不支持桥接对象或者 JS 加载失败有个兜底路径至少不会让用户卡死。兜底 URL 里的凭据同样有过期时间一般 30 分钟过期了就重新下单。4.2 结果页的轮询与状态刷新function onPayReturn(orderNo) { let count 0; const timer setInterval(async () { count; const r await fetch(/api/order/status?orderNo${orderNo}); const s await r.json(); if (s.data PAID) { clearInterval(timer); location.replace(/pay/success?orderNo${orderNo}); } else if (s.data CLOSED) { clearInterval(timer); location.replace(/pay/fail?orderNo${orderNo}); } else if (count 10) { clearInterval(timer); location.replace(/pay/pending?orderNo${orderNo}); } }, 1500); }1.5 秒一次、最多 10 次也就是 15 秒的窗口。这个时间是我调出来的太短了后端压力大太长了用户等得烦。超过就跳确认中页面引导用户去订单列表看那里会持续展示最新状态。轮询的接口要做得轻只查订单状态别去调平台查单接口。平台查单有频率限制用户每 1.5 秒点一次几十个人同时付款就把配额打满了。本地状态由异步通知驱动更新轮询只是读本地。还有个体验细节从收银台返回时页面可能触发pageshow事件浏览器后退缓存也会让页面看起来没刷新。我一般在pageshow里判断event.persisted如果是从缓存恢复的强制重新拉一次状态避免用户看到过期的待支付。4.3 移动端浏览器兼容的那些细节几个我踩过的具体问题列出来供参考。第一底部按钮被键盘顶起。输入数量的时候键盘弹出固定定位的支付按钮位置会乱。用visualViewport监听高度变化或者干脆把按钮放在内容流里而不是 fixed。第二重复点击。网络慢的时候用户会连点每次点都下一单最后付了其中一单另外几单成了脏数据。前端一定要加一个提交中状态按钮置灰后端也要做防重同一个用户对同一个商品在 5 秒内只允许下一单。第三容器内返回后页面白屏。某些内置容器的 webview 在唤起外部收银台后返回时上下文会被重建。这种时候不要依赖页面内存里的变量把 orderNo 写到 URL 上靠 URL 恢复状态。第四HTTPS 混合内容。如果你的页面是 HTTPS但加载的 JS 是 HTTP浏览器会直接拦截。上线前把所有的资源地址都过一遍。5. 常见问题与排查技巧实录排查支付问题的核心思路是先定位问题在哪一段再看原始报文最后才怀疑签名。跳过前面两步直接盯着签名看是最低效的路径。5.1 报错对照表现象可能原因处理方式返回签名错误参数排序错、空值未剔除、私钥格式不对打印待签串本地复算比对返回应用无权限appId 与商户号不匹配或应用未上线核对两套 ID检查应用状态返回订单号重复同一订单号重复提交检查生成逻辑与防重返回金额非法传了元、用了浮点、含小数统一转成分的整数收银台打不开凭据过期或容器不兼容重新下单检查兜底路径支付完成但订单没变异步通知地址不通或处理报错查网关日志与本服务访问日志订单重复发货通知未做幂等加状态条件更新用户付了但显示未付只信了同步跳转改为轮询后端状态5.2 签名错误的排查顺序按这个顺序走能省掉 80% 的时间。第一步打印待签串。在签名方法里加一行日志把排序拼接后的字符串原样打出来看清楚有没有多余空格、有没有把空值字段拼进去、有没有把 sign 字段自己也算进去。第二步用工具本地复算。拿同一个待签串和同一个私钥用命令行或者一段独立的测试代码算一遍签名和后端算出来的比对。一致说明算法没问题问题在参数不一致说明密钥加载有问题。第三步检查密钥。-----BEGIN-----头尾是不是去干净了换行有没有残留私钥和公钥是不是一对。验证方法很简单用私钥签一段内容用对应公钥验能过就是一对。第四步检查字符编码。全部统一 UTF-8包括读配置文件那一步。有些环境的默认编码不是 UTF-8中文标题一进去签名就变了。第五步检查传输层。签名算完之后的 URL 编码是不是把、/、这些 Base64 里的字符搞坏了。这种情况表现为本地算的签名和网关收到的看起来一样但长度不同。5.3 通知收不到的排查顺序先确认地址通不通。用一台外网机器curl你的通知地址能不能正常返回。注意平台要求的是公网可达内网地址、带 query 参数的地址、带端口的非标地址都可能被拒。再确认协议。大部分平台要求 HTTPS且证书要有效。自签证书会被拒绝表现是连接失败而你的服务器上啥日志都没有。然后看请求有没有到你的服务。查 Nginx 访问日志或者网关日志看有没有对应的 POST 记录。完全没有说明在网络层就被拦了有记录但业务日志里没有说明是框架层拦掉了比如安全组、CSRF 拦截、或者参数解析失败。最后看处理逻辑。如果日志显示进来了但返回了 fail去看异常栈。最常见的是空指针——通知参数里某个字段你没判空就用了。提示通知处理失败时平台会按递增间隔重试一般是几分钟到几小时共若干次。所以在开发阶段如果你发现通知一直在重试记得先把测试环境的地址改掉或者返回成功别让平台白白重试一整晚。排查通知还有一个笨办法但很有效在通知入口的第一行把整个params打到日志里同时把本机时间也打上。这样即使后面逻辑全错你至少知道报文长什么样拿这个报文去本地复现验签问题定位能快很多。6. 上线前我一般会再过一遍的几件事代码写完到真正能用中间还差几步。这几步不做出问题的概率很高而且往往是深夜出问题。6.1 幂等和并发这两件事测试环境测不出来并发问题在测试环境几乎永远不会暴露因为就你一个人在点。上线后活动一开同一秒几十个人下单问题就来了。除了通知处理的幂等还要注意下单接口本身的并发。同一个用户快速点两次两次请求几乎同时到达都查到没有待支付订单都去创建最后生成两笔。解决办法是给用户 商品加一个短暂的分布式锁或者用数据库唯一索引兜住——建一个user_id product_id status的部分唯一索引插入冲突就返回已有订单。库存扣减同理。支付成功后的发货逻辑如果涉及库存一定要用UPDATE stock SET num num - 1 WHERE id ? AND num 1这种条件更新看影响行数判断是否成功。6.2 日志、监控和告警怎么配支付链路的日志我分成三类分别有不同用途。第一类是业务日志按订单号串联。从下单、预下单成功、通知收到、验签结果、状态变更每个节点都带 orderNo 打一行。出问题时grep一个订单号全链路就出来了。第二类是异常日志。所有调用平台的请求失败时把返回码和返回描述完整记录但不记签名和私钥。第三类是对账日志。每天的对账结果单独一个文件或索引差异笔数大于 0 就触发告警。监控指标我最少配三个下单接口的成功率、通知接口的 QPS 和处理耗时、待支付订单的积压数量。第三个指标特别有用正常情况下待支付订单会随时间自然衰减如果突然开始增长说明下单成功但用户付不了款通常意味着收银台那边出了问题。6.3 灰度怎么走支付功能的上线我一般分三步走。第一步在测试环境用沙箱账号跑通全流程包括正常支付、取消支付、超时关单、通知重发这四种场景。特别要手动构造一次重复通知确认幂等生效。第二步生产环境小流量灰度。找一个内部账号把真实金额调到 0.01 元走一遍完整流程确认钱能到账、订单能变状态、对账能匹配。这一步别省沙箱和生产的差异比你想象的大。第三步放开给一部分用户观察 24 小时。看通知成功率、看有没有卡在待支付的单子、看客服有没有反馈付了钱没到账。灰度期间有个小技巧给灰度用户的订单号加一个特殊前缀这样在日志里一眼就能区分出问题能快速切回去。我个人在实际操作中的体会是支付这块的代码量其实不大一个下单接口、一个通知接口、一个查单接口加起来几百行。真正花时间的是把边界想清楚、把异常路径走一遍、把日志配好。新人最容易犯的错是急着让支付按钮能点通点通了就以为完事了结果上线第一周就在对账上加班。宁可前面多花两天把幂等、金额校验、对账这三块写扎实后面能省下很多个不眠之夜。另外补一个小技巧把平台文档里的错误码整理成一个枚举每个错误码配一句人话解释和一个建议动作比如订单号重复 → 检查是否重复提交建议直接返回用户已有订单。排查的时候直接查表比对着文档一页页翻快得多。这个枚举文件我一般放在项目根目录的docs下谁接手都能看懂。