登录注册、手机号、邮箱
This commit is contained in:
@@ -50,6 +50,39 @@ spring:
|
||||
# 关闭超时时间
|
||||
shutdown-timeout: 100ms
|
||||
|
||||
# 邮件配置
|
||||
mail:
|
||||
host: smtp.qq.com
|
||||
port: 587
|
||||
username: 3223905473@qq.com
|
||||
password: xmdmxvtjumxocicc
|
||||
default-encoding: UTF-8
|
||||
properties:
|
||||
mail:
|
||||
smtp:
|
||||
auth: true
|
||||
starttls:
|
||||
enable: true
|
||||
required: true
|
||||
|
||||
# 短信服务配置(支持多种服务商)
|
||||
sms:
|
||||
# 是否启用短信服务(true: 真实发送, false: 模拟模式)
|
||||
# 开发环境建议设置为 false,避免浪费短信费用
|
||||
enabled: false
|
||||
# 短信服务商(aliyun: 阿里云, tencent: 腾讯云)
|
||||
provider: aliyun
|
||||
# AccessKey ID(从服务商控制台获取)
|
||||
access-key-id: LTAI5t68do3qVXx5Rufugt3X
|
||||
# AccessKey Secret(从服务商控制台获取)
|
||||
access-key-secret: 2vD9ToIff49Vph4JQXsn0Cy8nXQfzA
|
||||
# 短信签名名称(需要在服务商平台申请)
|
||||
sign-name: 星洋智慧
|
||||
# 验证码模板CODE(需要在服务商平台申请)
|
||||
template-code: SMS_491985030
|
||||
# 区域ID(阿里云默认:cn-hangzhou,腾讯云默认:ap-guangzhou)
|
||||
region-id: cn-hangzhou
|
||||
endpoint: dysmsapi.aliyuncs.com
|
||||
# 认证配置
|
||||
school-news:
|
||||
auth:
|
||||
@@ -65,7 +98,10 @@ school-news:
|
||||
white-list:
|
||||
- "/auth/login"
|
||||
- "/auth/logout"
|
||||
- "/auth/register"
|
||||
- "/auth/captcha"
|
||||
- "/auth/send-sms-code"
|
||||
- "/auth/send-email-code"
|
||||
- "/auth/health"
|
||||
- "/actuator/**"
|
||||
- "/swagger-ui/**"
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
package org.xyzh.auth.controller;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.xyzh.api.auth.login.LoginService;
|
||||
import org.xyzh.api.system.user.UserService;
|
||||
import org.xyzh.common.core.domain.LoginParam;
|
||||
import org.xyzh.common.core.domain.LoginDomain;
|
||||
import org.xyzh.common.core.domain.ResultDomain;
|
||||
import org.xyzh.common.dto.user.TbSysUser;
|
||||
import org.xyzh.common.utils.IDUtils;
|
||||
import org.xyzh.common.utils.EmailUtils;
|
||||
import org.xyzh.common.utils.SmsUtils;
|
||||
import org.xyzh.common.redis.service.RedisService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* @description AuthController.java文件描述 认证控制器
|
||||
@@ -21,9 +31,25 @@ import jakarta.servlet.http.HttpServletRequest;
|
||||
@RequestMapping("/auth")
|
||||
public class AuthController {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(AuthController.class);
|
||||
|
||||
@Autowired
|
||||
private LoginService loginService;
|
||||
|
||||
@Autowired
|
||||
private UserService userService;
|
||||
|
||||
@Autowired
|
||||
private EmailUtils emailUtils;
|
||||
|
||||
@Autowired
|
||||
private SmsUtils smsUtils;
|
||||
|
||||
@Autowired
|
||||
private RedisService redisService;
|
||||
|
||||
@Autowired
|
||||
private PasswordEncoder passwordEncoder;
|
||||
/**
|
||||
* @description 用户登录
|
||||
* @param loginParam 登录参数
|
||||
@@ -99,4 +125,360 @@ public class AuthController {
|
||||
result.success("认证服务运行正常", "OK");
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 发送邮箱验证码
|
||||
* @param requestBody 包含email字段的请求体
|
||||
* @return ResultDomain<Boolean> 发送结果
|
||||
* @author yslg
|
||||
* @since 2025-11-03
|
||||
*/
|
||||
@PostMapping("/send-email-code")
|
||||
public ResultDomain<Map<String, String>> sendEmailCode(@RequestBody Map<String, String> requestBody) {
|
||||
ResultDomain<Map<String, String>> result = new ResultDomain<>();
|
||||
|
||||
String email = requestBody.get("email");
|
||||
|
||||
// 验证邮箱格式
|
||||
if (email == null || email.trim().isEmpty()) {
|
||||
result.fail("邮箱不能为空");
|
||||
return result;
|
||||
}
|
||||
|
||||
String emailRegex = "^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$";
|
||||
if (!email.matches(emailRegex)) {
|
||||
result.fail("邮箱格式不正确");
|
||||
return result;
|
||||
}
|
||||
|
||||
// 检查是否频繁发送(60秒内只能发送一次)
|
||||
String rateLimitKey = "email:code:ratelimit:" + email;
|
||||
if (redisService.hasKey(rateLimitKey)) {
|
||||
result.fail("验证码已发送,请勿重复发送");
|
||||
return result;
|
||||
}
|
||||
|
||||
// 生成会话ID(用于绑定验证码和用户)
|
||||
String sessionId = IDUtils.generateID();
|
||||
|
||||
// 生成6位数字验证码
|
||||
String code = EmailUtils.generateVerificationCode();
|
||||
|
||||
// 发送邮件
|
||||
boolean success = emailUtils.sendVerificationCode(email, code);
|
||||
|
||||
if (success) {
|
||||
// 将验证码存储到Redis,绑定sessionId,有效期5分钟
|
||||
String codeKey = "email:code:" + sessionId;
|
||||
String codeValue = email + ":" + code; // 格式:邮箱:验证码
|
||||
redisService.set(codeKey, codeValue, 5, TimeUnit.MINUTES);
|
||||
|
||||
// 设置5分钟的发送频率限制
|
||||
redisService.set(rateLimitKey, "1", 5, TimeUnit.MINUTES);
|
||||
|
||||
// 返回sessionId给前端
|
||||
Map<String, String> data = Map.of(
|
||||
"sessionId", sessionId,
|
||||
"message", "验证码已发送到邮箱"
|
||||
);
|
||||
|
||||
logger.info("邮箱验证码已发送,邮箱: {}, sessionId: {}", email, sessionId);
|
||||
result.success("验证码已发送到邮箱", data);
|
||||
} else {
|
||||
result.fail("验证码发送失败,请稍后重试");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 发送手机验证码
|
||||
* @param requestBody 包含phone字段的请求体
|
||||
* @return ResultDomain<Boolean> 发送结果
|
||||
* @author yslg
|
||||
* @since 2025-11-03
|
||||
*/
|
||||
@PostMapping("/send-sms-code")
|
||||
public ResultDomain<Map<String, String>> sendSmsCode(@RequestBody Map<String, String> requestBody) {
|
||||
ResultDomain<Map<String, String>> result = new ResultDomain<>();
|
||||
|
||||
String phone = requestBody.get("phone");
|
||||
|
||||
// 验证手机号格式
|
||||
if (phone == null || phone.trim().isEmpty()) {
|
||||
result.fail("手机号不能为空");
|
||||
return result;
|
||||
}
|
||||
|
||||
if (!SmsUtils.isValidPhone(phone)) {
|
||||
result.fail("手机号格式不正确");
|
||||
return result;
|
||||
}
|
||||
|
||||
// 检查是否频繁发送(60秒内只能发送一次)
|
||||
String rateLimitKey = "sms:code:ratelimit:" + phone;
|
||||
if (redisService.hasKey(rateLimitKey)) {
|
||||
result.fail("验证码已发送,请勿重复发送");
|
||||
return result;
|
||||
}
|
||||
|
||||
// 生成会话ID(用于绑定验证码和用户)
|
||||
String sessionId = IDUtils.generateID();
|
||||
|
||||
// 生成6位数字验证码
|
||||
String code = SmsUtils.generateVerificationCode();
|
||||
|
||||
// 发送短信
|
||||
boolean success = smsUtils.sendVerificationCode(phone, code);
|
||||
|
||||
if (success) {
|
||||
// 将验证码存储到Redis,绑定sessionId,有效期5分钟
|
||||
String codeKey = "sms:code:" + sessionId;
|
||||
String codeValue = phone + ":" + code; // 格式:手机号:验证码
|
||||
redisService.set(codeKey, codeValue, 5, TimeUnit.MINUTES);
|
||||
|
||||
// 设置5分钟的发送频率限制
|
||||
redisService.set(rateLimitKey, "1", 5, TimeUnit.MINUTES);
|
||||
|
||||
// 返回sessionId给前端
|
||||
Map<String, String> data = Map.of(
|
||||
"sessionId", sessionId,
|
||||
"message", "验证码已发送"
|
||||
);
|
||||
|
||||
logger.info("短信验证码已发送,手机号: {}, sessionId: {}", phone, sessionId);
|
||||
result.success("验证码已发送", data);
|
||||
} else {
|
||||
result.fail("验证码发送失败,请稍后重试");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 用户注册
|
||||
* @param requestBody 注册参数
|
||||
* @return ResultDomain<LoginDomain> 注册结果
|
||||
* @author yslg
|
||||
* @since 2025-11-03
|
||||
*/
|
||||
@PostMapping("/register")
|
||||
public ResultDomain<LoginDomain> register(@RequestBody Map<String, Object> requestBody, HttpServletRequest request) {
|
||||
ResultDomain<LoginDomain> result = new ResultDomain<>();
|
||||
|
||||
try {
|
||||
// 获取注册参数
|
||||
String registerType = (String) requestBody.get("registerType");
|
||||
String username = (String) requestBody.get("username");
|
||||
String phone = (String) requestBody.get("phone");
|
||||
String email = (String) requestBody.get("email");
|
||||
String password = (String) requestBody.get("password");
|
||||
String confirmPassword = (String) requestBody.get("confirmPassword");
|
||||
String smsCode = (String) requestBody.get("smsCode");
|
||||
String emailCode = (String) requestBody.get("emailCode");
|
||||
String smsSessionId = (String) requestBody.get("smsSessionId");
|
||||
String emailSessionId = (String) requestBody.get("emailSessionId");
|
||||
String studentId = (String) requestBody.get("studentId");
|
||||
|
||||
// 1. 参数验证
|
||||
if (password == null || password.trim().isEmpty()) {
|
||||
result.fail("密码不能为空");
|
||||
return result;
|
||||
}
|
||||
|
||||
if (password.length() < 6) {
|
||||
result.fail("密码至少6个字符");
|
||||
return result;
|
||||
}
|
||||
|
||||
if (!password.equals(confirmPassword)) {
|
||||
result.fail("两次输入的密码不一致");
|
||||
return result;
|
||||
}
|
||||
|
||||
// 2. 根据注册类型进行不同的验证
|
||||
TbSysUser user = new TbSysUser();
|
||||
|
||||
switch (registerType) {
|
||||
case "username":
|
||||
// 用户名注册
|
||||
if (username == null || username.trim().isEmpty()) {
|
||||
result.fail("用户名不能为空");
|
||||
return result;
|
||||
}
|
||||
if (username.length() < 3 || username.length() > 20) {
|
||||
result.fail("用户名长度为3-20个字符");
|
||||
return result;
|
||||
}
|
||||
if (!username.matches("^[a-zA-Z0-9_]+$")) {
|
||||
result.fail("用户名只能包含字母、数字和下划线");
|
||||
return result;
|
||||
}
|
||||
user.setUsername(username);
|
||||
logger.info("用户名注册: {}", username);
|
||||
break;
|
||||
|
||||
case "phone":
|
||||
// 手机号注册
|
||||
if (phone == null || phone.trim().isEmpty()) {
|
||||
result.fail("手机号不能为空");
|
||||
return result;
|
||||
}
|
||||
if (!phone.matches("^1[3-9]\\d{9}$")) {
|
||||
result.fail("手机号格式不正确");
|
||||
return result;
|
||||
}
|
||||
if (smsCode == null || smsCode.trim().isEmpty()) {
|
||||
result.fail("请输入手机验证码");
|
||||
return result;
|
||||
}
|
||||
if (smsSessionId == null || smsSessionId.trim().isEmpty()) {
|
||||
result.fail("会话已失效,请重新获取验证码");
|
||||
return result;
|
||||
}
|
||||
|
||||
// 通过sessionId验证手机验证码
|
||||
String smsCodeKey = "sms:code:" + smsSessionId;
|
||||
String storedSmsValue = (String) redisService.get(smsCodeKey);
|
||||
if (storedSmsValue == null) {
|
||||
result.fail("验证码已过期,请重新获取");
|
||||
return result;
|
||||
}
|
||||
|
||||
// 解析存储的值:手机号:验证码
|
||||
String[] smsParts = storedSmsValue.split(":");
|
||||
if (smsParts.length != 2) {
|
||||
result.fail("验证码数据异常");
|
||||
return result;
|
||||
}
|
||||
|
||||
String storedPhone = smsParts[0];
|
||||
String storedSmsCode = smsParts[1];
|
||||
|
||||
// 验证手机号和验证码是否匹配
|
||||
if (!storedPhone.equals(phone)) {
|
||||
result.fail("手机号与验证码不匹配");
|
||||
logger.warn("手机号注册验证失败,提交手机号: {}, 验证码绑定手机号: {}", phone, storedPhone);
|
||||
return result;
|
||||
}
|
||||
if (!storedSmsCode.equals(smsCode)) {
|
||||
result.fail("验证码错误");
|
||||
return result;
|
||||
}
|
||||
|
||||
// 验证码使用后删除
|
||||
redisService.delete(smsCodeKey);
|
||||
|
||||
user.setPhone(phone);
|
||||
user.setUsername(phone); // 使用手机号作为用户名
|
||||
logger.info("手机号注册: {}, sessionId: {}", phone, smsSessionId);
|
||||
break;
|
||||
|
||||
case "email":
|
||||
// 邮箱注册
|
||||
if (email == null || email.trim().isEmpty()) {
|
||||
result.fail("邮箱不能为空");
|
||||
return result;
|
||||
}
|
||||
if (!email.matches("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$")) {
|
||||
result.fail("邮箱格式不正确");
|
||||
return result;
|
||||
}
|
||||
if (emailCode == null || emailCode.trim().isEmpty()) {
|
||||
result.fail("请输入邮箱验证码");
|
||||
return result;
|
||||
}
|
||||
if (emailSessionId == null || emailSessionId.trim().isEmpty()) {
|
||||
result.fail("会话已失效,请重新获取验证码");
|
||||
return result;
|
||||
}
|
||||
|
||||
// 通过sessionId验证邮箱验证码
|
||||
String emailCodeKey = "email:code:" + emailSessionId;
|
||||
String storedEmailValue = (String) redisService.get(emailCodeKey);
|
||||
if (storedEmailValue == null) {
|
||||
result.fail("验证码已过期,请重新获取");
|
||||
return result;
|
||||
}
|
||||
|
||||
// 解析存储的值:邮箱:验证码
|
||||
String[] emailParts = storedEmailValue.split(":");
|
||||
if (emailParts.length != 2) {
|
||||
result.fail("验证码数据异常");
|
||||
return result;
|
||||
}
|
||||
|
||||
String storedEmail = emailParts[0];
|
||||
String storedEmailCode = emailParts[1];
|
||||
|
||||
// 验证邮箱和验证码是否匹配
|
||||
if (!storedEmail.equals(email)) {
|
||||
result.fail("邮箱与验证码不匹配");
|
||||
logger.warn("邮箱注册验证失败,提交邮箱: {}, 验证码绑定邮箱: {}", email, storedEmail);
|
||||
return result;
|
||||
}
|
||||
if (!storedEmailCode.equals(emailCode)) {
|
||||
result.fail("验证码错误");
|
||||
return result;
|
||||
}
|
||||
|
||||
// 验证码使用后删除
|
||||
redisService.delete(emailCodeKey);
|
||||
|
||||
user.setEmail(email);
|
||||
user.setUsername(email.split("@")[0]); // 使用邮箱前缀作为用户名
|
||||
logger.info("邮箱注册: {}, sessionId: {}", email, emailSessionId);
|
||||
break;
|
||||
|
||||
default:
|
||||
result.fail("未知的注册类型");
|
||||
return result;
|
||||
}
|
||||
|
||||
// 3. 密码加密
|
||||
String encryptedPassword = passwordEncoder.encode(password);
|
||||
user.setPassword(encryptedPassword);
|
||||
|
||||
// 4. 设置用户状态为正常
|
||||
user.setStatus(0);
|
||||
|
||||
// 5. 调用UserService注册用户
|
||||
ResultDomain<TbSysUser> registerResult = userService.registerUser(user);
|
||||
|
||||
if (!registerResult.isSuccess()) {
|
||||
result.fail(registerResult.getMessage());
|
||||
return result;
|
||||
}
|
||||
|
||||
logger.info("用户注册成功: {}", user.getUsername());
|
||||
|
||||
// 6. 注册成功后自动登录
|
||||
LoginParam loginParam = new LoginParam();
|
||||
loginParam.setUsername(user.getUsername());
|
||||
loginParam.setPassword(password);
|
||||
loginParam.setLoginType("password");
|
||||
|
||||
if (phone != null && !phone.trim().isEmpty()) {
|
||||
loginParam.setPhone(phone);
|
||||
}
|
||||
if (email != null && !email.trim().isEmpty()) {
|
||||
loginParam.setEmail(email);
|
||||
}
|
||||
|
||||
ResultDomain<LoginDomain> loginResult = loginService.login(loginParam, request);
|
||||
|
||||
if (loginResult.isSuccess()) {
|
||||
result.success("注册成功", loginResult.getData());
|
||||
} else {
|
||||
// 注册成功但登录失败,返回注册成功信息
|
||||
result.success("注册成功,请登录", loginResult.getData());
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("用户注册失败", e);
|
||||
result.fail("注册失败: " + e.getMessage());
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,23 +118,17 @@ public class LoginServiceImpl implements LoginService {
|
||||
}
|
||||
|
||||
if (loginType.equals("password")) {
|
||||
// 验证凭据(密码或验证码)
|
||||
if (!strategy.verifyCredential(loginParam.getPassword(), user.getPassword())) {
|
||||
result.fail("密码错误");
|
||||
logLoginAttempt(loginParam, user, false, loginAttempt, "密码错误");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
else {
|
||||
// 验证凭据(验证码)
|
||||
String storedCaptcha = (String) redisService.get("captcha:" + loginParam.getPhone());
|
||||
if (!strategy.verifyCredential(loginParam.getCaptcha(), storedCaptcha)) {
|
||||
result.fail("验证码错误");
|
||||
logLoginAttempt(loginParam, user, false, loginAttempt, "验证码错误");
|
||||
} else if (loginType.equals("email") || loginType.equals("phone")) {
|
||||
if (!strategy.verifyCaptchaWithSession(loginParam)) {
|
||||
result.fail("验证码错误或已过期");
|
||||
logLoginAttempt(loginParam, user, false, loginAttempt, "验证码验证失败");
|
||||
return result;
|
||||
}
|
||||
// 验证码使用后删除
|
||||
redisService.delete("captcha:" + loginParam.getPhone());
|
||||
}
|
||||
|
||||
// 构建登录域对象
|
||||
@@ -142,9 +136,12 @@ public class LoginServiceImpl implements LoginService {
|
||||
// 生成JWT令牌
|
||||
loginDomain.setToken(jwtTokenUtil.generateToken(loginDomain));
|
||||
|
||||
// 将LoginDomain存储到Redis中
|
||||
// 将LoginDomain存储到Redis中,根据rememberMe设置不同的过期时间
|
||||
String redisKey = "login:token:" + user.getID();
|
||||
redisService.set(redisKey, loginDomain, 24 * 60 * 60, TimeUnit.SECONDS);
|
||||
long expireTime = loginParam.isRememberMe()
|
||||
? 7 * 24 * 60 * 60 // rememberMe: 7天
|
||||
: 24 * 60 * 60; // 不rememberMe: 1天
|
||||
redisService.set(redisKey, loginDomain, expireTime, TimeUnit.SECONDS);
|
||||
|
||||
// 登录成功后清除失败次数并记录成功日志
|
||||
redisService.delete(attemptKey);
|
||||
@@ -280,6 +277,16 @@ public class LoginServiceImpl implements LoginService {
|
||||
if (user != null) {
|
||||
loginLog.setUserID(user.getID());
|
||||
loginLog.setUsername(user.getUsername());
|
||||
}else{
|
||||
if (loginParam.getLoginType().equals("password")) {
|
||||
loginLog.setUsername(loginParam.getUsername());
|
||||
}else if (loginParam.getLoginType().equals("email")) {
|
||||
loginLog.setUsername(loginParam.getEmail());
|
||||
}else if (loginParam.getLoginType().equals("phone")) {
|
||||
loginLog.setUsername(loginParam.getPhone());
|
||||
}else if (loginParam.getLoginType().equals("wechat")) {
|
||||
loginLog.setUsername(loginParam.getWechatID());
|
||||
}
|
||||
}
|
||||
// 注意:实际生产中不应记录密码
|
||||
// loginLog.setPassword(loginParam.getPassword());
|
||||
|
||||
@@ -47,4 +47,16 @@ public interface LoginStrategy {
|
||||
* @since 2025-09-28
|
||||
*/
|
||||
boolean verifyCredential(String inputCredential, String storedCredential);
|
||||
|
||||
/**
|
||||
* @description 验证验证码(从Redis获取并验证SessionID)
|
||||
* @param loginParam 登录参数
|
||||
* @return boolean 是否验证成功
|
||||
* @author yslg
|
||||
* @since 2025-11-03
|
||||
*/
|
||||
default boolean verifyCaptchaWithSession(LoginParam loginParam) {
|
||||
// 默认实现:不支持验证码登录
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,12 @@ package org.xyzh.auth.strategy.impl;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.xyzh.auth.strategy.LoginStrategy;
|
||||
import org.xyzh.common.core.domain.LoginParam;
|
||||
import org.xyzh.common.dto.user.TbSysUser;
|
||||
import org.xyzh.api.system.user.UserService;
|
||||
import org.xyzh.common.redis.service.RedisService;
|
||||
|
||||
/**
|
||||
* @description EmailLoginStrategy.java文件描述 邮箱登录策略
|
||||
@@ -34,23 +32,72 @@ public class EmailLoginStrategy implements LoginStrategy {
|
||||
|
||||
@Override
|
||||
public boolean validate(LoginParam loginParam) {
|
||||
return loginParam.getEmail() != null && !loginParam.getEmail().trim().isEmpty()
|
||||
&& loginParam.getPassword() != null && !loginParam.getPassword().trim().isEmpty();
|
||||
if (loginParam.getEmail() == null || loginParam.getEmail().trim().isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
// 密码登录或验证码登录都可以
|
||||
return (loginParam.getPassword() != null && !loginParam.getPassword().trim().isEmpty())
|
||||
|| (loginParam.getCaptcha() != null && !loginParam.getCaptcha().trim().isEmpty());
|
||||
}
|
||||
|
||||
@Override
|
||||
public TbSysUser findUser(LoginParam loginParam) {
|
||||
TbSysUser filter = new TbSysUser();
|
||||
filter.setEmail(loginParam.getEmail());
|
||||
List<TbSysUser> users = userService.getUserByFilter(filter).getDataList();
|
||||
if(users.isEmpty()) {
|
||||
TbSysUser user = userService.getLoginUser(filter).getData();
|
||||
if(user == null) {
|
||||
return null;
|
||||
}
|
||||
return users.get(0);
|
||||
return user;
|
||||
}
|
||||
|
||||
@Autowired
|
||||
private RedisService redisService;
|
||||
|
||||
@Override
|
||||
public boolean verifyCredential(String inputCredential, String storedCredential) {
|
||||
// 密码验证
|
||||
return passwordEncoder.matches(inputCredential, storedCredential);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean verifyCaptchaWithSession(LoginParam loginParam) {
|
||||
String captchaId = loginParam.getCaptchaId();
|
||||
String inputCaptcha = loginParam.getCaptcha();
|
||||
String email = loginParam.getEmail();
|
||||
|
||||
// 验证参数
|
||||
if (captchaId == null || captchaId.trim().isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
if (inputCaptcha == null || inputCaptcha.trim().isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 从Redis获取验证码
|
||||
String codeKey = "email:code:" + captchaId;
|
||||
String storedValue = (String) redisService.get(codeKey);
|
||||
|
||||
if (storedValue == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 解析存储的值:邮箱:验证码
|
||||
String[] parts = storedValue.split(":");
|
||||
if (parts.length != 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String storedEmail = parts[0];
|
||||
String storedCaptcha = parts[1];
|
||||
|
||||
// 验证邮箱和验证码是否匹配
|
||||
if (!storedEmail.equals(email) || !storedCaptcha.equals(inputCaptcha)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 验证码使用后删除
|
||||
redisService.delete(codeKey);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,9 @@ import org.xyzh.auth.strategy.LoginStrategy;
|
||||
import org.xyzh.common.core.domain.LoginParam;
|
||||
import org.xyzh.common.dto.user.TbSysUser;
|
||||
import org.xyzh.common.utils.NonUtils;
|
||||
import org.xyzh.common.utils.validation.ValidationUtils;
|
||||
import org.xyzh.common.utils.validation.method.EmailValidateMethod;
|
||||
import org.xyzh.common.utils.validation.method.PhoneValidateMethod;
|
||||
import org.xyzh.api.system.user.UserService;
|
||||
|
||||
import java.util.List;
|
||||
@@ -53,15 +56,15 @@ public class PasswordLoginStrategy implements LoginStrategy {
|
||||
@Override
|
||||
public TbSysUser findUser(LoginParam loginParam) {
|
||||
TbSysUser filter = new TbSysUser();
|
||||
if (NonUtils.isNotEmpty(loginParam.getUsername())) {
|
||||
EmailValidateMethod emailValidateMethod = new EmailValidateMethod();
|
||||
PhoneValidateMethod phoneValidateMethod = new PhoneValidateMethod();
|
||||
if(emailValidateMethod.validate(loginParam.getUsername())){
|
||||
filter.setEmail(loginParam.getUsername());
|
||||
}else if (phoneValidateMethod.validate(loginParam.getUsername())){
|
||||
filter.setPhone(loginParam.getUsername());
|
||||
}else{
|
||||
filter.setUsername(loginParam.getUsername());
|
||||
}
|
||||
if (NonUtils.isNotEmpty(loginParam.getEmail())) {
|
||||
filter.setEmail(loginParam.getEmail());
|
||||
}
|
||||
if (NonUtils.isNotEmpty(loginParam.getPhone())) {
|
||||
filter.setPhone(loginParam.getPhone());
|
||||
}
|
||||
filter.setPassword(passwordEncoder.encode(loginParam.getPassword()));
|
||||
TbSysUser user = userService.getLoginUser(filter).getData();
|
||||
if(user == null) {
|
||||
|
||||
@@ -7,8 +7,7 @@ import org.xyzh.auth.strategy.LoginStrategy;
|
||||
import org.xyzh.common.core.domain.LoginParam;
|
||||
import org.xyzh.common.dto.user.TbSysUser;
|
||||
import org.xyzh.api.system.user.UserService;
|
||||
|
||||
import java.util.List;
|
||||
import org.xyzh.common.redis.service.RedisService;
|
||||
|
||||
/**
|
||||
* @description PhoneLoginStrategy.java文件描述 手机号登录策略
|
||||
@@ -33,27 +32,72 @@ public class PhoneLoginStrategy implements LoginStrategy {
|
||||
|
||||
@Override
|
||||
public boolean validate(LoginParam loginParam) {
|
||||
return loginParam.getPhone() != null && !loginParam.getPhone().trim().isEmpty()
|
||||
&& (loginParam.getPassword() != null || loginParam.getCaptcha() != null);
|
||||
if (loginParam.getPhone() == null || loginParam.getPhone().trim().isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
// 密码登录或验证码登录都可以
|
||||
return (loginParam.getPassword() != null && !loginParam.getPassword().trim().isEmpty())
|
||||
|| (loginParam.getCaptcha() != null && !loginParam.getCaptcha().trim().isEmpty());
|
||||
}
|
||||
|
||||
@Override
|
||||
public TbSysUser findUser(LoginParam loginParam) {
|
||||
TbSysUser filter = new TbSysUser();
|
||||
filter.setPhone(loginParam.getPhone());
|
||||
List<TbSysUser> users = userService.getUserByFilter(filter).getDataList();
|
||||
if(users.isEmpty()) {
|
||||
TbSysUser user = userService.getLoginUser(filter).getData();
|
||||
if(user == null) {
|
||||
return null;
|
||||
}
|
||||
return users.get(0);
|
||||
return user;
|
||||
}
|
||||
|
||||
@Autowired
|
||||
private RedisService redisService;
|
||||
|
||||
@Override
|
||||
public boolean verifyCredential(String inputCredential, String storedCredential) {
|
||||
// 手机号登录可能使用验证码,如果有验证码则跳过密码验证
|
||||
if (inputCredential == null) {
|
||||
return true; // 假设验证码已经在其他地方验证过了
|
||||
}
|
||||
// 密码验证
|
||||
return passwordEncoder.matches(inputCredential, storedCredential);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean verifyCaptchaWithSession(LoginParam loginParam) {
|
||||
String captchaId = loginParam.getCaptchaId();
|
||||
String inputCaptcha = loginParam.getCaptcha();
|
||||
String phone = loginParam.getPhone();
|
||||
|
||||
// 验证参数
|
||||
if (captchaId == null || captchaId.trim().isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
if (inputCaptcha == null || inputCaptcha.trim().isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 从Redis获取验证码
|
||||
String codeKey = "sms:code:" + captchaId;
|
||||
String storedValue = (String) redisService.get(codeKey);
|
||||
|
||||
if (storedValue == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 解析存储的值:手机号:验证码
|
||||
String[] parts = storedValue.split(":");
|
||||
if (parts.length != 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String storedPhone = parts[0];
|
||||
String storedCaptcha = parts[1];
|
||||
|
||||
// 验证手机号和验证码是否匹配
|
||||
if (!storedPhone.equals(phone) || !storedCaptcha.equals(inputCaptcha)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 验证码使用后删除
|
||||
redisService.delete(codeKey);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,6 @@ import org.xyzh.common.core.domain.LoginParam;
|
||||
import org.xyzh.common.dto.user.TbSysUser;
|
||||
import org.xyzh.api.system.user.UserService;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* @description UsernameLoginStrategy.java文件描述 用户名登录策略
|
||||
* @filename UsernameLoginStrategy.java
|
||||
@@ -41,11 +39,11 @@ public class UsernameLoginStrategy implements LoginStrategy {
|
||||
public TbSysUser findUser(LoginParam loginParam) {
|
||||
TbSysUser filter = new TbSysUser();
|
||||
filter.setUsername(loginParam.getUsername());
|
||||
List<TbSysUser> users = userService.getUserByFilter(filter).getDataList();
|
||||
if(users.isEmpty()) {
|
||||
TbSysUser user = userService.getLoginUser(filter).getData();
|
||||
if(user == null) {
|
||||
return null;
|
||||
}
|
||||
return users.get(0);
|
||||
return user;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -7,8 +7,6 @@ import org.xyzh.common.core.domain.LoginParam;
|
||||
import org.xyzh.common.dto.user.TbSysUser;
|
||||
import org.xyzh.api.system.user.UserService;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* @description WechatLoginStrategy.java文件描述 微信登录策略
|
||||
* @filename WechatLoginStrategy.java
|
||||
@@ -37,11 +35,11 @@ public class WechatLoginStrategy implements LoginStrategy {
|
||||
public TbSysUser findUser(LoginParam loginParam) {
|
||||
TbSysUser filter = new TbSysUser();
|
||||
filter.setWechatID(loginParam.getWechatID());
|
||||
List<TbSysUser> users = userService.getUserByFilter(filter).getDataList();
|
||||
if(users.isEmpty()) {
|
||||
TbSysUser user = userService.getLoginUser(filter).getData();
|
||||
if(user == null) {
|
||||
return null;
|
||||
}
|
||||
return users.get(0);
|
||||
return user;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
430
schoolNewsServ/auth/注册功能说明.md
Normal file
430
schoolNewsServ/auth/注册功能说明.md
Normal file
@@ -0,0 +1,430 @@
|
||||
# 注册功能说明文档
|
||||
|
||||
## 功能概述
|
||||
|
||||
系统支持三种注册方式:
|
||||
1. **用户名注册** - 直接使用用户名+密码注册
|
||||
2. **手机号注册** - 使用手机号+验证码+密码注册
|
||||
3. **邮箱注册** - 使用邮箱+验证码+密码注册
|
||||
|
||||
## API接口
|
||||
|
||||
### 接口地址
|
||||
```
|
||||
POST /auth/register
|
||||
```
|
||||
|
||||
### 请求参数
|
||||
|
||||
#### 通用参数
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| registerType | String | 是 | 注册类型:username/phone/email |
|
||||
| password | String | 是 | 密码(至少6个字符) |
|
||||
| confirmPassword | String | 是 | 确认密码 |
|
||||
| studentId | String | 否 | 学号 |
|
||||
|
||||
#### 用户名注册 (registerType=username)
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| username | String | 是 | 用户名(3-20字符,仅字母数字下划线) |
|
||||
|
||||
#### 手机号注册 (registerType=phone)
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| phone | String | 是 | 手机号(11位中国手机号) |
|
||||
| smsCode | String | 是 | 手机验证码(6位数字) |
|
||||
|
||||
#### 邮箱注册 (registerType=email)
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| email | String | 是 | 邮箱地址 |
|
||||
| emailCode | String | 是 | 邮箱验证码(6位数字) |
|
||||
|
||||
### 请求示例
|
||||
|
||||
#### 1. 用户名注册
|
||||
```json
|
||||
{
|
||||
"registerType": "username",
|
||||
"username": "zhangsan",
|
||||
"password": "123456",
|
||||
"confirmPassword": "123456",
|
||||
"studentId": "2021001"
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. 手机号注册
|
||||
```json
|
||||
{
|
||||
"registerType": "phone",
|
||||
"phone": "13800138000",
|
||||
"smsCode": "123456",
|
||||
"password": "123456",
|
||||
"confirmPassword": "123456",
|
||||
"studentId": "2021001"
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. 邮箱注册
|
||||
```json
|
||||
{
|
||||
"registerType": "email",
|
||||
"email": "user@example.com",
|
||||
"emailCode": "123456",
|
||||
"password": "123456",
|
||||
"confirmPassword": "123456",
|
||||
"studentId": "2021001"
|
||||
}
|
||||
```
|
||||
|
||||
### 响应结果
|
||||
|
||||
#### 注册成功
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "注册成功",
|
||||
"data": {
|
||||
"user": {
|
||||
"ID": "xxx",
|
||||
"username": "zhangsan",
|
||||
"email": "user@example.com",
|
||||
"phone": "13800138000",
|
||||
"status": 0
|
||||
},
|
||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
"loginTime": "2025-11-03T10:30:00",
|
||||
"ipAddress": "127.0.0.1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 注册失败
|
||||
```json
|
||||
{
|
||||
"code": 500,
|
||||
"message": "用户已存在",
|
||||
"data": null
|
||||
}
|
||||
```
|
||||
|
||||
## 业务流程
|
||||
|
||||
### 1. 用户名注册流程
|
||||
```
|
||||
用户输入用户名和密码
|
||||
↓
|
||||
验证用户名格式(3-20字符,字母数字下划线)
|
||||
↓
|
||||
验证密码(至少6字符,两次输入一致)
|
||||
↓
|
||||
检查用户名是否已存在
|
||||
↓
|
||||
密码加密(BCrypt)
|
||||
↓
|
||||
创建用户账号
|
||||
↓
|
||||
自动登录并返回token
|
||||
```
|
||||
|
||||
### 2. 手机号注册流程
|
||||
```
|
||||
用户输入手机号
|
||||
↓
|
||||
点击"获取验证码"按钮
|
||||
↓
|
||||
系统发送短信验证码(60秒限制)
|
||||
↓
|
||||
用户输入验证码和密码
|
||||
↓
|
||||
验证手机号格式
|
||||
↓
|
||||
验证验证码(10分钟有效期)
|
||||
↓
|
||||
检查手机号是否已注册
|
||||
↓
|
||||
密码加密(BCrypt)
|
||||
↓
|
||||
创建用户账号(用户名=手机号)
|
||||
↓
|
||||
删除验证码(防止重复使用)
|
||||
↓
|
||||
自动登录并返回token
|
||||
```
|
||||
|
||||
### 3. 邮箱注册流程
|
||||
```
|
||||
用户输入邮箱
|
||||
↓
|
||||
点击"获取验证码"按钮
|
||||
↓
|
||||
系统发送邮件验证码(60秒限制)
|
||||
↓
|
||||
用户输入验证码和密码
|
||||
↓
|
||||
验证邮箱格式
|
||||
↓
|
||||
验证验证码(10分钟有效期)
|
||||
↓
|
||||
检查邮箱是否已注册
|
||||
↓
|
||||
密码加密(BCrypt)
|
||||
↓
|
||||
创建用户账号(用户名=邮箱前缀)
|
||||
↓
|
||||
删除验证码(防止重复使用)
|
||||
↓
|
||||
自动登录并返回token
|
||||
```
|
||||
|
||||
## 安全机制
|
||||
|
||||
### 1. 密码加密
|
||||
- 使用 BCryptPasswordEncoder 进行密码加密
|
||||
- 不可逆加密,安全性高
|
||||
- 每次加密结果不同(内置salt)
|
||||
|
||||
### 2. 验证码机制
|
||||
- **有效期**:10分钟
|
||||
- **发送频率**:60秒只能发送一次
|
||||
- **一次性使用**:验证后立即删除
|
||||
- **存储位置**:Redis
|
||||
- 手机验证码:`sms:code:手机号`
|
||||
- 邮箱验证码:`email:code:邮箱`
|
||||
|
||||
### 3. 用户唯一性校验
|
||||
通过UserService.registerUser内部实现:
|
||||
- 检查用户名是否重复
|
||||
- 检查手机号是否重复
|
||||
- 检查邮箱是否重复
|
||||
|
||||
### 4. 注册后自动登录
|
||||
- 注册成功后自动调用登录接口
|
||||
- 返回完整的登录信息(token、用户信息等)
|
||||
- 用户无需再次输入密码
|
||||
|
||||
## 数据库设计
|
||||
|
||||
### 用户表 (tb_sys_user)
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| ID | VARCHAR | 用户ID(主键) |
|
||||
| username | VARCHAR | 用户名 |
|
||||
| password | VARCHAR | 加密后的密码 |
|
||||
| email | VARCHAR | 邮箱 |
|
||||
| phone | VARCHAR | 手机号 |
|
||||
| wechatID | VARCHAR | 微信号 |
|
||||
| status | INT | 用户状态(0-正常,1-禁用,2-锁定) |
|
||||
| createTime | DATETIME | 创建时间 |
|
||||
| updateTime | DATETIME | 更新时间 |
|
||||
| deleted | BOOLEAN | 是否删除 |
|
||||
|
||||
### 用户信息表 (tb_sys_user_info)
|
||||
- 与用户表一对一关系
|
||||
- 存储用户详细信息(头像、性别、姓名等)
|
||||
- 注册时自动创建默认记录
|
||||
|
||||
## 错误码说明
|
||||
|
||||
| 错误码 | 错误信息 | 说明 |
|
||||
|--------|----------|------|
|
||||
| 500 | 密码不能为空 | 密码字段为空 |
|
||||
| 500 | 密码至少6个字符 | 密码长度不足 |
|
||||
| 500 | 两次输入的密码不一致 | 密码和确认密码不匹配 |
|
||||
| 500 | 用户名不能为空 | 用户名注册时用户名为空 |
|
||||
| 500 | 用户名长度为3-20个字符 | 用户名长度不符合要求 |
|
||||
| 500 | 用户名只能包含字母、数字和下划线 | 用户名包含非法字符 |
|
||||
| 500 | 手机号不能为空 | 手机号注册时手机号为空 |
|
||||
| 500 | 手机号格式不正确 | 手机号格式错误 |
|
||||
| 500 | 请输入手机验证码 | 手机验证码为空 |
|
||||
| 500 | 验证码已过期,请重新获取 | 验证码不存在或已过期 |
|
||||
| 500 | 验证码错误 | 验证码不正确 |
|
||||
| 500 | 邮箱不能为空 | 邮箱注册时邮箱为空 |
|
||||
| 500 | 邮箱格式不正确 | 邮箱格式错误 |
|
||||
| 500 | 请输入邮箱验证码 | 邮箱验证码为空 |
|
||||
| 500 | 用户已存在 | 用户名/手机号/邮箱已被注册 |
|
||||
| 500 | 未知的注册类型 | registerType参数错误 |
|
||||
|
||||
## 前端集成示例
|
||||
|
||||
### 1. 用户名注册
|
||||
```typescript
|
||||
import { authApi } from '@/apis/system/auth';
|
||||
|
||||
const handleRegister = async () => {
|
||||
const result = await authApi.register({
|
||||
registerType: 'username',
|
||||
username: 'zhangsan',
|
||||
password: '123456',
|
||||
confirmPassword: '123456',
|
||||
agree: true
|
||||
});
|
||||
|
||||
if (result.code === 200) {
|
||||
// 注册成功,保存token
|
||||
localStorage.setItem('token', result.data.token);
|
||||
// 跳转到首页
|
||||
router.push('/');
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 2. 手机号注册
|
||||
```typescript
|
||||
// 发送验证码
|
||||
const sendSmsCode = async () => {
|
||||
const result = await authApi.sendSmsCode('13800138000');
|
||||
if (result.code === 200) {
|
||||
ElMessage.success('验证码已发送');
|
||||
// 开始倒计时
|
||||
startCountdown();
|
||||
}
|
||||
};
|
||||
|
||||
// 注册
|
||||
const handleRegister = async () => {
|
||||
const result = await authApi.register({
|
||||
registerType: 'phone',
|
||||
phone: '13800138000',
|
||||
smsCode: '123456',
|
||||
password: '123456',
|
||||
confirmPassword: '123456',
|
||||
agree: true
|
||||
});
|
||||
|
||||
if (result.code === 200) {
|
||||
ElMessage.success('注册成功');
|
||||
// 保存token并跳转
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 3. 邮箱注册
|
||||
```typescript
|
||||
// 发送验证码
|
||||
const sendEmailCode = async () => {
|
||||
const result = await authApi.sendEmailCode('user@example.com');
|
||||
if (result.code === 200) {
|
||||
ElMessage.success('验证码已发送到邮箱');
|
||||
// 开始倒计时
|
||||
startCountdown();
|
||||
}
|
||||
};
|
||||
|
||||
// 注册
|
||||
const handleRegister = async () => {
|
||||
const result = await authApi.register({
|
||||
registerType: 'email',
|
||||
email: 'user@example.com',
|
||||
emailCode: '123456',
|
||||
password: '123456',
|
||||
confirmPassword: '123456',
|
||||
agree: true
|
||||
});
|
||||
|
||||
if (result.code === 200) {
|
||||
ElMessage.success('注册成功');
|
||||
// 保存token并跳转
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## 测试用例
|
||||
|
||||
### 1. 用户名注册测试
|
||||
```bash
|
||||
# 正常注册
|
||||
curl -X POST http://localhost:8081/schoolNewsServ/auth/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"registerType": "username",
|
||||
"username": "testuser",
|
||||
"password": "123456",
|
||||
"confirmPassword": "123456"
|
||||
}'
|
||||
|
||||
# 用户名重复
|
||||
curl -X POST http://localhost:8081/schoolNewsServ/auth/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"registerType": "username",
|
||||
"username": "testuser",
|
||||
"password": "123456",
|
||||
"confirmPassword": "123456"
|
||||
}'
|
||||
# 预期:返回"用户已存在"
|
||||
|
||||
# 用户名过短
|
||||
curl -X POST http://localhost:8081/schoolNewsServ/auth/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"registerType": "username",
|
||||
"username": "ab",
|
||||
"password": "123456",
|
||||
"confirmPassword": "123456"
|
||||
}'
|
||||
# 预期:返回"用户名长度为3-20个字符"
|
||||
```
|
||||
|
||||
### 2. 手机号注册测试
|
||||
```bash
|
||||
# 先发送验证码
|
||||
curl -X POST http://localhost:8081/schoolNewsServ/auth/send-sms-code \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"phone": "13800138000"}'
|
||||
|
||||
# 然后注册(使用日志中的验证码)
|
||||
curl -X POST http://localhost:8081/schoolNewsServ/auth/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"registerType": "phone",
|
||||
"phone": "13800138000",
|
||||
"smsCode": "123456",
|
||||
"password": "123456",
|
||||
"confirmPassword": "123456"
|
||||
}'
|
||||
```
|
||||
|
||||
### 3. 邮箱注册测试
|
||||
```bash
|
||||
# 先发送验证码
|
||||
curl -X POST http://localhost:8081/schoolNewsServ/auth/send-email-code \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email": "test@example.com"}'
|
||||
|
||||
# 然后注册(检查邮箱获取验证码)
|
||||
curl -X POST http://localhost:8081/schoolNewsServ/auth/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"registerType": "email",
|
||||
"email": "test@example.com",
|
||||
"emailCode": "123456",
|
||||
"password": "123456",
|
||||
"confirmPassword": "123456"
|
||||
}'
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **验证码有效期**:验证码10分钟后失效,需要重新获取
|
||||
2. **发送频率限制**:同一手机号/邮箱60秒内只能发送一次验证码
|
||||
3. **密码安全**:密码使用BCrypt加密存储,不可逆
|
||||
4. **自动登录**:注册成功后自动登录,前端需要保存返回的token
|
||||
5. **用户名规则**:
|
||||
- 用户名注册:用户自定义
|
||||
- 手机号注册:用户名=手机号
|
||||
- 邮箱注册:用户名=邮箱前缀
|
||||
6. **开发测试**:短信服务使用模拟模式时,验证码会在日志中输出
|
||||
|
||||
## 后续优化建议
|
||||
|
||||
1. 添加图形验证码,防止恶意发送验证码
|
||||
2. 添加IP限流,防止批量注册
|
||||
3. 添加邀请码机制
|
||||
4. 实现手机号和邮箱的绑定功能
|
||||
5. 添加密码强度验证
|
||||
6. 实现找回密码功能
|
||||
7. 添加注册协议弹窗确认
|
||||
8. 记录注册日志用于分析
|
||||
|
||||
454
schoolNewsServ/auth/登录功能说明.md
Normal file
454
schoolNewsServ/auth/登录功能说明.md
Normal file
@@ -0,0 +1,454 @@
|
||||
# 登录功能说明文档
|
||||
|
||||
## 功能概述
|
||||
|
||||
本系统实现了多种登录方式,包括:
|
||||
1. **密码登录**:用户名/手机号/邮箱 + 密码
|
||||
2. **验证码登录**:手机号 + 短信验证码 / 邮箱 + 邮箱验证码
|
||||
|
||||
## 架构设计
|
||||
|
||||
### 策略模式架构
|
||||
|
||||
登录功能采用**策略模式(Strategy Pattern)**设计,具有以下优势:
|
||||
- **高扩展性**:新增登录方式只需实现策略接口
|
||||
- **低耦合**:登录逻辑与业务逻辑分离
|
||||
- **易维护**:每种登录方式独立实现,互不干扰
|
||||
|
||||
### 核心组件
|
||||
|
||||
#### 1. 策略接口:`LoginStrategy`
|
||||
|
||||
```java
|
||||
public interface LoginStrategy {
|
||||
String getLoginType(); // 获取登录类型
|
||||
boolean validate(LoginParam loginParam); // 验证登录参数
|
||||
TbSysUser findUser(LoginParam loginParam); // 查找用户
|
||||
boolean verifyCredential(String input, String stored); // 验证密码
|
||||
boolean verifyCaptchaWithSession(LoginParam loginParam); // 验证验证码(带SessionID)
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. 策略实现
|
||||
|
||||
- **PhoneLoginStrategy**:手机号登录策略
|
||||
- 支持手机号 + 密码登录
|
||||
- 支持手机号 + 短信验证码登录
|
||||
|
||||
- **EmailLoginStrategy**:邮箱登录策略
|
||||
- 支持邮箱 + 密码登录
|
||||
- 支持邮箱 + 邮箱验证码登录
|
||||
|
||||
- **UsernameLoginStrategy**:用户名登录策略
|
||||
- 支持用户名 + 密码登录
|
||||
|
||||
- **PasswordLoginStrategy**:通用密码登录策略
|
||||
- 自动识别用户名/手机号/邮箱
|
||||
|
||||
#### 3. 策略工厂:`LoginStrategyFactory`
|
||||
|
||||
根据登录类型(loginType)返回对应的登录策略实例。
|
||||
|
||||
#### 4. 登录服务:`LoginServiceImpl`
|
||||
|
||||
统一的登录入口,调用策略模式处理不同的登录方式。
|
||||
|
||||
## 前后端交互
|
||||
|
||||
### 前端登录请求
|
||||
|
||||
#### 密码登录示例
|
||||
|
||||
```javascript
|
||||
{
|
||||
"loginType": "phone", // 登录类型:username/phone/email/password
|
||||
"phone": "13800138000", // 手机号(phone登录时必填)
|
||||
"password": "123456", // 密码
|
||||
"rememberMe": true, // 记住我
|
||||
"agree": true // 同意协议
|
||||
}
|
||||
```
|
||||
|
||||
#### 验证码登录示例
|
||||
|
||||
```javascript
|
||||
{
|
||||
"loginType": "phone", // 登录类型
|
||||
"phone": "13800138000", // 手机号
|
||||
"captcha": "123456", // 验证码
|
||||
"captchaId": "uuid-xxx", // 验证码会话ID
|
||||
"rememberMe": true,
|
||||
"agree": true
|
||||
}
|
||||
```
|
||||
|
||||
### 后端登录流程
|
||||
|
||||
```
|
||||
1. 接收登录请求 (POST /auth/login)
|
||||
↓
|
||||
2. 自动检测登录类型(detectLoginType)
|
||||
↓
|
||||
3. 获取对应的登录策略(LoginStrategyFactory)
|
||||
↓
|
||||
4. 验证登录参数(strategy.validate)
|
||||
↓
|
||||
5. 查找用户(strategy.findUser)
|
||||
↓
|
||||
6. 检查用户状态
|
||||
↓
|
||||
7. 验证凭据:
|
||||
- 密码登录 → strategy.verifyCredential(password, storedPassword)
|
||||
- 验证码登录 → strategy.verifyCaptchaWithSession(loginParam)
|
||||
↓
|
||||
8. 生成JWT令牌
|
||||
↓
|
||||
9. 返回登录结果
|
||||
```
|
||||
|
||||
## 验证码登录实现
|
||||
|
||||
### 验证码发送
|
||||
|
||||
#### 发送短信验证码
|
||||
|
||||
**接口**:`POST /auth/send-sms-code`
|
||||
|
||||
**请求**:
|
||||
```json
|
||||
{
|
||||
"phone": "13800138000"
|
||||
}
|
||||
```
|
||||
|
||||
**响应**:
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "验证码已发送",
|
||||
"data": {
|
||||
"sessionId": "uuid-xxx",
|
||||
"message": "验证码已发送"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 发送邮箱验证码
|
||||
|
||||
**接口**:`POST /auth/send-email-code`
|
||||
|
||||
**请求**:
|
||||
```json
|
||||
{
|
||||
"email": "user@example.com"
|
||||
}
|
||||
```
|
||||
|
||||
**响应**:
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "验证码已发送到邮箱",
|
||||
"data": {
|
||||
"sessionId": "uuid-xxx",
|
||||
"message": "验证码已发送到邮箱"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 验证码验证流程(策略模式)
|
||||
|
||||
以手机号验证码登录为例:
|
||||
|
||||
```java
|
||||
@Override
|
||||
public boolean verifyCaptchaWithSession(LoginParam loginParam) {
|
||||
// 1. 获取参数
|
||||
String captchaId = loginParam.getCaptchaId(); // 会话ID
|
||||
String inputCaptcha = loginParam.getCaptcha(); // 用户输入的验证码
|
||||
String phone = loginParam.getPhone(); // 手机号
|
||||
|
||||
// 2. 参数验证
|
||||
if (captchaId == null || inputCaptcha == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3. 从Redis获取验证码(使用SessionID)
|
||||
String codeKey = "sms:code:" + captchaId;
|
||||
String storedValue = redisService.get(codeKey); // 格式:"手机号:验证码"
|
||||
|
||||
if (storedValue == null) {
|
||||
return false; // 验证码已过期
|
||||
}
|
||||
|
||||
// 4. 解析存储的值
|
||||
String[] parts = storedValue.split(":");
|
||||
String storedPhone = parts[0];
|
||||
String storedCaptcha = parts[1];
|
||||
|
||||
// 5. 验证手机号和验证码是否匹配
|
||||
if (!storedPhone.equals(phone) || !storedCaptcha.equals(inputCaptcha)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 6. 验证码使用后删除
|
||||
redisService.delete(codeKey);
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
## 安全机制
|
||||
|
||||
### 1. SessionID绑定
|
||||
|
||||
- 验证码发送时生成唯一的`sessionId`
|
||||
- Redis存储格式:`key="sms:code:{sessionId}", value="phone:code"`
|
||||
- 验证时必须提供正确的`sessionId`才能获取验证码
|
||||
- 防止验证码被其他用户使用
|
||||
|
||||
### 2. 双重验证
|
||||
|
||||
验证码登录时需要同时验证:
|
||||
- 手机号/邮箱是否匹配
|
||||
- 验证码是否正确
|
||||
|
||||
### 3. 一次性验证
|
||||
|
||||
验证码验证成功后立即从Redis删除,防止重复使用。
|
||||
|
||||
### 4. 有效期限制
|
||||
|
||||
- 验证码有效期:5分钟
|
||||
- 发送频率限制:60秒内只能发送一次
|
||||
|
||||
### 5. 登录会话时长
|
||||
|
||||
- **勾选RememberMe**:Redis缓存7天,前端LocalStorage长期保存
|
||||
- **未勾选RememberMe**:Redis缓存1天,前端SessionStorage会话保存
|
||||
|
||||
### 6. 登录失败限制
|
||||
|
||||
- 最大尝试次数:3次
|
||||
- 超过限制后10分钟内无法登录
|
||||
|
||||
## Redis数据结构
|
||||
|
||||
### 验证码存储
|
||||
|
||||
```
|
||||
# 短信验证码
|
||||
key: sms:code:{sessionId}
|
||||
value: {phone}:{code}
|
||||
ttl: 300秒(5分钟)
|
||||
|
||||
# 邮箱验证码
|
||||
key: email:code:{sessionId}
|
||||
value: {email}:{code}
|
||||
ttl: 300秒(5分钟)
|
||||
```
|
||||
|
||||
### 发送频率限制
|
||||
|
||||
```
|
||||
key: send:sms:{phone}
|
||||
value: 1
|
||||
ttl: 60秒
|
||||
|
||||
key: send:email:{email}
|
||||
value: 1
|
||||
ttl: 60秒
|
||||
```
|
||||
|
||||
### 登录失败次数
|
||||
|
||||
```
|
||||
key: login:attempt:{username}
|
||||
value: {attemptCount}
|
||||
ttl: 600秒(10分钟)
|
||||
```
|
||||
|
||||
### 登录会话缓存
|
||||
|
||||
```
|
||||
key: login:token:{userId}
|
||||
value: LoginDomain对象
|
||||
ttl:
|
||||
- rememberMe=true: 7天(604800秒)
|
||||
- rememberMe=false: 1天(86400秒)
|
||||
```
|
||||
|
||||
## 前端实现要点
|
||||
|
||||
### 1. 登录模式切换
|
||||
|
||||
```javascript
|
||||
const loginMode = ref<'password' | 'captcha'>('password'); // 密码登录/验证码登录
|
||||
|
||||
// 切换登录模式
|
||||
const switchLoginMode = (mode) => {
|
||||
loginMode.value = mode;
|
||||
// 清空表单
|
||||
loginForm.username = '';
|
||||
loginForm.password = '';
|
||||
loginForm.captcha = '';
|
||||
loginForm.captchaId = '';
|
||||
};
|
||||
```
|
||||
|
||||
### 2. 验证码类型切换
|
||||
|
||||
```javascript
|
||||
const captchaType = ref<'phone' | 'email'>('phone'); // 手机号/邮箱
|
||||
|
||||
// 切换验证码类型
|
||||
const switchCaptchaType = (type) => {
|
||||
captchaType.value = type;
|
||||
// 清空相关字段
|
||||
};
|
||||
```
|
||||
|
||||
### 3. 发送验证码
|
||||
|
||||
```javascript
|
||||
const handleSendSmsCode = async () => {
|
||||
// 1. 验证手机号
|
||||
if (!loginForm.phone || !phonePattern.test(loginForm.phone)) {
|
||||
ElMessage.warning('请输入正确的手机号');
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 调用API发送验证码
|
||||
const result = await authApi.sendSmsCode(loginForm.phone);
|
||||
|
||||
if (result.code === 200 && result.data) {
|
||||
// 3. 保存sessionId(重要!)
|
||||
loginForm.captchaId = result.data.sessionId;
|
||||
|
||||
// 4. 开始倒计时
|
||||
smsCountdown.value = 60;
|
||||
const timer = setInterval(() => {
|
||||
smsCountdown.value--;
|
||||
if (smsCountdown.value <= 0) {
|
||||
clearInterval(timer);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
ElMessage.success('验证码已发送');
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 4. 提交登录
|
||||
|
||||
```javascript
|
||||
const handleLogin = async () => {
|
||||
// 1. 表单验证
|
||||
await loginFormRef.value.validate();
|
||||
|
||||
// 2. 调用登录API(前端会自动填充loginType)
|
||||
const result = await store.dispatch('auth/login', loginForm);
|
||||
|
||||
// 3. 登录成功处理
|
||||
if (result.code === 200) {
|
||||
ElMessage.success('登录成功!');
|
||||
router.push('/home');
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## API端点
|
||||
|
||||
| 方法 | 路径 | 说明 | 是否需要认证 |
|
||||
|------|------|------|-------------|
|
||||
| POST | /auth/login | 用户登录 | 否 |
|
||||
| POST | /auth/send-sms-code | 发送短信验证码 | 否 |
|
||||
| POST | /auth/send-email-code | 发送邮箱验证码 | 否 |
|
||||
| POST | /auth/logout | 用户登出 | 是 |
|
||||
|
||||
## 配置说明
|
||||
|
||||
### application.yml
|
||||
|
||||
```yaml
|
||||
# 邮件服务配置
|
||||
spring:
|
||||
mail:
|
||||
host: smtp.qq.com
|
||||
port: 587
|
||||
username: your-email@qq.com
|
||||
password: your-authorization-code
|
||||
properties:
|
||||
mail:
|
||||
smtp:
|
||||
auth: true
|
||||
starttls:
|
||||
enable: true
|
||||
|
||||
# 短信服务配置
|
||||
sms:
|
||||
enabled: true
|
||||
provider: aliyun
|
||||
access-key-id: your-access-key-id
|
||||
access-key-secret: your-access-key-secret
|
||||
sign-name: 红色思政学习平台
|
||||
template-code: SMS_xxxxx
|
||||
region-id: cn-hangzhou
|
||||
|
||||
# 安全白名单
|
||||
school-news:
|
||||
auth:
|
||||
white-list:
|
||||
- /auth/login
|
||||
- /auth/send-sms-code
|
||||
- /auth/send-email-code
|
||||
```
|
||||
|
||||
## 测试建议
|
||||
|
||||
### 1. 密码登录测试
|
||||
|
||||
- 用户名 + 密码
|
||||
- 手机号 + 密码
|
||||
- 邮箱 + 密码
|
||||
|
||||
### 2. 验证码登录测试
|
||||
|
||||
- 手机号 + 短信验证码
|
||||
- 邮箱 + 邮箱验证码
|
||||
- 验证码过期测试
|
||||
- SessionID不匹配测试
|
||||
- 验证码错误测试
|
||||
|
||||
### 3. 安全测试
|
||||
|
||||
- 登录失败次数限制
|
||||
- 验证码发送频率限制
|
||||
- 验证码一次性使用
|
||||
- SessionID绑定验证
|
||||
|
||||
## 扩展说明
|
||||
|
||||
如需新增登录方式(如微信登录),只需:
|
||||
|
||||
1. 创建新的策略实现类
|
||||
```java
|
||||
@Component
|
||||
public class WechatLoginStrategy implements LoginStrategy {
|
||||
@Override
|
||||
public String getLoginType() {
|
||||
return "wechat";
|
||||
}
|
||||
// 实现其他方法...
|
||||
}
|
||||
```
|
||||
|
||||
2. 前端添加对应的UI和逻辑
|
||||
|
||||
策略工厂会自动识别并加载新的登录策略。
|
||||
|
||||
---
|
||||
|
||||
**文档版本**:v1.0
|
||||
**更新日期**:2025-11-03
|
||||
**作者**:开发团队
|
||||
|
||||
264
schoolNewsServ/auth/短信服务架构说明.md
Normal file
264
schoolNewsServ/auth/短信服务架构说明.md
Normal file
@@ -0,0 +1,264 @@
|
||||
# 短信服务架构说明
|
||||
|
||||
## 设计理念
|
||||
|
||||
短信服务采用**通用化、可扩展**的设计:
|
||||
- ✅ 配置保持通用,不绑定特定服务商
|
||||
- ✅ 支持多种短信服务商切换
|
||||
- ✅ 易于扩展新的服务商
|
||||
- ✅ 统一的对外接口
|
||||
|
||||
## 架构设计
|
||||
|
||||
### 1. 配置层(通用化)
|
||||
|
||||
```yaml
|
||||
sms:
|
||||
enabled: false # 是否启用
|
||||
provider: aliyun # 服务商选择
|
||||
access-key-id: xxx # 通用配置
|
||||
access-key-secret: xxx
|
||||
sign-name: xxx
|
||||
template-code: xxx
|
||||
region-id: xxx
|
||||
```
|
||||
|
||||
**设计优势**:
|
||||
- 配置不带服务商前缀,保持通用性
|
||||
- 通过 `provider` 灵活切换服务商
|
||||
- 未来增加服务商无需修改配置结构
|
||||
|
||||
### 2. 工具类层(SmsUtils)
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class SmsUtils {
|
||||
// 通用配置
|
||||
@Value("${sms.provider:aliyun}")
|
||||
private String provider;
|
||||
|
||||
// 对外统一接口
|
||||
public boolean sendVerificationCode(String phone, String code) {
|
||||
switch (provider) {
|
||||
case "aliyun":
|
||||
return sendByAliyun(phone, code);
|
||||
case "tencent":
|
||||
return sendByTencent(phone, code);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 各服务商的私有实现
|
||||
private boolean sendByAliyun(String phone, String code) { ... }
|
||||
private boolean sendByTencent(String phone, String code) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
**设计优势**:
|
||||
- 对外统一接口:`sendVerificationCode()`
|
||||
- 各服务商只是内部的私有方法
|
||||
- 调用方无需关心使用哪个服务商
|
||||
- 新增服务商只需添加一个私有方法
|
||||
|
||||
### 3. 调用层(AuthController)
|
||||
|
||||
```java
|
||||
@Autowired
|
||||
private SmsUtils smsUtils;
|
||||
|
||||
public ResultDomain<Boolean> sendSmsCode(...) {
|
||||
// 直接调用统一接口,无需关心服务商
|
||||
boolean success = smsUtils.sendVerificationCode(phone, code);
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
## 支持的服务商
|
||||
|
||||
### 当前已实现
|
||||
- ✅ **阿里云**(dysmsapi20170525)
|
||||
|
||||
### 待实现
|
||||
- ⏳ **腾讯云**(预留接口)
|
||||
- ⏳ **华为云**(预留接口)
|
||||
|
||||
## 如何添加新服务商
|
||||
|
||||
### 步骤1:添加Maven依赖
|
||||
|
||||
在 `common-util/pom.xml` 中添加对应SDK:
|
||||
|
||||
```xml
|
||||
<!-- 例如:腾讯云短信 -->
|
||||
<dependency>
|
||||
<groupId>com.tencentcloudapi</groupId>
|
||||
<artifactId>tencentcloud-sdk-java</artifactId>
|
||||
<version>3.x.x</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
### 步骤2:实现私有方法
|
||||
|
||||
在 `SmsUtils.java` 中添加私有实现方法:
|
||||
|
||||
```java
|
||||
/**
|
||||
* 使用腾讯云发送短信验证码
|
||||
*/
|
||||
private boolean sendByTencent(String phone, String code) {
|
||||
try {
|
||||
// 腾讯云SDK调用逻辑
|
||||
...
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
logger.error("腾讯云短信发送失败", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 步骤3:添加到分发逻辑
|
||||
|
||||
在 `sendVerificationCode()` 方法中添加case:
|
||||
|
||||
```java
|
||||
switch (provider.toLowerCase()) {
|
||||
case "aliyun":
|
||||
return sendByAliyun(phone, code);
|
||||
case "tencent":
|
||||
return sendByTencent(phone, code); // 新增
|
||||
default:
|
||||
logger.error("未知的短信服务商: {}", provider);
|
||||
return false;
|
||||
}
|
||||
```
|
||||
|
||||
### 步骤4:配置切换
|
||||
|
||||
修改 `application.yml`:
|
||||
|
||||
```yaml
|
||||
sms:
|
||||
provider: tencent # 切换到腾讯云
|
||||
```
|
||||
|
||||
完成!无需修改调用方代码。
|
||||
|
||||
## 开发模式
|
||||
|
||||
### 模拟模式(推荐用于开发)
|
||||
|
||||
```yaml
|
||||
sms:
|
||||
enabled: false # 关闭真实发送
|
||||
```
|
||||
|
||||
**特点**:
|
||||
- 不会实际发送短信
|
||||
- 验证码输出到日志
|
||||
- 不产生任何费用
|
||||
- 便于本地调试
|
||||
|
||||
### 真实发送模式
|
||||
|
||||
```yaml
|
||||
sms:
|
||||
enabled: true
|
||||
provider: aliyun
|
||||
access-key-id: xxx
|
||||
access-key-secret: xxx
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 1. 环境隔离
|
||||
|
||||
**开发环境**:
|
||||
```yaml
|
||||
sms:
|
||||
enabled: false # 模拟模式
|
||||
```
|
||||
|
||||
**生产环境**:
|
||||
```yaml
|
||||
sms:
|
||||
enabled: true
|
||||
provider: aliyun
|
||||
access-key-id: ${SMS_ACCESS_KEY_ID} # 从环境变量读取
|
||||
access-key-secret: ${SMS_ACCESS_KEY_SECRET}
|
||||
```
|
||||
|
||||
### 2. 安全性
|
||||
|
||||
- ❌ 不要将 AccessKey 写在代码中
|
||||
- ❌ 不要将 AccessKey 提交到 Git
|
||||
- ✅ 使用环境变量或配置中心
|
||||
- ✅ 使用 RAM 子账号而非主账号
|
||||
- ✅ 定期更换密钥
|
||||
|
||||
### 3. 可靠性
|
||||
|
||||
```java
|
||||
// 系统已实现:
|
||||
- 60秒发送频率限制(防刷)
|
||||
- 10分钟验证码有效期
|
||||
- Redis存储验证码
|
||||
- 手机号格式验证
|
||||
|
||||
// 建议增加:
|
||||
- 图形验证码前置
|
||||
- IP限流
|
||||
- 黑名单机制
|
||||
- 发送失败重试
|
||||
```
|
||||
|
||||
### 4. 监控告警
|
||||
|
||||
建议监控指标:
|
||||
- 短信发送成功率
|
||||
- 短信发送量(防异常消耗)
|
||||
- 验证码验证成功率
|
||||
- 单个手机号发送频率
|
||||
|
||||
## 优势总结
|
||||
|
||||
### 对比旧方案
|
||||
|
||||
**旧方案**(绑定服务商):
|
||||
```yaml
|
||||
aliyun:
|
||||
sms:
|
||||
enabled: true
|
||||
```
|
||||
- ❌ 配置绑定服务商
|
||||
- ❌ 切换服务商需要大量修改
|
||||
- ❌ 扩展性差
|
||||
|
||||
**新方案**(通用化):
|
||||
```yaml
|
||||
sms:
|
||||
provider: aliyun
|
||||
enabled: true
|
||||
```
|
||||
- ✅ 配置保持通用
|
||||
- ✅ 切换服务商只需修改 `provider`
|
||||
- ✅ 扩展性强,易于维护
|
||||
|
||||
### 符合设计原则
|
||||
|
||||
- **开闭原则**:对扩展开放,对修改关闭
|
||||
- **单一职责**:每个服务商实现独立
|
||||
- **依赖倒置**:调用方依赖抽象接口,不依赖具体实现
|
||||
- **里氏替换**:各服务商实现可以互相替换
|
||||
|
||||
## 总结
|
||||
|
||||
这个架构设计的核心理念是:
|
||||
1. **配置通用化**:不绑定特定服务商
|
||||
2. **实现私有化**:服务商只是一个方法
|
||||
3. **接口统一化**:对外提供统一接口
|
||||
4. **扩展简单化**:新增服务商仅需3步
|
||||
|
||||
这样的设计既满足当前需求,又具备良好的扩展性,是一个**优雅、实用、易维护**的架构方案。
|
||||
|
||||
242
schoolNewsServ/auth/邮件服务配置说明.md
Normal file
242
schoolNewsServ/auth/邮件服务配置说明.md
Normal file
@@ -0,0 +1,242 @@
|
||||
# 邮件和短信服务配置说明
|
||||
|
||||
## 一、邮件服务配置
|
||||
|
||||
### 1. 在 application.yml 中添加邮件配置
|
||||
|
||||
```yaml
|
||||
spring:
|
||||
mail:
|
||||
# 邮件服务器地址(以QQ邮箱为例)
|
||||
host: smtp.qq.com
|
||||
# 端口号
|
||||
port: 587
|
||||
# 发件人邮箱
|
||||
username: your-email@qq.com
|
||||
# 授权码(不是邮箱密码)
|
||||
password: your-authorization-code
|
||||
# 编码
|
||||
default-encoding: UTF-8
|
||||
properties:
|
||||
mail:
|
||||
smtp:
|
||||
auth: true
|
||||
starttls:
|
||||
enable: true
|
||||
required: true
|
||||
```
|
||||
|
||||
### 2. 获取QQ邮箱授权码步骤
|
||||
|
||||
1. 登录QQ邮箱
|
||||
2. 点击【设置】->【账户】
|
||||
3. 找到【POP3/IMAP/SMTP/Exchange/CardDAV/CalDAV服务】
|
||||
4. 开启【POP3/SMTP服务】或【IMAP/SMTP服务】
|
||||
5. 点击【生成授权码】
|
||||
6. 按照提示发送短信,获取16位授权码
|
||||
7. 将授权码填入配置文件的 `password` 字段
|
||||
|
||||
### 3. 其他常用邮箱配置
|
||||
|
||||
#### 163邮箱
|
||||
```yaml
|
||||
spring:
|
||||
mail:
|
||||
host: smtp.163.com
|
||||
port: 465
|
||||
username: your-email@163.com
|
||||
password: your-authorization-code
|
||||
```
|
||||
|
||||
#### Gmail
|
||||
```yaml
|
||||
spring:
|
||||
mail:
|
||||
host: smtp.gmail.com
|
||||
port: 587
|
||||
username: your-email@gmail.com
|
||||
password: your-app-password
|
||||
```
|
||||
|
||||
## 二、短信服务配置(支持多服务商)
|
||||
|
||||
### 1. 通用配置说明
|
||||
|
||||
系统支持多种短信服务商,通过 `provider` 配置切换:
|
||||
- **aliyun**: 阿里云短信服务(已实现)
|
||||
- **tencent**: 腾讯云短信服务(待实现)
|
||||
|
||||
### 2. 配置文件设置
|
||||
|
||||
在 `application.yml` 中添加以下配置:
|
||||
|
||||
```yaml
|
||||
sms:
|
||||
# 是否启用短信服务(true: 真实发送, false: 模拟模式)
|
||||
enabled: false
|
||||
# 短信服务商(aliyun: 阿里云, tencent: 腾讯云)
|
||||
provider: aliyun
|
||||
# AccessKey ID(从服务商控制台获取)
|
||||
access-key-id: LTAI5txxxxxxxxxxxxxxxx
|
||||
# AccessKey Secret(从服务商控制台获取)
|
||||
access-key-secret: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
# 短信签名名称(需要在服务商平台申请)
|
||||
sign-name: 红色思政学习平台
|
||||
# 验证码模板CODE(需要在服务商平台申请)
|
||||
template-code: SMS_123456789
|
||||
# 区域ID(阿里云:cn-hangzhou,腾讯云:ap-guangzhou)
|
||||
region-id: cn-hangzhou
|
||||
```
|
||||
|
||||
### 3. 阿里云短信服务配置
|
||||
|
||||
#### 3.1 申请阿里云短信服务
|
||||
|
||||
1. 登录 [阿里云控制台](https://www.aliyun.com/)
|
||||
2. 开通【短信服务】产品
|
||||
3. 创建签名(需要企业资质或个人认证)
|
||||
4. 创建短信模板(验证码类型)
|
||||
- 模板示例:`您的验证码为:${code},该验证码10分钟内有效,请勿泄漏于他人!`
|
||||
5. 获取 AccessKey ID 和 AccessKey Secret
|
||||
- 进入【访问控制】-> 【用户管理】
|
||||
- 创建RAM用户并授予 AliyunDysmsFullAccess 权限
|
||||
- 创建 AccessKey
|
||||
|
||||
#### 3.2 阿里云配置示例
|
||||
|
||||
```yaml
|
||||
sms:
|
||||
enabled: true
|
||||
provider: aliyun
|
||||
access-key-id: LTAI5txxxxxxxxxxxxxxxx
|
||||
access-key-secret: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
sign-name: 红色思政学习平台
|
||||
template-code: SMS_123456789
|
||||
region-id: cn-hangzhou
|
||||
```
|
||||
|
||||
#### 3.3 费用说明
|
||||
|
||||
- **验证码短信**:约 ¥0.045/条
|
||||
- **通知短信**:约 ¥0.045/条
|
||||
|
||||
详细配置步骤请查看:`阿里云短信配置示例.md`
|
||||
|
||||
### 4. 腾讯云短信服务配置(待实现)
|
||||
|
||||
```yaml
|
||||
sms:
|
||||
enabled: true
|
||||
provider: tencent
|
||||
access-key-id: your-secret-id
|
||||
access-key-secret: your-secret-key
|
||||
sign-name: 红色思政学习平台
|
||||
template-code: 123456
|
||||
region-id: ap-guangzhou
|
||||
```
|
||||
|
||||
### 5. 开发模式说明
|
||||
|
||||
如果 `enabled` 设置为 `false` 或未配置 AccessKey,系统会自动使用**模拟模式**:
|
||||
- ✅ 不会实际发送短信
|
||||
- ✅ 在日志中输出验证码内容
|
||||
- ✅ 适合开发和测试环境
|
||||
- ✅ 不产生任何费用
|
||||
|
||||
**推荐**:开发环境始终使用 `enabled: false`
|
||||
|
||||
### 6. 模板参数说明
|
||||
|
||||
验证码模板需要包含 `${code}` 变量,系统会自动替换为6位数字验证码。
|
||||
|
||||
模板示例:
|
||||
```
|
||||
【红色思政学习平台】您的验证码为:${code},该验证码10分钟内有效,请勿泄漏于他人!
|
||||
```
|
||||
|
||||
### 7. 注意事项
|
||||
|
||||
- **AccessKey 安全**:不要将 AccessKey 提交到代码仓库
|
||||
- **短信限流**:各服务商都有频率限制,注意防刷
|
||||
- **验证码有效期**:建议在模板中明确说明
|
||||
- **测试建议**:开发环境使用 `enabled: false` 避免浪费
|
||||
|
||||
## 三、Redis配置
|
||||
|
||||
验证码存储在Redis中,确保Redis服务已启动并配置正确:
|
||||
|
||||
```yaml
|
||||
spring:
|
||||
redis:
|
||||
host: localhost
|
||||
port: 6379
|
||||
password: your-redis-password
|
||||
database: 0
|
||||
```
|
||||
|
||||
## 四、API接口说明
|
||||
|
||||
### 1. 发送邮箱验证码
|
||||
|
||||
- **接口**: POST /auth/send-email-code
|
||||
- **参数**:
|
||||
```json
|
||||
{
|
||||
"email": "user@example.com"
|
||||
}
|
||||
```
|
||||
- **返回**:
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "验证码已发送到邮箱",
|
||||
"data": true
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 发送手机验证码
|
||||
|
||||
- **接口**: POST /auth/send-sms-code
|
||||
- **参数**:
|
||||
```json
|
||||
{
|
||||
"phone": "13800138000"
|
||||
}
|
||||
```
|
||||
- **返回**:
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "验证码已发送",
|
||||
"data": true
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 验证码特性
|
||||
|
||||
- 验证码有效期:10分钟
|
||||
- 发送频率限制:60秒内只能发送一次
|
||||
- 验证码长度:6位数字
|
||||
- 验证码存储在Redis中,格式为:
|
||||
- 邮箱验证码:`email:code:邮箱地址`
|
||||
- 手机验证码:`sms:code:手机号`
|
||||
|
||||
## 五、前端注册方式
|
||||
|
||||
前端支持三种注册方式:
|
||||
|
||||
1. **用户名注册**:输入用户名、学号、密码
|
||||
2. **手机号注册**:输入手机号、验证码、学号、密码
|
||||
3. **邮箱注册**:输入邮箱、验证码、学号、密码
|
||||
|
||||
用户可以通过标签页切换注册方式。
|
||||
|
||||
## 六、开发环境测试
|
||||
|
||||
在开发环境中:
|
||||
|
||||
- 邮件服务:如果未配置邮箱,EmailUtils会记录错误日志
|
||||
- 短信服务:SmsUtils会在日志中输出验证码,不会实际发送
|
||||
|
||||
建议在开发环境中配置一个测试邮箱,以便完整测试邮件发送功能。
|
||||
|
||||
253
schoolNewsServ/auth/阿里云短信配置示例.md
Normal file
253
schoolNewsServ/auth/阿里云短信配置示例.md
Normal file
@@ -0,0 +1,253 @@
|
||||
# 阿里云短信服务配置完整指南
|
||||
|
||||
## 一、前期准备
|
||||
|
||||
### 1. 注册阿里云账号
|
||||
访问 https://www.aliyun.com/ 注册账号并完成实名认证
|
||||
|
||||
### 2. 开通短信服务
|
||||
1. 登录阿里云控制台
|
||||
2. 搜索"短信服务"并开通
|
||||
3. 进入短信服务控制台:https://dysms.console.aliyun.com/
|
||||
|
||||
## 二、创建短信签名
|
||||
|
||||
### 1. 申请签名
|
||||
- 进入【国内消息】->【签名管理】
|
||||
- 点击【添加签名】
|
||||
- 填写签名信息:
|
||||
- **签名名称**:红色思政学习平台
|
||||
- **签名来源**:网站(需要提供网站备案信息)
|
||||
- **签名场景**:验证码
|
||||
- **签名说明**:用于用户注册和登录验证
|
||||
- **资质证明**:上传相关资质文件
|
||||
|
||||
### 2. 审核时间
|
||||
通常1-2个工作日审核完成
|
||||
|
||||
## 三、创建短信模板
|
||||
|
||||
### 1. 申请模板
|
||||
- 进入【国内消息】->【模板管理】
|
||||
- 点击【添加模板】
|
||||
- 填写模板信息:
|
||||
- **模板类型**:验证码
|
||||
- **模板名称**:注册验证码
|
||||
- **模板内容**:您的验证码为:${code},该验证码10分钟内有效,请勿泄漏于他人!
|
||||
|
||||
### 2. 注意事项
|
||||
- 模板必须包含变量:`${code}`
|
||||
- 验证码模板必须说明有效期
|
||||
- 必须包含防骗提示语
|
||||
|
||||
### 3. 审核时间
|
||||
通常1-2个工作日审核完成
|
||||
|
||||
### 4. 获取模板CODE
|
||||
审核通过后,会生成模板CODE,格式如:`SMS_123456789`
|
||||
|
||||
## 四、创建RAM用户(推荐)
|
||||
|
||||
### 1. 为什么使用RAM用户
|
||||
- 主账号权限过大,不安全
|
||||
- RAM用户可以精细化权限控制
|
||||
- 方便管理多个应用的访问权限
|
||||
|
||||
### 2. 创建步骤
|
||||
|
||||
#### 步骤1:进入访问控制
|
||||
访问:https://ram.console.aliyun.com/users
|
||||
|
||||
#### 步骤2:创建用户
|
||||
1. 点击【创建用户】
|
||||
2. 填写用户信息:
|
||||
- **登录名称**:school-news-sms
|
||||
- **显示名称**:红色思政短信服务
|
||||
- **访问方式**:勾选【OpenAPI调用访问】
|
||||
|
||||
#### 步骤3:保存AccessKey
|
||||
创建成功后,**立即下载CSV文件保存AccessKey信息**(只显示一次!)
|
||||
- AccessKey ID:LTAI5txxxxxxxxxxxxxxxxxx
|
||||
- AccessKey Secret:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
#### 步骤4:授权
|
||||
1. 找到刚创建的用户
|
||||
2. 点击【添加权限】
|
||||
3. 搜索并添加权限:`AliyunDysmsFullAccess`(短信服务完全访问权限)
|
||||
|
||||
## 五、配置项目
|
||||
|
||||
### 1. 在 application.yml 中配置
|
||||
|
||||
```yaml
|
||||
aliyun:
|
||||
sms:
|
||||
# 是否启用阿里云短信服务(true: 真实发送, false: 模拟模式)
|
||||
enabled: true
|
||||
# AccessKey ID(从RAM用户获取)
|
||||
access-key-id: LTAI5txxxxxxxxxxxxxxxxxx
|
||||
# AccessKey Secret(从RAM用户获取)
|
||||
access-key-secret: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
# 短信签名名称(审核通过后的签名名称)
|
||||
sign-name: 红色思政学习平台
|
||||
# 验证码模板CODE(审核通过后的模板CODE)
|
||||
template-code: SMS_123456789
|
||||
# 区域ID
|
||||
region-id: cn-hangzhou
|
||||
```
|
||||
|
||||
### 2. 开发环境配置
|
||||
|
||||
开发环境建议使用模拟模式:
|
||||
|
||||
```yaml
|
||||
aliyun:
|
||||
sms:
|
||||
enabled: false # 关闭真实发送
|
||||
access-key-id:
|
||||
access-key-secret:
|
||||
sign-name: 红色思政学习平台
|
||||
template-code:
|
||||
region-id: cn-hangzhou
|
||||
```
|
||||
|
||||
**模拟模式特点**:
|
||||
- 不会实际发送短信(节省费用)
|
||||
- 验证码会在控制台日志中输出
|
||||
- 适合开发和测试
|
||||
|
||||
### 3. 生产环境配置
|
||||
|
||||
生产环境使用真实发送:
|
||||
|
||||
```yaml
|
||||
aliyun:
|
||||
sms:
|
||||
enabled: true # 开启真实发送
|
||||
access-key-id: ${ALIYUN_SMS_ACCESS_KEY_ID} # 从环境变量读取
|
||||
access-key-secret: ${ALIYUN_SMS_ACCESS_KEY_SECRET} # 从环境变量读取
|
||||
sign-name: 红色思政学习平台
|
||||
template-code: SMS_123456789
|
||||
region-id: cn-hangzhou
|
||||
```
|
||||
|
||||
## 六、测试验证
|
||||
|
||||
### 1. 启动项目
|
||||
|
||||
```bash
|
||||
cd schoolNewsServ/admin
|
||||
mvn spring-boot:run
|
||||
```
|
||||
|
||||
### 2. 测试发送验证码
|
||||
|
||||
使用 Postman 或 curl 测试:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8081/schoolNewsServ/auth/send-sms-code \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"phone":"13800138000"}'
|
||||
```
|
||||
|
||||
### 3. 查看日志
|
||||
|
||||
**模拟模式**:
|
||||
```
|
||||
【模拟发送】短信验证码,手机号: 13800138000, 验证码: 123456
|
||||
```
|
||||
|
||||
**真实发送**:
|
||||
```
|
||||
阿里云短信发送成功,手机号: 13800138000, BizId: 123456789^0
|
||||
```
|
||||
|
||||
## 七、费用说明
|
||||
|
||||
### 1. 收费标准
|
||||
- **验证码短信**:约 ¥0.045/条
|
||||
- **通知短信**:约 ¥0.045/条
|
||||
- **推广短信**:约 ¥0.055/条
|
||||
|
||||
### 2. 充值方式
|
||||
1. 进入短信服务控制台
|
||||
2. 点击【充值】
|
||||
3. 选择充值金额(建议先充值100元测试)
|
||||
|
||||
### 3. 余额查询
|
||||
控制台首页可查看账户余额和使用情况
|
||||
|
||||
## 八、常见问题
|
||||
|
||||
### 1. 签名审核不通过
|
||||
- **原因**:资质不全、信息不符
|
||||
- **解决**:按审核意见补充资料,重新提交
|
||||
|
||||
### 2. 模板审核不通过
|
||||
- **原因**:内容不规范、缺少必要信息
|
||||
- **解决**:修改模板内容,确保包含:
|
||||
- 验证码变量 `${code}`
|
||||
- 有效期说明
|
||||
- 防骗提示
|
||||
|
||||
### 3. 发送失败:isv.BUSINESS_LIMIT_CONTROL
|
||||
- **原因**:短信发送频率过高
|
||||
- **解决**:
|
||||
- 同一手机号1分钟内只能发送1条
|
||||
- 同一手机号1小时内最多发送5条
|
||||
- 同一手机号1天内最多发送10条
|
||||
|
||||
### 4. 发送失败:isv.AMOUNT_NOT_ENOUGH
|
||||
- **原因**:账户余额不足
|
||||
- **解决**:充值
|
||||
|
||||
### 5. 发送失败:isv.MOBILE_NUMBER_ILLEGAL
|
||||
- **原因**:手机号格式错误
|
||||
- **解决**:检查手机号是否正确
|
||||
|
||||
### 6. 代码中看不到验证码
|
||||
- **原因**:enabled=true 时真实发送,不会在日志输出
|
||||
- **解决**:
|
||||
- 开发环境设置 `enabled: false`
|
||||
- 或查看手机短信
|
||||
|
||||
## 九、安全建议
|
||||
|
||||
### 1. AccessKey 安全
|
||||
- ❌ 不要将 AccessKey 直接写在代码中
|
||||
- ❌ 不要将 AccessKey 提交到 Git 仓库
|
||||
- ✅ 使用环境变量或配置中心
|
||||
- ✅ 定期更换 AccessKey
|
||||
- ✅ 使用 RAM 用户而非主账号
|
||||
|
||||
### 2. 防刷机制
|
||||
系统已实现:
|
||||
- ✅ 60秒发送频率限制
|
||||
- ✅ 验证码10分钟有效期
|
||||
- ✅ Redis存储验证码
|
||||
- ✅ 手机号格式验证
|
||||
|
||||
建议增加:
|
||||
- 图形验证码
|
||||
- IP限流
|
||||
- 黑名单机制
|
||||
|
||||
### 3. 监控告警
|
||||
- 设置短信发送量告警
|
||||
- 监控异常发送行为
|
||||
- 定期查看短信发送报表
|
||||
|
||||
## 十、技术支持
|
||||
|
||||
- 阿里云短信服务文档:https://help.aliyun.com/product/44282.html
|
||||
- 工单咨询:https://selfservice.console.aliyun.com/ticket/createIndex
|
||||
- 电话客服:95187
|
||||
|
||||
---
|
||||
|
||||
**最后提醒**:
|
||||
1. 开发环境务必使用 `enabled: false` 避免浪费
|
||||
2. AccessKey 信息要妥善保管
|
||||
3. 注意短信发送频率限制
|
||||
4. 定期查看账户余额
|
||||
|
||||
327
schoolNewsServ/auth/验证码安全机制说明.md
Normal file
327
schoolNewsServ/auth/验证码安全机制说明.md
Normal file
@@ -0,0 +1,327 @@
|
||||
# 验证码安全机制说明
|
||||
|
||||
## 安全问题
|
||||
|
||||
### 旧方案的漏洞
|
||||
|
||||
```
|
||||
Redis存储:sms:code:手机号 => 验证码
|
||||
```
|
||||
|
||||
**问题**:任何人只要知道手机号和验证码,就可以注册该手机号的账号。
|
||||
|
||||
**攻击场景**:
|
||||
1. 攻击者获取目标用户手机号
|
||||
2. 攻击者触发验证码发送
|
||||
3. 如果攻击者通过其他方式获取到验证码(如社会工程学、短信拦截等)
|
||||
4. 攻击者可以用这个手机号+验证码注册账号
|
||||
|
||||
## 新方案:SessionID绑定
|
||||
|
||||
### 核心思想
|
||||
|
||||
验证码不直接绑定手机号/邮箱,而是绑定一个临时会话ID(SessionID),确保只有发起验证码请求的用户才能使用。
|
||||
|
||||
### 实现机制
|
||||
|
||||
#### 1. 发送验证码流程
|
||||
|
||||
```
|
||||
用户请求发送验证码(手机号/邮箱)
|
||||
↓
|
||||
后端生成 SessionID(UUID)
|
||||
↓
|
||||
发送验证码到用户手机/邮箱
|
||||
↓
|
||||
Redis存储:
|
||||
key: sms:code:SessionID
|
||||
value: 手机号:验证码
|
||||
expire: 5分钟
|
||||
↓
|
||||
返回 SessionID 给前端
|
||||
```
|
||||
|
||||
**关键点**:
|
||||
- SessionID 是随机生成的UUID,无法预测
|
||||
- 验证码存储时绑定SessionID,不直接绑定手机号
|
||||
- 前端必须保存并传递SessionID
|
||||
|
||||
#### 2. 验证验证码流程
|
||||
|
||||
```
|
||||
用户提交注册表单
|
||||
- 手机号/邮箱
|
||||
- 验证码
|
||||
- SessionID(从发送验证码时获取)
|
||||
↓
|
||||
后端验证流程:
|
||||
1. 通过SessionID从Redis获取:手机号:验证码
|
||||
2. 验证提交的手机号是否匹配存储的手机号
|
||||
3. 验证提交的验证码是否匹配存储的验证码
|
||||
4. 两者都匹配才允许注册
|
||||
↓
|
||||
注册成功后删除验证码
|
||||
```
|
||||
|
||||
### 数据结构
|
||||
|
||||
#### Redis存储格式
|
||||
|
||||
**短信验证码**:
|
||||
```
|
||||
Key: sms:code:550e8400-e29b-41d4-a716-446655440000
|
||||
Value: 13800138000:123456
|
||||
TTL: 300秒(5分钟)
|
||||
```
|
||||
|
||||
**邮箱验证码**:
|
||||
```
|
||||
Key: email:code:650e8400-e29b-41d4-a716-446655440001
|
||||
Value: user@example.com:654321
|
||||
TTL: 300秒(5分钟)
|
||||
```
|
||||
|
||||
**频率限制**:
|
||||
```
|
||||
Key: sms:code:ratelimit:13800138000
|
||||
Value: 1
|
||||
TTL: 300秒(5分钟)
|
||||
```
|
||||
|
||||
### API接口变化
|
||||
|
||||
#### 发送验证码接口
|
||||
|
||||
**旧版本**:
|
||||
```json
|
||||
POST /auth/send-sms-code
|
||||
Request: { "phone": "13800138000" }
|
||||
Response: {
|
||||
"code": 200,
|
||||
"message": "验证码已发送",
|
||||
"data": true
|
||||
}
|
||||
```
|
||||
|
||||
**新版本**:
|
||||
```json
|
||||
POST /auth/send-sms-code
|
||||
Request: { "phone": "13800138000" }
|
||||
Response: {
|
||||
"code": 200,
|
||||
"message": "验证码已发送",
|
||||
"data": {
|
||||
"sessionId": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"message": "验证码已发送"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 注册接口
|
||||
|
||||
**旧版本**:
|
||||
```json
|
||||
POST /auth/register
|
||||
{
|
||||
"registerType": "phone",
|
||||
"phone": "13800138000",
|
||||
"smsCode": "123456",
|
||||
"password": "123456",
|
||||
"confirmPassword": "123456"
|
||||
}
|
||||
```
|
||||
|
||||
**新版本**:
|
||||
```json
|
||||
POST /auth/register
|
||||
{
|
||||
"registerType": "phone",
|
||||
"phone": "13800138000",
|
||||
"smsCode": "123456",
|
||||
"smsSessionId": "550e8400-e29b-41d4-a716-446655440000", // 新增
|
||||
"password": "123456",
|
||||
"confirmPassword": "123456"
|
||||
}
|
||||
```
|
||||
|
||||
### 安全优势
|
||||
|
||||
#### 1. 防止验证码盗用
|
||||
|
||||
- **旧方案**:知道手机号+验证码 = 可以注册
|
||||
- **新方案**:需要手机号+验证码+SessionID = 更安全
|
||||
|
||||
攻击者即使获取到验证码,没有SessionID也无法注册。
|
||||
|
||||
#### 2. 会话绑定
|
||||
|
||||
SessionID由发起验证码请求的用户持有,其他人无法获取。
|
||||
|
||||
#### 3. 防止批量注册
|
||||
|
||||
每次发送验证码都会生成新的SessionID,无法重复使用。
|
||||
|
||||
#### 4. 时效性
|
||||
|
||||
SessionID和验证码都有5分钟有效期,过期自动失效。
|
||||
|
||||
### 攻击场景分析
|
||||
|
||||
#### 场景1:短信拦截攻击
|
||||
|
||||
**攻击流程**:
|
||||
1. 攻击者获取目标手机号
|
||||
2. 攻击者拦截短信获取验证码
|
||||
|
||||
**旧方案**:✗ 攻击者可以注册
|
||||
**新方案**:✓ 攻击者没有SessionID,无法注册
|
||||
|
||||
#### 场景2:社会工程学
|
||||
|
||||
**攻击流程**:
|
||||
1. 攻击者诱骗用户说出验证码
|
||||
|
||||
**旧方案**:✗ 攻击者可以注册
|
||||
**新方案**:✓ 攻击者没有SessionID,无法注册
|
||||
|
||||
#### 场景3:中间人攻击
|
||||
|
||||
**攻击流程**:
|
||||
1. 攻击者拦截网络请求获取验证码
|
||||
|
||||
**旧方案**:✗ 攻击者可以注册
|
||||
**新方案**:✗ 攻击者可以拦截SessionID(需要HTTPS)
|
||||
|
||||
**防御**:必须使用HTTPS加密传输
|
||||
|
||||
### 前端实现
|
||||
|
||||
#### 1. 保存SessionID
|
||||
|
||||
```typescript
|
||||
// 发送验证码
|
||||
const sendSmsCode = async () => {
|
||||
const result = await authApi.sendSmsCode(phone);
|
||||
if (result.code === 200 && result.data) {
|
||||
// 保存sessionId到表单
|
||||
registerForm.smsSessionId = result.data.sessionId;
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
#### 2. 提交注册
|
||||
|
||||
```typescript
|
||||
// 注册
|
||||
const register = async () => {
|
||||
const result = await authApi.register({
|
||||
registerType: 'phone',
|
||||
phone: '13800138000',
|
||||
smsCode: '123456',
|
||||
smsSessionId: registerForm.smsSessionId, // 传递sessionId
|
||||
password: '123456',
|
||||
confirmPassword: '123456'
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
### 后端实现
|
||||
|
||||
#### 1. 生成SessionID
|
||||
|
||||
```java
|
||||
// 生成会话ID
|
||||
String sessionId = IDUtils.generateID();
|
||||
|
||||
// 存储验证码(绑定sessionId)
|
||||
String codeKey = "sms:code:" + sessionId;
|
||||
String codeValue = phone + ":" + code;
|
||||
redisService.set(codeKey, codeValue, 5, TimeUnit.MINUTES);
|
||||
|
||||
// 返回sessionId
|
||||
Map<String, String> data = Map.of(
|
||||
"sessionId", sessionId,
|
||||
"message", "验证码已发送"
|
||||
);
|
||||
```
|
||||
|
||||
#### 2. 验证SessionID
|
||||
|
||||
```java
|
||||
// 获取存储的值
|
||||
String codeKey = "sms:code:" + smsSessionId;
|
||||
String storedValue = redisService.get(codeKey);
|
||||
|
||||
// 解析:手机号:验证码
|
||||
String[] parts = storedValue.split(":");
|
||||
String storedPhone = parts[0];
|
||||
String storedCode = parts[1];
|
||||
|
||||
// 验证
|
||||
if (!storedPhone.equals(phone)) {
|
||||
return "手机号与验证码不匹配";
|
||||
}
|
||||
if (!storedCode.equals(smsCode)) {
|
||||
return "验证码错误";
|
||||
}
|
||||
|
||||
// 验证通过后删除
|
||||
redisService.delete(codeKey);
|
||||
```
|
||||
|
||||
### 验证日志
|
||||
|
||||
```
|
||||
[INFO] 短信验证码已发送,手机号: 138****8000, sessionId: 550e8400-e29b-41d4-a716-446655440000
|
||||
[INFO] 手机号注册: 13800138000, sessionId: 550e8400-e29b-41d4-a716-446655440000
|
||||
[WARN] 手机号注册验证失败,提交手机号: 13800138001, 验证码绑定手机号: 13800138000
|
||||
```
|
||||
|
||||
### 注意事项
|
||||
|
||||
1. **HTTPS必须**
|
||||
- SessionID通过HTTP传输,必须使用HTTPS加密
|
||||
- 防止中间人攻击窃取SessionID
|
||||
|
||||
2. **SessionID保密**
|
||||
- 不要在日志中完整输出SessionID
|
||||
- 不要在URL参数中传递SessionID
|
||||
|
||||
3. **有效期设置**
|
||||
- 验证码有效期:5分钟
|
||||
- 频率限制:5分钟内只能发送一次
|
||||
- SessionID随验证码一起失效
|
||||
|
||||
4. **错误提示**
|
||||
- 不要暴露具体的验证失败原因
|
||||
- 统一返回"验证码错误"或"验证码已过期"
|
||||
|
||||
5. **前端存储**
|
||||
- SessionID存储在组件状态中,不持久化
|
||||
- 页面刷新后SessionID丢失,需要重新获取验证码
|
||||
|
||||
## 对比总结
|
||||
|
||||
| 方面 | 旧方案 | 新方案 | 优势 |
|
||||
|------|--------|--------|------|
|
||||
| 存储键 | 手机号 | SessionID | 无法通过手机号直接访问 |
|
||||
| 验证要素 | 手机号+验证码 | 手机号+验证码+SessionID | 多一层安全保障 |
|
||||
| 防止盗用 | ✗ | ✓ | SessionID由请求方持有 |
|
||||
| 批量攻击 | 易受攻击 | 难以攻击 | 每次请求生成新SessionID |
|
||||
| 实现复杂度 | 简单 | 中等 | 增加SessionID管理 |
|
||||
| 前端改动 | 无 | 需要保存SessionID | 增加一个字段 |
|
||||
|
||||
## 推荐实践
|
||||
|
||||
1. ✅ **使用HTTPS**:强制要求HTTPS访问
|
||||
2. ✅ **SessionID加密**:可以考虑对SessionID进行签名
|
||||
3. ✅ **监控异常**:记录验证失败的日志,监控异常行为
|
||||
4. ✅ **IP限流**:限制单个IP的发送频率
|
||||
5. ✅ **图形验证码**:发送验证码前增加图形验证码
|
||||
6. ✅ **行为分析**:分析用户行为,识别机器人
|
||||
7. ✅ **黑名单机制**:记录恶意手机号/IP,加入黑名单
|
||||
|
||||
## 总结
|
||||
|
||||
通过引入SessionID机制,我们将验证码从"谁都可以用"变成了"只有请求者可以用",大大提高了系统的安全性。这是一个简单但有效的安全增强方案,推荐在所有需要验证码的场景中使用。
|
||||
|
||||
@@ -51,6 +51,13 @@ public class LoginParam {
|
||||
*/
|
||||
private String captcha;
|
||||
|
||||
/**
|
||||
* @description 验证码SessionID(用于验证验证码)
|
||||
* @author yslg
|
||||
* @since 2025-11-03
|
||||
*/
|
||||
private String captchaId;
|
||||
|
||||
/**
|
||||
* @description 登录类型 (email/username/phone/wechat)
|
||||
* @author yslg
|
||||
@@ -173,6 +180,24 @@ public class LoginParam {
|
||||
this.captcha = captcha;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 获取验证码SessionID
|
||||
* @author yslg
|
||||
* @since 2025-11-03
|
||||
*/
|
||||
public String getCaptchaId() {
|
||||
return captchaId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 设置验证码SessionID
|
||||
* @author yslg
|
||||
* @since 2025-11-03
|
||||
*/
|
||||
public void setCaptchaId(String captchaId) {
|
||||
this.captchaId = captchaId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 获取登录类型
|
||||
* @author yslg
|
||||
|
||||
@@ -31,6 +31,19 @@
|
||||
<artifactId>poi-ooxml</artifactId>
|
||||
<version>${poi.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Spring Mail for Email -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-mail</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Aliyun SMS Service -->
|
||||
<dependency>
|
||||
<groupId>com.aliyun</groupId>
|
||||
<artifactId>dysmsapi20170525</artifactId>
|
||||
<version>4.2.0</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
</project>
|
||||
@@ -0,0 +1,152 @@
|
||||
package org.xyzh.common.utils;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.mail.SimpleMailMessage;
|
||||
import org.springframework.mail.javamail.JavaMailSender;
|
||||
import org.springframework.mail.javamail.MimeMessageHelper;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import jakarta.mail.MessagingException;
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
|
||||
/**
|
||||
* @description 邮件发送工具类
|
||||
* @filename EmailUtils.java
|
||||
* @author yslg
|
||||
* @copyright xyzh
|
||||
* @since 2025-11-03
|
||||
*/
|
||||
@Component
|
||||
public class EmailUtils {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(EmailUtils.class);
|
||||
|
||||
@Autowired
|
||||
private JavaMailSender mailSender;
|
||||
|
||||
@Value("${spring.mail.username:}")
|
||||
private String from;
|
||||
|
||||
/**
|
||||
* 发送简单文本邮件
|
||||
* @param to 收件人邮箱
|
||||
* @param subject 邮件主题
|
||||
* @param content 邮件内容
|
||||
* @return 是否发送成功
|
||||
*/
|
||||
public boolean sendSimpleEmail(String to, String subject, String content) {
|
||||
try {
|
||||
SimpleMailMessage message = new SimpleMailMessage();
|
||||
message.setFrom(from);
|
||||
message.setTo(to);
|
||||
message.setSubject(subject);
|
||||
message.setText(content);
|
||||
|
||||
mailSender.send(message);
|
||||
logger.info("简单邮件发送成功,收件人: {}", to);
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
logger.error("简单邮件发送失败,收件人: {}, 错误: {}", to, e.getMessage(), e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送HTML格式邮件
|
||||
* @param to 收件人邮箱
|
||||
* @param subject 邮件主题
|
||||
* @param content HTML格式的邮件内容
|
||||
* @return 是否发送成功
|
||||
*/
|
||||
public boolean sendHtmlEmail(String to, String subject, String content) {
|
||||
try {
|
||||
MimeMessage message = mailSender.createMimeMessage();
|
||||
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
|
||||
|
||||
helper.setFrom(from);
|
||||
helper.setTo(to);
|
||||
helper.setSubject(subject);
|
||||
helper.setText(content, true); // true表示HTML格式
|
||||
|
||||
mailSender.send(message);
|
||||
logger.info("HTML邮件发送成功,收件人: {}", to);
|
||||
return true;
|
||||
} catch (MessagingException e) {
|
||||
logger.error("HTML邮件发送失败,收件人: {}, 错误: {}", to, e.getMessage(), e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送验证码邮件
|
||||
* @param to 收件人邮箱
|
||||
* @param code 验证码
|
||||
* @return 是否发送成功
|
||||
*/
|
||||
public boolean sendVerificationCode(String to, String code) {
|
||||
String subject = "【红色思政学习平台】邮箱验证码";
|
||||
String content = buildVerificationCodeHtml(code);
|
||||
return sendHtmlEmail(to, subject, content);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建验证码邮件的HTML内容
|
||||
* @param code 验证码
|
||||
* @return HTML内容
|
||||
*/
|
||||
private String buildVerificationCodeHtml(String code) {
|
||||
return "<!DOCTYPE html>" +
|
||||
"<html>" +
|
||||
"<head>" +
|
||||
"<meta charset=\"UTF-8\">" +
|
||||
"<style>" +
|
||||
"body { font-family: 'Microsoft YaHei', Arial, sans-serif; background-color: #f5f5f5; margin: 0; padding: 20px; }" +
|
||||
".container { max-width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); overflow: hidden; }" +
|
||||
".header { background: linear-gradient(135deg, #C62828 0%, #E53935 100%); padding: 30px; text-align: center; }" +
|
||||
".header h1 { color: #ffffff; margin: 0; font-size: 24px; }" +
|
||||
".content { padding: 40px 30px; }" +
|
||||
".content p { color: #333333; line-height: 1.8; margin: 10px 0; }" +
|
||||
".code-box { background-color: #f8f9fa; border-left: 4px solid #C62828; padding: 20px; margin: 20px 0; text-align: center; }" +
|
||||
".code { font-size: 32px; font-weight: bold; color: #C62828; letter-spacing: 5px; font-family: 'Courier New', monospace; }" +
|
||||
".tips { color: #666666; font-size: 14px; margin-top: 20px; line-height: 1.6; }" +
|
||||
".footer { background-color: #f8f9fa; padding: 20px; text-align: center; color: #999999; font-size: 12px; }" +
|
||||
"</style>" +
|
||||
"</head>" +
|
||||
"<body>" +
|
||||
"<div class=\"container\">" +
|
||||
"<div class=\"header\">" +
|
||||
"<h1>红色思政学习平台</h1>" +
|
||||
"</div>" +
|
||||
"<div class=\"content\">" +
|
||||
"<p>尊敬的用户,您好!</p>" +
|
||||
"<p>您正在进行邮箱验证,您的验证码为:</p>" +
|
||||
"<div class=\"code-box\">" +
|
||||
"<div class=\"code\">" + code + "</div>" +
|
||||
"</div>" +
|
||||
"<div class=\"tips\">" +
|
||||
"<p>• 验证码有效期为10分钟,请尽快完成验证</p>" +
|
||||
"<p>• 如果这不是您的操作,请忽略此邮件</p>" +
|
||||
"<p>• 为了保护您的账号安全,请勿将验证码告知他人</p>" +
|
||||
"</div>" +
|
||||
"</div>" +
|
||||
"<div class=\"footer\">" +
|
||||
"<p>此邮件由系统自动发送,请勿回复</p>" +
|
||||
"<p>Copyright © 红色思政智能体平台</p>" +
|
||||
"</div>" +
|
||||
"</div>" +
|
||||
"</body>" +
|
||||
"</html>";
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成6位数字验证码
|
||||
* @return 验证码
|
||||
*/
|
||||
public static String generateVerificationCode() {
|
||||
return String.valueOf((int)((Math.random() * 9 + 1) * 100000));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
package org.xyzh.common.utils;
|
||||
|
||||
import com.aliyun.dysmsapi20170525.Client;
|
||||
import com.aliyun.dysmsapi20170525.models.SendSmsRequest;
|
||||
import com.aliyun.dysmsapi20170525.models.SendSmsResponse;
|
||||
import com.aliyun.teaopenapi.models.Config;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/**
|
||||
* @description 短信发送工具类 - 支持多种短信服务商
|
||||
* @filename SmsUtils.java
|
||||
* @author yslg
|
||||
* @copyright xyzh
|
||||
* @since 2025-11-03
|
||||
*/
|
||||
@Component
|
||||
public class SmsUtils {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(SmsUtils.class);
|
||||
|
||||
@Value("${sms.enabled:false}")
|
||||
private boolean enabled;
|
||||
|
||||
@Value("${sms.provider:aliyun}")
|
||||
private String provider;
|
||||
|
||||
@Value("${sms.access-key-id:}")
|
||||
private String accessKeyId;
|
||||
|
||||
@Value("${sms.access-key-secret:}")
|
||||
private String accessKeySecret;
|
||||
|
||||
@Value("${sms.sign-name:红色思政学习平台}")
|
||||
private String signName;
|
||||
|
||||
@Value("${sms.template-code:}")
|
||||
private String templateCode;
|
||||
|
||||
@Value("${sms.region-id:cn-hangzhou}")
|
||||
private String regionId;
|
||||
|
||||
@Value("${sms.endpoint:dysmsapi.aliyuncs.com}")
|
||||
private String endpoint;
|
||||
|
||||
/**
|
||||
* 发送短信验证码
|
||||
* @param phone 手机号
|
||||
* @param code 验证码
|
||||
* @return 是否发送成功
|
||||
*/
|
||||
public boolean sendVerificationCode(String phone, String code) {
|
||||
// 如果未启用短信服务,使用模拟模式
|
||||
if (!enabled || !StringUtils.hasText(accessKeyId) || !StringUtils.hasText(accessKeySecret)) {
|
||||
logger.warn("短信服务未配置或未启用,使用模拟模式");
|
||||
logger.info("【模拟发送】短信验证码,手机号: {}, 验证码: {}", phone, code);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 根据配置的服务商选择发送方式
|
||||
switch (provider.toLowerCase()) {
|
||||
case "aliyun":
|
||||
return sendByAliyun(phone, code);
|
||||
case "tencent":
|
||||
logger.warn("腾讯云短信服务暂未实现,使用模拟模式");
|
||||
logger.info("【模拟发送】短信验证码,手机号: {}, 验证码: {}", phone, code);
|
||||
return true;
|
||||
default:
|
||||
logger.error("未知的短信服务商: {}", provider);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用阿里云发送短信验证码
|
||||
* @param phone 手机号
|
||||
* @param code 验证码
|
||||
* @return 是否发送成功
|
||||
*/
|
||||
private boolean sendByAliyun(String phone, String code) {
|
||||
try {
|
||||
Client client = createAliyunClient();
|
||||
|
||||
SendSmsRequest request = new SendSmsRequest()
|
||||
.setPhoneNumbers(phone)
|
||||
.setSignName(signName)
|
||||
.setTemplateCode(templateCode)
|
||||
.setTemplateParam("{\"code\":\"" + code + "\"}");
|
||||
|
||||
SendSmsResponse response = client.sendSms(request);
|
||||
|
||||
if ("OK".equals(response.getBody().getCode())) {
|
||||
logger.info("阿里云短信发送成功,手机号: {}, BizId: {}", phone, response.getBody().getBizId());
|
||||
return true;
|
||||
} else {
|
||||
logger.error("阿里云短信发送失败,手机号: {}, Code: {}, Message: {}",
|
||||
phone, response.getBody().getCode(), response.getBody().getMessage());
|
||||
return false;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.error("阿里云短信发送异常,手机号: {}, 错误: {}", phone, e.getMessage(), e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建阿里云短信客户端
|
||||
* @return 短信客户端
|
||||
*/
|
||||
private Client createAliyunClient() throws Exception {
|
||||
Config config = new Config()
|
||||
.setAccessKeyId(accessKeyId)
|
||||
.setAccessKeySecret(accessKeySecret)
|
||||
.setEndpoint(endpoint);
|
||||
return new Client(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送通用短信(支持自定义模板)
|
||||
* @param phone 手机号
|
||||
* @param templateCode 模板CODE
|
||||
* @param templateParam 模板参数(JSON格式)
|
||||
* @return 是否发送成功
|
||||
*/
|
||||
public boolean sendSms(String phone, String templateCode, String templateParam) {
|
||||
// 如果未启用短信服务,使用模拟模式
|
||||
if (!enabled || !StringUtils.hasText(accessKeyId) || !StringUtils.hasText(accessKeySecret)) {
|
||||
logger.warn("短信服务未配置或未启用,使用模拟模式");
|
||||
logger.info("【模拟发送】短信,手机号: {}, 模板: {}, 参数: {}", phone, templateCode, templateParam);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 根据配置的服务商选择发送方式
|
||||
switch (provider.toLowerCase()) {
|
||||
case "aliyun":
|
||||
return sendSmsAliyun(phone, templateCode, templateParam);
|
||||
case "tencent":
|
||||
logger.warn("腾讯云短信服务暂未实现,使用模拟模式");
|
||||
logger.info("【模拟发送】短信,手机号: {}, 模板: {}, 参数: {}", phone, templateCode, templateParam);
|
||||
return true;
|
||||
default:
|
||||
logger.error("未知的短信服务商: {}", provider);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用阿里云发送通用短信
|
||||
*/
|
||||
private boolean sendSmsAliyun(String phone, String templateCode, String templateParam) {
|
||||
try {
|
||||
Client client = createAliyunClient();
|
||||
|
||||
SendSmsRequest request = new SendSmsRequest()
|
||||
.setPhoneNumbers(phone)
|
||||
.setSignName(signName)
|
||||
.setTemplateCode(templateCode)
|
||||
.setTemplateParam(templateParam);
|
||||
|
||||
SendSmsResponse response = client.sendSms(request);
|
||||
|
||||
if ("OK".equals(response.getBody().getCode())) {
|
||||
logger.info("阿里云短信发送成功,手机号: {}, BizId: {}", phone, response.getBody().getBizId());
|
||||
return true;
|
||||
} else {
|
||||
logger.error("阿里云短信发送失败,手机号: {}, Code: {}, Message: {}",
|
||||
phone, response.getBody().getCode(), response.getBody().getMessage());
|
||||
return false;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.error("阿里云短信发送异常,手机号: {}, 错误: {}", phone, e.getMessage(), e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量发送短信
|
||||
* @param phones 手机号列表,用逗号分隔
|
||||
* @param templateCode 模板CODE
|
||||
* @param templateParam 模板参数(JSON格式)
|
||||
* @return 是否发送成功
|
||||
*/
|
||||
public boolean sendBatchSms(String phones, String templateCode, String templateParam) {
|
||||
// 如果未启用短信服务,使用模拟模式
|
||||
if (!enabled || !StringUtils.hasText(accessKeyId) || !StringUtils.hasText(accessKeySecret)) {
|
||||
logger.warn("短信服务未配置或未启用,使用模拟模式");
|
||||
logger.info("【模拟发送】批量短信,手机号: {}, 模板: {}, 参数: {}", phones, templateCode, templateParam);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 根据配置的服务商选择发送方式
|
||||
switch (provider.toLowerCase()) {
|
||||
case "aliyun":
|
||||
return sendBatchSmsAliyun(phones, templateCode, templateParam);
|
||||
case "tencent":
|
||||
logger.warn("腾讯云短信服务暂未实现,使用模拟模式");
|
||||
logger.info("【模拟发送】批量短信,手机号: {}, 模板: {}, 参数: {}", phones, templateCode, templateParam);
|
||||
return true;
|
||||
default:
|
||||
logger.error("未知的短信服务商: {}", provider);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用阿里云批量发送短信
|
||||
*/
|
||||
private boolean sendBatchSmsAliyun(String phones, String templateCode, String templateParam) {
|
||||
try {
|
||||
Client client = createAliyunClient();
|
||||
|
||||
SendSmsRequest request = new SendSmsRequest()
|
||||
.setPhoneNumbers(phones)
|
||||
.setSignName(signName)
|
||||
.setTemplateCode(templateCode)
|
||||
.setTemplateParam(templateParam);
|
||||
|
||||
SendSmsResponse response = client.sendSms(request);
|
||||
|
||||
if ("OK".equals(response.getBody().getCode())) {
|
||||
logger.info("阿里云批量短信发送成功,手机号: {}, BizId: {}", phones, response.getBody().getBizId());
|
||||
return true;
|
||||
} else {
|
||||
logger.error("阿里云批量短信发送失败,手机号: {}, Code: {}, Message: {}",
|
||||
phones, response.getBody().getCode(), response.getBody().getMessage());
|
||||
return false;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.error("阿里云批量短信发送异常,手机号: {}, 错误: {}", phones, e.getMessage(), e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成6位数字验证码
|
||||
* @return 验证码
|
||||
*/
|
||||
public static String generateVerificationCode() {
|
||||
return String.valueOf((int)((Math.random() * 9 + 1) * 100000));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证手机号格式
|
||||
* @param phone 手机号
|
||||
* @return 是否有效
|
||||
*/
|
||||
public static boolean isValidPhone(String phone) {
|
||||
if (phone == null || phone.trim().isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
// 中国大陆手机号验证
|
||||
String regex = "^1[3-9]\\d{9}$";
|
||||
return phone.matches(regex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { api } from '@/apis/index';
|
||||
import type { LoginParam, LoginDomain, ResultDomain } from '@/types';
|
||||
import type { LoginParam, RegisterParam, LoginDomain, ResultDomain } from '@/types';
|
||||
|
||||
/**
|
||||
* 认证API服务
|
||||
@@ -21,6 +21,16 @@ export const authApi = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 用户注册
|
||||
* @param registerParam 注册参数
|
||||
* @returns Promise<ResultDomain<LoginDomain>>
|
||||
*/
|
||||
async register(registerParam: RegisterParam): Promise<ResultDomain<LoginDomain>> {
|
||||
const response = await api.post<LoginDomain>('/auth/register', registerParam);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 用户登出
|
||||
* @returns Promise<ResultDomain<string>>
|
||||
@@ -51,20 +61,20 @@ export const authApi = {
|
||||
/**
|
||||
* 发送手机验证码
|
||||
* @param phone 手机号
|
||||
* @returns Promise<ResultDomain<boolean>>
|
||||
* @returns Promise<ResultDomain<{sessionId: string, message: string}>>
|
||||
*/
|
||||
async sendSmsCode(phone: string): Promise<ResultDomain<boolean>> {
|
||||
const response = await api.post<boolean>('/auth/send-sms-code', { phone });
|
||||
async sendSmsCode(phone: string): Promise<ResultDomain<{sessionId: string, message: string}>> {
|
||||
const response = await api.post<{sessionId: string, message: string}>('/auth/send-sms-code', { phone });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 发送邮箱验证码
|
||||
* @param email 邮箱
|
||||
* @returns Promise<ResultDomain<boolean>>
|
||||
* @returns Promise<ResultDomain<{sessionId: string, message: string}>>
|
||||
*/
|
||||
async sendEmailCode(email: string): Promise<ResultDomain<boolean>> {
|
||||
const response = await api.post<boolean>('/auth/send-email-code', { email });
|
||||
async sendEmailCode(email: string): Promise<ResultDomain<{sessionId: string, message: string}>> {
|
||||
const response = await api.post<{sessionId: string, message: string}>('/auth/send-email-code', { email });
|
||||
return response.data;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<span class="avatar-placeholder" v-else>{{ avatarText }}</span>
|
||||
</div>
|
||||
<div class="user-details" v-if="!collapsed">
|
||||
<div class="user-name">{{ user?.realName || user?.username }}</div>
|
||||
<div class="user-name">{{ user?.fullName || user?.username }}</div>
|
||||
<div class="user-role">{{ primaryRole }}</div>
|
||||
</div>
|
||||
<i class="dropdown-icon" :class="{ 'open': dropdownVisible }"></i>
|
||||
@@ -113,13 +113,13 @@ const userAvatar = computed(() => {
|
||||
});
|
||||
|
||||
const avatarText = computed(() => {
|
||||
const name = props.user?.realName || props.user?.username || '';
|
||||
const name = props.user?.fullName || props.user?.username || '';
|
||||
return name.charAt(0).toUpperCase();
|
||||
});
|
||||
|
||||
const primaryRole = computed(() => {
|
||||
// 这里可以从store中获取用户角色信息
|
||||
return '管理员'; // 暂时硬编码
|
||||
return store.getters['auth/userRoles'][0].roleName || '';
|
||||
});
|
||||
|
||||
// 获取用户下拉菜单(个人中心和账号中心)
|
||||
|
||||
@@ -24,13 +24,45 @@ export interface AuthState {
|
||||
routesLoaded: boolean;
|
||||
}
|
||||
|
||||
// 从localStorage恢复状态的辅助函数
|
||||
// 存储工具函数
|
||||
const StorageUtil = {
|
||||
// 保存数据(根据rememberMe选择存储方式)
|
||||
setItem(key: string, value: string, rememberMe = false) {
|
||||
if (rememberMe) {
|
||||
localStorage.setItem(key, value);
|
||||
} else {
|
||||
sessionStorage.setItem(key, value);
|
||||
}
|
||||
},
|
||||
|
||||
// 获取数据(优先从localStorage,其次sessionStorage)
|
||||
getItem(key: string): string | null {
|
||||
return localStorage.getItem(key) || sessionStorage.getItem(key);
|
||||
},
|
||||
|
||||
// 删除数据(从两个存储中都删除)
|
||||
removeItem(key: string) {
|
||||
localStorage.removeItem(key);
|
||||
sessionStorage.removeItem(key);
|
||||
},
|
||||
|
||||
// 清除所有认证相关数据
|
||||
clearAuth() {
|
||||
const keys = ['token', 'loginDomain', 'menus', 'permissions', 'rememberMe'];
|
||||
keys.forEach(key => {
|
||||
localStorage.removeItem(key);
|
||||
sessionStorage.removeItem(key);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 从存储恢复状态的辅助函数
|
||||
function getStoredState(): Partial<AuthState> {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const loginDomainStr = localStorage.getItem('loginDomain');
|
||||
const menusStr = localStorage.getItem('menus');
|
||||
const permissionsStr = localStorage.getItem('permissions');
|
||||
const token = StorageUtil.getItem('token');
|
||||
const loginDomainStr = StorageUtil.getItem('loginDomain');
|
||||
const menusStr = StorageUtil.getItem('menus');
|
||||
const permissionsStr = StorageUtil.getItem('permissions');
|
||||
|
||||
return {
|
||||
token: token || null,
|
||||
@@ -40,7 +72,7 @@ function getStoredState(): Partial<AuthState> {
|
||||
routesLoaded: false, // 路由始终需要重新加载
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('从localStorage恢复状态失败:', error);
|
||||
console.error('从存储恢复状态失败:', error);
|
||||
return {
|
||||
token: null,
|
||||
loginDomain: null,
|
||||
@@ -111,50 +143,57 @@ const authModule: Module<AuthState, any> = {
|
||||
|
||||
mutations: {
|
||||
// 设置登录信息
|
||||
SET_LOGIN_DOMAIN(state, loginDomain: LoginDomain) {
|
||||
SET_LOGIN_DOMAIN(state, payload: { loginDomain: LoginDomain; rememberMe?: boolean }) {
|
||||
const { loginDomain, rememberMe = false } = payload;
|
||||
|
||||
state.loginDomain = loginDomain;
|
||||
state.token = loginDomain.token || null;
|
||||
state.menus = loginDomain.menus || [];
|
||||
state.permissions = loginDomain.permissions || [];
|
||||
|
||||
// 持久化到localStorage
|
||||
// 持久化到存储(根据rememberMe选择localStorage或sessionStorage)
|
||||
if (state.token) {
|
||||
localStorage.setItem('token', state.token);
|
||||
StorageUtil.setItem('token', state.token, rememberMe);
|
||||
}
|
||||
if (loginDomain) {
|
||||
localStorage.setItem('loginDomain', JSON.stringify(loginDomain));
|
||||
StorageUtil.setItem('loginDomain', JSON.stringify(loginDomain), rememberMe);
|
||||
}
|
||||
if (state.menus.length > 0) {
|
||||
localStorage.setItem('menus', JSON.stringify(state.menus));
|
||||
StorageUtil.setItem('menus', JSON.stringify(state.menus), rememberMe);
|
||||
}
|
||||
if (state.permissions.length > 0) {
|
||||
localStorage.setItem('permissions', JSON.stringify(state.permissions));
|
||||
StorageUtil.setItem('permissions', JSON.stringify(state.permissions), rememberMe);
|
||||
}
|
||||
// 保存rememberMe标记
|
||||
StorageUtil.setItem('rememberMe', String(rememberMe), rememberMe);
|
||||
},
|
||||
|
||||
// 设置Token
|
||||
SET_TOKEN(state, token: string | null) {
|
||||
state.token = token;
|
||||
const rememberMe = StorageUtil.getItem('rememberMe') === 'true';
|
||||
if (token) {
|
||||
localStorage.setItem('token', token);
|
||||
StorageUtil.setItem('token', token, rememberMe);
|
||||
} else {
|
||||
localStorage.removeItem('token');
|
||||
StorageUtil.removeItem('token');
|
||||
}
|
||||
},
|
||||
|
||||
// 设置菜单
|
||||
SET_MENUS(state, menus: SysMenu[]) {
|
||||
state.menus = menus;
|
||||
const rememberMe = StorageUtil.getItem('rememberMe') === 'true';
|
||||
if (menus.length > 0) {
|
||||
localStorage.setItem('menus', JSON.stringify(menus));
|
||||
StorageUtil.setItem('menus', JSON.stringify(menus), rememberMe);
|
||||
}
|
||||
},
|
||||
|
||||
// 设置权限
|
||||
SET_PERMISSIONS(state, permissions: SysPermission[]) {
|
||||
state.permissions = permissions;
|
||||
const rememberMe = StorageUtil.getItem('rememberMe') === 'true';
|
||||
if (permissions.length > 0) {
|
||||
localStorage.setItem('permissions', JSON.stringify(permissions));
|
||||
StorageUtil.setItem('permissions', JSON.stringify(permissions), rememberMe);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -171,11 +210,8 @@ const authModule: Module<AuthState, any> = {
|
||||
state.permissions = [];
|
||||
state.routesLoaded = false;
|
||||
|
||||
// 清除localStorage
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('loginDomain');
|
||||
localStorage.removeItem('menus');
|
||||
localStorage.removeItem('permissions');
|
||||
// 清除所有认证相关存储(localStorage和sessionStorage)
|
||||
StorageUtil.clearAuth();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -188,8 +224,12 @@ const authModule: Module<AuthState, any> = {
|
||||
return Promise.reject(result.message);
|
||||
}
|
||||
const loginDomain = result.data;
|
||||
// 保存登录信息
|
||||
commit('SET_LOGIN_DOMAIN', loginDomain);
|
||||
|
||||
// 保存登录信息(传递rememberMe参数)
|
||||
commit('SET_LOGIN_DOMAIN', {
|
||||
loginDomain,
|
||||
rememberMe: loginParam.rememberMe || false
|
||||
});
|
||||
|
||||
// 生成动态路由
|
||||
await dispatch('generateRoutes');
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { SysUser, SysUserInfo } from '../user';
|
||||
import { DeptRoleVO } from '../dept';
|
||||
import { UserDeptRoleVO } from '../dept';
|
||||
import { SysPermission } from '../permission';
|
||||
import { SysMenu } from '../menu';
|
||||
import { LoginType } from '../enums';
|
||||
@@ -32,6 +32,52 @@ export interface LoginParam {
|
||||
captchaId?: string;
|
||||
/** 记住我 */
|
||||
rememberMe?: boolean;
|
||||
/** 同意用户协议 */
|
||||
agree?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册方式枚举
|
||||
*/
|
||||
export enum RegisterType {
|
||||
/** 用户名注册 */
|
||||
USERNAME = 'username',
|
||||
/** 手机号注册 */
|
||||
PHONE = 'phone',
|
||||
/** 邮箱注册 */
|
||||
EMAIL = 'email'
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册参数
|
||||
*/
|
||||
export interface RegisterParam {
|
||||
/** 注册方式 */
|
||||
registerType: RegisterType;
|
||||
/** 用户名 */
|
||||
username?: string;
|
||||
/** 真实姓名 */
|
||||
realName?: string;
|
||||
/** 手机号 */
|
||||
phone?: string;
|
||||
/** 邮箱 */
|
||||
email?: string;
|
||||
/** 密码 */
|
||||
password: string;
|
||||
/** 确认密码 */
|
||||
confirmPassword: string;
|
||||
/** 学号 */
|
||||
studentId?: string;
|
||||
/** 手机验证码 */
|
||||
smsCode?: string;
|
||||
/** 邮箱验证码 */
|
||||
emailCode?: string;
|
||||
/** 短信会话ID */
|
||||
smsSessionId?: string;
|
||||
/** 邮箱会话ID */
|
||||
emailSessionId?: string;
|
||||
/** 同意用户协议 */
|
||||
agree: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -43,7 +89,7 @@ export interface LoginDomain {
|
||||
/** 用户详细信息 */
|
||||
userInfo?: SysUserInfo;
|
||||
/** 用户角色列表 */
|
||||
roles?: DeptRoleVO[];
|
||||
roles?: UserDeptRoleVO[];
|
||||
/** 用户权限列表 */
|
||||
permissions?: SysPermission[];
|
||||
/** 用户菜单列表 */
|
||||
|
||||
@@ -109,10 +109,10 @@
|
||||
<el-input v-model="currentUser.phone" placeholder="请输入手机号" />
|
||||
</el-form-item>
|
||||
<el-form-item label="真实姓名" prop="realName">
|
||||
<el-input v-model="currentUser.realName" placeholder="请输入真实姓名" />
|
||||
<el-input v-model="currentUser.fullName" placeholder="请输入真实姓名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="昵称" prop="nickname">
|
||||
<el-input v-model="currentUser.nickname" placeholder="请输入昵称" />
|
||||
<el-input v-model="currentUser.username" placeholder="请输入昵称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-radio-group v-model="currentUser.status">
|
||||
|
||||
@@ -24,7 +24,23 @@
|
||||
</div>
|
||||
<h1 class="platform-title">红色思政学习平台</h1>
|
||||
</div>
|
||||
<h2 class="login-title">账号登录</h2>
|
||||
<h2 class="login-title">{{ loginModeTitle }}</h2>
|
||||
</div>
|
||||
|
||||
<!-- 登录方式切换 -->
|
||||
<div class="login-mode-tabs">
|
||||
<div
|
||||
:class="['tab-item', { active: loginMode === 'password' }]"
|
||||
@click="switchLoginMode('password')"
|
||||
>
|
||||
密码登录
|
||||
</div>
|
||||
<div
|
||||
:class="['tab-item', { active: loginMode === 'captcha' }]"
|
||||
@click="switchLoginMode('captcha')"
|
||||
>
|
||||
验证码登录
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 登录表单 -->
|
||||
@@ -35,23 +51,104 @@
|
||||
class="login-form"
|
||||
@submit.prevent="handleLogin"
|
||||
>
|
||||
<el-form-item prop="username">
|
||||
<el-input
|
||||
v-model="loginForm.username"
|
||||
placeholder="请输入学号、手机号"
|
||||
class="form-input"
|
||||
/>
|
||||
</el-form-item>
|
||||
<!-- 密码登录模式 -->
|
||||
<template v-if="loginMode === 'password'">
|
||||
<el-form-item prop="username">
|
||||
<el-input
|
||||
v-model="loginForm.username"
|
||||
placeholder="请输入学号、手机号、邮箱"
|
||||
class="form-input"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="password">
|
||||
<el-input
|
||||
v-model="loginForm.password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
show-password
|
||||
class="form-input"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item prop="password">
|
||||
<el-input
|
||||
v-model="loginForm.password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
show-password
|
||||
class="form-input"
|
||||
/>
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<!-- 验证码登录模式 -->
|
||||
<template v-else>
|
||||
<!-- 登录方式选择 -->
|
||||
<div class="captcha-type-tabs">
|
||||
<div
|
||||
:class="['captcha-tab-item', { active: captchaType === 'phone' }]"
|
||||
@click="switchCaptchaType('phone')"
|
||||
>
|
||||
手机号
|
||||
</div>
|
||||
<div
|
||||
:class="['captcha-tab-item', { active: captchaType === 'email' }]"
|
||||
@click="switchCaptchaType('email')"
|
||||
>
|
||||
邮箱
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 手机号验证码登录 -->
|
||||
<template v-if="captchaType === 'phone'">
|
||||
<el-form-item prop="phone">
|
||||
<el-input
|
||||
v-model="loginForm.phone"
|
||||
placeholder="请输入手机号"
|
||||
class="form-input"
|
||||
maxlength="11"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="captcha">
|
||||
<div class="captcha-input-container">
|
||||
<el-input
|
||||
v-model="loginForm.captcha"
|
||||
placeholder="请输入验证码"
|
||||
class="form-input captcha-input"
|
||||
maxlength="6"
|
||||
/>
|
||||
<el-button
|
||||
class="send-captcha-btn"
|
||||
:disabled="smsCountdown > 0"
|
||||
@click="handleSendSmsCode"
|
||||
>
|
||||
{{ smsCountdown > 0 ? `${smsCountdown}秒后重试` : '获取验证码' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<!-- 邮箱验证码登录 -->
|
||||
<template v-else>
|
||||
<el-form-item prop="email">
|
||||
<el-input
|
||||
v-model="loginForm.email"
|
||||
placeholder="请输入邮箱"
|
||||
class="form-input"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="captcha">
|
||||
<div class="captcha-input-container">
|
||||
<el-input
|
||||
v-model="loginForm.captcha"
|
||||
placeholder="请输入验证码"
|
||||
class="form-input captcha-input"
|
||||
maxlength="6"
|
||||
/>
|
||||
<el-button
|
||||
class="send-captcha-btn"
|
||||
:disabled="emailCountdown > 0"
|
||||
@click="handleSendEmailCode"
|
||||
>
|
||||
{{ emailCountdown > 0 ? `${emailCountdown}秒后重试` : '获取验证码' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<el-form-item>
|
||||
<div class="login-options">
|
||||
@@ -75,8 +172,11 @@
|
||||
>
|
||||
登录
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="agree">
|
||||
<p class="agreement-text">
|
||||
<el-checkbox v-model="loginForm.rememberMe">登录即为同意<span class="agreement-link" style="color: red">《红色思政智能体平台》</span></el-checkbox>
|
||||
<el-checkbox v-model="loginForm.agree">登录即为同意<span class="agreement-link" style="color: red">《红色思政智能体平台》</span></el-checkbox>
|
||||
</p>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
@@ -96,11 +196,12 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue';
|
||||
import { ref, reactive, onMounted, computed } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { useStore } from 'vuex';
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus';
|
||||
import type { LoginParam } from '@/types';
|
||||
import { LoginType } from '@/types/enums';
|
||||
import { authApi } from '@/apis/system/auth';
|
||||
|
||||
// 响应式引用
|
||||
@@ -109,6 +210,15 @@ const loginLoading = ref(false);
|
||||
const showCaptcha = ref(false);
|
||||
const captchaImage = ref('');
|
||||
|
||||
// 登录模式:password-密码登录,captcha-验证码登录
|
||||
const loginMode = ref<'password' | 'captcha'>('password');
|
||||
// 验证码类型:phone-手机号,email-邮箱
|
||||
const captchaType = ref<'phone' | 'email'>('phone');
|
||||
|
||||
// 倒计时
|
||||
const smsCountdown = ref(0);
|
||||
const emailCountdown = ref(0);
|
||||
|
||||
// Composition API
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
@@ -116,39 +226,222 @@ const store = useStore();
|
||||
|
||||
// 表单数据
|
||||
const loginForm = reactive<LoginParam>({
|
||||
loginType: undefined,
|
||||
username: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
password: '',
|
||||
captcha: '',
|
||||
captchaId: '',
|
||||
rememberMe: false
|
||||
rememberMe: false,
|
||||
agree: false
|
||||
});
|
||||
|
||||
// 计算属性:登录模式标题
|
||||
const loginModeTitle = computed(() => {
|
||||
return loginMode.value === 'password' ? '密码登录' : '验证码登录';
|
||||
});
|
||||
|
||||
// 表单验证规则
|
||||
const loginRules: FormRules = {
|
||||
username: [
|
||||
{ required: true, message: '请输入用户名', trigger: 'blur' },
|
||||
{ min: 3, max: 20, message: '用户名长度为3-20个字符', trigger: 'blur' }
|
||||
{
|
||||
required: loginMode.value === 'password',
|
||||
message: '请输入用户名、手机号或邮箱',
|
||||
trigger: 'blur'
|
||||
}
|
||||
],
|
||||
phone: [
|
||||
{
|
||||
required: loginMode.value === 'captcha' && captchaType.value === 'phone',
|
||||
message: '请输入手机号',
|
||||
trigger: 'blur'
|
||||
},
|
||||
{
|
||||
pattern: /^1[3-9]\d{9}$/,
|
||||
message: '请输入正确的手机号',
|
||||
trigger: 'blur'
|
||||
}
|
||||
],
|
||||
email: [
|
||||
{
|
||||
required: loginMode.value === 'captcha' && captchaType.value === 'email',
|
||||
message: '请输入邮箱',
|
||||
trigger: 'blur'
|
||||
},
|
||||
{
|
||||
type: 'email',
|
||||
message: '请输入正确的邮箱格式',
|
||||
trigger: 'blur'
|
||||
}
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||
{ min: 6, message: '密码至少6个字符', trigger: 'blur' }
|
||||
{
|
||||
required: loginMode.value === 'password',
|
||||
message: '请输入密码',
|
||||
trigger: 'blur'
|
||||
},
|
||||
{
|
||||
min: 6,
|
||||
message: '密码至少6个字符',
|
||||
trigger: 'blur'
|
||||
}
|
||||
],
|
||||
captcha: [
|
||||
{ required: true, message: '请输入验证码', trigger: 'blur' },
|
||||
{ len: 4, message: '验证码为4位', trigger: 'blur' }
|
||||
{
|
||||
required: loginMode.value === 'captcha',
|
||||
message: '请输入验证码',
|
||||
trigger: 'blur'
|
||||
},
|
||||
{
|
||||
len: 6,
|
||||
message: '验证码为6位',
|
||||
trigger: 'blur'
|
||||
}
|
||||
],
|
||||
agree: [
|
||||
{
|
||||
validator: (rule: any, value: boolean, callback: any) => {
|
||||
if (!value) {
|
||||
callback(new Error('请同意《红色思政智能体平台》用户协议'));
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
},
|
||||
trigger: 'change'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// 切换登录模式
|
||||
const switchLoginMode = (mode: 'password' | 'captcha') => {
|
||||
if (loginMode.value === mode) return;
|
||||
|
||||
loginMode.value = mode;
|
||||
|
||||
// 清空表单数据和验证
|
||||
loginFormRef.value?.clearValidate();
|
||||
loginForm.username = '';
|
||||
loginForm.phone = '';
|
||||
loginForm.email = '';
|
||||
loginForm.password = '';
|
||||
loginForm.captcha = '';
|
||||
loginForm.captchaId = '';
|
||||
};
|
||||
|
||||
// 切换验证码类型
|
||||
const switchCaptchaType = (type: 'phone' | 'email') => {
|
||||
if (captchaType.value === type) return;
|
||||
|
||||
captchaType.value = type;
|
||||
|
||||
// 清空相关表单数据和验证
|
||||
loginFormRef.value?.clearValidate();
|
||||
loginForm.phone = '';
|
||||
loginForm.email = '';
|
||||
loginForm.captcha = '';
|
||||
loginForm.captchaId = '';
|
||||
};
|
||||
|
||||
// 发送短信验证码
|
||||
const handleSendSmsCode = async () => {
|
||||
// 验证手机号
|
||||
if (!loginForm.phone || loginForm.phone.trim() === '') {
|
||||
ElMessage.warning('请输入手机号');
|
||||
return;
|
||||
}
|
||||
|
||||
const phonePattern = /^1[3-9]\d{9}$/;
|
||||
if (!phonePattern.test(loginForm.phone)) {
|
||||
ElMessage.warning('请输入正确的手机号');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await authApi.sendSmsCode(loginForm.phone);
|
||||
if (result.code === 200 && result.data) {
|
||||
// 保存sessionId
|
||||
loginForm.captchaId = result.data.sessionId;
|
||||
ElMessage.success(result.data.message || '验证码已发送');
|
||||
|
||||
// 开始倒计时
|
||||
smsCountdown.value = 60;
|
||||
const timer = setInterval(() => {
|
||||
smsCountdown.value--;
|
||||
if (smsCountdown.value <= 0) {
|
||||
clearInterval(timer);
|
||||
}
|
||||
}, 1000);
|
||||
} else {
|
||||
ElMessage.error(result.message || '发送验证码失败');
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('发送验证码失败:', error);
|
||||
ElMessage.error(error.message || '发送验证码失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 发送邮箱验证码
|
||||
const handleSendEmailCode = async () => {
|
||||
// 验证邮箱
|
||||
if (!loginForm.email || loginForm.email.trim() === '') {
|
||||
ElMessage.warning('请输入邮箱');
|
||||
return;
|
||||
}
|
||||
|
||||
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailPattern.test(loginForm.email)) {
|
||||
ElMessage.warning('请输入正确的邮箱格式');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await authApi.sendEmailCode(loginForm.email);
|
||||
if (result.code === 200 && result.data) {
|
||||
// 保存sessionId
|
||||
loginForm.captchaId = result.data.sessionId;
|
||||
ElMessage.success(result.data.message || '验证码已发送到邮箱');
|
||||
|
||||
// 开始倒计时
|
||||
emailCountdown.value = 60;
|
||||
const timer = setInterval(() => {
|
||||
emailCountdown.value--;
|
||||
if (emailCountdown.value <= 0) {
|
||||
clearInterval(timer);
|
||||
}
|
||||
}, 1000);
|
||||
} else {
|
||||
ElMessage.error(result.message || '发送验证码失败');
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('发送验证码失败:', error);
|
||||
ElMessage.error(error.message || '发送验证码失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 方法
|
||||
const handleLogin = async () => {
|
||||
if (!loginFormRef.value) return;
|
||||
|
||||
// 检查是否同意用户协议
|
||||
if (!loginForm.agree) {
|
||||
ElMessage.warning('请先同意《红色思政智能体平台》用户协议');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const valid = await loginFormRef.value.validate();
|
||||
if (!valid) return;
|
||||
|
||||
loginLoading.value = true;
|
||||
|
||||
// 根据登录模式设置loginType
|
||||
if (loginMode.value === 'password') {
|
||||
loginForm.loginType = LoginType.PASSWORD;
|
||||
} else {
|
||||
loginForm.loginType = captchaType.value === 'phone' ? LoginType.PHONE : LoginType.EMAIL;
|
||||
}
|
||||
|
||||
// 调用store中的登录action
|
||||
const result = await store.dispatch('auth/login', loginForm);
|
||||
|
||||
@@ -164,7 +457,7 @@ const handleLogin = async () => {
|
||||
|
||||
// 登录失败后显示验证码
|
||||
showCaptcha.value = true;
|
||||
refreshCaptcha();
|
||||
// refreshCaptcha();
|
||||
} finally {
|
||||
loginLoading.value = false;
|
||||
}
|
||||
@@ -322,6 +615,60 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.login-mode-tabs {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
border-bottom: 1px solid #E8E8E8;
|
||||
|
||||
.tab-item {
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
color: #C62828;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: #C62828;
|
||||
border-bottom-color: #C62828;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.captcha-type-tabs {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.captcha-tab-item {
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
background: #F2F3F5;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
background: #E8E8E8;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: #FFFFFF;
|
||||
background: #C62828;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.login-form {
|
||||
.form-input {
|
||||
width: 100%;
|
||||
@@ -343,6 +690,37 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.captcha-input-container {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
|
||||
.captcha-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.send-captcha-btn {
|
||||
height: 48px;
|
||||
padding: 0 16px;
|
||||
background: #C62828;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
color: #FFFFFF;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #B71C1C;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: #D9D9D9;
|
||||
color: rgba(0, 0, 0, 0.25);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.login-options {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
@@ -1,25 +1,187 @@
|
||||
<template>
|
||||
<div class="register-container">
|
||||
<div class="register-box">
|
||||
<div class="register-header">
|
||||
<div class="logo">
|
||||
<img src="@/assets/logo.png" alt="Logo" />
|
||||
</div>
|
||||
<h1 class="title">注册账户</h1>
|
||||
<p class="subtitle">创建您的校园新闻管理系统账户</p>
|
||||
</div>
|
||||
|
||||
<div class="register-form">
|
||||
<!-- 注册表单内容 -->
|
||||
<div class="form-placeholder">
|
||||
<p>注册功能开发中...</p>
|
||||
<!-- 左侧励志区域 -->
|
||||
<div class="register-left">
|
||||
<div class="left-content">
|
||||
<div class="quote-text">
|
||||
<span class="quote-mark">“</span>
|
||||
<div class="quote-content">
|
||||
<p>不负时代韶华,</p>
|
||||
<p>争做时代新人。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧注册表单区域 -->
|
||||
<div class="register-right">
|
||||
<div class="register-form-container">
|
||||
<!-- Logo和标题区域 -->
|
||||
<div class="register-header">
|
||||
<div class="logo-section">
|
||||
<div class="logo">
|
||||
<img src="@/assets/imgs/logo-icon.svg" alt="Logo" />
|
||||
</div>
|
||||
<h1 class="platform-title">红色思政学习平台</h1>
|
||||
</div>
|
||||
<h2 class="register-title">{{ registerTypeTitle }}</h2>
|
||||
|
||||
<!-- 注册方式切换 -->
|
||||
<div class="register-type-tabs">
|
||||
<div
|
||||
class="tab-item"
|
||||
:class="{ active: registerType === RegisterType.USERNAME }"
|
||||
@click="switchRegisterType(RegisterType.USERNAME)"
|
||||
>
|
||||
用户名
|
||||
</div>
|
||||
<div
|
||||
class="tab-item"
|
||||
:class="{ active: registerType === RegisterType.PHONE }"
|
||||
@click="switchRegisterType(RegisterType.PHONE)"
|
||||
>
|
||||
手机号
|
||||
</div>
|
||||
<div
|
||||
class="tab-item"
|
||||
:class="{ active: registerType === RegisterType.EMAIL }"
|
||||
@click="switchRegisterType(RegisterType.EMAIL)"
|
||||
>
|
||||
邮箱
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 注册表单 -->
|
||||
<el-form
|
||||
ref="registerFormRef"
|
||||
:model="registerForm"
|
||||
:rules="registerRules"
|
||||
class="register-form"
|
||||
@submit.prevent="handleRegister"
|
||||
>
|
||||
<!-- 用户名注册 -->
|
||||
<template v-if="registerType === RegisterType.USERNAME">
|
||||
<el-form-item prop="username">
|
||||
<el-input
|
||||
v-model="registerForm.username"
|
||||
placeholder="请输入用户名"
|
||||
class="form-input"
|
||||
/>
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<!-- 手机号注册 -->
|
||||
<template v-if="registerType === RegisterType.PHONE">
|
||||
<el-form-item prop="phone">
|
||||
<el-input
|
||||
v-model="registerForm.phone"
|
||||
placeholder="请输入手机号"
|
||||
class="form-input"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="smsCode">
|
||||
<div class="phone-input-wrapper">
|
||||
<el-input
|
||||
v-model="registerForm.smsCode"
|
||||
placeholder="请输入手机验证码"
|
||||
class="form-input phone-input"
|
||||
/>
|
||||
<el-button
|
||||
:disabled="smsCountdown > 0"
|
||||
@click="handleSendSmsCode"
|
||||
class="code-button"
|
||||
>
|
||||
{{ smsCountdown > 0 ? `${smsCountdown}s` : '获取验证码' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<!-- 邮箱注册 -->
|
||||
<template v-if="registerType === RegisterType.EMAIL">
|
||||
<el-form-item prop="email">
|
||||
<el-input
|
||||
v-model="registerForm.email"
|
||||
placeholder="请输入邮箱"
|
||||
class="form-input"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="emailCode">
|
||||
<div class="phone-input-wrapper">
|
||||
<el-input
|
||||
v-model="registerForm.emailCode"
|
||||
placeholder="请输入邮箱验证码"
|
||||
class="form-input phone-input"
|
||||
/>
|
||||
<el-button
|
||||
:disabled="emailCountdown > 0"
|
||||
@click="handleSendEmailCode"
|
||||
class="code-button"
|
||||
>
|
||||
{{ emailCountdown > 0 ? `${emailCountdown}s` : '获取验证码' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<!-- 通用字段 -->
|
||||
<!-- <el-form-item prop="studentId">
|
||||
<el-input
|
||||
v-model="registerForm.studentId"
|
||||
placeholder="请输入学号"
|
||||
class="form-input"
|
||||
/>
|
||||
</el-form-item> -->
|
||||
|
||||
<el-form-item prop="password">
|
||||
<el-input
|
||||
v-model="registerForm.password"
|
||||
type="password"
|
||||
placeholder="请输入密码(至少6个字符)"
|
||||
show-password
|
||||
class="form-input"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="confirmPassword">
|
||||
<el-input
|
||||
v-model="registerForm.confirmPassword"
|
||||
type="password"
|
||||
placeholder="请再次输入密码"
|
||||
show-password
|
||||
class="form-input"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="registerLoading"
|
||||
@click="handleRegister"
|
||||
class="register-button"
|
||||
>
|
||||
注册
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="agree">
|
||||
<p class="agreement-text">
|
||||
<el-checkbox v-model="registerForm.agree">注册即为同意<span class="agreement-link" style="color: red">《红色思政智能体平台》</span></el-checkbox>
|
||||
</p>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
</div>
|
||||
<!-- 底部信息 -->
|
||||
<div class="register-footer">
|
||||
<p>
|
||||
已有账户?
|
||||
<el-link type="primary" @click="goToLogin">立即登录</el-link>
|
||||
<p class="login-link">
|
||||
已有账号?<span class="login-link-text" @click="goToLogin">立即登录</span>
|
||||
</p>
|
||||
<p class="copyright">
|
||||
Copyright ©红色思政智能体平台
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -27,75 +189,622 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus';
|
||||
import type { RegisterParam } from '@/types';
|
||||
import { RegisterType } from '@/types';
|
||||
import { authApi } from '@/apis/system/auth';
|
||||
|
||||
// 响应式引用
|
||||
const registerFormRef = ref<FormInstance>();
|
||||
const registerLoading = ref(false);
|
||||
const smsCountdown = ref(0);
|
||||
const emailCountdown = ref(0);
|
||||
let smsTimer: number | null = null;
|
||||
let emailTimer: number | null = null;
|
||||
|
||||
// Composition API
|
||||
const router = useRouter();
|
||||
|
||||
// 注册方式
|
||||
const registerType = ref<RegisterType>(RegisterType.USERNAME);
|
||||
|
||||
// 表单数据
|
||||
const registerForm = reactive<RegisterParam>({
|
||||
registerType: RegisterType.USERNAME,
|
||||
username: '',
|
||||
studentId: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
smsCode: '',
|
||||
emailCode: '',
|
||||
smsSessionId: '',
|
||||
emailSessionId: '',
|
||||
agree: false
|
||||
});
|
||||
|
||||
// 根据注册方式显示不同的标题
|
||||
const registerTypeTitle = computed(() => {
|
||||
switch (registerType.value) {
|
||||
case RegisterType.USERNAME:
|
||||
return '用户名注册';
|
||||
case RegisterType.PHONE:
|
||||
return '手机号注册';
|
||||
case RegisterType.EMAIL:
|
||||
return '邮箱注册';
|
||||
default:
|
||||
return '账号注册';
|
||||
}
|
||||
});
|
||||
|
||||
// 切换注册方式
|
||||
const switchRegisterType = (type: RegisterType) => {
|
||||
registerType.value = type;
|
||||
registerForm.registerType = type;
|
||||
// 清空表单验证
|
||||
registerFormRef.value?.clearValidate();
|
||||
};
|
||||
|
||||
// 自定义验证器
|
||||
const validatePass = (rule: any, value: string, callback: any) => {
|
||||
if (value === '') {
|
||||
callback(new Error('请输入密码'));
|
||||
} else if (value.length < 6) {
|
||||
callback(new Error('密码至少6个字符'));
|
||||
} else {
|
||||
if (registerForm.confirmPassword !== '') {
|
||||
registerFormRef.value?.validateField('confirmPassword');
|
||||
}
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
const validateConfirmPass = (rule: any, value: string, callback: any) => {
|
||||
if (value === '') {
|
||||
callback(new Error('请再次输入密码'));
|
||||
} else if (value !== registerForm.password) {
|
||||
callback(new Error('两次输入的密码不一致'));
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
const validatePhone = (rule: any, value: string, callback: any) => {
|
||||
if (registerType.value === RegisterType.PHONE) {
|
||||
if (value === '') {
|
||||
callback(new Error('请输入手机号'));
|
||||
} else if (!/^1[3-9]\d{9}$/.test(value)) {
|
||||
callback(new Error('请输入有效的手机号'));
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
const validateEmail = (rule: any, value: string, callback: any) => {
|
||||
if (registerType.value === RegisterType.EMAIL) {
|
||||
if (value === '') {
|
||||
callback(new Error('请输入邮箱'));
|
||||
} else if (!/^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/.test(value)) {
|
||||
callback(new Error('请输入有效的邮箱地址'));
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
const validateUsername = (rule: any, value: string, callback: any) => {
|
||||
if (registerType.value === RegisterType.USERNAME) {
|
||||
if (value === '') {
|
||||
callback(new Error('请输入用户名'));
|
||||
} else if (value.length < 3 || value.length > 20) {
|
||||
callback(new Error('用户名长度为3-20个字符'));
|
||||
} else if (!/^[a-zA-Z0-9_]+$/.test(value)) {
|
||||
callback(new Error('用户名只能包含字母、数字和下划线'));
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
// 表单验证规则
|
||||
const registerRules: FormRules = {
|
||||
username: [
|
||||
{ validator: validateUsername, trigger: 'blur' }
|
||||
],
|
||||
studentId: [
|
||||
{ required: true, message: '请输入学号', trigger: 'blur' },
|
||||
{ min: 5, max: 20, message: '学号长度为5-20个字符', trigger: 'blur' }
|
||||
],
|
||||
phone: [
|
||||
{ validator: validatePhone, trigger: 'blur' }
|
||||
],
|
||||
email: [
|
||||
{ validator: validateEmail, trigger: 'blur' }
|
||||
],
|
||||
smsCode: [
|
||||
{
|
||||
validator: (rule: any, value: string, callback: any) => {
|
||||
if (registerType.value === RegisterType.PHONE) {
|
||||
if (value === '') {
|
||||
callback(new Error('请输入手机验证码'));
|
||||
} else if (value.length !== 6) {
|
||||
callback(new Error('验证码为6位'));
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
],
|
||||
emailCode: [
|
||||
{
|
||||
validator: (rule: any, value: string, callback: any) => {
|
||||
if (registerType.value === RegisterType.EMAIL) {
|
||||
if (value === '') {
|
||||
callback(new Error('请输入邮箱验证码'));
|
||||
} else if (value.length !== 6) {
|
||||
callback(new Error('验证码为6位'));
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
],
|
||||
password: [
|
||||
{ required: true, validator: validatePass, trigger: 'blur' }
|
||||
],
|
||||
confirmPassword: [
|
||||
{ required: true, validator: validateConfirmPass, trigger: 'blur' }
|
||||
],
|
||||
agree: [
|
||||
{
|
||||
validator: (rule: any, value: boolean, callback: any) => {
|
||||
if (!value) {
|
||||
callback(new Error('请同意《红色思政智能体平台》用户协议'));
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
},
|
||||
trigger: 'change'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// 发送手机验证码
|
||||
const handleSendSmsCode = async () => {
|
||||
// 先验证手机号
|
||||
try {
|
||||
await registerFormRef.value?.validateField('phone');
|
||||
} catch (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await authApi.sendSmsCode(registerForm.phone!);
|
||||
if (result.code === 200 && result.data) {
|
||||
// 保存sessionId
|
||||
registerForm.smsSessionId = result.data.sessionId;
|
||||
ElMessage.success(result.data.message || '验证码已发送');
|
||||
|
||||
// 开始倒计时
|
||||
smsCountdown.value = 60;
|
||||
smsTimer = window.setInterval(() => {
|
||||
smsCountdown.value--;
|
||||
if (smsCountdown.value <= 0 && smsTimer) {
|
||||
clearInterval(smsTimer);
|
||||
smsTimer = null;
|
||||
}
|
||||
}, 1000);
|
||||
} else {
|
||||
ElMessage.error(result.message || '发送验证码失败');
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('发送验证码失败:', error);
|
||||
ElMessage.error(error.message || '发送验证码失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 发送邮箱验证码
|
||||
const handleSendEmailCode = async () => {
|
||||
// 先验证邮箱
|
||||
try {
|
||||
await registerFormRef.value?.validateField('email');
|
||||
} catch (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await authApi.sendEmailCode(registerForm.email!);
|
||||
if (result.code === 200 && result.data) {
|
||||
// 保存sessionId
|
||||
registerForm.emailSessionId = result.data.sessionId;
|
||||
ElMessage.success(result.data.message || '验证码已发送到邮箱');
|
||||
|
||||
// 开始倒计时
|
||||
emailCountdown.value = 60;
|
||||
emailTimer = window.setInterval(() => {
|
||||
emailCountdown.value--;
|
||||
if (emailCountdown.value <= 0 && emailTimer) {
|
||||
clearInterval(emailTimer);
|
||||
emailTimer = null;
|
||||
}
|
||||
}, 1000);
|
||||
} else {
|
||||
ElMessage.error(result.message || '发送验证码失败');
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('发送验证码失败:', error);
|
||||
ElMessage.error(error.message || '发送验证码失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 注册处理
|
||||
const handleRegister = async () => {
|
||||
if (!registerFormRef.value) return;
|
||||
|
||||
// 检查是否同意用户协议
|
||||
if (!registerForm.agree) {
|
||||
ElMessage.warning('请先同意《红色思政智能体平台》用户协议');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const valid = await registerFormRef.value.validate();
|
||||
if (!valid) return;
|
||||
|
||||
registerLoading.value = true;
|
||||
|
||||
// 调用注册API
|
||||
const result = await authApi.register(registerForm);
|
||||
|
||||
if (result.code === 200) {
|
||||
ElMessage.success('注册成功!请登录');
|
||||
|
||||
// 注册成功后跳转到登录页
|
||||
setTimeout(() => {
|
||||
router.push('/login');
|
||||
}, 1500);
|
||||
} else {
|
||||
ElMessage.error(result.message || '注册失败,请重试');
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('注册失败:', error);
|
||||
ElMessage.error(error.message || '注册失败,请重试');
|
||||
} finally {
|
||||
registerLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
function goToLogin() {
|
||||
router.push('/login');
|
||||
}
|
||||
|
||||
// 组件卸载时清除定时器
|
||||
import { onUnmounted } from 'vue';
|
||||
onUnmounted(() => {
|
||||
if (smsTimer) {
|
||||
clearInterval(smsTimer);
|
||||
}
|
||||
if (emailTimer) {
|
||||
clearInterval(emailTimer);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.register-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 80%;
|
||||
max-width: 1142px;
|
||||
margin: auto auto;
|
||||
box-shadow: 0px 4px 30px 0px rgba(176, 196, 225, 0.25);
|
||||
border-radius: 30px;
|
||||
overflow: hidden;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 20px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.register-box {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 40px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
|
||||
.register-left {
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
padding: 113px 120px;
|
||||
border-radius: 30px 0 0 30px;
|
||||
overflow: hidden;
|
||||
background: url(/schoolNewsWeb/src/assets/imgs/login-bg.png);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
|
||||
.left-content {
|
||||
width: 100%;
|
||||
max-width: 350px;
|
||||
}
|
||||
|
||||
.quote-text {
|
||||
color: #FFF2D3;
|
||||
|
||||
.quote-mark {
|
||||
font-family: 'Arial Black', sans-serif;
|
||||
font-weight: 900;
|
||||
font-size: 71.096px;
|
||||
line-height: 0.74;
|
||||
display: block;
|
||||
margin-left: 1.48px;
|
||||
}
|
||||
|
||||
.quote-content {
|
||||
margin-top: 46.66px;
|
||||
|
||||
p {
|
||||
font-family: 'Taipei Sans TC Beta', 'PingFang SC', sans-serif;
|
||||
font-weight: 700;
|
||||
font-size: 50px;
|
||||
line-height: 1.42;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.register-right {
|
||||
flex: 1;
|
||||
background: #FFFFFF;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
border-radius: 0 30px 30px 0;
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
.register-form-container {
|
||||
width: 287px;
|
||||
padding: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.register-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.logo img {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
.logo-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.logo {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: #C62828;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3px;
|
||||
|
||||
img {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
.platform-title {
|
||||
font-family: 'Taipei Sans TC Beta', sans-serif;
|
||||
font-weight: 700;
|
||||
font-size: 26px;
|
||||
line-height: 1.31;
|
||||
color: #141F38;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 24px;
|
||||
.register-title {
|
||||
font-family: 'PingFang SC', sans-serif;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
color: #141F38;
|
||||
margin: 0 0 16px 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
.register-type-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
.tab-item {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
text-align: center;
|
||||
background: #F2F3F5;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
background: #E8E9EB;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #C62828;
|
||||
color: #FFFFFF;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.register-form {
|
||||
.form-placeholder {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #999;
|
||||
.form-input {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
background: #F2F3F5;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
color: rgba(0, 0, 0, 0.3);
|
||||
|
||||
&::placeholder {
|
||||
color: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
background: #FFFFFF;
|
||||
border: 1px solid #C62828;
|
||||
}
|
||||
}
|
||||
|
||||
.phone-input-wrapper {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
|
||||
.phone-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.code-button {
|
||||
height: 48px;
|
||||
padding: 0 16px;
|
||||
background: #C62828;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
color: #FFFFFF;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #B71C1C;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: #D9D9D9;
|
||||
color: rgba(0, 0, 0, 0.3);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.register-button {
|
||||
width: 100%;
|
||||
height: 46px;
|
||||
background: #C62828;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
color: #FFFFFF;
|
||||
font-family: 'PingFang SC', sans-serif;
|
||||
font-weight: 500;
|
||||
font-size: 16px;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 8px;
|
||||
|
||||
&:hover {
|
||||
background: #B71C1C;
|
||||
}
|
||||
}
|
||||
|
||||
.agreement-text {
|
||||
font-family: 'PingFang SC', sans-serif;
|
||||
font-weight: 400;
|
||||
font-size: 10px;
|
||||
line-height: 1.8;
|
||||
color: rgba(0, 0, 0, 0.3);
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.register-footer {
|
||||
text-align: center;
|
||||
margin-top: 24px;
|
||||
width: 100%;
|
||||
|
||||
p {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
.login-link {
|
||||
font-family: 'PingFang SC', sans-serif;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
line-height: 1.83;
|
||||
color: #141F38;
|
||||
text-align: center;
|
||||
margin: 0 0 16px 0;
|
||||
|
||||
.login-link-text {
|
||||
cursor: pointer;
|
||||
color: #ff0000;
|
||||
&:hover {
|
||||
color: #C62828;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.copyright {
|
||||
font-family: 'PingFang SC', sans-serif;
|
||||
font-size: 12px;
|
||||
line-height: 2;
|
||||
color: #D9D9D9;
|
||||
text-align: center;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 768px) {
|
||||
.register-container {
|
||||
flex-direction: column;
|
||||
border-radius: 0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.register-left {
|
||||
min-height: 300px;
|
||||
padding: 40px;
|
||||
|
||||
.left-content {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.quote-text {
|
||||
.quote-mark {
|
||||
font-size: 50px;
|
||||
}
|
||||
|
||||
.quote-content p {
|
||||
font-size: 36px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.register-right {
|
||||
min-height: auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.register-form-container {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user