diff --git a/pom.xml b/pom.xml index 99e9c31..d214bf7 100644 --- a/pom.xml +++ b/pom.xml @@ -97,6 +97,12 @@ grpc-netty-shaded 1.65.1 + + + com.apple.itunes.storekit + app-store-server-library + 3.6.0 + diff --git a/src/main/java/com/yolo/keyborad/MyApplication.java b/src/main/java/com/yolo/keyborad/MyApplication.java index 7137577..de23dc7 100644 --- a/src/main/java/com/yolo/keyborad/MyApplication.java +++ b/src/main/java/com/yolo/keyborad/MyApplication.java @@ -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); diff --git a/src/main/java/com/yolo/keyborad/config/AppleAppStoreConfig.java b/src/main/java/com/yolo/keyborad/config/AppleAppStoreConfig.java new file mode 100644 index 0000000..a7fe265 --- /dev/null +++ b/src/main/java/com/yolo/keyborad/config/AppleAppStoreConfig.java @@ -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 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(); + } +} \ No newline at end of file diff --git a/src/main/java/com/yolo/keyborad/config/AppleAppStoreProperties.java b/src/main/java/com/yolo/keyborad/config/AppleAppStoreProperties.java new file mode 100644 index 0000000..22f92f5 --- /dev/null +++ b/src/main/java/com/yolo/keyborad/config/AppleAppStoreProperties.java @@ -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 rootCertificates; + +} diff --git a/src/main/java/com/yolo/keyborad/controller/AppleReceiptController.java b/src/main/java/com/yolo/keyborad/controller/AppleReceiptController.java new file mode 100644 index 0000000..90e436a --- /dev/null +++ b/src/main/java/com/yolo/keyborad/controller/AppleReceiptController.java @@ -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 body) { + String receipt = body.get("receipt"); + return appleReceiptService.validateReceipt(receipt); + } +} diff --git a/src/main/java/com/yolo/keyborad/controller/DemoController.java b/src/main/java/com/yolo/keyborad/controller/DemoController.java index 3970f9d..909834a 100644 --- a/src/main/java/com/yolo/keyborad/controller/DemoController.java +++ b/src/main/java/com/yolo/keyborad/controller/DemoController.java @@ -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 testSaveEmbed( @RequestBody List userInput) throws Exception { - qdrantVectorService.upsertPoint(1L, userInput, null); + public BaseResponse testSaveEmbed(@RequestBody EmbedSaveReq embedSaveReq) { + qdrantVectorService.upsertPoint(embedSaveReq.getRecordItem().getId() + , embedSaveReq.getVector() + , JSONUtil.toJsonStr(embedSaveReq.getRecordItem())); return ResultUtils.success(true); } } diff --git a/src/main/java/com/yolo/keyborad/model/dto/AppleReceiptValidationResult.java b/src/main/java/com/yolo/keyborad/model/dto/AppleReceiptValidationResult.java new file mode 100644 index 0000000..1d20e99 --- /dev/null +++ b/src/main/java/com/yolo/keyborad/model/dto/AppleReceiptValidationResult.java @@ -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 productIds; + + private Instant purchaseDate; + private Instant expiresDate; + + private Environment environment; + +} diff --git a/src/main/java/com/yolo/keyborad/model/dto/EmbedSaveReq.java b/src/main/java/com/yolo/keyborad/model/dto/EmbedSaveReq.java new file mode 100644 index 0000000..4cadcd5 --- /dev/null +++ b/src/main/java/com/yolo/keyborad/model/dto/EmbedSaveReq.java @@ -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 vector; + + private RecordItem recordItem; +} diff --git a/src/main/java/com/yolo/keyborad/model/entity/RecordItem.java b/src/main/java/com/yolo/keyborad/model/entity/RecordItem.java new file mode 100644 index 0000000..945f30a --- /dev/null +++ b/src/main/java/com/yolo/keyborad/model/entity/RecordItem.java @@ -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; +} diff --git a/src/main/java/com/yolo/keyborad/service/AppleReceiptService.java b/src/main/java/com/yolo/keyborad/service/AppleReceiptService.java new file mode 100644 index 0000000..24c30c5 --- /dev/null +++ b/src/main/java/com/yolo/keyborad/service/AppleReceiptService.java @@ -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); +} diff --git a/src/main/java/com/yolo/keyborad/service/impl/AppleReceiptServiceImpl.java b/src/main/java/com/yolo/keyborad/service/impl/AppleReceiptServiceImpl.java new file mode 100644 index 0000000..6754eee --- /dev/null +++ b/src/main/java/com/yolo/keyborad/service/impl/AppleReceiptServiceImpl.java @@ -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 应用内购买收据验证服务实现类 + *

+ * 负责验证苹果应用内购买的收据,包括一次性购买和订阅的验证逻辑 + * 使用 Apple App Store Server API 进行收据验证和交易信息获取 + *

+ */ +@Slf4j +@Service +public class AppleReceiptServiceImpl implements AppleReceiptService { + + /** + * App Store 服务器 API 客户端 + *

+ * 用于调用 Apple App Store Server API 获取交易信息 + *

+ */ + private final AppStoreServerAPIClient client; + + /** + * 签名数据验证器 + *

+ * 用于验证和解码 Apple 返回的 JWS 签名数据 + *

+ */ + private final SignedDataVerifier signedDataVerifier; + + /** + * 收据工具类 + *

+ * 用于解析应用收据内容,提取交易 ID 等信息 + *

+ */ + private final ReceiptUtility receiptUtility; + + /** + * 构造函数 + *

+ * 通过构造函数注入所需的依赖组件 + *

+ * @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 应用内购买收据 + *

+ * 执行完整的收据验证流程,包括解析收据、获取交易信息、验证签名和业务逻辑校验 + *

+ * @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"); + } + } + + /** + * 构建苹果收据验证结果对象 + *

+ * 根据交易有效性和交易负载构建详细的验证结果 + *

+ * @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; + } + + /** + * 创建无效的验证结果 + *

+ * 快速创建一个表示验证失败的结果对象 + *

+ * @param reason 失败原因 + * @return 标记为无效的验证结果对象 + */ + private AppleReceiptValidationResult invalid(String reason) { + AppleReceiptValidationResult r = new AppleReceiptValidationResult(); + r.setValid(false); + r.setReason(reason); + return r; + } + + /** + * 检查交易是否仍然有效 + *

+ * 根据交易负载中的信息判断交易是否处于有效状态 + * 1. 已撤销/退款的交易无效 + * 2. 有过期时间的订阅类交易检查是否已过期 + * 3. 一次性购买交易只要未撤销则认为有效 + *

+ * @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; + } +} \ No newline at end of file diff --git a/src/main/java/com/yolo/keyborad/service/impl/QdrantVectorService.java b/src/main/java/com/yolo/keyborad/service/impl/QdrantVectorService.java index 9c353a2..1a575eb 100644 --- a/src/main/java/com/yolo/keyborad/service/impl/QdrantVectorService.java +++ b/src/main/java/com/yolo/keyborad/service/impl/QdrantVectorService.java @@ -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 vector, Map payload) throws Exception { + public void upsertPoint(long id, List 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); +// } - - // 1. 确保 collection 存在(没有就创建一次即可) - qdrantClient.createCollectionAsync( - COLLECTION_NAME, - Collections.VectorParams.newBuilder() - .setSize(vector.size()) // 向量维度 - .setDistance(Collections.Distance.Cosine) // 相似度度量 - .build() - ).get(); // 简单起见直接 get(),生产建议在启动时提前创建好 - - - qdrantClient.upsertAsync( - COLLECTION_NAME, - List.of( - Points.PointStruct.newBuilder() - .setId(id(id)) - .setVectors(vectors(vector)) - .putAllPayload(Map.of("payload",value("testInfo"))) - .build() - ) - ).get(); + try { + qdrantClient.upsertAsync( + COLLECTION_NAME, + List.of( + Points.PointStruct.newBuilder() + .setId(id(id)) + .setVectors(vectors(vector)) + .putAllPayload(Map.of("payload",value(payload))) + .build() + ) + ).get(); + } catch (InterruptedException | ExecutionException e) { + log.error("upsert point 失败", e); + throw new BusinessException(ErrorCode.OPERATION_ERROR); + } } } diff --git a/src/main/resources/AppleRootCA-G2.cer b/src/main/resources/AppleRootCA-G2.cer new file mode 100644 index 0000000..739b814 Binary files /dev/null and b/src/main/resources/AppleRootCA-G2.cer differ diff --git a/src/main/resources/AppleRootCA-G3.cer b/src/main/resources/AppleRootCA-G3.cer new file mode 100644 index 0000000..228bfa3 Binary files /dev/null and b/src/main/resources/AppleRootCA-G3.cer differ diff --git a/src/main/resources/SubscriptionKey_Y7TF7BV74G.p8 b/src/main/resources/SubscriptionKey_Y7TF7BV74G.p8 new file mode 100644 index 0000000..e5780eb --- /dev/null +++ b/src/main/resources/SubscriptionKey_Y7TF7BV74G.p8 @@ -0,0 +1,6 @@ +-----BEGIN PRIVATE KEY----- +MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQg3YPU71xkC9jamBjy +HhI7dvQrwmK5MstIOEiwdPVSuvqgCgYIKoZIzj0DAQehRANCAATKNsGPJmrKtAda +byQwaaV6ODuiV1zX6JW9eNDS/1WJRqDEHCUG44kTaWyczCid4pQdzGumnbBoOxA8 +0SaFtsTR +-----END PRIVATE KEY----- \ No newline at end of file diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 41da143..fd4f44f 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -15,4 +15,23 @@ knife4j: default: api-rule: package api-rule-resources: - - com.yolo.keyborad.controller \ No newline at end of file + - 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" \ No newline at end of file