diff --git a/src/main/java/com/sb/web/account/controller/OcrController.java b/src/main/java/com/sb/web/account/controller/OcrController.java new file mode 100644 index 0000000..3b5ca9d --- /dev/null +++ b/src/main/java/com/sb/web/account/controller/OcrController.java @@ -0,0 +1,40 @@ +package com.sb.web.account.controller; + +import com.sb.web.account.service.VisionOcrService; +import com.sb.web.auth.dto.SessionUser; +import com.sb.web.auth.web.AuthInterceptor; +import com.sb.web.common.exception.ApiException; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestAttribute; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.Map; + +/** + * 영수증 OCR API (/api/account/ocr/** — 로그인 필요). + * 이미지 업로드 → Google Vision 으로 텍스트 추출 → 프론트에서 금액·날짜·상호 파싱. + */ +@RestController +@RequestMapping("/api/account/ocr") +@RequiredArgsConstructor +public class OcrController { + + private final VisionOcrService visionOcrService; + + @PostMapping("/receipt") + public Map receipt( + @RequestParam("image") MultipartFile image, + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) throws IOException { + if (image == null || image.isEmpty()) { + throw new ApiException(HttpStatus.BAD_REQUEST, "영수증 이미지를 첨부하세요."); + } + String text = visionOcrService.recognize(image.getBytes()); + return Map.of("text", text); + } +} diff --git a/src/main/java/com/sb/web/account/service/VisionOcrService.java b/src/main/java/com/sb/web/account/service/VisionOcrService.java new file mode 100644 index 0000000..d42b401 --- /dev/null +++ b/src/main/java/com/sb/web/account/service/VisionOcrService.java @@ -0,0 +1,82 @@ +package com.sb.web.account.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sb.web.common.exception.ApiException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClient; + +import java.util.Base64; +import java.util.List; +import java.util.Map; + +/** + * Google Cloud Vision 영수증 OCR. 이미지(byte) → 전체 텍스트. + * API 키는 환경변수(GOOGLE_VISION_API_KEY)로만 주입하며, 프론트엔드엔 노출하지 않는다. + */ +@Slf4j +@Service +public class VisionOcrService { + + private static final String ENDPOINT = "https://vision.googleapis.com/v1/images:annotate"; + + private final RestClient restClient = RestClient.builder().build(); + private final ObjectMapper objectMapper; + private final String apiKey; + + public VisionOcrService(ObjectMapper objectMapper, + @Value("${google.vision.api-key:}") String apiKey) { + this.objectMapper = objectMapper; + this.apiKey = apiKey; + } + + public boolean isEnabled() { + return apiKey != null && !apiKey.isBlank(); + } + + /** 영수증 이미지에서 전체 텍스트 추출 */ + public String recognize(byte[] image) { + if (!isEnabled()) { + throw new ApiException(HttpStatus.SERVICE_UNAVAILABLE, "영수증 OCR이 설정되지 않았습니다."); + } + String content = Base64.getEncoder().encodeToString(image); + Map body = Map.of( + "requests", List.of(Map.of( + "image", Map.of("content", content), + "features", List.of(Map.of("type", "DOCUMENT_TEXT_DETECTION")), + "imageContext", Map.of("languageHints", List.of("ko")) + )) + ); + + String responseJson; + try { + responseJson = restClient.post() + .uri(ENDPOINT + "?key=" + apiKey) + .contentType(MediaType.APPLICATION_JSON) + .body(body) + .retrieve() + .body(String.class); + } catch (Exception e) { + log.warn("Vision OCR 호출 실패: {}", e.getMessage()); + throw new ApiException(HttpStatus.BAD_GATEWAY, "영수증 인식 서버 호출에 실패했습니다."); + } + + try { + JsonNode r0 = objectMapper.readTree(responseJson).path("responses").path(0); + if (r0.has("error")) { + log.warn("Vision OCR 오류 응답: {}", r0.path("error").path("message").asText()); + throw new ApiException(HttpStatus.BAD_GATEWAY, "영수증 인식에 실패했습니다."); + } + return r0.path("fullTextAnnotation").path("text").asText(""); + } catch (ApiException e) { + throw e; + } catch (Exception e) { + log.warn("Vision OCR 응답 파싱 실패: {}", e.getMessage()); + throw new ApiException(HttpStatus.BAD_GATEWAY, "영수증 인식 결과를 처리하지 못했습니다."); + } + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index b8b10e3..3effacd 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -13,6 +13,12 @@ spring: application: name: sb_bt + # 영수증 OCR 이미지 업로드 크기 (프론트에서 축소해 보내지만 여유 확보) + servlet: + multipart: + max-file-size: 10MB + max-request-size: 12MB + # ===== SQL 초기화 ===== 기동 시 member 테이블 자동 생성(IF NOT EXISTS 라 멱등) sql: init: @@ -56,6 +62,12 @@ mybatis: default-fetch-size: 100 default-statement-timeout: 30 +# ===== Google Cloud Vision (영수증 OCR) ===== +# 키는 서버 /opt/sb-backend/.env 에 GOOGLE_VISION_API_KEY=... 로 주입 (git 미추적). 미설정이면 OCR 비활성. +google: + vision: + api-key: ${GOOGLE_VISION_API_KEY:} + # ===== Logging ===== logging: level: