diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/api/ChatMemberApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/api/ChatMemberApi.java new file mode 100644 index 000000000..a7fa2e66e --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/api/ChatMemberApi.java @@ -0,0 +1,45 @@ +package kr.co.pennyway.api.apis.chat.api; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.SchemaProperty; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import kr.co.pennyway.api.apis.chat.dto.ChatMemberReq; +import kr.co.pennyway.api.apis.chat.dto.ChatRoomRes; +import kr.co.pennyway.api.common.annotation.ApiExceptionExplanation; +import kr.co.pennyway.api.common.annotation.ApiResponseExplanations; +import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails; +import kr.co.pennyway.domain.domains.chatroom.exception.ChatRoomErrorCode; +import kr.co.pennyway.domain.domains.member.exception.ChatMemberErrorCode; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; + +@Tag(name = "[채팅방 멤버 API]") +public interface ChatMemberApi { + @Operation(summary = "채팅방 멤버 가입", method = "POST", description = "채팅방에 멤버로 가입한다.") + @Parameters({ + @Parameter(name = "chatRoomId", description = "채팅방 ID", required = true, in = ParameterIn.PATH), + @Parameter(name = "payload", description = "채팅방 멤버 가입 요청 DTO", required = true, in = ParameterIn.DEFAULT, schema = @Schema(implementation = ChatMemberReq.Join.class)) + }) + @ApiResponse(responseCode = "200", description = "채팅방 멤버 가입 성공", content = @Content(schemaProperties = @SchemaProperty(name = "chatRoom", schema = @Schema(implementation = ChatRoomRes.Detail.class)))) + @ApiResponseExplanations(errors = { + @ApiExceptionExplanation(value = ChatRoomErrorCode.class, constant = "INVALID_PASSWORD", summary = "비밀번호가 일치하지 않음", description = "비밀번호가 일치하지 않아 채팅방 멤버 가입에 실패했습니다."), + @ApiExceptionExplanation(value = ChatRoomErrorCode.class, constant = "NOT_FOUND_CHAT_ROOM", summary = "채팅방을 찾을 수 없음", description = "채팅방을 찾을 수 없어 채팅방 멤버 가입에 실패했습니다."), + @ApiExceptionExplanation(value = ChatRoomErrorCode.class, constant = "FULL_CHAT_ROOM", summary = "채팅방이 가득 참", description = "채팅방이 가득 차서 채팅방 멤버 가입에 실패했습니다."), + @ApiExceptionExplanation(value = ChatMemberErrorCode.class, constant = "BANNED", summary = "차단된 사용자", description = "차단된 사용자로 채팅방 멤버 가입에 실패했습니다."), + @ApiExceptionExplanation(value = ChatMemberErrorCode.class, constant = "ALREADY_JOINED", summary = "이미 가입한 사용자", description = "이미 가입한 사용자로 채팅방 멤버 가입에 실패했습니다.") + }) + ResponseEntity joinChatRoom( + @PathVariable("chatRoomId") Long chatRoomId, + @Validated @RequestBody ChatMemberReq.Join payload, + @AuthenticationPrincipal SecurityUserDetails user + ); +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/controller/ChatMemberController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/controller/ChatMemberController.java new file mode 100644 index 000000000..460138cd7 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/controller/ChatMemberController.java @@ -0,0 +1,37 @@ +package kr.co.pennyway.api.apis.chat.controller; + +import kr.co.pennyway.api.apis.chat.api.ChatMemberApi; +import kr.co.pennyway.api.apis.chat.dto.ChatMemberReq; +import kr.co.pennyway.api.apis.chat.dto.ChatRoomRes; +import kr.co.pennyway.api.apis.chat.usecase.ChatMemberUseCase; +import kr.co.pennyway.api.common.response.SuccessResponse; +import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/v2/chat-rooms/{chatRoomId}/chat-members") +public class ChatMemberController implements ChatMemberApi { + private static final String CHAT_ROOM = "chatRoom"; + private final ChatMemberUseCase chatMemberUseCase; + + @Override + @PostMapping("") + @PreAuthorize("isAuthenticated()") + public ResponseEntity joinChatRoom( + @PathVariable("chatRoomId") Long chatRoomId, + @Validated @RequestBody ChatMemberReq.Join payload, + @AuthenticationPrincipal SecurityUserDetails user + ) { + ChatRoomRes.Detail detail = chatMemberUseCase.joinChatRoom(user.getUserId(), chatRoomId, payload.password()); + + return ResponseEntity.ok(SuccessResponse.from(CHAT_ROOM, detail)); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/dto/ChatMemberReq.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/dto/ChatMemberReq.java new file mode 100644 index 000000000..7ed1b3472 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/dto/ChatMemberReq.java @@ -0,0 +1,30 @@ +package kr.co.pennyway.api.apis.chat.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Pattern; + +public final class ChatMemberReq { + @Schema(title = "채팅방 멤버 가입 요청 DTO") + public static class Join { + @Schema(description = "채팅방 비밀번호. NULL을 허용한다. 비밀번호는 6자리 정수만 허용", example = "123456") + @Pattern(regexp = "^[0-9]{6}$", message = "채팅방 비밀번호는 6자리 정수여야 합니다.") + private String password; + + protected Join() { + } + + public Join(String password) { + this.password = password; + } + + // 메서드 표현 일관성을 유지하고, password를 Integer로 변환하여 반환하는 getter + public Integer password() { + return password != null ? Integer.valueOf(password) : null; + } + + // Swagger UI에서 표현하기 위한 getter + public String getPassword() { + return password; + } + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/service/ChatMemberJoinService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/service/ChatMemberJoinService.java new file mode 100644 index 000000000..f66805fd2 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/service/ChatMemberJoinService.java @@ -0,0 +1,68 @@ +package kr.co.pennyway.api.apis.chat.service; + +import kr.co.pennyway.domain.common.redisson.DistributedLock; +import kr.co.pennyway.domain.domains.chatroom.domain.ChatRoom; +import kr.co.pennyway.domain.domains.chatroom.exception.ChatRoomErrorCode; +import kr.co.pennyway.domain.domains.chatroom.exception.ChatRoomErrorException; +import kr.co.pennyway.domain.domains.chatroom.service.ChatRoomService; +import kr.co.pennyway.domain.domains.member.domain.ChatMember; +import kr.co.pennyway.domain.domains.member.service.ChatMemberService; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; +import kr.co.pennyway.domain.domains.user.exception.UserErrorException; +import kr.co.pennyway.domain.domains.user.service.UserService; +import kr.co.pennyway.infra.common.event.ChatRoomJoinEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.tuple.Pair; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ChatMemberJoinService { + private static final long MAX_MEMBER_COUNT = 300; + + private final UserService userService; + private final ChatRoomService chatRoomService; + private final ChatMemberService chatMemberService; + + private final ApplicationEventPublisher eventPublisher; + + /** + * 사용자가 채팅방에 참여하는 도메인 비즈니스 로직을 처리한다. + * 채팅방 가입 가능 여부 확인을 위해 현재 가입한 회원 수를 조회하는데, 이 때 분산 락을 걸어 동시성 문제를 해결한다. + * + * @param userId Long : 가입하려는 사용자의 ID + * @param chatRoomId Long : 가입하려는 채팅방의 ID + * @param password Integer : 비공개 채팅방의 경우 비밀번호 정보를 입력받으며, 채팅방에 비밀번호가 없을 경우 null + * @return Pair - 채팅방 정보와 현재 가입한 회원 수 + */ + @DistributedLock(key = "'chat-room-join-' + #chatRoomId") + public Pair execute(Long userId, Long chatRoomId, Integer password) { + ChatRoom chatRoom = chatRoomService.readChatRoom(chatRoomId).orElseThrow(() -> new ChatRoomErrorException(ChatRoomErrorCode.NOT_FOUND_CHAT_ROOM)); + + Long currentMemberCount = chatMemberService.countActiveMembers(chatRoomId); + if (isFullRoom(currentMemberCount)) { + log.warn("채팅방이 가득 찼습니다. chatRoomId: {}", chatRoomId); + throw new ChatRoomErrorException(ChatRoomErrorCode.FULL_CHAT_ROOM); + } + + if (chatRoom.isPrivateRoom() && !chatRoom.matchPassword(password)) { + log.warn("채팅방 비밀번호가 일치하지 않습니다. chatRoomId: {}", chatRoomId); + throw new ChatRoomErrorException(ChatRoomErrorCode.INVALID_PASSWORD); + } + + User user = userService.readUser(userId).orElseThrow(() -> new UserErrorException(UserErrorCode.NOT_FOUND)); + ChatMember member = chatMemberService.createMember(user, chatRoom); + + eventPublisher.publishEvent(ChatRoomJoinEvent.of(chatRoomId, member.getName())); + + return Pair.of(chatRoom, currentMemberCount.intValue() + 1); + } + + private boolean isFullRoom(Long currentMemberCount) { + return currentMemberCount >= MAX_MEMBER_COUNT; + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/service/ChatRoomSaveService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/service/ChatRoomSaveService.java index eee311f49..a4c34ec0d 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/service/ChatRoomSaveService.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/service/ChatRoomSaveService.java @@ -4,9 +4,7 @@ import kr.co.pennyway.api.common.storage.AwsS3Adapter; import kr.co.pennyway.domain.domains.chatroom.domain.ChatRoom; import kr.co.pennyway.domain.domains.chatroom.service.ChatRoomService; -import kr.co.pennyway.domain.domains.member.domain.ChatMember; import kr.co.pennyway.domain.domains.member.service.ChatMemberService; -import kr.co.pennyway.domain.domains.member.type.ChatMemberRole; import kr.co.pennyway.domain.domains.user.domain.User; import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; import kr.co.pennyway.domain.domains.user.exception.UserErrorException; @@ -40,9 +38,7 @@ public ChatRoom createChatRoom(ChatRoomReq.Create request, Long userId) { ChatRoom chatRoom = chatRoomService.create(request.toEntity(chatRoomId, originImageUrl)); User user = userService.readUser(userId).orElseThrow(() -> new UserErrorException(UserErrorCode.NOT_FOUND)); - ChatMember member = ChatMember.of(user.getName(), user, chatRoom, ChatMemberRole.ADMIN); - - chatMemberService.create(member); + chatMemberService.createAdmin(user, chatRoom); return chatRoom; } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/usecase/ChatMemberUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/usecase/ChatMemberUseCase.java new file mode 100644 index 000000000..cfe55fc26 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/usecase/ChatMemberUseCase.java @@ -0,0 +1,23 @@ +package kr.co.pennyway.api.apis.chat.usecase; + +import kr.co.pennyway.api.apis.chat.dto.ChatRoomRes; +import kr.co.pennyway.api.apis.chat.mapper.ChatRoomMapper; +import kr.co.pennyway.api.apis.chat.service.ChatMemberJoinService; +import kr.co.pennyway.common.annotation.UseCase; +import kr.co.pennyway.domain.domains.chatroom.domain.ChatRoom; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.tuple.Pair; + +@Slf4j +@UseCase +@RequiredArgsConstructor +public class ChatMemberUseCase { + private final ChatMemberJoinService chatMemberJoinService; + + public ChatRoomRes.Detail joinChatRoom(Long userId, Long chatRoomId, Integer password) { + Pair chatRoom = chatMemberJoinService.execute(userId, chatRoomId, password); + + return ChatRoomMapper.toChatRoomResDetail(chatRoom.getLeft(), false, chatRoom.getRight()); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/InfraConfig.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/InfraConfig.java index 79b3a5ff0..39e5eed1b 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/InfraConfig.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/InfraConfig.java @@ -19,7 +19,8 @@ @EnablePennywayInfraConfig({ PennywayInfraConfigGroup.FCM, PennywayInfraConfigGroup.DISTRIBUTED_COORDINATION_CONFIG, - PennywayInfraConfigGroup.GUID_GENERATOR_CONFIG + PennywayInfraConfigGroup.GUID_GENERATOR_CONFIG, + PennywayInfraConfigGroup.MESSAGE_BROKER_CONFIG }) public class InfraConfig { } diff --git a/pennyway-app-external-api/src/main/resources/application.yml b/pennyway-app-external-api/src/main/resources/application.yml index 98ffa47a6..dd8d54ca3 100644 --- a/pennyway-app-external-api/src/main/resources/application.yml +++ b/pennyway-app-external-api/src/main/resources/application.yml @@ -13,6 +13,10 @@ jwt: access-token: ${JWT_ACCESS_EXPIRATION_TIME:1800000} # 30m (30 * 60 * 1000) refresh-token: ${JWT_REFRESH_EXPIRATION_TIME:604800000} # 7d (7 * 24 * 60 * 60 * 1000) +pennyway: + rabbitmq: + validate-connection: true + --- spring: config: diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/controller/ChatRoomSaveControllerUnitTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/controller/ChatRoomSaveControllerUnitTest.java index 2e813e677..f913c21eb 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/controller/ChatRoomSaveControllerUnitTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/controller/ChatRoomSaveControllerUnitTest.java @@ -50,7 +50,7 @@ void setUp(WebApplicationContext webApplicationContext) { @WithSecurityMockUser void createChatRoomSuccess() throws Exception { // given - ChatRoom fixture = ChatRoomFixture.PRIVATE_CHAT_ROOM.toEntity(); + ChatRoom fixture = ChatRoomFixture.PRIVATE_CHAT_ROOM.toEntity(1L); ChatRoomReq.Create request = ChatRoomFixture.PRIVATE_CHAT_ROOM.toCreateRequest(); given(chatRoomUseCase.createChatRoom(request, 1L)).willReturn(ChatRoomRes.Detail.from(fixture, true, 1)); @@ -67,7 +67,7 @@ void createChatRoomSuccess() throws Exception { @WithSecurityMockUser void createChatRoomSuccessWithNullBackgroundImageUrl() throws Exception { // given - ChatRoom fixture = ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntity(); + ChatRoom fixture = ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntity(1L); ChatRoomReq.Create request = ChatRoomFixture.PUBLIC_CHAT_ROOM.toCreateRequest(); given(chatRoomUseCase.createChatRoom(request, 1L)).willReturn(ChatRoomRes.Detail.from(fixture, true, 1)); diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/integration/ChatMemberJoinIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/integration/ChatMemberJoinIntegrationTest.java new file mode 100644 index 000000000..8823a9448 --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/integration/ChatMemberJoinIntegrationTest.java @@ -0,0 +1,286 @@ +package kr.co.pennyway.api.apis.chat.integration; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import kr.co.pennyway.api.apis.chat.dto.ChatMemberReq; +import kr.co.pennyway.api.apis.chat.dto.ChatRoomRes; +import kr.co.pennyway.api.common.response.ErrorResponse; +import kr.co.pennyway.api.common.response.SuccessResponse; +import kr.co.pennyway.api.common.security.jwt.access.AccessTokenClaim; +import kr.co.pennyway.api.config.ExternalApiDBTestConfig; +import kr.co.pennyway.api.config.ExternalApiIntegrationTest; +import kr.co.pennyway.api.config.fixture.ChatMemberFixture; +import kr.co.pennyway.api.config.fixture.ChatRoomFixture; +import kr.co.pennyway.api.config.fixture.UserFixture; +import kr.co.pennyway.domain.domains.chatroom.domain.ChatRoom; +import kr.co.pennyway.domain.domains.chatroom.exception.ChatRoomErrorCode; +import kr.co.pennyway.domain.domains.chatroom.repository.ChatRoomRepository; +import kr.co.pennyway.domain.domains.member.repository.ChatMemberRepository; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.repository.UserRepository; +import kr.co.pennyway.infra.client.guid.IdGenerator; +import kr.co.pennyway.infra.common.event.ChatRoomJoinEvent; +import kr.co.pennyway.infra.common.event.ChatRoomJoinEventHandler; +import kr.co.pennyway.infra.common.jwt.JwtProvider; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.*; +import org.springframework.test.context.event.ApplicationEvents; +import org.springframework.test.context.event.RecordApplicationEvents; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.stream.IntStream; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; + +@Slf4j +@ExternalApiIntegrationTest +@RecordApplicationEvents +public class ChatMemberJoinIntegrationTest extends ExternalApiDBTestConfig { + private static final String BASE_URL = "/v2/chat-rooms/{chatRoomId}/chat-members"; + + @Autowired + private TestRestTemplate restTemplate; + + @Autowired + private UserRepository userRepository; + + @Autowired + private ChatRoomRepository chatRoomRepository; + + @Autowired + private ChatMemberRepository chatMemberRepository; + + @Autowired + private JwtProvider accessTokenProvider; + + @Autowired + private IdGenerator idGenerator; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private ApplicationEvents events; + + @MockBean + private ChatRoomJoinEventHandler chatRoomJoinEventHandler; + + @LocalServerPort + private int port; + + private String url; + + @BeforeEach + void setUp() { + url = "http://localhost:" + port + BASE_URL; + } + + @Test + @DisplayName("Happy Path: 공개 채팅방 가입 성공") + void successJoinPublicRoom() { + // given + User admin = userRepository.save(UserFixture.GENERAL_USER.toUser()); + ChatRoom chatRoom = chatRoomRepository.save(ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntity(idGenerator.generate())); + chatMemberRepository.save(ChatMemberFixture.ADMIN.toEntity(admin, chatRoom)); + + User user = userRepository.save(UserFixture.GENERAL_USER.toUser()); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(ChatRoomJoinEvent.class); + + // when + ResponseEntity response = postJoining(user, chatRoom.getId(), new ChatMemberReq.Join(null)); + + // then + assertAll( + () -> assertEquals(HttpStatus.OK, response.getStatusCode()), + () -> assertTrue(chatMemberRepository.existsByChatRoomIdAndUserId(chatRoom.getId(), user.getId())), + () -> verify(chatRoomJoinEventHandler).handle(eventCaptor.capture()), + () -> { + ChatRoomJoinEvent capturedEvent = eventCaptor.getValue(); + assertEquals(chatRoom.getId(), capturedEvent.chatRoomId()); + assertEquals(user.getName(), capturedEvent.userName()); + } + ); + } + + @Test + @DisplayName("동시에 350명의 사용자가 가입을 시도하면 정원 초과로 인해, 299명만 가입에 성공한다") + void concurrentJoinRequests() throws InterruptedException { + // given + User admin = userRepository.save(UserFixture.GENERAL_USER.toUser()); + ChatRoom chatRoom = chatRoomRepository.save(ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntity(idGenerator.generate())); + chatMemberRepository.save(ChatMemberFixture.ADMIN.toEntity(admin, chatRoom)); + + List users = IntStream.range(0, 350) + .mapToObj(i -> userRepository.save(UserFixture.GENERAL_USER.toUser())) + .toList(); + + // when + CountDownLatch latch = new CountDownLatch(users.size()); + List> futures = users.stream() + .map(user -> CompletableFuture.supplyAsync(() -> { + try { + return JoinResult.from( + postJoining(user, chatRoom.getId(), new ChatMemberReq.Join(null)) + ); + } finally { + latch.countDown(); + } + })) + .toList(); + + latch.await(); + + List results = futures.stream() + .map(CompletableFuture::join) + .toList(); + + // then + assertAll( + () -> assertEquals(299, results.stream().filter(JoinResult::isSuccess).count()), + () -> assertEquals(51, results.stream().filter(JoinResult::isFullRoomError).count()), + () -> assertEquals(300, chatMemberRepository.countByChatRoomIdAndActive(chatRoom.getId())) + ); + } + + @Test + @DisplayName("트랜잭션 롤백: 이벤트 발행 실패 시 가입도 롤백된다") + void rollbackWhenEventPublishFails() { + // given + User admin = userRepository.save(UserFixture.GENERAL_USER.toUser()); + ChatRoom chatRoom = chatRoomRepository.save(ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntity(idGenerator.generate())); + chatMemberRepository.save(ChatMemberFixture.ADMIN.toEntity(admin, chatRoom)); + + User user = userRepository.save(UserFixture.GENERAL_USER.toUser()); + + doThrow(new RuntimeException("Event publish failed")).when(chatRoomJoinEventHandler).handle(any(ChatRoomJoinEvent.class)); + + // when + ResponseEntity response = postJoining(user, chatRoom.getId(), new ChatMemberReq.Join(null)); + + // then + assertAll( + () -> assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode()), + () -> assertFalse(chatMemberRepository.existsByChatRoomIdAndUserId(chatRoom.getId(), user.getId())) + ); + } + + @Test + @DisplayName("인증되지 않은 사용자는 가입할 수 없다") + void failWhenUserNotAuthenticated() { + // given + ChatRoom chatRoom = chatRoomRepository.save(ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntity(idGenerator.generate())); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + // when + ResponseEntity response = restTemplate.exchange( + url, + HttpMethod.POST, + new HttpEntity<>(new ChatMemberReq.Join(null), headers), + new ParameterizedTypeReference<>() { + }, + chatRoom.getId() + ); + + // then + assertEquals(HttpStatus.UNAUTHORIZED, response.getStatusCode()); + } + + @Test + @DisplayName("비공개 채팅방 가입 시 올바른 비밀번호로 가입할 수 있다") + void successJoinPrivateRoomWithValidPassword() { + // given + User admin = userRepository.save(UserFixture.GENERAL_USER.toUser()); + ChatRoom chatRoom = chatRoomRepository.save(ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntity(idGenerator.generate())); + chatMemberRepository.save(ChatMemberFixture.ADMIN.toEntity(admin, chatRoom)); + + User user = userRepository.save(UserFixture.GENERAL_USER.toUser()); + Integer expectedPassword = ChatRoomFixture.PRIVATE_CHAT_ROOM.toEntity(idGenerator.generate()).getPassword(); + + // when + ResponseEntity response = postJoining(user, chatRoom.getId(), new ChatMemberReq.Join(expectedPassword.toString())); + + // then + assertEquals(HttpStatus.OK, response.getStatusCode()); + + } + + private ResponseEntity postJoining(User user, Long chatRoomId, ChatMemberReq.Join request) { + ResponseEntity response = restTemplate.exchange( + url, + HttpMethod.POST, + createHttpEntity(user, request), + Object.class, + chatRoomId + ); + + Object body = response.getBody(); + if (body == null) { + throw new IllegalStateException("예상치 못한 반환 타입입니다. : " + response); + } + + if (response.getStatusCode().is2xxSuccessful()) { + return ResponseEntity + .status(response.getStatusCode()) + .headers(response.getHeaders()) + .body(objectMapper.convertValue(body, new TypeReference>>() { + })); + } else { + return ResponseEntity + .status(response.getStatusCode()) + .headers(response.getHeaders()) + .body(objectMapper.convertValue(body, new TypeReference() { + })); + } + } + + private HttpEntity createHttpEntity(User user, ChatMemberReq.Join request) { + HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", "Bearer " + accessTokenProvider.generateToken(AccessTokenClaim.of(user.getId(), user.getRole().name()))); + headers.setContentType(MediaType.APPLICATION_JSON); + + return new HttpEntity<>(request, headers); + } + + @Getter + private static class JoinResult { + private final HttpStatusCode status; + private final Object body; + private final boolean isSuccess; + + private JoinResult(ResponseEntity response) { + this.status = response.getStatusCode(); + this.body = response.getBody(); + this.isSuccess = status == HttpStatus.OK; + } + + public static JoinResult from(ResponseEntity response) { + return new JoinResult(response); + } + + public boolean isFullRoomError() { + if (!isSuccess && body instanceof ErrorResponse errorResponse) { + return errorResponse.getCode().equals(ChatRoomErrorCode.FULL_CHAT_ROOM.causedBy().getCode()); + } + return false; + } + } +} diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/service/ChatMemberJoinServiceTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/service/ChatMemberJoinServiceTest.java new file mode 100644 index 000000000..42c0f95b0 --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/service/ChatMemberJoinServiceTest.java @@ -0,0 +1,222 @@ +package kr.co.pennyway.api.apis.chat.service; + +import kr.co.pennyway.api.config.fixture.ChatMemberFixture; +import kr.co.pennyway.api.config.fixture.ChatRoomFixture; +import kr.co.pennyway.api.config.fixture.UserFixture; +import kr.co.pennyway.domain.domains.chatroom.domain.ChatRoom; +import kr.co.pennyway.domain.domains.chatroom.exception.ChatRoomErrorCode; +import kr.co.pennyway.domain.domains.chatroom.exception.ChatRoomErrorException; +import kr.co.pennyway.domain.domains.chatroom.service.ChatRoomService; +import kr.co.pennyway.domain.domains.member.domain.ChatMember; +import kr.co.pennyway.domain.domains.member.service.ChatMemberService; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.exception.UserErrorException; +import kr.co.pennyway.domain.domains.user.service.UserService; +import kr.co.pennyway.infra.common.event.ChatRoomJoinEvent; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.tuple.Pair; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +@Slf4j +@ExtendWith(MockitoExtension.class) +public class ChatMemberJoinServiceTest { + private static final long FULL_ROOM_CAPACITY = 300L; + private static final long AVAILABLE_CAPACITY = 299L; + private ChatMemberJoinService chatMemberJoinService; + @Mock + private UserService userService; + @Mock + private ChatRoomService chatRoomService; + @Mock + private ChatMemberService chatMemberService; + @Mock + private ApplicationEventPublisher eventPublisher; + private Long userId = 1L; + private Long chatRoomId = 1L; + + @BeforeEach + void setUp() { + chatMemberJoinService = new ChatMemberJoinService(userService, chatRoomService, chatMemberService, eventPublisher); + } + + @Test + @DisplayName("채팅방이 가득 찼을 때 (정원 300명) 가입에 실패한다.") + void failWhenChatRoomIsFull() { + // given + given(chatRoomService.readChatRoom(chatRoomId)).willReturn(Optional.of(createPublicRoom())); + given(chatMemberService.countActiveMembers(chatRoomId)).willReturn(FULL_ROOM_CAPACITY); + + // when + ChatRoomErrorException exception = assertThrows(ChatRoomErrorException.class, () -> chatMemberJoinService.execute(userId, chatRoomId, null)); + + // then + assertEquals(ChatRoomErrorCode.FULL_CHAT_ROOM, exception.getBaseErrorCode()); + } + + @Test + @DisplayName("비공개 채팅방의 비밀번호가 일치하지 않을 때 가입에 실패한다.") + void failWhenPasswordIsNotMatch() { + // given + Integer invalidPassword = 134679; + given(chatRoomService.readChatRoom(chatRoomId)).willReturn(Optional.of(createPrivateRoom())); + given(chatMemberService.countActiveMembers(chatRoomId)).willReturn(AVAILABLE_CAPACITY); + + // when + ChatRoomErrorException exception = assertThrows(ChatRoomErrorException.class, () -> chatMemberJoinService.execute(userId, chatRoomId, invalidPassword)); + + // then + assertEquals(ChatRoomErrorCode.INVALID_PASSWORD, exception.getBaseErrorCode()); + } + + @Test + @DisplayName("채팅방 수용 인원이 남아있고, 공개 채팅방이라면 비밀번호 검증을 수행하지 않는다.") + void successWhenChatRoomIsNotFullAndPublic() { + // given + ChatRoom expectedChatRoom = createPublicRoom(); + User expectedUser = createUser(); + + given(chatRoomService.readChatRoom(chatRoomId)).willReturn(Optional.of(expectedChatRoom)); + given(chatMemberService.countActiveMembers(chatRoomId)).willReturn(AVAILABLE_CAPACITY); + + given(userService.readUser(userId)).willReturn(Optional.of(expectedUser)); + given(chatMemberService.createMember(expectedUser, expectedChatRoom)).willReturn(ChatMemberFixture.MEMBER.toEntity(expectedUser, expectedChatRoom)); + + // when + chatMemberJoinService.execute(userId, chatRoomId, null); + + // then + verify(eventPublisher, times(1)).publishEvent(any(ChatRoomJoinEvent.class)); + } + + @Test + @DisplayName("채팅방 수용 인원이 남아있고, 비밀번호가 일치할 때 가입에 성공한다. 이 때, 가입에 성공한 경우 채팅방 정보와 현재 가입한 회원 수를 반환한다.") + void successWhenChatRoomIsNotFullAndPasswordIsMatch() { + // given + ChatRoom expectedChatRoom = createPrivateRoom(); + User expectedUser = createUser(); + ChatMember expectedMember = ChatMemberFixture.MEMBER.toEntity(expectedUser, expectedChatRoom); + Integer validPassword = expectedChatRoom.getPassword(); + + given(chatRoomService.readChatRoom(chatRoomId)).willReturn(Optional.of(expectedChatRoom)); + given(chatMemberService.countActiveMembers(chatRoomId)).willReturn(AVAILABLE_CAPACITY); + + given(userService.readUser(userId)).willReturn(Optional.of(expectedUser)); + given(chatMemberService.createMember(expectedUser, expectedChatRoom)).willReturn(expectedMember); + + // when + Pair result = chatMemberJoinService.execute(userId, chatRoomId, validPassword); + + // then + assertAll( + () -> assertEquals(expectedChatRoom, result.getLeft()), + () -> assertEquals(FULL_ROOM_CAPACITY, result.getRight().longValue()), + () -> verify(eventPublisher, times(1)).publishEvent(any(ChatRoomJoinEvent.class)) + ); + } + + @Test + @DisplayName("가입 성공 시 정확한 이벤트가 발행된다") + void explicitEventPublishedWhenJoinSuccess() { + // given + ChatRoom expectedChatRoom = createPrivateRoom(); + User expectedUser = createUser(); + ChatMember expectedMember = ChatMemberFixture.MEMBER.toEntity(expectedUser, expectedChatRoom); + Integer validPassword = expectedChatRoom.getPassword(); + + given(chatRoomService.readChatRoom(chatRoomId)).willReturn(Optional.of(expectedChatRoom)); + given(chatMemberService.countActiveMembers(chatRoomId)).willReturn(AVAILABLE_CAPACITY); + + given(userService.readUser(userId)).willReturn(Optional.of(expectedUser)); + given(chatMemberService.createMember(expectedUser, expectedChatRoom)).willReturn(expectedMember); + + // when + chatMemberJoinService.execute(userId, chatRoomId, validPassword); + + // then + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(Object.class); + verify(eventPublisher).publishEvent(eventCaptor.capture()); + + Object capturedEvent = eventCaptor.getValue(); + assertInstanceOf(ChatRoomJoinEvent.class, capturedEvent); + ChatRoomJoinEvent joinEvent = (ChatRoomJoinEvent) capturedEvent; + + assertAll( + () -> assertEquals(chatRoomId, joinEvent.chatRoomId()), + () -> assertEquals(expectedUser.getName(), joinEvent.userName()) + ); + } + + @Test + @DisplayName("채팅방 가입 시 정해진 순서대로 검증이 수행된다") + void verifyValidationOrder() { + // given + InOrder inOrder = inOrder(chatRoomService, chatMemberService, userService); + + ChatRoom expectedChatRoom = createPrivateRoom(); + User expectedUser = createUser(); + ChatMember expectedMember = ChatMemberFixture.MEMBER.toEntity(expectedUser, expectedChatRoom); + Integer validPassword = expectedChatRoom.getPassword(); + + given(chatRoomService.readChatRoom(chatRoomId)).willReturn(Optional.of(expectedChatRoom)); + given(chatMemberService.countActiveMembers(chatRoomId)).willReturn(AVAILABLE_CAPACITY); + + given(userService.readUser(userId)).willReturn(Optional.of(expectedUser)); + given(chatMemberService.createMember(expectedUser, expectedChatRoom)).willReturn(expectedMember); + + // when + chatMemberJoinService.execute(userId, chatRoomId, validPassword); + + // then + inOrder.verify(chatRoomService).readChatRoom(chatRoomId); + inOrder.verify(chatMemberService).countActiveMembers(chatRoomId); + inOrder.verify(userService).readUser(userId); + } + + @Test + @DisplayName("존재하지 않는 채팅방에 가입을 시도하면 실패한다") + void failWhenChatRoomNotFound() { + // given + given(chatRoomService.readChatRoom(chatRoomId)).willReturn(Optional.empty()); + + // when - then + assertThrows(ChatRoomErrorException.class, () -> chatMemberJoinService.execute(userId, chatRoomId, null)); + } + + @Test + @DisplayName("존재하지 않는 사용자가 가입을 시도하면 실패한다") + void failWhenUserNotFound() { + // given + given(chatRoomService.readChatRoom(chatRoomId)).willReturn(Optional.of(createPublicRoom())); + given(chatMemberService.countActiveMembers(chatRoomId)).willReturn(AVAILABLE_CAPACITY); + given(userService.readUser(userId)).willReturn(Optional.empty()); + + // when - then + assertThrows(UserErrorException.class, () -> chatMemberJoinService.execute(userId, chatRoomId, null)); + } + + private ChatRoom createPublicRoom() { + return ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntity(1L); + } + + private ChatRoom createPrivateRoom() { + return ChatRoomFixture.PRIVATE_CHAT_ROOM.toEntity(1L); + } + + private User createUser() { + return UserFixture.GENERAL_USER.toUser(); + } +} diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/ChatMemberFixture.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/ChatMemberFixture.java new file mode 100644 index 000000000..83570524b --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/ChatMemberFixture.java @@ -0,0 +1,22 @@ +package kr.co.pennyway.api.config.fixture; + +import kr.co.pennyway.domain.domains.chatroom.domain.ChatRoom; +import kr.co.pennyway.domain.domains.member.domain.ChatMember; +import kr.co.pennyway.domain.domains.member.type.ChatMemberRole; +import kr.co.pennyway.domain.domains.user.domain.User; + +public enum ChatMemberFixture { + ADMIN(ChatMemberRole.ADMIN), + MEMBER(ChatMemberRole.MEMBER), + ; + + private final ChatMemberRole role; + + ChatMemberFixture(ChatMemberRole role) { + this.role = role; + } + + public ChatMember toEntity(User user, ChatRoom chatRoom) { + return ChatMember.of(user, chatRoom, this.role); + } +} diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/ChatRoomFixture.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/ChatRoomFixture.java index 297a0c7d7..55e97a99f 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/ChatRoomFixture.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/ChatRoomFixture.java @@ -19,9 +19,9 @@ public enum ChatRoomFixture { this.password = password; } - public ChatRoom toEntity() { + public ChatRoom toEntity(Long id) { return ChatRoom.builder() - .id(1L) + .id(id) .title(title) .description(description) .backgroundImageUrl(backgroundImageUrl) diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/chatroom/domain/ChatRoom.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/chatroom/domain/ChatRoom.java index ff98f28c9..c9c72dbb2 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/chatroom/domain/ChatRoom.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/chatroom/domain/ChatRoom.java @@ -76,6 +76,14 @@ private void validate(String title, String description, Integer password) { } } + public boolean isPrivateRoom() { + return password != null; + } + + public boolean matchPassword(Integer password) { + return this.password.equals(password); + } + @Override public String toString() { return "ChatRoom{" + diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/chatroom/exception/ChatRoomErrorCode.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/chatroom/exception/ChatRoomErrorCode.java new file mode 100644 index 000000000..eaded0a8e --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/chatroom/exception/ChatRoomErrorCode.java @@ -0,0 +1,34 @@ +package kr.co.pennyway.domain.domains.chatroom.exception; + +import kr.co.pennyway.common.exception.BaseErrorCode; +import kr.co.pennyway.common.exception.CausedBy; +import kr.co.pennyway.common.exception.ReasonCode; +import kr.co.pennyway.common.exception.StatusCode; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public enum ChatRoomErrorCode implements BaseErrorCode { + /* 400 Bad Request */ + INVALID_PASSWORD(StatusCode.BAD_REQUEST, ReasonCode.INVALID_REQUEST, "비밀번호가 일치하지 않습니다."), + + /* 404 Not Found */ + NOT_FOUND_CHAT_ROOM(StatusCode.NOT_FOUND, ReasonCode.REQUESTED_RESOURCE_NOT_FOUND, "채팅방을 찾을 수 없습니다."), + + /* 409 Conflict */ + FULL_CHAT_ROOM(StatusCode.CONFLICT, ReasonCode.REQUESTED_RESPONSE_FORMAT_NOT_SUPPORTED, "채팅방 인원이 가득 찼습니다."), + ; + + private final StatusCode statusCode; + private final ReasonCode reasonCode; + private final String message; + + @Override + public CausedBy causedBy() { + return CausedBy.of(statusCode, reasonCode); + } + + @Override + public String getExplainError() throws NoSuchFieldError { + return message; + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/chatroom/exception/ChatRoomErrorException.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/chatroom/exception/ChatRoomErrorException.java new file mode 100644 index 000000000..747a8782b --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/chatroom/exception/ChatRoomErrorException.java @@ -0,0 +1,21 @@ +package kr.co.pennyway.domain.domains.chatroom.exception; + +import kr.co.pennyway.common.exception.CausedBy; +import kr.co.pennyway.common.exception.GlobalErrorException; + +public class ChatRoomErrorException extends GlobalErrorException { + private final ChatRoomErrorCode chatRoomErrorCode; + + public ChatRoomErrorException(ChatRoomErrorCode baseErrorCode) { + super(baseErrorCode); + this.chatRoomErrorCode = baseErrorCode; + } + + public CausedBy causedBy() { + return chatRoomErrorCode.causedBy(); + } + + public String getExplainError() { + return chatRoomErrorCode.getExplainError(); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/chatroom/service/ChatRoomService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/chatroom/service/ChatRoomService.java index 694fd0474..5a6cab25e 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/chatroom/service/ChatRoomService.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/chatroom/service/ChatRoomService.java @@ -11,6 +11,7 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; +import java.util.Optional; @Slf4j @DomainService @@ -23,6 +24,11 @@ public ChatRoom create(ChatRoom chatRoom) { return chatRoomRepository.save(chatRoom); } + @Transactional(readOnly = true) + public Optional readChatRoom(Long chatRoomId) { + return chatRoomRepository.findById(chatRoomId); + } + @Transactional(readOnly = true) public List readChatRoomsByUserId(Long userId) { return chatRoomRepository.findChatRoomsByUserId(userId); diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/member/domain/ChatMember.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/member/domain/ChatMember.java index dfb704e46..953e19dc5 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/member/domain/ChatMember.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/member/domain/ChatMember.java @@ -13,7 +13,6 @@ import org.hibernate.annotations.ColumnDefault; import org.hibernate.annotations.DynamicInsert; import org.hibernate.annotations.SQLDelete; -import org.hibernate.annotations.SQLRestriction; import org.springframework.util.StringUtils; import java.time.LocalDateTime; @@ -24,7 +23,6 @@ @Table(name = "chat_member") @NoArgsConstructor(access = AccessLevel.PROTECTED) @DynamicInsert -@SQLRestriction("deleted_at IS NULL") @SQLDelete(sql = "UPDATE chat_member SET deleted_at = NOW() WHERE id = ?") public class ChatMember extends DateAuditable { @Id @@ -53,7 +51,7 @@ public class ChatMember extends DateAuditable { private ChatRoom chatRoom; @Builder - public ChatMember(String name, User user, ChatRoom chatRoom, ChatMemberRole role) { + protected ChatMember(String name, User user, ChatRoom chatRoom, ChatMemberRole role) { validate(name, user, chatRoom, role); this.name = name; @@ -62,9 +60,9 @@ public ChatMember(String name, User user, ChatRoom chatRoom, ChatMemberRole role this.role = role; } - public static ChatMember of(String name, User user, ChatRoom chatRoom, ChatMemberRole role) { + public static ChatMember of(User user, ChatRoom chatRoom, ChatMemberRole role) { return ChatMember.builder() - .name(name) + .name(user.getName()) .user(user) .chatRoom(chatRoom) .role(role) @@ -81,6 +79,29 @@ private void validate(String name, User user, ChatRoom chatRoom, ChatMemberRole Objects.requireNonNull(role, "role은 null이 될 수 없습니다."); } + /** + * 사용자 데이터가 삭제되었는지 확인한다. + * + * @return 삭제된 데이터가 아니면 true, 삭제된 데이터이면 false + */ + public boolean isActive() { + return deletedAt == null; + } + + /** + * 사용자 추방된 이력이 있는 지 확인한다. + * + * @return 추방된 이력이 있으면 true, 없으면 false + */ + public boolean isBannedMember() { + return deletedAt != null && banned; + } + + public void ban() { + this.banned = true; + this.deletedAt = LocalDateTime.now(); + } + @Override public String toString() { return "ChatMember{" + diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/member/exception/ChatMemberErrorCode.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/member/exception/ChatMemberErrorCode.java new file mode 100644 index 000000000..dc023ec60 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/member/exception/ChatMemberErrorCode.java @@ -0,0 +1,31 @@ +package kr.co.pennyway.domain.domains.member.exception; + +import kr.co.pennyway.common.exception.BaseErrorCode; +import kr.co.pennyway.common.exception.CausedBy; +import kr.co.pennyway.common.exception.ReasonCode; +import kr.co.pennyway.common.exception.StatusCode; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public enum ChatMemberErrorCode implements BaseErrorCode { + /* 403 FORBIDDEN */ + BANNED(StatusCode.FORBIDDEN, ReasonCode.ACCESS_TO_THE_REQUESTED_RESOURCE_IS_FORBIDDEN, "차단된 회원입니다."), + + /* 409 Conflict */ + ALREADY_JOINED(StatusCode.CONFLICT, ReasonCode.RESOURCE_ALREADY_EXISTS, "이미 가입한 회원입니다."), + ; + + private final StatusCode statusCode; + private final ReasonCode reasonCode; + private final String message; + + @Override + public CausedBy causedBy() { + return CausedBy.of(statusCode, reasonCode); + } + + @Override + public String getExplainError() throws NoSuchFieldError { + return message; + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/member/exception/ChatMemberErrorException.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/member/exception/ChatMemberErrorException.java new file mode 100644 index 000000000..60ba76f2b --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/member/exception/ChatMemberErrorException.java @@ -0,0 +1,21 @@ +package kr.co.pennyway.domain.domains.member.exception; + +import kr.co.pennyway.common.exception.CausedBy; +import kr.co.pennyway.common.exception.GlobalErrorException; + +public class ChatMemberErrorException extends GlobalErrorException { + private final ChatMemberErrorCode chatMemberErrorCode; + + public ChatMemberErrorException(ChatMemberErrorCode baseErrorCode) { + super(baseErrorCode); + this.chatMemberErrorCode = baseErrorCode; + } + + public CausedBy causedBy() { + return chatMemberErrorCode.causedBy(); + } + + public String getExplainError() { + return chatMemberErrorCode.getExplainError(); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/member/repository/ChatMemberRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/member/repository/ChatMemberRepository.java index 0a6bd5801..f58dc6bb1 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/member/repository/ChatMemberRepository.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/member/repository/ChatMemberRepository.java @@ -2,6 +2,16 @@ import kr.co.pennyway.domain.common.repository.ExtendedRepository; import kr.co.pennyway.domain.domains.member.domain.ChatMember; +import org.springframework.data.jpa.repository.Query; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Set; public interface ChatMemberRepository extends ExtendedRepository, CustomChatMemberRepository { + @Transactional(readOnly = true) + Set findByChatRoom_IdAndUser_Id(Long chatRoomId, Long userId); + + @Transactional(readOnly = true) + @Query("SELECT COUNT(*) FROM ChatMember cm WHERE cm.chatRoom.id = :chatRoomId AND cm.deletedAt IS NULL") + long countByChatRoomIdAndActive(Long chatRoomId); } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/member/repository/CustomChatMemberRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/member/repository/CustomChatMemberRepository.java index 8824f4c13..3927f90b3 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/member/repository/CustomChatMemberRepository.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/member/repository/CustomChatMemberRepository.java @@ -1,5 +1,9 @@ package kr.co.pennyway.domain.domains.member.repository; public interface CustomChatMemberRepository { + /** + * 채팅방에 해당 유저가 존재하는지 확인한다. + * 이 때, 삭제된 사용자 데이터는 조회하지 않는다. + */ boolean existsByChatRoomIdAndUserId(Long chatRoomId, Long userId); } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/member/repository/CustomChatMemberRepositoryImpl.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/member/repository/CustomChatMemberRepositoryImpl.java index d3b8e477b..e10b67618 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/member/repository/CustomChatMemberRepositoryImpl.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/member/repository/CustomChatMemberRepositoryImpl.java @@ -18,7 +18,8 @@ public boolean existsByChatRoomIdAndUserId(Long chatRoomId, Long userId) { return queryFactory.select(ConstantImpl.create(1)) .from(chatMember) .where(chatMember.chatRoom.id.eq(chatRoomId) - .and(chatMember.user.id.eq(userId))) + .and(chatMember.user.id.eq(userId)) + .and(chatMember.deletedAt.isNull())) .fetchFirst() != null; } } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/member/service/ChatMemberService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/member/service/ChatMemberService.java index d7fb8840d..06f7dc271 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/member/service/ChatMemberService.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/member/service/ChatMemberService.java @@ -1,12 +1,19 @@ package kr.co.pennyway.domain.domains.member.service; import kr.co.pennyway.common.annotation.DomainService; +import kr.co.pennyway.domain.domains.chatroom.domain.ChatRoom; import kr.co.pennyway.domain.domains.member.domain.ChatMember; +import kr.co.pennyway.domain.domains.member.exception.ChatMemberErrorCode; +import kr.co.pennyway.domain.domains.member.exception.ChatMemberErrorException; import kr.co.pennyway.domain.domains.member.repository.ChatMemberRepository; +import kr.co.pennyway.domain.domains.member.type.ChatMemberRole; +import kr.co.pennyway.domain.domains.user.domain.User; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.transaction.annotation.Transactional; +import java.util.Set; + @Slf4j @DomainService @RequiredArgsConstructor @@ -14,12 +21,42 @@ public class ChatMemberService { private final ChatMemberRepository chatMemberRepository; @Transactional - public ChatMember create(ChatMember chatMember) { + public ChatMember createAdmin(User user, ChatRoom chatRoom) { + ChatMember chatMember = ChatMember.of(user, chatRoom, ChatMemberRole.ADMIN); + return chatMemberRepository.save(chatMember); } + @Transactional + public ChatMember createMember(User user, ChatRoom chatRoom) { + Set chatMembers = chatMemberRepository.findByChatRoom_IdAndUser_Id(chatRoom.getId(), user.getId()); + + if (chatMembers.stream().anyMatch(ChatMember::isActive)) { + log.warn("사용자는 이미 채팅방에 가입되어 있습니다. chatRoomId: {}, userId: {}", chatRoom.getId(), user.getId()); + throw new ChatMemberErrorException(ChatMemberErrorCode.ALREADY_JOINED); + } + + if (chatMembers.stream().anyMatch(ChatMember::isBanned)) { + log.warn("사용자는 채팅방에서 추방된 이력이 존재합니다. chatRoomId: {}, userId: {}", chatRoom.getId(), user.getId()); + throw new ChatMemberErrorException(ChatMemberErrorCode.BANNED); + } + + ChatMember chatMember = ChatMember.of(user, chatRoom, ChatMemberRole.MEMBER); + + return chatMemberRepository.save(chatMember); + } + + /** + * 채팅방에 해당 유저가 존재하는지 확인한다. + * 이 때, 삭제된 사용자 데이터는 조회하지 않는다. + */ @Transactional(readOnly = true) public boolean isExists(Long chatRoomId, Long userId) { return chatMemberRepository.existsByChatRoomIdAndUserId(chatRoomId, userId); } + + @Transactional(readOnly = true) + public long countActiveMembers(Long chatRoomId) { + return chatMemberRepository.countByChatRoomIdAndActive(chatRoomId); + } } diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/common/fixture/ChatRoomFixture.java b/pennyway-domain/src/test/java/kr/co/pennyway/common/fixture/ChatRoomFixture.java new file mode 100644 index 000000000..af5b6903b --- /dev/null +++ b/pennyway-domain/src/test/java/kr/co/pennyway/common/fixture/ChatRoomFixture.java @@ -0,0 +1,30 @@ +package kr.co.pennyway.common.fixture; + +import kr.co.pennyway.domain.domains.chatroom.domain.ChatRoom; + +public enum ChatRoomFixture { + PRIVATE_CHAT_ROOM("페니웨이", "페니웨이 채팅방입니다.", "delete/chatroom/1/fsdflasdfa_12121210.jpg", "123456"), + PUBLIC_CHAT_ROOM("페니웨이", "페니웨이 채팅방입니다.", "delete/chatroom/1/fsdflasdfa_12121210.jpg", null); + + private final String title; + private final String description; + private final String backgroundImageUrl; + private final String password; + + ChatRoomFixture(String title, String description, String backgroundImageUrl, String password) { + this.title = title; + this.description = description; + this.backgroundImageUrl = backgroundImageUrl; + this.password = password; + } + + public ChatRoom toEntity() { + return ChatRoom.builder() + .id(1L) + .title(title) + .description(description) + .backgroundImageUrl(backgroundImageUrl) + .password(password != null ? Integer.valueOf(password) : null) + .build(); + } +} diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/common/fixture/UserFixture.java b/pennyway-domain/src/test/java/kr/co/pennyway/common/fixture/UserFixture.java new file mode 100644 index 000000000..0eab325d4 --- /dev/null +++ b/pennyway-domain/src/test/java/kr/co/pennyway/common/fixture/UserFixture.java @@ -0,0 +1,49 @@ +package kr.co.pennyway.common.fixture; + +import kr.co.pennyway.domain.domains.user.domain.NotifySetting; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.type.ProfileVisibility; +import kr.co.pennyway.domain.domains.user.type.Role; +import lombok.Getter; + +@Getter +public enum UserFixture { + GENERAL_USER(1L, "jayang", "dkssudgktpdy1", "Yang", "010-1111-1111", Role.USER, ProfileVisibility.PUBLIC, NotifySetting.of(true, true, true), false), + OAUTH_USER(2L, "only._.o", null, "Only", "010-2222-2222", Role.USER, ProfileVisibility.PUBLIC, NotifySetting.of(true, true, true), false), + ; + + private final Long id; + private final String username; + private final String password; + private final String name; + private final String phone; + private final Role role; + private final ProfileVisibility profileVisibility; + private final NotifySetting notifySetting; + private final Boolean locked; + + UserFixture(Long id, String username, String password, String name, String phone, Role role, ProfileVisibility profileVisibility, NotifySetting notifySetting, Boolean locked) { + this.id = id; + this.username = username; + this.password = password; + this.name = name; + this.phone = phone; + this.role = role; + this.profileVisibility = profileVisibility; + this.notifySetting = notifySetting; + this.locked = locked; + } + + public User toUser() { + return User.builder() + .username(username) + .password(password) + .name(name) + .phone(phone) + .role(role) + .profileVisibility(profileVisibility) + .notifySetting(notifySetting) + .locked(locked) + .build(); + } +} \ No newline at end of file diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/member/service/ChatMemberCreateServiceTest.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/member/service/ChatMemberCreateServiceTest.java new file mode 100644 index 000000000..64acf2292 --- /dev/null +++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/member/service/ChatMemberCreateServiceTest.java @@ -0,0 +1,109 @@ +package kr.co.pennyway.domain.domains.member.service; + +import kr.co.pennyway.common.fixture.ChatRoomFixture; +import kr.co.pennyway.common.fixture.UserFixture; +import kr.co.pennyway.domain.domains.chatroom.domain.ChatRoom; +import kr.co.pennyway.domain.domains.member.domain.ChatMember; +import kr.co.pennyway.domain.domains.member.exception.ChatMemberErrorCode; +import kr.co.pennyway.domain.domains.member.exception.ChatMemberErrorException; +import kr.co.pennyway.domain.domains.member.repository.ChatMemberRepository; +import kr.co.pennyway.domain.domains.member.type.ChatMemberRole; +import kr.co.pennyway.domain.domains.user.domain.User; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; +import java.util.Set; + +import static org.junit.Assert.assertThrows; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; + +@Slf4j +@ExtendWith(MockitoExtension.class) +public class ChatMemberCreateServiceTest { + @Mock + private ChatMemberRepository chatMemberRepository; + private ChatMemberService chatMemberService; + + private User user; + private ChatRoom chatRoom; + + @BeforeEach + void setUp() { + chatMemberService = new ChatMemberService(chatMemberRepository); + user = UserFixture.GENERAL_USER.toUser(); + chatRoom = ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntity(); + } + + @Test + @DisplayName("이미 가입한 회원은 가입에 실패한다.") + void createMemberWhenAlreadyExist() { + // given + ChatMember chatMember = ChatMember.of(user, chatRoom, ChatMemberRole.MEMBER); + given(chatMemberRepository.findByChatRoom_IdAndUser_Id(chatRoom.getId(), user.getId())).willReturn(Set.of(chatMember)); + + // when + ChatMemberErrorException exception = assertThrows(ChatMemberErrorException.class, () -> chatMemberService.createMember(user, chatRoom)); + + // then + assertEquals(ChatMemberErrorCode.ALREADY_JOINED, exception.getBaseErrorCode(), "에러 코드는 ALREADY_JOINED 여야 한다."); + } + + @Test + @DisplayName("추방 당한 이력이 있는 회원은 가입에 실패한다.") + void createMemberWhenBanned() { + // given + ChatMember chatMember = ChatMember.of(user, chatRoom, ChatMemberRole.MEMBER); + chatMember.ban(); + given(chatMemberRepository.findByChatRoom_IdAndUser_Id(chatRoom.getId(), user.getId())).willReturn(Set.of(chatMember)); + + // when + ChatMemberErrorException exception = assertThrows(ChatMemberErrorException.class, () -> chatMemberService.createMember(user, chatRoom)); + + // then + assertEquals(ChatMemberErrorCode.BANNED, exception.getBaseErrorCode(), "에러 코드는 BANNED 여야 한다."); + } + + @Test + @DisplayName("가입한 이력이 없는 사용자는 가입에 성공한다.") + void createMemberWhenNotExist() { + + // given + given(chatMemberRepository.findByChatRoom_IdAndUser_Id(chatRoom.getId(), user.getId())).willReturn(Set.of()); + + ChatMember chatMember = ChatMember.of(user, chatRoom, ChatMemberRole.MEMBER); + given(chatMemberRepository.save(any(ChatMember.class))).willReturn(chatMember); + + // when + ChatMember result = chatMemberService.createMember(user, chatRoom); + + // then + Assertions.assertNotNull(result); + } + + @Test + @DisplayName("탈퇴한 이력이 있지만, 사유가 추방이 아니라면 가입에 성공한다.") + void createMemberWhenWithdrawn() { + // given + ChatMember original = ChatMember.of(user, chatRoom, ChatMemberRole.MEMBER); + ReflectionTestUtils.setField(original, "deletedAt", LocalDateTime.now()); + + ChatMember chatMember = ChatMember.of(user, chatRoom, ChatMemberRole.MEMBER); + given(chatMemberRepository.save(any(ChatMember.class))).willReturn(chatMember); + + // when + ChatMember result = chatMemberService.createMember(user, chatRoom); + + // then + Assertions.assertNotNull(result); + } +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/ChatRoomJoinEvent.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/ChatRoomJoinEvent.java new file mode 100644 index 000000000..6162eebf7 --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/ChatRoomJoinEvent.java @@ -0,0 +1,10 @@ +package kr.co.pennyway.infra.common.event; + +public record ChatRoomJoinEvent( + Long chatRoomId, + String userName +) { + public static ChatRoomJoinEvent of(Long chatRoomId, String userName) { + return new ChatRoomJoinEvent(chatRoomId, userName); + } +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/ChatRoomJoinEventHandler.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/ChatRoomJoinEventHandler.java new file mode 100644 index 000000000..414a24028 --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/ChatRoomJoinEventHandler.java @@ -0,0 +1,37 @@ +package kr.co.pennyway.infra.common.event; + +import kr.co.pennyway.infra.client.broker.MessageBrokerAdapter; +import kr.co.pennyway.infra.common.properties.ChatExchangeProperties; +import kr.co.pennyway.infra.common.properties.ChatJoinEventExchangeProperties; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.scheduling.annotation.Async; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import java.util.Map; + +@Slf4j +@RequiredArgsConstructor +public class ChatRoomJoinEventHandler { + private final MessageBrokerAdapter messageBrokerAdapter; + private final ChatExchangeProperties chatExchangeProperties; + private final ChatJoinEventExchangeProperties chatJoinEventExchangeProperties; + + @Async + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + public void handle(ChatRoomJoinEvent event) { + log.debug("handle: {}", event); + + Message message = MessageBuilder.createMessage(event, new MessageHeaders(Map.of())); + + messageBrokerAdapter.send( + chatExchangeProperties.getExchange(), + chatJoinEventExchangeProperties.getRoutingKey(), + message + ); + } +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/properties/ChatJoinEventExchangeProperties.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/properties/ChatJoinEventExchangeProperties.java new file mode 100644 index 000000000..abca425d4 --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/properties/ChatJoinEventExchangeProperties.java @@ -0,0 +1,21 @@ +package kr.co.pennyway.infra.common.properties; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@RequiredArgsConstructor +@ConfigurationProperties(prefix = "pennyway.rabbitmq.chat-join-event") +public class ChatJoinEventExchangeProperties { + private final String queue; + private final String routingKey; + + @Override + public String toString() { + return "ChatJoinEventExchangeProperties{" + + "queue='" + queue + '\'' + + ", routingKey='" + routingKey + '\'' + + '}'; + } +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/MessageBrokerConfig.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/MessageBrokerConfig.java index 624e728b9..90ad71215 100644 --- a/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/MessageBrokerConfig.java +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/MessageBrokerConfig.java @@ -6,8 +6,10 @@ import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import kr.co.pennyway.infra.client.broker.MessageBrokerAdapter; +import kr.co.pennyway.infra.common.event.ChatRoomJoinEventHandler; import kr.co.pennyway.infra.common.importer.PennywayInfraConfig; import kr.co.pennyway.infra.common.properties.ChatExchangeProperties; +import kr.co.pennyway.infra.common.properties.ChatJoinEventExchangeProperties; import kr.co.pennyway.infra.common.properties.RabbitMqProperties; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -18,12 +20,14 @@ import org.springframework.amqp.rabbit.annotation.EnableRabbit; import org.springframework.amqp.rabbit.config.SimpleRabbitListenerContainerFactory; import org.springframework.amqp.rabbit.connection.CachingConnectionFactory; +import org.springframework.amqp.rabbit.connection.Connection; import org.springframework.amqp.rabbit.connection.ConnectionFactory; import org.springframework.amqp.rabbit.core.RabbitMessagingTemplate; import org.springframework.amqp.rabbit.core.RabbitTemplate; import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter; import org.springframework.amqp.support.converter.MessageConverter; import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Primary; @@ -31,10 +35,16 @@ @Slf4j @EnableRabbit @RequiredArgsConstructor -@EnableConfigurationProperties({ChatExchangeProperties.class, RabbitMqProperties.class}) +@EnableConfigurationProperties({ChatExchangeProperties.class, ChatJoinEventExchangeProperties.class, RabbitMqProperties.class}) public class MessageBrokerConfig implements PennywayInfraConfig { private final RabbitMqProperties rabbitMqProperties; private final ChatExchangeProperties chatExchangeProperties; + private final ChatJoinEventExchangeProperties chatJoinEventExchangeProperties; + + @Bean + public TopicExchange chatExchange() { + return new TopicExchange(chatExchangeProperties.getExchange()); + } @Bean public Queue chatQueue() { @@ -42,8 +52,8 @@ public Queue chatQueue() { } @Bean - public TopicExchange chatExchange() { - return new TopicExchange(chatExchangeProperties.getExchange()); + public Queue chatJoinEventQueue(ChatJoinEventExchangeProperties chatJoinEventExchangeProperties) { + return new Queue(chatJoinEventExchangeProperties.getQueue(), true); } @Bean @@ -54,6 +64,14 @@ public Binding chatBinding(Queue chatQueue, TopicExchange chatExchange) { .with(chatExchangeProperties.getRoutingKey()); } + @Bean + public Binding chatJoinEventBinding(Queue chatJoinEventQueue, TopicExchange chatExchange) { + return BindingBuilder + .bind(chatJoinEventQueue) + .to(chatExchange) + .with(chatJoinEventExchangeProperties.getRoutingKey()); + } + @Bean public Module dateTimeModule() { return new JavaTimeModule(); @@ -83,9 +101,16 @@ public ConnectionFactory createConnectionFactory() { return factory; } - @Bean + @ConditionalOnProperty(prefix = "pennyway.rabbitmq", name = "validate-connection", havingValue = "true", matchIfMissing = false) ApplicationRunner connectionFactoryRunner(ConnectionFactory cf) { - return args -> cf.createConnection().close(); + return args -> { + try (Connection conn = cf.createConnection()) { + log.info("RabbitMQ connection validated"); + } catch (Exception e) { + log.error("Failed to validate RabbitMQ connection", e); + throw e; + } + }; } @Bean @@ -112,4 +137,9 @@ public RabbitMessagingTemplate customRabbitMessagingTemplate(RabbitTemplate rabb public MessageBrokerAdapter messageBrokerAdapter(RabbitMessagingTemplate rabbitMessagingTemplate) { return new MessageBrokerAdapter(rabbitMessagingTemplate); } + + @Bean + public ChatRoomJoinEventHandler chatRoomJoinEventHandler(MessageBrokerAdapter messageBrokerAdapter, ChatExchangeProperties chatExchangeProperties, ChatJoinEventExchangeProperties chatJoinEventExchangeProperties) { + return new ChatRoomJoinEventHandler(messageBrokerAdapter, chatExchangeProperties, chatJoinEventExchangeProperties); + } } diff --git a/pennyway-infra/src/main/resources/application-infra.yml b/pennyway-infra/src/main/resources/application-infra.yml index 97afa94d1..818f50a44 100644 --- a/pennyway-infra/src/main/resources/application-infra.yml +++ b/pennyway-infra/src/main/resources/application-infra.yml @@ -60,6 +60,9 @@ pennyway: queue: ${RABBITMQ_CHAT_QUEUE:chat.queue} exchange: ${RABBITMQ_CHAT_EXCHANGE:chat.exchange} routing-key: ${RABBITMQ_CHAT_ROUTING:chat.room.*} + chat-join-event: + queue: ${RABBITMQ_CHAT_JOIN_QUEUE:chat.join.queue} + routing-key: ${RABBITMQ_CHAT_JOIN_ROUTING:chat.join.*} oauth2: client: diff --git a/pennyway-socket/src/main/resources/application.yml b/pennyway-socket/src/main/resources/application.yml index 66d833e46..32218373b 100644 --- a/pennyway-socket/src/main/resources/application.yml +++ b/pennyway-socket/src/main/resources/application.yml @@ -12,6 +12,8 @@ pennyway: chat: endpoint: ${SOCKET_CHAT_ENDPOINT:/ws} allowed-origin-patterns: ${ALLOWED_ORIGIN_PATTERNS:*} + rabbitmq: + validate-connection: true message-broker: external: