-
Notifications
You must be signed in to change notification settings - Fork 0
feat: 복약 전화 미수신 시 재발신 및 통보 채널 분리 #80
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| # @RequiredArgsConstructor가 만드는 생성자 파라미터로 Spring 주입 애노테이션을 복사한다. | ||
| # 이 설정이 없으면 필드에 붙인 @Qualifier가 생성자에 전달되지 않아, | ||
| # 같은 타입 빈이 둘 이상일 때 주입이 모호해져 기동에 실패한다. | ||
| lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Qualifier | ||
| lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Value |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,6 +4,7 @@ | |
| import java.time.LocalDateTime; | ||
| import java.util.List; | ||
| import java.util.Locale; | ||
| import java.util.Map; | ||
| import java.util.Optional; | ||
|
|
||
| import org.springframework.beans.factory.annotation.Value; | ||
|
|
@@ -18,10 +19,16 @@ | |
| import com.piuda.callcare.domain.medication.enums.MealTime; | ||
| import com.piuda.callcare.domain.medication.repository.MedicationScheduleRepository; | ||
| import com.piuda.callcare.domain.medicationlog.service.command.MedicationLogCommandService; | ||
| import com.piuda.callcare.domain.notification.enums.NotificationType; | ||
| import com.piuda.callcare.domain.notification.util.NotificationTimeCalculator; | ||
| import com.piuda.callcare.domain.senior.entity.Senior; | ||
| import com.piuda.callcare.domain.senior.repository.SeniorRepository; | ||
| import com.piuda.callcare.domain.senior.service.sms.SmsSender; | ||
| import com.piuda.callcare.global.config.fcm.FcmRecipient; | ||
| import com.piuda.callcare.global.config.fcm.FcmSendRequest; | ||
| import com.piuda.callcare.global.config.fcm.FcmSendResult; | ||
| import com.piuda.callcare.global.config.fcm.FcmSendService; | ||
| import com.piuda.callcare.global.config.fcm.FcmSendStatus; | ||
| import com.piuda.callcare.global.exception.CallCareException; | ||
| import com.piuda.callcare.global.exception.ErrorCode; | ||
|
|
||
|
|
@@ -36,14 +43,22 @@ public class CallReminderCommandService { | |
| private static final List<MealTime> CALL_MEAL_TIMES = List.of( | ||
| MealTime.BREAKFAST, MealTime.LUNCH, MealTime.DINNER); | ||
| private static final List<CallStatus> NOTIFIABLE_STATUSES = List.of(CallStatus.PENDING, CallStatus.NO_ANSWER, CallStatus.FAILED); | ||
| private static final int GUARDIAN_SWEEP_OFFSET_MINUTES = 20; | ||
| // 최초 발신 후 이 시간이 지나도록 수신되지 않으면 1회 재발신한다 | ||
| private static final int RETRY_DELAY_MINUTES = 10; | ||
| // 재발신 대상으로 볼 최대 경과 시간. 스케줄러가 오래 멈춰 있었거나 재발신 도입 전에 쌓인 | ||
| // 과거 행까지 한꺼번에 발신하는 것을 막는다 — 한 시간 넘게 지난 복약 전화는 다시 걸지 않는다. | ||
| private static final int RETRY_MAX_AGE_MINUTES = 60; | ||
| // 마지막 발신(재발신 포함) 후 이 시간이 지나도록 수신되지 않으면 보호자·부모님에게 통보한다. | ||
| // 재발신 시 calledAt이 갱신되므로 최초 발신 기준으로는 총 20분이다. | ||
| private static final int GUARDIAN_SWEEP_OFFSET_MINUTES = 10; | ||
|
|
||
| private final SeniorRepository seniorRepository; | ||
| private final MedicationScheduleRepository medicationScheduleRepository; | ||
| private final CallLogRepository callLogRepository; | ||
| private final VoiceCallSender voiceCallSender; | ||
| private final SmsSender smsSender; | ||
| private final MedicationLogCommandService medicationLogCommandService; | ||
| private final FcmSendService fcmSendService; | ||
|
|
||
| @Value("${coolsms.sender:}") | ||
| private String senderNumber; | ||
|
|
@@ -64,6 +79,21 @@ public void sendDueFirstCalls(LocalDateTime now) { | |
| } | ||
| } | ||
|
|
||
| // 재발신 스윕: 최초 발신 후 RETRY_DELAY_MINUTES가 지나도록 수신되지 않은 콜을 1회 더 발신한다. | ||
| // 한 건의 실패가 스윕 전체를 멈추지 않도록 건별로 예외를 가둔다(sendDueFirstCalls와 동일 패턴). | ||
| public void retryUnansweredCalls(LocalDateTime now) { | ||
| LocalDateTime calledBefore = now.minusMinutes(RETRY_DELAY_MINUTES); | ||
| LocalDateTime calledAfter = now.minusMinutes(RETRY_MAX_AGE_MINUTES); | ||
| for (CallLog callLog : callLogRepository.findRetryTargets(NOTIFIABLE_STATUSES, calledAfter, calledBefore)) { | ||
| try { | ||
| retryCall(callLog, now); | ||
| } catch (Exception e) { | ||
| log.error("전화 알림 재발신 처리 실패 - callLogId={}, seniorId={}, mealTime={}", | ||
| callLog.getId(), callLog.getSenior().getId(), callLog.getMealTime(), e); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| public void notifyGuardiansForUnansweredCalls(LocalDateTime now) { | ||
| LocalDateTime notificationThreshold = now.minusMinutes(GUARDIAN_SWEEP_OFFSET_MINUTES); | ||
| callLogRepository.findGuardianNotificationTargets(NOTIFIABLE_STATUSES, notificationThreshold) | ||
|
|
@@ -76,7 +106,7 @@ public String triggerMedicationCallForTest(Long seniorId, MealTime mealTime) { | |
| .orElseThrow(() -> new CallCareException(ErrorCode.SENIOR_NOT_FOUND)); | ||
|
|
||
| LocalDate today = LocalDate.now(); | ||
| if (!medicationScheduleRepository.existsActiveScheduleForCall(seniorId, mealTime, today)) { | ||
| if (!medicationScheduleRepository.existsUntakenScheduleForCall(seniorId, mealTime, today)) { | ||
| throw new CallCareException(ErrorCode.MEDICATION_SCHEDULE_NOT_FOUND); | ||
| } | ||
|
|
||
|
|
@@ -104,23 +134,30 @@ public void applyCallResult(String messageId, String rawStatus) { | |
| } | ||
| return; | ||
| } | ||
| // 미수신·발신 실패는 1차 콜이면 통보하지 않는다 — 재발신 스윕이 10분 뒤 한 번 더 걸기 때문. | ||
| // 2차(마지막) 콜의 결과일 때만 즉시 통보하고, 웹훅이 오지 않는 경우는 통보 스윕이 받아준다. | ||
| if (result == CallResult.NO_ANSWER) { | ||
| if (callLog.markNoAnswer()) { | ||
| callLogRepository.save(callLog); | ||
| notifyGuardian(callLog); | ||
| if (callLog.hasRetried()) { | ||
| notifyGuardian(callLog); | ||
| } | ||
| } | ||
| return; | ||
| } | ||
| if (result == CallResult.FAILED) { | ||
| if (callLog.markFailed()) { | ||
| callLogRepository.save(callLog); | ||
| notifyGuardian(callLog); | ||
| if (callLog.hasRetried()) { | ||
| notifyGuardian(callLog); | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private void sendFirstCallIfDue(Senior senior, MealTime mealTime, LocalDate today, LocalDateTime now) { | ||
| if (!medicationScheduleRepository.existsActiveScheduleForCall(senior.getId(), mealTime, today)) { | ||
| // 그 시간대에 아직 안 먹은 약이 있을 때만 발신한다 — 보호자가 이미 체크했으면 전화 자체를 걸지 않는다. | ||
| if (!medicationScheduleRepository.existsUntakenScheduleForCall(senior.getId(), mealTime, today)) { | ||
| return; | ||
| } | ||
|
|
||
|
|
@@ -183,12 +220,63 @@ private Optional<CallLog> preemptCallLog(Senior senior, MealTime mealTime, Local | |
| } | ||
| } | ||
|
|
||
| // 재발신 1건. UNIQUE(senior_id, meal_time, call_date) 때문에 새 row를 만들 수 없으므로 같은 row를 갱신한다. | ||
| // 발신하기로 정했으면 상태를 먼저 확정(선점)한 뒤 발신한다 — 재발신은 유실보다 중복이 나쁘기 때문 | ||
| // (중복은 곧 어르신에게 전화 2통). 선점으로 retryCount가 1이 되어 다음 스윕이 같은 row를 집지 않는다. | ||
| private void retryCall(CallLog callLog, LocalDateTime now) { | ||
| Senior senior = callLog.getSenior(); | ||
| MealTime mealTime = callLog.getMealTime(); | ||
|
|
||
| // 1차 발신 이후 보호자가 그 시간대를 체크했을 수 있다 — 그러면 재발신하지 않는다. | ||
| if (!medicationScheduleRepository.existsUntakenScheduleForCall(senior.getId(), mealTime, callLog.getCallDate())) { | ||
| skipRetry(callLog, "시간대 복약 완료"); | ||
| return; | ||
| } | ||
|
|
||
| if (!StringUtils.hasText(senior.getPhoneNumber())) { | ||
| skipRetry(callLog, "부모님 전화번호 없음"); | ||
| return; | ||
| } | ||
|
|
||
| callLog.markRetryPreempted(now); | ||
| callLogRepository.saveAndFlush(callLog); | ||
|
|
||
| String messageId; | ||
| try { | ||
| messageId = voiceCallSender.call( | ||
| senderNumber, | ||
| senior.getPhoneNumber(), | ||
| "콜케어 복약 알림입니다.", | ||
| bodyMessage(mealTime) | ||
| ); | ||
| } catch (Exception e) { | ||
| callLog.markFailed(); | ||
| callLogRepository.save(callLog); | ||
| log.error("SOLAPI 전화 알림 재발신 실패 - callLogId={}, seniorId={}, mealTime={}", | ||
| callLog.getId(), senior.getId(), mealTime, e); | ||
| return; | ||
|
Comment on lines
+241
to
+257
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -eu
file="src/main/java/com/piuda/callcare/domain/calllog/service/CallReminderCommandService.java"
printf '%s\n' '--- target file outline ---'
if command -v ast-grep >/dev/null 2>&1; then
ast-grep outline "$file" --lang java
fi
printf '%s\n' '--- target lines ---'
cat -n "$file" | sed -n '180,285p'
printf '%s\n' '--- related symbols and repository declarations ---'
rg -n -C 4 \
'findRetryTargets|retryCall|markRetryPreempted|retryCount|CallReminderCommandService|saveAndFlush' \
src/main/java src/test 2>/dev/null || trueRepository: PIUDAProject/Backend Length of output: 41726 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- all call-log service and repository files ---'
git ls-files | rg 'Call(Reminder|Log)|calllog|CallLog'
printf '%s\n' '--- transaction and scheduling annotations ---'
rg -n -C 3 \
'@(Transactional|Scheduled|Async)|findRetryTargets|retryCall|voiceCallSender\.call|markRetryPreempted|markFailed' \
src/main/java src/test 2>/dev/null || trueRepository: PIUDAProject/Backend Length of output: 43149 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- service transaction and retry flow ---'
cat -n src/main/java/com/piuda/callcare/domain/calllog/service/CallReminderCommandService.java | sed -n '1,105p'
printf '%s\n' '--- repository ---'
cat -n src/main/java/com/piuda/callcare/domain/calllog/repository/CallLogRepository.java | sed -n '1,90p'
printf '%s\n' '--- entity state and versioning ---'
cat -n src/main/java/com/piuda/callcare/domain/calllog/entity/CallLog.java | sed -n '1,135p'
printf '%s\n' '--- scheduler ---'
cat -n src/main/java/com/piuda/callcare/domain/calllog/scheduler/CallReminderScheduler.java | sed -n '1,55p'
printf '%s\n' '--- deterministic source verifier ---'
python3 - <<'PY'
from pathlib import Path
service = Path("src/main/java/com/piuda/callcare/domain/calllog/service/CallReminderCommandService.java").read_text()
repo = Path("src/main/java/com/piuda/callcare/domain/calllog/repository/CallLogRepository.java").read_text()
entity = Path("src/main/java/com/piuda/callcare/domain/calllog/entity/CallLog.java").read_text()
checks = {
"retry target query filters retryCount to zero":
"COALESCE(cl.retryCount, 0) = 0" in repo,
"retry flow mutates entity before saveAndFlush":
"callLog.markRetryPreempted(now);" in service and
service.index("callLog.markRetryPreempted(now);") <
service.index("callLogRepository.saveAndFlush(callLog);"),
"external call follows saveAndFlush":
service.index("callLogRepository.saveAndFlush(callLog);") <
service.index("voiceCallSender.call("),
"retry save is not conditional update":
"callLogRepository.saveAndFlush(callLog);" in service and
"update(" not in repo.lower(),
"CallLog has no optimistic version field":
"`@Version`" not in entity,
"service has no class-level transaction annotation":
"`@Transactional`" not in service,
}
for name, result in checks.items():
print(f"{name}: {'PASS' if result else 'FAIL'}")
PYRepository: PIUDAProject/Backend Length of output: 14928 재발신 선점을 원자적으로 처리하세요. 동시 실행이 가능한 환경에서 두 스윕이 같은
🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| callLog.markSent(messageId, now); | ||
| callLogRepository.save(callLog); | ||
| } | ||
|
|
||
| // 재발신하지 않기로 한 콜을 종료 상태로 돌린다 — 그냥 두면 재발신 스윕에 매분 다시 걸린다. | ||
| private void skipRetry(CallLog callLog, String reason) { | ||
| callLog.markSkipped(); | ||
| callLogRepository.save(callLog); | ||
| log.info("전화 알림 재발신 생략({}) - callLogId={}, seniorId={}, mealTime={}", | ||
| reason, callLog.getId(), callLog.getSenior().getId(), callLog.getMealTime()); | ||
| } | ||
|
|
||
| private String bodyMessage(MealTime mealTime) { | ||
| return mealTime.getDescription() + " 약을 복용할 시간입니다."; | ||
| } | ||
|
|
||
| private void completeMealTimeMedicationLogs(CallLog callLog) { | ||
| LocalDate date = callLog.getCreatedAt().toLocalDate(); | ||
| // callDate가 이 콜이 대응하는 복약 날짜다(UNIQUE 키의 일부). 재발신으로 calledAt이 갱신되므로 | ||
| // 발신 시각이 아니라 callDate를 기준으로 삼아야 어느 날짜의 복약인지가 흔들리지 않는다. | ||
| LocalDate date = callLog.getCallDate(); | ||
| medicationScheduleRepository.findActiveSchedulesForMealTime( | ||
| callLog.getSenior().getId(), callLog.getMealTime(), date) | ||
| .forEach(schedule -> medicationLogCommandService.writeLog( | ||
|
|
@@ -203,20 +291,28 @@ private void notifyGuardian(CallLog callLog) { | |
| boolean hasRecipient = false; | ||
| boolean sentAny = false; | ||
|
|
||
| String guardianPhoneNumber = callLog.getSenior().getUser().getPhoneNumber(); | ||
| if (StringUtils.hasText(guardianPhoneNumber)) { | ||
| // 보호자는 앱을 쓰므로 FCM 푸시가 1순위다. 전달되지 못했으면(앱 미설치·전송 실패·FCM 미설정) | ||
| // 미수신 통보는 유실되면 안 되는 안전 알림이라 SMS로 폴백한다. | ||
| if (notifyGuardianByPush(callLog)) { | ||
| hasRecipient = true; | ||
| String guardianText = "[콜케어] " + callLog.getSenior().getName() + "님의 " | ||
| + callLog.getMealTime().getDescription() | ||
| + " 복약 전화 알림이 미수신되었습니다. 확인이 필요합니다."; | ||
| try { | ||
| smsSender.send(senderNumber, guardianPhoneNumber, guardianText); | ||
| sentAny = true; | ||
| } catch (Exception e) { | ||
| log.error("보호자 SMS 발송 실패 - seniorId={}, callLogId={}", callLog.getSenior().getId(), callLog.getId(), e); | ||
| } | ||
| sentAny = true; | ||
| } else { | ||
| log.warn("보호자 전화번호 없음 - seniorId={}, callLogId={}", callLog.getSenior().getId(), callLog.getId()); | ||
| String guardianPhoneNumber = callLog.getSenior().getUser().getPhoneNumber(); | ||
| if (StringUtils.hasText(guardianPhoneNumber)) { | ||
| hasRecipient = true; | ||
| String guardianText = "[콜케어] " + callLog.getSenior().getName() + "님의 " | ||
| + callLog.getMealTime().getDescription() | ||
| + " 복약 전화 알림이 미수신되었습니다. 확인이 필요합니다."; | ||
| try { | ||
| smsSender.send(senderNumber, guardianPhoneNumber, guardianText); | ||
| sentAny = true; | ||
| } catch (Exception e) { | ||
| log.error("보호자 SMS 폴백 발송 실패 - seniorId={}, callLogId={}", callLog.getSenior().getId(), callLog.getId(), e); | ||
| } | ||
| } else { | ||
| log.warn("보호자 푸시 미전달 + 전화번호 없음 - seniorId={}, callLogId={}", | ||
| callLog.getSenior().getId(), callLog.getId()); | ||
| } | ||
| } | ||
|
|
||
| String seniorPhoneNumber = callLog.getSenior().getPhoneNumber(); | ||
|
|
@@ -240,6 +336,31 @@ private void notifyGuardian(CallLog callLog) { | |
| } | ||
| } | ||
|
|
||
| // 보호자 FCM 푸시 1건. FcmSendRequest는 "한 요청 = 한 수신자"라 다른 보호자에게 새지 않는다. | ||
| // FcmSendService는 트랜잭션 밖 호출이 계약인데 이 서비스에는 클래스 레벨 @Transactional이 없어 충족한다. | ||
| // data의 딥링크 키는 알림 종류를 아는 이 트리거가 채운다(type·notificationId는 발송 계층이 얹는다). | ||
| private boolean notifyGuardianByPush(CallLog callLog) { | ||
| Senior senior = callLog.getSenior(); | ||
| try { | ||
| FcmSendResult result = fcmSendService.send(new FcmSendRequest( | ||
| NotificationType.MISSED_CALL, | ||
| "복약 전화 미수신", | ||
| senior.getName() + "님이 " + callLog.getMealTime().getDescription() | ||
| + " 복약 전화를 받지 않았습니다. 확인이 필요합니다.", | ||
| new FcmRecipient(senior.getUser().getId(), senior.getId()), | ||
| Map.of( | ||
| "seniorId", String.valueOf(senior.getId()), | ||
| "mealTime", callLog.getMealTime().name(), | ||
| "callDate", callLog.getCallDate().toString() | ||
| ) | ||
| )); | ||
| return result.status() == FcmSendStatus.SENT; | ||
| } catch (Exception e) { | ||
| log.error("보호자 FCM 푸시 발송 실패 - seniorId={}, callLogId={}", senior.getId(), callLog.getId(), e); | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| private void validateCallMealTime(MealTime mealTime) { | ||
| if (!CALL_MEAL_TIMES.contains(mealTime)) { | ||
| throw new CallCareException(ErrorCode.UNSUPPORTED_MEAL_TIME); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
재발신 전의
messageId를 삭제하지 마세요.markRetryPreempted는 1차 발신의messageId를 즉시null로 변경합니다. 이후 1차 콜백이 지연 도착하면applyCallResult가 해당CallLog를 찾지 못하고 결과를 버립니다.시도별
messageId를 영속적으로 보존하세요. 콜백을 개별 발신 시도에 연결하세요. 재발신 직후 1차ANSWERED콜백이 도착하는 회귀 테스트도 추가하세요.🤖 Prompt for AI Agents