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/internal/file/AdminLocalFileUploadApi.java deleted file mode 100644 index 79b813a3..00000000 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/file/AdminLocalFileUploadApi.java +++ /dev/null @@ -1,25 +0,0 @@ -package kr.ac.kookmin.stream.api.admin.internal.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.internal.domain.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/internal/file/AdminLocalFileUploadController.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/file/AdminLocalFileUploadController.java deleted file mode 100644 index 96d07a3a..00000000 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/file/AdminLocalFileUploadController.java +++ /dev/null @@ -1,38 +0,0 @@ -package kr.ac.kookmin.stream.api.admin.internal.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 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/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/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/AppNoticeController.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/notice/AppNoticeController.java index 559b4dfe..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 @@ -1,11 +1,15 @@ 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.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; import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCursor; @@ -23,6 +27,7 @@ public class AppNoticeController implements AppNoticeApi { private final NoticeService noticeService; + private final FileService fileService; @Override @GetMapping @@ -33,9 +38,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 filesById = fileService.findAllByIdIn(thumbnailIds); + CursorSliceResponse response = CursorSliceResponse.from( result, - NoticeListItemResponse::from + notice -> NoticeListItemResponse.from(notice, filesById) ); return ApiResponse.success(response); @@ -46,6 +58,17 @@ public ApiResponse> getNotices( public ApiResponse getNotice(@PathVariable("noticeId") Long noticeId) { Notice notice = noticeService.getNotice(noticeId); - return ApiResponse.success(NoticeDetailResponse.from(notice)); + Map imagesById = fileService.findAllByIdIn( + notice.getImageIds() == null ? List.of() : notice.getImageIds() + ); + Map attachmentsById = fileService.findAllByIdIn( + notice.getAttachmentIds() == null ? List.of() : notice.getAttachmentIds() + ); + + return ApiResponse.success(NoticeDetailResponse.from(notice, imagesById, attachmentsById)); + } + + 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..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 @@ -2,6 +2,9 @@ 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.file.domain.File; import kr.ac.kookmin.stream.welfare.domain.notice.domain.Notice; import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCategory; @@ -15,13 +18,26 @@ public record NoticeDetailResponse( LocalDateTime createdAt ) { - public static NoticeDetailResponse from(Notice notice) { + /** + * 존재하지 않는(삭제된) fileId는 imagesById/attachmentsById에 키가 없다 — 그런 항목은 목록에서 조용히 제외한다. + */ + public static NoticeDetailResponse from( + Notice notice, + Map imagesById, + Map attachmentsById + ) { List images = notice.getImageIds() == null ? List.of() - : notice.getImageIds().stream().map(Image::from).toList(); + : notice.getImageIds().stream() + .filter(imagesById::containsKey) + .map(fileId -> Image.of(fileId, imagesById.get(fileId))) + .toList(); List attachments = notice.getAttachmentIds() == null ? List.of() - : notice.getAttachmentIds().stream().map(Attachment::from).toList(); + : notice.getAttachmentIds().stream() + .filter(attachmentsById::containsKey) + .map(fileId -> Attachment.of(fileId, attachmentsById.get(fileId))) + .toList(); return new NoticeDetailResponse( notice.getId(), @@ -36,15 +52,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, File file) { + return new Image(fileId, StorageUrlBuilder.build(file.getFileKey())); } } 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, File file) { + 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 8718dd6d..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 @@ -1,6 +1,9 @@ package kr.ac.kookmin.stream.api.app.welfare.notice.response; import java.time.LocalDateTime; +import java.util.Map; +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; @@ -13,13 +16,18 @@ public record NoticeListItemResponse( boolean pinned ) { - public static NoticeListItemResponse from(Notice notice) { + /** 등록된 이미지 중 첫 번째를 썸네일로 쓴다. */ + public static NoticeListItemResponse from(Notice notice, Map filesById) { + Long thumbnailId = notice.getImageIds() == null || notice.getImageIds().isEmpty() + ? null + : notice.getImageIds().get(0); + return new NoticeListItemResponse( notice.getId(), notice.getTitle(), notice.getCategory(), notice.getCreatedAt(), - null, + StorageUrlBuilder.build(thumbnailId, filesById), 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..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 @@ -1,6 +1,7 @@ package kr.ac.kookmin.stream.api.app.welfare.rental; import jakarta.validation.Valid; +import java.util.List; 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; @@ -11,6 +12,7 @@ import kr.ac.kookmin.stream.api.common.dto.CursorSliceResponse; import kr.ac.kookmin.stream.common.CursorSliceResult; 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; @@ -44,16 +46,14 @@ 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)); } @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)); } } 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..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 @@ -1,6 +1,7 @@ package kr.ac.kookmin.stream.api.app.welfare.rental.response; import java.time.format.DateTimeFormatter; +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; @@ -18,13 +19,14 @@ public record ItemListItemResponse( public static ItemListItemResponse from(Item item) { ReturnPolicy returnPolicy = item.getReturnPolicy(); + String imageUrl = StorageUrlBuilder.build(item.getImageKey()); return new ItemListItemResponse( item.getId(), item.getName(), item.getCategory(), item.getType(), item.getCount(), - ItemImageUrl.from(item.getImageKey()), + 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 77fa4693..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 @@ -2,6 +2,7 @@ import java.time.LocalDateTime; import java.util.List; +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; @@ -23,10 +24,11 @@ public record History( public static History from(RentalRecord record) { RentalHistory history = record.history(); + String imageUrl = StorageUrlBuilder.build(record.itemImageKey()); return new History( history.getId(), record.itemName(), - ItemImageUrl.from(record.itemImageKey()), + 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 a17b88ad..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 @@ -2,6 +2,7 @@ import java.time.LocalDateTime; import java.util.List; +import kr.ac.kookmin.stream.api.common.StorageUrlBuilder; import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalRecord; public record ReturnRequiredListResponse(List rentalHistories) { @@ -18,10 +19,11 @@ public record Rental( ) { public static Rental from(RentalRecord record) { + String imageUrl = StorageUrlBuilder.build(record.itemImageKey()); return new Rental( record.history().getId(), record.itemName(), - ItemImageUrl.from(record.itemImageKey()), + imageUrl, record.dueAt() ); } 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/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..90eb44fe --- /dev/null +++ b/api/common-api/src/main/java/kr/ac/kookmin/stream/api/common/StorageUrlBuilder.java @@ -0,0 +1,24 @@ +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 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() {} +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/file/AdminFileApi.java b/api/common-api/src/main/java/kr/ac/kookmin/stream/api/common/file/FileApi.java similarity index 69% rename from api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/file/AdminFileApi.java rename to api/common-api/src/main/java/kr/ac/kookmin/stream/api/common/file/FileApi.java index 14442893..fdc1d155 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/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.internal.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.internal.file.request.FileUploadUrlIssueRequest; -import kr.ac.kookmin.stream.api.admin.internal.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.internal.domain.file.domain.FileErrorCode; +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/internal/file/AdminFileController.java b/api/common-api/src/main/java/kr/ac/kookmin/stream/api/common/file/FileController.java similarity index 72% rename from api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/file/AdminFileController.java rename to api/common-api/src/main/java/kr/ac/kookmin/stream/api/common/file/FileController.java index 2a90962b..f624d214 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/file/AdminFileController.java +++ b/api/common-api/src/main/java/kr/ac/kookmin/stream/api/common/file/FileController.java @@ -1,12 +1,12 @@ -package kr.ac.kookmin.stream.api.admin.internal.file; +package kr.ac.kookmin.stream.api.common.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.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.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; @@ -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/internal/file/request/FileUploadUrlIssueRequest.java b/api/common-api/src/main/java/kr/ac/kookmin/stream/api/common/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/common-api/src/main/java/kr/ac/kookmin/stream/api/common/file/request/FileUploadUrlIssueRequest.java index cc6d8bc8..08c8858c 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/file/request/FileUploadUrlIssueRequest.java +++ b/api/common-api/src/main/java/kr/ac/kookmin/stream/api/common/file/request/FileUploadUrlIssueRequest.java @@ -1,10 +1,10 @@ -package kr.ac.kookmin.stream.api.admin.internal.file.request; +package kr.ac.kookmin.stream.api.common.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/common-api/src/main/java/kr/ac/kookmin/stream/api/common/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/common-api/src/main/java/kr/ac/kookmin/stream/api/common/file/response/FileUploadUrlIssueResponse.java index 1098160c..f00055ad 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/file/response/FileUploadUrlIssueResponse.java +++ b/api/common-api/src/main/java/kr/ac/kookmin/stream/api/common/file/response/FileUploadUrlIssueResponse.java @@ -1,7 +1,7 @@ -package kr.ac.kookmin.stream.api.admin.internal.file.response; +package kr.ac.kookmin.stream.api.common.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..d5e4a4c0 --- /dev/null +++ b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/client/FileStorageClient.java @@ -0,0 +1,8 @@ +package kr.ac.kookmin.stream.file.client; + +import kr.ac.kookmin.stream.file.domain.UploadUrl; + +public interface FileStorageClient { + UploadUrl issuePresignedUrl(String fileKey, String contentType); + void deleteObject(String 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/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/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/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 new file mode 100644 index 00000000..f34418bc --- /dev/null +++ b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/repository/FileRepository.java @@ -0,0 +1,12 @@ +package kr.ac.kookmin.stream.file.repository; + +import java.util.List; +import java.util.Optional; +import kr.ac.kookmin.stream.file.domain.File; + +public interface FileRepository { + Optional findById(Long id); + 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..444c3c8b --- /dev/null +++ b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/FileService.java @@ -0,0 +1,15 @@ +package kr.ac.kookmin.stream.file.service; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import kr.ac.kookmin.stream.file.domain.File; +import kr.ac.kookmin.stream.file.domain.FileUploadUrlIssueCommand; +import kr.ac.kookmin.stream.file.domain.FileUploadUrlIssueResult; + +public interface FileService { + FileUploadUrlIssueResult issuePresignedUrl(FileUploadUrlIssueCommand command); + void delete(Long fileId); + Optional findById(Long fileId); + Map findAllByIdIn(List fileIds); +} diff --git a/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/service/impl/FileServiceImpl.java b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/impl/FileServiceImpl.java similarity index 64% rename from core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/service/impl/FileServiceImpl.java rename to core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/impl/FileServiceImpl.java index 9c82c600..003f0169 100644 --- a/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/service/impl/FileServiceImpl.java +++ b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/impl/FileServiceImpl.java @@ -1,16 +1,20 @@ -package kr.ac.kookmin.stream.internal.domain.file.service.impl; +package kr.ac.kookmin.stream.file.service.impl; -import java.io.InputStream; +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.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 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.FileUploadUrlIssueCommand; +import kr.ac.kookmin.stream.file.domain.FileUploadUrlIssueResult; +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; @@ -45,21 +49,27 @@ public FileUploadUrlIssueResult issuePresignedUrl(FileUploadUrlIssueCommand comm 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); + fileStorageClient.deleteObject(file.getFileKey()); + } + + @Override + public Optional findById(Long fileId) { + return fileRepository.findById(fileId); + } + + @Override + public Map findAllByIdIn(List fileIds) { + if (fileIds.isEmpty()) { + return Map.of(); + } + return fileRepository.findAllByIdIn(fileIds).stream() + .collect(Collectors.toMap(File::getId, Function.identity())); } private String generateFileKey(String originalName) { 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/repository/FileRepository.java b/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/repository/FileRepository.java deleted file mode 100644 index a56b7ff5..00000000 --- a/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/file/repository/FileRepository.java +++ /dev/null @@ -1,11 +0,0 @@ -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 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/docs/conventions/coding-style.md b/docs/conventions/coding-style.md index 893436cd..4df2ec39 100644 --- a/docs/conventions/coding-style.md +++ b/docs/conventions/coding-style.md @@ -486,43 +486,23 @@ 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(); } ``` -- **구현체가 여러 개이고 그중 일부 메서드가 특정 구현체에서 의미가 없으면, 인터페이스를 쪼개지 않고 그 구현체에서 `UnsupportedOperationException` + 사유 주석으로 막는다.** 인터페이스 분리는 그 구현체가 계속 쓰일 때만 이득이 크다 — 임시 구현체처럼 나중에 통째로 걷어낼 코드라면 지금 쪼개봤자 걷어낼 때 그 분리도 같이 없어진다. +- **구현체가 여러 개이고 그중 일부 메서드가 특정 구현체에서 의미가 없으면, 인터페이스를 쪼개지 않고 그 구현체에서 `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 { ... } +- **한 포트에 구현체가 여러 개면 `@ConditionalOnProperty`로 하나만 Bean으로 띄운다**(`@Profile`이 아니라 — 로컬/운영을 나누는 게 아니라 같은 환경 안에서 설정값으로 고르는 것이므로). 구현체가 하나뿐이면 이 어노테이션 자체가 필요 없다 — 나중에 두 번째 구현체가 생기는 시점에 다시 붙인다. -@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 +513,6 @@ public class S3StorageConfig { } @Component -@ConditionalOnProperty(prefix = "file.storage", name = "type", havingValue = "s3") @RequiredArgsConstructor public class S3FileStorageClient implements FileStorageClient { @@ -543,16 +522,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`가 이렇게 표시돼 있었고, 실제로 연동 후 그 주석 그대로 함께 제거됐다.) --- 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 deleted file mode 100644 index 8aaf2411..00000000 --- a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/local/LocalFileStorageClient.java +++ /dev/null @@ -1,58 +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.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 { - - 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); - } - } - - 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 e0fbde80..00000000 --- a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/file/local/LocalFileStorageProperties.java +++ /dev/null @@ -1,12 +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, - 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..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 @@ -1,13 +1,11 @@ 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 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; @@ -17,12 +15,10 @@ 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 -@ConditionalOnProperty(prefix = "file.storage", name = "type", havingValue = "s3") @RequiredArgsConstructor public class S3FileStorageClient implements FileStorageClient { @@ -48,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/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 95f32b32..7f0a6b85 100644 --- a/infrastructure/client/src/main/resources/application-infrastructure-client.yml +++ b/infrastructure/client/src/main/resources/application-infrastructure-client.yml @@ -1,10 +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} - upload-url-expiry-seconds: ${LOCAL_STORAGE_UPLOAD_URL_EXPIRY_SECONDS:600} s3: bucket: ${R2_BUCKET:} region: ${R2_REGION:auto} 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 53% 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..9b61d699 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,8 @@ -package kr.ac.kookmin.stream.db.internal; +package kr.ac.kookmin.stream.db.file; -import java.util.Optional; +import java.util.List; 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 67% 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..2e1fe512 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; @@ -18,8 +19,8 @@ public Optional findById(Long id) { } @Override - public Optional findByFileKey(String fileKey) { - return fileJpaRepository.findByFileKey(fileKey).map(FileJpaEntity::toDomain); + public List findAllByIdIn(List ids) { + return fileJpaRepository.findAllByIdIn(ids).stream().map(FileJpaEntity::toDomain).toList(); } @Override 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",