From 9db70dce2c3b232f1d889aa39fd383cfcf72354c Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Sat, 19 Sep 2026 22:54:46 +0900 Subject: [PATCH 01/19] =?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 782fb39..1718ea1 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 2a6b258..0cc0c18 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 4b7076e..cc0a660 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 b4078e3..f74eccd 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 615eecd..bddb331 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 26ec90d..978307c 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 18cc419..96d0513 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/19] =?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 0000000..d453304 --- /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 0000000..b1a96e3 --- /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 0000000..387242a --- /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/19] =?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 8e27d57..dbc2173 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 29abd30..3191372 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 0000000..b393bdb --- /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 0000000..699e816 --- /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 0000000..e09b66d --- /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 0000000..b6b397c --- /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 b1a96e3..0afccd5 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 0000000..87464ba --- /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 0000000..455cc8d --- /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 0000000..d46b74b --- /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 0000000..b837ad9 --- /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/19] =?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 218b54c..e44ba0a 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 0000000..f6d50e8 --- /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 0000000..e84c9e5 --- /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 0000000..c6b3992 --- /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 0000000..5200f77 --- /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 0000000..011d0d9 --- /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 00b5fcf..c87b413 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 7d02964..e90cae7 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 d318cec..706f4b7 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/19] =?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 0000000..5a91668 --- /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 0000000..54b57f9 --- /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 0000000..a45bd06 --- /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 0000000..e0984b1 --- /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 0000000..787ed01 --- /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 0000000..8e673bb --- /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/19] =?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 3191372..c833e43 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 0000000..1ab216f --- /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 0000000..e2625ea --- /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 0000000..11374c5 --- /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 0000000..f3cbc2d --- /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 0000000..f7497ed --- /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 0000000..b5129be --- /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 0000000..9bcad4e --- /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 787ed01..41bb99d 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 0000000..3fc2c12 --- /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 0000000..70dc4af --- /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 0000000..4093dda --- /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 0000000..805e4ab --- /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/19] =?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 43c137f..2a32746 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 bb04ec9..a7061f1 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 5c143e1..531aee3 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 0000000..6b88e16 --- /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 0000000..19e9a60 --- /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/19] =?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 31a4138..498952e 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 cc0a660..a3de5ce 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 7b55b7b..6bb578c 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 240a29c..83b6d6a 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 96d0513..19124d1 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/19] =?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 e44ba0a..5200620 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 0000000..b58ca4a --- /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 0000000..15c2923 --- /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 0000000..7b8d8f0 --- /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 c87b413..0c992d3 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 c833e43..2b6655b 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 0000000..dd219e2 --- /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 41bb99d..32da572 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 e90cae7..4a48017 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 706f4b7..820d010 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/19] =?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 b58ca4a..df1bb8d 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 15c2923..5a4a0e6 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 0c992d3..76e7af4 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 4a48017..01730c7 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 820d010..cd5259d 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/19] =?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 0c44771..0a06875 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 f6d50e8..cc3a02d 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 011d0d9..7b6c2fe 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 0afccd5..d2e6426 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 1718ea1..7ebe1a5 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/19] =?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 70dc4af..494852b 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 805e4ab..3108022 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 { From 96f3bbf04942996e6e959ea8f9f0d4a3259208d6 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Sun, 20 Sep 2026 23:03:31 +0900 Subject: [PATCH 13/19] =?UTF-8?q?[feat]=20Rating=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=20=EB=B0=8F=20=EA=B8=B0=EB=B0=98=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 공연·릴리즈 별점 평가(#163) 기능을 위한 Rating 엔티티, target_type enum, 마이그레이션, Repository, ErrorCode를 추가한다. Co-Authored-By: Claude Sonnet 5 --- .../Backend/common/exception/ErrorCode.java | 5 + .../Coming/Backend/rating/entity/Rating.java | 48 ++++++ .../rating/entity/RatingTargetType.java | 5 + .../InvalidRatingScoreException.java | 11 ++ .../exception/RatingNotFoundException.java | 11 ++ .../RatingTargetNotFoundException.java | 11 ++ .../exception/UnauthorizedException.java | 11 ++ .../rating/repository/RatingRepository.java | 29 ++++ .../db/migration/V40__create_rating_table.sql | 12 ++ .../repository/RatingRepositoryTest.java | 157 ++++++++++++++++++ 10 files changed, 300 insertions(+) create mode 100644 src/main/java/com/Coming/Backend/rating/entity/Rating.java create mode 100644 src/main/java/com/Coming/Backend/rating/entity/RatingTargetType.java create mode 100644 src/main/java/com/Coming/Backend/rating/exception/InvalidRatingScoreException.java create mode 100644 src/main/java/com/Coming/Backend/rating/exception/RatingNotFoundException.java create mode 100644 src/main/java/com/Coming/Backend/rating/exception/RatingTargetNotFoundException.java create mode 100644 src/main/java/com/Coming/Backend/rating/exception/UnauthorizedException.java create mode 100644 src/main/java/com/Coming/Backend/rating/repository/RatingRepository.java create mode 100644 src/main/resources/db/migration/V40__create_rating_table.sql create mode 100644 src/test/java/com/Coming/Backend/rating/repository/RatingRepositoryTest.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 2b6655b..0565c79 100644 --- a/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java +++ b/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java @@ -70,6 +70,11 @@ public enum ErrorCode { REPORT_ALREADY_EXISTS(HttpStatus.CONFLICT, "이미 신고한 대상입니다."), REPORT_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 신고입니다."), + // Rating + RATING_TARGET_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 평가 대상입니다."), + INVALID_RATING_SCORE(HttpStatus.BAD_REQUEST, "별점은 0.5~5.0 사이 0.5 단위여야 합니다."), + RATING_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/rating/entity/Rating.java b/src/main/java/com/Coming/Backend/rating/entity/Rating.java new file mode 100644 index 0000000..4b17950 --- /dev/null +++ b/src/main/java/com/Coming/Backend/rating/entity/Rating.java @@ -0,0 +1,48 @@ +package com.Coming.Backend.rating.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; + +import java.math.BigDecimal; + +@Entity +@Table(name = "rating") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder +public class Rating extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Enumerated(EnumType.STRING) + @Column(name = "target_type", nullable = false, length = 20) + private RatingTargetType targetType; + + @Column(name = "target_id", nullable = false) + private Long targetId; + + @Column(name = "score", nullable = false) + private BigDecimal score; + + public void updateScore(BigDecimal score) { + this.score = score; + } +} diff --git a/src/main/java/com/Coming/Backend/rating/entity/RatingTargetType.java b/src/main/java/com/Coming/Backend/rating/entity/RatingTargetType.java new file mode 100644 index 0000000..add2bca --- /dev/null +++ b/src/main/java/com/Coming/Backend/rating/entity/RatingTargetType.java @@ -0,0 +1,5 @@ +package com.Coming.Backend.rating.entity; + +public enum RatingTargetType { + CONCERT, RELEASE +} diff --git a/src/main/java/com/Coming/Backend/rating/exception/InvalidRatingScoreException.java b/src/main/java/com/Coming/Backend/rating/exception/InvalidRatingScoreException.java new file mode 100644 index 0000000..902aaf3 --- /dev/null +++ b/src/main/java/com/Coming/Backend/rating/exception/InvalidRatingScoreException.java @@ -0,0 +1,11 @@ +package com.Coming.Backend.rating.exception; + +import com.Coming.Backend.common.exception.BusinessException; +import com.Coming.Backend.common.exception.ErrorCode; + +public class InvalidRatingScoreException extends BusinessException { + + public InvalidRatingScoreException() { + super(ErrorCode.INVALID_RATING_SCORE); + } +} diff --git a/src/main/java/com/Coming/Backend/rating/exception/RatingNotFoundException.java b/src/main/java/com/Coming/Backend/rating/exception/RatingNotFoundException.java new file mode 100644 index 0000000..3ff5d3a --- /dev/null +++ b/src/main/java/com/Coming/Backend/rating/exception/RatingNotFoundException.java @@ -0,0 +1,11 @@ +package com.Coming.Backend.rating.exception; + +import com.Coming.Backend.common.exception.BusinessException; +import com.Coming.Backend.common.exception.ErrorCode; + +public class RatingNotFoundException extends BusinessException { + + public RatingNotFoundException() { + super(ErrorCode.RATING_NOT_FOUND); + } +} diff --git a/src/main/java/com/Coming/Backend/rating/exception/RatingTargetNotFoundException.java b/src/main/java/com/Coming/Backend/rating/exception/RatingTargetNotFoundException.java new file mode 100644 index 0000000..df18736 --- /dev/null +++ b/src/main/java/com/Coming/Backend/rating/exception/RatingTargetNotFoundException.java @@ -0,0 +1,11 @@ +package com.Coming.Backend.rating.exception; + +import com.Coming.Backend.common.exception.BusinessException; +import com.Coming.Backend.common.exception.ErrorCode; + +public class RatingTargetNotFoundException extends BusinessException { + + public RatingTargetNotFoundException() { + super(ErrorCode.RATING_TARGET_NOT_FOUND); + } +} diff --git a/src/main/java/com/Coming/Backend/rating/exception/UnauthorizedException.java b/src/main/java/com/Coming/Backend/rating/exception/UnauthorizedException.java new file mode 100644 index 0000000..8379e9b --- /dev/null +++ b/src/main/java/com/Coming/Backend/rating/exception/UnauthorizedException.java @@ -0,0 +1,11 @@ +package com.Coming.Backend.rating.exception; + +import com.Coming.Backend.common.exception.BusinessException; +import com.Coming.Backend.common.exception.ErrorCode; + +public class UnauthorizedException extends BusinessException { + + public UnauthorizedException() { + super(ErrorCode.UNAUTHORIZED); + } +} diff --git a/src/main/java/com/Coming/Backend/rating/repository/RatingRepository.java b/src/main/java/com/Coming/Backend/rating/repository/RatingRepository.java new file mode 100644 index 0000000..849310a --- /dev/null +++ b/src/main/java/com/Coming/Backend/rating/repository/RatingRepository.java @@ -0,0 +1,29 @@ +package com.Coming.Backend.rating.repository; + +import com.Coming.Backend.rating.entity.Rating; +import com.Coming.Backend.rating.entity.RatingTargetType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +public interface RatingRepository extends JpaRepository { + + Optional findByUserIdAndTargetTypeAndTargetId(Long userId, RatingTargetType targetType, Long targetId); + + @Query("SELECT r.targetId AS targetId, AVG(r.score) AS averageScore, COUNT(r) AS ratingCount " + + "FROM Rating r WHERE r.targetType = :targetType AND r.targetId IN :targetIds GROUP BY r.targetId") + List aggregateByTargetIds(@Param("targetType") RatingTargetType targetType, + @Param("targetIds") Collection targetIds); + + interface RatingAggregate { + Long getTargetId(); + + Double getAverageScore(); + + Long getRatingCount(); + } +} diff --git a/src/main/resources/db/migration/V40__create_rating_table.sql b/src/main/resources/db/migration/V40__create_rating_table.sql new file mode 100644 index 0000000..ba84f34 --- /dev/null +++ b/src/main/resources/db/migration/V40__create_rating_table.sql @@ -0,0 +1,12 @@ +CREATE TABLE rating ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + user_id bigint NOT NULL, + target_type varchar(20) NOT NULL, + target_id bigint NOT NULL, + score numeric(2,1) NOT NULL, + created_at timestamp NOT NULL, + updated_at timestamp NOT NULL, + UNIQUE (user_id, target_type, target_id) +); + +CREATE INDEX idx_rating_target ON rating (target_type, target_id); diff --git a/src/test/java/com/Coming/Backend/rating/repository/RatingRepositoryTest.java b/src/test/java/com/Coming/Backend/rating/repository/RatingRepositoryTest.java new file mode 100644 index 0000000..737773e --- /dev/null +++ b/src/test/java/com/Coming/Backend/rating/repository/RatingRepositoryTest.java @@ -0,0 +1,157 @@ +package com.Coming.Backend.rating.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.Coming.Backend.rating.entity.Rating; +import com.Coming.Backend.rating.entity.RatingTargetType; +import com.Coming.Backend.rating.repository.RatingRepository.RatingAggregate; +import java.math.BigDecimal; +import java.util.List; +import java.util.Optional; +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 RatingRepositoryTest { + + @Autowired + private RatingRepository ratingRepository; + + private static final Long USER_ID = 1L; + private static final Long OTHER_USER_ID = 2L; + private static final Long TARGET_ID = 10L; + private static final Long OTHER_TARGET_ID = 20L; + private static final Long UNRATED_TARGET_ID = 30L; + + private Rating buildRating(Long userId, RatingTargetType targetType, Long targetId, BigDecimal score) { + return Rating.builder() + .userId(userId) + .targetType(targetType) + .targetId(targetId) + .score(score) + .build(); + } + + @Test + void should_return_rating_when_matching_combination_exists() { + // given + ratingRepository.save(buildRating(USER_ID, RatingTargetType.CONCERT, TARGET_ID, BigDecimal.valueOf(4))); + + // when + Optional found = ratingRepository.findByUserIdAndTargetTypeAndTargetId( + USER_ID, RatingTargetType.CONCERT, TARGET_ID); + + // then + assertThat(found).isPresent(); + assertThat(found.get().getScore()).isEqualByComparingTo(BigDecimal.valueOf(4)); + } + + @Test + void should_return_empty_when_user_id_differs() { + // given + ratingRepository.save(buildRating(USER_ID, RatingTargetType.CONCERT, TARGET_ID, BigDecimal.valueOf(4))); + + // when + Optional found = ratingRepository.findByUserIdAndTargetTypeAndTargetId( + OTHER_USER_ID, RatingTargetType.CONCERT, TARGET_ID); + + // then + assertThat(found).isEmpty(); + } + + @Test + void should_return_empty_when_target_type_differs() { + // given + ratingRepository.save(buildRating(USER_ID, RatingTargetType.CONCERT, TARGET_ID, BigDecimal.valueOf(4))); + + // when + Optional found = ratingRepository.findByUserIdAndTargetTypeAndTargetId( + USER_ID, RatingTargetType.RELEASE, TARGET_ID); + + // then + assertThat(found).isEmpty(); + } + + @Test + void should_return_empty_when_target_id_differs() { + // given + ratingRepository.save(buildRating(USER_ID, RatingTargetType.CONCERT, TARGET_ID, BigDecimal.valueOf(4))); + + // when + Optional found = ratingRepository.findByUserIdAndTargetTypeAndTargetId( + USER_ID, RatingTargetType.CONCERT, OTHER_TARGET_ID); + + // then + assertThat(found).isEmpty(); + } + + @Test + void should_aggregate_average_and_count_when_multiple_users_rate_same_target() { + // given + ratingRepository.save(buildRating(USER_ID, RatingTargetType.CONCERT, TARGET_ID, BigDecimal.valueOf(4))); + ratingRepository.save(buildRating(OTHER_USER_ID, RatingTargetType.CONCERT, TARGET_ID, BigDecimal.valueOf(5))); + + // when + List aggregates = ratingRepository.aggregateByTargetIds( + RatingTargetType.CONCERT, List.of(TARGET_ID)); + + // then + assertThat(aggregates).hasSize(1); + assertThat(aggregates.get(0).getTargetId()).isEqualTo(TARGET_ID); + assertThat(aggregates.get(0).getAverageScore()).isEqualTo(4.5); + assertThat(aggregates.get(0).getRatingCount()).isEqualTo(2L); + } + + @Test + void should_aggregate_independently_when_multiple_target_ids_given() { + // given + ratingRepository.save(buildRating(USER_ID, RatingTargetType.CONCERT, TARGET_ID, BigDecimal.valueOf(4))); + ratingRepository.save(buildRating(USER_ID, RatingTargetType.CONCERT, OTHER_TARGET_ID, BigDecimal.valueOf(2))); + + // when + List aggregates = ratingRepository.aggregateByTargetIds( + RatingTargetType.CONCERT, List.of(TARGET_ID, OTHER_TARGET_ID)); + + // then + assertThat(aggregates).hasSize(2); + assertThat(aggregates) + .filteredOn(aggregate -> aggregate.getTargetId().equals(TARGET_ID)) + .extracting(RatingAggregate::getAverageScore) + .containsExactly(4.0); + assertThat(aggregates) + .filteredOn(aggregate -> aggregate.getTargetId().equals(OTHER_TARGET_ID)) + .extracting(RatingAggregate::getAverageScore) + .containsExactly(2.0); + } + + @Test + void should_exclude_rating_from_aggregate_when_target_type_differs() { + // given + ratingRepository.save(buildRating(USER_ID, RatingTargetType.RELEASE, TARGET_ID, BigDecimal.valueOf(5))); + + // when + List aggregates = ratingRepository.aggregateByTargetIds( + RatingTargetType.CONCERT, List.of(TARGET_ID)); + + // then + assertThat(aggregates).isEmpty(); + } + + @Test + void should_exclude_target_id_from_result_when_no_ratings_exist() { + // given + ratingRepository.save(buildRating(USER_ID, RatingTargetType.CONCERT, TARGET_ID, BigDecimal.valueOf(4))); + + // when + List aggregates = ratingRepository.aggregateByTargetIds( + RatingTargetType.CONCERT, List.of(TARGET_ID, UNRATED_TARGET_ID)); + + // then + assertThat(aggregates) + .extracting(RatingAggregate::getTargetId) + .containsExactly(TARGET_ID); + } +} From e06c870f5bcdeee70f6b504291044cc3b99e3f90 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Sun, 20 Sep 2026 23:11:47 +0900 Subject: [PATCH 14/19] =?UTF-8?q?[feat]=20=EB=B3=84=EC=A0=90=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=C2=B7=EC=88=98=EC=A0=95=C2=B7=EC=A1=B0=ED=9A=8C=C2=B7?= =?UTF-8?q?=EC=B7=A8=EC=86=8C=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RatingService와 DTO를 추가하고 ConcertController·ReleaseController에 PUT/GET(me)/DELETE 별점 엔드포인트를 붙인다. Co-Authored-By: Claude Sonnet 5 --- .../concert/controller/ConcertController.java | 40 +++ .../Backend/rating/dto/RatingMeResponse.java | 11 + .../Backend/rating/dto/RatingSummary.java | 7 + .../rating/dto/RatingUpsertRequest.java | 15 ++ .../Backend/rating/service/RatingService.java | 109 ++++++++ .../release/controller/ReleaseController.java | 40 +++ .../controller/ConcertControllerTest.java | 78 ++++++ .../rating/service/RatingServiceTest.java | 250 ++++++++++++++++++ .../controller/ReleaseControllerTest.java | 83 ++++++ 9 files changed, 633 insertions(+) create mode 100644 src/main/java/com/Coming/Backend/rating/dto/RatingMeResponse.java create mode 100644 src/main/java/com/Coming/Backend/rating/dto/RatingSummary.java create mode 100644 src/main/java/com/Coming/Backend/rating/dto/RatingUpsertRequest.java create mode 100644 src/main/java/com/Coming/Backend/rating/service/RatingService.java create mode 100644 src/test/java/com/Coming/Backend/rating/service/RatingServiceTest.java diff --git a/src/main/java/com/Coming/Backend/concert/controller/ConcertController.java b/src/main/java/com/Coming/Backend/concert/controller/ConcertController.java index 47a5fec..aee1ca0 100644 --- a/src/main/java/com/Coming/Backend/concert/controller/ConcertController.java +++ b/src/main/java/com/Coming/Backend/concert/controller/ConcertController.java @@ -9,9 +9,14 @@ import com.Coming.Backend.concert.entity.ConcertStatus; import com.Coming.Backend.concert.exception.UnauthorizedException; import com.Coming.Backend.concert.service.ConcertService; +import com.Coming.Backend.rating.dto.RatingMeResponse; +import com.Coming.Backend.rating.dto.RatingUpsertRequest; +import com.Coming.Backend.rating.entity.RatingTargetType; +import com.Coming.Backend.rating.service.RatingService; 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 jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import lombok.RequiredArgsConstructor; @@ -20,8 +25,11 @@ import org.springframework.data.web.PageableDefault; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.validation.annotation.Validated; @@ -40,6 +48,7 @@ public class ConcertController { private static final Set SORTABLE_PROPERTIES = Set.of("startDate", "ticketOpenAt"); private final ConcertService concertService; + private final RatingService ratingService; @Operation(summary = "공연 목록 조회") @ApiResponse(responseCode = "400", description = "INVALID_INPUT (허용되지 않은 sort 필드)") @@ -98,4 +107,35 @@ public ResponseEntity> getTicketingConcerts( public ResponseEntity getSetlist(@PathVariable Long id) { return ResponseEntity.ok(concertService.getSetlist(id)); } + + @Operation(summary = "공연 별점 등록·수정") + @ApiResponse(responseCode = "400", description = "INVALID_RATING_SCORE") + @ApiResponse(responseCode = "404", description = "RATING_TARGET_NOT_FOUND") + @PutMapping("/{id}/rating") + public ResponseEntity upsertRating( + @PathVariable Long id, + @AuthenticationPrincipal Long userId, + @RequestBody @Valid RatingUpsertRequest request) { + ratingService.upsert(userId, RatingTargetType.CONCERT, id, request.score()); + return ResponseEntity.ok().build(); + } + + @Operation(summary = "내 공연 별점 조회") + @ApiResponse(responseCode = "401", description = "UNAUTHORIZED") + @GetMapping("/{id}/rating/me") + public ResponseEntity getMyRating( + @PathVariable Long id, + @AuthenticationPrincipal Long userId) { + return ResponseEntity.ok(ratingService.getMine(userId, RatingTargetType.CONCERT, id)); + } + + @Operation(summary = "공연 별점 취소") + @ApiResponse(responseCode = "404", description = "RATING_NOT_FOUND") + @DeleteMapping("/{id}/rating") + public ResponseEntity deleteRating( + @PathVariable Long id, + @AuthenticationPrincipal Long userId) { + ratingService.delete(userId, RatingTargetType.CONCERT, id); + return ResponseEntity.ok().build(); + } } diff --git a/src/main/java/com/Coming/Backend/rating/dto/RatingMeResponse.java b/src/main/java/com/Coming/Backend/rating/dto/RatingMeResponse.java new file mode 100644 index 0000000..3a07804 --- /dev/null +++ b/src/main/java/com/Coming/Backend/rating/dto/RatingMeResponse.java @@ -0,0 +1,11 @@ +package com.Coming.Backend.rating.dto; + +import java.math.BigDecimal; + +public record RatingMeResponse( + BigDecimal score +) { + public static RatingMeResponse empty() { + return new RatingMeResponse(null); + } +} diff --git a/src/main/java/com/Coming/Backend/rating/dto/RatingSummary.java b/src/main/java/com/Coming/Backend/rating/dto/RatingSummary.java new file mode 100644 index 0000000..2e21546 --- /dev/null +++ b/src/main/java/com/Coming/Backend/rating/dto/RatingSummary.java @@ -0,0 +1,7 @@ +package com.Coming.Backend.rating.dto; + +public record RatingSummary( + Double averageRating, + long ratingCount +) { +} diff --git a/src/main/java/com/Coming/Backend/rating/dto/RatingUpsertRequest.java b/src/main/java/com/Coming/Backend/rating/dto/RatingUpsertRequest.java new file mode 100644 index 0000000..3acce29 --- /dev/null +++ b/src/main/java/com/Coming/Backend/rating/dto/RatingUpsertRequest.java @@ -0,0 +1,15 @@ +package com.Coming.Backend.rating.dto; + +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotNull; + +import java.math.BigDecimal; + +public record RatingUpsertRequest( + @NotNull + @DecimalMin("0.5") + @DecimalMax("5.0") + BigDecimal score +) { +} diff --git a/src/main/java/com/Coming/Backend/rating/service/RatingService.java b/src/main/java/com/Coming/Backend/rating/service/RatingService.java new file mode 100644 index 0000000..5dab15f --- /dev/null +++ b/src/main/java/com/Coming/Backend/rating/service/RatingService.java @@ -0,0 +1,109 @@ +package com.Coming.Backend.rating.service; + +import com.Coming.Backend.concert.repository.ConcertRepository; +import com.Coming.Backend.rating.dto.RatingMeResponse; +import com.Coming.Backend.rating.dto.RatingSummary; +import com.Coming.Backend.rating.entity.Rating; +import com.Coming.Backend.rating.entity.RatingTargetType; +import com.Coming.Backend.rating.exception.InvalidRatingScoreException; +import com.Coming.Backend.rating.exception.RatingNotFoundException; +import com.Coming.Backend.rating.exception.RatingTargetNotFoundException; +import com.Coming.Backend.rating.exception.UnauthorizedException; +import com.Coming.Backend.rating.repository.RatingRepository; +import com.Coming.Backend.release.repository.ReleaseGroupRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.util.Collection; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class RatingService { + + private static final BigDecimal SCORE_STEP = BigDecimal.valueOf(0.5); + + private final RatingRepository ratingRepository; + private final ConcertRepository concertRepository; + private final ReleaseGroupRepository releaseGroupRepository; + + /** + * 별점을 등록하거나 수정한다. 대상이 존재하지 않으면 RatingTargetNotFoundException, + * score가 0.5 단위가 아니면 InvalidRatingScoreException을 던진다. + */ + @Transactional + public void upsert(Long userId, RatingTargetType targetType, Long targetId, BigDecimal score) { + if (userId == null) { + throw new UnauthorizedException(); + } + if (score.remainder(SCORE_STEP).compareTo(BigDecimal.ZERO) != 0) { + throw new InvalidRatingScoreException(); + } + if (!targetExists(targetType, targetId)) { + throw new RatingTargetNotFoundException(); + } + + ratingRepository.findByUserIdAndTargetTypeAndTargetId(userId, targetType, targetId) + .ifPresentOrElse( + rating -> rating.updateScore(score), + () -> ratingRepository.save(Rating.builder() + .userId(userId) + .targetType(targetType) + .targetId(targetId) + .score(score) + .build()) + ); + } + + /** + * 내 별점을 조회한다. 등록한 적이 없으면 score가 null인 응답을 반환한다. + */ + public RatingMeResponse getMine(Long userId, RatingTargetType targetType, Long targetId) { + if (userId == null) { + throw new UnauthorizedException(); + } + return ratingRepository.findByUserIdAndTargetTypeAndTargetId(userId, targetType, targetId) + .map(rating -> new RatingMeResponse(rating.getScore())) + .orElseGet(RatingMeResponse::empty); + } + + /** + * 별점을 취소한다. 등록된 별점이 없으면 RatingNotFoundException을 던진다. + */ + @Transactional + public void delete(Long userId, RatingTargetType targetType, Long targetId) { + if (userId == null) { + throw new UnauthorizedException(); + } + Rating rating = ratingRepository.findByUserIdAndTargetTypeAndTargetId(userId, targetType, targetId) + .orElseThrow(RatingNotFoundException::new); + ratingRepository.delete(rating); + } + + /** + * 대상 ID 목록에 대한 평균 별점·평가 개수를 조회한다. 별점이 없는 대상은 결과 맵에 포함되지 않는다. + */ + public Map getSummaries(RatingTargetType targetType, Collection targetIds) { + if (targetIds.isEmpty()) { + return Map.of(); + } + return ratingRepository.aggregateByTargetIds(targetType, targetIds).stream() + .collect(Collectors.toMap( + RatingRepository.RatingAggregate::getTargetId, + aggregate -> new RatingSummary( + Math.round(aggregate.getAverageScore() * 10) / 10.0, + aggregate.getRatingCount()) + )); + } + + private boolean targetExists(RatingTargetType targetType, Long targetId) { + return switch (targetType) { + case CONCERT -> concertRepository.existsById(targetId); + case RELEASE -> releaseGroupRepository.existsById(targetId); + }; + } +} diff --git a/src/main/java/com/Coming/Backend/release/controller/ReleaseController.java b/src/main/java/com/Coming/Backend/release/controller/ReleaseController.java index a9ada41..ee45fad 100644 --- a/src/main/java/com/Coming/Backend/release/controller/ReleaseController.java +++ b/src/main/java/com/Coming/Backend/release/controller/ReleaseController.java @@ -1,19 +1,27 @@ package com.Coming.Backend.release.controller; import com.Coming.Backend.common.response.PageResponse; +import com.Coming.Backend.rating.dto.RatingMeResponse; +import com.Coming.Backend.rating.dto.RatingUpsertRequest; +import com.Coming.Backend.rating.entity.RatingTargetType; +import com.Coming.Backend.rating.service.RatingService; import com.Coming.Backend.release.dto.ReleaseDetailResponse; import com.Coming.Backend.release.dto.ReleaseListItemResponse; import com.Coming.Backend.release.service.ReleaseService; 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.data.domain.Pageable; import org.springframework.data.web.PageableDefault; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -25,6 +33,7 @@ public class ReleaseController { private final ReleaseService releaseService; + private final RatingService ratingService; @Operation(summary = "릴리즈 목록 조회") @ApiResponse(responseCode = "400", description = "INVALID_INPUT (type이 Album·Single이 아님)") @@ -45,4 +54,35 @@ public ResponseEntity> getReleases( public ResponseEntity getReleaseDetail(@PathVariable Long id) { return ResponseEntity.ok(releaseService.getReleaseDetail(id)); } + + @Operation(summary = "릴리즈 별점 등록·수정") + @ApiResponse(responseCode = "400", description = "INVALID_RATING_SCORE") + @ApiResponse(responseCode = "404", description = "RATING_TARGET_NOT_FOUND") + @PutMapping("/{id}/rating") + public ResponseEntity upsertRating( + @PathVariable Long id, + @AuthenticationPrincipal Long userId, + @RequestBody @Valid RatingUpsertRequest request) { + ratingService.upsert(userId, RatingTargetType.RELEASE, id, request.score()); + return ResponseEntity.ok().build(); + } + + @Operation(summary = "내 릴리즈 별점 조회") + @ApiResponse(responseCode = "401", description = "UNAUTHORIZED") + @GetMapping("/{id}/rating/me") + public ResponseEntity getMyRating( + @PathVariable Long id, + @AuthenticationPrincipal Long userId) { + return ResponseEntity.ok(ratingService.getMine(userId, RatingTargetType.RELEASE, id)); + } + + @Operation(summary = "릴리즈 별점 취소") + @ApiResponse(responseCode = "404", description = "RATING_NOT_FOUND") + @DeleteMapping("/{id}/rating") + public ResponseEntity deleteRating( + @PathVariable Long id, + @AuthenticationPrincipal Long userId) { + ratingService.delete(userId, RatingTargetType.RELEASE, id); + return ResponseEntity.ok().build(); + } } diff --git a/src/test/java/com/Coming/Backend/concert/controller/ConcertControllerTest.java b/src/test/java/com/Coming/Backend/concert/controller/ConcertControllerTest.java index 36b8545..9577944 100644 --- a/src/test/java/com/Coming/Backend/concert/controller/ConcertControllerTest.java +++ b/src/test/java/com/Coming/Backend/concert/controller/ConcertControllerTest.java @@ -4,8 +4,11 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -17,6 +20,11 @@ import com.Coming.Backend.concert.entity.ConcertStatus; import com.Coming.Backend.concert.exception.ConcertNotFoundException; import com.Coming.Backend.concert.service.ConcertService; +import com.Coming.Backend.rating.dto.RatingMeResponse; +import com.Coming.Backend.rating.entity.RatingTargetType; +import com.Coming.Backend.rating.exception.RatingTargetNotFoundException; +import com.Coming.Backend.rating.service.RatingService; +import java.math.BigDecimal; import java.time.LocalDate; import java.util.List; import org.junit.jupiter.api.BeforeEach; @@ -40,6 +48,9 @@ class ConcertControllerTest { @Mock private ConcertService concertService; + @Mock + private RatingService ratingService; + @InjectMocks private ConcertController concertController; @@ -194,4 +205,71 @@ void should_return_401_with_error_body_when_following_true_and_user_not_authenti .andExpect(jsonPath("$.code").value(ErrorCode.UNAUTHORIZED.name())) .andExpect(jsonPath("$.message").exists()); } + + // ------------------------------------------------------------------------- + // PUT /api/concerts/{id}/rating + // ------------------------------------------------------------------------- + + @Test + void should_return_200_when_valid_score_given() throws Exception { + // when & then + mockMvc.perform(put("/api/concerts/{id}/rating", CONCERT_ID) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"score\": 4.5}")) + .andExpect(status().isOk()); + verify(ratingService).upsert(isNull(), eq(RatingTargetType.CONCERT), eq(CONCERT_ID), eq(BigDecimal.valueOf(4.5))); + } + + @Test + void should_return_400_when_score_is_out_of_range() throws Exception { + // when & then + mockMvc.perform(put("/api/concerts/{id}/rating", CONCERT_ID) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"score\": 5.5}")) + .andExpect(status().isBadRequest()); + } + + @Test + void should_return_404_when_rating_target_does_not_exist() throws Exception { + // given + willThrow(new RatingTargetNotFoundException()) + .given(ratingService).upsert(isNull(), eq(RatingTargetType.CONCERT), eq(999L), any(BigDecimal.class)); + + // when & then + mockMvc.perform(put("/api/concerts/{id}/rating", 999L) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"score\": 4.5}")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ErrorCode.RATING_TARGET_NOT_FOUND.name())) + .andExpect(jsonPath("$.message").exists()); + } + + // ------------------------------------------------------------------------- + // GET /api/concerts/{id}/rating/me + // ------------------------------------------------------------------------- + + @Test + void should_return_200_with_my_score_when_rating_exists() throws Exception { + // given + given(ratingService.getMine(isNull(), eq(RatingTargetType.CONCERT), eq(CONCERT_ID))) + .willReturn(new RatingMeResponse(BigDecimal.valueOf(4.5))); + + // when & then + mockMvc.perform(get("/api/concerts/{id}/rating/me", CONCERT_ID) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.score").value(4.5)); + } + + // ------------------------------------------------------------------------- + // DELETE /api/concerts/{id}/rating + // ------------------------------------------------------------------------- + + @Test + void should_return_200_when_rating_deleted() throws Exception { + // when & then + mockMvc.perform(delete("/api/concerts/{id}/rating", CONCERT_ID)) + .andExpect(status().isOk()); + verify(ratingService).delete(isNull(), eq(RatingTargetType.CONCERT), eq(CONCERT_ID)); + } } diff --git a/src/test/java/com/Coming/Backend/rating/service/RatingServiceTest.java b/src/test/java/com/Coming/Backend/rating/service/RatingServiceTest.java new file mode 100644 index 0000000..f81fff9 --- /dev/null +++ b/src/test/java/com/Coming/Backend/rating/service/RatingServiceTest.java @@ -0,0 +1,250 @@ +package com.Coming.Backend.rating.service; + +import com.Coming.Backend.common.exception.ErrorCode; +import com.Coming.Backend.concert.repository.ConcertRepository; +import com.Coming.Backend.rating.dto.RatingMeResponse; +import com.Coming.Backend.rating.dto.RatingSummary; +import com.Coming.Backend.rating.entity.Rating; +import com.Coming.Backend.rating.entity.RatingTargetType; +import com.Coming.Backend.rating.exception.InvalidRatingScoreException; +import com.Coming.Backend.rating.exception.RatingNotFoundException; +import com.Coming.Backend.rating.exception.RatingTargetNotFoundException; +import com.Coming.Backend.rating.exception.UnauthorizedException; +import com.Coming.Backend.rating.repository.RatingRepository; +import com.Coming.Backend.release.repository.ReleaseGroupRepository; +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 java.math.BigDecimal; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +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.ArgumentMatchers.anyCollection; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +@ExtendWith(MockitoExtension.class) +class RatingServiceTest { + + @InjectMocks + private RatingService ratingService; + + @Mock + private RatingRepository ratingRepository; + + @Mock + private ConcertRepository concertRepository; + + @Mock + private ReleaseGroupRepository releaseGroupRepository; + + private static final Long USER_ID = 1L; + private static final Long CONCERT_ID = 10L; + + // ------------------------------------------------------------------------- + // upsert + // ------------------------------------------------------------------------- + + @Test + void should_throw_unauthorized_exception_when_user_id_is_null_on_upsert() { + // when & then + assertThatThrownBy(() -> ratingService.upsert(null, RatingTargetType.CONCERT, CONCERT_ID, BigDecimal.valueOf(4.5))) + .isInstanceOf(UnauthorizedException.class) + .hasMessage(ErrorCode.UNAUTHORIZED.getMessage()); + } + + @Test + void should_throw_invalid_rating_score_exception_when_score_is_not_half_step() { + // given + BigDecimal invalidScore = BigDecimal.valueOf(4.3); + + // when & then + assertThatThrownBy(() -> ratingService.upsert(USER_ID, RatingTargetType.CONCERT, CONCERT_ID, invalidScore)) + .isInstanceOf(InvalidRatingScoreException.class) + .hasMessage(ErrorCode.INVALID_RATING_SCORE.getMessage()); + } + + @Test + void should_throw_rating_target_not_found_exception_when_concert_does_not_exist() { + // given + given(concertRepository.existsById(CONCERT_ID)).willReturn(false); + + // when & then + assertThatThrownBy(() -> ratingService.upsert(USER_ID, RatingTargetType.CONCERT, CONCERT_ID, BigDecimal.valueOf(4.5))) + .isInstanceOf(RatingTargetNotFoundException.class) + .hasMessage(ErrorCode.RATING_TARGET_NOT_FOUND.getMessage()); + } + + @Test + void should_save_new_rating_when_no_existing_rating_found() { + // given + BigDecimal score = BigDecimal.valueOf(4.5); + given(concertRepository.existsById(CONCERT_ID)).willReturn(true); + given(ratingRepository.findByUserIdAndTargetTypeAndTargetId(USER_ID, RatingTargetType.CONCERT, CONCERT_ID)) + .willReturn(Optional.empty()); + + // when + ratingService.upsert(USER_ID, RatingTargetType.CONCERT, CONCERT_ID, score); + + // then + verify(ratingRepository).save(any(Rating.class)); + } + + @Test + void should_update_existing_rating_score_when_rating_already_exists() { + // given + Rating existingRating = Rating.builder() + .userId(USER_ID) + .targetType(RatingTargetType.CONCERT) + .targetId(CONCERT_ID) + .score(BigDecimal.valueOf(2.0)) + .build(); + given(concertRepository.existsById(CONCERT_ID)).willReturn(true); + given(ratingRepository.findByUserIdAndTargetTypeAndTargetId(USER_ID, RatingTargetType.CONCERT, CONCERT_ID)) + .willReturn(Optional.of(existingRating)); + + // when + ratingService.upsert(USER_ID, RatingTargetType.CONCERT, CONCERT_ID, BigDecimal.valueOf(3.5)); + + // then + assertThat(existingRating.getScore()).isEqualByComparingTo(BigDecimal.valueOf(3.5)); + verify(ratingRepository, never()).save(any(Rating.class)); + } + + // ------------------------------------------------------------------------- + // getMine + // ------------------------------------------------------------------------- + + @Test + void should_throw_unauthorized_exception_when_user_id_is_null_on_get_mine() { + // when & then + assertThatThrownBy(() -> ratingService.getMine(null, RatingTargetType.CONCERT, CONCERT_ID)) + .isInstanceOf(UnauthorizedException.class) + .hasMessage(ErrorCode.UNAUTHORIZED.getMessage()); + } + + @Test + void should_return_score_when_rating_exists() { + // given + Rating rating = Rating.builder() + .userId(USER_ID) + .targetType(RatingTargetType.CONCERT) + .targetId(CONCERT_ID) + .score(BigDecimal.valueOf(4.5)) + .build(); + given(ratingRepository.findByUserIdAndTargetTypeAndTargetId(USER_ID, RatingTargetType.CONCERT, CONCERT_ID)) + .willReturn(Optional.of(rating)); + + // when + RatingMeResponse response = ratingService.getMine(USER_ID, RatingTargetType.CONCERT, CONCERT_ID); + + // then + assertThat(response.score()).isEqualByComparingTo(BigDecimal.valueOf(4.5)); + } + + @Test + void should_return_null_score_when_rating_does_not_exist() { + // given + given(ratingRepository.findByUserIdAndTargetTypeAndTargetId(USER_ID, RatingTargetType.CONCERT, CONCERT_ID)) + .willReturn(Optional.empty()); + + // when + RatingMeResponse response = ratingService.getMine(USER_ID, RatingTargetType.CONCERT, CONCERT_ID); + + // then + assertThat(response.score()).isNull(); + } + + // ------------------------------------------------------------------------- + // delete + // ------------------------------------------------------------------------- + + @Test + void should_throw_unauthorized_exception_when_user_id_is_null_on_delete() { + // when & then + assertThatThrownBy(() -> ratingService.delete(null, RatingTargetType.CONCERT, CONCERT_ID)) + .isInstanceOf(UnauthorizedException.class) + .hasMessage(ErrorCode.UNAUTHORIZED.getMessage()); + } + + @Test + void should_throw_rating_not_found_exception_when_rating_does_not_exist() { + // given + given(ratingRepository.findByUserIdAndTargetTypeAndTargetId(USER_ID, RatingTargetType.CONCERT, CONCERT_ID)) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> ratingService.delete(USER_ID, RatingTargetType.CONCERT, CONCERT_ID)) + .isInstanceOf(RatingNotFoundException.class) + .hasMessage(ErrorCode.RATING_NOT_FOUND.getMessage()); + } + + @Test + void should_delete_rating_when_rating_exists() { + // given + Rating rating = Rating.builder() + .userId(USER_ID) + .targetType(RatingTargetType.CONCERT) + .targetId(CONCERT_ID) + .score(BigDecimal.valueOf(4.5)) + .build(); + given(ratingRepository.findByUserIdAndTargetTypeAndTargetId(USER_ID, RatingTargetType.CONCERT, CONCERT_ID)) + .willReturn(Optional.of(rating)); + + // when + ratingService.delete(USER_ID, RatingTargetType.CONCERT, CONCERT_ID); + + // then + verify(ratingRepository).delete(rating); + } + + // ------------------------------------------------------------------------- + // getSummaries + // ------------------------------------------------------------------------- + + @Test + void should_return_empty_map_when_target_ids_is_empty() { + // when + Map summaries = ratingService.getSummaries(RatingTargetType.CONCERT, List.of()); + + // then + assertThat(summaries).isEmpty(); + verifyNoInteractions(ratingRepository); + } + + @Test + void should_return_summary_map_when_aggregates_exist() { + // given + RatingRepository.RatingAggregate aggregate1 = mock(RatingRepository.RatingAggregate.class); + given(aggregate1.getTargetId()).willReturn(1L); + given(aggregate1.getAverageScore()).willReturn(4.33); + given(aggregate1.getRatingCount()).willReturn(3L); + + RatingRepository.RatingAggregate aggregate2 = mock(RatingRepository.RatingAggregate.class); + given(aggregate2.getTargetId()).willReturn(2L); + given(aggregate2.getAverageScore()).willReturn(5.0); + given(aggregate2.getRatingCount()).willReturn(1L); + + given(ratingRepository.aggregateByTargetIds(any(RatingTargetType.class), anyCollection())) + .willReturn(List.of(aggregate1, aggregate2)); + + // when + Map summaries = ratingService.getSummaries(RatingTargetType.CONCERT, List.of(1L, 2L)); + + // then + assertThat(summaries.get(1L).averageRating()).isEqualTo(4.3); + assertThat(summaries.get(1L).ratingCount()).isEqualTo(3L); + assertThat(summaries.get(2L).averageRating()).isEqualTo(5.0); + assertThat(summaries.get(2L).ratingCount()).isEqualTo(1L); + } +} diff --git a/src/test/java/com/Coming/Backend/release/controller/ReleaseControllerTest.java b/src/test/java/com/Coming/Backend/release/controller/ReleaseControllerTest.java index 754c32d..68d1801 100644 --- a/src/test/java/com/Coming/Backend/release/controller/ReleaseControllerTest.java +++ b/src/test/java/com/Coming/Backend/release/controller/ReleaseControllerTest.java @@ -4,7 +4,11 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -12,11 +16,16 @@ import com.Coming.Backend.common.exception.GlobalExceptionHandler; import com.Coming.Backend.common.exception.InvalidInputException; import com.Coming.Backend.common.response.PageResponse; +import com.Coming.Backend.rating.dto.RatingMeResponse; +import com.Coming.Backend.rating.entity.RatingTargetType; +import com.Coming.Backend.rating.exception.RatingTargetNotFoundException; +import com.Coming.Backend.rating.service.RatingService; import com.Coming.Backend.release.dto.ReleaseDetailResponse; import com.Coming.Backend.release.dto.ReleaseListItemResponse; import com.Coming.Backend.release.dto.TrackDto; import com.Coming.Backend.release.exception.ReleaseNotFoundException; import com.Coming.Backend.release.service.ReleaseService; +import java.math.BigDecimal; import java.time.LocalDate; import java.util.List; import org.junit.jupiter.api.BeforeEach; @@ -30,6 +39,7 @@ import org.springframework.http.MediaType; 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 ReleaseControllerTest { @@ -39,6 +49,9 @@ class ReleaseControllerTest { @Mock private ReleaseService releaseService; + @Mock + private RatingService ratingService; + @InjectMocks private ReleaseController releaseController; @@ -47,9 +60,12 @@ class ReleaseControllerTest { @BeforeEach void setUp() { + LocalValidatorFactoryBean validator = new LocalValidatorFactoryBean(); + validator.afterPropertiesSet(); mockMvc = MockMvcBuilders.standaloneSetup(releaseController) .setControllerAdvice(new GlobalExceptionHandler(new com.Coming.Backend.common.discord.NoOpDiscordNotifier())) .setCustomArgumentResolvers(new PageableHandlerMethodArgumentResolver()) + .setValidator(validator) .build(); } @@ -266,4 +282,71 @@ void should_return_404_when_release_not_found() throws Exception { .andExpect(jsonPath("$.code").value(ErrorCode.RELEASE_NOT_FOUND.name())) .andExpect(jsonPath("$.message").exists()); } + + // ------------------------------------------------------------------------- + // PUT /api/releases/{id}/rating + // ------------------------------------------------------------------------- + + @Test + void should_return_200_when_valid_score_given() throws Exception { + // when & then + mockMvc.perform(put("/api/releases/{id}/rating", RELEASE_ID) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"score\": 4.5}")) + .andExpect(status().isOk()); + verify(ratingService).upsert(isNull(), eq(RatingTargetType.RELEASE), eq(RELEASE_ID), eq(BigDecimal.valueOf(4.5))); + } + + @Test + void should_return_400_when_score_is_out_of_range() throws Exception { + // when & then + mockMvc.perform(put("/api/releases/{id}/rating", RELEASE_ID) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"score\": 0.2}")) + .andExpect(status().isBadRequest()); + } + + @Test + void should_return_404_when_rating_target_does_not_exist() throws Exception { + // given + willThrow(new RatingTargetNotFoundException()) + .given(ratingService).upsert(isNull(), eq(RatingTargetType.RELEASE), eq(999L), any(BigDecimal.class)); + + // when & then + mockMvc.perform(put("/api/releases/{id}/rating", 999L) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"score\": 4.5}")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ErrorCode.RATING_TARGET_NOT_FOUND.name())) + .andExpect(jsonPath("$.message").exists()); + } + + // ------------------------------------------------------------------------- + // GET /api/releases/{id}/rating/me + // ------------------------------------------------------------------------- + + @Test + void should_return_200_with_my_score_when_rating_exists() throws Exception { + // given + given(ratingService.getMine(isNull(), eq(RatingTargetType.RELEASE), eq(RELEASE_ID))) + .willReturn(new RatingMeResponse(BigDecimal.valueOf(4.5))); + + // when & then + mockMvc.perform(get("/api/releases/{id}/rating/me", RELEASE_ID) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.score").value(4.5)); + } + + // ------------------------------------------------------------------------- + // DELETE /api/releases/{id}/rating + // ------------------------------------------------------------------------- + + @Test + void should_return_200_when_rating_deleted() throws Exception { + // when & then + mockMvc.perform(delete("/api/releases/{id}/rating", RELEASE_ID)) + .andExpect(status().isOk()); + verify(ratingService).delete(isNull(), eq(RatingTargetType.RELEASE), eq(RELEASE_ID)); + } } From c928f23d1cb3b6a40a0e2c605020809e8aa410cb Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Sun, 20 Sep 2026 23:16:44 +0900 Subject: [PATCH 15/19] =?UTF-8?q?[feat]=20=EA=B3=B5=EC=97=B0=C2=B7?= =?UTF-8?q?=EB=A6=B4=EB=A6=AC=EC=A6=88=20=EC=9D=91=EB=8B=B5=EC=97=90=20?= =?UTF-8?q?=ED=8F=89=EA=B7=A0=20=EB=B3=84=EC=A0=90=20=ED=95=84=EB=93=9C=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 Concert/ReleaseService가 RatingService 집계를 조회해 상세·목록 응답에 averageRating, ratingCount 필드를 채운다. Co-Authored-By: Claude Sonnet 5 --- .../concert/dto/ConcertDetailResponse.java | 4 +- .../concert/dto/ConcertSummaryResponse.java | 4 +- .../concert/service/ConcertService.java | 18 +++- .../release/dto/ReleaseDetailResponse.java | 11 ++- .../release/dto/ReleaseListItemResponse.java | 11 ++- .../release/service/ReleaseService.java | 23 ++++-- .../controller/ConcertControllerTest.java | 4 +- .../concert/service/ConcertServiceTest.java | 81 ++++++++++++++++++ .../controller/ReleaseControllerTest.java | 14 ++-- .../release/service/ReleaseServiceTest.java | 82 +++++++++++++++++++ 10 files changed, 229 insertions(+), 23 deletions(-) diff --git a/src/main/java/com/Coming/Backend/concert/dto/ConcertDetailResponse.java b/src/main/java/com/Coming/Backend/concert/dto/ConcertDetailResponse.java index 9c21cf2..63ab40d 100644 --- a/src/main/java/com/Coming/Backend/concert/dto/ConcertDetailResponse.java +++ b/src/main/java/com/Coming/Backend/concert/dto/ConcertDetailResponse.java @@ -19,6 +19,8 @@ public record ConcertDetailResponse( String price, boolean isInCalendar, LocalDateTime ticketOpenAt, - List ticketLinks + List ticketLinks, + Double averageRating, + long ratingCount ) { } diff --git a/src/main/java/com/Coming/Backend/concert/dto/ConcertSummaryResponse.java b/src/main/java/com/Coming/Backend/concert/dto/ConcertSummaryResponse.java index 4e0d7e9..edaf933 100644 --- a/src/main/java/com/Coming/Backend/concert/dto/ConcertSummaryResponse.java +++ b/src/main/java/com/Coming/Backend/concert/dto/ConcertSummaryResponse.java @@ -16,6 +16,8 @@ public record ConcertSummaryResponse( String venue, ConcertStatus status, boolean isInCalendar, - LocalDateTime ticketOpenAt + LocalDateTime ticketOpenAt, + Double averageRating, + long ratingCount ) { } diff --git a/src/main/java/com/Coming/Backend/concert/service/ConcertService.java b/src/main/java/com/Coming/Backend/concert/service/ConcertService.java index bd5d16a..5f7127e 100644 --- a/src/main/java/com/Coming/Backend/concert/service/ConcertService.java +++ b/src/main/java/com/Coming/Backend/concert/service/ConcertService.java @@ -28,6 +28,9 @@ import com.Coming.Backend.concert.repository.ConcertRepository; import com.Coming.Backend.concert.repository.SetlistRepository; import com.Coming.Backend.concert.repository.SetlistTrackRepository; +import com.Coming.Backend.rating.dto.RatingSummary; +import com.Coming.Backend.rating.entity.RatingTargetType; +import com.Coming.Backend.rating.service.RatingService; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -53,6 +56,7 @@ public class ConcertService { private static final List HIDDEN_STATUSES = List.of(EXCLUDED, PENDING); + private static final RatingSummary EMPTY_RATING_SUMMARY = new RatingSummary(null, 0); private final ConcertRepository concertRepository; private final ConcertArtistRepository concertArtistRepository; @@ -64,6 +68,7 @@ public class ConcertService { private final UserFollowArtistRepository userFollowArtistRepository; private final SetlistRepository setlistRepository; private final SetlistTrackRepository setlistTrackRepository; + private final RatingService ratingService; /** * 예정·진행 중 공연을 우선 노출하고 이후 조회수 순으로 상위 10건의 인기 공연 목록을 반환한다. @@ -211,6 +216,9 @@ public ConcertDetailResponse getConcert(Long id, Long userId) { boolean isInCalendar = userId != null && userConcertCalendarRepository.existsByUserIdAndConcertId(userId, id); + RatingSummary ratingSummary = ratingService.getSummaries(RatingTargetType.CONCERT, List.of(id)) + .getOrDefault(id, EMPTY_RATING_SUMMARY); + return new ConcertDetailResponse( concert.getId(), concert.getPosterUrl(), @@ -224,7 +232,9 @@ public ConcertDetailResponse getConcert(Long id, Long userId) { concert.getPrice(), isInCalendar, concert.getTicketOpenAt(), - buildTicketLinks(id) + buildTicketLinks(id), + ratingSummary.averageRating(), + ratingSummary.ratingCount() ); } @@ -240,10 +250,12 @@ private List toConcertSummaryList(List concerts Map artistNameMap = buildArtistNameMap(allArtistIds); Map koreanNameMap = buildKoreanNameMap(List.copyOf(allArtistIds)); Set calendarConcertIds = buildCalendarConcertIds(userId, concertIds); + Map ratingSummaryMap = ratingService.getSummaries(RatingTargetType.CONCERT, concertIds); return concerts.stream().map(concert -> { List artists = concertToArtistIds.getOrDefault(concert.getId(), List.of()).stream() .map(artistId -> new ArtistSummary(artistId, artistNameMap.get(artistId), koreanNameMap.get(artistId))) .toList(); + RatingSummary ratingSummary = ratingSummaryMap.getOrDefault(concert.getId(), EMPTY_RATING_SUMMARY); return new ConcertSummaryResponse( concert.getId(), concert.getPosterUrl(), @@ -254,7 +266,9 @@ private List toConcertSummaryList(List concerts concert.getVenueName(), concert.getStatus(), calendarConcertIds.contains(concert.getId()), - concert.getTicketOpenAt() + concert.getTicketOpenAt(), + ratingSummary.averageRating(), + ratingSummary.ratingCount() ); }).toList(); } diff --git a/src/main/java/com/Coming/Backend/release/dto/ReleaseDetailResponse.java b/src/main/java/com/Coming/Backend/release/dto/ReleaseDetailResponse.java index 20ee93f..1409c5b 100644 --- a/src/main/java/com/Coming/Backend/release/dto/ReleaseDetailResponse.java +++ b/src/main/java/com/Coming/Backend/release/dto/ReleaseDetailResponse.java @@ -17,9 +17,12 @@ public record ReleaseDetailResponse( String artistName, String artistKoreanName, String spotifyId, - List tracks + List tracks, + Double averageRating, + long ratingCount ) { - public static ReleaseDetailResponse of(ReleaseGroup release, String artistName, String artistKoreanName, List tracks) { + public static ReleaseDetailResponse of(ReleaseGroup release, String artistName, String artistKoreanName, List tracks, + Double averageRating, long ratingCount) { return new ReleaseDetailResponse( release.getId(), release.getTitle(), @@ -32,7 +35,9 @@ public static ReleaseDetailResponse of(ReleaseGroup release, String artistName, artistName, artistKoreanName, release.getSpotifyId(), - tracks + tracks, + averageRating, + ratingCount ); } } diff --git a/src/main/java/com/Coming/Backend/release/dto/ReleaseListItemResponse.java b/src/main/java/com/Coming/Backend/release/dto/ReleaseListItemResponse.java index 47710fa..9632c59 100644 --- a/src/main/java/com/Coming/Backend/release/dto/ReleaseListItemResponse.java +++ b/src/main/java/com/Coming/Backend/release/dto/ReleaseListItemResponse.java @@ -12,9 +12,12 @@ public record ReleaseListItemResponse( String title, String type, LocalDate releaseDate, - String spotifyId + String spotifyId, + Double averageRating, + long ratingCount ) { - public static ReleaseListItemResponse of(ReleaseGroup release, String artistName, String artistKoreanName) { + public static ReleaseListItemResponse of(ReleaseGroup release, String artistName, String artistKoreanName, + Double averageRating, long ratingCount) { return new ReleaseListItemResponse( release.getId(), release.getCoverUrl(), @@ -23,7 +26,9 @@ public static ReleaseListItemResponse of(ReleaseGroup release, String artistName release.getTitle(), release.getType(), release.getFirstReleaseDate(), - release.getSpotifyId() + release.getSpotifyId(), + averageRating, + ratingCount ); } } diff --git a/src/main/java/com/Coming/Backend/release/service/ReleaseService.java b/src/main/java/com/Coming/Backend/release/service/ReleaseService.java index 6382318..767ed77 100644 --- a/src/main/java/com/Coming/Backend/release/service/ReleaseService.java +++ b/src/main/java/com/Coming/Backend/release/service/ReleaseService.java @@ -9,6 +9,9 @@ import com.Coming.Backend.artist.repository.UserFollowArtistRepository; import com.Coming.Backend.common.exception.InvalidInputException; import com.Coming.Backend.common.response.PageResponse; +import com.Coming.Backend.rating.dto.RatingSummary; +import com.Coming.Backend.rating.entity.RatingTargetType; +import com.Coming.Backend.rating.service.RatingService; import com.Coming.Backend.release.dto.ArtistReleaseItemResponse; import com.Coming.Backend.release.dto.ReleaseDetailResponse; import com.Coming.Backend.release.dto.ReleaseListItemResponse; @@ -40,12 +43,14 @@ public class ReleaseService { private static final List STANDARD_TYPES = List.of("Album", "Single"); + private static final RatingSummary EMPTY_RATING_SUMMARY = new RatingSummary(null, 0); private final ReleaseGroupRepository releaseGroupRepository; private final TrackRepository trackRepository; private final ArtistRepository artistRepository; private final ArtistAliasRepository artistAliasRepository; private final UserFollowArtistRepository userFollowArtistRepository; + private final RatingService ratingService; /** * 아티스트의 디스코그래피를 조회한다. types가 비어 있으면 전체 타입을 반환한다. @@ -102,10 +107,14 @@ public PageResponse getReleases(String q, Long artistId Map artistNameMap = artistRepository.findAllById(artistIds).stream() .collect(Collectors.toMap(Artist::getId, Artist::getName)); Map koreanNameMap = buildKoreanNameMap(List.copyOf(artistIds)); - - return PageResponse.from(page.map(release -> - ReleaseListItemResponse.of(release, artistNameMap.getOrDefault(release.getArtistId(), ""), koreanNameMap.get(release.getArtistId())) - )); + List releaseIds = page.stream().map(ReleaseGroup::getId).toList(); + Map ratingSummaryMap = ratingService.getSummaries(RatingTargetType.RELEASE, releaseIds); + + return PageResponse.from(page.map(release -> { + RatingSummary ratingSummary = ratingSummaryMap.getOrDefault(release.getId(), EMPTY_RATING_SUMMARY); + return ReleaseListItemResponse.of(release, artistNameMap.getOrDefault(release.getArtistId(), ""), koreanNameMap.get(release.getArtistId()), + ratingSummary.averageRating(), ratingSummary.ratingCount()); + })); } /** @@ -140,7 +149,11 @@ public ReleaseDetailResponse getReleaseDetail(Long id) { List tracks = trackRepository.findByReleaseGroupIdOrderByPosition(release.getId()) .stream().map(TrackDto::from).toList(); - return ReleaseDetailResponse.of(release, artistName, artistKoreanName, tracks); + RatingSummary ratingSummary = ratingService.getSummaries(RatingTargetType.RELEASE, List.of(release.getId())) + .getOrDefault(release.getId(), EMPTY_RATING_SUMMARY); + + return ReleaseDetailResponse.of(release, artistName, artistKoreanName, tracks, + ratingSummary.averageRating(), ratingSummary.ratingCount()); } private Map buildKoreanNameMap(List artistIds) { diff --git a/src/test/java/com/Coming/Backend/concert/controller/ConcertControllerTest.java b/src/test/java/com/Coming/Backend/concert/controller/ConcertControllerTest.java index 9577944..9aa6d30 100644 --- a/src/test/java/com/Coming/Backend/concert/controller/ConcertControllerTest.java +++ b/src/test/java/com/Coming/Backend/concert/controller/ConcertControllerTest.java @@ -81,7 +81,9 @@ private ConcertSummaryResponse buildSummary(Long id, String title, String artist "올림픽공원", ConcertStatus.UPCOMING, false, - null + null, + null, + 0 ); } diff --git a/src/test/java/com/Coming/Backend/concert/service/ConcertServiceTest.java b/src/test/java/com/Coming/Backend/concert/service/ConcertServiceTest.java index fba15e2..97ca2ae 100644 --- a/src/test/java/com/Coming/Backend/concert/service/ConcertServiceTest.java +++ b/src/test/java/com/Coming/Backend/concert/service/ConcertServiceTest.java @@ -8,6 +8,7 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -38,12 +39,17 @@ import com.Coming.Backend.concert.repository.ConcertBookingLinkRepository; import com.Coming.Backend.concert.repository.ConcertImageRepository; import com.Coming.Backend.concert.repository.ConcertRepository; +import com.Coming.Backend.rating.dto.RatingSummary; +import com.Coming.Backend.rating.entity.RatingTargetType; +import com.Coming.Backend.rating.service.RatingService; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; +import java.util.Map; import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -90,11 +96,21 @@ class ConcertServiceTest { @Mock private SetlistTrackRepository setlistTrackRepository; + @Mock + private RatingService ratingService; + private static final Long CONCERT_ID = 1L; private static final Long ARTIST_ID = 10L; private static final Long USER_ID = 100L; private static final Pageable PAGEABLE = PageRequest.of(0, 20); + @BeforeEach + void setUp() { + // 평점 집계는 대부분의 테스트와 무관하므로 기본값(빈 맵)을 lenient로 스텁한다. + // 평점 매핑을 직접 검증하는 테스트는 개별적으로 given()을 재정의한다. + lenient().when(ratingService.getSummaries(any(), any())).thenReturn(Map.of()); + } + private Concert buildConcert(Long id, ConcertStatus status) { return Concert.builder() .id(id) @@ -223,6 +239,28 @@ void should_return_is_in_calendar_true_for_popular_concerts_when_authenticated_u assertThat(result.get(0).isInCalendar()).isTrue(); } + @Test + void should_map_rating_summary_per_concert_and_use_default_when_only_some_concerts_have_ratings() { + // given + Concert rated = buildConcert(1L, ConcertStatus.UPCOMING); + Concert unrated = buildConcert(2L, ConcertStatus.UPCOMING); + RatingSummary ratingSummary = new RatingSummary(4.0, 5L); + + given(concertRepository.findTop10Popular(anyList())).willReturn(List.of(rated, unrated)); + given(concertArtistRepository.findByConcertIdIn(List.of(1L, 2L))).willReturn(List.of()); + given(ratingService.getSummaries(RatingTargetType.CONCERT, List.of(1L, 2L))) + .willReturn(Map.of(1L, ratingSummary)); + + // when + List result = concertService.getPopularConcerts(null); + + // then + assertThat(result.get(0).averageRating()).isEqualTo(4.0); + assertThat(result.get(0).ratingCount()).isEqualTo(5L); + assertThat(result.get(1).averageRating()).isNull(); + assertThat(result.get(1).ratingCount()).isZero(); + } + @Test void should_not_include_excluded_concerts_in_popular_list() { // given @@ -654,6 +692,49 @@ void should_throw_concert_not_found_when_concert_is_excluded() { .hasMessage(ErrorCode.CONCERT_NOT_FOUND.getMessage()); } + // ------------------------------------------------------------------------- + // getConcert — 평점 집계 매핑 + // ------------------------------------------------------------------------- + + @Test + void should_return_rating_summary_when_concert_has_ratings() { + // given + Concert concert = buildConcert(CONCERT_ID, ConcertStatus.UPCOMING); + RatingSummary ratingSummary = new RatingSummary(4.5, 12L); + + given(concertRepository.findById(CONCERT_ID)).willReturn(Optional.of(concert)); + given(concertArtistRepository.findByConcertId(CONCERT_ID)).willReturn(List.of()); + given(concertBookingLinkRepository.findByConcertId(CONCERT_ID)).willReturn(List.of()); + given(ratingService.getSummaries(RatingTargetType.CONCERT, List.of(CONCERT_ID))) + .willReturn(Map.of(CONCERT_ID, ratingSummary)); + + // when + ConcertDetailResponse response = concertService.getConcert(CONCERT_ID, null); + + // then + assertThat(response.averageRating()).isEqualTo(4.5); + assertThat(response.ratingCount()).isEqualTo(12L); + } + + @Test + void should_return_null_average_rating_and_zero_rating_count_when_concert_has_no_ratings() { + // given + Concert concert = buildConcert(CONCERT_ID, ConcertStatus.UPCOMING); + + given(concertRepository.findById(CONCERT_ID)).willReturn(Optional.of(concert)); + given(concertArtistRepository.findByConcertId(CONCERT_ID)).willReturn(List.of()); + given(concertBookingLinkRepository.findByConcertId(CONCERT_ID)).willReturn(List.of()); + given(ratingService.getSummaries(RatingTargetType.CONCERT, List.of(CONCERT_ID))) + .willReturn(Map.of()); + + // when + ConcertDetailResponse response = concertService.getConcert(CONCERT_ID, null); + + // then + assertThat(response.averageRating()).isNull(); + assertThat(response.ratingCount()).isZero(); + } + // ------------------------------------------------------------------------- // getSetlist // ------------------------------------------------------------------------- diff --git a/src/test/java/com/Coming/Backend/release/controller/ReleaseControllerTest.java b/src/test/java/com/Coming/Backend/release/controller/ReleaseControllerTest.java index 68d1801..3f6d219 100644 --- a/src/test/java/com/Coming/Backend/release/controller/ReleaseControllerTest.java +++ b/src/test/java/com/Coming/Backend/release/controller/ReleaseControllerTest.java @@ -78,7 +78,7 @@ void should_return_200_with_all_releases_when_no_filters_given() throws Exceptio // given ReleaseListItemResponse item = new ReleaseListItemResponse( RELEASE_ID, "https://cover.example.com/10", "IU", null, - "LILAC", "Album", LocalDate.of(2021, 3, 25), null + "LILAC", "Album", LocalDate.of(2021, 3, 25), null, null, 0 ); PageResponse pageResponse = new PageResponse<>(List.of(item), 0, 20, 1, 1); @@ -103,7 +103,7 @@ void should_pass_query_param_to_service_when_q_given() throws Exception { // given ReleaseListItemResponse item = new ReleaseListItemResponse( RELEASE_ID, "https://cover.example.com/10", "IU", null, - "LILAC", "Album", LocalDate.of(2021, 3, 25), null + "LILAC", "Album", LocalDate.of(2021, 3, 25), null, null, 0 ); PageResponse pageResponse = new PageResponse<>(List.of(item), 0, 20, 1, 1); @@ -124,7 +124,7 @@ void should_pass_artist_id_to_service_when_artist_id_given() throws Exception { // given ReleaseListItemResponse item = new ReleaseListItemResponse( RELEASE_ID, "https://cover.example.com/10", "IU", null, - "LILAC", "Single", LocalDate.of(2021, 3, 25), null + "LILAC", "Single", LocalDate.of(2021, 3, 25), null, null, 0 ); PageResponse pageResponse = new PageResponse<>(List.of(item), 0, 20, 1, 1); @@ -144,7 +144,7 @@ void should_pass_type_to_service_when_type_given() throws Exception { // given ReleaseListItemResponse item = new ReleaseListItemResponse( RELEASE_ID, "https://cover.example.com/10", "IU", null, - "LILAC", "Album", LocalDate.of(2021, 3, 25), null + "LILAC", "Album", LocalDate.of(2021, 3, 25), null, null, 0 ); PageResponse pageResponse = new PageResponse<>(List.of(item), 0, 20, 1, 1); @@ -180,7 +180,7 @@ void should_pass_following_true_to_service_when_following_param_given() throws E // given ReleaseListItemResponse item = new ReleaseListItemResponse( RELEASE_ID, "https://cover.example.com/10", "IU", null, - "LILAC", "Album", LocalDate.of(2021, 3, 25), null + "LILAC", "Album", LocalDate.of(2021, 3, 25), null, null, 0 ); PageResponse pageResponse = new PageResponse<>(List.of(item), 0, 20, 1, 1); @@ -202,7 +202,7 @@ void should_pass_all_filters_to_service_when_q_artist_id_and_type_given_together // given ReleaseListItemResponse item = new ReleaseListItemResponse( RELEASE_ID, "https://cover.example.com/10", "IU", null, - "LILAC", "Album", LocalDate.of(2021, 3, 25), null + "LILAC", "Album", LocalDate.of(2021, 3, 25), null, null, 0 ); PageResponse pageResponse = new PageResponse<>(List.of(item), 0, 20, 1, 1); @@ -249,7 +249,7 @@ void should_return_200_with_release_detail_when_release_exists() throws Exceptio ReleaseDetailResponse detail = new ReleaseDetailResponse( RELEASE_ID, "LILAC", "Album", LocalDate.of(2021, 3, 25), "https://cover.example.com/10", "KAKAO M", 2, - ARTIST_ID, "IU", null, null, List.of(track1, track2) + ARTIST_ID, "IU", null, null, List.of(track1, track2), null, 0 ); given(releaseService.getReleaseDetail(eq(RELEASE_ID))).willReturn(detail); diff --git a/src/test/java/com/Coming/Backend/release/service/ReleaseServiceTest.java b/src/test/java/com/Coming/Backend/release/service/ReleaseServiceTest.java index a07b7f9..91341a2 100644 --- a/src/test/java/com/Coming/Backend/release/service/ReleaseServiceTest.java +++ b/src/test/java/com/Coming/Backend/release/service/ReleaseServiceTest.java @@ -6,6 +6,7 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -18,6 +19,9 @@ import com.Coming.Backend.common.exception.ErrorCode; import com.Coming.Backend.common.exception.InvalidInputException; import com.Coming.Backend.common.response.PageResponse; +import com.Coming.Backend.rating.dto.RatingSummary; +import com.Coming.Backend.rating.entity.RatingTargetType; +import com.Coming.Backend.rating.service.RatingService; import com.Coming.Backend.release.dto.ArtistReleaseItemResponse; import com.Coming.Backend.release.dto.ReleaseDetailResponse; import com.Coming.Backend.release.dto.ReleaseListItemResponse; @@ -30,9 +34,11 @@ import java.time.LocalDate; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.Optional; import java.util.Set; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -64,11 +70,21 @@ class ReleaseServiceTest { @Mock private UserFollowArtistRepository userFollowArtistRepository; + @Mock + private RatingService ratingService; + private static final Long ARTIST_ID = 1L; private static final Long USER_ID = 100L; private static final Long RELEASE_ID = 10L; private static final Pageable PAGEABLE = PageRequest.of(0, 20); + @BeforeEach + void setUp() { + // 평점 집계는 대부분의 테스트와 무관하므로 기본값(빈 맵)을 lenient로 스텁한다. + // 평점 매핑을 직접 검증하는 테스트는 개별적으로 given()을 재정의한다. + lenient().when(ratingService.getSummaries(any(), any())).thenReturn(Map.of()); + } + private ReleaseGroup buildRelease(Long id, Long artistId, String type) { return ReleaseGroup.builder() .id(id) @@ -341,6 +357,33 @@ void should_combine_q_artist_id_and_type_filters_when_all_given() { eq(ARTIST_ID), isNull(), eq("Album"), eq("%lilac%"), any(Pageable.class)); } + @Test + void should_map_rating_summary_per_release_and_use_default_when_only_some_releases_have_ratings() { + // given + Long otherReleaseId = 20L; + ReleaseGroup rated = buildRelease(RELEASE_ID, ARTIST_ID, "Album"); + ReleaseGroup unrated = buildRelease(otherReleaseId, ARTIST_ID, "Single"); + Artist artist = buildArtist(ARTIST_ID, "IU"); + RatingSummary ratingSummary = new RatingSummary(4.5, 3L); + Page page = new PageImpl<>(List.of(rated, unrated), PAGEABLE, 2); + given(releaseGroupRepository.searchReleases( + isNull(), isNull(), isNull(), isNull(), any(Pageable.class))) + .willReturn(page); + given(artistRepository.findAllById(Set.of(ARTIST_ID))).willReturn(List.of(artist)); + given(ratingService.getSummaries(RatingTargetType.RELEASE, List.of(RELEASE_ID, otherReleaseId))) + .willReturn(Map.of(RELEASE_ID, ratingSummary)); + + // when + PageResponse response = + releaseService.getReleases(null, null, null, null, false, PAGEABLE); + + // then + assertThat(response.content().get(0).averageRating()).isEqualTo(4.5); + assertThat(response.content().get(0).ratingCount()).isEqualTo(3L); + assertThat(response.content().get(1).averageRating()).isNull(); + assertThat(response.content().get(1).ratingCount()).isZero(); + } + // ------------------------------------------------------------------------- // getReleases — following=true // ------------------------------------------------------------------------- @@ -429,6 +472,45 @@ void should_return_release_detail_with_tracks_when_release_exists() { assertThat(response.tracks().get(0).title()).isEqualTo("트랙 1"); } + @Test + void should_return_rating_summary_when_release_has_ratings() { + // given + ReleaseGroup release = buildRelease(RELEASE_ID, ARTIST_ID, "ALBUM"); + Artist artist = buildArtist(ARTIST_ID, "IU"); + RatingSummary ratingSummary = new RatingSummary(3.5, 8L); + given(releaseGroupRepository.findById(RELEASE_ID)).willReturn(Optional.of(release)); + given(artistRepository.findById(ARTIST_ID)).willReturn(Optional.of(artist)); + given(trackRepository.findByReleaseGroupIdOrderByPosition(RELEASE_ID)).willReturn(List.of()); + given(ratingService.getSummaries(RatingTargetType.RELEASE, List.of(RELEASE_ID))) + .willReturn(Map.of(RELEASE_ID, ratingSummary)); + + // when + ReleaseDetailResponse response = releaseService.getReleaseDetail(RELEASE_ID); + + // then + assertThat(response.averageRating()).isEqualTo(3.5); + assertThat(response.ratingCount()).isEqualTo(8L); + } + + @Test + void should_return_null_average_rating_and_zero_rating_count_when_release_has_no_ratings() { + // given + ReleaseGroup release = buildRelease(RELEASE_ID, ARTIST_ID, "ALBUM"); + Artist artist = buildArtist(ARTIST_ID, "IU"); + given(releaseGroupRepository.findById(RELEASE_ID)).willReturn(Optional.of(release)); + given(artistRepository.findById(ARTIST_ID)).willReturn(Optional.of(artist)); + given(trackRepository.findByReleaseGroupIdOrderByPosition(RELEASE_ID)).willReturn(List.of()); + given(ratingService.getSummaries(RatingTargetType.RELEASE, List.of(RELEASE_ID))) + .willReturn(Map.of()); + + // when + ReleaseDetailResponse response = releaseService.getReleaseDetail(RELEASE_ID); + + // then + assertThat(response.averageRating()).isNull(); + assertThat(response.ratingCount()).isZero(); + } + @Test void should_throw_release_not_found_when_release_does_not_exist() { // given From dae1adcda058dbcef8cfc256444d2008174642f5 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Sun, 20 Sep 2026 23:24:03 +0900 Subject: [PATCH 16/19] =?UTF-8?q?[style]=20be-review=C2=B7simplify=20?= =?UTF-8?q?=EB=B0=98=EC=98=81=20(=ED=8F=AC=EB=A7=B7=C2=B7=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EC=A0=9C=EA=B1=B0=C2=B7=EB=B3=B4=EC=95=88=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EB=B3=B4=EA=B0=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 100자 초과 라인 정리 - rating.exception.UnauthorizedException 중복 제거, concert 것 재사용 - EMPTY_RATING_SUMMARY 중복 제거, RatingSummary.empty() 팩토리로 통합 - SecurityConfig에 GET .../rating/me 인증 carve-out 추가 Co-Authored-By: Claude Sonnet 5 --- .../Backend/common/config/SecurityConfig.java | 4 ++++ .../Backend/concert/service/ConcertService.java | 12 +++++++----- .../Coming/Backend/rating/dto/RatingSummary.java | 3 +++ .../rating/exception/UnauthorizedException.java | 11 ----------- .../rating/repository/RatingRepository.java | 6 ++++-- .../Backend/rating/service/RatingService.java | 8 +++++--- .../Backend/release/service/ReleaseService.java | 16 ++++++++++------ .../rating/service/RatingServiceTest.java | 2 +- 8 files changed, 34 insertions(+), 28 deletions(-) delete mode 100644 src/main/java/com/Coming/Backend/rating/exception/UnauthorizedException.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 dbc2173..87c1b99 100644 --- a/src/main/java/com/Coming/Backend/common/config/SecurityConfig.java +++ b/src/main/java/com/Coming/Backend/common/config/SecurityConfig.java @@ -114,6 +114,10 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/actuator/health" ).permitAll() .requestMatchers(HttpMethod.GET, "/api/artists/following").hasAnyRole("USER", "ADMIN") + .requestMatchers(HttpMethod.GET, + "/api/concerts/*/rating/me", + "/api/releases/*/rating/me" + ).hasAnyRole("USER", "ADMIN") .requestMatchers(HttpMethod.GET, "/api/artists/**", "/api/concerts/**", diff --git a/src/main/java/com/Coming/Backend/concert/service/ConcertService.java b/src/main/java/com/Coming/Backend/concert/service/ConcertService.java index 5f7127e..62f8278 100644 --- a/src/main/java/com/Coming/Backend/concert/service/ConcertService.java +++ b/src/main/java/com/Coming/Backend/concert/service/ConcertService.java @@ -56,7 +56,6 @@ public class ConcertService { private static final List HIDDEN_STATUSES = List.of(EXCLUDED, PENDING); - private static final RatingSummary EMPTY_RATING_SUMMARY = new RatingSummary(null, 0); private final ConcertRepository concertRepository; private final ConcertArtistRepository concertArtistRepository; @@ -216,8 +215,9 @@ public ConcertDetailResponse getConcert(Long id, Long userId) { boolean isInCalendar = userId != null && userConcertCalendarRepository.existsByUserIdAndConcertId(userId, id); - RatingSummary ratingSummary = ratingService.getSummaries(RatingTargetType.CONCERT, List.of(id)) - .getOrDefault(id, EMPTY_RATING_SUMMARY); + RatingSummary ratingSummary = ratingService + .getSummaries(RatingTargetType.CONCERT, List.of(id)) + .getOrDefault(id, RatingSummary.empty()); return new ConcertDetailResponse( concert.getId(), @@ -250,12 +250,14 @@ private List toConcertSummaryList(List concerts Map artistNameMap = buildArtistNameMap(allArtistIds); Map koreanNameMap = buildKoreanNameMap(List.copyOf(allArtistIds)); Set calendarConcertIds = buildCalendarConcertIds(userId, concertIds); - Map ratingSummaryMap = ratingService.getSummaries(RatingTargetType.CONCERT, concertIds); + Map ratingSummaryMap = + ratingService.getSummaries(RatingTargetType.CONCERT, concertIds); return concerts.stream().map(concert -> { List artists = concertToArtistIds.getOrDefault(concert.getId(), List.of()).stream() .map(artistId -> new ArtistSummary(artistId, artistNameMap.get(artistId), koreanNameMap.get(artistId))) .toList(); - RatingSummary ratingSummary = ratingSummaryMap.getOrDefault(concert.getId(), EMPTY_RATING_SUMMARY); + RatingSummary ratingSummary = + ratingSummaryMap.getOrDefault(concert.getId(), RatingSummary.empty()); return new ConcertSummaryResponse( concert.getId(), concert.getPosterUrl(), diff --git a/src/main/java/com/Coming/Backend/rating/dto/RatingSummary.java b/src/main/java/com/Coming/Backend/rating/dto/RatingSummary.java index 2e21546..04235c8 100644 --- a/src/main/java/com/Coming/Backend/rating/dto/RatingSummary.java +++ b/src/main/java/com/Coming/Backend/rating/dto/RatingSummary.java @@ -4,4 +4,7 @@ public record RatingSummary( Double averageRating, long ratingCount ) { + public static RatingSummary empty() { + return new RatingSummary(null, 0); + } } diff --git a/src/main/java/com/Coming/Backend/rating/exception/UnauthorizedException.java b/src/main/java/com/Coming/Backend/rating/exception/UnauthorizedException.java deleted file mode 100644 index 8379e9b..0000000 --- a/src/main/java/com/Coming/Backend/rating/exception/UnauthorizedException.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.Coming.Backend.rating.exception; - -import com.Coming.Backend.common.exception.BusinessException; -import com.Coming.Backend.common.exception.ErrorCode; - -public class UnauthorizedException extends BusinessException { - - public UnauthorizedException() { - super(ErrorCode.UNAUTHORIZED); - } -} diff --git a/src/main/java/com/Coming/Backend/rating/repository/RatingRepository.java b/src/main/java/com/Coming/Backend/rating/repository/RatingRepository.java index 849310a..ffedb9b 100644 --- a/src/main/java/com/Coming/Backend/rating/repository/RatingRepository.java +++ b/src/main/java/com/Coming/Backend/rating/repository/RatingRepository.java @@ -12,10 +12,12 @@ public interface RatingRepository extends JpaRepository { - Optional findByUserIdAndTargetTypeAndTargetId(Long userId, RatingTargetType targetType, Long targetId); + Optional findByUserIdAndTargetTypeAndTargetId( + Long userId, RatingTargetType targetType, Long targetId); @Query("SELECT r.targetId AS targetId, AVG(r.score) AS averageScore, COUNT(r) AS ratingCount " + - "FROM Rating r WHERE r.targetType = :targetType AND r.targetId IN :targetIds GROUP BY r.targetId") + "FROM Rating r WHERE r.targetType = :targetType " + + "AND r.targetId IN :targetIds GROUP BY r.targetId") List aggregateByTargetIds(@Param("targetType") RatingTargetType targetType, @Param("targetIds") Collection targetIds); diff --git a/src/main/java/com/Coming/Backend/rating/service/RatingService.java b/src/main/java/com/Coming/Backend/rating/service/RatingService.java index 5dab15f..8240dc1 100644 --- a/src/main/java/com/Coming/Backend/rating/service/RatingService.java +++ b/src/main/java/com/Coming/Backend/rating/service/RatingService.java @@ -1,5 +1,6 @@ package com.Coming.Backend.rating.service; +import com.Coming.Backend.concert.exception.UnauthorizedException; import com.Coming.Backend.concert.repository.ConcertRepository; import com.Coming.Backend.rating.dto.RatingMeResponse; import com.Coming.Backend.rating.dto.RatingSummary; @@ -8,7 +9,6 @@ import com.Coming.Backend.rating.exception.InvalidRatingScoreException; import com.Coming.Backend.rating.exception.RatingNotFoundException; import com.Coming.Backend.rating.exception.RatingTargetNotFoundException; -import com.Coming.Backend.rating.exception.UnauthorizedException; import com.Coming.Backend.rating.repository.RatingRepository; import com.Coming.Backend.release.repository.ReleaseGroupRepository; import lombok.RequiredArgsConstructor; @@ -79,7 +79,8 @@ public void delete(Long userId, RatingTargetType targetType, Long targetId) { if (userId == null) { throw new UnauthorizedException(); } - Rating rating = ratingRepository.findByUserIdAndTargetTypeAndTargetId(userId, targetType, targetId) + Rating rating = ratingRepository + .findByUserIdAndTargetTypeAndTargetId(userId, targetType, targetId) .orElseThrow(RatingNotFoundException::new); ratingRepository.delete(rating); } @@ -87,7 +88,8 @@ public void delete(Long userId, RatingTargetType targetType, Long targetId) { /** * 대상 ID 목록에 대한 평균 별점·평가 개수를 조회한다. 별점이 없는 대상은 결과 맵에 포함되지 않는다. */ - public Map getSummaries(RatingTargetType targetType, Collection targetIds) { + public Map getSummaries(RatingTargetType targetType, + Collection targetIds) { if (targetIds.isEmpty()) { return Map.of(); } diff --git a/src/main/java/com/Coming/Backend/release/service/ReleaseService.java b/src/main/java/com/Coming/Backend/release/service/ReleaseService.java index 767ed77..4a75042 100644 --- a/src/main/java/com/Coming/Backend/release/service/ReleaseService.java +++ b/src/main/java/com/Coming/Backend/release/service/ReleaseService.java @@ -43,7 +43,6 @@ public class ReleaseService { private static final List STANDARD_TYPES = List.of("Album", "Single"); - private static final RatingSummary EMPTY_RATING_SUMMARY = new RatingSummary(null, 0); private final ReleaseGroupRepository releaseGroupRepository; private final TrackRepository trackRepository; @@ -108,11 +107,15 @@ public PageResponse getReleases(String q, Long artistId .collect(Collectors.toMap(Artist::getId, Artist::getName)); Map koreanNameMap = buildKoreanNameMap(List.copyOf(artistIds)); List releaseIds = page.stream().map(ReleaseGroup::getId).toList(); - Map ratingSummaryMap = ratingService.getSummaries(RatingTargetType.RELEASE, releaseIds); + Map ratingSummaryMap = + ratingService.getSummaries(RatingTargetType.RELEASE, releaseIds); return PageResponse.from(page.map(release -> { - RatingSummary ratingSummary = ratingSummaryMap.getOrDefault(release.getId(), EMPTY_RATING_SUMMARY); - return ReleaseListItemResponse.of(release, artistNameMap.getOrDefault(release.getArtistId(), ""), koreanNameMap.get(release.getArtistId()), + RatingSummary ratingSummary = + ratingSummaryMap.getOrDefault(release.getId(), RatingSummary.empty()); + return ReleaseListItemResponse.of(release, + artistNameMap.getOrDefault(release.getArtistId(), ""), + koreanNameMap.get(release.getArtistId()), ratingSummary.averageRating(), ratingSummary.ratingCount()); })); } @@ -149,8 +152,9 @@ public ReleaseDetailResponse getReleaseDetail(Long id) { List tracks = trackRepository.findByReleaseGroupIdOrderByPosition(release.getId()) .stream().map(TrackDto::from).toList(); - RatingSummary ratingSummary = ratingService.getSummaries(RatingTargetType.RELEASE, List.of(release.getId())) - .getOrDefault(release.getId(), EMPTY_RATING_SUMMARY); + RatingSummary ratingSummary = ratingService + .getSummaries(RatingTargetType.RELEASE, List.of(release.getId())) + .getOrDefault(release.getId(), RatingSummary.empty()); return ReleaseDetailResponse.of(release, artistName, artistKoreanName, tracks, ratingSummary.averageRating(), ratingSummary.ratingCount()); diff --git a/src/test/java/com/Coming/Backend/rating/service/RatingServiceTest.java b/src/test/java/com/Coming/Backend/rating/service/RatingServiceTest.java index f81fff9..e1655d0 100644 --- a/src/test/java/com/Coming/Backend/rating/service/RatingServiceTest.java +++ b/src/test/java/com/Coming/Backend/rating/service/RatingServiceTest.java @@ -1,6 +1,7 @@ package com.Coming.Backend.rating.service; import com.Coming.Backend.common.exception.ErrorCode; +import com.Coming.Backend.concert.exception.UnauthorizedException; import com.Coming.Backend.concert.repository.ConcertRepository; import com.Coming.Backend.rating.dto.RatingMeResponse; import com.Coming.Backend.rating.dto.RatingSummary; @@ -9,7 +10,6 @@ import com.Coming.Backend.rating.exception.InvalidRatingScoreException; import com.Coming.Backend.rating.exception.RatingNotFoundException; import com.Coming.Backend.rating.exception.RatingTargetNotFoundException; -import com.Coming.Backend.rating.exception.UnauthorizedException; import com.Coming.Backend.rating.repository.RatingRepository; import com.Coming.Backend.release.repository.ReleaseGroupRepository; import org.junit.jupiter.api.Test; From 4bfb4c7d5e0d599c82f08bebe254e93b0a819e8f Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Sun, 20 Sep 2026 23:54:17 +0900 Subject: [PATCH 17/19] =?UTF-8?q?[feat]=20=EA=B3=B5=EC=97=B0=20=EB=B3=84?= =?UTF-8?q?=EC=A0=90=20=EB=93=B1=EB=A1=9D=EC=97=90=20ENDED=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FE에서 공연 상태가 ENDED일 때만 별점 입력 UI를 노출하도록 정책을 변경함에 따라, 서버 측에서도 concert.status가 ENDED가 아니면 별점 등록·수정을 거부하도록 검증을 추가했다. release 별점은 정책 변경 대상이 아니므로 기존과 동일하게 상태 검증 없이 항상 허용한다. Co-Authored-By: Claude Sonnet 5 --- .../Backend/common/exception/ErrorCode.java | 1 + .../exception/ConcertNotEndedException.java | 11 +++++ .../Backend/rating/service/RatingService.java | 30 +++++++++---- .../rating/service/RatingServiceTest.java | 45 +++++++++++++++++-- 4 files changed, 75 insertions(+), 12 deletions(-) create mode 100644 src/main/java/com/Coming/Backend/concert/exception/ConcertNotEndedException.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 0565c79..13255f5 100644 --- a/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java +++ b/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java @@ -39,6 +39,7 @@ public enum ErrorCode { ALREADY_IN_CALENDAR(HttpStatus.CONFLICT, "이미 캘린더에 추가된 공연입니다."), NOT_IN_CALENDAR(HttpStatus.BAD_REQUEST, "캘린더에 없는 공연입니다."), CONCERT_ALREADY_EXISTS(HttpStatus.CONFLICT, "이미 존재하는 공연입니다."), + CONCERT_NOT_ENDED(HttpStatus.BAD_REQUEST, "공연 종료 후 별점을 등록할 수 있습니다."), // Release RELEASE_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 릴리즈입니다."), diff --git a/src/main/java/com/Coming/Backend/concert/exception/ConcertNotEndedException.java b/src/main/java/com/Coming/Backend/concert/exception/ConcertNotEndedException.java new file mode 100644 index 0000000..18d00f2 --- /dev/null +++ b/src/main/java/com/Coming/Backend/concert/exception/ConcertNotEndedException.java @@ -0,0 +1,11 @@ +package com.Coming.Backend.concert.exception; + +import com.Coming.Backend.common.exception.BusinessException; +import com.Coming.Backend.common.exception.ErrorCode; + +public class ConcertNotEndedException extends BusinessException { + + public ConcertNotEndedException() { + super(ErrorCode.CONCERT_NOT_ENDED); + } +} diff --git a/src/main/java/com/Coming/Backend/rating/service/RatingService.java b/src/main/java/com/Coming/Backend/rating/service/RatingService.java index 8240dc1..31b1d7a 100644 --- a/src/main/java/com/Coming/Backend/rating/service/RatingService.java +++ b/src/main/java/com/Coming/Backend/rating/service/RatingService.java @@ -1,5 +1,8 @@ package com.Coming.Backend.rating.service; +import com.Coming.Backend.concert.entity.Concert; +import com.Coming.Backend.concert.entity.ConcertStatus; +import com.Coming.Backend.concert.exception.ConcertNotEndedException; import com.Coming.Backend.concert.exception.UnauthorizedException; import com.Coming.Backend.concert.repository.ConcertRepository; import com.Coming.Backend.rating.dto.RatingMeResponse; @@ -33,7 +36,8 @@ public class RatingService { /** * 별점을 등록하거나 수정한다. 대상이 존재하지 않으면 RatingTargetNotFoundException, - * score가 0.5 단위가 아니면 InvalidRatingScoreException을 던진다. + * score가 0.5 단위가 아니면 InvalidRatingScoreException, + * 공연이 ENDED 상태가 아니면 ConcertNotEndedException을 던진다. */ @Transactional public void upsert(Long userId, RatingTargetType targetType, Long targetId, BigDecimal score) { @@ -43,9 +47,7 @@ public void upsert(Long userId, RatingTargetType targetType, Long targetId, BigD if (score.remainder(SCORE_STEP).compareTo(BigDecimal.ZERO) != 0) { throw new InvalidRatingScoreException(); } - if (!targetExists(targetType, targetId)) { - throw new RatingTargetNotFoundException(); - } + validateTarget(targetType, targetId); ratingRepository.findByUserIdAndTargetTypeAndTargetId(userId, targetType, targetId) .ifPresentOrElse( @@ -102,10 +104,20 @@ public Map getSummaries(RatingTargetType targetType, )); } - private boolean targetExists(RatingTargetType targetType, Long targetId) { - return switch (targetType) { - case CONCERT -> concertRepository.existsById(targetId); - case RELEASE -> releaseGroupRepository.existsById(targetId); - }; + private void validateTarget(RatingTargetType targetType, Long targetId) { + switch (targetType) { + case CONCERT -> { + Concert concert = concertRepository.findById(targetId) + .orElseThrow(RatingTargetNotFoundException::new); + if (concert.getStatus() != ConcertStatus.ENDED) { + throw new ConcertNotEndedException(); + } + } + case RELEASE -> { + if (!releaseGroupRepository.existsById(targetId)) { + throw new RatingTargetNotFoundException(); + } + } + } } } diff --git a/src/test/java/com/Coming/Backend/rating/service/RatingServiceTest.java b/src/test/java/com/Coming/Backend/rating/service/RatingServiceTest.java index e1655d0..2c29edc 100644 --- a/src/test/java/com/Coming/Backend/rating/service/RatingServiceTest.java +++ b/src/test/java/com/Coming/Backend/rating/service/RatingServiceTest.java @@ -1,6 +1,9 @@ package com.Coming.Backend.rating.service; import com.Coming.Backend.common.exception.ErrorCode; +import com.Coming.Backend.concert.entity.Concert; +import com.Coming.Backend.concert.entity.ConcertStatus; +import com.Coming.Backend.concert.exception.ConcertNotEndedException; import com.Coming.Backend.concert.exception.UnauthorizedException; import com.Coming.Backend.concert.repository.ConcertRepository; import com.Coming.Backend.rating.dto.RatingMeResponse; @@ -77,7 +80,7 @@ void should_throw_invalid_rating_score_exception_when_score_is_not_half_step() { @Test void should_throw_rating_target_not_found_exception_when_concert_does_not_exist() { // given - given(concertRepository.existsById(CONCERT_ID)).willReturn(false); + given(concertRepository.findById(CONCERT_ID)).willReturn(Optional.empty()); // when & then assertThatThrownBy(() -> ratingService.upsert(USER_ID, RatingTargetType.CONCERT, CONCERT_ID, BigDecimal.valueOf(4.5))) @@ -85,11 +88,28 @@ void should_throw_rating_target_not_found_exception_when_concert_does_not_exist( .hasMessage(ErrorCode.RATING_TARGET_NOT_FOUND.getMessage()); } + @Test + void should_throw_concert_not_ended_exception_when_concert_status_is_not_ended() { + // given + Concert concert = Concert.builder() + .status(ConcertStatus.UPCOMING) + .build(); + given(concertRepository.findById(CONCERT_ID)).willReturn(Optional.of(concert)); + + // when & then + assertThatThrownBy(() -> ratingService.upsert(USER_ID, RatingTargetType.CONCERT, CONCERT_ID, BigDecimal.valueOf(4.5))) + .isInstanceOf(ConcertNotEndedException.class) + .hasMessage(ErrorCode.CONCERT_NOT_ENDED.getMessage()); + } + @Test void should_save_new_rating_when_no_existing_rating_found() { // given BigDecimal score = BigDecimal.valueOf(4.5); - given(concertRepository.existsById(CONCERT_ID)).willReturn(true); + Concert concert = Concert.builder() + .status(ConcertStatus.ENDED) + .build(); + given(concertRepository.findById(CONCERT_ID)).willReturn(Optional.of(concert)); given(ratingRepository.findByUserIdAndTargetTypeAndTargetId(USER_ID, RatingTargetType.CONCERT, CONCERT_ID)) .willReturn(Optional.empty()); @@ -109,7 +129,10 @@ void should_update_existing_rating_score_when_rating_already_exists() { .targetId(CONCERT_ID) .score(BigDecimal.valueOf(2.0)) .build(); - given(concertRepository.existsById(CONCERT_ID)).willReturn(true); + Concert concert = Concert.builder() + .status(ConcertStatus.ENDED) + .build(); + given(concertRepository.findById(CONCERT_ID)).willReturn(Optional.of(concert)); given(ratingRepository.findByUserIdAndTargetTypeAndTargetId(USER_ID, RatingTargetType.CONCERT, CONCERT_ID)) .willReturn(Optional.of(existingRating)); @@ -121,6 +144,22 @@ void should_update_existing_rating_score_when_rating_already_exists() { verify(ratingRepository, never()).save(any(Rating.class)); } + @Test + void should_save_new_rating_when_release_target_exists() { + // given + Long releaseId = 20L; + BigDecimal score = BigDecimal.valueOf(4.0); + given(releaseGroupRepository.existsById(releaseId)).willReturn(true); + given(ratingRepository.findByUserIdAndTargetTypeAndTargetId(USER_ID, RatingTargetType.RELEASE, releaseId)) + .willReturn(Optional.empty()); + + // when + ratingService.upsert(USER_ID, RatingTargetType.RELEASE, releaseId, score); + + // then + verify(ratingRepository).save(any(Rating.class)); + } + // ------------------------------------------------------------------------- // getMine // ------------------------------------------------------------------------- From 91024f77721de1cd32f816202a5827841ccf1c24 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Mon, 21 Sep 2026 17:50:11 +0900 Subject: [PATCH 18/19] =?UTF-8?q?[fix]=20=EB=B3=84=EC=A0=90=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20API=EC=97=90=20CodeRabbit=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EC=A7=80=EC=A0=81=EC=82=AC=ED=95=AD=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit score 범위 검증(0.5~5.0)이 서비스 계층에서 빠져있어 컨트롤러 우회 시 잘못된 값이 저장될 수 있는 문제와, 조회 후 삽입 방식의 upsert가 동시 첫 등록 요청에서 유니크 제약 위반으로 500을 반환하던 레이스 컨디션을 DB 원자적 upsert(INSERT ... ON CONFLICT)로 해결했다. 아울러 rating 컨트롤러 테스트가 인증된 사용자 ID 전파를 검증하지 못하던 공백과 테스트 파일의 import 순서 컨벤션 위반도 함께 정리했다. Co-Authored-By: Claude Sonnet 5 --- .../rating/repository/RatingRepository.java | 16 +++++ .../Backend/rating/service/RatingService.java | 22 +++---- .../controller/ConcertControllerTest.java | 29 +++++++-- .../repository/RatingRepositoryTest.java | 47 ++++++++++++++- .../rating/service/RatingServiceTest.java | 59 ++++++++----------- .../controller/ReleaseControllerTest.java | 29 +++++++-- 6 files changed, 146 insertions(+), 56 deletions(-) diff --git a/src/main/java/com/Coming/Backend/rating/repository/RatingRepository.java b/src/main/java/com/Coming/Backend/rating/repository/RatingRepository.java index ffedb9b..538b348 100644 --- a/src/main/java/com/Coming/Backend/rating/repository/RatingRepository.java +++ b/src/main/java/com/Coming/Backend/rating/repository/RatingRepository.java @@ -3,9 +3,11 @@ import com.Coming.Backend.rating.entity.Rating; import com.Coming.Backend.rating.entity.RatingTargetType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.math.BigDecimal; import java.util.Collection; import java.util.List; import java.util.Optional; @@ -15,6 +17,20 @@ public interface RatingRepository extends JpaRepository { Optional findByUserIdAndTargetTypeAndTargetId( Long userId, RatingTargetType targetType, Long targetId); + /** + * user_id·target_type·target_id 유니크 제약을 이용해 별점을 원자적으로 등록·수정한다. + * 동시에 같은 대상에 첫 별점을 등록하는 요청이 몰려도 유니크 제약 위반 없이 하나는 삽입, 나머지는 갱신으로 처리된다. + */ + @Modifying + @Query(value = """ + INSERT INTO rating (user_id, target_type, target_id, score, created_at, updated_at) + VALUES (:userId, :targetType, :targetId, :score, now(), now()) + ON CONFLICT (user_id, target_type, target_id) + DO UPDATE SET score = :score, updated_at = now() + """, nativeQuery = true) + void upsert(@Param("userId") Long userId, @Param("targetType") String targetType, + @Param("targetId") Long targetId, @Param("score") BigDecimal score); + @Query("SELECT r.targetId AS targetId, AVG(r.score) AS averageScore, COUNT(r) AS ratingCount " + "FROM Rating r WHERE r.targetType = :targetType " + "AND r.targetId IN :targetIds GROUP BY r.targetId") diff --git a/src/main/java/com/Coming/Backend/rating/service/RatingService.java b/src/main/java/com/Coming/Backend/rating/service/RatingService.java index 31b1d7a..f095424 100644 --- a/src/main/java/com/Coming/Backend/rating/service/RatingService.java +++ b/src/main/java/com/Coming/Backend/rating/service/RatingService.java @@ -28,6 +28,8 @@ @Transactional(readOnly = true) public class RatingService { + private static final BigDecimal MIN_SCORE = BigDecimal.valueOf(0.5); + private static final BigDecimal MAX_SCORE = BigDecimal.valueOf(5.0); private static final BigDecimal SCORE_STEP = BigDecimal.valueOf(0.5); private final RatingRepository ratingRepository; @@ -36,29 +38,23 @@ public class RatingService { /** * 별점을 등록하거나 수정한다. 대상이 존재하지 않으면 RatingTargetNotFoundException, - * score가 0.5 단위가 아니면 InvalidRatingScoreException, + * score가 0.5~5.0 범위의 0.5 단위가 아니면 InvalidRatingScoreException, * 공연이 ENDED 상태가 아니면 ConcertNotEndedException을 던진다. + * + *

등록·수정은 DB의 유니크 제약을 이용한 원자적 upsert로 처리되어, 동일 사용자가 같은 대상에 + * 동시에 첫 별점을 등록해도 유니크 제약 위반 없이 안전하게 처리된다.

*/ @Transactional public void upsert(Long userId, RatingTargetType targetType, Long targetId, BigDecimal score) { if (userId == null) { throw new UnauthorizedException(); } - if (score.remainder(SCORE_STEP).compareTo(BigDecimal.ZERO) != 0) { + if (score.compareTo(MIN_SCORE) < 0 || score.compareTo(MAX_SCORE) > 0 + || score.remainder(SCORE_STEP).compareTo(BigDecimal.ZERO) != 0) { throw new InvalidRatingScoreException(); } validateTarget(targetType, targetId); - - ratingRepository.findByUserIdAndTargetTypeAndTargetId(userId, targetType, targetId) - .ifPresentOrElse( - rating -> rating.updateScore(score), - () -> ratingRepository.save(Rating.builder() - .userId(userId) - .targetType(targetType) - .targetId(targetId) - .score(score) - .build()) - ); + ratingRepository.upsert(userId, targetType.name(), targetId, score); } /** diff --git a/src/test/java/com/Coming/Backend/concert/controller/ConcertControllerTest.java b/src/test/java/com/Coming/Backend/concert/controller/ConcertControllerTest.java index 9aa6d30..6bc59af 100644 --- a/src/test/java/com/Coming/Backend/concert/controller/ConcertControllerTest.java +++ b/src/test/java/com/Coming/Backend/concert/controller/ConcertControllerTest.java @@ -27,6 +27,7 @@ import java.math.BigDecimal; import java.time.LocalDate; 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; @@ -36,6 +37,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; import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; @@ -55,6 +60,7 @@ class ConcertControllerTest { private ConcertController concertController; private static final Long CONCERT_ID = 1L; + private static final Long USER_ID = 1L; @BeforeEach void setUp() { @@ -62,11 +68,16 @@ void setUp() { validator.afterPropertiesSet(); mockMvc = MockMvcBuilders.standaloneSetup(concertController) .setControllerAdvice(new GlobalExceptionHandler(new com.Coming.Backend.common.discord.NoOpDiscordNotifier())) - .setCustomArgumentResolvers(new PageableHandlerMethodArgumentResolver()) + .setCustomArgumentResolvers(new PageableHandlerMethodArgumentResolver(), new AuthenticationPrincipalArgumentResolver()) .setValidator(validator) .build(); } + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + private ConcertSummaryResponse buildSummary(Long id, String title, String artistName) { List artists = artistName != null ? List.of(new ArtistSummary(1L, artistName, null)) @@ -214,12 +225,16 @@ void should_return_401_with_error_body_when_following_true_and_user_not_authenti @Test void should_return_200_when_valid_score_given() throws Exception { + // given + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(USER_ID, null, List.of(new SimpleGrantedAuthority("ROLE_USER")))); + // when & then mockMvc.perform(put("/api/concerts/{id}/rating", CONCERT_ID) .contentType(MediaType.APPLICATION_JSON) .content("{\"score\": 4.5}")) .andExpect(status().isOk()); - verify(ratingService).upsert(isNull(), eq(RatingTargetType.CONCERT), eq(CONCERT_ID), eq(BigDecimal.valueOf(4.5))); + verify(ratingService).upsert(eq(USER_ID), eq(RatingTargetType.CONCERT), eq(CONCERT_ID), eq(BigDecimal.valueOf(4.5))); } @Test @@ -253,7 +268,9 @@ void should_return_404_when_rating_target_does_not_exist() throws Exception { @Test void should_return_200_with_my_score_when_rating_exists() throws Exception { // given - given(ratingService.getMine(isNull(), eq(RatingTargetType.CONCERT), eq(CONCERT_ID))) + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(USER_ID, null, List.of(new SimpleGrantedAuthority("ROLE_USER")))); + given(ratingService.getMine(eq(USER_ID), eq(RatingTargetType.CONCERT), eq(CONCERT_ID))) .willReturn(new RatingMeResponse(BigDecimal.valueOf(4.5))); // when & then @@ -269,9 +286,13 @@ void should_return_200_with_my_score_when_rating_exists() throws Exception { @Test void should_return_200_when_rating_deleted() throws Exception { + // given + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(USER_ID, null, List.of(new SimpleGrantedAuthority("ROLE_USER")))); + // when & then mockMvc.perform(delete("/api/concerts/{id}/rating", CONCERT_ID)) .andExpect(status().isOk()); - verify(ratingService).delete(isNull(), eq(RatingTargetType.CONCERT), eq(CONCERT_ID)); + verify(ratingService).delete(eq(USER_ID), eq(RatingTargetType.CONCERT), eq(CONCERT_ID)); } } diff --git a/src/test/java/com/Coming/Backend/rating/repository/RatingRepositoryTest.java b/src/test/java/com/Coming/Backend/rating/repository/RatingRepositoryTest.java index 737773e..edb5e73 100644 --- a/src/test/java/com/Coming/Backend/rating/repository/RatingRepositoryTest.java +++ b/src/test/java/com/Coming/Backend/rating/repository/RatingRepositoryTest.java @@ -1,7 +1,5 @@ package com.Coming.Backend.rating.repository; -import static org.assertj.core.api.Assertions.assertThat; - import com.Coming.Backend.rating.entity.Rating; import com.Coming.Backend.rating.entity.RatingTargetType; import com.Coming.Backend.rating.repository.RatingRepository.RatingAggregate; @@ -12,6 +10,9 @@ 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.boot.jpa.test.autoconfigure.TestEntityManager; + +import static org.assertj.core.api.Assertions.assertThat; @DataJpaTest @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) @@ -20,6 +21,9 @@ class RatingRepositoryTest { @Autowired private RatingRepository ratingRepository; + @Autowired + private TestEntityManager entityManager; + private static final Long USER_ID = 1L; private static final Long OTHER_USER_ID = 2L; private static final Long TARGET_ID = 10L; @@ -154,4 +158,43 @@ void should_exclude_target_id_from_result_when_no_ratings_exist() { .extracting(RatingAggregate::getTargetId) .containsExactly(TARGET_ID); } + + // ------------------------------------------------------------------------- + // upsert + // ------------------------------------------------------------------------- + + @Test + void should_insert_new_rating_when_no_existing_rating() { + // given & when + ratingRepository.upsert(USER_ID, RatingTargetType.CONCERT.name(), TARGET_ID, BigDecimal.valueOf(4.0)); + entityManager.clear(); + + // then + Optional found = ratingRepository.findByUserIdAndTargetTypeAndTargetId( + USER_ID, RatingTargetType.CONCERT, TARGET_ID); + assertThat(found).isPresent(); + assertThat(found.get().getScore()).isEqualByComparingTo(BigDecimal.valueOf(4.0)); + } + + @Test + void should_update_existing_rating_when_conflict_occurs() { + // given + ratingRepository.save(buildRating(USER_ID, RatingTargetType.CONCERT, TARGET_ID, BigDecimal.valueOf(4.0))); + entityManager.clear(); + + // when + ratingRepository.upsert(USER_ID, RatingTargetType.CONCERT.name(), TARGET_ID, BigDecimal.valueOf(2.5)); + entityManager.clear(); + + // then + Optional found = ratingRepository.findByUserIdAndTargetTypeAndTargetId( + USER_ID, RatingTargetType.CONCERT, TARGET_ID); + assertThat(found).isPresent(); + assertThat(found.get().getScore()).isEqualByComparingTo(BigDecimal.valueOf(2.5)); + assertThat(ratingRepository.findAll()) + .filteredOn(rating -> rating.getUserId().equals(USER_ID) + && rating.getTargetType() == RatingTargetType.CONCERT + && rating.getTargetId().equals(TARGET_ID)) + .hasSize(1); + } } diff --git a/src/test/java/com/Coming/Backend/rating/service/RatingServiceTest.java b/src/test/java/com/Coming/Backend/rating/service/RatingServiceTest.java index 2c29edc..0b0a8e8 100644 --- a/src/test/java/com/Coming/Backend/rating/service/RatingServiceTest.java +++ b/src/test/java/com/Coming/Backend/rating/service/RatingServiceTest.java @@ -32,7 +32,6 @@ import static org.mockito.ArgumentMatchers.anyCollection; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; @@ -77,6 +76,28 @@ void should_throw_invalid_rating_score_exception_when_score_is_not_half_step() { .hasMessage(ErrorCode.INVALID_RATING_SCORE.getMessage()); } + @Test + void should_throw_invalid_rating_score_exception_when_score_is_below_min() { + // given + BigDecimal invalidScore = BigDecimal.valueOf(0.0); + + // when & then + assertThatThrownBy(() -> ratingService.upsert(USER_ID, RatingTargetType.CONCERT, CONCERT_ID, invalidScore)) + .isInstanceOf(InvalidRatingScoreException.class) + .hasMessage(ErrorCode.INVALID_RATING_SCORE.getMessage()); + } + + @Test + void should_throw_invalid_rating_score_exception_when_score_is_above_max() { + // given + BigDecimal invalidScore = BigDecimal.valueOf(5.5); + + // when & then + assertThatThrownBy(() -> ratingService.upsert(USER_ID, RatingTargetType.CONCERT, CONCERT_ID, invalidScore)) + .isInstanceOf(InvalidRatingScoreException.class) + .hasMessage(ErrorCode.INVALID_RATING_SCORE.getMessage()); + } + @Test void should_throw_rating_target_not_found_exception_when_concert_does_not_exist() { // given @@ -103,61 +124,33 @@ void should_throw_concert_not_ended_exception_when_concert_status_is_not_ended() } @Test - void should_save_new_rating_when_no_existing_rating_found() { + void should_call_repository_upsert_when_concert_is_ended() { // given BigDecimal score = BigDecimal.valueOf(4.5); Concert concert = Concert.builder() .status(ConcertStatus.ENDED) .build(); given(concertRepository.findById(CONCERT_ID)).willReturn(Optional.of(concert)); - given(ratingRepository.findByUserIdAndTargetTypeAndTargetId(USER_ID, RatingTargetType.CONCERT, CONCERT_ID)) - .willReturn(Optional.empty()); // when ratingService.upsert(USER_ID, RatingTargetType.CONCERT, CONCERT_ID, score); // then - verify(ratingRepository).save(any(Rating.class)); + verify(ratingRepository).upsert(USER_ID, "CONCERT", CONCERT_ID, score); } @Test - void should_update_existing_rating_score_when_rating_already_exists() { - // given - Rating existingRating = Rating.builder() - .userId(USER_ID) - .targetType(RatingTargetType.CONCERT) - .targetId(CONCERT_ID) - .score(BigDecimal.valueOf(2.0)) - .build(); - Concert concert = Concert.builder() - .status(ConcertStatus.ENDED) - .build(); - given(concertRepository.findById(CONCERT_ID)).willReturn(Optional.of(concert)); - given(ratingRepository.findByUserIdAndTargetTypeAndTargetId(USER_ID, RatingTargetType.CONCERT, CONCERT_ID)) - .willReturn(Optional.of(existingRating)); - - // when - ratingService.upsert(USER_ID, RatingTargetType.CONCERT, CONCERT_ID, BigDecimal.valueOf(3.5)); - - // then - assertThat(existingRating.getScore()).isEqualByComparingTo(BigDecimal.valueOf(3.5)); - verify(ratingRepository, never()).save(any(Rating.class)); - } - - @Test - void should_save_new_rating_when_release_target_exists() { + void should_call_repository_upsert_when_release_target_exists() { // given Long releaseId = 20L; BigDecimal score = BigDecimal.valueOf(4.0); given(releaseGroupRepository.existsById(releaseId)).willReturn(true); - given(ratingRepository.findByUserIdAndTargetTypeAndTargetId(USER_ID, RatingTargetType.RELEASE, releaseId)) - .willReturn(Optional.empty()); // when ratingService.upsert(USER_ID, RatingTargetType.RELEASE, releaseId, score); // then - verify(ratingRepository).save(any(Rating.class)); + verify(ratingRepository).upsert(USER_ID, "RELEASE", releaseId, score); } // ------------------------------------------------------------------------- diff --git a/src/test/java/com/Coming/Backend/release/controller/ReleaseControllerTest.java b/src/test/java/com/Coming/Backend/release/controller/ReleaseControllerTest.java index 3f6d219..3e05ce1 100644 --- a/src/test/java/com/Coming/Backend/release/controller/ReleaseControllerTest.java +++ b/src/test/java/com/Coming/Backend/release/controller/ReleaseControllerTest.java @@ -28,6 +28,7 @@ import java.math.BigDecimal; import java.time.LocalDate; 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; @@ -37,6 +38,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; import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; @@ -57,6 +62,7 @@ class ReleaseControllerTest { private static final Long ARTIST_ID = 1L; private static final Long RELEASE_ID = 10L; + private static final Long USER_ID = 1L; @BeforeEach void setUp() { @@ -64,11 +70,16 @@ void setUp() { validator.afterPropertiesSet(); mockMvc = MockMvcBuilders.standaloneSetup(releaseController) .setControllerAdvice(new GlobalExceptionHandler(new com.Coming.Backend.common.discord.NoOpDiscordNotifier())) - .setCustomArgumentResolvers(new PageableHandlerMethodArgumentResolver()) + .setCustomArgumentResolvers(new PageableHandlerMethodArgumentResolver(), new AuthenticationPrincipalArgumentResolver()) .setValidator(validator) .build(); } + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + // ------------------------------------------------------------------------- // GET /api/releases // ------------------------------------------------------------------------- @@ -289,12 +300,16 @@ void should_return_404_when_release_not_found() throws Exception { @Test void should_return_200_when_valid_score_given() throws Exception { + // given + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(USER_ID, null, List.of(new SimpleGrantedAuthority("ROLE_USER")))); + // when & then mockMvc.perform(put("/api/releases/{id}/rating", RELEASE_ID) .contentType(MediaType.APPLICATION_JSON) .content("{\"score\": 4.5}")) .andExpect(status().isOk()); - verify(ratingService).upsert(isNull(), eq(RatingTargetType.RELEASE), eq(RELEASE_ID), eq(BigDecimal.valueOf(4.5))); + verify(ratingService).upsert(eq(USER_ID), eq(RatingTargetType.RELEASE), eq(RELEASE_ID), eq(BigDecimal.valueOf(4.5))); } @Test @@ -328,7 +343,9 @@ void should_return_404_when_rating_target_does_not_exist() throws Exception { @Test void should_return_200_with_my_score_when_rating_exists() throws Exception { // given - given(ratingService.getMine(isNull(), eq(RatingTargetType.RELEASE), eq(RELEASE_ID))) + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(USER_ID, null, List.of(new SimpleGrantedAuthority("ROLE_USER")))); + given(ratingService.getMine(eq(USER_ID), eq(RatingTargetType.RELEASE), eq(RELEASE_ID))) .willReturn(new RatingMeResponse(BigDecimal.valueOf(4.5))); // when & then @@ -344,9 +361,13 @@ void should_return_200_with_my_score_when_rating_exists() throws Exception { @Test void should_return_200_when_rating_deleted() throws Exception { + // given + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(USER_ID, null, List.of(new SimpleGrantedAuthority("ROLE_USER")))); + // when & then mockMvc.perform(delete("/api/releases/{id}/rating", RELEASE_ID)) .andExpect(status().isOk()); - verify(ratingService).delete(isNull(), eq(RatingTargetType.RELEASE), eq(RELEASE_ID)); + verify(ratingService).delete(eq(USER_ID), eq(RatingTargetType.RELEASE), eq(RELEASE_ID)); } } From 761e9d394e5b10293f026d18b39566f9474f2897 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Mon, 21 Sep 2026 19:02:51 +0900 Subject: [PATCH 19/19] =?UTF-8?q?[fix]=20=EC=97=94=ED=8B=B0=ED=8B=B0=20?= =?UTF-8?q?=EB=A9=98=EC=85=98=20=EA=B2=80=EC=83=89=20API=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EB=88=84=EB=9D=BD=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/mentions/search가 permitAll 목록에 포함되어 인증 없이 호출 가능했다. 해당 API는 게시글 작성 시 멘션 자동완성 용도로 인증된 사용자만 사용해야 하므로, rating/me·following과 동일하게 hasAnyRole("USER", "ADMIN") 그룹으로 이동했다. Co-Authored-By: Claude Sonnet 5 --- .../java/com/Coming/Backend/common/config/SecurityConfig.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 87c1b99..b707ea3 100644 --- a/src/main/java/com/Coming/Backend/common/config/SecurityConfig.java +++ b/src/main/java/com/Coming/Backend/common/config/SecurityConfig.java @@ -116,14 +116,14 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers(HttpMethod.GET, "/api/artists/following").hasAnyRole("USER", "ADMIN") .requestMatchers(HttpMethod.GET, "/api/concerts/*/rating/me", - "/api/releases/*/rating/me" + "/api/releases/*/rating/me", + "/api/mentions/search" ).hasAnyRole("USER", "ADMIN") .requestMatchers(HttpMethod.GET, "/api/artists/**", "/api/concerts/**", "/api/releases/**", "/api/calendar", - "/api/mentions/search", "/api/posts/**", "/api/notices/**", "/api/search",