From bc59605267eb28fc892a66182f9e69adf47a6f70 Mon Sep 17 00:00:00 2001 From: junmin Date: Wed, 16 Sep 2026 11:42:21 +0900 Subject: [PATCH] =?UTF-8?q?fix(security):=20F01=C2=B7F02=C2=B7F03=C2=B7F05?= =?UTF-8?q?=20=EC=84=9C=EB=B2=84=20=EC=B7=A8=EC=95=BD=EC=A0=90=20=EB=B3=B4?= =?UTF-8?q?=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - F01: Gemini를 사용한 브리핑 생성을 서버로 이동 - 서버 환경변수로 API 키 관리 - 인증·소유권 확인 후 저장된 분석 결과로 프롬프트 구성 - 호출 제한·캐시·동시 요청 제어·타임아웃 적용 - F02: 영상 및 분석 리포트 소유권 검증 - 타인 영상·삭제된 영상·없는 영상 접근 차단 - URL 서명과 외부 API 호출 전에 권한 확인 - 접근 불가·분석 대기·실패 상태 구분 - F03: access와 refresh 토큰의 사용 목적 검증 - 일반 API에서 refresh 토큰 사용 차단 - 서명·만료·사용자 ID 검증 강화 - 토큰 갱신 시 저장된 refresh 토큰 확인 - F05: 업로드 파일의 실제 내용 검증 - ffprobe 기반 영상 형식·크기·해상도 검사 - 썸네일 검사 및 JPEG 재인코딩 - 검증 실패 시 저장과 분석 요청 차단 - 서버에서 MIME과 객체 키 결정 --- .dockerignore | 8 + .github/workflows/ci-cd.yml | 3 + Dockerfile | 3 +- .../backend/config/JwtAuthFilter.java | 12 +- .../rallytrack/backend/config/JwtUtil.java | 19 +- .../rallytrack/backend/config/S3Service.java | 18 +- .../controller/AnalysisController.java | 3 +- .../analysis/service/AnalysisService.java | 13 +- .../domain/briefing/BriefingController.java | 24 +++ .../domain/briefing/BriefingPrompt.java | 79 +++++++++ .../domain/briefing/BriefingService.java | 82 +++++++++ .../backend/domain/briefing/GeminiClient.java | 101 +++++++++++ .../domain/user/service/UserService.java | 8 +- .../video/controller/VideoController.java | 3 +- .../video/repository/VideoRepository.java | 3 + .../video/service/MediaValidationService.java | 167 ++++++++++++++++++ .../domain/video/service/ValidatedMedia.java | 18 ++ .../video/service/VideoAccessService.java | 21 +++ .../domain/video/service/VideoService.java | 62 ++++--- .../global/exception/ApiException.java | 17 ++ .../exception/GlobalExceptionHandler.java | 19 ++ .../backend/global/response/ApiResponse.java | 1 + src/main/resources/application.yml.example | 6 +- .../rallytrack/backend/SecurityHttpTest.java | 156 ++++++++++++++++ .../backend/config/JwtSecurityTest.java | 54 ++++++ .../domain/briefing/BriefingServiceTest.java | 91 ++++++++++ .../domain/briefing/GeminiClientTest.java | 62 +++++++ .../video/service/MediaValidationTest.java | 92 ++++++++++ src/test/resources/media/sample.mov | Bin 0 -> 1829 bytes src/test/resources/media/sample.mp4 | Bin 0 -> 1882 bytes src/test/resources/media/sample.webm | Bin 0 -> 972 bytes 31 files changed, 1095 insertions(+), 50 deletions(-) create mode 100644 .dockerignore create mode 100644 src/main/java/com/rallytrack/backend/domain/briefing/BriefingController.java create mode 100644 src/main/java/com/rallytrack/backend/domain/briefing/BriefingPrompt.java create mode 100644 src/main/java/com/rallytrack/backend/domain/briefing/BriefingService.java create mode 100644 src/main/java/com/rallytrack/backend/domain/briefing/GeminiClient.java create mode 100644 src/main/java/com/rallytrack/backend/domain/video/service/MediaValidationService.java create mode 100644 src/main/java/com/rallytrack/backend/domain/video/service/ValidatedMedia.java create mode 100644 src/main/java/com/rallytrack/backend/domain/video/service/VideoAccessService.java create mode 100644 src/main/java/com/rallytrack/backend/global/exception/ApiException.java create mode 100644 src/test/java/com/rallytrack/backend/SecurityHttpTest.java create mode 100644 src/test/java/com/rallytrack/backend/config/JwtSecurityTest.java create mode 100644 src/test/java/com/rallytrack/backend/domain/briefing/BriefingServiceTest.java create mode 100644 src/test/java/com/rallytrack/backend/domain/briefing/GeminiClientTest.java create mode 100644 src/test/java/com/rallytrack/backend/domain/video/service/MediaValidationTest.java create mode 100644 src/test/resources/media/sample.mov create mode 100644 src/test/resources/media/sample.mp4 create mode 100644 src/test/resources/media/sample.webm diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..35ccc3a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ + +.git +.gradle +build +.env +.env.* +src/main/resources/application.yml +src/main/resources/application-secret.yml diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index f3dcb7d..5ee6276 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -31,6 +31,9 @@ jobs: - name: Validate wrapper and configure Gradle cache uses: gradle/actions/setup-gradle@v6 + - name: Install media validator + run: sudo apt-get update && sudo apt-get install -y ffmpeg + - name: Run unit and context tests run: ./gradlew test --no-daemon diff --git a/Dockerfile b/Dockerfile index 1afad10..e02d402 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,13 +1,14 @@ FROM gradle:8.5-jdk17 AS builder WORKDIR /app COPY . . +RUN cp src/main/resources/application.yml.example src/main/resources/application.yml # 테스트는 건너뛰고 빌드만 빠르게 수행 RUN ./gradlew bootJar -x test # 2. 실행 단계 FROM eclipse-temurin:17-jdk # compose healthcheck용 curl -RUN apt-get update && apt-get install -y --no-install-recommends curl \ +RUN apt-get update && apt-get install -y --no-install-recommends curl ffmpeg \ && rm -rf /var/lib/apt/lists/* WORKDIR /app # 빌드 단계에서 만들어진 jar 파일을 가져옴 diff --git a/src/main/java/com/rallytrack/backend/config/JwtAuthFilter.java b/src/main/java/com/rallytrack/backend/config/JwtAuthFilter.java index 982c9bc..34df4b5 100644 --- a/src/main/java/com/rallytrack/backend/config/JwtAuthFilter.java +++ b/src/main/java/com/rallytrack/backend/config/JwtAuthFilter.java @@ -75,12 +75,16 @@ protected void doFilterInternal(HttpServletRequest request, if (authHeader != null && authHeader.startsWith("Bearer ")) { String token = authHeader.substring(7); - if (jwtUtil.isValid(token)) { - Long userId = jwtUtil.getUserId(token); - request.setAttribute("userId", userId); - filterChain.doFilter(request, response); + Long userId; + try { + userId = jwtUtil.parseAccessToken(token).get("user_id", Long.class); + } catch (io.jsonwebtoken.JwtException | IllegalArgumentException e) { + reject(response); return; } + request.setAttribute("userId", userId); + filterChain.doFilter(request, response); + return; } // 인증 실패 diff --git a/src/main/java/com/rallytrack/backend/config/JwtUtil.java b/src/main/java/com/rallytrack/backend/config/JwtUtil.java index 2219cce..fc7941c 100644 --- a/src/main/java/com/rallytrack/backend/config/JwtUtil.java +++ b/src/main/java/com/rallytrack/backend/config/JwtUtil.java @@ -55,12 +55,25 @@ public Claims parseToken(String token) { } public Long getUserId(String token) { - return parseToken(token).get("user_id", Long.class); + return parseAccessToken(token).get("user_id", Long.class); } - public boolean isValid(String token) { + public Claims parseAccessToken(String token) { + return parseTypedToken(token, "access"); + } + + private Claims parseTypedToken(String token, String expectedType) { + Claims claims = parseToken(token); + Long userId = claims.get("user_id", Long.class); + if (!expectedType.equals(claims.get("token_type", String.class)) || userId == null || userId <= 0 || claims.getExpiration() == null) { + throw new IllegalArgumentException("Invalid token purpose or principal"); + } + return claims; + } + + public boolean isValidRefreshToken(String token) { try { - parseToken(token); + parseTypedToken(token, "refresh"); return true; } catch (Exception e) { return false; diff --git a/src/main/java/com/rallytrack/backend/config/S3Service.java b/src/main/java/com/rallytrack/backend/config/S3Service.java index c483149..07b6adf 100644 --- a/src/main/java/com/rallytrack/backend/config/S3Service.java +++ b/src/main/java/com/rallytrack/backend/config/S3Service.java @@ -3,7 +3,6 @@ import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; -import org.springframework.web.multipart.MultipartFile; import software.amazon.awssdk.core.sync.RequestBody; import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; @@ -13,7 +12,6 @@ import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; -import java.io.IOException; import java.time.Duration; import java.util.UUID; @@ -32,17 +30,11 @@ public class S3Service { // DB에는 전체 URL이 아닌 object key(예: videos/uuid_name.mp4)만 저장한다. // 스토리지 endpoint(MinIO ↔ AWS)가 바뀌어도 DB 데이터가 유효하도록 하기 위함. - public String upLoadFile(MultipartFile file) throws IOException { - String key = "videos/" + UUID.randomUUID() + "_" + file.getOriginalFilename(); - - PutObjectRequest request = PutObjectRequest.builder() - .bucket(bucket) - .key(key) - .contentType(file.getContentType()) - .build(); - - s3Client.putObject(request, RequestBody.fromBytes(file.getBytes())); - + public String uploadMedia(com.rallytrack.backend.domain.video.service.ValidatedMedia media) { + String key = "videos/" + UUID.randomUUID() + "." + media.extension(); + PutObjectRequest request = PutObjectRequest.builder().bucket(bucket).key(key) + .contentType(media.contentType()).build(); + s3Client.putObject(request, RequestBody.fromFile(media.path())); return key; } diff --git a/src/main/java/com/rallytrack/backend/domain/analysis/controller/AnalysisController.java b/src/main/java/com/rallytrack/backend/domain/analysis/controller/AnalysisController.java index 8379230..4356642 100644 --- a/src/main/java/com/rallytrack/backend/domain/analysis/controller/AnalysisController.java +++ b/src/main/java/com/rallytrack/backend/domain/analysis/controller/AnalysisController.java @@ -29,8 +29,9 @@ public class AnalysisController { @Operation(summary = "분석 리포트 조회", description = "영상 분석 리포트를 조회합니다.") @GetMapping("/{videoId}") public ResponseEntity> getReport( + @RequestAttribute("userId") Long userId, @PathVariable Long videoId) { - AnalysisReportResponse response = analysisService.getReport(videoId); + AnalysisReportResponse response = analysisService.getReport(userId, videoId); return ResponseEntity.ok(ApiResponse.success("분석 리포트 조회 성공", response)); } diff --git a/src/main/java/com/rallytrack/backend/domain/analysis/service/AnalysisService.java b/src/main/java/com/rallytrack/backend/domain/analysis/service/AnalysisService.java index 7b4c70f..b13d36b 100644 --- a/src/main/java/com/rallytrack/backend/domain/analysis/service/AnalysisService.java +++ b/src/main/java/com/rallytrack/backend/domain/analysis/service/AnalysisService.java @@ -26,6 +26,7 @@ @RequiredArgsConstructor public class AnalysisService { + private final com.rallytrack.backend.domain.video.service.VideoAccessService videoAccessService; private final AnalysisResultRepository analysisResultRepository; private final HitRepository hitRepository; private final VideoRepository videoRepository; @@ -34,9 +35,17 @@ public class AnalysisService { // ── 분석 리포트 조회 ───────────────────────────────────── @Transactional(readOnly = true) - public AnalysisReportResponse getReport(Long videoId) { + public AnalysisReportResponse getReport(Long userId, Long videoId) { + Video video = videoAccessService.requireOwned(userId, videoId); + if ("FAILED".equals(video.getVideoStatus())) { + throw new com.rallytrack.backend.global.exception.ApiException(409, "ANALYSIS_FAILED", "영상 분석에 실패했습니다."); + } + if (!"COMPLETED".equals(video.getVideoStatus())) { + throw new com.rallytrack.backend.global.exception.ApiException(404, "ANALYSIS_NOT_READY", "영상 분석을 준비하고 있습니다."); + } AnalysisResult result = analysisResultRepository.findByVideoVideoId(videoId) - .orElseThrow(() -> new ResourceNotFroundException("해당 영상의 분석 결과가 없습니다.")); + .orElseThrow(() -> new com.rallytrack.backend.global.exception.ApiException( + 409, "ANALYSIS_RESULT_UNAVAILABLE", "분석 결과를 불러올 수 없습니다.")); // HitDto에 minimap_x/y 포함 → 프론트 히트맵이 미니맵과 동일한 좌표 사용 List hitDtos = result.getHits().stream() diff --git a/src/main/java/com/rallytrack/backend/domain/briefing/BriefingController.java b/src/main/java/com/rallytrack/backend/domain/briefing/BriefingController.java new file mode 100644 index 0000000..1f43666 --- /dev/null +++ b/src/main/java/com/rallytrack/backend/domain/briefing/BriefingController.java @@ -0,0 +1,24 @@ +package com.rallytrack.backend.domain.briefing; + +import com.rallytrack.backend.global.response.ApiResponse; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/videos") +public class BriefingController { + private final BriefingService service; + public record Request(@NotNull @Pattern(regexp = "top|bottom") String player) {} + public record Response(Long videoId, String player, String text) {} + + @PostMapping("/{videoId}/briefing") + public ApiResponse generate(@RequestAttribute("userId") Long userId, + @PathVariable Long videoId, @Valid @RequestBody Request request) { + return ApiResponse.success("브리핑 생성 성공", new Response(videoId, request.player(), + service.generate(userId, videoId, request.player()))); + } +} diff --git a/src/main/java/com/rallytrack/backend/domain/briefing/BriefingPrompt.java b/src/main/java/com/rallytrack/backend/domain/briefing/BriefingPrompt.java new file mode 100644 index 0000000..fee5abd --- /dev/null +++ b/src/main/java/com/rallytrack/backend/domain/briefing/BriefingPrompt.java @@ -0,0 +1,79 @@ +package com.rallytrack.backend.domain.briefing; + +import com.rallytrack.backend.domain.analysis.dto.*; + +final class BriefingPrompt { + static final String VERSION = "v1"; + private BriefingPrompt() {} + static String grade(int n) { return n >= 85 ? "S" : n >= 70 ? "A" : n >= 50 ? "B" : n >= 30 ? "C" : "D"; } + private static int count(Integer n) { return n == null ? 0 : n; } + static String create(AnalysisReportResponse report, String player) { + var p = "top".equals(player) ? report.getPlayers().getTop() : report.getPlayers().getBottom(); + var a = p.getAbilityMetrics(); var summary = report.getSummary(); + String label = "top".equals(player) ? "Top Player" : "Bottom Player"; + var strokes = counts(report, player); + boolean pro = java.util.stream.Stream.of(counts(report, "top"), counts(report, "bottom")) + .anyMatch(c -> c.get("lob") > 0 || c.get("drop") > 0); + var kinds = pro ? java.util.List.of("serve", "lob", "smash", "drop", "drive", "clear") + : java.util.List.of("serve", "smash", "clear", "drive"); + int total = kinds.stream().mapToInt(strokes::get).sum(); + String breakdown = kinds.stream().map(k -> k + ": " + strokes.get(k) + "회") + .collect(java.util.stream.Collectors.joining(", ")); + return """ + 당신은 전문 배드민턴 코치입니다. + 아래 데이터는 경기 영상에서 분석한 [%s] 개인의 데이터입니다. + [경기 전체 개요 — 참고용, 개인 수치 아님] + - 경기 결과(Bottom 기준): %s (Bottom %d : Top %d) + - 총 경기 시간: %s + - 양측 합산 총 스트로크: %d회 + [%s 개인 스트로크] + - 개인 스트로크 합계: %d회 + - 분류 체계: %s + - %s + [%s 능력치 등급 (S > A > B > C > D)] + - 공격성 %s등급: 전체 타격 중 스매시 비율 + - 랠리력 %s등급: 랠리 지속력 및 지구력 + - 수비력 %s등급: 빠른 반응 속도 + - 기동력 %s등급: 코트 커버리지 + - 안정성 %s등급: 실책 없이 안정적으로 플레이하는 능력 + [기존 코치 피드백] + %s + [출력 형식] + - 간결하게 (모바일 친화적) + - 다음 H2 제목을 정확히 순서대로 사용: ## 총평, ## 핵심 지표, ## 강점, ## 보완점, ## 추천 훈련 + - 총평은 2~3문장. 핵심 지표는 불릿 3~5개. 강점과 보완점은 각각 불릿 2개. 추천 훈련은 불릿 3개. + - 섹션 제목 외에는 H1/H2/H3를 쓰지 않는다. + """.formatted(label, summary.getMatchOutcome(), summary.getMyScore(), summary.getOpponentScore(), + summary.getMatchTime(), summary.getTotalStrokeCount(), label, total, pro ? "프로 6종" : "아마추어 4종", + breakdown, label, grade(a.getAggression()), grade(a.getRally()), grade(a.getDefense()), + grade(a.getMobility()), grade(a.getConsistency()), + p.getAiCoaching() == null ? "(없음)" : p.getAiCoaching().getFeedbackText()); + } + private static java.util.Map counts(AnalysisReportResponse report, String player) { + var p = "top".equals(player) ? report.getPlayers().getTop() : report.getPlayers().getBottom(); + var s = p.getStrokeTypes(); + var counts = new java.util.HashMap(); + for (String key : java.util.List.of("serve", "lob", "smash", "drop", "drive", "clear", "net", "others")) counts.put(key, 0); + int matched = 0; + if (report.getHitsData() != null) for (var hit : report.getHitsData()) { + String side = hit.getPlayer() == null ? "" : hit.getPlayer().toLowerCase(java.util.Locale.ROOT); + if (!(player.equals(side) || ("top".equals(player) ? "pink_top" : "green_bottom").equals(side))) continue; + String type = hit.getStrokeType() == null ? "" : hit.getStrokeType().toLowerCase(java.util.Locale.ROOT).trim(); + if (type.isEmpty()) continue; + String key = type.contains("smash") || type.contains("스매시") ? "smash" + : type.contains("lob") || type.contains("로브") ? "lob" + : type.contains("drop") || type.contains("드롭") || type.contains("커트") ? "drop" + : type.contains("drive") || type.contains("드라이브") ? "drive" + : type.contains("serve") || type.contains("service") || type.contains("서브") ? "serve" + : type.contains("clear") || type.contains("클리어") ? "clear" + : type.contains("net") || type.contains("네트") || type.contains("헤어핀") ? "net" : "others"; + counts.merge(key, 1, Integer::sum); matched++; + } + if (matched == 0) { + counts.put("serve", count(s.getServe())); counts.put("smash", count(s.getSmash())); + counts.put("drop", count(s.getDrop())); counts.put("drive", count(s.getDrive())); + counts.put("clear", count(s.getClear())); counts.put("net", count(s.getNet())); counts.put("others", count(s.getOthers())); + } + return counts; + } +} diff --git a/src/main/java/com/rallytrack/backend/domain/briefing/BriefingService.java b/src/main/java/com/rallytrack/backend/domain/briefing/BriefingService.java new file mode 100644 index 0000000..82738fa --- /dev/null +++ b/src/main/java/com/rallytrack/backend/domain/briefing/BriefingService.java @@ -0,0 +1,82 @@ +package com.rallytrack.backend.domain.briefing; + +import com.rallytrack.backend.domain.analysis.service.AnalysisService; +import com.rallytrack.backend.global.exception.ApiException; +import org.springframework.stereotype.Service; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Clock; +import java.util.*; +import java.util.concurrent.*; + +@Service +public class BriefingService { + private record Cached(String text, long until) {} + private record Usage(long window, int count) {} + private final AnalysisService analysis; + private final GeminiClient client; + private final Clock clock; + private final Map cache = new LinkedHashMap<>(); + private final Map> inFlight = new HashMap<>(); + private final Map users = new HashMap<>(); + private Usage global = new Usage(0, 0); + private final Object lock = new Object(); + + @org.springframework.beans.factory.annotation.Autowired + public BriefingService(AnalysisService analysis, GeminiClient client) { this(analysis, client, Clock.systemUTC()); } + BriefingService(AnalysisService analysis, GeminiClient client, Clock clock) { + this.analysis = analysis; this.client = client; this.clock = clock; + } + + public String generate(Long userId, Long videoId, String player) { + if (!Set.of("top", "bottom").contains(player == null ? "" : player)) + throw new ApiException(400, "INVALID_PLAYER", "선수 위치가 올바르지 않습니다."); + // This MUST precede both the cache and the provider, even for an already cached video. + var report = analysis.getReport(userId, videoId); + client.requireEnabled(); + String prompt = BriefingPrompt.create(report, player); + String key = userId + ":" + videoId + ":" + player + ":" + client.model() + ":" + BriefingPrompt.VERSION + ":" + digest(prompt); + CompletableFuture future; + boolean leader = false; + synchronized (lock) { + long now = clock.millis(); + cache.values().removeIf(c -> c.until <= now); + Cached existing = cache.get(key); + if (existing != null) return existing.text; + future = inFlight.get(key); + if (future == null) { + long window = now / 60000; + users.values().removeIf(u -> u.window != window); + Usage user = users.getOrDefault(userId, new Usage(window, 0)); + if (global.window != window) global = new Usage(window, 0); + if (user.count >= 6 || global.count >= 30 || inFlight.size() >= 2 || users.size() >= 10000) + throw new ApiException(429, "BRIEFING_RATE_LIMIT", "요청이 많습니다. 잠시 후 다시 시도해주세요."); + users.put(userId, new Usage(window, user.count + 1)); + global = new Usage(window, global.count + 1); + future = new CompletableFuture<>(); inFlight.put(key, future); leader = true; + } + } + if (leader) { + try { + String text = client.generate(prompt); + synchronized (lock) { + if (cache.size() >= 256) cache.remove(cache.keySet().iterator().next()); + cache.put(key, new Cached(text, clock.millis() + 1800000)); + } + future.complete(text); + } catch (RuntimeException e) { future.completeExceptionally(e); + } finally { synchronized (lock) { inFlight.remove(key); } } + } + try { return future.get(35, TimeUnit.SECONDS); + } catch (ExecutionException e) { + if (e.getCause() instanceof ApiException a) throw a; + throw new ApiException(502, "BRIEFING_PROVIDER_ERROR", "브리핑을 생성하지 못했습니다."); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); throw new ApiException(503, "BRIEFING_UNAVAILABLE", "브리핑이 중단되었습니다."); + } catch (TimeoutException e) { throw new ApiException(504, "BRIEFING_TIMEOUT", "브리핑 응답이 지연되고 있습니다."); } + } + private String digest(String text) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(text.getBytes(StandardCharsets.UTF_8))); + } catch (java.security.NoSuchAlgorithmException e) { throw new IllegalStateException(e); } + } +} diff --git a/src/main/java/com/rallytrack/backend/domain/briefing/GeminiClient.java b/src/main/java/com/rallytrack/backend/domain/briefing/GeminiClient.java new file mode 100644 index 0000000..b2b0147 --- /dev/null +++ b/src/main/java/com/rallytrack/backend/domain/briefing/GeminiClient.java @@ -0,0 +1,101 @@ +package com.rallytrack.backend.domain.briefing; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.rallytrack.backend.global.exception.ApiException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import java.net.URI; +import java.net.http.*; +import java.nio.ByteBuffer; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.*; +import java.util.concurrent.Flow; + +@Component +public class GeminiClient { + private final ObjectMapper mapper; + private final String apiKey; + private final String model; + private final boolean enabled; + private final HttpClient http; + private final Duration deadline; + + @org.springframework.beans.factory.annotation.Autowired + public GeminiClient(ObjectMapper mapper, @Value("${GEMINI_API_KEY:}") String apiKey, + @Value("${GEMINI_MODEL:gemini-3.6-flash}") String model, + @Value("${GEMINI_ENABLED:false}") boolean enabled) { + this(mapper, apiKey, model, enabled, HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)) + .followRedirects(HttpClient.Redirect.NEVER).build(), Duration.ofSeconds(30)); + } + GeminiClient(ObjectMapper mapper, String apiKey, String model, boolean enabled, HttpClient http, Duration deadline) { + this.mapper = mapper; this.apiKey = apiKey; this.model = model; this.enabled = enabled; + this.http = http; this.deadline = deadline; + if (!model.matches("[a-zA-Z0-9.-]{1,80}")) throw new IllegalArgumentException("Invalid Gemini model setting"); + } + public String model() { return model; } + public void requireEnabled() { + if (!enabled || apiKey.isBlank()) throw new ApiException(503, "BRIEFING_UNAVAILABLE", "브리핑 기능을 사용할 수 없습니다."); + } + public String generate(String prompt) { + requireEnabled(); + if (prompt.length() > 12000) throw new ApiException(400, "BRIEFING_INPUT_TOO_LARGE", "분석 데이터가 너무 큽니다."); + CompletableFuture> pending = null; + try { + byte[] body = mapper.writeValueAsBytes(Map.of( + "contents", List.of(Map.of("parts", List.of(Map.of("text", prompt)))), + "generationConfig", Map.of("maxOutputTokens", 1500, "temperature", 0.4))); + HttpRequest request = HttpRequest.newBuilder(URI.create( + "https://generativelanguage.googleapis.com/v1beta/models/" + model + ":generateContent")) + .header("x-goog-api-key", apiKey).header("Content-Type", "application/json") + .timeout(Duration.ofSeconds(25)).POST(HttpRequest.BodyPublishers.ofByteArray(body)).build(); + pending = http.sendAsync(request, info -> new LimitedBodySubscriber(65536)); + HttpResponse response = pending.get(deadline.toMillis(), TimeUnit.MILLISECONDS); + if (response.statusCode() != 200) throw providerError(); + var root = mapper.readTree(response.body()); + var candidate = root.path("candidates").path(0); + if (!"STOP".equals(candidate.path("finishReason").asText())) throw providerError(); + StringBuilder text = new StringBuilder(); + for (var part : candidate.path("content").path("parts")) { + if (!part.path("thought").asBoolean(false)) text.append(part.path("text").asText("")); + } + if (text.isEmpty() || text.length() > 8000) throw providerError(); + return text.toString(); + } catch (TimeoutException e) { + if (pending != null) pending.cancel(true); + throw new ApiException(504, "BRIEFING_TIMEOUT", "브리핑 응답이 지연되고 있습니다. 잠시 후 다시 시도해주세요."); + } catch (ExecutionException e) { + if (e.getCause() instanceof HttpTimeoutException) + throw new ApiException(504, "BRIEFING_TIMEOUT", "브리핑 응답이 지연되고 있습니다. 잠시 후 다시 시도해주세요."); + throw providerError(); + } catch (InterruptedException e) { + if (pending != null) pending.cancel(true); + Thread.currentThread().interrupt(); throw providerError(); + } catch (ApiException e) { throw e; + } catch (Exception e) { throw providerError(); } + } + private ApiException providerError() { + // Never propagate upstream bodies, URLs, headers or exceptions containing credentials. + return new ApiException(502, "BRIEFING_PROVIDER_ERROR", "브리핑 생성에 실패했습니다. 잠시 후 다시 시도해주세요."); + } + + static final class LimitedBodySubscriber implements HttpResponse.BodySubscriber { + private final HttpResponse.BodySubscriber delegate = HttpResponse.BodySubscribers.ofByteArray(); + private final int max; + private int received; + private Flow.Subscription subscription; + LimitedBodySubscriber(int max) { this.max = max; } + public CompletionStage getBody() { return delegate.getBody(); } + public void onSubscribe(Flow.Subscription s) { subscription = s; delegate.onSubscribe(s); } + public void onNext(List items) { + long bytes = items.stream().mapToLong(ByteBuffer::remaining).sum(); + if (bytes > max - received) { + subscription.cancel(); delegate.onError(new IllegalStateException("Provider response exceeds limit")); return; + } + received += (int) bytes; delegate.onNext(items); + } + public void onError(Throwable t) { delegate.onError(t); } + public void onComplete() { delegate.onComplete(); } + } +} diff --git a/src/main/java/com/rallytrack/backend/domain/user/service/UserService.java b/src/main/java/com/rallytrack/backend/domain/user/service/UserService.java index 4e3f4aa..658b0ce 100644 --- a/src/main/java/com/rallytrack/backend/domain/user/service/UserService.java +++ b/src/main/java/com/rallytrack/backend/domain/user/service/UserService.java @@ -125,15 +125,17 @@ public LoginResponse refreshToken(String refreshTokenStr) { } // 3. JWT 자체 검증 - if (!jwtUtil.isValid(refreshTokenStr)) { + if (!jwtUtil.isValidRefreshToken(refreshTokenStr)) { refreshTokenRepository.delete(refreshToken); throw new IllegalArgumentException("리프레시 토큰이 유효하지 않습니다."); } User user = refreshToken.getUser(); - // 4. 기존 리프레시 토큰 삭제 + // Flush deletion before insertion: JWT timestamps have second precision, so a fast + // refresh can produce the same token and otherwise collide with the unique index. refreshTokenRepository.delete(refreshToken); + refreshTokenRepository.flush(); // 5. 새 토큰 발급 String newAccessToken = jwtUtil.generateAccessToken(user.getId(), user.getEmail()); @@ -166,4 +168,4 @@ public void logout(String refreshTokenStr) { refreshTokenRepository.findByToken(refreshTokenStr) .ifPresent(refreshTokenRepository::delete); } -} \ No newline at end of file +} diff --git a/src/main/java/com/rallytrack/backend/domain/video/controller/VideoController.java b/src/main/java/com/rallytrack/backend/domain/video/controller/VideoController.java index 737fae0..f447cf3 100644 --- a/src/main/java/com/rallytrack/backend/domain/video/controller/VideoController.java +++ b/src/main/java/com/rallytrack/backend/domain/video/controller/VideoController.java @@ -65,9 +65,10 @@ public ResponseEntity> uploadVideo( @Operation(summary = "영상 상세 정보 조회", description = "영상 플레이어 정보 및 타임라인 이벤트를 조회합니다.") @GetMapping("/{videoId}") public ResponseEntity> getVideoDetail( + @RequestAttribute("userId") Long userId, @PathVariable("videoId") Long videoId) { - VideoDetailResponse response = videoService.getVideoDetail(videoId); + VideoDetailResponse response = videoService.getVideoDetail(userId, videoId); return ResponseEntity.ok(ApiResponse.success("성공", response)); } diff --git a/src/main/java/com/rallytrack/backend/domain/video/repository/VideoRepository.java b/src/main/java/com/rallytrack/backend/domain/video/repository/VideoRepository.java index e4f532c..22f8d6a 100644 --- a/src/main/java/com/rallytrack/backend/domain/video/repository/VideoRepository.java +++ b/src/main/java/com/rallytrack/backend/domain/video/repository/VideoRepository.java @@ -6,6 +6,9 @@ import java.util.List; public interface VideoRepository extends JpaRepository { + java.util.Optional