feat(user): 新增注销账户接口

This commit is contained in:
2026-02-27 14:13:47 +08:00
parent 1b68227ebc
commit 74b8b72545
9 changed files with 543 additions and 258 deletions

3
.gitignore vendored
View File

@@ -19,6 +19,9 @@ target/
*.iml
*.ipr
### macOS ###
.DS_Store
### NetBeans ###
/nbproject/private/

View File

@@ -73,6 +73,17 @@ public class UserController {
return ResultUtils.success(true);
}
@PostMapping("/cancelAccount")
@Operation(summary = "注销账户", description = "用户注销账户(软删除),同步写入 deleted_at并强制退出登录")
public BaseResponse<Boolean> cancelAccount() {
final long userId = StpUtil.getLoginIdAsLong();
final Boolean success = userService.cancelAccount(userId);
final String tokenValue = StpUtil.getTokenValue();
StpUtil.logout(userId);
StpUtil.logoutByTokenValue(tokenValue);
return ResultUtils.success(success);
}
@PostMapping("/login")
@Operation(summary = "登录", description = "登录接口")
public BaseResponse<KeyboardUserRespVO> login(@RequestBody UserLoginDTO userLoginDTO, HttpServletRequest request) {

View File

@@ -73,6 +73,13 @@ public class KeyboardUser {
@Schema(description = "是否删除(默认否)")
private Boolean deleted;
/**
* 删除时间
*/
@TableField(value = "deleted_at")
@Schema(description = "删除时间")
private Date deletedAt;
/**
* 邮箱地址
*/
@@ -125,4 +132,4 @@ public class KeyboardUser {
@TableField(value = "vip_level")
@Schema(description = "vip等级")
private Integer vipLevel;
}
}

View File

@@ -30,4 +30,12 @@ public interface UserService extends IService<KeyboardUser> {
Boolean bindInviteCode(BindInviteCodeDTO bindInviteCodeDTO);
/**
* 注销账户(软删除)
*
* @param userId 当前登录用户ID
* @return 是否成功
*/
Boolean cancelAccount(long userId);
}

View File

@@ -5,6 +5,7 @@ import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.RandomUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.yolo.keyborad.common.ErrorCode;
import com.yolo.keyborad.config.AppConfig;
@@ -12,13 +13,13 @@ 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.entity.*;
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.servlet.http.HttpServletRequest;
import com.yolo.keyborad.utils.RedisUtil;
import com.yolo.keyborad.utils.SendMailUtils;
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;
@@ -26,7 +27,10 @@ import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.Date;
import java.util.concurrent.TimeUnit;
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;
/*
* @author: ziin
@@ -42,12 +46,6 @@ public class UserServiceImpl extends ServiceImpl<KeyboardUserMapper, KeyboardUse
@Resource
private PasswordEncoder passwordEncoder;
@Resource
private SendMailUtils sendMailUtils;
@Resource
private RedisUtil redisUtil;
@Resource
private KeyboardCharacterService keyboardCharacterService;
@@ -64,10 +62,16 @@ public class UserServiceImpl extends ServiceImpl<KeyboardUserMapper, KeyboardUse
private KeyboardUserInviteCodesService inviteCodesService;
@Resource
private KeyboardUserInvitesService userInvitesService;
private UserRegistrationHandler registrationHandler;
@Resource
private HttpServletRequest request;
private UserMailVerificationHandler mailVerificationHandler;
@Resource
private UserPasswordHandler passwordHandler;
@Resource
private UserInviteCodeBinder inviteCodeBinder;
private final NacosAppConfigCenter.DynamicAppConfig cfgHolder;
@@ -85,39 +89,16 @@ public class UserServiceImpl extends ServiceImpl<KeyboardUserMapper, KeyboardUse
@Override
public KeyboardUser createUserWithSubjectId(String sub) {
KeyboardUser keyboardUser = new KeyboardUser();
keyboardUser.setSubjectId(sub);
keyboardUser.setUid(IdUtil.getSnowflake().nextId());
keyboardUser.setNickName("User_" + RandomUtil.randomString(6));
KeyboardUser keyboardUser = buildNewUserWithSubjectId(sub);
keyboardUserMapper.insert(keyboardUser);
keyboardCharacterService.addDefaultUserCharacter(keyboardUser.getId());
AppConfig appConfig = cfgHolder.getRef().get();
// 初始化用户钱包余额为0
KeyboardUserWallet wallet = new KeyboardUserWallet();
wallet.setUserId(keyboardUser.getId());
wallet.setBalance(BigDecimal.valueOf(appConfig.getUserRegisterProperties().getFreeTrialQuota()));
wallet.setVersion(0);
wallet.setStatus((short) 1);
wallet.setCreatedAt(new Date());
wallet.setUpdatedAt(new Date());
walletService.save(wallet);
// 初始化用户免费使用次数配额
KeyboardUserQuotaTotal quotaTotal = new KeyboardUserQuotaTotal();
quotaTotal.setUserId(keyboardUser.getId());
quotaTotal.setTotalQuota(appConfig.getUserRegisterProperties().getFreeTrialQuota());
quotaTotal.setUsedQuota(0);
quotaTotal.setVersion(0);
quotaTotal.setCreatedAt(new Date());
quotaTotal.setUpdatedAt(new Date());
quotaTotalService.save(quotaTotal);
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;
}
@@ -136,43 +117,7 @@ public class UserServiceImpl extends ServiceImpl<KeyboardUserMapper, KeyboardUse
StpUtil.login(keyboardUser.getId());
// 记录登录日志
try {
String ipAddress = request.getRemoteAddr();
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(
keyboardUser.getId(),
ipAddress,
userAgent,
os,
platform,
"SUCCESS"
);
} catch (Exception e) {
log.error("记录登录日志失败", e);
}
recordLoginLogSafely(keyboardUser.getId(), request);
KeyboardUserRespVO keyboardUserRespVO = BeanUtil.copyProperties(keyboardUser, KeyboardUserRespVO.class);
keyboardUserRespVO.setToken(StpUtil.getTokenValue());
@@ -202,212 +147,135 @@ public class UserServiceImpl extends ServiceImpl<KeyboardUserMapper, KeyboardUse
@Override
@Transactional(rollbackFor = Exception.class)
public Boolean userRegister(UserRegisterDTO userRegisterDTO) {
KeyboardUser userMail = keyboardUserMapper.selectOne(new LambdaQueryWrapper<KeyboardUser>()
.eq(KeyboardUser::getEmail, userRegisterDTO.getMailAddress()));
if (userMail != null) {
throw new BusinessException(ErrorCode.USER_HAS_EXISTED);
}
if (userRegisterDTO.getPassword() == null) {
throw new BusinessException(ErrorCode.PASSWORD_CAN_NOT_NULL);
}
if (!userRegisterDTO.getPassword().equals(userRegisterDTO.getPasswordConfirm())) {
throw new BusinessException(ErrorCode.CONFIRM_PASSWORD_NOT_MATCH);
}
KeyboardUser keyboardUser = new KeyboardUser();
keyboardUser.setUid(IdUtil.getSnowflake().nextId());
keyboardUser.setNickName("User_" + RandomUtil.randomString(6));
keyboardUser.setPassword(passwordEncoder.encode(userRegisterDTO.getPassword()));
keyboardUser.setEmail(userRegisterDTO.getMailAddress());
keyboardUser.setGender(userRegisterDTO.getGender());
log.info(keyboardUser.toString());
String s = redisUtil.get("user:" + userRegisterDTO.getMailAddress());
if (!userRegisterDTO.getVerifyCode().equals(s)) {
throw new BusinessException(ErrorCode.VERIFY_CODE_ERROR);
}
AppConfig appConfig = cfgHolder.getRef().get();
keyboardUser.setEmailVerified(true);
redisUtil.delete("user:" + userRegisterDTO.getMailAddress());
int insertCount = keyboardUserMapper.insert(keyboardUser);
if (insertCount > 0) {
keyboardCharacterService.addDefaultUserCharacter(keyboardUser.getId());
// 初始化用户钱包余额为0
KeyboardUserWallet wallet = new KeyboardUserWallet();
wallet.setUserId(keyboardUser.getId());
wallet.setBalance(appConfig.getUserRegisterProperties().getRewardBalance());
wallet.setVersion(0);
wallet.setStatus((short) 1);
wallet.setCreatedAt(new Date());
wallet.setUpdatedAt(new Date());
walletService.save(wallet);
// 初始化用户免费使用次数配额
com.yolo.keyborad.model.entity.KeyboardUserQuotaTotal quotaTotal =
new com.yolo.keyborad.model.entity.KeyboardUserQuotaTotal();
quotaTotal.setUserId(keyboardUser.getId());
quotaTotal.setTotalQuota(appConfig.getUserRegisterProperties().getFreeTrialQuota());
quotaTotal.setUsedQuota(0);
quotaTotal.setVersion(0);
quotaTotal.setCreatedAt(new Date());
quotaTotal.setUpdatedAt(new Date());
quotaTotalService.save(quotaTotal);
inviteCodesService.createInviteCode(keyboardUser.getId());
// 处理邀请码绑定
if (userRegisterDTO.getInviteCode() != null && !userRegisterDTO.getInviteCode().trim().isEmpty()) {
try {
// 验证邀请码
KeyboardUserInviteCodes inviteCode = inviteCodesService.validateInviteCode(userRegisterDTO.getInviteCode().trim());
// 创建邀请关系绑定记录
KeyboardUserInvites userInvite = new KeyboardUserInvites();
userInvite.setInviterUserId(inviteCode.getOwnerUserId());
userInvite.setInviteeUserId(keyboardUser.getId());
userInvite.setInviteCodeId(inviteCode.getId());
userInvite.setBindType((short) 1); // 1=手动填写邀请码
userInvite.setBoundAt(new Date());
userInvite.setBindIp(request.getRemoteAddr());
userInvite.setBindUserAgent(request.getHeader("User-Agent"));
// 记录邀请码类型快照(用户/租户)
userInvite.setInviteType(inviteCode.getInviteType());
userInvite.setInviteCode(inviteCode.getCode());
// 如果是租户邀请码记录租户ID
if ("AGENT".equals(inviteCode.getInviteType()) && inviteCode.getOwnerTenantId() != null) {
userInvite.setProfitTenantId(inviteCode.getOwnerTenantId());
userInvite.setInviterTenantId(inviteCode.getOwnerTenantId());
userInvite.setProfitEmployeeId(inviteCode.getOwnerSystemUserId());
}
userInvitesService.save(userInvite);
// 更新邀请码使用次数
inviteCode.setUsedCount(inviteCode.getUsedCount() + 1);
inviteCodesService.updateById(inviteCode);
log.info("User bound to invite code, userId={}, inviteCodeId={}, inviterUserId={}, inviteType={}",
keyboardUser.getId(), inviteCode.getId(), inviteCode.getOwnerUserId(), inviteCode.getInviteType());
} catch (BusinessException e) {
// 邀请码验证失败,记录日志但不影响注册流程
log.warn("Failed to bind invite code for user {}: {}", keyboardUser.getId(), e.getMessage());
}
}
log.info("User registered with email, userId={}, email={}, freeQuota={}",
keyboardUser.getId(), keyboardUser.getEmail(), appConfig.getUserRegisterProperties().getFreeTrialQuota());
}
return insertCount > 0;
return registrationHandler.userRegister(userRegisterDTO);
}
@Override
public void sendVerifyMail(SendMailDTO mailDTO) {
if (redisUtil.hasKey("limit_mail:" + mailDTO.getMailAddress())){
throw new BusinessException(ErrorCode.MAIL_SEND_BUSY);
}
String existCode = redisUtil.get("user:" + mailDTO.getMailAddress());
if (redisUtil.get("user:" + mailDTO.getMailAddress()) != null) {
sendMailUtils.sendEmail(null,mailDTO.getMailAddress(),Integer.valueOf(existCode));
return;
}
int code = RandomUtil.randomInt(100000, 999999);
redisUtil.setEx("user:" + mailDTO.getMailAddress(), String.valueOf(code),600, TimeUnit.SECONDS);
sendMailUtils.sendEmail(null,mailDTO.getMailAddress(),code);
redisUtil.setEx("limit_mail:" + mailDTO.getMailAddress(), String.valueOf(code),60, TimeUnit.SECONDS);
mailVerificationHandler.sendVerifyMail(mailDTO);
}
@Override
public Boolean verifyMailCode(VerifyCodeDTO verifyCodeDTO) {
String s = redisUtil.get("user:" + verifyCodeDTO.getMailAddress());
if (s == null) {
throw new BusinessException(ErrorCode.VERIFY_CODE_ERROR);
}
if (s.equals(String.valueOf(verifyCodeDTO.getVerifyCode()))){
redisUtil.delete("user:" + verifyCodeDTO.getMailAddress());
return true;
}
return false;
return mailVerificationHandler.verifyMailCode(verifyCodeDTO);
}
@Override
public Boolean resetPassWord(ResetPassWordDTO resetPassWordDTO) {
KeyboardUser keyboardUser = keyboardUserMapper.selectOne(
new LambdaQueryWrapper<KeyboardUser>()
.eq(KeyboardUser::getEmail, resetPassWordDTO.getMailAddress())
.eq(KeyboardUser::getStatus, false));
if (keyboardUser == null) {
throw new BusinessException(ErrorCode.USER_NOT_FOUND);
}
if (!resetPassWordDTO.getPassword().equals(resetPassWordDTO.getConfirmPassword())) {
throw new BusinessException(ErrorCode.CONFIRM_PASSWORD_NOT_MATCH);
}
keyboardUser.setPassword(passwordEncoder.encode(resetPassWordDTO.getPassword()));
return keyboardUserMapper.updateById(keyboardUser) > 0;
return passwordHandler.resetPassWord(resetPassWordDTO);
}
@Override
@Transactional(rollbackFor = Exception.class)
public Boolean bindInviteCode(BindInviteCodeDTO bindInviteCodeDTO) {
// 获取当前登录用户ID
long userId = StpUtil.getLoginIdAsLong();
return inviteCodeBinder.bind(userId, bindInviteCodeDTO.getInviteCode());
}
// 检查用户是否已经绑定过邀请码
long existingBindCount = userInvitesService.count(
new LambdaQueryWrapper<KeyboardUserInvites>()
.eq(KeyboardUserInvites::getInviteeUserId, userId)
);
if (existingBindCount > 0) {
throw new BusinessException(ErrorCode.INVITE_CODE_ALREADY_BOUND);
@Override
public Boolean cancelAccount(long userId) {
if (userId <= 0) {
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}
// 验证邀请码
String inviteCodeStr = bindInviteCodeDTO.getInviteCode().trim();
KeyboardUserInviteCodes inviteCode = inviteCodesService.validateInviteCode(inviteCodeStr);
Date now = new Date();
LambdaUpdateWrapper<KeyboardUser> updateWrapper = new LambdaUpdateWrapper<KeyboardUser>()
.eq(KeyboardUser::getId, userId)
.eq(KeyboardUser::getDeleted, false)
.set(KeyboardUser::getDeleted, true)
.set(KeyboardUser::getDeletedAt, now)
.set(KeyboardUser::getUpdatedAt, now)
.set(KeyboardUser::getStatus, true);
// 检查是否是绑定自己的邀请码
if (inviteCode.getOwnerUserId().equals(userId)) {
throw new BusinessException(ErrorCode.INVITE_CODE_CANNOT_BIND_SELF);
int affectedRows = keyboardUserMapper.update(null, updateWrapper);
if (affectedRows <= 0) {
throw new BusinessException(ErrorCode.USER_NOT_FOUND);
}
// 创建邀请关系绑定记录
KeyboardUserInvites userInvite = new KeyboardUserInvites();
userInvite.setInviterUserId(inviteCode.getOwnerUserId());
userInvite.setInviteeUserId(userId);
userInvite.setInviteCodeId(inviteCode.getId());
userInvite.setBindType((short) 1); // 1=手动填写邀请码
userInvite.setBoundAt(new Date());
userInvite.setBindIp(request.getRemoteAddr());
userInvite.setBindUserAgent(request.getHeader("User-Agent"));
// 记录邀请码类型快照(用户/租户)
userInvite.setInviteType(inviteCode.getInviteType());
userInvite.setInviteCode(inviteCode.getCode());
// 如果是租户邀请码记录租户ID
if ("AGENT".equals(inviteCode.getInviteType()) && inviteCode.getOwnerTenantId() != null) {
userInvite.setProfitTenantId(inviteCode.getOwnerTenantId());
userInvite.setInviterTenantId(inviteCode.getOwnerTenantId());
userInvite.setProfitEmployeeId(inviteCode.getOwnerSystemUserId());
}
userInvitesService.save(userInvite);
// 更新邀请码使用次数
inviteCode.setUsedCount(inviteCode.getUsedCount() + 1);
inviteCodesService.updateById(inviteCode);
log.info("User bound invite code, userId={}, inviteCodeId={}, inviterUserId={}, inviteType={}",
userId, inviteCode.getId(), inviteCode.getOwnerUserId(), inviteCode.getInviteType());
return true;
}
private KeyboardUser buildNewUserWithSubjectId(String sub) {
KeyboardUser keyboardUser = new KeyboardUser();
keyboardUser.setSubjectId(sub);
keyboardUser.setUid(IdUtil.getSnowflake().nextId());
keyboardUser.setNickName("User_" + RandomUtil.randomString(6));
return keyboardUser;
}
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 = request.getRemoteAddr();
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,98 @@
package com.yolo.keyborad.service.impl.user;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.yolo.keyborad.common.ErrorCode;
import com.yolo.keyborad.exception.BusinessException;
import com.yolo.keyborad.model.entity.KeyboardUserInviteCodes;
import com.yolo.keyborad.model.entity.KeyboardUserInvites;
import com.yolo.keyborad.service.KeyboardUserInviteCodesService;
import com.yolo.keyborad.service.KeyboardUserInvitesService;
import jakarta.servlet.http.HttpServletRequest;
import java.util.Date;
import org.springframework.stereotype.Component;
/*
* @author: ziin
* @date: 2026/2/27
*/
@Component
public class UserInviteCodeBinder {
private static final short MANUAL_BIND_TYPE = 1;
private static final String INVITE_TYPE_AGENT = "AGENT";
private final KeyboardUserInvitesService userInvitesService;
private final KeyboardUserInviteCodesService inviteCodesService;
private final HttpServletRequest request;
public UserInviteCodeBinder(
KeyboardUserInvitesService userInvitesService,
KeyboardUserInviteCodesService inviteCodesService,
HttpServletRequest request
) {
this.userInvitesService = userInvitesService;
this.inviteCodesService = inviteCodesService;
this.request = request;
}
public Boolean bind(long userId, String inviteCodeStr) {
if (userId <= 0 || inviteCodeStr == null || inviteCodeStr.trim().isEmpty()) {
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}
ensureNotBoundYet(userId);
KeyboardUserInviteCodes inviteCode = inviteCodesService.validateInviteCode(inviteCodeStr.trim());
ensureNotSelfInvite(userId, inviteCode);
KeyboardUserInvites userInvite = buildInviteBind(userId, inviteCode);
userInvitesService.save(userInvite);
increaseInviteCodeUsedCount(inviteCode);
return true;
}
private void ensureNotBoundYet(long userId) {
long bindCount = userInvitesService.count(new LambdaQueryWrapper<KeyboardUserInvites>()
.eq(KeyboardUserInvites::getInviteeUserId, userId));
if (bindCount > 0) {
throw new BusinessException(ErrorCode.INVITE_CODE_ALREADY_BOUND);
}
}
private void ensureNotSelfInvite(long userId, KeyboardUserInviteCodes inviteCode) {
if (inviteCode.getOwnerUserId().equals(userId)) {
throw new BusinessException(ErrorCode.INVITE_CODE_CANNOT_BIND_SELF);
}
}
private KeyboardUserInvites buildInviteBind(long userId, KeyboardUserInviteCodes inviteCode) {
KeyboardUserInvites userInvite = new KeyboardUserInvites();
userInvite.setInviterUserId(inviteCode.getOwnerUserId());
userInvite.setInviteeUserId(userId);
userInvite.setInviteCodeId(inviteCode.getId());
userInvite.setBindType(MANUAL_BIND_TYPE);
userInvite.setBoundAt(new Date());
userInvite.setBindIp(request.getRemoteAddr());
userInvite.setBindUserAgent(request.getHeader("User-Agent"));
userInvite.setInviteType(inviteCode.getInviteType());
userInvite.setInviteCode(inviteCode.getCode());
if (isAgentInvite(inviteCode)) {
userInvite.setProfitTenantId(inviteCode.getOwnerTenantId());
userInvite.setInviterTenantId(inviteCode.getOwnerTenantId());
userInvite.setProfitEmployeeId(inviteCode.getOwnerSystemUserId());
}
return userInvite;
}
private boolean isAgentInvite(KeyboardUserInviteCodes inviteCode) {
return INVITE_TYPE_AGENT.equals(inviteCode.getInviteType()) && inviteCode.getOwnerTenantId() != null;
}
private void increaseInviteCodeUsedCount(KeyboardUserInviteCodes inviteCode) {
inviteCode.setUsedCount(inviteCode.getUsedCount() + 1);
inviteCodesService.updateById(inviteCode);
}
}

View File

@@ -0,0 +1,71 @@
package com.yolo.keyborad.service.impl.user;
import com.yolo.keyborad.common.ErrorCode;
import com.yolo.keyborad.exception.BusinessException;
import com.yolo.keyborad.model.dto.user.SendMailDTO;
import com.yolo.keyborad.model.dto.user.VerifyCodeDTO;
import com.yolo.keyborad.utils.RedisUtil;
import com.yolo.keyborad.utils.SendMailUtils;
import java.util.concurrent.TimeUnit;
import org.springframework.stereotype.Component;
/*
* @author: ziin
* @date: 2026/2/27
*/
@Component
public class UserMailVerificationHandler {
private static final String USER_CODE_PREFIX = "user:";
private static final String LIMIT_MAIL_PREFIX = "limit_mail:";
private static final int VERIFY_CODE_TTL_SECONDS = 600;
private static final int LIMIT_TTL_SECONDS = 60;
private static final int VERIFY_CODE_MIN = 100000;
private static final int VERIFY_CODE_MAX = 999999;
private final RedisUtil redisUtil;
private final SendMailUtils sendMailUtils;
public UserMailVerificationHandler(RedisUtil redisUtil, SendMailUtils sendMailUtils) {
this.redisUtil = redisUtil;
this.sendMailUtils = sendMailUtils;
}
public void sendVerifyMail(SendMailDTO mailDTO) {
String mailAddress = mailDTO.getMailAddress();
ensureNotRateLimited(mailAddress);
String existCode = redisUtil.get(USER_CODE_PREFIX + mailAddress);
if (existCode != null) {
sendMailUtils.sendEmail(null, mailAddress, Integer.valueOf(existCode));
return;
}
int code = cn.hutool.core.util.RandomUtil.randomInt(VERIFY_CODE_MIN, VERIFY_CODE_MAX);
redisUtil.setEx(USER_CODE_PREFIX + mailAddress, String.valueOf(code), VERIFY_CODE_TTL_SECONDS, TimeUnit.SECONDS);
sendMailUtils.sendEmail(null, mailAddress, code);
redisUtil.setEx(LIMIT_MAIL_PREFIX + mailAddress, String.valueOf(code), LIMIT_TTL_SECONDS, TimeUnit.SECONDS);
}
public Boolean verifyMailCode(VerifyCodeDTO verifyCodeDTO) {
String mailAddress = verifyCodeDTO.getMailAddress();
String storedCode = redisUtil.get(USER_CODE_PREFIX + mailAddress);
if (storedCode == null) {
throw new BusinessException(ErrorCode.VERIFY_CODE_ERROR);
}
if (!storedCode.equals(String.valueOf(verifyCodeDTO.getVerifyCode()))) {
return false;
}
redisUtil.delete(USER_CODE_PREFIX + mailAddress);
return true;
}
private void ensureNotRateLimited(String mailAddress) {
if (redisUtil.hasKey(LIMIT_MAIL_PREFIX + mailAddress)) {
throw new BusinessException(ErrorCode.MAIL_SEND_BUSY);
}
}
}

View File

@@ -0,0 +1,43 @@
package com.yolo.keyborad.service.impl.user;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.yolo.keyborad.common.ErrorCode;
import com.yolo.keyborad.exception.BusinessException;
import com.yolo.keyborad.mapper.KeyboardUserMapper;
import com.yolo.keyborad.model.dto.user.ResetPassWordDTO;
import com.yolo.keyborad.model.entity.KeyboardUser;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
/*
* @author: ziin
* @date: 2026/2/27
*/
@Component
public class UserPasswordHandler {
private final KeyboardUserMapper keyboardUserMapper;
private final PasswordEncoder passwordEncoder;
public UserPasswordHandler(KeyboardUserMapper keyboardUserMapper, PasswordEncoder passwordEncoder) {
this.keyboardUserMapper = keyboardUserMapper;
this.passwordEncoder = passwordEncoder;
}
public Boolean resetPassWord(ResetPassWordDTO resetPassWordDTO) {
KeyboardUser keyboardUser = keyboardUserMapper.selectOne(new LambdaQueryWrapper<KeyboardUser>()
.eq(KeyboardUser::getEmail, resetPassWordDTO.getMailAddress())
.eq(KeyboardUser::getStatus, false));
if (keyboardUser == null) {
throw new BusinessException(ErrorCode.USER_NOT_FOUND);
}
if (!resetPassWordDTO.getPassword().equals(resetPassWordDTO.getConfirmPassword())) {
throw new BusinessException(ErrorCode.CONFIRM_PASSWORD_NOT_MATCH);
}
keyboardUser.setPassword(passwordEncoder.encode(resetPassWordDTO.getPassword()));
return keyboardUserMapper.updateById(keyboardUser) > 0;
}
}

View File

@@ -0,0 +1,176 @@
package com.yolo.keyborad.service.impl.user;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.RandomUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
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.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.util.Date;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
/*
* @author: ziin
* @date: 2026/2/27
*/
@Component
@Slf4j
public class UserRegistrationHandler {
private static final String USER_CODE_PREFIX = "user:";
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 UserInviteCodeBinder inviteCodeBinder;
private final NacosAppConfigCenter.DynamicAppConfig cfgHolder;
public UserRegistrationHandler(
KeyboardUserMapper keyboardUserMapper,
PasswordEncoder passwordEncoder,
RedisUtil redisUtil,
KeyboardCharacterService keyboardCharacterService,
KeyboardUserWalletService walletService,
KeyboardUserQuotaTotalService quotaTotalService,
KeyboardUserInviteCodesService inviteCodesService,
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.inviteCodeBinder = inviteCodeBinder;
this.cfgHolder = cfgHolder;
}
@Transactional(rollbackFor = Exception.class)
public Boolean userRegister(UserRegisterDTO userRegisterDTO) {
ensureUserNotExists(userRegisterDTO.getMailAddress());
validatePasswords(userRegisterDTO);
verifyCode(userRegisterDTO);
KeyboardUser keyboardUser = buildNewUser(userRegisterDTO);
int insertCount = keyboardUserMapper.insert(keyboardUser);
if (insertCount <= 0) {
return false;
}
initNewUserAssets(keyboardUser);
tryBindInviteCode(userRegisterDTO.getInviteCode(), keyboardUser.getId());
logRegisterSuccess(keyboardUser);
return true;
}
private void ensureUserNotExists(String mailAddress) {
KeyboardUser userMail = keyboardUserMapper.selectOne(new LambdaQueryWrapper<KeyboardUser>()
.eq(KeyboardUser::getEmail, mailAddress));
if (userMail != null) {
throw new BusinessException(ErrorCode.USER_HAS_EXISTED);
}
}
private void validatePasswords(UserRegisterDTO userRegisterDTO) {
if (userRegisterDTO.getPassword() == null) {
throw new BusinessException(ErrorCode.PASSWORD_CAN_NOT_NULL);
}
if (!userRegisterDTO.getPassword().equals(userRegisterDTO.getPasswordConfirm())) {
throw new BusinessException(ErrorCode.CONFIRM_PASSWORD_NOT_MATCH);
}
}
private void verifyCode(UserRegisterDTO userRegisterDTO) {
String stored = redisUtil.get(USER_CODE_PREFIX + userRegisterDTO.getMailAddress());
if (!userRegisterDTO.getVerifyCode().equals(stored)) {
throw new BusinessException(ErrorCode.VERIFY_CODE_ERROR);
}
redisUtil.delete(USER_CODE_PREFIX + userRegisterDTO.getMailAddress());
}
private KeyboardUser buildNewUser(UserRegisterDTO userRegisterDTO) {
KeyboardUser keyboardUser = new KeyboardUser();
keyboardUser.setUid(IdUtil.getSnowflake().nextId());
keyboardUser.setNickName("User_" + RandomUtil.randomString(6));
keyboardUser.setPassword(passwordEncoder.encode(userRegisterDTO.getPassword()));
keyboardUser.setEmail(userRegisterDTO.getMailAddress());
keyboardUser.setGender(userRegisterDTO.getGender());
keyboardUser.setEmailVerified(true);
return keyboardUser;
}
private void initNewUserAssets(KeyboardUser keyboardUser) {
keyboardCharacterService.addDefaultUserCharacter(keyboardUser.getId());
AppConfig appConfig = cfgHolder.getRef().get();
initWallet(keyboardUser.getId(), appConfig.getUserRegisterProperties().getRewardBalance());
initQuota(
keyboardUser.getId(),
appConfig.getUserRegisterProperties().getFreeTrialQuota()
);
inviteCodesService.createInviteCode(keyboardUser.getId());
}
private void initWallet(long userId, BigDecimal rewardBalance) {
KeyboardUserWallet wallet = new KeyboardUserWallet();
wallet.setUserId(userId);
wallet.setBalance(rewardBalance);
wallet.setVersion(0);
wallet.setStatus((short) 1);
wallet.setCreatedAt(new Date());
wallet.setUpdatedAt(new Date());
walletService.save(wallet);
}
private void initQuota(long userId, Integer freeTrialQuota) {
KeyboardUserQuotaTotal quotaTotal = new KeyboardUserQuotaTotal();
quotaTotal.setUserId(userId);
quotaTotal.setTotalQuota(freeTrialQuota);
quotaTotal.setUsedQuota(0);
quotaTotal.setVersion(0);
quotaTotal.setCreatedAt(new Date());
quotaTotal.setUpdatedAt(new Date());
quotaTotalService.save(quotaTotal);
}
private void tryBindInviteCode(String inviteCode, long userId) {
if (inviteCode == null || inviteCode.trim().isEmpty()) {
return;
}
try {
inviteCodeBinder.bind(userId, inviteCode.trim());
log.info("User bound to invite code on register, userId={}", userId);
} catch (BusinessException e) {
log.warn("Failed to bind invite code for user {}: {}", userId, e.getMessage());
}
}
private void logRegisterSuccess(KeyboardUser keyboardUser) {
AppConfig appConfig = cfgHolder.getRef().get();
log.info("User registered with email, userId={}, email={}, freeQuota={}",
keyboardUser.getId(), keyboardUser.getEmail(), appConfig.getUserRegisterProperties().getFreeTrialQuota());
}
}