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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ public ResponseEntity<ProblemResponse> createProblem(
@GetMapping
public ResponseEntity<ProblemCursorResponse> getProblems(
@PathVariable UUID spaceId,
@Valid ProblemSearchRequest request) {
@Valid ProblemSearchRequest request,
@AuthenticationPrincipal(expression = "userResponse.id") UUID userId) {

return ResponseEntity.ok(
problemService.getProblems(
Expand All @@ -64,7 +65,8 @@ public ResponseEntity<ProblemCursorResponse> getProblems(
request.contentKeyword(),
request.cursor(),
request.cursorId(),
request.size()
request.size(),
userId
)
);
}
Expand Down
4 changes: 4 additions & 0 deletions momogo-core/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -18,6 +19,7 @@

@Slf4j
@Service
@ConditionalOnProperty(name = "app.storage.type", havingValue = "local", matchIfMissing = true)
public class LocalStorageService implements StorageService {

private final ImageFileValidator imageFileValidator;
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
Comment thread
Junkov0 marked this conversation as resolved.
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()
);
}
Comment thread
Junkov0 marked this conversation as resolved.
}

@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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,6 @@ public record ProblemResponse(
String content,
String explanation,
OffsetDateTime createdAt,
OffsetDateTime updatedAt
OffsetDateTime updatedAt,
boolean isSolved
) {}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Problem> → List<ProblemResponse> 변환
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ public interface ProblemService {
* @param cursor 다음 커서 일자
* @param cursorId 다음 커서 ID (서브)
* @param size 조회하고자 하는 사이즈
* @param userId 현재 로그인한 유저 ID (문제별 풀이 여부 표시용)
*/
ProblemCursorResponse getProblems(
UUID spaceId,
Expand All @@ -38,7 +39,8 @@ ProblemCursorResponse getProblems(
String contentKeyword,
OffsetDateTime cursor,
UUID cursorId,
int size
int size,
UUID userId
);

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -127,8 +129,20 @@ public ProblemCursorResponse getProblems(

Problem last = hasNext ? content.getLast() : null;

List<UUID> problemIds = content.stream().map(Problem::getId).toList();
Set<UUID> solvedProblemIds = problemIds.isEmpty()
? Set.of()
: Set.copyOf(userProblemRepository.findSolvedProblemIds(userId, problemIds));

List<ProblemResponse> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -29,4 +30,8 @@ public interface UserProblemRepository extends JpaRepository<UserProblem, UserPr

@EntityGraph(attributePaths = {"problem", "problem.category"})
List<UserProblem> 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<UUID> findSolvedProblemIds(@Param("userId") UUID userId, @Param("problemIds") Collection<UUID> problemIds);
}
23 changes: 23 additions & 0 deletions momogo-frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 13 additions & 0 deletions momogo-frontend/src/pages/DashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ interface DashboardPageProps {

const DEFAULT_AVATAR = '/basic.png';

// 프로필 이미지 로드 실패(마이그레이션 이전 로컬 경로 등) 시 깨진 이미지 아이콘 대신 기본 아바타로 대체
const handleAvatarError = (e: React.SyntheticEvent<HTMLImageElement>) => {
e.currentTarget.onerror = null;
e.currentTarget.src = DEFAULT_AVATAR;
};

interface NotificationItem {
id: string;
title: string;
Expand Down Expand Up @@ -981,6 +987,7 @@ export const DashboardPage: React.FC<DashboardPageProps> = ({ user, initialTab,
src={profilePreview || (removeProfileImage ? DEFAULT_AVATAR : savedProfilePreview || user.profileImageUrl || DEFAULT_AVATAR)}
alt={user.name}
style={styles.headerAvatar}
onError={handleAvatarError}
/>
<span style={styles.headerUserName}>{user.name}</span>
{user.role && (
Expand All @@ -997,6 +1004,7 @@ export const DashboardPage: React.FC<DashboardPageProps> = ({ user, initialTab,
src={profilePreview || (removeProfileImage ? DEFAULT_AVATAR : savedProfilePreview || user.profileImageUrl || DEFAULT_AVATAR)}
alt={user.name}
style={styles.menuAvatar}
onError={handleAvatarError}
/>
<div>
<div style={{ fontWeight: 700, fontSize: '0.95rem' }}>{user.name}</div>
Expand Down Expand Up @@ -1528,6 +1536,7 @@ export const DashboardPage: React.FC<DashboardPageProps> = ({ 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}
/>
<span>{u.name}</span>
</div>
Expand Down Expand Up @@ -1871,6 +1880,7 @@ export const DashboardPage: React.FC<DashboardPageProps> = ({ 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}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.375rem' }}>
<span style={{ fontSize: '1.125rem', fontWeight: '600', color: 'var(--text)', display: 'flex', alignItems: 'center' }}>
Expand Down Expand Up @@ -2322,6 +2332,8 @@ const styles: Record<string, React.CSSProperties> = {
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '2rem',
flexWrap: 'wrap',
gap: '1rem',
},
welcomeTitle: {
fontSize: '1.75rem',
Expand All @@ -2339,6 +2351,7 @@ const styles: Record<string, React.CSSProperties> = {
alignItems: 'center',
gap: '1rem',
position: 'relative',
flexWrap: 'wrap',
},
notiTriggerBtn: {
position: 'relative',
Expand Down
Loading