使用Java接入苹果内购流程(附主要代码)
一、苹果内购介绍
在App Store上架的 APP ,如果订购的商品类型是虚拟的,非实物的,类似:虚拟币、豆子、金币等,需要使用苹果自己的支付方式,即:IAP内购,并且苹果会抽取30%的税。
上架商品可在 APP Store后台进行配置。
二、前端拉起苹果支付,获取支付票据
注意:这里是用的flutter来开发的,一套代码,android和ios系统均可发布。
主要是苹果的内购插件:in_app_purchase/in_app_purchase.dart
附上核心代码,仅供参考:
// 加载产品列表
void _loadProducts() async {
if (Platform.isAndroid) {
return;
}
const Set<String> kIds = {'com.linshang.huiyuan_zhuanye'};
final ProductDetailsResponse response =
await _inAppPurchase.queryProductDetails(kIds);
if (response.notFoundIDs.isNotEmpty) {
// 处理找不到商品的情况
print("商品未找到: ${response.notFoundIDs}");
}
setState(() {
_products = response.productDetails;
});
}
void _buyProduct(
ProductDetails productDetails,
int skuId,
) async {
final params = {
'payPlatform': '',
'payScene': '',
'skuId': skuId,
};
final res = await createIOSPayPort(params);
setState(() {
_orderNo = res.data;
});
final PurchaseParam purchaseParam =
PurchaseParam(productDetails: productDetails);
_inAppPurchase.buyNonConsumable(
purchaseParam: purchaseParam); // 如果是消耗品用 buyConsumable
}
// 监听购买更新
void _listenToPurchaseUpdated(
List<PurchaseDetails> purchaseDetailsList) async {
for (var purchaseDetails in purchaseDetailsList) {
switch (purchaseDetails.status) {
case PurchaseStatus.pending:
// 等待支付
print('等待支付');
break;
case PurchaseStatus.restored:
// 验证购买
print('验证购买');
break;
case PurchaseStatus.purchased:
// 支付成功
print('支付成功');
final isPay = await Storage.get('isPay');
if (isPay == 'true') {
await _inAppPurchase.completePurchase(purchaseDetails);
} else {
print(purchaseDetails.verificationData.serverVerificationData);
await verifyIOSPayPort(
purchaseDetails.verificationData.serverVerificationData, _orderNo);
Storage.set('isPay', 'true');
}
break;
case PurchaseStatus.error:
// 支付失败
print('支付失败');
await _inAppPurchase.completePurchase(purchaseDetails);
Storage.remove('isPay');
break;
case PurchaseStatus.canceled:
// 取消购买
print('取消购买');
await _inAppPurchase.completePurchase(purchaseDetails);
Storage.remove('isPay');
break;
}
}
}
三、服务端验证支付票据
3.1 服务端逻辑
@Value("${spring.profiles.active}")
private String profileActive;
@Override
public boolean verifyIosPayReceipt(Long userId, AppIosPayReceiptVerifyParam param) {
String receipt = param.getReceipt();
String orderNo = param.getOrderNo();
// 校验订单号是否存在
Orders orders = ordersRepository.getByOrderNo(orderNo);
if (null == orders) {
throw new CustomizeException("订单号不存在");
}
int type;
if ("prod".equalsIgnoreCase(profileActive)) {
type = 1;
} else {
type = 0;
}
// 注意,有的票据在客户端接收时 加号 可能会被转换为 空格
String data = receipt.replace(" ", "+");
// 请求苹果服务器进行票据验证
String result = AppleVerifyUtil.verifyApple(data, type);
if (null == result){
log.error("[ verify receipt error]");
return false;
}
JSONObject receiptData = JSONObject.parseObject(result);
log.info("[ verify receipt result] {}", receiptData.toJSONString());
int status = receiptData.getInteger("status");
// 苹果生成的订单号
String transactionId;
// 购买时间,单位:毫秒
Long purchaseDateMs;
// 商品ID 与在APP Store 后台配置的一致
String productId;
JSONObject receiptInfo = receiptData.getJSONObject("receipt");
if (receiptInfo == null) {
return false;
}
JSONArray inAppList = receiptInfo.getJSONArray("in_app");
if (!CollectionUtils.isEmpty(inAppList)) {
// ios7之前的数据格式
JSONObject inApp = inAppList.getJSONObject(inAppList.size() - 1);
transactionId = inApp.getString("transaction_id");
purchaseDateMs = inApp.getLong("purchase_date_ms");
productId = inApp.getString("product_id");
} else {
// ios7之后的数据格式
transactionId = receiptInfo.getString("transaction_id");
purchaseDateMs = receiptInfo.getLong("purchase_date_ms");
productId = receiptInfo.getString("product_id");
}
// todo 判断product_id,看返回的product_id与实际的充值金额是不是一致,防止骗单
if (0 == status) {
// todo 支付成功,剩余业务逻辑
return true;
} else {
// todo 支付失败,打印日志,更新订单状态等逻辑
log.error("[ verify ios receipt error ] status:{}", status);
return false;
}
}
@Data
public class AppIosPayReceiptVerifyParam {
/**
* ios支付票据(前端拿到的票据字段应该是:verificationData,传给服务端即可)
*/
@NotNull(message = "receipt不能为空")
private String receipt;
/**
* 商家自定义订单号
*/
@NotNull(message = "orderNo不能为空")
private String orderNo;
}
3.2 苹果内购验证工具类
package xxx.xxx.xxx.util;
import com.alibaba.fastjson.JSONObject;
import lombok.extern.slf4j.Slf4j;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import java.net.URL;
import java.security.cert.X509Certificate;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
/**
* 苹果内购验证工具类
* 苹果内购系统会根据用户的账号自动判断是沙盒测试环境还是生产环境。
*/
@Slf4j
public class AppleVerifyUtil {
/**
* 苹果内购沙盒环境
*/
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 KEY = "需要到APP Store后台获取";
/**
* 苹果服务器内购验证票据
*
* @param receipt 验证收据
* @param type 环境 (1 生产;0 开发)
* @return
*/
public static String verifyApple(String receipt, int type) {
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);
JSONObject jsonObject = new JSONObject();
jsonObject.put("receipt-data", receipt);
jsonObject.put("password", KEY);
OkHttpClient okHttpClient = new OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.build();
MediaType mediaType = MediaType.parse("application/json;charset=utf-8");
RequestBody stringBody = RequestBody.create(mediaType, jsonObject.toString());
Request request = new Request
.Builder()
.url(console)
.post(stringBody)
.build();
return Objects.requireNonNull(okHttpClient.newCall(request).execute().body()).string();
} catch (Exception e) {
log.error("[ios verify error]");
return null;
}
}
private static class TrustAnyTrustManager implements X509TrustManager {
@Override
public void checkClientTrusted(X509Certificate[] chain, String authType) {
}
@Override
public void checkServerTrusted(X509Certificate[] chain, String authType) {
}
@Override
public X509Certificate[] getAcceptedIssuers() {
return new X509Certificate[]{};
}
}
}
3.3 票据信息的数据结构
服务端携带票据请求苹果服务端验证成功后,苹果会返回解析后的票据信息。我们需要的信息都在receipt中。但是经过测试发现,出现了两种数据结构,分别是IOS7之前和IOS7之后。
- IOS7之前的数据格式
{
"receipt": {
"original_purchase_date_pst": "2016-12-03 01:11:01 America/Los_Angeles",
"purchase_date_ms": "1480756261254",
"unique_identifier": "96f51b28f628493709966f33a1fe7ba",
"original_transaction_id": "1000000255766",
"bvrs": "82",
"transaction_id": "1000000255766",
"quantity": "1",
"unique_vendor_identifier": "FE358-1362-40FD-870F-DF788AC5",
"item_id": "11822945",
"product_id": "rjkf_itemid_1",
"purchase_date": "2016-12-03 09:11:01 Etc/GMT",
"original_purchase_date": "2016-12-03 09:11:01 Etc/GMT",
"purchase_date_pst": "2016-12-03 01:11:01 America/Los_Angeles",
"bid": "com.xxx.xxx",
"original_purchase_date_ms": "1480756261254"
},
"status": 0
}
- IOS7之后的数据结构
{
"status": 0, //票据验证的状态码。0表示成功,其他值表示错误状态(如21007表示是沙盒环境票据)。
"environment": "Sandbox", //票据生成的环境。Production表示生产环境,Sandbox表示沙盒环境。
"receipt": {//票据的详细信息,包括应用程序和购买数据。
"receipt_type": "ProductionSandbox", //票据类型,可能是Production或Sandbox。
"adam_id": 0, //应用的唯一标识符,Apple ID。
"app_item_id": 0, //应用程序的ID。
"bundle_id": "com.xxx.xxx", //应用程序的捆绑包标识符(Bundle Identifier),即你的应用包名。
"application_version": "84", //购买时应用的版本号。
"download_id": 0, //下载的唯一标识符。
"version_external_identifier": 0, //外部版本标识符,Apple 用于标识版本。
"receipt_creation_date": "2016-12-05 08:41:57 Etc/GMT", //票据创建的日期,格式为 UTC。
"receipt_creation_date_ms": "1480927317000", //票据创建的时间戳,单位为毫秒。
"receipt_creation_date_pst": "2016-12-05 00:41:57 America/Los_Angeles", //票据创建的本地时间(美国太平洋标准时间)。
"request_date": "2016-12-05 08:41:59 Etc/GMT", //请求验证票据的日期,格式为 UTC。
"request_date_ms": "1480927319441", //请求验证票据的时间戳,单位为毫秒。
"request_date_pst": "2016-12-05 00:41:59 America/Los_Angeles", //请求验证票据的本地时间(美国太平洋标准时间)。
"original_purchase_date": "2013-08-01 07:00:00 Etc/GMT", //应用程序的原始购买日期。
"original_purchase_date_ms": "1375340400000", //应用程序的原始购买的时间戳,单位为毫秒。
"original_purchase_date_pst": "2013-08-01 00:00:00 America/Los_Angeles", //应用程序的原始购买的本地时间(美国太平洋标准时间)。
"original_application_version": "1.0", //用户首次下载应用时的应用版本。
"in_app": [//列出所有的应用内购买信息,每个内购都有详细记录。每个 in_app 数组中的项代表一个内购交易,包含以下字段:
{
"quantity": "1", //购买的数量。
"product_id": "rjkf_itemid_1", //购买的产品标识符(即内购产品的 ID)。
"transaction_id": "10000003970", //当前购买的交易 ID。
"original_transaction_id": "10000003970", //原始交易 ID,如果是续订的订阅,这个字段会与原始购买的交易 ID 相同。
"purchase_date": "2016-12-05 08:41:57 Etc/GMT", //购买日期(UTC 时间)。
"purchase_date_ms": "1480927317000", //购买时间戳(毫秒)。
"purchase_date_pst": "2016-12-05 00:41:57 America/Los_Angeles", //购买的本地时间(美国太平洋标准时间)。
"original_purchase_date": "2016-12-05 08:41:57 Etc/GMT", //订阅过期时间(如果有订阅)。
"original_purchase_date_ms": "1480927317000", //订阅过期时间戳,单位为毫秒。
"original_purchase_date_pst": "2016-12-05 00:41:57 America/Los_Angeles", //订阅过期的本地时间(美国太平洋标准时间)。
"is_trial_period": "false"//表示是否是试用期购买,true或false。
}
]
}
}
3.4 错误码
状态码 - | 详情 |
---|---|
0 | 校验成功 |
21000 | 未使用HTTP POST请求方法向App Store发送请求。 |
21001 | 此状态代码不再由App Store发送。 |
21002 | receipt-data属性中的数据格式错误或丢失。 |
21003 | 收据无法认证。 |
21004 | 您提供的共享密码与您帐户的文件共享密码不匹配。 |
21005 | 收据服务器当前不可用。 |
21006 | 该收据有效,但订阅已过期。当此状态代码返回到您的服务器时,收据数据也会被解码并作为响应的一部分返回。仅针对自动续订的iOS 6样式的交易收据返回。 |
21007 | 该收据来自测试环境,但已发送到生产环境以进行验证。 |
21008 | 该收据来自生产环境,但是已发送到测试环境以进行验证。 |
21009 | 内部数据访问错误。稍后再试。 |
210010 | 找不到或删除了该用户帐户。 |
四、接入IOS回调通知(有订阅续期或者退款需求的接入,仅内购商品订购不需要接入)
4.1 配置回调通知url
打开应用配置,设置沙盒生产环境的服务器接口地址即可。 苹果官方配置页面
注意:最好选择V2版本通知,不建议使用V1版本,V1版本即将废弃。
4.2 通知类型
通知类型(notification_type):苹果官方文档地址。
根据解析出来的notification_type字段来判断回调通知具体是什么场景,然后进行对应的业务逻辑处理。
- DID_RENEW:表示客户的订阅已成功自动续订新的交易周期。为客户提供对订阅内容或服务的访问权限。
- REFUND:表示 App Store 已成功对消耗性应用内购买、非消耗性应用内购买或非续订订阅的交易进行退款。包含退款交易的时间戳。并标识原始交易和产品。其中包含原因。
4.3 测试
官方提供的沙盒环境测试退款方法:苹果官方文档地址,需要在本地xcode跑StoreKit Test,需要ios开发人员支持。
这里提供一个免费的webhook网址,可以用于本地测试接收通知:https://webhook.site/
4.4 处理回调通知
-
【版本 1】App Store Server Notifications V1 的通知已被弃用。因此我们需要接入【版本 2】App Store Server Notifications V2 的通知。
-
请求报文结构
{"signedPayload":"eyJhbGciOiJFUzI1NiIsIng1YyI6WyJNSUlFTURDQ0E3YWdBd0lCQWdJUWFQb1BsZHZwU29FSDBsQnJqRFB2OWpBS0JnZ3Foa2pPUFFRREF6QjFNVVF3UWdZRFZRUURERHRCY0hCc1pTQlhiM0pzWkhkcFpHVWdSR1YyWld4dmNHVnlJRkpsYkdGMGFXOXVjeUJEWlhKMGFXWnBZMkYwYVc5dUlFRjFkR2h2Y21sMGVURUxNQWtHQTFVRUN3d0NSell4RXpBUkJnTlZCQW9NQ2tGd2NHeGxJRWx1WXk0eEN6QUpCZ05WQkFZVEFsVlRNQjRYRFRJeE1EZ3lOVEF5TlRBek5Gb1hEVEl6TURreU5EQXlOVEF6TTFvd2daSXhRREErQm..."}
- 验证签名,解析数据
/**
* 验证签名
* @param jws
* @return
* @throws CertificateException
*/
public static JSONObject verifyAndGet(String jws) throws CertificateException {
DecodedJWT decodedJWT = JWT.decode(jws);
// 拿到 header 中 x5c 数组中第一个
String header = new String(java.util.Base64.getDecoder().decode(decodedJWT.getHeader()));
String x5c = JSONObject.parseObject(header).getJSONArray("x5c").getString(0);
// 获取公钥
PublicKey publicKey = getPublicKeyByX5c(x5c);
// 验证 token
Algorithm algorithm = Algorithm.ECDSA256((ECPublicKey) publicKey, null);
try {
algorithm.verify(decodedJWT);
} catch (SignatureVerificationException e) {
throw new RuntimeException("签名验证失败");
}
// 解析数据
return JSONObject.parseObject(new String(java.util.Base64.getDecoder().decode(decodedJWT.getPayload())));
}
/**
* 获取公钥
* @param x5c
* @return
* @throws CertificateException
*/
private static PublicKey getPublicKeyByX5c(String x5c) throws CertificateException {
byte[] x5c0Bytes = java.util.Base64.getDecoder().decode(x5c);
CertificateFactory fact = CertificateFactory.getInstance("X.509");
X509Certificate cer = (X509Certificate) fact.generateCertificate(new ByteArrayInputStream(x5c0Bytes));
return cer.getPublicKey();
}
- 解析出来的JSON数据
{
// 描述 App Store 发送版本 2 通知的应用内购买或外部购买事件的类型。
"notificationType": "REFUND",
// 通知的唯一标识符。使用此值来识别重复的通知。
"notificationUUID": "334d1548-****-4ea9-****-e104731870b9",
// 包含应用程序元数据和已签名的续订和交易信息的对象。data、summary和字段互斥。有效载荷仅包含其中一个字段。externalPurchaseToken
"data": {
// 通知适用的应用的唯一标识符。此属性适用于用户从 App Store 下载的应用。它在沙盒环境中不存在。
"appAppleId": 1617026651,
// 应用程序的软件包标识符。
"bundleId": "com.*****",
// 标识软件包迭代的构建版本。
"bundleVersion": "1",
// 客户请求退款的原因。此字段仅在客户发起消费类应用内购买或自动续订订阅的退款请求时服务器发送的通知中显示。CONSUMPTION_REQUEST
"consumptionRequestReason": "UNINTENDED_PURCHASE",
// 通知适用的服务器环境,sandbox(沙盒) 或production(生产) 。
"environment": "Sandbox",
// 由 App Store 签名的交易信息,采用 JSON Web 签名(JWS)格式。
"signedTransactionInfo": "eyJhbGciOiJFUzI...",
// 由 App Store 签名的订阅续订信息,采用 JSON Web 签名 (JWS) 格式。此字段仅适用于自动续订订阅的通知。
"signedRenewalInfo": "eyJhbGciOiJFUzI...",
// 自动续订订阅的状态截至。此字段仅出现在针对自动续订订阅发送的通知中。signed
"status": "1"
},
// App Store 服务器通知版本号,。"2.0"
"version": "2.0",
// App Store 签署 JSON Web 签名数据的 UNIX 时间(以毫秒为单位)。
"signedDate": 1680778196476
}
data中的 signedTransactionInfo 和 signedRenewalInfo 依然是一个jws格式,且字段与主动查询的结果一致,用上面的解析代码再解码一次。其中 signedRenewalInfo 仅适用于自动续订订阅(DID_RENEW)的通知。
- 处理回调通知接口Java代码
/**
* IOS通知请求体
*/
@Data
public class IosNotificationRequest {
private String signedPayLoad;
}
/**
* 苹果ios服务通知回调
*/
@RequestMapping("/iosNotification")
public boolean iosNotification(@RequestBody IosNotificationRequest request) {
log.info("apple ios server notification come in, request:{}", JSONObject.toJSONString(request));
String signedPayLoad = request.getSignedPayload();
JSONObject payload;
String notificationType;
String transactionId;
String originalTransactionId;
String productId;
JSONObject data;
String environment;
JSONObject callbackInfo;
try {
payload = AppleVerifyUtil.verifyAndGet(signedPayLoad);
notificationType = payload.get("notificationType").toString();
data = payload.getJSONObject("data");
environment = data.get("environment").toString();
if (data.get("signedTransactionInfo") != null) {
String signedTransactionInfo = data.get("signedTransactionInfo").toString();
callbackInfo = AppleVerifyUtil.verifyAndGet(signedTransactionInfo);
} else {
String signedRenewalInfo = data.get("signedRenewalInfo").toString();
callbackInfo = AppleVerifyUtil.verifyAndGet(signedRenewalInfo);
}
transactionId = callbackInfo.get("transactionId").toString();
originalTransactionId = callbackInfo.get("originalTransactionId").toString();
productId = callbackInfo.get("productId").toString();
} catch (Exception e) {
log.error("apple ios server notification verify failed, request:{}", JSONObject.toJSONString(request), e);
return false;
}
// 保存支付回调
paymentCallbackRepository.save(originalTransactionId, PayPlatformEnum.APPLE, payload.toString());
// 根据通知类型处理不同的逻辑
switch (notificationType) {
// case "INITIAL_BUY":
// subscriptionsApplication.handleInitialBuy(originalTransactionId, transactionId, productId, purchaseDateMs, expiresDateMs);
// break;
// 订阅续费
case "DID_RENEW":
subscriptionsApplication.handleDidRenew(PayPlatformEnum.APPLE, originalTransactionId, transactionId, productId);
break;
// 取消订阅
case "CANCEL":
subscriptionsApplication.handleCancel(originalTransactionId);
break;
// 退款
// case "REFUND":
// handleRefund(transactionId);
// break;
default:
throw new IllegalArgumentException("Unknown notification_type: " + notificationType);
}
return true;
}
具体的业务处理这里就不过多赘述了,每个公司的业务不同,因此处理逻辑也不同,根据实际情况自行处理即可。