From fb268c6e2d29ffd296a8153f2c99488c73283ae3 Mon Sep 17 00:00:00 2001 From: soseongmin03 Date: Thu, 13 Aug 2026 16:42:37 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20=EB=B6=84=EC=84=9D=20=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tenure/domain/ootd/ai/AiTagService.java | 15 +++ .../domain/ootd/ai/GeminiAiTagService.java | 98 +++++++++++++++++-- .../domain/ootd/event/OotdCreatedEvent.java | 2 +- .../listener/OotdCreatedEventListener.java | 2 +- .../domain/ootd/service/OotdService.java | 7 +- .../domain/tag/service/OotdTagService.java | 1 + .../ootd/ai/GeminiAiTagServiceTest.java | 44 +++++++++ .../tag/service/OotdTagServiceTest.java | 5 + 8 files changed, 164 insertions(+), 10 deletions(-) create mode 100644 src/test/java/com/tenure/domain/ootd/ai/GeminiAiTagServiceTest.java diff --git a/src/main/java/com/tenure/domain/ootd/ai/AiTagService.java b/src/main/java/com/tenure/domain/ootd/ai/AiTagService.java index 32ffe5cf..54acb417 100644 --- a/src/main/java/com/tenure/domain/ootd/ai/AiTagService.java +++ b/src/main/java/com/tenure/domain/ootd/ai/AiTagService.java @@ -11,6 +11,10 @@ public interface AiTagService { List analyze(String imageUrl); + default List analyze(String imageUrl, String imageObjectKey) { + return analyze(imageUrl); + } + RegionAnalysisResult analyzeRegion( String imageUrl, BigDecimal bboxX, @@ -18,4 +22,15 @@ RegionAnalysisResult analyzeRegion( BigDecimal bboxWidth, BigDecimal bboxHeight ); + + default RegionAnalysisResult analyzeRegion( + String imageUrl, + String imageObjectKey, + BigDecimal bboxX, + BigDecimal bboxY, + BigDecimal bboxWidth, + BigDecimal bboxHeight + ) { + return analyzeRegion(imageUrl, bboxX, bboxY, bboxWidth, bboxHeight); + } } diff --git a/src/main/java/com/tenure/domain/ootd/ai/GeminiAiTagService.java b/src/main/java/com/tenure/domain/ootd/ai/GeminiAiTagService.java index 6919a707..a3d1c253 100644 --- a/src/main/java/com/tenure/domain/ootd/ai/GeminiAiTagService.java +++ b/src/main/java/com/tenure/domain/ootd/ai/GeminiAiTagService.java @@ -11,15 +11,19 @@ import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.io.InputStream; import java.math.BigDecimal; import java.math.RoundingMode; +import java.net.URI; import java.util.Base64; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.stream.Collectors; import javax.imageio.ImageIO; import lombok.extern.slf4j.Slf4j; +import org.springframework.core.io.ClassPathResource; import org.springframework.http.MediaType; import org.springframework.stereotype.Service; import org.springframework.web.client.RestClient; @@ -91,8 +95,13 @@ public GeminiAiTagService( @Override public List analyze(String imageUrl) { + return analyze(imageUrl, null); + } + + @Override + public List analyze(String imageUrl, String imageObjectKey) { try { - byte[] imageBytes = readImageBytes(imageUrl); + byte[] imageBytes = readImageBytes(imageUrl, imageObjectKey); String base64Image = Base64.getEncoder().encodeToString(imageBytes); String responseBody = requestGemini(base64Image, detectContentType(imageBytes)); return parseTagResults(responseBody); @@ -112,10 +121,22 @@ public RegionAnalysisResult analyzeRegion( BigDecimal bboxY, BigDecimal bboxWidth, BigDecimal bboxHeight + ) { + return analyzeRegion(imageUrl, null, bboxX, bboxY, bboxWidth, bboxHeight); + } + + @Override + public RegionAnalysisResult analyzeRegion( + String imageUrl, + String imageObjectKey, + BigDecimal bboxX, + BigDecimal bboxY, + BigDecimal bboxWidth, + BigDecimal bboxHeight ) { String base64Crop; try { - base64Crop = readCroppedImageAsBase64(imageUrl, bboxX, bboxY, bboxWidth, bboxHeight); + base64Crop = readCroppedImageAsBase64(imageUrl, imageObjectKey, bboxX, bboxY, bboxWidth, bboxHeight); } catch (Exception e) { log.error("Gemini 영역 분석 실패(크롭 단계) - imageUrl={}, bbox=({}, {}, {}, {})", imageUrl, bboxX, bboxY, bboxWidth, bboxHeight, e); @@ -187,12 +208,13 @@ private String buildRegionPrompt() { private String readCroppedImageAsBase64( String imageUrl, + String imageObjectKey, BigDecimal bboxX, BigDecimal bboxY, BigDecimal bboxWidth, BigDecimal bboxHeight ) throws IOException { - BufferedImage original = ImageIO.read(new ByteArrayInputStream(readImageBytes(imageUrl))); + BufferedImage original = ImageIO.read(new ByteArrayInputStream(readImageBytes(imageUrl, imageObjectKey))); if (original == null) { throw new IOException("이미지를 읽을 수 없습니다: " + imageUrl); } @@ -241,10 +263,72 @@ private String requestGemini(String base64Image, String contentType) { .body(String.class); } - private byte[] readImageBytes(String imageUrl) throws IOException { - String objectKey = imageStorageService.objectKeyFromUrl(imageUrl) - .orElseThrow(() -> new IOException("Image object key not found: " + imageUrl)); - return imageStorageService.readBytes(objectKey); + private byte[] readImageBytes(String imageUrl, String imageObjectKey) throws IOException { + Optional objectKey = hasText(imageObjectKey) + ? Optional.of(imageObjectKey) + : imageStorageService.objectKeyFromUrl(imageUrl).or(() -> objectKeyFromStaticFilesUrl(imageUrl)); + + return readImageBytesByObjectKey(objectKey + .orElseThrow(() -> new IOException("Image object key not found: " + imageUrl))); + } + + private byte[] readImageBytesByObjectKey(String objectKey) throws IOException { + try { + return imageStorageService.readBytes(objectKey); + } catch (IOException storageException) { + return readClasspathFileBytes(objectKey) + .orElseThrow(() -> storageException); + } + } + + private Optional readClasspathFileBytes(String objectKey) throws IOException { + String normalizedKey = trimLeadingSlash(objectKey); + if (normalizedKey.isBlank() || normalizedKey.contains("..")) { + return Optional.empty(); + } + + ClassPathResource resource = new ClassPathResource("static/files/" + normalizedKey); + if (!resource.exists()) { + return Optional.empty(); + } + + try (InputStream inputStream = resource.getInputStream()) { + return Optional.of(inputStream.readAllBytes()); + } + } + + private Optional objectKeyFromStaticFilesUrl(String imageUrl) { + if (!hasText(imageUrl)) { + return Optional.empty(); + } + + String path = imageUrl; + try { + URI uri = URI.create(imageUrl); + if (uri.getPath() != null) { + path = uri.getPath(); + } + } catch (IllegalArgumentException ignored) { + // Keep the original value when imageUrl is a relative path. + } + + String staticPrefix = "/files/"; + if (!path.startsWith(staticPrefix)) { + return Optional.empty(); + } + return Optional.of(path.substring(staticPrefix.length())); + } + + private String trimLeadingSlash(String value) { + String result = value.replace('\\', '/'); + while (result.startsWith("/")) { + result = result.substring(1); + } + return result; + } + + private boolean hasText(String value) { + return value != null && !value.isBlank(); } private String detectContentType(byte[] imageBytes) { diff --git a/src/main/java/com/tenure/domain/ootd/event/OotdCreatedEvent.java b/src/main/java/com/tenure/domain/ootd/event/OotdCreatedEvent.java index a6db1b5c..317a59f8 100644 --- a/src/main/java/com/tenure/domain/ootd/event/OotdCreatedEvent.java +++ b/src/main/java/com/tenure/domain/ootd/event/OotdCreatedEvent.java @@ -1,4 +1,4 @@ package com.tenure.domain.ootd.event; -public record OotdCreatedEvent(Long ootdId, Long ownerId, String imageUrl) { +public record OotdCreatedEvent(Long ootdId, Long ownerId, String imageUrl, String imageObjectKey) { } diff --git a/src/main/java/com/tenure/domain/ootd/listener/OotdCreatedEventListener.java b/src/main/java/com/tenure/domain/ootd/listener/OotdCreatedEventListener.java index 75173639..365dbdd8 100644 --- a/src/main/java/com/tenure/domain/ootd/listener/OotdCreatedEventListener.java +++ b/src/main/java/com/tenure/domain/ootd/listener/OotdCreatedEventListener.java @@ -28,7 +28,7 @@ public class OotdCreatedEventListener { public void handleOotdCreated(OotdCreatedEvent event) { log.info("OOTD_CREATED 이벤트 수신 - ootdId={}. AI 태그 분석을 시작합니다.", event.ootdId()); - List results = aiTagService.analyze(event.imageUrl()); + List results = aiTagService.analyze(event.imageUrl(), event.imageObjectKey()); ootdTagService.saveAiTags(event.ootdId(), results); } } diff --git a/src/main/java/com/tenure/domain/ootd/service/OotdService.java b/src/main/java/com/tenure/domain/ootd/service/OotdService.java index 342a9a64..0a224e6f 100644 --- a/src/main/java/com/tenure/domain/ootd/service/OotdService.java +++ b/src/main/java/com/tenure/domain/ootd/service/OotdService.java @@ -42,7 +42,12 @@ public OotdCreateResponse createAutoTagOotd( ) { Ootd ootd = createOotdEntity(currentUserId, image, source, false); - eventPublisher.publishEvent(new OotdCreatedEvent(ootd.getId(), ootd.getOwner().getId(), ootd.getImageUrl())); + eventPublisher.publishEvent(new OotdCreatedEvent( + ootd.getId(), + ootd.getOwner().getId(), + ootd.getImageUrl(), + ootd.getImageObjectKey() + )); return OotdCreateResponse.of(ootd); } diff --git a/src/main/java/com/tenure/domain/tag/service/OotdTagService.java b/src/main/java/com/tenure/domain/tag/service/OotdTagService.java index bc937e27..2186ee65 100644 --- a/src/main/java/com/tenure/domain/tag/service/OotdTagService.java +++ b/src/main/java/com/tenure/domain/tag/service/OotdTagService.java @@ -108,6 +108,7 @@ public OotdTagAnalyzeResponse analyzeTagArea(Long ootdId, Long currentUserId, Oo RegionAnalysisResult result = aiTagService.analyzeRegion( ootd.getImageUrl(), + ootd.getImageObjectKey(), request.bbox().x(), request.bbox().y(), request.bbox().width(), diff --git a/src/test/java/com/tenure/domain/ootd/ai/GeminiAiTagServiceTest.java b/src/test/java/com/tenure/domain/ootd/ai/GeminiAiTagServiceTest.java new file mode 100644 index 00000000..f53ea536 --- /dev/null +++ b/src/test/java/com/tenure/domain/ootd/ai/GeminiAiTagServiceTest.java @@ -0,0 +1,44 @@ +package com.tenure.domain.ootd.ai; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.tenure.domain.item.repository.CategoryRepository; +import com.tenure.global.config.GeminiProperties; +import com.tenure.global.storage.ImageStorageService; +import java.io.IOException; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.client.RestClient; + +class GeminiAiTagServiceTest { + + @Test + void readImageBytes_fallsBackToClasspathStaticFileWhenStorageCannotReadSeedObject() throws IOException { + ImageStorageService imageStorageService = mock(ImageStorageService.class); + String objectKey = "seed/ian/ootds/ootd_001.jpg"; + when(imageStorageService.readBytes(objectKey)).thenThrow(new IOException("missing storage object")); + + GeminiAiTagService service = new GeminiAiTagService( + RestClient.builder(), + new GeminiProperties("test-key", "http://localhost", "test-model"), + imageStorageService, + new ObjectMapper(), + mock(CategoryRepository.class) + ); + + byte[] imageBytes = ReflectionTestUtils.invokeMethod( + service, + "readImageBytes", + "/files/seed/ian/ootds/ootd_001.jpg", + objectKey + ); + + assertThat(imageBytes).isNotEmpty(); + assertThat(imageBytes[0] & 0xFF).isEqualTo(0xFF); + assertThat(imageBytes[1] & 0xFF).isEqualTo(0xD8); + assertThat(imageBytes[2] & 0xFF).isEqualTo(0xFF); + } +} diff --git a/src/test/java/com/tenure/domain/tag/service/OotdTagServiceTest.java b/src/test/java/com/tenure/domain/tag/service/OotdTagServiceTest.java index 3c6fdd22..efc91f63 100644 --- a/src/test/java/com/tenure/domain/tag/service/OotdTagServiceTest.java +++ b/src/test/java/com/tenure/domain/tag/service/OotdTagServiceTest.java @@ -794,6 +794,7 @@ void analyzeTagArea_returnsMatchedItemWhenConfidentAndMatching() { when(ootdRepository.findById(OOTD_ID)).thenReturn(Optional.of(ootd)); when(aiTagService.analyzeRegion( eq(ootd.getImageUrl()), + eq(ootd.getImageObjectKey()), any(BigDecimal.class), any(BigDecimal.class), any(BigDecimal.class), @@ -822,6 +823,7 @@ void analyzeTagArea_returnsMatchedItemsOrderedBySimilarityDescending() { when(ootdRepository.findById(OOTD_ID)).thenReturn(Optional.of(ootd)); when(aiTagService.analyzeRegion( eq(ootd.getImageUrl()), + eq(ootd.getImageObjectKey()), any(BigDecimal.class), any(BigDecimal.class), any(BigDecimal.class), @@ -843,6 +845,7 @@ void analyzeTagArea_returnsNullMatchedItemWhenNoOwnedItemMatches() { when(ootdRepository.findById(OOTD_ID)).thenReturn(Optional.of(ootd)); when(aiTagService.analyzeRegion( eq(ootd.getImageUrl()), + eq(ootd.getImageObjectKey()), any(BigDecimal.class), any(BigDecimal.class), any(BigDecimal.class), @@ -866,6 +869,7 @@ void analyzeTagArea_returnsNullMatchedItemWhenBelowConfidenceThreshold() { when(ootdRepository.findById(OOTD_ID)).thenReturn(Optional.of(ootd)); when(aiTagService.analyzeRegion( eq(ootd.getImageUrl()), + eq(ootd.getImageObjectKey()), any(BigDecimal.class), any(BigDecimal.class), any(BigDecimal.class), @@ -888,6 +892,7 @@ void analyzeTagArea_returnsAllNullWhenItemNotIdentified() { when(ootdRepository.findById(OOTD_ID)).thenReturn(Optional.of(ootd)); when(aiTagService.analyzeRegion( eq(ootd.getImageUrl()), + eq(ootd.getImageObjectKey()), any(BigDecimal.class), any(BigDecimal.class), any(BigDecimal.class),