Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
Original file line number Diff line number Diff line change
@@ -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<CommonApiResponse<AdminUploadFileResponseDto>> 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<CommonApiResponse<FilePresignedUrlResponseDto>> getPresignedUrl(
@PathVariable String fileId
) {
AdminActor actor = AdminActionContext.getInstance().getActor();
return ResponseEntity.ok(CommonApiResponse.of(adminGetPresignedUrl.execute(actor, fileId)));
}

@Override
@DeleteMapping("/{fileId}")
public ResponseEntity<CommonApiResponse<Void>> deleteFile(
@PathVariable String fileId
) {
AdminActor actor = AdminActionContext.getInstance().getActor();
adminDeleteFile.execute(actor, fileId);
return ResponseEntity.ok(CommonApiResponse.empty());
}
}
Original file line number Diff line number Diff line change
@@ -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<CommonApiResponse<AdminUploadFileResponseDto>> 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<CommonApiResponse<FilePresignedUrlResponseDto>> 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<CommonApiResponse<Void>> deleteFile(
@PathVariable String fileId
);
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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());
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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<CommonApiResponse<ManagerUploadFileResponseDto>> 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<CommonApiResponse<FilePresignedUrlResponseDto>> getPresignedUrl(
@PathVariable String fileId
) {
ManagerActor actor = ManagerActionContext.getInstance().getActor();
return ResponseEntity.ok(CommonApiResponse.of(managerGetPresignedUrl.execute(actor, fileId)));
}

@Override
@DeleteMapping("/{fileId}")
public ResponseEntity<CommonApiResponse<Void>> deleteFile(
@PathVariable String fileId
) {
ManagerActor actor = ManagerActionContext.getInstance().getActor();
managerDeleteFile.execute(actor, fileId);
return ResponseEntity.ok(CommonApiResponse.empty());
}
}
Original file line number Diff line number Diff line change
@@ -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<CommonApiResponse<ManagerUploadFileResponseDto>> 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<CommonApiResponse<FilePresignedUrlResponseDto>> 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<CommonApiResponse<Void>> deleteFile(
@PathVariable String fileId
);
}
Loading