diff --git a/src/backend/apigateway-server/src/main/resources/application-prod.yml b/src/backend/apigateway-server/src/main/resources/application-prod.yml index 733f2c15..9924e243 100644 --- a/src/backend/apigateway-server/src/main/resources/application-prod.yml +++ b/src/backend/apigateway-server/src/main/resources/application-prod.yml @@ -105,7 +105,7 @@ spring: - Path=/chats/** filters: - RemoveRequestHeader=Cookie - - RewritePath=/users/(?.*), /$\{segment} + - RewritePath=/chats/(?.*), /$\{segment} - AuthorizationHeaderFilter default-filters: diff --git a/src/backend/chat-server/build.gradle.kts b/src/backend/chat-server/build.gradle.kts index c9f5380f..1acae9f8 100644 --- a/src/backend/chat-server/build.gradle.kts +++ b/src/backend/chat-server/build.gradle.kts @@ -35,6 +35,8 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-actuator") + implementation("org.springframework.boot:spring-boot-starter-aop") + implementation("org.springframework.cloud:spring-cloud-starter") implementation("org.springframework.cloud:spring-cloud-starter-config") implementation("org.springframework.cloud:spring-cloud-starter-bootstrap") diff --git a/src/backend/chat-server/src/main/kotlin/com/asyncgate/chat_server/controller/DirectController.kt b/src/backend/chat-server/src/main/kotlin/com/asyncgate/chat_server/controller/DirectController.kt index 96841d21..dd89b717 100644 --- a/src/backend/chat-server/src/main/kotlin/com/asyncgate/chat_server/controller/DirectController.kt +++ b/src/backend/chat-server/src/main/kotlin/com/asyncgate/chat_server/controller/DirectController.kt @@ -70,7 +70,7 @@ class DirectController( } @ResponseBody - @GetMapping("chat/direct") + @GetMapping("/chat/direct") override fun readPaging( @RequestParam("page", defaultValue = "0") page: Int, @RequestParam("size", defaultValue = "10") size: Int, diff --git a/src/backend/chat-server/src/main/kotlin/com/asyncgate/chat_server/controller/DirectRequest.kt b/src/backend/chat-server/src/main/kotlin/com/asyncgate/chat_server/controller/DirectRequest.kt index 94e605fd..3fd05615 100644 --- a/src/backend/chat-server/src/main/kotlin/com/asyncgate/chat_server/controller/DirectRequest.kt +++ b/src/backend/chat-server/src/main/kotlin/com/asyncgate/chat_server/controller/DirectRequest.kt @@ -12,7 +12,7 @@ import java.io.Serializable @JsonSerialize @JsonDeserialize data class DirectMessageCreate( - @Schema(description = "채널 ID, 다이렉트 채팅방 생성 후 id. DirectId와 같음", example = "channel-12345") + @Schema(description = "채널 ID", example = "channel-12345") val channelId: String, @Schema(description = "프로필 이미지 URL", example = "http://example.com/image.png") @@ -24,20 +24,21 @@ data class DirectMessageCreate( @Schema(description = "메시지 내용", example = "Hello!") val content: String, - @Schema(description = "썸네일 URL", example = "http://example.com/thumbnail.png", nullable = true) + @Schema(description = "썸네일 URL", nullable = true) val thumbnail: String? = null, - @Schema(description = "부모 메시지 ID", example = "msg-12345", nullable = true) + @Schema(description = "부모 메시지 ID", nullable = true) val parentId: String? = null, - @Schema(description = "부모 메시지 작성자 이름", example = "Jane Doe", nullable = true) + @Schema(description = "부모 메시지 작성자 이름", nullable = true) val parentName: String? = null, - @Schema(description = "부모 메시지 내용", example = "Previous message content", nullable = true) + @Schema(description = "부모 메시지 내용", nullable = true) val parentContent: String? = null, ) : Serializable { + fun toDomain(userId: String): DirectMessage { - return DirectMessage( + return DirectMessage.create( channelId = channelId, userId = userId, type = DirectMessageType.CREATE, @@ -70,6 +71,7 @@ data class DirectMessageEditRequest( ) : Serializable { fun toDomain(userId: String): DirectMessage { return DirectMessage( + id = id, channelId = channelId, userId = userId, type = DirectMessageType.EDIT, @@ -113,7 +115,7 @@ data class DirectMessageTypingRequest( val name: String, ) : Serializable { fun toDomain(userId: String): DirectMessage { - return DirectMessage( + return DirectMessage.create( channelId = channelId, userId = userId, name = name, @@ -151,7 +153,7 @@ data class FileRequest( val fileType: DirectMessageType, ) : Serializable { fun toDomain(userId: String, type: DirectMessageType): DirectMessage { - return DirectMessage( + return DirectMessage.create( userId = userId, name = name, profileImage = profileImage, diff --git a/src/backend/chat-server/src/main/kotlin/com/asyncgate/chat_server/domain/DirectMessage.kt b/src/backend/chat-server/src/main/kotlin/com/asyncgate/chat_server/domain/DirectMessage.kt index dfff9d5d..76de90a9 100644 --- a/src/backend/chat-server/src/main/kotlin/com/asyncgate/chat_server/domain/DirectMessage.kt +++ b/src/backend/chat-server/src/main/kotlin/com/asyncgate/chat_server/domain/DirectMessage.kt @@ -1,29 +1,99 @@ package com.asyncgate.chat_server.domain +import com.asyncgate.chat_server.support.utility.IdGenerator import java.time.LocalDateTime class DirectMessage( - val id: String? = null, - + val id: String, val channelId: String, val userId: String, val type: DirectMessageType, - val profileImage: String? = null, - val read: Map? = null, - val name: String? = null, val content: String? = null, val thumbnail: String? = null, - val parentId: String? = null, val parentName: String? = null, val parentContent: String? = null, - val createdAt: LocalDateTime? = null, - val updatedAt: LocalDateTime? = null, + var updatedAt: LocalDateTime? = null, + var isDeleted: Boolean = false, ) { + companion object { + fun create( + channelId: String, + userId: String, + type: DirectMessageType, + profileImage: String? = null, + name: String? = null, + content: String? = null, + thumbnail: String? = null, + parentId: String? = null, + parentName: String? = null, + parentContent: String? = null, + ): DirectMessage { + return DirectMessage( + id = IdGenerator.generate(), + channelId = channelId, + userId = userId, + type = type, + profileImage = profileImage, + name = name, + content = content, + thumbnail = thumbnail, + parentId = parentId, + parentName = parentName, + parentContent = parentContent, + isDeleted = false + ) + } + } + + /** + * 현재 메시지를 "삭제" 상태로 변경한 새로운 도메인 객체 반환. + * (실제 삭제 플래그는 엔티티 변환 시 toEntity()에서 처리) + */ + fun markDeleted(): DirectMessage { + return DirectMessage( + id = this.id, + channelId = this.channelId, + userId = this.userId, + type = this.type, + profileImage = this.profileImage, + read = this.read, + name = this.name, + content = this.content, + thumbnail = this.thumbnail, + parentId = this.parentId, + parentName = this.parentName, + parentContent = this.parentContent, + isDeleted = true + ) + } + + /** + * 현재 메시지를 수정하여, 새로운 메시지(수정본)를 생성. + * 새로운 메시지 type은 EDIT로 변경됨. + */ + fun withEdit(newName: String, newContent: String): DirectMessage { + return DirectMessage( + id = this.id, + channelId = this.channelId, + userId = this.userId, + type = DirectMessageType.EDIT, + profileImage = this.profileImage, + read = this.read, + name = newName, + content = newContent, + thumbnail = this.thumbnail, + parentId = this.parentId, + parentName = this.parentName, + parentContent = this.parentContent, + isDeleted = false + ) + } + override fun toString(): String { return "DirectMessage(" + "id=$id, " + diff --git a/src/backend/chat-server/src/main/kotlin/com/asyncgate/chat_server/entity/DirectMessageEntity.kt b/src/backend/chat-server/src/main/kotlin/com/asyncgate/chat_server/entity/DirectMessageEntity.kt index 79c9a82d..20f28047 100644 --- a/src/backend/chat-server/src/main/kotlin/com/asyncgate/chat_server/entity/DirectMessageEntity.kt +++ b/src/backend/chat-server/src/main/kotlin/com/asyncgate/chat_server/entity/DirectMessageEntity.kt @@ -1,14 +1,13 @@ package com.asyncgate.chat_server.entity import com.asyncgate.chat_server.domain.DirectMessageType -import com.asyncgate.chat_server.support.utility.IdGenerator import org.springframework.data.annotation.Id import org.springframework.data.mongodb.core.mapping.Document @Document(collection = "directMessage") data class DirectMessageEntity( - @get:Id - val id: String = IdGenerator.generate(), + @Id + val id: String, val channelId: String, val userId: String, diff --git a/src/backend/chat-server/src/main/kotlin/com/asyncgate/chat_server/entity/MongoBaseTimeEntity.kt b/src/backend/chat-server/src/main/kotlin/com/asyncgate/chat_server/entity/MongoBaseTimeEntity.kt index eefca70c..3fed46c5 100644 --- a/src/backend/chat-server/src/main/kotlin/com/asyncgate/chat_server/entity/MongoBaseTimeEntity.kt +++ b/src/backend/chat-server/src/main/kotlin/com/asyncgate/chat_server/entity/MongoBaseTimeEntity.kt @@ -1,12 +1,11 @@ package com.asyncgate.chat_server.entity -import org.springframework.data.annotation.CreatedDate import org.springframework.data.annotation.LastModifiedDate import java.time.LocalDateTime abstract class MongoBaseTimeEntity { - @CreatedDate + @LastModifiedDate var createdAt: LocalDateTime? = null @LastModifiedDate diff --git a/src/backend/chat-server/src/main/kotlin/com/asyncgate/chat_server/exception/ChatServerErrorHandler.kt b/src/backend/chat-server/src/main/kotlin/com/asyncgate/chat_server/exception/ChatServerErrorHandler.kt index 913d6469..ef4f9631 100644 --- a/src/backend/chat-server/src/main/kotlin/com/asyncgate/chat_server/exception/ChatServerErrorHandler.kt +++ b/src/backend/chat-server/src/main/kotlin/com/asyncgate/chat_server/exception/ChatServerErrorHandler.kt @@ -4,6 +4,7 @@ import com.asyncgate.chat_server.support.response.FailResponse import org.slf4j.Logger import org.slf4j.LoggerFactory import org.springframework.http.ResponseEntity +import org.springframework.web.bind.MissingServletRequestParameterException import org.springframework.web.bind.annotation.ControllerAdvice import org.springframework.web.bind.annotation.ExceptionHandler import org.springframework.web.servlet.resource.NoResourceFoundException @@ -23,12 +24,32 @@ class ChatServerErrorHandler { return ResponseEntity.status(errorType.status).body(response) } + @ExceptionHandler(NoResourceFoundException::class) + fun handleResourceException(exception: NoResourceFoundException): ResponseEntity { + log.error("🚨 [Global Error] Resource not found: ${exception.message}", exception) + val errorType = FailType.RESOURCE_NOT_FOUND + val response: FailResponse = FailResponse.of( + errorType.errorCode, + errorType.message, + errorType.status.value() + ) + return ResponseEntity.status(errorType.status).body(response) + } + + @ExceptionHandler(MissingServletRequestParameterException::class) + fun handleMissingRequestParamException(e: MissingServletRequestParameterException): ResponseEntity { + log.error("🚨 [Global Error] Missing request parameter: ${e.parameterName}", e) + val errorType = FailType.REQUEST_PARAMETER_MISSING + val response: FailResponse = FailResponse.of( + errorType.errorCode, + errorType.message, + errorType.status.value() + ) + return ResponseEntity.status(errorType.status).body(response) + } + @ExceptionHandler(Exception::class) fun handleException(exception: Exception) { log.error("🚨 [Global Error] ${exception.message}", exception) } - - @ExceptionHandler(NoResourceFoundException::class) - fun handleResourceException(exception: NoResourceFoundException) { - } } diff --git a/src/backend/chat-server/src/main/kotlin/com/asyncgate/chat_server/exception/FailType.kt b/src/backend/chat-server/src/main/kotlin/com/asyncgate/chat_server/exception/FailType.kt index 0359a25c..18ac0cc7 100644 --- a/src/backend/chat-server/src/main/kotlin/com/asyncgate/chat_server/exception/FailType.kt +++ b/src/backend/chat-server/src/main/kotlin/com/asyncgate/chat_server/exception/FailType.kt @@ -30,5 +30,7 @@ enum class FailType( DIRECT_MESSAGE_BAD_REQUEST(HttpStatus.BAD_REQUEST, "DirectMessage_4002", "IMAGE가 필수입니다."), DIRECT_MESSAGE_CONTENT_NULL(HttpStatus.BAD_REQUEST, "DirectMessage_4002", "CODE, SNIPPET 타입은 content가 필수입니다."), + REQUEST_PARAMETER_MISSING(HttpStatus.BAD_REQUEST, "Request_4001", "필수 요청 파라미터가 누락되었습니다."), + RESOURCE_NOT_FOUND(HttpStatus.NOT_FOUND, "Resource_4040", "요청한 리소스를 찾을 수 없습니다."), ; } diff --git a/src/backend/chat-server/src/main/kotlin/com/asyncgate/chat_server/service/DirectListener.kt b/src/backend/chat-server/src/main/kotlin/com/asyncgate/chat_server/service/DirectListener.kt index 3cf2aa04..5f17e411 100644 --- a/src/backend/chat-server/src/main/kotlin/com/asyncgate/chat_server/service/DirectListener.kt +++ b/src/backend/chat-server/src/main/kotlin/com/asyncgate/chat_server/service/DirectListener.kt @@ -3,8 +3,6 @@ package com.asyncgate.chat_server.service import com.asyncgate.chat_server.domain.DirectMessage import com.asyncgate.chat_server.domain.DirectMessageType import com.asyncgate.chat_server.domain.ReadStatus -import com.asyncgate.chat_server.exception.ChatServerException -import com.asyncgate.chat_server.exception.FailType import com.fasterxml.jackson.databind.ObjectMapper import org.slf4j.Logger import org.slf4j.LoggerFactory @@ -32,13 +30,13 @@ class DirectListener( log.info("directMessage = $directMessage") val msg = HashMap() - msg["type"] = DirectMessageType.CREATE.toString().lowercase() + msg["type"] = DirectMessageType.CREATE.toString() msg["userId"] = java.lang.String.valueOf(directMessage.userId) msg["name"] = directMessage.name ?: "" msg["profileImage"] = directMessage.profileImage ?: "" msg["message"] = directMessage.content ?: "" msg["time"] = java.lang.String.valueOf(directMessage.createdAt) - msg["id"] = directMessage.id ?: throw ChatServerException(FailType.X_DIRECT_INTERNAL_ERROR) + msg["id"] = directMessage.id val serializable = objectMapper.writeValueAsString(msg) template.convertAndSend("/topic/direct-message/" + directMessage.channelId, serializable) @@ -85,13 +83,14 @@ class DirectListener( when (directMessage.type) { DirectMessageType.EDIT -> { + msg["id"] = directMessage.id msg["type"] = DirectMessageType.EDIT.toString() msg["userId"] = directMessage.userId msg["channelId"] = directMessage.channelId msg["content"] = directMessage.content ?: "" } DirectMessageType.DELETE -> { - msg["id"] = directMessage.id ?: throw ChatServerException(FailType.X_DIRECT_INTERNAL_ERROR) + msg["id"] = directMessage.id msg["type"] = DirectMessageType.DELETE.toString() msg["userId"] = directMessage.userId msg["channelId"] = directMessage.channelId diff --git a/src/backend/chat-server/src/main/kotlin/com/asyncgate/chat_server/service/DirectService.kt b/src/backend/chat-server/src/main/kotlin/com/asyncgate/chat_server/service/DirectService.kt index ec53560d..50f972bf 100644 --- a/src/backend/chat-server/src/main/kotlin/com/asyncgate/chat_server/service/DirectService.kt +++ b/src/backend/chat-server/src/main/kotlin/com/asyncgate/chat_server/service/DirectService.kt @@ -11,7 +11,6 @@ import com.asyncgate.chat_server.exception.FailType import com.asyncgate.chat_server.kafka.KafkaProperties import com.asyncgate.chat_server.repository.DirectMessageRepository import com.asyncgate.chat_server.repository.ReadStatusRepository -import com.asyncgate.chat_server.support.utility.IdGenerator import com.asyncgate.chat_server.support.utility.S3Util import com.asyncgate.chat_server.support.utility.toDomain import com.asyncgate.chat_server.support.utility.toEntity @@ -95,28 +94,24 @@ class DirectServiceImpl( @Transactional override fun edit(directMessage: DirectMessage) { - checkNotNull(directMessage.id) { - "Logic error: 수정시에는 id가 필수이므로 존재하지 않을 수 없음" - } - + checkNotNull(directMessage.id) { "Logic error: 수정시에는 id가 필수이므로 존재하지 않을 수 없음" } val pastMessage = directMessageRepository.findById(directMessage.id) - - checkNotNull(pastMessage) { - "Logic error: 이미 Null Check 완료" - } + ?: throw IllegalStateException("Logic error: 이미 Null Check 완료") validPermission(directMessage, pastMessage) - val deletedEntity = pastMessage.toEntity().copy(isDeleted = true) - directMessageRepository.save(deletedEntity.toDomain()) + // 도메인 멤버 메서드를 활용하여 수정 결과를 생성 + val deletedMessage = pastMessage.markDeleted() - val updatedMessage = pastMessage.toEntity().copy( - id = IdGenerator.generate(), - name = directMessage.name, - content = directMessage.content, - type = DirectMessageType.EDIT - ) - directMessageRepository.save(updatedMessage.toDomain()) + checkNotNull(directMessage.name) { "Logic error: 수정시 name이 필수로 들어옴" } + checkNotNull(directMessage.content) { "Logic error: 수정시 content가 필수로 들어옴" } + + val editedMessage = pastMessage.withEdit(directMessage.name, directMessage.content) + + // 기존 메시지를 삭제 상태로 업데이트 (엔티티 변환 후, isDeleted를 true로 설정) + directMessageRepository.save(deletedMessage.toEntity().copy(isDeleted = true).toDomain()) + // 새로 생성된 수정본 저장 + directMessageRepository.save(editedMessage) val key = directMessage.channelId kafkaTemplateForDirectMessage.send(kafkaProperties.topic.directAction, key, directMessage) diff --git a/src/backend/chat-server/src/main/kotlin/com/asyncgate/chat_server/support/utility/DirectMessageMapper.kt b/src/backend/chat-server/src/main/kotlin/com/asyncgate/chat_server/support/utility/DirectMessageMapper.kt index 5c76e353..915d8511 100644 --- a/src/backend/chat-server/src/main/kotlin/com/asyncgate/chat_server/support/utility/DirectMessageMapper.kt +++ b/src/backend/chat-server/src/main/kotlin/com/asyncgate/chat_server/support/utility/DirectMessageMapper.kt @@ -12,7 +12,7 @@ import org.springframework.data.domain.Page fun DirectMessage.toEntity(existingEntity: DirectMessageEntity? = null): DirectMessageEntity { return DirectMessageEntity( - id = id ?: IdGenerator.generate(), + id = id, channelId = channelId, userId = userId, profileImage = profileImage, @@ -24,7 +24,7 @@ fun DirectMessage.toEntity(existingEntity: DirectMessageEntity? = null): DirectM parentId = parentId, parentName = parentName, parentContent = parentContent, - isDeleted = existingEntity?.isDeleted ?: false + isDeleted = existingEntity?.isDeleted ?: isDeleted ) } @@ -43,7 +43,8 @@ fun DirectMessageEntity.toDomain(): DirectMessage { parentContent = parentContent, createdAt = createdAt, updatedAt = updatedAt, - type = type + type = type, + isDeleted = isDeleted ) } @@ -53,7 +54,7 @@ fun DirectMessage.toFileResponse( fileRequest: FileRequest, ): FileUploadResponse { val response = FileUploadResponse( - id = domain.id ?: throw ChatServerException(FailType.X_DIRECT_INTERNAL_ERROR), + id = domain.id, name = domain.name ?: throw ChatServerException(FailType.X_DIRECT_INTERNAL_ERROR), domain.profileImage ?: throw ChatServerException(FailType.X_DIRECT_INTERNAL_ERROR), content = domain.content ?: throw ChatServerException(FailType.X_DIRECT_INTERNAL_ERROR), @@ -67,7 +68,7 @@ fun DirectMessage.toFileResponse( fun DirectMessage.toSingleResponse(): DirectSingleResponse { return DirectSingleResponse( - id = id ?: throw ChatServerException(FailType.X_DIRECT_INTERNAL_ERROR), + id = id, channelId = channelId, userId = userId, type = type, diff --git a/src/backend/chat-server/src/main/kotlin/com/asyncgate/chat_server/support/utility/RequestResponseLoggingAspect.kt b/src/backend/chat-server/src/main/kotlin/com/asyncgate/chat_server/support/utility/RequestResponseLoggingAspect.kt new file mode 100644 index 00000000..9a39b6d0 --- /dev/null +++ b/src/backend/chat-server/src/main/kotlin/com/asyncgate/chat_server/support/utility/RequestResponseLoggingAspect.kt @@ -0,0 +1,75 @@ +package com.asyncgate.chat_server.support.utility + +import jakarta.servlet.http.HttpServletRequest +import org.aspectj.lang.JoinPoint +import org.aspectj.lang.annotation.After +import org.aspectj.lang.annotation.AfterReturning +import org.aspectj.lang.annotation.Aspect +import org.aspectj.lang.annotation.Before +import org.aspectj.lang.annotation.Pointcut +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.slf4j.MDC +import org.springframework.stereotype.Component +import org.springframework.web.context.request.RequestContextHolder +import org.springframework.web.context.request.ServletRequestAttributes +import java.util.* + +@Aspect +@Component +class RequestResponseLoggingAspect { + + @Pointcut("execution(* com.asyncgate.chat_server.controller..*Controller.*(..))") + fun apiControllerMethods() {} + + @Pointcut("!execution(* com.asyncgate.chat_server.controller.HealthCheckController.*(..))") + fun excludeHealthCheck() {} + + @Pointcut("apiControllerMethods() && excludeHealthCheck()") + fun apiControllerMethodsExcludingHealthCheck() {} + + @Before("apiControllerMethodsExcludingHealthCheck()") + fun logRequest(joinPoint: JoinPoint) { + setMDC() + requestLogger.info("Request received for method: {}", joinPoint.signature.name) + } + + @AfterReturning(pointcut = "apiControllerMethodsExcludingHealthCheck()") + fun logResponse() { + val startTimeStr = MDC.get("startTime") + val startTime = startTimeStr?.toLong() ?: 0L + val executionTime = (System.nanoTime() - startTime) / 1_000_000_000.0 + MDC.put("responseTime", String.format("%.3f초", executionTime)) + responseLogger.info("Response sent successfully") + } + + @After("apiControllerMethodsExcludingHealthCheck()") + fun clearMDC() { + MDC.clear() + } + + private fun setMDC() { + val request = currentHttpRequest + if (request != null) { + MDC.put("method", request.method) + MDC.put("requestUri", request.requestURI) + MDC.put("sourceIp", request.getHeader("X-Real-IP") ?: request.remoteAddr) + MDC.put("userAgent", request.getHeader("User-Agent")) + MDC.put("xForwardedFor", request.getHeader("X-Forwarded-For")) + MDC.put("xForwardedProto", request.getHeader("X-Forwarded-Proto")) + MDC.put("requestId", UUID.randomUUID().toString()) + MDC.put("startTime", System.nanoTime().toString()) + } + } + + private val currentHttpRequest: HttpServletRequest? + get() { + val attributes = RequestContextHolder.getRequestAttributes() as? ServletRequestAttributes + return attributes?.request + } + + companion object { + private val requestLogger: Logger = LoggerFactory.getLogger("HttpRequestLog") + private val responseLogger: Logger = LoggerFactory.getLogger("HttpResponseLog") + } +}