Skip to content

Commit

Permalink
feat: ✨ 채팅방 생성 API (#173)
Browse files Browse the repository at this point in the history
* feat: 각 모듈별 docker파일 분리

* fix: chat_member 엔티티 image_url 제거

* feat: 채팅방 생성 repo & service 정의

* feat: 채팅 멤버 생성 service 정의

* test: 채팅방 생성 컨트롤러 단위 테스트 성공 시나리오 작성

* feat: chat_room_request dto 정의

* feat: chat_room craete controller

* feat: chat_room_usecase 및 request & response schema 추가

* fix: controller success response 추가

* test: chat_room_fixture

* fix: chat_room_res detail dto to_string 수정

* fix: controller 내 usecase final 키워드 추가

* feat: 채팅방 역할 상수 정의

* fix: chat_memeber role 필드 추가

* fix: chat_room create dto to_entity 메서드 static 제거

* feat: chat_member entity 정적 팩토리 메서드 추가

* feat: 채팅방 생성 서비스 로직 구현

* feat: chat_room_mapper 정의

* test: 채팅방 생성 통합 테스트 구현

* fix: 채팅방 생성 요청 background_image_url pattern 수정

* feat: 채팅방 생성 요청 캐싱 entity

* feat: 생성 지연 채팅방 서비스 로직

* fix: pended_chat_room user_id 필드 추가 및 유효성 검사 & to_chat_room 메서드 추가

* fix: chat_room_req pend, create 요청 분리

* fix: 캐싱된 채팅방 정보 예외 정의

* fix: 채팅방 생성 서비스 로직 대기, 생성 메서드 분리

* feat: external-api 모듈 guid config 의존성 추가

* fix: pended_chat_room hash code 수정

* fix: 사용자 아이디로 캐싱된 정보 조회 -> 사용자 권한 예외 제거 가능하므로 삭제

* fix: controller 및 usecase 요청 분리

* test: controller 단위 테스트 pend, create 분리

* feat: 채팅방 entity pk auto_increment 속성 제거

* test: chat_room_fixture url 정보 수정

* fix: 캐싱된 채팅방 정보 로깅 추가

* test: 채팅방 생성 시나리오 테스트 수정

* fix: 채팅방 멤버 생성 시, entity 반환하도록 chat_member_service 로직 수정

* feat: 채팅방 저장 시, image url 변환 작업 수행 로직 추가

* test: aws_adapter mock bean 설정 추가

* feat: 채팅방 swagger group 등록

* docs: 채팅방 생성 대기 및 확정 api 스웨거 문서 작성

* chore: 불필요한 dockerfile 모두 제거

* chore: socket-relay dockerfile 복구
  • Loading branch information
psychology50 authored Oct 12, 2024
1 parent f46a3e1 commit 7650f4a
Show file tree
Hide file tree
Showing 26 changed files with 819 additions and 12 deletions.
48 changes: 48 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Common 모듈 빌드
FROM openjdk:17 AS common-builder
WORKDIR /app

COPY gradlew .
COPY gradle gradle
COPY build.gradle .
COPY settings.gradle .
COPY pennyway-common pennyway-common
RUN chmod +x ./gradlew


COPY pennyway-common pennyway-common
COPY build.gradle settings.gradle gradlew ./
COPY gradle gradle
RUN ./gradlew :pennyway-common:build -x test

# Infra 모듈 빌드
FROM openjdk:17 AS infra-builder
WORKDIR /app
COPY --from=common-builder /app/pennyway-common/build/libs/*.jar lib/
COPY pennyway-infra pennyway-infra
COPY build.gradle settings.gradle gradlew ./
COPY gradle gradle
RUN ./gradlew :pennyway-infra:build -x test

# Domain 모듈 빌드
FROM openjdk:17 AS domain-builder
WORKDIR /app
COPY --from=common-builder /app/pennyway-common/build/libs/*.jar lib/
COPY --from=infra-builder /app/pennyway-infra/build/libs/*.jar lib/
COPY pennyway-domain pennyway-domain
COPY build.gradle settings.gradle gradlew ./
COPY gradle gradle
RUN ./gradlew :pennyway-domain:build -x test

# 최종 실행 이미지
FROM openjdk:17

WORKDIR /app

# 빌드된 JAR 파일 복사
COPY --from=common-builder /app/pennyway-common/build/libs/*.jar common.jar
COPY --from=infra-builder /app/pennyway-infra/build/libs/*.jar infra.jar
COPY --from=domain-builder /app/pennyway-domain/build/libs/*.jar domain.jar

# 클래스패스 설정
ENV CLASSPATH=/app/common.jar:/app/infra.jar:/app/domain.jar
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package kr.co.pennyway.api.apis.chat.api;

import io.swagger.v3.oas.annotations.Operation;
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.ChatRoomReq;
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.common.redis.chatroom.PendedChatRoomErrorCode;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.RequestBody;

@Tag(name = "[채팅방 API]")
public interface ChatRoomApi {
@Operation(summary = "[1] 채팅방 생성 대기 요청", method = "POST", description = "채팅방 배경 이미지를 제외한 모든 정보를 서버에 저장하기 위한 API. 성공 시, 채팅방 아이디를 반환하며, 5분 후에 저장된 자동 삭제된다.")
@ApiResponse(responseCode = "200", description = "채팅방 생성 대기 요청 성공", content = @Content(schemaProperties = @SchemaProperty(name = "chatRoomId", schema = @Schema(implementation = Long.class))))
ResponseEntity<?> postChatRoom(@RequestBody ChatRoomReq.Pend request, @AuthenticationPrincipal SecurityUserDetails user);

@Operation(summary = "[2] 채팅방 생성", method = "POST", description = "채팅방 배경 이미지를 전달하여 `[1] 채팅방 생성 대기 요청`의 요청을 확정짓는다. 저장된 채팅방 정보를 반환한다.")
@ApiResponse(responseCode = "200", description = "채팅방 생성 성공", content = @Content(schemaProperties = @SchemaProperty(name = "chatRoom", schema = @Schema(implementation = ChatRoomRes.Detail.class))))
@ApiResponseExplanations(errors = {
@ApiExceptionExplanation(value = PendedChatRoomErrorCode.class, constant = "NOT_FOUND", name = "채팅방 정보 탐색 실패", description = "사용자가 생성한 채팅방 정보가 존재하지 않는 경우 발생하며, 이 경우 채팅방 생성 요청은 재시도 없이 실패 처리해야 한다.")
})
ResponseEntity<?> createChatRoom(@RequestBody ChatRoomReq.Create request, @AuthenticationPrincipal SecurityUserDetails user);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package kr.co.pennyway.api.apis.chat.controller;

import kr.co.pennyway.api.apis.chat.api.ChatRoomApi;
import kr.co.pennyway.api.apis.chat.dto.ChatRoomReq;
import kr.co.pennyway.api.apis.chat.usecase.ChatRoomUseCase;
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.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/v2/chat-rooms")
public class ChatRoomController implements ChatRoomApi {
private static final String CHAT_ROOM_ID = "chatRoomId";
private static final String CHAT_ROOM = "chatRoom";
private final ChatRoomUseCase chatRoomUseCase;

@Override
@PostMapping("/pend")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<?> postChatRoom(@RequestBody ChatRoomReq.Pend request, @AuthenticationPrincipal SecurityUserDetails user) {
return ResponseEntity.ok(SuccessResponse.from(CHAT_ROOM_ID, chatRoomUseCase.pendChatRoom(request, user.getUserId())));
}

@Override
@PostMapping("")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<?> createChatRoom(@RequestBody ChatRoomReq.Create request, @AuthenticationPrincipal SecurityUserDetails user) {
return ResponseEntity.ok(SuccessResponse.from(CHAT_ROOM, chatRoomUseCase.createChatRoom(request, user.getUserId())));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package kr.co.pennyway.api.apis.chat.dto;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import kr.co.pennyway.domain.common.redis.chatroom.PendedChatRoom;

public final class ChatRoomReq {
@Schema(title = "채팅방 생성 요청 - 대기")
public record Pend(
@NotBlank
@Size(min = 1, max = 50)
@Schema(description = "채팅방 제목. NULL 혹은 공백은 허용하지 않으며, 1~50자 이내의 문자열이어야 한다.", example = "페니웨이")
String title,
@Size(min = 1, max = 100)
@Schema(description = "채팅방 설명. NULL을 허용하며, 문자가 존재할 시 공백 허용 없이 1~100자 이내의 문자열이어야 한다.", example = "페니웨이 채팅방입니다.")
String description,
@Schema(description = "채팅방 비밀번호. NULL을 허용한다. 비밀번호는 6자리 정수만 허용", example = "123456")
@Size(min = 6, max = 6)
Integer password
) {
public PendedChatRoom toEntity(Long id, Long userId) {
return PendedChatRoom.of(id, userId, title, description, password);
}
}

@Schema(title = "채팅방 생성 요청 - 확정")
public record Create(
@Schema(description = "채팅방 배경 이미지 URL. NULL을 허용한다.", example = "delete/chatroom/{chatroom_id}/{uuid}_{timestamp}.{ext}")
@Pattern(regexp = "^delete/.*", message = "채팅방 배경 이미지 URL은 delete/로 시작해야 합니다.")
String backgroundImageUrl
) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package kr.co.pennyway.api.apis.chat.dto;

import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
import io.swagger.v3.oas.annotations.media.Schema;
import kr.co.pennyway.domain.domains.chatroom.domain.ChatRoom;

import java.time.LocalDateTime;
import java.util.Objects;

public final class ChatRoomRes {
@Schema(description = "채팅방 상세 정보")
public record Detail(
@Schema(description = "채팅방 ID", type = "long")
Long id,
@Schema(description = "채팅방 제목")
String title,
@Schema(description = "채팅방 설명")
String description,
@Schema(description = "채팅방 배경 이미지 URL")
String backgroundImageUrl,
@Schema(description = "채팅방 비공개 여부")
boolean isPrivate,
@Schema(description = "채팅방 참여자 수")
int participantCount,
@Schema(description = "채팅방 개설일")
@JsonSerialize(using = LocalDateTimeSerializer.class)
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
LocalDateTime createdAt
) {
public static Detail from(ChatRoom chatRoom, int participantCount) {
return new Detail(
chatRoom.getId(),
chatRoom.getTitle(),
Objects.toString(chatRoom.getDescription(), ""),
Objects.toString(chatRoom.getBackgroundImageUrl(), ""),
chatRoom.getPassword() != null,
participantCount,
chatRoom.getCreatedAt()
);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package kr.co.pennyway.api.apis.chat.mapper;

import kr.co.pennyway.api.apis.chat.dto.ChatRoomRes;
import kr.co.pennyway.common.annotation.Mapper;
import kr.co.pennyway.domain.domains.chatroom.domain.ChatRoom;

@Mapper
public final class ChatRoomMapper {
public static ChatRoomRes.Detail toChatRoomResDetail(ChatRoom chatRoom, int participantCount) {
return ChatRoomRes.Detail.from(chatRoom, participantCount);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package kr.co.pennyway.api.apis.chat.service;

import kr.co.pennyway.api.apis.chat.dto.ChatRoomReq;
import kr.co.pennyway.api.common.storage.AwsS3Adapter;
import kr.co.pennyway.domain.common.redis.chatroom.PendedChatRoom;
import kr.co.pennyway.domain.common.redis.chatroom.PendedChatRoomErrorCode;
import kr.co.pennyway.domain.common.redis.chatroom.PendedChatRoomErrorException;
import kr.co.pennyway.domain.common.redis.chatroom.PendedChatRoomService;
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;
import kr.co.pennyway.domain.domains.user.service.UserService;
import kr.co.pennyway.infra.client.aws.s3.ObjectKeyType;
import kr.co.pennyway.infra.client.guid.IdGenerator;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Slf4j
@Service
@RequiredArgsConstructor
public class ChatRoomSaveService {
private final UserService userService;
private final ChatRoomService chatRoomService;
private final ChatMemberService chatMemberService;
private final PendedChatRoomService pendedChatRoomService;

private final AwsS3Adapter awsS3Adapter;
private final IdGenerator<Long> idGenerator;

/**
* 채팅방 생성 정보를 캐싱한다.
* 해당 요청은 {@link #createChatRoom(ChatRoomReq.Create, Long)}를 통해 채팅방을 확정할 수 있다.
*
* @return 캐싱된 채팅방의 ID
*/
@Transactional
public Long pendChatRoom(ChatRoomReq.Pend request, Long userId) {
Long chatRoomId = idGenerator.generate();
log.info("Chat room ID: {}", chatRoomId);
PendedChatRoom info = pendedChatRoomService.create(request.toEntity(chatRoomId, userId));
log.info("Pended chat room: {}", info);

return chatRoomId;
}

/**
* 캐싱된 채팅방을 확정하고 채팅방을 생성한다.
* 채팅방을 생성한 사용자는 채팅방의 관리자로 설정된다.
*
* @throws PendedChatRoomErrorException: {@link PendedChatRoomErrorCode#NOT_FOUND} - 캐싱된 채팅방이 존재하지 않을 경우
*/
@Transactional
public ChatRoom createChatRoom(ChatRoomReq.Create request, Long userId) {
PendedChatRoom pendedChatRoom = pendedChatRoomService.readByUserId(userId)
.orElseThrow(() -> new PendedChatRoomErrorException(PendedChatRoomErrorCode.NOT_FOUND));

String originImageUrl = null;
if (request.backgroundImageUrl() != null) {
originImageUrl = awsS3Adapter.saveImage(request.backgroundImageUrl(), ObjectKeyType.CHAT_PROFILE);
}

ChatRoom chatRoom = chatRoomService.create(pendedChatRoom.toChatRoom(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);

return chatRoom;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package kr.co.pennyway.api.apis.chat.usecase;

import kr.co.pennyway.api.apis.chat.dto.ChatRoomReq;
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.ChatRoomSaveService;
import kr.co.pennyway.common.annotation.UseCase;
import kr.co.pennyway.domain.domains.chatroom.domain.ChatRoom;
import lombok.RequiredArgsConstructor;

@UseCase
@RequiredArgsConstructor
public class ChatRoomUseCase {
private final ChatRoomSaveService chatRoomSaveService;

public Long pendChatRoom(ChatRoomReq.Pend request, Long userId) {
return chatRoomSaveService.pendChatRoom(request, userId);
}

public ChatRoomRes.Detail createChatRoom(ChatRoomReq.Create request, Long userId) {
ChatRoom chatRoom = chatRoomSaveService.createChatRoom(request, userId);

return ChatRoomMapper.toChatRoomResDetail(chatRoom, 1);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
})
@EnablePennywayInfraConfig({
PennywayInfraConfigGroup.FCM,
PennywayInfraConfigGroup.DISTRIBUTED_COORDINATION_CONFIG
PennywayInfraConfigGroup.DISTRIBUTED_COORDINATION_CONFIG,
PennywayInfraConfigGroup.GUID_GENERATOR_CONFIG
})
public class InfraConfig {
}
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,17 @@ public GroupedOpenApi socketApi() {
.build();
}

@Bean
public GroupedOpenApi chatApi() {
String[] targets = {"kr.co.pennyway.api.apis.chat"};

return GroupedOpenApi.builder()
.packagesToScan(targets)
.group("채팅")
.addOperationCustomizer(customizer())
.build();
}

@Bean
public GroupedOpenApi backOfficeApi() {
String[] targets = {"kr.co.pennyway.api.apis.question"};
Expand Down
Loading

0 comments on commit 7650f4a

Please sign in to comment.