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
35 changes: 35 additions & 0 deletions src/main/java/com/ryu/studyhelper/common/MissionCyclePolicy.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.ryu.studyhelper.common;

import java.time.Clock;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;

/**
* 미션 사이클 도메인 정책
* 매일 오전 6시를 기준으로 미션 사이클이 갱신된다.
*/
public class MissionCyclePolicy {

public static final LocalTime MISSION_RESET_TIME = LocalTime.of(6, 0);

/**
* 현재 미션 사이클의 시작 시각을 반환한다.
* 오전 6시 이전이면 전날 오전 6시를 반환한다.
*/
public static LocalDateTime getMissionCycleStart(Clock clock) {
LocalDateTime now = LocalDateTime.now(clock);
return toMissionDate(now).atTime(MISSION_RESET_TIME);
}

/**
* 주어진 시각이 속하는 미션 날짜를 반환한다.
* 오전 6시 이전이면 전날로 취급한다.
*/
public static LocalDate toMissionDate(LocalDateTime dateTime) {
if (dateTime.toLocalTime().isBefore(MISSION_RESET_TIME)) {
return dateTime.toLocalDate().minusDays(1);
}
return dateTime.toLocalDate();
}
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,94 +1,95 @@
package com.ryu.studyhelper.recommendation.service;
package com.ryu.studyhelper.recommendation.service;

import com.ryu.studyhelper.recommendation.dto.internal.BatchResult;
import com.ryu.studyhelper.infrastructure.mail.sender.MailSender;
import com.ryu.studyhelper.recommendation.domain.member.EmailSendStatus;
import com.ryu.studyhelper.recommendation.domain.member.MemberRecommendation;
import com.ryu.studyhelper.recommendation.mailbuilder.RecommendationMailBuilder;
import com.ryu.studyhelper.recommendation.repository.MemberRecommendationRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.ryu.studyhelper.common.MissionCyclePolicy;
import com.ryu.studyhelper.recommendation.dto.internal.BatchResult;
import com.ryu.studyhelper.infrastructure.mail.sender.MailSender;
import com.ryu.studyhelper.recommendation.domain.member.EmailSendStatus;
import com.ryu.studyhelper.recommendation.domain.member.MemberRecommendation;
import com.ryu.studyhelper.recommendation.mailbuilder.RecommendationMailBuilder;
import com.ryu.studyhelper.recommendation.repository.MemberRecommendationRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

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

/**
* 추천 이메일 발송
* 배치(sendAll)와 수동(send) 모두 담당
*/
@Service
@RequiredArgsConstructor
@Transactional
@Slf4j
public class RecommendationEmailService {

private final Clock clock;
private final MailSender mailSender;
private final RecommendationMailBuilder recommendationMailBuilder;
private final MemberRecommendationRepository memberRecommendationRepository;
/**
* 추천 이메일 발송
* 배치(sendAll)와 수동(send) 모두 담당
*/
@Service
@RequiredArgsConstructor
@Transactional
@Slf4j
public class RecommendationEmailService {

/**
* 배치: PENDING 상태의 추천들에 대해 이메일 발송
* 미션 사이클 기준(06:00~06:00)으로 조회
*/
public BatchResult sendAll() {
LocalDateTime now = LocalDateTime.now(clock);
LocalDateTime missionCycleStart = MissionCyclePolicy.getMissionCycleStart(clock);
log.info("이메일 발송 배치 시작: {} (미션 사이클: {} 06:00 ~)", now.toLocalDate(), missionCycleStart.toLocalDate());
private final Clock clock;
private final MailSender mailSender;
private final RecommendationMailBuilder recommendationMailBuilder;
private final MemberRecommendationRepository memberRecommendationRepository;

List<MemberRecommendation> pendingRecommendations = memberRecommendationRepository
.findPendingRecommendationsByCreatedAtBetween(missionCycleStart, now, EmailSendStatus.PENDING);

int successCount = 0;
int failCount = 0;
/**
* 배치: PENDING 상태의 추천들에 대해 이메일 발송
* 미션 사이클 기준(06:00~06:00)으로 조회
*/
public BatchResult sendAll() {
LocalDateTime now = LocalDateTime.now(clock);
LocalDateTime missionCycleStart = MissionCyclePolicy.getMissionCycleStart(clock);
log.info("이메일 발송 배치 시작: {} (미션 사이클: {} 06:00 ~)", now.toLocalDate(), missionCycleStart.toLocalDate());

for (MemberRecommendation mr : pendingRecommendations) {
if (sendEmail(mr)) {
successCount++;
} else {
failCount++;
}
}
List<MemberRecommendation> pendingRecommendations = memberRecommendationRepository
.findPendingRecommendationsByCreatedAtBetween(missionCycleStart, now, EmailSendStatus.PENDING);

log.info("이메일 발송 배치 완료 - 대상: {}개, 성공: {}개, 실패: {}개",
pendingRecommendations.size(), successCount, failCount);
return new BatchResult(pendingRecommendations.size(), successCount, failCount);
}
int successCount = 0;
int failCount = 0;

/**
* 수동 추천: 해당 추천의 팀원들에게 이메일 즉시 발송
*/
public void send(List<MemberRecommendation> memberRecommendations) {
for (MemberRecommendation mr : memberRecommendations) {
sendEmail(mr);
for (MemberRecommendation mr : pendingRecommendations) {
if (sendEmail(mr)) {
successCount++;
} else {
failCount++;
}
}

private boolean sendEmail(MemberRecommendation mr) {
try {
String email = mr.getMember().getEmail();
if (email == null || email.isBlank()) {
mr.markEmailAsFailed();
memberRecommendationRepository.save(mr);
log.warn("회원 ID {}에 이메일이 없습니다", mr.getMember().getId());
return false;
}

mailSender.send(recommendationMailBuilder.build(mr));
log.info("이메일 발송 배치 완료 - 대상: {}개, 성공: {}개, 실패: {}개",
pendingRecommendations.size(), successCount, failCount);
return new BatchResult(pendingRecommendations.size(), successCount, failCount);
}

mr.markEmailAsSent();
memberRecommendationRepository.save(mr);
log.debug("회원 '{}' 이메일 발송 완료", mr.getMember().getHandle());
return true;
/**
* 수동 추천: 해당 추천의 팀원들에게 이메일 즉시 발송
*/
public void send(List<MemberRecommendation> memberRecommendations) {
for (MemberRecommendation mr : memberRecommendations) {
sendEmail(mr);
}
}

} catch (Exception e) {
private boolean sendEmail(MemberRecommendation mr) {
try {
String email = mr.getMember().getEmail();
if (email == null || email.isBlank()) {
mr.markEmailAsFailed();
memberRecommendationRepository.save(mr);
log.error("회원 ID {} 이메일 발송 실패", mr.getMember().getId(), e);
log.warn("회원 ID {}에 이메일이 없습니다", mr.getMember().getId());
return false;
}

mailSender.send(recommendationMailBuilder.build(mr));

mr.markEmailAsSent();
memberRecommendationRepository.save(mr);
log.debug("회원 '{}' 이메일 발송 완료", mr.getMember().getHandle());
return true;

} catch (Exception e) {
mr.markEmailAsFailed();
memberRecommendationRepository.save(mr);
log.error("회원 ID {} 이메일 발송 실패", mr.getMember().getId(), e);
return false;
}
Comment on lines +88 to 93
Copy link

@coderabbitai coderabbitai bot Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

catch 블록 내 save() 호출이 실패할 경우 예외가 전파됩니다.

Line 90의 memberRecommendationRepository.save(mr)가 DB 장애 등으로 실패하면, 이 예외는 catch 블록 밖으로 전파되어 sendAll()의 나머지 처리를 중단시킵니다. 클래스 레벨 @Transactional과 결합하면 이전 성공 건까지 모두 롤백될 수 있습니다.

🛡️ catch 블록 내 save 보호 제안
         } catch (Exception e) {
-            mr.markEmailAsFailed();
-            memberRecommendationRepository.save(mr);
-            log.error("회원 ID {} 이메일 발송 실패", mr.getMember().getId(), e);
+            try {
+                mr.markEmailAsFailed();
+                memberRecommendationRepository.save(mr);
+            } catch (Exception saveEx) {
+                log.error("회원 ID {} 실패 상태 저장 중 오류", mr.getMember().getId(), saveEx);
+            }
+            log.error("회원 ID {} 이메일 발송 실패", mr.getMember().getId(), e);
             return false;
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} catch (Exception e) {
mr.markEmailAsFailed();
memberRecommendationRepository.save(mr);
log.error("회원 ID {} 이메일 발송 실패", mr.getMember().getId(), e);
return false;
}
} catch (Exception e) {
try {
mr.markEmailAsFailed();
memberRecommendationRepository.save(mr);
} catch (Exception saveEx) {
log.error("회원 ID {} 실패 상태 저장 중 오류", mr.getMember().getId(), saveEx);
}
log.error("회원 ID {} 이메일 발송 실패", mr.getMember().getId(), e);
return false;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/com/ryu/studyhelper/recommendation/service/RecommendationEmailService.java`
around lines 88 - 93, The catch block in RecommendationEmailService (likely
inside sendAll()) calls memberRecommendationRepository.save(mr) which can throw
and escape the catch, cancelling remaining processing and (with class-level
`@Transactional`) rolling back prior successes; protect this by moving the save
into a protected persistence boundary: either wrap
memberRecommendationRepository.save(mr) in its own try-catch that logs any
persistence error (so it doesn't rethrow), or extract the save into a separate
method (e.g., persistFailedRecommendation(MemberRecommendation mr)) annotated
with `@Transactional`(propagation = REQUIRES_NEW) and call that from the catch
after mr.markEmailAsFailed(); ensure any exception from the save is caught and
logged so sendAll() can continue.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

추후 별도 이슈에서 해결예정

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ryuwldnjs, 네, 이해했습니다! 해당 내용은 추후 별도 이슈로 처리하는 것으로 확인했습니다.

필요하시다면 제가 이 내용을 추적할 수 있도록 GitHub 이슈를 생성해드릴 수 있습니다. 이슈 생성을 원하시면 말씀해주세요! 😊


🧠 Learnings used
Learnt from: CR
Repo: codemate-kr/codemate_BE PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-02-08T08:33:02.185Z
Learning: Applies to **/*Scheduler.java : Implement ProblemRecommendationScheduler to generate recommendations daily at 6 AM and EmailSendScheduler to send emails daily at 9 AM

}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.ryu.studyhelper.recommendation.service;

import com.ryu.studyhelper.common.MissionCyclePolicy;
import com.ryu.studyhelper.common.enums.CustomResponseStatus;
import com.ryu.studyhelper.common.exception.CustomException;
import com.ryu.studyhelper.problem.dto.projection.ProblemTagProjection;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.ryu.studyhelper.recommendation.service;

import com.ryu.studyhelper.common.MissionCyclePolicy;
import com.ryu.studyhelper.recommendation.dto.internal.BatchResult;
import com.ryu.studyhelper.recommendation.domain.RecommendationType;
import com.ryu.studyhelper.recommendation.repository.RecommendationRepository;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.ryu.studyhelper.team.service;

import com.ryu.studyhelper.common.MissionCyclePolicy;
import com.ryu.studyhelper.common.enums.CustomResponseStatus;
import com.ryu.studyhelper.common.exception.CustomException;
import com.ryu.studyhelper.member.domain.Member;
Expand All @@ -22,7 +23,6 @@

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.*;
import java.util.stream.Collectors;

Expand All @@ -42,7 +42,6 @@ public class TeamActivityService {

private static final int MAX_DAYS = 30;
private static final int DEFAULT_DAYS = 30;
private static final LocalTime MISSION_RESET_TIME = LocalTime.of(6, 0);

/**
* 팀 활동 현황 조회
Expand Down Expand Up @@ -101,20 +100,9 @@ private void validatePrivateTeamAccess(Team team, Long memberId) {

// ========== 기간 계산 ==========

/**
* 미션 사이클 기준 "오늘" 날짜 계산
* 오전 6시 이전이면 전날로 취급
*/
private LocalDate getMissionDate(LocalDateTime dateTime) {
if (dateTime.toLocalTime().isBefore(MISSION_RESET_TIME)) {
return dateTime.toLocalDate().minusDays(1);
}
return dateTime.toLocalDate();
}

private QueryPeriod calculateQueryPeriod(Integer days) {
int queryDays = calculateDays(days);
LocalDate endDate = getMissionDate(LocalDateTime.now());
LocalDate endDate = MissionCyclePolicy.toMissionDate(LocalDateTime.now());
LocalDate startDate = endDate.minusDays(queryDays - 1);
return QueryPeriod.of(queryDays, startDate, endDate);
}
Expand Down Expand Up @@ -229,7 +217,7 @@ private TeamActivityResponse.DailyActivity buildDailyActivity(
List<MemberSolvedStatus> memberSolvedStatuses) {

// 미션 사이클 기준 날짜 (6시 이전 생성분은 전날로 표시)
LocalDate date = getMissionDate(recommendation.getCreatedAt());
LocalDate date = MissionCyclePolicy.toMissionDate(recommendation.getCreatedAt());

List<TeamActivityResponse.ProblemInfo> problems = buildProblemInfoList(recommendation);
List<Long> problemIds = problems.stream()
Expand Down