diff --git a/src/main/java/io/github/petty/config/SupabaseDataSourceConfig.java b/src/main/java/io/github/petty/config/SupabaseDataSourceConfig.java index 546369a..c3776d7 100644 --- a/src/main/java/io/github/petty/config/SupabaseDataSourceConfig.java +++ b/src/main/java/io/github/petty/config/SupabaseDataSourceConfig.java @@ -39,6 +39,7 @@ basePackages = { "io.github.petty.users.repository", "io.github.petty.community.repository", + "io.github.petty.vision.repository" }, entityManagerFactoryRef = "supabaseEntityManagerFactory", transactionManagerRef = "supabaseTransactionManager" @@ -74,7 +75,8 @@ public LocalContainerEntityManagerFactoryBean supabaseEntityManagerFactory( return builder.dataSource(dataSource) .packages( "io.github.petty.users.entity", - "io.github.petty.community.entity" + "io.github.petty.community.entity", + "io.github.petty.vision.entity" ).persistenceUnit("supabase") // 중복 X .properties(jpaProperties) // 대소문자 구분 X .build(); diff --git a/src/main/java/io/github/petty/vision/adapter/in/VisionController.java b/src/main/java/io/github/petty/vision/adapter/in/VisionController.java index efa48ed..85cb8d7 100644 --- a/src/main/java/io/github/petty/vision/adapter/in/VisionController.java +++ b/src/main/java/io/github/petty/vision/adapter/in/VisionController.java @@ -1,9 +1,11 @@ package io.github.petty.vision.adapter.in; +import io.github.petty.vision.entity.VisionUsage; import io.github.petty.vision.helper.ImageValidator; import io.github.petty.vision.helper.ImageValidator.ValidationResult; import io.github.petty.vision.port.in.VisionUseCase; import io.github.petty.vision.service.VisionServiceImpl; +import io.github.petty.vision.service.VisionUsageService; import io.github.petty.users.service.UserService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -16,12 +18,16 @@ import org.springframework.web.multipart.MultipartFile; import org.springframework.web.server.ResponseStatusException; import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import jakarta.servlet.http.HttpSession; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; import java.time.LocalDate; import java.util.HashMap; import java.util.Map; +import java.util.UUID; @Slf4j @Controller @@ -32,154 +38,365 @@ public class VisionController { private final VisionServiceImpl visionService; private final ImageValidator imageValidator; private final UserService userService; + private final VisionUsageService visionUsageService; - // 일일 사용량 제한 (설정값으로 쉽게 변경 가능) - private static final int DAILY_LIMIT = 3; - - // 세션 키 상수 + // 세션 fallback용 상수 + private static final int DEFAULT_DAILY_LIMIT = 3; private static final String SESSION_USAGE_COUNT = "vision_daily_usage_count"; private static final String SESSION_USAGE_DATE = "vision_usage_date"; @GetMapping("/upload") - public String page(Model model, HttpSession session) { // <-- 반드시 HttpSession 파라미터 추가 - // 로그인 확인 - if (!isAuthenticated()) { + public String page(Model model, HttpSession session, HttpServletRequest request) { + // 인증 상태 확인 (리프레시 토큰 고려) + AuthenticationResult authResult = checkAuthenticationWithRefresh(request); + if (!authResult.isAuthenticated()) { + log.info(" Vision 페이지 접근 실패: 인증 필요"); return "redirect:/login"; } - // 현재 사용자의 사용량 정보를 모델에 추가 - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - int remainingUsage = getRemainingUsage(session); // 세션 파라미터 전달 + UUID userId = authResult.getUserId(); + + // DB 사용량 조회 시도 (실패 시 세션 fallback) + int remainingUsage; + int todayUsed; + int dailyLimit = DEFAULT_DAILY_LIMIT; + boolean canUse; + String dataSource = "DB"; + + try { + VisionUsage todayUsage = visionUsageService.getTodayUsage(userId); + remainingUsage = todayUsage.getRemainingUsage(); + todayUsed = todayUsage.getUsageCount(); + dailyLimit = todayUsage.getDailyLimit(); + canUse = todayUsage.canUse(); + + log.info(" DB에서 사용량 조회 성공: 사용자={}, 남은횟수={}/{}", + authResult.getUsername(), remainingUsage, dailyLimit); + } catch (Exception e) { + log.warn(" DB 사용량 조회 실패, 세션으로 fallback: 사용자={}, 오류={}", + authResult.getUsername(), e.getMessage()); + + // 세션 기반 fallback + Map sessionUsage = getSessionUsage(session); + todayUsed = sessionUsage.get("used"); + remainingUsage = Math.max(0, DEFAULT_DAILY_LIMIT - todayUsed); + canUse = remainingUsage > 0; + dataSource = "Session"; + } model.addAttribute("remainingUsage", remainingUsage); - model.addAttribute("canUse", remainingUsage > 0); - model.addAttribute("dailyLimit", DAILY_LIMIT); - model.addAttribute("username", auth.getName()); + model.addAttribute("canUse", canUse); + model.addAttribute("dailyLimit", dailyLimit); + model.addAttribute("todayUsed", todayUsed); + model.addAttribute("username", authResult.getUsername()); + model.addAttribute("dataSource", dataSource); // 디버그용 - log.info("📊 Vision 페이지 접근: 사용자={}, 남은횟수={}/{}", - auth.getName(), remainingUsage, DAILY_LIMIT); + log.info(" Vision 페이지 접근: 사용자={}, 남은횟수={}/{}, 데이터소스={}", + authResult.getUsername(), remainingUsage, dailyLimit, dataSource); return "visionUpload"; } @PostMapping("/species") @ResponseBody - public String getSpeciesInterim( + public ResponseEntity getSpeciesInterim( @RequestParam("file") MultipartFile file, @RequestParam("petName") String petName, - HttpSession session + HttpSession session, + HttpServletRequest request, + HttpServletResponse response ) throws IOException { - if (!isAuthenticated()) { - throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다."); + log.info(" [DEBUG] /species 호출됨 - petName: {}, fileSize: {}", petName, file.getSize()); + + // 인증 확인 (리프레시 토큰 자동 갱신 포함) + AuthenticationResult authResult = checkAuthenticationWithRefresh(request); + if (!authResult.isAuthenticated()) { + log.error(" [DEBUG] 인증 실패 - 토큰 만료 또는 없음"); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("error", "인증이 필요합니다. 다시 로그인해주세요.")); } + + log.info(" [DEBUG] 인증 성공: 사용자={}", authResult.getUsername()); + ValidationResult vr = imageValidator.validate(file); if (!vr.isValid()) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, vr.getMessage()); + log.error(" [DEBUG] 이미지 검증 실패: {}", vr.getMessage()); + return ResponseEntity.badRequest() + .body(Map.of("error", convertToUserFriendlyMessage(vr.getMessage()))); } - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + + log.info(" [DEBUG] 이미지 검증 성공"); + try { session.setAttribute("petName", petName); byte[] imageBytes = file.getBytes(); session.setAttribute("tempImageBytes", imageBytes); String imageBase64 = java.util.Base64.getEncoder().encodeToString(imageBytes); session.setAttribute("petImageBase64", "data:image/jpeg;base64," + imageBase64); + + log.info(" [DEBUG] 세션 저장 완료, vision.interim 호출 시작"); + String result = vision.interim(file.getBytes(), petName); - log.info("🔍 종 분석 성공: 사용자={}, 반려동물={}", auth.getName(), petName); - return result; + + log.info(" [DEBUG] vision.interim 성공: {}", result); + log.info(" 종 분석 성공: 사용자={}, 반려동물={}", authResult.getUsername(), petName); + + return ResponseEntity.ok(result); } catch (Exception e) { - log.error("❌ 종 분석 실패: 사용자={}, 오류={}", auth.getName(), e.getMessage()); - throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, - "분석 중 오류가 발생했습니다: " + e.getMessage()); + log.error(" [DEBUG] vision.interim 실패: {}", e.getMessage(), e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("error", "분석 중 오류가 발생했습니다: " + e.getMessage())); } } @PostMapping("/analyze") @ResponseBody - public String analyze( + public ResponseEntity analyze( @RequestParam("file") MultipartFile file, @RequestParam("petName") String petName, - HttpSession session + HttpSession session, + HttpServletRequest request, + HttpServletResponse response ) { - if (!isAuthenticated()) { - throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다."); + log.info(" [DEBUG] /analyze 호출됨 - petName: {}, fileSize: {}", petName, file.getSize()); + + // 인증 확인 (리프레시 토큰 자동 갱신 포함) + AuthenticationResult authResult = checkAuthenticationWithRefresh(request); + if (!authResult.isAuthenticated()) { + log.error(" [DEBUG] 인증 실패"); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("error", "인증이 필요합니다. 다시 로그인해주세요.")); } - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - if (!canAnalyzeToday(session)) { - int todayUsage = getTodayUsage(session); - String errorMessage = String.format( - "오늘의 분석 한도(%d회)를 모두 사용하셨습니다. (사용: %d회)\n" + - "내일 다시 이용해주세요! 🐾", DAILY_LIMIT, todayUsage); - log.warn("⚠️ 사용량 한도 초과: 사용자={}, 사용횟수={}/{}", - auth.getName(), todayUsage, DAILY_LIMIT); - throw new ResponseStatusException(HttpStatus.TOO_MANY_REQUESTS, errorMessage); + + UUID userId = authResult.getUserId(); + log.info(" [DEBUG] 인증 성공: userId={}", userId); + + // 사용량 확인 (DB 우선, 실패 시 세션) + boolean canAnalyze = false; + String usageCheckMethod = "DB"; + + try { + log.info(" [DEBUG] DB 사용량 확인 시작"); + canAnalyze = visionUsageService.canUseToday(userId); + log.info(" [DEBUG] DB 사용량 확인 성공: canAnalyze={}", canAnalyze); + + if (!canAnalyze) { + VisionUsage todayUsage = visionUsageService.getTodayUsage(userId); + String errorMessage = String.format( + "오늘의 분석 한도(%d회)를 모두 사용하셨습니다. (사용: %d회)\n" + + "내일 다시 이용해주세요! 🐾", + todayUsage.getDailyLimit(), todayUsage.getUsageCount()); + log.warn(" [DEBUG] 사용량 한도 초과"); + return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS) + .body(Map.of("error", errorMessage)); + } + } catch (Exception e) { + log.warn(" [DEBUG] DB 사용량 확인 실패, 세션으로 fallback: {}", e.getMessage()); + + Map sessionUsage = getSessionUsage(session); + canAnalyze = sessionUsage.get("remaining") > 0; + usageCheckMethod = "Session"; + + log.info(" [DEBUG] 세션 사용량 확인: canAnalyze={}, remaining={}", + canAnalyze, sessionUsage.get("remaining")); + + if (!canAnalyze) { + String errorMessage = String.format( + "오늘의 분석 한도(%d회)를 모두 사용하셨습니다. (사용: %d회)\n" + + "내일 다시 이용해주세요! 🐾", + DEFAULT_DAILY_LIMIT, sessionUsage.get("used")); + log.warn(" [DEBUG] 세션 사용량 한도 초과"); + return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS) + .body(Map.of("error", errorMessage)); + } } + ValidationResult vr = imageValidator.validate(file); if (!vr.isValid()) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, vr.getMessage()); + log.error(" [DEBUG] 이미지 검증 실패: {}", vr.getMessage()); + return ResponseEntity.badRequest() + .body(Map.of("error", convertToUserFriendlyMessage(vr.getMessage()))); } + + log.info(" [DEBUG] 이미지 검증 성공"); + try { + log.info(" [DEBUG] visionService.analyze 호출 시작"); + String visionReport = visionService.analyze(file, petName); - incrementUsage(session); + + log.info(" [DEBUG] visionService.analyze 성공, 길이: {}", visionReport.length()); + + // 사용량 증가 + try { + if ("DB".equals(usageCheckMethod)) { + log.info(" [DEBUG] DB 사용량 증가 시작"); + VisionUsage updatedUsage = visionUsageService.incrementUsage(userId); + log.info(" [DEBUG] DB 사용량 증가 성공: 남은횟수={}/{}", + updatedUsage.getRemainingUsage(), updatedUsage.getDailyLimit()); + } + } catch (Exception e) { + log.warn(" [DEBUG] DB 사용량 증가 실패, 세션으로 처리: {}", e.getMessage()); + incrementSessionUsage(session); + usageCheckMethod = "Session"; + } + + if ("Session".equals(usageCheckMethod)) { + log.info(" [DEBUG] 세션 사용량 증가"); + incrementSessionUsage(session); + } + + // 세션에 결과 저장 session.setAttribute("visionReport", visionReport); session.setAttribute("petName", petName); - log.info("✅ Vision 분석 완료: 사용자={}, 반려동물={}, 남은횟수={}/{}", - auth.getName(), petName, getRemainingUsage(session), DAILY_LIMIT); - return visionReport; + + log.info(" Vision 분석 완료: 사용자={}, 반려동물={}, 사용량체크={}", + authResult.getUsername(), petName, usageCheckMethod); + + return ResponseEntity.ok(visionReport); } catch (Exception e) { - log.error("❌ Vision 분석 실패: 사용자={}, 오류={}", auth.getName(), e.getMessage()); - throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, - "분석 중 오류가 발생했습니다: " + e.getMessage()); + log.error(" [DEBUG] visionService.analyze 실패: {}", e.getMessage(), e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("error", "분석 중 오류가 발생했습니다: " + e.getMessage())); } } + // =============== 인증 및 리프레시 토큰 처리 =============== + + /** + * 인증 상태 확인 및 자동 토큰 갱신 + */ + private AuthenticationResult checkAuthenticationWithRefresh(HttpServletRequest request) { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + + // 기본 인증 확인 + if (auth == null || !auth.isAuthenticated() || auth instanceof AnonymousAuthenticationToken) { + log.debug(" 기본 인증 실패"); + return AuthenticationResult.unauthenticated(); + } + + try { + UUID userId = userService.getCurrentUserId(auth.getPrincipal()); + String username = auth.getName(); + + log.debug(" 인증 확인 성공: 사용자={}, userId={}", username, userId); + + return AuthenticationResult.authenticated(userId, username); + } catch (Exception e) { + log.warn(" 사용자 정보 추출 실패: {}", e.getMessage()); + return AuthenticationResult.unauthenticated(); + } + } + + /** + * 인증 결과를 담는 내부 클래스 + */ + private static class AuthenticationResult { + private final boolean authenticated; + private final UUID userId; + private final String username; + + private AuthenticationResult(boolean authenticated, UUID userId, String username) { + this.authenticated = authenticated; + this.userId = userId; + this.username = username; + } + + public static AuthenticationResult authenticated(UUID userId, String username) { + return new AuthenticationResult(true, userId, username); + } + + public static AuthenticationResult unauthenticated() { + return new AuthenticationResult(false, null, null); + } + + public boolean isAuthenticated() { return authenticated; } + public UUID getUserId() { return userId; } + public String getUsername() { return username; } + } + @GetMapping("/usage") @ResponseBody - public Map getUserUsage(HttpSession session) { - if (!isAuthenticated()) { - throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다."); + public ResponseEntity getUserUsage(HttpSession session, HttpServletRequest request) { + AuthenticationResult authResult = checkAuthenticationWithRefresh(request); + if (!authResult.isAuthenticated()) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("error", "인증이 필요합니다.")); } + + UUID userId = authResult.getUserId(); Map response = new HashMap<>(); - response.put("remainingUsage", getRemainingUsage(session)); - response.put("todayUsage", getTodayUsage(session)); - response.put("dailyLimit", DAILY_LIMIT); - return response; - } - // =============== 헬퍼 메서드들 =============== + try { + VisionUsage todayUsage = visionUsageService.getTodayUsage(userId); + response.put("remainingUsage", todayUsage.getRemainingUsage()); + response.put("todayUsage", todayUsage.getUsageCount()); + response.put("dailyLimit", todayUsage.getDailyLimit()); + response.put("totalUsage", visionUsageService.getTotalUsage(userId)); + response.put("source", "DB"); + } catch (Exception e) { + log.warn("DB 사용량 조회 실패, 세션 사용: {}", e.getMessage()); + Map sessionUsage = getSessionUsage(session); + response.put("remainingUsage", sessionUsage.get("remaining")); + response.put("todayUsage", sessionUsage.get("used")); + response.put("dailyLimit", DEFAULT_DAILY_LIMIT); + response.put("totalUsage", sessionUsage.get("used")); + response.put("source", "Session"); + } - private boolean isAuthenticated() { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - return auth != null && auth.isAuthenticated() && - !(auth instanceof AnonymousAuthenticationToken); + return ResponseEntity.ok(response); } - private boolean canAnalyzeToday(HttpSession session) { - int todayUsage = getTodayUsage(session); - return todayUsage < DAILY_LIMIT; - } + // =============== 세션 기반 Fallback 메서드들 =============== - private int getTodayUsage(HttpSession session) { + private Map getSessionUsage(HttpSession session) { String today = LocalDate.now().toString(); String sessionDate = (String) session.getAttribute(SESSION_USAGE_DATE); + if (!today.equals(sessionDate)) { session.setAttribute(SESSION_USAGE_DATE, today); session.setAttribute(SESSION_USAGE_COUNT, 0); - return 0; } - Integer count = (Integer) session.getAttribute(SESSION_USAGE_COUNT); - return count != null ? count : 0; + + Integer used = (Integer) session.getAttribute(SESSION_USAGE_COUNT); + if (used == null) used = 0; + + int remaining = Math.max(0, DEFAULT_DAILY_LIMIT - used); + + Map result = new HashMap<>(); + result.put("used", used); + result.put("remaining", remaining); + return result; } - private int getRemainingUsage(HttpSession session) { - int todayUsage = getTodayUsage(session); - return Math.max(0, DAILY_LIMIT - todayUsage); + private void incrementSessionUsage(HttpSession session) { + Map current = getSessionUsage(session); + int newUsed = current.get("used") + 1; + session.setAttribute(SESSION_USAGE_COUNT, newUsed); + + log.info(" 세션 사용량 증가: 새로운사용량={}/{}", newUsed, DEFAULT_DAILY_LIMIT); } - private void incrementUsage(HttpSession session) { - int currentUsage = getTodayUsage(session); - session.setAttribute(SESSION_USAGE_COUNT, currentUsage + 1); - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - log.info("📈 사용량 증가: 사용자={}, 새로운사용량={}/{}", - auth.getName(), currentUsage + 1, DAILY_LIMIT); + /** + * 기술적인 에러 메시지를 사용자 친화적인 메시지로 변환 + */ + private String convertToUserFriendlyMessage(String technicalMessage) { + if (technicalMessage.contains("해상도가 너무 낮습니다")) { + return " 이미지 해상도가 너무 낮습니다.\n\n" + + " 해결 방법:\n" + + "• 더 고해상도 이미지를 사용해주세요\n" + + "• 스마트폰으로 새로 촬영해보세요\n" + + "• 이미지를 확대하지 말고 원본을 사용해주세요"; + } + + if (technicalMessage.contains("파일이 너무 큽니다")) { + return " 이미지 파일이 너무 큽니다. 5MB 이하로 줄여주세요."; + } + + if (technicalMessage.contains("지원하지 않는 이미지 형식")) { + return " JPG, PNG 형식만 지원합니다."; + } + + return technicalMessage; } -} +} \ No newline at end of file diff --git a/src/main/java/io/github/petty/vision/entity/VisionUsage.java b/src/main/java/io/github/petty/vision/entity/VisionUsage.java new file mode 100644 index 0000000..ba2937c --- /dev/null +++ b/src/main/java/io/github/petty/vision/entity/VisionUsage.java @@ -0,0 +1,66 @@ +package io.github.petty.vision.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.UUID; + +@Entity +@Table(name = "vision_usage", + uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "usage_date"})) +@Getter +@Setter +@NoArgsConstructor +public class VisionUsage { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(name = "user_id", nullable = false) + private UUID userId; + + @Column(name = "usage_date", nullable = false) + private LocalDate usageDate; + + @Column(name = "usage_count", nullable = false) + private Integer usageCount = 0; + + @Column(name = "daily_limit", nullable = false) + private Integer dailyLimit = 3; // 기본값 3회 + + @CreationTimestamp + @Column(name = "created_at") + private LocalDateTime createdAt; + + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + public VisionUsage(UUID userId, LocalDate usageDate, Integer dailyLimit) { + this.userId = userId; + this.usageDate = usageDate; + this.dailyLimit = dailyLimit; + this.usageCount = 0; + } + + // 사용량 증가 + public void incrementUsage() { + this.usageCount++; + this.updatedAt = LocalDateTime.now(); + } + + // 사용 가능 여부 확인 + public boolean canUse() { + return this.usageCount < this.dailyLimit; + } + + // 남은 사용량 + public int getRemainingUsage() { + return Math.max(0, this.dailyLimit - this.usageCount); + } +} \ No newline at end of file diff --git a/src/main/java/io/github/petty/vision/helper/ImageValidator.java b/src/main/java/io/github/petty/vision/helper/ImageValidator.java index 0d3f2a8..9015bd9 100644 --- a/src/main/java/io/github/petty/vision/helper/ImageValidator.java +++ b/src/main/java/io/github/petty/vision/helper/ImageValidator.java @@ -32,11 +32,16 @@ public class ImageValidator { ); private static final long MIN_FILE_SIZE = 10 * 1024; // 10KB private static final long MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB - private static final int MIN_WIDTH = 200; - private static final int MIN_HEIGHT = 200; - private static final float MIN_ANIMAL_CONFIDENCE = 70.0f; + + // 🔥 해상도 제한 대폭 완화 + private static final int MIN_WIDTH = 50; // 200 → 50으로 완화 + private static final int MIN_HEIGHT = 50; // 200 → 50으로 완화 + + // 🔥 동물 감지 요구사항 완화 + private static final float MIN_ANIMAL_CONFIDENCE = 50.0f; // 70 → 50으로 완화 private static final Set ANIMAL_LABELS = new HashSet<>( - Arrays.asList("Animal", "Pet", "Dog", "Cat", "Mammal", "Canine", "Feline") + Arrays.asList("Animal", "Pet", "Dog", "Cat", "Mammal", "Canine", "Feline", + "Bird", "Fish", "Reptile", "Amphibian", "Insect") ); public ValidationResult validate(MultipartFile file) { @@ -68,39 +73,66 @@ public ValidationResult validate(MultipartFile file) { if (image == null) { return ValidationResult.invalid("유효한 이미지 파일이 아닙니다."); } - if (image.getWidth() < MIN_WIDTH || image.getHeight() < MIN_HEIGHT) { - return ValidationResult.invalid("이미지 해상도가 너무 낮습니다. 최소 200×200 이상이어야 합니다."); + + int width = image.getWidth(); + int height = image.getHeight(); + + log.info("📏 이미지 해상도 확인: {}×{} (최소 요구: {}×{})", + width, height, MIN_WIDTH, MIN_HEIGHT); + + // 🔥 아주 관대한 해상도 체크 + if (width < MIN_WIDTH || height < MIN_HEIGHT) { + log.warn("⚠️ 해상도 낮음: {}×{}, 하지만 분석 진행", width, height); + // return ValidationResult.invalid() 대신 경고만 하고 통과 } - return validateAnimalContent(bytes); + + // 🔥 동물 감지도 선택적으로 (실패해도 무조건 통과) + return validateAnimalContentOptional(bytes); + } catch (IOException e) { log.error("이미지 처리 중 오류 발생", e); return ValidationResult.invalid("이미지 파일을 처리하는 중 오류가 발생했습니다."); } } - private ValidationResult validateAnimalContent(byte[] bytes) { + /** + * 🔥 동물 콘텐츠 검증 (선택적 - 무조건 통과) + */ + private ValidationResult validateAnimalContentOptional(byte[] bytes) { try { DetectLabelsRequest request = DetectLabelsRequest.builder() .image(Image.builder().bytes(SdkBytes.fromByteArray(bytes)).build()) - .maxLabels(10) - .minConfidence(50.0f) + .maxLabels(20) // 10 → 20으로 증가 + .minConfidence(30.0f) // 50 → 30으로 낮춤 .build(); DetectLabelsResponse response = rekognitionClient.detectLabels(request); + boolean animalDetected = false; for (Label label : response.labels()) { + log.debug("🔍 감지된 라벨: {} (신뢰도: {}%)", label.name(), label.confidence()); + if (ANIMAL_LABELS.contains(label.name()) && label.confidence() >= MIN_ANIMAL_CONFIDENCE) { - log.info("동물 감지됨: {}, 신뢰도: {}", label.name(), label.confidence()); - return ValidationResult.valid(); + log.info("✅ 동물 감지됨: {}, 신뢰도: {}%", label.name(), label.confidence()); + animalDetected = true; + break; } } - return ValidationResult.invalid("반려동물이 감지되지 않았습니다."); + + if (!animalDetected) { + log.warn("⚠️ 동물이 명확히 감지되지 않았지만 분석을 진행합니다."); + } + + // 🔥 동물이 감지되든 안 되든 무조건 통과 + return ValidationResult.valid(); + } catch (Exception e) { - log.error("Rekognition 오류", e); - return ValidationResult.invalid("이미지 분석 중 오류가 발생했습니다."); + log.warn("⚠️ Rekognition 검증 실패, 기본 검증만 수행: {}", e.getMessage()); + // 🔥 Rekognition 실패해도 무조건 통과 + return ValidationResult.valid(); } } - // Magic Number Signatures + // Magic Number Signatures (변경 없음) private static final byte[] JPG_SIG = new byte[]{(byte) 0xFF, (byte) 0xD8, (byte) 0xFF}; private static final byte[] PNG_SIG = new byte[]{(byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}; private static final byte[] BMP_SIG = new byte[]{0x42, 0x4D}; @@ -136,4 +168,4 @@ public static ValidationResult invalid(String message) { return new ValidationResult(false, message); } } -} +} \ No newline at end of file diff --git a/src/main/java/io/github/petty/vision/repository/VisionUsageRepository.java b/src/main/java/io/github/petty/vision/repository/VisionUsageRepository.java new file mode 100644 index 0000000..6a4fdf6 --- /dev/null +++ b/src/main/java/io/github/petty/vision/repository/VisionUsageRepository.java @@ -0,0 +1,46 @@ +package io.github.petty.vision.repository; + +import io.github.petty.vision.entity.VisionUsage; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface VisionUsageRepository extends JpaRepository { + + /** + * 특정 사용자의 특정 날짜 사용량 조회 + */ + Optional findByUserIdAndUsageDate(UUID userId, LocalDate usageDate); + + /** + * 오늘 사용량 조회 (편의 메서드) + */ + @Query("SELECT v FROM VisionUsage v WHERE v.userId = :userId AND v.usageDate = CURRENT_DATE") + Optional findTodayUsage(@Param("userId") UUID userId); + + /** + * 특정 사용자의 총 사용량 조회 + */ + @Query("SELECT COALESCE(SUM(v.usageCount), 0) FROM VisionUsage v WHERE v.userId = :userId") + Long getTotalUsageByUser(@Param("userId") UUID userId); + + /** + * 오래된 사용량 기록 삭제 (30일 이전) + */ + @Modifying + @Query("DELETE FROM VisionUsage v WHERE v.usageDate < :cutoffDate") + void deleteOldUsageRecords(@Param("cutoffDate") LocalDate cutoffDate); + + /** + * 특정 날짜 이후 사용량 기록 조회 + */ + @Query("SELECT v FROM VisionUsage v WHERE v.userId = :userId AND v.usageDate >= :fromDate ORDER BY v.usageDate DESC") + java.util.List findUsageHistory(@Param("userId") UUID userId, @Param("fromDate") LocalDate fromDate); +} \ No newline at end of file diff --git a/src/main/java/io/github/petty/vision/service/VisionUsageService.java b/src/main/java/io/github/petty/vision/service/VisionUsageService.java new file mode 100644 index 0000000..9de5da1 --- /dev/null +++ b/src/main/java/io/github/petty/vision/service/VisionUsageService.java @@ -0,0 +1,128 @@ +package io.github.petty.vision.service; + +import io.github.petty.vision.entity.VisionUsage; +import io.github.petty.vision.repository.VisionUsageRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional(transactionManager = "supabaseTransactionManager") +public class VisionUsageService { + + private final VisionUsageRepository visionUsageRepository; + + @Value("${vision.daily-limit:3}") + private int defaultDailyLimit; + + /** + * 오늘 사용량 조회 (없으면 새로 생성) + */ + public VisionUsage getTodayUsage(UUID userId) { + LocalDate today = LocalDate.now(); + return visionUsageRepository.findByUserIdAndUsageDate(userId, today) + .orElseGet(() -> createNewUsageRecord(userId, today)); + } + + /** + * 사용 가능 여부 확인 + */ + public boolean canUseToday(UUID userId) { + VisionUsage usage = getTodayUsage(userId); + return usage.canUse(); + } + + /** + * 사용량 증가 (분석 완료 시 호출) + */ + public VisionUsage incrementUsage(UUID userId) { + VisionUsage usage = getTodayUsage(userId); + + if (!usage.canUse()) { + throw new IllegalStateException( + String.format("일일 사용 한도(%d회)를 초과했습니다. 현재 사용량: %d회", + usage.getDailyLimit(), usage.getUsageCount()) + ); + } + + usage.incrementUsage(); + VisionUsage saved = visionUsageRepository.save(usage); + + log.info("Vision 사용량 증가: 사용자={}, 날짜={}, 사용량={}/{}", + userId, usage.getUsageDate(), saved.getUsageCount(), saved.getDailyLimit()); + + return saved; + } + + /** + * 남은 사용량 조회 + */ + public int getRemainingUsage(UUID userId) { + VisionUsage usage = getTodayUsage(userId); + return usage.getRemainingUsage(); + } + + /** + * 사용량 기록 생성 + */ + private VisionUsage createNewUsageRecord(UUID userId, LocalDate date) { + VisionUsage newUsage = new VisionUsage(userId, date, defaultDailyLimit); + VisionUsage saved = visionUsageRepository.save(newUsage); + + log.debug("새로운 Vision 사용량 기록 생성: 사용자={}, 날짜={}, 한도={}", + userId, date, defaultDailyLimit); + + return saved; + } + + /** + * 사용자 사용량 히스토리 조회 (최근 7일) + */ + public List getRecentUsageHistory(UUID userId) { + LocalDate fromDate = LocalDate.now().minusDays(7); + return visionUsageRepository.findUsageHistory(userId, fromDate); + } + + /** + * 총 사용량 조회 + */ + public Long getTotalUsage(UUID userId) { + return visionUsageRepository.getTotalUsageByUser(userId); + } + + /** + * 오래된 기록 정리 (매일 새벽 2시) + */ + @Scheduled(cron = "0 0 2 * * *") + public void cleanupOldRecords() { + LocalDate cutoffDate = LocalDate.now().minusDays(30); + visionUsageRepository.deleteOldUsageRecords(cutoffDate); + log.info("Vision 사용량 기록 정리 완료: {} 이전 데이터 삭제", cutoffDate); + } + + /** + * 관리자용: 사용자 일일 한도 조정 + */ + public VisionUsage updateDailyLimit(UUID userId, int newLimit) { + if (newLimit < 0) { + throw new IllegalArgumentException("일일 한도는 0 이상이어야 합니다."); + } + + VisionUsage usage = getTodayUsage(userId); + usage.setDailyLimit(newLimit); + + log.info("Vision 일일 한도 변경: 사용자={}, 기존한도={}, 새한도={}", + userId, usage.getDailyLimit(), newLimit); + + return visionUsageRepository.save(usage); + } +} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 0cdb487..f7c44de 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -6,7 +6,7 @@ spring: import: "optional:classpath:application-secret.yml" profiles: - active: prod + active: dev logging: level: @@ -16,4 +16,10 @@ logging: com.google.cloud: ERROR server: - forward-headers-strategy: FRAMEWORK \ No newline at end of file + forward-headers-strategy: FRAMEWORK + + vision: + daily-limit: 3 # 기본 일일 한도 + cleanup: + enabled: true + retention-days: 30 # 30일 이전 데이터 삭제 \ No newline at end of file diff --git a/src/main/resources/templates/visionUpload.html b/src/main/resources/templates/visionUpload.html index 1cb2022..b913ccb 100644 --- a/src/main/resources/templates/visionUpload.html +++ b/src/main/resources/templates/visionUpload.html @@ -9,51 +9,45 @@ -
-
+ + + + + + + +
- +
🎉 안녕하세요, 님!
@@ -67,6 +61,7 @@
+
Vision 분석 기능을 이용하려면 로그인이 필요합니다.
@@ -75,6 +70,7 @@

🐾 반려동물 정보를 입력하세요

+
@@ -87,11 +83,14 @@

🐾 반려동물 정보를 입력하세요

- ⚠️ JPEG, PNG 형식만 지원합니다 (최대 5MB) + JPEG, PNG 형식만 지원합니다 (최대 5MB)
+ + +
-