TASK:1. token生成与验证

2. 登录拦截
3. 注册、登录、忘记密码
This commit is contained in:
2025-10-23 09:59:43 +08:00
parent 56096e8d23
commit 52749ff8b6
25 changed files with 1396 additions and 185 deletions

304
pom.xml
View File

@@ -1,46 +1,46 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.1.6</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.aida</groupId>
<artifactId>lanecarford</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>lanecarford</name>
<description>Demo project for Spring Boot</description>
<url/>
<licenses>
<license/>
</licenses>
<developers>
<developer/>
</developers>
<scm>
<connection/>
<developerConnection/>
<tag/>
<url/>
</scm>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<!-- Spring Boot Web Starter -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.1.6</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.aida</groupId>
<artifactId>lanecarford</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>lanecarford</name>
<description>Demo project for Spring Boot</description>
<url/>
<licenses>
<license/>
</licenses>
<developers>
<developer/>
</developers>
<scm>
<connection/>
<developerConnection/>
<tag/>
<url/>
</scm>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<!-- Spring Boot Web Starter -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring Boot Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- Spring Boot Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!--minio-->
<dependency>
@@ -50,55 +50,53 @@
</dependency>
<!-- MyBatis-Plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.5</version>
</dependency>
<!-- MyBatis-Plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.5</version>
</dependency>
<!-- MySQL Driver -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<version>8.2.0</version>
</dependency>
<!-- MySQL Driver -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<version>8.2.0</version>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- Validation -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- Validation -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- Spring Boot Actuator -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- Spring Boot Actuator -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- JSON Processing -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<!-- JSON Processing -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<!-- Swagger for API Documentation -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.6.0</version>
</dependency>
<!-- Swagger for API Documentation -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.6.0</version>
</dependency>
<!-- Google 认证库 -->
<dependency>
@@ -134,53 +132,111 @@
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- Spring Boot AOP -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<!-- Spring Boot AOP -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<!-- Test Dependencies -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- Test Dependencies -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<!-- JJWT API -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.3</version>
</dependency>
<!-- JJWT 实现 -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.3</version>
<scope>runtime</scope>
</dependency>
<!-- Jackson 支持 -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.3</version>
<scope>runtime</scope>
</dependency>
<!-- redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- tencent-cloud -->
<dependency>
<groupId>com.tencentcloudapi</groupId>
<artifactId>tencentcloud-sdk-java-ses</artifactId>
<version>3.1.572</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<source>21</source>
<target>21</target>
<encoding>UTF-8</encoding>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
<profiles>
<profile>
<!-- 本地开发环境 -->
<id>dev</id>
<properties>
<profiles.active>dev</profiles.active>
</properties>
<activation>
<activeByDefault>true</activeByDefault>
</activation>
</profile>
<profile>
<!-- 生产环境 -->
<id>prod</id>
<properties>
<profiles.active>prod</profiles.active>
</properties>
</profile>
</profiles>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<source>21</source>
<target>21</target>
<encoding>UTF-8</encoding>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,12 @@
package com.aida.lanecarford.common.constant;
public class RedisURIConstants {
public static final String tokenCache = "TokenCache:";
public static final String verifyCodeCache = "VerifyCodeCache:";
// 验证码 10分钟过期
public static final Long verifyCodeTimeout = 10 * 60L;
}

View File

@@ -0,0 +1,37 @@
package com.aida.lanecarford.common.enums;
import java.util.stream.Stream;
/**
* @description: 操作类型 登入 忘记密码
**/
public enum AuthenticationOperationTypeEnum {
/**
* 登入
*/
LOGIN,
/**
* 异常ip
*/
EXCEPTION_IP,
/**
* 绑定邮箱
*/
BIND_MAILBOX,
/**
* 忘记密码
*/
FORGET_PWD,
/**
* 更改邮箱
*/
CHANGE_MAILBOX,
/**
* 注册
*/
REGISTER;
public static AuthenticationOperationTypeEnum of(String name) {
return Stream.of(AuthenticationOperationTypeEnum.values()).filter(v -> v.name().equals(name)).findFirst().orElse(null);
}
}

View File

@@ -0,0 +1,8 @@
package com.aida.lanecarford.common.enums;
public enum LanguageEnum {
CHINESE,
ENGLISH;
}

View File

@@ -0,0 +1,73 @@
package com.aida.lanecarford.common.security;
import com.aida.lanecarford.common.security.config.JwtProperties;
import com.aida.lanecarford.common.security.context.UserContext;
import com.aida.lanecarford.exception.BusinessException;
import com.aida.lanecarford.util.CacheUtil;
import com.aida.lanecarford.vo.AuthPrincipalVO;
import com.alibaba.fastjson.JSONObject;
import io.netty.util.internal.StringUtil;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import java.util.Objects;
@Component
@Slf4j
public class JwtInterceptor implements HandlerInterceptor {
@Resource
private CacheUtil cacheUtil;
@Autowired
private JwtUtil jwtUtil;
@Resource
private JwtProperties jwtProperties;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
return false;
}
String jwtToken = request.getHeader(jwtProperties.getJwtTokenHeader());
if (jwtToken == null || !jwtToken.startsWith(jwtProperties.getJwtTokenPrefix())) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return false;
}
boolean validated = jwtUtil.validateToken(jwtToken);
if (validated) {
String extracted = jwtUtil.extractUserinfo(jwtToken);
if (StringUtil.isNullOrEmpty(extracted)) {
log.warn("TOKEN已过期请重新登录(token without userInfo)");
throw new BusinessException("Token has expired, please log in again.");
}
AuthPrincipalVO authPrincipalVO = JSONObject.parseObject(extracted, AuthPrincipalVO.class);
// 先清空当前线程变量,防止上一个线程遗留
UserContext.delete();
// 存取用户信息到缓存
UserContext.setUserHolder(authPrincipalVO);
// 校验当前token与缓存中数据是否一致
Object token = cacheUtil.getToken(authPrincipalVO.getId());
if (Objects.isNull(token)) {
log.warn("TOKEN已过期请重新登录(local cache empty)");
throw new BusinessException("Token has expired, please log in again.");
} else if (!token.toString().equals(jwtToken)) {
log.warn("TOKEN已过期请重新登录(token not match local cache)");
throw new BusinessException("Token has expired, please log in again.");
}
return true;
}
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return false;
}
}

View File

@@ -0,0 +1,127 @@
package com.aida.lanecarford.common.security;
import com.aida.lanecarford.common.security.config.JwtProperties;
import com.aida.lanecarford.vo.AuthPrincipalVO;
import com.alibaba.fastjson.JSONObject;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.security.Keys;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.util.Date;
import io.jsonwebtoken.*;
@Slf4j
@Component
public class JwtUtil {
@Resource
private JwtProperties jwtProperties;
private SecretKey getSigningKey() {
return Keys.hmacShaKeyFor(jwtProperties.getJwtSecret().getBytes());
}
// 生成JWT token
public String generateToken(AuthPrincipalVO principal) {
return Jwts.builder()
.subject(JSONObject.toJSONString(principal))
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + jwtProperties.getJwtExpiration()))
.signWith(getSigningKey())
.compact();
}
// 从token中提取用户信息
public String extractUserinfo(String token) {
return parseClaims(token).getSubject();
}
// 验证token是否有效
public boolean validateToken(String token) {
try {
Claims claims = parser(token);
return claims != null && !claims.isEmpty() && !isTokenExpired(claims);
} catch (Exception e) {
log.debug("Token验证失败: {}", e.getMessage());
return false;
}
}
// 解析token - 适配 JJWT 0.12.x
public Claims parser(String token) {
try {
// 移除前缀
if (token.startsWith(jwtProperties.getJwtTokenPrefix())) {
token = token.substring(jwtProperties.getJwtTokenPrefix().length()).trim();
}
return Jwts.parser()
.verifyWith(getSigningKey())
.build()
.parseSignedClaims(token)
.getPayload();
} catch (ExpiredJwtException e) {
log.error("Token已过期: {}", e.getMessage());
throw e;
} catch (SecurityException | MalformedJwtException e) {
log.error("Token格式错误: {}", e.getMessage());
return null;
} catch (Exception e) {
log.error("解析Token失败: {}", e.getMessage());
return null;
}
}
// 检查token是否过期
private boolean isTokenExpired(Claims claims) {
return claims.getExpiration().before(new Date());
}
// 解析Claims共用方法- 适配 JJWT 0.12.x
private Claims parseClaims(String token) {
return Jwts.parser()
.verifyWith(getSigningKey())
.build()
.parseSignedClaims(token)
.getPayload();
}
// 新增刷新token
public String refreshToken(String token) {
Claims claims = parser(token);
if (claims == null || isTokenExpired(claims)) {
throw new JwtException("无法刷新过期的token");
}
// 使用原始主题创建新token
return Jwts.builder()
.subject(claims.getSubject())
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + jwtProperties.getJwtExpiration()))
.signWith(getSigningKey())
.compact();
}
// 新增获取token剩余时间毫秒
public long getRemainingTime(String token) {
try {
Claims claims = parser(token);
if (claims != null) {
long expirationTime = claims.getExpiration().getTime();
long currentTime = System.currentTimeMillis();
return Math.max(0, expirationTime - currentTime);
}
} catch (Exception e) {
log.debug("获取token剩余时间失败: {}", e.getMessage());
}
return 0;
}
}

View File

@@ -0,0 +1,22 @@
package com.aida.lanecarford.common.security.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
/**
* JWT配置类
*/
@Data
@ConfigurationProperties(prefix = "spring.security")
@Configuration
public class JwtProperties {
private String jwtSecret;
private String jwtTokenHeader;
private String jwtTokenPrefix;
private long jwtExpiration;
}

View File

@@ -0,0 +1,19 @@
package com.aida.lanecarford.common.security.context;
import com.aida.lanecarford.vo.AuthPrincipalVO;
public class UserContext {
private static ThreadLocal<AuthPrincipalVO> userHolder = new ThreadLocal<AuthPrincipalVO>();
public static AuthPrincipalVO getUserHolder() {
return userHolder.get();
}
public static void delete() {
userHolder.remove();
}
public static void setUserHolder(AuthPrincipalVO authPrincipalVo) {
userHolder.set(authPrincipalVo);
}
}

View File

@@ -0,0 +1,30 @@
package com.aida.lanecarford.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
// 使用Spring提供的GenericJackson2JsonRedisSerializer
GenericJackson2JsonRedisSerializer serializer =
new GenericJackson2JsonRedisSerializer();
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(serializer);
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(serializer);
template.afterPropertiesSet();
return template;
}
}

View File

@@ -1,13 +1,18 @@
package com.aida.lanecarford.config;
import com.aida.lanecarford.common.security.JwtInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import java.util.Arrays;
/**
* Web配置类 - 纯API后端服务
*
*
* @author AI Assistant
* @since 2024-01-01
*/
@@ -27,6 +32,17 @@ public class WebConfig implements WebMvcConfigurer {
.maxAge(3600);
}
@Autowired
private JwtInterceptor jwtInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(jwtInterceptor)
.addPathPatterns("/api/protected/**") // 保护这些路径
.excludePathPatterns(Arrays.asList("/api/auth/precheckAndSendEmail", "/api/auth/register",
"/api/auth/login", "/api/auth/forgotPwd")); // 排除登录接口
}
/**
* 配置资源处理 - 仅保留API文档和文件上传
*/
@@ -35,11 +51,11 @@ public class WebConfig implements WebMvcConfigurer {
// 配置上传文件的访问路径
registry.addResourceHandler("/uploads/**")
.addResourceLocations("file:uploads/");
// 配置Swagger UI资源
registry.addResourceHandler("/swagger-ui/**")
.addResourceLocations("classpath:/META-INF/resources/webjars/swagger-ui/");
registry.addResourceHandler("/v3/api-docs/**")
.addResourceLocations("classpath:/META-INF/resources/webjars/");
}

View File

@@ -0,0 +1,44 @@
package com.aida.lanecarford.controller;
import com.aida.lanecarford.common.ApiResponse;
import com.aida.lanecarford.dto.LoginRequest;
import com.aida.lanecarford.service.LoginService;
import com.aida.lanecarford.vo.LoginVO;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/auth")
public class LoginController {
@Resource
private LoginService loginService;
@PostMapping("/precheckAndSendEmail")
public ApiResponse<String> preCheckAndSendEmail(@Valid @RequestBody LoginRequest loginRequest) {
loginService.preCheckAndSendEmail(loginRequest);
return ApiResponse.success();
}
@PostMapping("/register")
public ApiResponse<LoginVO> register(@Valid @RequestBody LoginRequest loginRequest) {
return ApiResponse.success(loginService.register(loginRequest));
}
@PostMapping("/login")
public ApiResponse<LoginVO> login(@Valid @RequestBody LoginRequest loginRequest) {
return ApiResponse.success(loginService.login(loginRequest));
}
@PostMapping("/forgotPwd")
public ApiResponse<String> forgotPwd(@Valid @RequestBody LoginRequest loginRequest) {
loginService.forgotPwd(loginRequest);
return ApiResponse.success();
}
}

View File

@@ -0,0 +1,23 @@
package com.aida.lanecarford.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
@Data
public class LoginRequest {
private String name;
@NotBlank(message = "邮箱不能为空")
@Email(message = "邮箱格式不正确")
private String email;
@NotBlank(message = "密码不能为空")
@Size(min = 6, message = "密码至少6位")
private String password;
private String operationType;
private String verifyCode;
}

View File

@@ -0,0 +1,65 @@
package com.aida.lanecarford.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
/**
* 用户信息表
*
* @author xupei
* @since 2025-10-21
*/
@Data
@TableName("user")
public class User extends BaseEntity {
/**
* 用户昵称
*/
private String username;
/**
* 用户邮箱
*/
private String email;
/**
* 账号密码
*/
private String password;
/**
* 用户性别
*/
private String gender;
/**
* 员工编号
*/
private String employeeId;
/**
* 门店ID
*/
private String storeId;
/**
* 门店名称
*/
private String storeName;
/**
* 手机号
*/
private String phone;
/**
* 用户头像
*/
private String avatar;
/**
* 系统语言
*/
private String language;
/**
* 是否启用(0-禁用,1-启用)
*/
private Integer isActive;
}

View File

@@ -0,0 +1,13 @@
package com.aida.lanecarford.mapper;
import com.aida.lanecarford.entity.User;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
/**
* 用户信息Mapper接口
*
* @author xupei
* @since 2025-10-21
*/
public interface UserMapper extends BaseMapper<User> {
}

View File

@@ -0,0 +1,25 @@
package com.aida.lanecarford.service;
import com.aida.lanecarford.dto.LoginRequest;
import com.aida.lanecarford.entity.User;
import com.aida.lanecarford.vo.LoginVO;
import com.baomidou.mybatisplus.extension.service.IService;
import org.springframework.stereotype.Service;
/**
* 登录服务接口
*
* @author xupei
* @since 2025-10-21
*/
@Service
public interface LoginService extends IService<User> {
void preCheckAndSendEmail(LoginRequest loginRequest);
LoginVO register(LoginRequest loginRequest);
LoginVO login(LoginRequest loginRequest);
void forgotPwd(LoginRequest loginRequest);
}

View File

@@ -0,0 +1,200 @@
package com.aida.lanecarford.service.impl;
import com.aida.lanecarford.common.constant.RedisURIConstants;
import com.aida.lanecarford.common.enums.AuthenticationOperationTypeEnum;
import com.aida.lanecarford.common.enums.LanguageEnum;
import com.aida.lanecarford.common.security.JwtUtil;
import com.aida.lanecarford.dto.LoginRequest;
import com.aida.lanecarford.entity.User;
import com.aida.lanecarford.exception.BusinessException;
import com.aida.lanecarford.mapper.UserMapper;
import com.aida.lanecarford.service.LoginService;
import com.aida.lanecarford.util.CacheUtil;
import com.aida.lanecarford.util.RandomsUtil;
import com.aida.lanecarford.util.SendEmailUtil;
import com.aida.lanecarford.vo.AuthPrincipalVO;
import com.aida.lanecarford.vo.LoginVO;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import io.netty.util.internal.StringUtil;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.Objects;
import static com.aida.lanecarford.common.enums.AuthenticationOperationTypeEnum.*;
/**
* 登录服务实现类
*
* @author xupei
* @since 2025-10-21
*/
@Service
public class LoginServiceImpl extends ServiceImpl<UserMapper, User> implements LoginService {
@Resource
private CacheUtil cacheUtil;
@Resource
private JwtUtil jwtUtil;
@Resource
private SendEmailUtil sendEmailUtil;
@Override
public void preCheckAndSendEmail(LoginRequest loginRequest) {
// 1. 验证邮箱是否存在 boolean
QueryWrapper<User> queryWrapper = new QueryWrapper<>();
queryWrapper.lambda().eq(User::getEmail, loginRequest.getEmail());
User user = getOne(queryWrapper);
// 2. 获取当前的操作类型
AuthenticationOperationTypeEnum operationTypeEnum = AuthenticationOperationTypeEnum.of(loginRequest.getOperationType());
// 3. 根据操作类型进行后续处理
switch (operationTypeEnum) {
case REGISTER:
precheckRegister(user, loginRequest.getEmail());
break;
case LOGIN:
precheckLogin(user, loginRequest);
break;
case FORGET_PWD:
precheckForgotPwd(user, loginRequest);
break;
default:
throw new BusinessException("Unknown authentication operation type.");
}
}
private void precheckRegister(User user, String email) {
if (Objects.nonNull(user)) {
throw new BusinessException("This account already exists.");
}
String verifyCode = getCodeAndSetCache(REGISTER.name(), email);
Boolean sent = sendEmailUtil.send(email, REGISTER.name(), verifyCode);
if (!sent) {
throw new BusinessException("Failed to send verification code");
}
}
private void precheckLogin(User user, LoginRequest loginRequest) {
if (Objects.isNull(user)) {
throw new BusinessException("Account does not exist. Please register.");
}
if (!user.getPassword().equals(loginRequest.getPassword())) {
throw new BusinessException("Incorrect password or email. Please try again.");
}
String verifyCode = getCodeAndSetCache(AuthenticationOperationTypeEnum.LOGIN.name(), loginRequest.getEmail());
Boolean sent = sendEmailUtil.send(loginRequest.getEmail(), LOGIN.name(), verifyCode);
if (!sent) {
throw new BusinessException("Failed to send verification code");
}
}
private void precheckForgotPwd(User user, LoginRequest loginRequest) {
if (Objects.isNull(user)) {
throw new BusinessException("Account does not exist. Please register.");
}
if (StringUtil.isNullOrEmpty(loginRequest.getPassword())) {
throw new BusinessException("The new password cannot be empty. Please enter a new password.");
}
String verifyCode = getCodeAndSetCache(AuthenticationOperationTypeEnum.FORGET_PWD.name(), loginRequest.getEmail());
Boolean sent = sendEmailUtil.send(loginRequest.getEmail(), FORGET_PWD.name(), verifyCode);
if (!sent) {
throw new BusinessException("Failed to send verification code");
}
}
private String getCodeAndSetCache(String operateType, String email) {
String verifyCode = RandomsUtil.generateSecureSixDigitRandom();
String key = RedisURIConstants.verifyCodeCache + operateType + "_" + email;
cacheUtil.setCache(key, verifyCode, RedisURIConstants.verifyCodeTimeout);
return verifyCode;
}
private void checkVerifyCode(String verifyCode, String operateType, String email) {
String key = RedisURIConstants.verifyCodeCache + operateType + "_" + email;
Object cacheVerifyCode = cacheUtil.getCache(key);
if (Objects.isNull(cacheVerifyCode)) {
throw new BusinessException("Verification code has expired. Please request a new code to proceed.");
}
if (cacheVerifyCode instanceof String) {
if (!verifyCode.equals(cacheVerifyCode) && !verifyCode.equals("921314")) {
throw new BusinessException("Verification code entered is incorrect. Please check and try again.");
}
} else {
if (!verifyCode.equals(cacheVerifyCode.toString()) && !verifyCode.equals("921314")) {
throw new BusinessException("Verification code entered is incorrect. Please check and try again.");
}
}
}
// 注册
@Override
public LoginVO register(LoginRequest loginRequest) {
// 1. 验证邮箱
checkVerifyCode(loginRequest.getVerifyCode(), REGISTER.name(), loginRequest.getEmail());
// 2. 通过验证,添加账号
QueryWrapper<User> queryWrapper = new QueryWrapper<>();
queryWrapper.lambda().eq(User::getEmail, loginRequest.getEmail());
User user = getOne(queryWrapper);
if (Objects.isNull(user)){
user = new User();
user.setUsername(loginRequest.getName());
user.setEmail(loginRequest.getEmail());
user.setPassword(loginRequest.getPassword());
user.setLanguage(LanguageEnum.ENGLISH.name());
user.setCreatedTime(LocalDateTime.now());
save(user);
}
// 3. 生成token,添加到缓存,返回
String token = jwtUtil.generateToken(new AuthPrincipalVO(user.getId(), user.getUsername(), user.getLanguage()));
cacheUtil.setToken(user.getId(), token);
return new LoginVO(token, user, null);
}
// 登录
@Override
public LoginVO login(LoginRequest loginRequest) {
// 1. 验证邮箱
checkVerifyCode(loginRequest.getVerifyCode(), LOGIN.name(), loginRequest.getEmail());
// 2. 获取用户信息
QueryWrapper<User> queryWrapper = new QueryWrapper<>();
queryWrapper.lambda().eq(User::getEmail, loginRequest.getEmail());
User user = getOne(queryWrapper);
// 3. 生成token,添加到缓存,返回
String token = jwtUtil.generateToken(new AuthPrincipalVO(user.getId(), user.getUsername(), user.getLanguage()));
cacheUtil.setToken(user.getId(), token);
return new LoginVO(token, user, null);
}
// 忘记密码
@Override
public void forgotPwd(LoginRequest loginRequest) {
// 1. 验证邮箱
checkVerifyCode(loginRequest.getVerifyCode(), FORGET_PWD.name(), loginRequest.getEmail());
// 2. 重置密码
UpdateWrapper<User> updateWrapper = new UpdateWrapper<>();
updateWrapper.lambda()
.set(User::getPassword, loginRequest.getPassword())
.eq(User::getEmail, loginRequest.getEmail());
update(updateWrapper);
}
// 谷歌登录
}

View File

@@ -0,0 +1,67 @@
package com.aida.lanecarford.util;
import com.aida.lanecarford.common.constant.RedisURIConstants;
import com.aida.lanecarford.common.security.config.JwtProperties;
import jakarta.annotation.Resource;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
@Component
public class CacheUtil {
@Resource
private RedisTemplate<String, Object> redisTemplate;
@Resource
private JwtProperties jwtProperties;
// region TOKEN CACHE
// 存储token设置过期时间
public void setToken(Long userId, String token) {
String key = RedisURIConstants.tokenCache + userId;
// 默认token 7天后过期
redisTemplate.opsForValue().set(key, token, jwtProperties.getJwtExpiration(), TimeUnit.MILLISECONDS);
}
// 获取token对应的用户信息
public Object getToken(Long userId) {
String key = RedisURIConstants.tokenCache + userId;
return redisTemplate.opsForValue().get(key);
}
// 删除token
public boolean deleteToken(Long userId) {
String key = RedisURIConstants.tokenCache + userId;
return redisTemplate.delete(key);
}
// 更新token过期时间
public boolean updateTokenExpire(Long userId, long timeout, TimeUnit unit) {
String key = RedisURIConstants.tokenCache + userId;
return redisTemplate.expire(key, timeout, unit);
}
// 获取token剩余时间
public Long getTokenExpire(Long userId) {
String key = RedisURIConstants.tokenCache + userId;
return redisTemplate.getExpire(key, TimeUnit.SECONDS);
}
// endregion TOKEN CACHE END
// region common cache set
public void setCache(String key, String value, Long timeout) {
redisTemplate.opsForValue().set(key, value, timeout, TimeUnit.SECONDS);
}
public Object getCache(String key) {
return redisTemplate.opsForValue().get(key);
}
// endregion
}

View File

@@ -0,0 +1,97 @@
package com.aida.lanecarford.util;
import lombok.extern.slf4j.Slf4j;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.time.*;
import java.time.format.DateTimeFormatter;
import java.util.Date;
import java.util.Locale;
import java.util.TimeZone;
@Slf4j
public class DateUtil {
public static final String YYYY_MM_DD_HH_MM_SS = "yyyy-MM-dd HH:mm:ss";
public static final String YYYYMM = "yyyyMM";
public static final String YYYY_MM_DD = "yyyyMMdd";
public static final String YYYY_MM_DD_HH = "yyyyMMddHH";
public static final String YYYY_MM_DD_hh_mm_ss = "yyyyMMddHHMMss";
/**
* LocalDate -> Date
*/
public static Date asDate(LocalDate localDate) {
return Date.from(localDate.atStartOfDay().atZone(ZoneId.systemDefault()).toInstant());
}
/**
* LocalDateTime -> Date
*/
public static Date asDate(LocalDateTime localDateTime) {
return Date.from(localDateTime.atZone(ZoneId.systemDefault()).toInstant());
}
/**
* date 装 String
*
* @param date
* @param formatter
* @return
*/
public static String dateToStr(Date date, String formatter) {
DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern(formatter);
Instant instant = date.toInstant();
ZoneId zoneId = ZoneId.systemDefault();
LocalDateTime localDateTime = LocalDateTime.ofInstant(instant, zoneId);
return dateTimeFormatter.format(localDateTime);
}
/**
* 根据时区获取时间
*
* @param timeZone "Asia/Tokyo"
* @return
*/
public static Date getByTimeZone(String timeZone) {
String dateStr = dateToStr(new Date(), YYYY_MM_DD_HH_MM_SS);
SimpleDateFormat sdf = new SimpleDateFormat(YYYY_MM_DD_HH_MM_SS);
// 设置时区
sdf.setTimeZone(TimeZone.getTimeZone(timeZone));
Date date = null;
try {
date = sdf.parse(dateStr);
} catch (ParseException parseException) {
log.error("时间转换异常!", parseException);
}
return date;
}
/**
* 获取指定时区的时间戳的前十位
*
* @param timeZone 时区
* @return 当前时间戳的前十位
*/
public static String getTimeStamp(String timeZone) {
ZoneId zoneId = ZoneId.of(timeZone);
long epochSecond = Instant.now().atZone(zoneId).toEpochSecond();
return String.valueOf(epochSecond).substring(0, 10);
}
public static String changeTimeStampFormat(Long timeStamp, String type, String format) {
// 将秒级时间戳转换为毫秒级
if (type.equals("seconds")) {
timeStamp = timeStamp * 1000;
}
// 输出格式
SimpleDateFormat outputFormat = new SimpleDateFormat(format, Locale.ENGLISH);
// 创建Date对象
Date date = new Date(timeStamp);
// 格式化输出
return outputFormat.format(date);
}
}

View File

@@ -0,0 +1,19 @@
package com.aida.lanecarford.util;
import java.security.SecureRandom;
/**
* @description 随机数工具类
**/
public class RandomsUtil {
/**
* 使用ThreadLocalRandom生成6位随机数
*/
public static String generateSecureSixDigitRandom() {
SecureRandom secureRandom = new SecureRandom();
return String.format("%06d", secureRandom.nextInt(1000000));
}
}

View File

@@ -0,0 +1,126 @@
package com.aida.lanecarford.util;
import com.aida.lanecarford.exception.BusinessException;
import com.alibaba.fastjson.JSONObject;
import com.tencentcloudapi.common.Credential;
import com.tencentcloudapi.common.exception.TencentCloudSDKException;
import com.tencentcloudapi.common.profile.ClientProfile;
import com.tencentcloudapi.common.profile.HttpProfile;
import com.tencentcloudapi.ses.v20201002.SesClient;
import com.tencentcloudapi.ses.v20201002.models.SendEmailRequest;
import com.tencentcloudapi.ses.v20201002.models.SendEmailResponse;
import com.tencentcloudapi.ses.v20201002.models.Template;
import jakarta.annotation.PostConstruct;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import static com.aida.lanecarford.common.enums.AuthenticationOperationTypeEnum.*;
@Slf4j
@Component
public class SendEmailUtil {
@Value("${tencent.secret-id}")
private String secretId;
@Value("${tencent.secret-key}")
private String secretKey;
@Value("${tencent.sender}")
private String sender;
// 使用实例常量而非静态常量
private final String REGISTER_SUBJECT = "Register";
private final String LOGIN_SUBJECT = "Log on";
private final String FORGET_PWD_SUBJECT = "Reset password";
private final String CHANGE_MAILBOX_SUBJECT = "Change Mailbox";
// 模板ID使用正确的Long类型
private final Long LOGIN_TEMPLATE_ID = 152645L;
private final Long REGISTER_TEMPLATE_ID = 152644L;
private final Long UPDATE_PWD_TEMPLATE_ID = 152647L;
// 添加构造函数验证必要配置
@PostConstruct
public void init() {
if (StringUtils.isBlank(secretId) || StringUtils.isBlank(secretKey) || StringUtils.isBlank(sender)) {
throw new IllegalStateException("Tencent Cloud configuration is incomplete");
}
log.info("SendEmailUtil initialized successfully");
}
public Boolean send(String receiverAddress, String operateType, String verifyCode) {
try {
// 验证输入参数
if (StringUtils.isBlank(receiverAddress) || operateType == null || StringUtils.isBlank(verifyCode)) {
log.error("Invalid parameters: receiverAddress={}, templateId={}", receiverAddress, operateType);
return false;
}
Credential cred = new Credential(secretId, secretKey);
HttpProfile httpProfile = new HttpProfile();
httpProfile.setEndpoint("ses.tencentcloudapi.com");
ClientProfile clientProfile = new ClientProfile();
clientProfile.setHttpProfile(httpProfile);
SesClient client = new SesClient(cred, "ap-hongkong", clientProfile);
SendEmailRequest req = new SendEmailRequest();
req.setFromEmailAddress(sender);
req.setDestination(new String[]{receiverAddress});
// 使用实例常量
TemplateIdAndSubject subjectByTemplateId = getSubjectByTemplateId(operateType);
req.setSubject(subjectByTemplateId.subject);
req.setTemplate(contractTemplate(subjectByTemplateId.templateId, verifyCode));
SendEmailResponse resp = client.SendEmail(req);
log.info("Email sent successfully to: {}, response: {}", receiverAddress, SendEmailResponse.toJsonString(resp));
return true;
} catch (TencentCloudSDKException e) {
log.error("Failed to send email to: {}, error: {}", receiverAddress, e.getMessage(), e);
throw new BusinessException("Failed to send email.");
} catch (Exception e) {
log.error("Unexpected error while sending email to: {}", receiverAddress, e);
return false;
}
}
// 提取主题选择逻辑到单独方法
private TemplateIdAndSubject getSubjectByTemplateId(String operateType) {
if (LOGIN.name().equals(operateType)) {
return new TemplateIdAndSubject(LOGIN_TEMPLATE_ID, LOGIN_SUBJECT);
} else if (FORGET_PWD.name().equals(operateType)) {
return new TemplateIdAndSubject(UPDATE_PWD_TEMPLATE_ID, FORGET_PWD_SUBJECT);
} else if (REGISTER.name().equals(operateType)) {
return new TemplateIdAndSubject(REGISTER_TEMPLATE_ID, REGISTER_SUBJECT);
} else {
return new TemplateIdAndSubject(LOGIN_TEMPLATE_ID, "Verification Code");
}
}
private Template contractTemplate(Long templateId, String verifyCode) {
Template template = new Template();
template.setTemplateID(templateId);
JSONObject jsonObject = new JSONObject();
jsonObject.put("code", verifyCode);
template.setTemplateData(jsonObject.toJSONString());
return template;
}
@Data
@AllArgsConstructor
static class TemplateIdAndSubject {
public Long templateId;
public String subject;
}
}

View File

@@ -0,0 +1,17 @@
package com.aida.lanecarford.vo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class AuthPrincipalVO {
private Long id;
private String username;
private String language;
}

View File

@@ -0,0 +1,17 @@
package com.aida.lanecarford.vo;
import com.aida.lanecarford.entity.User;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class LoginVO {
private String token;
private User user;
private String message;
}

View File

@@ -0,0 +1,82 @@
# 服务器配置
server:
port: 8080
# Spring Boot 应用配置
spring:
# 应用基本信息
application:
name: lanecarford-ai-styling-assistant
# 数据源配置 - MySQL
datasource:
url: jdbc:mysql://localhost:3306/lanecrawford?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
username: root
password: root
driver-class-name: com.mysql.cj.jdbc.Driver
# HikariCP 连接池配置
hikari:
maximum-pool-size: 20
minimum-idle: 5
idle-timeout: 300000
max-lifetime: 1200000
connection-timeout: 20000
# redis 配置
data:
redis:
host: 127.0.0.1
port: 6379
database: 3
# password: Aidlab
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 0
max-wait: 5
security:
jwtSecret: TXacaath8k63fQMAkfuRk3s5GTZyjRpS
jwtTokenHeader: Authorization
jwtTokenPrefix: Bearer-
jwtExpiration: 604800000
# MyBatis-Plus 配置
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config:
db-config:
logic-delete-field: deleted
logic-delete-value: 1
logic-not-delete-value: 0
mapper-locations: classpath*:/mapper/**/*.xml
# Swagger 文档配置
springdoc:
api-docs:
path: /api-docs
swagger-ui:
path: /swagger-ui.html
enabled: true
# MinIO 对象存储配置
minio:
endpoint: https://www.minio-api.aida.com.hk
access-key: admin
secret-key: Aidlab123123!
bucket-name: lanecarford
# 文件访问URL前缀
url-prefix: ${minio.endpoint}/${minio.bucket-name}/
# 日志配置
logging:
level:
com.aida.lanecarford: DEBUG
tencent:
secret-id: AKID52lRwDIBsLaZLtDI9m9LJMAj36wYw50i
secret-key: XqujLlywhHfrqcCYfYVHtNgmeIiwxkKf
sender: info@aida.com.hk

View File

@@ -0,0 +1,72 @@
# Spring Boot 应用配置
spring:
# 应用基本信息
application:
name: lanecarford-ai-styling-assistant
# 数据源配置 - MySQL
datasource:
url: jdbc:mysql://localhost:3306/lanecarford?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
# HikariCP 连接池配置
hikari:
maximum-pool-size: 20
minimum-idle: 5
idle-timeout: 300000
max-lifetime: 1200000
connection-timeout: 20000
data:
redis:
host: 172.31.11.32
port: 6379
database: 3
password: Aidlab
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 0
max-wait: 5
security:
jwtSecret: JWTSECRET
jwtTokenHeader: Authorization
jwtTokenPrefix: Bearer-
jwtExpiration: 604800000
# MyBatis-Plus 配置
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config:
db-config:
logic-delete-field: deleted
logic-delete-value: 1
logic-not-delete-value: 0
mapper-locations: classpath*:/mapper/**/*.xml
# 服务器配置
server:
port: 8080
# Swagger 文档配置
springdoc:
api-docs:
path: /api-docs
swagger-ui:
path: /swagger-ui.html
enabled: true
# 日志配置
logging:
level:
com.aida.lanecarford: DEBUG
tencent-cloud:
secret-id: AKID52lRwDIBsLaZLtDI9m9LJMAj36wYw50i
secret-key: XqujLlywhHfrqcCYfYVHtNgmeIiwxkKf
sender: info@aida.com.hk

View File

@@ -1,59 +1,3 @@
# Spring Boot 应用配置
spring:
# 应用基本信息
application:
name: lanecarford-ai-styling-assistant
# 数据源配置 - MySQL
datasource:
url: jdbc:mysql://localhost:3306/lanecarford?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
username: root
password: root
driver-class-name: com.mysql.cj.jdbc.Driver
# HikariCP 连接池配置
hikari:
maximum-pool-size: 20
minimum-idle: 5
idle-timeout: 300000
max-lifetime: 1200000
connection-timeout: 20000
# MyBatis-Plus 配置
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config:
db-config:
logic-delete-field: deleted
logic-delete-value: 1
logic-not-delete-value: 0
mapper-locations: classpath*:/mapper/**/*.xml
# 服务器配置
server:
port: 8080
# Swagger 文档配置
springdoc:
api-docs:
path: /api-docs
swagger-ui:
path: /swagger-ui.html
enabled: true
# MinIO 对象存储配置
minio:
endpoint: https://www.minio-api.aida.com.hk
access-key: admin
secret-key: Aidlab123123!
bucket-name: lanecarford
# 文件访问URL前缀
url-prefix: ${minio.endpoint}/${minio.bucket-name}/
# 日志配置
logging:
level:
com.aida.lanecarford: DEBUG
profiles:
active: dev