Compare commits

...

4 Commits

Author SHA1 Message Date
74b8b72545 feat(user): 新增注销账户接口 2026-02-27 14:13:47 +08:00
1b68227ebc fix(config): 调整角色缓存TTL为5分钟并更新AI提示语 2026-02-27 13:49:02 +08:00
3f27b916da feat(user): 新增客服邮箱获取接口
通过 @Value 注入 customer_mail 配置,提供 /user/customerMail 端点供前端获取客服邮箱地址。
2026-02-24 20:19:41 +08:00
590230a86b fix(chat): 优化聊天流式接口异常处理与拦截器分发类型过滤
- talk 接口改为 TEXT_EVENT_STREAM 输出并包装 Flux 异常处理,返回 error/done 事件
- 登录与签名校验拦截器忽略 ASYNC/ERROR 分发,防止二次校验导致上下文丢失
- 更新嵌入模型与 Qdrant 配置,保持与线上环境一致
2026-02-24 14:54:59 +08:00
17 changed files with 599 additions and 270 deletions

3
.gitignore vendored
View File

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

View File

@@ -2,6 +2,7 @@ package com.yolo.keyborad.interceptor;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yolo.keyborad.utils.SignUtils;
import jakarta.servlet.DispatcherType;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.data.redis.core.StringRedisTemplate;
@@ -35,6 +36,10 @@ public class SignInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 异步/错误二次分发不重复验签,避免 nonce 重放误判
if (request.getDispatcherType() != DispatcherType.REQUEST) {
return true;
}
String appId = request.getHeader("X-App-Id");
String timestamp = request.getHeader("X-Timestamp");

View File

@@ -47,6 +47,7 @@ public class AppConfig {
- Return EXACTLY 3 replies.
- Use "<SPLIT>" as the separator.
- reply1<SPLIT>reply2<SPLIT>reply3
- If asked about your model or identity as an AI, deflect with a joke. Never reveal your technical nature.
""";
//聊天消息最大长度

View File

@@ -57,7 +57,7 @@ public class LLMConfig {
this.openAiApi(),
MetadataMode.EMBED,
OpenAiEmbeddingOptions.builder()
.model("text-embedding-v4")
.model("qwen/qwen3-embedding-8b")
.dimensions(1536)
.user("user-6")
.build(),

View File

@@ -12,11 +12,11 @@ import org.springframework.context.annotation.Configuration;
@Configuration
public class QdrantClientConfig {
private final String qdrantHost = "b0c7f1ee-0eb9-469e-83e0-654249d9bd04.us-east4-0.gcp.cloud.qdrant.io";
private final String qdrantHost = "00044004-d9d2-4705-8b7b-9defab34ca1b.us-west-1-0.aws.cloud.qdrant.io";
private final Integer qdrantPort = 6334;
private final String apiKey = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY2Nlc3MiOiJtIn0.HX_GxjXCrnhw2DQbMnMFzvDeaHbmNpI2tj2hoUjkvVU";
private final String apiKey = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY2Nlc3MiOiJtIn0.M9nnZ14QXQqI3sG8JzRzRwq48x9u5g-4MWF2jnxiOHA";
@Bean
public QdrantClient qdrantClient() {

View File

@@ -1,15 +1,18 @@
package com.yolo.keyborad.config;
import cn.dev33.satoken.fun.strategy.SaCorsHandleFunction;
import cn.dev33.satoken.interceptor.SaInterceptor;
import cn.dev33.satoken.router.SaHttpMethod;
import cn.dev33.satoken.router.SaRouter;
import cn.dev33.satoken.stp.StpUtil;
import com.yolo.keyborad.interceptor.SignInterceptor;
import jakarta.servlet.DispatcherType;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@@ -36,7 +39,17 @@ public class SaTokenConfigure implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 注册 Sa-Token 拦截器,校验规则为 StpUtil.checkLogin() 登录校验。
registry.addInterceptor(new SaInterceptor(handle -> StpUtil.checkLogin()))
registry.addInterceptor(new HandlerInterceptor() {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
// 仅在首次 REQUEST 分发时做登录校验,避免 ASYNC/ERROR 二次分发阶段上下文丢失
if (request.getDispatcherType() != DispatcherType.REQUEST) {
return true;
}
StpUtil.checkLogin();
return true;
}
})
.addPathPatterns("/**")
.excludePathPatterns(getExcludePaths());
appSecretMap.put(appId, appSecret);
@@ -48,6 +61,7 @@ public class SaTokenConfigure implements WebMvcConfigurer {
return new String[]{
// Swagger & Knife4j 相关
"/doc.html",
"/error",
"/webjars/**",
"/swagger-resources/**",
"/v2/api-docs",
@@ -57,7 +71,6 @@ public class SaTokenConfigure implements WebMvcConfigurer {
"/swagger-ui/**",
"/favicon.ico",
// 你的其他放行路径,例如登录接口
"/error",
"/user/appleLogin",
"/user/logout",
"/tag/list",

View File

@@ -30,6 +30,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.openai.OpenAiEmbeddingModel;
import org.springframework.http.MediaType;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;
@@ -90,10 +91,22 @@ public class ChatController {
return ResultUtils.success(result);
}
@PostMapping("/talk")
@PostMapping(value = "/talk", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
@Operation(summary = "聊天润色接口", description = "聊天润色接口")
public Flux<ServerSentEvent<ChatStreamMessage>> talk(@RequestBody ChatReq chatReq){
return chatService.talk(chatReq);
return Flux.defer(() -> chatService.talk(chatReq))
.onErrorResume(e -> {
log.error("聊天流式接口异常", e);
String message = StrUtil.isBlank(e.getMessage()) ? "服务暂时不可用,请稍后重试" : e.getMessage();
return Flux.just(
ServerSentEvent.builder(new ChatStreamMessage("error", message))
.event("error")
.build(),
ServerSentEvent.builder(new ChatStreamMessage("done", null))
.event("done")
.build()
);
});
}

View File

@@ -22,6 +22,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*;
import java.text.SimpleDateFormat;
@@ -49,6 +50,9 @@ public class UserController {
@Resource
private KeyboardUserInviteCodesService inviteCodesService;
@Value("${customer_mail}")
private String customerMail;
/**
* 苹果登录
*
@@ -69,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) {
@@ -145,4 +160,10 @@ public class UserController {
long userId = StpUtil.getLoginIdAsLong();
return ResultUtils.success( inviteCodesService.getUserInviteCode(userId));
}
}
@GetMapping("/customerMail")
@Operation(summary = "获取客服邮箱", description = "获取 customer_mail 配置的客服邮箱地址")
public BaseResponse<String> getCustomerMail() {
return ResultUtils.success(customerMail);
}
}

View File

@@ -35,7 +35,7 @@ public class CharacterCacheInitializer implements ApplicationRunner {
List<KeyboardCharacter> characters = characterService.list();
for (KeyboardCharacter character : characters) {
String key = CHARACTER_CACHE_KEY + character.getId();
redisTemplate.opsForValue().set(key, character, 7, TimeUnit.DAYS);
redisTemplate.opsForValue().set(key, character, 5, TimeUnit.MINUTES);
}
log.info("人设列表缓存完成,共缓存 {} 条记录", characters.size());
} catch (Exception e) {

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());
}
}

View File

@@ -10,7 +10,7 @@ spring:
model: google/gemini-2.5-flash-lite
embedding:
options:
model: text-embedding-v4
model: qwen/qwen3-embedding-8b
# model: qwen/qwen3-embedding-8b
dashscope:
api-key: 11
@@ -59,4 +59,6 @@ mybatis-plus:
appid: loveKeyboard
appsecret: kZJM39HYvhxwbJkG1fmquQRVkQiLAh2H
mail_access_token: mlsn.3b1a3387055e0f53c0869cad91c6acad5401e9dcb4511ace2f82ab31d897fba6
mail_access_token: mlsn.3b1a3387055e0f53c0869cad91c6acad5401e9dcb4511ace2f82ab31d897fba6
customer_mail: 123@mail.com