From 08f6fd48bc8871d2b751bbfcaaec3b2fa9675dae Mon Sep 17 00:00:00 2001 From: jjunh33 Date: Wed, 30 Sep 2026 16:05:54 +0900 Subject: [PATCH 01/11] =?UTF-8?q?refactor:=20file=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=EC=9D=84=20core:domain:file=EB=A1=9C=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=ED=95=98=EA=B3=A0=20=EA=B3=B5=EA=B0=9C=20URL=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit internal은 운영진 전용인데 file은 학생도 보는 공지·물품 이미지에 쓰여 위치가 맞지 않아 core:domain:internal의 file 서브도메인을 core:domain:file 모듈로 독립시켰다. config/display/schedule은 internal에 그대로 둔다. 공개 버킷 정책(고정 base URL 조립)에 맞춰 FileUrl/FileInfo를 신설하고, FileStorageClient/FileRepository/FileService에 key·id 기반 URL 조회를 추가했다. 로컬 스토리지는 아직 실제 GET 서빙이 없어 LocalFileServingConfig로 정적 리소스 핸들러를 추가하고 PublicEndpoints에 공개 경로를 등록했다. --- api/admin-api/build.gradle.kts | 1 + .../{internal => }/file/AdminFileApi.java | 8 +- .../file/AdminFileController.java | 10 +- .../file/AdminLocalFileUploadApi.java | 4 +- .../file/AdminLocalFileUploadController.java | 4 +- .../admin/file/LocalFileServingConfig.java | 25 ++++ .../request/FileUploadUrlIssueRequest.java | 6 +- .../response/FileUploadUrlIssueResponse.java | 4 +- core/domain/file/build.gradle.kts | 17 +++ .../stream/file/client/FileStorageClient.java | 23 ++++ .../ac/kookmin/stream}/file/domain/File.java | 2 +- .../stream}/file/domain/FileCategory.java | 2 +- .../stream}/file/domain/FileErrorCode.java | 2 +- .../kookmin/stream/file/domain/FileInfo.java | 3 + .../domain/FileUploadUrlIssueCommand.java | 2 +- .../file/domain/FileUploadUrlIssueResult.java | 2 +- .../kookmin/stream/file/domain/FileUrl.java | 3 + .../stream}/file/domain/UploadUrl.java | 2 +- .../ac/kookmin/stream/file/package-info.java | 8 ++ .../file/repository/FileRepository.java | 6 +- .../stream/file/service/FileService.java | 22 +++ .../file/service/impl/FileServiceImpl.java | 130 ++++++++++++++++++ .../file/service/impl/FileUploadPolicy.java | 6 +- .../domain/file/client/FileStorageClient.java | 10 -- .../domain/file/service/FileService.java | 11 -- .../file/service/impl/FileServiceImpl.java | 70 ---------- .../stream/security/PublicEndpoints.java | 4 + infrastructure/client/build.gradle.kts | 2 +- .../file/local/LocalFileStorageClient.java | 19 ++- .../local/LocalFileStorageProperties.java | 1 + .../client/file/s3/S3FileStorageClient.java | 19 ++- .../file/s3/S3FileStorageProperties.java | 1 + .../application-infrastructure-client.yml | 2 + infrastructure/db/build.gradle.kts | 1 + .../db/{internal => file}/FileJpaEntity.java | 6 +- .../{internal => file}/FileJpaRepository.java | 4 +- .../FileRepositoryImpl.java | 12 +- settings.gradle.kts | 1 + 38 files changed, 323 insertions(+), 132 deletions(-) rename api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/{internal => }/file/AdminFileApi.java (83%) rename api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/{internal => }/file/AdminFileController.java (78%) rename api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/{internal => }/file/AdminLocalFileUploadApi.java (90%) rename api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/{internal => }/file/AdminLocalFileUploadController.java (91%) create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/file/LocalFileServingConfig.java rename api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/{internal => }/file/request/FileUploadUrlIssueRequest.java (77%) rename api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/{internal => }/file/response/FileUploadUrlIssueResponse.java (72%) create mode 100644 core/domain/file/build.gradle.kts create mode 100644 core/domain/file/src/main/java/kr/ac/kookmin/stream/file/client/FileStorageClient.java rename core/domain/{internal/src/main/java/kr/ac/kookmin/stream/internal/domain => file/src/main/java/kr/ac/kookmin/stream}/file/domain/File.java (94%) rename core/domain/{internal/src/main/java/kr/ac/kookmin/stream/internal/domain => file/src/main/java/kr/ac/kookmin/stream}/file/domain/FileCategory.java (66%) rename core/domain/{internal/src/main/java/kr/ac/kookmin/stream/internal/domain => file/src/main/java/kr/ac/kookmin/stream}/file/domain/FileErrorCode.java (91%) create mode 100644 core/domain/file/src/main/java/kr/ac/kookmin/stream/file/domain/FileInfo.java rename core/domain/{internal/src/main/java/kr/ac/kookmin/stream/internal/domain => file/src/main/java/kr/ac/kookmin/stream}/file/domain/FileUploadUrlIssueCommand.java (73%) rename core/domain/{internal/src/main/java/kr/ac/kookmin/stream/internal/domain => file/src/main/java/kr/ac/kookmin/stream}/file/domain/FileUploadUrlIssueResult.java (86%) create mode 100644 core/domain/file/src/main/java/kr/ac/kookmin/stream/file/domain/FileUrl.java rename core/domain/{internal/src/main/java/kr/ac/kookmin/stream/internal/domain => file/src/main/java/kr/ac/kookmin/stream}/file/domain/UploadUrl.java (62%) create mode 100644 core/domain/file/src/main/java/kr/ac/kookmin/stream/file/package-info.java rename core/domain/{internal/src/main/java/kr/ac/kookmin/stream/internal/domain => file/src/main/java/kr/ac/kookmin/stream}/file/repository/FileRepository.java (56%) create mode 100644 core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/FileService.java create mode 100644 core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/impl/FileServiceImpl.java rename core/domain/{internal/src/main/java/kr/ac/kookmin/stream/internal/domain => file/src/main/java/kr/ac/kookmin/stream}/file/service/impl/FileUploadPolicy.java (91%) delete mode 100644 core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/client/FileStorageClient.java delete mode 100644 core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/service/FileService.java delete mode 100644 core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/service/impl/FileServiceImpl.java rename infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/{internal => file}/FileJpaEntity.java (91%) rename infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/{internal => file}/FileJpaRepository.java (66%) rename infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/{internal => file}/FileRepositoryImpl.java (70%) diff --git a/api/admin-api/build.gradle.kts b/api/admin-api/build.gradle.kts index bd5f43b3..3d032c0f 100644 --- a/api/admin-api/build.gradle.kts +++ b/api/admin-api/build.gradle.kts @@ -8,6 +8,7 @@ dependencies { implementation(project(":api:common-api")) implementation(project(":core:common")) implementation(project(":core:domain:internal")) + implementation(project(":core:domain:file")) implementation(project(":core:domain:member")) implementation(project(":core:domain:welfare")) implementation(project(":gateway:auth")) diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/file/AdminFileApi.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/file/AdminFileApi.java similarity index 83% rename from api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/file/AdminFileApi.java rename to api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/file/AdminFileApi.java index 14442893..5a0d7c65 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/file/AdminFileApi.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/file/AdminFileApi.java @@ -1,13 +1,13 @@ -package kr.ac.kookmin.stream.api.admin.internal.file; +package kr.ac.kookmin.stream.api.admin.file; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; -import kr.ac.kookmin.stream.api.admin.internal.file.request.FileUploadUrlIssueRequest; -import kr.ac.kookmin.stream.api.admin.internal.file.response.FileUploadUrlIssueResponse; +import kr.ac.kookmin.stream.api.admin.file.request.FileUploadUrlIssueRequest; +import kr.ac.kookmin.stream.api.admin.file.response.FileUploadUrlIssueResponse; import kr.ac.kookmin.stream.api.common.openapi.ApiErrorCode; import kr.ac.kookmin.stream.api.common.dto.ApiResponse; import kr.ac.kookmin.stream.common.CommonErrorCode; -import kr.ac.kookmin.stream.internal.domain.file.domain.FileErrorCode; +import kr.ac.kookmin.stream.file.domain.FileErrorCode; /** * 운영진 파일 API의 문서 명세. 구현은 {@link AdminFileController}가 맡는다. diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/file/AdminFileController.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/file/AdminFileController.java similarity index 78% rename from api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/file/AdminFileController.java rename to api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/file/AdminFileController.java index 2a90962b..7805b702 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/file/AdminFileController.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/file/AdminFileController.java @@ -1,12 +1,12 @@ -package kr.ac.kookmin.stream.api.admin.internal.file; +package kr.ac.kookmin.stream.api.admin.file; import jakarta.validation.Valid; -import kr.ac.kookmin.stream.api.admin.internal.file.request.FileUploadUrlIssueRequest; -import kr.ac.kookmin.stream.api.admin.internal.file.response.FileUploadUrlIssueResponse; +import kr.ac.kookmin.stream.api.admin.file.request.FileUploadUrlIssueRequest; +import kr.ac.kookmin.stream.api.admin.file.response.FileUploadUrlIssueResponse; import kr.ac.kookmin.stream.api.common.dto.ApiResponse; import kr.ac.kookmin.stream.common.PrincipalProvider; -import kr.ac.kookmin.stream.internal.domain.file.domain.FileUploadUrlIssueResult; -import kr.ac.kookmin.stream.internal.domain.file.service.FileService; +import kr.ac.kookmin.stream.file.domain.FileUploadUrlIssueResult; +import kr.ac.kookmin.stream.file.service.FileService; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PathVariable; diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/file/AdminLocalFileUploadApi.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/file/AdminLocalFileUploadApi.java similarity index 90% rename from api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/file/AdminLocalFileUploadApi.java rename to api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/file/AdminLocalFileUploadApi.java index 79b813a3..c7dae21b 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/file/AdminLocalFileUploadApi.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/file/AdminLocalFileUploadApi.java @@ -1,4 +1,4 @@ -package kr.ac.kookmin.stream.api.admin.internal.file; +package kr.ac.kookmin.stream.api.admin.file; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -6,7 +6,7 @@ import java.io.IOException; import kr.ac.kookmin.stream.api.common.openapi.ApiErrorCode; import kr.ac.kookmin.stream.api.common.dto.ApiResponse; -import kr.ac.kookmin.stream.internal.domain.file.domain.FileErrorCode; +import kr.ac.kookmin.stream.file.domain.FileErrorCode; /** * 로컬 업로드 수신 API의 문서 명세. 구현은 {@link AdminLocalFileUploadController}가 맡는다. diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/file/AdminLocalFileUploadController.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/file/AdminLocalFileUploadController.java similarity index 91% rename from api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/file/AdminLocalFileUploadController.java rename to api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/file/AdminLocalFileUploadController.java index 96d07a3a..b6a82b25 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/file/AdminLocalFileUploadController.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/file/AdminLocalFileUploadController.java @@ -1,9 +1,9 @@ -package kr.ac.kookmin.stream.api.admin.internal.file; +package kr.ac.kookmin.stream.api.admin.file; import jakarta.servlet.http.HttpServletRequest; import java.io.IOException; import kr.ac.kookmin.stream.api.common.dto.ApiResponse; -import kr.ac.kookmin.stream.internal.domain.file.service.FileService; +import kr.ac.kookmin.stream.file.service.FileService; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestMapping; diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/file/LocalFileServingConfig.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/file/LocalFileServingConfig.java new file mode 100644 index 00000000..be3fdf8c --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/file/LocalFileServingConfig.java @@ -0,0 +1,25 @@ +package kr.ac.kookmin.stream.api.admin.file; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * 로컬 스토리지에 저장된 파일을 공개 GET으로 읽을 수 있게 하는 임시 설정. + * S3 연동 전 임시 서빙이며, S3로 전환하면 {@link AdminLocalFileUploadController}와 함께 제거한다. + */ +@Configuration +@ConditionalOnProperty(prefix = "file.storage", name = "type", havingValue = "local", matchIfMissing = true) +public class LocalFileServingConfig implements WebMvcConfigurer { + + @Value("${file.storage.local.base-path}") + private String basePath; + + @Override + public void addResourceHandlers(ResourceHandlerRegistry registry) { + registry.addResourceHandler("/files/**") + .addResourceLocations("file:" + basePath + "/files/"); + } +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/file/request/FileUploadUrlIssueRequest.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/file/request/FileUploadUrlIssueRequest.java similarity index 77% rename from api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/file/request/FileUploadUrlIssueRequest.java rename to api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/file/request/FileUploadUrlIssueRequest.java index cc6d8bc8..b049a4ed 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/file/request/FileUploadUrlIssueRequest.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/file/request/FileUploadUrlIssueRequest.java @@ -1,10 +1,10 @@ -package kr.ac.kookmin.stream.api.admin.internal.file.request; +package kr.ac.kookmin.stream.api.admin.file.request; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Positive; -import kr.ac.kookmin.stream.internal.domain.file.domain.FileCategory; -import kr.ac.kookmin.stream.internal.domain.file.domain.FileUploadUrlIssueCommand; +import kr.ac.kookmin.stream.file.domain.FileCategory; +import kr.ac.kookmin.stream.file.domain.FileUploadUrlIssueCommand; public record FileUploadUrlIssueRequest( @NotBlank(message = "파일명은 필수 입력값입니다.") diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/file/response/FileUploadUrlIssueResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/file/response/FileUploadUrlIssueResponse.java similarity index 72% rename from api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/file/response/FileUploadUrlIssueResponse.java rename to api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/file/response/FileUploadUrlIssueResponse.java index 1098160c..87469ca8 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/file/response/FileUploadUrlIssueResponse.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/file/response/FileUploadUrlIssueResponse.java @@ -1,7 +1,7 @@ -package kr.ac.kookmin.stream.api.admin.internal.file.response; +package kr.ac.kookmin.stream.api.admin.file.response; import java.time.LocalDateTime; -import kr.ac.kookmin.stream.internal.domain.file.domain.FileUploadUrlIssueResult; +import kr.ac.kookmin.stream.file.domain.FileUploadUrlIssueResult; public record FileUploadUrlIssueResponse( Long fileId, diff --git a/core/domain/file/build.gradle.kts b/core/domain/file/build.gradle.kts new file mode 100644 index 00000000..e7895c35 --- /dev/null +++ b/core/domain/file/build.gradle.kts @@ -0,0 +1,17 @@ +plugins { + java +} + +description = "file 도메인" + +dependencies { + implementation(project(":core:common")) + + implementation(platform(libs.springBootDependenciesBom)) + implementation(libs.springContext) + implementation(libs.springTx) + + testImplementation(platform(libs.junitBom)) + testImplementation(libs.junitJupiter) + testRuntimeOnly(libs.junitPlatformLauncher) +} diff --git a/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/client/FileStorageClient.java b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/client/FileStorageClient.java new file mode 100644 index 00000000..9d929459 --- /dev/null +++ b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/client/FileStorageClient.java @@ -0,0 +1,23 @@ +package kr.ac.kookmin.stream.file.client; + +import java.io.InputStream; +import java.util.List; +import java.util.Map; +import kr.ac.kookmin.stream.file.domain.FileUrl; +import kr.ac.kookmin.stream.file.domain.UploadUrl; + +public interface FileStorageClient { + UploadUrl issuePresignedUrl(String fileKey, String contentType); + void write(String fileKey, InputStream content); + void deleteObject(String fileKey); + FileUrl publicUrl(String fileKey); + Map publicUrls(List fileKeys); + + /** publicBaseUrl 끝에 슬래시가 있어도 이어붙인 URL에 슬래시가 중복되지 않게 한다. */ + static String buildPublicUrl(String publicBaseUrl, String fileKey) { + String trimmedBaseUrl = publicBaseUrl.endsWith("/") + ? publicBaseUrl.substring(0, publicBaseUrl.length() - 1) + : publicBaseUrl; + return trimmedBaseUrl + "/" + fileKey; + } +} diff --git a/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/domain/File.java b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/domain/File.java similarity index 94% rename from core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/domain/File.java rename to core/domain/file/src/main/java/kr/ac/kookmin/stream/file/domain/File.java index d2c6cc28..e1392ef0 100644 --- a/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/domain/File.java +++ b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/domain/File.java @@ -1,4 +1,4 @@ -package kr.ac.kookmin.stream.internal.domain.file.domain; +package kr.ac.kookmin.stream.file.domain; import lombok.AccessLevel; import lombok.AllArgsConstructor; diff --git a/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/domain/FileCategory.java b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/domain/FileCategory.java similarity index 66% rename from core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/domain/FileCategory.java rename to core/domain/file/src/main/java/kr/ac/kookmin/stream/file/domain/FileCategory.java index 881b3842..0fa223d5 100644 --- a/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/domain/FileCategory.java +++ b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/domain/FileCategory.java @@ -1,4 +1,4 @@ -package kr.ac.kookmin.stream.internal.domain.file.domain; +package kr.ac.kookmin.stream.file.domain; public enum FileCategory { TEMP, diff --git a/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/domain/FileErrorCode.java b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/domain/FileErrorCode.java similarity index 91% rename from core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/domain/FileErrorCode.java rename to core/domain/file/src/main/java/kr/ac/kookmin/stream/file/domain/FileErrorCode.java index f279a086..77a3180a 100644 --- a/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/domain/FileErrorCode.java +++ b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/domain/FileErrorCode.java @@ -1,4 +1,4 @@ -package kr.ac.kookmin.stream.internal.domain.file.domain; +package kr.ac.kookmin.stream.file.domain; import kr.ac.kookmin.stream.common.ErrorCode; import kr.ac.kookmin.stream.common.ErrorStatus; diff --git a/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/domain/FileInfo.java b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/domain/FileInfo.java new file mode 100644 index 00000000..abe7419a --- /dev/null +++ b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/domain/FileInfo.java @@ -0,0 +1,3 @@ +package kr.ac.kookmin.stream.file.domain; + +public record FileInfo(String originalName, FileUrl url) {} diff --git a/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/domain/FileUploadUrlIssueCommand.java b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/domain/FileUploadUrlIssueCommand.java similarity index 73% rename from core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/domain/FileUploadUrlIssueCommand.java rename to core/domain/file/src/main/java/kr/ac/kookmin/stream/file/domain/FileUploadUrlIssueCommand.java index 8c2bbc58..ccd81426 100644 --- a/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/domain/FileUploadUrlIssueCommand.java +++ b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/domain/FileUploadUrlIssueCommand.java @@ -1,4 +1,4 @@ -package kr.ac.kookmin.stream.internal.domain.file.domain; +package kr.ac.kookmin.stream.file.domain; public record FileUploadUrlIssueCommand( String originalName, diff --git a/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/domain/FileUploadUrlIssueResult.java b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/domain/FileUploadUrlIssueResult.java similarity index 86% rename from core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/domain/FileUploadUrlIssueResult.java rename to core/domain/file/src/main/java/kr/ac/kookmin/stream/file/domain/FileUploadUrlIssueResult.java index 6e2fe910..9f82dadd 100644 --- a/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/domain/FileUploadUrlIssueResult.java +++ b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/domain/FileUploadUrlIssueResult.java @@ -1,4 +1,4 @@ -package kr.ac.kookmin.stream.internal.domain.file.domain; +package kr.ac.kookmin.stream.file.domain; import java.time.LocalDateTime; diff --git a/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/domain/FileUrl.java b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/domain/FileUrl.java new file mode 100644 index 00000000..156d44f1 --- /dev/null +++ b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/domain/FileUrl.java @@ -0,0 +1,3 @@ +package kr.ac.kookmin.stream.file.domain; + +public record FileUrl(String url) {} diff --git a/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/domain/UploadUrl.java b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/domain/UploadUrl.java similarity index 62% rename from core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/domain/UploadUrl.java rename to core/domain/file/src/main/java/kr/ac/kookmin/stream/file/domain/UploadUrl.java index 86a331fe..e67576d2 100644 --- a/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/domain/UploadUrl.java +++ b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/domain/UploadUrl.java @@ -1,4 +1,4 @@ -package kr.ac.kookmin.stream.internal.domain.file.domain; +package kr.ac.kookmin.stream.file.domain; import java.time.LocalDateTime; diff --git a/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/package-info.java b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/package-info.java new file mode 100644 index 00000000..beec629c --- /dev/null +++ b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/package-info.java @@ -0,0 +1,8 @@ +/** + * 계층 패키지(domain/{도메인}/{domain|repository|service})를 그대로 공개하기 위해 OPEN으로 둔다. + * service.impl 접근 차단은 bootstrap의 DomainImplAccessTests(ArchUnit)가 담당한다. + */ +@ApplicationModule(type = ApplicationModule.Type.OPEN) +package kr.ac.kookmin.stream.file; + +import org.springframework.modulith.ApplicationModule; diff --git a/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/repository/FileRepository.java b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/repository/FileRepository.java similarity index 56% rename from core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/repository/FileRepository.java rename to core/domain/file/src/main/java/kr/ac/kookmin/stream/file/repository/FileRepository.java index a56b7ff5..1a00be2b 100644 --- a/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/repository/FileRepository.java +++ b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/repository/FileRepository.java @@ -1,11 +1,13 @@ -package kr.ac.kookmin.stream.internal.domain.file.repository; +package kr.ac.kookmin.stream.file.repository; +import java.util.List; import java.util.Optional; -import kr.ac.kookmin.stream.internal.domain.file.domain.File; +import kr.ac.kookmin.stream.file.domain.File; public interface FileRepository { Optional findById(Long id); Optional findByFileKey(String fileKey); + List findAllByIdIn(List ids); File save(File file); void deleteById(Long id); } diff --git a/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/FileService.java b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/FileService.java new file mode 100644 index 00000000..ade0ff32 --- /dev/null +++ b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/FileService.java @@ -0,0 +1,22 @@ +package kr.ac.kookmin.stream.file.service; + +import java.io.InputStream; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import kr.ac.kookmin.stream.file.domain.FileInfo; +import kr.ac.kookmin.stream.file.domain.FileUploadUrlIssueCommand; +import kr.ac.kookmin.stream.file.domain.FileUploadUrlIssueResult; +import kr.ac.kookmin.stream.file.domain.FileUrl; + +public interface FileService { + FileUploadUrlIssueResult issuePresignedUrl(FileUploadUrlIssueCommand command); + void receiveUpload(String fileKey, InputStream content); + void delete(Long fileId); + Optional resolveUrl(Long fileId); + Map resolveUrls(List fileIds); + Optional resolveInfo(Long fileId); + Map resolveInfos(List fileIds); + FileUrl publicUrl(String fileKey); + Map publicUrls(List fileKeys); +} diff --git a/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/impl/FileServiceImpl.java b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/impl/FileServiceImpl.java new file mode 100644 index 00000000..7bc75996 --- /dev/null +++ b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/impl/FileServiceImpl.java @@ -0,0 +1,130 @@ +package kr.ac.kookmin.stream.file.service.impl; + +import java.io.InputStream; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import kr.ac.kookmin.stream.common.BusinessException; +import kr.ac.kookmin.stream.file.client.FileStorageClient; +import kr.ac.kookmin.stream.file.domain.File; +import kr.ac.kookmin.stream.file.domain.FileErrorCode; +import kr.ac.kookmin.stream.file.domain.FileInfo; +import kr.ac.kookmin.stream.file.domain.FileUploadUrlIssueCommand; +import kr.ac.kookmin.stream.file.domain.FileUploadUrlIssueResult; +import kr.ac.kookmin.stream.file.domain.FileUrl; +import kr.ac.kookmin.stream.file.domain.UploadUrl; +import kr.ac.kookmin.stream.file.repository.FileRepository; +import kr.ac.kookmin.stream.file.service.FileService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +class FileServiceImpl implements FileService { + + private static final String FILE_KEY_PREFIX = "files/"; + + private final FileRepository fileRepository; + private final FileStorageClient fileStorageClient; + + @Override + @Transactional + public FileUploadUrlIssueResult issuePresignedUrl(FileUploadUrlIssueCommand command) { + FileUploadPolicy.validate(command.category(), command.originalName(), command.fileSize()); + + String fileKey = generateFileKey(command.originalName()); + UploadUrl uploadUrl = fileStorageClient.issuePresignedUrl(fileKey, command.contentType()); + + File file = File.create( + fileKey, + command.category(), + command.originalName(), + command.fileSize(), + command.contentType(), + command.uploaderId() + ); + File saved = fileRepository.save(file); + + return FileUploadUrlIssueResult.of(saved, uploadUrl); + } + + @Override + @Transactional(readOnly = true) + public void receiveUpload(String fileKey, InputStream content) { + fileRepository.findByFileKey(fileKey) + .orElseThrow(() -> new BusinessException(FileErrorCode.FILE_NOT_FOUND)); + fileStorageClient.write(fileKey, content); + } + + @Override + @Transactional + public void delete(Long fileId) { + File file = fileRepository.findById(fileId) + .orElseThrow(() -> new BusinessException(FileErrorCode.FILE_NOT_FOUND)); + fileStorageClient.deleteObject(file.getFileKey()); + fileRepository.deleteById(fileId); + } + + @Override + public Optional resolveUrl(Long fileId) { + return fileRepository.findById(fileId) + .map(file -> fileStorageClient.publicUrl(file.getFileKey())); + } + + @Override + public Map resolveUrls(List fileIds) { + if (fileIds.isEmpty()) { + return Map.of(); + } + + List files = fileRepository.findAllByIdIn(fileIds); + Map urlsByKey = fileStorageClient.publicUrls(files.stream().map(File::getFileKey).toList()); + + Map result = new LinkedHashMap<>(); + for (File file : files) { + result.put(file.getId(), urlsByKey.get(file.getFileKey())); + } + return result; + } + + @Override + public Optional resolveInfo(Long fileId) { + return fileRepository.findById(fileId) + .map(file -> new FileInfo(file.getOriginalName(), fileStorageClient.publicUrl(file.getFileKey()))); + } + + @Override + public Map resolveInfos(List fileIds) { + if (fileIds.isEmpty()) { + return Map.of(); + } + + List files = fileRepository.findAllByIdIn(fileIds); + Map urlsByKey = fileStorageClient.publicUrls(files.stream().map(File::getFileKey).toList()); + + Map result = new LinkedHashMap<>(); + for (File file : files) { + result.put(file.getId(), new FileInfo(file.getOriginalName(), urlsByKey.get(file.getFileKey()))); + } + return result; + } + + @Override + public FileUrl publicUrl(String fileKey) { + return fileStorageClient.publicUrl(fileKey); + } + + @Override + public Map publicUrls(List fileKeys) { + return fileStorageClient.publicUrls(fileKeys); + } + + private String generateFileKey(String originalName) { + String extension = FileUploadPolicy.extractExtension(originalName); + String key = UUID.randomUUID().toString(); + return extension.isEmpty() ? FILE_KEY_PREFIX + key : FILE_KEY_PREFIX + key + "." + extension; + } +} diff --git a/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/service/impl/FileUploadPolicy.java b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/impl/FileUploadPolicy.java similarity index 91% rename from core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/service/impl/FileUploadPolicy.java rename to core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/impl/FileUploadPolicy.java index 851084b7..58d5f2b1 100644 --- a/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/service/impl/FileUploadPolicy.java +++ b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/impl/FileUploadPolicy.java @@ -1,12 +1,12 @@ -package kr.ac.kookmin.stream.internal.domain.file.service.impl; +package kr.ac.kookmin.stream.file.service.impl; import java.util.Map; import java.util.Set; import java.util.stream.Collectors; import java.util.stream.Stream; import kr.ac.kookmin.stream.common.BusinessException; -import kr.ac.kookmin.stream.internal.domain.file.domain.FileCategory; -import kr.ac.kookmin.stream.internal.domain.file.domain.FileErrorCode; +import kr.ac.kookmin.stream.file.domain.FileCategory; +import kr.ac.kookmin.stream.file.domain.FileErrorCode; final class FileUploadPolicy { diff --git a/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/client/FileStorageClient.java b/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/client/FileStorageClient.java deleted file mode 100644 index 2ccc9ff8..00000000 --- a/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/client/FileStorageClient.java +++ /dev/null @@ -1,10 +0,0 @@ -package kr.ac.kookmin.stream.internal.domain.file.client; - -import java.io.InputStream; -import kr.ac.kookmin.stream.internal.domain.file.domain.UploadUrl; - -public interface FileStorageClient { - UploadUrl issuePresignedUrl(String fileKey, String contentType); - void write(String fileKey, InputStream content); - void deleteObject(String fileKey); -} diff --git a/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/service/FileService.java b/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/service/FileService.java deleted file mode 100644 index e075fb0c..00000000 --- a/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/service/FileService.java +++ /dev/null @@ -1,11 +0,0 @@ -package kr.ac.kookmin.stream.internal.domain.file.service; - -import java.io.InputStream; -import kr.ac.kookmin.stream.internal.domain.file.domain.FileUploadUrlIssueCommand; -import kr.ac.kookmin.stream.internal.domain.file.domain.FileUploadUrlIssueResult; - -public interface FileService { - FileUploadUrlIssueResult issuePresignedUrl(FileUploadUrlIssueCommand command); - void receiveUpload(String fileKey, InputStream content); - void delete(Long fileId); -} diff --git a/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/service/impl/FileServiceImpl.java b/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/service/impl/FileServiceImpl.java deleted file mode 100644 index 9c82c600..00000000 --- a/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/service/impl/FileServiceImpl.java +++ /dev/null @@ -1,70 +0,0 @@ -package kr.ac.kookmin.stream.internal.domain.file.service.impl; - -import java.io.InputStream; -import java.util.UUID; -import kr.ac.kookmin.stream.common.BusinessException; -import kr.ac.kookmin.stream.internal.domain.file.domain.File; -import kr.ac.kookmin.stream.internal.domain.file.domain.FileErrorCode; -import kr.ac.kookmin.stream.internal.domain.file.domain.FileUploadUrlIssueCommand; -import kr.ac.kookmin.stream.internal.domain.file.domain.FileUploadUrlIssueResult; -import kr.ac.kookmin.stream.internal.domain.file.domain.UploadUrl; -import kr.ac.kookmin.stream.internal.domain.file.client.FileStorageClient; -import kr.ac.kookmin.stream.internal.domain.file.repository.FileRepository; -import kr.ac.kookmin.stream.internal.domain.file.service.FileService; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@RequiredArgsConstructor -class FileServiceImpl implements FileService { - - private static final String FILE_KEY_PREFIX = "files/"; - - private final FileRepository fileRepository; - private final FileStorageClient fileStorageClient; - - @Override - @Transactional - public FileUploadUrlIssueResult issuePresignedUrl(FileUploadUrlIssueCommand command) { - FileUploadPolicy.validate(command.category(), command.originalName(), command.fileSize()); - - String fileKey = generateFileKey(command.originalName()); - UploadUrl uploadUrl = fileStorageClient.issuePresignedUrl(fileKey, command.contentType()); - - File file = File.create( - fileKey, - command.category(), - command.originalName(), - command.fileSize(), - command.contentType(), - command.uploaderId() - ); - File saved = fileRepository.save(file); - - return FileUploadUrlIssueResult.of(saved, uploadUrl); - } - - @Override - @Transactional(readOnly = true) - public void receiveUpload(String fileKey, InputStream content) { - fileRepository.findByFileKey(fileKey) - .orElseThrow(() -> new BusinessException(FileErrorCode.FILE_NOT_FOUND)); - fileStorageClient.write(fileKey, content); - } - - @Override - @Transactional - public void delete(Long fileId) { - File file = fileRepository.findById(fileId) - .orElseThrow(() -> new BusinessException(FileErrorCode.FILE_NOT_FOUND)); - fileStorageClient.deleteObject(file.getFileKey()); - fileRepository.deleteById(fileId); - } - - private String generateFileKey(String originalName) { - String extension = FileUploadPolicy.extractExtension(originalName); - String key = UUID.randomUUID().toString(); - return extension.isEmpty() ? FILE_KEY_PREFIX + key : FILE_KEY_PREFIX + key + "." + extension; - } -} diff --git a/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/PublicEndpoints.java b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/PublicEndpoints.java index b8557575..af1ad4ed 100644 --- a/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/PublicEndpoints.java +++ b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/PublicEndpoints.java @@ -22,6 +22,10 @@ public enum PublicEndpoints { "/swagger-ui/**", "/swagger-ui.html", "/v3/api-docs/**" + )), + /** 로컬 스토리지 파일 공개 읽기. S3 연동 전 임시 서빙이라 S3 전환 시 제거한다. */ + LOCAL_FILE_READ(List.of( + "/files/**" )); private static final List ALL_PATH_PATTERNS = Arrays.stream(values()) diff --git a/infrastructure/client/build.gradle.kts b/infrastructure/client/build.gradle.kts index f7205cb8..fe315cf4 100644 --- a/infrastructure/client/build.gradle.kts +++ b/infrastructure/client/build.gradle.kts @@ -6,7 +6,7 @@ description = "외부 API 클라이언트 구현체" dependencies { implementation(project(":core:common")) - implementation(project(":core:domain:internal")) + implementation(project(":core:domain:file")) implementation(platform(libs.springBootDependenciesBom)) implementation(libs.springBootStarter) diff --git a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/local/LocalFileStorageClient.java b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/local/LocalFileStorageClient.java index 8aaf2411..8e73a985 100644 --- a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/local/LocalFileStorageClient.java +++ b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/local/LocalFileStorageClient.java @@ -7,8 +7,13 @@ import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.time.LocalDateTime; -import kr.ac.kookmin.stream.internal.domain.file.domain.UploadUrl; -import kr.ac.kookmin.stream.internal.domain.file.client.FileStorageClient; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import kr.ac.kookmin.stream.file.client.FileStorageClient; +import kr.ac.kookmin.stream.file.domain.FileUrl; +import kr.ac.kookmin.stream.file.domain.UploadUrl; import lombok.RequiredArgsConstructor; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; @@ -52,6 +57,16 @@ public void deleteObject(String fileKey) { } } + @Override + public FileUrl publicUrl(String fileKey) { + return new FileUrl(FileStorageClient.buildPublicUrl(properties.publicBaseUrl(), fileKey)); + } + + @Override + public Map publicUrls(List fileKeys) { + return fileKeys.stream().collect(Collectors.toMap(Function.identity(), this::publicUrl, (a, b) -> a)); + } + private Path resolvePath(String fileKey) { return Path.of(properties.basePath()).resolve(fileKey).normalize(); } diff --git a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/local/LocalFileStorageProperties.java b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/local/LocalFileStorageProperties.java index e0fbde80..5c65c8b5 100644 --- a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/local/LocalFileStorageProperties.java +++ b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/local/LocalFileStorageProperties.java @@ -8,5 +8,6 @@ public record LocalFileStorageProperties( String basePath, String baseUrl, + String publicBaseUrl, long uploadUrlExpirySeconds ) {} diff --git a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/s3/S3FileStorageClient.java b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/s3/S3FileStorageClient.java index b329be29..505174eb 100644 --- a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/s3/S3FileStorageClient.java +++ b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/s3/S3FileStorageClient.java @@ -4,8 +4,13 @@ import java.time.Duration; import java.time.LocalDateTime; import java.time.ZoneId; -import kr.ac.kookmin.stream.internal.domain.file.client.FileStorageClient; -import kr.ac.kookmin.stream.internal.domain.file.domain.UploadUrl; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import kr.ac.kookmin.stream.file.client.FileStorageClient; +import kr.ac.kookmin.stream.file.domain.FileUrl; +import kr.ac.kookmin.stream.file.domain.UploadUrl; import lombok.RequiredArgsConstructor; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; @@ -60,4 +65,14 @@ public void deleteObject(String fileKey) { .key(fileKey) .build()); } + + @Override + public FileUrl publicUrl(String fileKey) { + return new FileUrl(FileStorageClient.buildPublicUrl(properties.publicBaseUrl(), fileKey)); + } + + @Override + public Map publicUrls(List fileKeys) { + return fileKeys.stream().collect(Collectors.toMap(Function.identity(), this::publicUrl, (a, b) -> a)); + } } diff --git a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/s3/S3FileStorageProperties.java b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/s3/S3FileStorageProperties.java index fb64e8d4..1d431c9c 100644 --- a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/s3/S3FileStorageProperties.java +++ b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/s3/S3FileStorageProperties.java @@ -9,5 +9,6 @@ public record S3FileStorageProperties( String endpoint, String accessKey, String secretKey, + String publicBaseUrl, long uploadUrlExpirySeconds ) {} diff --git a/infrastructure/client/src/main/resources/application-infrastructure-client.yml b/infrastructure/client/src/main/resources/application-infrastructure-client.yml index 95f32b32..cca6942b 100644 --- a/infrastructure/client/src/main/resources/application-infrastructure-client.yml +++ b/infrastructure/client/src/main/resources/application-infrastructure-client.yml @@ -4,6 +4,7 @@ file: local: base-path: ${LOCAL_STORAGE_BASE_PATH:./local-storage} base-url: ${LOCAL_STORAGE_BASE_URL:http://localhost:8080} + public-base-url: ${LOCAL_STORAGE_PUBLIC_BASE_URL:http://localhost:8080} upload-url-expiry-seconds: ${LOCAL_STORAGE_UPLOAD_URL_EXPIRY_SECONDS:600} s3: bucket: ${R2_BUCKET:} @@ -11,4 +12,5 @@ file: endpoint: ${R2_ENDPOINT:} access-key: ${R2_ACCESS_KEY:} secret-key: ${R2_SECRET_KEY:} + public-base-url: ${R2_PUBLIC_BASE_URL:} upload-url-expiry-seconds: ${S3_UPLOAD_URL_EXPIRY_SECONDS:600} diff --git a/infrastructure/db/build.gradle.kts b/infrastructure/db/build.gradle.kts index 903dc34b..3aa9a172 100644 --- a/infrastructure/db/build.gradle.kts +++ b/infrastructure/db/build.gradle.kts @@ -10,6 +10,7 @@ dependencies { implementation(project(":core:domain:event")) implementation(project(":core:domain:welfare")) implementation(project(":core:domain:internal")) + implementation(project(":core:domain:file")) implementation(platform(libs.springBootDependenciesBom)) implementation(libs.springBootStarterDataJpa) diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/internal/FileJpaEntity.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/file/FileJpaEntity.java similarity index 91% rename from infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/internal/FileJpaEntity.java rename to infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/file/FileJpaEntity.java index ff9166c7..1a114e07 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/internal/FileJpaEntity.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/file/FileJpaEntity.java @@ -1,4 +1,4 @@ -package kr.ac.kookmin.stream.db.internal; +package kr.ac.kookmin.stream.db.file; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -9,8 +9,8 @@ import jakarta.persistence.Id; import jakarta.persistence.Table; import kr.ac.kookmin.stream.db.common.BaseCreatedTimeEntity; -import kr.ac.kookmin.stream.internal.domain.file.domain.File; -import kr.ac.kookmin.stream.internal.domain.file.domain.FileCategory; +import kr.ac.kookmin.stream.file.domain.File; +import kr.ac.kookmin.stream.file.domain.FileCategory; import lombok.AccessLevel; import lombok.NoArgsConstructor; diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/internal/FileJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/file/FileJpaRepository.java similarity index 66% rename from infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/internal/FileJpaRepository.java rename to infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/file/FileJpaRepository.java index cc054aa6..9bfd6fa8 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/internal/FileJpaRepository.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/file/FileJpaRepository.java @@ -1,8 +1,10 @@ -package kr.ac.kookmin.stream.db.internal; +package kr.ac.kookmin.stream.db.file; +import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; public interface FileJpaRepository extends JpaRepository { Optional findByFileKey(String fileKey); + List findAllByIdIn(List ids); } diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/internal/FileRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/file/FileRepositoryImpl.java similarity index 70% rename from infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/internal/FileRepositoryImpl.java rename to infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/file/FileRepositoryImpl.java index 6fbd852b..5d25ebbc 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/internal/FileRepositoryImpl.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/file/FileRepositoryImpl.java @@ -1,8 +1,9 @@ -package kr.ac.kookmin.stream.db.internal; +package kr.ac.kookmin.stream.db.file; +import java.util.List; import java.util.Optional; -import kr.ac.kookmin.stream.internal.domain.file.domain.File; -import kr.ac.kookmin.stream.internal.domain.file.repository.FileRepository; +import kr.ac.kookmin.stream.file.domain.File; +import kr.ac.kookmin.stream.file.repository.FileRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; @@ -22,6 +23,11 @@ public Optional findByFileKey(String fileKey) { return fileJpaRepository.findByFileKey(fileKey).map(FileJpaEntity::toDomain); } + @Override + public List findAllByIdIn(List ids) { + return fileJpaRepository.findAllByIdIn(ids).stream().map(FileJpaEntity::toDomain).toList(); + } + @Override public File save(File file) { return fileJpaRepository.save(FileJpaEntity.from(file)).toDomain(); diff --git a/settings.gradle.kts b/settings.gradle.kts index 9a5d6751..2abfa400 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -7,6 +7,7 @@ include( "api:app-api", "core:common", "core:domain:event", + "core:domain:file", "core:domain:internal", "core:domain:welfare", "core:domain:auth", From e983cb0390927bffb16d7e0a8b9d8e5af40715f2 Mon Sep 17 00:00:00 2001 From: jjunh33 Date: Wed, 30 Sep 2026 16:06:03 +0900 Subject: [PATCH 02/11] =?UTF-8?q?refactor:=20=EB=AC=BC=ED=92=88=C2=B7?= =?UTF-8?q?=EA=B3=B5=EC=A7=80=20=EC=9D=91=EB=8B=B5=EC=9D=B4=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EA=B3=B5=EA=B0=9C=20URL=EC=9D=84=20=EC=8B=A4?= =?UTF-8?q?=EC=A0=9C=EB=A1=9C=20=EC=A1=B0=ED=9A=8C=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EB=B0=B0=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 항상 null을 반환하던 ItemImageUrl 플레이스홀더를 제거하고, 물품 목록·대여 이력·반납 필요 목록·공지 목록·공지 상세 응답이 FileService를 통해 실제 공개 URL(및 첨부파일명)을 채우도록 연결했다. 물품은 imageKey를 그대로 갖고 있어 key 기반 조회(DB 조회 없음)를, 공지는 fileId 목록을 갖고 있어 id 기반 배치 조회(findAllByIdIn 단일 쿼리)를 쓴다. 삭제된 파일을 가리키는 fileId는 조회 결과 맵에 키가 없으므로 목록에서 조용히 제외된다. --- api/app-api/build.gradle.kts | 1 + .../welfare/notice/AppNoticeController.java | 28 +++++++++++++++++-- .../notice/response/NoticeDetailResponse.java | 26 ++++++++++++----- .../response/NoticeListItemResponse.java | 12 ++++++-- .../welfare/rental/AppRentalController.java | 27 +++++++++++++----- .../welfare/rental/response/ItemImageUrl.java | 17 ----------- .../rental/response/ItemListItemResponse.java | 7 +++-- .../response/RentalHistoryListResponse.java | 12 +++++--- .../response/ReturnRequiredListResponse.java | 12 +++++--- 9 files changed, 97 insertions(+), 45 deletions(-) delete mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/ItemImageUrl.java diff --git a/api/app-api/build.gradle.kts b/api/app-api/build.gradle.kts index 73240a99..40c51488 100644 --- a/api/app-api/build.gradle.kts +++ b/api/app-api/build.gradle.kts @@ -9,6 +9,7 @@ dependencies { implementation(project(":core:common")) implementation(project(":core:domain:event")) implementation(project(":core:domain:internal")) + implementation(project(":core:domain:file")) implementation(project(":core:domain:welfare")) implementation(project(":gateway:auth")) implementation(project(":gateway:logging")) diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/notice/AppNoticeController.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/notice/AppNoticeController.java index 559b4dfe..a0633d14 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/notice/AppNoticeController.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/notice/AppNoticeController.java @@ -1,11 +1,16 @@ package kr.ac.kookmin.stream.api.app.welfare.notice; +import java.util.List; +import java.util.Map; import kr.ac.kookmin.stream.api.common.dto.ApiResponse; import kr.ac.kookmin.stream.api.common.CursorCodec; import kr.ac.kookmin.stream.api.common.dto.CursorSliceResponse; import kr.ac.kookmin.stream.api.app.welfare.notice.response.NoticeDetailResponse; import kr.ac.kookmin.stream.api.app.welfare.notice.response.NoticeListItemResponse; import kr.ac.kookmin.stream.common.CursorSliceResult; +import kr.ac.kookmin.stream.file.domain.FileInfo; +import kr.ac.kookmin.stream.file.domain.FileUrl; +import kr.ac.kookmin.stream.file.service.FileService; import kr.ac.kookmin.stream.welfare.domain.notice.domain.Notice; import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCategory; import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCursor; @@ -23,6 +28,7 @@ public class AppNoticeController implements AppNoticeApi { private final NoticeService noticeService; + private final FileService fileService; @Override @GetMapping @@ -33,9 +39,16 @@ public ApiResponse> getNotices( ) { NoticeCursor noticeCursor = cursor == null ? null : NoticeCursor.from(CursorCodec.decode(cursor)); CursorSliceResult result = noticeService.getNotices(NoticeCategory.from(category), noticeCursor, size); + + List thumbnailIds = result.content().stream() + .map(AppNoticeController::firstImageId) + .filter(id -> id != null) + .toList(); + Map thumbnailUrls = fileService.resolveUrls(thumbnailIds); + CursorSliceResponse response = CursorSliceResponse.from( result, - NoticeListItemResponse::from + notice -> NoticeListItemResponse.from(notice, thumbnailUrls) ); return ApiResponse.success(response); @@ -46,6 +59,17 @@ public ApiResponse> getNotices( public ApiResponse getNotice(@PathVariable("noticeId") Long noticeId) { Notice notice = noticeService.getNotice(noticeId); - return ApiResponse.success(NoticeDetailResponse.from(notice)); + Map imageUrls = fileService.resolveUrls( + notice.getImageIds() == null ? List.of() : notice.getImageIds() + ); + Map attachmentInfos = fileService.resolveInfos( + notice.getAttachmentIds() == null ? List.of() : notice.getAttachmentIds() + ); + + return ApiResponse.success(NoticeDetailResponse.from(notice, imageUrls, attachmentInfos)); + } + + private static Long firstImageId(Notice notice) { + return notice.getImageIds() == null || notice.getImageIds().isEmpty() ? null : notice.getImageIds().get(0); } } diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/notice/response/NoticeDetailResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/notice/response/NoticeDetailResponse.java index 3b76f420..365e38cb 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/notice/response/NoticeDetailResponse.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/notice/response/NoticeDetailResponse.java @@ -2,6 +2,9 @@ import java.time.LocalDateTime; import java.util.List; +import java.util.Map; +import kr.ac.kookmin.stream.file.domain.FileInfo; +import kr.ac.kookmin.stream.file.domain.FileUrl; import kr.ac.kookmin.stream.welfare.domain.notice.domain.Notice; import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCategory; @@ -15,13 +18,22 @@ public record NoticeDetailResponse( LocalDateTime createdAt ) { - public static NoticeDetailResponse from(Notice notice) { + /** + * 존재하지 않는(삭제된) fileId는 imageUrls/attachmentInfos에 키가 없다 — 그런 항목은 목록에서 조용히 제외한다. + */ + public static NoticeDetailResponse from(Notice notice, Map imageUrls, Map attachmentInfos) { List images = notice.getImageIds() == null ? List.of() - : notice.getImageIds().stream().map(Image::from).toList(); + : notice.getImageIds().stream() + .filter(imageUrls::containsKey) + .map(fileId -> Image.of(fileId, imageUrls.get(fileId))) + .toList(); List attachments = notice.getAttachmentIds() == null ? List.of() - : notice.getAttachmentIds().stream().map(Attachment::from).toList(); + : notice.getAttachmentIds().stream() + .filter(attachmentInfos::containsKey) + .map(fileId -> Attachment.of(fileId, attachmentInfos.get(fileId))) + .toList(); return new NoticeDetailResponse( notice.getId(), @@ -36,15 +48,15 @@ public static NoticeDetailResponse from(Notice notice) { public record Image(Long fileId, String fileUrl) { - public static Image from(Long fileId) { - return new Image(fileId, null); + public static Image of(Long fileId, FileUrl fileUrl) { + return new Image(fileId, fileUrl.url()); } } public record Attachment(Long fileId, String fileName, String fileUrl) { - public static Attachment from(Long fileId) { - return new Attachment(fileId, null, null); + public static Attachment of(Long fileId, FileInfo info) { + return new Attachment(fileId, info.originalName(), info.url().url()); } } } diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/notice/response/NoticeListItemResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/notice/response/NoticeListItemResponse.java index 8718dd6d..2a9fb340 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/notice/response/NoticeListItemResponse.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/notice/response/NoticeListItemResponse.java @@ -1,6 +1,8 @@ package kr.ac.kookmin.stream.api.app.welfare.notice.response; import java.time.LocalDateTime; +import java.util.Map; +import kr.ac.kookmin.stream.file.domain.FileUrl; import kr.ac.kookmin.stream.welfare.domain.notice.domain.Notice; import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCategory; @@ -13,13 +15,19 @@ public record NoticeListItemResponse( boolean pinned ) { - public static NoticeListItemResponse from(Notice notice) { + /** 등록된 이미지 중 첫 번째를 썸네일로 쓴다. */ + public static NoticeListItemResponse from(Notice notice, Map thumbnailUrls) { + Long thumbnailId = notice.getImageIds() == null || notice.getImageIds().isEmpty() + ? null + : notice.getImageIds().get(0); + FileUrl thumbnailUrl = thumbnailId == null ? null : thumbnailUrls.get(thumbnailId); + return new NoticeListItemResponse( notice.getId(), notice.getTitle(), notice.getCategory(), notice.getCreatedAt(), - null, + thumbnailUrl == null ? null : thumbnailUrl.url(), notice.isPinned() ); } diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/AppRentalController.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/AppRentalController.java index 974387ba..1bdeaba7 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/AppRentalController.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/AppRentalController.java @@ -1,6 +1,8 @@ package kr.ac.kookmin.stream.api.app.welfare.rental; import jakarta.validation.Valid; +import java.util.List; +import java.util.Map; import kr.ac.kookmin.stream.api.app.AppApiUser; import kr.ac.kookmin.stream.api.app.welfare.rental.request.ItemListParams; import kr.ac.kookmin.stream.api.app.welfare.rental.request.RentalHistoryListParams; @@ -10,7 +12,10 @@ import kr.ac.kookmin.stream.api.common.dto.ApiResponse; import kr.ac.kookmin.stream.api.common.dto.CursorSliceResponse; import kr.ac.kookmin.stream.common.CursorSliceResult; +import kr.ac.kookmin.stream.file.domain.FileUrl; +import kr.ac.kookmin.stream.file.service.FileService; import kr.ac.kookmin.stream.welfare.domain.rental.domain.Item; +import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalRecord; import kr.ac.kookmin.stream.welfare.domain.rental.service.ItemService; import kr.ac.kookmin.stream.welfare.domain.rental.service.RentalHistoryService; import lombok.RequiredArgsConstructor; @@ -26,6 +31,7 @@ public class AppRentalController implements AppRentalApi { private final ItemService itemService; private final RentalHistoryService rentalHistoryService; + private final FileService fileService; @Override @GetMapping("/items") @@ -35,7 +41,10 @@ public ApiResponse> getItems( CursorSliceResult result = itemService.getItems( params.toCategory(), params.toKeyword(), params.toCursor(), params.sizeOrDefault() ); - return ApiResponse.success(CursorSliceResponse.from(result, ItemListItemResponse::from)); + Map imageUrls = fileService.publicUrls( + result.content().stream().map(Item::getImageKey).filter(key -> key != null).distinct().toList() + ); + return ApiResponse.success(CursorSliceResponse.from(result, item -> ItemListItemResponse.from(item, imageUrls))); } @Override @@ -44,16 +53,20 @@ public ApiResponse getHistories( AppApiUser apiUser, @Valid @ModelAttribute RentalHistoryListParams params ) { - return ApiResponse.success(RentalHistoryListResponse.from( - rentalHistoryService.getHistories(apiUser.userId(), params.toStatus()) - )); + List records = rentalHistoryService.getHistories(apiUser.userId(), params.toStatus()); + return ApiResponse.success(RentalHistoryListResponse.from(records, resolveImageUrls(records))); } @Override @GetMapping("/histories/return-required") public ApiResponse getReturnRequired(AppApiUser apiUser) { - return ApiResponse.success(ReturnRequiredListResponse.from( - rentalHistoryService.getReturnRequiredRentals(apiUser.userId()) - )); + List records = rentalHistoryService.getReturnRequiredRentals(apiUser.userId()); + return ApiResponse.success(ReturnRequiredListResponse.from(records, resolveImageUrls(records))); + } + + private Map resolveImageUrls(List records) { + return fileService.publicUrls( + records.stream().map(RentalRecord::itemImageKey).filter(key -> key != null).distinct().toList() + ); } } diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/ItemImageUrl.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/ItemImageUrl.java deleted file mode 100644 index a56ff3fe..00000000 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/ItemImageUrl.java +++ /dev/null @@ -1,17 +0,0 @@ -package kr.ac.kookmin.stream.api.app.welfare.rental.response; - -import lombok.AccessLevel; -import lombok.NoArgsConstructor; - -/** - * 물품 이미지 파일 키를 공개 URL로 바꾸는 지점. - *

- * 파일 키 → 공개 URL 조립이 아직 없어 현재는 항상 null이다. 조립이 생기면 이 메서드만 채우면 된다. - */ -@NoArgsConstructor(access = AccessLevel.PRIVATE) -final class ItemImageUrl { - - static String from(String imageKey) { - return null; - } -} diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/ItemListItemResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/ItemListItemResponse.java index 77f03b2c..0ff3bdb9 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/ItemListItemResponse.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/ItemListItemResponse.java @@ -1,6 +1,8 @@ package kr.ac.kookmin.stream.api.app.welfare.rental.response; import java.time.format.DateTimeFormatter; +import java.util.Map; +import kr.ac.kookmin.stream.file.domain.FileUrl; import kr.ac.kookmin.stream.welfare.domain.rental.domain.Item; import kr.ac.kookmin.stream.welfare.domain.rental.domain.ItemCategory; import kr.ac.kookmin.stream.welfare.domain.rental.domain.ItemType; @@ -16,15 +18,16 @@ public record ItemListItemResponse( ReturnPolicyResponse returnPolicy ) { - public static ItemListItemResponse from(Item item) { + public static ItemListItemResponse from(Item item, Map imageUrls) { ReturnPolicy returnPolicy = item.getReturnPolicy(); + FileUrl imageUrl = item.getImageKey() == null ? null : imageUrls.get(item.getImageKey()); return new ItemListItemResponse( item.getId(), item.getName(), item.getCategory(), item.getType(), item.getCount(), - ItemImageUrl.from(item.getImageKey()), + imageUrl == null ? null : imageUrl.url(), returnPolicy == null ? null : ReturnPolicyResponse.from(returnPolicy) ); } diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/RentalHistoryListResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/RentalHistoryListResponse.java index 77fa4693..5861aa55 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/RentalHistoryListResponse.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/RentalHistoryListResponse.java @@ -2,14 +2,16 @@ import java.time.LocalDateTime; import java.util.List; +import java.util.Map; +import kr.ac.kookmin.stream.file.domain.FileUrl; import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalHistory; import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalRecord; import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalStatus; public record RentalHistoryListResponse(List histories) { - public static RentalHistoryListResponse from(List records) { - return new RentalHistoryListResponse(records.stream().map(History::from).toList()); + public static RentalHistoryListResponse from(List records, Map imageUrls) { + return new RentalHistoryListResponse(records.stream().map(record -> History.from(record, imageUrls)).toList()); } public record History( @@ -21,12 +23,14 @@ public record History( RentalStatus status ) { - public static History from(RentalRecord record) { + public static History from(RentalRecord record, Map imageUrls) { RentalHistory history = record.history(); + String imageKey = record.itemImageKey(); + FileUrl imageUrl = imageKey == null ? null : imageUrls.get(imageKey); return new History( history.getId(), record.itemName(), - ItemImageUrl.from(record.itemImageKey()), + imageUrl == null ? null : imageUrl.url(), history.getRentAt(), history.getReturnedAt(), history.getRentalStatus() diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/ReturnRequiredListResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/ReturnRequiredListResponse.java index a17b88ad..0acc4602 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/ReturnRequiredListResponse.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/ReturnRequiredListResponse.java @@ -2,12 +2,14 @@ import java.time.LocalDateTime; import java.util.List; +import java.util.Map; +import kr.ac.kookmin.stream.file.domain.FileUrl; import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalRecord; public record ReturnRequiredListResponse(List rentalHistories) { - public static ReturnRequiredListResponse from(List records) { - return new ReturnRequiredListResponse(records.stream().map(Rental::from).toList()); + public static ReturnRequiredListResponse from(List records, Map imageUrls) { + return new ReturnRequiredListResponse(records.stream().map(record -> Rental.from(record, imageUrls)).toList()); } public record Rental( @@ -17,11 +19,13 @@ public record Rental( LocalDateTime dueAt ) { - public static Rental from(RentalRecord record) { + public static Rental from(RentalRecord record, Map imageUrls) { + String imageKey = record.itemImageKey(); + FileUrl imageUrl = imageKey == null ? null : imageUrls.get(imageKey); return new Rental( record.history().getId(), record.itemName(), - ItemImageUrl.from(record.itemImageKey()), + imageUrl == null ? null : imageUrl.url(), record.dueAt() ); } From 0691632f66c6bec32b2b414214142ffc1728be80 Mon Sep 17 00:00:00 2001 From: jjunh33 Date: Wed, 30 Sep 2026 16:16:53 +0900 Subject: [PATCH 03/11] =?UTF-8?q?fix:=20FileServiceImpl=EC=9D=98=20?= =?UTF-8?q?=EB=B6=88=ED=95=84=EC=9A=94=ED=95=9C=20readOnly=20=ED=8A=B8?= =?UTF-8?q?=EB=9E=9C=EC=9E=AD=EC=85=98=20=EC=A0=9C=EA=B1=B0=EC=99=80=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EC=88=9C=EC=84=9C=20=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit receiveUpload는 findByFileKey 단일 읽기뿐이라 readOnly 트랜잭션이 불필요해 제거했다. resolveUrl 등 같은 클래스의 다른 단일 읽기 메서드와도 일관된다. delete는 외부 스토리지 삭제를 먼저 하고 있었는데, 그 다음 DB 삭제가 실패하면 이미 지워진 파일을 가리키는 죽은 참조가 DB에 남는다. DB 삭제를 먼저 하도록 순서를 바꿔, 외부 삭제 실패 시 트랜잭션이 통째로 롤백되어 아무것도 바뀌지 않는 쪽으로 안전하게 실패하게 했다. --- .../ac/kookmin/stream/file/service/impl/FileServiceImpl.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/impl/FileServiceImpl.java b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/impl/FileServiceImpl.java index 7bc75996..7dfafd7c 100644 --- a/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/impl/FileServiceImpl.java +++ b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/impl/FileServiceImpl.java @@ -52,7 +52,6 @@ public FileUploadUrlIssueResult issuePresignedUrl(FileUploadUrlIssueCommand comm } @Override - @Transactional(readOnly = true) public void receiveUpload(String fileKey, InputStream content) { fileRepository.findByFileKey(fileKey) .orElseThrow(() -> new BusinessException(FileErrorCode.FILE_NOT_FOUND)); @@ -64,8 +63,8 @@ public void receiveUpload(String fileKey, InputStream content) { public void delete(Long fileId) { File file = fileRepository.findById(fileId) .orElseThrow(() -> new BusinessException(FileErrorCode.FILE_NOT_FOUND)); - fileStorageClient.deleteObject(file.getFileKey()); fileRepository.deleteById(fileId); + fileStorageClient.deleteObject(file.getFileKey()); } @Override From 5865a06b6d336f9ebc67cc58ed2060b816a4de43 Mon Sep 17 00:00:00 2001 From: jjunh33 Date: Wed, 30 Sep 2026 16:45:59 +0900 Subject: [PATCH 04/11] =?UTF-8?q?refactor:=20FileUrl/FileInfo=20=EB=9E=98?= =?UTF-8?q?=ED=8D=BC=20=ED=83=80=EC=9E=85=EC=9D=84=20=EC=A0=9C=EA=B1=B0?= =?UTF-8?q?=ED=95=98=EA=B3=A0=20File=20=EB=8F=84=EB=A9=94=EC=9D=B8=C2=B7?= =?UTF-8?q?=EC=88=9C=EC=88=98=20=EC=9C=A0=ED=8B=B8=EB=A1=9C=20=EB=8C=80?= =?UTF-8?q?=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FileUrl(String url)은 필드 하나뿐인 의미 없는 래퍼였고, FileInfo도 File이 이미 가진 fileKey·originalName을 그대로 다시 감싼 것이라 불필요했다. URL 조립(buildPublicUrl)을 core:common의 FileUrlUtil로 옮겨 DTO가 직접 호출하게 하고, FileService는 File 도메인 객체를 그대로 돌려주는 findById/findAllByIdIn만 남겼다. 응답 DTO들이 File.getFileKey()/ getOriginalName()과 FileUrlUtil을 직접 써서 URL·파일명을 조립한다. 이 과정에서 Map 배치 조회와 그 중복 key 방어 코드가 통째로 필요 없어졌다. --- .../welfare/notice/AppNoticeController.java | 16 +++--- .../notice/response/NoticeDetailResponse.java | 29 +++++----- .../response/NoticeListItemResponse.java | 9 ++-- .../welfare/rental/AppRentalController.java | 18 ++----- .../rental/response/ItemListItemResponse.java | 9 ++-- .../response/RentalHistoryListResponse.java | 13 +++-- .../response/ReturnRequiredListResponse.java | 13 +++-- .../ac/kookmin/stream/common/FileUrlUtil.java | 14 +++++ .../stream/file/client/FileStorageClient.java | 14 +---- .../kookmin/stream/file/domain/FileInfo.java | 3 -- .../kookmin/stream/file/domain/FileUrl.java | 3 -- .../stream/file/service/FileService.java | 12 ++--- .../file/service/impl/FileServiceImpl.java | 54 ++++--------------- .../file/local/LocalFileStorageClient.java | 14 +---- .../client/file/s3/S3FileStorageClient.java | 14 +---- 15 files changed, 83 insertions(+), 152 deletions(-) create mode 100644 core/common/src/main/java/kr/ac/kookmin/stream/common/FileUrlUtil.java delete mode 100644 core/domain/file/src/main/java/kr/ac/kookmin/stream/file/domain/FileInfo.java delete mode 100644 core/domain/file/src/main/java/kr/ac/kookmin/stream/file/domain/FileUrl.java diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/notice/AppNoticeController.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/notice/AppNoticeController.java index a0633d14..9fb5fd9f 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/notice/AppNoticeController.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/notice/AppNoticeController.java @@ -8,8 +8,7 @@ import kr.ac.kookmin.stream.api.app.welfare.notice.response.NoticeDetailResponse; import kr.ac.kookmin.stream.api.app.welfare.notice.response.NoticeListItemResponse; import kr.ac.kookmin.stream.common.CursorSliceResult; -import kr.ac.kookmin.stream.file.domain.FileInfo; -import kr.ac.kookmin.stream.file.domain.FileUrl; +import kr.ac.kookmin.stream.file.domain.File; import kr.ac.kookmin.stream.file.service.FileService; import kr.ac.kookmin.stream.welfare.domain.notice.domain.Notice; import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCategory; @@ -44,11 +43,12 @@ public ApiResponse> getNotices( .map(AppNoticeController::firstImageId) .filter(id -> id != null) .toList(); - Map thumbnailUrls = fileService.resolveUrls(thumbnailIds); + Map filesById = fileService.findAllByIdIn(thumbnailIds); + String publicBaseUrl = fileService.publicBaseUrl(); CursorSliceResponse response = CursorSliceResponse.from( result, - notice -> NoticeListItemResponse.from(notice, thumbnailUrls) + notice -> NoticeListItemResponse.from(notice, filesById, publicBaseUrl) ); return ApiResponse.success(response); @@ -59,14 +59,16 @@ public ApiResponse> getNotices( public ApiResponse getNotice(@PathVariable("noticeId") Long noticeId) { Notice notice = noticeService.getNotice(noticeId); - Map imageUrls = fileService.resolveUrls( + Map imagesById = fileService.findAllByIdIn( notice.getImageIds() == null ? List.of() : notice.getImageIds() ); - Map attachmentInfos = fileService.resolveInfos( + Map attachmentsById = fileService.findAllByIdIn( notice.getAttachmentIds() == null ? List.of() : notice.getAttachmentIds() ); - return ApiResponse.success(NoticeDetailResponse.from(notice, imageUrls, attachmentInfos)); + return ApiResponse.success( + NoticeDetailResponse.from(notice, imagesById, attachmentsById, fileService.publicBaseUrl()) + ); } private static Long firstImageId(Notice notice) { diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/notice/response/NoticeDetailResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/notice/response/NoticeDetailResponse.java index 365e38cb..4e1b4c7c 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/notice/response/NoticeDetailResponse.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/notice/response/NoticeDetailResponse.java @@ -3,8 +3,8 @@ import java.time.LocalDateTime; import java.util.List; import java.util.Map; -import kr.ac.kookmin.stream.file.domain.FileInfo; -import kr.ac.kookmin.stream.file.domain.FileUrl; +import kr.ac.kookmin.stream.common.FileUrlUtil; +import kr.ac.kookmin.stream.file.domain.File; import kr.ac.kookmin.stream.welfare.domain.notice.domain.Notice; import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCategory; @@ -19,20 +19,25 @@ public record NoticeDetailResponse( ) { /** - * 존재하지 않는(삭제된) fileId는 imageUrls/attachmentInfos에 키가 없다 — 그런 항목은 목록에서 조용히 제외한다. + * 존재하지 않는(삭제된) fileId는 imagesById/attachmentsById에 키가 없다 — 그런 항목은 목록에서 조용히 제외한다. */ - public static NoticeDetailResponse from(Notice notice, Map imageUrls, Map attachmentInfos) { + public static NoticeDetailResponse from( + Notice notice, + Map imagesById, + Map attachmentsById, + String publicBaseUrl + ) { List images = notice.getImageIds() == null ? List.of() : notice.getImageIds().stream() - .filter(imageUrls::containsKey) - .map(fileId -> Image.of(fileId, imageUrls.get(fileId))) + .filter(imagesById::containsKey) + .map(fileId -> Image.of(fileId, imagesById.get(fileId), publicBaseUrl)) .toList(); List attachments = notice.getAttachmentIds() == null ? List.of() : notice.getAttachmentIds().stream() - .filter(attachmentInfos::containsKey) - .map(fileId -> Attachment.of(fileId, attachmentInfos.get(fileId))) + .filter(attachmentsById::containsKey) + .map(fileId -> Attachment.of(fileId, attachmentsById.get(fileId), publicBaseUrl)) .toList(); return new NoticeDetailResponse( @@ -48,15 +53,15 @@ public static NoticeDetailResponse from(Notice notice, Map imageU public record Image(Long fileId, String fileUrl) { - public static Image of(Long fileId, FileUrl fileUrl) { - return new Image(fileId, fileUrl.url()); + public static Image of(Long fileId, File file, String publicBaseUrl) { + return new Image(fileId, FileUrlUtil.buildPublicUrl(publicBaseUrl, file.getFileKey())); } } public record Attachment(Long fileId, String fileName, String fileUrl) { - public static Attachment of(Long fileId, FileInfo info) { - return new Attachment(fileId, info.originalName(), info.url().url()); + public static Attachment of(Long fileId, File file, String publicBaseUrl) { + return new Attachment(fileId, file.getOriginalName(), FileUrlUtil.buildPublicUrl(publicBaseUrl, file.getFileKey())); } } } diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/notice/response/NoticeListItemResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/notice/response/NoticeListItemResponse.java index 2a9fb340..d4cdf361 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/notice/response/NoticeListItemResponse.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/notice/response/NoticeListItemResponse.java @@ -2,7 +2,8 @@ import java.time.LocalDateTime; import java.util.Map; -import kr.ac.kookmin.stream.file.domain.FileUrl; +import kr.ac.kookmin.stream.common.FileUrlUtil; +import kr.ac.kookmin.stream.file.domain.File; import kr.ac.kookmin.stream.welfare.domain.notice.domain.Notice; import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCategory; @@ -16,18 +17,18 @@ public record NoticeListItemResponse( ) { /** 등록된 이미지 중 첫 번째를 썸네일로 쓴다. */ - public static NoticeListItemResponse from(Notice notice, Map thumbnailUrls) { + public static NoticeListItemResponse from(Notice notice, Map filesById, String publicBaseUrl) { Long thumbnailId = notice.getImageIds() == null || notice.getImageIds().isEmpty() ? null : notice.getImageIds().get(0); - FileUrl thumbnailUrl = thumbnailId == null ? null : thumbnailUrls.get(thumbnailId); + File thumbnailFile = thumbnailId == null ? null : filesById.get(thumbnailId); return new NoticeListItemResponse( notice.getId(), notice.getTitle(), notice.getCategory(), notice.getCreatedAt(), - thumbnailUrl == null ? null : thumbnailUrl.url(), + thumbnailFile == null ? null : FileUrlUtil.buildPublicUrl(publicBaseUrl, thumbnailFile.getFileKey()), notice.isPinned() ); } diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/AppRentalController.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/AppRentalController.java index 1bdeaba7..a9575a44 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/AppRentalController.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/AppRentalController.java @@ -2,7 +2,6 @@ import jakarta.validation.Valid; import java.util.List; -import java.util.Map; import kr.ac.kookmin.stream.api.app.AppApiUser; import kr.ac.kookmin.stream.api.app.welfare.rental.request.ItemListParams; import kr.ac.kookmin.stream.api.app.welfare.rental.request.RentalHistoryListParams; @@ -12,7 +11,6 @@ import kr.ac.kookmin.stream.api.common.dto.ApiResponse; import kr.ac.kookmin.stream.api.common.dto.CursorSliceResponse; import kr.ac.kookmin.stream.common.CursorSliceResult; -import kr.ac.kookmin.stream.file.domain.FileUrl; import kr.ac.kookmin.stream.file.service.FileService; import kr.ac.kookmin.stream.welfare.domain.rental.domain.Item; import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalRecord; @@ -41,10 +39,8 @@ public ApiResponse> getItems( CursorSliceResult result = itemService.getItems( params.toCategory(), params.toKeyword(), params.toCursor(), params.sizeOrDefault() ); - Map imageUrls = fileService.publicUrls( - result.content().stream().map(Item::getImageKey).filter(key -> key != null).distinct().toList() - ); - return ApiResponse.success(CursorSliceResponse.from(result, item -> ItemListItemResponse.from(item, imageUrls))); + String publicBaseUrl = fileService.publicBaseUrl(); + return ApiResponse.success(CursorSliceResponse.from(result, item -> ItemListItemResponse.from(item, publicBaseUrl))); } @Override @@ -54,19 +50,13 @@ public ApiResponse getHistories( @Valid @ModelAttribute RentalHistoryListParams params ) { List records = rentalHistoryService.getHistories(apiUser.userId(), params.toStatus()); - return ApiResponse.success(RentalHistoryListResponse.from(records, resolveImageUrls(records))); + return ApiResponse.success(RentalHistoryListResponse.from(records, fileService.publicBaseUrl())); } @Override @GetMapping("/histories/return-required") public ApiResponse getReturnRequired(AppApiUser apiUser) { List records = rentalHistoryService.getReturnRequiredRentals(apiUser.userId()); - return ApiResponse.success(ReturnRequiredListResponse.from(records, resolveImageUrls(records))); - } - - private Map resolveImageUrls(List records) { - return fileService.publicUrls( - records.stream().map(RentalRecord::itemImageKey).filter(key -> key != null).distinct().toList() - ); + return ApiResponse.success(ReturnRequiredListResponse.from(records, fileService.publicBaseUrl())); } } diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/ItemListItemResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/ItemListItemResponse.java index 0ff3bdb9..04fb6eef 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/ItemListItemResponse.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/ItemListItemResponse.java @@ -1,8 +1,7 @@ package kr.ac.kookmin.stream.api.app.welfare.rental.response; import java.time.format.DateTimeFormatter; -import java.util.Map; -import kr.ac.kookmin.stream.file.domain.FileUrl; +import kr.ac.kookmin.stream.common.FileUrlUtil; import kr.ac.kookmin.stream.welfare.domain.rental.domain.Item; import kr.ac.kookmin.stream.welfare.domain.rental.domain.ItemCategory; import kr.ac.kookmin.stream.welfare.domain.rental.domain.ItemType; @@ -18,16 +17,16 @@ public record ItemListItemResponse( ReturnPolicyResponse returnPolicy ) { - public static ItemListItemResponse from(Item item, Map imageUrls) { + public static ItemListItemResponse from(Item item, String publicBaseUrl) { ReturnPolicy returnPolicy = item.getReturnPolicy(); - FileUrl imageUrl = item.getImageKey() == null ? null : imageUrls.get(item.getImageKey()); + String imageUrl = item.getImageKey() == null ? null : FileUrlUtil.buildPublicUrl(publicBaseUrl, item.getImageKey()); return new ItemListItemResponse( item.getId(), item.getName(), item.getCategory(), item.getType(), item.getCount(), - imageUrl == null ? null : imageUrl.url(), + imageUrl, returnPolicy == null ? null : ReturnPolicyResponse.from(returnPolicy) ); } diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/RentalHistoryListResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/RentalHistoryListResponse.java index 5861aa55..39699bfb 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/RentalHistoryListResponse.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/RentalHistoryListResponse.java @@ -2,16 +2,15 @@ import java.time.LocalDateTime; import java.util.List; -import java.util.Map; -import kr.ac.kookmin.stream.file.domain.FileUrl; +import kr.ac.kookmin.stream.common.FileUrlUtil; import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalHistory; import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalRecord; import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalStatus; public record RentalHistoryListResponse(List histories) { - public static RentalHistoryListResponse from(List records, Map imageUrls) { - return new RentalHistoryListResponse(records.stream().map(record -> History.from(record, imageUrls)).toList()); + public static RentalHistoryListResponse from(List records, String publicBaseUrl) { + return new RentalHistoryListResponse(records.stream().map(record -> History.from(record, publicBaseUrl)).toList()); } public record History( @@ -23,14 +22,14 @@ public record History( RentalStatus status ) { - public static History from(RentalRecord record, Map imageUrls) { + public static History from(RentalRecord record, String publicBaseUrl) { RentalHistory history = record.history(); String imageKey = record.itemImageKey(); - FileUrl imageUrl = imageKey == null ? null : imageUrls.get(imageKey); + String imageUrl = imageKey == null ? null : FileUrlUtil.buildPublicUrl(publicBaseUrl, imageKey); return new History( history.getId(), record.itemName(), - imageUrl == null ? null : imageUrl.url(), + imageUrl, history.getRentAt(), history.getReturnedAt(), history.getRentalStatus() diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/ReturnRequiredListResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/ReturnRequiredListResponse.java index 0acc4602..cc89c208 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/ReturnRequiredListResponse.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/ReturnRequiredListResponse.java @@ -2,14 +2,13 @@ import java.time.LocalDateTime; import java.util.List; -import java.util.Map; -import kr.ac.kookmin.stream.file.domain.FileUrl; +import kr.ac.kookmin.stream.common.FileUrlUtil; import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalRecord; public record ReturnRequiredListResponse(List rentalHistories) { - public static ReturnRequiredListResponse from(List records, Map imageUrls) { - return new ReturnRequiredListResponse(records.stream().map(record -> Rental.from(record, imageUrls)).toList()); + public static ReturnRequiredListResponse from(List records, String publicBaseUrl) { + return new ReturnRequiredListResponse(records.stream().map(record -> Rental.from(record, publicBaseUrl)).toList()); } public record Rental( @@ -19,13 +18,13 @@ public record Rental( LocalDateTime dueAt ) { - public static Rental from(RentalRecord record, Map imageUrls) { + public static Rental from(RentalRecord record, String publicBaseUrl) { String imageKey = record.itemImageKey(); - FileUrl imageUrl = imageKey == null ? null : imageUrls.get(imageKey); + String imageUrl = imageKey == null ? null : FileUrlUtil.buildPublicUrl(publicBaseUrl, imageKey); return new Rental( record.history().getId(), record.itemName(), - imageUrl == null ? null : imageUrl.url(), + imageUrl, record.dueAt() ); } diff --git a/core/common/src/main/java/kr/ac/kookmin/stream/common/FileUrlUtil.java b/core/common/src/main/java/kr/ac/kookmin/stream/common/FileUrlUtil.java new file mode 100644 index 00000000..80dfad11 --- /dev/null +++ b/core/common/src/main/java/kr/ac/kookmin/stream/common/FileUrlUtil.java @@ -0,0 +1,14 @@ +package kr.ac.kookmin.stream.common; + +public final class FileUrlUtil { + + private FileUrlUtil() {} + + /** publicBaseUrl 끝에 슬래시가 있어도 이어붙인 URL에 슬래시가 중복되지 않게 한다. */ + public static String buildPublicUrl(String publicBaseUrl, String fileKey) { + String trimmedBaseUrl = publicBaseUrl.endsWith("/") + ? publicBaseUrl.substring(0, publicBaseUrl.length() - 1) + : publicBaseUrl; + return trimmedBaseUrl + "/" + fileKey; + } +} diff --git a/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/client/FileStorageClient.java b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/client/FileStorageClient.java index 9d929459..f47e7462 100644 --- a/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/client/FileStorageClient.java +++ b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/client/FileStorageClient.java @@ -1,23 +1,11 @@ package kr.ac.kookmin.stream.file.client; import java.io.InputStream; -import java.util.List; -import java.util.Map; -import kr.ac.kookmin.stream.file.domain.FileUrl; import kr.ac.kookmin.stream.file.domain.UploadUrl; public interface FileStorageClient { UploadUrl issuePresignedUrl(String fileKey, String contentType); void write(String fileKey, InputStream content); void deleteObject(String fileKey); - FileUrl publicUrl(String fileKey); - Map publicUrls(List fileKeys); - - /** publicBaseUrl 끝에 슬래시가 있어도 이어붙인 URL에 슬래시가 중복되지 않게 한다. */ - static String buildPublicUrl(String publicBaseUrl, String fileKey) { - String trimmedBaseUrl = publicBaseUrl.endsWith("/") - ? publicBaseUrl.substring(0, publicBaseUrl.length() - 1) - : publicBaseUrl; - return trimmedBaseUrl + "/" + fileKey; - } + String publicBaseUrl(); } diff --git a/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/domain/FileInfo.java b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/domain/FileInfo.java deleted file mode 100644 index abe7419a..00000000 --- a/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/domain/FileInfo.java +++ /dev/null @@ -1,3 +0,0 @@ -package kr.ac.kookmin.stream.file.domain; - -public record FileInfo(String originalName, FileUrl url) {} diff --git a/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/domain/FileUrl.java b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/domain/FileUrl.java deleted file mode 100644 index 156d44f1..00000000 --- a/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/domain/FileUrl.java +++ /dev/null @@ -1,3 +0,0 @@ -package kr.ac.kookmin.stream.file.domain; - -public record FileUrl(String url) {} diff --git a/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/FileService.java b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/FileService.java index ade0ff32..7361a798 100644 --- a/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/FileService.java +++ b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/FileService.java @@ -4,19 +4,15 @@ import java.util.List; import java.util.Map; import java.util.Optional; -import kr.ac.kookmin.stream.file.domain.FileInfo; +import kr.ac.kookmin.stream.file.domain.File; import kr.ac.kookmin.stream.file.domain.FileUploadUrlIssueCommand; import kr.ac.kookmin.stream.file.domain.FileUploadUrlIssueResult; -import kr.ac.kookmin.stream.file.domain.FileUrl; public interface FileService { FileUploadUrlIssueResult issuePresignedUrl(FileUploadUrlIssueCommand command); void receiveUpload(String fileKey, InputStream content); void delete(Long fileId); - Optional resolveUrl(Long fileId); - Map resolveUrls(List fileIds); - Optional resolveInfo(Long fileId); - Map resolveInfos(List fileIds); - FileUrl publicUrl(String fileKey); - Map publicUrls(List fileKeys); + Optional findById(Long fileId); + Map findAllByIdIn(List fileIds); + String publicBaseUrl(); } diff --git a/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/impl/FileServiceImpl.java b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/impl/FileServiceImpl.java index 7dfafd7c..cb96c7cf 100644 --- a/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/impl/FileServiceImpl.java +++ b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/impl/FileServiceImpl.java @@ -1,19 +1,18 @@ package kr.ac.kookmin.stream.file.service.impl; import java.io.InputStream; -import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Collectors; import kr.ac.kookmin.stream.common.BusinessException; import kr.ac.kookmin.stream.file.client.FileStorageClient; import kr.ac.kookmin.stream.file.domain.File; import kr.ac.kookmin.stream.file.domain.FileErrorCode; -import kr.ac.kookmin.stream.file.domain.FileInfo; import kr.ac.kookmin.stream.file.domain.FileUploadUrlIssueCommand; import kr.ac.kookmin.stream.file.domain.FileUploadUrlIssueResult; -import kr.ac.kookmin.stream.file.domain.FileUrl; import kr.ac.kookmin.stream.file.domain.UploadUrl; import kr.ac.kookmin.stream.file.repository.FileRepository; import kr.ac.kookmin.stream.file.service.FileService; @@ -68,57 +67,22 @@ public void delete(Long fileId) { } @Override - public Optional resolveUrl(Long fileId) { - return fileRepository.findById(fileId) - .map(file -> fileStorageClient.publicUrl(file.getFileKey())); + public Optional findById(Long fileId) { + return fileRepository.findById(fileId); } @Override - public Map resolveUrls(List fileIds) { + public Map findAllByIdIn(List fileIds) { if (fileIds.isEmpty()) { return Map.of(); } - - List files = fileRepository.findAllByIdIn(fileIds); - Map urlsByKey = fileStorageClient.publicUrls(files.stream().map(File::getFileKey).toList()); - - Map result = new LinkedHashMap<>(); - for (File file : files) { - result.put(file.getId(), urlsByKey.get(file.getFileKey())); - } - return result; - } - - @Override - public Optional resolveInfo(Long fileId) { - return fileRepository.findById(fileId) - .map(file -> new FileInfo(file.getOriginalName(), fileStorageClient.publicUrl(file.getFileKey()))); - } - - @Override - public Map resolveInfos(List fileIds) { - if (fileIds.isEmpty()) { - return Map.of(); - } - - List files = fileRepository.findAllByIdIn(fileIds); - Map urlsByKey = fileStorageClient.publicUrls(files.stream().map(File::getFileKey).toList()); - - Map result = new LinkedHashMap<>(); - for (File file : files) { - result.put(file.getId(), new FileInfo(file.getOriginalName(), urlsByKey.get(file.getFileKey()))); - } - return result; - } - - @Override - public FileUrl publicUrl(String fileKey) { - return fileStorageClient.publicUrl(fileKey); + return fileRepository.findAllByIdIn(fileIds).stream() + .collect(Collectors.toMap(File::getId, Function.identity())); } @Override - public Map publicUrls(List fileKeys) { - return fileStorageClient.publicUrls(fileKeys); + public String publicBaseUrl() { + return fileStorageClient.publicBaseUrl(); } private String generateFileKey(String originalName) { diff --git a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/local/LocalFileStorageClient.java b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/local/LocalFileStorageClient.java index 8e73a985..318f665d 100644 --- a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/local/LocalFileStorageClient.java +++ b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/local/LocalFileStorageClient.java @@ -7,12 +7,7 @@ import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.time.LocalDateTime; -import java.util.List; -import java.util.Map; -import java.util.function.Function; -import java.util.stream.Collectors; import kr.ac.kookmin.stream.file.client.FileStorageClient; -import kr.ac.kookmin.stream.file.domain.FileUrl; import kr.ac.kookmin.stream.file.domain.UploadUrl; import lombok.RequiredArgsConstructor; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -58,13 +53,8 @@ public void deleteObject(String fileKey) { } @Override - public FileUrl publicUrl(String fileKey) { - return new FileUrl(FileStorageClient.buildPublicUrl(properties.publicBaseUrl(), fileKey)); - } - - @Override - public Map publicUrls(List fileKeys) { - return fileKeys.stream().collect(Collectors.toMap(Function.identity(), this::publicUrl, (a, b) -> a)); + public String publicBaseUrl() { + return properties.publicBaseUrl(); } private Path resolvePath(String fileKey) { diff --git a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/s3/S3FileStorageClient.java b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/s3/S3FileStorageClient.java index 505174eb..2803ec00 100644 --- a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/s3/S3FileStorageClient.java +++ b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/s3/S3FileStorageClient.java @@ -4,12 +4,7 @@ import java.time.Duration; import java.time.LocalDateTime; import java.time.ZoneId; -import java.util.List; -import java.util.Map; -import java.util.function.Function; -import java.util.stream.Collectors; import kr.ac.kookmin.stream.file.client.FileStorageClient; -import kr.ac.kookmin.stream.file.domain.FileUrl; import kr.ac.kookmin.stream.file.domain.UploadUrl; import lombok.RequiredArgsConstructor; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -67,12 +62,7 @@ public void deleteObject(String fileKey) { } @Override - public FileUrl publicUrl(String fileKey) { - return new FileUrl(FileStorageClient.buildPublicUrl(properties.publicBaseUrl(), fileKey)); - } - - @Override - public Map publicUrls(List fileKeys) { - return fileKeys.stream().collect(Collectors.toMap(Function.identity(), this::publicUrl, (a, b) -> a)); + public String publicBaseUrl() { + return properties.publicBaseUrl(); } } From 646a60968470c0c8a78f2a7f448b9cc8a242445f Mon Sep 17 00:00:00 2001 From: jjunh33 Date: Wed, 30 Sep 2026 16:51:31 +0900 Subject: [PATCH 05/11] =?UTF-8?q?refactor:=20=EB=A1=9C=EC=BB=AC=20?= =?UTF-8?q?=EC=8A=A4=ED=86=A0=EB=A6=AC=EC=A7=80=20=EC=9E=84=EC=8B=9C=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=EC=B2=B4=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R2가 이미 연동돼 있어 원래부터 "S3 연동 시 제거" 예정이던 임시 코드였다. LocalFileStorageClient/LocalFileStorageProperties, 로컬 업로드 수신 컨트롤러, 이번에 추가했던 로컬 GET 서빙 설정을 전부 지웠다. 구현체가 S3 하나만 남아 file.storage.type 스위치가 무의미해져서 @ConditionalOnProperty 분기도 같이 걷어내고, PublicEndpoints의 로컬 파일 공개 경로와 yml의 file.storage.local.* 설정도 제거했다. --- .../admin/file/AdminLocalFileUploadApi.java | 25 -------- .../file/AdminLocalFileUploadController.java | 38 ----------- .../admin/file/LocalFileServingConfig.java | 25 -------- .../stream/security/PublicEndpoints.java | 4 -- .../file/local/LocalFileStorageClient.java | 63 ------------------- .../local/LocalFileStorageProperties.java | 13 ---- .../client/file/s3/S3FileStorageClient.java | 4 +- .../client/file/s3/S3StorageConfig.java | 2 - .../application-infrastructure-client.yml | 6 -- 9 files changed, 1 insertion(+), 179 deletions(-) delete mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/file/AdminLocalFileUploadApi.java delete mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/file/AdminLocalFileUploadController.java delete mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/file/LocalFileServingConfig.java delete mode 100644 infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/local/LocalFileStorageClient.java delete mode 100644 infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/local/LocalFileStorageProperties.java diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/file/AdminLocalFileUploadApi.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/file/AdminLocalFileUploadApi.java deleted file mode 100644 index c7dae21b..00000000 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/file/AdminLocalFileUploadApi.java +++ /dev/null @@ -1,25 +0,0 @@ -package kr.ac.kookmin.stream.api.admin.file; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.servlet.http.HttpServletRequest; -import java.io.IOException; -import kr.ac.kookmin.stream.api.common.openapi.ApiErrorCode; -import kr.ac.kookmin.stream.api.common.dto.ApiResponse; -import kr.ac.kookmin.stream.file.domain.FileErrorCode; - -/** - * 로컬 업로드 수신 API의 문서 명세. 구현은 {@link AdminLocalFileUploadController}가 맡는다. - *

- * S3 연동 전 임시 엔드포인트다. presigned-url 발급 응답의 uploadUrl이 이 경로를 가리킨다. - * 스웨거 문서용 어노테이션만 이쪽에 두고 컨트롤러에는 라우팅과 본문만 남긴다. - */ -@Tag(name = "파일 로컬 업로드", description = "S3 전환 전 임시 로컬 업로드 수신 엔드포인트") -public interface AdminLocalFileUploadApi { - - /** presigned URL로 전달받은 파일 바이트를 로컬에 저장한다. */ - @Operation(summary = "로컬 업로드 수신", - description = "발급된 로컬 업로드 URL로 들어온 파일 바이트를 저장한다. S3 전환 시 제거된다.") - @ApiErrorCode(type = FileErrorCode.class, codes = {"FILE_NOT_FOUND"}) - ApiResponse receiveLocalUpload(HttpServletRequest request) throws IOException; -} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/file/AdminLocalFileUploadController.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/file/AdminLocalFileUploadController.java deleted file mode 100644 index b6a82b25..00000000 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/file/AdminLocalFileUploadController.java +++ /dev/null @@ -1,38 +0,0 @@ -package kr.ac.kookmin.stream.api.admin.file; - -import jakarta.servlet.http.HttpServletRequest; -import java.io.IOException; -import kr.ac.kookmin.stream.api.common.dto.ApiResponse; -import kr.ac.kookmin.stream.file.service.FileService; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -/** - * S3 연동 전 임시 컨트롤러. presigned-url 발급 응답의 uploadUrl이 이 경로를 가리킨다. - * S3로 전환하면 이 클래스와 LocalFileStorageClient의 로컬 구현을 함께 제거한다. - */ -@RestController -@RequestMapping("/v1/admin/files") -@RequiredArgsConstructor -public class AdminLocalFileUploadController implements AdminLocalFileUploadApi { - - private static final String LOCAL_UPLOAD_PATH = "/local-upload/"; - - private final FileService fileService; - - @Override - @PutMapping("/local-upload/**") - public ApiResponse receiveLocalUpload(HttpServletRequest request) throws IOException { - String fileKey = extractFileKey(request); - fileService.receiveUpload(fileKey, request.getInputStream()); - return ApiResponse.success(); - } - - private String extractFileKey(HttpServletRequest request) { - String uri = request.getRequestURI(); - int index = uri.indexOf(LOCAL_UPLOAD_PATH); - return uri.substring(index + LOCAL_UPLOAD_PATH.length()); - } -} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/file/LocalFileServingConfig.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/file/LocalFileServingConfig.java deleted file mode 100644 index be3fdf8c..00000000 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/file/LocalFileServingConfig.java +++ /dev/null @@ -1,25 +0,0 @@ -package kr.ac.kookmin.stream.api.admin.file; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.context.annotation.Configuration; -import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; -import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; - -/** - * 로컬 스토리지에 저장된 파일을 공개 GET으로 읽을 수 있게 하는 임시 설정. - * S3 연동 전 임시 서빙이며, S3로 전환하면 {@link AdminLocalFileUploadController}와 함께 제거한다. - */ -@Configuration -@ConditionalOnProperty(prefix = "file.storage", name = "type", havingValue = "local", matchIfMissing = true) -public class LocalFileServingConfig implements WebMvcConfigurer { - - @Value("${file.storage.local.base-path}") - private String basePath; - - @Override - public void addResourceHandlers(ResourceHandlerRegistry registry) { - registry.addResourceHandler("/files/**") - .addResourceLocations("file:" + basePath + "/files/"); - } -} diff --git a/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/PublicEndpoints.java b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/PublicEndpoints.java index af1ad4ed..b8557575 100644 --- a/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/PublicEndpoints.java +++ b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/PublicEndpoints.java @@ -22,10 +22,6 @@ public enum PublicEndpoints { "/swagger-ui/**", "/swagger-ui.html", "/v3/api-docs/**" - )), - /** 로컬 스토리지 파일 공개 읽기. S3 연동 전 임시 서빙이라 S3 전환 시 제거한다. */ - LOCAL_FILE_READ(List.of( - "/files/**" )); private static final List ALL_PATH_PATTERNS = Arrays.stream(values()) diff --git a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/local/LocalFileStorageClient.java b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/local/LocalFileStorageClient.java deleted file mode 100644 index 318f665d..00000000 --- a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/local/LocalFileStorageClient.java +++ /dev/null @@ -1,63 +0,0 @@ -package kr.ac.kookmin.stream.client.file.local; - -import java.io.IOException; -import java.io.InputStream; -import java.io.UncheckedIOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.StandardCopyOption; -import java.time.LocalDateTime; -import kr.ac.kookmin.stream.file.client.FileStorageClient; -import kr.ac.kookmin.stream.file.domain.UploadUrl; -import lombok.RequiredArgsConstructor; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.stereotype.Component; - -/** - * 로컬 디스크 기반 임시 구현체. S3 연동 시 이 클래스와 "임시 로컬 업로드 엔드포인트"를 함께 제거한다. - */ -@Component -@ConditionalOnProperty(prefix = "file.storage", name = "type", havingValue = "local", matchIfMissing = true) -@RequiredArgsConstructor -public class LocalFileStorageClient implements FileStorageClient { - - private static final String LOCAL_UPLOAD_PATH = "/v1/admin/files/local-upload/"; - - private final LocalFileStorageProperties properties; - - @Override - public UploadUrl issuePresignedUrl(String fileKey, String contentType) { - String url = properties.baseUrl() + LOCAL_UPLOAD_PATH + fileKey; - LocalDateTime expiresAt = LocalDateTime.now().plusSeconds(properties.uploadUrlExpirySeconds()); - return new UploadUrl(url, expiresAt); - } - - @Override - public void write(String fileKey, InputStream content) { - try { - Path path = resolvePath(fileKey); - Files.createDirectories(path.getParent()); - Files.copy(content, path, StandardCopyOption.REPLACE_EXISTING); - } catch (IOException e) { - throw new UncheckedIOException(e); - } - } - - @Override - public void deleteObject(String fileKey) { - try { - Files.deleteIfExists(resolvePath(fileKey)); - } catch (IOException e) { - throw new UncheckedIOException(e); - } - } - - @Override - public String publicBaseUrl() { - return properties.publicBaseUrl(); - } - - private Path resolvePath(String fileKey) { - return Path.of(properties.basePath()).resolve(fileKey).normalize(); - } -} diff --git a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/local/LocalFileStorageProperties.java b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/local/LocalFileStorageProperties.java deleted file mode 100644 index 5c65c8b5..00000000 --- a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/local/LocalFileStorageProperties.java +++ /dev/null @@ -1,13 +0,0 @@ -package kr.ac.kookmin.stream.client.file.local; - -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.context.annotation.Profile; - -@ConfigurationProperties(prefix = "file.storage.local") -@Profile("!prod") -public record LocalFileStorageProperties( - String basePath, - String baseUrl, - String publicBaseUrl, - long uploadUrlExpirySeconds -) {} diff --git a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/s3/S3FileStorageClient.java b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/s3/S3FileStorageClient.java index 2803ec00..4f95681d 100644 --- a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/s3/S3FileStorageClient.java +++ b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/s3/S3FileStorageClient.java @@ -7,7 +7,6 @@ import kr.ac.kookmin.stream.file.client.FileStorageClient; import kr.ac.kookmin.stream.file.domain.UploadUrl; import lombok.RequiredArgsConstructor; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; @@ -18,11 +17,10 @@ /** * S3 호환 스토리지(Cloudflare R2) 기반 구현체. presigned URL은 클라이언트가 스토리지에 직접 PUT하는 용도라, - * 로컬 구현체와 달리 서버가 파일 바이트를 직접 받는 write(...)는 지원하지 않는다. + * 서버가 파일 바이트를 직접 받는 write(...)는 지원하지 않는다. * R2 엔드포인트·자격증명 설정은 {@link S3StorageConfig}가 만드는 {@link S3Client}/{@link S3Presigner} 빈에 있다. */ @Component -@ConditionalOnProperty(prefix = "file.storage", name = "type", havingValue = "s3") @RequiredArgsConstructor public class S3FileStorageClient implements FileStorageClient { diff --git a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/s3/S3StorageConfig.java b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/s3/S3StorageConfig.java index 25f28d09..207830f2 100644 --- a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/s3/S3StorageConfig.java +++ b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/s3/S3StorageConfig.java @@ -1,7 +1,6 @@ package kr.ac.kookmin.stream.client.file.s3; import java.net.URI; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; @@ -16,7 +15,6 @@ * R2에는 EC2 인스턴스 프로필 같은 자동 자격증명 체인이 없어, R2 API 토큰의 액세스 키·시크릿 키를 정적으로 주입한다. */ @Configuration -@ConditionalOnProperty(prefix = "file.storage", name = "type", havingValue = "s3") public class S3StorageConfig { @Bean diff --git a/infrastructure/client/src/main/resources/application-infrastructure-client.yml b/infrastructure/client/src/main/resources/application-infrastructure-client.yml index cca6942b..abe88b8d 100644 --- a/infrastructure/client/src/main/resources/application-infrastructure-client.yml +++ b/infrastructure/client/src/main/resources/application-infrastructure-client.yml @@ -1,11 +1,5 @@ file: storage: - type: ${FILE_STORAGE_TYPE:local} - local: - base-path: ${LOCAL_STORAGE_BASE_PATH:./local-storage} - base-url: ${LOCAL_STORAGE_BASE_URL:http://localhost:8080} - public-base-url: ${LOCAL_STORAGE_PUBLIC_BASE_URL:http://localhost:8080} - upload-url-expiry-seconds: ${LOCAL_STORAGE_UPLOAD_URL_EXPIRY_SECONDS:600} s3: bucket: ${R2_BUCKET:} region: ${R2_REGION:auto} From 68cedbbfcfd7218efc6796933fe45179dfa0b0bd Mon Sep 17 00:00:00 2001 From: jjunh33 Date: Wed, 30 Sep 2026 16:53:14 +0900 Subject: [PATCH 06/11] =?UTF-8?q?docs:=20coding-style.md=EC=9D=98=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=8A=A4=ED=86=A0=EB=A6=AC=EC=A7=80=20?= =?UTF-8?q?=EC=98=88=EC=8B=9C=EB=A5=BC=20=EB=A1=9C=EC=BB=AC=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=EC=B2=B4=20=EC=A0=9C=EA=B1=B0=20=EC=9D=B4=ED=9B=84?= =?UTF-8?q?=EB=A1=9C=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LocalFileStorageClient가 삭제되면서 @ConditionalOnProperty 멀티 구현체 선택 예시와 로컬/S3 조건부 등록 예시가 실제 코드와 어긋났다. 구현체가 하나뿐인 지금 상태에 맞게 예시 코드를 정리하고, 모듈 경로 주석도 core:domain:internal에서 core:domain:file로 고쳤다. --- docs/conventions/coding-style.md | 30 +++++------------------------- 1 file changed, 5 insertions(+), 25 deletions(-) diff --git a/docs/conventions/coding-style.md b/docs/conventions/coding-style.md index 893436cd..23064d1f 100644 --- a/docs/conventions/coding-style.md +++ b/docs/conventions/coding-style.md @@ -486,11 +486,12 @@ lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Quali Repository(2-6절)와 같은 구조다 — `core:domain`에 인터페이스(공개), `infrastructure:client`에 구현체. ```java -// core:domain:internal — domain/file/client (공개) +// core:domain:file — client (공개) public interface FileStorageClient { UploadUrl issuePresignedUrl(String fileKey, String contentType); void write(String fileKey, InputStream content); void deleteObject(String fileKey); + String publicBaseUrl(); } ``` @@ -505,24 +506,13 @@ public void write(String fileKey, InputStream content) { } ``` -- **한 포트에 구현체가 여러 개면 `@ConditionalOnProperty`로 하나만 Bean으로 띄운다**(`@Profile`이 아니라 — 로컬/운영을 나누는 게 아니라 같은 환경 안에서 설정값으로 고르는 것이므로). +- **한 포트에 구현체가 여러 개면 `@ConditionalOnProperty`로 하나만 Bean으로 띄운다**(`@Profile`이 아니라 — 로컬/운영을 나누는 게 아니라 같은 환경 안에서 설정값으로 고르는 것이므로). 구현체가 하나뿐이면 이 어노테이션 자체가 필요 없다 — 나중에 두 번째 구현체가 생기는 시점에 다시 붙인다. -```java -@Component -@ConditionalOnProperty(prefix = "file.storage", name = "type", havingValue = "s3") -public class S3FileStorageClient implements FileStorageClient { ... } - -@Component -@ConditionalOnProperty(prefix = "file.storage", name = "type", havingValue = "local", matchIfMissing = true) -public class LocalFileStorageClient implements FileStorageClient { ... } -``` - -- **외부 SDK 클라이언트(`S3Client`, `S3Presigner` 등)는 구현체 생성자에서 만들지 않고, 같은 패키지의 설정 클래스(`@Configuration`)에서 `@Bean`으로 등록해 주입받는다.** 설정 클래스에도 구현체와 같은 `@ConditionalOnProperty`를 붙인다. SDK 클라이언트는 `close()`가 필요한 자원인데, 빈으로 등록하면 종료 시 스프링이 대신 호출한다. +- **외부 SDK 클라이언트(`S3Client`, `S3Presigner` 등)는 구현체 생성자에서 만들지 않고, 같은 패키지의 설정 클래스(`@Configuration`)에서 `@Bean`으로 등록해 주입받는다.** SDK 클라이언트는 `close()`가 필요한 자원인데, 빈으로 등록하면 종료 시 스프링이 대신 호출한다. 구현체가 `@ConditionalOnProperty`로 골라지는 상황이라면 설정 클래스에도 같은 조건을 붙인다. ```java // infrastructure:client — client/file/s3 @Configuration -@ConditionalOnProperty(prefix = "file.storage", name = "type", havingValue = "s3") public class S3StorageConfig { @Bean @@ -533,7 +523,6 @@ public class S3StorageConfig { } @Component -@ConditionalOnProperty(prefix = "file.storage", name = "type", havingValue = "s3") @RequiredArgsConstructor public class S3FileStorageClient implements FileStorageClient { @@ -543,16 +532,7 @@ public class S3FileStorageClient implements FileStorageClient { } ``` -- **임시 구현체(추후 다른 구현체로 완전히 교체될 코드)에는 "무엇으로 전환하면 이 코드를 지운다"는 클래스 주석을 남긴다.** 그 임시 구현체에 딸린 전용 엔드포인트·메서드(예: 로컬 전용 업로드 수신 API)도 같은 문구로 표시해서, 실제 전환 작업을 할 때 검색 한 번으로 같이 지울 대상을 찾을 수 있게 한다. - -```java -/** - * 로컬 디스크 기반 임시 구현체. S3 연동 시 이 클래스와 "임시 로컬 업로드 엔드포인트"를 함께 제거한다. - */ -@Component -@ConditionalOnProperty(prefix = "file.storage", name = "type", havingValue = "local", matchIfMissing = true) -public class LocalFileStorageClient implements FileStorageClient { ... } -``` +- **임시 구현체(추후 다른 구현체로 완전히 교체될 코드)에는 "무엇으로 전환하면 이 코드를 지운다"는 클래스 주석을 남긴다.** 그 임시 구현체에 딸린 전용 엔드포인트·메서드도 같은 문구로 표시해서, 실제 전환 작업을 할 때 검색 한 번으로 같이 지울 대상을 찾을 수 있게 한다. (R2 연동 전 `LocalFileStorageClient`가 이렇게 표시돼 있었고, 실제로 연동 후 그 주석 그대로 함께 제거됐다.) --- From 72055f1234b0542e27d3dda30870feb3bffb0801 Mon Sep 17 00:00:00 2001 From: jjunh33 Date: Wed, 30 Sep 2026 17:07:13 +0900 Subject: [PATCH 07/11] =?UTF-8?q?refactor:=20=EC=95=84=EB=AC=B4=EB=8F=84?= =?UTF-8?q?=20=EC=95=88=20=EC=93=B0=EB=8A=94=20receiveUpload/write/findByF?= =?UTF-8?q?ileKey=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 로컬 스토리지를 없애면서 그걸 쓰던 유일한 호출부(로컬 업로드 수신 컨트롤러)도 같이 지웠는데, 정작 FileService.receiveUpload와 그게 부르는 FileStorageClient.write는 남아 있었다. S3 구현체는 이 메서드가 항상 UnsupportedOperationException만 던졌는데, 이제 호출하는 곳 자체가 없어 실행될 일이 없는 코드였다. receiveUpload에서만 쓰이던 FileRepository.findByFileKey도 같은 이유로 같이 지웠다. coding-style.md의 관련 예시도 갱신했다. --- .../stream/file/client/FileStorageClient.java | 2 -- .../stream/file/repository/FileRepository.java | 1 - .../ac/kookmin/stream/file/service/FileService.java | 2 -- .../stream/file/service/impl/FileServiceImpl.java | 8 -------- docs/conventions/coding-style.md | 12 +----------- .../stream/client/file/s3/S3FileStorageClient.java | 9 +-------- .../ac/kookmin/stream/db/file/FileJpaRepository.java | 2 -- .../kookmin/stream/db/file/FileRepositoryImpl.java | 5 ----- 8 files changed, 2 insertions(+), 39 deletions(-) diff --git a/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/client/FileStorageClient.java b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/client/FileStorageClient.java index f47e7462..8af8ef06 100644 --- a/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/client/FileStorageClient.java +++ b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/client/FileStorageClient.java @@ -1,11 +1,9 @@ package kr.ac.kookmin.stream.file.client; -import java.io.InputStream; import kr.ac.kookmin.stream.file.domain.UploadUrl; public interface FileStorageClient { UploadUrl issuePresignedUrl(String fileKey, String contentType); - void write(String fileKey, InputStream content); void deleteObject(String fileKey); String publicBaseUrl(); } diff --git a/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/repository/FileRepository.java b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/repository/FileRepository.java index 1a00be2b..f34418bc 100644 --- a/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/repository/FileRepository.java +++ b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/repository/FileRepository.java @@ -6,7 +6,6 @@ public interface FileRepository { Optional findById(Long id); - Optional findByFileKey(String fileKey); List findAllByIdIn(List ids); File save(File file); void deleteById(Long id); diff --git a/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/FileService.java b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/FileService.java index 7361a798..4c9d86e9 100644 --- a/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/FileService.java +++ b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/FileService.java @@ -1,6 +1,5 @@ package kr.ac.kookmin.stream.file.service; -import java.io.InputStream; import java.util.List; import java.util.Map; import java.util.Optional; @@ -10,7 +9,6 @@ public interface FileService { FileUploadUrlIssueResult issuePresignedUrl(FileUploadUrlIssueCommand command); - void receiveUpload(String fileKey, InputStream content); void delete(Long fileId); Optional findById(Long fileId); Map findAllByIdIn(List fileIds); diff --git a/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/impl/FileServiceImpl.java b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/impl/FileServiceImpl.java index cb96c7cf..e4658846 100644 --- a/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/impl/FileServiceImpl.java +++ b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/impl/FileServiceImpl.java @@ -1,6 +1,5 @@ package kr.ac.kookmin.stream.file.service.impl; -import java.io.InputStream; import java.util.List; import java.util.Map; import java.util.Optional; @@ -50,13 +49,6 @@ public FileUploadUrlIssueResult issuePresignedUrl(FileUploadUrlIssueCommand comm return FileUploadUrlIssueResult.of(saved, uploadUrl); } - @Override - public void receiveUpload(String fileKey, InputStream content) { - fileRepository.findByFileKey(fileKey) - .orElseThrow(() -> new BusinessException(FileErrorCode.FILE_NOT_FOUND)); - fileStorageClient.write(fileKey, content); - } - @Override @Transactional public void delete(Long fileId) { diff --git a/docs/conventions/coding-style.md b/docs/conventions/coding-style.md index 23064d1f..4df2ec39 100644 --- a/docs/conventions/coding-style.md +++ b/docs/conventions/coding-style.md @@ -489,22 +489,12 @@ Repository(2-6절)와 같은 구조다 — `core:domain`에 인터페이스(공 // core:domain:file — client (공개) public interface FileStorageClient { UploadUrl issuePresignedUrl(String fileKey, String contentType); - void write(String fileKey, InputStream content); void deleteObject(String fileKey); String publicBaseUrl(); } ``` -- **구현체가 여러 개이고 그중 일부 메서드가 특정 구현체에서 의미가 없으면, 인터페이스를 쪼개지 않고 그 구현체에서 `UnsupportedOperationException` + 사유 주석으로 막는다.** 인터페이스 분리는 그 구현체가 계속 쓰일 때만 이득이 크다 — 임시 구현체처럼 나중에 통째로 걷어낼 코드라면 지금 쪼개봤자 걷어낼 때 그 분리도 같이 없어진다. - -```java -// infrastructure:client — S3FileStorageClient -// S3는 클라이언트가 presigned URL로 직접 업로드하므로 서버가 파일 바이트를 받을 일이 없다 -@Override -public void write(String fileKey, InputStream content) { - throw new UnsupportedOperationException("S3는 클라이언트가 presigned URL로 직접 업로드하므로 서버가 파일을 받지 않는다"); -} -``` +- **구현체가 여러 개이고 그중 일부 메서드가 특정 구현체에서 의미가 없으면, 인터페이스를 쪼개지 않고 그 구현체에서 `UnsupportedOperationException` + 사유 주석으로 막는다.** 인터페이스 분리는 그 구현체가 계속 쓰일 때만 이득이 크다 — 임시 구현체처럼 나중에 통째로 걷어낼 코드라면 지금 쪼개봤자 걷어낼 때 그 분리도 같이 없어진다. 반대로 그 메서드를 호출하는 곳이 아예 없어지면(예: 임시 구현체 전용이었던 메서드), 포트에서도 메서드 자체를 지운다 — 예외를 던지는 코드로만 남겨두지 않는다. - **한 포트에 구현체가 여러 개면 `@ConditionalOnProperty`로 하나만 Bean으로 띄운다**(`@Profile`이 아니라 — 로컬/운영을 나누는 게 아니라 같은 환경 안에서 설정값으로 고르는 것이므로). 구현체가 하나뿐이면 이 어노테이션 자체가 필요 없다 — 나중에 두 번째 구현체가 생기는 시점에 다시 붙인다. diff --git a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/s3/S3FileStorageClient.java b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/s3/S3FileStorageClient.java index 4f95681d..3b322740 100644 --- a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/s3/S3FileStorageClient.java +++ b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/s3/S3FileStorageClient.java @@ -1,6 +1,5 @@ package kr.ac.kookmin.stream.client.file.s3; -import java.io.InputStream; import java.time.Duration; import java.time.LocalDateTime; import java.time.ZoneId; @@ -16,8 +15,7 @@ import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; /** - * S3 호환 스토리지(Cloudflare R2) 기반 구현체. presigned URL은 클라이언트가 스토리지에 직접 PUT하는 용도라, - * 서버가 파일 바이트를 직접 받는 write(...)는 지원하지 않는다. + * S3 호환 스토리지(Cloudflare R2) 기반 구현체. * R2 엔드포인트·자격증명 설정은 {@link S3StorageConfig}가 만드는 {@link S3Client}/{@link S3Presigner} 빈에 있다. */ @Component @@ -46,11 +44,6 @@ public UploadUrl issuePresignedUrl(String fileKey, String contentType) { return new UploadUrl(presignedRequest.url().toString(), expiresAt); } - @Override - public void write(String fileKey, InputStream content) { - throw new UnsupportedOperationException("S3는 클라이언트가 presigned URL로 직접 업로드하므로 서버가 파일을 받지 않는다"); - } - @Override public void deleteObject(String fileKey) { s3Client.deleteObject(DeleteObjectRequest.builder() diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/file/FileJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/file/FileJpaRepository.java index 9bfd6fa8..9b61d699 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/file/FileJpaRepository.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/file/FileJpaRepository.java @@ -1,10 +1,8 @@ package kr.ac.kookmin.stream.db.file; import java.util.List; -import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; public interface FileJpaRepository extends JpaRepository { - Optional findByFileKey(String fileKey); List findAllByIdIn(List ids); } diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/file/FileRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/file/FileRepositoryImpl.java index 5d25ebbc..2e1fe512 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/file/FileRepositoryImpl.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/file/FileRepositoryImpl.java @@ -18,11 +18,6 @@ public Optional findById(Long id) { return fileJpaRepository.findById(id).map(FileJpaEntity::toDomain); } - @Override - public Optional findByFileKey(String fileKey) { - return fileJpaRepository.findByFileKey(fileKey).map(FileJpaEntity::toDomain); - } - @Override public List findAllByIdIn(List ids) { return fileJpaRepository.findAllByIdIn(ids).stream().map(FileJpaEntity::toDomain).toList(); From e77eebb8b12c25dae44348dba8c9d2db904b2eef Mon Sep 17 00:00:00 2001 From: jjunh33 Date: Thu, 1 Oct 2026 00:48:37 +0900 Subject: [PATCH 08/11] =?UTF-8?q?refactor:=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EA=B3=B5=EA=B0=9C=20URL=EC=9D=84=20=EC=84=A4=EC=A0=95=EA=B0=92?= =?UTF-8?q?=20=EB=8C=80=EC=8B=A0=20=EA=B3=A0=EC=A0=95=20=EC=83=81=EC=88=98?= =?UTF-8?q?=EB=A1=9C=20=EC=A1=B0=EB=A6=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R2 버킷 도메인이 바뀔 일이 거의 없다고 판단해, 배포 환경변수(R2_PUBLIC_BASE_URL) 대신 WebConstants.STORAGE_BASE_URL 상수로 관리하기로 했다. WebConstants는 값을 아는 쪽(api:common-api)에서 fileKey 하나만 받아 URL을 조립하는 buildStorageUrl(fileKey)도 같이 제공해, 응답 DTO들이 매번 baseUrl을 인자로 넘길 필요가 없어졌다. 이에 따라 FileService/FileStorageClient의 publicBaseUrl() 계열 메서드, S3FileStorageProperties의 publicBaseUrl 필드, yml의 public-base-url 설정이 전부 불필요해져 제거했다. baseUrl이 더 이상 런타임에 바뀌지 않으므로 트레일링 슬래시 방어 로직(FileUrlUtil)도 함께 정리했다. --- .../app/welfare/notice/AppNoticeController.java | 7 ++----- .../notice/response/NoticeDetailResponse.java | 17 ++++++++--------- .../notice/response/NoticeListItemResponse.java | 6 +++--- .../app/welfare/rental/AppRentalController.java | 9 +++------ .../rental/response/ItemListItemResponse.java | 6 +++--- .../response/RentalHistoryListResponse.java | 10 +++++----- .../response/ReturnRequiredListResponse.java | 10 +++++----- .../kookmin/stream/api/common/WebConstants.java | 14 ++++++++++++++ .../ac/kookmin/stream/common/FileUrlUtil.java | 14 -------------- .../stream/file/client/FileStorageClient.java | 1 - .../stream/file/service/FileService.java | 1 - .../file/service/impl/FileServiceImpl.java | 5 ----- .../client/file/s3/S3FileStorageClient.java | 5 ----- .../client/file/s3/S3FileStorageProperties.java | 1 - .../application-infrastructure-client.yml | 1 - 15 files changed, 43 insertions(+), 64 deletions(-) create mode 100644 api/common-api/src/main/java/kr/ac/kookmin/stream/api/common/WebConstants.java delete mode 100644 core/common/src/main/java/kr/ac/kookmin/stream/common/FileUrlUtil.java diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/notice/AppNoticeController.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/notice/AppNoticeController.java index 9fb5fd9f..a2350cc3 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/notice/AppNoticeController.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/notice/AppNoticeController.java @@ -44,11 +44,10 @@ public ApiResponse> getNotices( .filter(id -> id != null) .toList(); Map filesById = fileService.findAllByIdIn(thumbnailIds); - String publicBaseUrl = fileService.publicBaseUrl(); CursorSliceResponse response = CursorSliceResponse.from( result, - notice -> NoticeListItemResponse.from(notice, filesById, publicBaseUrl) + notice -> NoticeListItemResponse.from(notice, filesById) ); return ApiResponse.success(response); @@ -66,9 +65,7 @@ public ApiResponse getNotice(@PathVariable("noticeId") Lon notice.getAttachmentIds() == null ? List.of() : notice.getAttachmentIds() ); - return ApiResponse.success( - NoticeDetailResponse.from(notice, imagesById, attachmentsById, fileService.publicBaseUrl()) - ); + return ApiResponse.success(NoticeDetailResponse.from(notice, imagesById, attachmentsById)); } private static Long firstImageId(Notice notice) { diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/notice/response/NoticeDetailResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/notice/response/NoticeDetailResponse.java index 4e1b4c7c..f905edd8 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/notice/response/NoticeDetailResponse.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/notice/response/NoticeDetailResponse.java @@ -3,7 +3,7 @@ import java.time.LocalDateTime; import java.util.List; import java.util.Map; -import kr.ac.kookmin.stream.common.FileUrlUtil; +import kr.ac.kookmin.stream.api.common.WebConstants; import kr.ac.kookmin.stream.file.domain.File; import kr.ac.kookmin.stream.welfare.domain.notice.domain.Notice; import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCategory; @@ -24,20 +24,19 @@ public record NoticeDetailResponse( public static NoticeDetailResponse from( Notice notice, Map imagesById, - Map attachmentsById, - String publicBaseUrl + Map attachmentsById ) { List images = notice.getImageIds() == null ? List.of() : notice.getImageIds().stream() .filter(imagesById::containsKey) - .map(fileId -> Image.of(fileId, imagesById.get(fileId), publicBaseUrl)) + .map(fileId -> Image.of(fileId, imagesById.get(fileId))) .toList(); List attachments = notice.getAttachmentIds() == null ? List.of() : notice.getAttachmentIds().stream() .filter(attachmentsById::containsKey) - .map(fileId -> Attachment.of(fileId, attachmentsById.get(fileId), publicBaseUrl)) + .map(fileId -> Attachment.of(fileId, attachmentsById.get(fileId))) .toList(); return new NoticeDetailResponse( @@ -53,15 +52,15 @@ public static NoticeDetailResponse from( public record Image(Long fileId, String fileUrl) { - public static Image of(Long fileId, File file, String publicBaseUrl) { - return new Image(fileId, FileUrlUtil.buildPublicUrl(publicBaseUrl, file.getFileKey())); + public static Image of(Long fileId, File file) { + return new Image(fileId, WebConstants.buildStorageUrl(file.getFileKey())); } } public record Attachment(Long fileId, String fileName, String fileUrl) { - public static Attachment of(Long fileId, File file, String publicBaseUrl) { - return new Attachment(fileId, file.getOriginalName(), FileUrlUtil.buildPublicUrl(publicBaseUrl, file.getFileKey())); + public static Attachment of(Long fileId, File file) { + return new Attachment(fileId, file.getOriginalName(), WebConstants.buildStorageUrl(file.getFileKey())); } } } diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/notice/response/NoticeListItemResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/notice/response/NoticeListItemResponse.java index d4cdf361..ba885c3d 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/notice/response/NoticeListItemResponse.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/notice/response/NoticeListItemResponse.java @@ -2,7 +2,7 @@ import java.time.LocalDateTime; import java.util.Map; -import kr.ac.kookmin.stream.common.FileUrlUtil; +import kr.ac.kookmin.stream.api.common.WebConstants; import kr.ac.kookmin.stream.file.domain.File; import kr.ac.kookmin.stream.welfare.domain.notice.domain.Notice; import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCategory; @@ -17,7 +17,7 @@ public record NoticeListItemResponse( ) { /** 등록된 이미지 중 첫 번째를 썸네일로 쓴다. */ - public static NoticeListItemResponse from(Notice notice, Map filesById, String publicBaseUrl) { + public static NoticeListItemResponse from(Notice notice, Map filesById) { Long thumbnailId = notice.getImageIds() == null || notice.getImageIds().isEmpty() ? null : notice.getImageIds().get(0); @@ -28,7 +28,7 @@ public static NoticeListItemResponse from(Notice notice, Map filesBy notice.getTitle(), notice.getCategory(), notice.getCreatedAt(), - thumbnailFile == null ? null : FileUrlUtil.buildPublicUrl(publicBaseUrl, thumbnailFile.getFileKey()), + thumbnailFile == null ? null : WebConstants.buildStorageUrl(thumbnailFile.getFileKey()), notice.isPinned() ); } diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/AppRentalController.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/AppRentalController.java index a9575a44..019398f4 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/AppRentalController.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/AppRentalController.java @@ -11,7 +11,6 @@ import kr.ac.kookmin.stream.api.common.dto.ApiResponse; import kr.ac.kookmin.stream.api.common.dto.CursorSliceResponse; import kr.ac.kookmin.stream.common.CursorSliceResult; -import kr.ac.kookmin.stream.file.service.FileService; import kr.ac.kookmin.stream.welfare.domain.rental.domain.Item; import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalRecord; import kr.ac.kookmin.stream.welfare.domain.rental.service.ItemService; @@ -29,7 +28,6 @@ public class AppRentalController implements AppRentalApi { private final ItemService itemService; private final RentalHistoryService rentalHistoryService; - private final FileService fileService; @Override @GetMapping("/items") @@ -39,8 +37,7 @@ public ApiResponse> getItems( CursorSliceResult result = itemService.getItems( params.toCategory(), params.toKeyword(), params.toCursor(), params.sizeOrDefault() ); - String publicBaseUrl = fileService.publicBaseUrl(); - return ApiResponse.success(CursorSliceResponse.from(result, item -> ItemListItemResponse.from(item, publicBaseUrl))); + return ApiResponse.success(CursorSliceResponse.from(result, ItemListItemResponse::from)); } @Override @@ -50,13 +47,13 @@ public ApiResponse getHistories( @Valid @ModelAttribute RentalHistoryListParams params ) { List records = rentalHistoryService.getHistories(apiUser.userId(), params.toStatus()); - return ApiResponse.success(RentalHistoryListResponse.from(records, fileService.publicBaseUrl())); + return ApiResponse.success(RentalHistoryListResponse.from(records)); } @Override @GetMapping("/histories/return-required") public ApiResponse getReturnRequired(AppApiUser apiUser) { List records = rentalHistoryService.getReturnRequiredRentals(apiUser.userId()); - return ApiResponse.success(ReturnRequiredListResponse.from(records, fileService.publicBaseUrl())); + return ApiResponse.success(ReturnRequiredListResponse.from(records)); } } diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/ItemListItemResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/ItemListItemResponse.java index 04fb6eef..fa34deb4 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/ItemListItemResponse.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/ItemListItemResponse.java @@ -1,7 +1,7 @@ package kr.ac.kookmin.stream.api.app.welfare.rental.response; import java.time.format.DateTimeFormatter; -import kr.ac.kookmin.stream.common.FileUrlUtil; +import kr.ac.kookmin.stream.api.common.WebConstants; import kr.ac.kookmin.stream.welfare.domain.rental.domain.Item; import kr.ac.kookmin.stream.welfare.domain.rental.domain.ItemCategory; import kr.ac.kookmin.stream.welfare.domain.rental.domain.ItemType; @@ -17,9 +17,9 @@ public record ItemListItemResponse( ReturnPolicyResponse returnPolicy ) { - public static ItemListItemResponse from(Item item, String publicBaseUrl) { + public static ItemListItemResponse from(Item item) { ReturnPolicy returnPolicy = item.getReturnPolicy(); - String imageUrl = item.getImageKey() == null ? null : FileUrlUtil.buildPublicUrl(publicBaseUrl, item.getImageKey()); + String imageUrl = item.getImageKey() == null ? null : WebConstants.buildStorageUrl(item.getImageKey()); return new ItemListItemResponse( item.getId(), item.getName(), diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/RentalHistoryListResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/RentalHistoryListResponse.java index 39699bfb..8aa67a43 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/RentalHistoryListResponse.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/RentalHistoryListResponse.java @@ -2,15 +2,15 @@ import java.time.LocalDateTime; import java.util.List; -import kr.ac.kookmin.stream.common.FileUrlUtil; +import kr.ac.kookmin.stream.api.common.WebConstants; import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalHistory; import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalRecord; import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalStatus; public record RentalHistoryListResponse(List histories) { - public static RentalHistoryListResponse from(List records, String publicBaseUrl) { - return new RentalHistoryListResponse(records.stream().map(record -> History.from(record, publicBaseUrl)).toList()); + public static RentalHistoryListResponse from(List records) { + return new RentalHistoryListResponse(records.stream().map(History::from).toList()); } public record History( @@ -22,10 +22,10 @@ public record History( RentalStatus status ) { - public static History from(RentalRecord record, String publicBaseUrl) { + public static History from(RentalRecord record) { RentalHistory history = record.history(); String imageKey = record.itemImageKey(); - String imageUrl = imageKey == null ? null : FileUrlUtil.buildPublicUrl(publicBaseUrl, imageKey); + String imageUrl = imageKey == null ? null : WebConstants.buildStorageUrl(imageKey); return new History( history.getId(), record.itemName(), diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/ReturnRequiredListResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/ReturnRequiredListResponse.java index cc89c208..a28bbbc0 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/ReturnRequiredListResponse.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/ReturnRequiredListResponse.java @@ -2,13 +2,13 @@ import java.time.LocalDateTime; import java.util.List; -import kr.ac.kookmin.stream.common.FileUrlUtil; +import kr.ac.kookmin.stream.api.common.WebConstants; import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalRecord; public record ReturnRequiredListResponse(List rentalHistories) { - public static ReturnRequiredListResponse from(List records, String publicBaseUrl) { - return new ReturnRequiredListResponse(records.stream().map(record -> Rental.from(record, publicBaseUrl)).toList()); + public static ReturnRequiredListResponse from(List records) { + return new ReturnRequiredListResponse(records.stream().map(Rental::from).toList()); } public record Rental( @@ -18,9 +18,9 @@ public record Rental( LocalDateTime dueAt ) { - public static Rental from(RentalRecord record, String publicBaseUrl) { + public static Rental from(RentalRecord record) { String imageKey = record.itemImageKey(); - String imageUrl = imageKey == null ? null : FileUrlUtil.buildPublicUrl(publicBaseUrl, imageKey); + String imageUrl = imageKey == null ? null : WebConstants.buildStorageUrl(imageKey); return new Rental( record.history().getId(), record.itemName(), diff --git a/api/common-api/src/main/java/kr/ac/kookmin/stream/api/common/WebConstants.java b/api/common-api/src/main/java/kr/ac/kookmin/stream/api/common/WebConstants.java new file mode 100644 index 00000000..496d5cea --- /dev/null +++ b/api/common-api/src/main/java/kr/ac/kookmin/stream/api/common/WebConstants.java @@ -0,0 +1,14 @@ +package kr.ac.kookmin.stream.api.common; + +// 웹 응답 전반에서 쓰는 공통 URL 상수 모음. +public final class WebConstants { + + // 파일 공개 URL 조립 기준 주소. 값이 바뀔 일이 거의 없어 설정값이 아닌 상수로 둔다. + public static final String STORAGE_BASE_URL = "https://static.billilge.site/kmusw-stream"; + + public static String buildStorageUrl(String fileKey) { + return STORAGE_BASE_URL + "/" + fileKey; + } + + private WebConstants() {} +} diff --git a/core/common/src/main/java/kr/ac/kookmin/stream/common/FileUrlUtil.java b/core/common/src/main/java/kr/ac/kookmin/stream/common/FileUrlUtil.java deleted file mode 100644 index 80dfad11..00000000 --- a/core/common/src/main/java/kr/ac/kookmin/stream/common/FileUrlUtil.java +++ /dev/null @@ -1,14 +0,0 @@ -package kr.ac.kookmin.stream.common; - -public final class FileUrlUtil { - - private FileUrlUtil() {} - - /** publicBaseUrl 끝에 슬래시가 있어도 이어붙인 URL에 슬래시가 중복되지 않게 한다. */ - public static String buildPublicUrl(String publicBaseUrl, String fileKey) { - String trimmedBaseUrl = publicBaseUrl.endsWith("/") - ? publicBaseUrl.substring(0, publicBaseUrl.length() - 1) - : publicBaseUrl; - return trimmedBaseUrl + "/" + fileKey; - } -} diff --git a/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/client/FileStorageClient.java b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/client/FileStorageClient.java index 8af8ef06..d5e4a4c0 100644 --- a/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/client/FileStorageClient.java +++ b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/client/FileStorageClient.java @@ -5,5 +5,4 @@ public interface FileStorageClient { UploadUrl issuePresignedUrl(String fileKey, String contentType); void deleteObject(String fileKey); - String publicBaseUrl(); } diff --git a/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/FileService.java b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/FileService.java index 4c9d86e9..444c3c8b 100644 --- a/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/FileService.java +++ b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/FileService.java @@ -12,5 +12,4 @@ public interface FileService { void delete(Long fileId); Optional findById(Long fileId); Map findAllByIdIn(List fileIds); - String publicBaseUrl(); } diff --git a/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/impl/FileServiceImpl.java b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/impl/FileServiceImpl.java index e4658846..003f0169 100644 --- a/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/impl/FileServiceImpl.java +++ b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/impl/FileServiceImpl.java @@ -72,11 +72,6 @@ public Map findAllByIdIn(List fileIds) { .collect(Collectors.toMap(File::getId, Function.identity())); } - @Override - public String publicBaseUrl() { - return fileStorageClient.publicBaseUrl(); - } - private String generateFileKey(String originalName) { String extension = FileUploadPolicy.extractExtension(originalName); String key = UUID.randomUUID().toString(); diff --git a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/s3/S3FileStorageClient.java b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/s3/S3FileStorageClient.java index 3b322740..77ee0389 100644 --- a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/s3/S3FileStorageClient.java +++ b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/s3/S3FileStorageClient.java @@ -51,9 +51,4 @@ public void deleteObject(String fileKey) { .key(fileKey) .build()); } - - @Override - public String publicBaseUrl() { - return properties.publicBaseUrl(); - } } diff --git a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/s3/S3FileStorageProperties.java b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/s3/S3FileStorageProperties.java index 1d431c9c..fb64e8d4 100644 --- a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/s3/S3FileStorageProperties.java +++ b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/s3/S3FileStorageProperties.java @@ -9,6 +9,5 @@ public record S3FileStorageProperties( String endpoint, String accessKey, String secretKey, - String publicBaseUrl, long uploadUrlExpirySeconds ) {} diff --git a/infrastructure/client/src/main/resources/application-infrastructure-client.yml b/infrastructure/client/src/main/resources/application-infrastructure-client.yml index abe88b8d..7f0a6b85 100644 --- a/infrastructure/client/src/main/resources/application-infrastructure-client.yml +++ b/infrastructure/client/src/main/resources/application-infrastructure-client.yml @@ -6,5 +6,4 @@ file: endpoint: ${R2_ENDPOINT:} access-key: ${R2_ACCESS_KEY:} secret-key: ${R2_SECRET_KEY:} - public-base-url: ${R2_PUBLIC_BASE_URL:} upload-url-expiry-seconds: ${S3_UPLOAD_URL_EXPIRY_SECONDS:600} From a868d832dcdeef5c2b85fee928036541ec05b489 Mon Sep 17 00:00:00 2001 From: jjunh33 Date: Thu, 1 Oct 2026 00:48:48 +0900 Subject: [PATCH 09/11] =?UTF-8?q?refactor:=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20URL=20=EB=B0=9C=EA=B8=89=C2=B7?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20API=EB=A5=BC=20admin-api=EC=97=90=EC=84=9C?= =?UTF-8?q?=20common-api=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit STUDENT도 파일을 올릴 수 있어야 해서 ADMIN 전용이면 안 됐다. api:common-api는 지금까지 컨트롤러 없이 공통 인프라(ApiResponse, WebMvcConfig 등)만 뒀는데, 이 API는 role 무관이라 예외적으로 여기 둔다. 경로도 /v1/admin/files에서 /v1/files로 바꿔서 SecurityConfig의 역할별 규칙(/v1/admin/**, /v1/app/**) 어디에도 안 걸리고 anyRequest().authenticated()로 로그인한 사용자면 누구나 쓸 수 있게 했다. AdminFileController/AdminFileApi도 더 이상 운영진 전용이 아니라 FileController/FileApi로 이름을 바꿨다. --- api/admin-api/build.gradle.kts | 1 - api/common-api/build.gradle.kts | 1 + .../ac/kookmin/stream/api/common/file/FileApi.java} | 13 +++++++------ .../stream/api/common/file/FileController.java} | 10 +++++----- .../file/request/FileUploadUrlIssueRequest.java | 2 +- .../file/response/FileUploadUrlIssueResponse.java | 2 +- 6 files changed, 15 insertions(+), 14 deletions(-) rename api/{admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/file/AdminFileApi.java => common-api/src/main/java/kr/ac/kookmin/stream/api/common/file/FileApi.java} (72%) rename api/{admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/file/AdminFileController.java => common-api/src/main/java/kr/ac/kookmin/stream/api/common/file/FileController.java} (82%) rename api/{admin-api/src/main/java/kr/ac/kookmin/stream/api/admin => common-api/src/main/java/kr/ac/kookmin/stream/api/common}/file/request/FileUploadUrlIssueRequest.java (94%) rename api/{admin-api/src/main/java/kr/ac/kookmin/stream/api/admin => common-api/src/main/java/kr/ac/kookmin/stream/api/common}/file/response/FileUploadUrlIssueResponse.java (89%) diff --git a/api/admin-api/build.gradle.kts b/api/admin-api/build.gradle.kts index 3d032c0f..bd5f43b3 100644 --- a/api/admin-api/build.gradle.kts +++ b/api/admin-api/build.gradle.kts @@ -8,7 +8,6 @@ dependencies { implementation(project(":api:common-api")) implementation(project(":core:common")) implementation(project(":core:domain:internal")) - implementation(project(":core:domain:file")) implementation(project(":core:domain:member")) implementation(project(":core:domain:welfare")) implementation(project(":gateway:auth")) diff --git a/api/common-api/build.gradle.kts b/api/common-api/build.gradle.kts index 82a9f6e5..16da698a 100644 --- a/api/common-api/build.gradle.kts +++ b/api/common-api/build.gradle.kts @@ -6,6 +6,7 @@ description = "role 무관 공통 인프라 — ApiResponse, GlobalExceptionHand dependencies { implementation(project(":core:common")) + implementation(project(":core:domain:file")) implementation(project(":gateway:auth")) implementation(project(":gateway:logging")) diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/file/AdminFileApi.java b/api/common-api/src/main/java/kr/ac/kookmin/stream/api/common/file/FileApi.java similarity index 72% rename from api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/file/AdminFileApi.java rename to api/common-api/src/main/java/kr/ac/kookmin/stream/api/common/file/FileApi.java index 5a0d7c65..fdc1d155 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/file/AdminFileApi.java +++ b/api/common-api/src/main/java/kr/ac/kookmin/stream/api/common/file/FileApi.java @@ -1,22 +1,23 @@ -package kr.ac.kookmin.stream.api.admin.file; +package kr.ac.kookmin.stream.api.common.file; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; -import kr.ac.kookmin.stream.api.admin.file.request.FileUploadUrlIssueRequest; -import kr.ac.kookmin.stream.api.admin.file.response.FileUploadUrlIssueResponse; +import kr.ac.kookmin.stream.api.common.file.request.FileUploadUrlIssueRequest; +import kr.ac.kookmin.stream.api.common.file.response.FileUploadUrlIssueResponse; import kr.ac.kookmin.stream.api.common.openapi.ApiErrorCode; import kr.ac.kookmin.stream.api.common.dto.ApiResponse; import kr.ac.kookmin.stream.common.CommonErrorCode; import kr.ac.kookmin.stream.file.domain.FileErrorCode; /** - * 운영진 파일 API의 문서 명세. 구현은 {@link AdminFileController}가 맡는다. + * 파일 API의 문서 명세. 구현은 {@link FileController}가 맡는다. *

+ * role 무관 공통 엔드포인트라 admin-api/app-api가 아니라 common-api에 둔다(ADMIN·STUDENT 둘 다 사용). * 스웨거 문서용 어노테이션만 이쪽에 두고 컨트롤러에는 라우팅과 본문만 남긴다. 경로 매핑과 * 파라미터 바인딩(@{@code RequestBody}, @{@code PathVariable} 등)은 구현체에 둔다. */ -@Tag(name = "파일", description = "운영진 파일 업로드 URL 발급·삭제") -public interface AdminFileApi { +@Tag(name = "파일", description = "파일 업로드 URL 발급·삭제") +public interface FileApi { /** 업로드용 presigned URL 발급. 파일 형식·크기 정책을 통과하면 업로드 URL을 내려준다. */ @Operation(summary = "파일 업로드 URL 발급", diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/file/AdminFileController.java b/api/common-api/src/main/java/kr/ac/kookmin/stream/api/common/file/FileController.java similarity index 82% rename from api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/file/AdminFileController.java rename to api/common-api/src/main/java/kr/ac/kookmin/stream/api/common/file/FileController.java index 7805b702..f624d214 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/file/AdminFileController.java +++ b/api/common-api/src/main/java/kr/ac/kookmin/stream/api/common/file/FileController.java @@ -1,8 +1,8 @@ -package kr.ac.kookmin.stream.api.admin.file; +package kr.ac.kookmin.stream.api.common.file; import jakarta.validation.Valid; -import kr.ac.kookmin.stream.api.admin.file.request.FileUploadUrlIssueRequest; -import kr.ac.kookmin.stream.api.admin.file.response.FileUploadUrlIssueResponse; +import kr.ac.kookmin.stream.api.common.file.request.FileUploadUrlIssueRequest; +import kr.ac.kookmin.stream.api.common.file.response.FileUploadUrlIssueResponse; import kr.ac.kookmin.stream.api.common.dto.ApiResponse; import kr.ac.kookmin.stream.common.PrincipalProvider; import kr.ac.kookmin.stream.file.domain.FileUploadUrlIssueResult; @@ -16,9 +16,9 @@ import org.springframework.web.bind.annotation.RestController; @RestController -@RequestMapping("/v1/admin/files") +@RequestMapping("/v1/files") @RequiredArgsConstructor -public class AdminFileController implements AdminFileApi { +public class FileController implements FileApi { private final FileService fileService; private final PrincipalProvider principalProvider; diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/file/request/FileUploadUrlIssueRequest.java b/api/common-api/src/main/java/kr/ac/kookmin/stream/api/common/file/request/FileUploadUrlIssueRequest.java similarity index 94% rename from api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/file/request/FileUploadUrlIssueRequest.java rename to api/common-api/src/main/java/kr/ac/kookmin/stream/api/common/file/request/FileUploadUrlIssueRequest.java index b049a4ed..08c8858c 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/file/request/FileUploadUrlIssueRequest.java +++ b/api/common-api/src/main/java/kr/ac/kookmin/stream/api/common/file/request/FileUploadUrlIssueRequest.java @@ -1,4 +1,4 @@ -package kr.ac.kookmin.stream.api.admin.file.request; +package kr.ac.kookmin.stream.api.common.file.request; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/file/response/FileUploadUrlIssueResponse.java b/api/common-api/src/main/java/kr/ac/kookmin/stream/api/common/file/response/FileUploadUrlIssueResponse.java similarity index 89% rename from api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/file/response/FileUploadUrlIssueResponse.java rename to api/common-api/src/main/java/kr/ac/kookmin/stream/api/common/file/response/FileUploadUrlIssueResponse.java index 87469ca8..f00055ad 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/file/response/FileUploadUrlIssueResponse.java +++ b/api/common-api/src/main/java/kr/ac/kookmin/stream/api/common/file/response/FileUploadUrlIssueResponse.java @@ -1,4 +1,4 @@ -package kr.ac.kookmin.stream.api.admin.file.response; +package kr.ac.kookmin.stream.api.common.file.response; import java.time.LocalDateTime; import kr.ac.kookmin.stream.file.domain.FileUploadUrlIssueResult; From 0ead7d875798ced2777826c981c4039af473d739 Mon Sep 17 00:00:00 2001 From: jjunh33 Date: Thu, 1 Oct 2026 16:21:27 +0900 Subject: [PATCH 10/11] =?UTF-8?q?refactor:=20WebConstants=EB=A5=BC=20ApiCo?= =?UTF-8?q?nstants=EB=A1=9C=20=ED=95=A9=EC=B9=98=EA=B3=A0=20URL=20?= =?UTF-8?q?=EC=A1=B0=EB=A6=BD=EC=9D=80=20StorageUrlBuilder=EB=A1=9C=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이미 공통 상수 클래스 ApiConstants가 있어서 STORAGE_BASE_URL만 담던 WebConstants를 따로 둘 이유가 없었다. 상수는 ApiConstants로 합치고, fileKey를 받아 URL을 조립하는 동작은 StorageUrlBuilder.build(fileKey)로 분리해 역할을 나눴다. --- .../notice/response/NoticeDetailResponse.java | 6 +++--- .../notice/response/NoticeListItemResponse.java | 4 ++-- .../rental/response/ItemListItemResponse.java | 4 ++-- .../rental/response/RentalHistoryListResponse.java | 4 ++-- .../response/ReturnRequiredListResponse.java | 4 ++-- .../ac/kookmin/stream/api/common/ApiConstants.java | 3 +++ .../stream/api/common/StorageUrlBuilder.java | 10 ++++++++++ .../ac/kookmin/stream/api/common/WebConstants.java | 14 -------------- 8 files changed, 24 insertions(+), 25 deletions(-) create mode 100644 api/common-api/src/main/java/kr/ac/kookmin/stream/api/common/StorageUrlBuilder.java delete mode 100644 api/common-api/src/main/java/kr/ac/kookmin/stream/api/common/WebConstants.java diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/notice/response/NoticeDetailResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/notice/response/NoticeDetailResponse.java index f905edd8..07b5fc3a 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/notice/response/NoticeDetailResponse.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/notice/response/NoticeDetailResponse.java @@ -3,7 +3,7 @@ import java.time.LocalDateTime; import java.util.List; import java.util.Map; -import kr.ac.kookmin.stream.api.common.WebConstants; +import kr.ac.kookmin.stream.api.common.StorageUrlBuilder; import kr.ac.kookmin.stream.file.domain.File; import kr.ac.kookmin.stream.welfare.domain.notice.domain.Notice; import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCategory; @@ -53,14 +53,14 @@ public static NoticeDetailResponse from( public record Image(Long fileId, String fileUrl) { public static Image of(Long fileId, File file) { - return new Image(fileId, WebConstants.buildStorageUrl(file.getFileKey())); + return new Image(fileId, StorageUrlBuilder.build(file.getFileKey())); } } public record Attachment(Long fileId, String fileName, String fileUrl) { public static Attachment of(Long fileId, File file) { - return new Attachment(fileId, file.getOriginalName(), WebConstants.buildStorageUrl(file.getFileKey())); + return new Attachment(fileId, file.getOriginalName(), StorageUrlBuilder.build(file.getFileKey())); } } } diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/notice/response/NoticeListItemResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/notice/response/NoticeListItemResponse.java index ba885c3d..dc4edac2 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/notice/response/NoticeListItemResponse.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/notice/response/NoticeListItemResponse.java @@ -2,7 +2,7 @@ import java.time.LocalDateTime; import java.util.Map; -import kr.ac.kookmin.stream.api.common.WebConstants; +import kr.ac.kookmin.stream.api.common.StorageUrlBuilder; import kr.ac.kookmin.stream.file.domain.File; import kr.ac.kookmin.stream.welfare.domain.notice.domain.Notice; import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCategory; @@ -28,7 +28,7 @@ public static NoticeListItemResponse from(Notice notice, Map filesBy notice.getTitle(), notice.getCategory(), notice.getCreatedAt(), - thumbnailFile == null ? null : WebConstants.buildStorageUrl(thumbnailFile.getFileKey()), + thumbnailFile == null ? null : StorageUrlBuilder.build(thumbnailFile.getFileKey()), notice.isPinned() ); } diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/ItemListItemResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/ItemListItemResponse.java index fa34deb4..cc0a8b28 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/ItemListItemResponse.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/ItemListItemResponse.java @@ -1,7 +1,7 @@ package kr.ac.kookmin.stream.api.app.welfare.rental.response; import java.time.format.DateTimeFormatter; -import kr.ac.kookmin.stream.api.common.WebConstants; +import kr.ac.kookmin.stream.api.common.StorageUrlBuilder; import kr.ac.kookmin.stream.welfare.domain.rental.domain.Item; import kr.ac.kookmin.stream.welfare.domain.rental.domain.ItemCategory; import kr.ac.kookmin.stream.welfare.domain.rental.domain.ItemType; @@ -19,7 +19,7 @@ public record ItemListItemResponse( public static ItemListItemResponse from(Item item) { ReturnPolicy returnPolicy = item.getReturnPolicy(); - String imageUrl = item.getImageKey() == null ? null : WebConstants.buildStorageUrl(item.getImageKey()); + String imageUrl = item.getImageKey() == null ? null : StorageUrlBuilder.build(item.getImageKey()); return new ItemListItemResponse( item.getId(), item.getName(), diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/RentalHistoryListResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/RentalHistoryListResponse.java index 8aa67a43..85a7aeff 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/RentalHistoryListResponse.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/RentalHistoryListResponse.java @@ -2,7 +2,7 @@ import java.time.LocalDateTime; import java.util.List; -import kr.ac.kookmin.stream.api.common.WebConstants; +import kr.ac.kookmin.stream.api.common.StorageUrlBuilder; import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalHistory; import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalRecord; import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalStatus; @@ -25,7 +25,7 @@ public record History( public static History from(RentalRecord record) { RentalHistory history = record.history(); String imageKey = record.itemImageKey(); - String imageUrl = imageKey == null ? null : WebConstants.buildStorageUrl(imageKey); + String imageUrl = imageKey == null ? null : StorageUrlBuilder.build(imageKey); return new History( history.getId(), record.itemName(), diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/ReturnRequiredListResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/ReturnRequiredListResponse.java index a28bbbc0..cf5e64e7 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/ReturnRequiredListResponse.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/ReturnRequiredListResponse.java @@ -2,7 +2,7 @@ import java.time.LocalDateTime; import java.util.List; -import kr.ac.kookmin.stream.api.common.WebConstants; +import kr.ac.kookmin.stream.api.common.StorageUrlBuilder; import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalRecord; public record ReturnRequiredListResponse(List rentalHistories) { @@ -20,7 +20,7 @@ public record Rental( public static Rental from(RentalRecord record) { String imageKey = record.itemImageKey(); - String imageUrl = imageKey == null ? null : WebConstants.buildStorageUrl(imageKey); + String imageUrl = imageKey == null ? null : StorageUrlBuilder.build(imageKey); return new Rental( record.history().getId(), record.itemName(), diff --git a/api/common-api/src/main/java/kr/ac/kookmin/stream/api/common/ApiConstants.java b/api/common-api/src/main/java/kr/ac/kookmin/stream/api/common/ApiConstants.java index 6b6975c9..5deb1423 100644 --- a/api/common-api/src/main/java/kr/ac/kookmin/stream/api/common/ApiConstants.java +++ b/api/common-api/src/main/java/kr/ac/kookmin/stream/api/common/ApiConstants.java @@ -6,5 +6,8 @@ public final class ApiConstants { // member가 null이면 탈퇴(소프트 삭제)한 회원의 과거 요청이다 — 이름 대신 쓰는 표시용 placeholder. public static final String WITHDRAWN_MEMBER_LABEL = "(탈퇴한 회원)"; + // 파일 공개 URL 조립 기준 주소. 값이 바뀔 일이 거의 없어 설정값이 아닌 상수로 둔다. + public static final String STORAGE_BASE_URL = "https://static.billilge.site/kmusw-stream"; + private ApiConstants() {} } diff --git a/api/common-api/src/main/java/kr/ac/kookmin/stream/api/common/StorageUrlBuilder.java b/api/common-api/src/main/java/kr/ac/kookmin/stream/api/common/StorageUrlBuilder.java new file mode 100644 index 00000000..3b320b0f --- /dev/null +++ b/api/common-api/src/main/java/kr/ac/kookmin/stream/api/common/StorageUrlBuilder.java @@ -0,0 +1,10 @@ +package kr.ac.kookmin.stream.api.common; + +public final class StorageUrlBuilder { + + public static String build(String fileKey) { + return ApiConstants.STORAGE_BASE_URL + "/" + fileKey; + } + + private StorageUrlBuilder() {} +} diff --git a/api/common-api/src/main/java/kr/ac/kookmin/stream/api/common/WebConstants.java b/api/common-api/src/main/java/kr/ac/kookmin/stream/api/common/WebConstants.java deleted file mode 100644 index 496d5cea..00000000 --- a/api/common-api/src/main/java/kr/ac/kookmin/stream/api/common/WebConstants.java +++ /dev/null @@ -1,14 +0,0 @@ -package kr.ac.kookmin.stream.api.common; - -// 웹 응답 전반에서 쓰는 공통 URL 상수 모음. -public final class WebConstants { - - // 파일 공개 URL 조립 기준 주소. 값이 바뀔 일이 거의 없어 설정값이 아닌 상수로 둔다. - public static final String STORAGE_BASE_URL = "https://static.billilge.site/kmusw-stream"; - - public static String buildStorageUrl(String fileKey) { - return STORAGE_BASE_URL + "/" + fileKey; - } - - private WebConstants() {} -} From fffa7d5da2e2aaac4b527b4da87c7ce57bf2ad8f Mon Sep 17 00:00:00 2001 From: jjunh33 Date: Thu, 1 Oct 2026 16:59:44 +0900 Subject: [PATCH 11/11] =?UTF-8?q?refactor:=20=ED=96=89=EC=82=AC=C2=B7?= =?UTF-8?q?=EC=95=84=EC=B9=B4=EC=9D=B4=EB=B9=99=20=EC=8D=B8=EB=84=A4?= =?UTF-8?q?=EC=9D=BC/=EC=9D=B4=EB=AF=B8=EC=A7=80=20URL=20=EB=B0=B0?= =?UTF-8?q?=EC=84=A0=20=EB=B0=8F=20StorageUrlBuilder=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit event/archive 응답 DTO에 공지·물품과 같은 패턴으로 남아있던 placeholder (fileId → 공개 URL 조립 전까지 항상 null)를 전부 실제 조회로 채웠다. AppArchiveController/AppEventController가 FileService.findAllByIdIn으로 썸네일·이미지의 File을 배치 조회해 응답 DTO에 넘긴다. StorageUrlBuilder.build(String)는 fileKey가 null이면 null을 돌려주도록 바꿔서 호출부의 반복되는 null 체크를 없앴다. id로 Map에서 File을 찾아 URL까지 조립하는 build(Long, Map) 오버로드도 추가해, "id가 없거나 Map에 없으면 null, 있으면 key 꺼내서 조립" 체인이 여러 응답 DTO에서 반복되던 걸 한 곳으로 모았다. --- .../event/archive/AppArchiveController.java | 24 +++++++++++++---- .../response/ArchiveDetailResponse.java | 20 +++++++++----- .../response/ArchiveListItemResponse.java | 16 ++++-------- .../archive/response/ArchiveListResponse.java | 6 +++-- .../app/event/event/AppEventController.java | 26 ++++++++++++++++--- .../EventApplicationListItemResponse.java | 16 ++++-------- .../event/response/EventDetailResponse.java | 20 +++++++++----- .../event/response/EventListItemResponse.java | 16 ++++-------- .../response/NoticeListItemResponse.java | 3 +-- .../rental/response/ItemListItemResponse.java | 2 +- .../response/RentalHistoryListResponse.java | 3 +-- .../response/ReturnRequiredListResponse.java | 3 +-- .../stream/api/common/StorageUrlBuilder.java | 16 +++++++++++- 13 files changed, 106 insertions(+), 65 deletions(-) diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/archive/AppArchiveController.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/archive/AppArchiveController.java index 6c635e65..88cffaf9 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/archive/AppArchiveController.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/archive/AppArchiveController.java @@ -1,11 +1,17 @@ package kr.ac.kookmin.stream.api.app.event.archive; import jakarta.validation.Valid; +import java.util.List; +import java.util.Map; import kr.ac.kookmin.stream.api.common.dto.ApiResponse; import kr.ac.kookmin.stream.api.app.event.archive.request.ArchiveListParams; import kr.ac.kookmin.stream.api.app.event.archive.response.ArchiveDetailResponse; import kr.ac.kookmin.stream.api.app.event.archive.response.ArchiveListResponse; +import kr.ac.kookmin.stream.event.domain.archive.domain.ArchiveDetail; +import kr.ac.kookmin.stream.event.domain.archive.domain.ArchiveSummary; import kr.ac.kookmin.stream.event.domain.archive.service.ArchiveService; +import kr.ac.kookmin.stream.file.domain.File; +import kr.ac.kookmin.stream.file.service.FileService; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; @@ -19,6 +25,7 @@ public class AppArchiveController implements AppArchiveApi { private final ArchiveService archiveService; + private final FileService fileService; /** * 목록과 연도는 서로 독립적인 조회다. 연도 버튼은 필터와 무관하게 전체가 보여야 하므로 각각 조회해 응답에서 합친다. @@ -26,15 +33,22 @@ public class AppArchiveController implements AppArchiveApi { @Override @GetMapping public ApiResponse getArchives(@Valid @ModelAttribute ArchiveListParams params) { - return ApiResponse.success(ArchiveListResponse.of( - archiveService.getArchives(params.year()), - archiveService.getYears() - )); + List archives = archiveService.getArchives(params.year()); + List thumbnailIds = archives.stream() + .map(ArchiveSummary::thumbnailFileId) + .filter(id -> id != null) + .toList(); + Map filesById = fileService.findAllByIdIn(thumbnailIds); + + return ApiResponse.success(ArchiveListResponse.of(archives, archiveService.getYears(), filesById)); } @Override @GetMapping("/{archiveId}") public ApiResponse getArchive(@PathVariable Long archiveId) { - return ApiResponse.success(ArchiveDetailResponse.from(archiveService.getArchive(archiveId))); + ArchiveDetail detail = archiveService.getArchive(archiveId); + Map imagesById = fileService.findAllByIdIn(detail.imageIds()); + + return ApiResponse.success(ArchiveDetailResponse.from(detail, imagesById)); } } diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/archive/response/ArchiveDetailResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/archive/response/ArchiveDetailResponse.java index 2d6217a7..1631c120 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/archive/response/ArchiveDetailResponse.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/archive/response/ArchiveDetailResponse.java @@ -2,8 +2,11 @@ import java.time.LocalDate; import java.util.List; +import java.util.Map; +import kr.ac.kookmin.stream.api.common.StorageUrlBuilder; import kr.ac.kookmin.stream.event.domain.archive.domain.ArchiveDetail; import kr.ac.kookmin.stream.event.domain.archive.domain.ArchiveRelatedLink; +import kr.ac.kookmin.stream.file.domain.File; public record ArchiveDetailResponse( Long archiveId, @@ -17,7 +20,10 @@ public record ArchiveDetailResponse( List relatedLinks ) { - public static ArchiveDetailResponse from(ArchiveDetail detail) { + /** + * 존재하지 않는(삭제된) fileId는 imagesById에 키가 없다 — 그런 항목은 목록에서 조용히 제외한다. + */ + public static ArchiveDetailResponse from(ArchiveDetail detail, Map imagesById) { return new ArchiveDetailResponse( detail.archiveId(), detail.title(), @@ -26,18 +32,18 @@ public static ArchiveDetailResponse from(ArchiveDetail detail) { detail.location(), detail.departmentName(), detail.content(), - detail.imageIds().stream().map(Image::from).toList(), + detail.imageIds().stream() + .filter(imagesById::containsKey) + .map(fileId -> Image.of(fileId, imagesById.get(fileId))) + .toList(), detail.relatedLinks().stream().map(RelatedLink::from).toList() ); } public record Image(Long fileId, String fileUrl) { - /** - * 파일 키 → 공개 URL 조립이 아직 없어 URL은 비어 있다. 조립이 생기면 이 팩토리만 채우면 된다. - */ - public static Image from(Long fileId) { - return new Image(fileId, null); + public static Image of(Long fileId, File file) { + return new Image(fileId, StorageUrlBuilder.build(file.getFileKey())); } } diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/archive/response/ArchiveListItemResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/archive/response/ArchiveListItemResponse.java index 3c37783e..608f09f0 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/archive/response/ArchiveListItemResponse.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/archive/response/ArchiveListItemResponse.java @@ -1,7 +1,10 @@ package kr.ac.kookmin.stream.api.app.event.archive.response; import java.time.LocalDate; +import java.util.Map; +import kr.ac.kookmin.stream.api.common.StorageUrlBuilder; import kr.ac.kookmin.stream.event.domain.archive.domain.ArchiveSummary; +import kr.ac.kookmin.stream.file.domain.File; public record ArchiveListItemResponse( Long archiveId, @@ -11,22 +14,13 @@ public record ArchiveListItemResponse( String thumbnailUrl ) { - public static ArchiveListItemResponse from(ArchiveSummary summary) { + public static ArchiveListItemResponse from(ArchiveSummary summary, Map filesById) { return new ArchiveListItemResponse( summary.archiveId(), summary.title(), summary.startDate(), summary.endDate(), - thumbnailUrlOf(summary.thumbnailFileId()) + StorageUrlBuilder.build(summary.thumbnailFileId(), filesById) ); } - - /** - * 대표 이미지 파일 id를 공개 URL로 바꾼다. - *

- * 파일 키 → 공개 URL 조립이 아직 없어 현재는 항상 비어 있다. 조립이 생기면 이 메서드만 채우면 된다. - */ - private static String thumbnailUrlOf(Long thumbnailFileId) { - return null; - } } diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/archive/response/ArchiveListResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/archive/response/ArchiveListResponse.java index 89e0a539..d839acf2 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/archive/response/ArchiveListResponse.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/archive/response/ArchiveListResponse.java @@ -1,13 +1,15 @@ package kr.ac.kookmin.stream.api.app.event.archive.response; import java.util.List; +import java.util.Map; import kr.ac.kookmin.stream.event.domain.archive.domain.ArchiveSummary; +import kr.ac.kookmin.stream.file.domain.File; public record ArchiveListResponse(List archives, List years) { - public static ArchiveListResponse of(List archives, List years) { + public static ArchiveListResponse of(List archives, List years, Map filesById) { return new ArchiveListResponse( - archives.stream().map(ArchiveListItemResponse::from).toList(), + archives.stream().map(summary -> ArchiveListItemResponse.from(summary, filesById)).toList(), years ); } diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/event/AppEventController.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/event/AppEventController.java index a958acf0..33230be7 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/event/AppEventController.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/event/AppEventController.java @@ -1,6 +1,9 @@ package kr.ac.kookmin.stream.api.app.event.event; import jakarta.validation.Valid; +import java.util.List; +import java.util.Map; +import java.util.function.Function; import kr.ac.kookmin.stream.api.common.dto.ApiResponse; import kr.ac.kookmin.stream.api.common.dto.CursorSliceResponse; import kr.ac.kookmin.stream.api.app.AppApiUser; @@ -15,9 +18,12 @@ import kr.ac.kookmin.stream.api.app.event.event.response.EventListItemResponse; import kr.ac.kookmin.stream.common.CursorSliceResult; import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationSummary; +import kr.ac.kookmin.stream.event.domain.event.domain.EventDetail; import kr.ac.kookmin.stream.event.domain.event.domain.EventSummary; import kr.ac.kookmin.stream.event.domain.event.service.EventApplicationService; import kr.ac.kookmin.stream.event.domain.event.service.EventService; +import kr.ac.kookmin.stream.file.domain.File; +import kr.ac.kookmin.stream.file.service.FileService; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; @@ -43,6 +49,7 @@ public class AppEventController implements AppEventApi { private final EventService eventService; private final EventApplicationService eventApplicationService; + private final FileService fileService; @Override @GetMapping @@ -51,7 +58,10 @@ public ApiResponse> getEvents( ) { CursorSliceResult result = eventService.getPublishedEvents( params.toRecruitStatus(), params.toCursor(), params.sizeOrDefault()); - return ApiResponse.success(CursorSliceResponse.from(result, EventListItemResponse::from)); + Map filesById = fileService.findAllByIdIn(thumbnailIdsOf(result.content(), EventSummary::thumbnailFileId)); + + return ApiResponse.success( + CursorSliceResponse.from(result, summary -> EventListItemResponse.from(summary, filesById))); } @Override @@ -62,8 +72,11 @@ public ApiResponse> getMyA ) { CursorSliceResult result = eventApplicationService.getMyApplications( apiUser.userId(), params.toCursor(), params.sizeOrDefault()); + Map filesById = fileService.findAllByIdIn( + thumbnailIdsOf(result.content(), EventApplicationSummary::thumbnailFileId)); + return ApiResponse.success( - CursorSliceResponse.from(result, EventApplicationListItemResponse::from)); + CursorSliceResponse.from(result, summary -> EventApplicationListItemResponse.from(summary, filesById))); } @Override @@ -89,7 +102,10 @@ public ApiResponse cancelApplication( @Override @GetMapping("/{eventId}") public ApiResponse getEvent(@PathVariable Long eventId) { - return ApiResponse.success(EventDetailResponse.from(eventService.getPublishedEvent(eventId))); + EventDetail detail = eventService.getPublishedEvent(eventId); + Map imagesById = fileService.findAllByIdIn(detail.imageIds()); + + return ApiResponse.success(EventDetailResponse.from(detail, imagesById)); } @Override @@ -109,4 +125,8 @@ public ApiResponse apply( EventApplyResponse.from(eventApplicationService.apply(eventId, apiUser.userId(), request.toCommand())) ); } + + private static List thumbnailIdsOf(List items, Function thumbnailFileId) { + return items.stream().map(thumbnailFileId).filter(id -> id != null).toList(); + } } diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/event/response/EventApplicationListItemResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/event/response/EventApplicationListItemResponse.java index a7e3b118..7e552cd4 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/event/response/EventApplicationListItemResponse.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/event/response/EventApplicationListItemResponse.java @@ -1,8 +1,11 @@ package kr.ac.kookmin.stream.api.app.event.event.response; import java.time.LocalDateTime; +import java.util.Map; +import kr.ac.kookmin.stream.api.common.StorageUrlBuilder; import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationStatus; import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationSummary; +import kr.ac.kookmin.stream.file.domain.File; public record EventApplicationListItemResponse( Long applicationId, @@ -14,24 +17,15 @@ public record EventApplicationListItemResponse( LocalDateTime canceledAt ) { - public static EventApplicationListItemResponse from(EventApplicationSummary summary) { + public static EventApplicationListItemResponse from(EventApplicationSummary summary, Map filesById) { return new EventApplicationListItemResponse( summary.applicationId(), summary.eventId(), summary.title(), - thumbnailUrlOf(summary.thumbnailFileId()), + StorageUrlBuilder.build(summary.thumbnailFileId(), filesById), summary.applicationStatus(), summary.appliedAt(), summary.canceledAt() ); } - - /** - * 대표 이미지 파일 id를 공개 URL로 바꾼다. - *

- * 파일 키 → 공개 URL 조립(#17)이 아직 없어 현재는 항상 비어 있다. #17이 머지되면 이 메서드만 채우면 된다. - */ - private static String thumbnailUrlOf(Long thumbnailFileId) { - return null; - } } diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/event/response/EventDetailResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/event/response/EventDetailResponse.java index 64c7c793..42f0694a 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/event/response/EventDetailResponse.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/event/response/EventDetailResponse.java @@ -2,8 +2,11 @@ import java.time.LocalDateTime; import java.util.List; +import java.util.Map; +import kr.ac.kookmin.stream.api.common.StorageUrlBuilder; import kr.ac.kookmin.stream.event.domain.event.domain.EventDetail; import kr.ac.kookmin.stream.event.domain.event.domain.RecruitStatus; +import kr.ac.kookmin.stream.file.domain.File; public record EventDetailResponse( Long eventId, @@ -20,7 +23,10 @@ public record EventDetailResponse( List images ) { - public static EventDetailResponse from(EventDetail detail) { + /** + * 존재하지 않는(삭제된) fileId는 imagesById에 키가 없다 — 그런 항목은 목록에서 조용히 제외한다. + */ + public static EventDetailResponse from(EventDetail detail, Map imagesById) { return new EventDetailResponse( detail.eventId(), detail.title(), @@ -33,17 +39,17 @@ public static EventDetailResponse from(EventDetail detail) { detail.applyEndAt(), detail.recruitStatus(), detail.daysUntilDeadline(), - detail.imageIds().stream().map(Image::from).toList() + detail.imageIds().stream() + .filter(imagesById::containsKey) + .map(fileId -> Image.of(fileId, imagesById.get(fileId))) + .toList() ); } public record Image(Long fileId, String fileUrl) { - /** - * 파일 키 → 공개 URL 조립(#17)이 아직 없어 URL은 비어 있다. #17이 머지되면 이 팩토리만 채우면 된다. - */ - public static Image from(Long fileId) { - return new Image(fileId, null); + public static Image of(Long fileId, File file) { + return new Image(fileId, StorageUrlBuilder.build(file.getFileKey())); } } } diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/event/response/EventListItemResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/event/response/EventListItemResponse.java index 6fed233f..91d0eb4e 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/event/response/EventListItemResponse.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/event/response/EventListItemResponse.java @@ -1,8 +1,11 @@ package kr.ac.kookmin.stream.api.app.event.event.response; import java.time.LocalDateTime; +import java.util.Map; +import kr.ac.kookmin.stream.api.common.StorageUrlBuilder; import kr.ac.kookmin.stream.event.domain.event.domain.EventSummary; import kr.ac.kookmin.stream.event.domain.event.domain.RecruitStatus; +import kr.ac.kookmin.stream.file.domain.File; public record EventListItemResponse( Long eventId, @@ -16,26 +19,17 @@ public record EventListItemResponse( Integer daysUntilDeadline ) { - public static EventListItemResponse from(EventSummary summary) { + public static EventListItemResponse from(EventSummary summary, Map filesById) { return new EventListItemResponse( summary.eventId(), summary.title(), summary.target(), summary.eventStartAt(), - thumbnailUrlOf(summary.thumbnailFileId()), + StorageUrlBuilder.build(summary.thumbnailFileId(), filesById), summary.applyStartAt(), summary.applyEndAt(), summary.recruitStatus(), summary.daysUntilDeadline() ); } - - /** - * 대표 이미지 파일 id를 공개 URL로 바꾼다. - *

- * 파일 키 → 공개 URL 조립(#17)이 아직 없어 현재는 항상 비어 있다. #17이 머지되면 이 메서드만 채우면 된다. - */ - private static String thumbnailUrlOf(Long thumbnailFileId) { - return null; - } } diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/notice/response/NoticeListItemResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/notice/response/NoticeListItemResponse.java index dc4edac2..ad5e932e 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/notice/response/NoticeListItemResponse.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/notice/response/NoticeListItemResponse.java @@ -21,14 +21,13 @@ public static NoticeListItemResponse from(Notice notice, Map filesBy Long thumbnailId = notice.getImageIds() == null || notice.getImageIds().isEmpty() ? null : notice.getImageIds().get(0); - File thumbnailFile = thumbnailId == null ? null : filesById.get(thumbnailId); return new NoticeListItemResponse( notice.getId(), notice.getTitle(), notice.getCategory(), notice.getCreatedAt(), - thumbnailFile == null ? null : StorageUrlBuilder.build(thumbnailFile.getFileKey()), + StorageUrlBuilder.build(thumbnailId, filesById), notice.isPinned() ); } diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/ItemListItemResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/ItemListItemResponse.java index cc0a8b28..23be92c1 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/ItemListItemResponse.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/ItemListItemResponse.java @@ -19,7 +19,7 @@ public record ItemListItemResponse( public static ItemListItemResponse from(Item item) { ReturnPolicy returnPolicy = item.getReturnPolicy(); - String imageUrl = item.getImageKey() == null ? null : StorageUrlBuilder.build(item.getImageKey()); + String imageUrl = StorageUrlBuilder.build(item.getImageKey()); return new ItemListItemResponse( item.getId(), item.getName(), diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/RentalHistoryListResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/RentalHistoryListResponse.java index 85a7aeff..8b2b4bcc 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/RentalHistoryListResponse.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/RentalHistoryListResponse.java @@ -24,8 +24,7 @@ public record History( public static History from(RentalRecord record) { RentalHistory history = record.history(); - String imageKey = record.itemImageKey(); - String imageUrl = imageKey == null ? null : StorageUrlBuilder.build(imageKey); + String imageUrl = StorageUrlBuilder.build(record.itemImageKey()); return new History( history.getId(), record.itemName(), diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/ReturnRequiredListResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/ReturnRequiredListResponse.java index cf5e64e7..f9f00ab8 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/ReturnRequiredListResponse.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/ReturnRequiredListResponse.java @@ -19,8 +19,7 @@ public record Rental( ) { public static Rental from(RentalRecord record) { - String imageKey = record.itemImageKey(); - String imageUrl = imageKey == null ? null : StorageUrlBuilder.build(imageKey); + String imageUrl = StorageUrlBuilder.build(record.itemImageKey()); return new Rental( record.history().getId(), record.itemName(), diff --git a/api/common-api/src/main/java/kr/ac/kookmin/stream/api/common/StorageUrlBuilder.java b/api/common-api/src/main/java/kr/ac/kookmin/stream/api/common/StorageUrlBuilder.java index 3b320b0f..90eb44fe 100644 --- a/api/common-api/src/main/java/kr/ac/kookmin/stream/api/common/StorageUrlBuilder.java +++ b/api/common-api/src/main/java/kr/ac/kookmin/stream/api/common/StorageUrlBuilder.java @@ -1,9 +1,23 @@ package kr.ac.kookmin.stream.api.common; +import java.util.Map; +import kr.ac.kookmin.stream.file.domain.File; + public final class StorageUrlBuilder { public static String build(String fileKey) { - return ApiConstants.STORAGE_BASE_URL + "/" + fileKey; + return fileKey == null ? null : ApiConstants.STORAGE_BASE_URL + "/" + fileKey; + } + + /** + * fileId가 null이거나 filesById에 없으면(삭제된 파일) null을 돌려준다. + */ + public static String build(Long fileId, Map filesById) { + if (fileId == null) { + return null; + } + File file = filesById.get(fileId); + return file == null ? null : build(file.getFileKey()); } private StorageUrlBuilder() {}