Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 가 하나 있어야 한다.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -61,16 +62,6 @@ class ImagePresignService(
}
}

// item 에 매이지 못한 raw(거부·실패 등록의 orphan)를 best-effort 로 회수한다 — v1(multipart) 경로가 persist 거부·실패 시 부른다.
// 삭제 실패가 원래 예외(클라이언트로 나갈 사유)를 덮지 않게 runCatching 으로 삼키고 경고만 남긴다. 회수 못 한 raw 는
// items/raw/ S3 lifecycle 이 백업으로 만료한다.
fun deleteRawsQuietly(imageKeys: List<String>) {
imageKeys.forEach { key ->
runCatching { imageStorage.delete(key) }
.onFailure { e -> log.warn("raw {} 회수 실패(lifecycle 이 만료): {}", key, e.message) }
}
}

companion object {
const val RAW_PREFIX = "items/raw/"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) 응답 설명. 이 축의 한도는 요청자가 아니라 **토너먼트 오너**의 몫에서 깎이므로,
Expand Down Expand Up @@ -306,138 +305,11 @@ interface TournamentItemApi {
request: AddTournamentItemFromLinkRequest,
): ApiResponseBody<AddTournamentItemFromLinkResponse>

@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<MultipartFile>?,
): ApiResponseBody<AddTournamentItemsFromImagesResponse>

@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 으로 되돌려준다.
""",
Expand Down Expand Up @@ -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} 를 조회해 결과를 확인한다.
""",
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<MultipartFile>?,
): ApiResponseBody<AddTournamentItemsFromImagesResponse> {
// 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(
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
): List<PersistedTournamentItem> = 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
Expand Down
Loading
Loading