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
29 changes: 7 additions & 22 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,12 @@ dependencies {

// Web
developmentOnly 'org.springframework.boot:spring-boot-devtools'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-validation'

// WebFlux
implementation 'org.springframework.boot:spring-boot-starter-webflux'
implementation 'io.netty:netty-resolver-dns-native-macos:4.1.109.Final:osx-aarch_64'

// DB
runtimeOnly 'com.mysql:mysql-connector-j'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
Expand All @@ -51,9 +54,6 @@ dependencies {
// Apache StringUtils
implementation 'org.apache.commons:commons-lang3:3.17.0'

// Email
implementation 'org.springframework.boot:spring-boot-starter-mail'

// OAuth2 Client
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'

Expand All @@ -63,28 +63,13 @@ dependencies {
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'

// EXIF/메타데이터
implementation 'com.drewnoakes:metadata-extractor:2.18.0'

// TwelveMonkeys ImageIO
implementation 'com.twelvemonkeys.imageio:imageio-core:3.9.4'
implementation 'com.twelvemonkeys.imageio:imageio-jpeg:3.9.4'
implementation 'com.twelvemonkeys.imageio:imageio-tiff:3.9.4'

// Apache Commons Imaging
implementation 'org.apache.commons:commons-imaging:1.0-alpha3'

// 이미지 처리 관련 라이브러리
implementation 'net.coobird:thumbnailator:0.4.19'
// AWS S3 SDK v2
implementation platform('software.amazon.awssdk:bom:2.25.27')
implementation 'software.amazon.awssdk:s3'

// Monitoring
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'io.micrometer:micrometer-registry-prometheus'

// OpenAI 연동
implementation 'com.openai:openai-java:2.8.1'


}

tasks.named('test') {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,42 +1,93 @@
package ssafy.retrip.api.controller.retrip;

import jakarta.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.http.codec.multipart.FilePart;
import org.springframework.web.bind.annotation.GetMapping;
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.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import ssafy.retrip.api.controller.retrip.response.TravelAnalysisResponseDto;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import ssafy.retrip.api.controller.retrip.request.LambdaCallbackRequest;
import ssafy.retrip.api.controller.retrip.request.LambdaFailureRequest;
import ssafy.retrip.api.controller.retrip.response.JobStatusResponse;
import ssafy.retrip.api.controller.retrip.response.JobUploadResponse;
import ssafy.retrip.api.service.retrip.RetripService;
import ssafy.retrip.api.service.sse.SseService;

@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/images")
@RequestMapping("/api")
public class RetripController {

private final SseService sseService;
private final RetripService retripService;

@PostMapping("/uploads")
public CompletableFuture<ResponseEntity<TravelAnalysisResponseDto>> uploadMultipleImages(HttpServletRequest request,
@RequestParam("images") List<MultipartFile> images) throws IOException {
@PostMapping("/images/uploads")
public Mono<ResponseEntity<JobUploadResponse>> uploadImages(@RequestPart(value = "images") Flux<FilePart> images) {

return retripService.createRetripFromImages(images)
.thenApply(result -> {
log.info("여행 분석 완료: retripId={}", result.getRetripId());
return ResponseEntity.ok(result);
return retripService.uploadImagesToS3(images)
.map(jobId -> {
log.info("이미지 업로드 완료, jobId 반환: {}", jobId);
return ResponseEntity.ok(JobUploadResponse.of(jobId));
})
.exceptionally(ex -> {
log.error("여행 분석 처리 중 오류 발생", ex);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(null);
.onErrorResume(ex -> {
log.error("이미지 업로드 중 오류 발생", ex);
return Mono.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(null));
});
}

@GetMapping(value = "/retrips/{jobId}/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> streamResult(@PathVariable String jobId) {
log.info("SSE 연결 요청: jobId={}", jobId);
return sseService.connect(jobId);
}

@PostMapping("/internal/retrips/{jobId}/complete")
public Mono<ResponseEntity<Void>> handleLambdaCallback(
@PathVariable String jobId,
@RequestBody LambdaCallbackRequest request
) {

log.info("Lambda 콜백 수신: jobId={}", jobId);

return retripService.handleAnalysisResult(jobId, request)
.then(Mono.just(ResponseEntity.ok().<Void>build()))
.onErrorResume(ex -> {
log.error("Lambda 콜백 처리 실패: jobId={}", jobId, ex);
return Mono.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build());
});
}

@PostMapping("/internal/retrips/{jobId}/fail")
public Mono<ResponseEntity<Void>> handleLambdaFailure(
@PathVariable String jobId,
@RequestBody LambdaFailureRequest request
) {

log.error("Lambda 실패 콜백 수신: jobId={}, error={}", jobId, request.getErrorMessage());

return retripService.handleFailure(jobId, request.getErrorMessage())
.then(Mono.just(ResponseEntity.ok().<Void>build()))
.onErrorResume(ex -> {
log.error("Lambda 실패 콜백 처리 중 오류: jobId={}", jobId, ex);
return Mono.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build());
});
Comment on lines +55 to +84
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/internal/retrips/{jobId}/complete, /internal/retrips/{jobId}/fail가 외부에서 직접 호출 가능하며, 현재 Security 설정도 전체 permitAll이라 콜백 위조가 가능합니다. Lambda가 보낸 요청임을 검증할 수 있도록 (예: 공유 시크릿 헤더/X-Signature, IAM authorizer를 거친 API Gateway, IP allowlist 등) 최소 1개의 서버측 검증을 추가하세요.

Copilot uses AI. Check for mistakes.
}

@GetMapping("/retrips/{jobId}/status")
public Mono<ResponseEntity<JobStatusResponse>> getJobStatus(@PathVariable String jobId) {
return retripService.getJobStatus(jobId)
.map(ResponseEntity::ok)
.defaultIfEmpty(ResponseEntity.notFound().build());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package ssafy.retrip.api.controller.retrip.request;

import java.time.LocalDateTime;
import java.util.List;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import ssafy.retrip.api.service.openai.response.AnalysisResponse;
import ssafy.retrip.api.service.retrip.info.ImageMetaData;

@Getter
@NoArgsConstructor
@AllArgsConstructor
public class LambdaCallbackRequest {

private AnalysisResponse analysisResponse;
private List<MetadataDto> metadata;

@Getter
@NoArgsConstructor
@AllArgsConstructor
public static class MetadataDto {

private LocalDateTime takenDate;
private Double latitude;
private Double longitude;

public ImageMetaData toImageMetaData() {
ImageMetaData meta = new ImageMetaData();
meta.updateTakenDate(this.takenDate);
if (this.latitude != null && this.longitude != null) {
meta.updateGeoLocation(this.latitude, this.longitude);
}
return meta;
}
}

public List<ImageMetaData> toImageMetaDataList() {
if (metadata == null) {
return List.of();
}
return metadata.stream()
.map(MetadataDto::toImageMetaData)
.toList();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package ssafy.retrip.api.controller.retrip.request;

import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
public class LambdaFailureRequest {

private String errorMessage;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package ssafy.retrip.api.controller.retrip.response;


import static ssafy.retrip.domain.job.JobStatus.COMPLETED;
import static ssafy.retrip.domain.job.JobStatus.FAILED;
import static ssafy.retrip.domain.job.JobStatus.PROCESSING;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import ssafy.retrip.domain.job.JobStatus;

@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class JobStatusResponse {

private String jobId;
private JobStatus status;
private TravelAnalysisResponseDto result;
private String errorMessage;

public static JobStatusResponse processing(String jobId) {
return JobStatusResponse.builder()
.jobId(jobId)
.status(PROCESSING)
.build();
}

public static JobStatusResponse completed(String jobId, TravelAnalysisResponseDto result) {
return JobStatusResponse.builder()
.jobId(jobId)
.status(COMPLETED)
.result(result)
.build();
}

public static JobStatusResponse failed(String jobId, String errorMessage) {
return JobStatusResponse.builder()
.jobId(jobId)
.status(FAILED)
.errorMessage(errorMessage)
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package ssafy.retrip.api.controller.retrip.response;

import static ssafy.retrip.domain.job.JobStatus.PROCESSING;

import lombok.AllArgsConstructor;
import lombok.Getter;
import ssafy.retrip.domain.job.JobStatus;

@Getter
@AllArgsConstructor
public class JobUploadResponse {

private String jobId;
private JobStatus status;
private String message;

public static JobUploadResponse of(String jobId) {
return new JobUploadResponse(jobId, PROCESSING, "이미지 업로드 완료. SSE 또는 폴링으로 결과를 확인하세요.");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package ssafy.retrip.api.service.cache;

import java.time.Duration;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;

@Slf4j
@Service
@RequiredArgsConstructor
public class RetripResultCacheService {

private static final String KEY_PREFIX = "retrip:result:";
private static final Duration CACHE_TTL = Duration.ofMinutes(10);

private final RedisTemplate<String, String> redisTemplate;

public Mono<Void> cacheResult(String jobId, String resultJson) {
return Mono.fromRunnable(() -> {
String key = KEY_PREFIX + jobId;
redisTemplate.opsForValue().set(key, resultJson, CACHE_TTL);
log.info("Redis 결과 캐싱 완료: jobId={}, TTL={}분", jobId, CACHE_TTL.toMinutes());
})
.subscribeOn(Schedulers.boundedElastic())
.then();
}

public Mono<String> getCachedResult(String jobId) {
return Mono.fromCallable(() -> {
String key = KEY_PREFIX + jobId;
return redisTemplate.opsForValue().get(key);
})
.subscribeOn(Schedulers.boundedElastic())
.flatMap(result -> result != null ? Mono.just(result) : Mono.empty());
}
}
Loading
Loading