diff --git a/api/admin-api/build.gradle.kts b/api/admin-api/build.gradle.kts
index bd5f43b3..983679fe 100644
--- a/api/admin-api/build.gradle.kts
+++ b/api/admin-api/build.gradle.kts
@@ -7,6 +7,7 @@ description = "운영진 콘솔 — ADMIN, /v1/admin/**"
dependencies {
implementation(project(":api:common-api"))
implementation(project(":core:common"))
+ implementation(project(":core:domain:file"))
implementation(project(":core:domain:internal"))
implementation(project(":core:domain:member"))
implementation(project(":core:domain:welfare"))
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
new file mode 100644
index 00000000..4d00a8a0
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/notice/AdminNoticeApi.java
@@ -0,0 +1,54 @@
+package kr.ac.kookmin.stream.api.admin.welfare.notice;
+
+import io.swagger.v3.oas.annotations.Operation;
+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.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.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;
+
+/**
+ * 운영진 공지 API의 문서 명세. 구현은 {@link AdminNoticeController}가 맡는다.
+ *
+ * 접근은 {@code ADMIN} role이면 된다. 부서 제한은 두지 않았다.
+ */
+@Tag(name = "공지 관리", description = "운영진 공지 작성·수정·삭제·고정")
+public interface AdminNoticeApi {
+
+ /** 공지 작성. 작성자는 토큰의 관리자로 정해진다. */
+ @Operation(summary = "공지 작성",
+ description = "공지를 등록한다. 이미지·첨부 파일은 업로드를 마친 파일 ID로 넘기며, 존재하지 않는 파일이 하나라도 있으면 "
+ + "등록하지 않는다. 새 공지는 고정되지 않은 상태로 만들어진다.")
+ @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로 거부한다.")
+ @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"})
+ ApiResponse updateNotice(AdminApiUser apiUser, Long noticeId, NoticeUpdateRequest request);
+
+ /** 공지 삭제. 소프트 삭제라 학생 앱 목록·상세에서 보이지 않게 된다. */
+ @Operation(summary = "공지 삭제",
+ 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
new file mode 100644
index 00000000..bd84a8ec
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/notice/AdminNoticeController.java
@@ -0,0 +1,70 @@
+package kr.ac.kookmin.stream.api.admin.welfare.notice;
+
+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.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.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.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.RestController;
+
+@RestController
+@RequestMapping("/v1/admin/notices")
+@RequiredArgsConstructor
+public class AdminNoticeController implements AdminNoticeApi {
+
+ private final NoticeService noticeService;
+ private final AdminNoticeWriteUseCase adminNoticeWriteUseCase;
+
+ @Override
+ @PostMapping
+ public ApiResponse createNotice(
+ AdminApiUser apiUser,
+ @Valid @RequestBody NoticeCreateRequest request
+ ) {
+ Notice notice = adminNoticeWriteUseCase.create(apiUser.userId(), request.toCommand());
+ return ApiResponse.success(NoticeCreateResponse.from(notice));
+ }
+
+ @Override
+ @PutMapping("/{noticeId}")
+ public ApiResponse updateNotice(
+ AdminApiUser apiUser,
+ @PathVariable Long noticeId,
+ @Valid @RequestBody NoticeUpdateRequest request
+ ) {
+ Notice notice = adminNoticeWriteUseCase.update(noticeId, request.toCommand());
+ return ApiResponse.success(NoticeUpdateResponse.from(notice));
+ }
+
+ @Override
+ @DeleteMapping("/{noticeId}")
+ public ApiResponse deleteNotice(AdminApiUser apiUser, @PathVariable Long noticeId) {
+ 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
new file mode 100644
index 00000000..11c1a912
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/notice/request/NoticeCreateRequest.java
@@ -0,0 +1,48 @@
+package kr.ac.kookmin.stream.api.admin.welfare.notice.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import java.util.List;
+import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCategory;
+import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCreateCommand;
+
+/**
+ * 작성자는 요청 본문이 아니라 토큰의 관리자 ID로 정한다. 본문에 {@code createdBy}가 와도 읽지 않는다.
+ */
+public record NoticeCreateRequest(
+ @Schema(description = "제목", example = "제목 예시")
+ @NotBlank(message = "제목을 입력해 주세요.")
+ @Size(max = 200, message = "제목은 200자 이하로 입력해 주세요.")
+ String title,
+
+ // content는 TEXT 컬럼(최대 65,535바이트)이다. 글자당 최대 4바이트로 잡아도 넘치지 않는 길이로 막는다
+ @Schema(description = "본문(HTML)", example = "공지 내용입니다.
")
+ @NotBlank(message = "본문을 입력해 주세요.")
+ @Size(max = 16000, message = "본문은 16000자 이하로 입력해 주세요.")
+ String content,
+
+ @Schema(description = "공지 종류")
+ @NotNull(message = "공지 종류를 입력해 주세요.")
+ NoticeCategory category,
+
+ @Schema(description = "이미지 파일 ID 목록. 첫 번째가 목록의 썸네일이 된다", example = "[101, 102]")
+ @NotNull(message = "이미지 파일 목록을 입력해 주세요.")
+ List<@NotNull(message = "이미지 파일 ID는 비워 둘 수 없습니다.") Long> imageFileIds,
+
+ @Schema(description = "첨부 파일 ID 목록", example = "[201]")
+ @NotNull(message = "첨부 파일 목록을 입력해 주세요.")
+ List<@NotNull(message = "첨부 파일 ID는 비워 둘 수 없습니다.") Long> attachmentFileIds
+) {
+
+ public NoticeCreateCommand toCommand() {
+ return new NoticeCreateCommand(
+ title,
+ content,
+ category,
+ imageFileIds.stream().distinct().toList(),
+ attachmentFileIds.stream().distinct().toList()
+ );
+ }
+}
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
new file mode 100644
index 00000000..16b0a747
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/notice/request/NoticePinRequest.java
@@ -0,0 +1,11 @@
+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
new file mode 100644
index 00000000..64ed9905
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/notice/request/NoticeUpdateRequest.java
@@ -0,0 +1,54 @@
+package kr.ac.kookmin.stream.api.admin.welfare.notice.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+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;
+
+public record NoticeUpdateRequest(
+ @Schema(description = "제목", example = "제목 예시")
+ @NotBlank(message = "제목을 입력해 주세요.")
+ @Size(max = 200, message = "제목은 200자 이하로 입력해 주세요.")
+ String title,
+
+ // content는 TEXT 컬럼(최대 65,535바이트)이다. 글자당 최대 4바이트로 잡아도 넘치지 않는 길이로 막는다
+ @Schema(description = "본문(HTML)", example = "공지 내용입니다.
")
+ @NotBlank(message = "본문을 입력해 주세요.")
+ @Size(max = 16000, message = "본문은 16000자 이하로 입력해 주세요.")
+ String content,
+
+ @Schema(description = "공지 종류")
+ @NotNull(message = "공지 종류를 입력해 주세요.")
+ NoticeCategory category,
+
+ @Schema(description = "이미지 파일 ID 목록. 첫 번째가 목록의 썸네일이 된다", example = "[101, 103]")
+ @NotNull(message = "이미지 파일 목록을 입력해 주세요.")
+ List<@NotNull(message = "이미지 파일 ID는 비워 둘 수 없습니다.") Long> imageFileIds,
+
+ @Schema(description = "첨부 파일 ID 목록", example = "[201]")
+ @NotNull(message = "첨부 파일 목록을 입력해 주세요.")
+ List<@NotNull(message = "첨부 파일 ID는 비워 둘 수 없습니다.") Long> attachmentFileIds,
+
+ @Schema(description = "수정 화면이 공지를 읽었을 때의 수정 시각(KST 기준 epoch 밀리초). 그 사이 다른 관리자가 고쳤다면 409",
+ example = "1756339200000")
+ @NotNull(message = "수정 시각을 입력해 주세요.")
+ Long updatedAt
+) {
+
+ public NoticeUpdateCommand toCommand() {
+ return new NoticeUpdateCommand(
+ title,
+ content,
+ category,
+ imageFileIds.stream().distinct().toList(),
+ attachmentFileIds.stream().distinct().toList(),
+ LocalDateTime.ofInstant(Instant.ofEpochMilli(updatedAt), DateUtil.KST)
+ );
+ }
+}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/notice/response/NoticeCreateResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/notice/response/NoticeCreateResponse.java
new file mode 100644
index 00000000..913dde94
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/notice/response/NoticeCreateResponse.java
@@ -0,0 +1,10 @@
+package kr.ac.kookmin.stream.api.admin.welfare.notice.response;
+
+import kr.ac.kookmin.stream.welfare.domain.notice.domain.Notice;
+
+public record NoticeCreateResponse(Long noticeId) {
+
+ public static NoticeCreateResponse from(Notice notice) {
+ return new NoticeCreateResponse(notice.getId());
+ }
+}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/notice/response/NoticeUpdateResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/notice/response/NoticeUpdateResponse.java
new file mode 100644
index 00000000..87c76103
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/notice/response/NoticeUpdateResponse.java
@@ -0,0 +1,10 @@
+package kr.ac.kookmin.stream.api.admin.welfare.notice.response;
+
+import kr.ac.kookmin.stream.welfare.domain.notice.domain.Notice;
+
+public record NoticeUpdateResponse(Long noticeId) {
+
+ public static NoticeUpdateResponse from(Notice notice) {
+ return new NoticeUpdateResponse(notice.getId());
+ }
+}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/notice/usecase/AdminNoticeWriteUseCase.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/notice/usecase/AdminNoticeWriteUseCase.java
new file mode 100644
index 00000000..a1a5f2b3
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/notice/usecase/AdminNoticeWriteUseCase.java
@@ -0,0 +1,49 @@
+package kr.ac.kookmin.stream.api.admin.welfare.notice.usecase;
+
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import kr.ac.kookmin.stream.common.BusinessException;
+import kr.ac.kookmin.stream.file.domain.FileErrorCode;
+import kr.ac.kookmin.stream.file.service.FileService;
+import kr.ac.kookmin.stream.welfare.domain.notice.domain.Notice;
+import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCreateCommand;
+import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeUpdateCommand;
+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 AdminNoticeWriteUseCase {
+
+ private final NoticeService noticeService;
+ private final FileService fileService;
+
+ public Notice create(Long createdBy, NoticeCreateCommand command) {
+ requireFilesExist(command.imageIds(), command.attachmentIds());
+ return noticeService.create(createdBy, command);
+ }
+
+ public Notice update(Long noticeId, NoticeUpdateCommand command) {
+ requireFilesExist(command.imageIds(), command.attachmentIds());
+ return noticeService.update(noticeId, command);
+ }
+
+ private void requireFilesExist(List imageIds, List attachmentIds) {
+ Set fileIds = Stream.concat(imageIds.stream(), attachmentIds.stream())
+ .collect(Collectors.toUnmodifiableSet());
+
+ if (fileService.findAllByIdIn(List.copyOf(fileIds)).size() != fileIds.size()) {
+ throw new BusinessException(FileErrorCode.FILE_NOT_FOUND);
+ }
+ }
+}
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 cfbbd393..bd7f2ccf 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
@@ -21,6 +21,7 @@ public class Notice {
private List attachmentIds;
private List imageIds;
private LocalDateTime createdAt;
+ private LocalDateTime updatedAt;
public static Notice of(
Long id,
@@ -31,8 +32,37 @@ public static Notice of(
Long createdBy,
List attachmentIds,
List imageIds,
- LocalDateTime createdAt
+ LocalDateTime createdAt,
+ LocalDateTime updatedAt
) {
- return new Notice(id, title, content, category, pinned, createdBy, attachmentIds, imageIds, createdAt);
+ return new Notice(id, title, content, category, pinned, createdBy, attachmentIds, imageIds, createdAt, updatedAt);
+ }
+
+ // 새 공지는 고정되지 않은 채로 등록한다. 생성·수정 시각은 DB가 채운다
+ public static Notice create(Long createdBy, NoticeCreateCommand command) {
+ return new Notice(
+ null,
+ command.title(),
+ command.content(),
+ command.category(),
+ false,
+ createdBy,
+ command.attachmentIds(),
+ command.imageIds(),
+ null,
+ null
+ );
+ }
+
+ public void update(NoticeUpdateCommand command) {
+ this.title = command.title();
+ this.content = command.content();
+ this.category = command.category();
+ this.attachmentIds = command.attachmentIds();
+ this.imageIds = command.imageIds();
+ }
+
+ public void changePinned(boolean pinned) {
+ this.pinned = 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
new file mode 100644
index 00000000..4979a048
--- /dev/null
+++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/domain/NoticeCreateCommand.java
@@ -0,0 +1,12 @@
+package kr.ac.kookmin.stream.welfare.domain.notice.domain;
+
+import java.util.List;
+
+public record NoticeCreateCommand(
+ String title,
+ String content,
+ NoticeCategory category,
+ List imageIds,
+ List attachmentIds
+) {
+}
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 dd50ba77..86321490 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,7 +12,8 @@
public enum NoticeErrorCode implements ErrorCode {
NOTICE_NOT_FOUND(ErrorStatus.NOT_FOUND, "공지를 찾을 수 없습니다."),
- NOTICE_INVALID_CURSOR(ErrorStatus.BAD_REQUEST, "유효하지 않은 커서입니다.");
+ NOTICE_INVALID_CURSOR(ErrorStatus.BAD_REQUEST, "유효하지 않은 커서입니다."),
+ NOTICE_UPDATE_CONFLICT(ErrorStatus.CONFLICT, "다른 관리자가 먼저 공지를 수정했습니다. 새로고침한 뒤 다시 수정해 주세요.");
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
new file mode 100644
index 00000000..7082ae32
--- /dev/null
+++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/domain/NoticeUpdateCommand.java
@@ -0,0 +1,17 @@
+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
+) {
+}
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 5e9b101f..1697d3ed 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
@@ -9,4 +9,11 @@
public interface NoticeRepository {
CursorSliceResult findAll(NoticeCategory category, NoticeCursor cursor, int size);
Optional findById(Long id);
+ Notice save(Notice notice);
+
+ /**
+ * 공지를 소프트 삭제한다. 이미 삭제됐거나 없는 공지면 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 881d912e..6e1e1af4 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
@@ -3,9 +3,21 @@
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.NoticeCreateCommand;
import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCursor;
+import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeUpdateCommand;
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}로 거부한다.
+ */
+ 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 21ed9081..f5d5d39f 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,11 +1,15 @@
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.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;
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.domain.NoticeUpdateCommand;
import kr.ac.kookmin.stream.welfare.domain.notice.repository.NoticeRepository;
import kr.ac.kookmin.stream.welfare.domain.notice.service.NoticeService;
import lombok.RequiredArgsConstructor;
@@ -34,4 +38,43 @@ public Notice getNotice(Long id) {
return noticeRepository.findById(id)
.orElseThrow(() -> new BusinessException(NoticeErrorCode.NOTICE_NOT_FOUND));
}
+
+ @Override
+ public Notice create(Long createdBy, NoticeCreateCommand command) {
+ return noticeRepository.save(Notice.create(createdBy, command));
+ }
+
+ @Override
+ @Transactional
+ public Notice update(Long id, NoticeUpdateCommand command) {
+ Notice notice = getNotice(id);
+ validateNotModified(notice, command.expectedUpdatedAt());
+
+ notice.update(command);
+ return noticeRepository.save(notice);
+ }
+
+ @Override
+ @Transactional
+ public void delete(Long id) {
+ if (!noticeRepository.delete(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 7dcb50d2..3def9584 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
@@ -73,6 +73,7 @@ public static NoticeJpaEntity from(Notice notice) {
}
public Notice toDomain() {
- return Notice.of(id, title, content, category, pinned, createdBy, attachmentIds, imageIds, getCreatedAt());
+ return Notice.of(
+ id, title, content, category, pinned, createdBy, attachmentIds, imageIds, getCreatedAt(), getUpdatedAt());
}
}
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 d208b73f..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
@@ -33,4 +33,19 @@ public CursorSliceResult findAll(NoticeCategory category, NoticeCursor c
public Optional findById(Long id) {
return noticeJpaRepository.findByIdAndIsDeletedFalse(id).map(NoticeJpaEntity::toDomain);
}
+
+ @Override
+ public Notice save(Notice notice) {
+ return noticeJpaRepository.save(NoticeJpaEntity.from(notice)).toDomain();
+ }
+
+ @Override
+ public boolean delete(Long id) {
+ return noticeJpaRepository.findByIdAndIsDeletedFalse(id)
+ .map(entity -> {
+ entity.delete();
+ return true;
+ })
+ .orElse(false);
+ }
}