Skip to content

Commit

Permalink
feat: ✨ Implementing Chatroom Join API: A Bounded Context Approach (#184
Browse files Browse the repository at this point in the history
)

* chore: external-api module messabe broker config 주입

* chore: add chat_join_event_message exchange properties

* chore: add chat.join exchange

* style: domain service 역할을 구분하기 위한 chat_member 생성 로직 위치 수정

* fix: modify the chat_member entity to don't check exsists member is_deleted

* feat: add domain logic(is_active(), is_banned_member()) in the chat_member entity

* feat: add ban domain method in entity

* feat: add chat_member exception & error code

* test: add user_fixture & chat_room_fixture within the domain test package

* feat: impl chat_member_repository

* feat: impl create_member business logic within chat_member_service

* test: chat_member create business logic unit test

* fix: exclude nickname parameter when create chat_member

* feat: add password check and verify domain logic whitin the chat room entity

* feat: add chat_room error code with exception

* fix: add chat_room not_found error code

* feat: chat_room_service.read_by_id()

* feat: add count_chat_member in chat_room logic

* feat: chat_member_join business service impl

* feat: add chat_room_join event handler within infra module

* fix: when member join finish, call the chat_room join event handler

* feat: join_chat_room_usecase & fix chat_member_join_service return value

* fix: chat_member_join_service return value is adding plus 1 about current_member_count

* feat: impl join chat room controller

* docs: write join_api swagger

* fix: join_req_dto is added getter method for swagger ui presenting

* fix: chat_member_repository method rule find_by_chat_room_id -> find_by_chatroom_id

* test: add chat_member_fixture in the external-api module

* test: chat_member_join_service_unit_test

* test: refactor & add chat_member_join_service test case

* chore: prevent automatic rabbitmq connection creation during application startup

* fix: add two types of getter whitin chat_member_req

* fix: add default constructor within dto

* fix: modify distributed_lock's key correctly about the spel

* rename: add log within chat_member_join_service

* test: fix chat_room_id  of the chat_room_fixture due to integration failure when id is fixed 1

* rename: join_service_test (usecase package) move to (service package)

* fix: chat_room_join_event_hander bean create within the message_brocker_config

* fix: modify join_event_hander phase after_commit to before_commit due to at_least_one condition

* test: chat_member_join integration test
  • Loading branch information
psychology50 authored Oct 29, 2024
1 parent 7f74e8c commit afb5d86
Show file tree
Hide file tree
Showing 33 changed files with 1,241 additions and 22 deletions.
Original file line number Diff line number Diff line change
@@ -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
);
}
Original file line number Diff line number Diff line change
@@ -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));
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
Original file line number Diff line number Diff line change
@@ -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<ChatRoom, Integer> - 채팅방 정보와 현재 가입한 회원 수
*/
@DistributedLock(key = "'chat-room-join-' + #chatRoomId")
public Pair<ChatRoom, Integer> 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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, Integer> chatRoom = chatMemberJoinService.execute(userId, chatRoomId, password);

return ChatRoomMapper.toChatRoomResDetail(chatRoom.getLeft(), false, chatRoom.getRight());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
}
4 changes: 4 additions & 0 deletions pennyway-app-external-api/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));

Expand All @@ -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));
Expand Down
Loading

0 comments on commit afb5d86

Please sign in to comment.