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
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@
import lombok.extern.slf4j.Slf4j;

/**
* 성공 응답 (docs/api-contract.md). {@code from} 이 요구하는 세 필드는 호출자(core)의 READY 불변식과 같은
* 조건이다 — 그래서 하나라도 못 채우면 성공으로 내려보내지 않고 실패로 떨어뜨린다. currency 는 READY 필수가
* 성공 응답 (docs/api-contract.md). 세 필드(name·imageUrl·currentPrice)는 호출자(core)의 READY 불변식과 같은
* 조건이지만, **다 채우지 못해도 성공으로 내려보낸다** — 호출자가 부분값을 INCOMPLETE 로 받아 사용자가 나머지를
* 채우기 때문이다(TeamPiKi/core#944). 하나도 못 건졌을 때만 확정 실패로 닫는다. currency 는 READY 필수가
* 아니라 nullable 이다.
*
* <p>finalUrl·method 는 additive 확장이다. finalUrl 은 리다이렉트 귀결점(link 경로 항상, image 경로 null)으로
Expand All @@ -30,9 +31,16 @@ public static ExtractionResponse from(ProductSnapshot snapshot) {
if (snapshot.method() == null) {
log.warn("extraction response without method - origin marking missed");
}
if (snapshot.missingReadyField()) {
// 하나도 못 건졌을 때만 확정 실패로 닫는다. 예전에는 세 필드 중 하나라도 비면 닫아 채운 값까지 함께
// 버렸는데, 사진에 가격이 박혀 있지 않은 것은 정상 입력이라 그 계약은 "쇼핑몰 화면 캡처"만 통과시켰다.
// 부분값은 호출자가 INCOMPLETE 로 받아 사용자가 나머지를 채운다(TeamPiKi/core#944).
if (snapshot.hasNoExtractedValue()) {
throw ProductSnapshotException.untrustworthyValue();
}
// 부분값은 성공 응답이라 code 가 남지 않는다 — 어느 필드를 못 채웠는지는 여기서만 관측할 수 있다.
if (snapshot.missingReadyField()) {
log.info("extraction incomplete missing={} method={}", snapshot.missingFieldNames(), snapshot.method());
}
return new ExtractionResponse(
snapshot.name(),
snapshot.imageUrl(),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.depromeet.piki.extractor.domain;

import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

/**
Expand Down Expand Up @@ -44,6 +46,35 @@ public boolean missingReadyField() {
return name == null || name.isBlank() || imageUrl == null || currentPrice == null;
}

/**
* 추출값을 하나도 못 얻었는가 — 호출자에게 내려보낼 것도, 사용자에게 "무엇을 채우라" 할 근거도 없는 상태다.
*
* <p>부분값(일부만 채움)은 호출자가 INCOMPLETE 로 수용해 사용자가 나머지를 채우므로(TeamPiKi/core#944)
* 성공으로 내려보내고, 이 판정이 참일 때만 확정 실패로 닫는다. currency 는 READY 필수가 아니라 단독으로는
* "건졌다"의 근거가 되지 못하므로 세지 않는다.
*/
public boolean hasNoExtractedValue() {
return (name == null || name.isBlank()) && imageUrl == null && currentPrice == null;
}

/**
* 못 채운 READY 필드 이름들("currentPrice" · "name+currentPrice") — 무엇을 사용자에게 물어야 하는지를
* 로그로 남기는 데 쓴다. 부분값을 성공으로 내려보내면 code 만으로는 어느 필드가 비었는지 사후 판별이 불가능하다.
*/
public String missingFieldNames() {
List<String> missing = new ArrayList<>();
if (name == null || name.isBlank()) {
missing.add("name");
}
if (imageUrl == null) {
missing.add("imageUrl");
}
if (currentPrice == null) {
missing.add("currentPrice");
}
return String.join("+", missing);
}

/** 컬럼 길이 제약은 호출자(core items 테이블)의 계약이다. 값이 바뀌면 양쪽을 함께 갱신한다. */
private static final int NAME_MAX_LENGTH = 512;
private static final int IMAGE_URL_MAX_LENGTH = 2048;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,14 @@ public ProductSnapshot extract(String bucket, String key, String model) {
ImageExtraction extraction = productImageExtractor.extract(image, model);

// 크롭이 불가능해도 원본을 올린다 — 호출자의 READY 불변식이 imageUrl 을 요구한다.
byte[] resultBytes = croppedOrOriginal(image, extraction);
String imageUrl = imageStorage.upload(bucket, resultBytes, "items/" + UUID.randomUUID() + ".png", "image/png");
log.info("image extract bucket={} key={} croppedUrl={}", bucket, key, imageUrl);
UploadTarget target = croppedOrOriginal(image, extraction);
String objectKey = "items/" + UUID.randomUUID() + "." + target.extension();
String imageUrl = imageStorage.upload(bucket, target.bytes(), objectKey, target.mimeType());
// cropped 를 함께 남긴다 — croppedUrl 이라는 이름과 달리 크롭을 건너뛴 경우가 섞여 있어, 이 값 없이는
// "원본이 그대로 올라간 비율"을 사후에 알 수 없다.
log.info(
"image extract bucket={} key={} croppedUrl={} cropped={}",
bucket, key, imageUrl, target.cropped());

// 이미지 경로는 원본 URL 이 없어 finalUrl 도 없고, 추출이 Gemini 라 method 는 항상 LLM 이다.
ProductSnapshot s = extraction.snapshot();
Expand All @@ -52,11 +57,28 @@ private String mimeTypeFromKeyOrStored(String key, StoredImage stored) {
return fromKey != null ? fromKey : stored.contentType();
}

private byte[] croppedOrOriginal(ProductImage image, ImageExtraction extraction) {
/**
* 업로드할 결과물. 크롭이 실제로 일어났으면 PNG 인코딩 결과이고, 크롭 불가 포맷(HEIC·WebP·HEIF 는 ImageIO 에
* 디코더가 없다)이면 원본 바이트 그대로다.
*
* <p>확장자·content-type 을 결과물과 함께 나르는 이유: 예전에는 둘 다 png 로 하드코딩돼 있어, 크롭을 건너뛴
* HEIC 바이트가 {@code .png} · {@code image/png} 로 위장돼 저장됐다. 브라우저 대부분이 그 파일을 렌더링하지
* 못한다. 등록이 허용하는 5개 포맷 중 셋이 이 경로를 탄다(#35).
*/
private record UploadTarget(byte[] bytes, String extension, String mimeType, boolean cropped) {}

private UploadTarget croppedOrOriginal(ProductImage image, ImageExtraction extraction) {
if (extraction.boundingBox() == null) {
return image.bytes();
return original(image);
}
byte[] cropped = imageCropper.crop(image.bytes(), extraction.boundingBox());
return cropped != null ? cropped : image.bytes();
if (cropped == null) {
return original(image);
}
return new UploadTarget(cropped, "png", "image/png", true);
}

private UploadTarget original(ProductImage image) {
return new UploadTarget(image.bytes(), image.extension(), image.mimeType(), false);
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package com.depromeet.piki.extractor.api;

import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.hamcrest.Matchers.notNullValue;
import static org.hamcrest.Matchers.nullValue;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
Expand Down Expand Up @@ -114,6 +117,33 @@ void boundingBoxCropIsWiredToUpload() throws Exception {
BufferedImage uploaded = ImageIO.read(new ByteArrayInputStream(stubImageStorage.lastUploadedBytes));
assertEquals(320, uploaded.getWidth());
assertEquals(320, uploaded.getHeight());
// 크롭이 실제로 일어났을 때만 PNG 다 — 아래 HEIC 케이스와 짝을 이루는 대조군.
assertEquals("image/png", stubImageStorage.lastUploadedContentType);
assertTrue(stubImageStorage.lastUploadedKey.endsWith(".png"));
}

@Test
@DisplayName("크롭할 수 없는 포맷은 원본 확장자·content-type 으로 올린다 - png 로 위장하지 않는다")
void uploadsOriginalFormatWhenCropIsImpossible() throws Exception {
// HEIC 은 ImageIO 에 디코더가 없어 크롭이 건너뛰어진다(의도된 fallback). 그때 원본 바이트를 .png ·
// image/png 로 올리면 브라우저가 렌더링하지 못하는 파일이 저장된다 — prod 에서 실제로 그랬다(#35).
// 등록이 허용하는 5개 포맷 중 webp·heic·heif 셋이 이 경로를 탄다.
stubGeminiClient.reset();
stubImageStorage.lastUploadedKey = null;
stubImageStorage.lastUploadedContentType = null;
byte[] heicBytes = {1, 2, 3, 4};
stubImageStorage.onDownload = (bucket, key) -> new StoredImage(heicBytes, "image/heic");
stubGeminiClient.build = request ->
new GeminiImageResult("사진 상품", 30000, null, "KRW", new GeminiImageResult.BoundingBoxDto(100, 100, 500, 500));

mockMvc().perform(post("/internal/extractions/image")
.contentType(MediaType.APPLICATION_JSON)
.content(body("items/raw/photo.heic")))
.andExpect(status().isOk());

assertEquals("image/heic", stubImageStorage.lastUploadedContentType);
assertTrue(stubImageStorage.lastUploadedKey.endsWith(".heic"));
assertArrayEquals(heicBytes, stubImageStorage.lastUploadedBytes, "크롭이 불가능하면 원본 바이트가 그대로 올라간다");
}

@Test
Expand Down Expand Up @@ -146,7 +176,7 @@ void storageDownloadError() throws Exception {
}

@Test
@DisplayName("추출이 name 을 못 채우면 422 UNTRUSTWORTHY_VALUE 를 반환한다 (link 와 같은 non-null 규약)")
@DisplayName("추출이 name 을 못 채워도 200 으로 채운 값을 반환한다 - 호출자가 INCOMPLETE 로 받는다")
void incompleteExtraction() throws Exception {
stubGeminiClient.reset();
stubImageStorage.onDownload = (bucket, key) -> new StoredImage(new byte[] {1, 2, 3}, "image/png");
Expand All @@ -155,7 +185,26 @@ void incompleteExtraction() throws Exception {
mockMvc().perform(post("/internal/extractions/image")
.contentType(MediaType.APPLICATION_JSON)
.content(body("items/raw/noname.png")))
.andExpect(status().isUnprocessableEntity())
.andExpect(jsonPath("$.code").value("UNTRUSTWORTHY_VALUE"));
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value(nullValue()))
.andExpect(jsonPath("$.currentPrice").value(1000))
// 이미지 경로는 추출 결과물을 올려 imageUrl 이 항상 채워진다 — 사용자가 채울 것은 이름뿐이다.
.andExpect(jsonPath("$.imageUrl").value(notNullValue()));
}

@Test
@DisplayName("이미지 경로는 추출값이 전부 비어도 업로드 결과가 있어 200 이다 - 사용자가 이름·가격을 채운다")
void noExtractedValue() throws Exception {
stubGeminiClient.reset();
stubImageStorage.onDownload = (bucket, key) -> new StoredImage(new byte[] {1, 2, 3}, "image/png");
// 가격·이름 모두 없음. imageUrl 은 업로드 결과라 채워지므로, 값 0개를 만들려면 업로드 자체가 없어야 하는데
// 이미지 경로에는 그 상태가 없다 — 그래서 이 판정은 link 경로에서 표면화된다(ExtractionLinkIntegrationTest).
stubGeminiClient.build = request -> new GeminiImageResult(null, null, null, null, null);

mockMvc().perform(post("/internal/extractions/image")
.contentType(MediaType.APPLICATION_JSON)
.content(body("items/raw/empty.png")))
.andExpect(status().isOk())
.andExpect(jsonPath("$.imageUrl").value(notNullValue()));
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.depromeet.piki.extractor.api;

import static org.hamcrest.Matchers.nullValue;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
Expand Down Expand Up @@ -186,15 +187,34 @@ void emptyShellReclassified() throws Exception {
}

@Test
@DisplayName("추출 결과가 READY 필수 필드(name·price·imageUrl)를 못 채우면 422 UNTRUSTWORTHY_VALUE 를 반환한다")
@DisplayName("추출 결과가 READY 필수 필드를 다 못 채워도 200 으로 채운 값을 반환한다")
void incompleteExtraction() throws Exception {
stubGeminiClient.reset();
stubPageFetcher.build = link -> PageContent.of(link, NO_STRUCTURED_HTML);
stubGeminiClient.build = request -> new GeminiExtractionResult(true, "이미지 없는 상품", 5000, "KRW", null);

// 예전에는 imageUrl 하나가 비었다고 422 로 닫아 이름·가격까지 버렸다. 이제는 채운 값을 내려보내고
// 호출자가 INCOMPLETE 로 받아 사용자가 나머지를 채운다(TeamPiKi/core#944).
mockMvc().perform(post("/internal/extractions/link")
.contentType(MediaType.APPLICATION_JSON)
.content(body("https://shop.example.com/p/4")))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("이미지 없는 상품"))
.andExpect(jsonPath("$.currentPrice").value(5000))
.andExpect(jsonPath("$.imageUrl").value(nullValue()));
}

@Test
@DisplayName("추출이 값을 하나도 못 채우면 422 UNTRUSTWORTHY_VALUE 를 반환한다")
void noExtractedValue() throws Exception {
stubGeminiClient.reset();
stubPageFetcher.build = link -> PageContent.of(link, NO_STRUCTURED_HTML);
// 상품 페이지라고는 하는데 아무 값도 못 뽑은 경우 — 사용자에게 무엇을 채우라 할 근거조차 없어 확정 실패로 닫는다.
stubGeminiClient.build = request -> new GeminiExtractionResult(true, null, null, null, null);

mockMvc().perform(post("/internal/extractions/link")
.contentType(MediaType.APPLICATION_JSON)
.content(body("https://shop.example.com/p/empty")))
.andExpect(status().isUnprocessableEntity())
.andExpect(jsonPath("$.code").value("UNTRUSTWORTHY_VALUE"));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@ public class StubImageStorage implements ImageStorage {
*/
public byte[] lastUploadedBytes;

/**
* 위장 업로드 회귀(#35)를 잡으려면 바이트만으로는 부족하다 — 크롭 불가 포맷(HEIC 등)의 원본을 {@code .png} ·
* {@code image/png} 로 올리던 버그는 key·content-type 을 봐야 드러난다. 같은 이유로 테스트 본문이 먼저 초기화한다.
*/
public String lastUploadedKey;

public String lastUploadedContentType;

@Override
public StoredImage download(String bucket, String key) {
return onDownload.apply(bucket, key);
Expand All @@ -32,6 +40,8 @@ public StoredImage download(String bucket, String key) {
@Override
public String upload(String bucket, byte[] bytes, String key, String contentType) {
lastUploadedBytes = bytes;
lastUploadedKey = key;
lastUploadedContentType = contentType;
return UPLOADED_URL;
}
}
Loading