H5场景如何接入包月周期扣款并唤起支付宝签约页面?
结论
H5 场景应调用支付宝周期扣款签约接口 alipay.user.agreement.page.sign。商户服务端负责组装签约参数,并用应用私钥完成 RSA2 签名。随后,将支付宝 SDK 生成的表单页面返回浏览器,由页面自动提交表单,进入支付宝签约页。
签约后,不能只看前端跳转结果。服务端需要验证支付宝异步通知的签名;如有必要,再调用 alipay.user.agreement.query 查询协议状态。确认协议有效后,才能保存 agreement_no,用于后续的周期扣款。
接入流程
完整流程如下:
- H5 请求商户服务端创建签约。
- 服务端生成唯一的商户协议号
external_agreement_no。 - 服务端调用
alipay.user.agreement.page.sign。 - 服务端将支付宝 SDK 返回的 HTML 表单直接输出给浏览器。
- 浏览器自动提交表单,进入支付宝签约页面。
- 用户确认签约后,支付宝跳转到
return_url。 - 支付宝同时向
notify_url发送异步通知。 - 服务端验签,并通过协议查询接口确认最终状态。
- 保存支付宝协议号
agreement_no,供后续扣款、查询和解约使用。
H5 不应自行拼接支付宝签约地址,也不能在前端生成签名。签约参数和应用私钥都必须由服务端处理。
服务端发起签约
下面以支付宝 Java SDK 为例。具体的 SDK 版本、网关地址和部分业务参数,需要以当前开放平台文档及已签约产品的配置为准。
import com.alipay.api.AlipayClient;
import com.alipay.api.DefaultAlipayClient;
import com.alipay.api.domain.AccessParams;
import com.alipay.api.domain.AlipayUserAgreementPageSignModel;
import com.alipay.api.domain.PeriodRuleParams;
import com.alipay.api.request.AlipayUserAgreementPageSignRequest;
import com.alipay.api.response.AlipayUserAgreementPageSignResponse;
import java.math.BigDecimal;
public class AlipayAgreementService {
private final AlipayClient alipayClient;
public AlipayAgreementService() {
this.alipayClient = new DefaultAlipayClient(
"https://openapi.alipay.com/gateway.do",
"你的APP_ID",
"你的应用私钥",
"json",
"UTF-8",
"支付宝公钥或支付宝公钥证书配置",
"RSA2"
);
}
public String createSignPage(String externalAgreementNo) throws Exception {
AlipayUserAgreementPageSignRequest request =
new AlipayUserAgreementPageSignRequest();
request.setReturnUrl(
"https://www.example.com/alipay/agreement/result"
);
request.setNotifyUrl(
"https://api.example.com/alipay/agreement/notify"
);
AlipayUserAgreementPageSignModel model =
new AlipayUserAgreementPageSignModel();
// 周期扣款产品码
model.setPersonalProductCode("CYCLE_PAY_AUTH");
// 必须使用开放平台为当前应用和产品配置的签约场景值
model.setSignScene("YOUR_SIGN_SCENE");
// 商户侧协议号,必须唯一并可用于幂等处理
model.setExternalAgreementNo(externalAgreementNo);
AccessParams accessParams = new AccessParams();
// H5 唤起支付宝时常见配置为 ALIPAYAPP,
// 最终以周期扣款产品文档及应用签约配置为准
accessParams.setChannel("ALIPAYAPP");
model.setAccessParams(accessParams);
PeriodRuleParams periodRuleParams = new PeriodRuleParams();
periodRuleParams.setPeriodType("MONTH");
periodRuleParams.setPeriod(1L);
// 月周期执行时间的格式和取值限制需按接口文档填写
periodRuleParams.setExecuteTime("1");
// 单次扣款上限,不要使用 double 计算金额
periodRuleParams.setSingleAmount(new BigDecimal("99.00"));
model.setPeriodRuleParams(periodRuleParams);
request.setBizModel(model);
AlipayUserAgreementPageSignResponse response =
alipayClient.pageExecute(request);
if (!response.isSuccess()) {
throw new IllegalStateException(
"创建支付宝签约页面失败,code=" + response.getCode()
+ ", subCode=" + response.getSubCode()
+ ", subMsg=" + response.getSubMsg()
);
}
return response.getBody();
}
}
控制器需要把 response.getBody() 作为 HTML 输出:
@GetMapping(
value = "/api/alipay/agreement/sign",
produces = "text/html;charset=UTF-8"
)
@ResponseBody
public String sign() throws Exception {
String externalAgreementNo = createUniqueAgreementNo();
return alipayAgreementService.createSignPage(externalAgreementNo);
}
支付宝 SDK 返回的内容通常是一个自动提交的表单,因此 H5 可以直接跳转到商户服务端提供的这个地址:
const externalAgreementNo = "AGR_" + Date.now();
window.location.href =
"/api/alipay/agreement/sign?externalAgreementNo=" +
encodeURIComponent(externalAgreementNo);
实际项目中,最好不要完全由前端生成 external_agreement_no。由服务端先创建业务记录并生成协议号,可以避免重复签约和协议号冲突。
为什么不要使用 Ajax 获取表单
如果通过 fetch 或 Ajax 获取支付宝 SDK 返回的 HTML,再用 innerHTML 写入页面,HTML 中的自动提交脚本可能不会执行。此外,这种方式还可能受到 CSP、跨域限制和浏览器安全策略的影响。
更可靠的方式是让浏览器直接访问商户的签约接口:
window.location.assign("/api/alipay/agreement/sign");
商户接口返回自动提交表单后,浏览器会继续跳转到支付宝签约页。
如果业务流程必须先通过 Ajax 创建签约,服务端可以返回经过确认可用的跳转地址,或者由前端明确地创建并提交表单。不要直接执行来源不明的 HTML。
签约结果处理
return_url 只用于用户完成签约后的页面跳转。用户可能关闭页面或重复刷新,跳转请求也可能中断,所以不能仅凭 return_url 判断签约成功。
服务端还需要处理 notify_url:
@PostMapping("/alipay/agreement/notify")
@ResponseBody
public String agreementNotify(HttpServletRequest request) throws Exception {
Map<String, String> params = new HashMap<>();
request.getParameterMap().forEach((key, values) -> {
if (values != null && values.length > 0) {
params.put(key, values[0]);
}
});
boolean valid = AlipaySignature.rsaCheckV1(
params,
"支付宝公钥",
"UTF-8",
"RSA2"
);
if (!valid) {
return "failure";
}
String externalAgreementNo = params.get("external_agreement_no");
String agreementNo = params.get("agreement_no");
// 还需要核对 app_id、商户协议号和协议状态等业务字段。
// 更新操作必须具备幂等性,避免重复通知造成重复处理。
saveAgreement(externalAgreementNo, agreementNo, params);
return "success";
}
通知字段和表示成功的状态值应以当前接口文档为准。如果没有收到通知、通知字段不完整,或者协议状态无法确定,应调用:
alipay.user.agreement.query
可以使用 agreement_no 或 external_agreement_no 查询协议,并根据查询结果确认协议是否可用。
后续周期扣款
签约成功只表示商户取得了用户授权,并不代表支付宝会自动为商户生成每一期订单。商户系统通常还需要完成以下工作:
- 按照包月规则生成每一期账单。
- 到期后调用周期扣款对应的支付接口。
- 在支付请求中关联有效的
agreement_no。 - 处理支付异步通知。
- 对扣款失败的情况执行有限次数的重试或补缴流程。
- 用户解约后,立即停止创建新的扣款请求。
具体使用哪个扣款接口、协议参数如何组织、允许扣款的时间窗口以及重试规则,都取决于商户开通的周期扣款产品和支付宝当前规则。应以开放平台控制台及相应的产品文档为准,不能只根据签约接口的参数推断。
注意事项
- 周期扣款场景下,
personal_product_code一般为CYCLE_PAY_AUTH,但仍要与实际开通的产品一致。 sign_scene不能随意填写,必须使用支付宝为当前业务场景配置或认可的值。period_rule_params必须符合真实的包月规则,包括周期类型、周期长度、执行日期和单次扣款金额上限。- 金额应使用
BigDecimal或十进制定点类型处理,不能使用float、double。 external_agreement_no必须唯一,同时要为重复请求做好幂等控制。- 应用私钥只能保存在服务端,不能写入 H5、JavaScript 或 App 安装包。
return_url和notify_url应使用公网可访问的 HTTPS 地址。- 收到异步通知后,必须验证支付宝签名,并核对
app_id、协议号和业务归属。 - 保存
agreement_no时,需要同时关联用户、会员套餐和商户协议号,不能只保存一个没有业务关联的协议号。 - 不能仅凭页面显示”签约成功”就开通会员权益,应等服务端确认协议有效。
- 用户取消签约、协议失效或扣款权限终止后,应及时停用自动续费,并在业务页面显示当前订阅状态。
- 外部浏览器能否直接拉起支付宝客户端,取决于浏览器、操作系统和支付宝客户端环境。需要准备无法唤起时的降级提示,让用户通过支付宝提供的标准页面继续操作。
备注:内容仅供参考。
