Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Api: ✨ Implementing Chatroom Join API: A Bounded Context Approach #184

Merged
merged 40 commits into from
Oct 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
529af53
chore: external-api module messabe broker config 주입
psychology50 Oct 27, 2024
17fbd59
chore: add chat_join_event_message exchange properties
psychology50 Oct 27, 2024
f70fa0c
chore: add chat.join exchange
psychology50 Oct 27, 2024
20faf4b
style: domain service 역할을 구분하기 위한 chat_member 생성 로직 위치 수정
psychology50 Oct 27, 2024
3dd8dde
fix: modify the chat_member entity to don't check exsists member is_d…
psychology50 Oct 27, 2024
0292b30
feat: add domain logic(is_active(), is_banned_member()) in the chat_m…
psychology50 Oct 27, 2024
db98b94
feat: add ban domain method in entity
psychology50 Oct 27, 2024
74bb8b9
feat: add chat_member exception & error code
psychology50 Oct 27, 2024
7ef6be7
test: add user_fixture & chat_room_fixture within the domain test pac…
psychology50 Oct 27, 2024
57fdc46
feat: impl chat_member_repository
psychology50 Oct 27, 2024
c9869c2
feat: impl create_member business logic within chat_member_service
psychology50 Oct 27, 2024
50fcf39
test: chat_member create business logic unit test
psychology50 Oct 27, 2024
7113a65
fix: exclude nickname parameter when create chat_member
psychology50 Oct 27, 2024
5ae151f
feat: add password check and verify domain logic whitin the chat room…
psychology50 Oct 27, 2024
7f983be
feat: add chat_room error code with exception
psychology50 Oct 29, 2024
aac5895
fix: add chat_room not_found error code
psychology50 Oct 29, 2024
215b6e1
feat: chat_room_service.read_by_id()
psychology50 Oct 29, 2024
a3ce597
feat: add count_chat_member in chat_room logic
psychology50 Oct 29, 2024
27e7685
feat: chat_member_join business service impl
psychology50 Oct 29, 2024
e7aa55c
feat: add chat_room_join event handler within infra module
psychology50 Oct 29, 2024
8a94f4d
fix: when member join finish, call the chat_room join event handler
psychology50 Oct 29, 2024
9284ccc
feat: join_chat_room_usecase & fix chat_member_join_service return value
psychology50 Oct 29, 2024
57a6b48
fix: chat_member_join_service return value is adding plus 1 about cur…
psychology50 Oct 29, 2024
451cf22
feat: impl join chat room controller
psychology50 Oct 29, 2024
7379fe2
docs: write join_api swagger
psychology50 Oct 29, 2024
6218d86
fix: join_req_dto is added getter method for swagger ui presenting
psychology50 Oct 29, 2024
2793368
fix: chat_member_repository method rule find_by_chat_room_id -> find_…
psychology50 Oct 29, 2024
a12d85e
test: add chat_member_fixture in the external-api module
psychology50 Oct 29, 2024
0a67a54
test: chat_member_join_service_unit_test
psychology50 Oct 29, 2024
1ff3b7d
test: refactor & add chat_member_join_service test case
psychology50 Oct 29, 2024
96610b5
chore: prevent automatic rabbitmq connection creation during applicat…
psychology50 Oct 29, 2024
60ba5b6
fix: add two types of getter whitin chat_member_req
psychology50 Oct 29, 2024
5cdfe43
fix: add default constructor within dto
psychology50 Oct 29, 2024
1d44cb6
fix: modify distributed_lock's key correctly about the spel
psychology50 Oct 29, 2024
6981338
rename: add log within chat_member_join_service
psychology50 Oct 29, 2024
ad57c34
test: fix chat_room_id of the chat_room_fixture due to integration f…
psychology50 Oct 29, 2024
22a6d3a
rename: join_service_test (usecase package) move to (service package)
psychology50 Oct 29, 2024
c1cdbdf
fix: chat_room_join_event_hander bean create within the message_brock…
psychology50 Oct 29, 2024
9b65d96
fix: modify join_event_hander phase after_commit to before_commit due…
psychology50 Oct 29, 2024
2034cc3
test: chat_member_join integration test
psychology50 Oct 29, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading