diff --git a/api/app-api/build.gradle.kts b/api/app-api/build.gradle.kts index 42bcd18..6b53d86 100644 --- a/api/app-api/build.gradle.kts +++ b/api/app-api/build.gradle.kts @@ -7,6 +7,7 @@ description = "학생 앱 — STUDENT, /v1/app/**" dependencies { implementation(project(":api:common-api")) implementation(project(":core:common")) + implementation(project(":core:domain:welfare")) implementation(project(":gateway:auth")) implementation(project(":gateway:logging")) diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/welfare/AppNoticeController.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/welfare/AppNoticeController.java new file mode 100644 index 0000000..d91ef06 --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/welfare/AppNoticeController.java @@ -0,0 +1,46 @@ +package kr.ac.kookmin.stream.welfare; + +import kr.ac.kookmin.stream.ApiResponse; +import kr.ac.kookmin.stream.CursorCodec; +import kr.ac.kookmin.stream.CursorSliceResponse; +import kr.ac.kookmin.stream.common.CursorSliceResult; +import kr.ac.kookmin.stream.welfare.domain.notice.domain.Notice; +import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCategory; +import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCursor; +import kr.ac.kookmin.stream.welfare.domain.notice.service.NoticeService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/v1/app/notices") +@RequiredArgsConstructor +public class AppNoticeController { + + private final NoticeService noticeService; + + @GetMapping + public ApiResponse> getNotices( + @RequestParam(name = "category", required = false) String category, + @RequestParam(name = "cursor", required = false) String cursor, + @RequestParam(name = "size", defaultValue = "20") int size + ) { + NoticeCursor noticeCursor = cursor == null ? null : NoticeCursor.from(CursorCodec.decode(cursor)); + CursorSliceResult result = noticeService.getNotices(NoticeCategory.from(category), noticeCursor, size); + CursorSliceResponse response = new CursorSliceResponse<>( + result.content().stream().map(NoticeListItemResponse::from).toList(), + result.hasNext(), + result.nextCursor() == null ? null : CursorCodec.encode(result.nextCursor()) + ); + return ApiResponse.success(response); + } + + @GetMapping("/{noticeId}") + public ApiResponse getNotice(@PathVariable("noticeId") Long noticeId) { + Notice notice = noticeService.getNotice(noticeId); + return ApiResponse.success(NoticeDetailResponse.from(notice)); + } +} diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/welfare/NoticeDetailResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/welfare/NoticeDetailResponse.java new file mode 100644 index 0000000..63aa62e --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/welfare/NoticeDetailResponse.java @@ -0,0 +1,50 @@ +package kr.ac.kookmin.stream.welfare; + +import java.time.LocalDateTime; +import java.util.List; +import kr.ac.kookmin.stream.welfare.domain.notice.domain.Notice; +import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCategory; + +public record NoticeDetailResponse( + Long noticeId, + String title, + String content, + NoticeCategory category, + List images, + List attachments, + LocalDateTime createdAt +) { + + public static NoticeDetailResponse from(Notice notice) { + List images = notice.getImageIds() == null + ? List.of() + : notice.getImageIds().stream().map(Image::from).toList(); + List attachments = notice.getAttachmentIds() == null + ? List.of() + : notice.getAttachmentIds().stream().map(Attachment::from).toList(); + + return new NoticeDetailResponse( + notice.getId(), + notice.getTitle(), + notice.getContent(), + notice.getCategory(), + images, + attachments, + notice.getCreatedAt() + ); + } + + public record Image(Long fileId, String fileUrl) { + + public static Image from(Long fileId) { + return new Image(fileId, null); + } + } + + public record Attachment(Long fileId, String fileName, String fileUrl) { + + public static Attachment from(Long fileId) { + return new Attachment(fileId, null, null); + } + } +} diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/welfare/NoticeListItemResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/welfare/NoticeListItemResponse.java new file mode 100644 index 0000000..b70ca14 --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/welfare/NoticeListItemResponse.java @@ -0,0 +1,26 @@ +package kr.ac.kookmin.stream.welfare; + +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 NoticeListItemResponse( + Long noticeId, + String title, + NoticeCategory category, + LocalDateTime createdAt, + String thumbnailUrl, + boolean pinned +) { + + public static NoticeListItemResponse from(Notice notice) { + return new NoticeListItemResponse( + notice.getId(), + notice.getTitle(), + notice.getCategory(), + notice.getCreatedAt(), + null, + notice.isPinned() + ); + } +} diff --git a/api/common-api/src/main/java/kr/ac/kookmin/stream/CursorCodec.java b/api/common-api/src/main/java/kr/ac/kookmin/stream/CursorCodec.java new file mode 100644 index 0000000..51e5245 --- /dev/null +++ b/api/common-api/src/main/java/kr/ac/kookmin/stream/CursorCodec.java @@ -0,0 +1,25 @@ +package kr.ac.kookmin.stream; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import kr.ac.kookmin.stream.common.BusinessException; +import kr.ac.kookmin.stream.common.CommonErrorCode; + +// 커서 문자열을 클라이언트에게 불투명한 토큰으로 감싼다. 실제 정렬 키 파싱은 각 도메인이 담당하고, +// 여기서는 웹(쿼리 파라미터)으로 오가는 형태(Base64 URL-safe)만 다룬다. +public final class CursorCodec { + + private CursorCodec() {} + + public static String encode(String raw) { + return Base64.getUrlEncoder().withoutPadding().encodeToString(raw.getBytes(StandardCharsets.UTF_8)); + } + + public static String decode(String cursor) { + try { + return new String(Base64.getUrlDecoder().decode(cursor), StandardCharsets.UTF_8); + } catch (IllegalArgumentException e) { + throw new BusinessException(CommonErrorCode.INVALID_INPUT); + } + } +} diff --git a/api/common-api/src/main/java/kr/ac/kookmin/stream/CursorSliceResponse.java b/api/common-api/src/main/java/kr/ac/kookmin/stream/CursorSliceResponse.java index 21e6277..019667d 100644 --- a/api/common-api/src/main/java/kr/ac/kookmin/stream/CursorSliceResponse.java +++ b/api/common-api/src/main/java/kr/ac/kookmin/stream/CursorSliceResponse.java @@ -3,7 +3,7 @@ import java.util.List; import kr.ac.kookmin.stream.common.CursorSliceResult; -public record CursorSliceResponse(List content, boolean hasNext, Long nextCursor) { +public record CursorSliceResponse(List content, boolean hasNext, String nextCursor) { public static CursorSliceResponse from(CursorSliceResult result) { return new CursorSliceResponse<>( diff --git a/core/common/src/main/java/kr/ac/kookmin/stream/common/CursorSliceResult.java b/core/common/src/main/java/kr/ac/kookmin/stream/common/CursorSliceResult.java index 4bd0d3d..556170c 100644 --- a/core/common/src/main/java/kr/ac/kookmin/stream/common/CursorSliceResult.java +++ b/core/common/src/main/java/kr/ac/kookmin/stream/common/CursorSliceResult.java @@ -2,4 +2,4 @@ import java.util.List; -public record CursorSliceResult(List content, boolean hasNext, Long nextCursor) {} +public record CursorSliceResult(List content, boolean hasNext, String nextCursor) {} 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 af2e2b2..cfbbd39 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 @@ -1,5 +1,6 @@ package kr.ac.kookmin.stream.welfare.domain.notice.domain; +import java.time.LocalDateTime; import java.util.List; import lombok.AccessLevel; import lombok.AllArgsConstructor; @@ -19,6 +20,7 @@ public class Notice { private Long createdBy; private List attachmentIds; private List imageIds; + private LocalDateTime createdAt; public static Notice of( Long id, @@ -28,8 +30,9 @@ public static Notice of( boolean pinned, Long createdBy, List attachmentIds, - List imageIds + List imageIds, + LocalDateTime createdAt ) { - return new Notice(id, title, content, category, pinned, createdBy, attachmentIds, imageIds); + return new Notice(id, title, content, category, pinned, createdBy, attachmentIds, imageIds, createdAt); } } diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/domain/NoticeCategory.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/domain/NoticeCategory.java index e241a79..e30fc66 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/domain/NoticeCategory.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/domain/NoticeCategory.java @@ -1,6 +1,20 @@ package kr.ac.kookmin.stream.welfare.domain.notice.domain; +import kr.ac.kookmin.stream.common.BusinessException; +import kr.ac.kookmin.stream.common.CommonErrorCode; + public enum NoticeCategory { GENERAL, - PARTNERSHIP + PARTNERSHIP; + + public static NoticeCategory from(String value) { + if (value == null) { + return null; + } + try { + return NoticeCategory.valueOf(value); + } catch (IllegalArgumentException e) { + throw new BusinessException(CommonErrorCode.INVALID_INPUT); + } + } } diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/domain/NoticeCursor.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/domain/NoticeCursor.java new file mode 100644 index 0000000..4623c47 --- /dev/null +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/domain/NoticeCursor.java @@ -0,0 +1,37 @@ +package kr.ac.kookmin.stream.welfare.domain.notice.domain; + +import java.time.LocalDateTime; +import kr.ac.kookmin.stream.common.BusinessException; + +public record NoticeCursor(boolean pinned, LocalDateTime createdAt, Long id, NoticeCategory category) { + + private static final String JOIN = "|"; + private static final String SPLIT_REGEX = "\\|"; + private static final String NO_CATEGORY = "-"; + + public static NoticeCursor of(Notice notice, NoticeCategory category) { + return new NoticeCursor(notice.isPinned(), notice.getCreatedAt(), notice.getId(), category); + } + + // Base64 인코딩은 웹(Controller) 계층 책임이라 여기서는 순수 문자열 표현만 다룬다 + public static NoticeCursor from(String raw) { + String[] parts = raw.split(SPLIT_REGEX, -1); + if (parts.length != 4) { + throw new BusinessException(NoticeErrorCode.NOTICE_INVALID_CURSOR); + } + try { + boolean pinned = Boolean.parseBoolean(parts[0]); + LocalDateTime createdAt = LocalDateTime.parse(parts[1]); + Long id = Long.valueOf(parts[2]); + NoticeCategory category = NO_CATEGORY.equals(parts[3]) ? null : NoticeCategory.valueOf(parts[3]); + return new NoticeCursor(pinned, createdAt, id, category); + } catch (RuntimeException e) { + throw new BusinessException(NoticeErrorCode.NOTICE_INVALID_CURSOR); + } + } + + public String format() { + String categoryPart = category == null ? NO_CATEGORY : category.name(); + return pinned + JOIN + createdAt + JOIN + id + JOIN + categoryPart; + } +} 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 new file mode 100644 index 0000000..dd50ba7 --- /dev/null +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/domain/NoticeErrorCode.java @@ -0,0 +1,19 @@ +package kr.ac.kookmin.stream.welfare.domain.notice.domain; + +import kr.ac.kookmin.stream.common.ErrorCode; +import kr.ac.kookmin.stream.common.ErrorStatus; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.experimental.Accessors; + +@Getter +@Accessors(fluent = true) +@AllArgsConstructor +public enum NoticeErrorCode implements ErrorCode { + + NOTICE_NOT_FOUND(ErrorStatus.NOT_FOUND, "공지를 찾을 수 없습니다."), + 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/repository/NoticeRepository.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/repository/NoticeRepository.java new file mode 100644 index 0000000..5e9b101 --- /dev/null +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/repository/NoticeRepository.java @@ -0,0 +1,12 @@ +package kr.ac.kookmin.stream.welfare.domain.notice.repository; + +import java.util.Optional; +import kr.ac.kookmin.stream.common.CursorSliceResult; +import kr.ac.kookmin.stream.welfare.domain.notice.domain.Notice; +import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCategory; +import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCursor; + +public interface NoticeRepository { + CursorSliceResult findAll(NoticeCategory category, NoticeCursor cursor, int size); + Optional findById(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 new file mode 100644 index 0000000..881d912 --- /dev/null +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/service/NoticeService.java @@ -0,0 +1,11 @@ +package kr.ac.kookmin.stream.welfare.domain.notice.service; + +import kr.ac.kookmin.stream.common.CursorSliceResult; +import kr.ac.kookmin.stream.welfare.domain.notice.domain.Notice; +import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCategory; +import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCursor; + +public interface NoticeService { + CursorSliceResult getNotices(NoticeCategory category, NoticeCursor cursor, int size); + Notice getNotice(Long id); +} diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/service/impl/NoticeServiceImpl.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/service/impl/NoticeServiceImpl.java new file mode 100644 index 0000000..21ed908 --- /dev/null +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/service/impl/NoticeServiceImpl.java @@ -0,0 +1,37 @@ +package kr.ac.kookmin.stream.welfare.domain.notice.service.impl; + +import kr.ac.kookmin.stream.common.BusinessException; +import kr.ac.kookmin.stream.common.CursorSliceResult; +import kr.ac.kookmin.stream.welfare.domain.notice.domain.Notice; +import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCategory; +import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCursor; +import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeErrorCode; +import kr.ac.kookmin.stream.welfare.domain.notice.repository.NoticeRepository; +import kr.ac.kookmin.stream.welfare.domain.notice.service.NoticeService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +class NoticeServiceImpl implements NoticeService { + + private final NoticeRepository noticeRepository; + + @Override + @Transactional(readOnly = true) + public CursorSliceResult getNotices(NoticeCategory category, NoticeCursor cursor, int size) { + // 커서가 다른 category 필터에서 발급됐다면 keyset 경계가 다른 정렬 결과를 가리키므로 거부한다 + if (cursor != null && cursor.category() != category) { + throw new BusinessException(NoticeErrorCode.NOTICE_INVALID_CURSOR); + } + return noticeRepository.findAll(category, cursor, size); + } + + @Override + @Transactional(readOnly = true) + public Notice getNotice(Long id) { + return noticeRepository.findById(id) + .orElseThrow(() -> new BusinessException(NoticeErrorCode.NOTICE_NOT_FOUND)); + } +} diff --git a/docs/conventions/coding-style.md b/docs/conventions/coding-style.md index ccc5d9b..f464bf3 100644 --- a/docs/conventions/coding-style.md +++ b/docs/conventions/coding-style.md @@ -72,6 +72,22 @@ public record MemberResponse( public record MemberRegisterCommand(String studentNo, String name) {} ``` +**오직 하나의 Response에서만 쓰이는 하위 DTO**는 별도 파일로 빼지 않고 그 Response 안에 중첩 `record`로 선언한다. 다른 곳에서도 쓰이게 되면 그 시점에 최상위 파일로 승격한다. + +```java +// api:app-api +public record NoticeDetailResponse( + Long noticeId, + List images +) { + public record Image(Long fileId, String fileUrl) { + public static Image from(Long fileId) { + return new Image(fileId, null); + } + } +} +``` + **공통 응답 래퍼 (`ApiResponse`, `api:common-api`)** — `private` 생성자 + 정적 팩토리. ```java 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 3e80da0..8d8b739 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,6 @@ public static NoticeJpaEntity from(Notice notice) { } public Notice toDomain() { - return Notice.of(id, title, content, category, pinned, createdBy, attachmentIds, imageIds); + return Notice.of(id, title, content, category, pinned, createdBy, attachmentIds, imageIds, getCreatedAt()); } } 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 new file mode 100644 index 0000000..9ee415e --- /dev/null +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/NoticeJpaRepository.java @@ -0,0 +1,42 @@ +package kr.ac.kookmin.stream.db.welfare; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCategory; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface NoticeJpaRepository extends JpaRepository { + + Optional findByIdAndIsDeletedFalse(Long id); + + @Query(""" + SELECT n FROM NoticeJpaEntity n + WHERE n.isDeleted = false + AND (:category IS NULL OR n.category = :category) + ORDER BY n.pinned DESC, n.createdAt DESC, n.id DESC + """) + List findFirstSlice(@Param("category") NoticeCategory category, Pageable pageable); + + @Query(""" + SELECT n FROM NoticeJpaEntity n + WHERE n.isDeleted = false + AND (:category IS NULL OR n.category = :category) + AND ( + (CASE WHEN n.pinned = true THEN 1 ELSE 0 END) < (CASE WHEN :cursorPinned = true THEN 1 ELSE 0 END) + OR (n.pinned = :cursorPinned AND n.createdAt < :cursorCreatedAt) + OR (n.pinned = :cursorPinned AND n.createdAt = :cursorCreatedAt AND n.id < :cursorId) + ) + ORDER BY n.pinned DESC, n.createdAt DESC, n.id DESC + """) + List findNextSlice( + @Param("category") NoticeCategory category, + @Param("cursorPinned") boolean cursorPinned, + @Param("cursorCreatedAt") LocalDateTime cursorCreatedAt, + @Param("cursorId") Long cursorId, + Pageable pageable + ); +} 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 new file mode 100644 index 0000000..837b59c --- /dev/null +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/NoticeRepositoryImpl.java @@ -0,0 +1,41 @@ +package kr.ac.kookmin.stream.db.welfare; + +import java.util.List; +import java.util.Optional; +import kr.ac.kookmin.stream.common.CursorSliceResult; +import kr.ac.kookmin.stream.welfare.domain.notice.domain.Notice; +import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCategory; +import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCursor; +import kr.ac.kookmin.stream.welfare.domain.notice.repository.NoticeRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class NoticeRepositoryImpl implements NoticeRepository { + + private final NoticeJpaRepository noticeJpaRepository; + + @Override + public CursorSliceResult findAll(NoticeCategory category, NoticeCursor cursor, int size) { + Pageable pageable = Pageable.ofSize(size + 1); + List entities = cursor == null + ? noticeJpaRepository.findFirstSlice(category, pageable) + : noticeJpaRepository.findNextSlice(category, cursor.pinned(), cursor.createdAt(), cursor.id(), pageable); + + boolean hasNext = entities.size() > size; + List content = entities.stream() + .limit(size) + .map(NoticeJpaEntity::toDomain) + .toList(); + String nextCursor = hasNext ? NoticeCursor.of(content.get(content.size() - 1), category).format() : null; + + return new CursorSliceResult<>(content, hasNext, nextCursor); + } + + @Override + public Optional findById(Long id) { + return noticeJpaRepository.findByIdAndIsDeletedFalse(id).map(NoticeJpaEntity::toDomain); + } +}