Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions chat_service/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,16 @@ repositories {
dependencies {
// Spring Web
implementation 'org.springframework.boot:spring-boot-starter-web'
// WebSocket
implementation 'org.springframework.boot:spring-boot-starter-websocket'
// JPA
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
// Validation
implementation 'org.springframework.boot:spring-boot-starter-validation'
// Redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
// Session Redis
implementation 'org.springframework.session:spring-session-data-redis'
// H2
runtimeOnly 'com.h2database:h2'
// PostgreSQL
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.synapse.chat_service.common.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
* Redis 작업에 대한 공통 예외 처리를 위한 어노테이션
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RedisOperation {

/**
* 작업 설명 (로깅용)
*/
String value() default "";

/**
* 예외 발생 시 기본값 반환 여부
*/
boolean returnDefaultOnError() default false;

/**
* 예외를 다시 던질지 여부
*/
boolean rethrowException() default true;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package com.synapse.chat_service.common.aspect;

import com.synapse.chat_service.common.annotation.RedisOperation;
import com.synapse.chat_service.exception.commonexception.RedisOperationException;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;

@Slf4j
@Aspect
@Component
public class RedisOperationAspect {

@Around("@annotation(redisOperation)")
public Object handleRedisOperation(ProceedingJoinPoint joinPoint, RedisOperation redisOperation) throws Throwable {
String methodName = joinPoint.getSignature().getName();
String className = joinPoint.getTarget().getClass().getSimpleName();
String operation = redisOperation.value().isEmpty() ? methodName : redisOperation.value();

try {
Object result = joinPoint.proceed();
log.debug("Redis 작업 성공: {}.{}", className, operation);
return result;

} catch (Exception e) {
log.error("Redis 작업 실패: {}.{} - 원인: {}", className, operation, e.getMessage(), e);

if (redisOperation.returnDefaultOnError()) {
log.debug("Redis 작업 실패 시 기본값 반환: {}.{}", className, operation);
MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
return getDefaultValue(methodSignature.getReturnType());
}

if (redisOperation.rethrowException()) {
// 예외 체이닝을 통해 원본 예외의 스택 트레이스 보존
String operationDescription = String.format("%s.%s", className, operation);
throw RedisOperationException.operationError(operationDescription, e);
}

log.debug("Redis 작업 실패 시 null 반환: {}.{}", className, operation);
return null;
}
}

private Object getDefaultValue(Class<?> returnType) {
if (returnType == boolean.class || returnType == Boolean.class) {
return false;
}
if (returnType == int.class || returnType == Integer.class) {
return 0;
}
if (returnType == long.class || returnType == Long.class) {
return 0L;
}
return null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package com.synapse.chat_service.common.util;

import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

@Slf4j
@Component
@RequiredArgsConstructor
public class RedisTypeConverter {

private final ObjectMapper objectMapper;

/**
* Redis에서 조회한 원시 값을 지정된 타입으로 안전하게 변환
*
* @param rawValue Redis에서 조회한 원시 값
* @param targetType 변환할 대상 타입
* @return 변환된 객체 (실패 시 null)
*/
public <T> T convertValue(Object rawValue, Class<T> targetType) {
if (rawValue == null) {
return null;
}

try {
// 이미 올바른 타입인 경우
if (targetType.isInstance(rawValue)) {
return targetType.cast(rawValue);
}

// ObjectMapper를 사용한 타입 변환
return objectMapper.convertValue(rawValue, targetType);

} catch (Exception e) {
log.warn("Redis 값 타입 변환 실패: rawValue={}, targetType={}",
rawValue.getClass().getSimpleName(), targetType.getSimpleName(), e);
return null;
}
}

/**
* String 타입으로 안전하게 변환
*/
public String convertToString(Object rawValue) {
return convertValue(rawValue, String.class);
}

/**
* 객체를 byte 배열로 변환 (Redis 트랜잭션에서 사용)
*
* @param value 변환할 객체
* @return byte 배열 (실패 시 빈 배열)
*/
public byte[] convertToBytes(Object value) {
if (value == null) {
return new byte[0];
}

try {
return objectMapper.writeValueAsBytes(value);
} catch (Exception e) {
log.warn("객체를 byte 배열로 변환 실패: value={}", value.getClass().getSimpleName(), e);
return new byte[0];
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.synapse.chat_service.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;

/**
* ObjectMapper 설정 클래스
*
* 보안 고려사항:
* - Default Typing은 안전하지 않은 역직렬화 취약점을 유발할 수 있어 비활성화
* - 다형성 타입 처리가 필요한 경우 @JsonTypeInfo와 @JsonSubTypes 어노테이션을
* - 해당 클래스에 직접 사용하는 것을 권장
*/
@Configuration
public class ObjectMapperConfig {
@Bean
public ObjectMapper objectMapper() {
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
objectMapper.registerModule(new JavaTimeModule());
objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);

return objectMapper;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.synapse.chat_service.config;

import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@Configuration
@RequiredArgsConstructor
public class RedisConfig {

private final ObjectMapper objectMapper;

@Bean
public RedisTemplate<String, Object> objectRedisTemplate(RedisConnectionFactory connectionFactory) {
var template = new RedisTemplate<String, Object>();
template.setConnectionFactory(connectionFactory);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new GenericJackson2JsonRedisSerializer(objectMapper));

return template;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.synapse.chat_service.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
// 클라이언트에서 메시지를 받을 때 사용할 prefix
config.setApplicationDestinationPrefixes("/app");

// 클라이언트가 구독할 때 사용할 prefix
config.enableSimpleBroker("/topic", "/queue")
.setTaskScheduler(heartbeatScheduler())
.setHeartbeatValue(new long[] {10000, 10000});

// AI 응답을 특정 사용자에게 보낼 때 사용할 prefix
config.setUserDestinationPrefix("/ai");
}

@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws")
.setAllowedOriginPatterns("*"); // CORS 설정: 모든 도메인 허용 (개발 환경)
}

@Bean
public ThreadPoolTaskScheduler heartbeatScheduler() {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(1);
scheduler.setThreadNamePrefix("ws-heartbeat-");
return scheduler;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package com.synapse.chat_service.controller;

import com.synapse.chat_service.dto.response.MessageResponse;
import com.synapse.chat_service.service.MessageService;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.UUID;

@RestController
@RequestMapping("/api/v1/ai-chat")
@RequiredArgsConstructor
public class AiChatController {

private final MessageService messageService;

@GetMapping("/history")
public ResponseEntity<List<MessageResponse.Simple>> getMyAiChatHistory(
@RequestHeader("X-User-Id") Long userId
) {
List<MessageResponse.Simple> response = messageService.getMessagesByUserId(userId);
return ResponseEntity.ok(response);
}

@GetMapping("/history/paging")
public ResponseEntity<Page<MessageResponse.Simple>> getMyAiChatHistoryWithPaging(
@RequestHeader("X-User-Id") Long userId,
@PageableDefault(size = 50, sort = "createdDate", direction = Sort.Direction.ASC) Pageable pageable
) {
Page<MessageResponse.Simple> response = messageService.getMessagesByUserIdWithPaging(userId, pageable);
return ResponseEntity.ok(response);
}

@GetMapping("/history/recent")
public ResponseEntity<Page<MessageResponse.Simple>> getMyAiChatHistoryRecentFirst(
@RequestHeader("X-User-Id") Long userId,
@PageableDefault(size = 50, sort = "createdDate", direction = Sort.Direction.DESC) Pageable pageable
) {
Page<MessageResponse.Simple> response = messageService.getMessagesRecentFirst(userId, pageable);
return ResponseEntity.ok(response);
}

@GetMapping("/search")
public ResponseEntity<List<MessageResponse.Simple>> searchMyAiChatHistory(
@RequestHeader("X-User-Id") Long userId,
@RequestParam String keyword
) {
List<MessageResponse.Simple> response = messageService.searchMessages(userId, keyword);
return ResponseEntity.ok(response);
}

@GetMapping("/stats")
public ResponseEntity<AiChatStatsResponse> getMyAiChatStats(
@RequestHeader("X-User-Id") Long userId
) {
long messageCount = messageService.getMessageCountByUserId(userId);
UUID conversationId = messageService.getConversationId(userId);

AiChatStatsResponse response = new AiChatStatsResponse(
conversationId,
messageCount
);

return ResponseEntity.ok(response);
}

public record AiChatStatsResponse(
UUID conversationId,
long totalMessageCount
) {}
}
Loading
Loading