diff --git a/src/main/java/com/springboot/api/common/config/initializer/TestDataInitializer.java b/src/main/java/com/springboot/api/common/config/initializer/TestDataInitializer.java deleted file mode 100644 index fb9b658d..00000000 --- a/src/main/java/com/springboot/api/common/config/initializer/TestDataInitializer.java +++ /dev/null @@ -1,445 +0,0 @@ -package com.springboot.api.common.config.initializer; - -import com.springboot.api.counselee.entity.Counselee; -import com.springboot.api.counselor.entity.Counselor; -import com.springboot.api.counselsession.entity.CounselSession; -import com.springboot.api.counselsession.entity.CounseleeConsent; -import com.springboot.api.counselsession.entity.MedicationCounsel; -import com.springboot.api.counselsession.entity.MedicationRecordHist; -import com.springboot.api.counselsession.entity.WasteMedicationDisposal; -import com.springboot.api.counselsession.entity.WasteMedicationRecord; -import com.springboot.api.counselsession.enums.wasteMedication.DrugRemainActionType; -import com.springboot.api.counselsession.enums.wasteMedication.RecoveryAgreementType; -import com.springboot.api.medication.entity.Medication; -import com.springboot.enums.CounselorStatus; -import com.springboot.enums.GenderType; -import com.springboot.enums.HealthInsuranceType; -import com.springboot.enums.MedicationDivision; -import com.springboot.enums.MedicationUsageStatus; -import com.springboot.enums.RoleType; -import com.springboot.enums.ScheduleStatus; -import jakarta.persistence.EntityManager; -import jakarta.persistence.TypedQuery; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.util.Arrays; -import java.util.List; -import java.util.Random; -import java.util.stream.IntStream; -import lombok.RequiredArgsConstructor; -import org.springframework.boot.CommandLineRunner; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -@Component -@RequiredArgsConstructor -public class TestDataInitializer implements CommandLineRunner { - - private final EntityManager entityManager; - // private final ObjectMapper objectMapper; - private final PasswordEncoder passwordEncoder; - private final List names = List.of( - "김철수", "김바비", "김을동", "박찬수", "장덕구", "임꺽정", "송사리", "송새벽", "한여름", "오로라", "이루리"); - private final Random random = new Random(); - - @Override - @Transactional - public void run(String... args) { - - if (Arrays.asList(args).contains("--initTestData")) { - initTestData(); - } - } - - private void initTestData() { - // add Counselor - String counselorId = "01HQ7YXHG8ZYXM5T2Q3X4KDJPH"; - addCounselor(counselorId); - - // add Counselee - List counseleeIds = List.of( - "01HQ7YXHG8ZYXM5T2Q3X4KDJPJ", - "01HQ7YXHG8ZYXM5T2Q3X4KDJPK"); - - IntStream.range(0, counseleeIds.size()) - .forEach(index -> addCounselee(counseleeIds.get(index), index % 2 == 0)); - - // add CounselSession - List counselSessionIds = List.of( - "01HQ7YXHG8ZYXM5T2Q3X4KDJPL", - "01HQ7YXHG8ZYXM5T2Q3X4KDJPM", - "01HQ7YXHG8ZYXM5T2Q3X4KDJPN"); - - addCounselSession(counselSessionIds.getFirst(), counselorId, counseleeIds.getFirst(), ScheduleStatus.COMPLETED); - addCounselSession(counselSessionIds.get(1), counselorId, counseleeIds.getFirst(), ScheduleStatus.SCHEDULED); - addCounselSession(counselSessionIds.get(2), counselorId, counseleeIds.getLast(), ScheduleStatus.SCHEDULED); - -// // add CounselCard -// List counselCardIds = List.of( -// "01HQ7YXHG8ZYXM5T2Q3X4KDJPP"); -// createCounselCard(counselSessionIds.getFirst(), counselCardIds.getFirst(), counseleeIds.getFirst()); - - // add CounseleeConsent - List counseleeConsentIds = List.of( - "01HQ7YXHG8ZYXM5T2Q3X4KDJPQ"); - addCounseleeConsent(counselSessionIds.getFirst(), - counseleeConsentIds.getFirst(), - counseleeIds.getFirst()); - - // add MedicationCounsel - List medicationCounselIds = List.of( - "01HQ7YXHG8ZYXM5T2Q3X4KDJPR"); - addMedicationCounsel(counselSessionIds.getFirst(), - medicationCounselIds.getFirst()); - - // add MedicationRecordHist - List medicationRecordHistIds = List.of( - "01HQ7YXHG8ZYXM5T2Q3X4KDJPS", - "01HQ7YXHG8ZYXM5T2Q3X4KDJPT", - "01HQ7YXHG8ZYXM5T2Q3X4KDJPU", - "01HQ7YXHG8ZYXM5T2Q3X4KDJPV", - "01HQ7YXHG8ZYXM5T2Q3X4KDJPW", - "01HQ7YXHG8ZYXM5T2Q3X4KDJPX", - "01HQ7YXHG8ZYXM5T2Q3X4KDJPY", - "01HQ7YXHG8ZYXM5T2Q3X4KDJPZ", - "01HQ7YXHG8ZYXM5T2Q3X4KDJQ0", - "01HQ7YXHG8ZYXM5T2Q3X4KDJQ1"); - addMedicationRecordHist(counselSessionIds.getFirst(), medicationRecordHistIds); - - // add WasteMedicationDisposal - List wasteMedicationDisposalIds = List.of( - "01HQ7YXHG8ZYXM5T2Q3X4KDJQ2"); - addWasteMedicationDisposal(counselSessionIds.getFirst(), wasteMedicationDisposalIds.getFirst()); - - // add WasteMedicationRecord - List wasteMedicationRecordIds = List.of( - "01HQ7YXHG8ZYXM5T2Q3X4KDJQ3", - "01HQ7YXHG8ZYXM5T2Q3X4KDJQ4", - "01HQ7YXHG8ZYXM5T2Q3X4KDJQ5", - "01HQ7YXHG8ZYXM5T2Q3X4KDJQ6", - "01HQ7YXHG8ZYXM5T2Q3X4KDJQ7", - "01HQ7YXHG8ZYXM5T2Q3X4KDJQ8", - "01HQ7YXHG8ZYXM5T2Q3X4KDJQ9", - "01HQ7YXHG8ZYXM5T2Q3X4KDJQA", - "01HQ7YXHG8ZYXM5T2Q3X4KDJQB", - "01HQ7YXHG8ZYXM5T2Q3X4KDJQC"); - addWasteMedicationRecord(counselSessionIds.getFirst(), wasteMedicationRecordIds); - } - - private void addCounselor(String counselorId) { - - if (entityManager.find(Counselor.class, counselorId) == null) { - - Counselor counselor = Counselor - .builder() - .email(counselorId + "@gmail.com") - .phoneNumber(getRandomPhoneNumber()) - .name(names.get(random.nextInt(names.size()))) - .password(passwordEncoder.encode("1234qwer!@")) - .roleType(RoleType.ROLE_ADMIN) - .status(CounselorStatus.ACTIVE) - .registrationDate(LocalDate.now()) - .build(); - - counselor.setId(counselorId); - - entityManager.persist(counselor); - - } - - } - - private void addCounselee(String counseleeId, boolean isDisability) { - - if (entityManager.find(Counselee.class, counseleeId) == null) { - Counselee counselee = Counselee - .builder() - .name(names.get(random.nextInt(names.size()))) - .dateOfBirth(getRandomDate("1930-01-01", "2000-01-01")) - .genderType(GenderType.MALE) - .isDisability(isDisability) - .healthInsuranceType(HealthInsuranceType.HEALTH_INSURANCE) - .phoneNumber(getRandomPhoneNumber()) - .registrationDate(LocalDate.now()) - .build(); - - counselee.setId(counseleeId); - - entityManager.persist(counselee); - } - } - - private void addCounselSession(String counselSessionId, - String counselorId, - String counseleeId, - ScheduleStatus scheduleStatus) { - - Counselor counselor = entityManager.getReference(Counselor.class, counselorId); - Counselee counselee = entityManager.getReference(Counselee.class, counseleeId); - LocalDate scheduleDate = scheduleStatus == ScheduleStatus.SCHEDULED - ? LocalDate.now() - : getRandomDate("2024-12-01", LocalDate.now().toString()); - - LocalDateTime scheduleDateTime = scheduleDate.atTime(random.nextInt(9, 19), 0); - - if (entityManager.find(CounselSession.class, counselSessionId) == null) { - CounselSession counselSession = CounselSession - .builder() - .counselor(counselor) - .counselee(counselee) - .status(scheduleStatus) - .scheduledStartDateTime(scheduleDateTime) - .startDateTime(scheduleDateTime) - .endDateTime(scheduleDateTime.plusMinutes(30)) - .build(); - - counselSession.setId(counselSessionId); - - entityManager.persist(counselSession); - } - } - -// private void createCounselCard(String counselSessionId, String counselCardId, String counseleeId) -// throws JsonProcessingException { -// -// CounselSession counselSession = entityManager.getReference(CounselSession.class, counselSessionId); -// Counselee counselee = entityManager.getReference(Counselee.class, counseleeId); -// -// JsonNode baseInformation = objectMapper.readTree(String.format(""" -// { -// "version": "1.0", -// "baseInfo": { -// "counseleeId": "%s", -// "name": "%s", -// "birthDate": "%s", -// "counselSessionOrder": "1회차", -// "lastCounselDate": "", -// "healthInsuranceType": "MEDICAL_AID", -// }, -// "counselPurposeAndNote": { -// "counselPurpose": ["약물 부작용 상담", "약물 복용 관련 상담"], -// "SignificantNote": "특이사항", -// "MedicationNote": "복약 관련 메모" -// } -// } -// """, counselee.getId(), counselee.getName(), counselee.getDateOfBirth().toString())); -// -// JsonNode healthInformation = objectMapper.readTree(""" -// { -// "version": "1.0", -// "diseaseInfo": { -// "diseases": ["고혈압", "고지혈증"], -// "historyNote": "고혈압, 당뇨, 고관절, 염증 수술", -// "mainInconvenienceNote": "고관절 통증으로 걷기가 힘듦" -// }, -// "allergy": { -// "isAllergy": true, -// "allergyNote": "땅콩, 돼지고기" -// }, -// "medicationSideEffect": { -// "isSideEffect": true, -// "suspectedMedicationNote": "타미플루", -// "symptomsNote": "온 몸이 붓고, 특히 얼굴이 가렵고 붉어짐" -// } -// } -// """); -// -// JsonNode livingInformation = objectMapper.readTree(""" -// { -// "version": "1.0", -// "smoking": { -// "isSmoking": true, -// "smokingPeriodNote": "10년 02개월", -// "smokingAmount": "1갑" -// }, -// "drinking": { -// "isDrinking": true, -// "drinkingAmount": "1회" -// }, -// "nutrition": { -// "mealPattern": "하루 한 끼 규칙적 식사", -// "nutritionNote": "잇몸 문제로 딱딱한 음식 섭취 어려움" -// }, -// "exercise": { -// "exercisePattern": "1회", -// "exerciseNote": "유산소 운동" -// }, -// "medicationManagement": { -// "isAlone": true, -// "houseMateNote": "아들, 딸", -// "medicationAssistants": ["본인", "배우자", "자녀", "본인"] -// } -// } -// """); -// -// JsonNode independentLifeInformation = counselee.getIsDisability() ? objectMapper.readTree(""" -// { -// "version": "1.0", -// "walking": { -// "walkingMethods": ["와상 및 보행불가", "자립보행 가능"], -// "walkingEquipments": ["지팡이", "워커"], -// "etcNote": "" -// }, -// "evacuation": { -// "evacuationMethods": ["자립 화장실 사용", "화장실 유도"], -// "etcNote": "" -// }, -// "Communication": { -// "visibles": ["잘보임", "잘안보임", "안보임", "안경 사용"], -// "auditables": ["잘들림", "잘안들림", "안들림", "보청기 사용"], -// "Communications": ["소통 가능함", "대강 가능함", "불가능"], -// "Usingkoreans": ["읽기 가능", "쓰기 가능"] -// } -// } -// """) : null; -// -// if (entityManager.find(CounselCard.class, counselCardId) == null) { -// CounselCard counselCard = CounselCard -// .builder() -// .counselSession(counselSession) -// .baseInformation(baseInformation) -// .healthInformation(healthInformation) -// .livingInformation(livingInformation) -// .independentLifeInformation(independentLifeInformation) -// .cardRecordStatus(counselSession.getStatus() == ScheduleStatus.CANCELED -// ? CardRecordStatus.COMPLETED -// : CardRecordStatus.IN_PROGRESS) -// .build(); -// -// counselCard.setId(counselCardId); -// -// entityManager.persist(counselCard); -// } -// -// } - - private void addCounseleeConsent(String counselSessionId, String counseleeConsentId, String counseleeId) { - - CounselSession counselSession = entityManager.getReference(CounselSession.class, counselSessionId); - Counselee counselee = entityManager.getReference(Counselee.class, counseleeId); - - if (entityManager.find(CounseleeConsent.class, counseleeConsentId) == null) { - CounseleeConsent counseleeConsent = CounseleeConsent.create(counselSession, counselee); - counseleeConsent.accept(); - counseleeConsent.setId(counseleeConsentId); - - entityManager.persist(counseleeConsent); - } - - } - - private void addMedicationCounsel(String counselSessionId, String medicationCounselId) { - - CounselSession counselSession = entityManager.getReference(CounselSession.class, counselSessionId); - - if (entityManager.find(MedicationCounsel.class, medicationCounselId) == null) { - MedicationCounsel medicationCounsel = MedicationCounsel - .builder() - .counselSession(counselSession) - .counselRecord("의약 상담을 진행합니다. 아주 좋습니다. 뭐가 문제일까요?") - .build(); - - medicationCounsel.setId(medicationCounselId); - entityManager.persist(medicationCounsel); - - - } - } - - private void addMedicationRecordHist(String counselSessionId, List medicationRecordHistIds) { - - CounselSession counselSession = entityManager.getReference(CounselSession.class, counselSessionId); - String jpql = "SELECT m FROM Medication m"; - TypedQuery query = entityManager.createQuery(jpql, Medication.class); - query.setMaxResults(medicationRecordHistIds.size()); - List medications = query.getResultList(); - int idx = 0; - - for (Medication medication : medications) { - if (entityManager.find(MedicationRecordHist.class, medicationRecordHistIds.get(idx)) == null) { - MedicationRecordHist medicationRecordHist = MedicationRecordHist.builder() - .counselSession(counselSession) - .medication(medication) // Associate with a Medication - .medicationDivision(MedicationDivision.PRESCRIPTION) // Example Enum - .name(medication.getItemName()) - .usageObject("그냥") - .prescriptionDate(counselSession.getScheduledStartDateTime().minusDays(2).toLocalDate()) - .prescriptionDays(7) - .unit("mg") - .usageStatus(MedicationUsageStatus.AS_NEEDED) // Example Enum - .build(); - - medicationRecordHist.setId(medicationRecordHistIds.get(idx++)); - - entityManager.persist(medicationRecordHist); - } - } - - } - - private void addWasteMedicationDisposal(String counselSessionId, String wasteMedicationDisposalId) { - - CounselSession counselSession = entityManager.getReference(CounselSession.class, counselSessionId); - - if (entityManager.find(WasteMedicationDisposal.class, wasteMedicationDisposalId) == null) { - WasteMedicationDisposal wasteMedicationDisposal = WasteMedicationDisposal - .builder() - .counselSession(counselSession) - .unusedReasons(List.of("다른 약으로 대체함")) - .unusedReasonDetail("") - .drugRemainActionType(DrugRemainActionType.DOCTOR_OR_PHARMACIST) - .recoveryAgreementType(RecoveryAgreementType.AGREE) - .wasteMedicationGram(100) - .build(); - - wasteMedicationDisposal.setId(wasteMedicationDisposalId); - - entityManager.persist(wasteMedicationDisposal); - } - } - - private void addWasteMedicationRecord(String counselSessionId, List wasteMedicationRecordIds) { - - CounselSession counselSession = entityManager.getReference(CounselSession.class, counselSessionId); - String jpql = "SELECT m FROM Medication m"; - TypedQuery query = entityManager.createQuery(jpql, Medication.class); - query.setMaxResults(wasteMedicationRecordIds.size()); - List medications = query.getResultList(); - int idx = 0; - - for (Medication medication : medications) { - - if (entityManager.find(WasteMedicationRecord.class, wasteMedicationRecordIds.get(idx)) == null) { - WasteMedicationRecord wasteMedicationRecord = WasteMedicationRecord.builder() - .counselSession(counselSession) - .medication(medication) - .medicationName(medication.getItemName()) - .disposalReason("그냥") - .unit(100) - .build(); - - wasteMedicationRecord.setId(wasteMedicationRecordIds.get(idx++)); - - entityManager.persist(wasteMedicationRecord); - } - } - - } - - private LocalDate getRandomDate(String startDate, String endDate) { - - long start = LocalDate.parse(startDate).toEpochDay(); - long end = LocalDate.parse(endDate).toEpochDay(); - - long randomDay = start + random.nextInt((int) (end - start)); - - return LocalDate.ofEpochDay(randomDay); - - } - - private String getRandomPhoneNumber() { - return "010" + String.format("%08d", random.nextInt(100000000)); - } - -} diff --git a/src/main/java/com/springboot/api/common/config/security/SecurityConfig.java b/src/main/java/com/springboot/api/common/config/security/SecurityConfig.java index 6913ae52..f88a2080 100644 --- a/src/main/java/com/springboot/api/common/config/security/SecurityConfig.java +++ b/src/main/java/com/springboot/api/common/config/security/SecurityConfig.java @@ -64,7 +64,8 @@ public CorsConfigurationSource corsConfigurationSource() { configuration.setAllowCredentials(true); configuration.setExposedHeaders( List.of("Authorization", "Access-Token", "Uid", "Refresh-Token", "Access-Control-Expose-Headers", - "Upload-Offset, Location, Upload-Length, Tus-Version, Tus-Resumable, Tus-Max-Size, Tus-Extension, Upload-Metadata")); + "Upload-Offset", "Location", "Upload-Length", "Tus-Version", "Tus-Resumable", "Tus-Max-Size", + "Tus-Extension", "Upload-Metadata")); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", configuration); return source; diff --git a/src/main/java/com/springboot/api/common/exception/NoContentException.java b/src/main/java/com/springboot/api/common/exception/NoContentException.java index cc98b75e..abd87534 100644 --- a/src/main/java/com/springboot/api/common/exception/NoContentException.java +++ b/src/main/java/com/springboot/api/common/exception/NoContentException.java @@ -2,13 +2,26 @@ import com.springboot.api.common.message.ExceptionMessages; +/** + * 콘텐츠가 없을 때 발생하는 예외 + * 스택트레이스를 생성하지 않아 성능을 최적화하고 정확한 정보만 전달합니다. + */ public class NoContentException extends RuntimeException { public NoContentException() { - super(ExceptionMessages.NO_CONTENT); + super(ExceptionMessages.NO_CONTENT, null, false, false); } public NoContentException(String message) { - super(message); + super(message, null, false, false); + } + + /** + * 원인과 함께 예외를 생성하는 생성자 + * @param message 예외 메시지 + * @param cause 원인 예외 + */ + public NoContentException(String message, Throwable cause) { + super(message, cause, false, false); } } diff --git a/src/main/java/com/springboot/api/common/util/AiResponseParseUtil.java b/src/main/java/com/springboot/api/common/util/AiResponseParseUtil.java new file mode 100644 index 00000000..da53c861 --- /dev/null +++ b/src/main/java/com/springboot/api/common/util/AiResponseParseUtil.java @@ -0,0 +1,88 @@ +package com.springboot.api.common.util; + +import java.util.Optional; + +import org.springframework.stereotype.Component; + +import com.fasterxml.jackson.databind.JsonNode; +import com.springboot.api.common.exception.NoContentException; + +import lombok.extern.slf4j.Slf4j; + +/** + * AI 응답 파싱을 위한 유틸리티 클래스 + * ChatResponse JSON 구조에서 안전하게 데이터를 추출합니다. + */ +@Component +@Slf4j +public class AiResponseParseUtil { + + private static final String RESULT_PATH = "result"; + private static final String OUTPUT_PATH = "output"; + private static final String TEXT_PATH = "text"; + + /** + * ChatResponse JsonNode에서 분석된 텍스트를 안전하게 추출합니다. + * + * @param taResult ChatResponse가 JSON으로 변환된 JsonNode + * @return 분석된 텍스트 + * @throws NoContentException taResult가 null이거나 텍스트를 찾을 수 없는 경우 + */ + public String extractAnalysedText(JsonNode taResult) { + if (taResult == null || taResult.isNull()) { + log.warn("taResult is null or empty"); + throw new NoContentException(); + } + + return Optional.ofNullable(taResult.get(RESULT_PATH)) + .map(result -> result.get(OUTPUT_PATH)) + .map(output -> output.get(TEXT_PATH)) + .filter(text -> !text.isNull()) + .map(JsonNode::asText) + .filter(text -> !text.trim().isEmpty()) + .orElseThrow(() -> { + log.warn("Failed to extract text from taResult: missing result.output.text path"); + return new NoContentException(); + }); + } + + /** + * ChatResponse JsonNode에서 분석된 텍스트를 안전하게 추출합니다. (Optional 반환) + * + * @param taResult ChatResponse가 JSON으로 변환된 JsonNode + * @return 분석된 텍스트 Optional + */ + public Optional extractAnalysedTextSafely(JsonNode taResult) { + try { + return Optional.of(extractAnalysedText(taResult)); + } catch (NoContentException e) { + log.debug("Could not extract analysed text from taResult", e); + return Optional.empty(); + } + } + + /** + * JsonNode가 유효한 AI 분석 결과인지 검증합니다. + * + * @param taResult 검증할 JsonNode + * @return 유효한 AI 분석 결과인지 여부 + */ + public boolean isValidAiResponse(JsonNode taResult) { + if (taResult == null || taResult.isNull()) { + return false; + } + + JsonNode result = taResult.get(RESULT_PATH); + if (result == null || result.isNull()) { + return false; + } + + JsonNode output = result.get(OUTPUT_PATH); + if (output == null || output.isNull()) { + return false; + } + + JsonNode text = output.get(TEXT_PATH); + return text != null && !text.isNull() && !text.asText().trim().isEmpty(); + } +} \ No newline at end of file diff --git a/src/main/java/com/springboot/api/common/util/FileUtil.java b/src/main/java/com/springboot/api/common/util/FileUtil.java index 3c5880c5..54e3bbb3 100644 --- a/src/main/java/com/springboot/api/common/util/FileUtil.java +++ b/src/main/java/com/springboot/api/common/util/FileUtil.java @@ -5,6 +5,7 @@ import jakarta.validation.constraints.NotNull; import java.io.File; import java.io.IOException; +import java.io.UncheckedIOException; import java.net.MalformedURLException; import java.nio.file.Files; import java.nio.file.Path; @@ -14,6 +15,7 @@ import lombok.RequiredArgsConstructor; import net.bramp.ffmpeg.FFmpeg; import net.bramp.ffmpeg.builder.FFmpegBuilder; +import org.apache.commons.io.FileUtils; import org.slf4j.LoggerFactory; import org.springframework.core.io.Resource; import org.springframework.core.io.UrlResource; @@ -121,4 +123,26 @@ public Resource getUrlResource(Path path) { throw new RuntimeException("잘못된 파일 경로: " + path, e); } } + + public void deleteDirectory(String directoryPath) { + File directory = new File(directoryPath); + + if (!directory.exists()) { + log.warn("삭제 시도한 디렉토리가 존재하지 않습니다: {}", directoryPath); + return; + } + + if (!directory.isDirectory()) { + throw new IllegalArgumentException("경로가 디렉토리가 아닙니다: " + directoryPath); + } + + try { + FileUtils.cleanDirectory(directory); + if (!directory.delete()) { + throw new IOException("디렉토리 삭제 실패: " + directoryPath); + } + } catch (IOException e) { + throw new UncheckedIOException("디렉토리 삭제 중 오류 발생", e); + } + } } diff --git a/src/main/java/com/springboot/api/counselcard/controller/CounselCardController.java b/src/main/java/com/springboot/api/counselcard/controller/CounselCardController.java index d232dc27..e5ad6302 100644 --- a/src/main/java/com/springboot/api/counselcard/controller/CounselCardController.java +++ b/src/main/java/com/springboot/api/counselcard/controller/CounselCardController.java @@ -1,13 +1,5 @@ package com.springboot.api.counselcard.controller; -import java.util.List; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestParam; import com.springboot.api.common.annotation.ApiController; import com.springboot.api.common.annotation.RoleSecured; import com.springboot.api.common.annotation.ValidEnum; @@ -19,6 +11,10 @@ import com.springboot.api.counselcard.dto.response.CounselCardIdRes; import com.springboot.api.counselcard.dto.response.CounselCardIndependentLifeInformationRes; import com.springboot.api.counselcard.dto.response.CounselCardLivingInformationRes; +import com.springboot.api.counselcard.dto.response.MainCounselBaseInformationRes; +import com.springboot.api.counselcard.dto.response.MainCounselHealthInformationRes; +import com.springboot.api.counselcard.dto.response.MainCounselIndependentLifeInformationRes; +import com.springboot.api.counselcard.dto.response.MainCounselLivingInformationRes; import com.springboot.api.counselcard.dto.response.TimeRecordedRes; import com.springboot.api.counselcard.service.CounselCardService; import com.springboot.enums.CounselCardRecordType; @@ -27,7 +23,15 @@ import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; +import java.util.List; import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; @ApiController(path = "/v1/counsel/card", name = "CounselCardController", description = "상담카드 관련 API를 제공하는 Controller") @RequiredArgsConstructor @@ -37,7 +41,7 @@ public class CounselCardController { private final CounselCardService counselCardService; @GetMapping("/{counselSessionId}/base-information") - @Operation(summary = "상담 카드 기본 정보 조회", tags = {"상담 카드 작성", "본상담 - 상담 카드"}) + @Operation(summary = "기초 설문용 상담 카드 기본 정보 조회", tags = {"상담 카드 작성"}) @RoleSecured({RoleType.ROLE_ASSISTANT, RoleType.ROLE_ADMIN, RoleType.ROLE_USER}) ResponseEntity> selectCounselCardBaseInformation( @PathVariable @NotBlank(message = "상담 세션 ID는 필수 입력값입니다") @Size(min = 26, max = 26, message = "상담 세션 ID는 26자여야 합니다") String counselSessionId) { @@ -46,7 +50,7 @@ ResponseEntity> selectCounselCardBaseIn } @GetMapping("/{counselSessionId}/health-information") - @Operation(summary = "상담 카드 건강 정보 조회", tags = {"상담 카드 작성", "본상담 - 상담 카드"}) + @Operation(summary = "기초 설문용 상담 카드 건강 정보 조회", tags = {"상담 카드 작성"}) @RoleSecured({RoleType.ROLE_ASSISTANT, RoleType.ROLE_ADMIN, RoleType.ROLE_USER}) ResponseEntity> selectCounselCardHealthInformation( @PathVariable @NotBlank(message = "상담 세션 ID는 필수 입력값입니다") @Size(min = 26, max = 26, message = "상담 세션 ID는 26자여야 합니다") String counselSessionId) { @@ -55,7 +59,7 @@ ResponseEntity> selectCounselCardHeal } @GetMapping("/{counselSessionId}/living-information") - @Operation(summary = "상담 카드 생활 정보 조회", tags = {"상담 카드 작성", "본상담 - 상담 카드"}) + @Operation(summary = "기초 설문용 상담 카드 생활 정보 조회", tags = {"상담 카드 작성"}) @RoleSecured({RoleType.ROLE_ASSISTANT, RoleType.ROLE_ADMIN, RoleType.ROLE_USER}) ResponseEntity> selectCounselCardLivingInformation( @PathVariable @NotBlank(message = "상담 세션 ID는 필수 입력값입니다") @Size(min = 26, max = 26, message = "상담 세션 ID는 26자여야 합니다") String counselSessionId) { @@ -64,7 +68,7 @@ ResponseEntity> selectCounselCardLivi } @GetMapping("/{counselSessionId}/independent-information") - @Operation(summary = "상담 카드 자립생활 역량 조회", tags = {"상담 카드 작성", "본상담 - 상담 카드"}) + @Operation(summary = "기초 설문용 상담 카드 자립생활 역량 조회", tags = {"상담 카드 작성"}) @RoleSecured({RoleType.ROLE_ASSISTANT, RoleType.ROLE_ADMIN, RoleType.ROLE_USER}) ResponseEntity> selectCounselCardIndependentLifeInformation( @PathVariable @NotBlank(message = "상담 세션 ID는 필수 입력값입니다") @Size(min = 26, max = 26, message = "상담 세션 ID는 26자여야 합니다") String counselSessionId) { @@ -106,6 +110,7 @@ ResponseEntity> deleteCounselCard( new CommonRes<>(counselCardService.deleteCounselCard(counselSessionId))); } + @Deprecated @GetMapping("/{counselSessionId}/previous/item/list") @Operation(summary = "이전 상담 카드 item 목록 조회", tags = {"본상담 - 상담 카드"}) @RoleSecured({RoleType.ROLE_ASSISTANT, RoleType.ROLE_ADMIN, RoleType.ROLE_USER}) @@ -117,4 +122,45 @@ ResponseEntity>>> selectPreviousItemListB new CommonRes<>( counselCardService.selectPreviousRecordsByType(counselSessionId, type))); } + + @GetMapping("/main-counsel/{counselSessionId}/base-information") + @Operation(summary = "본 상담용 상담 카드 기본 정보 조회", tags = {"본상담 - 기초 설문"}) + @RoleSecured({RoleType.ROLE_ASSISTANT, RoleType.ROLE_ADMIN, RoleType.ROLE_USER}) + ResponseEntity> selectMainCounselBaseInformation( + @PathVariable @NotBlank(message = "상담 세션 ID는 필수 입력값입니다") @Size(min = 26, max = 26, message = "상담 세션 ID는 26자여야 합니다") String counselSessionId) { + + MainCounselBaseInformationRes mainCounselBaseInformationRes = counselCardService.selectMainCounselBaseInformation( + counselSessionId); + + return ResponseEntity.ok( + new CommonRes<>(mainCounselBaseInformationRes)); + } + + @GetMapping("/main-counsel/{counselSessionId}/health-information") + @Operation(summary = "본 상담용 상담 카드 건강 정보 조회", tags = {"본상담 - 기초 설문"}) + @RoleSecured({RoleType.ROLE_ASSISTANT, RoleType.ROLE_ADMIN, RoleType.ROLE_USER}) + ResponseEntity> selectMainCounselHealthInformation( + @PathVariable @NotBlank(message = "상담 세션 ID는 필수 입력값입니다") @Size(min = 26, max = 26, message = "상담 세션 ID는 26자여야 합니다") String counselSessionId) { + return ResponseEntity.ok( + new CommonRes<>(counselCardService.selectMainCounselHealthInformation(counselSessionId))); + } + + @GetMapping("/main-counsel/{counselSessionId}/living-information") + @Operation(summary = "본 상담용 상담 카드 생활 정보 조회", tags = {"본상담 - 기초 설문"}) + @RoleSecured({RoleType.ROLE_ASSISTANT, RoleType.ROLE_ADMIN, RoleType.ROLE_USER}) + ResponseEntity> selectMainCounselLivingInformation( + @PathVariable @NotBlank(message = "상담 세션 ID는 필수 입력값입니다") @Size(min = 26, max = 26, message = "상담 세션 ID는 26자여야 합니다") String counselSessionId) { + return ResponseEntity.ok( + new CommonRes<>(counselCardService.selectMainCounselLivingInformation(counselSessionId))); + } + + @GetMapping("/main-counsel/{counselSessionId}/independent-information") + @Operation(summary = "본 상담용 상담 카드 자립생활 역량 조회", tags = {"본상담 - 기초 설문"}) + @RoleSecured({RoleType.ROLE_ASSISTANT, RoleType.ROLE_ADMIN, RoleType.ROLE_USER}) + ResponseEntity> selectMainCounselIndependentLifeInformation( + @PathVariable @NotBlank(message = "상담 세션 ID는 필수 입력값입니다") @Size(min = 26, max = 26, message = "상담 세션 ID는 26자여야 합니다") String counselSessionId) { + return ResponseEntity.ok( + new CommonRes<>( + counselCardService.selectMainCounselIndependentLifeInformation(counselSessionId))); + } } diff --git a/src/main/java/com/springboot/api/counselcard/dto/response/MainCounselBaseInformationRes.java b/src/main/java/com/springboot/api/counselcard/dto/response/MainCounselBaseInformationRes.java new file mode 100644 index 00000000..b7b63d3c --- /dev/null +++ b/src/main/java/com/springboot/api/counselcard/dto/response/MainCounselBaseInformationRes.java @@ -0,0 +1,14 @@ +package com.springboot.api.counselcard.dto.response; + +import com.springboot.enums.CounselPurposeType; +import java.util.List; + +public record MainCounselBaseInformationRes( + MainCounselRecord> counselPurpose, + MainCounselRecord significantNote, + MainCounselRecord medicationNote +) { + +} + + diff --git a/src/main/java/com/springboot/api/counselcard/dto/response/MainCounselHealthInformationRes.java b/src/main/java/com/springboot/api/counselcard/dto/response/MainCounselHealthInformationRes.java new file mode 100644 index 00000000..53edd2fd --- /dev/null +++ b/src/main/java/com/springboot/api/counselcard/dto/response/MainCounselHealthInformationRes.java @@ -0,0 +1,18 @@ +package com.springboot.api.counselcard.dto.response; + +import com.springboot.api.counselcard.dto.information.health.AllergyDTO; +import com.springboot.api.counselcard.dto.information.health.MedicationSideEffectDTO; +import com.springboot.enums.DiseaseType; +import java.util.List; + +public record MainCounselHealthInformationRes( + MainCounselRecord> diseases, + MainCounselRecord historyNote, + MainCounselRecord mainInconvenienceNote, + MainCounselRecord medicationSideEffect, + MainCounselRecord allergy +) { + +} + + diff --git a/src/main/java/com/springboot/api/counselcard/dto/response/MainCounselIndependentLifeInformationRes.java b/src/main/java/com/springboot/api/counselcard/dto/response/MainCounselIndependentLifeInformationRes.java new file mode 100644 index 00000000..3481b52e --- /dev/null +++ b/src/main/java/com/springboot/api/counselcard/dto/response/MainCounselIndependentLifeInformationRes.java @@ -0,0 +1,15 @@ +package com.springboot.api.counselcard.dto.response; + +import com.springboot.api.counselcard.dto.information.independentlife.CommunicationDTO; +import com.springboot.api.counselcard.dto.information.independentlife.EvacuationDTO; +import com.springboot.api.counselcard.dto.information.independentlife.WalkingDTO; + +public record MainCounselIndependentLifeInformationRes( + MainCounselRecord communication, + MainCounselRecord evacuation, + MainCounselRecord walking +) { + +} + + diff --git a/src/main/java/com/springboot/api/counselcard/dto/response/MainCounselLivingInformationRes.java b/src/main/java/com/springboot/api/counselcard/dto/response/MainCounselLivingInformationRes.java new file mode 100644 index 00000000..a64d41ef --- /dev/null +++ b/src/main/java/com/springboot/api/counselcard/dto/response/MainCounselLivingInformationRes.java @@ -0,0 +1,19 @@ +package com.springboot.api.counselcard.dto.response; + +import com.springboot.api.counselcard.dto.information.living.ExerciseDTO; +import com.springboot.api.counselcard.dto.information.living.MedicationManagementDTO; +import com.springboot.api.counselcard.dto.information.living.NutritionDTO; +import com.springboot.api.counselcard.dto.information.living.SmokingDTO; +import com.springboot.enums.DrinkingAmount; + +public record MainCounselLivingInformationRes( + MainCounselRecord smoking, + MainCounselRecord drinkingAmount, + MainCounselRecord exercise, + MainCounselRecord medicationManagement, + MainCounselRecord nutrition +) { + +} + + diff --git a/src/main/java/com/springboot/api/counselcard/dto/response/MainCounselRecord.java b/src/main/java/com/springboot/api/counselcard/dto/response/MainCounselRecord.java new file mode 100644 index 00000000..fca09567 --- /dev/null +++ b/src/main/java/com/springboot/api/counselcard/dto/response/MainCounselRecord.java @@ -0,0 +1,12 @@ +package com.springboot.api.counselcard.dto.response; + +import java.util.List; + +public record MainCounselRecord( + T currentState, + List> history +) { + +} + + diff --git a/src/main/java/com/springboot/api/counselcard/dto/response/MainCounselRecordBuilder.java b/src/main/java/com/springboot/api/counselcard/dto/response/MainCounselRecordBuilder.java new file mode 100644 index 00000000..e1d6ae87 --- /dev/null +++ b/src/main/java/com/springboot/api/counselcard/dto/response/MainCounselRecordBuilder.java @@ -0,0 +1,31 @@ +package com.springboot.api.counselcard.dto.response; + +import com.springboot.api.counselcard.entity.CounselCard; +import java.util.List; +import java.util.function.Function; + +public class MainCounselRecordBuilder { + + private MainCounselRecordBuilder() { + } + + public static MainCounselRecord build( + List sortedCards, + Function extractor + ) { + if (sortedCards == null || sortedCards.isEmpty()) { + return new MainCounselRecord<>(null, List.of()); + } + + T current = extractor.apply(sortedCards.getFirst()); + + List> history = sortedCards.stream() + .skip(1) + .map(counselCard -> new TimeRecordedRes<>( + counselCard.getCounselSession().getScheduledStartDateTime().toLocalDate().toString(), + extractor.apply(counselCard))) + .toList(); + + return new MainCounselRecord<>(current, history); + } +} diff --git a/src/main/java/com/springboot/api/counselcard/repository/CounselCardRepositoryCustom.java b/src/main/java/com/springboot/api/counselcard/repository/CounselCardRepositoryCustom.java index 094d9316..a3287a87 100644 --- a/src/main/java/com/springboot/api/counselcard/repository/CounselCardRepositoryCustom.java +++ b/src/main/java/com/springboot/api/counselcard/repository/CounselCardRepositoryCustom.java @@ -1,10 +1,9 @@ package com.springboot.api.counselcard.repository; +import com.springboot.api.counselcard.entity.CounselCard; import java.util.List; import java.util.Optional; -import com.springboot.api.counselcard.entity.CounselCard; - public interface CounselCardRepositoryCustom { Optional findCounselCardWithCounselee(String counselSessionId); @@ -14,4 +13,6 @@ public interface CounselCardRepositoryCustom { Optional findLastRecordedCounselCard(String counseleeId); List findRecordedCardsByPreviousSessions(String currentSessionId); + + List findCurrentAndPastCounselCardsBySessionId(String counselSessionId); } \ No newline at end of file diff --git a/src/main/java/com/springboot/api/counselcard/repository/CounselCardRepositoryImpl.java b/src/main/java/com/springboot/api/counselcard/repository/CounselCardRepositoryImpl.java index 17899546..7f6563f6 100644 --- a/src/main/java/com/springboot/api/counselcard/repository/CounselCardRepositoryImpl.java +++ b/src/main/java/com/springboot/api/counselcard/repository/CounselCardRepositoryImpl.java @@ -1,18 +1,16 @@ package com.springboot.api.counselcard.repository; -import java.time.LocalDateTime; -import java.util.List; -import java.util.Optional; - -import org.springframework.data.jpa.repository.support.QuerydslRepositorySupport; -import org.springframework.stereotype.Repository; - import com.querydsl.jpa.impl.JPAQueryFactory; import com.springboot.api.counselcard.entity.CounselCard; import com.springboot.api.counselcard.entity.QCounselCard; import com.springboot.api.counselsession.entity.QCounselSession; import com.springboot.enums.CardRecordStatus; import com.springboot.enums.ScheduleStatus; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.support.QuerydslRepositorySupport; +import org.springframework.stereotype.Repository; @Repository public class CounselCardRepositoryImpl extends QuerydslRepositorySupport implements @@ -87,4 +85,27 @@ public List findRecordedCardsByPreviousSessions(String currentSessi .orderBy(counselSession.scheduledStartDateTime.desc()) .fetch(); } + + @Override + public List findCurrentAndPastCounselCardsBySessionId(String counselSessionId) { + QCounselSession counselSession = QCounselSession.counselSession; + + String counseleeId = queryFactory + .select(counselSession.counselee.id) + .from(counselSession) + .where(counselSession.id.eq(counselSessionId)) + .fetchOne(); + + return queryFactory + .selectFrom(counselCard) + .join(counselCard.counselSession, counselSession).fetchJoin() + .where(counselSession.counselee.id.eq(counseleeId) + .and(counselSession.id.eq(counselSessionId) + .or( + counselSession.counselee.id.eq(counseleeId) + .and(counselSession.status.eq(ScheduleStatus.COMPLETED)) + ))) + .orderBy(counselSession.scheduledStartDateTime.desc()) + .fetch(); + } } diff --git a/src/main/java/com/springboot/api/counselcard/service/CounselCardService.java b/src/main/java/com/springboot/api/counselcard/service/CounselCardService.java index 12828517..4235453e 100644 --- a/src/main/java/com/springboot/api/counselcard/service/CounselCardService.java +++ b/src/main/java/com/springboot/api/counselcard/service/CounselCardService.java @@ -1,6 +1,15 @@ package com.springboot.api.counselcard.service; import com.springboot.api.common.exception.NoContentException; +import com.springboot.api.counselcard.dto.information.health.AllergyDTO; +import com.springboot.api.counselcard.dto.information.health.MedicationSideEffectDTO; +import com.springboot.api.counselcard.dto.information.independentlife.CommunicationDTO; +import com.springboot.api.counselcard.dto.information.independentlife.EvacuationDTO; +import com.springboot.api.counselcard.dto.information.independentlife.WalkingDTO; +import com.springboot.api.counselcard.dto.information.living.ExerciseDTO; +import com.springboot.api.counselcard.dto.information.living.MedicationManagementDTO; +import com.springboot.api.counselcard.dto.information.living.NutritionDTO; +import com.springboot.api.counselcard.dto.information.living.SmokingDTO; import com.springboot.api.counselcard.dto.request.UpdateCounselCardReq; import com.springboot.api.counselcard.dto.response.CounselCardBaseInformationRes; import com.springboot.api.counselcard.dto.response.CounselCardHealthInformationRes; @@ -8,6 +17,11 @@ import com.springboot.api.counselcard.dto.response.CounselCardIndependentLifeInformationRes; import com.springboot.api.counselcard.dto.response.CounselCardLivingInformationRes; import com.springboot.api.counselcard.dto.response.CounselCardRes; +import com.springboot.api.counselcard.dto.response.MainCounselBaseInformationRes; +import com.springboot.api.counselcard.dto.response.MainCounselHealthInformationRes; +import com.springboot.api.counselcard.dto.response.MainCounselIndependentLifeInformationRes; +import com.springboot.api.counselcard.dto.response.MainCounselLivingInformationRes; +import com.springboot.api.counselcard.dto.response.MainCounselRecordBuilder; import com.springboot.api.counselcard.dto.response.TimeRecordedRes; import com.springboot.api.counselcard.entity.CounselCard; import com.springboot.api.counselcard.repository.CounselCardRepository; @@ -159,4 +173,58 @@ private List selectRecordedCardsByPreviousSessions(String counselSe return recordedCardsByPreviousSessions; } + + @Transactional(readOnly = true) + public MainCounselBaseInformationRes selectMainCounselBaseInformation(String counselSessionId) { + List counselCards = counselCardRepository + .findCurrentAndPastCounselCardsBySessionId(counselSessionId); + + return new MainCounselBaseInformationRes( + MainCounselRecordBuilder.build(counselCards, c -> c.getCounselPurposeAndNote().getCounselPurpose()), + MainCounselRecordBuilder.build(counselCards, c -> c.getCounselPurposeAndNote().getSignificantNote()), + MainCounselRecordBuilder.build(counselCards, c -> c.getCounselPurposeAndNote().getMedicationNote()) + ); + } + + @Transactional(readOnly = true) + public MainCounselHealthInformationRes selectMainCounselHealthInformation(String counselSessionId) { + List counselCards = counselCardRepository + .findCurrentAndPastCounselCardsBySessionId(counselSessionId); + + return new MainCounselHealthInformationRes( + MainCounselRecordBuilder.build(counselCards, c -> c.getDiseaseInfo().getDiseases()), + MainCounselRecordBuilder.build(counselCards, c -> c.getDiseaseInfo().getHistoryNote()), + MainCounselRecordBuilder.build(counselCards, c -> c.getDiseaseInfo().getMainInconvenienceNote()), + MainCounselRecordBuilder.build(counselCards, c -> new MedicationSideEffectDTO(c.getMedicationSideEffect())), + MainCounselRecordBuilder.build(counselCards, c -> new AllergyDTO(c.getAllergy())) + ); + } + + @Transactional(readOnly = true) + public MainCounselLivingInformationRes selectMainCounselLivingInformation( + String counselSessionId) { + List counselCards = counselCardRepository + .findCurrentAndPastCounselCardsBySessionId(counselSessionId); + + return new MainCounselLivingInformationRes( + MainCounselRecordBuilder.build(counselCards, c -> new SmokingDTO(c.getSmoking())), + MainCounselRecordBuilder.build(counselCards, c -> c.getDrinking().getDrinkingAmount()), + MainCounselRecordBuilder.build(counselCards, c -> new ExerciseDTO(c.getExercise())), + MainCounselRecordBuilder.build(counselCards, c -> new MedicationManagementDTO(c.getMedicationManagement())), + MainCounselRecordBuilder.build(counselCards, c -> new NutritionDTO(c.getNutrition())) + ); + } + + @Transactional(readOnly = true) + public MainCounselIndependentLifeInformationRes selectMainCounselIndependentLifeInformation( + String counselSessionId) { + List counselCards = counselCardRepository + .findCurrentAndPastCounselCardsBySessionId(counselSessionId); + + return new MainCounselIndependentLifeInformationRes( + MainCounselRecordBuilder.build(counselCards, c -> new CommunicationDTO(c.getCommunication())), + MainCounselRecordBuilder.build(counselCards, c -> new EvacuationDTO(c.getEvacuation())), + MainCounselRecordBuilder.build(counselCards, c -> new WalkingDTO(c.getWalking())) + ); + } } diff --git a/src/main/java/com/springboot/api/counselee/dto/UpdateCounseleeReq.java b/src/main/java/com/springboot/api/counselee/dto/UpdateCounseleeReq.java index 6d1eaeea..0738b8c5 100644 --- a/src/main/java/com/springboot/api/counselee/dto/UpdateCounseleeReq.java +++ b/src/main/java/com/springboot/api/counselee/dto/UpdateCounseleeReq.java @@ -1,13 +1,14 @@ package com.springboot.api.counselee.dto; -import com.springboot.api.common.annotation.ValidNullableEnum; -import jakarta.validation.constraints.PastOrPresent; import java.time.LocalDate; +import com.springboot.api.common.annotation.ValidNullableEnum; import com.springboot.enums.GenderType; +import com.springboot.enums.HealthInsuranceType; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Past; +import jakarta.validation.constraints.PastOrPresent; import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; import lombok.Builder; @@ -49,4 +50,7 @@ public class UpdateCounseleeReq { @Size(max = 50, message = "담당자 이름은 50자를 초과할 수 없습니다") private String careManagerName; + + @ValidNullableEnum(enumClass = HealthInsuranceType.class) + private HealthInsuranceType healthInsuranceType; } diff --git a/src/main/java/com/springboot/api/counselee/entity/Counselee.java b/src/main/java/com/springboot/api/counselee/entity/Counselee.java index bd70a195..fb428884 100644 --- a/src/main/java/com/springboot/api/counselee/entity/Counselee.java +++ b/src/main/java/com/springboot/api/counselee/entity/Counselee.java @@ -1,7 +1,7 @@ package com.springboot.api.counselee.entity; -import jakarta.persistence.Table; import java.time.LocalDate; +import java.util.Optional; import com.springboot.api.common.entity.BaseEntity; import com.springboot.api.counselee.dto.AddCounseleeReq; @@ -14,13 +14,13 @@ import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; import jakarta.persistence.Temporal; import jakarta.persistence.TemporalType; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; -import java.util.Optional; import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.NoArgsConstructor; @@ -116,5 +116,6 @@ public void update(UpdateCounseleeReq updateCounseleeReq) { Optional.ofNullable(updateCounseleeReq.getAddress()).ifPresent(value -> this.address = value); Optional.ofNullable(updateCounseleeReq.getIsDisability()).ifPresent(value -> this.isDisability = value); Optional.ofNullable(updateCounseleeReq.getCareManagerName()).ifPresent(value -> this.careManagerName = value); + Optional.ofNullable(updateCounseleeReq.getHealthInsuranceType()).ifPresent(value -> this.healthInsuranceType = value); } } diff --git a/src/main/java/com/springboot/api/counselor/controller/CounselorController.java b/src/main/java/com/springboot/api/counselor/controller/CounselorController.java index 5de78ae3..69e8c4a3 100644 --- a/src/main/java/com/springboot/api/counselor/controller/CounselorController.java +++ b/src/main/java/com/springboot/api/counselor/controller/CounselorController.java @@ -1,8 +1,5 @@ package com.springboot.api.counselor.controller; -import com.springboot.api.common.dto.PageReq; -import com.springboot.api.common.dto.PageRes; -import com.springboot.api.counselor.dto.CounselorInfoListRes; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -14,11 +11,16 @@ import com.springboot.api.common.annotation.ApiController; import com.springboot.api.common.annotation.RoleSecured; +import com.springboot.api.common.dto.PageReq; +import com.springboot.api.common.dto.PageRes; +import com.springboot.api.counselor.dto.ChangePasswordReq; +import com.springboot.api.counselor.dto.CounselorInfoListRes; import com.springboot.api.counselor.dto.CounselorNameListRes; import com.springboot.api.counselor.dto.GetCounselorRes; import com.springboot.api.counselor.dto.ResetPasswordReq; import com.springboot.api.counselor.dto.UpdateCounselorReq; import com.springboot.api.counselor.dto.UpdateCounselorRes; +import com.springboot.api.counselor.dto.UpdateMyInfoReq; import com.springboot.api.counselor.dto.UpdateRoleReq; import com.springboot.api.counselor.service.CounselorService; import com.springboot.enums.RoleType; @@ -113,4 +115,38 @@ public ResponseEntity> getCounselorsByPage( @RequestParam(defaultValue = "10") int size) { return ResponseEntity.ok(counselorService.getCounselorsByPage(PageReq.of(page, size))); } + + @Operation(summary = "내 정보 업데이트", description = "자기 자신의 이름과 전화번호를 업데이트한다.", responses = { + @ApiResponse(responseCode = "200", description = "내 정보 업데이트 성공"), + @ApiResponse(responseCode = "400", description = "잘못된 요청") + }) + @RoleSecured({RoleType.ROLE_ASSISTANT, RoleType.ROLE_ADMIN, RoleType.ROLE_USER, RoleType.ROLE_NONE}) + @PutMapping("/my-info") + public ResponseEntity updateMyInfo( + @Valid @RequestBody UpdateMyInfoReq updateMyInfoReq) { + return ResponseEntity.ok(counselorService.updateMyInfo(updateMyInfoReq)); + } + + @Operation(summary = "내 비밀번호 변경", description = "자기 자신의 비밀번호를 변경한다. Keycloak에서 비밀번호를 변경한다.", responses = { + @ApiResponse(responseCode = "204", description = "비밀번호 변경 성공"), + @ApiResponse(responseCode = "400", description = "잘못된 요청") + }) + @RoleSecured({RoleType.ROLE_ASSISTANT, RoleType.ROLE_ADMIN, RoleType.ROLE_USER, RoleType.ROLE_NONE}) + @PutMapping("/my-password") + public ResponseEntity changeMyPassword( + @Valid @RequestBody ChangePasswordReq changePasswordReq) { + counselorService.changeMyPassword(changePasswordReq); + return ResponseEntity.noContent().build(); + } + + @Operation(summary = "내 계정 탈퇴", description = "자기 자신의 계정을 탈퇴한다. Keycloak에서도 해당 사용자를 삭제한다.", responses = { + @ApiResponse(responseCode = "204", description = "계정 탈퇴 성공"), + @ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없음") + }) + @RoleSecured({RoleType.ROLE_ASSISTANT, RoleType.ROLE_ADMIN, RoleType.ROLE_USER, RoleType.ROLE_NONE}) + @DeleteMapping("/my-account") + public ResponseEntity deleteMyAccount() { + counselorService.deleteMyAccount(); + return ResponseEntity.noContent().build(); + } } diff --git a/src/main/java/com/springboot/api/counselor/dto/ChangePasswordReq.java b/src/main/java/com/springboot/api/counselor/dto/ChangePasswordReq.java new file mode 100644 index 00000000..939a49ef --- /dev/null +++ b/src/main/java/com/springboot/api/counselor/dto/ChangePasswordReq.java @@ -0,0 +1,23 @@ +package com.springboot.api.counselor.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 비밀번호 변경 요청 DTO + */ +@Data +@AllArgsConstructor +@NoArgsConstructor +public class ChangePasswordReq { + + /** + * 새 비밀번호 + */ + @NotBlank(message = "새 비밀번호는 필수 입력 항목입니다.") + @Size(min = 8, message = "새 비밀번호는 최소 8자 이상이어야 합니다.") + private String newPassword; +} \ No newline at end of file diff --git a/src/main/java/com/springboot/api/counselor/dto/UpdateMyInfoReq.java b/src/main/java/com/springboot/api/counselor/dto/UpdateMyInfoReq.java new file mode 100644 index 00000000..9f989635 --- /dev/null +++ b/src/main/java/com/springboot/api/counselor/dto/UpdateMyInfoReq.java @@ -0,0 +1,28 @@ +package com.springboot.api.counselor.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 내 정보 업데이트 요청 DTO + */ +@Data +@AllArgsConstructor +@NoArgsConstructor +public class UpdateMyInfoReq { + + /** + * 이름 + */ + @NotBlank(message = "이름은 필수 입력 항목입니다.") + private String name; + + /** + * 전화번호 + */ + @Pattern(regexp = "^\\d{2,3}-\\d{3,4}-\\d{4}$", message = "전화번호는 10~11자리의 숫자여야 합니다.") + private String phoneNumber; +} \ No newline at end of file diff --git a/src/main/java/com/springboot/api/counselor/entity/Counselor.java b/src/main/java/com/springboot/api/counselor/entity/Counselor.java index 1e8fcabe..96a806b0 100644 --- a/src/main/java/com/springboot/api/counselor/entity/Counselor.java +++ b/src/main/java/com/springboot/api/counselor/entity/Counselor.java @@ -1,18 +1,8 @@ package com.springboot.api.counselor.entity; -import java.time.LocalDate; -import java.util.Collection; -import java.util.Set; - -import org.hibernate.annotations.SQLDelete; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.core.userdetails.UserDetails; - import com.springboot.api.common.entity.BaseEntity; import com.springboot.enums.CounselorStatus; import com.springboot.enums.RoleType; - import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; @@ -24,11 +14,18 @@ import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; +import java.time.LocalDate; +import java.util.Collection; +import java.util.Set; import lombok.AllArgsConstructor; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; import lombok.experimental.SuperBuilder; +import org.hibernate.annotations.SQLDelete; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; @Entity @Table(name = "counselors", uniqueConstraints = { @@ -101,6 +98,7 @@ public class Counselor extends BaseEntity implements UserDetails { @Column(columnDefinition = "TEXT") private String description; + @Override public String getId() { if (this.status.equals(CounselorStatus.INACTIVE)) { return null; diff --git a/src/main/java/com/springboot/api/counselor/service/CounselorService.java b/src/main/java/com/springboot/api/counselor/service/CounselorService.java index cd57b13e..f12d8837 100644 --- a/src/main/java/com/springboot/api/counselor/service/CounselorService.java +++ b/src/main/java/com/springboot/api/counselor/service/CounselorService.java @@ -1,8 +1,5 @@ package com.springboot.api.counselor.service; -import com.springboot.api.common.dto.PageReq; -import com.springboot.api.common.dto.PageRes; -import com.springboot.api.counselor.dto.CounselorInfoListRes; import java.util.Arrays; import java.util.List; import java.util.stream.Collectors; @@ -16,12 +13,17 @@ import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; import org.springframework.stereotype.Service; +import com.springboot.api.common.dto.PageReq; +import com.springboot.api.common.dto.PageRes; import com.springboot.api.common.exception.ResourceNotFoundException; +import com.springboot.api.counselor.dto.ChangePasswordReq; +import com.springboot.api.counselor.dto.CounselorInfoListRes; import com.springboot.api.counselor.dto.CounselorNameListRes; import com.springboot.api.counselor.dto.GetCounselorRes; import com.springboot.api.counselor.dto.ResetPasswordReq; import com.springboot.api.counselor.dto.UpdateCounselorReq; import com.springboot.api.counselor.dto.UpdateCounselorRes; +import com.springboot.api.counselor.dto.UpdateMyInfoReq; import com.springboot.api.counselor.dto.UpdateRoleReq; import com.springboot.api.counselor.entity.Counselor; import com.springboot.api.counselor.repository.CounselorRepository; @@ -214,4 +216,101 @@ public Counselor findCounselorById(String counselorId) { return counselorRepository.findActiveById(counselorId) .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 상담사 ID입니다")); } + + /** + * 자기 자신의 정보를 업데이트합니다. 이름과 전화번호를 변경할 수 있습니다. + * + * @param updateMyInfoReq 업데이트할 내 정보 + * @return 업데이트된 상담사 정보 + */ + @CacheEvict(value = "counselorNames", allEntries = true) + @Transactional + public UpdateCounselorRes updateMyInfo(UpdateMyInfoReq updateMyInfoReq) { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + Jwt jwt = ((JwtAuthenticationToken) authentication).getToken(); + String username = jwt.getClaimAsString("preferred_username"); + + Counselor counselor = counselorRepository + .findActiveByUsername(username) + .orElseThrow(() -> new IllegalArgumentException("사용자 정보를 찾을 수 없습니다")); + + counselor.setName(updateMyInfoReq.getName()); + counselor.setPhoneNumber(updateMyInfoReq.getPhoneNumber()); + + Counselor updatedCounselor = counselorRepository.save(counselor); + log.info("내 정보 업데이트 완료: {}", counselor.getUsername()); + + return new UpdateCounselorRes( + updatedCounselor.getId(), + updatedCounselor.getName(), + updatedCounselor.getRoleType()); + } + + /** + * 자기 자신의 비밀번호를 변경합니다. Keycloak에서 비밀번호를 변경합니다. + * + * @param changePasswordReq 비밀번호 변경 요청 정보 + */ + @Transactional + public void changeMyPassword(ChangePasswordReq changePasswordReq) { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + Jwt jwt = ((JwtAuthenticationToken) authentication).getToken(); + String username = jwt.getClaimAsString("preferred_username"); + + Counselor counselor = counselorRepository + .findActiveByUsername(username) + .orElseThrow(() -> new IllegalArgumentException("사용자 정보를 찾을 수 없습니다")); + + try { + // Keycloak에서 사용자 찾기 + List users = keycloakUserService.getUsersByUsername(counselor.getUsername()); + if (users.isEmpty()) { + throw new ResourceNotFoundException("Keycloak에서 사용자를 찾을 수 없습니다: " + counselor.getUsername()); + } + + UserRepresentation user = users.getFirst(); + + // 새 비밀번호로 변경 (임시 비밀번호 아님) + keycloakUserService.resetPassword( + user.getId(), + changePasswordReq.getNewPassword(), + false + ); + + log.info("사용자 비밀번호 변경 완료: {}", counselor.getUsername()); + } catch (ResourceNotFoundException e) { + log.error("비밀번호 변경 중 오류 발생: {}", e.getMessage(), e); + throw new RuntimeException("비밀번호 변경 중 오류가 발생했습니다", e); + } + } + + /** + * 자기 자신의 계정을 탈퇴합니다. Keycloak에서도 해당 사용자를 삭제합니다. + */ + @CacheEvict(value = {"counselorNames", "sessionList"}, allEntries = true) + @Transactional + public void deleteMyAccount() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + Jwt jwt = ((JwtAuthenticationToken) authentication).getToken(); + String username = jwt.getClaimAsString("preferred_username"); + + Counselor counselor = counselorRepository + .findActiveByUsername(username) + .orElseThrow(() -> new IllegalArgumentException("사용자 정보를 찾을 수 없습니다")); + + try { + // Keycloak에서 사용자 찾기 시도 + List users = keycloakUserService.getUsersByUsername(username); + if (!users.isEmpty()) { + // Keycloak에서 사용자 삭제 + keycloakUserService.deleteUser(users.getFirst().getId()); + log.info("Keycloak에서 사용자 삭제 완료: {}", username); + } + } catch (Exception e) { + log.error("Keycloak에서 사용자 삭제 중 오류 발생: {}", e.getMessage(), e); + // Keycloak 삭제 실패해도 DB에서는 삭제 진행 + } + counselorRepository.deleteById(counselor.getId()); + log.info("내 계정 탈퇴 완료: {}", counselor.getId()); + } } diff --git a/src/main/java/com/springboot/api/counselsession/controller/AICounselSummaryController.java b/src/main/java/com/springboot/api/counselsession/controller/AICounselSummaryController.java index bb827099..943a7619 100644 --- a/src/main/java/com/springboot/api/counselsession/controller/AICounselSummaryController.java +++ b/src/main/java/com/springboot/api/counselsession/controller/AICounselSummaryController.java @@ -1,5 +1,19 @@ package com.springboot.api.counselsession.controller; +import java.io.IOException; +import java.time.LocalDate; +import java.util.List; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.multipart.MultipartFile; + import com.fasterxml.jackson.core.JsonProcessingException; import com.springboot.api.common.annotation.ApiController; import com.springboot.api.common.dto.CommonRes; @@ -11,21 +25,10 @@ import com.springboot.api.counselsession.dto.aiCounselSummary.SelectAnalysedTextRes; import com.springboot.api.counselsession.dto.aiCounselSummary.SelectSpeechToTextRes; import com.springboot.api.counselsession.service.AICounselSummaryService; + import io.swagger.v3.oas.annotations.Operation; import jakarta.validation.Valid; -import java.io.IOException; -import java.time.LocalDate; -import java.util.List; import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RequestPart; -import org.springframework.web.multipart.MultipartFile; @ApiController(name = "AICounselSummaryController", path = "/v1/counsel/ai", description = "본상담 내 AI요약 관련 API를 제공하는 Controller") @RequiredArgsConstructor @@ -40,7 +43,15 @@ public ResponseEntity convertSpeechToText( @RequestPart("body") @Valid ConvertSpeechToTextReq convertSpeechToTextReq) throws IOException { aiCounselSummaryService.convertSpeechToText(file, convertSpeechToTextReq); return ResponseEntity.ok(new SuccessRes()); + } + @PostMapping(value = "{counselSessionId}/stt") + @Operation(summary = "convert Speech to Text", tags = {"AI요약"}) + public ResponseEntity convertSpeechToText( + @PathVariable String counselSessionId) throws IOException { + + aiCounselSummaryService.convertSpeechToText(counselSessionId); + return ResponseEntity.ok(new SuccessRes()); } @GetMapping("{counselSessionId}/stt") diff --git a/src/main/java/com/springboot/api/counselsession/controller/CounselSessionController.java b/src/main/java/com/springboot/api/counselsession/controller/CounselSessionController.java index 4df911dc..ddcff60f 100644 --- a/src/main/java/com/springboot/api/counselsession/controller/CounselSessionController.java +++ b/src/main/java/com/springboot/api/counselsession/controller/CounselSessionController.java @@ -1,7 +1,5 @@ package com.springboot.api.counselsession.controller; -import com.springboot.api.common.dto.PageReq; -import com.springboot.api.common.dto.PageRes; import java.time.LocalDate; import java.util.List; @@ -16,8 +14,9 @@ import com.springboot.api.common.annotation.ApiController; import com.springboot.api.common.annotation.RoleSecured; -import com.springboot.api.common.dto.CommonCursorRes; import com.springboot.api.common.dto.CommonRes; +import com.springboot.api.common.dto.PageReq; +import com.springboot.api.common.dto.PageRes; import com.springboot.api.counselsession.dto.counselsession.CounselSessionStatRes; import com.springboot.api.counselsession.dto.counselsession.CreateCounselReservationReq; import com.springboot.api.counselsession.dto.counselsession.CreateCounselReservationRes; @@ -28,6 +27,7 @@ import com.springboot.api.counselsession.dto.counselsession.SearchCounselSessionReq; import com.springboot.api.counselsession.dto.counselsession.SelectCounselSessionListItem; import com.springboot.api.counselsession.dto.counselsession.SelectCounselSessionRes; +import com.springboot.api.counselsession.dto.counselsession.SelectPreviousCounselSessionDetailRes; import com.springboot.api.counselsession.dto.counselsession.SelectPreviousCounselSessionListRes; import com.springboot.api.counselsession.dto.counselsession.UpdateCounselorInCounselSessionReq; import com.springboot.api.counselsession.dto.counselsession.UpdateCounselorInCounselSessionRes; @@ -35,6 +35,7 @@ import com.springboot.api.counselsession.dto.counselsession.UpdateStatusInCounselSessionRes; import com.springboot.api.counselsession.service.CounselSessionService; import com.springboot.enums.RoleType; +import com.springboot.enums.ScheduleStatus; import io.swagger.v3.oas.annotations.Operation; import jakarta.validation.Valid; @@ -105,13 +106,15 @@ public ResponseEntity> searchCounselSessions( @RequestParam("size") @Min(1) @Max(100) int size, @RequestParam(required = false, name = "counseleeNameKeyword") @Pattern(regexp = "^[가-힣a-zA-Z\\s]*$", message = "이름은 한글과 영문만 허용됩니다") String counseleeNameKeyword, @RequestParam(required = false, name = "counselorNames") List counselorNames, - @RequestParam(required = false) List scheduledDates) { + @RequestParam(required = false) List scheduledDates, + @RequestParam(required = false) List statuses) { SearchCounselSessionReq searchCounselSessionReq = new SearchCounselSessionReq( new PageReq(page, size), counseleeNameKeyword, counselorNames, - scheduledDates); + scheduledDates, + statuses); return ResponseEntity.ok(counselSessionService.searchCounselSessions(searchCounselSessionReq)); } @@ -165,4 +168,19 @@ public ResponseEntity>> sele .selectPreviousCounselSessionList(counselSessionId); return ResponseEntity.ok(new CommonRes<>(result)); } + + @Operation(summary = "이전 상담 내역 상세 조회 (페이징)", tags = {"본상담 - 이전 상담 내역"}, + description = "현재 회차의 이전 회차들을 최신순으로 조회합니다. 날짜, 회차, 중재기록, AI요약 정보를 포함합니다.") + @GetMapping("/{counselSessionId}/previous/details") + @RoleSecured({RoleType.ROLE_ADMIN, RoleType.ROLE_USER}) + public ResponseEntity> selectPreviousCounselSessionDetailList( + @PathVariable("counselSessionId") String counselSessionId, + @RequestParam("page") @Min(0) int page, + @RequestParam("size") @Min(1) @Max(100) int size) { + + PageReq pageReq = PageReq.of(page, size); + PageRes result = counselSessionService + .selectPreviousCounselSessionDetailList(counselSessionId, pageReq); + return ResponseEntity.ok(result); + } } diff --git a/src/main/java/com/springboot/api/counselsession/controller/CounseleeConsentController.java b/src/main/java/com/springboot/api/counselsession/controller/CounseleeConsentController.java index b1b4c8ff..972bb6a9 100644 --- a/src/main/java/com/springboot/api/counselsession/controller/CounseleeConsentController.java +++ b/src/main/java/com/springboot/api/counselsession/controller/CounseleeConsentController.java @@ -1,29 +1,25 @@ package com.springboot.api.counselsession.controller; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestParam; + import com.springboot.api.common.annotation.ApiController; import com.springboot.api.common.annotation.RoleSecured; import com.springboot.api.common.dto.CommonRes; -import com.springboot.api.counselsession.dto.counseleeconsent.AddCounseleeConsentReq; -import com.springboot.api.counselsession.dto.counseleeconsent.AddCounseleeConsentRes; +import com.springboot.api.counselsession.dto.counseleeconsent.AcceptConsentRes; import com.springboot.api.counselsession.dto.counseleeconsent.DeleteCounseleeConsentRes; import com.springboot.api.counselsession.dto.counseleeconsent.SelectCounseleeConsentByCounseleeIdRes; -import com.springboot.api.counselsession.dto.counseleeconsent.UpdateCounseleeConsentReq; -import com.springboot.api.counselsession.dto.counseleeconsent.UpdateCounseleeConsentRes; import com.springboot.api.counselsession.service.CounseleeConsentService; import com.springboot.enums.RoleType; + import io.swagger.v3.oas.annotations.Operation; -import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestParam; @ApiController(path = "/v1/counselee/consent", name = "CounseleeConsentController", description = "내담자 동의 관련 API를 제공하는 Controller") @RequiredArgsConstructor @@ -45,52 +41,26 @@ public ResponseEntity> selectC return ResponseEntity.ok(new CommonRes<>(selectCounseleeConsentByCounseleeIdRes)); } - @Deprecated - @PostMapping - @Operation(summary = "내담자 개인정보 수집 동의 여부 등록", tags = {"개인 정보 수집 동의"}) - @RoleSecured({RoleType.ROLE_ASSISTANT, RoleType.ROLE_ADMIN, RoleType.ROLE_USER}) - public ResponseEntity> addCounseleeConsent( - @RequestBody @Valid AddCounseleeConsentReq addCounseleeConsentReq) { - - AddCounseleeConsentRes addCounseleeConsentRes = counseleeConsentService.addCounseleeConsent( - addCounseleeConsentReq); - - return ResponseEntity.ok(new CommonRes<>(addCounseleeConsentRes)); - } - - @Deprecated - @PutMapping - @Operation(summary = "내담자 개인정보 수집 동의 여부 수정", tags = {"개인 정보 수집 동의"}) - @RoleSecured({RoleType.ROLE_ASSISTANT, RoleType.ROLE_ADMIN, RoleType.ROLE_USER}) - public ResponseEntity> updateCounseleeConsent( - @RequestBody @Valid UpdateCounseleeConsentReq updateCounseleeConsentReq) { - - UpdateCounseleeConsentRes updateCounseleeConsentRes = counseleeConsentService.updateCounseleeConsent( - updateCounseleeConsentReq); - - return ResponseEntity.ok(new CommonRes<>(updateCounseleeConsentRes)); - } - - @PutMapping("/{counseleeConsentId}") + @PutMapping("/{counselSessionId}") @Operation(summary = "내담자 개인정보 수집 동의", tags = {"개인 정보 수집 동의"}) @RoleSecured({RoleType.ROLE_ASSISTANT, RoleType.ROLE_ADMIN, RoleType.ROLE_USER}) - public ResponseEntity> acceptCounseleeConsent( - @PathVariable @NotBlank(message = "내담자 동의 ID는 필수 입력값입니다") @Size(min = 26, max = 26, message = "내담자 동의 ID는 26자여야 합니다") String counseleeConsentId) { + public ResponseEntity> acceptCounseleeConsent( + @PathVariable @NotBlank(message = "상담 세션 ID는 필수 입력값입니다") @Size(min = 26, max = 26, message = "상담 세션 ID는 26자여야 합니다") String counselSessionId) { - UpdateCounseleeConsentRes updateCounseleeConsentRes = counseleeConsentService.acceptCounseleeConsent( - counseleeConsentId); + AcceptConsentRes acceptConsentRes = counseleeConsentService.acceptCounseleeConsent( + counselSessionId); - return ResponseEntity.ok(new CommonRes<>(updateCounseleeConsentRes)); + return ResponseEntity.ok(new CommonRes<>(acceptConsentRes)); } - @DeleteMapping("/{counseleeConsentId}") + @DeleteMapping("/{counselSessionId}") @Operation(summary = "내담자 개인정보 수집 동의 여부 삭제", tags = {"개인 정보 수집 동의"}) @RoleSecured({RoleType.ROLE_ASSISTANT, RoleType.ROLE_ADMIN, RoleType.ROLE_USER}) public ResponseEntity> deleteCounseleeConsent( - @PathVariable @NotBlank(message = "내담자 동의 ID는 필수 입력값입니다") @Size(min = 26, max = 26, message = "내담자 동의 ID는 26자여야 합니다") String counseleeConsentId) { + @PathVariable @NotBlank(message = "상담 세션 ID는 필수 입력값입니다") @Size(min = 26, max = 26, message = "상담 세션 ID는 26자여야 합니다") String counselSessionId) { DeleteCounseleeConsentRes deleteCounseleeConsentRes = counseleeConsentService - .deleteCounseleeConsent(counseleeConsentId); + .deleteCounseleeConsent(counselSessionId); return ResponseEntity.ok(new CommonRes<>(deleteCounseleeConsentRes)); diff --git a/src/main/java/com/springboot/api/counselsession/dto/counseleeconsent/AddCounseleeConsentRes.java b/src/main/java/com/springboot/api/counselsession/dto/counseleeconsent/AcceptConsentRes.java similarity index 59% rename from src/main/java/com/springboot/api/counselsession/dto/counseleeconsent/AddCounseleeConsentRes.java rename to src/main/java/com/springboot/api/counselsession/dto/counseleeconsent/AcceptConsentRes.java index 9b82ec97..e002d2ba 100644 --- a/src/main/java/com/springboot/api/counselsession/dto/counseleeconsent/AddCounseleeConsentRes.java +++ b/src/main/java/com/springboot/api/counselsession/dto/counseleeconsent/AcceptConsentRes.java @@ -3,6 +3,6 @@ import lombok.Builder; @Builder -public record AddCounseleeConsentRes(String counseleeConsentId) { +public record AcceptConsentRes(String updatedCounseleeConsentId) { -} +} \ No newline at end of file diff --git a/src/main/java/com/springboot/api/counselsession/dto/counseleeconsent/AddCounseleeConsentReq.java b/src/main/java/com/springboot/api/counselsession/dto/counseleeconsent/AddCounseleeConsentReq.java deleted file mode 100644 index b63f8425..00000000 --- a/src/main/java/com/springboot/api/counselsession/dto/counseleeconsent/AddCounseleeConsentReq.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.springboot.api.counselsession.dto.counseleeconsent; - -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Size; -import lombok.Builder; -import lombok.Data; - -@Data -@Builder -public class AddCounseleeConsentReq { - - @NotBlank - @Size(min = 26, max = 26, message = "상담 세션 ID는 26자여야 합니다") - private String counselSessionId; - - @NotBlank - @Size(min = 26, max = 26, message = "내담자 ID는 26자여야 합니다") - private String counseleeId; - - @NotNull(message = "동의 여부는 필수 입력값입니다") - private boolean isConsent; -} diff --git a/src/main/java/com/springboot/api/counselsession/dto/counseleeconsent/UpdateCounseleeConsentReq.java b/src/main/java/com/springboot/api/counselsession/dto/counseleeconsent/UpdateCounseleeConsentReq.java deleted file mode 100644 index 5c39493b..00000000 --- a/src/main/java/com/springboot/api/counselsession/dto/counseleeconsent/UpdateCounseleeConsentReq.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.springboot.api.counselsession.dto.counseleeconsent; - -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Size; -import lombok.Builder; -import lombok.Data; - -@Data -@Builder -public class UpdateCounseleeConsentReq { - - @NotBlank(message = "내담자 동의 ID는 필수 입력값입니다") - @Size(min = 26, max = 26, message = "내담자 동의 ID는 26자여야 합니다") - private String counseleeConsentId; - - @NotNull(message = "동의 여부는 필수 입력값입니다") - private boolean isConsent; -} diff --git a/src/main/java/com/springboot/api/counselsession/dto/counseleeconsent/UpdateCounseleeConsentRes.java b/src/main/java/com/springboot/api/counselsession/dto/counseleeconsent/UpdateCounseleeConsentRes.java deleted file mode 100644 index c05729b3..00000000 --- a/src/main/java/com/springboot/api/counselsession/dto/counseleeconsent/UpdateCounseleeConsentRes.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.springboot.api.counselsession.dto.counseleeconsent; - -import lombok.Builder; - -@Builder -public record UpdateCounseleeConsentRes(String updatedCounseleeConsentId) { - -} \ No newline at end of file diff --git a/src/main/java/com/springboot/api/counselsession/dto/counselsession/SearchCounselSessionReq.java b/src/main/java/com/springboot/api/counselsession/dto/counselsession/SearchCounselSessionReq.java index e327fa1b..0245c8e9 100644 --- a/src/main/java/com/springboot/api/counselsession/dto/counselsession/SearchCounselSessionReq.java +++ b/src/main/java/com/springboot/api/counselsession/dto/counselsession/SearchCounselSessionReq.java @@ -1,13 +1,12 @@ package com.springboot.api.counselsession.dto.counselsession; -import com.springboot.api.common.dto.PageReq; import java.time.LocalDate; import java.util.List; -import lombok.AllArgsConstructor; -import lombok.Getter; +import com.springboot.api.common.dto.PageReq; +import com.springboot.enums.ScheduleStatus; public record SearchCounselSessionReq(PageReq pageReq, String counseleeNameKeyword, List counselorNames, - List scheduledDates) { + List scheduledDates, List statuses) { } \ No newline at end of file diff --git a/src/main/java/com/springboot/api/counselsession/dto/counselsession/SelectCounselSessionRes.java b/src/main/java/com/springboot/api/counselsession/dto/counselsession/SelectCounselSessionRes.java index 8026e020..ef46d384 100644 --- a/src/main/java/com/springboot/api/counselsession/dto/counselsession/SelectCounselSessionRes.java +++ b/src/main/java/com/springboot/api/counselsession/dto/counselsession/SelectCounselSessionRes.java @@ -3,36 +3,40 @@ import com.springboot.api.counselor.entity.Counselor; import com.springboot.api.counselsession.entity.CounselSession; import com.springboot.enums.ScheduleStatus; -import java.util.Optional; -import lombok.Builder; - -@Builder -public record SelectCounselSessionRes( - String counselSessionId, - String scheduledTime, - String scheduledDate, - String counseleeId, - String counseleeName, - String counselorId, - String counselorName, - Integer sessionNumber, - ScheduleStatus status) { +import lombok.Getter; + +@Getter +public class SelectCounselSessionRes { + + private String counselSessionId; + private String scheduledTime; + private String scheduledDate; + private String counseleeId; + private String counseleeName; + private String counselorId; + private String counselorName; + private String sessionNumber; + private ScheduleStatus status; public static SelectCounselSessionRes from(CounselSession counselSession) { - return SelectCounselSessionRes.builder() - .counselSessionId(counselSession.getId()) - .scheduledTime(counselSession.getScheduledStartDateTime().toLocalTime().toString()) - .scheduledDate(counselSession.getScheduledStartDateTime().toLocalDate().toString()) - .counseleeId(counselSession.getCounselee().getId()) - .counseleeName(counselSession.getCounselee().getName()) - .counselorId(Optional.ofNullable(counselSession.getCounselor()) - .map(Counselor::getId) - .orElse("")) - .counselorName(Optional.ofNullable(counselSession.getCounselor()) - .map(Counselor::getName) - .orElse("")) - .sessionNumber(counselSession.getSessionNumber()) - .status(counselSession.getStatus()) - .build(); + SelectCounselSessionRes res = new SelectCounselSessionRes(); + + res.counselSessionId = counselSession.getId(); + res.scheduledTime = counselSession.getScheduledStartDateTime().toLocalTime().toString(); + res.scheduledDate = counselSession.getScheduledStartDateTime().toLocalDate().toString(); + + res.counseleeId = counselSession.getCounselee().getId(); + res.counseleeName = counselSession.getCounselee().getName(); + + Counselor counselor = counselSession.getCounselor(); + res.counselorId = (counselor != null) ? counselor.getId() : ""; + res.counselorName = (counselor != null) ? counselor.getName() : ""; + + res.status = counselSession.getStatus(); + res.sessionNumber = res.status == ScheduleStatus.CANCELED ? "-" : + String.valueOf(counselSession.getSessionNumber()); + + return res; } + } diff --git a/src/main/java/com/springboot/api/counselsession/dto/counselsession/SelectPreviousCounselSessionDetailRes.java b/src/main/java/com/springboot/api/counselsession/dto/counselsession/SelectPreviousCounselSessionDetailRes.java new file mode 100644 index 00000000..970881d0 --- /dev/null +++ b/src/main/java/com/springboot/api/counselsession/dto/counselsession/SelectPreviousCounselSessionDetailRes.java @@ -0,0 +1,16 @@ +package com.springboot.api.counselsession.dto.counselsession; + +import java.time.LocalDate; + +import lombok.Builder; + +@Builder +public record SelectPreviousCounselSessionDetailRes( + String counselSessionId, + LocalDate counselSessionDate, + Integer sessionNumber, + String counselorName, + String medicationCounselRecord, + String aiSummary +) { +} \ No newline at end of file diff --git a/src/main/java/com/springboot/api/counselsession/repository/CounselSessionRepositoryCustom.java b/src/main/java/com/springboot/api/counselsession/repository/CounselSessionRepositoryCustom.java index 4c8c60b4..57cf5890 100644 --- a/src/main/java/com/springboot/api/counselsession/repository/CounselSessionRepositoryCustom.java +++ b/src/main/java/com/springboot/api/counselsession/repository/CounselSessionRepositoryCustom.java @@ -1,14 +1,15 @@ package com.springboot.api.counselsession.repository; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + import com.springboot.api.common.dto.PageReq; import com.springboot.api.common.dto.PageRes; import com.springboot.api.counselsession.dto.counselsession.SelectCounselSessionListItem; import com.springboot.api.counselsession.entity.CounselSession; import com.springboot.enums.ScheduleStatus; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.util.List; -import java.util.Map; public interface CounselSessionRepositoryCustom { @@ -27,11 +28,12 @@ public interface CounselSessionRepositoryCustom { List findPreviousCompletedSessionsOrderByEndDateTimeDesc(String counseleeId, LocalDateTime beforeDateTime); - PageRes findByCounseleeNameAndCounselorNameAndScheduledDateTime( + PageRes findByCounseleeNameAndCounselorNameAndScheduledDateTimeAndStatus( PageReq pageReq, String counseleeNameKeyword, List counselorNames, - List scheduledDates); + List scheduledDates, + List statuses); List findValidCounselSessionsByCounseleeId(String counseleeId); diff --git a/src/main/java/com/springboot/api/counselsession/repository/CounselSessionRepositoryImpl.java b/src/main/java/com/springboot/api/counselsession/repository/CounselSessionRepositoryImpl.java index c83f2a35..9464789f 100644 --- a/src/main/java/com/springboot/api/counselsession/repository/CounselSessionRepositoryImpl.java +++ b/src/main/java/com/springboot/api/counselsession/repository/CounselSessionRepositoryImpl.java @@ -1,5 +1,12 @@ package com.springboot.api.counselsession.repository; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +import org.springframework.stereotype.Repository; + import com.querydsl.core.BooleanBuilder; import com.querydsl.core.Tuple; import com.querydsl.core.types.dsl.CaseBuilder; @@ -17,12 +24,6 @@ import com.springboot.api.counselsession.entity.QCounseleeConsent; import com.springboot.enums.CounselorStatus; import com.springboot.enums.ScheduleStatus; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; -import org.springframework.stereotype.Repository; @Repository public class CounselSessionRepositoryImpl implements CounselSessionRepositoryCustom { @@ -47,7 +48,7 @@ public List findDistinctDatesByYearAndMonth(int year, int month) { .stream() .map(LocalDateTime::toLocalDate) .distinct() - .collect(Collectors.toList()); + .toList(); } @Override @@ -84,8 +85,14 @@ public PageRes findSessionByCursorAndDate(LocalDat counselSession.scheduledStartDateTime, counselSession.counselee.id, counselSession.counselee.name, - counselSession.counselor.id, - counselSession.counselor.name, + new CaseBuilder() + .when(counselSession.counselor.status.eq(CounselorStatus.INACTIVE)) + .then("") + .otherwise(counselSession.counselor.id), + new CaseBuilder() + .when(counselSession.counselor.status.eq(CounselorStatus.INACTIVE)) + .then("탈퇴사용자") + .otherwise(counselSession.counselor.name), counselSession.status, counselCard.cardRecordStatus, counseleeConsent.isConsent)) @@ -134,7 +141,7 @@ public List cancelOverDueSessionsAndReturnAffectedCounseleeIds() { .select(counselSession.id, counselSession.counselee.id) .from(counselSession) .where( - counselSession.status.eq(ScheduleStatus.SCHEDULED), + counselSession.status.in(ScheduleStatus.SCHEDULED, ScheduleStatus.IN_PROGRESS), counselSession.scheduledStartDateTime.before(twentyFourHoursAgo) ) .fetch(); @@ -144,7 +151,7 @@ public List cancelOverDueSessionsAndReturnAffectedCounseleeIds() { } List sessionIds = canceledSessions.stream() .map(tuple -> tuple.get(counselSession.id)) - .collect(Collectors.toList()); + .toList(); List affectedCounseleeIds = canceledSessions.stream() .map(tuple -> tuple.get(counselSession.counselee.id)) @@ -174,11 +181,12 @@ public List findPreviousCompletedSessionsOrderByEndDateTimeDesc( } @Override - public PageRes findByCounseleeNameAndCounselorNameAndScheduledDateTime( + public PageRes findByCounseleeNameAndCounselorNameAndScheduledDateTimeAndStatus( PageReq pageReq, String counseleeNameKeyword, List counselorNames, - List scheduledDates) { + List scheduledDates, + List statuses) { BooleanBuilder builder = new BooleanBuilder(); @@ -203,6 +211,10 @@ public PageRes findByCounseleeNameAndCounselorNameAndScheduledDa builder.and(dateBuilder); } + if (statuses != null && !statuses.isEmpty()) { + builder.and(counselSession.status.in(statuses)); + } + JPAQuery contentQuery = queryFactory .selectFrom(counselSession) .where(builder) diff --git a/src/main/java/com/springboot/api/counselsession/service/AICounselSummaryService.java b/src/main/java/com/springboot/api/counselsession/service/AICounselSummaryService.java index b2766537..9265aae0 100644 --- a/src/main/java/com/springboot/api/counselsession/service/AICounselSummaryService.java +++ b/src/main/java/com/springboot/api/counselsession/service/AICounselSummaryService.java @@ -1,11 +1,35 @@ package com.springboot.api.counselsession.service; -import static com.springboot.api.counselsession.enums.AICounselSummaryStatus.GPT_COMPLETE; -import static com.springboot.api.counselsession.enums.AICounselSummaryStatus.GPT_FAILED; -import static com.springboot.api.counselsession.enums.AICounselSummaryStatus.GPT_PROGRESS; -import static com.springboot.api.counselsession.enums.AICounselSummaryStatus.STT_COMPLETE; -import static com.springboot.api.counselsession.enums.AICounselSummaryStatus.STT_FAILED; -import static com.springboot.api.counselsession.enums.AICounselSummaryStatus.STT_PROGRESS; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.LocalDate; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.atomic.AtomicInteger; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.core.io.FileSystemResource; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; @@ -13,6 +37,7 @@ import com.springboot.api.common.exception.NoContentException; import com.springboot.api.common.properties.NaverClovaProperties; import com.springboot.api.common.properties.SttFileProperties; +import com.springboot.api.common.util.AiResponseParseUtil; import com.springboot.api.common.util.DateTimeUtil; import com.springboot.api.common.util.FileUtil; import com.springboot.api.counselsession.dto.aiCounselSummary.ConvertSpeechToTextReq; @@ -31,41 +56,20 @@ import com.springboot.api.counselsession.entity.CounselSession; import com.springboot.api.counselsession.entity.PromptTemplate; import com.springboot.api.counselsession.enums.AICounselSummaryStatus; +import static com.springboot.api.counselsession.enums.AICounselSummaryStatus.GPT_COMPLETE; +import static com.springboot.api.counselsession.enums.AICounselSummaryStatus.GPT_FAILED; +import static com.springboot.api.counselsession.enums.AICounselSummaryStatus.GPT_PROGRESS; +import static com.springboot.api.counselsession.enums.AICounselSummaryStatus.STT_COMPLETE; +import static com.springboot.api.counselsession.enums.AICounselSummaryStatus.STT_FAILED; +import static com.springboot.api.counselsession.enums.AICounselSummaryStatus.STT_PROGRESS; import com.springboot.api.counselsession.repository.AICounselSummaryRepository; import com.springboot.api.counselsession.repository.CounselSessionRepository; import com.springboot.api.counselsession.repository.PromptTemplateRepository; import com.springboot.api.counselsession.service.eventlistener.STTCompleteEvent; import com.springboot.api.infra.external.NaverClovaExternalService; -import java.io.File; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.time.LocalDate; -import java.util.Comparator; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionException; -import java.util.concurrent.atomic.AtomicInteger; +import com.springboot.api.tus.service.TusService; + import lombok.RequiredArgsConstructor; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.ai.chat.client.ChatClient; -import org.springframework.ai.chat.messages.Message; -import org.springframework.ai.chat.messages.UserMessage; -import org.springframework.ai.chat.model.ChatModel; -import org.springframework.ai.chat.model.ChatResponse; -import org.springframework.ai.chat.prompt.Prompt; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.core.io.FileSystemResource; -import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.multipart.MultipartFile; @Service @RequiredArgsConstructor @@ -83,6 +87,8 @@ public class AICounselSummaryService { private final PromptTemplateRepository promptTemplateRepository; private final FileUtil fileUtil; private final ApplicationEventPublisher applicationEventPublisher; + private final TusService tusService; + private final AiResponseParseUtil aiResponseParseUtil; public void convertSpeechToText(MultipartFile multipartFile, ConvertSpeechToTextReq convertSpeechToTextReq) throws IOException { @@ -153,6 +159,76 @@ public void convertSpeechToText(MultipartFile multipartFile, ConvertSpeechToText }); } + public void convertSpeechToText(String counselSessionId) + throws IOException { + + CounselSession counselSession = counselSessionRepository + .findById(counselSessionId) + .orElseThrow(IllegalArgumentException::new); + + AICounselSummary aiCounselSummary = aiCounselSummaryRepository + .findByCounselSessionId(counselSessionId) + .orElse(AICounselSummary + .builder() + .counselSession(counselSession) + .build()); + + aiCounselSummary.setAiCounselSummaryStatus(STT_PROGRESS); + aiCounselSummary.setSpeakers(null); + aiCounselSummary.setTaResult(null); + aiCounselSummary.setSttResult(null); + aiCounselSummaryRepository.save(aiCounselSummary); + + Map headers = new HashMap<>(); + headers.put("Accept", "application/json"); + headers.put("X-CLOVASPEECH-API-KEY", naverClovaProperties.getApiKey()); + + SpeechToTextReq speechToTextReq = SpeechToTextReq + .builder() + .language("ko-KR") + .completion("sync") + .diarization(DiarizationDTO.builder() + .speakerCountMin(3) + .speakerCountMax(6) + .build()) + .wordAlignment(false) + .fullText(true) + .build(); + + tusService.mergeUploadedFile(counselSessionId); + String mergedFileName = counselSessionId + ".mp4"; + + callNaverClovaAsync(headers, mergedFileName, speechToTextReq) + .thenAcceptAsync( + speechToTextRes -> { + updateAiCounselSummaryStatus( + aiCounselSummary, + "COMPLETED".equals(speechToTextRes.result()) ? STT_COMPLETE : STT_FAILED, + objectMapper.valueToTree(speechToTextRes)); + applicationEventPublisher.publishEvent( + new STTCompleteEvent(counselSessionId)); + } + ) + .exceptionally( + ex -> { + log.error("Speech-to-text processing error", ex); + updateAiCounselSummaryStatus(aiCounselSummary, STT_FAILED, null); + return null; + }) + .whenComplete( + (result, throwable) -> { + // ✅ 성공/실패 여부 상관없이 파일 삭제 + try { + Files.deleteIfExists(Path.of(sttFileProperties.getOrigin() + mergedFileName)); + Files.deleteIfExists( + Path.of( + sttFileProperties.getConvert() + mergedFileName.replace(".webm", ".mp4"))); + } catch (IOException e) { + log.warn("Failed to delete temp file: {}", mergedFileName, e); + } + }); + } + @Async public CompletableFuture callNaverClovaAsync(Map headers, String originFileName, SpeechToTextReq request) { @@ -332,7 +408,7 @@ public SelectAnalysedTextRes selectAnalysedText(String counselSessionId) { JsonNode taResult = Optional.ofNullable(aiCounselSummary.getTaResult()) .orElseThrow(NoContentException::new); - String taResultText = taResult.get("result").get("output").get("text").asText(); + String taResultText = aiResponseParseUtil.extractAnalysedText(taResult); return new SelectAnalysedTextRes(taResultText); } diff --git a/src/main/java/com/springboot/api/counselsession/service/CounselSessionService.java b/src/main/java/com/springboot/api/counselsession/service/CounselSessionService.java index 25a30729..5d837ef6 100644 --- a/src/main/java/com/springboot/api/counselsession/service/CounselSessionService.java +++ b/src/main/java/com/springboot/api/counselsession/service/CounselSessionService.java @@ -1,8 +1,26 @@ package com.springboot.api.counselsession.service; +import java.time.Duration; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + import com.springboot.api.common.dto.PageReq; import com.springboot.api.common.dto.PageRes; import com.springboot.api.common.exception.NoContentException; +import com.springboot.api.common.util.AiResponseParseUtil; import com.springboot.api.common.util.DateTimeUtil; import com.springboot.api.counselcard.service.CounselCardService; import com.springboot.api.counselee.entity.Counselee; @@ -19,29 +37,22 @@ import com.springboot.api.counselsession.dto.counselsession.SearchCounselSessionReq; import com.springboot.api.counselsession.dto.counselsession.SelectCounselSessionListItem; import com.springboot.api.counselsession.dto.counselsession.SelectCounselSessionRes; +import com.springboot.api.counselsession.dto.counselsession.SelectPreviousCounselSessionDetailRes; import com.springboot.api.counselsession.dto.counselsession.SelectPreviousCounselSessionListRes; import com.springboot.api.counselsession.dto.counselsession.UpdateCounselorInCounselSessionReq; import com.springboot.api.counselsession.dto.counselsession.UpdateCounselorInCounselSessionRes; import com.springboot.api.counselsession.dto.counselsession.UpdateStatusInCounselSessionReq; import com.springboot.api.counselsession.dto.counselsession.UpdateStatusInCounselSessionRes; +import com.springboot.api.counselsession.entity.AICounselSummary; import com.springboot.api.counselsession.entity.CounselSession; +import com.springboot.api.counselsession.entity.MedicationCounsel; +import com.springboot.api.counselsession.repository.AICounselSummaryRepository; import com.springboot.api.counselsession.repository.CounselSessionRepository; +import com.springboot.api.counselsession.repository.MedicationCounselRepository; import com.springboot.enums.ScheduleStatus; -import java.time.Duration; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; + import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.cache.annotation.CacheEvict; -import org.springframework.cache.annotation.Cacheable; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; @Service @Slf4j @@ -54,6 +65,9 @@ public class CounselSessionService { private final CounseleeRepository counseleeRepository; private final CounselCardService counselCardService; private final CounseleeConsentService counseleeConsentService; + private final MedicationCounselRepository medicationCounselRepository; + private final AICounselSummaryRepository aiCounselSummaryRepository; + private final AiResponseParseUtil aiResponseParseUtil; @CacheEvict(value = {"sessionDates", "sessionStats", "sessionList"}, allEntries = true) @Transactional @@ -210,6 +224,75 @@ public List selectPreviousCounselSessionLis } + @Transactional(readOnly = true) + public PageRes selectPreviousCounselSessionDetailList( + String counselSessionId, PageReq pageReq) { + + CounselSession counselSession = counselSessionRepository.findById(counselSessionId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 상담 세션입니다.")); + + Counselee counselee = Optional.ofNullable(counselSession.getCounselee()) + .orElseThrow(() -> new NoContentException("내담자 정보를 찾을 수 없습니다.")); + + // 이전 완료된 상담 세션들을 최신 순으로 조회 + List previousCounselSessions = counselSessionRepository + .findPreviousCompletedSessionsOrderByEndDateTimeDesc(counselee.getId(), + counselSession.getScheduledStartDateTime()); + + if (previousCounselSessions.isEmpty()) { + throw new NoContentException("이전 상담 내역이 없습니다."); + } + + // 페이징 처리 + int totalElements = previousCounselSessions.size(); + int startIndex = pageReq.getPage() * pageReq.getSize(); + int endIndex = Math.min(startIndex + pageReq.getSize(), totalElements); + + if (startIndex >= totalElements) { + throw new NoContentException("요청한 페이지에 데이터가 없습니다."); + } + + List pagedSessions = previousCounselSessions.subList(startIndex, endIndex); + + // 각 세션에 대한 상세 정보 조회 + List detailList = pagedSessions.stream() + .map(session -> { + // 중재기록 조회 + Optional medicationCounsel = medicationCounselRepository + .findByCounselSessionId(session.getId()); + String counselRecord = medicationCounsel + .map(MedicationCounsel::getCounselRecord) + .orElse(null); + + // AI 요약 조회 + Optional aiCounselSummary = aiCounselSummaryRepository + .findByCounselSessionId(session.getId()); + String aiSummaryText = aiCounselSummary + .map(AICounselSummary::getTaResult) + .flatMap(aiResponseParseUtil::extractAnalysedTextSafely) + .orElse(null); + + String counselorName = Optional.ofNullable(session.getCounselor()) + .map(Counselor::getName) + .orElse("미지정"); + + return SelectPreviousCounselSessionDetailRes.builder() + .counselSessionId(session.getId()) + .counselSessionDate(session.getScheduledStartDateTime().toLocalDate()) + .sessionNumber(session.getSessionNumber()) + .counselorName(counselorName) + .medicationCounselRecord(counselRecord) + .aiSummary(aiSummaryText) + .build(); + }) + .toList(); + + Page page = new PageImpl<>( + detailList, pageReq.toPageable(), totalElements); + + return new PageRes<>(page); + } + @Cacheable(value = "sessionDates", key = "#year + '-' + #month") public List getSessionDatesByYearAndMonth(int year, int month) { return counselSessionRepository.findDistinctDatesByYearAndMonth(year, month); @@ -267,11 +350,12 @@ public void cancelOverdueSessions() { @Transactional(readOnly = true) public PageRes searchCounselSessions(SearchCounselSessionReq req) { PageRes counselSessionPageRes = counselSessionRepository - .findByCounseleeNameAndCounselorNameAndScheduledDateTime( + .findByCounseleeNameAndCounselorNameAndScheduledDateTimeAndStatus( req.pageReq(), req.counseleeNameKeyword(), req.counselorNames(), - req.scheduledDates() + req.scheduledDates(), + req.statuses() ); return counselSessionPageRes.map(SelectCounselSessionRes::from); diff --git a/src/main/java/com/springboot/api/counselsession/service/CounseleeConsentService.java b/src/main/java/com/springboot/api/counselsession/service/CounseleeConsentService.java index 4b2f7289..5e15af2a 100644 --- a/src/main/java/com/springboot/api/counselsession/service/CounseleeConsentService.java +++ b/src/main/java/com/springboot/api/counselsession/service/CounseleeConsentService.java @@ -1,23 +1,22 @@ package com.springboot.api.counselsession.service; +import java.util.Optional; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + import com.springboot.api.common.exception.NoContentException; import com.springboot.api.counselee.entity.Counselee; -import com.springboot.api.counselee.repository.CounseleeRepository; -import com.springboot.api.counselsession.dto.counseleeconsent.AddCounseleeConsentReq; -import com.springboot.api.counselsession.dto.counseleeconsent.AddCounseleeConsentRes; +import com.springboot.api.counselsession.dto.counseleeconsent.AcceptConsentRes; import com.springboot.api.counselsession.dto.counseleeconsent.DeleteCounseleeConsentRes; import com.springboot.api.counselsession.dto.counseleeconsent.SelectCounseleeConsentByCounseleeIdRes; -import com.springboot.api.counselsession.dto.counseleeconsent.UpdateCounseleeConsentReq; -import com.springboot.api.counselsession.dto.counseleeconsent.UpdateCounseleeConsentRes; import com.springboot.api.counselsession.entity.CounselSession; import com.springboot.api.counselsession.entity.CounseleeConsent; import com.springboot.api.counselsession.repository.CounselSessionRepository; import com.springboot.api.counselsession.repository.CounseleeConsentRepository; -import java.util.Optional; + import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor @@ -25,7 +24,6 @@ public class CounseleeConsentService { private final CounseleeConsentRepository counseleeConsentRepository; private final CounselSessionRepository counselSessionRepository; - private final CounseleeRepository counseleeRepository; public SelectCounseleeConsentByCounseleeIdRes selectCounseleeConsentByCounseleeId(String counselSessionId, String counseleeId) { @@ -53,33 +51,6 @@ public SelectCounseleeConsentByCounseleeIdRes selectCounseleeConsentByCounseleeI } - @Transactional - public AddCounseleeConsentRes addCounseleeConsent(AddCounseleeConsentReq addCounseleeConsentReq) { - - CounselSession counselSession = counselSessionRepository - .findById(addCounseleeConsentReq.getCounselSessionId()) - .orElseThrow(IllegalArgumentException::new); - - Counselee counselee = counseleeRepository.findById(addCounseleeConsentReq.getCounseleeId()) - .orElseThrow(IllegalArgumentException::new); - - CounseleeConsent counseleeConsent = CounseleeConsent.create(counselSession, counselee); - CounseleeConsent savedCounselConsent = counseleeConsentRepository.save(counseleeConsent); - - return new AddCounseleeConsentRes(savedCounselConsent.getId()); - } - - @Transactional - public UpdateCounseleeConsentRes updateCounseleeConsent(UpdateCounseleeConsentReq updateCounseleeConsentReq) { - CounseleeConsent counseleeConsent = counseleeConsentRepository - .findById(updateCounseleeConsentReq.getCounseleeConsentId()) - .orElseThrow(IllegalArgumentException::new); - - counseleeConsent.accept(); - - return new UpdateCounseleeConsentRes(counseleeConsent.getId()); - } - @Transactional(propagation = Propagation.REQUIRED) public void initializeCounseleeConsent(CounselSession counselSession, Counselee counselee) { if (counseleeConsentRepository.existsByCounselSessionIdAndCounseleeId(counselSession.getId(), @@ -92,23 +63,23 @@ public void initializeCounseleeConsent(CounselSession counselSession, Counselee } @Transactional - public UpdateCounseleeConsentRes acceptCounseleeConsent(String counseleeConsentId) { + public AcceptConsentRes acceptCounseleeConsent(String counselSessionId) { CounseleeConsent counseleeConsent = counseleeConsentRepository - .findById(counseleeConsentId) + .findByCounselSessionId(counselSessionId) .orElseThrow(IllegalArgumentException::new); counseleeConsent.accept(); - return new UpdateCounseleeConsentRes(counseleeConsent.getId()); + return new AcceptConsentRes(counseleeConsent.getId()); } @Transactional - public DeleteCounseleeConsentRes deleteCounseleeConsent(String counseleeConsentId) { + public DeleteCounseleeConsentRes deleteCounseleeConsent(String counselSessionId) { - CounseleeConsent counseleeConsent = counseleeConsentRepository.findById(counseleeConsentId) + CounseleeConsent counseleeConsent = counseleeConsentRepository.findByCounselSessionId(counselSessionId) .orElseThrow(IllegalArgumentException::new); - counseleeConsentRepository.deleteById(counseleeConsentId); + counseleeConsentRepository.deleteById(counseleeConsent.getId()); return new DeleteCounseleeConsentRes(counseleeConsent.getId()); } diff --git a/src/main/java/com/springboot/api/tus/config/TusConstant.java b/src/main/java/com/springboot/api/tus/config/TusConstant.java deleted file mode 100644 index c528fa6a..00000000 --- a/src/main/java/com/springboot/api/tus/config/TusConstant.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.springboot.api.tus.config; - -public interface TusConstant { - - String TUS_RESUMABLE_HEADER = "Tus-Resumable"; - String TUS_RESUMABLE_VALUE = "1.0.0"; - - String TUS_VERSION_HEADER = "Tus-Version"; - String TUS_VERSION_VALUE = "1.0.0,0.2.2,0.2.1"; - - String TUS_EXTENSION_HEADER = "Tus-Extension"; - - String UPLOAD_OFFSET_HEADER = "Upload-Offset"; - - String UPLOAD_LENGTH_HEADER = "Upload-Length"; - - String UPLOAD_DEFER_LENGTH_HEADER = "Upload-Defer-Length"; - - String UPLOAD_METADATA = "Upload-Metadata"; - - String LOCATION_HEADER = "Location"; - - String ACCESS_CONTROL_ALLOW_ORIGIN_HEADER = "Access-Control-Allow-Origin"; - String ACCESS_CONTROL_ALLOW_ORIGIN_VALUE = "*"; - - String ACCESS_CONTROL_ALLOW_METHODS_HEADER = "Access-Control-Allow-Methods"; - String ACCESS_CONTROL_ALLOW_METHODS_VALUE = "GET,PUT,PATCH,POST,DELETE"; - - String ACCESS_CONTROL_EXPOSE_HEADER = "Access-Control-Expose-Headers"; - String ACCESS_CONTROL_EXPOSE_OPTIONS_VALUE = "Tus-Resumable, Tus-Version, Tus-Max-Size, Tus-Extension"; - String ACCESS_CONTROL_EXPOSE_POST_VALUE = "Location, Tus-Resumable"; - - String CACHE_CONTROL_HEADER = "Cache-Control"; - String CACHE_CONTROL_VALUE = "no-store"; - - String URL_PREFIX = "/v1/tus"; - - String OFFSET_OCTET_STREAM = "application/offset+octet-stream"; - - String AUDIO_WEBM = "audio/webm"; -} diff --git a/src/main/java/com/springboot/api/tus/config/TusHeaderKeys.java b/src/main/java/com/springboot/api/tus/config/TusHeaderKeys.java new file mode 100644 index 00000000..84c37be3 --- /dev/null +++ b/src/main/java/com/springboot/api/tus/config/TusHeaderKeys.java @@ -0,0 +1,46 @@ +package com.springboot.api.tus.config; + +public final class TusHeaderKeys { + private TusHeaderKeys() { // 인스턴스화 방지 + } + + // --- Header Keys --- + public static final String TUS_RESUMABLE = "Tus-Resumable"; + public static final String TUS_VERSION = "Tus-Version"; + public static final String TUS_EXTENSION = "Tus-Extension"; + public static final String UPLOAD_OFFSET = "Upload-Offset"; + public static final String UPLOAD_LENGTH = "Upload-Length"; + public static final String UPLOAD_DEFER_LENGTH = "Upload-Defer-Length"; + public static final String UPLOAD_METADATA = "Upload-Metadata"; + public static final String LOCATION = "Location"; + public static final String CACHE_CONTROL = "Cache-Control"; + public static final String CONTENT_TYPE = "Content-Type"; // General Content-Type key + public static final String ACCESS_CONTROL_ALLOW_ORIGIN = "Access-Control-Allow-Origin"; + public static final String ACCESS_CONTROL_ALLOW_METHODS = "Access-Control-Allow-Methods"; + public static final String ACCESS_CONTROL_EXPOSE_HEADERS = "Access-Control-Expose-Headers"; + public static final String X_RECORDING_DURATION = "X-Recording-Duration"; + + // --- Common Header Values --- + // Tus-Resumable Default Value + public static final String TUS_RESUMABLE_VALUE = "1.0.0"; + // Tus-Version Supported Values + public static final String TUS_VERSION_VALUE = "1.0.0,0.2.2,0.2.1"; + // Tus-Extension Supported Values + public static final String TUS_EXTENSION_VALUE = "creation,expiration,termination,concatenation"; + // Access-Control-Allow-Origin Default Value + public static final String ACCESS_CONTROL_ALLOW_ORIGIN_VALUE = "*"; + // Access-Control-Allow-Methods Default Value + public static final String ACCESS_CONTROL_ALLOW_METHODS_VALUE = "GET,PUT,PATCH,POST,DELETE"; + // Access-Control-Expose-Headers Values for specific scenarios + public static final String ACCESS_CONTROL_EXPOSE_OPTIONS_VALUE = "Tus-Resumable,Tus-Version,Tus-Max-Size,Tus-Extension"; + public static final String ACCESS_CONTROL_EXPOSE_POST_VALUE = "Location,Tus-Resumable"; + // Cache-Control Default Value + public static final String CACHE_CONTROL_VALUE = "no-store"; + + // --- Specific Content-Type Values --- + public static final String CONTENT_TYPE_OFFSET_OCTET_STREAM = "application/offset+octet-stream"; + public static final String CONTENT_TYPE_AUDIO_WEBM = "audio/webm"; + + // --- Other TUS Related Constants --- + public static final String API_URL_PREFIX = "/v1/tus"; +} \ No newline at end of file diff --git a/src/main/java/com/springboot/api/tus/config/TusProperties.java b/src/main/java/com/springboot/api/tus/config/TusProperties.java index 8cbb4084..ec76a0b1 100644 --- a/src/main/java/com/springboot/api/tus/config/TusProperties.java +++ b/src/main/java/com/springboot/api/tus/config/TusProperties.java @@ -1,17 +1,16 @@ package com.springboot.api.tus.config; -import lombok.Getter; -import lombok.Setter; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; +import lombok.Data; + @Component @ConfigurationProperties(prefix = "tus") -@Getter -@Setter +@Data public class TusProperties { - - String uploadPath; - String extension; - String mergePath; + private String uploadPath; + private String extension; + private String mergePath; + private String pathPrefix; } diff --git a/src/main/java/com/springboot/api/tus/controller/TusController.java b/src/main/java/com/springboot/api/tus/controller/TusController.java index 2e05110d..358217f7 100644 --- a/src/main/java/com/springboot/api/tus/controller/TusController.java +++ b/src/main/java/com/springboot/api/tus/controller/TusController.java @@ -1,32 +1,28 @@ package com.springboot.api.tus.controller; -import static com.springboot.api.tus.config.TusConstant.ACCESS_CONTROL_ALLOW_METHODS_HEADER; -import static com.springboot.api.tus.config.TusConstant.ACCESS_CONTROL_ALLOW_METHODS_VALUE; -import static com.springboot.api.tus.config.TusConstant.ACCESS_CONTROL_ALLOW_ORIGIN_HEADER; -import static com.springboot.api.tus.config.TusConstant.ACCESS_CONTROL_ALLOW_ORIGIN_VALUE; -import static com.springboot.api.tus.config.TusConstant.ACCESS_CONTROL_EXPOSE_HEADER; -import static com.springboot.api.tus.config.TusConstant.ACCESS_CONTROL_EXPOSE_OPTIONS_VALUE; -import static com.springboot.api.tus.config.TusConstant.ACCESS_CONTROL_EXPOSE_POST_VALUE; -import static com.springboot.api.tus.config.TusConstant.AUDIO_WEBM; -import static com.springboot.api.tus.config.TusConstant.CACHE_CONTROL_HEADER; -import static com.springboot.api.tus.config.TusConstant.CACHE_CONTROL_VALUE; -import static com.springboot.api.tus.config.TusConstant.LOCATION_HEADER; -import static com.springboot.api.tus.config.TusConstant.OFFSET_OCTET_STREAM; -import static com.springboot.api.tus.config.TusConstant.TUS_EXTENSION_HEADER; -import static com.springboot.api.tus.config.TusConstant.TUS_RESUMABLE_HEADER; -import static com.springboot.api.tus.config.TusConstant.TUS_RESUMABLE_VALUE; -import static com.springboot.api.tus.config.TusConstant.TUS_VERSION_HEADER; -import static com.springboot.api.tus.config.TusConstant.TUS_VERSION_VALUE; -import static com.springboot.api.tus.config.TusConstant.UPLOAD_DEFER_LENGTH_HEADER; -import static com.springboot.api.tus.config.TusConstant.UPLOAD_LENGTH_HEADER; -import static com.springboot.api.tus.config.TusConstant.UPLOAD_METADATA; -import static com.springboot.api.tus.config.TusConstant.UPLOAD_OFFSET_HEADER; -import static com.springboot.api.tus.config.TusConstant.URL_PREFIX; +import static com.springboot.api.tus.config.TusHeaderKeys.ACCESS_CONTROL_ALLOW_METHODS_VALUE; +import static com.springboot.api.tus.config.TusHeaderKeys.ACCESS_CONTROL_ALLOW_ORIGIN_VALUE; +import static com.springboot.api.tus.config.TusHeaderKeys.ACCESS_CONTROL_EXPOSE_OPTIONS_VALUE; +import static com.springboot.api.tus.config.TusHeaderKeys.ACCESS_CONTROL_EXPOSE_POST_VALUE; +import static com.springboot.api.tus.config.TusHeaderKeys.API_URL_PREFIX; +import static com.springboot.api.tus.config.TusHeaderKeys.CACHE_CONTROL_VALUE; +import static com.springboot.api.tus.config.TusHeaderKeys.CONTENT_TYPE_AUDIO_WEBM; +import static com.springboot.api.tus.config.TusHeaderKeys.CONTENT_TYPE_OFFSET_OCTET_STREAM; +import static com.springboot.api.tus.config.TusHeaderKeys.TUS_EXTENSION_VALUE; +import static com.springboot.api.tus.config.TusHeaderKeys.TUS_RESUMABLE_VALUE; +import static com.springboot.api.tus.config.TusHeaderKeys.TUS_VERSION_VALUE; import com.springboot.api.common.annotation.ApiController; +import com.springboot.api.tus.config.TusHeaderKeys; +import com.springboot.api.tus.config.TusProperties; import com.springboot.api.tus.dto.response.TusFileInfoRes; import com.springboot.api.tus.service.TusService; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.parameters.RequestBody; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.constraints.NotNull; import java.io.IOException; @@ -37,6 +33,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -45,89 +42,87 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; -@ApiController(name = "TusController", description = "Tus 프로토콜 구현 Controller", path = URL_PREFIX) +@ApiController(name = "TusController", description = "Tus 프로토콜 구현 Controller", path = API_URL_PREFIX) @RequiredArgsConstructor public class TusController { private final TusService tusService; + private final TusProperties tusProperties; @Operation(summary = "서버의 tus 업로드 지원 버전 및 확장 정보를 반환합니다.", tags = {"TUS"}) @RequestMapping(method = RequestMethod.OPTIONS) public ResponseEntity processOptions() { return ResponseEntity.noContent() - .header(ACCESS_CONTROL_ALLOW_ORIGIN_HEADER, ACCESS_CONTROL_ALLOW_ORIGIN_VALUE) - .header(ACCESS_CONTROL_EXPOSE_HEADER, ACCESS_CONTROL_EXPOSE_OPTIONS_VALUE) - .header(TUS_RESUMABLE_HEADER, TUS_RESUMABLE_VALUE) - .header(TUS_VERSION_HEADER, TUS_VERSION_VALUE) - .header(TUS_EXTENSION_HEADER, TUS_EXTENSION_HEADER) - .header(ACCESS_CONTROL_ALLOW_METHODS_HEADER, ACCESS_CONTROL_ALLOW_METHODS_VALUE) + .header(TusHeaderKeys.ACCESS_CONTROL_ALLOW_ORIGIN, ACCESS_CONTROL_ALLOW_ORIGIN_VALUE) + .header(TusHeaderKeys.ACCESS_CONTROL_EXPOSE_HEADERS, ACCESS_CONTROL_EXPOSE_OPTIONS_VALUE) + .header(TusHeaderKeys.TUS_RESUMABLE, TUS_RESUMABLE_VALUE) + .header(TusHeaderKeys.TUS_VERSION, TUS_VERSION_VALUE) + .header(TusHeaderKeys.TUS_EXTENSION, TUS_EXTENSION_VALUE) + .header(TusHeaderKeys.ACCESS_CONTROL_ALLOW_METHODS, ACCESS_CONTROL_ALLOW_METHODS_VALUE) .build(); } - @Operation(summary = "새로운 tus 업로드 리소스를 생성합니다.", tags = {"TUS"}) + @Operation(summary = "새로운 tus 업로드 리소스를 생성합니다. X-Recording-Duration 헤더로 녹음 길이(초)를 전달할 수 있습니다.", tags = {"TUS"}) + @Parameter(name = TusHeaderKeys.UPLOAD_METADATA, description = "업로드 메타데이터", required = true, in = ParameterIn.HEADER) + @Parameter(name = TusHeaderKeys.UPLOAD_DEFER_LENGTH, description = "업로드 크기 지연 여부 (1이면 true)", required = true, in = ParameterIn.HEADER) @PostMapping public ResponseEntity startUpload( - @NotNull @RequestHeader(name = UPLOAD_METADATA) final String metadata, - @RequestHeader(name = UPLOAD_LENGTH_HEADER, required = false) final Long contentLength, - @RequestHeader(name = UPLOAD_DEFER_LENGTH_HEADER, required = false) final Boolean isDefer + @NotNull @RequestHeader(name = TusHeaderKeys.UPLOAD_METADATA) final String metadata, + @RequestHeader(name = TusHeaderKeys.UPLOAD_DEFER_LENGTH) final Boolean isDefer ) { - String fileId = tusService.initUpload(metadata, contentLength, isDefer); + String location = tusService.initUpload(metadata); return ResponseEntity.status(HttpStatus.CREATED) - .header(ACCESS_CONTROL_EXPOSE_HEADER, ACCESS_CONTROL_EXPOSE_POST_VALUE) - .header(LOCATION_HEADER, URL_PREFIX + "/" + fileId) - .header(TUS_RESUMABLE_HEADER, TUS_RESUMABLE_VALUE) + .header(TusHeaderKeys.ACCESS_CONTROL_EXPOSE_HEADERS, ACCESS_CONTROL_EXPOSE_POST_VALUE) + .header(TusHeaderKeys.LOCATION, location) + .header(TusHeaderKeys.TUS_RESUMABLE, TUS_RESUMABLE_VALUE) .build(); } - @Operation(summary = "지정된 업로드 리소스의 현재 업로드 오프셋과 길이를 조회합니다.", tags = {"TUS"}) - @RequestMapping(method = RequestMethod.HEAD, value = "/{fileId}") - public ResponseEntity getUploadStatus(@PathVariable final String fileId) { - TusFileInfoRes tusFileInfo = tusService.getTusFileInfo(fileId); + @Operation(summary = "지정된 업로드 리소스의 현재 업로드 오프셋, 길이 및 녹음 길이를 조회합니다.", tags = {"TUS"}) + @RequestMapping(method = RequestMethod.HEAD, value = "/{tusFileId}") + public ResponseEntity getUploadStatus(@PathVariable final String tusFileId) { + TusFileInfoRes tusFileInfo = tusService.getTusFileInfo(tusFileId); - if (Boolean.TRUE.equals(tusFileInfo.getIsDefer())) { - return ResponseEntity.noContent() - .header(LOCATION_HEADER, tusFileInfo.getLocation()) - .header(CACHE_CONTROL_HEADER, CACHE_CONTROL_VALUE) - .header(UPLOAD_DEFER_LENGTH_HEADER, "1") - .header(UPLOAD_OFFSET_HEADER, String.valueOf(tusFileInfo.getContentOffset())) - .header(TUS_RESUMABLE_HEADER, TUS_RESUMABLE_VALUE) - .build(); - } return ResponseEntity.noContent() - .header(LOCATION_HEADER, tusFileInfo.getLocation()) - .header(CACHE_CONTROL_HEADER, CACHE_CONTROL_VALUE) - .header(UPLOAD_LENGTH_HEADER, String.valueOf(tusFileInfo.getContentLength())) - .header(UPLOAD_OFFSET_HEADER, String.valueOf(tusFileInfo.getContentOffset())) - .header(TUS_RESUMABLE_HEADER, TUS_RESUMABLE_VALUE) + .header(TusHeaderKeys.LOCATION, tusFileInfo.getLocation()) + .header(TusHeaderKeys.CACHE_CONTROL, CACHE_CONTROL_VALUE) + .header(TusHeaderKeys.UPLOAD_DEFER_LENGTH, "1") + .header(TusHeaderKeys.UPLOAD_OFFSET, String.valueOf(tusFileInfo.getContentOffset())) + .header(TusHeaderKeys.TUS_RESUMABLE, TUS_RESUMABLE_VALUE) + .header(TusHeaderKeys.X_RECORDING_DURATION, String.valueOf(tusFileInfo.getDuration())) .build(); } - @Operation(summary = "업로드 리소스에 데이터를 이어서 전송하고 오프셋을 갱신합니다.", tags = {"TUS"}) - @PatchMapping(value = "/{fileId}", consumes = {OFFSET_OCTET_STREAM}) + @Operation(summary = "업로드 리소스에 데이터를 이어서 전송하고 오프셋을 갱신합니다. X-Recording-Duration 헤더로 현재까지의 녹음 길이(초)를 전달할 수 있습니다.", tags = { + "TUS"}) + @Parameter(name = TusHeaderKeys.UPLOAD_OFFSET, description = "현재 파일 오프셋", required = true, in = ParameterIn.HEADER) + @Parameter(name = TusHeaderKeys.X_RECORDING_DURATION, description = "현재까지의 녹음 길이 (초 단위)", required = false, in = ParameterIn.HEADER) + @RequestBody(content = @Content(mediaType = CONTENT_TYPE_OFFSET_OCTET_STREAM, schema = @Schema(type = "string", format = "binary"))) + @PatchMapping(value = "/{fileId}", consumes = {CONTENT_TYPE_OFFSET_OCTET_STREAM}) public ResponseEntity uploadProcess( @NonNull @PathVariable("fileId") final String fileId, @NonNull final HttpServletRequest request, - @RequestHeader(name = UPLOAD_OFFSET_HEADER) final long offset + @RequestHeader(name = TusHeaderKeys.UPLOAD_OFFSET) final long offset, + @RequestHeader(name = TusHeaderKeys.X_RECORDING_DURATION, required = false) final Long duration ) { try { - Long nextOffset = tusService.appendData(fileId, offset, request.getInputStream()); + Long nextOffset = tusService.appendData(fileId, offset, request.getInputStream(), duration); return ResponseEntity.noContent() - .header(UPLOAD_OFFSET_HEADER, String.valueOf(nextOffset)) - .header(TUS_RESUMABLE_HEADER, TUS_RESUMABLE_VALUE) + .header(TusHeaderKeys.UPLOAD_OFFSET, String.valueOf(nextOffset)) + .header(TusHeaderKeys.TUS_RESUMABLE, TUS_RESUMABLE_VALUE) .build(); } catch (IOException e) { return ResponseEntity.status(HttpStatus.CONFLICT).build(); } } - @Operation(summary = "업로드한 상담세션 녹음 파일을 병합합니다.", tags = {"TUS"}) + @Operation(summary = "업로드한 상담세션 녹음 파일을 병합합니다. 테스트용 API 입니다.", tags = {"TUS"}) @GetMapping(value = "/merge/{counselSessionId}") public ResponseEntity mergeMediaFile( @PathVariable("counselSessionId") final String counselSessionId ) { - tusService.mergeUploadedFile(counselSessionId); return ResponseEntity.ok().build(); } @@ -138,9 +133,18 @@ public ResponseEntity getMediaFile(@PathVariable("fileId") final Strin Resource uploadedFile = tusService.getUploadedFile(fileId); return ResponseEntity.ok() - .contentType(MediaType.parseMediaType(AUDIO_WEBM)) + .contentType(MediaType.parseMediaType(CONTENT_TYPE_AUDIO_WEBM)) .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + uploadedFile.getFilename() + "\"") .body(uploadedFile); } + + @Operation(summary = "업로드된 tus 파일을 삭제합니다.", tags = {"TUS"}) + @DeleteMapping(value = "/{counselSessionId}") + public ResponseEntity deleteUploadedFile(@PathVariable("counselSessionId") final String counselSessionId) { + tusService.deleteUploadedFile(counselSessionId); + return ResponseEntity.noContent() + .header(TusHeaderKeys.TUS_RESUMABLE, TUS_RESUMABLE_VALUE) + .build(); + } } diff --git a/src/main/java/com/springboot/api/tus/dto/response/TusFileInfoRes.java b/src/main/java/com/springboot/api/tus/dto/response/TusFileInfoRes.java index fd1d5434..eafa4762 100644 --- a/src/main/java/com/springboot/api/tus/dto/response/TusFileInfoRes.java +++ b/src/main/java/com/springboot/api/tus/dto/response/TusFileInfoRes.java @@ -1,22 +1,20 @@ package com.springboot.api.tus.dto.response; -import static com.springboot.api.tus.config.TusConstant.URL_PREFIX; - import com.springboot.api.tus.entity.TusFileInfo; import lombok.Getter; @Getter public class TusFileInfoRes { - private final String location; - private final Long contentLength; + private final String fileId; private final Long contentOffset; - private final Boolean isDefer; + private final String location; + private final Long duration; - public TusFileInfoRes(TusFileInfo tusFileInfo) { - this.location = URL_PREFIX + "/" + tusFileInfo.getCounselSession().getId() + "/" + tusFileInfo.getId(); - this.contentLength = tusFileInfo.getContentLength(); + public TusFileInfoRes(TusFileInfo tusFileInfo, String location) { + this.fileId = tusFileInfo.getId(); this.contentOffset = tusFileInfo.getContentOffset(); - this.isDefer = tusFileInfo.getIsDefer(); + this.location = location; + this.duration = tusFileInfo.getSessionRecord().getDuration(); } } diff --git a/src/main/java/com/springboot/api/tus/entity/SessionRecord.java b/src/main/java/com/springboot/api/tus/entity/SessionRecord.java new file mode 100644 index 00000000..40910f49 --- /dev/null +++ b/src/main/java/com/springboot/api/tus/entity/SessionRecord.java @@ -0,0 +1,53 @@ +package com.springboot.api.tus.entity; + +import com.springboot.api.common.entity.BaseEntity; +import com.springboot.api.counselsession.entity.CounselSession; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import java.nio.file.Path; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; + +@Entity +@Getter +@Table(name = "session_record") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class SessionRecord extends BaseEntity { + + @OneToOne(fetch = FetchType.LAZY) + @OnDelete(action = OnDeleteAction.CASCADE) + @JoinColumn(name = "counsel_session_id", nullable = false) + private CounselSession counselSession; + + private Long duration; + + private SessionRecord(CounselSession counselSession, Long duration) { + this.counselSession = counselSession; + this.duration = duration; + } + + public static SessionRecord of(CounselSession counselSession) { + return new SessionRecord(counselSession, 0L); + } + + @PrePersist + @Override + protected void onCreate() { + super.onCreate(); + } + + public void updateDuration(Long updatedDuration) { + this.duration = updatedDuration; + } + + public String getFolderPath(String uploadPath) { + return Path.of(uploadPath, this.getId()).toAbsolutePath().toString(); + } +} diff --git a/src/main/java/com/springboot/api/tus/entity/TusFileInfo.java b/src/main/java/com/springboot/api/tus/entity/TusFileInfo.java index e9dba884..578693ef 100644 --- a/src/main/java/com/springboot/api/tus/entity/TusFileInfo.java +++ b/src/main/java/com/springboot/api/tus/entity/TusFileInfo.java @@ -1,7 +1,6 @@ package com.springboot.api.tus.entity; import com.springboot.api.common.entity.BaseEntity; -import com.springboot.api.counselsession.entity.CounselSession; import de.huxhorn.sulky.ulid.ULID; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; @@ -11,7 +10,6 @@ import jakarta.persistence.Table; import java.nio.file.Path; import java.nio.file.Paths; -import java.util.Objects; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @@ -26,41 +24,21 @@ public class TusFileInfo extends BaseEntity { @ManyToOne(fetch = FetchType.LAZY) @OnDelete(action = OnDeleteAction.CASCADE) - @JoinColumn(name = "counsel_session_id", nullable = false) - private CounselSession counselSession; - - private Long contentLength; - - private Boolean isDefer; + @JoinColumn(name = "session_record_id", nullable = false) + private SessionRecord sessionRecord; private Long contentOffset; - private String originalName; - private String savedName; - private TusFileInfo(CounselSession counselSession, String originalName, Long contentLength, Boolean isDefer) { - this.counselSession = Objects.requireNonNull(counselSession); - this.originalName = Objects.requireNonNullElse(originalName, "NONE"); - this.contentLength = contentLength; - this.isDefer = isDefer; + private TusFileInfo(SessionRecord sessionRecord) { + this.sessionRecord = sessionRecord; this.contentOffset = 0L; this.savedName = new ULID().nextULID(); - - if (isDefer == null && contentLength == null) { - throw new IllegalArgumentException("isDefer, contentLength 둘 다 null일 수 없습니다."); - } - if (Boolean.FALSE.equals(isDefer)) { - throw new IllegalArgumentException("isDefer는 true 또는 null만 허용됩니다."); - } - if (isDefer != null && contentLength != null) { - throw new IllegalArgumentException("isDefer와 contentLength는 동시에 설정될 수 없습니다."); - } } - public static TusFileInfo of(CounselSession counselSession, String originalName, Long contentLength, - Boolean isDefer) { - return new TusFileInfo(counselSession, originalName, contentLength, isDefer); + public static TusFileInfo of(SessionRecord sessionRecord) { + return new TusFileInfo(sessionRecord); } @PrePersist @@ -69,11 +47,19 @@ protected void onCreate() { super.onCreate(); } - public void updateOffset(Long uploadLength) { + public void updateOffset(Integer uploadLength) { contentOffset += uploadLength; } public Path getFilePath(String uploadPath, String extension) { - return Paths.get(uploadPath, this.counselSession.getId(), this.savedName + extension); + return Paths.get(uploadPath, this.sessionRecord.getId(), this.savedName + extension); + } + + public String getLocation(String pathPrefix) { + return pathPrefix + "/" + this.getId(); + } + + public boolean isNotOffsetEqual(Long contentOffset) { + return !this.contentOffset.equals(contentOffset); } } diff --git a/src/main/java/com/springboot/api/tus/repository/SessionRecordRepository.java b/src/main/java/com/springboot/api/tus/repository/SessionRecordRepository.java new file mode 100644 index 00000000..e73d7fb5 --- /dev/null +++ b/src/main/java/com/springboot/api/tus/repository/SessionRecordRepository.java @@ -0,0 +1,10 @@ +package com.springboot.api.tus.repository; + +import com.springboot.api.tus.entity.SessionRecord; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface SessionRecordRepository extends JpaRepository { + + Optional findByCounselSessionId(String sessionId); +} diff --git a/src/main/java/com/springboot/api/tus/repository/TusFileInfoRepository.java b/src/main/java/com/springboot/api/tus/repository/TusFileInfoRepository.java index 123f13f1..8afe3c74 100644 --- a/src/main/java/com/springboot/api/tus/repository/TusFileInfoRepository.java +++ b/src/main/java/com/springboot/api/tus/repository/TusFileInfoRepository.java @@ -2,9 +2,14 @@ import com.springboot.api.tus.entity.TusFileInfo; import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; public interface TusFileInfoRepository extends JpaRepository { - List findAllByCounselSessionIdOrderByUpdatedDatetimeAsc(String counselSessionId); + @EntityGraph(attributePaths = "sessionRecord") + Optional findById(String id); + + List findAllBySessionRecordCounselSessionId(String counselSessionId); } diff --git a/src/main/java/com/springboot/api/tus/service/TusService.java b/src/main/java/com/springboot/api/tus/service/TusService.java index 43e6b26c..d4852b53 100644 --- a/src/main/java/com/springboot/api/tus/service/TusService.java +++ b/src/main/java/com/springboot/api/tus/service/TusService.java @@ -5,9 +5,12 @@ import com.springboot.api.counselsession.repository.CounselSessionRepository; import com.springboot.api.tus.config.TusProperties; import com.springboot.api.tus.dto.response.TusFileInfoRes; +import com.springboot.api.tus.entity.SessionRecord; import com.springboot.api.tus.entity.TusFileInfo; +import com.springboot.api.tus.repository.SessionRecordRepository; import com.springboot.api.tus.repository.TusFileInfoRepository; import io.micrometer.common.util.StringUtils; +import jakarta.persistence.EntityNotFoundException; import jakarta.servlet.ServletInputStream; import java.io.IOException; import java.io.OutputStream; @@ -17,75 +20,104 @@ import java.util.Arrays; import java.util.Base64; import java.util.List; -import java.util.Map; import java.util.Optional; -import java.util.stream.Collectors; import lombok.NonNull; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.core.io.Resource; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor +@Slf4j public class TusService { private final TusFileInfoRepository tusFileInfoRepository; private final CounselSessionRepository counselSessionRepository; + private final SessionRecordRepository sessionRecordRepository; private final TusProperties tusProperties; private final FileUtil fileUtil; @Transactional - public String initUpload(String metadata, Long contentLength, Boolean isDefer) { - - Map parsedMetadata = parseMetadata(metadata); - - CounselSession counselSession = counselSessionRepository.findById(parsedMetadata.get("counselSessionId")) - .orElseThrow(() -> new IllegalArgumentException("상담 세션을 찾을 수 없습니다.")); + public String initUpload(String metadata) { + String counselSessionId = extractCounselSessionId(metadata); + CounselSession counselSession = getCounselSession(counselSessionId); + SessionRecord sessionRecord = getOrCreateSessionRecord(counselSessionId, counselSession); + TusFileInfo fileInfo = createAndSaveFile(sessionRecord); + createUploadFile(fileInfo); + return fileInfo.getLocation(tusProperties.getPathPrefix()); + } - TusFileInfo fileInfo = TusFileInfo.of(counselSession, parsedMetadata.get("filename"), contentLength, isDefer); + private String extractCounselSessionId(@NonNull String metadata) { + return Arrays.stream(Optional.of(metadata).filter(StringUtils::isNotBlank) + .orElseThrow(() -> new IllegalArgumentException("metadata 가 없습니다.")) + .split(",")) + .map(kv -> { + String[] parts = kv.split(" ", 2); + if (parts.length != 2) { + throw new IllegalArgumentException("메타데이터 형식이 올바르지 않습니다."); + } + return new String[]{parts[0], parts[1]}; + }) + .filter(pair -> "counselSessionId".equals(pair[0])) + .findFirst() + .map(pair -> new String(Base64.getDecoder().decode(pair[1]))) + .orElseThrow(() -> new IllegalArgumentException("counselSessionId 가 메타데이터에 없습니다.")); + } - tusFileInfoRepository.save(fileInfo); + private CounselSession getCounselSession(String counselSessionId) { + return counselSessionRepository.findById(counselSessionId) + .orElseThrow(() -> new EntityNotFoundException("상담 세션을 찾을 수 없습니다. " + counselSessionId)); + } - fileUtil.createUploadFile(fileInfo.getFilePath(tusProperties.getUploadPath(), tusProperties.getExtension())); + private SessionRecord getOrCreateSessionRecord(String counselSessionId, CounselSession counselSession) { + return sessionRecordRepository.findByCounselSessionId(counselSessionId) + .orElseGet(() -> sessionRecordRepository.save(SessionRecord.of(counselSession))); + } - return fileInfo.getId(); + private TusFileInfo createAndSaveFile(SessionRecord sessionRecord) { + TusFileInfo fileInfo = TusFileInfo.of(sessionRecord); + return tusFileInfoRepository.save(fileInfo); } - private Map parseMetadata(@NonNull String metadata) { - return Arrays.stream(Optional.of(metadata).filter(StringUtils::isNotBlank) - .orElseThrow(() -> new RuntimeException("metadata 가 없습니다.")) - .split(",")) - .map(keyAndValue -> keyAndValue.split(" ")) - .collect( - Collectors.toMap(values -> values[0], values -> new String(Base64.getDecoder().decode(values[1])))); + private void createUploadFile(TusFileInfo fileInfo) { + Path filePath = fileInfo.getFilePath(tusProperties.getUploadPath(), tusProperties.getExtension()); + fileUtil.createUploadFile(filePath); } @Transactional(readOnly = true) public TusFileInfoRes getTusFileInfo(String fileId) { - TusFileInfo fileInfo = tusFileInfoRepository.findById(fileId) - .orElseThrow(() -> new IllegalArgumentException("Tus 파일 정보를 찾을 수 없습니다.")); + TusFileInfo fileInfo = getFileInfo(fileId); + String location = fileInfo.getLocation(tusProperties.getPathPrefix()); + return new TusFileInfoRes(fileInfo, location); + } - return new TusFileInfoRes(fileInfo); + private TusFileInfo getFileInfo(String fileId) { + return tusFileInfoRepository.findById(fileId) + .orElseThrow(() -> new EntityNotFoundException("Tus 파일 정보를 찾을 수 없습니다.")); } @Transactional - public Long appendData(String fileId, long offset, ServletInputStream inputStream) { - TusFileInfo fileInfo = tusFileInfoRepository.findById(fileId) - .orElseThrow(() -> new IllegalArgumentException("Tus 파일 정보을 찾을 수 없습니다.")); + public Long appendData(String fileId, long offset, ServletInputStream inputStream, Long duration) { + TusFileInfo fileInfo = getFileInfo(fileId); - if (fileInfo.getContentOffset() != offset) { + if (fileInfo.isNotOffsetEqual(offset)) { throw new IllegalArgumentException("Offset 정보가 맞지 않습니다."); } + if (duration != null) { + fileInfo.getSessionRecord().updateDuration(duration); + } + Path path = fileInfo.getFilePath(tusProperties.getUploadPath(), tusProperties.getExtension()); try (OutputStream outputStream = Files.newOutputStream(path, StandardOpenOption.APPEND)) { byte[] buffer = new byte[8192]; - long read; - while ((read = inputStream.read(buffer)) != -1) { - outputStream.write(buffer, 0, (int) read); - fileInfo.updateOffset(read); + int bytesRead; + while ((bytesRead = inputStream.read(buffer)) != -1) { + outputStream.write(buffer, 0, bytesRead); + fileInfo.updateOffset(bytesRead); } } catch (IOException e) { throw new RuntimeException("Tus 파일 업로드에 실패했습니다."); @@ -94,28 +126,38 @@ public Long appendData(String fileId, long offset, ServletInputStream inputStrea return fileInfo.getContentOffset(); } - @Transactional(readOnly = true) + @Transactional public void mergeUploadedFile(String counselSessionId) { - List tusFileInfoList = tusFileInfoRepository.findAllByCounselSessionIdOrderByUpdatedDatetimeAsc( + List tusFileInfoList = tusFileInfoRepository.findAllBySessionRecordCounselSessionId( counselSessionId); List pathList = tusFileInfoList.stream() .map(tusFileInfo -> tusFileInfo.getFilePath(tusProperties.getUploadPath(), tusProperties.getExtension())) - .map(Path::toAbsolutePath) - .map(Path::toString) - .toList(); + .map(Path::toAbsolutePath).map(Path::toString).toList(); + + String mergePath = Path.of(tusProperties.getMergePath(), counselSessionId + ".mp4").toAbsolutePath().toString(); - fileUtil.mergeWebmFile(pathList, tusProperties.getMergePath()); + fileUtil.mergeWebmFile(pathList, mergePath); } @Transactional(readOnly = true) public Resource getUploadedFile(String fileId) { - TusFileInfo fileInfo = tusFileInfoRepository.findById(fileId) - .orElseThrow(() -> new IllegalArgumentException("Tus 파일 정보을 찾을 수 없습니다.")); + TusFileInfo fileInfo = getFileInfo(fileId); Path path = fileInfo.getFilePath(tusProperties.getUploadPath(), tusProperties.getExtension()); return fileUtil.getUrlResource(path); } + + @Transactional + public void deleteUploadedFile(String counselSessionId) { + SessionRecord sessionRecord = sessionRecordRepository.findByCounselSessionId(counselSessionId) + .orElseThrow(() -> new EntityNotFoundException("Tus 녹음 정보를 찾을 수 없습니다.")); + + String folderPath = sessionRecord.getFolderPath(tusProperties.getUploadPath()); + + fileUtil.deleteDirectory(folderPath); + sessionRecordRepository.delete(sessionRecord); + } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 59dad49a..3d9d4273 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -83,7 +83,8 @@ ffmpeg: tus: uploadPath: /data/tus/upload/ extension: ".webm" - mergePath: /data/tus/merge/merge.mp4 + mergePath: /data/stt/audio/origin/ + pathPrefix: /api/v1/tus logging: level: diff --git a/src/test/java/com/springboot/api/common/util/AiResponseParseUtilTest.java b/src/test/java/com/springboot/api/common/util/AiResponseParseUtilTest.java new file mode 100644 index 00000000..b9ae4214 --- /dev/null +++ b/src/test/java/com/springboot/api/common/util/AiResponseParseUtilTest.java @@ -0,0 +1,140 @@ +package com.springboot.api.common.util; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.springboot.api.common.exception.NoContentException; + +class AiResponseParseUtilTest { + + private AiResponseParseUtil aiResponseParseUtil; + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + aiResponseParseUtil = new AiResponseParseUtil(); + objectMapper = new ObjectMapper(); + } + + @Test + @DisplayName("정상적인 AI 응답에서 텍스트를 추출할 수 있다") + void extractAnalysedText_ValidResponse_Success() throws Exception { + // given + String jsonResponse = """ + { + "result": { + "output": { + "text": "분석된 상담 내용입니다." + } + } + } + """; + JsonNode taResult = objectMapper.readTree(jsonResponse); + + // when + String result = aiResponseParseUtil.extractAnalysedText(taResult); + + // then + assertThat(result).isEqualTo("분석된 상담 내용입니다."); + } + + @Test + @DisplayName("null JsonNode에서는 NoContentException이 발생한다") + void extractAnalysedText_NullNode_ThrowsException() { + // when & then + assertThatThrownBy(() -> aiResponseParseUtil.extractAnalysedText(null)) + .isInstanceOf(NoContentException.class); + } + + @Test + @DisplayName("잘못된 구조의 JsonNode에서는 NoContentException이 발생한다") + void extractAnalysedText_InvalidStructure_ThrowsException() throws Exception { + // given + String jsonResponse = """ + { + "invalid": { + "structure": "test" + } + } + """; + JsonNode taResult = objectMapper.readTree(jsonResponse); + + // when & then + assertThatThrownBy(() -> aiResponseParseUtil.extractAnalysedText(taResult)) + .isInstanceOf(NoContentException.class); + } + + @Test + @DisplayName("안전한 추출에서는 예외 대신 Optional.empty()를 반환한다") + void extractAnalysedTextSafely_InvalidStructure_ReturnsEmpty() throws Exception { + // given + String jsonResponse = """ + { + "invalid": "structure" + } + """; + JsonNode taResult = objectMapper.readTree(jsonResponse); + + // when + Optional result = aiResponseParseUtil.extractAnalysedTextSafely(taResult); + + // then + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("빈 텍스트는 유효하지 않은 응답으로 처리된다") + void extractAnalysedText_EmptyText_ThrowsException() throws Exception { + // given + String jsonResponse = """ + { + "result": { + "output": { + "text": " " + } + } + } + """; + JsonNode taResult = objectMapper.readTree(jsonResponse); + + // when & then + assertThatThrownBy(() -> aiResponseParseUtil.extractAnalysedText(taResult)) + .isInstanceOf(NoContentException.class); + } + + @Test + @DisplayName("유효한 AI 응답인지 검증할 수 있다") + void isValidAiResponse_ValidResponse_ReturnsTrue() throws Exception { + // given + String jsonResponse = """ + { + "result": { + "output": { + "text": "유효한 텍스트" + } + } + } + """; + JsonNode taResult = objectMapper.readTree(jsonResponse); + + // when + boolean result = aiResponseParseUtil.isValidAiResponse(taResult); + + // then + assertThat(result).isTrue(); + } + + @Test + @DisplayName("유효하지 않은 AI 응답은 false를 반환한다") + void isValidAiResponse_InvalidResponse_ReturnsFalse() { + // when & then + assertThat(aiResponseParseUtil.isValidAiResponse(null)).isFalse(); + } +} \ No newline at end of file diff --git a/src/test/java/com/springboot/api/controller/CounseleeConsentControllerTest.java b/src/test/java/com/springboot/api/controller/CounseleeConsentControllerTest.java index 81757231..8d20f3dd 100644 --- a/src/test/java/com/springboot/api/controller/CounseleeConsentControllerTest.java +++ b/src/test/java/com/springboot/api/controller/CounseleeConsentControllerTest.java @@ -1,34 +1,32 @@ package com.springboot.api.controller; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.*; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; import java.util.Collection; import java.util.Collections; + import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.context.annotation.Import; -import org.springframework.http.MediaType; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.jwt.JwtDecoder; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; -import com.fasterxml.jackson.databind.ObjectMapper; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + import com.springboot.api.common.config.security.SecurityConfig; import com.springboot.api.common.converter.CustomJwtRoleConverter; import com.springboot.api.config.TestSecurityConfig; import com.springboot.api.counselsession.controller.CounseleeConsentController; -import com.springboot.api.counselsession.dto.counseleeconsent.AddCounseleeConsentReq; -import com.springboot.api.counselsession.dto.counseleeconsent.AddCounseleeConsentRes; import com.springboot.api.counselsession.dto.counseleeconsent.DeleteCounseleeConsentRes; import com.springboot.api.counselsession.dto.counseleeconsent.SelectCounseleeConsentByCounseleeIdRes; -import com.springboot.api.counselsession.dto.counseleeconsent.UpdateCounseleeConsentReq; -import com.springboot.api.counselsession.dto.counseleeconsent.UpdateCounseleeConsentRes; import com.springboot.api.counselsession.service.CounseleeConsentService; @WebMvcTest(CounseleeConsentController.class) @@ -155,84 +153,9 @@ public void testSelectConsent_WithoutToken_Failure() throws Exception { .andExpect(status().isUnauthorized()); } - @Test - public void testAddConsent_WithAdminRole_Success() throws Exception { - // Given - Collection authorities = Collections.singletonList( - new SimpleGrantedAuthority("ROLE_ADMIN")); - when(customJwtRoleConverter.convert(jwt)).thenReturn(authorities); - - AddCounseleeConsentReq request = AddCounseleeConsentReq.builder() - .counselSessionId(VALID_COUNSEL_SESSION_ID) - .counseleeId(VALID_COUNSELEE_ID) - .isConsent(true) - .build(); - - AddCounseleeConsentRes mockResponse = AddCounseleeConsentRes.builder() - .counseleeConsentId(VALID_COUNSELEE_ID) - .build(); - - when(counseleeConsentService.addCounseleeConsent(any(AddCounseleeConsentReq.class))) - .thenReturn(mockResponse); - - // When & Then - mockMvc.perform(post("/v1/counselee/consent") - .contentType(MediaType.APPLICATION_JSON) - .content(new ObjectMapper().writeValueAsString(request)) - .header("Authorization", "Bearer token")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.counseleeConsentId").value(VALID_COUNSELEE_ID)); - } - - @Test - public void testAddConsent_WithInvalidRole_Failure() throws Exception { - // Given - Collection authorities = Collections.singletonList( - new SimpleGrantedAuthority("ROLE_NONE")); - when(customJwtRoleConverter.convert(jwt)).thenReturn(authorities); - - AddCounseleeConsentReq request = AddCounseleeConsentReq.builder() - .counselSessionId(VALID_COUNSEL_SESSION_ID) - .counseleeId(VALID_COUNSELEE_ID) - .isConsent(true) - .build(); - - // When & Then - mockMvc.perform(post("/v1/counselee/consent") - .contentType(MediaType.APPLICATION_JSON) - .content(new ObjectMapper().writeValueAsString(request)) - .header("Authorization", "Bearer token")) - .andExpect(status().isForbidden()); - } - - @Test - public void testUpdateConsent_WithUserRole_Success() throws Exception { - // Given - Collection authorities = Collections.singletonList( - new SimpleGrantedAuthority("ROLE_USER")); - when(customJwtRoleConverter.convert(jwt)).thenReturn(authorities); - UpdateCounseleeConsentReq request = UpdateCounseleeConsentReq.builder() - .counseleeConsentId(VALID_COUNSELEE_ID) - .isConsent(false) - .build(); - - UpdateCounseleeConsentRes mockResponse = UpdateCounseleeConsentRes.builder() - .updatedCounseleeConsentId(VALID_COUNSELEE_CONSENT_ID) - .build(); - when(counseleeConsentService.updateCounseleeConsent(any(UpdateCounseleeConsentReq.class))) - .thenReturn(mockResponse); - // When & Then - mockMvc.perform(put("/v1/counselee/consent") - .contentType(MediaType.APPLICATION_JSON) - .content(new ObjectMapper().writeValueAsString(request)) - .header("Authorization", "Bearer token")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.updatedCounseleeConsentId") - .value(VALID_COUNSELEE_CONSENT_ID)); - } @Test public void testDeleteConsent_WithAssistantRole_Success() throws Exception { diff --git a/src/test/java/com/springboot/api/controller/CounseleeControllerTest.java b/src/test/java/com/springboot/api/controller/CounseleeControllerTest.java index 11c10692..cb56fcb1 100644 --- a/src/test/java/com/springboot/api/controller/CounseleeControllerTest.java +++ b/src/test/java/com/springboot/api/controller/CounseleeControllerTest.java @@ -1,15 +1,17 @@ package com.springboot.api.controller; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.*; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; import java.time.LocalDate; import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.List; + import org.junit.jupiter.api.Test; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.when; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.context.annotation.Import; @@ -23,7 +25,14 @@ import org.springframework.security.oauth2.jwt.JwtDecoder; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; import org.springframework.test.web.servlet.result.MockMvcResultHandlers; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + import com.fasterxml.jackson.databind.ObjectMapper; import com.springboot.api.common.config.security.SecurityConfig; import com.springboot.api.common.converter.CustomJwtRoleConverter; @@ -95,7 +104,7 @@ public void testUpdateCounseleeSuccess() throws Exception { Collection authorities = Collections.singletonList( new SimpleGrantedAuthority("ROLE_ADMIN")); mockJwtToken(authorities); - String requestBody = "{ \"counseleeId\": \"01HQ7YXHG8ZYXM5T2Q3X4KDJPJ\", \"name\": \"John Doe\", \"phoneNumber\": \"010-1234-5678\", \"dateOfBirth\": \"1990-01-01\" , \"genderType\": \"MALE\"}"; + String requestBody = "{ \"counseleeId\": \"01HQ7YXHG8ZYXM5T2Q3X4KDJPJ\", \"name\": \"John Doe\", \"phoneNumber\": \"010-1234-5678\", \"dateOfBirth\": \"1990-01-01\" , \"genderType\": \"MALE\", \"healthInsuranceType\": \"HEALTH_INSURANCE\"}"; when(counseleeService.updateCounselee(any(UpdateCounseleeReq.class))).thenReturn("Success"); diff --git a/src/test/java/com/springboot/api/service/CounseleeServiceTest.java b/src/test/java/com/springboot/api/service/CounseleeServiceTest.java index b247b770..e4d10a17 100644 --- a/src/test/java/com/springboot/api/service/CounseleeServiceTest.java +++ b/src/test/java/com/springboot/api/service/CounseleeServiceTest.java @@ -1,10 +1,22 @@ package com.springboot.api.service; +import java.time.LocalDate; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.Random; + import static org.assertj.core.api.AssertionsForClassTypes.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; import static org.mockito.BDDMockito.given; +import org.mockito.InjectMocks; +import org.mockito.Mock; import static org.mockito.Mockito.when; +import org.mockito.junit.jupiter.MockitoExtension; import com.navercorp.fixturemonkey.FixtureMonkey; import com.springboot.api.counselee.dto.SelectCounseleeAutocompleteRes; @@ -14,17 +26,7 @@ import com.springboot.api.counselee.service.CounseleeService; import com.springboot.api.fixture.CounseleeFixture; import com.springboot.enums.GenderType; -import java.time.LocalDate; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import java.util.Optional; -import java.util.Random; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; +import com.springboot.enums.HealthInsuranceType; @ExtendWith(MockitoExtension.class) public class CounseleeServiceTest { @@ -51,6 +53,7 @@ public void updateCounselee_all() { .address("서울시 강남구") .isDisability(true) .careManagerName("김철수") + .healthInsuranceType(HealthInsuranceType.MEDICAL_AID) .build(); //When @@ -66,6 +69,7 @@ public void updateCounselee_all() { assertThat(original.getAddress()).isEqualTo(request.getAddress()); assertThat(original.getIsDisability()).isEqualTo(request.getIsDisability()); assertThat(original.getCareManagerName()).isEqualTo(request.getCareManagerName()); + assertThat(original.getHealthInsuranceType()).isEqualTo(request.getHealthInsuranceType()); } @Test @@ -82,6 +86,7 @@ public void updateCounselee_none() { String note = original.getNote(); String careManagerName = original.getCareManagerName(); String affiliatedWelfareInstitution = original.getAffiliatedWelfareInstitution(); + HealthInsuranceType healthInsuranceType = original.getHealthInsuranceType(); UpdateCounseleeReq updateCounseleeReq = UpdateCounseleeReq.builder().build(); //When @@ -96,6 +101,7 @@ public void updateCounselee_none() { assertThat(original.getNote()).isEqualTo(note); assertThat(original.getCareManagerName()).isEqualTo(careManagerName); assertThat(original.getAffiliatedWelfareInstitution()).isEqualTo(affiliatedWelfareInstitution); + assertThat(original.getHealthInsuranceType()).isEqualTo(healthInsuranceType); } @Test