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