Skip to content

Commit ce3b101

Browse files
authored
Merge pull request #59 from Nexters/feat/12-user-mission-attempt-service
feat: 유저 도전기록 등록/조회 API
2 parents 8b18854 + 664344e commit ce3b101

File tree

12 files changed

+365
-41
lines changed

12 files changed

+365
-41
lines changed

src/main/java/com/climbup/climbup/attempt/controller/AttemptController.java

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,24 @@
22

33

44
import com.climbup.climbup.attempt.dto.request.CreateAttemptRequest;
5+
import com.climbup.climbup.attempt.dto.response.AttemptStatusResponse;
56
import com.climbup.climbup.attempt.dto.response.CreateAttemptResponse;
6-
import com.climbup.climbup.attempt.repository.UserMissionAttemptRepository;
7+
import com.climbup.climbup.attempt.dto.response.SessionAttemptResponse;
8+
import com.climbup.climbup.attempt.dto.response.UserMissionAttemptResponse;
79
import com.climbup.climbup.attempt.service.AttemptService;
810
import com.climbup.climbup.attempt.upload.dto.request.RouteMissionUploadChunkRequest;
911
import com.climbup.climbup.attempt.upload.dto.request.RouteMissionUploadSessionInitializeRequest;
1012
import com.climbup.climbup.attempt.upload.dto.response.RouteMissionUploadChunkResponse;
1113
import com.climbup.climbup.attempt.upload.dto.response.RouteMissionUploadSessionFinalizeResponse;
1214
import com.climbup.climbup.attempt.upload.dto.response.RouteMissionUploadSessionInitializeResponse;
1315
import com.climbup.climbup.attempt.upload.dto.response.RouteMissionUploadStatusResponse;
14-
import com.climbup.climbup.attempt.upload.entity.UploadSession;
15-
import com.climbup.climbup.attempt.upload.repository.UploadSessionRepository;
1616
import com.climbup.climbup.auth.util.SecurityUtil;
1717
import com.climbup.climbup.common.dto.ApiResult;
1818
import com.climbup.climbup.recommendation.dto.response.RouteMissionRecommendationResponse;
1919
import com.climbup.climbup.recommendation.service.RecommendationService;
2020
import io.swagger.v3.oas.annotations.Operation;
2121
import io.swagger.v3.oas.annotations.media.Content;
2222
import io.swagger.v3.oas.annotations.media.ExampleObject;
23-
import io.swagger.v3.oas.annotations.media.Schema;
2423
import io.swagger.v3.oas.annotations.responses.ApiResponse;
2524
import io.swagger.v3.oas.annotations.responses.ApiResponses;
2625
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
@@ -168,4 +167,41 @@ public ResponseEntity<ApiResult<RouteMissionUploadSessionFinalizeResponse>> fina
168167
RouteMissionUploadSessionFinalizeResponse response = attemptService.finalizeUploadSession(uploadId, thumbnailFile);
169168
return ResponseEntity.ok(ApiResult.success(response));
170169
}
170+
171+
@Operation(summary = "세션별 도전기록 조회",
172+
description = "특정 세션의 도전기록을 성공/실패로 구분하여 조회합니다.",
173+
security = @SecurityRequirement(name = "bearerAuth"))
174+
@ApiResponses({
175+
@ApiResponse(responseCode = "200", description = "세션 도전기록 조회 성공"),
176+
@ApiResponse(responseCode = "401", description = "인증되지 않은 사용자"),
177+
@ApiResponse(responseCode = "404", description = "세션을 찾을 수 없음")
178+
})
179+
@GetMapping("/sessions/{sessionId}")
180+
public ResponseEntity<ApiResult<SessionAttemptResponse>> getSessionAttempts(
181+
@PathVariable(name = "sessionId") Long sessionId
182+
) {
183+
Long userId = SecurityUtil.getCurrentUserId();
184+
SessionAttemptResponse response = attemptService.getSessionAttempts(userId, sessionId);
185+
186+
return ResponseEntity.ok(ApiResult.success(response));
187+
}
188+
189+
@Operation(summary = "특정 도전 기록의 상태 조회", description = "도전 기록의 현재 상태를 확인합니다.", security = @SecurityRequirement(name = "bearerAuth"))
190+
@ApiResponse(responseCode = "200", description = "도전 기록 상태 조회 성공")
191+
@GetMapping("/{attemptId}/status")
192+
public ResponseEntity<ApiResult<AttemptStatusResponse>> getAttemptStatus(
193+
@PathVariable(name = "attemptId") Long attemptId
194+
) {
195+
AttemptStatusResponse response = attemptService.getAttemptStatus(attemptId);
196+
return ResponseEntity.ok(ApiResult.success(response));
197+
}
198+
199+
@Operation(summary = "업로드 미완료 도전 기록 목록 조회", description = "사용자의 업로드가 완료되지 않은 도전 기록들을 조회합니다.", security = @SecurityRequirement(name = "bearerAuth"))
200+
@ApiResponse(responseCode = "200", description = "미완료 도전 기록 목록 조회 성공")
201+
@GetMapping("/incomplete")
202+
public ResponseEntity<ApiResult<List<UserMissionAttemptResponse>>> getIncompleteAttempts() {
203+
Long userId = SecurityUtil.getCurrentUserId();
204+
List<UserMissionAttemptResponse> response = attemptService.getIncompleteAttempts(userId);
205+
return ResponseEntity.ok(ApiResult.success(response));
206+
}
171207
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package com.climbup.climbup.attempt.dto.response;
2+
3+
import com.climbup.climbup.attempt.entity.UserMissionAttempt;
4+
import com.climbup.climbup.attempt.enums.AttemptStatus;
5+
import com.climbup.climbup.attempt.upload.entity.UploadSession;
6+
import io.swagger.v3.oas.annotations.media.Schema;
7+
import lombok.Builder;
8+
import lombok.Getter;
9+
10+
@Getter
11+
@Builder
12+
@Schema(description = "도전 기록 상태 응답")
13+
public class AttemptStatusResponse {
14+
15+
@Schema(description = "도전기록 ID", example = "1")
16+
private Long attemptId;
17+
18+
@Schema(description = "도전 상태", example = "COMPLETED")
19+
private AttemptStatus status;
20+
21+
@Schema(description = "업로드 진행률 (0-100)", example = "75")
22+
private Integer uploadProgress;
23+
24+
@Schema(description = "상태 메시지", example = "업로드가 완료되었습니다.")
25+
private String statusMessage;
26+
27+
public static AttemptStatusResponse from(UserMissionAttempt attempt) {
28+
return AttemptStatusResponse.builder()
29+
.attemptId(attempt.getId())
30+
.status(attempt.getStatus())
31+
.uploadProgress(calculateUploadProgress(attempt))
32+
.statusMessage(getStatusMessage(attempt.getStatus()))
33+
.build();
34+
}
35+
36+
private static Integer calculateUploadProgress(UserMissionAttempt attempt) {
37+
if (attempt.getUpload() == null) return 0;
38+
39+
UploadSession upload = attempt.getUpload();
40+
if (upload.getChunkLength() == 0) return 0;
41+
42+
return (int) ((upload.getReceivedChunkCount() * 100) / upload.getChunkLength());
43+
}
44+
45+
private static String getStatusMessage(AttemptStatus status) {
46+
return switch (status) {
47+
case PENDING_UPLOAD -> "영상 업로드를 기다리고 있습니다.";
48+
case UPLOADING -> "영상을 업로드 중입니다.";
49+
case COMPLETED -> "업로드가 완료되었습니다.";
50+
case UPLOAD_FAILED -> "업로드에 실패했습니다. 다시 시도해주세요.";
51+
};
52+
}
53+
}

src/main/java/com/climbup/climbup/attempt/dto/response/CreateAttemptResponse.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.climbup.climbup.attempt.dto.response;
22

3+
import com.climbup.climbup.attempt.enums.AttemptStatus;
34
import com.fasterxml.jackson.annotation.JsonFormat;
45
import io.swagger.v3.oas.annotations.media.Schema;
56
import lombok.Builder;
@@ -18,8 +19,8 @@ public class CreateAttemptResponse {
1819
@Schema(description = "성공 여부", example = "true")
1920
private Boolean success;
2021

21-
@Schema(description = "영상 URL", example = "https://example.com/video.mp4")
22-
private String videoUrl;
22+
@Schema(description = "도전 상태", example = "PENDING_UPLOAD")
23+
private AttemptStatus status;
2324

2425
@Schema(description = "생성 시간", example = "2025-07-31T14:20:00")
2526
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package com.climbup.climbup.attempt.dto.response;
2+
3+
import io.swagger.v3.oas.annotations.media.Schema;
4+
import lombok.Getter;
5+
6+
import java.time.LocalDateTime;
7+
8+
@Getter
9+
@Schema(description = "세션 도전기록 상세")
10+
public class SessionAttemptDetail {
11+
12+
@Schema(description = "도전기록 성공 여부", example = "true")
13+
private Boolean success;
14+
15+
@Schema(description = "난이도", example = "BLUE")
16+
private String difficulty;
17+
18+
@Schema(description = "난이도 이미지 URL", example = "https://example.com/BLUE.png")
19+
private String difficultyImageUrl;
20+
21+
@Schema(description = "루트미션 탈거일", example = "2025-08-15T10:00:00")
22+
private LocalDateTime removedAt;
23+
24+
@Schema(description = "가이드 썸네일 URL", example = "https://example.com/guide_thumb.jpg")
25+
private String guideThumbnailUrl;
26+
27+
@Schema(description = "가이드 비디오 URL", example = "https://example.com/guide_video.mp4")
28+
private String guideVideoUrl;
29+
30+
@Schema(description = "섹터 이름", example = "A섹터")
31+
private String sectorName;
32+
33+
@Schema(description = "루트미션 점수", example = "150")
34+
private Integer score;
35+
36+
@Schema(description = "도전기록 썸네일 URL", example = "https://example.com/attempt_thumb.jpg")
37+
private String attemptThumbnailUrl;
38+
39+
@Schema(description = "도전기록 비디오 URL", example = "https://example.com/attempt_video.mp4")
40+
private String attemptVideoUrl;
41+
42+
public SessionAttemptDetail(Boolean success, String difficulty, String difficultyImageUrl,
43+
LocalDateTime removedAt, String guideThumbnailUrl, String guideVideoUrl,
44+
String sectorName, Integer score, String attemptThumbnailUrl, String attemptVideoUrl) {
45+
this.success = success;
46+
this.difficulty = difficulty;
47+
this.difficultyImageUrl = difficultyImageUrl;
48+
this.removedAt = removedAt;
49+
this.guideThumbnailUrl = guideThumbnailUrl;
50+
this.guideVideoUrl = guideVideoUrl;
51+
this.sectorName = sectorName;
52+
this.score = score;
53+
this.attemptThumbnailUrl = attemptThumbnailUrl;
54+
this.attemptVideoUrl = attemptVideoUrl;
55+
}
56+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package com.climbup.climbup.attempt.dto.response;
2+
3+
import io.swagger.v3.oas.annotations.media.Schema;
4+
import lombok.Builder;
5+
import lombok.Getter;
6+
7+
import java.util.List;
8+
9+
@Getter
10+
@Builder
11+
@Schema(description = "세션 도전기록 응답")
12+
public class SessionAttemptResponse {
13+
14+
@Schema(description = "성공한 도전기록 목록")
15+
private List<SessionAttemptDetail> successfulAttempts;
16+
17+
@Schema(description = "실패한 도전기록 목록")
18+
private List<SessionAttemptDetail> failedAttempts;
19+
}

src/main/java/com/climbup/climbup/attempt/dto/response/UserMissionAttemptResponse.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.climbup.climbup.attempt.dto.response;
22

33
import com.climbup.climbup.attempt.entity.UserMissionAttempt;
4+
import com.climbup.climbup.attempt.enums.AttemptStatus;
45
import io.swagger.v3.oas.annotations.media.Schema;
56
import lombok.Builder;
67
import lombok.Getter;
@@ -18,17 +19,25 @@ public class UserMissionAttemptResponse {
1819
@Schema(description = "성공 여부", example = "true")
1920
private Boolean success;
2021

22+
@Schema(description = "도전 상태", example = "COMPLETED")
23+
private AttemptStatus status;
24+
2125
@Schema(description = "시도 영상 URL", example = "https://example.com/attempt1.mp4")
2226
private String videoUrl;
2327

28+
@Schema(description = "썸네일 URL", example = "https://example.com/thumbnail1.jpg")
29+
private String thumbnailUrl;
30+
2431
@Schema(description = "시도 생성 시간", example = "2025-07-31T14:20:00")
2532
private LocalDateTime createdAt;
2633

2734
public static UserMissionAttemptResponse toDto(UserMissionAttempt attempt) {
2835
return UserMissionAttemptResponse.builder()
2936
.missionAttemptId(attempt.getId())
3037
.success(attempt.getSuccess())
38+
.status(attempt.getStatus())
3139
.videoUrl(attempt.getVideoUrl())
40+
.thumbnailUrl(attempt.getThumbnailUrl())
3241
.createdAt(attempt.getCreatedAt())
3342
.build();
3443
}

src/main/java/com/climbup/climbup/attempt/entity/UserMissionAttempt.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.climbup.climbup.attempt.entity;
22

3+
import com.climbup.climbup.attempt.enums.AttemptStatus;
34
import com.climbup.climbup.attempt.upload.entity.UploadSession;
45
import com.climbup.climbup.common.entity.BaseEntity;
56
import com.climbup.climbup.route.entity.RouteMission;
@@ -49,4 +50,16 @@ public class UserMissionAttempt extends BaseEntity {
4950

5051
@Column(name = "video_url", columnDefinition = "TEXT")
5152
private String videoUrl;
53+
54+
public AttemptStatus getStatus() {
55+
if (upload == null) {
56+
return AttemptStatus.PENDING_UPLOAD;
57+
}
58+
59+
return switch (upload.getStatus()) {
60+
case NOT_STARTED, IN_PROGRESS -> AttemptStatus.UPLOADING;
61+
case FINISHED -> AttemptStatus.COMPLETED;
62+
case FAILED -> AttemptStatus.UPLOAD_FAILED;
63+
};
64+
}
5265
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package com.climbup.climbup.attempt.enums;
2+
3+
public enum AttemptStatus {
4+
PENDING_UPLOAD, // 영상 업로드 대기중
5+
UPLOADING, // 업로드 진행중
6+
COMPLETED, // 완료
7+
UPLOAD_FAILED // 업로드 실패
8+
}

src/main/java/com/climbup/climbup/attempt/repository/UserMissionAttemptRepository.java

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package com.climbup.climbup.attempt.repository;
22

3+
import com.climbup.climbup.attempt.dto.response.SessionAttemptDetail;
34
import com.climbup.climbup.attempt.entity.UserMissionAttempt;
5+
import com.climbup.climbup.attempt.upload.enums.UploadStatus;
46
import org.springframework.data.jpa.repository.JpaRepository;
57
import org.springframework.data.jpa.repository.Query;
68
import org.springframework.data.repository.query.Param;
@@ -16,4 +18,39 @@ public interface UserMissionAttemptRepository extends JpaRepository<UserMissionA
1618

1719
@Query("SELECT uma FROM UserMissionAttempt uma WHERE uma.upload.id = :uploadId")
1820
Optional<UserMissionAttempt> findByUploadId(@Param("uploadId") UUID uploadId);
21+
22+
@Query("SELECT uma FROM UserMissionAttempt uma " +
23+
"LEFT JOIN uma.upload us " +
24+
"WHERE uma.user.id = :userId " +
25+
"AND (uma.upload IS NULL OR us.status IN (:notStarted, :inProgress, :failed)) " +
26+
"ORDER BY uma.createdAt DESC")
27+
List<UserMissionAttempt> findIncompleteAttemptsByUserId(
28+
@Param("userId") Long userId,
29+
@Param("notStarted") UploadStatus notStarted,
30+
@Param("inProgress") UploadStatus inProgress,
31+
@Param("failed") UploadStatus failed);
32+
33+
@Query("SELECT new com.climbup.climbup.attempt.dto.response.SessionAttemptDetail(" +
34+
"uma.success, " +
35+
"m.difficulty, " +
36+
"gl.imageUrl, " +
37+
"m.removedAt, " +
38+
"m.thumbnailUrl, " +
39+
"m.videoUrl, " +
40+
"s.name, " +
41+
"m.score, " +
42+
"uma.thumbnailUrl, " +
43+
"uma.videoUrl) " +
44+
"FROM UserMissionAttempt uma " +
45+
"JOIN uma.mission m " +
46+
"JOIN m.sector s " +
47+
"JOIN m.gym g " +
48+
"JOIN g.brand b " +
49+
"JOIN GymLevel gl ON gl.brand = b AND gl.name = m.difficulty " +
50+
"WHERE uma.user.id = :userId " +
51+
"AND uma.session.id = :sessionId " +
52+
"ORDER BY uma.createdAt ASC")
53+
List<SessionAttemptDetail> findSessionAttemptsWithGymLevel(
54+
@Param("userId") Long userId,
55+
@Param("sessionId") Long sessionId);
1956
}

src/main/java/com/climbup/climbup/attempt/service/AttemptService.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
11
package com.climbup.climbup.attempt.service;
22

33
import com.climbup.climbup.attempt.dto.request.CreateAttemptRequest;
4+
import com.climbup.climbup.attempt.dto.response.AttemptStatusResponse;
45
import com.climbup.climbup.attempt.dto.response.CreateAttemptResponse;
6+
import com.climbup.climbup.attempt.dto.response.SessionAttemptResponse;
7+
import com.climbup.climbup.attempt.dto.response.UserMissionAttemptResponse;
58
import com.climbup.climbup.attempt.upload.dto.request.RouteMissionUploadChunkRequest;
69
import com.climbup.climbup.attempt.upload.dto.request.RouteMissionUploadSessionInitializeRequest;
710
import com.climbup.climbup.attempt.upload.dto.response.RouteMissionUploadChunkResponse;
811
import com.climbup.climbup.attempt.upload.dto.response.RouteMissionUploadSessionFinalizeResponse;
912
import com.climbup.climbup.attempt.upload.dto.response.RouteMissionUploadSessionInitializeResponse;
1013
import com.climbup.climbup.attempt.upload.dto.response.RouteMissionUploadStatusResponse;
14+
import com.climbup.climbup.attempt.upload.enums.UploadStatus;
1115
import org.springframework.web.multipart.MultipartFile;
1216

17+
import java.util.List;
1318
import java.util.UUID;
1419

1520
public interface AttemptService {
@@ -19,7 +24,15 @@ public interface AttemptService {
1924

2025
RouteMissionUploadSessionInitializeResponse initializeAttemptUploadSession(Long attemptId, RouteMissionUploadSessionInitializeRequest request);
2126

27+
void updateUploadSessionStatus(UUID uploadId, UploadStatus status);
28+
2229
RouteMissionUploadChunkResponse uploadChunk(UUID uploadId, RouteMissionUploadChunkRequest request);
2330

2431
RouteMissionUploadSessionFinalizeResponse finalizeUploadSession(UUID uploadId, MultipartFile thumbnailFile);
32+
33+
SessionAttemptResponse getSessionAttempts(Long userId, Long sessionId);
34+
35+
AttemptStatusResponse getAttemptStatus(Long attemptId);
36+
37+
List<UserMissionAttemptResponse> getIncompleteAttempts(Long userId);
2538
}

0 commit comments

Comments
 (0)