From 9db70dce2c3b232f1d889aa39fd383cfcf72354c Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Sat, 19 Sep 2026 22:54:46 +0900 Subject: [PATCH 01/12] =?UTF-8?q?[feat]=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EC=9D=B8=EA=B8=B0=EA=B8=80(=EC=B6=94=EC=B2=9C=20=EC=9E=84?= =?UTF-8?q?=EA=B3=84=EC=B9=98)=20=EC=A1=B0=ED=9A=8C=20API=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 추천수가 임계치(기본 10) 이상인 게시글을 최신순으로 조회하는 GET /api/posts/popular-board 엔드포인트 추가. 기존 /popular(최근 N일 TOP N 랭킹)와는 별개 API. 임계치는 application.yaml 프로퍼티로 분리. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KEuNZdecA5CU738DK5vZW7 --- .../post/controller/PostController.java | 8 +++ .../post/repository/PostRepository.java | 3 + .../Backend/post/service/PostService.java | 14 +++++ src/main/resources/application.yaml | 2 + .../post/controller/PostControllerTest.java | 22 ++++++++ .../post/repository/PostRepositoryTest.java | 56 +++++++++++++++++++ .../Backend/post/service/PostServiceTest.java | 39 +++++++++++++ 7 files changed, 144 insertions(+) diff --git a/src/main/java/com/Coming/Backend/post/controller/PostController.java b/src/main/java/com/Coming/Backend/post/controller/PostController.java index 782fb395..1718ea1b 100644 --- a/src/main/java/com/Coming/Backend/post/controller/PostController.java +++ b/src/main/java/com/Coming/Backend/post/controller/PostController.java @@ -76,6 +76,14 @@ public ResponseEntity> getPopular( return ResponseEntity.ok(postService.getPopular(days, limit)); } + @Operation(summary = "인기글(추천 임계치 초과) 목록 조회") + @GetMapping("/popular-board") + public ResponseEntity> getPopularBoard( + @RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) { + return ResponseEntity.ok(postService.getPopularBoard(page, size)); + } + @Operation(summary = "최근 많이 언급된 태그 조회") @GetMapping("/trending-tags") public ResponseEntity> getTrendingTags( diff --git a/src/main/java/com/Coming/Backend/post/repository/PostRepository.java b/src/main/java/com/Coming/Backend/post/repository/PostRepository.java index 2a6b258a..0cc0c18a 100644 --- a/src/main/java/com/Coming/Backend/post/repository/PostRepository.java +++ b/src/main/java/com/Coming/Backend/post/repository/PostRepository.java @@ -21,6 +21,9 @@ public interface PostRepository extends JpaRepository { @Query("SELECT p FROM Post p WHERE p.createdAt >= :since ORDER BY p.recommendCount DESC, p.createdAt DESC") List findPopularPosts(@Param("since") LocalDateTime since, Pageable pageable); + @Query("SELECT p FROM Post p WHERE p.recommendCount >= :threshold ORDER BY p.createdAt DESC") + Page findPopularBoard(@Param("threshold") long threshold, Pageable pageable); + @Modifying @Query("UPDATE Post p SET p.viewCount = p.viewCount + 1 WHERE p.id = :id") void incrementViewCount(@Param("id") Long id); diff --git a/src/main/java/com/Coming/Backend/post/service/PostService.java b/src/main/java/com/Coming/Backend/post/service/PostService.java index 4b7076eb..cc0a6605 100644 --- a/src/main/java/com/Coming/Backend/post/service/PostService.java +++ b/src/main/java/com/Coming/Backend/post/service/PostService.java @@ -34,6 +34,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -77,6 +78,9 @@ public class PostService { private static final int MIN_SEARCH_QUERY_LENGTH = 2; + @Value("${app.post.popular-board-threshold:10}") + private long popularBoardThreshold; + /** * 게시글을 생성한다. * entityTags가 가리키는 엔티티의 실존 여부는 검증하지 않는다 — 삭제된 참조와 동일하게 @@ -207,6 +211,16 @@ public List getPopular(int days, int limit) { return toSummaryResponses(posts); } + /** + * 추천수가 임계치 이상인 게시글(인기글)을 최신순으로 조회한다. + */ + public PageResponse getPopularBoard(int page, int size) { + Pageable pageable = PageRequest.of(page, size); + Page result = postRepository.findPopularBoard(popularBoardThreshold, pageable); + List content = toSummaryResponses(result.getContent()); + return new PageResponse<>(content, result.getNumber(), result.getSize(), result.getTotalElements(), result.getTotalPages()); + } + /** * 최근 N일 이내 작성된 게시글에 태그된 엔티티를 언급 빈도 내림차순으로 상위 K개 조회한다. */ diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index b4078e34..f74eccd1 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -88,6 +88,8 @@ jwt: refresh-token-expiry: 604800000 app: + post: + popular-board-threshold: 10 cors: allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:3000} oauth2: diff --git a/src/test/java/com/Coming/Backend/post/controller/PostControllerTest.java b/src/test/java/com/Coming/Backend/post/controller/PostControllerTest.java index 615eecd8..bddb331c 100644 --- a/src/test/java/com/Coming/Backend/post/controller/PostControllerTest.java +++ b/src/test/java/com/Coming/Backend/post/controller/PostControllerTest.java @@ -255,6 +255,28 @@ void should_return_200_with_popular_posts_when_default_params_given() throws Exc .andExpect(jsonPath("$[0].title").value("인기 게시글")); } + // ------------------------------------------------------------------------- + // GET /api/posts/popular-board + // ------------------------------------------------------------------------- + + @Test + void should_return_200_with_page_response_when_getting_popular_board_with_default_params() throws Exception { + // given + PostSummaryResponse summary = new PostSummaryResponse( + POST_ID, "IU", PostCategory.FREE, "인기글", List.of(), 10L, 0L, LocalDateTime.now()); + PageResponse pageResponse = new PageResponse<>(List.of(summary), 0, 20, 1, 1); + given(postService.getPopularBoard(eq(0), eq(20))).willReturn(pageResponse); + + // when & then + mockMvc.perform(get("/api/posts/popular-board").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.content[0].id").value(POST_ID)) + .andExpect(jsonPath("$.page").value(0)) + .andExpect(jsonPath("$.size").value(20)) + .andExpect(jsonPath("$.totalElements").value(1)); + } + // ------------------------------------------------------------------------- // GET /api/posts/trending-tags // ------------------------------------------------------------------------- diff --git a/src/test/java/com/Coming/Backend/post/repository/PostRepositoryTest.java b/src/test/java/com/Coming/Backend/post/repository/PostRepositoryTest.java index 26ec90d3..978307c4 100644 --- a/src/test/java/com/Coming/Backend/post/repository/PostRepositoryTest.java +++ b/src/test/java/com/Coming/Backend/post/repository/PostRepositoryTest.java @@ -57,6 +57,19 @@ private Post buildPost(String title, String contentText) { .build(); } + private Post buildPostWithRecommendCount(String title, long recommendCount) { + return Post.builder() + .userId(AUTHOR_ID) + .category(PostCategory.FREE) + .title(title) + .content("{\"type\":\"doc\"}") + .contentText("내용") + .recommendCount(recommendCount) + .viewCount(0L) + .commentCount(0L) + .build(); + } + @Test void should_return_post_when_title_matches_search_query() { // given @@ -219,4 +232,47 @@ void should_return_posts_ordered_by_created_at_desc_when_multiple_posts_match() assertThat(result.getContent()).extracting(Post::getId) .containsSubsequence(newerPost.getId(), olderPost.getId()); } + + // ------------------------------------------------------------------------- + // findPopularBoard + // ------------------------------------------------------------------------- + + @Test + void should_return_post_when_recommend_count_is_at_or_above_threshold() { + // given + Post qualifyingPost = postRepository.save(buildPostWithRecommendCount("추천수 딱 임계치", 10L)); + + // when + Page result = postRepository.findPopularBoard(10L, PageRequest.of(0, 20)); + + // then + assertThat(result.getContent()).extracting(Post::getId).contains(qualifyingPost.getId()); + } + + @Test + void should_exclude_post_when_recommend_count_is_below_threshold() { + // given + Post belowThresholdPost = postRepository.save(buildPostWithRecommendCount("추천수 미달", 9L)); + + // when + Page result = postRepository.findPopularBoard(10L, PageRequest.of(0, 20)); + + // then + assertThat(result.getContent()).extracting(Post::getId).doesNotContain(belowThresholdPost.getId()); + } + + @Test + void should_return_posts_ordered_by_created_at_desc_when_finding_popular_board() throws InterruptedException { + // given + Post olderPost = postRepository.save(buildPostWithRecommendCount("먼저 쓴 인기글", 10L)); + Thread.sleep(10); + Post newerPost = postRepository.save(buildPostWithRecommendCount("나중에 쓴 인기글", 20L)); + + // when + Page result = postRepository.findPopularBoard(10L, PageRequest.of(0, 20)); + + // then + assertThat(result.getContent()).extracting(Post::getId) + .containsSubsequence(newerPost.getId(), olderPost.getId()); + } } diff --git a/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java b/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java index 18cc419b..96d0513a 100644 --- a/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java +++ b/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java @@ -576,6 +576,45 @@ void should_return_empty_list_when_no_popular_posts_found() { verify(postEntityTagRepository, never()).findByPostIdIn(any()); } + // ------------------------------------------------------------------------- + // getPopularBoard + // ------------------------------------------------------------------------- + + @Test + void should_return_page_response_when_popular_board_posts_found() { + // given + ReflectionTestUtils.setField(postService, "popularBoardThreshold", 10L); + Post post = buildPost(POST_ID, AUTHOR_ID, PostCategory.FREE, "인기글", 3L); + Pageable pageable = PageRequest.of(0, 20); + Page page = new PageImpl<>(List.of(post), pageable, 1); + given(postRepository.findPopularBoard(10L, pageable)).willReturn(page); + given(postEntityTagRepository.findByPostIdIn(List.of(POST_ID))).willReturn(List.of()); + given(userRepository.findAllByIdIn(Set.of(AUTHOR_ID))).willReturn(List.of(buildUser(AUTHOR_ID, "IU"))); + + // when + PageResponse response = postService.getPopularBoard(0, 20); + + // then + assertThat(response.content()).hasSize(1); + assertThat(response.content().get(0).title()).isEqualTo("인기글"); + verify(postRepository).findPopularBoard(10L, pageable); + } + + @Test + void should_return_empty_page_response_when_no_popular_board_posts_found() { + // given + ReflectionTestUtils.setField(postService, "popularBoardThreshold", 10L); + Pageable pageable = PageRequest.of(0, 20); + given(postRepository.findPopularBoard(10L, pageable)).willReturn(Page.empty(pageable)); + + // when + PageResponse response = postService.getPopularBoard(0, 20); + + // then + assertThat(response.content()).isEmpty(); + assertThat(response.totalElements()).isZero(); + } + // ------------------------------------------------------------------------- // getTrendingTags // ------------------------------------------------------------------------- From 25ea648e2a6df92c8f18f37cf94ec517491ff984 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Sat, 19 Sep 2026 22:55:53 +0900 Subject: [PATCH 02/12] =?UTF-8?q?[feat]=20Notice=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=20=EB=B0=8F=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post 엔티티 재사용 없이 별도 notice 도메인 신설. 댓글·신고·추천이 없는 단순 콘텐츠 구조(plain text)이며, 관리자가 개별 공지의 노출 여부를 제어할 수 있도록 active 필드를 둔다. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KEuNZdecA5CU738DK5vZW7 --- .../Coming/Backend/notice/entity/Notice.java | 52 +++++++++++++++++++ .../notice/repository/NoticeRepository.java | 7 +++ .../db/migration/V38__create_notice_table.sql | 11 ++++ 3 files changed, 70 insertions(+) create mode 100644 src/main/java/com/Coming/Backend/notice/entity/Notice.java create mode 100644 src/main/java/com/Coming/Backend/notice/repository/NoticeRepository.java create mode 100644 src/main/resources/db/migration/V38__create_notice_table.sql diff --git a/src/main/java/com/Coming/Backend/notice/entity/Notice.java b/src/main/java/com/Coming/Backend/notice/entity/Notice.java new file mode 100644 index 00000000..d453304a --- /dev/null +++ b/src/main/java/com/Coming/Backend/notice/entity/Notice.java @@ -0,0 +1,52 @@ +package com.Coming.Backend.notice.entity; + +import com.Coming.Backend.common.entity.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "notice") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder +public class Notice extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "title", nullable = false, length = 255) + private String title; + + @Column(name = "content", nullable = false, columnDefinition = "text") + private String content; + + @Column(name = "active", nullable = false) + private boolean active; + + public void update(String title, String content) { + if (title != null) this.title = title; + if (content != null) this.content = content; + } + + public void activate() { + this.active = true; + } + + public void deactivate() { + this.active = false; + } +} diff --git a/src/main/java/com/Coming/Backend/notice/repository/NoticeRepository.java b/src/main/java/com/Coming/Backend/notice/repository/NoticeRepository.java new file mode 100644 index 00000000..b1a96e39 --- /dev/null +++ b/src/main/java/com/Coming/Backend/notice/repository/NoticeRepository.java @@ -0,0 +1,7 @@ +package com.Coming.Backend.notice.repository; + +import com.Coming.Backend.notice.entity.Notice; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface NoticeRepository extends JpaRepository { +} diff --git a/src/main/resources/db/migration/V38__create_notice_table.sql b/src/main/resources/db/migration/V38__create_notice_table.sql new file mode 100644 index 00000000..387242aa --- /dev/null +++ b/src/main/resources/db/migration/V38__create_notice_table.sql @@ -0,0 +1,11 @@ +CREATE TABLE notice ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + user_id bigint NOT NULL, + title varchar(255) NOT NULL, + content text NOT NULL, + active boolean NOT NULL DEFAULT true, + created_at timestamp, + updated_at timestamp +); + +CREATE INDEX idx_notice_active_created_at ON notice (active, created_at); From 17321fdce47c7348243a23ba1160a7906198d495 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Sat, 19 Sep 2026 22:59:07 +0900 Subject: [PATCH 03/12] =?UTF-8?q?[feat]=20=EA=B3=B5=EC=A7=80=EC=82=AC?= =?UTF-8?q?=ED=95=AD=20=EA=B3=B5=EA=B0=9C=20=EC=A1=B0=ED=9A=8C=20API=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/notices(커뮤니티 홈 상단 고정용, 활성 공지 최신순 N개), GET /api/notices/{id}(상세) 추가. 비활성 공지는 상세 조회 시에도 NOTICE_NOT_FOUND(404)로 응답해 링크 공유를 통한 노출을 막는다. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KEuNZdecA5CU738DK5vZW7 --- .../Backend/common/config/SecurityConfig.java | 1 + .../Backend/common/exception/ErrorCode.java | 3 + .../notice/controller/NoticeController.java | 44 +++++++ .../notice/dto/NoticeDetailResponse.java | 12 ++ .../notice/dto/NoticeSummaryResponse.java | 10 ++ .../exception/NoticeNotFoundException.java | 11 ++ .../notice/repository/NoticeRepository.java | 7 + .../Backend/notice/service/NoticeService.java | 48 +++++++ .../controller/NoticeControllerTest.java | 103 +++++++++++++++ .../repository/NoticeRepositoryTest.java | 69 ++++++++++ .../notice/service/NoticeServiceTest.java | 120 ++++++++++++++++++ 11 files changed, 428 insertions(+) create mode 100644 src/main/java/com/Coming/Backend/notice/controller/NoticeController.java create mode 100644 src/main/java/com/Coming/Backend/notice/dto/NoticeDetailResponse.java create mode 100644 src/main/java/com/Coming/Backend/notice/dto/NoticeSummaryResponse.java create mode 100644 src/main/java/com/Coming/Backend/notice/exception/NoticeNotFoundException.java create mode 100644 src/main/java/com/Coming/Backend/notice/service/NoticeService.java create mode 100644 src/test/java/com/Coming/Backend/notice/controller/NoticeControllerTest.java create mode 100644 src/test/java/com/Coming/Backend/notice/repository/NoticeRepositoryTest.java create mode 100644 src/test/java/com/Coming/Backend/notice/service/NoticeServiceTest.java diff --git a/src/main/java/com/Coming/Backend/common/config/SecurityConfig.java b/src/main/java/com/Coming/Backend/common/config/SecurityConfig.java index 8e27d573..dbc21736 100644 --- a/src/main/java/com/Coming/Backend/common/config/SecurityConfig.java +++ b/src/main/java/com/Coming/Backend/common/config/SecurityConfig.java @@ -121,6 +121,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/api/calendar", "/api/mentions/search", "/api/posts/**", + "/api/notices/**", "/api/search", "/api/entities/**" ).permitAll() diff --git a/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java b/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java index 29abd30a..31913722 100644 --- a/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java +++ b/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java @@ -61,6 +61,9 @@ public enum ErrorCode { ALREADY_LIKED(HttpStatus.CONFLICT, "이미 좋아요한 댓글입니다."), NOT_LIKED(HttpStatus.BAD_REQUEST, "좋아요하지 않은 댓글입니다."), + // Notice + NOTICE_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 공지사항입니다."), + // Pipeline PIPELINE_NOT_FOUND(HttpStatus.NOT_FOUND, "Data 파이프라인에서 해당 리소스를 찾을 수 없습니다."), PIPELINE_CONFLICT(HttpStatus.CONFLICT, "이미 처리 중인 수집 요청입니다. 잠시 후 다시 확인해주세요."), diff --git a/src/main/java/com/Coming/Backend/notice/controller/NoticeController.java b/src/main/java/com/Coming/Backend/notice/controller/NoticeController.java new file mode 100644 index 00000000..b393bdbb --- /dev/null +++ b/src/main/java/com/Coming/Backend/notice/controller/NoticeController.java @@ -0,0 +1,44 @@ +package com.Coming.Backend.notice.controller; + +import com.Coming.Backend.notice.dto.NoticeDetailResponse; +import com.Coming.Backend.notice.dto.NoticeSummaryResponse; +import com.Coming.Backend.notice.service.NoticeService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +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; + +import java.util.List; + +@Tag(name = "Notice") +@Validated +@RestController +@RequestMapping("/api/notices") +@RequiredArgsConstructor +public class NoticeController { + + private final NoticeService noticeService; + + @Operation(summary = "커뮤니티 홈 상단 고정용 최근 공지사항 조회") + @GetMapping + public ResponseEntity> getRecent( + @RequestParam(defaultValue = "5") @Min(1) @Max(20) int limit) { + return ResponseEntity.ok(noticeService.getRecent(limit)); + } + + @Operation(summary = "공지사항 상세 조회") + @ApiResponse(responseCode = "404", description = "NOTICE_NOT_FOUND") + @GetMapping("/{id}") + public ResponseEntity getDetail(@PathVariable Long id) { + return ResponseEntity.ok(noticeService.getDetail(id)); + } +} diff --git a/src/main/java/com/Coming/Backend/notice/dto/NoticeDetailResponse.java b/src/main/java/com/Coming/Backend/notice/dto/NoticeDetailResponse.java new file mode 100644 index 00000000..699e816e --- /dev/null +++ b/src/main/java/com/Coming/Backend/notice/dto/NoticeDetailResponse.java @@ -0,0 +1,12 @@ +package com.Coming.Backend.notice.dto; + +import java.time.LocalDateTime; + +public record NoticeDetailResponse( + Long id, + String title, + String content, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { +} diff --git a/src/main/java/com/Coming/Backend/notice/dto/NoticeSummaryResponse.java b/src/main/java/com/Coming/Backend/notice/dto/NoticeSummaryResponse.java new file mode 100644 index 00000000..e09b66dc --- /dev/null +++ b/src/main/java/com/Coming/Backend/notice/dto/NoticeSummaryResponse.java @@ -0,0 +1,10 @@ +package com.Coming.Backend.notice.dto; + +import java.time.LocalDateTime; + +public record NoticeSummaryResponse( + Long id, + String title, + LocalDateTime createdAt +) { +} diff --git a/src/main/java/com/Coming/Backend/notice/exception/NoticeNotFoundException.java b/src/main/java/com/Coming/Backend/notice/exception/NoticeNotFoundException.java new file mode 100644 index 00000000..b6b397cf --- /dev/null +++ b/src/main/java/com/Coming/Backend/notice/exception/NoticeNotFoundException.java @@ -0,0 +1,11 @@ +package com.Coming.Backend.notice.exception; + +import com.Coming.Backend.common.exception.BusinessException; +import com.Coming.Backend.common.exception.ErrorCode; + +public class NoticeNotFoundException extends BusinessException { + + public NoticeNotFoundException() { + super(ErrorCode.NOTICE_NOT_FOUND); + } +} diff --git a/src/main/java/com/Coming/Backend/notice/repository/NoticeRepository.java b/src/main/java/com/Coming/Backend/notice/repository/NoticeRepository.java index b1a96e39..0afccd5b 100644 --- a/src/main/java/com/Coming/Backend/notice/repository/NoticeRepository.java +++ b/src/main/java/com/Coming/Backend/notice/repository/NoticeRepository.java @@ -1,7 +1,14 @@ package com.Coming.Backend.notice.repository; import com.Coming.Backend.notice.entity.Notice; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.util.List; public interface NoticeRepository extends JpaRepository { + + @Query("SELECT n FROM Notice n WHERE n.active = true ORDER BY n.createdAt DESC") + List findActiveNotices(Pageable pageable); } diff --git a/src/main/java/com/Coming/Backend/notice/service/NoticeService.java b/src/main/java/com/Coming/Backend/notice/service/NoticeService.java new file mode 100644 index 00000000..87464bae --- /dev/null +++ b/src/main/java/com/Coming/Backend/notice/service/NoticeService.java @@ -0,0 +1,48 @@ +package com.Coming.Backend.notice.service; + +import com.Coming.Backend.notice.dto.NoticeDetailResponse; +import com.Coming.Backend.notice.dto.NoticeSummaryResponse; +import com.Coming.Backend.notice.entity.Notice; +import com.Coming.Backend.notice.exception.NoticeNotFoundException; +import com.Coming.Backend.notice.repository.NoticeRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class NoticeService { + + private final NoticeRepository noticeRepository; + + /** + * 활성화된 공지사항을 최신순으로 상위 N개 조회한다. 커뮤니티 홈 상단 고정 노출용. + */ + public List getRecent(int limit) { + List notices = noticeRepository.findActiveNotices(PageRequest.of(0, limit)); + return notices.stream() + .map(notice -> new NoticeSummaryResponse(notice.getId(), notice.getTitle(), notice.getCreatedAt())) + .toList(); + } + + /** + * 공지사항 상세를 조회한다. 비활성 공지는 일반 사용자에게 노출하지 않는다. + */ + public NoticeDetailResponse getDetail(Long id) { + Notice notice = noticeRepository.findById(id).orElseThrow(NoticeNotFoundException::new); + if (!notice.isActive()) { + throw new NoticeNotFoundException(); + } + return new NoticeDetailResponse( + notice.getId(), + notice.getTitle(), + notice.getContent(), + notice.getCreatedAt(), + notice.getUpdatedAt() + ); + } +} diff --git a/src/test/java/com/Coming/Backend/notice/controller/NoticeControllerTest.java b/src/test/java/com/Coming/Backend/notice/controller/NoticeControllerTest.java new file mode 100644 index 00000000..455cc8df --- /dev/null +++ b/src/test/java/com/Coming/Backend/notice/controller/NoticeControllerTest.java @@ -0,0 +1,103 @@ +package com.Coming.Backend.notice.controller; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.Coming.Backend.common.discord.NoOpDiscordNotifier; +import com.Coming.Backend.common.exception.ErrorCode; +import com.Coming.Backend.common.exception.GlobalExceptionHandler; +import com.Coming.Backend.notice.dto.NoticeDetailResponse; +import com.Coming.Backend.notice.dto.NoticeSummaryResponse; +import com.Coming.Backend.notice.exception.NoticeNotFoundException; +import com.Coming.Backend.notice.service.NoticeService; + +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; + +@ExtendWith(MockitoExtension.class) +class NoticeControllerTest { + + private MockMvc mockMvc; + + @Mock + private NoticeService noticeService; + + @InjectMocks + private NoticeController noticeController; + + private static final Long NOTICE_ID = 10L; + + @BeforeEach + void setUp() { + LocalValidatorFactoryBean validator = new LocalValidatorFactoryBean(); + validator.afterPropertiesSet(); + mockMvc = MockMvcBuilders.standaloneSetup(noticeController) + .setControllerAdvice(new GlobalExceptionHandler(new NoOpDiscordNotifier())) + .setMessageConverters(new MappingJackson2HttpMessageConverter()) + .setValidator(validator) + .build(); + } + + // ------------------------------------------------------------------------- + // GET /api/notices + // ------------------------------------------------------------------------- + + @Test + void should_return_200_with_notice_list_when_default_params_given() throws Exception { + // given + NoticeSummaryResponse summary = new NoticeSummaryResponse(NOTICE_ID, "점검 안내", LocalDateTime.now()); + given(noticeService.getRecent(eq(5))).willReturn(List.of(summary)); + + // when & then + mockMvc.perform(get("/api/notices").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$[0].id").value(NOTICE_ID)) + .andExpect(jsonPath("$[0].title").value("점검 안내")); + } + + // ------------------------------------------------------------------------- + // GET /api/notices/{id} + // ------------------------------------------------------------------------- + + @Test + void should_return_200_with_notice_detail_when_notice_exists() throws Exception { + // given + NoticeDetailResponse detail = new NoticeDetailResponse( + NOTICE_ID, "점검 안내", "점검은 새벽 2시부터 진행됩니다", LocalDateTime.now(), LocalDateTime.now()); + given(noticeService.getDetail(eq(NOTICE_ID))).willReturn(detail); + + // when & then + mockMvc.perform(get("/api/notices/{id}", NOTICE_ID).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(NOTICE_ID)) + .andExpect(jsonPath("$.title").value("점검 안내")); + } + + @Test + void should_return_404_when_notice_not_found_on_get_detail() throws Exception { + // given + given(noticeService.getDetail(eq(999L))).willThrow(new NoticeNotFoundException()); + + // when & then + mockMvc.perform(get("/api/notices/{id}", 999L).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ErrorCode.NOTICE_NOT_FOUND.name())) + .andExpect(jsonPath("$.message").exists()); + } +} diff --git a/src/test/java/com/Coming/Backend/notice/repository/NoticeRepositoryTest.java b/src/test/java/com/Coming/Backend/notice/repository/NoticeRepositoryTest.java new file mode 100644 index 00000000..d46b74b0 --- /dev/null +++ b/src/test/java/com/Coming/Backend/notice/repository/NoticeRepositoryTest.java @@ -0,0 +1,69 @@ +package com.Coming.Backend.notice.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.Coming.Backend.notice.entity.Notice; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; +import org.springframework.data.domain.PageRequest; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +class NoticeRepositoryTest { + + @Autowired + private NoticeRepository noticeRepository; + + private static final Long AUTHOR_ID = 1L; + + private Notice buildNotice(String title, boolean active) { + return Notice.builder() + .userId(AUTHOR_ID) + .title(title) + .content("공지 내용") + .active(active) + .build(); + } + + @Test + void should_return_active_notice_when_finding_active_notices() { + // given + Notice activeNotice = noticeRepository.save(buildNotice("점검 안내", true)); + + // when + var result = noticeRepository.findActiveNotices(PageRequest.of(0, 20)); + + // then + assertThat(result).extracting(Notice::getId).contains(activeNotice.getId()); + } + + @Test + void should_exclude_inactive_notice_when_finding_active_notices() { + // given + Notice inactiveNotice = noticeRepository.save(buildNotice("종료된 공지", false)); + + // when + var result = noticeRepository.findActiveNotices(PageRequest.of(0, 20)); + + // then + assertThat(result).extracting(Notice::getId).doesNotContain(inactiveNotice.getId()); + } + + @Test + void should_return_active_notices_ordered_by_created_at_desc() throws InterruptedException { + // given + Notice olderNotice = noticeRepository.save(buildNotice("먼저 등록된 공지", true)); + Thread.sleep(10); + Notice newerNotice = noticeRepository.save(buildNotice("나중에 등록된 공지", true)); + + // when + var result = noticeRepository.findActiveNotices(PageRequest.of(0, 20)); + + // then + assertThat(result).extracting(Notice::getId) + .containsSubsequence(newerNotice.getId(), olderNotice.getId()); + } +} diff --git a/src/test/java/com/Coming/Backend/notice/service/NoticeServiceTest.java b/src/test/java/com/Coming/Backend/notice/service/NoticeServiceTest.java new file mode 100644 index 00000000..b837ad92 --- /dev/null +++ b/src/test/java/com/Coming/Backend/notice/service/NoticeServiceTest.java @@ -0,0 +1,120 @@ +package com.Coming.Backend.notice.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; + +import com.Coming.Backend.common.exception.ErrorCode; +import com.Coming.Backend.notice.dto.NoticeDetailResponse; +import com.Coming.Backend.notice.dto.NoticeSummaryResponse; +import com.Coming.Backend.notice.entity.Notice; +import com.Coming.Backend.notice.exception.NoticeNotFoundException; +import com.Coming.Backend.notice.repository.NoticeRepository; + +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class NoticeServiceTest { + + @InjectMocks + private NoticeService noticeService; + + @Mock + private NoticeRepository noticeRepository; + + private static final Long AUTHOR_ID = 1L; + private static final Long NOTICE_ID = 10L; + + private Notice buildNotice(Long id, String title, String content, boolean active) { + Notice notice = Notice.builder() + .userId(AUTHOR_ID) + .title(title) + .content(content) + .active(active) + .build(); + ReflectionTestUtils.setField(notice, "id", id); + return notice; + } + + // ------------------------------------------------------------------------- + // getRecent + // ------------------------------------------------------------------------- + + @Test + void should_return_summary_responses_when_active_notices_found() { + // given + Notice notice = buildNotice(NOTICE_ID, "점검 안내", "내용", true); + given(noticeRepository.findActiveNotices(PageRequest.of(0, 5))).willReturn(List.of(notice)); + + // when + List response = noticeService.getRecent(5); + + // then + assertThat(response).hasSize(1); + assertThat(response.get(0).id()).isEqualTo(NOTICE_ID); + assertThat(response.get(0).title()).isEqualTo("점검 안내"); + } + + @Test + void should_return_empty_list_when_no_active_notices_found() { + // given + given(noticeRepository.findActiveNotices(PageRequest.of(0, 5))).willReturn(List.of()); + + // when + List response = noticeService.getRecent(5); + + // then + assertThat(response).isEmpty(); + } + + // ------------------------------------------------------------------------- + // getDetail + // ------------------------------------------------------------------------- + + @Test + void should_throw_notice_not_found_exception_when_notice_does_not_exist() { + // given + given(noticeRepository.findById(NOTICE_ID)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> noticeService.getDetail(NOTICE_ID)) + .isInstanceOf(NoticeNotFoundException.class) + .hasMessage(ErrorCode.NOTICE_NOT_FOUND.getMessage()); + } + + @Test + void should_return_notice_detail_when_active_notice_found() { + // given + Notice notice = buildNotice(NOTICE_ID, "점검 안내", "점검은 새벽 2시부터 진행됩니다", true); + given(noticeRepository.findById(NOTICE_ID)).willReturn(Optional.of(notice)); + + // when + NoticeDetailResponse response = noticeService.getDetail(NOTICE_ID); + + // then + assertThat(response.id()).isEqualTo(NOTICE_ID); + assertThat(response.title()).isEqualTo("점검 안내"); + assertThat(response.content()).isEqualTo("점검은 새벽 2시부터 진행됩니다"); + } + + @Test + void should_throw_notice_not_found_exception_when_notice_exists_but_inactive() { + // given + Notice notice = buildNotice(NOTICE_ID, "종료된 공지", "내용", false); + given(noticeRepository.findById(NOTICE_ID)).willReturn(Optional.of(notice)); + + // when & then + assertThatThrownBy(() -> noticeService.getDetail(NOTICE_ID)) + .isInstanceOf(NoticeNotFoundException.class) + .hasMessage(ErrorCode.NOTICE_NOT_FOUND.getMessage()); + } +} From 08f653da5eb68847869c03eaaa7e3bd97c805a8e Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Sat, 19 Sep 2026 23:05:07 +0900 Subject: [PATCH 04/12] =?UTF-8?q?[feat]=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EA=B3=B5=EC=A7=80=EC=82=AC=ED=95=AD=20=EC=9E=91=EC=84=B1=C2=B7?= =?UTF-8?q?=EC=88=98=EC=A0=95=C2=B7=EC=82=AD=EC=A0=9C=20API=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /api/admin/notices/** CRUD 추가(목록·상세는 활성 여부 무관 조회, 작성 시 active 미지정이면 기본 활성). 기존 inquiry 처리와 동일하게 AdminService가 NoticeRepository를 직접 참조해 위임한다. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KEuNZdecA5CU738DK5vZW7 --- .../admin/controller/AdminController.java | 46 ++++ .../admin/dto/AdminNoticeCreateRequest.java | 10 + .../admin/dto/AdminNoticeCreateResponse.java | 4 + .../admin/dto/AdminNoticeDetailResponse.java | 25 ++ .../dto/AdminNoticeListItemResponse.java | 21 ++ .../admin/dto/AdminNoticeUpdateRequest.java | 8 + .../Backend/admin/service/AdminService.java | 65 +++++ .../admin/controller/AdminControllerTest.java | 212 ++++++++++++++++- .../admin/service/AdminServiceTest.java | 224 ++++++++++++++++++ 9 files changed, 614 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/Coming/Backend/admin/dto/AdminNoticeCreateRequest.java create mode 100644 src/main/java/com/Coming/Backend/admin/dto/AdminNoticeCreateResponse.java create mode 100644 src/main/java/com/Coming/Backend/admin/dto/AdminNoticeDetailResponse.java create mode 100644 src/main/java/com/Coming/Backend/admin/dto/AdminNoticeListItemResponse.java create mode 100644 src/main/java/com/Coming/Backend/admin/dto/AdminNoticeUpdateRequest.java diff --git a/src/main/java/com/Coming/Backend/admin/controller/AdminController.java b/src/main/java/com/Coming/Backend/admin/controller/AdminController.java index 218b54c0..e44ba0a8 100644 --- a/src/main/java/com/Coming/Backend/admin/controller/AdminController.java +++ b/src/main/java/com/Coming/Backend/admin/controller/AdminController.java @@ -21,6 +21,11 @@ import com.Coming.Backend.admin.dto.AdminInquiryDetailResponse; import com.Coming.Backend.admin.dto.AdminInquiryListItemResponse; import com.Coming.Backend.admin.dto.AdminInquiryStatusUpdateRequest; +import com.Coming.Backend.admin.dto.AdminNoticeCreateRequest; +import com.Coming.Backend.admin.dto.AdminNoticeCreateResponse; +import com.Coming.Backend.admin.dto.AdminNoticeDetailResponse; +import com.Coming.Backend.admin.dto.AdminNoticeListItemResponse; +import com.Coming.Backend.admin.dto.AdminNoticeUpdateRequest; import com.Coming.Backend.admin.dto.AdminPendingConcertResponse; import com.Coming.Backend.admin.service.AdminService; import com.Coming.Backend.common.response.PageResponse; @@ -35,6 +40,7 @@ import org.springframework.data.web.PageableDefault; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import java.util.List; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -106,6 +112,46 @@ public ResponseEntity updateInquiryStatus( return ResponseEntity.ok().build(); } + @Operation(summary = "전체 공지사항 목록 조회") + @GetMapping("/notices") + public ResponseEntity> getNotices( + @PageableDefault(size = 20) Pageable pageable) { + return ResponseEntity.ok(adminService.getNotices(pageable)); + } + + @Operation(summary = "공지사항 상세 조회 (비활성 포함)") + @ApiResponse(responseCode = "404", description = "NOTICE_NOT_FOUND") + @GetMapping("/notices/{id}") + public ResponseEntity getAdminNotice(@PathVariable Long id) { + return ResponseEntity.ok(adminService.getAdminNotice(id)); + } + + @Operation(summary = "공지사항 작성") + @PostMapping("/notices") + public ResponseEntity createNotice( + @AuthenticationPrincipal Long adminId, + @RequestBody @Valid AdminNoticeCreateRequest request) { + return ResponseEntity.status(HttpStatus.CREATED).body(adminService.createNotice(adminId, request)); + } + + @Operation(summary = "공지사항 수정") + @ApiResponse(responseCode = "404", description = "NOTICE_NOT_FOUND") + @PatchMapping("/notices/{id}") + public ResponseEntity updateNotice( + @PathVariable Long id, + @RequestBody @Valid AdminNoticeUpdateRequest request) { + adminService.updateNotice(id, request); + return ResponseEntity.ok().build(); + } + + @Operation(summary = "공지사항 삭제") + @ApiResponse(responseCode = "404", description = "NOTICE_NOT_FOUND") + @DeleteMapping("/notices/{id}") + public ResponseEntity deleteNotice(@PathVariable Long id) { + adminService.deleteNotice(id); + return ResponseEntity.noContent().build(); + } + @Operation(summary = "EXCLUDED 공연 목록 조회") @GetMapping("/concerts/excluded") public ResponseEntity> getExcludedConcerts( diff --git a/src/main/java/com/Coming/Backend/admin/dto/AdminNoticeCreateRequest.java b/src/main/java/com/Coming/Backend/admin/dto/AdminNoticeCreateRequest.java new file mode 100644 index 00000000..f6d50e87 --- /dev/null +++ b/src/main/java/com/Coming/Backend/admin/dto/AdminNoticeCreateRequest.java @@ -0,0 +1,10 @@ +package com.Coming.Backend.admin.dto; + +import jakarta.validation.constraints.NotBlank; + +public record AdminNoticeCreateRequest( + @NotBlank String title, + @NotBlank String content, + Boolean active +) { +} diff --git a/src/main/java/com/Coming/Backend/admin/dto/AdminNoticeCreateResponse.java b/src/main/java/com/Coming/Backend/admin/dto/AdminNoticeCreateResponse.java new file mode 100644 index 00000000..e84c9e53 --- /dev/null +++ b/src/main/java/com/Coming/Backend/admin/dto/AdminNoticeCreateResponse.java @@ -0,0 +1,4 @@ +package com.Coming.Backend.admin.dto; + +public record AdminNoticeCreateResponse(Long noticeId) { +} diff --git a/src/main/java/com/Coming/Backend/admin/dto/AdminNoticeDetailResponse.java b/src/main/java/com/Coming/Backend/admin/dto/AdminNoticeDetailResponse.java new file mode 100644 index 00000000..c6b3992f --- /dev/null +++ b/src/main/java/com/Coming/Backend/admin/dto/AdminNoticeDetailResponse.java @@ -0,0 +1,25 @@ +package com.Coming.Backend.admin.dto; + +import com.Coming.Backend.notice.entity.Notice; + +import java.time.LocalDateTime; + +public record AdminNoticeDetailResponse( + Long id, + String title, + String content, + boolean active, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { + public static AdminNoticeDetailResponse of(Notice notice) { + return new AdminNoticeDetailResponse( + notice.getId(), + notice.getTitle(), + notice.getContent(), + notice.isActive(), + notice.getCreatedAt(), + notice.getUpdatedAt() + ); + } +} diff --git a/src/main/java/com/Coming/Backend/admin/dto/AdminNoticeListItemResponse.java b/src/main/java/com/Coming/Backend/admin/dto/AdminNoticeListItemResponse.java new file mode 100644 index 00000000..5200f77b --- /dev/null +++ b/src/main/java/com/Coming/Backend/admin/dto/AdminNoticeListItemResponse.java @@ -0,0 +1,21 @@ +package com.Coming.Backend.admin.dto; + +import com.Coming.Backend.notice.entity.Notice; + +import java.time.LocalDateTime; + +public record AdminNoticeListItemResponse( + Long id, + String title, + boolean active, + LocalDateTime createdAt +) { + public static AdminNoticeListItemResponse of(Notice notice) { + return new AdminNoticeListItemResponse( + notice.getId(), + notice.getTitle(), + notice.isActive(), + notice.getCreatedAt() + ); + } +} diff --git a/src/main/java/com/Coming/Backend/admin/dto/AdminNoticeUpdateRequest.java b/src/main/java/com/Coming/Backend/admin/dto/AdminNoticeUpdateRequest.java new file mode 100644 index 00000000..011d0d9a --- /dev/null +++ b/src/main/java/com/Coming/Backend/admin/dto/AdminNoticeUpdateRequest.java @@ -0,0 +1,8 @@ +package com.Coming.Backend.admin.dto; + +public record AdminNoticeUpdateRequest( + String title, + String content, + Boolean active +) { +} diff --git a/src/main/java/com/Coming/Backend/admin/service/AdminService.java b/src/main/java/com/Coming/Backend/admin/service/AdminService.java index 00b5fcfd..c87b4139 100644 --- a/src/main/java/com/Coming/Backend/admin/service/AdminService.java +++ b/src/main/java/com/Coming/Backend/admin/service/AdminService.java @@ -24,6 +24,11 @@ import com.Coming.Backend.admin.dto.AdminInquiryDetailResponse; import com.Coming.Backend.admin.dto.AdminInquiryListItemResponse; import com.Coming.Backend.admin.dto.AdminInquiryStatusUpdateRequest; +import com.Coming.Backend.admin.dto.AdminNoticeCreateRequest; +import com.Coming.Backend.admin.dto.AdminNoticeCreateResponse; +import com.Coming.Backend.admin.dto.AdminNoticeDetailResponse; +import com.Coming.Backend.admin.dto.AdminNoticeListItemResponse; +import com.Coming.Backend.admin.dto.AdminNoticeUpdateRequest; import com.Coming.Backend.admin.dto.AdminPendingConcertResponse; import com.Coming.Backend.admin.exception.PipelineConflictException; import com.Coming.Backend.admin.repository.ArtistCollectLockRepository; @@ -62,6 +67,9 @@ import com.Coming.Backend.inquiry.exception.InquiryNotFoundException; import com.Coming.Backend.inquiry.exception.InvalidInquiryStatusException; import com.Coming.Backend.inquiry.repository.InquiryRepository; +import com.Coming.Backend.notice.entity.Notice; +import com.Coming.Backend.notice.exception.NoticeNotFoundException; +import com.Coming.Backend.notice.repository.NoticeRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; @@ -86,6 +94,7 @@ public class AdminService { private final ArtistAliasRepository artistAliasRepository; private final ArtistUrlRepository artistUrlRepository; private final InquiryRepository inquiryRepository; + private final NoticeRepository noticeRepository; private final UserRepository userRepository; private final ConcertRepository concertRepository; private final ConcertArtistRepository concertArtistRepository; @@ -235,6 +244,62 @@ public void updateInquiryStatus(Long id, AdminInquiryStatusUpdateRequest request inquiry.updateStatus(request.status(), request.adminNote()); } + /** + * 전체 공지사항 목록을 활성 여부 무관하게 조회한다. + */ + public PageResponse getNotices(Pageable pageable) { + return PageResponse.from(noticeRepository.findAll(pageable).map(AdminNoticeListItemResponse::of)); + } + + /** + * 공지사항 상세를 활성 여부 무관하게 조회한다. 존재하지 않는 ID이면 NoticeNotFoundException을 던진다. + */ + public AdminNoticeDetailResponse getAdminNotice(Long id) { + Notice notice = noticeRepository.findById(id).orElseThrow(NoticeNotFoundException::new); + return AdminNoticeDetailResponse.of(notice); + } + + /** + * 공지사항을 작성한다. active를 지정하지 않으면 기본 활성 상태로 등록한다. + */ + @Transactional + public AdminNoticeCreateResponse createNotice(Long adminId, AdminNoticeCreateRequest request) { + Notice notice = Notice.builder() + .userId(adminId) + .title(request.title()) + .content(request.content()) + .active(request.active() == null || request.active()) + .build(); + noticeRepository.save(notice); + return new AdminNoticeCreateResponse(notice.getId()); + } + + /** + * 공지사항을 수정한다. title·content·active 중 null인 항목은 변경하지 않는다. + * 존재하지 않는 ID이면 NoticeNotFoundException을 던진다. + */ + @Transactional + public void updateNotice(Long id, AdminNoticeUpdateRequest request) { + Notice notice = noticeRepository.findById(id).orElseThrow(NoticeNotFoundException::new); + notice.update(request.title(), request.content()); + if (request.active() != null) { + if (request.active()) { + notice.activate(); + } else { + notice.deactivate(); + } + } + } + + /** + * 공지사항을 삭제한다. 존재하지 않는 ID이면 NoticeNotFoundException을 던진다. + */ + @Transactional + public void deleteNotice(Long id) { + Notice notice = noticeRepository.findById(id).orElseThrow(NoticeNotFoundException::new); + noticeRepository.delete(notice); + } + /** * status 제한 없이 공연 단건을 조회한다. EXCLUDED 포함 모든 상태 조회 가능. * diff --git a/src/test/java/com/Coming/Backend/admin/controller/AdminControllerTest.java b/src/test/java/com/Coming/Backend/admin/controller/AdminControllerTest.java index 7d029649..e90cae77 100644 --- a/src/test/java/com/Coming/Backend/admin/controller/AdminControllerTest.java +++ b/src/test/java/com/Coming/Backend/admin/controller/AdminControllerTest.java @@ -27,6 +27,11 @@ import com.Coming.Backend.admin.dto.AdminInquiryDetailResponse; import com.Coming.Backend.admin.dto.AdminInquiryListItemResponse; import com.Coming.Backend.admin.dto.AdminInquiryStatusUpdateRequest; +import com.Coming.Backend.admin.dto.AdminNoticeCreateRequest; +import com.Coming.Backend.admin.dto.AdminNoticeCreateResponse; +import com.Coming.Backend.admin.dto.AdminNoticeDetailResponse; +import com.Coming.Backend.admin.dto.AdminNoticeListItemResponse; +import com.Coming.Backend.admin.dto.AdminNoticeUpdateRequest; import com.Coming.Backend.admin.dto.DataArtistSearchResult; import com.Coming.Backend.admin.dto.DataConcertSearchResult; import com.Coming.Backend.admin.dto.PipelineArtistCollectResult; @@ -44,12 +49,15 @@ import com.Coming.Backend.inquiry.entity.InquiryStatus; import com.Coming.Backend.inquiry.entity.InquiryType; import com.Coming.Backend.inquiry.exception.InquiryNotFoundException; +import com.Coming.Backend.notice.exception.NoticeNotFoundException; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.List; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -59,6 +67,10 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableHandlerMethodArgumentResolver; import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; @@ -79,6 +91,8 @@ class AdminControllerTest { private static final Long USER_ID = 10L; private static final Long TARGET_ID = 99L; private static final Long CONCERT_ID = 1L; + private static final Long NOTICE_ID = 1L; + private static final Long ADMIN_ID = 5L; @BeforeEach void setUp() { @@ -86,9 +100,22 @@ void setUp() { objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); mockMvc = MockMvcBuilders.standaloneSetup(adminController) .setControllerAdvice(new GlobalExceptionHandler(new com.Coming.Backend.common.discord.NoOpDiscordNotifier())) - .setCustomArgumentResolvers(new PageableHandlerMethodArgumentResolver()) + .setCustomArgumentResolvers( + new PageableHandlerMethodArgumentResolver(), + new AuthenticationPrincipalArgumentResolver()) .setMessageConverters(new MappingJackson2HttpMessageConverter(objectMapper)) .build(); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken( + ADMIN_ID, null, + List.of(new SimpleGrantedAuthority("ROLE_ADMIN")) + ) + ); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); } // ------------------------------------------------------------------------- @@ -791,4 +818,187 @@ void should_return_400_when_link_type_is_blank_on_update() throws Exception { .content(requestBody)) .andExpect(status().isBadRequest()); } + + // ------------------------------------------------------------------------- + // GET /api/admin/notices + // ------------------------------------------------------------------------- + + @Test + void should_return_200_with_notice_list_when_notices_exist() throws Exception { + // given + AdminNoticeListItemResponse item = new AdminNoticeListItemResponse( + NOTICE_ID, "점검 안내", true, LocalDateTime.of(2025, 8, 20, 0, 0) + ); + PageResponse pageResponse = new PageResponse<>(List.of(item), 0, 20, 1, 1); + given(adminService.getNotices(any(Pageable.class))).willReturn(pageResponse); + + // when & then + mockMvc.perform(get("/api/admin/notices").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.content[0].id").value(NOTICE_ID)) + .andExpect(jsonPath("$.content[0].title").value("점검 안내")) + .andExpect(jsonPath("$.page").value(0)) + .andExpect(jsonPath("$.size").value(20)) + .andExpect(jsonPath("$.totalElements").isNumber()); + } + + // ------------------------------------------------------------------------- + // GET /api/admin/notices/{id} + // ------------------------------------------------------------------------- + + @Test + void should_return_200_with_notice_detail_when_valid_id_given() throws Exception { + // given + AdminNoticeDetailResponse detail = new AdminNoticeDetailResponse( + NOTICE_ID, "점검 안내", "9월 20일 점검이 진행됩니다.", true, + LocalDateTime.of(2025, 8, 20, 0, 0), LocalDateTime.of(2025, 8, 20, 0, 0) + ); + given(adminService.getAdminNotice(NOTICE_ID)).willReturn(detail); + + // when & then + mockMvc.perform(get("/api/admin/notices/{id}", NOTICE_ID).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(NOTICE_ID)) + .andExpect(jsonPath("$.title").value("점검 안내")) + .andExpect(jsonPath("$.content").value("9월 20일 점검이 진행됩니다.")) + .andExpect(jsonPath("$.active").value(true)); + } + + @Test + void should_return_404_when_notice_not_found_on_detail() throws Exception { + // given + given(adminService.getAdminNotice(999L)).willThrow(new NoticeNotFoundException()); + + // when & then + mockMvc.perform(get("/api/admin/notices/{id}", 999L).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ErrorCode.NOTICE_NOT_FOUND.name())) + .andExpect(jsonPath("$.message").exists()); + } + + // ------------------------------------------------------------------------- + // POST /api/admin/notices + // ------------------------------------------------------------------------- + + @Test + void should_return_201_when_valid_notice_create_request_given() throws Exception { + // given + String requestBody = """ + { + "title": "점검 안내", + "content": "9월 20일 점검이 진행됩니다." + } + """; + given(adminService.createNotice(eq(ADMIN_ID), any(AdminNoticeCreateRequest.class))) + .willReturn(new AdminNoticeCreateResponse(NOTICE_ID)); + + // when & then + mockMvc.perform(post("/api/admin/notices") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.noticeId").value(NOTICE_ID)); + } + + @Test + void should_return_400_when_title_is_blank_on_notice_create() throws Exception { + // given + String requestBody = """ + { + "title": "", + "content": "9월 20일 점검이 진행됩니다." + } + """; + + // when & then + mockMvc.perform(post("/api/admin/notices") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isBadRequest()); + } + + @Test + void should_return_400_when_content_is_blank_on_notice_create() throws Exception { + // given + String requestBody = """ + { + "title": "점검 안내", + "content": "" + } + """; + + // when & then + mockMvc.perform(post("/api/admin/notices") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isBadRequest()); + } + + // ------------------------------------------------------------------------- + // PATCH /api/admin/notices/{id} + // ------------------------------------------------------------------------- + + @Test + void should_return_200_when_valid_notice_update_request_given() throws Exception { + // given + String requestBody = """ + { + "title": "점검 안내 (수정)" + } + """; + willDoNothing().given(adminService).updateNotice(eq(NOTICE_ID), any(AdminNoticeUpdateRequest.class)); + + // when & then + mockMvc.perform(patch("/api/admin/notices/{id}", NOTICE_ID) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isOk()); + } + + @Test + void should_return_404_when_notice_not_found_on_update() throws Exception { + // given + String requestBody = """ + { + "title": "점검 안내 (수정)" + } + """; + willThrow(new NoticeNotFoundException()) + .given(adminService).updateNotice(eq(999L), any(AdminNoticeUpdateRequest.class)); + + // when & then + mockMvc.perform(patch("/api/admin/notices/{id}", 999L) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ErrorCode.NOTICE_NOT_FOUND.name())) + .andExpect(jsonPath("$.message").exists()); + } + + // ------------------------------------------------------------------------- + // DELETE /api/admin/notices/{id} + // ------------------------------------------------------------------------- + + @Test + void should_return_204_when_notice_deleted_successfully() throws Exception { + // given + willDoNothing().given(adminService).deleteNotice(NOTICE_ID); + + // when & then + mockMvc.perform(delete("/api/admin/notices/{id}", NOTICE_ID)) + .andExpect(status().isNoContent()); + } + + @Test + void should_return_404_when_notice_not_found_on_delete() throws Exception { + // given + willThrow(new NoticeNotFoundException()).given(adminService).deleteNotice(999L); + + // when & then + mockMvc.perform(delete("/api/admin/notices/{id}", 999L)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ErrorCode.NOTICE_NOT_FOUND.name())) + .andExpect(jsonPath("$.message").exists()); + } } diff --git a/src/test/java/com/Coming/Backend/admin/service/AdminServiceTest.java b/src/test/java/com/Coming/Backend/admin/service/AdminServiceTest.java index d318cecb..706f4b72 100644 --- a/src/test/java/com/Coming/Backend/admin/service/AdminServiceTest.java +++ b/src/test/java/com/Coming/Backend/admin/service/AdminServiceTest.java @@ -15,6 +15,11 @@ import com.Coming.Backend.admin.dto.AdminInquiryDetailResponse; import com.Coming.Backend.admin.dto.AdminInquiryListItemResponse; import com.Coming.Backend.admin.dto.AdminInquiryStatusUpdateRequest; +import com.Coming.Backend.admin.dto.AdminNoticeCreateRequest; +import com.Coming.Backend.admin.dto.AdminNoticeCreateResponse; +import com.Coming.Backend.admin.dto.AdminNoticeDetailResponse; +import com.Coming.Backend.admin.dto.AdminNoticeListItemResponse; +import com.Coming.Backend.admin.dto.AdminNoticeUpdateRequest; import com.Coming.Backend.admin.dto.AdminPendingConcertResponse; import com.Coming.Backend.admin.dto.BookingLinkRequest; import com.Coming.Backend.admin.dto.DataArtistSearchResult; @@ -60,6 +65,9 @@ import com.Coming.Backend.inquiry.exception.InquiryNotFoundException; import com.Coming.Backend.inquiry.exception.InvalidInquiryStatusException; import com.Coming.Backend.inquiry.repository.InquiryRepository; +import com.Coming.Backend.notice.entity.Notice; +import com.Coming.Backend.notice.exception.NoticeNotFoundException; +import com.Coming.Backend.notice.repository.NoticeRepository; import com.Coming.Backend.common.exception.InvalidInputException; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -105,6 +113,9 @@ class AdminServiceTest { @Mock private InquiryRepository inquiryRepository; + @Mock + private NoticeRepository noticeRepository; + @Mock private UserRepository userRepository; @@ -133,6 +144,8 @@ class AdminServiceTest { private static final Long INQUIRY_ID = 1L; private static final Long TARGET_ID = 100L; private static final Long ARTIST_ID = 20L; + private static final Long NOTICE_ID = 1L; + private static final Long ADMIN_ID = 5L; private static final Pageable PAGEABLE = PageRequest.of(0, 20); private static final LocalDateTime CREATED_AT = LocalDateTime.of(2025, 8, 20, 0, 0); @@ -162,6 +175,18 @@ private User buildUser(Long id, String nickname) { .build(); } + private Notice buildNotice(Long id, String title, String content, boolean active) { + Notice notice = Notice.builder() + .id(id) + .userId(ADMIN_ID) + .title(title) + .content(content) + .active(active) + .build(); + ReflectionTestUtils.setField(notice, "createdAt", CREATED_AT); + return notice; + } + @Test void should_update_all_fields_when_full_update_request_given() { // given @@ -1918,4 +1943,203 @@ void should_return_empty_page_when_no_local_artists_match() { assertThat(response.content()).isEmpty(); assertThat(response.totalElements()).isZero(); } + + // ------------------------------------------------------------------------- + // getNotices + // ------------------------------------------------------------------------- + + @Test + void should_return_paginated_notices_when_notices_exist() { + // given + Notice notice = buildNotice(NOTICE_ID, "점검 안내", "9월 20일 점검이 진행됩니다.", true); + Page page = new PageImpl<>(List.of(notice), PAGEABLE, 1); + given(noticeRepository.findAll(PAGEABLE)).willReturn(page); + + // when + PageResponse response = adminService.getNotices(PAGEABLE); + + // then + assertThat(response.content()).hasSize(1); + assertThat(response.content().get(0).id()).isEqualTo(NOTICE_ID); + assertThat(response.content().get(0).title()).isEqualTo("점검 안내"); + assertThat(response.content().get(0).active()).isTrue(); + } + + // ------------------------------------------------------------------------- + // getAdminNotice + // ------------------------------------------------------------------------- + + @Test + void should_return_notice_detail_when_valid_id_given() { + // given + Notice notice = buildNotice(NOTICE_ID, "점검 안내", "9월 20일 점검이 진행됩니다.", true); + given(noticeRepository.findById(NOTICE_ID)).willReturn(Optional.of(notice)); + + // when + AdminNoticeDetailResponse response = adminService.getAdminNotice(NOTICE_ID); + + // then + assertThat(response.id()).isEqualTo(NOTICE_ID); + assertThat(response.title()).isEqualTo("점검 안내"); + assertThat(response.content()).isEqualTo("9월 20일 점검이 진행됩니다."); + assertThat(response.active()).isTrue(); + } + + @Test + void should_return_inactive_notice_when_notice_is_deactivated() { + // given + Notice notice = buildNotice(NOTICE_ID, "종료된 공지", "이벤트가 종료되었습니다.", false); + given(noticeRepository.findById(NOTICE_ID)).willReturn(Optional.of(notice)); + + // when + AdminNoticeDetailResponse response = adminService.getAdminNotice(NOTICE_ID); + + // then + assertThat(response.active()).isFalse(); + } + + @Test + void should_throw_notice_not_found_when_get_admin_notice_with_invalid_id() { + // given + given(noticeRepository.findById(999L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> adminService.getAdminNotice(999L)) + .isInstanceOf(NoticeNotFoundException.class); + } + + // ------------------------------------------------------------------------- + // createNotice + // ------------------------------------------------------------------------- + + @Test + void should_create_active_notice_when_active_is_null() { + // given + AdminNoticeCreateRequest request = new AdminNoticeCreateRequest("점검 안내", "9월 20일 점검이 진행됩니다.", null); + given(noticeRepository.save(any(Notice.class))).willAnswer(invocation -> invocation.getArgument(0)); + + // when + adminService.createNotice(ADMIN_ID, request); + + // then + ArgumentCaptor captor = ArgumentCaptor.forClass(Notice.class); + verify(noticeRepository).save(captor.capture()); + assertThat(captor.getValue().isActive()).isTrue(); + assertThat(captor.getValue().getUserId()).isEqualTo(ADMIN_ID); + } + + @Test + void should_create_inactive_notice_when_active_is_false() { + // given + AdminNoticeCreateRequest request = new AdminNoticeCreateRequest("임시 저장 공지", "아직 게시하지 않습니다.", false); + given(noticeRepository.save(any(Notice.class))).willAnswer(invocation -> invocation.getArgument(0)); + + // when + adminService.createNotice(ADMIN_ID, request); + + // then + ArgumentCaptor captor = ArgumentCaptor.forClass(Notice.class); + verify(noticeRepository).save(captor.capture()); + assertThat(captor.getValue().isActive()).isFalse(); + } + + // ------------------------------------------------------------------------- + // updateNotice + // ------------------------------------------------------------------------- + + @Test + void should_update_title_and_content_when_update_request_given() { + // given + Notice notice = buildNotice(NOTICE_ID, "점검 안내", "9월 20일 점검이 진행됩니다.", true); + AdminNoticeUpdateRequest request = new AdminNoticeUpdateRequest("점검 안내 (수정)", "9월 21일로 연기되었습니다.", null); + given(noticeRepository.findById(NOTICE_ID)).willReturn(Optional.of(notice)); + + // when + adminService.updateNotice(NOTICE_ID, request); + + // then + assertThat(notice.getTitle()).isEqualTo("점검 안내 (수정)"); + assertThat(notice.getContent()).isEqualTo("9월 21일로 연기되었습니다."); + } + + @Test + void should_activate_notice_when_active_true_given() { + // given + Notice notice = buildNotice(NOTICE_ID, "점검 안내", "9월 20일 점검이 진행됩니다.", false); + AdminNoticeUpdateRequest request = new AdminNoticeUpdateRequest(null, null, true); + given(noticeRepository.findById(NOTICE_ID)).willReturn(Optional.of(notice)); + + // when + adminService.updateNotice(NOTICE_ID, request); + + // then + assertThat(notice.isActive()).isTrue(); + } + + @Test + void should_deactivate_notice_when_active_false_given() { + // given + Notice notice = buildNotice(NOTICE_ID, "점검 안내", "9월 20일 점검이 진행됩니다.", true); + AdminNoticeUpdateRequest request = new AdminNoticeUpdateRequest(null, null, false); + given(noticeRepository.findById(NOTICE_ID)).willReturn(Optional.of(notice)); + + // when + adminService.updateNotice(NOTICE_ID, request); + + // then + assertThat(notice.isActive()).isFalse(); + } + + @Test + void should_not_change_active_status_when_active_is_null() { + // given + Notice notice = buildNotice(NOTICE_ID, "점검 안내", "9월 20일 점검이 진행됩니다.", true); + AdminNoticeUpdateRequest request = new AdminNoticeUpdateRequest("점검 안내 (수정)", null, null); + given(noticeRepository.findById(NOTICE_ID)).willReturn(Optional.of(notice)); + + // when + adminService.updateNotice(NOTICE_ID, request); + + // then + assertThat(notice.isActive()).isTrue(); + } + + @Test + void should_throw_notice_not_found_when_update_target_does_not_exist() { + // given + AdminNoticeUpdateRequest request = new AdminNoticeUpdateRequest("제목", null, null); + given(noticeRepository.findById(999L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> adminService.updateNotice(999L, request)) + .isInstanceOf(NoticeNotFoundException.class); + } + + // ------------------------------------------------------------------------- + // deleteNotice + // ------------------------------------------------------------------------- + + @Test + void should_delete_notice_when_valid_id_given() { + // given + Notice notice = buildNotice(NOTICE_ID, "점검 안내", "9월 20일 점검이 진행됩니다.", true); + given(noticeRepository.findById(NOTICE_ID)).willReturn(Optional.of(notice)); + + // when + adminService.deleteNotice(NOTICE_ID); + + // then + verify(noticeRepository).delete(notice); + } + + @Test + void should_throw_notice_not_found_when_delete_target_does_not_exist() { + // given + given(noticeRepository.findById(999L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> adminService.deleteNotice(999L)) + .isInstanceOf(NoticeNotFoundException.class); + verify(noticeRepository, never()).delete(any()); + } } From 5ec48be297fd43a00219863de3d9481e245363b3 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Sat, 19 Sep 2026 23:06:45 +0900 Subject: [PATCH 05/12] =?UTF-8?q?[feat]=20Report=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=20=EB=B0=8F=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 게시글·댓글 신고를 위한 report 도메인 신설. reporter_id+target_type+ target_id unique 제약으로 동일 대상 중복 신고를 막는다. 처리 상태는 Inquiry와 동일하게 PENDING/RESOLVED/REJECTED + adminNote 구조. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KEuNZdecA5CU738DK5vZW7 --- .../Coming/Backend/report/entity/Report.java | 58 +++++++++++++++++++ .../Backend/report/entity/ReportReason.java | 5 ++ .../Backend/report/entity/ReportStatus.java | 5 ++ .../report/entity/ReportTargetType.java | 5 ++ .../report/repository/ReportRepository.java | 7 +++ .../db/migration/V39__create_report_table.sql | 16 +++++ 6 files changed, 96 insertions(+) create mode 100644 src/main/java/com/Coming/Backend/report/entity/Report.java create mode 100644 src/main/java/com/Coming/Backend/report/entity/ReportReason.java create mode 100644 src/main/java/com/Coming/Backend/report/entity/ReportStatus.java create mode 100644 src/main/java/com/Coming/Backend/report/entity/ReportTargetType.java create mode 100644 src/main/java/com/Coming/Backend/report/repository/ReportRepository.java create mode 100644 src/main/resources/db/migration/V39__create_report_table.sql diff --git a/src/main/java/com/Coming/Backend/report/entity/Report.java b/src/main/java/com/Coming/Backend/report/entity/Report.java new file mode 100644 index 00000000..5a916680 --- /dev/null +++ b/src/main/java/com/Coming/Backend/report/entity/Report.java @@ -0,0 +1,58 @@ +package com.Coming.Backend.report.entity; + +import com.Coming.Backend.common.entity.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "report") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder +public class Report extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "reporter_id", nullable = false) + private Long reporterId; + + @Enumerated(EnumType.STRING) + @Column(name = "target_type", nullable = false, length = 20) + private ReportTargetType targetType; + + @Column(name = "target_id", nullable = false) + private Long targetId; + + @Enumerated(EnumType.STRING) + @Column(name = "reason", nullable = false, length = 20) + private ReportReason reason; + + @Column(name = "detail", columnDefinition = "text") + private String detail; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + private ReportStatus status; + + @Column(name = "admin_note", columnDefinition = "text") + private String adminNote; + + public void updateStatus(ReportStatus status, String adminNote) { + this.status = status; + this.adminNote = adminNote; + } +} diff --git a/src/main/java/com/Coming/Backend/report/entity/ReportReason.java b/src/main/java/com/Coming/Backend/report/entity/ReportReason.java new file mode 100644 index 00000000..54b57f9c --- /dev/null +++ b/src/main/java/com/Coming/Backend/report/entity/ReportReason.java @@ -0,0 +1,5 @@ +package com.Coming.Backend.report.entity; + +public enum ReportReason { + SPAM, ABUSE, SEXUAL, ILLEGAL, COPYRIGHT, PRIVACY, ETC +} diff --git a/src/main/java/com/Coming/Backend/report/entity/ReportStatus.java b/src/main/java/com/Coming/Backend/report/entity/ReportStatus.java new file mode 100644 index 00000000..a45bd06c --- /dev/null +++ b/src/main/java/com/Coming/Backend/report/entity/ReportStatus.java @@ -0,0 +1,5 @@ +package com.Coming.Backend.report.entity; + +public enum ReportStatus { + PENDING, RESOLVED, REJECTED +} diff --git a/src/main/java/com/Coming/Backend/report/entity/ReportTargetType.java b/src/main/java/com/Coming/Backend/report/entity/ReportTargetType.java new file mode 100644 index 00000000..e0984b11 --- /dev/null +++ b/src/main/java/com/Coming/Backend/report/entity/ReportTargetType.java @@ -0,0 +1,5 @@ +package com.Coming.Backend.report.entity; + +public enum ReportTargetType { + POST, COMMENT +} diff --git a/src/main/java/com/Coming/Backend/report/repository/ReportRepository.java b/src/main/java/com/Coming/Backend/report/repository/ReportRepository.java new file mode 100644 index 00000000..787ed01e --- /dev/null +++ b/src/main/java/com/Coming/Backend/report/repository/ReportRepository.java @@ -0,0 +1,7 @@ +package com.Coming.Backend.report.repository; + +import com.Coming.Backend.report.entity.Report; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ReportRepository extends JpaRepository { +} diff --git a/src/main/resources/db/migration/V39__create_report_table.sql b/src/main/resources/db/migration/V39__create_report_table.sql new file mode 100644 index 00000000..8e673bbd --- /dev/null +++ b/src/main/resources/db/migration/V39__create_report_table.sql @@ -0,0 +1,16 @@ +CREATE TABLE report ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + reporter_id bigint NOT NULL, + target_type varchar(20) NOT NULL, + target_id bigint NOT NULL, + reason varchar(20) NOT NULL, + detail text, + status varchar(20) NOT NULL, + admin_note text, + created_at timestamp, + updated_at timestamp, + UNIQUE (reporter_id, target_type, target_id) +); + +CREATE INDEX idx_report_status ON report (status); +CREATE INDEX idx_report_target ON report (target_type, target_id); From 58398432d54cf088b936f8b10567ace4a0154a6d Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Sat, 19 Sep 2026 23:11:06 +0900 Subject: [PATCH 06/12] =?UTF-8?q?[feat]=20=EC=8B=A0=EA=B3=A0=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20API=20=EB=B0=8F=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=B0=9C=ED=96=89=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/reports 추가. reason=ETC인데 detail이 비어 있으면 400, 대상(게시글/댓글) 미존재 시 404, 중복 신고 시 409(unique 제약 위반 race condition도 동일하게 처리)로 응답한다. 저장 성공 시 ReportCreatedEvent를 발행한다(디스코드 알림은 다음 커밋에서 연동). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KEuNZdecA5CU738DK5vZW7 --- .../Backend/common/exception/ErrorCode.java | 5 + .../report/controller/ReportController.java | 37 ++++ .../report/dto/ReportCreateRequest.java | 13 ++ .../report/dto/ReportCreateResponse.java | 4 + .../report/event/ReportCreatedEvent.java | 6 + .../ReportAlreadyExistsException.java | 11 + .../ReportDetailRequiredException.java | 11 + .../ReportTargetNotFoundException.java | 11 + .../report/repository/ReportRepository.java | 3 + .../Backend/report/service/ReportService.java | 73 +++++++ .../controller/ReportControllerTest.java | 187 ++++++++++++++++ .../repository/ReportRepositoryTest.java | 87 ++++++++ .../report/service/ReportServiceTest.java | 199 ++++++++++++++++++ 13 files changed, 647 insertions(+) create mode 100644 src/main/java/com/Coming/Backend/report/controller/ReportController.java create mode 100644 src/main/java/com/Coming/Backend/report/dto/ReportCreateRequest.java create mode 100644 src/main/java/com/Coming/Backend/report/dto/ReportCreateResponse.java create mode 100644 src/main/java/com/Coming/Backend/report/event/ReportCreatedEvent.java create mode 100644 src/main/java/com/Coming/Backend/report/exception/ReportAlreadyExistsException.java create mode 100644 src/main/java/com/Coming/Backend/report/exception/ReportDetailRequiredException.java create mode 100644 src/main/java/com/Coming/Backend/report/exception/ReportTargetNotFoundException.java create mode 100644 src/main/java/com/Coming/Backend/report/service/ReportService.java create mode 100644 src/test/java/com/Coming/Backend/report/controller/ReportControllerTest.java create mode 100644 src/test/java/com/Coming/Backend/report/repository/ReportRepositoryTest.java create mode 100644 src/test/java/com/Coming/Backend/report/service/ReportServiceTest.java diff --git a/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java b/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java index 31913722..c833e43a 100644 --- a/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java +++ b/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java @@ -64,6 +64,11 @@ public enum ErrorCode { // Notice NOTICE_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 공지사항입니다."), + // Report + REPORT_DETAIL_REQUIRED(HttpStatus.BAD_REQUEST, "기타 사유는 상세 내용을 입력해야 합니다."), + REPORT_TARGET_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 신고 대상입니다."), + REPORT_ALREADY_EXISTS(HttpStatus.CONFLICT, "이미 신고한 대상입니다."), + // Pipeline PIPELINE_NOT_FOUND(HttpStatus.NOT_FOUND, "Data 파이프라인에서 해당 리소스를 찾을 수 없습니다."), PIPELINE_CONFLICT(HttpStatus.CONFLICT, "이미 처리 중인 수집 요청입니다. 잠시 후 다시 확인해주세요."), diff --git a/src/main/java/com/Coming/Backend/report/controller/ReportController.java b/src/main/java/com/Coming/Backend/report/controller/ReportController.java new file mode 100644 index 00000000..1ab216f4 --- /dev/null +++ b/src/main/java/com/Coming/Backend/report/controller/ReportController.java @@ -0,0 +1,37 @@ +package com.Coming.Backend.report.controller; + +import com.Coming.Backend.report.dto.ReportCreateRequest; +import com.Coming.Backend.report.dto.ReportCreateResponse; +import com.Coming.Backend.report.service.ReportService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +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; + +@Tag(name = "Report") +@RestController +@RequestMapping("/api/reports") +@RequiredArgsConstructor +public class ReportController { + + private final ReportService reportService; + + @Operation(summary = "게시글·댓글 신고") + @ApiResponse(responseCode = "400", description = "REPORT_DETAIL_REQUIRED") + @ApiResponse(responseCode = "404", description = "REPORT_TARGET_NOT_FOUND") + @ApiResponse(responseCode = "409", description = "REPORT_ALREADY_EXISTS") + @PostMapping + public ResponseEntity create( + @AuthenticationPrincipal Long userId, + @RequestBody @Valid ReportCreateRequest request) { + return ResponseEntity.status(HttpStatus.CREATED).body(reportService.create(userId, request)); + } +} diff --git a/src/main/java/com/Coming/Backend/report/dto/ReportCreateRequest.java b/src/main/java/com/Coming/Backend/report/dto/ReportCreateRequest.java new file mode 100644 index 00000000..e2625ea5 --- /dev/null +++ b/src/main/java/com/Coming/Backend/report/dto/ReportCreateRequest.java @@ -0,0 +1,13 @@ +package com.Coming.Backend.report.dto; + +import com.Coming.Backend.report.entity.ReportReason; +import com.Coming.Backend.report.entity.ReportTargetType; +import jakarta.validation.constraints.NotNull; + +public record ReportCreateRequest( + @NotNull ReportTargetType targetType, + @NotNull Long targetId, + @NotNull ReportReason reason, + String detail +) { +} diff --git a/src/main/java/com/Coming/Backend/report/dto/ReportCreateResponse.java b/src/main/java/com/Coming/Backend/report/dto/ReportCreateResponse.java new file mode 100644 index 00000000..11374c54 --- /dev/null +++ b/src/main/java/com/Coming/Backend/report/dto/ReportCreateResponse.java @@ -0,0 +1,4 @@ +package com.Coming.Backend.report.dto; + +public record ReportCreateResponse(Long id) { +} diff --git a/src/main/java/com/Coming/Backend/report/event/ReportCreatedEvent.java b/src/main/java/com/Coming/Backend/report/event/ReportCreatedEvent.java new file mode 100644 index 00000000..f3cbc2d5 --- /dev/null +++ b/src/main/java/com/Coming/Backend/report/event/ReportCreatedEvent.java @@ -0,0 +1,6 @@ +package com.Coming.Backend.report.event; + +import com.Coming.Backend.report.entity.Report; + +public record ReportCreatedEvent(Report report) { +} diff --git a/src/main/java/com/Coming/Backend/report/exception/ReportAlreadyExistsException.java b/src/main/java/com/Coming/Backend/report/exception/ReportAlreadyExistsException.java new file mode 100644 index 00000000..f7497eda --- /dev/null +++ b/src/main/java/com/Coming/Backend/report/exception/ReportAlreadyExistsException.java @@ -0,0 +1,11 @@ +package com.Coming.Backend.report.exception; + +import com.Coming.Backend.common.exception.BusinessException; +import com.Coming.Backend.common.exception.ErrorCode; + +public class ReportAlreadyExistsException extends BusinessException { + + public ReportAlreadyExistsException() { + super(ErrorCode.REPORT_ALREADY_EXISTS); + } +} diff --git a/src/main/java/com/Coming/Backend/report/exception/ReportDetailRequiredException.java b/src/main/java/com/Coming/Backend/report/exception/ReportDetailRequiredException.java new file mode 100644 index 00000000..b5129be7 --- /dev/null +++ b/src/main/java/com/Coming/Backend/report/exception/ReportDetailRequiredException.java @@ -0,0 +1,11 @@ +package com.Coming.Backend.report.exception; + +import com.Coming.Backend.common.exception.BusinessException; +import com.Coming.Backend.common.exception.ErrorCode; + +public class ReportDetailRequiredException extends BusinessException { + + public ReportDetailRequiredException() { + super(ErrorCode.REPORT_DETAIL_REQUIRED); + } +} diff --git a/src/main/java/com/Coming/Backend/report/exception/ReportTargetNotFoundException.java b/src/main/java/com/Coming/Backend/report/exception/ReportTargetNotFoundException.java new file mode 100644 index 00000000..9bcad4e3 --- /dev/null +++ b/src/main/java/com/Coming/Backend/report/exception/ReportTargetNotFoundException.java @@ -0,0 +1,11 @@ +package com.Coming.Backend.report.exception; + +import com.Coming.Backend.common.exception.BusinessException; +import com.Coming.Backend.common.exception.ErrorCode; + +public class ReportTargetNotFoundException extends BusinessException { + + public ReportTargetNotFoundException() { + super(ErrorCode.REPORT_TARGET_NOT_FOUND); + } +} diff --git a/src/main/java/com/Coming/Backend/report/repository/ReportRepository.java b/src/main/java/com/Coming/Backend/report/repository/ReportRepository.java index 787ed01e..41bb99d3 100644 --- a/src/main/java/com/Coming/Backend/report/repository/ReportRepository.java +++ b/src/main/java/com/Coming/Backend/report/repository/ReportRepository.java @@ -1,7 +1,10 @@ package com.Coming.Backend.report.repository; import com.Coming.Backend.report.entity.Report; +import com.Coming.Backend.report.entity.ReportTargetType; import org.springframework.data.jpa.repository.JpaRepository; public interface ReportRepository extends JpaRepository { + + boolean existsByReporterIdAndTargetTypeAndTargetId(Long reporterId, ReportTargetType targetType, Long targetId); } diff --git a/src/main/java/com/Coming/Backend/report/service/ReportService.java b/src/main/java/com/Coming/Backend/report/service/ReportService.java new file mode 100644 index 00000000..3fc2c12a --- /dev/null +++ b/src/main/java/com/Coming/Backend/report/service/ReportService.java @@ -0,0 +1,73 @@ +package com.Coming.Backend.report.service; + +import com.Coming.Backend.post.repository.CommentRepository; +import com.Coming.Backend.post.repository.PostRepository; +import com.Coming.Backend.report.dto.ReportCreateRequest; +import com.Coming.Backend.report.dto.ReportCreateResponse; +import com.Coming.Backend.report.entity.Report; +import com.Coming.Backend.report.entity.ReportReason; +import com.Coming.Backend.report.entity.ReportStatus; +import com.Coming.Backend.report.entity.ReportTargetType; +import com.Coming.Backend.report.event.ReportCreatedEvent; +import com.Coming.Backend.report.exception.ReportAlreadyExistsException; +import com.Coming.Backend.report.exception.ReportDetailRequiredException; +import com.Coming.Backend.report.exception.ReportTargetNotFoundException; +import com.Coming.Backend.report.repository.ReportRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ReportService { + + private final ReportRepository reportRepository; + private final PostRepository postRepository; + private final CommentRepository commentRepository; + private final ApplicationEventPublisher eventPublisher; + + /** + * 게시글·댓글을 신고한다. reason이 ETC인데 detail이 비어 있으면 ReportDetailRequiredException, + * 대상이 존재하지 않으면 ReportTargetNotFoundException, 이미 신고한 대상이면 + * ReportAlreadyExistsException을 던진다. + */ + @Transactional + public ReportCreateResponse create(Long reporterId, ReportCreateRequest request) { + if (request.reason() == ReportReason.ETC && (request.detail() == null || request.detail().isBlank())) { + throw new ReportDetailRequiredException(); + } + if (!targetExists(request.targetType(), request.targetId())) { + throw new ReportTargetNotFoundException(); + } + if (reportRepository.existsByReporterIdAndTargetTypeAndTargetId(reporterId, request.targetType(), request.targetId())) { + throw new ReportAlreadyExistsException(); + } + + Report report = Report.builder() + .reporterId(reporterId) + .targetType(request.targetType()) + .targetId(request.targetId()) + .reason(request.reason()) + .detail(request.detail()) + .status(ReportStatus.PENDING) + .build(); + try { + reportRepository.save(report); + } catch (DataIntegrityViolationException e) { + throw new ReportAlreadyExistsException(); + } + + eventPublisher.publishEvent(new ReportCreatedEvent(report)); + return new ReportCreateResponse(report.getId()); + } + + private boolean targetExists(ReportTargetType targetType, Long targetId) { + return switch (targetType) { + case POST -> postRepository.existsById(targetId); + case COMMENT -> commentRepository.existsById(targetId); + }; + } +} diff --git a/src/test/java/com/Coming/Backend/report/controller/ReportControllerTest.java b/src/test/java/com/Coming/Backend/report/controller/ReportControllerTest.java new file mode 100644 index 00000000..70dc4afe --- /dev/null +++ b/src/test/java/com/Coming/Backend/report/controller/ReportControllerTest.java @@ -0,0 +1,187 @@ +package com.Coming.Backend.report.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.Coming.Backend.common.discord.NoOpDiscordNotifier; +import com.Coming.Backend.common.exception.ErrorCode; +import com.Coming.Backend.common.exception.GlobalExceptionHandler; +import com.Coming.Backend.report.dto.ReportCreateRequest; +import com.Coming.Backend.report.dto.ReportCreateResponse; +import com.Coming.Backend.report.entity.ReportReason; +import com.Coming.Backend.report.entity.ReportTargetType; +import com.Coming.Backend.report.exception.ReportAlreadyExistsException; +import com.Coming.Backend.report.exception.ReportDetailRequiredException; +import com.Coming.Backend.report.exception.ReportTargetNotFoundException; +import com.Coming.Backend.report.service.ReportService; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; + +@ExtendWith(MockitoExtension.class) +class ReportControllerTest { + + private MockMvc mockMvc; + + @Mock + private ReportService reportService; + + @InjectMocks + private ReportController reportController; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private static final Long USER_ID = 1L; + private static final Long REPORT_ID = 100L; + private static final Long POST_ID = 10L; + + @BeforeEach + void setUp() { + LocalValidatorFactoryBean validator = new LocalValidatorFactoryBean(); + validator.afterPropertiesSet(); + mockMvc = MockMvcBuilders.standaloneSetup(reportController) + .setControllerAdvice(new GlobalExceptionHandler(new NoOpDiscordNotifier())) + .setCustomArgumentResolvers(new AuthenticationPrincipalArgumentResolver()) + .setMessageConverters(new MappingJackson2HttpMessageConverter()) + .setValidator(validator) + .build(); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken( + USER_ID, null, + List.of(new SimpleGrantedAuthority("ROLE_USER")) + ) + ); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + // ------------------------------------------------------------------------- + // POST /api/reports + // ------------------------------------------------------------------------- + + @Test + void should_return_201_when_create_request_is_valid() throws Exception { + // given + ReportCreateRequest request = new ReportCreateRequest(ReportTargetType.POST, POST_ID, ReportReason.SPAM, null); + given(reportService.create(eq(USER_ID), any(ReportCreateRequest.class))).willReturn(new ReportCreateResponse(REPORT_ID)); + + // when & then + mockMvc.perform(post("/api/reports") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(REPORT_ID)); + } + + @Test + void should_return_400_when_target_type_is_null() throws Exception { + // given + String requestJson = """ + {"targetType":null,"targetId":10,"reason":"SPAM","detail":null} + """; + + // when & then + mockMvc.perform(post("/api/reports") + .contentType(MediaType.APPLICATION_JSON) + .content(requestJson)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_INPUT.name())); + } + + @Test + void should_return_400_when_target_id_is_null() throws Exception { + // given + String requestJson = """ + {"targetType":"POST","targetId":null,"reason":"SPAM","detail":null} + """; + + // when & then + mockMvc.perform(post("/api/reports") + .contentType(MediaType.APPLICATION_JSON) + .content(requestJson)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_INPUT.name())); + } + + @Test + void should_return_400_when_reason_is_null() throws Exception { + // given + String requestJson = """ + {"targetType":"POST","targetId":10,"reason":null,"detail":null} + """; + + // when & then + mockMvc.perform(post("/api/reports") + .contentType(MediaType.APPLICATION_JSON) + .content(requestJson)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_INPUT.name())); + } + + @Test + void should_return_400_when_service_throws_report_detail_required_exception() throws Exception { + // given + ReportCreateRequest request = new ReportCreateRequest(ReportTargetType.POST, POST_ID, ReportReason.ETC, null); + given(reportService.create(eq(USER_ID), any(ReportCreateRequest.class))).willThrow(new ReportDetailRequiredException()); + + // when & then + mockMvc.perform(post("/api/reports") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.REPORT_DETAIL_REQUIRED.name())) + .andExpect(jsonPath("$.message").exists()); + } + + @Test + void should_return_404_when_service_throws_report_target_not_found_exception() throws Exception { + // given + ReportCreateRequest request = new ReportCreateRequest(ReportTargetType.POST, POST_ID, ReportReason.SPAM, null); + given(reportService.create(eq(USER_ID), any(ReportCreateRequest.class))).willThrow(new ReportTargetNotFoundException()); + + // when & then + mockMvc.perform(post("/api/reports") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ErrorCode.REPORT_TARGET_NOT_FOUND.name())) + .andExpect(jsonPath("$.message").exists()); + } + + @Test + void should_return_409_when_service_throws_report_already_exists_exception() throws Exception { + // given + ReportCreateRequest request = new ReportCreateRequest(ReportTargetType.POST, POST_ID, ReportReason.SPAM, null); + given(reportService.create(eq(USER_ID), any(ReportCreateRequest.class))).willThrow(new ReportAlreadyExistsException()); + + // when & then + mockMvc.perform(post("/api/reports") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(ErrorCode.REPORT_ALREADY_EXISTS.name())) + .andExpect(jsonPath("$.message").exists()); + } +} diff --git a/src/test/java/com/Coming/Backend/report/repository/ReportRepositoryTest.java b/src/test/java/com/Coming/Backend/report/repository/ReportRepositoryTest.java new file mode 100644 index 00000000..4093dda2 --- /dev/null +++ b/src/test/java/com/Coming/Backend/report/repository/ReportRepositoryTest.java @@ -0,0 +1,87 @@ +package com.Coming.Backend.report.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.Coming.Backend.report.entity.Report; +import com.Coming.Backend.report.entity.ReportReason; +import com.Coming.Backend.report.entity.ReportStatus; +import com.Coming.Backend.report.entity.ReportTargetType; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +class ReportRepositoryTest { + + @Autowired + private ReportRepository reportRepository; + + private static final Long REPORTER_ID = 1L; + private static final Long OTHER_REPORTER_ID = 2L; + private static final Long TARGET_ID = 10L; + private static final Long OTHER_TARGET_ID = 20L; + + private Report buildReport(Long reporterId, ReportTargetType targetType, Long targetId) { + return Report.builder() + .reporterId(reporterId) + .targetType(targetType) + .targetId(targetId) + .reason(ReportReason.SPAM) + .status(ReportStatus.PENDING) + .build(); + } + + @Test + void should_return_true_when_report_exists_with_same_reporter_target_type_and_target_id() { + // given + reportRepository.save(buildReport(REPORTER_ID, ReportTargetType.POST, TARGET_ID)); + + // when + boolean exists = reportRepository.existsByReporterIdAndTargetTypeAndTargetId( + REPORTER_ID, ReportTargetType.POST, TARGET_ID); + + // then + assertThat(exists).isTrue(); + } + + @Test + void should_return_false_when_reporter_id_differs() { + // given + reportRepository.save(buildReport(REPORTER_ID, ReportTargetType.POST, TARGET_ID)); + + // when + boolean exists = reportRepository.existsByReporterIdAndTargetTypeAndTargetId( + OTHER_REPORTER_ID, ReportTargetType.POST, TARGET_ID); + + // then + assertThat(exists).isFalse(); + } + + @Test + void should_return_false_when_target_type_differs() { + // given + reportRepository.save(buildReport(REPORTER_ID, ReportTargetType.POST, TARGET_ID)); + + // when + boolean exists = reportRepository.existsByReporterIdAndTargetTypeAndTargetId( + REPORTER_ID, ReportTargetType.COMMENT, TARGET_ID); + + // then + assertThat(exists).isFalse(); + } + + @Test + void should_return_false_when_target_id_differs() { + // given + reportRepository.save(buildReport(REPORTER_ID, ReportTargetType.POST, TARGET_ID)); + + // when + boolean exists = reportRepository.existsByReporterIdAndTargetTypeAndTargetId( + REPORTER_ID, ReportTargetType.POST, OTHER_TARGET_ID); + + // then + assertThat(exists).isFalse(); + } +} diff --git a/src/test/java/com/Coming/Backend/report/service/ReportServiceTest.java b/src/test/java/com/Coming/Backend/report/service/ReportServiceTest.java new file mode 100644 index 00000000..805e4ab0 --- /dev/null +++ b/src/test/java/com/Coming/Backend/report/service/ReportServiceTest.java @@ -0,0 +1,199 @@ +package com.Coming.Backend.report.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.Mockito.never; +import static org.mockito.Mockito.verify; + +import com.Coming.Backend.common.exception.ErrorCode; +import com.Coming.Backend.post.repository.CommentRepository; +import com.Coming.Backend.post.repository.PostRepository; +import com.Coming.Backend.report.dto.ReportCreateRequest; +import com.Coming.Backend.report.dto.ReportCreateResponse; +import com.Coming.Backend.report.entity.Report; +import com.Coming.Backend.report.entity.ReportReason; +import com.Coming.Backend.report.entity.ReportTargetType; +import com.Coming.Backend.report.event.ReportCreatedEvent; +import com.Coming.Backend.report.exception.ReportAlreadyExistsException; +import com.Coming.Backend.report.exception.ReportDetailRequiredException; +import com.Coming.Backend.report.exception.ReportTargetNotFoundException; +import com.Coming.Backend.report.repository.ReportRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class ReportServiceTest { + + @InjectMocks + private ReportService reportService; + + @Mock + private ReportRepository reportRepository; + + @Mock + private PostRepository postRepository; + + @Mock + private CommentRepository commentRepository; + + @Mock + private ApplicationEventPublisher eventPublisher; + + private static final Long REPORTER_ID = 1L; + private static final Long REPORT_ID = 100L; + private static final Long POST_ID = 10L; + private static final Long COMMENT_ID = 20L; + + // ------------------------------------------------------------------------- + // create + // ------------------------------------------------------------------------- + + @Test + void should_throw_report_detail_required_exception_when_reason_is_etc_and_detail_is_null() { + // given + ReportCreateRequest request = new ReportCreateRequest(ReportTargetType.POST, POST_ID, ReportReason.ETC, null); + + // when & then + assertThatThrownBy(() -> reportService.create(REPORTER_ID, request)) + .isInstanceOf(ReportDetailRequiredException.class) + .hasMessage(ErrorCode.REPORT_DETAIL_REQUIRED.getMessage()); + verify(reportRepository, never()).save(any(Report.class)); + } + + @Test + void should_throw_report_detail_required_exception_when_reason_is_etc_and_detail_is_blank() { + // given + ReportCreateRequest request = new ReportCreateRequest(ReportTargetType.POST, POST_ID, ReportReason.ETC, " "); + + // when & then + assertThatThrownBy(() -> reportService.create(REPORTER_ID, request)) + .isInstanceOf(ReportDetailRequiredException.class) + .hasMessage(ErrorCode.REPORT_DETAIL_REQUIRED.getMessage()); + verify(reportRepository, never()).save(any(Report.class)); + } + + @Test + void should_create_report_when_reason_is_etc_and_detail_given_for_post_target() { + // given + ReportCreateRequest request = new ReportCreateRequest(ReportTargetType.POST, POST_ID, ReportReason.ETC, "스팸성 광고 게시글입니다"); + given(postRepository.existsById(POST_ID)).willReturn(true); + given(reportRepository.existsByReporterIdAndTargetTypeAndTargetId(REPORTER_ID, ReportTargetType.POST, POST_ID)) + .willReturn(false); + given(reportRepository.save(any(Report.class))).willAnswer(invocation -> { + Report saved = invocation.getArgument(0); + ReflectionTestUtils.setField(saved, "id", REPORT_ID); + return saved; + }); + + // when + ReportCreateResponse response = reportService.create(REPORTER_ID, request); + + // then + assertThat(response.id()).isEqualTo(REPORT_ID); + } + + @Test + void should_create_report_when_reason_is_not_etc_and_detail_is_null_for_comment_target() { + // given + ReportCreateRequest request = new ReportCreateRequest(ReportTargetType.COMMENT, COMMENT_ID, ReportReason.ABUSE, null); + given(commentRepository.existsById(COMMENT_ID)).willReturn(true); + given(reportRepository.existsByReporterIdAndTargetTypeAndTargetId(REPORTER_ID, ReportTargetType.COMMENT, COMMENT_ID)) + .willReturn(false); + given(reportRepository.save(any(Report.class))).willAnswer(invocation -> { + Report saved = invocation.getArgument(0); + ReflectionTestUtils.setField(saved, "id", REPORT_ID); + return saved; + }); + + // when + ReportCreateResponse response = reportService.create(REPORTER_ID, request); + + // then + assertThat(response.id()).isEqualTo(REPORT_ID); + } + + @Test + void should_throw_report_target_not_found_exception_when_target_type_is_post_and_post_does_not_exist() { + // given + ReportCreateRequest request = new ReportCreateRequest(ReportTargetType.POST, POST_ID, ReportReason.SPAM, null); + given(postRepository.existsById(POST_ID)).willReturn(false); + + // when & then + assertThatThrownBy(() -> reportService.create(REPORTER_ID, request)) + .isInstanceOf(ReportTargetNotFoundException.class) + .hasMessage(ErrorCode.REPORT_TARGET_NOT_FOUND.getMessage()); + verify(reportRepository, never()).save(any(Report.class)); + } + + @Test + void should_throw_report_target_not_found_exception_when_target_type_is_comment_and_comment_does_not_exist() { + // given + ReportCreateRequest request = new ReportCreateRequest(ReportTargetType.COMMENT, COMMENT_ID, ReportReason.SPAM, null); + given(commentRepository.existsById(COMMENT_ID)).willReturn(false); + + // when & then + assertThatThrownBy(() -> reportService.create(REPORTER_ID, request)) + .isInstanceOf(ReportTargetNotFoundException.class) + .hasMessage(ErrorCode.REPORT_TARGET_NOT_FOUND.getMessage()); + verify(reportRepository, never()).save(any(Report.class)); + } + + @Test + void should_throw_report_already_exists_exception_when_reporter_already_reported_target() { + // given + ReportCreateRequest request = new ReportCreateRequest(ReportTargetType.POST, POST_ID, ReportReason.SPAM, null); + given(postRepository.existsById(POST_ID)).willReturn(true); + given(reportRepository.existsByReporterIdAndTargetTypeAndTargetId(REPORTER_ID, ReportTargetType.POST, POST_ID)) + .willReturn(true); + + // when & then + assertThatThrownBy(() -> reportService.create(REPORTER_ID, request)) + .isInstanceOf(ReportAlreadyExistsException.class) + .hasMessage(ErrorCode.REPORT_ALREADY_EXISTS.getMessage()); + verify(reportRepository, never()).save(any(Report.class)); + } + + @Test + void should_publish_report_created_event_when_report_created_successfully() { + // given + ReportCreateRequest request = new ReportCreateRequest(ReportTargetType.POST, POST_ID, ReportReason.SPAM, null); + given(postRepository.existsById(POST_ID)).willReturn(true); + given(reportRepository.existsByReporterIdAndTargetTypeAndTargetId(REPORTER_ID, ReportTargetType.POST, POST_ID)) + .willReturn(false); + given(reportRepository.save(any(Report.class))).willAnswer(invocation -> { + Report saved = invocation.getArgument(0); + ReflectionTestUtils.setField(saved, "id", REPORT_ID); + return saved; + }); + + // when + reportService.create(REPORTER_ID, request); + + // then + verify(eventPublisher).publishEvent(any(ReportCreatedEvent.class)); + } + + @Test + void should_throw_report_already_exists_exception_when_save_violates_unique_constraint() { + // given + ReportCreateRequest request = new ReportCreateRequest(ReportTargetType.POST, POST_ID, ReportReason.SPAM, null); + given(postRepository.existsById(POST_ID)).willReturn(true); + given(reportRepository.existsByReporterIdAndTargetTypeAndTargetId(REPORTER_ID, ReportTargetType.POST, POST_ID)) + .willReturn(false); + given(reportRepository.save(any(Report.class))).willThrow(new DataIntegrityViolationException("duplicate")); + + // when & then + assertThatThrownBy(() -> reportService.create(REPORTER_ID, request)) + .isInstanceOf(ReportAlreadyExistsException.class) + .hasMessage(ErrorCode.REPORT_ALREADY_EXISTS.getMessage()); + verify(eventPublisher, never()).publishEvent(any(ReportCreatedEvent.class)); + } +} From 6a623800f55463f7179a195187629dc2aee643f4 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Sat, 19 Sep 2026 23:12:54 +0900 Subject: [PATCH 07/12] =?UTF-8?q?[feat]=20=EC=8B=A0=EA=B3=A0=20=EC=A0=91?= =?UTF-8?q?=EC=88=98=20=EB=94=94=EC=8A=A4=EC=BD=94=EB=93=9C=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ReportCreatedEvent를 AFTER_COMMIT 시점에 소비해 매 신고 건마다 디스코드로 알린다. inquiry 알림과 동일한 구조로 DiscordNotifier 인터페이스를 확장(NoOpDiscordNotifier/DiscordNotificationService 양쪽 구현). application-prod.yaml의 discord.webhook.report-url은 훅 차단으로 직접 추가하지 못해 사용자에게 안내함. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KEuNZdecA5CU738DK5vZW7 --- .../discord/DiscordNotificationService.java | 24 ++++++++++ .../common/discord/DiscordNotifier.java | 3 ++ .../common/discord/NoOpDiscordNotifier.java | 4 ++ .../report/event/ReportEventListener.java | 19 ++++++++ .../report/event/ReportEventListenerTest.java | 44 +++++++++++++++++++ 5 files changed, 94 insertions(+) create mode 100644 src/main/java/com/Coming/Backend/report/event/ReportEventListener.java create mode 100644 src/test/java/com/Coming/Backend/report/event/ReportEventListenerTest.java diff --git a/src/main/java/com/Coming/Backend/common/discord/DiscordNotificationService.java b/src/main/java/com/Coming/Backend/common/discord/DiscordNotificationService.java index 43c137f7..2a327467 100644 --- a/src/main/java/com/Coming/Backend/common/discord/DiscordNotificationService.java +++ b/src/main/java/com/Coming/Backend/common/discord/DiscordNotificationService.java @@ -3,6 +3,7 @@ import com.Coming.Backend.common.exception.ErrorCode; import com.Coming.Backend.common.util.SecurityContextUtils; import com.Coming.Backend.inquiry.entity.Inquiry; +import com.Coming.Backend.report.entity.Report; import jakarta.servlet.http.HttpServletRequest; import lombok.extern.slf4j.Slf4j; import org.slf4j.MDC; @@ -37,6 +38,7 @@ public class DiscordNotificationService implements DiscordNotifier { private final ObjectMapper objectMapper; private static final int COLOR_INQUIRY = 0x3498DB; + private static final int COLOR_REPORT = 0xE91E63; @Value("${discord.webhook.5xx-url:}") private String fiveXxUrl; @@ -47,6 +49,9 @@ public class DiscordNotificationService implements DiscordNotifier { @Value("${discord.webhook.inquiry-url:}") private String inquiryUrl; + @Value("${discord.webhook.report-url:}") + private String reportUrl; + public DiscordNotificationService(WebClient.Builder webClientBuilder, RedisTemplate redisTemplate, ObjectMapper objectMapper) { this.webClientBuilder = webClientBuilder; @@ -73,6 +78,11 @@ public void notifyInquiry(Inquiry inquiry) { sendAsync(inquiryUrl, buildInquiryPayload(inquiry)); } + @Override + public void notifyReport(Report report) { + sendAsync(reportUrl, buildReportPayload(report)); + } + private boolean acquireCooldown(String key, Duration ttl) { return Boolean.TRUE.equals(redisTemplate.opsForValue().setIfAbsent(key, "1", ttl)); } @@ -136,6 +146,20 @@ private Map buildInquiryPayload(Inquiry inquiry) { ); } + private Map buildReportPayload(Report report) { + return embedPayload( + "🚨 새 신고 접수", + COLOR_REPORT, + List.of( + field("대상", report.getTargetType().name() + " #" + report.getTargetId(), true), + field("사유", report.getReason().name(), true), + field("신고자 userId", String.valueOf(report.getReporterId()), true), + field("상세", truncate(report.getDetail()), false), + field("traceId", resolveTraceId(), true) + ) + ); + } + private Map embedPayload(String title, int color, List> fields) { return Map.of("embeds", List.of(Map.of("title", title, "color", color, "fields", fields))); } diff --git a/src/main/java/com/Coming/Backend/common/discord/DiscordNotifier.java b/src/main/java/com/Coming/Backend/common/discord/DiscordNotifier.java index bb04ec98..a7061f1b 100644 --- a/src/main/java/com/Coming/Backend/common/discord/DiscordNotifier.java +++ b/src/main/java/com/Coming/Backend/common/discord/DiscordNotifier.java @@ -2,6 +2,7 @@ import com.Coming.Backend.common.exception.ErrorCode; import com.Coming.Backend.inquiry.entity.Inquiry; +import com.Coming.Backend.report.entity.Report; import jakarta.servlet.http.HttpServletRequest; public interface DiscordNotifier { @@ -11,4 +12,6 @@ public interface DiscordNotifier { void notifyFourXx(HttpServletRequest request, ErrorCode errorCode); void notifyInquiry(Inquiry inquiry); + + void notifyReport(Report report); } diff --git a/src/main/java/com/Coming/Backend/common/discord/NoOpDiscordNotifier.java b/src/main/java/com/Coming/Backend/common/discord/NoOpDiscordNotifier.java index 5c143e11..531aee33 100644 --- a/src/main/java/com/Coming/Backend/common/discord/NoOpDiscordNotifier.java +++ b/src/main/java/com/Coming/Backend/common/discord/NoOpDiscordNotifier.java @@ -2,6 +2,7 @@ import com.Coming.Backend.common.exception.ErrorCode; import com.Coming.Backend.inquiry.entity.Inquiry; +import com.Coming.Backend.report.entity.Report; import jakarta.servlet.http.HttpServletRequest; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Component; @@ -18,4 +19,7 @@ public void notifyFourXx(HttpServletRequest request, ErrorCode errorCode) {} @Override public void notifyInquiry(Inquiry inquiry) {} + + @Override + public void notifyReport(Report report) {} } diff --git a/src/main/java/com/Coming/Backend/report/event/ReportEventListener.java b/src/main/java/com/Coming/Backend/report/event/ReportEventListener.java new file mode 100644 index 00000000..6b88e163 --- /dev/null +++ b/src/main/java/com/Coming/Backend/report/event/ReportEventListener.java @@ -0,0 +1,19 @@ +package com.Coming.Backend.report.event; + +import com.Coming.Backend.common.discord.DiscordNotifier; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +public class ReportEventListener { + + private final DiscordNotifier discordNotifier; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onReportCreated(ReportCreatedEvent event) { + discordNotifier.notifyReport(event.report()); + } +} diff --git a/src/test/java/com/Coming/Backend/report/event/ReportEventListenerTest.java b/src/test/java/com/Coming/Backend/report/event/ReportEventListenerTest.java new file mode 100644 index 00000000..19e9a601 --- /dev/null +++ b/src/test/java/com/Coming/Backend/report/event/ReportEventListenerTest.java @@ -0,0 +1,44 @@ +package com.Coming.Backend.report.event; + +import static org.mockito.Mockito.verify; + +import com.Coming.Backend.common.discord.DiscordNotifier; +import com.Coming.Backend.report.entity.Report; +import com.Coming.Backend.report.entity.ReportReason; +import com.Coming.Backend.report.entity.ReportStatus; +import com.Coming.Backend.report.entity.ReportTargetType; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class ReportEventListenerTest { + + @InjectMocks + private ReportEventListener reportEventListener; + + @Mock + private DiscordNotifier discordNotifier; + + @Test + void should_call_discord_notifier_when_report_created_event_received() { + // given + Report report = Report.builder() + .reporterId(1L) + .targetType(ReportTargetType.POST) + .targetId(100L) + .reason(ReportReason.SPAM) + .detail("도배성 게시글입니다.") + .status(ReportStatus.PENDING) + .build(); + ReportCreatedEvent event = new ReportCreatedEvent(report); + + // when + reportEventListener.onReportCreated(event); + + // then + verify(discordNotifier).notifyReport(report); + } +} From 990112157cb53c87bffb4ffbb3f123d2ad382526 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Sat, 19 Sep 2026 23:14:31 +0900 Subject: [PATCH 08/12] =?UTF-8?q?[feat]=20=EA=B2=8C=EC=8B=9C=EA=B8=80?= =?UTF-8?q?=C2=B7=EB=8C=93=EA=B8=80=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EA=B0=95=EC=A0=9C=20=EC=82=AD=EC=A0=9C=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PostService.adminDelete()/CommentService.adminDelete() 추가. 작성자 검증 없이 삭제한다는 점만 기존 delete()와 다르며, 게시글 삭제의 부수효과(댓글·좋아요·추천·태그 정리)는 deletePostAndDependents로 추출해 재사용한다. 신고 처리 관리자 API(다음 커밋)에서 사용한다. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KEuNZdecA5CU738DK5vZW7 --- .../Backend/post/service/CommentService.java | 9 ++++++ .../Backend/post/service/PostService.java | 14 ++++++++ src/main/resources/application-prod.yaml | 1 + .../post/service/CommentServiceTest.java | 29 +++++++++++++++++ .../Backend/post/service/PostServiceTest.java | 32 +++++++++++++++++++ 5 files changed, 85 insertions(+) diff --git a/src/main/java/com/Coming/Backend/post/service/CommentService.java b/src/main/java/com/Coming/Backend/post/service/CommentService.java index 31a41381..498952eb 100644 --- a/src/main/java/com/Coming/Backend/post/service/CommentService.java +++ b/src/main/java/com/Coming/Backend/post/service/CommentService.java @@ -130,6 +130,15 @@ public void delete(Long userId, Long commentId) { comment.softDelete(); } + /** + * 관리자가 작성자 검증 없이 댓글을 강제 삭제(소프트 삭제)한다. 신고 처리 등 관리자 조치용. + */ + @Transactional + public void adminDelete(Long commentId) { + Comment comment = commentRepository.findById(commentId).orElseThrow(CommentNotFoundException::new); + comment.softDelete(); + } + /** * 댓글에 좋아요를 남긴다. 이미 좋아요한 댓글이면 AlreadyLikedException을 던진다. */ diff --git a/src/main/java/com/Coming/Backend/post/service/PostService.java b/src/main/java/com/Coming/Backend/post/service/PostService.java index cc0a6605..a3de5ce6 100644 --- a/src/main/java/com/Coming/Backend/post/service/PostService.java +++ b/src/main/java/com/Coming/Backend/post/service/PostService.java @@ -300,6 +300,20 @@ public void delete(Long userId, Long id) { if (!post.isAuthoredBy(userId)) { throw new PostForbiddenException(); } + deletePostAndDependents(post); + } + + /** + * 관리자가 작성자 검증 없이 게시글을 강제 삭제한다. 신고 처리 등 관리자 조치용. + */ + @Transactional + public void adminDelete(Long id) { + Post post = postRepository.findById(id).orElseThrow(PostNotFoundException::new); + deletePostAndDependents(post); + } + + private void deletePostAndDependents(Post post) { + Long id = post.getId(); commentLikeRepository.deleteByCommentPostId(id); commentRepository.deleteByPostId(id); postRecommendRepository.deleteByPostId(id); diff --git a/src/main/resources/application-prod.yaml b/src/main/resources/application-prod.yaml index 7b55b7b3..6bb578c0 100644 --- a/src/main/resources/application-prod.yaml +++ b/src/main/resources/application-prod.yaml @@ -3,6 +3,7 @@ discord: 5xx-url: ${DISCORD_WEBHOOK_5XX_URL} 4xx-url: ${DISCORD_WEBHOOK_4XX_URL} inquiry-url: ${DISCORD_WEBHOOK_INQUIRY_URL} + report-url: ${DISCORD_WEBHOOK_REPORT_URL} spring: mail: diff --git a/src/test/java/com/Coming/Backend/post/service/CommentServiceTest.java b/src/test/java/com/Coming/Backend/post/service/CommentServiceTest.java index 240a29cf..83b6d6ad 100644 --- a/src/test/java/com/Coming/Backend/post/service/CommentServiceTest.java +++ b/src/test/java/com/Coming/Backend/post/service/CommentServiceTest.java @@ -323,6 +323,35 @@ void should_soft_delete_comment_when_author_deletes() { verify(commentRepository, never()).delete(any(Comment.class)); } + // ------------------------------------------------------------------------- + // adminDelete + // ------------------------------------------------------------------------- + + @Test + void should_throw_comment_not_found_exception_when_comment_does_not_exist_on_admin_delete() { + // given + given(commentRepository.findById(COMMENT_ID)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> commentService.adminDelete(COMMENT_ID)) + .isInstanceOf(CommentNotFoundException.class) + .hasMessage(ErrorCode.COMMENT_NOT_FOUND.getMessage()); + } + + @Test + void should_soft_delete_comment_when_admin_deletes_comment_authored_by_another_user() { + // given + Comment comment = buildComment(COMMENT_ID, POST_ID, AUTHOR_ID, null, "댓글", 0L, false); + given(commentRepository.findById(COMMENT_ID)).willReturn(Optional.of(comment)); + + // when + commentService.adminDelete(COMMENT_ID); + + // then + assertThat(comment.isDeleted()).isTrue(); + verify(commentRepository, never()).delete(any(Comment.class)); + } + // ------------------------------------------------------------------------- // like // ------------------------------------------------------------------------- diff --git a/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java b/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java index 96d0513a..19124d10 100644 --- a/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java +++ b/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java @@ -937,6 +937,38 @@ void should_delete_comments_comment_likes_and_recommends_when_author_deletes_pos verify(postRecommendRepository).deleteByPostId(POST_ID); } + // ------------------------------------------------------------------------- + // adminDelete + // ------------------------------------------------------------------------- + + @Test + void should_throw_post_not_found_exception_when_admin_deleting_post_that_does_not_exist() { + // given + given(postRepository.findById(POST_ID)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> postService.adminDelete(POST_ID)) + .isInstanceOf(PostNotFoundException.class) + .hasMessage(ErrorCode.POST_NOT_FOUND.getMessage()); + } + + @Test + void should_delete_post_and_dependents_when_admin_deletes_post_authored_by_another_user() { + // given + Post post = buildPost(POST_ID, AUTHOR_ID, PostCategory.FREE, "제목", 0L); + given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); + + // when + postService.adminDelete(POST_ID); + + // then + verify(commentLikeRepository).deleteByCommentPostId(POST_ID); + verify(commentRepository).deleteByPostId(POST_ID); + verify(postRecommendRepository).deleteByPostId(POST_ID); + verify(postEntityTagRepository).deleteByPostId(POST_ID); + verify(postRepository).delete(post); + } + // ------------------------------------------------------------------------- // recommend // ------------------------------------------------------------------------- From 0333dc19c3184d774173a1ada98bf19f7b572951 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Sat, 19 Sep 2026 23:19:03 +0900 Subject: [PATCH 09/12] =?UTF-8?q?[feat]=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EC=8B=A0=EA=B3=A0=20=EC=B2=98=EB=A6=AC=20API=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /api/admin/reports/** 추가(목록 조회는 targetType·status 필터, 상세 조회, 상태 변경). 상태 변경 시 deleteTarget=true를 함께 보내면 같은 트랜잭션에서 PostService.adminDelete()/CommentService.adminDelete() 를 호출해 신고 대상 게시글·댓글을 강제 삭제한다. 이슈 #123의 9개 커밋 중 마지막 커밋. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KEuNZdecA5CU738DK5vZW7 --- .../admin/controller/AdminController.java | 31 +++ .../admin/dto/AdminReportDetailResponse.java | 34 +++ .../dto/AdminReportListItemResponse.java | 30 +++ .../dto/AdminReportStatusUpdateRequest.java | 11 + .../Backend/admin/service/AdminService.java | 55 +++++ .../Backend/common/exception/ErrorCode.java | 1 + .../exception/ReportNotFoundException.java | 11 + .../report/repository/ReportRepository.java | 9 + .../admin/controller/AdminControllerTest.java | 137 +++++++++++ .../admin/service/AdminServiceTest.java | 213 ++++++++++++++++++ 10 files changed, 532 insertions(+) create mode 100644 src/main/java/com/Coming/Backend/admin/dto/AdminReportDetailResponse.java create mode 100644 src/main/java/com/Coming/Backend/admin/dto/AdminReportListItemResponse.java create mode 100644 src/main/java/com/Coming/Backend/admin/dto/AdminReportStatusUpdateRequest.java create mode 100644 src/main/java/com/Coming/Backend/report/exception/ReportNotFoundException.java diff --git a/src/main/java/com/Coming/Backend/admin/controller/AdminController.java b/src/main/java/com/Coming/Backend/admin/controller/AdminController.java index e44ba0a8..52006209 100644 --- a/src/main/java/com/Coming/Backend/admin/controller/AdminController.java +++ b/src/main/java/com/Coming/Backend/admin/controller/AdminController.java @@ -27,10 +27,15 @@ import com.Coming.Backend.admin.dto.AdminNoticeListItemResponse; import com.Coming.Backend.admin.dto.AdminNoticeUpdateRequest; import com.Coming.Backend.admin.dto.AdminPendingConcertResponse; +import com.Coming.Backend.admin.dto.AdminReportDetailResponse; +import com.Coming.Backend.admin.dto.AdminReportListItemResponse; +import com.Coming.Backend.admin.dto.AdminReportStatusUpdateRequest; import com.Coming.Backend.admin.service.AdminService; import com.Coming.Backend.common.response.PageResponse; import com.Coming.Backend.inquiry.entity.InquiryStatus; import com.Coming.Backend.inquiry.entity.InquiryType; +import com.Coming.Backend.report.entity.ReportStatus; +import com.Coming.Backend.report.entity.ReportTargetType; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; @@ -152,6 +157,32 @@ public ResponseEntity deleteNotice(@PathVariable Long id) { return ResponseEntity.noContent().build(); } + @Operation(summary = "전체 신고 목록 조회") + @GetMapping("/reports") + public ResponseEntity> getReports( + @RequestParam(required = false) ReportTargetType targetType, + @RequestParam(required = false) ReportStatus status, + @PageableDefault(size = 20) Pageable pageable) { + return ResponseEntity.ok(adminService.getReports(targetType, status, pageable)); + } + + @Operation(summary = "신고 상세 조회") + @ApiResponse(responseCode = "404", description = "REPORT_NOT_FOUND") + @GetMapping("/reports/{id}") + public ResponseEntity getReportDetail(@PathVariable Long id) { + return ResponseEntity.ok(adminService.getReportDetail(id)); + } + + @Operation(summary = "신고 처리 상태 변경 (deleteTarget=true 시 대상 게시글·댓글 강제 삭제)") + @ApiResponse(responseCode = "404", description = "REPORT_NOT_FOUND") + @PatchMapping("/reports/{id}/status") + public ResponseEntity updateReportStatus( + @PathVariable Long id, + @RequestBody @Valid AdminReportStatusUpdateRequest request) { + adminService.updateReportStatus(id, request); + return ResponseEntity.ok().build(); + } + @Operation(summary = "EXCLUDED 공연 목록 조회") @GetMapping("/concerts/excluded") public ResponseEntity> getExcludedConcerts( diff --git a/src/main/java/com/Coming/Backend/admin/dto/AdminReportDetailResponse.java b/src/main/java/com/Coming/Backend/admin/dto/AdminReportDetailResponse.java new file mode 100644 index 00000000..b58ca4a4 --- /dev/null +++ b/src/main/java/com/Coming/Backend/admin/dto/AdminReportDetailResponse.java @@ -0,0 +1,34 @@ +package com.Coming.Backend.admin.dto; + +import com.Coming.Backend.report.entity.Report; +import com.Coming.Backend.report.entity.ReportReason; +import com.Coming.Backend.report.entity.ReportStatus; +import com.Coming.Backend.report.entity.ReportTargetType; + +import java.time.LocalDateTime; + +public record AdminReportDetailResponse( + Long id, + ReportTargetType targetType, + Long targetId, + ReportReason reason, + String detail, + ReportStatus status, + String adminNote, + Long reporterId, + LocalDateTime createdAt +) { + public static AdminReportDetailResponse of(Report report) { + return new AdminReportDetailResponse( + report.getId(), + report.getTargetType(), + report.getTargetId(), + report.getReason(), + report.getDetail(), + report.getStatus(), + report.getAdminNote(), + report.getReporterId(), + report.getCreatedAt() + ); + } +} diff --git a/src/main/java/com/Coming/Backend/admin/dto/AdminReportListItemResponse.java b/src/main/java/com/Coming/Backend/admin/dto/AdminReportListItemResponse.java new file mode 100644 index 00000000..15c29235 --- /dev/null +++ b/src/main/java/com/Coming/Backend/admin/dto/AdminReportListItemResponse.java @@ -0,0 +1,30 @@ +package com.Coming.Backend.admin.dto; + +import com.Coming.Backend.report.entity.Report; +import com.Coming.Backend.report.entity.ReportReason; +import com.Coming.Backend.report.entity.ReportStatus; +import com.Coming.Backend.report.entity.ReportTargetType; + +import java.time.LocalDateTime; + +public record AdminReportListItemResponse( + Long id, + ReportTargetType targetType, + Long targetId, + ReportReason reason, + ReportStatus status, + Long reporterId, + LocalDateTime createdAt +) { + public static AdminReportListItemResponse of(Report report) { + return new AdminReportListItemResponse( + report.getId(), + report.getTargetType(), + report.getTargetId(), + report.getReason(), + report.getStatus(), + report.getReporterId(), + report.getCreatedAt() + ); + } +} diff --git a/src/main/java/com/Coming/Backend/admin/dto/AdminReportStatusUpdateRequest.java b/src/main/java/com/Coming/Backend/admin/dto/AdminReportStatusUpdateRequest.java new file mode 100644 index 00000000..7b8d8f02 --- /dev/null +++ b/src/main/java/com/Coming/Backend/admin/dto/AdminReportStatusUpdateRequest.java @@ -0,0 +1,11 @@ +package com.Coming.Backend.admin.dto; + +import com.Coming.Backend.report.entity.ReportStatus; +import jakarta.validation.constraints.NotNull; + +public record AdminReportStatusUpdateRequest( + @NotNull ReportStatus status, + String adminNote, + Boolean deleteTarget +) { +} diff --git a/src/main/java/com/Coming/Backend/admin/service/AdminService.java b/src/main/java/com/Coming/Backend/admin/service/AdminService.java index c87b4139..0c992d38 100644 --- a/src/main/java/com/Coming/Backend/admin/service/AdminService.java +++ b/src/main/java/com/Coming/Backend/admin/service/AdminService.java @@ -30,6 +30,9 @@ import com.Coming.Backend.admin.dto.AdminNoticeListItemResponse; import com.Coming.Backend.admin.dto.AdminNoticeUpdateRequest; import com.Coming.Backend.admin.dto.AdminPendingConcertResponse; +import com.Coming.Backend.admin.dto.AdminReportDetailResponse; +import com.Coming.Backend.admin.dto.AdminReportListItemResponse; +import com.Coming.Backend.admin.dto.AdminReportStatusUpdateRequest; import com.Coming.Backend.admin.exception.PipelineConflictException; import com.Coming.Backend.admin.repository.ArtistCollectLockRepository; import com.Coming.Backend.artist.entity.Artist; @@ -70,6 +73,13 @@ import com.Coming.Backend.notice.entity.Notice; import com.Coming.Backend.notice.exception.NoticeNotFoundException; import com.Coming.Backend.notice.repository.NoticeRepository; +import com.Coming.Backend.post.service.CommentService; +import com.Coming.Backend.post.service.PostService; +import com.Coming.Backend.report.entity.Report; +import com.Coming.Backend.report.entity.ReportStatus; +import com.Coming.Backend.report.entity.ReportTargetType; +import com.Coming.Backend.report.exception.ReportNotFoundException; +import com.Coming.Backend.report.repository.ReportRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; @@ -95,6 +105,9 @@ public class AdminService { private final ArtistUrlRepository artistUrlRepository; private final InquiryRepository inquiryRepository; private final NoticeRepository noticeRepository; + private final ReportRepository reportRepository; + private final PostService postService; + private final CommentService commentService; private final UserRepository userRepository; private final ConcertRepository concertRepository; private final ConcertArtistRepository concertArtistRepository; @@ -300,6 +313,48 @@ public void deleteNotice(Long id) { noticeRepository.delete(notice); } + /** + * 전체 신고 목록을 조회한다. targetType·status 중 null인 항목은 필터 없이 조회한다. + */ + public PageResponse getReports(ReportTargetType targetType, ReportStatus status, Pageable pageable) { + Page page; + if (targetType != null && status != null) { + page = reportRepository.findAllByTargetTypeAndStatus(targetType, status, pageable); + } else if (targetType != null) { + page = reportRepository.findAllByTargetType(targetType, pageable); + } else if (status != null) { + page = reportRepository.findAllByStatus(status, pageable); + } else { + page = reportRepository.findAll(pageable); + } + return PageResponse.from(page.map(AdminReportListItemResponse::of)); + } + + /** + * 신고 상세를 조회한다. 존재하지 않는 ID이면 ReportNotFoundException을 던진다. + */ + public AdminReportDetailResponse getReportDetail(Long id) { + Report report = reportRepository.findById(id).orElseThrow(ReportNotFoundException::new); + return AdminReportDetailResponse.of(report); + } + + /** + * 신고 처리 상태를 변경한다. deleteTarget이 true이면 신고 대상 게시글·댓글을 함께 강제 삭제한다. + * 존재하지 않는 ID이면 ReportNotFoundException을 던진다. + */ + @Transactional + public void updateReportStatus(Long id, AdminReportStatusUpdateRequest request) { + Report report = reportRepository.findById(id).orElseThrow(ReportNotFoundException::new); + report.updateStatus(request.status(), request.adminNote()); + if (Boolean.TRUE.equals(request.deleteTarget())) { + if (report.getTargetType() == ReportTargetType.POST) { + postService.adminDelete(report.getTargetId()); + } else { + commentService.adminDelete(report.getTargetId()); + } + } + } + /** * status 제한 없이 공연 단건을 조회한다. EXCLUDED 포함 모든 상태 조회 가능. * diff --git a/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java b/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java index c833e43a..2b6655b6 100644 --- a/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java +++ b/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java @@ -68,6 +68,7 @@ public enum ErrorCode { REPORT_DETAIL_REQUIRED(HttpStatus.BAD_REQUEST, "기타 사유는 상세 내용을 입력해야 합니다."), REPORT_TARGET_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 신고 대상입니다."), REPORT_ALREADY_EXISTS(HttpStatus.CONFLICT, "이미 신고한 대상입니다."), + REPORT_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 신고입니다."), // Pipeline PIPELINE_NOT_FOUND(HttpStatus.NOT_FOUND, "Data 파이프라인에서 해당 리소스를 찾을 수 없습니다."), diff --git a/src/main/java/com/Coming/Backend/report/exception/ReportNotFoundException.java b/src/main/java/com/Coming/Backend/report/exception/ReportNotFoundException.java new file mode 100644 index 00000000..dd219e20 --- /dev/null +++ b/src/main/java/com/Coming/Backend/report/exception/ReportNotFoundException.java @@ -0,0 +1,11 @@ +package com.Coming.Backend.report.exception; + +import com.Coming.Backend.common.exception.BusinessException; +import com.Coming.Backend.common.exception.ErrorCode; + +public class ReportNotFoundException extends BusinessException { + + public ReportNotFoundException() { + super(ErrorCode.REPORT_NOT_FOUND); + } +} diff --git a/src/main/java/com/Coming/Backend/report/repository/ReportRepository.java b/src/main/java/com/Coming/Backend/report/repository/ReportRepository.java index 41bb99d3..32da572d 100644 --- a/src/main/java/com/Coming/Backend/report/repository/ReportRepository.java +++ b/src/main/java/com/Coming/Backend/report/repository/ReportRepository.java @@ -1,10 +1,19 @@ package com.Coming.Backend.report.repository; import com.Coming.Backend.report.entity.Report; +import com.Coming.Backend.report.entity.ReportStatus; import com.Coming.Backend.report.entity.ReportTargetType; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; public interface ReportRepository extends JpaRepository { boolean existsByReporterIdAndTargetTypeAndTargetId(Long reporterId, ReportTargetType targetType, Long targetId); + + Page findAllByTargetType(ReportTargetType targetType, Pageable pageable); + + Page findAllByStatus(ReportStatus status, Pageable pageable); + + Page findAllByTargetTypeAndStatus(ReportTargetType targetType, ReportStatus status, Pageable pageable); } diff --git a/src/test/java/com/Coming/Backend/admin/controller/AdminControllerTest.java b/src/test/java/com/Coming/Backend/admin/controller/AdminControllerTest.java index e90cae77..4a48017e 100644 --- a/src/test/java/com/Coming/Backend/admin/controller/AdminControllerTest.java +++ b/src/test/java/com/Coming/Backend/admin/controller/AdminControllerTest.java @@ -32,6 +32,9 @@ import com.Coming.Backend.admin.dto.AdminNoticeDetailResponse; import com.Coming.Backend.admin.dto.AdminNoticeListItemResponse; import com.Coming.Backend.admin.dto.AdminNoticeUpdateRequest; +import com.Coming.Backend.admin.dto.AdminReportDetailResponse; +import com.Coming.Backend.admin.dto.AdminReportListItemResponse; +import com.Coming.Backend.admin.dto.AdminReportStatusUpdateRequest; import com.Coming.Backend.admin.dto.DataArtistSearchResult; import com.Coming.Backend.admin.dto.DataConcertSearchResult; import com.Coming.Backend.admin.dto.PipelineArtistCollectResult; @@ -50,6 +53,10 @@ import com.Coming.Backend.inquiry.entity.InquiryType; import com.Coming.Backend.inquiry.exception.InquiryNotFoundException; import com.Coming.Backend.notice.exception.NoticeNotFoundException; +import com.Coming.Backend.report.entity.ReportReason; +import com.Coming.Backend.report.entity.ReportStatus; +import com.Coming.Backend.report.entity.ReportTargetType; +import com.Coming.Backend.report.exception.ReportNotFoundException; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; @@ -93,6 +100,8 @@ class AdminControllerTest { private static final Long CONCERT_ID = 1L; private static final Long NOTICE_ID = 1L; private static final Long ADMIN_ID = 5L; + private static final Long REPORT_ID = 1L; + private static final Long REPORTER_ID = 30L; @BeforeEach void setUp() { @@ -1001,4 +1010,132 @@ void should_return_404_when_notice_not_found_on_delete() throws Exception { .andExpect(jsonPath("$.code").value(ErrorCode.NOTICE_NOT_FOUND.name())) .andExpect(jsonPath("$.message").exists()); } + + // ------------------------------------------------------------------------- + // GET /api/admin/reports + // ------------------------------------------------------------------------- + + @Test + void should_return_200_with_report_list_when_no_filter_given() throws Exception { + // given + AdminReportListItemResponse item = new AdminReportListItemResponse( + REPORT_ID, ReportTargetType.POST, TARGET_ID, ReportReason.SPAM, ReportStatus.PENDING, + REPORTER_ID, LocalDateTime.of(2025, 8, 20, 0, 0) + ); + PageResponse pageResponse = new PageResponse<>(List.of(item), 0, 20, 1, 1); + given(adminService.getReports(isNull(), isNull(), any(Pageable.class))).willReturn(pageResponse); + + // when & then + mockMvc.perform(get("/api/admin/reports").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.content[0].id").value(REPORT_ID)) + .andExpect(jsonPath("$.page").value(0)) + .andExpect(jsonPath("$.size").value(20)) + .andExpect(jsonPath("$.totalElements").isNumber()); + } + + @Test + void should_pass_target_type_and_status_filter_to_service_when_filter_params_given() throws Exception { + // given + AdminReportListItemResponse item = new AdminReportListItemResponse( + REPORT_ID, ReportTargetType.COMMENT, TARGET_ID, ReportReason.ABUSE, ReportStatus.RESOLVED, + REPORTER_ID, LocalDateTime.of(2025, 8, 20, 0, 0) + ); + PageResponse pageResponse = new PageResponse<>(List.of(item), 0, 20, 1, 1); + given(adminService.getReports(eq(ReportTargetType.COMMENT), eq(ReportStatus.RESOLVED), any(Pageable.class))) + .willReturn(pageResponse); + + // when & then + mockMvc.perform(get("/api/admin/reports") + .param("targetType", "COMMENT") + .param("status", "RESOLVED") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content[0].targetType").value("COMMENT")) + .andExpect(jsonPath("$.content[0].status").value("RESOLVED")); + } + + // ------------------------------------------------------------------------- + // GET /api/admin/reports/{id} + // ------------------------------------------------------------------------- + + @Test + void should_return_200_with_report_detail_when_valid_id_given() throws Exception { + // given + AdminReportDetailResponse detail = new AdminReportDetailResponse( + REPORT_ID, ReportTargetType.POST, TARGET_ID, ReportReason.SPAM, "광고성 게시글입니다.", + ReportStatus.PENDING, null, REPORTER_ID, LocalDateTime.of(2025, 8, 20, 0, 0) + ); + given(adminService.getReportDetail(REPORT_ID)).willReturn(detail); + + // when & then + mockMvc.perform(get("/api/admin/reports/{id}", REPORT_ID).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(REPORT_ID)) + .andExpect(jsonPath("$.targetId").value(TARGET_ID)) + .andExpect(jsonPath("$.detail").value("광고성 게시글입니다.")) + .andExpect(jsonPath("$.reporterId").value(REPORTER_ID)); + } + + @Test + void should_return_404_when_report_not_found_on_detail() throws Exception { + // given + given(adminService.getReportDetail(999L)).willThrow(new ReportNotFoundException()); + + // when & then + mockMvc.perform(get("/api/admin/reports/{id}", 999L).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ErrorCode.REPORT_NOT_FOUND.name())) + .andExpect(jsonPath("$.message").exists()); + } + + // ------------------------------------------------------------------------- + // PATCH /api/admin/reports/{id}/status + // ------------------------------------------------------------------------- + + @Test + void should_return_200_when_report_status_updated_successfully() throws Exception { + // given + AdminReportStatusUpdateRequest request = new AdminReportStatusUpdateRequest(ReportStatus.RESOLVED, "삭제 처리", true); + willDoNothing().given(adminService).updateReportStatus(eq(REPORT_ID), any(AdminReportStatusUpdateRequest.class)); + + // when & then + mockMvc.perform(patch("/api/admin/reports/{id}/status", REPORT_ID) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + } + + @Test + void should_return_400_when_status_field_is_null_on_report_status_update() throws Exception { + // given — status 필드 누락 + String requestBody = """ + { + "adminNote": "삭제 처리" + } + """; + + // when & then + mockMvc.perform(patch("/api/admin/reports/{id}/status", REPORT_ID) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isBadRequest()); + } + + @Test + void should_return_404_when_report_not_found_on_status_update() throws Exception { + // given + AdminReportStatusUpdateRequest request = new AdminReportStatusUpdateRequest(ReportStatus.RESOLVED, null, null); + willThrow(new ReportNotFoundException()) + .given(adminService).updateReportStatus(eq(999L), any(AdminReportStatusUpdateRequest.class)); + + // when & then + mockMvc.perform(patch("/api/admin/reports/{id}/status", 999L) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ErrorCode.REPORT_NOT_FOUND.name())) + .andExpect(jsonPath("$.message").exists()); + } } diff --git a/src/test/java/com/Coming/Backend/admin/service/AdminServiceTest.java b/src/test/java/com/Coming/Backend/admin/service/AdminServiceTest.java index 706f4b72..820d0108 100644 --- a/src/test/java/com/Coming/Backend/admin/service/AdminServiceTest.java +++ b/src/test/java/com/Coming/Backend/admin/service/AdminServiceTest.java @@ -21,6 +21,9 @@ import com.Coming.Backend.admin.dto.AdminNoticeListItemResponse; import com.Coming.Backend.admin.dto.AdminNoticeUpdateRequest; import com.Coming.Backend.admin.dto.AdminPendingConcertResponse; +import com.Coming.Backend.admin.dto.AdminReportDetailResponse; +import com.Coming.Backend.admin.dto.AdminReportListItemResponse; +import com.Coming.Backend.admin.dto.AdminReportStatusUpdateRequest; import com.Coming.Backend.admin.dto.BookingLinkRequest; import com.Coming.Backend.admin.dto.DataArtistSearchResult; import com.Coming.Backend.admin.dto.DataConcertSearchResult; @@ -68,6 +71,14 @@ import com.Coming.Backend.notice.entity.Notice; import com.Coming.Backend.notice.exception.NoticeNotFoundException; import com.Coming.Backend.notice.repository.NoticeRepository; +import com.Coming.Backend.post.service.CommentService; +import com.Coming.Backend.post.service.PostService; +import com.Coming.Backend.report.entity.Report; +import com.Coming.Backend.report.entity.ReportReason; +import com.Coming.Backend.report.entity.ReportStatus; +import com.Coming.Backend.report.entity.ReportTargetType; +import com.Coming.Backend.report.exception.ReportNotFoundException; +import com.Coming.Backend.report.repository.ReportRepository; import com.Coming.Backend.common.exception.InvalidInputException; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -116,6 +127,15 @@ class AdminServiceTest { @Mock private NoticeRepository noticeRepository; + @Mock + private ReportRepository reportRepository; + + @Mock + private PostService postService; + + @Mock + private CommentService commentService; + @Mock private UserRepository userRepository; @@ -146,6 +166,8 @@ class AdminServiceTest { private static final Long ARTIST_ID = 20L; private static final Long NOTICE_ID = 1L; private static final Long ADMIN_ID = 5L; + private static final Long REPORT_ID = 1L; + private static final Long REPORTER_ID = 30L; private static final Pageable PAGEABLE = PageRequest.of(0, 20); private static final LocalDateTime CREATED_AT = LocalDateTime.of(2025, 8, 20, 0, 0); @@ -187,6 +209,20 @@ private Notice buildNotice(Long id, String title, String content, boolean active return notice; } + private Report buildReport(ReportTargetType targetType, Long targetId, ReportStatus status) { + Report report = Report.builder() + .id(REPORT_ID) + .reporterId(REPORTER_ID) + .targetType(targetType) + .targetId(targetId) + .reason(ReportReason.SPAM) + .detail("광고성 게시글입니다.") + .status(status) + .build(); + ReflectionTestUtils.setField(report, "createdAt", CREATED_AT); + return report; + } + @Test void should_update_all_fields_when_full_update_request_given() { // given @@ -2142,4 +2178,181 @@ void should_throw_notice_not_found_when_delete_target_does_not_exist() { .isInstanceOf(NoticeNotFoundException.class); verify(noticeRepository, never()).delete(any()); } + + // ------------------------------------------------------------------------- + // getReports + // ------------------------------------------------------------------------- + + @Test + void should_call_find_all_when_target_type_and_status_are_null() { + // given + Report report = buildReport(ReportTargetType.POST, TARGET_ID, ReportStatus.PENDING); + Page page = new PageImpl<>(List.of(report), PAGEABLE, 1); + given(reportRepository.findAll(PAGEABLE)).willReturn(page); + + // when + PageResponse response = adminService.getReports(null, null, PAGEABLE); + + // then + verify(reportRepository).findAll(PAGEABLE); + assertThat(response.content()).hasSize(1); + } + + @Test + void should_call_find_all_by_target_type_when_only_target_type_given() { + // given + Report report = buildReport(ReportTargetType.POST, TARGET_ID, ReportStatus.PENDING); + Page page = new PageImpl<>(List.of(report), PAGEABLE, 1); + given(reportRepository.findAllByTargetType(ReportTargetType.POST, PAGEABLE)).willReturn(page); + + // when + PageResponse response = adminService.getReports(ReportTargetType.POST, null, PAGEABLE); + + // then + verify(reportRepository).findAllByTargetType(ReportTargetType.POST, PAGEABLE); + assertThat(response.content()).hasSize(1); + } + + @Test + void should_call_find_all_by_status_when_only_status_given_for_reports() { + // given + Report report = buildReport(ReportTargetType.POST, TARGET_ID, ReportStatus.PENDING); + Page page = new PageImpl<>(List.of(report), PAGEABLE, 1); + given(reportRepository.findAllByStatus(ReportStatus.PENDING, PAGEABLE)).willReturn(page); + + // when + PageResponse response = adminService.getReports(null, ReportStatus.PENDING, PAGEABLE); + + // then + verify(reportRepository).findAllByStatus(ReportStatus.PENDING, PAGEABLE); + assertThat(response.content()).hasSize(1); + } + + @Test + void should_call_find_all_by_target_type_and_status_when_both_given() { + // given + Report report = buildReport(ReportTargetType.POST, TARGET_ID, ReportStatus.PENDING); + Page page = new PageImpl<>(List.of(report), PAGEABLE, 1); + given(reportRepository.findAllByTargetTypeAndStatus(ReportTargetType.POST, ReportStatus.PENDING, PAGEABLE)) + .willReturn(page); + + // when + PageResponse response = + adminService.getReports(ReportTargetType.POST, ReportStatus.PENDING, PAGEABLE); + + // then + verify(reportRepository).findAllByTargetTypeAndStatus(ReportTargetType.POST, ReportStatus.PENDING, PAGEABLE); + assertThat(response.content()).hasSize(1); + } + + // ------------------------------------------------------------------------- + // getReportDetail + // ------------------------------------------------------------------------- + + @Test + void should_return_report_detail_when_valid_id_given() { + // given + Report report = buildReport(ReportTargetType.POST, TARGET_ID, ReportStatus.PENDING); + given(reportRepository.findById(REPORT_ID)).willReturn(Optional.of(report)); + + // when + AdminReportDetailResponse response = adminService.getReportDetail(REPORT_ID); + + // then + assertThat(response.id()).isEqualTo(REPORT_ID); + assertThat(response.targetType()).isEqualTo(ReportTargetType.POST); + assertThat(response.targetId()).isEqualTo(TARGET_ID); + assertThat(response.reason()).isEqualTo(ReportReason.SPAM); + assertThat(response.detail()).isEqualTo("광고성 게시글입니다."); + assertThat(response.status()).isEqualTo(ReportStatus.PENDING); + assertThat(response.reporterId()).isEqualTo(REPORTER_ID); + } + + @Test + void should_throw_report_not_found_when_report_id_does_not_exist() { + // given + given(reportRepository.findById(999L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> adminService.getReportDetail(999L)) + .isInstanceOf(ReportNotFoundException.class); + } + + // ------------------------------------------------------------------------- + // updateReportStatus + // ------------------------------------------------------------------------- + + @Test + void should_update_status_without_deleting_target_when_delete_target_is_null() { + // given + Report report = buildReport(ReportTargetType.POST, TARGET_ID, ReportStatus.PENDING); + AdminReportStatusUpdateRequest request = new AdminReportStatusUpdateRequest(ReportStatus.RESOLVED, "처리 완료", null); + given(reportRepository.findById(REPORT_ID)).willReturn(Optional.of(report)); + + // when + adminService.updateReportStatus(REPORT_ID, request); + + // then + assertThat(report.getStatus()).isEqualTo(ReportStatus.RESOLVED); + assertThat(report.getAdminNote()).isEqualTo("처리 완료"); + verify(postService, never()).adminDelete(any()); + verify(commentService, never()).adminDelete(any()); + } + + @Test + void should_not_delete_target_when_delete_target_is_false() { + // given + Report report = buildReport(ReportTargetType.POST, TARGET_ID, ReportStatus.PENDING); + AdminReportStatusUpdateRequest request = new AdminReportStatusUpdateRequest(ReportStatus.REJECTED, "정상 게시글입니다.", false); + given(reportRepository.findById(REPORT_ID)).willReturn(Optional.of(report)); + + // when + adminService.updateReportStatus(REPORT_ID, request); + + // then + assertThat(report.getStatus()).isEqualTo(ReportStatus.REJECTED); + verify(postService, never()).adminDelete(any()); + verify(commentService, never()).adminDelete(any()); + } + + @Test + void should_delete_post_when_delete_target_true_and_target_type_post() { + // given + Report report = buildReport(ReportTargetType.POST, TARGET_ID, ReportStatus.PENDING); + AdminReportStatusUpdateRequest request = new AdminReportStatusUpdateRequest(ReportStatus.RESOLVED, "삭제 처리", true); + given(reportRepository.findById(REPORT_ID)).willReturn(Optional.of(report)); + + // when + adminService.updateReportStatus(REPORT_ID, request); + + // then + verify(postService).adminDelete(TARGET_ID); + verify(commentService, never()).adminDelete(any()); + } + + @Test + void should_delete_comment_when_delete_target_true_and_target_type_comment() { + // given + Report report = buildReport(ReportTargetType.COMMENT, TARGET_ID, ReportStatus.PENDING); + AdminReportStatusUpdateRequest request = new AdminReportStatusUpdateRequest(ReportStatus.RESOLVED, "삭제 처리", true); + given(reportRepository.findById(REPORT_ID)).willReturn(Optional.of(report)); + + // when + adminService.updateReportStatus(REPORT_ID, request); + + // then + verify(commentService).adminDelete(TARGET_ID); + verify(postService, never()).adminDelete(any()); + } + + @Test + void should_throw_report_not_found_when_update_target_does_not_exist() { + // given + AdminReportStatusUpdateRequest request = new AdminReportStatusUpdateRequest(ReportStatus.RESOLVED, null, null); + given(reportRepository.findById(999L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> adminService.updateReportStatus(999L, request)) + .isInstanceOf(ReportNotFoundException.class); + } } From 9fc2e4db9519e618e0b21e5a0049e25e9f079865 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Sat, 19 Sep 2026 23:51:55 +0900 Subject: [PATCH 10/12] =?UTF-8?q?[feat]=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EC=8B=A0=EA=B3=A0=20=EC=A1=B0=ED=9A=8C=20=EC=9D=91=EB=8B=B5?= =?UTF-8?q?=EC=97=90=20=EC=8B=A0=EA=B3=A0=EC=9E=90=20=EB=8B=89=EB=84=A4?= =?UTF-8?q?=EC=9E=84=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 숫자 ID만으로는 관리자가 신고자를 식별하기 어려워 FE에서 표시용 필드 요청. 기존 문의(Inquiry) 관리자 응답의 userNickname 조회 패턴을 그대로 재사용. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KEuNZdecA5CU738DK5vZW7 --- .../admin/dto/AdminReportDetailResponse.java | 4 +++- .../admin/dto/AdminReportListItemResponse.java | 4 +++- .../Backend/admin/service/AdminService.java | 16 ++++++++++++++-- .../admin/controller/AdminControllerTest.java | 9 +++++---- .../Backend/admin/service/AdminServiceTest.java | 7 +++++++ 5 files changed, 32 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/Coming/Backend/admin/dto/AdminReportDetailResponse.java b/src/main/java/com/Coming/Backend/admin/dto/AdminReportDetailResponse.java index b58ca4a4..df1bb8d3 100644 --- a/src/main/java/com/Coming/Backend/admin/dto/AdminReportDetailResponse.java +++ b/src/main/java/com/Coming/Backend/admin/dto/AdminReportDetailResponse.java @@ -16,9 +16,10 @@ public record AdminReportDetailResponse( ReportStatus status, String adminNote, Long reporterId, + String reporterNickname, LocalDateTime createdAt ) { - public static AdminReportDetailResponse of(Report report) { + public static AdminReportDetailResponse of(Report report, String reporterNickname) { return new AdminReportDetailResponse( report.getId(), report.getTargetType(), @@ -28,6 +29,7 @@ public static AdminReportDetailResponse of(Report report) { report.getStatus(), report.getAdminNote(), report.getReporterId(), + reporterNickname, report.getCreatedAt() ); } diff --git a/src/main/java/com/Coming/Backend/admin/dto/AdminReportListItemResponse.java b/src/main/java/com/Coming/Backend/admin/dto/AdminReportListItemResponse.java index 15c29235..5a4a0e6d 100644 --- a/src/main/java/com/Coming/Backend/admin/dto/AdminReportListItemResponse.java +++ b/src/main/java/com/Coming/Backend/admin/dto/AdminReportListItemResponse.java @@ -14,9 +14,10 @@ public record AdminReportListItemResponse( ReportReason reason, ReportStatus status, Long reporterId, + String reporterNickname, LocalDateTime createdAt ) { - public static AdminReportListItemResponse of(Report report) { + public static AdminReportListItemResponse of(Report report, String reporterNickname) { return new AdminReportListItemResponse( report.getId(), report.getTargetType(), @@ -24,6 +25,7 @@ public static AdminReportListItemResponse of(Report report) { report.getReason(), report.getStatus(), report.getReporterId(), + reporterNickname, report.getCreatedAt() ); } diff --git a/src/main/java/com/Coming/Backend/admin/service/AdminService.java b/src/main/java/com/Coming/Backend/admin/service/AdminService.java index 0c992d38..76e7af49 100644 --- a/src/main/java/com/Coming/Backend/admin/service/AdminService.java +++ b/src/main/java/com/Coming/Backend/admin/service/AdminService.java @@ -327,7 +327,16 @@ public PageResponse getReports(ReportTargetType tar } else { page = reportRepository.findAll(pageable); } - return PageResponse.from(page.map(AdminReportListItemResponse::of)); + + List reporterIds = page.getContent().stream() + .map(Report::getReporterId) + .distinct() + .toList(); + Map nicknameByUserId = userRepository.findAllByIdIn(reporterIds).stream() + .collect(Collectors.toMap(User::getId, User::getNickname)); + + return PageResponse.from(page.map(report -> + AdminReportListItemResponse.of(report, nicknameByUserId.getOrDefault(report.getReporterId(), "")))); } /** @@ -335,7 +344,10 @@ public PageResponse getReports(ReportTargetType tar */ public AdminReportDetailResponse getReportDetail(Long id) { Report report = reportRepository.findById(id).orElseThrow(ReportNotFoundException::new); - return AdminReportDetailResponse.of(report); + String nickname = userRepository.findById(report.getReporterId()) + .map(User::getNickname) + .orElse(""); + return AdminReportDetailResponse.of(report, nickname); } /** diff --git a/src/test/java/com/Coming/Backend/admin/controller/AdminControllerTest.java b/src/test/java/com/Coming/Backend/admin/controller/AdminControllerTest.java index 4a48017e..01730c77 100644 --- a/src/test/java/com/Coming/Backend/admin/controller/AdminControllerTest.java +++ b/src/test/java/com/Coming/Backend/admin/controller/AdminControllerTest.java @@ -1020,7 +1020,7 @@ void should_return_200_with_report_list_when_no_filter_given() throws Exception // given AdminReportListItemResponse item = new AdminReportListItemResponse( REPORT_ID, ReportTargetType.POST, TARGET_ID, ReportReason.SPAM, ReportStatus.PENDING, - REPORTER_ID, LocalDateTime.of(2025, 8, 20, 0, 0) + REPORTER_ID, "신고자닉네임", LocalDateTime.of(2025, 8, 20, 0, 0) ); PageResponse pageResponse = new PageResponse<>(List.of(item), 0, 20, 1, 1); given(adminService.getReports(isNull(), isNull(), any(Pageable.class))).willReturn(pageResponse); @@ -1040,7 +1040,7 @@ void should_pass_target_type_and_status_filter_to_service_when_filter_params_giv // given AdminReportListItemResponse item = new AdminReportListItemResponse( REPORT_ID, ReportTargetType.COMMENT, TARGET_ID, ReportReason.ABUSE, ReportStatus.RESOLVED, - REPORTER_ID, LocalDateTime.of(2025, 8, 20, 0, 0) + REPORTER_ID, "신고자닉네임", LocalDateTime.of(2025, 8, 20, 0, 0) ); PageResponse pageResponse = new PageResponse<>(List.of(item), 0, 20, 1, 1); given(adminService.getReports(eq(ReportTargetType.COMMENT), eq(ReportStatus.RESOLVED), any(Pageable.class))) @@ -1065,7 +1065,7 @@ void should_return_200_with_report_detail_when_valid_id_given() throws Exception // given AdminReportDetailResponse detail = new AdminReportDetailResponse( REPORT_ID, ReportTargetType.POST, TARGET_ID, ReportReason.SPAM, "광고성 게시글입니다.", - ReportStatus.PENDING, null, REPORTER_ID, LocalDateTime.of(2025, 8, 20, 0, 0) + ReportStatus.PENDING, null, REPORTER_ID, "신고자닉네임", LocalDateTime.of(2025, 8, 20, 0, 0) ); given(adminService.getReportDetail(REPORT_ID)).willReturn(detail); @@ -1075,7 +1075,8 @@ void should_return_200_with_report_detail_when_valid_id_given() throws Exception .andExpect(jsonPath("$.id").value(REPORT_ID)) .andExpect(jsonPath("$.targetId").value(TARGET_ID)) .andExpect(jsonPath("$.detail").value("광고성 게시글입니다.")) - .andExpect(jsonPath("$.reporterId").value(REPORTER_ID)); + .andExpect(jsonPath("$.reporterId").value(REPORTER_ID)) + .andExpect(jsonPath("$.reporterNickname").value("신고자닉네임")); } @Test diff --git a/src/test/java/com/Coming/Backend/admin/service/AdminServiceTest.java b/src/test/java/com/Coming/Backend/admin/service/AdminServiceTest.java index 820d0108..cd5259d8 100644 --- a/src/test/java/com/Coming/Backend/admin/service/AdminServiceTest.java +++ b/src/test/java/com/Coming/Backend/admin/service/AdminServiceTest.java @@ -2189,6 +2189,7 @@ void should_call_find_all_when_target_type_and_status_are_null() { Report report = buildReport(ReportTargetType.POST, TARGET_ID, ReportStatus.PENDING); Page page = new PageImpl<>(List.of(report), PAGEABLE, 1); given(reportRepository.findAll(PAGEABLE)).willReturn(page); + given(userRepository.findAllByIdIn(List.of(REPORTER_ID))).willReturn(List.of(buildUser(REPORTER_ID, "신고자닉네임"))); // when PageResponse response = adminService.getReports(null, null, PAGEABLE); @@ -2196,6 +2197,7 @@ void should_call_find_all_when_target_type_and_status_are_null() { // then verify(reportRepository).findAll(PAGEABLE); assertThat(response.content()).hasSize(1); + assertThat(response.content().get(0).reporterNickname()).isEqualTo("신고자닉네임"); } @Test @@ -2204,6 +2206,7 @@ void should_call_find_all_by_target_type_when_only_target_type_given() { Report report = buildReport(ReportTargetType.POST, TARGET_ID, ReportStatus.PENDING); Page page = new PageImpl<>(List.of(report), PAGEABLE, 1); given(reportRepository.findAllByTargetType(ReportTargetType.POST, PAGEABLE)).willReturn(page); + given(userRepository.findAllByIdIn(List.of(REPORTER_ID))).willReturn(List.of(buildUser(REPORTER_ID, "신고자닉네임"))); // when PageResponse response = adminService.getReports(ReportTargetType.POST, null, PAGEABLE); @@ -2219,6 +2222,7 @@ void should_call_find_all_by_status_when_only_status_given_for_reports() { Report report = buildReport(ReportTargetType.POST, TARGET_ID, ReportStatus.PENDING); Page page = new PageImpl<>(List.of(report), PAGEABLE, 1); given(reportRepository.findAllByStatus(ReportStatus.PENDING, PAGEABLE)).willReturn(page); + given(userRepository.findAllByIdIn(List.of(REPORTER_ID))).willReturn(List.of(buildUser(REPORTER_ID, "신고자닉네임"))); // when PageResponse response = adminService.getReports(null, ReportStatus.PENDING, PAGEABLE); @@ -2235,6 +2239,7 @@ void should_call_find_all_by_target_type_and_status_when_both_given() { Page page = new PageImpl<>(List.of(report), PAGEABLE, 1); given(reportRepository.findAllByTargetTypeAndStatus(ReportTargetType.POST, ReportStatus.PENDING, PAGEABLE)) .willReturn(page); + given(userRepository.findAllByIdIn(List.of(REPORTER_ID))).willReturn(List.of(buildUser(REPORTER_ID, "신고자닉네임"))); // when PageResponse response = @@ -2254,6 +2259,7 @@ void should_return_report_detail_when_valid_id_given() { // given Report report = buildReport(ReportTargetType.POST, TARGET_ID, ReportStatus.PENDING); given(reportRepository.findById(REPORT_ID)).willReturn(Optional.of(report)); + given(userRepository.findById(REPORTER_ID)).willReturn(Optional.of(buildUser(REPORTER_ID, "신고자닉네임"))); // when AdminReportDetailResponse response = adminService.getReportDetail(REPORT_ID); @@ -2266,6 +2272,7 @@ void should_return_report_detail_when_valid_id_given() { assertThat(response.detail()).isEqualTo("광고성 게시글입니다."); assertThat(response.status()).isEqualTo(ReportStatus.PENDING); assertThat(response.reporterId()).isEqualTo(REPORTER_ID); + assertThat(response.reporterNickname()).isEqualTo("신고자닉네임"); } @Test From 694453fdb6a8257755539a67446688027b604091 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Sun, 20 Sep 2026 00:16:05 +0900 Subject: [PATCH 11/12] =?UTF-8?q?[fix]=20PR=20#124=20CodeRabbit=20?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81=20(=EA=B3=B5=EC=A7=80?= =?UTF-8?q?=20=EA=B2=80=EC=A6=9D,=20=EC=A0=95=EB=A0=AC=20=EC=95=88?= =?UTF-8?q?=EC=A0=95=EC=84=B1,=20CD=20=ED=99=98=EA=B2=BD=EB=B3=80=EC=88=98?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 공지 생성/수정 DTO에 title 255자 제한 및 공백 거부 검증 추가 - 공지 목록 조회 정렬에 id 타이브레이커 추가로 동일 시각 데이터 순서 불안정성 제거 - 인기글(popular-board) API 설명을 실제 동작(이상)에 맞게 수정 - CD 워크플로우에 누락된 DISCORD_WEBHOOK_REPORT_URL 환경변수 전달 추가 Co-Authored-By: Claude Sonnet 5 --- .github/workflows/cd.yml | 4 +++- .../Backend/admin/dto/AdminNoticeCreateRequest.java | 3 ++- .../Backend/admin/dto/AdminNoticeUpdateRequest.java | 8 ++++++++ .../Backend/notice/repository/NoticeRepository.java | 2 +- .../Coming/Backend/post/controller/PostController.java | 2 +- 5 files changed, 15 insertions(+), 4 deletions(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 0c44771a..0a068759 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -67,6 +67,7 @@ jobs: V_DISCORD_WEBHOOK_5XX_URL: ${{ secrets.DISCORD_WEBHOOK_5XX_URL }} V_DISCORD_WEBHOOK_4XX_URL: ${{ secrets.DISCORD_WEBHOOK_4XX_URL }} V_DISCORD_WEBHOOK_INQUIRY_URL: ${{ secrets.DISCORD_WEBHOOK_INQUIRY_URL }} + V_DISCORD_WEBHOOK_REPORT_URL: ${{ secrets.DISCORD_WEBHOOK_REPORT_URL }} V_MAIL_USERNAME: ${{ secrets.MAIL_USERNAME }} V_MAIL_PASSWORD: ${{ secrets.MAIL_PASSWORD }} with: @@ -74,7 +75,7 @@ jobs: username: ${{ secrets.SERVER_USER }} key: ${{ secrets.SSH_PRIVATE_KEY }} port: 2222 - envs: V_GHCR_TOKEN,V_GHCR_USER,V_DB_URL,V_DB_USERNAME,V_DB_PASSWORD,V_JWT_SECRET,V_GOOGLE_CLIENT_ID,V_GOOGLE_CLIENT_SECRET,V_KAKAO_CLIENT_ID,V_KAKAO_CLIENT_SECRET,V_CORS_ALLOWED_ORIGINS,V_OAUTH2_REDIRECT_BASE_URI,V_GOOGLE_REDIRECT_URI,V_KAKAO_REDIRECT_URI,V_DATA_PIPELINE_SECRET,V_DATA_PIPELINE_BASE_URL,V_REDIS_PASSWORD,V_DISCORD_WEBHOOK_5XX_URL,V_DISCORD_WEBHOOK_4XX_URL,V_DISCORD_WEBHOOK_INQUIRY_URL,V_MAIL_USERNAME,V_MAIL_PASSWORD + envs: V_GHCR_TOKEN,V_GHCR_USER,V_DB_URL,V_DB_USERNAME,V_DB_PASSWORD,V_JWT_SECRET,V_GOOGLE_CLIENT_ID,V_GOOGLE_CLIENT_SECRET,V_KAKAO_CLIENT_ID,V_KAKAO_CLIENT_SECRET,V_CORS_ALLOWED_ORIGINS,V_OAUTH2_REDIRECT_BASE_URI,V_GOOGLE_REDIRECT_URI,V_KAKAO_REDIRECT_URI,V_DATA_PIPELINE_SECRET,V_DATA_PIPELINE_BASE_URL,V_REDIS_PASSWORD,V_DISCORD_WEBHOOK_5XX_URL,V_DISCORD_WEBHOOK_4XX_URL,V_DISCORD_WEBHOOK_INQUIRY_URL,V_DISCORD_WEBHOOK_REPORT_URL,V_MAIL_USERNAME,V_MAIL_PASSWORD script: | echo "$V_GHCR_TOKEN" | docker login ghcr.io -u "$V_GHCR_USER" --password-stdin @@ -100,6 +101,7 @@ jobs: printf 'DISCORD_WEBHOOK_5XX_URL=%s\n' "$V_DISCORD_WEBHOOK_5XX_URL" >> ~/compose/be.env printf 'DISCORD_WEBHOOK_4XX_URL=%s\n' "$V_DISCORD_WEBHOOK_4XX_URL" >> ~/compose/be.env printf 'DISCORD_WEBHOOK_INQUIRY_URL=%s\n' "$V_DISCORD_WEBHOOK_INQUIRY_URL" >> ~/compose/be.env + printf 'DISCORD_WEBHOOK_REPORT_URL=%s\n' "$V_DISCORD_WEBHOOK_REPORT_URL" >> ~/compose/be.env printf 'MAIL_USERNAME=%s\n' "$V_MAIL_USERNAME" >> ~/compose/be.env printf 'MAIL_PASSWORD=%s\n' "$V_MAIL_PASSWORD" >> ~/compose/be.env diff --git a/src/main/java/com/Coming/Backend/admin/dto/AdminNoticeCreateRequest.java b/src/main/java/com/Coming/Backend/admin/dto/AdminNoticeCreateRequest.java index f6d50e87..cc3a02d6 100644 --- a/src/main/java/com/Coming/Backend/admin/dto/AdminNoticeCreateRequest.java +++ b/src/main/java/com/Coming/Backend/admin/dto/AdminNoticeCreateRequest.java @@ -1,9 +1,10 @@ package com.Coming.Backend.admin.dto; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; public record AdminNoticeCreateRequest( - @NotBlank String title, + @NotBlank @Size(max = 255) String title, @NotBlank String content, Boolean active ) { diff --git a/src/main/java/com/Coming/Backend/admin/dto/AdminNoticeUpdateRequest.java b/src/main/java/com/Coming/Backend/admin/dto/AdminNoticeUpdateRequest.java index 011d0d9a..7b6c2fef 100644 --- a/src/main/java/com/Coming/Backend/admin/dto/AdminNoticeUpdateRequest.java +++ b/src/main/java/com/Coming/Backend/admin/dto/AdminNoticeUpdateRequest.java @@ -1,8 +1,16 @@ package com.Coming.Backend.admin.dto; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + public record AdminNoticeUpdateRequest( + @Pattern(regexp = "(?s).*\\S.*", message = "제목은 공백일 수 없습니다") + @Size(max = 255) String title, + + @Pattern(regexp = "(?s).*\\S.*", message = "내용은 공백일 수 없습니다") String content, + Boolean active ) { } diff --git a/src/main/java/com/Coming/Backend/notice/repository/NoticeRepository.java b/src/main/java/com/Coming/Backend/notice/repository/NoticeRepository.java index 0afccd5b..d2e6426b 100644 --- a/src/main/java/com/Coming/Backend/notice/repository/NoticeRepository.java +++ b/src/main/java/com/Coming/Backend/notice/repository/NoticeRepository.java @@ -9,6 +9,6 @@ public interface NoticeRepository extends JpaRepository { - @Query("SELECT n FROM Notice n WHERE n.active = true ORDER BY n.createdAt DESC") + @Query("SELECT n FROM Notice n WHERE n.active = true ORDER BY n.createdAt DESC, n.id DESC") List findActiveNotices(Pageable pageable); } diff --git a/src/main/java/com/Coming/Backend/post/controller/PostController.java b/src/main/java/com/Coming/Backend/post/controller/PostController.java index 1718ea1b..7ebe1a59 100644 --- a/src/main/java/com/Coming/Backend/post/controller/PostController.java +++ b/src/main/java/com/Coming/Backend/post/controller/PostController.java @@ -76,7 +76,7 @@ public ResponseEntity> getPopular( return ResponseEntity.ok(postService.getPopular(days, limit)); } - @Operation(summary = "인기글(추천 임계치 초과) 목록 조회") + @Operation(summary = "인기글(추천 임계치 이상) 목록 조회") @GetMapping("/popular-board") public ResponseEntity> getPopularBoard( @RequestParam(defaultValue = "0") @Min(0) int page, From 09201cdd5729ca8a1d88147724865fa2cc067706 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Sun, 20 Sep 2026 00:19:28 +0900 Subject: [PATCH 12/12] =?UTF-8?q?[style]=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20import=20=EC=88=9C=EC=84=9C=EB=A5=BC=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8=20=EC=BB=A8=EB=B2=A4?= =?UTF-8?q?=EC=85=98=EC=97=90=20=EB=A7=9E=EA=B2=8C=20=EC=A0=95=EB=A0=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit static import를 마지막 그룹으로 이동. admin/CLAUDE.md의 "static import는 마지막 그룹" 컨벤션에 맞춤 (PR #124 CodeRabbit nitpick 반영). Co-Authored-By: Claude Sonnet 5 --- .../report/controller/ReportControllerTest.java | 14 +++++++------- .../Backend/report/service/ReportServiceTest.java | 14 +++++++------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/test/java/com/Coming/Backend/report/controller/ReportControllerTest.java b/src/test/java/com/Coming/Backend/report/controller/ReportControllerTest.java index 70dc4afe..494852be 100644 --- a/src/test/java/com/Coming/Backend/report/controller/ReportControllerTest.java +++ b/src/test/java/com/Coming/Backend/report/controller/ReportControllerTest.java @@ -1,12 +1,5 @@ package com.Coming.Backend.report.controller; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.BDDMockito.given; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - import com.Coming.Backend.common.discord.NoOpDiscordNotifier; import com.Coming.Backend.common.exception.ErrorCode; import com.Coming.Backend.common.exception.GlobalExceptionHandler; @@ -37,6 +30,13 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + @ExtendWith(MockitoExtension.class) class ReportControllerTest { diff --git a/src/test/java/com/Coming/Backend/report/service/ReportServiceTest.java b/src/test/java/com/Coming/Backend/report/service/ReportServiceTest.java index 805e4ab0..31080225 100644 --- a/src/test/java/com/Coming/Backend/report/service/ReportServiceTest.java +++ b/src/test/java/com/Coming/Backend/report/service/ReportServiceTest.java @@ -1,12 +1,5 @@ package com.Coming.Backend.report.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.Mockito.never; -import static org.mockito.Mockito.verify; - import com.Coming.Backend.common.exception.ErrorCode; import com.Coming.Backend.post.repository.CommentRepository; import com.Coming.Backend.post.repository.PostRepository; @@ -29,6 +22,13 @@ import org.springframework.dao.DataIntegrityViolationException; import org.springframework.test.util.ReflectionTestUtils; +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.Mockito.never; +import static org.mockito.Mockito.verify; + @ExtendWith(MockitoExtension.class) class ReportServiceTest {