diff --git a/backend/src/main/java/com/example/backend/analysis/controller/CaseStatsController.java b/backend/src/main/java/com/example/backend/analysis/controller/CaseStatsController.java index 2b3e3aa1..81b518fc 100644 --- a/backend/src/main/java/com/example/backend/analysis/controller/CaseStatsController.java +++ b/backend/src/main/java/com/example/backend/analysis/controller/CaseStatsController.java @@ -7,7 +7,6 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import java.util.Collections; import java.util.List; import java.util.Map; @@ -23,10 +22,6 @@ public class CaseStatsController { public ResponseEntity getOverview(HttpSession session) { // 결과가 없으면 404, 있으면 200 CaseStatsOverviewResponse response = caseStatsService.getOverview(session); - if (response == null) { - return ResponseEntity.status(404) - .body(Collections.singletonMap("message", "개요 정보가 없습니다.")); - } return ResponseEntity.ok(response); } @@ -36,10 +31,6 @@ public ResponseEntity getHourlyCaseStats(@RequestParam("date") String date, @RequestParam(value = "category", required = false) String category, HttpSession session) { List stats = caseStatsService.getHourlyCaseStats(date, category, session); - if (stats.isEmpty()) { - return ResponseEntity.status(404) - .body(Collections.singletonMap("message", "시간대별 사건 정보가 없습니다.")); - } return ResponseEntity.ok(stats); } diff --git a/backend/src/main/java/com/example/backend/analysis/service/CaseStatsService.java b/backend/src/main/java/com/example/backend/analysis/service/CaseStatsService.java index 66dc640b..62d2aabb 100644 --- a/backend/src/main/java/com/example/backend/analysis/service/CaseStatsService.java +++ b/backend/src/main/java/com/example/backend/analysis/service/CaseStatsService.java @@ -56,7 +56,7 @@ public CaseStatsOverviewResponse getOverview(HttpSession session) { // 최근 한 달간 데이터를 기준으로 사건 건수가 가장 많은 CCTV 주소 조회 String patrolRegionAddress = statsOverviewRepository.findAddressWithMostIncidentsLastMonth(officeId) - .orElse("정보 없음"); + .orElse("해당 지역에 순찰 강화 필요 구역이 없습니다."); return CaseStatsOverviewResponse.fromEntity(stats, patrolRegionAddress); } diff --git a/backend/src/main/java/com/example/backend/common/GlobalExceptionHandler.java b/backend/src/main/java/com/example/backend/common/GlobalExceptionHandler.java index 5d0368a0..640540d4 100644 --- a/backend/src/main/java/com/example/backend/common/GlobalExceptionHandler.java +++ b/backend/src/main/java/com/example/backend/common/GlobalExceptionHandler.java @@ -1,21 +1,32 @@ package com.example.backend.common; -import org.springframework.web.bind.annotation.ControllerAdvice; - import jakarta.persistence.EntityNotFoundException; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; +import java.io.IOException; import java.util.Collections; import java.util.NoSuchElementException; +@Slf4j @ControllerAdvice public class GlobalExceptionHandler { + // SSE 연결 종료 (브라우저에서 탭 닫거나, 연결 끊긴 경우) + @ExceptionHandler(IOException.class) + public void handleIOException(IOException e) { + log.warn("SSE 연결 종료: {}", e.getMessage()); + } + @ExceptionHandler(IllegalStateException.class) public ResponseEntity handleIllegalStateException(IllegalStateException e) { - if (e.getMessage().contains("로그인이 필요합니다.")) { + log.warn("IllegalStateException: {}", e.getMessage()); + if (e.getMessage().contains("로그인이 필요")) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED) .body(Collections.singletonMap("message", e.getMessage())); } @@ -36,8 +47,19 @@ public ResponseEntity handleEntityNotFoundException(EntityNotFoundException e } @ExceptionHandler(Exception.class) - public ResponseEntity handleGenericException(Exception e) { - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(Collections.singletonMap("message", e.getMessage())); + public void handleGenericException(Exception e, HttpServletResponse response) throws IOException { + log.error("Unexpected error: ", e); + if ("text/event-stream".equals(response.getContentType())) { + // SSE 통신 도중 에러 → event-stream 포맷으로 전달 + response.getWriter().write("event: error\n"); + response.getWriter().write("data: " + e.getMessage() + "\n\n"); + response.getWriter().flush(); + } else { + // 일반 요청 → JSON 에러 응답 + response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.getWriter().write("{\"message\": \"" + e.getMessage() + "\"}"); + response.getWriter().flush(); + } } } diff --git a/backend/src/main/java/com/example/backend/config/CorsConfig.java b/backend/src/main/java/com/example/backend/config/CorsConfig.java index 581721ca..c93949bc 100644 --- a/backend/src/main/java/com/example/backend/config/CorsConfig.java +++ b/backend/src/main/java/com/example/backend/config/CorsConfig.java @@ -16,8 +16,8 @@ public void addCorsMappings(CorsRegistry corsRegistry) { corsRegistry.addMapping("/**") .allowedOrigins(allowedOrigins) .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD") - .allowedHeaders("Authorization", "Content-Type") - .exposedHeaders("Authorization") + .allowedHeaders("Authorization", "Content-Type", "Last-Event-ID") + .exposedHeaders("Authorization", "Content-Type", "Cache-Control", "Last-Event-ID") .allowCredentials(true); } } \ No newline at end of file diff --git a/backend/src/main/java/com/example/backend/config/SecurityConfig.java b/backend/src/main/java/com/example/backend/config/SecurityConfig.java index fdade071..cadc527a 100644 --- a/backend/src/main/java/com/example/backend/config/SecurityConfig.java +++ b/backend/src/main/java/com/example/backend/config/SecurityConfig.java @@ -1,8 +1,5 @@ package com.example.backend.config; -import java.util.Arrays; -import java.util.List; - import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; @@ -15,6 +12,9 @@ import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import java.util.Arrays; +import java.util.List; + @Configuration @EnableWebSecurity @RequiredArgsConstructor @@ -28,22 +28,21 @@ public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Excepti return httpSecurity .csrf(AbstractHttpConfigurer::disable) .httpBasic(AbstractHttpConfigurer::disable) - // CORS 설정을 수동으로 추가 .cors(cors -> cors.configurationSource(corsConfigurationSource())) .build(); } - // CORS 설정을 위한 Bean 정의 @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); configuration.setAllowedOrigins(List.of(allowedOrigins)); // 프론트엔드 도메인 configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD")); // 허용할 HTTP 메서드 - configuration.setAllowedHeaders(Arrays.asList("Authorization", "Content-Type")); // 허용할 헤더 - configuration.setExposedHeaders(List.of("Authorization")); // 응답에서 노출할 헤더 + configuration.setAllowedHeaders(Arrays.asList("Authorization", "Content-Type", "Last-Event-ID")); // 허용할 헤더 + configuration.setExposedHeaders(Arrays.asList("Authorization", "Content-Type", "Cache-Control", "Last-Event-ID")); // 응답에서 노출할 헤더 configuration.setAllowCredentials(true); // 자격 증명 포함 요청 허용 + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", configuration); // 모든 경로에 대해 CORS 설정 적용 return source; } -} \ No newline at end of file +} diff --git a/backend/src/main/java/com/example/backend/dashboard/controller/CaseDetectController.java b/backend/src/main/java/com/example/backend/dashboard/controller/CaseDetectController.java index 6f6dff6f..7683e4db 100644 --- a/backend/src/main/java/com/example/backend/dashboard/controller/CaseDetectController.java +++ b/backend/src/main/java/com/example/backend/dashboard/controller/CaseDetectController.java @@ -4,7 +4,9 @@ import com.example.backend.dashboard.dto.CaseDetectResponse; import com.example.backend.dashboard.service.CaseDetectService; import com.example.backend.dashboard.service.SseEmitterService; +import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; @@ -17,6 +19,9 @@ public class CaseDetectController { private final CaseDetectService caseDetectService; private final SseEmitterService sseEmitterService; + @Value("${cors.allowed-origins}") + private String allowedOrigin; + @PostMapping("/detect") public ResponseEntity detectAlarm(@RequestBody CaseDetectRequest request) { CaseDetectResponse response = caseDetectService.saveCase(request); @@ -25,7 +30,12 @@ public ResponseEntity detectAlarm(@RequestBody CaseDetectRequest request) { } @GetMapping("/subscribe") - public SseEmitter subscribe() { + public SseEmitter subscribe(HttpServletResponse response) { + response.setHeader("Access-Control-Allow-Origin", allowedOrigin); + response.setHeader("Access-Control-Allow-Credentials", "true"); + response.setHeader("Cache-Control", "no-cache"); + response.setHeader("Connection", "keep-alive"); + return sseEmitterService.createEmitter(); } diff --git a/backend/src/main/java/com/example/backend/dashboard/controller/DashboardController.java b/backend/src/main/java/com/example/backend/dashboard/controller/DashboardController.java index b85a519e..e48da81b 100644 --- a/backend/src/main/java/com/example/backend/dashboard/controller/DashboardController.java +++ b/backend/src/main/java/com/example/backend/dashboard/controller/DashboardController.java @@ -7,7 +7,6 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import java.util.Collections; import java.util.List; import java.util.Map; @@ -22,10 +21,6 @@ public class DashboardController { @GetMapping("") public ResponseEntity getCases(HttpSession session) { List cases = dashboardService.getCases(session); - if (cases.isEmpty()) { - return ResponseEntity.status(404) - .body(Collections.singletonMap("message", "사건이 없습니다.")); - } return ResponseEntity.ok(cases); } diff --git a/backend/src/main/java/com/example/backend/dashboard/service/DashboardService.java b/backend/src/main/java/com/example/backend/dashboard/service/DashboardService.java index dc33a3ba..3808a2e1 100644 --- a/backend/src/main/java/com/example/backend/dashboard/service/DashboardService.java +++ b/backend/src/main/java/com/example/backend/dashboard/service/DashboardService.java @@ -66,6 +66,10 @@ public List getCases(HttpSession session) { ); List cases = dashboardRepository.findAllByOfficeIdAndStateInOrderById(officeId, targetStates); + if (cases.isEmpty()) { + throw new NoSuchElementException("미확인, 확인 또는 출동 중인 사건이 없습니다."); + } + return cases.stream() .map(DashboardResponse::fromEntity) .collect(Collectors.toList()); @@ -80,6 +84,11 @@ public Map getCaseVideo(int id, HttpSession session) { throw new EntityNotFoundException("해당 사건에 대한 영상이 없습니다."); } + if (caseEntity.getState() == CaseEntity.CaseState.미확인) { + caseEntity.setState(CaseEntity.CaseState.확인); + dashboardRepository.save(caseEntity); + } + return Collections.singletonMap("video", videoUrl); }