-
Notifications
You must be signed in to change notification settings - Fork 0
feat: 채팅 이미지 업로드 API 구현 (presign/confirm) #88
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,65 @@ | ||
| package com.leets.tdd.chat.controller; | ||
|
|
||
| import com.leets.tdd.chat.dto.ChatImageConfirmRequest; | ||
| import com.leets.tdd.chat.dto.ChatImagePresignRequest; | ||
| import com.leets.tdd.chat.dto.ChatImagePresignResponse; | ||
| import com.leets.tdd.chat.service.ChatImageService; | ||
| import com.leets.tdd.global.common.ApiResponse; | ||
| import com.leets.tdd.global.jwt.UserPrincipal; | ||
| import io.swagger.v3.oas.annotations.Operation; | ||
| import io.swagger.v3.oas.annotations.security.SecurityRequirement; | ||
| import jakarta.validation.Valid; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.http.ResponseEntity; | ||
| import org.springframework.security.core.annotation.AuthenticationPrincipal; | ||
| 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.RestController; | ||
|
|
||
| @RestController | ||
| @RequestMapping("/api/v1/parties/{partyId}/chat/images") | ||
| @RequiredArgsConstructor | ||
| public class ChatImageController { | ||
|
|
||
| private final ChatImageService chatImageService; | ||
|
|
||
| @Operation( | ||
| summary = "채팅 이미지 업로드 1단계(업로드 URL 발급)", | ||
| description = "채팅 이미지를 올릴 Presigned PUT URL과 key를 발급한다(비공개 버킷). 해당 팟의 참여자만 " | ||
| + "발급받을 수 있다. 응답으로 받은 uploadUrl로 브라우저가 S3에 직접 PUT한 뒤, 그 key로 " | ||
| + "확정(confirm) API를 호출해야 한다. 허용 형식은 JPEG/PNG/WEBP다. " | ||
| + "Authorization 헤더에 access token(Bearer)이 필요하다." | ||
| ) | ||
| @SecurityRequirement(name = "bearerAuth") | ||
| @PostMapping("/presign") | ||
| public ResponseEntity<ApiResponse<ChatImagePresignResponse>> presignUpload( | ||
| @PathVariable Long partyId, | ||
| @AuthenticationPrincipal UserPrincipal userPrincipal, | ||
| @Valid @RequestBody ChatImagePresignRequest request | ||
| ) { | ||
| ChatImagePresignResponse response = | ||
| chatImageService.presignUpload(partyId, userPrincipal.userId(), request); | ||
| return ResponseEntity.ok(ApiResponse.success("업로드 URL이 발급되었습니다.", response)); | ||
| } | ||
|
|
||
| @Operation( | ||
| summary = "채팅 이미지 업로드 2단계(업로드 확정)", | ||
| description = "브라우저가 S3에 직접 업로드를 마친 뒤 호출한다. 해당 팟의 참여자만 호출할 수 있다. " | ||
| + "서버가 실제로 올라간 객체의 용량/형식을 확인하고, 기준을 벗어나면 객체를 지우고 실패 " | ||
| + "처리한다. 확정에 성공하면 클라이언트는 받은 key로 { messageType: \"IMAGE\", imageUrl: key } " | ||
| + "형태의 메시지를 WebSocket(/app/parties/{partyId}/chat)으로 발행해 이미지 메시지를 전송한다. " | ||
| + "Authorization 헤더에 access token(Bearer)이 필요하다." | ||
| ) | ||
| @SecurityRequirement(name = "bearerAuth") | ||
| @PostMapping("/confirm") | ||
| public ResponseEntity<ApiResponse<Void>> confirmUpload( | ||
| @PathVariable Long partyId, | ||
| @AuthenticationPrincipal UserPrincipal userPrincipal, | ||
| @Valid @RequestBody ChatImageConfirmRequest request | ||
| ) { | ||
| chatImageService.confirmUpload(partyId, userPrincipal.userId(), request); | ||
| return ResponseEntity.ok(ApiResponse.success("이미지 업로드가 확정되었습니다.", null)); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| package com.leets.tdd.chat.dto; | ||
|
|
||
| import io.swagger.v3.oas.annotations.media.Schema; | ||
| import jakarta.validation.constraints.NotBlank; | ||
|
|
||
| @Schema(description = "채팅 이미지 업로드 확정 요청") | ||
| public record ChatImageConfirmRequest( | ||
| @Schema(description = "발급 단계에서 받은 S3 객체 key", | ||
| example = "chat/1/1f0a2c4e-1b3d-4f5a-8c9d-0e1f2a3b4c5d.jpg") | ||
| @NotBlank(message = "key는 필수입니다.") | ||
| String key | ||
| ) { | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| package com.leets.tdd.chat.dto; | ||
|
|
||
| import io.swagger.v3.oas.annotations.media.Schema; | ||
| import jakarta.validation.constraints.NotBlank; | ||
|
|
||
| @Schema(description = "채팅 이미지 업로드 URL 발급 요청") | ||
| public record ChatImagePresignRequest( | ||
| @Schema(description = "업로드할 이미지의 Content-Type", example = "image/jpeg") | ||
| @NotBlank(message = "contentType은 필수입니다.") | ||
| String contentType | ||
| ) { | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| package com.leets.tdd.chat.dto; | ||
|
|
||
| import com.leets.tdd.global.storage.dto.PresignedUploadResponse; | ||
| import io.swagger.v3.oas.annotations.media.Schema; | ||
|
|
||
| @Schema(description = "채팅 이미지 업로드 URL 발급 결과") | ||
| public record ChatImagePresignResponse( | ||
| @Schema(description = "확정(confirm) 및 IMAGE 메시지 발행 시 사용할 S3 객체 key", | ||
| example = "chat/1/1f0a2c4e-1b3d-4f5a-8c9d-0e1f2a3b4c5d.jpg") | ||
| String key, | ||
|
|
||
| @Schema(description = "브라우저가 이미지를 직접 PUT할 URL") | ||
| String uploadUrl, | ||
|
|
||
| @Schema(description = "업로드 시 Content-Type 헤더에 그대로 넣어야 하는 값", example = "image/jpeg") | ||
| String contentType | ||
| ) { | ||
| public static ChatImagePresignResponse from(PresignedUploadResponse presigned) { | ||
| return new ChatImagePresignResponse( | ||
| presigned.key(), | ||
| presigned.uploadUrl(), | ||
| presigned.contentType() | ||
| ); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| package com.leets.tdd.chat.service; | ||
|
|
||
| import com.leets.tdd.chat.dto.ChatImageConfirmRequest; | ||
| import com.leets.tdd.chat.dto.ChatImagePresignRequest; | ||
| import com.leets.tdd.chat.dto.ChatImagePresignResponse; | ||
| import com.leets.tdd.global.storage.ImageCategory; | ||
| import com.leets.tdd.global.storage.ImageStorageService; | ||
| import com.leets.tdd.global.storage.dto.PresignedUploadResponse; | ||
| import com.leets.tdd.party.domain.PartyParticipantStatus; | ||
| import com.leets.tdd.party.repository.PartyParticipantRepository; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.stereotype.Service; | ||
| import org.springframework.transaction.annotation.Transactional; | ||
|
|
||
| @Service | ||
| @RequiredArgsConstructor | ||
| public class ChatImageService { | ||
|
|
||
| private final ImageStorageService imageStorageService; | ||
| private final PartyParticipantRepository partyParticipantRepository; | ||
|
|
||
| /** | ||
| * 채팅 이미지 업로드 1단계(발급). 발급 전에 로그인 사용자가 해당 팟(partyId)의 참여자(JOINED)인지 | ||
| * 검증한다 - key에 적힌 partyId를 권한 근거로 삼지 않기 위해, 요청 사용자가 실제 참여자인지 | ||
| * DB로 확인한다. 통과하면 S3에 직접 올릴 key와 Presigned PUT URL을 발급한다(DB는 건드리지 않음). | ||
| */ | ||
| @Transactional(readOnly = true) | ||
| public ChatImagePresignResponse presignUpload(Long partyId, Long userId, ChatImagePresignRequest request) { | ||
| validateParticipant(partyId, userId); | ||
|
|
||
| PresignedUploadResponse presigned = | ||
| imageStorageService.issueUploadUrl(ImageCategory.CHAT, partyId, request.contentType()); | ||
|
|
||
| return ChatImagePresignResponse.from(presigned); | ||
| } | ||
|
|
||
| /** | ||
| * 채팅 이미지 업로드 2단계(확정). 참여자 검증 후, 실제로 업로드된 객체의 용량/형식이 기준 안에 | ||
| * 있는지 검증한다(위반 시 객체 삭제 + 예외). 검증만 하고 메시지는 저장하지 않는다 - 확정 통과 후 | ||
| * 클라이언트가 그 key로 IMAGE 메시지를 WebSocket으로 발행하면 기존 ChatService.saveMessage가 | ||
| * 저장·브로드캐스트한다. | ||
| */ | ||
| @Transactional(readOnly = true) | ||
| public void confirmUpload(Long partyId, Long userId, ChatImageConfirmRequest request) { | ||
| validateParticipant(partyId, userId); | ||
|
|
||
| // 용량/형식 기준을 벗어나면 confirmUpload가 ImageException을 던지면서 객체도 함께 지운다. | ||
| imageStorageService.confirmUpload(request.key()); | ||
| } | ||
|
|
||
| /** | ||
| * 로그인 사용자가 해당 팟에 JOINED 상태로 참여 중인지 확인한다. 아니면 접근을 거부한다. | ||
| * (배달팟 참여(join) API가 아직 없어 실제 참여자 데이터로의 통과 검증은 join API 머지 후 가능하지만, | ||
| * 검증 로직 자체는 여기서 수행한다.) | ||
| */ | ||
| private void validateParticipant(Long partyId, Long userId) { | ||
| boolean isParticipant = partyParticipantRepository | ||
| .existsByPartyIdAndUserIdAndStatus(partyId, userId, PartyParticipantStatus.JOINED); | ||
| if (!isParticipant) { | ||
| throw new IllegalArgumentException("해당 팟의 참여자만 채팅 이미지를 업로드할 수 있습니다."); | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,99 @@ | ||||||||||||||||||||||||||||||||||||||
| package com.leets.tdd.chat.service; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| import com.leets.tdd.chat.dto.ChatImageConfirmRequest; | ||||||||||||||||||||||||||||||||||||||
| import com.leets.tdd.chat.dto.ChatImagePresignRequest; | ||||||||||||||||||||||||||||||||||||||
| import com.leets.tdd.chat.dto.ChatImagePresignResponse; | ||||||||||||||||||||||||||||||||||||||
| import com.leets.tdd.global.storage.ImageCategory; | ||||||||||||||||||||||||||||||||||||||
| import com.leets.tdd.global.storage.ImageStorageService; | ||||||||||||||||||||||||||||||||||||||
| import com.leets.tdd.global.storage.dto.PresignedUploadResponse; | ||||||||||||||||||||||||||||||||||||||
| import com.leets.tdd.party.domain.PartyParticipantStatus; | ||||||||||||||||||||||||||||||||||||||
| import com.leets.tdd.party.repository.PartyParticipantRepository; | ||||||||||||||||||||||||||||||||||||||
| 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 static org.assertj.core.api.Assertions.assertThat; | ||||||||||||||||||||||||||||||||||||||
| import static org.assertj.core.api.Assertions.assertThatThrownBy; | ||||||||||||||||||||||||||||||||||||||
| import static org.mockito.ArgumentMatchers.anyLong; | ||||||||||||||||||||||||||||||||||||||
| import static org.mockito.ArgumentMatchers.anyString; | ||||||||||||||||||||||||||||||||||||||
| import static org.mockito.ArgumentMatchers.eq; | ||||||||||||||||||||||||||||||||||||||
| import static org.mockito.Mockito.never; | ||||||||||||||||||||||||||||||||||||||
| import static org.mockito.Mockito.verify; | ||||||||||||||||||||||||||||||||||||||
| import static org.mockito.Mockito.when; | ||||||||||||||||||||||||||||||||||||||
| import static org.mockito.ArgumentMatchers.any; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| @ExtendWith(MockitoExtension.class) | ||||||||||||||||||||||||||||||||||||||
| class ChatImageServiceTest { | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| @Mock | ||||||||||||||||||||||||||||||||||||||
| private ImageStorageService imageStorageService; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| @Mock | ||||||||||||||||||||||||||||||||||||||
| private PartyParticipantRepository partyParticipantRepository; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| @InjectMocks | ||||||||||||||||||||||||||||||||||||||
| private ChatImageService chatImageService; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| private static final Long PARTY_ID = 1L; | ||||||||||||||||||||||||||||||||||||||
| private static final Long USER_ID = 10L; | ||||||||||||||||||||||||||||||||||||||
| private static final String CHAT_KEY = "chat/1/1f0a2c4e-1b3d-4f5a-8c9d-0e1f2a3b4c5d.jpg"; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| // 참여자 여부 stub 헬퍼 | ||||||||||||||||||||||||||||||||||||||
| private void givenParticipant(boolean joined) { | ||||||||||||||||||||||||||||||||||||||
| when(partyParticipantRepository.existsByPartyIdAndUserIdAndStatus( | ||||||||||||||||||||||||||||||||||||||
| PARTY_ID, USER_ID, PartyParticipantStatus.JOINED)) | ||||||||||||||||||||||||||||||||||||||
| .thenReturn(joined); | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| @Test | ||||||||||||||||||||||||||||||||||||||
| @DisplayName("팟 참여자가 발급을 요청하면 key와 업로드 URL을 반환한다") | ||||||||||||||||||||||||||||||||||||||
| void presign_participant_returnsUrl() { | ||||||||||||||||||||||||||||||||||||||
| givenParticipant(true); | ||||||||||||||||||||||||||||||||||||||
| when(imageStorageService.issueUploadUrl(eq(ImageCategory.CHAT), eq(PARTY_ID), anyString())) | ||||||||||||||||||||||||||||||||||||||
| .thenReturn(new PresignedUploadResponse(CHAT_KEY, "https://s3.example.com/put", "image/jpeg", 300)); | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| ChatImagePresignResponse response = | ||||||||||||||||||||||||||||||||||||||
| chatImageService.presignUpload(PARTY_ID, USER_ID, new ChatImagePresignRequest("image/jpeg")); | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| assertThat(response.key()).isEqualTo(CHAT_KEY); | ||||||||||||||||||||||||||||||||||||||
| assertThat(response.uploadUrl()).isEqualTo("https://s3.example.com/put"); | ||||||||||||||||||||||||||||||||||||||
| verify(imageStorageService).issueUploadUrl(eq(ImageCategory.CHAT), eq(PARTY_ID), anyString()); | ||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+55
to
+63
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Assert the exact content type passed to storage. Using Proposed fix- when(imageStorageService.issueUploadUrl(eq(ImageCategory.CHAT), eq(PARTY_ID), anyString()))
+ when(imageStorageService.issueUploadUrl(eq(ImageCategory.CHAT), eq(PARTY_ID), eq("image/jpeg")))
...
- verify(imageStorageService).issueUploadUrl(eq(ImageCategory.CHAT), eq(PARTY_ID), anyString());
+ verify(imageStorageService).issueUploadUrl(eq(ImageCategory.CHAT), eq(PARTY_ID), eq("image/jpeg"));📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| @Test | ||||||||||||||||||||||||||||||||||||||
| @DisplayName("팟 참여자가 아니면 발급이 거부되고 S3를 호출하지 않는다") | ||||||||||||||||||||||||||||||||||||||
| void presign_nonParticipant_throws() { | ||||||||||||||||||||||||||||||||||||||
| givenParticipant(false); | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| assertThatThrownBy(() -> | ||||||||||||||||||||||||||||||||||||||
| chatImageService.presignUpload(PARTY_ID, USER_ID, new ChatImagePresignRequest("image/jpeg"))) | ||||||||||||||||||||||||||||||||||||||
| .isInstanceOf(IllegalArgumentException.class); | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| verify(imageStorageService, never()).issueUploadUrl(any(), anyLong(), anyString()); | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| @Test | ||||||||||||||||||||||||||||||||||||||
| @DisplayName("팟 참여자가 확정을 요청하면 업로드 객체를 검증한다") | ||||||||||||||||||||||||||||||||||||||
| void confirm_participant_verifiesUpload() { | ||||||||||||||||||||||||||||||||||||||
| givenParticipant(true); | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| chatImageService.confirmUpload(PARTY_ID, USER_ID, new ChatImageConfirmRequest(CHAT_KEY)); | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| verify(imageStorageService).confirmUpload(CHAT_KEY); | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| @Test | ||||||||||||||||||||||||||||||||||||||
| @DisplayName("팟 참여자가 아니면 확정이 거부되고 검증을 호출하지 않는다") | ||||||||||||||||||||||||||||||||||||||
| void confirm_nonParticipant_throws() { | ||||||||||||||||||||||||||||||||||||||
| givenParticipant(false); | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| assertThatThrownBy(() -> | ||||||||||||||||||||||||||||||||||||||
| chatImageService.confirmUpload(PARTY_ID, USER_ID, new ChatImageConfirmRequest(CHAT_KEY))) | ||||||||||||||||||||||||||||||||||||||
| .isInstanceOf(IllegalArgumentException.class); | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| verify(imageStorageService, never()).confirmUpload(anyString()); | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
Uh oh!
There was an error while loading. Please reload this page.