From 75be83885a288079a74b89377589cc307dd71fc3 Mon Sep 17 00:00:00 2001 From: Junkov0 Date: Sun, 26 Jul 2026 16:15:00 +0900 Subject: [PATCH] feat: front-end or infra configuration --- .../momogo/api/problem/ProblemController.java | 6 +- momogo-core/build.gradle | 4 + .../common/storage/LocalStorageService.java | 2 + .../core/common/storage/S3StorageService.java | 87 +++++++++++++++++++ .../problem/dto/response/ProblemResponse.java | 3 +- .../domain/problem/mapper/ProblemMapper.java | 2 + .../problem/service/ProblemService.java | 4 +- .../problem/service/ProblemServiceImpl.java | 18 +++- .../repository/UserProblemRepository.java | 5 ++ momogo-frontend/package-lock.json | 23 +++++ momogo-frontend/src/pages/DashboardPage.tsx | 13 +++ momogo-frontend/src/pages/SpacePage.tsx | 65 ++++++++++---- momogo-frontend/src/styles/theme.css | 38 ++++++++ .../config/RealtimeFallbackConfig.java | 37 ++++++++ 14 files changed, 284 insertions(+), 23 deletions(-) create mode 100644 momogo-core/src/main/java/com/momogo/core/common/storage/S3StorageService.java create mode 100644 momogo-realtime/src/main/java/com/momogo/realtime/config/RealtimeFallbackConfig.java diff --git a/momogo-api/src/main/java/com/momogo/api/problem/ProblemController.java b/momogo-api/src/main/java/com/momogo/api/problem/ProblemController.java index a179943..71d2538 100644 --- a/momogo-api/src/main/java/com/momogo/api/problem/ProblemController.java +++ b/momogo-api/src/main/java/com/momogo/api/problem/ProblemController.java @@ -54,7 +54,8 @@ public ResponseEntity createProblem( @GetMapping public ResponseEntity getProblems( @PathVariable UUID spaceId, - @Valid ProblemSearchRequest request) { + @Valid ProblemSearchRequest request, + @AuthenticationPrincipal(expression = "userResponse.id") UUID userId) { return ResponseEntity.ok( problemService.getProblems( @@ -64,7 +65,8 @@ public ResponseEntity getProblems( request.contentKeyword(), request.cursor(), request.cursorId(), - request.size() + request.size(), + userId ) ); } diff --git a/momogo-core/build.gradle b/momogo-core/build.gradle index 41fe935..eb8815d 100644 --- a/momogo-core/build.gradle +++ b/momogo-core/build.gradle @@ -26,6 +26,10 @@ dependencies { // PDF 라이브러리 implementation 'com.github.librepdf:openpdf:2.2.2' + // S3 프로필 이미지 저장용 + implementation platform('software.amazon.awssdk:bom:2.29.52') + implementation 'software.amazon.awssdk:s3' + // 인프라 테스트용 Testcontainers testImplementation 'org.springframework.boot:spring-boot-testcontainers' testImplementation 'org.testcontainers:junit-jupiter' diff --git a/momogo-core/src/main/java/com/momogo/core/common/storage/LocalStorageService.java b/momogo-core/src/main/java/com/momogo/core/common/storage/LocalStorageService.java index a04ca89..9ca610e 100644 --- a/momogo-core/src/main/java/com/momogo/core/common/storage/LocalStorageService.java +++ b/momogo-core/src/main/java/com/momogo/core/common/storage/LocalStorageService.java @@ -6,6 +6,7 @@ import com.momogo.core.common.util.UrlUtils; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Service; import java.io.IOException; @@ -18,6 +19,7 @@ @Slf4j @Service +@ConditionalOnProperty(name = "app.storage.type", havingValue = "local", matchIfMissing = true) public class LocalStorageService implements StorageService { private final ImageFileValidator imageFileValidator; diff --git a/momogo-core/src/main/java/com/momogo/core/common/storage/S3StorageService.java b/momogo-core/src/main/java/com/momogo/core/common/storage/S3StorageService.java new file mode 100644 index 0000000..31b3bc3 --- /dev/null +++ b/momogo-core/src/main/java/com/momogo/core/common/storage/S3StorageService.java @@ -0,0 +1,87 @@ +package com.momogo.core.common.storage; + +import com.momogo.core.common.exception.BusinessException; +import com.momogo.core.common.exception.GlobalErrorCode; +import com.momogo.core.common.util.ImageFileValidator; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +import java.io.IOException; +import java.io.InputStream; +import java.util.UUID; + +@Slf4j +@Service +@ConditionalOnProperty(name = "app.storage.type", havingValue = "s3") +public class S3StorageService implements StorageService { + + private final ImageFileValidator imageFileValidator; + private final S3Client s3Client; + private final String bucket; + + public S3StorageService( + ImageFileValidator imageFileValidator, + @Value("${app.aws.s3-bucket}") String bucket, + @Value("${app.aws.region:ap-northeast-2}") String region + ) { + this.imageFileValidator = imageFileValidator; + this.bucket = bucket; + this.s3Client = S3Client.builder().region(Region.of(region)).build(); + log.info("[S3StorageService] 버킷 지정 완료: {}", bucket); + } + + @Override + public String upload(InputStream inputStream, String originalFileName, String contentType, String directory) { + InputStream validatedStream = imageFileValidator.validateImage(inputStream, originalFileName, contentType); + + String extension = ""; + if (originalFileName != null && originalFileName.contains(".")) { + extension = originalFileName.substring(originalFileName.lastIndexOf(".")); + } + + String savedFileName = UUID.randomUUID() + extension; + String key = directory + "/" + savedFileName; + + try { + byte[] bytes = validatedStream.readAllBytes(); + s3Client.putObject( + PutObjectRequest.builder() + .bucket(bucket) + .key(key) + .contentType(contentType) + .build(), + RequestBody.fromBytes(bytes) + ); + return savedFileName; + } catch (IOException e) { + log.error("[S3StorageService] 파일 업로드 실패 - originalFileName: {}, directory: {}", originalFileName, directory, e); + throw new BusinessException( + GlobalErrorCode.FILE_UPLOAD_FAILED, + "파일 저장 중 시스템 오류가 발생했습니다.", + e.getMessage() + ); + } + } + + @Override + public void delete(String fileUrl) { + if (fileUrl == null || fileUrl.isBlank()) { + return; + } + + s3Client.deleteObject( + DeleteObjectRequest.builder() + .bucket(bucket) + .key(fileUrl) + .build() + ); + log.info("[S3StorageService] 객체 삭제 완료: {}", fileUrl); + } +} diff --git a/momogo-core/src/main/java/com/momogo/core/domain/problem/dto/response/ProblemResponse.java b/momogo-core/src/main/java/com/momogo/core/domain/problem/dto/response/ProblemResponse.java index 969608c..b1d3fa0 100644 --- a/momogo-core/src/main/java/com/momogo/core/domain/problem/dto/response/ProblemResponse.java +++ b/momogo-core/src/main/java/com/momogo/core/domain/problem/dto/response/ProblemResponse.java @@ -17,5 +17,6 @@ public record ProblemResponse( String content, String explanation, OffsetDateTime createdAt, - OffsetDateTime updatedAt + OffsetDateTime updatedAt, + boolean isSolved ) {} diff --git a/momogo-core/src/main/java/com/momogo/core/domain/problem/mapper/ProblemMapper.java b/momogo-core/src/main/java/com/momogo/core/domain/problem/mapper/ProblemMapper.java index 209d5c8..d0646aa 100644 --- a/momogo-core/src/main/java/com/momogo/core/domain/problem/mapper/ProblemMapper.java +++ b/momogo-core/src/main/java/com/momogo/core/domain/problem/mapper/ProblemMapper.java @@ -14,9 +14,11 @@ public interface ProblemMapper { // Problem → ProblemResponse 변환 + // isSolved는 Problem 엔티티에 없는 값(유저별 풀이 이력)이라 서비스 레이어에서 별도로 채워 넣는다. @Mapping(source = "space.id", target = "spaceId") @Mapping(source = "category.id", target = "categoryId") @Mapping(source = "category.name", target = "categoryName") + @Mapping(target = "isSolved", ignore = true) ProblemResponse toResponse(Problem problem); // List → List 변환 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 607af27..9b86ade 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 @@ -30,6 +30,7 @@ public interface ProblemService { * @param cursor 다음 커서 일자 * @param cursorId 다음 커서 ID (서브) * @param size 조회하고자 하는 사이즈 + * @param userId 현재 로그인한 유저 ID (문제별 풀이 여부 표시용) */ ProblemCursorResponse getProblems( UUID spaceId, @@ -38,7 +39,8 @@ ProblemCursorResponse getProblems( String contentKeyword, OffsetDateTime cursor, UUID cursorId, - int size + int size, + UUID userId ); /** 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 b02e8b8..033e6c0 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 @@ -26,6 +26,7 @@ import com.momogo.core.domain.user.repository.UserRepository; import java.time.OffsetDateTime; import java.util.List; +import java.util.Set; import java.util.UUID; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -110,7 +111,8 @@ public ProblemCursorResponse getProblems( String contentKeyword, OffsetDateTime cursor, UUID cursorId, - int size) { + int size, + UUID userId) { if ((cursor == null) != (cursorId == null)) { throw new BusinessException(ProblemErrorCode.INVALID_CURSOR); @@ -127,8 +129,20 @@ public ProblemCursorResponse getProblems( Problem last = hasNext ? content.getLast() : null; + List problemIds = content.stream().map(Problem::getId).toList(); + Set solvedProblemIds = problemIds.isEmpty() + ? Set.of() + : Set.copyOf(userProblemRepository.findSolvedProblemIds(userId, problemIds)); + + List responseList = problemMapper.toResponseList(content).stream() + .map(r -> new ProblemResponse( + r.id(), r.spaceId(), r.categoryId(), r.categoryName(), r.name(), r.content(), + r.explanation(), r.createdAt(), r.updatedAt(), solvedProblemIds.contains(r.id()) + )) + .toList(); + return new ProblemCursorResponse( - problemMapper.toResponseList(content), + responseList, hasNext, last != null ? last.getCreatedAt() : null, last != null ? last.getId() : null 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 d40b2de..ef3b293 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 @@ -3,6 +3,7 @@ import com.momogo.core.domain.user.entity.UserProblem; import com.momogo.core.domain.user.entity.UserProblemId; import java.time.OffsetDateTime; +import java.util.Collection; import java.util.List; import java.util.UUID; import org.springframework.data.jpa.repository.EntityGraph; @@ -29,4 +30,8 @@ public interface UserProblemRepository extends JpaRepository findAllByUser_IdAndProblem_Space_IdOrderByCreatedAtDesc(UUID userId, UUID spaceId); + + // 문제 목록에 유저별 풀이 여부(isSolved) 배지를 표시하기 위해, 주어진 문제 목록 중 이미 푼 문제 ID만 조회 + @Query("select up.problem.id from UserProblem up where up.user.id = :userId and up.problem.id in :problemIds and up.isSolved = true") + List findSolvedProblemIds(@Param("userId") UUID userId, @Param("problemIds") Collection problemIds); } diff --git a/momogo-frontend/package-lock.json b/momogo-frontend/package-lock.json index f225c76..407d064 100644 --- a/momogo-frontend/package-lock.json +++ b/momogo-frontend/package-lock.json @@ -22,6 +22,29 @@ "vite": "^8.1.1" } }, + "node_modules/@emnapi/core": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz", + "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.3", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/core/node_modules/@emnapi/wasi-threads": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", + "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", diff --git a/momogo-frontend/src/pages/DashboardPage.tsx b/momogo-frontend/src/pages/DashboardPage.tsx index b4ef262..bc73c7a 100644 --- a/momogo-frontend/src/pages/DashboardPage.tsx +++ b/momogo-frontend/src/pages/DashboardPage.tsx @@ -16,6 +16,12 @@ interface DashboardPageProps { const DEFAULT_AVATAR = '/basic.png'; +// 프로필 이미지 로드 실패(마이그레이션 이전 로컬 경로 등) 시 깨진 이미지 아이콘 대신 기본 아바타로 대체 +const handleAvatarError = (e: React.SyntheticEvent) => { + e.currentTarget.onerror = null; + e.currentTarget.src = DEFAULT_AVATAR; +}; + interface NotificationItem { id: string; title: string; @@ -981,6 +987,7 @@ export const DashboardPage: React.FC = ({ user, initialTab, src={profilePreview || (removeProfileImage ? DEFAULT_AVATAR : savedProfilePreview || user.profileImageUrl || DEFAULT_AVATAR)} alt={user.name} style={styles.headerAvatar} + onError={handleAvatarError} /> {user.name} {user.role && ( @@ -997,6 +1004,7 @@ export const DashboardPage: React.FC = ({ user, initialTab, src={profilePreview || (removeProfileImage ? DEFAULT_AVATAR : savedProfilePreview || user.profileImageUrl || DEFAULT_AVATAR)} alt={user.name} style={styles.menuAvatar} + onError={handleAvatarError} />
{user.name}
@@ -1528,6 +1536,7 @@ export const DashboardPage: React.FC = ({ user, initialTab, src={u.profileImageUrl || '/basic.png'} alt={u.name} style={{ width: '28px', height: '28px', borderRadius: '50%', objectFit: 'cover', border: '1px solid #eaecf0', flexShrink: 0 }} + onError={handleAvatarError} /> {u.name}
@@ -1871,6 +1880,7 @@ export const DashboardPage: React.FC = ({ user, initialTab, src={profilePreview || (removeProfileImage ? DEFAULT_AVATAR : savedProfilePreview || user.profileImageUrl || DEFAULT_AVATAR)} alt="Profile" style={{ width: '64px', height: '64px', borderRadius: '50%', objectFit: 'cover', border: '1px solid #eaecf0' }} + onError={handleAvatarError} />
@@ -2322,6 +2332,8 @@ const styles: Record = { justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem', + flexWrap: 'wrap', + gap: '1rem', }, welcomeTitle: { fontSize: '1.75rem', @@ -2339,6 +2351,7 @@ const styles: Record = { alignItems: 'center', gap: '1rem', position: 'relative', + flexWrap: 'wrap', }, notiTriggerBtn: { position: 'relative', diff --git a/momogo-frontend/src/pages/SpacePage.tsx b/momogo-frontend/src/pages/SpacePage.tsx index 5c1efae..a13b753 100644 --- a/momogo-frontend/src/pages/SpacePage.tsx +++ b/momogo-frontend/src/pages/SpacePage.tsx @@ -23,7 +23,9 @@ interface ProblemResponse { content: string; correctAnswer: string; explanation: string; - category: CategoryResponse | null; + categoryId: string | null; + categoryName: string | null; + isSolved: boolean; } // 문제 편집용 단건 조회 응답 (정답 포함, ADMIN 전용 API) @@ -153,6 +155,12 @@ interface SpaceRankingResponse { solvedCount: number; } +// 프로필 이미지 로드 실패(마이그레이션 이전 로컬 경로 등) 시 깨진 이미지 아이콘 대신 기본 아바타로 대체 +const handleAvatarError = (e: React.SyntheticEvent) => { + e.currentTarget.onerror = null; + e.currentTarget.src = '/basic.png'; +}; + export const SpacePage: React.FC = ({ user, space, onBack, showToast, initialTab, onTabChange }) => { const [activeTab, setActiveTabState] = useState<'problems' | 'exams' | 'dashboard'>(initialTab ?? 'problems'); const setActiveTab = (tab: 'problems' | 'exams' | 'dashboard') => { @@ -1024,7 +1032,7 @@ export const SpacePage: React.FC = ({ user, space, onBack, showT {/* 본문 레이아웃 */}
{/* 공간 페이지 상단바 (요구사항 2, 2-1, 14) */} -
+

{space.name}

{space.description}

@@ -1046,9 +1054,10 @@ export const SpacePage: React.FC = ({ user, space, onBack, showT onClick={() => setShowUserMenu(!showUserMenu)} > {user.name} {user.name} {user.role && ( @@ -1073,9 +1082,10 @@ export const SpacePage: React.FC = ({ user, space, onBack, showT }} className="card">
{user.name}
{user.name}
@@ -1139,11 +1149,20 @@ export const SpacePage: React.FC = ({ user, space, onBack, showT ) : (
{problems.map(prob => ( -
+
- - {prob.category?.name || '미분류'} - +
+ + {prob.categoryName || '미분류'} + + + {prob.isSolved ? '풀이완료' : '미풀이'} + +

{prob.name}

{prob.content}

@@ -1351,7 +1370,7 @@ export const SpacePage: React.FC = ({ user, space, onBack, showT {i + 1}등{medal}
- {r.userName} + {r.userName} {r.userName}{isMe ? ' (나)' : ''}
@@ -1704,7 +1723,7 @@ export const SpacePage: React.FC = ({ user, space, onBack, showT 연습 문제 풀기
- {solvingProblem.category?.name || '미분류'} + {solvingProblem.categoryName || '미분류'}

{solvingProblem.name}

{solvingProblem.content} @@ -1951,6 +1970,7 @@ export const SpacePage: React.FC = ({ user, space, onBack, showT flexShrink: 0, border: selected ? '2px solid var(--primary)' : '1px solid var(--border-color)', }} + onError={handleAvatarError} />

{u.name} @@ -2243,7 +2263,7 @@ export const SpacePage: React.FC = ({ user, space, onBack, showT
- {grade.name} + {grade.name} {grade.name}
@@ -2488,7 +2508,7 @@ const styles: Record = { }, problemGrid: { display: 'grid', - gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', + gridTemplateColumns: 'repeat(auto-fill, minmax(min(260px, 100%), 1fr))', gap: '1.5rem', }, problemCard: { @@ -2497,6 +2517,17 @@ const styles: Record = { justifyContent: 'space-between', minHeight: '220px', }, + badgeUnsolved: { + display: 'inline-flex', + alignItems: 'center', + padding: '0.25rem 0.75rem', + borderRadius: '9999px', + fontSize: '0.75rem', + fontWeight: 600, + backgroundColor: '#f2f4f7', + color: '#475467', + border: '1px solid #e4e7ec', + }, problemTitleText: { fontSize: '1.15rem', fontWeight: 700, @@ -2526,7 +2557,7 @@ const styles: Record = { }, examGrid: { display: 'grid', - gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', + gridTemplateColumns: 'repeat(auto-fill, minmax(min(280px, 100%), 1fr))', gap: '1.5rem', }, examCard: { diff --git a/momogo-frontend/src/styles/theme.css b/momogo-frontend/src/styles/theme.css index 5e00f3e..9699bd4 100644 --- a/momogo-frontend/src/styles/theme.css +++ b/momogo-frontend/src/styles/theme.css @@ -232,3 +232,41 @@ h1, h2, h3, h4 { flex-direction: column; gap: 1.5rem; } + +/* 반응형 대응: 고정폭 사이드바(240px)가 좁은 화면에서 본문을 짓눌러 화면이 깨지는 것을 방지 */ +html, body { + overflow-x: hidden; +} + +@media (max-width: 768px) { + .app-container { + flex-direction: column; + } + + .sidebar-layout { + position: static !important; + width: 100% !important; + height: auto !important; + padding: 1rem !important; + border-right: none !important; + border-bottom: 1px solid var(--border-color); + } + + .main-layout { + margin-left: 0 !important; + padding: 1.25rem 1rem !important; + } + + .modal-overlay { + padding: 1rem; + } + + .modal-content { + padding: 1.25rem; + max-height: 85vh; + } + + .card { + padding: 1rem; + } +} diff --git a/momogo-realtime/src/main/java/com/momogo/realtime/config/RealtimeFallbackConfig.java b/momogo-realtime/src/main/java/com/momogo/realtime/config/RealtimeFallbackConfig.java new file mode 100644 index 0000000..f65c3de --- /dev/null +++ b/momogo-realtime/src/main/java/com/momogo/realtime/config/RealtimeFallbackConfig.java @@ -0,0 +1,37 @@ +package com.momogo.realtime.config; + +import com.momogo.core.domain.notification.sse.NotificationSseService; +import com.momogo.core.domain.problem.service.ProblemGenerationService; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Realtime 모듈에는 없는 core 필수 의존성의 대체(Fallback) 빈 모음. + * Realtime 앱은 core 전체를 컴포넌트 스캔하는데, core의 일부 서비스가 요구하는 구현체 + */ +@Configuration +public class RealtimeFallbackConfig { + + /** + * core NotificationEventListener가 요구. Realtime 중 알림 이벤트는 발생할 수 있으나 + * 실시간 전송 대상(접속 클라이언트)이 없으므로 no-op이 올바른 동작. + */ + @Bean + @ConditionalOnMissingBean(NotificationSseService.class) + public NotificationSseService noopNotificationSseService() { + return (userId, notification) -> { + }; + } + + /** + * core ProblemServiceImpl 등이 요구. Realtime에서 AI 문제 생성이 호출될 일은 없어야 하므로 예외. + */ + @Bean + @ConditionalOnMissingBean(ProblemGenerationService.class) + public ProblemGenerationService dummyProblemGenerationService() { + return (referenceText, questionCount) -> { + throw new UnsupportedOperationException("Realtime 모듈에서는 AI 문제 생성을 지원하지 않습니다."); + }; + } +}