Skip to content

Commit 03625f8

Browse files
authored
Merge pull request #53 from Nexters/feat/42-chunk-upload
feat: 영상 청크 업로드 기능 구현
2 parents af613a4 + 08e6838 commit 03625f8

22 files changed

+433
-12
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,5 @@ out/
4141
climbup-db-data
4242
climbup-redis-data
4343
.claude
44+
45+
.serena/

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

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,16 @@
33

44
import com.climbup.climbup.attempt.dto.request.CreateAttemptRequest;
55
import com.climbup.climbup.attempt.dto.response.CreateAttemptResponse;
6+
import com.climbup.climbup.attempt.repository.UserMissionAttemptRepository;
67
import com.climbup.climbup.attempt.service.AttemptService;
78
import com.climbup.climbup.attempt.upload.dto.request.RouteMissionUploadChunkRequest;
89
import com.climbup.climbup.attempt.upload.dto.request.RouteMissionUploadSessionInitializeRequest;
910
import com.climbup.climbup.attempt.upload.dto.response.RouteMissionUploadChunkResponse;
1011
import com.climbup.climbup.attempt.upload.dto.response.RouteMissionUploadSessionFinalizeResponse;
1112
import com.climbup.climbup.attempt.upload.dto.response.RouteMissionUploadSessionInitializeResponse;
1213
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;
1316
import com.climbup.climbup.auth.util.SecurityUtil;
1417
import com.climbup.climbup.common.dto.ApiResult;
1518
import com.climbup.climbup.recommendation.dto.response.RouteMissionRecommendationResponse;
@@ -39,6 +42,7 @@ public class AttemptController {
3942

4043
private final AttemptService attemptService;
4144
private final RecommendationService recommendationService;
45+
private final UploadSessionRepository uploadSessionRepository;
4246

4347

4448
@Operation(summary = "도전한 루트미션과 비슷한 난이도의 루트미션 리스트 불러오기", description = "도전한 루트미션과 비슷한 난이도의 루트미션 리스트를 받아보기", security = @SecurityRequirement(name = "bearerAuth"))
@@ -120,7 +124,8 @@ public ResponseEntity<ApiResult<CreateAttemptResponse>> createAttempt(
120124
public ResponseEntity<ApiResult<RouteMissionUploadStatusResponse>> getRouteMissionUploadStatus(
121125
@PathVariable(name = "attemptId") Long attemptId
122126
) {
123-
return ResponseEntity.ok(ApiResult.success(RouteMissionUploadStatusResponse.builder().build()));
127+
RouteMissionUploadStatusResponse response = attemptService.getAttemptUploadStatus(attemptId);
128+
return ResponseEntity.ok(ApiResult.success(response));
124129
}
125130

126131

@@ -131,7 +136,9 @@ public ResponseEntity<ApiResult<RouteMissionUploadSessionInitializeResponse>> in
131136
@PathVariable(name = "attemptId") Long attemptId,
132137
@Valid @RequestBody RouteMissionUploadSessionInitializeRequest request
133138
) {
134-
return ResponseEntity.ok(ApiResult.success(RouteMissionUploadSessionInitializeResponse.builder().build()));
139+
RouteMissionUploadSessionInitializeResponse response = attemptService.initializeAttemptUploadSession(attemptId, request);
140+
141+
return ResponseEntity.ok(ApiResult.success(response));
135142
}
136143

137144

@@ -143,7 +150,10 @@ public ResponseEntity<ApiResult<RouteMissionUploadChunkResponse>> uploadRouteMis
143150
@PathVariable(name = "uploadId") UUID uploadId,
144151
@Valid @RequestBody RouteMissionUploadChunkRequest request
145152
) {
146-
return ResponseEntity.ok(ApiResult.success(RouteMissionUploadChunkResponse.builder().build()));
153+
154+
RouteMissionUploadChunkResponse response = attemptService.uploadChunk(uploadId, request);
155+
156+
return ResponseEntity.ok(ApiResult.success(response));
147157
}
148158

149159

@@ -154,6 +164,7 @@ public ResponseEntity<ApiResult<RouteMissionUploadSessionFinalizeResponse>> fina
154164
@PathVariable(name = "attemptId") Long attemptId,
155165
@PathVariable(name = "uploadId") UUID uploadId
156166
) {
157-
return ResponseEntity.ok(ApiResult.success(RouteMissionUploadSessionFinalizeResponse.builder().build()));
167+
RouteMissionUploadSessionFinalizeResponse response = attemptService.finalizeUploadSession(uploadId);
168+
return ResponseEntity.ok(ApiResult.success(response));
158169
}
159170
}

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

Lines changed: 5 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.upload.entity.UploadSession;
34
import com.climbup.climbup.common.entity.BaseEntity;
45
import com.climbup.climbup.route.entity.RouteMission;
56
import com.climbup.climbup.session.entity.UserSession;
@@ -36,6 +37,10 @@ public class UserMissionAttempt extends BaseEntity {
3637
@JoinColumn(name = "mission_id", nullable = false)
3738
private RouteMission mission;
3839

40+
@OneToOne(cascade = CascadeType.ALL)
41+
@JoinColumn(name = "upload_id")
42+
private UploadSession upload;
43+
3944
@Column(name = "success", nullable = false)
4045
private Boolean success;
4146

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package com.climbup.climbup.attempt.exception;
2+
3+
import com.climbup.climbup.common.exception.BusinessException;
4+
import com.climbup.climbup.common.exception.ErrorCode;
5+
6+
public class UploadSessionAlreadyExistsException extends BusinessException {
7+
public UploadSessionAlreadyExistsException() {
8+
super(ErrorCode.UPLOAD_SESSION_ALREADY_EXISTS);
9+
}
10+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package com.climbup.climbup.attempt.exception;
2+
3+
import com.climbup.climbup.common.exception.BusinessException;
4+
import com.climbup.climbup.common.exception.ErrorCode;
5+
6+
public class UploadSessionChunkAlreadyExistsException extends BusinessException {
7+
public UploadSessionChunkAlreadyExistsException() {
8+
super(ErrorCode.UPLOAD_SESSION_CHUNK_ALREADY_EXISTS);
9+
}
10+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package com.climbup.climbup.attempt.exception;
2+
3+
import com.climbup.climbup.common.exception.BusinessException;
4+
import com.climbup.climbup.common.exception.ErrorCode;
5+
6+
public class UploadSessionChunkIncompleteException extends BusinessException {
7+
public UploadSessionChunkIncompleteException() {
8+
super(ErrorCode.UPLOAD_SESSION_CHUNK_INCOMPLETE);
9+
}
10+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package com.climbup.climbup.attempt.exception;
2+
3+
import com.climbup.climbup.common.exception.BusinessException;
4+
import com.climbup.climbup.common.exception.ErrorCode;
5+
6+
public class UploadSessionNotFoundException extends BusinessException {
7+
public UploadSessionNotFoundException() {
8+
super(ErrorCode.UPLOAD_SESSION_NOT_FOUND);
9+
}
10+
}

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,14 @@
66
import org.springframework.data.repository.query.Param;
77

88
import java.util.List;
9+
import java.util.Optional;
10+
import java.util.UUID;
911

1012
public interface UserMissionAttemptRepository extends JpaRepository<UserMissionAttempt, Long> {
1113

1214
@Query("SELECT uma FROM UserMissionAttempt uma WHERE uma.user.id = :userId AND uma.session.id = :sessionId")
1315
List<UserMissionAttempt> findByUserIdAndSessionId(@Param("userId") Long userId, @Param("sessionId") Long sessionId);
16+
17+
@Query("SELECT uma FROM UserMissionAttempt uma WHERE uma.upload.id = :uploadId")
18+
Optional<UserMissionAttempt> findByUploadId(@Param("uploadId") UUID uploadId);
1419
}

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

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,23 @@
22

33
import com.climbup.climbup.attempt.dto.request.CreateAttemptRequest;
44
import com.climbup.climbup.attempt.dto.response.CreateAttemptResponse;
5+
import com.climbup.climbup.attempt.upload.dto.request.RouteMissionUploadChunkRequest;
6+
import com.climbup.climbup.attempt.upload.dto.request.RouteMissionUploadSessionInitializeRequest;
7+
import com.climbup.climbup.attempt.upload.dto.response.RouteMissionUploadChunkResponse;
8+
import com.climbup.climbup.attempt.upload.dto.response.RouteMissionUploadSessionFinalizeResponse;
9+
import com.climbup.climbup.attempt.upload.dto.response.RouteMissionUploadSessionInitializeResponse;
10+
import com.climbup.climbup.attempt.upload.dto.response.RouteMissionUploadStatusResponse;
511

6-
public interface AttemptService {
12+
import java.util.UUID;
713

14+
public interface AttemptService {
815
CreateAttemptResponse createAttempt(Long userId, CreateAttemptRequest request);
16+
17+
RouteMissionUploadStatusResponse getAttemptUploadStatus(Long attemptId);
18+
19+
RouteMissionUploadSessionInitializeResponse initializeAttemptUploadSession(Long attemptId, RouteMissionUploadSessionInitializeRequest request);
20+
21+
RouteMissionUploadChunkResponse uploadChunk(UUID uploadId, RouteMissionUploadChunkRequest request);
22+
23+
RouteMissionUploadSessionFinalizeResponse finalizeUploadSession(UUID uploadId);
924
}

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

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,20 @@
44
import com.climbup.climbup.attempt.dto.request.CreateAttemptRequest;
55
import com.climbup.climbup.attempt.dto.response.CreateAttemptResponse;
66
import com.climbup.climbup.attempt.entity.UserMissionAttempt;
7+
import com.climbup.climbup.attempt.exception.*;
78
import com.climbup.climbup.attempt.repository.UserMissionAttemptRepository;
9+
import com.climbup.climbup.attempt.upload.dto.request.RouteMissionUploadChunkRequest;
10+
import com.climbup.climbup.attempt.upload.dto.request.RouteMissionUploadSessionInitializeRequest;
11+
import com.climbup.climbup.attempt.upload.dto.response.RouteMissionUploadChunkResponse;
12+
import com.climbup.climbup.attempt.upload.dto.response.RouteMissionUploadSessionFinalizeResponse;
13+
import com.climbup.climbup.attempt.upload.dto.response.RouteMissionUploadSessionInitializeResponse;
14+
import com.climbup.climbup.attempt.upload.dto.response.RouteMissionUploadStatusResponse;
15+
import com.climbup.climbup.attempt.upload.entity.UploadSession;
16+
import com.climbup.climbup.attempt.upload.entity.Chunk;
17+
import com.climbup.climbup.attempt.upload.enums.UploadStatus;
18+
import com.climbup.climbup.attempt.upload.repository.UploadSessionRepository;
19+
import com.climbup.climbup.attempt.upload.repository.ChunkRepository;
20+
import com.climbup.climbup.common.exception.CommonBusinessException;
821
import com.climbup.climbup.common.exception.ErrorCode;
922
import com.climbup.climbup.common.exception.ValidationException;
1023
import com.climbup.climbup.route.entity.RouteMission;
@@ -23,6 +36,12 @@
2336
import org.springframework.stereotype.Service;
2437
import org.springframework.transaction.annotation.Transactional;
2538

39+
import java.util.Comparator;
40+
import java.util.List;
41+
import java.util.UUID;
42+
import java.io.*;
43+
import java.nio.file.*;
44+
2645
@Service
2746
@RequiredArgsConstructor
2847
@Slf4j
@@ -34,6 +53,61 @@ public class AttemptServiceImpl implements AttemptService {
3453
private final UserRepository userRepository;
3554
private final UserSessionRepository sessionRepository;
3655
private final SRHistoryRepository srHistoryRepository;
56+
private final UploadSessionRepository uploadSessionRepository;
57+
private final ChunkRepository chunkRepository;
58+
59+
60+
private Path getUploadSessionDirectory(UUID uploadId) {
61+
return Paths.get("uploads", uploadId.toString(), "chunks");
62+
}
63+
64+
private void ensureDirectoryExists(Path uploadDirectory) {
65+
try {
66+
Files.createDirectories(uploadDirectory);
67+
} catch (IOException e) {
68+
throw new CommonBusinessException(ErrorCode.INTERNAL_SERVER_ERROR);
69+
}
70+
}
71+
72+
private Path getChunkFilePath(UUID uploadId, int chunkIndex) {
73+
return Paths.get("uploads", uploadId.toString(), "chunks", String.valueOf(chunkIndex));
74+
}
75+
76+
private String combineChunks(UploadSession uploadSession)
77+
{
78+
String fileName = uploadSession.getFileName() + "." + uploadSession.getFileType();
79+
Path finalFilePath = Paths.get("uploads", uploadSession.getId().toString(), fileName);
80+
ensureDirectoryExists(finalFilePath.getParent());
81+
82+
List<Chunk> sortedChunks = uploadSession.getChunks().stream()
83+
.sorted(Comparator.comparing(Chunk::getChunkIndex))
84+
.toList();
85+
86+
try (FileOutputStream fos = new FileOutputStream(finalFilePath.toFile());
87+
BufferedOutputStream bos = new BufferedOutputStream(fos)) {
88+
89+
for (Chunk chunk : sortedChunks) {
90+
Path chunkPath = Paths.get(chunk.getFilePath());
91+
byte[] chunkData = Files.readAllBytes(chunkPath);
92+
bos.write(chunkData);
93+
}
94+
bos.flush();
95+
} catch (IOException e) {
96+
log.error(e.toString());
97+
throw new CommonBusinessException(ErrorCode.INTERNAL_SERVER_ERROR);
98+
}
99+
100+
for (Chunk chunk : sortedChunks) {
101+
try {
102+
Files.deleteIfExists(Paths.get(chunk.getFilePath()));
103+
} catch (IOException e) {
104+
log.warn("Failed to delete chunk file: {}", chunk.getFilePath(), e);
105+
}
106+
}
107+
108+
return finalFilePath.toString();
109+
}
110+
37111

38112
@Transactional
39113
public CreateAttemptResponse createAttempt(Long userId, CreateAttemptRequest request) {
@@ -98,4 +172,119 @@ public CreateAttemptResponse createAttempt(Long userId, CreateAttemptRequest req
98172
.currentSr(currentSr)
99173
.build();
100174
}
175+
176+
@Override
177+
@Transactional(readOnly = true)
178+
public RouteMissionUploadStatusResponse getAttemptUploadStatus(Long attemptId) {
179+
UserMissionAttempt attempt = attemptRepository.findById(attemptId).orElseThrow(AttemptNotFoundException::new);
180+
181+
var uploadSession = attempt.getUpload();
182+
183+
if(uploadSession == null) {
184+
throw new UploadSessionNotFoundException();
185+
}
186+
187+
return RouteMissionUploadStatusResponse.toDto(uploadSession);
188+
}
189+
190+
@Override
191+
@Transactional
192+
public RouteMissionUploadSessionInitializeResponse initializeAttemptUploadSession(Long attemptId, RouteMissionUploadSessionInitializeRequest request) {
193+
UserMissionAttempt attempt = attemptRepository.findById(attemptId).orElseThrow(AttemptNotFoundException::new);
194+
195+
if(attempt.getUpload() != null) {
196+
throw new UploadSessionAlreadyExistsException();
197+
}
198+
199+
UploadSession uploadSession = UploadSession.builder().status(UploadStatus.NOT_STARTED).chunkLength(request.getChunkLength()).chunkSize(request.getChunkSize()).fileSize(request.getFileSize()).fileName(request.getFileName()).fileType(request.getFileType()).build();
200+
201+
uploadSession = uploadSessionRepository.save(uploadSession);
202+
203+
attempt.setUpload(uploadSession);
204+
205+
attemptRepository.save(attempt);
206+
207+
return RouteMissionUploadSessionInitializeResponse.builder().uploadId(uploadSession.getId()).build();
208+
}
209+
210+
@Override
211+
@Transactional
212+
public RouteMissionUploadChunkResponse uploadChunk(UUID uploadId, RouteMissionUploadChunkRequest request) {
213+
214+
UploadSession uploadSession = uploadSessionRepository.findById(uploadId).orElseThrow(UploadSessionNotFoundException::new);
215+
216+
if(uploadSession.hasChunk(request.getIndex())) {
217+
throw new UploadSessionChunkAlreadyExistsException();
218+
}
219+
220+
try {
221+
Path sessionDir = getUploadSessionDirectory(uploadId);
222+
ensureDirectoryExists(sessionDir);
223+
224+
Path chunkFilePath = getChunkFilePath(uploadId, request.getIndex());
225+
Files.write(chunkFilePath, request.getChunk());
226+
227+
Chunk chunk = new Chunk();
228+
chunk.setChunkIndex(request.getIndex());
229+
chunk.setChunkSize((long) request.getChunk().length);
230+
chunk.setCompleted(true);
231+
chunk.setFilePath(chunkFilePath.toString());
232+
233+
uploadSession.addChunk(chunk);
234+
235+
String chunkName = String.format("chunk_%d_%s", request.getIndex(), uploadSession.getFileName());
236+
chunk.setName(chunkName);
237+
238+
chunkRepository.save(chunk);
239+
240+
241+
} catch (IOException e) {
242+
log.error("Failed to store chunk {} for upload session {}", request.getIndex(), uploadId, e);
243+
throw new UploadSessionChunkIncompleteException();
244+
}
245+
246+
if (uploadSession.getStatus() == UploadStatus.NOT_STARTED) {
247+
uploadSession.setStatus(UploadStatus.IN_PROGRESS);
248+
uploadSessionRepository.save(uploadSession);
249+
}
250+
251+
252+
return RouteMissionUploadChunkResponse.builder()
253+
.index(request.getIndex())
254+
.totalChunkReceived(uploadSession.getReceivedChunkCount().intValue())
255+
.totalChunkExpected(uploadSession.getChunkLength())
256+
.build();
257+
}
258+
259+
@Override
260+
@Transactional
261+
public RouteMissionUploadSessionFinalizeResponse finalizeUploadSession(UUID uploadId) {
262+
UploadSession uploadSession = uploadSessionRepository.findById(uploadId).orElseThrow(UploadSessionNotFoundException::new);
263+
264+
long receivedChunks = uploadSession.getReceivedChunkCount();
265+
int expectedChunks = uploadSession.getChunkLength();
266+
267+
if (receivedChunks != expectedChunks) {
268+
throw new UploadSessionChunkIncompleteException();
269+
}
270+
271+
String finalVideoPath = combineChunks(uploadSession);
272+
273+
uploadSession.setStatus(UploadStatus.FINISHED);
274+
uploadSessionRepository.save(uploadSession);
275+
276+
log.info("video saved in {}", finalVideoPath);
277+
278+
// upload the video to NCP storage
279+
280+
UserMissionAttempt attempt = attemptRepository.findByUploadId(uploadId)
281+
.orElseThrow(AttemptNotFoundException::new);
282+
283+
attemptRepository.save(attempt);
284+
285+
return RouteMissionUploadSessionFinalizeResponse.builder()
286+
.fileName(uploadSession.getFileName())
287+
.build();
288+
}
289+
101290
}

0 commit comments

Comments
 (0)