Compare commits

...

20 Commits

Author SHA1 Message Date
005e621ab2 fix(config): 更新 Apple App ID 配置
将 dev 与 prod 环境的 app-apple-id 从 1234567890 替换为 6755171905,确保与最新 Apple 配置保持一致。
2026-04-20 17:56:08 +08:00
3f7304c3ab 修正AI陪聊列表返回最后聊天时间 2026-04-20 17:36:38 +08:00
4ca6d36e80 实现google登录和用户账号绑定 2026-04-20 13:38:57 +08:00
e657a22b10 refactor(googleplay): 新增并发发货防护并优化订单状态流转 2026-04-10 17:39:41 +08:00
d654777a02 refactor(googleplay): 支持purchaseOptionId作为备选商品ID 2026-04-10 14:30:53 +08:00
c8f8311cae fix(core): 过滤文本括号并升级语音模型至 flash 版本 2026-04-09 16:48:47 +08:00
20f8d9c152 refactor(service): 重构用户注册逻辑并新增注销校验 2026-04-09 15:23:18 +08:00
52727dfd7c refactor(core): 统一使用产品名称替换产品ID
将 ApplePurchaseServiceImpl、GooglePlayEntitlementApplier 与 GooglePlayWalletBenefitService 中记录的 productId 改为 productName,保持日志与业务语义一致。
2026-04-09 14:04:26 +08:00
9b4819900d refactor(service): 简化交易备注中的固定前缀文案 2026-04-09 13:49:56 +08:00
cdfeace2f1 refactor(service): 重构Apple购买服务并新增一次性购买处理 2026-04-09 11:29:23 +08:00
3665596c1f refactor(core): 重构用户与购买记录逻辑并添加 UUID 字段 2026-04-09 10:42:48 +08:00
06e7828b85 refactor(core): 迁移开场白字段至i18n表并简化实体 2026-04-08 17:53:39 +08:00
b83957e0bc refactor(core): 重构Google Play订阅与商品接口逻辑 2026-04-08 17:33:36 +08:00
da3ee94924 refactor(product): 新增平台字段区分安卓与苹果商品
- 在商品实体、VO、Service及Controller中统一增加platform字段
- 查询接口支持按平台(android/apple)过滤商品
- ChatService追加全局companionSystemPrompt配置读取
2026-04-08 09:29:46 +08:00
e027918387 chore(config): 关闭生产环境混淆账号ID校验 2026-04-07 10:08:54 +08:00
02dd37ffaf 修改个人键盘多语言兜底策略 2026-04-03 16:43:43 +08:00
dbc7ee365d 修改新用户注册添加键盘人设的数量 2026-04-03 16:14:08 +08:00
5220a22cbd 修复googlePlay购买验证问题 2026-04-03 16:06:45 +08:00
83cb65a31f feat(googleplay): 新增购买成功记录服务并注入使用 2026-04-03 16:06:45 +08:00
0555f1d0df 实施Google Play购买验证和RTDN处理 2026-04-03 16:06:39 +08:00
85 changed files with 4382 additions and 388 deletions

12
.gitignore vendored
View File

@@ -38,6 +38,16 @@ build/
/CLAUDE.md
/AGENTS.md
/src/test/
!/src/test/
/src/test/**
!/src/test/java/
!/src/test/java/com/
!/src/test/java/com/yolo/
!/src/test/java/com/yolo/keyborad/
!/src/test/java/com/yolo/keyborad/controller/
!/src/test/java/com/yolo/keyborad/controller/KeyboardAppVersionsControllerTest.java
!/src/test/java/com/yolo/keyborad/service/
!/src/test/java/com/yolo/keyborad/service/UserCancellationRegistrationTest.java
/.claude/agents/backend-architect.md
/.dockerignore
/Dockerfile
@@ -51,3 +61,5 @@ build/
/src/main/resources/static/ws-test.html
/.omc/
/logs/
/src/main/resources/sql/google-play-iap.sql
/docs/google-play-iap-integration.md

14
pom.xml
View File

@@ -233,6 +233,18 @@
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- Google ID Token 服务端验签 -->
<dependency>
<groupId>com.google.api-client</groupId>
<artifactId>google-api-client</artifactId>
<version>2.8.1</version>
</dependency>
<dependency>
<groupId>com.google.http-client</groupId>
<artifactId>google-http-client-gson</artifactId>
<version>1.47.0</version>
</dependency>
<!-- mailerSender 邮件服务 -->
<dependency>
<groupId>com.mailersend</groupId>
@@ -316,4 +328,4 @@
</plugins>
</build>
</project>
</project>

View File

@@ -2,6 +2,8 @@ package com.yolo.keyborad;
import com.yolo.keyborad.common.xfile.ByteFileWrapperAdapter;
import com.yolo.keyborad.config.AppleAppStoreProperties;
import com.yolo.keyborad.config.GoogleLoginProperties;
import com.yolo.keyborad.config.GooglePlayProperties;
import lombok.extern.slf4j.Slf4j;
import org.dromara.x.file.storage.core.tika.ContentTypeDetect;
import org.dromara.x.file.storage.spring.EnableFileStorage;
@@ -14,7 +16,11 @@ import org.springframework.context.annotation.Bean;
@Slf4j
@SpringBootApplication
@EnableConfigurationProperties(AppleAppStoreProperties.class)
@EnableConfigurationProperties({
AppleAppStoreProperties.class,
GooglePlayProperties.class,
GoogleLoginProperties.class
})
@EnableFileStorage
public class MyApplication {
public static void main(String[] args) {

View File

@@ -19,6 +19,8 @@ public enum ErrorCode {
SYSTEM_ERROR(50000, "系统内部异常"),
OPERATION_ERROR(50001, "操作失败"),
APPLE_LOGIN_ERROR(40003, "Apple登录失败"),
GOOGLE_LOGIN_ERROR(40023, "Google登录失败"),
GOOGLE_LOGIN_CSRF_INVALID(40024, "Google登录CSRF校验失败"),
FILE_IS_EMPTY(40001, "上传文件为空"),
FILE_NAME_ERROR(40002, "文件名错误"),
FILE_TYPE_ERROR(40004, "文件类型不支持,仅支持图片格式"),
@@ -76,9 +78,18 @@ public enum ErrorCode {
AUDIO_FILE_TOO_LARGE(40017, "音频文件过大"),
AUDIO_FORMAT_NOT_SUPPORTED(40018, "音频格式不支持"),
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_COMPANION_ID_EMPTY(40021, "被举报的AI角色ID不能为空"),
REPORT_TYPE_EMPTY(40022, "举报类型不能为空"),
ACCOUNT_RECENTLY_CANCELLED(50038, "账号注销未满7天暂不允许注册"),
GOOGLE_LOGIN_DISABLED(50039, "Google登录未开启"),
GOOGLE_LOGIN_BIND_REQUIRED(50040, "该邮箱已注册请使用原登录方式登录后再绑定Google账号"),
VERSION_NOT_FOUND(40022, "未找到可用的版本配置");
/**
@@ -104,4 +115,4 @@ public enum ErrorCode {
public String getCodeAsString() {
return String.valueOf(code);
}
}
}

View File

@@ -26,6 +26,8 @@ public class AppConfig {
private LLmModel llmModel = new LLmModel();
@Data
public static class UserRegisterProperties {
@@ -64,6 +66,8 @@ public class AppConfig {
//聊天消息最大长度
private Integer maxMessageLength = 1000;
private String companionSystemPrompt = "";
}
@Data

View File

@@ -0,0 +1,31 @@
package com.yolo.keyborad.config;
import java.util.ArrayList;
import java.util.List;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
@Data
@ConfigurationProperties(prefix = "google.login")
public class GoogleLoginProperties {
/**
* 是否开启 Google 登录。
*/
private boolean enabled = false;
/**
* 是否启用 GIS 的 CSRF 双提交校验。
*/
private boolean csrfCheckEnabled = false;
/**
* 允许访问当前后端的 Google Client ID 白名单。
*/
private List<String> clientIds = new ArrayList<>();
/**
* 可选:限制 Google Workspace 域。
*/
private String hostedDomain;
}

View File

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

View File

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

View File

@@ -86,8 +86,12 @@ public class SaTokenConfigure implements WebMvcConfigurer {
"/character/listByTagWithNotLogin",
"/ai-companion/report",
"/apple/notification",
"/google-play/rtdn",
"/appVersions/checkUpdate",
"/character/detailWithNotLogin"
"/appVersions/checkUpdate",
"/character/detailWithNotLogin",
"/apple/validate-receipt",
"/user/googleLogin"
};
}
@Bean

View File

@@ -7,6 +7,7 @@ import com.yolo.keyborad.common.ResultUtils;
import com.yolo.keyborad.exception.BusinessException;
import com.yolo.keyborad.model.dto.AppleReceiptValidationResult;
import com.yolo.keyborad.service.ApplePurchaseService;
import com.yolo.keyborad.service.KeyboardUserPurchaseRecordsService;
import com.yolo.keyborad.service.AppleReceiptService;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
@@ -23,11 +24,14 @@ public class AppleReceiptController {
private final AppleReceiptService appleReceiptService;
private final ApplePurchaseService applePurchaseService;
private final KeyboardUserPurchaseRecordsService purchaseRecordsService;
public AppleReceiptController(AppleReceiptService appleReceiptService,
ApplePurchaseService applePurchaseService) {
ApplePurchaseService applePurchaseService,
KeyboardUserPurchaseRecordsService purchaseRecordsService) {
this.appleReceiptService = appleReceiptService;
this.applePurchaseService = applePurchaseService;
this.purchaseRecordsService = purchaseRecordsService;
}
@PostMapping("/receipt")
@@ -85,4 +89,26 @@ public class AppleReceiptController {
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);
}
}

View File

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

View File

@@ -11,6 +11,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
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.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@@ -31,14 +32,14 @@ public class ProductsController {
private KeyboardProductItemsService productItemsService;
@GetMapping("/detail")
@Operation(summary = "查询商品明细", description = "根据商品ID或productId查询商品详情")
@Operation(summary = "查询商品明细", description = "根据商品ID或productId查询商品详情通过platform区分平台")
public BaseResponse<KeyboardProductItemRespVO> getProductDetail(
@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())) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "id 或 productId 至少传一个");
}
// 判断平台如果是android返回安卓商品否则默认返回苹果商品
KeyboardProductItemRespVO result = (id != null)
? productItemsService.getProductDetailById(id)
: productItemsService.getProductDetailByProductId(productId);
@@ -47,25 +48,29 @@ public class ProductsController {
@GetMapping("/listByType")
@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()) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "type 不能为空");
}
List<KeyboardProductItemRespVO> result = productItemsService.listProductsByType(type);
List<KeyboardProductItemRespVO> result = productItemsService.listProductsByType(type, platform);
return ResultUtils.success(result);
}
@GetMapping("/inApp/list")
@Operation(summary = "查询内购商品列表", description = "查询 type=in-app-purchase 的商品列表")
public BaseResponse<List<KeyboardProductItemRespVO>> listInAppPurchases() {
List<KeyboardProductItemRespVO> result = productItemsService.listProductsByType("in-app-purchase");
public BaseResponse<List<KeyboardProductItemRespVO>> listInAppPurchases(
@RequestHeader(value = "platform", required = false) String platform) {
List<KeyboardProductItemRespVO> result = productItemsService.listProductsByType("in-app-purchase", platform);
return ResultUtils.success(result);
}
@GetMapping("/subscription/list")
@Operation(summary = "查询订阅商品列表", description = "查询 type=subscription 的商品列表")
public BaseResponse<List<KeyboardProductItemRespVO>> listSubscriptions() {
List<KeyboardProductItemRespVO> result = productItemsService.listProductsByType("subscription");
public BaseResponse<List<KeyboardProductItemRespVO>> listSubscriptions(
@RequestHeader(value = "platform", required = false) String platform) {
List<KeyboardProductItemRespVO> result = productItemsService.listProductsByType("subscription", platform);
return ResultUtils.success(result);
}
}

View File

@@ -7,6 +7,7 @@ import com.yolo.keyborad.common.ResultUtils;
import com.yolo.keyborad.config.AppConfig;
import com.yolo.keyborad.config.NacosAppConfigCenter;
import com.yolo.keyborad.model.dto.AppleLoginReq;
import com.yolo.keyborad.model.dto.GoogleLoginReq;
import com.yolo.keyborad.model.dto.user.*;
import com.yolo.keyborad.model.entity.KeyboardFeedback;
import com.yolo.keyborad.model.entity.KeyboardUser;
@@ -14,6 +15,7 @@ import com.yolo.keyborad.model.entity.KeyboardUserInviteCodes;
import com.yolo.keyborad.model.vo.user.InviteCodeRespVO;
import com.yolo.keyborad.model.vo.user.KeyboardUserInfoRespVO;
import com.yolo.keyborad.model.vo.user.KeyboardUserRespVO;
import com.yolo.keyborad.service.GoogleLoginService;
import com.yolo.keyborad.service.IAppleService;
import com.yolo.keyborad.service.KeyboardFeedbackService;
import com.yolo.keyborad.service.KeyboardUserInviteCodesService;
@@ -46,6 +48,9 @@ public class UserController {
@Resource
private UserService userService;
@Resource
private GoogleLoginService googleLoginService;
@Resource
private KeyboardFeedbackService feedbackService;
@@ -69,6 +74,13 @@ public class UserController {
return ResultUtils.success(appleService.login(appleLoginReq.getIdentityToken(), request));
}
@PostMapping("/googleLogin")
@Operation(summary = "Google 登录", description = "Google 登录接口")
public BaseResponse<KeyboardUserRespVO> googleLogin(@RequestBody GoogleLoginReq googleLoginReq,
HttpServletRequest request) {
return ResultUtils.success(googleLoginService.login(googleLoginReq, request));
}
@GetMapping("/logout")
@Operation(summary = "退出登录", description = "退出登录接口")
public BaseResponse<Boolean> logout() {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 序列化失败");
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,11 @@
package com.yolo.keyborad.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.yolo.keyborad.model.dto.chat.ChattedCompanionLastChatDTO;
import com.yolo.keyborad.model.entity.KeyboardAiChatMessage;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/*
* @author: ziin
@@ -9,4 +13,12 @@ import com.yolo.keyborad.model.entity.KeyboardAiChatMessage;
*/
public interface KeyboardAiChatMessageMapper extends BaseMapper<KeyboardAiChatMessage> {
}
/**
* 查询用户在当前活跃会话中与每个AI角色的最后聊天时间
*
* @param userId 用户ID
* @return 最近聊天时间聚合结果
*/
List<ChattedCompanionLastChatDTO> selectLastChattedCompanions(@Param("userId") Long userId);
}

View File

@@ -0,0 +1,38 @@
package com.yolo.keyborad.model.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import org.springframework.util.StringUtils;
@Data
public class GoogleLoginReq {
/**
* 客户端自定义传参时使用的 ID Token 字段。
*/
private String idToken;
/**
* GIS 默认回传的 credential 字段。
*/
private String credential;
@JsonProperty("g_csrf_token")
private String gCsrfToken;
@JsonProperty("client_id")
private String clientId;
/**
* 统一读取可用的 ID Token兼容多种前端传参格式。
*/
public String resolveIdToken() {
if (StringUtils.hasText(idToken)) {
return idToken.trim();
}
if (StringUtils.hasText(credential)) {
return credential.trim();
}
return null;
}
}

View File

@@ -0,0 +1,22 @@
package com.yolo.keyborad.model.dto.chat;
import lombok.Data;
import java.util.Date;
/**
* 用户与AI角色最近聊天时间聚合结果
*/
@Data
public class ChattedCompanionLastChatDTO {
/**
* AI陪聊角色ID
*/
private Long companionId;
/**
* 当前用户与该角色最后一次聊天时间
*/
private Date lastChattedAt;
}

View File

@@ -0,0 +1,20 @@
package com.yolo.keyborad.model.dto.googlelogin;
import org.springframework.util.StringUtils;
public record GoogleIdTokenPayload(
String subject,
String email,
Boolean emailVerified,
String name,
String pictureUrl,
String hostedDomain
) {
/**
* 仅当邮箱存在且已通过 Google 校验时,才认为可以安全复用。
*/
public boolean hasVerifiedEmail() {
return StringUtils.hasText(email) && Boolean.TRUE.equals(emailVerified);
}
}

View File

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

View File

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

View File

@@ -119,14 +119,6 @@ public class KeyboardAiCompanion {
@Schema(description="更新时间")
private Date updatedAt;
@TableField(value = "prologue")
@Schema(description="开场白")
private String prologue;
@TableField(value = "prologue_audio")
@Schema(description="开场白音频")
private String prologueAudio;
@TableField(value = "voice_id")
@Schema(description="角色音频Id")
private String voiceId;

View File

@@ -75,4 +75,13 @@ public class KeyboardAiCompanionI18n {
@TableField(value = "updated_at")
@Schema(description="更新时间")
private Date updatedAt;
@TableField(value = "prologue")
@Schema(description = "开场白")
private String prologue;
@TableField(value = "prologue_audio")
@Schema(description = "开场白音频")
private String prologueAudio;
}

View File

@@ -112,4 +112,8 @@ public class KeyboardProductItems {
@TableField(value = "level")
@Schema(description = "级别")
private Integer level;
@TableField(value = "platform")
@Schema(description = "所属平台")
private String platform;
}

View File

@@ -132,4 +132,15 @@ public class KeyboardUser {
@TableField(value = "vip_level")
@Schema(description = "vip等级")
private Integer vipLevel;
@TableField(value = "uuid")
@Schema(description = "uuid")
private String uuid;
/**
* Google 登录 subjectId
*/
@TableField(value = "google_subject_id")
@Schema(description = "Google 登录 subjectId")
private String googleSubjectId;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -45,6 +45,9 @@ public class KeyboardProductItemRespVO {
private String description;
@Schema(description = "级别")
private Integer level;
private Integer level;
@Schema(description = "所属平台")
private String platform;
}

View File

@@ -57,4 +57,6 @@ public class KeyboardUserInfoRespVO {
@Schema(description = "vip等级")
private Integer vipLevel;
@Schema(description = "uuid")
private String uuid;
}

View File

@@ -59,4 +59,8 @@ public class KeyboardUserRespVO {
@TableField(value = "vip_level")
@Schema(description = "vip等级")
private Integer vipLevel;
@TableField(value = "uuid")
@Schema(description = "uuid")
private String uuid;
}

View File

@@ -43,4 +43,11 @@ public interface ApplePurchaseService {
* @param notification 解码后的通知载荷
*/
void handleConsumptionRequest(ResponseBodyV2DecodedPayload notification);
/**
* 处理一次性购买通知ONE_TIME_CHARGE
*
* @param notification 解码后的通知载荷
*/
void handleOneTimeChargeNotification(ResponseBodyV2DecodedPayload notification);
}

View File

@@ -0,0 +1,10 @@
package com.yolo.keyborad.service;
import com.yolo.keyborad.model.dto.GoogleLoginReq;
import com.yolo.keyborad.model.vo.user.KeyboardUserRespVO;
import jakarta.servlet.http.HttpServletRequest;
public interface GoogleLoginService {
KeyboardUserRespVO login(GoogleLoginReq googleLoginReq, HttpServletRequest request);
}

View File

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

View File

@@ -5,7 +5,9 @@ import com.yolo.keyborad.model.entity.KeyboardAiChatMessage;
import com.baomidou.mybatisplus.extension.service.IService;
import com.yolo.keyborad.model.vo.ChatMessageHistoryVO;
import java.util.Date;
import java.util.List;
import java.util.Map;
/*
* @author: ziin
@@ -42,6 +44,14 @@ public interface KeyboardAiChatMessageService extends IService<KeyboardAiChatMes
*/
List<Long> getChattedCompanionIds(Long userId);
/**
* 获取用户与每个AI角色的最后聊天时间
*
* @param userId 用户ID
* @return 角色ID到最后聊天时间的映射顺序为最近聊天时间倒序
*/
Map<Long, Date> getLastChattedAtByCompanionId(Long userId);
/**
* 根据聊天记录ID逻辑删除消息
*

View File

@@ -12,7 +12,7 @@ import java.util.List;
public interface KeyboardProductItemsService extends IService<KeyboardProductItems>{
/**
* 根据主键ID查询商品明细
* 根据主键ID和平台查询商品明细
*
* @param id 商品主键ID
* @return 商品明细(不存在返回 null
@@ -20,7 +20,7 @@ public interface KeyboardProductItemsService extends IService<KeyboardProductIte
KeyboardProductItemRespVO getProductDetailById(Long id);
/**
* 根据 Apple productId 查询商品明细
* 根据 productId 和平台查询商品明细
*
* @param productId 商品 productId
* @return 商品明细(不存在返回 null
@@ -39,6 +39,6 @@ public interface KeyboardProductItemsService extends IService<KeyboardProductIte
* @param type 商品类型subscription / in-app-purchase / all
* @return 商品列表
*/
List<KeyboardProductItemRespVO> listProductsByType(String type);
List<KeyboardProductItemRespVO> listProductsByType(String type, String platform);
}

View File

@@ -9,5 +9,12 @@ import com.baomidou.mybatisplus.extension.service.IService;
public interface KeyboardUserPurchaseRecordsService extends IService<KeyboardUserPurchaseRecords>{
/**
* 检查购买记录是否存在
*
* @param transactionId 交易 ID
* @param originalTransactionId 原始交易 ID
* @return 存在返回 true不存在返回 false
*/
boolean checkPurchaseExists(String transactionId, String originalTransactionId);
}

View File

@@ -0,0 +1,17 @@
package com.yolo.keyborad.service;
import com.yolo.keyborad.model.dto.googlelogin.GoogleIdTokenPayload;
import com.yolo.keyborad.model.entity.KeyboardUser;
public interface ThirdPartyLoginUserService {
KeyboardUser selectAppleUser(String appleSubjectId);
KeyboardUser createAppleUser(String appleSubjectId);
KeyboardUser selectGoogleUser(String googleSubjectId);
KeyboardUser selectActiveUserByEmail(String email);
KeyboardUser createGoogleUser(GoogleIdTokenPayload payload);
}

View File

@@ -12,10 +12,6 @@ import jakarta.servlet.http.HttpServletRequest;
*/
public interface UserService extends IService<KeyboardUser> {
KeyboardUser selectUserWithSubjectId(String sub);
KeyboardUser createUserWithSubjectId(String sub);
KeyboardUserRespVO login(UserLoginDTO userLoginDTO, HttpServletRequest request);
Boolean updateUserInfo(KeyboardUserReq keyboardUser);
@@ -38,4 +34,5 @@ public interface UserService extends IService<KeyboardUser> {
*/
Boolean cancelAccount(long userId);
Long selectUserByUUid(String uuid);
}

View File

@@ -1,5 +1,6 @@
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.JWSTransactionDecodedPayload;
import com.apple.itunes.storekit.model.NotificationTypeV2;
@@ -34,6 +35,7 @@ import java.time.format.DateTimeParseException;
import java.util.Date;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
/**
* 苹果购买后置处理:订阅续期 / 内购充值 + 记录落库
@@ -152,23 +154,10 @@ public class ApplePurchaseServiceImpl implements ApplePurchaseService {
JWSTransactionDecodedPayload transaction =
signedDataVerifier.verifyAndDecodeTransaction(signedTransactionInfo);
String originalTransactionId = transaction.getOriginalTransactionId();
String productId = transaction.getProductId();
// 根据原始交易ID查询用户购买记录
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 originalTransactionId={}", originalTransactionId);
return;
}
KeyboardUserPurchaseRecords existingRecord = records.get(0);
Long userId = existingRecord.getUserId().longValue();
UUID appAccountToken = transaction.getAppAccountToken();
Long userId = userService.selectUserByUUid(appAccountToken.toString());
// 查询商品信息
KeyboardProductItems product = productItemsService.getProductEntityByProductId(productId);
@@ -354,6 +343,127 @@ public class ApplePurchaseServiceImpl implements ApplePurchaseService {
// 提供用户消费状态、交付状态等信息,帮助 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有效期
@@ -673,7 +783,7 @@ public class ApplePurchaseServiceImpl implements ApplePurchaseService {
(short) 2, // 交易类型2-苹果内购充值
before,
after,
"Apple 充值: " + product.getProductId()
product.getName()
);
// 8. 记录充值成功日志

View File

@@ -155,6 +155,7 @@ public class AppleReceiptServiceImpl implements AppleReceiptService {
// 续订偏好变更通知
case DID_CHANGE_RENEWAL_PREF:
case DID_CHANGE_RENEWAL_STATUS:
case PRICE_INCREASE:
applePurchaseService.handleRenewalPreferenceChange(notification);
@@ -165,9 +166,14 @@ public class AppleReceiptServiceImpl implements AppleReceiptService {
applePurchaseService.handleConsumptionRequest(notification);
break;
// 一次性购买通知
case ONE_TIME_CHARGE:
applePurchaseService.handleOneTimeChargeNotification(notification);
break;
// 其他通知类型(记录但不处理)
case EXTERNAL_PURCHASE_TOKEN:
case ONE_TIME_CHARGE:
case REVOKE:
case TEST:
log.info("Received notification type {} - no action required", type);

View File

@@ -1,6 +1,5 @@
package com.yolo.keyborad.service.impl;
import cn.dev33.satoken.stp.SaTokenInfo;
import cn.dev33.satoken.stp.StpUtil;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.http.HttpUtil;
@@ -11,9 +10,7 @@ import com.yolo.keyborad.exception.BusinessException;
import com.yolo.keyborad.model.entity.KeyboardUser;
import com.yolo.keyborad.model.vo.user.KeyboardUserRespVO;
import com.yolo.keyborad.service.IAppleService;
import com.yolo.keyborad.service.KeyboardUserLoginLogService;
import com.yolo.keyborad.service.UserService;
import com.yolo.keyborad.utils.RequestIpUtils;
import com.yolo.keyborad.service.ThirdPartyLoginUserService;
import jakarta.servlet.http.HttpServletRequest;
import io.jsonwebtoken.*;
import jakarta.annotation.Resource;
@@ -39,10 +36,10 @@ import java.util.Objects;
public class AppleServiceImpl implements IAppleService {
@Resource
private UserService userService;
private ThirdPartyLoginUserService thirdPartyLoginUserService;
@Resource
private KeyboardUserLoginLogService loginLogService;
private UserLoginAuditService userLoginAuditService;
/**
* 登录
@@ -92,54 +89,16 @@ public class AppleServiceImpl implements IAppleService {
// 返回用户标识符
if (result) {
KeyboardUser user = userService.selectUserWithSubjectId(sub);
KeyboardUser user = thirdPartyLoginUserService.selectAppleUser(sub);
boolean isNewUser = false;
if (user == null) {
user = userService.createUserWithSubjectId(sub);
user = thirdPartyLoginUserService.createAppleUser(sub);
isNewUser = true;
}
// 记录登录日志
try {
String ipAddress = RequestIpUtils.resolveClientIp(request);
String userAgent = request.getHeader("User-Agent");
String platform = "Unknown";
String os = "Unknown";
if (userAgent != null) {
if (userAgent.contains("iOS")) {
platform = "iOS";
} else if (userAgent.contains("Android")) {
platform = "Android";
}
if (userAgent.contains("Windows")) {
os = "Windows";
} else if (userAgent.contains("Mac OS")) {
os = "Mac OS";
} else if (userAgent.contains("Linux")) {
os = "Linux";
} else if (userAgent.contains("iOS")) {
os = "iOS";
} else if (userAgent.contains("Android")) {
os = "Android";
}
}
loginLogService.recordLoginLog(
user.getId(),
ipAddress,
userAgent,
os,
platform,
isNewUser ? "APPLE_NEW_USER" : "SUCCESS"
);
} catch (Exception e) {
log.error("记录Apple登录日志失败", e);
}
KeyboardUserRespVO keyboardUserRespVO = BeanUtil.copyProperties(user, KeyboardUserRespVO.class);
StpUtil.login(user.getId());
userLoginAuditService.recordLoginLog(user.getId(), request, isNewUser ? "APPLE_NEW_USER" : "SUCCESS");
keyboardUserRespVO.setToken(StpUtil.getTokenValueByLoginId(user.getId()));
return keyboardUserRespVO;
}

View File

@@ -413,7 +413,7 @@ public class ChatServiceImpl implements ChatService {
if (companion.getStatus() == null || companion.getStatus() != 1) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "AI陪聊角色已下线");
}
String systemPrompt = companion.getSystemPrompt();
String systemPrompt = appConfig.getLlmConfig().getCompanionSystemPrompt() + companion.getSystemPrompt();
String voiceId = companion.getVoiceId();
// 获取最近20条聊天记录作为上下文
@@ -538,7 +538,7 @@ public class ChatServiceImpl implements ChatService {
// 1. TTS 转换
long ttsStart = System.currentTimeMillis();
TextToSpeechVO ttsResult = elevenLabsService.textToSpeechWithTimestamps(text, voiceId);
TextToSpeechVO ttsResult = elevenLabsService.textToSpeechWithTimestamps(text.replaceAll("\\(.*?\\)", ""), voiceId);
long ttsDuration = System.currentTimeMillis() - ttsStart;
log.info("TTS 完成, audioId: {}, 耗时: {}ms", audioId, ttsDuration);

View File

@@ -0,0 +1,87 @@
package com.yolo.keyborad.service.impl;
import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken;
import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier;
import com.google.api.client.http.javanet.NetHttpTransport;
import com.google.api.client.json.gson.GsonFactory;
import com.yolo.keyborad.common.ErrorCode;
import com.yolo.keyborad.config.GoogleLoginProperties;
import com.yolo.keyborad.exception.BusinessException;
import com.yolo.keyborad.model.dto.googlelogin.GoogleIdTokenPayload;
import java.io.IOException;
import java.security.GeneralSecurityException;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
@Service
@Slf4j
@RequiredArgsConstructor
public class GoogleIdTokenVerifierService {
private final GoogleLoginProperties googleLoginProperties;
/**
* 使用 Google 官方 verifier 校验签名、aud、iss 与 exp。
*/
public GoogleIdTokenPayload verify(String idToken) {
validateVerifierConfig();
try {
GoogleIdToken googleIdToken = buildVerifier().verify(idToken);
if (googleIdToken == null) {
log.warn("Google ID Token 校验失败token 无效");
throw new BusinessException(ErrorCode.GOOGLE_LOGIN_ERROR);
}
GoogleIdToken.Payload payload = googleIdToken.getPayload();
validateSubject(payload);
validateHostedDomain(payload);
return new GoogleIdTokenPayload(
payload.getSubject(),
payload.getEmail(),
payload.getEmailVerified(),
(String) payload.get("name"),
(String) payload.get("picture"),
payload.getHostedDomain()
);
} catch (GeneralSecurityException | IOException e) {
log.error("Google ID Token 验签异常", e);
throw new BusinessException(ErrorCode.GOOGLE_LOGIN_ERROR);
}
}
private GoogleIdTokenVerifier buildVerifier() {
return new GoogleIdTokenVerifier.Builder(new NetHttpTransport(), GsonFactory.getDefaultInstance())
.setAudience(List.copyOf(googleLoginProperties.getClientIds()))
.build();
}
private void validateVerifierConfig() {
if (!googleLoginProperties.isEnabled()) {
throw new BusinessException(ErrorCode.GOOGLE_LOGIN_DISABLED);
}
if (googleLoginProperties.getClientIds() == null || googleLoginProperties.getClientIds().isEmpty()) {
log.error("Google 登录已开启,但未配置 clientIds");
throw new BusinessException(ErrorCode.GOOGLE_LOGIN_ERROR);
}
}
private void validateSubject(GoogleIdToken.Payload payload) {
if (!StringUtils.hasText(payload.getSubject())) {
log.warn("Google ID Token 缺少 subject");
throw new BusinessException(ErrorCode.GOOGLE_LOGIN_ERROR);
}
}
private void validateHostedDomain(GoogleIdToken.Payload payload) {
if (!StringUtils.hasText(googleLoginProperties.getHostedDomain())) {
return;
}
if (!googleLoginProperties.getHostedDomain().equals(payload.getHostedDomain())) {
log.warn("Google hosted domain 不匹配expected={}, actual={}",
googleLoginProperties.getHostedDomain(), payload.getHostedDomain());
throw new BusinessException(ErrorCode.GOOGLE_LOGIN_ERROR);
}
}
}

View File

@@ -0,0 +1,100 @@
package com.yolo.keyborad.service.impl;
import cn.dev33.satoken.stp.StpUtil;
import cn.hutool.core.bean.BeanUtil;
import com.yolo.keyborad.common.ErrorCode;
import com.yolo.keyborad.config.GoogleLoginProperties;
import com.yolo.keyborad.exception.BusinessException;
import com.yolo.keyborad.model.dto.GoogleLoginReq;
import com.yolo.keyborad.model.dto.googlelogin.GoogleIdTokenPayload;
import com.yolo.keyborad.model.entity.KeyboardUser;
import com.yolo.keyborad.model.vo.user.KeyboardUserRespVO;
import com.yolo.keyborad.service.GoogleLoginService;
import com.yolo.keyborad.service.ThirdPartyLoginUserService;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
@Service
@RequiredArgsConstructor
public class GoogleLoginServiceImpl implements GoogleLoginService {
private static final String GOOGLE_CSRF_COOKIE_NAME = "g_csrf_token";
private static final String GOOGLE_NEW_USER_STATUS = "GOOGLE_NEW_USER";
private final GoogleLoginProperties googleLoginProperties;
private final GoogleIdTokenVerifierService googleIdTokenVerifierService;
private final ThirdPartyLoginUserService thirdPartyLoginUserService;
private final UserLoginAuditService userLoginAuditService;
@Override
public KeyboardUserRespVO login(GoogleLoginReq googleLoginReq, HttpServletRequest request) {
String idToken = validateRequestAndResolveToken(googleLoginReq);
validateCsrfTokenIfRequired(googleLoginReq, request);
GoogleIdTokenPayload payload = googleIdTokenVerifierService.verify(idToken);
KeyboardUser user = thirdPartyLoginUserService.selectGoogleUser(payload.subject());
boolean isNewUser = false;
if (user == null) {
ensureEmailNotOccupied(payload.email());
user = thirdPartyLoginUserService.createGoogleUser(payload);
isNewUser = true;
}
StpUtil.login(user.getId());
KeyboardUserRespVO keyboardUserRespVO = BeanUtil.copyProperties(user, KeyboardUserRespVO.class);
keyboardUserRespVO.setToken(StpUtil.getTokenValue());
userLoginAuditService.recordLoginLog(user.getId(), request, isNewUser ? GOOGLE_NEW_USER_STATUS : "SUCCESS");
return keyboardUserRespVO;
}
private String validateRequestAndResolveToken(GoogleLoginReq googleLoginReq) {
if (googleLoginReq == null) {
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}
String idToken = googleLoginReq.resolveIdToken();
if (!StringUtils.hasText(idToken)) {
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}
return idToken;
}
/**
* 仅在配置开启时校验 GIS 的双提交 CSRF token。
*/
private void validateCsrfTokenIfRequired(GoogleLoginReq googleLoginReq, HttpServletRequest request) {
if (!googleLoginProperties.isCsrfCheckEnabled()) {
return;
}
String cookieToken = resolveCookieValue(request.getCookies(), GOOGLE_CSRF_COOKIE_NAME);
String bodyToken = googleLoginReq.getGCsrfToken();
if (!StringUtils.hasText(cookieToken) || !StringUtils.hasText(bodyToken) || !cookieToken.equals(bodyToken)) {
throw new BusinessException(ErrorCode.GOOGLE_LOGIN_CSRF_INVALID);
}
}
private String resolveCookieValue(Cookie[] cookies, String cookieName) {
if (cookies == null) {
return null;
}
for (Cookie cookie : cookies) {
if (cookieName.equals(cookie.getName())) {
return cookie.getValue();
}
}
return null;
}
private void ensureEmailNotOccupied(String email) {
if (!StringUtils.hasText(email)) {
return;
}
KeyboardUser existedUser = thirdPartyLoginUserService.selectActiveUserByEmail(email);
if (existedUser != null) {
throw new BusinessException(ErrorCode.GOOGLE_LOGIN_BIND_REQUIRED);
}
}
}

View File

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

View File

@@ -8,6 +8,7 @@ import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.yolo.keyborad.mapper.KeyboardAiChatMessageMapper;
import com.yolo.keyborad.common.ErrorCode;
import com.yolo.keyborad.exception.BusinessException;
import com.yolo.keyborad.model.dto.chat.ChattedCompanionLastChatDTO;
import com.yolo.keyborad.model.entity.KeyboardAiChatMessage;
import com.yolo.keyborad.model.entity.KeyboardAiChatSession;
import com.yolo.keyborad.model.vo.ChatMessageHistoryVO;
@@ -17,7 +18,11 @@ import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/*
* @author: ziin
@@ -83,35 +88,23 @@ public class KeyboardAiChatMessageServiceImpl extends ServiceImpl<KeyboardAiChat
@Override
public List<Long> getChattedCompanionIds(Long userId) {
// 1. 查询用户所有活跃会话
LambdaQueryWrapper<KeyboardAiChatSession> sessionWrapper = new LambdaQueryWrapper<>();
sessionWrapper.eq(KeyboardAiChatSession::getUserId, userId)
.eq(KeyboardAiChatSession::getIsActive, true);
List<KeyboardAiChatSession> activeSessions = sessionService.list(sessionWrapper);
return getLastChattedAtByCompanionId(userId).keySet().stream().toList();
}
// 2. 如果没有活跃会话,返回空列表
if (activeSessions == null || activeSessions.isEmpty()) {
return Collections.emptyList();
@Override
public Map<Long, Date> getLastChattedAtByCompanionId(Long userId) {
List<ChattedCompanionLastChatDTO> records = baseMapper.selectLastChattedCompanions(userId);
if (records.isEmpty()) {
return Collections.emptyMap();
}
// 3. 提取活跃会话的 sessionId 列表
List<Long> activeSessionIds = activeSessions.stream()
.map(KeyboardAiChatSession::getId)
.collect(java.util.stream.Collectors.toList());
// 4. 查询这些会话中的消息,获取 companionId按最近聊天时间倒序
LambdaQueryWrapper<KeyboardAiChatMessage> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(KeyboardAiChatMessage::getUserId, userId)
.in(KeyboardAiChatMessage::getSessionId, activeSessionIds)
.orderByDesc(KeyboardAiChatMessage::getCreatedAt);
List<KeyboardAiChatMessage> messages = this.list(queryWrapper);
// 5. 去重并保持顺序(按最近聊天时间)
return messages.stream()
.map(KeyboardAiChatMessage::getCompanionId)
.distinct()
.collect(java.util.stream.Collectors.toList());
// LinkedHashMap 保留 SQL 按最后聊天时间倒序返回的顺序。
return records.stream().collect(Collectors.toMap(
ChattedCompanionLastChatDTO::getCompanionId,
ChattedCompanionLastChatDTO::getLastChattedAt,
(left, right) -> left,
LinkedHashMap::new
));
}
@Override

View File

@@ -24,6 +24,7 @@ import com.yolo.keyborad.service.KeyboardAiCompanionService;
import org.springframework.util.StringUtils;
import java.util.Collections;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -193,21 +194,8 @@ public class KeyboardAiCompanionServiceImpl extends ServiceImpl<KeyboardAiCompan
.collect(Collectors.toList());
Map<Long, KeyboardAiCompanionI18n> i18nMap = getCompanionI18nMap(companionIds, acceptLanguage);
// 批量统计点赞数
LambdaQueryWrapper<KeyboardAiCompanionLike> likeWrapper = new LambdaQueryWrapper<>();
likeWrapper.in(KeyboardAiCompanionLike::getCompanionId, companionIds)
.eq(KeyboardAiCompanionLike::getStatus, (short) 1);
List<KeyboardAiCompanionLike> likes = companionLikeService.list(likeWrapper);
Map<Long, Long> likeCountMap = likes.stream()
.collect(Collectors.groupingBy(KeyboardAiCompanionLike::getCompanionId, Collectors.counting()));
// 批量统计评论数
LambdaQueryWrapper<KeyboardAiCompanionComment> commentWrapper = new LambdaQueryWrapper<>();
commentWrapper.in(KeyboardAiCompanionComment::getCompanionId, companionIds)
.eq(KeyboardAiCompanionComment::getStatus, (short) 1);
List<KeyboardAiCompanionComment> comments = companionCommentService.list(commentWrapper);
Map<Long, Long> commentCountMap = comments.stream()
.collect(Collectors.groupingBy(KeyboardAiCompanionComment::getCompanionId, Collectors.counting()));
Map<Long, Long> likeCountMap = countLikesByCompanionId(companionIds);
Map<Long, Long> commentCountMap = countCommentsByCompanionId(companionIds);
// 转换为VO并填充统计数据
return companions.stream().map(entity -> {
@@ -221,11 +209,12 @@ public class KeyboardAiCompanionServiceImpl extends ServiceImpl<KeyboardAiCompan
@Override
public List<AiCompanionVO> getChattedCompanions(Long userId, String acceptLanguage) {
// 获取用户聊过天的所有AI角色ID
List<Long> chattedCompanionIds = chatMessageService.getChattedCompanionIds(userId);
if (chattedCompanionIds.isEmpty()) {
// 获取用户聊过天的AI角色ID及每个角色的最后聊天时间
Map<Long, Date> lastChattedAtMap = chatMessageService.getLastChattedAtByCompanionId(userId);
if (lastChattedAtMap.isEmpty()) {
return List.of();
}
List<Long> chattedCompanionIds = lastChattedAtMap.keySet().stream().toList();
// 查询这些AI角色的详细信息只返回已上线且可见的
LambdaQueryWrapper<KeyboardAiCompanion> queryWrapper = new LambdaQueryWrapper<>();
@@ -244,21 +233,8 @@ public class KeyboardAiCompanionServiceImpl extends ServiceImpl<KeyboardAiCompan
.collect(Collectors.toList());
Map<Long, KeyboardAiCompanionI18n> i18nMap = getCompanionI18nMap(companionIds, acceptLanguage);
// 批量统计点赞数
LambdaQueryWrapper<KeyboardAiCompanionLike> likeWrapper = new LambdaQueryWrapper<>();
likeWrapper.in(KeyboardAiCompanionLike::getCompanionId, companionIds)
.eq(KeyboardAiCompanionLike::getStatus, (short) 1);
List<KeyboardAiCompanionLike> likes = companionLikeService.list(likeWrapper);
Map<Long, Long> likeCountMap = likes.stream()
.collect(Collectors.groupingBy(KeyboardAiCompanionLike::getCompanionId, Collectors.counting()));
// 批量统计评论数
LambdaQueryWrapper<KeyboardAiCompanionComment> commentWrapper = new LambdaQueryWrapper<>();
commentWrapper.in(KeyboardAiCompanionComment::getCompanionId, companionIds)
.eq(KeyboardAiCompanionComment::getStatus, (short) 1);
List<KeyboardAiCompanionComment> comments = companionCommentService.list(commentWrapper);
Map<Long, Long> commentCountMap = comments.stream()
.collect(Collectors.groupingBy(KeyboardAiCompanionComment::getCompanionId, Collectors.counting()));
Map<Long, Long> likeCountMap = countLikesByCompanionId(companionIds);
Map<Long, Long> commentCountMap = countCommentsByCompanionId(companionIds);
// 获取当前用户已点赞的角色ID
Set<Long> likedCompanionIds = companionLikeService.getLikedCompanionIds(userId, companionIds);
@@ -275,6 +251,7 @@ public class KeyboardAiCompanionServiceImpl extends ServiceImpl<KeyboardAiCompan
vo.setLikeCount(likeCountMap.getOrDefault(entity.getId(), 0L).intValue());
vo.setCommentCount(commentCountMap.getOrDefault(entity.getId(), 0L).intValue());
vo.setLiked(likedCompanionIds.contains(entity.getId()));
vo.setCreatedAt(lastChattedAtMap.get(entity.getId()));
return vo;
}).collect(Collectors.toList());
}
@@ -324,9 +301,39 @@ public class KeyboardAiCompanionServiceImpl extends ServiceImpl<KeyboardAiCompan
vo.setName(i18n.getName());
vo.setShortDesc(i18n.getShortDesc());
vo.setIntroText(i18n.getIntroText());
vo.setPrologue(i18n.getPrologue());
vo.setPrologueAudio(i18n.getPrologueAudio());
return vo;
}
private Map<Long, Long> countLikesByCompanionId(List<Long> companionIds) {
if (companionIds.isEmpty()) {
return Map.of();
}
LambdaQueryWrapper<KeyboardAiCompanionLike> likeWrapper = new LambdaQueryWrapper<>();
likeWrapper.in(KeyboardAiCompanionLike::getCompanionId, companionIds)
.eq(KeyboardAiCompanionLike::getStatus, (short) 1);
List<KeyboardAiCompanionLike> likes = companionLikeService.list(likeWrapper);
return likes.stream().collect(Collectors.groupingBy(
KeyboardAiCompanionLike::getCompanionId,
Collectors.counting()
));
}
private Map<Long, Long> countCommentsByCompanionId(List<Long> companionIds) {
if (companionIds.isEmpty()) {
return Map.of();
}
LambdaQueryWrapper<KeyboardAiCompanionComment> commentWrapper = new LambdaQueryWrapper<>();
commentWrapper.in(KeyboardAiCompanionComment::getCompanionId, companionIds)
.eq(KeyboardAiCompanionComment::getStatus, (short) 1);
List<KeyboardAiCompanionComment> comments = companionCommentService.list(commentWrapper);
return comments.stream().collect(Collectors.groupingBy(
KeyboardAiCompanionComment::getCompanionId,
Collectors.counting()
));
}
private Map<Long, KeyboardAiCompanionI18n> getCompanionI18nMap(List<Long> companionIds, String acceptLanguage) {
String locale = RequestLocaleUtils.resolveLanguage(acceptLanguage);
if (companionIds == null || companionIds.isEmpty() || !StringUtils.hasText(locale)) {

View File

@@ -159,6 +159,9 @@ public class KeyboardCharacterServiceImpl extends ServiceImpl<KeyboardCharacterM
public List<KeyboardUserCharacterVO> selectListByUserId(String acceptLanguage) {
long loginId = StpUtil.getLoginIdAsLong();
String locale = RequestLocaleUtils.resolveLanguage(acceptLanguage);
if (!StringUtils.hasText(locale)) {
locale = DEFAULT_FALLBACK_LOCALE;
}
return keyboardUserCharacterMapper.selectByUserId(loginId, locale);
}
@@ -273,7 +276,7 @@ public class KeyboardCharacterServiceImpl extends ServiceImpl<KeyboardCharacterM
KeyboardUserCharacterAddDTO keyboardUserCharacterAddDTO = new KeyboardUserCharacterAddDTO();
// 获取所有人设列表未登录状态并限制取前5个
Stream<KeyboardCharacterRespVO> limit = selectListWithNotLoginRank(null).stream().limit(5);
Stream<KeyboardCharacterRespVO> limit = selectListWithNotLoginRank(null).stream();
// 遍历前5个人设为用户添加默认人设
limit.forEach(character -> {

View File

@@ -17,45 +17,41 @@ public class KeyboardProductItemsServiceImpl extends ServiceImpl<KeyboardProduct
/**
* 根据ID获取产品详情
*
* 根据ID和平台获取产品详情
*
* @param id 产品ID
* @return 产品详情响应对象,如果ID为空或未找到产品则返回null
* @return 产品详情响应对象如果未找到产品则返回null
*/
@Override
public KeyboardProductItemRespVO getProductDetailById(Long id) {
// 参数校验ID不能为空
if (id == null) {
return null;
}
// 根据ID查询产品信息
KeyboardProductItems item = this.getById(id);
// 将实体对象转换为响应VO对象并返回
KeyboardProductItems item = this.lambdaQuery()
.eq(KeyboardProductItems::getId, id)
.one();
return item == null ? null : BeanUtil.copyProperties(item, KeyboardProductItemRespVO.class);
}
/**
* 根据产品ID获取产品详情
*
* 根据产品ID和平台获取产品详情
*
* @param productId 产品ID
* @return 产品详情响应对象,如果产品ID为空或未找到产品则返回null
* @return 产品详情响应对象如果未找到产品则返回null
*/
@Override
public KeyboardProductItemRespVO getProductDetailByProductId(String productId) {
// 参数校验产品ID不能为空
if (productId == null || productId.isBlank()) {
return null;
}
// 根据产品ID查询产品信息
KeyboardProductItems item = this.lambdaQuery()
.eq(KeyboardProductItems::getProductId, productId)
.one();
// 将实体对象转换为响应VO对象并返回
return item == null ? null : BeanUtil.copyProperties(item, KeyboardProductItemRespVO.class);
}
@@ -77,14 +73,19 @@ public class KeyboardProductItemsServiceImpl extends ServiceImpl<KeyboardProduct
* @return 产品详情响应列表按ID升序排列
*/
@Override
public java.util.List<KeyboardProductItemRespVO> listProductsByType(String type) {
public java.util.List<KeyboardProductItemRespVO> listProductsByType(String type, String platform) {
// 创建Lambda查询构造器
var query = this.lambdaQuery();
// 如果类型参数有效且不是"all",则添加类型过滤条件
if (type != null && !type.isBlank() && !"all".equalsIgnoreCase(type)) {
query.eq(KeyboardProductItems::getType, type);
}
// 根据平台过滤商品
if (platform != null && !platform.isBlank()) {
query.eq(KeyboardProductItems::getPlatform, platform);
}
// 执行查询按ID升序排列
java.util.List<KeyboardProductItems> items = query

View File

@@ -133,7 +133,7 @@ public class KeyboardThemePurchaseServiceImpl extends ServiceImpl<KeyboardThemeP
(short) 1, // 交易类型1-购买主题
beforeBalance, // 交易前余额
afterBalance, // 交易后余额
"购买主题: " + theme.getThemeName() // 交易备注
theme.getThemeName() // 交易备注
);
// 8. 更新购买记录的交易ID

View File

@@ -1,8 +1,7 @@
package com.yolo.keyborad.service.impl;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Autowired;
import java.util.List;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.yolo.keyborad.mapper.KeyboardUserPurchaseRecordsMapper;
import com.yolo.keyborad.model.entity.KeyboardUserPurchaseRecords;
@@ -11,8 +10,19 @@ import com.yolo.keyborad.service.KeyboardUserPurchaseRecordsService;
* @author: ziin
* @date: 2025/12/12 15:16
*/
@Service
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);
}
}

View File

@@ -0,0 +1,119 @@
package com.yolo.keyborad.service.impl;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.RandomUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.support.SFunction;
import com.yolo.keyborad.common.ErrorCode;
import com.yolo.keyborad.exception.BusinessException;
import com.yolo.keyborad.mapper.KeyboardUserMapper;
import com.yolo.keyborad.model.dto.googlelogin.GoogleIdTokenPayload;
import com.yolo.keyborad.model.entity.KeyboardUser;
import com.yolo.keyborad.service.ThirdPartyLoginUserService;
import com.yolo.keyborad.service.impl.user.NewUserAssetsInitializer;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Date;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
@Service
@Slf4j
@RequiredArgsConstructor
public class ThirdPartyLoginUserServiceImpl implements ThirdPartyLoginUserService {
private static final int ACCOUNT_REUSE_COOLDOWN_DAYS = 7;
private final KeyboardUserMapper keyboardUserMapper;
private final NewUserAssetsInitializer newUserAssetsInitializer;
@Override
public KeyboardUser selectAppleUser(String appleSubjectId) {
return selectActiveUser(KeyboardUser::getSubjectId, appleSubjectId);
}
@Override
@Transactional(rollbackFor = Exception.class)
public KeyboardUser createAppleUser(String appleSubjectId) {
ensureSubjectNotRecentlyCancelled(KeyboardUser::getSubjectId, appleSubjectId);
KeyboardUser keyboardUser = buildBaseThirdPartyUser();
keyboardUser.setSubjectId(appleSubjectId);
saveNewThirdPartyUser(keyboardUser);
log.info("User registered with Apple Sign-In, userId={}", keyboardUser.getId());
return keyboardUser;
}
@Override
public KeyboardUser selectGoogleUser(String googleSubjectId) {
return selectActiveUser(KeyboardUser::getGoogleSubjectId, googleSubjectId);
}
@Override
public KeyboardUser selectActiveUserByEmail(String email) {
if (!StringUtils.hasText(email)) {
return null;
}
return keyboardUserMapper.selectOne(new LambdaQueryWrapper<KeyboardUser>()
.eq(KeyboardUser::getEmail, email)
.eq(KeyboardUser::getDeleted, false)
.eq(KeyboardUser::getStatus, false)
.last("LIMIT 1"));
}
@Override
@Transactional(rollbackFor = Exception.class)
public KeyboardUser createGoogleUser(GoogleIdTokenPayload payload) {
ensureSubjectNotRecentlyCancelled(KeyboardUser::getGoogleSubjectId, payload.subject());
KeyboardUser keyboardUser = buildBaseThirdPartyUser();
keyboardUser.setGoogleSubjectId(payload.subject());
keyboardUser.setEmail(payload.email());
keyboardUser.setEmailVerified(Boolean.TRUE.equals(payload.emailVerified()));
keyboardUser.setAvatarUrl(payload.pictureUrl());
if (StringUtils.hasText(payload.name())) {
keyboardUser.setNickName(payload.name());
}
saveNewThirdPartyUser(keyboardUser);
log.info("User registered with Google Sign-In, userId={}, googleSubjectId={}",
keyboardUser.getId(), payload.subject());
return keyboardUser;
}
private KeyboardUser selectActiveUser(SFunction<KeyboardUser, ?> column, String value) {
return keyboardUserMapper.selectOne(new LambdaQueryWrapper<KeyboardUser>()
.eq(column, value)
.eq(KeyboardUser::getDeleted, false)
.eq(KeyboardUser::getStatus, false)
.last("LIMIT 1"));
}
private void ensureSubjectNotRecentlyCancelled(SFunction<KeyboardUser, ?> column, String value) {
Date cooldownStart = Date.from(Instant.now().minus(ACCOUNT_REUSE_COOLDOWN_DAYS, ChronoUnit.DAYS));
KeyboardUser recentlyDeleted = keyboardUserMapper.selectOne(new LambdaQueryWrapper<KeyboardUser>()
.eq(column, value)
.eq(KeyboardUser::getDeleted, true)
.gt(KeyboardUser::getDeletedAt, cooldownStart)
.last("LIMIT 1"));
if (recentlyDeleted != null) {
throw new BusinessException(ErrorCode.ACCOUNT_RECENTLY_CANCELLED);
}
}
private KeyboardUser buildBaseThirdPartyUser() {
KeyboardUser keyboardUser = new KeyboardUser();
keyboardUser.setUid(IdUtil.getSnowflake().nextId());
keyboardUser.setNickName("User_" + RandomUtil.randomString(6));
keyboardUser.setUuid(IdUtil.randomUUID());
return keyboardUser;
}
/**
* 第三方新用户统一走同一套初始化流程,避免 Apple / Google 资产配置不一致。
*/
private void saveNewThirdPartyUser(KeyboardUser keyboardUser) {
keyboardUserMapper.insert(keyboardUser);
newUserAssetsInitializer.initialize(keyboardUser.getId());
}
}

View File

@@ -0,0 +1,70 @@
package com.yolo.keyborad.service.impl;
import com.yolo.keyborad.service.KeyboardUserLoginLogService;
import com.yolo.keyborad.utils.RequestIpUtils;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@Service
@Slf4j
@RequiredArgsConstructor
public class UserLoginAuditService {
private final KeyboardUserLoginLogService loginLogService;
/**
* 统一记录登录日志,避免不同登录方式重复解析 User-Agent。
*/
public void recordLoginLog(Long userId, HttpServletRequest request, String status) {
try {
String userAgent = request.getHeader("User-Agent");
loginLogService.recordLoginLog(
userId,
RequestIpUtils.resolveClientIp(request),
userAgent,
resolveOs(userAgent),
resolvePlatform(userAgent),
status
);
} catch (Exception e) {
log.error("记录登录日志失败", e);
}
}
private String resolvePlatform(String userAgent) {
if (userAgent == null) {
return "Unknown";
}
if (userAgent.contains("iOS")) {
return "iOS";
}
if (userAgent.contains("Android")) {
return "Android";
}
return "Unknown";
}
private String resolveOs(String userAgent) {
if (userAgent == null) {
return "Unknown";
}
if (userAgent.contains("Windows")) {
return "Windows";
}
if (userAgent.contains("Mac OS")) {
return "Mac OS";
}
if (userAgent.contains("Linux")) {
return "Linux";
}
if (userAgent.contains("iOS")) {
return "iOS";
}
if (userAgent.contains("Android")) {
return "Android";
}
return "Unknown";
}
}

View File

@@ -2,43 +2,38 @@ package com.yolo.keyborad.service.impl;
import cn.dev33.satoken.stp.StpUtil;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.RandomUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.yolo.keyborad.common.ErrorCode;
import com.yolo.keyborad.config.AppConfig;
import com.yolo.keyborad.config.NacosAppConfigCenter;
import com.yolo.keyborad.exception.BusinessException;
import com.yolo.keyborad.mapper.KeyboardUserMapper;
import com.yolo.keyborad.model.dto.user.*;
import com.yolo.keyborad.model.dto.user.BindInviteCodeDTO;
import com.yolo.keyborad.model.dto.user.KeyboardUserReq;
import com.yolo.keyborad.model.dto.user.ResetPassWordDTO;
import com.yolo.keyborad.model.dto.user.SendMailDTO;
import com.yolo.keyborad.model.dto.user.UserLoginDTO;
import com.yolo.keyborad.model.dto.user.UserRegisterDTO;
import com.yolo.keyborad.model.dto.user.VerifyCodeDTO;
import com.yolo.keyborad.model.entity.KeyboardUser;
import com.yolo.keyborad.model.entity.KeyboardUserQuotaTotal;
import com.yolo.keyborad.model.entity.KeyboardUserWallet;
import com.yolo.keyborad.model.vo.user.KeyboardUserRespVO;
import com.yolo.keyborad.service.*;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.Date;
import com.yolo.keyborad.service.UserService;
import com.yolo.keyborad.service.impl.user.UserInviteCodeBinder;
import com.yolo.keyborad.service.impl.user.UserMailVerificationHandler;
import com.yolo.keyborad.service.impl.user.UserPasswordHandler;
import com.yolo.keyborad.service.impl.user.UserRegistrationHandler;
import com.yolo.keyborad.utils.RequestIpUtils;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletRequest;
import java.util.Date;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/*
* @author: ziin
* @date: 2025/12/2 18:19
*/
@Service
@Slf4j
public class UserServiceImpl extends ServiceImpl<KeyboardUserMapper, KeyboardUser> implements UserService {
@Resource
@@ -47,21 +42,6 @@ public class UserServiceImpl extends ServiceImpl<KeyboardUserMapper, KeyboardUse
@Resource
private PasswordEncoder passwordEncoder;
@Resource
private KeyboardCharacterService keyboardCharacterService;
@Resource
private KeyboardUserWalletService walletService;
@Resource
private KeyboardUserLoginLogService loginLogService;
@Resource
private KeyboardUserQuotaTotalService quotaTotalService;
@Resource
private KeyboardUserInviteCodesService inviteCodesService;
@Resource
private UserRegistrationHandler registrationHandler;
@@ -74,34 +54,10 @@ public class UserServiceImpl extends ServiceImpl<KeyboardUserMapper, KeyboardUse
@Resource
private UserInviteCodeBinder inviteCodeBinder;
private final NacosAppConfigCenter.DynamicAppConfig cfgHolder;
@Resource
private UserLoginAuditService userLoginAuditService;
public UserServiceImpl(NacosAppConfigCenter.DynamicAppConfig cfgHolder) {
this.cfgHolder = cfgHolder;
}
@Override
public KeyboardUser selectUserWithSubjectId(String sub) {
return keyboardUserMapper.selectOne(
new LambdaQueryWrapper<KeyboardUser>()
.eq(KeyboardUser::getSubjectId, sub)
.eq(KeyboardUser::getDeleted, false)
.eq(KeyboardUser::getStatus, false));
}
@Override
public KeyboardUser createUserWithSubjectId(String sub) {
KeyboardUser keyboardUser = buildNewUserWithSubjectId(sub);
keyboardUserMapper.insert(keyboardUser);
keyboardCharacterService.addDefaultUserCharacter(keyboardUser.getId());
AppConfig appConfig = cfgHolder.getRef().get();
initNewUserWalletAndQuota(keyboardUser.getId(), appConfig.getUserRegisterProperties().getFreeTrialQuota());
inviteCodesService.createInviteCode(keyboardUser.getId());
log.info("User registered with Apple Sign-In, userId={}, freeQuota={}",
keyboardUser.getId(), appConfig.getUserRegisterProperties().getFreeTrialQuota());
return keyboardUser;
public UserServiceImpl() {
}
@Override
@@ -118,9 +74,7 @@ public class UserServiceImpl extends ServiceImpl<KeyboardUserMapper, KeyboardUse
throw new BusinessException(ErrorCode.PASSWORD_OR_MAIL_ERROR);
}
StpUtil.login(keyboardUser.getId());
// 记录登录日志
recordLoginLogSafely(keyboardUser.getId(), request);
userLoginAuditService.recordLoginLog(keyboardUser.getId(), request, "SUCCESS");
KeyboardUserRespVO keyboardUserRespVO = BeanUtil.copyProperties(keyboardUser, KeyboardUserRespVO.class);
keyboardUserRespVO.setToken(StpUtil.getTokenValue());
@@ -198,88 +152,11 @@ public class UserServiceImpl extends ServiceImpl<KeyboardUserMapper, KeyboardUse
return true;
}
private KeyboardUser buildNewUserWithSubjectId(String sub) {
KeyboardUser keyboardUser = new KeyboardUser();
keyboardUser.setSubjectId(sub);
keyboardUser.setUid(IdUtil.getSnowflake().nextId());
keyboardUser.setNickName("User_" + RandomUtil.randomString(6));
return keyboardUser;
@Override
public Long selectUserByUUid(String uuid) {
KeyboardUser keyboardUserDB = keyboardUserMapper.selectOne(
new LambdaQueryWrapper<KeyboardUser>()
.eq(KeyboardUser::getUuid, uuid));
return keyboardUserDB.getId();
}
private void initNewUserWalletAndQuota(long userId, Integer freeTrialQuota) {
Date now = new Date();
KeyboardUserWallet wallet = new KeyboardUserWallet();
wallet.setUserId(userId);
wallet.setBalance(BigDecimal.valueOf(freeTrialQuota.longValue()));
wallet.setVersion(0);
wallet.setStatus((short) 1);
wallet.setCreatedAt(now);
wallet.setUpdatedAt(now);
walletService.save(wallet);
KeyboardUserQuotaTotal quotaTotal = new KeyboardUserQuotaTotal();
quotaTotal.setUserId(userId);
quotaTotal.setTotalQuota(freeTrialQuota);
quotaTotal.setUsedQuota(0);
quotaTotal.setVersion(0);
quotaTotal.setCreatedAt(now);
quotaTotal.setUpdatedAt(now);
quotaTotalService.save(quotaTotal);
}
private void recordLoginLogSafely(Long userId, HttpServletRequest request) {
try {
String ipAddress = RequestIpUtils.resolveClientIp(request);
String userAgent = request.getHeader("User-Agent");
String platform = resolvePlatform(userAgent);
String os = resolveOs(userAgent);
loginLogService.recordLoginLog(
userId,
ipAddress,
userAgent,
os,
platform,
"SUCCESS"
);
} catch (Exception e) {
log.error("记录登录日志失败", e);
}
}
private String resolvePlatform(String userAgent) {
if (userAgent == null) {
return "Unknown";
}
if (userAgent.contains("iOS")) {
return "iOS";
}
if (userAgent.contains("Android")) {
return "Android";
}
return "Unknown";
}
private String resolveOs(String userAgent) {
if (userAgent == null) {
return "Unknown";
}
if (userAgent.contains("Windows")) {
return "Windows";
}
if (userAgent.contains("Mac OS")) {
return "Mac OS";
}
if (userAgent.contains("Linux")) {
return "Linux";
}
if (userAgent.contains("iOS")) {
return "iOS";
}
if (userAgent.contains("Android")) {
return "Android";
}
return "Unknown";
}
}

View File

@@ -0,0 +1,59 @@
package com.yolo.keyborad.service.impl.user;
import com.yolo.keyborad.config.AppConfig;
import com.yolo.keyborad.config.NacosAppConfigCenter;
import com.yolo.keyborad.model.entity.KeyboardUserQuotaTotal;
import com.yolo.keyborad.model.entity.KeyboardUserWallet;
import com.yolo.keyborad.service.KeyboardCharacterService;
import com.yolo.keyborad.service.KeyboardUserInviteCodesService;
import com.yolo.keyborad.service.KeyboardUserQuotaTotalService;
import com.yolo.keyborad.service.KeyboardUserWalletService;
import java.util.Date;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
public class NewUserAssetsInitializer {
private final KeyboardCharacterService keyboardCharacterService;
private final KeyboardUserWalletService walletService;
private final KeyboardUserQuotaTotalService quotaTotalService;
private final KeyboardUserInviteCodesService inviteCodesService;
private final NacosAppConfigCenter.DynamicAppConfig cfgHolder;
/**
* 初始化新用户资产,确保不同注册来源拿到一致的默认配置。
*/
public void initialize(long userId) {
AppConfig appConfig = cfgHolder.getRef().get();
keyboardCharacterService.addDefaultUserCharacter(userId);
initWallet(userId, appConfig);
initQuota(userId, appConfig);
inviteCodesService.createInviteCode(userId);
}
private void initWallet(long userId, AppConfig appConfig) {
Date now = new Date();
KeyboardUserWallet wallet = new KeyboardUserWallet();
wallet.setUserId(userId);
wallet.setBalance(appConfig.getUserRegisterProperties().getRewardBalance());
wallet.setVersion(0);
wallet.setStatus((short) 1);
wallet.setCreatedAt(now);
wallet.setUpdatedAt(now);
walletService.save(wallet);
}
private void initQuota(long userId, AppConfig appConfig) {
Date now = new Date();
KeyboardUserQuotaTotal quotaTotal = new KeyboardUserQuotaTotal();
quotaTotal.setUserId(userId);
quotaTotal.setTotalQuota(appConfig.getUserRegisterProperties().getFreeTrialQuota());
quotaTotal.setUsedQuota(0);
quotaTotal.setVersion(0);
quotaTotal.setCreatedAt(now);
quotaTotal.setUpdatedAt(now);
quotaTotalService.save(quotaTotal);
}
}

View File

@@ -10,14 +10,9 @@ import com.yolo.keyborad.exception.BusinessException;
import com.yolo.keyborad.mapper.KeyboardUserMapper;
import com.yolo.keyborad.model.dto.user.UserRegisterDTO;
import com.yolo.keyborad.model.entity.KeyboardUser;
import com.yolo.keyborad.model.entity.KeyboardUserQuotaTotal;
import com.yolo.keyborad.model.entity.KeyboardUserWallet;
import com.yolo.keyborad.service.KeyboardCharacterService;
import com.yolo.keyborad.service.KeyboardUserInviteCodesService;
import com.yolo.keyborad.service.KeyboardUserQuotaTotalService;
import com.yolo.keyborad.service.KeyboardUserWalletService;
import com.yolo.keyborad.utils.RedisUtil;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Date;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.crypto.password.PasswordEncoder;
@@ -33,14 +28,12 @@ import org.springframework.transaction.annotation.Transactional;
public class UserRegistrationHandler {
private static final String USER_CODE_PREFIX = "user:";
private static final int ACCOUNT_REUSE_COOLDOWN_DAYS = 7;
private final KeyboardUserMapper keyboardUserMapper;
private final PasswordEncoder passwordEncoder;
private final RedisUtil redisUtil;
private final KeyboardCharacterService keyboardCharacterService;
private final KeyboardUserWalletService walletService;
private final KeyboardUserQuotaTotalService quotaTotalService;
private final KeyboardUserInviteCodesService inviteCodesService;
private final NewUserAssetsInitializer newUserAssetsInitializer;
private final UserInviteCodeBinder inviteCodeBinder;
private final NacosAppConfigCenter.DynamicAppConfig cfgHolder;
@@ -48,20 +41,14 @@ public class UserRegistrationHandler {
KeyboardUserMapper keyboardUserMapper,
PasswordEncoder passwordEncoder,
RedisUtil redisUtil,
KeyboardCharacterService keyboardCharacterService,
KeyboardUserWalletService walletService,
KeyboardUserQuotaTotalService quotaTotalService,
KeyboardUserInviteCodesService inviteCodesService,
NewUserAssetsInitializer newUserAssetsInitializer,
UserInviteCodeBinder inviteCodeBinder,
NacosAppConfigCenter.DynamicAppConfig cfgHolder
) {
this.keyboardUserMapper = keyboardUserMapper;
this.passwordEncoder = passwordEncoder;
this.redisUtil = redisUtil;
this.keyboardCharacterService = keyboardCharacterService;
this.walletService = walletService;
this.quotaTotalService = quotaTotalService;
this.inviteCodesService = inviteCodesService;
this.newUserAssetsInitializer = newUserAssetsInitializer;
this.inviteCodeBinder = inviteCodeBinder;
this.cfgHolder = cfgHolder;
}
@@ -91,6 +78,24 @@ public class UserRegistrationHandler {
if (userMail != null) {
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) {
@@ -118,42 +123,12 @@ public class UserRegistrationHandler {
keyboardUser.setEmail(userRegisterDTO.getMailAddress());
keyboardUser.setGender(userRegisterDTO.getGender());
keyboardUser.setEmailVerified(true);
keyboardUser.setUuid(IdUtil.randomUUID());
return keyboardUser;
}
private void initNewUserAssets(KeyboardUser keyboardUser) {
keyboardCharacterService.addDefaultUserCharacter(keyboardUser.getId());
AppConfig appConfig = cfgHolder.getRef().get();
initWallet(keyboardUser.getId(), appConfig.getUserRegisterProperties().getRewardBalance());
initQuota(
keyboardUser.getId(),
appConfig.getUserRegisterProperties().getFreeTrialQuota()
);
inviteCodesService.createInviteCode(keyboardUser.getId());
}
private void initWallet(long userId, BigDecimal rewardBalance) {
KeyboardUserWallet wallet = new KeyboardUserWallet();
wallet.setUserId(userId);
wallet.setBalance(rewardBalance);
wallet.setVersion(0);
wallet.setStatus((short) 1);
wallet.setCreatedAt(new Date());
wallet.setUpdatedAt(new Date());
walletService.save(wallet);
}
private void initQuota(long userId, Integer freeTrialQuota) {
KeyboardUserQuotaTotal quotaTotal = new KeyboardUserQuotaTotal();
quotaTotal.setUserId(userId);
quotaTotal.setTotalQuota(freeTrialQuota);
quotaTotal.setUsedQuota(0);
quotaTotal.setVersion(0);
quotaTotal.setCreatedAt(new Date());
quotaTotal.setUpdatedAt(new Date());
quotaTotalService.save(quotaTotal);
newUserAssetsInitializer.initialize(keyboardUser.getId());
}
private void tryBindInviteCode(String inviteCode, long userId) {

View File

@@ -39,7 +39,7 @@ apple:
key-id: "Y7TF7BV74G"
bundle-id: "com.loveKey.nyx"
# app 在 App Store 中的 Apple ID可在开发者后台查看
app-apple-id: 1234567890
app-apple-id: 6755171905
# p8 私钥文件路径,建议放在 resources 目录
private-key-path: "classpath:SubscriptionKey_Y7TF7BV74G.p8"
@@ -52,6 +52,26 @@ apple:
- "classpath:AppleRootCA-G2.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/rtdn_dev"
service-account-email: "id-220@keyboard-490601.iam.gserviceaccount.com"
login:
enabled: true
csrf-check-enabled: false
client-ids: [1003033603130-ip8g4bpkvgo4iktb5lr27r7r0lsq0f6s.apps.googleusercontent.com,1003033603130-trkhtbbvnmcuvjg04g7o1uk3vucbfo76.apps.googleusercontent.com]
hosted-domain: ""
dromara:
x-file-storage: #文件存储配置
default-platform: cloudflare-r2 #默认使用的存储平台
@@ -104,7 +124,7 @@ nacos:
elevenlabs:
api-key: sk_25339d32bb14c91f460ed9fce83a1951672f07846a7a10ce
voice-id: JBFqnCBsd6RMkjVDRZzb
model-id: eleven_turbo_v2_5
model-id: eleven_flash_v2_5
output-format: mp3_44100_128
deepgram:
@@ -112,4 +132,4 @@ deepgram:
model: nova-2
language: en
smart-format: true
punctuate: true
punctuate: true

View File

@@ -37,7 +37,7 @@ apple:
key-id: "Y7TF7BV74G"
bundle-id: "com.loveKey.nyx"
# app 在 App Store 中的 Apple ID可在开发者后台查看
app-apple-id: 1234567890
app-apple-id: 6755171905
# p8 私钥文件路径,建议放在 resources 目录
private-key-path: "classpath:SubscriptionKey_Y7TF7BV74G.p8"
@@ -50,6 +50,26 @@ apple:
- "classpath:AppleRootCA-G2.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"
login:
enabled: true
csrf-check-enabled: false
client-ids: [1003033603130-ip8g4bpkvgo4iktb5lr27r7r0lsq0f6s.apps.googleusercontent.com,1003033603130-trkhtbbvnmcuvjg04g7o1uk3vucbfo76.apps.googleusercontent.com]
hosted-domain: ""
nacos:
config:
server-addr: 127.0.0.1:8848
@@ -76,7 +96,7 @@ sa-token:
elevenlabs:
api-key: sk_25339d32bb14c91f460ed9fce83a1951672f07846a7a10ce
voice-id: JBFqnCBsd6RMkjVDRZzb
model-id: eleven_turbo_v2_5
model-id: eleven_flash_v2_5
output-format: mp3_44100_128
deepgram:

View 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"
}

View File

@@ -17,4 +17,20 @@
<!--@mbg.generated-->
id, user_id, companion_id, sender, content, emotion_detected, support_type, created_at
</sql>
</mapper>
<resultMap id="LastChatResultMap" type="com.yolo.keyborad.model.dto.chat.ChattedCompanionLastChatDTO">
<result column="companion_id" jdbcType="BIGINT" property="companionId" />
<result column="last_chatted_at" jdbcType="TIMESTAMP" property="lastChattedAt" />
</resultMap>
<select id="selectLastChattedCompanions" resultMap="LastChatResultMap">
SELECT
m.companion_id,
MAX(m.created_at) AS last_chatted_at
FROM keyboard_ai_chat_message m
INNER JOIN keyboard_ai_chat_session s ON s.id = m.session_id
WHERE m.user_id = #{userId}
AND s.user_id = #{userId}
AND s.is_active = TRUE
GROUP BY m.companion_id
ORDER BY last_chatted_at DESC
</select>
</mapper>

View File

@@ -9,7 +9,7 @@
SELECT
kuc.id,
kuc.character_id,
kci.character_name,
COALESCE(kci.character_name, kci_en.character_name) AS character_name,
kuc.emoji
FROM keyboard_user_character kuc
JOIN keyboard_user_sort kus
@@ -17,6 +17,9 @@
LEFT JOIN keyboard_character_i18n kci
ON kuc.character_id = kci.character_id
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}
AND kuc.deleted = FALSE
ORDER BY array_position(kus.user_characteu_id_sort, kuc.id) NULLS LAST;

View File

@@ -19,11 +19,16 @@
<result column="email_verified" jdbcType="BOOLEAN" property="emailVerified" />
<result column="is_vip" jdbcType="BOOLEAN" property="isVip" />
<result column="vip_expiry" jdbcType="TIMESTAMP" property="vipExpiry" />
<result column="vip_level" jdbcType="SMALLINT" property="vipLevel" />
<result column="deleted_at" jdbcType="TIMESTAMP" property="deletedAt" />
<result column="uuid" jdbcType="VARCHAR" property="uuid" />
<result column="google_subject_id" jdbcType="VARCHAR" property="googleSubjectId" />
</resultMap>
<sql id="Base_Column_List">
<!--@mbg.generated-->
id, "uid", nick_name, gender, avatar_url, created_at, updated_at, deleted, email,
"status", "password", subject_id, email_verified, is_vip, vip_expiry
"status", "password", subject_id, email_verified, is_vip, vip_expiry, vip_level,
deleted_at, uuid, google_subject_id
</sql>
<update id="updateByuid">
@@ -36,4 +41,4 @@
</set>
where uid = #{uid}
</update>
</mapper>
</mapper>

View File

@@ -0,0 +1,177 @@
package com.yolo.keyborad.controller;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.yolo.keyborad.common.ErrorCode;
import com.yolo.keyborad.config.NacosAppConfigCenter;
import com.yolo.keyborad.exception.BusinessException;
import com.yolo.keyborad.model.vo.appversion.KeyboardAppUpdateCheckRespVO;
import com.yolo.keyborad.model.vo.user.KeyboardUserRespVO;
import com.yolo.keyborad.service.GoogleLoginService;
import com.yolo.keyborad.service.IAppleService;
import com.yolo.keyborad.service.II18nService;
import com.yolo.keyborad.service.KeyboardAppVersionsService;
import com.yolo.keyborad.service.KeyboardFeedbackService;
import com.yolo.keyborad.service.KeyboardUserInviteCodesService;
import com.yolo.keyborad.service.UserService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.FilterType;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
@WebMvcTest(
controllers = KeyboardAppVersionsController.class,
excludeFilters = @ComponentScan.Filter(
type = FilterType.ASSIGNABLE_TYPE,
classes = com.yolo.keyborad.config.SaTokenConfigure.class
)
)
@Import(com.yolo.keyborad.config.SecurityConfig.class)
class KeyboardAppVersionsControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private KeyboardAppVersionsService keyboardAppVersionsService;
@MockBean
private II18nService i18nService;
@Test
void checkUpdate_whenOk_returnsOk() throws Exception {
when(i18nService.getMessageWithAcceptLanguage(any(), any())).thenReturn(null);
final KeyboardAppUpdateCheckRespVO respVO = new KeyboardAppUpdateCheckRespVO();
respVO.setNeedUpdate(true);
respVO.setForceUpdate(false);
respVO.setLatestVersionName("1.2.3");
respVO.setLatestVersionCode(123L);
respVO.setMinSupportedCode(100L);
respVO.setDownloadUrl("https://example.com/app.apk");
respVO.setStoreUrl("https://example.com/store");
respVO.setReleaseNotes("notes");
when(keyboardAppVersionsService.checkUpdate(any())).thenReturn(respVO);
mockMvc.perform(post("/appVersions/checkUpdate")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"platform": "android",
"channel": "official",
"appId": "main",
"clientVersionCode": 120
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.needUpdate").value(true))
.andExpect(jsonPath("$.data.forceUpdate").value(false))
.andExpect(jsonPath("$.data.latestVersionName").value("1.2.3"))
.andExpect(jsonPath("$.data.latestVersionCode").value(123))
.andExpect(jsonPath("$.data.minSupportedCode").value(100));
}
@Test
void checkUpdate_whenServiceThrowsParamsError_returnsParamsError() throws Exception {
when(i18nService.getMessageWithAcceptLanguage(any(), any())).thenReturn(null);
when(keyboardAppVersionsService.checkUpdate(any()))
.thenThrow(new BusinessException(ErrorCode.PARAMS_ERROR));
mockMvc.perform(post("/appVersions/checkUpdate")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"platform": " ",
"clientVersionCode": 1
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(ErrorCode.PARAMS_ERROR.getCode()));
}
}
@WebMvcTest(
controllers = UserController.class,
excludeFilters = @ComponentScan.Filter(
type = FilterType.ASSIGNABLE_TYPE,
classes = com.yolo.keyborad.config.SaTokenConfigure.class
)
)
@Import(com.yolo.keyborad.config.SecurityConfig.class)
class UserControllerGoogleLoginTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private IAppleService appleService;
@MockBean
private UserService userService;
@MockBean
private GoogleLoginService googleLoginService;
@MockBean
private KeyboardFeedbackService feedbackService;
@MockBean
private KeyboardUserInviteCodesService inviteCodesService;
@MockBean
private II18nService i18nService;
@MockBean
private NacosAppConfigCenter.DynamicAppConfig dynamicAppConfig;
@Test
void googleLogin_whenOk_returnsOk() throws Exception {
KeyboardUserRespVO respVO = new KeyboardUserRespVO();
respVO.setUid(1001L);
respVO.setNickName("Google User");
respVO.setToken("token-value");
when(i18nService.getMessageWithAcceptLanguage(any(), any())).thenReturn(null);
when(googleLoginService.login(any(), any())).thenReturn(respVO);
mockMvc.perform(post("/user/googleLogin")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"idToken": "google-id-token"
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.uid").value(1001))
.andExpect(jsonPath("$.data.nickName").value("Google User"))
.andExpect(jsonPath("$.data.token").value("token-value"));
}
@Test
void googleLogin_whenServiceThrowsParamsError_returnsParamsError() throws Exception {
when(i18nService.getMessageWithAcceptLanguage(any(), any())).thenReturn(null);
when(googleLoginService.login(any(), any()))
.thenThrow(new BusinessException(ErrorCode.PARAMS_ERROR));
mockMvc.perform(post("/user/googleLogin")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"idToken": ""
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(ErrorCode.PARAMS_ERROR.getCode()));
}
}

View File

@@ -0,0 +1,260 @@
package com.yolo.keyborad.service;
import cn.dev33.satoken.context.mock.SaTokenContextMockUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfo;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.baomidou.mybatisplus.core.toolkit.LambdaUtils;
import com.yolo.keyborad.common.ErrorCode;
import com.yolo.keyborad.config.GoogleLoginProperties;
import com.yolo.keyborad.config.NacosAppConfigCenter;
import com.yolo.keyborad.exception.BusinessException;
import com.yolo.keyborad.mapper.KeyboardUserMapper;
import com.yolo.keyborad.model.dto.GoogleLoginReq;
import com.yolo.keyborad.model.dto.googlelogin.GoogleIdTokenPayload;
import com.yolo.keyborad.model.dto.user.UserRegisterDTO;
import com.yolo.keyborad.model.entity.KeyboardUser;
import com.yolo.keyborad.model.vo.user.KeyboardUserRespVO;
import com.yolo.keyborad.service.impl.GoogleIdTokenVerifierService;
import com.yolo.keyborad.service.impl.GoogleLoginServiceImpl;
import com.yolo.keyborad.service.impl.ThirdPartyLoginUserServiceImpl;
import com.yolo.keyborad.service.impl.UserLoginAuditService;
import com.yolo.keyborad.service.impl.user.UserInviteCodeBinder;
import com.yolo.keyborad.service.impl.user.NewUserAssetsInitializer;
import com.yolo.keyborad.service.impl.user.UserRegistrationHandler;
import com.yolo.keyborad.utils.RedisUtil;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.apache.ibatis.session.Configuration;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.crypto.password.PasswordEncoder;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class UserCancellationRegistrationTest {
@BeforeAll
static void initMybatisPlusLambdaCache() {
Configuration configuration = new Configuration();
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "test");
TableInfo tableInfo = TableInfoHelper.initTableInfo(assistant, KeyboardUser.class);
LambdaUtils.installCache(tableInfo);
}
@Test
void userRegister_emailUniqCheckFiltersDeleted() {
KeyboardUserMapper keyboardUserMapper = mock(KeyboardUserMapper.class);
PasswordEncoder passwordEncoder = mock(PasswordEncoder.class);
RedisUtil redisUtil = mock(RedisUtil.class);
NewUserAssetsInitializer newUserAssetsInitializer = mock(NewUserAssetsInitializer.class);
UserInviteCodeBinder inviteCodeBinder = mock(UserInviteCodeBinder.class);
NacosAppConfigCenter.DynamicAppConfig cfgHolder = new NacosAppConfigCenter.DynamicAppConfig();
when(passwordEncoder.encode(anyString())).thenReturn("hashed");
when(redisUtil.get(anyString())).thenReturn("123456");
when(keyboardUserMapper.selectOne(any())).thenReturn(null);
doAnswer(invocation -> {
KeyboardUser user = invocation.getArgument(0);
user.setId(1L);
return 1;
}).when(keyboardUserMapper).insert(any(KeyboardUser.class));
UserRegistrationHandler handler = new UserRegistrationHandler(
keyboardUserMapper,
passwordEncoder,
redisUtil,
newUserAssetsInitializer,
inviteCodeBinder,
cfgHolder
);
UserRegisterDTO dto = new UserRegisterDTO();
dto.setMailAddress("a@b.com");
dto.setPassword("p");
dto.setPasswordConfirm("p");
dto.setVerifyCode("123456");
assertTrue(handler.userRegister(dto));
@SuppressWarnings("unchecked")
ArgumentCaptor<LambdaQueryWrapper<KeyboardUser>> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
verify(keyboardUserMapper, times(2)).selectOne(captor.capture());
assertTrue(captor.getAllValues().stream()
.map(LambdaQueryWrapper::getSqlSegment)
.allMatch(sqlSegment -> sqlSegment != null && sqlSegment.contains("deleted")));
}
@Test
void selectAppleUserFiltersDeleted() {
KeyboardUserMapper keyboardUserMapper = mock(KeyboardUserMapper.class);
when(keyboardUserMapper.selectOne(any())).thenReturn(null);
ThirdPartyLoginUserServiceImpl userService = new ThirdPartyLoginUserServiceImpl(
keyboardUserMapper,
mock(NewUserAssetsInitializer.class)
);
userService.selectAppleUser("sub");
@SuppressWarnings("unchecked")
ArgumentCaptor<LambdaQueryWrapper<KeyboardUser>> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
verify(keyboardUserMapper).selectOne(captor.capture());
String sqlSegment = captor.getValue().getSqlSegment();
assertNotNull(sqlSegment);
assertTrue(sqlSegment.contains("deleted"));
}
@Test
void selectGoogleUserFiltersDeleted() {
KeyboardUserMapper keyboardUserMapper = mock(KeyboardUserMapper.class);
when(keyboardUserMapper.selectOne(any())).thenReturn(null);
ThirdPartyLoginUserServiceImpl userService = new ThirdPartyLoginUserServiceImpl(
keyboardUserMapper,
mock(NewUserAssetsInitializer.class)
);
userService.selectGoogleUser("google-sub");
@SuppressWarnings("unchecked")
ArgumentCaptor<LambdaQueryWrapper<KeyboardUser>> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
verify(keyboardUserMapper).selectOne(captor.capture());
String sqlSegment = captor.getValue().getSqlSegment();
assertNotNull(sqlSegment);
assertTrue(sqlSegment.contains("deleted"));
}
}
@ExtendWith(MockitoExtension.class)
class GoogleLoginServiceImplTest {
@Mock
private GoogleIdTokenVerifierService googleIdTokenVerifierService;
@Mock
private ThirdPartyLoginUserService thirdPartyLoginUserService;
@Mock
private UserLoginAuditService userLoginAuditService;
@Mock
private HttpServletRequest request;
private GoogleLoginProperties googleLoginProperties;
private GoogleLoginServiceImpl googleLoginService;
@BeforeEach
void setUp() {
googleLoginProperties = new GoogleLoginProperties();
googleLoginProperties.setEnabled(true);
googleLoginService = new GoogleLoginServiceImpl(
googleLoginProperties,
googleIdTokenVerifierService,
thirdPartyLoginUserService,
userLoginAuditService
);
}
@Test
void login_whenGoogleUserIsNew_returnsTokenAndRecordsAudit() {
GoogleLoginReq googleLoginReq = new GoogleLoginReq();
googleLoginReq.setIdToken("id-token");
GoogleIdTokenPayload payload = new GoogleIdTokenPayload(
"google-sub",
"google@example.com",
true,
"Google User",
"https://example.com/avatar.png",
null
);
KeyboardUser keyboardUser = new KeyboardUser();
keyboardUser.setId(1L);
keyboardUser.setUid(1001L);
keyboardUser.setNickName("Google User");
when(googleIdTokenVerifierService.verify("id-token")).thenReturn(payload);
when(thirdPartyLoginUserService.selectGoogleUser("google-sub")).thenReturn(null);
when(thirdPartyLoginUserService.selectActiveUserByEmail("google@example.com")).thenReturn(null);
when(thirdPartyLoginUserService.createGoogleUser(payload)).thenReturn(keyboardUser);
KeyboardUserRespVO respVO = SaTokenContextMockUtil.setMockContext(
() -> googleLoginService.login(googleLoginReq, request)
);
assertEquals(1001L, respVO.getUid());
assertNotNull(respVO.getToken());
assertTrue(!respVO.getToken().isBlank());
verify(userLoginAuditService).recordLoginLog(1L, request, "GOOGLE_NEW_USER");
}
@Test
void login_whenEmailOccupied_throwsBindRequired() {
GoogleLoginReq googleLoginReq = new GoogleLoginReq();
googleLoginReq.setIdToken("id-token");
GoogleIdTokenPayload payload = new GoogleIdTokenPayload(
"google-sub",
"occupied@example.com",
true,
"Google User",
null,
null
);
when(googleIdTokenVerifierService.verify("id-token")).thenReturn(payload);
when(thirdPartyLoginUserService.selectGoogleUser("google-sub")).thenReturn(null);
when(thirdPartyLoginUserService.selectActiveUserByEmail("occupied@example.com"))
.thenReturn(new KeyboardUser());
BusinessException exception = assertThrows(
BusinessException.class,
() -> googleLoginService.login(googleLoginReq, request)
);
assertEquals(ErrorCode.GOOGLE_LOGIN_BIND_REQUIRED.getCode(), exception.getCode());
verify(thirdPartyLoginUserService, never()).createGoogleUser(any());
}
@Test
void login_whenCsrfEnabledAndTokenMismatch_throwsCsrfError() {
GoogleLoginReq googleLoginReq = new GoogleLoginReq();
googleLoginReq.setIdToken("id-token");
googleLoginReq.setGCsrfToken("body-token");
googleLoginProperties.setCsrfCheckEnabled(true);
when(request.getCookies()).thenReturn(new Cookie[]{new Cookie("g_csrf_token", "cookie-token")});
BusinessException exception = assertThrows(
BusinessException.class,
() -> googleLoginService.login(googleLoginReq, request)
);
assertEquals(ErrorCode.GOOGLE_LOGIN_CSRF_INVALID.getCode(), exception.getCode());
verify(googleIdTokenVerifierService, never()).verify(eq("id-token"));
}
}