Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@

.git
.gradle
build
.env
.env.*
src/main/resources/application.yml
src/main/resources/application-secret.yml
3 changes: 3 additions & 0 deletions .github/workflows/ci-cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -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 파일을 가져옴
Expand Down
12 changes: 8 additions & 4 deletions src/main/java/com/rallytrack/backend/config/JwtAuthFilter.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

// 인증 실패
Expand Down
19 changes: 16 additions & 3 deletions src/main/java/com/rallytrack/backend/config/JwtUtil.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
18 changes: 5 additions & 13 deletions src/main/java/com/rallytrack/backend/config/S3Service.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand All @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,9 @@ public class AnalysisController {
@Operation(summary = "분석 리포트 조회", description = "영상 분석 리포트를 조회합니다.")
@GetMapping("/{videoId}")
public ResponseEntity<ApiResponse<AnalysisReportResponse>> 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));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<AnalysisReportResponse.HitDto> hitDtos = result.getHits().stream()
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Response> 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())));
}
}
Original file line number Diff line number Diff line change
@@ -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<String, Integer> 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<String, Integer>();
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;
}
}
Original file line number Diff line number Diff line change
@@ -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<String, Cached> cache = new LinkedHashMap<>();
private final Map<String, CompletableFuture<String>> inFlight = new HashMap<>();
private final Map<Long, Usage> 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<String> 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); }
}
}
Loading