feat(auth): 实现基于阿里云短信验证码的登录注册功能

- 新增阿里云短信发送客户端配置及属性绑定类
- 集成阿里云短信服务实现验证码发送功能
- 基于 Sa-Token 完成登录状态管理和 token 生成
- 实现手机号验证码登录、密码登录及验证码注册支持
- 添加密码加密 Bean,使用 BCrypt 保障密码安全
- 新增 Redis 缓存验证码,实现验证码有效期和校验
- Vue 前端新增登录弹窗组件,支持三种登录模式切换
- 统一 Axios 请求添加 Token 请求头及响应错误提示
- 更新配置文件,加入 Sa-Token 相关配置项
- 调整后端数据库实体生成配置,新增用户表映射
- 添加前端依赖包 @vueuse/integrations 和 universal-cookie
- 新增前端 Cookie 操作逻辑,用于 Token 的存取管理
- 优化 Header 组件,增加 Login 按钮触发登录弹窗
This commit is contained in:
lbw
2025-12-22 17:13:04 +08:00
parent 515bd8fae2
commit f4498e5676
24 changed files with 951 additions and 21 deletions

View File

@@ -0,0 +1,13 @@
package com.yinlihupo.enlish.service.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@ConfigurationProperties(prefix = "aliyun")
@Component
@Data
public class AliyunAccessKeyProperties {
private String accessKeyId;
private String accessKeySecret;
}

View File

@@ -0,0 +1,36 @@
package com.yinlihupo.enlish.service.config;
import com.aliyun.dysmsapi20170525.Client;
import com.aliyun.teaopenapi.models.Config;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
@Slf4j
public class AliyunSmsClientConfig {
@Resource
private AliyunAccessKeyProperties aliyunAccessKeyProperties;
@Bean
public Client smsClient() {
try {
Config config = new Config()
// 必填
.setAccessKeyId(aliyunAccessKeyProperties.getAccessKeyId())
// 必填
.setAccessKeySecret(aliyunAccessKeyProperties.getAccessKeySecret());
// Endpoint 请参考 https://api.aliyun.com/product/Dysmsapi
config.endpoint = "dysmsapi.aliyuncs.com";
return new Client(config);
} catch (Exception e) {
log.error("初始化阿里云短信发送客户端错误: ", e);
return null;
}
}
}

View File

@@ -0,0 +1,19 @@
package com.yinlihupo.enlish.service.config;
import org.springframework.context.annotation.Bean;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
@Component
public class PasswordEncoderConfig {
@Bean
public PasswordEncoder passwordEncoder() {
// BCrypt 是一种安全且适合密码存储的哈希算法,它在进行哈希时会自动加入“盐”,增加密码的安全性。
return new BCryptPasswordEncoder();
}
}

View File

@@ -0,0 +1,10 @@
package com.yinlihupo.enlish.service.constant;
public class UserRedisConstants {
public static final String USER_LOGIN_CODE = "user:login:code:";
public static String buildUserLoginCode(String phone) {
return USER_LOGIN_CODE + phone;
}
}

View File

@@ -0,0 +1,48 @@
package com.yinlihupo.enlish.service.controller;
import cn.dev33.satoken.stp.StpUtil;
import com.yinlihupo.enlish.service.model.vo.login.LoginReqVO;
import com.yinlihupo.enlish.service.model.vo.login.VerificationCodeReqVO;
import com.yinlihupo.enlish.service.service.LoginService;
import com.yinlihupo.framework.biz.operationlog.aspect.ApiOperationLog;
import com.yinlihupo.framework.common.response.Response;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RequestMapping("/login/")
@RestController
@Slf4j
public class LoginController {
@Resource
private LoginService loginService;
@PostMapping("login")
@ApiOperationLog(description = "登录")
public Response<String> login(@RequestBody LoginReqVO loginReqVO) {
try {
loginService.login(loginReqVO.getPhone(), loginReqVO.getName(), loginReqVO.getPassword(), loginReqVO.getCode());
return Response.success(StpUtil.getTokenInfo().getTokenValue());
} catch (Exception e) {
log.error("注册或登录失败 {}", e.getMessage());
return Response.fail("注册或登录失败 " + e.getMessage());
}
}
@PostMapping("sendVerificationCode")
@ApiOperationLog(description = "发送验证码")
public Response<Void> sendVerificationCode(@RequestBody VerificationCodeReqVO verificationCodeReqVO) {
try {
loginService.sendVerificationCode(verificationCodeReqVO.getPhone());
return Response.success();
} catch (Exception e) {
log.error("发送验证码失败 {}", e.getMessage());
return Response.fail("发送验证码失败 " + e.getMessage());
}
}
}

View File

@@ -0,0 +1,29 @@
package com.yinlihupo.enlish.service.domain.dataobject;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@AllArgsConstructor
@NoArgsConstructor
@Data
@Builder
public class UserDO {
private Long id;
private String password;
private String name;
private String openid;
private String phone;
private String email;
private Integer status;
private Integer isDeleted;
}

View File

@@ -0,0 +1,10 @@
package com.yinlihupo.enlish.service.domain.mapper;
import com.yinlihupo.enlish.service.domain.dataobject.UserDO;
public interface UserDOMapper {
UserDO selectByPhone(String phone);
void insert(UserDO userDO);
}

View File

@@ -0,0 +1,18 @@
package com.yinlihupo.enlish.service.model.vo.login;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@AllArgsConstructor
@NoArgsConstructor
@Data
@Builder
public class LoginReqVO {
private String phone;
private String name;
private String password;
private String code;
}

View File

@@ -0,0 +1,15 @@
package com.yinlihupo.enlish.service.model.vo.login;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@AllArgsConstructor
@NoArgsConstructor
@Data
@Builder
public class VerificationCodeReqVO {
private String phone;
}

View File

@@ -0,0 +1,8 @@
package com.yinlihupo.enlish.service.service;
public interface LoginService {
void login(String phone, String name, String reqPassword, String reqCode);
void sendVerificationCode(String phone);
}

View File

@@ -0,0 +1,108 @@
package com.yinlihupo.enlish.service.service.login;
import cn.dev33.satoken.stp.StpUtil;
import cn.hutool.core.util.RandomUtil;
import com.aliyun.dysmsapi20170525.Client;
import com.aliyun.dysmsapi20170525.models.SendSmsRequest;
import com.aliyun.dysmsapi20170525.models.SendSmsResponse;
import com.aliyun.teautil.models.RuntimeOptions;
import com.yinlihupo.enlish.service.constant.UserRedisConstants;
import com.yinlihupo.enlish.service.domain.dataobject.UserDO;
import com.yinlihupo.enlish.service.domain.mapper.UserDOMapper;
import com.yinlihupo.enlish.service.service.LoginService;
import com.yinlihupo.framework.common.util.JsonUtils;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.time.Duration;
@Service
@Slf4j
public class LoginServiceImpl implements LoginService {
@Resource
private UserDOMapper userDOMapper;
@Resource
private PasswordEncoder passwordEncoder;
@Resource
private StringRedisTemplate stringRedisTemplate;
@Resource
private Client client;
@Override
public void login(String phone, String name, String reqPassword, String reqCode) {
UserDO userDO = userDOMapper.selectByPhone(phone);
log.info("userDO:{}", userDO);
String code = stringRedisTemplate.opsForValue().get(UserRedisConstants.buildUserLoginCode(phone));
if (userDO == null) {
if (code == null || !code.equals(reqCode)) {
throw new RuntimeException("验证码错误");
}
userDO = UserDO.builder()
.phone(phone)
.name(name)
.password(passwordEncoder.encode(reqPassword))
.build();
userDOMapper.insert(userDO);
StpUtil.login(userDO.getId());
return;
}
if (code != null && code.equals(reqCode)) {
StpUtil.login(userDO.getId());
return;
}
if (reqPassword != null && passwordEncoder.matches(reqPassword, userDO.getPassword())) {
StpUtil.login(userDO.getId());
throw new RuntimeException("密码错误");
}
throw new RuntimeException("登录错误");
}
@Override
public void sendVerificationCode(String phone) {
String code = RandomUtil.randomNumbers(6);
stringRedisTemplate.opsForValue().set(UserRedisConstants.buildUserLoginCode(phone), code, Duration.ofSeconds(60));
String signName = "短信测试";
String templateCode = "SMS_154950909";
String templateParam = String.format("{\"code\":\"%s\"}", code);
try {
sendMessage(phone, signName, templateCode, templateParam);
} catch (Exception e) {
log.error("==> 短信发送失败, phone: {}, signName: {}, templateCode: {}, templateParam: {}", phone, signName, templateCode, templateParam);
throw new RuntimeException(e);
}
}
private void initUserRole(UserDO userDO) {
// todo
}
/**
* 发送短信
*/
public void sendMessage(String signName, String templateCode, String phone, String templateParam) throws Exception {
SendSmsRequest sendSmsRequest = new SendSmsRequest()
.setSignName(signName)
.setTemplateCode(templateCode)
.setPhoneNumbers(phone)
.setTemplateParam(templateParam);
RuntimeOptions runtime = new RuntimeOptions();
log.info("==> 开始短信发送, phone: {}, signName: {}, templateCode: {}, templateParam: {}", phone, signName, templateCode, templateParam);
// 发送短信
SendSmsResponse response = client.sendSmsWithOptions(sendSmsRequest, runtime);
log.info("==> 短信发送成功, response: {}", JsonUtils.toJsonString(response));
}
}

View File

@@ -34,4 +34,8 @@ tmp:
ai:
key: app-loC6IrJpj4cS54MAYp73QtGl
url: https://chat.cosonggle.com/v1/chat-messages
url: https://chat.cosonggle.com/v1/chat-messages
aliyun:
accessKeyId:
accessKeySecret:

View File

@@ -7,4 +7,23 @@ spring:
mybatis:
# MyBatis xml 配置文件路径
mapper-locations: classpath:/mapper/**/*.xml
mapper-locations: classpath:/mapper/**/*.xml
############## Sa-Token 配置 (文档: https://sa-token.cc) ##############
sa-token:
# token 名称(同时也是 cookie 名称)
token-name: Authorization
# token 前缀
token-prefix: Bearer
# token 风格默认可取值uuid、simple-uuid、random-32、random-64、random-128、tik
token-style: random-128
# token 有效期(单位:秒) 默认30天-1 代表永久有效
timeout: 2592000
# token 最低活跃频率(单位:秒),如果 token 超过此时间没有访问系统就会被冻结,默认-1 代表不限制,永不冻结
active-timeout: -1
# 是否允许同一账号多地同时登录 (为 true 时允许一起登录, 为 false 时新登录挤掉旧登录)
is-concurrent: true
# 在多人登录同一账号时,是否共用一个 token (为 true 时所有登录共用一个 token, 为 false 时每次登录新建一个 token
is-share: true
# 是否输出操作日志
is-log: true

View File

@@ -45,7 +45,7 @@
targetProject="src/main/java"/>
<!-- 需要生成的表-实体类 -->
<table tableName="student_lesson_plans" domainObjectName="StudentLessonPlansDO"
<table tableName="user" domainObjectName="UserDO"
enableCountByExample="false"
enableUpdateByExample="false"
enableDeleteByExample="false"

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.yinlihupo.enlish.service.domain.mapper.UserDOMapper">
<resultMap id="BaseResultMap" type="com.yinlihupo.enlish.service.domain.dataobject.UserDO">
<id column="id" jdbcType="BIGINT" property="id" />
<result column="password" jdbcType="VARCHAR" property="password" />
<result column="name" jdbcType="VARCHAR" property="name" />
<result column="openid" jdbcType="VARCHAR" property="openid" />
<result column="phone" jdbcType="VARCHAR" property="phone" />
<result column="email" jdbcType="VARCHAR" property="email" />
<result column="status" jdbcType="INTEGER" property="status" />
<result column="is_deleted" jdbcType="INTEGER" property="isDeleted" />
</resultMap>
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
insert into user (password, phone, name)
values (#{password}, #{phone}, #{name})
</insert>
<select id="selectByPhone" resultMap="BaseResultMap">
select *
from user
where phone = #{phone}
and is_deleted = 0
</select>
</mapper>