diff --git a/build.gradle b/build.gradle index c3751e39..da0567f7 100644 --- a/build.gradle +++ b/build.gradle @@ -47,7 +47,9 @@ dependencies { } implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' - implementation 'software.amazon.awssdk:ses:2.29.46' + implementation 'software.amazon.awssdk:ses:2.34.0' + implementation 'software.amazon.awssdk:s3:2.34.0' + implementation 'com.fasterxml.uuid:java-uuid-generator:5.1.0' compileOnly 'org.projectlombok:lombok' diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/admin/file/controller/AdminFileController.java b/src/main/java/com/dreamteam/alter/adapter/inbound/admin/file/controller/AdminFileController.java new file mode 100644 index 00000000..a6314dc7 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/admin/file/controller/AdminFileController.java @@ -0,0 +1,65 @@ +package com.dreamteam.alter.adapter.inbound.admin.file.controller; + +import com.dreamteam.alter.adapter.inbound.admin.file.dto.AdminUploadFileResponseDto; +import com.dreamteam.alter.adapter.inbound.common.dto.CommonApiResponse; +import com.dreamteam.alter.adapter.inbound.common.dto.FilePresignedUrlResponseDto; +import com.dreamteam.alter.application.aop.AdminActionContext; +import com.dreamteam.alter.domain.file.port.inbound.AdminDeleteFileUseCase; +import com.dreamteam.alter.domain.file.port.inbound.AdminGetPresignedUrlUseCase; +import com.dreamteam.alter.domain.file.port.inbound.AdminUploadFileUseCase; +import com.dreamteam.alter.domain.file.type.BucketType; +import com.dreamteam.alter.domain.file.type.FileTargetType; +import com.dreamteam.alter.domain.user.context.AdminActor; +import jakarta.annotation.Resource; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + + +@RestController +@RequestMapping("/admin/files") +@PreAuthorize("hasAnyRole('ADMIN')") +@RequiredArgsConstructor +public class AdminFileController implements AdminFileControllerSpec { + + @Resource(name = "adminUploadFile") + private final AdminUploadFileUseCase adminUploadFile; + + @Resource(name = "adminGetPresignedUrl") + private final AdminGetPresignedUrlUseCase adminGetPresignedUrl; + + @Resource(name = "adminDeleteFile") + private final AdminDeleteFileUseCase adminDeleteFile; + + @Override + @PostMapping + public ResponseEntity> uploadFile( + @RequestParam("file") MultipartFile file, + @RequestParam("targetType") FileTargetType targetType, + @RequestParam("bucketType") BucketType bucketType + ) { + AdminActor actor = AdminActionContext.getInstance().getActor(); + return ResponseEntity.ok(CommonApiResponse.of(adminUploadFile.execute(actor, file, targetType, bucketType))); + } + + @Override + @GetMapping("/{fileId}/presigned-url") + public ResponseEntity> getPresignedUrl( + @PathVariable String fileId + ) { + AdminActor actor = AdminActionContext.getInstance().getActor(); + return ResponseEntity.ok(CommonApiResponse.of(adminGetPresignedUrl.execute(actor, fileId))); + } + + @Override + @DeleteMapping("/{fileId}") + public ResponseEntity> deleteFile( + @PathVariable String fileId + ) { + AdminActor actor = AdminActionContext.getInstance().getActor(); + adminDeleteFile.execute(actor, fileId); + return ResponseEntity.ok(CommonApiResponse.empty()); + } +} diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/admin/file/controller/AdminFileControllerSpec.java b/src/main/java/com/dreamteam/alter/adapter/inbound/admin/file/controller/AdminFileControllerSpec.java new file mode 100644 index 00000000..3c5b33d0 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/admin/file/controller/AdminFileControllerSpec.java @@ -0,0 +1,73 @@ +package com.dreamteam.alter.adapter.inbound.admin.file.controller; + +import com.dreamteam.alter.adapter.inbound.admin.file.dto.AdminUploadFileResponseDto; +import com.dreamteam.alter.adapter.inbound.common.dto.CommonApiResponse; +import com.dreamteam.alter.adapter.inbound.common.dto.ErrorResponse; +import com.dreamteam.alter.adapter.inbound.common.dto.FilePresignedUrlResponseDto; +import com.dreamteam.alter.domain.file.type.BucketType; +import com.dreamteam.alter.domain.file.type.FileTargetType; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.multipart.MultipartFile; + + +@Tag(name = "ADMIN - 파일 API") +public interface AdminFileControllerSpec { + + @Operation(summary = "파일 업로드", description = "파일을 S3에 업로드하고 PENDING 상태로 저장합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "파일 업로드 성공"), + @ApiResponse(responseCode = "400", description = "실패 케이스", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject(name = "유효하지 않은 파일", value = "{\"code\" : \"B022\"}"), + @ExampleObject(name = "허용되지 않는 파일 형식", value = "{\"code\" : \"B023\"}"), + @ExampleObject(name = "파일 크기 초과", value = "{\"code\" : \"B024\"}") + })) + }) + ResponseEntity> uploadFile( + @RequestParam("file") MultipartFile file, + @RequestParam("targetType") FileTargetType targetType, + @RequestParam("bucketType") BucketType bucketType + ); + + @Operation(summary = "Presigned URL 조회", description = "모든 Private 파일 접근을 위한 Presigned URL을 조회합니다. (소유권 검증 없음)") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Presigned URL 조회 성공"), + @ApiResponse(responseCode = "404", description = "존재하지 않는 파일", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject(name = "파일 없음", value = "{\"code\" : \"B021\"}") + })) + }) + ResponseEntity> getPresignedUrl( + @PathVariable String fileId + ); + + @Operation(summary = "파일 삭제", description = "모든 파일을 S3에서 삭제하고 DELETED 상태로 변경합니다. (소유권 검증 없음)") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "파일 삭제 성공"), + @ApiResponse(responseCode = "404", description = "존재하지 않는 파일", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject(name = "파일 없음", value = "{\"code\" : \"B021\"}") + })) + }) + ResponseEntity> deleteFile( + @PathVariable String fileId + ); +} diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/admin/file/dto/AdminUploadFileResponseDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/admin/file/dto/AdminUploadFileResponseDto.java new file mode 100644 index 00000000..50ec81fa --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/admin/file/dto/AdminUploadFileResponseDto.java @@ -0,0 +1,21 @@ +package com.dreamteam.alter.adapter.inbound.admin.file.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@Schema(description = "파일 업로드 응답 DTO") +public class AdminUploadFileResponseDto { + + @Schema(description = "파일 ID", example = "01959b4e-4e5f-7c3a-8d9e-0f1a2b3c4d5e") + private String fileId; + + public static AdminUploadFileResponseDto of(String fileId) { + return new AdminUploadFileResponseDto(fileId); + } +} diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/common/dto/FilePresignedUrlResponseDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/common/dto/FilePresignedUrlResponseDto.java new file mode 100644 index 00000000..d646e648 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/common/dto/FilePresignedUrlResponseDto.java @@ -0,0 +1,25 @@ +package com.dreamteam.alter.adapter.inbound.common.dto; + +import com.dreamteam.alter.domain.file.PresignedUrlResult; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@Schema(description = "Presigned URL 조회 응답 DTO") +public class FilePresignedUrlResponseDto { + + @Schema(description = "S3 Presigned URL (유효시간 제한 있음)") + private String presignedUrl; + + @Schema(description = "Presigned URL 만료 시각 (epoch milliseconds)") + private long expiresAt; + + public static FilePresignedUrlResponseDto of(PresignedUrlResult result) { + return new FilePresignedUrlResponseDto(result.url(), result.expiresAt().toEpochMilli()); + } +} diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/common/dto/FileResponseDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/common/dto/FileResponseDto.java new file mode 100644 index 00000000..0d53b5e0 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/common/dto/FileResponseDto.java @@ -0,0 +1,20 @@ +package com.dreamteam.alter.adapter.inbound.common.dto; + +import com.dreamteam.alter.domain.file.entity.File; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class FileResponseDto { + + private String fileId; + private String url; + + public static FileResponseDto of(File file, String url) { + return new FileResponseDto(file.getId(), url); + } +} diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/file/scheduler/FileCleanupScheduler.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/file/scheduler/FileCleanupScheduler.java new file mode 100644 index 00000000..bef2d242 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/file/scheduler/FileCleanupScheduler.java @@ -0,0 +1,22 @@ +package com.dreamteam.alter.adapter.inbound.general.file.scheduler; + +import com.dreamteam.alter.domain.file.port.inbound.CleanupOrphanFilesUseCase; +import jakarta.annotation.Resource; +import lombok.RequiredArgsConstructor; +import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class FileCleanupScheduler { + + @Resource(name = "cleanupOrphanFiles") + private final CleanupOrphanFilesUseCase cleanupOrphanFiles; + + @Scheduled(cron = "0 0 3 * * *") + @SchedulerLock(name = "cleanupOrphanFiles", lockAtMostFor = "30m") + public void cleanupOrphanFiles() { + cleanupOrphanFiles.execute(); + } +} diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/manager/file/controller/ManagerFileController.java b/src/main/java/com/dreamteam/alter/adapter/inbound/manager/file/controller/ManagerFileController.java new file mode 100644 index 00000000..4312c0c3 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/manager/file/controller/ManagerFileController.java @@ -0,0 +1,65 @@ +package com.dreamteam.alter.adapter.inbound.manager.file.controller; + +import com.dreamteam.alter.adapter.inbound.common.dto.CommonApiResponse; +import com.dreamteam.alter.adapter.inbound.common.dto.FilePresignedUrlResponseDto; +import com.dreamteam.alter.adapter.inbound.manager.file.dto.ManagerUploadFileResponseDto; +import com.dreamteam.alter.application.aop.ManagerActionContext; +import com.dreamteam.alter.domain.file.port.inbound.ManagerDeleteFileUseCase; +import com.dreamteam.alter.domain.file.port.inbound.ManagerGetPresignedUrlUseCase; +import com.dreamteam.alter.domain.file.port.inbound.ManagerUploadFileUseCase; +import com.dreamteam.alter.domain.file.type.BucketType; +import com.dreamteam.alter.domain.file.type.FileTargetType; +import com.dreamteam.alter.domain.user.context.ManagerActor; +import jakarta.annotation.Resource; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + + +@RestController +@RequestMapping("/manager/files") +@PreAuthorize("hasAnyRole('MANAGER')") +@RequiredArgsConstructor +public class ManagerFileController implements ManagerFileControllerSpec { + + @Resource(name = "managerUploadFile") + private final ManagerUploadFileUseCase managerUploadFile; + + @Resource(name = "managerGetPresignedUrl") + private final ManagerGetPresignedUrlUseCase managerGetPresignedUrl; + + @Resource(name = "managerDeleteFile") + private final ManagerDeleteFileUseCase managerDeleteFile; + + @Override + @PostMapping + public ResponseEntity> uploadFile( + @RequestParam("file") MultipartFile file, + @RequestParam("targetType") FileTargetType targetType, + @RequestParam("bucketType") BucketType bucketType + ) { + ManagerActor actor = ManagerActionContext.getInstance().getActor(); + return ResponseEntity.ok(CommonApiResponse.of(managerUploadFile.execute(actor, file, targetType, bucketType))); + } + + @Override + @GetMapping("/{fileId}/presigned-url") + public ResponseEntity> getPresignedUrl( + @PathVariable String fileId + ) { + ManagerActor actor = ManagerActionContext.getInstance().getActor(); + return ResponseEntity.ok(CommonApiResponse.of(managerGetPresignedUrl.execute(actor, fileId))); + } + + @Override + @DeleteMapping("/{fileId}") + public ResponseEntity> deleteFile( + @PathVariable String fileId + ) { + ManagerActor actor = ManagerActionContext.getInstance().getActor(); + managerDeleteFile.execute(actor, fileId); + return ResponseEntity.ok(CommonApiResponse.empty()); + } +} diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/manager/file/controller/ManagerFileControllerSpec.java b/src/main/java/com/dreamteam/alter/adapter/inbound/manager/file/controller/ManagerFileControllerSpec.java new file mode 100644 index 00000000..8224c0f1 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/manager/file/controller/ManagerFileControllerSpec.java @@ -0,0 +1,87 @@ +package com.dreamteam.alter.adapter.inbound.manager.file.controller; + +import com.dreamteam.alter.adapter.inbound.common.dto.CommonApiResponse; +import com.dreamteam.alter.adapter.inbound.common.dto.ErrorResponse; +import com.dreamteam.alter.adapter.inbound.common.dto.FilePresignedUrlResponseDto; +import com.dreamteam.alter.adapter.inbound.manager.file.dto.ManagerUploadFileResponseDto; +import com.dreamteam.alter.domain.file.type.BucketType; +import com.dreamteam.alter.domain.file.type.FileTargetType; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.multipart.MultipartFile; + + +@Tag(name = "MANAGER - 파일 API") +public interface ManagerFileControllerSpec { + + @Operation(summary = "파일 업로드", description = "파일을 S3에 업로드하고 PENDING 상태로 저장합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "파일 업로드 성공"), + @ApiResponse(responseCode = "400", description = "실패 케이스", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject(name = "유효하지 않은 파일", value = "{\"code\" : \"B022\"}"), + @ExampleObject(name = "허용되지 않는 파일 형식", value = "{\"code\" : \"B023\"}"), + @ExampleObject(name = "파일 크기 초과", value = "{\"code\" : \"B024\"}") + })) + }) + ResponseEntity> uploadFile( + @RequestParam("file") MultipartFile file, + @RequestParam("targetType") FileTargetType targetType, + @RequestParam("bucketType") BucketType bucketType + ); + + @Operation(summary = "Presigned URL 조회", description = "본인이 업로드한 Private 파일 접근을 위한 Presigned URL을 조회합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Presigned URL 조회 성공"), + @ApiResponse(responseCode = "403", description = "권한 없음", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject(name = "본인 파일 아님", value = "{\"code\" : \"A005\"}") + })), + @ApiResponse(responseCode = "404", description = "존재하지 않는 파일", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject(name = "파일 없음", value = "{\"code\" : \"B021\"}") + })) + }) + ResponseEntity> getPresignedUrl( + @PathVariable String fileId + ); + + @Operation(summary = "파일 삭제", description = "본인이 업로드한 파일을 S3에서 삭제하고 DELETED 상태로 변경합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "파일 삭제 성공"), + @ApiResponse(responseCode = "403", description = "권한 없음", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject(name = "본인 파일 아님", value = "{\"code\" : \"A005\"}") + })), + @ApiResponse(responseCode = "404", description = "존재하지 않는 파일", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject(name = "파일 없음", value = "{\"code\" : \"B021\"}") + })) + }) + ResponseEntity> deleteFile( + @PathVariable String fileId + ); +} diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/manager/file/dto/ManagerUploadFileResponseDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/manager/file/dto/ManagerUploadFileResponseDto.java new file mode 100644 index 00000000..0db29cb5 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/manager/file/dto/ManagerUploadFileResponseDto.java @@ -0,0 +1,21 @@ +package com.dreamteam.alter.adapter.inbound.manager.file.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@Schema(description = "파일 업로드 응답 DTO") +public class ManagerUploadFileResponseDto { + + @Schema(description = "파일 ID", example = "01959b4e-4e5f-7c3a-8d9e-0f1a2b3c4d5e") + private String fileId; + + public static ManagerUploadFileResponseDto of(String fileId) { + return new ManagerUploadFileResponseDto(fileId); + } +} diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/file/external/S3ClientImpl.java b/src/main/java/com/dreamteam/alter/adapter/outbound/file/external/S3ClientImpl.java new file mode 100644 index 00000000..45191c10 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/file/external/S3ClientImpl.java @@ -0,0 +1,106 @@ +package com.dreamteam.alter.adapter.outbound.file.external; + +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.file.PresignedUrlResult; +import com.dreamteam.alter.domain.file.port.outbound.S3Client; +import com.dreamteam.alter.domain.file.type.BucketType; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +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.time.Duration; +import java.time.Instant; + +@Slf4j +@Component("s3Client") +@RequiredArgsConstructor +public class S3ClientImpl implements S3Client { + + private static final String PUBLIC_URL_FORMAT = "https://%s.s3.%s.amazonaws.com/%s"; + + private final software.amazon.awssdk.services.s3.S3Client awsS3Client; + private final S3Presigner s3Presigner; + + @Value("${aws.s3.public-bucket}") + private String publicBucket; + + @Value("${aws.s3.private-bucket}") + private String privateBucket; + + @Value("${aws.s3.region}") + private String bucketRegion; + + @Value("${aws.s3.presigned-url-expiration-minutes:30}") + private long presignedUrlExpirationMinutes; + + @Override + public String upload(MultipartFile file, String storedKey, BucketType bucketType) { + String bucket = resolveBucket(bucketType); + try { + PutObjectRequest request = PutObjectRequest.builder() + .bucket(bucket) + .key(storedKey) + .contentType(file.getContentType()) + .contentLength(file.getSize()) + .build(); + + awsS3Client.putObject(request, RequestBody.fromInputStream(file.getInputStream(), file.getSize())); + + if (bucketType == BucketType.PUBLIC) { + return PUBLIC_URL_FORMAT.formatted(bucket, bucketRegion, storedKey); + } + return null; + } catch (Exception e) { + log.error("S3 upload failed for key={}", storedKey, e); + throw new CustomException(ErrorCode.FILE_UPLOAD_FAILED); + } + } + + @Override + public PresignedUrlResult getPresignedUrl(String storedKey, BucketType bucketType) { + String bucket = resolveBucket(bucketType); + try { + Instant expiresAt = Instant.now().plus(Duration.ofMinutes(presignedUrlExpirationMinutes)); + GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() + .signatureDuration(Duration.ofMinutes(presignedUrlExpirationMinutes)) + .getObjectRequest(GetObjectRequest.builder() + .bucket(bucket) + .key(storedKey) + .build()) + .build(); + + String url = s3Presigner.presignGetObject(presignRequest).url().toString(); + return new PresignedUrlResult(url, expiresAt); + } catch (Exception e) { + log.error("S3 presign failed for key={}", storedKey, e); + throw new CustomException(ErrorCode.INTERNAL_SERVER_ERROR); + } + } + + @Override + public void delete(String storedKey, BucketType bucketType) { + String bucket = resolveBucket(bucketType); + try { + DeleteObjectRequest request = DeleteObjectRequest.builder() + .bucket(bucket) + .key(storedKey) + .build(); + awsS3Client.deleteObject(request); + } catch (Exception e) { + log.error("S3 delete failed for key={}", storedKey, e); + throw new CustomException(ErrorCode.FILE_DELETE_FAILED); + } + } + + private String resolveBucket(BucketType bucketType) { + return bucketType == BucketType.PUBLIC ? publicBucket : privateBucket; + } +} diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/file/persistence/FileJpaRepository.java b/src/main/java/com/dreamteam/alter/adapter/outbound/file/persistence/FileJpaRepository.java new file mode 100644 index 00000000..77bc65be --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/file/persistence/FileJpaRepository.java @@ -0,0 +1,7 @@ +package com.dreamteam.alter.adapter.outbound.file.persistence; + +import com.dreamteam.alter.domain.file.entity.File; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface FileJpaRepository extends JpaRepository { +} diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/file/persistence/FileQueryRepositoryImpl.java b/src/main/java/com/dreamteam/alter/adapter/outbound/file/persistence/FileQueryRepositoryImpl.java new file mode 100644 index 00000000..70221104 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/file/persistence/FileQueryRepositoryImpl.java @@ -0,0 +1,71 @@ +package com.dreamteam.alter.adapter.outbound.file.persistence; + +import com.dreamteam.alter.domain.file.entity.File; +import com.dreamteam.alter.domain.file.entity.QFile; +import com.dreamteam.alter.domain.file.port.outbound.FileQueryRepository; +import com.dreamteam.alter.domain.file.type.FileStatus; +import com.dreamteam.alter.domain.file.type.FileTargetType; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class FileQueryRepositoryImpl implements FileQueryRepository { + + private final JPAQueryFactory queryFactory; + + @Override + public Optional findById(String id) { + QFile qFile = QFile.file; + File file = queryFactory + .selectFrom(qFile) + .where( + qFile.id.eq(id), + qFile.status.ne(FileStatus.DELETED) + ) + .fetchOne(); + return Optional.ofNullable(file); + } + + @Override + public List findAllByIdIn(List ids) { + QFile qFile = QFile.file; + return queryFactory + .selectFrom(qFile) + .where( + qFile.id.in(ids), + qFile.status.ne(FileStatus.DELETED) + ) + .fetch(); + } + + @Override + public List findAllByTargetTypeAndTargetId(FileTargetType targetType, String targetId) { + QFile qFile = QFile.file; + return queryFactory + .selectFrom(qFile) + .where( + qFile.targetType.eq(targetType), + qFile.targetId.eq(targetId), + qFile.status.eq(FileStatus.ATTACHED) + ) + .fetch(); + } + + @Override + public List findOrphanFiles(LocalDateTime before) { + QFile qFile = QFile.file; + return queryFactory + .selectFrom(qFile) + .where( + qFile.status.eq(FileStatus.PENDING), + qFile.createdAt.lt(before) + ) + .fetch(); + } +} diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/file/persistence/FileRepositoryImpl.java b/src/main/java/com/dreamteam/alter/adapter/outbound/file/persistence/FileRepositoryImpl.java new file mode 100644 index 00000000..46a42ed8 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/file/persistence/FileRepositoryImpl.java @@ -0,0 +1,27 @@ +package com.dreamteam.alter.adapter.outbound.file.persistence; + +import com.dreamteam.alter.domain.file.entity.File; +import com.dreamteam.alter.domain.file.port.outbound.FileRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Repository +@RequiredArgsConstructor +@Transactional +public class FileRepositoryImpl implements FileRepository { + + private final FileJpaRepository fileJpaRepository; + + @Override + public File save(File file) { + return fileJpaRepository.save(file); + } + + @Override + public List saveAll(List files) { + return fileJpaRepository.saveAll(files); + } +} diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/file/redis/PresignedUrlCacheEntry.java b/src/main/java/com/dreamteam/alter/adapter/outbound/file/redis/PresignedUrlCacheEntry.java new file mode 100644 index 00000000..c2a98e30 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/file/redis/PresignedUrlCacheEntry.java @@ -0,0 +1,19 @@ +package com.dreamteam.alter.adapter.outbound.file.redis; + +import com.dreamteam.alter.domain.file.PresignedUrlResult; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.time.Instant; + +record PresignedUrlCacheEntry( + @JsonProperty("url") String url, + @JsonProperty("expiresAt") long expiresAt +) { + static PresignedUrlCacheEntry from(PresignedUrlResult result) { + return new PresignedUrlCacheEntry(result.url(), result.expiresAt().toEpochMilli()); + } + + PresignedUrlResult toResult() { + return new PresignedUrlResult(url, Instant.ofEpochMilli(expiresAt)); + } +} diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/file/redis/PresignedUrlCacheRepositoryImpl.java b/src/main/java/com/dreamteam/alter/adapter/outbound/file/redis/PresignedUrlCacheRepositoryImpl.java new file mode 100644 index 00000000..ff382422 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/file/redis/PresignedUrlCacheRepositoryImpl.java @@ -0,0 +1,67 @@ +package com.dreamteam.alter.adapter.outbound.file.redis; + +import com.dreamteam.alter.domain.file.PresignedUrlResult; +import com.dreamteam.alter.domain.file.port.outbound.PresignedUrlCacheRepository; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataAccessException; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; +import org.springframework.util.ObjectUtils; + +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; + +@Slf4j +@Component +@RequiredArgsConstructor +public class PresignedUrlCacheRepositoryImpl implements PresignedUrlCacheRepository { + + private final StringRedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + private static final String KEY_PREFIX = "file:presigned:"; + private static final long BUFFER_SECONDS = 60; + + @Override + public Optional findByFileId(String fileId) { + try { + String value = redisTemplate.opsForValue().get(KEY_PREFIX + fileId); + if (ObjectUtils.isEmpty(value)) return Optional.empty(); + return Optional.of(objectMapper.readValue(value, PresignedUrlCacheEntry.class).toResult()); + } catch (DataAccessException e) { + log.warn("Redis access failed for fileId={}", fileId, e); + return Optional.empty(); + } catch (JsonProcessingException e) { + log.warn("Failed to deserialize presigned URL cache for fileId={}", fileId, e); + return Optional.empty(); + } + } + + @Override + public void save(String fileId, PresignedUrlResult result) { + Duration ttl = Duration.between(Instant.now(), result.expiresAt()).minusSeconds(BUFFER_SECONDS); + if (ttl.isNegative() || ttl.isZero()) return; + + try { + String value = objectMapper.writeValueAsString(PresignedUrlCacheEntry.from(result)); + redisTemplate.opsForValue().set(KEY_PREFIX + fileId, value, ttl); + } catch (DataAccessException e) { + log.warn("Redis save failed for fileId={}", fileId, e); + } catch (JsonProcessingException e) { + log.warn("Failed to serialize presigned URL cache for fileId={}", fileId, e); + } + } + + @Override + public void deleteByFileId(String fileId) { + try { + redisTemplate.delete(KEY_PREFIX + fileId); + } catch (DataAccessException e) { + log.warn("Redis delete failed for fileId={}", fileId, e); + } + } +} diff --git a/src/main/java/com/dreamteam/alter/application/file/FileDeleteService.java b/src/main/java/com/dreamteam/alter/application/file/FileDeleteService.java new file mode 100644 index 00000000..adbfc556 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/file/FileDeleteService.java @@ -0,0 +1,24 @@ +package com.dreamteam.alter.application.file; + +import com.dreamteam.alter.domain.file.entity.File; +import com.dreamteam.alter.domain.file.port.outbound.PresignedUrlCacheRepository; +import com.dreamteam.alter.domain.file.port.outbound.S3Client; +import jakarta.annotation.Resource; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service("fileDeleteService") +@RequiredArgsConstructor +public class FileDeleteService { + + @Resource(name = "s3Client") + private final S3Client s3Client; + + private final PresignedUrlCacheRepository presignedUrlCacheRepository; + + public void delete(File file) { + s3Client.delete(file.getStoredKey(), file.getBucketType()); + file.markDeleted(); + presignedUrlCacheRepository.deleteByFileId(file.getId()); + } +} diff --git a/src/main/java/com/dreamteam/alter/application/file/FileUploadService.java b/src/main/java/com/dreamteam/alter/application/file/FileUploadService.java new file mode 100644 index 00000000..ad663a61 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/file/FileUploadService.java @@ -0,0 +1,47 @@ +package com.dreamteam.alter.application.file; + +import com.dreamteam.alter.common.util.FileValidator; +import com.dreamteam.alter.domain.file.entity.File; +import com.dreamteam.alter.domain.file.port.outbound.FileRepository; +import com.dreamteam.alter.domain.file.port.outbound.S3Client; +import com.dreamteam.alter.domain.file.type.BucketType; +import com.dreamteam.alter.domain.file.type.FileTargetType; +import jakarta.annotation.Resource; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +@Service("fileUploadService") +@RequiredArgsConstructor +public class FileUploadService { + + private final FileRepository fileRepository; + + @Resource(name = "s3Client") + private final S3Client s3Client; + + @Transactional + public String upload(MultipartFile file, FileTargetType targetType, BucketType bucketType, Long userId) { + FileValidator.validate(file); + + String originalFileName = FileValidator.sanitizeFileName(file.getOriginalFilename()); + String extension = FileValidator.extractExtension(originalFileName); + String storedKey = FileValidator.buildStoredKey(targetType, extension); + + String fileUrl = s3Client.upload(file, storedKey, bucketType); + + File savedFile = fileRepository.save(File.create( + targetType, + originalFileName, + storedKey, + fileUrl, + file.getContentType(), + file.getSize(), + bucketType, + userId + )); + + return savedFile.getId(); + } +} diff --git a/src/main/java/com/dreamteam/alter/application/file/FileUrlService.java b/src/main/java/com/dreamteam/alter/application/file/FileUrlService.java new file mode 100644 index 00000000..7f0ec69e --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/file/FileUrlService.java @@ -0,0 +1,41 @@ +package com.dreamteam.alter.application.file; + +import com.dreamteam.alter.adapter.inbound.common.dto.FileResponseDto; +import com.dreamteam.alter.domain.file.PresignedUrlResult; +import com.dreamteam.alter.domain.file.entity.File; +import com.dreamteam.alter.domain.file.port.outbound.PresignedUrlCacheRepository; +import com.dreamteam.alter.domain.file.port.outbound.S3Client; +import com.dreamteam.alter.domain.file.type.BucketType; +import jakarta.annotation.Resource; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service("fileUrlService") +@RequiredArgsConstructor +public class FileUrlService { + + @Resource(name = "s3Client") + private final S3Client s3Client; + + private final PresignedUrlCacheRepository presignedUrlCacheRepository; + + public PresignedUrlResult getPresignedUrl(File file) { + return presignedUrlCacheRepository.findByFileId(file.getId()) + .orElseGet(() -> { + PresignedUrlResult result = s3Client.getPresignedUrl(file.getStoredKey(), file.getBucketType()); + presignedUrlCacheRepository.save(file.getId(), result); + return result; + }); + } + + public FileResponseDto resolve(File file) { + String url; + if (BucketType.PUBLIC.equals(file.getBucketType())) { + url = file.getFileUrl(); + } else { + url = getPresignedUrl(file).url(); + } + + return FileResponseDto.of(file, url); + } +} diff --git a/src/main/java/com/dreamteam/alter/application/file/usecase/AdminDeleteFile.java b/src/main/java/com/dreamteam/alter/application/file/usecase/AdminDeleteFile.java new file mode 100644 index 00000000..5a6767f0 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/file/usecase/AdminDeleteFile.java @@ -0,0 +1,29 @@ +package com.dreamteam.alter.application.file.usecase; + +import com.dreamteam.alter.application.file.FileDeleteService; +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.file.entity.File; +import com.dreamteam.alter.domain.file.port.inbound.AdminDeleteFileUseCase; +import com.dreamteam.alter.domain.file.port.outbound.FileQueryRepository; +import com.dreamteam.alter.domain.user.context.AdminActor; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service("adminDeleteFile") +@RequiredArgsConstructor +public class AdminDeleteFile implements AdminDeleteFileUseCase { + + private final FileQueryRepository fileQueryRepository; + private final FileDeleteService fileDeleteService; + + @Override + @Transactional + public void execute(AdminActor actor, String fileId) { + File file = fileQueryRepository.findById(fileId) + .orElseThrow(() -> new CustomException(ErrorCode.FILE_NOT_FOUND)); + + fileDeleteService.delete(file); + } +} diff --git a/src/main/java/com/dreamteam/alter/application/file/usecase/AdminGetPresignedUrl.java b/src/main/java/com/dreamteam/alter/application/file/usecase/AdminGetPresignedUrl.java new file mode 100644 index 00000000..0ec12651 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/file/usecase/AdminGetPresignedUrl.java @@ -0,0 +1,30 @@ +package com.dreamteam.alter.application.file.usecase; + +import com.dreamteam.alter.adapter.inbound.common.dto.FilePresignedUrlResponseDto; +import com.dreamteam.alter.application.file.FileUrlService; +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.file.entity.File; +import com.dreamteam.alter.domain.file.port.inbound.AdminGetPresignedUrlUseCase; +import com.dreamteam.alter.domain.file.port.outbound.FileQueryRepository; +import com.dreamteam.alter.domain.user.context.AdminActor; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service("adminGetPresignedUrl") +@RequiredArgsConstructor +public class AdminGetPresignedUrl implements AdminGetPresignedUrlUseCase { + + private final FileQueryRepository fileQueryRepository; + private final FileUrlService fileUrlService; + + @Override + @Transactional(readOnly = true) + public FilePresignedUrlResponseDto execute(AdminActor actor, String fileId) { + File file = fileQueryRepository.findById(fileId) + .orElseThrow(() -> new CustomException(ErrorCode.FILE_NOT_FOUND)); + + return FilePresignedUrlResponseDto.of(fileUrlService.getPresignedUrl(file)); + } +} diff --git a/src/main/java/com/dreamteam/alter/application/file/usecase/AdminUploadFile.java b/src/main/java/com/dreamteam/alter/application/file/usecase/AdminUploadFile.java new file mode 100644 index 00000000..d432bdc0 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/file/usecase/AdminUploadFile.java @@ -0,0 +1,28 @@ +package com.dreamteam.alter.application.file.usecase; + +import com.dreamteam.alter.adapter.inbound.admin.file.dto.AdminUploadFileResponseDto; +import com.dreamteam.alter.application.file.FileUploadService; +import com.dreamteam.alter.domain.file.port.inbound.AdminUploadFileUseCase; +import com.dreamteam.alter.domain.file.type.BucketType; +import com.dreamteam.alter.domain.file.type.FileTargetType; +import com.dreamteam.alter.domain.user.context.AdminActor; +import jakarta.annotation.Resource; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +@Service("adminUploadFile") +@RequiredArgsConstructor +public class AdminUploadFile implements AdminUploadFileUseCase { + + @Resource(name = "fileUploadService") + private final FileUploadService fileUploadService; + + @Override + @Transactional + public AdminUploadFileResponseDto execute(AdminActor actor, MultipartFile file, FileTargetType targetType, BucketType bucketType) { + String fileId = fileUploadService.upload(file, targetType, bucketType, actor.getUserId()); + return AdminUploadFileResponseDto.of(fileId); + } +} diff --git a/src/main/java/com/dreamteam/alter/application/file/usecase/AttachFiles.java b/src/main/java/com/dreamteam/alter/application/file/usecase/AttachFiles.java new file mode 100644 index 00000000..999cdbe9 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/file/usecase/AttachFiles.java @@ -0,0 +1,44 @@ +package com.dreamteam.alter.application.file.usecase; + +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.file.entity.File; +import com.dreamteam.alter.domain.file.port.inbound.AttachFilesUseCase; +import com.dreamteam.alter.domain.file.port.outbound.FileQueryRepository; +import com.dreamteam.alter.domain.file.type.FileStatus; +import com.dreamteam.alter.domain.file.type.FileTargetType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service("attachFiles") +@RequiredArgsConstructor +public class AttachFiles implements AttachFilesUseCase { + + private final FileQueryRepository fileQueryRepository; + + @Override + @Transactional + public void execute(List fileIds, FileTargetType targetType, String targetId, Long userId) { + List files = fileQueryRepository.findAllByIdIn(fileIds); + + if (files.size() != fileIds.size()) { + throw new CustomException(ErrorCode.FILE_NOT_FOUND); + } + + for (File file : files) { + if (!file.getUploadedBy().equals(userId)) { + throw new CustomException(ErrorCode.FORBIDDEN); + } + if (file.getStatus() != FileStatus.PENDING) { + throw new CustomException(ErrorCode.FILE_ALREADY_ATTACHED); + } + if (file.getTargetType() != targetType) { + throw new CustomException(ErrorCode.INVALID_FILE); + } + file.attach(targetId); + } + } +} diff --git a/src/main/java/com/dreamteam/alter/application/file/usecase/CleanupOrphanFiles.java b/src/main/java/com/dreamteam/alter/application/file/usecase/CleanupOrphanFiles.java new file mode 100644 index 00000000..b4d54264 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/file/usecase/CleanupOrphanFiles.java @@ -0,0 +1,46 @@ +package com.dreamteam.alter.application.file.usecase; + +import com.dreamteam.alter.application.file.FileDeleteService; +import com.dreamteam.alter.domain.file.entity.File; +import com.dreamteam.alter.domain.file.port.inbound.CleanupOrphanFilesUseCase; +import com.dreamteam.alter.domain.file.port.outbound.FileQueryRepository; +import jakarta.annotation.Resource; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +@Slf4j +@Service("cleanupOrphanFiles") +@RequiredArgsConstructor +public class CleanupOrphanFiles implements CleanupOrphanFilesUseCase { + + private final FileQueryRepository fileQueryRepository; + + @Resource(name = "fileDeleteService") + private final FileDeleteService fileDeleteService; + + @Override + @Transactional + public void execute() { + LocalDateTime threshold = LocalDateTime.now().minusHours(24); + List orphanFiles = fileQueryRepository.findOrphanFiles(threshold); + + int successCount = 0; + int failureCount = 0; + for (File file : orphanFiles) { + try { + fileDeleteService.delete(file); + successCount++; + } catch (Exception e) { + log.error("Failed to cleanup orphan file id={}, key={}", file.getId(), file.getStoredKey(), e); + failureCount++; + } + } + + log.info("Orphan file cleanup completed. total={}, success={}, failed={}", orphanFiles.size(), successCount, failureCount); + } +} diff --git a/src/main/java/com/dreamteam/alter/application/file/usecase/ManagerDeleteFile.java b/src/main/java/com/dreamteam/alter/application/file/usecase/ManagerDeleteFile.java new file mode 100644 index 00000000..1c602b73 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/file/usecase/ManagerDeleteFile.java @@ -0,0 +1,33 @@ +package com.dreamteam.alter.application.file.usecase; + +import com.dreamteam.alter.application.file.FileDeleteService; +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.file.entity.File; +import com.dreamteam.alter.domain.file.port.inbound.ManagerDeleteFileUseCase; +import com.dreamteam.alter.domain.file.port.outbound.FileQueryRepository; +import com.dreamteam.alter.domain.user.context.ManagerActor; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service("managerDeleteFile") +@RequiredArgsConstructor +public class ManagerDeleteFile implements ManagerDeleteFileUseCase { + + private final FileQueryRepository fileQueryRepository; + private final FileDeleteService fileDeleteService; + + @Override + @Transactional + public void execute(ManagerActor actor, String fileId) { + File file = fileQueryRepository.findById(fileId) + .orElseThrow(() -> new CustomException(ErrorCode.FILE_NOT_FOUND)); + + if (!file.getUploadedBy().equals(actor.getUserId())) { + throw new CustomException(ErrorCode.FORBIDDEN); + } + + fileDeleteService.delete(file); + } +} diff --git a/src/main/java/com/dreamteam/alter/application/file/usecase/ManagerGetPresignedUrl.java b/src/main/java/com/dreamteam/alter/application/file/usecase/ManagerGetPresignedUrl.java new file mode 100644 index 00000000..f415d08a --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/file/usecase/ManagerGetPresignedUrl.java @@ -0,0 +1,34 @@ +package com.dreamteam.alter.application.file.usecase; + +import com.dreamteam.alter.adapter.inbound.common.dto.FilePresignedUrlResponseDto; +import com.dreamteam.alter.application.file.FileUrlService; +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.file.entity.File; +import com.dreamteam.alter.domain.file.port.inbound.ManagerGetPresignedUrlUseCase; +import com.dreamteam.alter.domain.file.port.outbound.FileQueryRepository; +import com.dreamteam.alter.domain.user.context.ManagerActor; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service("managerGetPresignedUrl") +@RequiredArgsConstructor +public class ManagerGetPresignedUrl implements ManagerGetPresignedUrlUseCase { + + private final FileQueryRepository fileQueryRepository; + private final FileUrlService fileUrlService; + + @Override + @Transactional(readOnly = true) + public FilePresignedUrlResponseDto execute(ManagerActor actor, String fileId) { + File file = fileQueryRepository.findById(fileId) + .orElseThrow(() -> new CustomException(ErrorCode.FILE_NOT_FOUND)); + + if (!file.getUploadedBy().equals(actor.getUserId())) { + throw new CustomException(ErrorCode.FORBIDDEN); + } + + return FilePresignedUrlResponseDto.of(fileUrlService.getPresignedUrl(file)); + } +} diff --git a/src/main/java/com/dreamteam/alter/application/file/usecase/ManagerUploadFile.java b/src/main/java/com/dreamteam/alter/application/file/usecase/ManagerUploadFile.java new file mode 100644 index 00000000..3410c3be --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/file/usecase/ManagerUploadFile.java @@ -0,0 +1,28 @@ +package com.dreamteam.alter.application.file.usecase; + +import com.dreamteam.alter.adapter.inbound.manager.file.dto.ManagerUploadFileResponseDto; +import com.dreamteam.alter.application.file.FileUploadService; +import com.dreamteam.alter.domain.file.port.inbound.ManagerUploadFileUseCase; +import com.dreamteam.alter.domain.file.type.BucketType; +import com.dreamteam.alter.domain.file.type.FileTargetType; +import com.dreamteam.alter.domain.user.context.ManagerActor; +import jakarta.annotation.Resource; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +@Service("managerUploadFile") +@RequiredArgsConstructor +public class ManagerUploadFile implements ManagerUploadFileUseCase { + + @Resource(name = "fileUploadService") + private final FileUploadService fileUploadService; + + @Override + @Transactional + public ManagerUploadFileResponseDto execute(ManagerActor actor, MultipartFile file, FileTargetType targetType, BucketType bucketType) { + String fileId = fileUploadService.upload(file, targetType, bucketType, actor.getUserId()); + return ManagerUploadFileResponseDto.of(fileId); + } +} diff --git a/src/main/java/com/dreamteam/alter/common/config/AwsS3Config.java b/src/main/java/com/dreamteam/alter/common/config/AwsS3Config.java new file mode 100644 index 00000000..40a603c3 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/common/config/AwsS3Config.java @@ -0,0 +1,64 @@ +package com.dreamteam.alter.common.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.util.StringUtils; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration; +import software.amazon.awssdk.core.retry.RetryMode; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.S3ClientBuilder; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +import java.time.Duration; + +@Configuration +@RequiredArgsConstructor +public class AwsS3Config { + + @Value("${aws.s3.region}") + private String region; + + @Value("${aws.access-key:}") + private String accessKey; + + @Value("${aws.secret-key:}") + private String secretKey; + + @Bean + public S3Client awsS3Client() { + ClientOverrideConfiguration overrideConfig = ClientOverrideConfiguration.builder() + .retryStrategy(RetryMode.STANDARD) + .apiCallAttemptTimeout(Duration.ofSeconds(10)) + .apiCallTimeout(Duration.ofSeconds(30)) + .build(); + + S3ClientBuilder builder = S3Client.builder() + .region(Region.of(region)) + .overrideConfiguration(overrideConfig); + + if (StringUtils.hasText(accessKey) && StringUtils.hasText(secretKey)) { + AwsBasicCredentials credentials = AwsBasicCredentials.create(accessKey, secretKey); + builder.credentialsProvider(StaticCredentialsProvider.create(credentials)); + } + + return builder.build(); + } + + @Bean + public S3Presigner s3Presigner() { + S3Presigner.Builder builder = S3Presigner.builder() + .region(Region.of(region)); + + if (StringUtils.hasText(accessKey) && StringUtils.hasText(secretKey)) { + AwsBasicCredentials credentials = AwsBasicCredentials.create(accessKey, secretKey); + builder.credentialsProvider(StaticCredentialsProvider.create(credentials)); + } + + return builder.build(); + } +} diff --git a/src/main/java/com/dreamteam/alter/common/exception/ErrorCode.java b/src/main/java/com/dreamteam/alter/common/exception/ErrorCode.java index b141be56..d3702580 100644 --- a/src/main/java/com/dreamteam/alter/common/exception/ErrorCode.java +++ b/src/main/java/com/dreamteam/alter/common/exception/ErrorCode.java @@ -42,11 +42,18 @@ public enum ErrorCode { WORKSPACE_WORKER_ALREADY_EXISTS(400, "B018", "이미 근무중인 사용자입니다."), NOT_FOUND(404, "B019", "요청한 리소스를 찾을 수 없습니다."), CONFLICT(409, "B020", "변경할 수 없는 상태입니다."), + FILE_NOT_FOUND(404, "B021", "존재하지 않는 파일입니다."), + INVALID_FILE(400, "B022", "유효하지 않은 파일입니다."), + INVALID_FILE_TYPE(400, "B023", "허용되지 않는 파일 형식입니다."), + FILE_SIZE_EXCEEDED(400, "B024", "파일 크기가 제한을 초과합니다."), + FILE_ALREADY_ATTACHED(409, "B025", "이미 연결된 파일입니다."), TOO_MANY_REQUESTS(429, "E001", "요청이 너무 많습니다. 잠시 후 다시 시도해주세요."), INTERNAL_SERVER_ERROR(400, "C001", "서버 내부 오류입니다."), EXTERNAL_API_ERROR(502, "C002", "외부 API 연동에 실패했습니다."), + FILE_UPLOAD_FAILED(500, "C003", "파일 업로드에 실패했습니다."), + FILE_DELETE_FAILED(500, "C004", "파일 삭제에 실패했습니다."), ; private final int status; diff --git a/src/main/java/com/dreamteam/alter/common/exception/handler/GlobalExceptionHandler.java b/src/main/java/com/dreamteam/alter/common/exception/handler/GlobalExceptionHandler.java index 78fcf2ce..e90ba081 100644 --- a/src/main/java/com/dreamteam/alter/common/exception/handler/GlobalExceptionHandler.java +++ b/src/main/java/com/dreamteam/alter/common/exception/handler/GlobalExceptionHandler.java @@ -9,8 +9,10 @@ import jakarta.validation.ConstraintViolationException; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.multipart.support.MissingServletRequestPartException; import java.util.List; @@ -65,4 +67,18 @@ public ResponseEntity>> handleConstraintVio return ResponseEntity.status(errorCode.getStatus()).body(ErrorResponse.of(errorCode, details)); } + @ExceptionHandler(MissingServletRequestPartException.class) + public ResponseEntity> handleMissingServletRequestPartException(MissingServletRequestPartException e) { + ErrorCode errorCode = ErrorCode.ILLEGAL_ARGUMENT; + return ResponseEntity.status(errorCode.getStatus()) + .body(ErrorResponse.of(errorCode, "Multipart가 누락됐습니다.")); + } + + @ExceptionHandler(MissingServletRequestParameterException.class) + public ResponseEntity> handleMissingServletRequestParameterException(MissingServletRequestParameterException e) { + ErrorCode errorCode = ErrorCode.ILLEGAL_ARGUMENT; + return ResponseEntity.status(errorCode.getStatus()) + .body(ErrorResponse.of(errorCode, "파라미터가 누락됐습니다.")); + } + } diff --git a/src/main/java/com/dreamteam/alter/common/util/FileValidator.java b/src/main/java/com/dreamteam/alter/common/util/FileValidator.java new file mode 100644 index 00000000..1dd9aa88 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/common/util/FileValidator.java @@ -0,0 +1,58 @@ +package com.dreamteam.alter.common.util; + +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.file.type.FileTargetType; +import org.springframework.util.ObjectUtils; +import org.springframework.web.multipart.MultipartFile; + +import java.nio.file.Paths; +import java.util.Set; +import java.util.UUID; + +public class FileValidator { + + private static final long MAX_FILE_SIZE = 20L * 1024 * 1024; // 20MB + + private static final Set ALLOWED_CONTENT_TYPES = Set.of( + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + "application/pdf" + ); + + public static void validate(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new CustomException(ErrorCode.INVALID_FILE); + } + if (file.getSize() > MAX_FILE_SIZE) { + throw new CustomException(ErrorCode.FILE_SIZE_EXCEEDED); + } + String contentType = file.getContentType(); + if (contentType == null || !ALLOWED_CONTENT_TYPES.contains(contentType)) { + throw new CustomException(ErrorCode.INVALID_FILE_TYPE); + } + } + + public static String sanitizeFileName(String fileName) { + if (fileName == null) { + return "unknown"; + } + return Paths.get(fileName).getFileName().toString(); + } + + public static String buildStoredKey(FileTargetType targetType, String extension) { + String uuid = UUID.randomUUID().toString().replace("-", ""); + return targetType.name().toLowerCase() + "/" + uuid + extension; + } + + public static String extractExtension(String fileName) { + if (ObjectUtils.isEmpty(fileName) || !fileName.contains(".")) { + return ""; + } + + String ext = fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase(); + return ext.isEmpty() ? "" : "." + ext; + } +} diff --git a/src/main/java/com/dreamteam/alter/domain/file/PresignedUrlResult.java b/src/main/java/com/dreamteam/alter/domain/file/PresignedUrlResult.java new file mode 100644 index 00000000..903b0129 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/file/PresignedUrlResult.java @@ -0,0 +1,5 @@ +package com.dreamteam.alter.domain.file; + +import java.time.Instant; + +public record PresignedUrlResult(String url, Instant expiresAt) {} diff --git a/src/main/java/com/dreamteam/alter/domain/file/entity/File.java b/src/main/java/com/dreamteam/alter/domain/file/entity/File.java new file mode 100644 index 00000000..b13c1dec --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/file/entity/File.java @@ -0,0 +1,105 @@ +package com.dreamteam.alter.domain.file.entity; + +import com.dreamteam.alter.domain.file.type.BucketType; +import com.dreamteam.alter.domain.file.type.FileStatus; +import com.dreamteam.alter.domain.file.type.FileTargetType; +import com.fasterxml.uuid.Generators; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "files") +@EntityListeners(AuditingEntityListener.class) +@Getter +@Builder(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class File { + + @Id + @Column(name = "id", nullable = false, unique = true) + private String id; + + @Enumerated(EnumType.STRING) + @Column(name = "target_type", nullable = false) + private FileTargetType targetType; + + @Column(name = "target_id") + private String targetId; + + @Column(name = "original_file_name", nullable = false) + private String originalFileName; + + @Column(name = "stored_key", nullable = false, unique = true) + private String storedKey; + + @Column(name = "file_url") + private String fileUrl; + + @Column(name = "content_type", nullable = false) + private String contentType; + + @Column(name = "file_size", nullable = false) + private Long fileSize; + + @Enumerated(EnumType.STRING) + @Column(name = "bucket_type", nullable = false) + private BucketType bucketType; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private FileStatus status; + + @Column(name = "uploaded_by", nullable = false) + private Long uploadedBy; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + public static File create( + FileTargetType targetType, + String originalFileName, + String storedKey, + String fileUrl, + String contentType, + Long fileSize, + BucketType bucketType, + Long uploadedBy + ) { + return File.builder() + .id(Generators.timeBasedEpochGenerator().generate().toString()) + .targetType(targetType) + .originalFileName(originalFileName) + .storedKey(storedKey) + .fileUrl(fileUrl) + .contentType(contentType) + .fileSize(fileSize) + .bucketType(bucketType) + .status(FileStatus.PENDING) + .uploadedBy(uploadedBy) + .build(); + } + + public void attach(String targetId) { + this.targetId = targetId; + this.status = FileStatus.ATTACHED; + } + + public void markDeleted() { + this.status = FileStatus.DELETED; + } +} diff --git a/src/main/java/com/dreamteam/alter/domain/file/port/inbound/AdminDeleteFileUseCase.java b/src/main/java/com/dreamteam/alter/domain/file/port/inbound/AdminDeleteFileUseCase.java new file mode 100644 index 00000000..a64d0973 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/file/port/inbound/AdminDeleteFileUseCase.java @@ -0,0 +1,7 @@ +package com.dreamteam.alter.domain.file.port.inbound; + +import com.dreamteam.alter.domain.user.context.AdminActor; + +public interface AdminDeleteFileUseCase { + void execute(AdminActor actor, String fileId); +} diff --git a/src/main/java/com/dreamteam/alter/domain/file/port/inbound/AdminGetPresignedUrlUseCase.java b/src/main/java/com/dreamteam/alter/domain/file/port/inbound/AdminGetPresignedUrlUseCase.java new file mode 100644 index 00000000..19caa80d --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/file/port/inbound/AdminGetPresignedUrlUseCase.java @@ -0,0 +1,8 @@ +package com.dreamteam.alter.domain.file.port.inbound; + +import com.dreamteam.alter.adapter.inbound.common.dto.FilePresignedUrlResponseDto; +import com.dreamteam.alter.domain.user.context.AdminActor; + +public interface AdminGetPresignedUrlUseCase { + FilePresignedUrlResponseDto execute(AdminActor actor, String fileId); +} diff --git a/src/main/java/com/dreamteam/alter/domain/file/port/inbound/AdminUploadFileUseCase.java b/src/main/java/com/dreamteam/alter/domain/file/port/inbound/AdminUploadFileUseCase.java new file mode 100644 index 00000000..edc414ce --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/file/port/inbound/AdminUploadFileUseCase.java @@ -0,0 +1,11 @@ +package com.dreamteam.alter.domain.file.port.inbound; + +import com.dreamteam.alter.adapter.inbound.admin.file.dto.AdminUploadFileResponseDto; +import com.dreamteam.alter.domain.file.type.BucketType; +import com.dreamteam.alter.domain.file.type.FileTargetType; +import com.dreamteam.alter.domain.user.context.AdminActor; +import org.springframework.web.multipart.MultipartFile; + +public interface AdminUploadFileUseCase { + AdminUploadFileResponseDto execute(AdminActor actor, MultipartFile file, FileTargetType targetType, BucketType bucketType); +} diff --git a/src/main/java/com/dreamteam/alter/domain/file/port/inbound/AttachFilesUseCase.java b/src/main/java/com/dreamteam/alter/domain/file/port/inbound/AttachFilesUseCase.java new file mode 100644 index 00000000..b71fe9e9 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/file/port/inbound/AttachFilesUseCase.java @@ -0,0 +1,9 @@ +package com.dreamteam.alter.domain.file.port.inbound; + +import com.dreamteam.alter.domain.file.type.FileTargetType; + +import java.util.List; + +public interface AttachFilesUseCase { + void execute(List fileIds, FileTargetType targetType, String targetId, Long userId); +} diff --git a/src/main/java/com/dreamteam/alter/domain/file/port/inbound/CleanupOrphanFilesUseCase.java b/src/main/java/com/dreamteam/alter/domain/file/port/inbound/CleanupOrphanFilesUseCase.java new file mode 100644 index 00000000..0c4256f5 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/file/port/inbound/CleanupOrphanFilesUseCase.java @@ -0,0 +1,5 @@ +package com.dreamteam.alter.domain.file.port.inbound; + +public interface CleanupOrphanFilesUseCase { + void execute(); +} diff --git a/src/main/java/com/dreamteam/alter/domain/file/port/inbound/ManagerDeleteFileUseCase.java b/src/main/java/com/dreamteam/alter/domain/file/port/inbound/ManagerDeleteFileUseCase.java new file mode 100644 index 00000000..30e97d21 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/file/port/inbound/ManagerDeleteFileUseCase.java @@ -0,0 +1,7 @@ +package com.dreamteam.alter.domain.file.port.inbound; + +import com.dreamteam.alter.domain.user.context.ManagerActor; + +public interface ManagerDeleteFileUseCase { + void execute(ManagerActor actor, String fileId); +} diff --git a/src/main/java/com/dreamteam/alter/domain/file/port/inbound/ManagerGetPresignedUrlUseCase.java b/src/main/java/com/dreamteam/alter/domain/file/port/inbound/ManagerGetPresignedUrlUseCase.java new file mode 100644 index 00000000..b183e3c5 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/file/port/inbound/ManagerGetPresignedUrlUseCase.java @@ -0,0 +1,8 @@ +package com.dreamteam.alter.domain.file.port.inbound; + +import com.dreamteam.alter.adapter.inbound.common.dto.FilePresignedUrlResponseDto; +import com.dreamteam.alter.domain.user.context.ManagerActor; + +public interface ManagerGetPresignedUrlUseCase { + FilePresignedUrlResponseDto execute(ManagerActor actor, String fileId); +} diff --git a/src/main/java/com/dreamteam/alter/domain/file/port/inbound/ManagerUploadFileUseCase.java b/src/main/java/com/dreamteam/alter/domain/file/port/inbound/ManagerUploadFileUseCase.java new file mode 100644 index 00000000..386a26b2 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/file/port/inbound/ManagerUploadFileUseCase.java @@ -0,0 +1,11 @@ +package com.dreamteam.alter.domain.file.port.inbound; + +import com.dreamteam.alter.adapter.inbound.manager.file.dto.ManagerUploadFileResponseDto; +import com.dreamteam.alter.domain.file.type.BucketType; +import com.dreamteam.alter.domain.file.type.FileTargetType; +import com.dreamteam.alter.domain.user.context.ManagerActor; +import org.springframework.web.multipart.MultipartFile; + +public interface ManagerUploadFileUseCase { + ManagerUploadFileResponseDto execute(ManagerActor actor, MultipartFile file, FileTargetType targetType, BucketType bucketType); +} diff --git a/src/main/java/com/dreamteam/alter/domain/file/port/outbound/FileQueryRepository.java b/src/main/java/com/dreamteam/alter/domain/file/port/outbound/FileQueryRepository.java new file mode 100644 index 00000000..2d370967 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/file/port/outbound/FileQueryRepository.java @@ -0,0 +1,15 @@ +package com.dreamteam.alter.domain.file.port.outbound; + +import com.dreamteam.alter.domain.file.entity.File; +import com.dreamteam.alter.domain.file.type.FileTargetType; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +public interface FileQueryRepository { + Optional findById(String id); + List findAllByIdIn(List ids); + List findAllByTargetTypeAndTargetId(FileTargetType targetType, String targetId); + List findOrphanFiles(LocalDateTime before); +} diff --git a/src/main/java/com/dreamteam/alter/domain/file/port/outbound/FileRepository.java b/src/main/java/com/dreamteam/alter/domain/file/port/outbound/FileRepository.java new file mode 100644 index 00000000..1f52c107 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/file/port/outbound/FileRepository.java @@ -0,0 +1,10 @@ +package com.dreamteam.alter.domain.file.port.outbound; + +import com.dreamteam.alter.domain.file.entity.File; + +import java.util.List; + +public interface FileRepository { + File save(File file); + List saveAll(List files); +} diff --git a/src/main/java/com/dreamteam/alter/domain/file/port/outbound/PresignedUrlCacheRepository.java b/src/main/java/com/dreamteam/alter/domain/file/port/outbound/PresignedUrlCacheRepository.java new file mode 100644 index 00000000..c2c3e45c --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/file/port/outbound/PresignedUrlCacheRepository.java @@ -0,0 +1,11 @@ +package com.dreamteam.alter.domain.file.port.outbound; + +import com.dreamteam.alter.domain.file.PresignedUrlResult; + +import java.util.Optional; + +public interface PresignedUrlCacheRepository { + Optional findByFileId(String fileId); + void save(String fileId, PresignedUrlResult result); + void deleteByFileId(String fileId); +} diff --git a/src/main/java/com/dreamteam/alter/domain/file/port/outbound/S3Client.java b/src/main/java/com/dreamteam/alter/domain/file/port/outbound/S3Client.java new file mode 100644 index 00000000..32f6f922 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/file/port/outbound/S3Client.java @@ -0,0 +1,11 @@ +package com.dreamteam.alter.domain.file.port.outbound; + +import com.dreamteam.alter.domain.file.PresignedUrlResult; +import com.dreamteam.alter.domain.file.type.BucketType; +import org.springframework.web.multipart.MultipartFile; + +public interface S3Client { + String upload(MultipartFile file, String storedKey, BucketType bucketType); + PresignedUrlResult getPresignedUrl(String storedKey, BucketType bucketType); + void delete(String storedKey, BucketType bucketType); +} diff --git a/src/main/java/com/dreamteam/alter/domain/file/type/BucketType.java b/src/main/java/com/dreamteam/alter/domain/file/type/BucketType.java new file mode 100644 index 00000000..4400d0bb --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/file/type/BucketType.java @@ -0,0 +1,6 @@ +package com.dreamteam.alter.domain.file.type; + +public enum BucketType { + PUBLIC, + PRIVATE +} diff --git a/src/main/java/com/dreamteam/alter/domain/file/type/FileStatus.java b/src/main/java/com/dreamteam/alter/domain/file/type/FileStatus.java new file mode 100644 index 00000000..1f8741ee --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/file/type/FileStatus.java @@ -0,0 +1,7 @@ +package com.dreamteam.alter.domain.file.type; + +public enum FileStatus { + PENDING, + ATTACHED, + DELETED +} diff --git a/src/main/java/com/dreamteam/alter/domain/file/type/FileTargetType.java b/src/main/java/com/dreamteam/alter/domain/file/type/FileTargetType.java new file mode 100644 index 00000000..e1478477 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/file/type/FileTargetType.java @@ -0,0 +1,9 @@ +package com.dreamteam.alter.domain.file.type; + +public enum FileTargetType { + USER_PROFILE, + USER_CERTIFICATE, + POSTING, + WORKSPACE, + CHAT_MESSAGE +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 7a1d0a8d..1913ba43 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -7,6 +7,10 @@ server: spring: application: name: alter + servlet: + multipart: + max-file-size: 20MB + max-request-size: 20MB datasource: driver-class-name: org.postgresql.Driver url: ${DB_URL} @@ -72,3 +76,8 @@ aws: region: ${AWS_REGION} access-key: ${AWS_ACCESS_KEY_ID} secret-key: ${AWS_SECRET_ACCESS_KEY} + s3: + region: ${AWS_S3_REGION} + public-bucket: ${AWS_S3_PUBLIC_BUCKET} + private-bucket: ${AWS_S3_PRIVATE_BUCKET} + presigned-url-expiration-minutes: ${AWS_S3_PRESIGNED_URL_EXPIRATION_MINUTES} diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 8809f35b..49ae696e 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -67,3 +67,8 @@ aws: region: mockocko access-key: mockmockmockmock secret-key: mockmockmockmock + s3: + region: mockocko + public-bucket: mock-public-bucket + private-bucket: mock-private-bucket + presigned-url-expiration-minutes: 30