Skip to content
Open
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 @@ -51,6 +51,11 @@ public ResponseEntity<CareCallSettingResponse> getCareCallSetting(@Parameter(hid
return ResponseEntity.ok(response);
}

/**
* 전화 서버로부터 통화 완료 raw 데이터를 수신하여 저장
* /care-call/test는 CareCallTestService.TEST_SETTING_ID(-1)를 사용하므로 저장을 건너뛴다.
* /care-call/immediate는 실제 DB의 settingId를 사용하므로 정상적으로 저장된다.
*/
@Override
@PostMapping("/call-data")
public ResponseEntity<CareCallRecord> receiveCallData(@Valid @RequestBody CareCallDataProcessRequest request) {
Expand All @@ -59,14 +64,22 @@ public ResponseEntity<CareCallRecord> receiveCallData(@Valid @RequestBody CareCa
return ResponseEntity.status(HttpStatus.CREATED).build();
}

/**
* 특정 어르신에게 cron을 통하지 않고, 즉시 케어콜을 발송 (베타테스트용)
* 실제 DB에 등록된 Elder와 CareCallSetting을 사용하며, 통화 결과가 정상적으로 DB에 저장
*/
@Override
@PostMapping("/care-call/immediate")
public ResponseEntity<String> sendImmediateCareCall(@Valid @RequestBody ImmediateCareCallRequest request) {
String result = careCallTestService.sendImmediateCall(request.getElderId(), request.getCareCallOption());
return ResponseEntity.ok(result);
}

//TODO: 커스텀 프롬프트 케어콜 테스트용, 개발 완료 후 삭제
/**
* 개발자용 케어콜 발신 테스트 API (프로덕션 미사용)
* staging 환경에서 프롬프트와 전화번호로 발신 로직만 확인하기 위한 용도
* 더미 Elder, CareCallSetting을 사용하며, 통화 결과(settingId = -1)는 DB에 저장되지 않는다
*/
@Override
@PostMapping("/care-call/test")
public ResponseEntity<String> testCareCall(@Valid @RequestBody CareCallTestRequest req) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,23 @@ public class CareCallService {
private final CareCallSettingRepository careCallSettingRepository;

/**
* 케어콜 통화 데이터를 저장하고 CareCallCompletedEvent 이벤트를 발행
*
* @param request 케어콜 데이터 처리 요청 정보를 담은 DTO
* @return 저장된 케어콜 기록 엔티티
* 전화 서버로부터 수신된 통화 raw 데이터를 저장하고 CareCallCompletedEvent를 발행
*
* settingId가 음수(< 0)인 경우 개발자용 테스트 발송(/care-call/test)의 결과이므로
* DB 저장 및 이벤트 발행을 건너뛴다
*
* @param request 전화 서버가 전송한 통화 완료 데이터
* @return 저장된 케어콜 기록 엔티티. 테스트 발송인 경우 null 반환
*/
@Transactional
public CareCallRecord saveCallData(CareCallDataProcessRequest request) {
log.info("통화 데이터 저장 시작: elderId={}, settingId={}", request.getElderId(), request.getSettingId());

if (request.getSettingId() != null && request.getSettingId() < 0) {
log.info("테스트 발송 결과 수신 (settingId={}), 저장을 건너뜁니다.", request.getSettingId());
return null;
}

Elder elder = elderRepository.findById(request.getElderId())
.orElseThrow(() -> new CustomException(ErrorCode.ELDER_NOT_FOUND));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ public CareCallRecord processUploadedCallData(CallDataUploadRequest request) {
.build();

CareCallRecord saved = careCallService.saveCallData(processRequest);
if (saved == null) {
log.info("테스트 발송 - 저장 건너뜀 (settingId={})", processRequest.getSettingId());
return null;
}
log.info("STT 데이터 처리 완료: recordId={}", saved.getId());
return saved;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@

import com.example.medicare_call.domain.CareCallSetting;
import com.example.medicare_call.domain.Elder;
import com.example.medicare_call.domain.Member;
import com.example.medicare_call.dto.carecall.CareCallTestRequest;
import com.example.medicare_call.dto.carecall.ImmediateCareCallRequest.CareCallOption;
import com.example.medicare_call.global.enums.CallType;
import com.example.medicare_call.global.enums.ElderRelation;
import com.example.medicare_call.global.enums.ResidenceType;
import com.example.medicare_call.global.exception.CustomException;
import com.example.medicare_call.global.exception.ErrorCode;
import com.example.medicare_call.repository.CareCallSettingRepository;
import com.example.medicare_call.repository.ElderRepository;
import com.example.medicare_call.service.carecall.outbound.client.CareCallClient;
import lombok.RequiredArgsConstructor;
Expand All @@ -22,56 +22,57 @@
@RequiredArgsConstructor
public class CareCallTestService {

/** 테스트 발송 시 사용하는 settingId. 실제 CareCallSetting과 무관한 식별용 음수 값 */
static final int TEST_SETTING_ID = -1;

private final ElderRepository elderRepository;
private final com.example.medicare_call.service.carecall.setting.CareCallSettingService careCallSettingService;
private final CareCallSettingRepository careCallSettingRepository;
private final CareCallRequestSenderService careCallRequestSenderService;
private final CareCallClient careCallClient;

// TODO: KUIT 데모데이 시연용 일시적 기능으로 불완전합니다. 제거 혹은 개선이 필요합니다.
/**
* 특정 어르신에게 즉시 케어콜을 발송
* 특정 어르신에게 즉시 케어콜을 발송 (베타테스트용)
* 실제 DB에 등록된 Elder와 CareCallSetting을 기반으로 발송하며,
* 통화 결과는 실제 케어콜과 동일하게 웹훅을 통해 DB에 저장
*
* @param elderId 대상 어르신 ID
* @param careCallOption 케어콜 옵션 (회차 정보 등)
* @return 발송 완료 메시지
*/
@Transactional
@Transactional(readOnly = true)
public String sendImmediateCall(Long elderId, CareCallOption careCallOption) {
Elder elder = elderRepository.findById(elderId.intValue())
Comment on lines 43 to 44
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Long → int 묵시적 변환 — 잠재적 오버플로우

elderId.intValue()Long 값이 Integer.MAX_VALUE를 초과할 경우 조용히 잘립니다. 현재 Elder ID가 int 타입으로 관리되고 있어 실제로 문제가 되지 않을 수 있지만, 파라미터 타입이 Long인데 내부에서 int로 강제 변환하는 구조 자체가 다소 불일치하네요. 가능하다면 Elder의 ID 타입을 Long으로 통일하거나, 범위 초과 시 명시적인 예외를 던지는 게 더 안전합니다.

🛡️ 방어적 처리 예시
-        Elder elder = elderRepository.findById(elderId.intValue())
+        if (elderId > Integer.MAX_VALUE) {
+            throw new CustomException(ErrorCode.ELDER_NOT_FOUND, "유효하지 않은 어르신 ID: " + elderId);
+        }
+        Elder elder = elderRepository.findById(elderId.intValue())
📝 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
public String sendImmediateCall(Long elderId, CareCallOption careCallOption) {
Elder elder = elderRepository.findById(elderId.intValue())
public String sendImmediateCall(Long elderId, CareCallOption careCallOption) {
if (elderId > Integer.MAX_VALUE) {
throw new CustomException(ErrorCode.ELDER_NOT_FOUND, "유효하지 않은 어르신 ID: " + elderId);
}
Elder elder = elderRepository.findById(elderId.intValue())
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/com/example/medicare_call/service/carecall/outbound/CareCallTestService.java`
around lines 43 - 44, In sendImmediateCall in CareCallTestService you silently
convert Long elderId to int via elderId.intValue() which can overflow; update
the code to avoid implicit narrowing by either (A) changing the Elder ID domain
to Long everywhere and use elderRepository.findById(elderId) (preferred), or (B)
if repository truly requires int, explicitly check elderId against
Integer.MIN_VALUE/MAX_VALUE and throw a clear IllegalArgumentException before
calling elderRepository.findById(elderId.intValue()); locate usages of
sendImmediateCall and the elderRepository.findById call to ensure consistent ID
types across Elder, repository, and method signatures.

.orElseThrow(() -> new CustomException(ErrorCode.ELDER_NOT_FOUND, "케어콜 발송 대상 어르신을 찾을 수 없습니다. ID: " + elderId));

try {
CareCallSetting setting = careCallSettingService.getOrCreateImmediateSetting(elder);
CallType callType = convertOptionToCallType(careCallOption);
CareCallSetting setting = careCallSettingRepository.findByElder(elder)
.orElseThrow(() -> new CustomException(ErrorCode.CARE_CALL_SETTING_NOT_FOUND,
"즉시 케어콜 발송 대상 어르신의 케어콜 설정을 찾을 수 없습니다. ID: " + elderId));
CallType callType = convertOptionToCallType(careCallOption);

careCallRequestSenderService.sendCall(setting.getId(), elderId.intValue(), callType);
return String.format("%s 어르신께 즉시 케어콜 발송이 완료되었습니다.", elder.getName());
} catch (Exception e) {
log.error("즉시 케어콜 발송 실패 - elderId: {}, error: {}", elder.getId(), e.getMessage());
throw new CustomException(ErrorCode.INTERNAL_SERVER_ERROR, "케어콜 발송 중 오류가 발생했습니다: " + e.getMessage());
}
careCallRequestSenderService.sendCall(setting.getId(), elderId.intValue(), callType);
return String.format("%s 어르신께 즉시 케어콜 발송이 완료되었습니다.", elder.getName());
}

// TODO: 테스트 서버를 운용하게 될 경우, @Profile로 분리를 권장
/**
* 테스트용 케어콜을 발신
* 가상의 어르신 데이터(더미)를 사용하여 실제 발신 로직만 테스트하고 DB 저장은 수행하지 않는다
*
* 개발자용 케어콜 발신 테스트 (production에서는 미사용)
* 더미 Elder 데이터를 사용하여 발신 로직만 확인하는 용도입니다.
* TEST_SETTING_ID(-1)를 사용하므로, 통화 완료 후 웹훅이 수신되어도 DB에 저장되지 않습니다.
*
* @param req 테스트할 프롬프트와 전화번호 정보
*/
public void sendTestCall(CareCallTestRequest req) {
// 테스트용이라 하드코딩된 더미 데이터 사용, DB 저장 안함
Elder testElder = Elder.builder()
.id(100)
.name("김옥자") // 테스트 이름
.name("김옥자")
.phone("01011111111")
.gender((byte)0)
.relationship(ElderRelation.CHILD)
.residenceType(ResidenceType.ALONE)
.build();

String testPrompt = req.prompt();
// 테스트 호출은 settingId 100으로 가정
careCallClient.requestCall(100, testElder.getId(), req.phoneNumber(), testPrompt);
careCallClient.requestCall(TEST_SETTING_ID, testElder.getId(), req.phoneNumber(), testPrompt);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalTime;

@Service
@RequiredArgsConstructor
Expand Down Expand Up @@ -98,30 +97,4 @@ private void validateManageAuthority(Integer memberId, Integer elderId) {
throw new CustomException(ErrorCode.HANDLE_ACCESS_DENIED);
}
}

/**
* 즉시 케어콜을 위한 케어콜 설정을 생성하거나 조회
* 1차 케어콜 시간을 현재 시간으로 설정하여 반환
*
* @param elder 대상 어르신 엔티티
* @return 케어콜 설정 엔티티
*/
@Transactional
public CareCallSetting getOrCreateImmediateSetting(Elder elder) {
LocalTime currentTime = LocalTime.now().withSecond(0).withNano(0);

return careCallSettingRepository.findByElder(elder)
.map(setting -> {
setting.update(currentTime, setting.getSecondCallTime(), setting.getThirdCallTime());
return setting;
})
.orElseGet(() -> {
CareCallSetting newSetting = CareCallSetting.builder()
.elder(elder)
.firstCallTime(currentTime)
.recurrence(CallRecurrenceType.DAILY)
.build();
return careCallSettingRepository.save(newSetting);
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -279,4 +279,46 @@ void saveCallData_fail_settingNotFound() {
verify(careCallRecordRepository, never()).save(any());
eventsMockedStatic.verify(() -> Events.raise(any(CareCallCompletedEvent.class)), never());
}
}

@Test
@DisplayName("통화 데이터 저장 건너뜀 - 테스트 발송 (TEST_SETTING_ID = -1)")
void saveCallData_skip_whenTestSettingId() {
// given
CareCallDataProcessRequest request = CareCallDataProcessRequest.builder()
.elderId(1)
.settingId(-1) // CareCallTestService.TEST_SETTING_ID
.status(CareCallStatus.COMPLETED)
.responded((byte) 1)
.build();

// when
CareCallRecord result = careCallService.saveCallData(request);

// then: 저장 없이 null 반환
assertThat(result).isNull();
verify(elderRepository, never()).findById(any());
verify(careCallSettingRepository, never()).findById(any());
verify(careCallRecordRepository, never()).save(any());
eventsMockedStatic.verify(() -> Events.raise(any(CareCallCompletedEvent.class)), never());
}

@Test
@DisplayName("통화 데이터 저장 건너뜀 - 음수 settingId는 모두 스킵")
void saveCallData_skip_whenAnyNegativeSettingId() {
// given
CareCallDataProcessRequest request = CareCallDataProcessRequest.builder()
.elderId(1)
.settingId(-99)
.status(CareCallStatus.COMPLETED)
.responded((byte) 1)
.build();

// when
CareCallRecord result = careCallService.saveCallData(request);

// then
assertThat(result).isNull();
verify(careCallRecordRepository, never()).save(any());
eventsMockedStatic.verify(() -> Events.raise(any(CareCallCompletedEvent.class)), never());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -328,4 +328,22 @@ void processUploadedCallData_success_longText() {
request.getTranscription().getFullText().size() == 20
));
}

@Test
@DisplayName("파일 업로드 - 테스트 발송(settingId < 0)이면 저장 없이 null 반환")
void processUploadedCallData_skip_whenTestSettingId() {
// given
uploadRequest.setSettingId(-1); // TEST_SETTING_ID

when(openAiSttService.transcribe(mockAudioFile)).thenReturn(sttResponse);
when(careCallService.saveCallData(any())).thenReturn(null); // 테스트 발송은 null 반환

// when
CareCallRecord result = careCallUploadService.processUploadedCallData(uploadRequest);

// then: NPE 없이 null 반환
assertThat(result).isNull();
verify(openAiSttService).transcribe(mockAudioFile);
verify(careCallService).saveCallData(any());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@
import com.example.medicare_call.global.enums.CallType;
import com.example.medicare_call.global.exception.CustomException;
import com.example.medicare_call.global.exception.ErrorCode;
import com.example.medicare_call.repository.CareCallSettingRepository;
import com.example.medicare_call.repository.ElderRepository;
import com.example.medicare_call.service.carecall.outbound.client.CareCallClient;
import com.example.medicare_call.service.carecall.setting.CareCallSettingService;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
Expand All @@ -32,7 +32,7 @@ class CareCallTestServiceTest {
@Mock
private ElderRepository elderRepository;
@Mock
private CareCallSettingService careCallSettingService;
private CareCallSettingRepository careCallSettingRepository;
@Mock
private CareCallRequestSenderService careCallRequestSenderService;
@Mock
Expand All @@ -51,7 +51,7 @@ void sendImmediateCall_success() {
CareCallSetting setting = CareCallSetting.builder().id(10).build();

when(elderRepository.findById(1)).thenReturn(Optional.of(elder));
when(careCallSettingService.getOrCreateImmediateSetting(elder)).thenReturn(setting);
when(careCallSettingRepository.findByElder(elder)).thenReturn(Optional.of(setting));

// when
String result = careCallTestService.sendImmediateCall(elderId, option);
Expand Down Expand Up @@ -84,6 +84,6 @@ void sendTestCall_success() {
careCallTestService.sendTestCall(request);

// then
verify(careCallClient).requestCall(eq(100), eq(100), eq(request.phoneNumber()), eq("테스트"));
verify(careCallClient).requestCall(eq(CareCallTestService.TEST_SETTING_ID), eq(100), eq(request.phoneNumber()), eq("테스트"));
}
}