From 94374f1442f11cd8699c35813d9b967d49b9eac4 Mon Sep 17 00:00:00 2001 From: popeye Date: Thu, 12 Mar 2026 14:58:00 +0900 Subject: [PATCH] Add admin notice create and active notice APIs --- .../src/main/resources/application-aws.yml | 2 +- .../notice/dto/ActiveNoticeResponse.java | 17 +++ .../notice/dto/NoticeCreateRequest.java | 23 ++++ .../item/domain/notice/entity/Notice.java | 47 +++++++ .../notice/repository/NoticeRepository.java | 16 +++ .../domain/notice/service/NoticeService.java | 13 ++ .../notice/service/NoticeServiceImpl.java | 58 +++++++++ .../infra/controller/NoticeController.java | 53 ++++++++ .../notice/service/NoticeServiceImplTest.java | 121 ++++++++++++++++++ 9 files changed, 349 insertions(+), 1 deletion(-) create mode 100644 item-service/src/main/java/com/comatching/item/domain/notice/dto/ActiveNoticeResponse.java create mode 100644 item-service/src/main/java/com/comatching/item/domain/notice/dto/NoticeCreateRequest.java create mode 100644 item-service/src/main/java/com/comatching/item/domain/notice/entity/Notice.java create mode 100644 item-service/src/main/java/com/comatching/item/domain/notice/repository/NoticeRepository.java create mode 100644 item-service/src/main/java/com/comatching/item/domain/notice/service/NoticeService.java create mode 100644 item-service/src/main/java/com/comatching/item/domain/notice/service/NoticeServiceImpl.java create mode 100644 item-service/src/main/java/com/comatching/item/infra/controller/NoticeController.java create mode 100644 item-service/src/test/java/com/comatching/item/domain/notice/service/NoticeServiceImplTest.java diff --git a/gateway-service/src/main/resources/application-aws.yml b/gateway-service/src/main/resources/application-aws.yml index e71558b..90bdbee 100644 --- a/gateway-service/src/main/resources/application-aws.yml +++ b/gateway-service/src/main/resources/application-aws.yml @@ -74,7 +74,7 @@ spring: - id: item-service uri: http://item-service:9006 predicates: - - Path=/api/items/**, /api/internal/items/** + - Path=/api/items/**, /api/internal/items/**, /api/v1/** filters: - AuthorizationHeaderFilter diff --git a/item-service/src/main/java/com/comatching/item/domain/notice/dto/ActiveNoticeResponse.java b/item-service/src/main/java/com/comatching/item/domain/notice/dto/ActiveNoticeResponse.java new file mode 100644 index 0000000..bda15ee --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/notice/dto/ActiveNoticeResponse.java @@ -0,0 +1,17 @@ +package com.comatching.item.domain.notice.dto; + +import com.comatching.item.domain.notice.entity.Notice; + +public record ActiveNoticeResponse( + Long noticeId, + String title, + String content +) { + public static ActiveNoticeResponse from(Notice notice) { + return new ActiveNoticeResponse( + notice.getId(), + notice.getTitle(), + notice.getContent() + ); + } +} diff --git a/item-service/src/main/java/com/comatching/item/domain/notice/dto/NoticeCreateRequest.java b/item-service/src/main/java/com/comatching/item/domain/notice/dto/NoticeCreateRequest.java new file mode 100644 index 0000000..155411c --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/notice/dto/NoticeCreateRequest.java @@ -0,0 +1,23 @@ +package com.comatching.item.domain.notice.dto; + +import java.time.LocalDateTime; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record NoticeCreateRequest( + @NotBlank(message = "제목은 필수입니다.") + @Size(max = 200, message = "제목은 200자 이하로 입력해주세요.") + String title, + + @NotBlank(message = "내용은 필수입니다.") + String content, + + @NotNull(message = "시작시간은 필수입니다.") + LocalDateTime startTime, + + @NotNull(message = "종료시간은 필수입니다.") + LocalDateTime endTime +) { +} diff --git a/item-service/src/main/java/com/comatching/item/domain/notice/entity/Notice.java b/item-service/src/main/java/com/comatching/item/domain/notice/entity/Notice.java new file mode 100644 index 0000000..1c08da6 --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/notice/entity/Notice.java @@ -0,0 +1,47 @@ +package com.comatching.item.domain.notice.entity; + +import java.time.LocalDateTime; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Lob; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "notice") +public class Notice { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 200) + private String title; + + @Lob + @Column(nullable = false, columnDefinition = "TEXT") + private String content; + + @Column(nullable = false) + private LocalDateTime startTime; + + @Column(nullable = false) + private LocalDateTime endTime; + + @Builder + public Notice(String title, String content, LocalDateTime startTime, LocalDateTime endTime) { + this.title = title; + this.content = content; + this.startTime = startTime; + this.endTime = endTime; + } +} diff --git a/item-service/src/main/java/com/comatching/item/domain/notice/repository/NoticeRepository.java b/item-service/src/main/java/com/comatching/item/domain/notice/repository/NoticeRepository.java new file mode 100644 index 0000000..99f1784 --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/notice/repository/NoticeRepository.java @@ -0,0 +1,16 @@ +package com.comatching.item.domain.notice.repository; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.comatching.item.domain.notice.entity.Notice; + +public interface NoticeRepository extends JpaRepository { + + List findAllByStartTimeLessThanEqualAndEndTimeGreaterThanEqualOrderByStartTimeDescIdDesc( + LocalDateTime currentTime, + LocalDateTime currentTime2 + ); +} diff --git a/item-service/src/main/java/com/comatching/item/domain/notice/service/NoticeService.java b/item-service/src/main/java/com/comatching/item/domain/notice/service/NoticeService.java new file mode 100644 index 0000000..d0575da --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/notice/service/NoticeService.java @@ -0,0 +1,13 @@ +package com.comatching.item.domain.notice.service; + +import java.util.List; + +import com.comatching.item.domain.notice.dto.ActiveNoticeResponse; +import com.comatching.item.domain.notice.dto.NoticeCreateRequest; + +public interface NoticeService { + + void createNotice(NoticeCreateRequest request); + + List getActiveNotices(); +} diff --git a/item-service/src/main/java/com/comatching/item/domain/notice/service/NoticeServiceImpl.java b/item-service/src/main/java/com/comatching/item/domain/notice/service/NoticeServiceImpl.java new file mode 100644 index 0000000..7c18eab --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/notice/service/NoticeServiceImpl.java @@ -0,0 +1,58 @@ +package com.comatching.item.domain.notice.service; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.comatching.common.exception.BusinessException; +import com.comatching.common.exception.code.GeneralErrorCode; +import com.comatching.item.domain.notice.dto.ActiveNoticeResponse; +import com.comatching.item.domain.notice.dto.NoticeCreateRequest; +import com.comatching.item.domain.notice.entity.Notice; +import com.comatching.item.domain.notice.repository.NoticeRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional +public class NoticeServiceImpl implements NoticeService { + + private final NoticeRepository noticeRepository; + + @Override + public void createNotice(NoticeCreateRequest request) { + validatePeriod(request.startTime(), request.endTime()); + + Notice notice = Notice.builder() + .title(request.title()) + .content(request.content()) + .startTime(request.startTime()) + .endTime(request.endTime()) + .build(); + + noticeRepository.save(notice); + } + + @Override + @Transactional(readOnly = true) + public List getActiveNotices() { + LocalDateTime currentTime = LocalDateTime.now(); + return noticeRepository + .findAllByStartTimeLessThanEqualAndEndTimeGreaterThanEqualOrderByStartTimeDescIdDesc(currentTime, currentTime) + .stream() + .map(ActiveNoticeResponse::from) + .toList(); + } + + private void validatePeriod(LocalDateTime startTime, LocalDateTime endTime) { + if (startTime == null || endTime == null) { + throw new BusinessException(GeneralErrorCode.INVALID_INPUT_VALUE, "시작시간과 종료시간은 필수입니다."); + } + if (!startTime.isBefore(endTime)) { + throw new BusinessException(GeneralErrorCode.INVALID_INPUT_VALUE, "시작시간은 종료시간보다 이전이어야 합니다."); + } + } +} diff --git a/item-service/src/main/java/com/comatching/item/infra/controller/NoticeController.java b/item-service/src/main/java/com/comatching/item/infra/controller/NoticeController.java new file mode 100644 index 0000000..49f8f7b --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/infra/controller/NoticeController.java @@ -0,0 +1,53 @@ +package com.comatching.item.infra.controller; + +import java.util.List; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.comatching.common.annotation.CurrentMember; +import com.comatching.common.annotation.RequireRole; +import com.comatching.common.domain.enums.MemberRole; +import com.comatching.common.dto.member.MemberInfo; +import com.comatching.common.dto.response.ApiResponse; +import com.comatching.item.domain.notice.dto.ActiveNoticeResponse; +import com.comatching.item.domain.notice.dto.NoticeCreateRequest; +import com.comatching.item.domain.notice.service.NoticeService; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@Tag(name = "Notice API", description = "공지사항 등록 및 조회") +@RestController +@RequestMapping("/api/v1") +@RequiredArgsConstructor +public class NoticeController { + + private final NoticeService noticeService; + + @RequireRole(MemberRole.ROLE_ADMIN) + @Operation(summary = "공지사항 등록", description = "관리자가 제목, 내용, 시작시간, 종료시간으로 공지사항을 등록합니다.") + @PostMapping("/admin/notices") + public ResponseEntity> createNotice( + @CurrentMember MemberInfo memberInfo, + @RequestBody @Valid NoticeCreateRequest request + ) { + noticeService.createNotice(request); + return ResponseEntity.ok(ApiResponse.ok()); + } + + @RequireRole({MemberRole.ROLE_USER, MemberRole.ROLE_ADMIN}) + @Operation(summary = "활성 공지사항 조회", description = "현재 시각 기준으로 노출 기간에 포함된 공지사항 목록을 조회합니다.") + @GetMapping("/notices/active") + public ResponseEntity>> getActiveNotices( + @CurrentMember MemberInfo memberInfo + ) { + return ResponseEntity.ok(ApiResponse.ok(noticeService.getActiveNotices())); + } +} diff --git a/item-service/src/test/java/com/comatching/item/domain/notice/service/NoticeServiceImplTest.java b/item-service/src/test/java/com/comatching/item/domain/notice/service/NoticeServiceImplTest.java new file mode 100644 index 0000000..30103b9 --- /dev/null +++ b/item-service/src/test/java/com/comatching/item/domain/notice/service/NoticeServiceImplTest.java @@ -0,0 +1,121 @@ +package com.comatching.item.domain.notice.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; + +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import com.comatching.common.exception.BusinessException; +import com.comatching.item.domain.notice.dto.ActiveNoticeResponse; +import com.comatching.item.domain.notice.dto.NoticeCreateRequest; +import com.comatching.item.domain.notice.entity.Notice; +import com.comatching.item.domain.notice.repository.NoticeRepository; + +@ExtendWith(MockitoExtension.class) +@DisplayName("NoticeServiceImpl 테스트") +class NoticeServiceImplTest { + + @InjectMocks + private NoticeServiceImpl noticeService; + + @Mock + private NoticeRepository noticeRepository; + + @Test + @DisplayName("관리자 공지사항 등록 시 내용을 그대로 저장한다") + void shouldSaveNoticeWithOriginalContent() { + // given + String content = "첫째 줄\n둘째 줄\n셋째 줄"; + NoticeCreateRequest request = new NoticeCreateRequest( + "점검 안내", + content, + LocalDateTime.of(2026, 3, 12, 14, 0), + LocalDateTime.of(2026, 3, 13, 2, 0) + ); + given(noticeRepository.save(any(Notice.class))).willAnswer(invocation -> invocation.getArgument(0)); + + // when + noticeService.createNotice(request); + + // then + ArgumentCaptor noticeCaptor = ArgumentCaptor.forClass(Notice.class); + then(noticeRepository).should().save(noticeCaptor.capture()); + Notice savedNotice = noticeCaptor.getValue(); + assertThat(savedNotice.getTitle()).isEqualTo("점검 안내"); + assertThat(savedNotice.getContent()).isEqualTo(content); + assertThat(savedNotice.getStartTime()).isEqualTo(request.startTime()); + assertThat(savedNotice.getEndTime()).isEqualTo(request.endTime()); + } + + @Test + @DisplayName("시작시간이 종료시간보다 같거나 늦으면 등록에 실패한다") + void shouldThrowWhenStartTimeIsNotBeforeEndTime() { + // given + LocalDateTime startTime = LocalDateTime.of(2026, 3, 12, 10, 0); + NoticeCreateRequest request = new NoticeCreateRequest( + "잘못된 공지", + "내용", + startTime, + startTime + ); + + // when & then + assertThatThrownBy(() -> noticeService.createNotice(request)) + .isInstanceOf(BusinessException.class); + } + + @Test + @DisplayName("시작시간 또는 종료시간이 null이면 등록에 실패한다") + void shouldThrowWhenPeriodIsNull() { + // given + NoticeCreateRequest request = new NoticeCreateRequest( + "공지", + "내용", + null, + LocalDateTime.of(2026, 3, 12, 11, 0) + ); + + // when & then + assertThatThrownBy(() -> noticeService.createNotice(request)) + .isInstanceOf(BusinessException.class); + } + + @Test + @DisplayName("현재 시각에 활성화된 공지사항 id, 제목, 내용을 그대로 반환한다") + void shouldReturnActiveNotices() { + // given + Notice notice = Notice.builder() + .title("공지 제목") + .content("한 줄\n두 줄") + .startTime(LocalDateTime.of(2026, 3, 12, 0, 0)) + .endTime(LocalDateTime.of(2026, 3, 20, 23, 59)) + .build(); + ReflectionTestUtils.setField(notice, "id", 10L); + + given(noticeRepository.findAllByStartTimeLessThanEqualAndEndTimeGreaterThanEqualOrderByStartTimeDescIdDesc( + any(LocalDateTime.class), any(LocalDateTime.class))) + .willReturn(List.of(notice)); + + // when + List responses = noticeService.getActiveNotices(); + + // then + assertThat(responses).hasSize(1); + assertThat(responses.get(0).noticeId()).isEqualTo(10L); + assertThat(responses.get(0).title()).isEqualTo("공지 제목"); + assertThat(responses.get(0).content()).isEqualTo("한 줄\n두 줄"); + } +}