From 7687ef4f3740fec388505377a279fffc53ee353d Mon Sep 17 00:00:00 2001 From: TongTongStudio Date: Mon, 17 Aug 2026 09:29:42 +0800 Subject: [PATCH] =?UTF-8?q?feat(open):=20=E9=87=8D=E6=9E=84C=E7=AB=AF?= =?UTF-8?q?=E8=AE=A4=E8=AF=81=E5=B9=B6=E6=94=AF=E6=8C=81=E5=AF=86=E7=A0=81?= =?UTF-8?q?=E9=87=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sql/app_user_fix.sql | 72 +++++ sql/youlai-admin.sql | 2 +- .../boot/client/model/entity/ClientUser.java | 28 ++ .../youlai/boot/common/aspect/LogAspect.java | 2 +- .../boot/common/constant/RedisConstants.java | 4 +- .../open/controller/OpenAuthController.java | 78 +++-- .../boot/open/mapper/ClientUserMapper.java | 11 + .../open/model/req/OpenPasswordLoginReq.java | 22 ++ .../boot/open/model/req/OpenRegisterReq.java | 32 -- .../open/model/req/OpenResetPasswordReq.java | 31 ++ .../boot/open/service/OpenAuthService.java | 39 ++- .../service/impl/OpenAuthServiceImpl.java | 295 +++++++++++------- .../youlai/boot/support/sms/SmsResult.java | 33 ++ .../youlai/boot/support/sms/SmsService.java | 10 + .../youlai/boot/support/sms/SmsTypeEnum.java | 7 +- .../sms/impl/AliyunSmsServiceImpl.java | 14 +- src/main/resources/application-dev.yml | 4 + src/main/resources/application-prod.yml | 3 + 18 files changed, 500 insertions(+), 187 deletions(-) create mode 100644 sql/app_user_fix.sql create mode 100644 src/main/java/com/youlai/boot/open/model/req/OpenPasswordLoginReq.java create mode 100644 src/main/java/com/youlai/boot/open/model/req/OpenResetPasswordReq.java create mode 100644 src/main/java/com/youlai/boot/support/sms/SmsResult.java diff --git a/sql/app_user_fix.sql b/sql/app_user_fix.sql new file mode 100644 index 00000000..2d6ea901 --- /dev/null +++ b/sql/app_user_fix.sql @@ -0,0 +1,72 @@ +-- ============================================================ +-- app_user 表结构修复与字段扩展 +-- 用途:支持手机号 + 微信小程序登录,补充常用用户字段 +-- ============================================================ + +-- 通用 helper:列不存在时才添加 +DROP PROCEDURE IF EXISTS add_app_user_column; +DELIMITER $$ +CREATE PROCEDURE add_app_user_column( + IN col_name VARCHAR(64), + IN col_def VARCHAR(256) +) +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'app_user' + AND COLUMN_NAME = col_name + ) THEN + SET @sql = CONCAT('ALTER TABLE app_user ADD COLUMN ', col_name, ' ', col_def); + PREPARE stmt FROM @sql; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END IF; +END$$ +DELIMITER ; + +-- 1. 补充原缺失的 username 列(注册/认证需要) +CALL add_app_user_column('username', 'VARCHAR(64) COMMENT ''用户名'' AFTER id'); + +-- 2. 补充 BaseEntity 审计/逻辑删除列(MyBatis-Plus 自动填充与逻辑删除依赖) +CALL add_app_user_column('is_deleted', 'TINYINT NOT NULL DEFAULT 0 COMMENT ''逻辑删除(0-否 1-是)'' AFTER status'); +CALL add_app_user_column('create_time', 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''创建时间'' AFTER is_deleted'); +CALL add_app_user_column('update_time', 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT ''更新时间'' AFTER create_time'); +CALL add_app_user_column('create_by', 'BIGINT DEFAULT NULL COMMENT ''创建人'' AFTER update_time'); +CALL add_app_user_column('update_by', 'BIGINT DEFAULT NULL COMMENT ''更新人'' AFTER create_by'); + +-- 3. 微信小程序登录相关字段 +CALL add_app_user_column('unionid', 'VARCHAR(64) DEFAULT NULL COMMENT ''微信unionid'' AFTER wechat_openid'); +CALL add_app_user_column('session_key', 'VARCHAR(128) DEFAULT NULL COMMENT ''微信小程序session_key'' AFTER unionid'); +CALL add_app_user_column('register_source', 'TINYINT DEFAULT 1 COMMENT ''注册来源(1-手机号 2-微信小程序)'' AFTER session_key'); + +-- 4. 常用用户字段 +CALL add_app_user_column('birthday', 'DATE DEFAULT NULL COMMENT ''生日'' AFTER register_source'); +CALL add_app_user_column('last_login_time', 'DATETIME DEFAULT NULL COMMENT ''最后登录时间'' AFTER birthday'); + +DROP PROCEDURE IF EXISTS add_app_user_column; + +-- 5. 删除多余的 user_id 列 +-- 实体 ClientUser 主键为 BaseEntity.id(列名 id),代码中 userId 均由 "id AS userId" 映射而来, +-- 表内遗留的 user_id 列(NOT NULL 无默认值)导致 INSERT 未写该列时报 "Field 'user_id' doesn't have a default value"。 +-- 该列未被任何代码引用,直接删除以消除冗余。 +SET @col_exists = ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'app_user' + AND COLUMN_NAME = 'user_id' +); +SET @sql = IF( + @col_exists > 0, + 'ALTER TABLE app_user DROP COLUMN user_id', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 6. 扩大 password 列,BCrypt 加密后固定 60 字符,原 VARCHAR(32) 会触发 Data too long +ALTER TABLE app_user MODIFY COLUMN password VARCHAR(100) NOT NULL COMMENT '密码(加密)'; + +-- 7. 扩大 sys_log.error_msg,避免长异常信息被截断 +ALTER TABLE sys_log MODIFY COLUMN error_msg VARCHAR(2000) COMMENT '错误信息'; diff --git a/sql/youlai-admin.sql b/sql/youlai-admin.sql index e9dbce87..f8f0da5a 100644 --- a/sql/youlai-admin.sql +++ b/sql/youlai-admin.sql @@ -423,7 +423,7 @@ CREATE TABLE `sys_log` ( `os` VARCHAR(100) COMMENT '操作系统', `browser` VARCHAR(100) COMMENT '浏览器', `status` TINYINT DEFAULT 1 COMMENT '0失败 1成功', - `error_msg` VARCHAR(1000) COMMENT '错误信息', + `error_msg` VARCHAR(2000) COMMENT '错误信息', `execution_time` INT COMMENT '执行时间(ms)', `create_time` DATETIME COMMENT '操作时间', PRIMARY KEY (`id`) USING BTREE, diff --git a/src/main/java/com/youlai/boot/client/model/entity/ClientUser.java b/src/main/java/com/youlai/boot/client/model/entity/ClientUser.java index c47cec8f..71757344 100644 --- a/src/main/java/com/youlai/boot/client/model/entity/ClientUser.java +++ b/src/main/java/com/youlai/boot/client/model/entity/ClientUser.java @@ -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-禁用) */ diff --git a/src/main/java/com/youlai/boot/common/aspect/LogAspect.java b/src/main/java/com/youlai/boot/common/aspect/LogAspect.java index cb1bb990..270e08c6 100644 --- a/src/main/java/com/youlai/boot/common/aspect/LogAspect.java +++ b/src/main/java/com/youlai/boot/common/aspect/LogAspect.java @@ -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; diff --git a/src/main/java/com/youlai/boot/common/constant/RedisConstants.java b/src/main/java/com/youlai/boot/common/constant/RedisConstants.java index 9f856858..f098d788 100644 --- a/src/main/java/com/youlai/boot/common/constant/RedisConstants.java +++ b/src/main/java/com/youlai/boot/common/constant/RedisConstants.java @@ -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:{}"; // 邮箱验证码 } diff --git a/src/main/java/com/youlai/boot/open/controller/OpenAuthController.java b/src/main/java/com/youlai/boot/open/controller/OpenAuthController.java index f69067ce..52a4a7b9 100644 --- a/src/main/java/com/youlai/boot/open/controller/OpenAuthController.java +++ b/src/main/java/com/youlai/boot/open/controller/OpenAuthController.java @@ -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 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 sendRegisterSmsCode( + @Operation(summary = "发送短信验证码(登录/注册通用)") + @PostMapping("/sms/code") + public Result 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 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 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 sendLoginSmsCode( + @Parameter(description = "手机号", example = "18888888888") @RequestParam String mobile + ) { + openAuthService.sendLoginSmsCode(mobile); + return Result.success(); + } + + @Operation(summary = "发送重置密码短信验证码") + @PostMapping("/reset-password/sms/code") + public Result 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 resetPassword(@RequestBody @Valid OpenResetPasswordReq request) { + openAuthService.resetPassword(request.getMobile(), request.getCode(), request.getPassword()); + return Result.success(); + } + + @Operation(summary = "刷新令牌") + @PostMapping("/refresh-token") + public Result refreshToken( + @Parameter(description = "刷新令牌") @RequestParam String refreshToken + ) { + return Result.success(openAuthService.refreshToken(refreshToken)); + } + + @Operation(summary = "退出登录") + @PostMapping("/logout") + public Result logout( + @Parameter(description = "访问令牌(可选,Bearer 前缀可省略)", example = "eyJhbGciOiJIUzI1NiJ9...") + @RequestParam(required = false) String accessToken + ) { + openAuthService.logout(accessToken); + return Result.success(); + } + } diff --git a/src/main/java/com/youlai/boot/open/mapper/ClientUserMapper.java b/src/main/java/com/youlai/boot/open/mapper/ClientUserMapper.java index 1bec2aa9..b40c8ae3 100644 --- a/src/main/java/com/youlai/boot/open/mapper/ClientUserMapper.java +++ b/src/main/java/com/youlai/boot/open/mapper/ClientUserMapper.java @@ -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 { * @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 { * @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) { diff --git a/src/main/java/com/youlai/boot/open/model/req/OpenPasswordLoginReq.java b/src/main/java/com/youlai/boot/open/model/req/OpenPasswordLoginReq.java new file mode 100644 index 00000000..6e93798f --- /dev/null +++ b/src/main/java/com/youlai/boot/open/model/req/OpenPasswordLoginReq.java @@ -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; +} diff --git a/src/main/java/com/youlai/boot/open/model/req/OpenRegisterReq.java b/src/main/java/com/youlai/boot/open/model/req/OpenRegisterReq.java index b8495e76..e69de29b 100644 --- a/src/main/java/com/youlai/boot/open/model/req/OpenRegisterReq.java +++ b/src/main/java/com/youlai/boot/open/model/req/OpenRegisterReq.java @@ -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; -} diff --git a/src/main/java/com/youlai/boot/open/model/req/OpenResetPasswordReq.java b/src/main/java/com/youlai/boot/open/model/req/OpenResetPasswordReq.java new file mode 100644 index 00000000..518d0e70 --- /dev/null +++ b/src/main/java/com/youlai/boot/open/model/req/OpenResetPasswordReq.java @@ -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; +} diff --git a/src/main/java/com/youlai/boot/open/service/OpenAuthService.java b/src/main/java/com/youlai/boot/open/service/OpenAuthService.java index 9ff9f493..78763aa6 100644 --- a/src/main/java/com/youlai/boot/open/service/OpenAuthService.java +++ b/src/main/java/com/youlai/boot/open/service/OpenAuthService.java @@ -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); /** * 刷新令牌 diff --git a/src/main/java/com/youlai/boot/open/service/impl/OpenAuthServiceImpl.java b/src/main/java/com/youlai/boot/open/service/impl/OpenAuthServiceImpl.java index e23d38ba..d1916bff 100644 --- a/src/main/java/com/youlai/boot/open/service/impl/OpenAuthServiceImpl.java +++ b/src/main/java/com/youlai/boot/open/service/impl/OpenAuthServiceImpl.java @@ -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; /** - * 认证服务实现类 + * 开放式客户端认证服务实现类 * + *

所有 C 端用户(client / app_user)登录均在此模块内完成认证与令牌签发, + * 不依赖后台管理端(sys_user)的全局认证链,实现账号体系分域隔离。

+ * + * @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 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() - .eq(ClientUser::getMobile, mobile)); - if (count > 0) { - throw new BusinessException("该手机号已被注册"); - } - + public void sendLoginSmsCode(String mobile) { + // 生成 6 位数字验证码 String code = CodeGeneratorUtil.generateNumericCode(6); - // 发送短信验证码 Map 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()) { + // 缓存验证码至 Redis,5 分钟内有效,用于登录校验 + 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() .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 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() + .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 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); + } + + /** + * 将短信供应商返回的错误信息转换为用户友好提示。 + *

重点识别流控、触发频次等常见限制,无法识别时透传原始信息。

+ */ + 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; + } } diff --git a/src/main/java/com/youlai/boot/support/sms/SmsResult.java b/src/main/java/com/youlai/boot/support/sms/SmsResult.java new file mode 100644 index 00000000..0accd6a2 --- /dev/null +++ b/src/main/java/com/youlai/boot/support/sms/SmsResult.java @@ -0,0 +1,33 @@ +package com.youlai.boot.support.sms; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 短信发送结果 + *

携带发送是否成功以及供应商返回的具体错误信息,便于上层透传给调用方。

+ * + * @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); + } +} diff --git a/src/main/java/com/youlai/boot/support/sms/SmsService.java b/src/main/java/com/youlai/boot/support/sms/SmsService.java index 1f229f03..9e8b2d52 100644 --- a/src/main/java/com/youlai/boot/support/sms/SmsService.java +++ b/src/main/java/com/youlai/boot/support/sms/SmsService.java @@ -22,4 +22,14 @@ public interface SmsService { */ boolean send(String mobile, SmsTypeEnum smsType, Map templateParams); + /** + * 发送短信并返回带错误信息的结果。 + * + * @param mobile 手机号 + * @param smsType 短信类型,对应 {@code sms.templates.*} 配置中的模板 + * @param templateParams 模板参数,用于替换短信模板中的变量 + * @return 发送结果(包含成功状态与供应商返回的具体信息) + */ + SmsResult sendWithResult(String mobile, SmsTypeEnum smsType, Map templateParams); + } diff --git a/src/main/java/com/youlai/boot/support/sms/SmsTypeEnum.java b/src/main/java/com/youlai/boot/support/sms/SmsTypeEnum.java index 55154ffc..23407809 100644 --- a/src/main/java/com/youlai/boot/support/sms/SmsTypeEnum.java +++ b/src/main/java/com/youlai/boot/support/sms/SmsTypeEnum.java @@ -27,7 +27,12 @@ public enum SmsTypeEnum implements IBaseEnum { /** * 修改手机号短信验证码 */ - CHANGE_MOBILE("change-mobile", "修改手机号短信验证码"); + CHANGE_MOBILE("change-mobile", "修改手机号短信验证码"), + + /** + * 重置密码短信验证码 + */ + RESET_PASSWORD("reset-password", "重置密码短信验证码"); private final String value; private final String label; diff --git a/src/main/java/com/youlai/boot/support/sms/impl/AliyunSmsServiceImpl.java b/src/main/java/com/youlai/boot/support/sms/impl/AliyunSmsServiceImpl.java index 376671b5..004a27a2 100644 --- a/src/main/java/com/youlai/boot/support/sms/impl/AliyunSmsServiceImpl.java +++ b/src/main/java/com/youlai/boot/support/sms/impl/AliyunSmsServiceImpl.java @@ -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 templateParams) { + return sendWithResult(mobile, smsType, templateParams).isSuccess(); + } + + @Override + public SmsResult sendWithResult(String mobile, SmsTypeEnum smsType, Map 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; } } diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 3115e336..e5348b8e 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -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: diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index a9d319c9..fe41909e 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -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: