Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -29,19 +29,21 @@ public enum StudyStatusResult {

public record MissionResult(
Long missionId,
Long childId,
Long categoryId,
String rewardTitle,
LocalDate startDate,
LocalDate endDate
) {
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);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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로 카테고리 정보 조회
Expand All @@ -63,7 +66,7 @@ public class GetHomeDashboardService implements GetHomeDashboardUseCase {
// - 여기서는 사용자가 했는지 여부보다 "오늘 해야 할 콘텐츠가 무엇인지"가 핵심
//
// 5) 캘린더 상태 계산을 위해 StudyRecord 목록 조회
// - 조건: (memberId, missionId, categoryId)
// - 조건: (childId, missionId, categoryId) // 부모가 요청해도 자녀 기록 조회
// - StudyRecord는 dailyContentId + quizProgressStatus를 통해 "학습 상태"를 결정하는 근거
//
// 6) records를 dailyContentId 기준으로 요약(Map)한다.
Expand All @@ -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;

Expand All @@ -105,36 +109,58 @@ 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(
mission.categoryId(),
elapsedDays
);

log.info("[DailyContent 조회 완료] - dailyContentId: {}, contentKeyword: {}",
dailyContent.dailyContentId(),
dailyContent.contentKeyword()
);

// 5. 캘린더 상태 계산을 위해 StudyRecord 목록 조회
List<StudyRecord> records = repository.findByMemberIdAndMissionIdAndCategoryId(
memberId,
mission.childId(),
mission.missionId(),
mission.categoryId()
);
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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<StudyRecord> existing = studyRecordPersistencePort.findByMemberIdAndDailyContentId(memberId,
dailyContentId);
Expand Down Expand Up @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -187,15 +187,16 @@ 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;
newsUnlocked = true;
return;
}

// 퀴즈 1번이라도 제출하면 날짜 기록
this.quizSubmittedDate = LocalDate.now();
// FAIL인 경우
if (attempt.getAttemptNo().isFirst()) {
// 1차 FAIL → 2차 기회 열어줌
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -21,6 +23,7 @@
public class HomeDashboardMissionReadAdapter implements HomeDashboardMissionReadPort {

private final MissionJpaRepository repository;
private final FamilyRelationJpaRepository familyRelationJpaRepository;

// 가장 최신의 활성 미션 조회
@Override
Expand All @@ -31,6 +34,7 @@ public Optional<MissionResult> 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(),
Expand All @@ -47,13 +51,21 @@ public Optional<MissionResult> 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(),
mission.getPeriod().getEndDate()
));
}

// 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<MissionId> findActiveMissionsByMemberId(Long memberId) {
return repository.findLatestActive(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -78,7 +79,9 @@ public ResponseEntity<DataResponse<StartStudyResult>> 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));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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;
}
Expand Down Expand Up @@ -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;

}
Expand Down