diff --git a/build.gradle b/build.gradle index 011f1aed..d31e534d 100644 --- a/build.gradle +++ b/build.gradle @@ -58,8 +58,11 @@ dependencies { implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.13.0' implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' + + testCompileOnly 'org.projectlombok:lombok' + testAnnotationProcessor 'org.projectlombok:lombok' } tasks.named('test') { useJUnitPlatform() -} \ No newline at end of file +} diff --git a/src/main/java/com/example/eatmate/EatmateApplication.java b/src/main/java/com/example/eatmate/EatmateApplication.java index eeaf0506..9947f88f 100644 --- a/src/main/java/com/example/eatmate/EatmateApplication.java +++ b/src/main/java/com/example/eatmate/EatmateApplication.java @@ -3,9 +3,13 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @EnableJpaAuditing +@EnableScheduling +@EnableAsync public class EatmateApplication { public static void main(String[] args) { diff --git a/src/main/java/com/example/eatmate/app/domain/chat/controller/ChatController.java b/src/main/java/com/example/eatmate/app/domain/chat/controller/ChatController.java index 537637b5..e1024dab 100644 --- a/src/main/java/com/example/eatmate/app/domain/chat/controller/ChatController.java +++ b/src/main/java/com/example/eatmate/app/domain/chat/controller/ChatController.java @@ -39,7 +39,7 @@ public void sendChatMessage( } //보완적 함수 웹소켓 끊어졌을 경우 - @PostMapping("/chat/{chatRoomId}") + @PostMapping("/api/chat/{chatRoomId}") @Operation(summary = "채팅 메세지 전송 대체 수단", description = "채팅을 메세지를 대체 방안을 통해 전송합니다.") public ResponseEntity> sendChatMessageAlter( @PathVariable Long chatRoomId, diff --git a/src/main/java/com/example/eatmate/app/domain/chat/domain/repository/ChatRepository.java b/src/main/java/com/example/eatmate/app/domain/chat/domain/repository/ChatRepository.java index dceda737..bb60d021 100644 --- a/src/main/java/com/example/eatmate/app/domain/chat/domain/repository/ChatRepository.java +++ b/src/main/java/com/example/eatmate/app/domain/chat/domain/repository/ChatRepository.java @@ -1,9 +1,10 @@ package com.example.eatmate.app.domain.chat.domain.repository; +import java.time.LocalDateTime; import java.util.List; -import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; import org.springframework.data.jpa.repository.JpaRepository; import com.example.eatmate.app.domain.chat.domain.Chat; @@ -12,7 +13,9 @@ public interface ChatRepository extends JpaRepository { - Page findChatByChatRoom(ChatRoom chatRoom, Pageable pageable); + Slice findChatByChatRoomOrderByCreatedAtDesc(ChatRoom chatRoom, Pageable pageable); + + Slice findChatByChatRoomAndCreatedAtLessThanOrderByCreatedAtDesc(ChatRoom chatRoom, LocalDateTime createdAt, Pageable pageable); List findChatByChatRoomAndDeletedStatusNot(ChatRoom chatRoom, DeletedStatus deletedStatus); -} \ No newline at end of file +} diff --git a/src/main/java/com/example/eatmate/app/domain/chat/dto/response/ChatMessageListDto.java b/src/main/java/com/example/eatmate/app/domain/chat/dto/response/ChatMessageListDto.java new file mode 100644 index 00000000..82d8430e --- /dev/null +++ b/src/main/java/com/example/eatmate/app/domain/chat/dto/response/ChatMessageListDto.java @@ -0,0 +1,33 @@ +package com.example.eatmate.app.domain.chat.dto.response; + +import java.util.List; + +import org.springframework.data.domain.Slice; + +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class ChatMessageListDto { + + private List chats; + private int pageNumber; + private boolean isLast; + + @Builder + private ChatMessageListDto(List chats, int pageNumber, boolean isLast) { + this.chats = chats; + this.pageNumber = pageNumber; + this.isLast = isLast; + } + + public static ChatMessageListDto from(Slice chatList) { + return ChatMessageListDto.builder() + .chats(chatList.getContent()) + .pageNumber(chatList.getNumber()) + .isLast(chatList.isLast()) + .build(); + } +} diff --git a/src/main/java/com/example/eatmate/app/domain/chat/service/ChatService.java b/src/main/java/com/example/eatmate/app/domain/chat/service/ChatService.java index e2420da7..a7151b9f 100644 --- a/src/main/java/com/example/eatmate/app/domain/chat/service/ChatService.java +++ b/src/main/java/com/example/eatmate/app/domain/chat/service/ChatService.java @@ -1,9 +1,10 @@ package com.example.eatmate.app.domain.chat.service; +import java.time.LocalDateTime; import java.util.List; -import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -11,6 +12,7 @@ import com.example.eatmate.app.domain.chat.domain.Chat; import com.example.eatmate.app.domain.chat.domain.repository.ChatRepository; import com.example.eatmate.app.domain.chat.dto.request.ChatMessageRequestDto; +import com.example.eatmate.app.domain.chat.dto.response.ChatMessageListDto; import com.example.eatmate.app.domain.chat.dto.response.ChatMessageResponseDto; import com.example.eatmate.app.domain.chatRoom.domain.ChatRoom; import com.example.eatmate.app.domain.chatRoom.domain.DeletedStatus; @@ -50,14 +52,30 @@ public void saveChat(ChatMessageRequestDto chatMessageDto, UserDetails userDetai chatRoom.updateLastChatAt(chat.getCreatedAt()); } - //불러오기(읽기 상태 없음) - public Page loadChat(Long chatRoomId, Pageable pageable) { + //불러오기 + public Slice loadChat(Long chatRoomId, LocalDateTime cursor, Pageable pageable) { ChatRoom chatRoom = chatRoomRepository.findByIdAndDeletedStatus(chatRoomId, DeletedStatus.NOT_DELETED) .orElseThrow(() -> new CommonException(ErrorCode.CHATROOM_NOT_FOUND)); - Page chats = chatRepository.findChatByChatRoom(chatRoom, pageable); + try{ + if (cursor == null) { + Slice chats = chatRepository.findChatByChatRoomOrderByCreatedAtDesc(chatRoom, pageable); + return chats.map(ChatMessageResponseDto::from); + } + if (cursor.isAfter(LocalDateTime.now())) { + throw new CommonException(ErrorCode.INVALID_CURSOR); + } + Slice chats = chatRepository.findChatByChatRoomAndCreatedAtLessThanOrderByCreatedAtDesc(chatRoom, cursor, pageable); - return chats.map(ChatMessageResponseDto::from); + return chats.map(ChatMessageResponseDto::from); + + } catch (CommonException e) { + throw e; + + } catch (Exception e) { + throw new CommonException(ErrorCode.CHAT_LOAD_FAIL); + + } } //채팅 삭제 @@ -65,4 +83,10 @@ public void deleteChat(ChatRoom chatRoom) { List chatList = chatRepository.findChatByChatRoomAndDeletedStatusNot(chatRoom, DeletedStatus.NOT_DELETED); chatList.forEach(Chat::deleteChat); } + + public ChatMessageListDto convertChatList(Long chatRoomId, LocalDateTime cursor, Pageable pageable) { + Slice chatDtos = loadChat(chatRoomId, cursor, pageable); + + return ChatMessageListDto.from(chatDtos); + } } diff --git a/src/main/java/com/example/eatmate/app/domain/chatRoom/controller/ChatRoomController.java b/src/main/java/com/example/eatmate/app/domain/chatRoom/controller/ChatRoomController.java index 7893ef80..fc779de0 100644 --- a/src/main/java/com/example/eatmate/app/domain/chatRoom/controller/ChatRoomController.java +++ b/src/main/java/com/example/eatmate/app/domain/chatRoom/controller/ChatRoomController.java @@ -1,8 +1,11 @@ package com.example.eatmate.app.domain.chatRoom.controller; +import java.time.LocalDateTime; + import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.data.web.PageableDefault; +import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -11,9 +14,11 @@ import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import com.example.eatmate.app.domain.chat.service.ChatPublisher; +import com.example.eatmate.app.domain.chat.dto.response.ChatMessageListDto; +import com.example.eatmate.app.domain.chat.service.ChatService; import com.example.eatmate.app.domain.chatRoom.dto.response.ChatRoomResponseDto; import com.example.eatmate.app.domain.chatRoom.service.ChatRoomService; import com.example.eatmate.global.response.GlobalResponseDto; @@ -26,20 +31,31 @@ @RequiredArgsConstructor public class ChatRoomController { - private final ChatPublisher chatPublisher; private final ChatRoomService chatRoomService; + private final ChatService chatService; @GetMapping("/{chatRoomId}") @Operation(summary = "채팅방 입장", description = "채팅방에 입장합니다.") public ResponseEntity> enterChatRoom( @PathVariable Long chatRoomId, @AuthenticationPrincipal UserDetails userDetails, - @PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.ASC) Pageable pageable) { + @PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable) { return ResponseEntity.status(HttpStatus.OK) .body(GlobalResponseDto.success(chatRoomService.enterChatRoomAndLoadMessage(chatRoomId, userDetails, pageable))); } + @GetMapping("/{chatRoomId}/past") + @Operation(summary = "채팅방의 과거 채팅 로딩", description = "채팅방의 과거 채팅을 로딩합니다.") + public ResponseEntity> loadChatRoomPastMessage( + @PathVariable Long chatRoomId, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime cursor, + @PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable) { + + return ResponseEntity.status(HttpStatus.OK) + .body(GlobalResponseDto.success(chatService.convertChatList(chatRoomId, cursor, pageable))); + } + @PatchMapping("/{chatRoomId}") @Operation(summary = "채팅방 나가기", description = "채팅방을 나갑니다.") public ResponseEntity> leftChatRoom( diff --git a/src/main/java/com/example/eatmate/app/domain/chatRoom/dto/response/ChatRoomResponseDto.java b/src/main/java/com/example/eatmate/app/domain/chatRoom/dto/response/ChatRoomResponseDto.java index 375e68ea..4d09dee3 100644 --- a/src/main/java/com/example/eatmate/app/domain/chatRoom/dto/response/ChatRoomResponseDto.java +++ b/src/main/java/com/example/eatmate/app/domain/chatRoom/dto/response/ChatRoomResponseDto.java @@ -2,7 +2,7 @@ import java.util.List; -import org.springframework.data.domain.Page; +import org.springframework.data.domain.Slice; import com.example.eatmate.app.domain.chat.dto.response.ChatMessageResponseDto; import com.example.eatmate.app.domain.member.domain.Mbti; @@ -23,7 +23,7 @@ public class ChatRoomResponseDto { private boolean isLast; @Builder - private ChatRoomResponseDto(Page chats, List participants, + private ChatRoomResponseDto(Slice chats, List participants, ChatRoomDeliveryNoticeDto deliveryNotice, ChatRoomOfflineNoticeDto offlineNotice) { this.chats = chats.getContent(); this.participants = participants; @@ -33,37 +33,42 @@ private ChatRoomResponseDto(Page chats, List participants, Page chatPage, ChatRoomDeliveryNoticeDto deliveryNotice) { + public static ChatRoomResponseDto ofWithDelivery(List participants, Slice chatPage, ChatRoomDeliveryNoticeDto deliveryNotice) { return ChatRoomResponseDto.builder() .chats(chatPage) .participants(participants) + .offlineNotice(null) .deliveryNotice(deliveryNotice) .build(); } - public static ChatRoomResponseDto ofWithOffline(List participants, Page chatPage, ChatRoomOfflineNoticeDto offlineNotice) { + public static ChatRoomResponseDto ofWithOffline(List participants, Slice chatPage, ChatRoomOfflineNoticeDto offlineNotice) { return ChatRoomResponseDto.builder() .chats(chatPage) .participants(participants) .offlineNotice(offlineNotice) + .deliveryNotice(null) .build(); } @Getter public static class ChatMemberResponseDto { - private String memberName; + private String nickname; private Mbti mbti; + private String profileImageUrl; @Builder - private ChatMemberResponseDto(String memberName, Mbti mbti) { - this.memberName = memberName; + private ChatMemberResponseDto(String nickname, Mbti mbti, String profileImageUrl) { + this.nickname = nickname; this.mbti = mbti; + this.profileImageUrl = profileImageUrl; } public static ChatMemberResponseDto from(Member member) { return ChatMemberResponseDto.builder() - .memberName(member.getName()) + .nickname(member.getNickname()) .mbti(member.getMbti()) + .profileImageUrl(member.getProfileImage().getImageUrl()) .build(); } } diff --git a/src/main/java/com/example/eatmate/app/domain/chatRoom/service/ChatRoomService.java b/src/main/java/com/example/eatmate/app/domain/chatRoom/service/ChatRoomService.java index 83ced027..1dafe4fc 100644 --- a/src/main/java/com/example/eatmate/app/domain/chatRoom/service/ChatRoomService.java +++ b/src/main/java/com/example/eatmate/app/domain/chatRoom/service/ChatRoomService.java @@ -4,8 +4,8 @@ import java.util.stream.Collectors; import org.springframework.context.ApplicationEventPublisher; -import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -79,7 +79,7 @@ public ChatRoomResponseDto enterChatRoomAndLoadMessage(Long chatRoomId, UserDeta .map(memberChatRoom -> ChatRoomResponseDto.ChatMemberResponseDto.from(memberChatRoom.getMember())) .collect(Collectors.toList()); - Page chatList = chatService.loadChat(chatRoomId, pageable); + Slice chatList = chatService.loadChat(chatRoomId, null, pageable); //채팅방 공지 처리 Meeting meeting = chatRoom.getMeeting(); diff --git a/src/main/java/com/example/eatmate/app/domain/meeting/domain/DeliveryMeeting.java b/src/main/java/com/example/eatmate/app/domain/meeting/domain/DeliveryMeeting.java index 14227153..af0ec638 100644 --- a/src/main/java/com/example/eatmate/app/domain/meeting/domain/DeliveryMeeting.java +++ b/src/main/java/com/example/eatmate/app/domain/meeting/domain/DeliveryMeeting.java @@ -9,7 +9,6 @@ import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; -import jakarta.validation.constraints.Future; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @@ -33,15 +32,11 @@ public class DeliveryMeeting extends Meeting { private String pickupLocation; // 현재 지도 API 관련 정보가 없어 임시로 String 자료형 선언 @Column(nullable = false) - @Future private LocalDateTime orderDeadline; @Column(nullable = false) private String accountNumber; - @Column(nullable = false) - private String accountHolder; - @Column(nullable = false) private BankName bankName; @@ -52,7 +47,6 @@ public void updateDeliveryMeeting( String storeName, String pickupLocation, String accountNumber, - String accountHolder, Image backgroundImage ) { super.updateMeeting(meetingName, description, backgroundImage); @@ -61,6 +55,5 @@ public void updateDeliveryMeeting( this.storeName = storeName; this.pickupLocation = pickupLocation; this.accountNumber = accountNumber; - this.accountHolder = accountHolder; } } diff --git a/src/main/java/com/example/eatmate/app/domain/meeting/domain/OfflineMeeting.java b/src/main/java/com/example/eatmate/app/domain/meeting/domain/OfflineMeeting.java index a09a18fe..26aa9e06 100644 --- a/src/main/java/com/example/eatmate/app/domain/meeting/domain/OfflineMeeting.java +++ b/src/main/java/com/example/eatmate/app/domain/meeting/domain/OfflineMeeting.java @@ -9,7 +9,6 @@ import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; -import jakarta.validation.constraints.Future; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @@ -25,7 +24,6 @@ public class OfflineMeeting extends Meeting { private String meetingPlace; // 현재 지도 API 관련 정보가 없어 임시로 String 자료형 선언 @Column(nullable = false) - @Future private LocalDateTime meetingDate; @Column(nullable = false) diff --git a/src/main/java/com/example/eatmate/app/domain/meeting/domain/repository/DeliveryMeetingRepository.java b/src/main/java/com/example/eatmate/app/domain/meeting/domain/repository/DeliveryMeetingRepository.java index 92b8f733..d89029cc 100644 --- a/src/main/java/com/example/eatmate/app/domain/meeting/domain/repository/DeliveryMeetingRepository.java +++ b/src/main/java/com/example/eatmate/app/domain/meeting/domain/repository/DeliveryMeetingRepository.java @@ -1,5 +1,8 @@ package com.example.eatmate.app.domain.meeting.domain.repository; +import java.time.LocalDateTime; +import java.util.List; + import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; import org.springframework.data.jpa.repository.JpaRepository; @@ -11,5 +14,8 @@ public interface DeliveryMeetingRepository extends JpaRepository { Slice findAllByFoodCategoryAndMeetingStatus(FoodCategory foodCategory, MeetingStatus meetingStatus, Pageable pageable); + + List findByMeetingStatusAndOrderDeadlineBefore(MeetingStatus meetingStatus, + LocalDateTime orderDeadline); } diff --git a/src/main/java/com/example/eatmate/app/domain/meeting/domain/repository/MeetingParticipantRepository.java b/src/main/java/com/example/eatmate/app/domain/meeting/domain/repository/MeetingParticipantRepository.java index eb8bf60c..942cebb3 100644 --- a/src/main/java/com/example/eatmate/app/domain/meeting/domain/repository/MeetingParticipantRepository.java +++ b/src/main/java/com/example/eatmate/app/domain/meeting/domain/repository/MeetingParticipantRepository.java @@ -4,12 +4,16 @@ import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; import com.example.eatmate.app.domain.meeting.domain.Meeting; import com.example.eatmate.app.domain.meeting.domain.MeetingParticipant; import com.example.eatmate.app.domain.meeting.domain.ParticipantRole; import com.example.eatmate.app.domain.member.domain.Member; +import jakarta.persistence.LockModeType; + public interface MeetingParticipantRepository extends JpaRepository { Long countByMeeting_Id(Long meetingId); @@ -24,4 +28,8 @@ public interface MeetingParticipantRepository extends JpaRepository findByMeeting(Meeting meeting); + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT mp FROM MeetingParticipant mp WHERE mp.meeting.id = :meetingId") + List findParticipantsByMeetingIdWithLock(Long meetingId); + } diff --git a/src/main/java/com/example/eatmate/app/domain/meeting/domain/repository/OfflineMeetingRepository.java b/src/main/java/com/example/eatmate/app/domain/meeting/domain/repository/OfflineMeetingRepository.java index e5f37e5f..857dd4d0 100644 --- a/src/main/java/com/example/eatmate/app/domain/meeting/domain/repository/OfflineMeetingRepository.java +++ b/src/main/java/com/example/eatmate/app/domain/meeting/domain/repository/OfflineMeetingRepository.java @@ -1,5 +1,8 @@ package com.example.eatmate.app.domain.meeting.domain.repository; +import java.time.LocalDateTime; +import java.util.List; + import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; import org.springframework.data.jpa.repository.JpaRepository; @@ -11,4 +14,7 @@ public interface OfflineMeetingRepository extends JpaRepository { Slice findAllByOfflineMeetingCategoryAndMeetingStatus(OfflineMeetingCategory offlineMeetingCategory, MeetingStatus meetingStatus, Pageable pageable); + + List findByMeetingStatusAndMeetingDateBefore(MeetingStatus meetingStatus, + LocalDateTime meetingDate); } diff --git a/src/main/java/com/example/eatmate/app/domain/meeting/dto/CreateDeliveryMeetingRequestDto.java b/src/main/java/com/example/eatmate/app/domain/meeting/dto/CreateDeliveryMeetingRequestDto.java index ddc37555..cc89519a 100644 --- a/src/main/java/com/example/eatmate/app/domain/meeting/dto/CreateDeliveryMeetingRequestDto.java +++ b/src/main/java/com/example/eatmate/app/domain/meeting/dto/CreateDeliveryMeetingRequestDto.java @@ -54,9 +54,6 @@ public class CreateDeliveryMeetingRequestDto { @Pattern(regexp = "^[0-9-]*$", message = "올바른 계좌번호 형식이 아닙니다") private String accountNumber; - @NotBlank(message = "예금주명은 필수입니다") - private String accountHolder; - @NotNull(message = "은행명은 필수입니다") private BankName bankName; diff --git a/src/main/java/com/example/eatmate/app/domain/meeting/dto/UpdateDeliveryMeetingRequestDto.java b/src/main/java/com/example/eatmate/app/domain/meeting/dto/UpdateDeliveryMeetingRequestDto.java index 97668a91..a3d38d26 100644 --- a/src/main/java/com/example/eatmate/app/domain/meeting/dto/UpdateDeliveryMeetingRequestDto.java +++ b/src/main/java/com/example/eatmate/app/domain/meeting/dto/UpdateDeliveryMeetingRequestDto.java @@ -1,11 +1,12 @@ package com.example.eatmate.app.domain.meeting.dto; +import java.time.LocalDateTime; + import org.springframework.web.multipart.MultipartFile; import com.example.eatmate.app.domain.meeting.domain.FoodCategory; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Future; import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; import lombok.AllArgsConstructor; @@ -15,28 +16,23 @@ @AllArgsConstructor public class UpdateDeliveryMeetingRequestDto { - @NotBlank(message = "모임 이름은 필수입니다") @Size(max = 30, message = "모임 이름은 30자 이하여야 합니다") private String meetingName; @Size(max = 100, message = "설명은 100자 이하여야 합니다") private String meetingDescription; - @NotNull(message = "음식 카테고리는 필수입니다") private FoodCategory foodCategory; - @NotBlank(message = "가게 이름은 필수입니다") + @Future + private LocalDateTime orderDeadline; + private String storeName; - @NotBlank(message = "픽업 위치는 필수입니다") private String pickupLocation; - @NotBlank(message = "계좌번호는 필수입니다") @Pattern(regexp = "^[0-9-]*$", message = "올바른 계좌번호 형식이 아닙니다") private String accountNumber; - @NotBlank(message = "예금주명은 필수입니다") - private String accountHolder; - private MultipartFile backgroundImage; } diff --git a/src/main/java/com/example/eatmate/app/domain/meeting/dto/UpdateOfflineMeetingRequestDto.java b/src/main/java/com/example/eatmate/app/domain/meeting/dto/UpdateOfflineMeetingRequestDto.java index 7ce7be63..bd43f096 100644 --- a/src/main/java/com/example/eatmate/app/domain/meeting/dto/UpdateOfflineMeetingRequestDto.java +++ b/src/main/java/com/example/eatmate/app/domain/meeting/dto/UpdateOfflineMeetingRequestDto.java @@ -7,8 +7,6 @@ import com.example.eatmate.app.domain.meeting.domain.OfflineMeetingCategory; import jakarta.validation.constraints.Future; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import lombok.AllArgsConstructor; import lombok.Getter; @@ -16,21 +14,17 @@ @Getter @AllArgsConstructor public class UpdateOfflineMeetingRequestDto { - @NotBlank(message = "모임 이름은 필수입니다") @Size(max = 30, message = "모임 이름은 30자 이하여야 합니다") private String meetingName; @Size(max = 100, message = "설명은 100자 이하여야 합니다") private String meetingDescription; - @NotBlank(message = "모임 장소는 필수입니다") private String meetingPlace; - @NotNull(message = "모임 시간은 필수입니다") @Future(message = "모임 시간은 현재 시간 이후여야 합니다") private LocalDateTime meetingDate; - @NotNull(message = "오프라인 모임 종류는 필수입니다") private OfflineMeetingCategory offlineMeetingCategory; private MultipartFile backgroundImage; diff --git a/src/main/java/com/example/eatmate/app/domain/meeting/scheduler/MeetingStatusScheduler.java b/src/main/java/com/example/eatmate/app/domain/meeting/scheduler/MeetingStatusScheduler.java new file mode 100644 index 00000000..e9d530c7 --- /dev/null +++ b/src/main/java/com/example/eatmate/app/domain/meeting/scheduler/MeetingStatusScheduler.java @@ -0,0 +1,63 @@ +package com.example.eatmate.app.domain.meeting.scheduler; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.scheduling.annotation.Async; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.example.eatmate.app.domain.meeting.domain.DeliveryMeeting; +import com.example.eatmate.app.domain.meeting.domain.MeetingStatus; +import com.example.eatmate.app.domain.meeting.domain.OfflineMeeting; +import com.example.eatmate.app.domain.meeting.domain.repository.DeliveryMeetingRepository; +import com.example.eatmate.app.domain.meeting.domain.repository.OfflineMeetingRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class MeetingStatusScheduler { + + private final DeliveryMeetingRepository deliveryMeetingRepository; + private final OfflineMeetingRepository offlineMeetingRepository; + + @Async + @Scheduled(fixedRate = 60000) + @Transactional + public void updateOfflineMeetingStatus() { + try { + List expiredOfflineMeetings = offlineMeetingRepository + .findByMeetingStatusAndMeetingDateBefore(MeetingStatus.ACTIVE, LocalDateTime.now()); + + for (OfflineMeeting meeting : expiredOfflineMeetings) { + meeting.deleteMeeting(); + log.info("OfflineMeeting status updated to INACTIVE: meetingId={}, thread={}", meeting.getId(), + Thread.currentThread().getName()); + } + } catch (Exception e) { + log.error("Failed to update offline meetings status: {}", e.getMessage()); + } + } + + @Async + @Scheduled(fixedRate = 60000) + @Transactional + public void updateDeliveryMeetingStatus() { + try { + List expiredDeliveryMeetings = deliveryMeetingRepository + .findByMeetingStatusAndOrderDeadlineBefore(MeetingStatus.ACTIVE, LocalDateTime.now()); + + for (DeliveryMeeting meeting : expiredDeliveryMeetings) { + meeting.deleteMeeting(); + log.info("Delivery status updated to INACTIVE: meetingId={}, thread={}", meeting.getId(), + Thread.currentThread().getName()); + } + } catch (Exception e) { + log.error("Failed to update delivery meetings status: {}", e.getMessage()); + } + } +} diff --git a/src/main/java/com/example/eatmate/app/domain/meeting/service/MeetingService.java b/src/main/java/com/example/eatmate/app/domain/meeting/service/MeetingService.java index 7401a7cf..6d353973 100644 --- a/src/main/java/com/example/eatmate/app/domain/meeting/service/MeetingService.java +++ b/src/main/java/com/example/eatmate/app/domain/meeting/service/MeetingService.java @@ -92,7 +92,7 @@ public CreateDeliveryMeetingResponseDto createDeliveryMeeting( validateGenderRestriction(createDeliveryMeetingRequestDto, member); - validateParticipantLimit( + validateParticipantLimitConfiguration( createDeliveryMeetingRequestDto.getMaxParticipants(), createDeliveryMeetingRequestDto.getIsLimited() ); @@ -115,7 +115,6 @@ public CreateDeliveryMeetingResponseDto createDeliveryMeeting( .pickupLocation(createDeliveryMeetingRequestDto.getPickupLocation()) .orderDeadline(LocalDateTime.now().plusMinutes(createDeliveryMeetingRequestDto.getOrderDeadline())) .accountNumber(createDeliveryMeetingRequestDto.getAccountNumber()) - .accountHolder(createDeliveryMeetingRequestDto.getAccountHolder()) .bankName(createDeliveryMeetingRequestDto.getBankName()) .backgroundImage(backgroundImage) .build(); @@ -139,7 +138,7 @@ public CreateOfflineMeetingResponseDto createOfflineMeeting( validateGenderRestriction(createOfflineMeetingRequestDto, member); - validateParticipantLimit( + validateParticipantLimitConfiguration( createOfflineMeetingRequestDto.getMaxParticipants(), createOfflineMeetingRequestDto.getIsLimited() ); @@ -205,7 +204,7 @@ private void joinMeeting(Long meetingId, UserDetails userDetails, boolean isDeli throw new CommonException(ErrorCode.MEETING_NOT_FOUND); } - validateParticipantLimit(meeting); + validateMeetingParticipantCapacity(meeting); validateGenderRestriction(meeting, member); validateDuplicateParticipant(meeting, member); @@ -334,8 +333,8 @@ private List getParticipants(Meeting me .collect(Collectors.toList()); } - // 참여 인원 제한 검증 로직 - private void validateParticipantLimit(Long participantLimit, Boolean isLimited) { + // 모임 생성시 참여 인원 제한 검증 로직 + private void validateParticipantLimitConfiguration(Long participantLimit, Boolean isLimited) { // isLimited가 false(무제한)인데 participantLimit가 null이 아닌 경우 if (!isLimited && participantLimit != null) { throw new CommonException(ErrorCode.INVALID_PARTICIPANT_LIMIT); @@ -350,9 +349,11 @@ private void validateParticipantLimit(Long participantLimit, Boolean isLimited) } } - // 참여 인원 제한 검증 - private void validateParticipantLimit(Meeting meeting) { - Long meetingCount = meetingParticipantRepository.countByMeeting_Id(meeting.getId()); + // 참여시 인원 제한 검증 + private void validateMeetingParticipantCapacity(Meeting meeting) { + List participants = meetingParticipantRepository.findParticipantsByMeetingIdWithLock( + meeting.getId()); + Long meetingCount = Long.valueOf(participants.size()); Long participantLimit = meeting.getParticipantLimit().getMaxParticipants(); Boolean isLimited = meeting.getParticipantLimit().isLimited(); @@ -533,7 +534,6 @@ public void updateDeliveryMeeting(Long meetingId, UpdateDeliveryMeetingRequestDt updateDeliveryMeetingRequestDto.getStoreName(), updateDeliveryMeetingRequestDto.getPickupLocation(), updateDeliveryMeetingRequestDto.getAccountNumber(), - updateDeliveryMeetingRequestDto.getAccountHolder(), backgroundImage ); } diff --git a/src/main/java/com/example/eatmate/global/auth/login/oauth/OAuthLoginSuccessHandler.java b/src/main/java/com/example/eatmate/global/auth/login/oauth/OAuthLoginSuccessHandler.java index d3dfca69..16b557d7 100644 --- a/src/main/java/com/example/eatmate/global/auth/login/oauth/OAuthLoginSuccessHandler.java +++ b/src/main/java/com/example/eatmate/global/auth/login/oauth/OAuthLoginSuccessHandler.java @@ -47,7 +47,7 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo } logTokens(accessToken, refreshToken); setTokensInCookie(response, accessToken, refreshToken); - response.sendRedirect("http://localhost:3000/oauth2/callback"); + response.sendRedirect("https://develop.d4u0qurydeei4.amplifyapp.com/intro/oauth2/callback"); } catch (Exception e) { log.error("OAuth2 로그인 처리 중 오류 발생: {} ", e.getMessage()); throw e; diff --git a/src/main/java/com/example/eatmate/global/config/AsyncConfig.java b/src/main/java/com/example/eatmate/global/config/AsyncConfig.java new file mode 100644 index 00000000..2d2fdd74 --- /dev/null +++ b/src/main/java/com/example/eatmate/global/config/AsyncConfig.java @@ -0,0 +1,22 @@ +package com.example.eatmate.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.task.TaskExecutor; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +@Configuration +public class AsyncConfig { + + @Bean + public TaskExecutor taskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(2); + executor.setMaxPoolSize(4); + executor.setQueueCapacity(100); + executor.setThreadNamePrefix("meeting-status-"); + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.initialize(); + return executor; + } +} diff --git a/src/main/java/com/example/eatmate/global/config/error/ErrorCode.java b/src/main/java/com/example/eatmate/global/config/error/ErrorCode.java index f8e6ca89..c8440eb0 100644 --- a/src/main/java/com/example/eatmate/global/config/error/ErrorCode.java +++ b/src/main/java/com/example/eatmate/global/config/error/ErrorCode.java @@ -13,6 +13,8 @@ public enum ErrorCode { JSON_PARSING_ERROR(400, "JSON_PARSING_ERROR", "JSON 데이터 처리 중 오류가 발생했습니다"), NO_RESOURCE_FOUND(404, "NO_RESOURCE_FOUND", "해당 리소스를 찾을 수 없습니다."), INVALID_EMAIL_DOMAIN(400, "INVALID_EMAIL_DOMAIN", "가천대학교 이메일이 아닙니다."), + INVALID_PARAMETER_TYPE(400, "INVALID_PARAMETER_TYPE", "적절하지 않은 파라미터 타입입니다."), + //회원 USER_NOT_FOUND(404, "USER_NOT_FOUND", "유저를 찾을 수 없습니다."), @@ -55,6 +57,8 @@ public enum ErrorCode { MEMBER_CHATROOM_NOT_FOUND(404, "MEMBER_CHATROOM_NOT_FOUND", "멤버채팅방을 찾을 수 없습니다."), CHAT_NOT_FOUND(404, "CHAT_NOT_FOUND", "채팅을 찾을 수 없습니다."), QUEUE_NOT_EXIST(404, "QUEUE_NOT_EXIST", "큐를 찾을 수 없습니다."), + CHAT_LOAD_FAIL(400, "CHAT_LOAD_FAIL", "채팅 로드를 실패했습니다."), + INVALID_CURSOR(400, "INVALID_CURSOR", "잘못된 커서 값입니다."), // 이미지 WRONG_IMAGE_FORMAT(400, "WRONG_IMAGE_FORMAT", "지원되지 않는 확장자 입니다. jpg, jpeg, png 파일만 업로드할 수 있습니다"), diff --git a/src/main/java/com/example/eatmate/global/config/error/exception/GithubIssueGenerator.java b/src/main/java/com/example/eatmate/global/config/error/exception/GithubIssueGenerator.java index fa76ef8a..363d33c6 100644 --- a/src/main/java/com/example/eatmate/global/config/error/exception/GithubIssueGenerator.java +++ b/src/main/java/com/example/eatmate/global/config/error/exception/GithubIssueGenerator.java @@ -4,12 +4,15 @@ import java.io.StringWriter; import java.nio.charset.StandardCharsets; import java.util.List; +import java.util.Map; import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Component; import org.springframework.web.client.RestTemplate; @@ -32,21 +35,46 @@ public class GithubIssueGenerator { @Value("${github.access-token}") private String accessToken; + // 기존 이슈 조회를 위한 메서드 추가 + private boolean isExistingIssue(String title) { + try { + ResponseEntity>> response = restTemplate.exchange( + REPO_URL, + HttpMethod.GET, + new HttpEntity<>(setAuthorization()), + new ParameterizedTypeReference>>() { + } + ); + + return response.getBody().stream() + .map(issue -> (String)issue.get("title")) + .anyMatch(issueTitle -> issueTitle.equals(title)); + } catch (Exception e) { + return false; + } + } + public void create(Exception exception) { - // api 요청 + String title = "[Fix] 서버 장애 발생 " + exception.getMessage(); + + // 동일한 제목의 이슈가 있는지 확인 + if (isExistingIssue(title)) { + return; + } + restTemplate.exchange( REPO_URL, HttpMethod.POST, - new HttpEntity<>(createRequestJson(exception), setAuthorization()), + new HttpEntity<>(createRequestJson(exception, getErrorLocation(exception)), setAuthorization()), String.class ); } // DTO 객체를 JSON 문자열로 변환 - private String createRequestJson(Exception exception) { + private String createRequestJson(Exception exception, String errorLocation) { return parseJsonString(new IssueCreateRequestDto( "[Fix] 서버 장애 발생 " + exception.getMessage(), - createIssueBody(exception), + createIssueBody(exception, errorLocation), ASSIGNEES, LABELS )); @@ -61,10 +89,27 @@ private String parseJsonString(IssueCreateRequestDto request) { } // 발생한 예외의 stackTrace를 마크다운 코드 블럭으로 감싸서 작성 - private String createIssueBody(Exception exception) { + private String createIssueBody(Exception exception, String errorLocation) { StringWriter stringWriter = new StringWriter(); exception.printStackTrace(new PrintWriter(stringWriter)); - return "```\n" + stringWriter + "\n```"; + return String.format("## Error Location\n%s\n\n## Stack Trace\n```\n%s\n```", + errorLocation, + stringWriter); + } + + // 에러 발생 위치를 찾는 메서드 추가 + private String getErrorLocation(Exception exception) { + StackTraceElement[] stackTrace = exception.getStackTrace(); + for (StackTraceElement element : stackTrace) { + // 프로젝트 패키지에 해당하는 첫 번째 스택트레이스를 찾음 + if (element.getClassName().startsWith("com.example")) { // 프로젝트 패키지명에 맞게 수정 + return String.format("%s.%s(line: %d)", + element.getClassName(), + element.getMethodName(), + element.getLineNumber()); + } + } + return "Unknown location"; } // Authorization 헤더에 accessToken 입력 diff --git a/src/main/java/com/example/eatmate/global/config/error/exception/GlobalExceptionHandler.java b/src/main/java/com/example/eatmate/global/config/error/exception/GlobalExceptionHandler.java index a4710fa2..2b8ae2d7 100644 --- a/src/main/java/com/example/eatmate/global/config/error/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/example/eatmate/global/config/error/exception/GlobalExceptionHandler.java @@ -3,15 +3,20 @@ import java.util.Arrays; import java.util.stream.Collectors; +import org.springframework.core.convert.ConversionFailedException; import org.springframework.core.env.Environment; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.transaction.TransactionSystemException; import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.servlet.View; import org.springframework.web.servlet.mvc.support.RedirectAttributes; + +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; + import org.springframework.web.servlet.resource.NoResourceFoundException; import com.example.eatmate.global.config.error.ErrorCode; @@ -29,7 +34,6 @@ public class GlobalExceptionHandler { private static final String ISSUE_CREATE_ENV = "dev"; - private final View error; private final GithubIssueGenerator githubIssueGenerator; private final Environment environment; @@ -126,6 +130,49 @@ public ResponseEntity> handleTransactionSystemExceptio .body(GlobalResponseDto.fail(errorCode, errorCode.getMessage())); } + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ResponseEntity> handleMethodArgumentTypeMismatchException( + MethodArgumentTypeMismatchException ex) { + ErrorCode errorCode = ErrorCode.INVALID_PARAMETER_TYPE; + + String paramName = ex.getName(); + String errorMessage = String.format("파라미터 '%s' 가 적절하지 않은 값을 가지고 있습니다.: %s", + paramName, + ex.getValue()); + + return ResponseEntity.status(HttpStatus.valueOf(errorCode.getStatus())) + .body(GlobalResponseDto.fail(errorCode, errorMessage)); + } + + @ExceptionHandler(ConversionFailedException.class) + public ResponseEntity> handleConversionFailedException( + ConversionFailedException ex) { + ErrorCode errorCode = ErrorCode.INVALID_PARAMETER_TYPE; + + String targetType = ex.getTargetType().getType().getSimpleName(); + String value = String.valueOf(ex.getValue()); + String errorMessage = String.format("ENUM '%s'에 '%s' 값이 존재하지 않습니다.", + targetType, + value); + + return ResponseEntity.status(HttpStatus.valueOf(errorCode.getStatus())) + .body(GlobalResponseDto.fail(errorCode, errorMessage)); + } + + @ExceptionHandler(MissingServletRequestParameterException.class) + public ResponseEntity> handleMissingServletRequestParameterException( + MissingServletRequestParameterException ex) { + ErrorCode errorCode = ErrorCode.INVALID_PARAMETER_TYPE; + + String parameterName = ex.getParameterName(); + String errorMessage = String.format("필수 파라미터 '%s'가 누락되었습니다.", + parameterName + ); + + return ResponseEntity.status(HttpStatus.valueOf(errorCode.getStatus())) + .body(GlobalResponseDto.fail(errorCode, errorMessage)); + } + public void handleUnexpectedError(Exception ex) { if (isProd(environment.getActiveProfiles())) { githubIssueGenerator.create(ex); diff --git a/src/test/java/com/example/eatmate/app/domain/meeting/scheduler/MeetingStatusSchedulerTest.java b/src/test/java/com/example/eatmate/app/domain/meeting/scheduler/MeetingStatusSchedulerTest.java new file mode 100644 index 00000000..3161b53b --- /dev/null +++ b/src/test/java/com/example/eatmate/app/domain/meeting/scheduler/MeetingStatusSchedulerTest.java @@ -0,0 +1,180 @@ +package com.example.eatmate.app.domain.meeting.scheduler; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; + +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.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.example.eatmate.app.domain.meeting.domain.DeliveryMeeting; +import com.example.eatmate.app.domain.meeting.domain.MeetingStatus; +import com.example.eatmate.app.domain.meeting.domain.OfflineMeeting; +import com.example.eatmate.app.domain.meeting.domain.repository.DeliveryMeetingRepository; +import com.example.eatmate.app.domain.meeting.domain.repository.OfflineMeetingRepository; + +@ExtendWith(MockitoExtension.class) +class MeetingStatusSchedulerTest { + + @Mock + private DeliveryMeetingRepository deliveryMeetingRepository; + + @Mock + private OfflineMeetingRepository offlineMeetingRepository; + + @InjectMocks + private MeetingStatusScheduler scheduler; + + private LocalDateTime now; + + private DeliveryMeeting expiredDeliveryMeeting; + private DeliveryMeeting activeDeliveryMeeting; + private OfflineMeeting expiredOfflineMeeting; + private OfflineMeeting activeOfflineMeeting; + + @BeforeEach + void setUp() { + now = LocalDateTime.now(); + expiredDeliveryMeeting = DeliveryMeeting.builder() + .meetingStatus(MeetingStatus.ACTIVE) + .orderDeadline(now.minusHours(1)) + .build(); + + activeDeliveryMeeting = DeliveryMeeting.builder() + .meetingStatus(MeetingStatus.ACTIVE) + .orderDeadline(now.plusHours(1)) + .build(); + + expiredOfflineMeeting = OfflineMeeting.builder() + .meetingStatus(MeetingStatus.ACTIVE) + .meetingDate(now.minusHours(1)) + .build(); + + activeOfflineMeeting = OfflineMeeting.builder() + .meetingStatus(MeetingStatus.ACTIVE) + .meetingDate(now.plusHours(1)) + .build(); + + } + + @Test + @DisplayName("배달 모임이 만료되면 상태가 INACTIVE로 변경되어야 한다") + void shouldUpdateExpiredDeliveryMeetingStatus() { + // given + when(deliveryMeetingRepository.findByMeetingStatusAndOrderDeadlineBefore( + eq(MeetingStatus.ACTIVE), any(LocalDateTime.class))) + .thenReturn(List.of(expiredDeliveryMeeting)); + + // when + scheduler.updateDeliveryMeetingStatus(); + + // then + assertEquals(MeetingStatus.INACTIVE, expiredDeliveryMeeting.getMeetingStatus()); + verify(deliveryMeetingRepository, times(1)) + .findByMeetingStatusAndOrderDeadlineBefore(eq(MeetingStatus.ACTIVE), any(LocalDateTime.class)); + } + + @Test + @DisplayName("오프라인 모임이 만료되면 상태가 INACTIVE로 변경되어야 한다") + void shouldUpdateExpiredOfflineMeetingStatus() { + // given + when(offlineMeetingRepository.findByMeetingStatusAndMeetingDateBefore( + eq(MeetingStatus.ACTIVE), any(LocalDateTime.class))) + .thenReturn(List.of(expiredOfflineMeeting)); + + // when + scheduler.updateOfflineMeetingStatus(); + + // then + assertEquals(MeetingStatus.INACTIVE, expiredOfflineMeeting.getMeetingStatus()); + verify(offlineMeetingRepository, times(1)) + .findByMeetingStatusAndMeetingDateBefore(eq(MeetingStatus.ACTIVE), any(LocalDateTime.class)); + } + + @Test + @DisplayName("활성 상태의 모임은 상태가 변경되지 않아야 한다") + void shouldNotUpdateActiveMeetingStatus() { + // given + when(deliveryMeetingRepository.findByMeetingStatusAndOrderDeadlineBefore( + eq(MeetingStatus.ACTIVE), any(LocalDateTime.class))) + .thenReturn(Collections.emptyList()); + when(offlineMeetingRepository.findByMeetingStatusAndMeetingDateBefore( + eq(MeetingStatus.ACTIVE), any(LocalDateTime.class))) + .thenReturn(Collections.emptyList()); + + // when + scheduler.updateOfflineMeetingStatus(); + scheduler.updateDeliveryMeetingStatus(); + + // then + assertEquals(MeetingStatus.ACTIVE, activeDeliveryMeeting.getMeetingStatus()); + assertEquals(MeetingStatus.ACTIVE, activeOfflineMeeting.getMeetingStatus()); + verify(deliveryMeetingRepository, times(1)) + .findByMeetingStatusAndOrderDeadlineBefore(eq(MeetingStatus.ACTIVE), any(LocalDateTime.class)); + verify(offlineMeetingRepository, times(1)) + .findByMeetingStatusAndMeetingDateBefore(eq(MeetingStatus.ACTIVE), any(LocalDateTime.class)); + } + + @Test + @DisplayName("오프라인 미팅 상태 업데이트 중 예외가 발생해도 배달 미팅 처리는 정상 동작해야 한다") + void shouldHandleOfflineMeetingExceptionsIndependently() { + // given + // 오프라인 미팅 조회 시 예외 발생 설정 + when(offlineMeetingRepository.findByMeetingStatusAndMeetingDateBefore(any(), any())) + .thenThrow(new RuntimeException("Offline meeting database error")); + + // 배달 미팅은 정상 동작 설정 + when(deliveryMeetingRepository.findByMeetingStatusAndOrderDeadlineBefore(any(), any())) + .thenReturn(List.of(expiredDeliveryMeeting)); + + // when + scheduler.updateOfflineMeetingStatus(); + scheduler.updateDeliveryMeetingStatus(); + + // then + // 오프라인 미팅 조회 시도 확인 + verify(offlineMeetingRepository, times(1)) + .findByMeetingStatusAndMeetingDateBefore(any(), any()); + + // 배달 미팅은 정상적으로 처리되었는지 확인 + verify(deliveryMeetingRepository, times(1)) + .findByMeetingStatusAndOrderDeadlineBefore(any(), any()); + assertEquals(MeetingStatus.INACTIVE, expiredDeliveryMeeting.getMeetingStatus()); + } + + @Test + @DisplayName("배달 미팅 상태 업데이트 중 예외가 발생해도 오프라인 미팅 처리는 정상 동작해야 한다") + void shouldHandleDeliveryMeetingExceptionsIndependently() { + // given + // 배달 미팅 조회 시 예외 발생 설정 + when(deliveryMeetingRepository.findByMeetingStatusAndOrderDeadlineBefore(any(), any())) + .thenThrow(new RuntimeException("Delivery meeting database error")); + + // 오프라인 미팅은 정상 동작 설정 + when(offlineMeetingRepository.findByMeetingStatusAndMeetingDateBefore(any(), any())) + .thenReturn(List.of(expiredOfflineMeeting)); + + // when + scheduler.updateOfflineMeetingStatus(); + scheduler.updateDeliveryMeetingStatus(); + + // then + // 배달 미팅 조회 시도 확인 + verify(deliveryMeetingRepository, times(1)) + .findByMeetingStatusAndOrderDeadlineBefore(any(), any()); + + // 오프라인 미팅은 정상적으로 처리되었는지 확인 + verify(offlineMeetingRepository, times(1)) + .findByMeetingStatusAndMeetingDateBefore(any(), any()); + assertEquals(MeetingStatus.INACTIVE, expiredOfflineMeeting.getMeetingStatus()); + } +} diff --git a/src/test/java/com/example/eatmate/app/domain/meeting/service/MeetingServiceTest.java b/src/test/java/com/example/eatmate/app/domain/meeting/service/MeetingServiceTest.java index 40a47721..b8e0d931 100644 --- a/src/test/java/com/example/eatmate/app/domain/meeting/service/MeetingServiceTest.java +++ b/src/test/java/com/example/eatmate/app/domain/meeting/service/MeetingServiceTest.java @@ -1,5 +1,6 @@ package com.example.eatmate.app.domain.meeting.service; +import static com.example.eatmate.app.domain.meeting.domain.BankName.*; import static org.junit.jupiter.api.Assertions.*; import java.time.LocalDateTime; @@ -8,6 +9,7 @@ import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -16,31 +18,34 @@ import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetails; +import com.example.eatmate.app.domain.chatRoom.domain.ChatRoom; +import com.example.eatmate.app.domain.chatRoom.domain.repository.ChatRoomRepository; +import com.example.eatmate.app.domain.chatRoom.domain.repository.MemberChatRoomRepository; import com.example.eatmate.app.domain.meeting.domain.DeliveryMeeting; import com.example.eatmate.app.domain.meeting.domain.FoodCategory; import com.example.eatmate.app.domain.meeting.domain.GenderRestriction; import com.example.eatmate.app.domain.meeting.domain.MeetingParticipant; +import com.example.eatmate.app.domain.meeting.domain.MeetingStatus; import com.example.eatmate.app.domain.meeting.domain.ParticipantLimit; import com.example.eatmate.app.domain.meeting.domain.ParticipantRole; import com.example.eatmate.app.domain.meeting.domain.repository.DeliveryMeetingRepository; import com.example.eatmate.app.domain.meeting.domain.repository.MeetingParticipantRepository; +import com.example.eatmate.app.domain.meeting.domain.repository.MeetingRepository; import com.example.eatmate.app.domain.member.domain.Gender; import com.example.eatmate.app.domain.member.domain.Member; import com.example.eatmate.app.domain.member.domain.repository.MemberRepository; import com.example.eatmate.global.config.error.ErrorCode; import com.example.eatmate.global.config.error.exception.CommonException; -import jakarta.persistence.EntityManager; +import lombok.extern.slf4j.Slf4j; @SpringBootTest +@Slf4j class MeetingServiceTest { @Autowired private MeetingService meetingService; - @Autowired - private EntityManager entityManager; - @Autowired private DeliveryMeetingRepository deliveryMeetingRepository; @@ -50,10 +55,19 @@ class MeetingServiceTest { @Autowired private MemberRepository memberRepository; + @Autowired + private ChatRoomRepository chatRoomRepository; + private DeliveryMeeting testMeeting; private Member hostMember; // 호스트 멤버 private Member member2; // 참여 시도할 첫 번째 멤버 - private Member member3; // 참여 시도할 두 번째 멤버 + private Member member3; // 참여 시도할 두 번째 멤버 + private UserDetails member2Details; // 첫 번째 멤버의 UserDetails + private UserDetails member3Details; // 두 번째 멤버의 UserDetails + @Autowired + private MemberChatRoomRepository memberChatRoomRepository; + @Autowired + private MeetingRepository meetingRepository; @BeforeEach void setUp() { @@ -68,10 +82,11 @@ void setUp() { .build()) .foodCategory(FoodCategory.CHICKEN) .storeName("Test Store") + .meetingStatus(MeetingStatus.ACTIVE) + .bankName(국민은행) .pickupLocation("Test Location") .orderDeadline(LocalDateTime.now().plusHours(1)) .accountNumber("1234-5678") - .accountHolder("Test Holder") .build(); // 호스트 멤버 생성 @@ -95,32 +110,52 @@ void setUp() { .gender(Gender.MALE) .build(); + member2Details = User.withUsername(member2.getEmail()) + .password("password") + .roles("USER") + .build(); + + member3Details = User.withUsername(member3.getEmail()) + .password("password") + .roles("USER") + .build(); + // 데이터 저장 - memberRepository.save(hostMember); - memberRepository.save(member2); - memberRepository.save(member3); + memberRepository.saveAndFlush(hostMember); + memberRepository.saveAndFlush(member2); + memberRepository.saveAndFlush(member3); + deliveryMeetingRepository.saveAndFlush(testMeeting); + + // 채팅방 생성 + ChatRoom chatRoom = ChatRoom.createChatRoom(hostMember.getMemberId(), testMeeting); + chatRoomRepository.save(chatRoom); + + // Meeting에 채팅방 연결 + testMeeting.setChatRoom(chatRoom); deliveryMeetingRepository.save(testMeeting); // 호스트를 참가자로 등록 MeetingParticipant hostParticipant = MeetingParticipant.createMeetingParticipant( hostMember, testMeeting, ParticipantRole.HOST); meetingParticipantRepository.save(hostParticipant); + + } + + @AfterEach + void tearDown() { + // 테스트 종료 후 데이터 초기화 + meetingParticipantRepository.deleteAll(); + deliveryMeetingRepository.deleteAll(); + meetingRepository.deleteAll(); + memberChatRoomRepository.deleteAll(); + chatRoomRepository.deleteAll(); + memberRepository.deleteAll(); } @Test @DisplayName("동시에 마지막 자리 참여 시도 테스트") void concurrentJoinTest() throws InterruptedException { - UserDetails member2Details = User.withUsername(member2.getEmail()) - .password("") - .roles("USER") - .build(); - - UserDetails member3Details = User.withUsername(member3.getEmail()) - .password("") - .roles("USER") - .build(); - // 동시에 실행할 스레드 개수 설정 (2개: member2, member3의 요청) int numberOfThreads = 2; @@ -143,9 +178,10 @@ void concurrentJoinTest() throws InterruptedException { meetingService.joinDeliveryMeeting(testMeeting.getId(), member2Details); // 참여 성공시 successCount 증가 successCount.incrementAndGet(); - } catch (CommonException e) { + } catch (Exception e) { // 인원 초과로 실패시 failCount 증가 - if (e.getErrorCode() == ErrorCode.PARTICIPANT_LIMIT_EXCEEDED) { + if (e instanceof CommonException && + ((CommonException)e).getErrorCode() == ErrorCode.PARTICIPANT_LIMIT_EXCEEDED) { failCount.incrementAndGet(); } } finally { @@ -174,12 +210,15 @@ void concurrentJoinTest() throws InterruptedException { // 더 이상 필요없는 스레드 풀 종료 executorService.shutdown(); - // 검증 + // 성공/실패 카운트 로깅 + log.info("성공 참가 횟수 - 기대값: {}, 실제값: {}", 1, successCount.get()); + log.info("실패 참가 횟수 - 기대값: {}, 실제값: {}", 1, failCount.get()); assertEquals(1, successCount.get(), "성공적인 참가는 1회여야 합니다"); assertEquals(1, failCount.get(), "참가 실패는 1회여야 합니다"); - // 최종 참가자 수 확인 + // 최종 참가자 수 로깅 Long finalParticipantCount = meetingParticipantRepository.countByMeeting_Id(testMeeting.getId()); + log.info("최종 참가자 수 - 기대값: {}, 실제값: {}", 2, finalParticipantCount); assertEquals(2, finalParticipantCount, "최종 참가자 수는 2명이어야 합니다 (호스트 1명 + 성공한 참가자 1명)"); } }