前言
一般而言,我们其实很少对接退款接口,因为退款基本都是商家自己决定后进行操作的,但是苹果比较特殊,用户可以直接向苹果发起退款请求,苹果觉得合理会退给用户,但是目前公司业务还是需要对接这个接口,可能是以后为了对账之类使用的吧
本来对接api也没啥好说的,但是由于苹果官方是英文的,考虑到大部分人可能还是懒得找英文文档,所以进行了整理归档(我自己也是百度整理的...)
以下为参考的一些地址,2023-11-22记录,目前是有限的,以后不确定..请知悉
参考对接地址: 苹果(apple)支付退款通知、api_苹果支付api_Arhhhhhhh的博客-CSDN博客
官网地址:
官网对接地址
主动通知地址:Get Refund History | Apple Developer Documentation
被动通知地址:Handling refund notifications | Apple Developer Documentation
必知
这里主要介绍被动接收的(连接需要支持https),因为这种不是很好性能,主要是由于主动查询没有条件可以终止,所以选择用被动的,但是也会把相应工具类放上来,方便使用
对接步骤
配置通知URL
在 App Store Connect 进行配置,地址为:https://appstoreconnect.apple.com/login,由于我没有账号,所以是别人帮忙配的,如果不知道在哪配置可以参考这篇文章
苹果iOS内购三步曲:App内退款、历史订单查询、绑定用户防掉单!--- WWDC21 - 掘金
我这里使用的是V2版本的,V1是明文的,不太安全,所以我这里采用了V2版本
引入依赖
加解密需要引入工具包进行处理,以下是maven的坐标
<!-- jwt --> <dependency> <groupId>com.auth0</groupId> <artifactId>java-jwt</artifactId> <version>3.8.1</version> </dependency>
编写工具类
这一步最重要,这里直接放代码,到时你们可以直接复制使用
主动调用工具类
public class AppStoreReturnUtil {
//退款api正式环境
private static final String APP_STORE_RETURN = "https://api.storekit.itunes.apple.com/inApps/v2/refund/lookup/{originalTransactionId}";
//退款api沙箱环境
private static final String APP_STORE_SANDBOX_RETURN = "https://api.storekit-sandbox.itunes.apple.com/inApps/v2/refund/lookup/{originalTransactionId}";
/**
* 生成token
* @return
* @throws Exception
*/
private static String generateJwtToken() throws Exception {
Map<String, Object> headers = new HashMap<>();
// apple指定ES256算法
headers.put("alg", "ES256");
// 密钥ID
headers.put("kid", "你的kid");
// jwt格式
headers.put("typ", "JWT");
return JWT.create()
.withHeader(headers)
// issId:见apple connect后台右上角
.withIssuer("你的issId")
// 签名日期
.withIssuedAt(new Date())
// 失效日期:最晚一个小时,否则报错401
.withExpiresAt(DateUtils.addHours(new Date(), 1))
// 目标接收者,固定值
.withAudience("appstoreconnect-v1")
// 包名,bundleId
.withClaim("bid", "你的bundleId")
// 签名密钥,需要用到apple connect下载p8文件
.sign(Algorithm.ECDSA256(null, (ECPrivateKey) getPrivateKey("p8文件路径")));
}
/**
* 获取私钥
* @param fileName apple connect下载的p8文件路径
* @return
* @throws Exception
*/
private static PrivateKey getPrivateKey(String fileName) throws Exception {
String content = new String(Files.readAllBytes(Paths.get(fileName)), StandardCharsets.UTF_8);
try {
String privateKey = content.replace("-----BEGIN PRIVATE KEY-----", "")
.replace("-----END PRIVATE KEY-----", "")
.replaceAll("\\s+", "");
KeyFactory kf = KeyFactory.getInstance("EC");
return kf.generatePrivate(new PKCS8EncodedKeySpec(Base64.getDecoder().decode(privateKey)));
} catch (InvalidKeySpecException e) {
throw new RuntimeException("Invalid key format");
}
}
//任何http请求工具类都可以
private static RefundHistResponseVO getRefundHist() throws Exception {
String token = generateToken();
HttpHeaders header = new HttpHeaders();
header.set("Authorization", "Bearer "+ token);
RequestEntity<Map<String, String>> requestEntity = new RequestEntity<>(header, HttpMethod.GET, URI.create("https://api.storekit-sandbox.itunes.apple.com/inApps/v2/refund/lookup/2000000308586738"));
ResponseEntity<RefundHistResponseVO> exchange = restTemplate.exchange(requestEntity, RefundHistResponseVO.class);
return exchange.getBody();
}
这里有几个注意的点,如下
1. getRefundHist 需要基于http工具去发送请求,你可以自己找你们项目中的,或者自己写一个
2. kid、issId、bundleId、p8文件都是你自己账号的,如果你不知道可以问ios或者产品经理要
3. originalTransactionId就是你之前下单时苹果返回的,所以这个数据你们之前必须要有
到此为止,剩下的就是你自己写代码去请求就行了
被动接收
苹果返回数据格式
格式如下(真实的很长,这里是为了你能看懂才故意弄短)
{"signedPayload":"BaR1VnUkdWMlpXeHZjR1Z5SUZKbGJHRjBh"}
如果你是用Java SpringBoot开发的话,可以直接这样接收(也就是用@RequestBody即可)
@RestController
@RequestMapping("app/store")
@Slf4j
public class AppStoreMsgController {
@PostMapping("/notify")
public String appStoreMsgNotify(@RequestBody AppStoreNotifyPayLoadDto appStoreNotifyPayLoadDto) {
log.info("appStoreNotifyPayLoadDto{}", JsonUtils.Object2Json(appStoreNotifyPayLoadDto));
return MSG.SUCCESS(result);
}
}
@Data
public class AppStoreNotifyPayLoadDto implements Serializable {
private static final long serialVersionUID = 1L;
private String signedPayload;
}
被动接收工具类
@Slf4j
public class AppStoreReturnUtil {
/**
* 验证签名并返回解析数据
* @param jws
* @return
* @throws CertificateException
*/
public static AppStoreNotifyDto 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) {
log.error("解密苹果数据失败", e);
throw new AppException("解密苹果数据失败");
}
// 解析数据
String decodeString = new String(java.util.Base64.getDecoder().decode(decodedJWT.getPayload()));
return JSON.parseObject(decodeString, AppStoreNotifyDto.class);
}
/**
* 解析事务数据
* @param appStoreNotifyDto
* @return
*/
public static AppStoreDecodedPayloadDto parseTransactionInfo(AppStoreNotifyDto appStoreNotifyDto) {
DecodedJWT decode = JWT.decode(appStoreNotifyDto.getData().getSignedTransactionInfo());
String decodeString = new String(Base64.getDecoder().decode(decode.getPayload()));
return JSON.parseObject(decodeString, AppStoreDecodedPayloadDto.class);
}
/**
* 获取公钥
* @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();
}
}
这些都是固定写法,放上去就行了,没啥好说的,相关的java Bean也贴出来吧,放在下面
/**
* zxc_user
* time: 2023-11-17 15:34:47
* @description: 解密核心数据
*
* 参考地址: https://developer.apple.com/documentation/appstoreservernotifications/jwstransactiondecodedpayload?language=objc
*/
@Data
public class AppStoreDecodedPayloadDto implements Serializable {
private static final long serialVersionUID = 1L;
///退款订单必存的字段
/**
* 应用的bundle标识符
*/
private String bundleId;
/**
* 与price参数相关联的三个字母的ISO 4217货币代码。此值仅在存在price时才存在
*/
private String currency;
/**
* 服务器环境,沙箱或生产环境。 sandbox or production
*/
private String environment;
/**
* 包含优惠代码或促销优惠标识符的标识符。
*/
private String offerIdentifier;
/**
* 表示促销优惠类型的值
*/
private String offerType;
/**
* UNIX时间,以毫秒为单位,表示原始事务标识符的购买日期。
*/
private String originalPurchaseDate;
/**
* 原始购买的交易标识符。
*/
private String originalTransactionId;
/**
* 一个整数值,表示您在App Store Connect中配置的应用内购买或订阅报价的价格乘以1000,并在购买时系统记录。有关更多信息,请参阅价格。currency参数表示此价格的货币。
*/
private String price;
/**
* 应用内购买的产品标识符。
*/
private String productId;
/**
* 用户购买的消耗品数量。
*/
private String quantity;
/**
* UNIX时间,以毫秒为单位,App Store在过期后向用户帐户收取购买、恢复产品、订阅或续订费用。
*/
private String purchaseDate;
/**
* UNIX时间,以毫秒为单位,应用商店将交易退款或从家庭共享中撤销交易
*/
private String revocationDate;
/**
* App Store退还交易或从家庭共享中撤销交易的原因。
*/
private String revocationReason;
/**
* 事务的唯一标识符。
*/
private String transactionId;
/**
* 购买事务的原因,这表明它是客户购买还是系统启动的自动续订订阅的续订。
*/
private String transactionReason;
/**
* 应用内购买的类型。
*/
private String type;
///跟订阅相关/
/**
* 订阅到期或更新的UNIX时间,以毫秒为单位。 跟订阅相关
*/
private String expiresDate;
/**
* 一个布尔值,指示客户是否升级到另一个订阅。 跟订阅相关
*/
private boolean isUpgraded;
/**
* 订阅服务使用的付费模式,如免费试用、按需付费或预先付费 ,跟订阅相关
*/
private String offerDiscountType;
/**
* 订阅所属的订阅组的标识符。 跟订阅相关
*/
private String subscriptionGroupIdentifier;
///其他相关/
/**
* 您在购买时创建的UUID,它将交易与您自己服务上的客户关联起来。如果你的应用没有提供appAccountToken,这个字符串是空的。更多信息请参见appAccountToken(_:)。
*/
private String appAccountToken;
/**
* 一个字符串,描述该事务是由客户购买的,还是通过家庭共享提供给客户
*/
private String inAppOwnershipType;
/**
* UNIX时间,以毫秒为单位,应用商店签署JSON Web签名(JWS)数据的时间。
*/
private String signedDate;
/**
* 三个字母的代码,表示与购买的App Store店面相关的国家或地区。
*/
private String storefront;
/**
* 一个apple定义的值,唯一标识与购买相关的App Store店面。
*/
private String storefrontId;
/**
* 跨设备订阅购买事件的唯一标识符,包括订阅续订。
*/
private String webOrderLineItemId;
}
/**
* zxc_user
* time: 2023-11-17 15:22:08
* @description: 苹果V2版本回调通知返回数据
*
*
* 参考官方地址: https://developer.apple.com/documentation/appstoreservernotifications/responsebodyv2decodedpayload?language=objc
*/
@Data
public class AppStoreNotifyDto implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 回调类型, 最主要的,等于REFUND是眼用户退款事件
*
* 参考地址:https://developer.apple.com/documentation/appstoreservernotifications/notificationtype?language=objc
*/
private String notificationType;
/**
* 通知的唯一标识符。使用此值来标识重复的通知。
*/
private String notificationUUID;
/**
* 标识通知事件的其他信息。子类型字段仅用于特定的版本2通知。
*/
private String subtype;
/**
* 核心数据,退款信息之类的都在里面
*/
private AppStoreNotifyDataDto data;
private String summary;
/**
* 通知版本号,V2
*/
private String version;
/**
* UNIX时间,以毫秒为单位
*/
private String signedDate;
}
操作步骤:
就是把AppStoreNotifyPayLoadDto对象里面的signedPayload传到AppStoreReturnUtil工具类的verifyAndGet即可,便可以获得基础数据
如果获取退款数据再调用一下AppStoreReturnUtil的parseTransactionInfo即可,
记得如果只是处理退款的需要注意一下AppStoreNotifyDto对象的notificationType类型当等于REFUND才是退款,其他的业务请参考官方文档,notificationType | Apple Developer Documentation
到这里就行了,剩下的就是你要处理的业务逻辑,每个人的可能不太一样,这里就不赘述了
两者对比
主动查询需要消耗你的性能,而且你不知道终止条件是啥,因为用户是随时可以向苹果发起退款申请的,虽然网上有人说下单后90天就不能,但是是不是也不确定....
其次主动查询需要那些kid,k8文件等数据记录(这里可以理解为私钥),所以还是比较麻烦的
被动接收相对就非常方便了,只需要配置url,然后提供控制器接收数据即可,这里是不需要kid,k8文件那些的(这里我理解是公钥在jar包里面提供的)而且可以节省你服务器性能
所以我目前是选择了被动接收处理
设计模式使用
这里我是用了command进行设计的,目前还没整理文档,后续整理了可以放出来大家讨论讨论
结语
这里再次感谢开头放置的那些文章地址,说的挺详细了,因为我英文也不是很好,如果没有这些文章可能还挺麻烦
整个流程其实并不难,就是以前没接过苹果的,所以刚开始有点懵逼,不过真正搞懂了其实也就那样