
小程序支付回调怎么处理?从接收通知到更新订单状态
用户在小程序里完成支付后,前端可以拿到支付结果,但这不等于商户系统已经可靠地确认收款。小程序可能被关闭、网络中断,客户端也可能伪造请求。订单是否已支付,应由服务端依据微信支付的支付通知等可信信息确认。
本文以微信支付 API v3 的小程序支付为例,说明服务端如何接收支付通知、验签和解密、核对订单并安全更新状态。示例使用 Node.js、Express 和 SQLite;生产环境可将数据库部分替换为已有的 MySQL、PostgreSQL 等实现。
一、支付回调适用于什么场景
小程序支付通常由服务端先创建微信支付订单,再把必要参数交给小程序调用支付能力。用户完成支付后,微信支付会向商户配置的通知地址发送支付结果通知。这个服务端到服务端的通知,常被称作支付回调或支付通知。
回调的主要用途是:核实交易确实成功,将商户订单从“待支付”改为“已支付”,并触发后续履约流程,例如开通服务、发货或记账。回调不是给小程序页面展示用的,也不应直接信任客户端传来的“支付成功”字段。
建议同时处理两类信息:
- 支付通知:用于尽快更新订单状态。通知可能重复,处理必须幂等。
- 主动查单:用于通知暂未到达、通知处理异常或订单状态需要核实的情况。可以由后台任务或人工操作触发。
两者都应以服务端验证过的微信支付信息为依据。客户端支付成功回调适合用于更新页面提示,不适合作为商户订单入账的最终依据。
二、整体处理流程
- 在微信支付商户平台配置可从公网访问的 HTTPS 通知地址。
- 服务端接收通知的原始 HTTP 请求体,以及微信支付附带的验签请求头。
- 使用对应的平台证书或微信支付公钥验证签名。
- 验证成功后,用 APIv3 密钥解密通知中的加密资源。
- 核对商户号、应用 ID、商户订单号、支付状态和金额等字段。
- 在数据库事务中幂等地更新订单,并记录微信支付交易号。
- 处理成功后向微信支付返回成功应答;处理失败时返回非成功状态,便于通知重试和排查。
验签与解密各有用途,不能互相替代:签名验证用于确认通知内容来自可信的微信支付签名方且内容未被篡改;解密用于读取通知资源中的交易信息。只解密不验签,或只检查请求来源 IP,都不足以代替验签。
三、关键参数与安全边界
1. 原始请求体和验签请求头
API v3 通知请求中,常见的验签头包括 Wechatpay-Timestamp、Wechatpay-Nonce、Wechatpay-Signature 和 Wechatpay-Serial。验签时要使用请求的原始正文,签名消息按以下格式拼接,末尾包含换行符:
时间戳 + "\n" + 随机串 + "\n" + 原始请求体 + "\n"
如果框架先把 JSON 解析为对象,再重新序列化后验签,空格、字段顺序或转义形式都可能变化,导致验签失败。因此通知路由应保留原始请求体字节。验签时还要根据序列号选择正确的平台证书或公钥,不能只取“当前默认证书”而忽略轮换。
时间戳和随机串是签名消息的一部分,不应被当作订单身份或支付凭据。可以按微信支付文档建议检查通知时间是否过旧,并保持服务器时钟准确;但防重的核心仍是数据库中的幂等处理,而不是单靠时间戳拦截。
2. APIv3 密钥与资源解密
通知正文中的 resource 使用 APIv3 的 AEAD_AES_256_GCM 算法加密。商户配置的 APIv3 密钥用于解密,必须是 32 字节;它不是商户 API 证书私钥,也不是小程序支付参数里的签名密钥。nonce、associated_data 和 ciphertext 应按通知原值处理。GCM 的认证标签包含在 Base64 解码后的密文末尾,Node.js 示例会将末尾 16 字节作为认证标签。
签名校验使用微信支付平台证书或微信支付公钥对应的公钥,和 APIv3 密钥是两种不同凭据。平台证书或公钥需要通过官方渠道获取并妥善更新;发生证书轮换时,应维护序列号到证书的对应关系,避免新旧证书切换期间通知无法验签。
3. 必须核对的业务字段
验签、解密通过后,还要进行业务核对。通常至少检查:
appid是否为该笔订单使用的小程序应用 ID。mchid是否为预期商户号。out_trade_no是否对应本地订单。trade_state是否为SUCCESS。amount.total是否等于本地订单金额,币种是否符合业务预期。transaction_id是否已被其他订单使用,或与已处理的交易记录冲突。
微信支付金额字段以整数表示货币最小单位;人民币场景中通常按“分”核对,不能把元的小数值直接与其比较。不要使用浮点数做金额换算。订单金额应以服务端创建订单时保存的金额为准,而不是回调请求或客户端提交的金额。
四、Node.js 示例:Express 接收、验签、解密并更新订单
示例依赖 Node.js、Express 和 better-sqlite3。保存为 server.js 后,可用 npm install express better-sqlite3 安装依赖。示例要求配置平台证书序列号、公钥 PEM、APIv3 密钥,以及预期的应用 ID 和商户号。数据库中的待支付订单应由自己的下单流程创建,示例不会替代创建微信支付订单的逻辑。
生产部署时应使用正式数据库、密钥管理设施和可靠的证书更新流程。下面的环境变量方式仅用于说明配置接口,不建议把真实密钥写入代码仓库。
const express = require('express');
const crypto = require('node:crypto');
const Database = require('better-sqlite3');
const app = express();
const db = new Database(process.env.DB_PATH || 'orders.db');
const appId = process.env.WECHAT_APP_ID;
const mchId = process.env.WECHAT_MCH_ID;
const apiV3Key = process.env.WECHAT_API_V3_KEY;
const platformSerial = process.env.WECHAT_PLATFORM_SERIAL;
const platformPublicKeyPem = process.env.WECHAT_PLATFORM_PUBLIC_KEY;
if (!appId || !mchId || !apiV3Key || !platformSerial || !platformPublicKeyPem) {
throw new Error('缺少微信支付回调配置');
}
if (Buffer.byteLength(apiV3Key, 'utf8') !== 32) {
throw new Error('APIv3 密钥必须是 32 字节');
}
// 示例用一个序列号映射一个平台公钥。
// 生产环境应支持证书/公钥轮换,并按通知头中的序列号选择。
const platformKeys = new Map([[platformSerial, platformPublicKeyPem]]);
db.exec(`
CREATE TABLE IF NOT EXISTS orders (
out_trade_no TEXT PRIMARY KEY,
status TEXT NOT NULL,
amount_fen INTEGER NOT NULL,
appid TEXT NOT NULL,
mchid TEXT NOT NULL,
transaction_id TEXT UNIQUE
)
`);
function header(req, name) {
const value = req.headers[name.toLowerCase()];
return Array.isArray(value) ? value[0] : value;
}
function verifyNotification(req, rawBody) {
const timestamp = header(req, 'Wechatpay-Timestamp');
const nonce = header(req, 'Wechatpay-Nonce');
const signature = header(req, 'Wechatpay-Signature');
const serial = header(req, 'Wechatpay-Serial');
if (!timestamp || !nonce || !signature || !serial) {
throw new Error('缺少验签请求头');
}
const publicKey = platformKeys.get(serial);
if (!publicKey) {
throw new Error('未配置该序列号对应的平台公钥');
}
const message =
timestamp + '\\n' +
nonce + '\\n' +
rawBody.toString('utf8') + '\\n';
const ok = crypto.verify(
'RSA-SHA256',
Buffer.from(message, 'utf8'),
publicKey,
Buffer.from(signature, 'base64')
);
if (!ok) {
throw new Error('通知签名验证失败');
}
}
function decryptResource(resource) {
if (!resource || resource.algorithm !== 'AEAD_AES_256_GCM') {
throw new Error('不支持的通知加密算法');
}
const ciphertext = Buffer.from(resource.ciphertext, 'base64');
if (ciphertext.length <= 16) {
throw new Error('通知密文长度无效');
}
const encryptedData = ciphertext.subarray(0, ciphertext.length - 16);
const authTag = ciphertext.subarray(ciphertext.length - 16);
const decipher = crypto.createDecipheriv(
'aes-256-gcm',
Buffer.from(apiV3Key, 'utf8'),
Buffer.from(resource.nonce, 'utf8')
);
if (resource.associated_data) {
decipher.setAAD(Buffer.from(resource.associated_data, 'utf8'));
}
decipher.setAuthTag(authTag);
const plaintext = Buffer.concat([
decipher.update(encryptedData),
decipher.final()
]);
return JSON.parse(plaintext.toString('utf8'));
}
function acknowledge(res) {
return res.status(200).json({ code: 'SUCCESS', message: '成功' });
}
function reject(res, message) {
return res.status(500).json({ code: 'FAIL', message });
}
// 此路由必须在 express.json() 等会消费请求体的中间件之前注册。
app.post(
'/wechat/pay/notify',
express.raw({ type: 'application/json', limit: '1mb' }),
(req, res) => {
try {
if (!Buffer.isBuffer(req.body)) {
return reject(res, '请求正文格式错误');
}
verifyNotification(req, req.body);
const notification = JSON.parse(req.body.toString('utf8'));
const payment = decryptResource(notification.resource);
if (payment.trade_state !== 'SUCCESS') {
// 这不是成功支付通知,不得据此将订单改为已支付。
return acknowledge(res);
}
if (
payment.appid !== appId ||
payment.mchid !== mchId ||
!payment.out_trade_no ||
!payment.transaction_id ||
!Number.isSafeInteger(payment.amount?.total)
) {
return reject(res, '支付通知字段不符合预期');
}
const updateOrder = db.transaction(() => {
const order = db.prepare(
'SELECT * FROM orders WHERE out_trade_no = ?'
).get(payment.out_trade_no);
if (!order) {
throw new Error('找不到对应的本地订单');
}
if (order.appid !== payment.appid || order.mchid !== payment.mchid) {
throw new Error('应用 ID 或商户号不匹配');
}
if (order.amount_fen !== payment.amount.total) {
throw new Error('订单金额与支付金额不匹配');
}
// 重复通知:已由同一笔微信交易完成的订单,直接视为处理成功。
if (order.status === 'PAID') {
if (order.transaction_id === payment.transaction_id) {
return;
}
throw new Error('已支付订单的交易号不匹配');
}
if (order.status !== 'PENDING') {
throw new Error('订单当前状态不允许更新为已支付');
}
const result = db.prepare(`
UPDATE orders
SET status = 'PAID', transaction_id = ?
WHERE out_trade_no = ? AND status = 'PENDING'
`).run(payment.transaction_id, payment.out_trade_no);
if (result.changes !== 1) {
throw new Error('订单状态更新未生效');
}
});
updateOrder();
return acknowledge(res);
} catch (err) {
// 生产环境应记录结构化日志,并避免记录密钥、完整敏感请求数据。
console.error('处理微信支付通知失败:', err.message);
return reject(res, '通知处理失败');
}
}
);
app.listen(Number(process.env.PORT || 3000), () => {
console.log('支付通知服务已启动');
});
用单一平台公钥的配置只是演示写法。生产环境应按 Wechatpay-Serial 选择受信任的平台证书或公钥,并通过微信支付官方渠道维护其有效性。不要因为收到了陌生序列号就临时信任请求中提供的证书或公钥。
示例中的数据库事务保证“检查订单状态”和“更新状态”在同一个事务里执行;transaction_id 的唯一约束还可以阻止同一微信交易号被错误地绑定到多笔订单。实际系统可增加支付记录表、通知审计表、订单状态变更记录和业务事件表,以便追踪与补偿。
五、应答、重试与幂等处理
成功处理后,应按微信支付通知接口要求返回 HTTP 200 和表示成功的 JSON 应答,例如示例中的 code: SUCCESS。验签失败、解密失败或数据库写入失败时,不应谎报成功;返回非成功状态,让通知可以重试,并由系统告警和后续查单兜底。具体重试节奏由微信支付通知机制决定,不要在业务代码中假定固定次数或固定时间。
通知可能重复到达,也可能并发到达。幂等不能只靠“通知看起来相同”:应在数据库里通过订单状态、微信交易号唯一约束和事务来实现。重复通知若对应同一笔已成功交易,应返回成功,不要再次发货、重复加余额或重复记账。
对于“订单不存在”“金额不一致”等异常,不能为了停止重试就把订单标成已支付。应返回失败、记录必要的诊断信息并触发告警;同时结合主动查单和人工处理确认实际支付状态。若业务设计中订单可能先过期或被取消,还要明确处理迟到的成功支付:不能简单覆盖状态,应走退款、人工审核或专门的异常订单流程。
六、常见错误及排查方法
错误一:先解析 JSON,再用序列化结果验签
验签需要原始请求体。JSON 对象重新序列化后,字节内容可能改变。将通知路由配置为原始正文模式,并确保没有更早的中间件消费或重写请求体。
错误二:把 APIv3 密钥当作验签公钥
两者用途不同。签名应使用微信支付平台证书或公钥验证;APIv3 密钥用于解密通知资源。混用通常会导致验签或解密失败,也可能造成密钥泄露风险。
错误三:只检查支付成功字段,不核对订单金额
必须把通知中的商户订单号映射到本地订单,并核对应用 ID、商户号、金额和交易状态。订单金额以本地记录为准,不能相信客户端传来的价格。
错误四:没有处理重复通知
把“收到一次通知”当作业务执行一次,会导致重复发货或重复加余额。更新订单、记录交易号和触发业务副作用都要考虑幂等;耗时业务可先在事务中写入待处理事件,再由后台消费者幂等执行。
错误五:数据库提交前就返回成功
如果先应答成功、后写数据库,服务在两步之间崩溃,微信支付可能认为通知已处理,但本地订单仍未更新。应先完成必要的持久化操作,再返回成功。耗时的非关键任务可以异步执行,但要先可靠地记录任务或事件。
错误六:固定使用旧证书,忽略轮换
平台证书或公钥可能更新。验签应根据请求中的证书序列号选择已验证且受信任的密钥,并建立更新和告警机制。遇到未知序列号时应拒绝通知并排查配置,而不是跳过验签。
错误七:把回调地址当作普通前端接口
通知地址需要公网可访问、使用 HTTPS,且不能依赖用户登录态、浏览器 Cookie 或小程序请求头。网关、WAF 和反向代理应保留请求正文及必要请求头;同时限制路由用途,避免被通用 JSON 中间件破坏正文。
错误八:记录过多敏感信息
排错日志应包含订单号、交易号、通知处理阶段、验签结果和错误原因等必要信息,并限制访问和保留期限。不要记录 APIv3 密钥、私钥或其他凭据;对可能包含个人信息的通知正文也应谨慎处理。
七、上线前检查清单
- 通知地址已在商户平台配置,线上可从公网通过 HTTPS 访问。
- 通知路由保留原始请求体,未被 JSON 中间件提前解析。
- 使用受信任的平台证书或公钥验签,并按序列号维护轮换。
- APIv3 密钥按 32 字节配置,仅用于解密,不暴露在客户端和日志中。
- 成功支付只在服务端验签、解密并核对业务字段后更新订单。
- 金额使用整数最小单位,且与下单时保存的本地金额严格核对。
- 订单更新具备事务和幂等保护,重复通知不会重复执行履约。
- 失败有日志、告警和查单补偿路径,迟到支付有明确业务处理方案。
小程序支付回调的核心不是“收到通知后把状态改成已支付”,而是建立一条可验证、可重试、可审计的服务端状态变更链路:先验签和解密,再核对订单事实,最后用事务幂等更新。把这些边界处理好,回调重复、网络波动和客户端异常都不容易演变成错账或重复履约。








