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
Expand Up @@ -17,6 +17,7 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.http.*;
import org.springframework.stereotype.Component;
import org.springframework.util.LinkedMultiValueMap;
Expand Down Expand Up @@ -121,10 +122,13 @@ public void getNewsEsDocumentList_Fixed(Long userId) {
/**
* 전송된 뉴스 정보를 히스토리로 저장합니다. 중복 뉴스는 저장하지 않습니다.
*
* 히스토리 저장 시 히스토리 조회 캐시 삭제
*
* @param newsList 뉴스 리스트
* @param settings 해당 뉴스에 적용된 사용자 설정들
* @return 저장이 이루어진 경우 true, 아무 것도 저장되지 않았으면 false
*/
@CacheEvict(value = "groupedNewsHistory", allEntries = true)
public boolean saveHistory(List<NewsEsDocument> newsList, List<SettingDTO> settings) {
if (newsList == null || newsList.isEmpty()) {
log.warn("해당 키워드로 검색된 뉴스가 없습니다.");
Expand Down Expand Up @@ -180,6 +184,7 @@ public boolean saveHistory(List<NewsEsDocument> newsList, List<SettingDTO> setti
* @param newsList 뉴스 리스트
* @return T,F
*/
@CacheEvict(value = "groupedNewsHistory", allEntries = true)
public boolean sendSingleKakaoMessage(String accessToken, List<NewsEsDocument> newsList) {
try {

Expand Down Expand Up @@ -219,11 +224,6 @@ public boolean sendSingleKakaoMessage(String accessToken, List<NewsEsDocument> n
*/
public void processSetting(String accessToken, SettingDTO setting) {

/* deprecated */
// // 뉴스 검색
// List<NewsEsDocument> newsList = kakaoNewsService.searchNews(
// setting.getSettingKeywords(), setting.getBlockKeywords());

// 뉴스 검색
List<NewsEsDocument> newsList = kakaoNewsService.searchNewsWithFallback(
setting.getSettingKeywords(), setting.getBlockKeywords());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,13 @@
import Baemin.News_Deliver.Global.Exception.ErrorCode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

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

/**
* 뉴스 배달 설정(Setting) 도메인 서비스
Expand Down Expand Up @@ -59,7 +60,11 @@ public class SettingService {
* @return 생성된 Setting ID
*/
@Transactional
@CacheEvict(value = "userSettingCache", key = "'user:' + #settingDTO.userId")
public Long saveSetting(SettingDTO settingDTO) {

log.info("[Cache Evict] {}번 유저 Setting 캐시 삭제(세팅 저장 API 호출)",settingDTO.getUserId());

User user = userRepository.findById(settingDTO.getUserId())
.orElseThrow(() -> new SettingException(ErrorCode.USER_NOT_FOUND));

Expand All @@ -76,6 +81,7 @@ public Long saveSetting(SettingDTO settingDTO) {
.build();

/**
* Add for Bug_Fix
* What : Setting 객체 저장 코드
* Why : Setting 데이터 저장 없이, 자식 관계인 SettingKeyword는 저장되는 상황 발생 -> Error 발생
* When : 2025-07-21
Expand Down Expand Up @@ -103,7 +109,11 @@ public Long saveSetting(SettingDTO settingDTO) {
* @return 204 No Content 응답
*/
@Transactional
@CacheEvict(value = "userSettingCache", key = "'user:' + #settingDTO.userId")
public void updateSetting(SettingDTO settingDTO) {

log.info("[Cache Evict] {}번 유저 Setting 캐시 삭제(세팅 수정 API 호출)",settingDTO.getUserId());

Setting setting = settingRepository.findById(settingDTO.getId())
.orElseThrow(() -> new SettingException(ErrorCode.SETTING_NOT_FOUND));

Expand Down Expand Up @@ -142,7 +152,11 @@ public void updateSetting(SettingDTO settingDTO) {
* @return 사용자 설정 리스트
*/
@Transactional(readOnly = true)
@Cacheable(value = "userSettingCache", key = "'user:' + #userId")
public List<SettingDTO> getAllSettingsByUserId(Long userId) {

log.info("[Cache Miss] {}번 유저 Setting 캐시 miss(세팅 조회 API 호출)",userId);

User user = userRepository.findById(userId)
.orElseThrow(() -> new SettingException(ErrorCode.USER_NOT_FOUND));

Expand All @@ -164,7 +178,10 @@ public List<SettingDTO> getAllSettingsByUserId(Long userId) {
* @return 204 No Content 응답
*/
@Transactional
@CacheEvict(value = "userSettingCache", key = "'user:' + #userId")
public void deleteSetting(Long settingId, Long userId) {

log.info("[Cache Evict] {}번 유저 Setting 캐시 삭제(세팅 삭제 API 호출)",userId);
Setting setting = settingRepository.findById(settingId)
.orElseThrow(() -> new SettingException(ErrorCode.SETTING_NOT_FOUND));

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@

/**
* 내 히스토리 조회하기 메서드 (페이지 네이션 적용)
*
* @param page 시작 페이지
* @param size 페이지 사이즈
* @return 페이지 네이션이 적용된 히스토리
*/
public PageResponse<GroupedNewsHistoryResponse> getGroupedNewsHistory(int page, int size) {
Long userId = 1L;

// 1. 모든 히스토리 조회
List<History> allHistories = historyRepository.findAllBySetting_User_Id(userId);

// 2. 히스토리 ID 수집 → Feedback 일괄 조회
List<Long> historyIds = allHistories.stream()
.map(History::getId)
.collect(Collectors.toList());

Map<Long, Feedback> feedbackMap = feedbackRepository.findAllById(historyIds)
.stream()
.collect(Collectors.toMap(fb -> fb.getHistory().getId(), fb -> fb));

// 3. 그룹핑: settingId + publishedAt(HOUR)
Map<String, List<History>> grouped = allHistories.stream()
.collect(Collectors.groupingBy(h -> {
Long settingId = h.getSetting().getId();
LocalDateTime truncatedPublishedAt = h.getPublishedAt().truncatedTo(ChronoUnit.HOURS);
return settingId + "_" + truncatedPublishedAt;
}));

// 4. DTO 변환
List<GroupedNewsHistoryResponse> groupedList = grouped.entrySet().stream()
.map(entry -> {
List<History> histories = entry.getValue();
History any = histories.get(0);

List<NewsHistoryResponse> newsResponses = histories.stream()
.map(history -> {
Feedback feedback = feedbackMap.get(history.getId());
return NewsHistoryResponse.from(history, feedback);
})
.toList();

return GroupedNewsHistoryResponse.builder()
.settingId(any.getSetting().getId())
.publishedAt(any.getPublishedAt().truncatedTo(ChronoUnit.HOURS))
.settingKeyword(any.getSettingKeyword())
.blockKeyword(any.getBlockKeyword())
.newsList(newsResponses)
.build();
})
.sorted(Comparator.comparing(GroupedNewsHistoryResponse::getPublishedAt).reversed())
.collect(Collectors.toList());

// 5. 페이지네이션
int fromIndex = page * size;
int toIndex = Math.min(fromIndex + size, groupedList.size());

if (fromIndex >= groupedList.size()) {
return Collections.emptyList();
}

return groupedList.subList(fromIndex, toIndex);
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import Baemin.News_Deliver.Global.Exception.ErrorCode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.stereotype.Service;

import java.util.Optional;
Expand All @@ -26,13 +27,15 @@ public class FeedBackService {
/**
* 키워드 반영도 피드백 메서드
*
* 빠르게 구현해야 해서 캐시는 전부 삭제로 임시 구현
*
* @param request 피드백 요청 DTO
* @return 피드백 결과 반환
*/
@CacheEvict(value = "groupedNewsHistory", allEntries = true)
public Long keywordFeedBack(FeedbackRequest request){

// 임시 하드코딩
// Long userId = 1L;
log.info("[CacheEvict] 피드백 API 호출로 인한, 히스토리 캐시 삭제");

/* 피드백 객체 반환 */
Optional<Feedback> optionalFeedback = feedbackRepository.findById(request.getHistoryId());
Expand Down Expand Up @@ -77,11 +80,16 @@ public Long keywordFeedBack(FeedbackRequest request){
/**
* 콘텐츠 품질 피드백 메서드
*
* 빠르게 구현해야 해서 캐시는 전부 삭제로 임시 구현
*
* @param request 피드백 요청 DTO
* @return 피드백 결과 반환
*/
@CacheEvict(value = "groupedNewsHistory", allEntries = true)
public Long contentQualityFeedback(FeedbackRequest request) {

log.info("[CacheEvict] 피드백 API 호출로 인한, 히스토리 캐시 삭제");

// 피드백 조회
Optional<Feedback> optionalFeedback = feedbackRepository.findById(request.getHistoryId());

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,31 @@

import Baemin.News_Deliver.Domain.SubServices.MoreNews.DTO.GroupedNewsHistoryResponse;
import Baemin.News_Deliver.Domain.SubServices.MoreNews.DTO.PageResponse;
import Baemin.News_Deliver.Domain.SubServices.MoreNews.Service.HistoryService;
import Baemin.News_Deliver.Domain.SubServices.MoreNews.Service.MoreNewsService;
import Baemin.News_Deliver.Global.News.ElasticSearch.dto.NewsEsDocument;
import Baemin.News_Deliver.Global.ResponseObject.ApiResponseWrapper;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;

import java.io.IOException;
import java.util.List;

@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/sub/history")
@Tag(name = "Sub/More-News", description = "서브 / 뉴스 더보기 API")
public class MoreNewsController {

private final MoreNewsService moreNewsService;
private final HistoryService historyService;

/**
* 뉴스 더보기 API
Expand Down Expand Up @@ -67,8 +71,9 @@ public ResponseEntity<ApiResponseWrapper<PageResponse<GroupedNewsHistoryResponse
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "3") int size
) {

// 내 히스토리 조회하기 서비스 레이어 호출
PageResponse<GroupedNewsHistoryResponse> groupedList = moreNewsService.getGroupedNewsHistory(page, size, authentication);
PageResponse<GroupedNewsHistoryResponse> groupedList = historyService.getGroupedNewsHistory(page, size, authentication);

return ResponseEntity.ok(new ApiResponseWrapper<>(groupedList, "히스토리가 성공적으로 조회되었습니다."));
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package Baemin.News_Deliver.Domain.SubServices.MoreNews.Service;

import Baemin.News_Deliver.Domain.Auth.Service.AuthService;
import Baemin.News_Deliver.Domain.Kakao.entity.History;
import Baemin.News_Deliver.Domain.Kakao.repository.HistoryRepository;
import Baemin.News_Deliver.Domain.SubServices.FeedBack.Entity.Feedback;
import Baemin.News_Deliver.Domain.SubServices.FeedBack.Repository.FeedbackRepository;
import Baemin.News_Deliver.Domain.SubServices.MoreNews.DTO.GroupedNewsHistoryResponse;
import Baemin.News_Deliver.Domain.SubServices.MoreNews.DTO.NewsHistoryResponse;
import Baemin.News_Deliver.Domain.SubServices.MoreNews.DTO.PageResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Service;

import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

@Slf4j
@Service
@RequiredArgsConstructor
public class HistoryService {

private final HistoryRepository historyRepository;
private final FeedbackRepository feedbackRepository;
private final AuthService authService;

/**
* 내 히스토리 조회하기 메서드 Ver2.0
*
* Updated
* Why : 프론트 레이어에서 페이지 네이셔닝을 위한 정보 부족
* How : 반환 DTO에 페이지네이션 정보 추가
* When : 2025-07-21
* Who : 류성열
*
* @param page 현재 페이지 번호 (0부터 시작)
* @param size 페이지당 아이템 수
* @param authentication 로그인 인증 객체 (카카오 ID 포함)
* @return 페이지 정보 + 그룹핑된 뉴스 히스토리 데이터
*
* // @CacheEvict(cacheNames = "groupedNewsHistory", allEntries = true)
*/
@Cacheable(
value = "groupedNewsHistory", // Redis에서 사용할 캐시 이름
key = "'user:' + #authentication.name + ':page:' + #page + ':size:' + #size", // 캐시 Key
cacheManager = "redisCacheManager"
)
public PageResponse<GroupedNewsHistoryResponse> getGroupedNewsHistory(int page, int size, Authentication authentication) {

/* 캐시 미스 로그 */
log.info("[Cache Miss] {}번 유저 내 히스토리 조회 시 캐시 미스 : DB를 조회합니다.",authentication.getName());

// 1. 인증 객체에서 카카오 ID 추출 → 유저 ID 조회
String kakaoId = authentication.getName();
Long userId = authService.findByKakaoId(kakaoId).getId();

// 2. 해당 유저가 수신한 모든 뉴스 히스토리 조회
List<History> allHistories = historyRepository.findAllBySetting_User_Id(userId);

// 3. 히스토리 ID 리스트 생성
List<Long> historyIds = allHistories.stream()
.map(History::getId)
.toList();

// 4. 피드백 정보를 미리 조회하여 Map<HistoryId, Feedback>으로 변환
Map<Long, Feedback> feedbackMap = feedbackRepository.findAllById(historyIds)
.stream()
.collect(Collectors.toMap(fb -> fb.getHistory().getId(), fb -> fb));

// 5. 히스토리를 "설정 ID + 시간(시 단위)" 기준으로 그룹핑
Map<String, List<History>> grouped = allHistories.stream()
.collect(Collectors.groupingBy(h -> {
Long settingId = h.getSetting().getId();
LocalDateTime truncatedPublishedAt = h.getPublishedAt().truncatedTo(ChronoUnit.HOURS);
return settingId + "_" + truncatedPublishedAt;
}));

// 6. 그룹핑된 데이터 가공 → GroupedNewsHistoryResponse로 변환
List<GroupedNewsHistoryResponse> groupedList = grouped.entrySet().stream()
.map(entry -> {
List<History> histories = entry.getValue();
History any = histories.get(0); // 대표 히스토리 하나 선택

// 각 뉴스 히스토리마다 Feedback과 함께 DTO로 변환
List<NewsHistoryResponse> newsResponses = histories.stream()
.map(history -> {
Feedback feedback = feedbackMap.get(history.getId());
return NewsHistoryResponse.from(history, feedback);
})
.toList();

// 그룹 단위로 응답 객체 생성
return GroupedNewsHistoryResponse.builder()
.settingId(any.getSetting().getId())
.publishedAt(any.getPublishedAt().truncatedTo(ChronoUnit.HOURS))
.settingKeyword(any.getSettingKeyword())
.blockKeyword(any.getBlockKeyword())
.newsList(newsResponses)
.build();
})
// 최신순으로 정렬 (publishedAt 기준)
.sorted(Comparator.comparing(GroupedNewsHistoryResponse::getPublishedAt).reversed())
.toList();

// 7. 페이지네이션 처리 (subList 사용)
int total = groupedList.size();
int fromIndex = page * size;
int toIndex = Math.min(fromIndex + size, total);

// 요청한 페이지 범위를 벗어난 경우 빈 리스트 반환
List<GroupedNewsHistoryResponse> paginated = fromIndex >= total
? Collections.emptyList()
: groupedList.subList(fromIndex, toIndex);

// 8. 최종 응답 DTO 반환 (페이지 정보 포함)
return PageResponse.<GroupedNewsHistoryResponse>builder()
.data(paginated)
.currentPage(page)
.pageSize(size)
.totalPages((int) Math.ceil((double) total / size))
.totalElements(total)
.build();
}


}
Loading