0%

springboot接入苹果内购

苹果内购

在上一篇给大家描述了google支付的集成后,苹果内购也是必不可少的。就我而言,感觉苹果内购比google支付要简单容易得多,因为苹果内购撸代码前不需要配置准备,只需要在代码里集成就行了。废话少说,接下来还是分享一下我是如何集成苹果内购的。
首先需要一个苹果支付工具类,这里是IosVerifyUtil.java:

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
package com.xy.goone.common.util.pay;

import javax.net.ssl.*;
import java.io.BufferedOutputStream;
import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.URL;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
import java.util.Locale;

/**
* @author fumei.jiang
* @date 2019-08-15 18:31
* @Description 苹果内购工具类
*/
public class IosVerifyUtil {
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 class TrustAnyHostnameVerifier implements HostnameVerifier {
public boolean verify(String hostname, SSLSession session) {
return true;
}
}

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 = "#######################";//苹果连续订阅共享密钥

/**
* 苹果服务器验证
*
* @param receipt
* 账单
* @url 要验证的地址
* @return null 或返回结果 沙盒 https://sandbox.itunes.apple.com/verifyReceipt
*
*/
public static String buyAppVerify(String receipt,int type,int status) {
//环境判断 线上/开发环境用不同的请求链接
String url = "";
if(type==0){
url = url_sandbox; //沙盒测试
}else{
url = url_verify; //线上测试
}
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 = null;
/* if (status==0){//消耗
// 拼成固定的格式传给平台
str = String.format(Locale.CHINA, "{\"receipt-data\":\"" + receipt + "\"}");//消耗型内购
}else if (status == 1){*/
//连续包月订阅需要加上共享密钥
str = String.format(Locale.CHINA, "{\"receipt-data\":\"" + receipt + "\",\"password\":\"" + IOS_SHARED_SECRET_PASSWORD + "\"}");
//}
hurlBufOus.write(str.getBytes());
hurlBufOus.flush();

InputStream 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) {
System.out.println("苹果服务器异常");
ex.printStackTrace();
}
return null;
}

/**
* 用BASE64加密
*
* @param str
* @return
*/
public static String getBASE64(String str) {
byte[] b = str.getBytes();
String s = null;
if (b != null) {
s = new sun.misc.BASE64Encoder().encode(b);
//s = new String(Base64.encodeBase64(b));
}
return s;
}
}

OrderController.java

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
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
@PostMapping("/iosPaymentVerify")
@Authorization
@ApiOperation("苹果内购支付")
public Result<Object> iosPayment(@RequestBody Map<String, String> map, @CurrentUser User user) {
String no = map.get("no");
String transactionId = map.get("transactionId");
String types = map.get("type");
int type = Integer.valueOf(types.trim()); //0消耗型 1订阅型
String payload = map.get("payload");
log.info("交易号:{},receipt:{},交易类型:{},本地订单号:{}", transactionId, payload, type, no);
IosPayment iosPayment = iosPaymentService.save(no, transactionId, type, payload, user);
//先校对与本地订单
Order localOrder = orderRepository.findByNo(no);//禁止重复刷单
if (localOrder == null) {
return new ResultUtil<>().setErrorMsg(this.i18n("this.order.does.not.exist.and.the.recharge.failed"));
} else {
if (localOrder.getStatus() == Constant.PAY_SUCCESS || (localOrder.getTransactionId() != null && localOrder.getStatus() == Constant.PAY_SUCCESS)) {//本地订单号和苹果交易号每次购买都是唯一的
return new ResultUtil<>().setErrorMsg(this.i18n("this.order.has.been.successfully.recharged.and.cannot.be.refilled"));
}
}
String verifyResult = null;
String receipt = payload.replaceAll("%2B", "+"); //notice: 注意%2B的符号
try {
verifyResult = IosVerifyUtil.buyAppVerify(receipt, 1, type);//1.先线上测试,发送平台验证
} catch (Exception e1) {
throw HttpRequestException.newI18N("server.exception");//服务器异常
}
if (verifyResult == null) {// 苹果服务器没有返回验证结果
throw HttpRequestException.newI18N("order.information.is.abnormal");//订单信息异常
} else {// 苹果验证有返回结果
log.info("线上,苹果平台返回JSON:{}", verifyResult);
JSONObject job = JSONObject.parseObject(verifyResult);
String states = job.getString("status");
if ("21007".equals(states)) {//是沙盒环境,应沙盒测试,否则执行下面
try {
verifyResult = IosVerifyUtil.buyAppVerify(receipt, 0, type);//2.再沙盒测试 发送平台验证
} catch (Exception e) {
throw HttpRequestException.newI18N("server.exception");//服务器异常
}
job = JSONObject.parseObject(verifyResult);
states = job.getString("status");
}
if (states.equals("0")) {// 前端所提供的收据是有效的,验证成功
String r_receipt = job.getString("receipt");
JSONObject returnJson = JSONObject.parseObject(r_receipt);
String in_app = returnJson.getString("in_app");
JSONArray arr = JSONArray.parseArray(in_app);//数组
boolean flag = false;
//如果单号一致 则保存到数据库
try {
for (int i = 0; i < arr.size(); i++) {
JSONObject obj = (JSONObject) arr.get(i);
// 交易中有当前交易,则认为交易成功
if (transactionId.equals(obj.get("transaction_id"))) {
flag = true;
}
if (flag) {//处理业务逻辑
String productId = obj.getString("product_id");
Product product = productService.getByProductId(productId).orElseThrow(() -> {
return HttpRequestException.newI18N("not.find.product");
});
String original_transaction_id = obj.getString("original_transaction_id");
if (type == 0) {//消耗型
long purchase_date = obj.getLong("purchase_date_ms");
int amount = product.getAmount();//充值的coin
int sum = user.getCoin() + amount;
user.setCoin(sum);
localOrder.setType(type);//消耗型
localOrder.setTransactionId(transactionId);
localOrder.setStatus(Constant.PAY_SUCCESS);
localOrder.setProduct(product);
localOrder.setQuantity(amount);
localOrder.setOriginTraId(original_transaction_id);
orderService.updateOrder(localOrder);
userService.updateUser(user);//给用户加币
billRecordService.save(amount, 1, "ポイント購入", user);
iosPayment.setPurchaseDate(new Date(purchase_date));
iosPayment.setLatestReceipt(obj.toString());
iosPaymentService.update(iosPayment, payload, original_transaction_id, product, IosPayment.Status.Completed);//苹果支付详情
return new ResultUtil<>().setData(user.getCoin());
} else {//订阅型
return iosSubscribe(user, payload, transactionId, type, localOrder, job, obj, productId, product, iosPayment);
}
}
}
} catch (Exception e) {
log.error("exception is {}", e.getMessage());
throw HttpRequestException.newI18N("recharge.failed");//充值失败
}
} else {
throw HttpRequestException.newI18N("invalid.receipt.information");//收据信息异常
}
}
return new ResultUtil<>().setErrorMsg(this.i18n("there.is.a.problem.with.the.order.verification"));
}

private Result<Object> iosSubscribe(@CurrentUser User user, String payload, String transactionId, int type, Order localOrder, JSONObject job, JSONObject obj, String productId, Product product, IosPayment iosPayment) {
//首次订阅与连续性订阅的区别判断
String originTransactionId = obj.getString("original_transaction_id");//现交易中的原始订单号,作为续费判定的依据
long purchase_date = obj.getLong("purchase_date_ms");
long expires_date = obj.getLong("expires_date_ms");
Date expiresTime = new Date(expires_date);
Date joinTime = new Date(purchase_date);
log.info("expiresTime is {}", expiresTime);
log.info("joinTime is {}", joinTime);
boolean is_trial_period = true;//是否处于试用期间
String isTrial = obj.getString("is_trial_period");
is_trial_period = Boolean.parseBoolean(isTrial);
if (is_trial_period) {//免费试用期间
user.setType(1);
user.setJoinTime(joinTime);//会员加入时间
user.setExpireTime(expiresTime);//会员过期时间
iosPayment.setExpiresDate(expiresTime);
iosPayment.setPurchaseDate(new Date(purchase_date));
log.info("user id {} 首次订阅成功,体验一个月免费试用期", user.getId());
return sucribeSuccess(user, payload, transactionId, originTransactionId, type, localOrder, product, iosPayment);

}//用户取消后又重新订阅判断依据
if (!StringUtils.isEmpty(job.getString("pending_renewal_info"))) {
JSONArray renewals = JSONArray.parseArray(job.getString("pending_renewal_info"));
JSONArray pendings = new JSONArray();
for (int p = 0; p < renewals.size(); p++) {
JSONObject renewal = (JSONObject) renewals.get(p);
if (originTransactionId.equals(renewal.getString("original_transaction_id"))) {
pendings.add(renewal);
}
}
JSONObject renewal = (JSONObject) pendings.get(pendings.size() - 1);
int auto_renew_status = Integer.parseInt(renewal.getString("auto_renew_status"));//自动续订订阅的当前续订状态。 “ 1”-订阅将在当前订阅期结束时续订。“ 0”-客户已关闭其订阅的自动续订。
if (auto_renew_status == 0) {//用户有关闭订阅,重新订阅
user.setExpireTime(expiresTime);
user.setJoinTime(joinTime);
user.setType(1);
iosPayment.setExpiresDate(expiresTime);
iosPayment.setPurchaseDate(new Date(purchase_date));
log.info("user id {} 重新订阅成功",user.getId());
return sucribeSuccess(user, payload, transactionId, originTransactionId, type, localOrder, product, iosPayment);
}else if (auto_renew_status == 1) {//对于扣费失败的用户, 苹果仍会尝试扣款60天, 解析 pending_renewal_info , auto_renew_status 为1并且 is_in_billing_retry_period 为1, 此时用户的状态并不能标记为已关闭, 而应该是扣费失败
String retry_period = renewal.getString("is_in_billing_retry_period");
if (!StringUtils.isEmpty(retry_period)){//沙盒测试时六次续订完关闭后再次重新订阅开始返回没有这个字段,只有auto_renew_status=1,再点击就有了(我也不知道为什么)
if (retry_period.equals("1")) {//-App Store仍在尝试续订。
return ResultUtil.error(this.i18n("deduction.failed"));//扣费失败
}
}
}

}
return ResultUtil.error(this.i18n("subscribe.already"));
}

private Result<Object> sucribeSuccess(@CurrentUser User user, String payload, String transactionId, String originTraId, int type, Order localOrder, Product product, IosPayment iosPayment) {
userService.updateUser(user);
localOrder.setProduct(product);
localOrder.setTransactionId(transactionId);
localOrder.setType(type);
localOrder.setOriginTraId(originTraId);
localOrder.setStatus(Constant.PAY_SUCCESS);
orderService.updateOrder(localOrder);
iosPaymentService.update(iosPayment, payload, originTraId, product, IosPayment.Status.Completed);//苹果支付详情
return new ResultUtil<>().setSuccessMsg(this.i18n("successfully"));
}


@Authorization
@PostMapping(value = "/createOrder")
@ApiOperation(value = "创建本地订单")
public Result<Object> createOrder(@RequestBody Order order, @CurrentUser User user) {
Order sysOrder = new Order();
sysOrder.setQuantity(order.getQuantity());
sysOrder.setPayType(order.getPayType());
sysOrder.setMoney(order.getMoney());
sysOrder.setType(order.getType());
sysOrder.setUser(user);
sysOrder.setStatus(Constant.PAY_FAILURE);
String no = String.valueOf(System.currentTimeMillis());//订单号
sysOrder.setNo(no + RandomUtil.getRandomNum());//时间戳+随机六位数
orderRepository.save(sysOrder);
return new ResultUtil<>().setData(sysOrder.getNo());
}

是不是感觉苹果内购简单多啦_。例如我所做的业务是会员每月续费,那么会员到期日期的更新就在latest_receipt_info数组里比较最后一条订单数据(最新的订阅数据)的expiresDate和现在时间,如果大于现在时间就更新。
关于自动连续订阅的有关问题,可以附上我的参考文章链接IAP 自动续费后端接入指南自动续期订阅总结

Tips

  • iOS端传过来的苹果回调收据信息需base64加密,如果有严重呢个错误问题,注意收据信息的特殊符号替换,eg:"%2B"。
  • 苹果内购订阅型验证服务器需多加共享密钥(由ios开发人员提供给你)验证,如果是添加的公共秘钥,那么消耗型验证也需要加上秘钥验证。
  • 苹果在上线审核的时候也是使用沙盒账号测试的,那如何识别App端发过来的收据是沙盒测试还是正式环境用户的购买呢?这里服务端就要采用双重验证,即先把收据拿到正式环境的验证地址去验证,如果苹果的正式环境验证服务器返回的状态码 status 为 21007,则说明当前收据是沙盒环境产生,则再连接一次沙盒环境服务器进行验证,这样不管是我们自己采用沙盒账号测试还是苹果审核人员采用沙盒账号进行审核、或者用户购买都可以保证收据正常的验证成功。
  • 苹果内购消耗型订单传过来的订单是在in_app数组里,而自动订阅型续订的最新数据都在latest_receipt_info数组里。

苹果内购自动连续订阅型注意点

  • 测试自动连续订阅型商品时,如果有免费使用期,每个沙盒账号只有一次免费试用期,且最多连续续订五次,一个月对应的测试时间是五分钟。
  • 自动订阅类型正式环境,除了第一次购买行为是用户主动触发的。后续续费都是Apple自动完成的,一般在要过期的前24小时开始,苹果会尝试扣费。

服务器对自动连续订阅型产品续订的解决方案

  1. 添加server to server 通知
  • latest_expired_receipt_info 用于自动续订。过期订阅的收据的JSON表示形式.仅当通知类型为RENEWAL或CANCEL或订阅过期且续订失败时返回。
    项目集成请看官网有介绍。
  1. 服务端server轮询要过期和过期的订单数据,主动向苹果服务器验证
欣赏此文?求鼓励,求支持!