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 24fb3c45..2753d334 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 @@ -14,7 +14,7 @@ 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 kr.ac.kookmin.stream.welfare.domain.notice.service.AdminNoticeService; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -32,7 +32,7 @@ @RequiredArgsConstructor public class AdminNoticeController implements AdminNoticeApi { - private final NoticeService noticeService; + private final AdminNoticeService adminNoticeService; private final AdminNoticeWriteUseCase adminNoticeWriteUseCase; @Override @@ -43,7 +43,7 @@ public ApiResponse> getNotices( @RequestParam(required = false) String keyword, @Valid @ModelAttribute PageParams pageParams ) { - PageResult result = noticeService.search(NoticeCategory.from(category), keyword, pageParams.toOffset()); + PageResult result = adminNoticeService.search(NoticeCategory.from(category), keyword, pageParams.toOffset()); return ApiResponse.success(PageResponse.from(result, AdminNoticeListItemResponse::from)); } @@ -71,7 +71,7 @@ public ApiResponse updateNotice( @Override @DeleteMapping("/{noticeId}") public ApiResponse deleteNotice(AdminApiUser apiUser, @PathVariable Long noticeId) { - noticeService.delete(noticeId); + adminNoticeService.delete(noticeId); return ApiResponse.success(); } } 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 index a1a5f2b3..54bfb47c 100644 --- 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 @@ -10,7 +10,7 @@ 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 kr.ac.kookmin.stream.welfare.domain.notice.service.AdminNoticeService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -25,17 +25,17 @@ @RequiredArgsConstructor public class AdminNoticeWriteUseCase { - private final NoticeService noticeService; + private final AdminNoticeService adminNoticeService; private final FileService fileService; public Notice create(Long createdBy, NoticeCreateCommand command) { requireFilesExist(command.imageIds(), command.attachmentIds()); - return noticeService.create(createdBy, command); + return adminNoticeService.create(createdBy, command); } public Notice update(Long noticeId, NoticeUpdateCommand command) { requireFilesExist(command.imageIds(), command.attachmentIds()); - return noticeService.update(noticeId, command); + return adminNoticeService.update(noticeId, command); } private void requireFilesExist(List imageIds, List attachmentIds) { diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/notice/AppNoticeController.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/notice/AppNoticeController.java index a2350cc3..03d7eaae 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/notice/AppNoticeController.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/notice/AppNoticeController.java @@ -56,7 +56,7 @@ public ApiResponse> getNotices( @Override @GetMapping("/{noticeId}") public ApiResponse getNotice(@PathVariable("noticeId") Long noticeId) { - Notice notice = noticeService.getNotice(noticeId); + Notice notice = noticeService.getById(noticeId); Map imagesById = fileService.findAllByIdIn( notice.getImageIds() == null ? List.of() : notice.getImageIds() diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/service/AdminNoticeService.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/service/AdminNoticeService.java new file mode 100644 index 00000000..11ae0edb --- /dev/null +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/service/AdminNoticeService.java @@ -0,0 +1,26 @@ +package kr.ac.kookmin.stream.welfare.domain.notice.service; + +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; +import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeUpdateCommand; + +/** + * 운영진용 공지 서비스. 목록 조회·작성·수정·삭제를 맡고, 상세 조회는 운영진도 {@link NoticeService}를 쓴다. + */ +public interface AdminNoticeService { + + /** + * 공지 목록. 종류({@code category})와 제목 검색어({@code keyword})로 거르고 고정 우선, 최신순으로 페이지 단위 + * 조회한다. 종류가 null이면 전체, 검색어가 null이거나 공백이면 거르지 않는다. + */ + PageResult search(NoticeCategory category, String keyword, PageOffset pageOffset); + + Notice create(Long createdBy, NoticeCreateCommand command); + + Notice update(Long id, NoticeUpdateCommand command); + + void 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 e9bba81e..63d705c5 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,28 +1,15 @@ 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; import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCursor; -import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeUpdateCommand; +/** + * 공지 조회. 학생 앱이 쓰고, 운영진도 상세 조회에 같은 API를 쓴다. 운영진의 목록 조회·작성·수정·삭제는 {@link AdminNoticeService}가 맡는다. + */ public interface NoticeService { CursorSliceResult getNotices(NoticeCategory category, NoticeCursor cursor, int size); - - /** - * 운영진용 공지 목록. 종류({@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); + Notice getById(Long id); } diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/service/impl/AdminNoticeServiceImpl.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/service/impl/AdminNoticeServiceImpl.java new file mode 100644 index 00000000..f37064f1 --- /dev/null +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/service/impl/AdminNoticeServiceImpl.java @@ -0,0 +1,61 @@ +package kr.ac.kookmin.stream.welfare.domain.notice.service.impl; + +import kr.ac.kookmin.stream.common.BusinessException; +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; +import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCreateCommand; +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.AdminNoticeService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +class AdminNoticeServiceImpl implements AdminNoticeService { + + private final NoticeRepository noticeRepository; + private final OutboxWriter outboxWriter; + + // 목록과 전체 개수를 두 문장으로 조회한다. 저장소의 @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 + @Transactional + public Notice create(Long createdBy, NoticeCreateCommand command) { + Notice notice = noticeRepository.save(Notice.create(createdBy, command)); + outboxWriter.record(new NoticeCreated(notice.getId())); + return notice; + } + + @Override + @Transactional + public Notice update(Long id, NoticeUpdateCommand command) { + Notice notice = noticeRepository.findById(id) + .orElseThrow(() -> new BusinessException(NoticeErrorCode.NOTICE_NOT_FOUND)); + 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); + } + } +} 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 51fb5cf6..a789bc9b 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 @@ -2,28 +2,20 @@ 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; -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; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor class NoticeServiceImpl implements NoticeService { private final NoticeRepository noticeRepository; - private final OutboxWriter outboxWriter; // 조회 쿼리가 1개라 트랜잭션을 걸지 않는다. 쿼리가 늘어 한 스냅샷이 필요해지면 @Transactional(readOnly = true)를 붙인다. @Override @@ -35,44 +27,9 @@ 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) { + public Notice getById(Long id) { return noticeRepository.findById(id) .orElseThrow(() -> new BusinessException(NoticeErrorCode.NOTICE_NOT_FOUND)); } - - // 공지 저장과 알림 이벤트 기록이 함께 커밋되도록 트랜잭션을 건다 - @Override - @Transactional - public Notice create(Long createdBy, NoticeCreateCommand command) { - Notice notice = noticeRepository.save(Notice.create(createdBy, command)); - outboxWriter.record(new NoticeCreated(notice.getId())); - return notice; - } - - @Override - @Transactional - public Notice update(Long id, NoticeUpdateCommand command) { - Notice notice = getNotice(id); - 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); - } - } } diff --git a/docs/conventions/coding-style.md b/docs/conventions/coding-style.md index 6278a2e7..a84bfffa 100644 --- a/docs/conventions/coding-style.md +++ b/docs/conventions/coding-style.md @@ -20,6 +20,7 @@ Business는 공개 인터페이스 + `service.impl` 구현체로 구성한다. | Presentation | 교차 도메인 조합 | `{Feature}UseCase` | `api:{client}-api` | | Business | 공개 진입점 인터페이스 | `{Domain}Service` | `core:domain:{모듈}` `domain/{도메인}/service` (공개) | | Business | 구현체 | `{Domain}ServiceImpl` | `core:domain:{모듈}` `domain/{도메인}/service/impl` (비공개) | +| Business | 운영진 전용 서비스 | `Admin{Domain}Service` / 구현체 `Admin{Domain}ServiceImpl` | 위와 같은 패키지 (2-7절) | | Data Access | Repository 인터페이스 | `{Domain}Repository` | `core:domain:{모듈}` `domain/{도메인}/repository` (공개) | | Data Access | Repository 구현체 | `{Domain}RepositoryImpl` | `infrastructure:db` | | Data Access | JPA Repository | `{Domain}JpaRepository` | `infrastructure:db` | @@ -282,6 +283,7 @@ Map countAppliedByEventIds(List eventIds); - **`{Domain}ServiceImpl`가 `{Domain}Repository`를 직접 참조**한다. - 비즈니스 규칙 검증은 `{Domain}ServiceImpl`(또는 `service.impl` 협력 객체)에서 하고 `BusinessException`을 던진다. - 트랜잭션 경계는 Service 메서드에. 조회 전용은 `@Transactional(readOnly = true)`, 교차 도메인 UseCase가 감쌀 수 있게 **기본 전파(REQUIRED)** 를 쓴다(`architecture.md` 6-1절). +- **운영진 전용 기능은 학생 앱이 쓰는 `{Domain}Service`와 분리해 `Admin{Domain}Service`로 둔다.** 같은 도메인 모듈의 `service`/`service.impl`에 두고 `{Domain}Repository`를 공유하며, 두 서비스는 서로 호출하지 않는다(`architecture.md` 레이어 규칙 4). 운영진 전용 서비스가 필요한 도메인부터 점진적으로 나누고, 분리하기 전의 도메인은 `{Domain}Service` 하나로 둔다. 다른 도메인 조합(예: 공지에 붙일 파일 확인)은 서비스가 아니라 `api:admin-api`의 UseCase가 맡는다. 예: 공지는 학생 앱 조회와 상세 조회를 `NoticeService`(운영진도 상세 조회에 공용), 운영진의 목록 조회·작성·수정·삭제를 `AdminNoticeService`가 맡는다. ```java // core:domain:member — domain/member/service (공개)