微信小程序交易侧 7 个易错点:金额精度、回调幂等、分账冻结与对账差异

做小程序定制项目的朋友大概都有体会:验收时页面个个能点通,真正半夜出问题的往往是钱的链路——同一笔订单入账两次、对账永远差一分、退款只改了本地状态、分账时才发现资金早已解冻。

这些事故的共同根因,多半是没有把「支付结果是异步的、会重复、会乱序、也可能根本不来」当成设计前提。

本文按下单、回调、退款、分账、对账、结算的顺序,把这条链路上容易踩的地方过一遍,代码以微信支付 APIv3 + PHP(Laravel)为例,思路适用于任何支付渠道。

picture.image

一、金额:全链路用「分」存整数

三条规则,缺一不可:

  1. 数据库存整型「分」(BIGINT),字段名或注释写明单位,不要和 DECIMAL 元混在一张表里;
  2. 与支付渠道交互的金额直接透传整数分,中间不做浮点转换;
  3. 只在展示层换算成元,且换算结果不再参与任何计算。
// 下单:只信服务端算出来的金额,前端传来的金额一律不作为依据
$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 不合规范,在某些场景下仍会被判为失败,然后被重试一整天。

picture.image

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',
    ],
]]);

三个必须接受的事实:

  1. **申请成功 ≠ 退款成功。**接口返回 PROCESSING 是常态,最终结果只能靠退款回调和主动查询,本地要有「退款中」这个中间态,并且它在前端可见。
  2. **部分退款要逐笔记账。**累计退款额不得超过实付额,这条校验必须在服务端做,不要相信前端传来的可退金额。
  3. **资金原路退回,到账时间不受你控制。**取决于用户侧的银行或支付渠道。客服话术和产品文案里不要写「秒到账」。

四、分账:先冻结、再请求、别忘了解冻

多商户、平台抽佣类小程序常见做法是:下单时冻结资金,履约确认后按比例请求分账,最后把剩余部分解冻。

$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 年起做本地项目。上述内容来自已经上线的零售、预约与多商户结算类项目,不含联系方式与报价,仅作技术备忘。
0
0
0
0
评论
未登录
暂无评论