登录注册、手机号、邮箱

This commit is contained in:
2025-11-03 13:37:55 +08:00
parent 16754b527e
commit 35aee59178
26 changed files with 4292 additions and 163 deletions

View File

@@ -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/**"

View File

@@ -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;
}
}

View File

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

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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) {

View File

@@ -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;
}
}

View File

@@ -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

View File

@@ -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

View 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. 记录注册日志用于分析

View 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
**作者**:开发团队

View 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步
这样的设计既满足当前需求,又具备良好的扩展性,是一个**优雅、实用、易维护**的架构方案。

View 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会在日志中输出验证码不会实际发送
建议在开发环境中配置一个测试邮箱,以便完整测试邮件发送功能。

View 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 IDLTAI5txxxxxxxxxxxxxxxxxx
- AccessKey Secretxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
#### 步骤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. 定期查看账户余额

View File

@@ -0,0 +1,327 @@
# 验证码安全机制说明
## 安全问题
### 旧方案的漏洞
```
Redis存储sms:code:手机号 => 验证码
```
**问题**:任何人只要知道手机号和验证码,就可以注册该手机号的账号。
**攻击场景**
1. 攻击者获取目标用户手机号
2. 攻击者触发验证码发送
3. 如果攻击者通过其他方式获取到验证码(如社会工程学、短信拦截等)
4. 攻击者可以用这个手机号+验证码注册账号
## 新方案SessionID绑定
### 核心思想
验证码不直接绑定手机号/邮箱而是绑定一个临时会话IDSessionID确保只有发起验证码请求的用户才能使用。
### 实现机制
#### 1. 发送验证码流程
```
用户请求发送验证码(手机号/邮箱)
后端生成 SessionIDUUID
发送验证码到用户手机/邮箱
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机制我们将验证码从"谁都可以用"变成了"只有请求者可以用",大大提高了系统的安全性。这是一个简单但有效的安全增强方案,推荐在所有需要验证码的场景中使用。

View File

@@ -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

View File

@@ -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>

View File

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

View File

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

View File

@@ -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;
}
};

View File

@@ -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 || '';
});
// 获取用户下拉菜单(个人中心和账号中心)

View File

@@ -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');

View File

@@ -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[];
/** 用户菜单列表 */

View File

@@ -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">

View File

@@ -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;

View File

@@ -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>