Compare commits
5 Commits
c8f8311cae
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 005e621ab2 | |||
| 3f7304c3ab | |||
| 4ca6d36e80 | |||
| e657a22b10 | |||
| d654777a02 |
10
.gitignore
vendored
10
.gitignore
vendored
@@ -38,6 +38,16 @@ build/
|
|||||||
/CLAUDE.md
|
/CLAUDE.md
|
||||||
/AGENTS.md
|
/AGENTS.md
|
||||||
/src/test/
|
/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
|
/.claude/agents/backend-architect.md
|
||||||
/.dockerignore
|
/.dockerignore
|
||||||
/Dockerfile
|
/Dockerfile
|
||||||
|
|||||||
14
pom.xml
14
pom.xml
@@ -233,6 +233,18 @@
|
|||||||
<artifactId>spring-boot-starter-security</artifactId>
|
<artifactId>spring-boot-starter-security</artifactId>
|
||||||
</dependency>
|
</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 邮件服务 -->
|
<!-- mailerSender 邮件服务 -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.mailersend</groupId>
|
<groupId>com.mailersend</groupId>
|
||||||
@@ -316,4 +328,4 @@
|
|||||||
</plugins>
|
</plugins>
|
||||||
</build>
|
</build>
|
||||||
|
|
||||||
</project>
|
</project>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.yolo.keyborad;
|
|||||||
|
|
||||||
import com.yolo.keyborad.common.xfile.ByteFileWrapperAdapter;
|
import com.yolo.keyborad.common.xfile.ByteFileWrapperAdapter;
|
||||||
import com.yolo.keyborad.config.AppleAppStoreProperties;
|
import com.yolo.keyborad.config.AppleAppStoreProperties;
|
||||||
|
import com.yolo.keyborad.config.GoogleLoginProperties;
|
||||||
import com.yolo.keyborad.config.GooglePlayProperties;
|
import com.yolo.keyborad.config.GooglePlayProperties;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.dromara.x.file.storage.core.tika.ContentTypeDetect;
|
import org.dromara.x.file.storage.core.tika.ContentTypeDetect;
|
||||||
@@ -15,7 +16,11 @@ import org.springframework.context.annotation.Bean;
|
|||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
@EnableConfigurationProperties({AppleAppStoreProperties.class, GooglePlayProperties.class})
|
@EnableConfigurationProperties({
|
||||||
|
AppleAppStoreProperties.class,
|
||||||
|
GooglePlayProperties.class,
|
||||||
|
GoogleLoginProperties.class
|
||||||
|
})
|
||||||
@EnableFileStorage
|
@EnableFileStorage
|
||||||
public class MyApplication {
|
public class MyApplication {
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ public enum ErrorCode {
|
|||||||
SYSTEM_ERROR(50000, "系统内部异常"),
|
SYSTEM_ERROR(50000, "系统内部异常"),
|
||||||
OPERATION_ERROR(50001, "操作失败"),
|
OPERATION_ERROR(50001, "操作失败"),
|
||||||
APPLE_LOGIN_ERROR(40003, "Apple登录失败"),
|
APPLE_LOGIN_ERROR(40003, "Apple登录失败"),
|
||||||
|
GOOGLE_LOGIN_ERROR(40023, "Google登录失败"),
|
||||||
|
GOOGLE_LOGIN_CSRF_INVALID(40024, "Google登录CSRF校验失败"),
|
||||||
FILE_IS_EMPTY(40001, "上传文件为空"),
|
FILE_IS_EMPTY(40001, "上传文件为空"),
|
||||||
FILE_NAME_ERROR(40002, "文件名错误"),
|
FILE_NAME_ERROR(40002, "文件名错误"),
|
||||||
FILE_TYPE_ERROR(40004, "文件类型不支持,仅支持图片格式"),
|
FILE_TYPE_ERROR(40004, "文件类型不支持,仅支持图片格式"),
|
||||||
@@ -86,6 +88,8 @@ public enum ErrorCode {
|
|||||||
REPORT_COMPANION_ID_EMPTY(40021, "被举报的AI角色ID不能为空"),
|
REPORT_COMPANION_ID_EMPTY(40021, "被举报的AI角色ID不能为空"),
|
||||||
REPORT_TYPE_EMPTY(40022, "举报类型不能为空"),
|
REPORT_TYPE_EMPTY(40022, "举报类型不能为空"),
|
||||||
ACCOUNT_RECENTLY_CANCELLED(50038, "账号注销未满7天,暂不允许注册"),
|
ACCOUNT_RECENTLY_CANCELLED(50038, "账号注销未满7天,暂不允许注册"),
|
||||||
|
GOOGLE_LOGIN_DISABLED(50039, "Google登录未开启"),
|
||||||
|
GOOGLE_LOGIN_BIND_REQUIRED(50040, "该邮箱已注册,请使用原登录方式登录后再绑定Google账号"),
|
||||||
VERSION_NOT_FOUND(40022, "未找到可用的版本配置");
|
VERSION_NOT_FOUND(40022, "未找到可用的版本配置");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -90,7 +90,8 @@ public class SaTokenConfigure implements WebMvcConfigurer {
|
|||||||
"/appVersions/checkUpdate",
|
"/appVersions/checkUpdate",
|
||||||
"/appVersions/checkUpdate",
|
"/appVersions/checkUpdate",
|
||||||
"/character/detailWithNotLogin",
|
"/character/detailWithNotLogin",
|
||||||
"/apple/validate-receipt"
|
"/apple/validate-receipt",
|
||||||
|
"/user/googleLogin"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@Bean
|
@Bean
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import com.yolo.keyborad.common.ResultUtils;
|
|||||||
import com.yolo.keyborad.config.AppConfig;
|
import com.yolo.keyborad.config.AppConfig;
|
||||||
import com.yolo.keyborad.config.NacosAppConfigCenter;
|
import com.yolo.keyborad.config.NacosAppConfigCenter;
|
||||||
import com.yolo.keyborad.model.dto.AppleLoginReq;
|
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.dto.user.*;
|
||||||
import com.yolo.keyborad.model.entity.KeyboardFeedback;
|
import com.yolo.keyborad.model.entity.KeyboardFeedback;
|
||||||
import com.yolo.keyborad.model.entity.KeyboardUser;
|
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.InviteCodeRespVO;
|
||||||
import com.yolo.keyborad.model.vo.user.KeyboardUserInfoRespVO;
|
import com.yolo.keyborad.model.vo.user.KeyboardUserInfoRespVO;
|
||||||
import com.yolo.keyborad.model.vo.user.KeyboardUserRespVO;
|
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.IAppleService;
|
||||||
import com.yolo.keyborad.service.KeyboardFeedbackService;
|
import com.yolo.keyborad.service.KeyboardFeedbackService;
|
||||||
import com.yolo.keyborad.service.KeyboardUserInviteCodesService;
|
import com.yolo.keyborad.service.KeyboardUserInviteCodesService;
|
||||||
@@ -46,6 +48,9 @@ public class UserController {
|
|||||||
@Resource
|
@Resource
|
||||||
private UserService userService;
|
private UserService userService;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private GoogleLoginService googleLoginService;
|
||||||
|
|
||||||
@Resource
|
@Resource
|
||||||
private KeyboardFeedbackService feedbackService;
|
private KeyboardFeedbackService feedbackService;
|
||||||
|
|
||||||
@@ -69,6 +74,13 @@ public class UserController {
|
|||||||
return ResultUtils.success(appleService.login(appleLoginReq.getIdentityToken(), request));
|
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")
|
@GetMapping("/logout")
|
||||||
@Operation(summary = "退出登录", description = "退出登录接口")
|
@Operation(summary = "退出登录", description = "退出登录接口")
|
||||||
public BaseResponse<Boolean> logout() {
|
public BaseResponse<Boolean> logout() {
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ public class GooglePlayApiClient {
|
|||||||
JsonNode offerDetails = firstLineItem.path("productOfferDetails");
|
JsonNode offerDetails = firstLineItem.path("productOfferDetails");
|
||||||
|
|
||||||
String state = mapOneTimeState(text(root.path("purchaseStateContext"), "purchaseState"));
|
String state = mapOneTimeState(text(root.path("purchaseStateContext"), "purchaseState"));
|
||||||
|
String purchaseOptionId = text(offerDetails, "purchaseOptionId");
|
||||||
// 修正:一次性购买的订单号字段名为 "orderId"
|
// 修正:一次性购买的订单号字段名为 "orderId"
|
||||||
String googleOrderId = text(root, "orderId");
|
String googleOrderId = text(root, "orderId");
|
||||||
|
|
||||||
@@ -156,6 +156,7 @@ public class GooglePlayApiClient {
|
|||||||
.productId(text(firstLineItem, "productId"))
|
.productId(text(firstLineItem, "productId"))
|
||||||
.productType(GooglePlayConstants.PRODUCT_TYPE_ONE_TIME)
|
.productType(GooglePlayConstants.PRODUCT_TYPE_ONE_TIME)
|
||||||
.purchaseToken(purchaseToken)
|
.purchaseToken(purchaseToken)
|
||||||
|
.purchaseOptionId(purchaseOptionId)
|
||||||
.orderKey(resolveOrderKey(googleOrderId, purchaseToken))
|
.orderKey(resolveOrderKey(googleOrderId, purchaseToken))
|
||||||
.googleOrderId(googleOrderId)
|
.googleOrderId(googleOrderId)
|
||||||
.linkedPurchaseToken(null)
|
.linkedPurchaseToken(null)
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ public final class GooglePlayConstants {
|
|||||||
public static final String CONSUMPTION_NOT_APPLICABLE = "NOT_APPLICABLE";
|
public static final String CONSUMPTION_NOT_APPLICABLE = "NOT_APPLICABLE";
|
||||||
|
|
||||||
public static final String DELIVERY_PENDING = "PENDING";
|
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_DELIVERED = "DELIVERED";
|
||||||
public static final String DELIVERY_REVOKED = "REVOKED";
|
public static final String DELIVERY_REVOKED = "REVOKED";
|
||||||
public static final String DELIVERY_NOT_REQUIRED = "NOT_REQUIRED";
|
public static final String DELIVERY_NOT_REQUIRED = "NOT_REQUIRED";
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ public class GooglePlayEntitlementApplier {
|
|||||||
private final GooglePlayUserEntitlementMapper entitlementMapper;
|
private final GooglePlayUserEntitlementMapper entitlementMapper;
|
||||||
private final GooglePlayVipBenefitService vipBenefitService;
|
private final GooglePlayVipBenefitService vipBenefitService;
|
||||||
private final GooglePlayWalletBenefitService walletBenefitService;
|
private final GooglePlayWalletBenefitService walletBenefitService;
|
||||||
|
private final GooglePlayOrderDeliveryGuard orderDeliveryGuard;
|
||||||
|
|
||||||
public GooglePlayUserEntitlement apply(Long userId,
|
public GooglePlayUserEntitlement apply(Long userId,
|
||||||
KeyboardProductItems product,
|
KeyboardProductItems product,
|
||||||
@@ -29,6 +30,7 @@ public class GooglePlayEntitlementApplier {
|
|||||||
GooglePlayOrder order) {
|
GooglePlayOrder order) {
|
||||||
String benefitType = resolveBenefitType(product, snapshot);
|
String benefitType = resolveBenefitType(product, snapshot);
|
||||||
String entitlementKey = resolveEntitlementKey(benefitType, product.getProductId());
|
String entitlementKey = resolveEntitlementKey(benefitType, product.getProductId());
|
||||||
|
boolean grantOwned = orderDeliveryGuard.prepareGrant(benefitType, snapshot, order);
|
||||||
GooglePlayUserEntitlement entitlement = loadEntitlement(snapshot.getPurchaseToken(), entitlementKey);
|
GooglePlayUserEntitlement entitlement = loadEntitlement(snapshot.getPurchaseToken(), entitlementKey);
|
||||||
if (entitlement == null) {
|
if (entitlement == null) {
|
||||||
entitlement = new GooglePlayUserEntitlement();
|
entitlement = new GooglePlayUserEntitlement();
|
||||||
@@ -37,9 +39,11 @@ public class GooglePlayEntitlementApplier {
|
|||||||
fillCommonFields(entitlement, userId, product, snapshot, order, benefitType, entitlementKey);
|
fillCommonFields(entitlement, userId, product, snapshot, order, benefitType, entitlementKey);
|
||||||
switch (benefitType) {
|
switch (benefitType) {
|
||||||
case GooglePlayConstants.ENTITLEMENT_VIP_SUBSCRIPTION -> applySubscriptionVip(userId, product, snapshot, order, entitlement);
|
case GooglePlayConstants.ENTITLEMENT_VIP_SUBSCRIPTION -> applySubscriptionVip(userId, product, snapshot, order, entitlement);
|
||||||
case GooglePlayConstants.ENTITLEMENT_VIP_ONE_TIME -> applyOneTimeVip(userId, product, snapshot, order, entitlement);
|
case GooglePlayConstants.ENTITLEMENT_VIP_ONE_TIME ->
|
||||||
case GooglePlayConstants.ENTITLEMENT_WALLET_TOP_UP -> applyWalletTopUp(userId, product, snapshot, order, entitlement);
|
applyOneTimeVip(userId, product, snapshot, order, entitlement, grantOwned);
|
||||||
default -> applyNonConsumable(snapshot, order, entitlement);
|
case GooglePlayConstants.ENTITLEMENT_WALLET_TOP_UP ->
|
||||||
|
applyWalletTopUp(userId, product, snapshot, order, entitlement, grantOwned);
|
||||||
|
default -> applyNonConsumable(snapshot, order, entitlement, grantOwned);
|
||||||
}
|
}
|
||||||
saveEntitlement(entitlement);
|
saveEntitlement(entitlement);
|
||||||
return entitlement;
|
return entitlement;
|
||||||
@@ -94,9 +98,10 @@ public class GooglePlayEntitlementApplier {
|
|||||||
KeyboardProductItems product,
|
KeyboardProductItems product,
|
||||||
GooglePlayPurchaseSnapshot snapshot,
|
GooglePlayPurchaseSnapshot snapshot,
|
||||||
GooglePlayOrder order,
|
GooglePlayOrder order,
|
||||||
GooglePlayUserEntitlement entitlement) {
|
GooglePlayUserEntitlement entitlement,
|
||||||
|
boolean grantOwned) {
|
||||||
if (GooglePlayConstants.STATE_ACTIVE.equals(snapshot.getState())) {
|
if (GooglePlayConstants.STATE_ACTIVE.equals(snapshot.getState())) {
|
||||||
grantOneTimeVip(userId, product, order, entitlement);
|
grantOneTimeVip(userId, product, order, entitlement, grantOwned);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
revokeVipEntitlement(userId, order, entitlement);
|
revokeVipEntitlement(userId, order, entitlement);
|
||||||
@@ -106,13 +111,14 @@ public class GooglePlayEntitlementApplier {
|
|||||||
KeyboardProductItems product,
|
KeyboardProductItems product,
|
||||||
GooglePlayPurchaseSnapshot snapshot,
|
GooglePlayPurchaseSnapshot snapshot,
|
||||||
GooglePlayOrder order,
|
GooglePlayOrder order,
|
||||||
GooglePlayUserEntitlement entitlement) {
|
GooglePlayUserEntitlement entitlement,
|
||||||
|
boolean grantOwned) {
|
||||||
BigDecimal amount = resolveWalletAmount(product);
|
BigDecimal amount = resolveWalletAmount(product);
|
||||||
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
|
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||||
throw new BusinessException(ErrorCode.PRODUCT_QUOTA_NOT_SET);
|
throw new BusinessException(ErrorCode.PRODUCT_QUOTA_NOT_SET);
|
||||||
}
|
}
|
||||||
if (GooglePlayConstants.STATE_ACTIVE.equals(snapshot.getState())) {
|
if (GooglePlayConstants.STATE_ACTIVE.equals(snapshot.getState())) {
|
||||||
grantWalletTopUp(userId, product, order, entitlement, amount);
|
grantWalletTopUp(userId, product, order, entitlement, amount, grantOwned);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
revokeWalletTopUp(userId, order, entitlement, amount);
|
revokeWalletTopUp(userId, order, entitlement, amount);
|
||||||
@@ -120,9 +126,14 @@ public class GooglePlayEntitlementApplier {
|
|||||||
|
|
||||||
private void applyNonConsumable(GooglePlayPurchaseSnapshot snapshot,
|
private void applyNonConsumable(GooglePlayPurchaseSnapshot snapshot,
|
||||||
GooglePlayOrder order,
|
GooglePlayOrder order,
|
||||||
GooglePlayUserEntitlement entitlement) {
|
GooglePlayUserEntitlement entitlement,
|
||||||
|
boolean grantOwned) {
|
||||||
boolean active = GooglePlayConstants.STATE_ACTIVE.equals(snapshot.getState())
|
boolean active = GooglePlayConstants.STATE_ACTIVE.equals(snapshot.getState())
|
||||||
|| GooglePlayConstants.STATE_CANCELED.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.setActive(active);
|
||||||
entitlement.setStartTime(snapshot.getStartTime());
|
entitlement.setStartTime(snapshot.getStartTime());
|
||||||
entitlement.setEndTime(snapshot.getExpiryTime());
|
entitlement.setEndTime(snapshot.getExpiryTime());
|
||||||
@@ -139,9 +150,10 @@ public class GooglePlayEntitlementApplier {
|
|||||||
private void grantOneTimeVip(Long userId,
|
private void grantOneTimeVip(Long userId,
|
||||||
KeyboardProductItems product,
|
KeyboardProductItems product,
|
||||||
GooglePlayOrder order,
|
GooglePlayOrder order,
|
||||||
GooglePlayUserEntitlement entitlement) {
|
GooglePlayUserEntitlement entitlement,
|
||||||
if (GooglePlayConstants.DELIVERY_DELIVERED.equals(order.getDeliveryStatus())) {
|
boolean grantOwned) {
|
||||||
entitlement.setActive(true);
|
if (!grantOwned || GooglePlayConstants.DELIVERY_DELIVERED.equals(order.getDeliveryStatus())) {
|
||||||
|
entitlement.setActive(GooglePlayConstants.DELIVERY_DELIVERED.equals(order.getDeliveryStatus()));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Date expiry = resolveOneTimeVipExpiry(product);
|
Date expiry = resolveOneTimeVipExpiry(product);
|
||||||
@@ -172,9 +184,10 @@ public class GooglePlayEntitlementApplier {
|
|||||||
KeyboardProductItems product,
|
KeyboardProductItems product,
|
||||||
GooglePlayOrder order,
|
GooglePlayOrder order,
|
||||||
GooglePlayUserEntitlement entitlement,
|
GooglePlayUserEntitlement entitlement,
|
||||||
BigDecimal amount) {
|
BigDecimal amount,
|
||||||
if (GooglePlayConstants.DELIVERY_DELIVERED.equals(order.getDeliveryStatus())) {
|
boolean grantOwned) {
|
||||||
entitlement.setActive(true);
|
if (!grantOwned || GooglePlayConstants.DELIVERY_DELIVERED.equals(order.getDeliveryStatus())) {
|
||||||
|
entitlement.setActive(GooglePlayConstants.DELIVERY_DELIVERED.equals(order.getDeliveryStatus()));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
walletBenefitService.grant(userId, order.getId(), product.getName(), amount);
|
walletBenefitService.grant(userId, order.getId(), product.getName(), amount);
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import com.yolo.keyborad.model.entity.googleplay.GooglePlayUserEntitlement;
|
|||||||
import com.yolo.keyborad.service.KeyboardProductItemsService;
|
import com.yolo.keyborad.service.KeyboardProductItemsService;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
@@ -32,11 +33,13 @@ public class GooglePlayStateService {
|
|||||||
|
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
public GooglePlaySyncResult sync(GooglePlaySyncCommand command, GooglePlayPurchaseSnapshot snapshot) {
|
public GooglePlaySyncResult sync(GooglePlaySyncCommand command, GooglePlayPurchaseSnapshot snapshot) {
|
||||||
KeyboardProductItems product = loadProduct(snapshot.getBasePlanId());
|
String productId = (snapshot.getBasePlanId() != null) ?
|
||||||
|
snapshot.getBasePlanId() :
|
||||||
|
snapshot.getPurchaseOptionId();
|
||||||
|
KeyboardProductItems product = loadProduct(productId);
|
||||||
GooglePlayOrder order = buildOrder(command, snapshot);
|
GooglePlayOrder order = buildOrder(command, snapshot);
|
||||||
GooglePlayPurchaseToken token = buildToken(command, snapshot);
|
GooglePlayPurchaseToken token = buildToken(command, snapshot);
|
||||||
// 先保存订单以确保 order.id 已生成,钱包充值等权益分发依赖 order.id 写入交易流水
|
persistOrderIfNew(order);
|
||||||
saveOrder(order);
|
|
||||||
GooglePlayUserEntitlement entitlement = null;
|
GooglePlayUserEntitlement entitlement = null;
|
||||||
if (command.getUserId() != null) {
|
if (command.getUserId() != null) {
|
||||||
entitlement = entitlementApplier.apply(command.getUserId(), product, snapshot, order);
|
entitlement = entitlementApplier.apply(command.getUserId(), product, snapshot, order);
|
||||||
@@ -51,8 +54,8 @@ public class GooglePlayStateService {
|
|||||||
.order(order)
|
.order(order)
|
||||||
.token(token)
|
.token(token)
|
||||||
.entitlement(entitlement)
|
.entitlement(entitlement)
|
||||||
.acknowledgeRequired(requiresAcknowledge(snapshot, command.getUserId()))
|
.acknowledgeRequired(requiresAcknowledge(snapshot, command.getUserId(), order))
|
||||||
.consumeRequired(requiresConsume(snapshot, entitlement, command.getUserId()))
|
.consumeRequired(requiresConsume(snapshot, entitlement, command.getUserId(), order))
|
||||||
.linkedPurchaseTokenToSync(resolveLinkedToken(snapshot))
|
.linkedPurchaseTokenToSync(resolveLinkedToken(snapshot))
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
@@ -144,15 +147,22 @@ public class GooglePlayStateService {
|
|||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean requiresAcknowledge(GooglePlayPurchaseSnapshot snapshot, Long userId) {
|
private boolean requiresAcknowledge(GooglePlayPurchaseSnapshot snapshot, Long userId, GooglePlayOrder order) {
|
||||||
return userId != null
|
if (userId == null
|
||||||
&& GooglePlayConstants.ACK_PENDING.equals(snapshot.getAcknowledgementState())
|
|| !GooglePlayConstants.ACK_PENDING.equals(snapshot.getAcknowledgementState())
|
||||||
&& GooglePlayConstants.STATE_ACTIVE.equals(snapshot.getState());
|
|| !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,
|
private boolean requiresConsume(GooglePlayPurchaseSnapshot snapshot,
|
||||||
GooglePlayUserEntitlement entitlement,
|
GooglePlayUserEntitlement entitlement,
|
||||||
Long userId) {
|
Long userId,
|
||||||
|
GooglePlayOrder order) {
|
||||||
if (userId == null || entitlement == null) {
|
if (userId == null || entitlement == null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -160,7 +170,8 @@ public class GooglePlayStateService {
|
|||||||
boolean wallet = GooglePlayConstants.ENTITLEMENT_WALLET_TOP_UP.equals(entitlement.getBenefitType());
|
boolean wallet = GooglePlayConstants.ENTITLEMENT_WALLET_TOP_UP.equals(entitlement.getBenefitType());
|
||||||
boolean active = GooglePlayConstants.STATE_ACTIVE.equals(snapshot.getState());
|
boolean active = GooglePlayConstants.STATE_ACTIVE.equals(snapshot.getState());
|
||||||
boolean pending = GooglePlayConstants.CONSUMPTION_PENDING.equals(snapshot.getConsumptionState());
|
boolean pending = GooglePlayConstants.CONSUMPTION_PENDING.equals(snapshot.getConsumptionState());
|
||||||
return oneTime && wallet && active && pending;
|
boolean owned = Boolean.TRUE.equals(order.getDeliveryOwnershipGranted());
|
||||||
|
return oneTime && wallet && active && pending && owned;
|
||||||
}
|
}
|
||||||
|
|
||||||
private String resolveLinkedToken(GooglePlayPurchaseSnapshot snapshot) {
|
private String resolveLinkedToken(GooglePlayPurchaseSnapshot snapshot) {
|
||||||
@@ -172,20 +183,67 @@ public class GooglePlayStateService {
|
|||||||
|
|
||||||
private void saveOrder(GooglePlayOrder order) {
|
private void saveOrder(GooglePlayOrder order) {
|
||||||
if (order.getId() == null) {
|
if (order.getId() == null) {
|
||||||
orderMapper.insert(order);
|
insertOrder(order);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
orderMapper.updateById(order);
|
orderMapper.updateById(order);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仅在新订单场景预落库,避免并发请求用旧快照把发货状态回写成 PENDING。
|
||||||
|
*/
|
||||||
|
private void persistOrderIfNew(GooglePlayOrder order) {
|
||||||
|
if (order.getId() == null) {
|
||||||
|
saveOrder(order);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void saveToken(GooglePlayPurchaseToken token) {
|
private void saveToken(GooglePlayPurchaseToken token) {
|
||||||
if (token.getId() == null) {
|
if (token.getId() == null) {
|
||||||
purchaseTokenMapper.insert(token);
|
insertToken(token);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
purchaseTokenMapper.updateById(token);
|
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) {
|
private void updateAckState(String purchaseToken, String orderKey, String state) {
|
||||||
GooglePlayPurchaseToken token = findToken(purchaseToken);
|
GooglePlayPurchaseToken token = findToken(purchaseToken);
|
||||||
if (token != null) {
|
if (token != null) {
|
||||||
|
|||||||
@@ -56,4 +56,6 @@ public class GooglePlayPurchaseSnapshot {
|
|||||||
private Date lastSyncedAt;
|
private Date lastSyncedAt;
|
||||||
|
|
||||||
private String rawResponse;
|
private String rawResponse;
|
||||||
|
|
||||||
|
private String purchaseOptionId;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,23 @@ package com.yolo.keyborad.mapper;
|
|||||||
|
|
||||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
import com.yolo.keyborad.model.entity.googleplay.GooglePlayOrder;
|
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> {
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
package com.yolo.keyborad.mapper;
|
package com.yolo.keyborad.mapper;
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.yolo.keyborad.model.dto.chat.ChattedCompanionLastChatDTO;
|
||||||
import com.yolo.keyborad.model.entity.KeyboardAiChatMessage;
|
import com.yolo.keyborad.model.entity.KeyboardAiChatMessage;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* @author: ziin
|
* @author: ziin
|
||||||
@@ -9,4 +13,12 @@ import com.yolo.keyborad.model.entity.KeyboardAiChatMessage;
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
public interface KeyboardAiChatMessageMapper extends BaseMapper<KeyboardAiChatMessage> {
|
public interface KeyboardAiChatMessageMapper extends BaseMapper<KeyboardAiChatMessage> {
|
||||||
}
|
|
||||||
|
/**
|
||||||
|
* 查询用户在当前活跃会话中与每个AI角色的最后聊天时间
|
||||||
|
*
|
||||||
|
* @param userId 用户ID
|
||||||
|
* @return 最近聊天时间聚合结果
|
||||||
|
*/
|
||||||
|
List<ChattedCompanionLastChatDTO> selectLastChattedCompanions(@Param("userId") Long userId);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -136,4 +136,11 @@ public class KeyboardUser {
|
|||||||
@TableField(value = "uuid")
|
@TableField(value = "uuid")
|
||||||
@Schema(description = "uuid")
|
@Schema(description = "uuid")
|
||||||
private String uuid;
|
private String uuid;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Google 登录 subjectId
|
||||||
|
*/
|
||||||
|
@TableField(value = "google_subject_id")
|
||||||
|
@Schema(description = "Google 登录 subjectId")
|
||||||
|
private String googleSubjectId;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,4 +81,10 @@ public class GooglePlayOrder {
|
|||||||
|
|
||||||
@TableField("updated_at")
|
@TableField("updated_at")
|
||||||
private Date updatedAt;
|
private Date updatedAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前线程是否拿到了本次发货资格,仅用于本次请求内控制幂等,不落库。
|
||||||
|
*/
|
||||||
|
@TableField(exist = false)
|
||||||
|
private Boolean deliveryOwnershipGranted;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -5,7 +5,9 @@ import com.yolo.keyborad.model.entity.KeyboardAiChatMessage;
|
|||||||
import com.baomidou.mybatisplus.extension.service.IService;
|
import com.baomidou.mybatisplus.extension.service.IService;
|
||||||
import com.yolo.keyborad.model.vo.ChatMessageHistoryVO;
|
import com.yolo.keyborad.model.vo.ChatMessageHistoryVO;
|
||||||
|
|
||||||
|
import java.util.Date;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* @author: ziin
|
* @author: ziin
|
||||||
@@ -42,6 +44,14 @@ public interface KeyboardAiChatMessageService extends IService<KeyboardAiChatMes
|
|||||||
*/
|
*/
|
||||||
List<Long> getChattedCompanionIds(Long userId);
|
List<Long> getChattedCompanionIds(Long userId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户与每个AI角色的最后聊天时间
|
||||||
|
*
|
||||||
|
* @param userId 用户ID
|
||||||
|
* @return 角色ID到最后聊天时间的映射,顺序为最近聊天时间倒序
|
||||||
|
*/
|
||||||
|
Map<Long, Date> getLastChattedAtByCompanionId(Long userId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据聊天记录ID逻辑删除消息
|
* 根据聊天记录ID逻辑删除消息
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -12,10 +12,6 @@ import jakarta.servlet.http.HttpServletRequest;
|
|||||||
*/
|
*/
|
||||||
public interface UserService extends IService<KeyboardUser> {
|
public interface UserService extends IService<KeyboardUser> {
|
||||||
|
|
||||||
KeyboardUser selectUserWithSubjectId(String sub);
|
|
||||||
|
|
||||||
KeyboardUser createUserWithSubjectId(String sub);
|
|
||||||
|
|
||||||
KeyboardUserRespVO login(UserLoginDTO userLoginDTO, HttpServletRequest request);
|
KeyboardUserRespVO login(UserLoginDTO userLoginDTO, HttpServletRequest request);
|
||||||
|
|
||||||
Boolean updateUserInfo(KeyboardUserReq keyboardUser);
|
Boolean updateUserInfo(KeyboardUserReq keyboardUser);
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package com.yolo.keyborad.service.impl;
|
package com.yolo.keyborad.service.impl;
|
||||||
|
|
||||||
import cn.dev33.satoken.stp.SaTokenInfo;
|
|
||||||
import cn.dev33.satoken.stp.StpUtil;
|
import cn.dev33.satoken.stp.StpUtil;
|
||||||
import cn.hutool.core.bean.BeanUtil;
|
import cn.hutool.core.bean.BeanUtil;
|
||||||
import cn.hutool.http.HttpUtil;
|
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.entity.KeyboardUser;
|
||||||
import com.yolo.keyborad.model.vo.user.KeyboardUserRespVO;
|
import com.yolo.keyborad.model.vo.user.KeyboardUserRespVO;
|
||||||
import com.yolo.keyborad.service.IAppleService;
|
import com.yolo.keyborad.service.IAppleService;
|
||||||
import com.yolo.keyborad.service.KeyboardUserLoginLogService;
|
import com.yolo.keyborad.service.ThirdPartyLoginUserService;
|
||||||
import com.yolo.keyborad.service.UserService;
|
|
||||||
import com.yolo.keyborad.utils.RequestIpUtils;
|
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import io.jsonwebtoken.*;
|
import io.jsonwebtoken.*;
|
||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
@@ -39,10 +36,10 @@ import java.util.Objects;
|
|||||||
public class AppleServiceImpl implements IAppleService {
|
public class AppleServiceImpl implements IAppleService {
|
||||||
|
|
||||||
@Resource
|
@Resource
|
||||||
private UserService userService;
|
private ThirdPartyLoginUserService thirdPartyLoginUserService;
|
||||||
|
|
||||||
@Resource
|
@Resource
|
||||||
private KeyboardUserLoginLogService loginLogService;
|
private UserLoginAuditService userLoginAuditService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 登录
|
* 登录
|
||||||
@@ -92,54 +89,16 @@ public class AppleServiceImpl implements IAppleService {
|
|||||||
|
|
||||||
// 返回用户标识符
|
// 返回用户标识符
|
||||||
if (result) {
|
if (result) {
|
||||||
KeyboardUser user = userService.selectUserWithSubjectId(sub);
|
KeyboardUser user = thirdPartyLoginUserService.selectAppleUser(sub);
|
||||||
boolean isNewUser = false;
|
boolean isNewUser = false;
|
||||||
if (user == null) {
|
if (user == null) {
|
||||||
user = userService.createUserWithSubjectId(sub);
|
user = thirdPartyLoginUserService.createAppleUser(sub);
|
||||||
isNewUser = true;
|
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);
|
KeyboardUserRespVO keyboardUserRespVO = BeanUtil.copyProperties(user, KeyboardUserRespVO.class);
|
||||||
StpUtil.login(user.getId());
|
StpUtil.login(user.getId());
|
||||||
|
userLoginAuditService.recordLoginLog(user.getId(), request, isNewUser ? "APPLE_NEW_USER" : "SUCCESS");
|
||||||
keyboardUserRespVO.setToken(StpUtil.getTokenValueByLoginId(user.getId()));
|
keyboardUserRespVO.setToken(StpUtil.getTokenValueByLoginId(user.getId()));
|
||||||
return keyboardUserRespVO;
|
return keyboardUserRespVO;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
|||||||
import com.yolo.keyborad.mapper.KeyboardAiChatMessageMapper;
|
import com.yolo.keyborad.mapper.KeyboardAiChatMessageMapper;
|
||||||
import com.yolo.keyborad.common.ErrorCode;
|
import com.yolo.keyborad.common.ErrorCode;
|
||||||
import com.yolo.keyborad.exception.BusinessException;
|
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.KeyboardAiChatMessage;
|
||||||
import com.yolo.keyborad.model.entity.KeyboardAiChatSession;
|
import com.yolo.keyborad.model.entity.KeyboardAiChatSession;
|
||||||
import com.yolo.keyborad.model.vo.ChatMessageHistoryVO;
|
import com.yolo.keyborad.model.vo.ChatMessageHistoryVO;
|
||||||
@@ -17,7 +18,11 @@ import jakarta.annotation.Resource;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* @author: ziin
|
* @author: ziin
|
||||||
@@ -83,35 +88,23 @@ public class KeyboardAiChatMessageServiceImpl extends ServiceImpl<KeyboardAiChat
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<Long> getChattedCompanionIds(Long userId) {
|
public List<Long> getChattedCompanionIds(Long userId) {
|
||||||
// 1. 查询用户所有活跃会话
|
return getLastChattedAtByCompanionId(userId).keySet().stream().toList();
|
||||||
LambdaQueryWrapper<KeyboardAiChatSession> sessionWrapper = new LambdaQueryWrapper<>();
|
}
|
||||||
sessionWrapper.eq(KeyboardAiChatSession::getUserId, userId)
|
|
||||||
.eq(KeyboardAiChatSession::getIsActive, true);
|
|
||||||
List<KeyboardAiChatSession> activeSessions = sessionService.list(sessionWrapper);
|
|
||||||
|
|
||||||
// 2. 如果没有活跃会话,返回空列表
|
@Override
|
||||||
if (activeSessions == null || activeSessions.isEmpty()) {
|
public Map<Long, Date> getLastChattedAtByCompanionId(Long userId) {
|
||||||
return Collections.emptyList();
|
List<ChattedCompanionLastChatDTO> records = baseMapper.selectLastChattedCompanions(userId);
|
||||||
|
if (records.isEmpty()) {
|
||||||
|
return Collections.emptyMap();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 提取活跃会话的 sessionId 列表
|
// LinkedHashMap 保留 SQL 按最后聊天时间倒序返回的顺序。
|
||||||
List<Long> activeSessionIds = activeSessions.stream()
|
return records.stream().collect(Collectors.toMap(
|
||||||
.map(KeyboardAiChatSession::getId)
|
ChattedCompanionLastChatDTO::getCompanionId,
|
||||||
.collect(java.util.stream.Collectors.toList());
|
ChattedCompanionLastChatDTO::getLastChattedAt,
|
||||||
|
(left, right) -> left,
|
||||||
// 4. 查询这些会话中的消息,获取 companionId,按最近聊天时间倒序
|
LinkedHashMap::new
|
||||||
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());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import com.yolo.keyborad.service.KeyboardAiCompanionService;
|
|||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
|
import java.util.Date;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -193,21 +194,8 @@ public class KeyboardAiCompanionServiceImpl extends ServiceImpl<KeyboardAiCompan
|
|||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
Map<Long, KeyboardAiCompanionI18n> i18nMap = getCompanionI18nMap(companionIds, acceptLanguage);
|
Map<Long, KeyboardAiCompanionI18n> i18nMap = getCompanionI18nMap(companionIds, acceptLanguage);
|
||||||
|
|
||||||
// 批量统计点赞数
|
Map<Long, Long> likeCountMap = countLikesByCompanionId(companionIds);
|
||||||
LambdaQueryWrapper<KeyboardAiCompanionLike> likeWrapper = new LambdaQueryWrapper<>();
|
Map<Long, Long> commentCountMap = countCommentsByCompanionId(companionIds);
|
||||||
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()));
|
|
||||||
|
|
||||||
// 转换为VO并填充统计数据
|
// 转换为VO并填充统计数据
|
||||||
return companions.stream().map(entity -> {
|
return companions.stream().map(entity -> {
|
||||||
@@ -221,11 +209,12 @@ public class KeyboardAiCompanionServiceImpl extends ServiceImpl<KeyboardAiCompan
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<AiCompanionVO> getChattedCompanions(Long userId, String acceptLanguage) {
|
public List<AiCompanionVO> getChattedCompanions(Long userId, String acceptLanguage) {
|
||||||
// 获取用户聊过天的所有AI角色ID
|
// 获取用户聊过天的AI角色ID及每个角色的最后聊天时间
|
||||||
List<Long> chattedCompanionIds = chatMessageService.getChattedCompanionIds(userId);
|
Map<Long, Date> lastChattedAtMap = chatMessageService.getLastChattedAtByCompanionId(userId);
|
||||||
if (chattedCompanionIds.isEmpty()) {
|
if (lastChattedAtMap.isEmpty()) {
|
||||||
return List.of();
|
return List.of();
|
||||||
}
|
}
|
||||||
|
List<Long> chattedCompanionIds = lastChattedAtMap.keySet().stream().toList();
|
||||||
|
|
||||||
// 查询这些AI角色的详细信息(只返回已上线且可见的)
|
// 查询这些AI角色的详细信息(只返回已上线且可见的)
|
||||||
LambdaQueryWrapper<KeyboardAiCompanion> queryWrapper = new LambdaQueryWrapper<>();
|
LambdaQueryWrapper<KeyboardAiCompanion> queryWrapper = new LambdaQueryWrapper<>();
|
||||||
@@ -244,21 +233,8 @@ public class KeyboardAiCompanionServiceImpl extends ServiceImpl<KeyboardAiCompan
|
|||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
Map<Long, KeyboardAiCompanionI18n> i18nMap = getCompanionI18nMap(companionIds, acceptLanguage);
|
Map<Long, KeyboardAiCompanionI18n> i18nMap = getCompanionI18nMap(companionIds, acceptLanguage);
|
||||||
|
|
||||||
// 批量统计点赞数
|
Map<Long, Long> likeCountMap = countLikesByCompanionId(companionIds);
|
||||||
LambdaQueryWrapper<KeyboardAiCompanionLike> likeWrapper = new LambdaQueryWrapper<>();
|
Map<Long, Long> commentCountMap = countCommentsByCompanionId(companionIds);
|
||||||
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()));
|
|
||||||
|
|
||||||
// 获取当前用户已点赞的角色ID
|
// 获取当前用户已点赞的角色ID
|
||||||
Set<Long> likedCompanionIds = companionLikeService.getLikedCompanionIds(userId, companionIds);
|
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.setLikeCount(likeCountMap.getOrDefault(entity.getId(), 0L).intValue());
|
||||||
vo.setCommentCount(commentCountMap.getOrDefault(entity.getId(), 0L).intValue());
|
vo.setCommentCount(commentCountMap.getOrDefault(entity.getId(), 0L).intValue());
|
||||||
vo.setLiked(likedCompanionIds.contains(entity.getId()));
|
vo.setLiked(likedCompanionIds.contains(entity.getId()));
|
||||||
|
vo.setCreatedAt(lastChattedAtMap.get(entity.getId()));
|
||||||
return vo;
|
return vo;
|
||||||
}).collect(Collectors.toList());
|
}).collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
@@ -329,6 +306,34 @@ public class KeyboardAiCompanionServiceImpl extends ServiceImpl<KeyboardAiCompan
|
|||||||
return vo;
|
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) {
|
private Map<Long, KeyboardAiCompanionI18n> getCompanionI18nMap(List<Long> companionIds, String acceptLanguage) {
|
||||||
String locale = RequestLocaleUtils.resolveLanguage(acceptLanguage);
|
String locale = RequestLocaleUtils.resolveLanguage(acceptLanguage);
|
||||||
if (companionIds == null || companionIds.isEmpty() || !StringUtils.hasText(locale)) {
|
if (companionIds == null || companionIds.isEmpty() || !StringUtils.hasText(locale)) {
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,46 +2,38 @@ package com.yolo.keyborad.service.impl;
|
|||||||
|
|
||||||
import cn.dev33.satoken.stp.StpUtil;
|
import cn.dev33.satoken.stp.StpUtil;
|
||||||
import cn.hutool.core.bean.BeanUtil;
|
import cn.hutool.core.bean.BeanUtil;
|
||||||
import cn.hutool.core.lang.UUID;
|
|
||||||
import cn.hutool.core.util.IdUtil;
|
|
||||||
import cn.hutool.core.util.RandomUtil;
|
|
||||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||||
import com.yolo.keyborad.common.ErrorCode;
|
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.exception.BusinessException;
|
||||||
import com.yolo.keyborad.mapper.KeyboardUserMapper;
|
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.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.model.vo.user.KeyboardUserRespVO;
|
||||||
import com.yolo.keyborad.service.*;
|
import com.yolo.keyborad.service.UserService;
|
||||||
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.time.Instant;
|
|
||||||
import java.time.temporal.ChronoUnit;
|
|
||||||
import java.util.Date;
|
|
||||||
import com.yolo.keyborad.service.impl.user.UserInviteCodeBinder;
|
import com.yolo.keyborad.service.impl.user.UserInviteCodeBinder;
|
||||||
import com.yolo.keyborad.service.impl.user.UserMailVerificationHandler;
|
import com.yolo.keyborad.service.impl.user.UserMailVerificationHandler;
|
||||||
import com.yolo.keyborad.service.impl.user.UserPasswordHandler;
|
import com.yolo.keyborad.service.impl.user.UserPasswordHandler;
|
||||||
import com.yolo.keyborad.service.impl.user.UserRegistrationHandler;
|
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
|
* @author: ziin
|
||||||
* @date: 2025/12/2 18:19
|
* @date: 2025/12/2 18:19
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
@Slf4j
|
|
||||||
public class UserServiceImpl extends ServiceImpl<KeyboardUserMapper, KeyboardUser> implements UserService {
|
public class UserServiceImpl extends ServiceImpl<KeyboardUserMapper, KeyboardUser> implements UserService {
|
||||||
|
|
||||||
@Resource
|
@Resource
|
||||||
@@ -50,21 +42,6 @@ public class UserServiceImpl extends ServiceImpl<KeyboardUserMapper, KeyboardUse
|
|||||||
@Resource
|
@Resource
|
||||||
private PasswordEncoder passwordEncoder;
|
private PasswordEncoder passwordEncoder;
|
||||||
|
|
||||||
@Resource
|
|
||||||
private KeyboardCharacterService keyboardCharacterService;
|
|
||||||
|
|
||||||
@Resource
|
|
||||||
private KeyboardUserWalletService walletService;
|
|
||||||
|
|
||||||
@Resource
|
|
||||||
private KeyboardUserLoginLogService loginLogService;
|
|
||||||
|
|
||||||
@Resource
|
|
||||||
private KeyboardUserQuotaTotalService quotaTotalService;
|
|
||||||
|
|
||||||
@Resource
|
|
||||||
private KeyboardUserInviteCodesService inviteCodesService;
|
|
||||||
|
|
||||||
@Resource
|
@Resource
|
||||||
private UserRegistrationHandler registrationHandler;
|
private UserRegistrationHandler registrationHandler;
|
||||||
|
|
||||||
@@ -77,52 +54,10 @@ public class UserServiceImpl extends ServiceImpl<KeyboardUserMapper, KeyboardUse
|
|||||||
@Resource
|
@Resource
|
||||||
private UserInviteCodeBinder inviteCodeBinder;
|
private UserInviteCodeBinder inviteCodeBinder;
|
||||||
|
|
||||||
private final NacosAppConfigCenter.DynamicAppConfig cfgHolder;
|
@Resource
|
||||||
|
private UserLoginAuditService userLoginAuditService;
|
||||||
|
|
||||||
public UserServiceImpl(NacosAppConfigCenter.DynamicAppConfig cfgHolder) {
|
public UserServiceImpl() {
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static final int ACCOUNT_REUSE_COOLDOWN_DAYS = 7;
|
|
||||||
|
|
||||||
private void ensureSubjectIdNotRecentlyCancelled(String sub) {
|
|
||||||
Date cooldownStart = Date.from(Instant.now().minus(ACCOUNT_REUSE_COOLDOWN_DAYS, ChronoUnit.DAYS));
|
|
||||||
KeyboardUser recentlyDeleted = keyboardUserMapper.selectOne(
|
|
||||||
new LambdaQueryWrapper<KeyboardUser>()
|
|
||||||
.eq(KeyboardUser::getSubjectId, sub)
|
|
||||||
.eq(KeyboardUser::getDeleted, true)
|
|
||||||
.gt(KeyboardUser::getDeletedAt, cooldownStart)
|
|
||||||
.last("LIMIT 1")
|
|
||||||
);
|
|
||||||
if (recentlyDeleted != null) {
|
|
||||||
throw new BusinessException(ErrorCode.ACCOUNT_RECENTLY_CANCELLED);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public KeyboardUser createUserWithSubjectId(String sub) {
|
|
||||||
ensureSubjectIdNotRecentlyCancelled(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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -139,9 +74,7 @@ public class UserServiceImpl extends ServiceImpl<KeyboardUserMapper, KeyboardUse
|
|||||||
throw new BusinessException(ErrorCode.PASSWORD_OR_MAIL_ERROR);
|
throw new BusinessException(ErrorCode.PASSWORD_OR_MAIL_ERROR);
|
||||||
}
|
}
|
||||||
StpUtil.login(keyboardUser.getId());
|
StpUtil.login(keyboardUser.getId());
|
||||||
|
userLoginAuditService.recordLoginLog(keyboardUser.getId(), request, "SUCCESS");
|
||||||
// 记录登录日志
|
|
||||||
recordLoginLogSafely(keyboardUser.getId(), request);
|
|
||||||
|
|
||||||
KeyboardUserRespVO keyboardUserRespVO = BeanUtil.copyProperties(keyboardUser, KeyboardUserRespVO.class);
|
KeyboardUserRespVO keyboardUserRespVO = BeanUtil.copyProperties(keyboardUser, KeyboardUserRespVO.class);
|
||||||
keyboardUserRespVO.setToken(StpUtil.getTokenValue());
|
keyboardUserRespVO.setToken(StpUtil.getTokenValue());
|
||||||
@@ -226,90 +159,4 @@ public class UserServiceImpl extends ServiceImpl<KeyboardUserMapper, KeyboardUse
|
|||||||
.eq(KeyboardUser::getUuid, uuid));
|
.eq(KeyboardUser::getUuid, uuid));
|
||||||
return keyboardUserDB.getId();
|
return keyboardUserDB.getId();
|
||||||
}
|
}
|
||||||
|
|
||||||
private KeyboardUser buildNewUserWithSubjectId(String sub) {
|
|
||||||
KeyboardUser keyboardUser = new KeyboardUser();
|
|
||||||
keyboardUser.setSubjectId(sub);
|
|
||||||
keyboardUser.setUid(IdUtil.getSnowflake().nextId());
|
|
||||||
keyboardUser.setNickName("User_" + RandomUtil.randomString(6));
|
|
||||||
keyboardUser.setUuid(IdUtil.randomUUID());
|
|
||||||
return keyboardUser;
|
|
||||||
}
|
|
||||||
|
|
||||||
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";
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,14 +10,7 @@ import com.yolo.keyborad.exception.BusinessException;
|
|||||||
import com.yolo.keyborad.mapper.KeyboardUserMapper;
|
import com.yolo.keyborad.mapper.KeyboardUserMapper;
|
||||||
import com.yolo.keyborad.model.dto.user.UserRegisterDTO;
|
import com.yolo.keyborad.model.dto.user.UserRegisterDTO;
|
||||||
import com.yolo.keyborad.model.entity.KeyboardUser;
|
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 com.yolo.keyborad.utils.RedisUtil;
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.temporal.ChronoUnit;
|
import java.time.temporal.ChronoUnit;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
@@ -40,10 +33,7 @@ public class UserRegistrationHandler {
|
|||||||
private final KeyboardUserMapper keyboardUserMapper;
|
private final KeyboardUserMapper keyboardUserMapper;
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
private final RedisUtil redisUtil;
|
private final RedisUtil redisUtil;
|
||||||
private final KeyboardCharacterService keyboardCharacterService;
|
private final NewUserAssetsInitializer newUserAssetsInitializer;
|
||||||
private final KeyboardUserWalletService walletService;
|
|
||||||
private final KeyboardUserQuotaTotalService quotaTotalService;
|
|
||||||
private final KeyboardUserInviteCodesService inviteCodesService;
|
|
||||||
private final UserInviteCodeBinder inviteCodeBinder;
|
private final UserInviteCodeBinder inviteCodeBinder;
|
||||||
private final NacosAppConfigCenter.DynamicAppConfig cfgHolder;
|
private final NacosAppConfigCenter.DynamicAppConfig cfgHolder;
|
||||||
|
|
||||||
@@ -51,20 +41,14 @@ public class UserRegistrationHandler {
|
|||||||
KeyboardUserMapper keyboardUserMapper,
|
KeyboardUserMapper keyboardUserMapper,
|
||||||
PasswordEncoder passwordEncoder,
|
PasswordEncoder passwordEncoder,
|
||||||
RedisUtil redisUtil,
|
RedisUtil redisUtil,
|
||||||
KeyboardCharacterService keyboardCharacterService,
|
NewUserAssetsInitializer newUserAssetsInitializer,
|
||||||
KeyboardUserWalletService walletService,
|
|
||||||
KeyboardUserQuotaTotalService quotaTotalService,
|
|
||||||
KeyboardUserInviteCodesService inviteCodesService,
|
|
||||||
UserInviteCodeBinder inviteCodeBinder,
|
UserInviteCodeBinder inviteCodeBinder,
|
||||||
NacosAppConfigCenter.DynamicAppConfig cfgHolder
|
NacosAppConfigCenter.DynamicAppConfig cfgHolder
|
||||||
) {
|
) {
|
||||||
this.keyboardUserMapper = keyboardUserMapper;
|
this.keyboardUserMapper = keyboardUserMapper;
|
||||||
this.passwordEncoder = passwordEncoder;
|
this.passwordEncoder = passwordEncoder;
|
||||||
this.redisUtil = redisUtil;
|
this.redisUtil = redisUtil;
|
||||||
this.keyboardCharacterService = keyboardCharacterService;
|
this.newUserAssetsInitializer = newUserAssetsInitializer;
|
||||||
this.walletService = walletService;
|
|
||||||
this.quotaTotalService = quotaTotalService;
|
|
||||||
this.inviteCodesService = inviteCodesService;
|
|
||||||
this.inviteCodeBinder = inviteCodeBinder;
|
this.inviteCodeBinder = inviteCodeBinder;
|
||||||
this.cfgHolder = cfgHolder;
|
this.cfgHolder = cfgHolder;
|
||||||
}
|
}
|
||||||
@@ -144,38 +128,7 @@ public class UserRegistrationHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void initNewUserAssets(KeyboardUser keyboardUser) {
|
private void initNewUserAssets(KeyboardUser keyboardUser) {
|
||||||
keyboardCharacterService.addDefaultUserCharacter(keyboardUser.getId());
|
newUserAssetsInitializer.initialize(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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void tryBindInviteCode(String inviteCode, long userId) {
|
private void tryBindInviteCode(String inviteCode, long userId) {
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ apple:
|
|||||||
key-id: "Y7TF7BV74G"
|
key-id: "Y7TF7BV74G"
|
||||||
bundle-id: "com.loveKey.nyx"
|
bundle-id: "com.loveKey.nyx"
|
||||||
# app 在 App Store 中的 Apple ID(可在开发者后台查看)
|
# app 在 App Store 中的 Apple ID(可在开发者后台查看)
|
||||||
app-apple-id: 1234567890
|
app-apple-id: 6755171905
|
||||||
|
|
||||||
# p8 私钥文件路径,建议放在 resources 目录
|
# p8 私钥文件路径,建议放在 resources 目录
|
||||||
private-key-path: "classpath:SubscriptionKey_Y7TF7BV74G.p8"
|
private-key-path: "classpath:SubscriptionKey_Y7TF7BV74G.p8"
|
||||||
@@ -64,8 +64,13 @@ google:
|
|||||||
require-obfuscated-account-id: false
|
require-obfuscated-account-id: false
|
||||||
pubsub:
|
pubsub:
|
||||||
expected-topic: "projects/keyboard-490601/topics/keyboard_topic"
|
expected-topic: "projects/keyboard-490601/topics/keyboard_topic"
|
||||||
expected-subscription: "projects/keyboard-490601/subscriptions/keyboard_topic-sub"
|
expected-subscription: "projects/keyboard-490601/subscriptions/rtdn_dev"
|
||||||
service-account-email: "id-220@keyboard-490601.iam.gserviceaccount.com"
|
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:
|
dromara:
|
||||||
x-file-storage: #文件存储配置
|
x-file-storage: #文件存储配置
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ apple:
|
|||||||
key-id: "Y7TF7BV74G"
|
key-id: "Y7TF7BV74G"
|
||||||
bundle-id: "com.loveKey.nyx"
|
bundle-id: "com.loveKey.nyx"
|
||||||
# app 在 App Store 中的 Apple ID(可在开发者后台查看)
|
# app 在 App Store 中的 Apple ID(可在开发者后台查看)
|
||||||
app-apple-id: 1234567890
|
app-apple-id: 6755171905
|
||||||
|
|
||||||
# p8 私钥文件路径,建议放在 resources 目录
|
# p8 私钥文件路径,建议放在 resources 目录
|
||||||
private-key-path: "classpath:SubscriptionKey_Y7TF7BV74G.p8"
|
private-key-path: "classpath:SubscriptionKey_Y7TF7BV74G.p8"
|
||||||
@@ -64,6 +64,11 @@ google:
|
|||||||
expected-topic: "projects/keyboard-490601/topics/keyboard_topic"
|
expected-topic: "projects/keyboard-490601/topics/keyboard_topic"
|
||||||
expected-subscription: "projects/keyboard-490601/subscriptions/keyboard_topic-sub"
|
expected-subscription: "projects/keyboard-490601/subscriptions/keyboard_topic-sub"
|
||||||
service-account-email: "id-220@keyboard-490601.iam.gserviceaccount.com"
|
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:
|
nacos:
|
||||||
config:
|
config:
|
||||||
|
|||||||
@@ -17,4 +17,20 @@
|
|||||||
<!--@mbg.generated-->
|
<!--@mbg.generated-->
|
||||||
id, user_id, companion_id, sender, content, emotion_detected, support_type, created_at
|
id, user_id, companion_id, sender, content, emotion_detected, support_type, created_at
|
||||||
</sql>
|
</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>
|
||||||
|
|||||||
@@ -19,11 +19,16 @@
|
|||||||
<result column="email_verified" jdbcType="BOOLEAN" property="emailVerified" />
|
<result column="email_verified" jdbcType="BOOLEAN" property="emailVerified" />
|
||||||
<result column="is_vip" jdbcType="BOOLEAN" property="isVip" />
|
<result column="is_vip" jdbcType="BOOLEAN" property="isVip" />
|
||||||
<result column="vip_expiry" jdbcType="TIMESTAMP" property="vipExpiry" />
|
<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>
|
</resultMap>
|
||||||
<sql id="Base_Column_List">
|
<sql id="Base_Column_List">
|
||||||
<!--@mbg.generated-->
|
<!--@mbg.generated-->
|
||||||
id, "uid", nick_name, gender, avatar_url, created_at, updated_at, deleted, email,
|
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>
|
</sql>
|
||||||
|
|
||||||
<update id="updateByuid">
|
<update id="updateByuid">
|
||||||
@@ -36,4 +41,4 @@
|
|||||||
</set>
|
</set>
|
||||||
where uid = #{uid}
|
where uid = #{uid}
|
||||||
</update>
|
</update>
|
||||||
</mapper>
|
</mapper>
|
||||||
|
|||||||
@@ -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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user