做小程序定制项目的朋友大概都有体会:验收时页面个个能点通,真正半夜出问题的往往是钱的链路——同一笔订单入账两次、对账永远差一分、退款只改了本地状态、分账时才发现资金早已解冻。
这些事故的共同根因,多半是没有把「支付结果是异步的、会重复、会乱序、也可能根本不来」当成设计前提。
本文按下单、回调、退款、分账、对账、结算的顺序,把这条链路上容易踩的地方过一遍,代码以微信支付 APIv3 + PHP(Laravel)为例,思路适用于任何支付渠道。
一、金额:全链路用「分」存整数
三条规则,缺一不可:
- 数据库存整型「分」(
BIGINT),字段名或注释写明单位,不要和DECIMAL元混在一张表里; - 与支付渠道交互的金额直接透传整数分,中间不做浮点转换;
- 只在展示层换算成元,且换算结果不再参与任何计算。
// 下单:只信服务端算出来的金额,前端传来的金额一律不作为依据
$fen = $cart->totalFen(); // int,单位:分
abort_if($fen <= 0, 400, 'invalid amount');
$response = $http->post('https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi', [
'json' => [
'appid' => config('wechat.appid'),
'mchid' => config('wechat.mchid'),
'description' => $order->title,
'out_trade_no' => $order->out_trade_no,
'notify_url' => route('wechat.pay.notify'),
'amount' => ['total' => $fen, 'currency' => 'CNY'],
'payer' => ['openid' => $user->wx_openid],
],
]);
// 前端只负责把分显示成元,且只出现在 render 里
const yuan = (fen) => (fen / 100).toFixed(2);
最常见的事故是这样:单价、数量、优惠券三者在小程序端各自 Math.round 之后相加,再传给服务端,结果比服务端少了一分。这一分在功能测试里永远发现不了,只会在第一次对账时冒出来。优惠计算必须在服务端做一次,前端要么不算,要么只算给用户看。
二、回调:按「会重复、会迟到、会乱序」来写
2.1 幂等键 + 状态机,两样都不能少
ALTER TABLE pay_notify_log ADD UNIQUE KEY uk_txn (transaction_id);
public function notify(Request $r)
{
// 1. 验签:Wechatpay-Signature / Serial / Nonce / Timestamp
if (!WechatPayVerify::signature($r)) {
return response()->json(['code' => 'FAIL', 'message' => 'invalid sign'], 401);
}
// 2. 解密:AES-256-GCM + APIv3 密钥,注意要用原始 body
$data = WechatPayCrypto::decrypt($r->json('resource'));
DB::transaction(function () use ($data) {
// 3. 先占坑:重复通知在这一步被唯一索引挡掉
DB::table('pay_notify_log')->insertOrIgnore([
'transaction_id' => $data['transaction_id'],
'out_trade_no' => $data['out_trade_no'],
'trade_state' => $data['trade_state'],
'raw' => json_encode($data, JSON_UNESCAPED_UNICODE),
'created_at' => now(),
]);
// 4. 行锁读单,禁止用「paid_at 为空」这种乐观判断
$order = Order::where('out_trade_no', $data['out_trade_no'])
->lockForUpdate()->first();
// 5. 只允许合法跃迁:待支付 -> 已支付,已支付不再回退
if ($order
&& $order->state === Order::STATE_UNPAID
&& $data['trade_state'] === 'SUCCESS') {
$order->markPaid($data['transaction_id'], (int) $data['amount']['payer_total']);
}
});
// 6. 30 秒内按文档要求的结构应答,否则会被判失败并持续重试
return response()->json(['code' => 'SUCCESS', 'message' => '成功']);
}
几个容易忽略的点:
- **重活不要放在回调里。**发短信、生成海报、推给第三方 ERP,全部扔进队列。回调只负责「把这笔钱的状态确定下来」。
- **迟到通知一定会出现。**官方按策略重试,间隔会越拉越长,完全可能在你已经修复 bug 之后才打过来。所以状态机必须能容忍旧通知,而不是假设「先到的一定是新状态」。
- **应答格式要照文档来。**返回 HTTP 200 但 body 不合规范,在某些场景下仍会被判为失败,然后被重试一整天。
2.2 必须有主动查询兜底
只依赖回调的系统,迟早会因为在一次网络抖动、一次证书轮换、一次域名切换中掉单。补一条定时任务:
// 每分钟扫一次:下单后 5~30 分钟仍未终态的订单,主动向渠道查询
Order::where('state', Order::STATE_UNPAID)
->whereBetween('created_at', [now()->subMinutes(30), now()->subMinutes(5)])
->chunkById(200, function ($orders) {
$orders->each(fn ($o) => QueryPayOrderJob::dispatch($o));
});
关键要求:查询到 SUCCESS 时,走与回调完全同一段入账代码(同一个方法、同一个幂等键)。两条路径复制两份实现,是后续对不上的主要来源。
三、退款:本地状态永远不是终态
$http->post('https://api.mch.weixin.qq.com/v3/refund/domestic/refunds', ['json' => [
'out_trade_no' => $order->out_trade_no,
'out_refund_no' => $refund->out_refund_no, // 自己生成,作为退款的幂等键
'reason' => $refund->reason,
'notify_url' => route('wechat.refund.notify'),
'amount' => [
'refund' => $refund->fen,
'total' => $order->paid_fen,
'currency' => 'CNY',
],
]]);
三个必须接受的事实:
- **申请成功 ≠ 退款成功。**接口返回
PROCESSING是常态,最终结果只能靠退款回调和主动查询,本地要有「退款中」这个中间态,并且它在前端可见。 - **部分退款要逐笔记账。**累计退款额不得超过实付额,这条校验必须在服务端做,不要相信前端传来的可退金额。
- **资金原路退回,到账时间不受你控制。**取决于用户侧的银行或支付渠道。客服话术和产品文案里不要写「秒到账」。
四、分账:先冻结、再请求、别忘了解冻
多商户、平台抽佣类小程序常见做法是:下单时冻结资金,履约确认后按比例请求分账,最后把剩余部分解冻。
$http->post('https://api.mch.weixin.qq.com/v3/profitsharing/orders', ['json' => [
'appid' => config('wechat.appid'),
'transaction_id' => $order->transaction_id,
'out_order_no' => $bill->out_order_no, // 同一笔分账的幂等键
'receivers' => [[
'type' => 'MERCHANT_ID',
'account' => $subMchid,
'amount' => $fen,
'description' => '佣金',
]],
'unfreeze_unshare' => true, // 分完顺带解冻剩余
]]);
这里最容易翻车的三件事,其实都不是代码问题:
- **分账需要先配置分账关系,且存在比例上限。**这两项属于进件阶段就要确认的事。等到联调才发现收不进来,项目要整体延期。
- 分账结果同样是异步的。
out_order_no重复提交是幂等的,但每笔接收方的状态要靠查询接口确认,不能提交完就当成功。 - 冻结有最长期限,到期会自动解冻,未分出去的资金会结算给商户号。如果业务依赖分账,就必须有「自动解冻之后如何补分」的兜底流程。
比例上限、冻结天数这类参数,写进项目文档时注明「以商户平台当期说明为准」。沿用两年前的方案截图,是甲方乙方都会后悔的操作。
五、对账:差异只有四类,处理成本天差地别
每天拉前一天的交易账单:
$link = $http->get('https://api.mch.weixin.qq.com/v3/bill/tradebill', ['query' => [
'bill_date' => $yesterday,
'bill_type' => 'ALL', // ALL 含代金券,BODY 不含
]])->json('download_url');
// download_url 返回 gzip 压缩的 CSV:记得解压、去掉 BOM、跳过表头前的汇总行
对不上的单子只有四种,分别对应完全不同的原因和修法:
| 差异类型 | 典型原因 | 处理 |
|---|---|---|
| 渠道有、本地无 | 回调丢失、订单号写错、存在影子订单 | 以渠道为准补单,并回头查为什么丢 |
| 本地有、渠道无 | 本地误标成功、测试数据混入 | 回滚状态,量大时必须人工复核 |
| 金额不符 | 优惠重复计算、元/分单位混用 | 定位到唯一一处计算代码,通常一改就好 |
| 状态不符 | 退款、关单的异步结果没同步 | 走主动查询覆盖,禁止手工改库 |
一条原则值得贴在墙上:**对账脚本只报警、只生成差异单,不自动改钱。**让脚本自动补单,等于把一个 bug 放大成一批错账。
六、结算周期是产品问题,不是财务问题
默认 T+1 结算意味着两件常被忽略的事:
- 用户今天付的钱,明天才到商户账上。凡是把「当日可提现」「当日到账」写进产品说明的,先回去确认结算规则。
- 退款是从商户可用资金里出的。如果当天收入当天就被划走,就会出现在账上没钱可退的状态。
写「支持随时全额退款」这句话之前,先确认结算账户余额与退款资金来源。分账场景还要再叠一层:冻结资金的解冻时点,直接决定合作方什么时候能收到钱,这属于要在合同里写清的商业条款,而不是技术细节。
七、上线前自测清单
| # | 用例 | 通过标准 |
|---|---|---|
| 1 | 同一笔支付重复回调 3 次 | 订单只入账一次,日志有 3 条且状态不跳变 |
| 2 | 故意让回调返回 500 | 主动查询任务在 30 分钟内补上状态 |
| 3 | 先收到退款成功、后收到支付成功 | 状态不回退,订单终态正确 |
| 4 | 部分退款至上限后再退 1 分 | 服务端拦截,前端有明确提示 |
| 5 | 用 0.01 元跑完整链路 | 金额、账单、对账三处一致 |
| 6 | 篡改回调金额后再发 | 验签失败被拒,且不进入业务逻辑 |
| 7 | 分账请求重复提交同一 out_order_no | 不产生第二次分账 |
| 8 | 跑一次前一天的对账任务 | 差异单能生成、能报警,且不会自动改余额 |
第 1、2、3 条是投入产出比最高的三项,也是绝大多数翻车案例里被跳过的那几项。
八、口径与说明
- 本文接口路径与字段名以微信支付官方文档当期版本为准;涉及费率、比例上限、冻结与结算天数的地方,一律以商户平台签约页与当期公示为准,不要在自家文档里写死。
- 代码为便于说明做了裁剪,验签、解密、异常处理请参照官方 SDK 或自行完整实现。
- 利益相关说明:本文由南京易企达整理,我们在南京承接小程序定制开发,团队自 2010 年起做本地项目。上述内容来自已经上线的零售、预约与多商户结算类项目,不含联系方式与报价,仅作技术备忘。
