周期扣款支付后签约场景文档

支付宝周期扣款产品介绍

业务流程

  1. 请求支付字符串时携带签约信息拉起支付,并创建待签约的订阅表信息
  2. 处理签约成功回调,添加到订阅表
  3. 定时任务自行请求订阅表,把达到扣款日期的订阅,然后请求支付宝扣款,再计算下次扣款时间
  4. 处理签约解除回调,修改订阅表数据状态。(需要去设置网关回调地址)

数据表设计

1. 用户周期扣款订阅表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
CREATE TABLE `customer_period_subscribe` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`app_id` bigint(20) NOT NULL DEFAULT '0',
`customer_id` bigint(20) NOT NULL DEFAULT '0',
`vip_config_id` bigint(20) NOT NULL DEFAULT '0' COMMENT '签约时购买的vip配置ID',
`channel` varchar(20) NOT NULL DEFAULT '',
`version` varchar(20) NOT NULL DEFAULT '',
`oaid` varchar(100) NOT NULL DEFAULT '',
`contract_no` varchar(100) NOT NULL DEFAULT '' COMMENT '支付宝商家本地唯一签约号',
`contract_time` datetime DEFAULT NULL COMMENT '签约成功时间',
`cancel_time` datetime DEFAULT NULL COMMENT '解约时间',
`contract_status` int(11) NOT NULL DEFAULT '0' COMMENT '订阅状态,0未订阅,1签约中,2已订阅,-1已退订',
`agreement_no` varchar(255) NOT NULL DEFAULT '' COMMENT '支付宝平台签约成功返回签约号',
`next_pay_date` varchar(20) NOT NULL DEFAULT '' COMMENT '商户系统下次扣款日期',
`contract_next_pay_date` varchar(20) NOT NULL DEFAULT '' COMMENT '签约时第三方系统下次扣款日期',
`pay_amount` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '签约扣款价格',
`create_time` datetime NOT NULL,
`update_time` datetime NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=35 DEFAULT CHARSET=utf8mb4 COMMENT='用户周期购签约表';

2. 周期扣款日志表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
CREATE TABLE `customer_period_pay_log` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`app_id` bigint(20) NOT NULL DEFAULT '0',
`customer_id` bigint(20) NOT NULL DEFAULT '0',
`period_subscribe_id` bigint(20) NOT NULL DEFAULT '0' COMMENT '周期购订阅记录ID',
`pay_id` bigint(20) NOT NULL DEFAULT '0' COMMENT '支付记录ID',
`subject` varchar(100) NOT NULL DEFAULT '' COMMENT '周期扣款描述',
`resp_json` text NOT NULL COMMENT '请求周期扣款接口响应数据',
`pay_status` int(11) NOT NULL DEFAULT '0' COMMENT '扣款状态,-1扣款失败,1扣款成功',
`date` varchar(20) NOT NULL DEFAULT '' COMMENT '执行日期',
`create_time` datetime NOT NULL,
`update_time` datetime NOT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=8 DEFAULT CHARSET=utf8mb4 COMMENT='周期扣款记录表';

3. 支付宝周期扣款签约回调表

1
2
3
4
5
6
7
8
9
10
CREATE TABLE `alipay_sign_callback` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`status` int(11) NOT NULL DEFAULT '0' COMMENT '回调情况 0未处理 1处理已完成',
`callback_json` text COMMENT '整个订单数据序列化,后续需要再拿出来使用',
`external_agreement_no` varchar(100) NOT NULL DEFAULT '' COMMENT '支付宝商家签约号',
`callback_status` varchar(20) NOT NULL DEFAULT '' COMMENT '回调状态;正常:NORMAL,解约:UNSIGN,暂存,协议未生效过:TEMP,暂停:STOP',
`create_time` datetime NOT NULL,
`update_time` datetime NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付宝周期购签约回调表';

接周期扣款要注意的点

  1. 支付宝的周期扣款,后续的扣款是商家自行请求扣款接口的,支付宝是不会帮你们做定时器然后回调接口提示你已经扣款的。需要你自己写定时任务计算好扣款日期,再去请求支付宝的,然后支付宝可以提前5天扣款。

  2. 周期扣款日期不能是28号到月底最后一天的,假设下次扣款日是9月28日,那么建议你设置扣款日期是下个月的1~3号,也就是这个字段:execute_time

  3. 周期扣款的后续,商家自行请求支付宝时候,每笔扣款是100元内,也就是你接入周期扣款的时候,后续的每笔自动扣款都必须是100元内,没得提升,想要提升额度就是要用商家代扣,具体问问alipay客服。

代码层

创建支付订单

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
// 1. 创建用户待签约订购记录数据 customer_period_subscribe

Config config = new Config();
config.protocol = "https";
config.gatewayHost = "openapi.alipay.com";
config.signType = "RSA2";
config.appId = application.getAlipayAppId();
config.merchantPrivateKey = application.getAlipayMchPrivateKey();
config.alipayPublicKey = application.getAlipayPublicKey();
// 可设置异步通知接收服务地址(可选)
config.notifyUrl = alipayCallbackUrl;
// 设置参数
Factory.setOptions(config);

AlipayTradeAppPayResponse response;

try {

String subject = "会员支付";

//签约接入的方式
Map<String, String> accessParams = new HashMap<>();
accessParams.put("channel", "ALIPAYAPP");

//签约规则
Map<String, Object> periodRuleParams = new HashMap<>();
//周期类型枚举值为 DAY 和 MONTH periodRuleParams.put("period_type", "MONTH");
//周期数,与 period_type 组合使用确定扣款周期
periodRuleParams.put("period", vipConfig.getMonthNumber());
//用户签约后,下一次使用代扣支付扣款的时间,支付宝周期扣不能大于 28号, 如果周期扣款当天计算是大于本月28号的,建议设置到下个月的1~3号
periodRuleParams.put("execute_time", alipayExecuteTime);
//周期扣款每笔限制扣款最大金额,目前支付宝最大是100元上限
periodRuleParams.put("single_amount", vipConfig.getAlipayPrice());

Map<String, Object> agreementSignParams = new HashMap<>();
//个人签约产品码固定为CYCLE_PAY_AUTH_P
agreementSignParams.put("personal_product_code", "CYCLE_PAY_AUTH_P");
//协议签约场景,参见下文sign_scene参数说明
agreementSignParams.put("sign_scene", "INDUSTRY|BOOKKEEPING");
//签约接入的方式
agreementSignParams.put("access_params", accessParams);
//签约规则
agreementSignParams.put("period_rule_params", periodRuleParams);
//商户签约号,代扣协议中标示用户的唯一签约号(确保在商户系统中唯一)。
//格式规则:支持大写小写字母和数字,最长32位。
//商户系统按需传入,如果同一用户在同一产品码、同一签约场景下,签订了多份代扣协议,那么需要指定并传入该值。
agreementSignParams.put("external_agreement_no", contractNo);
// 签约成功回调地址,需注意,解约时的回调地址是回调应用网关
agreementSignParams.put("sign_notify_url", alipaySignCallbackUrl);

// 2. 发起API调用
response = Factory.Payment.App()
// 周期扣款固定产品码
.optional("product_code", "CYCLE_PAY_AUTH")
// 签约参数
.optional("agreement_sign_params", agreementSignParams)
.pay(subject, pay.getOutTradeNo(), totalAmount);

// 3. 处理响应或异常
if (!ResponseChecker.success(response)) {
log.error("支付宝调用失败");
throw new BaseException(SystemErrorType.SYSTEM_BUSY);
}

} catch (Throwable e) {
log.error("支付宝调用失败,原因:" + e.getMessage());
throw new BaseException(SystemErrorType.SYSTEM_BUSY);
}

签约结果回调

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
/**
* 支付宝周期扣款签约结果回调
*
* @param request
* @return
* @throws Exception
*/
@PostMapping(value = "/alipaySignNotify")
public String alipaySignNotify(HttpServletRequest request) throws Exception {

//获取支付宝POST过来反馈信息
Map<String, String> params = new HashMap<String, String>();
Map requestParams = request.getParameterMap();
for (Iterator iter = requestParams.keySet().iterator(); iter.hasNext(); ) {
String name = (String) iter.next();
String[] values = (String[]) requestParams.get(name);
String valueStr = "";
for (int i = 0; i < values.length; i++) {
valueStr = (i == values.length - 1) ? valueStr + values[i] : valueStr + values[i] + ",";
}
//乱码解决,这段代码在出现乱码时使用。
//valueStr = new String(valueStr.getBytes("ISO-8859-1"), "utf-8");
params.put(name, valueStr);
}

log.error("支付宝周期扣款签约成功回调参数=" + JSON.toJSONString(params));

return vipService.alipaySignCallback(params);
}

@Transactional
@Override
public String alipaySignCallback(Map<String, String> params) {

if (!params.containsKey("external_agreement_no") || !params.containsKey("status") || !params.containsKey("agreement_no")) {
log.error("支付宝周期扣款回调,无效请求,必需字段不存在,params=" + JSON.toJSONString(params));
return "fail";
}

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

// 插入回调记录
AlipaySignCallback alipaySignCallback = new AlipaySignCallback();
alipaySignCallback.setExternalAgreementNo(externalAgreementNo);
alipaySignCallback.setCallbackJson(JSON.toJSONString(params));
alipaySignCallback.setCallbackStatus(status);
alipaySignCallbackMapper.insert(alipaySignCallback);

// 查询用户订阅记录
CustomerPeriodSubscribe customerPeriodSubscribe = customerPeriodSubscribeMapper.findByContractNo(externalAgreementNo);
if (CommonUtils.isNullOrEmpty(customerPeriodSubscribe)) {
log.error("支付宝周期扣款回调,该签约号有误, params=" + JSON.toJSONString(params));
return "fail";
}

// 如果是签约
if (status.equals("NORMAL")) {

// 如果签约状态不是签约中
if (customerPeriodSubscribe.getContractStatus() != 1) {
log.error("支付宝周期扣款回调,用户订阅记录签约状态不是签约中, params=" + JSON.toJSONString(params));
return "fail";
}

// 更新用户订阅状态
customerPeriodSubscribe.setContractStatus(2);
customerPeriodSubscribe.setContractTime(DateUtil.date());
customerPeriodSubscribe.setAgreementNo(agreementNo);
customerPeriodSubscribeMapper.updateById(customerPeriodSubscribe);
}

// 如果是解约
if (status.equals("UNSIGN")) {

// 如果签约状态不是签约中
if (customerPeriodSubscribe.getContractStatus() != 2) {
log.error("支付宝周期扣款回调,用户订阅记录签约状态不是订阅中,解约失败, params=" + JSON.toJSONString(params));
return "fail";
}

customerPeriodSubscribe.setContractStatus(-1);
customerPeriodSubscribe.setCancelTime(DateUtil.date());
customerPeriodSubscribeMapper.updateById(customerPeriodSubscribe);
}

// 更新回调记录状态
alipaySignCallback.setStatus(1);
alipaySignCallbackMapper.updateById(alipaySignCallback);

return "success";
}

定时扣款逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
@Override
public void batchAlipayPeriodPay(List<CustomerPeriodSubscribe> list) throws ParseException {

List<CustomerPeriodSubscribe> updatePeriodSubscribeList = new ArrayList<>();

for (CustomerPeriodSubscribe item : list) {

// 判断今日是否已执行过扣款
CustomerPeriodPayLog customerPeriodPayLog = customerPeriodPayLogMapper.findExecuteData(
item.getCustomerId(), item.getId(), DateUtils.getCurrentDate());
if (!CommonUtils.isNullOrEmpty(customerPeriodPayLog)) {
log.info("用户ID:{}, periodSubscribeId: {}, 今日已执行过扣款,跳过", item.getCustomerId(), item.getId());
continue;
}

// 获取应用参数
Application application = commonService.getRedisApplicationByAppId(item.getAppId());

// 获取会员价格配置
VipConfig vipConfig = vipConfigMapper.findById(item.getVipConfigId());
if (CommonUtils.isNullOrEmpty(vipConfig)) {
continue;
}

// 查询该用户
Customer customer = customerService.findById(item.getCustomerId());
if (CommonUtils.isNullOrEmpty(customer)) {
log.info("用户ID:{} 不存在,不执行扣款", item.getCustomerId());
continue;
}

// 如果执行扣款是VIP会员
if (ParentVipTypeEnum.vip.getType().equals(vipConfig.getParentType().intValue())) {

// 如果已经是终身vip了
if (VipTypeEnum.lifelong.getType().equals(customer.getVipType())) {
log.info("用户ID:{} 已经是终身会员,不执行周期扣款", item.getCustomerId());
continue;
}
} else {

if (VipTypeEnum.adVipLifelong.getType().equals(customer.getAdVipType())) {
log.info("用户ID:{} 已经是终身免广告会员,不执行周期扣款", item.getCustomerId());
continue;
}
}

// 创建支付记录和vip记录
BeforeBuyVipBo beforeBuyVipBo = vipService.beforeBuyVip(item.getCustomerId(),
vipConfig.getParentType().intValue(), vipConfig.getType(),
item.getChannel(), item.getVersion(), PayTypeEnum.alipay, item.getOaid(), true);

String parentVipTypeDesc = ParentVipTypeEnum.getDesc(vipConfig.getParentType().intValue());
String subject = item.getChannel() + "-" + item.getVersion() + "-" + item.getCustomerId() + "-"
+ parentVipTypeDesc + "-" + vipConfig.getTitle() + "-" + application.getAppName() + "自动续费会员支付";

// 调起自动扣款
String tradePayResp = this.alipayTradePay(application.getAlipayAppId(), application.getAlipayMchPrivateKey(),
application.getAlipayPublicKey(), item.getAgreementNo(), item.getPayAmount().toString(), subject,
beforeBuyVipBo.getPay().getOutTradeNo());

JSONObject payRespMap = null;
try {
payRespMap = JSON.parseObject(tradePayResp);
} catch (Throwable ignored) {}

Integer payStatus = -1;

if (payRespMap != null) {
// 如果接口调用成功
if (payRespMap.getString("code").equals("10000")) {
payStatus = 1;
}
}

// 写入周期扣日志
customerPeriodPayLog = new CustomerPeriodPayLog();
customerPeriodPayLog.setAppId(application.getId());
customerPeriodPayLog.setCustomerId(item.getCustomerId());
customerPeriodPayLog.setPeriodSubscribeId(item.getId());
customerPeriodPayLog.setPayId(beforeBuyVipBo.getPay().getId());
customerPeriodPayLog.setSubject(subject);
customerPeriodPayLog.setRespJson(tradePayResp);
customerPeriodPayLog.setPayStatus(payStatus);
customerPeriodPayLog.setDate(DateUtil.date().toDateStr());
customerPeriodPayLogMapper.insert(customerPeriodPayLog);

// 如果扣款成功
if (payStatus.equals(1)) {
CustomerPeriodSubscribe updatePeriodSubscribeData = new CustomerPeriodSubscribe();
updatePeriodSubscribeData.setId(item.getId());
// 下次系统扣款日
updatePeriodSubscribeData.setNextPayDate(
DateUtil.format(
DateUtil.offsetMonth(
DateUtil.parseDate(item.getNextPayDate()), 1), "yyyy-MM-dd"));
updatePeriodSubscribeData.setContractNextPayDate(DateUtil.format(
DateUtil.offsetMonth(
DateUtil.parseDate(item.getContractNextPayDate()), 1), "yyyy-MM-dd"));
updatePeriodSubscribeList.add(updatePeriodSubscribeData);
}
}

if (updatePeriodSubscribeList.size() > 0) {
customerPeriodSubscribeService.updateBatchById(updatePeriodSubscribeList);
}

}

private String alipayTradePay(String alipayAppId, String alipayMchPrivateKey, String alipayPublicKey,
String agreementNo, String totalAmount, String subject, String outTradeNo) {

Config config = new Config();
config.protocol = "https";
config.gatewayHost = "openapi.alipay.com";
config.signType = "RSA2";
config.appId = alipayAppId;
config.merchantPrivateKey = alipayMchPrivateKey;
config.alipayPublicKey = alipayPublicKey;
// 可设置异步通知接收服务地址(可选)
config.notifyUrl = alipayCallbackUrl;
// 设置参数
Factory.setOptions(config);

AlipayOpenApiGenericResponse response;

try {

Map<String, String> agreementSignParams = new HashMap<>();
agreementSignParams.put("agreement_no", agreementNo);

Map<String, Object> bizContents = new HashMap<>();
bizContents.put("product_code", "CYCLE_PAY_AUTH");
bizContents.put("agreement_params", agreementSignParams);
bizContents.put("out_trade_no", outTradeNo);
bizContents.put("total_amount", totalAmount);
bizContents.put("subject", subject);

// 发起API调用
response = Factory.Util.Generic().execute("alipay.trade.pay", new HashMap<>(), bizContents);

// 处理响应或异常
if (!ResponseChecker.success(response)) {
System.out.println(response.toMap());
log.error("支付宝调用失败");
return JSON.toJSONString(response.toMap());
}

} catch (Throwable e) {
log.error("支付宝调用失败,原因:" + e.getMessage());
return "支付宝调用失败,原因:" + e.getMessage();
}

return JSON.toJSONString(response.toMap());
}

解除签约回调

去支付宝的开放后台设置设置应用网关。用户解除签约的时候,是会回调到这个地址的

支付宝周期扣款逻辑梳理和代码流程设计

解除签约回调代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 支付宝应用网关回调
*
* @param request
* @return
* @throws Exception
*/
@PostMapping(value = "/alipayGatewayCallback")
public String alipayGatewayCallback(HttpServletRequest request) throws Exception {

// 目前只有周期扣款会回调这里
return this.alipaySignNotify(request);

}

还可优化

customer_period_pay_log 表里会记录本次自动扣款的状态,可能会有用户余额不足而扣款失败的情况,可以在加一个定时器来拉取这张表里扣款失败的重新尝试

参考链接

支付宝周期扣款(支付后签约)业务功能总结(php+golang)