uni-app 打包 H5 后如何实现支付宝签约周期扣款?
结论
uni-app 打包成 H5 后可以接入支付宝签约周期扣款,但支付宝开放平台的服务端 API 不能由前端直接调用。
完整流程如下:
- uni-app H5 跳转到商户服务端生成的支付宝签约页面。
- 用户在支付宝页面确认签约。
- 商户服务端接收支付宝异步通知,验签后保存签约协议号。
- 到达扣款时间后,商户服务端使用协议号调用支付宝扣款接口。
- 服务端根据支付宝异步通知更新扣款结果,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 参数和状态枚举应以当前能力中心文档为准,特别是产品码、签约场景、周期规则和协议状态字段。
备注:内容仅供参考。
