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
@@ -0,0 +1,47 @@
package com.devpick.domain.trend.controller;

import com.devpick.domain.trend.service.TrendAnalysisService;
import com.devpick.global.common.exception.DevpickException;
import com.devpick.global.common.exception.ErrorCode;
import com.devpick.global.common.response.ApiResponse;

Check warning on line 6 in src/main/java/com/devpick/domain/trend/controller/InternalTrendCacheController.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this unused import 'com.devpick.global.common.response.ApiResponse'.

See more on https://sonarcloud.io/project/issues?id=Devpick-Org_devpick-backend&issues=AZ2_-9HHfNOAnk5qkcq-&open=AZ2_-9HHfNOAnk5qkcq-&pullRequest=134
import io.swagger.v3.oas.annotations.Hidden;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

import java.time.LocalDate;

@Hidden
@RestController
@RequestMapping("/internal/trends")
@RequiredArgsConstructor
public class InternalTrendCacheController {

private final TrendAnalysisService trendAnalysisService;

@Value("${ai.server.internal-key:}")
private String cacheEvictKey;

@DeleteMapping("/cache")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void evictCache(
@RequestHeader(value = "X-Internal-Key", required = false) String key,
@RequestParam(defaultValue = "weekly") String unit,
@RequestParam(defaultValue = "global") String scope,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate periodStart) {
if (cacheEvictKey == null || cacheEvictKey.isBlank()) {
throw new DevpickException(ErrorCode.ENDPOINT_NOT_FOUND);
}
if (key == null || !cacheEvictKey.equals(key)) {
throw new DevpickException(ErrorCode.FORBIDDEN);
}
trendAnalysisService.evictCache(unit, scope, periodStart);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,22 +15,21 @@

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

@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";

Check failure on line 32 in src/main/java/com/devpick/domain/trend/service/TrendAnalysisService.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "trend:analysis:" 4 times.

See more on https://sonarcloud.io/project/issues?id=Devpick-Org_devpick-backend&issues=AZ2_-9J7fNOAnk5qkcq_&open=AZ2_-9J7fNOAnk5qkcq_&pullRequest=134
TrendAnalysisResponse cached = getFromRedis(key);
if (cached != null) return cached;

Expand All @@ -39,7 +38,7 @@
.orElseThrow(() -> new DevpickException(ErrorCode.TREND_NOT_FOUND));

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

Expand All @@ -54,10 +53,32 @@
.orElseThrow(() -> new DevpickException(ErrorCode.TREND_NOT_FOUND));

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

public void evictCache(String unit, String scope, LocalDate periodStart) {
List<String> keys = new ArrayList<>();
keys.add("trend:analysis:" + unit + ":" + scope + ":latest");
if (periodStart != null) {
keys.add("trend:analysis:" + unit + ":" + scope + ":" + periodStart);
}
try {
redisTemplate.delete(keys);
log.info("트렌드 캐시 무효화 완료: unit={}, scope={}, periodStart={}", unit, scope, periodStart);
} catch (Exception e) {
log.warn("트렌드 캐시 무효화 실패 (무시): unit={}, scope={}", unit, scope);
}
}

private Duration resolveTtl(String unit) {
return switch (unit) {
case "daily" -> Duration.ofHours(25);
case "monthly" -> Duration.ofDays(32);
default -> Duration.ofDays(8); // weekly
};
}

private TrendAnalysisResponse getFromRedis(String key) {
try {
String json = redisTemplate.opsForValue().get(key);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
.requestMatchers("/reports/weekly/share/**").permitAll()
.requestMatchers(HttpMethod.POST, "/internal/reports/weekly/run-batch").permitAll()
.requestMatchers(HttpMethod.POST, "/internal/reports/weekly/backfill-from-history").permitAll()
.requestMatchers(HttpMethod.DELETE, "/internal/trends/cache").permitAll()
.requestMatchers("/swagger-ui/**", "/swagger-ui.html", "/v3/api-docs/**").permitAll()
.requestMatchers("/dev/**").permitAll()
// 공개 읽기: 콘텐츠 피드·게시글·타인 프로필·트렌드 (비로그인 시 개인화 없이 최신순 피드 제공)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package com.devpick.domain.trend.controller;

import com.devpick.domain.trend.service.TrendAnalysisService;
import com.devpick.global.common.exception.GlobalExceptionHandler;
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.test.util.ReflectionTestUtils;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;

import java.time.LocalDate;

import static org.mockito.ArgumentMatchers.any;

Check warning on line 18 in src/test/java/com/devpick/domain/trend/controller/InternalTrendCacheControllerTest.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this unused import 'org.mockito.ArgumentMatchers.any'.

See more on https://sonarcloud.io/project/issues?id=Devpick-Org_devpick-backend&issues=AZ2_-9KEfNOAnk5qkcrB&open=AZ2_-9KEfNOAnk5qkcrB&pullRequest=134
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@ExtendWith(MockitoExtension.class)
class InternalTrendCacheControllerTest {

private MockMvc mockMvc;

@Mock
private TrendAnalysisService trendAnalysisService;

@InjectMocks
private InternalTrendCacheController controller;

private static final String VALID_KEY = "test-secret-key";

@BeforeEach
void setUp() {
mockMvc = MockMvcBuilders
.standaloneSetup(controller)
.setControllerAdvice(new GlobalExceptionHandler())
.build();
ReflectionTestUtils.setField(controller, "cacheEvictKey", VALID_KEY);
}

@Test
@DisplayName("유효한 키 + periodStart 없음 — latest 캐시만 무효화하고 204 반환")
void evictCache_validKey_noPeriodStart_returns204() throws Exception {
mockMvc.perform(delete("/internal/trends/cache")
.header("X-Internal-Key", VALID_KEY)
.param("unit", "weekly")
.param("scope", "global"))
.andExpect(status().isNoContent());

verify(trendAnalysisService).evictCache(eq("weekly"), eq("global"), isNull());
}

@Test
@DisplayName("유효한 키 + periodStart 있음 — latest + 특정 기간 캐시 무효화하고 204 반환")
void evictCache_validKey_withPeriodStart_returns204() throws Exception {
mockMvc.perform(delete("/internal/trends/cache")
.header("X-Internal-Key", VALID_KEY)
.param("unit", "weekly")
.param("scope", "global")
.param("periodStart", "2026-04-14"))
.andExpect(status().isNoContent());

verify(trendAnalysisService).evictCache(eq("weekly"), eq("global"), eq(LocalDate.of(2026, 4, 14)));

Check warning on line 70 in src/test/java/com/devpick/domain/trend/controller/InternalTrendCacheControllerTest.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_-9KEfNOAnk5qkcrA&open=AZ2_-9KEfNOAnk5qkcrA&pullRequest=134
}

@Test
@DisplayName("키 불일치 — 403 반환")
void evictCache_wrongKey_returns403() throws Exception {
mockMvc.perform(delete("/internal/trends/cache")
.header("X-Internal-Key", "wrong-key"))
.andExpect(status().isForbidden());

verifyNoInteractions(trendAnalysisService);
}

@Test
@DisplayName("키 헤더 없음 — 403 반환")
void evictCache_noKey_returns403() throws Exception {
mockMvc.perform(delete("/internal/trends/cache"))
.andExpect(status().isForbidden());

verifyNoInteractions(trendAnalysisService);
}

@Test
@DisplayName("키 미설정 시 — 404 반환")
void evictCache_keyNotConfigured_returns404() throws Exception {
ReflectionTestUtils.setField(controller, "cacheEvictKey", "");

mockMvc.perform(delete("/internal/trends/cache")
.header("X-Internal-Key", VALID_KEY))
.andExpect(status().isNotFound());

verifyNoInteractions(trendAnalysisService);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,22 @@
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;

import java.util.List;

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

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;

Expand Down Expand Up @@ -74,7 +78,7 @@ void setUp() throws Exception {
.generatedAt(LocalDateTime.now())
.build();

given(redisTemplate.opsForValue()).willReturn(valueOperations);
lenient().when(redisTemplate.opsForValue()).thenReturn(valueOperations);
}

@Test
Expand Down Expand Up @@ -179,4 +183,31 @@ void getLatest_redisStoreFails_stillReturnsResponse() {
assertThat(result).isNotNull();
assertThat(result.unit()).isEqualTo(UNIT);
}

@Test
@DisplayName("evictCache — periodStart 없으면 latest 키 1개만 삭제한다")
void evictCache_withoutPeriodStart_deletesLatestKey() {
trendAnalysisService.evictCache(UNIT, SCOPE, null);

verify(redisTemplate).delete(List.of("trend:analysis:" + UNIT + ":" + SCOPE + ":latest"));
}

@Test
@DisplayName("evictCache — periodStart 있으면 latest + 기간 키 2개 삭제한다")
void evictCache_withPeriodStart_deletesBothKeys() {
trendAnalysisService.evictCache(UNIT, SCOPE, PERIOD_START);

verify(redisTemplate).delete(List.of(
"trend:analysis:" + UNIT + ":" + SCOPE + ":latest",
"trend:analysis:" + UNIT + ":" + SCOPE + ":" + PERIOD_START));
}

@Test
@DisplayName("evictCache — Redis 삭제 실패해도 예외를 던지지 않는다")
void evictCache_redisThrows_noException() {
given(redisTemplate.delete(any(List.class))).willThrow(new RuntimeException("Redis 연결 실패"));

assertThatCode(() -> trendAnalysisService.evictCache(UNIT, SCOPE, null))
.doesNotThrowAnyException();
}
}
Loading