Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<ActivityTimelineResponse> getTimeline(String category);

@ApiResponses(value = {
@ApiResponse(responseCode = "200"),
@ApiResponse(responseCode = "404", description = "존재하지 않거나 숨겨진 활동"),
})
@Operation(summary = "활동 상세")
ActivityDetailResponse getActivity(Long id);
}
Original file line number Diff line number Diff line change
@@ -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<ActivityTimelineResponse> getTimeline(@RequestParam String category) {
return activityService.getTimeline(category);
}

@Override
@GetMapping("/{id}")
public ActivityDetailResponse getActivity(@PathVariable Long id) {
return activityService.getActivity(id);
}
}
Original file line number Diff line number Diff line change
@@ -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<String> images,
String externalUrl
) {
public static ActivityDetailResponse of(Activity activity, List<String> images) {
return new ActivityDetailResponse(
activity.getId(),
activity.getCategory().getSlug(),
activity.getYear(),
activity.getMonth(),
activity.getTitle(),
activity.getSummary(),
activity.getContent(),
images,
activity.getExternalUrl()
);
}
}
Original file line number Diff line number Diff line change
@@ -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<String> images,
String externalUrl,
boolean hasDetail
) {
public static ActivityListItemResponse of(Activity activity, List<String> images) {
return new ActivityListItemResponse(
activity.getId(),
activity.getMonth(),
activity.getTitle(),
activity.getSummary(),
images.isEmpty() ? null : images.get(0),
images,
activity.getExternalUrl(),
activity.hasDetail()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.response;

import java.util.List;

public record ActivityTimelineResponse(
int year,
List<ActivityListItemResponse> activities
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<ActivityCategory, Long> {
Expand All @@ -10,5 +11,7 @@ public interface ActivityCategoryRepository extends JpaRepository<ActivityCatego

List<ActivityCategory> findAllByPublishedTrueOrderByDisplayOrderAsc();

Optional<ActivityCategory> findBySlugAndPublishedTrue(String slug);

boolean existsBySlug(String slug);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -12,4 +13,6 @@ public interface ActivityRepository extends JpaRepository<Activity, Long>, JpaSp
List<Activity> findAllByCategory_IdAndYearAndMonthOrderByDisplayOrderAsc(Long categoryId, int year, int month);

List<Activity> findAllByCategory_IdAndPublishedTrueOrderByYearDescMonthDescDisplayOrderAsc(Long categoryId);

Optional<Activity> findByIdAndPublishedTrue(Long id);
}
Original file line number Diff line number Diff line change
@@ -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<ActivityTimelineResponse> getTimeline(String categorySlug) {
ActivityCategory category = activityCategoryRepository.findBySlugAndPublishedTrue(categorySlug)
.orElseThrow(() -> new ActivityException(ActivityExceptionType.CATEGORY_NOT_FOUND));

List<Activity> activities = activityRepository
.findAllByCategory_IdAndPublishedTrueOrderByYearDescMonthDescDisplayOrderAsc(category.getId());
List<Integer> years = activities.stream().map(Activity::getYear).distinct().toList();
Map<Integer, List<Activity>> activitiesByYear = activities.stream()
.collect(Collectors.groupingBy(Activity::getYear));

Map<Long, List<String>> 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<String> images = activityImageRepository.findAllByActivity_IdOrderByDisplayOrderAsc(id).stream()
.map(ActivityImage::getImageUrl)
.toList();
return ActivityDetailResponse.of(activity, images);
}

private Map<Long, List<String>> imagesByActivityId(List<Activity> activities) {
List<Long> activityIds = activities.stream().map(Activity::getId).toList();
List<ActivityImage> 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())));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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("<p>본문</p>")
.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("<p>본문</p>"));
}

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