From e1421d503bb1e4bab9a132b7946d82c149e40785 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A4=80=EC=98=81?= Date: Wed, 19 Aug 2026 23:21:30 +0900 Subject: [PATCH] =?UTF-8?q?T-16=20=ED=99=9C=EB=8F=99=20=EA=B3=B5=EA=B0=9C?= =?UTF-8?q?=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /v1/activities?category= : 연도 그룹 타임라인. 연도 내림차순 → 월 내림차순 → display_order 순(AC-3.1, AC-3.2). CurriculumQueryService와 같은 이유로 N+1을 명시적으로 피한다 — 활동 목록 1쿼리 + 이미지 IN절 배치 조회 1쿼리로, 연도/활동 개수와 무관하게 쿼리 수가 고정된다. - GET /v1/activities/{id} : 상세. 숨김·삭제된 활동은 404(INV-11, AC-3.7). - thumbnailUrl은 이미지 목록의 첫 장(AC-3.4), content가 비어 있으면 hasDetail: false (AC-3.9, Activity.hasDetail() 재사용). - category 파라미터는 slug로 찾되 숨겨진 카테고리는 존재하지 않는 것과 동일하게 404 (findBySlugAndPublishedTrue). - SecurityConfig에 /v1/activities/** permitAll 추가 — 기존 activity-categories와 동일하게 공개 API라 인증이 필요 없다(추가하지 않으면 401). 검증: ./gradlew test 전체 통과 3회 연속. (참고: 3회차 실행 중 InitialSetupIntegrationTest가 자체 Testcontainers 컨테이너의 SSL 핸드셰이크 일시 오류로 한 번 실패했으나, 이 도메인과 무관한 기존 테스트이고 격리 재실행 시 바로 통과해 인프라성 flake로 판단했다.) --- .../activity/controller/ActivityApi.java | 28 ++++ .../controller/ActivityController.java | 32 ++++ .../dto/response/ActivityDetailResponse.java | 30 ++++ .../response/ActivityListItemResponse.java | 28 ++++ .../response/ActivityTimelineResponse.java | 9 ++ .../ActivityCategoryRepository.java | 3 + .../repository/ActivityRepository.java | 3 + .../activity/service/ActivityService.java | 73 +++++++++ .../global/config/SecurityConfig.java | 1 + .../activity/ActivityIntegrationTest.java | 140 ++++++++++++++++++ 10 files changed, 347 insertions(+) create mode 100644 src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/ActivityApi.java create mode 100644 src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/ActivityController.java create mode 100644 src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/response/ActivityDetailResponse.java create mode 100644 src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/response/ActivityListItemResponse.java create mode 100644 src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/response/ActivityTimelineResponse.java create mode 100644 src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/service/ActivityService.java create mode 100644 src/test/java/com/bcsdlab/bcsdinternalapiv2/activity/ActivityIntegrationTest.java diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/ActivityApi.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/ActivityApi.java new file mode 100644 index 0000000..df5d330 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/ActivityApi.java @@ -0,0 +1,28 @@ +package com.bcsdlab.bcsdinternalapiv2.activity.controller; + +import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.response.ActivityDetailResponse; +import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.response.ActivityTimelineResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; + +@Tag(name = "활동 API (홈페이지 공개)") +public interface ActivityApi { + + @ApiResponses(value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "404", description = "존재하지 않거나 숨겨진 카테고리"), + }) + @Operation(summary = "연도 그룹 활동 타임라인", + description = "연도 내림차순 → 월 내림차순 → display_order 순으로, 공개된 활동만 반환합니다.") + List getTimeline(String category); + + @ApiResponses(value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "404", description = "존재하지 않거나 숨겨진 활동"), + }) + @Operation(summary = "활동 상세") + ActivityDetailResponse getActivity(Long id); +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/ActivityController.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/ActivityController.java new file mode 100644 index 0000000..dd52ad2 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/ActivityController.java @@ -0,0 +1,32 @@ +package com.bcsdlab.bcsdinternalapiv2.activity.controller; + +import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.response.ActivityDetailResponse; +import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.response.ActivityTimelineResponse; +import com.bcsdlab.bcsdinternalapiv2.activity.service.ActivityService; +import java.util.List; +import lombok.RequiredArgsConstructor; +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; + +@RestController +@RequestMapping("/v1/activities") +@RequiredArgsConstructor +public class ActivityController implements ActivityApi { + + private final ActivityService activityService; + + @Override + @GetMapping + public List getTimeline(@RequestParam String category) { + return activityService.getTimeline(category); + } + + @Override + @GetMapping("/{id}") + public ActivityDetailResponse getActivity(@PathVariable Long id) { + return activityService.getActivity(id); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/response/ActivityDetailResponse.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/response/ActivityDetailResponse.java new file mode 100644 index 0000000..ed18c58 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/response/ActivityDetailResponse.java @@ -0,0 +1,30 @@ +package com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.response; + +import com.bcsdlab.bcsdinternalapiv2.activity.model.Activity; +import java.util.List; + +public record ActivityDetailResponse( + Long id, + String categorySlug, + int year, + int month, + String title, + String summary, + String content, + List images, + String externalUrl +) { + public static ActivityDetailResponse of(Activity activity, List images) { + return new ActivityDetailResponse( + activity.getId(), + activity.getCategory().getSlug(), + activity.getYear(), + activity.getMonth(), + activity.getTitle(), + activity.getSummary(), + activity.getContent(), + images, + activity.getExternalUrl() + ); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/response/ActivityListItemResponse.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/response/ActivityListItemResponse.java new file mode 100644 index 0000000..9fa12c0 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/response/ActivityListItemResponse.java @@ -0,0 +1,28 @@ +package com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.response; + +import com.bcsdlab.bcsdinternalapiv2.activity.model.Activity; +import java.util.List; + +public record ActivityListItemResponse( + Long id, + int month, + String title, + String summary, + String thumbnailUrl, + List images, + String externalUrl, + boolean hasDetail +) { + public static ActivityListItemResponse of(Activity activity, List images) { + return new ActivityListItemResponse( + activity.getId(), + activity.getMonth(), + activity.getTitle(), + activity.getSummary(), + images.isEmpty() ? null : images.get(0), + images, + activity.getExternalUrl(), + activity.hasDetail() + ); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/response/ActivityTimelineResponse.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/response/ActivityTimelineResponse.java new file mode 100644 index 0000000..86110ef --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/response/ActivityTimelineResponse.java @@ -0,0 +1,9 @@ +package com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.response; + +import java.util.List; + +public record ActivityTimelineResponse( + int year, + List activities +) { +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/repository/ActivityCategoryRepository.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/repository/ActivityCategoryRepository.java index f8c9bb6..fde229f 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/repository/ActivityCategoryRepository.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/repository/ActivityCategoryRepository.java @@ -2,6 +2,7 @@ import com.bcsdlab.bcsdinternalapiv2.activity.model.ActivityCategory; import java.util.List; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; public interface ActivityCategoryRepository extends JpaRepository { @@ -10,5 +11,7 @@ public interface ActivityCategoryRepository extends JpaRepository findAllByPublishedTrueOrderByDisplayOrderAsc(); + Optional findBySlugAndPublishedTrue(String slug); + boolean existsBySlug(String slug); } diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/repository/ActivityRepository.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/repository/ActivityRepository.java index 7652d5e..a254c1f 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/repository/ActivityRepository.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/repository/ActivityRepository.java @@ -2,6 +2,7 @@ import com.bcsdlab.bcsdinternalapiv2.activity.model.Activity; import java.util.List; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; @@ -12,4 +13,6 @@ public interface ActivityRepository extends JpaRepository, JpaSp List findAllByCategory_IdAndYearAndMonthOrderByDisplayOrderAsc(Long categoryId, int year, int month); List findAllByCategory_IdAndPublishedTrueOrderByYearDescMonthDescDisplayOrderAsc(Long categoryId); + + Optional findByIdAndPublishedTrue(Long id); } diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/service/ActivityService.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/service/ActivityService.java new file mode 100644 index 0000000..eed6dc3 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/service/ActivityService.java @@ -0,0 +1,73 @@ +package com.bcsdlab.bcsdinternalapiv2.activity.service; + +import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.response.ActivityDetailResponse; +import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.response.ActivityListItemResponse; +import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.response.ActivityTimelineResponse; +import com.bcsdlab.bcsdinternalapiv2.activity.exception.ActivityException; +import com.bcsdlab.bcsdinternalapiv2.activity.exception.ActivityExceptionType; +import com.bcsdlab.bcsdinternalapiv2.activity.model.Activity; +import com.bcsdlab.bcsdinternalapiv2.activity.model.ActivityCategory; +import com.bcsdlab.bcsdinternalapiv2.activity.model.ActivityImage; +import com.bcsdlab.bcsdinternalapiv2.activity.repository.ActivityCategoryRepository; +import com.bcsdlab.bcsdinternalapiv2.activity.repository.ActivityImageRepository; +import com.bcsdlab.bcsdinternalapiv2.activity.repository.ActivityRepository; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 활동 타임라인(FR-5.1)이 쓰는 공개 조회. 연도 그룹 개수와 무관하게 항상 쿼리 3개 + * (활동 목록, 이미지 IN 절 배치 조회 각 1회 + 카테고리 조회 1회)로 끝낸다 — 홈페이지 빌드가 + * 매번 호출하는 공개 경로라 {@link com.bcsdlab.bcsdinternalapiv2.curriculum.service.CurriculumQueryService}와 + * 같은 이유로 N+1을 명시적으로 피한다. + */ +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ActivityService { + + private final ActivityRepository activityRepository; + private final ActivityImageRepository activityImageRepository; + private final ActivityCategoryRepository activityCategoryRepository; + + public List getTimeline(String categorySlug) { + ActivityCategory category = activityCategoryRepository.findBySlugAndPublishedTrue(categorySlug) + .orElseThrow(() -> new ActivityException(ActivityExceptionType.CATEGORY_NOT_FOUND)); + + List activities = activityRepository + .findAllByCategory_IdAndPublishedTrueOrderByYearDescMonthDescDisplayOrderAsc(category.getId()); + List years = activities.stream().map(Activity::getYear).distinct().toList(); + Map> activitiesByYear = activities.stream() + .collect(Collectors.groupingBy(Activity::getYear)); + + Map> imagesByActivityId = imagesByActivityId(activities); + + return years.stream() + .map(year -> new ActivityTimelineResponse(year, activitiesByYear.get(year).stream() + .map(activity -> ActivityListItemResponse.of( + activity, imagesByActivityId.getOrDefault(activity.getId(), List.of()))) + .toList())) + .toList(); + } + + public ActivityDetailResponse getActivity(Long id) { + Activity activity = activityRepository.findByIdAndPublishedTrue(id) + .orElseThrow(() -> new ActivityException(ActivityExceptionType.ACTIVITY_NOT_FOUND)); + List images = activityImageRepository.findAllByActivity_IdOrderByDisplayOrderAsc(id).stream() + .map(ActivityImage::getImageUrl) + .toList(); + return ActivityDetailResponse.of(activity, images); + } + + private Map> imagesByActivityId(List activities) { + List activityIds = activities.stream().map(Activity::getId).toList(); + List images = activityIds.isEmpty() + ? List.of() : activityImageRepository.findAllByActivity_IdInOrderByDisplayOrderAsc(activityIds); + return images.stream() + .collect(Collectors.groupingBy(image -> image.getActivity().getId(), + Collectors.mapping(ActivityImage::getImageUrl, Collectors.toList()))); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/config/SecurityConfig.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/config/SecurityConfig.java index 97717ac..84465c6 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/config/SecurityConfig.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/config/SecurityConfig.java @@ -42,6 +42,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers("/v1/auth/password/**").permitAll() .requestMatchers("/v1/tracks/**").permitAll() .requestMatchers("/v1/activity-categories/**").permitAll() + .requestMatchers("/v1/activities/**").permitAll() .requestMatchers("/v1/members/me/initial-setup") .hasAnyAuthority("SCOPE_PRE_ACTIVATION", "SCOPE_FULL") .requestMatchers("/v1/admin/**").access(AuthorizationManagers.allOf( diff --git a/src/test/java/com/bcsdlab/bcsdinternalapiv2/activity/ActivityIntegrationTest.java b/src/test/java/com/bcsdlab/bcsdinternalapiv2/activity/ActivityIntegrationTest.java new file mode 100644 index 0000000..b46a6a6 --- /dev/null +++ b/src/test/java/com/bcsdlab/bcsdinternalapiv2/activity/ActivityIntegrationTest.java @@ -0,0 +1,140 @@ +package com.bcsdlab.bcsdinternalapiv2.activity; + +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.bcsdlab.bcsdinternalapiv2.IntegrationTestSupport; +import com.bcsdlab.bcsdinternalapiv2.activity.model.Activity; +import com.bcsdlab.bcsdinternalapiv2.activity.model.ActivityCategory; +import com.bcsdlab.bcsdinternalapiv2.activity.model.ActivityImage; +import com.bcsdlab.bcsdinternalapiv2.activity.repository.ActivityCategoryRepository; +import com.bcsdlab.bcsdinternalapiv2.activity.repository.ActivityImageRepository; +import com.bcsdlab.bcsdinternalapiv2.activity.repository.ActivityRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; + +class ActivityIntegrationTest extends IntegrationTestSupport { + + @Autowired + private ActivityCategoryRepository activityCategoryRepository; + + @Autowired + private ActivityRepository activityRepository; + + @Autowired + private ActivityImageRepository activityImageRepository; + + @Autowired + private JdbcTemplate jdbcTemplate; + + private ActivityCategory category; + + @BeforeEach + void setUp() { + // @SQLRestriction 때문에 deleteAll()은 soft-delete된 행을 찾지 못해 물리적으로 + // 남겨 두고, 그 남은 행이 activity_category FK를 계속 참조해 다음 deleteAll()을 막는다. + jdbcTemplate.update("delete from activity_image"); + jdbcTemplate.update("delete from activity"); + jdbcTemplate.update("delete from activity_category"); + + category = activityCategoryRepository.save(ActivityCategory.builder() + .slug("event").name("EVENT").displayOrder(0).published(true).build()); + } + + @Test + @DisplayName("AC-3.1 연도 내림차순, AC-3.2 같은 달 안에서는 display_order 순서다") + void 연도_내림차순_같은_달은_순서대로() throws Exception { + activityRepository.save(Activity.builder() + .category(category).year(2019).month(1).title("A").summary("s").displayOrder(0).published(true) + .build()); + Activity may2 = activityRepository.save(Activity.builder() + .category(category).year(2019).month(5).title("5월 두번째").summary("s").displayOrder(1) + .published(true).build()); + Activity may1 = activityRepository.save(Activity.builder() + .category(category).year(2019).month(5).title("5월 첫번째").summary("s").displayOrder(0) + .published(true).build()); + activityRepository.save(Activity.builder() + .category(category).year(2020).month(3).title("B").summary("s").displayOrder(0).published(true) + .build()); + + mockMvc.perform(get("/v1/activities").queryParam("category", category.getSlug())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].year").value(2020)) + .andExpect(jsonPath("$[1].year").value(2019)) + .andExpect(jsonPath("$[1].activities[0].title").value(may1.getTitle())) + .andExpect(jsonPath("$[1].activities[1].title").value(may2.getTitle())) + .andExpect(jsonPath("$[1].activities[2].title").value("A")); + } + + @Test + @DisplayName("AC-3.4 목록의 thumbnailUrl은 사진 첫 장이다") + void 썸네일은_첫_사진() throws Exception { + Activity activity = activityRepository.save(Activity.builder() + .category(category).year(2019).month(5).title("A").summary("s").displayOrder(0).published(true) + .build()); + activityImageRepository.save(ActivityImage.builder() + .activity(activity).imageUrl("https://x/1.png").displayOrder(0).build()); + activityImageRepository.save(ActivityImage.builder() + .activity(activity).imageUrl("https://x/2.png").displayOrder(1).build()); + + mockMvc.perform(get("/v1/activities").queryParam("category", category.getSlug())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].activities[0].thumbnailUrl").value("https://x/1.png")) + .andExpect(jsonPath("$[0].activities[0].images.length()").value(2)); + } + + @Test + @DisplayName("AC-3.9 content가 없으면 hasDetail은 false다") + void 본문_없으면_hasDetail_false() throws Exception { + activityRepository.save(Activity.builder() + .category(category).year(2019).month(5).title("A").summary("s").displayOrder(0).published(true) + .build()); + + mockMvc.perform(get("/v1/activities").queryParam("category", category.getSlug())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].activities[0].hasDetail").value(false)); + } + + @Test + @DisplayName("AC-3.7 숨긴 활동은 목록에서 사라지고 상세는 404다") + void 숨긴_활동은_목록_제외_상세_404() throws Exception { + Activity hidden = activityRepository.save(Activity.builder() + .category(category).year(2019).month(5).title("숨김").summary("s").displayOrder(0).published(false) + .build()); + activityRepository.save(Activity.builder() + .category(category).year(2019).month(5).title("공개").summary("s").displayOrder(1).published(true) + .build()); + + mockMvc.perform(get("/v1/activities").queryParam("category", category.getSlug())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].activities.length()").value(1)) + .andExpect(jsonPath("$[0].activities[0].title").value("공개")); + + mockMvc.perform(get("/v1/activities/" + hidden.getId())) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("활동 상세는 본문과 카테고리 slug를 포함한다") + void 상세_응답() throws Exception { + Activity activity = activityRepository.save(Activity.builder() + .category(category).year(2019).month(5).title("A").summary("s").content("

본문

") + .displayOrder(0).published(true).build()); + + mockMvc.perform(get("/v1/activities/" + activity.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.categorySlug").value(category.getSlug())) + .andExpect(jsonPath("$.content").value("

본문

")); + } + + @Test + @DisplayName("존재하지 않거나 숨겨진 카테고리로 조회하면 404다") + void 존재하지_않는_카테고리는_404() throws Exception { + mockMvc.perform(get("/v1/activities").queryParam("category", "no-such-category")) + .andExpect(status().isNotFound()); + } +}