diff --git a/src/main/java/com/hansung/leafly/domain/book/exception/BookErrorCode.java b/src/main/java/com/hansung/leafly/domain/book/exception/BookErrorCode.java index cabf665..60f3a8c 100644 --- a/src/main/java/com/hansung/leafly/domain/book/exception/BookErrorCode.java +++ b/src/main/java/com/hansung/leafly/domain/book/exception/BookErrorCode.java @@ -8,6 +8,7 @@ @Getter @AllArgsConstructor public enum BookErrorCode implements BaseResponseCode{ + FILE_EMPTY("FILE_400_1", 400, "업로드된 파일이 비어있습니다."), BOOK_NOT_FOUND("BOOK_404_1", 404, "해당 ISBN으로 검색된 책이 없습니다."); private final String code; diff --git a/src/main/java/com/hansung/leafly/domain/book/exception/FileEmptyException.java b/src/main/java/com/hansung/leafly/domain/book/exception/FileEmptyException.java new file mode 100644 index 0000000..a6c394b --- /dev/null +++ b/src/main/java/com/hansung/leafly/domain/book/exception/FileEmptyException.java @@ -0,0 +1,10 @@ +package com.hansung.leafly.domain.book.exception; + +import com.hansung.leafly.global.exception.BaseException; +import com.hansung.leafly.global.response.code.BaseResponseCode; + +public class FileEmptyException extends BaseException { + public FileEmptyException() { + super(BookErrorCode.FILE_EMPTY); + } +} diff --git a/src/main/java/com/hansung/leafly/domain/book/service/BookService.java b/src/main/java/com/hansung/leafly/domain/book/service/BookService.java index ec5ebbc..c16ac3f 100644 --- a/src/main/java/com/hansung/leafly/domain/book/service/BookService.java +++ b/src/main/java/com/hansung/leafly/domain/book/service/BookService.java @@ -3,6 +3,7 @@ import com.hansung.leafly.domain.book.web.dto.BookFilterReq; import com.hansung.leafly.domain.book.web.dto.BookInfoRes; import com.hansung.leafly.domain.book.web.dto.SearchRes; +import org.springframework.web.multipart.MultipartFile; import java.util.List; @@ -10,4 +11,6 @@ public interface BookService { List search(String keyword, BookFilterReq req); BookInfoRes details(Long isbn); + + BookInfoRes ocr(MultipartFile file); } diff --git a/src/main/java/com/hansung/leafly/domain/book/service/BookServiceImpl.java b/src/main/java/com/hansung/leafly/domain/book/service/BookServiceImpl.java index beadafa..f4070df 100644 --- a/src/main/java/com/hansung/leafly/domain/book/service/BookServiceImpl.java +++ b/src/main/java/com/hansung/leafly/domain/book/service/BookServiceImpl.java @@ -2,23 +2,30 @@ import com.hansung.leafly.domain.book.entity.enums.BookGenre; import com.hansung.leafly.domain.book.exception.BookNotFoundException; +import com.hansung.leafly.domain.book.exception.FileEmptyException; import com.hansung.leafly.domain.book.web.dto.*; import com.hansung.leafly.infra.aladin.AladinClient; import com.hansung.leafly.infra.aladin.dto.BookRes; +import com.hansung.leafly.infra.googlevision.GoogleVisionClient; import com.hansung.leafly.infra.openai.OpenAiClient; import com.hansung.leafly.infra.openai.dto.BookSummaryAiRes; import com.hansung.leafly.infra.openai.prompt.RecommendationPrompt; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; @Service @RequiredArgsConstructor +@Slf4j public class BookServiceImpl implements BookService { private final AladinClient aladinClient; private final OpenAiClient openAiClient; + private final GoogleVisionClient googleVisionClient; // 검색 @Override @@ -76,6 +83,19 @@ public BookInfoRes details(Long isbn) { ); } + @Override + public BookInfoRes ocr(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new FileEmptyException(); + } + String ocrText = googleVisionClient.detectText(file); + log.info("[OCR] Vision API 추출 텍스트:\n{}", ocrText); + Long isbn = extractIsbn(ocrText); + log.info("[OCR] 추출된 ISBN: {}", isbn); + + return details(isbn); + } + //카테고리 필터링 private boolean matchesGenre(AladinBookItem item, List targetGenres) { String middle = extractMiddleCategory(item.categoryName()); @@ -105,4 +125,20 @@ private List toRecommendationList(AladinSearchRes res) { )) .toList(); } + + private Long extractIsbn(String ocrText) { + Pattern pattern = Pattern.compile("ISBN\\s*97[89][0-9\\-]{10,}"); + Matcher matcher = pattern.matcher(ocrText); + + if (matcher.find()) { + String raw = matcher.group(); + String isbn = raw.replaceAll("[^0-9]", ""); + + if (isbn.length() == 13) { + return Long.parseLong(isbn); + } + } + + return null; + } } diff --git a/src/main/java/com/hansung/leafly/domain/book/web/controller/BookController.java b/src/main/java/com/hansung/leafly/domain/book/web/controller/BookController.java index df45a66..ee5c48a 100644 --- a/src/main/java/com/hansung/leafly/domain/book/web/controller/BookController.java +++ b/src/main/java/com/hansung/leafly/domain/book/web/controller/BookController.java @@ -12,6 +12,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; import java.util.List; @@ -40,4 +41,12 @@ public ResponseEntity> details( return ResponseEntity.status(HttpStatus.OK).body(SuccessResponse.from(res)); } + @PostMapping("/ocr") + public ResponseEntity> ocr( + @RequestParam("file") MultipartFile file + ){ + BookInfoRes res = bookService.ocr(file); + return ResponseEntity.status(HttpStatus.OK).body(SuccessResponse.from(res)); + } + } diff --git a/src/main/java/com/hansung/leafly/infra/googlevision/GoogleVisionClient.java b/src/main/java/com/hansung/leafly/infra/googlevision/GoogleVisionClient.java new file mode 100644 index 0000000..d69089c --- /dev/null +++ b/src/main/java/com/hansung/leafly/infra/googlevision/GoogleVisionClient.java @@ -0,0 +1,78 @@ +package com.hansung.leafly.infra.googlevision; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.hansung.leafly.infra.googlevision.exception.GoogleVisionRequestFailedException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.multipart.MultipartFile; +import java.util.Base64; + +import java.util.List; +import java.util.Map; + +@Component +@RequiredArgsConstructor +@Slf4j +public class GoogleVisionClient { + + @Value("${google.vision.api.key}") + private String apiKey; + + @Value("${google.vision.endpoint}") + private String endpoint; + + private final RestTemplate restTemplate = new RestTemplate(); + private final ObjectMapper mapper = new ObjectMapper(); + + /** + * 이미지 OCR 수행 + */ + public String detectText(MultipartFile file) { + try { + // 이미지 Base64 인코딩 + String base64Image = Base64.getEncoder().encodeToString(file.getBytes()); + + // Vision API 요청 바디 + Map requestBody = Map.of( + "requests", List.of( + Map.of( + "image", Map.of("content", base64Image), + "features", List.of( + Map.of("type", "TEXT_DETECTION") + ) + ) + ) + ); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity entity = new HttpEntity<>(requestBody, headers); + + String url = endpoint + "?key=" + apiKey; + + String response = restTemplate.postForObject(url, entity, String.class); + + // JSON 파싱해서 OCR 텍스트만 추출 + JsonNode root = mapper.readTree(response); + JsonNode textNode = root + .path("responses") + .get(0) + .path("fullTextAnnotation") + .path("text"); + + return textNode.asText(); + + } catch (Exception e) { + log.error("Google Vision API 호출 실패: {}", e.getMessage(), e); + throw new GoogleVisionRequestFailedException(); + } + } +} diff --git a/src/main/java/com/hansung/leafly/infra/googlevision/exception/GoogleVisionErrorCode.java b/src/main/java/com/hansung/leafly/infra/googlevision/exception/GoogleVisionErrorCode.java new file mode 100644 index 0000000..4f1c43f --- /dev/null +++ b/src/main/java/com/hansung/leafly/infra/googlevision/exception/GoogleVisionErrorCode.java @@ -0,0 +1,15 @@ +package com.hansung.leafly.infra.googlevision.exception; + +import com.hansung.leafly.global.response.code.BaseResponseCode; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum GoogleVisionErrorCode implements BaseResponseCode { + GOOGLE_VISION_REQUEST_FAILED("VISION_503_1", 503, "Google Vision 요청 처리 중 오류가 발생하였습니다."); + + private final String code; + private final int httpStatus; + private final String message; +} diff --git a/src/main/java/com/hansung/leafly/infra/googlevision/exception/GoogleVisionRequestFailedException.java b/src/main/java/com/hansung/leafly/infra/googlevision/exception/GoogleVisionRequestFailedException.java new file mode 100644 index 0000000..978fcc8 --- /dev/null +++ b/src/main/java/com/hansung/leafly/infra/googlevision/exception/GoogleVisionRequestFailedException.java @@ -0,0 +1,10 @@ +package com.hansung.leafly.infra.googlevision.exception; + +import com.hansung.leafly.global.exception.BaseException; +import com.hansung.leafly.global.response.code.BaseResponseCode; + +public class GoogleVisionRequestFailedException extends BaseException { + public GoogleVisionRequestFailedException() { + super(GoogleVisionErrorCode.GOOGLE_VISION_REQUEST_FAILED); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 0cb38db..8f93ab3 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -30,4 +30,8 @@ openai.api.key=${OPENAI_API_KEY} spring.cloud.aws.credentials.access-key=${S3_ACCESS_KEY} spring.cloud.aws.credentials.secret-key=${S3_SECRET_KEY} spring.cloud.aws.region.static=ap-northeast-2 -spring.cloud.aws.s3.bucket=${S3_BUCKET_NAME} \ No newline at end of file +spring.cloud.aws.s3.bucket=${S3_BUCKET_NAME} + +#Google Vision +google.vision.api.key=${GOOGLE_VISION_KEY} +google.vision.endpoint=${GOOGLE_VISION_URL} \ No newline at end of file