苹果(apple)支付退款通知、api

た 入场券 2024-03-23 10:00 46阅读 0赞

苹果(apple)支付退款通知、api

背景:

用户在使用苹果支付购买商品后,可以直接像苹果申请退款,如果申请成功将导致商户直接构成损失。甚至某网络平台有这种专门薅羊毛的店铺,低价出售虚拟商品,再申请退款。所以有必要对用户发起的退款订单做及时响应,比如扣除对应的虚拟商品或像apple官方提供凭证使其退款不成功。

方案选型:

  1. 主动查询退款订单,文档地址
  2. 被动接收服务器通知,文档地址
主动请求退款api:
  1. 生成jwt身份标识,文档地址

    • 添加依赖项

      1. <!-- jwt -->
      2. <dependency>
      3. <groupId>com.auth0</groupId>
      4. <artifactId>java-jwt</artifactId>
      5. <version>3.8.1</version>
      6. </dependency>
    • 示例代码

      1. private String generateJwtToken() throws Exception {
      2. Map<String, Object> headers = new HashMap<>();
      3. // apple指定ES256算法
      4. headers.put("alg", "ES256");
      5. // 密钥ID
      6. headers.put("kid", "***********");
      7. // jwt格式
      8. headers.put("typ", "JWT");
      9. return JWT.create()
      10. .withHeader(headers)
      11. // issId:见apple connect后台右上角
      12. .withIssuer("*******-4f2e-4296-b2c7-**********")
      13. // 签名日期
      14. .withIssuedAt(new Date())
      15. // 失效日期:最晚一个小时,否则报错401
      16. .withExpiresAt(DateUtils.addHours(new Date(), 1))
      17. // 目标接收者,固定值
      18. .withAudience("appstoreconnect-v1")
      19. // 包名,bundleId
      20. .withClaim("bid", "com.********")
      21. // 签名密钥,需要用到apple connect下载p8文件
      22. .sign(Algorithm.ECDSA256(null, (ECPrivateKey) getPrivateKey("/payment/apple/AuthKey_****.p8")));
      23. }
      24. /**
      25. * 获取私钥
      26. * @param filename apple connect下载的p8文件路径
      27. * @return
      28. * @throws Exception
      29. */
      30. private PrivateKey getPrivateKey(String filename) throws Exception {
      31. String content = new String(Files.readAllBytes(Paths.get(filename)), StandardCharsets.UTF_8);
      32. try {
      33. String privateKey = content.replace("-----BEGIN PRIVATE KEY-----", "")
      34. .replace("-----END PRIVATE KEY-----", "")
      35. .replaceAll("\\s+", "");
      36. KeyFactory kf = KeyFactory.getInstance("EC");
      37. return kf.generatePrivate(new PKCS8EncodedKeySpec(Base64.getDecoder().decode(privateKey)));
      38. } catch (InvalidKeySpecException e) {
      39. throw new RuntimeException("Invalid key format");
      40. }
      41. }
  2. 请求、解析数据:

    • API地址:生产环境:GET https://api.storekit.itunes.apple.com/inApps/v2/refund/lookup/\{originalTransactionId\};沙盒环境:**GET** https://api.storekit-sandbox.itunes.apple.com/inApps/v2/refund/lookup/\{originalTransactionId\}
    • 示例代码和返回值:

      1. private RefundHistResponseVO getRefundHist() throws Exception {
      2. String token = generateToken();
      3. HttpHeaders header = new HttpHeaders();
      4. header.set("Authorization", "Bearer "+ token);
      5. RequestEntity<Map<String, String>> requestEntity = new RequestEntity<>(header, HttpMethod.GET, URI.create("https://api.storekit-sandbox.itunes.apple.com/inApps/v2/refund/lookup/2000000308586738"));
      6. ResponseEntity<RefundHistResponseVO> exchange = restTemplate.exchange(requestEntity, RefundHistResponseVO.class);
      7. return exchange.getBody();
      8. }
      9. @Data
      10. public static class RefundHistResponseVO{
      11. /**
      12. * 同一个用户的下的退款订单列表,jws格式
      13. */
      14. private List<String> signedTransactions;
      15. /**
      16. * 分页token
      17. * signedTransactions最大数量是20条,超过20条需要下一次请求带上分页token
      18. */
      19. private String revision;
      20. /**
      21. * 是否有下一页
      22. */
      23. private Boolean hasMore;
      24. }
      25. {
      26. "signedTransactions": [
      27. "eyJhbGciOiJFUzI1NiIsIng1YyI6WyJNSUlFTURDQ0E3YWdBd0lCQWdJUWFQb1BsZHZwU29FSDBsQnJqRFB2OWpBS0JnZ3Foa2pPUFFRREF6QjFNVVF3UWdZRFZRUURERHRCY0hCc1pTQlhiM0pzWkhkcFpHVWdSR1YyWld4dmNHVnlJRkpsYkdGMGFXOXVjeUJEWlhKMGFXWnBZMkYwYVc5dUlFRjFkR2h2Y21sMGVURUxNQWtHQTFVRUN3d0NSell4RXpBUkJnTlZCQW9NQ2tGd2NHeGxJRWx1WXk0eEN6QUpCZ05WQkFZVEFsVlRNQjRYRFRJeE1EZ3lOVEF5TlRBek5Gb1hEVEl6TURreU5EQXlOVEF6TTFvd2daSXhRREErQmdOVkJBTU1OMUJ5YjJRZ1JVTkRJRTFoWXlCQmNIQWdVM1J2Y21VZ1lXNWtJR2xVZFc1bGN5QlRkRzl5WlNCU1pXTmxhWEIwSUZOcFoyNXBibWN4TERBcUJnTlZCQXNNST...",
      28. "eyJhbGciOiJFUzI1NiIsIng1YyI6WyJNSUlFTURDQ0E3YWdBd0lCQWdJUWFQb1BsZHZwU29FSDBsQnJqRFB2OWpBS0JnZ3Foa2pPUFFRREF6QjFNVVF3UWdZRFZRUURERHRCY0hCc1pTQlhiM0pzWkhkcFpHVWdSR1YyWld4dmNHVnlJRkpsYkdGMGFXOXVjeUJEWlhKMGFXWnBZMkYwYVc5dUlFRjFkR2h2Y21sMGVURUxNQWtHQTFVRUN3d0NSell4RXpBUkJnTlZCQW9NQ2tGd2NHeGxJRWx1WXk0eEN6QUpCZ05WQkFZVEFsVlRNQjRYRFRJeE1EZ3lOVEF5TlRBek5Gb1hEVEl6TURreU5EQXlOVEF6TTFvd2daSXhRREErQmdOVkJBTU1OMUJ5YjJRZ1JVTkRJRTFoWXlCQmNIQWdVM1J2Y21VZ1lXNWtJR2xVZFc1bGN5QlRkRzl5WlNCU1pXTmxhWEIwSUZOcFoyNXBibWN4TERBcUJnTlZCQXNNSTBGd2NHeGxJRmR2Y214a2QybGtaU0JFWlhabGJHOXdaWElnVW1Wc1lYUnBiMjV6TVJNd0VRWURWUVFLREFwQmNIQnNaU0JKYm1NdU...",
      29. "eyJhbGciOiJFUzI1NiIsIng1YyI6WyJNSUlFTURDQ0E3YWdBd0lCQWdJUWFQb1BsZHZwU29FSDBsQnJqRFB2OWpBS0JnZ3Foa2pPUFFRREF6QjFNVVF3UWdZRFZRUURERHRCY0hCc1pTQlhiM0pzWkhkcFpHVWdSR1YyWld4dmNHVnlJRkpsYkdGMGFXOXVjeUJEWlhKMGFXWnBZMkYwYVc5dUlFRjFkR2h2Y21sMGVURUxNQWtHQTFVRUN3d0NSell4RXpBUkJnTlZCQW9NQ2tGd2NHeGxJRWx1WXk0eEN6QUpCZ05WQkFZVEFsVlRNQjRYRFRJeE1EZ3lOVEF5TlRBek5Gb1hEVEl6TURreU5EQXlOVEF6TTFvd2daSXhRREErQmdOVkJBTU1OMUJ5YjJRZ1JVTkRJRTFoWXlCQmNIQWdVM1J2Y21VZ1lXNWtJR2xVZFc1bGN5QlRkRzl5WlNCU1pXTmxhWEIwSUZOcFoyNXBibWN4TERBcUJnTlZCQXNNSTBGd2NHeGxJRm..."
      30. ],
      31. "revision": "1680777292000_2000000308641111",
      32. "hasMore": false
      33. }
    • 附上jws格式解析代码和解析后的参数示例:

      1. public void handleRefundOrder() throws Exception {
      2. RefundHistResponseVO refundHist = getRefundHist();
      3. for (String signedTransaction : refundHist.getSignedTransactions()) {
      4. DecodedJWT decode = JWT.decode(signedTransaction);
      5. TransactionResponseVO responseVO = JSONObject.parseObject(new String(Base64.getDecoder().decode(decode.getPayload())), TransactionResponseVO.class);
      6. // do something
      7. }
      8. }
      9. @Data
      10. public static class TransactionResponseVO{
      11. private String transactionId; // 交易订单号
      12. private String originalTransactionId; // 原始交易订单号
      13. private String bundleId; // 包名
      14. private String productId; // 商品编号
      15. private Date purchaseDate; // 购买日期
      16. private Date originalPurchaseDate; // 原始订单购买日期
      17. private Integer quantity; // 商品数量
      18. /**
      19. * <a href="https://developer.apple.com/documentation/appstoreserverapi/type/">消费类型</a>
      20. * Consumable 消耗品
      21. */
      22. private String type;
      23. /**
      24. * <a href="https://developer.apple.com/documentation/appstoreserverapi/type/">所有类型</a>
      25. * FAMILY_SHARED 交易属于受益于服务的家庭成员。
      26. * PURCHASED 交易属于买方。
      27. */
      28. private String inAppOwnershipType;
      29. private Date signedDate; // 签名日期
      30. private Integer revocationReason; // 退款方 1: apple ;0:客户
      31. private Date revocationDate; // 退款时间
      32. private String environment; //环境
      33. }
      34. {
      35. "transactionId": "2000000308611111",
      36. "originalTransactionId": "2000000308611111",
      37. "bundleId": "com.******",
      38. "productId": "a10001",
      39. "purchaseDate": 1680773327000,
      40. "originalPurchaseDate": 1680773327000,
      41. "quantity": 1,
      42. "type": "Consumable",
      43. "inAppOwnershipType": "PURCHASED",
      44. "signedDate": 1681198157817,
      45. "revocationReason": 0,
      46. "revocationDate": 1680777292000,
      47. "environment": "Sandbox"
      48. }
    • 这里使用的是v2版本的api接口,有几个注意点:

      1. 返回每页大小是20,下一页请求需带上revision参数,v1版本接口是50条且明文显示,官方不建议使用,文档地址
      2. 此接口只能查询已成功的退款订单(在生产环境,用户发起退款会有12小时的审核时间,开发者可以提供凭证证明商品发放成功),不能查询已发起但未通过的退款申请。
      3. original_transaction_id是必传参数,可以是任意一笔交易id,重点是交易id所关联的用户:apple ID,也就是说同一个appleId产生的订单在这个接口返回的结果是一样的。(吐槽一下苹果的api。。这个接口不能查询app范围内的所有退款订单,因为订单id是必传字段,但返回值跟这笔订单又没有强关联,而是关联用户)
      4. 如果需要做邮件实时通知、用户发起退款申请后自动响应。建议使用另外一种方法:接收服务器通知
    接收服务器通知:
    1. 配置服务器通知url:文档地址,步骤挺简单的,打开应用配置,设置沙盒和生产环境的服务器接口地址即可。注意这里也选择V2版本通知,不建议使用V1版本。
    2. 通知类型:文档地址,这里主要关注几个消耗品商品购买的类型(其他类型包含订阅类型购买的变更通知有需要的可以自行对接):

      • CONSUMPTION_REQUEST:用户发起退款
      • REFUND:用户退款成功
      • TEST:通过api发起的测试通知,测试服务器的通知url是否配置成功
    3. 官方提供的沙盒环境测试退款方法:文档地址,需要在本地xcode跑StoreKit Test,挺麻烦的需要ios开发人员支持。这里提供一个免费的webhook网址,可以用于本地测试接收通知:https://webhook.site/
    4. 用户发起退款申请后,服务器会收到一个notificationType = CONSUMPTION_REQUEST 的通知,代表用户发起退款,开发者可以在接收通知的逻辑中调用发送消费信息的api,以证明用户退款无效:文档地址
    5. 苹果审核退款申请通过后,服务器会收到一个notificationType = REFUND 的通知,代表退款成功,开发者可以在处理扣除虚拟商品等操作。下面是退款通知的数据示例及验证签名代码:

      • 请求报文:

        1. {
        2. "signedPayload":"eyJhbGciOiJFUzI1NiIsIng1YyI6WyJNSUlFTURDQ0E3YWdBd0lCQWdJUWFQb1BsZHZwU29FSDBsQnJqRFB2OWpBS0JnZ3Foa2pPUFFRREF6QjFNVVF3UWdZRFZRUURERHRCY0hCc1pTQlhiM0pzWkhkcFpHVWdSR1YyWld4dmNHVnlJRkpsYkdGMGFXOXVjeUJEWlhKMGFXWnBZMkYwYVc5dUlFRjFkR2h2Y21sMGVURUxNQWtHQTFVRUN3d0NSell4RXpBUkJnTlZCQW9NQ2tGd2NHeGxJRWx1WXk0eEN6QUpCZ05WQkFZVEFsVlRNQjRYRFRJeE1EZ3lOVEF5TlRBek5Gb1hEVEl6TURreU5EQXlOVEF6TTFvd2daSXhRREErQm..."}
      • 验签并解析数据

        1. /**
        2. * 验证签名
        3. * @param decodedJWT
        4. * @return
        5. * @throws CertificateException
        6. */
        7. private JSONObject verifyAndGet(String jws) throws CertificateException {
        8. DecodedJWT decodedJWT = JWT.decode(jws);
        9. // 拿到 header 中 x5c 数组中第一个
        10. String header = new String(java.util.Base64.getDecoder().decode(decodedJWT.getHeader()));
        11. String x5c = JSONObject.parseObject(header).getJSONArray("x5c").getString(0);
        12. // 获取公钥
        13. PublicKey publicKey = getPublicKeyByX5c(x5c);
        14. // 验证 token
        15. Algorithm algorithm = Algorithm.ECDSA256((ECPublicKey) publicKey, null);
        16. try {
        17. algorithm.verify(decodedJWT);
        18. } catch (SignatureVerificationException e) {
        19. return throw new RunTimeException();
        20. }
        21. // 解析数据
        22. JSONObject payload = JSONObject.parseObject(new String(java.util.Base64.getDecoder().decode(decodedJWT.getPayload()));
        23. }
  1. /**
  2. * 获取公钥
  3. * @param x5c
  4. * @return
  5. * @throws CertificateException
  6. */
  7. private PublicKey getPublicKeyByX5c(String x5c) throws CertificateException {
  8. byte[] x5c0Bytes = java.util.Base64.getDecoder().decode(x5c);
  9. CertificateFactory fact = CertificateFactory.getInstance("X.509");
  10. X509Certificate cer = (X509Certificate) fact.generateCertificate(new ByteArrayInputStream(x5c0Bytes));
  11. return cer.getPublicKey();
  12. }
  13. * 解析的json示例
  14. {
  15. "notificationType": "REFUND",
  16. "notificationUUID": "334d1548-****-4ea9-****-e104731870b9",
  17. "data": {
  18. "appAppleId": 1617026651,
  19. "bundleId": "com.*****",
  20. "bundleVersion": "1",
  21. "environment": "Sandbox",
  22. "signedTransactionInfo": "eyJhbGciOiJFUzI1NiIsIng1YyI6WyJNSUlFTURDQ0E3YWdBd0lCQWdJUWFQb1BsZHZwU29FSDBsQnJqRFB2OWpBS0JnZ3Foa2pPUFFRREF6QjFNVVF3UWdZRFZRUURERHRCY0hCc1pTQlhiM0pzWkhkcFpHVWdSR1YyWld4dmNHVnlJRkpsYkdGMGFXOXVjeUJEWlhKMGFXWnBZMkYwYVc5dUlFRjFkR2h2Y21sMGVURUxNQWtHQTFVRUN3d0NSell4RXpBUkJnTlZCQW9NQ2tGd2NHeGxJRWx1WXk0eEN6QUpCZ05WQkFZVEFsVlRNQjRYRFRJeE1EZ3lOVEF5TlRBek5Gb1hEVEl6TURreU5EQXlOVEF6TTFvd2daSXhRREErQmdOVkJBTU1OMUJ5YjJRZ1JVTkRJRTFoWXlCQmNIQWdVM1J2Y21VZ1lXNWtJR2xVZFc1bGN5QlRkRzl5WlNCU1pXTmxhWEIwSUZOcFoyNXBibWN4TERBcUJnTlZCQXNNSTBGd2NHeGxJRmR2Y214a2QybGtaU0JFWlhabGJHOXdaWElnVW1Wc1lYUnBi..."
  23. },
  24. "version": "2.0",
  25. "signedDate": 1680778196476
  26. }
  27. * 需要注意的是这里的signedTransactionInfo依然是一个jws格式,且字段与主动查询的结果一致,用上面的代码和vo类再解码一次
  28. DecodedJWT decode = JWT.decode(signedTransactionInfo);
  29. TransactionResponseVO responseVO = JSONObject.parseObject(new String(Base64.getDecoder().decode(decode.getPayload())), TransactionResponseVO.class);
  30. * 关于后置的业务处理就不过多赘述了,毕竟每个公司的业务不同,需要处理的逻辑也不同,没有参考价值。与产品沟通即可。

发表评论

表情:
评论列表 (有 0 条评论,46人围观)

还没有评论,来说两句吧...

相关阅读