From 46a49ac9f49d2abbdfbca532381efe01f3e127af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=9E=AC=EC=A4=91?= <126754298+m-a-king@users.noreply.github.com> Date: Sat, 22 Aug 2026 21:27:19 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=20v1(multipart)=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 클라(웹)가 v2 로 전환을 마쳐 서버가 바이트를 받을 이유가 없어졌다. 앱은 react-native-webview 래퍼라 웹 배포와 함께 넘어가므로 스토어 배포를 기다리지 않는다 - 삭제 시점은 prod 실측으로 확정했다. 처음 확인 때는 v1 이 2일간 5회 호출되고 v2 는 0회라 보류했고, 클라 배포 후 재확인해 v2 호출과 v1 중단(마지막 2026-08-20 15:19 UTC)을 보고 진행했다 - v1 은 재시도마다 새 UUID raw key 를 만들어, 응답 유실 후 재시도가 중복 위시를 만들고 등록 한도도 이중 차감했다. v2 는 imageKey 가 요청의 자연 키라 claim 이 한쪽만 등록한다 - ImagePresignService.deleteRawsQuietly 도 함께 삭제했다. v1 전용이라 호출부가 통째로 사라졌고, v2 는 raw 를 클라가 올리므로 서버가 회수하지 않고 폴링·lifecycle 에 맡긴다 - v1 테스트 중 v2 에 없던 검증 5개(다건 READY 전이·이벤트 1회 발행·CLONE 403·정원 32 초과·정원 동시성)는 v2 경로로 이관했다 - multipart 전용 검증(파트 미전송·빈 바이트·raw 업로드와 회수)은 v2 에 개념이 없어 함께 소멸시켰다. 동시성 테스트의 raw 회수 단언 자리에는 거부된 요청의 pending 매핑이 롤백으로 남는지를 넣어 같은 성질을 지킨다 --- .../common/storage/ImageStorageErrorCode.kt | 6 +- .../dto/ConfirmImageUploadRequest.kt | 2 +- .../dto/PresignedImageUploadRequest.kt | 2 +- .../piki/image/service/ImagePresignService.kt | 15 +- .../controller/TournamentItemApi.kt | 136 +--------------- .../controller/TournamentItemApiExamples.kt | 28 ---- .../controller/TournamentItemController.kt | 16 +- .../TournamentItemPersistenceService.kt | 10 +- .../service/TournamentItemService.kt | 37 +---- .../piki/user/service/ProfileUpdateService.kt | 2 +- .../piki/wishlist/controller/WishlistApi.kt | 125 +------------- .../controller/WishlistApiExamples.kt | 20 --- .../wishlist/controller/WishlistController.kt | 18 +-- .../service/WishPersistenceService.kt | 17 +- .../piki/wishlist/service/WishlistService.kt | 39 +---- .../AuthorizationBoundaryIntegrationTest.kt | 3 +- .../controller/TournamentIntegrationTest.kt | 152 +++++++----------- ...tItemImageAddConcurrencyIntegrationTest.kt | 75 +++++---- .../TournamentMatchIntegrationTest.kt | 21 ++- .../controller/WishlistCrudIntegrationTest.kt | 85 ---------- .../WishlistRegisterAsyncIntegrationTest.kt | 69 +++++--- 21 files changed, 199 insertions(+), 679 deletions(-) diff --git a/src/main/kotlin/com/depromeet/piki/common/storage/ImageStorageErrorCode.kt b/src/main/kotlin/com/depromeet/piki/common/storage/ImageStorageErrorCode.kt index 63bfb444a..8c400b759 100644 --- a/src/main/kotlin/com/depromeet/piki/common/storage/ImageStorageErrorCode.kt +++ b/src/main/kotlin/com/depromeet/piki/common/storage/ImageStorageErrorCode.kt @@ -23,10 +23,10 @@ enum class ImageStorageErrorCode( PRESIGN_FAILED("STORAGE-002", ErrorCategory.RETRYABLE, "이미지 업로드 URL 을 발급하지 못했어요. 잠시 후 다시 시도해 주세요."), EXISTS_CHECK_FAILED("STORAGE-003", ErrorCategory.RETRYABLE, "이미지 업로드 상태를 확인하지 못했어요. 잠시 후 다시 시도해 주세요."), - // ⚠️ 이 하나만 ErrorCodeRegistry 에 등록하지 않는다. 삭제 실패는 호출부 세 곳이 전부 runCatching 으로 + // ⚠️ 이 하나만 ErrorCodeRegistry 에 등록하지 않는다. 삭제 실패는 호출부가 전부 runCatching 으로 // 삼키고 warn 로그만 남긴다 — 탈퇴 시 프로필 파기(WithdrawalService)·공지 이미지 정리(AdminAnnouncementService)는 - // 본 작업을 성공 처리하고 후속 정리 대상으로 넘기며, raw 회수(deleteRawsQuietly)는 원래 예외를 덮지 않으려 - // 삼킨다. 따라서 GlobalExceptionHandler 에 닿지 않아 wire code 로 나갈 수 없고, 클라가 절대 못 받는 code 를 + // 본 작업을 성공 처리하고 후속 정리 대상으로 넘긴다. + // 따라서 GlobalExceptionHandler 에 닿지 않아 wire code 로 나갈 수 없고, 클라가 절대 못 받는 code 를 // 공개 카탈로그에 두면 code→문구 매핑에 노이즈만 된다(SNAPSHOT·EXTRACTOR 미등록과 같은 기준). // 그럼에도 엔트리를 두는 이유는 S3ImageStorage 가 실제로 이 예외를 던지기 때문이다 — 예외 클래스 모양을 // 다른 도메인과 통일(errorCode 참조)하려면 참조할 code 가 하나 있어야 한다. diff --git a/src/main/kotlin/com/depromeet/piki/image/controller/dto/ConfirmImageUploadRequest.kt b/src/main/kotlin/com/depromeet/piki/image/controller/dto/ConfirmImageUploadRequest.kt index 644a25704..fa5415b70 100644 --- a/src/main/kotlin/com/depromeet/piki/image/controller/dto/ConfirmImageUploadRequest.kt +++ b/src/main/kotlin/com/depromeet/piki/image/controller/dto/ConfirmImageUploadRequest.kt @@ -3,7 +3,7 @@ package com.depromeet.piki.image.controller.dto import io.swagger.v3.oas.annotations.media.Schema // 이미지 등록 v2 확정 요청 — presigned 로 업로드를 마친 이미지 key 목록(발급 응답의 imageKey, 1~5개). -// 개수·형식·존재 검증은 서버가 하므로 Bean Validation 을 걸지 않는다(v1 multipart 경로와 대칭). +// 개수·형식·존재 검증은 서버가 도메인 계약으로 하므로 Bean Validation 을 걸지 않는다. @Schema(description = "이미지 등록 확정 요청") data class ConfirmImageUploadRequest( @field:Schema( diff --git a/src/main/kotlin/com/depromeet/piki/image/controller/dto/PresignedImageUploadRequest.kt b/src/main/kotlin/com/depromeet/piki/image/controller/dto/PresignedImageUploadRequest.kt index 8b4454917..41aaca435 100644 --- a/src/main/kotlin/com/depromeet/piki/image/controller/dto/PresignedImageUploadRequest.kt +++ b/src/main/kotlin/com/depromeet/piki/image/controller/dto/PresignedImageUploadRequest.kt @@ -3,7 +3,7 @@ package com.depromeet.piki.image.controller.dto import io.swagger.v3.oas.annotations.media.Schema // 이미지 등록 v2 presigned 발급 요청 — 올릴 이미지들의 content-type 목록(1~5개). -// 개수·형식 검증은 서버가 도메인 계약으로 하므로(v1 multipart 경로와 대칭) Bean Validation 을 걸지 않는다. +// 개수·형식 검증은 서버가 도메인 계약으로 하므로 Bean Validation 을 걸지 않는다. @Schema(description = "presigned 업로드 URL 발급 요청") data class PresignedImageUploadRequest( @field:Schema( diff --git a/src/main/kotlin/com/depromeet/piki/image/service/ImagePresignService.kt b/src/main/kotlin/com/depromeet/piki/image/service/ImagePresignService.kt index b2b63e07b..9a6a03dcb 100644 --- a/src/main/kotlin/com/depromeet/piki/image/service/ImagePresignService.kt +++ b/src/main/kotlin/com/depromeet/piki/image/service/ImagePresignService.kt @@ -14,12 +14,13 @@ import java.time.Duration import java.time.LocalDateTime import java.util.UUID -// 이미지 등록 v2 의 공통 presigned 업로드 프리미티브 — 위시·토너먼트가 권한 검증 후 위임한다. +// 이미지 등록의 공통 presigned 업로드 프리미티브 — 위시·토너먼트가 권한 검증 후 위임한다. // 발급: content-type 을 검증해 raw key(items/raw/{UUID}.{ext})를 만들고, 클라가 서버를 거치지 않고 S3 에 직접 PUT 할 // presigned URL 을 준다. 발급된 key 는 pending_uploads 에 맥락과 함께 커밋해, confirm 이 안 와도 폴링 백스톱이 // S3 존재를 확인해 등록할 수 있게 한다(클라 신호에 의존하지 않는 at-least-once). // 확정 검증: 클라가 되돌려준 key 가 우리 발급 형식인지 + 실제로 S3 에 올라왔는지(HEAD) 확인한다. -// raw 회수: item 에 매이지 못한 raw 를 best-effort 로 삭제한다(v1 경로가 거부·실패 시 사용). +// raw 회수는 두지 않는다 — raw 를 올린 주체가 클라이고, 등록에 매이지 못한 raw 는 폴링이 pending 매핑을 정리한 뒤 +// items/raw/ S3 lifecycle 이 만료시킨다. // 개수 검증(1~5)은 도메인 계약이라 호출부(위시=member, 토너먼트=참여자·상태)가 각자 담당한다 — 여기선 형식·존재만 본다. @Service class ImagePresignService( @@ -61,16 +62,6 @@ class ImagePresignService( } } - // item 에 매이지 못한 raw(거부·실패 등록의 orphan)를 best-effort 로 회수한다 — v1(multipart) 경로가 persist 거부·실패 시 부른다. - // 삭제 실패가 원래 예외(클라이언트로 나갈 사유)를 덮지 않게 runCatching 으로 삼키고 경고만 남긴다. 회수 못 한 raw 는 - // items/raw/ S3 lifecycle 이 백업으로 만료한다. - fun deleteRawsQuietly(imageKeys: List) { - imageKeys.forEach { key -> - runCatching { imageStorage.delete(key) } - .onFailure { e -> log.warn("raw {} 회수 실패(lifecycle 이 만료): {}", key, e.message) } - } - } - companion object { const val RAW_PREFIX = "items/raw/" diff --git a/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentItemApi.kt b/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentItemApi.kt index 1b15036ce..8e9ac3c08 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentItemApi.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentItemApi.kt @@ -20,7 +20,6 @@ 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 org.springframework.http.MediaType -import org.springframework.web.multipart.MultipartFile import java.util.UUID // 아이템 등록 한도(#339) 응답 설명. 이 축의 한도는 요청자가 아니라 **토너먼트 오너**의 몫에서 깎이므로, @@ -306,138 +305,11 @@ interface TournamentItemApi { request: AddTournamentItemFromLinkRequest, ): ApiResponseBody - @Operation( - summary = "이미지로 토너먼트 아이템 추가", - description = """ - PENDING 상태의 토너먼트에 이미지 추출을 통해 아이템을 추가한다. - 플레이 링크로 생성된 복제 토너먼트에는 추가 불가. 토너먼트 참여자만 추가할 수 있다. - 이미지 1~5장을 전달하면 아이템이 PENDING 상태로 즉시 생성되어(link 처럼 작업 큐 적재) tournamentItemIds 가 반환된다. - 이미지 파싱은 비동기로 진행되며 완료 시 READY 또는 FAILED 상태로 전환된다. - 클라이언트는 SSE(`/api/v1/notifications/subscribe`)로 파싱 완료·실패를 통보받고, tournamentItemId 로 GET /tournaments/{id}/items/{tournamentItemId} 를 조회해 결과를 확인한다. - """, - ) - @ApiResponses( - value = [ - ApiResponse( - responseCode = "200", - description = "아이템 추가 성공 (item.status=PENDING, 파싱은 백그라운드)", - content = [ - Content( - mediaType = MediaType.APPLICATION_JSON_VALUE, - schema = Schema(implementation = ApiResponseBody::class), - ), - ], - ), - ApiResponse( - responseCode = "400", - description = - "잘못된 요청 (이미지 1~5장 범위 초과 · 빈 이미지 — code: PRODUCTIMAGE-001 · " + - "이미지 타입 미지정 — code: PRODUCTIMAGE-002 · " + - "지원하지 않는 이미지 형식(png/jpeg/webp/heic/heif만 허용) — code: PRODUCTIMAGE-003 · 아이템 최대 32개 초과)", - content = [ - Content( - mediaType = MediaType.APPLICATION_JSON_VALUE, - schema = Schema(implementation = ApiResponseBody::class), - ), - ], - ), - ApiResponse( - responseCode = "401", - description = "미인증 (JWT 토큰 없음 또는 유효하지 않음)", - content = [ - Content( - mediaType = MediaType.APPLICATION_JSON_VALUE, - schema = Schema(implementation = ApiResponseBody::class), - ), - ], - ), - ApiResponse( - responseCode = "403", - description = "권한 없음 (토너먼트 참여자가 아님 · 플레이 링크로 생성된 복제 토너먼트)", - content = [ - Content( - mediaType = MediaType.APPLICATION_JSON_VALUE, - schema = Schema(implementation = ApiResponseBody::class), - ), - ], - ), - ApiResponse( - responseCode = "404", - description = "토너먼트를 찾을 수 없음", - content = [ - Content( - mediaType = MediaType.APPLICATION_JSON_VALUE, - schema = Schema(implementation = ApiResponseBody::class), - ), - ], - ), - ApiResponse( - responseCode = "409", - description = "상태 충돌 (PENDING이 아닌 토너먼트)", - content = [ - Content( - mediaType = MediaType.APPLICATION_JSON_VALUE, - schema = Schema(implementation = ApiResponseBody::class), - ), - ], - ), - ApiResponse( - responseCode = "429", - description = TOURNAMENT_RATE_LIMIT_DESCRIPTION, - headers = [ - Header( - name = "Retry-After", - description = "한도가 풀리기까지 남은 시간(초). RFC 9110 delta-seconds.", - schema = Schema(type = "integer", format = "int64"), - ), - ], - content = [ - Content( - mediaType = MediaType.APPLICATION_JSON_VALUE, - schema = Schema(implementation = ApiResponseBody::class), - ), - ], - ), - ApiResponse( - responseCode = "502", - description = "이미지 저장 실패 (원본을 S3 에 적재하는 중 스토리지 장애 — 클라이언트는 재시도) — code: STORAGE-001", - content = [ - Content( - mediaType = MediaType.APPLICATION_JSON_VALUE, - schema = Schema(implementation = ApiResponseBody::class), - ), - ], - ), - ApiResponse( - responseCode = "503", - description = CAPACITY_DESCRIPTION, - headers = [ - Header( - name = "Retry-After", - description = "가용량이 회복되기까지 남은 시간(초). RFC 9110 delta-seconds.", - schema = Schema(type = "integer", format = "int64"), - ), - ], - content = [ - Content( - mediaType = MediaType.APPLICATION_JSON_VALUE, - schema = Schema(implementation = ApiResponseBody::class), - ), - ], - ), - ], - ) - fun addItemsFromImages( - @Parameter(hidden = true) userId: UUID, - @Parameter(description = "토너먼트 ID", example = "1") tournamentId: Long, - images: List?, - ): ApiResponseBody - @Operation( summary = "이미지로 토너먼트 아이템 추가 v2 - presigned 업로드 URL 발급", description = """ - 이미지 등록 v2 의 1단계. 올릴 이미지들의 content-type(1~5개)을 받아, 클라가 S3 에 직접 PUT 할 presigned URL 을 발급한다. - v1(multipart)이 서버로 바이트를 받아 S3 에 올리던 것을 클라→S3 직접 업로드로 바꿔 서버 대역·메모리를 아낀다. + 이미지 등록의 1단계. 올릴 이미지들의 content-type(1~5개)을 받아, 클라가 S3 에 직접 PUT 할 presigned URL 을 발급한다. + 원본 바이트가 서버를 경유하지 않아 서버 대역·메모리를 쓰지 않는다. 참여자·PENDING·비복제 권한을 사전 검증하며, 정원(최대 32개) 최종 판정은 저장이 일어나는 2단계(/images/confirm)로 미룬다. 클라는 각 uploadUrl 로 응답의 contentType 을 Content-Type 헤더에 실어 PUT 한 뒤, imageKey 들을 confirm 으로 되돌려준다. """, @@ -561,8 +433,8 @@ interface TournamentItemApi { @Operation( summary = "이미지로 토너먼트 아이템 추가 v2 - 업로드 확정", description = """ - 이미지 등록 v2 의 2단계. presigned 로 업로드를 마친 imageKey(1~5개)를 받아, 각 이미지를 PENDING 아이템으로 즉시 추가하고 tournamentItemIds 를 반환한다. - key 형식·실제 업로드 여부(S3 존재)를 검증한 뒤 v1 과 같은 작업 큐에 적재하며, 이후 파싱(Gemini Vision)·전이(READY/FAILED) 흐름은 v1 과 완전히 같다. + 이미지 등록의 2단계. presigned 로 업로드를 마친 imageKey(1~5개)를 받아, 각 이미지를 PENDING 아이템으로 즉시 추가하고 tournamentItemIds 를 반환한다. + key 형식·실제 업로드 여부(S3 존재)를 검증한 뒤 링크 추가와 같은 작업 큐에 적재하며, 이후 파싱(Gemini Vision)·전이(READY/FAILED) 흐름도 같다. 클라이언트는 SSE(`/api/v1/notifications/subscribe`)로 파싱 완료·실패를 통보받고, tournamentItemId 로 GET /tournaments/{id}/items/{tournamentItemId} 를 조회해 결과를 확인한다. """, ) diff --git a/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentItemApiExamples.kt b/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentItemApiExamples.kt index 70c179b00..742f7e85f 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentItemApiExamples.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentItemApiExamples.kt @@ -85,34 +85,6 @@ class TournamentItemApiExamples( add(capacityExceeded, name = "서비스 전체 가용량 소진") } - handlerMethod.binds(TournamentItemController::addItemsFromImages) -> - operation.examples(openApiObjectMapper.delegate) { - add( - status = HttpStatus.OK, - name = "이미지 아이템 추가 성공", - payload = - ApiResponseBody.ok( - AddTournamentItemsFromImagesResponse( - tournamentItemIds = listOf(1L, 2L, 3L), - ), - ), - ) - add(TournamentException.invalidImageCount(), name = "이미지 개수 위반 (1~5개)") - // ProductImage.of 의 형식 검증 3종 — S3 업로드 전에 동기로 거른다. - add(ProductImageException.emptyImage(), name = "빈 이미지 파일") - add(ProductImageException.unknownType(), name = "이미지 형식을 확인할 수 없음") - add(ProductImageException.unsupportedType(), name = "지원하지 않는 이미지 형식") - add(TournamentException.tooManyTournamentItems(), name = "아이템 최대 32개 초과") - unauthorized() - add(TournamentException.forbiddenTournament(), name = "토너먼트 권한 없음") - add(TournamentException.clonedTournamentCannotAddItems(), name = "플레이링크 복제 토너먼트에는 아이템 추가 불가") - add(TournamentException.notFoundTournament(), name = "토너먼트를 찾을 수 없음") - add(TournamentException.notPendingTournament(), name = "PENDING 상태 아님") - add(ImageStorageException.uploadFailed(), name = "이미지 저장 실패 (S3 업로드 장애)") - add(itemQuotaExceeded, name = "아이템 등록 한도 초과 (오너 몫에서 이미지 장수만큼 차감)") - add(capacityExceeded, name = "서비스 전체 가용량 소진") - } - handlerMethod.binds(TournamentItemController::presignImageUploads) -> operation.examples(openApiObjectMapper.delegate) { add( diff --git a/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentItemController.kt b/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentItemController.kt index 88ba73aa5..708c4e1c1 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentItemController.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentItemController.kt @@ -24,9 +24,7 @@ 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.RestController -import org.springframework.web.multipart.MultipartFile import java.util.UUID @RestController @@ -65,18 +63,6 @@ class TournamentItemController( return ApiResponseBody.ok(AddTournamentItemFromLinkResponse(tournamentItemId)) } - @PostMapping("/{tournamentId}/items/images", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) - override fun addItemsFromImages( - @AuthenticationPrincipal userId: UUID, - @PathVariable tournamentId: Long, - @RequestParam("images", required = false) images: List?, - ): ApiResponseBody { - // images 파트 미첨부(0장)는 Spring 이 진입 전 예외로 끊어 캐치올(500)로 가므로, - // required=false + orEmpty 로 항상 서비스 검증(invalidImageCount, 400)에 닿게 한다. - val tournamentItemIds = tournamentItemService.addItemsFromImages(userId, tournamentId, images.orEmpty()) - return ApiResponseBody.ok(AddTournamentItemsFromImagesResponse(tournamentItemIds)) - } - // 이미지 등록 v2 1단계 — presigned 발급. pending_uploads 에 발급 기록만 남기고 tournament_item 은 아직 만들지 않으므로 200 OK. @PostMapping("/{tournamentId}/items/images/presigned") override fun presignImageUploads( @@ -88,7 +74,7 @@ class TournamentItemController( return ApiResponseBody.ok(PresignedImageUploadResponse.from(uploads)) } - // 이미지 등록 v2 2단계 — 업로드 확정. v1(addItemsFromImages)과 같은 200 OK + tournamentItemIds. + // 이미지 등록 2단계 — 업로드 확정. 아이템이 실제로 추가되며 tournamentItemIds 를 200 으로 돌려준다. @PostMapping("/{tournamentId}/items/images/confirm") override fun confirmImageRegistration( @AuthenticationPrincipal userId: UUID, diff --git a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentItemPersistenceService.kt b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentItemPersistenceService.kt index 7264ae766..6ac7076bf 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentItemPersistenceService.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentItemPersistenceService.kt @@ -94,15 +94,7 @@ class TournamentItemPersistenceService( return PersistedTournamentItem(itemId = item.getId(), snapshotId = snapshot.getId(), tournamentItemId = tournamentItem.getId()) } - // v1(multipart) 이미지 아이템 추가 — 서버가 바이트를 받아 올린 뒤 pending 매핑 없이 바로 적재한다. - @Transactional - fun persistPendingImageItems( - userId: UUID, - tournamentId: Long, - imageKeys: List, - ): List = persistImageItemsInternal(userId, tournamentId, imageKeys) - - // v2 이미지 등록 — confirm 또는 폴링 백스톱이 "업로드 확인된" key 들을 등록한다. pending_uploads 를 FOR UPDATE 로 + // 이미지 등록 — confirm 또는 폴링 백스톱이 "업로드 확인된" key 들을 등록한다. pending_uploads 를 FOR UPDATE 로 // 잠가 삭제(claim)하고, claim 에 성공한 TOURNAMENT 매핑(해당 user·tournament)만 적재한다 — confirm·폴링이 같은 key 를 // 다퉈도 삭제는 한쪽만 성공하므로 중복 등록되지 않는다(멱등). 다른 맥락 매핑은 걸러낸다. @Transactional diff --git a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentItemService.kt b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentItemService.kt index 6fbf157fe..d5ec88075 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentItemService.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentItemService.kt @@ -66,34 +66,10 @@ class TournamentItemService( return persisted.tournamentItemId } - fun addItemsFromImages( - userId: UUID, - tournamentId: Long, - images: List, - ): List { - if (images.size !in MIN_IMAGE_COUNT..MAX_IMAGE_COUNT) throw TournamentException.invalidImageCount() - // 권한·상태·복제를 업로드 전에 미리 검증 — 거부될 요청이 S3 에 orphan raw 를 남기지 않게 한다(정원 동시성 최종 검증은 persist 의 FOR UPDATE). - tournamentItemPersistenceService.verifyCanAddItems(userId, tournamentId) - // 형식 검증(빈 바이트·미지원 MIME) — 실패 시 즉시 400. 유효한 이미지만 durable 적재한다. - val productImages = images.map { ProductImage.of(it.bytes, it.contentType) } - // 장마다 추출이 따로 도는 별개 item 이라 장수만큼 오너 몫에서 차감한다. S3 업로드 전에 둬서 거부될 요청이 raw 를 남기지 않게 한다. - itemQuotaGuard.consume(ownerIdOf(tournamentId), images.size, TournamentErrorCode.ITEM_QUOTA_EXCEEDED) - // 원본을 S3 raw 에 올려 입력을 durable 화한다(외부 호출, 트랜잭션 밖). 이 key 가 item 의 입력 정체성이 된다. - val imageKeys = productImages.map { uploadRaw(it) } - // 사전검증을 통과해도 정원은 persist 의 FOR UPDATE 가 최종 판정한다(동시 추가 race). 거기서 거부되면 방금 올린 raw 가 - // 어떤 item 에도 매이지 않은 orphan 으로 남고 워커가 영영 안 본다 — persist 실패 시 즉시 회수한다(best-effort, lifecycle 백업). - // 파싱·상태 전이는 item PK 를, 클라이언트 응답은 tournament_item PK 를 쓴다 (PersistedTournamentItem). - val persisted = - runCatching { tournamentItemPersistenceService.persistPendingImageItems(userId, tournamentId, imageKeys) } - .onFailure { imagePresignService.deleteRawsQuietly(imageKeys) } - .getOrThrow() - return persisted.map { it.tournamentItemId } - } - - // 이미지 등록 v2 발급 — 클라가 S3 에 직접 올릴 presigned URL 을 발급한다(위시 presignImageUploads 와 동일 패턴). - // v1(addItemsFromImages)이 서버로 바이트를 받아 S3 에 올리던 것을 클라→S3 직접 업로드로 바꿔 서버 대역·메모리를 아낀다. + // 이미지 등록 발급 — 클라가 S3 에 직접 올릴 presigned URL 을 발급한다(위시 presignImageUploads 와 동일 패턴). + // 원본 바이트가 서버 메모리·대역을 경유하지 않는다. // 개수·권한(참여자·PENDING·비복제)을 사전 검증하고, content-type 검증·raw key 생성·presign 은 ImagePresignService 에 위임한다. - // 발급은 pending_uploads 매핑만 남기고 tournament_item 을 만들지 않으므로 정원 최종 판정(persist 의 FOR UPDATE)은 confirm 으로 미룬다 — 여기선 사전 권한만 본다(v1 대칭). + // 발급은 pending_uploads 매핑만 남기고 tournament_item 을 만들지 않으므로 정원 최종 판정(persist 의 FOR UPDATE)은 confirm 으로 미룬다 — 여기선 사전 권한만 본다. fun presignImageUploads( userId: UUID, tournamentId: Long, @@ -130,13 +106,6 @@ class TournamentItemService( .map { it.tournamentItemId } } - // 원본 이미지를 S3 raw prefix 에 올리고 그 object key 를 돌려준다(워커가 download(key)로 다시 읽는다). 파싱이 끝나면 워커가 회수한다. - private fun uploadRaw(image: ProductImage): String { - val key = "items/raw/${UUID.randomUUID()}.${image.extension}" - imageStorage.upload(image.bytes, key, image.mimeType) - return key - } - // recoverWishItem 과 동일한 패턴(#825 결정 4) — 수기 수정은 상태 무관 허용이며 MANUAL 새 버전 + pin 이동으로 // 영속화한다(manualEdit). 이미지 형식 검증 후 S3 업로드는 트랜잭션 밖에서, 권한 검증·적재는 manualEdit 에 위임한다. // S3 업로드 전 병합 결과 필수 필드를 사전 확인해 orphan 업로드를 방지한다(최종 판정은 manualEdit). diff --git a/src/main/kotlin/com/depromeet/piki/user/service/ProfileUpdateService.kt b/src/main/kotlin/com/depromeet/piki/user/service/ProfileUpdateService.kt index 0a90a9309..3010ed733 100644 --- a/src/main/kotlin/com/depromeet/piki/user/service/ProfileUpdateService.kt +++ b/src/main/kotlin/com/depromeet/piki/user/service/ProfileUpdateService.kt @@ -39,7 +39,7 @@ class ProfileUpdateService( // 사이에 탈퇴가 커밋되면 탈퇴 cascade 의 prefix 파기가 이미 지나간 뒤라 프로필 사진(얼굴 등 PII)이 계속 남는다. // upload 가 던진 경우도 포함한다 — 응답 유실·timeout 이면 S3 에는 객체가 올라갔을 수 있다. key 는 우리가 // 만든 값이라 업로드 성공 여부와 무관하게 삭제를 걸 수 있고, 객체가 없으면 no-op 이라 안전하다. - // (registerFromImages 의 raw 회수와 같은 패턴.) + // (서버가 올린 객체는 서버가 회수한다는 원칙 — 등록 이미지는 클라가 올리므로 lifecycle 에 맡긴다.) return runCatching { val url = imageStorage.upload(profileImage.bytes, key, profileImage.mimeType) // 트랜잭션 밖, 실패 시 502 log.info("프로필 이미지 업로드 완료: userId={}, key={}", userId, key) diff --git a/src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistApi.kt b/src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistApi.kt index 6443e3e87..8c439a942 100644 --- a/src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistApi.kt +++ b/src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistApi.kt @@ -644,126 +644,11 @@ interface WishlistApi { ids: List?, ): ApiResponseBody - @Operation( - summary = "위시리스트 등록 (이미지)", - description = """ - 상품 페이지를 캡처한 이미지 1~5장을 받아, 각 이미지를 PENDING 상태의 위시 항목으로 즉시 등록하고(link 처럼 작업 큐 적재) 목록을 반환한다. - 실제 상품 정보 추출(Gemini Vision)은 백그라운드에서 비동기로 진행되어 각 항목을 READY 또는 FAILED 로 전이시킨다. - URL 등록과 결과 모양(WishItemResponse)이 같다. 이미지 등록 항목은 URL 이 없어 sourceUrl 이 null 이며, - 추출 결과는 SSE(`/api/v1/notifications/subscribe`)로 완료·실패를 통보받아 재조회하며, 추출 실패(FAILED) 항목은 보정 API(PATCH)로 직접 채워 복구한다. - """, - ) - @ApiResponses( - value = [ - ApiResponse( - responseCode = "201", - description = "이미지 등록 접수 — 각 항목이 PENDING 상태로 생성되고 비동기 파싱이 시작된다", - content = [ - Content( - mediaType = MediaType.APPLICATION_JSON_VALUE, - schema = Schema(implementation = ApiResponseBody::class), - ), - ], - ), - ApiResponse( - responseCode = "400", - description = - "잘못된 요청 (이미지 개수 1~5 위반 · 빈 이미지 — code: PRODUCTIMAGE-001 · " + - "이미지 타입 미지정 — code: PRODUCTIMAGE-002 · " + - "지원하지 않는 이미지 형식(png/jpeg/webp/heic/heif만 허용) — code: PRODUCTIMAGE-003)", - content = [ - Content( - mediaType = MediaType.APPLICATION_JSON_VALUE, - schema = Schema(implementation = ApiResponseBody::class), - ), - ], - ), - ApiResponse( - responseCode = "401", - description = "미인증 (JWT 토큰 없음 또는 유효하지 않음)", - content = [ - Content( - mediaType = MediaType.APPLICATION_JSON_VALUE, - schema = Schema(implementation = ApiResponseBody::class), - ), - ], - ), - ApiResponse( - responseCode = "403", - description = "권한 없음 (GUEST 권한으로 접근 불가 · MEMBER 필요)", - content = [ - Content( - mediaType = MediaType.APPLICATION_JSON_VALUE, - schema = Schema(implementation = ApiResponseBody::class), - ), - ], - ), - ApiResponse( - responseCode = "409", - description = "탈퇴한 계정 (JWT 는 아직 유효하나 계정이 탈퇴 상태) — code: USER-003", - content = [ - Content( - mediaType = MediaType.APPLICATION_JSON_VALUE, - schema = Schema(implementation = ApiResponseBody::class), - ), - ], - ), - ApiResponse( - responseCode = "429", - description = RATE_LIMIT_DESCRIPTION, - headers = [ - Header( - name = "Retry-After", - description = "한도가 풀리기까지 남은 시간(초). RFC 9110 delta-seconds.", - schema = Schema(type = "integer", format = "int64"), - ), - ], - content = [ - Content( - mediaType = MediaType.APPLICATION_JSON_VALUE, - schema = Schema(implementation = ApiResponseBody::class), - ), - ], - ), - ApiResponse( - responseCode = "502", - description = "이미지 저장 실패 (원본을 S3 에 적재하는 중 스토리지 장애 — 클라이언트는 재시도) — code: STORAGE-001", - content = [ - Content( - mediaType = MediaType.APPLICATION_JSON_VALUE, - schema = Schema(implementation = ApiResponseBody::class), - ), - ], - ), - ApiResponse( - responseCode = "503", - description = CAPACITY_DESCRIPTION, - headers = [ - Header( - name = "Retry-After", - description = "가용량이 회복되기까지 남은 시간(초). RFC 9110 delta-seconds.", - schema = Schema(type = "integer", format = "int64"), - ), - ], - content = [ - Content( - mediaType = MediaType.APPLICATION_JSON_VALUE, - schema = Schema(implementation = ApiResponseBody::class), - ), - ], - ), - ], - ) - fun registerFromImages( - @Parameter(hidden = true) userId: UUID, - images: List?, - ): ApiResponseBody> - @Operation( summary = "위시리스트 이미지 등록 v2 - presigned 업로드 URL 발급", description = """ - 이미지 등록 v2 의 1단계. 올릴 이미지들의 content-type(1~5개)을 받아, 클라가 S3 에 직접 PUT 할 presigned URL 을 발급한다. - v1(multipart)이 서버로 이미지 바이트를 받아 S3 에 올리던 것을 클라→S3 직접 업로드로 바꿔 서버 대역·메모리를 아낀다. + 이미지 등록의 1단계. 올릴 이미지들의 content-type(1~5개)을 받아, 클라가 S3 에 직접 PUT 할 presigned URL 을 발급한다. + 원본 바이트가 서버를 경유하지 않아 서버 대역·메모리를 쓰지 않는다. 클라는 각 uploadUrl 로 응답의 contentType 을 Content-Type 헤더에 실어 PUT 한 뒤, imageKey 들을 2단계(/images/confirm)로 되돌려준다. 발급 시점에는 pending_uploads 에 발급 기록만 남기고 Wish·Item 은 아직 만들지 않는다(확정 단계에서 생성). """, @@ -876,9 +761,9 @@ interface WishlistApi { @Operation( summary = "위시리스트 이미지 등록 v2 - 업로드 확정", description = """ - 이미지 등록 v2 의 2단계. presigned 로 업로드를 마친 imageKey(1~5개)를 받아, 각 이미지를 PENDING 위시로 즉시 등록하고 목록을 반환한다. - key 형식·실제 업로드 여부(S3 존재)를 검증한 뒤 v1 과 같은 작업 큐에 적재하며, 이후 추출(Gemini Vision)·전이(READY/FAILED) 흐름은 v1 과 완전히 같다. - 결과 모양(WishItemResponse)은 v1 이미지 등록과 동일하다 — URL 이 없어 sourceUrl 이 null 이며, + 이미지 등록의 2단계. presigned 로 업로드를 마친 imageKey(1~5개)를 받아, 각 이미지를 PENDING 위시로 즉시 등록하고 목록을 반환한다. + key 형식·실제 업로드 여부(S3 존재)를 검증한 뒤 URL 등록과 같은 작업 큐에 적재하며, 이후 추출(Gemini Vision)·전이(READY/FAILED) 흐름도 같다. + 결과 모양(WishItemResponse)은 URL 등록과 동일하다 — 다만 URL 이 없어 sourceUrl 이 null 이며, 추출 결과는 SSE(`/api/v1/notifications/subscribe`)로 통보받아 재조회하고, 추출 실패(FAILED) 항목은 보정 API(PATCH)로 복구한다. """, ) diff --git a/src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistApiExamples.kt b/src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistApiExamples.kt index fac9b1173..2e5ef3fa5 100644 --- a/src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistApiExamples.kt +++ b/src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistApiExamples.kt @@ -212,26 +212,6 @@ class WishlistApiExamples( unauthorized() } } - if (handlerMethod.binds(WishlistController::registerFromImages)) { - operation.examples(openApiObjectMapper.delegate) { - add( - status = HttpStatus.CREATED, - name = "이미지 등록 접수 (PENDING, 다건)", - payload = ApiResponseBody.created(imagePendingEntries), - ) - add(WishException.invalidImageCount(), name = "이미지 개수 위반 (1~5개 아님)") - // ProductImage.of 의 형식 검증 3종 — S3 업로드 전에 동기로 거른다. - add(ProductImageException.emptyImage(), name = "빈 이미지 파일") - add(ProductImageException.unknownType(), name = "이미지 형식을 확인할 수 없음") - add(ProductImageException.unsupportedType(), name = "지원하지 않는 이미지 형식") - add(ImageStorageException.uploadFailed(), name = "이미지 저장 실패 (S3 업로드 장애)") - unauthorized() - add(WishException.guestCannotUseWishlist(), name = "게스트의 위시리스트 이용 거부 (회원 전용)") - add(UserException.deletedUser(), name = "탈퇴한 유저") - add(itemQuotaExceeded, name = "아이템 등록 한도 초과 (이미지 장수만큼 소모)") - add(capacityExceeded, name = "서비스 전체 가용량 소진") - } - } if (handlerMethod.binds(WishlistController::presignImageUploads)) { operation.examples(openApiObjectMapper.delegate) { add( diff --git a/src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistController.kt b/src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistController.kt index eaa33a542..44f52a099 100644 --- a/src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistController.kt +++ b/src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistController.kt @@ -56,19 +56,7 @@ class WishlistController( ) } - @PostMapping("/images", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) - @ResponseStatus(HttpStatus.CREATED) - override fun registerFromImages( - @AuthenticationPrincipal userId: UUID, - @RequestParam("images", required = false) images: List?, - ): ApiResponseBody> { - // images 파트를 아예 안 보내면(0장) Spring 이 컨트롤러 진입 전 MissingServletRequestPartException 으로 - // 끊어 캐치올(500)로 떨어진다. required=false + orEmpty 로 항상 서비스 검증(invalidImageCount, 400)에 닿게 한다. - val results = wishlistService.registerFromImages(images = images.orEmpty(), userId = userId) - return ApiResponseBody.created(results.map { toResponse(it) }) - } - - // 이미지 등록 v2 1단계 — presigned 업로드 URL 발급. pending_uploads 에 발급 기록만 남기고 Wish·Item 은 아직 만들지 않으므로 200 OK. + // 이미지 등록 1단계 — presigned 업로드 URL 발급. pending_uploads 에 발급 기록만 남기고 Wish·Item 은 아직 만들지 않으므로 200 OK. @PostMapping("/images/presigned") override fun presignImageUploads( @AuthenticationPrincipal userId: UUID, @@ -78,7 +66,7 @@ class WishlistController( return ApiResponseBody.ok(PresignedImageUploadResponse.from(uploads)) } - // 이미지 등록 v2 2단계 — 업로드 확정. PENDING 위시를 생성하므로 v1(registerFromImages)과 같은 201 CREATED. + // 이미지 등록 2단계 — 업로드 확정. PENDING 위시를 생성하므로 URL 등록과 같은 201 CREATED. @PostMapping("/images/confirm") @ResponseStatus(HttpStatus.CREATED) override fun confirmImageRegistration( @@ -155,7 +143,7 @@ class WishlistController( // 다중 삭제는 의미상 DELETE 지만, DELETE + body 는 중간자(게이트웨이·LB·CDN)가 body 를 스트립/거절할 수 있어 // (RFC 9110 은 DELETE body 의미를 정의하지 않음) id 목록을 query param(?ids=1,2,3)으로 받는다. // 누락 시 required=false + orEmpty 로 WishDeleteIds 검증(400)에 닿게 한다 — required=true 면 누락이 - // MissingServletRequestParameterException → 캐치올 500 으로 새기 때문이다(registerFromImages 의 교훈). + // MissingServletRequestParameterException → 캐치올 500 으로 새기 때문이다. @DeleteMapping override fun deleteWishes( @AuthenticationPrincipal userId: UUID, diff --git a/src/main/kotlin/com/depromeet/piki/wishlist/service/WishPersistenceService.kt b/src/main/kotlin/com/depromeet/piki/wishlist/service/WishPersistenceService.kt index 5165084c6..dc08075ff 100644 --- a/src/main/kotlin/com/depromeet/piki/wishlist/service/WishPersistenceService.kt +++ b/src/main/kotlin/com/depromeet/piki/wishlist/service/WishPersistenceService.kt @@ -82,20 +82,7 @@ class WishPersistenceService( return WishWithItem(wish = wish, item = saved, snapshot = snapshot) } - // v1(multipart) 이미지 다건 등록 — 서버가 바이트를 받아 S3 에 올린 뒤 pending 매핑 없이 바로 적재한다. - // 입력(imageKey)이 행에 박혀 durable 하므로 link 경로와 같은 작업 큐에 적재한다 — 디스패처가 PENDING 을 집어 워커에 넘긴다. - @Transactional - fun persistPendingImages( - userId: UUID, - imageKeys: List, - ): List { - // 실시간(v1 multipart) 경로 — persist 와 같은 활성 유저 잠금 가드(#776). tombstone race 를 막고, - // absent 는 앞단(requireMember)이 거른다(persist 주석 참고). - userService.rejectIfWithdrawnForUpdate(userId) - return persistImagesInternal(userId, imageKeys) - } - - // v2 이미지 등록 — confirm 또는 폴링 백스톱이 "업로드 확인된" key 들을 등록한다. pending_uploads 를 FOR UPDATE 로 + // 이미지 등록 — confirm 또는 폴링 백스톱이 "업로드 확인된" key 들을 등록한다. pending_uploads 를 FOR UPDATE 로 // 잠가 삭제(claim)하고, claim 에 성공한(=이 트랜잭션이 가져간) WISH 매핑만 적재한다 — confirm·폴링이 같은 key 를 // 다퉈도 삭제는 한쪽만 성공하므로 중복 등록되지 않는다(멱등). 다른 user·토너먼트 맥락 매핑은 걸러낸다. @Transactional @@ -123,7 +110,7 @@ class WishPersistenceService( } // 이미지 key 들을 item(정체성) → PENDING snapshot(작업 큐 적재) → wish 순서로 배치 적재하는 공통 코어. - // 트랜잭션은 호출부(persistPendingImages·registerClaimedImages)가 연다 — self-invocation 으로 트랜잭션이 무력화되지 않게 private. + // 트랜잭션은 호출부(registerClaimedImages)가 연다 — self-invocation 으로 트랜잭션이 무력화되지 않게 private. private fun persistImagesInternal( userId: UUID, imageKeys: List, diff --git a/src/main/kotlin/com/depromeet/piki/wishlist/service/WishlistService.kt b/src/main/kotlin/com/depromeet/piki/wishlist/service/WishlistService.kt index 8839253b9..baf701329 100644 --- a/src/main/kotlin/com/depromeet/piki/wishlist/service/WishlistService.kt +++ b/src/main/kotlin/com/depromeet/piki/wishlist/service/WishlistService.kt @@ -72,31 +72,8 @@ class WishlistService( return wishPersistenceService.persist(userId, Item(link)) } - // 이미지 등록은 registerFromUrl(link)와 같은 비동기 작업 큐 흐름 — 입력이 이미지(다건)일 뿐이다. - // 개수·형식을 동기로 검증(400)한 뒤, 원본을 S3 에 durable 적재(raw key 확보)하고 link 경로처럼 PENDING item·wish 를 - // 배치 저장해 즉시 반환한다. 실제 추출(Gemini·크롭·결과 업로드)은 디스패처(@Scheduled)가 PENDING 을 집어 워커에 넘긴다. - // raw 를 먼저 올려 입력이 durable 하므로, @Async 유실·일시 오류로 재실행돼도 워커가 그 key 로 원본을 다시 읽는다. - fun registerFromImages( - images: List, - userId: UUID, - ): List { - requireMember(userId) - if (images.size !in MIN_IMAGE_COUNT..MAX_IMAGE_COUNT) throw WishException.invalidImageCount() - // 형식 검증(빈 바이트·미지원 MIME) — 실패 시 즉시 400. 유효한 이미지만 durable 적재한다. - val productImages = images.map { ProductImage.of(it.bytes, it.contentType) } - // 장마다 추출이 따로 도는 별개 item 이라 장수만큼 차감한다. S3 업로드 전에 둬서 거부될 요청이 raw 를 남기지 않게 한다. - itemQuotaGuard.consume(userId, images.size, WishErrorCode.ITEM_QUOTA_EXCEEDED) - // 원본을 S3 raw 에 올려 입력을 durable 화한다(외부 호출, 트랜잭션 밖). 이 key 가 item 의 입력 정체성이 된다. - val imageKeys = productImages.map { uploadRaw(it) } - // 위시 이미지 등록엔 정원 같은 계약 거부가 없어 정상 흐름에선 persist 가 떨어지지 않지만, 예기치 못한 영속화 실패에도 - // 방금 올린 raw 가 orphan 으로 새지 않게 즉시 회수한다(tournament 경로와 대칭, best-effort, lifecycle 백업). - return runCatching { wishPersistenceService.persistPendingImages(userId, imageKeys) } - .onFailure { imagePresignService.deleteRawsQuietly(imageKeys) } - .getOrThrow() - } - - // 이미지 등록 v2 발급 — 클라가 S3 에 직접 올릴 presigned URL 을 발급한다. v1(registerFromImages)이 서버로 바이트를 - // 받아 S3 에 올리던 것을 클라→S3 직접 업로드로 바꿔, 원본 바이트가 서버 메모리·대역을 경유하지 않게 한다. + // 이미지 등록 발급 — 클라가 S3 에 직접 올릴 presigned URL 을 발급한다. 클라→S3 직접 업로드라 + // 원본 바이트가 서버 메모리·대역을 경유하지 않는다. // 회원·개수(계약) 검증만 여기서 하고, content-type 검증·raw key 생성·presign 발급은 ImagePresignService 에 위임한다. fun presignImageUploads( contentTypes: List, @@ -105,8 +82,8 @@ class WishlistService( requireMember(userId) if (contentTypes.size !in MIN_IMAGE_COUNT..MAX_IMAGE_COUNT) throw WishException.invalidImageCount() // content-type 검증을 차감 앞으로 당긴다 — presignRawUploads 안에서 걸러도 결과는 같지만, 그러면 지원하지 - // 않는 MIME 을 보낸 요청이 몫을 깎고 400 을 받는다. v1(registerFromImages)이 ProductImage.of 로 형식을 - // 먼저 거르는 것과 순서를 맞춘다. 같은 검증이 발급 시점에 한 번 더 도는 것은 부작용 없는 순수 함수라 무해하다. + // 않는 MIME 을 보낸 요청이 몫을 깎고 400 을 받는다. 형식 위반은 몫을 건드리기 전에 거른다는 순서를 지킨다. + // 같은 검증이 발급 시점에 한 번 더 도는 것은 부작용 없는 순수 함수라 무해하다. contentTypes.forEach { ProductImage.extensionForMimeType(it) } // v2 는 발급(presign) 시점에 차감한다 — confirm 이 안 와도 폴링 백스톱이 pending 을 회수해 큐에 넣으므로, // confirm 에서만 세면 그 경로가 통째로 한도를 우회한다. 대신 confirm 은 차감하지 않는다(이중 차감 방지). @@ -132,14 +109,6 @@ class WishlistService( return wishPersistenceService.registerClaimedImages(imageKeys, userId) } - // 원본 이미지를 S3 raw prefix 에 올리고 그 object key 를 돌려준다. upload 는 공개 URL 을 반환하지만 작업 큐 입력엔 - // 우리가 만든 key 가 필요하다(워커가 download(key)로 다시 읽는다). 파싱이 끝나면 워커가 이 raw 를 회수한다. - private fun uploadRaw(image: ProductImage): String { - val key = "items/raw/${UUID.randomUUID()}.${image.extension}" - imageStorage.upload(image.bytes, key, image.mimeType) - return key - } - @Transactional(readOnly = true) fun getWishlist( userId: UUID, diff --git a/src/test/kotlin/com/depromeet/piki/auth/config/AuthorizationBoundaryIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/auth/config/AuthorizationBoundaryIntegrationTest.kt index f3a08cc1f..7cd6249a1 100644 --- a/src/test/kotlin/com/depromeet/piki/auth/config/AuthorizationBoundaryIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/auth/config/AuthorizationBoundaryIntegrationTest.kt @@ -68,7 +68,8 @@ class AuthorizationBoundaryIntegrationTest : IntegrationTestSupport() { arguments(HttpMethod.POST, "/api/v1/dev/00000000-0000-0000-0000-000000000000/token"), arguments(HttpMethod.POST, "/api/v1/auth/logout"), arguments(HttpMethod.POST, "/api/v1/wishlists"), - arguments(HttpMethod.POST, "/api/v1/wishlists/images"), + arguments(HttpMethod.POST, "/api/v1/wishlists/images/presigned"), + arguments(HttpMethod.POST, "/api/v1/wishlists/images/confirm"), arguments(HttpMethod.GET, "/api/v1/wishlists"), arguments(HttpMethod.PATCH, "/api/v1/wishlists/1"), arguments(HttpMethod.DELETE, "/api/v1/wishlists/1"), diff --git a/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentIntegrationTest.kt index 948fee003..5a16f03f2 100644 --- a/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentIntegrationTest.kt @@ -2334,31 +2334,6 @@ class TournamentIntegrationTest : IntegrationTestSupport() { ).andExpect(status().isForbidden) } - @Test - fun `POST tournaments-id-items-images 는 참여자이면 PENDING 아이템을 생성하고 tournamentItemIds 를 반환한다`() { - stubImageParsingWorker.enabled = false - try { - val mockMvc = buildMockMvc() - val tournamentId = createTournament(mockMvc) - val image1 = MockMultipartFile("images", "img1.jpg", "image/jpeg", ByteArray(100) { 1 }) - val image2 = MockMultipartFile("images", "img2.jpg", "image/jpeg", ByteArray(100) { 2 }) - - mockMvc - .perform( - multipart("/api/v1/tournaments/$tournamentId/items/images") - .file(image1) - .file(image2) - .header(HttpHeaders.AUTHORIZATION, authHeader(userId)), - ).andExpect(status().isOk) - .andExpect(jsonPath("$.data.tournamentItemIds").isArray) - .andExpect(jsonPath("$.data.tournamentItemIds.length()").value(2)) - - assertEquals(2, tournamentItemJpaRepository.findAllByTournamentIdAndNotDeleted(tournamentId).size) - } finally { - stubImageParsingWorker.enabled = true - } - } - @Test fun `게스트 합류 시 TournamentJoined 이벤트가 발행된다`() { val mockMvc = buildMockMvc() @@ -2406,16 +2381,9 @@ class TournamentIntegrationTest : IntegrationTestSupport() { try { val mockMvc = buildMockMvc() val tournamentId = createTournament(mockMvc) - val image1 = MockMultipartFile("images", "img1.jpg", "image/jpeg", ByteArray(100) { 1 }) - val image2 = MockMultipartFile("images", "img2.jpg", "image/jpeg", ByteArray(100) { 2 }) + val imageKeys = presignImageKeys(mockMvc, tournamentId, count = 2) - mockMvc - .perform( - multipart("/api/v1/tournaments/$tournamentId/items/images") - .file(image1) - .file(image2) - .header(HttpHeaders.AUTHORIZATION, authHeader(userId)), - ).andExpect(status().isOk) + confirmImages(mockMvc, tournamentId, imageKeys).andExpect(status().isOk) val added = applicationEvents.stream(TournamentItemAdded::class.java).toList() assertEquals(1, added.size) @@ -2426,24 +2394,6 @@ class TournamentIntegrationTest : IntegrationTestSupport() { } } - @Test - fun `POST tournaments-id-items-images 에서 토너먼트 참여자가 아니면 403 을 반환하고 raw 를 올리지 않는다`() { - val mockMvc = buildMockMvc() - val tournamentId = createTournament(mockMvc) - val image = MockMultipartFile("images", "test.jpg", "image/jpeg", ByteArray(100) { 1 }) - val rawBefore = stubImageStorage.uploadedKeys.count { it.startsWith("items/raw/") } - - mockMvc - .perform( - multipart("/api/v1/tournaments/$tournamentId/items/images") - .file(image) - .header(HttpHeaders.AUTHORIZATION, authHeader(otherUserId)), - ).andExpect(status().isForbidden) - - // 권한 검증(verifyCanAddItems)이 업로드 전에 거부하므로 raw 가 S3 에 올라가지 않아야 한다(orphan 방지). - assertEquals(rawBefore, stubImageStorage.uploadedKeys.count { it.startsWith("items/raw/") }) - } - @Test fun `POST tournaments-id-items-images 에서 플레이링크 복제 토너먼트이면 403 을 반환한다`() { val mockMvc = buildMockMvc() @@ -2460,44 +2410,17 @@ class TournamentIntegrationTest : IntegrationTestSupport() { .header(HttpHeaders.AUTHORIZATION, authHeader(otherUserId)), ).andReturn() val cloneId = objectMapper.readTree(cloneResult.response.contentAsString)["data"].asLong() - val image = MockMultipartFile("images", "test.jpg", "image/jpeg", ByteArray(100) { 1 }) + // 복제 토너먼트는 아이템 추가 자체가 막혀 있어, 업로드를 시작하기도 전인 발급 단계에서 거부된다. mockMvc .perform( - multipart("/api/v1/tournaments/$cloneId/items/images") - .file(image) - .header(HttpHeaders.AUTHORIZATION, authHeader(otherUserId)), + post("/api/v1/tournaments/$cloneId/items/images/presigned") + .contentType(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, authHeader(otherUserId)) + .content(objectMapper.writeValueAsString(mapOf("contentTypes" to listOf("image/jpeg")))), ).andExpect(status().isForbidden) } - @Test - fun `POST tournaments-id-items-images 에서 이미지 6개 이상이면 400 을 반환한다`() { - val mockMvc = buildMockMvc() - val tournamentId = createTournament(mockMvc) - val images = (1..6).map { i -> MockMultipartFile("images", "img$i.jpg", "image/jpeg", ByteArray(100) { 1 }) } - - val request = images.fold(multipart("/api/v1/tournaments/$tournamentId/items/images")) { req, file -> - req.file(file) - }.header(HttpHeaders.AUTHORIZATION, authHeader(userId)) - - mockMvc - .perform(request) - .andExpect(status().isBadRequest) - } - - @Test - fun `POST tournaments-id-items-images 에서 이미지 파트를 보내지 않으면 400 을 반환한다`() { - val mockMvc = buildMockMvc() - val tournamentId = createTournament(mockMvc) - - // .file(...) 없이 images 파트를 아예 생략 — required=false + orEmpty 로 서비스 검증(개수 0)에 닿아 400. - mockMvc - .perform( - multipart("/api/v1/tournaments/$tournamentId/items/images") - .header(HttpHeaders.AUTHORIZATION, authHeader(userId)), - ).andExpect(status().isBadRequest) - } - @Test fun `POST tournaments-id-items 에서 위시리스트에 없는 아이템이면 403 을 반환한다`() { val mockMvc = buildMockMvc() @@ -2536,16 +2459,48 @@ class TournamentIntegrationTest : IntegrationTestSupport() { val tournamentId = createTournament(mockMvc) val full32 = (1..32).map { saveWishItem() }.toLongArray() addItemsToTournament(mockMvc, tournamentId, userId, *full32) - val image = MockMultipartFile("images", "img.jpg", "image/jpeg", ByteArray(100) { 1 }) + // 발급은 사전 권한만 보므로 통과한다 — 정원 최종 판정은 아이템이 실제로 생기는 확정 단계가 쥔다. + val imageKeys = presignImageKeys(mockMvc, tournamentId, count = 1) - mockMvc - .perform( - multipart("/api/v1/tournaments/$tournamentId/items/images") - .file(image) - .header(HttpHeaders.AUTHORIZATION, authHeader(userId)), - ).andExpect(status().isBadRequest) + confirmImages(mockMvc, tournamentId, imageKeys).andExpect(status().isBadRequest) + } + + // 이미지 등록 1단계 — presigned 를 발급받아 imageKey 들을 돌려준다. 업로드는 클라가 S3 에 직접 하므로 + // 테스트에서 재현하지 않는다(StubImageStorage.exists 기본값이 "올라왔다"라 확정 단계가 그대로 통과한다). + private fun presignImageKeys( + mockMvc: MockMvc, + tournamentId: Long, + count: Int, + actor: UUID = userId, + ): List { + val response = + mockMvc + .perform( + post("/api/v1/tournaments/$tournamentId/items/images/presigned") + .contentType(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, authHeader(actor)) + .content(objectMapper.writeValueAsString(mapOf("contentTypes" to List(count) { "image/jpeg" }))), + ).andExpect(status().isOk) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + val uploads = objectMapper.readTree(response).path("data").path("uploads") + return (0 until uploads.size()).map { uploads.path(it).path("imageKey").asText() } } + // 이미지 등록 2단계 — 상태 단언은 호출부가 한다(성공·거부 시나리오가 갈리므로). + private fun confirmImages( + mockMvc: MockMvc, + tournamentId: Long, + imageKeys: List, + actor: UUID = userId, + ) = mockMvc.perform( + post("/api/v1/tournaments/$tournamentId/items/images/confirm") + .contentType(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, authHeader(actor)) + .content(objectMapper.writeValueAsString(mapOf("imageKeys" to imageKeys))), + ) + private fun buildMockMvc(): MockMvc = MockMvcBuilders .webAppContextSetup(webApplicationContext) @@ -2706,19 +2661,22 @@ class TournamentIntegrationTest : IntegrationTestSupport() { ) // 위시리스트에도 등록된 READY 아이템 생성 — /items/wish 엔드포인트용. 이미지 등록류(link 없이 sourceImageKey)라 sourceUrl 이 없다. - // 이미지 경로도 link 처럼 PENDING 으로 작업 큐 적재되므로, persistPendingImages 로 만든 뒤 claim(PROCESSING)→markReady 로 - // 전이시켜 추출값을 채운다. 표시값·상태는 활성 snapshot 이 보유한다. + // 등록 API 를 타지 않고 행을 직접 심는다 — 필요한 것은 "이미지로 만들어진 READY 위시" 라는 상태뿐이고, + // 등록 경로 자체(발급·확정)는 TournamentItemImagePresignedIntegrationTest 가 따로 덮는다. + // 적재 후 claim(PROCESSING)→markExtracted 로 전이시켜 추출값을 채운다. 표시값·상태는 활성 snapshot 이 보유한다. private fun saveWishItem(owner: UUID = userId, name: String = "테스트 아이템", price: Int = 10_000): Long { - val result = wishPersistenceService.persistPendingImages(owner, listOf("items/raw/${UUID.randomUUID()}.png")).first() - itemSnapshotJpaRepository.findById(result.snapshot.getId()).get().markProcessing() + val item = itemJpaRepository.save(Item(sourceImageKey = "items/raw/${UUID.randomUUID()}.png")) + val snapshot = itemSnapshotJpaRepository.save(ItemSnapshot.pending(item.getId())) + wishJpaRepository.save(Wish(userId = owner, snapshotId = snapshot.getId())) + snapshot.markProcessing() // 이 시딩은 워커를 태우지 않고 전이만 재현한다 — 실행이 없었으므로 attempt 는 집기 직후 값(0) 그대로이고, // 전이의 fencing 토큰도 그 값이다. (실행까지 재현하는 흐름은 WishlistRegisterAsyncIntegrationTest 가 덮는다.) itemParsingService.markExtracted( - result.snapshot.getId(), + snapshot.getId(), ProductSnapshot(name = name, price = price, currency = "KRW", imageUrl = "https://img.example.com/a.png"), expectedAttempt = 0, ) - return result.item.getId() + return item.getId() } @Test diff --git a/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentItemImageAddConcurrencyIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentItemImageAddConcurrencyIntegrationTest.kt index a5330d17a..6d8e93402 100644 --- a/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentItemImageAddConcurrencyIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentItemImageAddConcurrencyIntegrationTest.kt @@ -8,7 +8,6 @@ import com.depromeet.piki.item.repository.ItemJpaRepository import com.depromeet.piki.item.repository.ItemSnapshotJpaRepository import com.depromeet.piki.support.IntegrationTestSupport import com.depromeet.piki.support.StubImageParsingWorker -import com.depromeet.piki.support.StubImageStorage import com.depromeet.piki.support.uuidToBytes import com.depromeet.piki.tournament.domain.TournamentItem import com.depromeet.piki.tournament.repository.TournamentItemJpaRepository @@ -20,9 +19,8 @@ import org.springframework.beans.factory.annotation.Autowired import org.springframework.http.HttpHeaders import org.springframework.http.MediaType import org.springframework.jdbc.core.JdbcTemplate -import org.springframework.mock.web.MockMultipartFile import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart +import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post import org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder import org.springframework.test.web.servlet.setup.MockMvcBuilders @@ -43,8 +41,8 @@ import kotlin.test.assertTrue // 통과해 합산 37개가 들어간다. FOR UPDATE 로 직렬화하면 두 번째 요청이 첫 번째 커밋 후 existing=32 를 보고 32+5>32 로 // 400 처리된다. "정확히 1개 200, 1개 400" 이 그 직렬화의 시그니처다(TournamentWishAddConcurrencyIntegrationTest 와 동결). // -// 더해, 이미지 경로는 raw 를 persist 전에 S3 에 올리므로 거부된 요청의 raw 가 orphan 으로 남는다 — 서비스가 persist 실패 시 -// 즉시 회수하는지(addItemsFromImages 의 deleteRawsQuietly)를 deletedKeys 로 함께 검증한다. +// 이미지 경로는 발급(presigned)과 확정(confirm)이 나뉘어 있고, 정원 판정은 아이템이 실제로 생기는 confirm 이 쥔다. +// 그래서 발급은 미리 각자 끝내 두고 confirm 만 동시에 쏴, 이 테스트의 관심사인 저장 시점 경합만 남긴다. // // 일반 통합 테스트와 달리 @Transactional 을 쓰지 않는다 — 별도 트랜잭션 동시 진행이 race 시뮬레이션의 본질이다. // 데이터 격리는 새 UUID 를 쓰고 finally 에서 직접 정리한다. @@ -57,13 +55,11 @@ class TournamentItemImageAddConcurrencyIntegrationTest : IntegrationTestSupport( @Autowired private lateinit var itemSnapshotJpaRepository: ItemSnapshotJpaRepository @Autowired private lateinit var tournamentItemJpaRepository: TournamentItemJpaRepository @Autowired private lateinit var jdbcTemplate: JdbcTemplate - @Autowired private lateinit var stubImageStorage: StubImageStorage @Autowired private lateinit var stubImageParsingWorker: StubImageParsingWorker @Test - fun `이미지 담기를 동시에 두 번 요청하면 FOR UPDATE 로 직렬화되어 32개 상한을 넘지 않고 거부된 요청의 raw 가 회수된다`() { - // 디스패처(@Scheduled)가 성공 요청의 PENDING raw 를 워커로 회수하면 deletedKeys 단언이 흔들린다 — 워커를 꺼 - // 성공분 raw 는 PENDING 으로 보존하고, 거부분 raw 회수(서비스 cleanup)만 결정적으로 관찰한다. + fun `이미지 담기를 동시에 두 번 확정하면 FOR UPDATE 로 직렬화되어 32개 상한을 넘지 않는다`() { + // 디스패처(@Scheduled)가 성공분 PENDING 을 집어 상태를 바꾸면 정리와 간섭하므로 워커를 꺼 둔다. // enabled 는 컨텍스트 공유 전역 상태라, try 진입 전 setup 이 실패해 끈 채 새면 다른 테스트가 연쇄 실패한다. // 끄기는 try 안으로 미루고 원래 값을 보관해, finally 가 항상 원복하도록 한다. val previousWorkerEnabled = stubImageParsingWorker.enabled @@ -84,9 +80,6 @@ class TournamentItemImageAddConcurrencyIntegrationTest : IntegrationTestSupport( // 이 테스트가 새로 만드는 item/snapshot 의 하한 — finally 에서 이보다 큰 id 만 지워 추가분(사전 27 + 성공 5)까지 정리한다. val maxItemIdBefore = jdbcTemplate.queryForObject("SELECT COALESCE(MAX(id), 0) FROM items", Long::class.java) ?: 0L - // deletedKeys 는 컨텍스트 캐싱으로 모든 통합 테스트가 공유하는 stub 누적 상태다 — 앞선 테스트의 raw 삭제가 섞이므로 - // 절대값이 아니라 이 테스트가 만든 증가분(delta)만 본다. - val rawDeletedBefore = stubImageStorage.deletedKeys.count { it.startsWith("items/raw/") } var tournamentId = 0L try { @@ -125,6 +118,8 @@ class TournamentItemImageAddConcurrencyIntegrationTest : IntegrationTestSupport( // else 없는 when 이 제3 상태의 정체를 삼켜 원인 추적이 불가능했다. 작업 큐 claim 스캔이 대기 에지로 // 끼는 InnoDB 교착이 실측됐고 SKIP LOCKED 로 제거됐다. 만에 하나 재발하면 이 증거가 정체를 밝힌다. val unexpectedResponses = CopyOnWriteArrayList() + // 발급은 사전 권한만 보므로 둘 다 통과한다 — 경합은 정원을 판정하는 confirm 에서만 일어나야 하므로 여기서 미리 끝낸다. + val keysByRequest = (0 until 2).map { presignKeys(mockMvc, tournamentId, ownerAuth, count = 5) } val executor = Executors.newFixedThreadPool(2) val ready = CountDownLatch(2) val start = CountDownLatch(1) @@ -135,11 +130,13 @@ class TournamentItemImageAddConcurrencyIntegrationTest : IntegrationTestSupport( ready.countDown() start.await() try { - val builder = multipart("/api/v1/tournaments/$tournamentId/items/images") - repeat(5) { i -> - builder.file(MockMultipartFile("images", "req$req-$i.jpg", "image/jpeg", ByteArray(10) { 1 })) - } - val res = mockMvc.perform(builder.header(HttpHeaders.AUTHORIZATION, ownerAuth)).andReturn() + val body = objectMapper.writeValueAsString(mapOf("imageKeys" to keysByRequest[req])) + val res = mockMvc.perform( + post("/api/v1/tournaments/$tournamentId/items/images/confirm") + .contentType(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, ownerAuth) + .content(body), + ).andReturn() when (res.response.status) { 200, 201 -> status200.incrementAndGet() 400 -> status400.incrementAndGet() @@ -167,21 +164,22 @@ class TournamentItemImageAddConcurrencyIntegrationTest : IntegrationTestSupport( assertEquals(1, status200.get(), "정확히 하나만 성공이어야 한다 (5장 담기 성공)") assertEquals(1, status400.get(), "나머지 하나는 락 대기 후 32개 초과로 400 이어야 한다") - // 두 요청 모두 persist 전에 5장씩 raw 를 올리지만(합 10장), 거부된 요청의 5장만 서비스가 즉시 회수해야 한다. - // 성공분 5장은 PENDING item 의 입력이라 보존된다(워커를 꺼 둬 회수되지 않음). lifecycle 백업이 아닌 즉시 회수를 단언. - val rawDeleted = stubImageStorage.deletedKeys.count { it.startsWith("items/raw/") } - rawDeletedBefore - assertEquals(5, rawDeleted, "거부된 요청이 올린 raw 5장이 즉시 회수되어야 한다") + // 상한을 넘겨 저장된 것이 없어야 한다 — 성공한 5장까지만 반영되어 정확히 32개다. + assertEquals(32, tournamentItemJpaRepository.findAllByTournamentIdAndNotDeleted(tournamentId).size) + + // 거부된 요청은 트랜잭션이 통째로 롤백돼 claim(pending_uploads 삭제)도 되살아난다 — 그 5장은 폴링이 + // 다시 집을 수 있도록 매핑이 남아 있어야 하고, 성공분 5장만 소비돼 사라진다. + val remainingPending = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM pending_uploads WHERE tournament_id = ?", + Int::class.java, + tournamentId, + ) + assertEquals(5, remainingPending, "거부된 요청의 pending 매핑은 롤백으로 남아야 한다") } finally { stubImageParsingWorker.enabled = previousWorkerEnabled - // 워커를 꺼 둔 탓에 성공분 raw 는 stub 에 orphan 으로 남는다(정상 흐름이면 워커가 회수). 동시성 테스트는 자기가 만든 것을 - // 직접 정리하므로, DB 행을 지우기 전에 이 테스트가 올린 raw 도 stub 에서 회수한다(공유 stub 누적 방지). - jdbcTemplate.queryForList( - "SELECT source_image_key FROM items WHERE id > ? AND source_image_key IS NOT NULL", - String::class.java, - maxItemIdBefore, - ).filterNotNull().forEach { key -> runCatching { stubImageStorage.delete(key) } } // @Transactional 자동 롤백이 없으므로 직접 지운다. 추가된 item/snapshot 은 id 하한으로 일괄 정리한다. if (tournamentId != 0L) { + jdbcTemplate.update("DELETE FROM pending_uploads WHERE tournament_id = ?", tournamentId) jdbcTemplate.update("DELETE FROM tournament_items WHERE tournament_id = ?", tournamentId) jdbcTemplate.update("DELETE FROM tournament_users WHERE tournament_id = ?", tournamentId) jdbcTemplate.update("DELETE FROM tournaments WHERE id = ?", tournamentId) @@ -191,4 +189,25 @@ class TournamentItemImageAddConcurrencyIntegrationTest : IntegrationTestSupport( jdbcTemplate.update("DELETE FROM users WHERE id = ?", uuidToBytes(ownerId)) } } + + // 이미지 등록 1단계 — presigned 를 발급받아 imageKey 들을 돌려준다. 업로드는 클라가 S3 에 직접 하므로 + // 테스트에서 재현하지 않는다(StubImageStorage.exists 기본값이 "올라왔다"라 확정 단계가 그대로 통과한다). + private fun presignKeys( + mockMvc: MockMvc, + tournamentId: Long, + auth: String, + count: Int, + ): List { + val response = mockMvc + .perform( + post("/api/v1/tournaments/$tournamentId/items/images/presigned") + .contentType(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, auth) + .content(objectMapper.writeValueAsString(mapOf("contentTypes" to List(count) { "image/jpeg" }))), + ).andReturn() + .response + .getContentAsString(Charsets.UTF_8) + val uploads = objectMapper.readTree(response).path("data").path("uploads") + return (0 until uploads.size()).map { uploads.path(it).path("imageKey").asText() } + } } diff --git a/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentMatchIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentMatchIntegrationTest.kt index 1e6981cef..ee9169f28 100644 --- a/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentMatchIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentMatchIntegrationTest.kt @@ -1,6 +1,9 @@ package com.depromeet.piki.tournament.controller import com.depromeet.piki.auth.infrastructure.jwt.JwtProvider +import com.depromeet.piki.item.domain.Item +import com.depromeet.piki.item.domain.ItemSnapshot +import com.depromeet.piki.item.repository.ItemJpaRepository import com.depromeet.piki.item.repository.ItemSnapshotJpaRepository import com.depromeet.piki.item.service.ItemParsingService import com.depromeet.piki.product.service.ProductSnapshot @@ -9,7 +12,8 @@ import com.depromeet.piki.tournament.repository.TournamentHistoryJpaRepository import com.depromeet.piki.tournament.repository.TournamentItemJpaRepository import com.depromeet.piki.tournament.service.TournamentErrorCode import com.depromeet.piki.user.domain.IdentityType -import com.depromeet.piki.wishlist.service.WishPersistenceService +import com.depromeet.piki.wishlist.domain.Wish +import com.depromeet.piki.wishlist.repository.WishJpaRepository import org.springframework.beans.factory.annotation.Autowired import org.springframework.http.HttpHeaders import org.springframework.http.MediaType @@ -46,7 +50,9 @@ class TournamentMatchIntegrationTest : IntegrationTestSupport() { @Autowired private lateinit var itemParsingService: ItemParsingService - @Autowired private lateinit var wishPersistenceService: WishPersistenceService + @Autowired private lateinit var itemJpaRepository: ItemJpaRepository + + @Autowired private lateinit var wishJpaRepository: WishJpaRepository @Autowired private lateinit var jwtProvider: JwtProvider @@ -341,14 +347,17 @@ class TournamentMatchIntegrationTest : IntegrationTestSupport() { name: String, price: Int, ): Long { - val result = wishPersistenceService.persistPendingImages(userId, listOf("items/raw/${UUID.randomUUID()}.png")).first() - itemSnapshotJpaRepository.findById(result.snapshot.getId()).get().markProcessing() + // 필요한 것은 "READY 인 위시 아이템" 이라는 상태뿐이라 등록 API 를 타지 않고 행을 직접 심는다. + val item = itemJpaRepository.save(Item(sourceImageKey = "items/raw/${UUID.randomUUID()}.png")) + val snapshot = itemSnapshotJpaRepository.save(ItemSnapshot.pending(item.getId())) + wishJpaRepository.save(Wish(userId = userId, snapshotId = snapshot.getId())) + snapshot.markProcessing() itemParsingService.markExtracted( - result.snapshot.getId(), + snapshot.getId(), ProductSnapshot(name = name, price = price, currency = "KRW", imageUrl = "https://img.example.com/a.png"), expectedAttempt = 0, ) - return result.item.getId() + return item.getId() } private fun tournamentItemIdsOf(tournamentId: Long): List = diff --git a/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishlistCrudIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishlistCrudIntegrationTest.kt index 7578c15c3..47544320d 100644 --- a/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishlistCrudIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishlistCrudIntegrationTest.kt @@ -1085,89 +1085,4 @@ class WishlistCrudIntegrationTest : IntegrationTestSupport() { .andExpect(status().isOk) .andExpect(jsonPath("$.data.length()").value(0)) } - - @Test - fun `다건 이미지로 등록하면 201 과 함께 PENDING 항목이 개수만큼 반환된다`() { - val mockMvc = buildMockMvc() - val userId = UUID.randomUUID() - insertMember(userId) - val image1 = MockMultipartFile("images", "p1.png", "image/png", byteArrayOf(1, 2, 3)) - val image2 = MockMultipartFile("images", "p2.png", "image/png", byteArrayOf(4, 5, 6)) - - mockMvc - .perform( - multipart("/api/v1/wishlists/images") - .file(image1) - .file(image2) - .header(HttpHeaders.AUTHORIZATION, "Bearer ${memberToken(userId)}"), - ).andExpect(status().isCreated) - .andExpect(jsonPath("$.data.length()").value(2)) - // 등록 직후라 두 항목 모두 PENDING(link 처럼 작업 큐 적재) — 추출 결과는 비어 있고 sourceUrl 도 null(이미지 등록). - // 실제 파싱 완료(READY/FAILED)·크롭 imageUrl 은 WishlistRegisterAsyncIntegrationTest 가 검증한다. - .andExpect(jsonPath("$.data[0].item.status").value("PENDING")) - .andExpect(jsonPath("$.data[0].item.name").value(nullValue())) - .andExpect(jsonPath("$.data[0].item.sourceUrl").value(nullValue())) - .andExpect(jsonPath("$.data[0].wish.id").isNumber) - .andExpect(jsonPath("$.data[1].item.status").value("PENDING")) - } - - @Test - fun `이미지를 6개 등록하면 400 BAD_REQUEST 가 반환된다`() { - val mockMvc = buildMockMvc() - val userId = UUID.randomUUID() - insertMember(userId) - val request = multipart("/api/v1/wishlists/images") - (1..6).forEach { i -> - request.file(MockMultipartFile("images", "p$i.png", "image/png", byteArrayOf(1, 2, 3))) - } - request.header(HttpHeaders.AUTHORIZATION, "Bearer ${memberToken(userId)}") - - mockMvc - .perform(request) - .andExpect(status().isBadRequest) - } - - @Test - fun `이미지 파트를 보내지 않으면 400 BAD_REQUEST 가 반환된다`() { - val mockMvc = buildMockMvc() - val userId = UUID.randomUUID() - insertMember(userId) - - // .file(...) 없이 images 파트를 아예 생략 — required=false + orEmpty 로 서비스 검증(개수 0)에 닿아 400. - mockMvc - .perform( - multipart("/api/v1/wishlists/images") - .header(HttpHeaders.AUTHORIZATION, "Bearer ${memberToken(userId)}"), - ).andExpect(status().isBadRequest) - } - - @Test - fun `빈 이미지로 등록하면 400 BAD_REQUEST 가 반환된다`() { - val mockMvc = buildMockMvc() - val userId = UUID.randomUUID() - insertMember(userId) - val emptyImage = MockMultipartFile("images", "empty.png", "image/png", ByteArray(0)) - - mockMvc - .perform( - multipart("/api/v1/wishlists/images") - .file(emptyImage) - .header(HttpHeaders.AUTHORIZATION, "Bearer ${memberToken(userId)}"), - ).andExpect(status().isBadRequest) - } - - @Test - fun `지원하지 않는 이미지 형식으로 등록하면 400 BAD_REQUEST 가 반환된다`() { - val mockMvc = buildMockMvc() - val userId = UUID.randomUUID() - insertMember(userId) - val gif = MockMultipartFile("images", "product.gif", "image/gif", byteArrayOf(1, 2, 3)) - - mockMvc - .perform( - multipart("/api/v1/wishlists/images") - .file(gif) - .header(HttpHeaders.AUTHORIZATION, "Bearer ${memberToken(userId)}"), - ).andExpect(status().isBadRequest) - } } diff --git a/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishlistRegisterAsyncIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishlistRegisterAsyncIntegrationTest.kt index 763faf75f..b4f2c2987 100644 --- a/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishlistRegisterAsyncIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishlistRegisterAsyncIntegrationTest.kt @@ -30,10 +30,8 @@ import org.springframework.beans.factory.annotation.Autowired import org.springframework.http.HttpHeaders import org.springframework.http.MediaType import org.springframework.jdbc.core.JdbcTemplate -import org.springframework.mock.web.MockMultipartFile import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity import org.springframework.test.web.servlet.MockMvc -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status @@ -270,8 +268,7 @@ class WishlistRegisterAsyncIntegrationTest : IntegrationTestSupport() { stubImageSnapshotExtractor.build = { ProductSnapshot(link = null, name = "나이키 에어포스", price = 99_000, currency = "KRW", imageUrl = "https://img.example.com/af.png") } - val image = MockMultipartFile("images", "p.png", "image/png", byteArrayOf(1, 2, 3)) - val itemId = registerImageAndGetItemId(mockMvc, userId, image) + val itemId = registerImageAndGetItemId(mockMvc, userId) await().atMost(Duration.ofSeconds(5)).until { latestSnapshot(itemId)?.status == ItemStatus.READY @@ -296,8 +293,7 @@ class WishlistRegisterAsyncIntegrationTest : IntegrationTestSupport() { try { // 확정 실패(상품 아님)는 다시 해도 결과가 같아 즉시 FAILED 로 종결한다 (일시 외부 오류는 PROCESSING 유지 — 아래 별도 테스트). stubImageSnapshotExtractor.build = { throw ProductSnapshotException.notProductPage() } - val image = MockMultipartFile("images", "p.png", "image/png", byteArrayOf(1, 2, 3)) - val itemId = registerImageAndGetItemId(mockMvc, userId, image) + val itemId = registerImageAndGetItemId(mockMvc, userId) await().atMost(Duration.ofSeconds(5)).until { latestSnapshot(itemId)?.status == ItemStatus.FAILED @@ -327,8 +323,7 @@ class WishlistRegisterAsyncIntegrationTest : IntegrationTestSupport() { val userId = UUID.randomUUID() insertMember(userId) try { - val image = MockMultipartFile("images", "p.png", "image/png", byteArrayOf(1, 2, 3)) - val itemId = registerImageAndGetItemId(mockMvc, userId, image) + val itemId = registerImageAndGetItemId(mockMvc, userId) // 반납 → 재집힘이 실제로 돌아 실행 예산(MAX_ATTEMPTS)을 다 쓸 때까지. await().atMost(Duration.ofSeconds(20)).until { calls.get() >= ItemParsingService.MAX_ATTEMPTS } // 예산을 다 쓴 뒤에야 종결된다 — 첫 일시 오류에 FAILED 로 떨어지지 않는다. @@ -348,15 +343,27 @@ class WishlistRegisterAsyncIntegrationTest : IntegrationTestSupport() { stubImageSnapshotExtractor.build = { ProductSnapshot(link = null, name = "상품", price = 1_000, imageUrl = "https://img.example.com/p.png") } - val request = multipart("/api/v1/wishlists/images") - (1..5).forEach { i -> - request.file(MockMultipartFile("images", "p$i.png", "image/png", byteArrayOf(i.toByte()))) - } - request.header(HttpHeaders.AUTHORIZATION, "Bearer ${memberToken(userId)}") + val presignResponse = + mockMvc + .perform( + post("/api/v1/wishlists/images/presigned") + .contentType(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer ${memberToken(userId)}") + .content(objectMapper.writeValueAsString(mapOf("contentTypes" to List(5) { "image/png" }))), + ).andExpect(status().isOk) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + val uploads = objectMapper.readTree(presignResponse).path("data").path("uploads") + val imageKeys = (0 until uploads.size()).map { uploads.path(it).path("imageKey").asText() } val response = mockMvc - .perform(request) - .andExpect(status().isCreated) + .perform( + post("/api/v1/wishlists/images/confirm") + .contentType(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer ${memberToken(userId)}") + .content(objectMapper.writeValueAsString(mapOf("imageKeys" to imageKeys))), + ).andExpect(status().isCreated) .andExpect(jsonPath("$.data.length()").value(5)) // 등록 직후 응답은 모두 PENDING 이어야 한다 — 이미지도 link 처럼 작업 큐에 적재되고, 서버가 즉시 READY/PROCESSING 을 내리는 회귀를 잡는다. .andExpect(jsonPath("$.data[0].item.status").value("PENDING")) @@ -391,8 +398,7 @@ class WishlistRegisterAsyncIntegrationTest : IntegrationTestSupport() { stubImageSnapshotExtractor.build = { ProductSnapshot(link = null, name = "상품", price = 1_000, currency = "KRW", imageUrl = "https://img.example.com/p.png") } - val image = MockMultipartFile("images", "p.png", "image/png", byteArrayOf(1, 2, 3)) - val itemId = registerImageAndGetItemId(mockMvc, userId, image) + val itemId = registerImageAndGetItemId(mockMvc, userId) await().atMost(Duration.ofSeconds(5)).until { latestSnapshot(itemId)?.status == ItemStatus.READY } // 파싱이 끝나면 등록 시 올린 raw 원본(items/raw/...)을 S3 에서 회수한다(누수 방지, best-effort 라 회수까지 await). @@ -676,17 +682,38 @@ class WishlistRegisterAsyncIntegrationTest : IntegrationTestSupport() { .asLong() } + // 이미지 한 장을 등록하고 그 item id 를 돌려준다 — 발급(presigned) → 확정(confirm) 2단계를 그대로 탄다. + // 업로드 자체는 클라가 S3 에 직접 하므로 여기선 재현하지 않는다(StubImageStorage.exists 기본값이 "올라왔다"). private fun registerImageAndGetItemId( mockMvc: MockMvc, userId: UUID, - image: MockMultipartFile, ): Long { + val presignResponse = + mockMvc + .perform( + post("/api/v1/wishlists/images/presigned") + .contentType(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer ${memberToken(userId)}") + .content(objectMapper.writeValueAsString(mapOf("contentTypes" to listOf("image/png")))), + ).andExpect(status().isOk) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + val imageKey = + objectMapper + .readTree(presignResponse) + .path("data") + .path("uploads") + .path(0) + .path("imageKey") + .asText() val response = mockMvc .perform( - multipart("/api/v1/wishlists/images") - .file(image) - .header(HttpHeaders.AUTHORIZATION, "Bearer ${memberToken(userId)}"), + post("/api/v1/wishlists/images/confirm") + .contentType(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer ${memberToken(userId)}") + .content(objectMapper.writeValueAsString(mapOf("imageKeys" to listOf(imageKey)))), ).andExpect(status().isCreated) .andReturn() .response