From c21b63f5bbd3509fb8a2dae016622e8a06d23461 Mon Sep 17 00:00:00 2001 From: jjunh33 Date: Mon, 19 Oct 2026 01:23:37 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EC=8B=9C=EA=B0=81=EA=B3=BC=20epoch?= =?UTF-8?q?=20=EB=B0=80=EB=A6=AC=EC=B4=88=20=EB=B3=80=ED=99=98=EC=9D=84=20?= =?UTF-8?q?DateUtil=EC=97=90=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kr/ac/kookmin/stream/common/DateUtil.java | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/core/common/src/main/java/kr/ac/kookmin/stream/common/DateUtil.java b/core/common/src/main/java/kr/ac/kookmin/stream/common/DateUtil.java index 948a8cc8..1fa1355d 100644 --- a/core/common/src/main/java/kr/ac/kookmin/stream/common/DateUtil.java +++ b/core/common/src/main/java/kr/ac/kookmin/stream/common/DateUtil.java @@ -1,6 +1,8 @@ package kr.ac.kookmin.stream.common; +import java.time.Instant; import java.time.LocalDate; +import java.time.LocalDateTime; import java.time.ZoneId; import lombok.AccessLevel; import lombok.NoArgsConstructor; @@ -32,4 +34,19 @@ public static LocalDate startOfYear(Integer year) { public static LocalDate startOfNextYear(Integer year) { return year == null ? null : LocalDate.of(year + 1, FIRST_MONTH, FIRST_DAY); } + + /** + * DB의 시각(한국 시간 기준 {@code LocalDateTime})을 epoch 밀리초로 바꾼다. 시각을 클라이언트에 내려줄 때 쓰고, + * 돌려받는 쪽은 {@link #fromEpochMillis(long)}로 되돌린다. 두 방향이 같은 기준(KST)이어야 값이 어긋나지 않는다. + */ + public static long toEpochMillis(LocalDateTime dateTime) { + return dateTime.atZone(KST).toInstant().toEpochMilli(); + } + + /** + * {@link #toEpochMillis(LocalDateTime)}의 반대 방향. epoch 밀리초를 한국 시간 기준 {@code LocalDateTime}으로 바꾼다. + */ + public static LocalDateTime fromEpochMillis(long epochMillis) { + return LocalDateTime.ofInstant(Instant.ofEpochMilli(epochMillis), KST); + } } From 8055502f48d4e1f243ec9356416b6a528f304fd7 Mon Sep 17 00:00:00 2001 From: jjunh33 Date: Mon, 19 Oct 2026 01:23:37 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=EA=B3=B5=EC=A7=80=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1=C2=B7=EC=88=98=EC=A0=95=EC=97=90=20=EA=B3=A0=EC=A0=95?= =?UTF-8?q?=20=EC=97=AC=EB=B6=80=20=EB=B0=98=EC=98=81,=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=EC=9E=90=20=EB=AA=A9=EB=A1=9D=C2=B7=EC=83=81=EC=84=B8?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 관리자 웹의 공지 폼이 고정 여부를 등록·수정과 함께 보내고, 수정 화면이 기존 공지와 수정 시각을 불러와야 해서 맞춘다. --- .../admin/welfare/notice/AdminNoticeApi.java | 31 ++++++- .../welfare/notice/AdminNoticeController.java | 27 ++++++ .../notice/request/NoticeCreateRequest.java | 9 +- .../notice/request/NoticeUpdateRequest.java | 9 +- .../response/AdminNoticeDetailResponse.java | 83 +++++++++++++++++++ .../response/AdminNoticeListItemResponse.java | 24 ++++++ .../notice/usecase/AdminNoticeDetail.java | 16 ++++ .../usecase/AdminNoticeReadUseCase.java | 33 ++++++++ .../welfare/domain/notice/domain/Notice.java | 5 +- .../notice/domain/NoticeCreateCommand.java | 3 +- .../notice/domain/NoticeUpdateCommand.java | 1 + .../notice/repository/NoticeRepository.java | 9 ++ .../domain/notice/service/NoticeService.java | 9 ++ .../service/impl/NoticeServiceImpl.java | 10 +++ .../db/welfare/NoticeJpaRepository.java | 15 ++++ .../db/welfare/NoticeRepositoryImpl.java | 18 ++++ 16 files changed, 290 insertions(+), 12 deletions(-) create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/notice/response/AdminNoticeDetailResponse.java create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/notice/response/AdminNoticeListItemResponse.java create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/notice/usecase/AdminNoticeDetail.java create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/notice/usecase/AdminNoticeReadUseCase.java diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/notice/AdminNoticeApi.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/notice/AdminNoticeApi.java index 4d00a8a0..edfba876 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/notice/AdminNoticeApi.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/notice/AdminNoticeApi.java @@ -6,34 +6,57 @@ import kr.ac.kookmin.stream.api.admin.welfare.notice.request.NoticeCreateRequest; import kr.ac.kookmin.stream.api.admin.welfare.notice.request.NoticePinRequest; import kr.ac.kookmin.stream.api.admin.welfare.notice.request.NoticeUpdateRequest; +import kr.ac.kookmin.stream.api.admin.welfare.notice.response.AdminNoticeDetailResponse; +import kr.ac.kookmin.stream.api.admin.welfare.notice.response.AdminNoticeListItemResponse; import kr.ac.kookmin.stream.api.admin.welfare.notice.response.NoticeCreateResponse; import kr.ac.kookmin.stream.api.admin.welfare.notice.response.NoticeUpdateResponse; import kr.ac.kookmin.stream.api.common.dto.ApiResponse; +import kr.ac.kookmin.stream.api.common.dto.PageParams; +import kr.ac.kookmin.stream.api.common.dto.PageResponse; import kr.ac.kookmin.stream.api.common.openapi.ApiErrorCode; import kr.ac.kookmin.stream.common.CommonErrorCode; import kr.ac.kookmin.stream.file.domain.FileErrorCode; import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeErrorCode; +import org.springdoc.core.annotations.ParameterObject; /** * 운영진 공지 API의 문서 명세. 구현은 {@link AdminNoticeController}가 맡는다. *

* 접근은 {@code ADMIN} role이면 된다. 부서 제한은 두지 않았다. */ -@Tag(name = "공지 관리", description = "운영진 공지 작성·수정·삭제·고정") +@Tag(name = "공지 관리", description = "운영진 공지 조회·작성·수정·삭제·고정") public interface AdminNoticeApi { + /** 공지 목록. 제목으로 검색하고 고정 공지를 먼저, 그다음 최신순으로 페이지 단위 조회한다. */ + @Operation(summary = "공지 목록 조회", + description = "삭제되지 않은 공지를 고정 공지 우선, 최신순으로 페이지 단위로 조회한다. keyword를 주면 제목에 그 글자가 " + + "들어 있는 공지만 조회한다. page는 0부터 시작한다.") + @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) + ApiResponse> getNotices( + AdminApiUser apiUser, + String keyword, + @ParameterObject PageParams pageParams + ); + + /** 공지 상세. 수정 화면이 이 값을 불러와 채우고, 수정할 때 updatedAt을 그대로 돌려 보낸다. */ + @Operation(summary = "공지 상세 조회", + description = "공지 한 건을 조회한다. 수정 화면에 필요한 고정 여부, 이미지·첨부 파일의 ID와 URL, 수정 시각(updatedAt)을 " + + "함께 내려준다. 삭제된 파일은 목록에서 빠진다.") + @ApiErrorCode(type = NoticeErrorCode.class, codes = {"NOTICE_NOT_FOUND"}) + ApiResponse getNotice(AdminApiUser apiUser, Long noticeId); + /** 공지 작성. 작성자는 토큰의 관리자로 정해진다. */ @Operation(summary = "공지 작성", description = "공지를 등록한다. 이미지·첨부 파일은 업로드를 마친 파일 ID로 넘기며, 존재하지 않는 파일이 하나라도 있으면 " - + "등록하지 않는다. 새 공지는 고정되지 않은 상태로 만들어진다.") + + "등록하지 않는다. 고정 여부(pinned)도 함께 정한다.") @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) @ApiErrorCode(type = FileErrorCode.class, codes = {"FILE_NOT_FOUND"}) ApiResponse createNotice(AdminApiUser apiUser, NoticeCreateRequest request); /** 공지 수정. 요청의 수정 시각이 저장된 값과 다르면 거부한다. */ @Operation(summary = "공지 수정", - description = "공지의 제목·본문·종류·이미지·첨부 파일을 통째로 바꾼다. 수정 화면이 공지를 읽은 뒤 다른 관리자가 먼저 " - + "고쳤다면(수정 시각 불일치) 409로 거부한다.") + description = "공지의 제목·본문·종류·이미지·첨부 파일·고정 여부를 통째로 바꾼다. 수정 화면이 공지를 읽은 뒤 다른 관리자가 " + + "먼저 고쳤다면(수정 시각 불일치) 409로 거부한다. 상세 조회에서 받은 updatedAt을 그대로 보낸다.") @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) @ApiErrorCode(type = FileErrorCode.class, codes = {"FILE_NOT_FOUND"}) @ApiErrorCode(type = NoticeErrorCode.class, codes = {"NOTICE_NOT_FOUND", "NOTICE_UPDATE_CONFLICT"}) diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/notice/AdminNoticeController.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/notice/AdminNoticeController.java index bd84a8ec..3e6f8aea 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/notice/AdminNoticeController.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/notice/AdminNoticeController.java @@ -5,20 +5,29 @@ import kr.ac.kookmin.stream.api.admin.welfare.notice.request.NoticeCreateRequest; import kr.ac.kookmin.stream.api.admin.welfare.notice.request.NoticePinRequest; import kr.ac.kookmin.stream.api.admin.welfare.notice.request.NoticeUpdateRequest; +import kr.ac.kookmin.stream.api.admin.welfare.notice.response.AdminNoticeDetailResponse; +import kr.ac.kookmin.stream.api.admin.welfare.notice.response.AdminNoticeListItemResponse; import kr.ac.kookmin.stream.api.admin.welfare.notice.response.NoticeCreateResponse; import kr.ac.kookmin.stream.api.admin.welfare.notice.response.NoticeUpdateResponse; +import kr.ac.kookmin.stream.api.admin.welfare.notice.usecase.AdminNoticeReadUseCase; import kr.ac.kookmin.stream.api.admin.welfare.notice.usecase.AdminNoticeWriteUseCase; import kr.ac.kookmin.stream.api.common.dto.ApiResponse; +import kr.ac.kookmin.stream.api.common.dto.PageParams; +import kr.ac.kookmin.stream.api.common.dto.PageResponse; +import kr.ac.kookmin.stream.common.PageResult; import kr.ac.kookmin.stream.welfare.domain.notice.domain.Notice; import kr.ac.kookmin.stream.welfare.domain.notice.service.NoticeService; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @RestController @@ -27,8 +36,26 @@ public class AdminNoticeController implements AdminNoticeApi { private final NoticeService noticeService; + private final AdminNoticeReadUseCase adminNoticeReadUseCase; private final AdminNoticeWriteUseCase adminNoticeWriteUseCase; + @Override + @GetMapping + public ApiResponse> getNotices( + AdminApiUser apiUser, + @RequestParam(required = false) String keyword, + @Valid @ModelAttribute PageParams pageParams + ) { + PageResult result = noticeService.search(keyword, pageParams.toOffset()); + return ApiResponse.success(PageResponse.from(result, AdminNoticeListItemResponse::from)); + } + + @Override + @GetMapping("/{noticeId}") + public ApiResponse getNotice(AdminApiUser apiUser, @PathVariable Long noticeId) { + return ApiResponse.success(AdminNoticeDetailResponse.from(adminNoticeReadUseCase.getDetail(noticeId))); + } + @Override @PostMapping public ApiResponse createNotice( diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/notice/request/NoticeCreateRequest.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/notice/request/NoticeCreateRequest.java index 11c1a912..5f201001 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/notice/request/NoticeCreateRequest.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/notice/request/NoticeCreateRequest.java @@ -33,7 +33,11 @@ public record NoticeCreateRequest( @Schema(description = "첨부 파일 ID 목록", example = "[201]") @NotNull(message = "첨부 파일 목록을 입력해 주세요.") - List<@NotNull(message = "첨부 파일 ID는 비워 둘 수 없습니다.") Long> attachmentFileIds + List<@NotNull(message = "첨부 파일 ID는 비워 둘 수 없습니다.") Long> attachmentFileIds, + + @Schema(description = "상단 고정 여부", example = "false") + @NotNull(message = "고정 여부를 입력해 주세요.") + Boolean pinned ) { public NoticeCreateCommand toCommand() { @@ -42,7 +46,8 @@ public NoticeCreateCommand toCommand() { content, category, imageFileIds.stream().distinct().toList(), - attachmentFileIds.stream().distinct().toList() + attachmentFileIds.stream().distinct().toList(), + pinned ); } } diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/notice/request/NoticeUpdateRequest.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/notice/request/NoticeUpdateRequest.java index 64ed9905..fbabd8a9 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/notice/request/NoticeUpdateRequest.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/notice/request/NoticeUpdateRequest.java @@ -4,8 +4,6 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; -import java.time.Instant; -import java.time.LocalDateTime; import java.util.List; import kr.ac.kookmin.stream.common.DateUtil; import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCategory; @@ -35,6 +33,10 @@ public record NoticeUpdateRequest( @NotNull(message = "첨부 파일 목록을 입력해 주세요.") List<@NotNull(message = "첨부 파일 ID는 비워 둘 수 없습니다.") Long> attachmentFileIds, + @Schema(description = "상단 고정 여부", example = "false") + @NotNull(message = "고정 여부를 입력해 주세요.") + Boolean pinned, + @Schema(description = "수정 화면이 공지를 읽었을 때의 수정 시각(KST 기준 epoch 밀리초). 그 사이 다른 관리자가 고쳤다면 409", example = "1756339200000") @NotNull(message = "수정 시각을 입력해 주세요.") @@ -48,7 +50,8 @@ public NoticeUpdateCommand toCommand() { category, imageFileIds.stream().distinct().toList(), attachmentFileIds.stream().distinct().toList(), - LocalDateTime.ofInstant(Instant.ofEpochMilli(updatedAt), DateUtil.KST) + pinned, + DateUtil.fromEpochMillis(updatedAt) ); } } diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/notice/response/AdminNoticeDetailResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/notice/response/AdminNoticeDetailResponse.java new file mode 100644 index 00000000..2e9aef0b --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/notice/response/AdminNoticeDetailResponse.java @@ -0,0 +1,83 @@ +package kr.ac.kookmin.stream.api.admin.welfare.notice.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import kr.ac.kookmin.stream.api.admin.welfare.notice.usecase.AdminNoticeDetail; +import kr.ac.kookmin.stream.api.common.StorageUrlBuilder; +import kr.ac.kookmin.stream.common.DateUtil; +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; + +/** + * @param images 수정할 때 {@code imageFileIds}로 되돌려 보낼 수 있도록 파일 ID를 함께 내려준다. 저장된 순서 그대로이고 첫 번째가 썸네일이다 + * @param updatedAt 수정할 때 그대로 되돌려 보내는 수정 시각(KST 기준 epoch 밀리초). 그 사이 다른 관리자가 고쳤는지 가려내는 데 쓴다 + */ +public record AdminNoticeDetailResponse( + Long noticeId, + String title, + String content, + NoticeCategory category, + boolean pinned, + List images, + List attachments, + LocalDateTime createdAt, + @Schema(description = "수정 시각(KST 기준 epoch 밀리초). 수정 요청의 updatedAt에 그대로 보낸다", example = "1756339200000") + long updatedAt +) { + + /** + * 존재하지 않는(삭제된) 파일은 조용히 뺀다. 학생 앱 상세와 같은 정책이다. + */ + public static AdminNoticeDetailResponse from(AdminNoticeDetail detail) { + Notice notice = detail.notice(); + + return new AdminNoticeDetailResponse( + notice.getId(), + notice.getTitle(), + notice.getContent(), + notice.getCategory(), + notice.isPinned(), + toImages(notice.getImageIds(), detail.imagesById()), + toAttachments(notice.getAttachmentIds(), detail.attachmentsById()), + notice.getCreatedAt(), + DateUtil.toEpochMillis(notice.getUpdatedAt()) + ); + } + + private static List toImages(List fileIds, Map filesById) { + if (fileIds == null) { + return List.of(); + } + return fileIds.stream() + .filter(filesById::containsKey) + .map(fileId -> Image.of(fileId, filesById.get(fileId))) + .toList(); + } + + private static List toAttachments(List fileIds, Map filesById) { + if (fileIds == null) { + return List.of(); + } + return fileIds.stream() + .filter(filesById::containsKey) + .map(fileId -> Attachment.of(fileId, filesById.get(fileId))) + .toList(); + } + + public record Image(Long fileId, String fileUrl) { + + 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 of(Long fileId, File file) { + return new Attachment(fileId, file.getOriginalName(), StorageUrlBuilder.build(file.getFileKey())); + } + } +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/notice/response/AdminNoticeListItemResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/notice/response/AdminNoticeListItemResponse.java new file mode 100644 index 00000000..5fc9f0ce --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/notice/response/AdminNoticeListItemResponse.java @@ -0,0 +1,24 @@ +package kr.ac.kookmin.stream.api.admin.welfare.notice.response; + +import java.time.LocalDateTime; +import kr.ac.kookmin.stream.welfare.domain.notice.domain.Notice; +import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCategory; + +public record AdminNoticeListItemResponse( + Long noticeId, + String title, + NoticeCategory category, + boolean pinned, + LocalDateTime createdAt +) { + + public static AdminNoticeListItemResponse from(Notice notice) { + return new AdminNoticeListItemResponse( + notice.getId(), + notice.getTitle(), + notice.getCategory(), + notice.isPinned(), + notice.getCreatedAt() + ); + } +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/notice/usecase/AdminNoticeDetail.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/notice/usecase/AdminNoticeDetail.java new file mode 100644 index 00000000..c6c5e808 --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/notice/usecase/AdminNoticeDetail.java @@ -0,0 +1,16 @@ +package kr.ac.kookmin.stream.api.admin.welfare.notice.usecase; + +import java.util.Map; +import kr.ac.kookmin.stream.file.domain.File; +import kr.ac.kookmin.stream.welfare.domain.notice.domain.Notice; + +/** + * 운영진용 공지 상세. 공지에 그 공지가 연결한 파일 정보를 붙인 조회용 조합 객체다. + * 존재하지 않는(삭제된) 파일은 맵에 키가 없다. + */ +public record AdminNoticeDetail( + Notice notice, + Map imagesById, + Map attachmentsById +) { +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/notice/usecase/AdminNoticeReadUseCase.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/notice/usecase/AdminNoticeReadUseCase.java new file mode 100644 index 00000000..589e567a --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/notice/usecase/AdminNoticeReadUseCase.java @@ -0,0 +1,33 @@ +package kr.ac.kookmin.stream.api.admin.welfare.notice.usecase; + +import java.util.List; +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.service.NoticeService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/** + * 운영진 공지 상세를 조회한다. 공지(welfare)와 그 공지가 연결한 파일(file)은 서로 다른 도메인이라 이 UseCase가 두 Service를 조합한다. + */ +@Component +@RequiredArgsConstructor +public class AdminNoticeReadUseCase { + + private final NoticeService noticeService; + private final FileService fileService; + + public AdminNoticeDetail getDetail(Long noticeId) { + Notice notice = noticeService.getNotice(noticeId); + + return new AdminNoticeDetail( + notice, + fileService.findAllByIdIn(orEmpty(notice.getImageIds())), + fileService.findAllByIdIn(orEmpty(notice.getAttachmentIds())) + ); + } + + private List orEmpty(List ids) { + return ids == null ? List.of() : ids; + } +} diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/domain/Notice.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/domain/Notice.java index bd7f2ccf..e4f5baaa 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/domain/Notice.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/domain/Notice.java @@ -38,14 +38,14 @@ public static Notice of( return new Notice(id, title, content, category, pinned, createdBy, attachmentIds, imageIds, createdAt, updatedAt); } - // 새 공지는 고정되지 않은 채로 등록한다. 생성·수정 시각은 DB가 채운다 + // 생성·수정 시각은 DB가 채운다 public static Notice create(Long createdBy, NoticeCreateCommand command) { return new Notice( null, command.title(), command.content(), command.category(), - false, + command.pinned(), createdBy, command.attachmentIds(), command.imageIds(), @@ -60,6 +60,7 @@ public void update(NoticeUpdateCommand command) { this.category = command.category(); this.attachmentIds = command.attachmentIds(); this.imageIds = command.imageIds(); + this.pinned = command.pinned(); } public void changePinned(boolean pinned) { diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/domain/NoticeCreateCommand.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/domain/NoticeCreateCommand.java index 4979a048..833dd500 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/domain/NoticeCreateCommand.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/domain/NoticeCreateCommand.java @@ -7,6 +7,7 @@ public record NoticeCreateCommand( String content, NoticeCategory category, List imageIds, - List attachmentIds + List attachmentIds, + boolean pinned ) { } diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/domain/NoticeUpdateCommand.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/domain/NoticeUpdateCommand.java index 7082ae32..90ea1d41 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/domain/NoticeUpdateCommand.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/domain/NoticeUpdateCommand.java @@ -12,6 +12,7 @@ public record NoticeUpdateCommand( NoticeCategory category, List imageIds, List attachmentIds, + boolean pinned, LocalDateTime expectedUpdatedAt ) { } diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/repository/NoticeRepository.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/repository/NoticeRepository.java index 1697d3ed..204b0f1a 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/repository/NoticeRepository.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/repository/NoticeRepository.java @@ -2,12 +2,21 @@ import java.util.Optional; import kr.ac.kookmin.stream.common.CursorSliceResult; +import kr.ac.kookmin.stream.common.PageOffset; +import kr.ac.kookmin.stream.common.PageResult; 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; public interface NoticeRepository { CursorSliceResult findAll(NoticeCategory category, NoticeCursor cursor, int size); + + /** + * 삭제되지 않은 공지를 고정 우선, 최신순으로 페이지 단위로 조회한다. 제목에 {@code keyword}가 들어 있는 공지만 고르고 + * {@code keyword}가 null이면 조건 없이 모두 조회한다. + */ + PageResult search(String keyword, PageOffset pageOffset); + Optional findById(Long id); Notice save(Notice notice); diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/service/NoticeService.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/service/NoticeService.java index 6e1e1af4..e4c8a77b 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/service/NoticeService.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/service/NoticeService.java @@ -1,6 +1,8 @@ package kr.ac.kookmin.stream.welfare.domain.notice.service; import kr.ac.kookmin.stream.common.CursorSliceResult; +import kr.ac.kookmin.stream.common.PageOffset; +import kr.ac.kookmin.stream.common.PageResult; 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.NoticeCreateCommand; @@ -9,6 +11,13 @@ public interface NoticeService { CursorSliceResult getNotices(NoticeCategory category, NoticeCursor cursor, int size); + + /** + * 운영진용 공지 목록. 제목 검색어({@code keyword})로 거르고 고정 우선, 최신순으로 페이지 단위 조회한다. + * 검색어가 null이거나 공백이면 거르지 않는다. + */ + PageResult search(String keyword, PageOffset pageOffset); + Notice getNotice(Long id); Notice create(Long createdBy, NoticeCreateCommand command); diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/service/impl/NoticeServiceImpl.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/service/impl/NoticeServiceImpl.java index 8597370c..801ff4a4 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/service/impl/NoticeServiceImpl.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/service/impl/NoticeServiceImpl.java @@ -5,6 +5,8 @@ import kr.ac.kookmin.stream.common.BusinessException; import kr.ac.kookmin.stream.common.CursorSliceResult; import kr.ac.kookmin.stream.common.OutboxWriter; +import kr.ac.kookmin.stream.common.PageOffset; +import kr.ac.kookmin.stream.common.PageResult; import kr.ac.kookmin.stream.common.event.NoticeCreated; import kr.ac.kookmin.stream.welfare.domain.notice.domain.Notice; import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCategory; @@ -35,6 +37,14 @@ public CursorSliceResult getNotices(NoticeCategory category, NoticeCurso return noticeRepository.findAll(category, cursor, size); } + // 목록과 전체 개수를 두 번 조회하므로 같은 스냅샷에서 읽도록 읽기 트랜잭션으로 묶는다 + @Override + @Transactional(readOnly = true) + public PageResult search(String keyword, PageOffset pageOffset) { + String normalizedKeyword = keyword == null || keyword.isBlank() ? null : keyword.trim(); + return noticeRepository.search(normalizedKeyword, pageOffset); + } + @Override @Transactional(readOnly = true) public Notice getNotice(Long id) { diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/NoticeJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/NoticeJpaRepository.java index 9ee415e8..7c91b4a5 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/NoticeJpaRepository.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/NoticeJpaRepository.java @@ -4,6 +4,7 @@ import java.util.List; import java.util.Optional; import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCategory; +import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; @@ -13,6 +14,20 @@ public interface NoticeJpaRepository extends JpaRepository findByIdAndIsDeletedFalse(Long id); + // LIKE 대신 LOCATE를 써서 %·_ 같은 문자도 와일드카드가 아닌 글자 그대로 검색된다 + @Query(value = """ + SELECT n FROM NoticeJpaEntity n + WHERE n.isDeleted = false + AND (:keyword IS NULL OR LOCATE(:keyword, n.title) > 0) + ORDER BY n.pinned DESC, n.createdAt DESC, n.id DESC + """, + countQuery = """ + SELECT COUNT(n) FROM NoticeJpaEntity n + WHERE n.isDeleted = false + AND (:keyword IS NULL OR LOCATE(:keyword, n.title) > 0) + """) + Page search(@Param("keyword") String keyword, Pageable pageable); + @Query(""" SELECT n FROM NoticeJpaEntity n WHERE n.isDeleted = false diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/NoticeRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/NoticeRepositoryImpl.java index aba0e249..84aadef8 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/NoticeRepositoryImpl.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/NoticeRepositoryImpl.java @@ -3,11 +3,15 @@ import java.util.List; import java.util.Optional; import kr.ac.kookmin.stream.common.CursorSliceResult; +import kr.ac.kookmin.stream.common.PageOffset; +import kr.ac.kookmin.stream.common.PageResult; 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; import kr.ac.kookmin.stream.welfare.domain.notice.repository.NoticeRepository; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Repository; @@ -29,6 +33,20 @@ public CursorSliceResult findAll(NoticeCategory category, NoticeCursor c ); } + @Override + public PageResult search(String keyword, PageOffset pageOffset) { + Page result = noticeJpaRepository.search( + keyword, PageRequest.of(pageOffset.page(), pageOffset.size()) + ); + + return PageResult.of( + result.getContent().stream().map(NoticeJpaEntity::toDomain).toList(), + result.getNumber(), + result.getSize(), + result.getTotalElements() + ); + } + @Override public Optional findById(Long id) { return noticeJpaRepository.findByIdAndIsDeletedFalse(id).map(NoticeJpaEntity::toDomain);