diff --git a/src/main/java/com/depromeet/piki/extractor/api/ExtractionResponse.java b/src/main/java/com/depromeet/piki/extractor/api/ExtractionResponse.java index 9b3001f..afc3b4b 100644 --- a/src/main/java/com/depromeet/piki/extractor/api/ExtractionResponse.java +++ b/src/main/java/com/depromeet/piki/extractor/api/ExtractionResponse.java @@ -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 이다. * *

finalUrl·method 는 additive 확장이다. finalUrl 은 리다이렉트 귀결점(link 경로 항상, image 경로 null)으로 @@ -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(), diff --git a/src/main/java/com/depromeet/piki/extractor/domain/ProductSnapshot.java b/src/main/java/com/depromeet/piki/extractor/domain/ProductSnapshot.java index 6750901..44a48ae 100644 --- a/src/main/java/com/depromeet/piki/extractor/domain/ProductSnapshot.java +++ b/src/main/java/com/depromeet/piki/extractor/domain/ProductSnapshot.java @@ -1,5 +1,7 @@ package com.depromeet.piki.extractor.domain; +import java.util.ArrayList; +import java.util.List; import java.util.Objects; /** @@ -44,6 +46,35 @@ public boolean missingReadyField() { return name == null || name.isBlank() || imageUrl == null || currentPrice == null; } + /** + * 추출값을 하나도 못 얻었는가 — 호출자에게 내려보낼 것도, 사용자에게 "무엇을 채우라" 할 근거도 없는 상태다. + * + *

부분값(일부만 채움)은 호출자가 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 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; diff --git a/src/main/java/com/depromeet/piki/extractor/image/ImageExtractionService.java b/src/main/java/com/depromeet/piki/extractor/image/ImageExtractionService.java index 18ee037..c77a922 100644 --- a/src/main/java/com/depromeet/piki/extractor/image/ImageExtractionService.java +++ b/src/main/java/com/depromeet/piki/extractor/image/ImageExtractionService.java @@ -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(); @@ -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 에 + * 디코더가 없다)이면 원본 바이트 그대로다. + * + *

확장자·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); } } diff --git a/src/test/java/com/depromeet/piki/extractor/api/ExtractionImageIntegrationTest.java b/src/test/java/com/depromeet/piki/extractor/api/ExtractionImageIntegrationTest.java index 81b77fc..e6c2ae3 100644 --- a/src/test/java/com/depromeet/piki/extractor/api/ExtractionImageIntegrationTest.java +++ b/src/test/java/com/depromeet/piki/extractor/api/ExtractionImageIntegrationTest.java @@ -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; @@ -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 @@ -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"); @@ -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())); } } diff --git a/src/test/java/com/depromeet/piki/extractor/api/ExtractionLinkIntegrationTest.java b/src/test/java/com/depromeet/piki/extractor/api/ExtractionLinkIntegrationTest.java index 4efe841..d870901 100644 --- a/src/test/java/com/depromeet/piki/extractor/api/ExtractionLinkIntegrationTest.java +++ b/src/test/java/com/depromeet/piki/extractor/api/ExtractionLinkIntegrationTest.java @@ -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; @@ -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")); } diff --git a/src/test/java/com/depromeet/piki/extractor/support/StubImageStorage.java b/src/test/java/com/depromeet/piki/extractor/support/StubImageStorage.java index e96321c..b66c7d8 100644 --- a/src/test/java/com/depromeet/piki/extractor/support/StubImageStorage.java +++ b/src/test/java/com/depromeet/piki/extractor/support/StubImageStorage.java @@ -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); @@ -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; } }