refactor: 重命名 backend-single 目录为 server

This commit is contained in:
2026-06-27 17:23:53 +08:00
parent 8daf51301a
commit 0d77d76858
464 changed files with 0 additions and 0 deletions
@@ -0,0 +1,45 @@
package com.emotion.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
/**
* 多线程异步任务线程池配置
*/
@Configuration
@EnableAsync
public class AsyncConfig {
@Value("${async.core-pool-size:10}")
private int corePoolSize;
@Value("${async.max-pool-size:50}")
private int maxPoolSize;
@Value("${async.queue-capacity:200}")
private int queueCapacity;
@Value("${async.keep-alive-seconds:60}")
private int keepAliveSeconds;
@Value("${async.thread-name-prefix:single-async-}")
private String threadNamePrefix;
@Bean(name = "taskExecutor")
public Executor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(corePoolSize);
executor.setMaxPoolSize(maxPoolSize);
executor.setQueueCapacity(queueCapacity);
executor.setKeepAliveSeconds(keepAliveSeconds);
executor.setThreadNamePrefix(threadNamePrefix);
executor.setRejectedExecutionHandler(new java.util.concurrent.ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
}
@@ -0,0 +1,91 @@
package com.emotion.config;
import com.emotion.util.SnowflakeIdGenerator;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.net.InetAddress;
import java.net.NetworkInterface;
/**
* ID生成器配置类
*
* @author huazhongmin
* @date 2025-07-23
*/
@Slf4j
@Configuration
public class IdGeneratorConfig {
/**
* 机器ID配置(可通过配置文件指定)
*/
@Value("${emotion.snowflake.machine-id:#{null}}")
private Long machineId;
/**
* 配置雪花算法ID生成器
*
* @return SnowflakeIdGenerator实例
*/
@Bean
public SnowflakeIdGenerator snowflakeIdGenerator() {
long finalMachineId;
if (machineId != null) {
// 使用配置文件中指定的机器ID
finalMachineId = machineId;
log.info("使用配置文件指定的机器ID: {}", finalMachineId);
} else {
// 自动生成机器ID
finalMachineId = generateMachineId();
log.info("自动生成机器ID: {}", finalMachineId);
}
return new SnowflakeIdGenerator(finalMachineId);
}
/**
* 自动生成机器ID
* 基于MAC地址和IP地址生成唯一的机器ID
*
* @return 机器ID (0-1023)
*/
private long generateMachineId() {
try {
// 获取本机IP地址
InetAddress localHost = InetAddress.getLocalHost();
// 获取网络接口
NetworkInterface networkInterface = NetworkInterface.getByInetAddress(localHost);
if (networkInterface != null) {
// 获取MAC地址
byte[] hardwareAddress = networkInterface.getHardwareAddress();
if (hardwareAddress != null && hardwareAddress.length >= 6) {
// 使用MAC地址的后两个字节生成机器ID
long machineId = ((hardwareAddress[4] & 0xFF) << 8) | (hardwareAddress[5] & 0xFF);
// 确保机器ID在有效范围内 (0-1023)
return machineId & 0x3FF;
}
}
// 如果无法获取MAC地址,使用IP地址生成
byte[] address = localHost.getAddress();
if (address.length >= 4) {
// 使用IP地址的后两个字节生成机器ID
long machineId = ((address[2] & 0xFF) << 8) | (address[3] & 0xFF);
return machineId & 0x3FF;
}
} catch (Exception e) {
log.warn("自动生成机器ID失败,使用默认策略: {}", e.getMessage());
}
// 如果所有方法都失败,使用当前时间戳生成
return System.currentTimeMillis() % 1024;
}
}
@@ -0,0 +1,26 @@
package com.emotion.config;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Contact;
import io.swagger.v3.oas.models.info.Info;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Knife4j / OpenAPI 文档配置
*/
@Configuration
public class Knife4jConfig {
@Bean
public OpenAPI customOpenAPI() {
return new OpenAPI()
.info(new Info()
.title("情绪博物馆 API 文档")
.version("1.0.0")
.description("情绪博物馆后端接口文档")
.contact(new Contact()
.name("emotion-museum")
.email("")));
}
}
@@ -0,0 +1,28 @@
package com.emotion.config;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* MyBatis-Plus配置类
*
* @author huazhongmin
* @date 2025-07-23
*/
@Configuration
public class MybatisPlusConfig {
/**
* 分页插件配置
*/
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 添加分页插件
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
}
@@ -0,0 +1,40 @@
package com.emotion.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 huazhongmin
* @date 2025-07-23
*/
@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,52 @@
package com.emotion.config;
import com.emotion.interceptor.WechatApiLoggingInterceptor;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.BufferingClientHttpRequestFactory;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.http.converter.StringHttpMessageConverter;
import org.springframework.web.client.RestTemplate;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
/**
* 统一 RestTemplate Bean
*
* <p>设计要点:
* <ol>
* <li>用 BufferingClientHttpRequestFactory 包装 SimpleClientHttpRequestFactory
* 响应体被缓存到字节数组,可被 Interceptor / ErrorHandler / Converter 多次读取
* (避免破坏其他 7 个 RestTemplate 使用点:DifyProviderAdapter、
* CozeProviderAdapter、HttpTtsEngineClient、AsrServiceImpl、
* ApiEndpointServiceImpl、AiChatServiceImpl、ApiTestProxyController</li>
* <li>显式设置 User-Agent 为 Mozilla/5.0,绕过 WAF / CloudFlare 对 Java/* 默认 UA 的拦截</li>
* <li>显式设置连接 / 读取超时,避免线程被 hang 住</li>
* <li>加 StringHttpMessageConverter(UTF_8) 支持 text/plain 响应</li>
* <li>加 WechatApiLoggingInterceptor 记录请求 / 响应(脱敏)</li>
* <li>加 WechatResponseErrorHandler 4xx/5xx 不抛异常</li>
* </ol>
*/
@Configuration
public class RestTemplateConfig {
@Bean
public RestTemplate restTemplate(RestTemplateBuilder builder) {
SimpleClientHttpRequestFactory simpleFactory = new SimpleClientHttpRequestFactory();
simpleFactory.setConnectTimeout((int) Duration.ofSeconds(5).toMillis());
simpleFactory.setReadTimeout((int) Duration.ofSeconds(10).toMillis());
BufferingClientHttpRequestFactory bufferingFactory =
new BufferingClientHttpRequestFactory(simpleFactory);
return builder
.requestFactory(() -> bufferingFactory)
.defaultHeader("User-Agent", "Mozilla/5.0 (compatible; EmotionMuseum/1.0)")
.defaultHeader("Accept", "application/json, text/plain, */*")
.additionalMessageConverters(new StringHttpMessageConverter(StandardCharsets.UTF_8))
.additionalInterceptors(new WechatApiLoggingInterceptor())
.errorHandler(new WechatResponseErrorHandler())
.build();
}
}
@@ -0,0 +1,92 @@
package com.emotion.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
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.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
/**
* 安全配置
*
* @author huazhongmin
* @date 2025-07-22
*/
@Configuration
@EnableWebSecurity
public class SecurityConfig {
/**
* 密码编码器
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* 安全过滤器链
*/
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// 禁用CSRF
.csrf(csrf -> csrf.disable())
// 配置CORS
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
// 配置会话管理
.sessionManagement(management -> management
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// 配置授权规则
.authorizeHttpRequests(authz -> authz
// 允许所有请求(简化配置)
.anyRequest().permitAll())
// 禁用默认登录页面
.formLogin(login -> login.disable())
// 禁用HTTP Basic认证
.httpBasic(basic -> basic.disable());
return http.build();
}
/**
* CORS配置
*/
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
// 允许所有来源
configuration.setAllowedOriginPatterns(Arrays.asList("*"));
// 允许的HTTP方法
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"));
// 允许的请求头
configuration.setAllowedHeaders(Arrays.asList("*"));
// 允许携带凭证
configuration.setAllowCredentials(true);
// 预检请求的缓存时间
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}
@@ -0,0 +1,96 @@
package com.emotion.config;
import com.emotion.interceptor.AuthInterceptor;
import com.emotion.interceptor.AdminAuthInterceptor;
import com.emotion.interceptor.UserContextInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* Web配置类
* 配置拦截器、跨域等
*
* @author huazhongmin
* @date 2025-07-23
*/
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Autowired
private AuthInterceptor authInterceptor;
@Autowired
private AdminAuthInterceptor adminAuthInterceptor;
@Autowired
private UserContextInterceptor userContextInterceptor;
/**
* 添加拦截器
*/
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 管理员认证拦截器 - 优先级最高
registry.addInterceptor(adminAuthInterceptor)
.addPathPatterns("/admin/**")
.order(1);
// 用户认证拦截器
registry.addInterceptor(authInterceptor)
.addPathPatterns("/**")
.excludePathPatterns(
"/auth/**",
"/analytics/events/batch",
"/tts/audio/**",
"/admin/**", // 排除管理员接口,由AdminAuthInterceptor处理
"/error",
"/analytics/events/batch",
"/tts/audio/**",
"/favicon.ico",
"/actuator/**",
"/swagger-ui/**",
"/swagger-resources/**",
"/v2/api-docs",
"/v3/api-docs",
"/webjars/**",
"/doc.html",
"/static/**",
"/public/**"
)
.order(2);
// 用户上下文拦截器
registry.addInterceptor(userContextInterceptor)
.addPathPatterns("/**")
.excludePathPatterns(
"/error",
"/favicon.ico",
"/actuator/**",
"/swagger-ui/**",
"/swagger-resources/**",
"/v2/api-docs",
"/v3/api-docs",
"/webjars/**",
"/doc.html",
"/static/**",
"/public/**"
)
.order(3);
}
/**
* 跨域配置
*/
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOriginPatterns("*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}
@@ -0,0 +1,68 @@
package com.emotion.config;
import com.emotion.interceptor.AdminAuthInterceptor;
import com.emotion.interceptor.JwtAuthInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* Web MVC配置
*
* @author huazhongmin
* @date 2025-07-23
*/
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Autowired
private JwtAuthInterceptor jwtAuthInterceptor;
@Autowired
private AdminAuthInterceptor adminAuthInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 管理员拦截器 - 优先级最高,只拦截 /admin/** 路径
registry.addInterceptor(adminAuthInterceptor)
.addPathPatterns("/admin/**")
.excludePathPatterns(
"/admin/auth/login", // 管理员登录接口
"/admin/auth/refreshToken" // 管理员刷新token接口
)
.order(1); // 优先级1,最先执行
// 普通用户拦截器 - 拦截除管理员路径外的所有请求
// 注意: 由于 context-path=/api, excludePathPatterns 需要包含 /api 前缀的路径
registry.addInterceptor(jwtAuthInterceptor)
.addPathPatterns("/**")
.excludePathPatterns(
"/auth/login", // 登录接口
"/auth/register", // 注册接口
"/auth/captcha", // 图形验证码接口
"/auth/sms-code", // 短信验证码接口(免登录)
"/auth/refresh-token", // 刷新token接口
"/auth/resetPassword", // 重置密码接口(免登录)
"/analytics/events/batch", // Analytics event batch endpoint
"/tts/audio/**", // Public generated TTS audio files
"/health", // 健康检查接口
"/ws/**", // WebSocket接口
"/swagger-ui", // Swagger UI
"/swagger-ui/**", // Swagger UI sub-paths
"/v3/api-docs", // API docs root
"/v3/api-docs/**", // API docs sub-paths
"/doc.html", // Knife4j entry
"/webjars", // Knife4j static resources
"/webjars/**", // Knife4j static resources sub-paths
"/swagger-resources", // Swagger resources root
"/swagger-resources/**", // Swagger resources sub-paths
"/actuator/**", // Actuator endpoints
"/admin/**", // 排除管理员路径,由管理员拦截器处理
"/auth/wechat/login", // WeChat mini program login endpoint
"/auth/loginConfig", // 登录方式配置接口(免登录)
"/error" // Spring Boot error page
)
.order(2); // 优先级2
}
}
@@ -0,0 +1,54 @@
package com.emotion.config;
import com.emotion.interceptor.WebSocketAuthInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.ChannelRegistration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
/**
* WebSocket配置
*
* @author huazhongmin
* @date 2025-07-22
*/
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Autowired
private WebSocketAuthInterceptor webSocketAuthInterceptor;
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
// 启用简单消息代理,并设置消息代理的前缀
config.enableSimpleBroker("/topic", "/queue", "/user");
// 设置应用程序的目标前缀
config.setApplicationDestinationPrefixes("/app");
// 设置用户目标前缀
config.setUserDestinationPrefix("/user");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
// 注册STOMP端点
registry.addEndpoint("/ws/chat")
.setAllowedOriginPatterns("*")
.withSockJS();
// 注册普通WebSocket端点
registry.addEndpoint("/ws/chat")
.setAllowedOriginPatterns("*");
}
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
// 添加WebSocket认证拦截器
registration.interceptors(webSocketAuthInterceptor);
}
}
@@ -0,0 +1,17 @@
package com.emotion.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
@Data
@Configuration
@ConfigurationProperties(prefix = "emotion.wechat.mini-program")
public class WechatMiniProgramProperties {
private String appId;
private String appSecret;
private String baseUrl = "https://api.weixin.qq.com";
}
@@ -0,0 +1,35 @@
package com.emotion.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.web.client.DefaultResponseErrorHandler;
import java.io.IOException;
/**
* 微信 API 错误处理器
*
* <p>设计要点:
* <ol>
* <li>显式重写 hasError() 委托父类判断(4xx/5xx → true),让 handleError() 在错误时被调用</li>
* <li>handleError() 不抛 RestClientException,由业务层(WechatMiniProgramServiceImpl
* 根据 status code 判断如何处理</li>
* <li>不在此处读 body(避免流消费,由 LoggingInterceptor 统一处理日志)</li>
* </ol>
*/
@Slf4j
public class WechatResponseErrorHandler extends DefaultResponseErrorHandler {
@Override
public boolean hasError(ClientHttpResponse response) throws IOException {
// 委托父类 DefaultResponseErrorHandler.hasError()4xx/5xx 返回 true,其他返回 false
return super.hasError(response);
}
@Override
public void handleError(ClientHttpResponse response) throws IOException {
// 不抛异常;body 已在 LoggingInterceptor 中读取并脱敏记录
// 业务层会通过 response.getStatusCode() 判断
log.warn("[WeChatAPI] HTTP error response, status={}", response.getStatusCode());
}
}