From a030837d455284aa5d338deac9b4094a9b552746 Mon Sep 17 00:00:00 2001 From: vladp Date: Wed, 17 Dec 2025 22:31:30 +0700 Subject: [PATCH] update controller chunk endpoints --- .../nocopy/controller/ApiController.java | 159 ++++++++++++ .../controller/file/FileUploadController.java | 159 ------------ .../ru/soune/nocopy/dto/AuthResponse.java | 0 .../ru/soune/nocopy/dto/LoginResponse.java | 0 .../java/ru/soune/nocopy/dto/MessageCode.java | 5 + .../ru/soune/nocopy/dto/RegisterRequest.java | 0 .../soune/nocopy/dto/file/ActionResponse.java | 17 ++ .../dto/file/CanceledUploadResponse.java | 15 ++ .../nocopy/dto/file/ChunkStatusResponse.java | 28 ++ .../nocopy/dto/file/ChunkUploadResponse.java | 25 ++ .../dto/file/CompleteUploadResponse.java | 31 +++ .../nocopy/dto/file/FileTypesResponse.java | 19 ++ .../nocopy/dto/file/FileUploadRequest.java | 31 +++ .../nocopy/dto/file/InitFileResponse.java | 30 +++ .../nocopy/dto/file/RetryUploadResponse.java | 18 ++ .../soune/nocopy/dto/file/UploadProgress.java | 40 +++ .../nocopy/entity/file/FileUploadSession.java | 12 +- .../exception/UserAlreadyExistsException.java | 0 .../nocopy/handler/FileUploadHandler.java | 243 ++++++++++++++++++ .../validator/FileUploadRequestValidator.java | 66 +++++ .../service/file/FileUploadServiceImpl.java | 5 +- 21 files changed, 740 insertions(+), 163 deletions(-) delete mode 100644 src/main/java/ru/soune/nocopy/dto/AuthResponse.java delete mode 100644 src/main/java/ru/soune/nocopy/dto/LoginResponse.java delete mode 100644 src/main/java/ru/soune/nocopy/dto/RegisterRequest.java create mode 100644 src/main/java/ru/soune/nocopy/dto/file/ActionResponse.java create mode 100644 src/main/java/ru/soune/nocopy/dto/file/CanceledUploadResponse.java create mode 100644 src/main/java/ru/soune/nocopy/dto/file/ChunkStatusResponse.java create mode 100644 src/main/java/ru/soune/nocopy/dto/file/ChunkUploadResponse.java create mode 100644 src/main/java/ru/soune/nocopy/dto/file/CompleteUploadResponse.java create mode 100644 src/main/java/ru/soune/nocopy/dto/file/FileTypesResponse.java create mode 100644 src/main/java/ru/soune/nocopy/dto/file/FileUploadRequest.java create mode 100644 src/main/java/ru/soune/nocopy/dto/file/InitFileResponse.java create mode 100644 src/main/java/ru/soune/nocopy/dto/file/RetryUploadResponse.java create mode 100644 src/main/java/ru/soune/nocopy/dto/file/UploadProgress.java delete mode 100644 src/main/java/ru/soune/nocopy/exception/UserAlreadyExistsException.java create mode 100644 src/main/java/ru/soune/nocopy/handler/FileUploadHandler.java create mode 100644 src/main/java/ru/soune/nocopy/handler/validator/FileUploadRequestValidator.java diff --git a/src/main/java/ru/soune/nocopy/controller/ApiController.java b/src/main/java/ru/soune/nocopy/controller/ApiController.java index 460ad8b..04e2d29 100644 --- a/src/main/java/ru/soune/nocopy/controller/ApiController.java +++ b/src/main/java/ru/soune/nocopy/controller/ApiController.java @@ -8,15 +8,22 @@ import org.springframework.http.ResponseEntity; import org.springframework.validation.BindingResult; import org.springframework.validation.FieldError; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; import ru.soune.nocopy.dto.BaseRequest; import ru.soune.nocopy.dto.BaseResponse; import ru.soune.nocopy.dto.MessageCode; import ru.soune.nocopy.dto.RegAnswer; +import ru.soune.nocopy.dto.file.ChunkUploadResponse; +import ru.soune.nocopy.dto.file.CompleteUploadResponse; +import ru.soune.nocopy.dto.file.UploadProgress; +import ru.soune.nocopy.entity.file.UploadStatus; import ru.soune.nocopy.exception.NotValidFieldException; import ru.soune.nocopy.exception.ValidationException; +import ru.soune.nocopy.handler.FileUploadHandler; import ru.soune.nocopy.handler.LoginRequestHandler; import ru.soune.nocopy.handler.RegRequestHandler; import ru.soune.nocopy.handler.RequestHandler; +import ru.soune.nocopy.service.file.FileUploadService; import java.util.HashMap; import java.util.List; @@ -29,6 +36,7 @@ import java.util.stream.Collectors; @RequiredArgsConstructor public class ApiController { private final ApplicationContext applicationContext; + private final FileUploadService fileUploadService; private Map handlers = new HashMap<>(); @@ -36,7 +44,9 @@ public class ApiController { public void init() { RegRequestHandler regHandler = applicationContext.getBean(RegRequestHandler.class); LoginRequestHandler loginHandler = applicationContext.getBean(LoginRequestHandler.class); + FileUploadHandler fileUploadHandler = applicationContext.getBean(FileUploadHandler.class); + handlers.put(20004, fileUploadHandler); handlers.put(20002, regHandler); handlers.put(20001, loginHandler); } @@ -76,6 +86,155 @@ public class ApiController { } } + @PostMapping("/v{version}/files/chunk") + public ResponseEntity uploadChunk( + @PathVariable("version") int version, + @RequestParam("upload_id") String uploadId, + @RequestParam("chunk_number") Integer chunkNumber, + @RequestParam("chunk") MultipartFile chunk) { + + log.info("Uploading chunk {} for session {}, file size: {} bytes, version: {}", + chunkNumber, uploadId, chunk.getSize(), version); + + if (chunk.isEmpty()) { + return ResponseEntity.ok().body(new BaseResponse(20004, MessageCode.FILE_UPLOAD_ERROR.getCode(), + "Chunk file is empty", null)); + } + + if (uploadId == null || uploadId.isBlank()) { + return ResponseEntity.ok().body(new BaseResponse(20004, MessageCode.FILE_UPLOAD_ERROR.getCode(), + "Upload ID is required", null)); + } + + if (chunkNumber == null || chunkNumber < 0) { + return ResponseEntity.ok().body(new BaseResponse(20004, MessageCode.FILE_UPLOAD_ERROR.getCode(), + "Valid chunk number is required", null)); + } + + try { + fileUploadService.uploadChunk(uploadId, chunkNumber, chunk); + + ChunkUploadResponse responseBody = ChunkUploadResponse.builder() + .uploadId(uploadId) + .chunkNumber(chunkNumber) + .chunkSize(chunk.getSize()) + .message("Chunk uploaded successfully") + .build(); + + return ResponseEntity.ok().body(new BaseResponse(20004, MessageCode.SUCCESS.getCode(), + "Chunk uploaded successfully", responseBody)); + + } catch (Exception e) { + log.error("Error uploading chunk", e); + + ChunkUploadResponse responseBody = ChunkUploadResponse.builder() + .uploadId(uploadId) + .chunkNumber(chunkNumber) + .build(); + + return ResponseEntity.ok().body(new BaseResponse( + 20004, MessageCode.FILE_UPLOAD_ERROR.getCode(), + "Failed to upload chunk: " + e.getMessage(), responseBody)); + } + } + + @GetMapping("/v{version}/files/progress/{uploadId}") + public ResponseEntity getUploadProgress( + @PathVariable("version") int version, + @PathVariable String uploadId) { + + log.info("Getting progress for upload session: {}, version: {}", uploadId, version); + + try { + var progress = fileUploadService.getUploadProgress(uploadId); + + UploadProgress responseBody = UploadProgress.builder() + .uploadId(progress.getUploadId()) + .fileName(progress.getFileName()) + .totalChunks(progress.getTotalChunks()) + .uploadedChunks(progress.getUploadedChunks()) + .status(progress.getStatus().toString()) + .progressPercentage(progress.getProgressPercentage()) + .filePath(progress.getFilePath()) + .remainingChunks(progress.getTotalChunks() - progress.getUploadedChunks()) + .build(); + + return ResponseEntity.ok().body(new BaseResponse( + 20004, MessageCode.SUCCESS.getCode(), MessageCode.SUCCESS.getDescription(), responseBody)); + + } catch (Exception e) { + log.error("Error getting progress for upload: {}", uploadId, e); + + UploadProgress responseBody = UploadProgress.builder() + .uploadId(uploadId) + .build(); + + return ResponseEntity.ok().body(new BaseResponse( + 20004, MessageCode.FILE_UPLOAD_ERROR.getCode(), + "Failed to get upload progress: " + e.getMessage(), responseBody)); + } + } + + @PostMapping("/v{version}/files/{uploadId}/complete") + public ResponseEntity completeUpload( + @PathVariable("version") int version, + @PathVariable String uploadId) { + + log.info("Completing upload session: {}, version: {}", uploadId, version); + + try { + var progress = fileUploadService.getUploadProgress(uploadId); + + if (progress.getStatus() == UploadStatus.COMPLETED) { + CompleteUploadResponse responseBody = CompleteUploadResponse.builder() + .uploadId(progress.getUploadId()) + .status(progress.getStatus().toString()) + .uploadedChunks(progress.getUploadedChunks()) + .totalChunks(progress.getTotalChunks()) + .message("Upload already completed") + .filePath(progress.getFilePath()) + .build(); + + return ResponseEntity.ok().body(new BaseResponse(20004, MessageCode.SUCCESS.getCode(), + "Upload already completed", responseBody)); + } + + if (progress.getUploadedChunks() < progress.getTotalChunks()) { + CompleteUploadResponse responseBody = CompleteUploadResponse.builder() + .uploadId(progress.getUploadId()) + .status(progress.getStatus().toString()) + .uploadedChunks(progress.getUploadedChunks()) + .totalChunks(progress.getTotalChunks()) + .message("Not all chunks uploaded") + .build(); + + return ResponseEntity.ok().body(new BaseResponse(20004, MessageCode.INCOMPLETE_UPLOAD.getCode(), + "Not all chunks uploaded", responseBody)); + } + + CompleteUploadResponse responseBody = CompleteUploadResponse.builder() + .uploadId(progress.getUploadId()) + .status(progress.getStatus().toString()) + .uploadedChunks(progress.getUploadedChunks()) + .totalChunks(progress.getTotalChunks()) + .message("File assembly in progress") + .build(); + + return ResponseEntity.ok().body(new BaseResponse(20004, MessageCode.SUCCESS.getCode(), + "File assembly in progress", responseBody)); + + } catch (Exception e) { + log.error("Error completing upload: {}", uploadId, e); + + CompleteUploadResponse responseBody = CompleteUploadResponse.builder() + .uploadId(uploadId) + .build(); + + return ResponseEntity.ok().body(new BaseResponse(20004, MessageCode.FILE_UPLOAD_ERROR.getCode(), + "Failed to complete upload: " + e.getMessage(), responseBody)); + } + } + private ResponseEntity createValidationErrorResponse(BindingResult bindingResult, Integer msgId) { List> fieldErrors = bindingResult.getFieldErrors() .stream() diff --git a/src/main/java/ru/soune/nocopy/controller/file/FileUploadController.java b/src/main/java/ru/soune/nocopy/controller/file/FileUploadController.java index 6eab4bb..d4bb6da 100644 --- a/src/main/java/ru/soune/nocopy/controller/file/FileUploadController.java +++ b/src/main/java/ru/soune/nocopy/controller/file/FileUploadController.java @@ -31,38 +31,6 @@ public class FileUploadController { private final FileUploadService fileUploadService; - private final AuthTokenRepository authTokenRepository; - - private final FileUploadSessionRepository fileUploadSessionRepository; - - //TODO ADD TEMPLATE JSON, RESPONSE OK,ADD MESSAGE CODE - @PostMapping("/init") - public ResponseEntity> initUpload(@Valid @RequestBody InitUploadRequest request, - @RequestHeader("Authorization") String tokenHeader) { - String token = tokenHeader.replace("Bearer ", ""); - - Optional tokenOptional = authTokenRepository.findByToken(token); - - AuthToken authToken = tokenOptional.orElseThrow(() -> new NotFoundAuthToken("Token: " + token + "not found")); - - try { - FileUploadSession session = fileUploadService.initUpload(authToken.getUser().getId(), request.getFileName(), - request.getFileType(), request.getExtension(), request.getFileSize()); - - return ResponseEntity.ok(FileApiResponse.success(session)); - } catch (Exception e) { - log.error("Error initializing upload", e); - return ResponseEntity.badRequest() - .body(FileApiResponse.error(e.getMessage())); - } - } - - @GetMapping("/file-types") - public ResponseEntity> getFileTypes() { - return ResponseEntity.ok(Arrays.asList(FileType.values())); - } - - @PostMapping("/chunk") public ResponseEntity> uploadChunk( @RequestParam("uploadId") String uploadId, @@ -94,131 +62,4 @@ public class FileUploadController { .body(FileApiResponse.error(e.getMessage())); } } - - @GetMapping("/progress/{uploadId}") - public ResponseEntity> getProgress( - @PathVariable String uploadId) { - - try { - UploadProgressResponse progress = fileUploadService.getUploadProgress(uploadId); - return ResponseEntity.ok(FileApiResponse.success(progress)); - - } catch (UploadSessionNotFoundException e) { - return ResponseEntity.status(HttpStatus.NOT_FOUND) - .body(FileApiResponse.error("Upload session not found")); - } catch (Exception e) { - log.error("Error getting progress", e); - return ResponseEntity.badRequest() - .body(FileApiResponse.error("Failed to get progress")); - } - } - - @PostMapping("/{uploadId}/complete") - public ResponseEntity> completeUpload( - @PathVariable String uploadId) { - - log.info("Manual completion requested for session: {}", uploadId); - - try { - UploadProgressResponse progress = fileUploadService.getUploadProgress(uploadId); - - if (progress.getStatus() == UploadStatus.COMPLETED) { - return ResponseEntity.ok(FileApiResponse.success( - "Upload already completed", progress)); - } - - if (progress.getUploadedChunks() < progress.getTotalChunks()) { - return ResponseEntity.badRequest() - .body(FileApiResponse.error( - String.format("Not all chunks uploaded: %d/%d", - progress.getUploadedChunks(), progress.getTotalChunks()))); - } - - return ResponseEntity.ok(FileApiResponse.success( - "File assembly in progress", progress)); - - } catch (UploadSessionNotFoundException e) { - return ResponseEntity.status(HttpStatus.NOT_FOUND) - .body(FileApiResponse.error("Upload session not found")); - } catch (Exception e) { - log.error("Error completing upload", e); - return ResponseEntity.internalServerError() - .body(FileApiResponse.error("Failed to complete upload")); - } - } - - @PostMapping("/{uploadId}/cancel") - public ResponseEntity> cancelUpload( - @PathVariable String uploadId) { - - log.info("Cancelling upload session: {}", uploadId); - - try { - fileUploadService.cancelUpload(uploadId); - return ResponseEntity.ok(FileApiResponse.success( - "Upload cancelled successfully", null)); - - } catch (UploadSessionNotFoundException e) { - return ResponseEntity.status(HttpStatus.NOT_FOUND) - .body(FileApiResponse.error("Upload session not found")); - } catch (FileUploadException e) { - return ResponseEntity.badRequest() - .body(FileApiResponse.error(e.getMessage())); - } catch (Exception e) { - log.error("Error cancelling upload", e); - return ResponseEntity.internalServerError() - .body(FileApiResponse.error("Failed to cancel upload")); - } - } - - @PostMapping("/{uploadId}/retry") - public ResponseEntity> retryUpload( - @PathVariable String uploadId) { - - log.info("Retry requested for failed upload: {}", uploadId); - - try { - fileUploadService.retryFailedUpload(uploadId); - UploadProgressResponse progress = fileUploadService.getUploadProgress(uploadId); - - return ResponseEntity.ok(FileApiResponse.success( - "Upload retry initiated", progress)); - - } catch (UploadSessionNotFoundException e) { - return ResponseEntity.status(HttpStatus.NOT_FOUND) - .body(FileApiResponse.error("Upload session not found")); - } catch (FileUploadException e) { - return ResponseEntity.badRequest() - .body(FileApiResponse.error(e.getMessage())); - } catch (Exception e) { - log.error("Error retrying upload", e); - return ResponseEntity.internalServerError() - .body(FileApiResponse.error("Failed to retry upload")); - } - } - - @GetMapping("/{uploadId}/chunks") - public ResponseEntity>> getChunkStatus( - @PathVariable String uploadId) { - - try { - FileUploadSession session = fileUploadSessionRepository.findById(uploadId) - .orElseThrow(() -> new UploadSessionNotFoundException(uploadId)); - - Map chunkStatus = new HashMap<>(); - for (int i = 0; i < session.getTotalChunks(); i++) { - chunkStatus.put(i, session.getChunkPaths().containsKey(i)); - } - - return ResponseEntity.ok(FileApiResponse.success(chunkStatus)); - - } catch (UploadSessionNotFoundException e) { - return ResponseEntity.status(HttpStatus.NOT_FOUND) - .body(FileApiResponse.error("Upload session not found")); - } catch (Exception e) { - log.error("Error getting chunk status", e); - return ResponseEntity.internalServerError() - .body(FileApiResponse.error("Failed to get chunk status")); - } - } } diff --git a/src/main/java/ru/soune/nocopy/dto/AuthResponse.java b/src/main/java/ru/soune/nocopy/dto/AuthResponse.java deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/ru/soune/nocopy/dto/LoginResponse.java b/src/main/java/ru/soune/nocopy/dto/LoginResponse.java deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/ru/soune/nocopy/dto/MessageCode.java b/src/main/java/ru/soune/nocopy/dto/MessageCode.java index baa48be..36cd838 100644 --- a/src/main/java/ru/soune/nocopy/dto/MessageCode.java +++ b/src/main/java/ru/soune/nocopy/dto/MessageCode.java @@ -5,10 +5,15 @@ public enum MessageCode { REG_EMAIL_EXISTS(1, "Email already registered"), REG_EMAIL_OR_PHONE_EXISTS(1, "Email or phone already registered"), INVALID_FIELD(2, "Invalid field"), + INVALID_TOKEN(2, "Invalid token"), + INVALID_ACTION(2, "Invalid action"), + FILE_UPLOAD_ERROR(2, "File upload error"), INVALID_JSON_BODY(2, "Invalid fields in JSON object"), + INCOMPLETE_UPLOAD(2, "Not load all chunks"), MSG_ID_NOT_FOUND(4, "Message id not found"), AUTH_EMAIL_NOT_FOUND(4, "Email not found"), + FILE_NOT_FOUND(4, "File not found"), AUTH_PASSWORD_NOT_MATCHES(2, "Password does not match"); private final Integer code; diff --git a/src/main/java/ru/soune/nocopy/dto/RegisterRequest.java b/src/main/java/ru/soune/nocopy/dto/RegisterRequest.java deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/ru/soune/nocopy/dto/file/ActionResponse.java b/src/main/java/ru/soune/nocopy/dto/file/ActionResponse.java new file mode 100644 index 0000000..f492b6c --- /dev/null +++ b/src/main/java/ru/soune/nocopy/dto/file/ActionResponse.java @@ -0,0 +1,17 @@ +package ru.soune.nocopy.dto.file; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class ActionResponse { + private List availableActions; + private String action; +} diff --git a/src/main/java/ru/soune/nocopy/dto/file/CanceledUploadResponse.java b/src/main/java/ru/soune/nocopy/dto/file/CanceledUploadResponse.java new file mode 100644 index 0000000..99dc43e --- /dev/null +++ b/src/main/java/ru/soune/nocopy/dto/file/CanceledUploadResponse.java @@ -0,0 +1,15 @@ +package ru.soune.nocopy.dto.file; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class CanceledUploadResponse { + @JsonProperty("upload_id") + private String uploadId; + + @JsonProperty("message") + private String message; +} diff --git a/src/main/java/ru/soune/nocopy/dto/file/ChunkStatusResponse.java b/src/main/java/ru/soune/nocopy/dto/file/ChunkStatusResponse.java new file mode 100644 index 0000000..df78fd8 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/dto/file/ChunkStatusResponse.java @@ -0,0 +1,28 @@ +package ru.soune.nocopy.dto.file; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Map; + +@Data +@Builder +public class ChunkStatusResponse { + @JsonProperty("upload_id") + private String uploadId; + + @JsonProperty("total_chunks") + private Integer totalChunks; + + @JsonProperty("chunk_status") + private Map chunkStatus; + + @JsonProperty("uploaded_chunks") + private Integer uploadedChunks; + + @JsonProperty("missing_chunks") + private Integer missingChunks; +} \ No newline at end of file diff --git a/src/main/java/ru/soune/nocopy/dto/file/ChunkUploadResponse.java b/src/main/java/ru/soune/nocopy/dto/file/ChunkUploadResponse.java new file mode 100644 index 0000000..09bce18 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/dto/file/ChunkUploadResponse.java @@ -0,0 +1,25 @@ +package ru.soune.nocopy.dto.file; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ChunkUploadResponse { + @JsonProperty("upload_id") + private String uploadId; + + @JsonProperty("chunk_number") + private Integer chunkNumber; + + @JsonProperty("chunk_size") + private Long chunkSize; + + @JsonProperty("message") + private String message; +} diff --git a/src/main/java/ru/soune/nocopy/dto/file/CompleteUploadResponse.java b/src/main/java/ru/soune/nocopy/dto/file/CompleteUploadResponse.java new file mode 100644 index 0000000..0275520 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/dto/file/CompleteUploadResponse.java @@ -0,0 +1,31 @@ +package ru.soune.nocopy.dto.file; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CompleteUploadResponse { + @JsonProperty("upload_id") + private String uploadId; + + @JsonProperty("status") + private String status; + + @JsonProperty("uploaded_chunks") + private Integer uploadedChunks; + + @JsonProperty("total_chunks") + private Integer totalChunks; + + @JsonProperty("message") + private String message; + + @JsonProperty("file_path") + private String filePath; +} \ No newline at end of file diff --git a/src/main/java/ru/soune/nocopy/dto/file/FileTypesResponse.java b/src/main/java/ru/soune/nocopy/dto/file/FileTypesResponse.java new file mode 100644 index 0000000..e4b8af9 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/dto/file/FileTypesResponse.java @@ -0,0 +1,19 @@ +package ru.soune.nocopy.dto.file; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; +import lombok.Data; +import ru.soune.nocopy.entity.file.FileType; + +import java.util.List; + +@Data +@Builder +public class FileTypesResponse { + @JsonProperty("file_types") + private List fileTypes; + + @JsonProperty("count") + private Integer count; +} + diff --git a/src/main/java/ru/soune/nocopy/dto/file/FileUploadRequest.java b/src/main/java/ru/soune/nocopy/dto/file/FileUploadRequest.java new file mode 100644 index 0000000..3b72bb4 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/dto/file/FileUploadRequest.java @@ -0,0 +1,31 @@ +package ru.soune.nocopy.dto.file; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +@Data +public class FileUploadRequest { + @JsonProperty("upload_id") + private String uploadId; + + @JsonProperty("file_name") + private String fileName; + + @JsonProperty("file_type") + private String fileType; + + @JsonProperty("extension") + private String extension; + + @JsonProperty("file_size") + private Long fileSize; + + @JsonProperty("chunk_number") + private Integer chunkNumber; + + @JsonProperty("action") + private String action; + + @JsonProperty("token") + private String token; +} diff --git a/src/main/java/ru/soune/nocopy/dto/file/InitFileResponse.java b/src/main/java/ru/soune/nocopy/dto/file/InitFileResponse.java new file mode 100644 index 0000000..0c16a41 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/dto/file/InitFileResponse.java @@ -0,0 +1,30 @@ +package ru.soune.nocopy.dto.file; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; +import lombok.Data; + +import java.util.List; +import java.util.Map; + +@Data +@Builder +public class InitFileResponse { + @JsonProperty("upload_id") + private String uploadId; + + @JsonProperty("file_name") + private String fileName; + + @JsonProperty("total_chunks") + private Integer totalChunks; + + @JsonProperty("chunk_size") + private Long chunkSize; + + @JsonProperty("status") + private String status; + + @JsonProperty("field_errors") + private List> fieldErrors; +} diff --git a/src/main/java/ru/soune/nocopy/dto/file/RetryUploadResponse.java b/src/main/java/ru/soune/nocopy/dto/file/RetryUploadResponse.java new file mode 100644 index 0000000..33d02de --- /dev/null +++ b/src/main/java/ru/soune/nocopy/dto/file/RetryUploadResponse.java @@ -0,0 +1,18 @@ +package ru.soune.nocopy.dto.file; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class RetryUploadResponse { + @JsonProperty("upload_id") + private String uploadId; + + @JsonProperty("message") + private String message; + + @JsonProperty("status") + private String status; +} diff --git a/src/main/java/ru/soune/nocopy/dto/file/UploadProgress.java b/src/main/java/ru/soune/nocopy/dto/file/UploadProgress.java new file mode 100644 index 0000000..7fc1ac2 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/dto/file/UploadProgress.java @@ -0,0 +1,40 @@ +package ru.soune.nocopy.dto.file; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Builder +@Data +@AllArgsConstructor +@NoArgsConstructor +public class UploadProgress { + @JsonProperty("upload_id") + private String uploadId; + + @JsonProperty("file_name") + private String fileName; + + @JsonProperty("total_chunks") + private Integer totalChunks; + + @JsonProperty("uploaded_chunks") + private Integer uploadedChunks; + + @JsonProperty("status") + private String status; + + @JsonProperty("progress_percentage") + private Integer progressPercentage; + + @JsonProperty("file_path") + private String filePath; + + @JsonProperty("remaining_chunks") + private Integer remainingChunks; + + @JsonProperty("message") + private String message; +} \ No newline at end of file diff --git a/src/main/java/ru/soune/nocopy/entity/file/FileUploadSession.java b/src/main/java/ru/soune/nocopy/entity/file/FileUploadSession.java index 46a8698..996f0de 100644 --- a/src/main/java/ru/soune/nocopy/entity/file/FileUploadSession.java +++ b/src/main/java/ru/soune/nocopy/entity/file/FileUploadSession.java @@ -33,6 +33,9 @@ public class FileUploadSession { @Column(name = "file_size") private Long fileSize; + @Column(name = "chunk_size") + private Long chunkSize; + @Column(name = "total_chunks") private Integer totalChunks; @@ -84,9 +87,14 @@ public class FileUploadSession { public void prePersist() { this.createdAt = LocalDateTime.now(); this.expiresAt = LocalDateTime.now().plusHours(24); - this.chunksUploaded = 0; + if (this.chunksUploaded == null) { + this.chunksUploaded = 0; + } if (this.status == null) { this.status = UploadStatus.INITIATED; } + if (this.retryCount == null) { + this.retryCount = 0; + } } -} +} \ No newline at end of file diff --git a/src/main/java/ru/soune/nocopy/exception/UserAlreadyExistsException.java b/src/main/java/ru/soune/nocopy/exception/UserAlreadyExistsException.java deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/ru/soune/nocopy/handler/FileUploadHandler.java b/src/main/java/ru/soune/nocopy/handler/FileUploadHandler.java new file mode 100644 index 0000000..26f6dba --- /dev/null +++ b/src/main/java/ru/soune/nocopy/handler/FileUploadHandler.java @@ -0,0 +1,243 @@ +package ru.soune.nocopy.handler; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.validation.BeanPropertyBindingResult; +import org.springframework.validation.BindingResult; +import ru.soune.nocopy.dto.*; +import ru.soune.nocopy.dto.file.*; +import ru.soune.nocopy.entity.AuthToken; +import ru.soune.nocopy.entity.file.FileType; +import ru.soune.nocopy.entity.file.FileUploadSession; +import ru.soune.nocopy.exception.*; +import ru.soune.nocopy.handler.validator.FileUploadRequestValidator; +import ru.soune.nocopy.repository.AuthTokenRepository; +import ru.soune.nocopy.repository.FileUploadSessionRepository; +import ru.soune.nocopy.service.file.FileUploadService; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +@Slf4j +@Component +@RequiredArgsConstructor +public class FileUploadHandler implements RequestHandler { + private final FileUploadService fileUploadService; + private final AuthTokenRepository authTokenRepository; + private final FileUploadSessionRepository fileUploadSessionRepository; + private final ObjectMapper objectMapper; + private final FileUploadRequestValidator fileUploadRequestValidator; + + @Override + public BaseResponse handle(BaseRequest request) { + try { + FileUploadRequest fileRequest = objectMapper.convertValue( + request.getMessageBody(), FileUploadRequest.class); + + String action = fileRequest.getAction(); + + switch (action) { + case "init": + return handleInitUpload(request, fileRequest); + case "file_types": + return handleGetFileTypes(request); + case "chunks": + return handleGetChunkStatus(request, fileRequest); + case "cancel": + return handleCancelUpload(request, fileRequest); + case "progress": + return handleGetProgress(request, fileRequest); + case "retry": + return handleRetryUpload(request, fileRequest); + default: + ActionResponse response = ActionResponse.builder() + .action(action) + .availableActions(Arrays.asList("init", "file_types", "chunks", "cancel", "progress", "retry")) + .build(); + + return new BaseResponse(request.getMsgId(), MessageCode.INVALID_ACTION.getCode(), + "Invalid action: " + action, response); + } + } catch (Exception e) { + log.error("Error in FileUploadHandler", e); + return new BaseResponse(request.getMsgId(), MessageCode.FILE_UPLOAD_ERROR.getCode(), + "Handler error: " + e.getMessage(), null); + } + } + + private BaseResponse handleInitUpload(BaseRequest request, FileUploadRequest fileRequest) { + try { + String token = fileRequest.getToken(); + + Optional tokenOptional = authTokenRepository.findByToken(token); + + AuthToken authToken = tokenOptional.orElseThrow(() -> new NotFoundAuthToken("Token not found")); + + BindingResult bindingResult = new BeanPropertyBindingResult(fileRequest, "fileRequest"); + fileUploadRequestValidator.validate(fileRequest, bindingResult); + + if (bindingResult.hasErrors()) { + throw new ValidationException(bindingResult, request.getMsgId()); + } + + FileUploadSession session = fileUploadService.initUpload( + authToken.getUser().getId(), + fileRequest.getFileName(), + fileRequest.getFileType(), + fileRequest.getExtension(), + fileRequest.getFileSize()); + + InitFileResponse fileResponse = InitFileResponse.builder() + .uploadId(session.getUploadId()) + .fileName(session.getFileName()) + .totalChunks(session.getTotalChunks()) + .chunkSize(session.getChunkSize()) + .status(session.getStatus().toString()) + .build(); + + return new BaseResponse(request.getMsgId(), MessageCode.SUCCESS.getCode(), + MessageCode.SUCCESS.getDescription(), fileResponse); + + } catch (NotFoundAuthToken e) { + InitFileResponse initFileResponse = InitFileResponse.builder() + .build(); + + throw new NotValidFieldException("Invalid or expired token: " + fileRequest.getToken(), + new BaseResponse(request.getMsgId(), MessageCode.INVALID_TOKEN.getCode(), + MessageCode.INVALID_TOKEN.getDescription(), initFileResponse)); + } + } + + private BaseResponse handleGetProgress(BaseRequest request, FileUploadRequest fileRequest) { + try { + ru.soune.nocopy.dto.file.UploadProgressResponse progress = + fileUploadService.getUploadProgress(fileRequest.getUploadId()); + + UploadProgressResponse responseDto = UploadProgressResponse.builder() + .uploadId(progress.getUploadId()) + .fileName(progress.getFileName()) + .totalChunks(progress.getTotalChunks()) + .uploadedChunks(progress.getUploadedChunks()) + .status(progress.getStatus()) + .progressPercentage(progress.getProgressPercentage()) + .filePath(progress.getFilePath()) + .build(); + + return new BaseResponse(request.getMsgId(), MessageCode.SUCCESS.getCode(), + MessageCode.SUCCESS.getDescription(), responseDto); + + } catch (UploadSessionNotFoundException e) { + return new BaseResponse(request.getMsgId(), MessageCode.FILE_NOT_FOUND.getCode(), + MessageCode.FILE_NOT_FOUND.getDescription(), null); + } catch (Exception e) { + log.error("Error getting progress", e); + return new BaseResponse(request.getMsgId(), MessageCode.FILE_UPLOAD_ERROR.getCode(), + MessageCode.FILE_UPLOAD_ERROR.getDescription(), null); + } + } + + private BaseResponse handleCancelUpload(BaseRequest request, FileUploadRequest fileRequest) { + try { + fileUploadService.cancelUpload(fileRequest.getUploadId()); + + CanceledUploadResponse response = CanceledUploadResponse.builder() + .uploadId(fileRequest.getUploadId()) + .message("Upload cancelled successfully") + .build(); + + return new BaseResponse(request.getMsgId(), MessageCode.SUCCESS.getCode(), + "Upload cancelled successfully", response); + + } catch (UploadSessionNotFoundException e) { + return new BaseResponse(request.getMsgId(), MessageCode.FILE_NOT_FOUND.getCode(), + "Upload session not found", null); + } catch (FileUploadException e) { + return new BaseResponse(request.getMsgId(), MessageCode.FILE_UPLOAD_ERROR.getCode(), e.getMessage(), null); + } catch (Exception e) { + log.error("Error cancelling upload", e); + return new BaseResponse(request.getMsgId(), MessageCode.FILE_UPLOAD_ERROR.getCode(), + "Failed to cancel upload", null); + } + } + + private BaseResponse handleRetryUpload(BaseRequest request, FileUploadRequest fileRequest) { + try { + fileUploadService.retryFailedUpload(fileRequest.getUploadId()); + ru.soune.nocopy.dto.file.UploadProgressResponse progress = + fileUploadService.getUploadProgress(fileRequest.getUploadId()); + + RetryUploadResponse response = RetryUploadResponse.builder() + .uploadId(progress.getUploadId()) + .message("Upload retry initiated") + .status(progress.getStatus().toString()) + .build(); + + return new BaseResponse(request.getMsgId(), MessageCode.SUCCESS.getCode(), "Upload retry initiated", + response); + + } catch (UploadSessionNotFoundException e) { + return new BaseResponse(request.getMsgId(), MessageCode.FILE_NOT_FOUND.getCode(), + "Upload session not found", null); + } catch (FileUploadException e) { + return new BaseResponse(request.getMsgId(), MessageCode.FILE_UPLOAD_ERROR.getCode(), e.getMessage(), + null); + } catch (Exception e) { + log.error("Error retrying upload", e); + return new BaseResponse(request.getMsgId(), MessageCode.FILE_UPLOAD_ERROR.getCode(), + "Failed to retry upload", null); + } + } + + private BaseResponse handleGetChunkStatus(BaseRequest request, FileUploadRequest fileRequest) { + try { + FileUploadSession session = fileUploadSessionRepository.findById(fileRequest.getUploadId()) + .orElseThrow(() -> new UploadSessionNotFoundException(fileRequest.getUploadId())); + + Map chunkStatus = new HashMap<>(); + for (int i = 0; i < session.getTotalChunks(); i++) { + chunkStatus.put("chunk_" + i, session.getChunkPaths().containsKey(i)); + } + + ChunkStatusResponse response = ChunkStatusResponse.builder() + .uploadId(fileRequest.getUploadId()) + .totalChunks(session.getTotalChunks()) + .uploadedChunks(session.getChunksUploaded()) + .missingChunks(session.getTotalChunks() - session.getChunksUploaded()) + .chunkStatus(chunkStatus) + .build(); + + return new BaseResponse(request.getMsgId(), MessageCode.SUCCESS.getCode(), + MessageCode.SUCCESS.getDescription(), response); + + } catch (UploadSessionNotFoundException e) { + return new BaseResponse(request.getMsgId(), MessageCode.FILE_NOT_FOUND.getCode(), + "Upload session not found", null); + } catch (Exception e) { + log.error("Error getting chunk status", e); + return new BaseResponse(request.getMsgId(), + MessageCode.FILE_UPLOAD_ERROR.getCode(), "Failed to get chunk status", + null); + } + } + + private BaseResponse handleGetFileTypes(BaseRequest request) { + try { + FileTypesResponse response = FileTypesResponse.builder() + .fileTypes(Arrays.asList(FileType.values())) + .count(FileType.values().length) + .build(); + + return new BaseResponse(request.getMsgId(), MessageCode.SUCCESS.getCode(), + MessageCode.SUCCESS.getDescription(), response); + + } catch (Exception e) { + log.error("Error getting file types", e); + return new BaseResponse(request.getMsgId(), MessageCode.FILE_UPLOAD_ERROR.getCode(), + "Failed to get file types", null); + } + } +} diff --git a/src/main/java/ru/soune/nocopy/handler/validator/FileUploadRequestValidator.java b/src/main/java/ru/soune/nocopy/handler/validator/FileUploadRequestValidator.java new file mode 100644 index 0000000..10737f7 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/handler/validator/FileUploadRequestValidator.java @@ -0,0 +1,66 @@ +package ru.soune.nocopy.handler.validator; + +import org.springframework.stereotype.Component; +import org.springframework.validation.Errors; +import org.springframework.validation.Validator; +import ru.soune.nocopy.dto.file.FileUploadRequest; +import ru.soune.nocopy.entity.file.FileType; + +import java.util.Arrays; + +@Component +public class FileUploadRequestValidator implements Validator { + @Override + public boolean supports(Class clazz) { + return FileUploadRequest.class.isAssignableFrom(clazz); + } + + @Override + public void validate(Object target, Errors errors) { + FileUploadRequest request = (FileUploadRequest) target; + + validateType(request.getFileType(), errors); + validateFileName(request.getFileName(), errors); + validateExtension(request.getExtension(), errors); + } + + private void validateType(String fileType, Errors errors) { + if (fileType == null || fileType.isBlank()) { + errors.rejectValue("fileType", "fileType.required", "File type is required"); + return; + } + + try { + FileType.valueOf(fileType.toUpperCase()); + } catch (IllegalArgumentException e) { + errors.rejectValue("fileType", "fileType.invalid", + "Invalid file type. Valid types: " + Arrays.toString(FileType.values())); + } + } + + private void validateFileName(String fileName, Errors errors) { + if (fileName == null || fileName.isBlank()) { + errors.rejectValue("fileName", "fileName.required", "File name is required"); + return; + } + + if (fileName.length() > 255) { + errors.rejectValue("fileName", "fileName.too.long", + "File name too long, max 255 characters"); + } + } + + private void validateExtension(String extension, Errors errors) { + if (extension == null || extension.isBlank()) { + errors.rejectValue("extension", "extension.required", "Extension is required"); + return; + } + + if (!extension.startsWith(".")) { + errors.rejectValue("extension", "extension.invalid.format", + "Extension must start with '.'"); + return; + } + } + +} diff --git a/src/main/java/ru/soune/nocopy/service/file/FileUploadServiceImpl.java b/src/main/java/ru/soune/nocopy/service/file/FileUploadServiceImpl.java index 49e5aca..a1eebb4 100644 --- a/src/main/java/ru/soune/nocopy/service/file/FileUploadServiceImpl.java +++ b/src/main/java/ru/soune/nocopy/service/file/FileUploadServiceImpl.java @@ -510,8 +510,8 @@ public class FileUploadServiceImpl implements FileUploadService { FileUploadSession session = sessionRepository.findById(uploadId) .orElseThrow(() -> new UploadSessionNotFoundException(uploadId)); - if (session.getStatus() == UploadStatus.COMPLETED) { - throw new FileUploadException("Cannot cancel completed upload"); + if (session.getStatus() == UploadStatus.COMPLETED || session.getStatus() == UploadStatus.CANCELLED) { + throw new FileUploadException("Cannot cancel completed or cancelled upload"); } session.setStatus(UploadStatus.CANCELLED); @@ -539,6 +539,7 @@ public class FileUploadServiceImpl implements FileUploadService { session.setStatus(UploadStatus.UPLOADING); session.setRetryCount(session.getRetryCount() + 1); session.setLastError(null); + sessionRepository.save(session); if (session.getChunksUploaded().equals(session.getTotalChunks())) {