diff --git a/.env.example b/.env.example index 9539c94a..e9860beb 100644 --- a/.env.example +++ b/.env.example @@ -28,4 +28,4 @@ AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= # S3 bucket -CLOUD_AWS_S3_BUCKET= \ No newline at end of file +CLOUD_AWS_S3_BUCKET= diff --git a/build.gradle b/build.gradle index 8420a56e..b549fca8 100644 --- a/build.gradle +++ b/build.gradle @@ -39,9 +39,9 @@ dependencies { testRuntimeOnly 'org.junit.platform:junit-platform-launcher' testAnnotationProcessor 'org.projectlombok:lombok' - // AWS - implementation platform('software.amazon.awssdk:bom:2.25.60') - implementation 'software.amazon.awssdk:s3' + // AWS + implementation platform('software.amazon.awssdk:bom:2.27.21') + implementation 'software.amazon.awssdk:s3' } tasks.named('test') { @@ -50,4 +50,4 @@ tasks.named('test') { tasks.named('bootJar') { archiveFileName = 'slatto.jar' -} \ No newline at end of file +} diff --git a/src/main/java/com/slatto/domain/project/controller/ProjectFileController.java b/src/main/java/com/slatto/domain/project/controller/ProjectFileController.java new file mode 100644 index 00000000..fc2f06cd --- /dev/null +++ b/src/main/java/com/slatto/domain/project/controller/ProjectFileController.java @@ -0,0 +1,136 @@ +package com.slatto.domain.project.controller; + +import com.slatto.domain.project.dto.ProjectFileDownloadResponse; +import com.slatto.domain.project.dto.ProjectFileListResponse; +import com.slatto.domain.project.dto.ProjectFileResponse; +import com.slatto.domain.project.dto.ProjectFileUpdateRequest; +import com.slatto.domain.project.dto.ProjectFileUploadRequest; +import com.slatto.domain.project.service.ProjectFileService; +import com.slatto.global.response.ApiResponse; +import com.slatto.global.response.code.CommonSuccessCode; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.core.io.InputStreamResource; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import java.nio.charset.StandardCharsets; + +@Tag(name = "Project File", description = "프로젝트 파일 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/projects/{projectId}/files") +public class ProjectFileController { + + private final ProjectFileService projectFileService; + + @Operation(summary = "프로젝트 파일 목록 조회") + @GetMapping + public ApiResponse getProjectFiles( + @AuthenticationPrincipal Long currentUserId, + @PathVariable Long projectId, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) Long cursor, + @RequestParam(defaultValue = "20") int size + ) { + ProjectFileListResponse response = projectFileService.getProjectFiles( + projectId, + currentUserId, + keyword, + cursor, + size + ); + + return ApiResponse.success(CommonSuccessCode.OK, response); + } + + @Operation(summary = "프로젝트 파일 업로드") + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @ResponseStatus(HttpStatus.CREATED) + public ApiResponse uploadProjectFile( + @AuthenticationPrincipal Long currentUserId, + @PathVariable Long projectId, + @Valid @RequestPart("request") ProjectFileUploadRequest request, + @RequestPart("file") MultipartFile file + ) { + ProjectFileResponse response = projectFileService.uploadProjectFile( + projectId, + currentUserId, + request, + file + ); + + return ApiResponse.success(CommonSuccessCode.CREATED, response); + } + + @Operation(summary = "프로젝트 파일 수정") + @PatchMapping("/{fileId}") + public ApiResponse updateProjectFile( + @AuthenticationPrincipal Long currentUserId, + @PathVariable Long projectId, + @PathVariable Long fileId, + @Valid @RequestBody ProjectFileUpdateRequest request + ) { + ProjectFileResponse response = projectFileService.updateProjectFile( + projectId, + fileId, + currentUserId, + request + ); + + return ApiResponse.success(CommonSuccessCode.OK, response); + } + + @Operation(summary = "프로젝트 파일 삭제") + @DeleteMapping("/{fileId}") + public ApiResponse deleteProjectFile( + @AuthenticationPrincipal Long currentUserId, + @PathVariable Long projectId, + @PathVariable Long fileId + ) { + projectFileService.deleteProjectFile(projectId, fileId, currentUserId); + + return ApiResponse.success(CommonSuccessCode.OK, null); + } + + @Operation(summary = "프로젝트 파일 다운로드") + @GetMapping("/{fileId}/download") + public ResponseEntity downloadProjectFile( + @AuthenticationPrincipal Long currentUserId, + @PathVariable Long projectId, + @PathVariable Long fileId + ) { + ProjectFileDownloadResponse response = projectFileService.downloadProjectFile( + projectId, + fileId, + currentUserId + ); + + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType(response.getContentType())) + .contentLength(response.getFileSize()) + .headers(headers -> headers.setContentDisposition( + ContentDisposition.attachment() + .filename(response.getFileName(), StandardCharsets.UTF_8) + .build() + )) + .body(new InputStreamResource(response.getInputStream())); + } +} diff --git a/src/main/java/com/slatto/domain/project/converter/ProjectConverter.java b/src/main/java/com/slatto/domain/project/converter/ProjectConverter.java index 195b05bd..d349cb7f 100644 --- a/src/main/java/com/slatto/domain/project/converter/ProjectConverter.java +++ b/src/main/java/com/slatto/domain/project/converter/ProjectConverter.java @@ -26,7 +26,6 @@ public Project toProject(Users ownerUser, ProjectCreateRequest request) { ownerUser, request.getTitle(), request.getType(), - request.getCustomTypeName(), request.getLengthType(), request.getDescription(), request.getEndDate(), @@ -84,7 +83,7 @@ public ProjectListResponse.ProjectSummary toSummary( public ProjectDetailResponse toDetailResponse( Project project, ProjectMember currentMember, - List myRoles, + List roleNames, Long memberCount ) { boolean admin = currentMember.isAdmin(); @@ -102,7 +101,7 @@ public ProjectDetailResponse toDetailResponse( .kind(project.getKind()) .owner(toOwnerSummary(project.getOwnerUser())) .myPermission(currentMember.getPermission()) - .myRoles(myRoles) + .roleNames(roleNames) .memberCount(memberCount) .canEdit(admin) .canDelete(admin) diff --git a/src/main/java/com/slatto/domain/project/dto/ProjectCreateRequest.java b/src/main/java/com/slatto/domain/project/dto/ProjectCreateRequest.java index 147beb9d..e551536a 100644 --- a/src/main/java/com/slatto/domain/project/dto/ProjectCreateRequest.java +++ b/src/main/java/com/slatto/domain/project/dto/ProjectCreateRequest.java @@ -26,9 +26,6 @@ public class ProjectCreateRequest { @NotNull(message = "프로젝트 유형은 필수입니다.") private CategoryName type; - @Size(max = 100, message = "커스텀 유형명은 최대 100자까지 입력할 수 있습니다.") - private String customTypeName; - @NotNull(message = "영상 길이는 필수입니다.") private LengthType lengthType; diff --git a/src/main/java/com/slatto/domain/project/dto/ProjectDetailResponse.java b/src/main/java/com/slatto/domain/project/dto/ProjectDetailResponse.java index a527f79e..d2d6bd03 100644 --- a/src/main/java/com/slatto/domain/project/dto/ProjectDetailResponse.java +++ b/src/main/java/com/slatto/domain/project/dto/ProjectDetailResponse.java @@ -41,7 +41,7 @@ public class ProjectDetailResponse { private Permission myPermission; - private List myRoles; + private List roleNames; private Long memberCount; diff --git a/src/main/java/com/slatto/domain/project/dto/ProjectFileDownloadResponse.java b/src/main/java/com/slatto/domain/project/dto/ProjectFileDownloadResponse.java new file mode 100644 index 00000000..48c9f653 --- /dev/null +++ b/src/main/java/com/slatto/domain/project/dto/ProjectFileDownloadResponse.java @@ -0,0 +1,19 @@ +package com.slatto.domain.project.dto; + +import lombok.Builder; +import lombok.Getter; + +import java.io.InputStream; + +@Getter +@Builder +public class ProjectFileDownloadResponse { + + private String fileName; + + private String contentType; + + private Long fileSize; + + private InputStream inputStream; +} diff --git a/src/main/java/com/slatto/domain/project/dto/ProjectFileListResponse.java b/src/main/java/com/slatto/domain/project/dto/ProjectFileListResponse.java new file mode 100644 index 00000000..ac11cc59 --- /dev/null +++ b/src/main/java/com/slatto/domain/project/dto/ProjectFileListResponse.java @@ -0,0 +1,17 @@ +package com.slatto.domain.project.dto; + +import lombok.Builder; +import lombok.Getter; + +import java.util.List; + +@Getter +@Builder +public class ProjectFileListResponse { + + private List items; + + private Long nextCursor; + + private Boolean hasNext; +} diff --git a/src/main/java/com/slatto/domain/project/dto/ProjectFileResponse.java b/src/main/java/com/slatto/domain/project/dto/ProjectFileResponse.java new file mode 100644 index 00000000..3d0ea940 --- /dev/null +++ b/src/main/java/com/slatto/domain/project/dto/ProjectFileResponse.java @@ -0,0 +1,40 @@ +package com.slatto.domain.project.dto; + +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder +public class ProjectFileResponse { + + private Long id; + + private String fileName; + + private String description; + + private String contentType; + + private Long fileSize; + + private Boolean isPinned; + + private Boolean isFinal; + + private UploaderSummary uploader; + + private LocalDateTime createdAt; + + private LocalDateTime updatedAt; + + @Getter + @Builder + public static class UploaderSummary { + + private Long id; + + private String nickname; + } +} diff --git a/src/main/java/com/slatto/domain/project/dto/ProjectFileUpdateRequest.java b/src/main/java/com/slatto/domain/project/dto/ProjectFileUpdateRequest.java new file mode 100644 index 00000000..6039129d --- /dev/null +++ b/src/main/java/com/slatto/domain/project/dto/ProjectFileUpdateRequest.java @@ -0,0 +1,20 @@ +package com.slatto.domain.project.dto; + +import jakarta.validation.constraints.Size; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ProjectFileUpdateRequest { + + @Size(max = 255, message = "파일명은 최대 255자까지 입력할 수 있습니다.") + private String fileName; + + private String description; + + private Boolean isPinned; + + private Boolean isFinal; +} diff --git a/src/main/java/com/slatto/domain/project/dto/ProjectFileUploadRequest.java b/src/main/java/com/slatto/domain/project/dto/ProjectFileUploadRequest.java new file mode 100644 index 00000000..c4a43241 --- /dev/null +++ b/src/main/java/com/slatto/domain/project/dto/ProjectFileUploadRequest.java @@ -0,0 +1,22 @@ +package com.slatto.domain.project.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ProjectFileUploadRequest { + + @NotBlank(message = "파일명은 필수입니다.") + @Size(max = 255, message = "파일명은 최대 255자까지 입력할 수 있습니다.") + private String fileName; + + private String description; + + private Boolean isPinned; + + private Boolean isFinal; +} diff --git a/src/main/java/com/slatto/domain/project/dto/ProjectUpdateRequest.java b/src/main/java/com/slatto/domain/project/dto/ProjectUpdateRequest.java index 0d1836bc..7b24de0f 100644 --- a/src/main/java/com/slatto/domain/project/dto/ProjectUpdateRequest.java +++ b/src/main/java/com/slatto/domain/project/dto/ProjectUpdateRequest.java @@ -24,9 +24,6 @@ public class ProjectUpdateRequest { @NotNull(message = "프로젝트 유형은 필수입니다.") private CategoryName type; - @Size(max = 100, message = "커스텀 유형명은 최대 100자까지 입력할 수 있습니다.") - private String customTypeName; - @NotNull(message = "영상 길이는 필수입니다.") private LengthType lengthType; diff --git a/src/main/java/com/slatto/domain/project/entity/Project.java b/src/main/java/com/slatto/domain/project/entity/Project.java index 06e67592..49b9a675 100644 --- a/src/main/java/com/slatto/domain/project/entity/Project.java +++ b/src/main/java/com/slatto/domain/project/entity/Project.java @@ -40,9 +40,6 @@ public class Project extends BaseEntity { @Column(name = "type", nullable = false) private CategoryName type; - @Column(name = "custom_type_name", nullable = true, length = 100) - private String customTypeName; - @Enumerated(EnumType.STRING) @Column(name = "length_type", nullable = true) private LengthType lengthType; @@ -74,7 +71,6 @@ private Project( Users ownerUser, String title, CategoryName type, - String customTypeName, LengthType lengthType, String description, LocalDate endDate, @@ -87,7 +83,6 @@ private Project( this.ownerUser = ownerUser; this.title = title; this.type = type; - this.customTypeName = customTypeName; this.lengthType = lengthType; this.description = description; this.startDate = startDate; @@ -101,7 +96,6 @@ public static Project create( Users ownerUser, String title, CategoryName type, - String customTypeName, LengthType lengthType, String description, LocalDate endDate, @@ -112,7 +106,6 @@ public static Project create( ownerUser, title, type, - customTypeName, lengthType, description, endDate, @@ -124,7 +117,6 @@ public static Project create( public void updateInfo( String title, CategoryName type, - String customTypeName, LengthType lengthType, String description, LocalDate endDate, @@ -135,7 +127,6 @@ public void updateInfo( this.title = title; this.type = type; - this.customTypeName = customTypeName; this.lengthType = lengthType; this.description = description; this.endDate = endDate; diff --git a/src/main/java/com/slatto/domain/project/entity/ProjectFile.java b/src/main/java/com/slatto/domain/project/entity/ProjectFile.java index 1fd4423a..a3a9c612 100644 --- a/src/main/java/com/slatto/domain/project/entity/ProjectFile.java +++ b/src/main/java/com/slatto/domain/project/entity/ProjectFile.java @@ -31,21 +31,15 @@ public class ProjectFile extends BaseEntity { @Column(name = "file_name", nullable = false, length = 255) private String fileName; - @Column(name = "file_url", nullable = false, length = 500) - private String fileUrl; - - @Column(name = "content_type", nullable = false, length = 50) + @Column(name = "content_type", nullable = false, length = 150) private String contentType; - @Column(name = "file_size", nullable = true) + @Column(name = "file_size", nullable = false) private Long fileSize; @Column(name = "description", nullable = true, columnDefinition = "TEXT") private String description; - @Column(name = "is_pinned", nullable = false) - private Boolean isPinned = false; - @Column(name = "pinned_at", nullable = true) private LocalDateTime pinnedAt; @@ -57,4 +51,80 @@ public class ProjectFile extends BaseEntity { @Column(name = "deleted_at", nullable = true) private LocalDateTime deletedAt; -} \ No newline at end of file + + private ProjectFile( + Project project, + Users uploader, + String fileName, + String contentType, + Long fileSize, + String description, + Boolean isFinal, + String storageKey + ) { + this.project = project; + this.uploader = uploader; + this.fileName = fileName; + this.contentType = contentType; + this.fileSize = fileSize; + this.description = description; + this.isFinal = Boolean.TRUE.equals(isFinal); + this.storageKey = storageKey; + } + + public static ProjectFile create( + Project project, + Users uploader, + String fileName, + String contentType, + Long fileSize, + String description, + Boolean isFinal, + String storageKey + ) { + return new ProjectFile( + project, + uploader, + fileName, + contentType, + fileSize, + description, + isFinal, + storageKey + ); + } + + public boolean isUploadedBy(Long userId) { + return uploader.getId().equals(userId); + } + + public boolean isPinned() { + return pinnedAt != null; + } + + public void updateFileName(String fileName) { + this.fileName = fileName; + } + + public void updateDescription(String description) { + this.description = description; + } + + public void changeFinal(boolean isFinal) { + this.isFinal = isFinal; + } + + public void pin() { + if (pinnedAt == null) { + this.pinnedAt = LocalDateTime.now(); + } + } + + public void unpin() { + this.pinnedAt = null; + } + + public void delete() { + this.deletedAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/com/slatto/domain/project/exception/ProjectErrorCode.java b/src/main/java/com/slatto/domain/project/exception/ProjectErrorCode.java index 842e013e..e6e8fb91 100644 --- a/src/main/java/com/slatto/domain/project/exception/ProjectErrorCode.java +++ b/src/main/java/com/slatto/domain/project/exception/ProjectErrorCode.java @@ -16,6 +16,10 @@ public enum ProjectErrorCode implements BaseCode { PROJECT_INVITATION_EXPIRED(HttpStatus.BAD_REQUEST, "PROJECT_INVITATION_EXPIRED400", "만료된 초대 링크입니다."), PROJECT_INVITATION_ALREADY_ACCEPTED(HttpStatus.CONFLICT, "PROJECT_INVITATION409", "이미 수락된 초대 링크입니다."), PROJECT_NOTICE_NOT_FOUND(HttpStatus.NOT_FOUND, "PROJECT_NOTICE404", "프로젝트 공지를 찾을 수 없습니다."), + PROJECT_FILE_NOT_FOUND(HttpStatus.NOT_FOUND, "PROJECT_FILE404", "프로젝트 파일을 찾을 수 없습니다."), + PROJECT_FILE_EMPTY(HttpStatus.BAD_REQUEST, "PROJECT_FILE_EMPTY400", "업로드할 파일이 비어 있습니다."), + PROJECT_FILE_INVALID_TYPE(HttpStatus.BAD_REQUEST, "PROJECT_FILE_INVALID_TYPE400", "지원하지 않는 파일 형식입니다."), + PROJECT_FILE_SIZE_EXCEEDED(HttpStatus.BAD_REQUEST, "PROJECT_FILE_SIZE400", "프로젝트 파일은 최대 100MB까지 업로드할 수 있습니다."), PROJECT_ACCESS_DENIED(HttpStatus.FORBIDDEN, "PROJECT403", "프로젝트 접근 권한이 없습니다."), PROJECT_ADMIN_REQUIRED(HttpStatus.FORBIDDEN, "PROJECT_ADMIN403", "프로젝트 관리자 권한이 필요합니다."), PROJECT_MEMBER_ALREADY_EXISTS(HttpStatus.CONFLICT, "PROJECT_MEMBER409", "이미 프로젝트에 참여 중인 멤버입니다."), diff --git a/src/main/java/com/slatto/domain/project/repository/ProjectFileRepository.java b/src/main/java/com/slatto/domain/project/repository/ProjectFileRepository.java new file mode 100644 index 00000000..140431a5 --- /dev/null +++ b/src/main/java/com/slatto/domain/project/repository/ProjectFileRepository.java @@ -0,0 +1,49 @@ +package com.slatto.domain.project.repository; + +import com.slatto.domain.project.entity.ProjectFile; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface ProjectFileRepository extends JpaRepository { + + @Query(""" + select pf + from ProjectFile pf + join fetch pf.uploader u + where pf.project.id = :projectId + and pf.deletedAt is null + and (:keyword is null + or :keyword = '' + or lower(pf.fileName) like lower(concat('%', :keyword, '%'))) + and (:cursor is null or pf.id < :cursor) + order by + case when pf.pinnedAt is null then 1 else 0 end asc, + pf.pinnedAt desc, + pf.id desc + """) + List findActiveFilesByCursor( + @Param("projectId") Long projectId, + @Param("keyword") String keyword, + @Param("cursor") Long cursor, + Pageable pageable + ); + + @Query(""" + select pf + from ProjectFile pf + join fetch pf.project p + join fetch pf.uploader u + where p.id = :projectId + and pf.id = :fileId + and pf.deletedAt is null + """) + Optional findActiveFileByProjectIdAndFileId( + @Param("projectId") Long projectId, + @Param("fileId") Long fileId + ); +} diff --git a/src/main/java/com/slatto/domain/project/service/ProjectFileService.java b/src/main/java/com/slatto/domain/project/service/ProjectFileService.java new file mode 100644 index 00000000..09ea1f89 --- /dev/null +++ b/src/main/java/com/slatto/domain/project/service/ProjectFileService.java @@ -0,0 +1,319 @@ +package com.slatto.domain.project.service; + +import com.slatto.domain.project.dto.ProjectFileDownloadResponse; +import com.slatto.domain.project.dto.ProjectFileResponse; +import com.slatto.domain.project.dto.ProjectFileListResponse; +import com.slatto.domain.project.dto.ProjectFileUpdateRequest; +import com.slatto.domain.project.dto.ProjectFileUploadRequest; +import com.slatto.domain.project.entity.Project; +import com.slatto.domain.project.entity.ProjectFile; +import com.slatto.domain.project.entity.ProjectMember; +import com.slatto.domain.project.exception.ProjectErrorCode; +import com.slatto.domain.project.repository.ProjectFileRepository; +import com.slatto.domain.user.entity.Users; +import com.slatto.global.exception.BaseException; +import com.slatto.global.response.code.CommonErrorCode; +import com.slatto.global.storage.StorageService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +@Service +@Slf4j +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ProjectFileService { + + private static final long MAX_FILE_SIZE = 100L * 1024 * 1024; + private static final int DEFAULT_PAGE_SIZE = 20; + private static final int MAX_PAGE_SIZE = 50; + private static final String STORAGE_KEY_FORMAT = "projects/%d/files/%s.%s"; + private static final Map> ALLOWED_EXTENSIONS_BY_CONTENT_TYPE = Map.of( + "application/pdf", Set.of("pdf"), + "image/jpeg", Set.of("jpg", "jpeg"), + "image/png", Set.of("png"), + "application/msword", Set.of("doc"), + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", Set.of("docx") + ); + + private final ProjectFileRepository projectFileRepository; + private final ProjectAccessValidator projectAccessValidator; + private final StorageService storageService; + + public ProjectFileListResponse getProjectFiles( + Long projectId, + Long currentUserId, + String keyword, + Long cursor, + int size + ) { + projectAccessValidator.getProjectOrThrow(projectId); + projectAccessValidator.validateProjectAccess(projectId, currentUserId); + + int pageSize = normalizePageSize(size); + List projectFiles = projectFileRepository.findActiveFilesByCursor( + projectId, + keyword, + cursor, + PageRequest.of(0, pageSize + 1) + ); + + boolean hasNext = projectFiles.size() > pageSize; + List items = projectFiles.stream() + .limit(pageSize) + .map(this::toResponse) + .toList(); + + Long nextCursor = hasNext && !items.isEmpty() + ? items.get(items.size() - 1).getId() + : null; + + return ProjectFileListResponse.builder() + .items(items) + .nextCursor(nextCursor) + .hasNext(hasNext) + .build(); + } + + @Transactional + public ProjectFileResponse uploadProjectFile( + Long projectId, + Long currentUserId, + ProjectFileUploadRequest request, + MultipartFile file + ) { + Project project = projectAccessValidator.getProjectOrThrow(projectId); + ProjectMember currentMember = projectAccessValidator.getCurrentMemberOrThrow(projectId, currentUserId); + + validateFile(file, request.getFileName()); + + String contentType = file.getContentType(); + String storageKey = createStorageKey(projectId, request.getFileName()); + storageService.upload(file, storageKey); + registerStorageCleanupOnRollback(storageKey); + + ProjectFile projectFile = ProjectFile.create( + project, + currentMember.getUser(), + request.getFileName(), + contentType, + file.getSize(), + request.getDescription(), + request.getIsFinal(), + storageKey + ); + + if (Boolean.TRUE.equals(request.getIsPinned())) { + projectFile.pin(); + } + + ProjectFile savedFile = projectFileRepository.save(projectFile); + + return toResponse(savedFile); + } + + private void registerStorageCleanupOnRollback(String storageKey) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + return; + } + + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCompletion(int status) { + if (status == STATUS_COMMITTED) { + return; + } + + deleteUploadedFileQuietly(storageKey); + } + }); + } + + private void deleteUploadedFileQuietly(String storageKey) { + try { + storageService.delete(storageKey); + } catch (RuntimeException exception) { + log.warn("Failed to clean up S3 object after project file upload rollback. storageKey={}", storageKey, exception); + } + } + + @Transactional + public ProjectFileResponse updateProjectFile( + Long projectId, + Long fileId, + Long currentUserId, + ProjectFileUpdateRequest request + ) { + projectAccessValidator.getProjectOrThrow(projectId); + ProjectMember currentMember = projectAccessValidator.getCurrentMemberOrThrow(projectId, currentUserId); + ProjectFile projectFile = getActiveFileOrThrow(projectId, fileId); + + validateFileEditable(projectFile, currentMember, currentUserId); + updateProjectFileInfo(projectFile, request); + + return toResponse(projectFile); + } + + @Transactional + public void deleteProjectFile(Long projectId, Long fileId, Long currentUserId) { + projectAccessValidator.getProjectOrThrow(projectId); + ProjectMember currentMember = projectAccessValidator.getCurrentMemberOrThrow(projectId, currentUserId); + ProjectFile projectFile = getActiveFileOrThrow(projectId, fileId); + + validateFileEditable(projectFile, currentMember, currentUserId); + + projectFile.delete(); + } + + public ProjectFileDownloadResponse downloadProjectFile( + Long projectId, + Long fileId, + Long currentUserId + ) { + projectAccessValidator.getProjectOrThrow(projectId); + projectAccessValidator.validateProjectAccess(projectId, currentUserId); + + ProjectFile projectFile = getActiveFileOrThrow(projectId, fileId); + + return ProjectFileDownloadResponse.builder() + .fileName(projectFile.getFileName()) + .contentType(projectFile.getContentType()) + .fileSize(projectFile.getFileSize()) + .inputStream(storageService.download(projectFile.getStorageKey())) + .build(); + } + + private void updateProjectFileInfo(ProjectFile projectFile, ProjectFileUpdateRequest request) { + if (request.getFileName() != null) { + validateFileName(request.getFileName(), projectFile.getContentType()); + projectFile.updateFileName(request.getFileName()); + } + + if (request.getDescription() != null) { + projectFile.updateDescription(request.getDescription()); + } + + if (request.getIsFinal() != null) { + projectFile.changeFinal(request.getIsFinal()); + } + + if (request.getIsPinned() == null) { + return; + } + + if (Boolean.TRUE.equals(request.getIsPinned())) { + projectFile.pin(); + return; + } + + projectFile.unpin(); + } + + private void validateFile(MultipartFile file, String fileName) { + if (file == null || file.isEmpty()) { + throw new BaseException(ProjectErrorCode.PROJECT_FILE_EMPTY); + } + + if (file.getSize() > MAX_FILE_SIZE) { + throw new BaseException(ProjectErrorCode.PROJECT_FILE_SIZE_EXCEEDED); + } + + String contentType = file.getContentType(); + String extension = getExtension(fileName); + if (!isAllowedFileType(contentType, extension)) { + throw new BaseException(ProjectErrorCode.PROJECT_FILE_INVALID_TYPE); + } + } + + private void validateFileName(String fileName, String contentType) { + if (!StringUtils.hasText(fileName)) { + throw new BaseException(CommonErrorCode.BAD_REQUEST); + } + + if (!isAllowedFileType(contentType, getExtension(fileName))) { + throw new BaseException(ProjectErrorCode.PROJECT_FILE_INVALID_TYPE); + } + } + + private boolean isAllowedFileType(String contentType, String extension) { + if (!StringUtils.hasText(contentType) || !StringUtils.hasText(extension)) { + return false; + } + + return ALLOWED_EXTENSIONS_BY_CONTENT_TYPE + .getOrDefault(contentType.toLowerCase(Locale.ROOT), Set.of()) + .contains(extension); + } + + private String createStorageKey(Long projectId, String fileName) { + String extension = getExtension(fileName); + + return STORAGE_KEY_FORMAT.formatted(projectId, UUID.randomUUID(), extension); + } + + private String getExtension(String fileName) { + String extension = StringUtils.getFilenameExtension(fileName); + if (!StringUtils.hasText(extension)) { + return ""; + } + + return extension.toLowerCase(Locale.ROOT); + } + + private ProjectFile getActiveFileOrThrow(Long projectId, Long fileId) { + return projectFileRepository.findActiveFileByProjectIdAndFileId(projectId, fileId) + .orElseThrow(() -> new BaseException(ProjectErrorCode.PROJECT_FILE_NOT_FOUND)); + } + + private void validateFileEditable( + ProjectFile projectFile, + ProjectMember currentMember, + Long currentUserId + ) { + if (currentMember.isAdmin() || projectFile.isUploadedBy(currentUserId)) { + return; + } + + throw new BaseException(ProjectErrorCode.PROJECT_ACCESS_DENIED); + } + + private int normalizePageSize(int size) { + if (size <= 0) { + return DEFAULT_PAGE_SIZE; + } + + return Math.min(size, MAX_PAGE_SIZE); + } + + private ProjectFileResponse toResponse(ProjectFile projectFile) { + Users uploader = projectFile.getUploader(); + + return ProjectFileResponse.builder() + .id(projectFile.getId()) + .fileName(projectFile.getFileName()) + .description(projectFile.getDescription()) + .contentType(projectFile.getContentType()) + .fileSize(projectFile.getFileSize()) + .isPinned(projectFile.isPinned()) + .isFinal(projectFile.getIsFinal()) + .uploader(ProjectFileResponse.UploaderSummary.builder() + .id(uploader.getId()) + .nickname(uploader.getNickname()) + .build()) + .createdAt(projectFile.getCreatedAt()) + .updatedAt(projectFile.getUpdatedAt()) + .build(); + } +} diff --git a/src/main/java/com/slatto/domain/project/service/ProjectService.java b/src/main/java/com/slatto/domain/project/service/ProjectService.java index 46740318..f8907b02 100644 --- a/src/main/java/com/slatto/domain/project/service/ProjectService.java +++ b/src/main/java/com/slatto/domain/project/service/ProjectService.java @@ -103,7 +103,7 @@ public ProjectDetailResponse getProject(Long projectId, Long currentUserId) { Project project = projectAccessValidator.getProjectOrThrow(projectId); ProjectMember currentMember = projectAccessValidator.getCurrentMemberOrThrow(projectId, currentUserId); - List myRoles = projectUserRoleRepository.findAllByProjectMemberId(currentMember.getId()) + List roleNames = projectUserRoleRepository.findAllByProjectMemberId(currentMember.getId()) .stream() .map(ProjectUserRole::getRoleName) .toList(); @@ -111,7 +111,7 @@ public ProjectDetailResponse getProject(Long projectId, Long currentUserId) { return projectConverter.toDetailResponse( project, currentMember, - myRoles, + roleNames, countActiveMembers(project) ); } @@ -128,7 +128,6 @@ public ProjectResponse updateProject( project.updateInfo( request.getTitle(), request.getType(), - request.getCustomTypeName(), request.getLengthType(), request.getDescription(), request.getEndDate(), diff --git a/src/main/java/com/slatto/global/config/S3Config.java b/src/main/java/com/slatto/global/config/S3Config.java index e0b35e1d..ab857c16 100644 --- a/src/main/java/com/slatto/global/config/S3Config.java +++ b/src/main/java/com/slatto/global/config/S3Config.java @@ -4,22 +4,14 @@ import org.springframework.context.annotation.Configuration; import software.amazon.awssdk.regions.Region; import software.amazon.awssdk.services.s3.S3Client; -import software.amazon.awssdk.services.s3.presigner.S3Presigner; @Configuration public class S3Config { - @Bean - public S3Client s3Client() { - return S3Client.builder() - .region(Region.AP_NORTHEAST_2) - .build(); - } - - @Bean - public S3Presigner s3Presigner() { - return S3Presigner.builder() - .region(Region.AP_NORTHEAST_2) - .build(); - } -} \ No newline at end of file + @Bean + public S3Client s3Client() { + return S3Client.builder() + .region(Region.AP_NORTHEAST_2) + .build(); + } +} diff --git a/src/main/java/com/slatto/global/s3/S3Controller.java b/src/main/java/com/slatto/global/s3/S3Controller.java deleted file mode 100644 index e3196c4f..00000000 --- a/src/main/java/com/slatto/global/s3/S3Controller.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.slatto.global.s3; - -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; -import org.springframework.web.multipart.MultipartFile; - -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/s3") -public class S3Controller { - - private final S3Service s3Service; - - @PostMapping(value = "/upload", consumes = "multipart/form-data") - public String upload( - @RequestParam("file") MultipartFile file - ) { - return s3Service.upload(file); - } -} \ No newline at end of file diff --git a/src/main/java/com/slatto/global/s3/S3Service.java b/src/main/java/com/slatto/global/s3/S3Service.java deleted file mode 100644 index 61c37b6e..00000000 --- a/src/main/java/com/slatto/global/s3/S3Service.java +++ /dev/null @@ -1,102 +0,0 @@ -package com.slatto.global.s3; - -import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Service; -import org.springframework.web.multipart.MultipartFile; -import software.amazon.awssdk.core.sync.RequestBody; -import software.amazon.awssdk.services.s3.S3Client; -import software.amazon.awssdk.services.s3.model.GetObjectRequest; -import software.amazon.awssdk.services.s3.model.PutObjectRequest; -import software.amazon.awssdk.services.s3.presigner.S3Presigner; -import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; - -import java.io.IOException; -import java.io.InputStream; -import java.time.Duration; -import java.util.UUID; - -@Service -@RequiredArgsConstructor -public class S3Service { - - private final S3Client s3Client; - private final S3Presigner s3Presigner; - - @Value("${cloud.aws.s3.bucket}") - private String bucket; - - public String upload(MultipartFile file) { - - validateFileSize(file); - - String key = createKey(file); - - try (InputStream inputStream = file.getInputStream()) { - - PutObjectRequest request = PutObjectRequest.builder() - .bucket(bucket) - .key(key) - .contentType(file.getContentType()) - .build(); - - s3Client.putObject( - request, - RequestBody.fromInputStream( - inputStream, - file.getSize() - ) - ); - - } catch (IOException e) { - throw new RuntimeException("파일 업로드에 실패했습니다."); - } - - return createPresignedUrl(key); - } - - private String createPresignedUrl(String key) { - - GetObjectRequest getObjectRequest = - GetObjectRequest.builder() - .bucket(bucket) - .key(key) - .build(); - - GetObjectPresignRequest presignRequest = - GetObjectPresignRequest.builder() - .signatureDuration(Duration.ofMinutes(10)) - .getObjectRequest(getObjectRequest) - .build(); - - return s3Presigner.presignGetObject(presignRequest) - .url() - .toString(); - } - - private String createKey(MultipartFile file) { - - String extension = ""; - - String filename = file.getOriginalFilename(); - - if (filename != null && filename.contains(".")) { - extension = filename.substring(filename.lastIndexOf(".")); - } - - return "images/" - + UUID.randomUUID() - + extension; - } - - private void validateFileSize(MultipartFile file) { - - long maxSize = 10 * 1024 * 1024; - - if (file.getSize() > maxSize) { - throw new IllegalArgumentException( - "파일 크기는 10MB 이하만 가능합니다." - ); - } - } -} \ No newline at end of file diff --git a/src/main/java/com/slatto/global/storage/S3StorageService.java b/src/main/java/com/slatto/global/storage/S3StorageService.java new file mode 100644 index 00000000..9ed0111c --- /dev/null +++ b/src/main/java/com/slatto/global/storage/S3StorageService.java @@ -0,0 +1,75 @@ +package com.slatto.global.storage; + +import com.slatto.global.exception.BaseException; +import com.slatto.global.response.code.CommonErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.core.ResponseInputStream; +import software.amazon.awssdk.core.exception.SdkException; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectResponse; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +import java.io.IOException; +import java.io.InputStream; + +@Slf4j +@Service +@RequiredArgsConstructor +public class S3StorageService implements StorageService { + + private final S3Client s3Client; + + @Value("${cloud.aws.s3.bucket}") + private String bucket; + + @Override + public void upload(MultipartFile file, String storageKey) { + try (InputStream inputStream = file.getInputStream()) { + PutObjectRequest request = PutObjectRequest.builder() + .bucket(bucket) + .key(storageKey) + .contentType(file.getContentType()) + .contentLength(file.getSize()) + .build(); + + s3Client.putObject(request, RequestBody.fromInputStream(inputStream, file.getSize())); + } catch (IOException | SdkException exception) { + log.warn("S3 file upload failed. storageKey={}", storageKey, exception); + throw new BaseException(CommonErrorCode.INTERNAL_SERVER_ERROR); + } + } + + @Override + public ResponseInputStream download(String storageKey) { + try { + GetObjectRequest request = GetObjectRequest.builder() + .bucket(bucket) + .key(storageKey) + .build(); + + return s3Client.getObject(request); + } catch (SdkException exception) { + log.warn("S3 file download failed. storageKey={}", storageKey, exception); + throw new BaseException(CommonErrorCode.INTERNAL_SERVER_ERROR); + } + } + + @Override + public void delete(String storageKey) { + try { + s3Client.deleteObject(request -> request + .bucket(bucket) + .key(storageKey) + ); + } catch (SdkException exception) { + log.warn("S3 file delete failed. storageKey={}", storageKey, exception); + throw new BaseException(CommonErrorCode.INTERNAL_SERVER_ERROR); + } + } +} diff --git a/src/main/java/com/slatto/global/storage/StorageService.java b/src/main/java/com/slatto/global/storage/StorageService.java new file mode 100644 index 00000000..23892ef7 --- /dev/null +++ b/src/main/java/com/slatto/global/storage/StorageService.java @@ -0,0 +1,14 @@ +package com.slatto.global.storage; + +import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.core.ResponseInputStream; +import software.amazon.awssdk.services.s3.model.GetObjectResponse; + +public interface StorageService { + + void upload(MultipartFile file, String storageKey); + + ResponseInputStream download(String storageKey); + + void delete(String storageKey); +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 435b51b8..5d8e0bfc 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -2,6 +2,11 @@ spring: application: name: slatto + servlet: + multipart: + max-file-size: 100MB + max-request-size: 105MB + datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: ${DB_URL} diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index e72d19ae..3af4839d 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -1,4 +1,9 @@ spring: + servlet: + multipart: + max-file-size: 100MB + max-request-size: 105MB + datasource: driver-class-name: org.h2.Driver url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1