diff --git a/SpringBoot/src/main/java/Baemin/News_Deliver/Domain/Kakao/service/KakaoMessageService.java b/SpringBoot/src/main/java/Baemin/News_Deliver/Domain/Kakao/service/KakaoMessageService.java index 03fb432..1e7598e 100644 --- a/SpringBoot/src/main/java/Baemin/News_Deliver/Domain/Kakao/service/KakaoMessageService.java +++ b/SpringBoot/src/main/java/Baemin/News_Deliver/Domain/Kakao/service/KakaoMessageService.java @@ -25,22 +25,23 @@ import org.springframework.web.client.RestTemplate; import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; - /** * KakaoMessageService는 사용자의 키워드 기반으로 뉴스를 조회하고, * 해당 뉴스를 카카오톡 메시지로 전송하는 기능을 담당하는 서비스입니다. * * 주요 기능: * */ @@ -64,7 +65,6 @@ public class KakaoMessageService { @Value("${spring.security.oauth2.client.registration.kakao.client-secret}") private String kakaoClientSecret; - /** * 카카오 사용자 Access Token을 Refresh Token을 이용해 갱신 후 반환합니다. * @@ -75,7 +75,7 @@ public class KakaoMessageService { */ public String getKakaoUserAccessToken(String refreshAccessToken, Long userId) { - //유저의 accesstoken을 가져 올 것 + // 유저의 accesstoken을 가져 올 것 String accessToken = provider.refreshAccessToken(refreshAccessToken); if (accessToken == null || accessToken.isEmpty()) { throw new KakaoException(ErrorCode.KAKAO_TOKEN_ACCESS_FAILED); @@ -85,25 +85,46 @@ public String getKakaoUserAccessToken(String refreshAccessToken, Long userId) { return accessToken; } - /** * 카카오 메시지 전송 메서드 * * @param refreshAccessToken 유저의 리프레시 토큰 - * @param userId 유저의 고유 번호 + * @param userId 유저의 고유 번호 * @return T,F */ public boolean sendKakaoMessage(String refreshAccessToken, Long userId) { - - /* 유저의 세팅 리스트 반환 */ log.info("refreshAccessToken 발급 결과 :{}", refreshAccessToken); String accessToken = getKakaoUserAccessToken(refreshAccessToken, userId); log.info("accessToken 발급 결과:{}", accessToken); + + // 1. 유저의 모든 유효한 세팅 조회 List settings = settingService.getAllSettingsByUserId(userId); + + // LocalTime nowTime = LocalTime.now(); + // // 2. 현재 시간과 일치하는 세팅만 필터링 + // List currentSettings = settings.stream() + // .filter(setting -> + // setting.getDeliveryTime().equals(nowTime.truncatedTo(ChronoUnit.MINUTES))) + // .toList(); + + LocalTime nowTime = LocalTime.now().truncatedTo(ChronoUnit.MINUTES); + + List currentSettings = settings.stream() + .filter(setting -> { + LocalTime deliveryTime = setting.getDeliveryTime().toLocalTime().truncatedTo(ChronoUnit.MINUTES); + return deliveryTime.equals(nowTime); + }) + .toList(); + + // todo : 계속 여기서 문제가 생김 + if (currentSettings.isEmpty()) { + log.info("현재 시간에 발송할 세팅이 없습니다: {}", nowTime); + return false; + } + boolean anySuccess = false; - /* Setting을 순회하며 뉴스 리스트 저장&전송 */ - for (SettingDTO setting : settings) { + for (SettingDTO setting : currentSettings) { List newsList = newsService.searchNews( setting.getSettingKeywords(), setting.getBlockKeywords()); @@ -124,17 +145,55 @@ public boolean sendKakaoMessage(String refreshAccessToken, Long userId) { return anySuccess; } + // /** + // * 카카오 메시지 전송 메서드 + // * + // * @param refreshAccessToken 유저의 리프레시 토큰 + // * @param userId 유저의 고유 번호 + // * @return T,F + // */ + // public boolean sendKakaoMessage(String refreshAccessToken, Long userId) { + // + // /* 유저의 세팅 리스트 반환 */ + // log.info("refreshAccessToken 발급 결과 :{}", refreshAccessToken); + // String accessToken = getKakaoUserAccessToken(refreshAccessToken, userId); + // log.info("accessToken 발급 결과:{}", accessToken); + // List settings = settingService.getAllSettingsByUserId(userId); + // boolean anySuccess = false; + // + // /* Setting을 순회하며 뉴스 리스트 저장&전송 */ + // for (SettingDTO setting : settings) { + // List newsList = newsService.searchNews( + // setting.getSettingKeywords(), setting.getBlockKeywords()); + // + // if (newsList == null || newsList.isEmpty()) { + // log.info("세팅 ID {}에 해당하는 뉴스가 없음", setting.getId()); + // continue; + // } + // + // if (newsList.size() > 5) { + // newsList = newsList.subList(0, 5); + // } + // + // saveHistory(newsList, List.of(setting)); + // boolean success = sendSingleKakaoMessage(accessToken, newsList); + // anySuccess = anySuccess || success; + // } + // + // return anySuccess; + // } + /** * 개별 카카오톡 전송 메섣, * * @param accessToken 유저 엑세스 토큰 - * @param newsList 뉴스 리스트 + * @param newsList 뉴스 리스트 * @return T,F */ private boolean sendSingleKakaoMessage(String accessToken, List newsList) { try { - /* 헤더 설정 */ + /* 헤더 설정 */ HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); headers.set("Authorization", "Bearer " + accessToken); @@ -171,6 +230,7 @@ private boolean sendSingleKakaoMessage(String accessToken, List * Deprecated된 메서드는 하단에 정리하였습니다. * * @param userId 유저의 고유 번호 + * * @return 각 세팅에 맞는 뉴스 기사 리스트 */ private List getNewsEsDocumentList_Fixed(Long userId) { @@ -181,7 +241,7 @@ private List getNewsEsDocumentList_Fixed(Long userId) { log.info("셋팅값 확인용 코드 : " + setting.getSettingKeywords()); log.info("셋팅 제외 확인용 코드 : " + setting.getBlockKeywords()); - List keywords = setting.getSettingKeywords(); // 예: [이재명] + List keywords = setting.getSettingKeywords(); // 예: [이재명] List blockKeywords = setting.getBlockKeywords(); // 예: [한국, 중국] if (keywords == null || keywords.isEmpty()) { @@ -220,7 +280,7 @@ private static Map createTemplateData(List newsL log.info("뉴스 전체 리스트 확인:" + newsList); Map templateArgs = new HashMap<>(); - //메세지 5개 고정 + // 메세지 5개 고정 for (int i = 0; i < Math.min(5, newsList.size()); i++) { NewsEsDocument news = newsList.get(i); templateArgs.put("TITLE" + (i + 1), news.getTitle()); @@ -236,8 +296,8 @@ private static Map createTemplateData(List newsL /** * 전송된 뉴스 정보를 히스토리로 저장합니다. 중복 뉴스는 저장하지 않습니다. * - * @param newsList 뉴스 리스트 - * @param settings 해당 뉴스에 적용된 사용자 설정들 + * @param newsList 뉴스 리스트 + * @param settings 해당 뉴스에 적용된 사용자 설정들 * @return 저장이 이루어진 경우 true, 아무 것도 저장되지 않았으면 false */ private boolean saveHistory(List newsList, List settings) { @@ -246,12 +306,13 @@ private boolean saveHistory(List newsList, List sett throw new KakaoException(ErrorCode.NO_NEWS_DATA); } - //중복 저장 방지용 플래그 + // 중복 저장 방지용 플래그 boolean saved = false; for (NewsEsDocument newsDoc : newsList) { -// News newsitem = newsRepository.findById(Long.parseLong(newsDoc.getId())) -// .orElseThrow(() -> new RuntimeException("뉴스가 존재하지 않습니다: " + newsDoc.getId())); + // News newsitem = newsRepository.findById(Long.parseLong(newsDoc.getId())) + // .orElseThrow(() -> new RuntimeException("뉴스가 존재하지 않습니다: " + + // newsDoc.getId())); /* DB와 ES 동기화 되어있지 않을 시, 예외 */ News newsitem = newsRepository.findById(Long.parseLong(newsDoc.getId())) @@ -277,8 +338,9 @@ private boolean saveHistory(List newsList, List sett .setting(setting) .news(newsitem) .settingKeyword(String.join(",", settingDTO.getSettingKeywords())) - .blockKeyword(settingDTO.getBlockKeywords() != null ? - String.join(",", settingDTO.getBlockKeywords()) : null) + .blockKeyword( + settingDTO.getBlockKeywords() != null ? String.join(",", settingDTO.getBlockKeywords()) + : null) .build(); historyRepository.save(history); @@ -291,96 +353,100 @@ private boolean saveHistory(List newsList, List sett // ======================= Deprecated ========================= -// /** -// * 사용자의 키워드에 맞는 뉴스를 검색한 후, 카카오 메시지를 전송합니다. -// * -// * @param refreshAccessToken 사용자 카카오 Refresh Token -// * @param userId 사용자 고유 ID -// * @return 메시지 전송 성공 여부 (true: 성공, false: 실패) -// */ -// public boolean sendKakaoMessage(String refreshAccessToken, Long userId) { -// try { -// -// /** -// * 문제 정의 : 세팅 1번에 대해서만 메시지가 전송된다. -// */ -// -// /* 유저에게 맞는 뉴스 리스트 검색*/ -// String accessToken = getKakaoUserAccessToken(refreshAccessToken, userId); -// List newsList = getNewsEsDocumentList_Fixed(userId); -// if (newsList == null) new KakaoException(ErrorCode.NO_NEWS_DATA);; -// -// /* Http 요청 헤더 설정 */ -// HttpHeaders headers = new HttpHeaders(); -// headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); -// headers.set("Authorization", "Bearer " + accessToken); -// -// /* 템플릿 설정(ES로 검색한 뉴스 리스트 넘겨받음) */ -// Map templateArgs = createTemplateData(newsList); -// -// /* JSON 문자열로 변환 */ -// ObjectMapper objectMapper = new ObjectMapper(); -// String templateArgsJson = objectMapper.writeValueAsString(templateArgs); -// -// /* 요청 파라미터 구성 */ -// MultiValueMap params = new LinkedMultiValueMap<>(); -// params.add("template_id", "122080"); -// params.add("template_args", templateArgsJson); -// -// /* 카카오 메시지 전송 */ -// HttpEntity> entity = new HttpEntity<>(params, headers); -// ResponseEntity response = restTemplate.postForEntity(KAKAO_SEND_TOME_URL, entity, String.class); -// log.info("카카오 메시지 전송 응답: {}", response.getBody()); -// -// return response.getStatusCode() == HttpStatus.OK; -// -// } catch (Exception e) { -// log.error("카카오 메시지 전송 실패: ", e); -// throw new KakaoException(ErrorCode.MESSAGE_SEND_FAILED); -// } -// } - -// /** -// * 사용자의 Setting 정보를 기반으로 키워드에 해당하는 뉴스를 검색하여 반환합니다. -// * 뉴스는 히스토리에 저장되며, 최대 5개까지 템플릿으로 전송됩니다. -// * -// * @param userId 사용자 고유 ID -// * @return 뉴스 리스트 {@code List}, 키워드가 없거나 오류 시 {@code null} -// */ -// private List getNewsEsDocumentList(Long userId) { -// -// //유저 정보를 기준으로 Settig값 가져오기 -// List settings = settingService.getAllSettingsByUserId(userId); -// -// List keywords = new ArrayList<>(); -// List blockKeywords = new ArrayList<>(); -// -// for (SettingDTO setting : settings) { -// log.info("셋팅값 확인용 코드 : " + setting.getSettingKeywords()); -// log.info("셋팅 제외 확인용 코드 : " + setting.getBlockKeywords()); -// -// // 키워드리스트의 null 값 체크 -// if (setting.getSettingKeywords() != null) { -// keywords.add(setting.getSettingKeywords().toString()); -// } -// -// blockKeywords.add(setting.getBlockKeywords().toString()); -// } -// -// if (keywords.isEmpty()) { -// log.error("설정된 키워드가 없습니다."); -// throw new KakaoException(ErrorCode.SETTING_NOT_FOUND); -// } -// -// //키워드별 뉴스 검색 -// List newsList = newsService.searchNews(keywords, blockKeywords); -// -// log.info("검색된 뉴스 수: {}", newsList.size()); -// newsList.forEach(n -> log.info("뉴스: {} - {}", n.getPublisher(), n.getSummary())); -// -// // 검색된 뉴스를 히스토리로 보내는 코드 -// if (saveHistory(newsList, settings)) return null; -// return newsList; -// } + // /** + // * 사용자의 키워드에 맞는 뉴스를 검색한 후, 카카오 메시지를 전송합니다. + // * + // * @param refreshAccessToken 사용자 카카오 Refresh Token + // * @param userId 사용자 고유 ID + // * @return 메시지 전송 성공 여부 (true: 성공, false: 실패) + // */ + // public boolean sendKakaoMessage(String refreshAccessToken, Long userId) { + // try { + // + // /** + // * 문제 정의 : 세팅 1번에 대해서만 메시지가 전송된다. + // */ + // + // /* 유저에게 맞는 뉴스 리스트 검색*/ + // String accessToken = getKakaoUserAccessToken(refreshAccessToken, userId); + // List newsList = getNewsEsDocumentList_Fixed(userId); + // if (newsList == null) new KakaoException(ErrorCode.NO_NEWS_DATA);; + // + // /* Http 요청 헤더 설정 */ + // HttpHeaders headers = new HttpHeaders(); + // headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + // headers.set("Authorization", "Bearer " + accessToken); + // + // /* 템플릿 설정(ES로 검색한 뉴스 리스트 넘겨받음) */ + // Map templateArgs = createTemplateData(newsList); + // + // /* JSON 문자열로 변환 */ + // ObjectMapper objectMapper = new ObjectMapper(); + // String templateArgsJson = objectMapper.writeValueAsString(templateArgs); + // + // /* 요청 파라미터 구성 */ + // MultiValueMap params = new LinkedMultiValueMap<>(); + // params.add("template_id", "122080"); + // params.add("template_args", templateArgsJson); + // + // /* 카카오 메시지 전송 */ + // HttpEntity> entity = new HttpEntity<>(params, + // headers); + // ResponseEntity response = + // restTemplate.postForEntity(KAKAO_SEND_TOME_URL, entity, String.class); + // log.info("카카오 메시지 전송 응답: {}", response.getBody()); + // + // return response.getStatusCode() == HttpStatus.OK; + // + // } catch (Exception e) { + // log.error("카카오 메시지 전송 실패: ", e); + // throw new KakaoException(ErrorCode.MESSAGE_SEND_FAILED); + // } + // } + + // /** + // * 사용자의 Setting 정보를 기반으로 키워드에 해당하는 뉴스를 검색하여 반환합니다. + // * 뉴스는 히스토리에 저장되며, 최대 5개까지 템플릿으로 전송됩니다. + // * + // * @param userId 사용자 고유 ID + // * @return 뉴스 리스트 {@code List}, 키워드가 없거나 오류 시 {@code null} + // */ + // private List getNewsEsDocumentList(Long userId) { + // + // //유저 정보를 기준으로 Settig값 가져오기 + // List settings = settingService.getAllSettingsByUserId(userId); + // + // List keywords = new ArrayList<>(); + // List blockKeywords = new ArrayList<>(); + // + // for (SettingDTO setting : settings) { + // log.info("셋팅값 확인용 코드 : " + setting.getSettingKeywords()); + // log.info("셋팅 제외 확인용 코드 : " + setting.getBlockKeywords()); + // + // // 키워드리스트의 null 값 체크 + // if (setting.getSettingKeywords() != null) { + // keywords.add(setting.getSettingKeywords().toString()); + // } + // + // blockKeywords.add(setting.getBlockKeywords().toString()); + // } + // + // if (keywords.isEmpty()) { + // log.error("설정된 키워드가 없습니다."); + // throw new KakaoException(ErrorCode.SETTING_NOT_FOUND); + // } + // + // //키워드별 뉴스 검색 + // List newsList = newsService.searchNews(keywords, + // blockKeywords); + // + // log.info("검색된 뉴스 수: {}", newsList.size()); + // newsList.forEach(n -> log.info("뉴스: {} - {}", n.getPublisher(), + // n.getSummary())); + // + // // 검색된 뉴스를 히스토리로 보내는 코드 + // if (saveHistory(newsList, settings)) return null; + // return newsList; + // } } \ No newline at end of file diff --git a/SpringBoot/src/main/java/Baemin/News_Deliver/Domain/Mypage/Repository/SettingRepository.java b/SpringBoot/src/main/java/Baemin/News_Deliver/Domain/Mypage/Repository/SettingRepository.java index 7e59765..3c10f07 100644 --- a/SpringBoot/src/main/java/Baemin/News_Deliver/Domain/Mypage/Repository/SettingRepository.java +++ b/SpringBoot/src/main/java/Baemin/News_Deliver/Domain/Mypage/Repository/SettingRepository.java @@ -65,4 +65,26 @@ public interface SettingRepository extends JpaRepository { "AND (s.isDeleted IS NULL OR s.isDeleted = false)") List findAllValidSettingsWithDays(@Param("now") LocalDateTime now); + /** + * Hot Fix + * What : 세팅 고유 번로를 통한 세팅 객체 반환 + * How : Fetch join으로 미리 컬렉션을 로딩 + * Why : keywords, blockKeywords 필드는 지연 로딩문제 + * Who : 류성열 + * When : 2025-07-24 + * + * + * @param id 세팅 고유 번호 + * @return 세팅 + */ + @Query(""" + SELECT s FROM Setting s + LEFT JOIN FETCH s.days + LEFT JOIN FETCH s.keywords + LEFT JOIN FETCH s.blockKeywords + WHERE s.id = :id + """) + Optional findByIdWithAllAssociations(@Param("id") Long id); + + } diff --git a/SpringBoot/src/main/java/Baemin/News_Deliver/Domain/Mypage/service/SettingService.java b/SpringBoot/src/main/java/Baemin/News_Deliver/Domain/Mypage/service/SettingService.java index fe3c183..0cf6cef 100644 --- a/SpringBoot/src/main/java/Baemin/News_Deliver/Domain/Mypage/service/SettingService.java +++ b/SpringBoot/src/main/java/Baemin/News_Deliver/Domain/Mypage/service/SettingService.java @@ -284,4 +284,13 @@ public List getAllSettings() { public Setting getById(Long settingId) { return settingRepository.findById(settingId).get(); } + + @Transactional(readOnly = true) + public SettingDTO getSettingById(Long id) { + Setting setting = settingRepository.findById(id) + .orElseThrow(() -> new SettingException(ErrorCode.SETTING_NOT_FOUND)); + + return convertToDTO(setting); + } + } \ No newline at end of file diff --git a/SpringBoot/src/main/java/Baemin/News_Deliver/Global/Scheduler/TaskSchedulerService.java b/SpringBoot/src/main/java/Baemin/News_Deliver/Global/Scheduler/TaskSchedulerService.java index 3eedf69..1a14426 100644 --- a/SpringBoot/src/main/java/Baemin/News_Deliver/Global/Scheduler/TaskSchedulerService.java +++ b/SpringBoot/src/main/java/Baemin/News_Deliver/Global/Scheduler/TaskSchedulerService.java @@ -30,15 +30,18 @@ * 동적으로 카카오 뉴스 메시지를 전송할 스케줄을 등록/취소하는 서비스입니다. * *

- * Spring의 {@link TaskScheduler}를 사용하여 사용자별로 개별 {@link ScheduledFuture} 작업을 관리합니다. + * Spring의 {@link TaskScheduler}를 사용하여 사용자별로 개별 {@link ScheduledFuture} 작업을 + * 관리합니다. * 각 작업은 userId-settingId 조합으로 유니크하게 식별됩니다. *

* - *

기능 요약:

+ *

+ * 기능 요약: + *

*
    - *
  • 사용자 설정에 따른 cron 스케줄 등록
  • - *
  • 기존 스케줄 취소 및 갱신
  • - *
  • 스케줄 실행 시 카카오 메시지 발송 트리거
  • + *
  • 사용자 설정에 따른 cron 스케줄 등록
  • + *
  • 기존 스케줄 취소 및 갱신
  • + *
  • 스케줄 실행 시 카카오 메시지 발송 트리거
  • *
*/ @@ -47,7 +50,7 @@ @Slf4j public class TaskSchedulerService { - @Qualifier("customTaskScheduler") //커스텀한 테스크 스케쥴러로 등록함. + @Qualifier("customTaskScheduler") // 커스텀한 테스크 스케쥴러로 등록함. private final TaskScheduler taskScheduler; private final KakaoMessageService kakaoMessageService; @@ -75,6 +78,9 @@ public class TaskSchedulerService { public void scheduleUser(Setting setting) { Long settingId = setting.getId(); + // findByIdWithAllAssociations 사용 + // setting = settingRepository.findByIdWithAllAssociations(settingId) + // .orElseThrow(() -> new KakaoException(ErrorCode.SETTING_NOT_FOUND)); // Fetch Join으로 days 미리 가져와서 LazyInitializationException 방지 setting = settingRepository.findByIdWithDays(settingId) .orElseThrow(() -> new KakaoException(ErrorCode.SETTING_NOT_FOUND)); @@ -82,7 +88,7 @@ public void scheduleUser(Setting setting) { Long userId = setting.getUser().getId(); String taskKey = generateTaskKey(userId, settingId); - // 이미 등록된 경우 기존 스케줄 취소 + /* 이미 등록된 경우 기존 스케줄 취소 */ if (scheduledTasks.containsKey(taskKey)) { cancelUser(userId, settingId); } @@ -102,7 +108,17 @@ public void scheduleUser(Setting setting) { log.info("[Scheduler] 유저 {} / setting {} 메시지 발송 트리거 - {}", userId, settingId, LocalDateTime.now()); try { - Optional optionalSetting = settingRepository.findById(settingId); + + /** + * + * Hot Fix : Setting Keyword Feth Join 문제 해결 시도 + * + * + */ + Optional optionalSetting = settingRepository.findByIdWithDays(settingId); + // Optional optionalSetting = + // settingRepository.findByIdWithAllAssociations(settingId); //안됨됨 + // Optional optionalSetting = settingRepository.findById(settingId); if (optionalSetting.isEmpty()) { log.warn("[Scheduler] 유저 {} / setting {} 설정 정보가 없음", userId, settingId); @@ -111,8 +127,12 @@ public void scheduleUser(Setting setting) { Setting settings = optionalSetting.get(); - log.info("[Scheduler] 유저 {} / setting {} 키워드: {}, 제외 키워드: {}", - userId, settingId, settings.getKeywords(), settings.getBlockKeywords()); + // blockKeywords, keywords가 꼭 필요하다면 강제 초기화 + // Hibernate.initialize(settings.getBlockKeywords()); + // Hibernate.initialize(settings.getKeywords()); + + // log.info("[Scheduler] 유저 {} / setting {} 키워드: {}, 제외 키워드: {}", + // userId, settingId, settings.getKeywords(), settings.getBlockKeywords()); kakaoMessageService.sendKakaoMessage(refreshAccessToken, userId); @@ -152,7 +172,7 @@ public void cancelUser(Long userId, Long settingId) { * @param settingId 설정 ID * @return userId-settingId 형식의 문자열 키 */ - //중복 스케쥴러 삭제 메서드용 + // 중복 스케쥴러 삭제 메서드용 private String generateTaskKey(Long userId, Long settingId) { return userId + "-" + settingId; }