From 4ca6d36e809b7ecbfb071a18ad34dcfa82a9388a Mon Sep 17 00:00:00 2001 From: ziin Date: Mon, 20 Apr 2026 13:37:29 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0google=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E5=92=8C=E7=94=A8=E6=88=B7=E8=B4=A6=E5=8F=B7=E7=BB=91=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 10 + pom.xml | 14 +- .../java/com/yolo/keyborad/MyApplication.java | 7 +- .../com/yolo/keyborad/common/ErrorCode.java | 4 + .../config/GoogleLoginProperties.java | 31 +++ .../keyborad/config/SaTokenConfigure.java | 3 +- .../keyborad/controller/UserController.java | 12 + .../keyborad/model/dto/GoogleLoginReq.java | 38 +++ .../dto/googlelogin/GoogleIdTokenPayload.java | 20 ++ .../keyborad/model/entity/KeyboardUser.java | 7 + .../keyborad/service/GoogleLoginService.java | 10 + .../service/ThirdPartyLoginUserService.java | 17 ++ .../yolo/keyborad/service/UserService.java | 4 - .../service/impl/AppleServiceImpl.java | 53 +--- .../impl/GoogleIdTokenVerifierService.java | 87 ++++++ .../service/impl/GoogleLoginServiceImpl.java | 100 +++++++ .../impl/ThirdPartyLoginUserServiceImpl.java | 119 ++++++++ .../service/impl/UserLoginAuditService.java | 70 +++++ .../service/impl/UserServiceImpl.java | 189 ++----------- .../impl/user/NewUserAssetsInitializer.java | 59 ++++ .../impl/user/UserRegistrationHandler.java | 55 +--- src/main/resources/application-dev.yml | 7 +- src/main/resources/application-prod.yml | 5 + .../resources/mapper/KeyboardUserMapper.xml | 9 +- .../KeyboardAppVersionsControllerTest.java | 177 ++++++++++++ .../UserCancellationRegistrationTest.java | 260 ++++++++++++++++++ 26 files changed, 1088 insertions(+), 279 deletions(-) create mode 100644 src/main/java/com/yolo/keyborad/config/GoogleLoginProperties.java create mode 100644 src/main/java/com/yolo/keyborad/model/dto/GoogleLoginReq.java create mode 100644 src/main/java/com/yolo/keyborad/model/dto/googlelogin/GoogleIdTokenPayload.java create mode 100644 src/main/java/com/yolo/keyborad/service/GoogleLoginService.java create mode 100644 src/main/java/com/yolo/keyborad/service/ThirdPartyLoginUserService.java create mode 100644 src/main/java/com/yolo/keyborad/service/impl/GoogleIdTokenVerifierService.java create mode 100644 src/main/java/com/yolo/keyborad/service/impl/GoogleLoginServiceImpl.java create mode 100644 src/main/java/com/yolo/keyborad/service/impl/ThirdPartyLoginUserServiceImpl.java create mode 100644 src/main/java/com/yolo/keyborad/service/impl/UserLoginAuditService.java create mode 100644 src/main/java/com/yolo/keyborad/service/impl/user/NewUserAssetsInitializer.java create mode 100644 src/test/java/com/yolo/keyborad/controller/KeyboardAppVersionsControllerTest.java create mode 100644 src/test/java/com/yolo/keyborad/service/UserCancellationRegistrationTest.java 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")); + } +}