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
15 changes: 15 additions & 0 deletions src/main/java/com/tenure/domain/ootd/ai/AiTagService.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,26 @@ public interface AiTagService {

List<AiTagResult> analyze(String imageUrl);

default List<AiTagResult> analyze(String imageUrl, String imageObjectKey) {
return analyze(imageUrl);
}

RegionAnalysisResult analyzeRegion(
String imageUrl,
BigDecimal bboxX,
BigDecimal bboxY,
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);
}
}
98 changes: 91 additions & 7 deletions src/main/java/com/tenure/domain/ootd/ai/GeminiAiTagService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -91,8 +95,13 @@ public GeminiAiTagService(

@Override
public List<AiTagResult> analyze(String imageUrl) {
return analyze(imageUrl, null);
}

@Override
public List<AiTagResult> 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);
Expand All @@ -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);
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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<String> 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<byte[]> 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<String> 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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public class OotdCreatedEventListener {
public void handleOotdCreated(OotdCreatedEvent event) {
log.info("OOTD_CREATED 이벤트 수신 - ootdId={}. AI 태그 분석을 시작합니다.", event.ootdId());

List<AiTagResult> results = aiTagService.analyze(event.imageUrl());
List<AiTagResult> results = aiTagService.analyze(event.imageUrl(), event.imageObjectKey());
ootdTagService.saveAiTags(event.ootdId(), results);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand All @@ -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),
Expand All @@ -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),
Expand All @@ -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),
Expand Down
Loading