From 01cbfc17813f699ebac3b5e81097293050a13741 Mon Sep 17 00:00:00 2001 From: Junkov0 Date: Thu, 23 Jul 2026 18:32:33 +0900 Subject: [PATCH 1/2] fix: wire notifications, dashboards, and exam-room creation to real data --- db/schema.sql | 2 + .../momogo/api/report/ReportController.java | 56 ++ .../com/momogo/api/room/RoomController.java | 18 + .../com/momogo/api/user/UserController.java | 14 + .../listener/NotificationEventListener.java | 10 + .../problem/service/ProblemServiceImpl.java | 23 + .../domain/report/service/ReportService.java | 20 + .../report/service/ReportServiceImpl.java | 179 +++- .../request/RoomProblemAiCreateRequest.java | 5 + .../request/RoomProblemCreatedRequest.java | 5 + .../dto/request/RoomProblemUpdateRequest.java | 4 + .../dto/response/RoomProblemResponse.java | 8 +- .../core/domain/room/entity/RoomProblem.java | 10 + .../domain/room/mapper/RoomProblemMapper.java | 2 + .../repository/RoomProblemRepository.java | 2 + .../room/repository/RoomUserRepository.java | 8 + .../repository/UserRoomAnswerRepository.java | 3 + .../room/service/RoomProblemPersister.java | 3 + .../room/service/RoomProblemServiceImpl.java | 23 +- .../core/domain/room/service/RoomService.java | 5 + .../domain/room/service/RoomServiceImpl.java | 34 +- .../core/domain/user/entity/UserProblem.java | 6 + .../repository/UserProblemRepository.java | 22 + .../core/domain/user/service/UserService.java | 9 + .../domain/user/service/UserServiceImpl.java | 17 + momogo-frontend/src/App.tsx | 51 + momogo-frontend/src/pages/DashboardPage.tsx | 158 +-- momogo-frontend/src/pages/SpacePage.tsx | 917 +++++++++++++----- momogo-frontend/src/services/api.ts | 148 ++- 29 files changed, 1390 insertions(+), 372 deletions(-) diff --git a/db/schema.sql b/db/schema.sql index ee4b843..ce0f3ec 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -88,6 +88,7 @@ CREATE TABLE "TBL_ROOM_USER" ( CREATE TABLE "TBL_ROOM_PROBLEM" ( "id" UUID NOT NULL, "room_id" UUID NOT NULL, + "category_id" UUID NOT NULL, "problem_order" INTEGER NOT NULL, "name" VARCHAR(255) NULL, "content" TEXT NULL, @@ -175,6 +176,7 @@ ALTER TABLE "TBL_PROBLEM_COUNTERS" ADD CONSTRAINT "FK_TBL_PROBLEM_TO_TBL_PROBLEM -- 방 및 독립 시험지 세팅 ALTER TABLE "TBL_ROOM" ADD CONSTRAINT "FK_TBL_SPACE_TO_TBL_ROOM" FOREIGN KEY ("space_id") REFERENCES "TBL_SPACE" ("id"); ALTER TABLE "TBL_ROOM_PROBLEM" ADD CONSTRAINT "FK_TBL_ROOM_TO_TBL_ROOM_PROBLEM" FOREIGN KEY ("room_id") REFERENCES "TBL_ROOM" ("id") ON DELETE CASCADE; +ALTER TABLE "TBL_ROOM_PROBLEM" ADD CONSTRAINT "FK_TBL_PROBLEM_CATEGORY_TO_TBL_ROOM_PROBLEM" FOREIGN KEY ("category_id") REFERENCES "TBL_PROBLEM_CATEGORY" ("id"); -- 방 참여 유저 맵 ALTER TABLE "TBL_ROOM_USER" ADD CONSTRAINT "FK_TBL_ROOM_TO_TBL_ROOM_USER" FOREIGN KEY ("room_id") REFERENCES "TBL_ROOM" ("id") ON DELETE CASCADE; diff --git a/momogo-api/src/main/java/com/momogo/api/report/ReportController.java b/momogo-api/src/main/java/com/momogo/api/report/ReportController.java index 828080c..f16af8f 100644 --- a/momogo-api/src/main/java/com/momogo/api/report/ReportController.java +++ b/momogo-api/src/main/java/com/momogo/api/report/ReportController.java @@ -1,6 +1,11 @@ package com.momogo.api.report; +import com.momogo.core.domain.report.dto.response.DashboardSummaryResponse; +import com.momogo.core.domain.report.dto.response.MyExamDetailResponse; +import com.momogo.core.domain.report.dto.response.MyExamListItemResponse; import com.momogo.core.domain.report.dto.response.PersonalReportResponse; +import com.momogo.core.domain.report.dto.response.SingleModeHistoryResponse; +import com.momogo.core.domain.report.dto.response.SingleModeSummaryResponse; import com.momogo.core.domain.report.dto.response.SpaceRankingResponse; import com.momogo.core.domain.report.entity.ReportType; import com.momogo.core.domain.report.service.ReportService; @@ -59,4 +64,55 @@ public ResponseEntity> getSpaceRanking( ) { return ResponseEntity.ok(reportService.getSpaceRanking(spaceId)); } + + /** + * 메인 대시보드 요약 카드(오늘 푼 문제 수 / 이번 주 정답률 / 참여 완료 시험 수) 실시간 조회 + */ + @GetMapping("/summary") + public ResponseEntity getDashboardSummary( + @AuthenticationPrincipal(expression = "userResponse.id") UUID userId + ) { + return ResponseEntity.ok(reportService.getDashboardSummary(userId)); + } + + /** + * 공간 내 성적 대시보드: 싱글모드 요약(오늘/이번 주 정답 수, 전체 평균 정답률) 조회 + */ + @GetMapping("/single/summary") + public ResponseEntity getSingleModeSummary( + @AuthenticationPrincipal(expression = "userResponse.id") UUID userId + ) { + return ResponseEntity.ok(reportService.getSingleModeSummary(userId)); + } + + /** + * 공간 내 성적 대시보드: 싱글모드 전체 풀이 이력 조회 + */ + @GetMapping("/single/history") + public ResponseEntity> getSingleModeHistory( + @AuthenticationPrincipal(expression = "userResponse.id") UUID userId + ) { + return ResponseEntity.ok(reportService.getSingleModeHistory(userId)); + } + + /** + * 공간 내 성적 대시보드: 내가 응시(제출 완료)한 평가 시험 목록 조회 + */ + @GetMapping("/exams") + public ResponseEntity> getMyExamList( + @AuthenticationPrincipal(expression = "userResponse.id") UUID userId + ) { + return ResponseEntity.ok(reportService.getMyExamList(userId)); + } + + /** + * 공간 내 성적 대시보드: 내가 응시한 평가 시험의 문항별 채점 결과(리뷰) 조회 + */ + @GetMapping("/exams/{roomId}") + public ResponseEntity getMyExamDetail( + @AuthenticationPrincipal(expression = "userResponse.id") UUID userId, + @PathVariable UUID roomId + ) { + return ResponseEntity.ok(reportService.getMyExamDetail(userId, roomId)); + } } diff --git a/momogo-api/src/main/java/com/momogo/api/room/RoomController.java b/momogo-api/src/main/java/com/momogo/api/room/RoomController.java index 3d397ba..6b97836 100644 --- a/momogo-api/src/main/java/com/momogo/api/room/RoomController.java +++ b/momogo-api/src/main/java/com/momogo/api/room/RoomController.java @@ -1,7 +1,9 @@ package com.momogo.api.room; +import com.momogo.core.domain.problem.dto.response.GeneratedProblemData; import com.momogo.core.domain.room.dto.request.RoomAnswerSubmitRequest; import com.momogo.core.domain.room.dto.request.RoomCreateRequest; +import com.momogo.core.domain.room.dto.request.RoomProblemDraftAiRequest; import com.momogo.core.domain.room.dto.response.RoomProblemResponse; import com.momogo.core.domain.room.dto.response.RoomReportResponse; import com.momogo.core.domain.room.dto.response.RoomResponse; @@ -49,6 +51,22 @@ public ResponseEntity createRoom( return ResponseEntity.status(HttpStatus.CREATED).body(response); } + /** + * 시험방 생성 마법사(2단계) 미리보기용 AI 문제 생성 (ADMIN 전용) + * 방이 아직 만들어지기 전 단계이므로 DB에는 저장하지 않고 생성 결과만 반환한다. + * @param spaceId 공간 아이디 + * @param request 참고자료/문항 수 요청 DTO + * @return 생성된 문제 초안 목록 (미저장) + */ + @PostMapping("/spaces/{spaceId}/rooms/ai-draft-problems") + public ResponseEntity> generateDraftProblems( + @AuthenticationPrincipal(expression = "userResponse.id") UUID userId, + @PathVariable UUID spaceId, + @Valid @RequestBody RoomProblemDraftAiRequest request + ) { + return ResponseEntity.ok(roomService.generateDraftProblems(userId, spaceId, request)); + } + /** * 평가 시험 상세 조회 * @param userId 유저 아이디 diff --git a/momogo-api/src/main/java/com/momogo/api/user/UserController.java b/momogo-api/src/main/java/com/momogo/api/user/UserController.java index 59dbf0e..77ad9bf 100644 --- a/momogo-api/src/main/java/com/momogo/api/user/UserController.java +++ b/momogo-api/src/main/java/com/momogo/api/user/UserController.java @@ -8,6 +8,7 @@ import com.momogo.core.domain.user.service.UserService; import jakarta.validation.Valid; import java.io.IOException; +import java.util.List; import java.util.UUID; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; @@ -45,6 +46,19 @@ public ResponseEntity createUser( return ResponseEntity.status(HttpStatus.CREATED).body(userResponse); } + /** + * 요청 유저와 같은 공간에 소속된 전체 유저 목록을 조회합니다. (응시 대상자 지정 등에 사용) + * + * @param userId 인증 객체에서 추출한 요청 유저 식별자 + * @return 같은 공간에 소속된 유저 목록 DTO + */ + @GetMapping + public ResponseEntity> findUsersInMySpace( + @AuthenticationPrincipal(expression = "userResponse.id") UUID userId + ) { + return ResponseEntity.ok(userService.findUsersInMySpace(userId)); + } + /** * 로그인된 현재 유저의 상세 정보를 조회합니다. * diff --git a/momogo-core/src/main/java/com/momogo/core/domain/notification/listener/NotificationEventListener.java b/momogo-core/src/main/java/com/momogo/core/domain/notification/listener/NotificationEventListener.java index 447f520..e8d1f9e 100644 --- a/momogo-core/src/main/java/com/momogo/core/domain/notification/listener/NotificationEventListener.java +++ b/momogo-core/src/main/java/com/momogo/core/domain/notification/listener/NotificationEventListener.java @@ -19,11 +19,18 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.event.TransactionPhase; import org.springframework.transaction.event.TransactionalEventListener; /* * 다른 도메인에서 발행한 이벤트를 받아서 알림을 저장하고 SSE로 실시간 전송하는 리스너. + * + * AFTER_COMMIT 단계는 원본 트랜잭션이 이미 커밋을 마친 뒤(스레드에 바인딩된 트랜잭션 리소스가 + * 정리되는 시점) 실행되므로, 이 시점에서 수행하는 DB 쓰기는 REQUIRES_NEW로 완전히 새로운 + * 트랜잭션을 열어야 한다. 그렇지 않으면 save()가 예외 없이 반환되어도(엔티티에 ID까지 채워진 + * 상태로) 실제로는 어떤 트랜잭션에도 커밋되지 않아 DB에 반영되지 않는 문제가 발생한다. */ @Slf4j @Component @@ -37,6 +44,7 @@ public class NotificationEventListener { // 평가 시험(방) 생성 -> 대상 유저 전원에게 알림 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Transactional(propagation = Propagation.REQUIRES_NEW) public void handleRoomCreated(RoomCreatedEvent event) { Map userMap = userRepository.findAllById(event.userIds()).stream() .collect(Collectors.toMap(User::getId, Function.identity())); @@ -54,6 +62,7 @@ public void handleRoomCreated(RoomCreatedEvent event) { // 공간 내 권한 변경 -> 변경된 본인에게 알림 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Transactional(propagation = Propagation.REQUIRES_NEW) public void handleUserRoleChanged(SpaceUserRoleChangedEvent event) { User receiver = userRepository.findById(event.targetUserId()).orElse(null); if (receiver == null) { @@ -66,6 +75,7 @@ public void handleUserRoleChanged(SpaceUserRoleChangedEvent event) { // 공간에 새 유저 가입 -> 그 공간 ADMIN에게 알림 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Transactional(propagation = Propagation.REQUIRES_NEW) public void handleUserJoined(SpaceUserJoinedEvent event) { User receiver = userRepository.findBySpaceIdAndRole(event.spaceId(), UserRole.ADMIN).orElse(null); if (receiver == null) { diff --git a/momogo-core/src/main/java/com/momogo/core/domain/problem/service/ProblemServiceImpl.java b/momogo-core/src/main/java/com/momogo/core/domain/problem/service/ProblemServiceImpl.java index 4311425..b02e8b8 100644 --- a/momogo-core/src/main/java/com/momogo/core/domain/problem/service/ProblemServiceImpl.java +++ b/momogo-core/src/main/java/com/momogo/core/domain/problem/service/ProblemServiceImpl.java @@ -20,6 +20,10 @@ import com.momogo.core.domain.problem.repository.ProblemRepository; import com.momogo.core.domain.space.entity.Space; import com.momogo.core.domain.space.repository.SpaceRepository; +import com.momogo.core.domain.user.entity.UserProblem; +import com.momogo.core.domain.user.entity.UserProblemId; +import com.momogo.core.domain.user.repository.UserProblemRepository; +import com.momogo.core.domain.user.repository.UserRepository; import java.time.OffsetDateTime; import java.util.List; import java.util.UUID; @@ -49,6 +53,10 @@ public class ProblemServiceImpl implements ProblemService { private final ProblemPersister problemPersister; + private final UserProblemRepository userProblemRepository; + + private final UserRepository userRepository; + /** * 문제 직접 생성 * @@ -241,6 +249,21 @@ public ProblemSolveResponse solveProblem( counters.recordAttempt(isSolved); + // 유저별 문제 풀이 이력 upsert (싱글모드 대시보드/랭킹 집계의 데이터 소스) + UserProblemId userProblemId = new UserProblemId(userId, problemId); + UserProblem userProblem = userProblemRepository.findById(userProblemId).orElse(null); + if (userProblem != null) { + userProblem.updateAttempt(isSolved, request.userAnswer()); + } else { + userProblemRepository.save(UserProblem.builder() + .id(userProblemId) + .user(userRepository.getReferenceById(userId)) + .problem(problem) + .isSolved(isSolved) + .userAnswer(request.userAnswer()) + .build()); + } + return new ProblemSolveResponse( isSolved, problem.getCorrectAnswer(), diff --git a/momogo-core/src/main/java/com/momogo/core/domain/report/service/ReportService.java b/momogo-core/src/main/java/com/momogo/core/domain/report/service/ReportService.java index b0e198f..79cf83a 100644 --- a/momogo-core/src/main/java/com/momogo/core/domain/report/service/ReportService.java +++ b/momogo-core/src/main/java/com/momogo/core/domain/report/service/ReportService.java @@ -1,6 +1,11 @@ package com.momogo.core.domain.report.service; +import com.momogo.core.domain.report.dto.response.DashboardSummaryResponse; +import com.momogo.core.domain.report.dto.response.MyExamDetailResponse; +import com.momogo.core.domain.report.dto.response.MyExamListItemResponse; import com.momogo.core.domain.report.dto.response.PersonalReportResponse; +import com.momogo.core.domain.report.dto.response.SingleModeHistoryResponse; +import com.momogo.core.domain.report.dto.response.SingleModeSummaryResponse; import com.momogo.core.domain.report.dto.response.SpaceRankingResponse; import com.momogo.core.domain.report.entity.ReportType; import java.time.LocalDate; @@ -14,4 +19,19 @@ public interface ReportService { // 공간 대시보드: 이번 주 정답 수 기준 랭킹 조회 List getSpaceRanking(UUID spaceId); + + // 메인 대시보드 요약 카드(오늘 푼 문제 수 / 이번 주 정답률 / 참여 완료 시험 수) 실시간 조회 + DashboardSummaryResponse getDashboardSummary(UUID userId); + + // 공간 내 성적 대시보드: 싱글모드 요약(오늘/이번 주 정답 수, 전체 평균 정답률) 조회 + SingleModeSummaryResponse getSingleModeSummary(UUID userId); + + // 공간 내 성적 대시보드: 싱글모드 전체 풀이 이력 조회 + List getSingleModeHistory(UUID userId); + + // 공간 내 성적 대시보드: 내가 응시(제출 완료)한 평가 시험 목록 조회 + List getMyExamList(UUID userId); + + // 공간 내 성적 대시보드: 내가 응시한 평가 시험의 문항별 채점 결과(리뷰) 조회 + MyExamDetailResponse getMyExamDetail(UUID userId, UUID roomId); } diff --git a/momogo-core/src/main/java/com/momogo/core/domain/report/service/ReportServiceImpl.java b/momogo-core/src/main/java/com/momogo/core/domain/report/service/ReportServiceImpl.java index ebe3d28..ef83b59 100644 --- a/momogo-core/src/main/java/com/momogo/core/domain/report/service/ReportServiceImpl.java +++ b/momogo-core/src/main/java/com/momogo/core/domain/report/service/ReportServiceImpl.java @@ -1,18 +1,42 @@ package com.momogo.core.domain.report.service; +import com.momogo.core.common.exception.BusinessException; +import com.momogo.core.domain.report.dto.response.DashboardSummaryResponse; +import com.momogo.core.domain.report.dto.response.MyExamDetailResponse; +import com.momogo.core.domain.report.dto.response.MyExamListItemResponse; +import com.momogo.core.domain.report.dto.response.MyExamProblemResultResponse; import com.momogo.core.domain.report.dto.response.PersonalReportResponse; +import com.momogo.core.domain.report.dto.response.SingleModeHistoryResponse; +import com.momogo.core.domain.report.dto.response.SingleModeSummaryResponse; import com.momogo.core.domain.report.dto.response.SpaceRankingResponse; import com.momogo.core.domain.report.entity.ReportType; import com.momogo.core.domain.report.mapper.ReportMapper; import com.momogo.core.domain.report.repository.PersonalReportRepository; import com.momogo.core.domain.report.repository.SpaceRankingRepository; +import com.momogo.core.domain.room.entity.Room; +import com.momogo.core.domain.room.entity.RoomProblem; +import com.momogo.core.domain.room.entity.RoomUser; +import com.momogo.core.domain.room.entity.RoomUserId; +import com.momogo.core.domain.room.entity.UserRoomAnswer; +import com.momogo.core.domain.room.exception.RoomErrorCode; +import com.momogo.core.domain.room.repository.RoomProblemRepository; +import com.momogo.core.domain.room.repository.RoomUserRepository; +import com.momogo.core.domain.room.repository.UserRoomAnswerRepository; +import com.momogo.core.domain.space.exception.SpaceErrorCode; +import com.momogo.core.domain.user.entity.User; +import com.momogo.core.domain.user.entity.UserProblem; +import com.momogo.core.domain.user.repository.UserProblemRepository; +import com.momogo.core.domain.user.repository.UserRepository; import java.time.DayOfWeek; import java.time.LocalDate; import java.time.OffsetDateTime; import java.time.ZoneId; import java.time.temporal.TemporalAdjusters; import java.util.List; +import java.util.Map; import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -25,6 +49,11 @@ public class ReportServiceImpl implements ReportService { private final PersonalReportRepository personalReportRepository; private final SpaceRankingRepository spaceRankingRepository; private final ReportMapper reportMapper; + private final UserProblemRepository userProblemRepository; + private final RoomUserRepository roomUserRepository; + private final RoomProblemRepository roomProblemRepository; + private final UserRoomAnswerRepository userRoomAnswerRepository; + private final UserRepository userRepository; @Override public PersonalReportResponse getPersonalReport(UUID userId, ReportType reportType, @@ -46,11 +75,153 @@ public PersonalReportResponse getPersonalReport(UUID userId, ReportType reportTy public List getSpaceRanking(UUID spaceId) { // "이번 주"의 시작 시각(월요일 00:00)을 구해서, 그 이후에 풀린 문제만 랭킹에 반영함 // (전체 누적이 아니라 "이번 주" 랭킹이라는 요구사항이라, 매주 월요일이 되면 자연스럽게 초기화됨) - LocalDate mondayOfThisWeek = LocalDate.now() - .with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); - OffsetDateTime periodStart = mondayOfThisWeek.atStartOfDay(ZoneId.systemDefault()) - .toOffsetDateTime(); + OffsetDateTime periodStart = startOfThisWeek(); return spaceRankingRepository.findRankingBySpaceId(spaceId, periodStart); } + + @Override + public DashboardSummaryResponse getDashboardSummary(UUID userId) { + UUID spaceId = resolveCurrentSpaceId(userId); + if (spaceId == null) { + return new DashboardSummaryResponse(0, 0.0, 0); + } + + OffsetDateTime todayStart = startOfToday(); + OffsetDateTime todayEnd = todayStart.plusDays(1); + OffsetDateTime weekStart = startOfThisWeek(); + OffsetDateTime now = OffsetDateTime.now(); + + long todaySolvedCount = userProblemRepository.countSolvedByUserAndSpaceAndPeriod(userId, spaceId, todayStart, todayEnd); + + long weeklyAttempted = userProblemRepository.countAttemptedByUserAndSpaceAndPeriod(userId, spaceId, weekStart, now); + long weeklySolved = userProblemRepository.countSolvedByUserAndSpaceAndPeriod(userId, spaceId, weekStart, now); + double weeklyAccuracyRate = calculateAccuracyRate(weeklySolved, weeklyAttempted); + + long completedExamCount = roomUserRepository.countByUser_IdAndIsAttendedTrueAndRoom_Space_Id(userId, spaceId); + + return new DashboardSummaryResponse(todaySolvedCount, weeklyAccuracyRate, completedExamCount); + } + + @Override + public SingleModeSummaryResponse getSingleModeSummary(UUID userId) { + UUID spaceId = resolveCurrentSpaceId(userId); + if (spaceId == null) { + return new SingleModeSummaryResponse(0, 0, 0.0); + } + + OffsetDateTime todayStart = startOfToday(); + OffsetDateTime todayEnd = todayStart.plusDays(1); + OffsetDateTime weekStart = startOfThisWeek(); + OffsetDateTime now = OffsetDateTime.now(); + + long dailySolvedCount = userProblemRepository.countSolvedByUserAndSpaceAndPeriod(userId, spaceId, todayStart, todayEnd); + long weeklySolvedCount = userProblemRepository.countSolvedByUserAndSpaceAndPeriod(userId, spaceId, weekStart, now); + + long totalAttempted = userProblemRepository.countByUser_IdAndProblem_Space_Id(userId, spaceId); + long totalSolved = userProblemRepository.countByUser_IdAndProblem_Space_IdAndIsSolvedTrue(userId, spaceId); + double averageCorrectRate = calculateAccuracyRate(totalSolved, totalAttempted); + + return new SingleModeSummaryResponse(dailySolvedCount, weeklySolvedCount, averageCorrectRate); + } + + @Override + public List getSingleModeHistory(UUID userId) { + UUID spaceId = resolveCurrentSpaceId(userId); + if (spaceId == null) { + return List.of(); + } + + return userProblemRepository.findAllByUser_IdAndProblem_Space_IdOrderByCreatedAtDesc(userId, spaceId).stream() + .map(up -> new SingleModeHistoryResponse( + up.getProblem().getId(), + up.getProblem().getName(), + up.getProblem().getCategory().getName(), + Boolean.TRUE.equals(up.getIsSolved()), + up.getUserAnswer(), + up.getProblem().getCorrectAnswer(), + up.getCreatedAt() + )) + .toList(); + } + + @Override + public List getMyExamList(UUID userId) { + UUID spaceId = resolveCurrentSpaceId(userId); + if (spaceId == null) { + return List.of(); + } + + return roomUserRepository.findAllByUser_IdAndRoom_Space_Id(userId, spaceId).stream() + .filter(ru -> Boolean.TRUE.equals(ru.getIsAttended())) + .map(ru -> { + Room room = ru.getRoom(); + int totalProblems = (int) roomProblemRepository.countByRoomId(room.getId()); + return new MyExamListItemResponse( + room.getId(), + room.getName(), + room.getDescription(), + ru.getScore(), + totalProblems, + room.getTestStartAt(), + room.getTestEndAt(), + Boolean.TRUE.equals(room.getIsEnded()) + ); + }) + .toList(); + } + + @Override + public MyExamDetailResponse getMyExamDetail(UUID userId, UUID roomId) { + RoomUser roomUser = roomUserRepository.findById(new RoomUserId(roomId, userId)) + .orElseThrow(() -> new BusinessException(RoomErrorCode.NOT_ROOM_PARTICIPANT)); + + Room room = roomUser.getRoom(); + + List roomProblems = roomProblemRepository.findByRoomIdOrderByProblemOrder(roomId); + List myAnswers = userRoomAnswerRepository.findByRoomProblemRoomIdAndUserId(roomId, userId); + Map answerByProblemId = myAnswers.stream() + .collect(Collectors.toMap(a -> a.getRoomProblem().getId(), Function.identity())); + + List problems = roomProblems.stream() + .map(problem -> { + UserRoomAnswer answer = answerByProblemId.get(problem.getId()); + return new MyExamProblemResultResponse( + problem.getId(), + problem.getProblemOrder(), + problem.getName(), + problem.getContent(), + answer != null ? answer.getUserAnswer() : "", + problem.getCorrectAnswer(), + problem.getExplanation(), + answer != null && Boolean.TRUE.equals(answer.getIsCorrect()) + ); + }) + .toList(); + + return new MyExamDetailResponse(room.getId(), room.getName(), room.getDescription(), roomUser.getScore(), problems); + } + + // 요청 유저가 현재 소속된 공간 ID를 조회 (소속 공간이 없으면 null) + private UUID resolveCurrentSpaceId(UUID userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(SpaceErrorCode.SPACE_USER_NOT_FOUND)); + return user.getSpace() != null ? user.getSpace().getId() : null; + } + + private double calculateAccuracyRate(long solved, long attempted) { + if (attempted == 0) { + return 0.0; + } + return (double) solved / attempted * 100; + } + + private OffsetDateTime startOfToday() { + return LocalDate.now().atStartOfDay(ZoneId.systemDefault()).toOffsetDateTime(); + } + + private OffsetDateTime startOfThisWeek() { + LocalDate mondayOfThisWeek = LocalDate.now().with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); + return mondayOfThisWeek.atStartOfDay(ZoneId.systemDefault()).toOffsetDateTime(); + } } diff --git a/momogo-core/src/main/java/com/momogo/core/domain/room/dto/request/RoomProblemAiCreateRequest.java b/momogo-core/src/main/java/com/momogo/core/domain/room/dto/request/RoomProblemAiCreateRequest.java index 6dd5dce..f4a3542 100644 --- a/momogo-core/src/main/java/com/momogo/core/domain/room/dto/request/RoomProblemAiCreateRequest.java +++ b/momogo-core/src/main/java/com/momogo/core/domain/room/dto/request/RoomProblemAiCreateRequest.java @@ -5,14 +5,19 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; +import java.util.UUID; /** * AI 문제 자동 생성 요청 DTO (방 문제 생성) + * @param categoryId 문제 카테고리 아이디 * @param referenceText 참고자료 텍스트 * @param questionCount 지정 문항 수 */ public record RoomProblemAiCreateRequest( + @NotNull(message = "카테고리는 필수입니다.") + UUID categoryId, + @NotBlank(message = "참고자료는 필수입니다.") @Size(max = 10000, message = "참고자료는 10,000자를 초과할 수 없습니다.") String referenceText, diff --git a/momogo-core/src/main/java/com/momogo/core/domain/room/dto/request/RoomProblemCreatedRequest.java b/momogo-core/src/main/java/com/momogo/core/domain/room/dto/request/RoomProblemCreatedRequest.java index 395818b..7324ffe 100644 --- a/momogo-core/src/main/java/com/momogo/core/domain/room/dto/request/RoomProblemCreatedRequest.java +++ b/momogo-core/src/main/java/com/momogo/core/domain/room/dto/request/RoomProblemCreatedRequest.java @@ -3,9 +3,11 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Positive; +import java.util.UUID; /** * 평가 시험 문제 생성 요청 DTO + * @param categoryId 문제 카테고리 아이디 * @param name 문제 제목 * @param content 문제 내용 * @param explanation 문제 해설 @@ -14,6 +16,9 @@ */ public record RoomProblemCreatedRequest( + @NotNull(message = "카테고리는 필수입니다.") + UUID categoryId, + @NotBlank(message = "문제 제목은 필수입니다.") String name, diff --git a/momogo-core/src/main/java/com/momogo/core/domain/room/dto/request/RoomProblemUpdateRequest.java b/momogo-core/src/main/java/com/momogo/core/domain/room/dto/request/RoomProblemUpdateRequest.java index 137072c..caf78ec 100644 --- a/momogo-core/src/main/java/com/momogo/core/domain/room/dto/request/RoomProblemUpdateRequest.java +++ b/momogo-core/src/main/java/com/momogo/core/domain/room/dto/request/RoomProblemUpdateRequest.java @@ -1,13 +1,17 @@ package com.momogo.core.domain.room.dto.request; +import java.util.UUID; + /** * 방 문제 수정 DTO + * @param categoryId 문제 카테고리 아이디 * @param name 문제 이름 * @param content 문제 내용 * @param explanation 문제 해설 * @param correctAnswer 정답 답안 */ public record RoomProblemUpdateRequest( + UUID categoryId, String name, String content, String explanation, diff --git a/momogo-core/src/main/java/com/momogo/core/domain/room/dto/response/RoomProblemResponse.java b/momogo-core/src/main/java/com/momogo/core/domain/room/dto/response/RoomProblemResponse.java index f393aca..fa0aac0 100644 --- a/momogo-core/src/main/java/com/momogo/core/domain/room/dto/response/RoomProblemResponse.java +++ b/momogo-core/src/main/java/com/momogo/core/domain/room/dto/response/RoomProblemResponse.java @@ -3,9 +3,11 @@ import java.util.UUID; /** - * 응시자용 평가 시험 문제 상세 응답 DTO (정답 및 해설 제외) + * 관리자용 방 문제 응답 DTO * @param id 평가 시험 문제 ID * @param roomId 평가 시험 방 ID + * @param categoryId 문제 카테고리 ID + * @param categoryName 문제 카테고리 이름 * @param problemOrder 문제 순서 * @param name 문제 이름 * @param content 문제 내용 @@ -13,9 +15,11 @@ public record RoomProblemResponse( UUID id, UUID roomId, + UUID categoryId, + String categoryName, Integer problemOrder, String name, String content ) { -} \ No newline at end of file +} diff --git a/momogo-core/src/main/java/com/momogo/core/domain/room/entity/RoomProblem.java b/momogo-core/src/main/java/com/momogo/core/domain/room/entity/RoomProblem.java index e659abb..ae2be30 100644 --- a/momogo-core/src/main/java/com/momogo/core/domain/room/entity/RoomProblem.java +++ b/momogo-core/src/main/java/com/momogo/core/domain/room/entity/RoomProblem.java @@ -1,6 +1,7 @@ package com.momogo.core.domain.room.entity; import com.momogo.core.common.base.BaseTimeEntity; +import com.momogo.core.domain.problem.entity.ProblemCategory; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; @@ -33,6 +34,10 @@ public class RoomProblem extends BaseTimeEntity { @JoinColumn(name = "room_id", nullable = false) private Room room; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "category_id", nullable = false) + private ProblemCategory category; + @Column(name = "problem_order", nullable = false) private Integer problemOrder; @@ -51,6 +56,7 @@ public class RoomProblem extends BaseTimeEntity { // 정적 팩토리 생성 메소드 public static RoomProblem of( Room room, + ProblemCategory category, Integer problemOrder, String name, String content, @@ -59,6 +65,7 @@ public static RoomProblem of( ) { return RoomProblem.builder() .room(room) + .category(category) .problemOrder(problemOrder) .name(name) .content(content) @@ -69,12 +76,15 @@ public static RoomProblem of( // 방 문제 수정 시 사용하는 팩토리 메서드 public void update( + ProblemCategory category, Integer problemOrder, String name, String content, String explanation, String correctAnswer) { + if (category != null) this.category = category; + if (problemOrder != null) this.problemOrder = problemOrder; if (name != null) this.name = name; diff --git a/momogo-core/src/main/java/com/momogo/core/domain/room/mapper/RoomProblemMapper.java b/momogo-core/src/main/java/com/momogo/core/domain/room/mapper/RoomProblemMapper.java index ddbb600..de4d0cf 100644 --- a/momogo-core/src/main/java/com/momogo/core/domain/room/mapper/RoomProblemMapper.java +++ b/momogo-core/src/main/java/com/momogo/core/domain/room/mapper/RoomProblemMapper.java @@ -10,6 +10,8 @@ public interface RoomProblemMapper { @Mapping(source = "room.id", target = "roomId") + @Mapping(source = "category.id", target = "categoryId") + @Mapping(source = "category.name", target = "categoryName") RoomProblemResponse toResponse(RoomProblem roomProblem); @Mapping(source = "room.id", target = "roomId") diff --git a/momogo-core/src/main/java/com/momogo/core/domain/room/repository/RoomProblemRepository.java b/momogo-core/src/main/java/com/momogo/core/domain/room/repository/RoomProblemRepository.java index a7b4daf..d90ce4b 100644 --- a/momogo-core/src/main/java/com/momogo/core/domain/room/repository/RoomProblemRepository.java +++ b/momogo-core/src/main/java/com/momogo/core/domain/room/repository/RoomProblemRepository.java @@ -7,4 +7,6 @@ @Repository public interface RoomProblemRepository extends JpaRepository, RoomProblemRepositoryCustom { + + long countByRoomId(UUID roomId); } diff --git a/momogo-core/src/main/java/com/momogo/core/domain/room/repository/RoomUserRepository.java b/momogo-core/src/main/java/com/momogo/core/domain/room/repository/RoomUserRepository.java index 98110ff..8064791 100644 --- a/momogo-core/src/main/java/com/momogo/core/domain/room/repository/RoomUserRepository.java +++ b/momogo-core/src/main/java/com/momogo/core/domain/room/repository/RoomUserRepository.java @@ -23,4 +23,12 @@ public interface RoomUserRepository extends JpaRepository @EntityGraph(attributePaths = {"user"}) List findAllByRoomId(UUID roomId); + + // "참여 완료 시험" 집계도 항상 "내가 지금 속한 공간"의 시험만 대상으로 해야 한다. + // (공간을 나갔다가 다른 공간에 가입해도 예전 RoomUser 행은 남아있으므로, + // room.space로 반드시 필터링해서 다른 공간의 이력이 섞여 보이지 않게 한다.) + long countByUser_IdAndIsAttendedTrueAndRoom_Space_Id(UUID userId, UUID spaceId); + + @EntityGraph(attributePaths = {"room"}) + List findAllByUser_IdAndRoom_Space_Id(UUID userId, UUID spaceId); } diff --git a/momogo-core/src/main/java/com/momogo/core/domain/room/repository/UserRoomAnswerRepository.java b/momogo-core/src/main/java/com/momogo/core/domain/room/repository/UserRoomAnswerRepository.java index f952ce2..c5a75d9 100644 --- a/momogo-core/src/main/java/com/momogo/core/domain/room/repository/UserRoomAnswerRepository.java +++ b/momogo-core/src/main/java/com/momogo/core/domain/room/repository/UserRoomAnswerRepository.java @@ -13,4 +13,7 @@ public interface UserRoomAnswerRepository extends JpaRepository findByRoomProblemRoomId(@Param("roomId") java.util.UUID roomId); + + @Query("select u from UserRoomAnswer u join fetch u.roomProblem rp where rp.room.id = :roomId and u.user.id = :userId") + List findByRoomProblemRoomIdAndUserId(@Param("roomId") java.util.UUID roomId, @Param("userId") java.util.UUID userId); } diff --git a/momogo-core/src/main/java/com/momogo/core/domain/room/service/RoomProblemPersister.java b/momogo-core/src/main/java/com/momogo/core/domain/room/service/RoomProblemPersister.java index 4ad09e3..da8f164 100644 --- a/momogo-core/src/main/java/com/momogo/core/domain/room/service/RoomProblemPersister.java +++ b/momogo-core/src/main/java/com/momogo/core/domain/room/service/RoomProblemPersister.java @@ -2,6 +2,7 @@ import com.momogo.core.common.exception.BusinessException; import com.momogo.core.domain.problem.dto.response.GeneratedProblemData; +import com.momogo.core.domain.problem.entity.ProblemCategory; import com.momogo.core.domain.room.dto.response.RoomProblemResponse; import com.momogo.core.domain.room.entity.Room; import com.momogo.core.domain.room.entity.RoomProblem; @@ -32,6 +33,7 @@ public class RoomProblemPersister { @Transactional public List saveGeneratedProblems( UUID roomId, + ProblemCategory category, List generated) { // 같은 방에 대한 동시 채번 요청을 직렬화하기 위해 비관적 락으로 재조회 @@ -49,6 +51,7 @@ public List saveGeneratedProblems( roomProblemsToSave.add( RoomProblem.of( lockedRoom, + category, nextOrder++, dto.name(), dto.content(), diff --git a/momogo-core/src/main/java/com/momogo/core/domain/room/service/RoomProblemServiceImpl.java b/momogo-core/src/main/java/com/momogo/core/domain/room/service/RoomProblemServiceImpl.java index 478f63a..2024947 100644 --- a/momogo-core/src/main/java/com/momogo/core/domain/room/service/RoomProblemServiceImpl.java +++ b/momogo-core/src/main/java/com/momogo/core/domain/room/service/RoomProblemServiceImpl.java @@ -2,6 +2,9 @@ import com.momogo.core.common.exception.BusinessException; import com.momogo.core.domain.problem.dto.response.GeneratedProblemData; +import com.momogo.core.domain.problem.entity.ProblemCategory; +import com.momogo.core.domain.problem.exception.ProblemErrorCode; +import com.momogo.core.domain.problem.repository.ProblemCategoryRepository; import com.momogo.core.domain.problem.service.ProblemGenerationService; import com.momogo.core.domain.room.dto.request.RoomProblemAiCreateRequest; import com.momogo.core.domain.room.dto.request.RoomProblemCreatedRequest; @@ -38,6 +41,8 @@ public class RoomProblemServiceImpl implements RoomProblemService { private final RoomProblemMapper roomProblemMapper; + private final ProblemCategoryRepository categoryRepository; + private final ProblemGenerationService problemGenerationService; private final RoomProblemPersister roomProblemPersister; @@ -56,8 +61,10 @@ public RoomProblemResponse createRoomProblem( validateAdmin(userId, room); + ProblemCategory category = getCategory(request.categoryId()); + RoomProblem roomProblem = RoomProblem.of( - room, request.problemOrder(), request.name(), + room, category, request.problemOrder(), request.name(), request.content(), request.explanation(), request.correctAnswer()); RoomProblem saved = roomProblemRepository.save(roomProblem); @@ -82,7 +89,9 @@ public RoomProblemResponse updateRoomProblem( RoomProblem roomProblem = getRoomProblem(roomId, roomProblemId); - roomProblem.update(null, request.name(), request.content(), request.explanation(), request.correctAnswer()); + ProblemCategory category = request.categoryId() != null ? getCategory(request.categoryId()) : null; + + roomProblem.update(category, null, request.name(), request.content(), request.explanation(), request.correctAnswer()); return roomProblemMapper.toResponse(roomProblem); } @@ -121,12 +130,14 @@ public List createRoomProblemsByAi( validateAdmin(userId, room); + ProblemCategory category = getCategory(request.categoryId()); + List generated = problemGenerationService.generateProblems( request.referenceText(), request.questionCount() ); - return roomProblemPersister.saveGeneratedProblems(roomId, generated); + return roomProblemPersister.saveGeneratedProblems(roomId, category, generated); } @@ -136,6 +147,12 @@ private Room getRoom(UUID roomId) { .orElseThrow(() -> new BusinessException(RoomErrorCode.ROOM_NOT_FOUND)); } + private ProblemCategory getCategory(UUID categoryId) { + + return categoryRepository.findById(categoryId) + .orElseThrow(() -> new BusinessException(ProblemErrorCode.CATEGORY_NOT_FOUND)); + } + private RoomProblem getRoomProblem(UUID roomId, UUID roomProblemId) { RoomProblem roomProblem = roomProblemRepository.findById(roomProblemId) diff --git a/momogo-core/src/main/java/com/momogo/core/domain/room/service/RoomService.java b/momogo-core/src/main/java/com/momogo/core/domain/room/service/RoomService.java index ee5c886..eb3afd9 100644 --- a/momogo-core/src/main/java/com/momogo/core/domain/room/service/RoomService.java +++ b/momogo-core/src/main/java/com/momogo/core/domain/room/service/RoomService.java @@ -1,7 +1,9 @@ package com.momogo.core.domain.room.service; +import com.momogo.core.domain.problem.dto.response.GeneratedProblemData; import com.momogo.core.domain.room.dto.request.RoomAnswerSubmitRequest; import com.momogo.core.domain.room.dto.request.RoomCreateRequest; +import com.momogo.core.domain.room.dto.request.RoomProblemDraftAiRequest; import com.momogo.core.domain.room.dto.response.RoomProblemResponse; import com.momogo.core.domain.room.dto.response.RoomReportResponse; import com.momogo.core.domain.room.dto.response.RoomResponse; @@ -13,6 +15,9 @@ public interface RoomService { RoomResponse createRoom(UUID userId, UUID spaceId, RoomCreateRequest request); + // 시험방 생성 마법사(2단계) 미리보기용 AI 문제 생성 - DB에 저장하지 않고 생성 결과만 반환 + List generateDraftProblems(UUID userId, UUID spaceId, RoomProblemDraftAiRequest request); + RoomResponse getRoomDetails(UUID userId, UUID roomId); List getRoomProblems(UUID userId, UUID roomId); diff --git a/momogo-core/src/main/java/com/momogo/core/domain/room/service/RoomServiceImpl.java b/momogo-core/src/main/java/com/momogo/core/domain/room/service/RoomServiceImpl.java index a9a1f39..7dd6266 100644 --- a/momogo-core/src/main/java/com/momogo/core/domain/room/service/RoomServiceImpl.java +++ b/momogo-core/src/main/java/com/momogo/core/domain/room/service/RoomServiceImpl.java @@ -7,7 +7,13 @@ import com.lowagie.text.pdf.BaseFont; import com.lowagie.text.pdf.PdfWriter; import com.momogo.core.common.exception.BusinessException; +import com.momogo.core.domain.problem.dto.response.GeneratedProblemData; +import com.momogo.core.domain.problem.entity.ProblemCategory; +import com.momogo.core.domain.problem.exception.ProblemErrorCode; +import com.momogo.core.domain.problem.repository.ProblemCategoryRepository; +import com.momogo.core.domain.problem.service.ProblemGenerationService; import com.momogo.core.domain.room.dto.request.ProblemAnswerRequest; +import com.momogo.core.domain.room.dto.request.RoomProblemDraftAiRequest; import com.momogo.core.domain.room.dto.request.RoomAnswerSubmitRequest; import com.momogo.core.domain.room.dto.request.RoomCreateRequest; import com.momogo.core.domain.room.dto.response.ProblemGradeReport; @@ -62,6 +68,8 @@ public class RoomServiceImpl implements RoomService{ private final UserRepository userRepository; private final SpaceRepository spaceRepository; private final UserRoomAnswerRepository userRoomAnswerRepository; + private final ProblemCategoryRepository categoryRepository; + private final ProblemGenerationService problemGenerationService; private final RoomMapper roomMapper; private final ApplicationEventPublisher eventPublisher; @@ -92,10 +100,22 @@ public RoomResponse createRoom(UUID userId, UUID spaceId, RoomCreateRequest requ .toList(); roomUserRepository.saveAll(roomUsers); - // 4. RoomProblem 생성 및 저장 + // 4. 문제별 카테고리 일괄 조회 및 검증 + List categoryIds = request.problems().stream() + .map(com.momogo.core.domain.room.dto.request.RoomProblemCreatedRequest::categoryId) + .distinct() + .toList(); + Map categoriesById = categoryRepository.findAllById(categoryIds).stream() + .collect(Collectors.toMap(ProblemCategory::getId, Function.identity())); + if (categoriesById.size() != categoryIds.size()) { + throw new BusinessException(ProblemErrorCode.CATEGORY_NOT_FOUND); + } + + // 5. RoomProblem 생성 및 저장 List roomProblems = request.problems().stream() .map(prob -> RoomProblem.of( savedRoom, + categoriesById.get(prob.categoryId()), prob.problemOrder(), prob.name(), prob.content(), @@ -117,6 +137,18 @@ public RoomResponse createRoom(UUID userId, UUID spaceId, RoomCreateRequest requ return roomMapper.toResponse(savedRoom); } + @Override + public List generateDraftProblems(UUID userId, UUID spaceId, RoomProblemDraftAiRequest request) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(SpaceErrorCode.SPACE_USER_NOT_FOUND)); + + if (user.getRole() != UserRole.ADMIN || user.getSpace() == null || !user.getSpace().getId().equals(spaceId)) { + throw new BusinessException(SpaceErrorCode.NOT_SPACE_ADMIN); + } + + return problemGenerationService.generateProblems(request.referenceText(), request.questionCount()); + } + @Override public RoomResponse getRoomDetails(UUID userId, UUID roomId) { log.info("[RoomService] 시험방 상세 조회 - userId: {}, roomId: {}", userId, roomId); diff --git a/momogo-core/src/main/java/com/momogo/core/domain/user/entity/UserProblem.java b/momogo-core/src/main/java/com/momogo/core/domain/user/entity/UserProblem.java index 7957bd6..3f739b8 100644 --- a/momogo-core/src/main/java/com/momogo/core/domain/user/entity/UserProblem.java +++ b/momogo-core/src/main/java/com/momogo/core/domain/user/entity/UserProblem.java @@ -33,4 +33,10 @@ public class UserProblem extends BaseCreatedTimeEntity { @Column(name = "user_answer", columnDefinition = "TEXT") private String userAnswer; + + // 재시도 시 최신 제출 결과로 갱신 + public void updateAttempt(Boolean isSolved, String userAnswer) { + this.isSolved = isSolved; + this.userAnswer = userAnswer; + } } diff --git a/momogo-core/src/main/java/com/momogo/core/domain/user/repository/UserProblemRepository.java b/momogo-core/src/main/java/com/momogo/core/domain/user/repository/UserProblemRepository.java index b0c4373..d40b2de 100644 --- a/momogo-core/src/main/java/com/momogo/core/domain/user/repository/UserProblemRepository.java +++ b/momogo-core/src/main/java/com/momogo/core/domain/user/repository/UserProblemRepository.java @@ -2,9 +2,31 @@ import com.momogo.core.domain.user.entity.UserProblem; import com.momogo.core.domain.user.entity.UserProblemId; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.UUID; +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @Repository public interface UserProblemRepository extends JpaRepository { + + // 대시보드 통계는 항상 "내가 지금 속한 공간"의 문제 풀이만 집계해야 한다. + // (공간을 나갔다가 다른 공간에 가입해도 예전 UserProblem 행은 남아있으므로, + // problem.space로 반드시 필터링해서 다른 공간의 이력이 섞여 보이지 않게 한다.) + long countByUser_IdAndProblem_Space_Id(UUID userId, UUID spaceId); + + long countByUser_IdAndProblem_Space_IdAndIsSolvedTrue(UUID userId, UUID spaceId); + + @Query("select count(up) from UserProblem up where up.user.id = :userId and up.problem.space.id = :spaceId and up.createdAt >= :start and up.createdAt < :end") + long countAttemptedByUserAndSpaceAndPeriod(@Param("userId") UUID userId, @Param("spaceId") UUID spaceId, @Param("start") OffsetDateTime start, @Param("end") OffsetDateTime end); + + @Query("select count(up) from UserProblem up where up.user.id = :userId and up.problem.space.id = :spaceId and up.isSolved = true and up.createdAt >= :start and up.createdAt < :end") + long countSolvedByUserAndSpaceAndPeriod(@Param("userId") UUID userId, @Param("spaceId") UUID spaceId, @Param("start") OffsetDateTime start, @Param("end") OffsetDateTime end); + + @EntityGraph(attributePaths = {"problem", "problem.category"}) + List findAllByUser_IdAndProblem_Space_IdOrderByCreatedAtDesc(UUID userId, UUID spaceId); } diff --git a/momogo-core/src/main/java/com/momogo/core/domain/user/service/UserService.java b/momogo-core/src/main/java/com/momogo/core/domain/user/service/UserService.java index 64cdddb..3977c57 100644 --- a/momogo-core/src/main/java/com/momogo/core/domain/user/service/UserService.java +++ b/momogo-core/src/main/java/com/momogo/core/domain/user/service/UserService.java @@ -7,6 +7,7 @@ import com.momogo.core.domain.user.dto.response.CursorResponse; import com.momogo.core.domain.user.dto.response.UserResponse; +import java.util.List; import java.util.UUID; public interface UserService { @@ -23,6 +24,14 @@ public interface UserService { CursorResponse findAllUsers(UserPageRequest request); + /** + * 요청 유저가 소속된 공간에 함께 속한 전체 유저 목록을 조회합니다. (응시 대상자 지정 등에 사용) + * + * @param userId 요청 유저 식별자 + * @return 같은 공간에 소속된 유저 목록 DTO (소속 공간이 없으면 빈 리스트) + */ + List findUsersInMySpace(UUID userId); + /** * 유저의 정지(밴) 상태를 업데이트합니다. * diff --git a/momogo-core/src/main/java/com/momogo/core/domain/user/service/UserServiceImpl.java b/momogo-core/src/main/java/com/momogo/core/domain/user/service/UserServiceImpl.java index 329617e..64e3ed0 100644 --- a/momogo-core/src/main/java/com/momogo/core/domain/user/service/UserServiceImpl.java +++ b/momogo-core/src/main/java/com/momogo/core/domain/user/service/UserServiceImpl.java @@ -331,6 +331,23 @@ public CursorResponse findAllUsers(UserPageRequest request) { ); } + /** + * 요청 유저가 소속된 공간에 함께 속한 전체 유저 목록을 조회합니다. (응시 대상자 지정 등에 사용) + * + * @param userId 요청 유저 식별자 + * @return 같은 공간에 소속된 유저 목록 DTO (소속 공간이 없으면 빈 리스트) + */ + @Override + public List findUsersInMySpace(UUID userId) { + User user = findActiveUser(userId); + if (user.getSpace() == null) { + return List.of(); + } + return userRepository.findAllBySpaceId(user.getSpace().getId()).stream() + .map(userMapper::toResponse) + .toList(); + } + /** * 유저의 정지(밴) 상태를 업데이트합니다. * 유저를 정지 처리할 경우, 유저의 모든 활성 JWT 세션을 만료시키는 이벤트를 발행합니다. diff --git a/momogo-frontend/src/App.tsx b/momogo-frontend/src/App.tsx index 490db0b..ab8d350 100644 --- a/momogo-frontend/src/App.tsx +++ b/momogo-frontend/src/App.tsx @@ -18,12 +18,28 @@ const App: React.FC = () => { const [user, setUser] = useState(null); const [checkingSession, setCheckingSession] = useState(true); const [toasts, setToasts] = useState([]); + // 소셜 로그인 실패 시 백엔드가 리다이렉트로 넘긴 error/code 쿼리 파라미터를 담아 모달로 보여주기 위한 상태 + const [oauthErrorMessage, setOauthErrorMessage] = useState(null); // 대시보드와 공간 상세 페이지 뷰 상태 정의 const [view, setView] = useState< { type: 'dashboard'; initialTab?: 'dashboard' | 'superadmin' | 'mypage' } | { type: 'space'; space: any } >({ type: 'dashboard' }); useEffect(() => { + // 소셜 로그인 실패 리다이렉트(/oauth-callback?error=...&code=...)로 들어온 경우, + // 주소창에 에러 메시지/코드가 그대로 노출되지 않도록 즉시 쿼리스트링을 제거하고 모달로 대체 안내한다. + const params = new URLSearchParams(window.location.search); + const code = params.get('code'); + const errorMsg = params.get('error'); + if (code || errorMsg) { + window.history.replaceState({}, '', window.location.pathname); + setOauthErrorMessage( + code === 'USER-BANNED_USER' + ? '해당 계정은 정지된 상태입니다.\n(관리자에게 문의하세요.)' + : (errorMsg || '소셜 로그인 처리 중 오류가 발생했습니다.') + ); + } + checkSession(); }, []); @@ -82,6 +98,25 @@ const App: React.FC = () => { ))} + {/* 소셜 로그인 실패(정지 계정 등) 안내 모달 */} + {oauthErrorMessage && ( +
+
+

로그인 실패

+

{oauthErrorMessage}

+
+ +
+
+
+ )} + {user === null ? ( { @@ -172,6 +207,22 @@ const styles: Record = { color: '#4338ca', border: '1px solid #c7d2fe', }, + oauthModalTitle: { + fontSize: '1.25rem', + fontWeight: 700, + color: '#1d2939', + }, + oauthModalDesc: { + fontSize: '0.875rem', + color: '#475467', + lineHeight: 1.5, + whiteSpace: 'pre-line', + }, + oauthModalActions: { + display: 'flex', + justifyContent: 'flex-end', + marginTop: '0.5rem', + }, }; if (typeof document !== 'undefined') { diff --git a/momogo-frontend/src/pages/DashboardPage.tsx b/momogo-frontend/src/pages/DashboardPage.tsx index 97aab50..d0d1191 100644 --- a/momogo-frontend/src/pages/DashboardPage.tsx +++ b/momogo-frontend/src/pages/DashboardPage.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react'; import type { UserResponse } from '../types/user'; import { PASSWORD_REGEX, PASSWORD_INVALID_MESSAGE } from '../types/user'; import type { SpaceResponse, SpaceCreateRequest } from '../types/space'; -import { request, getAccessToken } from '../services/api'; +import { request, getAccessToken, connectNotificationSse } from '../services/api'; import { logout } from '../services/auth'; interface DashboardPageProps { @@ -18,8 +18,10 @@ const DEFAULT_AVATAR = '/basic.png'; interface NotificationItem { id: string; + title: string; content: string; - status: 'UNCONFIRMED' | 'CONFIRMED'; + type: string; + isConfirmed: boolean; createdAt: string; } @@ -28,6 +30,12 @@ interface CategoryResponse { name: string; } +interface DashboardSummaryResponse { + todaySolvedCount: number; + weeklyAccuracyRate: number; + completedExamCount: number; +} + export const DashboardPage: React.FC = ({ user, initialTab, onLogout, showToast, onEnterSpace, onUserUpdate }) => { // 탭 관련 상태 ('dashboard' | 'superadmin' | 'mypage') const [currentTab, setCurrentTab] = useState<'dashboard' | 'superadmin' | 'mypage'>(initialTab || 'dashboard'); @@ -42,6 +50,7 @@ export const DashboardPage: React.FC = ({ user, initialTab, const [superadminSubTab, setSuperadminSubTab] = useState<'users' | 'spaces' | 'problems' | 'categories'>('users'); const [space, setSpace] = useState(null); + const [dashboardSummary, setDashboardSummary] = useState(null); const [notifications, setNotifications] = useState([]); const [showNotifications, setShowNotifications] = useState(false); const [showUserMenu, setShowUserMenu] = useState(false); @@ -388,6 +397,19 @@ export const DashboardPage: React.FC = ({ user, initialTab, loadInitialData(); }, []); + // 알림 실시간 수신(SSE) 연결 - 신규 알림 발생 시 목록 맨 앞에 추가 + useEffect(() => { + const disconnect = connectNotificationSse({ + onNotification: (noti: NotificationItem) => { + setNotifications(prev => (prev.some(n => n.id === noti.id) ? prev : [noti, ...prev])); + }, + onError: (err) => { + console.error('알림 SSE 연결 오류:', err); + }, + }); + return () => disconnect(); + }, []); + const loadInitialData = async () => { setLoading(true); try { @@ -395,38 +417,18 @@ export const DashboardPage: React.FC = ({ user, initialTab, const spaceData = await request('/api/spaces/me', { method: 'GET' }); setSpace(spaceData); - // 2. 알림 조회 및 모의 알림 추가 + // 1-1. 메인 대시보드 요약 카드(싱글모드/종합성과/참여시험) 조회 + try { + const summaryData = await request('/api/dashboards/summary', { method: 'GET' }); + setDashboardSummary(summaryData); + } catch (err) { + console.error('대시보드 요약 조회 실패', err); + } + + // 2. 알림 조회 try { const notiData = await request('/api/notifications', { method: 'GET' }); - const list = notiData?.values || notiData?.data || []; - if (list.length > 0) { - setNotifications(list); - } else { - // 알림이 비어 있는 경우 시나리오 검증용 모의 알림 삽입 (규칙 3.3.4 알림 발송 요건 반영) - const mockNotifications: NotificationItem[] = [ - { - id: 'noti-1', - content: user.role === 'ADMIN' - ? '관리하는 공간에 새로운 유저(김철수)가 새로 유입되었습니다.' - : '학습 공간의 어드민이 회원님의 권한을 ADMIN으로 상향조정했습니다.', - status: 'UNCONFIRMED', - createdAt: new Date().toISOString() - }, - { - id: 'noti-2', - content: '참여 중인 공간 [마포고 수학 문제은행]에 새로운 평가시험 [2026학년도 1회차 정기 평가시험]이 생성되었습니다.', - status: 'UNCONFIRMED', - createdAt: new Date(Date.now() - 3600000).toISOString() - }, - { - id: 'noti-3', - content: '회원님의 개인 일간/주간 학습 리포트가 발행되었습니다. 대시보드 탭에서 확인하세요.', - status: 'UNCONFIRMED', - createdAt: new Date(Date.now() - 7200000).toISOString() - } - ]; - setNotifications(mockNotifications); - } + setNotifications(notiData?.data || []); } catch (err) { console.error('Failed to load notifications', err); } @@ -498,7 +500,7 @@ export const DashboardPage: React.FC = ({ user, initialTab, try { await request(`/api/super-admin/users/${targetUser.id}/ban`, { method: 'PATCH', - body: JSON.stringify({ is_banned: nextBannedState }), + body: JSON.stringify({ banned: nextBannedState }), }); showToast('회원 정지 상태가 변경되었습니다.', 'success'); @@ -662,7 +664,7 @@ export const DashboardPage: React.FC = ({ user, initialTab, setValidationErrors({}); }; - const unreadNotiCount = notifications.filter(n => n.status !== 'CONFIRMED').length; + const unreadNotiCount = notifications.filter(n => !n.isConfirmed).length; if (loading) { return
MoMoGo 데이터를 불러오는 중입니다...
; @@ -786,10 +788,10 @@ export const DashboardPage: React.FC = ({ user, initialTab, style={styles.userHeaderWidget} onClick={() => { setShowUserMenu(!showUserMenu); setShowNotifications(false); }} > - {user.name} {user.name} {user.role && ( @@ -802,10 +804,10 @@ export const DashboardPage: React.FC = ({ user, initialTab, {showUserMenu && (
- {user.name}
{user.name}
@@ -868,21 +870,21 @@ export const DashboardPage: React.FC = ({ user, initialTab,
싱글 모드 -

오늘 완료한 퀴즈

-
4개
-

어제보다 2개 더 많이 학습했습니다.

+

오늘 맞춘 퀴즈

+
{dashboardSummary?.todaySolvedCount ?? 0}개
+

오늘 정답 처리된 싱글모드 문제 수입니다.

종합 성과

이번 주 평균 정답률

-
82%
-

전주 대비 4% 상승하였습니다.

+
{Math.round(dashboardSummary?.weeklyAccuracyRate ?? 0)}%
+

이번 주 월요일부터 지금까지의 정답률입니다.

참여 시험

참가 완료 시험방

-
1개
-

완료된 모든 시험은 채점 중입니다.

+
{dashboardSummary?.completedExamCount ?? 0}개
+

답안을 제출해 응시를 완료한 시험방 수입니다.

@@ -1042,7 +1044,7 @@ export const DashboardPage: React.FC = ({ user, initialTab, 이메일 역할 권한 정지 상태 - 제재 명령 + 유저 정지 @@ -1062,15 +1064,27 @@ export const DashboardPage: React.FC = ({ user, initialTab, {u.role === 'SUPER_ADMIN' ? ( - 명령 금지 + 변경 불가 ) : ( - +
+
+
)} @@ -1404,12 +1418,6 @@ export const DashboardPage: React.FC = ({ user, initialTab, )}
- {profilePreview && ( -
-

프로필 이미지 미리보기:

- Profile Preview -
- )}
{/* 비밀번호 변경 영역 (소셜 가입 유저는 비밀번호 변경 불가) */} @@ -2121,9 +2129,29 @@ const styles: Record = { fontSize: '0.825rem', fontStyle: 'italic', }, - banBtn: { - fontSize: '0.8rem', - padding: '0.4rem 0.8rem', + banSwitch: { + position: 'relative', + display: 'inline-block', + width: '44px', + height: '24px', + cursor: 'pointer', + }, + banSwitchTrack: { + position: 'absolute', + inset: 0, + borderRadius: '999px', + transition: 'background-color 0.2s', + }, + banSwitchKnob: { + position: 'absolute', + top: '2px', + left: '2px', + width: '20px', + height: '20px', + borderRadius: '50%', + backgroundColor: '#fff', + transition: 'transform 0.2s', + boxShadow: '0 1px 2px rgba(0,0,0,0.3)', }, moreLoadBtn: { alignSelf: 'center', diff --git a/momogo-frontend/src/pages/SpacePage.tsx b/momogo-frontend/src/pages/SpacePage.tsx index ab7dbe4..32c06f0 100644 --- a/momogo-frontend/src/pages/SpacePage.tsx +++ b/momogo-frontend/src/pages/SpacePage.tsx @@ -118,6 +118,12 @@ interface RoomProblemResponse { content: string; } +interface SpaceRankingResponse { + userId: string; + userName: string; + solvedCount: number; +} + export const SpacePage: React.FC = ({ user, space, onBack, showToast }) => { const [activeTab, setActiveTab] = useState<'problems' | 'exams' | 'dashboard'>('problems'); @@ -155,20 +161,32 @@ export const SpacePage: React.FC = ({ user, space, onBack, showT // 2. 평가 시험방 관련 상태 const [rooms, setRooms] = useState([]); const [showCreateRoomModal, setShowCreateRoomModal] = useState(false); + const [roomWizardStep, setRoomWizardStep] = useState<1 | 2>(1); - // 시험방 생성 폼 상태 + // 시험방 생성 폼 상태 (1단계: 기본 정보) const [roomName, setRoomName] = useState(''); const [roomDesc, setRoomDesc] = useState(''); const [roomStart, setRoomStart] = useState(''); const [roomEnd, setRoomEnd] = useState(''); const [selectedMembers, setSelectedMembers] = useState([]); + const [memberSearchQuery, setMemberSearchQuery] = useState(''); + + // 시험방 생성 폼 상태 (2단계: 문제 출제 - 수동/AI 혼합) const [examProblemsList, setExamProblemsList] = useState<{ + categoryId: string; problemOrder: number; name: string; content: string; explanation: string; correctAnswer: string; - }[]>([{ problemOrder: 1, name: '', content: '', explanation: '', correctAnswer: '' }]); + }[]>([{ categoryId: '', problemOrder: 1, name: '', content: '', explanation: '', correctAnswer: '' }]); + + // 2단계 내 AI 자동 출제 (싱글모드 AI 출제기와 동일한 방식, 결과는 저장 전 미리보기로 목록에 추가됨) + const [showRoomAiModal, setShowRoomAiModal] = useState(false); + const [roomAiCategory, setRoomAiCategory] = useState(''); + const [roomAiRefData, setRoomAiRefData] = useState(''); + const [roomAiCount, setRoomAiCount] = useState(3); + const [roomAiLoading, setRoomAiLoading] = useState(false); const [allUsersList, setAllUsersList] = useState([]); const [showUserMenu, setShowUserMenu] = useState(false); @@ -191,6 +209,7 @@ export const SpacePage: React.FC = ({ user, space, onBack, showT const [history, setHistory] = useState([]); const [examsHistory, setExamsHistory] = useState([]); const [viewingExamDetail, setViewingExamDetail] = useState(null); + const [ranking, setRanking] = useState([]); const [validationErrors, setValidationErrors] = useState>({}); const timerRef = useRef(null); @@ -271,36 +290,25 @@ export const SpacePage: React.FC = ({ user, space, onBack, showT const summaryData = await request('/api/dashboards/single/summary', { method: 'GET' }); const historyData = await request('/api/dashboards/single/history', { method: 'GET' }); const examHistoryData = await request('/api/dashboards/exams', { method: 'GET' }); + const rankingData = await request(`/api/dashboards/spaces/${space.id}/ranking`, { method: 'GET' }); if (summaryData) setSummary(summaryData); if (historyData) setHistory(historyData); if (examHistoryData) setExamsHistory(examHistoryData); + if (rankingData) setRanking(rankingData); } catch (err: any) { console.error(err); } }; - // 멤버 가입 리스트 조회 (초대용/응시 대상자 지정용) + // 멤버 가입 리스트 조회 (초대용/응시 대상자 지정용) - 내가 소속된 공간의 전체 유저 목록 const loadMembersForInvitation = async () => { try { - const usersData = await request('/api/users', { method: 'GET', params: { size: '100' } }); - const list = Array.isArray(usersData) ? usersData : (usersData?.values || usersData?.data || []); - if (list.length > 0) { - setAllUsersList(list); - } else { - setAllUsersList([ - { id: user.id, name: user.name, email: user.email, role: user.role, banned: false, profileImageUrl: user.profileImageUrl || '', createdAt: new Date().toISOString() }, - { id: 'usr-101', name: '김철수', email: 'chulsoo@momogo.com', role: 'USER', banned: false, profileImageUrl: '', createdAt: new Date().toISOString() }, - { id: 'usr-102', name: '이영희', email: 'younghee@momogo.com', role: 'USER', banned: false, profileImageUrl: '', createdAt: new Date().toISOString() }, - { id: 'usr-103', name: '박민수', email: 'minsu@momogo.com', role: 'USER', banned: false, profileImageUrl: '', createdAt: new Date().toISOString() } - ]); - } - } catch { - setAllUsersList([ - { id: user.id, name: user.name, email: user.email, role: user.role, banned: false, profileImageUrl: user.profileImageUrl || '', createdAt: new Date().toISOString() }, - { id: 'usr-101', name: '김철수', email: 'chulsoo@momogo.com', role: 'USER', banned: false, profileImageUrl: '', createdAt: new Date().toISOString() }, - { id: 'usr-102', name: '이영희', email: 'younghee@momogo.com', role: 'USER', banned: false, profileImageUrl: '', createdAt: new Date().toISOString() } - ]); + const usersData = await request('/api/users', { method: 'GET' }); + setAllUsersList(usersData || []); + } catch (err) { + console.error('공간 멤버 목록 조회 실패:', err); + setAllUsersList([]); } }; @@ -436,21 +444,6 @@ export const SpacePage: React.FC = ({ user, space, onBack, showT } ); - // localStorage에 풀이 이력 저장 (오답 이력 및 통계용) - const singleHistory = JSON.parse(localStorage.getItem('momogo_single_history') || '[]'); - const newHistoryItem = { - problemId: solvingProblem?.id, - problemName: solvingProblem?.name, - categoryName: solvingProblem?.category?.name || '미분류', - isSolved: result.isSolved, - userAnswer: solveUserAnswer, - correctAnswer: result.correctAnswer, - solvedAt: new Date().toISOString() - }; - // 중복 풀이 기록이 있을 시 이전 기록 제거하고 최신 풀이로 갱신 - const filteredHistory = singleHistory.filter((h: any) => h.problemId !== solvingProblem?.id); - localStorage.setItem('momogo_single_history', JSON.stringify([newHistoryItem, ...filteredHistory])); - setSolveResult({ correct: result.isSolved, answer: result.correctAnswer, @@ -462,9 +455,8 @@ export const SpacePage: React.FC = ({ user, space, onBack, showT } }; - // 평가 시험방 생성 처리 - const handleCreateRoom = async (e: React.FormEvent) => { - e.preventDefault(); + // 시험방 생성 마법사 1단계(기본 정보) 검증 - 통과 시 2단계(문제 출제)로 전환 + const validateRoomStep1 = () => { const errors: Record = {}; if (!roomName.trim()) errors.roomName = '시험 이름을 입력해 주세요.'; if (!roomStart) errors.roomStart = '시작 일시를 입력해 주세요.'; @@ -473,6 +465,25 @@ export const SpacePage: React.FC = ({ user, space, onBack, showT if (Object.keys(errors).length > 0) { setValidationErrors(errors); + return false; + } + setValidationErrors({}); + return true; + }; + + // 시험방 생성 마법사 2단계(문제 출제) 검증 후 최종 개설 요청 + const submitCreateRoom = async () => { + const errors: Record = {}; + examProblemsList.forEach((p, i) => { + if (!p.categoryId) errors[`problem_${i}_category`] = '카테고리를 선택해 주세요.'; + if (!p.name.trim()) errors[`problem_${i}_name`] = '문제명을 입력해 주세요.'; + if (!p.content.trim()) errors[`problem_${i}_content`] = '문제 내용을 입력해 주세요.'; + if (!p.correctAnswer.trim()) errors[`problem_${i}_answer`] = '정답을 입력해 주세요.'; + }); + + if (Object.keys(errors).length > 0) { + setValidationErrors(errors); + showToast('출제 문제 정보를 모두 입력해 주세요. (카테고리 포함)', 'error'); return; } @@ -489,25 +500,11 @@ export const SpacePage: React.FC = ({ user, space, onBack, showT })), }; - const newRoom = await request(`/api/spaces/${space.id}/rooms`, { + await request(`/api/spaces/${space.id}/rooms`, { method: 'POST', body: JSON.stringify(payload), }); - // 개설된 룸의 문제를 로컬 스토리지에 백업해둡니다. (사용자 오프라인 채점용) - if (newRoom && newRoom.id) { - localStorage.setItem(`momogo_created_room_problems_${newRoom.id}`, JSON.stringify( - examProblemsList.map((p, i) => ({ - id: `rp-${newRoom.id}-${i}`, - problemOrder: i + 1, - name: p.name || `문제 ${i + 1}`, - content: p.content, - correctAnswer: p.correctAnswer, - explanation: p.explanation - })) - )); - } - showToast('정기 평가시험이 개설되었습니다.', 'success'); setShowCreateRoomModal(false); resetRoomForm(); @@ -517,6 +514,71 @@ export const SpacePage: React.FC = ({ user, space, onBack, showT } }; + // 마법사 폼 제출 - 1단계면 다음 단계로, 2단계면 실제 개설 요청 + const handleCreateRoom = async (e: React.FormEvent) => { + e.preventDefault(); + if (roomWizardStep === 1) { + if (validateRoomStep1()) { + setRoomWizardStep(2); + } + return; + } + await submitCreateRoom(); + }; + + // 2단계: AI 자동 출제 - 생성 결과를 저장하지 않고 문제 목록에 미리보기로 추가 + const handleGenerateRoomAiProblems = async (e: React.FormEvent) => { + e.preventDefault(); + const errors: Record = {}; + if (!roomAiCategory) errors.roomAiCategory = '카테고리를 선택해 주세요.'; + if (!roomAiRefData.trim()) errors.roomAiRefData = '참고 학습 자료를 기입해 주세요.'; + + if (Object.keys(errors).length > 0) { + setValidationErrors(errors); + return; + } + + try { + setRoomAiLoading(true); + const generated = await request<{ name: string; content: string; explanation: string; correctAnswer: string }[]>( + `/api/spaces/${space.id}/rooms/ai-draft-problems`, + { + method: 'POST', + body: JSON.stringify({ + referenceText: roomAiRefData, + questionCount: roomAiCount, + }), + } + ); + + const newItems = (generated || []).map(g => ({ + categoryId: roomAiCategory, + problemOrder: 0, + name: g.name, + content: g.content, + explanation: g.explanation, + correctAnswer: g.correctAnswer, + })); + + setExamProblemsList(prev => { + const withoutEmptyFirst = prev.length === 1 && !prev[0].name.trim() && !prev[0].content.trim() + ? [] + : prev; + return [...withoutEmptyFirst, ...newItems]; + }); + + showToast(`AI 문제 ${newItems.length}개가 생성되어 목록에 추가되었습니다. 내용을 검토한 뒤 개설해 주세요.`, 'success'); + setShowRoomAiModal(false); + setRoomAiRefData(''); + setRoomAiCategory(''); + setValidationErrors({}); + } catch (err: any) { + showToast(err.message || 'AI 생성 도중 서버 내부 오류가 발생했습니다.', 'error'); + } finally { + setRoomAiLoading(false); + } + }; + // 실시간 대기실 입장 처리 const handleEnterWaitRoom = async (room: RoomResponse) => { setCurrentExamRoom(room); @@ -586,9 +648,6 @@ export const SpacePage: React.FC = ({ user, space, onBack, showT body: JSON.stringify({ answers: answersPayload }), }); - // 제출 답안을 로컬 스토리지에 임시 저장 (채점 시 사용) - localStorage.setItem(`momogo_temp_exam_submission_${currentExamRoom.id}`, JSON.stringify(examAnswers)); - showToast('시험 답안이 성공적으로 제출되었습니다.', 'success'); exitExamMode(); } catch (err: any) { @@ -619,65 +678,6 @@ export const SpacePage: React.FC = ({ user, space, onBack, showT await request(`/api/rooms/${roomId}/finalize-grade`, { method: 'POST' }); showToast('최종 채점 마감이 확정되었습니다. 이제 리포트를 열람할 수 있습니다.', 'success'); - // 로컬 스토리지에 저장된 유저 제출 답안과 출제 문제의 정답을 비교하여 채점 결과 생성 - const tempSubmission = JSON.parse(localStorage.getItem(`momogo_temp_exam_submission_${roomId}`) || '{}'); - const createdProblems = JSON.parse(localStorage.getItem(`momogo_created_room_problems_${roomId}`) || '[]'); - - if (createdProblems.length > 0) { - // 백업된 제출 내역이 없는 경우 가상의 답안 생성 (데모 편의성 확보) - const defaultSubmission: Record = {}; - createdProblems.forEach((p: any) => { - defaultSubmission[p.id] = Math.random() > 0.2 ? p.correctAnswer : '오답'; - }); - const userAnswers = Object.keys(tempSubmission).length > 0 ? tempSubmission : defaultSubmission; - - let correctCount = 0; - const detailedProblems = createdProblems.map((rp: any) => { - const userAns = userAnswers[rp.id] || ''; - const isCorrect = userAns.trim().toLowerCase() === rp.correctAnswer?.trim().toLowerCase(); - if (isCorrect) correctCount++; - return { - problemId: rp.id, - problemOrder: rp.problemOrder, - name: rp.name, - content: rp.content, - userAnswer: userAns, - correctAnswer: rp.correctAnswer, - explanation: rp.explanation || '해설 정보가 존재하지 않습니다.', - isCorrect - }; - }); - - const score = Math.round((correctCount / createdProblems.length) * 100); - - // 1. 리스트 아이템 추가 - const examsHistory = JSON.parse(localStorage.getItem('momogo_exams_history') || '[]'); - const targetRoom = rooms.find(r => r.id === roomId); - const newItem = { - roomId, - roomName: targetRoom?.name || '평가 시험', - description: targetRoom?.description || '', - score, - totalProblems: createdProblems.length, - testStartAt: targetRoom?.testStartAt || new Date().toISOString(), - testEndAt: targetRoom?.testEndAt || new Date().toISOString(), - isEnded: true - }; - const filteredList = examsHistory.filter((item: any) => item.roomId !== roomId); - localStorage.setItem('momogo_exams_history', JSON.stringify([newItem, ...filteredList])); - - // 2. 상세 내역 저장 - const examDetails = JSON.parse(localStorage.getItem('momogo_exam_details') || '{}'); - examDetails[roomId] = { - roomId, - roomName: targetRoom?.name || '평가 시험', - description: targetRoom?.description || '', - score, - problems: detailedProblems - }; - localStorage.setItem('momogo_exam_details', JSON.stringify(examDetails)); - } - loadRooms(); loadDashboardData(); } catch (err: any) { @@ -736,12 +736,18 @@ export const SpacePage: React.FC = ({ user, space, onBack, showT }; const resetRoomForm = () => { + setRoomWizardStep(1); setRoomName(''); setRoomDesc(''); setRoomStart(''); setRoomEnd(''); setSelectedMembers([]); - setExamProblemsList([{ problemOrder: 1, name: '', content: '', explanation: '', correctAnswer: '' }]); + setMemberSearchQuery(''); + setExamProblemsList([{ categoryId: '', problemOrder: 1, name: '', content: '', explanation: '', correctAnswer: '' }]); + setShowRoomAiModal(false); + setRoomAiCategory(''); + setRoomAiRefData(''); + setRoomAiCount(3); setValidationErrors({}); }; @@ -869,6 +875,28 @@ export const SpacePage: React.FC = ({ user, space, onBack, showT ); } + // 응시 대상자 검색/선택 (관리자 본인은 응시 대상에서 제외) + const selectableMembers = allUsersList.filter(u => u.id !== user.id); + const filteredMembers = selectableMembers.filter(u => { + const q = memberSearchQuery.trim().toLowerCase(); + if (!q) return true; + return (u.name || '').toLowerCase().includes(q) || (u.email || '').toLowerCase().includes(q); + }); + const allFilteredSelected = filteredMembers.length > 0 && filteredMembers.every(u => selectedMembers.includes(u.id || '')); + + // 시험 시작~종료 소요 시간 표시용 (30분 단위 반올림 없이 그대로 표기) + const examDurationLabel = (() => { + if (!roomStart || !roomEnd) return null; + const diffMs = new Date(roomEnd).getTime() - new Date(roomStart).getTime(); + if (isNaN(diffMs) || diffMs <= 0) return null; + const totalMinutes = Math.round(diffMs / 60000); + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + if (hours === 0) return `${minutes}분`; + if (minutes === 0) return `${hours}시간`; + return `${hours}시간 ${minutes}분`; + })(); + return (
{/* 사이드바 메뉴 */} @@ -1180,7 +1208,6 @@ export const SpacePage: React.FC = ({ user, space, onBack, showT

내 학습 대시보드 - 데모 데이터 (백엔드 미연동)

{/* 카드 요약 정보 (규칙 3.6 개인 대시보드 반영) */} @@ -1209,46 +1236,41 @@ export const SpacePage: React.FC = ({ user, space, onBack, showT

공간 랭킹 대시보드 - 데모 데이터 (백엔드 미연동)

- 공간 내부에 등록되어 있는 문제를 푼 유저들의 실시간 랭킹입니다. + 공간 내부에 등록되어 있는 문제를 푼 유저들의 이번 주(월요일~현재) 실시간 랭킹입니다.

- + - - - - - - - - - - - - - - - - - - - - - - - - - + {ranking.map((r, i) => { + const isMe = r.userId === user.id; + const medal = i === 0 ? ' 🥇' : i === 1 ? ' 🥈' : i === 2 ? ' 🥉' : ''; + return ( + + + + + + ); + })} + {ranking.length === 0 && ( + + + + )}
순위이름 (이메일)이름 해결한 문제 수정답률
1등 🥇김철수 (chulsoo@naver.com)15개92.5%
2등 🥈이영희 (younghee@gmail.com)12개85.0%
3등 🥉 (나){user.name} ({user.email}){history.filter(h => h.isSolved).length}개{summary?.averageCorrectRate || 0}%
4등박민수 (minsoo@daum.net)3개66.6%
{i + 1}등{medal}{r.userName}{isMe ? ' (나)' : ''}{r.solvedCount}개
+ 이번 주 랭킹 데이터가 존재하지 않습니다. +
@@ -1664,12 +1686,24 @@ export const SpacePage: React.FC = ({ user, space, onBack, showT
)} - {/* 평가 시험방 개설 모달 */} + {/* 평가 시험방 개설 모달 (2단계 마법사) */} {showCreateRoomModal && (
-
-

정기 평가 시험방 개설

+
+
+

정기 평가 시험방 개설

+
+ = 1 ? styles.wizardStepDotActive : {}) }}>1 + + = 2 ? styles.wizardStepDotActive : {}) }}>2 +
+
+

+ {roomWizardStep === 1 ? '1단계 · 기본 정보 및 응시 대상자' : '2단계 · 문제 출제 (수동 + AI 혼합 가능)'} +

+ {roomWizardStep === 1 && ( + <>
= ({ user, space, onBack, showT />
-
-
- - { - setRoomStart(e.target.value); - if (validationErrors.roomStart) { - setValidationErrors(prev => ({ ...prev, roomStart: '' })); - } - }} - style={{ - borderColor: validationErrors.roomStart ? '#ef4444' : undefined, - }} - /> - {validationErrors.roomStart && ( - {validationErrors.roomStart} + {/* 시험 일정 설정 */} +
+ +
+
+ +
+ 시작 + { + setRoomStart(e.target.value); + if (validationErrors.roomStart) { + setValidationErrors(prev => ({ ...prev, roomStart: '' })); + } + }} + /> +
+
+ +
+ +
+ 종료 + { + setRoomEnd(e.target.value); + if (validationErrors.roomEnd) { + setValidationErrors(prev => ({ ...prev, roomEnd: '' })); + } + }} + /> +
+
+ {examDurationLabel && ( + ⏱ {examDurationLabel} )}
-
- + {(validationErrors.roomStart || validationErrors.roomEnd) && ( + {validationErrors.roomStart || validationErrors.roomEnd} + )} +
+ + {/* 응시 대상자 다중 선택 */} +
+
+ + {selectedMembers.length}명 선택됨 +
+ +
{ - setRoomEnd(e.target.value); - if (validationErrors.roomEnd) { - setValidationErrors(prev => ({ ...prev, roomEnd: '' })); + placeholder="이름 또는 이메일로 검색" + style={styles.examineeSearchInput} + value={memberSearchQuery} + onChange={(e) => setMemberSearchQuery(e.target.value)} + /> +
-
- {/* 응시 대상자 다중 선택 */} -
- -
- {allUsersList.map(u => ( -
+ + )} - {/* 출제 문제 등록 */} + {roomWizardStep === 2 && ( + <> + {/* 출제 문제 등록 (수동 입력 + AI 자동 출제 혼합 가능) */}
- +
+ + {examProblemsList.length}문항 +
{examProblemsList.map((item, idx) => ( -
-

- 문항 {idx + 1} -

+
+
+

+ 문항 {idx + 1} +

+ {examProblemsList.length > 1 && ( + + )} +
+ = ({ user, space, onBack, showT
))} +
+ + +
+
+
+ + )} + +
+ {roomWizardStep === 2 && ( -
-
- -
- +
+ +
+
+ )} + + {/* 2단계 내 AI 자동 출제 모달 (시험방 문제) */} + {showRoomAiModal && ( +
+
+

AI 자동 문제 출제기

+
+
+ + + {validationErrors.roomAiCategory && ( + {validationErrors.roomAiCategory} + )} + {categories.length === 0 && ( + + 선택 가능한 카테고리가 없습니다. 슈퍼관리자에게 카테고리 생성을 요청해 주세요. + + )} +
+ +
+ +