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",