From a8337922dcc2653143a64d9f7d32e0f828a6776f Mon Sep 17 00:00:00 2001 From: chazy-d Date: Thu, 23 Jul 2026 00:01:23 +0900 Subject: [PATCH 1/9] =?UTF-8?q?refactor:=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=20=ED=8C=8C=EC=9D=BC=20=EB=8F=84=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=EB=AA=A8=EB=8D=B8=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/project/entity/ProjectFile.java | 84 +++++++++++++++++-- 1 file changed, 75 insertions(+), 9 deletions(-) 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..510441db 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,76 @@ 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 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(); + } +} From 16dfd0f23bfa8915256057fb82686fb085d5d90d Mon Sep 17 00:00:00 2001 From: chazy-d Date: Thu, 23 Jul 2026 00:41:06 +0900 Subject: [PATCH 2/9] =?UTF-8?q?feat:=20S3=20=ED=8C=8C=EC=9D=BC=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 6 ++ build.gradle | 4 +- .../com/slatto/global/config/S3Config.java | 25 +++++++ .../config/properties/S3Properties.java | 12 +++ .../global/storage/S3StorageService.java | 73 +++++++++++++++++++ .../slatto/global/storage/StorageService.java | 14 ++++ src/main/resources/application.yml | 6 ++ src/test/resources/application.yml | 6 ++ 8 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/slatto/global/config/S3Config.java create mode 100644 src/main/java/com/slatto/global/config/properties/S3Properties.java create mode 100644 src/main/java/com/slatto/global/storage/S3StorageService.java create mode 100644 src/main/java/com/slatto/global/storage/StorageService.java diff --git a/.env.example b/.env.example index 60b337e8..8950e51c 100644 --- a/.env.example +++ b/.env.example @@ -20,5 +20,11 @@ FRONTEND_BASE_URL=http://localhost:3000 # 프로젝트 팀원 초대 링크 주소 PROJECT_INVITATION_BASE_URL=http://localhost:3000/project-invitations +# AWS S3 +AWS_ACCESS_KEY= +AWS_SECRET_KEY= +AWS_REGION=ap-northeast-2 +AWS_S3_BUCKET= + # 리프레시 토큰 쿠키의 Secure 속성. 로컬 http 테스트 시에만 false COOKIE_SECURE=true diff --git a/build.gradle b/build.gradle index 94207e0c..b5254220 100644 --- a/build.gradle +++ b/build.gradle @@ -26,6 +26,8 @@ dependencies { implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.16' implementation 'me.paulschwarz:spring-dotenv:4.0.0' implementation 'io.jsonwebtoken:jjwt-api:0.12.6' + implementation platform('software.amazon.awssdk:bom:2.27.21') + implementation 'software.amazon.awssdk:s3' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' compileOnly 'org.projectlombok:lombok' @@ -46,4 +48,4 @@ tasks.named('test') { tasks.named('bootJar') { archiveFileName = 'slatto.jar' -} \ No newline at end of file +} diff --git a/src/main/java/com/slatto/global/config/S3Config.java b/src/main/java/com/slatto/global/config/S3Config.java new file mode 100644 index 00000000..17348734 --- /dev/null +++ b/src/main/java/com/slatto/global/config/S3Config.java @@ -0,0 +1,25 @@ +package com.slatto.global.config; + +import com.slatto.global.config.properties.S3Properties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; + +@Configuration +public class S3Config { + + @Bean + public S3Client s3Client(S3Properties s3Properties) { + return S3Client.builder() + .region(Region.of(s3Properties.region())) + .credentialsProvider( + StaticCredentialsProvider.create( + AwsBasicCredentials.create(s3Properties.accessKey(), s3Properties.secretKey()) + ) + ) + .build(); + } +} diff --git a/src/main/java/com/slatto/global/config/properties/S3Properties.java b/src/main/java/com/slatto/global/config/properties/S3Properties.java new file mode 100644 index 00000000..044c8a6a --- /dev/null +++ b/src/main/java/com/slatto/global/config/properties/S3Properties.java @@ -0,0 +1,12 @@ +package com.slatto.global.config.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "app.s3") +public record S3Properties( + String accessKey, + String secretKey, + String region, + String bucket +) { +} 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..6244c1f4 --- /dev/null +++ b/src/main/java/com/slatto/global/storage/S3StorageService.java @@ -0,0 +1,73 @@ +package com.slatto.global.storage; + +import com.slatto.global.config.properties.S3Properties; +import com.slatto.global.exception.BaseException; +import com.slatto.global.response.code.CommonErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +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; + private final S3Properties s3Properties; + + @Override + public void upload(MultipartFile file, String storageKey) { + try (InputStream inputStream = file.getInputStream()) { + PutObjectRequest request = PutObjectRequest.builder() + .bucket(s3Properties.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(s3Properties.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(s3Properties.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 a7a7d96b..b9cdcaa7 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -59,6 +59,12 @@ app: invitation: base-url: ${PROJECT_INVITATION_BASE_URL:${FRONTEND_BASE_URL}/project-invitations} + s3: + access-key: ${AWS_ACCESS_KEY} + secret-key: ${AWS_SECRET_KEY} + region: ${AWS_REGION} + bucket: ${AWS_S3_BUCKET} + cookie: refresh-token-name: refreshToken oauth-state-name: oauthState diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index a03929cd..c6559bff 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -46,6 +46,12 @@ app: invitation: base-url: http://localhost:3000/project-invitations + s3: + access-key: test-access-key + secret-key: test-secret-key + region: ap-northeast-2 + bucket: test-bucket + cookie: refresh-token-name: refreshToken oauth-state-name: oauthState From 8dba9801c4b79666e5fed71bf058999bab78200f Mon Sep 17 00:00:00 2001 From: chazy-d Date: Thu, 23 Jul 2026 01:00:21 +0900 Subject: [PATCH 3/9] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=20=ED=8C=8C=EC=9D=BC=20=EC=9A=94=EC=B2=AD=20=EB=B0=8F?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20=EA=B5=AC=EC=A1=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../project/dto/ProjectFileListResponse.java | 17 +++++++ .../project/dto/ProjectFileResponse.java | 40 +++++++++++++++ .../project/dto/ProjectFileUpdateRequest.java | 20 ++++++++ .../project/dto/ProjectFileUploadRequest.java | 22 +++++++++ .../repository/ProjectFileRepository.java | 49 +++++++++++++++++++ 5 files changed, 148 insertions(+) create mode 100644 src/main/java/com/slatto/domain/project/dto/ProjectFileListResponse.java create mode 100644 src/main/java/com/slatto/domain/project/dto/ProjectFileResponse.java create mode 100644 src/main/java/com/slatto/domain/project/dto/ProjectFileUpdateRequest.java create mode 100644 src/main/java/com/slatto/domain/project/dto/ProjectFileUploadRequest.java create mode 100644 src/main/java/com/slatto/domain/project/repository/ProjectFileRepository.java 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/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 + ); +} From c595a172b27f19d8924e25df27b8bb43a66ee455 Mon Sep 17 00:00:00 2001 From: chazy-d Date: Thu, 23 Jul 2026 01:16:24 +0900 Subject: [PATCH 4/9] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=20=ED=8C=8C=EC=9D=BC=20=EC=97=85=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ProjectFileController.java | 49 ++++++ .../project/exception/ProjectErrorCode.java | 3 + .../project/service/ProjectFileService.java | 139 ++++++++++++++++++ 3 files changed, 191 insertions(+) create mode 100644 src/main/java/com/slatto/domain/project/controller/ProjectFileController.java create mode 100644 src/main/java/com/slatto/domain/project/service/ProjectFileService.java 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..49decf23 --- /dev/null +++ b/src/main/java/com/slatto/domain/project/controller/ProjectFileController.java @@ -0,0 +1,49 @@ +package com.slatto.domain.project.controller; + +import com.slatto.domain.project.dto.ProjectFileResponse; +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.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +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; + +@Tag(name = "Project File", description = "프로젝트 파일 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/projects/{projectId}/files") +public class ProjectFileController { + + private final ProjectFileService projectFileService; + + @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); + } +} 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..7ca8c1aa 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,9 @@ 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_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/service/ProjectFileService.java b/src/main/java/com/slatto/domain/project/service/ProjectFileService.java new file mode 100644 index 00000000..8ca34fb7 --- /dev/null +++ b/src/main/java/com/slatto/domain/project/service/ProjectFileService.java @@ -0,0 +1,139 @@ +package com.slatto.domain.project.service; + +import com.slatto.domain.project.dto.ProjectFileResponse; +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.storage.StorageService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; + +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ProjectFileService { + + private static final long MAX_FILE_SIZE = 100L * 1024 * 1024; + 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; + + @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); + + 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 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 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 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(); + } +} From c7841bf622fe28822c6d8c86c26b9023b81a74e7 Mon Sep 17 00:00:00 2001 From: chazy-d Date: Thu, 23 Jul 2026 01:48:33 +0900 Subject: [PATCH 5/9] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=20=ED=8C=8C=EC=9D=BC=20=EA=B4=80=EB=A6=AC=20API=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ProjectFileController.java | 57 ++++++++ .../domain/project/entity/ProjectFile.java | 4 + .../project/exception/ProjectErrorCode.java | 1 + .../project/service/ProjectFileService.java | 131 ++++++++++++++++++ 4 files changed, 193 insertions(+) diff --git a/src/main/java/com/slatto/domain/project/controller/ProjectFileController.java b/src/main/java/com/slatto/domain/project/controller/ProjectFileController.java index 49decf23..d6abbfa4 100644 --- a/src/main/java/com/slatto/domain/project/controller/ProjectFileController.java +++ b/src/main/java/com/slatto/domain/project/controller/ProjectFileController.java @@ -1,6 +1,8 @@ package com.slatto.domain.project.controller; +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; @@ -12,9 +14,14 @@ import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; 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; @@ -28,6 +35,26 @@ 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) @@ -46,4 +73,34 @@ public ApiResponse uploadProjectFile( 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); + } } 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 510441db..a3a9c612 100644 --- a/src/main/java/com/slatto/domain/project/entity/ProjectFile.java +++ b/src/main/java/com/slatto/domain/project/entity/ProjectFile.java @@ -102,6 +102,10 @@ public boolean isPinned() { return pinnedAt != null; } + public void updateFileName(String fileName) { + this.fileName = fileName; + } + public void updateDescription(String description) { this.description = description; } 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 7ca8c1aa..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,7 @@ 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까지 업로드할 수 있습니다."), diff --git a/src/main/java/com/slatto/domain/project/service/ProjectFileService.java b/src/main/java/com/slatto/domain/project/service/ProjectFileService.java index 8ca34fb7..7b63ea80 100644 --- a/src/main/java/com/slatto/domain/project/service/ProjectFileService.java +++ b/src/main/java/com/slatto/domain/project/service/ProjectFileService.java @@ -1,6 +1,8 @@ package com.slatto.domain.project.service; 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; @@ -9,13 +11,16 @@ 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 org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; 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; @@ -27,6 +32,8 @@ 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"), @@ -40,6 +47,41 @@ public class ProjectFileService { 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, @@ -76,6 +118,60 @@ public ProjectFileResponse uploadProjectFile( return toResponse(savedFile); } + @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(); + } + + 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); @@ -92,6 +188,16 @@ private void validateFile(MultipartFile file, String fileName) { } } + 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; @@ -117,6 +223,31 @@ private String getExtension(String fileName) { 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(); From e4e788f7abd6c35d4eaccaf0270d743f93f8eba6 Mon Sep 17 00:00:00 2001 From: chazy-d Date: Thu, 23 Jul 2026 01:56:44 +0900 Subject: [PATCH 6/9] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=20=ED=8C=8C=EC=9D=BC=20=EB=8B=A4=EC=9A=B4=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ProjectFileController.java | 30 +++++++++++++++++++ .../dto/ProjectFileDownloadResponse.java | 19 ++++++++++++ .../project/service/ProjectFileService.java | 19 ++++++++++++ 3 files changed, 68 insertions(+) create mode 100644 src/main/java/com/slatto/domain/project/dto/ProjectFileDownloadResponse.java diff --git a/src/main/java/com/slatto/domain/project/controller/ProjectFileController.java b/src/main/java/com/slatto/domain/project/controller/ProjectFileController.java index d6abbfa4..fc2f06cd 100644 --- a/src/main/java/com/slatto/domain/project/controller/ProjectFileController.java +++ b/src/main/java/com/slatto/domain/project/controller/ProjectFileController.java @@ -1,5 +1,6 @@ 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; @@ -11,8 +12,11 @@ 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; @@ -27,6 +31,8 @@ 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 @@ -103,4 +109,28 @@ public ApiResponse deleteProjectFile( 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/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/service/ProjectFileService.java b/src/main/java/com/slatto/domain/project/service/ProjectFileService.java index 7b63ea80..b4a72432 100644 --- a/src/main/java/com/slatto/domain/project/service/ProjectFileService.java +++ b/src/main/java/com/slatto/domain/project/service/ProjectFileService.java @@ -1,5 +1,6 @@ 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; @@ -146,6 +147,24 @@ public void deleteProjectFile(Long projectId, Long fileId, Long 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()); From d1410afed73666cd027612f1f9454dae651534cd Mon Sep 17 00:00:00 2001 From: chazy-d Date: Thu, 23 Jul 2026 21:58:47 +0900 Subject: [PATCH 7/9] =?UTF-8?q?fix:=20S3=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EB=B3=B4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/storage/S3StorageService.java | 26 +++++++++---------- src/test/resources/application.yml | 11 ++++---- 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/src/main/java/com/slatto/global/storage/S3StorageService.java b/src/main/java/com/slatto/global/storage/S3StorageService.java index c788c7d2..9ed0111c 100644 --- a/src/main/java/com/slatto/global/storage/S3StorageService.java +++ b/src/main/java/com/slatto/global/storage/S3StorageService.java @@ -31,10 +31,10 @@ public class S3StorageService implements StorageService { @Override public void upload(MultipartFile file, String storageKey) { try (InputStream inputStream = file.getInputStream()) { - PutObjectRequest request = PutObjectRequest.builder() - .bucket(bucket) - .key(storageKey) - .contentType(file.getContentType()) + PutObjectRequest request = PutObjectRequest.builder() + .bucket(bucket) + .key(storageKey) + .contentType(file.getContentType()) .contentLength(file.getSize()) .build(); @@ -48,10 +48,10 @@ public void upload(MultipartFile file, String storageKey) { @Override public ResponseInputStream download(String storageKey) { try { - GetObjectRequest request = GetObjectRequest.builder() - .bucket(bucket) - .key(storageKey) - .build(); + GetObjectRequest request = GetObjectRequest.builder() + .bucket(bucket) + .key(storageKey) + .build(); return s3Client.getObject(request); } catch (SdkException exception) { @@ -62,11 +62,11 @@ public ResponseInputStream download(String storageKey) { @Override public void delete(String storageKey) { - try { - s3Client.deleteObject(request -> request - .bucket(bucket) - .key(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/test/resources/application.yml b/src/test/resources/application.yml index c6559bff..19e5ebe0 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -17,6 +17,11 @@ youtube: connect-timeout: 3s read-timeout: 5s +cloud: + aws: + s3: + bucket: test-bucket + app: jwt: secret: test-secret-key-for-context-load-only-32bytes @@ -46,12 +51,6 @@ app: invitation: base-url: http://localhost:3000/project-invitations - s3: - access-key: test-access-key - secret-key: test-secret-key - region: ap-northeast-2 - bucket: test-bucket - cookie: refresh-token-name: refreshToken oauth-state-name: oauthState From 2bd593f36a20354b815e639eeb951fbbbeed6417 Mon Sep 17 00:00:00 2001 From: chazy-d Date: Sat, 25 Jul 2026 01:34:57 +0900 Subject: [PATCH 8/9] =?UTF-8?q?fix:=20=ED=8C=8C=EC=9D=BC=20=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20multipart=20=EC=A0=9C=ED=95=9C=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application.yml | 5 +++++ src/test/resources/application.yml | 5 +++++ 2 files changed, 10 insertions(+) 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 From 09440a488db54a4fa65b3fc0dfb8cb22bf2843eb Mon Sep 17 00:00:00 2001 From: chazy-d Date: Sat, 25 Jul 2026 01:35:48 +0900 Subject: [PATCH 9/9] =?UTF-8?q?fix:=20=ED=8C=8C=EC=9D=BC=20=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20=EB=A1=A4=EB=B0=B1=20=EC=8B=9C=20S3=20?= =?UTF-8?q?=EA=B0=9D=EC=B2=B4=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../project/service/ProjectFileService.java | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/main/java/com/slatto/domain/project/service/ProjectFileService.java b/src/main/java/com/slatto/domain/project/service/ProjectFileService.java index b4a72432..09ea1f89 100644 --- a/src/main/java/com/slatto/domain/project/service/ProjectFileService.java +++ b/src/main/java/com/slatto/domain/project/service/ProjectFileService.java @@ -15,9 +15,12 @@ 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; @@ -28,6 +31,7 @@ import java.util.UUID; @Service +@Slf4j @RequiredArgsConstructor @Transactional(readOnly = true) public class ProjectFileService { @@ -98,6 +102,7 @@ public ProjectFileResponse uploadProjectFile( String contentType = file.getContentType(); String storageKey = createStorageKey(projectId, request.getFileName()); storageService.upload(file, storageKey); + registerStorageCleanupOnRollback(storageKey); ProjectFile projectFile = ProjectFile.create( project, @@ -119,6 +124,31 @@ public ProjectFileResponse uploadProjectFile( 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,