From e097c7849bb107f609fa393839091304ed67955a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Wed, 9 Sep 2026 11:02:55 +0900 Subject: [PATCH 01/11] =?UTF-8?q?feat:=20=ED=8C=8C=EC=9D=BC=20=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20URL=20=EB=B0=9C=EA=B8=89/=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/file/domain/FileErrorCode.java | 20 ++++++ .../domain/FileUploadUrlIssueCommand.java | 9 +++ .../file/domain/FileUploadUrlIssueResult.java | 10 +++ .../domain/file/domain/UploadUrl.java | 5 ++ .../domain/file/repository/FileClient.java | 10 +++ .../file/repository/FileRepository.java | 11 +++ .../domain/file/service/FileService.java | 11 +++ .../file/service/impl/FileServiceImpl.java | 71 +++++++++++++++++++ .../file/service/impl/FileUploadPolicy.java | 59 +++++++++++++++ 9 files changed, 206 insertions(+) create mode 100644 core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/domain/FileErrorCode.java create mode 100644 core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/domain/FileUploadUrlIssueCommand.java create mode 100644 core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/domain/FileUploadUrlIssueResult.java create mode 100644 core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/domain/UploadUrl.java create mode 100644 core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/repository/FileClient.java create mode 100644 core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/repository/FileRepository.java create mode 100644 core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/service/FileService.java create mode 100644 core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/service/impl/FileServiceImpl.java create mode 100644 core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/service/impl/FileUploadPolicy.java diff --git a/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/domain/FileErrorCode.java b/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/domain/FileErrorCode.java new file mode 100644 index 0000000..f279a08 --- /dev/null +++ b/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/domain/FileErrorCode.java @@ -0,0 +1,20 @@ +package kr.ac.kookmin.stream.internal.domain.file.domain; + +import kr.ac.kookmin.stream.common.ErrorCode; +import kr.ac.kookmin.stream.common.ErrorStatus; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.experimental.Accessors; + +@Getter +@Accessors(fluent = true) +@AllArgsConstructor +public enum FileErrorCode implements ErrorCode { + + FILE_NOT_FOUND(ErrorStatus.NOT_FOUND, "존재하지 않는 파일입니다."), + UNSUPPORTED_FILE_EXTENSION(ErrorStatus.BAD_REQUEST, "지원하지 않는 파일 형식입니다."), + FILE_SIZE_EXCEEDED(ErrorStatus.BAD_REQUEST, "파일의 최대 업로드 용량을 초과했습니다."); + + private final int status; + private final String message; +} diff --git a/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/domain/FileUploadUrlIssueCommand.java b/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/domain/FileUploadUrlIssueCommand.java new file mode 100644 index 0000000..8c2bbc5 --- /dev/null +++ b/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/domain/FileUploadUrlIssueCommand.java @@ -0,0 +1,9 @@ +package kr.ac.kookmin.stream.internal.domain.file.domain; + +public record FileUploadUrlIssueCommand( + String originalName, + String contentType, + long fileSize, + FileCategory category, + Long uploaderId +) {} diff --git a/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/domain/FileUploadUrlIssueResult.java b/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/domain/FileUploadUrlIssueResult.java new file mode 100644 index 0000000..49d76fd --- /dev/null +++ b/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/domain/FileUploadUrlIssueResult.java @@ -0,0 +1,10 @@ +package kr.ac.kookmin.stream.internal.domain.file.domain; + +import java.time.LocalDateTime; + +public record FileUploadUrlIssueResult( + Long fileId, + String uploadUrl, + String fileKey, + LocalDateTime expiresAt +) {} diff --git a/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/domain/UploadUrl.java b/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/domain/UploadUrl.java new file mode 100644 index 0000000..86a331f --- /dev/null +++ b/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/domain/UploadUrl.java @@ -0,0 +1,5 @@ +package kr.ac.kookmin.stream.internal.domain.file.domain; + +import java.time.LocalDateTime; + +public record UploadUrl(String url, LocalDateTime expiresAt) {} diff --git a/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/repository/FileClient.java b/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/repository/FileClient.java new file mode 100644 index 0000000..13ab4b5 --- /dev/null +++ b/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/repository/FileClient.java @@ -0,0 +1,10 @@ +package kr.ac.kookmin.stream.internal.domain.file.repository; + +import java.io.InputStream; +import kr.ac.kookmin.stream.internal.domain.file.domain.UploadUrl; + +public interface FileClient { + UploadUrl issueUploadUrl(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/repository/FileRepository.java b/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/repository/FileRepository.java new file mode 100644 index 0000000..a56b7ff --- /dev/null +++ b/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/repository/FileRepository.java @@ -0,0 +1,11 @@ +package kr.ac.kookmin.stream.internal.domain.file.repository; + +import java.util.Optional; +import kr.ac.kookmin.stream.internal.domain.file.domain.File; + +public interface FileRepository { + Optional findById(Long id); + Optional findByFileKey(String fileKey); + File save(File file); + void deleteById(Long id); +} 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 new file mode 100644 index 0000000..45765b5 --- /dev/null +++ b/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/service/FileService.java @@ -0,0 +1,11 @@ +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 issueUploadUrl(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 new file mode 100644 index 0000000..b1a56d3 --- /dev/null +++ b/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/service/impl/FileServiceImpl.java @@ -0,0 +1,71 @@ +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.repository.FileClient; +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 FileClient fileClient; + + @Override + @Transactional + public FileUploadUrlIssueResult issueUploadUrl(FileUploadUrlIssueCommand command) { + FileUploadPolicy.validate(command.category(), command.originalName(), command.fileSize()); + + String fileKey = generateFileKey(command.originalName()); + UploadUrl uploadUrl = fileClient.issueUploadUrl(fileKey, command.contentType()); + + File file = File.of( + null, + fileKey, + command.category(), + command.originalName(), + command.fileSize(), + command.contentType(), + command.uploaderId() + ); + File saved = fileRepository.save(file); + + return new FileUploadUrlIssueResult(saved.getId(), uploadUrl.url(), fileKey, uploadUrl.expiresAt()); + } + + @Override + @Transactional(readOnly = true) + public void receiveUpload(String fileKey, InputStream content) { + fileRepository.findByFileKey(fileKey) + .orElseThrow(() -> new BusinessException(FileErrorCode.FILE_NOT_FOUND)); + fileClient.write(fileKey, content); + } + + @Override + @Transactional + public void delete(Long fileId) { + File file = fileRepository.findById(fileId) + .orElseThrow(() -> new BusinessException(FileErrorCode.FILE_NOT_FOUND)); + fileClient.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/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/service/impl/FileUploadPolicy.java b/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/service/impl/FileUploadPolicy.java new file mode 100644 index 0000000..851084b --- /dev/null +++ b/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/service/impl/FileUploadPolicy.java @@ -0,0 +1,59 @@ +package kr.ac.kookmin.stream.internal.domain.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; + +final class FileUploadPolicy { + + private static final Set IMAGE_EXTENSIONS = Set.of("jpg", "jpeg", "png", "webp"); + private static final Set ATTACHMENT_EXTENSIONS = Set.of("pdf", "doc", "docx", "hwp", "zip"); + + private static final long IMAGE_MAX_FILE_SIZE = 10 * 1024 * 1024; + private static final long ATTACHMENT_MAX_FILE_SIZE = 20 * 1024 * 1024; + + private static final Map> ALLOWED_EXTENSIONS = Map.of( + FileCategory.TEMP, union(IMAGE_EXTENSIONS, ATTACHMENT_EXTENSIONS), + FileCategory.NOTICE_ATTACHMENT, ATTACHMENT_EXTENSIONS, + FileCategory.NOTICE_IMAGE, IMAGE_EXTENSIONS, + FileCategory.EVENT_IMAGE, IMAGE_EXTENSIONS, + FileCategory.ARCHIVE_IMAGE, IMAGE_EXTENSIONS + ); + + private static final Map MAX_FILE_SIZE = Map.of( + FileCategory.TEMP, ATTACHMENT_MAX_FILE_SIZE, + FileCategory.NOTICE_ATTACHMENT, ATTACHMENT_MAX_FILE_SIZE, + FileCategory.NOTICE_IMAGE, IMAGE_MAX_FILE_SIZE, + FileCategory.EVENT_IMAGE, IMAGE_MAX_FILE_SIZE, + FileCategory.ARCHIVE_IMAGE, IMAGE_MAX_FILE_SIZE + ); + + private FileUploadPolicy() {} + + static void validate(FileCategory category, String originalName, long fileSize) { + String extension = extractExtension(originalName); + if (!ALLOWED_EXTENSIONS.get(category).contains(extension)) { + throw new BusinessException(FileErrorCode.UNSUPPORTED_FILE_EXTENSION); + } + if (fileSize <= 0 || fileSize > MAX_FILE_SIZE.get(category)) { + throw new BusinessException(FileErrorCode.FILE_SIZE_EXCEEDED); + } + } + + static String extractExtension(String originalName) { + int dotIndex = originalName.lastIndexOf('.'); + if (dotIndex == -1 || dotIndex == originalName.length() - 1) { + return ""; + } + return originalName.substring(dotIndex + 1).toLowerCase(); + } + + private static Set union(Set first, Set second) { + return Stream.concat(first.stream(), second.stream()) + .collect(Collectors.toUnmodifiableSet()); + } +} From 0e291f6c05481979a95e6d7c59e06b5cc3abd8f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Wed, 9 Sep 2026 11:03:00 +0900 Subject: [PATCH 02/11] =?UTF-8?q?feat:=20=ED=8C=8C=EC=9D=BC=20=EB=A9=94?= =?UTF-8?q?=ED=83=80=EB=8D=B0=EC=9D=B4=ED=84=B0=20Repository=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=EC=B2=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stream/db/internal/FileJpaRepository.java | 8 +++++ .../db/internal/FileRepositoryImpl.java | 34 +++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/internal/FileJpaRepository.java create mode 100644 infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/internal/FileRepositoryImpl.java 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/internal/FileJpaRepository.java new file mode 100644 index 0000000..cc054aa --- /dev/null +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/internal/FileJpaRepository.java @@ -0,0 +1,8 @@ +package kr.ac.kookmin.stream.db.internal; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface FileJpaRepository extends JpaRepository { + Optional findByFileKey(String fileKey); +} 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/internal/FileRepositoryImpl.java new file mode 100644 index 0000000..6fbd852 --- /dev/null +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/internal/FileRepositoryImpl.java @@ -0,0 +1,34 @@ +package kr.ac.kookmin.stream.db.internal; + +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 lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class FileRepositoryImpl implements FileRepository { + + private final FileJpaRepository fileJpaRepository; + + @Override + 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 File save(File file) { + return fileJpaRepository.save(FileJpaEntity.from(file)).toDomain(); + } + + @Override + public void deleteById(Long id) { + fileJpaRepository.deleteById(id); + } +} From 8453977a9f9f4422ae74c098c813130774f0a941 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Wed, 9 Sep 2026 11:03:06 +0900 Subject: [PATCH 03/11] =?UTF-8?q?feat:=20=EB=A1=9C=EC=BB=AC=20=EB=94=94?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=20=EA=B8=B0=EB=B0=98=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20=ED=81=B4=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EC=96=B8=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 5 ++ bootstrap/src/main/resources/application.yaml | 1 + infrastructure/client/build.gradle.kts | 2 + .../stream/client/file/FileClientImpl.java | 56 +++++++++++++++++++ .../file/LocalFileStorageProperties.java | 10 ++++ .../application-infrastructure-client.yml | 6 ++ 6 files changed, 80 insertions(+) create mode 100644 infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/FileClientImpl.java create mode 100644 infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/LocalFileStorageProperties.java create mode 100644 infrastructure/client/src/main/resources/application-infrastructure-client.yml diff --git a/.env.example b/.env.example index 9bcd8c1..b003b16 100644 --- a/.env.example +++ b/.env.example @@ -9,3 +9,8 @@ JWT_ACCESS_TOKEN_EXPIRY=3600000 DB_URL=jdbc:mysql://localhost:3306/stream?serverTimezone=Asia/Seoul&characterEncoding=UTF-8 DB_USERNAME=stream DB_PASSWORD=replace-with-db-password + +### infrastructure:client — 파일 로컬 스토리지 (S3 연동 전 임시) ### +LOCAL_STORAGE_BASE_PATH=./local-storage +LOCAL_STORAGE_BASE_URL=http://localhost:8080 +LOCAL_STORAGE_UPLOAD_URL_EXPIRY_SECONDS=600 diff --git a/bootstrap/src/main/resources/application.yaml b/bootstrap/src/main/resources/application.yaml index 73cc461..3d7c783 100644 --- a/bootstrap/src/main/resources/application.yaml +++ b/bootstrap/src/main/resources/application.yaml @@ -5,3 +5,4 @@ spring: import: - classpath:application-gateway-auth.yml - classpath:application-infrastructure-db.yml + - classpath:application-infrastructure-client.yml diff --git a/infrastructure/client/build.gradle.kts b/infrastructure/client/build.gradle.kts index 9db7ed1..60d6d9b 100644 --- a/infrastructure/client/build.gradle.kts +++ b/infrastructure/client/build.gradle.kts @@ -6,8 +6,10 @@ description = "외부 API 클라이언트 구현체" dependencies { implementation(project(":core:common")) + implementation(project(":core:domain:internal")) implementation(platform(libs.springBootDependenciesBom)) + implementation(libs.springBootStarter) implementation(libs.springWeb) implementation(libs.jacksonDatabind) diff --git a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/FileClientImpl.java b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/FileClientImpl.java new file mode 100644 index 0000000..a75edbe --- /dev/null +++ b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/FileClientImpl.java @@ -0,0 +1,56 @@ +package kr.ac.kookmin.stream.client.file; + +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.internal.domain.file.domain.UploadUrl; +import kr.ac.kookmin.stream.internal.domain.file.repository.FileClient; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/** + * 로컬 디스크 기반 임시 구현체. S3 연동 시 이 클래스와 "임시 로컬 업로드 엔드포인트"를 함께 제거한다. + */ +@Component +@RequiredArgsConstructor +public class FileClientImpl implements FileClient { + + private static final String LOCAL_UPLOAD_PATH = "/v1/admin/files/local-upload/"; + + private final LocalFileStorageProperties properties; + + @Override + public UploadUrl issueUploadUrl(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); + } + } + + 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/LocalFileStorageProperties.java b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/LocalFileStorageProperties.java new file mode 100644 index 0000000..ff22451 --- /dev/null +++ b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/LocalFileStorageProperties.java @@ -0,0 +1,10 @@ +package kr.ac.kookmin.stream.client.file; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "file.storage.local") +public record LocalFileStorageProperties( + String basePath, + String baseUrl, + long uploadUrlExpirySeconds +) {} diff --git a/infrastructure/client/src/main/resources/application-infrastructure-client.yml b/infrastructure/client/src/main/resources/application-infrastructure-client.yml new file mode 100644 index 0000000..05bc934 --- /dev/null +++ b/infrastructure/client/src/main/resources/application-infrastructure-client.yml @@ -0,0 +1,6 @@ +file: + storage: + local: + base-path: ${LOCAL_STORAGE_BASE_PATH:./local-storage} + base-url: ${LOCAL_STORAGE_BASE_URL:http://localhost:8080} + upload-url-expiry-seconds: ${LOCAL_STORAGE_UPLOAD_URL_EXPIRY_SECONDS:600} From 911c7a4ec93d5f5a70e26df4b87f0e3a6d942e98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Wed, 9 Sep 2026 11:03:11 +0900 Subject: [PATCH 04/11] =?UTF-8?q?feat:=20=ED=8C=8C=EC=9D=BC=20=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20URL=20=EB=B0=9C=EA=B8=89/=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EA=B4=80=EB=A6=AC=EC=9E=90=20API=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/admin-api/build.gradle.kts | 1 + .../stream/internal/AdminFileController.java | 57 +++++++++++++++++++ .../internal/FileUploadUrlIssueRequest.java | 25 ++++++++ .../internal/FileUploadUrlIssueResponse.java | 15 +++++ 4 files changed, 98 insertions(+) create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/internal/AdminFileController.java create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/internal/FileUploadUrlIssueRequest.java create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/internal/FileUploadUrlIssueResponse.java diff --git a/api/admin-api/build.gradle.kts b/api/admin-api/build.gradle.kts index b649533..c014858 100644 --- a/api/admin-api/build.gradle.kts +++ b/api/admin-api/build.gradle.kts @@ -7,6 +7,7 @@ description = "운영진 콘솔 — ADMIN, /v1/admin/**" dependencies { implementation(project(":api:common-api")) implementation(project(":core:common")) + implementation(project(":core:domain:internal")) implementation(project(":gateway:auth")) implementation(project(":gateway:logging")) diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/internal/AdminFileController.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/internal/AdminFileController.java new file mode 100644 index 0000000..826e26b --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/internal/AdminFileController.java @@ -0,0 +1,57 @@ +package kr.ac.kookmin.stream.internal; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import java.io.IOException; +import kr.ac.kookmin.stream.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 lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/v1/admin/files") +@RequiredArgsConstructor +public class AdminFileController { + + private static final String LOCAL_UPLOAD_PATH = "/local-upload/"; + + private final FileService fileService; + private final PrincipalProvider principalProvider; + + @PostMapping("/presigned-url") + public ApiResponse issueUploadUrl(@Valid @RequestBody FileUploadUrlIssueRequest request) { + FileUploadUrlIssueResult result = fileService.issueUploadUrl(request.toCommand(principalProvider.userId())); + return ApiResponse.success(FileUploadUrlIssueResponse.from(result)); + } + + @DeleteMapping("/{fileId}") + public ApiResponse delete(@PathVariable Long fileId) { + fileService.delete(fileId); + return ApiResponse.success(); + } + + /** + * S3 연동 전 임시 엔드포인트. presigned-url 발급 응답의 uploadUrl이 이 경로를 가리킨다. + * S3로 전환하면 이 메서드와 FileClientImpl의 로컬 구현을 함께 제거한다. + */ + @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/internal/FileUploadUrlIssueRequest.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/internal/FileUploadUrlIssueRequest.java new file mode 100644 index 0000000..837f83d --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/internal/FileUploadUrlIssueRequest.java @@ -0,0 +1,25 @@ +package kr.ac.kookmin.stream.internal; + +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; + +public record FileUploadUrlIssueRequest( + @NotBlank(message = "파일명은 필수 입력값입니다.") + String originalName, + + @NotBlank(message = "MIME 타입은 필수 입력값입니다.") + String contentType, + + @Positive(message = "파일 크기는 0보다 커야 합니다.") + long fileSize, + + @NotNull(message = "파일 카테고리는 필수 입력값입니다.") + FileCategory category +) { + public FileUploadUrlIssueCommand toCommand(Long uploaderId) { + return new FileUploadUrlIssueCommand(originalName, contentType, fileSize, category, uploaderId); + } +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/internal/FileUploadUrlIssueResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/internal/FileUploadUrlIssueResponse.java new file mode 100644 index 0000000..b476b8b --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/internal/FileUploadUrlIssueResponse.java @@ -0,0 +1,15 @@ +package kr.ac.kookmin.stream.internal; + +import java.time.LocalDateTime; +import kr.ac.kookmin.stream.internal.domain.file.domain.FileUploadUrlIssueResult; + +public record FileUploadUrlIssueResponse( + Long fileId, + String uploadUrl, + String fileKey, + LocalDateTime expiresAt +) { + public static FileUploadUrlIssueResponse from(FileUploadUrlIssueResult result) { + return new FileUploadUrlIssueResponse(result.fileId(), result.uploadUrl(), result.fileKey(), result.expiresAt()); + } +} From 451402757a0b44b769b95c1fe602756c42a860e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Wed, 9 Sep 2026 16:18:01 +0900 Subject: [PATCH 05/11] =?UTF-8?q?refactor:=20FileClient=EB=A5=BC=20FileSto?= =?UTF-8?q?rageClient=EB=A1=9C,=20issueUploadUrl=EC=9D=84=20issuePresigned?= =?UTF-8?q?Url=EB=A1=9C=20=EC=9D=B4=EB=A6=84=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kookmin/stream/internal/AdminFileController.java | 6 +++--- .../FileStorageClient.java} | 6 +++--- .../internal/domain/file/service/FileService.java | 2 +- .../domain/file/service/impl/FileServiceImpl.java | 12 ++++++------ .../LocalFileStorageClient.java} | 8 ++++---- .../file/{ => local}/LocalFileStorageProperties.java | 2 +- 6 files changed, 18 insertions(+), 18 deletions(-) rename core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/{repository/FileClient.java => client/FileStorageClient.java} (53%) rename infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/{FileClientImpl.java => local/LocalFileStorageClient.java} (86%) rename infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/{ => local}/LocalFileStorageProperties.java (84%) diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/internal/AdminFileController.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/internal/AdminFileController.java index 826e26b..2390eba 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/internal/AdminFileController.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/internal/AdminFileController.java @@ -27,8 +27,8 @@ public class AdminFileController { private final PrincipalProvider principalProvider; @PostMapping("/presigned-url") - public ApiResponse issueUploadUrl(@Valid @RequestBody FileUploadUrlIssueRequest request) { - FileUploadUrlIssueResult result = fileService.issueUploadUrl(request.toCommand(principalProvider.userId())); + public ApiResponse issuePresignedUrl(@Valid @RequestBody FileUploadUrlIssueRequest request) { + FileUploadUrlIssueResult result = fileService.issuePresignedUrl(request.toCommand(principalProvider.userId())); return ApiResponse.success(FileUploadUrlIssueResponse.from(result)); } @@ -40,7 +40,7 @@ public ApiResponse delete(@PathVariable Long fileId) { /** * S3 연동 전 임시 엔드포인트. presigned-url 발급 응답의 uploadUrl이 이 경로를 가리킨다. - * S3로 전환하면 이 메서드와 FileClientImpl의 로컬 구현을 함께 제거한다. + * S3로 전환하면 이 메서드와 LocalFileStorageClient의 로컬 구현을 함께 제거한다. */ @PutMapping("/local-upload/**") public ApiResponse receiveLocalUpload(HttpServletRequest request) throws IOException { diff --git a/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/repository/FileClient.java b/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/client/FileStorageClient.java similarity index 53% rename from core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/repository/FileClient.java rename to core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/client/FileStorageClient.java index 13ab4b5..2ccc9ff 100644 --- a/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/repository/FileClient.java +++ b/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/client/FileStorageClient.java @@ -1,10 +1,10 @@ -package kr.ac.kookmin.stream.internal.domain.file.repository; +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 FileClient { - UploadUrl issueUploadUrl(String fileKey, String contentType); +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 index 45765b5..e075fb0 100644 --- 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 @@ -5,7 +5,7 @@ import kr.ac.kookmin.stream.internal.domain.file.domain.FileUploadUrlIssueResult; public interface FileService { - FileUploadUrlIssueResult issueUploadUrl(FileUploadUrlIssueCommand command); + 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 index b1a56d3..dec9aaa 100644 --- 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 @@ -8,7 +8,7 @@ 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.repository.FileClient; +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; @@ -22,15 +22,15 @@ class FileServiceImpl implements FileService { private static final String FILE_KEY_PREFIX = "files/"; private final FileRepository fileRepository; - private final FileClient fileClient; + private final FileStorageClient fileStorageClient; @Override @Transactional - public FileUploadUrlIssueResult issueUploadUrl(FileUploadUrlIssueCommand command) { + public FileUploadUrlIssueResult issuePresignedUrl(FileUploadUrlIssueCommand command) { FileUploadPolicy.validate(command.category(), command.originalName(), command.fileSize()); String fileKey = generateFileKey(command.originalName()); - UploadUrl uploadUrl = fileClient.issueUploadUrl(fileKey, command.contentType()); + UploadUrl uploadUrl = fileStorageClient.issuePresignedUrl(fileKey, command.contentType()); File file = File.of( null, @@ -51,7 +51,7 @@ public FileUploadUrlIssueResult issueUploadUrl(FileUploadUrlIssueCommand command public void receiveUpload(String fileKey, InputStream content) { fileRepository.findByFileKey(fileKey) .orElseThrow(() -> new BusinessException(FileErrorCode.FILE_NOT_FOUND)); - fileClient.write(fileKey, content); + fileStorageClient.write(fileKey, content); } @Override @@ -59,7 +59,7 @@ public void receiveUpload(String fileKey, InputStream content) { public void delete(Long fileId) { File file = fileRepository.findById(fileId) .orElseThrow(() -> new BusinessException(FileErrorCode.FILE_NOT_FOUND)); - fileClient.deleteObject(file.getFileKey()); + fileStorageClient.deleteObject(file.getFileKey()); fileRepository.deleteById(fileId); } diff --git a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/FileClientImpl.java b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/local/LocalFileStorageClient.java similarity index 86% rename from infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/FileClientImpl.java rename to infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/local/LocalFileStorageClient.java index a75edbe..ef72ba7 100644 --- a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/FileClientImpl.java +++ b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/local/LocalFileStorageClient.java @@ -1,4 +1,4 @@ -package kr.ac.kookmin.stream.client.file; +package kr.ac.kookmin.stream.client.file.local; import java.io.IOException; import java.io.InputStream; @@ -8,7 +8,7 @@ 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.repository.FileClient; +import kr.ac.kookmin.stream.internal.domain.file.client.FileStorageClient; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -17,14 +17,14 @@ */ @Component @RequiredArgsConstructor -public class FileClientImpl implements FileClient { +public class LocalFileStorageClient implements FileStorageClient { private static final String LOCAL_UPLOAD_PATH = "/v1/admin/files/local-upload/"; private final LocalFileStorageProperties properties; @Override - public UploadUrl issueUploadUrl(String fileKey, String contentType) { + 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); diff --git a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/LocalFileStorageProperties.java b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/local/LocalFileStorageProperties.java similarity index 84% rename from infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/LocalFileStorageProperties.java rename to infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/local/LocalFileStorageProperties.java index ff22451..64204b2 100644 --- a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/LocalFileStorageProperties.java +++ b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/local/LocalFileStorageProperties.java @@ -1,4 +1,4 @@ -package kr.ac.kookmin.stream.client.file; +package kr.ac.kookmin.stream.client.file.local; import org.springframework.boot.context.properties.ConfigurationProperties; From e107d58fc9c3e745967f4c2c7140b1abcd05d2e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Thu, 10 Sep 2026 12:50:31 +0900 Subject: [PATCH 06/11] =?UTF-8?q?feat:=20S3=20=EA=B8=B0=EB=B0=98=20FileSto?= =?UTF-8?q?rageClient=20=EA=B5=AC=ED=98=84=EC=B2=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 10 ++- gradle/libs.versions.toml | 4 + infrastructure/client/build.gradle.kts | 3 + .../file/local/LocalFileStorageClient.java | 2 + .../client/file/s3/S3FileStorageClient.java | 80 +++++++++++++++++++ .../file/s3/S3FileStorageProperties.java | 10 +++ .../application-infrastructure-client.yml | 5 ++ 7 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/s3/S3FileStorageClient.java create mode 100644 infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/s3/S3FileStorageProperties.java diff --git a/.env.example b/.env.example index b003b16..7932998 100644 --- a/.env.example +++ b/.env.example @@ -10,7 +10,15 @@ DB_URL=jdbc:mysql://localhost:3306/stream?serverTimezone=Asia/Seoul&characterEnc DB_USERNAME=stream DB_PASSWORD=replace-with-db-password -### infrastructure:client — 파일 로컬 스토리지 (S3 연동 전 임시) ### +### infrastructure:client — 파일 스토리지 (local | s3) ### +FILE_STORAGE_TYPE=local + +# type=local일 때 LOCAL_STORAGE_BASE_PATH=./local-storage LOCAL_STORAGE_BASE_URL=http://localhost:8080 LOCAL_STORAGE_UPLOAD_URL_EXPIRY_SECONDS=600 + +# type=s3일 때. 자격증명은 앱에 넣지 않는다 — EC2 배포 시 인스턴스 프로필(IAM 역할)에서 자동으로 가져온다 +AWS_S3_BUCKET=replace-with-bucket-name +AWS_REGION=ap-northeast-2 +S3_UPLOAD_URL_EXPIRY_SECONDS=600 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 968f0d2..da3dff5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,12 +5,14 @@ jjwtVersion = "0.12.6" archunitVersion = "1.3.0" junitBomVersion = "6.0.0" lombokVersion = "1.18.36" +awsSdkVersion = "2.54.15" [libraries] # BOMs — import as `platform(...)` in modules that don't apply the Boot plugin directly springBootDependenciesBom = { module = "org.springframework.boot:spring-boot-dependencies", version.ref = "springBootVersion" } springModulithBom = { module = "org.springframework.modulith:spring-modulith-bom", version.ref = "springModulithVersion" } junitBom = { module = "org.junit:junit-bom", version.ref = "junitBomVersion" } +awsSdkBom = { module = "software.amazon.awssdk:bom", version.ref = "awsSdkVersion" } # Spring Boot starters (version managed by springBootDependenciesBom / the Boot plugin) springBootStarter = { module = "org.springframework.boot:spring-boot-starter" } @@ -40,6 +42,8 @@ jacksonDatabind = { module = "com.fasterxml.jackson.core:jackson-databind" } flywayMysql = { module = "org.flywaydb:flyway-mysql" } mysqlConnectorJ = { module = "com.mysql:mysql-connector-j" } +awsS3 = { module = "software.amazon.awssdk:s3" } + jjwtApi = { module = "io.jsonwebtoken:jjwt-api", version.ref = "jjwtVersion" } jjwtImpl = { module = "io.jsonwebtoken:jjwt-impl", version.ref = "jjwtVersion" } jjwtJackson = { module = "io.jsonwebtoken:jjwt-jackson", version.ref = "jjwtVersion" } diff --git a/infrastructure/client/build.gradle.kts b/infrastructure/client/build.gradle.kts index 60d6d9b..f7205cb 100644 --- a/infrastructure/client/build.gradle.kts +++ b/infrastructure/client/build.gradle.kts @@ -13,6 +13,9 @@ dependencies { implementation(libs.springWeb) implementation(libs.jacksonDatabind) + implementation(platform(libs.awsSdkBom)) + implementation(libs.awsS3) + testImplementation(platform(libs.springBootDependenciesBom)) testImplementation(libs.springBootStarterTest) testRuntimeOnly(libs.junitPlatformLauncher) 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 ef72ba7..8aaf241 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 @@ -10,12 +10,14 @@ import kr.ac.kookmin.stream.internal.domain.file.domain.UploadUrl; import kr.ac.kookmin.stream.internal.domain.file.client.FileStorageClient; 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 { 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 new file mode 100644 index 0000000..b57b6c1 --- /dev/null +++ b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/s3/S3FileStorageClient.java @@ -0,0 +1,80 @@ +package kr.ac.kookmin.stream.client.file.s3; + +import java.io.InputStream; +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 org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; +import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; + +/** + * S3 기반 구현체. presigned URL은 클라이언트가 S3에 직접 PUT하는 용도라, 로컬 구현체와 달리 + * 서버가 파일 바이트를 직접 받는 write(...)는 지원하지 않는다. + * 자격증명은 AWS 기본 자격증명 체인({@link DefaultCredentialsProvider})을 사용한다 — + * EC2에 배포하면 인스턴스 프로필(IAM 역할)에서 자동으로 자격증명을 가져오므로 액세스 키를 앱에 직접 넣지 않는다. + */ +@Component +@ConditionalOnProperty(prefix = "file.storage", name = "type", havingValue = "s3") +public class S3FileStorageClient implements FileStorageClient { + + private final S3FileStorageProperties properties; + private final S3Client s3Client; + private final S3Presigner s3Presigner; + + public S3FileStorageClient(S3FileStorageProperties properties) { + this.properties = properties; + + DefaultCredentialsProvider credentialsProvider = DefaultCredentialsProvider.create(); + Region region = Region.of(properties.region()); + + this.s3Client = S3Client.builder() + .region(region) + .credentialsProvider(credentialsProvider) + .build(); + this.s3Presigner = S3Presigner.builder() + .region(region) + .credentialsProvider(credentialsProvider) + .build(); + } + + @Override + public UploadUrl issuePresignedUrl(String fileKey, String contentType) { + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(properties.bucket()) + .key(fileKey) + .contentType(contentType) + .build(); + + PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder() + .signatureDuration(Duration.ofSeconds(properties.uploadUrlExpirySeconds())) + .putObjectRequest(putObjectRequest) + .build(); + + PresignedPutObjectRequest presignedRequest = s3Presigner.presignPutObject(presignRequest); + LocalDateTime expiresAt = LocalDateTime.ofInstant(presignedRequest.expiration(), ZoneId.systemDefault()); + 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() + .bucket(properties.bucket()) + .key(fileKey) + .build()); + } +} 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 new file mode 100644 index 0000000..d676c08 --- /dev/null +++ b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/s3/S3FileStorageProperties.java @@ -0,0 +1,10 @@ +package kr.ac.kookmin.stream.client.file.s3; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "file.storage.s3") +public record S3FileStorageProperties( + String bucket, + String region, + 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 05bc934..0deba86 100644 --- a/infrastructure/client/src/main/resources/application-infrastructure-client.yml +++ b/infrastructure/client/src/main/resources/application-infrastructure-client.yml @@ -1,6 +1,11 @@ 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} upload-url-expiry-seconds: ${LOCAL_STORAGE_UPLOAD_URL_EXPIRY_SECONDS:600} + s3: + bucket: ${AWS_S3_BUCKET:} + region: ${AWS_REGION:} + upload-url-expiry-seconds: ${S3_UPLOAD_URL_EXPIRY_SECONDS:600} From 5d66d32a0be4099afb6ffca9456a4c2fdf207385 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Thu, 10 Sep 2026 12:50:37 +0900 Subject: [PATCH 07/11] =?UTF-8?q?docs:=20=EC=BD=94=EB=94=A9=20=EC=BB=A8?= =?UTF-8?q?=EB=B2=A4=EC=85=98=EC=97=90=20Client(=EC=99=B8=EB=B6=80=20?= =?UTF-8?q?=EC=8A=A4=ED=86=A0=EB=A6=AC=EC=A7=80=C2=B7API=20=ED=81=B4?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EC=96=B8=ED=8A=B8)=20=ED=8C=A8=ED=84=B4=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/conventions/00-index.md | 3 +- docs/conventions/coding-style.md | 47 ++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/docs/conventions/00-index.md b/docs/conventions/00-index.md index fd4011e..4a4b172 100644 --- a/docs/conventions/00-index.md +++ b/docs/conventions/00-index.md @@ -12,7 +12,7 @@ Java 21 + Spring Boot 4.1 + Spring Modulith 기반, 단일 학생회 플랫폼 | 문서 | 다루는 내용 | 언제 참조하는가 | | --- | --- | --- | | [`architecture.md`](./architecture.md) | 모듈 구조, 의존 방향, Modulith 경계 규칙, 레이어, 도메인 간 통신(UseCase·이벤트·아웃박스) | 새 모듈/도메인 설계, 의존성 리뷰 | -| [`coding-style.md`](./coding-style.md) | 네이밍, 도메인 객체(record)/DTO/Command/Entity/Repository/Service/UseCase 패턴, 정적 팩토리·Lombok, Validation | 실제 코드 작성/리뷰 | +| [`coding-style.md`](./coding-style.md) | 네이밍, 도메인 객체(record)/DTO/Command/Entity/Repository/Client/Service/UseCase 패턴, 정적 팩토리·Lombok, Validation | 실제 코드 작성/리뷰 | | [`error-handling.md`](./error-handling.md) | `ErrorCode`/`BusinessException`, `GlobalExceptionHandler`, `@ApiErrorCode` Swagger 문서화 | 에러 코드 추가, 예외 처리 | | [`config-and-auth.md`](./config-and-auth.md) | 설정 바인딩, 2계층 권한 모델(role + 부서), `PrincipalProvider`, `DepartmentAccessChecker` | 설정값 추가, 인증·인가 작업 | | [`logging.md`](./logging.md) | MDC 요청 추적, `MdcFilter`/`LoggingFilter`, 로그 레벨, JSON 로깅 | 로깅 코드, MDC 필드 추가 | @@ -29,6 +29,7 @@ Java 21 + Spring Boot 4.1 + Spring Modulith 기반, 단일 학생회 플랫폼 - "부서 권한으로 승인 제한" → `config-and-auth.md` 4-4절 (`DepartmentAccessChecker`) - "soft delete 컬럼 인덱스/유니크" → `flyway-migration.md` 3-4절 - "객체를 어떻게 생성하지 / Lombok 어디까지" → `coding-style.md` 2-10·2-11절 +- "외부 클라이언트 구현체가 여러 개인데 일부 메서드가 특정 구현체에서 의미 없음" → `coding-style.md` 2-12절 (`UnsupportedOperationException` + `@ConditionalOnProperty` 스위칭) - "에러 코드 추가" → `error-handling.md` - "도메인 내부 구현 숨기기 / 패키지 구조" → `architecture.md` 4-3절 (`domain/{도메인}/{domain|repository|service|service.impl}`, OPEN 모듈 + ArchUnit) diff --git a/docs/conventions/coding-style.md b/docs/conventions/coding-style.md index ccc5d9b..8dedcde 100644 --- a/docs/conventions/coding-style.md +++ b/docs/conventions/coding-style.md @@ -420,6 +420,53 @@ config.stopBubbling = true lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Qualifier ``` +### 2-12. Client (외부 스토리지·API 클라이언트) + +Repository(2-6절)와 같은 구조다 — `core:domain`에 인터페이스(공개), `infrastructure:client`에 구현체. + +```java +// core:domain:internal — domain/file/client (공개) +public interface FileStorageClient { + UploadUrl issuePresignedUrl(String fileKey, String contentType); + void write(String fileKey, InputStream content); + void deleteObject(String fileKey); +} +``` + +- **구현체가 여러 개이고 그중 일부 메서드가 특정 구현체에서 의미가 없으면, 인터페이스를 쪼개지 않고 그 구현체에서 `UnsupportedOperationException` + 사유 주석으로 막는다.** 인터페이스 분리는 그 구현체가 계속 쓰일 때만 이득이 크다 — 임시 구현체처럼 나중에 통째로 걷어낼 코드라면 지금 쪼개봤자 걷어낼 때 그 분리도 같이 없어진다. + +```java +// infrastructure:client — S3FileStorageClient +// S3는 클라이언트가 presigned URL로 직접 업로드하므로 서버가 파일 바이트를 받을 일이 없다 +@Override +public void write(String fileKey, InputStream content) { + throw new UnsupportedOperationException("S3는 클라이언트가 presigned URL로 직접 업로드하므로 서버가 파일을 받지 않는다"); +} +``` + +- **한 포트에 구현체가 여러 개면 `@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 { ... } +``` + +- **임시 구현체(추후 다른 구현체로 완전히 교체될 코드)에는 "무엇으로 전환하면 이 코드를 지운다"는 클래스 주석을 남긴다.** 그 임시 구현체에 딸린 전용 엔드포인트·메서드(예: 로컬 전용 업로드 수신 API)도 같은 문구로 표시해서, 실제 전환 작업을 할 때 검색 한 번으로 같이 지울 대상을 찾을 수 있게 한다. + +```java +/** + * 로컬 디스크 기반 임시 구현체. S3 연동 시 이 클래스와 "임시 로컬 업로드 엔드포인트"를 함께 제거한다. + */ +@Component +@ConditionalOnProperty(prefix = "file.storage", name = "type", havingValue = "local", matchIfMissing = true) +public class LocalFileStorageClient implements FileStorageClient { ... } +``` + --- ## 3. Validation From 90390ffead69c652017f83bc00f935f3d0ba7534 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Sun, 13 Sep 2026 03:53:11 +0900 Subject: [PATCH 08/11] =?UTF-8?q?refactor:=20File=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=EC=8B=9C=20id=20=EC=97=86=EB=8A=94=20create()=20=EC=A0=95?= =?UTF-8?q?=EC=A0=81=20=ED=8C=A9=ED=86=A0=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stream/internal/domain/file/domain/File.java | 11 +++++++++++ .../domain/file/service/impl/FileServiceImpl.java | 3 +-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/domain/File.java b/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/domain/File.java index e3bc2d2..d2c6cc2 100644 --- a/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/domain/File.java +++ b/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/domain/File.java @@ -18,6 +18,17 @@ public class File { private String contentType; private Long uploaderId; + public static File create( + String fileKey, + FileCategory category, + String originalName, + long fileSize, + String contentType, + Long uploaderId + ) { + return new File(null, fileKey, category, originalName, fileSize, contentType, uploaderId); + } + public static File of( Long id, String fileKey, 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 index dec9aaa..d9c3735 100644 --- 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 @@ -32,8 +32,7 @@ public FileUploadUrlIssueResult issuePresignedUrl(FileUploadUrlIssueCommand comm String fileKey = generateFileKey(command.originalName()); UploadUrl uploadUrl = fileStorageClient.issuePresignedUrl(fileKey, command.contentType()); - File file = File.of( - null, + File file = File.create( fileKey, command.category(), command.originalName(), From 86a043e4088930d4416a2e21393f80b7b8d8e128 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Sun, 13 Sep 2026 03:53:16 +0900 Subject: [PATCH 09/11] =?UTF-8?q?refactor:=20=EB=A1=9C=EC=BB=AC=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20=EC=9E=84=EC=8B=9C=20=EC=97=94?= =?UTF-8?q?=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=EB=A5=BC=20AdminLocalFileU?= =?UTF-8?q?ploadController=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stream/internal/AdminFileController.java | 22 ----------- .../AdminLocalFileUploadController.java | 37 +++++++++++++++++++ 2 files changed, 37 insertions(+), 22 deletions(-) create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/internal/AdminLocalFileUploadController.java diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/internal/AdminFileController.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/internal/AdminFileController.java index 2390eba..2d9d4a9 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/internal/AdminFileController.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/internal/AdminFileController.java @@ -1,8 +1,6 @@ package kr.ac.kookmin.stream.internal; -import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; -import java.io.IOException; import kr.ac.kookmin.stream.ApiResponse; import kr.ac.kookmin.stream.common.PrincipalProvider; import kr.ac.kookmin.stream.internal.domain.file.domain.FileUploadUrlIssueResult; @@ -11,7 +9,6 @@ import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -21,8 +18,6 @@ @RequiredArgsConstructor public class AdminFileController { - private static final String LOCAL_UPLOAD_PATH = "/local-upload/"; - private final FileService fileService; private final PrincipalProvider principalProvider; @@ -37,21 +32,4 @@ public ApiResponse delete(@PathVariable Long fileId) { fileService.delete(fileId); return ApiResponse.success(); } - - /** - * S3 연동 전 임시 엔드포인트. presigned-url 발급 응답의 uploadUrl이 이 경로를 가리킨다. - * S3로 전환하면 이 메서드와 LocalFileStorageClient의 로컬 구현을 함께 제거한다. - */ - @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/internal/AdminLocalFileUploadController.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/internal/AdminLocalFileUploadController.java new file mode 100644 index 0000000..475b788 --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/internal/AdminLocalFileUploadController.java @@ -0,0 +1,37 @@ +package kr.ac.kookmin.stream.internal; + +import jakarta.servlet.http.HttpServletRequest; +import java.io.IOException; +import kr.ac.kookmin.stream.ApiResponse; +import kr.ac.kookmin.stream.internal.domain.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 { + + private static final String LOCAL_UPLOAD_PATH = "/local-upload/"; + + private final FileService fileService; + + @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()); + } +} From 5d6d6b543c504891d8a5216fcf7e36a2b2a72ca1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:54:46 +0900 Subject: [PATCH 10/11] =?UTF-8?q?refactor:=20FileUploadUrlIssueResult=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=EC=9D=84=20of()=20=EC=A0=95=EC=A0=81=20?= =?UTF-8?q?=ED=8C=A9=ED=86=A0=EB=A6=AC=EB=A1=9C=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/file/domain/FileUploadUrlIssueResult.java | 6 +++++- .../internal/domain/file/service/impl/FileServiceImpl.java | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/domain/FileUploadUrlIssueResult.java b/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/domain/FileUploadUrlIssueResult.java index 49d76fd..6e2fe91 100644 --- a/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/domain/FileUploadUrlIssueResult.java +++ b/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/domain/FileUploadUrlIssueResult.java @@ -7,4 +7,8 @@ public record FileUploadUrlIssueResult( String uploadUrl, String fileKey, LocalDateTime expiresAt -) {} +) { + public static FileUploadUrlIssueResult of(File file, UploadUrl uploadUrl) { + return new FileUploadUrlIssueResult(file.getId(), uploadUrl.url(), file.getFileKey(), uploadUrl.expiresAt()); + } +} 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 index d9c3735..9c82c60 100644 --- 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 @@ -42,7 +42,7 @@ public FileUploadUrlIssueResult issuePresignedUrl(FileUploadUrlIssueCommand comm ); File saved = fileRepository.save(file); - return new FileUploadUrlIssueResult(saved.getId(), uploadUrl.url(), fileKey, uploadUrl.expiresAt()); + return FileUploadUrlIssueResult.of(saved, uploadUrl); } @Override From 41d8a2e860f1363dd99e36956873b98ef4059ebf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:54:55 +0900 Subject: [PATCH 11/11] =?UTF-8?q?fix:=20LocalFileStorageProperties?= =?UTF-8?q?=EA=B0=80=20prod=20=ED=94=84=EB=A1=9C=ED=95=84=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=93=B1=EB=A1=9D=EB=90=98=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=A0=9C=ED=95=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stream/client/file/local/LocalFileStorageProperties.java | 2 ++ 1 file changed, 2 insertions(+) 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 64204b2..e0fbde8 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 @@ -1,8 +1,10 @@ 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,