feat(apple): 新增服务器通知续订与JWT解析能力

- 支持解析Apple签名JWT并提取交易信息
- 新增processRenewNotification处理续订通知
- 添加测试用JWT生成、解析及发送重试记录示例
- 移除废弃ApplePayUtil,统一走新验证逻辑
This commit is contained in:
2025-12-15 14:56:38 +08:00
parent c1dd4faf0e
commit a70c1f4049
12 changed files with 528 additions and 91 deletions

View File

@@ -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"
};
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View 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();
}
}