demo
This commit is contained in:
lxy
2025-08-25 16:01:26 +08:00
commit 5a671c4233
93 changed files with 26892 additions and 0 deletions

View File

@ -0,0 +1,13 @@
package com.lxy.hsend;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class HsEndApplication {
public static void main(String[] args) {
SpringApplication.run(HsEndApplication.class, args);
}
}

View File

@ -0,0 +1,71 @@
package com.lxy.hsend.annotation;
import java.lang.annotation.*;
import java.util.concurrent.TimeUnit;
/**
* 限流注解
* 用于对接口进行访问频率限制
*
* @author lxy
*/
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RateLimit {
/**
* 限流key的前缀默认为方法的全限定名
*/
String key() default "";
/**
* 时间窗口内允许的最大请求次数
*/
int count() default 100;
/**
* 时间窗口大小
*/
int period() default 60;
/**
* 时间单位
*/
TimeUnit timeUnit() default TimeUnit.SECONDS;
/**
* 限流类型
*/
LimitType limitType() default LimitType.IP;
/**
* 超出限制时的提示信息
*/
String message() default "访问频率过高,请稍后再试";
/**
* 限流类型枚举
*/
enum LimitType {
/**
* 根据IP地址限流
*/
IP,
/**
* 根据用户ID限流
*/
USER,
/**
* 根据自定义key限流
*/
CUSTOM,
/**
* 全局限流
*/
GLOBAL
}
}

View File

@ -0,0 +1,90 @@
package com.lxy.hsend.annotation;
import java.lang.annotation.*;
/**
* 请求日志注解
* 用于标记需要记录请求日志的接口
*
* @author lxy
*/
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RequestLog {
/**
* 操作描述
*/
String value() default "";
/**
* 是否记录请求参数
*/
boolean logParams() default true;
/**
* 是否记录响应结果
*/
boolean logResult() default true;
/**
* 是否记录执行时间
*/
boolean logTime() default true;
/**
* 操作类型
*/
OperationType type() default OperationType.OTHER;
/**
* 操作类型枚举
*/
enum OperationType {
/**
* 查询操作
*/
QUERY,
/**
* 新增操作
*/
CREATE,
/**
* 更新操作
*/
UPDATE,
/**
* 删除操作
*/
DELETE,
/**
* 登录操作
*/
LOGIN,
/**
* 登出操作
*/
LOGOUT,
/**
* 文件上传
*/
UPLOAD,
/**
* 文件下载
*/
DOWNLOAD,
/**
* 其他操作
*/
OTHER
}
}

View File

@ -0,0 +1,25 @@
package com.lxy.hsend.annotation;
import java.lang.annotation.*;
/**
* 需要认证的注解
* 用于标记需要用户登录才能访问的接口
*
* @author lxy
*/
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RequireAuth {
/**
* 是否可选认证如果有token则验证没有token则不验证
*/
boolean optional() default false;
/**
* 描述信息
*/
String value() default "";
}

View File

@ -0,0 +1,25 @@
package com.lxy.hsend.annotation;
import com.lxy.hsend.annotation.validator.EmailValidator;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.*;
/**
* 邮箱格式验证注解
*
* @author lxy
*/
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = EmailValidator.class)
@Documented
public @interface ValidEmail {
String message() default "邮箱格式不正确";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}

View File

@ -0,0 +1,25 @@
package com.lxy.hsend.annotation;
import com.lxy.hsend.annotation.validator.PasswordValidator;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.*;
/**
* 密码格式验证注解
*
* @author lxy
*/
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PasswordValidator.class)
@Documented
public @interface ValidPassword {
String message() default "密码格式不正确密码长度应为6-20位包含字母和数字";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}

View File

@ -0,0 +1,29 @@
package com.lxy.hsend.annotation.validator;
import com.lxy.hsend.annotation.ValidEmail;
import com.lxy.hsend.util.StringUtil;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
/**
* 邮箱格式验证器
*
* @author lxy
*/
public class EmailValidator implements ConstraintValidator<ValidEmail, String> {
@Override
public void initialize(ValidEmail constraintAnnotation) {
// 初始化方法,可以获取注解参数
}
@Override
public boolean isValid(String email, ConstraintValidatorContext context) {
// 空值交给@NotNull等注解处理
if (email == null) {
return true;
}
return StringUtil.isValidEmail(email);
}
}

View File

@ -0,0 +1,29 @@
package com.lxy.hsend.annotation.validator;
import com.lxy.hsend.annotation.ValidPassword;
import com.lxy.hsend.util.StringUtil;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
/**
* 密码格式验证器
*
* @author lxy
*/
public class PasswordValidator implements ConstraintValidator<ValidPassword, String> {
@Override
public void initialize(ValidPassword constraintAnnotation) {
// 初始化方法,可以获取注解参数
}
@Override
public boolean isValid(String password, ConstraintValidatorContext context) {
// 空值交给@NotNull等注解处理
if (password == null) {
return true;
}
return StringUtil.isValidPassword(password);
}
}

View File

@ -0,0 +1,123 @@
package com.lxy.hsend.common;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
/**
* 统一API响应格式
*
* @author lxy
* @param <T> 响应数据类型
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ApiResponse<T> {
/**
* 错误码0表示成功
*/
private Integer error;
/**
* 响应数据
*/
private T body;
/**
* 响应消息
*/
private String message;
/**
* 是否成功
*/
private Boolean success;
/**
* 成功响应(无数据)
*/
public static <T> ApiResponse<T> success() {
return new ApiResponse<T>(0, null, "操作成功", true);
}
/**
* 成功响应(带数据)
*/
public static <T> ApiResponse<T> success(T data) {
return new ApiResponse<T>(0, data, "操作成功", true);
}
/**
* 成功响应(带数据和消息)
*/
public static <T> ApiResponse<T> success(T data, String message) {
return new ApiResponse<T>(0, data, message, true);
}
/**
* 失败响应
*/
public static <T> ApiResponse<T> error(Integer errorCode, String message) {
return new ApiResponse<T>(errorCode, null, message, false);
}
/**
* 失败响应(带数据)
*/
public static <T> ApiResponse<T> error(Integer errorCode, String message, T data) {
return new ApiResponse<T>(errorCode, data, message, false);
}
/**
* 参数验证错误
*/
public static <T> ApiResponse<T> validationError(String message) {
return new ApiResponse<T>(1001, null, message, false);
}
/**
* 认证失败
*/
public static <T> ApiResponse<T> authError(String message) {
return new ApiResponse<T>(1002, null, message, false);
}
/**
* 权限不足
*/
public static <T> ApiResponse<T> forbiddenError(String message) {
return new ApiResponse<T>(1003, null, message, false);
}
/**
* 资源不存在
*/
public static <T> ApiResponse<T> notFoundError(String message) {
return new ApiResponse<T>(1004, null, message, false);
}
/**
* 业务逻辑错误
*/
public static <T> ApiResponse<T> businessError(String message) {
return new ApiResponse<T>(2001, null, message, false);
}
/**
* 系统错误
*/
public static <T> ApiResponse<T> systemError(String message) {
return new ApiResponse<T>(5001, null, message, false);
}
/**
* 未知错误
*/
public static <T> ApiResponse<T> unknownError() {
return new ApiResponse<T>(5002, null, "系统出现未知错误", false);
}
}

View File

@ -0,0 +1,86 @@
package com.lxy.hsend.common;
/**
* 应用常量
*
* @author lxy
*/
public class Constants {
// JWT相关常量
public static final String JWT_SECRET_KEY = "hs-end-jwt-secret-key-2024-secure-256bit-hmac-sha256";
public static final String JWT_TOKEN_PREFIX = "Bearer ";
public static final String JWT_HEADER_NAME = "Authorization";
public static final long JWT_EXPIRATION_TIME = 7 * 24 * 60 * 60 * 1000L; // 7天
public static final long JWT_REFRESH_TIME = 30 * 24 * 60 * 60 * 1000L; // 30天
// Redis相关常量
public static final String REDIS_USER_SESSION_PREFIX = "user:session:";
public static final String REDIS_JWT_BLACKLIST_PREFIX = "jwt:blacklist:";
public static final String REDIS_USER_INFO_PREFIX = "user:info:";
public static final String REDIS_RATE_LIMIT_PREFIX = "rate:limit:";
public static final long REDIS_SESSION_EXPIRE_TIME = 7 * 24 * 60 * 60; // 7天
public static final long REDIS_USER_INFO_EXPIRE_TIME = 30 * 60; // 30分钟
// 分页相关常量
public static final int DEFAULT_PAGE_SIZE = 10;
public static final int MAX_PAGE_SIZE = 100;
public static final int DEFAULT_PAGE_NUM = 1;
// 文件上传相关常量
public static final long MAX_FILE_SIZE = 10 * 1024 * 1024L; // 10MB
public static final String[] ALLOWED_FILE_TYPES = {
"image/jpeg", "image/png", "image/gif", "image/webp",
"application/pdf", "text/plain", "text/markdown",
"application/msword", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
};
public static final String FILE_UPLOAD_PATH = "uploads/";
// 用户相关常量
public static final int PASSWORD_MIN_LENGTH = 6;
public static final int PASSWORD_MAX_LENGTH = 20;
public static final int NICKNAME_MIN_LENGTH = 1;
public static final int NICKNAME_MAX_LENGTH = 50;
// 会话相关常量
public static final int SESSION_TITLE_MAX_LENGTH = 100;
public static final int MESSAGE_CONTENT_MAX_LENGTH = 10000;
// API相关常量
public static final String API_PREFIX = "/api";
public static final String API_VERSION = "v1";
// 时间格式常量
public static final String DATE_FORMAT = "yyyy-MM-dd";
public static final String DATETIME_FORMAT = "yyyy-MM-dd HH:mm:ss";
public static final String TIMEZONE = "Asia/Shanghai";
// 请求限流常量
public static final int RATE_LIMIT_REQUESTS = 100; // 每分钟最大请求数
public static final int RATE_LIMIT_WINDOW = 60; // 时间窗口(秒)
// 邮箱验证正则
public static final String EMAIL_REGEX = "^[A-Za-z0-9+_.-]+@([A-Za-z0-9.-]+\\.[A-Za-z]{2,})$";
// 密码验证正则(至少包含字母和数字)
public static final String PASSWORD_REGEX = "^(?=.*[A-Za-z])(?=.*\\d)[A-Za-z\\d@$!%*#?&]{6,20}$";
// 用户状态
public static final byte USER_STATUS_NORMAL = 1;
public static final byte USER_STATUS_LOCKED = 2;
public static final byte USER_STATUS_DELETED = 3;
// 消息角色
public static final String MESSAGE_ROLE_USER = "user";
public static final String MESSAGE_ROLE_ASSISTANT = "assistant";
public static final String MESSAGE_ROLE_SYSTEM = "system";
// AI模型
public static final String AI_MODEL_GPT35 = "gpt-3.5-turbo";
public static final String AI_MODEL_GPT4 = "gpt-4";
public static final String AI_MODEL_CLAUDE = "claude-3";
private Constants() {
// 工具类不允许实例化
}
}

View File

@ -0,0 +1,74 @@
package com.lxy.hsend.common;
/**
* 错误码枚举
*
* @author lxy
*/
public enum ErrorCode {
// 成功
SUCCESS(0, "操作成功"),
// 客户端错误 1xxx
VALIDATION_ERROR(1001, "参数验证失败"),
AUTH_ERROR(1002, "认证失败"),
FORBIDDEN_ERROR(1003, "权限不足"),
NOT_FOUND_ERROR(1004, "资源不存在"),
METHOD_NOT_ALLOWED(1005, "请求方法不支持"),
RATE_LIMIT_ERROR(1006, "请求频率超限"),
// 业务逻辑错误 2xxx
BUSINESS_ERROR(2001, "业务逻辑错误"),
USER_NOT_FOUND(2002, "用户不存在"),
USER_ALREADY_EXISTS(2003, "用户已存在"),
RESOURCE_NOT_FOUND(2005, "资源不存在"),
PASSWORD_ERROR(2004, "密码错误"),
SESSION_NOT_FOUND(2005, "会话不存在"),
MESSAGE_NOT_FOUND(2006, "消息不存在"),
FILE_NOT_FOUND(2007, "文件不存在"),
FILE_UPLOAD_ERROR(2008, "文件上传失败"),
AI_SERVICE_ERROR(2009, "AI服务调用失败"),
// 第三方服务错误 3xxx
EXTERNAL_SERVICE_ERROR(3001, "第三方服务异常"),
AI_API_ERROR(3002, "AI接口调用失败"),
REDIS_ERROR(3003, "Redis服务异常"),
// 数据访问错误 4xxx
DATABASE_ERROR(4001, "数据库操作失败"),
DATA_INTEGRITY_ERROR(4002, "数据完整性约束违反"),
TRANSACTION_ERROR(4003, "事务处理失败"),
// 系统错误 5xxx
SYSTEM_ERROR(5001, "系统内部错误"),
UNKNOWN_ERROR(5002, "未知错误");
private final Integer code;
private final String message;
ErrorCode(Integer code, String message) {
this.code = code;
this.message = message;
}
public Integer getCode() {
return code;
}
public String getMessage() {
return message;
}
/**
* 根据错误码获取枚举
*/
public static ErrorCode getByCode(Integer code) {
for (ErrorCode errorCode : values()) {
if (errorCode.getCode().equals(code)) {
return errorCode;
}
}
return UNKNOWN_ERROR;
}
}

View File

@ -0,0 +1,159 @@
package com.lxy.hsend.config;
import com.lxy.hsend.common.ApiResponse;
import com.lxy.hsend.common.ErrorCode;
import com.lxy.hsend.util.JwtUtil;
import com.lxy.hsend.util.StringUtil;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;
/**
* JWT认证拦截器
*
* @author lxy
*/
@Slf4j
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final ObjectMapper objectMapper;
// 不需要认证的路径
private static final List<String> EXCLUDE_PATHS = Arrays.asList(
"/api/auth/",
"/api/health/",
"/swagger-ui/",
"/v3/api-docs/",
"/actuator/",
"/static/",
"/uploads/"
);
// 可选认证的路径如果有token则验证没有token也放行
private static final List<String> OPTIONAL_AUTH_PATHS = Arrays.asList(
"/api/user/\\d+" // 匹配 /api/user/{userId} 格式
);
@Autowired
public JwtAuthenticationFilter(JwtUtil jwtUtil, ObjectMapper objectMapper) {
this.jwtUtil = jwtUtil;
this.objectMapper = objectMapper;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String requestURI = request.getRequestURI();
String method = request.getMethod();
log.debug("JWT过滤器处理请求: {} {}", method, requestURI);
// 跳过OPTIONS请求
if ("OPTIONS".equals(method)) {
filterChain.doFilter(request, response);
return;
}
try {
// 检查是否在排除列表中
if (isExcludePath(requestURI)) {
log.debug("路径在排除列表中,跳过认证: {}", requestURI);
filterChain.doFilter(request, response);
return;
}
// 检查是否为可选认证路径
boolean isOptionalAuth = isOptionalAuthPath(requestURI);
// 获取Token
String authHeader = request.getHeader("Authorization");
String token = jwtUtil.extractTokenFromHeader(authHeader);
// 如果没有Token
if (StringUtil.isEmpty(token)) {
if (isOptionalAuth) {
log.debug("可选认证路径没有token继续执行: {}", requestURI);
filterChain.doFilter(request, response);
return;
} else {
log.warn("缺少认证Token: {}", requestURI);
writeErrorResponse(response, ErrorCode.AUTH_ERROR, "请提供有效的认证Token");
return;
}
}
// 验证Token
if (!jwtUtil.validateToken(token)) {
log.warn("Token验证失败: {}", requestURI);
writeErrorResponse(response, ErrorCode.AUTH_ERROR, "Token无效或已过期请重新登录");
return;
}
// Token验证通过设置用户信息到请求属性
Long userId = jwtUtil.getUserIdFromToken(token);
String email = jwtUtil.getEmailFromToken(token);
if (userId != null && email != null) {
request.setAttribute("currentUserId", userId);
request.setAttribute("currentUserEmail", email);
log.debug("JWT认证成功: userId={}, email={}, uri={}", userId, email, requestURI);
} else {
log.warn("Token中用户信息不完整: {}", requestURI);
writeErrorResponse(response, ErrorCode.AUTH_ERROR, "Token信息不完整");
return;
}
} catch (Exception e) {
log.error("JWT认证异常: {} - {}", requestURI, e.getMessage(), e);
writeErrorResponse(response, ErrorCode.SYSTEM_ERROR, "认证系统异常");
return;
}
filterChain.doFilter(request, response);
}
/**
* 检查路径是否在排除列表中
*/
private boolean isExcludePath(String requestURI) {
return EXCLUDE_PATHS.stream().anyMatch(requestURI::startsWith);
}
/**
* 检查是否为可选认证路径
*/
private boolean isOptionalAuthPath(String requestURI) {
return OPTIONAL_AUTH_PATHS.stream()
.anyMatch(pattern -> requestURI.matches("/api/user/\\d+"));
}
/**
* 写入错误响应
*/
private void writeErrorResponse(HttpServletResponse response, ErrorCode errorCode, String message) throws IOException {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
response.setHeader("Access-Control-Allow-Origin", "*");
response.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
response.setHeader("Access-Control-Allow-Headers", "*");
ApiResponse<Object> errorResponse = ApiResponse.error(errorCode.getCode(), message);
String json = objectMapper.writeValueAsString(errorResponse);
response.getWriter().write(json);
response.getWriter().flush();
}
}

View File

@ -0,0 +1,88 @@
package com.lxy.hsend.config;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
import com.lxy.hsend.common.Constants;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import java.time.Duration;
/**
* Redis配置类
* 配置Redis连接和序列化方式
*
* @author lxy
*/
@Configuration
@EnableCaching
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
// 创建ObjectMapper实例并配置
ObjectMapper mapper = new ObjectMapper();
mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
mapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL);
// 使用Jackson2JsonRedisSerializer来序列化和反序列化redis的value值
Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
jackson2JsonRedisSerializer.setObjectMapper(mapper);
StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
// key采用String的序列化方式
template.setKeySerializer(stringRedisSerializer);
// hash的key也采用String的序列化方式
template.setHashKeySerializer(stringRedisSerializer);
// value序列化方式采用jackson
template.setValueSerializer(jackson2JsonRedisSerializer);
// hash的value序列化方式采用jackson
template.setHashValueSerializer(jackson2JsonRedisSerializer);
template.afterPropertiesSet();
return template;
}
/**
* 缓存管理器配置
*/
@Bean
public CacheManager cacheManager(RedisConnectionFactory connectionFactory) {
// 创建ObjectMapper实例并配置
ObjectMapper mapper = new ObjectMapper();
mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
mapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL);
// JSON序列化配置
Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
jackson2JsonRedisSerializer.setObjectMapper(mapper);
// 配置序列化
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofSeconds(Constants.REDIS_USER_INFO_EXPIRE_TIME)) // 默认缓存时间30分钟
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(jackson2JsonRedisSerializer))
.disableCachingNullValues(); // 不缓存null值
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(config)
.transactionAware()
.build();
}
}

View File

@ -0,0 +1,70 @@
package com.lxy.hsend.config;
import org.springframework.beans.factory.annotation.Autowired;
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.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
/**
* Spring Security配置类
*
* @author lxy
*/
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
@Autowired
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// 禁用CSRF保护使用JWT不需要CSRF保护
.csrf(AbstractHttpConfigurer::disable)
// 禁用默认的Session管理使用无状态JWT
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// 禁用默认的登录表单
.formLogin(AbstractHttpConfigurer::disable)
// 禁用HTTP Basic认证
.httpBasic(AbstractHttpConfigurer::disable)
// 配置请求授权
.authorizeHttpRequests(authz -> authz
// 公开接口,无需认证
.requestMatchers("/api/health/**").permitAll()
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/user/{userId}").permitAll() // 公开的用户信息接口
// Swagger相关接口
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/swagger-resources/**").permitAll()
// Actuator监控接口
.requestMatchers("/actuator/**").permitAll()
// 静态资源
.requestMatchers("/static/**", "/uploads/**").permitAll()
// 其他所有请求需要认证
.anyRequest().authenticated()
)
// 添加JWT认证拦截器在Spring Security的UsernamePasswordAuthenticationFilter之前执行
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}

View File

@ -0,0 +1,42 @@
package com.lxy.hsend.config;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.info.Contact;
import io.swagger.v3.oas.models.info.License;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme;
import io.swagger.v3.oas.models.Components;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Swagger配置类
* 配置API文档生成
*/
@Configuration
public class SwaggerConfig {
@Bean
public OpenAPI customOpenAPI() {
return new OpenAPI()
.info(new Info()
.title("海角AI - API接口文档")
.version("1.0.0")
.description("海角AI后端服务API接口文档提供用户认证、聊天会话、消息处理、收藏管理、文件上传等功能。")
.contact(new Contact()
.name("海角AI团队")
.email("contact@haijiao.ai"))
.license(new License()
.name("MIT License")
.url("https://opensource.org/licenses/MIT")))
.components(new Components()
.addSecuritySchemes("bearerAuth",
new SecurityScheme()
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT")
.description("JWT认证令牌格式Bearer <token>")))
.addSecurityItem(new SecurityRequirement().addList("bearerAuth"));
}
}

View File

@ -0,0 +1,36 @@
package com.lxy.hsend.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* Web配置类
* 配置跨域访问等Web相关设置
*/
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Value("${cors.allowed-origins}")
private String allowedOrigins;
@Value("${cors.allowed-methods}")
private String allowedMethods;
@Value("${cors.allowed-headers}")
private String allowedHeaders;
@Value("${cors.allow-credentials}")
private boolean allowCredentials;
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOriginPatterns(allowedOrigins.split(","))
.allowedMethods(allowedMethods.split(","))
.allowedHeaders(allowedHeaders)
.allowCredentials(allowCredentials)
.maxAge(3600);
}
}

View File

@ -0,0 +1,95 @@
package com.lxy.hsend.config;
import com.lxy.hsend.common.Constants;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.*;
/**
* Web MVC配置
*
* @author lxy
*/
@Configuration
@EnableWebMvc
public class WebMvcConfig implements WebMvcConfigurer {
/**
* 配置CORS跨域
*/
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOriginPatterns("*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
/**
* 配置静态资源处理
*/
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
// 文件上传资源
registry.addResourceHandler("/uploads/**")
.addResourceLocations("file:" + Constants.FILE_UPLOAD_PATH);
// Swagger UI资源
registry.addResourceHandler("/swagger-ui/**")
.addResourceLocations("classpath:/META-INF/resources/webjars/springdoc-openapi-ui/");
// 静态资源
registry.addResourceHandler("/static/**")
.addResourceLocations("classpath:/static/");
}
/**
* 配置路径匹配
*/
@Override
public void configurePathMatch(PathMatchConfigurer configurer) {
// 设置路径匹配策略
configurer.setUseTrailingSlashMatch(true);
// 设置后缀模式匹配
configurer.setUseSuffixPatternMatch(false);
}
/**
* 配置内容协商
*/
@Override
public void configureContentNegotiation(ContentNegotiationConfigurer configurer) {
configurer
.defaultContentType(org.springframework.http.MediaType.APPLICATION_JSON)
.favorParameter(false)
.ignoreAcceptHeader(false);
}
/**
* 添加拦截器
*/
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 这里可以添加自定义拦截器
// 例如:认证拦截器、日志拦截器等
}
/**
* 配置视图解析器
*/
@Override
public void configureViewResolvers(ViewResolverRegistry registry) {
// 配置JSON视图解析器
registry.enableContentNegotiation();
}
/**
* 配置消息转换器
*/
@Override
public void configureMessageConverters(java.util.List<org.springframework.http.converter.HttpMessageConverter<?>> converters) {
// Spring Boot会自动配置Jackson消息转换器
WebMvcConfigurer.super.configureMessageConverters(converters);
}
}

View File

@ -0,0 +1,130 @@
package com.lxy.hsend.controller;
import com.lxy.hsend.annotation.RequestLog;
import com.lxy.hsend.annotation.RequireAuth;
import com.lxy.hsend.common.ApiResponse;
import com.lxy.hsend.dto.auth.LoginRequest;
import com.lxy.hsend.dto.auth.LoginResponse;
import com.lxy.hsend.dto.auth.RegisterRequest;
import com.lxy.hsend.dto.user.UserInfoResponse;
import com.lxy.hsend.service.UserService;
import com.lxy.hsend.util.JwtUtil;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
/**
* 认证控制器
*
* @author lxy
*/
@Slf4j
@RestController
@RequestMapping("/api/auth")
@Tag(name = "认证管理", description = "用户认证相关接口")
public class AuthController {
private final UserService userService;
private final JwtUtil jwtUtil;
@Autowired
public AuthController(UserService userService, JwtUtil jwtUtil) {
this.userService = userService;
this.jwtUtil = jwtUtil;
}
/**
* 用户注册
*/
@PostMapping("/register")
@Operation(summary = "用户注册", description = "通过邮箱和密码注册新用户")
@RequestLog(value = "用户注册", type = RequestLog.OperationType.CREATE)
public ApiResponse<UserInfoResponse> register(@Valid @RequestBody RegisterRequest request) {
UserInfoResponse userInfo = userService.register(request);
return ApiResponse.success(userInfo, "注册成功");
}
/**
* 用户登录
*/
@PostMapping("/login")
@Operation(summary = "用户登录", description = "通过邮箱和密码登录")
@RequestLog(value = "用户登录", type = RequestLog.OperationType.LOGIN)
public ApiResponse<LoginResponse> login(@Valid @RequestBody LoginRequest request) {
LoginResponse loginResponse = userService.login(request);
return ApiResponse.success(loginResponse, "登录成功");
}
/**
* 用户登出
*/
@PostMapping("/logout")
@RequireAuth
@Operation(summary = "用户登出", description = "用户登出将token加入黑名单")
@RequestLog(value = "用户登出", type = RequestLog.OperationType.LOGOUT)
public ApiResponse<Void> logout(HttpServletRequest request) {
// 从请求头获取token
String authHeader = request.getHeader("Authorization");
String token = jwtUtil.extractTokenFromHeader(authHeader);
if (token != null) {
// 从token获取用户ID
Long userId = jwtUtil.getUserIdFromToken(token);
if (userId != null) {
userService.logout(userId, token);
}
}
return ApiResponse.success(null, "登出成功");
}
/**
* 刷新Token
*/
@PostMapping("/refresh")
@Operation(summary = "刷新Token", description = "使用刷新Token获取新的访问Token")
@RequestLog(value = "刷新Token", type = RequestLog.OperationType.OTHER)
public ApiResponse<LoginResponse> refreshToken(@RequestParam String refreshToken) {
try {
// 验证刷新Token
if (!jwtUtil.validateToken(refreshToken)) {
return ApiResponse.authError("刷新Token无效");
}
String tokenType = jwtUtil.getTokenTypeFromToken(refreshToken);
if (!"refresh".equals(tokenType)) {
return ApiResponse.authError("Token类型错误");
}
// 生成新的访问Token
String newAccessToken = jwtUtil.refreshToken(refreshToken);
Long userId = jwtUtil.getUserIdFromToken(refreshToken);
// 获取用户信息
UserInfoResponse userInfo = userService.getUserById(userId);
LoginResponse loginResponse = new LoginResponse(newAccessToken, refreshToken,
com.lxy.hsend.common.Constants.JWT_EXPIRATION_TIME / 1000, userInfo);
return ApiResponse.success(loginResponse, "Token刷新成功");
} catch (Exception e) {
log.error("Token刷新失败: {}", e.getMessage());
return ApiResponse.authError("Token刷新失败");
}
}
/**
* 检查邮箱是否已存在
*/
@GetMapping("/check-email")
@Operation(summary = "检查邮箱", description = "检查邮箱是否已被注册")
public ApiResponse<Boolean> checkEmail(@RequestParam String email) {
boolean exists = userService.existsByEmail(email);
return ApiResponse.success(exists, exists ? "邮箱已存在" : "邮箱可用");
}
}

View File

@ -0,0 +1,128 @@
package com.lxy.hsend.controller;
import com.lxy.hsend.annotation.RequireAuth;
import com.lxy.hsend.annotation.RequestLog;
import com.lxy.hsend.annotation.RateLimit;
import com.lxy.hsend.common.ApiResponse;
import com.lxy.hsend.dto.favorite.AddFavoriteRequest;
import com.lxy.hsend.dto.favorite.FavoriteListRequest;
import com.lxy.hsend.dto.favorite.FavoriteResponse;
import com.lxy.hsend.dto.favorite.RemoveFavoriteRequest;
import com.lxy.hsend.service.FavoriteService;
import com.lxy.hsend.util.PageUtil;
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.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid;
/**
* 收藏控制器
*/
@Slf4j
@RestController
@RequestMapping("/api/favorites")
@RequiredArgsConstructor
@Tag(name = "收藏管理", description = "消息收藏相关接口")
public class FavoriteController {
private final FavoriteService favoriteService;
/**
* 添加收藏
*/
@PostMapping("/add")
@RequireAuth
@RequestLog
@RateLimit(count = 60, period = 60) // 每分钟最多60次收藏操作
@Operation(summary = "添加收藏", description = "将指定消息添加到收藏列表")
public ApiResponse<FavoriteResponse> addFavorite(
@AuthenticationPrincipal UserDetails userDetails,
@RequestBody @Valid AddFavoriteRequest request) {
log.info("用户添加收藏: user={}, messageId={}, sessionId={}",
userDetails.getUsername(), request.getMessageId(), request.getSessionId());
Long userId = Long.parseLong(userDetails.getUsername());
return favoriteService.addFavorite(userId, request);
}
/**
* 取消收藏
*/
@PostMapping("/remove")
@RequireAuth
@RequestLog
@RateLimit(count = 60, period = 60) // 每分钟最多60次取消收藏操作
@Operation(summary = "取消收藏", description = "从收藏列表中移除指定消息")
public ApiResponse<Void> removeFavorite(
@AuthenticationPrincipal UserDetails userDetails,
@RequestBody @Valid RemoveFavoriteRequest request) {
log.info("用户取消收藏: user={}, messageId={}",
userDetails.getUsername(), request.getMessageId());
Long userId = Long.parseLong(userDetails.getUsername());
return favoriteService.removeFavorite(userId, request);
}
/**
* 获取收藏列表
*/
@GetMapping("/list")
@RequireAuth
@RequestLog
@Operation(summary = "获取收藏列表", description = "分页获取用户的收藏消息列表,支持关键词搜索")
public ApiResponse<PageUtil.PageResponse<FavoriteResponse>> getFavoriteList(
@AuthenticationPrincipal UserDetails userDetails,
@Parameter(description = "搜索关键词") @RequestParam(required = false) String keyword,
@Parameter(description = "页码从1开始") @RequestParam(defaultValue = "1") Integer page,
@Parameter(description = "每页数量") @RequestParam(defaultValue = "20") Integer pageSize,
@Parameter(description = "消息角色过滤") @RequestParam(defaultValue = "all") String messageRole) {
log.info("获取收藏列表: user={}, keyword={}, page={}, pageSize={}, messageRole={}",
userDetails.getUsername(), keyword, page, pageSize, messageRole);
Long userId = Long.parseLong(userDetails.getUsername());
FavoriteListRequest request = new FavoriteListRequest(keyword, page, pageSize, messageRole);
return favoriteService.getFavoriteList(userId, request);
}
/**
* 检查消息是否被收藏
*/
@GetMapping("/check/{messageId}")
@RequireAuth
@RequestLog
@Operation(summary = "检查收藏状态", description = "检查指定消息是否已被当前用户收藏")
public ApiResponse<Boolean> checkFavoriteStatus(
@AuthenticationPrincipal UserDetails userDetails,
@Parameter(description = "消息ID") @PathVariable String messageId) {
log.info("检查收藏状态: user={}, messageId={}", userDetails.getUsername(), messageId);
Long userId = Long.parseLong(userDetails.getUsername());
return favoriteService.isFavorited(userId, messageId);
}
/**
* 获取用户收藏数量
*/
@GetMapping("/count")
@RequireAuth
@RequestLog
@Operation(summary = "获取收藏数量", description = "获取当前用户的总收藏数量")
public ApiResponse<Long> getFavoriteCount(
@AuthenticationPrincipal UserDetails userDetails) {
log.info("获取收藏数量: user={}", userDetails.getUsername());
Long userId = Long.parseLong(userDetails.getUsername());
return favoriteService.getUserFavoriteCount(userId);
}
}

View File

@ -0,0 +1,47 @@
package com.lxy.hsend.controller;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
/**
* 健康检查控制器
* 提供系统健康状态检查接口
*/
@Tag(name = "健康检查", description = "系统健康状态检查相关接口")
@RestController
@RequestMapping("/api/health")
public class HealthController {
@Operation(summary = "系统健康检查", description = "检查系统运行状态")
@GetMapping("/check")
public ResponseEntity<Map<String, Object>> healthCheck() {
Map<String, Object> response = new HashMap<>();
response.put("status", "UP");
response.put("timestamp", LocalDateTime.now());
response.put("service", "hs-end");
response.put("version", "1.0.0");
return ResponseEntity.ok(response);
}
@Operation(summary = "系统信息", description = "获取系统基本信息")
@GetMapping("/info")
public ResponseEntity<Map<String, Object>> systemInfo() {
Map<String, Object> response = new HashMap<>();
response.put("application", "海角AI后端服务");
response.put("version", "1.0.0");
response.put("java.version", System.getProperty("java.version"));
response.put("os.name", System.getProperty("os.name"));
response.put("startup.time", LocalDateTime.now());
return ResponseEntity.ok(response);
}
}

View File

@ -0,0 +1,92 @@
package com.lxy.hsend.controller;
import com.lxy.hsend.annotation.RequireAuth;
import com.lxy.hsend.annotation.RequestLog;
import com.lxy.hsend.annotation.RateLimit;
import com.lxy.hsend.common.ApiResponse;
import com.lxy.hsend.dto.message.MessageListRequest;
import com.lxy.hsend.dto.message.MessageResponse;
import com.lxy.hsend.dto.message.SendMessageRequest;
import com.lxy.hsend.service.MessageService;
import com.lxy.hsend.util.PageUtil;
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.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid;
/**
* 消息控制器
*/
@Slf4j
@RestController
@RequestMapping("/api/chat")
@RequiredArgsConstructor
@Tag(name = "消息管理", description = "聊天消息相关接口")
public class MessageController {
private final MessageService messageService;
/**
* 发送消息
*/
@PostMapping("/send")
@RequireAuth
@RequestLog
@RateLimit(count = 30, period = 60) // 每分钟最多30条消息
@Operation(summary = "发送消息", description = "发送聊天消息并获取AI响应")
public ApiResponse<MessageResponse> sendMessage(
@AuthenticationPrincipal UserDetails userDetails,
@RequestBody @Valid SendMessageRequest request) {
log.info("用户发送消息: user={}, sessionId={}, contentLength={}",
userDetails.getUsername(), request.getSessionId(), request.getContent().length());
Long userId = Long.parseLong(userDetails.getUsername());
return messageService.sendMessage(userId, request);
}
/**
* 获取消息列表
*/
@GetMapping("/messages")
@RequireAuth
@RequestLog
@Operation(summary = "获取消息列表", description = "分页获取指定会话的消息列表")
public ApiResponse<PageUtil.PageResponse<MessageResponse>> getMessages(
@AuthenticationPrincipal UserDetails userDetails,
@Parameter(description = "会话ID") @RequestParam String sessionId,
@Parameter(description = "页码从1开始") @RequestParam(defaultValue = "1") Integer page,
@Parameter(description = "每页数量") @RequestParam(defaultValue = "20") Integer pageSize,
@Parameter(description = "角色过滤") @RequestParam(defaultValue = "all") String role) {
log.info("获取消息列表: user={}, sessionId={}, page={}, pageSize={}, role={}",
userDetails.getUsername(), sessionId, page, pageSize, role);
Long userId = Long.parseLong(userDetails.getUsername());
MessageListRequest request = new MessageListRequest(sessionId, page, pageSize, role);
return messageService.getMessages(userId, request);
}
/**
* 获取消息详情
*/
@GetMapping("/message/{messageId}")
@RequireAuth
@RequestLog
@Operation(summary = "获取消息详情", description = "根据消息ID获取消息详细信息")
public ApiResponse<MessageResponse> getMessageById(
@AuthenticationPrincipal UserDetails userDetails,
@Parameter(description = "消息ID") @PathVariable String messageId) {
log.info("获取消息详情: user={}, messageId={}", userDetails.getUsername(), messageId);
Long userId = Long.parseLong(userDetails.getUsername());
return messageService.getMessageById(userId, messageId);
}
}

View File

@ -0,0 +1,166 @@
package com.lxy.hsend.controller;
import com.lxy.hsend.annotation.RequestLog;
import com.lxy.hsend.annotation.RequireAuth;
import com.lxy.hsend.common.ApiResponse;
import com.lxy.hsend.dto.session.*;
import com.lxy.hsend.service.SessionService;
import com.lxy.hsend.util.JwtUtil;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.web.bind.annotation.*;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
/**
* 会话控制器
*
* @author lxy
*/
@Slf4j
@RestController
@RequestMapping("/api/chat")
@RequireAuth
@Tag(name = "会话管理", description = "聊天会话管理相关接口")
public class SessionController {
private final SessionService sessionService;
private final JwtUtil jwtUtil;
@Autowired
public SessionController(SessionService sessionService, JwtUtil jwtUtil) {
this.sessionService = sessionService;
this.jwtUtil = jwtUtil;
}
/**
* 创建会话
*/
@PostMapping("/session")
@Operation(summary = "创建会话", description = "创建一个新的聊天会话")
@RequestLog(value = "创建会话", type = RequestLog.OperationType.CREATE)
public ApiResponse<SessionDetailResponse> createSession(
@Valid @RequestBody CreateSessionRequest request,
HttpServletRequest httpRequest) {
Long userId = getCurrentUserId(httpRequest);
SessionDetailResponse response = sessionService.createSession(userId, request);
return ApiResponse.success(response, "会话创建成功");
}
/**
* 获取会话列表
*/
@GetMapping("/sessions")
@Operation(summary = "获取会话列表", description = "获取用户的会话列表,支持搜索和分页")
@RequestLog(value = "获取会话列表", type = RequestLog.OperationType.QUERY)
public ApiResponse<Page<SessionListResponse>> getSessionList(
@Parameter(description = "搜索关键词") @RequestParam(required = false) String keyword,
@Parameter(description = "页码从1开始") @RequestParam(defaultValue = "1") Integer page,
@Parameter(description = "每页大小") @RequestParam(defaultValue = "10") Integer pageSize,
@Parameter(description = "排序字段") @RequestParam(defaultValue = "last_message_time") String sortBy,
@Parameter(description = "排序方向") @RequestParam(defaultValue = "desc") String sortOrder,
HttpServletRequest httpRequest) {
Long userId = getCurrentUserId(httpRequest);
// 构建搜索请求
SessionSearchRequest searchRequest = new SessionSearchRequest();
searchRequest.setKeyword(keyword);
searchRequest.setPage(page);
searchRequest.setPageSize(pageSize);
searchRequest.setSortBy(sortBy);
searchRequest.setSortOrder(sortOrder);
Page<SessionListResponse> response = sessionService.getSessionList(userId, searchRequest);
return ApiResponse.success(response, "获取会话列表成功");
}
/**
* 获取会话详情
*/
@GetMapping("/session/{sessionId}")
@Operation(summary = "获取会话详情", description = "根据会话ID获取会话的详细信息")
@RequestLog(value = "获取会话详情", type = RequestLog.OperationType.QUERY)
public ApiResponse<SessionDetailResponse> getSessionDetail(
@Parameter(description = "会话ID") @PathVariable String sessionId,
HttpServletRequest httpRequest) {
Long userId = getCurrentUserId(httpRequest);
SessionDetailResponse response = sessionService.getSessionDetail(userId, sessionId);
return ApiResponse.success(response, "获取会话详情成功");
}
/**
* 更新会话
*/
@PutMapping("/session/{sessionId}")
@Operation(summary = "更新会话", description = "更新会话信息,如标题等")
@RequestLog(value = "更新会话", type = RequestLog.OperationType.UPDATE)
public ApiResponse<SessionDetailResponse> updateSession(
@Parameter(description = "会话ID") @PathVariable String sessionId,
@Valid @RequestBody UpdateSessionRequest request,
HttpServletRequest httpRequest) {
Long userId = getCurrentUserId(httpRequest);
SessionDetailResponse response = sessionService.updateSession(userId, sessionId, request);
return ApiResponse.success(response, "会话更新成功");
}
/**
* 删除会话
*/
@DeleteMapping("/session/{sessionId}")
@Operation(summary = "删除会话", description = "删除指定的会话及其所有消息和收藏记录")
@RequestLog(value = "删除会话", type = RequestLog.OperationType.DELETE)
public ApiResponse<Void> deleteSession(
@Parameter(description = "会话ID") @PathVariable String sessionId,
HttpServletRequest httpRequest) {
Long userId = getCurrentUserId(httpRequest);
sessionService.deleteSession(userId, sessionId);
return ApiResponse.success(null, "会话删除成功");
}
/**
* 清空会话
*/
@PostMapping("/session/{sessionId}/clear")
@Operation(summary = "清空会话", description = "清空会话中的所有消息,但保留会话本身")
@RequestLog(value = "清空会话", type = RequestLog.OperationType.DELETE)
public ApiResponse<Void> clearSession(
@Parameter(description = "会话ID") @PathVariable String sessionId,
HttpServletRequest httpRequest) {
Long userId = getCurrentUserId(httpRequest);
sessionService.clearSession(userId, sessionId);
return ApiResponse.success(null, "会话清空成功");
}
// 获取会话消息列表的功能现在由 MessageController 处理
/**
* 从请求中获取当前用户ID
*/
private Long getCurrentUserId(HttpServletRequest request) {
String authHeader = request.getHeader("Authorization");
String token = jwtUtil.extractTokenFromHeader(authHeader);
if (token != null && jwtUtil.validateToken(token)) {
return jwtUtil.getUserIdFromToken(token);
}
throw new RuntimeException("无法获取用户信息");
}
}

View File

@ -0,0 +1,98 @@
package com.lxy.hsend.controller;
import com.lxy.hsend.annotation.RequestLog;
import com.lxy.hsend.annotation.RequireAuth;
import com.lxy.hsend.common.ApiResponse;
import com.lxy.hsend.dto.user.UpdateUserRequest;
import com.lxy.hsend.dto.user.UserInfoResponse;
import com.lxy.hsend.service.UserService;
import com.lxy.hsend.util.JwtUtil;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
/**
* 用户控制器
*
* @author lxy
*/
@Slf4j
@RestController
@RequestMapping("/api/user")
@Tag(name = "用户管理", description = "用户信息相关接口")
public class UserController {
private final UserService userService;
private final JwtUtil jwtUtil;
@Autowired
public UserController(UserService userService, JwtUtil jwtUtil) {
this.userService = userService;
this.jwtUtil = jwtUtil;
}
/**
* 获取当前用户信息
*/
@GetMapping("/info")
@RequireAuth
@Operation(summary = "获取用户信息", description = "获取当前登录用户的基本信息")
@RequestLog(value = "获取用户信息", type = RequestLog.OperationType.QUERY)
public ApiResponse<UserInfoResponse> getUserInfo(HttpServletRequest request) {
Long userId = getCurrentUserId(request);
UserInfoResponse userInfo = userService.getUserById(userId);
return ApiResponse.success(userInfo, "获取用户信息成功");
}
/**
* 更新当前用户信息
*/
@PutMapping("/info")
@RequireAuth
@Operation(summary = "更新用户信息", description = "更新当前登录用户的基本信息")
@RequestLog(value = "更新用户信息", type = RequestLog.OperationType.UPDATE)
public ApiResponse<UserInfoResponse> updateUserInfo(@Valid @RequestBody UpdateUserRequest request, HttpServletRequest httpRequest) {
Long userId = getCurrentUserId(httpRequest);
UserInfoResponse userInfo = userService.updateUser(userId, request);
return ApiResponse.success(userInfo, "更新用户信息成功");
}
/**
* 根据ID获取用户信息公开接口用于获取其他用户的基本信息
*/
@GetMapping("/{userId}")
@Operation(summary = "根据ID获取用户信息", description = "获取指定用户的基本信息(公开信息)")
@RequestLog(value = "根据ID获取用户信息", type = RequestLog.OperationType.QUERY)
public ApiResponse<UserInfoResponse> getUserById(@PathVariable Long userId) {
UserInfoResponse userInfo = userService.getUserById(userId);
// 对于公开接口,隐藏敏感信息
UserInfoResponse publicInfo = new UserInfoResponse();
publicInfo.setUserId(userInfo.getUserId());
publicInfo.setNickname(userInfo.getNickname());
publicInfo.setAvatarUrl(userInfo.getAvatarUrl());
publicInfo.setCreatedAt(userInfo.getCreatedAt());
// 不返回邮箱、状态、最后登录时间等敏感信息
return ApiResponse.success(publicInfo, "获取用户信息成功");
}
/**
* 从请求中获取当前用户ID
*/
private Long getCurrentUserId(HttpServletRequest request) {
String authHeader = request.getHeader("Authorization");
String token = jwtUtil.extractTokenFromHeader(authHeader);
if (token != null && jwtUtil.validateToken(token)) {
return jwtUtil.getUserIdFromToken(token);
}
throw new RuntimeException("无法获取用户信息");
}
}

View File

@ -0,0 +1,33 @@
package com.lxy.hsend.dto.auth;
import com.lxy.hsend.annotation.ValidEmail;
import lombok.Data;
import jakarta.validation.constraints.NotBlank;
/**
* 用户登录请求DTO
*
* @author lxy
*/
@Data
public class LoginRequest {
/**
* 邮箱地址
*/
@NotBlank(message = "邮箱不能为空")
@ValidEmail(message = "邮箱格式不正确")
private String email;
/**
* 密码
*/
@NotBlank(message = "密码不能为空")
private String password;
/**
* 记住登录状态(可选,暂时保留)
*/
private Boolean rememberMe = false;
}

View File

@ -0,0 +1,53 @@
package com.lxy.hsend.dto.auth;
import com.lxy.hsend.dto.user.UserInfoResponse;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 用户登录响应DTO
*
* @author lxy
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class LoginResponse {
/**
* JWT访问令牌
*/
private String accessToken;
/**
* JWT刷新令牌
*/
private String refreshToken;
/**
* 令牌类型
*/
private String tokenType = "Bearer";
/**
* 令牌过期时间(秒)
*/
private Long expiresIn;
/**
* 用户基本信息
*/
private UserInfoResponse userInfo;
/**
* 构造方法不包含tokenType
*/
public LoginResponse(String accessToken, String refreshToken, Long expiresIn, UserInfoResponse userInfo) {
this.accessToken = accessToken;
this.refreshToken = refreshToken;
this.tokenType = "Bearer";
this.expiresIn = expiresIn;
this.userInfo = userInfo;
}
}

View File

@ -0,0 +1,49 @@
package com.lxy.hsend.dto.auth;
import com.lxy.hsend.annotation.ValidEmail;
import com.lxy.hsend.annotation.ValidPassword;
import lombok.Data;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
/**
* 用户注册请求DTO
*
* @author lxy
*/
@Data
public class RegisterRequest {
/**
* 邮箱地址
*/
@NotBlank(message = "邮箱不能为空")
@ValidEmail(message = "邮箱格式不正确")
private String email;
/**
* 密码
*/
@NotBlank(message = "密码不能为空")
@ValidPassword(message = "密码格式不正确密码长度应为6-20位包含字母和数字")
private String password;
/**
* 确认密码
*/
@NotBlank(message = "确认密码不能为空")
private String confirmPassword;
/**
* 用户昵称
*/
@NotBlank(message = "昵称不能为空")
@Size(min = 1, max = 50, message = "昵称长度应在1-50个字符之间")
private String nickname;
/**
* 头像URL可选
*/
private String avatarUrl;
}

View File

@ -0,0 +1,28 @@
package com.lxy.hsend.dto.favorite;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
import jakarta.validation.constraints.NotBlank;
/**
* 添加收藏请求DTO
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class AddFavoriteRequest {
/**
* 消息ID
*/
@NotBlank(message = "消息ID不能为空")
private String messageId;
/**
* 会话ID
*/
@NotBlank(message = "会话ID不能为空")
private String sessionId;
}

View File

@ -0,0 +1,40 @@
package com.lxy.hsend.dto.favorite;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Max;
/**
* 收藏列表查询请求DTO
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class FavoriteListRequest {
/**
* 搜索关键词(会话标题、消息内容)
*/
private String keyword;
/**
* 页码从1开始
*/
@Min(value = 1, message = "页码必须大于0")
private Integer page = 1;
/**
* 每页数量
*/
@Min(value = 1, message = "每页数量必须大于0")
@Max(value = 100, message = "每页数量不能超过100")
private Integer pageSize = 20;
/**
* 消息类型过滤user/assistant/all
*/
private String messageRole = "all";
}

View File

@ -0,0 +1,58 @@
package com.lxy.hsend.dto.favorite;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
import lombok.Builder;
import java.time.LocalDateTime;
/**
* 收藏响应DTO
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class FavoriteResponse {
/**
* 收藏ID
*/
private String favoriteId;
/**
* 消息ID
*/
private String messageId;
/**
* 会话ID
*/
private String sessionId;
/**
* 会话标题
*/
private String sessionTitle;
/**
* 消息内容
*/
private String messageContent;
/**
* 消息角色user/assistant
*/
private String messageRole;
/**
* 消息创建时间
*/
private LocalDateTime messageCreatedAt;
/**
* 收藏时间
*/
private LocalDateTime favoritedAt;
}

View File

@ -0,0 +1,22 @@
package com.lxy.hsend.dto.favorite;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
import jakarta.validation.constraints.NotBlank;
/**
* 取消收藏请求DTO
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class RemoveFavoriteRequest {
/**
* 消息ID
*/
@NotBlank(message = "消息ID不能为空")
private String messageId;
}

View File

@ -0,0 +1,42 @@
package com.lxy.hsend.dto.message;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Max;
/**
* 消息列表查询请求DTO
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class MessageListRequest {
/**
* 会话ID
*/
@NotBlank(message = "会话ID不能为空")
private String sessionId;
/**
* 页码从1开始
*/
@Min(value = 1, message = "页码必须大于0")
private Integer page = 1;
/**
* 每页数量
*/
@Min(value = 1, message = "每页数量必须大于0")
@Max(value = 100, message = "每页数量不能超过100")
private Integer pageSize = 20;
/**
* 消息类型过滤user/assistant/all
*/
private String role = "all";
}

View File

@ -0,0 +1,63 @@
package com.lxy.hsend.dto.message;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
import lombok.Builder;
import java.time.LocalDateTime;
/**
* 消息响应DTO
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class MessageResponse {
/**
* 消息ID
*/
private String messageId;
/**
* 会话ID
*/
private String sessionId;
/**
* 消息角色user/assistant
*/
private String role;
/**
* 消息内容
*/
private String content;
/**
* 消息状态pending/completed/failed
*/
private String status;
/**
* 是否深度思考
*/
private Boolean deepThinking;
/**
* 是否联网搜索
*/
private Boolean webSearch;
/**
* 创建时间
*/
private LocalDateTime createdAt;
/**
* 更新时间
*/
private LocalDateTime updatedAt;
}

View File

@ -0,0 +1,40 @@
package com.lxy.hsend.dto.message;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
/**
* 发送消息请求DTO
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SendMessageRequest {
/**
* 会话ID
*/
@NotBlank(message = "会话ID不能为空")
private String sessionId;
/**
* 消息内容
*/
@NotBlank(message = "消息内容不能为空")
@Size(min = 1, max = 4000, message = "消息内容长度必须在1-4000字符之间")
private String content;
/**
* 是否深度思考
*/
private Boolean deepThinking = false;
/**
* 是否联网搜索
*/
private Boolean webSearch = false;
}

View File

@ -0,0 +1,27 @@
package com.lxy.hsend.dto.session;
import lombok.Data;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
/**
* 创建会话请求DTO
*
* @author lxy
*/
@Data
public class CreateSessionRequest {
/**
* 会话标题
*/
@NotBlank(message = "会话标题不能为空")
@Size(min = 1, max = 100, message = "会话标题长度应在1-100个字符之间")
private String title;
/**
* 初始消息内容(可选)
*/
@Size(max = 10000, message = "消息内容长度不能超过10000个字符")
private String initialMessage;
}

View File

@ -0,0 +1,65 @@
package com.lxy.hsend.dto.session;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 会话详情响应DTO
*
* @author lxy
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SessionDetailResponse {
/**
* 会话ID
*/
private String sessionId;
/**
* 用户ID
*/
private Long userId;
/**
* 会话标题
*/
private String title;
/**
* 消息总数
*/
private Integer messageCount;
/**
* 最后消息时间
*/
private Long lastMessageTime;
/**
* 创建时间
*/
private Long createdAt;
/**
* 更新时间
*/
private Long updatedAt;
/**
* 检查是否有消息
*/
public boolean hasMessages() {
return messageCount != null && messageCount > 0;
}
/**
* 检查会话是否为空(无消息)
*/
public boolean isEmpty() {
return !hasMessages();
}
}

View File

@ -0,0 +1,74 @@
package com.lxy.hsend.dto.session;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 会话列表响应DTO
*
* @author lxy
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SessionListResponse {
/**
* 会话ID
*/
private String sessionId;
/**
* 会话标题
*/
private String title;
/**
* 消息总数
*/
private Integer messageCount;
/**
* 最后一条消息的内容摘要
*/
private String lastMessageContent;
/**
* 最后消息时间
*/
private Long lastMessageTime;
/**
* 创建时间
*/
private Long createdAt;
/**
* 更新时间
*/
private Long updatedAt;
/**
* 获取最后消息内容的摘要(限制长度)
*/
public String getLastMessageSummary() {
if (lastMessageContent == null || lastMessageContent.trim().isEmpty()) {
return "暂无消息";
}
String content = lastMessageContent.trim();
if (content.length() <= 50) {
return content;
}
return content.substring(0, 47) + "...";
}
/**
* 检查是否有消息
*/
public boolean hasMessages() {
return messageCount != null && messageCount > 0;
}
}

View File

@ -0,0 +1,88 @@
package com.lxy.hsend.dto.session;
import lombok.Data;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Max;
/**
* 会话搜索请求DTO
*
* @author lxy
*/
@Data
public class SessionSearchRequest {
/**
* 搜索关键词(标题和消息内容)
*/
private String keyword;
/**
* 页码从1开始
*/
@Min(value = 1, message = "页码必须大于0")
private Integer page = 1;
/**
* 每页大小
*/
@Min(value = 1, message = "每页大小必须大于0")
@Max(value = 100, message = "每页大小不能超过100")
private Integer pageSize = 10;
/**
* 排序字段支持created_at, updated_at, last_message_time
*/
private String sortBy = "last_message_time";
/**
* 排序方向asc: 升序, desc: 降序)
*/
private String sortOrder = "desc";
/**
* 检查是否有搜索关键词
*/
public boolean hasKeyword() {
return keyword != null && !keyword.trim().isEmpty();
}
/**
* 获取清理后的关键词
*/
public String getCleanKeyword() {
return hasKeyword() ? keyword.trim() : null;
}
/**
* 检查排序方向是否为升序
*/
public boolean isAscOrder() {
return "asc".equalsIgnoreCase(sortOrder);
}
/**
* 检查排序方向是否为降序
*/
public boolean isDescOrder() {
return "desc".equalsIgnoreCase(sortOrder);
}
/**
* 获取有效的排序字段
*/
public String getValidSortBy() {
if (sortBy == null) {
return "last_message_time";
}
switch (sortBy.toLowerCase()) {
case "created_at":
case "updated_at":
case "last_message_time":
return sortBy.toLowerCase();
default:
return "last_message_time";
}
}
}

View File

@ -0,0 +1,19 @@
package com.lxy.hsend.dto.session;
import lombok.Data;
import jakarta.validation.constraints.Size;
/**
* 更新会话请求DTO
*
* @author lxy
*/
@Data
public class UpdateSessionRequest {
/**
* 会话标题
*/
@Size(min = 1, max = 100, message = "会话标题长度应在1-100个字符之间")
private String title;
}

View File

@ -0,0 +1,26 @@
package com.lxy.hsend.dto.user;
import lombok.Data;
import jakarta.validation.constraints.Size;
/**
* 更新用户信息请求DTO
*
* @author lxy
*/
@Data
public class UpdateUserRequest {
/**
* 用户昵称
*/
@Size(min = 1, max = 50, message = "昵称长度应在1-50个字符之间")
private String nickname;
/**
* 头像URL
*/
@Size(max = 500, message = "头像URL长度不能超过500个字符")
private String avatarUrl;
}

View File

@ -0,0 +1,93 @@
package com.lxy.hsend.dto.user;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 用户信息响应DTO
*
* @author lxy
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class UserInfoResponse {
/**
* 用户ID
*/
private Long userId;
/**
* 邮箱地址
*/
private String email;
/**
* 用户昵称
*/
private String nickname;
/**
* 头像URL
*/
private String avatarUrl;
/**
* 用户状态1-正常2-锁定3-删除)
*/
private Byte status;
/**
* 最后登录时间
*/
private Long lastLoginTime;
/**
* 创建时间
*/
private Long createdAt;
/**
* 更新时间
*/
private Long updatedAt;
/**
* 获取状态描述
*/
public String getStatusText() {
switch (status) {
case 1:
return "正常";
case 2:
return "锁定";
case 3:
return "删除";
default:
return "未知";
}
}
/**
* 检查用户状态是否正常
*/
public boolean isNormal() {
return status != null && status == 1;
}
/**
* 检查用户是否被锁定
*/
public boolean isLocked() {
return status != null && status == 2;
}
/**
* 检查用户是否被删除
*/
public boolean isDeleted() {
return status != null && status == 3;
}
}

View File

@ -0,0 +1,181 @@
package com.lxy.hsend.entity;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.time.Instant;
import java.util.UUID;
/**
* 收藏实体类
*/
@Entity
@Table(name = "favorites",
indexes = {
@Index(name = "idx_user_id", columnList = "userId"),
@Index(name = "idx_message_id", columnList = "messageId"),
@Index(name = "idx_session_id", columnList = "sessionId"),
@Index(name = "idx_created_at", columnList = "createdAt")
},
uniqueConstraints = {
@UniqueConstraint(name = "uk_user_message", columnNames = {"user_id", "message_id"})
}
)
public class Favorite {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotBlank
@Size(max = 36)
@Column(name = "favorite_id", nullable = false, unique = true, length = 36)
private String favoriteId;
@NotNull
@Column(name = "user_id", nullable = false)
private Long userId;
@NotBlank
@Size(max = 36)
@Column(name = "message_id", nullable = false, length = 36)
private String messageId;
@NotBlank
@Size(max = 36)
@Column(name = "session_id", nullable = false, length = 36)
private String sessionId;
@NotNull
@Column(name = "created_at", nullable = false)
private Long createdAt;
@NotNull
@Column(name = "updated_at", nullable = false)
private Long updatedAt;
@Column(name = "deleted_at")
private Long deletedAt;
// 关联用户实体
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", referencedColumnName = "id", insertable = false, updatable = false)
private User user;
// 构造函数
public Favorite() {
long now = Instant.now().getEpochSecond();
this.favoriteId = UUID.randomUUID().toString();
this.createdAt = now;
this.updatedAt = now;
}
public Favorite(Long userId, String messageId, String sessionId) {
this();
this.userId = userId;
this.messageId = messageId;
this.sessionId = sessionId;
}
// JPA生命周期回调
@PreUpdate
public void preUpdate() {
this.updatedAt = Instant.now().getEpochSecond();
}
// Getters and Setters
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getFavoriteId() {
return favoriteId;
}
public void setFavoriteId(String favoriteId) {
this.favoriteId = favoriteId;
}
public Long getUserId() {
return userId;
}
public void setUserId(Long userId) {
this.userId = userId;
}
public String getMessageId() {
return messageId;
}
public void setMessageId(String messageId) {
this.messageId = messageId;
}
public String getSessionId() {
return sessionId;
}
public void setSessionId(String sessionId) {
this.sessionId = sessionId;
}
public Long getCreatedAt() {
return createdAt;
}
public void setCreatedAt(Long createdAt) {
this.createdAt = createdAt;
}
public Long getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(Long updatedAt) {
this.updatedAt = updatedAt;
}
public Long getDeletedAt() {
return deletedAt;
}
public void setDeletedAt(Long deletedAt) {
this.deletedAt = deletedAt;
}
public User getUser() {
return user;
}
public void setUser(User user) {
this.user = user;
}
// 工具方法
public boolean isDeleted() {
return deletedAt != null;
}
public void markAsDeleted() {
this.deletedAt = Instant.now().getEpochSecond();
}
@Override
public String toString() {
return "Favorite{" +
"id=" + id +
", favoriteId='" + favoriteId + '\'' +
", userId=" + userId +
", messageId='" + messageId + '\'' +
", sessionId='" + sessionId + '\'' +
", createdAt=" + createdAt +
'}';
}
}

View File

@ -0,0 +1,286 @@
package com.lxy.hsend.entity;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.time.Instant;
import java.util.UUID;
/**
* 文件实体类
*/
@Entity
@Table(name = "files", indexes = {
@Index(name = "idx_user_id", columnList = "userId"),
@Index(name = "idx_session_id", columnList = "sessionId"),
@Index(name = "idx_file_type", columnList = "fileType"),
@Index(name = "idx_upload_time", columnList = "uploadTime"),
@Index(name = "idx_created_at", columnList = "createdAt")
})
public class File {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotBlank
@Size(max = 36)
@Column(name = "file_id", nullable = false, unique = true, length = 36)
private String fileId;
@NotNull
@Column(name = "user_id", nullable = false)
private Long userId;
@Size(max = 36)
@Column(name = "session_id", length = 36)
private String sessionId;
@NotBlank
@Size(max = 255)
@Column(name = "filename", nullable = false)
private String filename;
@NotBlank
@Size(max = 255)
@Column(name = "stored_filename", nullable = false)
private String storedFilename;
@NotBlank
@Size(max = 500)
@Column(name = "file_path", nullable = false, length = 500)
private String filePath;
@NotNull
@Column(name = "file_size", nullable = false)
private Long fileSize;
@NotBlank
@Size(max = 100)
@Column(name = "file_type", nullable = false, length = 100)
private String fileType;
@Size(max = 100)
@Column(name = "mime_type", length = 100)
private String mimeType;
@NotNull
@Column(name = "upload_time", nullable = false)
private Long uploadTime;
@NotNull
@Column(name = "created_at", nullable = false)
private Long createdAt;
@NotNull
@Column(name = "updated_at", nullable = false)
private Long updatedAt;
@Column(name = "deleted_at")
private Long deletedAt;
// 关联用户实体
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", referencedColumnName = "id", insertable = false, updatable = false)
private User user;
// 构造函数
public File() {
long now = Instant.now().getEpochSecond();
this.fileId = UUID.randomUUID().toString();
this.uploadTime = now;
this.createdAt = now;
this.updatedAt = now;
}
public File(Long userId, String filename, String storedFilename, String filePath,
Long fileSize, String fileType, String mimeType) {
this();
this.userId = userId;
this.filename = filename;
this.storedFilename = storedFilename;
this.filePath = filePath;
this.fileSize = fileSize;
this.fileType = fileType;
this.mimeType = mimeType;
}
// JPA生命周期回调
@PreUpdate
public void preUpdate() {
this.updatedAt = Instant.now().getEpochSecond();
}
// Getters and Setters
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getFileId() {
return fileId;
}
public void setFileId(String fileId) {
this.fileId = fileId;
}
public Long getUserId() {
return userId;
}
public void setUserId(Long userId) {
this.userId = userId;
}
public String getSessionId() {
return sessionId;
}
public void setSessionId(String sessionId) {
this.sessionId = sessionId;
}
public String getFilename() {
return filename;
}
public void setFilename(String filename) {
this.filename = filename;
}
public String getStoredFilename() {
return storedFilename;
}
public void setStoredFilename(String storedFilename) {
this.storedFilename = storedFilename;
}
public String getFilePath() {
return filePath;
}
public void setFilePath(String filePath) {
this.filePath = filePath;
}
public Long getFileSize() {
return fileSize;
}
public void setFileSize(Long fileSize) {
this.fileSize = fileSize;
}
public String getFileType() {
return fileType;
}
public void setFileType(String fileType) {
this.fileType = fileType;
}
public String getMimeType() {
return mimeType;
}
public void setMimeType(String mimeType) {
this.mimeType = mimeType;
}
public Long getUploadTime() {
return uploadTime;
}
public void setUploadTime(Long uploadTime) {
this.uploadTime = uploadTime;
}
public Long getCreatedAt() {
return createdAt;
}
public void setCreatedAt(Long createdAt) {
this.createdAt = createdAt;
}
public Long getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(Long updatedAt) {
this.updatedAt = updatedAt;
}
public Long getDeletedAt() {
return deletedAt;
}
public void setDeletedAt(Long deletedAt) {
this.deletedAt = deletedAt;
}
public User getUser() {
return user;
}
public void setUser(User user) {
this.user = user;
}
// 工具方法
public boolean isDeleted() {
return deletedAt != null;
}
public void markAsDeleted() {
this.deletedAt = Instant.now().getEpochSecond();
}
public String getFileSizeFormatted() {
if (fileSize == null) return "0 B";
long size = fileSize;
String[] units = {"B", "KB", "MB", "GB", "TB"};
int unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return size + " " + units[unitIndex];
}
public boolean isImageFile() {
if (mimeType == null) return false;
return mimeType.startsWith("image/");
}
public boolean isDocumentFile() {
if (fileType == null) return false;
String lowerType = fileType.toLowerCase();
return lowerType.equals("pdf") || lowerType.equals("doc") ||
lowerType.equals("docx") || lowerType.equals("txt") ||
lowerType.equals("md");
}
@Override
public String toString() {
return "File{" +
"id=" + id +
", fileId='" + fileId + '\'' +
", userId=" + userId +
", filename='" + filename + '\'' +
", fileSize=" + fileSize +
", fileType='" + fileType + '\'' +
", uploadTime=" + uploadTime +
'}';
}
}

View File

@ -0,0 +1,266 @@
package com.lxy.hsend.entity;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.time.Instant;
import java.util.UUID;
/**
* 消息实体类
*/
@Entity
@Table(name = "messages", indexes = {
@Index(name = "idx_session_id", columnList = "sessionId"),
@Index(name = "idx_user_id", columnList = "userId"),
@Index(name = "idx_role", columnList = "role"),
@Index(name = "idx_timestamp", columnList = "timestamp"),
@Index(name = "idx_is_favorited", columnList = "isFavorited"),
@Index(name = "idx_created_at", columnList = "createdAt")
})
public class Message {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotBlank
@Size(max = 36)
@Column(name = "message_id", nullable = false, unique = true, length = 36)
private String messageId;
@NotBlank
@Size(max = 36)
@Column(name = "session_id", nullable = false, length = 36)
private String sessionId;
@NotNull
@Column(name = "user_id", nullable = false)
private Long userId;
@NotBlank
@Size(max = 20)
@Column(name = "role", nullable = false, length = 20)
private String role; // user/assistant
@NotBlank
@Lob
@Column(name = "content", nullable = false, columnDefinition = "LONGTEXT")
private String content;
@Size(max = 50)
@Column(name = "model_used", length = 50)
private String modelUsed;
@Column(name = "deep_thinking")
private Boolean deepThinking = false;
@Column(name = "web_search")
private Boolean webSearch = false;
@Column(name = "is_favorited")
private Boolean isFavorited = false;
@NotNull
@Column(name = "timestamp", nullable = false)
private Long timestamp;
@NotNull
@Column(name = "created_at", nullable = false)
private Long createdAt;
@NotNull
@Column(name = "updated_at", nullable = false)
private Long updatedAt;
@Column(name = "deleted_at")
private Long deletedAt;
// 关联用户实体
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", referencedColumnName = "id", insertable = false, updatable = false)
private User user;
// 构造函数
public Message() {
long now = Instant.now().getEpochSecond();
this.messageId = UUID.randomUUID().toString();
this.timestamp = now;
this.createdAt = now;
this.updatedAt = now;
}
public Message(String sessionId, Long userId, String role, String content) {
this();
this.sessionId = sessionId;
this.userId = userId;
this.role = role;
this.content = content;
}
// JPA生命周期回调
@PreUpdate
public void preUpdate() {
this.updatedAt = Instant.now().getEpochSecond();
}
// Getters and Setters
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getMessageId() {
return messageId;
}
public void setMessageId(String messageId) {
this.messageId = messageId;
}
public String getSessionId() {
return sessionId;
}
public void setSessionId(String sessionId) {
this.sessionId = sessionId;
}
public Long getUserId() {
return userId;
}
public void setUserId(Long userId) {
this.userId = userId;
}
public String getRole() {
return role;
}
public void setRole(String role) {
this.role = role;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public String getModelUsed() {
return modelUsed;
}
public void setModelUsed(String modelUsed) {
this.modelUsed = modelUsed;
}
public Boolean getDeepThinking() {
return deepThinking;
}
public void setDeepThinking(Boolean deepThinking) {
this.deepThinking = deepThinking;
}
public Boolean getWebSearch() {
return webSearch;
}
public void setWebSearch(Boolean webSearch) {
this.webSearch = webSearch;
}
public Boolean getIsFavorited() {
return isFavorited;
}
public void setIsFavorited(Boolean isFavorited) {
this.isFavorited = isFavorited;
}
public Long getTimestamp() {
return timestamp;
}
public void setTimestamp(Long timestamp) {
this.timestamp = timestamp;
}
public Long getCreatedAt() {
return createdAt;
}
public void setCreatedAt(Long createdAt) {
this.createdAt = createdAt;
}
public Long getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(Long updatedAt) {
this.updatedAt = updatedAt;
}
public Long getDeletedAt() {
return deletedAt;
}
public void setDeletedAt(Long deletedAt) {
this.deletedAt = deletedAt;
}
public User getUser() {
return user;
}
public void setUser(User user) {
this.user = user;
}
// 工具方法
public boolean isDeleted() {
return deletedAt != null;
}
public void markAsDeleted() {
this.deletedAt = Instant.now().getEpochSecond();
}
public boolean isUserMessage() {
return "user".equals(role);
}
public boolean isAssistantMessage() {
return "assistant".equals(role);
}
public void toggleFavorite() {
this.isFavorited = !Boolean.TRUE.equals(this.isFavorited);
}
// 消息角色常量
public static final String ROLE_USER = "user";
public static final String ROLE_ASSISTANT = "assistant";
@Override
public String toString() {
return "Message{" +
"id=" + id +
", messageId='" + messageId + '\'' +
", sessionId='" + sessionId + '\'' +
", role='" + role + '\'' +
", isFavorited=" + isFavorited +
", timestamp=" + timestamp +
'}';
}
}

View File

@ -0,0 +1,192 @@
package com.lxy.hsend.entity;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.time.Instant;
import java.util.UUID;
/**
* 会话实体类
*/
@Entity
@Table(name = "sessions", indexes = {
@Index(name = "idx_user_id", columnList = "userId"),
@Index(name = "idx_last_message_time", columnList = "lastMessageTime"),
@Index(name = "idx_created_at", columnList = "createdAt")
})
public class Session {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotBlank
@Size(max = 36)
@Column(name = "session_id", nullable = false, unique = true, length = 36)
private String sessionId;
@NotNull
@Column(name = "user_id", nullable = false)
private Long userId;
@NotBlank
@Size(max = 200)
@Column(name = "title", nullable = false, length = 200)
private String title;
@Column(name = "message_count")
private Integer messageCount = 0;
@Column(name = "last_message_time")
private Long lastMessageTime;
@NotNull
@Column(name = "created_at", nullable = false)
private Long createdAt;
@NotNull
@Column(name = "updated_at", nullable = false)
private Long updatedAt;
@Column(name = "deleted_at")
private Long deletedAt;
// 关联用户实体
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", referencedColumnName = "id", insertable = false, updatable = false)
private User user;
// 构造函数
public Session() {
long now = Instant.now().getEpochSecond();
this.sessionId = UUID.randomUUID().toString();
this.createdAt = now;
this.updatedAt = now;
}
public Session(Long userId, String title) {
this();
this.userId = userId;
this.title = title;
}
// JPA生命周期回调
@PreUpdate
public void preUpdate() {
this.updatedAt = Instant.now().getEpochSecond();
}
// Getters and Setters
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getSessionId() {
return sessionId;
}
public void setSessionId(String sessionId) {
this.sessionId = sessionId;
}
public Long getUserId() {
return userId;
}
public void setUserId(Long userId) {
this.userId = userId;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public Integer getMessageCount() {
return messageCount;
}
public void setMessageCount(Integer messageCount) {
this.messageCount = messageCount;
}
public Long getLastMessageTime() {
return lastMessageTime;
}
public void setLastMessageTime(Long lastMessageTime) {
this.lastMessageTime = lastMessageTime;
}
public Long getCreatedAt() {
return createdAt;
}
public void setCreatedAt(Long createdAt) {
this.createdAt = createdAt;
}
public Long getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(Long updatedAt) {
this.updatedAt = updatedAt;
}
public Long getDeletedAt() {
return deletedAt;
}
public void setDeletedAt(Long deletedAt) {
this.deletedAt = deletedAt;
}
public User getUser() {
return user;
}
public void setUser(User user) {
this.user = user;
}
// 工具方法
public boolean isDeleted() {
return deletedAt != null;
}
public void markAsDeleted() {
this.deletedAt = Instant.now().getEpochSecond();
}
public void incrementMessageCount() {
this.messageCount = (this.messageCount == null ? 0 : this.messageCount) + 1;
this.lastMessageTime = Instant.now().getEpochSecond();
}
public void decrementMessageCount() {
this.messageCount = Math.max(0, (this.messageCount == null ? 0 : this.messageCount) - 1);
}
@Override
public String toString() {
return "Session{" +
"id=" + id +
", sessionId='" + sessionId + '\'' +
", userId=" + userId +
", title='" + title + '\'' +
", messageCount=" + messageCount +
", createdAt=" + createdAt +
'}';
}
}

View File

@ -0,0 +1,185 @@
package com.lxy.hsend.entity;
import jakarta.persistence.*;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.time.Instant;
/**
* 用户实体类
*/
@Entity
@Table(name = "users", indexes = {
@Index(name = "idx_status", columnList = "status"),
@Index(name = "idx_created_at", columnList = "createdAt")
})
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotBlank
@Email
@Size(max = 100)
@Column(name = "email", nullable = false, unique = true, length = 100)
private String email;
@NotBlank
@Size(max = 255)
@Column(name = "password_hash", nullable = false)
private String passwordHash;
@NotBlank
@Size(max = 50)
@Column(name = "nickname", nullable = false, length = 50)
private String nickname;
@Size(max = 500)
@Column(name = "avatar_url", length = 500)
private String avatarUrl;
@NotNull
@Column(name = "status", nullable = false, columnDefinition = "TINYINT")
private Byte status = 1; // 1-正常2-锁定3-删除
@Column(name = "last_login_time")
private Long lastLoginTime;
@NotNull
@Column(name = "created_at", nullable = false)
private Long createdAt;
@NotNull
@Column(name = "updated_at", nullable = false)
private Long updatedAt;
@Column(name = "deleted_at")
private Long deletedAt;
// 构造函数
public User() {
long now = Instant.now().getEpochSecond();
this.createdAt = now;
this.updatedAt = now;
}
// JPA生命周期回调
@PreUpdate
public void preUpdate() {
this.updatedAt = Instant.now().getEpochSecond();
}
// Getters and Setters
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public String getPasswordHash() {
return passwordHash;
}
public void setPasswordHash(String passwordHash) {
this.passwordHash = passwordHash;
}
public String getNickname() {
return nickname;
}
public void setNickname(String nickname) {
this.nickname = nickname;
}
public String getAvatarUrl() {
return avatarUrl;
}
public void setAvatarUrl(String avatarUrl) {
this.avatarUrl = avatarUrl;
}
public Byte getStatus() {
return status;
}
public void setStatus(Byte status) {
this.status = status;
}
public Long getLastLoginTime() {
return lastLoginTime;
}
public void setLastLoginTime(Long lastLoginTime) {
this.lastLoginTime = lastLoginTime;
}
public Long getCreatedAt() {
return createdAt;
}
public void setCreatedAt(Long createdAt) {
this.createdAt = createdAt;
}
public Long getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(Long updatedAt) {
this.updatedAt = updatedAt;
}
public Long getDeletedAt() {
return deletedAt;
}
public void setDeletedAt(Long deletedAt) {
this.deletedAt = deletedAt;
}
// 工具方法
public boolean isDeleted() {
return deletedAt != null;
}
public boolean isNormal() {
return status == 1;
}
public boolean isLocked() {
return status == 2;
}
public void markAsDeleted() {
this.deletedAt = Instant.now().getEpochSecond();
this.status = 3;
}
@Override
public String toString() {
return "User{" +
"id=" + id +
", email='" + email + '\'' +
", nickname='" + nickname + '\'' +
", status=" + status +
", createdAt=" + createdAt +
'}';
}
}

View File

@ -0,0 +1,76 @@
package com.lxy.hsend.exception;
import com.lxy.hsend.common.ErrorCode;
import lombok.Getter;
/**
* 业务异常类
*
* @author lxy
*/
@Getter
public class BusinessException extends RuntimeException {
private final ErrorCode errorCode;
public BusinessException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.errorCode = errorCode;
}
public BusinessException(ErrorCode errorCode, String message) {
super(message);
this.errorCode = errorCode;
}
public BusinessException(ErrorCode errorCode, String message, Throwable cause) {
super(message, cause);
this.errorCode = errorCode;
}
// 便捷构造方法
public static BusinessException of(ErrorCode errorCode) {
return new BusinessException(errorCode);
}
public static BusinessException of(ErrorCode errorCode, String message) {
return new BusinessException(errorCode, message);
}
public static BusinessException of(ErrorCode errorCode, String message, Throwable cause) {
return new BusinessException(errorCode, message, cause);
}
// 常用业务异常
public static BusinessException userNotFound() {
return new BusinessException(ErrorCode.USER_NOT_FOUND);
}
public static BusinessException userAlreadyExists() {
return new BusinessException(ErrorCode.USER_ALREADY_EXISTS);
}
public static BusinessException passwordError() {
return new BusinessException(ErrorCode.PASSWORD_ERROR);
}
public static BusinessException sessionNotFound() {
return new BusinessException(ErrorCode.SESSION_NOT_FOUND);
}
public static BusinessException messageNotFound() {
return new BusinessException(ErrorCode.MESSAGE_NOT_FOUND);
}
public static BusinessException fileNotFound() {
return new BusinessException(ErrorCode.FILE_NOT_FOUND);
}
public static BusinessException fileUploadError(String message) {
return new BusinessException(ErrorCode.FILE_UPLOAD_ERROR, message);
}
public static BusinessException aiServiceError(String message) {
return new BusinessException(ErrorCode.AI_SERVICE_ERROR, message);
}
}

View File

@ -0,0 +1,199 @@
package com.lxy.hsend.exception;
import com.lxy.hsend.common.ApiResponse;
import com.lxy.hsend.common.ErrorCode;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.validation.BindException;
import org.springframework.validation.FieldError;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
import org.springframework.web.servlet.NoHandlerFoundException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.ConstraintViolationException;
import java.sql.SQLException;
import java.util.stream.Collectors;
/**
* 全局异常处理器
*
* @author lxy
*/
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
/**
* 业务异常处理
*/
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ApiResponse<Object>> handleBusinessException(BusinessException e, HttpServletRequest request) {
log.warn("业务异常: {} - {}", request.getRequestURI(), e.getMessage());
return ResponseEntity.ok(ApiResponse.error(e.getErrorCode().getCode(), e.getMessage()));
}
/**
* 参数验证异常处理
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Object>> handleValidationException(MethodArgumentNotValidException e, HttpServletRequest request) {
String message = e.getBindingResult().getFieldErrors().stream()
.map(FieldError::getDefaultMessage)
.collect(Collectors.joining("; "));
log.warn("参数验证异常: {} - {}", request.getRequestURI(), message);
return ResponseEntity.ok(ApiResponse.validationError(message));
}
/**
* 绑定异常处理
*/
@ExceptionHandler(BindException.class)
public ResponseEntity<ApiResponse<Object>> handleBindException(BindException e, HttpServletRequest request) {
String message = e.getBindingResult().getFieldErrors().stream()
.map(FieldError::getDefaultMessage)
.collect(Collectors.joining("; "));
log.warn("参数绑定异常: {} - {}", request.getRequestURI(), message);
return ResponseEntity.ok(ApiResponse.validationError(message));
}
/**
* 约束违反异常处理
*/
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ApiResponse<Object>> handleConstraintViolationException(ConstraintViolationException e, HttpServletRequest request) {
String message = e.getConstraintViolations().stream()
.map(ConstraintViolation::getMessage)
.collect(Collectors.joining("; "));
log.warn("约束违反异常: {} - {}", request.getRequestURI(), message);
return ResponseEntity.ok(ApiResponse.validationError(message));
}
/**
* 缺少请求参数异常处理
*/
@ExceptionHandler(MissingServletRequestParameterException.class)
public ResponseEntity<ApiResponse<Object>> handleMissingParamException(MissingServletRequestParameterException e, HttpServletRequest request) {
String message = String.format("缺少必需的请求参数: %s", e.getParameterName());
log.warn("缺少请求参数异常: {} - {}", request.getRequestURI(), message);
return ResponseEntity.ok(ApiResponse.validationError(message));
}
/**
* 参数类型不匹配异常处理
*/
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ResponseEntity<ApiResponse<Object>> handleTypeMismatchException(MethodArgumentTypeMismatchException e, HttpServletRequest request) {
String message = String.format("参数类型不匹配: %s", e.getName());
log.warn("参数类型不匹配异常: {} - {}", request.getRequestURI(), message);
return ResponseEntity.ok(ApiResponse.validationError(message));
}
/**
* HTTP消息不可读异常处理
*/
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<ApiResponse<Object>> handleHttpMessageNotReadableException(HttpMessageNotReadableException e, HttpServletRequest request) {
log.warn("HTTP消息不可读异常: {} - {}", request.getRequestURI(), e.getMessage());
return ResponseEntity.ok(ApiResponse.validationError("请求体格式错误"));
}
/**
* 认证异常处理
*/
@ExceptionHandler(AuthenticationException.class)
public ResponseEntity<ApiResponse<Object>> handleAuthenticationException(AuthenticationException e, HttpServletRequest request) {
log.warn("认证异常: {} - {}", request.getRequestURI(), e.getMessage());
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(ApiResponse.authError("认证失败,请重新登录"));
}
/**
* 凭据错误异常处理
*/
@ExceptionHandler(BadCredentialsException.class)
public ResponseEntity<ApiResponse<Object>> handleBadCredentialsException(BadCredentialsException e, HttpServletRequest request) {
log.warn("凭据错误异常: {} - {}", request.getRequestURI(), e.getMessage());
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(ApiResponse.authError("用户名或密码错误"));
}
/**
* 权限不足异常处理
*/
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ApiResponse<Object>> handleAccessDeniedException(AccessDeniedException e, HttpServletRequest request) {
log.warn("权限不足异常: {} - {}", request.getRequestURI(), e.getMessage());
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.forbiddenError("权限不足,无法访问该资源"));
}
/**
* 请求方法不支持异常处理
*/
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public ResponseEntity<ApiResponse<Object>> handleMethodNotSupportedException(HttpRequestMethodNotSupportedException e, HttpServletRequest request) {
log.warn("请求方法不支持异常: {} - {}", request.getRequestURI(), e.getMessage());
return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED)
.body(ApiResponse.error(ErrorCode.METHOD_NOT_ALLOWED.getCode(), "请求方法不支持"));
}
/**
* 资源不存在异常处理
*/
@ExceptionHandler(NoHandlerFoundException.class)
public ResponseEntity<ApiResponse<Object>> handleNoHandlerFoundException(NoHandlerFoundException e, HttpServletRequest request) {
log.warn("资源不存在异常: {} - {}", request.getRequestURI(), e.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ApiResponse.notFoundError("请求的资源不存在"));
}
/**
* 文件上传大小超限异常处理
*/
@ExceptionHandler(MaxUploadSizeExceededException.class)
public ResponseEntity<ApiResponse<Object>> handleMaxUploadSizeExceededException(MaxUploadSizeExceededException e, HttpServletRequest request) {
log.warn("文件上传大小超限异常: {} - {}", request.getRequestURI(), e.getMessage());
return ResponseEntity.ok(ApiResponse.error(ErrorCode.FILE_UPLOAD_ERROR.getCode(), "文件大小超过限制"));
}
/**
* 数据完整性违反异常处理
*/
@ExceptionHandler(DataIntegrityViolationException.class)
public ResponseEntity<ApiResponse<Object>> handleDataIntegrityViolationException(DataIntegrityViolationException e, HttpServletRequest request) {
log.error("数据完整性违反异常: {} - {}", request.getRequestURI(), e.getMessage());
return ResponseEntity.ok(ApiResponse.error(ErrorCode.DATA_INTEGRITY_ERROR.getCode(), "数据操作违反完整性约束"));
}
/**
* SQL异常处理
*/
@ExceptionHandler(SQLException.class)
public ResponseEntity<ApiResponse<Object>> handleSQLException(SQLException e, HttpServletRequest request) {
log.error("SQL异常: {} - {}", request.getRequestURI(), e.getMessage());
return ResponseEntity.ok(ApiResponse.error(ErrorCode.DATABASE_ERROR.getCode(), "数据库操作失败"));
}
/**
* 通用异常处理
*/
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Object>> handleGenericException(Exception e, HttpServletRequest request) {
log.error("未知异常: {} - {}", request.getRequestURI(), e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.systemError("系统内部错误,请稍后重试"));
}
}

View File

@ -0,0 +1,231 @@
package com.lxy.hsend.repository;
import com.lxy.hsend.entity.Favorite;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
/**
* 收藏数据访问层
*/
@Repository
public interface FavoriteRepository extends JpaRepository<Favorite, Long> {
/**
* 根据收藏ID查找收藏
*/
Optional<Favorite> findByFavoriteId(String favoriteId);
/**
* 根据收藏ID查找未删除的收藏
*/
@Query("SELECT f FROM Favorite f WHERE f.favoriteId = :favoriteId AND f.deletedAt IS NULL")
Optional<Favorite> findByFavoriteIdAndNotDeleted(@Param("favoriteId") String favoriteId);
/**
* 根据用户ID和消息ID查找收藏
*/
Optional<Favorite> findByUserIdAndMessageId(Long userId, String messageId);
/**
* 根据用户ID和消息ID查找未删除的收藏
*/
@Query("SELECT f FROM Favorite f WHERE f.userId = :userId AND f.messageId = :messageId AND f.deletedAt IS NULL")
Optional<Favorite> findByUserIdAndMessageIdAndNotDeleted(@Param("userId") Long userId,
@Param("messageId") String messageId);
/**
* 根据用户ID查找所有收藏
*/
@Query("SELECT f FROM Favorite f WHERE f.userId = :userId AND f.deletedAt IS NULL ORDER BY f.createdAt DESC")
List<Favorite> findByUserIdAndNotDeleted(@Param("userId") Long userId);
/**
* 根据用户ID分页查找收藏
*/
@Query("SELECT f FROM Favorite f WHERE f.userId = :userId AND f.deletedAt IS NULL ORDER BY f.createdAt DESC")
Page<Favorite> findByUserIdAndNotDeleted(@Param("userId") Long userId, Pageable pageable);
/**
* 根据会话ID查找收藏
*/
@Query("SELECT f FROM Favorite f WHERE f.sessionId = :sessionId AND f.deletedAt IS NULL ORDER BY f.createdAt DESC")
List<Favorite> findBySessionIdAndNotDeleted(@Param("sessionId") String sessionId);
/**
* 根据用户ID和会话ID查找收藏
*/
@Query("SELECT f FROM Favorite f WHERE f.userId = :userId AND f.sessionId = :sessionId AND f.deletedAt IS NULL ORDER BY f.createdAt DESC")
List<Favorite> findByUserIdAndSessionIdAndNotDeleted(@Param("userId") Long userId,
@Param("sessionId") String sessionId);
/**
* 根据用户ID和会话ID分页查找收藏
*/
@Query("SELECT f FROM Favorite f WHERE f.userId = :userId AND f.sessionId = :sessionId AND f.deletedAt IS NULL ORDER BY f.createdAt DESC")
Page<Favorite> findByUserIdAndSessionIdAndNotDeleted(@Param("userId") Long userId,
@Param("sessionId") String sessionId,
Pageable pageable);
/**
* 根据时间范围查找收藏
*/
@Query("SELECT f FROM Favorite f WHERE f.userId = :userId AND f.createdAt BETWEEN :startTime AND :endTime AND f.deletedAt IS NULL ORDER BY f.createdAt DESC")
List<Favorite> findByUserIdAndCreatedAtBetween(@Param("userId") Long userId,
@Param("startTime") Long startTime,
@Param("endTime") Long endTime);
/**
* 检查用户是否已收藏某条消息
*/
@Query("SELECT COUNT(f) > 0 FROM Favorite f WHERE f.userId = :userId AND f.messageId = :messageId AND f.deletedAt IS NULL")
boolean existsByUserIdAndMessageIdAndNotDeleted(@Param("userId") Long userId,
@Param("messageId") String messageId);
/**
* 统计用户收藏总数
*/
@Query("SELECT COUNT(f) FROM Favorite f WHERE f.userId = :userId AND f.deletedAt IS NULL")
long countByUserIdAndNotDeleted(@Param("userId") Long userId);
/**
* 统计会话收藏总数
*/
@Query("SELECT COUNT(f) FROM Favorite f WHERE f.sessionId = :sessionId AND f.deletedAt IS NULL")
long countBySessionIdAndNotDeleted(@Param("sessionId") String sessionId);
/**
* 根据消息ID查找所有收藏记录
*/
@Query("SELECT f FROM Favorite f WHERE f.messageId = :messageId AND f.deletedAt IS NULL")
List<Favorite> findByMessageIdAndNotDeleted(@Param("messageId") String messageId);
/**
* 软删除收藏
*/
@Modifying
@Query("UPDATE Favorite f SET f.deletedAt = :deletedAt, f.updatedAt = :updateTime WHERE f.favoriteId = :favoriteId")
void softDeleteByFavoriteId(@Param("favoriteId") String favoriteId,
@Param("deletedAt") Long deletedAt,
@Param("updateTime") Long updateTime);
/**
* 根据用户ID和消息ID软删除收藏
*/
@Modifying
@Query("UPDATE Favorite f SET f.deletedAt = :deletedAt, f.updatedAt = :updateTime WHERE f.userId = :userId AND f.messageId = :messageId AND f.deletedAt IS NULL")
void softDeleteByUserIdAndMessageId(@Param("userId") Long userId,
@Param("messageId") String messageId,
@Param("deletedAt") Long deletedAt,
@Param("updateTime") Long updateTime);
/**
* 批量软删除会话的所有收藏
*/
@Modifying
@Query("UPDATE Favorite f SET f.deletedAt = :deletedAt, f.updatedAt = :updateTime WHERE f.sessionId = :sessionId AND f.deletedAt IS NULL")
void softDeleteAllBySessionId(@Param("sessionId") String sessionId,
@Param("deletedAt") Long deletedAt,
@Param("updateTime") Long updateTime);
/**
* 批量软删除用户的所有收藏
*/
@Modifying
@Query("UPDATE Favorite f SET f.deletedAt = :deletedAt, f.updatedAt = :updateTime WHERE f.userId = :userId AND f.deletedAt IS NULL")
void softDeleteAllByUserId(@Param("userId") Long userId,
@Param("deletedAt") Long deletedAt,
@Param("updateTime") Long updateTime);
/**
* 批量软删除指定消息的所有收藏
*/
@Modifying
@Query("UPDATE Favorite f SET f.deletedAt = :deletedAt, f.updatedAt = :updateTime WHERE f.messageId = :messageId AND f.deletedAt IS NULL")
void softDeleteAllByMessageId(@Param("messageId") String messageId,
@Param("deletedAt") Long deletedAt,
@Param("updateTime") Long updateTime);
/**
* 检查收藏是否属于指定用户
*/
@Query("SELECT COUNT(f) > 0 FROM Favorite f WHERE f.favoriteId = :favoriteId AND f.userId = :userId AND f.deletedAt IS NULL")
boolean existsByFavoriteIdAndUserIdAndNotDeleted(@Param("favoriteId") String favoriteId,
@Param("userId") Long userId);
/**
* 查找用户最近的收藏
*/
@Query("SELECT f FROM Favorite f WHERE f.userId = :userId AND f.deletedAt IS NULL ORDER BY f.createdAt DESC")
Page<Favorite> findRecentFavoritesByUserId(@Param("userId") Long userId, Pageable pageable);
/**
* 统计今日收藏数量
*/
@Query("SELECT COUNT(f) FROM Favorite f WHERE f.createdAt >= :todayStart AND f.deletedAt IS NULL")
long countTodayFavorites(@Param("todayStart") Long todayStart);
/**
* 统计系统总收藏数
*/
@Query("SELECT COUNT(f) FROM Favorite f WHERE f.deletedAt IS NULL")
long countAllNotDeleted();
/**
* 查找收藏数最多的消息
*/
@Query("SELECT f.messageId, COUNT(f) as favoriteCount FROM Favorite f WHERE f.deletedAt IS NULL GROUP BY f.messageId ORDER BY favoriteCount DESC")
Page<Object[]> findMostFavoritedMessages(Pageable pageable);
/**
* 删除会话的所有收藏记录
*/
@Modifying
@Query("DELETE FROM Favorite f WHERE f.sessionId = :sessionId")
void deleteBySessionId(@Param("sessionId") String sessionId);
/**
* 根据关键词搜索用户收藏(搜索消息内容和会话标题)
*/
@Query("""
SELECT f FROM Favorite f
JOIN Message m ON f.messageId = m.messageId
JOIN Session s ON f.sessionId = s.sessionId
WHERE f.userId = :userId
AND f.deletedAt IS NULL
AND m.deletedAt IS NULL
AND s.deletedAt IS NULL
AND (m.content LIKE %:keyword% OR s.title LIKE %:keyword%)
ORDER BY f.createdAt DESC
""")
Page<Favorite> findByUserIdAndKeywordAndNotDeleted(@Param("userId") Long userId,
@Param("keyword") String keyword,
Pageable pageable);
/**
* 根据关键词和消息角色搜索用户收藏
*/
@Query("""
SELECT f FROM Favorite f
JOIN Message m ON f.messageId = m.messageId
JOIN Session s ON f.sessionId = s.sessionId
WHERE f.userId = :userId
AND f.deletedAt IS NULL
AND m.deletedAt IS NULL
AND s.deletedAt IS NULL
AND m.role = :role
AND (m.content LIKE %:keyword% OR s.title LIKE %:keyword%)
ORDER BY f.createdAt DESC
""")
Page<Favorite> findByUserIdAndKeywordAndRoleAndNotDeleted(@Param("userId") Long userId,
@Param("keyword") String keyword,
@Param("role") String role,
Pageable pageable);
}

View File

@ -0,0 +1,218 @@
package com.lxy.hsend.repository;
import com.lxy.hsend.entity.File;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
/**
* 文件数据访问层
*/
@Repository
public interface FileRepository extends JpaRepository<File, Long> {
/**
* 根据文件ID查找文件
*/
Optional<File> findByFileId(String fileId);
/**
* 根据文件ID查找未删除的文件
*/
@Query("SELECT f FROM File f WHERE f.fileId = :fileId AND f.deletedAt IS NULL")
Optional<File> findByFileIdAndNotDeleted(@Param("fileId") String fileId);
/**
* 根据用户ID查找所有文件
*/
@Query("SELECT f FROM File f WHERE f.userId = :userId AND f.deletedAt IS NULL ORDER BY f.uploadTime DESC")
List<File> findByUserIdAndNotDeleted(@Param("userId") Long userId);
/**
* 根据用户ID分页查找文件
*/
@Query("SELECT f FROM File f WHERE f.userId = :userId AND f.deletedAt IS NULL ORDER BY f.uploadTime DESC")
Page<File> findByUserIdAndNotDeleted(@Param("userId") Long userId, Pageable pageable);
/**
* 根据会话ID查找文件
*/
@Query("SELECT f FROM File f WHERE f.sessionId = :sessionId AND f.deletedAt IS NULL ORDER BY f.uploadTime DESC")
List<File> findBySessionIdAndNotDeleted(@Param("sessionId") String sessionId);
/**
* 根据用户ID和会话ID查找文件
*/
@Query("SELECT f FROM File f WHERE f.userId = :userId AND f.sessionId = :sessionId AND f.deletedAt IS NULL ORDER BY f.uploadTime DESC")
List<File> findByUserIdAndSessionIdAndNotDeleted(@Param("userId") Long userId,
@Param("sessionId") String sessionId);
/**
* 根据文件类型查找文件
*/
@Query("SELECT f FROM File f WHERE f.userId = :userId AND f.fileType = :fileType AND f.deletedAt IS NULL ORDER BY f.uploadTime DESC")
List<File> findByUserIdAndFileTypeAndNotDeleted(@Param("userId") Long userId,
@Param("fileType") String fileType);
/**
* 根据文件名搜索文件
*/
@Query("SELECT f FROM File f WHERE f.userId = :userId AND f.filename LIKE %:filename% AND f.deletedAt IS NULL ORDER BY f.uploadTime DESC")
List<File> findByUserIdAndFilenameLikeAndNotDeleted(@Param("userId") Long userId,
@Param("filename") String filename);
/**
* 根据MIME类型查找文件
*/
@Query("SELECT f FROM File f WHERE f.userId = :userId AND f.mimeType = :mimeType AND f.deletedAt IS NULL ORDER BY f.uploadTime DESC")
List<File> findByUserIdAndMimeTypeAndNotDeleted(@Param("userId") Long userId,
@Param("mimeType") String mimeType);
/**
* 根据文件大小范围查找文件
*/
@Query("SELECT f FROM File f WHERE f.userId = :userId AND f.fileSize BETWEEN :minSize AND :maxSize AND f.deletedAt IS NULL ORDER BY f.uploadTime DESC")
List<File> findByUserIdAndFileSizeBetween(@Param("userId") Long userId,
@Param("minSize") Long minSize,
@Param("maxSize") Long maxSize);
/**
* 根据上传时间范围查找文件
*/
@Query("SELECT f FROM File f WHERE f.userId = :userId AND f.uploadTime BETWEEN :startTime AND :endTime AND f.deletedAt IS NULL ORDER BY f.uploadTime DESC")
List<File> findByUserIdAndUploadTimeBetween(@Param("userId") Long userId,
@Param("startTime") Long startTime,
@Param("endTime") Long endTime);
/**
* 检查文件是否存在
*/
@Query("SELECT COUNT(f) > 0 FROM File f WHERE f.storedFilename = :storedFilename AND f.deletedAt IS NULL")
boolean existsByStoredFilenameAndNotDeleted(@Param("storedFilename") String storedFilename);
/**
* 统计用户文件总数
*/
@Query("SELECT COUNT(f) FROM File f WHERE f.userId = :userId AND f.deletedAt IS NULL")
long countByUserIdAndNotDeleted(@Param("userId") Long userId);
/**
* 统计用户文件总大小
*/
@Query("SELECT COALESCE(SUM(f.fileSize), 0) FROM File f WHERE f.userId = :userId AND f.deletedAt IS NULL")
long sumFileSizeByUserIdAndNotDeleted(@Param("userId") Long userId);
/**
* 统计会话文件数量
*/
@Query("SELECT COUNT(f) FROM File f WHERE f.sessionId = :sessionId AND f.deletedAt IS NULL")
long countBySessionIdAndNotDeleted(@Param("sessionId") String sessionId);
/**
* 根据文件类型统计数量
*/
@Query("SELECT f.fileType, COUNT(f) FROM File f WHERE f.userId = :userId AND f.deletedAt IS NULL GROUP BY f.fileType")
List<Object[]> countByFileTypeAndUserId(@Param("userId") Long userId);
/**
* 查找大文件
*/
@Query("SELECT f FROM File f WHERE f.userId = :userId AND f.fileSize > :threshold AND f.deletedAt IS NULL ORDER BY f.fileSize DESC")
List<File> findLargeFilesByUserId(@Param("userId") Long userId,
@Param("threshold") Long threshold);
/**
* 查找图片文件
*/
@Query("SELECT f FROM File f WHERE f.userId = :userId AND f.mimeType LIKE 'image/%' AND f.deletedAt IS NULL ORDER BY f.uploadTime DESC")
List<File> findImageFilesByUserId(@Param("userId") Long userId);
/**
* 查找文档文件
*/
@Query("SELECT f FROM File f WHERE f.userId = :userId AND f.fileType IN ('pdf', 'doc', 'docx', 'txt', 'md') AND f.deletedAt IS NULL ORDER BY f.uploadTime DESC")
List<File> findDocumentFilesByUserId(@Param("userId") Long userId);
/**
* 软删除文件
*/
@Modifying
@Query("UPDATE File f SET f.deletedAt = :deletedAt, f.updatedAt = :updateTime WHERE f.fileId = :fileId")
void softDeleteByFileId(@Param("fileId") String fileId,
@Param("deletedAt") Long deletedAt,
@Param("updateTime") Long updateTime);
/**
* 批量软删除会话的所有文件
*/
@Modifying
@Query("UPDATE File f SET f.deletedAt = :deletedAt, f.updatedAt = :updateTime WHERE f.sessionId = :sessionId AND f.deletedAt IS NULL")
void softDeleteAllBySessionId(@Param("sessionId") String sessionId,
@Param("deletedAt") Long deletedAt,
@Param("updateTime") Long updateTime);
/**
* 批量软删除用户的所有文件
*/
@Modifying
@Query("UPDATE File f SET f.deletedAt = :deletedAt, f.updatedAt = :updateTime WHERE f.userId = :userId AND f.deletedAt IS NULL")
void softDeleteAllByUserId(@Param("userId") Long userId,
@Param("deletedAt") Long deletedAt,
@Param("updateTime") Long updateTime);
/**
* 检查文件是否属于指定用户
*/
@Query("SELECT COUNT(f) > 0 FROM File f WHERE f.fileId = :fileId AND f.userId = :userId AND f.deletedAt IS NULL")
boolean existsByFileIdAndUserIdAndNotDeleted(@Param("fileId") String fileId,
@Param("userId") Long userId);
/**
* 查找用户最近上传的文件
*/
@Query("SELECT f FROM File f WHERE f.userId = :userId AND f.deletedAt IS NULL ORDER BY f.uploadTime DESC")
Page<File> findRecentFilesByUserId(@Param("userId") Long userId, Pageable pageable);
/**
* 查找旧文件(用于清理)
*/
@Query("SELECT f FROM File f WHERE f.uploadTime < :threshold AND f.deletedAt IS NULL")
List<File> findOldFiles(@Param("threshold") Long threshold);
/**
* 统计今日上传文件数量
*/
@Query("SELECT COUNT(f) FROM File f WHERE f.uploadTime >= :todayStart AND f.deletedAt IS NULL")
long countTodayUploads(@Param("todayStart") Long todayStart);
/**
* 统计今日上传文件总大小
*/
@Query("SELECT COALESCE(SUM(f.fileSize), 0) FROM File f WHERE f.uploadTime >= :todayStart AND f.deletedAt IS NULL")
long sumTodayUploadSize(@Param("todayStart") Long todayStart);
/**
* 统计系统总文件数
*/
@Query("SELECT COUNT(f) FROM File f WHERE f.deletedAt IS NULL")
long countAllNotDeleted();
/**
* 统计系统文件总大小
*/
@Query("SELECT COALESCE(SUM(f.fileSize), 0) FROM File f WHERE f.deletedAt IS NULL")
long sumAllFileSize();
/**
* 根据文件路径查找文件
*/
@Query("SELECT f FROM File f WHERE f.filePath = :filePath AND f.deletedAt IS NULL")
Optional<File> findByFilePathAndNotDeleted(@Param("filePath") String filePath);
}

View File

@ -0,0 +1,199 @@
package com.lxy.hsend.repository;
import com.lxy.hsend.entity.Message;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
/**
* 消息数据访问层
*/
@Repository
public interface MessageRepository extends JpaRepository<Message, Long> {
/**
* 根据消息ID查找消息
*/
Optional<Message> findByMessageId(String messageId);
/**
* 根据消息ID查找未删除的消息
*/
@Query("SELECT m FROM Message m WHERE m.messageId = :messageId AND m.deletedAt IS NULL")
Optional<Message> findByMessageIdAndNotDeleted(@Param("messageId") String messageId);
/**
* 根据会话ID查找所有消息
*/
List<Message> findBySessionId(String sessionId);
/**
* 根据会话ID查找未删除的消息按时间排序
*/
@Query("SELECT m FROM Message m WHERE m.sessionId = :sessionId AND m.deletedAt IS NULL ORDER BY m.timestamp ASC")
List<Message> findBySessionIdAndNotDeletedOrderByTimestamp(@Param("sessionId") String sessionId);
/**
* 根据会话ID分页查找未删除的消息
*/
@Query("SELECT m FROM Message m WHERE m.sessionId = :sessionId AND m.deletedAt IS NULL ORDER BY m.timestamp ASC")
Page<Message> findBySessionIdAndNotDeleted(@Param("sessionId") String sessionId, Pageable pageable);
/**
* 根据用户ID查找消息
*/
@Query("SELECT m FROM Message m WHERE m.userId = :userId AND m.deletedAt IS NULL ORDER BY m.timestamp DESC")
Page<Message> findByUserIdAndNotDeleted(@Param("userId") Long userId, Pageable pageable);
/**
* 根据角色查找消息
*/
@Query("SELECT m FROM Message m WHERE m.sessionId = :sessionId AND m.role = :role AND m.deletedAt IS NULL ORDER BY m.timestamp ASC")
List<Message> findBySessionIdAndRoleAndNotDeleted(@Param("sessionId") String sessionId,
@Param("role") String role);
/**
* 查找收藏的消息
*/
@Query("SELECT m FROM Message m WHERE m.sessionId = :sessionId AND m.isFavorited = true AND m.deletedAt IS NULL ORDER BY m.timestamp ASC")
List<Message> findFavoritedBySessionId(@Param("sessionId") String sessionId);
/**
* 根据用户ID查找所有收藏的消息
*/
@Query("SELECT m FROM Message m WHERE m.userId = :userId AND m.isFavorited = true AND m.deletedAt IS NULL ORDER BY m.timestamp DESC")
Page<Message> findFavoritedByUserId(@Param("userId") Long userId, Pageable pageable);
/**
* 根据内容关键词搜索消息
*/
@Query("SELECT m FROM Message m WHERE m.sessionId = :sessionId AND m.content LIKE %:keyword% AND m.deletedAt IS NULL ORDER BY m.timestamp ASC")
List<Message> findBySessionIdAndContentContaining(@Param("sessionId") String sessionId,
@Param("keyword") String keyword);
/**
* 根据AI模型查找消息
*/
@Query("SELECT m FROM Message m WHERE m.modelUsed = :model AND m.deletedAt IS NULL ORDER BY m.timestamp DESC")
Page<Message> findByModelUsed(@Param("model") String model, Pageable pageable);
/**
* 统计会话消息数量
*/
@Query("SELECT COUNT(m) FROM Message m WHERE m.sessionId = :sessionId AND m.deletedAt IS NULL")
long countBySessionIdAndNotDeleted(@Param("sessionId") String sessionId);
/**
* 统计用户消息数量
*/
@Query("SELECT COUNT(m) FROM Message m WHERE m.userId = :userId AND m.deletedAt IS NULL")
long countByUserIdAndNotDeleted(@Param("userId") Long userId);
/**
* 统计用户收藏消息数量
*/
@Query("SELECT COUNT(m) FROM Message m WHERE m.userId = :userId AND m.isFavorited = true AND m.deletedAt IS NULL")
long countFavoritedByUserId(@Param("userId") Long userId);
/**
* 获取会话的最后一条消息
*/
@Query("SELECT m FROM Message m WHERE m.sessionId = :sessionId AND m.deletedAt IS NULL ORDER BY m.timestamp DESC")
Page<Message> findLastMessageBySessionId(@Param("sessionId") String sessionId, Pageable pageable);
/**
* 获取会话的第一条消息
*/
@Query("SELECT m FROM Message m WHERE m.sessionId = :sessionId AND m.deletedAt IS NULL ORDER BY m.timestamp ASC")
Page<Message> findFirstMessageBySessionId(@Param("sessionId") String sessionId, Pageable pageable);
/**
* 查找指定时间范围内的消息
*/
@Query("SELECT m FROM Message m WHERE m.sessionId = :sessionId AND m.timestamp BETWEEN :startTime AND :endTime AND m.deletedAt IS NULL ORDER BY m.timestamp ASC")
List<Message> findBySessionIdAndTimestampBetween(@Param("sessionId") String sessionId,
@Param("startTime") Long startTime,
@Param("endTime") Long endTime);
/**
* 更新消息收藏状态
*/
@Modifying
@Query("UPDATE Message m SET m.isFavorited = :isFavorited, m.updatedAt = :updateTime WHERE m.messageId = :messageId")
void updateFavoriteStatus(@Param("messageId") String messageId,
@Param("isFavorited") Boolean isFavorited,
@Param("updateTime") Long updateTime);
/**
* 软删除消息
*/
@Modifying
@Query("UPDATE Message m SET m.deletedAt = :deletedAt, m.updatedAt = :updateTime WHERE m.messageId = :messageId")
void softDeleteByMessageId(@Param("messageId") String messageId,
@Param("deletedAt") Long deletedAt,
@Param("updateTime") Long updateTime);
/**
* 软删除会话的所有消息
*/
@Modifying
@Query("UPDATE Message m SET m.deletedAt = :deletedAt, m.updatedAt = :updateTime WHERE m.sessionId = :sessionId AND m.deletedAt IS NULL")
void softDeleteAllBySessionId(@Param("sessionId") String sessionId,
@Param("deletedAt") Long deletedAt,
@Param("updateTime") Long updateTime);
/**
* 批量取消收藏
*/
@Modifying
@Query("UPDATE Message m SET m.isFavorited = false, m.updatedAt = :updateTime WHERE m.messageId IN :messageIds")
void unfavoriteMessages(@Param("messageIds") List<String> messageIds,
@Param("updateTime") Long updateTime);
/**
* 检查消息是否属于指定用户
*/
@Query("SELECT COUNT(m) > 0 FROM Message m WHERE m.messageId = :messageId AND m.userId = :userId AND m.deletedAt IS NULL")
boolean existsByMessageIdAndUserIdAndNotDeleted(@Param("messageId") String messageId,
@Param("userId") Long userId);
/**
* 查找使用深度思考的消息
*/
@Query("SELECT m FROM Message m WHERE m.userId = :userId AND m.deepThinking = true AND m.deletedAt IS NULL ORDER BY m.timestamp DESC")
Page<Message> findDeepThinkingMessages(@Param("userId") Long userId, Pageable pageable);
/**
* 查找使用联网搜索的消息
*/
@Query("SELECT m FROM Message m WHERE m.userId = :userId AND m.webSearch = true AND m.deletedAt IS NULL ORDER BY m.timestamp DESC")
Page<Message> findWebSearchMessages(@Param("userId") Long userId, Pageable pageable);
/**
* 统计各模型使用次数
*/
@Query("SELECT m.modelUsed, COUNT(m) FROM Message m WHERE m.modelUsed IS NOT NULL AND m.deletedAt IS NULL GROUP BY m.modelUsed")
List<Object[]> countByModelUsage();
/**
* 统计今日消息数量
*/
@Query("SELECT COUNT(m) FROM Message m WHERE m.timestamp >= :todayStart AND m.deletedAt IS NULL")
long countTodayMessages(@Param("todayStart") Long todayStart);
/**
* 软删除会话的所有消息
*/
@Modifying
@Query("UPDATE Message m SET m.deletedAt = :deletedAt, m.updatedAt = :updateTime WHERE m.sessionId = :sessionId AND m.deletedAt IS NULL")
void softDeleteBySessionId(@Param("sessionId") String sessionId,
@Param("deletedAt") Long deletedAt,
@Param("updateTime") Long updateTime);
}

View File

@ -0,0 +1,162 @@
package com.lxy.hsend.repository;
import com.lxy.hsend.entity.Session;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
/**
* 会话数据访问层
*/
@Repository
public interface SessionRepository extends JpaRepository<Session, Long> {
/**
* 根据会话ID查找会话
*/
Optional<Session> findBySessionId(String sessionId);
/**
* 根据会话ID查找未删除的会话
*/
@Query("SELECT s FROM Session s WHERE s.sessionId = :sessionId AND s.deletedAt IS NULL")
Optional<Session> findBySessionIdAndNotDeleted(@Param("sessionId") String sessionId);
/**
* 根据用户ID查找所有会话
*/
List<Session> findByUserId(Long userId);
/**
* 根据用户ID查找未删除的会话
*/
@Query("SELECT s FROM Session s WHERE s.userId = :userId AND s.deletedAt IS NULL ORDER BY s.lastMessageTime DESC NULLS LAST, s.createdAt DESC")
List<Session> findByUserIdAndNotDeleted(@Param("userId") Long userId);
/**
* 根据用户ID分页查找未删除的会话
*/
@Query("SELECT s FROM Session s WHERE s.userId = :userId AND s.deletedAt IS NULL")
Page<Session> findByUserIdAndNotDeleted(@Param("userId") Long userId, Pageable pageable);
/**
* 根据标题关键词搜索会话
*/
@Query("SELECT s FROM Session s WHERE s.userId = :userId AND s.title LIKE %:keyword% AND s.deletedAt IS NULL ORDER BY s.lastMessageTime DESC NULLS LAST")
Page<Session> findByUserIdAndTitleContainingAndNotDeleted(@Param("userId") Long userId,
@Param("keyword") String keyword,
Pageable pageable);
/**
* 根据关键词搜索会话(搜索标题和消息内容)
*/
@Query("SELECT DISTINCT s FROM Session s LEFT JOIN Message m ON s.sessionId = m.sessionId " +
"WHERE s.userId = :userId AND s.deletedAt IS NULL " +
"AND (s.title LIKE %:keyword% OR m.content LIKE %:keyword%) " +
"ORDER BY s.lastMessageTime DESC NULLS LAST, s.createdAt DESC")
Page<Session> findByUserIdAndKeywordAndNotDeleted(@Param("userId") Long userId,
@Param("keyword") String keyword,
Pageable pageable);
/**
* 根据会话ID和用户ID查找未删除的会话
*/
@Query("SELECT s FROM Session s WHERE s.sessionId = :sessionId AND s.userId = :userId AND s.deletedAt IS NULL")
Optional<Session> findBySessionIdAndUserIdAndNotDeleted(@Param("sessionId") String sessionId,
@Param("userId") Long userId);
/**
* 根据用户ID和时间范围查找会话
*/
@Query("SELECT s FROM Session s WHERE s.userId = :userId AND s.createdAt BETWEEN :startTime AND :endTime AND s.deletedAt IS NULL")
List<Session> findByUserIdAndCreatedAtBetween(@Param("userId") Long userId,
@Param("startTime") Long startTime,
@Param("endTime") Long endTime);
/**
* 统计用户的会话总数
*/
@Query("SELECT COUNT(s) FROM Session s WHERE s.userId = :userId AND s.deletedAt IS NULL")
long countByUserIdAndNotDeleted(@Param("userId") Long userId);
/**
* 查找用户最近的会话
*/
@Query("SELECT s FROM Session s WHERE s.userId = :userId AND s.deletedAt IS NULL ORDER BY s.lastMessageTime DESC NULLS LAST, s.createdAt DESC")
Page<Session> findRecentSessionsByUserId(@Param("userId") Long userId, Pageable pageable);
/**
* 查找有消息的会话
*/
@Query("SELECT s FROM Session s WHERE s.userId = :userId AND s.messageCount > 0 AND s.deletedAt IS NULL ORDER BY s.lastMessageTime DESC")
List<Session> findSessionsWithMessages(@Param("userId") Long userId);
/**
* 查找空会话(没有消息的会话)
*/
@Query("SELECT s FROM Session s WHERE s.userId = :userId AND (s.messageCount = 0 OR s.messageCount IS NULL) AND s.deletedAt IS NULL")
List<Session> findEmptySessions(@Param("userId") Long userId);
/**
* 更新会话的消息统计
*/
@Modifying
@Query("UPDATE Session s SET s.messageCount = :messageCount, s.lastMessageTime = :lastMessageTime, s.updatedAt = :updateTime WHERE s.sessionId = :sessionId")
void updateMessageStatistics(@Param("sessionId") String sessionId,
@Param("messageCount") Integer messageCount,
@Param("lastMessageTime") Long lastMessageTime,
@Param("updateTime") Long updateTime);
/**
* 更新会话标题
*/
@Modifying
@Query("UPDATE Session s SET s.title = :title, s.updatedAt = :updateTime WHERE s.sessionId = :sessionId")
void updateTitle(@Param("sessionId") String sessionId,
@Param("title") String title,
@Param("updateTime") Long updateTime);
/**
* 软删除会话
*/
@Modifying
@Query("UPDATE Session s SET s.deletedAt = :deletedAt, s.updatedAt = :updateTime WHERE s.sessionId = :sessionId")
void softDeleteBySessionId(@Param("sessionId") String sessionId,
@Param("deletedAt") Long deletedAt,
@Param("updateTime") Long updateTime);
/**
* 批量软删除用户的所有会话
*/
@Modifying
@Query("UPDATE Session s SET s.deletedAt = :deletedAt, s.updatedAt = :updateTime WHERE s.userId = :userId AND s.deletedAt IS NULL")
void softDeleteAllByUserId(@Param("userId") Long userId,
@Param("deletedAt") Long deletedAt,
@Param("updateTime") Long updateTime);
/**
* 检查会话是否属于指定用户
*/
@Query("SELECT COUNT(s) > 0 FROM Session s WHERE s.sessionId = :sessionId AND s.userId = :userId AND s.deletedAt IS NULL")
boolean existsBySessionIdAndUserIdAndNotDeleted(@Param("sessionId") String sessionId,
@Param("userId") Long userId);
/**
* 查找长时间未活动的会话
*/
@Query("SELECT s FROM Session s WHERE s.lastMessageTime < :threshold AND s.deletedAt IS NULL")
List<Session> findInactiveSessions(@Param("threshold") Long threshold);
/**
* 统计系统总会话数
*/
@Query("SELECT COUNT(s) FROM Session s WHERE s.deletedAt IS NULL")
long countAllNotDeleted();
}

View File

@ -0,0 +1,132 @@
package com.lxy.hsend.repository;
import com.lxy.hsend.entity.User;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
/**
* 用户数据访问层
*/
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
/**
* 根据邮箱查找用户
*/
Optional<User> findByEmail(String email);
/**
* 根据邮箱查找未删除的用户
*/
@Query("SELECT u FROM User u WHERE u.email = :email AND u.deletedAt IS NULL")
Optional<User> findByEmailAndNotDeleted(@Param("email") String email);
/**
* 根据邮箱查找未删除的用户Spring Data方法命名
*/
Optional<User> findByEmailAndDeletedAtIsNull(String email);
/**
* 根据ID查找未删除的用户
*/
Optional<User> findByIdAndDeletedAtIsNull(Long id);
/**
* 检查邮箱是否存在(未删除)
*/
boolean existsByEmailAndDeletedAtIsNull(String email);
/**
* 根据昵称查找用户
*/
List<User> findByNicknameContaining(String nickname);
/**
* 根据状态查找用户
*/
List<User> findByStatus(Byte status);
/**
* 查找未删除的用户
*/
@Query("SELECT u FROM User u WHERE u.deletedAt IS NULL")
List<User> findAllNotDeleted();
/**
* 分页查找未删除的用户
*/
@Query("SELECT u FROM User u WHERE u.deletedAt IS NULL")
Page<User> findAllNotDeleted(Pageable pageable);
/**
* 检查邮箱是否已存在
*/
boolean existsByEmail(String email);
/**
* 检查邮箱是否已存在(排除指定用户)
*/
@Query("SELECT COUNT(u) > 0 FROM User u WHERE u.email = :email AND u.id != :userId AND u.deletedAt IS NULL")
boolean existsByEmailAndNotUserId(@Param("email") String email, @Param("userId") Long userId);
/**
* 根据状态和创建时间范围查找用户
*/
@Query("SELECT u FROM User u WHERE u.status = :status AND u.createdAt BETWEEN :startTime AND :endTime AND u.deletedAt IS NULL")
List<User> findByStatusAndCreatedAtBetween(@Param("status") Byte status,
@Param("startTime") Long startTime,
@Param("endTime") Long endTime);
/**
* 统计正常状态的用户数量
*/
@Query("SELECT COUNT(u) FROM User u WHERE u.status = 1 AND u.deletedAt IS NULL")
long countActiveUsers();
/**
* 统计今日注册用户数量
*/
@Query("SELECT COUNT(u) FROM User u WHERE u.createdAt >= :todayStart AND u.deletedAt IS NULL")
long countTodayRegistrations(@Param("todayStart") Long todayStart);
/**
* 更新用户最后登录时间
*/
@Modifying
@Query("UPDATE User u SET u.lastLoginTime = :loginTime, u.updatedAt = :updateTime WHERE u.id = :userId")
void updateLastLoginTime(@Param("userId") Long userId,
@Param("loginTime") Long loginTime,
@Param("updateTime") Long updateTime);
/**
* 软删除用户
*/
@Modifying
@Query("UPDATE User u SET u.deletedAt = :deletedAt, u.status = 3, u.updatedAt = :updateTime WHERE u.id = :userId")
void softDeleteUser(@Param("userId") Long userId,
@Param("deletedAt") Long deletedAt,
@Param("updateTime") Long updateTime);
/**
* 批量更新用户状态
*/
@Modifying
@Query("UPDATE User u SET u.status = :status, u.updatedAt = :updateTime WHERE u.id IN :userIds")
void updateStatusByIds(@Param("userIds") List<Long> userIds,
@Param("status") Byte status,
@Param("updateTime") Long updateTime);
/**
* 查找长期未登录的用户
*/
@Query("SELECT u FROM User u WHERE u.lastLoginTime < :threshold AND u.deletedAt IS NULL")
List<User> findInactiveUsers(@Param("threshold") Long threshold);
}

View File

@ -0,0 +1,243 @@
package com.lxy.hsend.service;
import com.lxy.hsend.common.ApiResponse;
import com.lxy.hsend.common.ErrorCode;
import com.lxy.hsend.dto.favorite.AddFavoriteRequest;
import com.lxy.hsend.dto.favorite.FavoriteListRequest;
import com.lxy.hsend.dto.favorite.FavoriteResponse;
import com.lxy.hsend.dto.favorite.RemoveFavoriteRequest;
import com.lxy.hsend.entity.Favorite;
import com.lxy.hsend.entity.Message;
import com.lxy.hsend.entity.Session;
import com.lxy.hsend.entity.User;
import com.lxy.hsend.exception.BusinessException;
import com.lxy.hsend.repository.FavoriteRepository;
import com.lxy.hsend.repository.MessageRepository;
import com.lxy.hsend.repository.SessionRepository;
import com.lxy.hsend.repository.UserRepository;
import com.lxy.hsend.util.PageUtil;
import com.lxy.hsend.util.TimeUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* 收藏服务
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class FavoriteService {
private final FavoriteRepository favoriteRepository;
private final MessageRepository messageRepository;
private final SessionRepository sessionRepository;
private final UserRepository userRepository;
/**
* 用户收藏数量上限
*/
private static final long MAX_FAVORITES_PER_USER = 1000;
/**
* 添加收藏
*/
@Transactional
public ApiResponse<FavoriteResponse> addFavorite(Long userId, AddFavoriteRequest request) {
// 1. 验证用户存在
User user = userRepository.findByIdAndDeletedAtIsNull(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
// 2. 验证消息存在且属于用户
Message message = messageRepository.findByMessageIdAndNotDeleted(request.getMessageId())
.orElseThrow(() -> new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "消息不存在"));
if (!message.getUserId().equals(userId)) {
throw new BusinessException(ErrorCode.FORBIDDEN_ERROR, "无权限收藏此消息");
}
// 3. 验证会话存在且属于用户
Session session = sessionRepository.findBySessionIdAndUserIdAndNotDeleted(request.getSessionId(), userId)
.orElseThrow(() -> new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "会话不存在"));
// 4. 检查是否已经收藏
if (favoriteRepository.existsByUserIdAndMessageIdAndNotDeleted(userId, request.getMessageId())) {
throw new BusinessException(ErrorCode.BUSINESS_ERROR, "该消息已经收藏过了");
}
// 5. 检查用户收藏数量是否超过限制
long userFavoriteCount = favoriteRepository.countByUserIdAndNotDeleted(userId);
if (userFavoriteCount >= MAX_FAVORITES_PER_USER) {
throw new BusinessException(ErrorCode.BUSINESS_ERROR, "收藏数量已达上限(" + MAX_FAVORITES_PER_USER + "条)");
}
// 6. 创建收藏记录
Favorite favorite = createFavorite(userId, request.getMessageId(), request.getSessionId());
Favorite savedFavorite = favoriteRepository.save(favorite);
log.info("用户添加收藏成功: userId={}, messageId={}, favoriteId={}",
userId, request.getMessageId(), savedFavorite.getFavoriteId());
// 7. 转换为响应DTO
FavoriteResponse response = convertToFavoriteResponse(savedFavorite, message, session);
return ApiResponse.success(response, "收藏成功");
}
/**
* 取消收藏
*/
@Transactional
public ApiResponse<Void> removeFavorite(Long userId, RemoveFavoriteRequest request) {
// 1. 验证用户存在
User user = userRepository.findByIdAndDeletedAtIsNull(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
// 2. 查找收藏记录
Favorite favorite = favoriteRepository.findByUserIdAndMessageIdAndNotDeleted(userId, request.getMessageId())
.orElseThrow(() -> new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "收藏记录不存在"));
// 3. 软删除收藏记录
Long currentTime = TimeUtil.getCurrentTimestamp();
favoriteRepository.softDeleteByUserIdAndMessageId(userId, request.getMessageId(), currentTime, currentTime);
log.info("用户取消收藏成功: userId={}, messageId={}, favoriteId={}",
userId, request.getMessageId(), favorite.getFavoriteId());
return ApiResponse.success(null, "取消收藏成功");
}
/**
* 获取收藏列表
*/
public ApiResponse<PageUtil.PageResponse<FavoriteResponse>> getFavoriteList(Long userId, FavoriteListRequest request) {
// 1. 验证用户存在
User user = userRepository.findByIdAndDeletedAtIsNull(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
// 2. 构建分页参数
Pageable pageable = PageRequest.of(request.getPage() - 1, request.getPageSize());
// 3. 查询收藏列表
Page<Favorite> favoritePage;
if (StringUtils.hasText(request.getKeyword())) {
// 有关键词搜索
if ("all".equals(request.getMessageRole())) {
favoritePage = favoriteRepository.findByUserIdAndKeywordAndNotDeleted(userId, request.getKeyword(), pageable);
} else {
favoritePage = favoriteRepository.findByUserIdAndKeywordAndRoleAndNotDeleted(
userId, request.getKeyword(), request.getMessageRole(), pageable);
}
} else {
// 无关键词,查询所有收藏
favoritePage = favoriteRepository.findByUserIdAndNotDeleted(userId, pageable);
}
// 4. 转换为响应DTO列表
List<FavoriteResponse> favoriteResponses = favoritePage.getContent().stream()
.map(this::convertToFavoriteResponseWithLookup)
.collect(Collectors.toList());
// 5. 构建分页响应
PageUtil.PageResponse<FavoriteResponse> pageResponse = new PageUtil.PageResponse<>(
favoriteResponses,
request.getPage(),
request.getPageSize(),
favoritePage.getTotalElements(),
favoritePage.getTotalPages(),
favoritePage.isFirst(),
favoritePage.isLast(),
favoritePage.hasNext(),
favoritePage.hasPrevious()
);
return ApiResponse.success(pageResponse);
}
/**
* 检查消息是否被收藏
*/
public ApiResponse<Boolean> isFavorited(Long userId, String messageId) {
boolean isFavorited = favoriteRepository.existsByUserIdAndMessageIdAndNotDeleted(userId, messageId);
return ApiResponse.success(isFavorited);
}
/**
* 获取用户收藏数量
*/
public ApiResponse<Long> getUserFavoriteCount(Long userId) {
// 1. 验证用户存在
User user = userRepository.findByIdAndDeletedAtIsNull(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
// 2. 统计收藏数量
long count = favoriteRepository.countByUserIdAndNotDeleted(userId);
return ApiResponse.success(count);
}
/**
* 创建收藏对象
*/
private Favorite createFavorite(Long userId, String messageId, String sessionId) {
Favorite favorite = new Favorite();
favorite.setFavoriteId(UUID.randomUUID().toString());
favorite.setUserId(userId);
favorite.setMessageId(messageId);
favorite.setSessionId(sessionId);
Long currentTime = TimeUtil.getCurrentTimestamp();
favorite.setCreatedAt(currentTime);
favorite.setUpdatedAt(currentTime);
return favorite;
}
/**
* 转换为收藏响应DTO使用已有的消息和会话对象
*/
private FavoriteResponse convertToFavoriteResponse(Favorite favorite, Message message, Session session) {
return FavoriteResponse.builder()
.favoriteId(favorite.getFavoriteId())
.messageId(favorite.getMessageId())
.sessionId(favorite.getSessionId())
.sessionTitle(session.getTitle())
.messageContent(message.getContent())
.messageRole(message.getRole())
.messageCreatedAt(TimeUtil.timestampToLocalDateTime(message.getCreatedAt()))
.favoritedAt(TimeUtil.timestampToLocalDateTime(favorite.getCreatedAt()))
.build();
}
/**
* 转换为收藏响应DTO需要查询消息和会话信息
*/
private FavoriteResponse convertToFavoriteResponseWithLookup(Favorite favorite) {
// 查询消息信息
Message message = messageRepository.findByMessageIdAndNotDeleted(favorite.getMessageId())
.orElse(null);
// 查询会话信息
Session session = sessionRepository.findBySessionIdAndNotDeleted(favorite.getSessionId())
.orElse(null);
return FavoriteResponse.builder()
.favoriteId(favorite.getFavoriteId())
.messageId(favorite.getMessageId())
.sessionId(favorite.getSessionId())
.sessionTitle(session != null ? session.getTitle() : "未知会话")
.messageContent(message != null ? message.getContent() : "消息已删除")
.messageRole(message != null ? message.getRole() : "unknown")
.messageCreatedAt(message != null ? TimeUtil.timestampToLocalDateTime(message.getCreatedAt()) : null)
.favoritedAt(TimeUtil.timestampToLocalDateTime(favorite.getCreatedAt()))
.build();
}
}

View File

@ -0,0 +1,244 @@
package com.lxy.hsend.service;
import com.lxy.hsend.common.ApiResponse;
import com.lxy.hsend.common.ErrorCode;
import com.lxy.hsend.dto.message.MessageListRequest;
import com.lxy.hsend.dto.message.MessageResponse;
import com.lxy.hsend.dto.message.SendMessageRequest;
import com.lxy.hsend.entity.Message;
import com.lxy.hsend.entity.Session;
import com.lxy.hsend.entity.User;
import com.lxy.hsend.exception.BusinessException;
import com.lxy.hsend.repository.MessageRepository;
import com.lxy.hsend.repository.SessionRepository;
import com.lxy.hsend.repository.UserRepository;
import com.lxy.hsend.util.PageUtil;
import com.lxy.hsend.util.TimeUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* 消息服务
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class MessageService {
private final MessageRepository messageRepository;
private final SessionRepository sessionRepository;
private final UserRepository userRepository;
/**
* 发送消息
*/
@Transactional
public ApiResponse<MessageResponse> sendMessage(Long userId, SendMessageRequest request) {
// 1. 验证用户
User user = userRepository.findByIdAndDeletedAtIsNull(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
// 2. 验证会话
Session session = sessionRepository.findBySessionIdAndUserIdAndNotDeleted(request.getSessionId(), userId)
.orElseThrow(() -> new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "会话不存在"));
// 3. 内容过滤和验证
String filteredContent = filterMessageContent(request.getContent());
if (filteredContent.trim().isEmpty()) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "消息内容不能为空");
}
// 4. 创建用户消息
Message userMessage = createMessage(
userId,
request.getSessionId(),
"user",
filteredContent,
request.getDeepThinking(),
request.getWebSearch()
);
// 5. 保存用户消息
Message savedUserMessage = messageRepository.save(userMessage);
log.info("用户消息已保存: userId={}, sessionId={}, messageId={}",
userId, request.getSessionId(), savedUserMessage.getMessageId());
// 6. 更新会话统计
updateSessionStatistics(session, savedUserMessage);
// 7. 预留AI响应生成暂时返回占位响应
// TODO: 集成AI服务生成响应
Message aiMessage = createAIPlaceholderMessage(userId, request.getSessionId(), savedUserMessage);
Message savedAiMessage = messageRepository.save(aiMessage);
// 8. 再次更新会话统计
updateSessionStatistics(session, savedAiMessage);
// 9. 返回AI消息响应
MessageResponse response = convertToMessageResponse(savedAiMessage);
return ApiResponse.success(response);
}
/**
* 获取消息列表
*/
public ApiResponse<PageUtil.PageResponse<MessageResponse>> getMessages(Long userId, MessageListRequest request) {
// 1. 验证用户
User user = userRepository.findByIdAndDeletedAtIsNull(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
// 2. 验证会话权限
Session session = sessionRepository.findBySessionIdAndUserIdAndNotDeleted(request.getSessionId(), userId)
.orElseThrow(() -> new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "会话不存在"));
// 3. 构建分页参数
Pageable pageable = PageRequest.of(request.getPage() - 1, request.getPageSize());
// 4. 查询消息
Page<Message> messagePage;
if ("all".equals(request.getRole())) {
messagePage = messageRepository.findBySessionIdAndNotDeleted(request.getSessionId(), pageable);
} else {
// 如果指定了角色过滤需要添加对应的repository方法
messagePage = messageRepository.findBySessionIdAndNotDeleted(request.getSessionId(), pageable);
}
// 5. 转换为响应DTO
List<MessageResponse> messageResponses = messagePage.getContent().stream()
.filter(message -> "all".equals(request.getRole()) || request.getRole().equals(message.getRole()))
.map(this::convertToMessageResponse)
.collect(Collectors.toList());
// 6. 构建分页响应
PageUtil.PageResponse<MessageResponse> pageResponse = new PageUtil.PageResponse<>(
messageResponses,
request.getPage(),
request.getPageSize(),
messagePage.getTotalElements(),
messagePage.getTotalPages(),
messagePage.isFirst(),
messagePage.isLast(),
messagePage.hasNext(),
messagePage.hasPrevious()
);
return ApiResponse.success(pageResponse);
}
/**
* 根据消息ID获取消息详情
*/
public ApiResponse<MessageResponse> getMessageById(Long userId, String messageId) {
// 1. 验证消息存在且属于用户
if (!messageRepository.existsByMessageIdAndUserIdAndNotDeleted(messageId, userId)) {
throw new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "消息不存在");
}
// 2. 获取消息
Message message = messageRepository.findByMessageIdAndNotDeleted(messageId)
.orElseThrow(() -> new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "消息不存在"));
// 3. 转换为响应DTO
MessageResponse response = convertToMessageResponse(message);
return ApiResponse.success(response);
}
/**
* 创建消息对象
*/
private Message createMessage(Long userId, String sessionId, String role, String content,
Boolean deepThinking, Boolean webSearch) {
Message message = new Message();
message.setMessageId(UUID.randomUUID().toString());
message.setSessionId(sessionId);
message.setUserId(userId);
message.setRole(role);
message.setContent(content);
// 消息状态通过其他字段表示无需单独status字段
message.setDeepThinking(deepThinking != null ? deepThinking : false);
message.setWebSearch(webSearch != null ? webSearch : false);
message.setIsFavorited(false);
Long currentTime = TimeUtil.getCurrentTimestamp();
message.setTimestamp(currentTime);
message.setCreatedAt(currentTime);
message.setUpdatedAt(currentTime);
return message;
}
/**
* 创建AI占位响应消息
*/
private Message createAIPlaceholderMessage(Long userId, String sessionId, Message userMessage) {
String aiContent = generateAIPlaceholderResponse(userMessage.getContent());
return createMessage(
userId,
sessionId,
"assistant",
aiContent,
userMessage.getDeepThinking(),
userMessage.getWebSearch()
);
}
/**
* 生成AI占位响应后续将被实际AI服务替换
*/
private String generateAIPlaceholderResponse(String userContent) {
return "感谢您的提问。这是一个临时响应实际的AI服务将在后续版本中集成。您的问题是" + userContent;
}
/**
* 消息内容过滤
*/
private String filterMessageContent(String content) {
if (content == null) {
return "";
}
// 基础过滤:去除首尾空格,限制长度
String filtered = content.trim();
// TODO: 添加更多过滤规则(敏感词、恶意内容等)
return filtered;
}
/**
* 更新会话统计
*/
private void updateSessionStatistics(Session session, Message message) {
session.setMessageCount(session.getMessageCount() + 1);
session.setLastMessageTime(message.getTimestamp());
session.setUpdatedAt(TimeUtil.getCurrentTimestamp());
sessionRepository.save(session);
}
/**
* 转换为消息响应DTO
*/
private MessageResponse convertToMessageResponse(Message message) {
return MessageResponse.builder()
.messageId(message.getMessageId())
.sessionId(message.getSessionId())
.role(message.getRole())
.content(message.getContent())
.status("completed")
.deepThinking(message.getDeepThinking())
.webSearch(message.getWebSearch())
.createdAt(TimeUtil.timestampToLocalDateTime(message.getCreatedAt()))
.updatedAt(TimeUtil.timestampToLocalDateTime(message.getUpdatedAt()))
.build();
}
}

View File

@ -0,0 +1,307 @@
package com.lxy.hsend.service;
import com.lxy.hsend.common.ErrorCode;
import com.lxy.hsend.dto.session.*;
import com.lxy.hsend.entity.Message;
import com.lxy.hsend.entity.Session;
import com.lxy.hsend.exception.BusinessException;
import com.lxy.hsend.repository.FavoriteRepository;
import com.lxy.hsend.repository.MessageRepository;
import com.lxy.hsend.repository.SessionRepository;
import com.lxy.hsend.util.StringUtil;
import com.lxy.hsend.util.TimeUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* 会话服务类
*
* @author lxy
*/
@Slf4j
@Service
@Transactional
public class SessionService {
private final SessionRepository sessionRepository;
private final MessageRepository messageRepository;
private final FavoriteRepository favoriteRepository;
@Autowired
public SessionService(SessionRepository sessionRepository,
MessageRepository messageRepository,
FavoriteRepository favoriteRepository) {
this.sessionRepository = sessionRepository;
this.messageRepository = messageRepository;
this.favoriteRepository = favoriteRepository;
}
/**
* 创建会话
*/
public SessionDetailResponse createSession(Long userId, CreateSessionRequest request) {
log.info("创建会话: userId={}, title={}", userId, request.getTitle());
// 创建会话实体
Session session = new Session();
session.setSessionId(UUID.randomUUID().toString());
session.setUserId(userId);
session.setTitle(request.getTitle());
session.setMessageCount(0);
long now = TimeUtil.getCurrentTimestamp();
session.setCreatedAt(now);
session.setUpdatedAt(now);
// 保存会话
Session savedSession = sessionRepository.save(session);
// 如果有初始消息,创建第一条消息
if (StringUtil.isNotEmpty(request.getInitialMessage())) {
Message initialMessage = new Message();
initialMessage.setSessionId(savedSession.getSessionId());
initialMessage.setUserId(userId);
initialMessage.setContent(request.getInitialMessage());
initialMessage.setRole("user");
initialMessage.setCreatedAt(now);
initialMessage.setUpdatedAt(now);
messageRepository.save(initialMessage);
// 更新会话统计
updateSessionStatistics(savedSession.getSessionId());
}
log.info("会话创建成功: sessionId={}", savedSession.getSessionId());
return convertToSessionDetailResponse(savedSession);
}
/**
* 获取会话列表(支持搜索和分页)
*/
@Transactional(readOnly = true)
public Page<SessionListResponse> getSessionList(Long userId, SessionSearchRequest request) {
log.debug("获取会话列表: userId={}, keyword={}, page={}, pageSize={}",
userId, request.getKeyword(), request.getPage(), request.getPageSize());
// 创建分页对象
Sort sort = createSort(request.getValidSortBy(), request.isDescOrder());
Pageable pageable = PageRequest.of(request.getPage() - 1, request.getPageSize(), sort);
Page<Session> sessionPage;
// 根据是否有关键词选择不同的查询方法
if (request.hasKeyword()) {
sessionPage = sessionRepository.findByUserIdAndKeywordAndNotDeleted(
userId, request.getCleanKeyword(), pageable);
} else {
sessionPage = sessionRepository.findByUserIdAndNotDeleted(userId, pageable);
}
// 转换为响应DTO
return sessionPage.map(this::convertToSessionListResponse);
}
/**
* 获取会话详情
*/
@Transactional(readOnly = true)
public SessionDetailResponse getSessionDetail(Long userId, String sessionId) {
log.debug("获取会话详情: userId={}, sessionId={}", userId, sessionId);
Session session = findSessionByIdAndUserId(sessionId, userId);
return convertToSessionDetailResponse(session);
}
/**
* 更新会话标题
*/
public SessionDetailResponse updateSession(Long userId, String sessionId, UpdateSessionRequest request) {
log.info("更新会话: userId={}, sessionId={}, newTitle={}", userId, sessionId, request.getTitle());
// 验证会话存在和权限
Session session = findSessionByIdAndUserId(sessionId, userId);
if (StringUtil.isNotEmpty(request.getTitle()) && !StringUtil.equals(session.getTitle(), request.getTitle())) {
long now = TimeUtil.getCurrentTimestamp();
sessionRepository.updateTitle(sessionId, request.getTitle(), now);
// 更新本地对象
session.setTitle(request.getTitle());
session.setUpdatedAt(now);
log.info("会话标题更新成功: sessionId={}", sessionId);
}
return convertToSessionDetailResponse(session);
}
/**
* 删除会话(软删除,级联删除消息和收藏)
*/
public void deleteSession(Long userId, String sessionId) {
log.info("删除会话: userId={}, sessionId={}", userId, sessionId);
// 验证会话存在和权限
findSessionByIdAndUserId(sessionId, userId);
long now = TimeUtil.getCurrentTimestamp();
// 软删除会话
sessionRepository.softDeleteBySessionId(sessionId, now, now);
// 软删除关联的消息
messageRepository.softDeleteBySessionId(sessionId, now, now);
// 删除收藏记录
favoriteRepository.deleteBySessionId(sessionId);
log.info("会话删除成功: sessionId={}", sessionId);
}
/**
* 清空会话(只删除消息,保留会话)
*/
public void clearSession(Long userId, String sessionId) {
log.info("清空会话: userId={}, sessionId={}", userId, sessionId);
// 验证会话存在和权限
findSessionByIdAndUserId(sessionId, userId);
long now = TimeUtil.getCurrentTimestamp();
// 软删除关联的消息
messageRepository.softDeleteBySessionId(sessionId, now, now);
// 删除收藏记录
favoriteRepository.deleteBySessionId(sessionId);
// 重置会话统计
sessionRepository.updateMessageStatistics(sessionId, 0, null, now);
log.info("会话清空成功: sessionId={}", sessionId);
}
/**
* 更新会话统计信息
*/
public void updateSessionStatistics(String sessionId) {
log.debug("更新会话统计: sessionId={}", sessionId);
// 获取消息统计
long messageCount = messageRepository.countBySessionIdAndNotDeleted(sessionId);
// 获取最后一条消息的时间
Long lastMessageTime = null;
if (messageCount > 0) {
Page<Message> lastMessagePage = messageRepository.findBySessionIdAndNotDeleted(
sessionId, PageRequest.of(0, 1, Sort.by(Sort.Direction.DESC, "createdAt")));
if (!lastMessagePage.isEmpty()) {
lastMessageTime = lastMessagePage.getContent().get(0).getCreatedAt();
}
}
// 更新统计信息
long now = TimeUtil.getCurrentTimestamp();
sessionRepository.updateMessageStatistics(sessionId, (int) messageCount, lastMessageTime, now);
log.debug("会话统计更新完成: sessionId={}, messageCount={}, lastMessageTime={}",
sessionId, messageCount, lastMessageTime);
}
/**
* 验证会话权限
*/
@Transactional(readOnly = true)
public boolean hasSessionPermission(Long userId, String sessionId) {
return sessionRepository.existsBySessionIdAndUserIdAndNotDeleted(sessionId, userId);
}
/**
* 查找用户的会话并验证权限
*/
private Session findSessionByIdAndUserId(String sessionId, Long userId) {
return sessionRepository.findBySessionIdAndUserIdAndNotDeleted(sessionId, userId)
.orElseThrow(() -> BusinessException.of(ErrorCode.RESOURCE_NOT_FOUND, "会话不存在或无访问权限"));
}
/**
* 创建排序对象
*/
private Sort createSort(String sortBy, boolean isDesc) {
Sort.Direction direction = isDesc ? Sort.Direction.DESC : Sort.Direction.ASC;
switch (sortBy) {
case "created_at":
return Sort.by(direction, "createdAt");
case "updated_at":
return Sort.by(direction, "updatedAt");
case "last_message_time":
default:
// 对于 last_message_time需要处理 NULL 值
if (isDesc) {
return Sort.by(Sort.Direction.DESC, "lastMessageTime")
.and(Sort.by(Sort.Direction.DESC, "createdAt"));
} else {
return Sort.by(Sort.Direction.ASC, "lastMessageTime")
.and(Sort.by(Sort.Direction.ASC, "createdAt"));
}
}
}
/**
* 转换为会话列表响应DTO
*/
private SessionListResponse convertToSessionListResponse(Session session) {
SessionListResponse response = new SessionListResponse();
response.setSessionId(session.getSessionId());
response.setTitle(session.getTitle());
response.setMessageCount(session.getMessageCount() != null ? session.getMessageCount() : 0);
response.setLastMessageTime(session.getLastMessageTime());
response.setCreatedAt(session.getCreatedAt());
response.setUpdatedAt(session.getUpdatedAt());
// 获取最后一条消息的内容摘要
if (session.getMessageCount() != null && session.getMessageCount() > 0) {
try {
Page<Message> lastMessagePage = messageRepository.findBySessionIdAndNotDeleted(
session.getSessionId(), PageRequest.of(0, 1, Sort.by(Sort.Direction.DESC, "createdAt")));
if (!lastMessagePage.isEmpty()) {
Message lastMessage = lastMessagePage.getContent().get(0);
response.setLastMessageContent(lastMessage.getContent());
}
} catch (Exception e) {
log.warn("获取最后消息内容失败: sessionId={}, error={}", session.getSessionId(), e.getMessage());
}
}
return response;
}
/**
* 转换为会话详情响应DTO
*/
private SessionDetailResponse convertToSessionDetailResponse(Session session) {
SessionDetailResponse response = new SessionDetailResponse();
response.setSessionId(session.getSessionId());
response.setUserId(session.getUserId());
response.setTitle(session.getTitle());
response.setMessageCount(session.getMessageCount() != null ? session.getMessageCount() : 0);
response.setLastMessageTime(session.getLastMessageTime());
response.setCreatedAt(session.getCreatedAt());
response.setUpdatedAt(session.getUpdatedAt());
return response;
}
}

View File

@ -0,0 +1,287 @@
package com.lxy.hsend.service;
import com.lxy.hsend.common.Constants;
import com.lxy.hsend.common.ErrorCode;
import com.lxy.hsend.dto.auth.LoginRequest;
import com.lxy.hsend.dto.auth.LoginResponse;
import com.lxy.hsend.dto.auth.RegisterRequest;
import com.lxy.hsend.dto.user.UpdateUserRequest;
import com.lxy.hsend.dto.user.UserInfoResponse;
import com.lxy.hsend.entity.User;
import com.lxy.hsend.exception.BusinessException;
import com.lxy.hsend.repository.UserRepository;
import com.lxy.hsend.util.CryptoUtil;
import com.lxy.hsend.util.JwtUtil;
import com.lxy.hsend.util.StringUtil;
import com.lxy.hsend.util.TimeUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.concurrent.TimeUnit;
/**
* 用户服务类
*
* @author lxy
*/
@Slf4j
@Service
@Transactional
public class UserService {
private final UserRepository userRepository;
private final JwtUtil jwtUtil;
private final RedisTemplate<String, Object> redisTemplate;
@Autowired
public UserService(UserRepository userRepository, JwtUtil jwtUtil, RedisTemplate<String, Object> redisTemplate) {
this.userRepository = userRepository;
this.jwtUtil = jwtUtil;
this.redisTemplate = redisTemplate;
}
/**
* 用户注册
*/
public UserInfoResponse register(RegisterRequest request) {
log.info("用户注册请求: {}", request.getEmail());
// 验证确认密码
if (!StringUtil.equals(request.getPassword(), request.getConfirmPassword())) {
throw BusinessException.of(ErrorCode.VALIDATION_ERROR, "两次输入的密码不一致");
}
// 转换邮箱为小写
String email = request.getEmail().toLowerCase();
// 检查邮箱是否已存在
if (userRepository.existsByEmailAndDeletedAtIsNull(email)) {
throw BusinessException.of(ErrorCode.USER_ALREADY_EXISTS, "该邮箱已被注册");
}
// 创建用户
User user = new User();
user.setEmail(email);
user.setPasswordHash(CryptoUtil.encryptPassword(request.getPassword()));
user.setNickname(request.getNickname());
user.setAvatarUrl(StringUtil.isEmpty(request.getAvatarUrl()) ? getDefaultAvatarUrl() : request.getAvatarUrl());
user.setStatus(Constants.USER_STATUS_NORMAL);
long now = TimeUtil.getCurrentTimestamp();
user.setCreatedAt(now);
user.setUpdatedAt(now);
// 保存用户
User savedUser = userRepository.save(user);
log.info("用户注册成功: userId={}, email={}", savedUser.getId(), savedUser.getEmail());
return convertToUserInfoResponse(savedUser);
}
/**
* 用户登录
*/
public LoginResponse login(LoginRequest request) {
String email = request.getEmail().toLowerCase();
log.info("用户登录请求: {}", email);
// 检查登录失败次数
checkLoginAttempts(email);
// 查找用户
User user = userRepository.findByEmailAndDeletedAtIsNull(email)
.orElseThrow(() -> BusinessException.of(ErrorCode.USER_NOT_FOUND, "用户不存在"));
// 检查用户状态
checkUserStatus(user);
// 验证密码
if (!CryptoUtil.verifyPassword(request.getPassword(), user.getPasswordHash())) {
// 记录登录失败
recordLoginFailure(email);
throw BusinessException.of(ErrorCode.PASSWORD_ERROR, "密码错误");
}
// 清除登录失败记录
clearLoginFailures(email);
// 更新最后登录时间
long now = TimeUtil.getCurrentTimestamp();
user.setLastLoginTime(now);
user.setUpdatedAt(now);
userRepository.save(user);
// 生成Token
String accessToken = jwtUtil.generateToken(user.getId(), user.getEmail());
String refreshToken = jwtUtil.generateRefreshToken(user.getId(), user.getEmail());
// 缓存用户信息到Redis
cacheUserInfo(user);
log.info("用户登录成功: userId={}, email={}", user.getId(), user.getEmail());
UserInfoResponse userInfo = convertToUserInfoResponse(user);
return new LoginResponse(accessToken, refreshToken, Constants.JWT_EXPIRATION_TIME / 1000, userInfo);
}
/**
* 用户登出
*/
public void logout(Long userId, String token) {
log.info("用户登出: userId={}", userId);
// 将token加入黑名单
jwtUtil.blacklistToken(token);
// 清除Redis中的用户信息缓存
String cacheKey = Constants.REDIS_USER_INFO_PREFIX + userId;
redisTemplate.delete(cacheKey);
log.info("用户登出成功: userId={}", userId);
}
/**
* 根据ID获取用户信息
*/
@Transactional(readOnly = true)
public UserInfoResponse getUserById(Long userId) {
// 先尝试从Redis获取
String cacheKey = Constants.REDIS_USER_INFO_PREFIX + userId;
UserInfoResponse cachedUser = (UserInfoResponse) redisTemplate.opsForValue().get(cacheKey);
if (cachedUser != null) {
return cachedUser;
}
// 从数据库获取
User user = userRepository.findByIdAndDeletedAtIsNull(userId)
.orElseThrow(() -> BusinessException.of(ErrorCode.USER_NOT_FOUND, "用户不存在"));
UserInfoResponse userInfo = convertToUserInfoResponse(user);
// 缓存到Redis
redisTemplate.opsForValue().set(cacheKey, userInfo, Constants.REDIS_USER_INFO_EXPIRE_TIME, TimeUnit.SECONDS);
return userInfo;
}
/**
* 更新用户信息
*/
public UserInfoResponse updateUser(Long userId, UpdateUserRequest request) {
log.info("更新用户信息: userId={}", userId);
User user = userRepository.findByIdAndDeletedAtIsNull(userId)
.orElseThrow(() -> BusinessException.of(ErrorCode.USER_NOT_FOUND, "用户不存在"));
// 更新字段
boolean updated = false;
if (StringUtil.isNotEmpty(request.getNickname()) && !StringUtil.equals(user.getNickname(), request.getNickname())) {
user.setNickname(request.getNickname());
updated = true;
}
if (StringUtil.isNotEmpty(request.getAvatarUrl()) && !StringUtil.equals(user.getAvatarUrl(), request.getAvatarUrl())) {
user.setAvatarUrl(request.getAvatarUrl());
updated = true;
}
if (updated) {
user.setUpdatedAt(TimeUtil.getCurrentTimestamp());
userRepository.save(user);
// 更新缓存
cacheUserInfo(user);
log.info("用户信息更新成功: userId={}", userId);
}
return convertToUserInfoResponse(user);
}
/**
* 检查邮箱是否已存在
*/
@Transactional(readOnly = true)
public boolean existsByEmail(String email) {
return userRepository.existsByEmailAndDeletedAtIsNull(email.toLowerCase());
}
/**
* 检查用户状态
*/
private void checkUserStatus(User user) {
if (user.getStatus() == Constants.USER_STATUS_LOCKED) {
throw BusinessException.of(ErrorCode.FORBIDDEN_ERROR, "账户已被锁定,请联系管理员");
}
if (user.getStatus() == Constants.USER_STATUS_DELETED) {
throw BusinessException.of(ErrorCode.USER_NOT_FOUND, "用户不存在");
}
}
/**
* 检查登录失败次数
*/
private void checkLoginAttempts(String email) {
String key = Constants.REDIS_RATE_LIMIT_PREFIX + "login_fail:" + email;
Integer attempts = (Integer) redisTemplate.opsForValue().get(key);
if (attempts != null && attempts >= 5) {
throw BusinessException.of(ErrorCode.RATE_LIMIT_ERROR, "登录失败次数过多,请稍后再试");
}
}
/**
* 记录登录失败
*/
private void recordLoginFailure(String email) {
String key = Constants.REDIS_RATE_LIMIT_PREFIX + "login_fail:" + email;
Integer attempts = (Integer) redisTemplate.opsForValue().get(key);
attempts = (attempts == null) ? 1 : attempts + 1;
redisTemplate.opsForValue().set(key, attempts, 15, TimeUnit.MINUTES);
log.warn("登录失败: email={}, 失败次数={}", email, attempts);
}
/**
* 清除登录失败记录
*/
private void clearLoginFailures(String email) {
String key = Constants.REDIS_RATE_LIMIT_PREFIX + "login_fail:" + email;
redisTemplate.delete(key);
}
/**
* 缓存用户信息到Redis
*/
private void cacheUserInfo(User user) {
String cacheKey = Constants.REDIS_USER_INFO_PREFIX + user.getId();
UserInfoResponse userInfo = convertToUserInfoResponse(user);
redisTemplate.opsForValue().set(cacheKey, userInfo, Constants.REDIS_USER_INFO_EXPIRE_TIME, TimeUnit.SECONDS);
}
/**
* 获取默认头像URL
*/
private String getDefaultAvatarUrl() {
// 可以配置默认头像列表,随机选择一个
return "https://via.placeholder.com/150x150?text=User";
}
/**
* 转换User实体为UserInfoResponse
*/
private UserInfoResponse convertToUserInfoResponse(User user) {
UserInfoResponse response = new UserInfoResponse();
response.setUserId(user.getId());
response.setEmail(user.getEmail());
response.setNickname(user.getNickname());
response.setAvatarUrl(user.getAvatarUrl());
response.setStatus(user.getStatus());
response.setLastLoginTime(user.getLastLoginTime());
response.setCreatedAt(user.getCreatedAt());
response.setUpdatedAt(user.getUpdatedAt());
return response;
}
}

View File

@ -0,0 +1,246 @@
package com.lxy.hsend.util;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.Base64;
/**
* 加密工具类
*
* @author lxy
*/
@Slf4j
public class CryptoUtil {
private static final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
private static final String AES_ALGORITHM = "AES";
private static final String AES_TRANSFORMATION = "AES/ECB/PKCS5Padding";
/**
* 使用BCrypt加密密码
*/
public static String encryptPassword(String plainPassword) {
return passwordEncoder.encode(plainPassword);
}
/**
* 验证密码
*/
public static boolean verifyPassword(String plainPassword, String encryptedPassword) {
return passwordEncoder.matches(plainPassword, encryptedPassword);
}
/**
* MD5加密
*/
public static String md5(String input) {
try {
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] messageDigest = md.digest(input.getBytes(StandardCharsets.UTF_8));
return bytesToHex(messageDigest);
} catch (NoSuchAlgorithmException e) {
log.error("MD5加密失败", e);
return null;
}
}
/**
* SHA-256加密
*/
public static String sha256(String input) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8));
return bytesToHex(hash);
} catch (NoSuchAlgorithmException e) {
log.error("SHA-256加密失败", e);
return null;
}
}
/**
* SHA-512加密
*/
public static String sha512(String input) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-512");
byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8));
return bytesToHex(hash);
} catch (NoSuchAlgorithmException e) {
log.error("SHA-512加密失败", e);
return null;
}
}
/**
* 生成AES密钥
*/
public static String generateAESKey() {
try {
KeyGenerator keyGenerator = KeyGenerator.getInstance(AES_ALGORITHM);
keyGenerator.init(256);
SecretKey secretKey = keyGenerator.generateKey();
return Base64.getEncoder().encodeToString(secretKey.getEncoded());
} catch (NoSuchAlgorithmException e) {
log.error("生成AES密钥失败", e);
return null;
}
}
/**
* AES加密
*/
public static String aesEncrypt(String plainText, String key) {
try {
SecretKeySpec secretKey = new SecretKeySpec(Base64.getDecoder().decode(key), AES_ALGORITHM);
Cipher cipher = Cipher.getInstance(AES_TRANSFORMATION);
cipher.init(Cipher.ENCRYPT_MODE, secretKey);
byte[] encrypted = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(encrypted);
} catch (Exception e) {
log.error("AES加密失败", e);
return null;
}
}
/**
* AES解密
*/
public static String aesDecrypt(String encryptedText, String key) {
try {
SecretKeySpec secretKey = new SecretKeySpec(Base64.getDecoder().decode(key), AES_ALGORITHM);
Cipher cipher = Cipher.getInstance(AES_TRANSFORMATION);
cipher.init(Cipher.DECRYPT_MODE, secretKey);
byte[] decrypted = cipher.doFinal(Base64.getDecoder().decode(encryptedText));
return new String(decrypted, StandardCharsets.UTF_8);
} catch (Exception e) {
log.error("AES解密失败", e);
return null;
}
}
/**
* Base64编码
*/
public static String base64Encode(String input) {
return Base64.getEncoder().encodeToString(input.getBytes(StandardCharsets.UTF_8));
}
/**
* Base64解码
*/
public static String base64Decode(String encodedInput) {
try {
byte[] decodedBytes = Base64.getDecoder().decode(encodedInput);
return new String(decodedBytes, StandardCharsets.UTF_8);
} catch (Exception e) {
log.error("Base64解码失败", e);
return null;
}
}
/**
* 生成随机盐值
*/
public static String generateSalt() {
SecureRandom random = new SecureRandom();
byte[] salt = new byte[16];
random.nextBytes(salt);
return bytesToHex(salt);
}
/**
* 带盐值的哈希
*/
public static String hashWithSalt(String input, String salt) {
return sha256(input + salt);
}
/**
* 验证带盐值的哈希
*/
public static boolean verifyHashWithSalt(String input, String salt, String hash) {
String computedHash = hashWithSalt(input, salt);
return StringUtil.equals(computedHash, hash);
}
/**
* 字节数组转十六进制字符串
*/
private static String bytesToHex(byte[] bytes) {
StringBuilder result = new StringBuilder();
for (byte b : bytes) {
result.append(String.format("%02x", b));
}
return result.toString();
}
/**
* 十六进制字符串转字节数组
*/
public static byte[] hexToBytes(String hex) {
int len = hex.length();
byte[] data = new byte[len / 2];
for (int i = 0; i < len; i += 2) {
data[i / 2] = (byte) ((Character.digit(hex.charAt(i), 16) << 4)
+ Character.digit(hex.charAt(i + 1), 16));
}
return data;
}
/**
* 生成安全的随机数
*/
public static String generateSecureRandomString(int length) {
SecureRandom random = new SecureRandom();
StringBuilder sb = new StringBuilder();
String chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
for (int i = 0; i < length; i++) {
sb.append(chars.charAt(random.nextInt(chars.length())));
}
return sb.toString();
}
/**
* 简单的异或加密/解密
*/
public static String xorEncrypt(String input, String key) {
StringBuilder result = new StringBuilder();
for (int i = 0; i < input.length(); i++) {
result.append((char) (input.charAt(i) ^ key.charAt(i % key.length())));
}
return Base64.getEncoder().encodeToString(result.toString().getBytes(StandardCharsets.UTF_8));
}
/**
* 异或解密
*/
public static String xorDecrypt(String encryptedInput, String key) {
try {
String decoded = new String(Base64.getDecoder().decode(encryptedInput), StandardCharsets.UTF_8);
StringBuilder result = new StringBuilder();
for (int i = 0; i < decoded.length(); i++) {
result.append((char) (decoded.charAt(i) ^ key.charAt(i % key.length())));
}
return result.toString();
} catch (Exception e) {
log.error("异或解密失败", e);
return null;
}
}
private CryptoUtil() {
// 工具类不允许实例化
}
}

View File

@ -0,0 +1,237 @@
package com.lxy.hsend.util;
import com.lxy.hsend.common.Constants;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
/**
* JWT工具类
*
* @author lxy
*/
@Slf4j
@Component
public class JwtUtil {
private final SecretKey secretKey;
private final RedisTemplate<String, Object> redisTemplate;
@Autowired
public JwtUtil(RedisTemplate<String, Object> redisTemplate) {
this.redisTemplate = redisTemplate;
this.secretKey = Keys.hmacShaKeyFor(Constants.JWT_SECRET_KEY.getBytes());
}
/**
* 生成JWT Token
*/
public String generateToken(Long userId, String email) {
Map<String, Object> claims = new HashMap<>();
claims.put("userId", userId);
claims.put("email", email);
claims.put("type", "access");
return createToken(claims, userId.toString(), Constants.JWT_EXPIRATION_TIME);
}
/**
* 生成刷新Token
*/
public String generateRefreshToken(Long userId, String email) {
Map<String, Object> claims = new HashMap<>();
claims.put("userId", userId);
claims.put("email", email);
claims.put("type", "refresh");
return createToken(claims, userId.toString(), Constants.JWT_REFRESH_TIME);
}
/**
* 创建Token
*/
private String createToken(Map<String, Object> claims, String subject, long expiration) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + expiration);
return Jwts.builder()
.setClaims(claims)
.setSubject(subject)
.setIssuedAt(now)
.setExpiration(expiryDate)
.signWith(secretKey, SignatureAlgorithm.HS256)
.compact();
}
/**
* 验证Token
*/
public boolean validateToken(String token) {
try {
// 检查是否在黑名单中
if (isTokenBlacklisted(token)) {
log.warn("Token已被加入黑名单: {}", token);
return false;
}
Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token);
return true;
} catch (SecurityException e) {
log.error("JWT签名无效: {}", e.getMessage());
} catch (MalformedJwtException e) {
log.error("JWT格式错误: {}", e.getMessage());
} catch (ExpiredJwtException e) {
log.warn("JWT已过期: {}", e.getMessage());
} catch (UnsupportedJwtException e) {
log.error("不支持的JWT: {}", e.getMessage());
} catch (IllegalArgumentException e) {
log.error("JWT为空: {}", e.getMessage());
}
return false;
}
/**
* 从Token中获取用户ID
*/
public Long getUserIdFromToken(String token) {
Claims claims = getClaimsFromToken(token);
return claims != null ? claims.get("userId", Long.class) : null;
}
/**
* 从Token中获取邮箱
*/
public String getEmailFromToken(String token) {
Claims claims = getClaimsFromToken(token);
return claims != null ? claims.get("email", String.class) : null;
}
/**
* 从Token中获取Token类型
*/
public String getTokenTypeFromToken(String token) {
Claims claims = getClaimsFromToken(token);
return claims != null ? claims.get("type", String.class) : null;
}
/**
* 获取Token过期时间
*/
public Date getExpirationDateFromToken(String token) {
Claims claims = getClaimsFromToken(token);
return claims != null ? claims.getExpiration() : null;
}
/**
* 检查Token是否过期
*/
public boolean isTokenExpired(String token) {
Date expiration = getExpirationDateFromToken(token);
return expiration != null && expiration.before(new Date());
}
/**
* 从Token中获取Claims
*/
private Claims getClaimsFromToken(String token) {
try {
return Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload();
} catch (Exception e) {
log.error("解析Token失败: {}", e.getMessage());
return null;
}
}
/**
* 将Token加入黑名单
*/
public void blacklistToken(String token) {
try {
Date expiration = getExpirationDateFromToken(token);
if (expiration != null) {
String key = Constants.REDIS_JWT_BLACKLIST_PREFIX + token;
long ttl = expiration.getTime() - System.currentTimeMillis();
if (ttl > 0) {
redisTemplate.opsForValue().set(key, "blacklisted", ttl, TimeUnit.MILLISECONDS);
log.info("Token已加入黑名单: {}", token);
}
}
} catch (Exception e) {
log.error("将Token加入黑名单失败: {}", e.getMessage());
}
}
/**
* 检查Token是否在黑名单中
*/
public boolean isTokenBlacklisted(String token) {
try {
String key = Constants.REDIS_JWT_BLACKLIST_PREFIX + token;
return Boolean.TRUE.equals(redisTemplate.hasKey(key));
} catch (Exception e) {
log.error("检查Token黑名单状态失败: {}", e.getMessage());
return false;
}
}
/**
* 刷新Token
*/
public String refreshToken(String refreshToken) {
if (!validateToken(refreshToken)) {
throw new IllegalArgumentException("刷新Token无效");
}
String tokenType = getTokenTypeFromToken(refreshToken);
if (!"refresh".equals(tokenType)) {
throw new IllegalArgumentException("Token类型错误");
}
Long userId = getUserIdFromToken(refreshToken);
String email = getEmailFromToken(refreshToken);
if (userId == null || email == null) {
throw new IllegalArgumentException("Token信息不完整");
}
return generateToken(userId, email);
}
/**
* 从请求头中提取Token
*/
public String extractTokenFromHeader(String authHeader) {
if (authHeader != null && authHeader.startsWith(Constants.JWT_TOKEN_PREFIX)) {
return authHeader.substring(Constants.JWT_TOKEN_PREFIX.length());
}
return null;
}
/**
* 获取Token剩余有效时间
*/
public long getTokenRemainingTime(String token) {
Date expiration = getExpirationDateFromToken(token);
if (expiration != null) {
long remaining = expiration.getTime() - System.currentTimeMillis();
return Math.max(0, remaining / 1000);
}
return 0;
}
}

View File

@ -0,0 +1,243 @@
package com.lxy.hsend.util;
import com.lxy.hsend.common.Constants;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import java.util.List;
/**
* 分页工具类
*
* @author lxy
*/
public class PageUtil {
/**
* 创建分页对象
*/
public static Pageable createPageable(Integer page, Integer size) {
return createPageable(page, size, null);
}
/**
* 创建带排序的分页对象
*/
public static Pageable createPageable(Integer page, Integer size, Sort sort) {
page = validatePage(page);
size = validateSize(size);
if (sort != null) {
return PageRequest.of(page - 1, size, sort);
} else {
return PageRequest.of(page - 1, size);
}
}
/**
* 创建降序排序的分页对象
*/
public static Pageable createPageableDesc(Integer page, Integer size, String... properties) {
Sort sort = Sort.by(Sort.Direction.DESC, properties);
return createPageable(page, size, sort);
}
/**
* 创建升序排序的分页对象
*/
public static Pageable createPageableAsc(Integer page, Integer size, String... properties) {
Sort sort = Sort.by(Sort.Direction.ASC, properties);
return createPageable(page, size, sort);
}
/**
* 验证页码
*/
private static Integer validatePage(Integer page) {
if (page == null || page < 1) {
return Constants.DEFAULT_PAGE_NUM;
}
return page;
}
/**
* 验证页面大小
*/
private static Integer validateSize(Integer size) {
if (size == null || size < 1) {
return Constants.DEFAULT_PAGE_SIZE;
}
if (size > Constants.MAX_PAGE_SIZE) {
return Constants.MAX_PAGE_SIZE;
}
return size;
}
/**
* 将Spring Data的Page对象转换为自定义的分页响应对象
*/
public static <T> PageResponse<T> toPageResponse(Page<T> page) {
return new PageResponse<T>(
page.getContent(),
page.getNumber() + 1, // Spring Data的页码从0开始转换为从1开始
page.getSize(),
page.getTotalElements(),
page.getTotalPages(),
page.isFirst(),
page.isLast(),
page.hasNext(),
page.hasPrevious()
);
}
/**
* 创建空的分页响应
*/
public static <T> PageResponse<T> emptyPageResponse(Integer page, Integer size) {
page = validatePage(page);
size = validateSize(size);
return new PageResponse<T>(
List.of(),
page,
size,
0L,
0,
true,
true,
false,
false
);
}
/**
* 分页响应对象
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class PageResponse<T> {
/**
* 数据列表
*/
private List<T> content;
/**
* 当前页码从1开始
*/
private Integer page;
/**
* 页面大小
*/
private Integer size;
/**
* 总记录数
*/
private Long total;
/**
* 总页数
*/
private Integer totalPages;
/**
* 是否是第一页
*/
private Boolean first;
/**
* 是否是最后一页
*/
private Boolean last;
/**
* 是否有下一页
*/
private Boolean hasNext;
/**
* 是否有上一页
*/
private Boolean hasPrevious;
}
/**
* 分页查询参数
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class PageQuery {
/**
* 页码从1开始
*/
private Integer page = Constants.DEFAULT_PAGE_NUM;
/**
* 页面大小
*/
private Integer size = Constants.DEFAULT_PAGE_SIZE;
/**
* 排序字段
*/
private String sortBy;
/**
* 排序方向asc/desc
*/
private String sortDir = "desc";
/**
* 获取Pageable对象
*/
public Pageable toPageable() {
if (StringUtil.isNotEmpty(sortBy)) {
Sort.Direction direction = "asc".equalsIgnoreCase(sortDir) ?
Sort.Direction.ASC : Sort.Direction.DESC;
Sort sort = Sort.by(direction, sortBy);
return PageUtil.createPageable(page, size, sort);
} else {
return PageUtil.createPageable(page, size);
}
}
}
/**
* 计算偏移量
*/
public static long calculateOffset(Integer page, Integer size) {
page = validatePage(page);
size = validateSize(size);
return (long) (page - 1) * size;
}
/**
* 计算总页数
*/
public static int calculateTotalPages(long totalElements, int size) {
return (int) Math.ceil((double) totalElements / size);
}
/**
* 检查页码是否有效
*/
public static boolean isValidPage(Integer page, long totalElements, Integer size) {
if (page == null || page < 1) {
return false;
}
size = validateSize(size);
int totalPages = calculateTotalPages(totalElements, size);
return page <= totalPages;
}
private PageUtil() {
// 工具类不允许实例化
}
}

View File

@ -0,0 +1,276 @@
package com.lxy.hsend.util;
import com.lxy.hsend.common.Constants;
import java.util.UUID;
import java.util.regex.Pattern;
/**
* 字符串工具类
*
* @author lxy
*/
public class StringUtil {
private static final Pattern EMAIL_PATTERN = Pattern.compile(Constants.EMAIL_REGEX);
private static final Pattern PASSWORD_PATTERN = Pattern.compile(Constants.PASSWORD_REGEX);
/**
* 检查字符串是否为空或null
*/
public static boolean isEmpty(String str) {
return str == null || str.trim().isEmpty();
}
/**
* 检查字符串是否不为空
*/
public static boolean isNotEmpty(String str) {
return !isEmpty(str);
}
/**
* 安全的字符串比较(避免空指针异常)
*/
public static boolean equals(String str1, String str2) {
if (str1 == null && str2 == null) {
return true;
}
if (str1 == null || str2 == null) {
return false;
}
return str1.equals(str2);
}
/**
* 安全的字符串比较(忽略大小写)
*/
public static boolean equalsIgnoreCase(String str1, String str2) {
if (str1 == null && str2 == null) {
return true;
}
if (str1 == null || str2 == null) {
return false;
}
return str1.equalsIgnoreCase(str2);
}
/**
* 生成UUID字符串去掉连字符
*/
public static String generateUuid() {
return UUID.randomUUID().toString().replace("-", "");
}
/**
* 生成标准UUID字符串带连字符
*/
public static String generateStandardUuid() {
return UUID.randomUUID().toString();
}
/**
* 验证邮箱格式
*/
public static boolean isValidEmail(String email) {
return isNotEmpty(email) && EMAIL_PATTERN.matcher(email).matches();
}
/**
* 验证密码格式
*/
public static boolean isValidPassword(String password) {
return isNotEmpty(password) && PASSWORD_PATTERN.matcher(password).matches();
}
/**
* 验证用户名长度
*/
public static boolean isValidNickname(String nickname) {
return isNotEmpty(nickname) &&
nickname.length() >= Constants.NICKNAME_MIN_LENGTH &&
nickname.length() <= Constants.NICKNAME_MAX_LENGTH;
}
/**
* 掩码邮箱地址(隐藏部分字符)
*/
public static String maskEmail(String email) {
if (isEmpty(email) || !email.contains("@")) {
return email;
}
String[] parts = email.split("@");
String username = parts[0];
String domain = parts[1];
if (username.length() <= 2) {
return email;
}
String maskedUsername = username.charAt(0) +
"*".repeat(username.length() - 2) +
username.charAt(username.length() - 1);
return maskedUsername + "@" + domain;
}
/**
* 掩码手机号码
*/
public static String maskPhoneNumber(String phoneNumber) {
if (isEmpty(phoneNumber) || phoneNumber.length() < 7) {
return phoneNumber;
}
return phoneNumber.substring(0, 3) +
"*".repeat(phoneNumber.length() - 6) +
phoneNumber.substring(phoneNumber.length() - 3);
}
/**
* 截断字符串到指定长度
*/
public static String truncate(String str, int maxLength) {
if (isEmpty(str) || str.length() <= maxLength) {
return str;
}
return str.substring(0, maxLength) + "...";
}
/**
* 移除字符串中的HTML标签
*/
public static String removeHtmlTags(String html) {
if (isEmpty(html)) {
return html;
}
return html.replaceAll("<[^>]+>", "");
}
/**
* 转义HTML特殊字符
*/
public static String escapeHtml(String str) {
if (isEmpty(str)) {
return str;
}
return str.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&#x27;");
}
/**
* 将字符串转换为驼峰命名
*/
public static String toCamelCase(String str) {
if (isEmpty(str)) {
return str;
}
StringBuilder result = new StringBuilder();
boolean capitalizeNext = false;
for (char c : str.toCharArray()) {
if (c == '_' || c == '-' || c == ' ') {
capitalizeNext = true;
} else if (capitalizeNext) {
result.append(Character.toUpperCase(c));
capitalizeNext = false;
} else {
result.append(Character.toLowerCase(c));
}
}
return result.toString();
}
/**
* 将驼峰命名转换为下划线命名
*/
public static String toSnakeCase(String str) {
if (isEmpty(str)) {
return str;
}
StringBuilder result = new StringBuilder();
for (int i = 0; i < str.length(); i++) {
char c = str.charAt(i);
if (Character.isUpperCase(c)) {
if (i > 0) {
result.append('_');
}
result.append(Character.toLowerCase(c));
} else {
result.append(c);
}
}
return result.toString();
}
/**
* 生成随机字符串
*/
public static String generateRandomString(int length) {
String chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
StringBuilder result = new StringBuilder();
for (int i = 0; i < length; i++) {
result.append(chars.charAt((int) (Math.random() * chars.length())));
}
return result.toString();
}
/**
* 生成随机数字字符串
*/
public static String generateRandomNumbers(int length) {
StringBuilder result = new StringBuilder();
for (int i = 0; i < length; i++) {
result.append((int) (Math.random() * 10));
}
return result.toString();
}
/**
* 统计字符串中的字数中文按1个字计算英文按0.5个字计算)
*/
public static double countWords(String str) {
if (isEmpty(str)) {
return 0;
}
double count = 0;
for (char c : str.toCharArray()) {
if (c >= 0x4e00 && c <= 0x9fa5) {
// 中文字符
count += 1;
} else if (Character.isLetter(c)) {
// 英文字符
count += 0.5;
}
}
return count;
}
/**
* 获取字符串的字节长度UTF-8编码
*/
public static int getByteLength(String str) {
if (isEmpty(str)) {
return 0;
}
return str.getBytes().length;
}
private StringUtil() {
// 工具类不允许实例化
}
}

View File

@ -0,0 +1,220 @@
package com.lxy.hsend.util;
import com.lxy.hsend.common.Constants;
import java.time.*;
import java.time.format.DateTimeFormatter;
import java.util.Date;
/**
* 时间工具类
*
* @author lxy
*/
public class TimeUtil {
private static final ZoneId ZONE_ID = ZoneId.of(Constants.TIMEZONE);
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern(Constants.DATE_FORMAT);
private static final DateTimeFormatter DATETIME_FORMATTER = DateTimeFormatter.ofPattern(Constants.DATETIME_FORMAT);
/**
* 获取当前时间戳(毫秒)
*/
public static long getCurrentTimestamp() {
return System.currentTimeMillis();
}
/**
* 获取当前时间戳(秒)
*/
public static long getCurrentTimestampSeconds() {
return System.currentTimeMillis() / 1000;
}
/**
* 获取当前日期时间
*/
public static LocalDateTime getCurrentDateTime() {
return LocalDateTime.now(ZONE_ID);
}
/**
* 获取当前日期
*/
public static LocalDate getCurrentDate() {
return LocalDate.now(ZONE_ID);
}
/**
* 时间戳转LocalDateTime
*/
public static LocalDateTime timestampToLocalDateTime(long timestamp) {
return LocalDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZONE_ID);
}
/**
* LocalDateTime转时间戳
*/
public static long localDateTimeToTimestamp(LocalDateTime dateTime) {
return dateTime.atZone(ZONE_ID).toInstant().toEpochMilli();
}
/**
* 时间戳转Date
*/
public static Date timestampToDate(long timestamp) {
return new Date(timestamp);
}
/**
* Date转时间戳
*/
public static long dateToTimestamp(Date date) {
return date.getTime();
}
/**
* 格式化日期时间
*/
public static String formatDateTime(LocalDateTime dateTime) {
return dateTime.format(DATETIME_FORMATTER);
}
/**
* 格式化日期
*/
public static String formatDate(LocalDate date) {
return date.format(DATE_FORMATTER);
}
/**
* 格式化时间戳为日期时间字符串
*/
public static String formatTimestamp(long timestamp) {
return formatDateTime(timestampToLocalDateTime(timestamp));
}
/**
* 解析日期时间字符串
*/
public static LocalDateTime parseDateTime(String dateTimeStr) {
return LocalDateTime.parse(dateTimeStr, DATETIME_FORMATTER);
}
/**
* 解析日期字符串
*/
public static LocalDate parseDate(String dateStr) {
return LocalDate.parse(dateStr, DATE_FORMATTER);
}
/**
* 计算两个时间戳之间的天数差
*/
public static long daysBetween(long timestamp1, long timestamp2) {
LocalDate date1 = timestampToLocalDateTime(timestamp1).toLocalDate();
LocalDate date2 = timestampToLocalDateTime(timestamp2).toLocalDate();
return Duration.between(date1.atStartOfDay(), date2.atStartOfDay()).toDays();
}
/**
* 计算两个时间戳之间的小时差
*/
public static long hoursBetween(long timestamp1, long timestamp2) {
return (timestamp2 - timestamp1) / (1000 * 60 * 60);
}
/**
* 计算两个时间戳之间的分钟差
*/
public static long minutesBetween(long timestamp1, long timestamp2) {
return (timestamp2 - timestamp1) / (1000 * 60);
}
/**
* 计算两个时间戳之间的秒数差
*/
public static long secondsBetween(long timestamp1, long timestamp2) {
return (timestamp2 - timestamp1) / 1000;
}
/**
* 获取今天开始时间戳
*/
public static long getTodayStartTimestamp() {
LocalDateTime startOfDay = getCurrentDate().atStartOfDay();
return localDateTimeToTimestamp(startOfDay);
}
/**
* 获取今天结束时间戳
*/
public static long getTodayEndTimestamp() {
LocalDateTime endOfDay = getCurrentDate().atTime(23, 59, 59, 999_000_000);
return localDateTimeToTimestamp(endOfDay);
}
/**
* 获取指定天数前的时间戳
*/
public static long getDaysAgoTimestamp(int days) {
LocalDateTime daysAgo = getCurrentDateTime().minusDays(days);
return localDateTimeToTimestamp(daysAgo);
}
/**
* 获取指定小时前的时间戳
*/
public static long getHoursAgoTimestamp(int hours) {
LocalDateTime hoursAgo = getCurrentDateTime().minusHours(hours);
return localDateTimeToTimestamp(hoursAgo);
}
/**
* 获取指定分钟前的时间戳
*/
public static long getMinutesAgoTimestamp(int minutes) {
LocalDateTime minutesAgo = getCurrentDateTime().minusMinutes(minutes);
return localDateTimeToTimestamp(minutesAgo);
}
/**
* 检查时间戳是否是今天
*/
public static boolean isToday(long timestamp) {
LocalDate date = timestampToLocalDateTime(timestamp).toLocalDate();
return date.equals(getCurrentDate());
}
/**
* 检查时间戳是否是昨天
*/
public static boolean isYesterday(long timestamp) {
LocalDate date = timestampToLocalDateTime(timestamp).toLocalDate();
return date.equals(getCurrentDate().minusDays(1));
}
/**
* 获取友好的时间显示刚刚、5分钟前、1小时前等
*/
public static String getFriendlyTime(long timestamp) {
long now = getCurrentTimestamp();
long diff = now - timestamp;
if (diff < 60 * 1000) {
return "刚刚";
} else if (diff < 60 * 60 * 1000) {
return (diff / (60 * 1000)) + "分钟前";
} else if (diff < 24 * 60 * 60 * 1000) {
return (diff / (60 * 60 * 1000)) + "小时前";
} else if (diff < 30L * 24 * 60 * 60 * 1000) {
return (diff / (24 * 60 * 60 * 1000)) + "天前";
} else {
return formatTimestamp(timestamp);
}
}
private TimeUtil() {
// 工具类不允许实例化
}
}

View File

@ -0,0 +1,61 @@
# 开发环境配置
spring:
# 数据库配置
datasource:
url: jdbc:mysql://localhost:3306/hsai?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: root
password: root
# JPA配置
jpa:
hibernate:
ddl-auto: validate
show-sql: true
# Flyway配置
flyway:
enabled: true
properties:
hibernate:
format_sql: true
# Redis配置
data:
redis:
host: localhost
port: 6379
database: 0
# 服务器配置
server:
port: 8080
# 日志配置
logging:
level:
com.lxy.hsend: debug
org.springframework.security: debug
org.hibernate.SQL: debug
org.hibernate.type.descriptor.sql.BasicBinder: trace
# JWT配置开发环境使用简单密钥
jwt:
secret: hsendDevSecretKey2024
expiration: 86400 # 1天
# AI服务配置开发环境
ai:
service:
timeout: 30000
retry-count: 3
base-url: https://api-dev.example.com
api-key: dev-api-key
# 文件存储配置
file:
upload:
path: ./uploads/dev
# 跨域配置(开发环境允许所有来源)
cors:
allowed-origins: "*"

View File

@ -0,0 +1,62 @@
# 生产环境配置
spring:
# 数据库配置(生产环境从环境变量读取)
datasource:
url: ${DB_URL}
username: ${DB_USERNAME}
password: ${DB_PASSWORD}
hikari:
maximum-pool-size: 50
minimum-idle: 10
connection-timeout: 30000
idle-timeout: 300000
max-lifetime: 900000
# JPA配置
jpa:
hibernate:
ddl-auto: validate
show-sql: false
# Redis配置
data:
redis:
host: ${REDIS_HOST}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD}
database: 0
# 服务器配置
server:
port: ${SERVER_PORT:8080}
# 日志配置
logging:
level:
com.lxy.hsend: info
org.springframework.security: warn
org.hibernate.SQL: warn
file:
name: /var/log/hs-end/hs-end.log
# JWT配置生产环境必须使用环境变量
jwt:
secret: ${JWT_SECRET}
expiration: ${JWT_EXPIRATION:604800}
# AI服务配置
ai:
service:
timeout: ${AI_TIMEOUT:30000}
retry-count: ${AI_RETRY:3}
base-url: ${AI_SERVICE_URL}
api-key: ${AI_API_KEY}
# 文件存储配置
file:
upload:
path: ${FILE_UPLOAD_PATH:/data/uploads}
# 跨域配置(生产环境严格控制)
cors:
allowed-origins: ${CORS_ORIGINS}

View File

@ -0,0 +1,62 @@
# 测试环境配置
spring:
# 数据库配置 - 使用H2内存数据库进行测试
datasource:
url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
username: sa
password:
driver-class-name: org.h2.Driver
# JPA配置
jpa:
hibernate:
ddl-auto: create-drop
show-sql: true
properties:
hibernate:
dialect: org.hibernate.dialect.H2Dialect
format_sql: true
# H2控制台配置
h2:
console:
enabled: true
# Flyway配置 - 测试环境禁用
flyway:
enabled: false
# Redis配置
data:
redis:
host: localhost
port: 6379
database: 1
# 服务器配置
server:
port: 8081
# 日志配置
logging:
level:
com.lxy.hsend: info
org.springframework.security: info
# JWT配置
jwt:
secret: hsendTestSecretKey2024
expiration: 3600 # 1小时
# AI服务配置测试环境使用Mock
ai:
service:
timeout: 10000
retry-count: 1
base-url: http://localhost:8082/mock
api-key: test-api-key
# 文件存储配置
file:
upload:
path: ./uploads/test

View File

@ -0,0 +1,134 @@
spring:
application:
name: hs-end
profiles:
active: dev
# 数据库配置
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: ${DB_URL:jdbc:mysql://localhost:3306/hsai?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true}
username: ${DB_USERNAME:root}
password: ${DB_PASSWORD:root}
# 连接池配置
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
# JPA配置
jpa:
hibernate:
ddl-auto: update
show-sql: false
properties:
hibernate:
dialect: org.hibernate.dialect.MySQL8Dialect
format_sql: true
# Flyway数据库迁移配置
flyway:
enabled: true
baseline-on-migrate: true
validate-on-migrate: true
locations: classpath:db/migration
# Redis配置
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:}
database: 0
timeout: 5000ms
lettuce:
pool:
max-active: 20
max-wait: -1ms
max-idle: 10
min-idle: 0
# 文件上传配置
servlet:
multipart:
max-file-size: 10MB
max-request-size: 10MB
# Jackson配置
jackson:
date-format: yyyy-MM-dd HH:mm:ss
time-zone: Asia/Shanghai
default-property-inclusion: non_null
# 服务器配置
server:
port: 8080
servlet:
context-path: /
encoding:
charset: UTF-8
enabled: true
force: true
# Actuator健康检查配置
management:
endpoints:
web:
exposure:
include: health,info,metrics
endpoint:
health:
show-details: always
# 日志配置
logging:
level:
com.lxy.hsend: info
org.springframework.security: debug
org.hibernate.SQL: debug
org.hibernate.type.descriptor.sql.BasicBinder: trace
pattern:
console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
file: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
file:
name: logs/hs-end.log
max-size: 10MB
max-history: 30
# JWT配置
jwt:
secret: ${JWT_SECRET:hsendSecretKeyForJWTTokenGenerationAndValidation2024}
expiration: 604800 # 7天
# AI服务配置
ai:
service:
timeout: 30000
retry-count: 3
base-url: ${AI_SERVICE_URL:https://api.example.com}
api-key: ${AI_API_KEY:your-api-key}
# 文件存储配置
file:
upload:
path: ${FILE_UPLOAD_PATH:./uploads}
allowed-types: .txt,.pdf,.md,.docx,.doc,.xlsx,.xls,.png,.jpg,.jpeg
max-size: 10485760 # 10MB
# Swagger配置
springdoc:
api-docs:
path: /api-docs
swagger-ui:
path: /swagger-ui.html
# 跨域配置
cors:
allowed-origins: ${CORS_ORIGINS:http://localhost:3000,http://localhost:8080}
allowed-methods: GET,POST,PUT,DELETE,OPTIONS
allowed-headers: "*"
allow-credentials: true

View File

@ -0,0 +1,166 @@
-- 创建数据库初始表结构
-- 版本: V1
-- 描述: 创建用户、会话、消息、收藏、文件等核心业务表
-- 设置字符集
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- 用户表
-- ----------------------------
CREATE TABLE `users` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '用户ID',
`email` VARCHAR(100) NOT NULL COMMENT '用户邮箱',
`password_hash` VARCHAR(255) NOT NULL COMMENT '密码哈希',
`nickname` VARCHAR(50) NOT NULL COMMENT '用户昵称',
`avatar_url` VARCHAR(500) DEFAULT NULL COMMENT '头像URL',
`status` TINYINT DEFAULT 1 COMMENT '用户状态1-正常2-锁定3-删除',
`last_login_time` BIGINT DEFAULT NULL COMMENT '最后登录时间戳',
`created_at` BIGINT NOT NULL COMMENT '创建时间戳',
`updated_at` BIGINT NOT NULL COMMENT '更新时间戳',
`deleted_at` BIGINT DEFAULT NULL COMMENT '删除时间戳(软删除)',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_email` (`email`),
KEY `idx_status` (`status`),
KEY `idx_created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表';
-- ----------------------------
-- 会话表
-- ----------------------------
CREATE TABLE `sessions` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '会话自增ID',
`session_id` VARCHAR(36) NOT NULL COMMENT '会话UUID',
`user_id` BIGINT NOT NULL COMMENT '用户ID',
`title` VARCHAR(200) NOT NULL COMMENT '会话标题',
`message_count` INT DEFAULT 0 COMMENT '消息数量',
`last_message_time` BIGINT DEFAULT NULL COMMENT '最后消息时间戳',
`created_at` BIGINT NOT NULL COMMENT '创建时间戳',
`updated_at` BIGINT NOT NULL COMMENT '更新时间戳',
`deleted_at` BIGINT DEFAULT NULL COMMENT '删除时间戳(软删除)',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_session_id` (`session_id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_last_message_time` (`last_message_time`),
KEY `idx_created_at` (`created_at`),
CONSTRAINT `fk_sessions_user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='聊天会话表';
-- ----------------------------
-- 消息表
-- ----------------------------
CREATE TABLE `messages` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '消息自增ID',
`message_id` VARCHAR(36) NOT NULL COMMENT '消息UUID',
`session_id` VARCHAR(36) NOT NULL COMMENT '会话ID',
`user_id` BIGINT NOT NULL COMMENT '用户ID',
`role` VARCHAR(20) NOT NULL COMMENT '角色user/assistant',
`content` LONGTEXT NOT NULL COMMENT '消息内容',
`model_used` VARCHAR(50) DEFAULT NULL COMMENT '使用的AI模型',
`deep_thinking` BOOLEAN DEFAULT FALSE COMMENT '是否启用深度思考',
`web_search` BOOLEAN DEFAULT FALSE COMMENT '是否启用联网搜索',
`is_favorited` BOOLEAN DEFAULT FALSE COMMENT '是否已收藏',
`timestamp` BIGINT NOT NULL COMMENT '消息时间戳',
`created_at` BIGINT NOT NULL COMMENT '创建时间戳',
`updated_at` BIGINT NOT NULL COMMENT '更新时间戳',
`deleted_at` BIGINT DEFAULT NULL COMMENT '删除时间戳(软删除)',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_message_id` (`message_id`),
KEY `idx_session_id` (`session_id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_role` (`role`),
KEY `idx_timestamp` (`timestamp`),
KEY `idx_is_favorited` (`is_favorited`),
KEY `idx_created_at` (`created_at`),
CONSTRAINT `fk_messages_user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='消息表';
-- ----------------------------
-- 收藏表
-- ----------------------------
CREATE TABLE `favorites` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '收藏自增ID',
`favorite_id` VARCHAR(36) NOT NULL COMMENT '收藏UUID',
`user_id` BIGINT NOT NULL COMMENT '用户ID',
`message_id` VARCHAR(36) NOT NULL COMMENT '消息ID',
`session_id` VARCHAR(36) NOT NULL COMMENT '会话ID',
`created_at` BIGINT NOT NULL COMMENT '收藏时间戳',
`updated_at` BIGINT NOT NULL COMMENT '更新时间戳',
`deleted_at` BIGINT DEFAULT NULL COMMENT '删除时间戳(软删除)',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_favorite_id` (`favorite_id`),
UNIQUE KEY `uk_user_message` (`user_id`, `message_id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_message_id` (`message_id`),
KEY `idx_session_id` (`session_id`),
KEY `idx_created_at` (`created_at`),
CONSTRAINT `fk_favorites_user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='收藏表';
-- ----------------------------
-- 文件表
-- ----------------------------
CREATE TABLE `files` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '文件自增ID',
`file_id` VARCHAR(36) NOT NULL COMMENT '文件UUID',
`user_id` BIGINT NOT NULL COMMENT '用户ID',
`session_id` VARCHAR(36) DEFAULT NULL COMMENT '关联会话ID',
`filename` VARCHAR(255) NOT NULL COMMENT '原始文件名',
`stored_filename` VARCHAR(255) NOT NULL COMMENT '存储文件名',
`file_path` VARCHAR(500) NOT NULL COMMENT '文件存储路径',
`file_size` BIGINT NOT NULL COMMENT '文件大小(字节)',
`file_type` VARCHAR(100) NOT NULL COMMENT '文件类型',
`mime_type` VARCHAR(100) DEFAULT NULL COMMENT 'MIME类型',
`upload_time` BIGINT NOT NULL COMMENT '上传时间戳',
`created_at` BIGINT NOT NULL COMMENT '创建时间戳',
`updated_at` BIGINT NOT NULL COMMENT '更新时间戳',
`deleted_at` BIGINT DEFAULT NULL COMMENT '删除时间戳(软删除)',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_file_id` (`file_id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_session_id` (`session_id`),
KEY `idx_file_type` (`file_type`),
KEY `idx_upload_time` (`upload_time`),
KEY `idx_created_at` (`created_at`),
CONSTRAINT `fk_files_user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='文件表';
-- ----------------------------
-- 操作日志表
-- ----------------------------
CREATE TABLE `operation_logs` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '日志ID',
`user_id` BIGINT DEFAULT NULL COMMENT '操作用户ID',
`operation_type` VARCHAR(50) NOT NULL COMMENT '操作类型',
`operation_desc` VARCHAR(500) DEFAULT NULL COMMENT '操作描述',
`ip_address` VARCHAR(50) DEFAULT NULL COMMENT 'IP地址',
`user_agent` VARCHAR(500) DEFAULT NULL COMMENT '用户代理',
`request_params` TEXT DEFAULT NULL COMMENT '请求参数',
`response_result` TEXT DEFAULT NULL COMMENT '响应结果',
`execution_time` INT DEFAULT NULL COMMENT '执行时长(毫秒)',
`created_at` BIGINT NOT NULL COMMENT '创建时间戳',
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_operation_type` (`operation_type`),
KEY `idx_created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='操作日志表';
-- ----------------------------
-- 系统配置表
-- ----------------------------
CREATE TABLE `system_configs` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '配置ID',
`config_key` VARCHAR(100) NOT NULL COMMENT '配置键',
`config_value` TEXT DEFAULT NULL COMMENT '配置值',
`config_desc` VARCHAR(500) DEFAULT NULL COMMENT '配置描述',
`config_type` VARCHAR(20) DEFAULT 'STRING' COMMENT '配置类型STRING/NUMBER/BOOLEAN/JSON',
`is_encrypted` BOOLEAN DEFAULT FALSE COMMENT '是否加密存储',
`created_at` BIGINT NOT NULL COMMENT '创建时间戳',
`updated_at` BIGINT NOT NULL COMMENT '更新时间戳',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_config_key` (`config_key`),
KEY `idx_config_type` (`config_type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='系统配置表';
SET FOREIGN_KEY_CHECKS = 1;

View File

@ -0,0 +1,13 @@
package com.lxy.hsend;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class HsEndApplicationTests {
@Test
void contextLoads() {
}
}

View File

@ -0,0 +1,105 @@
package com.lxy.hsend.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.lxy.hsend.dto.auth.LoginRequest;
import com.lxy.hsend.dto.auth.RegisterRequest;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.context.WebApplicationContext;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
/**
* 认证控制器集成测试
*
* @author lxy
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
@Transactional
public class AuthControllerTest {
@Autowired
private WebApplicationContext webApplicationContext;
@Autowired
private ObjectMapper objectMapper;
private MockMvc mockMvc;
@Test
public void testUserRegistrationFlow() throws Exception {
mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build();
// 1. 测试用户注册
RegisterRequest registerRequest = new RegisterRequest();
registerRequest.setEmail("test@example.com");
registerRequest.setPassword("Test123456");
registerRequest.setConfirmPassword("Test123456");
registerRequest.setNickname("测试用户");
String registerJson = objectMapper.writeValueAsString(registerRequest);
mockMvc.perform(post("/api/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(registerJson))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.body.email").value("test@example.com"))
.andExpect(jsonPath("$.body.nickname").value("测试用户"))
.andExpect(jsonPath("$.message").value("注册成功"));
System.out.println("✅ 用户注册测试通过");
// 2. 测试用户登录
LoginRequest loginRequest = new LoginRequest();
loginRequest.setEmail("test@example.com");
loginRequest.setPassword("Test123456");
String loginJson = objectMapper.writeValueAsString(loginRequest);
mockMvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(loginJson))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.body.accessToken").exists())
.andExpect(jsonPath("$.body.refreshToken").exists())
.andExpect(jsonPath("$.body.userInfo.email").value("test@example.com"))
.andExpect(jsonPath("$.message").value("登录成功"));
System.out.println("✅ 用户登录测试通过");
// 3. 测试邮箱重复注册
mockMvc.perform(post("/api/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(registerJson))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.error").value(2003));
System.out.println("✅ 邮箱重复注册验证测试通过");
}
@Test
public void testEmailCheck() throws Exception {
mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build();
// 测试邮箱是否已存在检查
mockMvc.perform(get("/api/auth/check-email")
.param("email", "nonexistent@example.com"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.body").value(false))
.andExpect(jsonPath("$.message").value("邮箱可用"));
System.out.println("✅ 邮箱检查测试通过");
}
}

View File

@ -0,0 +1,203 @@
package com.lxy.hsend.repository;
import com.lxy.hsend.entity.User;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
import org.springframework.test.context.ActiveProfiles;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.ResultSet;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat;
/**
* 数据库连接和表创建测试
*/
@DataJpaTest
@ActiveProfiles("test")
public class DatabaseConnectionTest {
@Autowired
private TestEntityManager entityManager;
@Autowired
private DataSource dataSource;
@Autowired
private UserRepository userRepository;
@Test
public void testDatabaseConnection() throws Exception {
// 测试数据库连接
try (Connection connection = dataSource.getConnection()) {
assertThat(connection).isNotNull();
assertThat(connection.isValid(1)).isTrue();
DatabaseMetaData metaData = connection.getMetaData();
System.out.println("Database Product Name: " + metaData.getDatabaseProductName());
System.out.println("Database Product Version: " + metaData.getDatabaseProductVersion());
}
}
@Test
public void testTablesExist() throws Exception {
// 测试核心表是否存在
try (Connection connection = dataSource.getConnection()) {
DatabaseMetaData metaData = connection.getMetaData();
// 检查用户表
try (ResultSet rs = metaData.getTables(null, null, "users", null)) {
assertThat(rs.next()).isTrue();
System.out.println("✓ users table exists");
}
// 检查会话表
try (ResultSet rs = metaData.getTables(null, null, "sessions", null)) {
assertThat(rs.next()).isTrue();
System.out.println("✓ sessions table exists");
}
// 检查消息表
try (ResultSet rs = metaData.getTables(null, null, "messages", null)) {
assertThat(rs.next()).isTrue();
System.out.println("✓ messages table exists");
}
// 检查收藏表
try (ResultSet rs = metaData.getTables(null, null, "favorites", null)) {
assertThat(rs.next()).isTrue();
System.out.println("✓ favorites table exists");
}
// 检查文件表
try (ResultSet rs = metaData.getTables(null, null, "files", null)) {
assertThat(rs.next()).isTrue();
System.out.println("✓ files table exists");
}
}
}
@Test
public void testUserRepositoryBasicOperations() {
// 测试基本的CRUD操作
// 创建测试用户
User user = new User();
user.setEmail("test@example.com");
user.setPasswordHash("hashedPassword");
user.setNickname("TestUser");
user.setStatus((byte) 1);
// 保存用户
User savedUser = userRepository.save(user);
assertThat(savedUser.getId()).isNotNull();
assertThat(savedUser.getEmail()).isEqualTo("test@example.com");
System.out.println("✓ User saved successfully: " + savedUser);
// 通过邮箱查找用户
var foundUser = userRepository.findByEmail("test@example.com");
assertThat(foundUser).isPresent();
assertThat(foundUser.get().getNickname()).isEqualTo("TestUser");
System.out.println("✓ User found by email: " + foundUser.get());
// 检查邮箱是否存在
boolean exists = userRepository.existsByEmail("test@example.com");
assertThat(exists).isTrue();
System.out.println("✓ Email exists check passed");
// 统计用户数量
long count = userRepository.count();
assertThat(count).isGreaterThan(0);
System.out.println("✓ User count: " + count);
// 删除用户
userRepository.delete(savedUser);
// 验证删除
var deletedUser = userRepository.findById(savedUser.getId());
assertThat(deletedUser).isEmpty();
System.out.println("✓ User deleted successfully");
}
@Test
public void testUserValidation() {
// 测试用户实体验证
User user = new User();
user.setEmail("invalid-email"); // 无效邮箱格式
user.setPasswordHash("password");
user.setNickname("Test");
try {
entityManager.persistAndFlush(user);
// 如果没有抛出异常,说明验证可能有问题
} catch (Exception e) {
// 预期会抛出验证异常
System.out.println("✓ Email validation working: " + e.getMessage());
}
}
@Test
public void testTimestampFields() {
// 测试时间戳字段
User user = new User();
user.setEmail("timestamp@test.com");
user.setPasswordHash("password");
user.setNickname("TimestampTest");
long beforeSave = Instant.now().getEpochSecond();
User savedUser = userRepository.save(user);
long afterSave = Instant.now().getEpochSecond();
// 验证创建时间和更新时间
assertThat(savedUser.getCreatedAt()).isNotNull();
assertThat(savedUser.getUpdatedAt()).isNotNull();
assertThat(savedUser.getCreatedAt()).isBetween(beforeSave - 1, afterSave + 1);
assertThat(savedUser.getUpdatedAt()).isBetween(beforeSave - 1, afterSave + 1);
System.out.println("✓ Timestamp fields working correctly");
System.out.println(" Created at: " + savedUser.getCreatedAt());
System.out.println(" Updated at: " + savedUser.getUpdatedAt());
// 清理
userRepository.delete(savedUser);
}
@Test
public void testSoftDelete() {
// 测试软删除功能
User user = new User();
user.setEmail("softdelete@test.com");
user.setPasswordHash("password");
user.setNickname("SoftDeleteTest");
User savedUser = userRepository.save(user);
Long userId = savedUser.getId();
// 执行软删除
savedUser.markAsDeleted();
userRepository.save(savedUser);
// 验证软删除
User deletedUser = userRepository.findById(userId).orElse(null);
assertThat(deletedUser).isNotNull();
assertThat(deletedUser.isDeleted()).isTrue();
assertThat(deletedUser.getDeletedAt()).isNotNull();
System.out.println("✓ Soft delete working correctly");
System.out.println(" Deleted at: " + deletedUser.getDeletedAt());
// 测试查找未删除的用户
var activeUser = userRepository.findByEmailAndNotDeleted("softdelete@test.com");
assertThat(activeUser).isEmpty();
System.out.println("✓ Soft delete query filter working");
// 清理
userRepository.delete(deletedUser);
}
}

View File

@ -0,0 +1,250 @@
package com.lxy.hsend.service;
import com.lxy.hsend.common.ApiResponse;
import com.lxy.hsend.dto.favorite.AddFavoriteRequest;
import com.lxy.hsend.dto.favorite.FavoriteListRequest;
import com.lxy.hsend.dto.favorite.FavoriteResponse;
import com.lxy.hsend.dto.favorite.RemoveFavoriteRequest;
import com.lxy.hsend.entity.Favorite;
import com.lxy.hsend.entity.Message;
import com.lxy.hsend.entity.Session;
import com.lxy.hsend.entity.User;
import com.lxy.hsend.repository.FavoriteRepository;
import com.lxy.hsend.repository.MessageRepository;
import com.lxy.hsend.repository.SessionRepository;
import com.lxy.hsend.repository.UserRepository;
import com.lxy.hsend.util.PageUtil;
import com.lxy.hsend.util.TimeUtil;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.transaction.annotation.Transactional;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
/**
* 收藏服务测试
*/
@SpringBootTest
@ActiveProfiles("test")
@Transactional
class FavoriteServiceTest {
@Autowired
private FavoriteService favoriteService;
@Autowired
private UserRepository userRepository;
@Autowired
private SessionRepository sessionRepository;
@Autowired
private MessageRepository messageRepository;
@Autowired
private FavoriteRepository favoriteRepository;
private User testUser;
private Session testSession;
private Message testMessage;
@BeforeEach
void setUp() {
// 创建测试用户
testUser = new User();
testUser.setEmail("test@example.com");
testUser.setNickname("testuser");
testUser.setPasswordHash("hashedpassword");
testUser.setStatus((byte) 1);
testUser = userRepository.save(testUser);
// 创建测试会话
testSession = new Session();
testSession.setSessionId(UUID.randomUUID().toString());
testSession.setUserId(testUser.getId());
testSession.setTitle("测试会话");
testSession.setMessageCount(0);
testSession.setLastMessageTime(TimeUtil.getCurrentTimestamp());
testSession = sessionRepository.save(testSession);
// 创建测试消息
testMessage = new Message();
testMessage.setMessageId(UUID.randomUUID().toString());
testMessage.setSessionId(testSession.getSessionId());
testMessage.setUserId(testUser.getId());
testMessage.setRole("user");
testMessage.setContent("这是一条测试消息内容");
testMessage.setTimestamp(TimeUtil.getCurrentTimestamp());
testMessage.setCreatedAt(TimeUtil.getCurrentTimestamp());
testMessage.setUpdatedAt(TimeUtil.getCurrentTimestamp());
testMessage = messageRepository.save(testMessage);
}
@Test
void testAddFavorite() {
// 准备请求
AddFavoriteRequest request = new AddFavoriteRequest();
request.setMessageId(testMessage.getMessageId());
request.setSessionId(testSession.getSessionId());
// 添加收藏
ApiResponse<FavoriteResponse> response = favoriteService.addFavorite(testUser.getId(), request);
// 验证响应
assertNotNull(response);
assertTrue(response.getSuccess());
assertNotNull(response.getBody());
FavoriteResponse favoriteResponse = response.getBody();
assertEquals(testMessage.getMessageId(), favoriteResponse.getMessageId());
assertEquals(testSession.getSessionId(), favoriteResponse.getSessionId());
assertEquals(testSession.getTitle(), favoriteResponse.getSessionTitle());
assertEquals(testMessage.getContent(), favoriteResponse.getMessageContent());
assertEquals(testMessage.getRole(), favoriteResponse.getMessageRole());
assertNotNull(favoriteResponse.getFavoriteId());
assertNotNull(favoriteResponse.getFavoritedAt());
// 验证数据库中的收藏记录
boolean exists = favoriteRepository.existsByUserIdAndMessageIdAndNotDeleted(
testUser.getId(), testMessage.getMessageId());
assertTrue(exists);
}
@Test
void testAddFavoriteDuplicate() {
// 先添加一次收藏
AddFavoriteRequest request = new AddFavoriteRequest();
request.setMessageId(testMessage.getMessageId());
request.setSessionId(testSession.getSessionId());
favoriteService.addFavorite(testUser.getId(), request);
// 再次尝试添加收藏,应该失败
try {
favoriteService.addFavorite(testUser.getId(), request);
fail("应该抛出重复收藏的异常");
} catch (Exception e) {
assertTrue(e.getMessage().contains("已经收藏过了"));
}
}
@Test
void testRemoveFavorite() {
// 先添加收藏
AddFavoriteRequest addRequest = new AddFavoriteRequest();
addRequest.setMessageId(testMessage.getMessageId());
addRequest.setSessionId(testSession.getSessionId());
favoriteService.addFavorite(testUser.getId(), addRequest);
// 准备取消收藏请求
RemoveFavoriteRequest removeRequest = new RemoveFavoriteRequest();
removeRequest.setMessageId(testMessage.getMessageId());
// 取消收藏
ApiResponse<Void> response = favoriteService.removeFavorite(testUser.getId(), removeRequest);
// 验证响应
assertNotNull(response);
assertTrue(response.getSuccess());
// 验证收藏记录已被删除
boolean exists = favoriteRepository.existsByUserIdAndMessageIdAndNotDeleted(
testUser.getId(), testMessage.getMessageId());
assertFalse(exists);
}
@Test
void testGetFavoriteList() {
// 先添加几个收藏
AddFavoriteRequest request1 = new AddFavoriteRequest();
request1.setMessageId(testMessage.getMessageId());
request1.setSessionId(testSession.getSessionId());
favoriteService.addFavorite(testUser.getId(), request1);
// 创建第二条消息和收藏
Message message2 = new Message();
message2.setMessageId(UUID.randomUUID().toString());
message2.setSessionId(testSession.getSessionId());
message2.setUserId(testUser.getId());
message2.setRole("assistant");
message2.setContent("这是AI助手的回答");
message2.setTimestamp(TimeUtil.getCurrentTimestamp());
message2.setCreatedAt(TimeUtil.getCurrentTimestamp());
message2.setUpdatedAt(TimeUtil.getCurrentTimestamp());
messageRepository.save(message2);
AddFavoriteRequest request2 = new AddFavoriteRequest();
request2.setMessageId(message2.getMessageId());
request2.setSessionId(testSession.getSessionId());
favoriteService.addFavorite(testUser.getId(), request2);
// 准备查询请求
FavoriteListRequest listRequest = new FavoriteListRequest();
listRequest.setPage(1);
listRequest.setPageSize(10);
listRequest.setMessageRole("all");
// 获取收藏列表
ApiResponse<PageUtil.PageResponse<FavoriteResponse>> response =
favoriteService.getFavoriteList(testUser.getId(), listRequest);
// 验证响应
assertNotNull(response);
assertTrue(response.getSuccess());
assertNotNull(response.getBody());
PageUtil.PageResponse<FavoriteResponse> pageResponse = response.getBody();
assertNotNull(pageResponse.getContent());
assertEquals(2, pageResponse.getContent().size());
assertEquals(2L, pageResponse.getTotal());
assertEquals(1, pageResponse.getPage());
assertEquals(10, pageResponse.getSize());
}
@Test
void testIsFavorited() {
// 检查未收藏的消息
ApiResponse<Boolean> response1 = favoriteService.isFavorited(testUser.getId(), testMessage.getMessageId());
assertNotNull(response1);
assertTrue(response1.getSuccess());
assertFalse(response1.getBody());
// 添加收藏
AddFavoriteRequest request = new AddFavoriteRequest();
request.setMessageId(testMessage.getMessageId());
request.setSessionId(testSession.getSessionId());
favoriteService.addFavorite(testUser.getId(), request);
// 检查已收藏的消息
ApiResponse<Boolean> response2 = favoriteService.isFavorited(testUser.getId(), testMessage.getMessageId());
assertNotNull(response2);
assertTrue(response2.getSuccess());
assertTrue(response2.getBody());
}
@Test
void testGetUserFavoriteCount() {
// 初始收藏数量应该为0
ApiResponse<Long> response1 = favoriteService.getUserFavoriteCount(testUser.getId());
assertNotNull(response1);
assertTrue(response1.getSuccess());
assertEquals(0L, response1.getBody());
// 添加一个收藏
AddFavoriteRequest request = new AddFavoriteRequest();
request.setMessageId(testMessage.getMessageId());
request.setSessionId(testSession.getSessionId());
favoriteService.addFavorite(testUser.getId(), request);
// 收藏数量应该为1
ApiResponse<Long> response2 = favoriteService.getUserFavoriteCount(testUser.getId());
assertNotNull(response2);
assertTrue(response2.getSuccess());
assertEquals(1L, response2.getBody());
}
}

View File

@ -0,0 +1,176 @@
package com.lxy.hsend.service;
import com.lxy.hsend.common.ApiResponse;
import com.lxy.hsend.dto.message.MessageListRequest;
import com.lxy.hsend.dto.message.MessageResponse;
import com.lxy.hsend.dto.message.SendMessageRequest;
import com.lxy.hsend.entity.Message;
import com.lxy.hsend.entity.Session;
import com.lxy.hsend.entity.User;
import com.lxy.hsend.repository.MessageRepository;
import com.lxy.hsend.repository.SessionRepository;
import com.lxy.hsend.repository.UserRepository;
import com.lxy.hsend.util.PageUtil;
import com.lxy.hsend.util.TimeUtil;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.transaction.annotation.Transactional;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
/**
* 消息服务测试
*/
@SpringBootTest
@ActiveProfiles("test")
@Transactional
class MessageServiceTest {
@Autowired
private MessageService messageService;
@Autowired
private UserRepository userRepository;
@Autowired
private SessionRepository sessionRepository;
@Autowired
private MessageRepository messageRepository;
private User testUser;
private Session testSession;
@BeforeEach
void setUp() {
// 创建测试用户
testUser = new User();
testUser.setEmail("test@example.com");
testUser.setNickname("testuser");
testUser.setPasswordHash("hashedpassword");
testUser.setStatus((byte) 1);
testUser = userRepository.save(testUser);
// 创建测试会话
testSession = new Session();
testSession.setSessionId(UUID.randomUUID().toString());
testSession.setUserId(testUser.getId());
testSession.setTitle("测试会话");
testSession.setMessageCount(0);
testSession.setLastMessageTime(TimeUtil.getCurrentTimestamp());
testSession = sessionRepository.save(testSession);
}
@Test
void testSendMessage() {
// 准备请求
SendMessageRequest request = new SendMessageRequest();
request.setSessionId(testSession.getSessionId());
request.setContent("你好,这是一个测试消息");
request.setDeepThinking(false);
request.setWebSearch(false);
// 发送消息
ApiResponse<MessageResponse> response = messageService.sendMessage(testUser.getId(), request);
// 验证响应
assertNotNull(response);
assertTrue(response.getSuccess());
assertNotNull(response.getBody());
MessageResponse messageResponse = response.getBody();
assertEquals(testSession.getSessionId(), messageResponse.getSessionId());
assertEquals("assistant", messageResponse.getRole());
assertNotNull(messageResponse.getContent());
assertNotNull(messageResponse.getMessageId());
assertNotNull(messageResponse.getCreatedAt());
// 验证数据库中的消息
long messageCount = messageRepository.countBySessionIdAndNotDeleted(testSession.getSessionId());
assertEquals(2, messageCount); // 用户消息 + AI响应
}
@Test
void testGetMessages() {
// 先创建一些测试消息
Message userMessage = new Message();
userMessage.setMessageId(UUID.randomUUID().toString());
userMessage.setSessionId(testSession.getSessionId());
userMessage.setUserId(testUser.getId());
userMessage.setRole("user");
userMessage.setContent("用户消息");
userMessage.setTimestamp(TimeUtil.getCurrentTimestamp());
userMessage.setCreatedAt(TimeUtil.getCurrentTimestamp());
userMessage.setUpdatedAt(TimeUtil.getCurrentTimestamp());
messageRepository.save(userMessage);
Message aiMessage = new Message();
aiMessage.setMessageId(UUID.randomUUID().toString());
aiMessage.setSessionId(testSession.getSessionId());
aiMessage.setUserId(testUser.getId());
aiMessage.setRole("assistant");
aiMessage.setContent("AI响应");
aiMessage.setTimestamp(TimeUtil.getCurrentTimestamp());
aiMessage.setCreatedAt(TimeUtil.getCurrentTimestamp());
aiMessage.setUpdatedAt(TimeUtil.getCurrentTimestamp());
messageRepository.save(aiMessage);
// 准备请求
MessageListRequest request = new MessageListRequest();
request.setSessionId(testSession.getSessionId());
request.setPage(1);
request.setPageSize(10);
request.setRole("all");
// 获取消息列表
ApiResponse<PageUtil.PageResponse<MessageResponse>> response =
messageService.getMessages(testUser.getId(), request);
// 验证响应
assertNotNull(response);
assertTrue(response.getSuccess());
assertNotNull(response.getBody());
PageUtil.PageResponse<MessageResponse> pageResponse = response.getBody();
assertNotNull(pageResponse.getContent());
assertEquals(2, pageResponse.getContent().size());
assertEquals(2L, pageResponse.getTotal());
assertEquals(1, pageResponse.getPage());
assertEquals(10, pageResponse.getSize());
}
@Test
void testGetMessageById() {
// 创建测试消息
Message testMessage = new Message();
testMessage.setMessageId(UUID.randomUUID().toString());
testMessage.setSessionId(testSession.getSessionId());
testMessage.setUserId(testUser.getId());
testMessage.setRole("user");
testMessage.setContent("测试消息内容");
testMessage.setTimestamp(TimeUtil.getCurrentTimestamp());
testMessage.setCreatedAt(TimeUtil.getCurrentTimestamp());
testMessage.setUpdatedAt(TimeUtil.getCurrentTimestamp());
testMessage = messageRepository.save(testMessage);
// 获取消息详情
ApiResponse<MessageResponse> response =
messageService.getMessageById(testUser.getId(), testMessage.getMessageId());
// 验证响应
assertNotNull(response);
assertTrue(response.getSuccess());
assertNotNull(response.getBody());
MessageResponse messageResponse = response.getBody();
assertEquals(testMessage.getMessageId(), messageResponse.getMessageId());
assertEquals(testMessage.getSessionId(), messageResponse.getSessionId());
assertEquals(testMessage.getRole(), messageResponse.getRole());
assertEquals(testMessage.getContent(), messageResponse.getContent());
}
}

View File

@ -0,0 +1,168 @@
package com.lxy.hsend.service;
import com.lxy.hsend.dto.session.CreateSessionRequest;
import com.lxy.hsend.dto.session.SessionDetailResponse;
import com.lxy.hsend.dto.session.SessionSearchRequest;
import com.lxy.hsend.dto.session.UpdateSessionRequest;
import com.lxy.hsend.entity.Session;
import com.lxy.hsend.entity.User;
import com.lxy.hsend.repository.SessionRepository;
import com.lxy.hsend.repository.UserRepository;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.domain.Page;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.transaction.annotation.Transactional;
import static org.junit.jupiter.api.Assertions.*;
/**
* 会话服务测试
*
* @author lxy
*/
@Slf4j
@SpringBootTest
@ActiveProfiles("test")
@Transactional
public class SessionServiceTest {
@Autowired
private SessionService sessionService;
@Autowired
private UserRepository userRepository;
@Autowired
private SessionRepository sessionRepository;
@Test
public void testSessionManagement() {
log.info("开始测试会话管理功能");
// 1. 创建测试用户
User testUser = createTestUser();
Long userId = testUser.getId();
// 2. 测试创建会话
CreateSessionRequest createRequest = new CreateSessionRequest();
createRequest.setTitle("测试会话标题");
createRequest.setInitialMessage("这是一条初始消息");
SessionDetailResponse createdSession = sessionService.createSession(userId, createRequest);
assertNotNull(createdSession);
assertNotNull(createdSession.getSessionId());
assertEquals("测试会话标题", createdSession.getTitle());
assertEquals(userId, createdSession.getUserId());
log.info("✅ 会话创建成功: sessionId={}", createdSession.getSessionId());
// 3. 测试获取会话详情
SessionDetailResponse sessionDetail = sessionService.getSessionDetail(userId, createdSession.getSessionId());
assertNotNull(sessionDetail);
assertEquals(createdSession.getSessionId(), sessionDetail.getSessionId());
assertEquals("测试会话标题", sessionDetail.getTitle());
log.info("✅ 获取会话详情成功");
// 4. 测试更新会话标题
UpdateSessionRequest updateRequest = new UpdateSessionRequest();
updateRequest.setTitle("更新后的标题");
SessionDetailResponse updatedSession = sessionService.updateSession(userId, createdSession.getSessionId(), updateRequest);
assertNotNull(updatedSession);
assertEquals("更新后的标题", updatedSession.getTitle());
log.info("✅ 会话标题更新成功");
// 5. 测试获取会话列表
SessionSearchRequest searchRequest = new SessionSearchRequest();
searchRequest.setPage(1);
searchRequest.setPageSize(10);
Page<com.lxy.hsend.dto.session.SessionListResponse> sessionList = sessionService.getSessionList(userId, searchRequest);
assertNotNull(sessionList);
assertTrue(sessionList.getTotalElements() >= 1);
log.info("✅ 获取会话列表成功,总数: {}", sessionList.getTotalElements());
// 6. 测试会话权限验证
boolean hasPermission = sessionService.hasSessionPermission(userId, createdSession.getSessionId());
assertTrue(hasPermission);
log.info("✅ 会话权限验证成功");
// 7. 测试清空会话
sessionService.clearSession(userId, createdSession.getSessionId());
log.info("✅ 会话清空成功");
// 8. 测试删除会话
sessionService.deleteSession(userId, createdSession.getSessionId());
log.info("✅ 会话删除成功");
// 验证会话已被软删除
boolean hasPermissionAfterDelete = sessionService.hasSessionPermission(userId, createdSession.getSessionId());
assertFalse(hasPermissionAfterDelete);
log.info("✅ 会话软删除验证成功");
System.out.println("✅ 所有会话管理功能测试通过!");
}
@Test
public void testSessionSearch() {
log.info("开始测试会话搜索功能");
// 创建测试用户
User testUser = createTestUser();
Long userId = testUser.getId();
// 创建多个测试会话
CreateSessionRequest request1 = new CreateSessionRequest();
request1.setTitle("Java开发学习");
sessionService.createSession(userId, request1);
CreateSessionRequest request2 = new CreateSessionRequest();
request2.setTitle("Python数据分析");
sessionService.createSession(userId, request2);
CreateSessionRequest request3 = new CreateSessionRequest();
request3.setTitle("前端React开发");
sessionService.createSession(userId, request3);
// 测试关键词搜索
SessionSearchRequest searchRequest = new SessionSearchRequest();
searchRequest.setKeyword("开发");
searchRequest.setPage(1);
searchRequest.setPageSize(10);
Page<com.lxy.hsend.dto.session.SessionListResponse> searchResult = sessionService.getSessionList(userId, searchRequest);
assertNotNull(searchResult);
assertTrue(searchResult.getTotalElements() >= 2); // 至少找到2个包含"开发"的会话
log.info("✅ 会话关键词搜索成功,找到 {} 个结果", searchResult.getTotalElements());
// 测试排序
searchRequest.setKeyword(null);
searchRequest.setSortBy("created_at");
searchRequest.setSortOrder("desc");
Page<com.lxy.hsend.dto.session.SessionListResponse> sortedResult = sessionService.getSessionList(userId, searchRequest);
assertNotNull(sortedResult);
log.info("✅ 会话排序功能测试成功");
System.out.println("✅ 会话搜索功能测试通过!");
}
private User createTestUser() {
User user = new User();
user.setEmail("session-test@example.com");
user.setPasswordHash("test-password-hash");
user.setNickname("会话测试用户");
user.setStatus((byte) 1);
user.setCreatedAt(System.currentTimeMillis());
user.setUpdatedAt(System.currentTimeMillis());
return userRepository.save(user);
}
}