diff --git a/oneco/src/main/java/com/oneco/backend/StudyRecord/application/port/dto/result/HomeDashboardResult.java b/oneco/src/main/java/com/oneco/backend/StudyRecord/application/port/dto/result/HomeDashboardResult.java index 1b34040..0c779aa 100644 --- a/oneco/src/main/java/com/oneco/backend/StudyRecord/application/port/dto/result/HomeDashboardResult.java +++ b/oneco/src/main/java/com/oneco/backend/StudyRecord/application/port/dto/result/HomeDashboardResult.java @@ -29,6 +29,7 @@ public enum StudyStatusResult { public record MissionResult( Long missionId, + Long childId, Long categoryId, String rewardTitle, LocalDate startDate, @@ -36,12 +37,13 @@ public record MissionResult( ) { public static MissionResult of( Long missionId, + Long childId, Long categoryId, String rewardTitle, LocalDate startDate, LocalDate endDate ) { - return new MissionResult(missionId, categoryId, rewardTitle, startDate, endDate); + return new MissionResult(missionId, childId, categoryId, rewardTitle, startDate, endDate); } } diff --git a/oneco/src/main/java/com/oneco/backend/StudyRecord/application/port/in/StartStudyUseCase.java b/oneco/src/main/java/com/oneco/backend/StudyRecord/application/port/in/StartStudyUseCase.java index 5c47a60..4ec53e6 100644 --- a/oneco/src/main/java/com/oneco/backend/StudyRecord/application/port/in/StartStudyUseCase.java +++ b/oneco/src/main/java/com/oneco/backend/StudyRecord/application/port/in/StartStudyUseCase.java @@ -2,7 +2,8 @@ import com.oneco.backend.StudyRecord.application.dto.command.StartStudyCommand; import com.oneco.backend.StudyRecord.application.dto.result.StartStudyResult; +import com.oneco.backend.member.domain.FamilyRole; public interface StartStudyUseCase { - StartStudyResult start(StartStudyCommand command, Long memberId); + StartStudyResult start(StartStudyCommand command, Long memberId, FamilyRole familyRole); } diff --git a/oneco/src/main/java/com/oneco/backend/StudyRecord/application/service/GetHomeDashboardService.java b/oneco/src/main/java/com/oneco/backend/StudyRecord/application/service/GetHomeDashboardService.java index af2fcad..92cffc3 100644 --- a/oneco/src/main/java/com/oneco/backend/StudyRecord/application/service/GetHomeDashboardService.java +++ b/oneco/src/main/java/com/oneco/backend/StudyRecord/application/service/GetHomeDashboardService.java @@ -43,12 +43,15 @@ public class GetHomeDashboardService implements GetHomeDashboardUseCase { // // 목표: // - 홈 화면에서 "대시보드" 구성을 위한 데이터를 한 번에 조회하여 반환한다. - // 1) 미션/카테고리(키워드) 정보 + // 1) 미션의 기본 정보 + // 2) 카테고리(키워드) 정보 // 2) 오늘 학습해야 할 DailyContent 1건 - // 3) 캘린더 뷰(미션 기간의 평일 날짜별 조개 상태를 한 번에 내려준다. + // 3) 캘린더 뷰(미션 기간의 평일 날짜별 조개 상태를 한 번에 내려준다.) + // 4) 미션 진행률(%) 정보 (자녀의 학습 완료 일수 / 전체 학습일 수) + // 5) 오늘이 미션의 몇 번째 학습일인가 (elapsedDays) // // 전체 흐름: - // 1) memberId로 최신 진행중 미션 1건 조회 + // 1) memberId로 최신 진행중 미션 1건 조회 (MissionResult에 childId 포함) // - 없으면 홈 대시보드 구성이 불가하므로 예외 // // 2) 미션의 categoryId로 카테고리 정보 조회 @@ -63,7 +66,7 @@ public class GetHomeDashboardService implements GetHomeDashboardUseCase { // - 여기서는 사용자가 했는지 여부보다 "오늘 해야 할 콘텐츠가 무엇인지"가 핵심 // // 5) 캘린더 상태 계산을 위해 StudyRecord 목록 조회 - // - 조건: (memberId, missionId, categoryId) + // - 조건: (childId, missionId, categoryId) // 부모가 요청해도 자녀 기록 조회 // - StudyRecord는 dailyContentId + quizProgressStatus를 통해 "학습 상태"를 결정하는 근거 // // 6) records를 dailyContentId 기준으로 요약(Map)한다. @@ -90,6 +93,7 @@ public class GetHomeDashboardService implements GetHomeDashboardUseCase { @Override public HomeDashboardResult getHomeDashboard(Long memberId, Long missionId) { + log.info("[getHomeDashboard 시작] - (Long) memberId: {}, (Long) missionId: {}", memberId, missionId); MissionResult mission; @@ -105,26 +109,43 @@ public HomeDashboardResult getHomeDashboard(Long memberId, Long missionId) { .orElseThrow(() -> BaseException.from(MissionErrorCode.MISSION_NOT_FOUND)); } + log.info("[미션 조회 완료] - missionId: {}, childId: {}, categoryId: {}, rewardTitle: {}, startDate: {}, endDate: {}", + mission.missionId(), + mission.childId(), + mission.categoryId(), + mission.rewardTitle(), + mission.startDate(), + mission.endDate() + ); + + // 2. 미션의 categoryId로 카테고리 정보(CategoryResult) 조회 CategoryResult category = homeDashboardCategoryReadPort.findById(mission.categoryId()) .orElseThrow(() -> BaseException.from(CategoryErrorCode.INVALID_CATEGORY_ID, "Invalid categoryId: " + mission.categoryId())); - log.info("미션의 categoryId로 카테고리 정보(CategoryResult) 조회 완료. categoryId = {}, categoryTitle= {}", - category.categoryId(), category.categoryTitle()); + + log.info("[카테고리 조회 완료] - categoryId: {}, categoryTitle : {}", + category.categoryId(), + category.categoryTitle() + ); // 3. today 기준 "오늘이 미션의 몇 번째 학습일인가(daySequence)" 계산 LocalDate today = LocalDate.now(); - log.info("오늘 날짜 today = {}", today); // elapsedDays(시작한지 몇 번째 날인가) = 오늘에 해당하는 daySequence (1부터 시작) - log.info("미션 기간: startDate = {}, endDate = {}", mission.startDate(), mission.endDate()); + log.info("[elapsedDays 계산 시작] - startDate: {}, endDate: {}, today: {}", + mission.startDate(), + mission.endDate(), + today + ); int elapsedDays = MissionDateCalculator.openedDaySequenceExcludeWeekend( mission.startDate(), mission.endDate(), today ); - log.info("오늘이 미션의 몇번째 학습일인가(daySequence) 계산 완료. elapsedDays = {}", elapsedDays); + + log.info("[elapsedDays 계산 종료] - elapsedDays = {}", elapsedDays); // 4. (categoryId, elapsedDays)로 "오늘의 DailyContent" 1건 조회 DailyContentResult dailyContent = homeDashboardDailyContentReadPort.findByCategoryIdAndDaySequence( @@ -132,9 +153,14 @@ public HomeDashboardResult getHomeDashboard(Long memberId, Long missionId) { elapsedDays ); + log.info("[DailyContent 조회 완료] - dailyContentId: {}, contentKeyword: {}", + dailyContent.dailyContentId(), + dailyContent.contentKeyword() + ); + // 5. 캘린더 상태 계산을 위해 StudyRecord 목록 조회 List records = repository.findByMemberIdAndMissionIdAndCategoryId( - memberId, + mission.childId(), mission.missionId(), mission.categoryId() ); @@ -178,8 +204,9 @@ public HomeDashboardResult getHomeDashboard(Long memberId, Long missionId) { // progressPercentage 계산 // record에서 quizProgressStatus가 COMPLETED인 수 / 전체 학습일 수 long progressPercentage = calculateProgressPercentage(dailyContents, statusByDailyContentId); - log.info("진행률 계산 완료. progressPercentage = {}%", progressPercentage); + log.info("[진행률 계산 완료] - progressPercentage={}%", progressPercentage); + log.info("[getHomeDashboard 종료]"); return new HomeDashboardResult( mission, elapsedDays, diff --git a/oneco/src/main/java/com/oneco/backend/StudyRecord/application/service/StartStudyService.java b/oneco/src/main/java/com/oneco/backend/StudyRecord/application/service/StartStudyService.java index 5da4328..97fc4f5 100644 --- a/oneco/src/main/java/com/oneco/backend/StudyRecord/application/service/StartStudyService.java +++ b/oneco/src/main/java/com/oneco/backend/StudyRecord/application/service/StartStudyService.java @@ -18,6 +18,7 @@ import com.oneco.backend.category.domain.category.CategoryId; import com.oneco.backend.dailycontent.domain.dailycontent.DailyContentId; import com.oneco.backend.global.exception.BaseException; +import com.oneco.backend.member.domain.FamilyRole; import com.oneco.backend.member.domain.MemberId; import com.oneco.backend.mission.domain.mission.MissionId; @@ -35,13 +36,39 @@ public class StartStudyService implements StartStudyUseCase { @Override @Transactional - public StartStudyResult start(StartStudyCommand command, Long memberId) { + public StartStudyResult start(StartStudyCommand command, Long memberId, FamilyRole familyRole) { Long dailyContentId = command.dailyContentId(); // 1) DailyContent 로드 (본문/키워드/이미지/+ categoryId/daySequence 포함) DailyContentSnapshot dailyContent = dailyContentQueryPort.loadDailyContentSnapshot(dailyContentId); log.info("로드된 DailyContentSnapshot: {}", dailyContent); + // 부모면: 검증만 하고 StudyRecord 생성/조회/저장 없이 바로 반환 + if (familyRole == FamilyRole.PARENT) { + log.info("부모 요청: StudyRecord 생성 없이 열람 처리. memberId={}, dailyContentId={}", memberId, dailyContentId); + + ActiveMissionSnapshot activeMission = + missionQueryPort.findActiveMission(memberId, dailyContent.categoryId()) + .orElseThrow(() -> BaseException.from(StudyErrorCode.INVALID_STUDY_STATUS, "활성 미션이 없습니다.")); + log.info("로드된 ActiveMissionSnapshot: {}", activeMission); + + if (!activeMission.active()) { + throw BaseException.from(StudyErrorCode.INVALID_STUDY_STATUS, "미션이 active가 아닙니다."); + } + + if (dailyContent.daySequence() > activeMission.openedDaySequence()) { + throw BaseException.from( + StudyErrorCode.INVALID_STUDY_STATUS, + "콘텐츠가 아직 열리지 않았습니다. contentDay=" + dailyContent.daySequence() + + ", openedDay=" + activeMission.openedDaySequence() + ); + } + + // StudyRecord 관련 값은 null/기본값 처리 + return mapStartStudyResultForParent(dailyContent); + } + + // 자녀면: 기존 로직대로 진행 // 2) 이미 StudyRecord가 존재하면 그대로 반환 Optional existing = studyRecordPersistencePort.findByMemberIdAndDailyContentId(memberId, dailyContentId); @@ -95,6 +122,24 @@ public StartStudyResult start(StartStudyCommand command, Long memberId) { return mapStartStudyResult(saved, dailyContent); } + private StartStudyResult mapStartStudyResultForParent(DailyContentSnapshot dailyContent) { + return new StartStudyResult( + null, // studyRecordId 없음 + dailyContent.dailyContentId(), + dailyContent.categoryId(), + dailyContent.daySequence(), + null, // quizProgressStatus (빠른 처리: null) + false, // newsUnlocked 기본값 + new StartStudyResult.DailyContentCard( + dailyContent.title(), + dailyContent.bodyText(), + dailyContent.summary(), + dailyContent.keyword(), + dailyContent.imageUrl() + ) + ); + } + private StartStudyResult mapStartStudyResult(StudyRecord studyRecord, DailyContentSnapshot dailyContent) { return new StartStudyResult( studyRecord.getId(), diff --git a/oneco/src/main/java/com/oneco/backend/StudyRecord/domain/studyRecord/StudyRecord.java b/oneco/src/main/java/com/oneco/backend/StudyRecord/domain/studyRecord/StudyRecord.java index 2b8b73d..1d2cb6d 100644 --- a/oneco/src/main/java/com/oneco/backend/StudyRecord/domain/studyRecord/StudyRecord.java +++ b/oneco/src/main/java/com/oneco/backend/StudyRecord/domain/studyRecord/StudyRecord.java @@ -187,6 +187,9 @@ public void submitQuizAttempt(Long attemptId, // 자식 엔티티가 “제출 가능 상태/quizId mismatch/옵션 인덱스” 등 검증 + result 계산까지 수행 attempt.submit(answers, correctCount); + // 퀴즈 1번이라도 제출하면 날짜 기록 + this.quizSubmittedDate = LocalDate.now(); + // 루트가 “전체 진행 상태”를 업데이트 (총 2회 규칙 적용) if (attempt.getAttemptResult() == AttemptResult.PASS) { quizProgressStatus = QuizProgressStatus.PASSED; @@ -194,8 +197,6 @@ public void submitQuizAttempt(Long attemptId, return; } - // 퀴즈 1번이라도 제출하면 날짜 기록 - this.quizSubmittedDate = LocalDate.now(); // FAIL인 경우 if (attempt.getAttemptNo().isFirst()) { // 1차 FAIL → 2차 기회 열어줌 diff --git a/oneco/src/main/java/com/oneco/backend/StudyRecord/infrastructure/persistence/HomeDashboardMissionReadAdapter.java b/oneco/src/main/java/com/oneco/backend/StudyRecord/infrastructure/persistence/HomeDashboardMissionReadAdapter.java index 4ef680b..05623a0 100644 --- a/oneco/src/main/java/com/oneco/backend/StudyRecord/infrastructure/persistence/HomeDashboardMissionReadAdapter.java +++ b/oneco/src/main/java/com/oneco/backend/StudyRecord/infrastructure/persistence/HomeDashboardMissionReadAdapter.java @@ -8,11 +8,13 @@ import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Component; -import com.oneco.backend.StudyRecord.application.port.dto.result.HomeActiveMissionsResult; import com.oneco.backend.StudyRecord.application.port.out.HomeDashboardMissionReadPort; +import com.oneco.backend.family.domain.exception.constant.FamilyErrorCode; +import com.oneco.backend.family.infrastructure.persistence.FamilyRelationJpaRepository; import com.oneco.backend.mission.domain.mission.MissionId; import com.oneco.backend.mission.domain.mission.MissionStatus; import com.oneco.backend.mission.infrastructure.MissionJpaRepository; +import com.oneco.backend.global.exception.BaseException; import lombok.RequiredArgsConstructor; @@ -21,6 +23,7 @@ public class HomeDashboardMissionReadAdapter implements HomeDashboardMissionReadPort { private final MissionJpaRepository repository; + private final FamilyRelationJpaRepository familyRelationJpaRepository; // 가장 최신의 활성 미션 조회 @Override @@ -31,6 +34,7 @@ public Optional findLatestActiveMission(Long memberId) { PageRequest.of(0, 1) ).stream().findFirst().map(mission -> MissionResult.of( mission.getId(), + extractChildId(mission.getFamilyRelationId().getValue()), mission.getCategoryId().getValue(), mission.getReward().getTitle(), mission.getPeriod().getStartDate(), @@ -47,6 +51,7 @@ public Optional findActiveMissionById(Long memberId, Long mission MissionStatus.IN_PROGRESS ).map(mission -> MissionResult.of( mission.getId(), + extractChildId(mission.getFamilyRelationId().getValue()), mission.getCategoryId().getValue(), mission.getReward().getTitle(), mission.getPeriod().getStartDate(), @@ -54,6 +59,13 @@ public Optional findActiveMissionById(Long memberId, Long mission )); } + // FamilyRelation -> ChildId 추출한다. + private Long extractChildId(Long familyRelationId) { + return familyRelationJpaRepository.findById(familyRelationId) // DB 조회 1번 + .map(relation -> relation.getChildId().getValue()) + .orElseThrow(() -> BaseException.from(FamilyErrorCode.FAMILY_RELATION_NOT_FOUND)); + } + @Override public List findActiveMissionsByMemberId(Long memberId) { return repository.findLatestActive( diff --git a/oneco/src/main/java/com/oneco/backend/StudyRecord/presentation/StudyRecordController.java b/oneco/src/main/java/com/oneco/backend/StudyRecord/presentation/StudyRecordController.java index eda8d0d..2303061 100644 --- a/oneco/src/main/java/com/oneco/backend/StudyRecord/presentation/StudyRecordController.java +++ b/oneco/src/main/java/com/oneco/backend/StudyRecord/presentation/StudyRecordController.java @@ -22,6 +22,7 @@ import com.oneco.backend.StudyRecord.application.port.in.SubmitQuizSubmissionUseCase; import com.oneco.backend.global.response.DataResponse; import com.oneco.backend.global.security.jwt.JwtPrincipal; +import com.oneco.backend.member.domain.FamilyRole; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -78,7 +79,9 @@ public ResponseEntity> startStudy( ) @RequestBody @Valid StartStudyCommand command ) { - StartStudyResult result = startStudyUseCase.start(command, principal.memberId()); + String role = principal.familyRole(); + FamilyRole familyRole = FamilyRole.valueOf(role); + StartStudyResult result = startStudyUseCase.start(command, principal.memberId(), familyRole); return ResponseEntity.ok(DataResponse.from(result)); } diff --git a/oneco/src/main/java/com/oneco/backend/category/domain/exception/constant/CategoryErrorCode.java b/oneco/src/main/java/com/oneco/backend/category/domain/exception/constant/CategoryErrorCode.java index dbfb276..129673d 100644 --- a/oneco/src/main/java/com/oneco/backend/category/domain/exception/constant/CategoryErrorCode.java +++ b/oneco/src/main/java/com/oneco/backend/category/domain/exception/constant/CategoryErrorCode.java @@ -33,7 +33,8 @@ public enum CategoryErrorCode implements ErrorCode { "CATEGORY_400_007"), CATEGORY_MISSION_DAYS_OUT_OF_RANGE(HttpStatus.BAD_REQUEST, "정해진 일자에서 벗어났습니다.", "CATEGORY_400_008"), - INVALID_CATEGORY_ID(HttpStatus.BAD_REQUEST, "카테고리 ID가 유효하지 않습니다.","CATEGORY_ERROR_401_INVALID_CATEGORY_ID"); + INVALID_CATEGORY_ID(HttpStatus.BAD_REQUEST, "카테고리 ID가 유효하지 않습니다.", + "CATEGORY_ERROR_401_INVALID_CATEGORY_ID"); private final HttpStatus httpStatus; private final String message; diff --git a/oneco/src/main/java/com/oneco/backend/mission/domain/mission/MissionDateCalculator.java b/oneco/src/main/java/com/oneco/backend/mission/domain/mission/MissionDateCalculator.java index 1826478..75e209b 100644 --- a/oneco/src/main/java/com/oneco/backend/mission/domain/mission/MissionDateCalculator.java +++ b/oneco/src/main/java/com/oneco/backend/mission/domain/mission/MissionDateCalculator.java @@ -24,7 +24,7 @@ public static int openedDaySequenceExcludeWeekend( LocalDate today ) { - log.info("openedDaySequenceExcludeWeekend 호출 startDate={}, endDate={}, today={}", + log.info("[openedDaySequenceExcludeWeekend 시작] - startDate={}, endDate={}, today={}", startDate, endDate, today); if (today == null) { @@ -37,7 +37,7 @@ public static int openedDaySequenceExcludeWeekend( // 2) 미션 시작 전이면 진행 일수는 0이다. if (effectiveEnd.isBefore(startDate)) { - log.info("오늘 날짜가 미션 시작일 이전이므로 진행 일수는 0으로 처리합니다. startDate={}, effectiveEnd={}", + log.info("today가 startDate 과거이므로 진행 일수는 0으로 처리 startDate={}, effectiveEnd={}", startDate, effectiveEnd); return 0; } @@ -65,8 +65,8 @@ public static int openedDaySequenceExcludeWeekend( cursor = cursor.plusDays(1); } - log.info("평일 일수 weekdays={}", weekdays); - log.info("openedDaySequenceExcludeWeekend 호출 끝"); + log.info("평일 일수 weekdays: {}", weekdays); + log.info("[openedDaySequenceExcludeWeekend 종료]"); return (int)weekdays; }