最近有一个版本需求,需要接入周期扣款做连续会员的功能,没想到这一做就是小半个月,趟了很多坑,所以觉得有必要记录一下
1.周期扣款总体设计
在支付宝和微信中(非苹果支付),周期扣款的流程主要有以下两种,并且各有利弊
- 先签约,签约成功后再由商户发起主动扣款 #推荐#
利端:由于一般来讲连续会员会有额外的折扣优惠,先签约再扣款避免了用户薅羊毛。
弊端:签约和发起扣款时分开的,要额外做很多工作保障一致性。并且据微信官方文档,主动扣款是要延迟一段时间才能发起的。 - 支付并完成签约
利端:签约和支付是合在一起的,用户完成了签约即完成了第一次支付
弊端:在支付过程中,用户可以取消签约,只完成普通支付(好反人类,还可以取消),导致用户一直可以享受连续会员的优惠,但是并不进行签约。
由于签约和扣款的一致性问题开发努努力还能保证一下,所以我目前选用的是先签约-再扣款流程,也推荐选用。
苹果连续订阅服务
总结一句话–对服务端来说都是满满的恶意。
首先用户付款连续订阅成功了,服务端收不到苹果的任何通知,只有IOS客户端才能知道用户订阅成功了。
其次,用户订阅期间内每次扣款,服务端也无法进行感知,必须要不断的轮询用户的订单列表才能进行判断(IOS客户端都有办法能知道用户续订了)。
下面是一图流,我目前的系统设计方案
2.支付宝周期扣款
首先贴上官方文档地址-周期扣款
和支付宝对接还是相对轻松点的,毕竟技术支持还是比较尽心尽力的。
在先签约再扣款流程中,主要有用到以下几个接口
2.1 签约接口-文档
签约接口有几个坑,在这里给大家排一下雷
- 签约最小的周期是七天。
- 签约接口无法像下单接口一样,透传业务参数,需要开发者生成签约号并保留签约相关的业务信息。
- 在签约回调接口中,目前只能收到用户成功签约的通知,收不到用户解约通知,用户解约的通知是回调到应用网关地址(超级坑)详见回调文档说明
以下是签约接口调用的sdk示例
/*** @Description: 生成客户端唤起签约页面的参数* @param notifyUrl 签约成功回调通知地址* @param isH5 是否为h5* @return: xxx.Result* @Author: lvqiushi* @Date: 2021-04-21*/public static Result<String> userAgreement(AlipayUserAgreementPageSignModel model, String notifyUrl, boolean isH5) {AlipayClient alipayClient = new DefaultAlipayClient("https://openapi.alipay.com/gateway.do", AlipayAppId, AlipayPriveteKey, "json",H5AlipayConfig.CHARSET, AlipayPublicKey, "RSA2");AlipayUserAgreementPageSignRequest request = new AlipayUserAgreementPageSignRequest();request.setBizModel(model);request.setNotifyUrl(notifyUrl);// 周期扣款场景使用小程序/h5 接口跳转至签约页面时请使用 alipayClient.sdkExecute 方法; 若想获取跳转链接转换二维码可使用 alipayClient.pageExecute(request,"get")AlipayUserAgreementPageSignResponse response = null;try {if (isH5) {response = alipayClient.pageExecute(request,"get");} else {response = alipayClient.sdkExecute(request);}} catch (AlipayApiException e) {log.error("生成支付宝签约参数时,发生错误", e);return Result.fail("失败");}return Result.ok(response.getBody());}public static void main(String[] args) {LocalDateTime now = LocalDateTime.now();AlipayUserAgreementPageSignModel model = new AlipayUserAgreementPageSignModel();model.setSignValidityPeriod("7d");model.setProductCode("CYCLE_PAY_AUTH");model.setPersonalProductCode("CYCLE_PAY_AUTH_P");model.setSignScene("INDUSTRY|SOCIALIZATION");// 自定义订单号model.setExternalAgreementNo("XIUCAI2013548132543612315");model.setAgreementEffectType("DIRECT");AccessParams accessParams = new AccessParams();accessParams.setChannel("ALIPAYAPP");model.setAccessParams(accessParams);PeriodRuleParams periodRuleParams = new PeriodRuleParams();// 周期天数periodRuleParams.setPeriodType("DAY");periodRuleParams.setPeriod(7L);periodRuleParams.setExecuteTime(now.format(CommonConstant.SIMPLE_DAY_FORMATTER_OTHER));// 单个周期价格int singlePrice = 100;periodRuleParams.setSingleAmount(Money.ofCent(singlePrice).getYuanString());periodRuleParams.setTotalAmount(Money.ofCent(singlePrice * 36L).getYuanString());periodRuleParams.setTotalPayments(36L);model.setPeriodRuleParams(periodRuleParams);Result<String> signResult = AlipayUtils.userAgreement(model, "http://192.168.0.1:222222/callback/sign/aliNotify", true);}
2.2 主动扣款接口-文档
主动扣款接口其实使用的普通的收单交易接口
注意点有以下几个
- SDK方法默认是同步返回扣款结果的,如果需异步通知,需要设置is_async_pay参数。
- 支持提前5天发起扣款。
- 在一个扣款周期内,只能发起一次扣款,后续扣款会返回40004和无法再次扣款的响应。
- 同步返回结果中,只有total_amount字段有值,在周期扣款中,表示实际扣款金额。
2.3 解约接口-文档
解约接口的话,没什么好讲的,就调用就好了。。。。
3.微信周期扣款
截止目前仍没申请下来。。。等申请下来了再补上
4.苹果连续订阅
苹果本身对于内购和连续订阅的购买逻辑是统一的
在用户完成付款后,会立即向IOS客户端返回苹果交易号transaction_id和用户订单凭证receipt。
这时需要客户端将这两个参数加上订单号通知服务端,完成后续付款操作。
但是对于连续订阅的receipt验证与普通订单有些区别,需要额外的苹果专用共享密钥。
以下这个验证方法也是我从别的地方看过来的,再分享一下吧。
这个是苹果官方的订单信息文档
private static class TrustAnyTrustManager implements X509TrustManager {public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException {}public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException {}public X509Certificate[] getAcceptedIssuers() {return new X509Certificate[]{};}}/** 苹果沙盒环境 */private static final String url_sandbox = "https://sandbox.itunes.apple.com/verifyReceipt";/** 正式环境 */private static final String url_verify = "https://buy.itunes.apple.com/verifyReceipt";/** 专用共享密钥 */private static final String IOS_SHARED_SECRET_PASSWORD = "xxxxxx";/*** @Description: 连续订阅验证回执* @param receipt* @param online* @return: java.lang.String* @Author: lvqiushi* @Date: 2021-04-25*/public static String buyAppVerifyContinuesSub(String receipt, boolean online) {String url = online ? url_verify : url_sandbox;InputStream is = null;try {SSLContext sc = SSLContext.getInstance("SSL");sc.init(null, new TrustManager[] { new TrustAnyTrustManager() }, new java.security.SecureRandom());URL console = new URL(url);HttpsURLConnection conn = (HttpsURLConnection) console.openConnection();conn.setSSLSocketFactory(sc.getSocketFactory());conn.setHostnameVerifier(new TrustAnyHostnameVerifier());conn.setRequestMethod("POST");conn.setRequestProperty("content-type", "text/json");conn.setRequestProperty("Proxy-Connection", "Keep-Alive");conn.setDoInput(true);conn.setDoOutput(true);BufferedOutputStream hurlBufOus = new BufferedOutputStream(conn.getOutputStream());String str = String.format(Locale.CHINA,"{\"receipt-data\":\"" + receipt + "\",\"password\":\"" + IOS_SHARED_SECRET_PASSWORD + "\"}");hurlBufOus.write(str.getBytes());hurlBufOus.flush();is = conn.getInputStream();BufferedReader reader = new BufferedReader(new InputStreamReader(is));String line = null;StringBuffer sb = new StringBuffer();while ((line = reader.readLine()) != null) {sb.append(line);}return sb.toString();} catch (Exception ex) {log.error("调用苹果服务器,进行验证订单回执异常", ex);} finally {try {is.close();} catch (IOException e) {e.printStackTrace();}}return null;}
拿到苹果的订单信息后,还需要进行订单验证,和续订状态的判断,相关的封装类和方法都已经贴在下面了,可以直接用来使用的
/*** https://developer.apple.com/documentation/appstorereceipts/responsebody* @description: 苹果服务器 根据回执返回的订单信息* @author: xiucai* @create: 2021-04-29***/@Datapublic static class AppleReceiptOrder {private AppleReceipt receipt;/** latest_receipt_info 包含订阅的所有交易,其中包括初次购买和后续续期,但不包括任何恢复购买 */private List<AppleTradeInfo> latest_receipt_info;/** 最新的Base64编码的应用收据。仅针对包含自动续订的收据返回。 */private String latest_receipt;/*** 0 正常** 21000 未使用HTTP POST请求方法向App Store发送请求。** 21001 此状态代码不再由App Store发送。** 21002 receipt-data属性中的数据格式错误,或者服务遇到了临时问题。再试一次。** 21003 收据无法认证。** 21004 您提供的共享密钥与您帐户的文件共享密钥不匹配。** 21005 收据服务器暂时无法提供收据。再试一次。** 21006 该收据有效,但订阅已过期。当此状态代码返回到您的服务器时,收据数据也会被解码并作为响应的一部分返回。仅针对自动续订的iOS 6样式的交易收据返回。** 21007 该收据来自测试环境,但是已发送到生产环境以进行验证。** 21008 该收据来自生产环境,但是已发送到测试环境以进行验证。** 21009 内部数据访问错误。稍后再试。** 21010 找不到或删除了该用户帐户。*/private Integer status;}@Datapublic static class AppleReceipt {private String receipt_type;private String bundle_id;private String application_version;private String receipt_creation_date;private String receipt_creation_date_ms;private String original_purchase_date;private String original_purchase_date_ms;/** in_app 数组包含非消耗型、非续期订阅,以及用户之前购买的自动续期订阅。根据需要,检查响应中这些 App 内购买项目类型对应的值来验证交易。 */private List<AppleTradeInfo> in_app;}@Datapublic static class AppleTradeInfo {/** 购买的消费品数量 */private String quantity;/** 购买产品的唯一标识符。您可以在App Store Connect中创建产品时提供此值,该值与存储在交易的付款属性中的对象的属性相对应 */private String product_id;/** 交易的唯一标识符,例如购买,还原或续订 */private String transaction_id;/** 原始购买的交易标识符 */private String original_transaction_id;private String purchase_date;/** 对于自动续订订阅,指经过一段时间后,App Store向用户的帐户收取订阅购买或续订费用的时间 */private Long purchase_date_ms;private String original_purchase_date;/** 原始应用购买时间(以UNIX纪元时间格式),以毫秒为单位。使用此时间格式来处理日期。对于自动续订的订阅,此值指示订阅的首次购买日期 */private String original_purchase_date_ms;private String expires_date;/** 订阅到期的时间或续订的时间(以UNIX纪元时间格式),以毫秒为单位 */private Long expires_date_ms;/** 订阅所属的订阅组的标识符 */private String subscription_group_identifier;/** 订阅是否在免费试用期内的指标 */private Boolean is_trial_period;/** 自动续订订阅是否在介绍性价格期内的指标。请参阅以获取更多信息 */private Boolean is_in_intro_offer_period;}/*** @Description: 在苹果订单中,验证是否包含所给订单,如果验证成功,返回苹果订单信息* @param in_app* @param appleProductId* @param transaction_id* @return: boolean* @Author: lvqiushi* @Date: 2021-05-19*/public static AppleTradeInfo judgeTradeSuccess(List<AppleTradeInfo> in_app, String appleProductId, String transaction_id) {if (CollectionUtils.isEmpty(in_app)) {return null;}for (AppleTradeInfo tradeInfo : in_app) {if (tradeInfo.getTransaction_id().equals(transaction_id) && tradeInfo.getProduct_id().equals(appleProductId)) {return tradeInfo;}}return null;}/*** @Description: 判断苹果用户是否有进行过最新一次的订阅* @param latest_receipt_info* @param original_transaction_id* @param lastPayTime* @return: boolean* @Author: lvqiushi* @Date: 2021-04-29*/public static boolean judgeSubscriptSuccess(List<AppleTradeInfo> latest_receipt_info, String original_transaction_id, LocalDateTime lastPayTime) {if (CollectionUtils.isEmpty(latest_receipt_info)) {return false;}long expireTimeMs = lastPayTime.toInstant(ZoneOffset.of("+8")).toEpochMilli();for (AppleTradeInfo tradeInfo : latest_receipt_info) {if (tradeInfo.getOriginal_transaction_id().equals(original_transaction_id)) {// 购买时间大于上期购买时间 则认定为有续订if (tradeInfo.getPurchase_date_ms() > expireTimeMs) {return true;}}}return false;}
唯一需要注意的是,当IOS客户端向苹果发送了确认订单后,订单会从苹果的订单列表消失