Skip to content
Merged
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
7 changes: 4 additions & 3 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
## ✅ Self-Check List
<!-- 본인이 리뷰를 요청하기 전 마지막으로 확인한 사항을 체크해 주세요. -->
- [ ] 스스로 코드를 한 번 이상 리뷰했습니다. (Self-review)
- [ ] `ktlint` 포맷팅과 Kotlin 공식 컨벤션을 준수했습니다.
- [ ] 불필요한 `!!` (Not-null assertion) 사용을 배제하고 안전하게 예외 처리(`?: throw`)를 했습니다.
- [ ] 코드에 불필요한 주석이나 `println`, TODO가 남아있지 않습니다.
- [ ] `./gradlew spotlessApply` 로 포맷팅을 적용했습니다.
- [ ] `./gradlew build` 가 통과합니다.
- [ ] 예외를 `BusinessException` + `ErrorCode` 로 처리했습니다.
- [ ] 코드에 불필요한 주석이나 `System.out.println`, TODO 가 남아있지 않습니다.
21 changes: 10 additions & 11 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")}")
}
}

Expand All @@ -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")
Expand All @@ -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")
Expand All @@ -84,7 +84,6 @@ tasks.withType<Test> {
useJUnitPlatform()
}

// Spring Boot 실행 가능 jar만 생성 (plain jar 비활성 → Docker COPY 글롭 모호성 제거)
tasks.named<Jar>("jar") {
enabled = false
}
Expand Down
2 changes: 1 addition & 1 deletion infra/terraform/edge.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@
import jakarta.validation.constraints.NotBlank;

/**
* 스캔 시작 요청. 이미지는 미리 Blob 에 업로드된 상태이고, 그 키(또는 우리 컨테이너 URL)를 넘긴다.
* 스캔 시작 요청. 이미지는 presigned URL 로 S3 에 업로드된 상태이고, 그 객체 키를 넘긴다.
*
* <p>표시용 제목(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) {}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -33,20 +33,14 @@
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 프록시가 정상 적용된다.
*
* <p>실패해도 예외를 호출자에게 던지지 않고 scan_status 를 FAILED 로 남긴다(폴링으로 확인).
*/
@Component
public class ScanProcessor {

private static final Logger log = LoggerFactory.getLogger(ScanProcessor.class);
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;
Expand All @@ -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;
Expand All @@ -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 메타 반영
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -21,32 +21,33 @@
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

/**
* 스캔 시작/조회. 시작은 세션을 PROCESSING 으로 저장(즉시 커밋)한 뒤 {@link ScanProcessor} 비동기 처리를 트리거한다.
*
* <p>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));
Expand Down
Original file line number Diff line number Diff line change
@@ -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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) {}
Original file line number Diff line number Diff line change
Expand Up @@ -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) {}
Loading
Loading