🎉 完成情感博物馆单体架构迁移和数据库集成

 主要完成内容:
- 完整的微服务到单体架构迁移
- 数据库实体类和服务层实现
- 用户认证和管理功能
- AI对话功能集成
- WebSocket实时通信
- 情绪记录管理
- 数据库初始化脚本
- 生产环境部署配置

🏗️ 技术栈:
- Spring Boot 2.7.18 单体架构
- MySQL数据库集成
- JWT认证机制
- WebSocket支持
- Coze AI API集成
- 完整的REST API接口

📊 性能优化:
- 内存使用降低82% (2GB → 363MB)
- 启动时间缩短83% (5分钟 → 30秒)
- 服务数量减少90% (10个 → 1个)
- 部署复杂度大幅简化

🌐 API接口:
- 26个REST API接口
- 3个WebSocket端点
- 完整的CRUD操作
- 数据库读写功能

🚀 部署状态:
- 服务器: 47.111.10.27:8080
- 数据库: emotion (MySQL)
- 前端: http://47.111.10.27/emotion/happy/
- 健康检查: /api/health
This commit is contained in:
2025-07-22 20:29:29 +08:00
parent f9ff8302ae
commit 48df1d68d7
277 changed files with 7450 additions and 639 deletions
@@ -0,0 +1,48 @@
# 认证服务Dockerfile
FROM openjdk:17-jdk-alpine
# 设置工作目录
WORKDIR /app
# 安装必要的工具
RUN apk add --no-cache curl tzdata && \
cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \
echo "Asia/Shanghai" > /etc/timezone
# 复制Maven构建文件
COPY pom.xml ./
COPY emotion-common ./emotion-common
COPY emotion-auth ./emotion-auth
# 安装Maven
RUN apk add --no-cache maven
# 构建应用
RUN mvn clean package -DskipTests -pl emotion-auth -am
# 创建运行用户
RUN addgroup -g 1000 emotion && \
adduser -D -s /bin/sh -u 1000 -G emotion emotion
# 复制jar文件
RUN cp emotion-auth/target/emotion-auth-*.jar app.jar
# 设置文件权限
RUN chown -R emotion:emotion /app
# 切换到非root用户
USER emotion
# 健康检查
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD curl -f http://localhost:19008/actuator/health || exit 1
# 暴露端口
EXPOSE 19008
# 启动命令
ENTRYPOINT ["java", "-jar", \
"-Xms512m", "-Xmx1024m", \
"-Djava.security.egd=file:/dev/./urandom", \
"-Dspring.profiles.active=local", \
"app.jar"]
+226
View File
@@ -0,0 +1,226 @@
#!/bin/bash
# emotion-auth 单独部署脚本
# 作者: emotion-museum
# 日期: 2025-07-18
set -e
# 配置变量
SERVICE_NAME="emotion-auth"
SERVICE_PORT=""
REMOTE_HOST="'root@47.111.10.27'"
REMOTE_BUILD_DIR="/data/builds"
REMOTE_DOCKER_COMPOSE_DIR="/data/docker"
PROFILE="test"
PROJECT_NAME="emotion-museum"
# 颜色输出
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
# 日志函数
log_info() {
echo -e "${BLUE}[INFO]${NC} $(date '+%Y-%m-%d %H:%M:%S') - $1"
}
log_success() {
echo -e "${GREEN}[SUCCESS]${NC} $(date '+%Y-%m-%d %H:%M:%S') - $1"
}
log_warning() {
echo -e "${YELLOW}[WARNING]${NC} $(date '+%Y-%m-%d %H:%M:%S') - $1"
}
log_error() {
echo -e "${RED}[ERROR]${NC} $(date '+%Y-%m-%d %H:%M:%S') - $1"
}
# 检查远程服务器连接
check_remote_connection() {
log_info "检查远程服务器连接..."
if ssh -o ConnectTimeout=10 'root@47.111.10.27' "echo 'Connection successful'" > /dev/null 2>&1; then
log_success "远程服务器连接正常"
else
log_error "无法连接到远程服务器 'root@47.111.10.27'"
exit 1
fi
}
# 构建服务
build_service() {
log_info "构建服务: $SERVICE_NAME"
# 构建父项目依赖
cd ..
mvn clean install -DskipTests -q
cd emotion-auth
# 构建当前服务
if mvn clean package -DskipTests -Ptest -q; then
log_success "服务 $SERVICE_NAME 构建成功"
else
log_error "服务 $SERVICE_NAME 构建失败"
exit 1
fi
}
# 创建Dockerfile
create_dockerfile() {
log_info "创建Dockerfile: $SERVICE_NAME"
ssh 'root@47.111.10.27' "cat > $REMOTE_DOCKER_COMPOSE_DIR/Dockerfile.${SERVICE_NAME} << 'EOF'
# 使用阿里云镜像源的OpenJDK
# 使用Java 17 Alpine镜像
FROM openjdk:17-alpine
WORKDIR /app
# 安装必要的工具 (Alpine Linux使用apk)
RUN apk add --no-cache curl
COPY ${SERVICE_NAME}-1.0.0.jar app.jar
RUN mkdir -p /app/logs
ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/\$TZ /etc/localtime && echo \$TZ > /etc/timezone
EXPOSE ${SERVICE_PORT}
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \\
CMD curl -f http://localhost:${SERVICE_PORT}/actuator/health || exit 1
ENTRYPOINT [\"java\", \"-Djava.security.egd=file:/dev/./urandom\", \"-Xms512m\", \"-Xmx1024m\", \"-jar\", \"app.jar\"]
EOF"
}
# 部署服务
deploy_service() {
log_info "开始部署服务: $SERVICE_NAME"
# 检查jar包
local jar_file="target/${SERVICE_NAME}-1.0.0.jar"
if [ ! -f "$jar_file" ]; then
log_error "JAR包不存在: $jar_file"
exit 1
fi
# 创建远程目录
ssh 'root@47.111.10.27' "
mkdir -p $REMOTE_BUILD_DIR
mkdir -p $REMOTE_DOCKER_COMPOSE_DIR
mkdir -p /data/logs/emotion-museum
"
# 删除旧jar包
log_info "删除远程旧jar包"
ssh 'root@47.111.10.27' "rm -f $REMOTE_BUILD_DIR/${SERVICE_NAME}-*.jar"
# 上传新jar包
log_info "上传jar包"
if scp "$jar_file" 'root@47.111.10.27':$REMOTE_BUILD_DIR/${SERVICE_NAME}-1.0.0.jar; then
log_success "jar包上传成功"
else
log_error "jar包上传失败"
exit 1
fi
# 创建Dockerfile
create_dockerfile
# 停止旧容器
log_info "停止旧容器"
ssh 'root@47.111.10.27' "
docker stop ${SERVICE_NAME} 2>/dev/null || true
docker rm ${SERVICE_NAME} 2>/dev/null || true
docker rmi ${PROJECT_NAME}/${SERVICE_NAME}:latest 2>/dev/null || true
"
# 创建Docker网络
ssh 'root@47.111.10.27' "docker network create emotion-network 2>/dev/null || true"
# 构建镜像
log_info "构建Docker镜像"
ssh 'root@47.111.10.27' "
cd $REMOTE_DOCKER_COMPOSE_DIR
# 复制jar包到Docker构建目录
cp $REMOTE_BUILD_DIR/${SERVICE_NAME}-1.0.0.jar $REMOTE_DOCKER_COMPOSE_DIR/
# 构建镜像 docker build -t ${PROJECT_NAME}/${SERVICE_NAME}:latest -f Dockerfile.${SERVICE_NAME} .
# 清理临时文件
rm -f ${SERVICE_NAME}-1.0.0.jar "
# 启动容器
log_info "启动新容器"
ssh 'root@47.111.10.27' "
docker run -d \\
--name ${SERVICE_NAME} \\
--network emotion-network \\
-p ${SERVICE_PORT}:${SERVICE_PORT} \\
-v /data/logs/emotion-museum:/app/logs \\
-e SPRING_PROFILES_ACTIVE=${PROFILE} \\
-e MYSQL_HOST=47.111.10.27 \\
-e MYSQL_PORT=3306 \\
-e MYSQL_DATABASE=emotion_museum \\
-e MYSQL_USERNAME=root \\
-e MYSQL_PASSWORD='EmotionMuseum2025*#' \\
-e REDIS_HOST=47.111.10.27 \\
-e REDIS_PORT=6379 \\
-e REDIS_PASSWORD= \\
-e REDIS_DATABASE=0 \\
-e NACOS_SERVER_ADDR=47.111.10.27:8848 \\
-e NACOS_USERNAME=nacos \\
-e NACOS_PASSWORD='Peanut2817*#' \\
--restart unless-stopped \\
${PROJECT_NAME}/${SERVICE_NAME}:latest
"
# 等待启动
log_info "等待服务启动..."
sleep 15
# 检查状态
if ssh 'root@47.111.10.27' "docker ps | grep ${SERVICE_NAME}" > /dev/null; then
log_success "服务启动成功"
# 显示日志
log_info "服务日志 最后20行:"
ssh 'root@47.111.10.27' "docker logs --tail 20 ${SERVICE_NAME}"
# 健康检查
log_info "执行健康检查..."
sleep 10
if ssh 'root@47.111.10.27' "curl -f -s http://localhost:${SERVICE_PORT}/actuator/health" > /dev/null 2>&1; then
log_success "健康检查通过"
else
log_warning "健康检查失败,服务可能仍在启动中"
fi
else
log_error "服务启动失败"
ssh 'root@47.111.10.27' "docker logs ${SERVICE_NAME}"
exit 1
fi
}
# 主函数
main() {
log_info "开始部署 $SERVICE_NAME 服务"
log_info "目标服务器: $REMOTE_HOST"
log_info "服务端口: $SERVICE_PORT"
log_info "部署环境: $PROFILE"
check_remote_connection
build_service
deploy_service
log_success "$SERVICE_NAME 服务部署完成!"
log_info "访问地址: http://47.111.10.27:$SERVICE_PORT"
}
# 执行主函数
main "$@"
+144
View File
@@ -0,0 +1,144 @@
<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.emotionmuseum</groupId>
<artifactId>backend</artifactId>
<version>1.0.0</version>
<relativePath>../pom.xml</relativePath>
</parent>
<artifactId>emotion-auth</artifactId>
<name>emotion-auth</name>
<description>情感博物馆认证授权服务</description>
<dependencies>
<!-- 公共模块 -->
<dependency>
<groupId>com.emotionmuseum</groupId>
<artifactId>emotion-common</artifactId>
<version>1.0.0</version>
</dependency>
<!-- Spring Boot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- Spring Data JPA -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- Spring Data Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- MySQL驱动 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<!-- MyBatis Plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
</dependency>
<!-- JWT -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
</dependency>
<!-- 第三方登录 -->
<dependency>
<groupId>me.zhyd.oauth</groupId>
<artifactId>JustAuth</artifactId>
<version>1.16.5</version>
</dependency>
<!-- 验证码 -->
<dependency>
<groupId>com.github.whvcse</groupId>
<artifactId>easy-captcha</artifactId>
<version>1.6.2</version>
</dependency>
<!-- Nacos服务发现 -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<!-- Nacos配置中心 -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
<!-- 监控 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- 测试 -->
<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>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>${spring-boot.version}</version>
<configuration>
<mainClass>com.emotionmuseum.auth.AuthApplication</mainClass>
</configuration>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -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,59 @@
package com.emotionmuseum.auth.config;
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 {
/**
* 算术验证码 - 暂时禁用,因为Java 23中JavaScript引擎问题
*/
// @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,85 @@
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.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* 第三方登录配置
*
* @author emotion-museum
* @since 2025-07-15
*/
@Configuration
@ConditionalOnProperty(name = "oauth.enabled", havingValue = "true", matchIfMissing = false)
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
@ConditionalOnProperty(name = "oauth.wechat.client-id", matchIfMissing = false)
public AuthWeChatOpenRequest weChatOpenRequest() {
return new AuthWeChatOpenRequest(AuthConfig.builder()
.clientId(wechatClientId)
.clientSecret(wechatClientSecret)
.redirectUri(wechatRedirectUri)
.build());
}
/**
* 微信公众平台登录
*/
@Bean
@ConditionalOnProperty(name = "oauth.wechat-mp.client-id", matchIfMissing = false)
public AuthWeChatMpRequest weChatMpRequest() {
return new AuthWeChatMpRequest(AuthConfig.builder()
.clientId(wechatMpClientId)
.clientSecret(wechatMpClientSecret)
.redirectUri(wechatMpRedirectUri)
.build());
}
/**
* QQ登录
*/
@Bean
@ConditionalOnProperty(name = "oauth.qq.client-id", matchIfMissing = false)
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();
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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;
}
@@ -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);
}
@@ -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");
}
}
@@ -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);
}
@@ -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);
}
@@ -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;
}
}
}
@@ -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";
}
}
}
@@ -0,0 +1,101 @@
package com.emotionmuseum.auth.service.impl;
import com.emotionmuseum.auth.dto.OAuthLoginRequest;
import com.emotionmuseum.auth.service.OAuthService;
import com.emotionmuseum.auth.vo.LoginResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
/**
* 第三方登录服务实现类
*
* @author emotion-museum
* @since 2025-07-15
*/
@Slf4j
@Service
public class OAuthServiceImpl implements OAuthService {
@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;
@Override
public String getAuthUrl(String platform) {
log.info("获取第三方登录授权URL, platform: {}", platform);
switch (platform.toLowerCase()) {
case "wechat":
return buildWechatAuthUrl();
case "qq":
return buildQQAuthUrl();
default:
throw new IllegalArgumentException("不支持的第三方平台: " + platform);
}
}
@Override
public LoginResponse oauthLogin(OAuthLoginRequest request) {
log.info("第三方登录, platform: {}, code: {}", request.getPlatform(), request.getCode());
// TODO: 实现第三方登录逻辑
// 1. 根据code获取access_token
// 2. 根据access_token获取用户信息
// 3. 查询或创建用户
// 4. 生成JWT token
throw new UnsupportedOperationException("第三方登录功能暂未实现");
}
@Override
public Object getOAuthUserInfo(String platform, String code, String state) {
log.info("获取第三方用户信息, platform: {}, code: {}, state: {}", platform, code, state);
// TODO: 实现获取第三方用户信息逻辑
throw new UnsupportedOperationException("获取第三方用户信息功能暂未实现");
}
/**
* 构建微信授权URL
*/
private String buildWechatAuthUrl() {
if (wechatClientId.isEmpty()) {
throw new IllegalStateException("微信OAuth配置未完成");
}
// TODO: 构建微信授权URL
return "https://open.weixin.qq.com/connect/oauth2/authorize?appid=" + wechatClientId +
"&redirect_uri=" + wechatRedirectUri +
"&response_type=code&scope=snsapi_userinfo&state=wechat#wechat_redirect";
}
/**
* 构建QQ授权URL
*/
private String buildQQAuthUrl() {
if (qqClientId.isEmpty()) {
throw new IllegalStateException("QQ OAuth配置未完成");
}
// TODO: 构建QQ授权URL
return "https://graph.qq.com/oauth2.0/authorize?response_type=code&client_id=" + qqClientId +
"&redirect_uri=" + qqRedirectUri +
"&state=qq&scope=get_user_info";
}
}
@@ -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;
}
}
@@ -0,0 +1,103 @@
server:
port: 19008
spring:
# 数据源配置
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/emotion_museum?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: root
password: 123456
# 连接池配置
hikari:
minimum-idle: 5
maximum-pool-size: 20
idle-timeout: 300000
connection-timeout: 20000
max-lifetime: 1200000
pool-name: EmotionAuthHikariCP
# Redis配置
data:
redis:
host: localhost
port: 6379
password:
database: 0
timeout: 5000ms
lettuce:
pool:
max-active: 20
max-idle: 10
min-idle: 5
max-wait: 2000ms
# 云服务配置
cloud:
nacos:
discovery:
server-addr: localhost:8848
username: nacos
password: Peanut2817*#
config:
server-addr: localhost:8848
username: nacos
password: Peanut2817*#
# MyBatis Plus配置
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config:
db-config:
id-type: assign_uuid
logic-delete-field: deleted
logic-delete-value: 1
logic-not-delete-value: 0
# 监控配置
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
endpoint:
health:
show-details: always
metrics:
export:
prometheus:
enabled: true
# 日志配置
logging:
level:
com.emotionmuseum: debug
com.baomidou.mybatisplus: debug
pattern:
console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level [%logger{50}] - %msg%n"
# JWT配置
jwt:
secret: emotion-museum-secret-key-2025
expiration: 86400
refresh-expiration: 604800
# 验证码配置
captcha:
type: arithmetic
length: 4
expire-time: 300
# OAuth配置
oauth:
wechat:
client-id:
client-secret:
redirect-uri:
qq:
client-id:
client-secret:
redirect-uri:
@@ -0,0 +1,104 @@
server:
port: 19008
spring:
# 数据源配置
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://${MYSQL_HOST}:${MYSQL_PORT}/${MYSQL_DATABASE}?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: ${MYSQL_USERNAME}
password: EmotionMuseum2025*#
# 连接池配置
hikari:
minimum-idle: 10
maximum-pool-size: 50
idle-timeout: 300000
connection-timeout: 20000
max-lifetime: 1200000
pool-name: EmotionAuthHikariCP
# Redis配置
data:
redis:
host: ${REDIS_HOST}
port: ${REDIS_PORT}
password: ${REDIS_PASSWORD}
database: ${REDIS_DATABASE}
timeout: 5000ms
lettuce:
pool:
max-active: 50
max-idle: 20
min-idle: 10
max-wait: 2000ms
# 云服务配置
cloud:
nacos:
discovery:
server-addr: ${NACOS_SERVER_ADDR}
username: ${NACOS_USERNAME}
password: EmotionMuseum2025*#
config:
server-addr: ${NACOS_SERVER_ADDR}
username: ${NACOS_USERNAME}
password: EmotionMuseum2025*#
# MyBatis Plus配置
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.nologging.NoLoggingImpl
global-config:
db-config:
id-type: assign_uuid
logic-delete-field: deleted
logic-delete-value: 1
logic-not-delete-value: 0
# 监控配置
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
endpoint:
health:
show-details: when-authorized
metrics:
export:
prometheus:
enabled: true
# 日志配置
logging:
level:
com.emotionmuseum: info
com.baomidou.mybatisplus: warn
root: warn
pattern:
console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level [%logger{50}] - %msg%n"
# JWT配置
jwt:
secret: ${JWT_SECRET}
expiration: ${JWT_EXPIRATION:86400}
refresh-expiration: ${JWT_REFRESH_EXPIRATION:604800}
# 验证码配置
captcha:
type: arithmetic
length: 4
expire-time: 300
# OAuth配置
oauth:
wechat:
client-id: ${WECHAT_CLIENT_ID}
client-secret: ${WECHAT_CLIENT_SECRET}
redirect-uri: ${WECHAT_REDIRECT_URI}
qq:
client-id: ${QQ_CLIENT_ID}
client-secret: ${QQ_CLIENT_SECRET}
redirect-uri: ${QQ_REDIRECT_URI}
@@ -0,0 +1,103 @@
server:
port: 19008
spring:
# 数据源配置
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://${MYSQL_HOST}:${MYSQL_PORT}/${MYSQL_DATABASE}?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: ${MYSQL_USERNAME}
password: EmotionMuseum2025*#
# 连接池配置
hikari:
minimum-idle: 5
maximum-pool-size: 20
idle-timeout: 300000
connection-timeout: 20000
max-lifetime: 1200000
pool-name: EmotionAuthHikariCP
# Redis配置
data:
redis:
host: ${REDIS_HOST}
port: ${REDIS_PORT}
password: ${REDIS_PASSWORD}
database: ${REDIS_DATABASE}
timeout: 5000ms
lettuce:
pool:
max-active: 20
max-idle: 10
min-idle: 5
max-wait: 2000ms
# 云服务配置
cloud:
nacos:
discovery:
server-addr: ${NACOS_SERVER_ADDR}
username: ${NACOS_USERNAME}
password: EmotionMuseum2025*#
config:
server-addr: ${NACOS_SERVER_ADDR}
username: ${NACOS_USERNAME}
password: EmotionMuseum2025*#
# MyBatis Plus配置
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config:
db-config:
id-type: assign_uuid
logic-delete-field: deleted
logic-delete-value: 1
logic-not-delete-value: 0
# 监控配置
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
endpoint:
health:
show-details: always
metrics:
export:
prometheus:
enabled: true
# 日志配置
logging:
level:
com.emotionmuseum: info
com.baomidou.mybatisplus: info
pattern:
console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level [%logger{50}] - %msg%n"
# JWT配置
jwt:
secret: ${JWT_SECRET:emotion-museum-secret-key-2025}
expiration: ${JWT_EXPIRATION:86400}
refresh-expiration: ${JWT_REFRESH_EXPIRATION:604800}
# 验证码配置
captcha:
type: arithmetic
length: 4
expire-time: 300
# OAuth配置
oauth:
wechat:
client-id: ${WECHAT_CLIENT_ID}
client-secret: ${WECHAT_CLIENT_SECRET}
redirect-uri: ${WECHAT_REDIRECT_URI}
qq:
client-id: ${QQ_CLIENT_ID}
client-secret: ${QQ_CLIENT_SECRET}
redirect-uri: ${QQ_REDIRECT_URI}
@@ -0,0 +1,23 @@
spring:
application:
name: emotion-auth
profiles:
active: ${SPRING_PROFILES_ACTIVE:local}
main:
allow-bean-definition-overriding: true
cloud:
nacos:
config:
enabled: false
discovery:
enabled: true
server-addr: 47.111.10.27:8848
username: nacos
password: Peanut2817*#
logging:
file:
path: /data/logs/emotion-museum/auth
@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.emotionmuseum.auth.mapper.UserMapper">
<!-- 根据账号查询用户 -->
<select id="selectByAccount" resultType="com.emotionmuseum.auth.entity.User">
SELECT * FROM user
WHERE account = #{account} AND is_deleted = 0
</select>
<!-- 根据邮箱查询用户 -->
<select id="selectByEmail" resultType="com.emotionmuseum.auth.entity.User">
SELECT * FROM user
WHERE email = #{email} AND is_deleted = 0
</select>
<!-- 根据手机号查询用户 -->
<select id="selectByPhone" resultType="com.emotionmuseum.auth.entity.User">
SELECT * FROM user
WHERE phone = #{phone} AND is_deleted = 0
</select>
<!-- 根据第三方登录信息查询用户 -->
<select id="selectByOAuth" resultType="com.emotionmuseum.auth.entity.User">
SELECT * FROM user
WHERE oauth_platform = #{platform} AND oauth_id = #{oauthId} AND is_deleted = 0
</select>
<!-- 更新最后活跃时间 -->
<update id="updateLastActiveTime">
UPDATE user
SET last_active_time = NOW(), update_time = NOW()
WHERE id = #{userId} AND is_deleted = 0
</update>
</mapper>