前言
????????一般而言,我們其實很少對接退款接口,因為退款基本都是商家自己決定后進行操作的,但是蘋果比較特殊,用戶可以直接向蘋果發起退款請求,蘋果覺得合理會退給用戶,但是目前公司業務還是需要對接這個接口,可能是以后為了對賬之類使用的吧
? ? ? ? 本來對接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");// 密鑰IDheaders.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);// 驗證 tokenAlgorithm 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進行設計的,目前還沒整理文檔,后續整理了可以放出來大家討論討論
結語
? ? ? ? 這里再次感謝開頭放置的那些文章地址,說的挺詳細了,因為我英文也不是很好,如果沒有這些文章可能還挺麻煩
? ? ? ? 整個流程其實并不難,就是以前沒接過蘋果的,所以剛開始有點懵逼,不過真正搞懂了其實也就那樣