diff --git a/.gitignore b/.gitignore
index 7fbcb53..722d10e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -38,6 +38,16 @@ build/
/CLAUDE.md
/AGENTS.md
/src/test/
+!/src/test/
+/src/test/**
+!/src/test/java/
+!/src/test/java/com/
+!/src/test/java/com/yolo/
+!/src/test/java/com/yolo/keyborad/
+!/src/test/java/com/yolo/keyborad/controller/
+!/src/test/java/com/yolo/keyborad/controller/KeyboardAppVersionsControllerTest.java
+!/src/test/java/com/yolo/keyborad/service/
+!/src/test/java/com/yolo/keyborad/service/UserCancellationRegistrationTest.java
/.claude/agents/backend-architect.md
/.dockerignore
/Dockerfile
diff --git a/pom.xml b/pom.xml
index e490c7d..b47018f 100644
--- a/pom.xml
+++ b/pom.xml
@@ -233,6 +233,18 @@
spring-boot-starter-security
+
+
+ com.google.api-client
+ google-api-client
+ 2.8.1
+
+
+ com.google.http-client
+ google-http-client-gson
+ 1.47.0
+
+
com.mailersend
@@ -316,4 +328,4 @@
-
\ No newline at end of file
+
diff --git a/src/main/java/com/yolo/keyborad/MyApplication.java b/src/main/java/com/yolo/keyborad/MyApplication.java
index 1a77a5e..541defd 100644
--- a/src/main/java/com/yolo/keyborad/MyApplication.java
+++ b/src/main/java/com/yolo/keyborad/MyApplication.java
@@ -2,6 +2,7 @@ package com.yolo.keyborad;
import com.yolo.keyborad.common.xfile.ByteFileWrapperAdapter;
import com.yolo.keyborad.config.AppleAppStoreProperties;
+import com.yolo.keyborad.config.GoogleLoginProperties;
import com.yolo.keyborad.config.GooglePlayProperties;
import lombok.extern.slf4j.Slf4j;
import org.dromara.x.file.storage.core.tika.ContentTypeDetect;
@@ -15,7 +16,11 @@ import org.springframework.context.annotation.Bean;
@Slf4j
@SpringBootApplication
-@EnableConfigurationProperties({AppleAppStoreProperties.class, GooglePlayProperties.class})
+@EnableConfigurationProperties({
+ AppleAppStoreProperties.class,
+ GooglePlayProperties.class,
+ GoogleLoginProperties.class
+})
@EnableFileStorage
public class MyApplication {
public static void main(String[] args) {
diff --git a/src/main/java/com/yolo/keyborad/common/ErrorCode.java b/src/main/java/com/yolo/keyborad/common/ErrorCode.java
index f5812f8..ca2ece2 100644
--- a/src/main/java/com/yolo/keyborad/common/ErrorCode.java
+++ b/src/main/java/com/yolo/keyborad/common/ErrorCode.java
@@ -19,6 +19,8 @@ public enum ErrorCode {
SYSTEM_ERROR(50000, "系统内部异常"),
OPERATION_ERROR(50001, "操作失败"),
APPLE_LOGIN_ERROR(40003, "Apple登录失败"),
+ GOOGLE_LOGIN_ERROR(40023, "Google登录失败"),
+ GOOGLE_LOGIN_CSRF_INVALID(40024, "Google登录CSRF校验失败"),
FILE_IS_EMPTY(40001, "上传文件为空"),
FILE_NAME_ERROR(40002, "文件名错误"),
FILE_TYPE_ERROR(40004, "文件类型不支持,仅支持图片格式"),
@@ -86,6 +88,8 @@ public enum ErrorCode {
REPORT_COMPANION_ID_EMPTY(40021, "被举报的AI角色ID不能为空"),
REPORT_TYPE_EMPTY(40022, "举报类型不能为空"),
ACCOUNT_RECENTLY_CANCELLED(50038, "账号注销未满7天,暂不允许注册"),
+ GOOGLE_LOGIN_DISABLED(50039, "Google登录未开启"),
+ GOOGLE_LOGIN_BIND_REQUIRED(50040, "该邮箱已注册,请使用原登录方式登录后再绑定Google账号"),
VERSION_NOT_FOUND(40022, "未找到可用的版本配置");
/**
diff --git a/src/main/java/com/yolo/keyborad/config/GoogleLoginProperties.java b/src/main/java/com/yolo/keyborad/config/GoogleLoginProperties.java
new file mode 100644
index 0000000..c261a8b
--- /dev/null
+++ b/src/main/java/com/yolo/keyborad/config/GoogleLoginProperties.java
@@ -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 clientIds = new ArrayList<>();
+
+ /**
+ * 可选:限制 Google Workspace 域。
+ */
+ private String hostedDomain;
+}
diff --git a/src/main/java/com/yolo/keyborad/config/SaTokenConfigure.java b/src/main/java/com/yolo/keyborad/config/SaTokenConfigure.java
index 8862ead..9640207 100644
--- a/src/main/java/com/yolo/keyborad/config/SaTokenConfigure.java
+++ b/src/main/java/com/yolo/keyborad/config/SaTokenConfigure.java
@@ -90,7 +90,8 @@ public class SaTokenConfigure implements WebMvcConfigurer {
"/appVersions/checkUpdate",
"/appVersions/checkUpdate",
"/character/detailWithNotLogin",
- "/apple/validate-receipt"
+ "/apple/validate-receipt",
+ "/user/googleLogin"
};
}
@Bean
diff --git a/src/main/java/com/yolo/keyborad/controller/UserController.java b/src/main/java/com/yolo/keyborad/controller/UserController.java
index 831dae9..176cfae 100644
--- a/src/main/java/com/yolo/keyborad/controller/UserController.java
+++ b/src/main/java/com/yolo/keyborad/controller/UserController.java
@@ -7,6 +7,7 @@ import com.yolo.keyborad.common.ResultUtils;
import com.yolo.keyborad.config.AppConfig;
import com.yolo.keyborad.config.NacosAppConfigCenter;
import com.yolo.keyborad.model.dto.AppleLoginReq;
+import com.yolo.keyborad.model.dto.GoogleLoginReq;
import com.yolo.keyborad.model.dto.user.*;
import com.yolo.keyborad.model.entity.KeyboardFeedback;
import com.yolo.keyborad.model.entity.KeyboardUser;
@@ -14,6 +15,7 @@ import com.yolo.keyborad.model.entity.KeyboardUserInviteCodes;
import com.yolo.keyborad.model.vo.user.InviteCodeRespVO;
import com.yolo.keyborad.model.vo.user.KeyboardUserInfoRespVO;
import com.yolo.keyborad.model.vo.user.KeyboardUserRespVO;
+import com.yolo.keyborad.service.GoogleLoginService;
import com.yolo.keyborad.service.IAppleService;
import com.yolo.keyborad.service.KeyboardFeedbackService;
import com.yolo.keyborad.service.KeyboardUserInviteCodesService;
@@ -46,6 +48,9 @@ public class UserController {
@Resource
private UserService userService;
+ @Resource
+ private GoogleLoginService googleLoginService;
+
@Resource
private KeyboardFeedbackService feedbackService;
@@ -69,6 +74,13 @@ public class UserController {
return ResultUtils.success(appleService.login(appleLoginReq.getIdentityToken(), request));
}
+ @PostMapping("/googleLogin")
+ @Operation(summary = "Google 登录", description = "Google 登录接口")
+ public BaseResponse googleLogin(@RequestBody GoogleLoginReq googleLoginReq,
+ HttpServletRequest request) {
+ return ResultUtils.success(googleLoginService.login(googleLoginReq, request));
+ }
+
@GetMapping("/logout")
@Operation(summary = "退出登录", description = "退出登录接口")
public BaseResponse logout() {
diff --git a/src/main/java/com/yolo/keyborad/model/dto/GoogleLoginReq.java b/src/main/java/com/yolo/keyborad/model/dto/GoogleLoginReq.java
new file mode 100644
index 0000000..3a2e902
--- /dev/null
+++ b/src/main/java/com/yolo/keyborad/model/dto/GoogleLoginReq.java
@@ -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;
+ }
+}
diff --git a/src/main/java/com/yolo/keyborad/model/dto/googlelogin/GoogleIdTokenPayload.java b/src/main/java/com/yolo/keyborad/model/dto/googlelogin/GoogleIdTokenPayload.java
new file mode 100644
index 0000000..b901317
--- /dev/null
+++ b/src/main/java/com/yolo/keyborad/model/dto/googlelogin/GoogleIdTokenPayload.java
@@ -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);
+ }
+}
diff --git a/src/main/java/com/yolo/keyborad/model/entity/KeyboardUser.java b/src/main/java/com/yolo/keyborad/model/entity/KeyboardUser.java
index 1529dcc..bb55edc 100644
--- a/src/main/java/com/yolo/keyborad/model/entity/KeyboardUser.java
+++ b/src/main/java/com/yolo/keyborad/model/entity/KeyboardUser.java
@@ -136,4 +136,11 @@ public class KeyboardUser {
@TableField(value = "uuid")
@Schema(description = "uuid")
private String uuid;
+
+ /**
+ * Google 登录 subjectId
+ */
+ @TableField(value = "google_subject_id")
+ @Schema(description = "Google 登录 subjectId")
+ private String googleSubjectId;
}
diff --git a/src/main/java/com/yolo/keyborad/service/GoogleLoginService.java b/src/main/java/com/yolo/keyborad/service/GoogleLoginService.java
new file mode 100644
index 0000000..ada9f8d
--- /dev/null
+++ b/src/main/java/com/yolo/keyborad/service/GoogleLoginService.java
@@ -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);
+}
diff --git a/src/main/java/com/yolo/keyborad/service/ThirdPartyLoginUserService.java b/src/main/java/com/yolo/keyborad/service/ThirdPartyLoginUserService.java
new file mode 100644
index 0000000..6a24b27
--- /dev/null
+++ b/src/main/java/com/yolo/keyborad/service/ThirdPartyLoginUserService.java
@@ -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);
+}
diff --git a/src/main/java/com/yolo/keyborad/service/UserService.java b/src/main/java/com/yolo/keyborad/service/UserService.java
index 550b910..9f8b78c 100644
--- a/src/main/java/com/yolo/keyborad/service/UserService.java
+++ b/src/main/java/com/yolo/keyborad/service/UserService.java
@@ -12,10 +12,6 @@ import jakarta.servlet.http.HttpServletRequest;
*/
public interface UserService extends IService {
- KeyboardUser selectUserWithSubjectId(String sub);
-
- KeyboardUser createUserWithSubjectId(String sub);
-
KeyboardUserRespVO login(UserLoginDTO userLoginDTO, HttpServletRequest request);
Boolean updateUserInfo(KeyboardUserReq keyboardUser);
diff --git a/src/main/java/com/yolo/keyborad/service/impl/AppleServiceImpl.java b/src/main/java/com/yolo/keyborad/service/impl/AppleServiceImpl.java
index 9094c3e..c9926c0 100644
--- a/src/main/java/com/yolo/keyborad/service/impl/AppleServiceImpl.java
+++ b/src/main/java/com/yolo/keyborad/service/impl/AppleServiceImpl.java
@@ -1,6 +1,5 @@
package com.yolo.keyborad.service.impl;
-import cn.dev33.satoken.stp.SaTokenInfo;
import cn.dev33.satoken.stp.StpUtil;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.http.HttpUtil;
@@ -11,9 +10,7 @@ import com.yolo.keyborad.exception.BusinessException;
import com.yolo.keyborad.model.entity.KeyboardUser;
import com.yolo.keyborad.model.vo.user.KeyboardUserRespVO;
import com.yolo.keyborad.service.IAppleService;
-import com.yolo.keyborad.service.KeyboardUserLoginLogService;
-import com.yolo.keyborad.service.UserService;
-import com.yolo.keyborad.utils.RequestIpUtils;
+import com.yolo.keyborad.service.ThirdPartyLoginUserService;
import jakarta.servlet.http.HttpServletRequest;
import io.jsonwebtoken.*;
import jakarta.annotation.Resource;
@@ -39,10 +36,10 @@ import java.util.Objects;
public class AppleServiceImpl implements IAppleService {
@Resource
- private UserService userService;
+ private ThirdPartyLoginUserService thirdPartyLoginUserService;
@Resource
- private KeyboardUserLoginLogService loginLogService;
+ private UserLoginAuditService userLoginAuditService;
/**
* 登录
@@ -92,54 +89,16 @@ public class AppleServiceImpl implements IAppleService {
// 返回用户标识符
if (result) {
- KeyboardUser user = userService.selectUserWithSubjectId(sub);
+ KeyboardUser user = thirdPartyLoginUserService.selectAppleUser(sub);
boolean isNewUser = false;
if (user == null) {
- user = userService.createUserWithSubjectId(sub);
+ user = thirdPartyLoginUserService.createAppleUser(sub);
isNewUser = true;
}
- // 记录登录日志
- try {
- String ipAddress = RequestIpUtils.resolveClientIp(request);
- String userAgent = request.getHeader("User-Agent");
- String platform = "Unknown";
- String os = "Unknown";
-
- if (userAgent != null) {
- if (userAgent.contains("iOS")) {
- platform = "iOS";
- } else if (userAgent.contains("Android")) {
- platform = "Android";
- }
-
- if (userAgent.contains("Windows")) {
- os = "Windows";
- } else if (userAgent.contains("Mac OS")) {
- os = "Mac OS";
- } else if (userAgent.contains("Linux")) {
- os = "Linux";
- } else if (userAgent.contains("iOS")) {
- os = "iOS";
- } else if (userAgent.contains("Android")) {
- os = "Android";
- }
- }
-
- loginLogService.recordLoginLog(
- user.getId(),
- ipAddress,
- userAgent,
- os,
- platform,
- isNewUser ? "APPLE_NEW_USER" : "SUCCESS"
- );
- } catch (Exception e) {
- log.error("记录Apple登录日志失败", e);
- }
-
KeyboardUserRespVO keyboardUserRespVO = BeanUtil.copyProperties(user, KeyboardUserRespVO.class);
StpUtil.login(user.getId());
+ userLoginAuditService.recordLoginLog(user.getId(), request, isNewUser ? "APPLE_NEW_USER" : "SUCCESS");
keyboardUserRespVO.setToken(StpUtil.getTokenValueByLoginId(user.getId()));
return keyboardUserRespVO;
}
diff --git a/src/main/java/com/yolo/keyborad/service/impl/GoogleIdTokenVerifierService.java b/src/main/java/com/yolo/keyborad/service/impl/GoogleIdTokenVerifierService.java
new file mode 100644
index 0000000..72d1f8d
--- /dev/null
+++ b/src/main/java/com/yolo/keyborad/service/impl/GoogleIdTokenVerifierService.java
@@ -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);
+ }
+ }
+}
diff --git a/src/main/java/com/yolo/keyborad/service/impl/GoogleLoginServiceImpl.java b/src/main/java/com/yolo/keyborad/service/impl/GoogleLoginServiceImpl.java
new file mode 100644
index 0000000..a64f746
--- /dev/null
+++ b/src/main/java/com/yolo/keyborad/service/impl/GoogleLoginServiceImpl.java
@@ -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);
+ }
+ }
+}
diff --git a/src/main/java/com/yolo/keyborad/service/impl/ThirdPartyLoginUserServiceImpl.java b/src/main/java/com/yolo/keyborad/service/impl/ThirdPartyLoginUserServiceImpl.java
new file mode 100644
index 0000000..ab48af6
--- /dev/null
+++ b/src/main/java/com/yolo/keyborad/service/impl/ThirdPartyLoginUserServiceImpl.java
@@ -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()
+ .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 column, String value) {
+ return keyboardUserMapper.selectOne(new LambdaQueryWrapper()
+ .eq(column, value)
+ .eq(KeyboardUser::getDeleted, false)
+ .eq(KeyboardUser::getStatus, false)
+ .last("LIMIT 1"));
+ }
+
+ private void ensureSubjectNotRecentlyCancelled(SFunction column, String value) {
+ Date cooldownStart = Date.from(Instant.now().minus(ACCOUNT_REUSE_COOLDOWN_DAYS, ChronoUnit.DAYS));
+ KeyboardUser recentlyDeleted = keyboardUserMapper.selectOne(new LambdaQueryWrapper()
+ .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());
+ }
+}
diff --git a/src/main/java/com/yolo/keyborad/service/impl/UserLoginAuditService.java b/src/main/java/com/yolo/keyborad/service/impl/UserLoginAuditService.java
new file mode 100644
index 0000000..9ba2b4f
--- /dev/null
+++ b/src/main/java/com/yolo/keyborad/service/impl/UserLoginAuditService.java
@@ -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";
+ }
+}
diff --git a/src/main/java/com/yolo/keyborad/service/impl/UserServiceImpl.java b/src/main/java/com/yolo/keyborad/service/impl/UserServiceImpl.java
index 0d921db..ae1ecb2 100644
--- a/src/main/java/com/yolo/keyborad/service/impl/UserServiceImpl.java
+++ b/src/main/java/com/yolo/keyborad/service/impl/UserServiceImpl.java
@@ -2,46 +2,38 @@ package com.yolo.keyborad.service.impl;
import cn.dev33.satoken.stp.StpUtil;
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.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.yolo.keyborad.common.ErrorCode;
-import com.yolo.keyborad.config.AppConfig;
-import com.yolo.keyborad.config.NacosAppConfigCenter;
import com.yolo.keyborad.exception.BusinessException;
import com.yolo.keyborad.mapper.KeyboardUserMapper;
-import com.yolo.keyborad.model.dto.user.*;
+import com.yolo.keyborad.model.dto.user.BindInviteCodeDTO;
+import com.yolo.keyborad.model.dto.user.KeyboardUserReq;
+import com.yolo.keyborad.model.dto.user.ResetPassWordDTO;
+import com.yolo.keyborad.model.dto.user.SendMailDTO;
+import com.yolo.keyborad.model.dto.user.UserLoginDTO;
+import com.yolo.keyborad.model.dto.user.UserRegisterDTO;
+import com.yolo.keyborad.model.dto.user.VerifyCodeDTO;
import com.yolo.keyborad.model.entity.KeyboardUser;
-import com.yolo.keyborad.model.entity.KeyboardUserQuotaTotal;
-import com.yolo.keyborad.model.entity.KeyboardUserWallet;
import com.yolo.keyborad.model.vo.user.KeyboardUserRespVO;
-import com.yolo.keyborad.service.*;
-import jakarta.annotation.Resource;
-import jakarta.servlet.http.HttpServletRequest;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.security.crypto.password.PasswordEncoder;
-import org.springframework.stereotype.Service;
-import org.springframework.transaction.annotation.Transactional;
-
-import java.math.BigDecimal;
-import java.time.Instant;
-import java.time.temporal.ChronoUnit;
-import java.util.Date;
+import com.yolo.keyborad.service.UserService;
import com.yolo.keyborad.service.impl.user.UserInviteCodeBinder;
import com.yolo.keyborad.service.impl.user.UserMailVerificationHandler;
import com.yolo.keyborad.service.impl.user.UserPasswordHandler;
import com.yolo.keyborad.service.impl.user.UserRegistrationHandler;
-import com.yolo.keyborad.utils.RequestIpUtils;
+import jakarta.annotation.Resource;
+import jakarta.servlet.http.HttpServletRequest;
+import java.util.Date;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
/*
* @author: ziin
* @date: 2025/12/2 18:19
*/
@Service
-@Slf4j
public class UserServiceImpl extends ServiceImpl implements UserService {
@Resource
@@ -50,21 +42,6 @@ public class UserServiceImpl extends ServiceImpl()
- .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()
- .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;
+ public UserServiceImpl() {
}
@Override
@@ -139,9 +74,7 @@ public class UserServiceImpl extends ServiceImpl
+
+
+
+
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
@@ -36,4 +41,4 @@
where uid = #{uid}
-
\ No newline at end of file
+
diff --git a/src/test/java/com/yolo/keyborad/controller/KeyboardAppVersionsControllerTest.java b/src/test/java/com/yolo/keyborad/controller/KeyboardAppVersionsControllerTest.java
new file mode 100644
index 0000000..2d3379b
--- /dev/null
+++ b/src/test/java/com/yolo/keyborad/controller/KeyboardAppVersionsControllerTest.java
@@ -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()));
+ }
+}
diff --git a/src/test/java/com/yolo/keyborad/service/UserCancellationRegistrationTest.java b/src/test/java/com/yolo/keyborad/service/UserCancellationRegistrationTest.java
new file mode 100644
index 0000000..5b1d04c
--- /dev/null
+++ b/src/test/java/com/yolo/keyborad/service/UserCancellationRegistrationTest.java
@@ -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> 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> 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> 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"));
+ }
+}