From 9e8c7f0fa105cfbb2b66f1d7b83b6ec30a21f7bb Mon Sep 17 00:00:00 2001 From: vladp Date: Wed, 17 Dec 2025 01:19:48 +0700 Subject: [PATCH] add fileentity --- .../file/FileDownloadController.java | 172 +++++++++++ .../controller/file/FileEntityController.java | 210 +++++++++++++ .../no_copy/dto/file/FileEntityResponse.java | 33 +++ .../no_copy/dto/file/FileListResponse.java | 22 ++ .../soune/no_copy/entity/file/FileEntity.java | 78 +++++ .../soune/no_copy/entity/file/FileStatus.java | 8 + .../FileEntityNotFoundException.java | 11 + .../repository/FileEntityRepository.java | 33 +++ .../service/file/FileEntityService.java | 277 ++++++++++++++++++ .../service/file/FileUploadServiceImpl.java | 74 ++++- src/main/resources/application.yaml | 7 + 11 files changed, 921 insertions(+), 4 deletions(-) create mode 100644 src/main/java/ru/soune/no_copy/controller/file/FileDownloadController.java create mode 100644 src/main/java/ru/soune/no_copy/controller/file/FileEntityController.java create mode 100644 src/main/java/ru/soune/no_copy/dto/file/FileEntityResponse.java create mode 100644 src/main/java/ru/soune/no_copy/dto/file/FileListResponse.java create mode 100644 src/main/java/ru/soune/no_copy/entity/file/FileEntity.java create mode 100644 src/main/java/ru/soune/no_copy/entity/file/FileStatus.java create mode 100644 src/main/java/ru/soune/no_copy/exception/FileEntityNotFoundException.java create mode 100644 src/main/java/ru/soune/no_copy/repository/FileEntityRepository.java create mode 100644 src/main/java/ru/soune/no_copy/service/file/FileEntityService.java diff --git a/src/main/java/ru/soune/no_copy/controller/file/FileDownloadController.java b/src/main/java/ru/soune/no_copy/controller/file/FileDownloadController.java new file mode 100644 index 0000000..6be2c46 --- /dev/null +++ b/src/main/java/ru/soune/no_copy/controller/file/FileDownloadController.java @@ -0,0 +1,172 @@ +package ru.soune.no_copy.controller.file; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.io.Resource; +import org.springframework.core.io.UrlResource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import ru.soune.no_copy.dto.file.FileEntityResponse; +import ru.soune.no_copy.entity.AuthToken; +import ru.soune.no_copy.exception.NotFoundAuthToken; +import ru.soune.no_copy.repository.AuthTokenRepository; +import ru.soune.no_copy.service.file.FileEntityService; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +@Slf4j +@RestController +@RequestMapping("/api/files") +@RequiredArgsConstructor +public class FileDownloadController { + + private final FileEntityService fileEntityService; + private final AuthTokenRepository authTokenRepository; + + /** + * Скачать файл по ID FileEntity + */ + @GetMapping("/download/{fileId}") + public ResponseEntity downloadFile( + @PathVariable String fileId, + @RequestHeader("Authorization") String tokenHeader) { + + try { + Long userId = getUserIdFromToken(tokenHeader); + FileEntityResponse fileInfo = fileEntityService.getById(fileId); + + if (!fileInfo.getUserId().equals(userId)) { + return ResponseEntity.status(403).build(); + } + + if (!fileInfo.isExistsOnDisk()) { + return ResponseEntity.status(404) + .body(null); + } + + Path filePath = Paths.get(fileInfo.getFilePath()); + Resource resource = new UrlResource(filePath.toUri()); + + if (!resource.exists()) { + return ResponseEntity.status(404).build(); + } + + String contentType = determineContentType(filePath); + + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType(contentType)) + .header(HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename=\"" + fileInfo.getOriginalFileName() + "\"") + .header(HttpHeaders.CONTENT_LENGTH, String.valueOf(fileInfo.getFileSize())) + .body(resource); + + } catch (NotFoundAuthToken e) { + return ResponseEntity.status(401).build(); + } catch (Exception e) { + log.error("Error downloading file", e); + return ResponseEntity.status(500).build(); + } + } + + /** + * Получить предпросмотр файла (если поддерживается) + */ + @GetMapping("/preview/{fileId}") + public ResponseEntity previewFile( + @PathVariable String fileId, + @RequestHeader("Authorization") String tokenHeader) { + + try { + Long userId = getUserIdFromToken(tokenHeader); + FileEntityResponse fileInfo = fileEntityService.getById(fileId); + + if (!fileInfo.getUserId().equals(userId)) { + return ResponseEntity.status(403).build(); + } + + if (!isPreviewSupported(fileInfo.getMimeType())) { + return ResponseEntity.status(415) + .body(null); + } + + Path filePath = Paths.get(fileInfo.getFilePath()); + Resource resource = new UrlResource(filePath.toUri()); + + if (!resource.exists()) { + return ResponseEntity.status(404).build(); + } + + String contentType = determineContentType(filePath); + + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType(contentType)) + .header(HttpHeaders.CONTENT_DISPOSITION, + "inline; filename=\"" + fileInfo.getOriginalFileName() + "\"") + .body(resource); + + } catch (NotFoundAuthToken e) { + return ResponseEntity.status(401).build(); + } catch (Exception e) { + log.error("Error previewing file", e); + return ResponseEntity.status(500).build(); + } + } + + /** + * Получить информацию о файле для фронтенда + */ + @GetMapping("/info/{fileId}") + public ResponseEntity getFileInfo( + @PathVariable String fileId, + @RequestHeader("Authorization") String tokenHeader) { + + try { + Long userId = getUserIdFromToken(tokenHeader); + FileEntityResponse fileInfo = fileEntityService.getById(fileId); + + if (!fileInfo.getUserId().equals(userId)) { + return ResponseEntity.status(403).build(); + } + + return ResponseEntity.ok(fileInfo); + + } catch (NotFoundAuthToken e) { + return ResponseEntity.status(401).build(); + } catch (Exception e) { + log.error("Error getting file info", e); + return ResponseEntity.status(500).build(); + } + } + + private String determineContentType(Path filePath) throws IOException { + String contentType = Files.probeContentType(filePath); + if (contentType == null) { + contentType = "application/octet-stream"; + } + return contentType; + } + + private boolean isPreviewSupported(String mimeType) { + if (mimeType == null) { + return false; + } + + return mimeType.startsWith("image/") || + mimeType.startsWith("text/") || + mimeType.equals("application/pdf") || + mimeType.startsWith("video/") || + mimeType.startsWith("audio/"); + } + + private Long getUserIdFromToken(String tokenHeader) { + String token = tokenHeader.replace("Bearer ", ""); + AuthToken authToken = authTokenRepository.findByToken(token) + .orElseThrow(() -> new NotFoundAuthToken("Token not found")); + return authToken.getUser().getId(); + } +} diff --git a/src/main/java/ru/soune/no_copy/controller/file/FileEntityController.java b/src/main/java/ru/soune/no_copy/controller/file/FileEntityController.java new file mode 100644 index 0000000..77987a3 --- /dev/null +++ b/src/main/java/ru/soune/no_copy/controller/file/FileEntityController.java @@ -0,0 +1,210 @@ +package ru.soune.no_copy.controller.file; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import ru.soune.no_copy.dto.file.FileApiResponse; +import ru.soune.no_copy.dto.file.FileEntityResponse; +import ru.soune.no_copy.dto.file.FileListResponse; +import ru.soune.no_copy.entity.AuthToken; +import ru.soune.no_copy.exception.NotFoundAuthToken; +import ru.soune.no_copy.repository.AuthTokenRepository; +import ru.soune.no_copy.service.file.FileEntityService; + +@Slf4j +@RestController +@RequestMapping("/api/file-entities") +@RequiredArgsConstructor +public class FileEntityController { + + private final FileEntityService fileEntityService; + private final AuthTokenRepository authTokenRepository; + + /** + * Получить информацию о файле по ID + */ + @GetMapping("/{fileId}") + public ResponseEntity> getFileInfo( + @PathVariable String fileId, + @RequestHeader("Authorization") String tokenHeader) { + + try { + Long userId = getUserIdFromToken(tokenHeader); + FileEntityResponse fileInfo = fileEntityService.getById(fileId); + + if (!fileInfo.getUserId().equals(userId)) { + return ResponseEntity.status(403) + .body(FileApiResponse.error("Access denied")); + } + + return ResponseEntity.ok(FileApiResponse.success(fileInfo)); + + } catch (NotFoundAuthToken e) { + return ResponseEntity.status(401) + .body(FileApiResponse.error("Authentication required")); + } catch (Exception e) { + log.error("Error getting file info", e); + return ResponseEntity.badRequest() + .body(FileApiResponse.error("Failed to get file info: " + e.getMessage())); + } + } + + /** + * Получить информацию о файле по ID сессии загрузки + */ + @GetMapping("/by-session/{uploadSessionId}") + public ResponseEntity> getFileByUploadSession( + @PathVariable String uploadSessionId, + @RequestHeader("Authorization") String tokenHeader) { + + try { + Long userId = getUserIdFromToken(tokenHeader); + FileEntityResponse fileInfo = fileEntityService.getByUploadSessionId(uploadSessionId); + + if (!fileInfo.getUserId().equals(userId)) { + return ResponseEntity.status(403) + .body(FileApiResponse.error("Access denied")); + } + + return ResponseEntity.ok(FileApiResponse.success(fileInfo)); + + } catch (NotFoundAuthToken e) { + return ResponseEntity.status(401) + .body(FileApiResponse.error("Authentication required")); + } catch (Exception e) { + log.error("Error getting file by session", e); + return ResponseEntity.badRequest() + .body(FileApiResponse.error("Failed to get file: " + e.getMessage())); + } + } + + /** + * Получить все файлы пользователя + */ + @GetMapping("/my-files") + public ResponseEntity> getUserFiles( + @RequestHeader("Authorization") String tokenHeader, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int pageSize) { + + try { + Long userId = getUserIdFromToken(tokenHeader); + FileListResponse files = fileEntityService.getUserFiles(userId, page, pageSize); + + return ResponseEntity.ok(FileApiResponse.success(files)); + + } catch (NotFoundAuthToken e) { + return ResponseEntity.status(401) + .body(FileApiResponse.error("Authentication required")); + } catch (Exception e) { + log.error("Error getting user files", e); + return ResponseEntity.badRequest() + .body(FileApiResponse.error("Failed to get files: " + e.getMessage())); + } + } + + /** + * Поиск файлов пользователя + */ + @GetMapping("/search") + public ResponseEntity> searchFiles( + @RequestParam String query, + @RequestHeader("Authorization") String tokenHeader, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int pageSize) { + + try { + Long userId = getUserIdFromToken(tokenHeader); + FileListResponse allFiles = fileEntityService.getUserFiles(userId, 1, 1000); + + var filteredFiles = allFiles.getFiles().stream() + .filter(f -> f.getOriginalFileName().toLowerCase().contains(query.toLowerCase())) + .toList(); + + int start = (page - 1) * pageSize; + int end = Math.min(start + pageSize, filteredFiles.size()); + + FileListResponse response = FileListResponse.builder() + .files(filteredFiles.subList(start, Math.min(end, filteredFiles.size()))) + .totalCount(filteredFiles.size()) + .totalSize(allFiles.getTotalSize()) + .formattedTotalSize(allFiles.getFormattedTotalSize()) + .page(page) + .pageSize(pageSize) + .build(); + + return ResponseEntity.ok(FileApiResponse.success(response)); + + } catch (NotFoundAuthToken e) { + return ResponseEntity.status(401) + .body(FileApiResponse.error("Authentication required")); + } catch (Exception e) { + log.error("Error searching files", e); + return ResponseEntity.badRequest() + .body(FileApiResponse.error("Failed to search files: " + e.getMessage())); + } + } + + /** + * Получить общий размер файлов пользователя + */ + @GetMapping("/storage/usage") + public ResponseEntity> getStorageUsage( + @RequestHeader("Authorization") String tokenHeader) { + + try { + Long userId = getUserIdFromToken(tokenHeader); + long usage = fileEntityService.getUserStorageUsed(userId); + + return ResponseEntity.ok(FileApiResponse.success(usage)); + + } catch (NotFoundAuthToken e) { + return ResponseEntity.status(401) + .body(FileApiResponse.error("Authentication required")); + } catch (Exception e) { + log.error("Error getting storage usage", e); + return ResponseEntity.badRequest() + .body(FileApiResponse.error("Failed to get storage usage: " + e.getMessage())); + } + } + + /** + * Пометить файл как удаленный + */ + @DeleteMapping("/{fileId}") + public ResponseEntity> deleteFile( + @PathVariable String fileId, + @RequestHeader("Authorization") String tokenHeader) { + + try { + Long userId = getUserIdFromToken(tokenHeader); + FileEntityResponse fileInfo = fileEntityService.getById(fileId); + + if (!fileInfo.getUserId().equals(userId)) { + return ResponseEntity.status(403) + .body(FileApiResponse.error("Access denied")); + } + + fileEntityService.markAsDeleted(fileId); + + return ResponseEntity.ok(FileApiResponse.success( + "File marked as deleted", null)); + + } catch (NotFoundAuthToken e) { + return ResponseEntity.status(401) + .body(FileApiResponse.error("Authentication required")); + } catch (Exception e) { + log.error("Error deleting file", e); + return ResponseEntity.badRequest() + .body(FileApiResponse.error("Failed to delete file: " + e.getMessage())); + } + } + + private Long getUserIdFromToken(String tokenHeader) { + String token = tokenHeader.replace("Bearer ", ""); + AuthToken authToken = authTokenRepository.findByToken(token) + .orElseThrow(() -> new NotFoundAuthToken("Token not found")); + return authToken.getUser().getId(); + } +} diff --git a/src/main/java/ru/soune/no_copy/dto/file/FileEntityResponse.java b/src/main/java/ru/soune/no_copy/dto/file/FileEntityResponse.java new file mode 100644 index 0000000..ca96341 --- /dev/null +++ b/src/main/java/ru/soune/no_copy/dto/file/FileEntityResponse.java @@ -0,0 +1,33 @@ +package ru.soune.no_copy.dto.file; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import ru.soune.no_copy.entity.file.FileStatus; + +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class FileEntityResponse { + + private String id; + private Long userId; + private String originalFileName; + private String storedFileName; + private String filePath; + private Long fileSize; + private String mimeType; + private String fileExtension; + private String checksum; + private String uploadSessionId; + private FileStatus status; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private String formattedSize; + private String downloadUrl; + private boolean existsOnDisk; +} diff --git a/src/main/java/ru/soune/no_copy/dto/file/FileListResponse.java b/src/main/java/ru/soune/no_copy/dto/file/FileListResponse.java new file mode 100644 index 0000000..8c2c503 --- /dev/null +++ b/src/main/java/ru/soune/no_copy/dto/file/FileListResponse.java @@ -0,0 +1,22 @@ +package ru.soune.no_copy.dto.file; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class FileListResponse { + + private List files; + private int totalCount; + private long totalSize; + private String formattedTotalSize; + private int page; + private int pageSize; +} diff --git a/src/main/java/ru/soune/no_copy/entity/file/FileEntity.java b/src/main/java/ru/soune/no_copy/entity/file/FileEntity.java new file mode 100644 index 0000000..abe0e9d --- /dev/null +++ b/src/main/java/ru/soune/no_copy/entity/file/FileEntity.java @@ -0,0 +1,78 @@ +package ru.soune.no_copy.entity.file; + +import jakarta.persistence.*; +import lombok.*; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "file_entities") +@EntityListeners(AuditingEntityListener.class) +public class FileEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private String id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "original_file_name", nullable = false) + private String originalFileName; + + @Column(name = "stored_file_name", nullable = false) + private String storedFileName; + + @Column(name = "file_path", nullable = false, unique = true) + private String filePath; + + @Column(name = "file_size", nullable = false) + private Long fileSize; + + @Column(name = "mime_type") + private String mimeType; + + @Column(name = "file_extension") + private String fileExtension; + + @Column(name = "checksum") + private String checksum; + + @Column(name = "upload_session_id") + private String uploadSessionId; + + @Enumerated(EnumType.STRING) + @Column(name = "status") + private FileStatus status; + + @CreatedDate + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + @PrePersist + public void prePersist() { + if (this.status == null) { + this.status = FileStatus.ACTIVE; + } + if (this.createdAt == null) { + this.createdAt = LocalDateTime.now(); + } + this.updatedAt = LocalDateTime.now(); + } + + @PreUpdate + public void preUpdate() { + this.updatedAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/ru/soune/no_copy/entity/file/FileStatus.java b/src/main/java/ru/soune/no_copy/entity/file/FileStatus.java new file mode 100644 index 0000000..e84fc00 --- /dev/null +++ b/src/main/java/ru/soune/no_copy/entity/file/FileStatus.java @@ -0,0 +1,8 @@ +package ru.soune.no_copy.entity.file; + +public enum FileStatus { + ACTIVE, + DELETED, + PROCESSING, + ERROR +} diff --git a/src/main/java/ru/soune/no_copy/exception/FileEntityNotFoundException.java b/src/main/java/ru/soune/no_copy/exception/FileEntityNotFoundException.java new file mode 100644 index 0000000..8b5fd82 --- /dev/null +++ b/src/main/java/ru/soune/no_copy/exception/FileEntityNotFoundException.java @@ -0,0 +1,11 @@ +package ru.soune.no_copy.exception; + +public class FileEntityNotFoundException extends RuntimeException { + public FileEntityNotFoundException(String fileId) { + super("FileEntity not found with ID: " + fileId); + } + + public FileEntityNotFoundException(String message, Throwable cause) { + super(message, cause); + } +} \ No newline at end of file diff --git a/src/main/java/ru/soune/no_copy/repository/FileEntityRepository.java b/src/main/java/ru/soune/no_copy/repository/FileEntityRepository.java new file mode 100644 index 0000000..41e2781 --- /dev/null +++ b/src/main/java/ru/soune/no_copy/repository/FileEntityRepository.java @@ -0,0 +1,33 @@ +package ru.soune.no_copy.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import ru.soune.no_copy.entity.file.FileEntity; +import ru.soune.no_copy.entity.file.FileStatus; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface FileEntityRepository extends JpaRepository { + + List findByUserId(Long userId); + + List findByUserIdAndStatus(Long userId, FileStatus status); + + Optional findByFilePath(String filePath); + + Optional findByUploadSessionId(String uploadSessionId); + + boolean existsByFilePath(String filePath); + + @Query("SELECT SUM(f.fileSize) FROM FileEntity f WHERE f.userId = :userId AND f.status = 'ACTIVE'") + Long getTotalSizeByUserId(@Param("userId") Long userId); + + @Query("SELECT f FROM FileEntity f WHERE f.userId = :userId AND f.originalFileName LIKE %:keyword%") + List searchByFileName(@Param("userId") Long userId, @Param("keyword") String keyword); + + long countByUserId(Long userId); +} diff --git a/src/main/java/ru/soune/no_copy/service/file/FileEntityService.java b/src/main/java/ru/soune/no_copy/service/file/FileEntityService.java new file mode 100644 index 0000000..2a745a9 --- /dev/null +++ b/src/main/java/ru/soune/no_copy/service/file/FileEntityService.java @@ -0,0 +1,277 @@ +package ru.soune.no_copy.service.file; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import ru.soune.no_copy.dto.file.FileEntityResponse; +import ru.soune.no_copy.dto.file.FileListResponse; +import ru.soune.no_copy.entity.file.FileEntity; +import ru.soune.no_copy.entity.file.FileStatus; +import ru.soune.no_copy.entity.file.FileUploadSession; +import ru.soune.no_copy.exception.FileEntityNotFoundException; +import ru.soune.no_copy.repository.FileEntityRepository; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class FileEntityService { + + private final FileEntityRepository fileEntityRepository; + + /** + * Создает FileEntity на основе завершенной сессии загрузки + */ + @Transactional + public FileEntity createFromUploadSession(FileUploadSession session, String checksum) { + log.info("Creating FileEntity for upload session: {}", session.getUploadId()); + + try { + Path filePath = Paths.get(session.getFilePath()); + + if (!Files.exists(filePath)) { + throw new IOException("File not found on disk: " + filePath); + } + + long fileSize = Files.size(filePath); + String originalName = session.getFileName(); + String extension = extractFileExtension(originalName); + String storedName = filePath.getFileName().toString(); + + FileEntity fileEntity = FileEntity.builder() + .userId(session.getUserId()) + .originalFileName(originalName) + .storedFileName(storedName) + .filePath(session.getFilePath()) + .fileSize(fileSize) + .mimeType(session.getFileType()) + .fileExtension(extension) + .checksum(checksum) + .uploadSessionId(session.getUploadId()) + .status(FileStatus.ACTIVE) + .build(); + + FileEntity saved = fileEntityRepository.save(fileEntity); + log.info("FileEntity created successfully: {} (size: {} bytes)", + saved.getId(), fileSize); + + return saved; + + } catch (IOException e) { + log.error("Failed to create FileEntity for session {}: {}", + session.getUploadId(), e.getMessage(), e); + throw new RuntimeException("Failed to create file entity: " + e.getMessage(), e); + } + } + + /** + * Извлекает расширение файла из имени + */ + private String extractFileExtension(String fileName) { + int dotIndex = fileName.lastIndexOf('.'); + if (dotIndex > 0 && dotIndex < fileName.length() - 1) { + return fileName.substring(dotIndex + 1).toLowerCase(); + } + return ""; + } + + /** + * Получает FileEntity по ID + */ + @Transactional(readOnly = true) + public FileEntityResponse getById(String fileId) { + FileEntity fileEntity = fileEntityRepository.findById(fileId) + .orElseThrow(() -> new FileEntityNotFoundException(fileId)); + + return convertToResponse(fileEntity); + } + + /** + * Получает FileEntity по ID сессии загрузки + */ + @Transactional(readOnly = true) + public FileEntityResponse getByUploadSessionId(String uploadSessionId) { + FileEntity fileEntity = fileEntityRepository.findByUploadSessionId(uploadSessionId) + .orElseThrow(() -> new FileEntityNotFoundException( + "Not found for upload session: " + uploadSessionId)); + + return convertToResponse(fileEntity); + } + + /** + * Получает FileEntity по пути файла + */ + @Transactional(readOnly = true) + public FileEntityResponse getByFilePath(String filePath) { + FileEntity fileEntity = fileEntityRepository.findByFilePath(filePath) + .orElseThrow(() -> new FileEntityNotFoundException("Path: " + filePath)); + + return convertToResponse(fileEntity); + } + + /** + * Получает все файлы пользователя + */ + @Transactional(readOnly = true) + public FileListResponse getAllUserFiles(Long userId) { + List fileEntities = fileEntityRepository.findByUserIdAndStatus( + userId, FileStatus.ACTIVE); + + List files = fileEntities.stream() + .map(this::convertToResponse) + .collect(Collectors.toList()); + + long totalSize = fileEntities.stream() + .mapToLong(FileEntity::getFileSize) + .sum(); + + return FileListResponse.builder() + .files(files) + .totalCount(files.size()) + .totalSize(totalSize) + .formattedTotalSize(formatFileSize(totalSize)) + .page(1) + .pageSize(files.size()) + .build(); + } + + /** + * Получает файлы пользователя с пагинацией + */ + @Transactional(readOnly = true) + public FileListResponse getUserFiles(Long userId, int page, int pageSize) { + List allFiles = fileEntityRepository.findByUserIdAndStatus( + userId, FileStatus.ACTIVE); + + int start = (page - 1) * pageSize; + int end = Math.min(start + pageSize, allFiles.size()); + + if (start >= allFiles.size()) { + return FileListResponse.builder() + .files(List.of()) + .totalCount(allFiles.size()) + .totalSize(0) + .formattedTotalSize("0 B") + .page(page) + .pageSize(pageSize) + .build(); + } + + List pageFiles = allFiles.subList(start, end); + + List files = pageFiles.stream() + .map(this::convertToResponse) + .collect(Collectors.toList()); + + long totalSize = allFiles.stream() + .mapToLong(FileEntity::getFileSize) + .sum(); + + return FileListResponse.builder() + .files(files) + .totalCount(allFiles.size()) + .totalSize(totalSize) + .formattedTotalSize(formatFileSize(totalSize)) + .page(page) + .pageSize(pageSize) + .build(); + } + + /** + * Помечает файл как удаленный (мягкое удаление) + */ + @Transactional + public void markAsDeleted(String fileId) { + FileEntity fileEntity = fileEntityRepository.findById(fileId) + .orElseThrow(() -> new FileEntityNotFoundException(fileId)); + + fileEntity.setStatus(FileStatus.DELETED); + fileEntity.setUpdatedAt(LocalDateTime.now()); + fileEntityRepository.save(fileEntity); + + log.info("FileEntity marked as deleted: {}", fileId); + } + + /** + * Получает общий размер файлов пользователя + */ + @Transactional(readOnly = true) + public long getUserStorageUsed(Long userId) { + Long totalSize = fileEntityRepository.getTotalSizeByUserId(userId); + return totalSize != null ? totalSize : 0L; + } + + /** + * Поиск файлов пользователя по имени + */ + @Transactional(readOnly = true) + public List searchFiles(Long userId, String query) { + List files = fileEntityRepository.searchByFileName(userId, query); + + return files.stream() + .filter(f -> f.getStatus() == FileStatus.ACTIVE) + .map(this::convertToResponse) + .collect(Collectors.toList()); + } + + /** + * Проверяет, существует ли файл на диске + */ + private boolean checkFileExistsOnDisk(String filePath) { + try { + return Files.exists(Paths.get(filePath)); + } catch (Exception e) { + log.warn("Error checking file existence: {}", filePath, e); + return false; + } + } + + /** + * Конвертирует FileEntity в DTO + */ + private FileEntityResponse convertToResponse(FileEntity fileEntity) { + boolean existsOnDisk = checkFileExistsOnDisk(fileEntity.getFilePath()); + + return FileEntityResponse.builder() + .id(fileEntity.getId()) + .userId(fileEntity.getUserId()) + .originalFileName(fileEntity.getOriginalFileName()) + .storedFileName(fileEntity.getStoredFileName()) + .filePath(fileEntity.getFilePath()) + .fileSize(fileEntity.getFileSize()) + .mimeType(fileEntity.getMimeType()) + .fileExtension(fileEntity.getFileExtension()) + .checksum(fileEntity.getChecksum()) + .uploadSessionId(fileEntity.getUploadSessionId()) + .status(fileEntity.getStatus()) + .createdAt(fileEntity.getCreatedAt()) + .updatedAt(fileEntity.getUpdatedAt()) + .formattedSize(formatFileSize(fileEntity.getFileSize())) + .downloadUrl("/api/files/download/" + fileEntity.getId()) + .existsOnDisk(existsOnDisk) + .build(); + } + + /** + * Форматирует размер файла в читаемый вид + */ + private String formatFileSize(long size) { + if (size < 1024) { + return size + " B"; + } else if (size < 1024 * 1024) { + return String.format("%.1f KB", size / 1024.0); + } else if (size < 1024 * 1024 * 1024) { + return String.format("%.1f MB", size / (1024.0 * 1024.0)); + } else { + return String.format("%.1f GB", size / (1024.0 * 1024.0 * 1024.0)); + } + } +} \ No newline at end of file diff --git a/src/main/java/ru/soune/no_copy/service/file/FileUploadServiceImpl.java b/src/main/java/ru/soune/no_copy/service/file/FileUploadServiceImpl.java index 26bebb7..d01a2f0 100644 --- a/src/main/java/ru/soune/no_copy/service/file/FileUploadServiceImpl.java +++ b/src/main/java/ru/soune/no_copy/service/file/FileUploadServiceImpl.java @@ -3,6 +3,7 @@ package ru.soune.no_copy.service.file; import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; @@ -56,6 +57,9 @@ public class FileUploadServiceImpl implements FileUploadService { private Path storageRoot; + @Autowired + private FileEntityService fileEntityService; + @PostConstruct public void init() { try { @@ -328,14 +332,46 @@ public class FileUploadServiceImpl implements FileUploadService { } } +// private void assembleFile(FileUploadSession session) throws IOException { +// log.info("Starting file assembly for session: {} ({})", +// session.getUploadId(), session.getFileName()); +// +// Path finalFilePath = null; +// +// try { +// finalFilePath = prepareFinalFile(session); +// +// validateAllChunksExist(session); +// +// mergeChunksToFile(session, finalFilePath); +// +// validateFinalFile(session, finalFilePath); +// +// updateSessionOnSuccess(session, finalFilePath); +// +// cleanupSessionFiles(session); +// +// log.info("File assembly completed: {} -> {} ({} bytes)", +// session.getFileName(), finalFilePath, session.getFileSize()); +// +// } catch (Exception e) { +// if (finalFilePath != null) { +// Files.deleteIfExists(finalFilePath); +// } +// throw e; +// } +// } + private void assembleFile(FileUploadSession session) throws IOException { log.info("Starting file assembly for session: {} ({})", session.getUploadId(), session.getFileName()); Path finalFilePath = null; + String checksum = null; try { finalFilePath = prepareFinalFile(session); + log.info("Final file path: {}", finalFilePath); validateAllChunksExist(session); @@ -343,18 +379,48 @@ public class FileUploadServiceImpl implements FileUploadService { validateFinalFile(session, finalFilePath); - updateSessionOnSuccess(session, finalFilePath); + checksum = calculateChecksum(finalFilePath); + log.debug("File checksum calculated: {}", checksum); + + session.setFilePath(finalFilePath.toString()); + session.setChecksum(checksum); + session.setStatus(UploadStatus.COMPLETED); + session.setCompletedAt(LocalDateTime.now()); + sessionRepository.save(session); + log.info("Upload session updated to COMPLETED: {}", session.getUploadId()); + + try { + fileEntityService.createFromUploadSession(session, checksum); + log.info("FileEntity successfully created for session: {}", + session.getUploadId()); + } catch (Exception e) { + log.error("Failed to create FileEntity for session {} (file uploaded, but metadata not saved): {}", + session.getUploadId(), e.getMessage()); + } cleanupSessionFiles(session); - log.info("File assembly completed: {} -> {} ({} bytes)", + log.info("File assembly completed successfully: {} -> {} ({} bytes)", session.getFileName(), finalFilePath, session.getFileSize()); } catch (Exception e) { + log.error("File assembly failed for session {}: {}", + session.getUploadId(), e.getMessage(), e); + if (finalFilePath != null) { - Files.deleteIfExists(finalFilePath); + try { + Files.deleteIfExists(finalFilePath); + log.debug("Cleaned up partial file: {}", finalFilePath); + } catch (IOException ioException) { + log.warn("Failed to cleanup partial file: {}", finalFilePath, ioException); + } } - throw e; + + session.setStatus(UploadStatus.FAILED); + session.setLastError(e.getMessage()); + sessionRepository.save(session); + + throw new IOException("File assembly failed: " + e.getMessage(), e); } } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 2880f4c..08326e1 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -31,6 +31,13 @@ file: temp-ttl-hours: ${TEMP_TTL_HOURS:72} # 3 дня session-expiry-hours: ${SESSION_EXPIRY_HOURS:24} +security: + cors: + allowed-origins: "*" + allowed-methods: GET,POST,PUT,DELETE,OPTIONS + allowed-headers: "*" + allow-credentials: true + server: port: ${SERVER_PORT:8080}