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..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 @@ -4,39 +4,60 @@ 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.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가 제공한다(제목 검색, 페이지 번호 이동). 상세 조회는 학생 앱 공지 API({@code /v1/app/notices/{noticeId}})를 + * 그대로 쓴다. 관리자 토큰도 {@code STUDENT} 권한을 가지므로 호출할 수 있고, 상세 응답이 수정 화면에 필요한 고정 여부를 + * 함께 내려준다. */ -@Tag(name = "공지 관리", description = "운영진 공지 작성·수정·삭제·고정") +@Tag(name = "공지 관리", description = "운영진 공지 목록 조회·작성·수정·삭제") public interface AdminNoticeApi { + /** 공지 목록. 종류로 거르고 제목으로 검색하며, 고정 공지를 먼저, 그다음 최신순으로 페이지 단위 조회한다. */ + @Operation(summary = "공지 목록 조회", + 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 + ); + /** 공지 작성. 작성자는 토큰의 관리자로 정해진다. */ @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 = "공지의 제목·본문·종류·이미지·첨부 파일·고정 여부를 통째로 바꾼다. 수정 화면은 공지 상세 조회" + + "(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); /** 공지 삭제. 소프트 삭제라 학생 앱 목록·상세에서 보이지 않게 된다. */ @@ -44,11 +65,4 @@ public interface AdminNoticeApi { 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 bd84a8ec..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 @@ -3,22 +3,28 @@ 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.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.domain.NoticeCategory; 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.PatchMapping; +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 @@ -29,6 +35,18 @@ public class AdminNoticeController implements AdminNoticeApi { private final NoticeService noticeService; private final AdminNoticeWriteUseCase adminNoticeWriteUseCase; + @Override + @GetMapping + public ApiResponse> getNotices( + AdminApiUser apiUser, + @RequestParam(required = false) String category, + @RequestParam(required = false) String keyword, + @Valid @ModelAttribute PageParams pageParams + ) { + PageResult result = noticeService.search(NoticeCategory.from(category), keyword, pageParams.toOffset()); + return ApiResponse.success(PageResponse.from(result, AdminNoticeListItemResponse::from)); + } + @Override @PostMapping public ApiResponse createNotice( @@ -56,15 +74,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/NoticeCreateRequest.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/notice/request/NoticeCreateRequest.java index 11c1a912..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, @@ -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/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/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..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,10 +4,7 @@ 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; import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeUpdateCommand; @@ -18,7 +15,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, @@ -35,10 +32,9 @@ public record NoticeUpdateRequest( @NotNull(message = "첨부 파일 목록을 입력해 주세요.") List<@NotNull(message = "첨부 파일 ID는 비워 둘 수 없습니다.") Long> attachmentFileIds, - @Schema(description = "수정 화면이 공지를 읽었을 때의 수정 시각(KST 기준 epoch 밀리초). 그 사이 다른 관리자가 고쳤다면 409", - example = "1756339200000") - @NotNull(message = "수정 시각을 입력해 주세요.") - Long updatedAt + @Schema(description = "상단 고정 여부", example = "false") + @NotNull(message = "고정 여부를 입력해 주세요.") + Boolean pinned ) { public NoticeUpdateCommand toCommand() { @@ -48,7 +44,7 @@ public NoticeUpdateCommand toCommand() { category, imageFileIds.stream().distinct().toList(), attachmentFileIds.stream().distinct().toList(), - LocalDateTime.ofInstant(Instant.ofEpochMilli(updatedAt), DateUtil.KST) + pinned ); } } 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/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..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 @@ -16,7 +16,7 @@ * 스웨거 문서용 어노테이션만 이쪽에 두고 컨트롤러에는 라우팅과 본문만 남긴다. 경로 매핑과 * 파라미터 바인딩(@{@code RequestParam}, @{@code PathVariable} 등)은 구현체에 둔다. */ -@Tag(name = "공지", description = "학생 앱 공지사항 조회") +@Tag(name = "공지", description = "공지사항 조회 (학생 앱·운영진 공용)") public interface AppNoticeApi { /** 공지 목록. 커서 기반 페이지네이션이며 카테고리로 필터링한다. */ @@ -30,8 +30,10 @@ ApiResponse> getNotices( int size ); - /** 공지 상세. */ - @Operation(summary = "공지 상세 조회") + /** 공지 상세. 운영진 수정 화면도 이 응답으로 폼을 채운다. */ + @Operation(summary = "공지 상세 조회", + 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 07b5fc3a..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 @@ -13,6 +13,7 @@ public record NoticeDetailResponse( String title, String content, NoticeCategory category, + boolean pinned, List images, List attachments, LocalDateTime createdAt @@ -44,6 +45,7 @@ public static NoticeDetailResponse from( notice.getTitle(), notice.getContent(), notice.getCategory(), + notice.isPinned(), images, attachments, notice.getCreatedAt() 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..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 @@ -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,9 +60,6 @@ public void update(NoticeUpdateCommand command) { this.category = command.category(); this.attachmentIds = command.attachmentIds(); this.imageIds = command.imageIds(); - } - - public void changePinned(boolean pinned) { - this.pinned = pinned; + this.pinned = command.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/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 7082ae32..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,17 +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, - 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 1697d3ed..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 @@ -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 category}가 null이 아니면 그 종류만, + * 제목에 {@code keyword}가 들어 있는 공지만 고르고, 각각 null이면 그 조건 없이 조회한다. + */ + 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 6e1e1af4..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 @@ -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,15 +11,18 @@ public interface NoticeService { CursorSliceResult getNotices(NoticeCategory category, NoticeCursor cursor, int size); - Notice getNotice(Long id); - Notice create(Long createdBy, NoticeCreateCommand command); + /** - * 공지를 수정한다. 명령의 수정 시각이 저장된 값과 다르면(그 사이 다른 관리자가 고친 경우) - * {@code NOTICE_UPDATE_CONFLICT}로 거부한다. + * 운영진용 공지 목록. 종류({@code category})와 제목 검색어({@code keyword})로 거르고 고정 우선, 최신순으로 + * 페이지 단위 조회한다. 종류가 null이면 전체, 검색어가 null이거나 공백이면 거르지 않는다. */ + PageResult search(NoticeCategory category, String keyword, PageOffset pageOffset); + + Notice getNotice(Long id); + Notice create(Long createdBy, NoticeCreateCommand command); + 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 8597370c..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 @@ -1,10 +1,10 @@ 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; +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; @@ -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) { @@ -35,8 +35,16 @@ public CursorSliceResult getNotices(NoticeCategory category, NoticeCurso return noticeRepository.findAll(category, cursor, size); } + // 목록과 전체 개수를 두 문장으로 조회한다. 저장소의 @Query 메서드에는 트랜잭션이 붙지 않으므로, 두 조회가 같은 스냅샷을 + // 보도록 읽기 트랜잭션으로 묶는다 @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); + } + + @Override public Notice getNotice(Long id) { return noticeRepository.findById(id) .orElseThrow(() -> new BusinessException(NoticeErrorCode.NOTICE_NOT_FOUND)); @@ -55,8 +63,6 @@ public Notice create(Long createdBy, NoticeCreateCommand command) { @Transactional public Notice update(Long id, NoticeUpdateCommand command) { Notice notice = getNotice(id); - validateNotModified(notice, command.expectedUpdatedAt()); - notice.update(command); return noticeRepository.save(notice); } @@ -68,20 +74,4 @@ public void delete(Long id) { throw new BusinessException(NoticeErrorCode.NOTICE_NOT_FOUND); } } - - @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); - 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 3def9584..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 @@ -15,9 +15,12 @@ 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.type.SqlTypes; +// 바뀐 컬럼만 UPDATE한다(@DynamicUpdate). 수정과 삭제가 겹쳤을 때 읽어 둔 나머지 컬럼까지 되써서 삭제 표시(is_deleted)나 +// 다른 관리자가 방금 고친 값을 되돌리는 일을 막는다. 동시 수정을 막는 락은 두지 않아 겹치면 나중에 저장한 내용이 반영된다. @Entity @Table( name = "notices", @@ -25,6 +28,7 @@ @Index(name = "idx_notices_is_deleted_pinned_created_at", columnList = "is_deleted, pinned, created_at") } ) +@DynamicUpdate @NoArgsConstructor(access = AccessLevel.PROTECTED) public class NoticeJpaEntity extends BaseSoftDeleteEntity { 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..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 @@ -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,26 @@ public interface NoticeJpaRepository extends JpaRepository findByIdAndIsDeletedFalse(Long id); + // LIKE 대신 LOCATE를 써서 %·_ 같은 문자도 와일드카드가 아닌 글자 그대로 검색된다 + @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) + 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("category") NoticeCategory category, + @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..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 @@ -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(NoticeCategory category, String keyword, PageOffset pageOffset) { + Page result = noticeJpaRepository.search( + category, 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);