PAYATHON 2026

uni-app 打包 H5 后如何实现支付宝签约周期扣款?

支付阿杰

结论

uni-app 打包成 H5 后可以接入支付宝签约周期扣款,但支付宝开放平台的服务端 API 不能由前端直接调用。

完整流程如下:

  1. uni-app H5 跳转到商户服务端生成的支付宝签约页面。
  2. 用户在支付宝页面确认签约。
  3. 商户服务端接收支付宝异步通知,验签后保存签约协议号。
  4. 到达扣款时间后,商户服务端使用协议号调用支付宝扣款接口。
  5. 服务端根据支付宝异步通知更新扣款结果,H5 只展示状态。

周期扣款涉及应用私钥、协议号和主动扣款权限,这些内容都必须保存在服务端。uni-app 是否打包为 H5,不影响支付宝服务端 API 的接入方式。

接入前提

开发前,需要先在支付宝开放平台确认以下事项:

  • 应用已创建,并完成必要的签约、上线或审核流程。
  • 商户已开通“签约周期扣款”对应的产品能力。
  • 应用已配置支付宝公钥证书或支付宝公钥,以及应用私钥。
  • 已配置可通过公网访问的 HTTPS 异步通知地址。
  • 当前业务场景、签约周期和扣款规则符合产品准入要求。
  • 所用接口、产品码和签约场景参数与能力中心页面一致。

能力中心展示的接口会因产品方案、商户类型和应用配置而异。product_code、personal_product_code、sign_scene 等参数应以当前应用控制台和对应 API 文档为准,不能直接照搬其他业务的取值。

整体流程

uni-app H5
   │
   │ 1. 请求发起签约
   ▼
商户服务端
   │
   │ 2. 调用 alipay.user.agreement.page.sign
   ▼
支付宝签约页
   │
   │ 3. 用户确认签约
   ▼
支付宝异步通知 ──► 商户服务端验签并保存 agreement_no
                         │
                         │ 4. 到期后发起扣款
                         ▼
                   alipay.trade.pay
                         │
                         │ 5. 支付结果通知
                         ▼
                   更新订单和账单状态

签约成功不代表扣款已经完成。签约和扣款是两个独立阶段,需要分别保存业务记录并处理各自的通知。

H5 端发起签约

H5 页面不能保存支付宝私钥,也不应自行拼装签名参数。前端只需携带当前业务订单号或订阅编号,跳转到商户服务端的签约入口。

<template>
  <view>
    <button :disabled="signing" @click="startAgreement">
      {{ signing ? '正在跳转...' : '开通周期扣款' }}
    </button>
  </view>
</template>

<script>
export default {
  data() {
    return {
      signing: false
    }
  },
  methods: {
    startAgreement() {
      if (this.signing) return

      this.signing = true

      const subscriptionId = 'SUBSCRIPTION_202609090001'
      const url =
        'https://api.example.com/alipay/agreement/sign' +
        '?subscriptionId=' +
        encodeURIComponent(subscriptionId)

      // H5 环境直接跳转到商户服务端的签约入口
      window.location.href = url
    }
  }
}
</script>

如果项目会同时发布到多个平台,应通过条件编译,让这段代码只在 H5 环境中运行:

// #ifdef H5
window.location.href = signUrl
// #endif

不要通过 uni.request 获取签约表单,再把它当作普通 JSON 处理。支付宝的页面类接口通常返回跳转地址或自动提交的 HTML 表单。让浏览器直接访问商户服务端的签约入口更稳妥。

服务端生成签约请求

周期扣款签约通常通过页面类接口 alipay.user.agreement.page.sign 发起。下面用支付宝 Java SDK 展示基本结构,其中的参数值只用于说明接入方式。

@GetMapping(
    value = "/alipay/agreement/sign",
    produces = "text/html;charset=UTF-8"
)
@ResponseBody
public String signAgreement(@RequestParam String subscriptionId) throws Exception {
    // 必须先验证当前登录用户是否有权操作该订阅
    Subscription subscription = subscriptionService.getForCurrentUser(subscriptionId);

    // 商户侧签约编号必须唯一,并建议提前落库
    String externalAgreementNo = subscription.getExternalAgreementNo();

    AlipayUserAgreementPageSignRequest request =
        new AlipayUserAgreementPageSignRequest();

    request.setNotifyUrl(
        "https://api.example.com/alipay/agreement/notify"
    );
    request.setReturnUrl(
        "https://m.example.com/pages/agreement/result" +
        "?subscriptionId=" + URLEncoder.encode(subscriptionId, StandardCharsets.UTF_8)
    );

    JSONObject bizContent = new JSONObject();
    bizContent.put("external_agreement_no", externalAgreementNo);

    // 以下参数必须以商户实际开通的产品文档为准
    bizContent.put("product_code", "实际产品码");
    bizContent.put("personal_product_code", "实际个人产品码");
    bizContent.put("sign_scene", "实际签约场景");

    JSONObject periodRuleParams = new JSONObject();
    periodRuleParams.put("period_type", "实际周期单位");
    periodRuleParams.put("period", "实际周期值");
    periodRuleParams.put("execute_time", "实际执行时间");
    periodRuleParams.put("single_amount", "实际单次扣款金额");

    bizContent.put("period_rule_params", periodRuleParams);

    request.setBizContent(bizContent.toJSONString());

    /*
     * alipayClient 应在服务端使用应用私钥初始化。
     * pageExecute 返回值的具体形态与 SDK 版本、调用方式有关,
     * 可能是自动提交的 HTML,也可能需要按 SDK 文档取得跳转内容。
     */
    AlipayUserAgreementPageSignResponse response =
        alipayClient.pageExecute(request, "POST");

    if (!response.isSuccess()) {
        throw new IllegalStateException(
            response.getCode() + ": " + response.getSubMsg()
        );
    }

    return response.getBody();
}

实际项目不能直接采用前端传入的金额、周期和外部协议号。服务端应根据 subscriptionId 查询数据库,读取已经确认的订阅方案,再生成支付宝请求参数。

处理签约异步通知

用户完成签约后,支付宝会向 notify_url 发送异步通知。服务端必须先验签,然后根据商户签约编号更新记录。

@PostMapping("/alipay/agreement/notify")
@ResponseBody
public String agreementNotify(HttpServletRequest request) throws Exception {
    Map<String, String> params = extractAlipayParams(request);

    boolean verified = AlipaySignature.rsaCheckV1(
        params,
        alipayPublicKey,
        StandardCharsets.UTF_8.name(),
        "RSA2"
    );

    if (!verified) {
        return "failure";
    }

    String appId = params.get("app_id");
    if (!configuredAppId.equals(appId)) {
        return "failure";
    }

    String externalAgreementNo = params.get("external_agreement_no");
    String agreementNo = params.get("agreement_no");
    String status = params.get("status");

    /*
     * 字段名和状态枚举应以实际签约接口的异步通知文档为准。
     * 更新操作必须具备幂等性,因为支付宝可能重复发送通知。
     */
    agreementService.processNotify(
        externalAgreementNo,
        agreementNo,
        status,
        params
    );

    return "success";
}

除了验签,还要检查:

  • app_id 是否属于当前应用。
  • external_agreement_no 是否存在。
  • 通知中的商户、产品和业务信息是否与本地记录一致。
  • 当前协议状态是否允许这次状态变更。
  • 同一通知是否已经处理。

只有业务处理成功后,才能返回纯文本 success。处理失败时应返回 failure,让支付宝按照通知机制重试。

服务端执行周期扣款

保存签约协议号后,商户服务端通过定时任务或账单系统控制扣款时间。到期时,系统需要创建一笔具有唯一订单号的扣款订单,并调用能力中心指定的支付接口。

部分周期扣款方案会通过 alipay.trade.pay 携带协议参数完成扣款,示例如下:

public String executeCyclePayment(Bill bill, Agreement agreement)
        throws Exception {

    AlipayTradePayRequest request = new AlipayTradePayRequest();
    request.setNotifyUrl(
        "https://api.example.com/alipay/payment/notify"
    );

    JSONObject bizContent = new JSONObject();
    bizContent.put("out_trade_no", bill.getOutTradeNo());
    bizContent.put("total_amount", bill.getAmount().toPlainString());
    bizContent.put("subject", bill.getSubject());

    // 必须使用当前周期扣款产品要求的 product_code
    bizContent.put("product_code", "实际产品码");

    JSONObject agreementParams = new JSONObject();
    agreementParams.put("agreement_no", agreement.getAgreementNo());
    bizContent.put("agreement_params", agreementParams);

    request.setBizContent(bizContent.toJSONString());

    AlipayTradePayResponse response = alipayClient.execute(request);

    /*
     * 同步响应只表示本次接口调用结果。
     * 最终支付状态应结合异步通知或主动查询确认。
     */
    paymentService.saveGatewayResponse(
        bill.getOutTradeNo(),
        response.getCode(),
        response.getSubCode(),
        response.getTradeNo()
    );

    return response.getCode();
}

不同能力中心方案使用的扣款接口和业务参数不一定相同。如果当前能力页面指定了其他 API,应以页面列出的接口为准,不能仅根据签约接口名称推断扣款接口。

H5 查询签约和扣款结果

return_url 只是浏览器的返回地址,不能用来判断签约是否成功。用户可能关闭页面、篡改查询参数,也可能在异步通知到达前就返回 H5。

结果页应从商户服务端查询真实状态:

export default {
  data() {
    return {
      status: 'PROCESSING'
    }
  },
  onLoad(options) {
    this.queryAgreementStatus(options.subscriptionId)
  },
  methods: {
    queryAgreementStatus(subscriptionId) {
      uni.request({
        url: 'https://api.example.com/agreement/status',
        data: {
          subscriptionId
        },
        success: ({ data }) => {
          this.status = data.status
        }
      })
    }
  }
}

服务端返回的状态应来自已经验签的异步通知,或支付宝主动查询结果,不能直接使用 H5 地址栏中的参数。

解约处理

业务还需要提供取消自动续费或解除协议的入口。服务端应调用当前产品对应的协议解约 API,并在验签通知或查询确认后更新本地状态。

处理解约时需要:

  • 确认协议属于当前登录用户。
  • 停止生成新的周期账单。
  • 单独处理已经发起的扣款订单。
  • 记录解约时间、发起方和支付宝返回结果。
  • 保留必要的本地协议记录。仅删除本地协议号,支付宝侧的协议仍可能有效。

常见问题

能否在 uni-app 中直接使用支付宝 JavaScript API 完成周期扣款?

通常不能只用 JavaScript API 完成整个流程。JavaScript 可以处理页面跳转或唤起支付宝,但签名、签约接口调用、协议号保存和后续自动扣款都必须在服务端完成。

能否把应用私钥放进 H5?

不能。H5 代码会下载到用户浏览器,把私钥写入前端就等于将其公开。所有需要应用私钥签名的请求都应由可信服务端完成。

签约成功后为什么没有自动扣款?

签约只代表商户取得了后续扣款授权。除非接入的具体产品明确提供支付宝侧自动执行能力,否则商户仍需在约定时间创建账单并调用扣款接口。

同一个账单可以重复使用新的订单号重试吗?

不建议这样做。网络超时并不代表支付宝没有受理请求。应先用原 out_trade_no 查询结果,再决定是否重试,以免产生重复扣款订单。

注意事项

  • 外部协议号和扣款订单号必须全局唯一,并持久化保存。
  • 金额应使用十进制类型处理,不能使用浮点数计算。
  • 签约通知和支付通知都必须验签,并进行幂等处理。
  • 不能把同步响应、前端跳转结果或页面提示作为最终成功依据。
  • 定时扣款任务要避免并发执行和重复消费。
  • 扣款失败后的重试次数、重试时间和用户提醒应符合实际产品规则。
  • 应向用户明确展示扣款金额、周期、用途、取消方式和协议内容。
  • 日志中不能完整记录应用私钥、签名原文、用户敏感信息或支付凭证。
  • 支付宝网关、SDK 初始化方式和证书模式应与应用控制台配置一致。
  • API 参数和状态枚举应以当前能力中心文档为准,特别是产品码、签约场景、周期规则和协议状态字段。

备注:内容仅供参考。