Skip to content
Merged
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
@@ -1,27 +1,76 @@
package com.devpick.domain.trend.controller;

import com.devpick.domain.trend.dto.TrendAnalysisResponse;
import com.devpick.domain.trend.dto.TrendingKeywordsResponse;
import com.devpick.domain.trend.service.TrendAnalysisService;
import com.devpick.domain.trend.service.TrendService;
import com.devpick.global.common.response.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
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;

@Tag(name = "Trend", description = "트렌딩 키워드 API")
import java.time.LocalDate;
import java.util.UUID;

@Tag(name = "Trend", description = "트렌딩 키워드 / 트렌드 분석 API")
@RestController
@RequestMapping("/trends")
@RequiredArgsConstructor
public class TrendController {

private final TrendService trendService;
private final TrendAnalysisService trendAnalysisService;

@Operation(summary = "트렌딩 키워드 조회",
description = "Stack Overflow 활동 기반 최근 7일 트렌딩 기술 키워드 TOP 20을 반환합니다.")
@GetMapping("/keywords")
public ApiResponse<TrendingKeywordsResponse> getTrendingKeywords() {
return ApiResponse.ok(trendService.getTrendingKeywords());
}

@Operation(summary = "최신 트렌드 분석 조회",
description = "AI 서버가 저장한 가장 최근 트렌드 분석 결과를 반환합니다. Redis 캐시(6h) 적용.")
@ApiResponses({

Check warning on line 42 in src/main/java/com/devpick/domain/trend/controller/TrendController.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove the 'ApiResponses' wrapper from this annotation group

See more on https://sonarcloud.io/project/issues?id=Devpick-Org_devpick-backend&issues=AZ2--S4lIZWI7jsKlNQR&open=AZ2--S4lIZWI7jsKlNQR&pullRequest=132
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "인증 필요"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "트렌드 분석 결과 없음")
})
@GetMapping("/analysis")
public ApiResponse<TrendAnalysisResponse> getLatestAnalysis(
@AuthenticationPrincipal UUID userId,
@Parameter(description = "집계 단위 (weekly/daily)", example = "weekly")
@RequestParam(defaultValue = "weekly") String unit,
@Parameter(description = "범위 (global)", example = "global")
@RequestParam(defaultValue = "global") String scope) {
return ApiResponse.ok(trendAnalysisService.getLatest(unit, scope));
}

@Operation(summary = "특정 기간 트렌드 분석 조회",
description = "period_start 기준의 트렌드 분석 결과를 반환합니다. Redis 캐시(24h) 적용.")
@ApiResponses({

Check warning on line 59 in src/main/java/com/devpick/domain/trend/controller/TrendController.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove the 'ApiResponses' wrapper from this annotation group

See more on https://sonarcloud.io/project/issues?id=Devpick-Org_devpick-backend&issues=AZ2--S4lIZWI7jsKlNQS&open=AZ2--S4lIZWI7jsKlNQS&pullRequest=132
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 날짜 형식"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "인증 필요"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "트렌드 분석 결과 없음")
})
@GetMapping("/analysis/{periodStart}")
public ApiResponse<TrendAnalysisResponse> getAnalysisByPeriod(
@AuthenticationPrincipal UUID userId,
@Parameter(description = "기간 시작일 (yyyy-MM-dd)", required = true, example = "2026-04-14")
@PathVariable @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate periodStart,
@Parameter(description = "집계 단위 (weekly/daily)", example = "weekly")
@RequestParam(defaultValue = "weekly") String unit,
@Parameter(description = "범위 (global)", example = "global")
@RequestParam(defaultValue = "global") String scope) {
return ApiResponse.ok(trendAnalysisService.getByPeriod(unit, scope, periodStart));
}
}
18 changes: 18 additions & 0 deletions src/main/java/com/devpick/domain/trend/dto/TopContentItem.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.devpick.domain.trend.dto;

import com.fasterxml.jackson.annotation.JsonAlias;

import java.util.List;

public record TopContentItem(
String id,
String title,
@JsonAlias("translated_title") String translatedTitle,
@JsonAlias("source_name") String sourceName,
List<String> tags,
@JsonAlias("view_count") Integer viewCount,
@JsonAlias("thumbnail_url") String thumbnailUrl,
String category,
@JsonAlias("change_rate") Double changeRate,
boolean isMyInterest
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.devpick.domain.trend.dto;

import com.fasterxml.jackson.annotation.JsonAlias;

import java.time.LocalDate;
import java.util.List;

public record TrendAnalysisResponse(
String unit,
@JsonAlias("period_start") LocalDate periodStart,
@JsonAlias("period_end") LocalDate periodEnd,
@JsonAlias("date_label") String dateLabel,
@JsonAlias("top_posts") List<TopContentItem> topPosts,
@JsonAlias("top_posts_summary") String topPostsSummary,
@JsonAlias("collection_summary") String collectionSummary
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.devpick.domain.trend.repository;

import com.devpick.domain.trend.entity.TrendSnapshot;
import org.springframework.data.jpa.repository.JpaRepository;

import java.time.LocalDate;
import java.util.Optional;
import java.util.UUID;

public interface TrendSnapshotRepository extends JpaRepository<TrendSnapshot, UUID> {
Optional<TrendSnapshot> findFirstByUnitAndScopeOrderByPeriodStartDesc(String unit, String scope);
Optional<TrendSnapshot> findByUnitAndScopeAndPeriodStart(String unit, String scope, LocalDate periodStart);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package com.devpick.domain.trend.service;

import com.devpick.domain.trend.dto.TrendAnalysisResponse;
import com.devpick.domain.trend.entity.TrendSnapshot;
import com.devpick.domain.trend.repository.TrendSnapshotRepository;
import com.devpick.global.common.exception.DevpickException;
import com.devpick.global.common.exception.ErrorCode;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.Duration;
import java.time.LocalDate;

@Service
@RequiredArgsConstructor
@Slf4j
public class TrendAnalysisService {

private static final Duration LATEST_CACHE_TTL = Duration.ofHours(6);
private static final Duration PERIOD_CACHE_TTL = Duration.ofHours(24);

private final TrendSnapshotRepository trendSnapshotRepository;
private final StringRedisTemplate redisTemplate;
private final ObjectMapper objectMapper;

@Transactional(readOnly = true)
public TrendAnalysisResponse getLatest(String unit, String scope) {
String key = "trend:analysis:" + unit + ":" + scope + ":latest";
TrendAnalysisResponse cached = getFromRedis(key);
if (cached != null) return cached;

TrendSnapshot snapshot = trendSnapshotRepository
.findFirstByUnitAndScopeOrderByPeriodStartDesc(unit, scope)
.orElseThrow(() -> new DevpickException(ErrorCode.TREND_NOT_FOUND));

TrendAnalysisResponse response = parsePayload(snapshot.getPayload());
saveToRedis(key, response, LATEST_CACHE_TTL);
return response;
}

@Transactional(readOnly = true)
public TrendAnalysisResponse getByPeriod(String unit, String scope, LocalDate periodStart) {
String key = "trend:analysis:" + unit + ":" + scope + ":" + periodStart;
TrendAnalysisResponse cached = getFromRedis(key);
if (cached != null) return cached;

TrendSnapshot snapshot = trendSnapshotRepository
.findByUnitAndScopeAndPeriodStart(unit, scope, periodStart)
.orElseThrow(() -> new DevpickException(ErrorCode.TREND_NOT_FOUND));

TrendAnalysisResponse response = parsePayload(snapshot.getPayload());
saveToRedis(key, response, PERIOD_CACHE_TTL);
return response;
}

private TrendAnalysisResponse getFromRedis(String key) {
try {
String json = redisTemplate.opsForValue().get(key);
if (json == null) return null;
return objectMapper.readValue(json, TrendAnalysisResponse.class);
} catch (Exception e) {
log.warn("Redis 캐시 조회 실패 (PG fallback): key={}", key);
return null;
}
}

private void saveToRedis(String key, TrendAnalysisResponse response, Duration ttl) {
try {
redisTemplate.opsForValue().set(key, objectMapper.writeValueAsString(response), ttl);
} catch (Exception e) {
log.warn("Redis 캐시 저장 실패 (무시): key={}", key);
}
}

private TrendAnalysisResponse parsePayload(String payload) {
try {
return objectMapper.readValue(payload, TrendAnalysisResponse.class);
} catch (JsonProcessingException e) {
log.error("trend_snapshots payload 파싱 실패: {}", e.getMessage());
throw new DevpickException(ErrorCode.TREND_NOT_FOUND);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,9 @@ public enum ErrorCode {
// Badge
BADGE_NOT_FOUND(HttpStatus.NOT_FOUND, "BADGE_001", "배지 정보를 찾을 수 없습니다."),

// Trend
TREND_NOT_FOUND(HttpStatus.NOT_FOUND, "TREND_001", "트렌드 분석 결과가 없습니다."),

// File / storage
FILE_STORAGE_NOT_CONFIGURED(HttpStatus.SERVICE_UNAVAILABLE, "FILE_001", "파일 저장소가 설정되지 않았습니다."),
FILE_UPLOAD_INVALID_TYPE(HttpStatus.BAD_REQUEST, "FILE_002", "허용되지 않는 파일 형식입니다."),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
.requestMatchers("/dev/**").permitAll()
// 공개 읽기: 콘텐츠 피드·게시글·타인 프로필·트렌드 (비로그인 시 개인화 없이 최신순 피드 제공)
.requestMatchers(HttpMethod.GET, "/trends/keywords").permitAll()
.requestMatchers(HttpMethod.GET, "/trends/analysis", "/trends/analysis/**").authenticated()
.requestMatchers(HttpMethod.GET, "/contents").permitAll()
.requestMatchers(HttpMethod.GET, "/contents/search").permitAll()
.requestMatchers(HttpMethod.GET, "/posts").permitAll()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,22 +1,35 @@
package com.devpick.domain.trend.controller;

import com.devpick.domain.trend.dto.TrendAnalysisResponse;
import com.devpick.domain.trend.dto.TrendingKeywordsResponse;
import com.devpick.domain.trend.service.TrendAnalysisService;
import com.devpick.domain.trend.service.TrendService;
import com.devpick.global.common.exception.DevpickException;
import com.devpick.global.common.exception.ErrorCode;
import com.devpick.global.common.exception.GlobalExceptionHandler;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
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.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 java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;

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.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
Expand All @@ -31,6 +44,9 @@
@Mock
private TrendService trendService;

@Mock
private TrendAnalysisService trendAnalysisService;

@InjectMocks
private TrendController trendController;

Expand All @@ -40,25 +56,85 @@
mockMvc = MockMvcBuilders
.standaloneSetup(trendController)
.setControllerAdvice(new GlobalExceptionHandler())
.setCustomArgumentResolvers(new AuthenticationPrincipalArgumentResolver())
.build();

SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(
UUID.randomUUID(), null,
List.of(new SimpleGrantedAuthority("ROLE_USER")))
);
}

@AfterEach
void tearDown() {
SecurityContextHolder.clearContext();
}

@Test
@DisplayName("GET /trends/keywords - 트렌딩 키워드 목록을 반환한다")
void getTrendingKeywords_success() throws Exception {
// given
TrendingKeywordsResponse response = new TrendingKeywordsResponse(
List.of("react", "python", "typescript"),
Instant.parse("2026-03-23T00:00:00Z")
);
given(trendService.getTrendingKeywords()).willReturn(response);

// when & then
mockMvc.perform(get("/trends/keywords"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.keywords").isArray())
.andExpect(jsonPath("$.data.keywords[0]").value("react"))
.andExpect(jsonPath("$.data.keywords[2]").value("typescript"));
}

@Test
@DisplayName("GET /trends/analysis - 최신 트렌드 분석 조회 성공 시 200 반환")
void getLatestAnalysis_success_returns200() throws Exception {
TrendAnalysisResponse response = new TrendAnalysisResponse(
"weekly", LocalDate.of(2026, 4, 14), LocalDate.of(2026, 4, 20),
"2026년 4월 3주차", List.of(), "요약", "컬렉션 요약");
given(trendAnalysisService.getLatest(eq("weekly"), eq("global"))).willReturn(response);

Check warning on line 97 in src/test/java/com/devpick/domain/trend/controller/TrendControllerTest.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this and every subsequent useless "eq(...)" invocation; pass the values directly.

See more on https://sonarcloud.io/project/issues?id=Devpick-Org_devpick-backend&issues=AZ2--S7MIZWI7jsKlNQU&open=AZ2--S7MIZWI7jsKlNQU&pullRequest=132

mockMvc.perform(get("/trends/analysis"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.unit").value("weekly"))
.andExpect(jsonPath("$.data.dateLabel").value("2026년 4월 3주차"));
}

@Test
@DisplayName("GET /trends/analysis - 데이터 없으면 404 반환")
void getLatestAnalysis_notFound_returns404() throws Exception {
given(trendAnalysisService.getLatest(any(), any()))
.willThrow(new DevpickException(ErrorCode.TREND_NOT_FOUND));

mockMvc.perform(get("/trends/analysis"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.success").value(false));
}

@Test
@DisplayName("GET /trends/analysis/{periodStart} - 특정 기간 트렌드 분석 조회 성공 시 200 반환")
void getAnalysisByPeriod_success_returns200() throws Exception {
LocalDate periodStart = LocalDate.of(2026, 4, 14);
TrendAnalysisResponse response = new TrendAnalysisResponse(
"weekly", periodStart, LocalDate.of(2026, 4, 20),
"2026년 4월 3주차", List.of(), "요약", "컬렉션 요약");
given(trendAnalysisService.getByPeriod(eq("weekly"), eq("global"), eq(periodStart)))

Check warning on line 124 in src/test/java/com/devpick/domain/trend/controller/TrendControllerTest.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this and every subsequent useless "eq(...)" invocation; pass the values directly.

See more on https://sonarcloud.io/project/issues?id=Devpick-Org_devpick-backend&issues=AZ2--S7MIZWI7jsKlNQV&open=AZ2--S7MIZWI7jsKlNQV&pullRequest=132
.willReturn(response);

mockMvc.perform(get("/trends/analysis/2026-04-14"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.unit").value("weekly"));
}

@Test
@DisplayName("GET /trends/analysis/{periodStart} - 잘못된 날짜 형식이면 400 반환")
void getAnalysisByPeriod_invalidDate_returns400() throws Exception {
mockMvc.perform(get("/trends/analysis/not-a-date"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.success").value(false));
}
}
Loading
Loading