周期扣款的 APP 与服务端支付流程分别是什么?
结论
周期扣款并不是由 APP 定时发起支付。APP 负责完成签约,后续扣款由服务端按协议执行。
两端的职责如下:
- APP 端展示协议、发起支付宝签约、接收签约结果,并向服务端同步业务状态。
- 服务端生成签约请求、验证异步通知、保存支付宝协议号,并在约定的周期调用支付接口扣款。
- APP 收到的签约结果只用于页面展示。最终签约状态和扣款结果应以服务端查询结果或支付宝异步通知为准。
完整流程如下:
用户在 APP 确认协议
↓
商户服务端生成签约请求
↓
APP 拉起支付宝完成签约
↓
支付宝通知商户服务端
↓
服务端保存有效协议号
↓
到达扣款周期
↓
服务端携带协议号发起扣款
↓
支付宝返回并异步通知支付结果
↓
服务端更新账单,APP 查询并展示结果
为什么要这样划分
周期扣款分为两个阶段:
-
用户授权阶段
用户必须主动确认扣款协议,这一步通常在 APP 的交互流程中完成。 -
后续扣款阶段
签约成功后,用户不需要每期重新打开 APP。商户服务端会根据业务周期、扣款金额和协议状态发起支付。
APP 不能保存商户私钥,也不应自行组装或签名服务端支付请求。协议号、签名、扣款计划、幂等控制以及异步通知验签都应由服务端处理。
APP 端流程
1. 展示协议及扣款信息
APP 应向用户明确展示以下内容:
- 服务名称;
- 扣款周期;
- 金额或金额计算规则;
- 首次扣款时间;
- 协议生效和解约条件;
- 商户名称及必要的风险提示。
用户确认后,APP 请求商户服务端创建签约单。
2. 向服务端申请签约参数
请求示例:
POST /api/payment/agreement/create
Content-Type: application/json
{
"userId": "10001",
"planId": "vip_monthly",
"clientType": "ANDROID"
}
服务端返回签约所需的跳转地址或已签名参数:
{
"requestId": "agr_req_202609090001",
"signPayload": "<服务端生成的签约参数>"
}
服务端具体返回 URL、表单还是 SDK 参数,取决于当前支付宝产品的接入方式和官方文档要求。
3. 拉起支付宝完成签约
APP 通过支付宝提供的 SDK、收银台页面或签约页面完成用户授权。下面的代码只表示调用关系,不对应某个特定版本 SDK 的固定方法签名:
fun startAgreementSign(signPayload: String) {
// 使用支付宝当前接入文档指定的 SDK 或页面打开 signPayload
alipayClient.open(signPayload) { result ->
showSigningResult(result)
refreshAgreementStatusFromServer()
}
}
APP 不能只根据客户端回调将协议标记为 “已生效”。网络中断、用户切换应用或结果被篡改,都可能导致客户端回调不完整。
4. 向服务端查询最终状态
GET /api/payment/agreement/agr_req_202609090001
响应示例:
{
"status": "SIGNED",
"effectiveTime": "2026-09-09T10:30:00+08:00"
}
服务端可以返回统一的业务状态,例如:
CREATED 已创建
PROCESSING 签约处理中
SIGNED 已签约并生效
FAILED 签约失败
CANCELED 已解约
服务端流程
1. 创建签约请求
服务端收到 APP 请求后,需要完成以下处理:
- 校验用户身份和业务套餐;
- 生成唯一的商户签约请求号;
- 保存待签约记录;
- 组装支付宝签约参数;
- 使用商户私钥签名;
- 将 APP 所需的签约参数返回给客户端。
周期扣款签约通常会用到支付宝签约能力,例如 alipay.user.agreement.page.sign。实际接口名称、产品码和必填参数,应以商户已签约产品在支付宝开放平台展示的文档为准。
伪代码示例:
public SignResponse createAgreement(SignCommand command) {
String externalAgreementNo = agreementNoGenerator.next();
AgreementRecord record = new AgreementRecord();
record.setExternalAgreementNo(externalAgreementNo);
record.setUserId(command.getUserId());
record.setPlanId(command.getPlanId());
record.setStatus("CREATED");
agreementRepository.save(record);
Map<String, Object> bizContent = new HashMap<>();
bizContent.put("external_agreement_no", externalAgreementNo);
bizContent.put("sign_scene", "<以产品文档为准>");
bizContent.put("personal_product_code", "<以产品文档为准>");
String signPayload = alipayAgreementClient.buildSignPayload(bizContent);
return new SignResponse(externalAgreementNo, signPayload);
}
其中,buildSignPayload 只是业务封装示例,并非支付宝官方规定的方法名。
2. 接收并验证签约通知
收到支付宝的异步通知后,服务端应按以下步骤处理:
- 使用支付宝公钥或支付宝公钥证书验签;
- 校验
app_id、商户签约请求号等关键字段; - 必要时调用协议查询接口复核;
- 保存支付宝返回的协议号;
- 更新签约状态;
- 按接口要求返回成功响应。
@PostMapping("/notify/alipay/agreement")
public String handleAgreementNotify(
@RequestParam Map<String, String> parameters) {
if (!alipaySignatureVerifier.verify(parameters)) {
return "failure";
}
String externalAgreementNo =
parameters.get("external_agreement_no");
String agreementNo = parameters.get("agreement_no");
String status = parameters.get("status");
agreementService.applyNotify(
externalAgreementNo,
agreementNo,
status,
parameters
);
return "success";
}
通知字段名称和成功响应格式应以对应的接口文档为准。示例中出现的字段,不代表所有产品版本都会提供。
3. 保存协议号并建立扣款计划
协议生效后,服务端应关联并保存以下信息:
- 商户用户;
- 商户签约请求号;
- 支付宝协议号;
- 协议状态;
- 扣款计划;
- 下次扣款时间;
- 最近一次扣款结果。
支付宝协议号是后续扣款的重要凭证,不应由 APP 长期保存,也不能将 APP 回传的协议号当作可信输入。
4. 到期后由服务端发起扣款
服务端的调度任务找到到期账单后,需要先确认协议仍然有效,再发起支付。周期扣款通常调用 alipay.trade.pay,并携带协议相关参数。准确的 product_code、agreement_params 结构和适用场景,应以当前能力文档和商户实际签约权限为准。
示意代码:
public PayResult executeCyclePayment(Bill bill, Agreement agreement) {
Map<String, Object> bizContent = new HashMap<>();
bizContent.put("out_trade_no", bill.getTradeNo());
bizContent.put("subject", bill.getSubject());
bizContent.put("total_amount", bill.getAmount().toPlainString());
// 以下字段和值需以周期扣款产品文档为准
bizContent.put("product_code", "<周期扣款产品码>");
Map<String, String> agreementParams = new HashMap<>();
agreementParams.put("agreement_no", agreement.getAgreementNo());
bizContent.put("agreement_params", agreementParams);
return alipayTradeClient.pay(bizContent);
}
每期扣款都必须使用唯一且稳定的 out_trade_no。如果请求超时,应先查询原订单,不能直接更换订单号重新扣款,否则可能造成重复支付。
5. 处理支付结果
扣款接口同步返回成功,不一定说明整个业务流程已经完成。同步请求超时,也不代表支付失败。服务端应综合以下信息判断最终结果:
- 支付接口同步响应;
- 支付结果查询;
- 支付异步通知。
@PostMapping("/notify/alipay/payment")
public String handlePaymentNotify(
@RequestParam Map<String, String> parameters) {
if (!alipaySignatureVerifier.verify(parameters)) {
return "failure";
}
String outTradeNo = parameters.get("out_trade_no");
String tradeNo = parameters.get("trade_no");
String tradeStatus = parameters.get("trade_status");
paymentService.applyNotify(
outTradeNo,
tradeNo,
tradeStatus,
parameters
);
return "success";
}
通知处理必须保证幂等。同一条通知可能重复发送,无论处理多少次,都不能重复入账、重复发放权益或重复更新余额。
两端的交互关系
APP 与服务端之间通常只传递商户自己的业务标识:
APP 商户服务端 支付宝
│ │ │
│ 创建签约请求 │ │
├─────────────────────────────>│ │
│ │ 生成并签名签约参数 │
│<─────────────────────────────┤ │
│ 拉起签约页面或 SDK │ │
├───────────────────────────────────────────────────────────>│
│<──────────────────── 客户端签约结果 ───────────────────────┤
│ │<────── 签约异步通知 ─────────┤
│ 查询最终签约状态 │ │
├─────────────────────────────>│ │
│<─────────────────────────────┤ │
│ │ 到期后发起扣款 │
│ ├────────────────────────────>│
│ │<──────── 同步响应 ───────────┤
│ │<────── 支付异步通知 ─────────┤
│ 查询账单或权益状态 │ │
├─────────────────────────────>│ │
│<─────────────────────────────┤ │
APP 不参与每一期扣款。即使用户没有启动 APP,只要协议仍然有效且业务条件满足,服务端就可以在协议允许的范围内发起扣款。
注意事项
以服务端状态为准
客户端返回结果只用于改善交互体验。签约成功、解约成功和支付成功,都应由服务端通过验签后的通知或主动查询来确认。
私钥不得放入 APP
商户私钥、应用私钥和服务端密钥都不能打包到客户端中。APP 容易被反编译,私钥一旦泄露,攻击者可能伪造商户请求。
做好幂等和防重复扣款
每一期账单应使用固定的唯一订单号,并在数据库层设置唯一约束。接口超时后,先查询原订单,再决定是否重试。
校验协议与订单归属
服务端不能直接信任 APP 上传的协议号。发起扣款前,需要确认:
- 协议属于当前商户应用;
- 协议与当前用户绑定;
- 协议处于有效状态;
- 本期账单尚未支付;
- 金额符合用户确认的规则;
- 当前时间满足扣款周期约束。
正确处理异步通知
通知接口应允许重复调用,并在验签和业务数据落库完成后返回成功。验签失败、关键字段不匹配或业务处理失败时,不能返回成功结果。
区分失败类型
扣款失败可能由协议失效、余额不足、风控限制、订单参数错误或系统繁忙引起。是否重试、重试次数和间隔,需要根据支付宝返回码及业务协议制定,不能对所有失败无条件重试。
支持解约和状态同步
用户解约后,服务端应立即停止生成新的扣款任务。如果本地状态与支付宝状态不一致,应根据协议查询结果和有效通知进行修正。
参数以实际开通能力为准
周期扣款的产品码、签约参数、支付参数和接口权限,可能因产品方案、商户类型及开放平台配置而不同。接入前应查看能力中心中当前商户可见的接口文档和已开通权限,不能只根据通用示例填写生产参数。
备注:内容仅供参考。
