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 @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@
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;

public interface BookService {
List<SearchRes> search(String keyword, BookFilterReq req);

BookInfoRes details(Long isbn);

BookInfoRes ocr(MultipartFile file);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<BookGenre> targetGenres) {
String middle = extractMiddleCategory(item.categoryName());
Expand Down Expand Up @@ -105,4 +125,20 @@ private List<RecommendRes> 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -40,4 +41,12 @@ public ResponseEntity<SuccessResponse<BookInfoRes>> details(
return ResponseEntity.status(HttpStatus.OK).body(SuccessResponse.from(res));
}

@PostMapping("/ocr")
public ResponseEntity<SuccessResponse<BookInfoRes>> ocr(
@RequestParam("file") MultipartFile file
){
BookInfoRes res = bookService.ocr(file);
return ResponseEntity.status(HttpStatus.OK).body(SuccessResponse.from(res));
}

}
Original file line number Diff line number Diff line change
@@ -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<String, Object> 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();
}
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
6 changes: 5 additions & 1 deletion src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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}
spring.cloud.aws.s3.bucket=${S3_BUCKET_NAME}

#Google Vision
google.vision.api.key=${GOOGLE_VISION_KEY}
google.vision.endpoint=${GOOGLE_VISION_URL}