diff --git a/src/main/java/com/fitlink/client/FitnessVideoFeignClient.java b/src/main/java/com/fitlink/client/FitnessVideoFeignClient.java index b8ea0e7..f3a58f2 100644 --- a/src/main/java/com/fitlink/client/FitnessVideoFeignClient.java +++ b/src/main/java/com/fitlink/client/FitnessVideoFeignClient.java @@ -2,25 +2,29 @@ import com.fitlink.config.FeignConfig; import com.fitlink.web.dto.FitnessVideoResponseDTO; +import feign.Headers; +import feign.Response; import org.springframework.cloud.openfeign.FeignClient; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestParam; +import java.net.URI; + /** - * 서울올림픽기념국민체육진흥공단의 '국민체력100 동영상 정보' API 호출을 위한 Feign Client. + * 서울올림픽기념국민체육진흥공단의 국민체력100 동영상 정보 API 호출을 담당하는 Feign Client 인터페이스임 */ @FeignClient(name = "kf100-video-api", url = "https://${kf100.base-url}", configuration = FeignConfig.class) public interface FitnessVideoFeignClient { /** - * 국민체력100 동영상 목록을 조회함. + * 국민체력100 동영상 목록을 조회함 * * @param serviceKey 공공데이터포털에서 발급받은 서비스 키 * @param pageNo 조회할 페이지 번호 * @param numOfRows 한 페이지당 결과 수 * @param fitnessFactor 검색할 동영상 제목 키워드 - * @param resultType 응답 데이터 형식 ("json" 또는 "xml") - * @return 외부 API 응답을 매핑한 {@link FitnessVideoResponseDTO} 객체 + * @param resultType 응답 데이터 형식 json 또는 xml + * @return 외부 API 응답을 매핑한 FitnessVideoResponseDTO 객체 */ @GetMapping(path = "/TODZ_VDO_FTNS_CERT_I", consumes = "text/json") FitnessVideoResponseDTO getVideos( @@ -30,4 +34,16 @@ FitnessVideoResponseDTO getVideos( @RequestParam("ftns_fctr_nm") String fitnessFactor, @RequestParam("resultType") String resultType ); + + /** + * 동영상 다운로드 (User-Agent 위장 필수!) + */ + @GetMapping + @Headers({ + // 크롬 브라우저인 척 User-Agent 설정 + "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + // 멀티미디어 요청임을 명시 + "Accept: */*" + }) + Response downloadVideo(URI uri); } \ No newline at end of file diff --git a/src/main/java/com/fitlink/config/security/SecurityConfig.java b/src/main/java/com/fitlink/config/security/SecurityConfig.java index 6e0b1c0..9b52b00 100644 --- a/src/main/java/com/fitlink/config/security/SecurityConfig.java +++ b/src/main/java/com/fitlink/config/security/SecurityConfig.java @@ -49,7 +49,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/images/**", "/oauth2/**", "/login", - "/login/oauth2/**" + "/login/oauth2/**", + "/api/video/stream" ).permitAll() .requestMatchers("/admin/**").hasRole("ADMIN") .anyRequest().authenticated() diff --git a/src/main/java/com/fitlink/web/controller/VideoController.java b/src/main/java/com/fitlink/web/controller/VideoController.java index 5b77bda..1890c0d 100644 --- a/src/main/java/com/fitlink/web/controller/VideoController.java +++ b/src/main/java/com/fitlink/web/controller/VideoController.java @@ -3,9 +3,17 @@ import com.fitlink.apiPayload.ApiResponse; import com.fitlink.client.FitnessVideoFeignClient; import com.fitlink.web.dto.FitnessVideoResponseDTO; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.util.StreamUtils; import org.springframework.web.bind.annotation.*; +import jakarta.servlet.http.HttpServletResponse; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URL; + +@Slf4j @RestController @RequestMapping("/api/video") public class VideoController { @@ -14,8 +22,10 @@ public class VideoController { @Value("${kf100.service-key}") private String serviceKey; + private final String USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"; + /** - * 컨트롤러 생성자. + * 컨트롤러 생성자 * @param feignClient 주입된 FeignClient 인스턴스 */ public VideoController(FitnessVideoFeignClient feignClient) { @@ -23,12 +33,12 @@ public VideoController(FitnessVideoFeignClient feignClient) { } /** - * 국민체력100 동영상 목록을 HTTP GET 요청을 통해 조회함. + * 국민체력100 동영상 목록을 HTTP GET 요청을 통해 조회함 * - * @param pageNo 조회할 페이지 번호 (기본값 1) - * @param numOfRows 한 페이지당 결과 수 (기본값 10) - * @param fitnessFactor 검색 키워드 (선택 사항) - * @return API 호출 결과를 담은 응답 엔티티 (HTTP 200 OK) + * @param pageNo 조회할 페이지 번호 기본값 1 + * @param numOfRows 한 페이지당 결과 수 기본값 10 + * @param fitnessFactor 검색 키워드 선택 사항 + * @return API 호출 결과를 담은 응답 엔티티 HTTP 200 OK */ @GetMapping public ApiResponse getVideos( @@ -36,9 +46,94 @@ public ApiResponse getVideos( @RequestParam(defaultValue = "10") int numOfRows, @RequestParam String fitnessFactor ) { + // [Log] 요청 수신 로그 + log.info("[VideoController] 동영상 목록 조회 요청 - Page: {}, Rows: {}, Factor: {}", pageNo, numOfRows, fitnessFactor); FitnessVideoResponseDTO response = feignClient.getVideos(serviceKey, pageNo, numOfRows, fitnessFactor, "json"); + // [Log] 응답 성공 로그 + log.info("[VideoController] 동영상 목록 조회 성공 - TotalCount: {}", response.getResponse().getBody().getTotalCount()); + return ApiResponse.onSuccess(response); } -} + + /** + * [Native Java 방식] 동영상 스트리밍 프록시 + * Feign을 거치지 않고 직접 연결하여 호환성 및 성능 문제를 해결합니다. + */ + @GetMapping("/stream") + public void streamVideo(@RequestParam("url") String videoUrl, HttpServletResponse response) { + log.info("[VideoController] 스트리밍 요청: {}", videoUrl); + + HttpURLConnection connection = null; + InputStream inputStream = null; + + try { + URL url = new URL(videoUrl); + connection = (HttpURLConnection) url.openConnection(); + + // 1. 헤더 설정 (브라우저인 척) + connection.setRequestMethod("GET"); + connection.setRequestProperty("User-Agent", USER_AGENT); + connection.setRequestProperty("Accept", "*/*"); + + connection.connect(); + + // 2. 응답 코드 확인 + int responseCode = connection.getResponseCode(); + + // ★ [핵심 추가] 3xx 리다이렉트(301, 302) 발생 시 처리 로직 + if (responseCode == HttpURLConnection.HTTP_MOVED_PERM || + responseCode == HttpURLConnection.HTTP_MOVED_TEMP || + responseCode == 307 || + responseCode == 308) { + + String newUrl = connection.getHeaderField("Location"); + log.info("[VideoController] 리다이렉트 감지 ({} -> {}), 재연결 시도...", responseCode, newUrl); + + // 기존 연결 해제 후 새 URL로 연결 + url = new URL(newUrl); + connection = (HttpURLConnection) url.openConnection(); + + // 헤더 다시 설정 (필수) + connection.setRequestMethod("GET"); + connection.setRequestProperty("User-Agent", USER_AGENT); + connection.setRequestProperty("Accept", "*/*"); + + connection.connect(); + responseCode = connection.getResponseCode(); // 새로운 응답 코드 확인 + } + + // 3. 최종 응답이 200이 아니면 에러 처리 + if (responseCode != 200) { + log.error("원본 서버 응답 에러: {}", responseCode); + response.sendError(responseCode, "원본 서버에서 영상을 가져올 수 없습니다."); + return; + } + + // 4. 헤더 설정 (브라우저 재생 유도) + response.setContentType("video/mp4"); + response.setHeader("Content-Disposition", "inline"); + + long contentLength = connection.getContentLengthLong(); + if (contentLength > 0) { + response.setHeader("Content-Length", String.valueOf(contentLength)); + } + + // 5. 데이터 전송 (스트리밍) + inputStream = connection.getInputStream(); + StreamUtils.copy(inputStream, response.getOutputStream()); + response.flushBuffer(); + + log.info("[VideoController] 스트리밍 전송 완료"); + + } catch (Exception e) { + // 클라이언트가 재생 중단(브라우저 닫기 등) 시 'Broken pipe' 에러가 날 수 있으나 자연스러운 현상입니다. + log.warn("스트리밍 전송 중단 또는 오류: {}", e.getMessage()); + } finally { + try { + if (inputStream != null) inputStream.close(); + } catch (Exception ignored) {} + } + } +} \ No newline at end of file