连续支付(周期扣款)功能开发及注意事项

最近有一个版本需求,需要接入周期扣款做连续会员的功能,没想到这一做就是小半个月,趟了很多坑,所以觉得有必要记录一下

1.周期扣款总体设计

在支付宝和微信中(非苹果支付),周期扣款的流程主要有以下两种,并且各有利弊

  1. 先签约,签约成功后再由商户发起主动扣款 #推荐#
    利端:由于一般来讲连续会员会有额外的折扣优惠,先签约再扣款避免了用户薅羊毛。
    弊端:签约和发起扣款时分开的,要额外做很多工作保障一致性。并且据微信官方文档,主动扣款是要延迟一段时间才能发起的。
  2. 支付并完成签约
    利端:签约和支付是合在一起的,用户完成了签约即完成了第一次支付
    弊端:在支付过程中,用户可以取消签约,只完成普通支付(好反人类,还可以取消),导致用户一直可以享受连续会员的优惠,但是并不进行签约。

由于签约和扣款的一致性问题开发努努力还能保证一下,所以我目前选用的是先签约-再扣款流程,也推荐选用。

苹果连续订阅服务
总结一句话–对服务端来说都是满满的恶意。
首先用户付款连续订阅成功了,服务端收不到苹果的任何通知,只有IOS客户端才能知道用户订阅成功了。
其次,用户订阅期间内每次扣款,服务端也无法进行感知,必须要不断的轮询用户的订单列表才能进行判断(IOS客户端都有办法能知道用户续订了)。

下面是一图流,我目前的系统设计方案
连续支付(周期扣款)功能开发及注意事项_第1张图片

2.支付宝周期扣款

首先贴上官方文档地址-周期扣款
和支付宝对接还是相对轻松点的,毕竟技术支持还是比较尽心尽力的。
在先签约再扣款流程中,主要有用到以下几个接口

2.1 签约接口-文档

签约接口有几个坑,在这里给大家排一下雷

  1. 签约最小的周期是七天。
  2. 签约接口无法像下单接口一样,透传业务参数,需要开发者生成签约号并保留签约相关的业务信息。
  3. 在签约回调接口中,目前只能收到用户成功签约的通知,收不到用户解约通知,用户解约的通知是回调到应用网关地址(超级坑)详见回调文档说明

以下是签约接口调用的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 主动扣款接口-文档

主动扣款接口其实使用的普通的收单交易接口
注意点有以下几个

  1. SDK方法默认是同步返回扣款结果的,如果需异步通知,需要设置is_async_pay参数。
  2. 支持提前5天发起扣款。
  3. 在一个扣款周期内,只能发起一次扣款,后续扣款会返回40004和无法再次扣款的响应。
  4. 同步返回结果中,只有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
	 *
	 **/
	@Data
	public 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;
	}

	@Data
	public 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;
	}

	@Data
	public 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客户端向苹果发送了确认订单后,订单会从苹果的订单列表消失

你可能感兴趣的:(java)