From bc2807db7920e0e67209b2de3f09dfaa91a84ecb Mon Sep 17 00:00:00 2001 From: AWESOMEDEV Date: Mon, 20 Jul 2026 13:29:49 +0900 Subject: [PATCH] =?UTF-8?q?feat(mentor):=20=EC=BD=94=ED=98=B8=ED=8A=B8(?= =?UTF-8?q?=EA=B8=B0=EC=88=98)=20=EA=B4=80=EB=A6=AC=20+=20=EA=B8=B0?= =?UTF-8?q?=EC=88=98=20=EB=B6=84=EC=84=9D=20=EB=8C=80=EC=8B=9C=EB=B3=B4?= =?UTF-8?q?=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 멘토 화면의 '분석·기수관리'를 개인 카드 나열에서 '묶어서 본 그림'으로 끌어올림. 기수(코호트): - Flyway V2 마이그레이션으로 student_profile.cohort 추가(기존 학생 '2026' 백필) - 가입 시 현재 기수(app.current-cohort, .env로 매년 변경) 자동 배정 - 명단에 기수 컬럼 + 배지 클릭으로 변경(POST /mentor/students/{id}/cohort) - CSV 내보내기·StudentProfileResponse에 기수 포함 분석 대시보드(GET /mentor/analytics?cohort=): - MentorAnalyticsService: 코호트별 KPI(진도·퀴즈·코딩·리뷰대기), 학생별 마지막 활동 계산(4개 테이블 타임스탬프 max), 관심필요(7일+ 미활동), 어려운 지점(평균 낮은 퀴즈·통과율 낮은 코딩 top5) - MentorAnalytics 컴포넌트: 기수 선택·KPI 카드·관심필요·핫스팟, 슬러그→제목 해석 테스트: 백엔드 47→51(분석 4), 프론트 27→30(분석 3). 운영 검증: V2 빈DB→운영 무손실 적용(3명 백필), 분석 API·대시보드 브라우저 확인, 스모크 11/11. Co-Authored-By: Claude Opus 4.8 --- .../mirim/domain/StudentProfile.java | 20 +- .../repository/StudentProfileRepository.java | 3 + .../awesomedev/mirim/service/AuthService.java | 6 +- .../mirim/service/MentorAnalyticsService.java | 218 ++++++++++++++++++ .../mirim/service/StudentProfileService.java | 12 +- .../mirim/web/MentorController.java | 23 +- .../mirim/web/dto/CohortRequest.java | 15 ++ .../mirim/web/dto/StudentProfileResponse.java | 2 + backend/src/main/resources/application.yml | 5 + .../db/migration/V2__add_student_cohort.sql | 10 + .../service/MentorAnalyticsServiceTest.java | 180 +++++++++++++++ .../service/StudentProfileServiceTest.java | 4 +- .../mirim/web/SecurityContractTest.java | 2 + frontend/src/components/MentorAnalytics.jsx | 192 +++++++++++++++ .../src/components/MentorAnalytics.test.jsx | 57 +++++ frontend/src/pages/MentorPage.jsx | 30 +++ 16 files changed, 773 insertions(+), 6 deletions(-) create mode 100644 backend/src/main/java/dev/awesomedev/mirim/service/MentorAnalyticsService.java create mode 100644 backend/src/main/java/dev/awesomedev/mirim/web/dto/CohortRequest.java create mode 100644 backend/src/main/resources/db/migration/V2__add_student_cohort.sql create mode 100644 backend/src/test/java/dev/awesomedev/mirim/service/MentorAnalyticsServiceTest.java create mode 100644 frontend/src/components/MentorAnalytics.jsx create mode 100644 frontend/src/components/MentorAnalytics.test.jsx diff --git a/backend/src/main/java/dev/awesomedev/mirim/domain/StudentProfile.java b/backend/src/main/java/dev/awesomedev/mirim/domain/StudentProfile.java index 5923a43..5109a41 100644 --- a/backend/src/main/java/dev/awesomedev/mirim/domain/StudentProfile.java +++ b/backend/src/main/java/dev/awesomedev/mirim/domain/StudentProfile.java @@ -63,6 +63,14 @@ public class StudentProfile { @Column(nullable = false, length = 60) private String major; + /** + * 기수(코호트) — 예: "2026". 같은 시기에 들어온 수습생 묶음. + * 학습 포인트: 멘토가 "기수 단위"로 진도·분석을 볼 수 있게 하는 축이다. 해마다 새 기수가 + * 들어와도 같은 플랫폼에서 기수별로 나눠 관리한다. 값은 가입 시 앱이 현재 기수로 채운다. + */ + @Column(nullable = false, length = 20) + private String cohort; + /** 비상 연락처 — 이름 / 관계 / 전화번호 */ @Column(nullable = false, length = 30) private String emergencyName; @@ -88,7 +96,7 @@ public class StudentProfile { public StudentProfile(User user, String companyEmailLocal, LocalDate birthDate, String phone, String address, String school, String major, String emergencyName, String emergencyRelation, String emergencyPhone, - Instant privacyConsentAt) { + Instant privacyConsentAt, String cohort) { this.user = user; this.companyEmailLocal = companyEmailLocal; this.birthDate = birthDate; @@ -100,6 +108,12 @@ public class StudentProfile { this.emergencyRelation = emergencyRelation; this.emergencyPhone = emergencyPhone; this.privacyConsentAt = privacyConsentAt; + this.cohort = cohort; + } + + /** 멘토가 이 학생의 기수를 바꾼다(예: 잘못 배정됐거나 재편성). */ + public void changeCohort(String cohort) { + this.cohort = cohort; } /** @@ -161,6 +175,10 @@ public class StudentProfile { return major; } + public String getCohort() { + return cohort; + } + public String getEmergencyName() { return emergencyName; } diff --git a/backend/src/main/java/dev/awesomedev/mirim/repository/StudentProfileRepository.java b/backend/src/main/java/dev/awesomedev/mirim/repository/StudentProfileRepository.java index 4b2de4d..865d0aa 100644 --- a/backend/src/main/java/dev/awesomedev/mirim/repository/StudentProfileRepository.java +++ b/backend/src/main/java/dev/awesomedev/mirim/repository/StudentProfileRepository.java @@ -19,6 +19,9 @@ public interface StudentProfileRepository extends JpaRepository findByUser(User user); + /** 학생 user_id로 프로필 조회 (멘토의 기수 변경 등). user.id 중첩 프로퍼티로 쿼리 생성. */ + Optional findByUserId(Long userId); + /** * 전체 프로필을 User까지 함께 가져온다 (멘토 명단·CSV용). * diff --git a/backend/src/main/java/dev/awesomedev/mirim/service/AuthService.java b/backend/src/main/java/dev/awesomedev/mirim/service/AuthService.java index 2a84e08..c1a2b11 100644 --- a/backend/src/main/java/dev/awesomedev/mirim/service/AuthService.java +++ b/backend/src/main/java/dev/awesomedev/mirim/service/AuthService.java @@ -28,6 +28,10 @@ public class AuthService { private final PasswordEncoder passwordEncoder; private final TelegramNotifier telegramNotifier; + /** 가입하는 학생이 편입될 현재 기수(코호트). 운영에선 .env의 CURRENT_COHORT로 매년 바꾼다. */ + @org.springframework.beans.factory.annotation.Value("${app.current-cohort:2026}") + private String currentCohort; + public AuthService(UserRepository userRepository, StudentProfileRepository studentProfileRepository, PasswordEncoder passwordEncoder, @@ -104,7 +108,7 @@ public class AuthService { user, req.companyEmailLocal(), req.birthDate(), req.phone(), req.address(), req.school(), req.major(), req.emergencyName(), req.emergencyRelation(), req.emergencyPhone(), - java.time.Instant.now())); + java.time.Instant.now(), currentCohort)); // 멘토에게 텔레그램 알림. 실패해도 가입은 성공한다(TelegramNotifier가 예외를 삼킨다). telegramNotifier.send("🎓 새 수습생 가입\n" diff --git a/backend/src/main/java/dev/awesomedev/mirim/service/MentorAnalyticsService.java b/backend/src/main/java/dev/awesomedev/mirim/service/MentorAnalyticsService.java new file mode 100644 index 0000000..fe58014 --- /dev/null +++ b/backend/src/main/java/dev/awesomedev/mirim/service/MentorAnalyticsService.java @@ -0,0 +1,218 @@ +package dev.awesomedev.mirim.service; + +import dev.awesomedev.mirim.domain.CodingSubmission; +import dev.awesomedev.mirim.domain.CourseProgress; +import dev.awesomedev.mirim.domain.QuizResult; +import dev.awesomedev.mirim.domain.StudentProfile; +import dev.awesomedev.mirim.domain.Submission; +import dev.awesomedev.mirim.domain.User; +import dev.awesomedev.mirim.repository.ChecklistItemRepository; +import dev.awesomedev.mirim.repository.CodingProblemRepository; +import dev.awesomedev.mirim.repository.CodingSubmissionRepository; +import dev.awesomedev.mirim.repository.CourseProgressRepository; +import dev.awesomedev.mirim.repository.ProgressRepository; +import dev.awesomedev.mirim.repository.QuizResultRepository; +import dev.awesomedev.mirim.repository.StudentProfileRepository; +import dev.awesomedev.mirim.repository.SubmissionRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +/** + * 이 파일이 하는 일: + * 멘토용 "코호트(기수) 분석" — 학생 개개인 카드로는 안 보이는 "묶어서 본 그림"을 만든다. + * - 코호트별 KPI(진도·퀴즈·코딩·리뷰대기), 학생별 요약 + 마지막 활동/관심필요, + * - "어려운 지점"(평균 점수가 낮은 퀴즈·통과율이 낮은 코딩 문제) top 5. + * + * 학습 포인트 ① — "데이터의 주인"을 지킨다. + * 서버는 자기가 저장한 것(퀴즈 점수·코딩 통과·활동 시각)만 집계한다. "코스 총 몇 개"는 + * 프론트 카탈로그의 지식이라 여기서 %로 만들지 않고 coursesDone(원자료)만 준다 — 화면이 계산. + * + * 학습 포인트 ② — 지금 규모(학생 4명)에선 학생마다 조회(N+1)해도 충분히 빠르다. + * 수백 명이 되면 집계 쿼리(GROUP BY)로 바꾼다. "규모가 설계를 정한다." + */ +@Service +public class MentorAnalyticsService { + + /** 이 일수를 넘게 아무 활동이 없으면 '관심 필요'로 본다. */ + public static final int STALE_DAYS = 7; + + private final StudentProfileRepository studentProfileRepository; + private final QuizResultRepository quizResultRepository; + private final CodingSubmissionRepository codingSubmissionRepository; + private final CodingProblemRepository codingProblemRepository; + private final CourseProgressRepository courseProgressRepository; + private final ProgressRepository progressRepository; + private final ChecklistItemRepository checklistItemRepository; + private final SubmissionRepository submissionRepository; + private final Clock clock; + + public MentorAnalyticsService(StudentProfileRepository studentProfileRepository, + QuizResultRepository quizResultRepository, + CodingSubmissionRepository codingSubmissionRepository, + CodingProblemRepository codingProblemRepository, + CourseProgressRepository courseProgressRepository, + ProgressRepository progressRepository, + ChecklistItemRepository checklistItemRepository, + SubmissionRepository submissionRepository, + Clock clock) { + this.studentProfileRepository = studentProfileRepository; + this.quizResultRepository = quizResultRepository; + this.codingSubmissionRepository = codingSubmissionRepository; + this.codingProblemRepository = codingProblemRepository; + this.courseProgressRepository = courseProgressRepository; + this.progressRepository = progressRepository; + this.checklistItemRepository = checklistItemRepository; + this.submissionRepository = submissionRepository; + this.clock = clock; + } + + public record CohortCount(String cohort, int count) { + } + + /** 학생 한 명의 분석 요약 한 줄. lastActiveAt이 null이면 아직 아무 활동도 없다. */ + public record StudentRow(Long userId, String name, String track, String cohort, + int coursesDone, int quizAttempted, int quizAvgPct, + int codingPassed, int codingTotal, + int checklistDone, int checklistTotal, + String lastActiveAt, Long daysSinceActive) { + } + + public record QuizHotspot(String courseSlug, int avgScorePct, int attempts) { + } + + public record CodingHotspot(Long problemId, String title, int passRatePct, int attempts) { + } + + public record Analytics(String cohort, int studentCount, int pendingReviews, + List cohorts, List students, + List hardestQuizzes, List hardestCoding) { + } + + @Transactional(readOnly = true) + public Analytics analytics(String cohortFilter) { + List all = studentProfileRepository.findAllWithUser(); + + // 코호트 목록 + 인원수 (필터와 무관하게 전체 기준 — 화면의 기수 선택지가 된다) + Map counts = new TreeMap<>(); + for (StudentProfile p : all) { + counts.merge(p.getCohort(), 1, Integer::sum); + } + List cohorts = counts.entrySet().stream() + .map(e -> new CohortCount(e.getKey(), e.getValue())) + .toList(); + + boolean allCohorts = cohortFilter == null || cohortFilter.isBlank() || "all".equals(cohortFilter); + List scoped = allCohorts ? all + : all.stream().filter(p -> cohortFilter.equals(p.getCohort())).toList(); + + int codingTotal = (int) codingProblemRepository.count(); + int checklistTotal = (int) checklistItemRepository.count(); + Instant now = clock.instant(); + + // 핫스팟 누적기: 코스별 [점수합, 응시자수] / 문제별 [통과수, 시도자수] + Map quizAgg = new HashMap<>(); + Map codeAgg = new HashMap<>(); + Map codeTitle = new HashMap<>(); + int pendingReviews = 0; + + List rows = new ArrayList<>(); + for (StudentProfile p : scoped) { + User u = p.getUser(); + Instant last = null; + + // 퀴즈 — 코스별 최고 점수(%) + Map bestPct = new HashMap<>(); + for (QuizResult qr : quizResultRepository.findByUserOrderBySubmittedAtDesc(u)) { + int pct = qr.getTotal() > 0 ? qr.getScore() * 100 / qr.getTotal() : 0; + bestPct.merge(qr.getCourseSlug(), pct, Math::max); + last = later(last, qr.getSubmittedAt()); + } + int quizAttempted = bestPct.size(); + int quizAvgPct = quizAttempted == 0 ? 0 + : (int) Math.round(bestPct.values().stream().mapToInt(Integer::intValue).average().orElse(0)); + for (Map.Entry e : bestPct.entrySet()) { + int[] agg = quizAgg.computeIfAbsent(e.getKey(), k -> new int[2]); + agg[0] += e.getValue(); + agg[1] += 1; + } + + // 코딩 — 통과 수 + 문제별 통과율 누적 + int codingPassed = 0; + for (CodingSubmission cs : codingSubmissionRepository.findByUser(u)) { + boolean passed = "PASSED".equals(cs.getStatus()); + if (passed) { + codingPassed++; + } + Long pid = cs.getProblem().getId(); + int[] agg = codeAgg.computeIfAbsent(pid, k -> new int[2]); + if (passed) { + agg[0] += 1; + } + agg[1] += 1; + codeTitle.putIfAbsent(pid, cs.getProblem().getTitle()); + last = later(last, cs.getSubmittedAt()); + } + + // 코스 진도 (완료한 slug 수) — 총 코스 수는 프론트가 카탈로그로 안다 + List courses = courseProgressRepository.findByUser(u); + for (CourseProgress cp : courses) { + last = later(last, cp.getCompletedAt()); + } + + // 체크리스트 완료 수 + int checklistDone = progressRepository.findByUser(u).size(); + + // 과제 — 리뷰 대기(SUBMITTED) 집계 + 활동 시각 + for (Submission s : submissionRepository.findByUserOrderBySubmittedAtDesc(u)) { + if ("SUBMITTED".equals(s.getStatus())) { + pendingReviews++; + } + last = later(last, s.getSubmittedAt()); + } + + Long daysSince = last == null ? null : Duration.between(last, now).toDays(); + rows.add(new StudentRow(u.getId(), u.getName(), u.getTrack(), p.getCohort(), + courses.size(), quizAttempted, quizAvgPct, codingPassed, codingTotal, + checklistDone, checklistTotal, + last == null ? null : last.toString(), daysSince)); + } + + // 어려운 지점 = 평균이 낮은 순 top 5 (응시/시도가 있는 것만) + List hardestQuizzes = quizAgg.entrySet().stream() + .map(e -> new QuizHotspot(e.getKey(), e.getValue()[0] / e.getValue()[1], e.getValue()[1])) + .sorted(Comparator.comparingInt(QuizHotspot::avgScorePct)) + .limit(5) + .toList(); + List hardestCoding = codeAgg.entrySet().stream() + .map(e -> new CodingHotspot(e.getKey(), codeTitle.get(e.getKey()), + e.getValue()[0] * 100 / e.getValue()[1], e.getValue()[1])) + .sorted(Comparator.comparingInt(CodingHotspot::passRatePct)) + .limit(5) + .toList(); + + String label = allCohorts ? "전체" : cohortFilter; + return new Analytics(label, scoped.size(), pendingReviews, cohorts, rows, + hardestQuizzes, hardestCoding); + } + + /** 두 시각 중 나중 것(둘 중 하나가 null이면 나머지). */ + private static Instant later(Instant a, Instant b) { + if (a == null) { + return b; + } + if (b == null) { + return a; + } + return a.isAfter(b) ? a : b; + } +} diff --git a/backend/src/main/java/dev/awesomedev/mirim/service/StudentProfileService.java b/backend/src/main/java/dev/awesomedev/mirim/service/StudentProfileService.java index 3a2b76c..f1bdb0a 100644 --- a/backend/src/main/java/dev/awesomedev/mirim/service/StudentProfileService.java +++ b/backend/src/main/java/dev/awesomedev/mirim/service/StudentProfileService.java @@ -106,12 +106,13 @@ public class StudentProfileService { StringBuilder sb = new StringBuilder(""); // BOM // 헤더 - sb.append("이름,회사이메일,트랙,생년월일,연락처,주소,학교,전공,비상연락-이름,비상연락-관계,비상연락-전화,개인정보동의일,가입일\n"); + sb.append("이름,회사이메일,트랙,기수,생년월일,연락처,주소,학교,전공,비상연락-이름,비상연락-관계,비상연락-전화,개인정보동의일,가입일\n"); for (StudentProfile p : allProfiles()) { sb.append(csv(p.getUser().getName())).append(',') .append(csv(p.fullEmail())).append(',') .append(csv("DESIGN".equals(p.getUser().getTrack()) ? "디자인" : "개발")).append(',') + .append(csv(p.getCohort())).append(',') .append(csv(p.getBirthDate().toString())).append(',') .append(csv(p.getPhone())).append(',') .append(csv(p.getAddress())).append(',') @@ -151,4 +152,13 @@ public class StudentProfileService { } return "\"" + v.replace("\"", "\"\"") + "\""; } + + /** 멘토가 학생의 기수(코호트)를 바꾼다. */ + @Transactional + public void changeCohort(Long userId, String cohort) { + StudentProfile p = studentProfileRepository.findByUserId(userId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "학생을 찾을 수 없습니다.")); + p.changeCohort(cohort.trim()); + // @Transactional 안에서 managed 엔티티를 바꾸면 dirty checking으로 UPDATE가 나간다. + } } diff --git a/backend/src/main/java/dev/awesomedev/mirim/web/MentorController.java b/backend/src/main/java/dev/awesomedev/mirim/web/MentorController.java index e7a749b..c59f246 100644 --- a/backend/src/main/java/dev/awesomedev/mirim/web/MentorController.java +++ b/backend/src/main/java/dev/awesomedev/mirim/web/MentorController.java @@ -6,6 +6,7 @@ import dev.awesomedev.mirim.domain.Submission; import dev.awesomedev.mirim.repository.UserRepository; import dev.awesomedev.mirim.service.AuditService; import dev.awesomedev.mirim.service.AuthService; +import dev.awesomedev.mirim.service.MentorAnalyticsService; import dev.awesomedev.mirim.service.CodingService; import dev.awesomedev.mirim.service.CourseProgressService; import dev.awesomedev.mirim.service.ProgressService; @@ -15,6 +16,7 @@ import dev.awesomedev.mirim.service.StudentPurgeService; import dev.awesomedev.mirim.service.SubmissionService; import dev.awesomedev.mirim.web.dto.AuditLogResponse; import dev.awesomedev.mirim.web.dto.ChecklistProgressOverviewResponse; +import dev.awesomedev.mirim.web.dto.CohortRequest; import dev.awesomedev.mirim.web.dto.FeedbackRequest; import dev.awesomedev.mirim.web.dto.MentorResetPasswordRequest; import dev.awesomedev.mirim.web.dto.MentorSubmissionResponse; @@ -57,6 +59,7 @@ public class MentorController { private final StudentPurgeService studentPurgeService; private final UserRepository userRepository; private final AuditService auditService; + private final MentorAnalyticsService mentorAnalyticsService; public MentorController(SubmissionService submissionService, CourseProgressService courseProgressService, @@ -67,7 +70,8 @@ public class MentorController { StudentProfileService studentProfileService, StudentPurgeService studentPurgeService, UserRepository userRepository, - AuditService auditService) { + AuditService auditService, + MentorAnalyticsService mentorAnalyticsService) { this.submissionService = submissionService; this.courseProgressService = courseProgressService; this.progressService = progressService; @@ -78,6 +82,23 @@ public class MentorController { this.studentPurgeService = studentPurgeService; this.userRepository = userRepository; this.auditService = auditService; + this.mentorAnalyticsService = mentorAnalyticsService; + } + + /** + * GET /api/mentor/analytics?cohort=2026 — 코호트(기수) 단위 분석. + * cohort 없으면 전체. 코호트 목록·KPI·학생별 요약·어려운 지점을 한 번에 준다. + */ + @GetMapping("/analytics") + public MentorAnalyticsService.Analytics analytics( + @RequestParam(name = "cohort", required = false) String cohort) { + return mentorAnalyticsService.analytics(cohort); + } + + /** POST /api/mentor/students/{id}/cohort — 학생의 기수를 바꾼다. */ + @PostMapping("/students/{id}/cohort") + public void changeCohort(@PathVariable Long id, @Valid @RequestBody CohortRequest request) { + studentProfileService.changeCohort(id, request.cohort()); } /** diff --git a/backend/src/main/java/dev/awesomedev/mirim/web/dto/CohortRequest.java b/backend/src/main/java/dev/awesomedev/mirim/web/dto/CohortRequest.java new file mode 100644 index 0000000..ae36193 --- /dev/null +++ b/backend/src/main/java/dev/awesomedev/mirim/web/dto/CohortRequest.java @@ -0,0 +1,15 @@ +package dev.awesomedev.mirim.web.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +/** + * 이 파일이 하는 일: 멘토가 학생의 기수(코호트)를 바꿀 때 보내는 본문 {cohort}. + */ +public record CohortRequest( + + @NotBlank(message = "기수는 필수입니다.") + @Size(max = 20, message = "기수는 20자 이하입니다.") + String cohort +) { +} diff --git a/backend/src/main/java/dev/awesomedev/mirim/web/dto/StudentProfileResponse.java b/backend/src/main/java/dev/awesomedev/mirim/web/dto/StudentProfileResponse.java index c5b59a5..1e32746 100644 --- a/backend/src/main/java/dev/awesomedev/mirim/web/dto/StudentProfileResponse.java +++ b/backend/src/main/java/dev/awesomedev/mirim/web/dto/StudentProfileResponse.java @@ -15,6 +15,7 @@ public record StudentProfileResponse( String name, String username, String track, + String cohort, String companyEmail, LocalDate birthDate, String phone, @@ -31,6 +32,7 @@ public record StudentProfileResponse( p.getUser().getName(), p.getUser().getUsername(), p.getUser().getTrack(), + p.getCohort(), p.fullEmail(), p.getBirthDate(), p.getPhone(), diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 0615d2f..85a7d0f 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -32,6 +32,11 @@ spring: baseline-on-migrate: true baseline-version: 1 +# 앱 도메인 설정 +app: + # 지금 가입하는 학생이 편입될 기수(코호트). 새 기수를 받을 땐 운영 .env에서 이 값만 바꾼다. + current-cohort: ${CURRENT_COHORT:2026} + server: port: 8080 error: diff --git a/backend/src/main/resources/db/migration/V2__add_student_cohort.sql b/backend/src/main/resources/db/migration/V2__add_student_cohort.sql new file mode 100644 index 0000000..5393a79 --- /dev/null +++ b/backend/src/main/resources/db/migration/V2__add_student_cohort.sql @@ -0,0 +1,10 @@ +-- V2: 수습생 프로필에 코호트(기수) 컬럼 추가. +-- +-- 학습 포인트: 이게 Flyway를 도입한 진짜 이유다 — 운영 DB의 데이터를 지키면서 +-- 스키마를 "버전 관리하며" 바꾼다. V1(baseline) 이후의 첫 실제 마이그레이션. +-- 기존 학생들은 DEFAULT로 '2026' 기수에 자동 편입된다(백필). +ALTER TABLE student_profile ADD COLUMN cohort varchar(20) NOT NULL DEFAULT '2026'; + +-- 백필이 끝났으니 DB 기본값은 떼어 낸다 — 이후 값은 앱(Java)이 가입 시 항상 넣어 주므로, +-- "값의 출처는 한 곳(앱)"으로 두는 게 깔끔하다. (엔티티는 nullable=false varchar(20)와 일치) +ALTER TABLE student_profile ALTER COLUMN cohort DROP DEFAULT; diff --git a/backend/src/test/java/dev/awesomedev/mirim/service/MentorAnalyticsServiceTest.java b/backend/src/test/java/dev/awesomedev/mirim/service/MentorAnalyticsServiceTest.java new file mode 100644 index 0000000..4bad96f --- /dev/null +++ b/backend/src/test/java/dev/awesomedev/mirim/service/MentorAnalyticsServiceTest.java @@ -0,0 +1,180 @@ +package dev.awesomedev.mirim.service; + +import dev.awesomedev.mirim.domain.CodingProblem; +import dev.awesomedev.mirim.domain.CodingSubmission; +import dev.awesomedev.mirim.domain.QuizResult; +import dev.awesomedev.mirim.domain.StudentProfile; +import dev.awesomedev.mirim.domain.User; +import dev.awesomedev.mirim.repository.ChecklistItemRepository; +import dev.awesomedev.mirim.repository.CodingProblemRepository; +import dev.awesomedev.mirim.repository.CodingSubmissionRepository; +import dev.awesomedev.mirim.repository.CourseProgressRepository; +import dev.awesomedev.mirim.repository.ProgressRepository; +import dev.awesomedev.mirim.repository.QuizResultRepository; +import dev.awesomedev.mirim.repository.StudentProfileRepository; +import dev.awesomedev.mirim.repository.SubmissionRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * 이 파일이 하는 일: 코호트 분석의 집계 로직을 검증한다 — 코호트 필터, 퀴즈 평균, + * '마지막 활동/관심필요' 일수 계산, 어려운 지점(핫스팟) 정렬. + * + * 학습 포인트 ① — 시각(Clock)을 고정해 "며칠 전 활동"을 결정적으로 검증한다. + * 학습 포인트 ② — Mockito 함정: when(...).thenReturn(X)의 X를 만드는 도중에 또 when(...)을 + * 부르면 "UnfinishedStubbing" 오류가 난다. 그래서 목 객체는 먼저 변수에 다 만들어 두고 넘긴다. + */ +class MentorAnalyticsServiceTest { + + private static final Instant NOW = Instant.parse("2026-09-10T09:00:00Z"); + private final Clock clock = Clock.fixed(NOW, ZoneOffset.UTC); + + private final StudentProfileRepository profileRepo = mock(StudentProfileRepository.class); + private final QuizResultRepository quizRepo = mock(QuizResultRepository.class); + private final CodingSubmissionRepository codeRepo = mock(CodingSubmissionRepository.class); + private final CodingProblemRepository problemRepo = mock(CodingProblemRepository.class); + private final CourseProgressRepository courseRepo = mock(CourseProgressRepository.class); + private final ProgressRepository progressRepo = mock(ProgressRepository.class); + private final ChecklistItemRepository checklistRepo = mock(ChecklistItemRepository.class); + private final SubmissionRepository submissionRepo = mock(SubmissionRepository.class); + + private final MentorAnalyticsService service = new MentorAnalyticsService( + profileRepo, quizRepo, codeRepo, problemRepo, courseRepo, progressRepo, + checklistRepo, submissionRepo, clock); + + private User student(String name, String track) { + return new User(name, "해시", name, "STUDENT", track); + } + + private StudentProfile profile(User u, String cohort) { + StudentProfile p = mock(StudentProfile.class); + when(p.getUser()).thenReturn(u); + when(p.getCohort()).thenReturn(cohort); + return p; + } + + @Test + @DisplayName("코호트 목록과 인원수를 집계하고, 필터로 해당 기수만 남긴다") + void cohorts_and_filter() { + StudentProfile pa = profile(student("가", "DEV"), "2025"); + StudentProfile pb = profile(student("나", "DEV"), "2026"); + StudentProfile pc = profile(student("다", "DESIGN"), "2026"); + emptyActivity(); + when(profileRepo.findAllWithUser()).thenReturn(List.of(pa, pb, pc)); + + var all = service.analytics(null); + assertEquals(3, all.studentCount()); + assertEquals(List.of( + new MentorAnalyticsService.CohortCount("2025", 1), + new MentorAnalyticsService.CohortCount("2026", 2)), all.cohorts()); + + var only2026 = service.analytics("2026"); + assertEquals(2, only2026.studentCount()); + assertTrue(only2026.students().stream().allMatch(s -> s.cohort().equals("2026"))); + } + + @Test + @DisplayName("퀴즈는 코스별 최고점의 평균(%)을 내고, 마지막 활동 일수를 계산한다") + void quiz_average_and_last_active() { + User a = student("가", "DEV"); + StudentProfile pa = profile(a, "2026"); + // 같은 코스 2회 응시(최고점 채택: 5/5=100 vs 3/5=60 → 100), 다른 코스 1회(2/4=50) + QuizResult q1 = quiz("c1", 5, 5, NOW.minus(Duration.ofDays(3))); + QuizResult q2 = quiz("c1", 3, 5, NOW.minus(Duration.ofDays(10))); + QuizResult q3 = quiz("c2", 2, 4, NOW.minus(Duration.ofDays(5))); + emptyActivity(); + when(profileRepo.findAllWithUser()).thenReturn(List.of(pa)); + when(quizRepo.findByUserOrderBySubmittedAtDesc(a)).thenReturn(List.of(q1, q2, q3)); + + var row = service.analytics(null).students().get(0); + assertEquals(2, row.quizAttempted()); + assertEquals(75, row.quizAvgPct()); // (100 + 50) / 2 + assertEquals(3L, row.daysSinceActive()); // 가장 최근 활동이 3일 전 + } + + @Test + @DisplayName("활동이 없으면 lastActive는 null, daysSinceActive도 null이다") + void no_activity() { + StudentProfile pa = profile(student("가", "DEV"), "2026"); + emptyActivity(); + when(profileRepo.findAllWithUser()).thenReturn(List.of(pa)); + + var row = service.analytics(null).students().get(0); + assertNull(row.lastActiveAt()); + assertNull(row.daysSinceActive()); + assertEquals(0, row.quizAvgPct()); + } + + @Test + @DisplayName("어려운 코딩 문제는 통과율 낮은 순으로 정렬된다") + void coding_hotspots_sorted_by_pass_rate() { + User a = student("가", "DEV"); + User b = student("나", "DEV"); + StudentProfile pa = profile(a, "2026"); + StudentProfile pb = profile(b, "2026"); + CodingProblem easy = problem(10L, "쉬운문제"); + CodingProblem hard = problem(20L, "어려운문제"); + // easy: 둘 다 통과(100%). hard: 한 명만 통과(50%) + CodingSubmission a1 = sub(easy, "PASSED"); + CodingSubmission a2 = sub(hard, "PASSED"); + CodingSubmission b1 = sub(easy, "PASSED"); + CodingSubmission b2 = sub(hard, "FAILED"); + emptyActivity(); + when(profileRepo.findAllWithUser()).thenReturn(List.of(pa, pb)); + when(codeRepo.findByUser(a)).thenReturn(List.of(a1, a2)); + when(codeRepo.findByUser(b)).thenReturn(List.of(b1, b2)); + + var hot = service.analytics(null).hardestCoding(); + assertEquals(2, hot.size()); + assertEquals("어려운문제", hot.get(0).title()); // 통과율 낮은 게 먼저 + assertEquals(50, hot.get(0).passRatePct()); + assertEquals(100, hot.get(1).passRatePct()); + } + + // ── 헬퍼: 목 객체는 먼저 만들어 두고, when()에는 완성된 객체만 넘긴다 ── + + private void emptyActivity() { + when(problemRepo.count()).thenReturn(25L); + when(checklistRepo.count()).thenReturn(30L); + when(quizRepo.findByUserOrderBySubmittedAtDesc(any())).thenReturn(List.of()); + when(codeRepo.findByUser(any())).thenReturn(List.of()); + when(courseRepo.findByUser(any())).thenReturn(List.of()); + when(progressRepo.findByUser(any())).thenReturn(List.of()); + when(submissionRepo.findByUserOrderBySubmittedAtDesc(any())).thenReturn(List.of()); + } + + private QuizResult quiz(String slug, int score, int total, Instant at) { + QuizResult qr = mock(QuizResult.class); + when(qr.getCourseSlug()).thenReturn(slug); + when(qr.getScore()).thenReturn(score); + when(qr.getTotal()).thenReturn(total); + when(qr.getSubmittedAt()).thenReturn(at); + return qr; + } + + private CodingProblem problem(Long id, String title) { + CodingProblem p = mock(CodingProblem.class); + when(p.getId()).thenReturn(id); + when(p.getTitle()).thenReturn(title); + return p; + } + + private CodingSubmission sub(CodingProblem problem, String status) { + CodingSubmission cs = mock(CodingSubmission.class); + when(cs.getProblem()).thenReturn(problem); + when(cs.getStatus()).thenReturn(status); + when(cs.getSubmittedAt()).thenReturn(NOW.minus(Duration.ofDays(2))); + return cs; + } +} diff --git a/backend/src/test/java/dev/awesomedev/mirim/service/StudentProfileServiceTest.java b/backend/src/test/java/dev/awesomedev/mirim/service/StudentProfileServiceTest.java index b6f0977..a55227d 100644 --- a/backend/src/test/java/dev/awesomedev/mirim/service/StudentProfileServiceTest.java +++ b/backend/src/test/java/dev/awesomedev/mirim/service/StudentProfileServiceTest.java @@ -45,7 +45,7 @@ class StudentProfileServiceTest { ReflectionTestUtils.setField(student, "id", 3L); profile = new StudentProfile(student, "donghyoek.lim", LocalDate.of(2008, 2, 27), "010-6485-4121", "시흥시/정왕2동", "미림고", "뉴미디어SW", - "차정순", "모", "010-4316-6756", Instant.now()); + "차정순", "모", "010-4316-6756", Instant.now(), "2026"); } private ProfileUpdateRequest request(String emailLocal, String address) { @@ -106,7 +106,7 @@ class StudentProfileServiceTest { User attacker = new User("evil", "해시", "=HYPERLINK(\"http://evil/\")", "STUDENT", "DEV"); ReflectionTestUtils.setField(attacker, "id", 9L); // createdAt은 생성자가 이미 채움 StudentProfile evilProfile = new StudentProfile(attacker, "evil", LocalDate.of(2008, 1, 1), - "010-0000-0000", "@SUM(A1)", "학교", "전공", "보호자", "부", "010-1111-1111", Instant.now()); + "010-0000-0000", "@SUM(A1)", "학교", "전공", "보호자", "부", "010-1111-1111", Instant.now(), "2026"); when(studentProfileRepository.findAllWithUser()).thenReturn(List.of(evilProfile)); String csv = service.exportCsv(); diff --git a/backend/src/test/java/dev/awesomedev/mirim/web/SecurityContractTest.java b/backend/src/test/java/dev/awesomedev/mirim/web/SecurityContractTest.java index 78caba9..2fba749 100644 --- a/backend/src/test/java/dev/awesomedev/mirim/web/SecurityContractTest.java +++ b/backend/src/test/java/dev/awesomedev/mirim/web/SecurityContractTest.java @@ -8,6 +8,7 @@ import dev.awesomedev.mirim.service.AuthService; import dev.awesomedev.mirim.service.CodingService; import dev.awesomedev.mirim.service.CourseProgressService; import dev.awesomedev.mirim.service.LoginAttemptService; +import dev.awesomedev.mirim.service.MentorAnalyticsService; import dev.awesomedev.mirim.service.ProgressService; import dev.awesomedev.mirim.service.QuizService; import dev.awesomedev.mirim.service.RateLimiterService; @@ -78,6 +79,7 @@ class SecurityContractTest { @MockitoBean private QuizService quizService; @MockitoBean private StudentProfileService studentProfileService; @MockitoBean private StudentPurgeService studentPurgeService; + @MockitoBean private MentorAnalyticsService mentorAnalyticsService; @MockitoBean private UserRepository userRepository; /** 실제 로그인과 똑같이, 주어진 역할의 인증 정보를 담은 세션을 만든다. */ diff --git a/frontend/src/components/MentorAnalytics.jsx b/frontend/src/components/MentorAnalytics.jsx new file mode 100644 index 0000000..f715efb --- /dev/null +++ b/frontend/src/components/MentorAnalytics.jsx @@ -0,0 +1,192 @@ +// 이 파일이 하는 일: 멘토용 "기수(코호트) 분석 대시보드". +// 학생 카드 하나하나로는 안 보이는 "묶어서 본 그림"을 보여준다 — +// 기수 선택 → KPI(진도·퀴즈·코딩·리뷰대기), 관심 필요 학생, 어려운 지점(퀴즈·코딩). +// +// 학습 포인트: "총 코스 수"는 프론트 카탈로그의 지식이라 진도 %를 여기서 계산한다. +// 서버는 학생별 원자료(완료 수·점수·활동시각)만 주고, 카탈로그를 아는 화면이 %로 빚는다. +import { useEffect, useMemo, useState } from 'react'; +import client from '../api/client'; +import { ALL_COURSES } from '../courseCatalog'; +import { LEVEL_COURSES } from '../levelCatalog'; + +const TOTAL_COURSES = ALL_COURSES.length + LEVEL_COURSES.length; +// 퀴즈 핫스팟은 서버가 slug만 주므로, 카탈로그로 사람이 읽는 제목을 붙인다. +const SLUG_TITLE = new Map( + [...ALL_COURSES, ...LEVEL_COURSES].map((c) => [c.slug, c.title]), +); + +function Kpi({ label, value, sub }) { + return ( +
+
{value}
+
{label}
+ {sub &&
{sub}
} +
+ ); +} + +function daysLabel(d) { + if (d == null) return '활동 없음'; + if (d === 0) return '오늘'; + return `${d}일 전`; +} + +export default function MentorAnalytics() { + const [cohort, setCohort] = useState('all'); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + setLoading(true); + const q = cohort === 'all' ? '' : `?cohort=${encodeURIComponent(cohort)}`; + client + .get(`/mentor/analytics${q}`) + .then((res) => { + if (!cancelled) { + setData(res.data); + setLoading(false); + } + }) + .catch(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [cohort]); + + const kpi = useMemo(() => { + if (!data) return null; + const rows = data.students; + const n = rows.length; + const avgCoursePct = n + ? Math.round((rows.reduce((s, x) => s + x.coursesDone, 0) / n / TOTAL_COURSES) * 100) + : 0; + const quizzed = rows.filter((x) => x.quizAttempted > 0); + const avgQuiz = quizzed.length + ? Math.round(quizzed.reduce((s, x) => s + x.quizAvgPct, 0) / quizzed.length) + : 0; + const codingRate = n + ? Math.round( + (rows.reduce((s, x) => s + (x.codingTotal ? x.codingPassed / x.codingTotal : 0), 0) / n) * 100, + ) + : 0; + return { n, avgCoursePct, avgQuiz, quizzedCount: quizzed.length, codingRate }; + }, [data]); + + if (loading && !data) return

기수 분석을 불러오는 중...

; + if (!data) return null; + + // 관심 필요: 활동이 없거나 7일 이상 조용한 학생 — 오래 조용한 순으로. + const atRisk = data.students + .filter((x) => x.daysSinceActive == null || x.daysSinceActive >= 7) + .sort((a, b) => (b.daysSinceActive ?? 99999) - (a.daysSinceActive ?? 99999)); + + const totalHead = data.cohorts.reduce((s, c) => s + c.count, 0); + + return ( +
+
+

📊 기수 분석

+ +
+ + {/* KPI 카드 */} +
+ + + + + +
+ + {/* 관심 필요 학생 */} +

🔔 관심 필요 (7일 넘게 활동 없음)

+ {atRisk.length === 0 ? ( +

모두 최근에 활동했어요 👍

+ ) : ( +
+ + + + + + + + + + + {atRisk.map((s) => ( + + + + + + + ))} + +
학생트랙마지막 활동완료 코스
{s.name}{s.track === 'DESIGN' ? '디자인' : '개발'} + {daysLabel(s.daysSinceActive)} + + {s.coursesDone} / {TOTAL_COURSES} +
+
+ )} + + {/* 어려운 지점 — 퀴즈 / 코딩 */} +
+
+

🧩 어려운 퀴즈 (평균 낮은 순)

+ {data.hardestQuizzes.length === 0 ? ( +

아직 퀴즈 응시 기록이 없어요.

+ ) : ( +
    + {data.hardestQuizzes.map((q) => ( +
  • + {SLUG_TITLE.get(q.courseSlug) || q.courseSlug} + {' — '} + {q.avgScorePct}% + (응시 {q.attempts}명) +
  • + ))} +
+ )} +
+
+

💻 어려운 코딩 (통과율 낮은 순)

+ {data.hardestCoding.length === 0 ? ( +

아직 코딩 제출 기록이 없어요.

+ ) : ( +
    + {data.hardestCoding.map((c) => ( +
  • + {c.title} + {' — '} + {c.passRatePct}% + (시도 {c.attempts}명) +
  • + ))} +
+ )} +
+
+
+ ); +} diff --git a/frontend/src/components/MentorAnalytics.test.jsx b/frontend/src/components/MentorAnalytics.test.jsx new file mode 100644 index 0000000..ac199ab --- /dev/null +++ b/frontend/src/components/MentorAnalytics.test.jsx @@ -0,0 +1,57 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' + +// api 클라이언트를 목으로 — 실제 네트워크 없이 분석 데이터를 주입한다. +vi.mock('../api/client', () => ({ default: { get: vi.fn() } })) +import client from '../api/client' +import MentorAnalytics from './MentorAnalytics.jsx' + +const SAMPLE = { + cohort: '전체', + studentCount: 2, + pendingReviews: 3, + cohorts: [{ cohort: '2026', count: 2 }], + students: [ + { + userId: 1, name: '가영', track: 'DEV', cohort: '2026', + coursesDone: 10, quizAttempted: 2, quizAvgPct: 80, + codingPassed: 5, codingTotal: 10, checklistDone: 3, checklistTotal: 10, + lastActiveAt: '2026-09-08T00:00:00Z', daysSinceActive: 2, + }, + { + userId: 2, name: '나윤', track: 'DEV', cohort: '2026', + coursesDone: 0, quizAttempted: 0, quizAvgPct: 0, + codingPassed: 0, codingTotal: 10, checklistDone: 0, checklistTotal: 10, + lastActiveAt: null, daysSinceActive: null, + }, + ], + hardestQuizzes: [{ courseSlug: 'lv1-loops', avgScorePct: 40, attempts: 2 }], + hardestCoding: [{ problemId: 1, title: '별 찍기', passRatePct: 25, attempts: 4 }], +} + +describe('MentorAnalytics', () => { + beforeEach(() => { + client.get.mockReset() + client.get.mockResolvedValue({ data: SAMPLE }) + }) + + it('KPI와 리뷰 대기 건수를 보여준다', async () => { + render() + expect(await screen.findByText('2명')).toBeInTheDocument() // 학생 수 + expect(screen.getByText('3건')).toBeInTheDocument() // 리뷰 대기 + }) + + it('활동 없는 학생을 "관심 필요"에 "활동 없음"으로 올린다', async () => { + render() + await waitFor(() => expect(screen.getByText('나윤')).toBeInTheDocument()) + expect(screen.getByText('활동 없음')).toBeInTheDocument() + }) + + it('어려운 퀴즈는 slug를 강좌 제목으로 바꿔 보여준다', async () => { + render() + // lv1-loops → 카탈로그 제목("반복문...")으로 해석돼야 한다 + expect(await screen.findByText(/반복문/)).toBeInTheDocument() + expect(screen.getByText('별 찍기')).toBeInTheDocument() // 어려운 코딩 + }) +}) diff --git a/frontend/src/pages/MentorPage.jsx b/frontend/src/pages/MentorPage.jsx index 4eae4ab..08bfb5d 100644 --- a/frontend/src/pages/MentorPage.jsx +++ b/frontend/src/pages/MentorPage.jsx @@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'; import client from '../api/client'; import MentorCodingSection from '../components/MentorCodingSection'; +import MentorAnalytics from '../components/MentorAnalytics'; import Card from '../components/Card'; import Badge from '../components/Badge'; import { COURSE_CATEGORIES, ALL_COURSES } from '../courseCatalog'; @@ -215,6 +216,20 @@ function StudentRoster({ students, onChanged }) { } } + // 기수(코호트) 변경 — 잘못 배정됐거나 재편성할 때. + async function changeCohort(s) { + const next = window.prompt(`${s.name} 님의 기수를 입력하세요 (현재: ${s.cohort})`, s.cohort); + if (next === null) return; + const v = next.trim(); + if (!v || v === s.cohort) return; + try { + await client.post(`/mentor/students/${s.userId}/cohort`, { cohort: v }); + onChanged(); + } catch { + window.alert('기수 변경 중 문제가 생겼어요. 잠시 후 다시 시도해 주세요.'); + } + } + return (
@@ -232,6 +247,7 @@ function StudentRoster({ students, onChanged }) { 이름 회사 이메일 트랙 + 기수 생년월일 연락처 학교 @@ -245,6 +261,17 @@ function StudentRoster({ students, onChanged }) { {s.name} {s.companyEmail} {s.track === 'DESIGN' ? '디자인' : '개발'} + + {/* 기수 배지를 누르면 변경 — 클릭 가능함을 알 수 있게 button으로 감싼다 */} + + {s.birthDate} {s.phone} {s.school} @@ -470,6 +497,9 @@ export default function MentorPage() {

)} + {/* ── 기수(코호트) 분석 대시보드 — 묶어서 본 그림 ── */} + + {/* ── 수습생 명단 (인적사항) ── */}

수습생 명단

{roster.length === 0 ? (