支付宝周期扣款逻辑梳理和代码流程设计
周期扣款支付后签约场景文档
业务流程
- 请求支付字符串时携带签约信息拉起支付,并创建待签约的订阅表信息
- 处理签约成功回调,添加到订阅表
- 定时任务自行请求订阅表,把达到扣款日期的订阅,然后请求支付宝扣款,再计算下次扣款时间
- 处理签约解除回调,修改订阅表数据状态。(需要去设置网关回调地址)
数据表设计
1. 用户周期扣款订阅表
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. 周期扣款日志表
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. 支付宝周期扣款签约回调表
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='支付宝周期购签约回调表';
接周期扣款要注意的点
支付宝的周期扣款,后续的扣款是商家自行请求扣款接口的,支付宝是不会帮你们做定时器然后回调接口提示你已经扣款的。需要你自己写定时任务计算好扣款日期,再去请求支付宝的,然后支付宝可以提前5天扣款。
周期扣款日期不能是28号到月底最后一天的,假设下次扣款日是9月28日,那么建议你设置扣款日期是下个月的1~3号,也就是这个字段:execute_time
周期扣款的后续,商家自行请求支付宝时候,每笔扣款是100元内,也就是你接入周期扣款的时候,后续的每笔自动扣款都必须是100元内,没得提升,想要提升额度就是要用商家代扣,具体问问alipay客服。
代码层
创建支付订单
// 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);
}
签约结果回调
/**
* 支付宝周期扣款签约结果回调
*
* @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";
}
定时扣款逻辑
@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());
}
解除签约回调
去支付宝的开放后台设置设置应用网关。用户解除签约的时候,是会回调到这个地址的
解除签约回调代码
/**
* 支付宝应用网关回调
*
* @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)
本作品采用《CC 协议》,转载必须注明作者和本文链接