From c21b63f5bbd3509fb8a2dae016622e8a06d23461 Mon Sep 17 00:00:00 2001 From: jjunh33 Date: Mon, 19 Oct 2026 01:23:37 +0900 Subject: [PATCH 01/15] =?UTF-8?q?feat:=20=EC=8B=9C=EA=B0=81=EA=B3=BC=20epo?= =?UTF-8?q?ch=20=EB=B0=80=EB=A6=AC=EC=B4=88=20=EB=B3=80=ED=99=98=EC=9D=84?= =?UTF-8?q?=20DateUtil=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 02/15] =?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); From a08fa9fd7a731e886cdaf2d3573e196f2beb8c37 Mon Sep 17 00:00:00 2001 From: jjunh33 Date: Thu, 22 Oct 2026 16:07:16 +0900 Subject: [PATCH 03/15] =?UTF-8?q?refactor:=20=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=20=EA=B3=B5=EC=A7=80=20=EA=B3=A0=EC=A0=95=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=20API=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/admin/welfare/notice/AdminNoticeApi.java | 10 +--------- .../admin/welfare/notice/AdminNoticeController.java | 13 ------------- .../welfare/notice/request/NoticePinRequest.java | 11 ----------- .../stream/welfare/domain/notice/domain/Notice.java | 4 ---- .../domain/notice/service/NoticeService.java | 1 - .../notice/service/impl/NoticeServiceImpl.java | 8 -------- 6 files changed, 1 insertion(+), 46 deletions(-) delete mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/notice/request/NoticePinRequest.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 edfba876..0dabe9ef 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 @@ -4,7 +4,6 @@ import io.swagger.v3.oas.annotations.tags.Tag; import kr.ac.kookmin.stream.api.admin.AdminApiUser; 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; @@ -24,7 +23,7 @@ *

* 접근은 {@code ADMIN} role이면 된다. 부서 제한은 두지 않았다. */ -@Tag(name = "공지 관리", description = "운영진 공지 조회·작성·수정·삭제·고정") +@Tag(name = "공지 관리", description = "운영진 공지 작성·수정·삭제") public interface AdminNoticeApi { /** 공지 목록. 제목으로 검색하고 고정 공지를 먼저, 그다음 최신순으로 페이지 단위 조회한다. */ @@ -67,11 +66,4 @@ ApiResponse> getNotices( description = "공지를 삭제한다. 데이터는 남기고 학생 앱에서만 보이지 않게 한다. 연결된 파일은 지우지 않는다.") @ApiErrorCode(type = NoticeErrorCode.class, codes = {"NOTICE_NOT_FOUND"}) ApiResponse deleteNotice(AdminApiUser apiUser, Long noticeId); - - /** 공지 상단 고정 설정/해제. */ - @Operation(summary = "공지 고정 설정/해제", - description = "공지를 목록 맨 위에 고정하거나 고정을 해제한다. 이미 그 상태여도 성공으로 응답한다.") - @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) - @ApiErrorCode(type = NoticeErrorCode.class, codes = {"NOTICE_NOT_FOUND"}) - ApiResponse changePinned(AdminApiUser apiUser, Long noticeId, NoticePinRequest request); } 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 3e6f8aea..623c1c79 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 @@ -3,7 +3,6 @@ import jakarta.validation.Valid; import kr.ac.kookmin.stream.api.admin.AdminApiUser; 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; @@ -21,7 +20,6 @@ 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; @@ -83,15 +81,4 @@ public ApiResponse deleteNotice(AdminApiUser apiUser, @PathVariable Long n noticeService.delete(noticeId); return ApiResponse.success(); } - - @Override - @PatchMapping("/{noticeId}/pin") - public ApiResponse changePinned( - AdminApiUser apiUser, - @PathVariable Long noticeId, - @Valid @RequestBody NoticePinRequest request - ) { - noticeService.changePinned(noticeId, request.pinned()); - return ApiResponse.success(); - } } diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/notice/request/NoticePinRequest.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/notice/request/NoticePinRequest.java deleted file mode 100644 index 16b0a747..00000000 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/notice/request/NoticePinRequest.java +++ /dev/null @@ -1,11 +0,0 @@ -package kr.ac.kookmin.stream.api.admin.welfare.notice.request; - -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotNull; - -public record NoticePinRequest( - @Schema(description = "고정 여부. true면 목록 맨 위에 고정하고 false면 해제한다", example = "true") - @NotNull(message = "고정 여부를 입력해 주세요.") - Boolean pinned -) { -} 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 e4f5baaa..011dc96e 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 @@ -62,8 +62,4 @@ public void update(NoticeUpdateCommand command) { this.imageIds = command.imageIds(); this.pinned = command.pinned(); } - - public void changePinned(boolean pinned) { - this.pinned = pinned; - } } 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 e4c8a77b..18682763 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 @@ -28,5 +28,4 @@ public interface NoticeService { Notice update(Long id, NoticeUpdateCommand command); void delete(Long id); - void changePinned(Long id, boolean pinned); } 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 801ff4a4..c0d37333 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 @@ -79,14 +79,6 @@ public void delete(Long id) { } } - @Override - @Transactional - public void changePinned(Long id, boolean pinned) { - Notice notice = getNotice(id); - notice.changePinned(pinned); - noticeRepository.save(notice); - } - // updated_at이 초 단위 DATETIME이라 같은 초로 맞춰 비교한다. 수정 화면이 읽은 뒤 고쳐졌다면 시각이 달라진다 private void validateNotModified(Notice notice, LocalDateTime expectedUpdatedAt) { LocalDateTime current = notice.getUpdatedAt().truncatedTo(ChronoUnit.SECONDS); From 2f2e5ede23b7669ae39ad90458202e1743f48c43 Mon Sep 17 00:00:00 2001 From: jjunh33 Date: Thu, 22 Oct 2026 16:08:06 +0900 Subject: [PATCH 04/15] =?UTF-8?q?refactor:=20=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=20=EA=B3=B5=EC=A7=80=20=EB=AA=A9=EB=A1=9D=C2=B7=EC=83=81?= =?UTF-8?q?=EC=84=B8=EB=A5=BC=20=EC=9D=BC=EB=B0=98=20=EA=B3=B5=EC=A7=80=20?= =?UTF-8?q?API=EB=A1=9C=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/welfare/notice/AdminNoticeApi.java | 29 ++----- .../welfare/notice/AdminNoticeController.java | 27 ------ .../response/AdminNoticeDetailResponse.java | 83 ------------------- .../response/AdminNoticeListItemResponse.java | 24 ------ .../notice/usecase/AdminNoticeDetail.java | 16 ---- .../usecase/AdminNoticeReadUseCase.java | 33 -------- .../api/app/welfare/notice/AppNoticeApi.java | 11 ++- .../notice/response/NoticeDetailResponse.java | 8 +- .../notice/repository/NoticeRepository.java | 8 -- .../domain/notice/service/NoticeService.java | 8 -- .../service/impl/NoticeServiceImpl.java | 10 --- .../db/welfare/NoticeJpaRepository.java | 15 ---- .../db/welfare/NoticeRepositoryImpl.java | 18 ---- 13 files changed, 19 insertions(+), 271 deletions(-) delete mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/notice/response/AdminNoticeDetailResponse.java delete mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/notice/response/AdminNoticeListItemResponse.java delete mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/notice/usecase/AdminNoticeDetail.java delete 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 0dabe9ef..a4ad053c 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 @@ -5,45 +5,25 @@ import kr.ac.kookmin.stream.api.admin.AdminApiUser; import kr.ac.kookmin.stream.api.admin.welfare.notice.request.NoticeCreateRequest; 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이면 된다. 부서 제한은 두지 않았다. + *

+ * 목록·상세 조회는 학생 앱 공지 API({@code /v1/app/notices})를 그대로 쓴다. 관리자 토큰도 {@code STUDENT} 권한을 + * 가지므로 호출할 수 있고, 상세 응답이 수정 화면에 필요한 고정 여부와 수정 시각(updatedAt)을 함께 내려준다. */ @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로 넘기며, 존재하지 않는 파일이 하나라도 있으면 " @@ -55,7 +35,8 @@ ApiResponse> getNotices( /** 공지 수정. 요청의 수정 시각이 저장된 값과 다르면 거부한다. */ @Operation(summary = "공지 수정", description = "공지의 제목·본문·종류·이미지·첨부 파일·고정 여부를 통째로 바꾼다. 수정 화면이 공지를 읽은 뒤 다른 관리자가 " - + "먼저 고쳤다면(수정 시각 불일치) 409로 거부한다. 상세 조회에서 받은 updatedAt을 그대로 보낸다.") + + "먼저 고쳤다면(수정 시각 불일치) 409로 거부한다. 공지 상세 조회(GET /v1/app/notices/{noticeId})에서 받은 " + + "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 623c1c79..335b7015 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 @@ -4,28 +4,19 @@ import kr.ac.kookmin.stream.api.admin.AdminApiUser; import kr.ac.kookmin.stream.api.admin.welfare.notice.request.NoticeCreateRequest; 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.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 @@ -34,26 +25,8 @@ 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/response/AdminNoticeDetailResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/notice/response/AdminNoticeDetailResponse.java deleted file mode 100644 index 2e9aef0b..00000000 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/notice/response/AdminNoticeDetailResponse.java +++ /dev/null @@ -1,83 +0,0 @@ -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 deleted file mode 100644 index 5fc9f0ce..00000000 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/notice/response/AdminNoticeListItemResponse.java +++ /dev/null @@ -1,24 +0,0 @@ -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 deleted file mode 100644 index c6c5e808..00000000 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/notice/usecase/AdminNoticeDetail.java +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index 589e567a..00000000 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/notice/usecase/AdminNoticeReadUseCase.java +++ /dev/null @@ -1,33 +0,0 @@ -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/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/notice/AppNoticeApi.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/notice/AppNoticeApi.java index 935ac901..321888a7 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/notice/AppNoticeApi.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/notice/AppNoticeApi.java @@ -16,7 +16,7 @@ * 스웨거 문서용 어노테이션만 이쪽에 두고 컨트롤러에는 라우팅과 본문만 남긴다. 경로 매핑과 * 파라미터 바인딩(@{@code RequestParam}, @{@code PathVariable} 등)은 구현체에 둔다. */ -@Tag(name = "공지", description = "학생 앱 공지사항 조회") +@Tag(name = "공지", description = "공지사항 조회 (학생 앱·운영진 공용)") public interface AppNoticeApi { /** 공지 목록. 커서 기반 페이지네이션이며 카테고리로 필터링한다. */ @@ -30,8 +30,13 @@ ApiResponse> getNotices( int size ); - /** 공지 상세. */ - @Operation(summary = "공지 상세 조회") + /** + * 공지 상세. 운영진 수정 화면도 이 응답으로 폼을 채운다. 수정할 때 updatedAt을 그대로 돌려 보내면 + * 그 사이 다른 관리자가 고친 공지를 덮어쓰지 않는다. + */ + @Operation(summary = "공지 상세 조회", + description = "공지 한 건을 조회한다. 고정 여부(pinned), 이미지·첨부 파일의 ID와 URL, 수정 시각(updatedAt)을 함께 내려준다. " + + "삭제된 파일은 목록에서 빠진다.") @ApiErrorCode(type = NoticeErrorCode.class, codes = {"NOTICE_NOT_FOUND"}) ApiResponse getNotice(Long noticeId); } 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 07b5fc3a..d4734ba0 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 @@ -13,9 +13,11 @@ public record NoticeDetailResponse( String title, String content, NoticeCategory category, + boolean pinned, List images, List attachments, - LocalDateTime createdAt + LocalDateTime createdAt, + LocalDateTime updatedAt ) { /** @@ -44,9 +46,11 @@ public static NoticeDetailResponse from( notice.getTitle(), notice.getContent(), notice.getCategory(), + notice.isPinned(), images, attachments, - notice.getCreatedAt() + notice.getCreatedAt(), + notice.getUpdatedAt() ); } 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 204b0f1a..c5e6b8ac 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,8 +2,6 @@ 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; @@ -11,12 +9,6 @@ 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 18682763..7abf7024 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,8 +1,6 @@ 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; @@ -12,12 +10,6 @@ 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 c0d37333..5e516b36 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,8 +5,6 @@ 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; @@ -37,14 +35,6 @@ 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 7c91b4a5..9ee415e8 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,7 +4,6 @@ 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; @@ -14,20 +13,6 @@ 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 84aadef8..aba0e249 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,15 +3,11 @@ 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; @@ -33,20 +29,6 @@ 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); From 126725bfaa6520c912e6bbd1a47ef2660a8a2cc4 Mon Sep 17 00:00:00 2001 From: jjunh33 Date: Thu, 22 Oct 2026 16:08:14 +0900 Subject: [PATCH 05/15] =?UTF-8?q?refactor:=20=EA=B3=B5=EC=A7=80=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EC=8B=9C=EA=B0=81=EC=9D=84=20LocalDateTim?= =?UTF-8?q?e=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../notice/request/NoticeUpdateRequest.java | 10 +++++----- .../kr/ac/kookmin/stream/common/DateUtil.java | 17 ----------------- 2 files changed, 5 insertions(+), 22 deletions(-) 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 fbabd8a9..823b6367 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,8 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; +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; import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeUpdateCommand; @@ -37,10 +37,10 @@ public record NoticeUpdateRequest( @NotNull(message = "고정 여부를 입력해 주세요.") Boolean pinned, - @Schema(description = "수정 화면이 공지를 읽었을 때의 수정 시각(KST 기준 epoch 밀리초). 그 사이 다른 관리자가 고쳤다면 409", - example = "1756339200000") + @Schema(description = "공지 상세 조회가 내려준 수정 시각. 받은 값을 그대로 보낸다. 그 사이 다른 관리자가 고쳤다면 409", + example = "2026-10-22T10:00:00") @NotNull(message = "수정 시각을 입력해 주세요.") - Long updatedAt + LocalDateTime updatedAt ) { public NoticeUpdateCommand toCommand() { @@ -51,7 +51,7 @@ public NoticeUpdateCommand toCommand() { imageFileIds.stream().distinct().toList(), attachmentFileIds.stream().distinct().toList(), pinned, - DateUtil.fromEpochMillis(updatedAt) + updatedAt ); } } 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 1fa1355d..948a8cc8 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,8 +1,6 @@ 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; @@ -34,19 +32,4 @@ 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 ef249eb6c3efa22459f8102f5b7b96a52e31d3d1 Mon Sep 17 00:00:00 2001 From: jjunh33 Date: Thu, 22 Oct 2026 16:10:25 +0900 Subject: [PATCH 06/15] =?UTF-8?q?feat:=20=EA=B3=B5=EC=A7=80=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=C2=B7=EC=82=AD=EC=A0=9C=20=EA=B2=BD=ED=95=A9=EC=9D=84?= =?UTF-8?q?=20=EB=82=99=EA=B4=80=EC=A0=81=20=EB=9D=BD=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../notice/repository/NoticeRepository.java | 9 +++++++-- .../notice/service/impl/NoticeServiceImpl.java | 2 ++ .../stream/db/welfare/NoticeJpaEntity.java | 12 ++++++++++++ .../stream/db/welfare/NoticeRepositoryImpl.java | 17 ++++++++++++++++- 4 files changed, 37 insertions(+), 3 deletions(-) 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 c5e6b8ac..31fe0809 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 @@ -10,11 +10,16 @@ public interface NoticeRepository { CursorSliceResult findAll(NoticeCategory category, NoticeCursor cursor, int size); Optional findById(Long id); + + /** + * 공지를 저장한다. 수정이라면 읽은 뒤 다른 요청이 같은 내용을 먼저 고쳤을 때 + * {@code NOTICE_UPDATE_CONFLICT}로 실패한다. + */ Notice save(Notice notice); /** - * 공지를 소프트 삭제한다. 이미 삭제됐거나 없는 공지면 false를 돌려준다. - * 변경은 호출한 쪽 트랜잭션이 커밋될 때 반영된다. + * 공지를 소프트 삭제한다. 이미 삭제됐거나 없는 공지, 또는 읽은 직후 다른 요청이 먼저 삭제한 공지면 false를 돌려준다. + * 변경은 호출 안에서 바로 DB에 보낸다(커밋은 호출한 쪽 트랜잭션이 한다). */ boolean delete(Long id); } 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 5e516b36..cb695b6f 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 @@ -51,6 +51,8 @@ public Notice create(Long createdBy, NoticeCreateCommand command) { return notice; } + // 읽기와 저장을 한 트랜잭션으로 묶어야 NoticeJpaEntity의 낙관적 락이 동작한다. updatedAt 비교는 수정 화면이 읽은 뒤의 + // 변경을, 낙관적 락은 이 메서드가 읽은 뒤 UPDATE 전의 변경을 막는다 @Override @Transactional public Notice update(Long id, NoticeUpdateCommand command) { diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/NoticeJpaEntity.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/NoticeJpaEntity.java index 3def9584..89c7dec3 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/NoticeJpaEntity.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/NoticeJpaEntity.java @@ -15,9 +15,19 @@ import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCategory; import lombok.AccessLevel; import lombok.NoArgsConstructor; +import org.hibernate.annotations.DynamicUpdate; import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.annotations.OptimisticLockType; +import org.hibernate.annotations.OptimisticLocking; import org.hibernate.type.SqlTypes; +// 공지 수정·삭제가 겹쳐도 앞선 변경을 덮어쓰지 않도록 버전 컬럼 없는 낙관적 락으로 보호한다. 바뀐 컬럼만 UPDATE하고 +// (@DynamicUpdate), 그 컬럼의 읽은 값을 WHERE에 붙인다(UPDATE notices SET title = ? WHERE notice_id = ? AND title = 읽은 값). +// 읽은 뒤 UPDATE 전에 다른 트랜잭션이 같은 컬럼을 바꿨으면 0건이 되어 OptimisticLockingFailureException이 난다. +// 서로 다른 컬럼을 고친 동시 수정은 충돌 없이 둘 다 반영된다. +// ⚠️ 이 락은 같은 트랜잭션에서 읽은 값과 비교하므로 수정 화면이 공지를 읽은 뒤 다른 관리자가 고쳤는지는 알 수 없다. +// 그 검사는 서비스가 요청의 updatedAt과 비교해 맡고, 이 락은 그 검사 뒤 UPDATE 직전의 틈만 닫는다. +// ⚠️ 읽기와 저장이 한 트랜잭션이어야 읽은 값이 WHERE에 쓰인다. 공지 수정의 @Transactional을 빼면 이 락은 동작하지 않는다. @Entity @Table( name = "notices", @@ -25,6 +35,8 @@ @Index(name = "idx_notices_is_deleted_pinned_created_at", columnList = "is_deleted, pinned, created_at") } ) +@DynamicUpdate +@OptimisticLocking(type = OptimisticLockType.DIRTY) @NoArgsConstructor(access = AccessLevel.PROTECTED) public class NoticeJpaEntity extends BaseSoftDeleteEntity { 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..5b6572c1 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 @@ -2,12 +2,15 @@ import java.util.List; import java.util.Optional; +import kr.ac.kookmin.stream.common.BusinessException; import kr.ac.kookmin.stream.common.CursorSliceResult; 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.domain.NoticeErrorCode; import kr.ac.kookmin.stream.welfare.domain.notice.repository.NoticeRepository; import lombok.RequiredArgsConstructor; +import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Repository; @@ -34,9 +37,15 @@ public Optional findById(Long id) { return noticeJpaRepository.findByIdAndIsDeletedFalse(id).map(NoticeJpaEntity::toDomain); } + // 낙관적 락 충돌은 커밋할 때 터지면 이 try/catch 밖이라 변환할 곳이 없다. 그래서 UPDATE를 여기서 바로 보낸다 + // (INSERT에는 WHERE가 없어 충돌하지 않는다). 재시도하지 않고 바로 알리는 이유는 NoticeJpaEntity 주석 참고 @Override public Notice save(Notice notice) { - return noticeJpaRepository.save(NoticeJpaEntity.from(notice)).toDomain(); + try { + return noticeJpaRepository.saveAndFlush(NoticeJpaEntity.from(notice)).toDomain(); + } catch (OptimisticLockingFailureException e) { + throw new BusinessException(NoticeErrorCode.NOTICE_UPDATE_CONFLICT); + } } @Override @@ -44,6 +53,12 @@ public boolean delete(Long id) { return noticeJpaRepository.findByIdAndIsDeletedFalse(id) .map(entity -> { entity.delete(); + try { + noticeJpaRepository.flush(); + } catch (OptimisticLockingFailureException e) { + // 읽은 뒤 UPDATE 전에 다른 요청이 먼저 삭제했다. 순차 요청과 같게 없는 공지로 다룬다 + return false; + } return true; }) .orElse(false); From 2728fb7298e2ffd80eb04d4d9151412155119a4b Mon Sep 17 00:00:00 2001 From: jjunh33 Date: Thu, 22 Oct 2026 16:10:26 +0900 Subject: [PATCH 07/15] =?UTF-8?q?docs:=20=EA=B3=B5=EC=A7=80=20=EB=B3=B8?= =?UTF-8?q?=EB=AC=B8=EC=9D=84=20=EC=9D=BC=EB=B0=98=20=ED=85=8D=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EB=A1=9C=20=EB=AA=85=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/admin/welfare/notice/request/NoticeCreateRequest.java | 2 +- .../api/admin/welfare/notice/request/NoticeUpdateRequest.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 5f201001..59f3957b 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 @@ -18,7 +18,7 @@ public record NoticeCreateRequest( String title, // content는 TEXT 컬럼(최대 65,535바이트)이다. 글자당 최대 4바이트로 잡아도 넘치지 않는 길이로 막는다 - @Schema(description = "본문(HTML)", example = "

공지 내용입니다.

") + @Schema(description = "본문(일반 텍스트). 서식 없이 입력한 그대로 저장하며 줄바꿈도 보존한다", example = "공지 내용입니다.") @NotBlank(message = "본문을 입력해 주세요.") @Size(max = 16000, message = "본문은 16000자 이하로 입력해 주세요.") String content, 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 823b6367..af922fe5 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 @@ -16,7 +16,7 @@ public record NoticeUpdateRequest( String title, // content는 TEXT 컬럼(최대 65,535바이트)이다. 글자당 최대 4바이트로 잡아도 넘치지 않는 길이로 막는다 - @Schema(description = "본문(HTML)", example = "

공지 내용입니다.

") + @Schema(description = "본문(일반 텍스트). 서식 없이 입력한 그대로 저장하며 줄바꿈도 보존한다", example = "공지 내용입니다.") @NotBlank(message = "본문을 입력해 주세요.") @Size(max = 16000, message = "본문은 16000자 이하로 입력해 주세요.") String content, From d484c43abf0dd5b2c9843bff8271bc5c5cbf9a1f Mon Sep 17 00:00:00 2001 From: jjunh33 Date: Thu, 22 Oct 2026 16:10:42 +0900 Subject: [PATCH 08/15] =?UTF-8?q?refactor:=20=EA=B3=B5=EC=A7=80=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=EC=9D=98=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=9D=BD=EA=B8=B0=20=ED=8A=B8=EB=9E=9C=EC=9E=AD?= =?UTF-8?q?=EC=85=98=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../welfare/domain/notice/service/impl/NoticeServiceImpl.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 cb695b6f..af880870 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 @@ -25,8 +25,8 @@ class NoticeServiceImpl implements NoticeService { private final NoticeRepository noticeRepository; private final OutboxWriter outboxWriter; + // 조회 쿼리가 1개라 트랜잭션을 걸지 않는다. 쿼리가 늘어 한 스냅샷이 필요해지면 @Transactional(readOnly = true)를 붙인다. @Override - @Transactional(readOnly = true) public CursorSliceResult getNotices(NoticeCategory category, NoticeCursor cursor, int size) { // 커서가 다른 category 필터에서 발급됐다면 keyset 경계가 다른 정렬 결과를 가리키므로 거부한다 if (cursor != null && cursor.category() != category) { @@ -36,7 +36,6 @@ public CursorSliceResult getNotices(NoticeCategory category, NoticeCurso } @Override - @Transactional(readOnly = true) public Notice getNotice(Long id) { return noticeRepository.findById(id) .orElseThrow(() -> new BusinessException(NoticeErrorCode.NOTICE_NOT_FOUND)); From 08b57a6db9b4ef037b1f0068ac4798517b06283a Mon Sep 17 00:00:00 2001 From: jjunh33 Date: Thu, 22 Oct 2026 16:27:22 +0900 Subject: [PATCH 09/15] =?UTF-8?q?refactor:=20=EA=B3=B5=EC=A7=80=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=EC=9D=98=20=EB=8F=99=EC=8B=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EA=B0=90=EC=A7=80=EB=A5=BC=20=EC=A0=9C=EA=B1=B0?= =?UTF-8?q?=ED=95=98=EA=B3=A0=20=EB=A7=88=EC=A7=80=EB=A7=89=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=EC=9D=B4=20=EB=B0=98=EC=98=81=EB=90=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/welfare/notice/AdminNoticeApi.java | 12 ++++++------ .../notice/request/NoticeUpdateRequest.java | 11 ++--------- .../api/app/welfare/notice/AppNoticeApi.java | 7 ++----- .../notice/response/NoticeDetailResponse.java | 6 ++---- .../domain/notice/domain/NoticeErrorCode.java | 3 +-- .../notice/domain/NoticeUpdateCommand.java | 7 +------ .../notice/repository/NoticeRepository.java | 9 ++------- .../domain/notice/service/NoticeService.java | 4 ---- .../notice/service/impl/NoticeServiceImpl.java | 14 -------------- .../stream/db/welfare/NoticeJpaEntity.java | 12 ++---------- .../stream/db/welfare/NoticeRepositoryImpl.java | 17 +---------------- 11 files changed, 19 insertions(+), 83 deletions(-) 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 a4ad053c..acb35bd5 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 @@ -19,7 +19,7 @@ * 접근은 {@code ADMIN} role이면 된다. 부서 제한은 두지 않았다. *

* 목록·상세 조회는 학생 앱 공지 API({@code /v1/app/notices})를 그대로 쓴다. 관리자 토큰도 {@code STUDENT} 권한을 - * 가지므로 호출할 수 있고, 상세 응답이 수정 화면에 필요한 고정 여부와 수정 시각(updatedAt)을 함께 내려준다. + * 가지므로 호출할 수 있고, 상세 응답이 수정 화면에 필요한 고정 여부를 함께 내려준다. */ @Tag(name = "공지 관리", description = "운영진 공지 작성·수정·삭제") public interface AdminNoticeApi { @@ -32,14 +32,14 @@ public interface AdminNoticeApi { @ApiErrorCode(type = FileErrorCode.class, codes = {"FILE_NOT_FOUND"}) ApiResponse createNotice(AdminApiUser apiUser, NoticeCreateRequest request); - /** 공지 수정. 요청의 수정 시각이 저장된 값과 다르면 거부한다. */ + /** 공지 수정. 두 관리자가 겹쳐 수정하면 나중에 저장한 내용이 반영된다. */ @Operation(summary = "공지 수정", - description = "공지의 제목·본문·종류·이미지·첨부 파일·고정 여부를 통째로 바꾼다. 수정 화면이 공지를 읽은 뒤 다른 관리자가 " - + "먼저 고쳤다면(수정 시각 불일치) 409로 거부한다. 공지 상세 조회(GET /v1/app/notices/{noticeId})에서 받은 " - + "updatedAt을 그대로 보낸다.") + description = "공지의 제목·본문·종류·이미지·첨부 파일·고정 여부를 통째로 바꾼다. 수정 화면은 공지 상세 조회" + + "(GET /v1/app/notices/{noticeId})로 채운다. 다른 관리자가 먼저 고쳤는지는 확인하지 않으므로 나중에 저장한 " + + "내용이 그대로 반영된다.") @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"}) + @ApiErrorCode(type = NoticeErrorCode.class, codes = {"NOTICE_NOT_FOUND"}) ApiResponse updateNotice(AdminApiUser apiUser, Long noticeId, NoticeUpdateRequest request); /** 공지 삭제. 소프트 삭제라 학생 앱 목록·상세에서 보이지 않게 된다. */ 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 af922fe5..9f40e777 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,7 +4,6 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; -import java.time.LocalDateTime; import java.util.List; import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCategory; import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeUpdateCommand; @@ -35,12 +34,7 @@ public record NoticeUpdateRequest( @Schema(description = "상단 고정 여부", example = "false") @NotNull(message = "고정 여부를 입력해 주세요.") - Boolean pinned, - - @Schema(description = "공지 상세 조회가 내려준 수정 시각. 받은 값을 그대로 보낸다. 그 사이 다른 관리자가 고쳤다면 409", - example = "2026-10-22T10:00:00") - @NotNull(message = "수정 시각을 입력해 주세요.") - LocalDateTime updatedAt + Boolean pinned ) { public NoticeUpdateCommand toCommand() { @@ -50,8 +44,7 @@ public NoticeUpdateCommand toCommand() { category, imageFileIds.stream().distinct().toList(), attachmentFileIds.stream().distinct().toList(), - pinned, - updatedAt + pinned ); } } diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/notice/AppNoticeApi.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/notice/AppNoticeApi.java index 321888a7..c0a75916 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/notice/AppNoticeApi.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/notice/AppNoticeApi.java @@ -30,12 +30,9 @@ ApiResponse> getNotices( int size ); - /** - * 공지 상세. 운영진 수정 화면도 이 응답으로 폼을 채운다. 수정할 때 updatedAt을 그대로 돌려 보내면 - * 그 사이 다른 관리자가 고친 공지를 덮어쓰지 않는다. - */ + /** 공지 상세. 운영진 수정 화면도 이 응답으로 폼을 채운다. */ @Operation(summary = "공지 상세 조회", - description = "공지 한 건을 조회한다. 고정 여부(pinned), 이미지·첨부 파일의 ID와 URL, 수정 시각(updatedAt)을 함께 내려준다. " + description = "공지 한 건을 조회한다. 고정 여부(pinned), 이미지·첨부 파일의 ID와 URL을 함께 내려준다. " + "삭제된 파일은 목록에서 빠진다.") @ApiErrorCode(type = NoticeErrorCode.class, codes = {"NOTICE_NOT_FOUND"}) ApiResponse getNotice(Long noticeId); 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 d4734ba0..3ac4c924 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 @@ -16,8 +16,7 @@ public record NoticeDetailResponse( boolean pinned, List images, List attachments, - LocalDateTime createdAt, - LocalDateTime updatedAt + LocalDateTime createdAt ) { /** @@ -49,8 +48,7 @@ public static NoticeDetailResponse from( notice.isPinned(), images, attachments, - notice.getCreatedAt(), - notice.getUpdatedAt() + notice.getCreatedAt() ); } diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/domain/NoticeErrorCode.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/domain/NoticeErrorCode.java index 86321490..dd50ba77 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/domain/NoticeErrorCode.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/domain/NoticeErrorCode.java @@ -12,8 +12,7 @@ public enum NoticeErrorCode implements ErrorCode { NOTICE_NOT_FOUND(ErrorStatus.NOT_FOUND, "공지를 찾을 수 없습니다."), - NOTICE_INVALID_CURSOR(ErrorStatus.BAD_REQUEST, "유효하지 않은 커서입니다."), - NOTICE_UPDATE_CONFLICT(ErrorStatus.CONFLICT, "다른 관리자가 먼저 공지를 수정했습니다. 새로고침한 뒤 다시 수정해 주세요."); + NOTICE_INVALID_CURSOR(ErrorStatus.BAD_REQUEST, "유효하지 않은 커서입니다."); private final int status; private final String message; 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 90ea1d41..8123fdd8 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 @@ -1,18 +1,13 @@ package kr.ac.kookmin.stream.welfare.domain.notice.domain; -import java.time.LocalDateTime; import java.util.List; -/** - * @param expectedUpdatedAt 수정 화면이 공지를 읽었을 때의 수정 시각. 그 사이 다른 관리자가 고쳤는지 가려내는 데 쓴다 - */ public record NoticeUpdateCommand( String title, String content, NoticeCategory category, List imageIds, List attachmentIds, - boolean pinned, - LocalDateTime expectedUpdatedAt + boolean pinned ) { } 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 31fe0809..c5e6b8ac 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 @@ -10,16 +10,11 @@ public interface NoticeRepository { CursorSliceResult findAll(NoticeCategory category, NoticeCursor cursor, int size); Optional findById(Long id); - - /** - * 공지를 저장한다. 수정이라면 읽은 뒤 다른 요청이 같은 내용을 먼저 고쳤을 때 - * {@code NOTICE_UPDATE_CONFLICT}로 실패한다. - */ Notice save(Notice notice); /** - * 공지를 소프트 삭제한다. 이미 삭제됐거나 없는 공지, 또는 읽은 직후 다른 요청이 먼저 삭제한 공지면 false를 돌려준다. - * 변경은 호출 안에서 바로 DB에 보낸다(커밋은 호출한 쪽 트랜잭션이 한다). + * 공지를 소프트 삭제한다. 이미 삭제됐거나 없는 공지면 false를 돌려준다. + * 변경은 호출한 쪽 트랜잭션이 커밋될 때 반영된다. */ boolean delete(Long id); } 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 7abf7024..7c6722d5 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 @@ -13,10 +13,6 @@ public interface NoticeService { Notice getNotice(Long id); Notice create(Long createdBy, NoticeCreateCommand command); - /** - * 공지를 수정한다. 명령의 수정 시각이 저장된 값과 다르면(그 사이 다른 관리자가 고친 경우) - * {@code NOTICE_UPDATE_CONFLICT}로 거부한다. - */ Notice update(Long id, NoticeUpdateCommand command); void delete(Long id); 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 af880870..3f78b13e 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 @@ -1,7 +1,5 @@ package kr.ac.kookmin.stream.welfare.domain.notice.service.impl; -import java.time.LocalDateTime; -import java.time.temporal.ChronoUnit; import kr.ac.kookmin.stream.common.BusinessException; import kr.ac.kookmin.stream.common.CursorSliceResult; import kr.ac.kookmin.stream.common.OutboxWriter; @@ -50,14 +48,10 @@ public Notice create(Long createdBy, NoticeCreateCommand command) { return notice; } - // 읽기와 저장을 한 트랜잭션으로 묶어야 NoticeJpaEntity의 낙관적 락이 동작한다. updatedAt 비교는 수정 화면이 읽은 뒤의 - // 변경을, 낙관적 락은 이 메서드가 읽은 뒤 UPDATE 전의 변경을 막는다 @Override @Transactional public Notice update(Long id, NoticeUpdateCommand command) { Notice notice = getNotice(id); - validateNotModified(notice, command.expectedUpdatedAt()); - notice.update(command); return noticeRepository.save(notice); } @@ -69,12 +63,4 @@ public void delete(Long id) { throw new BusinessException(NoticeErrorCode.NOTICE_NOT_FOUND); } } - - // updated_at이 초 단위 DATETIME이라 같은 초로 맞춰 비교한다. 수정 화면이 읽은 뒤 고쳐졌다면 시각이 달라진다 - private void validateNotModified(Notice notice, LocalDateTime expectedUpdatedAt) { - LocalDateTime current = notice.getUpdatedAt().truncatedTo(ChronoUnit.SECONDS); - if (!current.equals(expectedUpdatedAt.truncatedTo(ChronoUnit.SECONDS))) { - throw new BusinessException(NoticeErrorCode.NOTICE_UPDATE_CONFLICT); - } - } } diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/NoticeJpaEntity.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/NoticeJpaEntity.java index 89c7dec3..d767afe0 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/NoticeJpaEntity.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/NoticeJpaEntity.java @@ -17,17 +17,10 @@ import lombok.NoArgsConstructor; import org.hibernate.annotations.DynamicUpdate; import org.hibernate.annotations.JdbcTypeCode; -import org.hibernate.annotations.OptimisticLockType; -import org.hibernate.annotations.OptimisticLocking; import org.hibernate.type.SqlTypes; -// 공지 수정·삭제가 겹쳐도 앞선 변경을 덮어쓰지 않도록 버전 컬럼 없는 낙관적 락으로 보호한다. 바뀐 컬럼만 UPDATE하고 -// (@DynamicUpdate), 그 컬럼의 읽은 값을 WHERE에 붙인다(UPDATE notices SET title = ? WHERE notice_id = ? AND title = 읽은 값). -// 읽은 뒤 UPDATE 전에 다른 트랜잭션이 같은 컬럼을 바꿨으면 0건이 되어 OptimisticLockingFailureException이 난다. -// 서로 다른 컬럼을 고친 동시 수정은 충돌 없이 둘 다 반영된다. -// ⚠️ 이 락은 같은 트랜잭션에서 읽은 값과 비교하므로 수정 화면이 공지를 읽은 뒤 다른 관리자가 고쳤는지는 알 수 없다. -// 그 검사는 서비스가 요청의 updatedAt과 비교해 맡고, 이 락은 그 검사 뒤 UPDATE 직전의 틈만 닫는다. -// ⚠️ 읽기와 저장이 한 트랜잭션이어야 읽은 값이 WHERE에 쓰인다. 공지 수정의 @Transactional을 빼면 이 락은 동작하지 않는다. +// 바뀐 컬럼만 UPDATE한다(@DynamicUpdate). 수정과 삭제가 겹쳤을 때 읽어 둔 나머지 컬럼까지 되써서 삭제 표시(is_deleted)나 +// 다른 관리자가 방금 고친 값을 되돌리는 일을 막는다. 동시 수정을 막는 락은 두지 않아 겹치면 나중에 저장한 내용이 반영된다. @Entity @Table( name = "notices", @@ -36,7 +29,6 @@ } ) @DynamicUpdate -@OptimisticLocking(type = OptimisticLockType.DIRTY) @NoArgsConstructor(access = AccessLevel.PROTECTED) public class NoticeJpaEntity extends BaseSoftDeleteEntity { 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 5b6572c1..aba0e249 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 @@ -2,15 +2,12 @@ import java.util.List; import java.util.Optional; -import kr.ac.kookmin.stream.common.BusinessException; import kr.ac.kookmin.stream.common.CursorSliceResult; 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.domain.NoticeErrorCode; import kr.ac.kookmin.stream.welfare.domain.notice.repository.NoticeRepository; import lombok.RequiredArgsConstructor; -import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Repository; @@ -37,15 +34,9 @@ public Optional findById(Long id) { return noticeJpaRepository.findByIdAndIsDeletedFalse(id).map(NoticeJpaEntity::toDomain); } - // 낙관적 락 충돌은 커밋할 때 터지면 이 try/catch 밖이라 변환할 곳이 없다. 그래서 UPDATE를 여기서 바로 보낸다 - // (INSERT에는 WHERE가 없어 충돌하지 않는다). 재시도하지 않고 바로 알리는 이유는 NoticeJpaEntity 주석 참고 @Override public Notice save(Notice notice) { - try { - return noticeJpaRepository.saveAndFlush(NoticeJpaEntity.from(notice)).toDomain(); - } catch (OptimisticLockingFailureException e) { - throw new BusinessException(NoticeErrorCode.NOTICE_UPDATE_CONFLICT); - } + return noticeJpaRepository.save(NoticeJpaEntity.from(notice)).toDomain(); } @Override @@ -53,12 +44,6 @@ public boolean delete(Long id) { return noticeJpaRepository.findByIdAndIsDeletedFalse(id) .map(entity -> { entity.delete(); - try { - noticeJpaRepository.flush(); - } catch (OptimisticLockingFailureException e) { - // 읽은 뒤 UPDATE 전에 다른 요청이 먼저 삭제했다. 순차 요청과 같게 없는 공지로 다룬다 - return false; - } return true; }) .orElse(false); From a5c7aac5e6fab8358c241656b80041d5220dfe96 Mon Sep 17 00:00:00 2001 From: jjunh33 Date: Thu, 22 Oct 2026 17:17:29 +0900 Subject: [PATCH 10/15] =?UTF-8?q?feat:=20=EA=B3=B5=EC=A7=80=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=EC=97=90=20=EC=A0=9C=EB=AA=A9=20=EA=B2=80=EC=83=89=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stream/api/app/welfare/notice/AppNoticeApi.java | 7 +++++-- .../api/app/welfare/notice/AppNoticeController.java | 3 ++- .../domain/notice/repository/NoticeRepository.java | 9 ++++++++- .../welfare/domain/notice/service/NoticeService.java | 7 ++++++- .../domain/notice/service/impl/NoticeServiceImpl.java | 8 ++++++-- .../kookmin/stream/db/welfare/NoticeJpaRepository.java | 10 +++++++++- .../stream/db/welfare/NoticeRepositoryImpl.java | 8 +++++--- 7 files changed, 41 insertions(+), 11 deletions(-) diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/notice/AppNoticeApi.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/notice/AppNoticeApi.java index c0a75916..d21910c7 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/notice/AppNoticeApi.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/notice/AppNoticeApi.java @@ -19,13 +19,16 @@ @Tag(name = "공지", description = "공지사항 조회 (학생 앱·운영진 공용)") public interface AppNoticeApi { - /** 공지 목록. 커서 기반 페이지네이션이며 카테고리로 필터링한다. */ + /** 공지 목록. 커서 기반 페이지네이션이며 카테고리로 필터링하고 제목으로 검색한다. */ @Operation(summary = "공지 목록 조회", - description = "커서 기반으로 공지 목록을 조회한다. category로 분류를 필터링하고, cursor/size로 다음 페이지를 넘긴다.") + description = "커서 기반으로 공지 목록을 고정 공지 우선, 최신순으로 조회한다. category로 분류를 필터링하고, keyword를 주면 " + + "제목에 그 글자가 들어 있는 공지만 조회한다. cursor/size로 다음 페이지를 넘기며, 다음 페이지에도 같은 " + + "category와 keyword를 보낸다.") @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) @ApiErrorCode(type = NoticeErrorCode.class, codes = {"NOTICE_INVALID_CURSOR"}) ApiResponse> getNotices( String category, + String keyword, String cursor, int size ); 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 a2350cc3..cca6c5eb 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 @@ -33,11 +33,12 @@ public class AppNoticeController implements AppNoticeApi { @GetMapping public ApiResponse> getNotices( @RequestParam(name = "category", required = false) String category, + @RequestParam(name = "keyword", required = false) String keyword, @RequestParam(name = "cursor", required = false) String cursor, @RequestParam(name = "size", defaultValue = "20") int size ) { NoticeCursor noticeCursor = cursor == null ? null : NoticeCursor.from(CursorCodec.decode(cursor)); - CursorSliceResult result = noticeService.getNotices(NoticeCategory.from(category), noticeCursor, size); + CursorSliceResult result = noticeService.getNotices(NoticeCategory.from(category), keyword, noticeCursor, size); List thumbnailIds = result.content().stream() .map(AppNoticeController::firstImageId) 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 c5e6b8ac..0599e874 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 @@ -7,7 +7,14 @@ import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCursor; public interface NoticeRepository { - CursorSliceResult findAll(NoticeCategory category, NoticeCursor cursor, int size); + /** + * 삭제되지 않은 공지를 고정 우선, 최신순으로 커서 페이지 조회한다. + * + * @param category 카테고리. null이면 전체 + * @param keyword 제목에 포함된 검색어. null이면 검색하지 않는다 + * @param cursor 이전 페이지의 마지막 항목. null이면 첫 페이지 + */ + CursorSliceResult findAll(NoticeCategory category, String keyword, NoticeCursor cursor, int size); 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 7c6722d5..baf7ad41 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 @@ -8,7 +8,12 @@ import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeUpdateCommand; public interface NoticeService { - CursorSliceResult getNotices(NoticeCategory category, NoticeCursor cursor, int size); + /** + * 공지 목록. 고정 우선, 최신순으로 커서 페이지 조회한다. + * + * @param keyword 제목 검색어. null이거나 공백이면 검색하지 않는다 + */ + CursorSliceResult getNotices(NoticeCategory category, String keyword, NoticeCursor cursor, int size); 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 3f78b13e..b5a402e6 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 @@ -25,12 +25,16 @@ class NoticeServiceImpl implements NoticeService { // 조회 쿼리가 1개라 트랜잭션을 걸지 않는다. 쿼리가 늘어 한 스냅샷이 필요해지면 @Transactional(readOnly = true)를 붙인다. @Override - public CursorSliceResult getNotices(NoticeCategory category, NoticeCursor cursor, int size) { + public CursorSliceResult getNotices( + NoticeCategory category, String keyword, NoticeCursor cursor, int size + ) { // 커서가 다른 category 필터에서 발급됐다면 keyset 경계가 다른 정렬 결과를 가리키므로 거부한다 if (cursor != null && cursor.category() != category) { throw new BusinessException(NoticeErrorCode.NOTICE_INVALID_CURSOR); } - return noticeRepository.findAll(category, cursor, size); + // 공백뿐인 검색어는 검색하지 않는 것과 같다 + String normalizedKeyword = keyword == null || keyword.isBlank() ? null : keyword.strip(); + return noticeRepository.findAll(category, normalizedKeyword, cursor, size); } @Override 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..3998e212 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 @@ -13,18 +13,25 @@ public interface NoticeJpaRepository extends JpaRepository findByIdAndIsDeletedFalse(Long id); + // keyword는 제목 부분 일치 검색이다. LIKE 대신 LOCATE를 써서 %·_ 같은 문자도 와일드카드가 아닌 글자 그대로 검색된다 @Query(""" SELECT n FROM NoticeJpaEntity n WHERE n.isDeleted = false AND (:category IS NULL OR n.category = :category) + AND (:keyword IS NULL OR LOCATE(:keyword, n.title) > 0) ORDER BY n.pinned DESC, n.createdAt DESC, n.id DESC """) - List findFirstSlice(@Param("category") NoticeCategory category, Pageable pageable); + List findFirstSlice( + @Param("category") NoticeCategory category, + @Param("keyword") String keyword, + Pageable pageable + ); @Query(""" SELECT n FROM NoticeJpaEntity n WHERE n.isDeleted = false AND (:category IS NULL OR n.category = :category) + AND (:keyword IS NULL OR LOCATE(:keyword, n.title) > 0) AND ( (CASE WHEN n.pinned = true THEN 1 ELSE 0 END) < (CASE WHEN :cursorPinned = true THEN 1 ELSE 0 END) OR (n.pinned = :cursorPinned AND n.createdAt < :cursorCreatedAt) @@ -34,6 +41,7 @@ public interface NoticeJpaRepository extends JpaRepository findNextSlice( @Param("category") NoticeCategory category, + @Param("keyword") String keyword, @Param("cursorPinned") boolean cursorPinned, @Param("cursorCreatedAt") LocalDateTime cursorCreatedAt, @Param("cursorId") Long cursorId, 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..eee01d05 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 @@ -18,11 +18,13 @@ public class NoticeRepositoryImpl implements NoticeRepository { private final NoticeJpaRepository noticeJpaRepository; @Override - public CursorSliceResult findAll(NoticeCategory category, NoticeCursor cursor, int size) { + public CursorSliceResult findAll( + NoticeCategory category, String keyword, NoticeCursor cursor, int size + ) { Pageable pageable = Pageable.ofSize(size + 1); List entities = cursor == null - ? noticeJpaRepository.findFirstSlice(category, pageable) - : noticeJpaRepository.findNextSlice(category, cursor.pinned(), cursor.createdAt(), cursor.id(), pageable); + ? noticeJpaRepository.findFirstSlice(category, keyword, pageable) + : noticeJpaRepository.findNextSlice(category, keyword, cursor.pinned(), cursor.createdAt(), cursor.id(), pageable); return CursorSliceResult.ofSlice( entities, size, NoticeJpaEntity::toDomain, notice -> NoticeCursor.of(notice, category).format() From 98226bc1cd0f0ff68d89f1e6e5ef627421dd462e Mon Sep 17 00:00:00 2001 From: jjunh33 Date: Thu, 22 Oct 2026 18:10:58 +0900 Subject: [PATCH 11/15] =?UTF-8?q?refactor:=20=ED=95=99=EC=83=9D=EC=9A=A9?= =?UTF-8?q?=20=EA=B3=B5=EC=A7=80=20=EB=AA=A9=EB=A1=9D=EC=9D=98=20=EC=A0=9C?= =?UTF-8?q?=EB=AA=A9=20=EA=B2=80=EC=83=89=20=EC=B6=94=EA=B0=80=EB=A5=BC=20?= =?UTF-8?q?=EB=90=98=EB=8F=8C=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stream/api/app/welfare/notice/AppNoticeApi.java | 7 ++----- .../api/app/welfare/notice/AppNoticeController.java | 3 +-- .../domain/notice/repository/NoticeRepository.java | 9 +-------- .../welfare/domain/notice/service/NoticeService.java | 7 +------ .../domain/notice/service/impl/NoticeServiceImpl.java | 8 ++------ .../kookmin/stream/db/welfare/NoticeJpaRepository.java | 10 +--------- .../stream/db/welfare/NoticeRepositoryImpl.java | 8 +++----- 7 files changed, 11 insertions(+), 41 deletions(-) diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/notice/AppNoticeApi.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/notice/AppNoticeApi.java index d21910c7..c0a75916 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/notice/AppNoticeApi.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/notice/AppNoticeApi.java @@ -19,16 +19,13 @@ @Tag(name = "공지", description = "공지사항 조회 (학생 앱·운영진 공용)") public interface AppNoticeApi { - /** 공지 목록. 커서 기반 페이지네이션이며 카테고리로 필터링하고 제목으로 검색한다. */ + /** 공지 목록. 커서 기반 페이지네이션이며 카테고리로 필터링한다. */ @Operation(summary = "공지 목록 조회", - description = "커서 기반으로 공지 목록을 고정 공지 우선, 최신순으로 조회한다. category로 분류를 필터링하고, keyword를 주면 " - + "제목에 그 글자가 들어 있는 공지만 조회한다. cursor/size로 다음 페이지를 넘기며, 다음 페이지에도 같은 " - + "category와 keyword를 보낸다.") + description = "커서 기반으로 공지 목록을 조회한다. category로 분류를 필터링하고, cursor/size로 다음 페이지를 넘긴다.") @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) @ApiErrorCode(type = NoticeErrorCode.class, codes = {"NOTICE_INVALID_CURSOR"}) ApiResponse> getNotices( String category, - String keyword, String cursor, int size ); 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 cca6c5eb..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 @@ -33,12 +33,11 @@ public class AppNoticeController implements AppNoticeApi { @GetMapping public ApiResponse> getNotices( @RequestParam(name = "category", required = false) String category, - @RequestParam(name = "keyword", required = false) String keyword, @RequestParam(name = "cursor", required = false) String cursor, @RequestParam(name = "size", defaultValue = "20") int size ) { NoticeCursor noticeCursor = cursor == null ? null : NoticeCursor.from(CursorCodec.decode(cursor)); - CursorSliceResult result = noticeService.getNotices(NoticeCategory.from(category), keyword, noticeCursor, size); + CursorSliceResult result = noticeService.getNotices(NoticeCategory.from(category), noticeCursor, size); List thumbnailIds = result.content().stream() .map(AppNoticeController::firstImageId) 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 0599e874..c5e6b8ac 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 @@ -7,14 +7,7 @@ import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCursor; public interface NoticeRepository { - /** - * 삭제되지 않은 공지를 고정 우선, 최신순으로 커서 페이지 조회한다. - * - * @param category 카테고리. null이면 전체 - * @param keyword 제목에 포함된 검색어. null이면 검색하지 않는다 - * @param cursor 이전 페이지의 마지막 항목. null이면 첫 페이지 - */ - CursorSliceResult findAll(NoticeCategory category, String keyword, NoticeCursor cursor, int size); + CursorSliceResult findAll(NoticeCategory category, NoticeCursor cursor, int size); 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 baf7ad41..7c6722d5 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 @@ -8,12 +8,7 @@ import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeUpdateCommand; public interface NoticeService { - /** - * 공지 목록. 고정 우선, 최신순으로 커서 페이지 조회한다. - * - * @param keyword 제목 검색어. null이거나 공백이면 검색하지 않는다 - */ - CursorSliceResult getNotices(NoticeCategory category, String keyword, NoticeCursor cursor, int size); + CursorSliceResult getNotices(NoticeCategory category, NoticeCursor cursor, int size); 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 b5a402e6..3f78b13e 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 @@ -25,16 +25,12 @@ class NoticeServiceImpl implements NoticeService { // 조회 쿼리가 1개라 트랜잭션을 걸지 않는다. 쿼리가 늘어 한 스냅샷이 필요해지면 @Transactional(readOnly = true)를 붙인다. @Override - public CursorSliceResult getNotices( - NoticeCategory category, String keyword, NoticeCursor cursor, int size - ) { + public CursorSliceResult getNotices(NoticeCategory category, NoticeCursor cursor, int size) { // 커서가 다른 category 필터에서 발급됐다면 keyset 경계가 다른 정렬 결과를 가리키므로 거부한다 if (cursor != null && cursor.category() != category) { throw new BusinessException(NoticeErrorCode.NOTICE_INVALID_CURSOR); } - // 공백뿐인 검색어는 검색하지 않는 것과 같다 - String normalizedKeyword = keyword == null || keyword.isBlank() ? null : keyword.strip(); - return noticeRepository.findAll(category, normalizedKeyword, cursor, size); + return noticeRepository.findAll(category, cursor, size); } @Override 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 3998e212..9ee415e8 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 @@ -13,25 +13,18 @@ public interface NoticeJpaRepository extends JpaRepository findByIdAndIsDeletedFalse(Long id); - // keyword는 제목 부분 일치 검색이다. LIKE 대신 LOCATE를 써서 %·_ 같은 문자도 와일드카드가 아닌 글자 그대로 검색된다 @Query(""" SELECT n FROM NoticeJpaEntity n WHERE n.isDeleted = false AND (:category IS NULL OR n.category = :category) - AND (:keyword IS NULL OR LOCATE(:keyword, n.title) > 0) ORDER BY n.pinned DESC, n.createdAt DESC, n.id DESC """) - List findFirstSlice( - @Param("category") NoticeCategory category, - @Param("keyword") String keyword, - Pageable pageable - ); + List findFirstSlice(@Param("category") NoticeCategory category, Pageable pageable); @Query(""" SELECT n FROM NoticeJpaEntity n WHERE n.isDeleted = false AND (:category IS NULL OR n.category = :category) - AND (:keyword IS NULL OR LOCATE(:keyword, n.title) > 0) AND ( (CASE WHEN n.pinned = true THEN 1 ELSE 0 END) < (CASE WHEN :cursorPinned = true THEN 1 ELSE 0 END) OR (n.pinned = :cursorPinned AND n.createdAt < :cursorCreatedAt) @@ -41,7 +34,6 @@ List findFirstSlice( """) List findNextSlice( @Param("category") NoticeCategory category, - @Param("keyword") String keyword, @Param("cursorPinned") boolean cursorPinned, @Param("cursorCreatedAt") LocalDateTime cursorCreatedAt, @Param("cursorId") Long cursorId, 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 eee01d05..aba0e249 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 @@ -18,13 +18,11 @@ public class NoticeRepositoryImpl implements NoticeRepository { private final NoticeJpaRepository noticeJpaRepository; @Override - public CursorSliceResult findAll( - NoticeCategory category, String keyword, NoticeCursor cursor, int size - ) { + public CursorSliceResult findAll(NoticeCategory category, NoticeCursor cursor, int size) { Pageable pageable = Pageable.ofSize(size + 1); List entities = cursor == null - ? noticeJpaRepository.findFirstSlice(category, keyword, pageable) - : noticeJpaRepository.findNextSlice(category, keyword, cursor.pinned(), cursor.createdAt(), cursor.id(), pageable); + ? noticeJpaRepository.findFirstSlice(category, pageable) + : noticeJpaRepository.findNextSlice(category, cursor.pinned(), cursor.createdAt(), cursor.id(), pageable); return CursorSliceResult.ofSlice( entities, size, NoticeJpaEntity::toDomain, notice -> NoticeCursor.of(notice, category).format() From e42e755ab88e4df3a09d1b0b25ece7c43894bae5 Mon Sep 17 00:00:00 2001 From: jjunh33 Date: Thu, 22 Oct 2026 18:12:02 +0900 Subject: [PATCH 12/15] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EA=B3=B5=EC=A7=80=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=EB=A5=BC=20offset=20=ED=8E=98=EC=9D=B4=EC=A7=80=EB=84=A4?= =?UTF-8?q?=EC=9D=B4=EC=85=98=EC=9C=BC=EB=A1=9C=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 | 22 ++++++++++++++--- .../welfare/notice/AdminNoticeController.java | 18 ++++++++++++++ .../response/AdminNoticeListItemResponse.java | 24 +++++++++++++++++++ .../notice/repository/NoticeRepository.java | 8 +++++++ .../domain/notice/service/NoticeService.java | 9 +++++++ .../service/impl/NoticeServiceImpl.java | 11 +++++++++ .../db/welfare/NoticeJpaRepository.java | 15 ++++++++++++ .../db/welfare/NoticeRepositoryImpl.java | 18 ++++++++++++++ 8 files changed, 122 insertions(+), 3 deletions(-) create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/notice/response/AdminNoticeListItemResponse.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 acb35bd5..b2e1e761 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 @@ -5,25 +5,41 @@ import kr.ac.kookmin.stream.api.admin.AdminApiUser; import kr.ac.kookmin.stream.api.admin.welfare.notice.request.NoticeCreateRequest; import kr.ac.kookmin.stream.api.admin.welfare.notice.request.NoticeUpdateRequest; +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이면 된다. 부서 제한은 두지 않았다. *

- * 목록·상세 조회는 학생 앱 공지 API({@code /v1/app/notices})를 그대로 쓴다. 관리자 토큰도 {@code STUDENT} 권한을 - * 가지므로 호출할 수 있고, 상세 응답이 수정 화면에 필요한 고정 여부를 함께 내려준다. + * 목록은 이 API가 제공한다(제목 검색, 페이지 번호 이동). 상세 조회는 학생 앱 공지 API({@code /v1/app/notices/{noticeId}})를 + * 그대로 쓴다. 관리자 토큰도 {@code STUDENT} 권한을 가지므로 호출할 수 있고, 상세 응답이 수정 화면에 필요한 고정 여부를 + * 함께 내려준다. */ -@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 + ); + /** 공지 작성. 작성자는 토큰의 관리자로 정해진다. */ @Operation(summary = "공지 작성", description = "공지를 등록한다. 이미지·첨부 파일은 업로드를 마친 파일 ID로 넘기며, 존재하지 않는 파일이 하나라도 있으면 " 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 335b7015..095c0c84 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 @@ -4,19 +4,26 @@ import kr.ac.kookmin.stream.api.admin.AdminApiUser; import kr.ac.kookmin.stream.api.admin.welfare.notice.request.NoticeCreateRequest; import kr.ac.kookmin.stream.api.admin.welfare.notice.request.NoticeUpdateRequest; +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.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.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,6 +34,17 @@ public class AdminNoticeController implements AdminNoticeApi { private final NoticeService noticeService; 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 @PostMapping public ApiResponse createNotice( 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/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 c5e6b8ac..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,6 +2,8 @@ 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; @@ -9,6 +11,12 @@ 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 7c6722d5..e49b3628 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; @@ -10,6 +12,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 3f78b13e..95742c6d 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 @@ -3,6 +3,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; @@ -33,6 +35,15 @@ public CursorSliceResult getNotices(NoticeCategory category, NoticeCurso return noticeRepository.findAll(category, cursor, size); } + // 목록과 전체 개수를 두 문장으로 조회한다. 저장소의 @Query 메서드에는 트랜잭션이 붙지 않으므로, 두 조회가 같은 스냅샷을 + // 보도록 읽기 트랜잭션으로 묶는다 + @Override + @Transactional(readOnly = true) + public PageResult search(String keyword, PageOffset pageOffset) { + String normalizedKeyword = keyword == null || keyword.isBlank() ? null : keyword.strip(); + return noticeRepository.search(normalizedKeyword, pageOffset); + } + @Override public Notice getNotice(Long id) { return noticeRepository.findById(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); From 16fb83820163f8455990b96430d85be1629e6bee Mon Sep 17 00:00:00 2001 From: jjunh33 Date: Thu, 22 Oct 2026 18:56:34 +0900 Subject: [PATCH 13/15] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EA=B3=B5=EC=A7=80=20=EB=AA=A9=EB=A1=9D=EC=97=90=20=EC=A2=85?= =?UTF-8?q?=EB=A5=98=20=ED=95=84=ED=84=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stream/api/admin/welfare/notice/AdminNoticeApi.java | 8 +++++--- .../api/admin/welfare/notice/AdminNoticeController.java | 4 +++- .../domain/notice/repository/NoticeRepository.java | 6 +++--- .../welfare/domain/notice/service/NoticeService.java | 6 +++--- .../domain/notice/service/impl/NoticeServiceImpl.java | 4 ++-- .../ac/kookmin/stream/db/welfare/NoticeJpaRepository.java | 8 +++++++- .../kookmin/stream/db/welfare/NoticeRepositoryImpl.java | 4 ++-- 7 files changed, 25 insertions(+), 15 deletions(-) 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 b2e1e761..6845ec4d 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 @@ -29,13 +29,15 @@ @Tag(name = "공지 관리", description = "운영진 공지 목록 조회·작성·수정·삭제") public interface AdminNoticeApi { - /** 공지 목록. 제목으로 검색하고 고정 공지를 먼저, 그다음 최신순으로 페이지 단위 조회한다. */ + /** 공지 목록. 종류로 거르고 제목으로 검색하며, 고정 공지를 먼저, 그다음 최신순으로 페이지 단위 조회한다. */ @Operation(summary = "공지 목록 조회", - description = "삭제되지 않은 공지를 고정 공지 우선, 최신순으로 페이지 단위로 조회한다. keyword를 주면 제목에 그 글자가 " - + "들어 있는 공지만 조회한다. page는 0부터 시작한다.") + description = "삭제되지 않은 공지를 고정 공지 우선, 최신순으로 페이지 단위로 조회한다. category(GENERAL, PARTNERSHIP)를 " + + "주면 그 종류만, keyword를 주면 제목에 그 글자가 들어 있는 공지만 조회하며 둘을 함께 쓸 수 있다. " + + "page는 0부터 시작한다.") @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) ApiResponse> getNotices( AdminApiUser apiUser, + String category, String keyword, @ParameterObject PageParams pageParams ); 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 095c0c84..24fb3c45 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 @@ -13,6 +13,7 @@ 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.domain.NoticeCategory; import kr.ac.kookmin.stream.welfare.domain.notice.service.NoticeService; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.DeleteMapping; @@ -38,10 +39,11 @@ public class AdminNoticeController implements AdminNoticeApi { @GetMapping public ApiResponse> getNotices( AdminApiUser apiUser, + @RequestParam(required = false) String category, @RequestParam(required = false) String keyword, @Valid @ModelAttribute PageParams pageParams ) { - PageResult result = noticeService.search(keyword, pageParams.toOffset()); + PageResult result = noticeService.search(NoticeCategory.from(category), keyword, pageParams.toOffset()); return ApiResponse.success(PageResponse.from(result, AdminNoticeListItemResponse::from)); } 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 204b0f1a..a3284286 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 @@ -12,10 +12,10 @@ public interface NoticeRepository { CursorSliceResult findAll(NoticeCategory category, NoticeCursor cursor, int size); /** - * 삭제되지 않은 공지를 고정 우선, 최신순으로 페이지 단위로 조회한다. 제목에 {@code keyword}가 들어 있는 공지만 고르고 - * {@code keyword}가 null이면 조건 없이 모두 조회한다. + * 삭제되지 않은 공지를 고정 우선, 최신순으로 페이지 단위로 조회한다. {@code category}가 null이 아니면 그 종류만, + * 제목에 {@code keyword}가 들어 있는 공지만 고르고, 각각 null이면 그 조건 없이 조회한다. */ - PageResult search(String keyword, PageOffset pageOffset); + PageResult search(NoticeCategory category, 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 e49b3628..e9bba81e 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 @@ -14,10 +14,10 @@ public interface NoticeService { /** - * 운영진용 공지 목록. 제목 검색어({@code keyword})로 거르고 고정 우선, 최신순으로 페이지 단위 조회한다. - * 검색어가 null이거나 공백이면 거르지 않는다. + * 운영진용 공지 목록. 종류({@code category})와 제목 검색어({@code keyword})로 거르고 고정 우선, 최신순으로 + * 페이지 단위 조회한다. 종류가 null이면 전체, 검색어가 null이거나 공백이면 거르지 않는다. */ - PageResult search(String keyword, PageOffset pageOffset); + PageResult search(NoticeCategory category, 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 95742c6d..bf087eca 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 @@ -39,9 +39,9 @@ public CursorSliceResult getNotices(NoticeCategory category, NoticeCurso // 보도록 읽기 트랜잭션으로 묶는다 @Override @Transactional(readOnly = true) - public PageResult search(String keyword, PageOffset pageOffset) { + public PageResult search(NoticeCategory category, String keyword, PageOffset pageOffset) { String normalizedKeyword = keyword == null || keyword.isBlank() ? null : keyword.strip(); - return noticeRepository.search(normalizedKeyword, pageOffset); + return noticeRepository.search(category, normalizedKeyword, pageOffset); } @Override 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 7c91b4a5..e3ec29d1 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 @@ -18,15 +18,21 @@ public interface NoticeJpaRepository extends JpaRepository 0) ORDER BY n.pinned DESC, n.createdAt DESC, n.id DESC """, countQuery = """ SELECT COUNT(n) FROM NoticeJpaEntity n WHERE n.isDeleted = false + AND (:category IS NULL OR n.category = :category) AND (:keyword IS NULL OR LOCATE(:keyword, n.title) > 0) """) - Page search(@Param("keyword") String keyword, Pageable pageable); + Page search( + @Param("category") NoticeCategory category, + @Param("keyword") String keyword, + Pageable pageable + ); @Query(""" SELECT n FROM NoticeJpaEntity n 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 84aadef8..916ec4c2 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 @@ -34,9 +34,9 @@ public CursorSliceResult findAll(NoticeCategory category, NoticeCursor c } @Override - public PageResult search(String keyword, PageOffset pageOffset) { + public PageResult search(NoticeCategory category, String keyword, PageOffset pageOffset) { Page result = noticeJpaRepository.search( - keyword, PageRequest.of(pageOffset.page(), pageOffset.size()) + category, keyword, PageRequest.of(pageOffset.page(), pageOffset.size()) ); return PageResult.of( From 7445fb24b8a57ee102db02c88dec472f56bcd57b Mon Sep 17 00:00:00 2001 From: jjunh33 Date: Thu, 22 Oct 2026 20:03:32 +0900 Subject: [PATCH 14/15] =?UTF-8?q?refactor:=20=EA=B3=B5=EC=A7=80=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=A0=9C=EB=AA=A9=20=EA=B2=80=EC=83=89?= =?UTF-8?q?=EC=9D=84=20LOCATE=EC=97=90=EC=84=9C=20LIKE=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ac/kookmin/stream/db/welfare/NoticeJpaRepository.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 e3ec29d1..925ee2f9 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 @@ -14,19 +14,19 @@ public interface NoticeJpaRepository extends JpaRepository findByIdAndIsDeletedFalse(Long id); - // LIKE 대신 LOCATE를 써서 %·_ 같은 문자도 와일드카드가 아닌 글자 그대로 검색된다 + // keyword는 제목 부분 일치 검색이다. 검색어 안의 %·_는 LIKE의 와일드카드로 해석된다(이스케이프하지 않는다) @Query(value = """ SELECT n FROM NoticeJpaEntity n WHERE n.isDeleted = false AND (:category IS NULL OR n.category = :category) - AND (:keyword IS NULL OR LOCATE(:keyword, n.title) > 0) + AND (:keyword IS NULL OR n.title LIKE CONCAT('%', :keyword, '%')) ORDER BY n.pinned DESC, n.createdAt DESC, n.id DESC """, countQuery = """ SELECT COUNT(n) FROM NoticeJpaEntity n WHERE n.isDeleted = false AND (:category IS NULL OR n.category = :category) - AND (:keyword IS NULL OR LOCATE(:keyword, n.title) > 0) + AND (:keyword IS NULL OR n.title LIKE CONCAT('%', :keyword, '%')) """) Page search( @Param("category") NoticeCategory category, From 43ef8d4b0f4e504531649a18bd554d3ea0b60a57 Mon Sep 17 00:00:00 2001 From: jjunh33 Date: Thu, 22 Oct 2026 20:40:32 +0900 Subject: [PATCH 15/15] =?UTF-8?q?refactor:=20=EA=B3=B5=EC=A7=80=20?= =?UTF-8?q?=EC=A0=9C=EB=AA=A9=20=EA=B2=80=EC=83=89=EC=96=B4=EC=9D=98=20LIK?= =?UTF-8?q?E=20=EC=99=80=EC=9D=BC=EB=93=9C=EC=B9=B4=EB=93=9C=EB=A5=BC=20?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=EC=BC=80=EC=9D=B4=ED=94=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../notice/repository/NoticeRepository.java | 1 + .../service/impl/NoticeServiceImpl.java | 1 + .../stream/db/common/LikePatterns.java | 44 +++++++++++++++++++ .../db/welfare/NoticeJpaRepository.java | 6 +-- .../db/welfare/NoticeRepositoryImpl.java | 3 +- 5 files changed, 51 insertions(+), 4 deletions(-) create mode 100644 infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/common/LikePatterns.java 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 a3284286..c3e2e194 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 @@ -14,6 +14,7 @@ public interface NoticeRepository { /** * 삭제되지 않은 공지를 고정 우선, 최신순으로 페이지 단위로 조회한다. {@code category}가 null이 아니면 그 종류만, * 제목에 {@code keyword}가 들어 있는 공지만 고르고, 각각 null이면 그 조건 없이 조회한다. + * 검색어는 호출하는 쪽이 공백을 정리해서 넘기고(공백뿐이면 null), 검색어의 {@code %}·{@code _}는 글자 그대로 검색한다. */ PageResult search(NoticeCategory category, String keyword, PageOffset pageOffset); 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 bf087eca..51fb5cf6 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 @@ -40,6 +40,7 @@ public CursorSliceResult getNotices(NoticeCategory category, NoticeCurso @Override @Transactional(readOnly = true) public PageResult search(NoticeCategory category, String keyword, PageOffset pageOffset) { + // 공백뿐인 검색어는 검색하지 않는 것과 같다 String normalizedKeyword = keyword == null || keyword.isBlank() ? null : keyword.strip(); return noticeRepository.search(category, normalizedKeyword, pageOffset); } diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/common/LikePatterns.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/common/LikePatterns.java new file mode 100644 index 00000000..1e82708d --- /dev/null +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/common/LikePatterns.java @@ -0,0 +1,44 @@ +package kr.ac.kookmin.stream.db.common; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/** + * {@code LIKE} 검색어 이스케이프 유틸. + *

+ * 사용자 입력을 {@code CONCAT('%', :keyword, '%')}로 그대로 붙이면 검색어 안의 {@code %}·{@code _}가 와일드카드로 + * 동작해서 {@code %}만 입력해도 모든 행이 걸린다. 검색어를 {@link #escape(String)}로 이스케이프해서 넘기고, 쿼리는 + * {@code LIKE CONCAT('%', :keyword, '%') ESCAPE '!'}로 받는다. + *

+ * 이스케이프 문자는 {@code \}가 아니라 {@code !}다. MySQL은 문자열 리터럴에서 {@code \}를 이스케이프로 먹기 때문에 + * {@code ESCAPE '\'}를 쓰려면 SQL을 {@code '\\'}로 적어야 하는데, JPQL을 거치면 이 이중 이스케이프가 어긋나기 쉽다. + *

+ * 쿼리의 {@code ESCAPE} 문자는 {@link #ESCAPE}와 같아야 한다. + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class LikePatterns { + + /** 쿼리의 {@code ESCAPE '!'}와 같아야 한다. */ + public static final char ESCAPE = '!'; + + /** + * 검색어 안의 {@code LIKE} 메타문자({@code %}, {@code _})와 이스케이프 문자를 글자 그대로 검색되도록 이스케이프한다. + * 공백 제거나 "검색어 없음" 판단은 하지 않는다. + * + * @return 이스케이프한 검색어. {@code raw}가 null이면 null + */ + public static String escape(String raw) { + if (raw == null) { + return null; + } + + StringBuilder escaped = new StringBuilder(raw.length() + 4); + for (char c : raw.toCharArray()) { + if (c == ESCAPE || c == '%' || c == '_') { + escaped.append(ESCAPE); + } + escaped.append(c); + } + return escaped.toString(); + } +} 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 925ee2f9..71bb2ed9 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 @@ -14,19 +14,19 @@ public interface NoticeJpaRepository extends JpaRepository findByIdAndIsDeletedFalse(Long id); - // keyword는 제목 부분 일치 검색이다. 검색어 안의 %·_는 LIKE의 와일드카드로 해석된다(이스케이프하지 않는다) + // keyword는 LikePatterns.escape로 이스케이프한 제목 검색어다. 쿼리의 ESCAPE 문자는 LikePatterns.ESCAPE와 같아야 한다 @Query(value = """ SELECT n FROM NoticeJpaEntity n WHERE n.isDeleted = false AND (:category IS NULL OR n.category = :category) - AND (:keyword IS NULL OR n.title LIKE CONCAT('%', :keyword, '%')) + AND (:keyword IS NULL OR n.title LIKE CONCAT('%', :keyword, '%') ESCAPE '!') ORDER BY n.pinned DESC, n.createdAt DESC, n.id DESC """, countQuery = """ SELECT COUNT(n) FROM NoticeJpaEntity n WHERE n.isDeleted = false AND (:category IS NULL OR n.category = :category) - AND (:keyword IS NULL OR n.title LIKE CONCAT('%', :keyword, '%')) + AND (:keyword IS NULL OR n.title LIKE CONCAT('%', :keyword, '%') ESCAPE '!') """) Page search( @Param("category") NoticeCategory category, 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 916ec4c2..119812f6 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 @@ -5,6 +5,7 @@ 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.db.common.LikePatterns; 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; @@ -36,7 +37,7 @@ public CursorSliceResult findAll(NoticeCategory category, NoticeCursor c @Override public PageResult search(NoticeCategory category, String keyword, PageOffset pageOffset) { Page result = noticeJpaRepository.search( - category, keyword, PageRequest.of(pageOffset.page(), pageOffset.size()) + category, LikePatterns.escape(keyword), PageRequest.of(pageOffset.page(), pageOffset.size()) ); return PageResult.of(