feat(core): 新增苹果 App Store 订阅票据校验与向量存储结构升级

- 引入 Apple App Store Server Library,完成票据验证、续订、退款通知全套流程
- 新增 AppleReceiptController / AppleReceiptService 及相关配置类,支持沙箱与生产环境双端点
- 向量存储接口升级:EmbedSaveReq 封装向量与业务实体,QdrantVectorService 改为 JSON 字符串载荷并补全异常处理
- 补充 Apple 根证书与订阅密钥资源文件,pom 与 yml 增加对应依赖与配置
This commit is contained in:
2025-11-14 15:48:12 +08:00
parent 9170f93d67
commit f60ee2df3d
16 changed files with 514 additions and 26 deletions

View File

@@ -97,6 +97,12 @@
<artifactId>grpc-netty-shaded</artifactId>
<version>1.65.1</version>
</dependency>
<!-- 苹果 IAP sdk -->
<dependency>
<groupId>com.apple.itunes.storekit</groupId>
<artifactId>app-store-server-library</artifactId>
<version>3.6.0</version>
</dependency>
<!-- <dependency>-->
<!-- <groupId>com.alibaba.cloud.ai</groupId>-->

View File

@@ -1,14 +1,17 @@
package com.yolo.keyborad;
import com.yolo.keyborad.config.AppleAppStoreProperties;
import lombok.extern.slf4j.Slf4j;
import org.mybatis.spring.annotation.MapperScan;
import org.slf4j.LoggerFactory;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
@Slf4j
@SpringBootApplication
@MapperScan("com.yolo.keyborad.mapper")
@EnableConfigurationProperties(AppleAppStoreProperties.class)
public class MyApplication {
public static void main(String[] args) {
SpringApplication.run(MyApplication.class, args);

View File

@@ -0,0 +1,107 @@
package com.yolo.keyborad.config;
import com.apple.itunes.storekit.client.AppStoreServerAPIClient;
import com.apple.itunes.storekit.migration.ReceiptUtility;
import com.apple.itunes.storekit.model.Environment;
import com.apple.itunes.storekit.verification.SignedDataVerifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashSet;
import java.util.Set;
/**
* Apple App Store 配置类
* 用于配置与Apple App Store服务器API交互所需的各种组件
*/
@Configuration
public class AppleAppStoreConfig {
// Apple App Store 属性配置
private final AppleAppStoreProperties properties;
// 资源加载器,用于加载证书和密钥文件
private final ResourceLoader resourceLoader;
/**
* 构造函数
* @param properties Apple App Store属性配置对象
* @param resourceLoader Spring资源加载器
*/
public AppleAppStoreConfig(AppleAppStoreProperties properties,
ResourceLoader resourceLoader) {
this.properties = properties;
this.resourceLoader = resourceLoader;
}
/**
* 创建App Store服务器API客户端
* 用于与Apple App Store服务器进行API交互
*
* @return AppStoreServerAPIClient实例
* @throws Exception 当加载密钥文件或创建客户端失败时抛出
*/
@Bean
public AppStoreServerAPIClient appStoreServerAPIClient() throws Exception {
// 加载私钥文件
Resource keyResource = resourceLoader.getResource(properties.getPrivateKeyPath());
Path keyPath = keyResource.getFile().toPath();
String encodedKey = Files.readString(keyPath);
// 获取环境配置(沙盒或生产)
Environment env = Environment.valueOf(properties.getEnvironment());
// 创建并返回API客户端
return new AppStoreServerAPIClient(
encodedKey, // 私钥内容
properties.getKeyId(), // 密钥ID
properties.getIssuerId(), // 颁发者ID
properties.getBundleId(), // 应用包ID
env // 环境
);
}
/**
* 创建签名数据验证器
* 用于验证来自App Store的签名收据和通知
*
* @return SignedDataVerifier实例
* @throws Exception 当加载根证书或创建验证器失败时抛出
*/
@Bean
public SignedDataVerifier signedDataVerifier() throws Exception {
// 加载所有根证书
Set<InputStream> rootCAs = new HashSet<>();
for (String path : properties.getRootCertificates()) {
Resource resource = resourceLoader.getResource(path);
rootCAs.add(resource.getInputStream());
}
// 获取环境配置(沙盒或生产)
Environment env = Environment.valueOf(properties.getEnvironment());
// 创建并返回签名数据验证器
return new SignedDataVerifier(
rootCAs, // 根证书集合
properties.getBundleId(), // 应用包ID
properties.getAppAppleId(), // 应用Apple ID生产环境必须填写
env, // 环境
true // 启用在线检查
);
}
/**
* 创建收据工具类实例
* 用于处理和解析App Store收据数据
*
* @return ReceiptUtility实例
*/
@Bean
public ReceiptUtility receiptUtility() {
return new ReceiptUtility();
}
}

View File

@@ -0,0 +1,20 @@
package com.yolo.keyborad.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.util.List;
@ConfigurationProperties(prefix = "apple.appstore")
@Data
public class AppleAppStoreProperties {
private String issuerId;
private String keyId;
private String bundleId;
private Long appAppleId;
private String privateKeyPath;
private String environment; // SANDBOX / PRODUCTION
private List<String> rootCertificates;
}

View File

@@ -0,0 +1,27 @@
package com.yolo.keyborad.controller;
import com.yolo.keyborad.model.dto.AppleReceiptValidationResult;
import com.yolo.keyborad.service.AppleReceiptService;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
@RequestMapping("/api/apple")
public class AppleReceiptController {
private final AppleReceiptService appleReceiptService;
public AppleReceiptController(AppleReceiptService appleReceiptService) {
this.appleReceiptService = appleReceiptService;
}
@PostMapping("/validate-receipt")
public AppleReceiptValidationResult validateReceipt(@RequestBody Map<String, String> body) {
String receipt = body.get("receipt");
return appleReceiptService.validateReceipt(receipt);
}
}

View File

@@ -1,8 +1,11 @@
package com.yolo.keyborad.controller;
import cn.hutool.json.JSON;
import cn.hutool.json.JSONUtil;
import com.yolo.keyborad.common.BaseResponse;
import com.yolo.keyborad.common.ResultUtils;
import com.yolo.keyborad.model.dto.EmbedSaveReq;
import com.yolo.keyborad.model.dto.IosPayVerifyReq;
import com.yolo.keyborad.service.impl.QdrantVectorService;
import io.qdrant.client.QdrantClient;
@@ -82,8 +85,10 @@ public class DemoController {
@PostMapping("/testSaveEmbed")
@Operation(summary = "测试存储向量接口", description = "测试存储向量接口")
@Parameter(name = "userInput",required = true,description = "测试存储向量接口")
public BaseResponse<Boolean> testSaveEmbed( @RequestBody List<Float> userInput) throws Exception {
qdrantVectorService.upsertPoint(1L, userInput, null);
public BaseResponse<Boolean> testSaveEmbed(@RequestBody EmbedSaveReq embedSaveReq) {
qdrantVectorService.upsertPoint(embedSaveReq.getRecordItem().getId()
, embedSaveReq.getVector()
, JSONUtil.toJsonStr(embedSaveReq.getRecordItem()));
return ResultUtils.success(true);
}
}

View File

@@ -0,0 +1,30 @@
package com.yolo.keyborad.model.dto;
import com.apple.itunes.storekit.model.Environment;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
import java.util.List;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class AppleReceiptValidationResult {
private boolean valid;
private String reason;
private String transactionId;
private String originalTransactionId;
private List<String> productIds;
private Instant purchaseDate;
private Instant expiresDate;
private Environment environment;
}

View File

@@ -0,0 +1,20 @@
package com.yolo.keyborad.model.dto;
import com.yolo.keyborad.model.entity.RecordItem;
import lombok.Data;
import java.util.List;
/*
* @author: ziin
* @date: 2025/11/14 13:21
*/
@Data
public class EmbedSaveReq {
private List<Float> vector;
private RecordItem recordItem;
}

View File

@@ -0,0 +1,19 @@
package com.yolo.keyborad.model.entity;
import lombok.Data;
/*
* @author: ziin
* @date: 2025/11/14 13:22
*/
@Data
public class RecordItem {
private Long id;
private String document;
private String content;
private Integer frequency;
}

View File

@@ -0,0 +1,13 @@
package com.yolo.keyborad.service;
import com.yolo.keyborad.model.dto.AppleReceiptValidationResult;
public interface AppleReceiptService {
/**
* 验证 base64 app receipt 是否有效,并返回解析结果。
*
* @param appReceipt Base64 的 app receipt以 MI... 开头那串)
*/
AppleReceiptValidationResult validateReceipt(String appReceipt);
}

View File

@@ -0,0 +1,202 @@
package com.yolo.keyborad.service.impl;
import com.apple.itunes.storekit.client.APIException;
import com.apple.itunes.storekit.client.AppStoreServerAPIClient;
import com.apple.itunes.storekit.migration.ReceiptUtility;
import com.apple.itunes.storekit.model.Environment;
import com.apple.itunes.storekit.model.JWSTransactionDecodedPayload;
import com.apple.itunes.storekit.model.TransactionInfoResponse;
import com.apple.itunes.storekit.verification.SignedDataVerifier;
import com.apple.itunes.storekit.verification.VerificationException;
import com.yolo.keyborad.model.dto.AppleReceiptValidationResult;
import com.yolo.keyborad.service.AppleReceiptService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.List;
/**
* Apple 应用内购买收据验证服务实现类
* <p>
* 负责验证苹果应用内购买的收据,包括一次性购买和订阅的验证逻辑
* 使用 Apple App Store Server API 进行收据验证和交易信息获取
* </p>
*/
@Slf4j
@Service
public class AppleReceiptServiceImpl implements AppleReceiptService {
/**
* App Store 服务器 API 客户端
* <p>
* 用于调用 Apple App Store Server API 获取交易信息
* </p>
*/
private final AppStoreServerAPIClient client;
/**
* 签名数据验证器
* <p>
* 用于验证和解码 Apple 返回的 JWS 签名数据
* </p>
*/
private final SignedDataVerifier signedDataVerifier;
/**
* 收据工具类
* <p>
* 用于解析应用收据内容,提取交易 ID 等信息
* </p>
*/
private final ReceiptUtility receiptUtility;
/**
* 构造函数
* <p>
* 通过构造函数注入所需的依赖组件
* </p>
* @param client App Store 服务器 API 客户端
* @param signedDataVerifier 签名数据验证器
* @param receiptUtility 收据工具类
*/
public AppleReceiptServiceImpl(AppStoreServerAPIClient client,
SignedDataVerifier signedDataVerifier,
ReceiptUtility receiptUtility) {
this.client = client;
this.signedDataVerifier = signedDataVerifier;
this.receiptUtility = receiptUtility;
}
/**
* 验证 Apple 应用内购买收据
* <p>
* 执行完整的收据验证流程,包括解析收据、获取交易信息、验证签名和业务逻辑校验
* </p>
* @param appReceipt Base64 编码的应用内购买收据
* @return 验证结果对象,包含验证状态和交易信息
*/
@Override
public AppleReceiptValidationResult validateReceipt(String appReceipt) {
// 检查收据是否为空
if (appReceipt == null || appReceipt.isBlank()) {
return invalid("empty_receipt");
}
try {
// 1. 从收据里解析出 transactionId不做验证只是解析 ASN.1
String transactionId = receiptUtility.extractTransactionIdFromAppReceipt(appReceipt);
if (transactionId == null) {
return invalid("no_transaction_id_in_receipt");
}
// 2. 调用 App Store Server API 获取单笔交易信息
TransactionInfoResponse infoResponse = client.getTransactionInfo(transactionId);
String signedTransactionInfo = infoResponse.getSignedTransactionInfo();
if (signedTransactionInfo == null) {
return invalid("no_signed_transaction_info");
}
// 3. 使用 SignedDataVerifier 验证 JWS 并解码 payload
JWSTransactionDecodedPayload payload =
signedDataVerifier.verifyAndDecodeTransaction(signedTransactionInfo);
// 4. 执行业务校验:检查交易是否仍然有效
boolean stillActive = isTransactionActive(payload);
// 构建并返回验证结果
return getAppleReceiptValidationResult(stillActive, payload);
} catch (VerificationException e) {
// 验证异常处理
log.warn("Apple receipt verification failed", e);
return invalid("verification_exception:" + e.getStatus());
} catch (APIException e) {
// API 异常处理,可以根据错误码进一步处理(如环境切换)
// 4040010 表示订单不存在,可能是环境不匹配导致
long code = e.getApiError().errorCode();
log.warn("App Store API error, code={}", code, e);
return invalid("api_exception:" + code);
} catch (Exception e) {
// 其他未预期异常处理
log.error("Unexpected error when validating Apple receipt", e);
return invalid("unexpected_error");
}
}
/**
* 构建苹果收据验证结果对象
* <p>
* 根据交易有效性和交易负载构建详细的验证结果
* </p>
* @param stillActive 交易是否仍然有效
* @param payload 解码后的交易负载
* @return 包含详细信息的验证结果对象
*/
private static AppleReceiptValidationResult getAppleReceiptValidationResult(boolean stillActive, JWSTransactionDecodedPayload payload) {
AppleReceiptValidationResult result = new AppleReceiptValidationResult();
// 设置验证状态和原因
result.setValid(stillActive);
result.setReason(stillActive ? "ok" : "expired_or_revoked");
// 设置交易相关信息
result.setTransactionId(payload.getTransactionId());
result.setOriginalTransactionId(payload.getOriginalTransactionId());
result.setProductIds(List.of(payload.getProductId()));
// 设置时间信息
if (payload.getPurchaseDate() != null) {
result.setPurchaseDate(Instant.ofEpochMilli(payload.getPurchaseDate()));
}
if (payload.getExpiresDate() != null) {
result.setExpiresDate(Instant.ofEpochMilli(payload.getExpiresDate()));
}
// 设置环境信息
Environment env = payload.getEnvironment();
result.setEnvironment(env);
return result;
}
/**
* 创建无效的验证结果
* <p>
* 快速创建一个表示验证失败的结果对象
* </p>
* @param reason 失败原因
* @return 标记为无效的验证结果对象
*/
private AppleReceiptValidationResult invalid(String reason) {
AppleReceiptValidationResult r = new AppleReceiptValidationResult();
r.setValid(false);
r.setReason(reason);
return r;
}
/**
* 检查交易是否仍然有效
* <p>
* 根据交易负载中的信息判断交易是否处于有效状态
* 1. 已撤销/退款的交易无效
* 2. 有过期时间的订阅类交易检查是否已过期
* 3. 一次性购买交易只要未撤销则认为有效
* </p>
* @param payload 解码后的交易负载
* @return 交易是否有效
*/
private boolean isTransactionActive(JWSTransactionDecodedPayload payload) {
// 已撤销/退款的可以直接认为无效
if (payload.getRevocationDate() != null) {
return false;
}
// 有 expiresDate 的一般是订阅(自动续订等),过期就无效
if (payload.getExpiresDate() != null) {
long now = System.currentTimeMillis();
return now < payload.getExpiresDate();
}
// 没有 expiresDate 的一次性内购,只要能通过验签 + 没撤销,就认为是有效购买
return true;
}
}

View File

@@ -1,15 +1,18 @@
package com.yolo.keyborad.service.impl;
import com.yolo.keyborad.utils.ProtoUtils;
import com.yolo.keyborad.common.ErrorCode;
import com.yolo.keyborad.exception.BusinessException;
import io.qdrant.client.QdrantClient;
import io.qdrant.client.grpc.Collections;
import io.qdrant.client.grpc.JsonWithInt;
import io.qdrant.client.grpc.Points;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutionException;
import static io.qdrant.client.PointIdFactory.id;
import static io.qdrant.client.ValueFactory.value;
@@ -17,6 +20,7 @@ import static io.qdrant.client.VectorsFactory.vectors;
@Service
@Slf4j
public class QdrantVectorService {
@Resource
@@ -31,30 +35,37 @@ public class QdrantVectorService {
* @param vector 向量(和 collection 中定义的 size 一致)
* @param payload 额外信息例如原文、标题、userId 等
*/
public void upsertPoint(long id, List<Float> vector, Map<String, JsonWithInt.Value> payload) throws Exception {
// 1. 确保 collection 存在(没有就创建一次即可)
qdrantClient.createCollectionAsync(
COLLECTION_NAME,
Collections.VectorParams.newBuilder()
.setSize(vector.size()) // 向量维度
.setDistance(Collections.Distance.Cosine) // 相似度度量
.build()
).get(); // 简单起见直接 get(),生产建议在启动时提前创建好
public void upsertPoint(long id, List<Float> vector,String payload){
// // 1. 确保 collection 存在(没有就创建一次即可)
// try {
// qdrantClient.createCollectionAsync(
// COLLECTION_NAME,
// Collections.VectorParams.newBuilder()
// .setSize(vector.size()) // 向量维度
// .setDistance(Collections.Distance.Cosine) // 相似度度量
// .build()
// ).get(); // 简单起见直接 get(),生产建议在启动时提前创建好
// } catch (InterruptedException | ExecutionException e) {
// log.error("创建 collection 失败", e);
// throw new BusinessException(ErrorCode.OPERATION_ERROR);
// }
try {
qdrantClient.upsertAsync(
COLLECTION_NAME,
List.of(
Points.PointStruct.newBuilder()
.setId(id(id))
.setVectors(vectors(vector))
.putAllPayload(Map.of("payload",value("testInfo")))
.putAllPayload(Map.of("payload",value(payload)))
.build()
)
).get();
} catch (InterruptedException | ExecutionException e) {
log.error("upsert point 失败", e);
throw new BusinessException(ErrorCode.OPERATION_ERROR);
}
}
}

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,6 @@
-----BEGIN PRIVATE KEY-----
MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQg3YPU71xkC9jamBjy
HhI7dvQrwmK5MstIOEiwdPVSuvqgCgYIKoZIzj0DAQehRANCAATKNsGPJmrKtAda
byQwaaV6ODuiV1zX6JW9eNDS/1WJRqDEHCUG44kTaWyczCid4pQdzGumnbBoOxA8
0SaFtsTR
-----END PRIVATE KEY-----

View File

@@ -16,3 +16,22 @@ knife4j:
api-rule: package
api-rule-resources:
- com.yolo.keyborad.controller
apple:
appstore:
issuer-id: "178b442e-b7be-4526-bd13-ab293d019df0"
key-id: "Y7TF7BV74G"
bundle-id: "com.loveKey.nyx"
# app 在 App Store 的 Apple ID数值生产环境必填
app-apple-id: 1234567890
# p8 私钥文件路径(你可以放在 resources 下)
private-key-path: "classpath:SubscriptionKey_Y7TF7BV74G.p8"
# SANDBOX 或 PRODUCTION
environment: "SANDBOX"
# 根证书路径(从 Apple PKI 下载)
root-certificates:
- "classpath:AppleRootCA-G2.cer"
- "classpath:AppleRootCA-G3.cer"