diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 43f513e..d0145f3 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -18,6 +18,7 @@ ## ✅ Self-Check List - [ ] 스스로 코드를 한 번 이상 리뷰했습니다. (Self-review) -- [ ] `ktlint` 포맷팅과 Kotlin 공식 컨벤션을 준수했습니다. -- [ ] 불필요한 `!!` (Not-null assertion) 사용을 배제하고 안전하게 예외 처리(`?: throw`)를 했습니다. -- [ ] 코드에 불필요한 주석이나 `println`, TODO가 남아있지 않습니다. \ No newline at end of file +- [ ] `./gradlew spotlessApply` 로 포맷팅을 적용했습니다. +- [ ] `./gradlew build` 가 통과합니다. +- [ ] 예외를 `BusinessException` + `ErrorCode` 로 처리했습니다. +- [ ] 코드에 불필요한 주석이나 `System.out.println`, TODO 가 남아있지 않습니다. diff --git a/build.gradle.kts b/build.gradle.kts index 4e0eee4..ac5a352 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -18,11 +18,10 @@ repositories { mavenCentral() } -extra["springCloudAzureVersion"] = "7.1.0" - +extra["awsSdkVersion"] = "2.54.13" dependencyManagement { imports { - mavenBom("com.azure.spring:spring-cloud-azure-dependencies:${property("springCloudAzureVersion")}") + mavenBom("software.amazon.awssdk:bom:${property("awsSdkVersion")}") } } @@ -40,16 +39,17 @@ dependencies { runtimeOnly("org.postgresql:postgresql") implementation("com.pgvector:pgvector:0.1.6") - // DB Migration (Flyway) — Spring Boot 4는 자동설정이 spring-boot-flyway 모듈에 분리됨 + // DB Migration (Flyway) implementation("org.springframework.boot:spring-boot-flyway") implementation("org.flywaydb:flyway-core") implementation("org.flywaydb:flyway-database-postgresql") - // Azure Configuration (Key Vault secrets) - implementation("com.azure.spring:spring-cloud-azure-starter-keyvault-secrets") - - // Azure Blob Storage (SAS 발급용 — 업로드 프록시 아님) - implementation("com.azure:azure-storage-blob") + // AWS S3 + implementation("software.amazon.awssdk:s3") { + exclude(group = "software.amazon.awssdk", module = "netty-nio-client") + exclude(group = "software.amazon.awssdk", module = "apache-client") + } + implementation("software.amazon.awssdk:url-connection-client") // OpenAPI & Swagger (Spring Boot 4 compatible) implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3") @@ -74,7 +74,7 @@ dependencies { testAnnotationProcessor("org.projectlombok:lombok") testRuntimeOnly("org.junit.platform:junit-platform-launcher") - // Test - Testcontainers (통합 테스트용 PostgreSQL). Testcontainers 2.x 아티팩트 명명 사용 + // Test - Testcontainers testImplementation("org.springframework.boot:spring-boot-testcontainers") testImplementation("org.testcontainers:testcontainers-junit-jupiter") testImplementation("org.testcontainers:testcontainers-postgresql") @@ -84,7 +84,6 @@ tasks.withType { useJUnitPlatform() } -// Spring Boot 실행 가능 jar만 생성 (plain jar 비활성 → Docker COPY 글롭 모호성 제거) tasks.named("jar") { enabled = false } diff --git a/infra/terraform/edge.tf b/infra/terraform/edge.tf index 4e6fc53..f5f460a 100644 --- a/infra/terraform/edge.tf +++ b/infra/terraform/edge.tf @@ -89,7 +89,7 @@ resource "aws_cloudfront_distribution" "main" { } ordered_cache_behavior { - path_pattern = "/images/*" + path_pattern = "/menus/*" target_origin_id = "images" viewer_protocol_policy = "redirect-to-https" allowed_methods = ["GET", "HEAD"] diff --git a/src/main/java/com/hanspoon/backend_api/domain/scan/dto/StartScanRequest.java b/src/main/java/com/hanspoon/backend_api/domain/scan/dto/StartScanRequest.java index cb270b7..b3e8d5c 100644 --- a/src/main/java/com/hanspoon/backend_api/domain/scan/dto/StartScanRequest.java +++ b/src/main/java/com/hanspoon/backend_api/domain/scan/dto/StartScanRequest.java @@ -4,14 +4,14 @@ import jakarta.validation.constraints.NotBlank; /** - * 스캔 시작 요청. 이미지는 미리 Blob 에 업로드된 상태이고, 그 키(또는 우리 컨테이너 URL)를 넘긴다. + * 스캔 시작 요청. 이미지는 presigned URL 로 S3 에 업로드된 상태이고, 그 객체 키를 넘긴다. * *

표시용 제목(title)은 받지 않는다 — 생성 시엔 기본값(스캔 시각)으로 보이고, 수정은 마이페이지의 별도 API 담당. * - * @param storageKey 업로드된 blob 키(예: menu-xxxx.jpg) 또는 우리 컨테이너의 imageUrl + * @param storageKey 업로드된 S3 객체 키 (예: scans/{userId}/{uuid}.jpg) * @param source 이미지 소스 (camera | upload), 선택 */ @Schema(description = "스캔 시작 요청") public record StartScanRequest( - @Schema(description = "업로드된 blob 키 또는 우리 컨테이너 imageUrl", example = "menu-9f3c.jpg") @NotBlank String storageKey, + @Schema(description = "업로드된 S3 객체 키", example = "scans/3f2a.../9f3c....jpg") @NotBlank String storageKey, @Schema(description = "이미지 소스", example = "upload") String source) {} diff --git a/src/main/java/com/hanspoon/backend_api/domain/scan/service/ScanProcessor.java b/src/main/java/com/hanspoon/backend_api/domain/scan/service/ScanProcessor.java index cea8a27..537a295 100644 --- a/src/main/java/com/hanspoon/backend_api/domain/scan/service/ScanProcessor.java +++ b/src/main/java/com/hanspoon/backend_api/domain/scan/service/ScanProcessor.java @@ -16,7 +16,7 @@ import com.hanspoon.backend_api.domain.scan.repository.MenuAnalysisRepository; import com.hanspoon.backend_api.domain.scan.repository.MenuImageRepository; import com.hanspoon.backend_api.domain.scan.repository.ScanSessionRepository; -import com.hanspoon.backend_api.domain.upload.service.BlobStorageService; +import com.hanspoon.backend_api.domain.upload.service.S3StorageService; import com.hanspoon.backend_api.domain.user.entity.UserAllergy; import com.hanspoon.backend_api.domain.user.entity.UserProfile; import com.hanspoon.backend_api.domain.user.repository.UserAllergyRepository; @@ -33,12 +33,6 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; -/** - * 스캔 비동기 파이프라인. read SAS 생성 → OCR → (needs_retake 분기) → 프로필 매핑 → RuleEngine → ai_result → - * OCR↔Final index 머지 → 영속화. {@link ScanService} 와 분리된 빈이라 @Async 프록시가 정상 적용된다. - * - *

실패해도 예외를 호출자에게 던지지 않고 scan_status 를 FAILED 로 남긴다(폴링으로 확인). - */ @Component public class ScanProcessor { @@ -46,7 +40,7 @@ public class ScanProcessor { private static final String NEEDS_RETAKE = "needs_retake"; private final AiClient aiClient; - private final BlobStorageService blobStorageService; + private final S3StorageService s3StorageService; private final UserProfileRepository userProfileRepository; private final UserAllergyRepository userAllergyRepository; private final ScanSessionRepository scanSessionRepository; @@ -55,14 +49,14 @@ public class ScanProcessor { public ScanProcessor( AiClient aiClient, - BlobStorageService blobStorageService, + S3StorageService s3StorageService, UserProfileRepository userProfileRepository, UserAllergyRepository userAllergyRepository, ScanSessionRepository scanSessionRepository, MenuImageRepository menuImageRepository, MenuAnalysisRepository menuAnalysisRepository) { this.aiClient = aiClient; - this.blobStorageService = blobStorageService; + this.s3StorageService = s3StorageService; this.userProfileRepository = userProfileRepository; this.userAllergyRepository = userAllergyRepository; this.scanSessionRepository = scanSessionRepository; @@ -79,8 +73,8 @@ public void process(UUID scanId, UUID userId, String storageKey, String source) return; } try { - // 1) read SAS → OCR - String imageUrl = blobStorageService.createReadSasUrl(storageKey); + // 1) presigned GET URL → OCR + String imageUrl = s3StorageService.createReadUrl(storageKey); OcrResponse ocr = aiClient.requestOcr(new OcrRequest(source, storageKey, imageUrl)); // 2) menu_image 저장 + 세션에 OCR 메타 반영 @@ -135,7 +129,7 @@ private void persistMenuImage(UUID scanId, String source, String storageKey, Ocr fileSize = ocr.menuImage().fileSize(); } menuImageRepository.save(MenuImage.create( - scanId, resolvedSource, storageKey, blobStorageService.blobUrl(storageKey), mimeType, fileSize)); + scanId, resolvedSource, storageKey, s3StorageService.objectUri(storageKey), mimeType, fileSize)); } private boolean isNeedsRetake(OcrResponse ocr) { diff --git a/src/main/java/com/hanspoon/backend_api/domain/scan/service/ScanService.java b/src/main/java/com/hanspoon/backend_api/domain/scan/service/ScanService.java index 8382733..6c5ac14 100644 --- a/src/main/java/com/hanspoon/backend_api/domain/scan/service/ScanService.java +++ b/src/main/java/com/hanspoon/backend_api/domain/scan/service/ScanService.java @@ -11,7 +11,7 @@ import com.hanspoon.backend_api.domain.scan.entity.ScanStatus; import com.hanspoon.backend_api.domain.scan.repository.MenuAnalysisRepository; import com.hanspoon.backend_api.domain.scan.repository.ScanSessionRepository; -import com.hanspoon.backend_api.domain.upload.service.BlobStorageService; +import com.hanspoon.backend_api.domain.upload.service.S3StorageService; import com.hanspoon.backend_api.global.common.PageResponse; import com.hanspoon.backend_api.global.exception.BusinessException; import com.hanspoon.backend_api.global.exception.ErrorCode; @@ -21,32 +21,33 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -/** - * 스캔 시작/조회. 시작은 세션을 PROCESSING 으로 저장(즉시 커밋)한 뒤 {@link ScanProcessor} 비동기 처리를 트리거한다. - * - *

startScan 은 의도적으로 비트랜잭션 — 세션 저장이 즉시 커밋돼야 별도 스레드의 비동기 작업이 그 행을 조회할 수 있다. - */ @Service public class ScanService { - private final BlobStorageService blobStorageService; + private final S3StorageService s3StorageService; private final ScanSessionRepository scanSessionRepository; private final MenuAnalysisRepository menuAnalysisRepository; private final ScanProcessor scanProcessor; public ScanService( - BlobStorageService blobStorageService, + S3StorageService s3StorageService, ScanSessionRepository scanSessionRepository, MenuAnalysisRepository menuAnalysisRepository, ScanProcessor scanProcessor) { - this.blobStorageService = blobStorageService; + this.s3StorageService = s3StorageService; this.scanSessionRepository = scanSessionRepository; this.menuAnalysisRepository = menuAnalysisRepository; this.scanProcessor = scanProcessor; } public ScanCreatedResponse startScan(UUID userId, StartScanRequest request) { - String storageKey = blobStorageService.extractStorageKey(request.storageKey()); + // 형식 · 소유권 검증 (외부 입력을 받는 유일한 지점) + String storageKey = s3StorageService.resolveKey(userId, request.storageKey()); + + // presigned PUT 은 서버가 내용을 모르므로 실제 업로드 여부·크기·타입을 여기서 확인한다. + // 비동기로 넘긴 뒤 실패하면 사용자는 폴링만 하다 FAILED 를 받게 된다. + s3StorageService.verifyUploadObject(storageKey); + // title 은 생성 시 null — 조회 때 기본값(스캔 시각)으로 보이고, 수정은 마이페이지 API 담당 ScanSession session = scanSessionRepository.save(ScanSession.create(userId, null, null, null, ScanStatus.PROCESSING, null)); diff --git a/src/main/java/com/hanspoon/backend_api/domain/upload/controller/UploadController.java b/src/main/java/com/hanspoon/backend_api/domain/upload/controller/UploadController.java index 427f9cc..5fd4337 100644 --- a/src/main/java/com/hanspoon/backend_api/domain/upload/controller/UploadController.java +++ b/src/main/java/com/hanspoon/backend_api/domain/upload/controller/UploadController.java @@ -1,34 +1,34 @@ package com.hanspoon.backend_api.domain.upload.controller; -import com.hanspoon.backend_api.domain.upload.dto.UploadSasRequest; +import com.hanspoon.backend_api.domain.upload.dto.UploadTicketRequest; import com.hanspoon.backend_api.domain.upload.dto.UploadTicketResponse; -import com.hanspoon.backend_api.domain.upload.service.BlobStorageService; +import com.hanspoon.backend_api.domain.upload.service.S3StorageService; +import com.hanspoon.backend_api.global.security.CurrentUser; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; +import java.util.UUID; 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; -/** - * 1. 업로드 SAS 발급. 인증된 사용자만 호출. - * 2. FE 하드코딩 컨테이너 SAS 에서는 미사용으로 남는다. - */ -@Tag(name = "Upload", description = "이미지 업로드용 Blob SAS 발급 API") +/** 이미지 업로드용 presigned URL 발급. 인증된 사용자만 호출. */ +@Tag(name = "Upload", description = "이미지 업로드용 presigned URL 발급 API") @RestController @RequestMapping("/api/v1/uploads") public class UploadController { - private final BlobStorageService blobStorageService; + private final S3StorageService s3StorageService; - public UploadController(BlobStorageService blobStorageService) { - this.blobStorageService = blobStorageService; + public UploadController(S3StorageService s3StorageService) { + this.s3StorageService = s3StorageService; } - @Operation(summary = "이미지 업로드용 쓰기 SAS 발급. FE 는 uploadUrl 로 PUT 후 스캔 요청에 storageKey 전달") + @Operation(summary = "업로드용 presigned PUT URL 발급. FE 는 uploadUrl 로 PUT 후 스캔 요청에 storageKey 전달") @PostMapping("/sas") - public UploadTicketResponse issueUploadSas(@Valid @RequestBody UploadSasRequest request) { - return blobStorageService.createUploadSas(request.contentType()); + public UploadTicketResponse issueUploadTicket( + @CurrentUser String userId, @Valid @RequestBody UploadTicketRequest request) { + return s3StorageService.createUploadUrl(UUID.fromString(userId), request.contentType()); } } diff --git a/src/main/java/com/hanspoon/backend_api/domain/upload/dto/UploadSasRequest.java b/src/main/java/com/hanspoon/backend_api/domain/upload/dto/UploadTicketRequest.java similarity index 55% rename from src/main/java/com/hanspoon/backend_api/domain/upload/dto/UploadSasRequest.java rename to src/main/java/com/hanspoon/backend_api/domain/upload/dto/UploadTicketRequest.java index 159e94a..e1872c0 100644 --- a/src/main/java/com/hanspoon/backend_api/domain/upload/dto/UploadSasRequest.java +++ b/src/main/java/com/hanspoon/backend_api/domain/upload/dto/UploadTicketRequest.java @@ -3,11 +3,6 @@ import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; -/** - * 쓰기 SAS 발급 요청 (Path B). - * - * @param contentType 업로드할 이미지 MIME 타입 (예: image/jpeg) - */ -@Schema(description = "업로드 SAS 발급 요청") -public record UploadSasRequest( +@Schema(description = "업로드 티켓 발급 요청") +public record UploadTicketRequest( @Schema(description = "이미지 MIME 타입", example = "image/jpeg") @NotBlank String contentType) {} diff --git a/src/main/java/com/hanspoon/backend_api/domain/upload/dto/UploadTicketResponse.java b/src/main/java/com/hanspoon/backend_api/domain/upload/dto/UploadTicketResponse.java index 8547e7b..339720d 100644 --- a/src/main/java/com/hanspoon/backend_api/domain/upload/dto/UploadTicketResponse.java +++ b/src/main/java/com/hanspoon/backend_api/domain/upload/dto/UploadTicketResponse.java @@ -3,13 +3,5 @@ import io.swagger.v3.oas.annotations.media.Schema; import java.time.Instant; -/** - * 쓰기 SAS 발급 응답 (Path B). FE 는 {@code uploadUrl} 로 이미지를 PUT 하고, 스캔 요청 시 {@code storageKey} 를 보낸다. - * - * @param storageKey 서버가 생성한 blob 명 (예: menu-xxxx.jpg) - * @param uploadUrl 쓰기 SAS 가 붙은 업로드 URL (PUT 대상) - * @param readUrl SAS 없는 평문 blob URL (참조용) - * @param expiresAt 업로드 SAS 만료 시각 - */ -@Schema(description = "업로드 SAS 발급 응답") -public record UploadTicketResponse(String storageKey, String uploadUrl, String readUrl, Instant expiresAt) {} +@Schema(description = "업로드 티켓 발급 응답") +public record UploadTicketResponse(String storageKey, String uploadUrl, Instant expiresAt) {} diff --git a/src/main/java/com/hanspoon/backend_api/domain/upload/service/BlobStorageService.java b/src/main/java/com/hanspoon/backend_api/domain/upload/service/BlobStorageService.java deleted file mode 100644 index f78e7e4..0000000 --- a/src/main/java/com/hanspoon/backend_api/domain/upload/service/BlobStorageService.java +++ /dev/null @@ -1,123 +0,0 @@ -package com.hanspoon.backend_api.domain.upload.service; - -import com.azure.storage.blob.BlobClient; -import com.azure.storage.blob.BlobContainerClient; -import com.azure.storage.blob.sas.BlobSasPermission; -import com.azure.storage.blob.sas.BlobServiceSasSignatureValues; -import com.hanspoon.backend_api.domain.upload.dto.UploadTicketResponse; -import com.hanspoon.backend_api.global.exception.BusinessException; -import com.hanspoon.backend_api.global.exception.ErrorCode; -import java.time.Duration; -import java.time.OffsetDateTime; -import java.util.List; -import java.util.Map; -import java.util.UUID; -import java.util.regex.Pattern; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Service; - -/** - * Blob 업로드/읽기 SAS 발급. - * - A: FE 가 Blob 에 직접 업로드(백엔드 우회)하므로 백엔드는 업로드 바이트를 다루지 않고 단일 blob 범위의 짧은 SAS 만 발급한다. - * - B: FE 가 백엔드에서 쓰기 SAS 를 받아 업로드. Path A 보다 보안은 약간 향상되나, 여전히 SAS URL 이 노출될 수 있으므로 짧은 TTL 권장. - * - *

- */ -@Service -public class BlobStorageService { - - private static final Pattern STORAGE_KEY_PATTERN = Pattern.compile("^menu-[A-Za-z0-9-]+\\.(jpg|jpeg|png|webp)$"); - - private static final Map CONTENT_TYPE_EXTENSION = - Map.of("image/jpeg", "jpg", "image/png", "png", "image/webp", "webp"); - - private final BlobContainerClient containerClient; - private final Duration uploadSasTtl; - private final Duration readSasTtl; - private final List allowedContentTypes; - - public BlobStorageService( - BlobContainerClient menuImageContainerClient, - @Value("${app.blob.upload-sas-ttl:10m}") Duration uploadSasTtl, - @Value("${app.blob.read-sas-ttl:15m}") Duration readSasTtl, - @Value("${app.blob.allowed-content-types:image/jpeg,image/png,image/webp}") - List allowedContentTypes) { - this.containerClient = menuImageContainerClient; - this.uploadSasTtl = uploadSasTtl; - this.readSasTtl = readSasTtl; - this.allowedContentTypes = allowedContentTypes; - } - - /** B: 단일 blob 쓰기 SAS 발급. */ - public UploadTicketResponse createUploadSas(String contentType) { - String normalized = contentType == null ? "" : contentType.trim().toLowerCase(); - String extension = CONTENT_TYPE_EXTENSION.get(normalized); - if (extension == null || !allowedContentTypes.contains(normalized)) { - throw new BusinessException(ErrorCode.INVALID_CONTENT_TYPE, "Unsupported content type: " + contentType); - } - - String storageKey = "menu-" + UUID.randomUUID() + "." + extension; - BlobClient blob = containerClient.getBlobClient(storageKey); - OffsetDateTime expiry = OffsetDateTime.now().plus(uploadSasTtl); - BlobSasPermission permission = - new BlobSasPermission().setCreatePermission(true).setWritePermission(true); - - String sas = generateSas(blob, expiry, permission); - return new UploadTicketResponse( - storageKey, blob.getBlobUrl() + "?" + sas, blob.getBlobUrl(), expiry.toInstant()); - } - - /** A, B 공유 코어: storageKey 로 읽기 SAS URL 발급 (AI OCR 이 private blob 을 읽도록). */ - public String createReadSasUrl(String storageKey) { - String key = validateKey(storageKey); - BlobClient blob = containerClient.getBlobClient(key); - OffsetDateTime expiry = OffsetDateTime.now().plus(readSasTtl); - BlobSasPermission permission = new BlobSasPermission().setReadPermission(true); - - String sas = generateSas(blob, expiry, permission); - return blob.getBlobUrl() + "?" + sas; - } - - /** SAS 없는 평문 blob URL (DB 저장용 — SAS 는 휘발성이라 미저장). */ - public String blobUrl(String storageKey) { - return containerClient.getBlobClient(validateKey(storageKey)).getBlobUrl(); - } - - /** A, B 공유 코어: storageKey 또는 imageUrl(Path A) 을 받아 blob 명으로 정규화. 우리 컨테이너 외 URL 은 거부(SSRF 가드). */ - public String extractStorageKey(String storageKeyOrUrl) { - if (storageKeyOrUrl == null || storageKeyOrUrl.isBlank()) { - throw new BusinessException(ErrorCode.INVALID_STORAGE_KEY, "storageKey/imageUrl is blank."); - } - String value = storageKeyOrUrl.trim(); - if (value.startsWith("http://") || value.startsWith("https://")) { - String prefix = containerClient.getBlobContainerUrl() + "/"; - if (!value.startsWith(prefix)) { - throw new BusinessException(ErrorCode.INVALID_STORAGE_KEY, "URL is outside the managed container."); - } - String afterPrefix = value.substring(prefix.length()); - int query = afterPrefix.indexOf('?'); - value = query >= 0 ? afterPrefix.substring(0, query) : afterPrefix; - } - return validateKey(value); - } - - private String validateKey(String key) { - if (key == null || !STORAGE_KEY_PATTERN.matcher(key).matches()) { - throw new BusinessException(ErrorCode.INVALID_STORAGE_KEY, "Invalid storage key: " + key); - } - return key; - } - - private String generateSas(BlobClient blob, OffsetDateTime expiry, BlobSasPermission permission) { - try { - return blob.generateSas(new BlobServiceSasSignatureValues(expiry, permission)); - } catch (RuntimeException exception) { - throw new BusinessException(ErrorCode.BLOB_SAS_ERROR, "Failed to sign blob SAS.", exception); - } - } -} diff --git a/src/main/java/com/hanspoon/backend_api/domain/upload/service/S3StorageService.java b/src/main/java/com/hanspoon/backend_api/domain/upload/service/S3StorageService.java new file mode 100644 index 0000000..38c86dc --- /dev/null +++ b/src/main/java/com/hanspoon/backend_api/domain/upload/service/S3StorageService.java @@ -0,0 +1,140 @@ +package com.hanspoon.backend_api.domain.upload.service; + +import com.hanspoon.backend_api.domain.upload.dto.UploadTicketResponse; +import com.hanspoon.backend_api.global.config.S3Properties; +import com.hanspoon.backend_api.global.exception.BusinessException; +import com.hanspoon.backend_api.global.exception.ErrorCode; +import java.time.Instant; +import java.util.Map; +import java.util.UUID; +import java.util.regex.Pattern; +import org.springframework.stereotype.Service; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.*; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; + +@Service +public class S3StorageService { + + private static final String SCAN_PREFIX = "scans/"; + + // 브라우저가 실제로 보내는 표준 MIME 만 허용. + // ⚠ 값(확장자)은 아래 KEY_TAIL_PATTERN 의 허용 목록과 반드시 일치해야 한다. + // 불일치하면 createUploadUrl 이 만든 키를 resolveKey 가 거부한다. + private static final Map EXTENSIONS = Map.of( + "image/jpeg", "jpg", + "image/png", "png", + "image/webp", "webp"); + + private static final Pattern KEY_TAIL_PATTERN = + Pattern.compile("^[0-9a-fA-F-]{36}/[A-Za-z0-9][A-Za-z0-9._-]{0,127}\\.(jpg|png|webp)$"); + + private final S3Presigner presigner; + private final S3Client s3Client; + private final S3Properties properties; + + public S3StorageService(S3Presigner presigner, S3Client s3Client, S3Properties properties) { + this.presigner = presigner; + this.s3Client = s3Client; + this.properties = properties; + } + + public UploadTicketResponse createUploadUrl(UUID userId, String contentType) { + String normalized = contentType == null ? "" : contentType.trim().toLowerCase(); + String extension = EXTENSIONS.get(normalized); + + if (extension == null) { + throw new BusinessException(ErrorCode.INVALID_CONTENT_TYPE, "Unsupported content type: " + contentType); + } + + // Server에서 Key 생성. + String key = "%s%s/%s.%s".formatted(SCAN_PREFIX, userId, UUID.randomUUID(), extension); + + PutObjectRequest putRequest = PutObjectRequest.builder() + .bucket(properties.bucket()) + .key(key) + .contentType(normalized) + // 같은 URL이 재사용되어 기존 객체가 덮어쓰여지는 것 방지. + .ifNoneMatch("*") + .build(); + + try { + PresignedPutObjectRequest signedRequest = presigner.presignPutObject(builder -> + builder.signatureDuration(properties.uploadUrlTtl()).putObjectRequest(putRequest)); + + return new UploadTicketResponse( + key, signedRequest.url().toString(), Instant.now().plus(properties.uploadUrlTtl())); + } catch (RuntimeException exception) { + throw new BusinessException(ErrorCode.STORAGE_PRESIGN_ERROR, "Failed to presign upload URL.", exception); + } + } + + // 프론트가 보낸 키 검증, 유효하면 그대로 반환. (S3에 접근할 때 사용) + public String resolveKey(UUID userId, String storageKey) { + if (storageKey == null || storageKey.isBlank()) { + throw new BusinessException(ErrorCode.INVALID_STORAGE_KEY, "storageKey is blank."); + } + + String key = storageKey.trim(); + + if (!key.startsWith(SCAN_PREFIX) || key.contains("..") || key.contains("//")) { + throw new BusinessException(ErrorCode.INVALID_STORAGE_KEY, "Invalid storage key: " + key); + } + + String tail = key.substring(SCAN_PREFIX.length()); + if (!KEY_TAIL_PATTERN.matcher(tail).matches()) { + throw new BusinessException(ErrorCode.INVALID_STORAGE_KEY, "Invalid storage key: " + key); + } + + if (!tail.startsWith(userId + "/")) { + throw new BusinessException(ErrorCode.ACCESS_DENIED, "Storage key belongs to another user."); + } + + return key; + } + + // 객체가 실제로 올라왔는지, 크기·타입이 정책에 맞는지 확인. + public HeadObjectResponse verifyUploadObject(String key) { + try { + HeadObjectResponse object = s3Client.headObject( + builder -> builder.bucket(properties.bucket()).key(key)); + + if (object.contentLength() > properties.maxFileSize()) { + throw new BusinessException(ErrorCode.FILE_TOO_LARGE); + } + + if (!EXTENSIONS.containsKey(object.contentType())) { + throw new BusinessException(ErrorCode.INVALID_CONTENT_TYPE); + } + + return object; + } catch (S3Exception exception) { + if (exception.statusCode() == 404) { + throw new BusinessException(ErrorCode.UPLOAD_NOT_FOUND, "Object not found: " + key, exception); + } + throw new BusinessException(ErrorCode.STORAGE_PRESIGN_ERROR, "Failed to read object metadata.", exception); + } + } + + // presigned GET URL 발급 (객체 조회) + public String createReadUrl(String key) { + GetObjectRequest getRequest = + GetObjectRequest.builder().bucket(properties.bucket()).key(key).build(); + + try { + return presigner + .presignGetObject(builder -> + builder.signatureDuration(properties.readUrlTtl()).getObjectRequest(getRequest)) + .url() + .toString(); + } catch (RuntimeException exception) { + throw new BusinessException(ErrorCode.STORAGE_PRESIGN_ERROR, "Failed to presign read URL.", exception); + } + } + + // DB 기록용 객체 좌표. + public String objectUri(String key) { + return "s3://" + properties.bucket() + "/" + key; + } +} diff --git a/src/main/java/com/hanspoon/backend_api/global/config/BlobStorageConfig.java b/src/main/java/com/hanspoon/backend_api/global/config/BlobStorageConfig.java deleted file mode 100644 index 7c44e46..0000000 --- a/src/main/java/com/hanspoon/backend_api/global/config/BlobStorageConfig.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.hanspoon.backend_api.global.config; - -import com.azure.storage.blob.BlobContainerClient; -import com.azure.storage.blob.BlobServiceClientBuilder; -import com.azure.storage.common.StorageSharedKeyCredential; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -/** - * Azure Blob 컨테이너 클라이언트. 계정키 기반 {@link StorageSharedKeyCredential} 로 SAS 를 로컬 서명한다(네트워크 불필요). - * 클라이언트 생성은 지연(lazy)이라 부팅 시 네트워크 호출이 없다 → 로컬에서 키 없이도 기동 가능. - * (하드닝 후속: 계정키 대신 Managed Identity + User Delegation SAS) - */ -@Configuration -public class BlobStorageConfig { - - // 로컬/테스트에서 키 없이도 기동 가능하도록 placeholder(base64). 실제 SAS 발급에는 운영의 BLOB_ACCOUNT_KEY(Key Vault) 필요. - private static final String PLACEHOLDER_KEY = "ZGV2LXBsYWNlaG9sZGVy"; // base64("dev-placeholder") - - @Bean - public BlobContainerClient menuImageContainerClient( - @Value("${app.blob.account-name}") String accountName, - @Value("${app.blob.account-key:}") String accountKey, - @Value("${app.blob.container}") String container) { - - String effectiveKey = (accountKey == null || accountKey.isBlank()) ? PLACEHOLDER_KEY : accountKey; - return new BlobServiceClientBuilder() - .endpoint("https://" + accountName + ".blob.core.windows.net") - .credential(new StorageSharedKeyCredential(accountName, effectiveKey)) - .buildClient() - .getBlobContainerClient(container); - } -} diff --git a/src/main/java/com/hanspoon/backend_api/global/config/OpenApiConfig.java b/src/main/java/com/hanspoon/backend_api/global/config/OpenApiConfig.java deleted file mode 100644 index 9cff2a8..0000000 --- a/src/main/java/com/hanspoon/backend_api/global/config/OpenApiConfig.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.hanspoon.backend_api.global.config; - -import io.swagger.v3.oas.models.Components; -import io.swagger.v3.oas.models.OpenAPI; -import io.swagger.v3.oas.models.info.Info; -import io.swagger.v3.oas.models.security.SecurityRequirement; -import io.swagger.v3.oas.models.security.SecurityScheme; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -/** - * OpenAPI(Swagger UI) 설정. Bearer(JWT) 인증 스킴을 전역으로 등록해 - * Swagger UI 의 "Authorize" 버튼에 access token 을 넣고 보호된 API 를 호출할 수 있게 한다. - * (permitAll 엔드포인트는 토큰 없이도 동작한다.) - */ -@Configuration -public class OpenApiConfig { - - private static final String BEARER_SCHEME = "bearerAuth"; - - @Bean - public OpenAPI openAPI() { - return new OpenAPI() - .info(new Info().title("Han-Spoon API").version("v1").description("Han-Spoon 백엔드 API")) - .addSecurityItem(new SecurityRequirement().addList(BEARER_SCHEME)) - .components(new Components() - .addSecuritySchemes( - BEARER_SCHEME, - new SecurityScheme() - .name(BEARER_SCHEME) - .type(SecurityScheme.Type.HTTP) - .scheme("bearer") - .bearerFormat("JWT"))); - } -} diff --git a/src/main/java/com/hanspoon/backend_api/global/config/RestClientConfig.java b/src/main/java/com/hanspoon/backend_api/global/config/RestClientConfig.java index a6f8b9b..2900d7f 100644 --- a/src/main/java/com/hanspoon/backend_api/global/config/RestClientConfig.java +++ b/src/main/java/com/hanspoon/backend_api/global/config/RestClientConfig.java @@ -17,25 +17,9 @@ public RestClient.Builder restClientBuilder() { return RestClient.builder(); } - @Bean - public RestClient azureAiRestClient( - RestClient.Builder builder, - @Value("${app.azure-ai.endpoint:https://han-spoon-openai.openai.azure.com}") String azureAiEndpoint, - @Value("${app.azure-ai.api-key:}") String azureAiApiKey, - @Value("${app.rest-client.connect-timeout:3s}") Duration connectTimeout, - @Value("${app.rest-client.read-timeout:10s}") Duration readTimeout) { - - return builder.baseUrl(azureAiEndpoint) - .requestFactory(clientHttpRequestFactory(connectTimeout, readTimeout)) - .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) - .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) - .defaultHeader("api-key", azureAiApiKey) - .build(); - } - /** * AI 서비스(OCR + Rule Engine) 호출용 RestClient. - * OCR 은 Azure Document Intelligence 를 거쳐 느리므로 read-timeout 을 길게 둔다. + * OCR + 룰엔진 + 결과생성 파이프라인이 길어 read-timeout 을 넉넉히 둔다. */ @Bean public RestClient aiServiceRestClient( diff --git a/src/main/java/com/hanspoon/backend_api/global/config/S3Properties.java b/src/main/java/com/hanspoon/backend_api/global/config/S3Properties.java new file mode 100644 index 0000000..075183d --- /dev/null +++ b/src/main/java/com/hanspoon/backend_api/global/config/S3Properties.java @@ -0,0 +1,8 @@ +package com.hanspoon.backend_api.global.config; + +import java.time.Duration; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties("app.s3") +public record S3Properties( + String bucket, String region, String endpoint, Duration uploadUrlTtl, Duration readUrlTtl, long maxFileSize) {} diff --git a/src/main/java/com/hanspoon/backend_api/global/config/S3StorageConfig.java b/src/main/java/com/hanspoon/backend_api/global/config/S3StorageConfig.java new file mode 100644 index 0000000..bd73c10 --- /dev/null +++ b/src/main/java/com/hanspoon/backend_api/global/config/S3StorageConfig.java @@ -0,0 +1,42 @@ +package com.hanspoon.backend_api.global.config; + +import java.net.URI; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.S3Configuration; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +@Configuration +@EnableConfigurationProperties(S3Properties.class) +public class S3StorageConfig { + + @Bean + S3Client s3Client(S3Properties properties) { + software.amazon.awssdk.services.s3.S3ClientBuilder builder = + S3Client.builder().region(Region.of(properties.region())); + applyEndpointOverride(properties, builder::endpointOverride, builder::serviceConfiguration); + return builder.build(); + } + + @Bean + S3Presigner s3Presigner(S3Properties properties) { + S3Presigner.Builder builder = S3Presigner.builder().region(Region.of(properties.region())); + applyEndpointOverride(properties, builder::endpointOverride, builder::serviceConfiguration); + return builder.build(); + } + + private void applyEndpointOverride( + S3Properties properties, + java.util.function.Consumer endpointSetter, + java.util.function.Consumer configSetter) { + if (properties.endpoint() == null || properties.endpoint().isBlank()) { + return; + } + endpointSetter.accept(URI.create(properties.endpoint())); + configSetter.accept( + S3Configuration.builder().pathStyleAccessEnabled(true).build()); + } +} diff --git a/src/main/java/com/hanspoon/backend_api/global/config/SecurityConfig.java b/src/main/java/com/hanspoon/backend_api/global/config/SecurityConfig.java index 6cd682b..81cccd6 100644 --- a/src/main/java/com/hanspoon/backend_api/global/config/SecurityConfig.java +++ b/src/main/java/com/hanspoon/backend_api/global/config/SecurityConfig.java @@ -1,6 +1,5 @@ package com.hanspoon.backend_api.global.config; -import com.fasterxml.jackson.databind.ObjectMapper; import com.hanspoon.backend_api.global.exception.ErrorCode; import com.nimbusds.jose.jwk.source.ImmutableSecret; import io.jsonwebtoken.security.Keys; @@ -31,6 +30,7 @@ import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.access.AccessDeniedHandler; +import tools.jackson.databind.ObjectMapper; @Configuration @EnableMethodSecurity @@ -123,7 +123,7 @@ private void writeProblemDetail( ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(status, errorCode.getMessage()); problemDetail.setTitle(status.getReasonPhrase()); - problemDetail.setType(URI.create("https://api.han-spoon.com/problems/" + errorCode.getCode())); + problemDetail.setType(URI.create("https://api.han-spoon.site/problems/" + errorCode.getCode())); problemDetail.setProperty("code", errorCode.getCode()); problemDetail.setProperty("timestamp", OffsetDateTime.now()); diff --git a/src/main/java/com/hanspoon/backend_api/global/config/SwaggerConfig.java b/src/main/java/com/hanspoon/backend_api/global/config/SwaggerConfig.java index 349095e..d577b89 100644 --- a/src/main/java/com/hanspoon/backend_api/global/config/SwaggerConfig.java +++ b/src/main/java/com/hanspoon/backend_api/global/config/SwaggerConfig.java @@ -26,7 +26,7 @@ public OpenAPI hanSpoonOpenApi() { private Info apiInfo() { return new Info() .title("Han-Spoon Backend API") - .description("Foreign tourist service API for Han-Spoon running on Azure Container Apps.") + .description("Foreign tourist service API for Han-Spoon.") .version("v1") .contact(new Contact().name("Han-Spoon Backend Team").email("backend@han-spoon.com")) .license(new License().name("Private").url("https://han-spoon.com")); diff --git a/src/main/java/com/hanspoon/backend_api/global/exception/ErrorCode.java b/src/main/java/com/hanspoon/backend_api/global/exception/ErrorCode.java index 3712de0..97ef2ca 100644 --- a/src/main/java/com/hanspoon/backend_api/global/exception/ErrorCode.java +++ b/src/main/java/com/hanspoon/backend_api/global/exception/ErrorCode.java @@ -4,26 +4,49 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; +/** + * 비즈니스 예외 코드. 도메인 패키지 구조를 따라 묶는다. + * + *

{@code code} 는 응답 바디의 {@code code} 필드로 그대로 나가는 API 계약이다. 값을 바꾸면 클라이언트의 에러 분기가 + * 깨지므로, 이름만 바꿀 때도 문자열은 유지할 것. + */ @Getter @RequiredArgsConstructor public enum ErrorCode { + + // ── 공통 ──────────────────────────────────────────────────────────── + INVALID_REQUEST(HttpStatus.BAD_REQUEST, "INVALID_REQUEST", "Invalid request."), + RATE_LIMIT_EXCEEDED(HttpStatus.TOO_MANY_REQUESTS, "RATE_LIMIT_EXCEEDED", "Too many requests."), + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_SERVER_ERROR", "Unexpected server error."), + + // ── 인증 · 인가 ───────────────────────────────────────────────────── + INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "INVALID_TOKEN", "Invalid or expired token."), + ACCESS_DENIED(HttpStatus.FORBIDDEN, "ACCESS_DENIED", "Access is denied."), + + // ── 사용자 · 프로필 ───────────────────────────────────────────────── USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_NOT_FOUND", "User not found."), PROFILE_NOT_FOUND(HttpStatus.NOT_FOUND, "PROFILE_NOT_FOUND", "Profile not found."), PROFILE_ALREADY_EXISTS(HttpStatus.CONFLICT, "PROFILE_ALREADY_EXISTS", "Profile already exists."), - INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "INVALID_TOKEN", "Invalid or expired token."), - ACCESS_DENIED(HttpStatus.FORBIDDEN, "ACCESS_DENIED", "Access is denied."), - RATE_LIMIT_EXCEEDED(HttpStatus.TOO_MANY_REQUESTS, "RATE_LIMIT_EXCEEDED", "Too many requests."), - INVALID_REQUEST(HttpStatus.BAD_REQUEST, "INVALID_REQUEST", "Invalid request."), - OCR_SERVICE_ERROR(HttpStatus.BAD_GATEWAY, "OCR_SERVICE_ERROR", "OCR service failed."), - RULE_ENGINE_ERROR(HttpStatus.BAD_GATEWAY, "RULE_ENGINE_ERROR", "Rule engine evaluation failed."), - AI_SERVICE_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "AI_SERVICE_UNAVAILABLE", "AI service is unavailable."), + + // ── 업로드 · 스토리지(S3) ─────────────────────────────────────────── INVALID_CONTENT_TYPE(HttpStatus.BAD_REQUEST, "INVALID_CONTENT_TYPE", "Unsupported image content type."), - INVALID_STORAGE_KEY(HttpStatus.BAD_REQUEST, "INVALID_STORAGE_KEY", "Invalid storage key or image URL."), - BLOB_SAS_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "BLOB_SAS_ERROR", "Failed to issue blob SAS."), + INVALID_STORAGE_KEY(HttpStatus.BAD_REQUEST, "INVALID_STORAGE_KEY", "Invalid storage key."), + FILE_TOO_LARGE(HttpStatus.PAYLOAD_TOO_LARGE, "FILE_TOO_LARGE", "Uploaded file exceeds the size limit."), + UPLOAD_NOT_FOUND(HttpStatus.NOT_FOUND, "UPLOAD_NOT_FOUND", "Uploaded object not found."), + STORAGE_PRESIGN_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "STORAGE_PRESIGN_ERROR", "Failed to presign storage URL."), + + // ── 스캔 ──────────────────────────────────────────────────────────── SCAN_NOT_FOUND(HttpStatus.NOT_FOUND, "SCAN_NOT_FOUND", "Scan not found."), + + // ── 소통 카드 ─────────────────────────────────────────────────────── CARD_NOT_FOUND(HttpStatus.NOT_FOUND, "CARD_NOT_FOUND", "Saved card not found."), + + // ── AI 서비스 연동 ────────────────────────────────────────────────── + OCR_SERVICE_ERROR(HttpStatus.BAD_GATEWAY, "OCR_SERVICE_ERROR", "OCR service failed."), + RULE_ENGINE_ERROR(HttpStatus.BAD_GATEWAY, "RULE_ENGINE_ERROR", "Rule engine evaluation failed."), RESULT_SERVICE_ERROR(HttpStatus.BAD_GATEWAY, "RESULT_SERVICE_ERROR", "Result generation failed."), - INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_SERVER_ERROR", "Unexpected server error."); + AI_SERVICE_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "AI_SERVICE_UNAVAILABLE", "AI service is unavailable."), + ; private final HttpStatus status; private final String code; diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 119a75f..4a07827 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -2,17 +2,10 @@ spring: config: activate: on-profile: prod - import: optional:azure-keyvault:${AZURE_KEYVAULT_ENDPOINT:https://kv-hanspoon-prod.vault.azure.net/} - cloud: - azure: - keyvault: - secret: - endpoint: ${AZURE_KEYVAULT_ENDPOINT:https://kv-hanspoon-prod.vault.azure.net/} datasource: - url: ${DB_PRIVATE_ENDPOINT_URL:jdbc:postgresql://psql-hanspoon-prod.privatelink.postgres.database.azure.com:5432/hanspoon?sslmode=require} + url: jdbc:postgresql://${DB_HOST}:5432/${DB_NAME:hanspoon}?sslmode=require username: ${DB_USERNAME:hanspoon_app} - # Key Vault 우선, 미사용/부재 시 DB_PASSWORD 환경변수로 폴백 - password: ${kv-secret-db-password:${DB_PASSWORD:}} + password: ${DB_PASSWORD} driver-class-name: org.postgresql.Driver hikari: maximum-pool-size: ${DB_MAX_POOL_SIZE:10} @@ -45,20 +38,15 @@ server: app: cors: allowed-origins: ${CORS_ALLOWED_ORIGINS:https://han-spoon.site,https://www.han-spoon.site} - blob: - # SAS 서명용 스토리지 계정 키. Key Vault 우선, 미사용/부재 시 BLOB_ACCOUNT_KEY 환경변수로 폴백. - account-key: ${kv-secret-blob-account-key:${BLOB_ACCOUNT_KEY:}} - azure-ai: - endpoint: ${AZURE_AI_PRIVATE_ENDPOINT:https://aoai-hanspoon-prod.openai.azure.com} - # Key Vault 우선, 미사용/부재 시 AZURE_AI_API_KEY 환경변수로 폴백 - api-key: ${kv-secret-azure-openai-api-key:${AZURE_AI_API_KEY:}} + s3: + bucket: ${S3_BUCKET} + region: ${AWS_REGION:ap-northeast-2} security: google: client-id: ${GOOGLE_CLIENT_ID} jwt: - issuer: ${JWT_ISSUER:https://api.han-spoon.com} - # Key Vault 우선, 미사용/부재 시 JWT_SECRET 환경변수로 폴백 - secret: ${kv-secret-jwt-signing-key:${JWT_SECRET:}} + issuer: ${JWT_ISSUER:https://api.han-spoon.site} + secret: ${JWT_SECRET} access-token-expiration: ${JWT_ACCESS_TOKEN_EXPIRATION:15m} refresh-token-expiration: ${JWT_REFRESH_TOKEN_EXPIRATION:14d} @@ -75,4 +63,4 @@ springdoc: swagger-ui: enabled: ${SWAGGER_UI_ENABLED:false} api-docs: - enabled: ${OPENAPI_DOCS_ENABLED:false} + enabled: ${OPENAPI_DOCS_ENABLED:false} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 3b0473c..3543f90 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -37,20 +37,17 @@ app: openai: capacity: ${OPENAI_RATE_LIMIT_CAPACITY:30} refill-period: ${OPENAI_RATE_LIMIT_REFILL_PERIOD:1m} - azure-ai: - endpoint: ${AZURE_AI_ENDPOINT:https://han-spoon-openai.openai.azure.com} - api-key: ${AZURE_AI_API_KEY:} ai-service: base-url: ${AI_SERVICE_BASE_URL:http://localhost:8000} connect-timeout: ${AI_SERVICE_CONNECT_TIMEOUT:3s} read-timeout: ${AI_SERVICE_READ_TIMEOUT:30s} - blob: - account-name: ${BLOB_ACCOUNT_NAME:sthanspoonprod} - container: ${BLOB_CONTAINER:board-images} - account-key: ${BLOB_ACCOUNT_KEY:} - upload-sas-ttl: ${BLOB_UPLOAD_SAS_TTL:10m} - read-sas-ttl: ${BLOB_READ_SAS_TTL:15m} - allowed-content-types: ${BLOB_ALLOWED_CONTENT_TYPES:image/jpeg,image/png,image/webp} + s3: + bucket: ${S3_BUCKET:hanspoon-local-images} + region: ${AWS_REGION:ap-northeast-2} + endpoint: ${S3_ENDPOINT:} + upload-url-ttl: ${S3_UPLOAD_URL_TTL:10m} + read-url-ttl: ${S3_READ_URL_TTL:15m} + max-file-size: ${S3_MAX_FILE_SIZE:10485760} # 10MB security: google: client-id: ${GOOGLE_CLIENT_ID:} diff --git a/src/test/java/com/hanspoon/backend_api/domain/scan/service/ScanProcessorTest.java b/src/test/java/com/hanspoon/backend_api/domain/scan/service/ScanProcessorTest.java index bf8000f..96821c6 100644 --- a/src/test/java/com/hanspoon/backend_api/domain/scan/service/ScanProcessorTest.java +++ b/src/test/java/com/hanspoon/backend_api/domain/scan/service/ScanProcessorTest.java @@ -24,7 +24,7 @@ import com.hanspoon.backend_api.domain.scan.repository.MenuAnalysisRepository; import com.hanspoon.backend_api.domain.scan.repository.MenuImageRepository; import com.hanspoon.backend_api.domain.scan.repository.ScanSessionRepository; -import com.hanspoon.backend_api.domain.upload.service.BlobStorageService; +import com.hanspoon.backend_api.domain.upload.service.S3StorageService; import com.hanspoon.backend_api.domain.user.entity.ReligionType; import com.hanspoon.backend_api.domain.user.entity.UserProfile; import com.hanspoon.backend_api.domain.user.repository.UserAllergyRepository; @@ -44,11 +44,13 @@ @ExtendWith(MockitoExtension.class) class ScanProcessorTest { + private static final String STORAGE_KEY = "scans/11111111-1111-1111-1111-111111111111/abc.jpg"; + @Mock private AiClient aiClient; @Mock - private BlobStorageService blobStorageService; + private S3StorageService s3StorageService; @Mock private UserProfileRepository userProfileRepository; @@ -79,7 +81,7 @@ private OcrResponse usableOcr() { new com.hanspoon.backend_api.domain.ai.dto.ocr.ScanSession( "menu.jpg", 2, null, "completed", "2026-06-05T00:00:00Z"), new com.hanspoon.backend_api.domain.ai.dto.ocr.MenuImage( - "upload", "menu-x.jpg", "https://acct/board-images/menu-x.jpg", "image/jpeg", 123L), + "upload", STORAGE_KEY, "https://s3/presigned", "image/jpeg", 123L), new com.hanspoon.backend_api.domain.ai.dto.ocr.ScanQuality( "usable", 80, 20, 2, 1.0, 1280, 960, null, List.of(), List.of()), List.of(ocrMenu("samgyeopsal", "9000", false, 1), ocrMenu("doenjang", "8000", true, 2)), @@ -94,9 +96,8 @@ void completesScanAndMergesOcrWithRuleEngine() { OcrResponse ocr = usableOcr(); when(scanSessionRepository.findById(scanId)).thenReturn(Optional.of(session)); - when(blobStorageService.createReadSasUrl("menu-x.jpg")) - .thenReturn("https://acct/board-images/menu-x.jpg?sig=r"); - when(blobStorageService.blobUrl("menu-x.jpg")).thenReturn("https://acct/board-images/menu-x.jpg"); + when(s3StorageService.createReadUrl(STORAGE_KEY)).thenReturn("https://s3/presigned?X-Amz-Signature=r"); + when(s3StorageService.objectUri(STORAGE_KEY)).thenReturn("s3://test-bucket/" + STORAGE_KEY); when(aiClient.requestOcr(any())).thenReturn(ocr); UserProfile profile = UserProfile.create(userId, "KR", false, false, null, ReligionType.HALAL, true, true); when(userProfileRepository.findByUserId(userId)).thenReturn(Optional.of(profile)); @@ -145,7 +146,7 @@ void completesScanAndMergesOcrWithRuleEngine() { "doenjang", "has_unclear_broth", new OwnerQuestion("use anchovy?", null, null))))); when(aiClient.result(any())).thenReturn(finalResult); - scanProcessor.process(scanId, userId, "menu-x.jpg", "upload"); + scanProcessor.process(scanId, userId, STORAGE_KEY, "upload"); assertThat(session.getScanStatus()).isEqualTo(ScanStatus.COMPLETED); assertThat(session.getMenuCount()).isEqualTo(2); @@ -176,19 +177,18 @@ void marksNeedsRetakeAndSkipsRuleEngine() { UUID scanId = session.getId(); OcrResponse ocr = new OcrResponse( new com.hanspoon.backend_api.domain.ai.dto.ocr.ScanSession("menu.jpg", 0, null, "completed", null), - new com.hanspoon.backend_api.domain.ai.dto.ocr.MenuImage("upload", "menu-x.jpg", "u", "image/jpeg", 1L), + new com.hanspoon.backend_api.domain.ai.dto.ocr.MenuImage("upload", STORAGE_KEY, "u", "image/jpeg", 1L), new com.hanspoon.backend_api.domain.ai.dto.ocr.ScanQuality( "needs_retake", 20, 1, 0, 0.0, 100, 100, null, List.of(), List.of("too blurry")), List.of(), null); when(scanSessionRepository.findById(scanId)).thenReturn(Optional.of(session)); - when(blobStorageService.createReadSasUrl("menu-x.jpg")) - .thenReturn("https://acct/board-images/menu-x.jpg?sig=r"); - when(blobStorageService.blobUrl("menu-x.jpg")).thenReturn("https://acct/board-images/menu-x.jpg"); + when(s3StorageService.createReadUrl(STORAGE_KEY)).thenReturn("https://s3/presigned?X-Amz-Signature=r"); + when(s3StorageService.objectUri(STORAGE_KEY)).thenReturn("s3://test-bucket/" + STORAGE_KEY); when(aiClient.requestOcr(any())).thenReturn(ocr); - scanProcessor.process(scanId, userId, "menu-x.jpg", "upload"); + scanProcessor.process(scanId, userId, STORAGE_KEY, "upload"); assertThat(session.getScanStatus()).isEqualTo(ScanStatus.NEEDS_RETAKE); assertThat(session.getRetakeReasons()).containsExactly("too blurry"); @@ -203,11 +203,10 @@ void marksFailedWhenOcrThrows() { UUID scanId = session.getId(); when(scanSessionRepository.findById(scanId)).thenReturn(Optional.of(session)); - when(blobStorageService.createReadSasUrl("menu-x.jpg")) - .thenReturn("https://acct/board-images/menu-x.jpg?sig=r"); + when(s3StorageService.createReadUrl(STORAGE_KEY)).thenReturn("https://s3/presigned?X-Amz-Signature=r"); when(aiClient.requestOcr(any())).thenThrow(new BusinessException(ErrorCode.OCR_SERVICE_ERROR, "boom")); - scanProcessor.process(scanId, userId, "menu-x.jpg", "upload"); + scanProcessor.process(scanId, userId, STORAGE_KEY, "upload"); assertThat(session.getScanStatus()).isEqualTo(ScanStatus.FAILED); verify(menuAnalysisRepository, never()).saveAll(any()); diff --git a/src/test/java/com/hanspoon/backend_api/domain/scan/service/ScanServiceTest.java b/src/test/java/com/hanspoon/backend_api/domain/scan/service/ScanServiceTest.java index 8ce1a4b..3b5a430 100644 --- a/src/test/java/com/hanspoon/backend_api/domain/scan/service/ScanServiceTest.java +++ b/src/test/java/com/hanspoon/backend_api/domain/scan/service/ScanServiceTest.java @@ -16,7 +16,7 @@ import com.hanspoon.backend_api.domain.scan.entity.ScanStatus; import com.hanspoon.backend_api.domain.scan.repository.MenuAnalysisRepository; import com.hanspoon.backend_api.domain.scan.repository.ScanSessionRepository; -import com.hanspoon.backend_api.domain.upload.service.BlobStorageService; +import com.hanspoon.backend_api.domain.upload.service.S3StorageService; import com.hanspoon.backend_api.global.common.PageResponse; import com.hanspoon.backend_api.global.exception.BusinessException; import com.hanspoon.backend_api.global.exception.ErrorCode; @@ -37,7 +37,7 @@ class ScanServiceTest { @Mock - private BlobStorageService blobStorageService; + private S3StorageService s3StorageService; @Mock private ScanSessionRepository scanSessionRepository; @@ -54,14 +54,15 @@ class ScanServiceTest { @Test void startScanSavesSessionTriggersProcessorAndReturnsProcessing() { UUID userId = UUID.randomUUID(); - when(blobStorageService.extractStorageKey("menu-x.jpg")).thenReturn("menu-x.jpg"); + String key = "scans/" + userId + "/2f1c9d3e-0000-4000-8000-000000000001.jpg"; + when(s3StorageService.resolveKey(userId, key)).thenReturn(key); when(scanSessionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); - ScanCreatedResponse response = scanService.startScan(userId, new StartScanRequest("menu-x.jpg", "upload")); + ScanCreatedResponse response = scanService.startScan(userId, new StartScanRequest(key, "upload")); assertThat(response.status()).isEqualTo(ScanStatus.PROCESSING); assertThat(response.scanId()).isNotNull(); - verify(scanProcessor).process(eq(response.scanId()), eq(userId), eq("menu-x.jpg"), eq("upload")); + verify(scanProcessor).process(eq(response.scanId()), eq(userId), eq(key), eq("upload")); } @Test diff --git a/src/test/java/com/hanspoon/backend_api/domain/upload/controller/UploadControllerTest.java b/src/test/java/com/hanspoon/backend_api/domain/upload/controller/UploadControllerTest.java index ae8ecc4..bc1267f 100644 --- a/src/test/java/com/hanspoon/backend_api/domain/upload/controller/UploadControllerTest.java +++ b/src/test/java/com/hanspoon/backend_api/domain/upload/controller/UploadControllerTest.java @@ -1,6 +1,7 @@ package com.hanspoon.backend_api.domain.upload.controller; import static org.hamcrest.Matchers.containsString; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; @@ -8,44 +9,65 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.hanspoon.backend_api.domain.upload.dto.UploadTicketResponse; -import com.hanspoon.backend_api.domain.upload.service.BlobStorageService; +import com.hanspoon.backend_api.domain.upload.service.S3StorageService; +import com.hanspoon.backend_api.global.security.CurrentUser; import java.time.Instant; +import java.util.UUID; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.core.MethodParameter; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; -/** - * 컨트롤러 standalone 슬라이스. 인증 강제는 SecurityConfig(anyRequest authenticated, PUBLIC_ENDPOINTS 미포함)가 - * 담당하므로 여기서는 위임/검증/응답 스키마만 확인한다. - */ class UploadControllerTest { - private final BlobStorageService blobStorageService = mock(BlobStorageService.class); + private static final UUID USER_ID = UUID.randomUUID(); + + private final S3StorageService s3StorageService = mock(S3StorageService.class); private MockMvc mockMvc; @BeforeEach void setUp() { - mockMvc = MockMvcBuilders.standaloneSetup(new UploadController(blobStorageService)) + HandlerMethodArgumentResolver currentUserResolver = new HandlerMethodArgumentResolver() { + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(CurrentUser.class); + } + + @Override + public Object resolveArgument( + MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory) { + return USER_ID.toString(); + } + }; + mockMvc = MockMvcBuilders.standaloneSetup(new UploadController(s3StorageService)) + .setCustomArgumentResolvers(currentUserResolver) .build(); } @Test - void issuesUploadSas() throws Exception { - when(blobStorageService.createUploadSas("image/jpeg")) + void issuesUploadTicket() throws Exception { + String key = "scans/" + USER_ID + "/abc.jpg"; + when(s3StorageService.createUploadUrl(eq(USER_ID), eq("image/jpeg"))) .thenReturn(new UploadTicketResponse( - "menu-abc.jpg", - "https://testacct.blob.core.windows.net/board-images/menu-abc.jpg?sig=xyz", - "https://testacct.blob.core.windows.net/board-images/menu-abc.jpg", + key, + "https://bucket.s3.ap-northeast-2.amazonaws.com/" + key + "?X-Amz-Signature=xyz", Instant.parse("2026-06-06T00:10:00Z"))); mockMvc.perform(post("/api/v1/uploads/sas") .contentType("application/json") .content("{\"contentType\":\"image/jpeg\"}")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.storageKey").value("menu-abc.jpg")) - .andExpect(jsonPath("$.uploadUrl").value(containsString("sig="))) - .andExpect(jsonPath("$.readUrl").value(containsString("board-images/menu-abc.jpg"))); + .andExpect(jsonPath("$.storageKey").value(key)) + .andExpect(jsonPath("$.uploadUrl").value(containsString("X-Amz-Signature="))) + .andExpect(jsonPath("$.expiresAt").exists()); } @Test diff --git a/src/test/java/com/hanspoon/backend_api/domain/upload/service/BlobStorageServiceTest.java b/src/test/java/com/hanspoon/backend_api/domain/upload/service/BlobStorageServiceTest.java deleted file mode 100644 index a9297bf..0000000 --- a/src/test/java/com/hanspoon/backend_api/domain/upload/service/BlobStorageServiceTest.java +++ /dev/null @@ -1,108 +0,0 @@ -package com.hanspoon.backend_api.domain.upload.service; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import com.azure.storage.blob.BlobContainerClient; -import com.azure.storage.blob.BlobServiceClientBuilder; -import com.azure.storage.common.StorageSharedKeyCredential; -import com.hanspoon.backend_api.domain.upload.dto.UploadTicketResponse; -import com.hanspoon.backend_api.global.exception.BusinessException; -import com.hanspoon.backend_api.global.exception.ErrorCode; -import java.time.Duration; -import java.util.List; -import org.junit.jupiter.api.Test; - -/** 더미 base64 계정키로 SAS 를 오프라인 서명 → 네트워크 없이 검증. */ -class BlobStorageServiceTest { - - private static final String ACCOUNT = "testacct"; - private static final String CONTAINER = "board-images"; - private static final String CONTAINER_URL = "https://testacct.blob.core.windows.net/board-images"; - // base64("storage-account-key") - private static final String DUMMY_KEY = "c3RvcmFnZS1hY2NvdW50LWtleQ=="; - - private final BlobStorageService service = new BlobStorageService( - containerClient(), - Duration.ofMinutes(10), - Duration.ofMinutes(15), - List.of("image/jpeg", "image/png", "image/webp")); - - private static BlobContainerClient containerClient() { - return new BlobServiceClientBuilder() - .endpoint("https://" + ACCOUNT + ".blob.core.windows.net") - .credential(new StorageSharedKeyCredential(ACCOUNT, DUMMY_KEY)) - .buildClient() - .getBlobContainerClient(CONTAINER); - } - - @Test - void createUploadSasIssuesWriteSas() { - UploadTicketResponse ticket = service.createUploadSas("image/jpeg"); - - assertThat(ticket.storageKey()).matches("^menu-[A-Za-z0-9-]+\\.jpg$"); - assertThat(ticket.uploadUrl()) - .startsWith(CONTAINER_URL + "/" + ticket.storageKey() + "?") - .contains("sig=") - .contains("se="); - assertThat(ticket.readUrl()).isEqualTo(CONTAINER_URL + "/" + ticket.storageKey()); - assertThat(ticket.readUrl()).doesNotContain("?"); - assertThat(ticket.expiresAt()).isNotNull(); - } - - @Test - void createUploadSasRejectsUnsupportedContentType() { - assertThatThrownBy(() -> service.createUploadSas("image/gif")) - .isInstanceOf(BusinessException.class) - .extracting(e -> ((BusinessException) e).getErrorCode()) - .isEqualTo(ErrorCode.INVALID_CONTENT_TYPE); - } - - @Test - void createReadSasUrlIssuesReadSas() { - String url = service.createReadSasUrl("menu-abc123.jpg"); - - assertThat(url) - .startsWith(CONTAINER_URL + "/menu-abc123.jpg?") - .contains("sp=r") - .contains("sig="); - } - - @Test - void createReadSasUrlRejectsInvalidKey() { - assertThatThrownBy(() -> service.createReadSasUrl("../secret.jpg")) - .isInstanceOf(BusinessException.class) - .extracting(e -> ((BusinessException) e).getErrorCode()) - .isEqualTo(ErrorCode.INVALID_STORAGE_KEY); - assertThatThrownBy(() -> service.createReadSasUrl("menu-x.gif")).isInstanceOf(BusinessException.class); - } - - @Test - void extractStorageKeyAcceptsPlainKey() { - assertThat(service.extractStorageKey("menu-abc123.png")).isEqualTo("menu-abc123.png"); - } - - @Test - void extractStorageKeyAcceptsOurContainerUrlWithSas() { - String url = CONTAINER_URL + "/menu-abc123.jpg?sv=2024&sig=xyz"; - - assertThat(service.extractStorageKey(url)).isEqualTo("menu-abc123.jpg"); - } - - @Test - void extractStorageKeyRejectsForeignHost() { - assertThatThrownBy(() -> service.extractStorageKey("https://evil.com/board-images/menu-abc123.jpg")) - .isInstanceOf(BusinessException.class) - .extracting(e -> ((BusinessException) e).getErrorCode()) - .isEqualTo(ErrorCode.INVALID_STORAGE_KEY); - } - - @Test - void extractStorageKeyRejectsOtherContainer() { - assertThatThrownBy( - () -> service.extractStorageKey("https://testacct.blob.core.windows.net/other/menu-abc123.jpg")) - .isInstanceOf(BusinessException.class) - .extracting(e -> ((BusinessException) e).getErrorCode()) - .isEqualTo(ErrorCode.INVALID_STORAGE_KEY); - } -} diff --git a/src/test/java/com/hanspoon/backend_api/domain/upload/service/S3StorageServiceTest.java b/src/test/java/com/hanspoon/backend_api/domain/upload/service/S3StorageServiceTest.java new file mode 100644 index 0000000..97a1785 --- /dev/null +++ b/src/test/java/com/hanspoon/backend_api/domain/upload/service/S3StorageServiceTest.java @@ -0,0 +1,99 @@ +package com.hanspoon.backend_api.domain.upload.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; + +import com.hanspoon.backend_api.domain.upload.dto.UploadTicketResponse; +import com.hanspoon.backend_api.global.config.S3Properties; +import com.hanspoon.backend_api.global.exception.BusinessException; +import com.hanspoon.backend_api.global.exception.ErrorCode; +import java.time.Duration; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +class S3StorageServiceTest { + + private static final String BUCKET = "hanspoon-test-images"; + private static final UUID USER = UUID.fromString("11111111-1111-1111-1111-111111111111"); + private static final UUID OTHER_USER = UUID.fromString("22222222-2222-2222-2222-222222222222"); + + private final S3Properties properties = new S3Properties( + BUCKET, "ap-northeast-2", null, Duration.ofMinutes(10), Duration.ofMinutes(15), 10 * 1024 * 1024); + + private final S3StorageService service = new S3StorageService(presigner(), mock(S3Client.class), properties); + + private static S3Presigner presigner() { + return S3Presigner.builder() + .region(Region.AP_NORTHEAST_2) + .credentialsProvider(StaticCredentialsProvider.create(AwsBasicCredentials.create("test-ak", "test-sk"))) + .build(); + } + + @Test + void createUploadUrlIssuesPresignedPut() { + UploadTicketResponse ticket = service.createUploadUrl(USER, "image/jpeg"); + + assertThat(ticket.storageKey()).matches("^scans/" + USER + "/[0-9a-f-]{36}\\.jpg$"); + assertThat(ticket.uploadUrl()) + .contains(BUCKET) + .contains("X-Amz-Signature=") + .contains("X-Amz-Expires="); + assertThat(ticket.expiresAt()).isNotNull(); + } + + @Test + void createUploadUrlRejectsUnsupportedContentType() { + assertThatThrownBy(() -> service.createUploadUrl(USER, "image/gif")) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getErrorCode()) + .isEqualTo(ErrorCode.INVALID_CONTENT_TYPE); + } + + @Test + void resolveKeyAcceptsOwnKey() { + String key = service.createUploadUrl(USER, "image/png").storageKey(); + + assertThat(service.resolveKey(USER, key)).isEqualTo(key); + } + + @Test + void resolveKeyRejectsOtherUsersKey() { + String key = service.createUploadUrl(OTHER_USER, "image/png").storageKey(); + + assertThatThrownBy(() -> service.resolveKey(USER, key)) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getErrorCode()) + .isEqualTo(ErrorCode.ACCESS_DENIED); + } + + @Test + void resolveKeyRejectsMalformedKeys() { + assertThatThrownBy(() -> service.resolveKey(USER, "menus/foo.jpg")).isInstanceOf(BusinessException.class); + assertThatThrownBy(() -> service.resolveKey(USER, "scans/" + USER + "/../secret.jpg")) + .isInstanceOf(BusinessException.class); + assertThatThrownBy(() -> service.resolveKey(USER, "scans/" + USER + "/a.gif")) + .isInstanceOf(BusinessException.class); + assertThatThrownBy(() -> service.resolveKey(USER, "https://evil.com/scans/x.jpg")) + .isInstanceOf(BusinessException.class); + assertThatThrownBy(() -> service.resolveKey(USER, "")).isInstanceOf(BusinessException.class); + } + + @Test + void objectUriReturnsS3Scheme() { + assertThat(service.objectUri("scans/" + USER + "/abc.jpg")) + .isEqualTo("s3://" + BUCKET + "/scans/" + USER + "/abc.jpg"); + } + + @Test + void createReadUrlIssuesPresignedGet() { + String key = "scans/" + USER + "/abc.jpg"; + + assertThat(service.createReadUrl(key)).contains(key).contains("X-Amz-Signature="); + } +}