feat(open): 重构C端认证并支持密码重置

This commit is contained in:
2026-08-17 09:29:42 +08:00
parent 0591ff67d0
commit 7687ef4f37
18 changed files with 500 additions and 187 deletions

View File

@@ -5,6 +5,9 @@ import com.youlai.boot.common.base.BaseEntity;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* 移动端用户实体
*/
@@ -47,6 +50,31 @@ public class ClientUser extends BaseEntity {
*/
private String wechatOpenid;
/**
* 微信 unionid同一微信开放平台下唯一标识用于跨小程序/公众号识别同一用户)
*/
private String unionid;
/**
* 微信小程序 session_key用于解密手机号、数据校验登录态刷新时更新
*/
private String sessionKey;
/**
* 注册来源(1-手机号 2-微信小程序)
*/
private Integer registerSource;
/**
* 生日
*/
private LocalDate birthday;
/**
* 最后登录时间
*/
private LocalDateTime lastLoginTime;
/**
* 状态((1-正常 0-禁用)
*/

View File

@@ -37,7 +37,7 @@ import java.util.concurrent.Executor;
@Slf4j
public class LogAspect {
private static final int MAX_ERROR_MSG_LENGTH = 1000;
private static final int MAX_ERROR_MSG_LENGTH = 2000;
private final LogService logService;
private final Executor operationLogExecutor;

View File

@@ -52,8 +52,8 @@ public interface RedisConstants {
*/
interface Captcha {
String IMAGE_CODE = "captcha:image:{}"; // 图形验证码
String SMS_LOGIN_CODE = "captcha:sms_login:{}"; // 登录短信验证码
String SMS_REGISTER_CODE = "captcha:sms_register:{}";// 注册短信验证码
String SMS_LOGIN_CODE = "captcha:sms_login:{}"; // 登录/注册通用短信验证码
String SMS_RESET_CODE = "captcha:sms_reset:{}"; // 重置密码短信验证码
String MOBILE_CODE = "captcha:mobile:{}"; // 绑定、更换手机验证码
String EMAIL_CODE = "captcha:email:{}"; // 邮箱验证码
}

View File

@@ -1,7 +1,8 @@
package com.youlai.boot.open.controller;
import com.youlai.boot.open.model.req.OpenLoginReq;
import com.youlai.boot.open.model.req.OpenRegisterReq;
import com.youlai.boot.open.model.req.OpenPasswordLoginReq;
import com.youlai.boot.open.model.req.OpenResetPasswordReq;
import com.youlai.boot.open.service.OpenAuthService;
import com.youlai.boot.common.annotation.Log;
import com.youlai.boot.common.enums.ActionTypeEnum;
@@ -30,28 +31,16 @@ public class OpenAuthController {
private final OpenAuthService openAuthService;
@Operation(summary = "手机号注册")
@PostMapping("/register/mobile")
@Log(module = LogModuleEnum.LOGIN, value = ActionTypeEnum.REGISTER)
public Result<AuthenticationToken> registerByMobile(@RequestBody @Valid OpenRegisterReq request) {
AuthenticationToken authenticationToken = openAuthService.registerByMobile(
request.getMobile(),
request.getCode(),
request.getPassword(),
request.getNickname()
);
return Result.success(authenticationToken);
}
@Operation(summary = "发送注册短信验证码")
@PostMapping("/register/sms/code")
public Result<Void> sendRegisterSmsCode(
@Operation(summary = "发送短信验证码(登录/注册通用)")
@PostMapping("/sms/code")
public Result<Void> sendSmsCode(
@Parameter(description = "手机号", example = "18888888888") @RequestParam String mobile
) {
return Result.judge(openAuthService.sendRegisterSmsCode(mobile));
openAuthService.sendLoginSmsCode(mobile);
return Result.success();
}
@Operation(summary = "手机号验证码登录")
@Operation(summary = "手机号验证码登录/注册")
@PostMapping("/login/mobile")
@Log(module = LogModuleEnum.LOGIN, value = ActionTypeEnum.LOGIN)
public Result<AuthenticationToken> loginByMobile(@RequestBody @Valid OpenLoginReq request) {
@@ -59,5 +48,56 @@ public class OpenAuthController {
return Result.success(authenticationToken);
}
@Operation(summary = "账号密码登录")
@PostMapping("/login")
@Log(module = LogModuleEnum.LOGIN, value = ActionTypeEnum.LOGIN)
public Result<AuthenticationToken> login(@RequestBody @Valid OpenPasswordLoginReq request) {
AuthenticationToken authenticationToken = openAuthService.login(request.getUsername(), request.getPassword());
return Result.success(authenticationToken);
}
@Operation(summary = "发送登录短信验证码")
@PostMapping("/login/sms/code")
public Result<Void> sendLoginSmsCode(
@Parameter(description = "手机号", example = "18888888888") @RequestParam String mobile
) {
openAuthService.sendLoginSmsCode(mobile);
return Result.success();
}
@Operation(summary = "发送重置密码短信验证码")
@PostMapping("/reset-password/sms/code")
public Result<Void> sendResetPwdSmsCode(
@Parameter(description = "手机号", example = "18888888888") @RequestParam String mobile
) {
return Result.judge(openAuthService.sendResetPwdSmsCode(mobile));
}
@Operation(summary = "重置密码")
@PostMapping("/reset-password")
@Log(module = LogModuleEnum.LOGIN, value = ActionTypeEnum.RESET_PASSWORD)
public Result<Void> resetPassword(@RequestBody @Valid OpenResetPasswordReq request) {
openAuthService.resetPassword(request.getMobile(), request.getCode(), request.getPassword());
return Result.success();
}
@Operation(summary = "刷新令牌")
@PostMapping("/refresh-token")
public Result<AuthenticationToken> refreshToken(
@Parameter(description = "刷新令牌") @RequestParam String refreshToken
) {
return Result.success(openAuthService.refreshToken(refreshToken));
}
@Operation(summary = "退出登录")
@PostMapping("/logout")
public Result<Void> logout(
@Parameter(description = "访问令牌(可选Bearer 前缀可省略)", example = "eyJhbGciOiJIUzI1NiJ9...")
@RequestParam(required = false) String accessToken
) {
openAuthService.logout(accessToken);
return Result.success();
}
}

View File

@@ -12,6 +12,7 @@ import com.youlai.boot.system.model.vo.UserPageVO;
import com.youlai.boot.system.model.vo.UserProfileVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@@ -46,6 +47,11 @@ public interface ClientUserMapper extends BaseMapper<ClientUser> {
* @param username 用户名
* @return 认证信息
*/
@Select("""
SELECT id AS userId, username, nickname, password, status, avatar, mobile
FROM app_user
WHERE username = #{username} AND is_deleted = 0
""")
SecurityUser getAuthInfoByUsername(String username);
default SecurityUser getAuthCredentialsByUsername(String username) {
@@ -58,6 +64,11 @@ public interface ClientUserMapper extends BaseMapper<ClientUser> {
* @param mobile 手机号
* @return 认证信息
*/
@Select("""
SELECT id AS userId, username, nickname, password, status, avatar, mobile
FROM app_user
WHERE mobile = #{mobile} AND is_deleted = 0
""")
SecurityUser getAuthInfoByMobile(String mobile);
default SecurityUser getAuthCredentialsByMobile(String mobile) {

View File

@@ -0,0 +1,22 @@
package com.youlai.boot.open.model.req;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* 账号密码登录请求参数
*
*/
@Schema(description = "账号密码登录请求参数")
@Data
public class OpenPasswordLoginReq {
@Schema(description = "用户名 / 手机号", requiredMode = Schema.RequiredMode.REQUIRED, example = "18888888888")
@NotBlank(message = "用户名不能为空")
private String username;
@Schema(description = "密码", requiredMode = Schema.RequiredMode.REQUIRED, example = "123456")
@NotBlank(message = "密码不能为空")
private String password;
}

View File

@@ -1,32 +0,0 @@
package com.youlai.boot.open.model.req;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
/**
* 手机号注册请求参数
*
*/
@Schema(description = "手机号注册请求参数")
@Data
public class OpenRegisterReq {
@Schema(description = "手机号", requiredMode = Schema.RequiredMode.REQUIRED, example = "18888888888")
@NotBlank(message = "手机号不能为空")
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
private String mobile;
@Schema(description = "验证码", requiredMode = Schema.RequiredMode.REQUIRED, example = "123456")
@NotBlank(message = "验证码不能为空")
private String code;
@Schema(description = "密码", requiredMode = Schema.RequiredMode.REQUIRED, example = "123456")
@NotBlank(message = "密码不能为空")
private String password;
@Schema(description = "昵称", example = "用户昵称")
private String nickname;
}

View File

@@ -0,0 +1,31 @@
package com.youlai.boot.open.model.req;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Data;
/**
* 重置密码请求参数
*
* @author TongTong Studio
*/
@Schema(description = "重置密码请求参数")
@Data
public class OpenResetPasswordReq {
@Schema(description = "手机号", requiredMode = Schema.RequiredMode.REQUIRED, example = "18888888888")
@NotBlank(message = "手机号不能为空")
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
private String mobile;
@Schema(description = "验证码", requiredMode = Schema.RequiredMode.REQUIRED, example = "123456")
@NotBlank(message = "验证码不能为空")
private String code;
@Schema(description = "新密码", requiredMode = Schema.RequiredMode.REQUIRED, example = "123456")
@NotBlank(message = "新密码不能为空")
@Size(min = 6, max = 32, message = "密码长度需在 6-32 位之间")
private String password;
}

View File

@@ -11,25 +11,7 @@ public interface OpenAuthService {
CaptchaInfo getCaptcha();
/**
* 发送注册短信验证码
*
* @param mobile 手机号
*/
boolean sendRegisterSmsCode(String mobile);
/**
* 手机号注册
*
* @param mobile 手机号
* @param code 验证码
* @param password 密码
* @param nickname 昵称
* @return 认证令牌
*/
AuthenticationToken registerByMobile(String mobile, String code, String password, String nickname);
/**
* 发送登录短信验证码
* 发送登录/注册短信验证码(用户存在则用于登录,不存在则用于注册)
*
* @param mobile 手机号
*/
@@ -53,11 +35,28 @@ public interface OpenAuthService {
*/
AuthenticationToken login(String username, String password);
/**
* 发送重置密码短信验证码
*
* @param mobile 手机号
*/
boolean sendResetPwdSmsCode(String mobile);
/**
* 重置密码
*
* @param mobile 手机号
* @param code 短信验证码
* @param password 新密码
*/
void resetPassword(String mobile, String code, String password);
/**
* 退出登录
*
* @param accessToken 访问令牌(可选,为空时仅清理当前上下文)
*/
void logout();
void logout(String accessToken);
/**
* 刷新令牌

View File

@@ -1,28 +1,28 @@
package com.youlai.boot.open.service.impl;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil;
import com.youlai.boot.client.model.entity.ClientUser;
import com.youlai.boot.framework.security.model.AuthenticationToken;
import com.youlai.boot.framework.security.model.SecurityUser;
import com.youlai.boot.framework.security.model.SecurityUserDetails;
import com.youlai.boot.framework.security.token.TokenManager;
import com.youlai.boot.open.mapper.ClientUserMapper;
import com.youlai.boot.open.service.OpenAuthService;
import com.youlai.boot.client.service.ClientUserService;
import com.youlai.boot.auth.model.resp.CaptchaInfo;
import com.youlai.boot.auth.service.CaptchaService;
import com.youlai.boot.common.constant.RedisConstants;
import com.youlai.boot.common.util.CodeGeneratorUtil;
import com.youlai.boot.framework.security.model.AuthenticationToken;
import com.youlai.boot.framework.security.token.TokenManager;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.youlai.boot.common.exception.BusinessException;
import com.youlai.boot.support.sms.SmsService;
import com.youlai.boot.support.sms.SmsResult;
import com.youlai.boot.support.sms.SmsTypeEnum;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.password.PasswordEncoder;
import com.youlai.boot.common.exception.BusinessException;
import cn.hutool.core.lang.Assert;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.springframework.stereotype.Service;
import java.util.HashMap;
@@ -31,165 +31,246 @@ import java.util.Objects;
import java.util.concurrent.TimeUnit;
/**
* 认证服务实现类
* 开放式客户端认证服务实现类
*
* <p>所有 C 端用户client / app_user登录均在此模块内完成认证与令牌签发
* 不依赖后台管理端sys_user的全局认证链实现账号体系分域隔离。</p>
*
* @author TongTong Studio
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class OpenAuthServiceImpl implements OpenAuthService {
private final AuthenticationManager authenticationManager;
private final TokenManager tokenManager;
private final SmsService smsService;
private final RedisTemplate<String, Object> redisTemplate;
private final CaptchaService captchaService;
private final StringRedisTemplate redisTemplate;
private final ClientUserService userService;
private final ClientUserMapper clientUserMapper;
private final PasswordEncoder passwordEncoder;
/**
* 获取验证码C 端登录无需图形验证码,预留接口返回 null
*/
@Override
public CaptchaInfo getCaptcha() {
public com.youlai.boot.auth.model.resp.CaptchaInfo getCaptcha() {
return null;
}
/**
* 发送注册短信验证码
* 发送短信验证码(登录/注册通用,阿里云)
*
* @param mobile 手机号
*/
@Override
public boolean sendRegisterSmsCode(String mobile) {
// 检查手机号是否已注册
long count = userService.count(new LambdaQueryWrapper<ClientUser>()
.eq(ClientUser::getMobile, mobile));
if (count > 0) {
throw new BusinessException("该手机号已被注册");
}
public void sendLoginSmsCode(String mobile) {
// 生成 6 位数字验证码
String code = CodeGeneratorUtil.generateNumericCode(6);
// 发送短信验证码
Map<String, String> templateParams = new HashMap<>();
templateParams.put("code", code);
boolean success = smsService.send(mobile, SmsTypeEnum.REGISTER, templateParams);
if (success) {
// 缓存验证码至Redis用于注册校验
redisTemplate.opsForValue().set(StrUtil.format(RedisConstants.Captcha.SMS_REGISTER_CODE, mobile), code, 5, TimeUnit.MINUTES);
SmsResult result = smsService.sendWithResult(mobile, SmsTypeEnum.LOGIN, templateParams);
if (result.isSuccess()) {
// 缓存验证码至 Redis5 分钟内有效,用于登录校验
redisTemplate.opsForValue().set(
StrUtil.format(RedisConstants.Captcha.SMS_LOGIN_CODE, mobile),
code, 5, TimeUnit.MINUTES);
} else {
log.warn("短信发送失败,手机号: {}", mobile);
log.warn("登录短信验证码发送失败,手机号: {}, 原因: {}", mobile, result.getMessage());
throw new BusinessException(resolveSmsErrorMessage(result.getMessage()));
}
return success;
}
/**
* 手机号注册
* 手机验证码登录
*
* @param mobile 手机号
* @param code 验证码
* @param password 密码
* @param nickname 昵称
* @param code 短信验证码
* @return 认证令牌
*/
@Override
public AuthenticationToken registerByMobile(String mobile, String code, String password, String nickname) {
// 1. 校验验证码
String cacheKey = StrUtil.format(RedisConstants.Captcha.SMS_REGISTER_CODE, mobile);
public AuthenticationToken loginBySms(String mobile, String code) {
String cacheKey = StrUtil.format(RedisConstants.Captcha.SMS_LOGIN_CODE, mobile);
String cachedCode = (String) redisTemplate.opsForValue().get(cacheKey);
if (StrUtil.isBlank(cachedCode)) {
throw new BusinessException("验证码已过期");
}
if (!Objects.equals(code, cachedCode)) {
throw new BusinessException("验证码错误");
}
// 2. 检查手机号是否已注册
SecurityUser securityUser = clientUserMapper.getAuthInfoByMobile(mobile);
if (securityUser == null) {
// 用户不存在则自动注册(验证码登录/注册合一)
ClientUser newUser = new ClientUser();
newUser.setMobile(mobile);
newUser.setUsername(mobile);
String nickname = mobile.replaceAll("(\\d{3})\\d{4}(\\d{4})", "$1****$2");
newUser.setNickname(nickname);
// 验证码登录场景下用户未设置密码,生成随机密码,后续可在重置密码中修改
newUser.setPassword(passwordEncoder.encode(IdUtil.fastSimpleUUID()));
newUser.setStatus(1);
newUser.setRegisterSource(1);
userService.save(newUser);
securityUser = clientUserMapper.getAuthInfoByMobile(mobile);
}
if (!Objects.equals(securityUser.getStatus(), 1)) {
throw new BusinessException("账号已被禁用");
}
// 登录成功,删除验证码
redisTemplate.delete(cacheKey);
// 更新最后登录时间
ClientUser update = new ClientUser();
update.setId(securityUser.getUserId());
update.setLastLoginTime(java.time.LocalDateTime.now());
userService.updateById(update);
return generateToken(securityUser);
}
/**
* 账号密码登录
*
* @param username 用户名
* @param password 明文密码
* @return 认证令牌
*/
@Override
public AuthenticationToken login(String username, String password) {
SecurityUser securityUser = clientUserMapper.getAuthInfoByUsername(username);
if (securityUser == null) {
throw new BusinessException("用户名或密码错误");
}
if (!Objects.equals(securityUser.getStatus(), 1)) {
throw new BusinessException("账号已被禁用");
}
if (StrUtil.isBlank(securityUser.getPassword())
|| !passwordEncoder.matches(password, securityUser.getPassword())) {
throw new BusinessException("用户名或密码错误");
}
return generateToken(securityUser);
}
/**
* 发送重置密码短信验证码
*
* @param mobile 手机号
*/
@Override
public boolean sendResetPwdSmsCode(String mobile) {
// 检查手机号是否已注册(未注册用户无需重置密码)
long count = userService.count(new LambdaQueryWrapper<ClientUser>()
.eq(ClientUser::getMobile, mobile));
Assert.isTrue(count == 0, "该手机号已被注册");
if (count == 0) {
throw new BusinessException("该手机号尚未注册");
}
// 3. 创建新用户
ClientUser user = new ClientUser();
user.setUsername(mobile); // 使用手机号作为用户名
user.setMobile(mobile);
user.setPassword(passwordEncoder.encode(password));
user.setNickname(StrUtil.isNotBlank(nickname) ? nickname : mobile.replaceAll("(\\d{3})\\d{4}(\\d{4})", "$1****$2"));
user.setStatus(1); // 正常状态
String codeStr = CodeGeneratorUtil.generateNumericCode(6);
boolean saveResult = userService.save(user);
if (!saveResult) {
throw new BusinessException("注册失败");
Map<String, String> templateParams = new HashMap<>();
templateParams.put("code", codeStr);
boolean success = smsService.send(mobile, SmsTypeEnum.RESET_PASSWORD, templateParams);
if (success) {
// 缓存验证码至Redis用于重置密码校验
redisTemplate.opsForValue().set(
StrUtil.format(RedisConstants.Captcha.SMS_RESET_CODE, mobile),
codeStr, 5, TimeUnit.MINUTES);
} else {
log.warn("重置密码短信发送失败,手机号: {}", mobile);
throw new BusinessException("短信验证码发送失败,请稍后重试");
}
return success;
}
/**
* 重置密码
*
* @param mobile 手机号
* @param code 短信验证码
* @param password 新密码
*/
@Override
public void resetPassword(String mobile, String code, String password) {
// 1. 校验验证码
String cacheKey = StrUtil.format(RedisConstants.Captcha.SMS_RESET_CODE, mobile);
String cachedCode = (String) redisTemplate.opsForValue().get(cacheKey);
if (StrUtil.isBlank(cachedCode)) {
throw new BusinessException("验证码已过期");
}
if (!Objects.equals(code, cachedCode)) {
throw new BusinessException("验证码错误");
}
// 2. 查询用户
ClientUser user = userService.getOne(new LambdaQueryWrapper<ClientUser>()
.eq(ClientUser::getMobile, mobile)
.last("LIMIT 1"));
if (user == null) {
throw new BusinessException("该手机号尚未注册");
}
// 3. 更新密码
ClientUser update = new ClientUser();
update.setId(user.getId());
update.setPassword(passwordEncoder.encode(password));
boolean updated = userService.updateById(update);
if (!updated) {
throw new BusinessException("重置密码失败");
}
// 4. 删除验证码
redisTemplate.delete(cacheKey);
// 5. 自动登录并生成token
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(mobile, password);
Authentication authentication = authenticationManager.authenticate(authenticationToken);
AuthenticationToken authenticationTokenResponse =
tokenManager.generateToken(authentication);
SecurityContextHolder.getContext().setAuthentication(authentication);
return authenticationTokenResponse;
}
@Override
public void sendLoginSmsCode(String mobile) {
String code = "1234";
Map<String, String> templateParams = new HashMap<>();
templateParams.put("code", code);
boolean success = false;
// 方式1: 使用阿里云短信(默认)
// try {
// success = aliyunSmsService.sendSms(mobile, SmsTypeEnum.LOGIN, templateParams);
// log.info("阿里云短信发送结果: {}", success ? "成功" : "失败");
// } catch (Exception e) {
// log.error("阿里云短信发送异常", e);
// }
// 方式2: 使用腾讯云短信(需要时取消下面注释,并注释掉上面的阿里云代码)
try {
success = smsService.send(mobile, SmsTypeEnum.LOGIN, templateParams);
log.info("腾讯云短信发送结果: {}", success ? "成功" : "失败");
} catch (Exception e) {
log.error("腾讯云短信发送异常", e);
public void logout(String accessToken) {
if (StrUtil.isNotBlank(accessToken)) {
tokenManager.invalidateToken(accessToken);
}
if (success) {
redisTemplate.opsForValue().set(StrUtil.format(RedisConstants.Captcha.SMS_LOGIN_CODE, mobile), code, 5, TimeUnit.MINUTES);
} else {
log.warn("短信发送失败,手机号: {}", mobile);
}
}
@Override
public AuthenticationToken loginBySms(String mobile, String code) {
return null;
}
@Override
public AuthenticationToken login(String username, String password) {
return null;
}
@Override
public void logout() {
}
@Override
public AuthenticationToken refreshToken(String refreshToken) {
return null;
return tokenManager.refreshToken(refreshToken);
}
/**
* 根据 SecurityUser 构造 Authentication 并签发令牌C 端用户本地认证)
*/
private AuthenticationToken generateToken(SecurityUser securityUser) {
SecurityUserDetails userDetails = new SecurityUserDetails(securityUser);
Authentication authentication = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
return tokenManager.generateToken(authentication);
}
/**
* 将短信供应商返回的错误信息转换为用户友好提示。
* <p>重点识别流控、触发频次等常见限制,无法识别时透传原始信息。</p>
*/
private String resolveSmsErrorMessage(String message) {
if (StrUtil.isBlank(message)) {
return "短信验证码发送失败,请稍后重试";
}
// 触发流控 / 发送频率限制
if (message.contains("流控") || message.contains("BUSINESS_LIMIT_CONTROL")
|| message.contains("限流") || message.contains("频率") || message.contains("过于频繁")) {
return "操作过于频繁,请稍后再试";
}
// 触发验证码发送次数上限
if (message.contains("触发") && message.contains("Permits")) {
return "验证码发送次数已达上限,请稍后再试";
}
// 其它情况透传原始错误信息,便于前端直接展示
return message;
}
}

View File

@@ -0,0 +1,33 @@
package com.youlai.boot.support.sms;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 短信发送结果
* <p>携带发送是否成功以及供应商返回的具体错误信息,便于上层透传给调用方。</p>
*
* @author TongTong Studio
*/
@Getter
@AllArgsConstructor
public class SmsResult {
/**
* 是否发送成功
*/
private final boolean success;
/**
* 供应商返回的具体信息(失败时为错误描述,如流控提示)
*/
private final String message;
public static SmsResult ok() {
return new SmsResult(true, null);
}
public static SmsResult fail(String message) {
return new SmsResult(false, message);
}
}

View File

@@ -22,4 +22,14 @@ public interface SmsService {
*/
boolean send(String mobile, SmsTypeEnum smsType, Map<String, String> templateParams);
/**
* 发送短信并返回带错误信息的结果。
*
* @param mobile 手机号
* @param smsType 短信类型,对应 {@code sms.templates.*} 配置中的模板
* @param templateParams 模板参数,用于替换短信模板中的变量
* @return 发送结果(包含成功状态与供应商返回的具体信息)
*/
SmsResult sendWithResult(String mobile, SmsTypeEnum smsType, Map<String, String> templateParams);
}

View File

@@ -27,7 +27,12 @@ public enum SmsTypeEnum implements IBaseEnum<String> {
/**
* 修改手机号短信验证码
*/
CHANGE_MOBILE("change-mobile", "修改手机号短信验证码");
CHANGE_MOBILE("change-mobile", "修改手机号短信验证码"),
/**
* 重置密码短信验证码
*/
RESET_PASSWORD("reset-password", "重置密码短信验证码");
private final String value;
private final String label;

View File

@@ -9,6 +9,7 @@ import com.aliyuncs.IAcsClient;
import com.aliyuncs.exceptions.ClientException;
import com.aliyuncs.http.MethodType;
import com.aliyuncs.profile.DefaultProfile;
import com.youlai.boot.support.sms.SmsResult;
import com.youlai.boot.support.sms.SmsTypeEnum;
import com.youlai.boot.config.property.AliyunSmsProperties;
import com.youlai.boot.support.sms.SmsService;
@@ -36,6 +37,11 @@ public class AliyunSmsServiceImpl implements SmsService {
@Override
public boolean send(String mobile, SmsTypeEnum smsType, Map<String, String> templateParams) {
return sendWithResult(mobile, smsType, templateParams).isSuccess();
}
@Override
public SmsResult sendWithResult(String mobile, SmsTypeEnum smsType, Map<String, String> templateParams) {
// 根据 smsType 从配置中获取对应的阿里云短信模板编码
String templateCode = aliyunSmsProperties.getTemplates().get(smsType.getValue());
@@ -67,13 +73,13 @@ public class AliyunSmsServiceImpl implements SmsService {
boolean success = "OK".equals(code);
if (!success) {
log.error("阿里云短信发送失败,手机号: {}, Code: {}, Message: {}", mobile, code, message);
} else {
log.info("阿里云短信发送成功,手机号: {}", mobile);
return SmsResult.fail(message);
}
return success;
log.info("阿里云短信发送成功,手机号: {}", mobile);
return SmsResult.ok();
} catch (ClientException e) {
log.error("阿里云短信发送异常,手机号: {}, 错误信息: {}", mobile, e.getMessage(), e);
return SmsResult.fail(e.getMessage());
}
return false;
}
}

View File

@@ -136,6 +136,7 @@ security:
- /api/v1/logs/** # 日志接口(访问日志列表)
- /api/v1/auth/qr-code/** # 扫码登录接口(生成票据/查询状态/换取令牌)
- /api/v1/sn/** # 移动设备专用接口(通过设备签名验证)
- /api/v1/open/** # C端客户端(open)接口(注册/登录/短信/刷新令牌等,落地 app_user)
- /static/** # 静态资源(应用图标等img 标签无法携带 token文件名为内容MD5不可枚举)
# 非安全端点路径,完全绕过 Spring Security 的过滤器
unsecured-urls:
@@ -196,6 +197,8 @@ sms:
login: SMS_506225577
# 修改手机号短信验证码模板
change-mobile: SMS_506225577
# 重置密码短信验证码模板
reset-password: SMS_506225577
tencent:
secretId: AKIDJXDqJk2963sUuAE7oIsQtAD4jANNBmCG
@@ -207,6 +210,7 @@ sms:
register: "2510826"
login: "2496464"
change-mobile: "1234569"
reset-password: "1234569"
# springdoc 配置文档: https://springdoc.org/properties.html
springdoc:

View File

@@ -130,6 +130,7 @@ security:
- /api/v1/logs/** # 日志接口(访问日志列表)
- /api/v1/auth/qr-code/** # 扫码登录接口(生成票据/查询状态/换取令牌)
- /api/v1/sn/** # 移动设备专用接口(通过设备签名验证)
- /api/v1/open/** # C端客户端(open)接口(注册/登录/短信/刷新令牌等,落地 app_user)
- /static/** # 静态资源(应用图标等img 标签无法携带 token文件名为内容MD5不可枚举)
# 非安全端点路径,完全绕过 Spring Security 的过滤器
unsecured-urls:
@@ -189,6 +190,8 @@ sms:
login: SMS_22xxx772
# 修改手机号短信验证码模板
change-mobile: SMS_22xxx773
# 重置密码短信验证码模板
reset-password: SMS_22xxx774
# springdoc 配置文档: https://springdoc.org/properties.html
springdoc: