diff --git a/build.gradle b/build.gradle index 1ccc1e45d..635ef125d 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,7 @@ buildscript { repositories { mavenCentral() + gradlePluginPortal() } } @@ -30,6 +31,7 @@ subprojects { repositories { mavenCentral() + gradlePluginPortal() } configurations { diff --git a/pennyway-socket/build.gradle b/pennyway-socket/build.gradle index 58a265d22..0f0117c82 100644 --- a/pennyway-socket/build.gradle +++ b/pennyway-socket/build.gradle @@ -1,5 +1,9 @@ +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + plugins { id 'java' + id 'org.jetbrains.kotlin.jvm' version '1.8.21' + id 'org.jetbrains.kotlin.plugin.spring' version '1.8.21' } bootJar { enabled = true } @@ -32,4 +36,12 @@ dependencies { implementation group: 'org.openapitools', name: 'jackson-databind-nullable', version: '0.2.6' implementation 'org.springframework.boot:spring-boot-starter-validation:3.2.3' +} + +tasks.withType(KotlinCompile) { + kotlinOptions { + freeCompilerArgs = ['-Xjsr305=strict'] + javaParameters = true + jvmTarget = '17' + } } \ No newline at end of file diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/PennywaySocketApplication.java b/pennyway-socket/src/main/java/kr/co/pennyway/PennywaySocketApplication.java deleted file mode 100644 index 0c0b6952c..000000000 --- a/pennyway-socket/src/main/java/kr/co/pennyway/PennywaySocketApplication.java +++ /dev/null @@ -1,19 +0,0 @@ -package kr.co.pennyway; - -import jakarta.annotation.PostConstruct; -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; - -import java.util.TimeZone; - -@SpringBootApplication -public class PennywaySocketApplication { - public static void main(String[] args) { - SpringApplication.run(PennywaySocketApplication.class, args); - } - - @PostConstruct - public void init() { - TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")); - } -} \ No newline at end of file diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/PennywaySocketApplication.kt b/pennyway-socket/src/main/java/kr/co/pennyway/PennywaySocketApplication.kt new file mode 100644 index 000000000..e7d846a6f --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/PennywaySocketApplication.kt @@ -0,0 +1,18 @@ +package kr.co.pennyway; + +import jakarta.annotation.PostConstruct +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.runApplication +import java.util.* + +@SpringBootApplication +class PennywaySocketApplication { + @PostConstruct + fun setDefaultTimeZone() { + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")) + } +} + +fun main(args: Array) { + runApplication(*args) +} diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/aop/PreAuthorizeAspect.java b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/aop/PreAuthorizeAspect.java deleted file mode 100644 index 560243d7e..000000000 --- a/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/aop/PreAuthorizeAspect.java +++ /dev/null @@ -1,74 +0,0 @@ -package kr.co.pennyway.socket.common.aop; - -import kr.co.pennyway.socket.common.annotation.PreAuthorize; -import kr.co.pennyway.socket.common.exception.PreAuthorizeErrorCode; -import kr.co.pennyway.socket.common.exception.PreAuthorizeErrorException; -import kr.co.pennyway.socket.common.util.PreAuthorizeSpELParser; -import lombok.RequiredArgsConstructor; -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.context.ApplicationContext; -import org.springframework.stereotype.Component; - -import java.lang.reflect.Method; -import java.security.Principal; -import java.util.stream.Stream; - -@Slf4j -@Aspect -@Component -@RequiredArgsConstructor -public class PreAuthorizeAspect { - private final ApplicationContext applicationContext; - - /** - * {@link PreAuthorize} 어노테이션이 붙은 메서드를 가로채고 인증/인가를 수행합니다. - * - * @param joinPoint 가로챈 메서드의 실행 지점 - * @return 인증/인가가 성공하면 원래 메서드의 실행 결과, 실패하면 UnauthorizedResponse - * @throws Throwable 메서드 실행 중 발생한 예외 - */ - @Around("@annotation(kr.co.pennyway.socket.common.annotation.PreAuthorize)") - public Object execute(final ProceedingJoinPoint joinPoint) throws Throwable { - MethodSignature signature = (MethodSignature) joinPoint.getSignature(); - Method method = signature.getMethod(); - PreAuthorize preAuthorize = method.getAnnotation(PreAuthorize.class); - - Principal principal = extractPrincipal(joinPoint.getArgs()); - - boolean isAuthorized = PreAuthorizeSpELParser.evaluate(preAuthorize.value(), method, joinPoint.getArgs(), applicationContext); - - if (!isAuthorized) { - handleUnauthorized(principal, preAuthorize); - } - - return joinPoint.proceed(); - } - - /** - * 메서드 인자에서 Principal 객체를 추출합니다. - * - * @param args 메서드 인자 배열 - * @return 찾은 Principal 객체, 없으면 null - */ - private Principal extractPrincipal(Object[] args) { - return Stream.of(args) - .filter(arg -> arg instanceof Principal) - .map(arg -> (Principal) arg) - .findFirst() - .orElse(null); - } - - /** - * 인증/인가 실패 시 수행할 동작을 정의합니다. - */ - private void handleUnauthorized(Principal principal, PreAuthorize preAuthorize) { - if (preAuthorize.value().contains(PreAuthorizeSpELParser.SpELFunction.IS_AUTHENTICATED.getName())) { - log.warn("인증 실패: {}", principal); - throw new PreAuthorizeErrorException(PreAuthorizeErrorCode.UNAUTHENTICATED); - } - } -} diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/aop/PreAuthorizeAspect.kt b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/aop/PreAuthorizeAspect.kt new file mode 100644 index 000000000..7bf6ad853 --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/aop/PreAuthorizeAspect.kt @@ -0,0 +1,90 @@ +package kr.co.pennyway.socket.common.aop; + +import kr.co.pennyway.socket.common.annotation.PreAuthorize +import kr.co.pennyway.socket.common.exception.PreAuthorizeErrorCode +import kr.co.pennyway.socket.common.exception.PreAuthorizeErrorException +import kr.co.pennyway.socket.common.util.PreAuthorizeSpELParser +import kr.co.pennyway.socket.common.util.logger +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.context.ApplicationContext +import org.springframework.stereotype.Component +import java.lang.reflect.Method +import java.security.Principal + +@Aspect +@Component +class PreAuthorizeAspect(private val applicationContext: ApplicationContext) { + private val log = logger() + + /** + * {@link PreAuthorize} 어노테이션이 붙은 메서드를 가로채고 인증/인가를 수행합니다. + * + * @param joinPoint 가로챈 메서드의 실행 지점 + * @return 인증/인가가 성공하면 원래 메서드의 실행 결과, 실패하면 UnauthorizedResponse + * @throws Throwable 메서드 실행 중 발생한 예외 + */ + @Around("@annotation(kr.co.pennyway.socket.common.annotation.PreAuthorize)") + fun execute(joinPoint: ProceedingJoinPoint): Any = with(joinPoint) { + (signature as? MethodSignature) + ?.method + ?.let { method -> validateAccess(method, this) } + ?: throw IllegalStateException("PreAuthorize는 메서드에만 적용할 수 있습니다") + } + + private fun validateAccess(method: Method, joinPoint: ProceedingJoinPoint): Any { + val preAuthorize = method.requireAnnotation() + val principal = joinPoint.args.findPrincipal() + + evaluateAccess( + principal = principal, + preAuthorize = preAuthorize, + method = method, + args = joinPoint.args + ) + + return joinPoint.proceed() + } + + private fun evaluateAccess( + principal: Principal?, + preAuthorize: PreAuthorize, + method: Method, + args: Array + ) = PreAuthorizeSpELParser + .evaluate( + expression = preAuthorize.value, + method = method, + args = args, + applicationContext = applicationContext + ) + .also { result -> handleEvaluationResult(result, principal) } + + private fun handleEvaluationResult( + result: PreAuthorizeSpELParser.EvaluationResult, + principal: Principal? + ) = when (result) { + is PreAuthorizeSpELParser.EvaluationResult.Permitted -> Unit + is PreAuthorizeSpELParser.EvaluationResult.Denied.Unauthenticated -> { + log.warn("인증 실패: {}", principal) + throw PreAuthorizeErrorException(PreAuthorizeErrorCode.UNAUTHENTICATED) + } + + is PreAuthorizeSpELParser.EvaluationResult.Denied.Unauthorized -> { + log.warn("인가 실패: {}", principal) + throw PreAuthorizeErrorException(PreAuthorizeErrorCode.FORBIDDEN) + } + } + + private companion object { + inline fun Method.requireAnnotation(): T = + getAnnotation(T::class.java) + ?: throw IllegalStateException("Required annotation ${T::class.simpleName} not found") + + fun Array.findPrincipal(): Principal? = asSequence() + .filterIsInstance() + .firstOrNull() + } +} \ No newline at end of file diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/security/authenticate/UserPrincipal.java b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/security/authenticate/UserPrincipal.java deleted file mode 100644 index 1c6ed97b2..000000000 --- a/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/security/authenticate/UserPrincipal.java +++ /dev/null @@ -1,97 +0,0 @@ -package kr.co.pennyway.socket.common.security.authenticate; - -import kr.co.pennyway.domain.domains.user.domain.User; -import kr.co.pennyway.domain.domains.user.type.Role; -import lombok.Builder; -import lombok.Getter; - -import java.security.Principal; -import java.time.LocalDateTime; - -@Getter -public class UserPrincipal implements Principal { - private final Long userId; - private String name; - private String username; - private Role role; - private boolean isChatNotify; - private LocalDateTime expiresAt; - private String deviceId; - private String deviceName; - - @Builder - private UserPrincipal(Long userId, String name, String username, Role role, boolean isChatNotify, LocalDateTime expiresAt, String deviceId, String deviceName) { - this.userId = userId; - this.name = name; - this.username = username; - this.role = role; - this.isChatNotify = isChatNotify; - this.expiresAt = expiresAt; - this.deviceId = deviceId; - this.deviceName = deviceName; - } - - public static UserPrincipal of(User user, LocalDateTime expiresAt, String deviceId, String deviceName) { - return UserPrincipal.builder() - .userId(user.getId()) - .name(user.getName()) - .username(user.getUsername()) - .role(user.getRole()) - .isChatNotify(user.getNotifySetting().isChatNotify()) - .expiresAt(expiresAt) - .deviceId(deviceId) - .deviceName(deviceName) - .build(); - } - - public void updateExpiresAt(LocalDateTime expiresAt) { - if (expiresAt.isBefore(this.expiresAt)) { - throw new IllegalArgumentException("만료 시간을 줄일 수 없습니다."); - } - - this.expiresAt = expiresAt; - } - - // Principal이 getName으로 사용자를 식별하는 메서드로 구현되어 있음. - @Override - public String getName() { - return userId.toString(); - } - - // name 필드를 조회하기 위한 메서드 - public String getDefaultName() { - return name; - } - - @Override - public int hashCode() { - int result = userId.hashCode() * 31; - return result + username.hashCode() * 31; - } - - @Override - public boolean equals(Object obj) { - if (this == obj) { - return true; - } - if (obj == null || getClass() != obj.getClass()) { - return false; - } - UserPrincipal that = (UserPrincipal) obj; - return userId.equals(that.userId); - } - - @Override - public String toString() { - return "UserPrincipal{" + - "userId=" + userId + - ", name='" + name + '\'' + - ", username='" + username + '\'' + - ", role=" + role + '\'' + - ", isChatNotify=" + isChatNotify + '\'' + - ", expiresAt=" + expiresAt + '\'' + - ", deviceId='" + deviceId + '\'' + - ", deviceName='" + deviceName + '\'' + - '}'; - } -} \ No newline at end of file diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/security/authenticate/UserPrincipal.kt b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/security/authenticate/UserPrincipal.kt new file mode 100644 index 000000000..0891b6ef4 --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/security/authenticate/UserPrincipal.kt @@ -0,0 +1,48 @@ +package kr.co.pennyway.socket.common.security.authenticate; + +import kr.co.pennyway.domain.domains.user.domain.User +import kr.co.pennyway.domain.domains.user.type.Role +import java.security.Principal +import java.time.LocalDateTime + +data class UserPrincipal( + val userId: Long, + private var _name: String, + var username: String, + var role: Role, + var isChatNotify: Boolean, + var expiresAt: LocalDateTime, + var deviceId: String, + var deviceName: String +) : Principal { + fun isAuthenticated(): Boolean = !isExpired() + + fun updateExpiresAt(newExpiresAt: LocalDateTime) { + this.expiresAt = newExpiresAt + } + + override fun getName(): String = userId.toString() + + fun getDefaultName(): String = _name + + private fun isExpired(): Boolean = LocalDateTime.now().isAfter(expiresAt) + + companion object { + @JvmStatic + fun of( + user: User, + expiresAt: LocalDateTime, + deviceId: String, + deviceName: String + ): UserPrincipal = UserPrincipal( + userId = user.id, + _name = user.name, + username = user.username, + role = user.role, + isChatNotify = user.notifySetting.isChatNotify, + expiresAt = expiresAt, + deviceId = deviceId, + deviceName = deviceName + ) + } +} \ No newline at end of file diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/util/PreAuthorizeSpELParser.java b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/util/PreAuthorizeSpELParser.java deleted file mode 100644 index 582629802..000000000 --- a/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/util/PreAuthorizeSpELParser.java +++ /dev/null @@ -1,130 +0,0 @@ -package kr.co.pennyway.socket.common.util; - -import kr.co.pennyway.socket.common.security.authenticate.UserPrincipal; -import org.springframework.context.ApplicationContext; -import org.springframework.context.expression.BeanFactoryResolver; -import org.springframework.expression.ExpressionParser; -import org.springframework.expression.spel.standard.SpelExpressionParser; -import org.springframework.expression.spel.support.StandardEvaluationContext; - -import java.lang.reflect.Method; -import java.lang.reflect.Parameter; -import java.security.Principal; -import java.time.LocalDateTime; - -/** - * WebSocket 인증 및 인가를 위한 Spring Expression Language (SpEL) 파서. - * 이 클래스는 WebSocket 연결에서 사용되는 다양한 인증/인가 함수를 제공하고, - * SpEL 표현식을 평가하는 기능을 제공합니다. - * - * @author YANG JAESEO - * @version 1.0.0 - * @since 2024.09.26 - */ -public final class PreAuthorizeSpELParser { - private static final ExpressionParser parser = new SpelExpressionParser(); - private static final StandardEvaluationContext context = new StandardEvaluationContext(); - - static { - initializeStaticContext(); - } - - private PreAuthorizeSpELParser() { - throw new IllegalStateException("Utility class"); - } - - private static void initializeStaticContext() { - for (SpELFunction function : SpELFunction.values()) { - try { - context.registerFunction(function.getName(), - PreAuthorizeSpELParser.class.getDeclaredMethod(function.getMethodName(), function.getParameterTypes())); - } catch (NoSuchMethodException e) { - throw new RuntimeException("Error registering SpEL function: " + function.getName(), e); - } - } - } - - /** - * 주어진 SpEL 표현식을 평가합니다. - * - * @param expression 평가할 SpEL 표현식 - * @param method 평가 중인 메서드 - * @param args 메서드의 인자들 - * @param applicationContext Spring의 ApplicationContext - * @return 표현식 평가 결과 (true/false) - */ - public static synchronized boolean evaluate(String expression, Method method, Object[] args, ApplicationContext applicationContext) { - populateContext(method, args, applicationContext); - return Boolean.TRUE.equals(parser.parseExpression(expression).getValue(context, Boolean.class)); - } - - /** - * SpEL 평가를 위해, 사용자의 Principal 객체와 메서드의 인자들을 EvaluationContext에 추가합니다. - * - * @param method 평가 중인 메서드 - * @param args 메서드의 인자들 - * @param applicationContext Spring의 ApplicationContext - * @return 생성된 StandardEvaluationContext - */ - private static void populateContext(Method method, Object[] args, ApplicationContext applicationContext) { - context.setBeanResolver(new BeanFactoryResolver(applicationContext)); - - Parameter[] parameters = method.getParameters(); - for (int i = 0; i < parameters.length; i++) { - context.setVariable(parameters[i].getName(), args[i]); - } - } - - /** - * 모든 사용자에게 접근을 허용합니다. - * - * @return 언제나 true를 반환한다. - */ - public static boolean permitAll() { - return true; - } - - /** - * 주어진 Principal이 인증된 사용자인지 확인합니다. - * - * @param principal 확인할 Principal 객체 - * @return 인증된 사용자이고 토큰이 만료되지 않았으면 true, 그렇지 않으면 false - */ - public static boolean isAuthenticated(Principal principal) { - if (principal instanceof UserPrincipal userPrincipal) { - return userPrincipal.getExpiresAt().isAfter(LocalDateTime.now()); - } - return false; - } - - /** - * WebSocket 인증/인가에 사용되는 SpEL 함수들을 정의하는 열거형. - * 각 함수는 이름, 메서드 이름, 파라미터 타입을 가집니다. - */ - public enum SpELFunction { - PERMIT_ALL("permitAll", "permitAll"), - IS_AUTHENTICATED("isAuthenticated", "isAuthenticated", Principal.class); - - private final String name; - private final String methodName; - private final Class[] parameterTypes; - - SpELFunction(String name, String methodName, Class... parameterTypes) { - this.name = name; - this.methodName = methodName; - this.parameterTypes = parameterTypes; - } - - public String getName() { - return name; - } - - public String getMethodName() { - return methodName; - } - - public Class[] getParameterTypes() { - return parameterTypes; - } - } -} \ No newline at end of file diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/util/PreAuthorizeSpELParser.kt b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/util/PreAuthorizeSpELParser.kt new file mode 100644 index 000000000..355f2fa33 --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/util/PreAuthorizeSpELParser.kt @@ -0,0 +1,130 @@ +package kr.co.pennyway.socket.common.util; + +import kr.co.pennyway.socket.common.security.authenticate.UserPrincipal +import org.springframework.context.ApplicationContext +import org.springframework.context.expression.BeanFactoryResolver +import org.springframework.expression.spel.standard.SpelExpressionParser +import org.springframework.expression.spel.support.StandardEvaluationContext +import java.lang.reflect.Method +import java.security.Principal + +/** + * WebSocket 인증 및 인가를 위한 Spring Expression Language (SpEL) 파서. + * 이 클래스는 WebSocket 연결에서 사용되는 다양한 인증/인가 함수를 제공하고, + * SpEL 표현식을 평가하는 기능을 제공합니다. + * + * @author YANG JAESEO + * @version 1.1.0 + * @since 2024.12.25 + */ +object PreAuthorizeSpELParser { + private val parser = SpelExpressionParser() + private val context = StandardEvaluationContext().apply { + initializeContext() + } + + sealed interface EvaluationResult { + object Permitted : EvaluationResult + sealed interface Denied : EvaluationResult { + object Unauthenticated : Denied + object Unauthorized : Denied + } + } + + private fun StandardEvaluationContext.initializeContext() = apply { + SpELFunction.values().forEach { function -> registerSpELFunction(function) } + } + + private fun StandardEvaluationContext.registerSpELFunction(function: SpELFunction) { + runCatching { + PreAuthorizeSpELParser::class.java + .getDeclaredMethod(function.methodName, *function.parameterTypes) + .let { method -> registerFunction(function.level, method) } + }.onFailure { e -> + throw RuntimeException("Error registering SpEL function: ${function.level}", e) + } + } + + /** + * 주어진 SpEL 표현식을 평가합니다. + */ + @Synchronized + fun evaluate( + expression: String, + method: Method, + args: Array, + applicationContext: ApplicationContext + ): EvaluationResult = context.run { + setupContext(method, args, applicationContext) + evaluateExpression(expression) + } + + /** + * SpEL 평가를 위해, 사용자의 Principal 객체와 메서드의 인자들을 EvaluationContext에 추가합니다. + */ + private fun setupContext( + method: Method, + args: Array, + applicationContext: ApplicationContext + ) { + with(context) { + context.setBeanResolver(BeanFactoryResolver(applicationContext)) + + method.parameters.forEachIndexed { index, parameter -> + println("parameter.name: ${parameter.name} -> args[index]: ${args[index]}") + setVariable(parameter.name, args[index]) + } + } + } + + private fun StandardEvaluationContext.evaluateExpression( + expression: String + ): EvaluationResult { + val isAuthenticationRequired = expression.contains(SpELFunction.IS_AUTHENTICATED.level) + + val authenticationResult = when { + isAuthenticationRequired -> evaluateAuthentication() + else -> true + } + + val authorizationResult = evaluateAuthorization(expression) + + return when { + authenticationResult.not() -> EvaluationResult.Denied.Unauthenticated + authorizationResult.not() -> EvaluationResult.Denied.Unauthorized + else -> EvaluationResult.Permitted + } + } + + private fun StandardEvaluationContext.evaluateAuthentication(): Boolean = + parser.parseExpression("#isAuthenticated(#principal)") + .getValue(this, Boolean::class.java) ?: false + + private fun StandardEvaluationContext.evaluateAuthorization(expression: String): Boolean = + parser.parseExpression(expression) + .getValue(this, Boolean::class.java) ?: false + + /** + * 모든 사용자에게 접근을 허용합니다. + */ + @JvmStatic + fun permitAll(): Boolean = true + + /** + * 주어진 Principal이 인증된 사용자인지 확인합니다. + */ + @JvmStatic + fun isAuthenticated(principal: Principal): Boolean = when (principal) { + is UserPrincipal -> principal.isAuthenticated() + else -> false + } + + enum class SpELFunction( + val level: String, + val methodName: String, + vararg val parameterTypes: Class<*> + ) { + PERMIT_ALL("permitAll", "permitAll"), + IS_AUTHENTICATED("isAuthenticated", "isAuthenticated", Principal::class.java); + } +} \ No newline at end of file diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/util/StompMessageUtil.java b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/util/StompMessageUtil.java deleted file mode 100644 index 8f4014978..000000000 --- a/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/util/StompMessageUtil.java +++ /dev/null @@ -1,42 +0,0 @@ -package kr.co.pennyway.socket.common.util; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import kr.co.pennyway.socket.common.dto.ServerSideMessage; -import lombok.experimental.UtilityClass; -import lombok.extern.slf4j.Slf4j; -import org.springframework.messaging.Message; -import org.springframework.messaging.simp.stomp.StompHeaderAccessor; -import org.springframework.messaging.support.MessageBuilder; - -/** - * STOMP 메시지 처리를 위한 유틸리티 클래스. - * 이 클래스는 STOMP 헤더 액세서 생성 및 메시지 생성과 관련된 공통 기능을 제공합니다. - */ -@Slf4j -@UtilityClass -public class StompMessageUtil { - private static final byte[] EMPTY_PAYLOAD = new byte[0]; - - /** - * StompHeaderAccessor와 페이로드를 사용하여 STOMP 메시지를 생성합니다. - * - * @param accessor {@link StompHeaderAccessor} - * @param payload {@link ServerSideMessage} 메시지 페이로드 (null일 수 있음) - * @param objectMapper Jackson ObjectMapper - * @return 생성된 STOMP 메시지 - */ - public static Message createMessage(StompHeaderAccessor accessor, ServerSideMessage payload, ObjectMapper objectMapper) { - if (payload == null) { - return MessageBuilder.createMessage(EMPTY_PAYLOAD, accessor.getMessageHeaders()); - } - - try { - byte[] serializedPayload = objectMapper.writeValueAsBytes(payload); - return MessageBuilder.createMessage(serializedPayload, accessor.getMessageHeaders()); - } catch (JsonProcessingException e) { - log.error("Error serializing payload", e); - return MessageBuilder.createMessage(EMPTY_PAYLOAD, accessor.getMessageHeaders()); - } - } -} diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/util/StompMessageUtil.kt b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/util/StompMessageUtil.kt new file mode 100644 index 000000000..768c429e8 --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/util/StompMessageUtil.kt @@ -0,0 +1,46 @@ +package kr.co.pennyway.socket.common.util; + +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.pennyway.socket.common.dto.ServerSideMessage +import org.springframework.messaging.Message +import org.springframework.messaging.simp.stomp.StompHeaderAccessor +import org.springframework.messaging.support.MessageBuilder + +/** + * STOMP 메시지 처리를 위한 유틸리티 클래스. + * 이 클래스는 STOMP 헤더 액세서 생성 및 메시지 생성과 관련된 공통 기능을 제공합니다. + */ +object StompMessageUtil { + private val log = logger() + private val EMPTY_PAYLOAD = ByteArray(0) + + /** + * StompHeaderAccessor와 페이로드를 사용하여 STOMP 메시지를 생성합니다. + * + * @param accessor StompHeaderAccessor + * @param payload ServerSideMessage 메시지 페이로드 (null일 수 있음) + * @param objectMapper Jackson ObjectMapper + * @return 생성된 STOMP 메시지 + */ + @JvmStatic + fun createMessage( + accessor: StompHeaderAccessor, + payload: ServerSideMessage?, + objectMapper: ObjectMapper + ): Message = payload?.let { nonNullPayload -> + runCatching { + objectMapper.writeValueAsBytes(nonNullPayload) + }.fold( + onSuccess = { bytes -> + MessageBuilder.createMessage(bytes, accessor.messageHeaders) + }, + onFailure = { e -> + log.error("Error serializing payload", e) + createEmptyMessage(accessor) + } + ) + } ?: createEmptyMessage(accessor) + + private fun createEmptyMessage(accessor: StompHeaderAccessor): Message = + MessageBuilder.createMessage(EMPTY_PAYLOAD, accessor.messageHeaders) +} diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/util/log.kt b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/util/log.kt new file mode 100644 index 000000000..9eff54c4c --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/common/util/log.kt @@ -0,0 +1,5 @@ +package kr.co.pennyway.socket.common.util + +import org.slf4j.LoggerFactory + +inline fun T.logger() = LoggerFactory.getLogger(T::class.java)!! diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/controller/AuthController.java b/pennyway-socket/src/main/java/kr/co/pennyway/socket/controller/AuthController.java deleted file mode 100644 index 208fbc04b..000000000 --- a/pennyway-socket/src/main/java/kr/co/pennyway/socket/controller/AuthController.java +++ /dev/null @@ -1,33 +0,0 @@ -package kr.co.pennyway.socket.controller; - -import kr.co.pennyway.infra.common.exception.JwtErrorCode; -import kr.co.pennyway.infra.common.exception.JwtErrorException; -import kr.co.pennyway.socket.common.annotation.PreAuthorize; -import kr.co.pennyway.socket.common.security.authenticate.UserPrincipal; -import kr.co.pennyway.socket.service.AuthService; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.messaging.handler.annotation.Header; -import org.springframework.messaging.handler.annotation.MessageMapping; -import org.springframework.messaging.simp.stomp.StompHeaderAccessor; -import org.springframework.stereotype.Controller; - -import java.security.Principal; - -@Slf4j -@Controller -@RequiredArgsConstructor -public class AuthController { - private final AuthService authService; - - @MessageMapping("auth.refresh") - @PreAuthorize("#principal instanceof T(kr.co.pennyway.socket.common.security.authenticate.UserPrincipal)") - public void refreshPrincipal(@Header("Authorization") String authorization, Principal principal, StompHeaderAccessor accessor) { - if (authorization == null || !authorization.startsWith("Bearer ")) { - throw new JwtErrorException(JwtErrorCode.EMPTY_ACCESS_TOKEN); - } - String token = authorization.substring(7); - - authService.refreshPrincipal(token, (UserPrincipal) principal, accessor); - } -} diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/controller/AuthController.kt b/pennyway-socket/src/main/java/kr/co/pennyway/socket/controller/AuthController.kt new file mode 100644 index 000000000..0632af427 --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/controller/AuthController.kt @@ -0,0 +1,42 @@ +package kr.co.pennyway.socket.controller; + +import kr.co.pennyway.infra.common.exception.JwtErrorCode +import kr.co.pennyway.infra.common.exception.JwtErrorException +import kr.co.pennyway.socket.common.annotation.PreAuthorize +import kr.co.pennyway.socket.common.security.authenticate.UserPrincipal +import kr.co.pennyway.socket.common.util.logger +import kr.co.pennyway.socket.service.AuthService +import org.springframework.messaging.handler.annotation.Header +import org.springframework.messaging.handler.annotation.MessageMapping +import org.springframework.messaging.simp.stomp.StompHeaderAccessor +import org.springframework.stereotype.Controller +import java.security.Principal + +@Controller +class AuthController(private val authService: AuthService) { + private val log = logger() + + @MessageMapping("auth.refresh") + @PreAuthorize("#principal instanceof T(kr.co.pennyway.socket.common.security.authenticate.UserPrincipal)") + fun refreshPrincipal( + @Header("Authorization") authorization: String?, + principal: Principal, + accessor: StompHeaderAccessor + ) { + val token = authorization + ?.takeIf { it.startsWith("Bearer ") } + ?.substring(7) + ?: run { + log.warn("Authorization header is null or invalid") + throw JwtErrorException(JwtErrorCode.EMPTY_ACCESS_TOKEN) + } + + val userPrincipal = principal as? UserPrincipal + ?: run { + log.warn("Principal is not an instance of UserPrincipal") + throw IllegalArgumentException("Principal must be UserPrincipal") + } + + authService.refreshPrincipal(token, userPrincipal, accessor) + } +} diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/controller/ChatMessageController.java b/pennyway-socket/src/main/java/kr/co/pennyway/socket/controller/ChatMessageController.java deleted file mode 100644 index 2bbfbc94b..000000000 --- a/pennyway-socket/src/main/java/kr/co/pennyway/socket/controller/ChatMessageController.java +++ /dev/null @@ -1,38 +0,0 @@ -package kr.co.pennyway.socket.controller; - -import jakarta.validation.constraints.NotNull; -import kr.co.pennyway.socket.command.SendMessageCommand; -import kr.co.pennyway.socket.common.annotation.PreAuthorize; -import kr.co.pennyway.socket.common.dto.ChatMessageDto; -import kr.co.pennyway.socket.common.security.authenticate.UserPrincipal; -import kr.co.pennyway.socket.service.ChatMessageSendService; -import kr.co.pennyway.socket.service.LastMessageIdSaveService; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.messaging.handler.annotation.DestinationVariable; -import org.springframework.messaging.handler.annotation.MessageMapping; -import org.springframework.stereotype.Controller; -import org.springframework.validation.annotation.Validated; - -@Slf4j -@Controller -@RequiredArgsConstructor -public class ChatMessageController { - private final ChatMessageSendService chatMessageSendService; - private final LastMessageIdSaveService lastMessageIdSaveService; - - @MessageMapping("chat.message.{chatRoomId}") - @PreAuthorize("#isAuthenticated(#principal) and @chatRoomAccessChecker.hasPermission(#chatRoomId, #principal)") - public void sendMessage(@DestinationVariable Long chatRoomId, @Validated ChatMessageDto.Request payload, UserPrincipal principal) { - chatMessageSendService.execute(SendMessageCommand.createUserMessage(chatRoomId, payload.content(), payload.contentType(), principal.getUserId())); - } - - @MessageMapping("chat.message.{chatRoomId}.read.{lastReadMessageId}") - @PreAuthorize("#isAuthenticated(#principal) and @chatRoomAccessChecker.hasPermission(#chatRoomId, #principal)") - public void readMessage(@DestinationVariable(value = "chatRoomId") @Validated @NotNull Long chatRoomId, - @DestinationVariable(value = "lastReadMessageId") @Validated @NotNull Long lastReadMessageId, - UserPrincipal principal - ) { - lastMessageIdSaveService.execute(principal.getUserId(), chatRoomId, lastReadMessageId); - } -} diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/controller/ChatMessageController.kt b/pennyway-socket/src/main/java/kr/co/pennyway/socket/controller/ChatMessageController.kt new file mode 100644 index 000000000..d7a233729 --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/controller/ChatMessageController.kt @@ -0,0 +1,50 @@ +package kr.co.pennyway.socket.controller; + +import kr.co.pennyway.socket.command.SendMessageCommand +import kr.co.pennyway.socket.common.annotation.PreAuthorize +import kr.co.pennyway.socket.common.dto.ChatMessageDto +import kr.co.pennyway.socket.common.security.authenticate.UserPrincipal +import kr.co.pennyway.socket.service.ChatMessageSendService +import kr.co.pennyway.socket.service.LastMessageIdSaveService +import org.springframework.messaging.handler.annotation.DestinationVariable +import org.springframework.messaging.handler.annotation.MessageMapping +import org.springframework.stereotype.Controller +import org.springframework.validation.annotation.Validated + +@Controller +class ChatMessageController( + private val chatMessageSendService: ChatMessageSendService, + private val lastMessageIdSaveService: LastMessageIdSaveService +) { + companion object { + private const val CHAT_MESSAGE_PATH = "chat.message.{chatRoomId}" + private const val READ_MESSAGE_PATH = "chat.message.{chatRoomId}.read.{lastReadMessageId}" + } + + @MessageMapping(CHAT_MESSAGE_PATH) + @PreAuthorize("#isAuthenticated(#principal) and @chatRoomAccessChecker.hasPermission(#chatRoomId, #principal)") + fun sendMessage( + @DestinationVariable chatRoomId: Long, + @Validated payload: ChatMessageDto.Request, + principal: UserPrincipal + ) { + chatMessageSendService.execute( + SendMessageCommand.createUserMessage( + chatRoomId, + payload.content(), + payload.contentType(), + principal.userId + ) + ) + } + + @MessageMapping(READ_MESSAGE_PATH) + @PreAuthorize("#isAuthenticated(#principal) and @chatRoomAccessChecker.hasPermission(#chatRoomId, #principal)") + fun readMessage( + @DestinationVariable("chatRoomId") @Validated chatRoomId: Long, + @DestinationVariable("lastReadMessageId") @Validated lastReadMessageId: Long, + principal: UserPrincipal + ) { + lastMessageIdSaveService.execute(principal.userId, chatRoomId, lastReadMessageId) + } +} diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/controller/StatusController.java b/pennyway-socket/src/main/java/kr/co/pennyway/socket/controller/StatusController.java deleted file mode 100644 index 35e90d87a..000000000 --- a/pennyway-socket/src/main/java/kr/co/pennyway/socket/controller/StatusController.java +++ /dev/null @@ -1,24 +0,0 @@ -package kr.co.pennyway.socket.controller; - -import kr.co.pennyway.socket.common.annotation.PreAuthorize; -import kr.co.pennyway.socket.common.dto.StatusMessage; -import kr.co.pennyway.socket.common.security.authenticate.UserPrincipal; -import kr.co.pennyway.socket.service.StatusService; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.messaging.handler.annotation.MessageMapping; -import org.springframework.messaging.simp.stomp.StompHeaderAccessor; -import org.springframework.stereotype.Controller; - -@Slf4j -@Controller -@RequiredArgsConstructor -public class StatusController { - private final StatusService statusService; - - @MessageMapping("status.me") - @PreAuthorize("#isAuthenticated(#principal)") - public void updateStatus(UserPrincipal principal, StatusMessage message, StompHeaderAccessor accessor) { - statusService.updateStatus(principal.getUserId(), principal.getDeviceId(), message, accessor); - } -} diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/controller/StatusController.kt b/pennyway-socket/src/main/java/kr/co/pennyway/socket/controller/StatusController.kt new file mode 100644 index 000000000..6de5e5363 --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/controller/StatusController.kt @@ -0,0 +1,18 @@ +package kr.co.pennyway.socket.controller; + +import kr.co.pennyway.socket.common.annotation.PreAuthorize +import kr.co.pennyway.socket.common.dto.StatusMessage +import kr.co.pennyway.socket.common.security.authenticate.UserPrincipal +import kr.co.pennyway.socket.service.StatusService +import org.springframework.messaging.handler.annotation.MessageMapping +import org.springframework.messaging.simp.stomp.StompHeaderAccessor +import org.springframework.stereotype.Controller + +@Controller +class StatusController(private val statusService: StatusService) { + @MessageMapping("status.me") + @PreAuthorize("#isAuthenticated(#principal)") + fun updateStatus(principal: UserPrincipal, message: StatusMessage, accessor: StompHeaderAccessor) { + statusService.updateStatus(principal.userId, principal.deviceId, message, accessor); + } +} diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/service/AuthService.java b/pennyway-socket/src/main/java/kr/co/pennyway/socket/service/AuthService.java deleted file mode 100644 index da2b908a7..000000000 --- a/pennyway-socket/src/main/java/kr/co/pennyway/socket/service/AuthService.java +++ /dev/null @@ -1,43 +0,0 @@ -package kr.co.pennyway.socket.service; - -import kr.co.pennyway.infra.common.exception.JwtErrorException; -import kr.co.pennyway.socket.common.dto.ServerSideMessage; -import kr.co.pennyway.socket.common.event.ReceiptEvent; -import kr.co.pennyway.socket.common.security.authenticate.UserPrincipal; -import kr.co.pennyway.socket.common.security.jwt.AccessTokenProvider; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.messaging.Message; -import org.springframework.messaging.simp.stomp.StompHeaderAccessor; -import org.springframework.messaging.support.MessageBuilder; -import org.springframework.stereotype.Service; - -import java.time.LocalDateTime; - -@Slf4j -@Service -@RequiredArgsConstructor -public class AuthService { - private final AccessTokenProvider accessTokenProvider; - private final ApplicationEventPublisher eventPublisher; - - public void refreshPrincipal(String token, UserPrincipal principal, StompHeaderAccessor accessor) { - Message message; - - try { - LocalDateTime expiresAt = accessTokenProvider.getExpiryDate(token); - principal.updateExpiresAt(expiresAt); - - ServerSideMessage payload = ServerSideMessage.of("2000", "토큰 갱신 성공"); - message = MessageBuilder.createMessage(payload, accessor.getMessageHeaders()); - } catch (JwtErrorException e) { - log.warn("refresh failed: {}", e.getErrorCode().getExplainError()); - - ServerSideMessage payload = ServerSideMessage.of(e.causedBy().getCode(), e.getErrorCode().getExplainError()); - message = MessageBuilder.createMessage(payload, accessor.getMessageHeaders()); - } - - eventPublisher.publishEvent(ReceiptEvent.of(message)); - } -} diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/service/AuthService.kt b/pennyway-socket/src/main/java/kr/co/pennyway/socket/service/AuthService.kt new file mode 100644 index 000000000..d3a3fae9a --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/service/AuthService.kt @@ -0,0 +1,49 @@ +package kr.co.pennyway.socket.service; + +import kr.co.pennyway.infra.common.exception.JwtErrorException +import kr.co.pennyway.socket.common.dto.ServerSideMessage +import kr.co.pennyway.socket.common.event.ReceiptEvent +import kr.co.pennyway.socket.common.security.authenticate.UserPrincipal +import kr.co.pennyway.socket.common.security.jwt.AccessTokenProvider +import kr.co.pennyway.socket.common.util.logger +import org.springframework.context.ApplicationEventPublisher +import org.springframework.messaging.Message +import org.springframework.messaging.simp.stomp.StompHeaderAccessor +import org.springframework.messaging.support.MessageBuilder +import org.springframework.stereotype.Service + +@Service +class AuthService( + private val accessTokenProvider: AccessTokenProvider, + private val eventPublisher: ApplicationEventPublisher +) { + private val log = logger() + + fun refreshPrincipal( + token: String, + principal: UserPrincipal, + accessor: StompHeaderAccessor + ) { + val message = try { + val expiresAt = accessTokenProvider.getExpiryDate(token) + principal.updateExpiresAt(expiresAt) + + createMessage("2000", "토큰 갱신 성공", accessor) + } catch (e: JwtErrorException) { + log.warn("refresh failed: {}", e.errorCode.explainError) + + createMessage(code = e.causedBy().code, message = e.errorCode.explainError, accessor = accessor) + } + + eventPublisher.publishEvent(ReceiptEvent.of(message)) + } + + private fun createMessage( + code: String, + message: String, + accessor: StompHeaderAccessor + ): Message { + val payload = ServerSideMessage.of(code, message) + return MessageBuilder.createMessage(payload, accessor.messageHeaders) + } +} diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/service/ChatMessageRelayService.java b/pennyway-socket/src/main/java/kr/co/pennyway/socket/service/ChatMessageRelayService.java deleted file mode 100644 index 86dcaa2a2..000000000 --- a/pennyway-socket/src/main/java/kr/co/pennyway/socket/service/ChatMessageRelayService.java +++ /dev/null @@ -1,51 +0,0 @@ -package kr.co.pennyway.socket.service; - -import kr.co.pennyway.domain.context.chat.dto.ChatPushNotificationContext; -import kr.co.pennyway.domain.context.chat.service.ChatNotificationCoordinatorService; -import kr.co.pennyway.infra.common.event.NotificationEvent; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.Map; -import java.util.function.Supplier; - -@Slf4j -@Service -@RequiredArgsConstructor -public class ChatMessageRelayService { - private final ApplicationEventPublisher eventPublisher; - - private final ChatNotificationCoordinatorService chatNotificationCoordinatorService; - - /** - * 채팅방, 채팅 리스트 뷰를 보고 있지 않은 사용자들에게만 푸시 알림을 전송합니다. - * - * @param senderId Long 전송자 아이디 - * @param chatRoomId Long 채팅방 아이디 - * @param content String 채팅 내용 - * @apiNote push notification 전송 실패에 대한 재시도를 수행하고 있지 않습니다. - */ - @Transactional(readOnly = true) - public void execute(Long senderId, Long chatRoomId, String content) { - ChatPushNotificationContext context = executeInTransaction(() -> chatNotificationCoordinatorService.determineRecipients(senderId, chatRoomId)); - log.info("채팅 메시지 알림 전송 컨텍스트: {}", context); - - NotificationEvent notificationEvent = NotificationEvent.of( - context.senderName(), - content, - context.deviceTokens(), - context.senderImageUrl(), - Map.of("chatRoomId", chatRoomId.toString()) - ); - - eventPublisher.publishEvent(notificationEvent); - } - - @Transactional - public T executeInTransaction(Supplier operation) { - return operation.get(); - } -} diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/service/ChatMessageRelayService.kt b/pennyway-socket/src/main/java/kr/co/pennyway/socket/service/ChatMessageRelayService.kt new file mode 100644 index 000000000..1ccee80bd --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/service/ChatMessageRelayService.kt @@ -0,0 +1,40 @@ +package kr.co.pennyway.socket.service; + +import kr.co.pennyway.domain.context.chat.service.ChatNotificationCoordinatorService +import kr.co.pennyway.infra.common.event.NotificationEvent +import kr.co.pennyway.socket.common.util.logger +import org.springframework.context.ApplicationEventPublisher +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class ChatMessageRelayService( + private val eventPublisher: ApplicationEventPublisher, + private val chatNotificationCoordinatorService: ChatNotificationCoordinatorService +) { + private val log = logger() + + /** + * 채팅방, 채팅 리스트 뷰를 보고 있지 않은 사용자들에게만 푸시 알림을 전송합니다. + * + * @param senderId Long 전송자 아이디 + * @param chatRoomId Long 채팅방 아이디 + * @param content String 채팅 내용 + * @apiNote push notification 전송 실패에 대한 재시도를 수행하고 있지 않습니다. + */ + @Transactional + fun execute(senderId: Long, chatRoomId: Long, content: String) { + chatNotificationCoordinatorService.determineRecipients(senderId, chatRoomId) + .also { log.info("채팅 메시지 알림 전송 컨텍스트: {}", it) } + .let { context -> + NotificationEvent.of( + context.senderName(), + content, + context.deviceTokens(), + context.senderImageUrl(), + mapOf("chatRoomId" to chatRoomId.toString()) + ) + } + .let { eventPublisher.publishEvent(it) } + } +} \ No newline at end of file diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/service/ChatMessageSendService.java b/pennyway-socket/src/main/java/kr/co/pennyway/socket/service/ChatMessageSendService.java deleted file mode 100644 index df765e20f..000000000 --- a/pennyway-socket/src/main/java/kr/co/pennyway/socket/service/ChatMessageSendService.java +++ /dev/null @@ -1,51 +0,0 @@ -package kr.co.pennyway.socket.service; - -import kr.co.pennyway.domain.context.chat.service.ChatMessageService; -import kr.co.pennyway.domain.domains.message.domain.ChatMessage; -import kr.co.pennyway.domain.domains.message.domain.ChatMessageBuilder; -import kr.co.pennyway.infra.client.broker.MessageBrokerAdapter; -import kr.co.pennyway.infra.client.guid.IdGenerator; -import kr.co.pennyway.infra.common.properties.ChatExchangeProperties; -import kr.co.pennyway.socket.command.SendMessageCommand; -import kr.co.pennyway.socket.common.dto.ChatMessageDto; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.stereotype.Component; - -@Slf4j -@Component -@RequiredArgsConstructor -@EnableConfigurationProperties({ChatExchangeProperties.class}) -public class ChatMessageSendService { - private final ChatMessageService chatMessageService; - - private final MessageBrokerAdapter messageBrokerAdapter; - private final IdGenerator idGenerator; - private final ChatExchangeProperties chatExchangeProperties; - - /** - * 채팅 메시지를 전송한다. - * - * @param command SendMessageCommand : 채팅 메시지 전송을 위한 Command - */ - public void execute(SendMessageCommand command) { - ChatMessage message = ChatMessageBuilder.builder() - .chatRoomId(command.chatRoomId()) - .chatId(idGenerator.generate()) - .content(command.content()) - .contentType(command.contentType()) - .categoryType(command.categoryType()) - .sender(command.senderId()) - .build(); - message = chatMessageService.create(message); - - ChatMessageDto.Response response = ChatMessageDto.Response.from(message); - - messageBrokerAdapter.convertAndSend( - chatExchangeProperties.getExchange(), - "chat.room." + command.chatRoomId(), - response - ); - } -} diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/service/ChatMessageSendService.kt b/pennyway-socket/src/main/java/kr/co/pennyway/socket/service/ChatMessageSendService.kt new file mode 100644 index 000000000..8f9337b1f --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/service/ChatMessageSendService.kt @@ -0,0 +1,43 @@ +package kr.co.pennyway.socket.service; + +import kr.co.pennyway.domain.context.chat.service.ChatMessageService +import kr.co.pennyway.domain.domains.message.domain.ChatMessageBuilder +import kr.co.pennyway.infra.client.broker.MessageBrokerAdapter +import kr.co.pennyway.infra.client.guid.IdGenerator +import kr.co.pennyway.infra.common.properties.ChatExchangeProperties +import kr.co.pennyway.socket.command.SendMessageCommand +import kr.co.pennyway.socket.common.dto.ChatMessageDto +import org.springframework.boot.context.properties.EnableConfigurationProperties +import org.springframework.stereotype.Component + +@Component +@EnableConfigurationProperties(ChatExchangeProperties::class) +class ChatMessageSendService( + private val chatMessageService: ChatMessageService, + private val messageBrokerAdapter: MessageBrokerAdapter, + private val idGenerator: IdGenerator, + private val chatExchangeProperties: ChatExchangeProperties +) { + /** + * 채팅 메시지를 전송한다. + * + * @param command SendMessageCommand : 채팅 메시지 전송을 위한 Command + */ + fun execute(command: SendMessageCommand) { + val message = ChatMessageBuilder.builder() + .chatRoomId(command.chatRoomId()) + .chatId(idGenerator.generate()) + .content(command.content()) + .contentType(command.contentType()) + .categoryType(command.categoryType()) + .sender(command.senderId()) + .build() + .let { chatMessageService.create(it) } + + messageBrokerAdapter.convertAndSend( + chatExchangeProperties.exchange, + "chat.room.${command.chatRoomId()}", + ChatMessageDto.Response.from(message) + ) + } +} \ No newline at end of file diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/service/LastMessageIdSaveService.java b/pennyway-socket/src/main/java/kr/co/pennyway/socket/service/LastMessageIdSaveService.java deleted file mode 100644 index f06dcee36..000000000 --- a/pennyway-socket/src/main/java/kr/co/pennyway/socket/service/LastMessageIdSaveService.java +++ /dev/null @@ -1,15 +0,0 @@ -package kr.co.pennyway.socket.service; - -import kr.co.pennyway.domain.context.chat.service.ChatMessageStatusService; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class LastMessageIdSaveService { - private final ChatMessageStatusService chatMessageStatusService; - - public void execute(Long userId, Long chatRoomId, Long lastReadMessageId) { - chatMessageStatusService.saveLastReadMessageId(userId, chatRoomId, lastReadMessageId); - } -} diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/service/LastMessageIdSaveService.kt b/pennyway-socket/src/main/java/kr/co/pennyway/socket/service/LastMessageIdSaveService.kt new file mode 100644 index 000000000..a2256babc --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/service/LastMessageIdSaveService.kt @@ -0,0 +1,11 @@ +package kr.co.pennyway.socket.service; + +import kr.co.pennyway.domain.context.chat.service.ChatMessageStatusService +import org.springframework.stereotype.Service + +@Service +class LastMessageIdSaveService(private val chatMessageStatusService: ChatMessageStatusService) { + fun execute(userId: Long, chatRoomId: Long, lastReadMessageId: Long) { + chatMessageStatusService.saveLastReadMessageId(userId, chatRoomId, lastReadMessageId) + } +} diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/service/StatusService.java b/pennyway-socket/src/main/java/kr/co/pennyway/socket/service/StatusService.java deleted file mode 100644 index b0743ae80..000000000 --- a/pennyway-socket/src/main/java/kr/co/pennyway/socket/service/StatusService.java +++ /dev/null @@ -1,37 +0,0 @@ -package kr.co.pennyway.socket.service; - -import kr.co.pennyway.domain.context.account.service.UserSessionService; -import kr.co.pennyway.domain.domains.session.domain.UserSession; -import kr.co.pennyway.socket.common.dto.ServerSideMessage; -import kr.co.pennyway.socket.common.dto.StatusMessage; -import kr.co.pennyway.socket.common.event.ReceiptEvent; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.messaging.Message; -import org.springframework.messaging.simp.stomp.StompHeaderAccessor; -import org.springframework.messaging.support.MessageBuilder; -import org.springframework.stereotype.Service; - -@Slf4j -@Service -@RequiredArgsConstructor -public class StatusService { - private final UserSessionService userSessionService; - private final ApplicationEventPublisher publisher; - - public void updateStatus(Long userId, String deviceId, StatusMessage message, StompHeaderAccessor accessor) { - if (message.isChatRoomStatus()) { - UserSession session = userSessionService.updateUserStatus(userId, deviceId, message.chatRoomId()); - log.debug("사용자 상태 변경: {}", session); - } else { - UserSession session = userSessionService.updateUserStatus(userId, deviceId, message.status()); - log.debug("사용자 상태 변경: {}", session); - } - - ServerSideMessage payload = ServerSideMessage.of("2000", "OK"); - Message response = MessageBuilder.createMessage(payload, accessor.getMessageHeaders()); - - publisher.publishEvent(ReceiptEvent.of(response)); // @FIXME: Refresh Event와 달리 Receipt가 성공적으로 처리되지 않음. - } -} diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/service/StatusService.kt b/pennyway-socket/src/main/java/kr/co/pennyway/socket/service/StatusService.kt new file mode 100644 index 000000000..11002855c --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/service/StatusService.kt @@ -0,0 +1,36 @@ +package kr.co.pennyway.socket.service; + +import kr.co.pennyway.domain.context.account.service.UserSessionService +import kr.co.pennyway.socket.common.dto.ServerSideMessage +import kr.co.pennyway.socket.common.dto.StatusMessage +import kr.co.pennyway.socket.common.event.ReceiptEvent +import kr.co.pennyway.socket.common.util.logger +import org.springframework.context.ApplicationEventPublisher +import org.springframework.messaging.simp.stomp.StompHeaderAccessor +import org.springframework.messaging.support.MessageBuilder +import org.springframework.stereotype.Service + +@Service +class StatusService( + private val userSessionService: UserSessionService, + private val publisher: ApplicationEventPublisher +) { + private val log = logger() + + fun updateStatus( + userId: Long, + deviceId: String, + message: StatusMessage, + accessor: StompHeaderAccessor + ) = when (message.isChatRoomStatus) { + true -> userSessionService.updateUserStatus(userId, deviceId, message.chatRoomId()) + false -> userSessionService.updateUserStatus(userId, deviceId, message.status()) + } + .also { session -> log.info("사용자 상태 변경: {}", session) } + .run { + ServerSideMessage.of("2000", "OK") + .let { MessageBuilder.createMessage(it, accessor.messageHeaders) } + .let { ReceiptEvent.of(it) } + .let { publisher.publishEvent(it) } + } +} \ No newline at end of file