Compare commits
18 Commits
9bd9a2646f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e657a22b10 | |||
| d654777a02 | |||
| c8f8311cae | |||
| 20f8d9c152 | |||
| 52727dfd7c | |||
| 9b4819900d | |||
| cdfeace2f1 | |||
| 3665596c1f | |||
| 06e7828b85 | |||
| b83957e0bc | |||
| da3ee94924 | |||
| e027918387 | |||
| 02dd37ffaf | |||
| dbc7ee365d | |||
| 5220a22cbd | |||
| 83cb65a31f | |||
| 0555f1d0df | |||
| 33b5de3e07 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -51,3 +51,5 @@ build/
|
|||||||
/src/main/resources/static/ws-test.html
|
/src/main/resources/static/ws-test.html
|
||||||
/.omc/
|
/.omc/
|
||||||
/logs/
|
/logs/
|
||||||
|
/src/main/resources/sql/google-play-iap.sql
|
||||||
|
/docs/google-play-iap-integration.md
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.yolo.keyborad;
|
|||||||
|
|
||||||
import com.yolo.keyborad.common.xfile.ByteFileWrapperAdapter;
|
import com.yolo.keyborad.common.xfile.ByteFileWrapperAdapter;
|
||||||
import com.yolo.keyborad.config.AppleAppStoreProperties;
|
import com.yolo.keyborad.config.AppleAppStoreProperties;
|
||||||
|
import com.yolo.keyborad.config.GooglePlayProperties;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.dromara.x.file.storage.core.tika.ContentTypeDetect;
|
import org.dromara.x.file.storage.core.tika.ContentTypeDetect;
|
||||||
import org.dromara.x.file.storage.spring.EnableFileStorage;
|
import org.dromara.x.file.storage.spring.EnableFileStorage;
|
||||||
@@ -14,7 +15,7 @@ import org.springframework.context.annotation.Bean;
|
|||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
@EnableConfigurationProperties(AppleAppStoreProperties.class)
|
@EnableConfigurationProperties({AppleAppStoreProperties.class, GooglePlayProperties.class})
|
||||||
@EnableFileStorage
|
@EnableFileStorage
|
||||||
public class MyApplication {
|
public class MyApplication {
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
|
|||||||
@@ -76,9 +76,16 @@ public enum ErrorCode {
|
|||||||
AUDIO_FILE_TOO_LARGE(40017, "音频文件过大"),
|
AUDIO_FILE_TOO_LARGE(40017, "音频文件过大"),
|
||||||
AUDIO_FORMAT_NOT_SUPPORTED(40018, "音频格式不支持"),
|
AUDIO_FORMAT_NOT_SUPPORTED(40018, "音频格式不支持"),
|
||||||
STT_SERVICE_ERROR(50031, "语音转文字服务异常"),
|
STT_SERVICE_ERROR(50031, "语音转文字服务异常"),
|
||||||
|
GOOGLE_PLAY_NOT_ENABLED(50032, "Google Play 服务未启用"),
|
||||||
|
GOOGLE_PLAY_PACKAGE_NAME_MISSING(50033, "Google Play packageName 未配置"),
|
||||||
|
GOOGLE_PLAY_PURCHASE_MISMATCH(50034, "Google Play 购买信息不匹配"),
|
||||||
|
GOOGLE_PLAY_WEBHOOK_UNAUTHORIZED(50035, "Google Play RTDN 来源校验失败"),
|
||||||
|
GOOGLE_PLAY_RTDN_PAYLOAD_INVALID(50036, "Google Play RTDN payload 非法"),
|
||||||
|
GOOGLE_PLAY_STATE_INVALID(50037, "Google Play 购买状态非法"),
|
||||||
REPORT_TYPE_INVALID(40020, "举报类型无效"),
|
REPORT_TYPE_INVALID(40020, "举报类型无效"),
|
||||||
REPORT_COMPANION_ID_EMPTY(40021, "被举报的AI角色ID不能为空"),
|
REPORT_COMPANION_ID_EMPTY(40021, "被举报的AI角色ID不能为空"),
|
||||||
REPORT_TYPE_EMPTY(40022, "举报类型不能为空"),
|
REPORT_TYPE_EMPTY(40022, "举报类型不能为空"),
|
||||||
|
ACCOUNT_RECENTLY_CANCELLED(50038, "账号注销未满7天,暂不允许注册"),
|
||||||
VERSION_NOT_FOUND(40022, "未找到可用的版本配置");
|
VERSION_NOT_FOUND(40022, "未找到可用的版本配置");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -104,4 +111,4 @@ public enum ErrorCode {
|
|||||||
public String getCodeAsString() {
|
public String getCodeAsString() {
|
||||||
return String.valueOf(code);
|
return String.valueOf(code);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ public class AppConfig {
|
|||||||
|
|
||||||
private LLmModel llmModel = new LLmModel();
|
private LLmModel llmModel = new LLmModel();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
public static class UserRegisterProperties {
|
public static class UserRegisterProperties {
|
||||||
|
|
||||||
@@ -64,6 +66,8 @@ public class AppConfig {
|
|||||||
|
|
||||||
//聊天消息最大长度
|
//聊天消息最大长度
|
||||||
private Integer maxMessageLength = 1000;
|
private Integer maxMessageLength = 1000;
|
||||||
|
|
||||||
|
private String companionSystemPrompt = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.yolo.keyborad.config;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class GooglePlayHttpConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public HttpClient googlePlayHttpClient() {
|
||||||
|
return HttpClient.newBuilder()
|
||||||
|
.connectTimeout(Duration.ofSeconds(10))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.yolo.keyborad.config;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@ConfigurationProperties(prefix = "google.play")
|
||||||
|
public class GooglePlayProperties {
|
||||||
|
|
||||||
|
private boolean enabled;
|
||||||
|
|
||||||
|
private String packageName;
|
||||||
|
|
||||||
|
private String serviceAccountKeyPath;
|
||||||
|
|
||||||
|
private String oauthTokenUri = "https://oauth2.googleapis.com/token";
|
||||||
|
|
||||||
|
private String androidPublisherScope = "https://www.googleapis.com/auth/androidpublisher";
|
||||||
|
|
||||||
|
private String pubsubTokenInfoUri = "https://oauth2.googleapis.com/tokeninfo";
|
||||||
|
|
||||||
|
private boolean validatePubsubJwt = true;
|
||||||
|
|
||||||
|
private boolean requireObfuscatedAccountId = false;
|
||||||
|
|
||||||
|
private Pubsub pubsub = new Pubsub();
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class Pubsub {
|
||||||
|
private String expectedTopic;
|
||||||
|
private String expectedSubscription;
|
||||||
|
private String audience;
|
||||||
|
private String serviceAccountEmail;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -86,8 +86,11 @@ public class SaTokenConfigure implements WebMvcConfigurer {
|
|||||||
"/character/listByTagWithNotLogin",
|
"/character/listByTagWithNotLogin",
|
||||||
"/ai-companion/report",
|
"/ai-companion/report",
|
||||||
"/apple/notification",
|
"/apple/notification",
|
||||||
|
"/google-play/rtdn",
|
||||||
"/appVersions/checkUpdate",
|
"/appVersions/checkUpdate",
|
||||||
"/character/detailWithNotLogin"
|
"/appVersions/checkUpdate",
|
||||||
|
"/character/detailWithNotLogin",
|
||||||
|
"/apple/validate-receipt"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@Bean
|
@Bean
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import com.yolo.keyborad.common.ResultUtils;
|
|||||||
import com.yolo.keyborad.exception.BusinessException;
|
import com.yolo.keyborad.exception.BusinessException;
|
||||||
import com.yolo.keyborad.model.dto.AppleReceiptValidationResult;
|
import com.yolo.keyborad.model.dto.AppleReceiptValidationResult;
|
||||||
import com.yolo.keyborad.service.ApplePurchaseService;
|
import com.yolo.keyborad.service.ApplePurchaseService;
|
||||||
|
import com.yolo.keyborad.service.KeyboardUserPurchaseRecordsService;
|
||||||
import com.yolo.keyborad.service.AppleReceiptService;
|
import com.yolo.keyborad.service.AppleReceiptService;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
@@ -23,11 +24,14 @@ public class AppleReceiptController {
|
|||||||
|
|
||||||
private final AppleReceiptService appleReceiptService;
|
private final AppleReceiptService appleReceiptService;
|
||||||
private final ApplePurchaseService applePurchaseService;
|
private final ApplePurchaseService applePurchaseService;
|
||||||
|
private final KeyboardUserPurchaseRecordsService purchaseRecordsService;
|
||||||
|
|
||||||
public AppleReceiptController(AppleReceiptService appleReceiptService,
|
public AppleReceiptController(AppleReceiptService appleReceiptService,
|
||||||
ApplePurchaseService applePurchaseService) {
|
ApplePurchaseService applePurchaseService,
|
||||||
|
KeyboardUserPurchaseRecordsService purchaseRecordsService) {
|
||||||
this.appleReceiptService = appleReceiptService;
|
this.appleReceiptService = appleReceiptService;
|
||||||
this.applePurchaseService = applePurchaseService;
|
this.applePurchaseService = applePurchaseService;
|
||||||
|
this.purchaseRecordsService = purchaseRecordsService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/receipt")
|
@PostMapping("/receipt")
|
||||||
@@ -85,4 +89,26 @@ public class AppleReceiptController {
|
|||||||
return ResultUtils.success(Boolean.TRUE);
|
return ResultUtils.success(Boolean.TRUE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查购买记录是否存在
|
||||||
|
* 根据 transactionId 和 originalTransactionId 查询购买记录
|
||||||
|
*
|
||||||
|
* @param body 请求体,包含 transactionId 和 originalTransactionId
|
||||||
|
* @return 存在返回 true,不存在返回 false
|
||||||
|
*/
|
||||||
|
@PostMapping("/check-purchase")
|
||||||
|
public BaseResponse<Boolean> checkPurchaseExists(@RequestBody Map<String, String> body) {
|
||||||
|
if (body == null) {
|
||||||
|
throw new BusinessException(ErrorCode.PARAMS_ERROR, "body 不能为空");
|
||||||
|
}
|
||||||
|
String transactionId = body.get("transactionId");
|
||||||
|
String originalTransactionId = body.get("originalTransactionId");
|
||||||
|
if ((transactionId == null || transactionId.isBlank())
|
||||||
|
&& (originalTransactionId == null || originalTransactionId.isBlank())) {
|
||||||
|
throw new BusinessException(ErrorCode.PARAMS_ERROR, "transactionId 和 originalTransactionId 不能同时为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean exists = purchaseRecordsService.checkPurchaseExists(transactionId, originalTransactionId);
|
||||||
|
return ResultUtils.success(exists);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.yolo.keyborad.controller;
|
||||||
|
|
||||||
|
import cn.dev33.satoken.stp.StpUtil;
|
||||||
|
import com.yolo.keyborad.common.BaseResponse;
|
||||||
|
import com.yolo.keyborad.common.ResultUtils;
|
||||||
|
import com.yolo.keyborad.model.dto.googleplay.GooglePlayPubSubPushRequest;
|
||||||
|
import com.yolo.keyborad.model.dto.googleplay.GooglePlayPurchaseVerifyReq;
|
||||||
|
import com.yolo.keyborad.model.vo.googleplay.GooglePlayPurchaseVerifyResp;
|
||||||
|
import com.yolo.keyborad.service.GooglePlayBillingService;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
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;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/google-play")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class GooglePlayController {
|
||||||
|
|
||||||
|
private final GooglePlayBillingService googlePlayBillingService;
|
||||||
|
|
||||||
|
@PostMapping("/purchases/verify")
|
||||||
|
public BaseResponse<GooglePlayPurchaseVerifyResp> verify(@RequestBody GooglePlayPurchaseVerifyReq req) {
|
||||||
|
Long userId = StpUtil.getLoginIdAsLong();
|
||||||
|
return ResultUtils.success(googlePlayBillingService.verifyPurchase(userId, req));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/rtdn")
|
||||||
|
public BaseResponse<Boolean> handleRtdn(HttpServletRequest request,
|
||||||
|
@RequestBody GooglePlayPubSubPushRequest pushRequest) {
|
||||||
|
googlePlayBillingService.handleRtdn(request, pushRequest);
|
||||||
|
return ResultUtils.success(Boolean.TRUE);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
|||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestHeader;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
@@ -31,14 +32,14 @@ public class ProductsController {
|
|||||||
private KeyboardProductItemsService productItemsService;
|
private KeyboardProductItemsService productItemsService;
|
||||||
|
|
||||||
@GetMapping("/detail")
|
@GetMapping("/detail")
|
||||||
@Operation(summary = "查询商品明细", description = "根据商品ID或productId查询商品详情")
|
@Operation(summary = "查询商品明细", description = "根据商品ID或productId查询商品详情,通过platform区分平台")
|
||||||
public BaseResponse<KeyboardProductItemRespVO> getProductDetail(
|
public BaseResponse<KeyboardProductItemRespVO> getProductDetail(
|
||||||
@RequestParam(value = "id", required = false) Long id,
|
@RequestParam(value = "id", required = false) Long id,
|
||||||
@RequestParam(value = "productId", required = false) String productId
|
@RequestParam(value = "productId", required = false) String productId) {
|
||||||
) {
|
|
||||||
if (id == null && (productId == null || productId.isBlank())) {
|
if (id == null && (productId == null || productId.isBlank())) {
|
||||||
throw new BusinessException(ErrorCode.PARAMS_ERROR, "id 或 productId 至少传一个");
|
throw new BusinessException(ErrorCode.PARAMS_ERROR, "id 或 productId 至少传一个");
|
||||||
}
|
}
|
||||||
|
// 判断平台:如果是android返回安卓商品,否则默认返回苹果商品
|
||||||
KeyboardProductItemRespVO result = (id != null)
|
KeyboardProductItemRespVO result = (id != null)
|
||||||
? productItemsService.getProductDetailById(id)
|
? productItemsService.getProductDetailById(id)
|
||||||
: productItemsService.getProductDetailByProductId(productId);
|
: productItemsService.getProductDetailByProductId(productId);
|
||||||
@@ -47,25 +48,29 @@ public class ProductsController {
|
|||||||
|
|
||||||
@GetMapping("/listByType")
|
@GetMapping("/listByType")
|
||||||
@Operation(summary = "按类型查询商品列表", description = "根据商品类型查询商品列表,type=all 返回全部")
|
@Operation(summary = "按类型查询商品列表", description = "根据商品类型查询商品列表,type=all 返回全部")
|
||||||
public BaseResponse<List<KeyboardProductItemRespVO>> listByType(@RequestParam("type") String type) {
|
public BaseResponse<List<KeyboardProductItemRespVO>> listByType(
|
||||||
|
@RequestParam("type") String type,
|
||||||
|
@RequestHeader(value = "platform", required = false) String platform) {
|
||||||
if (type == null || type.isBlank()) {
|
if (type == null || type.isBlank()) {
|
||||||
throw new BusinessException(ErrorCode.PARAMS_ERROR, "type 不能为空");
|
throw new BusinessException(ErrorCode.PARAMS_ERROR, "type 不能为空");
|
||||||
}
|
}
|
||||||
List<KeyboardProductItemRespVO> result = productItemsService.listProductsByType(type);
|
List<KeyboardProductItemRespVO> result = productItemsService.listProductsByType(type, platform);
|
||||||
return ResultUtils.success(result);
|
return ResultUtils.success(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/inApp/list")
|
@GetMapping("/inApp/list")
|
||||||
@Operation(summary = "查询内购商品列表", description = "查询 type=in-app-purchase 的商品列表")
|
@Operation(summary = "查询内购商品列表", description = "查询 type=in-app-purchase 的商品列表")
|
||||||
public BaseResponse<List<KeyboardProductItemRespVO>> listInAppPurchases() {
|
public BaseResponse<List<KeyboardProductItemRespVO>> listInAppPurchases(
|
||||||
List<KeyboardProductItemRespVO> result = productItemsService.listProductsByType("in-app-purchase");
|
@RequestHeader(value = "platform", required = false) String platform) {
|
||||||
|
List<KeyboardProductItemRespVO> result = productItemsService.listProductsByType("in-app-purchase", platform);
|
||||||
return ResultUtils.success(result);
|
return ResultUtils.success(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/subscription/list")
|
@GetMapping("/subscription/list")
|
||||||
@Operation(summary = "查询订阅商品列表", description = "查询 type=subscription 的商品列表")
|
@Operation(summary = "查询订阅商品列表", description = "查询 type=subscription 的商品列表")
|
||||||
public BaseResponse<List<KeyboardProductItemRespVO>> listSubscriptions() {
|
public BaseResponse<List<KeyboardProductItemRespVO>> listSubscriptions(
|
||||||
List<KeyboardProductItemRespVO> result = productItemsService.listProductsByType("subscription");
|
@RequestHeader(value = "platform", required = false) String platform) {
|
||||||
|
List<KeyboardProductItemRespVO> result = productItemsService.listProductsByType("subscription", platform);
|
||||||
return ResultUtils.success(result);
|
return ResultUtils.success(result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,307 @@
|
|||||||
|
package com.yolo.keyborad.googleplay;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.yolo.keyborad.config.GooglePlayProperties;
|
||||||
|
import com.yolo.keyborad.googleplay.model.GooglePlayPurchaseSnapshot;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.URLEncoder;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class GooglePlayApiClient {
|
||||||
|
|
||||||
|
private static final String API_BASE = "https://androidpublisher.googleapis.com/androidpublisher/v3/applications";
|
||||||
|
private static final int MAX_RETRY = 3;
|
||||||
|
private static final long RETRY_INTERVAL_MILLIS = 300L;
|
||||||
|
|
||||||
|
private final GooglePlayServiceAccountTokenProvider tokenProvider;
|
||||||
|
private final HttpClient googlePlayHttpClient;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final GooglePlayProperties properties;
|
||||||
|
|
||||||
|
public GooglePlayPurchaseSnapshot getSubscription(String packageName, String purchaseToken) {
|
||||||
|
String url = API_BASE + "/" + encode(packageName) + "/purchases/subscriptionsv2/tokens/" + encode(purchaseToken);
|
||||||
|
JsonNode jsonNode = executeJson("GET_SUBSCRIPTION", url, null, true);
|
||||||
|
return mapSubscriptionSnapshot(packageName, purchaseToken, jsonNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
public GooglePlayPurchaseSnapshot getOneTimeProduct(String packageName, String purchaseToken) {
|
||||||
|
String url = API_BASE + "/" + encode(packageName) + "/purchases/productsv2/tokens/" + encode(purchaseToken);
|
||||||
|
JsonNode jsonNode = executeJson("GET_ONE_TIME", url, null, true);
|
||||||
|
return mapOneTimeSnapshot(packageName, purchaseToken, jsonNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void acknowledgeSubscription(String packageName, String productId, String purchaseToken) {
|
||||||
|
String url = API_BASE + "/" + encode(packageName) + "/purchases/subscriptions/"
|
||||||
|
+ encode(productId) + "/tokens/" + encode(purchaseToken) + ":acknowledge";
|
||||||
|
executeJson("ACK_SUBSCRIPTION", url, "{}", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void acknowledgeProduct(String packageName, String productId, String purchaseToken) {
|
||||||
|
String url = API_BASE + "/" + encode(packageName) + "/purchases/products/"
|
||||||
|
+ encode(productId) + "/tokens/" + encode(purchaseToken) + ":acknowledge";
|
||||||
|
executeJson("ACK_PRODUCT", url, "{}", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void consumeProduct(String packageName, String productId, String purchaseToken) {
|
||||||
|
String url = API_BASE + "/" + encode(packageName) + "/purchases/products/"
|
||||||
|
+ encode(productId) + "/tokens/" + encode(purchaseToken) + ":consume";
|
||||||
|
executeJson("CONSUME_PRODUCT", url, "{}", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public JsonNode verifyPubSubJwt(String bearerToken) {
|
||||||
|
String url = properties.getPubsubTokenInfoUri() + "?id_token=" + encode(bearerToken);
|
||||||
|
return executeJson("VERIFY_PUBSUB_JWT", url, null, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonNode executeJson(String operation, String url, String body, boolean withAuthorization) {
|
||||||
|
for (int attempt = 1; attempt <= MAX_RETRY; attempt++) {
|
||||||
|
try {
|
||||||
|
HttpRequest request = buildRequest(url, body, withAuthorization);
|
||||||
|
HttpResponse<String> response = googlePlayHttpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
|
if (response.statusCode() / 100 != 2) {
|
||||||
|
throw new GooglePlayApiException(response.statusCode(), response.body());
|
||||||
|
}
|
||||||
|
String responseBody = response.body();
|
||||||
|
return responseBody == null || responseBody.isBlank()
|
||||||
|
? objectMapper.createObjectNode()
|
||||||
|
: objectMapper.readTree(responseBody);
|
||||||
|
} catch (GooglePlayApiException e) {
|
||||||
|
if (attempt == MAX_RETRY || e.getStatusCode() == 404) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
sleep(attempt, operation, e);
|
||||||
|
} catch (Exception e) {
|
||||||
|
if (attempt == MAX_RETRY) {
|
||||||
|
throw new GooglePlayApiException("Google Play 请求失败: " + operation, e);
|
||||||
|
}
|
||||||
|
sleep(attempt, operation, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new IllegalStateException("unreachable");
|
||||||
|
}
|
||||||
|
|
||||||
|
private HttpRequest buildRequest(String url, String body, boolean withAuthorization) {
|
||||||
|
HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(url))
|
||||||
|
.header("Accept", "application/json");
|
||||||
|
if (withAuthorization) {
|
||||||
|
builder.header("Authorization", "Bearer " + tokenProvider.getAccessToken());
|
||||||
|
}
|
||||||
|
if (body == null) {
|
||||||
|
return builder.GET().build();
|
||||||
|
}
|
||||||
|
return builder.header("Content-Type", "application/json")
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofString(body))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private GooglePlayPurchaseSnapshot mapSubscriptionSnapshot(String packageName, String purchaseToken, JsonNode root) {
|
||||||
|
JsonNode lineItem = firstLineItem(root);
|
||||||
|
String state = mapSubscriptionState(text(root, "subscriptionState"));
|
||||||
|
String googleOrderId = firstNonBlank(text(lineItem, "latestSuccessfulOrderId"), text(root, "latestOrderId"));
|
||||||
|
return GooglePlayPurchaseSnapshot.builder()
|
||||||
|
.packageName(packageName)
|
||||||
|
.productId(text(lineItem, "productId"))
|
||||||
|
.productType(GooglePlayConstants.PRODUCT_TYPE_SUBSCRIPTION)
|
||||||
|
.basePlanId(text(lineItem.path("offerDetails"), "basePlanId"))
|
||||||
|
.purchaseToken(purchaseToken)
|
||||||
|
.orderKey(resolveOrderKey(googleOrderId, purchaseToken))
|
||||||
|
.googleOrderId(googleOrderId)
|
||||||
|
.linkedPurchaseToken(text(root, "linkedPurchaseToken"))
|
||||||
|
.state(state)
|
||||||
|
.acknowledgementState(mapAcknowledgementState(text(root, "acknowledgementState")))
|
||||||
|
.consumptionState(GooglePlayConstants.CONSUMPTION_NOT_APPLICABLE)
|
||||||
|
.quantity(1)
|
||||||
|
.refundableQuantity(null)
|
||||||
|
.autoRenewEnabled(bool(lineItem.path("autoRenewingPlan"), "autoRenewEnabled"))
|
||||||
|
.accessGranted(isSubscriptionAccessGranted(state))
|
||||||
|
.externalAccountId(text(root.path("externalAccountIdentifiers"), "obfuscatedExternalAccountId"))
|
||||||
|
.externalProfileId(text(root.path("externalAccountIdentifiers"), "obfuscatedExternalProfileId"))
|
||||||
|
.regionCode(text(root, "regionCode"))
|
||||||
|
.canceledStateReason(resolveCanceledReason(root.path("canceledStateContext")))
|
||||||
|
.startTime(parseTime(firstNonBlank(text(root, "startTime"), text(lineItem, "startTime"))))
|
||||||
|
.expiryTime(parseTime(text(lineItem, "expiryTime")))
|
||||||
|
.autoResumeTime(parseTime(text(root.path("pausedStateContext"), "autoResumeTime")))
|
||||||
|
.lastSyncedAt(new Date())
|
||||||
|
.rawResponse(writeJson(root))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private GooglePlayPurchaseSnapshot mapOneTimeSnapshot(String packageName, String purchaseToken, JsonNode root) {
|
||||||
|
// 1. 获取 productLineItem 数组的第一个元素 (index 0)
|
||||||
|
JsonNode firstLineItem = root.path("productLineItem").path(0);
|
||||||
|
// 2. 获取嵌套的 productOfferDetails 对象
|
||||||
|
JsonNode offerDetails = firstLineItem.path("productOfferDetails");
|
||||||
|
|
||||||
|
String state = mapOneTimeState(text(root.path("purchaseStateContext"), "purchaseState"));
|
||||||
|
String purchaseOptionId = text(offerDetails, "purchaseOptionId");
|
||||||
|
// 修正:一次性购买的订单号字段名为 "orderId"
|
||||||
|
String googleOrderId = text(root, "orderId");
|
||||||
|
|
||||||
|
return GooglePlayPurchaseSnapshot.builder()
|
||||||
|
.packageName(packageName)
|
||||||
|
// 修正:从第一个元素中获取 productId
|
||||||
|
.productId(text(firstLineItem, "productId"))
|
||||||
|
.productType(GooglePlayConstants.PRODUCT_TYPE_ONE_TIME)
|
||||||
|
.purchaseToken(purchaseToken)
|
||||||
|
.purchaseOptionId(purchaseOptionId)
|
||||||
|
.orderKey(resolveOrderKey(googleOrderId, purchaseToken))
|
||||||
|
.googleOrderId(googleOrderId)
|
||||||
|
.linkedPurchaseToken(null)
|
||||||
|
.state(state)
|
||||||
|
.acknowledgementState(mapAcknowledgementState(text(root, "acknowledgementState")))
|
||||||
|
// 修正:从 productOfferDetails 中获取以下字段
|
||||||
|
.consumptionState(mapConsumptionState(text(offerDetails, "consumptionState")))
|
||||||
|
.quantity(number(offerDetails, "quantity"))
|
||||||
|
.refundableQuantity(number(offerDetails, "refundableQuantity"))
|
||||||
|
.autoRenewEnabled(false)
|
||||||
|
.accessGranted(GooglePlayConstants.STATE_ACTIVE.equals(state))
|
||||||
|
.externalAccountId(text(root.path("externalAccountIdentifiers"), "obfuscatedExternalAccountId"))
|
||||||
|
.externalProfileId(text(root.path("externalAccountIdentifiers"), "obfuscatedExternalProfileId"))
|
||||||
|
.regionCode(text(root, "regionCode"))
|
||||||
|
.canceledStateReason(null)
|
||||||
|
.startTime(parseTime(text(root, "purchaseCompletionTime")))
|
||||||
|
.expiryTime(null)
|
||||||
|
.autoResumeTime(null)
|
||||||
|
.lastSyncedAt(new Date())
|
||||||
|
.rawResponse(writeJson(root))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonNode firstLineItem(JsonNode root) {
|
||||||
|
JsonNode lineItems = root.path("lineItems");
|
||||||
|
return lineItems.isArray() && !lineItems.isEmpty() ? lineItems.get(0) : objectMapper.createObjectNode();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveOrderKey(String googleOrderId, String purchaseToken) {
|
||||||
|
return firstNonBlank(googleOrderId, "TOKEN:" + purchaseToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveCanceledReason(JsonNode node) {
|
||||||
|
if (node == null || node.isMissingNode()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (node.hasNonNull("userInitiatedCancellation")) {
|
||||||
|
return "USER";
|
||||||
|
}
|
||||||
|
if (node.hasNonNull("systemInitiatedCancellation")) {
|
||||||
|
return "SYSTEM";
|
||||||
|
}
|
||||||
|
if (node.hasNonNull("developerInitiatedCancellation")) {
|
||||||
|
return "DEVELOPER";
|
||||||
|
}
|
||||||
|
if (node.hasNonNull("replacementCancellation")) {
|
||||||
|
return "REPLACEMENT";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String mapSubscriptionState(String state) {
|
||||||
|
return switch (state) {
|
||||||
|
case "SUBSCRIPTION_STATE_ACTIVE" -> GooglePlayConstants.STATE_ACTIVE;
|
||||||
|
case "SUBSCRIPTION_STATE_CANCELED" -> GooglePlayConstants.STATE_CANCELED;
|
||||||
|
case "SUBSCRIPTION_STATE_IN_GRACE_PERIOD" -> GooglePlayConstants.STATE_IN_GRACE_PERIOD;
|
||||||
|
case "SUBSCRIPTION_STATE_ON_HOLD" -> GooglePlayConstants.STATE_ON_HOLD;
|
||||||
|
case "SUBSCRIPTION_STATE_PAUSED" -> GooglePlayConstants.STATE_PAUSED;
|
||||||
|
case "SUBSCRIPTION_STATE_EXPIRED" -> GooglePlayConstants.STATE_EXPIRED;
|
||||||
|
case "SUBSCRIPTION_STATE_PENDING" -> GooglePlayConstants.STATE_PENDING;
|
||||||
|
case "SUBSCRIPTION_STATE_PENDING_PURCHASE_CANCELED" -> GooglePlayConstants.STATE_PENDING_PURCHASE_CANCELED;
|
||||||
|
default -> GooglePlayConstants.STATE_UNKNOWN;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isSubscriptionAccessGranted(String state) {
|
||||||
|
return GooglePlayConstants.STATE_ACTIVE.equals(state)
|
||||||
|
|| GooglePlayConstants.STATE_CANCELED.equals(state)
|
||||||
|
|| GooglePlayConstants.STATE_IN_GRACE_PERIOD.equals(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String mapOneTimeState(String state) {
|
||||||
|
if (state == null) {
|
||||||
|
return GooglePlayConstants.STATE_UNKNOWN;
|
||||||
|
}
|
||||||
|
return switch (state) {
|
||||||
|
// productsv2 API 返回短枚举名
|
||||||
|
case "PURCHASED", "PURCHASE_STATE_PURCHASED" -> GooglePlayConstants.STATE_ACTIVE;
|
||||||
|
case "PENDING", "PURCHASE_STATE_PENDING" -> GooglePlayConstants.STATE_PENDING;
|
||||||
|
case "CANCELLED", "PURCHASE_STATE_CANCELLED" -> GooglePlayConstants.STATE_CANCELED;
|
||||||
|
default -> GooglePlayConstants.STATE_UNKNOWN;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private String mapAcknowledgementState(String state) {
|
||||||
|
return "ACKNOWLEDGEMENT_STATE_ACKNOWLEDGED".equals(state)
|
||||||
|
? GooglePlayConstants.ACK_ACKNOWLEDGED
|
||||||
|
: GooglePlayConstants.ACK_PENDING;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String mapConsumptionState(String state) {
|
||||||
|
if ("CONSUMPTION_STATE_CONSUMED".equals(state)) {
|
||||||
|
return GooglePlayConstants.CONSUMPTION_CONSUMED;
|
||||||
|
}
|
||||||
|
if ("CONSUMPTION_STATE_NO_NEED_TO_CONSUME".equals(state)) {
|
||||||
|
return GooglePlayConstants.CONSUMPTION_NOT_APPLICABLE;
|
||||||
|
}
|
||||||
|
return GooglePlayConstants.CONSUMPTION_PENDING;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String text(JsonNode node, String field) {
|
||||||
|
JsonNode child = node == null ? null : node.get(field);
|
||||||
|
return child == null || child.isNull() ? null : child.asText();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Boolean bool(JsonNode node, String field) {
|
||||||
|
JsonNode child = node == null ? null : node.get(field);
|
||||||
|
return child == null || child.isNull() ? null : child.asBoolean();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Integer number(JsonNode node, String field) {
|
||||||
|
JsonNode child = node == null ? null : node.get(field);
|
||||||
|
return child == null || child.isNull() ? null : child.asInt();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Date parseTime(String value) {
|
||||||
|
if (value == null || value.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Date.from(Instant.parse(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String firstNonBlank(String first, String second) {
|
||||||
|
return first != null && !first.isBlank() ? first : second;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String encode(String raw) {
|
||||||
|
return URLEncoder.encode(raw, StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String writeJson(JsonNode jsonNode) {
|
||||||
|
try {
|
||||||
|
return objectMapper.writeValueAsString(jsonNode);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return "{}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sleep(int attempt, String operation, Exception e) {
|
||||||
|
log.warn("Google Play API retry, operation={}, attempt={}", operation, attempt, e);
|
||||||
|
try {
|
||||||
|
Thread.sleep(RETRY_INTERVAL_MILLIS * attempt);
|
||||||
|
} catch (InterruptedException interruptedException) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw new GooglePlayApiException("Google Play 请求被中断", interruptedException);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.yolo.keyborad.googleplay;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
public class GooglePlayApiException extends RuntimeException {
|
||||||
|
|
||||||
|
private final int statusCode;
|
||||||
|
|
||||||
|
private final String responseBody;
|
||||||
|
|
||||||
|
public GooglePlayApiException(int statusCode, String responseBody) {
|
||||||
|
super("Google Play API request failed, status=" + statusCode);
|
||||||
|
this.statusCode = statusCode;
|
||||||
|
this.responseBody = responseBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
public GooglePlayApiException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
this.statusCode = -1;
|
||||||
|
this.responseBody = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package com.yolo.keyborad.googleplay;
|
||||||
|
|
||||||
|
public final class GooglePlayConstants {
|
||||||
|
|
||||||
|
public static final String PRODUCT_TYPE_SUBSCRIPTION = "SUBSCRIPTION";
|
||||||
|
public static final String PRODUCT_TYPE_ONE_TIME = "ONE_TIME";
|
||||||
|
|
||||||
|
public static final String STATE_PENDING = "PENDING";
|
||||||
|
public static final String STATE_ACTIVE = "ACTIVE";
|
||||||
|
public static final String STATE_CANCELED = "CANCELED";
|
||||||
|
public static final String STATE_IN_GRACE_PERIOD = "IN_GRACE_PERIOD";
|
||||||
|
public static final String STATE_ON_HOLD = "ON_HOLD";
|
||||||
|
public static final String STATE_PAUSED = "PAUSED";
|
||||||
|
public static final String STATE_EXPIRED = "EXPIRED";
|
||||||
|
public static final String STATE_REVOKED = "REVOKED";
|
||||||
|
public static final String STATE_REFUNDED = "REFUNDED";
|
||||||
|
public static final String STATE_PENDING_PURCHASE_CANCELED = "PENDING_PURCHASE_CANCELED";
|
||||||
|
public static final String STATE_UNKNOWN = "UNKNOWN";
|
||||||
|
|
||||||
|
public static final String ACK_PENDING = "PENDING";
|
||||||
|
public static final String ACK_ACKNOWLEDGED = "ACKNOWLEDGED";
|
||||||
|
|
||||||
|
public static final String CONSUMPTION_PENDING = "PENDING";
|
||||||
|
public static final String CONSUMPTION_CONSUMED = "CONSUMED";
|
||||||
|
public static final String CONSUMPTION_NOT_APPLICABLE = "NOT_APPLICABLE";
|
||||||
|
|
||||||
|
public static final String DELIVERY_PENDING = "PENDING";
|
||||||
|
public static final String DELIVERY_PROCESSING = "PROCESSING";
|
||||||
|
public static final String DELIVERY_DELIVERED = "DELIVERED";
|
||||||
|
public static final String DELIVERY_REVOKED = "REVOKED";
|
||||||
|
public static final String DELIVERY_NOT_REQUIRED = "NOT_REQUIRED";
|
||||||
|
public static final String DELIVERY_MANUAL_REVIEW = "MANUAL_REVIEW";
|
||||||
|
|
||||||
|
public static final String ENTITLEMENT_VIP_SUBSCRIPTION = "VIP_SUBSCRIPTION";
|
||||||
|
public static final String ENTITLEMENT_VIP_ONE_TIME = "VIP_ONE_TIME";
|
||||||
|
public static final String ENTITLEMENT_WALLET_TOP_UP = "WALLET_TOP_UP";
|
||||||
|
public static final String ENTITLEMENT_NON_CONSUMABLE = "NON_CONSUMABLE";
|
||||||
|
|
||||||
|
public static final String EVENT_RECEIVED = "RECEIVED";
|
||||||
|
public static final String EVENT_PROCESSED = "PROCESSED";
|
||||||
|
public static final String EVENT_FAILED = "FAILED";
|
||||||
|
public static final String EVENT_IGNORED = "IGNORED";
|
||||||
|
|
||||||
|
public static final String EVENT_TYPE_SUBSCRIPTION = "SUBSCRIPTION";
|
||||||
|
public static final String EVENT_TYPE_ONE_TIME = "ONE_TIME";
|
||||||
|
public static final String EVENT_TYPE_VOIDED = "VOIDED";
|
||||||
|
public static final String EVENT_TYPE_TEST = "TEST";
|
||||||
|
|
||||||
|
public static final String PAYMENT_METHOD_GOOGLE_PLAY = "GOOGLE_PLAY";
|
||||||
|
|
||||||
|
private GooglePlayConstants() {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
package com.yolo.keyborad.googleplay;
|
||||||
|
|
||||||
|
import com.yolo.keyborad.common.ErrorCode;
|
||||||
|
import com.yolo.keyborad.exception.BusinessException;
|
||||||
|
import com.yolo.keyborad.googleplay.model.GooglePlayPurchaseSnapshot;
|
||||||
|
import com.yolo.keyborad.mapper.GooglePlayUserEntitlementMapper;
|
||||||
|
import com.yolo.keyborad.model.entity.KeyboardProductItems;
|
||||||
|
import com.yolo.keyborad.model.entity.googleplay.GooglePlayOrder;
|
||||||
|
import com.yolo.keyborad.model.entity.googleplay.GooglePlayUserEntitlement;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class GooglePlayEntitlementApplier {
|
||||||
|
|
||||||
|
private final GooglePlayUserEntitlementMapper entitlementMapper;
|
||||||
|
private final GooglePlayVipBenefitService vipBenefitService;
|
||||||
|
private final GooglePlayWalletBenefitService walletBenefitService;
|
||||||
|
private final GooglePlayOrderDeliveryGuard orderDeliveryGuard;
|
||||||
|
|
||||||
|
public GooglePlayUserEntitlement apply(Long userId,
|
||||||
|
KeyboardProductItems product,
|
||||||
|
GooglePlayPurchaseSnapshot snapshot,
|
||||||
|
GooglePlayOrder order) {
|
||||||
|
String benefitType = resolveBenefitType(product, snapshot);
|
||||||
|
String entitlementKey = resolveEntitlementKey(benefitType, product.getProductId());
|
||||||
|
boolean grantOwned = orderDeliveryGuard.prepareGrant(benefitType, snapshot, order);
|
||||||
|
GooglePlayUserEntitlement entitlement = loadEntitlement(snapshot.getPurchaseToken(), entitlementKey);
|
||||||
|
if (entitlement == null) {
|
||||||
|
entitlement = new GooglePlayUserEntitlement();
|
||||||
|
entitlement.setCreatedAt(new Date());
|
||||||
|
}
|
||||||
|
fillCommonFields(entitlement, userId, product, snapshot, order, benefitType, entitlementKey);
|
||||||
|
switch (benefitType) {
|
||||||
|
case GooglePlayConstants.ENTITLEMENT_VIP_SUBSCRIPTION -> applySubscriptionVip(userId, product, snapshot, order, entitlement);
|
||||||
|
case GooglePlayConstants.ENTITLEMENT_VIP_ONE_TIME ->
|
||||||
|
applyOneTimeVip(userId, product, snapshot, order, entitlement, grantOwned);
|
||||||
|
case GooglePlayConstants.ENTITLEMENT_WALLET_TOP_UP ->
|
||||||
|
applyWalletTopUp(userId, product, snapshot, order, entitlement, grantOwned);
|
||||||
|
default -> applyNonConsumable(snapshot, order, entitlement, grantOwned);
|
||||||
|
}
|
||||||
|
saveEntitlement(entitlement);
|
||||||
|
return entitlement;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void fillCommonFields(GooglePlayUserEntitlement entitlement,
|
||||||
|
Long userId,
|
||||||
|
KeyboardProductItems product,
|
||||||
|
GooglePlayPurchaseSnapshot snapshot,
|
||||||
|
GooglePlayOrder order,
|
||||||
|
String benefitType,
|
||||||
|
String entitlementKey) {
|
||||||
|
Date now = new Date();
|
||||||
|
entitlement.setUserId(userId);
|
||||||
|
entitlement.setEntitlementKey(entitlementKey);
|
||||||
|
entitlement.setProductId(product.getProductId());
|
||||||
|
entitlement.setProductType(snapshot.getProductType());
|
||||||
|
entitlement.setSourcePurchaseToken(snapshot.getPurchaseToken());
|
||||||
|
entitlement.setCurrentOrderKey(order.getOrderKey());
|
||||||
|
entitlement.setBenefitType(benefitType);
|
||||||
|
entitlement.setState(snapshot.getState());
|
||||||
|
entitlement.setUpdatedAt(now);
|
||||||
|
if (entitlement.getQuantity() == null) {
|
||||||
|
entitlement.setQuantity(BigDecimal.ZERO);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applySubscriptionVip(Long userId,
|
||||||
|
KeyboardProductItems product,
|
||||||
|
GooglePlayPurchaseSnapshot snapshot,
|
||||||
|
GooglePlayOrder order,
|
||||||
|
GooglePlayUserEntitlement entitlement) {
|
||||||
|
Date expiryTime = snapshot.getExpiryTime();
|
||||||
|
if (snapshot.getAccessGranted() && expiryTime == null) {
|
||||||
|
throw new BusinessException(ErrorCode.GOOGLE_PLAY_STATE_INVALID, "订阅缺少 expiryTime");
|
||||||
|
}
|
||||||
|
entitlement.setActive(Boolean.TRUE.equals(snapshot.getAccessGranted()));
|
||||||
|
entitlement.setStartTime(snapshot.getStartTime());
|
||||||
|
entitlement.setEndTime(expiryTime);
|
||||||
|
order.setDeliveryStatus(GooglePlayConstants.DELIVERY_NOT_REQUIRED);
|
||||||
|
order.setGrantedQuantity(BigDecimal.ZERO);
|
||||||
|
if (Boolean.TRUE.equals(snapshot.getAccessGranted())) {
|
||||||
|
entitlement.setLastGrantedAt(new Date());
|
||||||
|
vipBenefitService.activate(userId, product, expiryTime);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
entitlement.setLastRevokedAt(new Date());
|
||||||
|
vipBenefitService.deactivate(userId, expiryTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyOneTimeVip(Long userId,
|
||||||
|
KeyboardProductItems product,
|
||||||
|
GooglePlayPurchaseSnapshot snapshot,
|
||||||
|
GooglePlayOrder order,
|
||||||
|
GooglePlayUserEntitlement entitlement,
|
||||||
|
boolean grantOwned) {
|
||||||
|
if (GooglePlayConstants.STATE_ACTIVE.equals(snapshot.getState())) {
|
||||||
|
grantOneTimeVip(userId, product, order, entitlement, grantOwned);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
revokeVipEntitlement(userId, order, entitlement);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyWalletTopUp(Long userId,
|
||||||
|
KeyboardProductItems product,
|
||||||
|
GooglePlayPurchaseSnapshot snapshot,
|
||||||
|
GooglePlayOrder order,
|
||||||
|
GooglePlayUserEntitlement entitlement,
|
||||||
|
boolean grantOwned) {
|
||||||
|
BigDecimal amount = resolveWalletAmount(product);
|
||||||
|
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||||
|
throw new BusinessException(ErrorCode.PRODUCT_QUOTA_NOT_SET);
|
||||||
|
}
|
||||||
|
if (GooglePlayConstants.STATE_ACTIVE.equals(snapshot.getState())) {
|
||||||
|
grantWalletTopUp(userId, product, order, entitlement, amount, grantOwned);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
revokeWalletTopUp(userId, order, entitlement, amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyNonConsumable(GooglePlayPurchaseSnapshot snapshot,
|
||||||
|
GooglePlayOrder order,
|
||||||
|
GooglePlayUserEntitlement entitlement,
|
||||||
|
boolean grantOwned) {
|
||||||
|
boolean active = GooglePlayConstants.STATE_ACTIVE.equals(snapshot.getState())
|
||||||
|
|| GooglePlayConstants.STATE_CANCELED.equals(snapshot.getState());
|
||||||
|
if (!grantOwned && active) {
|
||||||
|
entitlement.setActive(GooglePlayConstants.DELIVERY_DELIVERED.equals(order.getDeliveryStatus()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
entitlement.setActive(active);
|
||||||
|
entitlement.setStartTime(snapshot.getStartTime());
|
||||||
|
entitlement.setEndTime(snapshot.getExpiryTime());
|
||||||
|
entitlement.setQuantity(BigDecimal.ONE);
|
||||||
|
order.setGrantedQuantity(BigDecimal.ONE);
|
||||||
|
order.setDeliveryStatus(active ? GooglePlayConstants.DELIVERY_DELIVERED : GooglePlayConstants.DELIVERY_REVOKED);
|
||||||
|
if (active) {
|
||||||
|
entitlement.setLastGrantedAt(new Date());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
entitlement.setLastRevokedAt(new Date());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void grantOneTimeVip(Long userId,
|
||||||
|
KeyboardProductItems product,
|
||||||
|
GooglePlayOrder order,
|
||||||
|
GooglePlayUserEntitlement entitlement,
|
||||||
|
boolean grantOwned) {
|
||||||
|
if (!grantOwned || GooglePlayConstants.DELIVERY_DELIVERED.equals(order.getDeliveryStatus())) {
|
||||||
|
entitlement.setActive(GooglePlayConstants.DELIVERY_DELIVERED.equals(order.getDeliveryStatus()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Date expiry = resolveOneTimeVipExpiry(product);
|
||||||
|
vipBenefitService.activate(userId, product, expiry);
|
||||||
|
entitlement.setActive(true);
|
||||||
|
entitlement.setQuantity(BigDecimal.ONE);
|
||||||
|
entitlement.setStartTime(new Date());
|
||||||
|
entitlement.setEndTime(expiry);
|
||||||
|
entitlement.setLastGrantedAt(new Date());
|
||||||
|
order.setDeliveryStatus(GooglePlayConstants.DELIVERY_DELIVERED);
|
||||||
|
order.setGrantedQuantity(BigDecimal.ONE);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void revokeVipEntitlement(Long userId,
|
||||||
|
GooglePlayOrder order,
|
||||||
|
GooglePlayUserEntitlement entitlement) {
|
||||||
|
entitlement.setActive(false);
|
||||||
|
entitlement.setLastRevokedAt(new Date());
|
||||||
|
if (!GooglePlayConstants.DELIVERY_DELIVERED.equals(order.getDeliveryStatus())) {
|
||||||
|
order.setDeliveryStatus(GooglePlayConstants.DELIVERY_REVOKED);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
vipBenefitService.deactivate(userId, entitlement.getEndTime());
|
||||||
|
order.setDeliveryStatus(GooglePlayConstants.DELIVERY_REVOKED);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void grantWalletTopUp(Long userId,
|
||||||
|
KeyboardProductItems product,
|
||||||
|
GooglePlayOrder order,
|
||||||
|
GooglePlayUserEntitlement entitlement,
|
||||||
|
BigDecimal amount,
|
||||||
|
boolean grantOwned) {
|
||||||
|
if (!grantOwned || GooglePlayConstants.DELIVERY_DELIVERED.equals(order.getDeliveryStatus())) {
|
||||||
|
entitlement.setActive(GooglePlayConstants.DELIVERY_DELIVERED.equals(order.getDeliveryStatus()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
walletBenefitService.grant(userId, order.getId(), product.getName(), amount);
|
||||||
|
entitlement.setActive(true);
|
||||||
|
entitlement.setQuantity(amount);
|
||||||
|
entitlement.setStartTime(new Date());
|
||||||
|
entitlement.setLastGrantedAt(new Date());
|
||||||
|
order.setDeliveryStatus(GooglePlayConstants.DELIVERY_DELIVERED);
|
||||||
|
order.setGrantedQuantity(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void revokeWalletTopUp(Long userId,
|
||||||
|
GooglePlayOrder order,
|
||||||
|
GooglePlayUserEntitlement entitlement,
|
||||||
|
BigDecimal amount) {
|
||||||
|
entitlement.setActive(false);
|
||||||
|
entitlement.setLastRevokedAt(new Date());
|
||||||
|
if (!GooglePlayConstants.DELIVERY_DELIVERED.equals(order.getDeliveryStatus())) {
|
||||||
|
order.setDeliveryStatus(GooglePlayConstants.DELIVERY_REVOKED);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
boolean revoked = walletBenefitService.revoke(userId, order.getId(), amount);
|
||||||
|
if (!revoked) {
|
||||||
|
order.setDeliveryStatus(GooglePlayConstants.DELIVERY_MANUAL_REVIEW);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
order.setDeliveryStatus(GooglePlayConstants.DELIVERY_REVOKED);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Date resolveOneTimeVipExpiry(KeyboardProductItems product) {
|
||||||
|
if (product.getDurationDays() == null || product.getDurationDays() <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
long millis = (long) product.getDurationDays() * 24 * 60 * 60 * 1000;
|
||||||
|
return new Date(System.currentTimeMillis() + millis);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveBenefitType(KeyboardProductItems product, GooglePlayPurchaseSnapshot snapshot) {
|
||||||
|
if (GooglePlayConstants.PRODUCT_TYPE_SUBSCRIPTION.equals(snapshot.getProductType())) {
|
||||||
|
return GooglePlayConstants.ENTITLEMENT_VIP_SUBSCRIPTION;
|
||||||
|
}
|
||||||
|
if (looksLikeVipProduct(product)) {
|
||||||
|
return GooglePlayConstants.ENTITLEMENT_VIP_ONE_TIME;
|
||||||
|
}
|
||||||
|
if (looksLikeWalletProduct(product)) {
|
||||||
|
return GooglePlayConstants.ENTITLEMENT_WALLET_TOP_UP;
|
||||||
|
}
|
||||||
|
return GooglePlayConstants.ENTITLEMENT_NON_CONSUMABLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean looksLikeVipProduct(KeyboardProductItems product) {
|
||||||
|
String unit = product.getUnit() == null ? "" : product.getUnit().toLowerCase();
|
||||||
|
return product.getDurationDays() != null && product.getDurationDays() > 0
|
||||||
|
|| unit.contains("vip")
|
||||||
|
|| unit.contains("member")
|
||||||
|
|| unit.contains("会员");
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean looksLikeWalletProduct(KeyboardProductItems product) {
|
||||||
|
String unit = product.getUnit() == null ? "" : product.getUnit().toLowerCase();
|
||||||
|
return unit.contains("coin")
|
||||||
|
|| unit.contains("quota")
|
||||||
|
|| unit.contains("credit")
|
||||||
|
|| unit.contains("金币")
|
||||||
|
|| unit.contains("次数")
|
||||||
|
|| resolveWalletAmount(product).compareTo(BigDecimal.ZERO) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private BigDecimal resolveWalletAmount(KeyboardProductItems product) {
|
||||||
|
BigDecimal fromName = parseNumber(product.getName());
|
||||||
|
if (fromName != null) {
|
||||||
|
return fromName;
|
||||||
|
}
|
||||||
|
if (product.getDurationValue() != null) {
|
||||||
|
return BigDecimal.valueOf(product.getDurationValue());
|
||||||
|
}
|
||||||
|
return BigDecimal.ZERO;
|
||||||
|
}
|
||||||
|
|
||||||
|
private BigDecimal parseNumber(String raw) {
|
||||||
|
if (raw == null || raw.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return new BigDecimal(raw.replaceAll("[^\\d.]", ""));
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveEntitlementKey(String benefitType, String productId) {
|
||||||
|
if (GooglePlayConstants.ENTITLEMENT_NON_CONSUMABLE.equals(benefitType)) {
|
||||||
|
return "PRODUCT:" + productId;
|
||||||
|
}
|
||||||
|
if (GooglePlayConstants.ENTITLEMENT_WALLET_TOP_UP.equals(benefitType)) {
|
||||||
|
return "WALLET";
|
||||||
|
}
|
||||||
|
return "VIP";
|
||||||
|
}
|
||||||
|
|
||||||
|
private GooglePlayUserEntitlement loadEntitlement(String purchaseToken, String entitlementKey) {
|
||||||
|
return entitlementMapper.selectOne(com.baomidou.mybatisplus.core.toolkit.Wrappers.<GooglePlayUserEntitlement>lambdaQuery()
|
||||||
|
.eq(GooglePlayUserEntitlement::getSourcePurchaseToken, purchaseToken)
|
||||||
|
.eq(GooglePlayUserEntitlement::getEntitlementKey, entitlementKey)
|
||||||
|
.last("LIMIT 1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void saveEntitlement(GooglePlayUserEntitlement entitlement) {
|
||||||
|
if (entitlement.getId() == null) {
|
||||||
|
entitlementMapper.insert(entitlement);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
entitlementMapper.updateById(entitlement);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
package com.yolo.keyborad.googleplay;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.yolo.keyborad.common.ErrorCode;
|
||||||
|
import com.yolo.keyborad.exception.BusinessException;
|
||||||
|
import com.yolo.keyborad.googleplay.model.GooglePlaySyncCommand;
|
||||||
|
import com.yolo.keyborad.model.dto.googleplay.GooglePlayPubSubPushRequest;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class GooglePlayNotificationSupport {
|
||||||
|
|
||||||
|
private static final int VOIDED_PRODUCT_TYPE_SUBSCRIPTION = 1;
|
||||||
|
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
public GooglePlayPubSubPushRequest.DeveloperNotification decode(GooglePlayPubSubPushRequest pushRequest) {
|
||||||
|
try {
|
||||||
|
byte[] decoded = Base64.getDecoder().decode(pushRequest.getMessage().getData());
|
||||||
|
return objectMapper.readValue(decoded, GooglePlayPubSubPushRequest.DeveloperNotification.class);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new BusinessException(ErrorCode.GOOGLE_PLAY_RTDN_PAYLOAD_INVALID, "RTDN payload 解析失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public GooglePlaySyncCommand buildCommand(GooglePlayPubSubPushRequest pushRequest,
|
||||||
|
GooglePlayPubSubPushRequest.DeveloperNotification notification,
|
||||||
|
String packageName) {
|
||||||
|
if (notification.getSubscriptionNotification() != null) {
|
||||||
|
return buildSubscriptionCommand(pushRequest, notification, packageName);
|
||||||
|
}
|
||||||
|
if (notification.getOneTimeProductNotification() != null) {
|
||||||
|
return buildOneTimeCommand(pushRequest, notification, packageName);
|
||||||
|
}
|
||||||
|
return buildVoidedCommand(pushRequest, notification, packageName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String resolveEventType(GooglePlayPubSubPushRequest.DeveloperNotification notification) {
|
||||||
|
if (notification.getSubscriptionNotification() != null) {
|
||||||
|
return GooglePlayConstants.EVENT_TYPE_SUBSCRIPTION;
|
||||||
|
}
|
||||||
|
if (notification.getOneTimeProductNotification() != null) {
|
||||||
|
return GooglePlayConstants.EVENT_TYPE_ONE_TIME;
|
||||||
|
}
|
||||||
|
if (notification.getVoidedPurchaseNotification() != null) {
|
||||||
|
return GooglePlayConstants.EVENT_TYPE_VOIDED;
|
||||||
|
}
|
||||||
|
return GooglePlayConstants.EVENT_TYPE_TEST;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer resolveNotificationType(GooglePlayPubSubPushRequest.DeveloperNotification notification) {
|
||||||
|
if (notification.getSubscriptionNotification() != null) {
|
||||||
|
return notification.getSubscriptionNotification().getNotificationType();
|
||||||
|
}
|
||||||
|
if (notification.getOneTimeProductNotification() != null) {
|
||||||
|
return notification.getOneTimeProductNotification().getNotificationType();
|
||||||
|
}
|
||||||
|
return notification.getVoidedPurchaseNotification() == null ? null : notification.getVoidedPurchaseNotification().getRefundType();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String resolveNotificationName(GooglePlayPubSubPushRequest.DeveloperNotification notification) {
|
||||||
|
if (notification.getSubscriptionNotification() != null) {
|
||||||
|
return resolveSubscriptionName(notification.getSubscriptionNotification().getNotificationType());
|
||||||
|
}
|
||||||
|
if (notification.getOneTimeProductNotification() != null) {
|
||||||
|
return resolveOneTimeName(notification.getOneTimeProductNotification().getNotificationType());
|
||||||
|
}
|
||||||
|
return notification.getVoidedPurchaseNotification() == null ? "TEST_NOTIFICATION" : "VOIDED_PURCHASE";
|
||||||
|
}
|
||||||
|
|
||||||
|
public String resolvePurchaseToken(GooglePlayPubSubPushRequest.DeveloperNotification notification) {
|
||||||
|
if (notification.getSubscriptionNotification() != null) {
|
||||||
|
return notification.getSubscriptionNotification().getPurchaseToken();
|
||||||
|
}
|
||||||
|
if (notification.getOneTimeProductNotification() != null) {
|
||||||
|
return notification.getOneTimeProductNotification().getPurchaseToken();
|
||||||
|
}
|
||||||
|
return notification.getVoidedPurchaseNotification() == null ? null : notification.getVoidedPurchaseNotification().getPurchaseToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String resolveProductId(GooglePlayPubSubPushRequest.DeveloperNotification notification) {
|
||||||
|
if (notification.getOneTimeProductNotification() != null) {
|
||||||
|
return notification.getOneTimeProductNotification().getSku();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String normalizeVoidedProductType(Integer productType) {
|
||||||
|
return VOIDED_PRODUCT_TYPE_SUBSCRIPTION == productType
|
||||||
|
? GooglePlayConstants.PRODUCT_TYPE_SUBSCRIPTION
|
||||||
|
: GooglePlayConstants.PRODUCT_TYPE_ONE_TIME;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Date resolveEventTime(String eventTimeMillis) {
|
||||||
|
if (eventTimeMillis == null || eventTimeMillis.isBlank()) {
|
||||||
|
return new Date();
|
||||||
|
}
|
||||||
|
return new Date(Long.parseLong(eventTimeMillis));
|
||||||
|
}
|
||||||
|
|
||||||
|
private GooglePlaySyncCommand buildSubscriptionCommand(GooglePlayPubSubPushRequest pushRequest,
|
||||||
|
GooglePlayPubSubPushRequest.DeveloperNotification notification,
|
||||||
|
String packageName) {
|
||||||
|
int type = notification.getSubscriptionNotification().getNotificationType();
|
||||||
|
return GooglePlaySyncCommand.builder()
|
||||||
|
.packageName(packageName)
|
||||||
|
.productType(GooglePlayConstants.PRODUCT_TYPE_SUBSCRIPTION)
|
||||||
|
.purchaseToken(notification.getSubscriptionNotification().getPurchaseToken())
|
||||||
|
.source("RTDN")
|
||||||
|
.eventType(GooglePlayConstants.EVENT_TYPE_SUBSCRIPTION)
|
||||||
|
.notificationType(type)
|
||||||
|
.notificationName(resolveSubscriptionName(type))
|
||||||
|
.messageId(pushRequest.getMessage().getMessageId())
|
||||||
|
.subscriptionName(pushRequest.getSubscription())
|
||||||
|
.eventTime(resolveEventTime(notification.getEventTimeMillis()))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private GooglePlaySyncCommand buildOneTimeCommand(GooglePlayPubSubPushRequest pushRequest,
|
||||||
|
GooglePlayPubSubPushRequest.DeveloperNotification notification,
|
||||||
|
String packageName) {
|
||||||
|
int type = notification.getOneTimeProductNotification().getNotificationType();
|
||||||
|
return GooglePlaySyncCommand.builder()
|
||||||
|
.packageName(packageName)
|
||||||
|
.productId(notification.getOneTimeProductNotification().getSku())
|
||||||
|
.productType(GooglePlayConstants.PRODUCT_TYPE_ONE_TIME)
|
||||||
|
.purchaseToken(notification.getOneTimeProductNotification().getPurchaseToken())
|
||||||
|
.source("RTDN")
|
||||||
|
.eventType(GooglePlayConstants.EVENT_TYPE_ONE_TIME)
|
||||||
|
.notificationType(type)
|
||||||
|
.notificationName(resolveOneTimeName(type))
|
||||||
|
.messageId(pushRequest.getMessage().getMessageId())
|
||||||
|
.subscriptionName(pushRequest.getSubscription())
|
||||||
|
.eventTime(resolveEventTime(notification.getEventTimeMillis()))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private GooglePlaySyncCommand buildVoidedCommand(GooglePlayPubSubPushRequest pushRequest,
|
||||||
|
GooglePlayPubSubPushRequest.DeveloperNotification notification,
|
||||||
|
String packageName) {
|
||||||
|
GooglePlayPubSubPushRequest.VoidedPurchaseNotification voided = notification.getVoidedPurchaseNotification();
|
||||||
|
return GooglePlaySyncCommand.builder()
|
||||||
|
.packageName(packageName)
|
||||||
|
.productType(normalizeVoidedProductType(voided.getProductType()))
|
||||||
|
.purchaseToken(voided.getPurchaseToken())
|
||||||
|
.source("RTDN")
|
||||||
|
.eventType(GooglePlayConstants.EVENT_TYPE_VOIDED)
|
||||||
|
.notificationType(voided.getRefundType())
|
||||||
|
.notificationName("VOIDED_PURCHASE")
|
||||||
|
.messageId(pushRequest.getMessage().getMessageId())
|
||||||
|
.subscriptionName(pushRequest.getSubscription())
|
||||||
|
.eventTime(resolveEventTime(notification.getEventTimeMillis()))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveSubscriptionName(Integer type) {
|
||||||
|
return switch (type) {
|
||||||
|
case 1 -> "SUBSCRIPTION_RECOVERED";
|
||||||
|
case 2 -> "SUBSCRIPTION_RENEWED";
|
||||||
|
case 3 -> "SUBSCRIPTION_CANCELED";
|
||||||
|
case 4 -> "SUBSCRIPTION_PURCHASED";
|
||||||
|
case 5 -> "SUBSCRIPTION_ON_HOLD";
|
||||||
|
case 6 -> "SUBSCRIPTION_IN_GRACE_PERIOD";
|
||||||
|
case 7 -> "SUBSCRIPTION_RESTARTED";
|
||||||
|
case 9 -> "SUBSCRIPTION_DEFERRED";
|
||||||
|
case 10 -> "SUBSCRIPTION_PAUSED";
|
||||||
|
case 11 -> "SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED";
|
||||||
|
case 12 -> "SUBSCRIPTION_REVOKED";
|
||||||
|
case 13 -> "SUBSCRIPTION_EXPIRED";
|
||||||
|
case 17 -> "SUBSCRIPTION_ITEMS_CHANGED";
|
||||||
|
case 18 -> "SUBSCRIPTION_CANCELLATION_SCHEDULED";
|
||||||
|
case 19 -> "SUBSCRIPTION_PRICE_CHANGE_UPDATED";
|
||||||
|
case 20 -> "SUBSCRIPTION_PENDING_PURCHASE_CANCELED";
|
||||||
|
case 22 -> "SUBSCRIPTION_PRICE_STEP_UP_CONSENT_UPDATED";
|
||||||
|
default -> "SUBSCRIPTION_UNKNOWN";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveOneTimeName(Integer type) {
|
||||||
|
return switch (type) {
|
||||||
|
case 1 -> "ONE_TIME_PRODUCT_PURCHASED";
|
||||||
|
case 2 -> "ONE_TIME_PRODUCT_CANCELED";
|
||||||
|
default -> "ONE_TIME_UNKNOWN";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package com.yolo.keyborad.googleplay;
|
||||||
|
|
||||||
|
import com.yolo.keyborad.googleplay.model.GooglePlayPurchaseSnapshot;
|
||||||
|
import com.yolo.keyborad.mapper.GooglePlayOrderMapper;
|
||||||
|
import com.yolo.keyborad.model.entity.googleplay.GooglePlayOrder;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class GooglePlayOrderDeliveryGuard {
|
||||||
|
|
||||||
|
private final GooglePlayOrderMapper orderMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一次性商品在真正发权益前先抢占处理资格,避免并发请求重复发货。
|
||||||
|
*/
|
||||||
|
public boolean prepareGrant(String benefitType, GooglePlayPurchaseSnapshot snapshot, GooglePlayOrder order) {
|
||||||
|
if (!requiresGrantGuard(benefitType, snapshot) || order.getId() == null) {
|
||||||
|
order.setDeliveryOwnershipGranted(true);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (GooglePlayConstants.DELIVERY_DELIVERED.equals(order.getDeliveryStatus())) {
|
||||||
|
order.setDeliveryOwnershipGranted(false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Date now = new Date();
|
||||||
|
int updated = orderMapper.updateDeliveryStatusIfMatch(
|
||||||
|
order.getId(),
|
||||||
|
GooglePlayConstants.DELIVERY_PENDING,
|
||||||
|
GooglePlayConstants.DELIVERY_PROCESSING,
|
||||||
|
now
|
||||||
|
);
|
||||||
|
if (updated == 1) {
|
||||||
|
order.setDeliveryStatus(GooglePlayConstants.DELIVERY_PROCESSING);
|
||||||
|
order.setDeliveryOwnershipGranted(true);
|
||||||
|
order.setUpdatedAt(now);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
refreshDeliveryState(order);
|
||||||
|
order.setDeliveryOwnershipGranted(false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean requiresGrantGuard(String benefitType, GooglePlayPurchaseSnapshot snapshot) {
|
||||||
|
if (!GooglePlayConstants.PRODUCT_TYPE_ONE_TIME.equals(snapshot.getProductType())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (GooglePlayConstants.ENTITLEMENT_NON_CONSUMABLE.equals(benefitType)) {
|
||||||
|
return GooglePlayConstants.STATE_ACTIVE.equals(snapshot.getState())
|
||||||
|
|| GooglePlayConstants.STATE_CANCELED.equals(snapshot.getState());
|
||||||
|
}
|
||||||
|
return GooglePlayConstants.STATE_ACTIVE.equals(snapshot.getState());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void refreshDeliveryState(GooglePlayOrder order) {
|
||||||
|
GooglePlayOrder latestOrder = orderMapper.selectById(order.getId());
|
||||||
|
if (latestOrder == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
order.setDeliveryStatus(latestOrder.getDeliveryStatus());
|
||||||
|
order.setGrantedQuantity(latestOrder.getGrantedQuantity());
|
||||||
|
order.setUpdatedAt(latestOrder.getUpdatedAt());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package com.yolo.keyborad.googleplay;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.yolo.keyborad.common.ErrorCode;
|
||||||
|
import com.yolo.keyborad.config.GooglePlayProperties;
|
||||||
|
import com.yolo.keyborad.exception.BusinessException;
|
||||||
|
import com.yolo.keyborad.model.dto.googleplay.GooglePlayPubSubPushRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class GooglePlayPubSubAuthService {
|
||||||
|
|
||||||
|
private final GooglePlayProperties properties;
|
||||||
|
private final GooglePlayApiClient apiClient;
|
||||||
|
|
||||||
|
public void verify(HttpServletRequest request, GooglePlayPubSubPushRequest pushRequest) {
|
||||||
|
// verifyTopic(request);
|
||||||
|
verifySubscription(pushRequest);
|
||||||
|
if (!properties.isValidatePubsubJwt()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String bearerToken = resolveBearerToken(request);
|
||||||
|
JsonNode tokenInfo = apiClient.verifyPubSubJwt(bearerToken);
|
||||||
|
verifyAudience(tokenInfo);
|
||||||
|
verifyEmail(tokenInfo);
|
||||||
|
verifyIssuer(tokenInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void verifyTopic(HttpServletRequest request) {
|
||||||
|
String expectedTopic = properties.getPubsub().getExpectedTopic();
|
||||||
|
if (expectedTopic == null || expectedTopic.isBlank()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String currentTopic = request.getHeader("projects/keyboard-490601/topics/keyboard_topic");
|
||||||
|
if (!expectedTopic.equals(currentTopic)) {
|
||||||
|
throw new BusinessException(ErrorCode.GOOGLE_PLAY_WEBHOOK_UNAUTHORIZED, "Pub/Sub topic 不匹配");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void verifySubscription(GooglePlayPubSubPushRequest pushRequest) {
|
||||||
|
String expectedSubscription = properties.getPubsub().getExpectedSubscription();
|
||||||
|
if (expectedSubscription == null || expectedSubscription.isBlank()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!expectedSubscription.equals(pushRequest.getSubscription())) {
|
||||||
|
throw new BusinessException(ErrorCode.GOOGLE_PLAY_WEBHOOK_UNAUTHORIZED, "Pub/Sub subscription 不匹配");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveBearerToken(HttpServletRequest request) {
|
||||||
|
String authorization = request.getHeader("Authorization");
|
||||||
|
if (authorization == null || !authorization.startsWith("Bearer ")) {
|
||||||
|
throw new BusinessException(ErrorCode.GOOGLE_PLAY_WEBHOOK_UNAUTHORIZED, "缺少 Pub/Sub Bearer Token");
|
||||||
|
}
|
||||||
|
return authorization.substring("Bearer ".length());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void verifyAudience(JsonNode tokenInfo) {
|
||||||
|
String expectedAudience = properties.getPubsub().getAudience();
|
||||||
|
if (expectedAudience == null || expectedAudience.isBlank()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!expectedAudience.equalsIgnoreCase(tokenInfo.path("aud").asText())) {
|
||||||
|
throw new BusinessException(ErrorCode.GOOGLE_PLAY_WEBHOOK_UNAUTHORIZED, "Pub/Sub audience 不匹配");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void verifyEmail(JsonNode tokenInfo) {
|
||||||
|
if (!tokenInfo.path("email_verified").asBoolean(false)) {
|
||||||
|
throw new BusinessException(ErrorCode.GOOGLE_PLAY_WEBHOOK_UNAUTHORIZED, "Pub/Sub 邮箱未验证");
|
||||||
|
}
|
||||||
|
String expectedEmail = properties.getPubsub().getServiceAccountEmail();
|
||||||
|
if (expectedEmail == null || expectedEmail.isBlank()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!expectedEmail.equalsIgnoreCase(tokenInfo.path("email").asText())) {
|
||||||
|
throw new BusinessException(ErrorCode.GOOGLE_PLAY_WEBHOOK_UNAUTHORIZED, "Pub/Sub service account 不匹配");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void verifyIssuer(JsonNode tokenInfo) {
|
||||||
|
String issuer = tokenInfo.path("iss").asText();
|
||||||
|
boolean valid = "https://accounts.google.com".equals(issuer) || "accounts.google.com".equals(issuer);
|
||||||
|
if (!valid) {
|
||||||
|
throw new BusinessException(ErrorCode.GOOGLE_PLAY_WEBHOOK_UNAUTHORIZED, "Pub/Sub issuer 非法");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package com.yolo.keyborad.googleplay;
|
||||||
|
|
||||||
|
import com.yolo.keyborad.googleplay.model.GooglePlayPurchaseSnapshot;
|
||||||
|
import com.yolo.keyborad.model.entity.KeyboardProductItems;
|
||||||
|
import com.yolo.keyborad.model.entity.KeyboardUserPurchaseRecords;
|
||||||
|
import com.yolo.keyborad.model.entity.googleplay.GooglePlayOrder;
|
||||||
|
import com.yolo.keyborad.model.entity.googleplay.GooglePlayUserEntitlement;
|
||||||
|
import com.yolo.keyborad.service.KeyboardUserPurchaseRecordsService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class GooglePlayPurchaseRecordService {
|
||||||
|
|
||||||
|
private static final String PURCHASE_STATUS_PAID = "PAID";
|
||||||
|
|
||||||
|
private final KeyboardUserPurchaseRecordsService purchaseRecordsService;
|
||||||
|
|
||||||
|
public void recordSuccess(Long userId,
|
||||||
|
KeyboardProductItems product,
|
||||||
|
GooglePlayPurchaseSnapshot snapshot,
|
||||||
|
GooglePlayOrder order,
|
||||||
|
GooglePlayUserEntitlement entitlement) {
|
||||||
|
if (!shouldRecord(userId, order, entitlement)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String transactionId = resolveTransactionId(order);
|
||||||
|
if (alreadyRecorded(transactionId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
purchaseRecordsService.save(buildRecord(userId, product, snapshot, order, transactionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean shouldRecord(Long userId,
|
||||||
|
GooglePlayOrder order,
|
||||||
|
GooglePlayUserEntitlement entitlement) {
|
||||||
|
return userId != null
|
||||||
|
&& order != null
|
||||||
|
&& entitlement != null
|
||||||
|
&& Boolean.TRUE.equals(entitlement.getActive())
|
||||||
|
&& !GooglePlayConstants.DELIVERY_REVOKED.equals(order.getDeliveryStatus())
|
||||||
|
&& !GooglePlayConstants.DELIVERY_MANUAL_REVIEW.equals(order.getDeliveryStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean alreadyRecorded(String transactionId) {
|
||||||
|
return purchaseRecordsService.lambdaQuery()
|
||||||
|
.eq(KeyboardUserPurchaseRecords::getTransactionId, transactionId)
|
||||||
|
.eq(KeyboardUserPurchaseRecords::getPaymentMethod, GooglePlayConstants.PAYMENT_METHOD_GOOGLE_PLAY)
|
||||||
|
.exists();
|
||||||
|
}
|
||||||
|
|
||||||
|
private KeyboardUserPurchaseRecords buildRecord(Long userId,
|
||||||
|
KeyboardProductItems product,
|
||||||
|
GooglePlayPurchaseSnapshot snapshot,
|
||||||
|
GooglePlayOrder order,
|
||||||
|
String transactionId) {
|
||||||
|
KeyboardUserPurchaseRecords record = new KeyboardUserPurchaseRecords();
|
||||||
|
record.setUserId(Math.toIntExact(userId));
|
||||||
|
record.setProductId(product.getProductId());
|
||||||
|
record.setPurchaseQuantity(resolvePurchaseQuantity(product, snapshot, order));
|
||||||
|
record.setPrice(product.getPrice());
|
||||||
|
record.setCurrency(product.getCurrency());
|
||||||
|
record.setPurchaseType(resolvePurchaseType(product, snapshot));
|
||||||
|
Date purchaseTime = resolvePurchaseTime(snapshot, order);
|
||||||
|
record.setPurchaseTime(purchaseTime);
|
||||||
|
record.setStatus(PURCHASE_STATUS_PAID);
|
||||||
|
record.setPaymentMethod(GooglePlayConstants.PAYMENT_METHOD_GOOGLE_PLAY);
|
||||||
|
record.setTransactionId(transactionId);
|
||||||
|
record.setOriginalTransactionId(order.getPurchaseToken());
|
||||||
|
record.setProductIds(new String[]{product.getProductId()});
|
||||||
|
record.setPurchaseDate(purchaseTime);
|
||||||
|
record.setExpiresDate(snapshot.getExpiryTime());
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Integer resolvePurchaseQuantity(KeyboardProductItems product,
|
||||||
|
GooglePlayPurchaseSnapshot snapshot,
|
||||||
|
GooglePlayOrder order) {
|
||||||
|
Integer configured = product.getDurationValue();
|
||||||
|
if (configured != null) {
|
||||||
|
return configured;
|
||||||
|
}
|
||||||
|
Integer parsed = parseInteger(product.getName());
|
||||||
|
if (parsed != null) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
if (snapshot.getQuantity() != null) {
|
||||||
|
return snapshot.getQuantity();
|
||||||
|
}
|
||||||
|
return Objects.requireNonNullElse(order.getQuantity(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Integer parseInteger(String raw) {
|
||||||
|
if (raw == null || raw.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String digits = raw.replaceAll("[^\\d]", "");
|
||||||
|
if (digits.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Integer.parseInt(digits);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolvePurchaseType(KeyboardProductItems product, GooglePlayPurchaseSnapshot snapshot) {
|
||||||
|
if (product.getType() != null && !product.getType().isBlank()) {
|
||||||
|
return product.getType();
|
||||||
|
}
|
||||||
|
if (GooglePlayConstants.PRODUCT_TYPE_SUBSCRIPTION.equals(snapshot.getProductType())) {
|
||||||
|
return "subscription";
|
||||||
|
}
|
||||||
|
return "in-app-purchase";
|
||||||
|
}
|
||||||
|
|
||||||
|
private Date resolvePurchaseTime(GooglePlayPurchaseSnapshot snapshot, GooglePlayOrder order) {
|
||||||
|
if (snapshot.getStartTime() != null) {
|
||||||
|
return snapshot.getStartTime();
|
||||||
|
}
|
||||||
|
if (order.getLastEventTime() != null) {
|
||||||
|
return order.getLastEventTime();
|
||||||
|
}
|
||||||
|
if (snapshot.getLastSyncedAt() != null) {
|
||||||
|
return snapshot.getLastSyncedAt();
|
||||||
|
}
|
||||||
|
return new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveTransactionId(GooglePlayOrder order) {
|
||||||
|
if (order.getGoogleOrderId() != null && !order.getGoogleOrderId().isBlank()) {
|
||||||
|
return order.getGoogleOrderId();
|
||||||
|
}
|
||||||
|
return order.getOrderKey();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package com.yolo.keyborad.googleplay;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.yolo.keyborad.common.ErrorCode;
|
||||||
|
import com.yolo.keyborad.exception.BusinessException;
|
||||||
|
import com.yolo.keyborad.mapper.GooglePlayRtdnEventMapper;
|
||||||
|
import com.yolo.keyborad.model.dto.googleplay.GooglePlayPubSubPushRequest;
|
||||||
|
import com.yolo.keyborad.model.entity.googleplay.GooglePlayRtdnEvent;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class GooglePlayRtdnEventService {
|
||||||
|
|
||||||
|
private final GooglePlayRtdnEventMapper rtdnEventMapper;
|
||||||
|
private final GooglePlayNotificationSupport notificationSupport;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
public GooglePlayRtdnEvent upsertEvent(GooglePlayPubSubPushRequest pushRequest,
|
||||||
|
GooglePlayPubSubPushRequest.DeveloperNotification notification) {
|
||||||
|
String messageId = pushRequest.getMessage().getMessageId();
|
||||||
|
GooglePlayRtdnEvent event = rtdnEventMapper.selectOne(Wrappers.<GooglePlayRtdnEvent>lambdaQuery()
|
||||||
|
.eq(GooglePlayRtdnEvent::getMessageId, messageId)
|
||||||
|
.last("LIMIT 1"));
|
||||||
|
if (event == null) {
|
||||||
|
event = new GooglePlayRtdnEvent();
|
||||||
|
event.setMessageId(messageId);
|
||||||
|
event.setRetryCount(0);
|
||||||
|
event.setCreatedAt(new Date());
|
||||||
|
} else if (GooglePlayConstants.EVENT_PROCESSED.equals(event.getStatus())) {
|
||||||
|
return event;
|
||||||
|
} else {
|
||||||
|
event.setRetryCount(event.getRetryCount() == null ? 1 : event.getRetryCount() + 1);
|
||||||
|
}
|
||||||
|
fillEvent(event, pushRequest, notification);
|
||||||
|
saveEvent(event);
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void markProcessed(GooglePlayRtdnEvent event) {
|
||||||
|
mark(event, GooglePlayConstants.EVENT_PROCESSED, null, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void markIgnored(GooglePlayRtdnEvent event) {
|
||||||
|
mark(event, GooglePlayConstants.EVENT_IGNORED, null, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void markFailed(GooglePlayRtdnEvent event, Exception e) {
|
||||||
|
mark(event, GooglePlayConstants.EVENT_FAILED, e.getMessage(), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void fillEvent(GooglePlayRtdnEvent event,
|
||||||
|
GooglePlayPubSubPushRequest pushRequest,
|
||||||
|
GooglePlayPubSubPushRequest.DeveloperNotification notification) {
|
||||||
|
event.setSubscriptionName(pushRequest.getSubscription());
|
||||||
|
event.setPackageName(notification.getPackageName());
|
||||||
|
event.setEventType(notificationSupport.resolveEventType(notification));
|
||||||
|
event.setNotificationType(notificationSupport.resolveNotificationType(notification));
|
||||||
|
event.setNotificationName(notificationSupport.resolveNotificationName(notification));
|
||||||
|
event.setPurchaseToken(notificationSupport.resolvePurchaseToken(notification));
|
||||||
|
event.setProductId(notificationSupport.resolveProductId(notification));
|
||||||
|
event.setOrderId(notification.getVoidedPurchaseNotification() == null ? null : notification.getVoidedPurchaseNotification().getOrderId());
|
||||||
|
event.setEventTime(notificationSupport.resolveEventTime(notification.getEventTimeMillis()));
|
||||||
|
event.setStatus(GooglePlayConstants.EVENT_RECEIVED);
|
||||||
|
event.setRawEnvelope(writeJson(pushRequest));
|
||||||
|
event.setRawPayload(writeJson(notification));
|
||||||
|
event.setErrorMessage(null);
|
||||||
|
event.setProcessedAt(null);
|
||||||
|
event.setUpdatedAt(new Date());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void mark(GooglePlayRtdnEvent event, String status, String errorMessage, boolean processed) {
|
||||||
|
event.setStatus(status);
|
||||||
|
event.setErrorMessage(errorMessage);
|
||||||
|
event.setProcessedAt(processed ? new Date() : null);
|
||||||
|
event.setUpdatedAt(new Date());
|
||||||
|
saveEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void saveEvent(GooglePlayRtdnEvent event) {
|
||||||
|
if (event.getId() == null) {
|
||||||
|
rtdnEventMapper.insert(event);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rtdnEventMapper.updateById(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String writeJson(Object value) {
|
||||||
|
try {
|
||||||
|
return objectMapper.writeValueAsString(value);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new BusinessException(ErrorCode.OPERATION_ERROR, "JSON 序列化失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package com.yolo.keyborad.googleplay;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.yolo.keyborad.config.GooglePlayProperties;
|
||||||
|
import io.jsonwebtoken.Jwts;
|
||||||
|
import io.jsonwebtoken.SignatureAlgorithm;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.core.io.Resource;
|
||||||
|
import org.springframework.core.io.ResourceLoader;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.URLEncoder;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.KeyFactory;
|
||||||
|
import java.security.PrivateKey;
|
||||||
|
import java.security.spec.PKCS8EncodedKeySpec;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class GooglePlayServiceAccountTokenProvider {
|
||||||
|
|
||||||
|
private static final long ASSERTION_EXPIRE_SECONDS = 3600;
|
||||||
|
private static final long REFRESH_EARLY_SECONDS = 300;
|
||||||
|
|
||||||
|
private final GooglePlayProperties properties;
|
||||||
|
private final ResourceLoader resourceLoader;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final HttpClient googlePlayHttpClient;
|
||||||
|
|
||||||
|
private volatile CachedToken cachedToken;
|
||||||
|
private volatile ServiceAccountCredentials credentials;
|
||||||
|
|
||||||
|
public String getAccessToken() {
|
||||||
|
CachedToken token = cachedToken;
|
||||||
|
if (token != null && token.expiresAt().isAfter(Instant.now().plusSeconds(REFRESH_EARLY_SECONDS))) {
|
||||||
|
return token.token();
|
||||||
|
}
|
||||||
|
synchronized (this) {
|
||||||
|
token = cachedToken;
|
||||||
|
if (token != null && token.expiresAt().isAfter(Instant.now().plusSeconds(REFRESH_EARLY_SECONDS))) {
|
||||||
|
return token.token();
|
||||||
|
}
|
||||||
|
cachedToken = fetchAccessToken(loadCredentials());
|
||||||
|
return cachedToken.token();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private CachedToken fetchAccessToken(ServiceAccountCredentials account) {
|
||||||
|
try {
|
||||||
|
String assertion = buildAssertion(account);
|
||||||
|
String body = "grant_type=" + encode("urn:ietf:params:oauth:grant-type:jwt-bearer")
|
||||||
|
+ "&assertion=" + encode(assertion);
|
||||||
|
HttpRequest request = HttpRequest.newBuilder(URI.create(properties.getOauthTokenUri()))
|
||||||
|
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofString(body))
|
||||||
|
.build();
|
||||||
|
HttpResponse<String> response = googlePlayHttpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
|
if (response.statusCode() / 100 != 2) {
|
||||||
|
throw new GooglePlayApiException(response.statusCode(), response.body());
|
||||||
|
}
|
||||||
|
JsonNode jsonNode = objectMapper.readTree(response.body());
|
||||||
|
String accessToken = jsonNode.path("access_token").asText();
|
||||||
|
long expiresIn = jsonNode.path("expires_in").asLong(ASSERTION_EXPIRE_SECONDS);
|
||||||
|
return new CachedToken(accessToken, Instant.now().plusSeconds(expiresIn));
|
||||||
|
} catch (GooglePlayApiException e) {
|
||||||
|
throw e;
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new GooglePlayApiException("获取 Google Play access token 失败", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildAssertion(ServiceAccountCredentials account) {
|
||||||
|
Instant now = Instant.now();
|
||||||
|
return Jwts.builder()
|
||||||
|
.setHeaderParam("kid", account.privateKeyId())
|
||||||
|
.setIssuer(account.clientEmail())
|
||||||
|
.setAudience(properties.getOauthTokenUri())
|
||||||
|
.claim("scope", properties.getAndroidPublisherScope())
|
||||||
|
.setIssuedAt(Date.from(now))
|
||||||
|
.setExpiration(Date.from(now.plusSeconds(ASSERTION_EXPIRE_SECONDS)))
|
||||||
|
.signWith(account.privateKey(), SignatureAlgorithm.RS256)
|
||||||
|
.compact();
|
||||||
|
}
|
||||||
|
|
||||||
|
private ServiceAccountCredentials loadCredentials() {
|
||||||
|
ServiceAccountCredentials account = credentials;
|
||||||
|
if (account != null) {
|
||||||
|
return account;
|
||||||
|
}
|
||||||
|
synchronized (this) {
|
||||||
|
account = credentials;
|
||||||
|
if (account != null) {
|
||||||
|
return account;
|
||||||
|
}
|
||||||
|
credentials = readCredentials();
|
||||||
|
return credentials;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ServiceAccountCredentials readCredentials() {
|
||||||
|
if (properties.getServiceAccountKeyPath() == null || properties.getServiceAccountKeyPath().isBlank()) {
|
||||||
|
throw new IllegalStateException("google.play.service-account-key-path 未配置");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Resource resource = resourceLoader.getResource(properties.getServiceAccountKeyPath());
|
||||||
|
JsonNode jsonNode = objectMapper.readTree(resource.getInputStream());
|
||||||
|
String clientEmail = jsonNode.path("client_email").asText();
|
||||||
|
String privateKeyId = jsonNode.path("private_key_id").asText();
|
||||||
|
String privateKeyPem = jsonNode.path("private_key").asText();
|
||||||
|
return new ServiceAccountCredentials(clientEmail, privateKeyId, parsePrivateKey(privateKeyPem));
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new GooglePlayApiException("读取 Google Play service account 失败", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private PrivateKey parsePrivateKey(String privateKeyPem) throws Exception {
|
||||||
|
String sanitized = privateKeyPem
|
||||||
|
.replace("-----BEGIN PRIVATE KEY-----", "")
|
||||||
|
.replace("-----END PRIVATE KEY-----", "")
|
||||||
|
.replaceAll("\\s+", "");
|
||||||
|
byte[] decoded = Base64.getDecoder().decode(sanitized);
|
||||||
|
PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(decoded);
|
||||||
|
return KeyFactory.getInstance("RSA").generatePrivate(spec);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String encode(String raw) {
|
||||||
|
return URLEncoder.encode(raw, StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
|
||||||
|
private record CachedToken(String token, Instant expiresAt) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private record ServiceAccountCredentials(String clientEmail, String privateKeyId, PrivateKey privateKey) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
package com.yolo.keyborad.googleplay;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||||
|
import com.yolo.keyborad.common.ErrorCode;
|
||||||
|
import com.yolo.keyborad.exception.BusinessException;
|
||||||
|
import com.yolo.keyborad.googleplay.model.GooglePlayPurchaseSnapshot;
|
||||||
|
import com.yolo.keyborad.googleplay.model.GooglePlaySyncCommand;
|
||||||
|
import com.yolo.keyborad.googleplay.model.GooglePlaySyncResult;
|
||||||
|
import com.yolo.keyborad.mapper.GooglePlayOrderMapper;
|
||||||
|
import com.yolo.keyborad.mapper.GooglePlayPurchaseTokenMapper;
|
||||||
|
import com.yolo.keyborad.model.entity.KeyboardProductItems;
|
||||||
|
import com.yolo.keyborad.model.entity.googleplay.GooglePlayOrder;
|
||||||
|
import com.yolo.keyborad.model.entity.googleplay.GooglePlayPurchaseToken;
|
||||||
|
import com.yolo.keyborad.model.entity.googleplay.GooglePlayUserEntitlement;
|
||||||
|
import com.yolo.keyborad.service.KeyboardProductItemsService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class GooglePlayStateService {
|
||||||
|
|
||||||
|
private final GooglePlayOrderMapper orderMapper;
|
||||||
|
private final GooglePlayPurchaseTokenMapper purchaseTokenMapper;
|
||||||
|
private final KeyboardProductItemsService productItemsService;
|
||||||
|
private final GooglePlayEntitlementApplier entitlementApplier;
|
||||||
|
private final GooglePlayPurchaseRecordService purchaseRecordService;
|
||||||
|
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
public GooglePlaySyncResult sync(GooglePlaySyncCommand command, GooglePlayPurchaseSnapshot snapshot) {
|
||||||
|
String productId = (snapshot.getBasePlanId() != null) ?
|
||||||
|
snapshot.getBasePlanId() :
|
||||||
|
snapshot.getPurchaseOptionId();
|
||||||
|
KeyboardProductItems product = loadProduct(productId);
|
||||||
|
GooglePlayOrder order = buildOrder(command, snapshot);
|
||||||
|
GooglePlayPurchaseToken token = buildToken(command, snapshot);
|
||||||
|
persistOrderIfNew(order);
|
||||||
|
GooglePlayUserEntitlement entitlement = null;
|
||||||
|
if (command.getUserId() != null) {
|
||||||
|
entitlement = entitlementApplier.apply(command.getUserId(), product, snapshot, order);
|
||||||
|
purchaseRecordService.recordSuccess(command.getUserId(), product, snapshot, order, entitlement);
|
||||||
|
}
|
||||||
|
// 再次保存以持久化 apply() 中修改的 deliveryStatus/grantedQuantity
|
||||||
|
saveOrder(order);
|
||||||
|
token.setLatestOrderKey(order.getOrderKey());
|
||||||
|
token.setLatestOrderId(order.getGoogleOrderId());
|
||||||
|
saveToken(token);
|
||||||
|
return GooglePlaySyncResult.builder()
|
||||||
|
.order(order)
|
||||||
|
.token(token)
|
||||||
|
.entitlement(entitlement)
|
||||||
|
.acknowledgeRequired(requiresAcknowledge(snapshot, command.getUserId(), order))
|
||||||
|
.consumeRequired(requiresConsume(snapshot, entitlement, command.getUserId(), order))
|
||||||
|
.linkedPurchaseTokenToSync(resolveLinkedToken(snapshot))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
public void markAcknowledged(String purchaseToken, String orderKey) {
|
||||||
|
updateAckState(purchaseToken, orderKey, GooglePlayConstants.ACK_ACKNOWLEDGED);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
public void markConsumed(String purchaseToken, String orderKey) {
|
||||||
|
updateConsumptionState(purchaseToken, orderKey, GooglePlayConstants.CONSUMPTION_CONSUMED);
|
||||||
|
}
|
||||||
|
|
||||||
|
public GooglePlayPurchaseToken findToken(String purchaseToken) {
|
||||||
|
return purchaseTokenMapper.selectOne(Wrappers.<GooglePlayPurchaseToken>lambdaQuery()
|
||||||
|
.eq(GooglePlayPurchaseToken::getPurchaseToken, purchaseToken)
|
||||||
|
.last("LIMIT 1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private KeyboardProductItems loadProduct(String productId) {
|
||||||
|
KeyboardProductItems product = productItemsService.getProductEntityByProductId(productId);
|
||||||
|
if (product == null) {
|
||||||
|
throw new BusinessException(ErrorCode.PRODUCT_NOT_FOUND, "Google Play 商品不存在: " + productId);
|
||||||
|
}
|
||||||
|
return product;
|
||||||
|
}
|
||||||
|
|
||||||
|
private GooglePlayOrder buildOrder(GooglePlaySyncCommand command, GooglePlayPurchaseSnapshot snapshot) {
|
||||||
|
GooglePlayOrder order = orderMapper.selectOne(Wrappers.<GooglePlayOrder>lambdaQuery()
|
||||||
|
.eq(GooglePlayOrder::getOrderKey, snapshot.getOrderKey())
|
||||||
|
.last("LIMIT 1"));
|
||||||
|
if (order == null) {
|
||||||
|
order = new GooglePlayOrder();
|
||||||
|
order.setCreatedAt(new Date());
|
||||||
|
order.setDeliveryStatus(defaultDeliveryStatus(snapshot));
|
||||||
|
order.setGrantedQuantity(BigDecimal.ZERO);
|
||||||
|
}
|
||||||
|
order.setUserId(command.getUserId());
|
||||||
|
order.setPackageName(snapshot.getPackageName());
|
||||||
|
order.setProductId(snapshot.getProductId());
|
||||||
|
order.setProductType(snapshot.getProductType());
|
||||||
|
order.setPurchaseToken(snapshot.getPurchaseToken());
|
||||||
|
order.setOrderKey(snapshot.getOrderKey());
|
||||||
|
order.setGoogleOrderId(snapshot.getGoogleOrderId());
|
||||||
|
order.setLinkedPurchaseToken(snapshot.getLinkedPurchaseToken());
|
||||||
|
order.setOrderState(snapshot.getState());
|
||||||
|
order.setAcknowledgementState(snapshot.getAcknowledgementState());
|
||||||
|
order.setConsumptionState(snapshot.getConsumptionState());
|
||||||
|
order.setQuantity(snapshot.getQuantity());
|
||||||
|
order.setRefundableQuantity(snapshot.getRefundableQuantity());
|
||||||
|
order.setEntitlementStartTime(snapshot.getStartTime());
|
||||||
|
order.setEntitlementEndTime(snapshot.getExpiryTime());
|
||||||
|
order.setLastEventTime(command.getEventTime());
|
||||||
|
order.setLastSyncedAt(snapshot.getLastSyncedAt());
|
||||||
|
order.setRawResponse(snapshot.getRawResponse());
|
||||||
|
order.setUpdatedAt(new Date());
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
|
||||||
|
private GooglePlayPurchaseToken buildToken(GooglePlaySyncCommand command, GooglePlayPurchaseSnapshot snapshot) {
|
||||||
|
GooglePlayPurchaseToken token = findToken(snapshot.getPurchaseToken());
|
||||||
|
if (token == null) {
|
||||||
|
token = new GooglePlayPurchaseToken();
|
||||||
|
token.setCreatedAt(new Date());
|
||||||
|
}
|
||||||
|
token.setPurchaseToken(snapshot.getPurchaseToken());
|
||||||
|
token.setLinkedPurchaseToken(snapshot.getLinkedPurchaseToken());
|
||||||
|
token.setUserId(command.getUserId());
|
||||||
|
token.setPackageName(snapshot.getPackageName());
|
||||||
|
token.setProductId(snapshot.getProductId());
|
||||||
|
token.setProductType(snapshot.getProductType());
|
||||||
|
token.setTokenState(snapshot.getState());
|
||||||
|
token.setAcknowledgementState(snapshot.getAcknowledgementState());
|
||||||
|
token.setConsumptionState(snapshot.getConsumptionState());
|
||||||
|
token.setAutoRenewEnabled(snapshot.getAutoRenewEnabled());
|
||||||
|
token.setExternalAccountId(snapshot.getExternalAccountId());
|
||||||
|
token.setExternalProfileId(snapshot.getExternalProfileId());
|
||||||
|
token.setRegionCode(snapshot.getRegionCode());
|
||||||
|
token.setStartTime(snapshot.getStartTime());
|
||||||
|
token.setExpiryTime(snapshot.getExpiryTime());
|
||||||
|
token.setAutoResumeTime(snapshot.getAutoResumeTime());
|
||||||
|
token.setCanceledStateReason(snapshot.getCanceledStateReason());
|
||||||
|
token.setLastEventType(command.getNotificationName());
|
||||||
|
token.setLastEventTime(command.getEventTime());
|
||||||
|
token.setLastSyncedAt(snapshot.getLastSyncedAt());
|
||||||
|
token.setRawResponse(snapshot.getRawResponse());
|
||||||
|
token.setUpdatedAt(new Date());
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean requiresAcknowledge(GooglePlayPurchaseSnapshot snapshot, Long userId, GooglePlayOrder order) {
|
||||||
|
if (userId == null
|
||||||
|
|| !GooglePlayConstants.ACK_PENDING.equals(snapshot.getAcknowledgementState())
|
||||||
|
|| !GooglePlayConstants.STATE_ACTIVE.equals(snapshot.getState())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!GooglePlayConstants.PRODUCT_TYPE_ONE_TIME.equals(snapshot.getProductType())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return Boolean.TRUE.equals(order.getDeliveryOwnershipGranted());
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean requiresConsume(GooglePlayPurchaseSnapshot snapshot,
|
||||||
|
GooglePlayUserEntitlement entitlement,
|
||||||
|
Long userId,
|
||||||
|
GooglePlayOrder order) {
|
||||||
|
if (userId == null || entitlement == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
boolean oneTime = GooglePlayConstants.PRODUCT_TYPE_ONE_TIME.equals(snapshot.getProductType());
|
||||||
|
boolean wallet = GooglePlayConstants.ENTITLEMENT_WALLET_TOP_UP.equals(entitlement.getBenefitType());
|
||||||
|
boolean active = GooglePlayConstants.STATE_ACTIVE.equals(snapshot.getState());
|
||||||
|
boolean pending = GooglePlayConstants.CONSUMPTION_PENDING.equals(snapshot.getConsumptionState());
|
||||||
|
boolean owned = Boolean.TRUE.equals(order.getDeliveryOwnershipGranted());
|
||||||
|
return oneTime && wallet && active && pending && owned;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveLinkedToken(GooglePlayPurchaseSnapshot snapshot) {
|
||||||
|
if (!GooglePlayConstants.STATE_PENDING_PURCHASE_CANCELED.equals(snapshot.getState())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return snapshot.getLinkedPurchaseToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void saveOrder(GooglePlayOrder order) {
|
||||||
|
if (order.getId() == null) {
|
||||||
|
insertOrder(order);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
orderMapper.updateById(order);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仅在新订单场景预落库,避免并发请求用旧快照把发货状态回写成 PENDING。
|
||||||
|
*/
|
||||||
|
private void persistOrderIfNew(GooglePlayOrder order) {
|
||||||
|
if (order.getId() == null) {
|
||||||
|
saveOrder(order);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void saveToken(GooglePlayPurchaseToken token) {
|
||||||
|
if (token.getId() == null) {
|
||||||
|
insertToken(token);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
purchaseTokenMapper.updateById(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 并发首写同一订单时,唯一键冲突后回读已存在记录继续流程。
|
||||||
|
*/
|
||||||
|
private void insertOrder(GooglePlayOrder order) {
|
||||||
|
try {
|
||||||
|
orderMapper.insert(order);
|
||||||
|
} catch (DuplicateKeyException e) {
|
||||||
|
GooglePlayOrder existingOrder = orderMapper.selectOne(Wrappers.<GooglePlayOrder>lambdaQuery()
|
||||||
|
.eq(GooglePlayOrder::getOrderKey, order.getOrderKey())
|
||||||
|
.last("LIMIT 1"));
|
||||||
|
if (existingOrder == null) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
order.setId(existingOrder.getId());
|
||||||
|
order.setCreatedAt(existingOrder.getCreatedAt());
|
||||||
|
order.setDeliveryStatus(existingOrder.getDeliveryStatus());
|
||||||
|
order.setGrantedQuantity(existingOrder.getGrantedQuantity());
|
||||||
|
orderMapper.updateById(order);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* purchaseToken 首次并发写入时回读已有记录,避免重复插入直接失败。
|
||||||
|
*/
|
||||||
|
private void insertToken(GooglePlayPurchaseToken token) {
|
||||||
|
try {
|
||||||
|
purchaseTokenMapper.insert(token);
|
||||||
|
} catch (DuplicateKeyException e) {
|
||||||
|
GooglePlayPurchaseToken existingToken = findToken(token.getPurchaseToken());
|
||||||
|
if (existingToken == null) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
token.setId(existingToken.getId());
|
||||||
|
token.setCreatedAt(existingToken.getCreatedAt());
|
||||||
|
purchaseTokenMapper.updateById(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateAckState(String purchaseToken, String orderKey, String state) {
|
||||||
|
GooglePlayPurchaseToken token = findToken(purchaseToken);
|
||||||
|
if (token != null) {
|
||||||
|
token.setAcknowledgementState(state);
|
||||||
|
token.setUpdatedAt(new Date());
|
||||||
|
purchaseTokenMapper.updateById(token);
|
||||||
|
}
|
||||||
|
GooglePlayOrder order = orderMapper.selectOne(Wrappers.<GooglePlayOrder>lambdaQuery()
|
||||||
|
.eq(GooglePlayOrder::getOrderKey, orderKey)
|
||||||
|
.last("LIMIT 1"));
|
||||||
|
if (order == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
order.setAcknowledgementState(state);
|
||||||
|
order.setUpdatedAt(new Date());
|
||||||
|
orderMapper.updateById(order);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateConsumptionState(String purchaseToken, String orderKey, String state) {
|
||||||
|
GooglePlayPurchaseToken token = findToken(purchaseToken);
|
||||||
|
if (token != null) {
|
||||||
|
token.setConsumptionState(state);
|
||||||
|
token.setUpdatedAt(new Date());
|
||||||
|
purchaseTokenMapper.updateById(token);
|
||||||
|
}
|
||||||
|
GooglePlayOrder order = orderMapper.selectOne(Wrappers.<GooglePlayOrder>lambdaQuery()
|
||||||
|
.eq(GooglePlayOrder::getOrderKey, orderKey)
|
||||||
|
.last("LIMIT 1"));
|
||||||
|
if (order == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
order.setConsumptionState(state);
|
||||||
|
order.setUpdatedAt(new Date());
|
||||||
|
orderMapper.updateById(order);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String defaultDeliveryStatus(GooglePlayPurchaseSnapshot snapshot) {
|
||||||
|
if (GooglePlayConstants.PRODUCT_TYPE_SUBSCRIPTION.equals(snapshot.getProductType())) {
|
||||||
|
return GooglePlayConstants.DELIVERY_NOT_REQUIRED;
|
||||||
|
}
|
||||||
|
return GooglePlayConstants.DELIVERY_PENDING;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package com.yolo.keyborad.googleplay;
|
||||||
|
|
||||||
|
import com.yolo.keyborad.common.ErrorCode;
|
||||||
|
import com.yolo.keyborad.exception.BusinessException;
|
||||||
|
import com.yolo.keyborad.model.entity.KeyboardProductItems;
|
||||||
|
import com.yolo.keyborad.model.entity.KeyboardUser;
|
||||||
|
import com.yolo.keyborad.service.UserService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class GooglePlayVipBenefitService {
|
||||||
|
|
||||||
|
private final UserService userService;
|
||||||
|
|
||||||
|
public void activate(Long userId, KeyboardProductItems product, Date expiry) {
|
||||||
|
KeyboardUser user = requireUser(userId);
|
||||||
|
user.setIsVip(true);
|
||||||
|
user.setVipLevel(product.getLevel());
|
||||||
|
user.setVipExpiry(expiry);
|
||||||
|
if (!userService.updateById(user)) {
|
||||||
|
throw new BusinessException(ErrorCode.UPDATE_USER_VIP_STATUS_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void deactivate(Long userId, Date expiry) {
|
||||||
|
KeyboardUser user = requireUser(userId);
|
||||||
|
user.setIsVip(false);
|
||||||
|
user.setVipExpiry(expiry);
|
||||||
|
if (!userService.updateById(user)) {
|
||||||
|
throw new BusinessException(ErrorCode.UPDATE_USER_VIP_STATUS_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private KeyboardUser requireUser(Long userId) {
|
||||||
|
KeyboardUser user = userService.getById(userId);
|
||||||
|
if (user == null) {
|
||||||
|
throw new BusinessException(ErrorCode.USER_NOT_FOUND);
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package com.yolo.keyborad.googleplay;
|
||||||
|
|
||||||
|
import com.yolo.keyborad.model.entity.KeyboardUserWallet;
|
||||||
|
import com.yolo.keyborad.service.KeyboardUserWalletService;
|
||||||
|
import com.yolo.keyborad.service.KeyboardWalletTransactionService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class GooglePlayWalletBenefitService {
|
||||||
|
|
||||||
|
private static final short GOOGLE_PLAY_WALLET_TX_TYPE = 3;
|
||||||
|
|
||||||
|
private final KeyboardUserWalletService walletService;
|
||||||
|
private final KeyboardWalletTransactionService walletTransactionService;
|
||||||
|
|
||||||
|
public void grant(Long userId, Long orderId, String productName, BigDecimal amount) {
|
||||||
|
KeyboardUserWallet wallet = getOrCreateWallet(userId);
|
||||||
|
BigDecimal before = defaultBalance(wallet.getBalance());
|
||||||
|
BigDecimal after = before.add(amount);
|
||||||
|
wallet.setBalance(after);
|
||||||
|
wallet.setUpdatedAt(new Date());
|
||||||
|
walletService.saveOrUpdate(wallet);
|
||||||
|
walletTransactionService.createTransaction(userId, orderId, amount, GOOGLE_PLAY_WALLET_TX_TYPE,
|
||||||
|
before, after, productName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean revoke(Long userId, Long orderId, BigDecimal amount) {
|
||||||
|
KeyboardUserWallet wallet = walletService.lambdaQuery()
|
||||||
|
.eq(KeyboardUserWallet::getUserId, userId)
|
||||||
|
.one();
|
||||||
|
BigDecimal before = wallet == null ? BigDecimal.ZERO : defaultBalance(wallet.getBalance());
|
||||||
|
if (before.compareTo(amount) < 0) {
|
||||||
|
log.error("Google Play 退款回滚失败,余额不足, userId={}, orderId={}", userId, orderId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
BigDecimal after = before.subtract(amount);
|
||||||
|
wallet.setBalance(after);
|
||||||
|
wallet.setUpdatedAt(new Date());
|
||||||
|
walletService.updateById(wallet);
|
||||||
|
walletTransactionService.createTransaction(userId, orderId, amount.negate(), GOOGLE_PLAY_WALLET_TX_TYPE,
|
||||||
|
before, after, "Google Play 退款回滚");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private KeyboardUserWallet getOrCreateWallet(Long userId) {
|
||||||
|
KeyboardUserWallet wallet = walletService.lambdaQuery()
|
||||||
|
.eq(KeyboardUserWallet::getUserId, userId)
|
||||||
|
.one();
|
||||||
|
if (wallet != null) {
|
||||||
|
return wallet;
|
||||||
|
}
|
||||||
|
KeyboardUserWallet created = new KeyboardUserWallet();
|
||||||
|
created.setUserId(userId);
|
||||||
|
created.setBalance(BigDecimal.ZERO);
|
||||||
|
created.setStatus((short) 1);
|
||||||
|
created.setVersion(0);
|
||||||
|
created.setCreatedAt(new Date());
|
||||||
|
walletService.save(created);
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
private BigDecimal defaultBalance(BigDecimal balance) {
|
||||||
|
return balance == null ? BigDecimal.ZERO : balance;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package com.yolo.keyborad.googleplay.model;
|
||||||
|
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder(toBuilder = true)
|
||||||
|
public class GooglePlayPurchaseSnapshot {
|
||||||
|
|
||||||
|
private String packageName;
|
||||||
|
|
||||||
|
private String productId;
|
||||||
|
|
||||||
|
private String productType;
|
||||||
|
|
||||||
|
private String basePlanId;
|
||||||
|
|
||||||
|
private String purchaseToken;
|
||||||
|
|
||||||
|
private String orderKey;
|
||||||
|
|
||||||
|
private String googleOrderId;
|
||||||
|
|
||||||
|
private String linkedPurchaseToken;
|
||||||
|
|
||||||
|
private String state;
|
||||||
|
|
||||||
|
private String acknowledgementState;
|
||||||
|
|
||||||
|
private String consumptionState;
|
||||||
|
|
||||||
|
private Integer quantity;
|
||||||
|
|
||||||
|
private Integer refundableQuantity;
|
||||||
|
|
||||||
|
private Boolean autoRenewEnabled;
|
||||||
|
|
||||||
|
private Boolean accessGranted;
|
||||||
|
|
||||||
|
private String externalAccountId;
|
||||||
|
|
||||||
|
private String externalProfileId;
|
||||||
|
|
||||||
|
private String regionCode;
|
||||||
|
|
||||||
|
private String canceledStateReason;
|
||||||
|
|
||||||
|
private Date startTime;
|
||||||
|
|
||||||
|
private Date expiryTime;
|
||||||
|
|
||||||
|
private Date autoResumeTime;
|
||||||
|
|
||||||
|
private Date lastSyncedAt;
|
||||||
|
|
||||||
|
private String rawResponse;
|
||||||
|
|
||||||
|
private String purchaseOptionId;
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.yolo.keyborad.googleplay.model;
|
||||||
|
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder(toBuilder = true)
|
||||||
|
public class GooglePlaySyncCommand {
|
||||||
|
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
private String packageName;
|
||||||
|
|
||||||
|
private String productId;
|
||||||
|
|
||||||
|
private String productType;
|
||||||
|
|
||||||
|
private String purchaseToken;
|
||||||
|
|
||||||
|
private String source;
|
||||||
|
|
||||||
|
private String eventType;
|
||||||
|
|
||||||
|
private Integer notificationType;
|
||||||
|
|
||||||
|
private String notificationName;
|
||||||
|
|
||||||
|
private String messageId;
|
||||||
|
|
||||||
|
private String subscriptionName;
|
||||||
|
|
||||||
|
private Date eventTime;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.yolo.keyborad.googleplay.model;
|
||||||
|
|
||||||
|
import com.yolo.keyborad.model.entity.googleplay.GooglePlayOrder;
|
||||||
|
import com.yolo.keyborad.model.entity.googleplay.GooglePlayPurchaseToken;
|
||||||
|
import com.yolo.keyborad.model.entity.googleplay.GooglePlayUserEntitlement;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
public class GooglePlaySyncResult {
|
||||||
|
|
||||||
|
private GooglePlayOrder order;
|
||||||
|
|
||||||
|
private GooglePlayPurchaseToken token;
|
||||||
|
|
||||||
|
private GooglePlayUserEntitlement entitlement;
|
||||||
|
|
||||||
|
private boolean acknowledgeRequired;
|
||||||
|
|
||||||
|
private boolean consumeRequired;
|
||||||
|
|
||||||
|
private String linkedPurchaseTokenToSync;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.yolo.keyborad.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.yolo.keyborad.model.entity.googleplay.GooglePlayOrder;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Update;
|
||||||
|
|
||||||
|
public interface GooglePlayOrderMapper extends BaseMapper<GooglePlayOrder> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 原子抢占发货资格,只有当前状态匹配时才允许进入处理中。
|
||||||
|
*/
|
||||||
|
@Update("""
|
||||||
|
UPDATE google_play_order
|
||||||
|
SET delivery_status = #{targetStatus},
|
||||||
|
updated_at = #{updatedAt}
|
||||||
|
WHERE id = #{orderId}
|
||||||
|
AND delivery_status = #{expectedStatus}
|
||||||
|
""")
|
||||||
|
int updateDeliveryStatusIfMatch(@Param("orderId") Long orderId,
|
||||||
|
@Param("expectedStatus") String expectedStatus,
|
||||||
|
@Param("targetStatus") String targetStatus,
|
||||||
|
@Param("updatedAt") java.util.Date updatedAt);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.yolo.keyborad.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.yolo.keyborad.model.entity.googleplay.GooglePlayPurchaseToken;
|
||||||
|
|
||||||
|
public interface GooglePlayPurchaseTokenMapper extends BaseMapper<GooglePlayPurchaseToken> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.yolo.keyborad.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.yolo.keyborad.model.entity.googleplay.GooglePlayRtdnEvent;
|
||||||
|
|
||||||
|
public interface GooglePlayRtdnEventMapper extends BaseMapper<GooglePlayRtdnEvent> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.yolo.keyborad.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.yolo.keyborad.model.entity.googleplay.GooglePlayUserEntitlement;
|
||||||
|
|
||||||
|
public interface GooglePlayUserEntitlementMapper extends BaseMapper<GooglePlayUserEntitlement> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package com.yolo.keyborad.model.dto.googleplay;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class GooglePlayPubSubPushRequest {
|
||||||
|
|
||||||
|
private Message message;
|
||||||
|
|
||||||
|
private String subscription;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class Message {
|
||||||
|
private Map<String, String> attributes;
|
||||||
|
private String data;
|
||||||
|
private String messageId;
|
||||||
|
private String publishTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class DeveloperNotification {
|
||||||
|
private String version;
|
||||||
|
private String packageName;
|
||||||
|
private String eventTimeMillis;
|
||||||
|
private SubscriptionNotification subscriptionNotification;
|
||||||
|
private OneTimeProductNotification oneTimeProductNotification;
|
||||||
|
private VoidedPurchaseNotification voidedPurchaseNotification;
|
||||||
|
private TestNotification testNotification;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class SubscriptionNotification {
|
||||||
|
private String version;
|
||||||
|
private Integer notificationType;
|
||||||
|
private String purchaseToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class OneTimeProductNotification {
|
||||||
|
private String version;
|
||||||
|
private Integer notificationType;
|
||||||
|
private String purchaseToken;
|
||||||
|
private String sku;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class VoidedPurchaseNotification {
|
||||||
|
private String purchaseToken;
|
||||||
|
private String orderId;
|
||||||
|
private Integer productType;
|
||||||
|
private Integer refundType;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class TestNotification {
|
||||||
|
private String version;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.yolo.keyborad.model.dto.googleplay;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class GooglePlayPurchaseVerifyReq {
|
||||||
|
|
||||||
|
private String packageName;
|
||||||
|
|
||||||
|
private String productId;
|
||||||
|
|
||||||
|
private String productType;
|
||||||
|
|
||||||
|
private String purchaseToken;
|
||||||
|
}
|
||||||
@@ -119,14 +119,6 @@ public class KeyboardAiCompanion {
|
|||||||
@Schema(description="更新时间")
|
@Schema(description="更新时间")
|
||||||
private Date updatedAt;
|
private Date updatedAt;
|
||||||
|
|
||||||
@TableField(value = "prologue")
|
|
||||||
@Schema(description="开场白")
|
|
||||||
private String prologue;
|
|
||||||
|
|
||||||
@TableField(value = "prologue_audio")
|
|
||||||
@Schema(description="开场白音频")
|
|
||||||
private String prologueAudio;
|
|
||||||
|
|
||||||
@TableField(value = "voice_id")
|
@TableField(value = "voice_id")
|
||||||
@Schema(description="角色音频Id")
|
@Schema(description="角色音频Id")
|
||||||
private String voiceId;
|
private String voiceId;
|
||||||
|
|||||||
@@ -75,4 +75,13 @@ public class KeyboardAiCompanionI18n {
|
|||||||
@TableField(value = "updated_at")
|
@TableField(value = "updated_at")
|
||||||
@Schema(description="更新时间")
|
@Schema(description="更新时间")
|
||||||
private Date updatedAt;
|
private Date updatedAt;
|
||||||
|
|
||||||
|
@TableField(value = "prologue")
|
||||||
|
@Schema(description = "开场白")
|
||||||
|
private String prologue;
|
||||||
|
|
||||||
|
@TableField(value = "prologue_audio")
|
||||||
|
@Schema(description = "开场白音频")
|
||||||
|
private String prologueAudio;
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -112,4 +112,8 @@ public class KeyboardProductItems {
|
|||||||
@TableField(value = "level")
|
@TableField(value = "level")
|
||||||
@Schema(description = "级别")
|
@Schema(description = "级别")
|
||||||
private Integer level;
|
private Integer level;
|
||||||
|
|
||||||
|
@TableField(value = "platform")
|
||||||
|
@Schema(description = "所属平台")
|
||||||
|
private String platform;
|
||||||
}
|
}
|
||||||
@@ -132,4 +132,8 @@ public class KeyboardUser {
|
|||||||
@TableField(value = "vip_level")
|
@TableField(value = "vip_level")
|
||||||
@Schema(description = "vip等级")
|
@Schema(description = "vip等级")
|
||||||
private Integer vipLevel;
|
private Integer vipLevel;
|
||||||
|
|
||||||
|
@TableField(value = "uuid")
|
||||||
|
@Schema(description = "uuid")
|
||||||
|
private String uuid;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package com.yolo.keyborad.model.entity.googleplay;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableField;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@TableName("google_play_order")
|
||||||
|
public class GooglePlayOrder {
|
||||||
|
|
||||||
|
@TableId(value = "id", type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@TableField("user_id")
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
@TableField("package_name")
|
||||||
|
private String packageName;
|
||||||
|
|
||||||
|
@TableField("product_id")
|
||||||
|
private String productId;
|
||||||
|
|
||||||
|
@TableField("product_type")
|
||||||
|
private String productType;
|
||||||
|
|
||||||
|
@TableField("purchase_token")
|
||||||
|
private String purchaseToken;
|
||||||
|
|
||||||
|
@TableField("order_key")
|
||||||
|
private String orderKey;
|
||||||
|
|
||||||
|
@TableField("google_order_id")
|
||||||
|
private String googleOrderId;
|
||||||
|
|
||||||
|
@TableField("linked_purchase_token")
|
||||||
|
private String linkedPurchaseToken;
|
||||||
|
|
||||||
|
@TableField("order_state")
|
||||||
|
private String orderState;
|
||||||
|
|
||||||
|
@TableField("acknowledgement_state")
|
||||||
|
private String acknowledgementState;
|
||||||
|
|
||||||
|
@TableField("consumption_state")
|
||||||
|
private String consumptionState;
|
||||||
|
|
||||||
|
@TableField("quantity")
|
||||||
|
private Integer quantity;
|
||||||
|
|
||||||
|
@TableField("refundable_quantity")
|
||||||
|
private Integer refundableQuantity;
|
||||||
|
|
||||||
|
@TableField("delivery_status")
|
||||||
|
private String deliveryStatus;
|
||||||
|
|
||||||
|
@TableField("granted_quantity")
|
||||||
|
private BigDecimal grantedQuantity;
|
||||||
|
|
||||||
|
@TableField("entitlement_start_time")
|
||||||
|
private Date entitlementStartTime;
|
||||||
|
|
||||||
|
@TableField("entitlement_end_time")
|
||||||
|
private Date entitlementEndTime;
|
||||||
|
|
||||||
|
@TableField("last_event_time")
|
||||||
|
private Date lastEventTime;
|
||||||
|
|
||||||
|
@TableField("last_synced_at")
|
||||||
|
private Date lastSyncedAt;
|
||||||
|
|
||||||
|
@TableField("raw_response")
|
||||||
|
private String rawResponse;
|
||||||
|
|
||||||
|
@TableField("created_at")
|
||||||
|
private Date createdAt;
|
||||||
|
|
||||||
|
@TableField("updated_at")
|
||||||
|
private Date updatedAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前线程是否拿到了本次发货资格,仅用于本次请求内控制幂等,不落库。
|
||||||
|
*/
|
||||||
|
@TableField(exist = false)
|
||||||
|
private Boolean deliveryOwnershipGranted;
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package com.yolo.keyborad.model.entity.googleplay;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableField;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@TableName("google_play_purchase_token")
|
||||||
|
public class GooglePlayPurchaseToken {
|
||||||
|
|
||||||
|
@TableId(value = "id", type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@TableField("purchase_token")
|
||||||
|
private String purchaseToken;
|
||||||
|
|
||||||
|
@TableField("linked_purchase_token")
|
||||||
|
private String linkedPurchaseToken;
|
||||||
|
|
||||||
|
@TableField("user_id")
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
@TableField("package_name")
|
||||||
|
private String packageName;
|
||||||
|
|
||||||
|
@TableField("product_id")
|
||||||
|
private String productId;
|
||||||
|
|
||||||
|
@TableField("product_type")
|
||||||
|
private String productType;
|
||||||
|
|
||||||
|
@TableField("latest_order_key")
|
||||||
|
private String latestOrderKey;
|
||||||
|
|
||||||
|
@TableField("latest_order_id")
|
||||||
|
private String latestOrderId;
|
||||||
|
|
||||||
|
@TableField("token_state")
|
||||||
|
private String tokenState;
|
||||||
|
|
||||||
|
@TableField("acknowledgement_state")
|
||||||
|
private String acknowledgementState;
|
||||||
|
|
||||||
|
@TableField("consumption_state")
|
||||||
|
private String consumptionState;
|
||||||
|
|
||||||
|
@TableField("auto_renew_enabled")
|
||||||
|
private Boolean autoRenewEnabled;
|
||||||
|
|
||||||
|
@TableField("external_account_id")
|
||||||
|
private String externalAccountId;
|
||||||
|
|
||||||
|
@TableField("external_profile_id")
|
||||||
|
private String externalProfileId;
|
||||||
|
|
||||||
|
@TableField("region_code")
|
||||||
|
private String regionCode;
|
||||||
|
|
||||||
|
@TableField("start_time")
|
||||||
|
private Date startTime;
|
||||||
|
|
||||||
|
@TableField("expiry_time")
|
||||||
|
private Date expiryTime;
|
||||||
|
|
||||||
|
@TableField("auto_resume_time")
|
||||||
|
private Date autoResumeTime;
|
||||||
|
|
||||||
|
@TableField("canceled_state_reason")
|
||||||
|
private String canceledStateReason;
|
||||||
|
|
||||||
|
@TableField("last_event_type")
|
||||||
|
private String lastEventType;
|
||||||
|
|
||||||
|
@TableField("last_event_time")
|
||||||
|
private Date lastEventTime;
|
||||||
|
|
||||||
|
@TableField("last_synced_at")
|
||||||
|
private Date lastSyncedAt;
|
||||||
|
|
||||||
|
@TableField("raw_response")
|
||||||
|
private String rawResponse;
|
||||||
|
|
||||||
|
@TableField("created_at")
|
||||||
|
private Date createdAt;
|
||||||
|
|
||||||
|
@TableField("updated_at")
|
||||||
|
private Date updatedAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package com.yolo.keyborad.model.entity.googleplay;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableField;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@TableName("google_play_rtdn_event")
|
||||||
|
public class GooglePlayRtdnEvent {
|
||||||
|
|
||||||
|
@TableId(value = "id", type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@TableField("message_id")
|
||||||
|
private String messageId;
|
||||||
|
|
||||||
|
@TableField("subscription_name")
|
||||||
|
private String subscriptionName;
|
||||||
|
|
||||||
|
@TableField("package_name")
|
||||||
|
private String packageName;
|
||||||
|
|
||||||
|
@TableField("event_type")
|
||||||
|
private String eventType;
|
||||||
|
|
||||||
|
@TableField("notification_type")
|
||||||
|
private Integer notificationType;
|
||||||
|
|
||||||
|
@TableField("notification_name")
|
||||||
|
private String notificationName;
|
||||||
|
|
||||||
|
@TableField("purchase_token")
|
||||||
|
private String purchaseToken;
|
||||||
|
|
||||||
|
@TableField("product_id")
|
||||||
|
private String productId;
|
||||||
|
|
||||||
|
@TableField("order_id")
|
||||||
|
private String orderId;
|
||||||
|
|
||||||
|
@TableField("event_time")
|
||||||
|
private Date eventTime;
|
||||||
|
|
||||||
|
@TableField("status")
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
@TableField("retry_count")
|
||||||
|
private Integer retryCount;
|
||||||
|
|
||||||
|
@TableField("raw_envelope")
|
||||||
|
private String rawEnvelope;
|
||||||
|
|
||||||
|
@TableField("raw_payload")
|
||||||
|
private String rawPayload;
|
||||||
|
|
||||||
|
@TableField("error_message")
|
||||||
|
private String errorMessage;
|
||||||
|
|
||||||
|
@TableField("processed_at")
|
||||||
|
private Date processedAt;
|
||||||
|
|
||||||
|
@TableField("created_at")
|
||||||
|
private Date createdAt;
|
||||||
|
|
||||||
|
@TableField("updated_at")
|
||||||
|
private Date updatedAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package com.yolo.keyborad.model.entity.googleplay;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableField;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@TableName("google_play_user_entitlement")
|
||||||
|
public class GooglePlayUserEntitlement {
|
||||||
|
|
||||||
|
@TableId(value = "id", type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@TableField("user_id")
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
@TableField("entitlement_key")
|
||||||
|
private String entitlementKey;
|
||||||
|
|
||||||
|
@TableField("product_id")
|
||||||
|
private String productId;
|
||||||
|
|
||||||
|
@TableField("product_type")
|
||||||
|
private String productType;
|
||||||
|
|
||||||
|
@TableField("source_purchase_token")
|
||||||
|
private String sourcePurchaseToken;
|
||||||
|
|
||||||
|
@TableField("current_order_key")
|
||||||
|
private String currentOrderKey;
|
||||||
|
|
||||||
|
@TableField("benefit_type")
|
||||||
|
private String benefitType;
|
||||||
|
|
||||||
|
@TableField("state")
|
||||||
|
private String state;
|
||||||
|
|
||||||
|
@TableField("active")
|
||||||
|
private Boolean active;
|
||||||
|
|
||||||
|
@TableField("quantity")
|
||||||
|
private BigDecimal quantity;
|
||||||
|
|
||||||
|
@TableField("start_time")
|
||||||
|
private Date startTime;
|
||||||
|
|
||||||
|
@TableField("end_time")
|
||||||
|
private Date endTime;
|
||||||
|
|
||||||
|
@TableField("last_granted_at")
|
||||||
|
private Date lastGrantedAt;
|
||||||
|
|
||||||
|
@TableField("last_revoked_at")
|
||||||
|
private Date lastRevokedAt;
|
||||||
|
|
||||||
|
@TableField("metadata")
|
||||||
|
private String metadata;
|
||||||
|
|
||||||
|
@TableField("created_at")
|
||||||
|
private Date createdAt;
|
||||||
|
|
||||||
|
@TableField("updated_at")
|
||||||
|
private Date updatedAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.yolo.keyborad.model.vo.googleplay;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class GooglePlayPurchaseVerifyResp {
|
||||||
|
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
private String productId;
|
||||||
|
|
||||||
|
private String productType;
|
||||||
|
|
||||||
|
private String purchaseToken;
|
||||||
|
|
||||||
|
private String orderId;
|
||||||
|
|
||||||
|
private String orderState;
|
||||||
|
|
||||||
|
private String entitlementState;
|
||||||
|
|
||||||
|
private String deliveryStatus;
|
||||||
|
|
||||||
|
private Boolean accessGranted;
|
||||||
|
|
||||||
|
private Boolean acknowledged;
|
||||||
|
|
||||||
|
private Boolean consumed;
|
||||||
|
|
||||||
|
private Date expiryTime;
|
||||||
|
|
||||||
|
private Date lastSyncedAt;
|
||||||
|
}
|
||||||
@@ -45,6 +45,9 @@ public class KeyboardProductItemRespVO {
|
|||||||
private String description;
|
private String description;
|
||||||
|
|
||||||
@Schema(description = "级别")
|
@Schema(description = "级别")
|
||||||
private Integer level;
|
private Integer level;
|
||||||
|
|
||||||
|
@Schema(description = "所属平台")
|
||||||
|
private String platform;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,4 +57,6 @@ public class KeyboardUserInfoRespVO {
|
|||||||
@Schema(description = "vip等级")
|
@Schema(description = "vip等级")
|
||||||
private Integer vipLevel;
|
private Integer vipLevel;
|
||||||
|
|
||||||
|
@Schema(description = "uuid")
|
||||||
|
private String uuid;
|
||||||
}
|
}
|
||||||
@@ -59,4 +59,8 @@ public class KeyboardUserRespVO {
|
|||||||
@TableField(value = "vip_level")
|
@TableField(value = "vip_level")
|
||||||
@Schema(description = "vip等级")
|
@Schema(description = "vip等级")
|
||||||
private Integer vipLevel;
|
private Integer vipLevel;
|
||||||
|
|
||||||
|
@TableField(value = "uuid")
|
||||||
|
@Schema(description = "uuid")
|
||||||
|
private String uuid;
|
||||||
}
|
}
|
||||||
@@ -43,4 +43,11 @@ public interface ApplePurchaseService {
|
|||||||
* @param notification 解码后的通知载荷
|
* @param notification 解码后的通知载荷
|
||||||
*/
|
*/
|
||||||
void handleConsumptionRequest(ResponseBodyV2DecodedPayload notification);
|
void handleConsumptionRequest(ResponseBodyV2DecodedPayload notification);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理一次性购买通知(ONE_TIME_CHARGE)
|
||||||
|
*
|
||||||
|
* @param notification 解码后的通知载荷
|
||||||
|
*/
|
||||||
|
void handleOneTimeChargeNotification(ResponseBodyV2DecodedPayload notification);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.yolo.keyborad.service;
|
||||||
|
|
||||||
|
import com.yolo.keyborad.model.dto.googleplay.GooglePlayPubSubPushRequest;
|
||||||
|
import com.yolo.keyborad.model.dto.googleplay.GooglePlayPurchaseVerifyReq;
|
||||||
|
import com.yolo.keyborad.model.vo.googleplay.GooglePlayPurchaseVerifyResp;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
|
||||||
|
public interface GooglePlayBillingService {
|
||||||
|
|
||||||
|
GooglePlayPurchaseVerifyResp verifyPurchase(Long userId, GooglePlayPurchaseVerifyReq req);
|
||||||
|
|
||||||
|
void handleRtdn(HttpServletRequest request, GooglePlayPubSubPushRequest pushRequest);
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ import java.util.List;
|
|||||||
public interface KeyboardProductItemsService extends IService<KeyboardProductItems>{
|
public interface KeyboardProductItemsService extends IService<KeyboardProductItems>{
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据主键ID查询商品明细
|
* 根据主键ID和平台查询商品明细
|
||||||
*
|
*
|
||||||
* @param id 商品主键ID
|
* @param id 商品主键ID
|
||||||
* @return 商品明细(不存在返回 null)
|
* @return 商品明细(不存在返回 null)
|
||||||
@@ -20,7 +20,7 @@ public interface KeyboardProductItemsService extends IService<KeyboardProductIte
|
|||||||
KeyboardProductItemRespVO getProductDetailById(Long id);
|
KeyboardProductItemRespVO getProductDetailById(Long id);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据 Apple productId 查询商品明细
|
* 根据 productId 和平台查询商品明细
|
||||||
*
|
*
|
||||||
* @param productId 商品 productId
|
* @param productId 商品 productId
|
||||||
* @return 商品明细(不存在返回 null)
|
* @return 商品明细(不存在返回 null)
|
||||||
@@ -39,6 +39,6 @@ public interface KeyboardProductItemsService extends IService<KeyboardProductIte
|
|||||||
* @param type 商品类型:subscription / in-app-purchase / all
|
* @param type 商品类型:subscription / in-app-purchase / all
|
||||||
* @return 商品列表
|
* @return 商品列表
|
||||||
*/
|
*/
|
||||||
List<KeyboardProductItemRespVO> listProductsByType(String type);
|
List<KeyboardProductItemRespVO> listProductsByType(String type, String platform);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,5 +9,12 @@ import com.baomidou.mybatisplus.extension.service.IService;
|
|||||||
|
|
||||||
public interface KeyboardUserPurchaseRecordsService extends IService<KeyboardUserPurchaseRecords>{
|
public interface KeyboardUserPurchaseRecordsService extends IService<KeyboardUserPurchaseRecords>{
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查购买记录是否存在
|
||||||
|
*
|
||||||
|
* @param transactionId 交易 ID
|
||||||
|
* @param originalTransactionId 原始交易 ID
|
||||||
|
* @return 存在返回 true,不存在返回 false
|
||||||
|
*/
|
||||||
|
boolean checkPurchaseExists(String transactionId, String originalTransactionId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,4 +38,5 @@ public interface UserService extends IService<KeyboardUser> {
|
|||||||
*/
|
*/
|
||||||
Boolean cancelAccount(long userId);
|
Boolean cancelAccount(long userId);
|
||||||
|
|
||||||
|
Long selectUserByUUid(String uuid);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.yolo.keyborad.service.impl;
|
package com.yolo.keyborad.service.impl;
|
||||||
|
|
||||||
|
import cn.dev33.satoken.stp.StpUtil;
|
||||||
import com.apple.itunes.storekit.model.JWSRenewalInfoDecodedPayload;
|
import com.apple.itunes.storekit.model.JWSRenewalInfoDecodedPayload;
|
||||||
import com.apple.itunes.storekit.model.JWSTransactionDecodedPayload;
|
import com.apple.itunes.storekit.model.JWSTransactionDecodedPayload;
|
||||||
import com.apple.itunes.storekit.model.NotificationTypeV2;
|
import com.apple.itunes.storekit.model.NotificationTypeV2;
|
||||||
@@ -34,6 +35,7 @@ import java.time.format.DateTimeParseException;
|
|||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 苹果购买后置处理:订阅续期 / 内购充值 + 记录落库
|
* 苹果购买后置处理:订阅续期 / 内购充值 + 记录落库
|
||||||
@@ -152,23 +154,10 @@ public class ApplePurchaseServiceImpl implements ApplePurchaseService {
|
|||||||
JWSTransactionDecodedPayload transaction =
|
JWSTransactionDecodedPayload transaction =
|
||||||
signedDataVerifier.verifyAndDecodeTransaction(signedTransactionInfo);
|
signedDataVerifier.verifyAndDecodeTransaction(signedTransactionInfo);
|
||||||
|
|
||||||
String originalTransactionId = transaction.getOriginalTransactionId();
|
|
||||||
String productId = transaction.getProductId();
|
String productId = transaction.getProductId();
|
||||||
|
|
||||||
// 根据原始交易ID查询用户购买记录
|
UUID appAccountToken = transaction.getAppAccountToken();
|
||||||
List<KeyboardUserPurchaseRecords> records = purchaseRecordsService.lambdaQuery()
|
Long userId = userService.selectUserByUUid(appAccountToken.toString());
|
||||||
.eq(KeyboardUserPurchaseRecords::getOriginalTransactionId, originalTransactionId)
|
|
||||||
.orderByDesc(KeyboardUserPurchaseRecords::getId)
|
|
||||||
.last("LIMIT 1")
|
|
||||||
.list();
|
|
||||||
|
|
||||||
if (records == null || records.isEmpty()) {
|
|
||||||
log.warn("No purchase record found for originalTransactionId={}", originalTransactionId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
KeyboardUserPurchaseRecords existingRecord = records.get(0);
|
|
||||||
Long userId = existingRecord.getUserId().longValue();
|
|
||||||
|
|
||||||
// 查询商品信息
|
// 查询商品信息
|
||||||
KeyboardProductItems product = productItemsService.getProductEntityByProductId(productId);
|
KeyboardProductItems product = productItemsService.getProductEntityByProductId(productId);
|
||||||
@@ -354,6 +343,127 @@ public class ApplePurchaseServiceImpl implements ApplePurchaseService {
|
|||||||
// 提供用户消费状态、交付状态等信息,帮助 Apple 评估退款请求
|
// 提供用户消费状态、交付状态等信息,帮助 Apple 评估退款请求
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理一次性购买通知(ONE_TIME_CHARGE)
|
||||||
|
* 流程参考 processPurchase:
|
||||||
|
* 1. 验证并解码交易信息
|
||||||
|
* 2. 幂等性检查,避免重复处理
|
||||||
|
* 3. 通过 originalTransactionId 查找用户
|
||||||
|
* 4. 查询商品信息
|
||||||
|
* 5. 保存购买记录
|
||||||
|
* 6. 根据商品类型执行对应逻辑(订阅延期或钱包充值)
|
||||||
|
*
|
||||||
|
* @param notification 解码后的通知载荷
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
public void handleOneTimeChargeNotification(ResponseBodyV2DecodedPayload notification) {
|
||||||
|
if (notification == null || notification.getData() == null) {
|
||||||
|
log.warn("ONE_TIME_CHARGE notification data is null");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 解码交易信息
|
||||||
|
String signedTransactionInfo = notification.getData().getSignedTransactionInfo();
|
||||||
|
if (signedTransactionInfo == null || signedTransactionInfo.isBlank()) {
|
||||||
|
log.warn("No signed transaction info in ONE_TIME_CHARGE notification");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
JWSTransactionDecodedPayload transaction =
|
||||||
|
signedDataVerifier.verifyAndDecodeTransaction(signedTransactionInfo);
|
||||||
|
|
||||||
|
String transactionId = transaction.getTransactionId();
|
||||||
|
String originalTransactionId = transaction.getOriginalTransactionId();
|
||||||
|
String productId = transaction.getProductId();
|
||||||
|
UUID appAccountToken = transaction.getAppAccountToken();
|
||||||
|
Long userId = userService.selectUserByUUid(appAccountToken.toString());
|
||||||
|
|
||||||
|
log.info("Processing ONE_TIME_CHARGE: transactionId={}, productId={}", transactionId, productId);
|
||||||
|
|
||||||
|
// 2. 幂等性检查:根据交易ID判断是否已处理
|
||||||
|
boolean handled = purchaseRecordsService.lambdaQuery()
|
||||||
|
.eq(KeyboardUserPurchaseRecords::getTransactionId, transactionId)
|
||||||
|
.eq(KeyboardUserPurchaseRecords::getStatus, "PAID")
|
||||||
|
.exists();
|
||||||
|
if (handled) {
|
||||||
|
log.info("ONE_TIME_CHARGE already handled, transactionId={}", transactionId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// // 3. 通过 originalTransactionId 查找关联的用户购买记录以获取 userId
|
||||||
|
// List<KeyboardUserPurchaseRecords> records = purchaseRecordsService.lambdaQuery()
|
||||||
|
// .eq(KeyboardUserPurchaseRecords::getOriginalTransactionId, originalTransactionId)
|
||||||
|
// .orderByDesc(KeyboardUserPurchaseRecords::getId)
|
||||||
|
// .last("LIMIT 1")
|
||||||
|
// .list();
|
||||||
|
//
|
||||||
|
// if (records == null || records.isEmpty()) {
|
||||||
|
// log.warn("No purchase record found for ONE_TIME_CHARGE, originalTransactionId={}", originalTransactionId);
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Long userId = records.get(0).getUserId().longValue();
|
||||||
|
|
||||||
|
// 4. 查询商品信息
|
||||||
|
KeyboardProductItems product = productItemsService.getProductEntityByProductId(productId);
|
||||||
|
if (product == null) {
|
||||||
|
log.error("Product not found for ONE_TIME_CHARGE, productId={}", productId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 5. 构建并保存购买记录
|
||||||
|
KeyboardUserPurchaseRecords purchaseRecord = new KeyboardUserPurchaseRecords();
|
||||||
|
purchaseRecord.setUserId(userId.intValue());
|
||||||
|
purchaseRecord.setProductId(productId);
|
||||||
|
purchaseRecord.setPurchaseQuantity(product.getDurationValue());
|
||||||
|
purchaseRecord.setPrice(product.getPrice());
|
||||||
|
purchaseRecord.setCurrency(product.getCurrency());
|
||||||
|
purchaseRecord.setPurchaseType(product.getType());
|
||||||
|
purchaseRecord.setStatus("PAID");
|
||||||
|
purchaseRecord.setPaymentMethod("APPLE");
|
||||||
|
purchaseRecord.setTransactionId(transactionId);
|
||||||
|
purchaseRecord.setOriginalTransactionId(originalTransactionId);
|
||||||
|
purchaseRecord.setProductIds(new String[]{productId});
|
||||||
|
|
||||||
|
if (transaction.getPurchaseDate() != null) {
|
||||||
|
purchaseRecord.setPurchaseTime(Date.from(Instant.ofEpochMilli(transaction.getPurchaseDate())));
|
||||||
|
purchaseRecord.setPurchaseDate(Date.from(Instant.ofEpochMilli(transaction.getPurchaseDate())));
|
||||||
|
} else {
|
||||||
|
purchaseRecord.setPurchaseTime(new Date());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transaction.getExpiresDate() != null) {
|
||||||
|
purchaseRecord.setExpiresDate(Date.from(Instant.ofEpochMilli(transaction.getExpiresDate())));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transaction.getEnvironment() != null) {
|
||||||
|
purchaseRecord.setEnvironment(transaction.getEnvironment().name());
|
||||||
|
}
|
||||||
|
|
||||||
|
purchaseRecordsService.save(purchaseRecord);
|
||||||
|
|
||||||
|
// 6. 根据商品类型执行对应的业务逻辑
|
||||||
|
if ("subscription".equalsIgnoreCase(product.getType())) {
|
||||||
|
Instant expiresInstant = transaction.getExpiresDate() != null
|
||||||
|
? Instant.ofEpochMilli(transaction.getExpiresDate()) : null;
|
||||||
|
extendVip(userId, product, expiresInstant);
|
||||||
|
} else if ("in-app-purchase".equalsIgnoreCase(product.getType())) {
|
||||||
|
handleInAppPurchase(userId, product, purchaseRecord.getId());
|
||||||
|
} else {
|
||||||
|
log.warn("未知商品类型, type={}, productId={}", product.getType(), productId);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("ONE_TIME_CHARGE processed successfully: userId={}, transactionId={}", userId, transactionId);
|
||||||
|
|
||||||
|
} catch (VerificationException e) {
|
||||||
|
log.error("Failed to verify transaction in ONE_TIME_CHARGE notification", e);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error processing ONE_TIME_CHARGE notification", e);
|
||||||
|
throw new BusinessException(ErrorCode.OPERATION_ERROR, "处理一次性购买通知失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理成功的续订
|
* 处理成功的续订
|
||||||
* 创建新的购买记录并延长VIP有效期
|
* 创建新的购买记录并延长VIP有效期
|
||||||
@@ -673,7 +783,7 @@ public class ApplePurchaseServiceImpl implements ApplePurchaseService {
|
|||||||
(short) 2, // 交易类型:2-苹果内购充值
|
(short) 2, // 交易类型:2-苹果内购充值
|
||||||
before,
|
before,
|
||||||
after,
|
after,
|
||||||
"Apple 充值: " + product.getProductId()
|
product.getName()
|
||||||
);
|
);
|
||||||
|
|
||||||
// 8. 记录充值成功日志
|
// 8. 记录充值成功日志
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ public class AppleReceiptServiceImpl implements AppleReceiptService {
|
|||||||
|
|
||||||
// 续订偏好变更通知
|
// 续订偏好变更通知
|
||||||
case DID_CHANGE_RENEWAL_PREF:
|
case DID_CHANGE_RENEWAL_PREF:
|
||||||
|
|
||||||
case DID_CHANGE_RENEWAL_STATUS:
|
case DID_CHANGE_RENEWAL_STATUS:
|
||||||
case PRICE_INCREASE:
|
case PRICE_INCREASE:
|
||||||
applePurchaseService.handleRenewalPreferenceChange(notification);
|
applePurchaseService.handleRenewalPreferenceChange(notification);
|
||||||
@@ -165,9 +166,14 @@ public class AppleReceiptServiceImpl implements AppleReceiptService {
|
|||||||
applePurchaseService.handleConsumptionRequest(notification);
|
applePurchaseService.handleConsumptionRequest(notification);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
// 一次性购买通知
|
||||||
|
case ONE_TIME_CHARGE:
|
||||||
|
applePurchaseService.handleOneTimeChargeNotification(notification);
|
||||||
|
break;
|
||||||
|
|
||||||
// 其他通知类型(记录但不处理)
|
// 其他通知类型(记录但不处理)
|
||||||
case EXTERNAL_PURCHASE_TOKEN:
|
case EXTERNAL_PURCHASE_TOKEN:
|
||||||
case ONE_TIME_CHARGE:
|
|
||||||
case REVOKE:
|
case REVOKE:
|
||||||
case TEST:
|
case TEST:
|
||||||
log.info("Received notification type {} - no action required", type);
|
log.info("Received notification type {} - no action required", type);
|
||||||
|
|||||||
@@ -413,7 +413,7 @@ public class ChatServiceImpl implements ChatService {
|
|||||||
if (companion.getStatus() == null || companion.getStatus() != 1) {
|
if (companion.getStatus() == null || companion.getStatus() != 1) {
|
||||||
throw new BusinessException(ErrorCode.PARAMS_ERROR, "AI陪聊角色已下线");
|
throw new BusinessException(ErrorCode.PARAMS_ERROR, "AI陪聊角色已下线");
|
||||||
}
|
}
|
||||||
String systemPrompt = companion.getSystemPrompt();
|
String systemPrompt = appConfig.getLlmConfig().getCompanionSystemPrompt() + companion.getSystemPrompt();
|
||||||
String voiceId = companion.getVoiceId();
|
String voiceId = companion.getVoiceId();
|
||||||
|
|
||||||
// 获取最近20条聊天记录作为上下文
|
// 获取最近20条聊天记录作为上下文
|
||||||
@@ -538,7 +538,7 @@ public class ChatServiceImpl implements ChatService {
|
|||||||
|
|
||||||
// 1. TTS 转换
|
// 1. TTS 转换
|
||||||
long ttsStart = System.currentTimeMillis();
|
long ttsStart = System.currentTimeMillis();
|
||||||
TextToSpeechVO ttsResult = elevenLabsService.textToSpeechWithTimestamps(text, voiceId);
|
TextToSpeechVO ttsResult = elevenLabsService.textToSpeechWithTimestamps(text.replaceAll("\\(.*?\\)", ""), voiceId);
|
||||||
long ttsDuration = System.currentTimeMillis() - ttsStart;
|
long ttsDuration = System.currentTimeMillis() - ttsStart;
|
||||||
log.info("TTS 完成, audioId: {}, 耗时: {}ms", audioId, ttsDuration);
|
log.info("TTS 完成, audioId: {}, 耗时: {}ms", audioId, ttsDuration);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,293 @@
|
|||||||
|
package com.yolo.keyborad.service.impl;
|
||||||
|
|
||||||
|
import com.yolo.keyborad.common.ErrorCode;
|
||||||
|
import com.yolo.keyborad.config.GooglePlayProperties;
|
||||||
|
import com.yolo.keyborad.exception.BusinessException;
|
||||||
|
import com.yolo.keyborad.googleplay.GooglePlayApiClient;
|
||||||
|
import com.yolo.keyborad.googleplay.GooglePlayApiException;
|
||||||
|
import com.yolo.keyborad.googleplay.GooglePlayConstants;
|
||||||
|
import com.yolo.keyborad.googleplay.GooglePlayNotificationSupport;
|
||||||
|
import com.yolo.keyborad.googleplay.GooglePlayPubSubAuthService;
|
||||||
|
import com.yolo.keyborad.googleplay.GooglePlayRtdnEventService;
|
||||||
|
import com.yolo.keyborad.googleplay.GooglePlayStateService;
|
||||||
|
import com.yolo.keyborad.googleplay.model.GooglePlayPurchaseSnapshot;
|
||||||
|
import com.yolo.keyborad.googleplay.model.GooglePlaySyncCommand;
|
||||||
|
import com.yolo.keyborad.googleplay.model.GooglePlaySyncResult;
|
||||||
|
import com.yolo.keyborad.model.dto.googleplay.GooglePlayPubSubPushRequest;
|
||||||
|
import com.yolo.keyborad.model.dto.googleplay.GooglePlayPurchaseVerifyReq;
|
||||||
|
import com.yolo.keyborad.model.entity.googleplay.GooglePlayPurchaseToken;
|
||||||
|
import com.yolo.keyborad.model.entity.googleplay.GooglePlayRtdnEvent;
|
||||||
|
import com.yolo.keyborad.model.vo.googleplay.GooglePlayPurchaseVerifyResp;
|
||||||
|
import com.yolo.keyborad.service.GooglePlayBillingService;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class GooglePlayBillingServiceImpl implements GooglePlayBillingService {
|
||||||
|
|
||||||
|
private final GooglePlayProperties properties;
|
||||||
|
private final GooglePlayApiClient apiClient;
|
||||||
|
private final GooglePlayPubSubAuthService pubSubAuthService;
|
||||||
|
private final GooglePlayStateService stateService;
|
||||||
|
private final GooglePlayNotificationSupport notificationSupport;
|
||||||
|
private final GooglePlayRtdnEventService rtdnEventService;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public GooglePlayPurchaseVerifyResp verifyPurchase(Long userId, GooglePlayPurchaseVerifyReq req) {
|
||||||
|
ensureEnabled();
|
||||||
|
validateVerifyRequest(req);
|
||||||
|
String packageName = resolvePackageName(req.getPackageName());
|
||||||
|
String productType = normalizeProductType(req.getProductType());
|
||||||
|
GooglePlayPurchaseSnapshot snapshot = fetchSnapshot(packageName, productType, req.getPurchaseToken());
|
||||||
|
// validateProduct(snapshot, req.getProductId());
|
||||||
|
verifyExternalAccount(userId, snapshot);
|
||||||
|
GooglePlaySyncCommand command = GooglePlaySyncCommand.builder()
|
||||||
|
.userId(userId)
|
||||||
|
.packageName(packageName)
|
||||||
|
.productId(snapshot.getProductId())
|
||||||
|
.productType(productType)
|
||||||
|
.purchaseToken(req.getPurchaseToken())
|
||||||
|
.source("CLIENT")
|
||||||
|
.eventType("CLIENT_VERIFY")
|
||||||
|
.notificationName("CLIENT_VERIFY")
|
||||||
|
.eventTime(new Date())
|
||||||
|
.build();
|
||||||
|
GooglePlaySyncResult result = stateService.sync(command, snapshot);
|
||||||
|
finalizePurchase(snapshot, result);
|
||||||
|
return buildResponse(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void handleRtdn(HttpServletRequest request, GooglePlayPubSubPushRequest pushRequest) {
|
||||||
|
ensureEnabled();
|
||||||
|
validatePushRequest(pushRequest);
|
||||||
|
pubSubAuthService.verify(request, pushRequest);
|
||||||
|
GooglePlayPubSubPushRequest.DeveloperNotification notification = notificationSupport.decode(pushRequest);
|
||||||
|
GooglePlayRtdnEvent event = rtdnEventService.upsertEvent(pushRequest, notification);
|
||||||
|
if (GooglePlayConstants.EVENT_PROCESSED.equals(event.getStatus())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (notification.getTestNotification() != null) {
|
||||||
|
rtdnEventService.markIgnored(event);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
GooglePlaySyncCommand command = notificationSupport.buildCommand(
|
||||||
|
pushRequest,
|
||||||
|
notification,
|
||||||
|
resolvePackageName(notification.getPackageName())
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
GooglePlayPurchaseSnapshot snapshot = fetchSnapshotForNotification(command);
|
||||||
|
GooglePlayPurchaseSnapshot effectiveSnapshot = applyVoidedStateIfNeeded(snapshot, notification);
|
||||||
|
Long userId = resolveUserId(command.getPurchaseToken(), effectiveSnapshot);
|
||||||
|
GooglePlaySyncResult result = stateService.sync(command.toBuilder().userId(userId).build(), effectiveSnapshot);
|
||||||
|
finalizePurchase(effectiveSnapshot, result);
|
||||||
|
syncLinkedTokenIfNeeded(command, result, effectiveSnapshot);
|
||||||
|
rtdnEventService.markProcessed(event);
|
||||||
|
} catch (Exception e) {
|
||||||
|
rtdnEventService.markFailed(event, e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureEnabled() {
|
||||||
|
if (!properties.isEnabled()) {
|
||||||
|
throw new BusinessException(ErrorCode.GOOGLE_PLAY_NOT_ENABLED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateVerifyRequest(GooglePlayPurchaseVerifyReq req) {
|
||||||
|
if (req == null || req.getPurchaseToken() == null || req.getPurchaseToken().isBlank()) {
|
||||||
|
throw new BusinessException(ErrorCode.PARAMS_ERROR, "purchaseToken 不能为空");
|
||||||
|
}
|
||||||
|
if (req.getProductType() == null || req.getProductType().isBlank()) {
|
||||||
|
throw new BusinessException(ErrorCode.PARAMS_ERROR, "productType 不能为空");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validatePushRequest(GooglePlayPubSubPushRequest pushRequest) {
|
||||||
|
if (pushRequest == null || pushRequest.getMessage() == null) {
|
||||||
|
throw new BusinessException(ErrorCode.PARAMS_ERROR, "Pub/Sub body 不能为空");
|
||||||
|
}
|
||||||
|
if (pushRequest.getMessage().getMessageId() == null || pushRequest.getMessage().getMessageId().isBlank()) {
|
||||||
|
throw new BusinessException(ErrorCode.PARAMS_ERROR, "messageId 不能为空");
|
||||||
|
}
|
||||||
|
if (pushRequest.getMessage().getData() == null || pushRequest.getMessage().getData().isBlank()) {
|
||||||
|
throw new BusinessException(ErrorCode.PARAMS_ERROR, "data 不能为空");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private GooglePlayPurchaseSnapshot fetchSnapshot(String packageName, String productType, String purchaseToken) {
|
||||||
|
if (GooglePlayConstants.PRODUCT_TYPE_SUBSCRIPTION.equals(productType)) {
|
||||||
|
return apiClient.getSubscription(packageName, purchaseToken);
|
||||||
|
}
|
||||||
|
return apiClient.getOneTimeProduct(packageName, purchaseToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private GooglePlayPurchaseSnapshot fetchSnapshotForNotification(GooglePlaySyncCommand command) {
|
||||||
|
try {
|
||||||
|
return fetchSnapshot(command.getPackageName(), command.getProductType(), command.getPurchaseToken());
|
||||||
|
} catch (GooglePlayApiException e) {
|
||||||
|
if (e.getStatusCode() != 404 || !GooglePlayConstants.EVENT_TYPE_VOIDED.equals(command.getEventType())) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
GooglePlayPurchaseToken localToken = stateService.findToken(command.getPurchaseToken());
|
||||||
|
return GooglePlayPurchaseSnapshot.builder()
|
||||||
|
.packageName(command.getPackageName())
|
||||||
|
.productId(localToken == null ? command.getProductId() : localToken.getProductId())
|
||||||
|
.productType(localToken == null ? command.getProductType() : localToken.getProductType())
|
||||||
|
.purchaseToken(command.getPurchaseToken())
|
||||||
|
.orderKey(localToken == null || localToken.getLatestOrderKey() == null
|
||||||
|
? "TOKEN:" + command.getPurchaseToken()
|
||||||
|
: localToken.getLatestOrderKey())
|
||||||
|
.googleOrderId(localToken == null ? null : localToken.getLatestOrderId())
|
||||||
|
.state(GooglePlayConstants.STATE_UNKNOWN)
|
||||||
|
.acknowledgementState(GooglePlayConstants.ACK_PENDING)
|
||||||
|
.consumptionState(GooglePlayConstants.CONSUMPTION_PENDING)
|
||||||
|
.accessGranted(false)
|
||||||
|
.externalAccountId(localToken == null ? null : localToken.getExternalAccountId())
|
||||||
|
.lastSyncedAt(new Date())
|
||||||
|
.rawResponse("{}")
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateProduct(GooglePlayPurchaseSnapshot snapshot, String requestProductId) {
|
||||||
|
if (requestProductId == null || requestProductId.isBlank()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!requestProductId.equals(snapshot.getBasePlanId())) {
|
||||||
|
throw new BusinessException(ErrorCode.GOOGLE_PLAY_PURCHASE_MISMATCH, "productId 与 Google 返回不一致");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void verifyExternalAccount(Long userId, GooglePlayPurchaseSnapshot snapshot) {
|
||||||
|
String externalAccountId = snapshot.getExternalAccountId();
|
||||||
|
if ((externalAccountId == null || externalAccountId.isBlank()) && properties.isRequireObfuscatedAccountId()) {
|
||||||
|
throw new BusinessException(ErrorCode.GOOGLE_PLAY_PURCHASE_MISMATCH, "缺少 obfuscatedExternalAccountId");
|
||||||
|
}
|
||||||
|
if (externalAccountId == null || externalAccountId.isBlank()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!String.valueOf(userId).equals(externalAccountId)) {
|
||||||
|
throw new BusinessException(ErrorCode.GOOGLE_PLAY_PURCHASE_MISMATCH, "购买用户与当前登录用户不一致");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private GooglePlayPurchaseVerifyResp buildResponse(GooglePlaySyncResult result) {
|
||||||
|
GooglePlayPurchaseVerifyResp resp = new GooglePlayPurchaseVerifyResp();
|
||||||
|
resp.setUserId(result.getOrder().getUserId());
|
||||||
|
resp.setProductId(result.getOrder().getProductId());
|
||||||
|
resp.setProductType(result.getOrder().getProductType());
|
||||||
|
resp.setPurchaseToken(result.getOrder().getPurchaseToken());
|
||||||
|
resp.setOrderId(result.getOrder().getGoogleOrderId());
|
||||||
|
resp.setOrderState(result.getOrder().getOrderState());
|
||||||
|
resp.setEntitlementState(result.getEntitlement() == null ? null : result.getEntitlement().getState());
|
||||||
|
resp.setDeliveryStatus(result.getOrder().getDeliveryStatus());
|
||||||
|
resp.setAccessGranted(result.getEntitlement() != null && Boolean.TRUE.equals(result.getEntitlement().getActive()));
|
||||||
|
resp.setAcknowledged(GooglePlayConstants.ACK_ACKNOWLEDGED.equals(result.getOrder().getAcknowledgementState()));
|
||||||
|
resp.setConsumed(GooglePlayConstants.CONSUMPTION_CONSUMED.equals(result.getOrder().getConsumptionState()));
|
||||||
|
resp.setExpiryTime(result.getOrder().getEntitlementEndTime());
|
||||||
|
resp.setLastSyncedAt(result.getOrder().getLastSyncedAt());
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
|
|
||||||
|
private GooglePlayPurchaseSnapshot applyVoidedStateIfNeeded(GooglePlayPurchaseSnapshot snapshot,
|
||||||
|
GooglePlayPubSubPushRequest.DeveloperNotification notification) {
|
||||||
|
if (notification.getVoidedPurchaseNotification() == null) {
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
String productType = notificationSupport.normalizeVoidedProductType(
|
||||||
|
notification.getVoidedPurchaseNotification().getProductType()
|
||||||
|
);
|
||||||
|
String state = GooglePlayConstants.PRODUCT_TYPE_SUBSCRIPTION.equals(productType)
|
||||||
|
? GooglePlayConstants.STATE_REVOKED
|
||||||
|
: GooglePlayConstants.STATE_REFUNDED;
|
||||||
|
return snapshot.toBuilder().state(state).accessGranted(false).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void finalizePurchase(GooglePlayPurchaseSnapshot snapshot, GooglePlaySyncResult result) {
|
||||||
|
if (result.isConsumeRequired()) {
|
||||||
|
try {
|
||||||
|
apiClient.consumeProduct(snapshot.getPackageName(), snapshot.getProductId(), snapshot.getPurchaseToken());
|
||||||
|
stateService.markAcknowledged(snapshot.getPurchaseToken(), snapshot.getOrderKey());
|
||||||
|
stateService.markConsumed(snapshot.getPurchaseToken(), snapshot.getOrderKey());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Google Play consume retry pending, orderKey={}", snapshot.getOrderKey(), e);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!result.isAcknowledgeRequired()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
acknowledge(snapshot);
|
||||||
|
stateService.markAcknowledged(snapshot.getPurchaseToken(), snapshot.getOrderKey());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Google Play acknowledge retry pending, orderKey={}", snapshot.getOrderKey(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void acknowledge(GooglePlayPurchaseSnapshot snapshot) {
|
||||||
|
if (GooglePlayConstants.PRODUCT_TYPE_SUBSCRIPTION.equals(snapshot.getProductType())) {
|
||||||
|
apiClient.acknowledgeSubscription(snapshot.getPackageName(), snapshot.getProductId(), snapshot.getPurchaseToken());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
apiClient.acknowledgeProduct(snapshot.getPackageName(), snapshot.getProductId(), snapshot.getPurchaseToken());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void syncLinkedTokenIfNeeded(GooglePlaySyncCommand command,
|
||||||
|
GooglePlaySyncResult result,
|
||||||
|
GooglePlayPurchaseSnapshot snapshot) {
|
||||||
|
if (result.getLinkedPurchaseTokenToSync() == null || result.getLinkedPurchaseTokenToSync().isBlank()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
GooglePlayPurchaseSnapshot linkedSnapshot = apiClient.getSubscription(command.getPackageName(), result.getLinkedPurchaseTokenToSync());
|
||||||
|
GooglePlaySyncCommand linkedCommand = command.toBuilder()
|
||||||
|
.purchaseToken(result.getLinkedPurchaseTokenToSync())
|
||||||
|
.productType(GooglePlayConstants.PRODUCT_TYPE_SUBSCRIPTION)
|
||||||
|
.productId(linkedSnapshot.getProductId())
|
||||||
|
.userId(resolveUserId(result.getLinkedPurchaseTokenToSync(), linkedSnapshot))
|
||||||
|
.notificationName("LINKED_TOKEN_SYNC")
|
||||||
|
.build();
|
||||||
|
GooglePlaySyncResult linkedResult = stateService.sync(linkedCommand, linkedSnapshot);
|
||||||
|
finalizePurchase(linkedSnapshot, linkedResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long resolveUserId(String purchaseToken, GooglePlayPurchaseSnapshot snapshot) {
|
||||||
|
GooglePlayPurchaseToken localToken = stateService.findToken(purchaseToken);
|
||||||
|
if (localToken != null && localToken.getUserId() != null) {
|
||||||
|
return localToken.getUserId();
|
||||||
|
}
|
||||||
|
String externalAccountId = snapshot.getExternalAccountId();
|
||||||
|
if (externalAccountId == null || !externalAccountId.matches("\\d+")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Long.parseLong(externalAccountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolvePackageName(String packageName) {
|
||||||
|
if (packageName != null && !packageName.isBlank()) {
|
||||||
|
return packageName;
|
||||||
|
}
|
||||||
|
if (properties.getPackageName() == null || properties.getPackageName().isBlank()) {
|
||||||
|
throw new BusinessException(ErrorCode.GOOGLE_PLAY_PACKAGE_NAME_MISSING);
|
||||||
|
}
|
||||||
|
return properties.getPackageName();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeProductType(String rawType) {
|
||||||
|
String lowerCase = rawType.toLowerCase(Locale.ROOT);
|
||||||
|
if (lowerCase.contains("sub")) {
|
||||||
|
return GooglePlayConstants.PRODUCT_TYPE_SUBSCRIPTION;
|
||||||
|
}
|
||||||
|
return GooglePlayConstants.PRODUCT_TYPE_ONE_TIME;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ import com.yolo.keyborad.service.KeyboardAiCompanionService;
|
|||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
@@ -36,6 +37,8 @@ import java.util.stream.Collectors;
|
|||||||
@Service
|
@Service
|
||||||
public class KeyboardAiCompanionServiceImpl extends ServiceImpl<KeyboardAiCompanionMapper, KeyboardAiCompanion> implements KeyboardAiCompanionService {
|
public class KeyboardAiCompanionServiceImpl extends ServiceImpl<KeyboardAiCompanionMapper, KeyboardAiCompanion> implements KeyboardAiCompanionService {
|
||||||
|
|
||||||
|
private static final String DEFAULT_FALLBACK_LOCALE = "en";
|
||||||
|
|
||||||
@Resource
|
@Resource
|
||||||
private KeyboardAiCompanionLikeService companionLikeService;
|
private KeyboardAiCompanionLikeService companionLikeService;
|
||||||
|
|
||||||
@@ -321,11 +324,36 @@ public class KeyboardAiCompanionServiceImpl extends ServiceImpl<KeyboardAiCompan
|
|||||||
vo.setName(i18n.getName());
|
vo.setName(i18n.getName());
|
||||||
vo.setShortDesc(i18n.getShortDesc());
|
vo.setShortDesc(i18n.getShortDesc());
|
||||||
vo.setIntroText(i18n.getIntroText());
|
vo.setIntroText(i18n.getIntroText());
|
||||||
|
vo.setPrologue(i18n.getPrologue());
|
||||||
|
vo.setPrologueAudio(i18n.getPrologueAudio());
|
||||||
return vo;
|
return vo;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Map<Long, KeyboardAiCompanionI18n> getCompanionI18nMap(List<Long> companionIds, String acceptLanguage) {
|
private Map<Long, KeyboardAiCompanionI18n> getCompanionI18nMap(List<Long> companionIds, String acceptLanguage) {
|
||||||
String locale = RequestLocaleUtils.resolveLanguage(acceptLanguage);
|
String locale = RequestLocaleUtils.resolveLanguage(acceptLanguage);
|
||||||
|
if (companionIds == null || companionIds.isEmpty() || !StringUtils.hasText(locale)) {
|
||||||
|
return Collections.emptyMap();
|
||||||
|
}
|
||||||
|
Map<Long, KeyboardAiCompanionI18n> requestedLocaleMap = queryI18nMap(companionIds, locale);
|
||||||
|
if (requestedLocaleMap.size() == companionIds.size() || DEFAULT_FALLBACK_LOCALE.equalsIgnoreCase(locale)) {
|
||||||
|
return requestedLocaleMap;
|
||||||
|
}
|
||||||
|
List<Long> missingCompanionIds = companionIds.stream()
|
||||||
|
.filter(companionId -> !requestedLocaleMap.containsKey(companionId))
|
||||||
|
.toList();
|
||||||
|
if (missingCompanionIds.isEmpty()) {
|
||||||
|
return requestedLocaleMap;
|
||||||
|
}
|
||||||
|
Map<Long, KeyboardAiCompanionI18n> fallbackMap = queryI18nMap(missingCompanionIds, DEFAULT_FALLBACK_LOCALE);
|
||||||
|
if (fallbackMap.isEmpty()) {
|
||||||
|
return requestedLocaleMap;
|
||||||
|
}
|
||||||
|
Map<Long, KeyboardAiCompanionI18n> mergedMap = new LinkedHashMap<>(requestedLocaleMap);
|
||||||
|
fallbackMap.forEach(mergedMap::putIfAbsent);
|
||||||
|
return mergedMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<Long, KeyboardAiCompanionI18n> queryI18nMap(List<Long> companionIds, String locale) {
|
||||||
if (companionIds == null || companionIds.isEmpty() || !StringUtils.hasText(locale)) {
|
if (companionIds == null || companionIds.isEmpty() || !StringUtils.hasText(locale)) {
|
||||||
return Collections.emptyMap();
|
return Collections.emptyMap();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import java.util.stream.Stream;
|
|||||||
@Service
|
@Service
|
||||||
public class KeyboardCharacterServiceImpl extends ServiceImpl<KeyboardCharacterMapper, KeyboardCharacter> implements KeyboardCharacterService{
|
public class KeyboardCharacterServiceImpl extends ServiceImpl<KeyboardCharacterMapper, KeyboardCharacter> implements KeyboardCharacterService{
|
||||||
|
|
||||||
|
private static final String DEFAULT_FALLBACK_LOCALE = "en";
|
||||||
private static final String CHARACTER_CACHE_KEY_PREFIX = "character:";
|
private static final String CHARACTER_CACHE_KEY_PREFIX = "character:";
|
||||||
private static final String CHARACTER_LIST_CACHE_KEY = "character:list:all";
|
private static final String CHARACTER_LIST_CACHE_KEY = "character:list:all";
|
||||||
private static final String CHARACTER_TAG_CACHE_KEY_PREFIX = "character:list:tag:";
|
private static final String CHARACTER_TAG_CACHE_KEY_PREFIX = "character:list:tag:";
|
||||||
@@ -158,6 +159,9 @@ public class KeyboardCharacterServiceImpl extends ServiceImpl<KeyboardCharacterM
|
|||||||
public List<KeyboardUserCharacterVO> selectListByUserId(String acceptLanguage) {
|
public List<KeyboardUserCharacterVO> selectListByUserId(String acceptLanguage) {
|
||||||
long loginId = StpUtil.getLoginIdAsLong();
|
long loginId = StpUtil.getLoginIdAsLong();
|
||||||
String locale = RequestLocaleUtils.resolveLanguage(acceptLanguage);
|
String locale = RequestLocaleUtils.resolveLanguage(acceptLanguage);
|
||||||
|
if (!StringUtils.hasText(locale)) {
|
||||||
|
locale = DEFAULT_FALLBACK_LOCALE;
|
||||||
|
}
|
||||||
return keyboardUserCharacterMapper.selectByUserId(loginId, locale);
|
return keyboardUserCharacterMapper.selectByUserId(loginId, locale);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,7 +276,7 @@ public class KeyboardCharacterServiceImpl extends ServiceImpl<KeyboardCharacterM
|
|||||||
KeyboardUserCharacterAddDTO keyboardUserCharacterAddDTO = new KeyboardUserCharacterAddDTO();
|
KeyboardUserCharacterAddDTO keyboardUserCharacterAddDTO = new KeyboardUserCharacterAddDTO();
|
||||||
|
|
||||||
// 获取所有人设列表(未登录状态),并限制取前5个
|
// 获取所有人设列表(未登录状态),并限制取前5个
|
||||||
Stream<KeyboardCharacterRespVO> limit = selectListWithNotLoginRank(null).stream().limit(5);
|
Stream<KeyboardCharacterRespVO> limit = selectListWithNotLoginRank(null).stream();
|
||||||
|
|
||||||
// 遍历前5个人设,为用户添加默认人设
|
// 遍历前5个人设,为用户添加默认人设
|
||||||
limit.forEach(character -> {
|
limit.forEach(character -> {
|
||||||
@@ -353,6 +357,29 @@ public class KeyboardCharacterServiceImpl extends ServiceImpl<KeyboardCharacterM
|
|||||||
if (characterIds == null || characterIds.isEmpty()) {
|
if (characterIds == null || characterIds.isEmpty()) {
|
||||||
return Collections.emptyMap();
|
return Collections.emptyMap();
|
||||||
}
|
}
|
||||||
|
Map<Long, KeyboardCharacterI18n> requestedLocaleMap = queryI18nMap(characterIds, locale);
|
||||||
|
if (requestedLocaleMap.size() == characterIds.size() || DEFAULT_FALLBACK_LOCALE.equalsIgnoreCase(locale)) {
|
||||||
|
return requestedLocaleMap;
|
||||||
|
}
|
||||||
|
List<Long> missingCharacterIds = characterIds.stream()
|
||||||
|
.filter(characterId -> !requestedLocaleMap.containsKey(characterId))
|
||||||
|
.toList();
|
||||||
|
if (missingCharacterIds.isEmpty()) {
|
||||||
|
return requestedLocaleMap;
|
||||||
|
}
|
||||||
|
Map<Long, KeyboardCharacterI18n> fallbackMap = queryI18nMap(missingCharacterIds, DEFAULT_FALLBACK_LOCALE);
|
||||||
|
if (fallbackMap.isEmpty()) {
|
||||||
|
return requestedLocaleMap;
|
||||||
|
}
|
||||||
|
Map<Long, KeyboardCharacterI18n> mergedMap = new LinkedHashMap<>(requestedLocaleMap);
|
||||||
|
fallbackMap.forEach(mergedMap::putIfAbsent);
|
||||||
|
return mergedMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<Long, KeyboardCharacterI18n> queryI18nMap(List<Long> characterIds, String locale) {
|
||||||
|
if (characterIds == null || characterIds.isEmpty() || !StringUtils.hasText(locale)) {
|
||||||
|
return Collections.emptyMap();
|
||||||
|
}
|
||||||
List<KeyboardCharacterI18n> i18nList = keyboardCharacterI18nMapper.selectList(
|
List<KeyboardCharacterI18n> i18nList = keyboardCharacterI18nMapper.selectList(
|
||||||
new LambdaQueryWrapper<KeyboardCharacterI18n>()
|
new LambdaQueryWrapper<KeyboardCharacterI18n>()
|
||||||
.eq(KeyboardCharacterI18n::getLocale, locale)
|
.eq(KeyboardCharacterI18n::getLocale, locale)
|
||||||
|
|||||||
@@ -17,45 +17,41 @@ public class KeyboardProductItemsServiceImpl extends ServiceImpl<KeyboardProduct
|
|||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据ID获取产品详情
|
* 根据ID和平台获取产品详情
|
||||||
*
|
*
|
||||||
* @param id 产品ID
|
* @param id 产品ID
|
||||||
* @return 产品详情响应对象,如果ID为空或未找到产品则返回null
|
* @return 产品详情响应对象,如果未找到产品则返回null
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public KeyboardProductItemRespVO getProductDetailById(Long id) {
|
public KeyboardProductItemRespVO getProductDetailById(Long id) {
|
||||||
// 参数校验:ID不能为空
|
|
||||||
if (id == null) {
|
if (id == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 根据ID查询产品信息
|
KeyboardProductItems item = this.lambdaQuery()
|
||||||
KeyboardProductItems item = this.getById(id);
|
.eq(KeyboardProductItems::getId, id)
|
||||||
|
.one();
|
||||||
// 将实体对象转换为响应VO对象并返回
|
|
||||||
return item == null ? null : BeanUtil.copyProperties(item, KeyboardProductItemRespVO.class);
|
return item == null ? null : BeanUtil.copyProperties(item, KeyboardProductItemRespVO.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据产品ID获取产品详情
|
* 根据产品ID和平台获取产品详情
|
||||||
*
|
*
|
||||||
* @param productId 产品ID
|
* @param productId 产品ID
|
||||||
* @return 产品详情响应对象,如果产品ID为空或未找到产品则返回null
|
* @return 产品详情响应对象,如果未找到产品则返回null
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public KeyboardProductItemRespVO getProductDetailByProductId(String productId) {
|
public KeyboardProductItemRespVO getProductDetailByProductId(String productId) {
|
||||||
// 参数校验:产品ID不能为空
|
|
||||||
if (productId == null || productId.isBlank()) {
|
if (productId == null || productId.isBlank()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 根据产品ID查询产品信息
|
|
||||||
KeyboardProductItems item = this.lambdaQuery()
|
KeyboardProductItems item = this.lambdaQuery()
|
||||||
.eq(KeyboardProductItems::getProductId, productId)
|
.eq(KeyboardProductItems::getProductId, productId)
|
||||||
.one();
|
.one();
|
||||||
|
|
||||||
// 将实体对象转换为响应VO对象并返回
|
|
||||||
return item == null ? null : BeanUtil.copyProperties(item, KeyboardProductItemRespVO.class);
|
return item == null ? null : BeanUtil.copyProperties(item, KeyboardProductItemRespVO.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,14 +73,19 @@ public class KeyboardProductItemsServiceImpl extends ServiceImpl<KeyboardProduct
|
|||||||
* @return 产品详情响应列表,按ID升序排列
|
* @return 产品详情响应列表,按ID升序排列
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public java.util.List<KeyboardProductItemRespVO> listProductsByType(String type) {
|
public java.util.List<KeyboardProductItemRespVO> listProductsByType(String type, String platform) {
|
||||||
// 创建Lambda查询构造器
|
// 创建Lambda查询构造器
|
||||||
var query = this.lambdaQuery();
|
var query = this.lambdaQuery();
|
||||||
|
|
||||||
// 如果类型参数有效且不是"all",则添加类型过滤条件
|
// 如果类型参数有效且不是"all",则添加类型过滤条件
|
||||||
if (type != null && !type.isBlank() && !"all".equalsIgnoreCase(type)) {
|
if (type != null && !type.isBlank() && !"all".equalsIgnoreCase(type)) {
|
||||||
query.eq(KeyboardProductItems::getType, type);
|
query.eq(KeyboardProductItems::getType, type);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 根据平台过滤商品
|
||||||
|
if (platform != null && !platform.isBlank()) {
|
||||||
|
query.eq(KeyboardProductItems::getPlatform, platform);
|
||||||
|
}
|
||||||
|
|
||||||
// 执行查询,按ID升序排列
|
// 执行查询,按ID升序排列
|
||||||
java.util.List<KeyboardProductItems> items = query
|
java.util.List<KeyboardProductItems> items = query
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ public class KeyboardThemePurchaseServiceImpl extends ServiceImpl<KeyboardThemeP
|
|||||||
(short) 1, // 交易类型:1-购买主题
|
(short) 1, // 交易类型:1-购买主题
|
||||||
beforeBalance, // 交易前余额
|
beforeBalance, // 交易前余额
|
||||||
afterBalance, // 交易后余额
|
afterBalance, // 交易后余额
|
||||||
"购买主题: " + theme.getThemeName() // 交易备注
|
theme.getThemeName() // 交易备注
|
||||||
);
|
);
|
||||||
|
|
||||||
// 8. 更新购买记录的交易ID
|
// 8. 更新购买记录的交易ID
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
package com.yolo.keyborad.service.impl;
|
package com.yolo.keyborad.service.impl;
|
||||||
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
import java.util.List;
|
|
||||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||||
import com.yolo.keyborad.mapper.KeyboardUserPurchaseRecordsMapper;
|
import com.yolo.keyborad.mapper.KeyboardUserPurchaseRecordsMapper;
|
||||||
import com.yolo.keyborad.model.entity.KeyboardUserPurchaseRecords;
|
import com.yolo.keyborad.model.entity.KeyboardUserPurchaseRecords;
|
||||||
@@ -11,8 +10,19 @@ import com.yolo.keyborad.service.KeyboardUserPurchaseRecordsService;
|
|||||||
* @author: ziin
|
* @author: ziin
|
||||||
* @date: 2025/12/12 15:16
|
* @date: 2025/12/12 15:16
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class KeyboardUserPurchaseRecordsServiceImpl extends ServiceImpl<KeyboardUserPurchaseRecordsMapper, KeyboardUserPurchaseRecords> implements KeyboardUserPurchaseRecordsService{
|
public class KeyboardUserPurchaseRecordsServiceImpl extends ServiceImpl<KeyboardUserPurchaseRecordsMapper, KeyboardUserPurchaseRecords> implements KeyboardUserPurchaseRecordsService{
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean checkPurchaseExists(String transactionId, String originalTransactionId) {
|
||||||
|
LambdaQueryWrapper<KeyboardUserPurchaseRecords> queryWrapper = new LambdaQueryWrapper<>();
|
||||||
|
if (transactionId != null && !transactionId.isBlank()) {
|
||||||
|
queryWrapper.eq(KeyboardUserPurchaseRecords::getTransactionId, transactionId);
|
||||||
|
}
|
||||||
|
if (originalTransactionId != null && !originalTransactionId.isBlank()) {
|
||||||
|
queryWrapper.eq(KeyboardUserPurchaseRecords::getOriginalTransactionId, originalTransactionId);
|
||||||
|
}
|
||||||
|
return exists(queryWrapper);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import org.springframework.stereotype.Service;
|
|||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
@@ -27,6 +28,8 @@ import java.util.stream.Collectors;
|
|||||||
@Service
|
@Service
|
||||||
public class TagServiceImpl extends ServiceImpl<KeyboardTagMapper, KeyboardTag> implements TagService {
|
public class TagServiceImpl extends ServiceImpl<KeyboardTagMapper, KeyboardTag> implements TagService {
|
||||||
|
|
||||||
|
private static final String DEFAULT_FALLBACK_LOCALE = "en";
|
||||||
|
|
||||||
@Resource
|
@Resource
|
||||||
private KeyboardTagMapper keyboardTagMapper;
|
private KeyboardTagMapper keyboardTagMapper;
|
||||||
|
|
||||||
@@ -63,6 +66,29 @@ public class TagServiceImpl extends ServiceImpl<KeyboardTagMapper, KeyboardTag>
|
|||||||
if (tagIds == null || tagIds.isEmpty()) {
|
if (tagIds == null || tagIds.isEmpty()) {
|
||||||
return Collections.emptyMap();
|
return Collections.emptyMap();
|
||||||
}
|
}
|
||||||
|
Map<Integer, KeyboardTagI18n> requestedLocaleMap = queryI18nMap(tagIds, locale);
|
||||||
|
if (requestedLocaleMap.size() == tagIds.size() || DEFAULT_FALLBACK_LOCALE.equalsIgnoreCase(locale)) {
|
||||||
|
return requestedLocaleMap;
|
||||||
|
}
|
||||||
|
List<Integer> missingTagIds = tagIds.stream()
|
||||||
|
.filter(tagId -> !requestedLocaleMap.containsKey(tagId))
|
||||||
|
.toList();
|
||||||
|
if (missingTagIds.isEmpty()) {
|
||||||
|
return requestedLocaleMap;
|
||||||
|
}
|
||||||
|
Map<Integer, KeyboardTagI18n> fallbackMap = queryI18nMap(missingTagIds, DEFAULT_FALLBACK_LOCALE);
|
||||||
|
if (fallbackMap.isEmpty()) {
|
||||||
|
return requestedLocaleMap;
|
||||||
|
}
|
||||||
|
Map<Integer, KeyboardTagI18n> mergedMap = new LinkedHashMap<>(requestedLocaleMap);
|
||||||
|
fallbackMap.forEach(mergedMap::putIfAbsent);
|
||||||
|
return mergedMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<Integer, KeyboardTagI18n> queryI18nMap(List<Integer> tagIds, String locale) {
|
||||||
|
if (tagIds == null || tagIds.isEmpty() || !StringUtils.hasText(locale)) {
|
||||||
|
return Collections.emptyMap();
|
||||||
|
}
|
||||||
List<KeyboardTagI18n> i18nList = keyboardTagI18nMapper.selectList(
|
List<KeyboardTagI18n> i18nList = keyboardTagI18nMapper.selectList(
|
||||||
new LambdaQueryWrapper<KeyboardTagI18n>()
|
new LambdaQueryWrapper<KeyboardTagI18n>()
|
||||||
.in(KeyboardTagI18n::getTagId, tagIds)
|
.in(KeyboardTagI18n::getTagId, tagIds)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.yolo.keyborad.service.impl;
|
|||||||
|
|
||||||
import cn.dev33.satoken.stp.StpUtil;
|
import cn.dev33.satoken.stp.StpUtil;
|
||||||
import cn.hutool.core.bean.BeanUtil;
|
import cn.hutool.core.bean.BeanUtil;
|
||||||
|
import cn.hutool.core.lang.UUID;
|
||||||
import cn.hutool.core.util.IdUtil;
|
import cn.hutool.core.util.IdUtil;
|
||||||
import cn.hutool.core.util.RandomUtil;
|
import cn.hutool.core.util.RandomUtil;
|
||||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
@@ -26,6 +27,8 @@ import org.springframework.stereotype.Service;
|
|||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import com.yolo.keyborad.service.impl.user.UserInviteCodeBinder;
|
import com.yolo.keyborad.service.impl.user.UserInviteCodeBinder;
|
||||||
import com.yolo.keyborad.service.impl.user.UserMailVerificationHandler;
|
import com.yolo.keyborad.service.impl.user.UserMailVerificationHandler;
|
||||||
@@ -89,8 +92,26 @@ public class UserServiceImpl extends ServiceImpl<KeyboardUserMapper, KeyboardUse
|
|||||||
.eq(KeyboardUser::getStatus, false));
|
.eq(KeyboardUser::getStatus, false));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static final int ACCOUNT_REUSE_COOLDOWN_DAYS = 7;
|
||||||
|
|
||||||
|
private void ensureSubjectIdNotRecentlyCancelled(String sub) {
|
||||||
|
Date cooldownStart = Date.from(Instant.now().minus(ACCOUNT_REUSE_COOLDOWN_DAYS, ChronoUnit.DAYS));
|
||||||
|
KeyboardUser recentlyDeleted = keyboardUserMapper.selectOne(
|
||||||
|
new LambdaQueryWrapper<KeyboardUser>()
|
||||||
|
.eq(KeyboardUser::getSubjectId, sub)
|
||||||
|
.eq(KeyboardUser::getDeleted, true)
|
||||||
|
.gt(KeyboardUser::getDeletedAt, cooldownStart)
|
||||||
|
.last("LIMIT 1")
|
||||||
|
);
|
||||||
|
if (recentlyDeleted != null) {
|
||||||
|
throw new BusinessException(ErrorCode.ACCOUNT_RECENTLY_CANCELLED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public KeyboardUser createUserWithSubjectId(String sub) {
|
public KeyboardUser createUserWithSubjectId(String sub) {
|
||||||
|
ensureSubjectIdNotRecentlyCancelled(sub);
|
||||||
|
|
||||||
KeyboardUser keyboardUser = buildNewUserWithSubjectId(sub);
|
KeyboardUser keyboardUser = buildNewUserWithSubjectId(sub);
|
||||||
keyboardUserMapper.insert(keyboardUser);
|
keyboardUserMapper.insert(keyboardUser);
|
||||||
keyboardCharacterService.addDefaultUserCharacter(keyboardUser.getId());
|
keyboardCharacterService.addDefaultUserCharacter(keyboardUser.getId());
|
||||||
@@ -198,11 +219,20 @@ public class UserServiceImpl extends ServiceImpl<KeyboardUserMapper, KeyboardUse
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Long selectUserByUUid(String uuid) {
|
||||||
|
KeyboardUser keyboardUserDB = keyboardUserMapper.selectOne(
|
||||||
|
new LambdaQueryWrapper<KeyboardUser>()
|
||||||
|
.eq(KeyboardUser::getUuid, uuid));
|
||||||
|
return keyboardUserDB.getId();
|
||||||
|
}
|
||||||
|
|
||||||
private KeyboardUser buildNewUserWithSubjectId(String sub) {
|
private KeyboardUser buildNewUserWithSubjectId(String sub) {
|
||||||
KeyboardUser keyboardUser = new KeyboardUser();
|
KeyboardUser keyboardUser = new KeyboardUser();
|
||||||
keyboardUser.setSubjectId(sub);
|
keyboardUser.setSubjectId(sub);
|
||||||
keyboardUser.setUid(IdUtil.getSnowflake().nextId());
|
keyboardUser.setUid(IdUtil.getSnowflake().nextId());
|
||||||
keyboardUser.setNickName("User_" + RandomUtil.randomString(6));
|
keyboardUser.setNickName("User_" + RandomUtil.randomString(6));
|
||||||
|
keyboardUser.setUuid(IdUtil.randomUUID());
|
||||||
return keyboardUser;
|
return keyboardUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import com.yolo.keyborad.service.KeyboardUserQuotaTotalService;
|
|||||||
import com.yolo.keyborad.service.KeyboardUserWalletService;
|
import com.yolo.keyborad.service.KeyboardUserWalletService;
|
||||||
import com.yolo.keyborad.utils.RedisUtil;
|
import com.yolo.keyborad.utils.RedisUtil;
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
@@ -33,6 +35,7 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
public class UserRegistrationHandler {
|
public class UserRegistrationHandler {
|
||||||
|
|
||||||
private static final String USER_CODE_PREFIX = "user:";
|
private static final String USER_CODE_PREFIX = "user:";
|
||||||
|
private static final int ACCOUNT_REUSE_COOLDOWN_DAYS = 7;
|
||||||
|
|
||||||
private final KeyboardUserMapper keyboardUserMapper;
|
private final KeyboardUserMapper keyboardUserMapper;
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
@@ -91,6 +94,24 @@ public class UserRegistrationHandler {
|
|||||||
if (userMail != null) {
|
if (userMail != null) {
|
||||||
throw new BusinessException(ErrorCode.USER_HAS_EXISTED);
|
throw new BusinessException(ErrorCode.USER_HAS_EXISTED);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ensureNotRecentlyCancelled(
|
||||||
|
new LambdaQueryWrapper<KeyboardUser>()
|
||||||
|
.eq(KeyboardUser::getEmail, mailAddress)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureNotRecentlyCancelled(LambdaQueryWrapper<KeyboardUser> baseQuery) {
|
||||||
|
Date cooldownStart = Date.from(Instant.now().minus(ACCOUNT_REUSE_COOLDOWN_DAYS, ChronoUnit.DAYS));
|
||||||
|
KeyboardUser recentlyDeleted = keyboardUserMapper.selectOne(
|
||||||
|
baseQuery
|
||||||
|
.eq(KeyboardUser::getDeleted, true)
|
||||||
|
.gt(KeyboardUser::getDeletedAt, cooldownStart)
|
||||||
|
.last("LIMIT 1")
|
||||||
|
);
|
||||||
|
if (recentlyDeleted != null) {
|
||||||
|
throw new BusinessException(ErrorCode.ACCOUNT_RECENTLY_CANCELLED);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void validatePasswords(UserRegisterDTO userRegisterDTO) {
|
private void validatePasswords(UserRegisterDTO userRegisterDTO) {
|
||||||
@@ -118,6 +139,7 @@ public class UserRegistrationHandler {
|
|||||||
keyboardUser.setEmail(userRegisterDTO.getMailAddress());
|
keyboardUser.setEmail(userRegisterDTO.getMailAddress());
|
||||||
keyboardUser.setGender(userRegisterDTO.getGender());
|
keyboardUser.setGender(userRegisterDTO.getGender());
|
||||||
keyboardUser.setEmailVerified(true);
|
keyboardUser.setEmailVerified(true);
|
||||||
|
keyboardUser.setUuid(IdUtil.randomUUID());
|
||||||
return keyboardUser;
|
return keyboardUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,21 @@ apple:
|
|||||||
- "classpath:AppleRootCA-G2.cer"
|
- "classpath:AppleRootCA-G2.cer"
|
||||||
- "classpath:AppleRootCA-G3.cer"
|
- "classpath:AppleRootCA-G3.cer"
|
||||||
|
|
||||||
|
google:
|
||||||
|
play:
|
||||||
|
enabled: true
|
||||||
|
package-name: "com.boshan.key.of.love"
|
||||||
|
service-account-key-path: "classpath:keyboard-490601-ee503a425cc4.json"
|
||||||
|
oauth-token-uri: "https://oauth2.googleapis.com/token"
|
||||||
|
android-publisher-scope: "https://www.googleapis.com/auth/androidpublisher"
|
||||||
|
pubsub-token-info-uri: "https://oauth2.googleapis.com/tokeninfo"
|
||||||
|
validate-pubsub-jwt: true
|
||||||
|
require-obfuscated-account-id: false
|
||||||
|
pubsub:
|
||||||
|
expected-topic: "projects/keyboard-490601/topics/keyboard_topic"
|
||||||
|
expected-subscription: "projects/keyboard-490601/subscriptions/keyboard_topic-sub"
|
||||||
|
service-account-email: "id-220@keyboard-490601.iam.gserviceaccount.com"
|
||||||
|
|
||||||
dromara:
|
dromara:
|
||||||
x-file-storage: #文件存储配置
|
x-file-storage: #文件存储配置
|
||||||
default-platform: cloudflare-r2 #默认使用的存储平台
|
default-platform: cloudflare-r2 #默认使用的存储平台
|
||||||
@@ -104,7 +119,7 @@ nacos:
|
|||||||
elevenlabs:
|
elevenlabs:
|
||||||
api-key: sk_25339d32bb14c91f460ed9fce83a1951672f07846a7a10ce
|
api-key: sk_25339d32bb14c91f460ed9fce83a1951672f07846a7a10ce
|
||||||
voice-id: JBFqnCBsd6RMkjVDRZzb
|
voice-id: JBFqnCBsd6RMkjVDRZzb
|
||||||
model-id: eleven_turbo_v2_5
|
model-id: eleven_flash_v2_5
|
||||||
output-format: mp3_44100_128
|
output-format: mp3_44100_128
|
||||||
|
|
||||||
deepgram:
|
deepgram:
|
||||||
@@ -112,4 +127,4 @@ deepgram:
|
|||||||
model: nova-2
|
model: nova-2
|
||||||
language: en
|
language: en
|
||||||
smart-format: true
|
smart-format: true
|
||||||
punctuate: true
|
punctuate: true
|
||||||
|
|||||||
@@ -50,6 +50,21 @@ apple:
|
|||||||
- "classpath:AppleRootCA-G2.cer"
|
- "classpath:AppleRootCA-G2.cer"
|
||||||
- "classpath:AppleRootCA-G3.cer"
|
- "classpath:AppleRootCA-G3.cer"
|
||||||
|
|
||||||
|
google:
|
||||||
|
play:
|
||||||
|
enabled: true
|
||||||
|
package-name: "com.boshan.key.of.love"
|
||||||
|
service-account-key-path: "classpath:keyboard-490601-ee503a425cc4.json"
|
||||||
|
oauth-token-uri: "https://oauth2.googleapis.com/token"
|
||||||
|
android-publisher-scope: "https://www.googleapis.com/auth/androidpublisher"
|
||||||
|
pubsub-token-info-uri: "https://oauth2.googleapis.com/tokeninfo"
|
||||||
|
validate-pubsub-jwt: true
|
||||||
|
require-obfuscated-account-id: false
|
||||||
|
pubsub:
|
||||||
|
expected-topic: "projects/keyboard-490601/topics/keyboard_topic"
|
||||||
|
expected-subscription: "projects/keyboard-490601/subscriptions/keyboard_topic-sub"
|
||||||
|
service-account-email: "id-220@keyboard-490601.iam.gserviceaccount.com"
|
||||||
|
|
||||||
nacos:
|
nacos:
|
||||||
config:
|
config:
|
||||||
server-addr: 127.0.0.1:8848
|
server-addr: 127.0.0.1:8848
|
||||||
@@ -76,7 +91,7 @@ sa-token:
|
|||||||
elevenlabs:
|
elevenlabs:
|
||||||
api-key: sk_25339d32bb14c91f460ed9fce83a1951672f07846a7a10ce
|
api-key: sk_25339d32bb14c91f460ed9fce83a1951672f07846a7a10ce
|
||||||
voice-id: JBFqnCBsd6RMkjVDRZzb
|
voice-id: JBFqnCBsd6RMkjVDRZzb
|
||||||
model-id: eleven_turbo_v2_5
|
model-id: eleven_flash_v2_5
|
||||||
output-format: mp3_44100_128
|
output-format: mp3_44100_128
|
||||||
|
|
||||||
deepgram:
|
deepgram:
|
||||||
|
|||||||
13
src/main/resources/keyboard-490601-ee503a425cc4.json
Normal file
13
src/main/resources/keyboard-490601-ee503a425cc4.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"type": "service_account",
|
||||||
|
"project_id": "keyboard-490601",
|
||||||
|
"private_key_id": "ee503a425cc4eca9024352f5f588a93b2280c2d6",
|
||||||
|
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCezXyzVJQht9FE\n/Ghrf7uo45kzSL5UrznBYN7k85gTca997kNKzdj5vb0Y6DMwN9SXBjA9D8GQVLcM\nt4WhqIC26/T1UPDkXT7Zwk+3o/doYXeg0RIKFWN0D528lWxR4qafE0xz31HxcYpR\nmo1n7gpM1LKCHBEdylamsGUYZCJjE4UvSOZGUMiJRRponf/xLxNC8nwIEpygEfUr\n1QGnVe84jfLhnFCSkxzQNLkbDf7i2fWyI0HK1hM/r4SMCJuWV8Z9JqzpijOnPWBt\nc4VMOmHpWZKxkHL91K5nx4yedrqaMctHMvQD7hcutWVxM4aTzCQQ1477u1EN8Ud1\n5xBg1zS1AgMBAAECggEAD9+sgmSG9iPguEueyHgZMxWlH7o6xE5LsKfVP/+ViQQJ\nLcZeVaDj+nrb5xx22XALQRluQvxLNfkx3wSNSA6G50II12jC26DygmPpAgtS1M1B\nXwLnEbj3mwdglhQ9oqXMUARm1QJSt5bI48VWMzhZMTrlqRnTIC40oS7qvBhuU4bc\nrNjr49y+THCs34eBRgbyQl5JeZoOsO1iShkJsIwRPq6nUcYoXOS0UvgeJSNnyyW2\nyERPSKsvWtCCNkRFCM898axE7pbfxHXbOq4I507nrOqj8fc0+WeDA4ipEse/amlm\ngyQsB3ossHeEaRtSMiX/iJL+ogkQHAcB624UO75TYQKBgQDN4teFg4xDr8XlDg2n\noAXFz1NvC8pvfPusJD5x0+9yp4HE7DfifvleDdwTCrnw/09jR/S57UmLm1fJ5Vg4\nOISBfjhzrM87Y+ziyARTMmpVcji3qvxxhTJE3/z1lT64to+HiYhoC1aIkKixzGun\nkySjqVWwa1l16+iRU6y5ep3R/QKBgQDFdMmCVlcsIqbmQHvqNqhpf1LGzkQtzU7S\nmr47+wjxJWRC4z3QyR2B92+fQROAA5rAwxSjuphKmiYPV4mIaMueruP2JVSISpj+\nHR62F8nB/VlUJNGOUtO2WyNP+Zg1s00x3G7UM29+4gmKjF4TO0ODbhdBqiWDjjyH\n7Sj2gDVvGQKBgGZhD8T/piceZ+y/8UBSjaxQrW+B0HdiEhAGsdqOhfpgm2IeCKgj\ndcM0ZyQ86DuT6Zk39dTizviSFbR6zESgrhtqdY3n9+DTjr8ysRvh7QhyVQvYBdI5\nZsbjDvnb7iWR+otuc5sxUCV2sbxAJ6RbwhN0J/0jVIgT+EET98b/1yzNAoGAeant\nI8cJbWNojQT8lSLagC54lZCwh7wyLig0wQZ7ywIsFd6ozwWsjdElUO4rEryl1NIe\n3IdzoLv8aYWZc5iGph7CzX7Q6C12uVS+AJsFsObm7KbHDDfSxVDAoF19QNFa5jcZ\nfty2fWrPUDQPHzBr+AaUg9xPwxCYEXS6wa/bvLkCgYEAsZXYI4u53tpawcqazYzZ\nitFqEXvKbTuFneVhqOtbLIo2Iqr9AmfPo2YfUjO2QJVQDd+a1BQNFnnHkZoO4Sj1\nXynMcA6hu81qi4IZ9mQJq6F7BSpDddQRssgYVjqlYaV4V4+4Bwj3OJLOHSl33nV9\n4t67bLfWuMHCLQWIqnGrRhY=\n-----END PRIVATE KEY-----\n",
|
||||||
|
"client_email": "id-220@keyboard-490601.iam.gserviceaccount.com",
|
||||||
|
"client_id": "103246842098216114468",
|
||||||
|
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||||
|
"token_uri": "https://oauth2.googleapis.com/token",
|
||||||
|
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
||||||
|
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/id-220%40keyboard-490601.iam.gserviceaccount.com",
|
||||||
|
"universe_domain": "googleapis.com"
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
SELECT
|
SELECT
|
||||||
kuc.id,
|
kuc.id,
|
||||||
kuc.character_id,
|
kuc.character_id,
|
||||||
kci.character_name,
|
COALESCE(kci.character_name, kci_en.character_name) AS character_name,
|
||||||
kuc.emoji
|
kuc.emoji
|
||||||
FROM keyboard_user_character kuc
|
FROM keyboard_user_character kuc
|
||||||
JOIN keyboard_user_sort kus
|
JOIN keyboard_user_sort kus
|
||||||
@@ -17,6 +17,9 @@
|
|||||||
LEFT JOIN keyboard_character_i18n kci
|
LEFT JOIN keyboard_character_i18n kci
|
||||||
ON kuc.character_id = kci.character_id
|
ON kuc.character_id = kci.character_id
|
||||||
AND kci."locale" = #{locale}
|
AND kci."locale" = #{locale}
|
||||||
|
LEFT JOIN keyboard_character_i18n kci_en
|
||||||
|
ON kuc.character_id = kci_en.character_id
|
||||||
|
AND kci_en."locale" = 'en'
|
||||||
WHERE kuc.user_id = #{loginId}
|
WHERE kuc.user_id = #{loginId}
|
||||||
AND kuc.deleted = FALSE
|
AND kuc.deleted = FALSE
|
||||||
ORDER BY array_position(kus.user_characteu_id_sort, kuc.id) NULLS LAST;
|
ORDER BY array_position(kus.user_characteu_id_sort, kuc.id) NULLS LAST;
|
||||||
|
|||||||
Reference in New Issue
Block a user