feat: 完成Nacos配置优化和WebSocket集成
主要更新: 1. 统一所有微服务端口配置(19000-19008) 2. 为所有服务创建本地/测试/生产三套环境配置 3. 配置Nacos认证密码(本地:Peanut2817*#, 测试/生产:EmotionMuseum2025) 4. 优化网关路由配置,支持负载均衡和WebSocket 5. 新增emotion-websocket模块,支持实时聊天 6. 前端集成WebSocket,替代HTTP轮询 7. 添加配置验证和管理工具脚本 技术特性: - 完整的环境隔离和服务发现 - WebSocket实时通信支持 - 负载均衡路由配置 - 跨域和安全配置 - 自动重连和心跳检测
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
package com.emotionmuseum.auth;
|
||||
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
|
||||
|
||||
/**
|
||||
* 认证服务启动类
|
||||
*
|
||||
* @author emotion-museum
|
||||
* @since 2025-07-16
|
||||
*/
|
||||
@SpringBootApplication(scanBasePackages = {"com.emotionmuseum"})
|
||||
@EnableDiscoveryClient
|
||||
@MapperScan("com.emotionmuseum.auth.mapper")
|
||||
public class AuthApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(AuthApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.emotionmuseum.auth.config;
|
||||
|
||||
import com.wf.captcha.ArithmeticCaptcha;
|
||||
import com.wf.captcha.ChineseCaptcha;
|
||||
import com.wf.captcha.GifCaptcha;
|
||||
import com.wf.captcha.SpecCaptcha;
|
||||
import com.wf.captcha.base.Captcha;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* 验证码配置
|
||||
*
|
||||
* @author emotion-museum
|
||||
* @since 2025-07-15
|
||||
*/
|
||||
@Configuration
|
||||
public class CaptchaConfig {
|
||||
|
||||
/**
|
||||
* 算术验证码
|
||||
*/
|
||||
@Bean("arithmeticCaptcha")
|
||||
public Captcha arithmeticCaptcha() {
|
||||
ArithmeticCaptcha captcha = new ArithmeticCaptcha(130, 48);
|
||||
captcha.setLen(2); // 几位数运算,默认是两位
|
||||
captcha.getArithmeticString(); // 获取运算的公式:3+2=?
|
||||
return captcha;
|
||||
}
|
||||
|
||||
/**
|
||||
* 中文验证码
|
||||
*/
|
||||
@Bean("chineseCaptcha")
|
||||
public Captcha chineseCaptcha() {
|
||||
ChineseCaptcha captcha = new ChineseCaptcha(130, 48);
|
||||
captcha.setLen(4); // 几个汉字,默认5个
|
||||
return captcha;
|
||||
}
|
||||
|
||||
/**
|
||||
* GIF验证码
|
||||
*/
|
||||
@Bean("gifCaptcha")
|
||||
public Captcha gifCaptcha() {
|
||||
GifCaptcha captcha = new GifCaptcha(130, 48);
|
||||
captcha.setLen(4); // 几位数字,默认5位
|
||||
return captcha;
|
||||
}
|
||||
|
||||
/**
|
||||
* PNG验证码
|
||||
*/
|
||||
@Bean("specCaptcha")
|
||||
public Captcha specCaptcha() {
|
||||
SpecCaptcha captcha = new SpecCaptcha(130, 48, 4);
|
||||
captcha.setCharType(Captcha.TYPE_DEFAULT); // 设置类型,纯数字、纯字母、字母数字混合
|
||||
return captcha;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.emotionmuseum.auth.config;
|
||||
|
||||
import me.zhyd.oauth.config.AuthConfig;
|
||||
import me.zhyd.oauth.request.*;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* 第三方登录配置
|
||||
*
|
||||
* @author emotion-museum
|
||||
* @since 2025-07-15
|
||||
*/
|
||||
@Configuration
|
||||
public class OAuthConfig {
|
||||
|
||||
@Value("${oauth.wechat.client-id:}")
|
||||
private String wechatClientId;
|
||||
|
||||
@Value("${oauth.wechat.client-secret:}")
|
||||
private String wechatClientSecret;
|
||||
|
||||
@Value("${oauth.wechat.redirect-uri:}")
|
||||
private String wechatRedirectUri;
|
||||
|
||||
@Value("${oauth.qq.client-id:}")
|
||||
private String qqClientId;
|
||||
|
||||
@Value("${oauth.qq.client-secret:}")
|
||||
private String qqClientSecret;
|
||||
|
||||
@Value("${oauth.qq.redirect-uri:}")
|
||||
private String qqRedirectUri;
|
||||
|
||||
@Value("${oauth.wechat-mp.client-id:}")
|
||||
private String wechatMpClientId;
|
||||
|
||||
@Value("${oauth.wechat-mp.client-secret:}")
|
||||
private String wechatMpClientSecret;
|
||||
|
||||
@Value("${oauth.wechat-mp.redirect-uri:}")
|
||||
private String wechatMpRedirectUri;
|
||||
|
||||
/**
|
||||
* 微信开放平台登录
|
||||
*/
|
||||
@Bean
|
||||
public AuthWeChatOpenRequest weChatOpenRequest() {
|
||||
return new AuthWeChatOpenRequest(AuthConfig.builder()
|
||||
.clientId(wechatClientId)
|
||||
.clientSecret(wechatClientSecret)
|
||||
.redirectUri(wechatRedirectUri)
|
||||
.build());
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信公众平台登录
|
||||
*/
|
||||
@Bean
|
||||
public AuthWeChatMpRequest weChatMpRequest() {
|
||||
return new AuthWeChatMpRequest(AuthConfig.builder()
|
||||
.clientId(wechatMpClientId)
|
||||
.clientSecret(wechatMpClientSecret)
|
||||
.redirectUri(wechatMpRedirectUri)
|
||||
.build());
|
||||
}
|
||||
|
||||
/**
|
||||
* QQ登录
|
||||
*/
|
||||
@Bean
|
||||
public AuthQqRequest qqRequest() {
|
||||
return new AuthQqRequest(AuthConfig.builder()
|
||||
.clientId(qqClientId)
|
||||
.clientSecret(qqClientSecret)
|
||||
.redirectUri(qqRedirectUri)
|
||||
.build());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.emotionmuseum.auth.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;
|
||||
|
||||
/**
|
||||
* Redis配置类
|
||||
*
|
||||
* @author emotion-museum
|
||||
* @since 2025-07-15
|
||||
*/
|
||||
@Configuration
|
||||
public class RedisConfig {
|
||||
|
||||
/**
|
||||
* 配置RedisTemplate
|
||||
*/
|
||||
@Bean
|
||||
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
|
||||
RedisTemplate<String, Object> template = new RedisTemplate<>();
|
||||
template.setConnectionFactory(connectionFactory);
|
||||
|
||||
// 使用String序列化器作为key的序列化器
|
||||
StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
|
||||
template.setKeySerializer(stringRedisSerializer);
|
||||
template.setHashKeySerializer(stringRedisSerializer);
|
||||
|
||||
// 使用JSON序列化器作为value的序列化器
|
||||
GenericJackson2JsonRedisSerializer jsonRedisSerializer = new GenericJackson2JsonRedisSerializer();
|
||||
template.setValueSerializer(jsonRedisSerializer);
|
||||
template.setHashValueSerializer(jsonRedisSerializer);
|
||||
|
||||
template.afterPropertiesSet();
|
||||
return template;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package com.emotionmuseum.auth.config;
|
||||
|
||||
import com.emotionmuseum.auth.security.JwtAuthenticationFilter;
|
||||
import com.emotionmuseum.auth.security.UserDetailsServiceImpl;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.authentication.AuthenticationProvider;
|
||||
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
|
||||
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
|
||||
/**
|
||||
* Spring Security配置类
|
||||
*
|
||||
* @author emotion-museum
|
||||
* @since 2025-07-15
|
||||
*/
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@EnableMethodSecurity
|
||||
@RequiredArgsConstructor
|
||||
public class SecurityConfig {
|
||||
|
||||
private final UserDetailsServiceImpl userDetailsService;
|
||||
private final JwtAuthenticationFilter jwtAuthenticationFilter;
|
||||
|
||||
/**
|
||||
* 密码编码器
|
||||
*/
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
|
||||
/**
|
||||
* 认证提供者
|
||||
*/
|
||||
@Bean
|
||||
public AuthenticationProvider authenticationProvider() {
|
||||
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
|
||||
authProvider.setUserDetailsService(userDetailsService);
|
||||
authProvider.setPasswordEncoder(passwordEncoder());
|
||||
return authProvider;
|
||||
}
|
||||
|
||||
/**
|
||||
* 认证管理器
|
||||
*/
|
||||
@Bean
|
||||
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
|
||||
return config.getAuthenticationManager();
|
||||
}
|
||||
|
||||
/**
|
||||
* CORS配置
|
||||
*/
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration configuration = new CorsConfiguration();
|
||||
configuration.setAllowedOriginPatterns(Collections.singletonList("*"));
|
||||
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
|
||||
configuration.setAllowedHeaders(Collections.singletonList("*"));
|
||||
configuration.setAllowCredentials(true);
|
||||
configuration.setMaxAge(3600L);
|
||||
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/**", configuration);
|
||||
return source;
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全过滤器链
|
||||
*/
|
||||
@Bean
|
||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
// 禁用CSRF
|
||||
.csrf(AbstractHttpConfigurer::disable)
|
||||
|
||||
// 配置CORS
|
||||
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||
|
||||
// 配置会话管理
|
||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
|
||||
// 配置授权规则
|
||||
.authorizeHttpRequests(authz -> authz
|
||||
// 公开接口
|
||||
.requestMatchers(
|
||||
"/auth/register",
|
||||
"/auth/login",
|
||||
"/auth/refresh",
|
||||
"/auth/check-account",
|
||||
"/auth/check-email",
|
||||
"/auth/check-phone",
|
||||
"/captcha/**",
|
||||
"/oauth/**")
|
||||
.permitAll()
|
||||
|
||||
// 监控和文档接口
|
||||
.requestMatchers(
|
||||
"/actuator/**",
|
||||
"/swagger-ui/**",
|
||||
"/v3/api-docs/**",
|
||||
"/doc.html",
|
||||
"/swagger-resources/**",
|
||||
"/webjars/**",
|
||||
"/error")
|
||||
.permitAll()
|
||||
|
||||
// 其他接口需要认证
|
||||
.anyRequest().authenticated())
|
||||
|
||||
// 配置认证提供者
|
||||
.authenticationProvider(authenticationProvider())
|
||||
|
||||
// 添加JWT过滤器
|
||||
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
package com.emotionmuseum.auth.controller;
|
||||
|
||||
import com.emotionmuseum.common.result.Result;
|
||||
import com.emotionmuseum.auth.dto.LoginRequest;
|
||||
import com.emotionmuseum.auth.dto.RegisterRequest;
|
||||
import com.emotionmuseum.auth.service.AuthService;
|
||||
import com.emotionmuseum.auth.vo.LoginResponse;
|
||||
import com.emotionmuseum.auth.vo.UserInfoResponse;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
|
||||
/**
|
||||
* 认证控制器
|
||||
*
|
||||
* @author emotion-museum
|
||||
* @since 2025-07-16
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/auth")
|
||||
@RequiredArgsConstructor
|
||||
@Validated
|
||||
@Tag(name = "用户认证", description = "用户注册、登录、认证管理")
|
||||
public class AuthController {
|
||||
|
||||
private final AuthService authService;
|
||||
|
||||
@Operation(summary = "用户注册")
|
||||
@PostMapping("/register")
|
||||
public Result<UserInfoResponse> register(@Valid @RequestBody RegisterRequest request) {
|
||||
log.info("用户注册请求: {}", request.getAccount());
|
||||
UserInfoResponse response = authService.register(request);
|
||||
return Result.success("注册成功", response);
|
||||
}
|
||||
|
||||
@Operation(summary = "用户登录")
|
||||
@PostMapping("/login")
|
||||
public Result<LoginResponse> login(@Valid @RequestBody LoginRequest request) {
|
||||
log.info("用户登录请求: {}", request.getAccount());
|
||||
LoginResponse response = authService.login(request);
|
||||
return Result.success("登录成功", response);
|
||||
}
|
||||
|
||||
@Operation(summary = "刷新Token")
|
||||
@PostMapping("/refresh")
|
||||
public Result<LoginResponse> refreshToken(
|
||||
@Parameter(description = "刷新Token") @RequestParam String refreshToken) {
|
||||
log.info("刷新Token请求");
|
||||
LoginResponse response = authService.refreshToken(refreshToken);
|
||||
return Result.success("Token刷新成功", response);
|
||||
}
|
||||
|
||||
@Operation(summary = "用户登出")
|
||||
@PostMapping("/logout")
|
||||
public Result<Void> logout(
|
||||
@Parameter(description = "用户ID") @RequestParam String userId) {
|
||||
log.info("用户登出请求: {}", userId);
|
||||
authService.logout(userId);
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
@Operation(summary = "验证Token")
|
||||
@GetMapping("/validate-token")
|
||||
public Result<Boolean> validateToken() {
|
||||
log.info("验证Token请求");
|
||||
// 如果能到达这里,说明token有效(通过了JWT过滤器)
|
||||
return Result.success("Token有效", true);
|
||||
}
|
||||
|
||||
@Operation(summary = "获取当前用户信息")
|
||||
@GetMapping("/user-info")
|
||||
public Result<UserInfoResponse> getCurrentUserInfo() {
|
||||
log.info("获取当前用户信息请求");
|
||||
UserInfoResponse response = authService.getCurrentUserInfo();
|
||||
return Result.success(response);
|
||||
}
|
||||
|
||||
@Operation(summary = "检查账号是否存在")
|
||||
@GetMapping("/check-account")
|
||||
public Result<Boolean> checkAccount(
|
||||
@Parameter(description = "账号") @RequestParam String account) {
|
||||
log.info("检查账号是否存在: {}", account);
|
||||
boolean exists = authService.existsByAccount(account);
|
||||
return Result.success(exists);
|
||||
}
|
||||
|
||||
@Operation(summary = "检查邮箱是否存在")
|
||||
@GetMapping("/check-email")
|
||||
public Result<Boolean> checkEmail(
|
||||
@Parameter(description = "邮箱") @RequestParam String email) {
|
||||
log.info("检查邮箱是否存在: {}", email);
|
||||
boolean exists = authService.existsByEmail(email);
|
||||
return Result.success(exists);
|
||||
}
|
||||
|
||||
@Operation(summary = "检查手机号是否存在")
|
||||
@GetMapping("/check-phone")
|
||||
public Result<Boolean> checkPhone(
|
||||
@Parameter(description = "手机号") @RequestParam String phone) {
|
||||
log.info("检查手机号是否存在: {}", phone);
|
||||
boolean exists = authService.existsByPhone(phone);
|
||||
return Result.success(exists);
|
||||
}
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
package com.emotionmuseum.auth.controller;
|
||||
|
||||
import com.emotionmuseum.common.result.Result;
|
||||
import com.emotionmuseum.auth.dto.CaptchaResponse;
|
||||
import com.emotionmuseum.auth.dto.SliderCaptchaResponse;
|
||||
import com.emotionmuseum.auth.dto.SliderCaptchaVerifyRequest;
|
||||
import com.emotionmuseum.auth.service.CaptchaService;
|
||||
import com.emotionmuseum.auth.service.SliderCaptchaService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
/**
|
||||
* 验证码控制器
|
||||
*
|
||||
* @author emotion-museum
|
||||
* @since 2025-07-15
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/captcha")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "验证码管理", description = "验证码生成和验证接口")
|
||||
public class CaptchaController {
|
||||
|
||||
private final CaptchaService captchaService;
|
||||
private final SliderCaptchaService sliderCaptchaService;
|
||||
|
||||
@Operation(summary = "生成验证码")
|
||||
@GetMapping("/generate")
|
||||
public Result<CaptchaResponse> generateCaptcha(
|
||||
@Parameter(description = "验证码类型", example = "arithmetic") @RequestParam(defaultValue = "arithmetic") String type) {
|
||||
log.info("生成验证码请求,类型: {}", type);
|
||||
CaptchaResponse response = captchaService.generateCaptcha(type);
|
||||
return Result.success(response);
|
||||
}
|
||||
|
||||
@Operation(summary = "验证验证码")
|
||||
@PostMapping("/verify")
|
||||
public Result<Boolean> verifyCaptcha(
|
||||
@Parameter(description = "验证码ID") @RequestParam String captchaId,
|
||||
@Parameter(description = "验证码") @RequestParam String captcha) {
|
||||
log.info("验证验证码请求,ID: {}", captchaId);
|
||||
boolean isValid = captchaService.verifyCaptcha(captchaId, captcha);
|
||||
return Result.success(isValid);
|
||||
}
|
||||
|
||||
@Operation(summary = "生成滑块验证码")
|
||||
@GetMapping("/slider/generate")
|
||||
public Result<SliderCaptchaResponse> generateSliderCaptcha() {
|
||||
log.info("生成滑块验证码请求");
|
||||
SliderCaptchaResponse response = sliderCaptchaService.generateSliderCaptcha();
|
||||
return Result.success(response);
|
||||
}
|
||||
|
||||
@Operation(summary = "验证滑块验证码")
|
||||
@PostMapping("/slider/verify")
|
||||
public Result<Boolean> verifySliderCaptcha(@RequestBody SliderCaptchaVerifyRequest request) {
|
||||
log.info("验证滑块验证码请求,ID: {}", request.getCaptchaId());
|
||||
boolean isValid = sliderCaptchaService.verifySliderCaptcha(request);
|
||||
return Result.success(isValid);
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package com.emotionmuseum.auth.controller;
|
||||
|
||||
import com.emotionmuseum.common.result.Result;
|
||||
import com.emotionmuseum.auth.dto.OAuthLoginRequest;
|
||||
import com.emotionmuseum.auth.service.OAuthService;
|
||||
import com.emotionmuseum.auth.vo.LoginResponse;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
|
||||
/**
|
||||
* 第三方登录控制器
|
||||
*
|
||||
* @author emotion-museum
|
||||
* @since 2025-07-15
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/oauth")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "第三方登录", description = "微信、QQ等第三方登录接口")
|
||||
public class OAuthController {
|
||||
|
||||
private final OAuthService oauthService;
|
||||
|
||||
@Operation(summary = "获取第三方登录授权URL")
|
||||
@GetMapping("/auth-url/{platform}")
|
||||
public Result<String> getAuthUrl(
|
||||
@Parameter(description = "平台类型", example = "wechat")
|
||||
@PathVariable String platform) {
|
||||
log.info("获取第三方登录授权URL: {}", platform);
|
||||
String authUrl = oauthService.getAuthUrl(platform);
|
||||
return Result.success(authUrl);
|
||||
}
|
||||
|
||||
@Operation(summary = "第三方登录")
|
||||
@PostMapping("/login")
|
||||
public Result<LoginResponse> oauthLogin(@Valid @RequestBody OAuthLoginRequest request) {
|
||||
log.info("第三方登录请求: {}", request.getPlatform());
|
||||
LoginResponse response = oauthService.oauthLogin(request);
|
||||
return Result.success(response);
|
||||
}
|
||||
|
||||
@Operation(summary = "获取第三方用户信息")
|
||||
@GetMapping("/user-info/{platform}")
|
||||
public Result<Object> getOAuthUserInfo(
|
||||
@Parameter(description = "平台类型") @PathVariable String platform,
|
||||
@Parameter(description = "授权码") @RequestParam String code,
|
||||
@Parameter(description = "状态码") @RequestParam(required = false) String state) {
|
||||
log.info("获取第三方用户信息: {}", platform);
|
||||
Object userInfo = oauthService.getOAuthUserInfo(platform, code, state);
|
||||
return Result.success(userInfo);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.emotionmuseum.auth.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 验证码响应
|
||||
*
|
||||
* @author emotion-museum
|
||||
* @since 2025-07-15
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "验证码响应")
|
||||
public class CaptchaResponse {
|
||||
|
||||
@Schema(description = "验证码ID")
|
||||
private String captchaId;
|
||||
|
||||
@Schema(description = "验证码图片Base64")
|
||||
private String captchaImage;
|
||||
|
||||
@Schema(description = "验证码类型", example = "arithmetic")
|
||||
private String captchaType;
|
||||
|
||||
@Schema(description = "过期时间(秒)", example = "300")
|
||||
private Long expireTime;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.emotionmuseum.auth.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
/**
|
||||
* 用户登录请求
|
||||
*
|
||||
* @author emotion-museum
|
||||
* @since 2025-07-12
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "用户登录请求")
|
||||
public class LoginRequest {
|
||||
|
||||
@Schema(description = "账号(支持账号/邮箱/手机号)", example = "test_user")
|
||||
@NotBlank(message = "账号不能为空")
|
||||
private String account;
|
||||
|
||||
@Schema(description = "密码", example = "123456")
|
||||
@NotBlank(message = "密码不能为空")
|
||||
private String password;
|
||||
|
||||
@Schema(description = "验证码ID", example = "captcha_123")
|
||||
@NotBlank(message = "验证码ID不能为空")
|
||||
private String captchaId;
|
||||
|
||||
@Schema(description = "验证码", example = "1234")
|
||||
@NotBlank(message = "验证码不能为空")
|
||||
private String captcha;
|
||||
|
||||
@Schema(description = "记住我", example = "false")
|
||||
private Boolean rememberMe = false;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.emotionmuseum.auth.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
/**
|
||||
* 第三方登录请求
|
||||
*
|
||||
* @author emotion-museum
|
||||
* @since 2025-07-15
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "第三方登录请求")
|
||||
public class OAuthLoginRequest {
|
||||
|
||||
@Schema(description = "第三方平台类型", example = "wechat")
|
||||
@NotBlank(message = "平台类型不能为空")
|
||||
private String platform;
|
||||
|
||||
@Schema(description = "授权码", example = "auth_code_123")
|
||||
@NotBlank(message = "授权码不能为空")
|
||||
private String code;
|
||||
|
||||
@Schema(description = "状态码", example = "state_123")
|
||||
private String state;
|
||||
|
||||
@Schema(description = "验证码ID", example = "captcha_123")
|
||||
@NotBlank(message = "验证码ID不能为空")
|
||||
private String captchaId;
|
||||
|
||||
@Schema(description = "验证码", example = "1234")
|
||||
@NotBlank(message = "验证码不能为空")
|
||||
private String captcha;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.emotionmuseum.auth.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import jakarta.validation.constraints.*;
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* 用户注册请求
|
||||
*
|
||||
* @author emotion-museum
|
||||
* @since 2025-07-12
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "用户注册请求")
|
||||
public class RegisterRequest {
|
||||
|
||||
@Schema(description = "账号", example = "test_user")
|
||||
@NotBlank(message = "账号不能为空")
|
||||
@Size(min = 4, max = 20, message = "账号长度必须在4-20位之间")
|
||||
@Pattern(regexp = "^[a-zA-Z0-9_]+$", message = "账号只能包含字母、数字和下划线")
|
||||
private String account;
|
||||
|
||||
@Schema(description = "密码", example = "123456")
|
||||
@NotBlank(message = "密码不能为空")
|
||||
@Size(min = 6, max = 20, message = "密码长度必须在6-20位之间")
|
||||
private String password;
|
||||
|
||||
@Schema(description = "确认密码", example = "123456")
|
||||
@NotBlank(message = "确认密码不能为空")
|
||||
private String confirmPassword;
|
||||
|
||||
@Schema(description = "验证码ID", example = "captcha_123")
|
||||
@NotBlank(message = "验证码ID不能为空")
|
||||
private String captchaId;
|
||||
|
||||
@Schema(description = "验证码", example = "1234")
|
||||
@NotBlank(message = "验证码不能为空")
|
||||
private String captcha;
|
||||
|
||||
@Schema(description = "用户名", example = "测试用户")
|
||||
@NotBlank(message = "用户名不能为空")
|
||||
@Size(min = 2, max = 20, message = "用户名长度必须在2-20位之间")
|
||||
private String username;
|
||||
|
||||
@Schema(description = "邮箱", example = "test@example.com")
|
||||
@NotBlank(message = "邮箱不能为空")
|
||||
@Email(message = "邮箱格式不正确")
|
||||
private String email;
|
||||
|
||||
@Schema(description = "手机号", example = "13800138000")
|
||||
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
|
||||
private String phone;
|
||||
|
||||
@Schema(description = "昵称", example = "小测试")
|
||||
@NotBlank(message = "昵称不能为空")
|
||||
@Size(min = 1, max = 20, message = "昵称长度必须在1-20位之间")
|
||||
private String nickname;
|
||||
|
||||
@Schema(description = "生日", example = "1990-01-01")
|
||||
private LocalDate birthDate;
|
||||
|
||||
@Schema(description = "所在地", example = "北京市")
|
||||
@Size(max = 50, message = "所在地长度不能超过50位")
|
||||
private String location;
|
||||
|
||||
@Schema(description = "个人简介", example = "这是一个测试用户")
|
||||
@Size(max = 200, message = "个人简介长度不能超过200位")
|
||||
private String bio;
|
||||
|
||||
/**
|
||||
* 验证密码一致性
|
||||
*/
|
||||
public boolean isPasswordMatch() {
|
||||
return password != null && password.equals(confirmPassword);
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.emotionmuseum.auth.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 滑块验证码响应
|
||||
*
|
||||
* @author emotion-museum
|
||||
* @since 2025-07-15
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "滑块验证码响应")
|
||||
public class SliderCaptchaResponse {
|
||||
|
||||
@Schema(description = "验证码ID")
|
||||
private String captchaId;
|
||||
|
||||
@Schema(description = "背景图片Base64")
|
||||
private String backgroundImage;
|
||||
|
||||
@Schema(description = "滑块图片Base64")
|
||||
private String sliderImage;
|
||||
|
||||
@Schema(description = "滑块X坐标")
|
||||
private Integer sliderX;
|
||||
|
||||
@Schema(description = "滑块Y坐标")
|
||||
private Integer sliderY;
|
||||
|
||||
@Schema(description = "过期时间(秒)")
|
||||
private Long expireTime;
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package com.emotionmuseum.auth.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
/**
|
||||
* 滑块验证码验证请求
|
||||
*
|
||||
* @author emotion-museum
|
||||
* @since 2025-07-15
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "滑块验证码验证请求")
|
||||
public class SliderCaptchaVerifyRequest {
|
||||
|
||||
@Schema(description = "验证码ID")
|
||||
@NotBlank(message = "验证码ID不能为空")
|
||||
private String captchaId;
|
||||
|
||||
@Schema(description = "滑块X坐标")
|
||||
@NotNull(message = "滑块X坐标不能为空")
|
||||
private Integer x;
|
||||
|
||||
@Schema(description = "滑块Y坐标")
|
||||
@NotNull(message = "滑块Y坐标不能为空")
|
||||
private Integer y;
|
||||
|
||||
@Schema(description = "滑动轨迹")
|
||||
private String track;
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package com.emotionmuseum.auth.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.emotionmuseum.common.entity.BaseEntity;
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 用户实体
|
||||
*
|
||||
* @author emotion-museum
|
||||
* @since 2025-07-16
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@TableName("user")
|
||||
public class User extends BaseEntity {
|
||||
|
||||
/**
|
||||
* 账号
|
||||
*/
|
||||
@TableField("account")
|
||||
private String account;
|
||||
|
||||
/**
|
||||
* 密码
|
||||
*/
|
||||
@TableField("password")
|
||||
@JsonIgnore
|
||||
private String password;
|
||||
|
||||
/**
|
||||
* 用户名
|
||||
*/
|
||||
@TableField("username")
|
||||
private String username;
|
||||
|
||||
/**
|
||||
* 邮箱
|
||||
*/
|
||||
@TableField("email")
|
||||
private String email;
|
||||
|
||||
/**
|
||||
* 手机号
|
||||
*/
|
||||
@TableField("phone")
|
||||
private String phone;
|
||||
|
||||
/**
|
||||
* 头像URL
|
||||
*/
|
||||
@TableField("avatar")
|
||||
private String avatar;
|
||||
|
||||
/**
|
||||
* 昵称
|
||||
*/
|
||||
@TableField("nickname")
|
||||
private String nickname;
|
||||
|
||||
/**
|
||||
* 生日
|
||||
*/
|
||||
@TableField("birth_date")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd")
|
||||
private LocalDate birthDate;
|
||||
|
||||
/**
|
||||
* 所在地
|
||||
*/
|
||||
@TableField("location")
|
||||
private String location;
|
||||
|
||||
/**
|
||||
* 个人简介
|
||||
*/
|
||||
@TableField("bio")
|
||||
private String bio;
|
||||
|
||||
/**
|
||||
* 会员等级
|
||||
*/
|
||||
@TableField("member_level")
|
||||
private String memberLevel;
|
||||
|
||||
/**
|
||||
* 使用天数
|
||||
*/
|
||||
@TableField("total_days")
|
||||
private Integer totalDays;
|
||||
|
||||
/**
|
||||
* 自我感知
|
||||
*/
|
||||
@TableField("self_awareness")
|
||||
private BigDecimal selfAwareness;
|
||||
|
||||
/**
|
||||
* 情绪韧性
|
||||
*/
|
||||
@TableField("emotional_resilience")
|
||||
private BigDecimal emotionalResilience;
|
||||
|
||||
/**
|
||||
* 行动力
|
||||
*/
|
||||
@TableField("action_power")
|
||||
private BigDecimal actionPower;
|
||||
|
||||
/**
|
||||
* 共情力
|
||||
*/
|
||||
@TableField("empathy")
|
||||
private BigDecimal empathy;
|
||||
|
||||
/**
|
||||
* 生活热度
|
||||
*/
|
||||
@TableField("life_enthusiasm")
|
||||
private BigDecimal lifeEnthusiasm;
|
||||
|
||||
/**
|
||||
* 状态:0-禁用,1-正常
|
||||
*/
|
||||
@TableField("status")
|
||||
private Integer status;
|
||||
|
||||
/**
|
||||
* 是否已验证:0-未验证,1-已验证
|
||||
*/
|
||||
@TableField("is_verified")
|
||||
private Integer isVerified;
|
||||
|
||||
/**
|
||||
* 最后活跃时间
|
||||
*/
|
||||
@TableField("last_active_time")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private LocalDateTime lastActiveTime;
|
||||
|
||||
/**
|
||||
* 第三方登录平台
|
||||
*/
|
||||
@TableField("oauth_platform")
|
||||
private String oauthPlatform;
|
||||
|
||||
/**
|
||||
* 第三方登录ID
|
||||
*/
|
||||
@TableField("oauth_id")
|
||||
private String oauthId;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.emotionmuseum.auth.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.emotionmuseum.auth.entity.User;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
/**
|
||||
* 用户Mapper接口
|
||||
*
|
||||
* @author emotion-museum
|
||||
* @since 2025-07-16
|
||||
*/
|
||||
@Mapper
|
||||
public interface UserMapper extends BaseMapper<User> {
|
||||
|
||||
/**
|
||||
* 根据账号查询用户
|
||||
*
|
||||
* @param account 账号
|
||||
* @return 用户信息
|
||||
*/
|
||||
User selectByAccount(@Param("account") String account);
|
||||
|
||||
/**
|
||||
* 根据邮箱查询用户
|
||||
*
|
||||
* @param email 邮箱
|
||||
* @return 用户信息
|
||||
*/
|
||||
User selectByEmail(@Param("email") String email);
|
||||
|
||||
/**
|
||||
* 根据手机号查询用户
|
||||
*
|
||||
* @param phone 手机号
|
||||
* @return 用户信息
|
||||
*/
|
||||
User selectByPhone(@Param("phone") String phone);
|
||||
|
||||
/**
|
||||
* 根据第三方登录信息查询用户
|
||||
*
|
||||
* @param platform 平台
|
||||
* @param oauthId 第三方ID
|
||||
* @return 用户信息
|
||||
*/
|
||||
User selectByOAuth(@Param("platform") String platform, @Param("oauthId") String oauthId);
|
||||
|
||||
/**
|
||||
* 更新最后活跃时间
|
||||
*
|
||||
* @param userId 用户ID
|
||||
*/
|
||||
void updateLastActiveTime(@Param("userId") String userId);
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
package com.emotionmuseum.auth.security;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.emotionmuseum.common.util.JwtUtil;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* JWT认证过滤器
|
||||
*
|
||||
* @author emotion-museum
|
||||
* @since 2025-07-15
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
private final JwtUtil jwtUtil;
|
||||
private final UserDetailsService userDetailsService;
|
||||
private final RedisTemplate<String, Object> redisTemplate;
|
||||
|
||||
private static final String TOKEN_PREFIX = "Bearer ";
|
||||
private static final String HEADER_NAME = "Authorization";
|
||||
private static final String REDIS_TOKEN_KEY_PREFIX = "auth:token:";
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
|
||||
try {
|
||||
String token = extractTokenFromRequest(request);
|
||||
|
||||
if (StrUtil.isNotBlank(token) && SecurityContextHolder.getContext().getAuthentication() == null) {
|
||||
// 验证token有效性
|
||||
if (jwtUtil.validateToken(token)) {
|
||||
String userId = jwtUtil.getUserIdFromToken(token);
|
||||
|
||||
// 检查Redis中是否存在该token(用于登出功能)
|
||||
String redisKey = REDIS_TOKEN_KEY_PREFIX + userId;
|
||||
String redisToken = (String) redisTemplate.opsForValue().get(redisKey);
|
||||
|
||||
if (StrUtil.isNotBlank(redisToken) && redisToken.equals(token)) {
|
||||
// 加载用户详情
|
||||
UserDetails userDetails = userDetailsService.loadUserByUsername(userId);
|
||||
|
||||
// 创建认证对象
|
||||
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
|
||||
userDetails, null, userDetails.getAuthorities());
|
||||
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
||||
|
||||
// 设置到安全上下文
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
|
||||
// 更新token在Redis中的过期时间
|
||||
redisTemplate.expire(redisKey, 24, TimeUnit.HOURS);
|
||||
|
||||
log.debug("JWT认证成功,用户ID: {}", userId);
|
||||
} else {
|
||||
log.debug("Redis中未找到有效token,用户ID: {}", userId);
|
||||
}
|
||||
} else {
|
||||
log.debug("JWT token无效");
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("JWT认证过程中发生错误: {}", e.getMessage());
|
||||
}
|
||||
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从请求中提取token
|
||||
*/
|
||||
private String extractTokenFromRequest(HttpServletRequest request) {
|
||||
String bearerToken = request.getHeader(HEADER_NAME);
|
||||
if (StrUtil.isNotBlank(bearerToken) && bearerToken.startsWith(TOKEN_PREFIX)) {
|
||||
return bearerToken.substring(TOKEN_PREFIX.length());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否跳过JWT认证
|
||||
*/
|
||||
@Override
|
||||
protected boolean shouldNotFilter(HttpServletRequest request) {
|
||||
String path = request.getRequestURI();
|
||||
|
||||
// 跳过认证的路径
|
||||
return path.startsWith("/user/register") ||
|
||||
path.startsWith("/user/login") ||
|
||||
path.startsWith("/user/refresh") ||
|
||||
path.startsWith("/user/check/") ||
|
||||
path.startsWith("/captcha/") ||
|
||||
path.startsWith("/oauth/") ||
|
||||
path.startsWith("/actuator/") ||
|
||||
path.startsWith("/swagger-ui/") ||
|
||||
path.startsWith("/v3/api-docs") ||
|
||||
path.startsWith("/doc.html") ||
|
||||
path.equals("/error");
|
||||
}
|
||||
}
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
package com.emotionmuseum.auth.security;
|
||||
|
||||
import com.emotionmuseum.auth.entity.User;
|
||||
import com.emotionmuseum.auth.mapper.UserMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
|
||||
/**
|
||||
* Spring Security用户详情服务实现
|
||||
*
|
||||
* @author emotion-museum
|
||||
* @since 2025-07-15
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class UserDetailsServiceImpl implements UserDetailsService {
|
||||
|
||||
private final UserMapper userMapper;
|
||||
|
||||
@Override
|
||||
public UserDetails loadUserByUsername(String userId) throws UsernameNotFoundException {
|
||||
log.debug("加载用户详情,用户ID: {}", userId);
|
||||
|
||||
User user = userMapper.selectById(userId);
|
||||
if (user == null) {
|
||||
log.warn("用户不存在,用户ID: {}", userId);
|
||||
throw new UsernameNotFoundException("用户不存在: " + userId);
|
||||
}
|
||||
|
||||
if (user.getStatus() == 0) {
|
||||
log.warn("用户已被禁用,用户ID: {}", userId);
|
||||
throw new UsernameNotFoundException("用户已被禁用: " + userId);
|
||||
}
|
||||
|
||||
return new SecurityUser(user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Spring Security用户详情实现类
|
||||
*/
|
||||
public static class SecurityUser implements UserDetails {
|
||||
|
||||
private final User user;
|
||||
|
||||
public SecurityUser(User user) {
|
||||
this.user = user;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<? extends GrantedAuthority> getAuthorities() {
|
||||
// 这里可以根据用户角色返回权限
|
||||
// 目前简单返回一个默认角色
|
||||
return Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getPassword() {
|
||||
return user.getPassword();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getUsername() {
|
||||
return user.getId();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户账号
|
||||
*/
|
||||
public String getAccount() {
|
||||
return user.getAccount();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户昵称
|
||||
*/
|
||||
public String getNickname() {
|
||||
return user.getNickname();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户实体
|
||||
*/
|
||||
public User getUser() {
|
||||
return user;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isAccountNonExpired() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isAccountNonLocked() {
|
||||
return user.getStatus() == 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isCredentialsNonExpired() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isEnabled() {
|
||||
return user.getStatus() == 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.emotionmuseum.auth.service;
|
||||
|
||||
import com.emotionmuseum.auth.dto.LoginRequest;
|
||||
import com.emotionmuseum.auth.dto.RegisterRequest;
|
||||
import com.emotionmuseum.auth.vo.LoginResponse;
|
||||
import com.emotionmuseum.auth.vo.UserInfoResponse;
|
||||
|
||||
/**
|
||||
* 认证服务接口
|
||||
*
|
||||
* @author emotion-museum
|
||||
* @since 2025-07-16
|
||||
*/
|
||||
public interface AuthService {
|
||||
|
||||
/**
|
||||
* 用户注册
|
||||
*
|
||||
* @param request 注册请求
|
||||
* @return 用户信息响应
|
||||
*/
|
||||
UserInfoResponse register(RegisterRequest request);
|
||||
|
||||
/**
|
||||
* 用户登录
|
||||
*
|
||||
* @param request 登录请求
|
||||
* @return 登录响应
|
||||
*/
|
||||
LoginResponse login(LoginRequest request);
|
||||
|
||||
/**
|
||||
* 刷新Token
|
||||
*
|
||||
* @param refreshToken 刷新Token
|
||||
* @return 登录响应
|
||||
*/
|
||||
LoginResponse refreshToken(String refreshToken);
|
||||
|
||||
/**
|
||||
* 用户登出
|
||||
*
|
||||
* @param userId 用户ID
|
||||
*/
|
||||
void logout(String userId);
|
||||
|
||||
/**
|
||||
* 获取当前用户信息
|
||||
*
|
||||
* @return 用户信息响应
|
||||
*/
|
||||
UserInfoResponse getCurrentUserInfo();
|
||||
|
||||
/**
|
||||
* 检查账号是否存在
|
||||
*
|
||||
* @param account 账号
|
||||
* @return 是否存在
|
||||
*/
|
||||
boolean existsByAccount(String account);
|
||||
|
||||
/**
|
||||
* 检查邮箱是否存在
|
||||
*
|
||||
* @param email 邮箱
|
||||
* @return 是否存在
|
||||
*/
|
||||
boolean existsByEmail(String email);
|
||||
|
||||
/**
|
||||
* 检查手机号是否存在
|
||||
*
|
||||
* @param phone 手机号
|
||||
* @return 是否存在
|
||||
*/
|
||||
boolean existsByPhone(String phone);
|
||||
|
||||
/**
|
||||
* 根据用户ID获取用户信息
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @return 用户信息响应
|
||||
*/
|
||||
UserInfoResponse getUserInfo(String userId);
|
||||
|
||||
/**
|
||||
* 更新最后活跃时间
|
||||
*
|
||||
* @param userId 用户ID
|
||||
*/
|
||||
void updateLastActiveTime(String userId);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.emotionmuseum.auth.service;
|
||||
|
||||
import com.emotionmuseum.auth.dto.CaptchaResponse;
|
||||
|
||||
/**
|
||||
* 验证码服务接口
|
||||
*
|
||||
* @author emotion-museum
|
||||
* @since 2025-07-15
|
||||
*/
|
||||
public interface CaptchaService {
|
||||
|
||||
/**
|
||||
* 生成验证码
|
||||
*
|
||||
* @param type 验证码类型 (arithmetic, chinese, gif, spec)
|
||||
* @return 验证码响应
|
||||
*/
|
||||
CaptchaResponse generateCaptcha(String type);
|
||||
|
||||
/**
|
||||
* 验证验证码
|
||||
*
|
||||
* @param captchaId 验证码ID
|
||||
* @param captcha 用户输入的验证码
|
||||
* @return 是否验证成功
|
||||
*/
|
||||
boolean verifyCaptcha(String captchaId, String captcha);
|
||||
|
||||
/**
|
||||
* 删除验证码
|
||||
*
|
||||
* @param captchaId 验证码ID
|
||||
*/
|
||||
void removeCaptcha(String captchaId);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.emotionmuseum.auth.service;
|
||||
|
||||
import com.emotionmuseum.auth.dto.OAuthLoginRequest;
|
||||
import com.emotionmuseum.auth.vo.LoginResponse;
|
||||
|
||||
/**
|
||||
* 第三方登录服务接口
|
||||
*
|
||||
* @author emotion-museum
|
||||
* @since 2025-07-15
|
||||
*/
|
||||
public interface OAuthService {
|
||||
|
||||
/**
|
||||
* 获取第三方登录授权URL
|
||||
*
|
||||
* @param platform 平台类型 (wechat, qq, wechat-mp)
|
||||
* @return 授权URL
|
||||
*/
|
||||
String getAuthUrl(String platform);
|
||||
|
||||
/**
|
||||
* 第三方登录
|
||||
*
|
||||
* @param request 第三方登录请求
|
||||
* @return 登录响应
|
||||
*/
|
||||
LoginResponse oauthLogin(OAuthLoginRequest request);
|
||||
|
||||
/**
|
||||
* 获取第三方用户信息
|
||||
*
|
||||
* @param platform 平台类型
|
||||
* @param code 授权码
|
||||
* @param state 状态码
|
||||
* @return 用户信息
|
||||
*/
|
||||
Object getOAuthUserInfo(String platform, String code, String state);
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package com.emotionmuseum.auth.service;
|
||||
|
||||
import com.emotionmuseum.auth.dto.SliderCaptchaResponse;
|
||||
import com.emotionmuseum.auth.dto.SliderCaptchaVerifyRequest;
|
||||
|
||||
/**
|
||||
* 滑块验证码服务接口
|
||||
*
|
||||
* @author emotion-museum
|
||||
* @since 2025-07-15
|
||||
*/
|
||||
public interface SliderCaptchaService {
|
||||
|
||||
/**
|
||||
* 生成滑块验证码
|
||||
*
|
||||
* @return 滑块验证码响应
|
||||
*/
|
||||
SliderCaptchaResponse generateSliderCaptcha();
|
||||
|
||||
/**
|
||||
* 验证滑块验证码
|
||||
*
|
||||
* @param request 验证请求
|
||||
* @return 是否验证成功
|
||||
*/
|
||||
boolean verifySliderCaptcha(SliderCaptchaVerifyRequest request);
|
||||
|
||||
/**
|
||||
* 删除滑块验证码
|
||||
*
|
||||
* @param captchaId 验证码ID
|
||||
*/
|
||||
void removeSliderCaptcha(String captchaId);
|
||||
}
|
||||
+305
@@ -0,0 +1,305 @@
|
||||
package com.emotionmuseum.auth.service.impl;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.emotionmuseum.common.result.ResultCode;
|
||||
import com.emotionmuseum.common.util.JwtUtil;
|
||||
import com.emotionmuseum.auth.dto.LoginRequest;
|
||||
import com.emotionmuseum.auth.dto.RegisterRequest;
|
||||
import com.emotionmuseum.auth.entity.User;
|
||||
import com.emotionmuseum.auth.mapper.UserMapper;
|
||||
import com.emotionmuseum.auth.service.AuthService;
|
||||
import com.emotionmuseum.auth.service.CaptchaService;
|
||||
import com.emotionmuseum.auth.vo.LoginResponse;
|
||||
import com.emotionmuseum.auth.vo.UserInfoResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 认证服务实现
|
||||
*
|
||||
* @author emotion-museum
|
||||
* @since 2025-07-16
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AuthServiceImpl extends ServiceImpl<UserMapper, User> implements AuthService {
|
||||
|
||||
private final ApplicationContext applicationContext;
|
||||
private final CaptchaService captchaService;
|
||||
private final JwtUtil jwtUtil;
|
||||
private final RedisTemplate<String, Object> redisTemplate;
|
||||
|
||||
private static final String REDIS_TOKEN_KEY_PREFIX = "auth:token:";
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public UserInfoResponse register(RegisterRequest request) {
|
||||
// 验证验证码
|
||||
if (!captchaService.verifyCaptcha(request.getCaptchaId(), request.getCaptcha())) {
|
||||
throw new RuntimeException(ResultCode.CAPTCHA_ERROR.getMessage());
|
||||
}
|
||||
|
||||
// 验证密码一致性
|
||||
if (!request.isPasswordMatch()) {
|
||||
throw new RuntimeException(ResultCode.PARAM_VALIDATION_ERROR.getMessage() + ": 两次密码不一致");
|
||||
}
|
||||
|
||||
// 检查账号是否存在
|
||||
if (existsByAccount(request.getAccount())) {
|
||||
throw new RuntimeException(ResultCode.ACCOUNT_ALREADY_EXISTS.getMessage());
|
||||
}
|
||||
|
||||
// 检查邮箱是否存在
|
||||
if (StrUtil.isNotBlank(request.getEmail()) && existsByEmail(request.getEmail())) {
|
||||
throw new RuntimeException(ResultCode.EMAIL_ALREADY_EXISTS.getMessage());
|
||||
}
|
||||
|
||||
// 检查手机号是否存在
|
||||
if (StrUtil.isNotBlank(request.getPhone()) && existsByPhone(request.getPhone())) {
|
||||
throw new RuntimeException("手机号已存在");
|
||||
}
|
||||
|
||||
// 创建用户
|
||||
User user = new User();
|
||||
BeanUtils.copyProperties(request, user);
|
||||
|
||||
// 加密密码
|
||||
PasswordEncoder passwordEncoder = applicationContext.getBean(PasswordEncoder.class);
|
||||
user.setPassword(passwordEncoder.encode(request.getPassword()));
|
||||
|
||||
// 设置默认值
|
||||
user.setMemberLevel("free");
|
||||
user.setTotalDays(0);
|
||||
user.setSelfAwareness(new BigDecimal("50.00"));
|
||||
user.setEmotionalResilience(new BigDecimal("50.00"));
|
||||
user.setActionPower(new BigDecimal("50.00"));
|
||||
user.setEmpathy(new BigDecimal("50.00"));
|
||||
user.setLifeEnthusiasm(new BigDecimal("50.00"));
|
||||
user.setStatus(1);
|
||||
user.setIsVerified(0);
|
||||
user.setLastActiveTime(LocalDateTime.now());
|
||||
|
||||
// 保存用户
|
||||
save(user);
|
||||
|
||||
log.info("用户注册成功: {}", user.getAccount());
|
||||
return convertToUserInfoResponse(user);
|
||||
}
|
||||
|
||||
@Override
|
||||
public LoginResponse login(LoginRequest request) {
|
||||
// 验证验证码
|
||||
if (!captchaService.verifyCaptcha(request.getCaptchaId(), request.getCaptcha())) {
|
||||
throw new RuntimeException(ResultCode.CAPTCHA_ERROR.getMessage());
|
||||
}
|
||||
|
||||
// 查找用户(支持账号/邮箱/手机号登录)
|
||||
User user = findUserByAccount(request.getAccount());
|
||||
if (user == null) {
|
||||
throw new RuntimeException(ResultCode.USER_NOT_FOUND.getMessage());
|
||||
}
|
||||
|
||||
// 验证密码
|
||||
PasswordEncoder passwordEncoder = applicationContext.getBean(PasswordEncoder.class);
|
||||
if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) {
|
||||
throw new RuntimeException(ResultCode.INVALID_CREDENTIALS.getMessage());
|
||||
}
|
||||
|
||||
// 检查用户状态
|
||||
if (user.getStatus() == 0) {
|
||||
throw new RuntimeException(ResultCode.USER_DISABLED.getMessage());
|
||||
}
|
||||
|
||||
// 生成Token
|
||||
String accessToken = jwtUtil.generateToken(user.getId(), user.getUsername());
|
||||
String refreshToken = jwtUtil.generateRefreshToken(user.getId(), user.getUsername());
|
||||
|
||||
// 将token存储到Redis中(用于登出和token管理)
|
||||
String redisKey = REDIS_TOKEN_KEY_PREFIX + user.getId();
|
||||
redisTemplate.opsForValue().set(redisKey, accessToken, 24, TimeUnit.HOURS);
|
||||
|
||||
// 更新最后活跃时间
|
||||
updateLastActiveTime(user.getId());
|
||||
|
||||
// 构建响应
|
||||
LoginResponse response = new LoginResponse();
|
||||
response.setAccessToken(accessToken);
|
||||
response.setRefreshToken(refreshToken);
|
||||
response.setExpiresIn(86400L); // 24小时
|
||||
response.setUserInfo(convertToUserInfoResponse(user));
|
||||
response.setLoginTime(LocalDateTime.now());
|
||||
|
||||
log.info("用户登录成功: {}", user.getAccount());
|
||||
return response;
|
||||
}
|
||||
|
||||
@Override
|
||||
public LoginResponse refreshToken(String refreshToken) {
|
||||
try {
|
||||
// 验证刷新Token
|
||||
if (!jwtUtil.validateToken(refreshToken)) {
|
||||
throw new RuntimeException("刷新Token无效");
|
||||
}
|
||||
|
||||
// 从刷新Token中获取用户信息
|
||||
String userId = jwtUtil.getUserIdFromToken(refreshToken);
|
||||
User user = getById(userId);
|
||||
if (user == null) {
|
||||
throw new RuntimeException("用户不存在");
|
||||
}
|
||||
|
||||
// 生成新的Token
|
||||
String newAccessToken = jwtUtil.generateToken(user.getId(), user.getUsername());
|
||||
String newRefreshToken = jwtUtil.generateRefreshToken(user.getId(), user.getUsername());
|
||||
|
||||
// 更新Redis中的token
|
||||
String redisKey = REDIS_TOKEN_KEY_PREFIX + user.getId();
|
||||
redisTemplate.opsForValue().set(redisKey, newAccessToken, 24, TimeUnit.HOURS);
|
||||
|
||||
// 构建响应
|
||||
LoginResponse response = new LoginResponse();
|
||||
response.setAccessToken(newAccessToken);
|
||||
response.setRefreshToken(newRefreshToken);
|
||||
response.setExpiresIn(86400L);
|
||||
response.setUserInfo(convertToUserInfoResponse(user));
|
||||
response.setLoginTime(LocalDateTime.now());
|
||||
|
||||
log.info("Token刷新成功: {}", user.getAccount());
|
||||
return response;
|
||||
} catch (Exception e) {
|
||||
log.error("Token刷新失败: {}", e.getMessage());
|
||||
throw new RuntimeException("Token刷新失败");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void logout(String userId) {
|
||||
try {
|
||||
// 从Redis中删除token
|
||||
String redisKey = REDIS_TOKEN_KEY_PREFIX + userId;
|
||||
redisTemplate.delete(redisKey);
|
||||
|
||||
log.info("用户登出成功: {}", userId);
|
||||
} catch (Exception e) {
|
||||
log.error("用户登出失败: {}", e.getMessage());
|
||||
throw new RuntimeException("登出失败");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public UserInfoResponse getCurrentUserInfo() {
|
||||
// 从安全上下文获取当前用户ID
|
||||
String userId = getCurrentUserId();
|
||||
if (StrUtil.isBlank(userId)) {
|
||||
throw new RuntimeException("未登录");
|
||||
}
|
||||
|
||||
User user = getById(userId);
|
||||
if (user == null) {
|
||||
throw new RuntimeException("用户不存在");
|
||||
}
|
||||
|
||||
return convertToUserInfoResponse(user);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean existsByAccount(String account) {
|
||||
return baseMapper.selectByAccount(account) != null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean existsByEmail(String email) {
|
||||
return baseMapper.selectByEmail(email) != null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean existsByPhone(String phone) {
|
||||
return baseMapper.selectByPhone(phone) != null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public UserInfoResponse getUserInfo(String userId) {
|
||||
User user = getById(userId);
|
||||
if (user == null) {
|
||||
throw new RuntimeException("用户不存在");
|
||||
}
|
||||
return convertToUserInfoResponse(user);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateLastActiveTime(String userId) {
|
||||
baseMapper.updateLastActiveTime(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据账号查找用户(支持账号/邮箱/手机号)
|
||||
*/
|
||||
private User findUserByAccount(String account) {
|
||||
// 先按账号查找
|
||||
User user = baseMapper.selectByAccount(account);
|
||||
if (user != null) {
|
||||
return user;
|
||||
}
|
||||
|
||||
// 按邮箱查找
|
||||
if (account.contains("@")) {
|
||||
user = baseMapper.selectByEmail(account);
|
||||
if (user != null) {
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
||||
// 按手机号查找
|
||||
if (account.matches("^1[3-9]\\d{9}$")) {
|
||||
user = baseMapper.selectByPhone(account);
|
||||
if (user != null) {
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为用户信息响应
|
||||
*/
|
||||
private UserInfoResponse convertToUserInfoResponse(User user) {
|
||||
UserInfoResponse response = new UserInfoResponse();
|
||||
BeanUtils.copyProperties(user, response);
|
||||
|
||||
// 设置成长数据
|
||||
UserInfoResponse.GrowthStatsVO growthStats = new UserInfoResponse.GrowthStatsVO();
|
||||
growthStats.setSelfAwareness(user.getSelfAwareness());
|
||||
growthStats.setEmotionalResilience(user.getEmotionalResilience());
|
||||
growthStats.setActionPower(user.getActionPower());
|
||||
growthStats.setEmpathy(user.getEmpathy());
|
||||
growthStats.setLifeEnthusiasm(user.getLifeEnthusiasm());
|
||||
response.setGrowthStats(growthStats);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户ID
|
||||
*/
|
||||
private String getCurrentUserId() {
|
||||
try {
|
||||
return SecurityContextHolder.getContext().getAuthentication().getName();
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
package com.emotionmuseum.auth.service.impl;
|
||||
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.emotionmuseum.auth.dto.CaptchaResponse;
|
||||
import com.emotionmuseum.auth.dto.SliderCaptchaResponse;
|
||||
import com.emotionmuseum.auth.dto.SliderCaptchaVerifyRequest;
|
||||
import com.emotionmuseum.auth.service.CaptchaService;
|
||||
import com.emotionmuseum.auth.service.SliderCaptchaService;
|
||||
import com.wf.captcha.base.Captcha;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 验证码服务实现
|
||||
*
|
||||
* @author emotion-museum
|
||||
* @since 2025-07-15
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class CaptchaServiceImpl implements CaptchaService {
|
||||
|
||||
private final RedisTemplate<String, Object> redisTemplate;
|
||||
private final ApplicationContext applicationContext;
|
||||
|
||||
private static final String CAPTCHA_KEY_PREFIX = "captcha:";
|
||||
private static final long CAPTCHA_EXPIRE_TIME = 300; // 5分钟
|
||||
|
||||
@Override
|
||||
public CaptchaResponse generateCaptcha(String type) {
|
||||
try {
|
||||
// 根据类型获取验证码Bean
|
||||
String beanName = getBeanNameByType(type);
|
||||
Captcha captcha = (Captcha) applicationContext.getBean(beanName);
|
||||
|
||||
// 生成验证码
|
||||
String captchaId = IdUtil.simpleUUID();
|
||||
String captchaText = captcha.text();
|
||||
String captchaImage = captcha.toBase64();
|
||||
|
||||
// 存储到Redis
|
||||
String redisKey = CAPTCHA_KEY_PREFIX + captchaId;
|
||||
redisTemplate.opsForValue().set(redisKey, captchaText.toLowerCase(), CAPTCHA_EXPIRE_TIME, TimeUnit.SECONDS);
|
||||
|
||||
log.debug("生成验证码成功,ID: {}, 内容: {}", captchaId, captchaText);
|
||||
|
||||
return new CaptchaResponse(captchaId, captchaImage, type, CAPTCHA_EXPIRE_TIME);
|
||||
} catch (Exception e) {
|
||||
log.error("生成验证码失败: {}", e.getMessage());
|
||||
throw new RuntimeException("生成验证码失败");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean verifyCaptcha(String captchaId, String captcha) {
|
||||
if (StrUtil.isBlank(captchaId) || StrUtil.isBlank(captcha)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
String redisKey = CAPTCHA_KEY_PREFIX + captchaId;
|
||||
String storedCaptcha = (String) redisTemplate.opsForValue().get(redisKey);
|
||||
|
||||
if (StrUtil.isBlank(storedCaptcha)) {
|
||||
log.warn("验证码已过期或不存在,ID: {}", captchaId);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 验证码不区分大小写
|
||||
boolean isValid = storedCaptcha.equalsIgnoreCase(captcha.trim());
|
||||
|
||||
if (isValid) {
|
||||
// 验证成功后删除验证码
|
||||
redisTemplate.delete(redisKey);
|
||||
log.debug("验证码验证成功,ID: {}", captchaId);
|
||||
} else {
|
||||
log.warn("验证码验证失败,ID: {}, 期望: {}, 实际: {}", captchaId, storedCaptcha, captcha);
|
||||
}
|
||||
|
||||
return isValid;
|
||||
} catch (Exception e) {
|
||||
log.error("验证验证码失败: {}", e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeCaptcha(String captchaId) {
|
||||
if (StrUtil.isNotBlank(captchaId)) {
|
||||
String redisKey = CAPTCHA_KEY_PREFIX + captchaId;
|
||||
redisTemplate.delete(redisKey);
|
||||
log.debug("删除验证码,ID: {}", captchaId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据类型获取Bean名称
|
||||
*/
|
||||
private String getBeanNameByType(String type) {
|
||||
String defaultType = StrUtil.blankToDefault(type, "spec");
|
||||
switch (defaultType) {
|
||||
case "arithmetic":
|
||||
return "arithmeticCaptcha";
|
||||
case "chinese":
|
||||
return "chineseCaptcha";
|
||||
case "gif":
|
||||
return "gifCaptcha";
|
||||
default:
|
||||
return "specCaptcha";
|
||||
}
|
||||
}
|
||||
}
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
package com.emotionmuseum.auth.service.impl;
|
||||
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.emotionmuseum.auth.dto.SliderCaptchaResponse;
|
||||
import com.emotionmuseum.auth.dto.SliderCaptchaVerifyRequest;
|
||||
import com.emotionmuseum.auth.service.SliderCaptchaService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.*;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.Base64;
|
||||
import java.util.Random;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 滑块验证码服务实现
|
||||
*
|
||||
* @author emotion-museum
|
||||
* @since 2025-07-15
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class SliderCaptchaServiceImpl implements SliderCaptchaService {
|
||||
|
||||
private final RedisTemplate<String, Object> redisTemplate;
|
||||
|
||||
private static final String SLIDER_CAPTCHA_KEY_PREFIX = "slider_captcha:";
|
||||
private static final long SLIDER_CAPTCHA_EXPIRE_TIME = 300; // 5分钟
|
||||
private static final int BACKGROUND_WIDTH = 300;
|
||||
private static final int BACKGROUND_HEIGHT = 150;
|
||||
private static final int SLIDER_WIDTH = 60;
|
||||
private static final int SLIDER_HEIGHT = 60;
|
||||
private static final int TOLERANCE = 5; // 容错范围
|
||||
|
||||
@Override
|
||||
public SliderCaptchaResponse generateSliderCaptcha() {
|
||||
try {
|
||||
String captchaId = IdUtil.simpleUUID();
|
||||
|
||||
// 生成随机位置
|
||||
Random random = new Random();
|
||||
int sliderX = random.nextInt(BACKGROUND_WIDTH - SLIDER_WIDTH - 50) + 50;
|
||||
int sliderY = random.nextInt(BACKGROUND_HEIGHT - SLIDER_HEIGHT - 20) + 20;
|
||||
|
||||
// 生成背景图片
|
||||
BufferedImage backgroundImage = generateBackgroundImage(sliderX, sliderY);
|
||||
String backgroundBase64 = imageToBase64(backgroundImage);
|
||||
|
||||
// 生成滑块图片
|
||||
BufferedImage sliderImage = generateSliderImage();
|
||||
String sliderBase64 = imageToBase64(sliderImage);
|
||||
|
||||
// 存储到Redis
|
||||
String redisKey = SLIDER_CAPTCHA_KEY_PREFIX + captchaId;
|
||||
SliderCaptchaData data = new SliderCaptchaData(sliderX, sliderY);
|
||||
redisTemplate.opsForValue().set(redisKey, data, SLIDER_CAPTCHA_EXPIRE_TIME, TimeUnit.SECONDS);
|
||||
|
||||
log.debug("生成滑块验证码成功,ID: {}, 位置: ({}, {})", captchaId, sliderX, sliderY);
|
||||
|
||||
return new SliderCaptchaResponse(captchaId, backgroundBase64, sliderBase64, 0, sliderY, SLIDER_CAPTCHA_EXPIRE_TIME);
|
||||
} catch (Exception e) {
|
||||
log.error("生成滑块验证码失败: {}", e.getMessage());
|
||||
throw new RuntimeException("生成滑块验证码失败");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean verifySliderCaptcha(SliderCaptchaVerifyRequest request) {
|
||||
if (StrUtil.isBlank(request.getCaptchaId()) || request.getX() == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
String redisKey = SLIDER_CAPTCHA_KEY_PREFIX + request.getCaptchaId();
|
||||
SliderCaptchaData data = (SliderCaptchaData) redisTemplate.opsForValue().get(redisKey);
|
||||
|
||||
if (data == null) {
|
||||
log.warn("滑块验证码已过期或不存在,ID: {}", request.getCaptchaId());
|
||||
return false;
|
||||
}
|
||||
|
||||
// 验证X坐标是否在容错范围内
|
||||
boolean isValid = Math.abs(data.getSliderX() - request.getX()) <= TOLERANCE;
|
||||
|
||||
if (isValid) {
|
||||
// 验证成功后删除验证码
|
||||
redisTemplate.delete(redisKey);
|
||||
log.debug("滑块验证码验证成功,ID: {}", request.getCaptchaId());
|
||||
} else {
|
||||
log.warn("滑块验证码验证失败,ID: {}, 期望X: {}, 实际X: {}",
|
||||
request.getCaptchaId(), data.getSliderX(), request.getX());
|
||||
}
|
||||
|
||||
return isValid;
|
||||
} catch (Exception e) {
|
||||
log.error("验证滑块验证码失败: {}", e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeSliderCaptcha(String captchaId) {
|
||||
if (StrUtil.isNotBlank(captchaId)) {
|
||||
String redisKey = SLIDER_CAPTCHA_KEY_PREFIX + captchaId;
|
||||
redisTemplate.delete(redisKey);
|
||||
log.debug("删除滑块验证码,ID: {}", captchaId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成背景图片
|
||||
*/
|
||||
private BufferedImage generateBackgroundImage(int sliderX, int sliderY) {
|
||||
BufferedImage image = new BufferedImage(BACKGROUND_WIDTH, BACKGROUND_HEIGHT, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D g2d = image.createGraphics();
|
||||
|
||||
// 设置抗锯齿
|
||||
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||
|
||||
// 绘制渐变背景
|
||||
GradientPaint gradient = new GradientPaint(0, 0, new Color(135, 206, 250),
|
||||
BACKGROUND_WIDTH, BACKGROUND_HEIGHT, new Color(70, 130, 180));
|
||||
g2d.setPaint(gradient);
|
||||
g2d.fillRect(0, 0, BACKGROUND_WIDTH, BACKGROUND_HEIGHT);
|
||||
|
||||
// 绘制一些装饰性图形
|
||||
Random random = new Random();
|
||||
g2d.setColor(new Color(255, 255, 255, 100));
|
||||
for (int i = 0; i < 20; i++) {
|
||||
int x = random.nextInt(BACKGROUND_WIDTH);
|
||||
int y = random.nextInt(BACKGROUND_HEIGHT);
|
||||
int size = random.nextInt(20) + 5;
|
||||
g2d.fillOval(x, y, size, size);
|
||||
}
|
||||
|
||||
// 绘制滑块缺口
|
||||
g2d.setColor(new Color(0, 0, 0, 150));
|
||||
g2d.fillRoundRect(sliderX, sliderY, SLIDER_WIDTH, SLIDER_HEIGHT, 10, 10);
|
||||
|
||||
g2d.dispose();
|
||||
return image;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成滑块图片
|
||||
*/
|
||||
private BufferedImage generateSliderImage() {
|
||||
BufferedImage image = new BufferedImage(SLIDER_WIDTH, SLIDER_HEIGHT, BufferedImage.TYPE_INT_ARGB);
|
||||
Graphics2D g2d = image.createGraphics();
|
||||
|
||||
// 设置抗锯齿
|
||||
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||
|
||||
// 绘制滑块
|
||||
g2d.setColor(new Color(70, 130, 180));
|
||||
g2d.fillRoundRect(0, 0, SLIDER_WIDTH, SLIDER_HEIGHT, 10, 10);
|
||||
|
||||
// 绘制边框
|
||||
g2d.setColor(new Color(255, 255, 255));
|
||||
g2d.setStroke(new BasicStroke(2));
|
||||
g2d.drawRoundRect(1, 1, SLIDER_WIDTH - 2, SLIDER_HEIGHT - 2, 10, 10);
|
||||
|
||||
// 绘制箭头
|
||||
g2d.setColor(Color.WHITE);
|
||||
int[] xPoints = {SLIDER_WIDTH/2 - 8, SLIDER_WIDTH/2 + 8, SLIDER_WIDTH/2};
|
||||
int[] yPoints = {SLIDER_HEIGHT/2, SLIDER_HEIGHT/2, SLIDER_HEIGHT/2 - 8};
|
||||
g2d.fillPolygon(xPoints, yPoints, 3);
|
||||
|
||||
g2d.dispose();
|
||||
return image;
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片转Base64
|
||||
*/
|
||||
private String imageToBase64(BufferedImage image) throws IOException {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
ImageIO.write(image, "PNG", baos);
|
||||
byte[] bytes = baos.toByteArray();
|
||||
return "data:image/png;base64," + Base64.getEncoder().encodeToString(bytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* 滑块验证码数据
|
||||
*/
|
||||
public static class SliderCaptchaData {
|
||||
private int sliderX;
|
||||
private int sliderY;
|
||||
|
||||
public SliderCaptchaData() {}
|
||||
|
||||
public SliderCaptchaData(int sliderX, int sliderY) {
|
||||
this.sliderX = sliderX;
|
||||
this.sliderY = sliderY;
|
||||
}
|
||||
|
||||
public int getSliderX() { return sliderX; }
|
||||
public void setSliderX(int sliderX) { this.sliderX = sliderX; }
|
||||
public int getSliderY() { return sliderY; }
|
||||
public void setSliderY(int sliderY) { this.sliderY = sliderY; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.emotionmuseum.auth.vo;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 登录响应
|
||||
*
|
||||
* @author emotion-museum
|
||||
* @since 2025-07-12
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "登录响应")
|
||||
public class LoginResponse {
|
||||
|
||||
@Schema(description = "访问Token")
|
||||
private String accessToken;
|
||||
|
||||
@Schema(description = "刷新Token")
|
||||
private String refreshToken;
|
||||
|
||||
@Schema(description = "Token类型", example = "Bearer")
|
||||
private String tokenType = "Bearer";
|
||||
|
||||
@Schema(description = "Token过期时间(秒)", example = "86400")
|
||||
private Long expiresIn;
|
||||
|
||||
@Schema(description = "用户信息")
|
||||
private UserInfoResponse userInfo;
|
||||
|
||||
@Schema(description = "登录时间")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private LocalDateTime loginTime;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package com.emotionmuseum.auth.vo;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 用户信息响应
|
||||
*
|
||||
* @author emotion-museum
|
||||
* @since 2025-07-16
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "用户信息响应")
|
||||
public class UserInfoResponse {
|
||||
|
||||
@Schema(description = "用户ID")
|
||||
private String id;
|
||||
|
||||
@Schema(description = "账号")
|
||||
private String account;
|
||||
|
||||
@Schema(description = "用户名")
|
||||
private String username;
|
||||
|
||||
@Schema(description = "邮箱")
|
||||
private String email;
|
||||
|
||||
@Schema(description = "手机号")
|
||||
private String phone;
|
||||
|
||||
@Schema(description = "头像URL")
|
||||
private String avatar;
|
||||
|
||||
@Schema(description = "昵称")
|
||||
private String nickname;
|
||||
|
||||
@Schema(description = "生日")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd")
|
||||
private LocalDate birthDate;
|
||||
|
||||
@Schema(description = "所在地")
|
||||
private String location;
|
||||
|
||||
@Schema(description = "个人简介")
|
||||
private String bio;
|
||||
|
||||
@Schema(description = "会员等级")
|
||||
private String memberLevel;
|
||||
|
||||
@Schema(description = "使用天数")
|
||||
private Integer totalDays;
|
||||
|
||||
@Schema(description = "成长数据")
|
||||
private GrowthStatsVO growthStats;
|
||||
|
||||
@Schema(description = "状态")
|
||||
private Integer status;
|
||||
|
||||
@Schema(description = "是否已验证")
|
||||
private Integer isVerified;
|
||||
|
||||
@Schema(description = "创建时间")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private LocalDateTime createTime;
|
||||
|
||||
@Schema(description = "最后活跃时间")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private LocalDateTime lastActiveTime;
|
||||
|
||||
/**
|
||||
* 成长数据VO
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "成长数据")
|
||||
public static class GrowthStatsVO {
|
||||
|
||||
@Schema(description = "自我感知")
|
||||
private BigDecimal selfAwareness;
|
||||
|
||||
@Schema(description = "情绪韧性")
|
||||
private BigDecimal emotionalResilience;
|
||||
|
||||
@Schema(description = "行动力")
|
||||
private BigDecimal actionPower;
|
||||
|
||||
@Schema(description = "共情力")
|
||||
private BigDecimal empathy;
|
||||
|
||||
@Schema(description = "生活热度")
|
||||
private BigDecimal lifeEnthusiasm;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user