From 64388f42c33785bc0b7a810fc084e816ae36df68 Mon Sep 17 00:00:00 2001 From: Junkov0 Date: Mon, 10 Aug 2026 15:51:51 +0900 Subject: [PATCH 1/3] feat: add SUPER_ADMIN endpoints to manage spaces and problems across all spaces Add force update/delete for spaces and full CRUD for problems that bypass per-space ADMIN ownership checks, scoped to SUPER_ADMIN only. --- .../api/superadmin/SuperAdminController.java | 72 +++++++++++++++++++ .../problem/repository/ProblemRepository.java | 12 ++++ .../problem/service/ProblemService.java | 21 ++++++ .../problem/service/ProblemServiceImpl.java | 36 ++++++++++ .../domain/space/service/SpaceService.java | 6 ++ .../space/service/SpaceServiceImpl.java | 29 ++++++++ 6 files changed, 176 insertions(+) diff --git a/momogo-api/src/main/java/com/momogo/api/superadmin/SuperAdminController.java b/momogo-api/src/main/java/com/momogo/api/superadmin/SuperAdminController.java index 33378ec..7f5747a 100644 --- a/momogo-api/src/main/java/com/momogo/api/superadmin/SuperAdminController.java +++ b/momogo-api/src/main/java/com/momogo/api/superadmin/SuperAdminController.java @@ -2,9 +2,16 @@ import com.momogo.core.domain.problem.dto.request.CategoryCreateRequest; import com.momogo.core.domain.problem.dto.request.CategoryUpdateRequest; +import com.momogo.core.domain.problem.dto.request.ProblemUpdateRequest; import com.momogo.core.domain.problem.dto.response.CategoryResponse; +import com.momogo.core.domain.problem.dto.response.ProblemDetailResponse; +import com.momogo.core.domain.problem.dto.response.ProblemResponse; import com.momogo.core.domain.problem.service.ProblemCategoryService; +import com.momogo.core.domain.problem.service.ProblemService; +import com.momogo.core.domain.space.dto.request.SpaceUpdateRequest; import com.momogo.core.domain.space.dto.response.SpaceResponse; +import com.momogo.core.domain.space.entity.Space; +import com.momogo.core.domain.space.mapper.SpaceMapper; import com.momogo.core.domain.space.service.SpaceService; import com.momogo.core.domain.user.dto.request.UserBannedRequest; import com.momogo.core.domain.user.dto.request.UserPageRequest; @@ -20,11 +27,13 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -36,8 +45,10 @@ public class SuperAdminController { private final SpaceService spaceService; + private final SpaceMapper spaceMapper; private final UserService userService; private final ProblemCategoryService problemCategoryService; + private final ProblemService problemService; /** * 전체 공간 조회 @@ -51,6 +62,67 @@ public ResponseEntity> getAllSpaces( return ResponseEntity.ok(spaceService.getAllSpaces(pageable)); } + /** + * 공간 강제 수정 (SUPER_ADMIN 전용) + * @param spaceId 공간 ID + * @param request 공간 수정 요청 DTO + * @return 수정된 공간 정보 + */ + @PutMapping("/spaces/{spaceId}") + public ResponseEntity forceUpdateSpace( + @PathVariable UUID spaceId, + @Valid @RequestBody SpaceUpdateRequest request + ) { + Space space = spaceService.forceUpdateSpace(spaceId, request); + return ResponseEntity.ok(spaceMapper.toResponse(space)); + } + + /** + * 공간 강제 폐쇄 (SUPER_ADMIN 전용) + * @param spaceId 공간 ID + */ + @DeleteMapping("/spaces/{spaceId}") + public ResponseEntity forceDeleteSpace(@PathVariable UUID spaceId) { + spaceService.forceDeleteSpace(spaceId); + return ResponseEntity.noContent().build(); + } + + /** + * 전체 문제 조회 (SUPER_ADMIN 전용, 공간 무관, 정답 포함) + * @param pageable 페이징 정보 + * @return 전체 문제 정보 + */ + @GetMapping("/problems") + public ResponseEntity> getAllProblems( + @PageableDefault(size = 10) Pageable pageable + ) { + return ResponseEntity.ok(problemService.getAllProblems(pageable)); + } + + /** + * 문제 강제 수정 (SUPER_ADMIN 전용) + * @param problemId 문제 ID + * @param request 문제 수정 요청 DTO + * @return 수정된 문제 정보 + */ + @PutMapping("/problems/{problemId}") + public ResponseEntity forceUpdateProblem( + @PathVariable UUID problemId, + @Valid @RequestBody ProblemUpdateRequest request + ) { + return ResponseEntity.ok(problemService.updateProblemAsSuperAdmin(problemId, request)); + } + + /** + * 문제 강제 삭제 (SUPER_ADMIN 전용) + * @param problemId 문제 ID + */ + @DeleteMapping("/problems/{problemId}") + public ResponseEntity forceDeleteProblem(@PathVariable UUID problemId) { + problemService.deleteProblemAsSuperAdmin(problemId); + return ResponseEntity.noContent().build(); + } + /** * 전체 유저 목록을 페이징(커서 기반) 조회합니다. (SUPER_ADMIN 전용) diff --git a/momogo-core/src/main/java/com/momogo/core/domain/problem/repository/ProblemRepository.java b/momogo-core/src/main/java/com/momogo/core/domain/problem/repository/ProblemRepository.java index d60dee9..e604cae 100644 --- a/momogo-core/src/main/java/com/momogo/core/domain/problem/repository/ProblemRepository.java +++ b/momogo-core/src/main/java/com/momogo/core/domain/problem/repository/ProblemRepository.java @@ -3,6 +3,8 @@ import com.momogo.core.domain.problem.entity.Problem; import java.util.Optional; import java.util.UUID; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -19,6 +21,16 @@ public interface ProblemRepository extends JpaRepository, Problem @Query("SELECT p FROM Problem p JOIN FETCH p.category JOIN FETCH p.space WHERE p.id = :id") Optional findByIdWithCategory(@Param("id") UUID id); + /** + * 전체 문제 목록 조회 (SUPER_ADMIN 전용, 공간 무관) + * - 카테고리/공간을 JOIN FETCH 하여 N+1 방지 + */ + @Query( + value = "SELECT p FROM Problem p JOIN FETCH p.category JOIN FETCH p.space", + countQuery = "SELECT count(p) FROM Problem p" + ) + Page findAllWithCategory(Pageable pageable); + /** * 카테고리 삭제 전 확인 메서드 * - 카테고리 삭제 전 관련되어 있는 문제가 남아 있으면 diff --git a/momogo-core/src/main/java/com/momogo/core/domain/problem/service/ProblemService.java b/momogo-core/src/main/java/com/momogo/core/domain/problem/service/ProblemService.java index 24568b0..80a760c 100644 --- a/momogo-core/src/main/java/com/momogo/core/domain/problem/service/ProblemService.java +++ b/momogo-core/src/main/java/com/momogo/core/domain/problem/service/ProblemService.java @@ -11,6 +11,8 @@ import java.time.OffsetDateTime; import java.util.List; import java.util.UUID; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; public interface ProblemService { @@ -77,4 +79,23 @@ ProblemCursorResponse getProblems( * @param request AI 문제 자동 생성 요청 DTO */ List createProblemsByAi(UUID spaceId, UUID idempotencyKey, ProblemAiCreateRequest request); + + /** + * 전체 문제 목록 조회 (SUPER_ADMIN 전용, 공간 무관, 정답 포함) + * @param pageable 페이징 정보 + */ + Page getAllProblems(Pageable pageable); + + /** + * 문제 강제 수정 (SUPER_ADMIN 전용, 공간 소속 ADMIN 검증 없이 임의 문제 수정) + * @param problemId 문제 ID + * @param request 문제 수정 DTO + */ + ProblemResponse updateProblemAsSuperAdmin(UUID problemId, ProblemUpdateRequest request); + + /** + * 문제 강제 삭제 (SUPER_ADMIN 전용, 공간 소속 ADMIN 검증 없이 임의 문제 삭제) + * @param problemId 문제 ID + */ + void deleteProblemAsSuperAdmin(UUID problemId); } 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 c15dd7b..9ab1eff 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 @@ -31,6 +31,8 @@ import java.util.UUID; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.script.RedisScript; import org.springframework.stereotype.Service; @@ -347,4 +349,38 @@ public List createProblemsByAi(UUID spaceId, UUID idempotencyKe redisTemplate.execute(UNLOCK_SCRIPT, List.of(lockKey), ownerToken); } } + + /** + * 전체 문제 목록 조회 (SUPER_ADMIN 전용, 공간 무관, 정답 포함) + */ + @Override + public Page getAllProblems(Pageable pageable) { + return problemRepository.findAllWithCategory(pageable).map(problemMapper::toDetailResponse); + } + + /** + * 문제 강제 수정 (SUPER_ADMIN 전용) + * - 문제가 소속된 공간을 조회한 뒤 기존 updateProblem 검증/수정 로직에 위임한다. + */ + @Override + @Transactional + public ProblemResponse updateProblemAsSuperAdmin(UUID problemId, ProblemUpdateRequest request) { + Problem problem = problemRepository.findById(problemId) + .orElseThrow(() -> new BusinessException(ProblemErrorCode.PROBLEM_NOT_FOUND)); + + return updateProblem(problem.getSpace().getId(), problemId, request); + } + + /** + * 문제 강제 삭제 (SUPER_ADMIN 전용) + * - 문제가 소속된 공간을 조회한 뒤 기존 deleteProblem 검증/삭제 로직에 위임한다. + */ + @Override + @Transactional + public void deleteProblemAsSuperAdmin(UUID problemId) { + Problem problem = problemRepository.findById(problemId) + .orElseThrow(() -> new BusinessException(ProblemErrorCode.PROBLEM_NOT_FOUND)); + + deleteProblem(problem.getSpace().getId(), problemId); + } } diff --git a/momogo-core/src/main/java/com/momogo/core/domain/space/service/SpaceService.java b/momogo-core/src/main/java/com/momogo/core/domain/space/service/SpaceService.java index 4cd21ae..195134e 100644 --- a/momogo-core/src/main/java/com/momogo/core/domain/space/service/SpaceService.java +++ b/momogo-core/src/main/java/com/momogo/core/domain/space/service/SpaceService.java @@ -37,4 +37,10 @@ public interface SpaceService { // 전체 공간 조회 (SUPER_ADMIN 권한 검증) Page getAllSpaces(Pageable pageable); + + // 공간 강제 수정 (SUPER_ADMIN 전용, 소유 ADMIN 검증 없이 임의 공간 수정) + Space forceUpdateSpace(UUID spaceId, SpaceUpdateRequest request); + + // 공간 강제 폐쇄 (SUPER_ADMIN 전용, 소유 ADMIN 검증 없이 임의 공간 삭제) + void forceDeleteSpace(UUID spaceId); } diff --git a/momogo-core/src/main/java/com/momogo/core/domain/space/service/SpaceServiceImpl.java b/momogo-core/src/main/java/com/momogo/core/domain/space/service/SpaceServiceImpl.java index 82db64f..167e350 100644 --- a/momogo-core/src/main/java/com/momogo/core/domain/space/service/SpaceServiceImpl.java +++ b/momogo-core/src/main/java/com/momogo/core/domain/space/service/SpaceServiceImpl.java @@ -200,6 +200,35 @@ public Page getAllSpaces(Pageable pageable) { return spaceRepository.findAll(pageable).map(spaceMapper::toResponse); } + @Override + @Transactional + public Space forceUpdateSpace(UUID spaceId, SpaceUpdateRequest request) { + + Space space = spaceRepository.findById(spaceId) + .orElseThrow(() -> new BusinessException(SpaceErrorCode.SPACE_NOT_FOUND)); + + spaceMapper.updateFromDto(request, space); // MapStruct 더티 체킹 + + if (request.spacePassword() != null && !request.spacePassword().isBlank()) { + space.updateSpacePassword(passwordEncryptor.encrypt(request.spacePassword())); + } + + return space; + } + + @Override + @Transactional + public void forceDeleteSpace(UUID spaceId) { + + Space space = spaceRepository.findById(spaceId) + .orElseThrow(() -> new BusinessException(SpaceErrorCode.SPACE_NOT_FOUND)); + + // 벌크 쿼리로 공간 소속 유저 탈퇴 처리 + userRepository.bulkLeaveSpace(spaceId, UserRole.USER); + + spaceRepository.delete(space); + } + // 공통 헬퍼 메소드: 사용자 조회 및 공간 관리자 권한 검증 private User validateAndGetSpaceAdmin(UUID userId, UUID spaceId) { User user = userRepository.findById(userId) From 108bb6aad58a035406212d469e01bf5f311c736c Mon Sep 17 00:00:00 2001 From: Junkov0 Date: Mon, 10 Aug 2026 15:52:02 +0900 Subject: [PATCH 2/3] feat: connect super-admin console to backend and fix notification confirm Remove the mock interceptor for /api/super-admin/spaces and /problems so the console hits the real API, paginate through all results, and drop the now-stale demo-data banners. Add the notification bell to the space problem-bank page, surface confirm failures as a toast instead of only logging them, and filter already-confirmed notifications out of the list so confirmed items don't reappear. --- momogo-frontend/src/pages/DashboardPage.tsx | 43 ++--- momogo-frontend/src/pages/SpacePage.tsx | 166 +++++++++++++++++++- momogo-frontend/src/services/api.ts | 66 -------- 3 files changed, 187 insertions(+), 88 deletions(-) diff --git a/momogo-frontend/src/pages/DashboardPage.tsx b/momogo-frontend/src/pages/DashboardPage.tsx index bc73c7a..5b6a898 100644 --- a/momogo-frontend/src/pages/DashboardPage.tsx +++ b/momogo-frontend/src/pages/DashboardPage.tsx @@ -249,10 +249,27 @@ export const DashboardPage: React.FC = ({ user, initialTab, setShowConfirmModal(true); }; + // 백엔드가 Page 응답(기본 size=10)을 주기 때문에, 관리자 콘솔의 "전체 목록" 요건을 맞추려면 + // last=true가 될 때까지 모든 페이지를 순회하여 합쳐야 한다. + const loadAllPages = async (path: string): Promise => { + let page = 0; + let all: any[] = []; + while (true) { + const data = await request<{ content: any[]; last: boolean }>(path, { + method: 'GET', + params: { page: String(page), size: '100' } + }); + if (!data) break; + all = all.concat(data.content); + if (data.last) break; + page += 1; + } + return all; + }; + const loadSuperAdminSpaces = async () => { try { - const data = await request('/api/super-admin/spaces', { method: 'GET' }); - if (data) setSuperSpaces(data); + setSuperSpaces(await loadAllPages('/api/super-admin/spaces')); } catch (err: any) { showToast(err.message || '공간 목록 로드 실패', 'error'); } @@ -260,8 +277,7 @@ export const DashboardPage: React.FC = ({ user, initialTab, const loadSuperAdminProblems = async () => { try { - const data = await request('/api/super-admin/problems', { method: 'GET' }); - if (data) setSuperProblems(data); + setSuperProblems(await loadAllPages('/api/super-admin/problems')); } catch (err: any) { showToast(err.message || '문제 목록 로드 실패', 'error'); } @@ -468,7 +484,8 @@ export const DashboardPage: React.FC = ({ user, initialTab, // 2. 알림 조회 try { const notiData = await request('/api/notifications', { method: 'GET' }); - setNotifications(notiData?.data || []); + // GET은 확인 완료된 알림까지 전부 내려주므로, 미확인 알림만 목록에 남긴다. + setNotifications((notiData?.data || []).filter((n: NotificationItem) => !n.isConfirmed)); } catch (err) { console.error('Failed to load notifications', err); } @@ -840,6 +857,7 @@ export const DashboardPage: React.FC = ({ user, initialTab, setNotifications(prev => prev.filter(n => n.id !== id)); } catch (err: any) { console.error('알림 읽음 처리 실패:', err.message); + showToast(err.message || '알림 확인 처리에 실패했습니다.', 'error'); } }; @@ -1639,7 +1657,6 @@ export const DashboardPage: React.FC = ({ user, initialTab,

서비스 개설 공간 전체 목록 - 데모 데이터 (백엔드 미연동)

@@ -1697,7 +1714,6 @@ export const DashboardPage: React.FC = ({ user, initialTab,

전체 출제 문제 목록 - 데모 데이터 (백엔드 미연동)

@@ -1715,7 +1731,7 @@ export const DashboardPage: React.FC = ({ user, initialTab, @@ -2628,17 +2644,6 @@ const styles: Record = { fontFamily: "'Pretendard', -apple-system, BlinkMacSystemFont, system-ui, Roboto, sans-serif", letterSpacing: '-0.025em', }, - demoBadge: { - marginLeft: '0.5rem', - padding: '0.15rem 0.5rem', - borderRadius: '999px', - fontSize: '0.7rem', - fontWeight: 700, - color: '#b45309', - backgroundColor: '#fef3c7', - border: '1px solid #fde68a', - verticalAlign: 'middle', - }, tableWrapper: { width: '100%', overflowX: 'auto', diff --git a/momogo-frontend/src/pages/SpacePage.tsx b/momogo-frontend/src/pages/SpacePage.tsx index 8dd2acd..735f552 100644 --- a/momogo-frontend/src/pages/SpacePage.tsx +++ b/momogo-frontend/src/pages/SpacePage.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useState, useRef } from 'react'; import type { UserResponse } from '../types/user'; import type { SpaceResponse } from '../types/space'; -import { request, getAccessToken } from '../services/api'; +import { request, getAccessToken, connectNotificationSse } from '../services/api'; interface SpacePageProps { user: UserResponse; @@ -17,6 +17,15 @@ interface CategoryResponse { name: string; } +interface NotificationItem { + id: string; + title: string; + content: string; + type: string; + isConfirmed: boolean; + createdAt: string; +} + interface ProblemResponse { id: string; name: string; @@ -231,6 +240,8 @@ export const SpacePage: React.FC = ({ user, space, onBack, showT const [allUsersList, setAllUsersList] = useState([]); const [showUserMenu, setShowUserMenu] = useState(false); + const [notifications, setNotifications] = useState([]); + const [showNotifications, setShowNotifications] = useState(false); // 2-1. 실시간 대기실 / 시험 모드 관련 상태 const [currentExamRoom, setCurrentExamRoom] = useState(null); @@ -268,6 +279,46 @@ export const SpacePage: React.FC = ({ user, space, onBack, showT loadMembersForInvitation(); }, [selectedCategory, activeTab]); + // 알림 목록 최초 조회 + useEffect(() => { + const loadNotifications = async () => { + try { + const notiData = await request('/api/notifications', { method: 'GET' }); + // GET은 확인 완료된 알림까지 전부 내려주므로, 미확인 알림만 목록에 남긴다. + setNotifications((notiData?.data || []).filter((n: NotificationItem) => !n.isConfirmed)); + } catch (err) { + console.error('Failed to load notifications', err); + } + }; + loadNotifications(); + }, []); + + // 알림 실시간 수신(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 handleConfirmNotification = async (id: string) => { + try { + await request(`/api/notifications/${id}/confirm`, { + method: 'PATCH', + }); + setNotifications(prev => prev.filter(n => n.id !== id)); + } catch (err: any) { + console.error('알림 읽음 처리 실패:', err.message); + showToast(err.message || '알림 확인 처리에 실패했습니다.', 'error'); + } + }; + // 대기실 또는 시험 중일 때의 타이머 처리 useEffect(() => { if (examMode === 'testing' && timeLeft > 0) { @@ -984,6 +1035,8 @@ export const SpacePage: React.FC = ({ user, space, onBack, showT return `${hours}시간 ${minutes}분`; })(); + const unreadNotiCount = notifications.filter(n => !n.isConfirmed).length; + return (
{/* 사이드바 메뉴 */} @@ -1039,8 +1092,17 @@ export const SpacePage: React.FC = ({ user, space, onBack, showT

{space.description}

+ +
-
= ({ user, space, onBack, showT cursor: 'pointer', boxShadow: '0 1px 3px rgba(16, 24, 40, 0.05)', }} - onClick={() => setShowUserMenu(!showUserMenu)} + onClick={() => { setShowUserMenu(!showUserMenu); setShowNotifications(false); }} > = ({ user, space, onBack, showT
+ {/* 실시간 알림 팝오버 */} + {showNotifications && ( +
+

수신된 최근 알림

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

수신된 새 알림이 없습니다.

+ ) : ( +
+ {notifications.map(noti => ( +
+

{noti.content}

+ +
+ ))} +
+ )} +
+ )} + {/* 싱글 문제은행 탭 */} {activeTab === 'problems' && (
@@ -2449,6 +2536,79 @@ export const SpacePage: React.FC = ({ user, space, onBack, showT }; const styles: Record = { + notiTriggerBtn: { + position: 'relative', + paddingRight: '2rem', + }, + notiCountBadge: { + position: 'absolute', + top: '50%', + right: '0.5rem', + transform: 'translateY(-50%)', + backgroundColor: '#ef4444', + color: '#ffffff', + fontSize: '0.7rem', + fontWeight: 700, + borderRadius: '50%', + width: '18px', + height: '18px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, + notiPopover: { + position: 'absolute', + top: '5rem', + right: '3rem', + width: '320px', + zIndex: 100, + padding: '1rem', + backgroundColor: '#ffffff', + borderRadius: '12px', + boxShadow: '0 10px 15px -3px rgba(16, 24, 40, 0.08)', + }, + notiPopoverTitle: { + fontSize: '0.875rem', + fontWeight: 700, + color: '#1d2939', + marginBottom: '0.75rem', + borderBottom: '1px solid #eaecf0', + paddingBottom: '0.5rem', + }, + notiEmpty: { + fontSize: '0.825rem', + color: '#98a2b3', + textAlign: 'center', + padding: '1rem 0', + }, + notiList: { + display: 'flex', + flexDirection: 'column', + gap: '0.5rem', + maxHeight: '200px', + overflowY: 'auto', + }, + notiItem: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + gap: '0.5rem', + padding: '0.5rem 0', + borderBottom: '1px solid #f2f4f7', + }, + notiText: { + fontSize: '0.775rem', + color: '#475467', + flex: 1, + lineHeight: 1.4, + }, + notiConfirmBtn: { + fontSize: '0.7rem', + padding: '0.25rem 0.5rem', + backgroundColor: '#f2f4f7', + border: 'none', + color: '#475467', + }, sidebarTitle: { fontSize: '1.75rem', fontWeight: 800, diff --git a/momogo-frontend/src/services/api.ts b/momogo-frontend/src/services/api.ts index dd570f7..b98d010 100644 --- a/momogo-frontend/src/services/api.ts +++ b/momogo-frontend/src/services/api.ts @@ -75,72 +75,6 @@ export async function request(path: string, options: RequestOptions = {}): Pr let resolvedPath = path; - // 모의 응답 인터셉터 (Mock Interceptor) - const lowerPath = resolvedPath.toLowerCase(); - - // C. 슈퍼 관리자용 모의 처리 (공간 및 문제 관리) - if (lowerPath.startsWith('/api/super-admin/spaces')) { - let superSpaces = JSON.parse(localStorage.getItem('momogo_super_spaces') || '[]'); - if (superSpaces.length === 0) { - superSpaces = [ - { id: 'space-1', name: '마포고 수학 문제은행', description: '마포고 학생들을 위한 수학 퀴즈방', profileImageUrl: null, createdAt: new Date().toISOString() }, - { id: 'space-2', name: '수능 영어 1등급 정복', description: 'EBS 연계 교재 및 기출문제 모음', profileImageUrl: null, createdAt: new Date().toISOString() } - ]; - localStorage.setItem('momogo_super_spaces', JSON.stringify(superSpaces)); - } - - const method = options.method?.toUpperCase() || 'GET'; - if (method === 'GET') { - return superSpaces as unknown as T; - } - - if (method === 'PUT' || method === 'PATCH') { - const spaceId = resolvedPath.split('/').pop(); - const body = JSON.parse(options.body as string); - superSpaces = superSpaces.map((s: any) => s.id === spaceId ? { ...s, ...body } : s); - localStorage.setItem('momogo_super_spaces', JSON.stringify(superSpaces)); - return { id: spaceId, ...body } as unknown as T; - } - - if (method === 'DELETE') { - const spaceId = resolvedPath.split('/').pop(); - superSpaces = superSpaces.filter((s: any) => s.id !== spaceId); - localStorage.setItem('momogo_super_spaces', JSON.stringify(superSpaces)); - return null as unknown as T; - } - } - - if (lowerPath.startsWith('/api/super-admin/problems')) { - let superProblems = JSON.parse(localStorage.getItem('momogo_super_problems') || '[]'); - if (superProblems.length === 0) { - superProblems = [ - { id: 'prob-1', name: '미적분 기초 계산', content: 'f(x) = x^2 일 때 f\'(3)의 값은?', correctAnswer: '6', explanation: '도함수는 2x이므로 3을 대입하면 6입니다.', category: { id: 'cat-1', name: '수학' } }, - { id: 'prob-2', name: '영어 빈칸 추론', content: '다음 빈칸에 가장 알맞은 단어는? "Actions speak louder than _______."', correctAnswer: 'words', explanation: '말보다 행동이 중요하다는 뜻의 속담입니다.', category: { id: 'cat-2', name: '영어' } } - ]; - localStorage.setItem('momogo_super_problems', JSON.stringify(superProblems)); - } - - const method = options.method?.toUpperCase() || 'GET'; - if (method === 'GET') { - return superProblems as unknown as T; - } - - if (method === 'PUT' || method === 'PATCH') { - const problemId = resolvedPath.split('/').pop(); - const body = JSON.parse(options.body as string); - superProblems = superProblems.map((p: any) => p.id === problemId ? { ...p, ...body } : p); - localStorage.setItem('momogo_super_problems', JSON.stringify(superProblems)); - return { id: problemId, ...body } as unknown as T; - } - - if (method === 'DELETE') { - const problemId = resolvedPath.split('/').pop(); - superProblems = superProblems.filter((p: any) => p.id !== problemId); - localStorage.setItem('momogo_super_problems', JSON.stringify(superProblems)); - return null as unknown as T; - } - } - // URL 파라미터 조립 (ISO-8601 커서 등 + 기호가 포함된 파라미터의 %2B 인코딩 보장) let url = resolvedPath; if (params) { From 6ebef408814cacf0a060627d3d6420940a8220f1 Mon Sep 17 00:00:00 2001 From: Junkov0 Date: Mon, 10 Aug 2026 16:00:34 +0900 Subject: [PATCH 3/3] fix: open notification SSE before fetching the initial list Connect the SSE stream first and only fetch GET /api/notifications after the server's connect event is received, so notifications created between the list fetch and the SSE connection are no longer lost. connectNotificationSse now exposes the connect event's id via an onConnect callback. --- .../service/NotificationSseServiceImpl.java | 4 ++- momogo-frontend/src/pages/DashboardPage.tsx | 30 +++++++++++-------- momogo-frontend/src/pages/SpacePage.tsx | 11 +++---- momogo-frontend/src/services/api.ts | 9 +++++- 4 files changed, 35 insertions(+), 19 deletions(-) diff --git a/momogo-api/src/main/java/com/momogo/api/notification/service/NotificationSseServiceImpl.java b/momogo-api/src/main/java/com/momogo/api/notification/service/NotificationSseServiceImpl.java index 219d0ef..9c0ee64 100644 --- a/momogo-api/src/main/java/com/momogo/api/notification/service/NotificationSseServiceImpl.java +++ b/momogo-api/src/main/java/com/momogo/api/notification/service/NotificationSseServiceImpl.java @@ -46,8 +46,10 @@ public SseEmitter connect(UUID userId) { // 연결 직후 더미 이벤트를 하나 보내는 이유: // 클라이언트가 아무 데이터도 안 보내고 가만히 있으면 연결이 대기상태로 오해받아 타임아웃/503이 날 수 있음 + // id를 함께 실어 보내는 이유: 클라이언트가 이 이벤트를 "연결이 실제로 확립된 시점"의 기준점으로 삼아, + // 그 이후에 초기 목록(GET)을 조회하도록 순서를 맞추기 위함 (목록 조회~SSE 연결 사이 알림 유실 방지) try { - emitter.send(SseEmitter.event().name("connect").data("connected")); + emitter.send(SseEmitter.event().name("connect").id(UUID.randomUUID().toString()).data("connected")); } catch (IOException e) { // 이 시점 실패는 서블릿 컨테이너의 오류 디스패치로 정리되므로 로그만 남김 log.warn("SSE 초기 연결 이벤트 전송 실패 - userId: {}", userId, e); diff --git a/momogo-frontend/src/pages/DashboardPage.tsx b/momogo-frontend/src/pages/DashboardPage.tsx index 5b6a898..43f2e09 100644 --- a/momogo-frontend/src/pages/DashboardPage.tsx +++ b/momogo-frontend/src/pages/DashboardPage.tsx @@ -453,9 +453,24 @@ export const DashboardPage: React.FC = ({ user, initialTab, loadInitialData(); }, []); - // 알림 실시간 수신(SSE) 연결 - 신규 알림 발생 시 목록 맨 앞에 추가 + // 알림 실시간 수신(SSE) 연결 + // SSE 연결이 확립된 뒤에 초기 목록(GET)을 조회해야, "목록 조회~SSE 연결" 사이에 발생한 + // 알림을 놓치지 않는다. 재연결 시에도 다시 확립 시점 기준으로 목록을 새로 맞춘다. useEffect(() => { + const loadNotifications = async () => { + try { + const notiData = await request('/api/notifications', { method: 'GET' }); + // GET은 확인 완료된 알림까지 전부 내려주므로, 미확인 알림만 목록에 남긴다. + setNotifications((notiData?.data || []).filter((n: NotificationItem) => !n.isConfirmed)); + } catch (err) { + console.error('Failed to load notifications', err); + } + }; + const disconnect = connectNotificationSse({ + onConnect: () => { + loadNotifications(); + }, onNotification: (noti: NotificationItem) => { setNotifications(prev => (prev.some(n => n.id === noti.id) ? prev : [noti, ...prev])); }, @@ -481,22 +496,13 @@ export const DashboardPage: React.FC = ({ user, initialTab, console.error('대시보드 요약 조회 실패', err); } - // 2. 알림 조회 - try { - const notiData = await request('/api/notifications', { method: 'GET' }); - // GET은 확인 완료된 알림까지 전부 내려주므로, 미확인 알림만 목록에 남긴다. - setNotifications((notiData?.data || []).filter((n: NotificationItem) => !n.isConfirmed)); - } catch (err) { - console.error('Failed to load notifications', err); - } - - // 3. 미가입 공간 조회 + // 2. 미가입 공간 조회 const unjoinedData = await request<{ values: SpaceResponse[] }>('/api/spaces/unjoined', { method: 'GET' }); if (unjoinedData && unjoinedData.values) { setUnjoinedSpaces(unjoinedData.values); } - // 4. 슈퍼관리자 권한인 경우 관련 데이터 미리 조회 + // 3. 슈퍼관리자 권한인 경우 관련 데이터 미리 조회 if (user.role === 'SUPER_ADMIN') { await loadSuperAdminUsers(null, null); await loadSuperAdminCategories(); diff --git a/momogo-frontend/src/pages/SpacePage.tsx b/momogo-frontend/src/pages/SpacePage.tsx index 735f552..9ed286f 100644 --- a/momogo-frontend/src/pages/SpacePage.tsx +++ b/momogo-frontend/src/pages/SpacePage.tsx @@ -279,7 +279,9 @@ export const SpacePage: React.FC = ({ user, space, onBack, showT loadMembersForInvitation(); }, [selectedCategory, activeTab]); - // 알림 목록 최초 조회 + // 알림 실시간 수신(SSE) 연결 + // SSE 연결이 확립된 뒤에 초기 목록(GET)을 조회해야, "목록 조회~SSE 연결" 사이에 발생한 + // 알림을 놓치지 않는다. 재연결 시에도 다시 확립 시점 기준으로 목록을 새로 맞춘다. useEffect(() => { const loadNotifications = async () => { try { @@ -290,12 +292,11 @@ export const SpacePage: React.FC = ({ user, space, onBack, showT console.error('Failed to load notifications', err); } }; - loadNotifications(); - }, []); - // 알림 실시간 수신(SSE) 연결 - 신규 알림 발생 시 목록 맨 앞에 추가 - useEffect(() => { const disconnect = connectNotificationSse({ + onConnect: () => { + loadNotifications(); + }, onNotification: (noti: NotificationItem) => { setNotifications(prev => (prev.some(n => n.id === noti.id) ? prev : [noti, ...prev])); }, diff --git a/momogo-frontend/src/services/api.ts b/momogo-frontend/src/services/api.ts index b98d010..1c815e6 100644 --- a/momogo-frontend/src/services/api.ts +++ b/momogo-frontend/src/services/api.ts @@ -168,6 +168,10 @@ export async function request(path: string, options: RequestOptions = {}): Pr export interface NotificationSseHandlers { onNotification: (data: any) => void; + // SSE 연결이 실제로 확립된 시점(서버의 최초 connect 이벤트 수신)에 호출된다. + // 이 콜백을 받은 뒤에 초기 목록(GET)을 조회해야, "목록 조회~SSE 연결" 사이에 발생한 + // 알림을 놓치지 않는다. eventId는 서버가 connect 이벤트에 실어 보낸 SSE id 필드 값. + onConnect?: (eventId: string | null) => void; onError?: (err: unknown) => void; } @@ -223,13 +227,16 @@ export function connectNotificationSse(handlers: NotificationSseHandlers): () => buffer = buffer.slice(sepIndex + 2); const eventName = /^event:\s*(.+)$/m.exec(rawEvent)?.[1]?.trim() || 'message'; + const eventId = /^id:\s*(.+)$/m.exec(rawEvent)?.[1]?.trim() ?? null; const dataStr = rawEvent .split('\n') .filter(line => line.startsWith('data:')) .map(line => line.slice(5).trim()) .join('\n'); - if (eventName === 'notifications' && dataStr) { + if (eventName === 'connect') { + handlers.onConnect?.(eventId); + } else if (eventName === 'notifications' && dataStr) { try { handlers.onNotification(JSON.parse(dataStr)); } catch (e) {
{p.name} - {p.category?.name || '미분류'} + {p.categoryName || '미분류'} {p.correctAnswer} {p.content}