diff --git a/.gitignore b/.gitignore index 8854d7d..3ed6af4 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,9 @@ target/ *.iml *.ipr +### macOS ### +.DS_Store + ### NetBeans ### /nbproject/private/ diff --git a/src/main/java/com/yolo/keyborad/controller/UserController.java b/src/main/java/com/yolo/keyborad/controller/UserController.java index 1ee3947..9819b94 100644 --- a/src/main/java/com/yolo/keyborad/controller/UserController.java +++ b/src/main/java/com/yolo/keyborad/controller/UserController.java @@ -73,6 +73,17 @@ public class UserController { return ResultUtils.success(true); } + @PostMapping("/cancelAccount") + @Operation(summary = "注销账户", description = "用户注销账户(软删除),同步写入 deleted_at,并强制退出登录") + public BaseResponse 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 login(@RequestBody UserLoginDTO userLoginDTO, HttpServletRequest request) { 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 a213d49..93282a5 100644 --- a/src/main/java/com/yolo/keyborad/model/entity/KeyboardUser.java +++ b/src/main/java/com/yolo/keyborad/model/entity/KeyboardUser.java @@ -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; -} \ No newline at end of file +} diff --git a/src/main/java/com/yolo/keyborad/service/UserService.java b/src/main/java/com/yolo/keyborad/service/UserService.java index 960c355..f530601 100644 --- a/src/main/java/com/yolo/keyborad/service/UserService.java +++ b/src/main/java/com/yolo/keyborad/service/UserService.java @@ -30,4 +30,12 @@ public interface UserService extends IService { Boolean bindInviteCode(BindInviteCodeDTO bindInviteCodeDTO); + /** + * 注销账户(软删除) + * + * @param userId 当前登录用户ID + * @return 是否成功 + */ + Boolean cancelAccount(long userId); + } 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 498ac94..fbc4fe5 100644 --- a/src/main/java/com/yolo/keyborad/service/impl/UserServiceImpl.java +++ b/src/main/java/com/yolo/keyborad/service/impl/UserServiceImpl.java @@ -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() - .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() - .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() - .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 updateWrapper = new LambdaUpdateWrapper() + .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"; + } + } diff --git a/src/main/java/com/yolo/keyborad/service/impl/user/UserInviteCodeBinder.java b/src/main/java/com/yolo/keyborad/service/impl/user/UserInviteCodeBinder.java new file mode 100644 index 0000000..68e9410 --- /dev/null +++ b/src/main/java/com/yolo/keyborad/service/impl/user/UserInviteCodeBinder.java @@ -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() + .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); + } +} + diff --git a/src/main/java/com/yolo/keyborad/service/impl/user/UserMailVerificationHandler.java b/src/main/java/com/yolo/keyborad/service/impl/user/UserMailVerificationHandler.java new file mode 100644 index 0000000..b86f0db --- /dev/null +++ b/src/main/java/com/yolo/keyborad/service/impl/user/UserMailVerificationHandler.java @@ -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); + } + } +} + diff --git a/src/main/java/com/yolo/keyborad/service/impl/user/UserPasswordHandler.java b/src/main/java/com/yolo/keyborad/service/impl/user/UserPasswordHandler.java new file mode 100644 index 0000000..55b3e84 --- /dev/null +++ b/src/main/java/com/yolo/keyborad/service/impl/user/UserPasswordHandler.java @@ -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() + .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; + } +} + diff --git a/src/main/java/com/yolo/keyborad/service/impl/user/UserRegistrationHandler.java b/src/main/java/com/yolo/keyborad/service/impl/user/UserRegistrationHandler.java new file mode 100644 index 0000000..c99d5e7 --- /dev/null +++ b/src/main/java/com/yolo/keyborad/service/impl/user/UserRegistrationHandler.java @@ -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() + .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()); + } +}