所属专题:移动开发专题:Swift、SwiftUI 与 iOS 实践

微信小程序支付开发实战:订单、签名、回调与退款怎么设计

微信小程序支付开发实战:订单、签名、回调与退款怎么设计 - 暂无配图,技术文章默认封面

微信小程序支付开发的难点不在于页面上出现一个“立即支付”按钮,而在于订单、金额、签名、回调、退款和对账能否形成一条可恢复的服务端链路。客户端可能断网、重复点击或在支付完成后关闭页面;微信支付回调也可能重复到达或暂时延迟。只在客户端显示“支付成功”,不能证明业务订单已经安全完成。

本文以微信小程序和微信支付 API v3 的常见流程为例,整理从创建订单到退款、关单和对账的设计方法。上一篇小程序开发公司怎么选讨论项目交付,微信小程序开发需要多少钱讨论成本,微信小程序接口安全实战讨论登录态和权限,本文聚焦支付这个最容易产生真实资金和业务状态的环节。接口参数、权限和签名细节应以当前微信支付官方文档及官方 SDK 为准。

支付系统的核心原则是:客户端只负责发起支付和展示状态,服务端负责金额、订单状态、签名验证、幂等处理和最终业务确认。

一、先确认账号和支付边界

开始写代码前,先确认小程序 AppID、微信支付商户号、商户主体和支付场景之间的关系。支付账号、证书、API v3 密钥和回调域名不能用开发者个人账号长期管理;项目方应控制生产账号,供应商通过受限成员权限协作。

还要把支付范围写进需求:

边界 需要确认的问题
支付主体 哪个小程序 AppID 发起支付,哪个商户号收款?
订单对象 一笔订单包含一个商品、多个商品、预约时段还是服务套餐?
金额来源 价格、优惠、运费、税费和舍入由哪个服务端计算?
支付结果 订单何时进入已支付、部分退款、已退款或关闭?
退款责任 谁能发起退款,能否部分退款,退款原因如何记录?
对账要求 是否需要下载账单、日结核对和异常订单人工处理?
失败路径 超时、取消、重复回调、余额不足和商户配置错误如何处理?

不要先让客户端提交一个金额,再让服务端照此下单。客户端传来的商品 ID、数量、优惠券和金额都必须重新从服务端数据计算;否则用户可以修改请求体,把一笔高价订单改成极低金额。

二、用订单状态机管理支付生命周期

支付不是一个布尔值。建议把内部订单状态和微信支付状态分别保存,避免把一次查询结果直接当成整个业务流程:

内部状态 含义 允许的下一步
PENDING 已创建,等待发起支付 创建支付参数、关闭订单
PAYING 已向支付侧发起请求,结果待确认 查询订单、等待回调、关闭
PAID 服务端已确认支付成功 发货、预约确认、开通权益
CLOSED 未支付订单已关闭 不能继续使用原订单支付
REFUNDING 已申请退款,等待结果 查询退款、等待回调
REFUNDED 全额退款完成 结束或重新创建新订单
PARTIAL_REFUNDED 部分退款完成 继续退款或结束

状态转换必须由服务端根据当前状态和事件决定。客户端不能提交 status=PAID,也不能用“支付按钮返回成功”直接更新商品库存、会员权益或预约名额。支付成功回调和主动查单都应通过同一套状态转换函数处理,重复事件只产生一次业务效果。

三、完整支付链路是什么

一个可恢复的微信小程序支付流程通常如下:

  1. 小程序把商品、数量和业务参数发给本站服务端。
  2. 服务端验证用户身份、商品状态、价格、库存和幂等键,创建内部订单。
  3. 服务端按当前微信支付文档调用小程序/JSAPI 下单接口,生成预支付信息。
  4. 服务端把调起支付所需的安全参数返回小程序,不返回商户私钥、API v3 密钥或平台证书私密材料。
  5. 小程序调用 wx.requestPayment 调起支付,并根据结果展示“处理中”“已完成”或“请查询订单”。
  6. 微信支付服务端回调到商户通知地址;服务端验证通知签名并解密通知数据。
  7. 服务端校验商户订单号、金额、AppID、商户号和支付状态,事务性更新内部订单。
  8. 回调处理成功后返回符合文档要求的应答;若暂时失败,使用安全的重试和补偿机制。
  9. 对没有收到回调的订单,服务端主动查单,不依赖客户端再次点击。

客户端的 success 回调只能说明调起支付流程在客户端返回了成功结果,不能替代服务端回调或查单。用户关闭页面、网络切换或客户端版本差异都可能让页面展示与支付侧状态短暂不一致。

四、服务端创建订单时锁定金额

创建订单接口应接收业务标识,而不是接受客户端最终价格。下面是简化的 Django 风格示例,重点展示校验顺序,不代表可直接用于生产:

from decimal import Decimal
from django.db import transaction


@transaction.atomic
def create_payment_order(*, user, items, idempotency_key):
    request_digest = digest_request(user.id, items)
    existing = PaymentRequest.objects.filter(
        user=user,
        key=idempotency_key,
    ).first()
    if existing:
        if existing.request_digest != request_digest:
            raise ConflictError("幂等键对应了不同请求")
        return existing.order

    lines = load_and_validate_items(items, user=user)
    total = sum((line.final_amount for line in lines), Decimal("0.00"))
    order = Order.objects.create(
        user=user,
        amount=total,
        status="PENDING",
        currency="CNY",
    )
    PaymentRequest.objects.create(
        user=user,
        key=idempotency_key,
        request_digest=request_digest,
        order=order,
    )
    return order

load_and_validate_items 应从服务端商品、优惠、库存和用户权限重新计算价格。金额使用定点十进制或最小货币单位保存,避免浮点数误差;订单创建、库存锁定和幂等记录应在同一个事务边界内。数据库还要对商户订单号、幂等键和退款单号建立唯一约束,防止并发请求绕过应用层检查。

调用微信支付接口时,后端按官方 SDK 和当前 API 文档生成请求签名。商户私钥、商户 API v3 密钥和证书文件只在服务端受控配置中使用;不要把它们写入小程序包、前端配置、普通日志或错误响应。支付下单响应中的预支付信息只返回调起支付所需字段。

五、小程序端只负责调起和展示

服务端返回支付参数后,小程序才调用 wx.requestPayment。示例只展示处理边界:

async function pay(orderId) {
  const payment = await api.createPayment(orderId);

  try {
    await wxRequestPayment(payment);
    // 这里显示“正在确认”,随后查询本站订单状态。
    return await api.waitForOrderStatus(orderId);
  } catch (error) {
    if (error && error.errMsg && error.errMsg.includes("cancel")) {
      return { status: "USER_CANCELLED" };
    }
    // 网络或客户端异常不能直接判定支付失败。
    return { status: "PENDING_CONFIRMATION" };
  }
}

用户取消支付可以回到待支付状态;网络异常和未知错误应展示“订单状态确认中”,让用户进入订单详情查询,而不是立即重新创建一笔订单。自动重试前先判断接口是否有副作用,创建支付订单必须依赖幂等键。

六、回调通知必须验签、解密和校验业务字段

微信支付 API v3 的通知处理至少包含以下步骤:

  1. 读取通知请求头和原始请求体,保留原始字节用于验签;
  2. 按官方规则验证微信支付平台签名、时间戳、随机串和通知内容;
  3. 使用 API v3 密钥解密通知资源,不把解密前后的敏感内容写入普通日志;
  4. 校验商户号、AppID、商户订单号、币种、支付金额和交易状态;
  5. 在事务中执行幂等状态更新,并触发发货或权益开通任务;
  6. 返回官方要求的成功应答;处理失败时记录请求 ID,避免返回“成功”掩盖未处理事件。

不要只验证通知 URL 能被访问,也不要只依赖客户端传来的订单号。支付回调可能重复发送,服务端必须在当前订单已经是 PAID 时安全地重复处理;如果回调金额与内部订单金额不一致,应拒绝更新并进入人工或自动告警队列。

示例伪代码:

def payment_notify(request):
    raw_body = request.body
    notification = verify_and_decrypt_wechat_notification(
        headers=request.headers,
        raw_body=raw_body,
    )

    with transaction.atomic():
        order = (Order.objects
                 .select_for_update()
                 .get(merchant_order_id=notification.out_trade_no))
        check_payment_identity_and_amount(notification, order)

        if order.status == "PENDING":
            order.status = "PAID"
            order.paid_at = timezone.now()
            order.wechat_transaction_id = notification.transaction_id
            order.save(update_fields=[
                "status", "paid_at", "wechat_transaction_id",
            ])
            enqueue_fulfillment(order.id)
        elif order.status in {"PAID", "REFUNDING", "REFUNDED"}:
            pass  # 重复通知不重复发货或开通权益。
        else:
            record_payment_anomaly(order, notification)

    return success_response()

真实项目应使用官方 SDK 或经过审计的支付库完成签名验证、证书更新和通知解密,不要把密码学细节复制成一段未经测试的示例代码。通知处理要有请求 ID、订单号摘要、结果和耗时,禁止记录完整密钥、签名原文、用户敏感信息和通知明文。

七、没有回调时主动查单

网络故障、服务重启、通知延迟或配置错误都可能导致商户暂时没有收到回调。服务端应为 PAYING 状态建立补偿任务,按退避间隔查询微信支付订单,并设置最大查询时间:

  • 查询到成功:按回调相同的状态转换函数处理;
  • 查询到未支付:在允许时间内保持待支付或关闭;
  • 查询到关闭或失败:释放库存,通知用户重新下单;
  • 查询接口异常:记录重试次数和最后错误,不把未知状态当成失败;
  • 超过业务截止时间:进入人工核对队列,不直接删除订单。

查单和回调必须共享幂等逻辑。否则同一笔支付可能被两个入口同时处理,造成重复发货、重复开通会员或重复扣减库存。数据库行锁、状态条件更新和唯一业务约束应一起使用。

八、关单、退款和部分退款

未支付订单不能无限期保留。服务端应根据业务截止时间关闭订单,并在关闭前确认没有进入支付处理中状态。关单不等于退款:已经支付的订单必须走退款流程。

退款至少要保存内部退款单号、原订单号、申请金额、累计退款金额、退款原因、申请人、审核人、微信退款单号和退款状态。部分退款时必须验证:本次退款金额大于零,累计退款不超过原支付金额,且当前订单状态允许退款。

退款流程通常包括:

  1. 服务端鉴权并检查订单、售后规则和可退金额;
  2. 创建唯一退款单,使用幂等键防止重复申请;
  3. 调用微信退款接口,并保存受理结果;
  4. 接收退款结果通知或主动查询退款状态;
  5. 事务性更新退款单和订单累计退款金额;
  6. 失败或异常时保留原始业务证据,进入重试或人工处理。

退款申请也需要服务端签名和权限控制。不能让客户端指定任意退款金额、退款账户或最终退款状态。支付成功回调、退款结果通知和账单结果之间出现差异时,应暂停自动发货或继续退款,先进入异常核对流程。

九、对账和财务可追溯性

支付系统不能只依赖实时回调。建议每天按商户订单号、微信支付交易号、订单金额、退款金额和状态做对账:

对账结果 处理方式
本地和微信均已支付,金额一致 正常归档
微信已支付,本地仍待支付 补偿更新并告警
本地已支付,微信查询不到 暂停发货,人工核对
金额不一致 立即标记异常,不自动完成订单
本地已退款,微信退款未完成 保持退款中并重试查询
微信已退款,本地未更新 补偿更新售后状态

账单下载、解析和对账任务应保存批次号、文件校验信息、处理时间和异常行数。账单文件包含敏感业务数据,应限制访问、加密保存并按隐私政策和财务要求设置留存期限。不要用访问日志代替支付账单,也不要把支付金额和用户信息直接暴露给无关后台角色。

十、测试环境和安全检查

支付上线前至少覆盖以下场景:

测试场景 预期结果
正常支付并收到回调 订单只完成一次,发货或权益只触发一次
用户取消支付 订单保持待支付,不创建重复订单
调起后断网 页面显示确认中,可进入订单查询
回调重复发送 返回正确应答,不重复发货或开通权益
回调金额被篡改 验证失败,订单不更新并产生告警
回调商户号或 AppID 不匹配 拒绝处理,保留异常记录
同一幂等键不同请求 返回冲突,不复用旧结果
支付超时 查单后决定保持、关闭或人工处理
全额退款 退款状态、订单状态和业务权益一致
部分退款 累计退款不超过原支付金额
旧版本客户端 接口保持兼容,不接受客户端最终状态
密钥或证书变更 测试环境可轮换,生产有回滚路径

测试环境必须使用测试商户配置、测试账号和非生产密钥。不要用真实金额测试,也不要把生产回调地址、证书或 API v3 密钥复制到开发机、截图和代码仓库。支付接口的请求、响应和异常日志只保留必要字段,并给每个订单、回调和补偿任务关联请求 ID。

十一、常见错误排查顺序

下单返回 AppID、商户号或用户标识不匹配

先核对小程序 AppID、商户号、支付主体、用户身份和服务端环境配置,再检查签名证书和请求参数。不要只在客户端重复调用;配置关系错误不会因为重试而消失。

调起支付缺少参数或签名错误

检查服务端下单响应字段、时间戳单位、随机串、签名算法、商户证书序列号和前端参数映射。每个字段都应来自当前官方文档,不要混用旧版示例或公众号 JSAPI 参数。

用户已付款但订单仍显示待支付

先查微信支付订单,再查通知接收日志、验签结果和状态转换记录。确认补偿查单任务是否运行,不要让用户反复点击支付按钮创建新订单。

退款显示处理中

退款受理成功不代表资金已经完成退回。保存退款单号,按官方结果通知或查询接口更新状态;超过业务时限仍未完成时,进入人工核对,不要直接标记为失败或成功。

十二、上线验收清单

  • [ ] AppID、商户号、支付主体和回调地址已经核对。
  • [ ] 生产密钥、证书和 API v3 密钥只存在于服务端受控配置。
  • [ ] 金额、优惠、库存和订单状态都由服务端重新计算和校验。
  • [ ] 内部订单号、支付请求和退款请求都有唯一约束或幂等键。
  • [ ] 小程序端不会把调起支付成功当成最终业务成功。
  • [ ] 回调通知会验签、解密、校验商户号、AppID、订单号和金额。
  • [ ] 回调与查单共享同一套幂等状态转换逻辑。
  • [ ] 未支付订单有关闭策略,支付订单有退款和部分退款策略。
  • [ ] 对账任务能发现本地与微信支付状态、金额不一致的记录。
  • [ ] 日志不记录密钥、完整签名、通知明文或不必要的个人信息。
  • [ ] 测试覆盖取消、超时、重复回调、金额篡改、退款和密钥轮换。
  • [ ] 已记录监控、告警、补偿、人工核对和回滚步骤。

微信小程序支付不是前端功能,而是一套由订单、服务端校验、支付通知、退款和对账组成的状态系统。把最终确认放在服务端,把每个入口做成幂等,把异常订单保留下来并可恢复,才能避免“用户付了钱、系统却不知道”或“系统重复发货”的问题。正式接入前应按当前微信支付官方文档、官方 SDK 和商户权限逐项验证。

参考资料

分享这篇文章:

评论 (0)

请 登录 后发表评论, 还没有账户?立即注册

暂无评论,快来抢沙发吧!