feat(apple): 新增服务器通知续订与JWT解析能力
- 支持解析Apple签名JWT并提取交易信息 - 新增processRenewNotification处理续订通知 - 添加测试用JWT生成、解析及发送重试记录示例 - 移除废弃ApplePayUtil,统一走新验证逻辑
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -34,3 +34,4 @@ build/
|
||||
.vscode/
|
||||
/CLAUDE.md
|
||||
/AGENTS.md
|
||||
/src/test/
|
||||
|
||||
@@ -81,7 +81,6 @@ public class SaTokenConfigure implements WebMvcConfigurer {
|
||||
"/character/listByTagWithNotLogin",
|
||||
"/character/detailWithNotLogin",
|
||||
"/character/addUserCharacter",
|
||||
"/api/apple/validate-receipt",
|
||||
"/character/list",
|
||||
"/user/resetPassWord",
|
||||
"/chat/talk",
|
||||
@@ -100,7 +99,8 @@ public class SaTokenConfigure implements WebMvcConfigurer {
|
||||
"/products/subscription/list",
|
||||
"/purchase/handle",
|
||||
"/apple/notification",
|
||||
"/apple/receipt"
|
||||
"/apple/receipt",
|
||||
"/apple/validate-receipt"
|
||||
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,9 +6,11 @@ import com.yolo.keyborad.common.ErrorCode;
|
||||
import com.yolo.keyborad.common.ResultUtils;
|
||||
import com.yolo.keyborad.exception.BusinessException;
|
||||
import com.yolo.keyborad.model.dto.AppleReceiptValidationResult;
|
||||
import com.yolo.keyborad.model.dto.AppleServerNotification;
|
||||
import com.yolo.keyborad.service.ApplePurchaseService;
|
||||
import com.yolo.keyborad.service.AppleReceiptService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import com.yolo.keyborad.utils.JwtParser;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
@@ -16,6 +18,8 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import java.util.Map;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/apple")
|
||||
@@ -50,10 +54,79 @@ public class AppleReceiptController {
|
||||
}
|
||||
|
||||
@PostMapping("/notification")
|
||||
public BaseResponse<Boolean> receiveNotification(@RequestBody Map<String, Object> body, HttpServletRequest request) {
|
||||
log.info(request.getQueryString());
|
||||
log.info("Apple server notification: {}", body);
|
||||
public BaseResponse<Boolean> receiveNotification(@RequestBody Map<String, String> body, HttpServletRequest request) {
|
||||
String signedPayload = body.get("signedPayload");
|
||||
log.warn(body.toString());
|
||||
if (signedPayload == null || signedPayload.isBlank()) {
|
||||
throw new BusinessException(ErrorCode.PARAMS_ERROR, "signedPayload 不能为空");
|
||||
}
|
||||
AppleServerNotification notification = decodeSignedPayload(signedPayload);
|
||||
log.info("Apple server notification decoded: {}, query: {}", notification, request.getQueryString());
|
||||
if (notification != null && notification.getNotificationType() != null
|
||||
&& notification.getNotificationType().toUpperCase().contains("RENEW")) {
|
||||
applePurchaseService.processRenewNotification(notification);
|
||||
}
|
||||
return ResultUtils.success(Boolean.TRUE);
|
||||
}
|
||||
|
||||
|
||||
|
||||
private AppleServerNotification decodeSignedPayload(String signedPayload) {
|
||||
try {
|
||||
JsonNode root = JwtParser.parsePayload(signedPayload);
|
||||
AppleServerNotification notification = new AppleServerNotification();
|
||||
notification.setNotificationType(text(root, "notificationType", "notification_type"));
|
||||
JsonNode data = root.get("data");
|
||||
if (data != null && !data.isNull()) {
|
||||
notification.setEnvironment(text(data, "environment"));
|
||||
notification.setProductId(text(data, "productId", "product_id"));
|
||||
notification.setOriginalTransactionId(text(data, "originalTransactionId", "original_transaction_id"));
|
||||
notification.setSignedTransactionInfo(text(data, "signedTransactionInfo", "signed_transaction_info"));
|
||||
}
|
||||
if (notification.getSignedTransactionInfo() != null) {
|
||||
JsonNode txNode = JwtParser.parsePayload(notification.getSignedTransactionInfo());
|
||||
notification.setTransactionId(text(txNode, "transactionId", "transaction_id"));
|
||||
notification.setProductId(firstNonBlank(notification.getProductId(), text(txNode, "productId", "product_id")));
|
||||
notification.setOriginalTransactionId(firstNonBlank(notification.getOriginalTransactionId(), text(txNode, "originalTransactionId", "original_transaction_id")));
|
||||
notification.setPurchaseDate(epochToIso(txNode, "purchaseDate", "purchase_date"));
|
||||
notification.setExpiresDate(epochToIso(txNode, "expiresDate", "expires_date"));
|
||||
}
|
||||
return notification;
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new BusinessException(ErrorCode.PARAMS_ERROR, e.getMessage());
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to decode signedPayload", e);
|
||||
throw new BusinessException(ErrorCode.OPERATION_ERROR, "signedPayload 解析失败");
|
||||
}
|
||||
}
|
||||
|
||||
private String text(JsonNode node, String... keys) {
|
||||
for (String k : keys) {
|
||||
if (node != null && node.has(k) && !node.get(k).isNull()) {
|
||||
return node.get(k).asText();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String firstNonBlank(String a, String b) {
|
||||
if (a != null && !a.isBlank()) {
|
||||
return a;
|
||||
}
|
||||
return (b != null && !b.isBlank()) ? b : a;
|
||||
}
|
||||
|
||||
private String epochToIso(JsonNode node, String... keys) {
|
||||
String val = text(node, keys);
|
||||
if (val == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
long epochMillis = Long.parseLong(val);
|
||||
return java.time.Instant.ofEpochMilli(epochMillis).toString();
|
||||
} catch (NumberFormatException e) {
|
||||
return val;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.yolo.keyborad.model.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* Apple 服务器通知(精简字段)
|
||||
*/
|
||||
@Data
|
||||
public class AppleServerNotification {
|
||||
|
||||
@JsonProperty("notification_type")
|
||||
private String notificationType;
|
||||
|
||||
@JsonProperty("auto_renew_status")
|
||||
private String autoRenewStatus;
|
||||
|
||||
@JsonProperty("app_account_token")
|
||||
private String appAccountToken;
|
||||
|
||||
@JsonProperty("original_transaction_id")
|
||||
private String originalTransactionId;
|
||||
|
||||
@JsonProperty("product_id")
|
||||
private String productId;
|
||||
|
||||
@JsonProperty("purchase_date")
|
||||
private String purchaseDate;
|
||||
|
||||
@JsonProperty("expires_date")
|
||||
private String expiresDate;
|
||||
|
||||
@JsonProperty("environment")
|
||||
private String environment;
|
||||
|
||||
@JsonProperty("transaction_id")
|
||||
private String transactionId;
|
||||
|
||||
@JsonProperty("signed_transaction_info")
|
||||
private String signedTransactionInfo;
|
||||
}
|
||||
@@ -14,5 +14,11 @@ public interface ApplePurchaseService {
|
||||
* @param validationResult 苹果验签结果
|
||||
*/
|
||||
void processPurchase(Long userId, AppleReceiptValidationResult validationResult);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理苹果服务器续订通知(无收据,仅基于原始交易号和商品信息)
|
||||
*
|
||||
* @param notification 通知内容
|
||||
*/
|
||||
void processRenewNotification(com.yolo.keyborad.model.dto.AppleServerNotification notification);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.yolo.keyborad.service.impl;
|
||||
import com.yolo.keyborad.common.ErrorCode;
|
||||
import com.yolo.keyborad.exception.BusinessException;
|
||||
import com.yolo.keyborad.model.dto.AppleReceiptValidationResult;
|
||||
import com.yolo.keyborad.model.dto.AppleServerNotification;
|
||||
import com.yolo.keyborad.model.entity.KeyboardProductItems;
|
||||
import com.yolo.keyborad.model.entity.KeyboardUser;
|
||||
import com.yolo.keyborad.model.entity.KeyboardUserPurchaseRecords;
|
||||
@@ -23,6 +24,7 @@ import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
@@ -53,6 +55,7 @@ public class ApplePurchaseServiceImpl implements ApplePurchaseService {
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void processPurchase(Long userId, AppleReceiptValidationResult validationResult) {
|
||||
if (validationResult == null || !validationResult.isValid()) {
|
||||
log.error("Apple receipt validation failed.{}", validationResult.getReason());
|
||||
throw new BusinessException(ErrorCode.RECEIPT_INVALID);
|
||||
}
|
||||
String productId = resolveProductId(validationResult.getProductIds());
|
||||
@@ -85,6 +88,54 @@ public class ApplePurchaseServiceImpl implements ApplePurchaseService {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void processRenewNotification(AppleServerNotification notification) {
|
||||
if (notification == null || notification.getOriginalTransactionId() == null) {
|
||||
throw new BusinessException(ErrorCode.PARAMS_ERROR, "缺少原始交易ID");
|
||||
}
|
||||
// 找到用户:原始交易ID可能对应多条记录,取最新的一条
|
||||
List<KeyboardUserPurchaseRecords> records = purchaseRecordsService.lambdaQuery()
|
||||
.eq(KeyboardUserPurchaseRecords::getOriginalTransactionId, notification.getOriginalTransactionId())
|
||||
.orderByDesc(KeyboardUserPurchaseRecords::getId)
|
||||
.list();
|
||||
if (records == null || records.isEmpty()) {
|
||||
log.warn("Renewal notification without matching purchase record, originalTransactionId={}", notification.getOriginalTransactionId());
|
||||
return;
|
||||
}
|
||||
KeyboardUserPurchaseRecords record = records.get(0);
|
||||
|
||||
KeyboardProductItems product = productItemsService.getProductEntityByProductId(notification.getProductId());
|
||||
if (product == null || !"subscription".equalsIgnoreCase(product.getType())) {
|
||||
log.warn("Renewal notification ignored, product not subscription or not found. productId={}", notification.getProductId());
|
||||
return;
|
||||
}
|
||||
|
||||
// 写一条续订记录
|
||||
KeyboardUserPurchaseRecords renewRecord = new KeyboardUserPurchaseRecords();
|
||||
renewRecord.setUserId(record.getUserId());
|
||||
renewRecord.setProductId(product.getProductId());
|
||||
renewRecord.setPurchaseQuantity(product.getDurationValue());
|
||||
renewRecord.setPrice(product.getPrice());
|
||||
renewRecord.setCurrency(product.getCurrency());
|
||||
renewRecord.setPurchaseTime(toDate(parseInstant(notification.getPurchaseDate())));
|
||||
renewRecord.setPurchaseType(product.getType());
|
||||
renewRecord.setStatus("PAID");
|
||||
renewRecord.setPaymentMethod("APPLE");
|
||||
renewRecord.setTransactionId(notification.getTransactionId());
|
||||
renewRecord.setOriginalTransactionId(notification.getOriginalTransactionId());
|
||||
renewRecord.setProductIds(new String[]{product.getProductId()});
|
||||
Instant expiresInstant = parseInstant(notification.getExpiresDate());
|
||||
if (expiresInstant != null) {
|
||||
renewRecord.setExpiresDate(Date.from(expiresInstant));
|
||||
}
|
||||
renewRecord.setEnvironment(notification.getEnvironment());
|
||||
renewRecord.setPurchaseDate(toDate(parseInstant(notification.getPurchaseDate())));
|
||||
purchaseRecordsService.save(renewRecord);
|
||||
|
||||
extendVip(record.getUserId().longValue(), product, expiresInstant);
|
||||
}
|
||||
|
||||
private void handleSubscription(Long userId, KeyboardProductItems product, AppleReceiptValidationResult validationResult) {
|
||||
KeyboardUser user = userService.getById(userId);
|
||||
if (user == null) {
|
||||
@@ -158,6 +209,27 @@ public class ApplePurchaseServiceImpl implements ApplePurchaseService {
|
||||
return record;
|
||||
}
|
||||
|
||||
private void extendVip(Long userId, KeyboardProductItems product, Instant targetExpiry) {
|
||||
KeyboardUser user = userService.getById(userId);
|
||||
if (user == null) {
|
||||
throw new BusinessException(ErrorCode.USER_NOT_FOUND);
|
||||
}
|
||||
long durationDays = resolveDurationDays(product);
|
||||
Instant base = resolveBaseExpiry(user.getVipExpiry());
|
||||
Instant newExpiry = targetExpiry != null ? targetExpiry : base.plus(durationDays, ChronoUnit.DAYS);
|
||||
// 如果目标时间早于当前,则基于当前时间加时长
|
||||
if (newExpiry.isBefore(Instant.now())) {
|
||||
newExpiry = Instant.now().plus(durationDays, ChronoUnit.DAYS);
|
||||
}
|
||||
user.setIsVip(true);
|
||||
user.setVipExpiry(Date.from(newExpiry));
|
||||
boolean updated = userService.updateById(user);
|
||||
if (!updated) {
|
||||
throw new BusinessException(ErrorCode.OPERATION_ERROR, "更新用户VIP失败");
|
||||
}
|
||||
log.info("Extend VIP by notification, user {} to {}", userId, newExpiry);
|
||||
}
|
||||
|
||||
private String resolveProductId(List<String> productIds) {
|
||||
if (productIds == null || productIds.isEmpty()) {
|
||||
throw new BusinessException(ErrorCode.PARAMS_ERROR, "productId 缺失");
|
||||
@@ -213,4 +285,20 @@ public class ApplePurchaseServiceImpl implements ApplePurchaseService {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private Instant parseInstant(String iso) {
|
||||
if (iso == null || iso.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Instant.parse(iso);
|
||||
} catch (DateTimeParseException e) {
|
||||
log.warn("Failed to parse expiresDate: {}", iso);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private Date toDate(Instant instant) {
|
||||
return instant == null ? null : Date.from(instant);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
package com.yolo.keyborad.utils;
|
||||
|
||||
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;
|
||||
|
||||
|
||||
public class ApplePayUtil {
|
||||
|
||||
private static class TrustAnyTrustManager implements X509TrustManager {
|
||||
|
||||
@Override
|
||||
public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException {
|
||||
}
|
||||
|
||||
@Override
|
||||
public X509Certificate[] getAcceptedIssuers() {
|
||||
return new X509Certificate[]{};
|
||||
}
|
||||
}
|
||||
|
||||
private static class TrustAnyHostnameVerifier implements HostnameVerifier {
|
||||
@Override
|
||||
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";
|
||||
|
||||
/**
|
||||
* 苹果服务器验证
|
||||
*
|
||||
* @param receipt 账单
|
||||
* @return null 或返回结果 沙盒 https://sandbox.itunes.apple.com/verifyReceipt
|
||||
* @url 要验证的地址
|
||||
*/
|
||||
public static String buyAppVerify(String receipt, int type) throws Exception {
|
||||
//环境判断 线上/开发环境用不同的请求链接
|
||||
String url = "";
|
||||
if (type == 0) {
|
||||
url = url_sandbox; //沙盒测试
|
||||
} else {
|
||||
url = url_verify; //线上测试
|
||||
}
|
||||
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 = String.format(Locale.CHINA, "{\"receipt-data\":\"" + receipt + "\"}");
|
||||
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();
|
||||
}
|
||||
|
||||
}
|
||||
70
src/main/java/com/yolo/keyborad/utils/JwtParser.java
Normal file
70
src/main/java/com/yolo/keyborad/utils/JwtParser.java
Normal file
@@ -0,0 +1,70 @@
|
||||
package com.yolo.keyborad.utils;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.Jws;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.security.PublicKey;
|
||||
import java.security.cert.CertificateFactory;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.Base64;
|
||||
|
||||
public class JwtParser {
|
||||
private static final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
/**
|
||||
* 解析 JWT 并返回 JsonNode
|
||||
*/
|
||||
public static JsonNode parsePayload(String signedPayload) throws Exception {
|
||||
// 从 JWT header 中提取公钥
|
||||
PublicKey publicKey = extractPublicKeyFromJWT(signedPayload);
|
||||
// 解码 JWT(使用公钥验证)
|
||||
Jws<Claims> claimsJws = Jwts.parserBuilder()
|
||||
.setSigningKey(publicKey)
|
||||
.build()
|
||||
.parseClaimsJws(signedPayload);
|
||||
Claims claims = claimsJws.getBody();
|
||||
|
||||
// 将 Claims 转换为 JsonNode
|
||||
return objectMapper.valueToTree(claims);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 JWT 的 x5c header 中提取公钥
|
||||
*/
|
||||
private static PublicKey extractPublicKeyFromJWT(String jwt) throws Exception {
|
||||
// 解析 JWT header(不验证签名)
|
||||
String[] parts = jwt.split("\\.");
|
||||
if (parts.length < 2) {
|
||||
throw new IllegalArgumentException("Invalid JWT format");
|
||||
}
|
||||
|
||||
// 解码 header
|
||||
String headerJson = new String(Base64.getUrlDecoder().decode(parts[0])); // 使用 URL 安全的 Base64 解码
|
||||
JSONObject header = new JSONObject(headerJson);
|
||||
|
||||
// 获取 x5c 证书链(第一个证书包含公钥)
|
||||
JSONArray x5cArray = header.getJSONArray("x5c");
|
||||
if (x5cArray.length() == 0) {
|
||||
throw new IllegalArgumentException("No x5c certificates found in JWT header");
|
||||
}
|
||||
|
||||
// 获取第一个证书(Base64 编码,标准格式非URL安全格式)
|
||||
String certBase64 = x5cArray.getString(0);
|
||||
|
||||
// x5c 中的证书使用标准 Base64 编码(非 URL 安全编码)
|
||||
byte[] certBytes = Base64.getDecoder().decode(certBase64);
|
||||
|
||||
// 生成 X509 证书并提取公钥
|
||||
CertificateFactory certFactory = CertificateFactory.getInstance("X.509");
|
||||
X509Certificate cert = (X509Certificate) certFactory.generateCertificate(
|
||||
new ByteArrayInputStream(certBytes)
|
||||
);
|
||||
return cert.getPublicKey();
|
||||
}
|
||||
}
|
||||
88
src/test/java/com/yolo/keyborad/AppStoreJWT.java
Normal file
88
src/test/java/com/yolo/keyborad/AppStoreJWT.java
Normal file
@@ -0,0 +1,88 @@
|
||||
package com.yolo.keyborad;
|
||||
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import io.jsonwebtoken.SignatureAlgorithm;
|
||||
import java.io.FileInputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Paths;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.interfaces.RSAPrivateKey;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.security.PublicKey;
|
||||
import java.io.IOException;
|
||||
import java.security.spec.InvalidKeySpecException;
|
||||
import java.security.spec.PKCS8EncodedKeySpec;
|
||||
import java.util.Base64;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public class AppStoreJWT {
|
||||
|
||||
// Apple JWT 的配置参数
|
||||
private static final String ISSUER_ID = "178b442e-b7be-4526-bd13-ab293d019df0";
|
||||
private static final String KEY_ID = "Y7TF7BV74G";
|
||||
private static final String PRIVATE_KEY_PATH = "/Users/ziin/Desktop/keyborad-backend/src/main/resources/SubscriptionKey_Y7TF7BV74G.p8";
|
||||
|
||||
// 生成 JWT
|
||||
public static String generateJWT() throws Exception {
|
||||
// 读取私钥
|
||||
PrivateKey privateKey = loadPrivateKey(PRIVATE_KEY_PATH);
|
||||
|
||||
// 当前时间
|
||||
long now = System.currentTimeMillis();
|
||||
Date issuedAt = new Date(now);
|
||||
Date expiration = new Date(now + 30 * 60 * 1000); // 过期时间,通常是 20 分钟
|
||||
|
||||
Map<String, Object> headerMap = new HashMap<>();
|
||||
headerMap.put("type", "JWT");
|
||||
headerMap.put("kid", KEY_ID);
|
||||
// 使用私钥签名生成 JWT
|
||||
return Jwts.builder()
|
||||
.setIssuer(ISSUER_ID)
|
||||
.setAudience("appstoreconnect-v1")
|
||||
.setIssuedAt(issuedAt)
|
||||
.setExpiration(expiration)
|
||||
.claim("bid", "com.loveKey.nyx") // 使用 claim() 方法添加自定义字段
|
||||
.setHeader(headerMap)
|
||||
.signWith(privateKey, SignatureAlgorithm.ES256) // ES256 签名算法
|
||||
.compact();
|
||||
}
|
||||
|
||||
// 加载私钥
|
||||
static PrivateKey loadPrivateKey(String privateKeyPath) throws Exception {
|
||||
try {
|
||||
// 读取 p8 文件内容
|
||||
String privateKeyContent = new String(Files.readAllBytes(Paths.get(privateKeyPath)));
|
||||
|
||||
// 移除 PEM 格式的头部和尾部标记,以及换行符
|
||||
privateKeyContent = privateKeyContent
|
||||
.replace("-----BEGIN PRIVATE KEY-----", "")
|
||||
.replace("-----END PRIVATE KEY-----", "")
|
||||
.replaceAll("\\s", ""); // 移除所有空白字符(包括换行符)
|
||||
|
||||
// Base64 解码
|
||||
byte[] encoded = Base64.getDecoder().decode(privateKeyContent);
|
||||
|
||||
// 生成私钥
|
||||
KeyFactory keyFactory = KeyFactory.getInstance("EC");
|
||||
PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(encoded);
|
||||
return keyFactory.generatePrivate(keySpec);
|
||||
} catch (IOException | NoSuchAlgorithmException | InvalidKeySpecException e) {
|
||||
throw new Exception("加载私钥失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
// 调用生成的 JWT
|
||||
public static void main(String[] args) {
|
||||
try {
|
||||
String jwt = generateJWT();
|
||||
System.out.println("生成的 JWT: " + jwt);
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
88
src/test/java/com/yolo/keyborad/JwtParser.java
Normal file
88
src/test/java/com/yolo/keyborad/JwtParser.java
Normal file
File diff suppressed because one or more lines are too long
35
src/test/java/com/yolo/keyborad/SendAttemptsParser.java
Normal file
35
src/test/java/com/yolo/keyborad/SendAttemptsParser.java
Normal file
@@ -0,0 +1,35 @@
|
||||
package com.yolo.keyborad;
|
||||
|
||||
import cn.hutool.json.JSONArray;
|
||||
import cn.hutool.json.JSONException;
|
||||
import cn.hutool.json.JSONObject;
|
||||
|
||||
public class SendAttemptsParser {
|
||||
public static void main(String[] args) throws JSONException {
|
||||
String jsonResponse = "{\n" +
|
||||
" \"signedPayload\": \"eyJhbGciOiJFUzI1NiIsIng1YyI6WyJNSUlFTVRDQ0E3YWdBd0lCQWdJUVI4S0h6ZG41NTRaL1VvcmFkTng5dHpBS0JnZ3Foa2pPUFFRREF6QjFNVVF3UWdZRFZRUURERHRCY0hCc1pTQlhiM0pzWkhkcFpHVWdSR1YyWld4dmNHVnlJRkpsYkdGMGFXOXVjeUJEWlhKMGFXWnBZMkYwYVc5dUlFRjFkR2h2Y21sMGVURUxNQWtHQTFVRUN3d0NSell4RXpBUkJnTlZCQW9NQ2tGd2NHeGxJRWx1WXk0eEN6QUpCZ05WQkFZVEFsVlRNQjRYRFRJMU1Ea3hPVEU1TkRRMU1Wb1hEVEkzTVRBeE16RTNORGN5TTFvd2daSXhRREErQmdOVkJBTU1OMUJ5YjJRZ1JVTkRJRTFoWXlCQmNIQWdVM1J2Y21VZ1lXNWtJR2xVZFc1bGN5QlRkRzl5WlNCU1pXTmxhWEIwSUZOcFoyNXBibWN4TERBcUJnTlZCQXNNSTBGd2NHeGxJRmR2Y214a2QybGtaU0JFWlhabGJHOXdaWElnVW1Wc1lYUnBiMjV6TVJNd0VRWURWUVFLREFwQmNIQnNaU0JKYm1NdU1Rc3dDUVlEVlFRR0V3SlZVekJaTUJNR0J5cUdTTTQ5QWdFR0NDcUdTTTQ5QXdFSEEwSUFCTm5WdmhjdjdpVCs3RXg1dEJNQmdyUXNwSHpJc1hSaTBZeGZlazdsdjh3RW1qL2JIaVd0TndKcWMyQm9IenNRaUVqUDdLRklJS2c0WTh5MC9ueW51QW1qZ2dJSU1JSUNCREFNQmdOVkhSTUJBZjhFQWpBQU1COEdBMVVkSXdRWU1CYUFGRDh2bENOUjAxREptaWc5N2JCODVjK2xrR0taTUhBR0NDc0dBUVVGQndFQkJHUXdZakF0QmdnckJnRUZCUWN3QW9ZaGFIUjBjRG92TDJObGNuUnpMbUZ3Y0d4bExtTnZiUzkzZDJSeVp6WXVaR1Z5TURFR0NDc0dBUVVGQnpBQmhpVm9kSFJ3T2k4dmIyTnpjQzVoY0hCc1pTNWpiMjB2YjJOemNEQXpMWGQzWkhKbk5qQXlNSUlCSGdZRFZSMGdCSUlCRlRDQ0FSRXdnZ0VOQmdvcWhraUc5Mk5rQlFZQk1JSCtNSUhEQmdnckJnRUZCUWNDQWpDQnRneUJzMUpsYkdsaGJtTmxJRzl1SUhSb2FYTWdZMlZ5ZEdsbWFXTmhkR1VnWW5rZ1lXNTVJSEJoY25SNUlHRnpjM1Z0WlhNZ1lXTmpaWEIwWVc1alpTQnZaaUIwYUdVZ2RHaGxiaUJoY0hCc2FXTmhZbXhsSUhOMFlXNWtZWEprSUhSbGNtMXpJR0Z1WkNCamIyNWthWFJwYjI1eklHOW1JSFZ6WlN3Z1kyVnlkR2xtYVdOaGRHVWdjRzlzYVdONUlHRnVaQ0JqWlhKMGFXWnBZMkYwYVc5dUlIQnlZV04wYVdObElITjBZWFJsYldWdWRITXVNRFlHQ0NzR0FRVUZCd0lCRmlwb2RIUndPaTh2ZDNkM0xtRndjR3hsTG1OdmJTOWpaWEowYVdacFkyRjBaV0YxZEdodmNtbDBlUzh3SFFZRFZSME9CQllFRklGaW9HNHdNTVZBMWt1OXpKbUdOUEFWbjNlcU1BNEdBMVVkRHdFQi93UUVBd0lIZ0RBUUJnb3Foa2lHOTJOa0Jnc0JCQUlGQURBS0JnZ3Foa2pPUFFRREF3TnBBREJtQWpFQStxWG5SRUM3aFhJV1ZMc0x4em5qUnBJelBmN1ZIejlWL0NUbTgrTEpsclFlcG5tY1B2R0xOY1g2WFBubGNnTEFBakVBNUlqTlpLZ2c1cFE3OWtuRjRJYlRYZEt2OHZ1dElETVhEbWpQVlQzZEd2RnRzR1J3WE95d1Iya1pDZFNyZmVvdCIsIk1JSURGakNDQXB5Z0F3SUJBZ0lVSXNHaFJ3cDBjMm52VTRZU3ljYWZQVGp6Yk5jd0NnWUlLb1pJemowRUF3TXdaekViTUJrR0ExVUVBd3dTUVhCd2JHVWdVbTl2ZENCRFFTQXRJRWN6TVNZd0pBWURWUVFMREIxQmNIQnNaU0JEWlhKMGFXWnBZMkYwYVc5dUlFRjFkR2h2Y21sMGVURVRNQkVHQTFVRUNnd0tRWEJ3YkdVZ1NXNWpMakVMTUFrR0ExVUVCaE1DVlZNd0hoY05NakV3TXpFM01qQXpOekV3V2hjTk16WXdNekU1TURBd01EQXdXakIxTVVRd1FnWURWUVFERER0QmNIQnNaU0JYYjNKc1pIZHBaR1VnUkdWMlpXeHZjR1Z5SUZKbGJHRjBhVzl1Y3lCRFpYSjBhV1pwWTJGMGFXOXVJRUYxZEdodmNtbDBlVEVMTUFrR0ExVUVDd3dDUnpZeEV6QVJCZ05WQkFvTUNrRndjR3hsSUVsdVl5NHhDekFKQmdOVkJBWVRBbFZUTUhZd0VBWUhLb1pJemowQ0FRWUZLNEVFQUNJRFlnQUVic1FLQzk0UHJsV21aWG5YZ3R4emRWSkw4VDBTR1luZ0RSR3BuZ24zTjZQVDhKTUViN0ZEaTRiQm1QaENuWjMvc3E2UEYvY0djS1hXc0w1dk90ZVJoeUo0NXgzQVNQN2NPQithYW85MGZjcHhTdi9FWkZibmlBYk5nWkdoSWhwSW80SDZNSUgzTUJJR0ExVWRFd0VCL3dRSU1BWUJBZjhDQVFBd0h3WURWUjBqQkJnd0ZvQVV1N0Rlb1ZnemlKcWtpcG5ldnIzcnI5ckxKS3N3UmdZSUt3WUJCUVVIQVFFRU9qQTRNRFlHQ0NzR0FRVUZCekFCaGlwb2RIUndPaTh2YjJOemNDNWhjSEJzWlM1amIyMHZiMk56Y0RBekxXRndjR3hsY205dmRHTmhaek13TndZRFZSMGZCREF3TGpBc29DcWdLSVltYUhSMGNEb3ZMMk55YkM1aGNIQnNaUzVqYjIwdllYQndiR1Z5YjI5MFkyRm5NeTVqY213d0hRWURWUjBPQkJZRUZEOHZsQ05SMDFESm1pZzk3YkI4NWMrbGtHS1pNQTRHQTFVZER3RUIvd1FFQXdJQkJqQVFCZ29xaGtpRzkyTmtCZ0lCQkFJRkFEQUtCZ2dxaGtqT1BRUURBd05vQURCbEFqQkFYaFNxNUl5S29nTUNQdHc0OTBCYUI2NzdDYUVHSlh1ZlFCL0VxWkdkNkNTamlDdE9udU1UYlhWWG14eGN4ZmtDTVFEVFNQeGFyWlh2TnJreFUzVGtVTUkzM3l6dkZWVlJUNHd4V0pDOTk0T3NkY1o0K1JHTnNZRHlSNWdtZHIwbkRHZz0iLCJNSUlDUXpDQ0FjbWdBd0lCQWdJSUxjWDhpTkxGUzVVd0NnWUlLb1pJemowRUF3TXdaekViTUJrR0ExVUVBd3dTUVhCd2JHVWdVbTl2ZENCRFFTQXRJRWN6TVNZd0pBWURWUVFMREIxQmNIQnNaU0JEWlhKMGFXWnBZMkYwYVc5dUlFRjFkR2h2Y21sMGVURVRNQkVHQTFVRUNnd0tRWEJ3YkdVZ1NXNWpMakVMTUFrR0ExVUVCaE1DVlZNd0hoY05NVFF3TkRNd01UZ3hPVEEyV2hjTk16a3dORE13TVRneE9UQTJXakJuTVJzd0dRWURWUVFEREJKQmNIQnNaU0JTYjI5MElFTkJJQzBnUnpNeEpqQWtCZ05WQkFzTUhVRndjR3hsSUVObGNuUnBabWxqWVhScGIyNGdRWFYwYUc5eWFYUjVNUk13RVFZRFZRUUtEQXBCY0hCc1pTQkpibU11TVFzd0NRWURWUVFHRXdKVlV6QjJNQkFHQnlxR1NNNDlBZ0VHQlN1QkJBQWlBMklBQkpqcEx6MUFjcVR0a3lKeWdSTWMzUkNWOGNXalRuSGNGQmJaRHVXbUJTcDNaSHRmVGpqVHV4eEV0WC8xSDdZeVlsM0o2WVJiVHpCUEVWb0EvVmhZREtYMUR5eE5CMGNUZGRxWGw1ZHZNVnp0SzUxN0lEdll1VlRaWHBta09sRUtNYU5DTUVBd0hRWURWUjBPQkJZRUZMdXczcUZZTTRpYXBJcVozcjY5NjYvYXl5U3JNQThHQTFVZEV3RUIvd1FGTUFNQkFmOHdEZ1lEVlIwUEFRSC9CQVFEQWdFR01Bb0dDQ3FHU000OUJBTURBMmdBTUdVQ01RQ0Q2Y0hFRmw0YVhUUVkyZTN2OUd3T0FFWkx1Tit5UmhIRkQvM21lb3locG12T3dnUFVuUFdUeG5TNGF0K3FJeFVDTUcxbWloREsxQTNVVDgyTlF6NjBpbU9sTTI3amJkb1h0MlFmeUZNbStZaGlkRGtMRjF2TFVhZ002QmdENTZLeUtBPT0iXX0.eyJub3RpZmljYXRpb25UeXBlIjoiVEVTVCIsIm5vdGlmaWNhdGlvblVVSUQiOiIxNjg5YjA5OS00MTMzLTQzYjctOGFiYi05YzdmMDNlZDkwN2YiLCJkYXRhIjp7ImJ1bmRsZUlkIjoiY29tLmxvdmVLZXkubnl4IiwiZW52aXJvbm1lbnQiOiJTYW5kYm94In0sInZlcnNpb24iOiIyLjAiLCJzaWduZWREYXRlIjoxNzY1NTM5Nzk3MjA2fQ.UhmtfQ5Bk2aqvGOKPdOBLQDpssZ7aT4SgnlR29talFerwtfXjBh3b1vqsc565a8U4g8NJNwOt_dkCqtMjUO-Vw\",\n" +
|
||||
" \"firstSendAttemptResult\": \"SUCCESS\",\n" +
|
||||
" \"sendAttempts\": [\n" +
|
||||
" {\n" +
|
||||
" \"attemptDate\": 1765539797242,\n" +
|
||||
" \"sendAttemptResult\": \"SUCCESS\"\n" +
|
||||
" }\n" +
|
||||
" ]\n" +
|
||||
"}"; // 将响应字符串传递进来
|
||||
|
||||
JSONObject jsonObject = new JSONObject(jsonResponse);
|
||||
|
||||
// 获取 sendAttempts 数组
|
||||
JSONArray sendAttempts = jsonObject.getJSONArray("sendAttempts");
|
||||
|
||||
// 解析每个 sendAttempt
|
||||
for (int i = 0; i < sendAttempts.size(); i++) {
|
||||
JSONObject attempt = sendAttempts.getJSONObject(i);
|
||||
long attemptDate = attempt.getLong("attemptDate"); // 发送尝试时间戳
|
||||
String sendAttemptResult = attempt.getStr("sendAttemptResult"); // 发送尝试结果
|
||||
|
||||
System.out.println("Attempt Date: " + attemptDate);
|
||||
System.out.println("Send Attempt Result: " + sendAttemptResult);
|
||||
}
|
||||
}
|
||||
}
|
||||
32
src/test/java/com/yolo/keyborad/test.java
Normal file
32
src/test/java/com/yolo/keyborad/test.java
Normal file
@@ -0,0 +1,32 @@
|
||||
package com.yolo.keyborad;
|
||||
|
||||
import com.apple.itunes.storekit.signature.JWSSignatureCreator;
|
||||
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.UUID;
|
||||
|
||||
/*
|
||||
* @author: ziin
|
||||
* @date: 2025/12/12 18:56
|
||||
*/
|
||||
public class test {
|
||||
public static void main(String[] args) throws Exception {
|
||||
try {
|
||||
String jwt = AppStoreJWT.generateJWT();
|
||||
|
||||
URL url = new URL("https://api.storekit.itunes.apple.com/inApps/v1/subscriptions"); // 你需要访问的 Apple API URL
|
||||
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
|
||||
connection.setRequestMethod("GET");
|
||||
connection.setRequestProperty("Authorization", "Bearer " + jwt); // 设置 JWT 为 Bearer Token
|
||||
|
||||
// 处理响应
|
||||
int responseCode = connection.getResponseCode();
|
||||
System.out.println("Response Code: " + responseCode);
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user