Skip to content
Open
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
2 changes: 1 addition & 1 deletion gateway-service/src/main/resources/application-aws.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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()
);
}
}
Original file line number Diff line number Diff line change
@@ -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
) {
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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<Notice, Long> {

List<Notice> findAllByStartTimeLessThanEqualAndEndTimeGreaterThanEqualOrderByStartTimeDescIdDesc(
LocalDateTime currentTime,
LocalDateTime currentTime2
);
}
Original file line number Diff line number Diff line change
@@ -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<ActiveNoticeResponse> getActiveNotices();
}
Original file line number Diff line number Diff line change
@@ -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<ActiveNoticeResponse> 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, "시작시간은 종료시간보다 이전이어야 합니다.");
}
}
}
Original file line number Diff line number Diff line change
@@ -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<ApiResponse<Void>> 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<ApiResponse<List<ActiveNoticeResponse>>> getActiveNotices(
@CurrentMember MemberInfo memberInfo
) {
return ResponseEntity.ok(ApiResponse.ok(noticeService.getActiveNotices()));
}
}
Original file line number Diff line number Diff line change
@@ -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<Notice> 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<ActiveNoticeResponse> 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두 줄");
}
}