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);