From 7493095ba3f2ce66382e14cc4b0dd22e0d7820d9 Mon Sep 17 00:00:00 2001 From: 1000hyehyang Date: Sun, 18 Jan 2026 00:59:20 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(#4):=20=EC=B1=84=ED=8C=85=20REST=20API?= =?UTF-8?q?=20=EA=B3=84=EC=95=BD(Controller/DTO/Swagger/=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8)=20=EC=B6=94=EA=B0=80=20(#13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 요약 채팅 REST API 계약을 확정하기 위해 Controller/DTO/Swagger/Test를 Top-Down 방식으로 구성했습니다. 샘플 응답은 `ChatFixtureFactory`로 분리했습니다. ## 변경 사항 - 채팅 REST 컨트롤러 구성 및 응답 샘플 연동 (`ChatController`, `ChatFixtureFactory`) - 요청/응답 DTO 및 시스템 메시지 payload 정의 - 채팅 관련 enum 정리 및 패키지 분리 - 커서 타입(`RoomCursor`, `MessageCursor`)과 변환 컨버터 설정 추가 - Swagger 명세 최소화 + 요약/설명 보강 image ## 테스트 - `ChatControllerTest` 작성 (MockMvc 기반 계약 테스트) ## 참고 - Swagger 테스트를 위해 채팅 url은 다 open 해놨습니다. --------- Co-authored-by: Yoonchul Chung <84674889+Yoonchulchung@users.noreply.github.com> --- .../config/ChatCursorConverterConfig.java | 25 ++ .../controller/ChatController.java | 83 ++++++ .../fixture/ChatFixtureFactory.java | 269 ++++++++++++++++++ .../conversion/MessageCursor.java | 28 ++ .../presentation/conversion/RoomCursor.java | 44 +++ .../dto/enums/ChatAttachmentStatus.java | 7 + .../dto/enums/ChatAttachmentType.java | 6 + .../dto/enums/ChatMessageType.java | 8 + .../dto/enums/ChatProposalDecisionStatus.java | 8 + .../dto/enums/ChatProposalDirection.java | 7 + .../dto/enums/ChatProposalStatus.java | 22 ++ .../dto/enums/ChatRoomFilterStatus.java | 8 + .../presentation/dto/enums/ChatRoomSort.java | 5 + .../presentation/dto/enums/ChatRoomTab.java | 7 + .../dto/enums/ChatSenderType.java | 6 + .../dto/enums/ChatSystemMessageKind.java | 7 + .../request/ChatAttachmentUploadRequest.java | 10 + .../dto/request/ChatRoomCreateRequest.java | 9 + .../response/ChatAttachmentInfoResponse.java | 15 + .../ChatAttachmentUploadResponse.java | 18 ++ .../ChatMatchedCampaignPayloadResponse.java | 11 + .../dto/response/ChatMessageListResponse.java | 12 + .../dto/response/ChatMessageResponse.java | 20 ++ .../ChatProposalActionButtonResponse.java | 7 + .../ChatProposalActionButtonsResponse.java | 7 + .../ChatProposalCardPayloadResponse.java | 18 ++ ...atProposalStatusNoticePayloadResponse.java | 10 + .../dto/response/ChatRoomCardResponse.java | 21 ++ .../dto/response/ChatRoomCreateResponse.java | 13 + .../dto/response/ChatRoomDetailResponse.java | 16 ++ .../dto/response/ChatRoomListResponse.java | 15 + .../response/ChatSystemMessagePayload.java | 4 + .../response/ChatSystemMessageResponse.java | 10 + .../presentation/swagger/ChatSwagger.java | 116 ++++++++ .../global/config/SecurityConfig.java | 1 + .../controller/ChatControllerTest.java | 180 ++++++++++++ 36 files changed, 1053 insertions(+) create mode 100644 src/main/java/com/example/RealMatch/chat/presentation/config/ChatCursorConverterConfig.java create mode 100644 src/main/java/com/example/RealMatch/chat/presentation/controller/ChatController.java create mode 100644 src/main/java/com/example/RealMatch/chat/presentation/controller/fixture/ChatFixtureFactory.java create mode 100644 src/main/java/com/example/RealMatch/chat/presentation/conversion/MessageCursor.java create mode 100644 src/main/java/com/example/RealMatch/chat/presentation/conversion/RoomCursor.java create mode 100644 src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatAttachmentStatus.java create mode 100644 src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatAttachmentType.java create mode 100644 src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatMessageType.java create mode 100644 src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatProposalDecisionStatus.java create mode 100644 src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatProposalDirection.java create mode 100644 src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatProposalStatus.java create mode 100644 src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatRoomFilterStatus.java create mode 100644 src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatRoomSort.java create mode 100644 src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatRoomTab.java create mode 100644 src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatSenderType.java create mode 100644 src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatSystemMessageKind.java create mode 100644 src/main/java/com/example/RealMatch/chat/presentation/dto/request/ChatAttachmentUploadRequest.java create mode 100644 src/main/java/com/example/RealMatch/chat/presentation/dto/request/ChatRoomCreateRequest.java create mode 100644 src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatAttachmentInfoResponse.java create mode 100644 src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatAttachmentUploadResponse.java create mode 100644 src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatMatchedCampaignPayloadResponse.java create mode 100644 src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatMessageListResponse.java create mode 100644 src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatMessageResponse.java create mode 100644 src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatProposalActionButtonResponse.java create mode 100644 src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatProposalActionButtonsResponse.java create mode 100644 src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatProposalCardPayloadResponse.java create mode 100644 src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatProposalStatusNoticePayloadResponse.java create mode 100644 src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatRoomCardResponse.java create mode 100644 src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatRoomCreateResponse.java create mode 100644 src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatRoomDetailResponse.java create mode 100644 src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatRoomListResponse.java create mode 100644 src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatSystemMessagePayload.java create mode 100644 src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatSystemMessageResponse.java create mode 100644 src/main/java/com/example/RealMatch/chat/presentation/swagger/ChatSwagger.java create mode 100644 src/test/java/com/example/RealMatch/chat/presentation/controller/ChatControllerTest.java diff --git a/src/main/java/com/example/RealMatch/chat/presentation/config/ChatCursorConverterConfig.java b/src/main/java/com/example/RealMatch/chat/presentation/config/ChatCursorConverterConfig.java new file mode 100644 index 00000000..e5be5b8b --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/config/ChatCursorConverterConfig.java @@ -0,0 +1,25 @@ +package com.example.RealMatch.chat.presentation.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.convert.converter.Converter; + +import com.example.RealMatch.chat.presentation.conversion.MessageCursor; +import com.example.RealMatch.chat.presentation.conversion.RoomCursor; + +/** + * cursor 요청 파라미터를 RoomCursor/MessageCursor로 바인딩하는 설정 + */ +@Configuration +public class ChatCursorConverterConfig { + + @Bean + public Converter roomCursorConverter() { + return RoomCursor::decode; + } + + @Bean + public Converter messageCursorConverter() { + return MessageCursor::decode; + } +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/controller/ChatController.java b/src/main/java/com/example/RealMatch/chat/presentation/controller/ChatController.java new file mode 100644 index 00000000..abe47b3f --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/controller/ChatController.java @@ -0,0 +1,83 @@ +package com.example.RealMatch.chat.presentation.controller; + +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +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.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import com.example.RealMatch.chat.presentation.controller.fixture.ChatFixtureFactory; +import com.example.RealMatch.chat.presentation.conversion.MessageCursor; +import com.example.RealMatch.chat.presentation.conversion.RoomCursor; +import com.example.RealMatch.chat.presentation.dto.enums.ChatRoomFilterStatus; +import com.example.RealMatch.chat.presentation.dto.enums.ChatRoomSort; +import com.example.RealMatch.chat.presentation.dto.enums.ChatRoomTab; +import com.example.RealMatch.chat.presentation.dto.request.ChatAttachmentUploadRequest; +import com.example.RealMatch.chat.presentation.dto.request.ChatRoomCreateRequest; +import com.example.RealMatch.chat.presentation.dto.response.ChatAttachmentUploadResponse; +import com.example.RealMatch.chat.presentation.dto.response.ChatMessageListResponse; +import com.example.RealMatch.chat.presentation.dto.response.ChatRoomCreateResponse; +import com.example.RealMatch.chat.presentation.dto.response.ChatRoomDetailResponse; +import com.example.RealMatch.chat.presentation.dto.response.ChatRoomListResponse; +import com.example.RealMatch.chat.presentation.swagger.ChatSwagger; +import com.example.RealMatch.global.config.jwt.CustomUserDetails; +import com.example.RealMatch.global.presentation.CustomResponse; + +import jakarta.validation.Valid; + +@RestController +@RequestMapping("/api/chat") +public class ChatController implements ChatSwagger { + + @PostMapping("/rooms") + public CustomResponse createOrGetRoom( + @AuthenticationPrincipal CustomUserDetails user, + @Valid @RequestBody ChatRoomCreateRequest request + ) { + return CustomResponse.ok(ChatFixtureFactory.sampleRoomCreateResponse()); + } + + @GetMapping("/rooms") + public CustomResponse getRoomList( + @AuthenticationPrincipal CustomUserDetails user, + @RequestParam(required = false) ChatRoomTab tab, + @RequestParam(name = "status", required = false) ChatRoomFilterStatus filterStatus, + @RequestParam(required = false) ChatRoomSort sort, + @RequestParam(name = "cursor", required = false) RoomCursor roomCursor, + @RequestParam(defaultValue = "20") int size + ) { + return CustomResponse.ok(ChatFixtureFactory.sampleRoomListResponse()); + } + + @GetMapping("/rooms/{roomId}") + public CustomResponse getRoomDetail( + @AuthenticationPrincipal CustomUserDetails user, + @PathVariable Long roomId + ) { + return CustomResponse.ok(ChatFixtureFactory.sampleRoomDetailResponse(roomId)); + } + + @GetMapping("/rooms/{roomId}/messages") + public CustomResponse getMessages( + @AuthenticationPrincipal CustomUserDetails user, + @PathVariable Long roomId, + @RequestParam(name = "cursor", required = false) MessageCursor messageCursor, + @RequestParam(defaultValue = "20") int size + ) { + return CustomResponse.ok(ChatFixtureFactory.sampleMessageListResponse(roomId)); + } + + @PostMapping("/attachments") + public CustomResponse uploadAttachment( + @AuthenticationPrincipal CustomUserDetails user, + @Valid @RequestPart("request") ChatAttachmentUploadRequest request, + @RequestPart("file") MultipartFile file + ) { + return CustomResponse.ok(ChatFixtureFactory.sampleAttachmentUploadResponse(request, file)); + } +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/controller/fixture/ChatFixtureFactory.java b/src/main/java/com/example/RealMatch/chat/presentation/controller/fixture/ChatFixtureFactory.java new file mode 100644 index 00000000..2b09281c --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/controller/fixture/ChatFixtureFactory.java @@ -0,0 +1,269 @@ +package com.example.RealMatch.chat.presentation.controller.fixture; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.web.multipart.MultipartFile; + +import com.example.RealMatch.chat.presentation.conversion.MessageCursor; +import com.example.RealMatch.chat.presentation.conversion.RoomCursor; +import com.example.RealMatch.chat.presentation.dto.enums.ChatAttachmentStatus; +import com.example.RealMatch.chat.presentation.dto.enums.ChatAttachmentType; +import com.example.RealMatch.chat.presentation.dto.enums.ChatMessageType; +import com.example.RealMatch.chat.presentation.dto.enums.ChatProposalDecisionStatus; +import com.example.RealMatch.chat.presentation.dto.enums.ChatProposalDirection; +import com.example.RealMatch.chat.presentation.dto.enums.ChatProposalStatus; +import com.example.RealMatch.chat.presentation.dto.enums.ChatRoomTab; +import com.example.RealMatch.chat.presentation.dto.enums.ChatSenderType; +import com.example.RealMatch.chat.presentation.dto.enums.ChatSystemMessageKind; +import com.example.RealMatch.chat.presentation.dto.request.ChatAttachmentUploadRequest; +import com.example.RealMatch.chat.presentation.dto.response.ChatAttachmentInfoResponse; +import com.example.RealMatch.chat.presentation.dto.response.ChatAttachmentUploadResponse; +import com.example.RealMatch.chat.presentation.dto.response.ChatMatchedCampaignPayloadResponse; +import com.example.RealMatch.chat.presentation.dto.response.ChatMessageListResponse; +import com.example.RealMatch.chat.presentation.dto.response.ChatMessageResponse; +import com.example.RealMatch.chat.presentation.dto.response.ChatProposalActionButtonResponse; +import com.example.RealMatch.chat.presentation.dto.response.ChatProposalActionButtonsResponse; +import com.example.RealMatch.chat.presentation.dto.response.ChatProposalCardPayloadResponse; +import com.example.RealMatch.chat.presentation.dto.response.ChatProposalStatusNoticePayloadResponse; +import com.example.RealMatch.chat.presentation.dto.response.ChatRoomCardResponse; +import com.example.RealMatch.chat.presentation.dto.response.ChatRoomCreateResponse; +import com.example.RealMatch.chat.presentation.dto.response.ChatRoomDetailResponse; +import com.example.RealMatch.chat.presentation.dto.response.ChatRoomListResponse; +import com.example.RealMatch.chat.presentation.dto.response.ChatSystemMessageResponse; + +/** + * 채팅 API 테스트를 위한 Fixture 응답 DTO를 생성하는 팩토리 (서비스 로직 완성하면 삭제할 것임) + */ +public final class ChatFixtureFactory { + + private ChatFixtureFactory() { + } + + public static ChatRoomCreateResponse sampleRoomCreateResponse() { + return new ChatRoomCreateResponse( + 3001L, + "direct:101:202", + ChatProposalDirection.NONE, + LocalDateTime.of(2025, 1, 1, 0, 0) + ); + } + + public static ChatRoomListResponse sampleRoomListResponse() { + ChatRoomCardResponse card = new ChatRoomCardResponse( + 3001L, + 202L, + "라운드랩", + "https://yt3.googleusercontent.com/ytc/AIdro_lLlKeDBBNPBO1FW7jkxvXpJyyM6CU2AR7NMx2GIjFFxQ=s900-c-k-c0x00ffffff-no-rj", + ChatProposalStatus.REVIEWING, + "안녕하세요!", + ChatMessageType.TEXT, + LocalDateTime.of(2025, 1, 1, 10, 0), + 3, + ChatRoomTab.SENT + ); + return new ChatRoomListResponse( + 5L, + 2L, + 7L, + List.of(card), + RoomCursor.of(LocalDateTime.of(2025, 1, 1, 9, 59, 59), 2999L), + true + ); + } + + public static ChatRoomDetailResponse sampleRoomDetailResponse(Long roomId) { + return new ChatRoomDetailResponse( + roomId, + 202L, + "라운드랩", + "https://yt3.googleusercontent.com/ytc/AIdro_lLlKeDBBNPBO1FW7jkxvXpJyyM6CU2AR7NMx2GIjFFxQ=s900-c-k-c0x00ffffff-no-rj", + List.of("청정자극", "저자극", "심플한 감성"), + ChatProposalStatus.REVIEWING, + ChatProposalStatus.REVIEWING.labelOrNull() + ); + } + + public static ChatMessageListResponse sampleMessageListResponse(Long roomId) { + return new ChatMessageListResponse( + List.of( + sampleMatchedCampaignMessage(roomId), + sampleProposalStatusNoticeMessage(roomId), + sampleSystemMessage(roomId), + sampleFileMessage(roomId), + sampleImageMessage(roomId), + sampleTextMessage(roomId) + ), + MessageCursor.of(6999L), + true + ); + } + + private static ChatMessageResponse sampleTextMessage(Long roomId) { + return new ChatMessageResponse( + 7001L, + roomId, + 202L, + ChatSenderType.USER, + ChatMessageType.TEXT, + "안녕하세요!", + null, + null, + LocalDateTime.of(2025, 1, 1, 10, 2), + "11111111-1111-1111-1111-111111111111" + ); + } + + private static ChatMessageResponse sampleImageMessage(Long roomId) { + ChatAttachmentInfoResponse attachment = new ChatAttachmentInfoResponse( + 9001L, + ChatAttachmentType.IMAGE, + "image/png", + "photo.png", + 204800L, + "https://img.cjnews.cj.net/wp-content/uploads/2020/11/CJ%EC%98%AC%EB%A6%AC%EB%B8%8C%EC%98%81-%EC%98%AC%EB%A6%AC%EB%B8%8C%EC%98%81-%EC%83%88-BI-%EB%A1%9C%EA%B3%A0.jpg", + ChatAttachmentStatus.READY + ); + return new ChatMessageResponse( + 7002L, + roomId, + 202L, + ChatSenderType.USER, + ChatMessageType.IMAGE, + null, + attachment, + null, + LocalDateTime.of(2025, 1, 1, 10, 3), + "22222222-2222-2222-2222-222222222222" + ); + } + + private static ChatMessageResponse sampleFileMessage(Long roomId) { + ChatAttachmentInfoResponse attachment = new ChatAttachmentInfoResponse( + 9002L, + ChatAttachmentType.FILE, + "application/pdf", + "proposal.pdf", + 102400L, + "https://cdn.example.com/attachments/9002.pdf", + ChatAttachmentStatus.READY + ); + return new ChatMessageResponse( + 7003L, + roomId, + 202L, + ChatSenderType.USER, + ChatMessageType.FILE, + null, + attachment, + null, + LocalDateTime.of(2025, 1, 1, 10, 4), + "33333333-3333-3333-3333-333333333333" + ); + } + + private static ChatMessageResponse sampleSystemMessage(Long roomId) { + ChatSystemMessageResponse systemMessage = new ChatSystemMessageResponse( + 1, + ChatSystemMessageKind.PROPOSAL_CARD, + new ChatProposalCardPayloadResponse( + 5001L, + 4001L, + "캠페인 A", + "캠페인 요약 문구", + ChatProposalDecisionStatus.PENDING, + ChatProposalDirection.BRAND_TO_CREATOR, + new ChatProposalActionButtonsResponse( + new ChatProposalActionButtonResponse( + "제안 수락하기", + true + ), + new ChatProposalActionButtonResponse( + "거절하기", + true + ) + ), + null + ) + ); + return new ChatMessageResponse( + 7004L, + roomId, + null, + ChatSenderType.SYSTEM, + ChatMessageType.SYSTEM, + null, + null, + systemMessage, + LocalDateTime.of(2025, 1, 1, 10, 5), + null + ); + } + + private static ChatMessageResponse sampleProposalStatusNoticeMessage(Long roomId) { + ChatSystemMessageResponse systemMessage = new ChatSystemMessageResponse( + 1, + ChatSystemMessageKind.PROPOSAL_STATUS_NOTICE, + new ChatProposalStatusNoticePayloadResponse( + 5001L, + 202L, + LocalDateTime.of(2025, 1, 1, 10, 6) + ) + ); + return new ChatMessageResponse( + 7005L, + roomId, + null, + ChatSenderType.SYSTEM, + ChatMessageType.SYSTEM, + null, + null, + systemMessage, + LocalDateTime.of(2025, 1, 1, 10, 6), + null + ); + } + + private static ChatMessageResponse sampleMatchedCampaignMessage(Long roomId) { + ChatSystemMessageResponse systemMessage = new ChatSystemMessageResponse( + 1, + ChatSystemMessageKind.MATCHED_CAMPAIGN_CARD, + new ChatMatchedCampaignPayloadResponse( + 4001L, + "캠페인 A", + 150000L, + "KRW", + "ORDER-20250101-0001", + "캠페인이 매칭되었습니다. 협업을 시작해 주세요." + ) + ); + return new ChatMessageResponse( + 7006L, + roomId, + null, + ChatSenderType.SYSTEM, + ChatMessageType.SYSTEM, + null, + null, + systemMessage, + LocalDateTime.of(2025, 1, 1, 10, 7), + null + ); + } + + public static ChatAttachmentUploadResponse sampleAttachmentUploadResponse( + ChatAttachmentUploadRequest request, + MultipartFile file + ) { + return new ChatAttachmentUploadResponse( + 9001L, + request.attachmentType(), + file.getContentType(), + file.getOriginalFilename(), + file.getSize(), + "https://cdn.example.com/attachments/9001", + ChatAttachmentStatus.UPLOADED, + LocalDateTime.of(2025, 1, 1, 10, 10) + ); + } +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/conversion/MessageCursor.java b/src/main/java/com/example/RealMatch/chat/presentation/conversion/MessageCursor.java new file mode 100644 index 00000000..322e7226 --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/conversion/MessageCursor.java @@ -0,0 +1,28 @@ +package com.example.RealMatch.chat.presentation.conversion; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +public record MessageCursor(Long messageId) { + + public static MessageCursor of(Long messageId) { + return new MessageCursor(messageId); + } + + @JsonCreator + public static MessageCursor decode(String value) { + if (value == null || value.isBlank()) { + return null; + } + try { + return new MessageCursor(Long.valueOf(value)); + } catch (NumberFormatException ex) { + throw new IllegalArgumentException("Message Cursor messageId 형식이 올바르지 않습니다.", ex); + } + } + + @JsonValue + public String encode() { + return messageId.toString(); + } +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/conversion/RoomCursor.java b/src/main/java/com/example/RealMatch/chat/presentation/conversion/RoomCursor.java new file mode 100644 index 00000000..6e6c2ea1 --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/conversion/RoomCursor.java @@ -0,0 +1,44 @@ +package com.example.RealMatch.chat.presentation.conversion; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +public record RoomCursor(LocalDateTime lastMessageAt, Long roomId) { + private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE_TIME; + + public static RoomCursor of(LocalDateTime lastMessageAt, Long roomId) { + return new RoomCursor(lastMessageAt, roomId); + } + + @JsonCreator + public static RoomCursor decode(String value) { + if (value == null || value.isBlank()) { + return null; + } + String[] parts = value.split("\\|", -1); + if (parts.length != 2) { + throw new IllegalArgumentException("Room Cursor 형식이 올바르지 않습니다."); + } + LocalDateTime lastMessageAt; + try { + lastMessageAt = LocalDateTime.parse(parts[0], FORMATTER); + } catch (RuntimeException ex) { + throw new IllegalArgumentException("Room Cursor lastMessageAt 형식이 올바르지 않습니다.", ex); + } + Long roomId; + try { + roomId = Long.valueOf(parts[1]); + } catch (NumberFormatException ex) { + throw new IllegalArgumentException("Room Cursor roomId 형식이 올바르지 않습니다.", ex); + } + return new RoomCursor(lastMessageAt, roomId); + } + + @JsonValue + public String encode() { + return FORMATTER.format(lastMessageAt) + "|" + roomId; + } +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatAttachmentStatus.java b/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatAttachmentStatus.java new file mode 100644 index 00000000..d74827a9 --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatAttachmentStatus.java @@ -0,0 +1,7 @@ +package com.example.RealMatch.chat.presentation.dto.enums; + +public enum ChatAttachmentStatus { + UPLOADED, + READY, + FAILED +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatAttachmentType.java b/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatAttachmentType.java new file mode 100644 index 00000000..f0753553 --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatAttachmentType.java @@ -0,0 +1,6 @@ +package com.example.RealMatch.chat.presentation.dto.enums; + +public enum ChatAttachmentType { + IMAGE, + FILE +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatMessageType.java b/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatMessageType.java new file mode 100644 index 00000000..b703d21a --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatMessageType.java @@ -0,0 +1,8 @@ +package com.example.RealMatch.chat.presentation.dto.enums; + +public enum ChatMessageType { + TEXT, + IMAGE, + FILE, + SYSTEM +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatProposalDecisionStatus.java b/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatProposalDecisionStatus.java new file mode 100644 index 00000000..79fd0579 --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatProposalDecisionStatus.java @@ -0,0 +1,8 @@ +package com.example.RealMatch.chat.presentation.dto.enums; + +public enum ChatProposalDecisionStatus { + PENDING, + ACCEPTED, + REJECTED, + EXPIRED +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatProposalDirection.java b/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatProposalDirection.java new file mode 100644 index 00000000..e2912684 --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatProposalDirection.java @@ -0,0 +1,7 @@ +package com.example.RealMatch.chat.presentation.dto.enums; + +public enum ChatProposalDirection { + NONE, + BRAND_TO_CREATOR, + CREATOR_TO_BRAND +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatProposalStatus.java b/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatProposalStatus.java new file mode 100644 index 00000000..bff1976f --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatProposalStatus.java @@ -0,0 +1,22 @@ +package com.example.RealMatch.chat.presentation.dto.enums; + +public enum ChatProposalStatus { + MATCHED("매칭"), + REVIEWING("검토중"), + REJECTED("거절"), + NONE(""); + + private final String label; + + ChatProposalStatus(String label) { + this.label = label; + } + + public String label() { + return label; + } + + public String labelOrNull() { + return label == null || label.isBlank() ? null : label; + } +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatRoomFilterStatus.java b/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatRoomFilterStatus.java new file mode 100644 index 00000000..84cb02e3 --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatRoomFilterStatus.java @@ -0,0 +1,8 @@ +package com.example.RealMatch.chat.presentation.dto.enums; + +public enum ChatRoomFilterStatus { + MATCHED, + REVIEWING, + REJECTED, + ALL +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatRoomSort.java b/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatRoomSort.java new file mode 100644 index 00000000..00b05ecc --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatRoomSort.java @@ -0,0 +1,5 @@ +package com.example.RealMatch.chat.presentation.dto.enums; + +public enum ChatRoomSort { + LATEST +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatRoomTab.java b/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatRoomTab.java new file mode 100644 index 00000000..a9cfd9ee --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatRoomTab.java @@ -0,0 +1,7 @@ +package com.example.RealMatch.chat.presentation.dto.enums; + +public enum ChatRoomTab { + SENT, + RECEIVED, + ALL +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatSenderType.java b/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatSenderType.java new file mode 100644 index 00000000..5532fb7c --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatSenderType.java @@ -0,0 +1,6 @@ +package com.example.RealMatch.chat.presentation.dto.enums; + +public enum ChatSenderType { + USER, + SYSTEM +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatSystemMessageKind.java b/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatSystemMessageKind.java new file mode 100644 index 00000000..6f09384f --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatSystemMessageKind.java @@ -0,0 +1,7 @@ +package com.example.RealMatch.chat.presentation.dto.enums; + +public enum ChatSystemMessageKind { + PROPOSAL_CARD, + PROPOSAL_STATUS_NOTICE, + MATCHED_CAMPAIGN_CARD +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/dto/request/ChatAttachmentUploadRequest.java b/src/main/java/com/example/RealMatch/chat/presentation/dto/request/ChatAttachmentUploadRequest.java new file mode 100644 index 00000000..be7bb41e --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/dto/request/ChatAttachmentUploadRequest.java @@ -0,0 +1,10 @@ +package com.example.RealMatch.chat.presentation.dto.request; + +import com.example.RealMatch.chat.presentation.dto.enums.ChatAttachmentType; + +import jakarta.validation.constraints.NotNull; + +public record ChatAttachmentUploadRequest( + @NotNull ChatAttachmentType attachmentType +) { +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/dto/request/ChatRoomCreateRequest.java b/src/main/java/com/example/RealMatch/chat/presentation/dto/request/ChatRoomCreateRequest.java new file mode 100644 index 00000000..ccd173ce --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/dto/request/ChatRoomCreateRequest.java @@ -0,0 +1,9 @@ +package com.example.RealMatch.chat.presentation.dto.request; + +import jakarta.validation.constraints.NotNull; + +public record ChatRoomCreateRequest( + @NotNull Long brandId, + @NotNull Long creatorId +) { +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatAttachmentInfoResponse.java b/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatAttachmentInfoResponse.java new file mode 100644 index 00000000..866cf97f --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatAttachmentInfoResponse.java @@ -0,0 +1,15 @@ +package com.example.RealMatch.chat.presentation.dto.response; + +import com.example.RealMatch.chat.presentation.dto.enums.ChatAttachmentStatus; +import com.example.RealMatch.chat.presentation.dto.enums.ChatAttachmentType; + +public record ChatAttachmentInfoResponse( + Long attachmentId, + ChatAttachmentType attachmentType, + String contentType, + String originalName, + Long fileSize, + String accessUrl, + ChatAttachmentStatus status +) { +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatAttachmentUploadResponse.java b/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatAttachmentUploadResponse.java new file mode 100644 index 00000000..863f203b --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatAttachmentUploadResponse.java @@ -0,0 +1,18 @@ +package com.example.RealMatch.chat.presentation.dto.response; + +import java.time.LocalDateTime; + +import com.example.RealMatch.chat.presentation.dto.enums.ChatAttachmentStatus; +import com.example.RealMatch.chat.presentation.dto.enums.ChatAttachmentType; + +public record ChatAttachmentUploadResponse( + Long attachmentId, + ChatAttachmentType attachmentType, + String contentType, + String originalName, + Long fileSize, + String accessUrl, + ChatAttachmentStatus status, + LocalDateTime createdAt +) { +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatMatchedCampaignPayloadResponse.java b/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatMatchedCampaignPayloadResponse.java new file mode 100644 index 00000000..ea2e6cf1 --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatMatchedCampaignPayloadResponse.java @@ -0,0 +1,11 @@ +package com.example.RealMatch.chat.presentation.dto.response; + +public record ChatMatchedCampaignPayloadResponse( + Long campaignId, + String campaignName, + long amount, + String currency, + String orderNumber, + String message +) implements ChatSystemMessagePayload { +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatMessageListResponse.java b/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatMessageListResponse.java new file mode 100644 index 00000000..75dadc5d --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatMessageListResponse.java @@ -0,0 +1,12 @@ +package com.example.RealMatch.chat.presentation.dto.response; + +import java.util.List; + +import com.example.RealMatch.chat.presentation.conversion.MessageCursor; + +public record ChatMessageListResponse( + List messages, + MessageCursor nextCursor, + boolean hasNext +) { +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatMessageResponse.java b/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatMessageResponse.java new file mode 100644 index 00000000..258158e3 --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatMessageResponse.java @@ -0,0 +1,20 @@ +package com.example.RealMatch.chat.presentation.dto.response; + +import java.time.LocalDateTime; + +import com.example.RealMatch.chat.presentation.dto.enums.ChatMessageType; +import com.example.RealMatch.chat.presentation.dto.enums.ChatSenderType; + +public record ChatMessageResponse( + Long messageId, + Long roomId, + Long senderId, + ChatSenderType senderType, + ChatMessageType messageType, + String content, + ChatAttachmentInfoResponse attachment, + ChatSystemMessageResponse systemMessage, + LocalDateTime createdAt, + String clientMessageId +) { +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatProposalActionButtonResponse.java b/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatProposalActionButtonResponse.java new file mode 100644 index 00000000..26e6a633 --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatProposalActionButtonResponse.java @@ -0,0 +1,7 @@ +package com.example.RealMatch.chat.presentation.dto.response; + +public record ChatProposalActionButtonResponse( + String label, + boolean enabled +) { +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatProposalActionButtonsResponse.java b/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatProposalActionButtonsResponse.java new file mode 100644 index 00000000..8104bded --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatProposalActionButtonsResponse.java @@ -0,0 +1,7 @@ +package com.example.RealMatch.chat.presentation.dto.response; + +public record ChatProposalActionButtonsResponse( + ChatProposalActionButtonResponse accept, + ChatProposalActionButtonResponse reject +) { +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatProposalCardPayloadResponse.java b/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatProposalCardPayloadResponse.java new file mode 100644 index 00000000..6b34589c --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatProposalCardPayloadResponse.java @@ -0,0 +1,18 @@ +package com.example.RealMatch.chat.presentation.dto.response; + +import java.time.LocalDateTime; + +import com.example.RealMatch.chat.presentation.dto.enums.ChatProposalDecisionStatus; +import com.example.RealMatch.chat.presentation.dto.enums.ChatProposalDirection; + +public record ChatProposalCardPayloadResponse( + Long proposalId, + Long campaignId, + String campaignName, + String campaignSummary, + ChatProposalDecisionStatus proposalStatus, + ChatProposalDirection proposalDirection, + ChatProposalActionButtonsResponse buttons, + LocalDateTime expiresAt +) implements ChatSystemMessagePayload { +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatProposalStatusNoticePayloadResponse.java b/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatProposalStatusNoticePayloadResponse.java new file mode 100644 index 00000000..7d3ac0c7 --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatProposalStatusNoticePayloadResponse.java @@ -0,0 +1,10 @@ +package com.example.RealMatch.chat.presentation.dto.response; + +import java.time.LocalDateTime; + +public record ChatProposalStatusNoticePayloadResponse( + Long proposalId, + Long actorUserId, + LocalDateTime processedAt +) implements ChatSystemMessagePayload { +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatRoomCardResponse.java b/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatRoomCardResponse.java new file mode 100644 index 00000000..e8c4ab40 --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatRoomCardResponse.java @@ -0,0 +1,21 @@ +package com.example.RealMatch.chat.presentation.dto.response; + +import java.time.LocalDateTime; + +import com.example.RealMatch.chat.presentation.dto.enums.ChatMessageType; +import com.example.RealMatch.chat.presentation.dto.enums.ChatProposalStatus; +import com.example.RealMatch.chat.presentation.dto.enums.ChatRoomTab; + +public record ChatRoomCardResponse( + Long roomId, + Long opponentUserId, + String opponentName, + String opponentProfileImageUrl, + ChatProposalStatus proposalStatus, + String lastMessagePreview, + ChatMessageType lastMessageType, + LocalDateTime lastMessageAt, + int unreadCount, + ChatRoomTab tabCategory +) { +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatRoomCreateResponse.java b/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatRoomCreateResponse.java new file mode 100644 index 00000000..483b3b90 --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatRoomCreateResponse.java @@ -0,0 +1,13 @@ +package com.example.RealMatch.chat.presentation.dto.response; + +import java.time.LocalDateTime; + +import com.example.RealMatch.chat.presentation.dto.enums.ChatProposalDirection; + +public record ChatRoomCreateResponse( + Long roomId, + String roomKey, + ChatProposalDirection lastProposalDirection, + LocalDateTime createdAt +) { +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatRoomDetailResponse.java b/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatRoomDetailResponse.java new file mode 100644 index 00000000..9f330cc7 --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatRoomDetailResponse.java @@ -0,0 +1,16 @@ +package com.example.RealMatch.chat.presentation.dto.response; + +import java.util.List; + +import com.example.RealMatch.chat.presentation.dto.enums.ChatProposalStatus; + +public record ChatRoomDetailResponse( + Long roomId, + Long opponentUserId, + String opponentName, + String opponentProfileImageUrl, + List opponentTags, + ChatProposalStatus proposalStatus, + String proposalStatusLabel +) { +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatRoomListResponse.java b/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatRoomListResponse.java new file mode 100644 index 00000000..4fc35afd --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatRoomListResponse.java @@ -0,0 +1,15 @@ +package com.example.RealMatch.chat.presentation.dto.response; + +import java.util.List; + +import com.example.RealMatch.chat.presentation.conversion.RoomCursor; + +public record ChatRoomListResponse( + long sentTabUnreadCount, + long receivedTabUnreadCount, + Long totalUnreadCount, + List rooms, + RoomCursor nextCursor, + boolean hasNext +) { +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatSystemMessagePayload.java b/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatSystemMessagePayload.java new file mode 100644 index 00000000..0353e8c9 --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatSystemMessagePayload.java @@ -0,0 +1,4 @@ +package com.example.RealMatch.chat.presentation.dto.response; + +public interface ChatSystemMessagePayload { +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatSystemMessageResponse.java b/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatSystemMessageResponse.java new file mode 100644 index 00000000..cec6619c --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatSystemMessageResponse.java @@ -0,0 +1,10 @@ +package com.example.RealMatch.chat.presentation.dto.response; + +import com.example.RealMatch.chat.presentation.dto.enums.ChatSystemMessageKind; + +public record ChatSystemMessageResponse( + int schemaVersion, + ChatSystemMessageKind kind, + ChatSystemMessagePayload payload +) { +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/swagger/ChatSwagger.java b/src/main/java/com/example/RealMatch/chat/presentation/swagger/ChatSwagger.java new file mode 100644 index 00000000..8dbc8478 --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/swagger/ChatSwagger.java @@ -0,0 +1,116 @@ +package com.example.RealMatch.chat.presentation.swagger; + +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.multipart.MultipartFile; + +import com.example.RealMatch.chat.presentation.conversion.MessageCursor; +import com.example.RealMatch.chat.presentation.conversion.RoomCursor; +import com.example.RealMatch.chat.presentation.dto.enums.ChatRoomFilterStatus; +import com.example.RealMatch.chat.presentation.dto.enums.ChatRoomSort; +import com.example.RealMatch.chat.presentation.dto.enums.ChatRoomTab; +import com.example.RealMatch.chat.presentation.dto.request.ChatAttachmentUploadRequest; +import com.example.RealMatch.chat.presentation.dto.request.ChatRoomCreateRequest; +import com.example.RealMatch.chat.presentation.dto.response.ChatAttachmentUploadResponse; +import com.example.RealMatch.chat.presentation.dto.response.ChatMessageListResponse; +import com.example.RealMatch.chat.presentation.dto.response.ChatRoomCreateResponse; +import com.example.RealMatch.chat.presentation.dto.response.ChatRoomDetailResponse; +import com.example.RealMatch.chat.presentation.dto.response.ChatRoomListResponse; +import com.example.RealMatch.global.config.jwt.CustomUserDetails; +import com.example.RealMatch.global.presentation.CustomResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; + +@Tag(name = "chat", description = "채팅 REST API") +@RequestMapping("/api/chat") +public interface ChatSwagger { + + @Operation(summary = "채팅방 생성/조회 API By 여채현", + description = """ + brandId, creatorId 기준으로 1:1 채팅방을 생성하거나 기존 방을 반환합니다. + 생성 직후 방 정보를 내려주며, 필요 시 상세 헤더는 별도 조회로 보완합니다. + """) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "채팅방 생성/조회 성공"), + @ApiResponse(responseCode = "COMMON401_1", description = "인증이 필요합니다.") + }) + CustomResponse createOrGetRoom( + @AuthenticationPrincipal CustomUserDetails user, + @Valid @RequestBody ChatRoomCreateRequest request + ); + + @Operation(summary = "채팅방 목록 조회 API By 여채현", + description = """ + tab/status/sort 기준으로 채팅방 목록을 조회합니다. + sort=LATEST는 lastMessageAt desc, roomId desc 기준으로 정렬합니다. + cursor는 lastMessageAt|roomId 포맷을 그대로 재사용하세요. + 메시지가 없는 방은 목록에서 제외됩니다. + """) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "채팅방 목록 조회 성공"), + @ApiResponse(responseCode = "COMMON401_1", description = "인증이 필요합니다.") + }) + CustomResponse getRoomList( + @AuthenticationPrincipal CustomUserDetails user, + @RequestParam(required = false) ChatRoomTab tab, + @RequestParam(name = "status", required = false) ChatRoomFilterStatus filterStatus, + @RequestParam(required = false) ChatRoomSort sort, + @RequestParam(name = "cursor", required = false) RoomCursor roomCursor, + @RequestParam(defaultValue = "20") int size + ); + + @Operation(summary = "채팅방 상세 조회 API By 여채현", + description = """ + 채팅방 헤더에 필요한 상대 정보와 상태 값을 반환합니다. + 태그/상태 라벨 등 UI 구성에 필요한 필드를 포함합니다. + """) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "채팅방 상세 조회 성공"), + @ApiResponse(responseCode = "COMMON401_1", description = "인증이 필요합니다.") + }) + CustomResponse getRoomDetail( + @AuthenticationPrincipal CustomUserDetails user, + @PathVariable Long roomId + ); + + @Operation(summary = "채팅 메시지 조회 API By 여채현", + description = """ + messageId desc 기준으로 메시지를 조회합니다. + cursor는 해당 id보다 작은 메시지를 조회하는 기준입니다. + 렌더링 기준은 messageType이며, 타입별 필드는 배타적으로 사용됩니다. + """) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "채팅 메시지 조회 성공"), + @ApiResponse(responseCode = "COMMON401_1", description = "인증이 필요합니다.") + }) + CustomResponse getMessages( + @AuthenticationPrincipal CustomUserDetails user, + @PathVariable Long roomId, + @RequestParam(name = "cursor", required = false) MessageCursor messageCursor, + @RequestParam(defaultValue = "20") int size + ); + + @Operation(summary = "첨부 업로드 API By 여채현", + description = """ + 첨부 파일을 업로드하고 메타 정보를 반환합니다. + UPLOADED 상태여도 accessUrl을 즉시 사용할 수 있습니다. + READY는 내부 상태값입니다. + """) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "첨부 업로드 성공"), + @ApiResponse(responseCode = "COMMON401_1", description = "인증이 필요합니다.") + }) + CustomResponse uploadAttachment( + @AuthenticationPrincipal CustomUserDetails user, + @Valid @RequestPart("request") ChatAttachmentUploadRequest request, + @RequestPart("file") MultipartFile file + ); +} diff --git a/src/main/java/com/example/RealMatch/global/config/SecurityConfig.java b/src/main/java/com/example/RealMatch/global/config/SecurityConfig.java index c1bfe019..09c4535b 100644 --- a/src/main/java/com/example/RealMatch/global/config/SecurityConfig.java +++ b/src/main/java/com/example/RealMatch/global/config/SecurityConfig.java @@ -27,6 +27,7 @@ public class SecurityConfig { private static final String[] PERMIT_ALL_URL_ARRAY = { "/api/test", + "/api/chat/**", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-resources/**", "/swagger-ui.html" }; diff --git a/src/test/java/com/example/RealMatch/chat/presentation/controller/ChatControllerTest.java b/src/test/java/com/example/RealMatch/chat/presentation/controller/ChatControllerTest.java new file mode 100644 index 00000000..f24e5e47 --- /dev/null +++ b/src/test/java/com/example/RealMatch/chat/presentation/controller/ChatControllerTest.java @@ -0,0 +1,180 @@ +package com.example.RealMatch.chat.presentation.controller; + +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import com.example.RealMatch.chat.presentation.config.ChatCursorConverterConfig; +import com.example.RealMatch.chat.presentation.dto.enums.ChatAttachmentType; +import com.example.RealMatch.chat.presentation.dto.request.ChatAttachmentUploadRequest; +import com.example.RealMatch.chat.presentation.dto.request.ChatRoomCreateRequest; +import com.example.RealMatch.global.config.jwt.JwtProvider; +import com.fasterxml.jackson.databind.ObjectMapper; +@SuppressWarnings("null") +@WebMvcTest(ChatController.class) +@AutoConfigureMockMvc(addFilters = false) +@Import(ChatCursorConverterConfig.class) +class ChatControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private JwtProvider jwtProvider; + + @Test + @DisplayName("채팅방 생성/조회: 200 + 공통 응답 포맷") + void createOrGetRoom_returnsOk() throws Exception { + ChatRoomCreateRequest request = new ChatRoomCreateRequest(101L, 202L); + + mockMvc.perform(post("/api/chat/rooms") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.code").value("COMMON200_1")) + .andExpect(jsonPath("$.message").value("정상적인 요청입니다.")) + .andExpect(jsonPath("$.result.roomId").value(3001)) + .andExpect(jsonPath("$.result.roomKey").value("direct:101:202")) + .andExpect(jsonPath("$.result.lastProposalDirection").value("NONE")) + .andExpect(jsonPath("$.result.createdAt").value("2025-01-01T00:00:00")); + } + + @Test + @DisplayName("채팅방 목록 조회: 200 + 공통 응답 포맷") + void getRoomList_returnsOk() throws Exception { + mockMvc.perform(get("/api/chat/rooms") + .param("tab", "SENT") + .param("status", "ALL") + .param("sort", "LATEST") + .param("cursor", "2025-01-01T10:00:00|3001") + .param("size", "20")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.code").value("COMMON200_1")) + .andExpect(jsonPath("$.message").value("정상적인 요청입니다.")) + .andExpect(jsonPath("$.result.sentTabUnreadCount").value(5)) + .andExpect(jsonPath("$.result.receivedTabUnreadCount").value(2)) + .andExpect(jsonPath("$.result.totalUnreadCount").value(7)) + .andExpect(jsonPath("$.result.rooms", hasSize(1))) + .andExpect(jsonPath("$.result.rooms[0].roomId").value(3001)) + .andExpect(jsonPath("$.result.rooms[0].opponentUserId").value(202)) + .andExpect(jsonPath("$.result.rooms[0].opponentName").value("라운드랩")) + .andExpect(jsonPath("$.result.rooms[0].proposalStatus").value("REVIEWING")) + .andExpect(jsonPath("$.result.rooms[0].lastMessageType").value("TEXT")) + .andExpect(jsonPath("$.result.rooms[0].tabCategory").value("SENT")) + .andExpect(jsonPath("$.result.rooms[0].unreadCount").value(3)) + .andExpect(jsonPath("$.result.nextCursor").value("2025-01-01T09:59:59|2999")) + .andExpect(jsonPath("$.result.hasNext", is(true))); + } + + @Test + @DisplayName("채팅방 상세 조회: 200 + 공통 응답 포맷") + void getRoomDetail_returnsOk() throws Exception { + mockMvc.perform(get("/api/chat/rooms/{roomId}", 3001L)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.code").value("COMMON200_1")) + .andExpect(jsonPath("$.message").value("정상적인 요청입니다.")) + .andExpect(jsonPath("$.result.roomId").value(3001)) + .andExpect(jsonPath("$.result.opponentUserId").value(202)) + .andExpect(jsonPath("$.result.opponentName").value("라운드랩")) + .andExpect(jsonPath("$.result.opponentTags", hasSize(3))) + .andExpect(jsonPath("$.result.proposalStatus").value("REVIEWING")) + .andExpect(jsonPath("$.result.proposalStatusLabel").value("검토중")); + } + + @Test + @DisplayName("채팅 메시지 조회: 200 + 공통 응답 포맷") + void getMessages_returnsOk() throws Exception { + mockMvc.perform(get("/api/chat/rooms/{roomId}/messages", 3001L) + .param("cursor", "7001") + .param("size", "20")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.code").value("COMMON200_1")) + .andExpect(jsonPath("$.message").value("정상적인 요청입니다.")) + .andExpect(jsonPath("$.result.messages", hasSize(6))) + .andExpect(jsonPath("$.result.messages[0].messageId").value(7006)) + .andExpect(jsonPath("$.result.messages[0].senderType").value("SYSTEM")) + .andExpect(jsonPath("$.result.messages[0].messageType").value("SYSTEM")) + .andExpect(jsonPath("$.result.messages[0].attachment").doesNotExist()) + .andExpect(jsonPath("$.result.messages[0].systemMessage.kind").value("MATCHED_CAMPAIGN_CARD")) + .andExpect(jsonPath("$.result.messages[1].messageId").value(7005)) + .andExpect(jsonPath("$.result.messages[1].senderType").value("SYSTEM")) + .andExpect(jsonPath("$.result.messages[1].messageType").value("SYSTEM")) + .andExpect(jsonPath("$.result.messages[1].attachment").doesNotExist()) + .andExpect(jsonPath("$.result.messages[1].systemMessage.kind").value("PROPOSAL_STATUS_NOTICE")) + .andExpect(jsonPath("$.result.messages[2].messageId").value(7004)) + .andExpect(jsonPath("$.result.messages[2].senderType").value("SYSTEM")) + .andExpect(jsonPath("$.result.messages[2].messageType").value("SYSTEM")) + .andExpect(jsonPath("$.result.messages[2].attachment").doesNotExist()) + .andExpect(jsonPath("$.result.messages[2].systemMessage.kind").value("PROPOSAL_CARD")) + .andExpect(jsonPath("$.result.messages[3].messageId").value(7003)) + .andExpect(jsonPath("$.result.messages[3].senderType").value("USER")) + .andExpect(jsonPath("$.result.messages[3].messageType").value("FILE")) + .andExpect(jsonPath("$.result.messages[3].attachment.attachmentId").value(9002)) + .andExpect(jsonPath("$.result.messages[3].systemMessage").doesNotExist()) + .andExpect(jsonPath("$.result.messages[4].messageId").value(7002)) + .andExpect(jsonPath("$.result.messages[4].senderType").value("USER")) + .andExpect(jsonPath("$.result.messages[4].messageType").value("IMAGE")) + .andExpect(jsonPath("$.result.messages[4].attachment.attachmentId").value(9001)) + .andExpect(jsonPath("$.result.messages[4].systemMessage").doesNotExist()) + .andExpect(jsonPath("$.result.messages[5].messageId").value(7001)) + .andExpect(jsonPath("$.result.messages[5].senderType").value("USER")) + .andExpect(jsonPath("$.result.messages[5].messageType").value("TEXT")) + .andExpect(jsonPath("$.result.messages[5].attachment").doesNotExist()) + .andExpect(jsonPath("$.result.messages[5].systemMessage").doesNotExist()) + .andExpect(jsonPath("$.result.nextCursor").value("6999")) + .andExpect(jsonPath("$.result.hasNext", is(true))); + } + + @Test + @DisplayName("첨부 업로드: 200 + 공통 응답 포맷") + void uploadAttachment_returnsOk() throws Exception { + ChatAttachmentUploadRequest request = new ChatAttachmentUploadRequest( + ChatAttachmentType.IMAGE); + + MockMultipartFile jsonPart = new MockMultipartFile( + "request", + "request", + MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(request) + ); + MockMultipartFile filePart = new MockMultipartFile( + "file", + "photo.png", + MediaType.IMAGE_PNG_VALUE, + "dummy".getBytes() + ); + + mockMvc.perform(multipart("/api/chat/attachments") + .file(jsonPart) + .file(filePart)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.code").value("COMMON200_1")) + .andExpect(jsonPath("$.message").value("정상적인 요청입니다.")) + .andExpect(jsonPath("$.result.attachmentId").value(9001)) + .andExpect(jsonPath("$.result.attachmentType").value("IMAGE")) + .andExpect(jsonPath("$.result.status").value("UPLOADED")); + } +} From 79e1a08a58ce824e5c6ea40a577508d2fdb3fccf Mon Sep 17 00:00:00 2001 From: 1000hyehyang Date: Mon, 19 Jan 2026 13:06:44 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat(#17):=20=EC=B1=84=ED=8C=85=20WS=20?= =?UTF-8?q?=EA=B8=B0=EB=B3=B8=20=EA=B5=AC=EC=84=B1=20=EB=B0=8F=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80=20(#19)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 작업 개요 채팅 WebSocket(STOMP) 기본 구성과 샘플 컨트롤러/DTO를 추가하고, WS 테스트를 ACK/브로드캐스트로 분리해 검증 범위를 명확히 했습니다. 프론트엔드 개발팀을 위해 WS 흐름 문서를 함께 API 명세서에 적어두었습니다. ## 변경 사항 - WS 설정 추가(`/ws/chat`, `/app`, `/topic`, `/queue`, `/user`) - WS 샘플 컨트롤러/DTO/fixture 구성 - WS 테스트 분리(ACK 수신 / 브로드캐스트 수신) - 이미지/파일 메시지 브로드캐스트 테스트 케이스 추가 - 보안 설정에 채팅 API 접근 허용 범위 추가 (인증 인가 완료되면 다시 설정할 것!!) --- build.gradle | 1 + .../config/ChatWebSocketConfig.java | 41 ++++ .../controller/ChatSocketController.java | 34 +++ .../fixture/ChatSocketFixtureFactory.java | 66 +++++ .../dto/enums/ChatSendMessageAckStatus.java | 6 + .../websocket/ChatMessageCreatedEvent.java | 9 + .../dto/websocket/ChatSendMessageAck.java | 10 + .../dto/websocket/ChatSendMessageCommand.java | 14 ++ .../global/config/SecurityConfig.java | 3 +- .../controller/ChatSocketControllerTest.java | 225 ++++++++++++++++++ 10 files changed, 408 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/example/RealMatch/chat/presentation/config/ChatWebSocketConfig.java create mode 100644 src/main/java/com/example/RealMatch/chat/presentation/controller/ChatSocketController.java create mode 100644 src/main/java/com/example/RealMatch/chat/presentation/controller/fixture/ChatSocketFixtureFactory.java create mode 100644 src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatSendMessageAckStatus.java create mode 100644 src/main/java/com/example/RealMatch/chat/presentation/dto/websocket/ChatMessageCreatedEvent.java create mode 100644 src/main/java/com/example/RealMatch/chat/presentation/dto/websocket/ChatSendMessageAck.java create mode 100644 src/main/java/com/example/RealMatch/chat/presentation/dto/websocket/ChatSendMessageCommand.java create mode 100644 src/test/java/com/example/RealMatch/chat/presentation/controller/ChatSocketControllerTest.java diff --git a/build.gradle b/build.gradle index f490a7e2..206f5904 100644 --- a/build.gradle +++ b/build.gradle @@ -31,6 +31,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jdbc' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-websocket' implementation 'org.springframework.boot:spring-boot-starter-validation' compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' diff --git a/src/main/java/com/example/RealMatch/chat/presentation/config/ChatWebSocketConfig.java b/src/main/java/com/example/RealMatch/chat/presentation/config/ChatWebSocketConfig.java new file mode 100644 index 00000000..862343a2 --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/config/ChatWebSocketConfig.java @@ -0,0 +1,41 @@ +package com.example.RealMatch.chat.presentation.config; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import org.springframework.lang.NonNull; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; +import org.springframework.web.socket.server.HandshakeHandler; + +@Configuration +@EnableWebSocketMessageBroker +public class ChatWebSocketConfig implements WebSocketMessageBrokerConfigurer { + + @Value("${cors.allowed-origin}") + private String allowedOrigin; + private final ObjectProvider handshakeHandlerProvider; + + public ChatWebSocketConfig(ObjectProvider handshakeHandlerProvider) { + this.handshakeHandlerProvider = handshakeHandlerProvider; + } + + @Override + public void registerStompEndpoints(@NonNull StompEndpointRegistry registry) { + var registration = registry.addEndpoint("/ws/chat") + .setAllowedOrigins(allowedOrigin); + HandshakeHandler handshakeHandler = handshakeHandlerProvider.getIfAvailable(); + if (handshakeHandler != null) { + registration.setHandshakeHandler(handshakeHandler); + } + } + + @Override + public void configureMessageBroker(@NonNull MessageBrokerRegistry registry) { + registry.setApplicationDestinationPrefixes("/app"); + registry.setUserDestinationPrefix("/user"); + registry.enableSimpleBroker("/topic", "/queue"); + } +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/controller/ChatSocketController.java b/src/main/java/com/example/RealMatch/chat/presentation/controller/ChatSocketController.java new file mode 100644 index 00000000..370b78c6 --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/controller/ChatSocketController.java @@ -0,0 +1,34 @@ +package com.example.RealMatch.chat.presentation.controller; + +import java.util.Objects; + +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.messaging.simp.annotation.SendToUser; +import org.springframework.stereotype.Controller; + +import com.example.RealMatch.chat.presentation.controller.fixture.ChatSocketFixtureFactory; +import com.example.RealMatch.chat.presentation.dto.websocket.ChatMessageCreatedEvent; +import com.example.RealMatch.chat.presentation.dto.websocket.ChatSendMessageAck; +import com.example.RealMatch.chat.presentation.dto.websocket.ChatSendMessageCommand; + +import jakarta.validation.Valid; + +@Controller +public class ChatSocketController { + + private final SimpMessagingTemplate messagingTemplate; + + public ChatSocketController(SimpMessagingTemplate messagingTemplate) { + this.messagingTemplate = messagingTemplate; + } + + @MessageMapping("/chat.send") + @SendToUser("/queue/chat.ack") + public ChatSendMessageAck sendMessage(@Valid @Payload ChatSendMessageCommand command) { + ChatMessageCreatedEvent event = ChatSocketFixtureFactory.sampleMessageCreatedEvent(command); + messagingTemplate.convertAndSend("/topic/rooms/" + command.roomId(), Objects.requireNonNull(event)); + return ChatSocketFixtureFactory.sampleAck(command, event.message().messageId()); + } +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/controller/fixture/ChatSocketFixtureFactory.java b/src/main/java/com/example/RealMatch/chat/presentation/controller/fixture/ChatSocketFixtureFactory.java new file mode 100644 index 00000000..d83c753c --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/controller/fixture/ChatSocketFixtureFactory.java @@ -0,0 +1,66 @@ +package com.example.RealMatch.chat.presentation.controller.fixture; + +import java.time.LocalDateTime; + +import com.example.RealMatch.chat.presentation.dto.enums.ChatAttachmentStatus; +import com.example.RealMatch.chat.presentation.dto.enums.ChatAttachmentType; +import com.example.RealMatch.chat.presentation.dto.enums.ChatMessageType; +import com.example.RealMatch.chat.presentation.dto.enums.ChatSendMessageAckStatus; +import com.example.RealMatch.chat.presentation.dto.enums.ChatSenderType; +import com.example.RealMatch.chat.presentation.dto.response.ChatAttachmentInfoResponse; +import com.example.RealMatch.chat.presentation.dto.response.ChatMessageResponse; +import com.example.RealMatch.chat.presentation.dto.websocket.ChatMessageCreatedEvent; +import com.example.RealMatch.chat.presentation.dto.websocket.ChatSendMessageAck; +import com.example.RealMatch.chat.presentation.dto.websocket.ChatSendMessageCommand; + +public final class ChatSocketFixtureFactory { + + private ChatSocketFixtureFactory() { + } + + public static ChatMessageCreatedEvent sampleMessageCreatedEvent(ChatSendMessageCommand command) { + ChatAttachmentInfoResponse attachment = sampleAttachment(command); + String content = command.messageType() == ChatMessageType.TEXT ? command.content() : null; + + ChatMessageResponse message = new ChatMessageResponse( + 8001L, + command.roomId(), + 202L, + ChatSenderType.USER, + command.messageType(), + content, + attachment, + null, + LocalDateTime.of(2025, 1, 1, 10, 2), + command.clientMessageId() + ); + return new ChatMessageCreatedEvent(command.roomId(), message); + } + + public static ChatSendMessageAck sampleAck(ChatSendMessageCommand command, Long messageId) { + return new ChatSendMessageAck( + command.clientMessageId(), + messageId, + ChatSendMessageAckStatus.SUCCESS + ); + } + + private static ChatAttachmentInfoResponse sampleAttachment(ChatSendMessageCommand command) { + ChatMessageType messageType = command.messageType(); + if (messageType != ChatMessageType.IMAGE && messageType != ChatMessageType.FILE) { + return null; + } + ChatAttachmentType attachmentType = messageType == ChatMessageType.IMAGE + ? ChatAttachmentType.IMAGE + : ChatAttachmentType.FILE; + return new ChatAttachmentInfoResponse( + command.attachmentId(), + attachmentType, + messageType == ChatMessageType.IMAGE ? "image/png" : "application/octet-stream", + messageType == ChatMessageType.IMAGE ? "photo.png" : "file.bin", + 204800L, + "https://cdn.example.com/attachments/8001", + ChatAttachmentStatus.READY + ); + } +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatSendMessageAckStatus.java b/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatSendMessageAckStatus.java new file mode 100644 index 00000000..4c6f9dd1 --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatSendMessageAckStatus.java @@ -0,0 +1,6 @@ +package com.example.RealMatch.chat.presentation.dto.enums; + +public enum ChatSendMessageAckStatus { + SUCCESS, + FAILED +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/dto/websocket/ChatMessageCreatedEvent.java b/src/main/java/com/example/RealMatch/chat/presentation/dto/websocket/ChatMessageCreatedEvent.java new file mode 100644 index 00000000..c0a823b0 --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/dto/websocket/ChatMessageCreatedEvent.java @@ -0,0 +1,9 @@ +package com.example.RealMatch.chat.presentation.dto.websocket; + +import com.example.RealMatch.chat.presentation.dto.response.ChatMessageResponse; + +public record ChatMessageCreatedEvent( + Long roomId, + ChatMessageResponse message +) { +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/dto/websocket/ChatSendMessageAck.java b/src/main/java/com/example/RealMatch/chat/presentation/dto/websocket/ChatSendMessageAck.java new file mode 100644 index 00000000..ac10f862 --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/dto/websocket/ChatSendMessageAck.java @@ -0,0 +1,10 @@ +package com.example.RealMatch.chat.presentation.dto.websocket; + +import com.example.RealMatch.chat.presentation.dto.enums.ChatSendMessageAckStatus; + +public record ChatSendMessageAck( + String clientMessageId, + Long messageId, + ChatSendMessageAckStatus status +) { +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/dto/websocket/ChatSendMessageCommand.java b/src/main/java/com/example/RealMatch/chat/presentation/dto/websocket/ChatSendMessageCommand.java new file mode 100644 index 00000000..6f757a82 --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/dto/websocket/ChatSendMessageCommand.java @@ -0,0 +1,14 @@ +package com.example.RealMatch.chat.presentation.dto.websocket; + +import com.example.RealMatch.chat.presentation.dto.enums.ChatMessageType; + +import jakarta.validation.constraints.NotNull; + +public record ChatSendMessageCommand( + @NotNull Long roomId, + @NotNull ChatMessageType messageType, + String content, + Long attachmentId, + @NotNull String clientMessageId +) { +} diff --git a/src/main/java/com/example/RealMatch/global/config/SecurityConfig.java b/src/main/java/com/example/RealMatch/global/config/SecurityConfig.java index 09c4535b..7aeff704 100644 --- a/src/main/java/com/example/RealMatch/global/config/SecurityConfig.java +++ b/src/main/java/com/example/RealMatch/global/config/SecurityConfig.java @@ -28,6 +28,7 @@ public class SecurityConfig { private static final String[] PERMIT_ALL_URL_ARRAY = { "/api/test", "/api/chat/**", + "/ws/**", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-resources/**", "/swagger-ui.html" }; @@ -64,7 +65,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http, CustomAuthEntr public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); - configuration.setAllowedOrigins(List.of(allowedOrigin, "http://localhost:8080", swaggerUrl)); + configuration.setAllowedOrigins(List.of(allowedOrigin, swaggerUrl)); configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); configuration.setAllowedHeaders(List.of("*")); configuration.setAllowCredentials(true); // 쿠키/인증정보 포함 요청 diff --git a/src/test/java/com/example/RealMatch/chat/presentation/controller/ChatSocketControllerTest.java b/src/test/java/com/example/RealMatch/chat/presentation/controller/ChatSocketControllerTest.java new file mode 100644 index 00000000..9f4d944d --- /dev/null +++ b/src/test/java/com/example/RealMatch/chat/presentation/controller/ChatSocketControllerTest.java @@ -0,0 +1,225 @@ +package com.example.RealMatch.chat.presentation.controller; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.lang.reflect.Type; +import java.security.Principal; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.annotation.Bean; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.lang.NonNull; +import org.springframework.lang.Nullable; +import org.springframework.messaging.converter.MappingJackson2MessageConverter; +import org.springframework.messaging.simp.stomp.StompFrameHandler; +import org.springframework.messaging.simp.stomp.StompHeaders; +import org.springframework.messaging.simp.stomp.StompSession; +import org.springframework.messaging.simp.stomp.StompSessionHandlerAdapter; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.client.standard.StandardWebSocketClient; +import org.springframework.web.socket.messaging.WebSocketStompClient; +import org.springframework.web.socket.server.HandshakeHandler; +import org.springframework.web.socket.server.support.DefaultHandshakeHandler; + +import com.example.RealMatch.chat.presentation.dto.enums.ChatMessageType; +import com.example.RealMatch.chat.presentation.dto.enums.ChatSendMessageAckStatus; +import com.example.RealMatch.chat.presentation.dto.websocket.ChatMessageCreatedEvent; +import com.example.RealMatch.chat.presentation.dto.websocket.ChatSendMessageAck; +import com.example.RealMatch.chat.presentation.dto.websocket.ChatSendMessageCommand; +import com.fasterxml.jackson.databind.ObjectMapper; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class ChatSocketControllerTest { + private static final String WS_ENDPOINT = "/ws/chat"; + private static final String APP_SEND_DESTINATION = "/app/chat.send"; + private static final String ROOM_TOPIC_PREFIX = "/topic/rooms/"; + private static final String ACK_QUEUE = "/user/queue/chat.ack"; + private static final long ROOM_ID = 3001L; + private static final String CLIENT_MESSAGE_ID = "11111111-1111-1111-1111-111111111111"; + private static final String IMAGE_CLIENT_MESSAGE_ID = "22222222-2222-2222-2222-222222222222"; + private static final String FILE_CLIENT_MESSAGE_ID = "33333333-3333-3333-3333-333333333333"; + private static final long ATTACHMENT_ID = 9001L; + private static final int TIMEOUT_SECONDS = 3; + private static final long SUBSCRIBE_WAIT_MILLIS = 200L; + + @LocalServerPort + private int port; + + private WebSocketStompClient stompClient; + + @BeforeEach + void setUp() { + stompClient = new WebSocketStompClient(new StandardWebSocketClient()); + MappingJackson2MessageConverter converter = new MappingJackson2MessageConverter(); + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.findAndRegisterModules(); + converter.setObjectMapper(objectMapper); + stompClient.setMessageConverter(converter); + } + + @Test + @DisplayName("채팅 메시지 전송: ACK 수신") + void sendMessage_returnsAck() throws Exception { + StompSession session = connect(); + CompletableFuture ackFuture = new CompletableFuture<>(); + + session.subscribe(ACK_QUEUE, new StompFrameHandler() { + @Override + public @NonNull Type getPayloadType(@NonNull StompHeaders headers) { + return ChatSendMessageAck.class; + } + + @Override + public void handleFrame(@NonNull StompHeaders headers, @Nullable Object payload) { + ackFuture.complete((ChatSendMessageAck) payload); + } + }); + waitForSubscriptions(); + + ChatSendMessageCommand command = createCommand( + ChatMessageType.TEXT, + "안녕하세요!", + null, + CLIENT_MESSAGE_ID + ); + session.send(APP_SEND_DESTINATION, Objects.requireNonNull(command)); + + ChatSendMessageAck ack = ackFuture.get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertThat(ack.status()).isEqualTo(ChatSendMessageAckStatus.SUCCESS); + assertThat(ack.clientMessageId()).isEqualTo(command.clientMessageId()); + } + + @Test + @DisplayName("채팅 메시지 전송: 브로드캐스트 수신") + void sendMessage_broadcastsMessage() throws Exception { + StompSession session = connect(); + CompletableFuture messageFuture = new CompletableFuture<>(); + + session.subscribe(ROOM_TOPIC_PREFIX + ROOM_ID, new StompFrameHandler() { + @Override + public @NonNull Type getPayloadType(@NonNull StompHeaders headers) { + return ChatMessageCreatedEvent.class; + } + + @Override + public void handleFrame(@NonNull StompHeaders headers, @Nullable Object payload) { + messageFuture.complete((ChatMessageCreatedEvent) payload); + } + }); + waitForSubscriptions(); + + ChatSendMessageCommand command = createCommand(ChatMessageType.TEXT, "안녕하세요!", null, CLIENT_MESSAGE_ID); + session.send(APP_SEND_DESTINATION, Objects.requireNonNull(command)); + + ChatMessageCreatedEvent event = messageFuture.get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertThat(event.roomId()).isEqualTo(ROOM_ID); + assertThat(event.message().messageType()).isEqualTo(ChatMessageType.TEXT); + } + + @Test + @DisplayName("이미지 메시지 전송: 브로드캐스트 수신") + void sendImageMessage_broadcastsMessage() throws Exception { + StompSession session = connect(); + CompletableFuture messageFuture = new CompletableFuture<>(); + + session.subscribe(ROOM_TOPIC_PREFIX + ROOM_ID, new StompFrameHandler() { + @Override + public @NonNull Type getPayloadType(@NonNull StompHeaders headers) { + return ChatMessageCreatedEvent.class; + } + + @Override + public void handleFrame(@NonNull StompHeaders headers, @Nullable Object payload) { + messageFuture.complete((ChatMessageCreatedEvent) payload); + } + }); + waitForSubscriptions(); + + ChatSendMessageCommand command = createCommand(ChatMessageType.IMAGE, null, ATTACHMENT_ID, IMAGE_CLIENT_MESSAGE_ID); + session.send(APP_SEND_DESTINATION, Objects.requireNonNull(command)); + + ChatMessageCreatedEvent event = messageFuture.get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertThat(event.message().messageType()).isEqualTo(ChatMessageType.IMAGE); + assertThat(event.message().attachment()).isNotNull(); + } + + @Test + @DisplayName("파일 메시지 전송: 브로드캐스트 수신") + void sendFileMessage_broadcastsMessage() throws Exception { + StompSession session = connect(); + CompletableFuture messageFuture = new CompletableFuture<>(); + + session.subscribe(ROOM_TOPIC_PREFIX + ROOM_ID, new StompFrameHandler() { + @Override + public @NonNull Type getPayloadType(@NonNull StompHeaders headers) { + return ChatMessageCreatedEvent.class; + } + + @Override + public void handleFrame(@NonNull StompHeaders headers, @Nullable Object payload) { + messageFuture.complete((ChatMessageCreatedEvent) payload); + } + }); + waitForSubscriptions(); + + ChatSendMessageCommand command = createCommand(ChatMessageType.FILE, null, ATTACHMENT_ID, FILE_CLIENT_MESSAGE_ID); + session.send(APP_SEND_DESTINATION, Objects.requireNonNull(command)); + + ChatMessageCreatedEvent event = messageFuture.get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertThat(event.message().messageType()).isEqualTo(ChatMessageType.FILE); + assertThat(event.message().attachment()).isNotNull(); + } + + private StompSession connect() throws Exception { + return stompClient + .connectAsync("ws://localhost:" + port + WS_ENDPOINT, new StompSessionHandlerAdapter() { + }) + .get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + } + + private @NonNull ChatSendMessageCommand createCommand( + ChatMessageType messageType, + String content, + Long attachmentId, + String clientMessageId + ) { + return new ChatSendMessageCommand( + ROOM_ID, + messageType, + content, + attachmentId, + clientMessageId + ); + } + + private void waitForSubscriptions() throws InterruptedException { + TimeUnit.MILLISECONDS.sleep(SUBSCRIBE_WAIT_MILLIS); + } + + @TestConfiguration + static class WebSocketTestConfig { + + @Bean + HandshakeHandler handshakeHandler() { + return new DefaultHandshakeHandler() { + @Override + protected Principal determineUser( + @NonNull ServerHttpRequest request, + @NonNull WebSocketHandler wsHandler, + @NonNull Map attributes + ) { + return () -> "test-user"; + } + }; + } + } +} From 69c70ba5b36528c1459480b7af4590ff4984dec2 Mon Sep 17 00:00:00 2001 From: Park-JiYeong <89844427+ParkJiYeoung8297@users.noreply.github.com> Date: Mon, 19 Jan 2026 15:05:23 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix(#7):=20CD=EC=97=90=EC=84=9C=20image=20p?= =?UTF-8?q?ull=20=EB=B0=A9=EC=8B=9D=EC=9C=BC=EB=A1=9C=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=20(#18)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/cd-prod.yml | 16 ++++++++-------- docker-compose.yaml | 7 +++---- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/.github/workflows/cd-prod.yml b/.github/workflows/cd-prod.yml index 3828ee47..8f02caee 100644 --- a/.github/workflows/cd-prod.yml +++ b/.github/workflows/cd-prod.yml @@ -35,6 +35,7 @@ jobs: with: context: . file: Docker/Dockerfile + platforms: linux/amd64 push: true tags: | ${{ secrets.DOCKERHUB_USERNAME }}/realmatch-backend:prod @@ -48,16 +49,15 @@ jobs: key: ${{ secrets.PROD_SERVER_SSH_KEY }} script: | cd /home/ubuntu/realmatch - git pull origin main - docker compose pull - docker compose up -d - + docker-compose pull + docker-compose up -d + echo "Waiting for app to start..." sleep 10 - - if ! docker compose ps | grep "Up"; then + + if ! docker-compose ps | grep "Up"; then echo "❌ Container is not running" - docker compose logs + docker-compose logs exit 1 - fi \ No newline at end of file + fi diff --git a/docker-compose.yaml b/docker-compose.yaml index a2d2f9df..12164669 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -25,12 +25,11 @@ services: - "6379:6379" app: - build: - context: . - dockerfile: Docker/Dockerfile + image: ${DOCKERHUB_USERNAME}/realmatch-backend:prod container_name: spring_app + restart: always ports: - - "6000:6000" + - "8080:6000" depends_on: - db - redis