实现google登录和用户账号绑定

This commit is contained in:
2026-04-20 13:37:29 +08:00
parent e657a22b10
commit 4ca6d36e80
26 changed files with 1088 additions and 279 deletions

10
.gitignore vendored
View File

@@ -38,6 +38,16 @@ build/
/CLAUDE.md
/AGENTS.md
/src/test/
!/src/test/
/src/test/**
!/src/test/java/
!/src/test/java/com/
!/src/test/java/com/yolo/
!/src/test/java/com/yolo/keyborad/
!/src/test/java/com/yolo/keyborad/controller/
!/src/test/java/com/yolo/keyborad/controller/KeyboardAppVersionsControllerTest.java
!/src/test/java/com/yolo/keyborad/service/
!/src/test/java/com/yolo/keyborad/service/UserCancellationRegistrationTest.java
/.claude/agents/backend-architect.md
/.dockerignore
/Dockerfile

12
pom.xml
View File

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

View File

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

View File

@@ -19,6 +19,8 @@ public enum ErrorCode {
SYSTEM_ERROR(50000, "系统内部异常"),
OPERATION_ERROR(50001, "操作失败"),
APPLE_LOGIN_ERROR(40003, "Apple登录失败"),
GOOGLE_LOGIN_ERROR(40023, "Google登录失败"),
GOOGLE_LOGIN_CSRF_INVALID(40024, "Google登录CSRF校验失败"),
FILE_IS_EMPTY(40001, "上传文件为空"),
FILE_NAME_ERROR(40002, "文件名错误"),
FILE_TYPE_ERROR(40004, "文件类型不支持,仅支持图片格式"),
@@ -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, "未找到可用的版本配置");
/**

View File

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

View File

@@ -90,7 +90,8 @@ public class SaTokenConfigure implements WebMvcConfigurer {
"/appVersions/checkUpdate",
"/appVersions/checkUpdate",
"/character/detailWithNotLogin",
"/apple/validate-receipt"
"/apple/validate-receipt",
"/user/googleLogin"
};
}
@Bean

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,10 +12,6 @@ import jakarta.servlet.http.HttpServletRequest;
*/
public interface UserService extends IService<KeyboardUser> {
KeyboardUser selectUserWithSubjectId(String sub);
KeyboardUser createUserWithSubjectId(String sub);
KeyboardUserRespVO login(UserLoginDTO userLoginDTO, HttpServletRequest request);
Boolean updateUserInfo(KeyboardUserReq keyboardUser);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,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<KeyboardUserMapper, KeyboardUser> implements UserService {
@Resource
@@ -50,21 +42,6 @@ public class UserServiceImpl extends ServiceImpl<KeyboardUserMapper, KeyboardUse
@Resource
private PasswordEncoder passwordEncoder;
@Resource
private KeyboardCharacterService keyboardCharacterService;
@Resource
private KeyboardUserWalletService walletService;
@Resource
private KeyboardUserLoginLogService loginLogService;
@Resource
private KeyboardUserQuotaTotalService quotaTotalService;
@Resource
private KeyboardUserInviteCodesService inviteCodesService;
@Resource
private UserRegistrationHandler registrationHandler;
@@ -77,52 +54,10 @@ public class UserServiceImpl extends ServiceImpl<KeyboardUserMapper, KeyboardUse
@Resource
private UserInviteCodeBinder inviteCodeBinder;
private final NacosAppConfigCenter.DynamicAppConfig cfgHolder;
@Resource
private UserLoginAuditService userLoginAuditService;
public UserServiceImpl(NacosAppConfigCenter.DynamicAppConfig cfgHolder) {
this.cfgHolder = cfgHolder;
}
@Override
public KeyboardUser selectUserWithSubjectId(String sub) {
return keyboardUserMapper.selectOne(
new LambdaQueryWrapper<KeyboardUser>()
.eq(KeyboardUser::getSubjectId, sub)
.eq(KeyboardUser::getDeleted, false)
.eq(KeyboardUser::getStatus, false));
}
private static final int ACCOUNT_REUSE_COOLDOWN_DAYS = 7;
private void ensureSubjectIdNotRecentlyCancelled(String sub) {
Date cooldownStart = Date.from(Instant.now().minus(ACCOUNT_REUSE_COOLDOWN_DAYS, ChronoUnit.DAYS));
KeyboardUser recentlyDeleted = keyboardUserMapper.selectOne(
new LambdaQueryWrapper<KeyboardUser>()
.eq(KeyboardUser::getSubjectId, sub)
.eq(KeyboardUser::getDeleted, true)
.gt(KeyboardUser::getDeletedAt, cooldownStart)
.last("LIMIT 1")
);
if (recentlyDeleted != null) {
throw new BusinessException(ErrorCode.ACCOUNT_RECENTLY_CANCELLED);
}
}
@Override
public KeyboardUser createUserWithSubjectId(String sub) {
ensureSubjectIdNotRecentlyCancelled(sub);
KeyboardUser keyboardUser = buildNewUserWithSubjectId(sub);
keyboardUserMapper.insert(keyboardUser);
keyboardCharacterService.addDefaultUserCharacter(keyboardUser.getId());
AppConfig appConfig = cfgHolder.getRef().get();
initNewUserWalletAndQuota(keyboardUser.getId(), appConfig.getUserRegisterProperties().getFreeTrialQuota());
inviteCodesService.createInviteCode(keyboardUser.getId());
log.info("User registered with Apple Sign-In, userId={}, freeQuota={}",
keyboardUser.getId(), appConfig.getUserRegisterProperties().getFreeTrialQuota());
return keyboardUser;
public UserServiceImpl() {
}
@Override
@@ -139,9 +74,7 @@ public class UserServiceImpl extends ServiceImpl<KeyboardUserMapper, KeyboardUse
throw new BusinessException(ErrorCode.PASSWORD_OR_MAIL_ERROR);
}
StpUtil.login(keyboardUser.getId());
// 记录登录日志
recordLoginLogSafely(keyboardUser.getId(), request);
userLoginAuditService.recordLoginLog(keyboardUser.getId(), request, "SUCCESS");
KeyboardUserRespVO keyboardUserRespVO = BeanUtil.copyProperties(keyboardUser, KeyboardUserRespVO.class);
keyboardUserRespVO.setToken(StpUtil.getTokenValue());
@@ -226,90 +159,4 @@ public class UserServiceImpl extends ServiceImpl<KeyboardUserMapper, KeyboardUse
.eq(KeyboardUser::getUuid, uuid));
return keyboardUserDB.getId();
}
private KeyboardUser buildNewUserWithSubjectId(String sub) {
KeyboardUser keyboardUser = new KeyboardUser();
keyboardUser.setSubjectId(sub);
keyboardUser.setUid(IdUtil.getSnowflake().nextId());
keyboardUser.setNickName("User_" + RandomUtil.randomString(6));
keyboardUser.setUuid(IdUtil.randomUUID());
return keyboardUser;
}
private void initNewUserWalletAndQuota(long userId, Integer freeTrialQuota) {
Date now = new Date();
KeyboardUserWallet wallet = new KeyboardUserWallet();
wallet.setUserId(userId);
wallet.setBalance(BigDecimal.valueOf(freeTrialQuota.longValue()));
wallet.setVersion(0);
wallet.setStatus((short) 1);
wallet.setCreatedAt(now);
wallet.setUpdatedAt(now);
walletService.save(wallet);
KeyboardUserQuotaTotal quotaTotal = new KeyboardUserQuotaTotal();
quotaTotal.setUserId(userId);
quotaTotal.setTotalQuota(freeTrialQuota);
quotaTotal.setUsedQuota(0);
quotaTotal.setVersion(0);
quotaTotal.setCreatedAt(now);
quotaTotal.setUpdatedAt(now);
quotaTotalService.save(quotaTotal);
}
private void recordLoginLogSafely(Long userId, HttpServletRequest request) {
try {
String ipAddress = RequestIpUtils.resolveClientIp(request);
String userAgent = request.getHeader("User-Agent");
String platform = resolvePlatform(userAgent);
String os = resolveOs(userAgent);
loginLogService.recordLoginLog(
userId,
ipAddress,
userAgent,
os,
platform,
"SUCCESS"
);
} catch (Exception e) {
log.error("记录登录日志失败", e);
}
}
private String resolvePlatform(String userAgent) {
if (userAgent == null) {
return "Unknown";
}
if (userAgent.contains("iOS")) {
return "iOS";
}
if (userAgent.contains("Android")) {
return "Android";
}
return "Unknown";
}
private String resolveOs(String userAgent) {
if (userAgent == null) {
return "Unknown";
}
if (userAgent.contains("Windows")) {
return "Windows";
}
if (userAgent.contains("Mac OS")) {
return "Mac OS";
}
if (userAgent.contains("Linux")) {
return "Linux";
}
if (userAgent.contains("iOS")) {
return "iOS";
}
if (userAgent.contains("Android")) {
return "Android";
}
return "Unknown";
}
}

View File

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

View File

@@ -10,14 +10,7 @@ import com.yolo.keyborad.exception.BusinessException;
import com.yolo.keyborad.mapper.KeyboardUserMapper;
import com.yolo.keyborad.model.dto.user.UserRegisterDTO;
import com.yolo.keyborad.model.entity.KeyboardUser;
import com.yolo.keyborad.model.entity.KeyboardUserQuotaTotal;
import com.yolo.keyborad.model.entity.KeyboardUserWallet;
import com.yolo.keyborad.service.KeyboardCharacterService;
import com.yolo.keyborad.service.KeyboardUserInviteCodesService;
import com.yolo.keyborad.service.KeyboardUserQuotaTotalService;
import com.yolo.keyborad.service.KeyboardUserWalletService;
import com.yolo.keyborad.utils.RedisUtil;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Date;
@@ -40,10 +33,7 @@ public class UserRegistrationHandler {
private final KeyboardUserMapper keyboardUserMapper;
private final PasswordEncoder passwordEncoder;
private final RedisUtil redisUtil;
private final KeyboardCharacterService keyboardCharacterService;
private final KeyboardUserWalletService walletService;
private final KeyboardUserQuotaTotalService quotaTotalService;
private final KeyboardUserInviteCodesService inviteCodesService;
private final NewUserAssetsInitializer newUserAssetsInitializer;
private final UserInviteCodeBinder inviteCodeBinder;
private final NacosAppConfigCenter.DynamicAppConfig cfgHolder;
@@ -51,20 +41,14 @@ public class UserRegistrationHandler {
KeyboardUserMapper keyboardUserMapper,
PasswordEncoder passwordEncoder,
RedisUtil redisUtil,
KeyboardCharacterService keyboardCharacterService,
KeyboardUserWalletService walletService,
KeyboardUserQuotaTotalService quotaTotalService,
KeyboardUserInviteCodesService inviteCodesService,
NewUserAssetsInitializer newUserAssetsInitializer,
UserInviteCodeBinder inviteCodeBinder,
NacosAppConfigCenter.DynamicAppConfig cfgHolder
) {
this.keyboardUserMapper = keyboardUserMapper;
this.passwordEncoder = passwordEncoder;
this.redisUtil = redisUtil;
this.keyboardCharacterService = keyboardCharacterService;
this.walletService = walletService;
this.quotaTotalService = quotaTotalService;
this.inviteCodesService = inviteCodesService;
this.newUserAssetsInitializer = newUserAssetsInitializer;
this.inviteCodeBinder = inviteCodeBinder;
this.cfgHolder = cfgHolder;
}
@@ -144,38 +128,7 @@ public class UserRegistrationHandler {
}
private void initNewUserAssets(KeyboardUser keyboardUser) {
keyboardCharacterService.addDefaultUserCharacter(keyboardUser.getId());
AppConfig appConfig = cfgHolder.getRef().get();
initWallet(keyboardUser.getId(), appConfig.getUserRegisterProperties().getRewardBalance());
initQuota(
keyboardUser.getId(),
appConfig.getUserRegisterProperties().getFreeTrialQuota()
);
inviteCodesService.createInviteCode(keyboardUser.getId());
}
private void initWallet(long userId, BigDecimal rewardBalance) {
KeyboardUserWallet wallet = new KeyboardUserWallet();
wallet.setUserId(userId);
wallet.setBalance(rewardBalance);
wallet.setVersion(0);
wallet.setStatus((short) 1);
wallet.setCreatedAt(new Date());
wallet.setUpdatedAt(new Date());
walletService.save(wallet);
}
private void initQuota(long userId, Integer freeTrialQuota) {
KeyboardUserQuotaTotal quotaTotal = new KeyboardUserQuotaTotal();
quotaTotal.setUserId(userId);
quotaTotal.setTotalQuota(freeTrialQuota);
quotaTotal.setUsedQuota(0);
quotaTotal.setVersion(0);
quotaTotal.setCreatedAt(new Date());
quotaTotal.setUpdatedAt(new Date());
quotaTotalService.save(quotaTotal);
newUserAssetsInitializer.initialize(keyboardUser.getId());
}
private void tryBindInviteCode(String inviteCode, long userId) {

View File

@@ -64,8 +64,13 @@ google:
require-obfuscated-account-id: false
pubsub:
expected-topic: "projects/keyboard-490601/topics/keyboard_topic"
expected-subscription: "projects/keyboard-490601/subscriptions/keyboard_topic-sub"
expected-subscription: "projects/keyboard-490601/subscriptions/rtdn_dev"
service-account-email: "id-220@keyboard-490601.iam.gserviceaccount.com"
login:
enabled: true
csrf-check-enabled: false
client-ids: [1003033603130-ip8g4bpkvgo4iktb5lr27r7r0lsq0f6s.apps.googleusercontent.com,1003033603130-trkhtbbvnmcuvjg04g7o1uk3vucbfo76.apps.googleusercontent.com]
hosted-domain: ""
dromara:
x-file-storage: #文件存储配置

View File

@@ -64,6 +64,11 @@ google:
expected-topic: "projects/keyboard-490601/topics/keyboard_topic"
expected-subscription: "projects/keyboard-490601/subscriptions/keyboard_topic-sub"
service-account-email: "id-220@keyboard-490601.iam.gserviceaccount.com"
login:
enabled: true
csrf-check-enabled: false
client-ids: [1003033603130-ip8g4bpkvgo4iktb5lr27r7r0lsq0f6s.apps.googleusercontent.com,1003033603130-trkhtbbvnmcuvjg04g7o1uk3vucbfo76.apps.googleusercontent.com]
hosted-domain: ""
nacos:
config:

View File

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

View File

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

View File

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