Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -32,7 +32,7 @@
@RequiredArgsConstructor
public class AdminNoticeController implements AdminNoticeApi {

private final NoticeService noticeService;
private final AdminNoticeService adminNoticeService;
private final AdminNoticeWriteUseCase adminNoticeWriteUseCase;

@Override
Expand All @@ -43,7 +43,7 @@ public ApiResponse<PageResponse<AdminNoticeListItemResponse>> getNotices(
@RequestParam(required = false) String keyword,
@Valid @ModelAttribute PageParams pageParams
) {
PageResult<Notice> result = noticeService.search(NoticeCategory.from(category), keyword, pageParams.toOffset());
PageResult<Notice> result = adminNoticeService.search(NoticeCategory.from(category), keyword, pageParams.toOffset());
return ApiResponse.success(PageResponse.from(result, AdminNoticeListItemResponse::from));
}

Expand Down Expand Up @@ -71,7 +71,7 @@ public ApiResponse<NoticeUpdateResponse> updateNotice(
@Override
@DeleteMapping("/{noticeId}")
public ApiResponse<Void> deleteNotice(AdminApiUser apiUser, @PathVariable Long noticeId) {
noticeService.delete(noticeId);
adminNoticeService.delete(noticeId);
return ApiResponse.success();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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<Long> imageIds, List<Long> attachmentIds) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ public ApiResponse<CursorSliceResponse<NoticeListItemResponse>> getNotices(
@Override
@GetMapping("/{noticeId}")
public ApiResponse<NoticeDetailResponse> getNotice(@PathVariable("noticeId") Long noticeId) {
Notice notice = noticeService.getNotice(noticeId);
Notice notice = noticeService.getById(noticeId);

Map<Long, File> imagesById = fileService.findAllByIdIn(
notice.getImageIds() == null ? List.of() : notice.getImageIds()
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Notice> search(NoticeCategory category, String keyword, PageOffset pageOffset);

Notice create(Long createdBy, NoticeCreateCommand command);

Notice update(Long id, NoticeUpdateCommand command);

void delete(Long id);
}
Original file line number Diff line number Diff line change
@@ -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<Notice> getNotices(NoticeCategory category, NoticeCursor cursor, int size);


/**
* 운영진용 공지 목록. 종류({@code category})와 제목 검색어({@code keyword})로 거르고 고정 우선, 최신순으로
* 페이지 단위 조회한다. 종류가 null이면 전체, 검색어가 null이거나 공백이면 거르지 않는다.
*/
PageResult<Notice> 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);
}
Original file line number Diff line number Diff line change
@@ -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<Notice> 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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -35,44 +27,9 @@ public CursorSliceResult<Notice> getNotices(NoticeCategory category, NoticeCurso
return noticeRepository.findAll(category, cursor, size);
}

// 목록과 전체 개수를 두 문장으로 조회한다. 저장소의 @Query 메서드에는 트랜잭션이 붙지 않으므로, 두 조회가 같은 스냅샷을
// 보도록 읽기 트랜잭션으로 묶는다
@Override
@Transactional(readOnly = true)
public PageResult<Notice> 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);
}
}
}
2 changes: 2 additions & 0 deletions docs/conventions/coding-style.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down Expand Up @@ -282,6 +283,7 @@ Map<Long, Long> countAppliedByEventIds(List<Long> 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 (공개)
Expand Down