diff --git a/src/asciidoc/api/chat.adoc b/src/asciidoc/api/chat.adoc index 830ae63b..79844181 100644 --- a/src/asciidoc/api/chat.adoc +++ b/src/asciidoc/api/chat.adoc @@ -32,7 +32,7 @@ === 채팅방 API '''' -==== 1. 채팅방 정보 조회 +==== - 채팅방 정보 조회 *Description* + @@ -65,7 +65,28 @@ include::{snippets}/chat-room-controller-rest-docs-test/get-chat-room-info_succe include::{snippets}/chat-room-controller-rest-docs-test/get-chat-room-info_success/response-fields.adoc[] include::{snippets}/chat-room-controller-rest-docs-test/get-chat-room-info_success/http-response.adoc[] -''' +'''' +==== - 채팅을 통한 이미지 전송 + +'''' +*Description* + + +채팅방에서 사용할 이미지를 업로드합니다. + +- *HTTP Method*: `POST` +- *Endpoint*: `/files/image/chat` +- *Content-Type*: `multipart/form-data` +- *Requires Authentication*: Yes + +*REQUEST* + +include::{snippets}/chat-room-controller-rest-docs-test/up-load-chat-image_success/http-request.adoc[] +include::{snippets}/chat-room-controller-rest-docs-test/up-load-chat-image_success/request-parts.adoc[] + +*RESPONSE* + +include::{snippets}/chat-room-controller-rest-docs-test/up-load-chat-image_success/response-fields.adoc[] +include::{snippets}/chat-room-controller-rest-docs-test/up-load-chat-image_success/http-response.adoc[] + + ''' === 채팅 diff --git a/src/asciidoc/api/file.adoc b/src/asciidoc/api/file.adoc deleted file mode 100644 index 1f5ac738..00000000 --- a/src/asciidoc/api/file.adoc +++ /dev/null @@ -1,69 +0,0 @@ -= 👥 file API -:doctype: book -:icons: font -:source-highlighter: highlightjs -:toc: left -:toclevels: 3 -:sectlinks: - -== 👥 그룹 관련 응답 코드 (I01) - -|=== -| 기능 코드 | 설명 - -| 00 | 프로필 이미지 업로드 Presigned URL 조회 -| 01 | 채팅 이미지 업로드 Presigned URL 조회 -|=== - -== ✨ API 문서 - -=== 파일 API -'''' - -==== 1. 프로필 이미지 업로드 URL 조회 - -*Description* + - -''' - -프로필 이미지 업로드를 위한 Presigned URL을 조회합니다. 해당 Presigned URL을 통해 MinIO에 직접 요청하여 이미지를 업로드해야 합니다. - - -*REQUEST* + - -''' - -include::{snippets}/image-file-controller-rest-docs-test/get-profile-upload-url_success/http-request.adoc[] -include::{snippets}/image-file-controller-rest-docs-test/get-profile-upload-url_success/request-fields.adoc[] - -*RESPONSE* + - -''' - -include::{snippets}/image-file-controller-rest-docs-test/get-profile-upload-url_success/response-fields.adoc[] -include::{snippets}/image-file-controller-rest-docs-test/get-profile-upload-url_success/http-response.adoc[] - -'''' - -==== 2. 채팅 이미지 업로드 URL 조회 - -*Description* + - -''' - -채팅 이미지 업로드를 위한 Presigned URL을 조회합니다. 해당 Presigned URL을 통해 MinIO에 직접 요청하여 이미지를 업로드해야 합니다. - - -*REQUEST* + - -''' - -include::{snippets}/image-file-controller-rest-docs-test/get-chat-upload-url_success/http-request.adoc[] -include::{snippets}/image-file-controller-rest-docs-test/get-chat-upload-url_success/request-fields.adoc[] - -*RESPONSE* + - -''' - -include::{snippets}/image-file-controller-rest-docs-test/get-chat-upload-url_success/response-fields.adoc[] -include::{snippets}/image-file-controller-rest-docs-test/get-chat-upload-url_success/http-response.adoc[] diff --git a/src/asciidoc/api/member.adoc b/src/asciidoc/api/member.adoc index 4e706f7b..ae09bbe1 100644 --- a/src/asciidoc/api/member.adoc +++ b/src/asciidoc/api/member.adoc @@ -21,6 +21,7 @@ | 06 | 유저 권한 검색/검증 | 07 | token 재발급 | 08 | 토큰 인증 +| 09 | 유저 프로필 사진 등록 |=== == ✨ API 문서 @@ -174,6 +175,23 @@ include::{snippets}/member-controller-rest-docs-test/check-availability_success_ include::{snippets}/member-controller-rest-docs-test/check-availability_success_with_username/response-fields.adoc[] include::{snippets}/member-controller-rest-docs-test/check-availability_success_with_username/http-response.adoc[] +=== 7. 프로필 이미지 업로드 +'''' +*Description* + + +사용자의 프로필 이미지를 업로드합니다. 기존 프로필 이미지가 존재할 경우, 새로운 이미지로 교체됩니다. + +- *HTTP Method*: `POST` +- *Endpoint*: `/files/image/profile` +- *Content-Type*: `multipart/form-data` + +*REQUEST* + +include::{snippets}/member-controller-rest-docs-test/up-load-profile-image_success/http-request.adoc[] +include::{snippets}/member-controller-rest-docs-test/up-load-profile-image_success/request-parts.adoc[] + +*RESPONSE* + +include::{snippets}/member-controller-rest-docs-test/up-load-profile-image_success/response-fields.adoc[] +include::{snippets}/member-controller-rest-docs-test/up-load-profile-image_success/http-response.adoc[] == ⛔ 예외 출력 diff --git a/src/main/java/com/studypals/domain/chatManage/api/ChatRoomController.java b/src/main/java/com/studypals/domain/chatManage/api/ChatRoomController.java index 44003de4..02c5ccc1 100644 --- a/src/main/java/com/studypals/domain/chatManage/api/ChatRoomController.java +++ b/src/main/java/com/studypals/domain/chatManage/api/ChatRoomController.java @@ -1,13 +1,17 @@ package com.studypals.domain.chatManage.api; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; import lombok.RequiredArgsConstructor; import com.studypals.domain.chatManage.dto.ChatRoomInfoRes; import com.studypals.domain.chatManage.service.ChatRoomService; +import com.studypals.global.file.dto.ImageUploadRes; +import com.studypals.global.file.service.ImageFileService; import com.studypals.global.responses.CommonResponse; import com.studypals.global.responses.Response; import com.studypals.global.responses.ResponseCode; @@ -18,6 +22,7 @@ * *
  *     - GET /chat/room/{chatRoomId} : 채팅방 정보 조회
+ *     - POST /chat/room/{chatRoomId}/image : 채팅방 이미지 업로드
  * 
* * @author jack8 @@ -29,6 +34,7 @@ public class ChatRoomController { private final ChatRoomService chatRoomService; + private final ImageFileService imageFileService; // 구독 이후, 해당 요청 보냄 -> 응답을 받고 정렬 마칠 때 까지, 새로운 메시지가 와도 일단 렌더링 중지, 마치고 렌더링 @GetMapping("/{chatRoomId}") @@ -40,4 +46,14 @@ public ResponseEntity> getChatRoomInfo( return ResponseEntity.ok(CommonResponse.success(ResponseCode.CHAT_ROOM_SEARCH, chatRoomInfo, chatRoomId)); } + + @PostMapping(value = "/{chatRoomId}/image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity> uploadChatImage( + @RequestPart("file") MultipartFile file, + @PathVariable("chatRoomId") String chatRoomId, + @AuthenticationPrincipal Long userId) { + + ImageUploadRes response = imageFileService.uploadChatImage(file, chatRoomId, userId); + return ResponseEntity.ok(CommonResponse.success(ResponseCode.FILE_IMAGE_UPLOAD, response)); + } } diff --git a/src/main/java/com/studypals/domain/chatManage/dao/ChatImageRepository.java b/src/main/java/com/studypals/domain/chatManage/dao/ChatImageRepository.java new file mode 100644 index 00000000..d3fb36eb --- /dev/null +++ b/src/main/java/com/studypals/domain/chatManage/dao/ChatImageRepository.java @@ -0,0 +1,7 @@ +package com.studypals.domain.chatManage.dao; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.studypals.domain.chatManage.entity.ChatImage; + +public interface ChatImageRepository extends JpaRepository {} diff --git a/src/main/java/com/studypals/domain/chatManage/dto/mapper/ChatRoomMapper.java b/src/main/java/com/studypals/domain/chatManage/dto/mapper/ChatRoomMapper.java index 43a9a795..99033552 100644 --- a/src/main/java/com/studypals/domain/chatManage/dto/mapper/ChatRoomMapper.java +++ b/src/main/java/com/studypals/domain/chatManage/dto/mapper/ChatRoomMapper.java @@ -2,29 +2,34 @@ import java.util.Map; -import org.mapstruct.Mapper; -import org.mapstruct.Mapping; +import org.springframework.stereotype.Component; import com.studypals.domain.chatManage.dto.ChatRoomInfoRes; +import com.studypals.domain.chatManage.dto.ChatRoomInfoRes.UserInfo; import com.studypals.domain.chatManage.dto.ChatRoomListRes; import com.studypals.domain.chatManage.dto.ChatroomLatestInfo; import com.studypals.domain.chatManage.entity.ChatRoomMember; +import com.studypals.global.file.ObjectStorage; /** - * ChatRoom 에 대한 mapper 클래스입니다. + * ChatRoom 도메인 관련 mapper 입니다. * - * @author jack8 - * @since 2025-05-22 + * @author sleepyhoon + * @see + * @since 2026-01-27 */ -@Mapper(componentModel = "spring") -public interface ChatRoomMapper { - /** - * 트랜잭션 내에서만 처리되어야 합니다. - */ - @Mapping(target = "userId", source = "member.id") - @Mapping(target = "imageUrl", source = "member.imageUrl") - @Mapping(target = "nickname", source = "member.nickname") - ChatRoomInfoRes.UserInfo toDto(ChatRoomMember entity); +@Component +public class ChatRoomMapper { + + public ChatRoomInfoRes.UserInfo toDto(ChatRoomMember entity, ObjectStorage objectStorage) { + return UserInfo.builder() + .userId(entity.getMember().getId()) + .nickname(entity.getMember().getNickname()) + .role(entity.getRole()) + // TODO: 채팅방 이미지도 minio로 이동해야함. 아직 구현되지 않음. + .imageUrl(objectStorage.convertKeyToFileUrl(entity.getChatRoom().getImageUrl())) + .build(); + } /** * 단일 ChatRoomMember 객체와 최신 메시지 조회 결과를 기반으로 @@ -35,7 +40,7 @@ public interface ChatRoomMapper { * @param latestInfos 채팅방별 최신 메시지 및 언리드 정보 * @return ChatRoomListRes.ChatRoomInfo 변환 결과 */ - default ChatRoomListRes.ChatRoomInfo toChatRoomInfo( + public ChatRoomListRes.ChatRoomInfo toChatRoomInfo( ChatRoomMember chatRoomMember, Map latestInfos) { String chatRoomId = chatRoomMember.getChatRoom().getId(); ChatroomLatestInfo info = latestInfos.get(chatRoomId); diff --git a/src/main/java/com/studypals/domain/chatManage/entity/ChatImage.java b/src/main/java/com/studypals/domain/chatManage/entity/ChatImage.java new file mode 100644 index 00000000..1d92646f --- /dev/null +++ b/src/main/java/com/studypals/domain/chatManage/entity/ChatImage.java @@ -0,0 +1,51 @@ +package com.studypals.domain.chatManage.entity; + +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import com.studypals.global.file.entity.ImageFile; + +/** + * 채팅방(ChatRoom) 내에서 전송된 이미지의 메타데이터를 관리하는 엔티티입니다. + *

+ * 이 엔티티는 {@link ImageFile}을 상속받아 이미지 파일의 공통 속성을 관리하며, + * {@link ChatRoom}과 다대일(Many-to-One) 관계를 맺습니다. + * 채팅 이미지는 한 번 생성되면 수정되지 않는 불변(Immutable)의 특성을 가집니다. + * + *

주요 특징: + *

    + *
  • 상속 관계: {@link ImageFile}의 모든 속성을 상속받습니다.
  • + *
  • 연관 관계: 여러 개의 채팅 이미지가 하나의 {@link ChatRoom}에 속합니다.
  • + *
  • 불변성: 생성 후 상태가 변경되지 않습니다. (수정 기능 없음)
  • + *
  • 인덱싱: 이미지 처리 상태({@code imageStatus})와 생성일({@code createdAt})에 복합 인덱스가 설정되어 있어, + * 리사이징 등 비동기 처리 대상 조회 시 성능을 향상시킵니다.
  • + *
+ * + * @author sleepyhoon + * @since 2026-01-15 + * @see ImageFile + * @see ChatRoom + * @see com.studypals.domain.chatManage.worker.ChatImageManager + */ +@Entity +@Getter +@SuperBuilder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table( + name = "chat_image", + indexes = @Index(name = "idx_chat_image_status_created_at", columnList = "imageStatus, createdAt")) +public class ChatImage extends ImageFile { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "chat_room_id", nullable = false) + private ChatRoom chatRoom; +} diff --git a/src/main/java/com/studypals/domain/chatManage/service/ChatRoomServiceImpl.java b/src/main/java/com/studypals/domain/chatManage/service/ChatRoomServiceImpl.java index d0e79b2a..29a7ae20 100644 --- a/src/main/java/com/studypals/domain/chatManage/service/ChatRoomServiceImpl.java +++ b/src/main/java/com/studypals/domain/chatManage/service/ChatRoomServiceImpl.java @@ -19,6 +19,7 @@ import com.studypals.domain.memberManage.worker.MemberReader; import com.studypals.global.exceptions.errorCode.ChatErrorCode; import com.studypals.global.exceptions.exception.ChatException; +import com.studypals.global.file.ObjectStorage; /** * 채팅방 진입 시 필요한 정보를 조회하는 서비스 구현 클래스입니다. @@ -53,6 +54,8 @@ public class ChatRoomServiceImpl implements ChatRoomService { private final ChatMessageReader chatMessageReader; private final MemberReader memberReader; + private final ObjectStorage objectStorage; + /** * 특정 유저가 특정 채팅방에 입장할 때 필요한 전체 정보를 조회합니다. *

@@ -112,7 +115,9 @@ public ChatRoomInfoRes getChatRoomInfo(Long userId, String chatRoomId, String ch return ChatRoomInfoRes.builder() .roomId(chatRoomId) .name(chatRoom.getName()) - .userInfos(members.stream().map(chatRoomMapper::toDto).toList()) + .userInfos(members.stream() + .map(m -> chatRoomMapper.toDto(m, objectStorage)) + .toList()) .cursor(chatCursorRes) .logs(logs) .build(); diff --git a/src/main/java/com/studypals/domain/chatManage/worker/ChatImageManager.java b/src/main/java/com/studypals/domain/chatManage/worker/ChatImageManager.java index f30640c0..92eab5b1 100644 --- a/src/main/java/com/studypals/domain/chatManage/worker/ChatImageManager.java +++ b/src/main/java/com/studypals/domain/chatManage/worker/ChatImageManager.java @@ -1,14 +1,19 @@ package com.studypals.domain.chatManage.worker; +import java.util.List; import java.util.UUID; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import com.studypals.domain.chatManage.entity.ChatRoom; import com.studypals.global.exceptions.errorCode.ChatErrorCode; import com.studypals.global.exceptions.exception.ChatException; +import com.studypals.global.file.FileProperties; import com.studypals.global.file.ObjectStorage; import com.studypals.global.file.dao.AbstractImageManager; import com.studypals.global.file.entity.ImageType; +import com.studypals.global.file.entity.ImageVariantKey; /** * 파일 중 채팅 이미지를 처리하는데 사용하는 구체 클래스입니다. @@ -21,17 +26,23 @@ * {@link AbstractImageManager} * * @author sleepyhoon - * @See AbstractImageManager + * @see AbstractImageManager * @since 2026-01-13 */ @Component public class ChatImageManager extends AbstractImageManager { - private static final String CHAT_IMAGE_PATH = "chat"; + private static final String CHAT_IMAGE_PATH = "origin/chat"; private final ChatRoomReader chatRoomReader; + private final ChatImageWriter chatImageWriter; - public ChatImageManager(ObjectStorage objectStorage, ChatRoomReader chatRoomReader) { - super(objectStorage); + public ChatImageManager( + ObjectStorage objectStorage, + FileProperties properties, + ChatRoomReader chatRoomReader, + ChatImageWriter chatImageWriter) { + super(objectStorage, properties); this.chatRoomReader = chatRoomReader; + this.chatImageWriter = chatImageWriter; } /** @@ -51,12 +62,30 @@ protected String generateObjectKeyDetail(String chatRoomId, String ext) { return CHAT_IMAGE_PATH + "/" + chatRoomId + "/" + UUID.randomUUID() + "." + ext; } - /** - * 이 클래스는 채팅 이미지를 처리합니다. - * @return 처리하는 이미지 종류 - */ @Override - public ImageType getFileType() { + @Transactional + protected Long saveImage(Long userId, String chatRoomId, String objectKey, String originalFileName) { + ChatRoom chatRoom = chatRoomReader.getById(chatRoomId); + return chatImageWriter.save(chatRoom, objectKey, originalFileName); + } + + @Override + protected List variants() { + return List.of(ImageVariantKey.SMALL, ImageVariantKey.MEDIUM, ImageVariantKey.LARGE); + } + + @Override + public ImageType getType() { return ImageType.CHAT_IMAGE; } + + @Override + public boolean supports(ImageType type) { + return type == getType(); + } + + @Override + protected boolean usePresignedUrl() { + return true; // 채팅 이미지는 Pre-Signed URL 사용 + } } diff --git a/src/main/java/com/studypals/domain/chatManage/worker/ChatImageWriter.java b/src/main/java/com/studypals/domain/chatManage/worker/ChatImageWriter.java new file mode 100644 index 00000000..f079711b --- /dev/null +++ b/src/main/java/com/studypals/domain/chatManage/worker/ChatImageWriter.java @@ -0,0 +1,43 @@ +package com.studypals.domain.chatManage.worker; + +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; + +import com.studypals.domain.chatManage.dao.ChatImageRepository; +import com.studypals.domain.chatManage.entity.ChatImage; +import com.studypals.domain.chatManage.entity.ChatRoom; +import com.studypals.global.annotations.Worker; +import com.studypals.global.file.FileUtils; + +/** + * 채팅 이미지의 메타데이터를 데이터베이스에 저장하는 역할을 전담하는 Worker 클래스입니다. + *

+ * 이 클래스는 CQRS(Command Query Responsibility Segregation) 패턴의 'Command' 측면을 담당하며, + * 시스템의 상태를 변경하는 '쓰기(Write)' 작업에만 집중합니다. + * {@link Transactional} 어노테이션을 통해 데이터 저장 작업의 원자성을 보장합니다. + * + * @author sleepyhoon + * @since 2026-01-15 + * @see ChatImage + * @see ChatImageRepository + */ +@Worker +@RequiredArgsConstructor +public class ChatImageWriter { + private final ChatImageRepository chatImageRepository; + + @Transactional + public Long save(ChatRoom chatRoom, String objectKey, String fileName) { + String extension = FileUtils.extractExtension(fileName); + + ChatImage savedImage = chatImageRepository.save(ChatImage.builder() + .chatRoom(chatRoom) + .objectKey(objectKey) + .originalFileName(fileName) + .mimeType(extension) + .build()); + + return savedImage.getId(); + } +} diff --git a/src/main/java/com/studypals/domain/groupManage/dao/GroupMemberCustomRepositoryImpl.java b/src/main/java/com/studypals/domain/groupManage/dao/GroupMemberCustomRepositoryImpl.java index 3a391a40..1c231be5 100644 --- a/src/main/java/com/studypals/domain/groupManage/dao/GroupMemberCustomRepositoryImpl.java +++ b/src/main/java/com/studypals/domain/groupManage/dao/GroupMemberCustomRepositoryImpl.java @@ -38,10 +38,15 @@ public class GroupMemberCustomRepositoryImpl implements GroupMemberCustomReposit public List findTopNMemberByJoinedAt(Long groupId, int limit) { return queryFactory .select(Projections.constructor( - GroupMemberProfileDto.class, member.id, member.nickname, member.imageUrl, groupMember.role)) + GroupMemberProfileDto.class, + member.id, + member.nickname, + member.profileImage.objectKey, + groupMember.role)) .from(groupMember) .join(member) .on(groupMember.member.id.eq(member.id)) + .leftJoin(member.profileImage) // 프로필 이미지가 없는 멤버도 조회되도록 Left Join 추가 .where(groupMember.group.id.eq(groupId)) .orderBy(orderByLeaderPriority(), groupMember.joinedAt.desc()) .limit(limit) @@ -52,10 +57,14 @@ public List findTopNMemberByJoinedAt(Long groupId, int li public List findTopNMemberInGroupIds(List groupIds, int limit) { return queryFactory .select(Projections.constructor( - GroupMemberProfileMappingDto.class, groupMember.group.id, member.imageUrl, groupMember.role)) + GroupMemberProfileMappingDto.class, + groupMember.group.id, + member.profileImage.objectKey, + groupMember.role)) .from(groupMember) .join(member) .on(groupMember.member.id.eq(member.id)) + .leftJoin(member.profileImage) // 프로필 이미지가 없는 멤버도 조회되도록 Left Join 추가 .where(groupMember.group.id.in(groupIds)) .orderBy(orderByLeaderPriority(), groupMember.joinedAt.desc()) .limit(limit) diff --git a/src/main/java/com/studypals/domain/groupManage/dto/GetGroupDetailRes.java b/src/main/java/com/studypals/domain/groupManage/dto/GetGroupDetailRes.java index ee7dcf70..765ed900 100644 --- a/src/main/java/com/studypals/domain/groupManage/dto/GetGroupDetailRes.java +++ b/src/main/java/com/studypals/domain/groupManage/dto/GetGroupDetailRes.java @@ -5,6 +5,7 @@ import com.studypals.domain.groupManage.entity.Group; import com.studypals.domain.groupManage.entity.GroupMember; import com.studypals.domain.memberManage.entity.Member; +import com.studypals.global.file.ObjectStorage; public record GetGroupDetailRes( Long id, @@ -15,7 +16,8 @@ public record GetGroupDetailRes( int currentMemberCount, List profiles, GroupTotalGoalDto groupGoals) { - public static GetGroupDetailRes of(Group group, List groupMembers, GroupTotalGoalDto goals) { + public static GetGroupDetailRes of( + Group group, List groupMembers, GroupTotalGoalDto goals, ObjectStorage objectStorage) { return new GetGroupDetailRes( group.getId(), group.getName(), @@ -27,7 +29,10 @@ public static GetGroupDetailRes of(Group group, List groupMembers, .map(gm -> { Member member = gm.getMember(); return new GroupMemberProfileDto( - member.getId(), member.getNickname(), member.getImageUrl(), gm.getRole()); + member.getId(), + member.getNickname(), + objectStorage.convertKeyToFileUrl(member.getProfileImageObjectKey()), + gm.getRole()); }) .toList(), goals); diff --git a/src/main/java/com/studypals/domain/groupManage/dto/GetGroupsRes.java b/src/main/java/com/studypals/domain/groupManage/dto/GetGroupsRes.java index e892933f..356dfbe9 100644 --- a/src/main/java/com/studypals/domain/groupManage/dto/GetGroupsRes.java +++ b/src/main/java/com/studypals/domain/groupManage/dto/GetGroupsRes.java @@ -4,6 +4,7 @@ import java.util.List; import com.studypals.domain.studyManage.dto.GroupCategoryDto; +import com.studypals.global.file.ObjectStorage; public record GetGroupsRes( Long groupId, @@ -17,7 +18,10 @@ public record GetGroupsRes( List profiles, List categoryIds) { public static GetGroupsRes of( - GroupSummaryDto dto, List rawProfiles, List categoryIds) { + GroupSummaryDto dto, + List rawProfiles, + List categoryIds, + ObjectStorage objectStorage) { return new GetGroupsRes( dto.id(), dto.name(), @@ -28,7 +32,8 @@ public static GetGroupsRes of( dto.approvalRequired(), dto.createdDate(), rawProfiles.stream() - .map(rp -> new GroupMemberProfileImageDto(rp.imageUrl(), rp.role())) + .map(rp -> new GroupMemberProfileImageDto( + objectStorage.convertKeyToFileUrl(rp.imageUrl()), rp.role())) .toList(), categoryIds.stream().map(GroupCategoryDto::categoryId).toList()); } diff --git a/src/main/java/com/studypals/domain/groupManage/dto/mappers/GroupEntryRequestCustomMapper.java b/src/main/java/com/studypals/domain/groupManage/dto/mappers/GroupEntryRequestCustomMapper.java index 238d9ae8..5cad28a9 100644 --- a/src/main/java/com/studypals/domain/groupManage/dto/mappers/GroupEntryRequestCustomMapper.java +++ b/src/main/java/com/studypals/domain/groupManage/dto/mappers/GroupEntryRequestCustomMapper.java @@ -10,6 +10,7 @@ import com.studypals.domain.groupManage.entity.GroupEntryRequest; import com.studypals.domain.memberManage.dto.MemberProfileDto; import com.studypals.domain.memberManage.entity.Member; +import com.studypals.global.file.ObjectStorage; /** * mapstruct 가 아닌 별도의 DTO 매핑 로직을 담당하는 유틸성 클래스입니다. @@ -26,7 +27,8 @@ public class GroupEntryRequestCustomMapper { * @param members * @return */ - public static List map(List requests, List members) { + public static List map( + List requests, List members, ObjectStorage objectStorage) { Map memberMap = members.stream().collect(Collectors.toMap(Member::getId, Function.identity())); return requests.stream() @@ -37,7 +39,10 @@ public static List map(List requests, L new IllegalStateException("Member not found for request ID: " + request.getId())); return new GroupEntryRequestDto( request.getId(), - new MemberProfileDto(member.getId(), member.getNickname(), member.getImageUrl()), + new MemberProfileDto( + member.getId(), + member.getNickname(), + objectStorage.convertKeyToFileUrl(member.getProfileImageObjectKey())), request.getCreatedDate()); }) .toList(); diff --git a/src/main/java/com/studypals/domain/groupManage/service/GroupEntryServiceImpl.java b/src/main/java/com/studypals/domain/groupManage/service/GroupEntryServiceImpl.java index f24e465e..78e59cc0 100644 --- a/src/main/java/com/studypals/domain/groupManage/service/GroupEntryServiceImpl.java +++ b/src/main/java/com/studypals/domain/groupManage/service/GroupEntryServiceImpl.java @@ -19,6 +19,7 @@ import com.studypals.domain.memberManage.worker.MemberReader; import com.studypals.global.exceptions.errorCode.GroupErrorCode; import com.studypals.global.exceptions.exception.GroupException; +import com.studypals.global.file.ObjectStorage; import com.studypals.global.request.Cursor; import com.studypals.global.responses.CursorResponse; @@ -52,6 +53,8 @@ public class GroupEntryServiceImpl implements GroupEntryService { private final ChatRoomWriter chatRoomWriter; + private final ObjectStorage objectStorage; + @Override @Transactional(readOnly = true) public GroupEntryCodeRes generateEntryCode(Long userId, Long groupId) { @@ -111,7 +114,7 @@ public CursorResponse.Content getEntryRequests(Long userId .map(r -> r.getMember().getId()) .toList()); List content = - GroupEntryRequestCustomMapper.map(entryRequests.getContent(), requestedMembers); + GroupEntryRequestCustomMapper.map(entryRequests.getContent(), requestedMembers, objectStorage); return new CursorResponse.Content<>( content, content.get(content.size() - 1).requestId(), entryRequests.hasNext()); diff --git a/src/main/java/com/studypals/domain/groupManage/service/GroupRankingServiceImpl.java b/src/main/java/com/studypals/domain/groupManage/service/GroupRankingServiceImpl.java index f8c984d8..af3b7831 100644 --- a/src/main/java/com/studypals/domain/groupManage/service/GroupRankingServiceImpl.java +++ b/src/main/java/com/studypals/domain/groupManage/service/GroupRankingServiceImpl.java @@ -13,6 +13,7 @@ import com.studypals.domain.groupManage.worker.GroupAuthorityValidator; import com.studypals.domain.groupManage.worker.GroupMemberReader; import com.studypals.domain.groupManage.worker.GroupRankingWorker; +import com.studypals.global.file.ObjectStorage; /** * groupRankingService 구현 클래스입니다. @@ -36,6 +37,8 @@ public class GroupRankingServiceImpl implements GroupRankingService { private final GroupMemberReader groupMemberReader; private final GroupAuthorityValidator validator; + private final ObjectStorage objectStorage; + @Override public List getGroupRanking(Long userId, Long groupId, GroupRankingPeriod period) { // 해당 유저가 속한 그룹인가? @@ -54,7 +57,8 @@ public List getGroupRanking(Long userId, Long groupId, Gr return new GroupMemberRankingDto( groupMember.getMember().getId(), groupMember.getMember().getNickname(), - groupMember.getMember().getImageUrl(), + objectStorage.convertKeyToFileUrl( + groupMember.getMember().getProfileImageObjectKey()), studySeconds, groupMember.getRole()); }) diff --git a/src/main/java/com/studypals/domain/groupManage/service/GroupServiceImpl.java b/src/main/java/com/studypals/domain/groupManage/service/GroupServiceImpl.java index c6f7d138..15390b72 100644 --- a/src/main/java/com/studypals/domain/groupManage/service/GroupServiceImpl.java +++ b/src/main/java/com/studypals/domain/groupManage/service/GroupServiceImpl.java @@ -25,6 +25,7 @@ import com.studypals.domain.studyManage.dto.GroupCategoryDto; import com.studypals.domain.studyManage.entity.StudyType; import com.studypals.domain.studyManage.worker.StudyCategoryReader; +import com.studypals.global.file.ObjectStorage; import com.studypals.global.retry.RetryTx; /** @@ -53,13 +54,14 @@ public class GroupServiceImpl implements GroupService { private final GroupAuthorityValidator validator; private final GroupMapper groupMapper; private final GroupGoalCalculator groupGoalCalculator; - private final GroupHashTagWorker groupHashTagWorker; // chat room worker class private final ChatRoomWriter chatRoomWriter; private final StudyCategoryReader studyCategoryReader; + private final ObjectStorage objectStorage; + @Override public List getGroupTags() { return groupReader.getGroupTags().stream().map(groupMapper::toTagDto).toList(); @@ -114,7 +116,8 @@ public List getGroups(Long userId) { .map(group -> GetGroupsRes.of( group, membersMap.getOrDefault(group.id(), Collections.emptyList()), - categoriesMap.getOrDefault(group.id(), Collections.emptyList()))) + categoriesMap.getOrDefault(group.id(), Collections.emptyList()), + objectStorage)) .toList(); } @@ -131,6 +134,6 @@ public GetGroupDetailRes getGroupDetails(Long userId, Long groupId) { // 그룹에 속한 유저들의 목표 달성률 계산 GroupTotalGoalDto userGoals = groupGoalCalculator.calculateGroupGoals(groupId, groupMembers); - return GetGroupDetailRes.of(group, groupMembers, userGoals); + return GetGroupDetailRes.of(group, groupMembers, userGoals, objectStorage); } } diff --git a/src/main/java/com/studypals/domain/memberManage/api/MemberController.java b/src/main/java/com/studypals/domain/memberManage/api/MemberController.java index 9bff73f6..1f44abb0 100644 --- a/src/main/java/com/studypals/domain/memberManage/api/MemberController.java +++ b/src/main/java/com/studypals/domain/memberManage/api/MemberController.java @@ -2,14 +2,18 @@ import jakarta.validation.Valid; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; import lombok.RequiredArgsConstructor; import com.studypals.domain.memberManage.dto.*; import com.studypals.domain.memberManage.service.MemberService; +import com.studypals.global.file.dto.ImageUploadRes; +import com.studypals.global.file.service.ImageFileService; import com.studypals.global.responses.CommonResponse; import com.studypals.global.responses.Response; import com.studypals.global.responses.ResponseCode; @@ -21,6 +25,8 @@ * - POST /register : 회원가입({@link CreateMemberReq}) * - GET /profile : 프로필 조회 * - PUT /profile : 프로필 수정 ({@link UpdateProfileReq}) + * - POST /profile : 프로필 이미지 업로드 + * - GET /register/check : 중복 체크 * * * @@ -31,6 +37,7 @@ @RequiredArgsConstructor public class MemberController { private final MemberService memberService; + private final ImageFileService imageFileService; @PostMapping("/register") public ResponseEntity> register(@Valid @RequestBody CreateMemberReq req) { @@ -54,6 +61,13 @@ public ResponseEntity> updateProfile( return ResponseEntity.ok(CommonResponse.success(ResponseCode.USER_UPDATE, id, "프로필 갱신을 성공하였습니다.")); } + @PostMapping(value = "/profile/image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity> uploadProfileImage( + @RequestPart("file") MultipartFile file, @AuthenticationPrincipal Long userId) { + ImageUploadRes response = imageFileService.uploadProfileImage(file, userId); + return ResponseEntity.ok(CommonResponse.success(ResponseCode.FILE_IMAGE_UPLOAD, response)); + } + @GetMapping("/register/check") public ResponseEntity> checkAvailability( @RequestParam(required = false) String username, @RequestParam(required = false) String nickname) { diff --git a/src/main/java/com/studypals/domain/memberManage/dao/MemberProfileImageRepository.java b/src/main/java/com/studypals/domain/memberManage/dao/MemberProfileImageRepository.java new file mode 100644 index 00000000..6c0def4d --- /dev/null +++ b/src/main/java/com/studypals/domain/memberManage/dao/MemberProfileImageRepository.java @@ -0,0 +1,7 @@ +package com.studypals.domain.memberManage.dao; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.studypals.domain.memberManage.entity.MemberProfileImage; + +public interface MemberProfileImageRepository extends JpaRepository {} diff --git a/src/main/java/com/studypals/domain/memberManage/dto/UpdateProfileReq.java b/src/main/java/com/studypals/domain/memberManage/dto/UpdateProfileReq.java index a04ba41c..794b3471 100644 --- a/src/main/java/com/studypals/domain/memberManage/dto/UpdateProfileReq.java +++ b/src/main/java/com/studypals/domain/memberManage/dto/UpdateProfileReq.java @@ -12,4 +12,4 @@ * @author jack8 * @since 2025-12-16 */ -public record UpdateProfileReq(@PastOrPresent LocalDate birthday, String position, String imageUrl) {} +public record UpdateProfileReq(@PastOrPresent LocalDate birthday, String position) {} diff --git a/src/main/java/com/studypals/domain/memberManage/dto/mappers/MemberMapper.java b/src/main/java/com/studypals/domain/memberManage/dto/mappers/MemberMapper.java index 75932050..217c1df1 100644 --- a/src/main/java/com/studypals/domain/memberManage/dto/mappers/MemberMapper.java +++ b/src/main/java/com/studypals/domain/memberManage/dto/mappers/MemberMapper.java @@ -1,9 +1,10 @@ package com.studypals.domain.memberManage.dto.mappers; -import org.mapstruct.Mapper; +import org.springframework.stereotype.Component; import com.studypals.domain.memberManage.dto.MemberDetailsRes; import com.studypals.domain.memberManage.entity.Member; +import com.studypals.global.file.ObjectStorage; /** * Member 에 대한 mapping 클래스입니다. @@ -16,8 +17,18 @@ * @author jack8 * @since 2025-04-16 */ -@Mapper(componentModel = "spring") -public interface MemberMapper { +@Component +public class MemberMapper { - MemberDetailsRes toRes(Member member); + public MemberDetailsRes toRes(Member member, ObjectStorage objectStorage) { + return MemberDetailsRes.builder() + .id(member.getId()) + .username(member.getUsername()) + .nickname(member.getNickname()) + .birthday(member.getBirthday()) + .imageUrl(objectStorage.convertKeyToFileUrl(member.getProfileImageObjectKey())) + .createdDate(member.getCreatedDate()) + .token(member.getToken()) + .build(); + } } diff --git a/src/main/java/com/studypals/domain/memberManage/entity/Member.java b/src/main/java/com/studypals/domain/memberManage/entity/Member.java index 610fe2cc..be90d36a 100644 --- a/src/main/java/com/studypals/domain/memberManage/entity/Member.java +++ b/src/main/java/com/studypals/domain/memberManage/entity/Member.java @@ -48,8 +48,9 @@ public class Member { @Column(name = "position", nullable = true, length = 255) private String position; - @Column(name = "image_url", nullable = true, length = 255) - private String imageUrl; + @OneToOne(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true) + @Setter + private MemberProfileImage profileImage; @Column(name = "created_at") @CreatedDate @@ -70,9 +71,16 @@ public Member(String username, String password, String nickname) { this.token = 0L; } - public void updateProfile(LocalDate birthday, String position, String imageUrl) { + public void updateProfile(LocalDate birthday, String position) { this.birthday = birthday; this.position = position; - this.imageUrl = imageUrl; + } + + /** + * 프로필 이미지의 Object Key를 반환합니다. + * 프로필 이미지가 없는 경우 null을 반환합니다. + */ + public String getProfileImageObjectKey() { + return this.profileImage != null ? this.profileImage.getObjectKey() : null; } } diff --git a/src/main/java/com/studypals/domain/memberManage/entity/MemberProfileImage.java b/src/main/java/com/studypals/domain/memberManage/entity/MemberProfileImage.java new file mode 100644 index 00000000..27a97742 --- /dev/null +++ b/src/main/java/com/studypals/domain/memberManage/entity/MemberProfileImage.java @@ -0,0 +1,76 @@ +package com.studypals.domain.memberManage.entity; + +import java.time.LocalDateTime; + +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; + +import org.springframework.data.annotation.LastModifiedDate; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import com.studypals.global.file.entity.ImageFile; +import com.studypals.global.file.entity.ImageStatus; + +/** + * 회원(Member)의 프로필 이미지 정보를 관리하는 엔티티입니다. + *

+ * 이 엔티티는 {@link ImageFile}을 상속받아 이미지 파일의 공통 메타데이터(objectKey, fileName 등)를 관리하며, + * {@link Member}와 일대일(One-to-One) 관계를 맺습니다. + * 프로필 이미지는 수정(update)이 가능하며, 이 때 기존 레코드를 재활용하여 새로운 이미지 정보로 갱신합니다. + * + *

주요 특징: + *

    + *
  • 상속 관계: {@link ImageFile}의 모든 속성을 상속받습니다.
  • + *
  • 연관 관계: {@link Member}와 1:1 관계를 맺습니다.
  • + *
  • 수정 기능: {@link #update} 메서드를 통해 기존 프로필 이미지를 새로운 이미지로 교체할 수 있습니다.
  • + *
  • 인덱싱: 이미지 처리 상태({@code imageStatus})와 생성일({@code createdAt})에 복합 인덱스가 설정되어 있어, + * 리사이징 등 비동기 처리 대상 조회 시 성능을 향상시킵니다.
  • + *
+ * + * @author sleepyhoon + * @since 2026-01-15 + * @see ImageFile + * @see Member + * @see com.studypals.global.file.service.ImageFileServiceImpl + */ +@Entity +@Getter +@SuperBuilder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table( + name = "member_profile_image", + indexes = @Index(name = "idx_member_profile_image_status_created_at", columnList = "imageStatus, createdAt")) +public class MemberProfileImage extends ImageFile { + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private Member member; + + @LastModifiedDate + private LocalDateTime updatedAt; + + /** + * 프로필 이미지 정보를 새로운 파일 정보로 업데이트합니다. + *

+ * 이 메서드는 엔티티의 상태를 변경하는 역할을 하며, 전달되는 값들은 + * 이미 서비스 레이어에서 유효성 검증이 완료되었다고 가정합니다. + * + * @param newObjectKey 새로 업로드된 파일의 Object Key + * @param newOriginalFileName 새로 업로드된 파일의 원본 이름 + * @param newMimeType 새로 업로드된 파일의 확장자 + */ + public void update(String newObjectKey, String newOriginalFileName, String newMimeType) { + this.setObjectKey(newObjectKey); + this.setOriginalFileName(newOriginalFileName); + this.setMimeType(newMimeType); + this.setImageStatus(ImageStatus.PENDING); // 새 파일이므로 리사이징을 위해 PENDING으로 상태 변경 + } +} diff --git a/src/main/java/com/studypals/domain/memberManage/service/MemberServiceImpl.java b/src/main/java/com/studypals/domain/memberManage/service/MemberServiceImpl.java index b44422ff..9b00058c 100644 --- a/src/main/java/com/studypals/domain/memberManage/service/MemberServiceImpl.java +++ b/src/main/java/com/studypals/domain/memberManage/service/MemberServiceImpl.java @@ -16,6 +16,7 @@ import com.studypals.domain.memberManage.worker.MemberWriter; import com.studypals.global.exceptions.errorCode.AuthErrorCode; import com.studypals.global.exceptions.exception.AuthException; +import com.studypals.global.file.ObjectStorage; /** * member service 의 구현 클래스입니다. @@ -41,6 +42,8 @@ public class MemberServiceImpl implements MemberService { private final PasswordEncoder passwordEncoder; private final MemberMapper memberMapper; + private final ObjectStorage objectStorage; + @Override @Transactional public Long createMember(CreateMemberReq dto) { @@ -64,7 +67,7 @@ public Long getMemberIdByUsername(String username) { public Long updateProfile(Long userId, UpdateProfileReq dto) { Member member = memberReader.get(userId); - member.updateProfile(dto.birthday(), dto.position(), dto.imageUrl()); + member.updateProfile(dto.birthday(), dto.position()); memberWriter.save(member); @@ -75,7 +78,7 @@ public Long updateProfile(Long userId, UpdateProfileReq dto) { @Transactional(readOnly = true) public MemberDetailsRes getProfile(Long userId) { Member member = memberReader.get(userId); - return memberMapper.toRes(member); + return memberMapper.toRes(member, objectStorage); } @Override diff --git a/src/main/java/com/studypals/domain/memberManage/worker/MemberProfileImageManager.java b/src/main/java/com/studypals/domain/memberManage/worker/MemberProfileImageManager.java new file mode 100644 index 00000000..8a372fd5 --- /dev/null +++ b/src/main/java/com/studypals/domain/memberManage/worker/MemberProfileImageManager.java @@ -0,0 +1,109 @@ +package com.studypals.domain.memberManage.worker; + +import java.util.List; +import java.util.UUID; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import com.studypals.domain.memberManage.entity.Member; +import com.studypals.domain.memberManage.entity.MemberProfileImage; +import com.studypals.global.file.FileProperties; +import com.studypals.global.file.FileUtils; +import com.studypals.global.file.ObjectStorage; +import com.studypals.global.file.dao.AbstractImageManager; +import com.studypals.global.file.entity.ImageType; +import com.studypals.global.file.entity.ImageVariantKey; + +/** + * 파일 중 프로필 이미지를 처리하는데 사용하는 구체 클래스입니다. + * + *

+ * - 프로필 이미지 업로드를 위해 Presigned URL을 사용합니다. + * - 프로필 이미지 조회를 위해 Public URL을 사용합니다. + * + *

상속 구조
+ * {@link AbstractImageManager} + * + * @author sleepyhoon + * @See AbstractImageManager + * @since 2026-01-13 + */ +@Component +public class MemberProfileImageManager extends AbstractImageManager { + + private static final String PROFILE_IMAGE_PATH = "origin/profile"; + + private final MemberReader memberReader; + private final MemberProfileImageWriter memberProfileImageWriter; + + public MemberProfileImageManager( + ObjectStorage objectStorage, + FileProperties properties, + MemberReader memberReader, + MemberProfileImageWriter memberProfileImageWriter) { + super(objectStorage, properties); + this.memberReader = memberReader; + this.memberProfileImageWriter = memberProfileImageWriter; + } + + @Override + protected String generateObjectKeyDetail(String targetId, String ext) { + // targetId는 userId + return PROFILE_IMAGE_PATH + "/" + targetId + "/" + UUID.randomUUID() + "." + ext; + } + + @Override + @Transactional + protected Long saveImage(Long userId, String targetId, String objectKey, String originalFileName) { + Member member = memberReader.get(userId); + MemberProfileImage currentProfile = member.getProfileImage(); + + if (currentProfile != null) { + // 기존 정보가 있으면 -> DB 업데이트 및 기존 파일 삭제 예약 + String oldObjectKey = currentProfile.getObjectKey(); + String extension = FileUtils.extractExtension(originalFileName); + + currentProfile.update(objectKey, originalFileName, extension); + + // 트랜잭션 커밋 성공 시 스토리지에서 구버전 파일 삭제 + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + delete(oldObjectKey); + } + }); + } + return currentProfile.getId(); + } else { + // 기존 프로필이 없으면 새로 저장 + MemberProfileImage savedImage = memberProfileImageWriter.save(member, objectKey, originalFileName); + + member.setProfileImage(savedImage); + return savedImage.getId(); + } + } + + @Override + protected List variants() { + return List.of(ImageVariantKey.SMALL, ImageVariantKey.MEDIUM); + } + + @Override + public ImageType getType() { + return ImageType.PROFILE_IMAGE; + } + + @Override + public boolean supports(ImageType type) { + return type == getType(); + } + + @Override + protected boolean usePresignedUrl() { + return false; // 프로필은 Public URL 사용 + } +} diff --git a/src/main/java/com/studypals/domain/memberManage/worker/MemberProfileImageWriter.java b/src/main/java/com/studypals/domain/memberManage/worker/MemberProfileImageWriter.java new file mode 100644 index 00000000..b408bb84 --- /dev/null +++ b/src/main/java/com/studypals/domain/memberManage/worker/MemberProfileImageWriter.java @@ -0,0 +1,54 @@ +package com.studypals.domain.memberManage.worker; + +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; + +import com.studypals.domain.memberManage.dao.MemberProfileImageRepository; +import com.studypals.domain.memberManage.entity.Member; +import com.studypals.domain.memberManage.entity.MemberProfileImage; +import com.studypals.global.annotations.Worker; +import com.studypals.global.file.FileUtils; +import com.studypals.global.file.entity.ImageStatus; + +/** + * 회원 프로필 이미지의 메타데이터를 데이터베이스에 저장하는 역할을 전담하는 Worker 클래스입니다. + *

+ * 이 클래스는 CQRS(Command Query Responsibility Segregation) 패턴의 'Command' 측면을 담당하며, + * 시스템의 상태를 변경하는 '쓰기(Write)' 작업에만 집중합니다. + * {@link Transactional} 어노테이션을 통해 데이터 저장 작업의 원자성을 보장합니다. + * + * @author sleepyhoon + * @since 2026-01-15 + * @see MemberProfileImage + * @see MemberProfileImageRepository + */ +@Worker +@RequiredArgsConstructor +public class MemberProfileImageWriter { + private final MemberProfileImageRepository memberProfileImageRepository; + + /** + * 회원 프로필 이미지의 메타데이터를 생성하고 데이터베이스에 저장합니다. + *

+ * 이 메서드는 서버가 클라이언트로부터 전달받은 파일을 스토리지에 업로드할 때 호출됩니다. + * 서버가 파일을 업로드하는 과정에서 해당 파일의 메타데이터를 데이터베이스에 저장하는 역할을 합니다. + * + * @param member 프로필 이미지가 속한 회원 엔티티 + * @param objectKey 스토리지에 저장될 파일의 고유 객체 키 + * @param fileName 원본 파일의 이름 + * @return 데이터베이스에 저장된 {@link MemberProfileImage}의 고유 ID + */ + @Transactional + public MemberProfileImage save(Member member, String objectKey, String fileName) { + String extension = FileUtils.extractExtension(fileName); + + return memberProfileImageRepository.save(MemberProfileImage.builder() + .member(member) + .objectKey(objectKey) + .originalFileName(fileName) + .mimeType(extension) + .imageStatus(ImageStatus.PENDING) + .build()); + } +} diff --git a/src/main/java/com/studypals/domain/memberManage/worker/MemberProfileManager.java b/src/main/java/com/studypals/domain/memberManage/worker/MemberProfileManager.java deleted file mode 100644 index d0cad9e8..00000000 --- a/src/main/java/com/studypals/domain/memberManage/worker/MemberProfileManager.java +++ /dev/null @@ -1,51 +0,0 @@ -package com.studypals.domain.memberManage.worker; - -import java.util.UUID; - -import org.springframework.stereotype.Component; - -import com.studypals.global.file.ObjectStorage; -import com.studypals.global.file.dao.AbstractImageManager; -import com.studypals.global.file.entity.ImageType; - -/** - * 파일 중 프로필 이미지를 처리하는데 사용하는 구체 클래스입니다. - * - *

- * - 프로필 이미지 업로드를 위해 Presigned URL을 사용합니다. - * - 프로필 이미지 조회를 위해 Public URL을 사용합니다. - * - *

상속 구조
- * {@link AbstractImageManager} - * - * @author sleepyhoon - * @See AbstractImageManager - * @since 2026-01-13 - */ -@Component -public class MemberProfileManager extends AbstractImageManager { - - private static final String PROFILE_PATH = "profile"; - - public MemberProfileManager(ObjectStorage objectStorage) { - super(objectStorage); - } - - /** - * 프로필 사진을 저장할 경로(objectKey) 지정합니다. - * @return 프로필 사진 조회에 필요한 경로(objectKey) 반환 - */ - @Override - protected String generateObjectKeyDetail(String targetId, String ext) { - return PROFILE_PATH + "/" + targetId + "/" + UUID.randomUUID() + "." + ext; - } - - /** - * 이 클래스는 프로필 이미지를 처리합니다. - * @return 처리하는 이미지 종류 - */ - @Override - public ImageType getFileType() { - return ImageType.PROFILE_IMAGE; - } -} diff --git a/src/main/java/com/studypals/global/configs/FileConfig.java b/src/main/java/com/studypals/global/configs/FileConfig.java new file mode 100644 index 00000000..c9306229 --- /dev/null +++ b/src/main/java/com/studypals/global/configs/FileConfig.java @@ -0,0 +1,14 @@ +package com.studypals.global.configs; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +import com.studypals.global.file.FileProperties; + +/** + * 파일 관련 설정을 담당하는 Configuration 클래스입니다. + * FileProperties 빈으로 등록하고 활성화합니다. + */ +@Configuration +@EnableConfigurationProperties(FileProperties.class) +public class FileConfig {} diff --git a/src/main/java/com/studypals/global/file/FileProperties.java b/src/main/java/com/studypals/global/file/FileProperties.java new file mode 100644 index 00000000..da98480d --- /dev/null +++ b/src/main/java/com/studypals/global/file/FileProperties.java @@ -0,0 +1,24 @@ +package com.studypals.global.file; + +import java.util.List; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Positive; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +/** + * 파일 업로드 관련 설정을 담는 {@link ConfigurationProperties} 클래스입니다. + *

+ * 이 클래스는 {@code application.yml} 파일의 {@code file.upload} 접두사를 가진 프로퍼티들을 + * 타입 안전하게 바인딩합니다. + * + * @param extensions 허용되는 파일 확장자 목록. {@code @NotEmpty} 제약조건이 적용되어, 최소 하나 이상의 확장자가 설정되어야 합니다. + * @param presignedUrlExpireTime Presigned URL의 만료 시간(초 단위). {@code @Positive} 제약조건이 적용되어, 반드시 양수여야 합니다. + * @author sleepyhoon + * @since 2026-01-10 + */ +@Validated +@ConfigurationProperties(prefix = "file.upload") +public record FileProperties(@NotEmpty List extensions, @Positive int presignedUrlExpireTime) {} diff --git a/src/main/java/com/studypals/global/file/FileType.java b/src/main/java/com/studypals/global/file/FileType.java new file mode 100644 index 00000000..37e2df20 --- /dev/null +++ b/src/main/java/com/studypals/global/file/FileType.java @@ -0,0 +1,25 @@ +package com.studypals.global.file; + +import com.studypals.global.file.entity.ImageType; + +/** + * 시스템에서 다루는 모든 파일의 종류를 나타내기 위한 최상위 마커(Marker) 인터페이스입니다. + *

+ * 이 인터페이스는 내부에 메서드를 가지지 않으며, 오직 타입을 그룹화하는 용도로만 사용됩니다. + * {@link ImageType}과 같이 파일을 종류별로 구분하는 모든 열거형(Enum)은 이 인터페이스를 구현해야 합니다. + *

+ * 설계 의도: + *

    + *
  • 타입 안전성 및 다형성: 서로 다른 파일 타입 Enum들을 공통된 {@code FileType}으로 다룰 수 있게 합니다.
  • + *
  • 확장성: 향후 'LogType' 등 새로운 파일 종류가 추가되더라도, + * 이 인터페이스를 구현함으로써 기존의 파일 관리 메커니즘(예: 전략 패턴)에 쉽게 통합될 수 있습니다.
  • + *
+ * 예를 들어, {@code ImageFileServiceImpl}에서는 {@code Map} 구조를 사용하여 + * 파일 타입에 따라 적절한 Manager를 동적으로 선택합니다. + * + * @author sleepyhoon + * @since 2026-01-10 + * @see ImageType + * @see com.studypals.global.file.dao.AbstractFileManager + */ +public interface FileType {} diff --git a/src/main/java/com/studypals/global/file/FileUtils.java b/src/main/java/com/studypals/global/file/FileUtils.java new file mode 100644 index 00000000..2d30eb7b --- /dev/null +++ b/src/main/java/com/studypals/global/file/FileUtils.java @@ -0,0 +1,44 @@ +package com.studypals.global.file; + +import com.studypals.global.exceptions.errorCode.FileErrorCode; +import com.studypals.global.exceptions.exception.FileException; + +/** + * 파일 관련 유틸리티 메서드를 제공하는 클래스입니다. + *

+ * 이 클래스의 모든 메서드는 상태를 가지지 않는 순수 함수 형태의 static 메서드입니다. + * 따라서 별도의 상태 관리나 의존성 주입이 필요 없어 Spring의 빈(Bean)으로 등록하지 않습니다. + * 외부에서 인스턴스화되는 것을 방지하기 위해 private 생성자를 가집니다. + * + * @author sleepyhoon + * @since 2026-01-10 + */ +public final class FileUtils { + + /** + * {@link FileUtils}는 유틸리티 클래스이므로 인스턴스화할 수 없습니다. + */ + private FileUtils() { + throw new UnsupportedOperationException("This is a utility class and cannot be instantiated"); + } + + /** + * 파일 이름에서 확장자를 추출합니다. + *

+ * 파일 이름에 점(.)이 없거나 마지막 문자가 점인 경우, 빈 문자열을 반환합니다. + * 추출된 확장자는 모두 소문자로 변환됩니다. + * + * @param fileName 파일 이름 (예: "image.JPG", "document.pdf") + * @return 추출된 소문자 확장자 (예: "jpg", "pdf") 또는 확장자가 없는 경우 빈 문자열 + */ + public static String extractExtension(String fileName) { + if (fileName == null || fileName.isBlank()) { + throw new FileException(FileErrorCode.INVALID_FILE_NAME); + } + int lastDotIndex = fileName.lastIndexOf("."); + if (lastDotIndex == -1 || lastDotIndex == fileName.length() - 1) { + return ""; // 확장자가 없는 경우 처리 + } + return fileName.substring(lastDotIndex + 1).toLowerCase(); + } +} diff --git a/src/main/java/com/studypals/global/file/ObjectStorage.java b/src/main/java/com/studypals/global/file/ObjectStorage.java index 947ea39c..9aa3b367 100644 --- a/src/main/java/com/studypals/global/file/ObjectStorage.java +++ b/src/main/java/com/studypals/global/file/ObjectStorage.java @@ -1,23 +1,67 @@ package com.studypals.global.file; +import org.springframework.web.multipart.MultipartFile; + /** - * Object Storage 의 인터페이스입니다. 메서드를 정의합니다. - * - *

확장성을 고려해 스토리지 관련 메서드를 인터페이스로 분리했습니다. - * - *

상속 정보:
- * MinioStorage의 부모 인터페이스입니다. + * Object Storage와의 상호작용을 위한 표준 인터페이스를 정의합니다. + *

+ * 이 인터페이스는 파일(객체)의 삭제, 경로 분석, Presigned URL 생성 등 + * 객체 스토리지에서 수행해야 하는 핵심 기능들을 추상화합니다. + * 실제 구현체(예: {@code MinioStorage})는 이 인터페이스를 구현하여 + * 특정 스토리지 기술(MinIO, AWS S3 등)에 대한 구체적인 로직을 제공합니다. + *

+ * 이를 통해 서비스 로직은 실제 스토리지 구현에 대한 의존성을 낮추고, + * 향후 다른 스토리지 시스템으로 유연하게 교체할 수 있습니다. * - * @author s0o0bn + * @author s0o0bn, sleepyhoon * @since 2025-04-11 */ public interface ObjectStorage { - void delete(String destination); + /** + * objectKey에서 fileUrl로 변환해줍니다. + * + * @param objectKey + * @return 클라이언트에서 바로 접근할 수 있는 파일 경로 + */ + String convertKeyToFileUrl(String objectKey); + /** + * 스토리지에 파일을 저장합니다. + * + * @param file 저장할 파일 + * @param objectKey 파일을 저장할 경로 + * @return 업로드된 파일에 접근할 수 있는 전체 URL + */ + String upload(MultipartFile file, String objectKey); + + /** + * 스토리지에서 지정된 객체(파일)를 삭제합니다. + * + * @param objectKey 삭제할 객체의 경로 + */ + void delete(String objectKey); + + /** + * 전체 파일 URL에서 객체 키(Object Key) 부분만 추출합니다. + *

+ * 예를 들어, "https://storage.example.com/bucket-name/path/to/object.jpg" 라는 URL이 주어졌을 때, + * "path/to/object.jpg" 부분을 반환합니다. + * + * @param url 전체 파일 URL + * @return 추출된 객체 키 + */ String parsePath(String url); + /** + * 객체 조회를 위한 Presigned URL을 생성합니다. + *

+ * 이 URL은 제한된 시간 동안만 유효하며, private 객체에 대한 임시적인 접근 권한을 부여합니다. + * 클라이언트는 이 URL을 사용하여 파일을 다운로드하거나 이미지 뷰어에 표시할 수 있습니다. + * + * @param objectKey Presigned URL을 생성할 객체의 고유 키 + * @param expirySeconds URL의 만료 시간 (초 단위) + * @return 생성된 Presigned GET URL + */ String createPresignedGetUrl(String objectKey, int expirySeconds); - - String createPresignedPutUrl(String objectKey, int expirySeconds); } diff --git a/src/main/java/com/studypals/global/file/api/ImageFileController.java b/src/main/java/com/studypals/global/file/api/ImageFileController.java deleted file mode 100644 index ad7d1a7e..00000000 --- a/src/main/java/com/studypals/global/file/api/ImageFileController.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.studypals.global.file.api; - -import jakarta.validation.Valid; - -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -import lombok.RequiredArgsConstructor; - -import com.studypals.global.file.dto.ChatPresignedUrlReq; -import com.studypals.global.file.dto.PresignedUrlRes; -import com.studypals.global.file.dto.ProfilePresignedUrlReq; -import com.studypals.global.file.service.ImageFileService; -import com.studypals.global.responses.CommonResponse; -import com.studypals.global.responses.Response; -import com.studypals.global.responses.ResponseCode; - -/** - * 파일 관련 로직을 처리하는 컨트롤러입니다. - * 파일 업로드는 서버 측에서 presigned url을 발급하고 클라이언트 측에서 진행합니다. - * - *

- *     - POST /files/image/profile : 프로필 사진 업로드를 위한 URL 발급
- *     - POST /files/image/chat : 채팅 사진 업로드를 위한 URL 발급
- * 
- * - * @author sleepyhoon - * @since 2026-01-10 - */ -@RestController -@RequestMapping("/files/image") -@RequiredArgsConstructor -public class ImageFileController { - private final ImageFileService imageFileService; - - @PostMapping("/profile") - public ResponseEntity> getUploadUrl( - @Valid @RequestBody ProfilePresignedUrlReq request, @AuthenticationPrincipal Long userId) { - PresignedUrlRes response = imageFileService.getProfileUploadUrl(request, userId); - return ResponseEntity.ok(CommonResponse.success(ResponseCode.FILE_IMAGE_UPLOAD, response)); - } - - @PostMapping("/chat") - public ResponseEntity> getUploadUrl( - @Valid @RequestBody ChatPresignedUrlReq request, @AuthenticationPrincipal Long userId) { - PresignedUrlRes response = imageFileService.getChatUploadUrl(request, userId); - return ResponseEntity.ok(CommonResponse.success(ResponseCode.FILE_IMAGE_UPLOAD, response)); - } -} diff --git a/src/main/java/com/studypals/global/file/dao/AbstractFileManager.java b/src/main/java/com/studypals/global/file/dao/AbstractFileManager.java index 56944649..cee3d1e0 100644 --- a/src/main/java/com/studypals/global/file/dao/AbstractFileManager.java +++ b/src/main/java/com/studypals/global/file/dao/AbstractFileManager.java @@ -1,50 +1,61 @@ package com.studypals.global.file.dao; +import org.springframework.web.multipart.MultipartFile; + import lombok.RequiredArgsConstructor; +import com.studypals.global.file.FileType; import com.studypals.global.file.ObjectStorage; -import com.studypals.global.file.entity.FileType; /** - * 파일을 처리하는데 사용하는 최상위 추상 클래스입니다. - * 파일을 다루며 Minio/S3 에 접근하는 클래스를 만들 경우, 해당 클래스를 상속해야 합니다. - * + * 모든 파일 관리자(Manager)의 최상위 추상 클래스입니다. *

- * 파일 종류와 상관 없이 파일 삭제, 파일 타입 반환, 파일 확장자 반환이 가능합니다. + * 이 클래스는 파일 관리에 필요한 공통 기능과 기본 계약을 정의합니다. + * 특정 도메인의 파일을 관리하는 모든 구체적인 Manager 클래스(예: {@link AbstractImageManager})는 + * 이 클래스를 상속받아야 합니다. * * @author sleepyhoon * @since 2026-01-14 + * @see ObjectStorage + * @see AbstractImageManager */ @RequiredArgsConstructor public abstract class AbstractFileManager { + + /** + * 실제 객체 스토리지와의 상호작용을 담당하는 구현체입니다. + * 하위 클래스에서 스토리지 기능에 접근할 수 있도록 {@code protected}로 선언되었습니다. + */ protected final ObjectStorage objectStorage; /** - * 파일을 삭제합니다. + * 스토리지에 저장된 파일을 삭제합니다. * - * @param url 삭제할 파일 URL + * @param objectKey 삭제할 파일의 객체 키(Object Key) */ - public void delete(String url) { - String destination = objectStorage.parsePath(url); - objectStorage.delete(destination); + public void delete(String objectKey) { + objectStorage.delete(objectKey); } /** - * 클래스가 담당하는 파일 타입을 반환합니다. - * @return 파일 타입 + * 이 Manager가 담당하는 파일의 종류({@link FileType})를 반환합니다. + *

+ * 이 추상 메서드는 하위 클래스에서 반드시 구현해야 합니다. + * 반환된 값은 {@code ImageFileServiceImpl} 등에서 적절한 Manager를 찾는 키로 사용됩니다. + * + * @return 이 Manager가 처리하는 {@link FileType} */ - public abstract FileType getFileType(); + public abstract FileType getType(); /** - * 파일 이름에서 확장자를 추출합니다. - * @param fileName 파일 이름 - * @return 추출한 확장자 이름 + * 파일을 스토리지에 업로드합니다. + *

+ * 이 추상 메서드는 하위 클래스에서 반드시 구현해야 합니다. + * @param file 업로드할 파일 + * @param objectKey 스토리지에 저장될 키 + * @return 업로드된 파일의 접근 URL */ - protected String extractExtension(String fileName) { - int lastDotIndex = fileName.lastIndexOf("."); - if (lastDotIndex == -1 || lastDotIndex == fileName.length() - 1) { - return ""; // 확장자가 없는 경우 처리 - } - return fileName.substring(lastDotIndex + 1).toLowerCase(); + protected String upload(MultipartFile file, String objectKey) { + return objectStorage.upload(file, objectKey); } } diff --git a/src/main/java/com/studypals/global/file/dao/AbstractImageManager.java b/src/main/java/com/studypals/global/file/dao/AbstractImageManager.java index 0ae6bbae..2c49a127 100644 --- a/src/main/java/com/studypals/global/file/dao/AbstractImageManager.java +++ b/src/main/java/com/studypals/global/file/dao/AbstractImageManager.java @@ -2,94 +2,199 @@ import java.util.List; -import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.multipart.MultipartFile; import com.studypals.global.exceptions.errorCode.FileErrorCode; import com.studypals.global.exceptions.exception.FileException; +import com.studypals.global.file.FileProperties; +import com.studypals.global.file.FileUtils; import com.studypals.global.file.ObjectStorage; +import com.studypals.global.file.dto.ImageUploadDto; +import com.studypals.global.file.dto.ImageUploadRes; +import com.studypals.global.file.entity.ImageType; +import com.studypals.global.file.entity.ImageVariantKey; /** - * * 파일을 처리하는데 사용하는 추상 클래스입니다. - * * - * *

- * * 파일을 업로드하기 위한 UploadUrl을 반환합니다. - * * 파일 조회(다운로드)의 경우 일부 도메인은 Public URL을 사용하고, 일부는 Presigned URL을 사용합니다. - * * 파일 삭제 시, URL에서 경로를 추출하여 스토리지에서 삭제합니다. + * 다양한 종류의 이미지 파일을 일관된 방식으로 처리하기 위한 추상 클래스입니다. + *

+ * 이를 통해 프로필 이미지, 채팅 이미지 등 각기 다른 도메인의 이미지 관리 로직을 + * 표준화된 프로세스에 따라 처리하면서도, 도메인별 경로 생성 정책 등은 유연하게 확장할 수 있습니다. * * @author sleepyhoon * @since 2026-01-13 */ public abstract class AbstractImageManager extends AbstractFileManager { - @Value("${file.upload.extensions}") - private List acceptableExtensions; + private final List acceptableExtensions; + private final int presignedUrlExpireTime; - @Value("${file.upload.presigned-url-expire-time}") - private int presignedUrlExpireTime; - - public AbstractImageManager(ObjectStorage objectStorage) { + /** + * {@code AbstractImageManager}의 생성자입니다. + * + * @param objectStorage 스토리지 상호작용을 위한 인터페이스 구현체 + * @param properties 파일 관련 설정값 (허용 확장자, Presigned URL 만료 시간 등) + */ + public AbstractImageManager(ObjectStorage objectStorage, FileProperties properties) { super(objectStorage); + this.acceptableExtensions = properties.extensions(); + this.presignedUrlExpireTime = properties.presignedUrlExpireTime(); } /** - * 파일 업로드를 위한 Presigned URL을 발급합니다. - * 내부적으로 파일 이름 검증과 타겟 ID 검증을 수행합니다. - * 해당 메서드는 재정의할 수 없습니다. - * @param userId 업로드 요청한 사용자 ID - * @param fileName 업로드할 파일 이름 - * @param targetId 업로드 대상 식별자 (예: userId, groupId, chatRoomId) - * @return 업로드 가능한 Presigned URL + * 파일 업로드를 위한 Presigned URL을 생성하여 반환합니다. + *

+ * 이 메서드는 템플릿의 일부로, 모든 하위 클래스에서 동일한 방식으로 동작해야 하므로 {@code final}로 선언되었습니다. + * 실제 URL 생성은 {@link ObjectStorage} 구현체에 위임합니다. + * + * @param objectKey 스토리지에 저장될 객체의 고유 키 + * @return 업로드 전용 Presigned URL */ - public final String getUploadUrl(Long userId, String fileName, String targetId) { - validateFileName(fileName); - validateTargetId(userId, targetId); - String objectKey = generateObjectKey(fileName, targetId); - return objectStorage.createPresignedPutUrl(objectKey, presignedUrlExpireTime); + public String getPresignedGetUrl(String objectKey) { + return objectStorage.createPresignedGetUrl(objectKey, presignedUrlExpireTime); } /** - * MinIO/S3 에 저장할 경로(ObjectKey)를 생성합니다. - * @param fileName 이미지 이름 - * @param targetId 업로드 대상 식별자 (예: userId, groupId, chatRoomId) - * @return 생성된 ObjectKey + * 이미지 업로드 및 메타데이터 저장을 수행하는 템플릿 메서드입니다. + *

+ * 1. 스토리지 업로드 ({@link #uploadImage})
+ * 2. DB 메타데이터 저장 ({@link #saveImage})
+ * 과정을 순차적으로 실행합니다. + * + * @param file 업로드할 파일 + * @param userId 요청한 사용자 ID + * @param targetId 대상 식별자 (프로필인 경우 userId, 채팅인 경우 chatRoomId) + * @return 업로드된 이미지 정보 (ID, URL) */ - private String generateObjectKey(String fileName, String targetId) { - String ext = extractExtension(fileName); - return generateObjectKeyDetail(targetId, ext); + public ImageUploadRes upload(MultipartFile file, Long userId, String targetId) { + ImageUploadDto uploadDto = uploadImage(file, userId, targetId); + Long imageId = saveImage(userId, targetId, uploadDto.objectKey(), file.getOriginalFilename()); + return new ImageUploadRes(imageId, uploadDto.imageUrl()); } /** - * 프로필, 채팅 이미지의 경로(ObjectKey)가 다르기 때문에 구체 클래스에서 구현해야 합니다. - * @param targetId 업로드 대상 식별자 (예: userId, groupId, chatRoomId) - * @param ext 파일 확장자 - * @return 생성된 ObjectKey + * 실제 파일 업로드를 수행하는 공통 메서드입니다. + *

+ * 하위 클래스의 {@code upload} 메서드에서 호출되며, 다음 과정을 수행합니다: + *

    + *
  1. {@link #createObjectKey}를 호출하여 저장 경로(Object Key)를 생성합니다.
  2. + *
  3. 부모 클래스의 {@link AbstractFileManager#upload}를 호출하여 실제 스토리지 업로드를 수행합니다.
  4. + *
  5. 업로드된 결과(Key, URL)를 DTO로 변환하여 반환합니다.
  6. + *
+ * + * @param file 업로드할 멀티파트 파일 + * @param userId 요청한 사용자 ID + * @param targetId 업로드 대상 식별자 (예: 사용자 ID, 채팅방 ID) + * @return 업로드된 파일의 키와 URL 정보를 담은 DTO */ - protected abstract String generateObjectKeyDetail(String targetId, String ext); + protected ImageUploadDto uploadImage(MultipartFile file, Long userId, String targetId) { + String objectKey = createObjectKey(userId, file.getOriginalFilename(), targetId); + String imageUrl = super.upload(file, objectKey); + + if (usePresignedUrl()) { + imageUrl = getPresignedGetUrl(objectKey); + } + + return new ImageUploadDto(objectKey, imageUrl); + } /** - * targetId의 유효성을 검증합니다. - * 기본적으로는 아무런 검증도 수행하지 않으며(Hook Method), - * 검증이 필요한 구체 클래스에서 이 메서드를 오버라이드하여 구현합니다. + * 스토리지에 저장될 고유한 객체 키(Object Key)를 생성하는 템플릿 메서드입니다. + *

+ * 이 메서드는 {@code final}로 선언되어 있으며, 다음과 같은 정해진 순서로 동작합니다. + *

    + *
  1. {@link #validateFileName}: 파일 이름과 확장자를 검증합니다.
  2. + *
  3. {@link #validateTargetId}: 하위 클래스에서 재정의 가능한 대상 ID 유효성을 검증합니다 (Hook).
  4. + *
* - * @param userId 검증할 사용자 ID - * @param targetId 검증할 대상 식별자 - * @throws IllegalArgumentException 유효하지 않은 targetId인 경우 + * @param userId 업로드를 요청한 사용자 ID + * @param fileName 원본 파일 이름 + * @param targetId 업로드 대상의 식별자 (예: 사용자 ID, 채팅방 ID 등) + * @return 생성된 고유 객체 키 */ - protected void validateTargetId(Long userId, String targetId) { - // 기본 구현: 검증 없음 + public String createObjectKey(Long userId, String fileName, String targetId) { + // 1. 검증과 동시에 정규화된 확장자를 받아옵니다. + String normalizedExtension = validateAndGetExtension(fileName); + + validateTargetId(userId, targetId); + + // 2. 하위 클래스에 넘겨줍니다. + return generateObjectKeyDetail(targetId, normalizedExtension); } /** - * 사전에 정해둔 파일 확장자를 가지는지 확인합니다. - * @param fileName 확인할 파일 이름 + * 파일 이름의 유효성과 확장자를 검증합니다. + * 파일 이름이 null이거나 '.'을 포함하지 않는 경우, 또는 허용되지 않은 확장자인 경우 예외를 발생시킵니다. + * TODO: 강도높은 유효성 검증을 위해 Tika, ImageIO을 추가로 도입할 수 있습니다. + * @param fileName 검증할 파일 이름 + * @throws FileException 유효하지 않은 파일 이름 또는 지원하지 않는 확장자인 경우 + * @return 유효한 파일 확장자값 */ - private void validateFileName(String fileName) { + private String validateAndGetExtension(String fileName) { if (fileName == null || !fileName.contains(".")) { throw new FileException(FileErrorCode.INVALID_FILE_NAME); } - String extension = extractExtension(fileName); + + // 확장자 추출 및 소문자 변환 (NPE 방지 및 대소문자 문제 해결) + String extension = FileUtils.extractExtension(fileName).toLowerCase(); + if (!acceptableExtensions.contains(extension)) { throw new FileException(FileErrorCode.UNSUPPORTED_FILE_IMAGE_EXTENSION); } + return extension; } + + /** + * 대상 식별자(targetId)의 유효성을 검증하는 Hook 메서드입니다. + *

+ * 기본적으로는 아무런 검증을 수행하지 않습니다. + * 특정 도메인에서 추가적인 검증(예: 채팅방 멤버 여부 확인)이 필요한 경우, + * 하위 클래스에서 이 메서드를 재정의(Override)하여 사용합니다. + * + * @param userId 검증을 요청한 사용자 ID + * @param targetId 검증할 대상 식별자 + * @throws RuntimeException 유효성 검증에 실패할 경우 적절한 예외를 발생시킬 수 있습니다. + */ + protected void validateTargetId(Long userId, String targetId) { + // 기본 구현은 비어 있으며, 하위 클래스에서 필요에 따라 재정의합니다. + } + + /** + * 객체 키의 상세 경로를 생성하는 추상 메서드입니다. + *

+ * 이 메서드는 하위 클래스에서 반드시 구현해야 합니다. + * 이미지의 종류(프로필, 채팅 등)에 따라 달라지는 저장 경로 구조를 정의하는 역할을 합니다. + * + * @param targetId 업로드 대상 식별자 (예: 사용자 ID, 채팅방 ID) + * @param ext 파일 확장자 + * @return 도메인에 특화된 경로가 포함된 최종 객체 키 + */ + protected abstract String generateObjectKeyDetail(String targetId, String ext); + + /** + * 업로드된 이미지 정보를 데이터베이스에 저장합니다. + * + * @param userId 요청 사용자 ID + * @param targetId 대상 식별자 + * @param objectKey 이미지 저장 경로 + * @param originalFileName 원본 파일명 + * @return 저장된 이미지의 PK (ID) + */ + protected abstract Long saveImage(Long userId, String targetId, String objectKey, String originalFileName); + + /** + * 이 Manager가 처리하는 이미지의 다양한 크기 버전(Variant) 정보를 반환합니다. + * 하위 클래스는 이 메서드를 구현하여 원본, 썸네일 등 필요한 이미지 종류를 정의해야 합니다. + * + * @return {@link ImageVariantKey} 리스트 + */ + protected abstract List variants(); + + /** + * 업로드 완료 후 반환할 URL을 Presigned URL로 할지 여부를 결정합니다. + * + * @return true면 Presigned URL 반환, false면 업로드 시 반환된 URL(Public) 사용 + */ + protected abstract boolean usePresignedUrl(); + + public abstract boolean supports(ImageType fileType); } diff --git a/src/main/java/com/studypals/global/file/dto/ChatPresignedUrlReq.java b/src/main/java/com/studypals/global/file/dto/ChatPresignedUrlReq.java deleted file mode 100644 index be7c76d9..00000000 --- a/src/main/java/com/studypals/global/file/dto/ChatPresignedUrlReq.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.studypals.global.file.dto; - -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; - -public record ChatPresignedUrlReq( - @NotNull(message = "파일 이름은 필수입니다.") @NotBlank(message = "파일 이름은 공백일 수 없습니다.") String fileName, - @NotNull(message = "채팅방 ID는 필수입니다.") @NotBlank(message = "채팅방 ID는 공백일 수 없습니다.") String chatRoomId) {} diff --git a/src/main/java/com/studypals/global/file/dto/ImageUploadDto.java b/src/main/java/com/studypals/global/file/dto/ImageUploadDto.java new file mode 100644 index 00000000..a0fd6c13 --- /dev/null +++ b/src/main/java/com/studypals/global/file/dto/ImageUploadDto.java @@ -0,0 +1,12 @@ +package com.studypals.global.file.dto; + +/** + * 이미지 파일 업로드 완료 후 반환되는 데이터 전송 객체(DTO)입니다. + *

+ * 스토리지에 저장된 파일의 고유 식별자(Object Key)와 + * 클라이언트가 접근 가능한 URL 정보를 포함합니다. + * + * @param objectKey 스토리지에 저장된 객체의 고유 키 (예: "profile/1/uuid.jpg") + * @param imageUrl 이미지에 접근할 수 있는 전체 URL (예: "http://cdn.example.com/profile/1/uuid.jpg") + */ +public record ImageUploadDto(String objectKey, String imageUrl) {} diff --git a/src/main/java/com/studypals/global/file/dto/ImageUploadRes.java b/src/main/java/com/studypals/global/file/dto/ImageUploadRes.java new file mode 100644 index 00000000..d3021875 --- /dev/null +++ b/src/main/java/com/studypals/global/file/dto/ImageUploadRes.java @@ -0,0 +1,18 @@ +package com.studypals.global.file.dto; + +/** + * 이미지 업로드 완료 후 반환되는 응답 DTO입니다. + * + * @param imageId 데이터베이스에 저장된 이미지의 고유 ID (PK). + * 추후 비즈니스 로직(예: 회원 정보 수정, 채팅 메시지 전송)에서 이 ID를 참조합니다. + * @param imageUrl 업로드된 이미지를 즉시 조회할 수 있는 URL. + *

+ * 스토리지 설정에 따라 다음 중 하나가 반환됩니다: + *

    + *
  • Public 버킷인 경우: 영구적인 정적 URL
  • + *
  • Private 버킷인 경우: 일정 시간 동안 유효한 GET Presigned URL
  • + *
+ * @author sleepyhoon + * @since 2026-01-15 + */ +public record ImageUploadRes(Long imageId, String imageUrl) {} diff --git a/src/main/java/com/studypals/global/file/dto/PresignedUrlRes.java b/src/main/java/com/studypals/global/file/dto/PresignedUrlRes.java deleted file mode 100644 index 7bbf6e8e..00000000 --- a/src/main/java/com/studypals/global/file/dto/PresignedUrlRes.java +++ /dev/null @@ -1,3 +0,0 @@ -package com.studypals.global.file.dto; - -public record PresignedUrlRes(String url) {} diff --git a/src/main/java/com/studypals/global/file/dto/ProfilePresignedUrlReq.java b/src/main/java/com/studypals/global/file/dto/ProfilePresignedUrlReq.java deleted file mode 100644 index 159c67ba..00000000 --- a/src/main/java/com/studypals/global/file/dto/ProfilePresignedUrlReq.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.studypals.global.file.dto; - -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; - -public record ProfilePresignedUrlReq(@NotNull @NotBlank String fileName) {} diff --git a/src/main/java/com/studypals/global/file/entity/FileType.java b/src/main/java/com/studypals/global/file/entity/FileType.java deleted file mode 100644 index af88d389..00000000 --- a/src/main/java/com/studypals/global/file/entity/FileType.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.studypals.global.file.entity; - -/** - * 파일의 종류를 나타내는 최상위 마커 인터페이스입니다.

- * 모든 파일 타입 enum은 이 인터페이스를 구현해야 합니다. - * - * @author sleepyhoon - * @since 2026-01-10 - */ -public interface FileType {} diff --git a/src/main/java/com/studypals/global/file/entity/ImageFile.java b/src/main/java/com/studypals/global/file/entity/ImageFile.java new file mode 100644 index 00000000..cf443aee --- /dev/null +++ b/src/main/java/com/studypals/global/file/entity/ImageFile.java @@ -0,0 +1,104 @@ +package com.studypals.global.file.entity; + +import java.time.LocalDateTime; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.MappedSuperclass; + +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.SuperBuilder; + +/** + * 객체 스토리지에 저장된 이미지 파일의 메타데이터를 관리하는 엔티티의 공통 속성을 정의하는 추상 클래스입니다. + * {@code @MappedSuperclass}를 사용하여 이 클래스를 상속하는 엔티티들은 아래 필드들을 자신의 컬럼으로 포함하게 됩니다. + * + * objectKey, originalFileName, mimeType, imageStatus의 경우 protected setter를 가집니다. 이는 오직 수정 가능한 이미지 한정으로 사용합니다. + * + * @author sleepyhoon + * @since 2026-01-13 + */ +@Getter +@SuperBuilder +@AllArgsConstructor +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public abstract class ImageFile { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", nullable = false, unique = true) + private Long id; + + /** + * 객체 스토리지(예: MinIO, S3) 내에서 파일을 식별하는 고유한 키입니다. + * 예: "profile/1/uuid.jpg" + */ + @Column(nullable = false, unique = true) + @Setter(AccessLevel.PROTECTED) + private String objectKey; + + /** + * 사용자가 업로드한 원본 파일의 이름입니다. + * 예: "my_vacation_photo.jpg" + */ + @Column(nullable = false) + @Setter(AccessLevel.PROTECTED) + private String originalFileName; + + /** + * 파일의 MIME 타입입니다. + * 예: "jpg" + */ + @Column(nullable = false) + @Setter(AccessLevel.PROTECTED) + private String mimeType; + + /** + * 이미지의 처리 상태입니다. + *

+ * - PENDING: 처리 대기 중 (리사이징 전) + * - COMPLETE: 처리 완료 (리사이징 완료) + * - FAILED: 처리 실패 (재시도 필요) + */ + @Column(nullable = false) + @Enumerated(EnumType.STRING) + @Builder.Default + @Setter(AccessLevel.PROTECTED) + private ImageStatus imageStatus = ImageStatus.PENDING; + + /** + * 이미지가 업로드된 시간입니다. + */ + @CreatedDate + @Column(updatable = false) + private LocalDateTime createdAt; + + /** + * 이미지 처리가 완료되었음을 표시합니다. + */ + public void complete() { + this.imageStatus = ImageStatus.COMPLETE; + } + + /** + * 이미지 처리 중 오류가 발생했음을 표시합니다. + */ + public void fail() { + this.imageStatus = ImageStatus.FAILED; + } +} diff --git a/src/main/java/com/studypals/global/file/entity/ImageStatus.java b/src/main/java/com/studypals/global/file/entity/ImageStatus.java new file mode 100644 index 00000000..bf2aa4e5 --- /dev/null +++ b/src/main/java/com/studypals/global/file/entity/ImageStatus.java @@ -0,0 +1,29 @@ +package com.studypals.global.file.entity; + +/** + * 이미지 파일의 업로드 상태를 나타내는 열거형(Enum) 클래스입니다. + *

+ * 서버 직접 업로드 방식에서 이미지의 리사이징 및 저장 처리 상태를 관리하는 데 사용됩니다. + * 특히 리사이징 실패 시 재시도 로직을 위한 상태 구분에 활용됩니다. + * + * @author sleepyhoon + * @since 2026-01-15 + */ +public enum ImageStatus { + /** + * 이미지 메타데이터가 생성되었으나, 아직 리사이징 등 후속 처리가 완료되지 않은 대기 상태입니다. + */ + PENDING, + + /** + * 리사이징 및 스토리지 저장이 성공적으로 완료된 상태입니다. + * 서비스에서 정상적으로 조회 가능한 상태입니다. + */ + COMPLETE, + + /** + * 리사이징이나 업로드 과정에서 오류가 발생한 상태입니다. + * 추후 배치 작업 등을 통해 재시도를 수행할 수 있습니다. + */ + FAILED +} diff --git a/src/main/java/com/studypals/global/file/entity/ImageType.java b/src/main/java/com/studypals/global/file/entity/ImageType.java index 2868a7f4..a43fd799 100644 --- a/src/main/java/com/studypals/global/file/entity/ImageType.java +++ b/src/main/java/com/studypals/global/file/entity/ImageType.java @@ -1,13 +1,29 @@ package com.studypals.global.file.entity; +import com.studypals.global.file.FileType; + /** - * 파일 이미지 타입을 정의합니다.

- * 현재 프로필, 채팅 이미지만 고려합니다. + * 시스템에서 다루는 이미지의 종류를 정의하는 열거형(Enum) 클래스입니다. + *

+ * 이 Enum은 {@link FileType} 인터페이스를 구현하며, 각 상수는 특정 도메인에서 사용되는 + * 이미지의 유형을 나타냅니다. (예: 사용자 프로필, 채팅 메시지) + *

+ * {@code ImageFileServiceImpl}에서는 이 타입을 키로 사용하여 + * 적절한 {@code AbstractImageManager}를 동적으로 선택하는 전략 패턴을 구현합니다. * * @author sleepyhoon * @since 2026-01-10 + * @see FileType + * @see com.studypals.global.file.service.ImageFileServiceImpl */ public enum ImageType implements FileType { + /** + * 사용자 프로필 이미지를 나타냅니다. + */ PROFILE_IMAGE, + + /** + * 채팅 메시지에서 사용되는 이미지를 나타냅니다. + */ CHAT_IMAGE } diff --git a/src/main/java/com/studypals/global/file/entity/ImageVariantKey.java b/src/main/java/com/studypals/global/file/entity/ImageVariantKey.java new file mode 100644 index 00000000..3d7bac76 --- /dev/null +++ b/src/main/java/com/studypals/global/file/entity/ImageVariantKey.java @@ -0,0 +1,44 @@ +package com.studypals.global.file.entity; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * 이미지의 다양한 크기 버전(Variant)을 정의하는 열거형(Enum) 클래스입니다. + *

+ * 원본 이미지를 스토리지에 업로드한 후, 썸네일, 중간 크기 이미지 등 다양한 크기의 + * 파생 이미지를 생성하고 관리하는 데 사용될 수 있습니다. + * 각 상수는 특정 크기(픽셀 단위)를 정의합니다. + *

+ * 예를 들어, {@code AbstractImageManager}의 하위 클래스에서 이 Enum을 사용하여 + * 어떤 크기의 이미지들을 생성하고 관리할지 명시할 수 있습니다. + * + * @author sleepyhoon + * @since 2026-01-16 + * @see com.studypals.global.file.dao.AbstractImageManager + */ +@Getter +@RequiredArgsConstructor +public enum ImageVariantKey { + + /** + * 작은 크기 (256px). 주로 썸네일이나 목록 뷰에 사용하기에 적합합니다. + */ + SMALL(256), + + /** + * 중간 크기 (512px). 일반적인 콘텐츠 뷰에 사용하기에 적합합니다. + */ + MEDIUM(512), + + /** + * 큰 크기 (1024px). 상세 보기나 전체 화면 표시에 사용하기에 적합합니다. + */ + LARGE(1024); + + /** + * 해당 이미지 크기 버전의 한 변의 길이(픽셀 단위)입니다. + * 일반적으로 정사각형 이미지의 가로/세로 크기를 의미합니다. + */ + private final int size; +} diff --git a/src/main/java/com/studypals/global/file/service/ImageFileService.java b/src/main/java/com/studypals/global/file/service/ImageFileService.java index f8cd24a4..d0603167 100644 --- a/src/main/java/com/studypals/global/file/service/ImageFileService.java +++ b/src/main/java/com/studypals/global/file/service/ImageFileService.java @@ -1,8 +1,8 @@ package com.studypals.global.file.service; -import com.studypals.global.file.dto.ChatPresignedUrlReq; -import com.studypals.global.file.dto.PresignedUrlRes; -import com.studypals.global.file.dto.ProfilePresignedUrlReq; +import org.springframework.web.multipart.MultipartFile; + +import com.studypals.global.file.dto.ImageUploadRes; /** * 파일을 처리하는 로직을 정의한 인터페이스입니다. @@ -16,16 +16,19 @@ */ public interface ImageFileService { /** - * 프로필 이미지 업로드를 위한 URL을 발급합니다. - * @param request 프로필 파일 이름 정보가 담긴 요청 DTO - * @return 업로드 가능한 URL + * 프로필 이미지를 스토리지에 업로드합니다. + * @param file 업로드할 이미지 파일 + * @param userId 요청한 사용자 ID + * @return 업로드된 파일 정보 (ID, Access URL) */ - PresignedUrlRes getProfileUploadUrl(ProfilePresignedUrlReq request, Long userId); + ImageUploadRes uploadProfileImage(MultipartFile file, Long userId); /** - * 채팅 이미지 업로드를 위한 URL을 발급합니다. - * @param request 채팅 파일 이름과 타겟 ID 정보가 담긴 요청 DTO - * @return 업로드 가능한 URL + * 채팅 이미지를 스토리지에 업로드합니다. + * @param file 업로드할 이미지 파일 + * @param chatRoomId 채팅방 ID + * @param userId 요청한 사용자 ID + * @return 업로드된 파일 정보 (ID, Access URL) */ - PresignedUrlRes getChatUploadUrl(ChatPresignedUrlReq request, Long userId); + ImageUploadRes uploadChatImage(MultipartFile file, String chatRoomId, Long userId); } diff --git a/src/main/java/com/studypals/global/file/service/ImageFileServiceImpl.java b/src/main/java/com/studypals/global/file/service/ImageFileServiceImpl.java index 6d50baa7..739638f3 100644 --- a/src/main/java/com/studypals/global/file/service/ImageFileServiceImpl.java +++ b/src/main/java/com/studypals/global/file/service/ImageFileServiceImpl.java @@ -1,71 +1,58 @@ package com.studypals.global.file.service; -import java.util.List; -import java.util.Map; -import java.util.function.Function; -import java.util.stream.Collectors; - import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import lombok.RequiredArgsConstructor; -import com.studypals.global.exceptions.errorCode.FileErrorCode; -import com.studypals.global.exceptions.exception.FileException; -import com.studypals.global.file.dao.AbstractFileManager; import com.studypals.global.file.dao.AbstractImageManager; -import com.studypals.global.file.dto.ChatPresignedUrlReq; -import com.studypals.global.file.dto.PresignedUrlRes; -import com.studypals.global.file.dto.ProfilePresignedUrlReq; -import com.studypals.global.file.entity.FileType; +import com.studypals.global.file.dto.ImageUploadRes; import com.studypals.global.file.entity.ImageType; +import com.studypals.global.file.worker.ImageManagerFactory; /** - * 파일을 처리하는 로직을 정의한 구현 클래스입니다. - * 파일 업로드를 위한 presigned url을 발급을 진행합니다. + * 이미지 파일 관련 비즈니스 로직을 처리하는 서비스 구현체입니다. + *

+ * 이 서비스는 클라이언트로부터 받은 파일을 스토리지에 직접 업로드하는 역할을 담당합니다. + * {@link ImageType}에 따라 적절한 {@link AbstractImageManager}를 동적으로 선택하여 로직을 위임하는 전략 패턴을 사용합니다. + * 이를 통해 새로운 이미지 타입이 추가되더라도 서비스 코드의 변경 없이 유연하게 확장할 수 있습니다. * * @author sleepyhoon * @since 2026-01-10 + * @see ImageFileService + * @see AbstractImageManager */ @Service +@RequiredArgsConstructor public class ImageFileServiceImpl implements ImageFileService { - private final Map managerMap; - - public ImageFileServiceImpl(List managers) { - this.managerMap = managers.stream() - .collect(Collectors.toMap( - AbstractFileManager::getFileType, Function.identity(), (existing, duplicate) -> { - throw new IllegalStateException(String.format( - "ImageType 중복 등록 오류. '%s' 타입이 '%s'와 '%s' 클래스에서 중복으로 처리됩니다.", - existing.getFileType(), - existing.getClass().getName(), - duplicate.getClass().getName())); - })); - } + private final ImageManagerFactory imageManagerFactory; + /** + * 사용자 프로필 이미지를 업로드합니다. + * + * @param file 업로드할 이미지 파일 + * @param userId Presigned URL을 요청한 사용자의 ID + * @return 생성된 이미지 ID와 접근 URL이 포함된 응답 DTO + */ @Override - public PresignedUrlRes getProfileUploadUrl(ProfilePresignedUrlReq request, Long userId) { - AbstractImageManager manager = getManager(ImageType.PROFILE_IMAGE, AbstractImageManager.class); - String uploadUrl = manager.getUploadUrl(userId, request.fileName(), String.valueOf(userId)); - return new PresignedUrlRes(uploadUrl); + public ImageUploadRes uploadProfileImage(MultipartFile file, Long userId) { + AbstractImageManager manager = imageManagerFactory.getManager(ImageType.PROFILE_IMAGE); + // 프로필 이미지의 targetId는 userId와 동일하게 취급 + return manager.upload(file, userId, String.valueOf(userId)); } + /** + * 채팅방 내 이미지를 업로드합니다. + * + * @param file 업로드할 이미지 파일 + * @param chatRoomId 채팅방 ID + * @param userId Presigned URL을 요청한 사용자의 ID + * @return 생성된 이미지 ID와 접근 URL이 포함된 응답 DTO + */ @Override - public PresignedUrlRes getChatUploadUrl(ChatPresignedUrlReq request, Long userId) { - AbstractImageManager manager = getManager(ImageType.CHAT_IMAGE, AbstractImageManager.class); - String uploadUrl = manager.getUploadUrl(userId, request.fileName(), request.chatRoomId()); - return new PresignedUrlRes(uploadUrl); - } - - private T getManager(FileType fileType, Class managerClass) { - AbstractFileManager manager = managerMap.get(fileType); - if (manager == null) { - throw new FileException(FileErrorCode.UNSUPPORTED_FILE_IMAGE_TYPE); - } - if (!managerClass.isInstance(manager)) { - // 잘못된 타입의 Manager가 매핑된 경우, 이는 심각한 설정 오류입니다. - throw new IllegalStateException(String.format( - "요청된 FileType '%s'에 대한 Manager 타입이 일치하지 않습니다. 기대값: %s, 실제값: %s", - fileType, managerClass.getName(), manager.getClass().getName())); - } - return managerClass.cast(manager); + public ImageUploadRes uploadChatImage(MultipartFile file, String chatRoomId, Long userId) { + AbstractImageManager manager = imageManagerFactory.getManager(ImageType.CHAT_IMAGE); + return manager.upload(file, userId, chatRoomId); } } diff --git a/src/main/java/com/studypals/global/file/worker/ImageManagerFactory.java b/src/main/java/com/studypals/global/file/worker/ImageManagerFactory.java new file mode 100644 index 00000000..97bdced7 --- /dev/null +++ b/src/main/java/com/studypals/global/file/worker/ImageManagerFactory.java @@ -0,0 +1,49 @@ +package com.studypals.global.file.worker; + +import java.util.List; + +import org.springframework.stereotype.Component; + +import com.studypals.global.exceptions.errorCode.FileErrorCode; +import com.studypals.global.exceptions.exception.FileException; +import com.studypals.global.file.FileType; +import com.studypals.global.file.dao.AbstractImageManager; +import com.studypals.global.file.entity.ImageType; + +/** + * 이미지 파일 처리를 담당하는 매니저({@link AbstractImageManager})들을 관리하고 제공하는 팩토리 클래스입니다. + *

+ * 이 클래스는 애플리케이션 구동 시 Spring Context에 등록된 모든 {@link AbstractImageManager} 구현체를 수집하여 + * {@link FileType}을 키로 하는 Map으로 초기화합니다. + * 이후 비즈니스 로직에서 특정 이미지 타입에 대한 처리가 필요할 때, 적절한 구현체를 찾아 반환하는 역할을 수행합니다. + *

+ * 이를 통해 전략 패턴(Strategy Pattern)을 지원하며, 새로운 이미지 타입이 추가되더라도 + * 클라이언트 코드(Service 등)의 변경 없이 기능을 확장할 수 있습니다. + * + * @author sleepyhoon + * @see AbstractImageManager + * @see FileType + * @since 2026-02-07 + */ +@Component +public class ImageManagerFactory { + private final List managers; + + public ImageManagerFactory(List managers) { + this.managers = managers; + } + + /** + * 지정된 {@link FileType}에 해당하는 {@link AbstractImageManager}의 구현체를 타입 안전하게 조회합니다. + * + * @param fileType 조회할 파일 타입 (예: {@code ImageType.PROFILE_IMAGE}) + * @return 요청된 타입의 Manager 인스턴스 + * @throws FileException 해당 {@code fileType}을 처리하는 Manager가 등록되어 있지 않을 경우 발생 + */ + public AbstractImageManager getManager(ImageType imageType) { + return managers.stream() + .filter(s -> s.supports(imageType)) + .findFirst() + .orElseThrow(() -> new FileException(FileErrorCode.UNSUPPORTED_FILE_IMAGE_TYPE)); + } +} diff --git a/src/main/java/com/studypals/global/minio/MinioStorage.java b/src/main/java/com/studypals/global/minio/MinioStorage.java index ca61e339..d029a0bf 100644 --- a/src/main/java/com/studypals/global/minio/MinioStorage.java +++ b/src/main/java/com/studypals/global/minio/MinioStorage.java @@ -1,9 +1,12 @@ package com.studypals.global.minio; +import java.io.InputStream; + import jakarta.annotation.PostConstruct; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; import lombok.RequiredArgsConstructor; @@ -45,6 +48,42 @@ public void init() { validateBucket(); } + /** + * objectKey에서 fileUrl로 변환해줍니다. objectKey가 null 이거나 비어있다면 null을 반환합니다. + * @param objectKey minio 내 파일 경로 + * @return 클라이언트에서 바로 접근할 수 있는 파일 경로 + */ + @Override + public String convertKeyToFileUrl(String objectKey) { + if (objectKey == null || objectKey.isBlank()) { + return null; + } + return endpoint + "/" + bucket + "/" + objectKey; + } + + /** + * MultipartFile 형태의 파일을 업로드합니다. + * + * @param file 업로드할 파일 + * @param objectKey 저장할 파일 경로 + * @return 저장된 minio URL + */ + @Override + public String upload(MultipartFile file, String objectKey) { + try { + InputStream inputStream = file.getInputStream(); + + minioClient.putObject( + PutObjectArgs.builder().bucket(bucket).object(objectKey).stream(inputStream, file.getSize(), -1) + .contentType(file.getContentType()) + .build()); + + return convertKeyToFileUrl(objectKey); + } catch (Exception e) { + throw new RuntimeException("MinIO 파일 업로드에 실패했습니다. ObjectKey: " + objectKey, e); + } + } + /** * path 경로에 저장된 파일을 삭제합니다. * @@ -58,7 +97,7 @@ public void delete(String destination) { .object(destination) .build()); } catch (Exception e) { - throw new RuntimeException(e.getMessage()); + throw new RuntimeException("MinIO 파일 삭제에 실패했습니다. ObjectKey: " + destination, e); } } @@ -88,20 +127,6 @@ public String createPresignedGetUrl(String objectKey, int expirySeconds) { } } - @Override - public String createPresignedPutUrl(String objectKey, int expirySeconds) { - try { - return minioClient.getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder() - .method(Method.PUT) - .bucket(bucket) - .object(objectKey) - .expiry(expirySeconds) - .build()); - } catch (Exception e) { - throw new RuntimeException("Presigned PUT URL 생성에 실패했습니다.", e); - } - } - /** * MinIO 버킷이 유효한지 확인합니다. 유효하지 않다면, 해당 이름으로 버킷을 생성합니다. */ @@ -117,7 +142,7 @@ private void validateBucket() { .config("public") .build()); } catch (Exception e) { - throw new RuntimeException(e.getMessage()); + throw new RuntimeException("MinIO 버킷 초기화에 실패했습니다. Bucket: " + bucket, e); } } } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index aa6a9b53..a20353bb 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -75,3 +75,6 @@ chat.subscribe.address.default=/sub/chat/room/ # =============================== file.upload.extensions=jpg,jpeg,png,bmp,webp file.upload.presigned-url-expire-time=600 + +spring.servlet.multipart.max-file-size=10MB +spring.servlet.multipart.max-request-size=12MB \ No newline at end of file diff --git a/src/test/java/com/studypals/domain/chatManage/restDocsTest/ChatRoomControllerRestDocsTest.java b/src/test/java/com/studypals/domain/chatManage/restDocsTest/ChatRoomControllerRestDocsTest.java index 3df360a7..a45c77ff 100644 --- a/src/test/java/com/studypals/domain/chatManage/restDocsTest/ChatRoomControllerRestDocsTest.java +++ b/src/test/java/com/studypals/domain/chatManage/restDocsTest/ChatRoomControllerRestDocsTest.java @@ -6,24 +6,34 @@ import static org.springframework.restdocs.http.HttpDocumentation.httpRequest; import static org.springframework.restdocs.http.HttpDocumentation.httpResponse; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.multipart; import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.partWithName; import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.restdocs.request.RequestDocumentation.requestParts; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import java.util.List; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.ResultActions; +import org.springframework.web.multipart.MultipartFile; import com.studypals.domain.chatManage.api.ChatRoomController; import com.studypals.domain.chatManage.dto.*; import com.studypals.domain.chatManage.entity.ChatRoomRole; import com.studypals.domain.chatManage.service.ChatRoomService; +import com.studypals.global.file.dto.ImageUploadRes; +import com.studypals.global.file.service.ImageFileService; import com.studypals.global.responses.CommonResponse; import com.studypals.global.responses.Response; import com.studypals.global.responses.ResponseCode; @@ -41,6 +51,16 @@ class ChatRoomControllerRestDocsTest extends RestDocsSupport { @MockitoBean private ChatRoomService chatRoomService; + @MockitoBean + private ImageFileService imageFileService; + + private final MockMultipartFile mockMultipartFile = new MockMultipartFile( + "file", // 컨트롤러가 받는 파라미터 변수명 (필수 확인!) + "chat-image.png", // 업로드할 파일명 + "image/png", // 파일 타입 + "fake-image-content".getBytes() // 파일 내용 (더미) + ); + @Test @WithMockUser void getChatRoomInfo_success() throws Exception { @@ -120,4 +140,41 @@ void getChatRoomInfo_success() throws Exception { fieldWithPath("data.logs[].content").description("채팅 메시지 내용"), fieldWithPath("data.logs[].sender").description("메시지 보낸 유저 ID")))); } + + @Test + @WithMockUser + @DisplayName("채팅 이미지 업로드 성공") + void upLoadChatImage_success() throws Exception { + // given + Long imageId = 1L; + String imageUrl = "http://example.com/presigned-url-image.jpg"; + ImageUploadRes response = new ImageUploadRes(imageId, imageUrl); + + given(imageFileService.uploadChatImage(any(MultipartFile.class), any(), any())) + .willReturn(response); + + Response expected = CommonResponse.success(ResponseCode.FILE_IMAGE_UPLOAD, response); + + // when + ResultActions result = + mockMvc.perform(multipart("/chat/room/{chatRoomId}/image", "chatRoomId-123-456") // 1. URL 템플릿 사용 + .file(mockMultipartFile) + .contentType(MediaType.MULTIPART_FORM_DATA)); + + // then + result.andExpect(status().isOk()) + .andExpect(hasKey(expected)) + .andDo(print()) + .andDo(restDocs.document( + httpRequest(), + httpResponse(), + pathParameters(parameterWithName("chatRoomId").description("채팅방 ID")), + requestParts(partWithName("file").description("업로드할 이미지 파일 (MultipartFile)")), + responseFields( + fieldWithPath("code").description("응답 코드 (I01-01)"), + fieldWithPath("status").description("응답 상태"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.imageId").description("이미지 파일의 식별 ID"), + fieldWithPath("data.imageUrl").description("저장된 이미지를 조회할 presigned url")))); + } } diff --git a/src/test/java/com/studypals/domain/chatManage/service/ChatRoomServiceTest.java b/src/test/java/com/studypals/domain/chatManage/service/ChatRoomServiceTest.java index 997e92f4..8a7a911b 100644 --- a/src/test/java/com/studypals/domain/chatManage/service/ChatRoomServiceTest.java +++ b/src/test/java/com/studypals/domain/chatManage/service/ChatRoomServiceTest.java @@ -15,6 +15,7 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mapstruct.factory.Mappers; import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -29,6 +30,7 @@ import com.studypals.domain.chatManage.worker.ChatRoomReader; import com.studypals.domain.memberManage.entity.Member; import com.studypals.domain.memberManage.worker.MemberReader; +import com.studypals.global.file.ObjectStorage; /** * {@link ChatRoomService} 에 대한 테스트코드 @@ -66,6 +68,10 @@ class ChatRoomServiceTest { @Mock private MemberReader memberReader; + @Mock + private ObjectStorage objectStorage; + + @InjectMocks private ChatRoomServiceImpl chatRoomService; private final ChatMessageMapper chatMessageMapper = Mappers.getMapper(ChatMessageMapper.class); @@ -73,7 +79,7 @@ class ChatRoomServiceTest { @BeforeEach void setup() { chatRoomService = new ChatRoomServiceImpl( - chatRoomReader, chatRoomMapper, chatMessageMapper, chatMessageReader, memberReader); + chatRoomReader, chatRoomMapper, chatMessageMapper, chatMessageReader, memberReader, objectStorage); } @Test @@ -91,7 +97,7 @@ void getChatRoomInfo_success() { given(mockMember1.getId()).willReturn(userId); given(mockMember1.getId()).willReturn(2L); - given(chatRoomMapper.toDto(any())) + given(chatRoomMapper.toDto(any(), any())) .willReturn(new ChatRoomInfoRes.UserInfo(userId, "nickname", ChatRoomRole.MEMBER, "image")); given(mockMember1.getId()).willReturn(userId); diff --git a/src/test/java/com/studypals/domain/chatManage/worker/ChatImageManagerTest.java b/src/test/java/com/studypals/domain/chatManage/worker/ChatImageManagerTest.java index 56173927..5f75f99d 100644 --- a/src/test/java/com/studypals/domain/chatManage/worker/ChatImageManagerTest.java +++ b/src/test/java/com/studypals/domain/chatManage/worker/ChatImageManagerTest.java @@ -1,11 +1,6 @@ package com.studypals.domain.chatManage.worker; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.verify; import java.util.List; @@ -15,10 +10,8 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.test.util.ReflectionTestUtils; -import com.studypals.global.exceptions.errorCode.ChatErrorCode; -import com.studypals.global.exceptions.exception.ChatException; +import com.studypals.global.file.FileProperties; import com.studypals.global.file.ObjectStorage; import com.studypals.global.file.entity.ImageType; @@ -31,57 +24,22 @@ class ChatImageManagerTest { @Mock private ChatRoomReader chatRoomReader; + @Mock + private ChatImageWriter chatImageWriter; + private ChatImageManager chatImageManager; @BeforeEach void setUp() { - chatImageManager = new ChatImageManager(objectStorage, chatRoomReader); - // 부모 클래스의 @Value 필드 주입 - ReflectionTestUtils.setField(chatImageManager, "acceptableExtensions", List.of("jpg", "png")); - ReflectionTestUtils.setField(chatImageManager, "presignedUrlExpireTime", 600); - } - - @Test - @DisplayName("업로드 URL 발급 성공 - 채팅방 멤버인 경우") - void getUploadUrl_success() { - // given - Long userId = 1L; - String chatRoomId = "room1"; - String fileName = "image.jpg"; - String expectedUrl = "https://example.com/presigned-url"; - - given(chatRoomReader.isMemberOfChatRoom(userId, chatRoomId)).willReturn(true); - given(objectStorage.createPresignedPutUrl(anyString(), anyInt())).willReturn(expectedUrl); - - // when - String result = chatImageManager.getUploadUrl(userId, fileName, chatRoomId); - - // then - assertThat(result).isEqualTo(expectedUrl); - verify(chatRoomReader).isMemberOfChatRoom(userId, chatRoomId); - } - - @Test - @DisplayName("업로드 URL 발급 실패 - 채팅방 멤버가 아닌 경우") - void getUploadUrl_fail_notMember() { - // given - Long userId = 1L; - String chatRoomId = "room1"; - String fileName = "image.jpg"; - - given(chatRoomReader.isMemberOfChatRoom(userId, chatRoomId)).willReturn(false); - - // when & then - assertThatThrownBy(() -> chatImageManager.getUploadUrl(userId, fileName, chatRoomId)) - .isInstanceOf(ChatException.class) - .hasFieldOrPropertyWithValue("errorCode", ChatErrorCode.CHAT_ROOM_NOT_CONTAIN_MEMBER); + FileProperties fileUploadProperties = new FileProperties(List.of("jpg", "png"), 600); + chatImageManager = new ChatImageManager(objectStorage, fileUploadProperties, chatRoomReader, chatImageWriter); } @Test @DisplayName("파일 타입 반환 확인") - void getFileType() { + void getType() { // when - ImageType type = chatImageManager.getFileType(); + ImageType type = chatImageManager.getType(); // then assertThat(type).isEqualTo(ImageType.CHAT_IMAGE); diff --git a/src/test/java/com/studypals/domain/groupManage/dao/GroupMemberRepositoryTest.java b/src/test/java/com/studypals/domain/groupManage/dao/GroupMemberRepositoryTest.java index 48d7c17e..23569cc0 100644 --- a/src/test/java/com/studypals/domain/groupManage/dao/GroupMemberRepositoryTest.java +++ b/src/test/java/com/studypals/domain/groupManage/dao/GroupMemberRepositoryTest.java @@ -60,9 +60,9 @@ void findTopNMembers_success() { List expected = List.of( new GroupMemberProfileDto( - member1.getId(), member1.getNickname(), member1.getImageUrl(), leader.getRole()), + member1.getId(), member1.getNickname(), member1.getProfileImageObjectKey(), leader.getRole()), new GroupMemberProfileDto( - member2.getId(), member2.getNickname(), member2.getImageUrl(), member.getRole())); + member2.getId(), member2.getNickname(), member2.getProfileImageObjectKey(), member.getRole())); // when List actual = @@ -121,13 +121,13 @@ void findAllMembersInGroupIds_success() { .findFirst() .get(); assertThat(mapping1.groupId()).isEqualTo(g1.getId()); - assertThat(mapping1.imageUrl()).isEqualTo("imageUrl-url"); + assertThat(mapping1.imageUrl()).isEqualTo(m1.getProfileImageObjectKey()); GroupMemberProfileMappingDto mapping2 = result.stream() .filter(r -> r.groupId().equals(g2.getId())) .findFirst() .get(); assertThat(mapping2.groupId()).isEqualTo(g2.getId()); - assertThat(mapping2.imageUrl()).isEqualTo("imageUrl-url"); + assertThat(mapping2.imageUrl()).isEqualTo(m2.getProfileImageObjectKey()); } } diff --git a/src/test/java/com/studypals/domain/groupManage/service/GroupEntryServiceTest.java b/src/test/java/com/studypals/domain/groupManage/service/GroupEntryServiceTest.java index cffd8a68..cdf4891c 100644 --- a/src/test/java/com/studypals/domain/groupManage/service/GroupEntryServiceTest.java +++ b/src/test/java/com/studypals/domain/groupManage/service/GroupEntryServiceTest.java @@ -28,6 +28,7 @@ import com.studypals.domain.memberManage.worker.MemberReader; import com.studypals.global.exceptions.errorCode.GroupErrorCode; import com.studypals.global.exceptions.exception.GroupException; +import com.studypals.global.file.ObjectStorage; import com.studypals.global.request.Cursor; import com.studypals.global.request.DateSortType; import com.studypals.global.responses.CursorResponse; @@ -74,6 +75,9 @@ public class GroupEntryServiceTest { @Mock private GroupEntryRequest mockEntryRequest; + @Mock + private ObjectStorage objectStorage; + @InjectMocks private GroupEntryServiceImpl groupEntryService; diff --git a/src/test/java/com/studypals/domain/groupManage/service/GroupRankingServiceTest.java b/src/test/java/com/studypals/domain/groupManage/service/GroupRankingServiceTest.java index a0c1157c..6f4ed391 100644 --- a/src/test/java/com/studypals/domain/groupManage/service/GroupRankingServiceTest.java +++ b/src/test/java/com/studypals/domain/groupManage/service/GroupRankingServiceTest.java @@ -23,6 +23,7 @@ import com.studypals.domain.groupManage.worker.GroupMemberReader; import com.studypals.domain.groupManage.worker.GroupRankingWorker; import com.studypals.domain.memberManage.entity.Member; +import com.studypals.global.file.ObjectStorage; @ExtendWith(MockitoExtension.class) class GroupRankingServiceTest { @@ -36,6 +37,9 @@ class GroupRankingServiceTest { @Mock private GroupAuthorityValidator validator; + @Mock + private ObjectStorage objectStorage; + @InjectMocks private GroupRankingServiceImpl groupRankingService; @@ -82,11 +86,7 @@ private List createMockGroupMembers(Long groupId) { } private GroupMember createMember(Long id, String nick, String img, Group group, GroupRole role) { - Member member = Member.builder() - .id(id) - .nickname(nick) - .imageUrl("https://example.com/" + img) - .build(); + Member member = Member.builder().id(id).nickname(nick).build(); return GroupMember.builder() .id(id + 1000L) // GroupMember 자체의 ID diff --git a/src/test/java/com/studypals/domain/groupManage/service/GroupServiceTest.java b/src/test/java/com/studypals/domain/groupManage/service/GroupServiceTest.java index 1b14ef82..23dc66b9 100644 --- a/src/test/java/com/studypals/domain/groupManage/service/GroupServiceTest.java +++ b/src/test/java/com/studypals/domain/groupManage/service/GroupServiceTest.java @@ -30,6 +30,7 @@ import com.studypals.domain.studyManage.worker.StudyCategoryReader; import com.studypals.global.exceptions.errorCode.GroupErrorCode; import com.studypals.global.exceptions.exception.GroupException; +import com.studypals.global.file.ObjectStorage; /** * {@link GroupService} 에 대한 단위 테스트입니다. @@ -88,6 +89,9 @@ public class GroupServiceTest { @Mock private GroupHashTagWorker groupHashTagWorker; + @Mock + private ObjectStorage objectStorage; + @InjectMocks private GroupServiceImpl groupService; @@ -278,11 +282,7 @@ private List createMockGroupMembers(Long groupId) { } private GroupMember createMember(Long id, String nick, String img, Group group, GroupRole role) { - Member member = Member.builder() - .id(id) - .nickname(nick) - .imageUrl("https://example.com/" + img) - .build(); + Member member = Member.builder().id(id).nickname(nick).build(); return GroupMember.builder() .id(id + 1000L) // GroupMember 자체의 ID diff --git a/src/test/java/com/studypals/domain/groupManage/worker/GroupGoalCalculatorTest.java b/src/test/java/com/studypals/domain/groupManage/worker/GroupGoalCalculatorTest.java index 4433f79f..5fab7676 100644 --- a/src/test/java/com/studypals/domain/groupManage/worker/GroupGoalCalculatorTest.java +++ b/src/test/java/com/studypals/domain/groupManage/worker/GroupGoalCalculatorTest.java @@ -249,11 +249,7 @@ private List createMockGroupMembers(Long groupId) { } private Member createMemberEntity(Long id, String nick, String img) { - return Member.builder() - .id(id) - .nickname(nick) - .imageUrl("https://example.com/" + img) - .build(); + return Member.builder().id(id).nickname(nick).build(); } private GroupMember createGroupMember(Member member, Group group, GroupRole role) { diff --git a/src/test/java/com/studypals/domain/memberManage/restDocsTest/MemberControllerRestDocsTest.java b/src/test/java/com/studypals/domain/memberManage/restDocsTest/MemberControllerRestDocsTest.java index a4e90280..c2c6548f 100644 --- a/src/test/java/com/studypals/domain/memberManage/restDocsTest/MemberControllerRestDocsTest.java +++ b/src/test/java/com/studypals/domain/memberManage/restDocsTest/MemberControllerRestDocsTest.java @@ -10,17 +10,23 @@ import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.*; import static org.springframework.restdocs.payload.PayloadDocumentation.*; import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.partWithName; import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; +import static org.springframework.restdocs.request.RequestDocumentation.requestParts; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import java.time.LocalDate; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.ResultActions; +import org.springframework.web.multipart.MultipartFile; import com.studypals.domain.memberManage.api.MemberController; import com.studypals.domain.memberManage.dto.CheckDuplicateDto; @@ -30,6 +36,8 @@ import com.studypals.domain.memberManage.service.MemberService; import com.studypals.global.exceptions.errorCode.AuthErrorCode; import com.studypals.global.exceptions.exception.AuthException; +import com.studypals.global.file.dto.ImageUploadRes; +import com.studypals.global.file.service.ImageFileService; import com.studypals.global.responses.CommonResponse; import com.studypals.global.responses.Response; import com.studypals.global.responses.ResponseCode; @@ -49,6 +57,16 @@ public class MemberControllerRestDocsTest extends RestDocsSupport { @MockitoBean private MemberService memberService; + @MockitoBean + private ImageFileService imageFileService; + + private final MockMultipartFile mockMultipartFile = new MockMultipartFile( + "file", // 컨트롤러가 받는 파라미터 변수명 (필수 확인!) + "chat-image.png", // 업로드할 파일명 + "image/png", // 파일 타입 + "fake-image-content".getBytes() // 파일 내용 (더미) + ); + @Test void register_success() throws Exception { @@ -153,7 +171,7 @@ void getProfile_success() throws Exception { @WithMockUser void updateProfile_success() throws Exception { // given - UpdateProfileReq req = new UpdateProfileReq(LocalDate.of(1999, 8, 20), "학생", "exmaple.image.com"); + UpdateProfileReq req = new UpdateProfileReq(LocalDate.of(1999, 8, 20), "학생"); given(memberService.updateProfile(anyLong(), any(UpdateProfileReq.class))) .willReturn(1L); @@ -169,10 +187,7 @@ void updateProfile_success() throws Exception { httpResponse(), requestFields( fieldWithPath("birthday").description("생일").optional(), - fieldWithPath("position").description("직무/포지션").optional(), - fieldWithPath("imageUrl") - .description("프로필 이미지 URL") - .optional()), + fieldWithPath("position").description("직무/포지션").optional()), responseFields( fieldWithPath("code").description("응답 코드 (U01-02)"), fieldWithPath("status").description("응답 상태"), @@ -180,6 +195,40 @@ void updateProfile_success() throws Exception { fieldWithPath("message").description("응답 메시지")))); } + @Test + @WithMockUser + @DisplayName("프로필 이미지 업로드 성공") + void upLoadProfileImage_success() throws Exception { + // given + Long imageId = 1L; + String imageUrl = "http://example.com/image.jpg"; + + ImageUploadRes response = new ImageUploadRes(imageId, imageUrl); + given(imageFileService.uploadProfileImage(any(MultipartFile.class), any())) + .willReturn(response); + + Response expected = CommonResponse.success(ResponseCode.FILE_IMAGE_UPLOAD, response); + + // when + ResultActions result = mockMvc.perform( + multipart("/profile/image").file(mockMultipartFile).contentType(MediaType.MULTIPART_FORM_DATA)); + + // then + result.andExpect(status().isOk()) + .andExpect(hasKey(expected)) + .andDo(print()) + .andDo(restDocs.document( + httpRequest(), + httpResponse(), + requestParts(partWithName("file").description("업로드할 이미지 파일 (MultipartFile)")), + responseFields( + fieldWithPath("code").description("응답 코드 (I01-01)"), + fieldWithPath("status").description("응답 상태"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.imageId").description("이미지 파일의 식별 ID"), + fieldWithPath("data.imageUrl").description("저장된 이미지 주소")))); + } + @Test @WithMockUser void checkAvailability_fail_when_both_username_and_nickname_present() throws Exception { diff --git a/src/test/java/com/studypals/domain/memberManage/service/MemberServiceTest.java b/src/test/java/com/studypals/domain/memberManage/service/MemberServiceTest.java index 6b4cc721..6782db72 100644 --- a/src/test/java/com/studypals/domain/memberManage/service/MemberServiceTest.java +++ b/src/test/java/com/studypals/domain/memberManage/service/MemberServiceTest.java @@ -24,6 +24,7 @@ import com.studypals.domain.memberManage.worker.MemberWriter; import com.studypals.global.exceptions.errorCode.AuthErrorCode; import com.studypals.global.exceptions.exception.AuthException; +import com.studypals.global.file.ObjectStorage; /** * {@link MemberService} 에 대한 단위 테스트입니다. @@ -50,6 +51,9 @@ class MemberServiceTest { @Mock private Member mockMember; + @Mock + private ObjectStorage objectStorage; + @InjectMocks private MemberServiceImpl memberService; @@ -129,7 +133,7 @@ void updateProfile_success() { // given Long userId = 1L; - UpdateProfileReq req = new UpdateProfileReq(LocalDate.of(1999, 8, 20), "학생", "example.image.com"); + UpdateProfileReq req = new UpdateProfileReq(LocalDate.of(1999, 8, 20), "학생"); given(memberReader.get(userId)).willReturn(mockMember); given(mockMember.getId()).willReturn(1L); @@ -141,7 +145,7 @@ void updateProfile_success() { assertThat(result).isEqualTo(1L); then(memberReader).should().get(userId); - then(mockMember).should().updateProfile(LocalDate.of(1999, 8, 20), "학생", "example.image.com"); + then(mockMember).should().updateProfile(LocalDate.of(1999, 8, 20), "학생"); then(memberWriter).should().save(mockMember); } @@ -162,7 +166,7 @@ void getProfile_success() { .build(); given(memberReader.get(userId)).willReturn(mockMember); - given(mapper.toRes(mockMember)).willReturn(res); + given(mapper.toRes(mockMember, objectStorage)).willReturn(res); // when MemberDetailsRes result = memberService.getProfile(userId); @@ -171,6 +175,6 @@ void getProfile_success() { assertThat(result).isSameAs(res); then(memberReader).should().get(userId); - then(mapper).should().toRes(mockMember); + then(mapper).should().toRes(mockMember, objectStorage); } } diff --git a/src/test/java/com/studypals/domain/memberManage/worker/MemberProfileImageManagerTest.java b/src/test/java/com/studypals/domain/memberManage/worker/MemberProfileImageManagerTest.java new file mode 100644 index 00000000..5e40701d --- /dev/null +++ b/src/test/java/com/studypals/domain/memberManage/worker/MemberProfileImageManagerTest.java @@ -0,0 +1,48 @@ +package com.studypals.domain.memberManage.worker; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.studypals.global.file.FileProperties; +import com.studypals.global.file.ObjectStorage; +import com.studypals.global.file.entity.ImageType; + +@ExtendWith(MockitoExtension.class) +class MemberProfileImageManagerTest { + + @Mock + private ObjectStorage objectStorage; + + @Mock + private MemberReader memberReader; + + @Mock + private MemberProfileImageWriter memberProfileImageWriter; + + private MemberProfileImageManager memberProfileImageManager; + + @BeforeEach + void setUp() { + FileProperties fileUploadProperties = new FileProperties(List.of("jpg", "png"), 600); + memberProfileImageManager = new MemberProfileImageManager( + objectStorage, fileUploadProperties, memberReader, memberProfileImageWriter); + } + + @Test + @DisplayName("파일 타입 반환 확인") + void getType() { + // when + ImageType type = memberProfileImageManager.getType(); + + // then + assertThat(type).isEqualTo(ImageType.PROFILE_IMAGE); + } +} diff --git a/src/test/java/com/studypals/domain/memberManage/worker/MemberProfileManagerTest.java b/src/test/java/com/studypals/domain/memberManage/worker/MemberProfileManagerTest.java deleted file mode 100644 index c74b59ec..00000000 --- a/src/test/java/com/studypals/domain/memberManage/worker/MemberProfileManagerTest.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.studypals.domain.memberManage.worker; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.BDDMockito.given; - -import java.util.List; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.test.util.ReflectionTestUtils; - -import com.studypals.global.file.ObjectStorage; -import com.studypals.global.file.entity.ImageType; - -@ExtendWith(MockitoExtension.class) -class MemberProfileManagerTest { - - @Mock - private ObjectStorage objectStorage; - - private MemberProfileManager memberProfileManager; - - @BeforeEach - void setUp() { - memberProfileManager = new MemberProfileManager(objectStorage); - // 부모 클래스의 @Value 필드 주입 - ReflectionTestUtils.setField(memberProfileManager, "acceptableExtensions", List.of("jpg", "png")); - ReflectionTestUtils.setField(memberProfileManager, "presignedUrlExpireTime", 600); - } - - @Test - @DisplayName("업로드 URL 발급 성공") - void getUploadUrl_success() { - // given - Long userId = 1L; - String fileName = "profile.jpg"; - String targetId = String.valueOf(userId); - String expectedUrl = "https://example.com/presigned-url"; - - given(objectStorage.createPresignedPutUrl(anyString(), anyInt())).willReturn(expectedUrl); - - // when - String result = memberProfileManager.getUploadUrl(userId, fileName, targetId); - - // then - assertThat(result).isEqualTo(expectedUrl); - } - - @Test - @DisplayName("파일 타입 반환 확인") - void getFileType() { - // when - ImageType type = memberProfileManager.getFileType(); - - // then - assertThat(type).isEqualTo(ImageType.PROFILE_IMAGE); - } -} diff --git a/src/test/java/com/studypals/global/file/dao/AbstractFileManagerTest.java b/src/test/java/com/studypals/global/file/dao/AbstractFileManagerTest.java index 403b7a5e..43d19e82 100644 --- a/src/test/java/com/studypals/global/file/dao/AbstractFileManagerTest.java +++ b/src/test/java/com/studypals/global/file/dao/AbstractFileManagerTest.java @@ -1,7 +1,6 @@ package com.studypals.global.file.dao; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.times; @@ -12,6 +11,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import com.studypals.global.file.FileUtils; import com.studypals.global.file.ObjectStorage; import com.studypals.global.file.entity.ImageType; @@ -41,13 +41,10 @@ void setUp() { @DisplayName("파일 삭제 성공") void delete_success() { // given - String url = "https://example.com/test/image.jpg"; String objectKey = "test/image.jpg"; - given(objectStorage.parsePath(url)).willReturn(objectKey); - // when - fileRepository.delete(url); + fileRepository.delete(objectKey); // then then(objectStorage).should(times(1)).delete(objectKey); @@ -112,12 +109,12 @@ public TestFileManager(ObjectStorage objectStorage) { } @Override - public ImageType getFileType() { + public ImageType getType() { return ImageType.PROFILE_IMAGE; } public String callExtractExtension(String fileName) { - return extractExtension(fileName); + return FileUtils.extractExtension(fileName); } } } diff --git a/src/test/java/com/studypals/global/file/dao/AbstractImageManagerTest.java b/src/test/java/com/studypals/global/file/dao/AbstractImageManagerTest.java index b402c523..b2b76e17 100644 --- a/src/test/java/com/studypals/global/file/dao/AbstractImageManagerTest.java +++ b/src/test/java/com/studypals/global/file/dao/AbstractImageManagerTest.java @@ -1,23 +1,26 @@ package com.studypals.global.file.dao; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.BDDMockito.given; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import java.util.List; +import java.util.UUID; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.test.util.ReflectionTestUtils; +import com.studypals.global.exceptions.exception.FileException; +import com.studypals.global.file.FileProperties; import com.studypals.global.file.ObjectStorage; import com.studypals.global.file.entity.ImageType; +import com.studypals.global.file.entity.ImageVariantKey; @ExtendWith(MockitoExtension.class) class AbstractImageManagerTest { @@ -25,106 +28,149 @@ class AbstractImageManagerTest { @Mock private ObjectStorage objectStorage; + private FileProperties fileUploadProperties; private TestImageManager imageManager; - @BeforeEach - void setUp() { - imageManager = new TestImageManager(objectStorage); - // @Value 주입을 시뮬레이션하기 위해 ReflectionTestUtils 사용 - ReflectionTestUtils.setField( - imageManager, "acceptableExtensions", List.of("jpg", "jpeg", "png", "bmp", "webp")); - ReflectionTestUtils.setField(imageManager, "presignedUrlExpireTime", 600); - } - - @Test - @DisplayName("업로드 URL 발급 성공 - 파일 이름 검증 통과") - void getUploadUrl_success() { - // given - Long userId = 1L; - String fileName = "image.jpg"; - String targetId = "user1"; - String expectedUrl = "https://example.com/presigned-url"; + // 테스트를 위한 구체 클래스 + static class TestImageManager extends AbstractImageManager { - given(objectStorage.createPresignedPutUrl(anyString(), anyInt())).willReturn(expectedUrl); + public TestImageManager(ObjectStorage objectStorage, FileProperties properties) { + super(objectStorage, properties); + } - // when - String result = imageManager.getUploadUrl(userId, fileName, targetId); + @Override + protected String generateObjectKeyDetail(String targetId, String ext) { + return "test-path/" + targetId + "/" + UUID.randomUUID() + "." + ext; + } - // then - assertThat(result).isEqualTo(expectedUrl); - } + @Override + protected Long saveImage(Long userId, String targetId, String objectKey, String originalFileName) { + return 1L; // 테스트용 더미 ID 반환 + } - @Test - @DisplayName("업로드 URL 발급 실패 - 대문자 확장자도 허용") - void getUploadUrl_upperCase() { - // given - Long userId = 1L; - String fileName = "image.PNG"; - String targetId = "user1"; - String expectedUrl = "https://example.com/presigned-url"; + @Override + protected List variants() { + return List.of(); + } - given(objectStorage.createPresignedPutUrl(anyString(), anyInt())).willReturn(expectedUrl); + @Override + protected boolean usePresignedUrl() { + return true; + } - // when - String result = imageManager.getUploadUrl(userId, fileName, targetId); + @Override + public boolean supports(ImageType fileType) { + return true; + } - // then - assertThat(result).isEqualTo(expectedUrl); + @Override + public ImageType getType() { + return ImageType.PROFILE_IMAGE; + } } - @Test - @DisplayName("업로드 URL 발급 실패 - 지원하지 않는 확장자") - void getUploadUrl_invalidExtension() { + @BeforeEach + void setUp() { // given - Long userId = 1L; - String fileName = "document.txt"; - String targetId = "user1"; - - // when & then - assertThatThrownBy(() -> imageManager.getUploadUrl(userId, fileName, targetId)) - .isInstanceOf(RuntimeException.class); // FileException이 RuntimeException을 상속한다고 가정 + fileUploadProperties = new FileProperties(List.of("jpg", "jpeg", "png", "bmp", "webp"), 600); + imageManager = new TestImageManager(objectStorage, fileUploadProperties); } - @Test - @DisplayName("업로드 URL 발급 실패 - 확장자 없음") - void getUploadUrl_noExtension() { - // given - Long userId = 1L; - String fileName = "image"; - String targetId = "user1"; + @Nested + @DisplayName("createObjectKey 메서드 테스트") + class CreateObjectKeyTest { - // when & then - assertThatThrownBy(() -> imageManager.getUploadUrl(userId, fileName, targetId)) - .isInstanceOf(RuntimeException.class); - } + @Test + @DisplayName("성공: 유효한 요청 시 ObjectKey를 정상적으로 생성한다") + void should_CreateObjectKey_When_RequestIsValid() { + // given + Long userId = 1L; + String fileName = "image.jpg"; + String targetId = "user1"; - @Test - @DisplayName("업로드 URL 발급 실패 - null 파일 이름") - void getUploadUrl_null() { - // given - Long userId = 1L; - String fileName = null; - String targetId = "user1"; + // when + String objectKey = imageManager.createObjectKey(userId, fileName, targetId); - // when & then - assertThatThrownBy(() -> imageManager.getUploadUrl(userId, fileName, targetId)) - .isInstanceOf(RuntimeException.class); - } + // then + assertThat(objectKey).contains("test-path/user1/"); + assertThat(objectKey).endsWith(".jpg"); + } - // 테스트를 위한 구체 클래스 - static class TestImageManager extends AbstractImageManager { - public TestImageManager(ObjectStorage objectStorage) { - super(objectStorage); + @Test + @DisplayName("성공: 대문자 확장자도 허용하여 ObjectKey를 생성한다") + void should_CreateObjectKey_When_ExtensionIsUpperCase() { + // given + Long userId = 1L; + String fileName = "image.PNG"; + String targetId = "user1"; + + // when + String objectKey = imageManager.createObjectKey(userId, fileName, targetId); + + // then + assertThat(objectKey).contains("test-path/user1/"); + assertThat(objectKey).endsWith(".png"); } - @Override - protected String generateObjectKeyDetail(String targetId, String ext) { - return "key"; + @Test + @DisplayName("실패: 지원하지 않는 확장자이면 FileException 던진다") + void should_ThrowException_When_ExtensionIsUnsupported() { + // given + Long userId = 1L; + String fileName = "document.txt"; + String targetId = "user1"; + + // when & then + assertThatCode(() -> imageManager.createObjectKey(userId, fileName, targetId)) + .isInstanceOf(FileException.class); } - @Override - public ImageType getFileType() { - return ImageType.PROFILE_IMAGE; + @Test + @DisplayName("실패: 파일 이름에 확장자가 없으면 FileException 던진다") + void should_ThrowException_When_FileNameHasNoExtension() { + // given + Long userId = 1L; + String fileName = "image"; + String targetId = "user1"; + + // when & then + assertThatCode(() -> imageManager.createObjectKey(userId, fileName, targetId)) + .isInstanceOf(FileException.class); + } + + @Test + @DisplayName("실패: 파일 이름이 null이면 FileException 던진다") + void should_ThrowException_When_FileNameIsNull() { + // given + Long userId = 1L; + String targetId = "user1"; + + // when & then + assertThatCode(() -> imageManager.createObjectKey(userId, null, targetId)) + .isInstanceOf(FileException.class); + } + } + + @Nested + @DisplayName("getUploadUrl(objectKey) 메서드 테스트") + class GetUploadUrlTest { + + @Test + @DisplayName("성공: 주어진 ObjectKey로 Presigned URL을 정상적으로 생성한다") + void should_ReturnPresignedUrl_When_ObjectKeyIsValid() { + // given + String objectKey = "test-path/user1/some-uuid.jpg"; + String expectedUrl = "https://example.com/presigned-url"; + int expireTime = fileUploadProperties.presignedUrlExpireTime(); + + when(objectStorage.createPresignedGetUrl(objectKey, expireTime)).thenReturn(expectedUrl); + + // when + String actualUrl = imageManager.getPresignedGetUrl(objectKey); + + // then + assertThat(actualUrl).isEqualTo(expectedUrl); + verify(objectStorage).createPresignedGetUrl(objectKey, expireTime); } } } diff --git a/src/test/java/com/studypals/global/file/restDocsTest/ImageFileControllerRestDocsTest.java b/src/test/java/com/studypals/global/file/restDocsTest/ImageFileControllerRestDocsTest.java deleted file mode 100644 index 2bb66620..00000000 --- a/src/test/java/com/studypals/global/file/restDocsTest/ImageFileControllerRestDocsTest.java +++ /dev/null @@ -1,112 +0,0 @@ -package com.studypals.global.file.restDocsTest; - -import static com.studypals.testModules.testUtils.JsonFieldResultMatcher.*; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.BDDMockito.*; -import static org.springframework.restdocs.http.HttpDocumentation.httpRequest; -import static org.springframework.restdocs.http.HttpDocumentation.httpResponse; -import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.*; -import static org.springframework.restdocs.payload.PayloadDocumentation.*; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.http.MediaType; -import org.springframework.security.test.context.support.WithMockUser; -import org.springframework.test.context.bean.override.mockito.MockitoBean; -import org.springframework.test.web.servlet.ResultActions; - -import com.studypals.global.file.api.ImageFileController; -import com.studypals.global.file.dto.ChatPresignedUrlReq; -import com.studypals.global.file.dto.PresignedUrlRes; -import com.studypals.global.file.dto.ProfilePresignedUrlReq; -import com.studypals.global.file.service.ImageFileService; -import com.studypals.global.responses.CommonResponse; -import com.studypals.global.responses.Response; -import com.studypals.global.responses.ResponseCode; -import com.studypals.testModules.testSupport.RestDocsSupport; - -@WebMvcTest(ImageFileController.class) -class ImageFileControllerRestDocsTest extends RestDocsSupport { - - @MockitoBean - private ImageFileService imageFileService; - - @Test - @WithMockUser - @DisplayName("프로필 이미지 업로드용 Presigned URL 요청") - void getProfileUploadUrl_success() throws Exception { - // given - ProfilePresignedUrlReq request = new ProfilePresignedUrlReq("my-profile.jpeg"); - String presignedUrl = "https://s3-presigned-url.com/for/profile/my-profile.jpeg?signature=..."; - - PresignedUrlRes response = new PresignedUrlRes(presignedUrl); - given(imageFileService.getProfileUploadUrl(any(ProfilePresignedUrlReq.class), any())) - .willReturn(response); - - Response expected = CommonResponse.success(ResponseCode.FILE_IMAGE_UPLOAD, response); - - // when - ResultActions result = mockMvc.perform(post("/files/image/profile") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))); - - // then - result.andExpect(status().isOk()) - .andExpect(hasKey(expected)) - .andDo(print()) - .andDo(restDocs.document( - httpRequest(), - httpResponse(), - requestFields(fieldWithPath("fileName") - .description("업로드할 파일 이름 (확장자 포함)") - .attributes(constraints("not null, not blank"))), - responseFields( - fieldWithPath("code").description("응답 코드 (I01-01)"), - fieldWithPath("status").description("응답 상태"), - fieldWithPath("message").description("응답 메시지"), - fieldWithPath("data.url").description("생성된 Presigned URL")))); - } - - @Test - @WithMockUser - @DisplayName("채팅 이미지 업로드용 Presigned URL 요청") - void getChatUploadUrl_success() throws Exception { - // given - ChatPresignedUrlReq request = new ChatPresignedUrlReq("chat-image.png", "chat-room-123"); - String presignedUrl = "https://s3-presigned-url.com/for/chat/chat-image.png?signature=..."; - PresignedUrlRes response = new PresignedUrlRes(presignedUrl); - - given(imageFileService.getChatUploadUrl(any(ChatPresignedUrlReq.class), any())) - .willReturn(response); - - Response expected = CommonResponse.success(ResponseCode.FILE_IMAGE_UPLOAD, response); - - // when - ResultActions result = mockMvc.perform(post("/files/image/chat") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))); - - // then - result.andExpect(status().isOk()) - .andExpect(hasKey(expected)) - .andDo(print()) - .andDo(restDocs.document( - httpRequest(), - httpResponse(), - requestFields( - fieldWithPath("fileName") - .description("업로드할 파일 이름 (확장자 포함)") - .attributes(constraints("not null, not blank")), - fieldWithPath("chatRoomId") - .description("업로드 대상 채팅방 ID") - .attributes(constraints("not null, not blank"))), - responseFields( - fieldWithPath("code").description("응답 코드 (I01-01)"), - fieldWithPath("status").description("응답 상태"), - fieldWithPath("message").description("응답 메시지"), - fieldWithPath("data.url").description("생성된 Presigned URL")))); - } -} diff --git a/src/test/java/com/studypals/global/file/service/ImageFileServiceImplTest.java b/src/test/java/com/studypals/global/file/service/ImageFileServiceImplTest.java index 08ff1d93..8248115d 100644 --- a/src/test/java/com/studypals/global/file/service/ImageFileServiceImplTest.java +++ b/src/test/java/com/studypals/global/file/service/ImageFileServiceImplTest.java @@ -1,84 +1,74 @@ package com.studypals.global.file.service; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.never; +import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; -import java.util.List; - -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.multipart.MultipartFile; import com.studypals.global.file.dao.AbstractImageManager; -import com.studypals.global.file.dto.ChatPresignedUrlReq; -import com.studypals.global.file.dto.PresignedUrlRes; -import com.studypals.global.file.dto.ProfilePresignedUrlReq; +import com.studypals.global.file.dto.ImageUploadRes; import com.studypals.global.file.entity.ImageType; +import com.studypals.global.file.worker.ImageManagerFactory; @ExtendWith(MockitoExtension.class) class ImageFileServiceImplTest { - // Service under test - private ImageFileService imageFileService; + @InjectMocks + private ImageFileServiceImpl imageFileService; @Mock - private AbstractImageManager mockProfileImageManager; + private ImageManagerFactory imageManagerFactory; @Mock - private AbstractImageManager mockChatImageManager; - - @BeforeEach - void setUp() { - when(mockProfileImageManager.getFileType()).thenReturn(ImageType.PROFILE_IMAGE); - when(mockChatImageManager.getFileType()).thenReturn(ImageType.CHAT_IMAGE); + private AbstractImageManager imageManager; - imageFileService = new ImageFileServiceImpl(List.of(mockProfileImageManager, mockChatImageManager)); - } + @Mock + private MultipartFile multipartFile; @Test - @DisplayName("getProfileUploadUrl 호출 시 ProfileImageManager의 getUploadUrl을 호출해야 한다") - void getProfileUploadUrl_shouldCallCorrectManager() { + @DisplayName("프로필 이미지 업로드 - 성공") + void uploadProfileImage_Success() { // given Long userId = 1L; - ProfilePresignedUrlReq request = new ProfilePresignedUrlReq("profile.jpg"); - String expectedUrl = "http://s3.com/profile-upload-url"; + String targetId = String.valueOf(userId); + ImageUploadRes expectedRes = new ImageUploadRes(1L, "http://url"); - when(mockProfileImageManager.getUploadUrl(userId, "profile.jpg", "1")).thenReturn(expectedUrl); + given(imageManagerFactory.getManager(ImageType.PROFILE_IMAGE)).willReturn(imageManager); + given(imageManager.upload(multipartFile, userId, targetId)).willReturn(expectedRes); // when - PresignedUrlRes actualResult = imageFileService.getProfileUploadUrl(request, userId); + ImageUploadRes res = imageFileService.uploadProfileImage(multipartFile, userId); // then - assertThat(actualResult).isNotNull(); - assertThat(actualResult.url()).isEqualTo(expectedUrl); - verify(mockProfileImageManager).getUploadUrl(userId, "profile.jpg", "1"); - verify(mockChatImageManager, never()).getUploadUrl(any(), any(), any()); + assertThat(res).isEqualTo(expectedRes); + verify(imageManagerFactory).getManager(ImageType.PROFILE_IMAGE); + verify(imageManager).upload(multipartFile, userId, targetId); } @Test - @DisplayName("getChatUploadUrl 호출 시 ChatImageManager의 getUploadUrl을 호출해야 한다") - void getChatUploadUrl_shouldCallCorrectManager() { + @DisplayName("채팅방 이미지 업로드 - 성공") + void uploadChatImage_Success() { // given Long userId = 1L; - ChatPresignedUrlReq request = new ChatPresignedUrlReq("chat-image.png", "chat-room-123"); - String expectedUrl = "http://s3.com/chat-upload-url"; + String chatRoomId = "room1"; + ImageUploadRes expectedRes = new ImageUploadRes(2L, "http://chat-url"); - when(mockChatImageManager.getUploadUrl(userId, "chat-image.png", "chat-room-123")) - .thenReturn(expectedUrl); + given(imageManagerFactory.getManager(ImageType.CHAT_IMAGE)).willReturn(imageManager); + given(imageManager.upload(multipartFile, userId, chatRoomId)).willReturn(expectedRes); // when - PresignedUrlRes actualResult = imageFileService.getChatUploadUrl(request, userId); + ImageUploadRes res = imageFileService.uploadChatImage(multipartFile, chatRoomId, userId); // then - assertThat(actualResult).isNotNull(); - assertThat(actualResult.url()).isEqualTo(expectedUrl); - verify(mockChatImageManager).getUploadUrl(userId, "chat-image.png", "chat-room-123"); - verify(mockProfileImageManager, never()).getUploadUrl(any(), any(), any()); + assertThat(res).isEqualTo(expectedRes); + verify(imageManagerFactory).getManager(ImageType.CHAT_IMAGE); + verify(imageManager).upload(multipartFile, userId, chatRoomId); } } diff --git a/src/test/java/com/studypals/global/minio/MinioStorageTest.java b/src/test/java/com/studypals/global/minio/MinioStorageTest.java index a84b5eca..c03e801d 100644 --- a/src/test/java/com/studypals/global/minio/MinioStorageTest.java +++ b/src/test/java/com/studypals/global/minio/MinioStorageTest.java @@ -103,31 +103,32 @@ void createPresignedGetUrl_success() throws Exception { assertThat(args.expiry()).isEqualTo(expiry); } - @Test - void createPresignedPutUrl_success() throws Exception { - // given - String objectKey = "test-object"; - int expiry = 300; - String expectedUrl = "http://presigned-url"; - - given(minioClient.getPresignedObjectUrl(any(GetPresignedObjectUrlArgs.class))) - .willReturn(expectedUrl); - - // when - String actualUrl = minioStorage.createPresignedPutUrl(objectKey, expiry); - - // then - assertThat(actualUrl).isEqualTo(expectedUrl); - - ArgumentCaptor captor = ArgumentCaptor.forClass(GetPresignedObjectUrlArgs.class); - then(minioClient).should(times(1)).getPresignedObjectUrl(captor.capture()); - - GetPresignedObjectUrlArgs args = captor.getValue(); - assertThat(args.bucket()).isEqualTo(TEST_BUCKET); - assertThat(args.object()).isEqualTo(objectKey); - assertThat(args.method()).isEqualTo(Method.PUT); - assertThat(args.expiry()).isEqualTo(expiry); - } + // @Test + // void createPresignedPutUrl_success() throws Exception { + // // given + // String objectKey = "test-object"; + // int expiry = 300; + // String expectedUrl = "http://presigned-url"; + // + // given(minioClient.getPresignedObjectUrl(any(GetPresignedObjectUrlArgs.class))) + // .willReturn(expectedUrl); + // + // // when + // String actualUrl = minioStorage.createPresignedPutUrl(objectKey, expiry); + // + // // then + // assertThat(actualUrl).isEqualTo(expectedUrl); + // + // ArgumentCaptor captor = + // ArgumentCaptor.forClass(GetPresignedObjectUrlArgs.class); + // then(minioClient).should(times(1)).getPresignedObjectUrl(captor.capture()); + // + // GetPresignedObjectUrlArgs args = captor.getValue(); + // assertThat(args.bucket()).isEqualTo(TEST_BUCKET); + // assertThat(args.object()).isEqualTo(objectKey); + // assertThat(args.method()).isEqualTo(Method.PUT); + // assertThat(args.expiry()).isEqualTo(expiry); + // } private String getStoragePath() { return TEST_ENDPOINT + "/" + TEST_BUCKET + "/"; diff --git a/src/test/java/com/studypals/testModules/testSupport/DataJpaSupport.java b/src/test/java/com/studypals/testModules/testSupport/DataJpaSupport.java index 85898b9e..74f97f79 100644 --- a/src/test/java/com/studypals/testModules/testSupport/DataJpaSupport.java +++ b/src/test/java/com/studypals/testModules/testSupport/DataJpaSupport.java @@ -7,6 +7,7 @@ import org.springframework.context.annotation.Import; import com.studypals.domain.memberManage.entity.Member; +import com.studypals.domain.memberManage.entity.MemberProfileImage; import com.studypals.global.config.QueryDslTestConfig; @DataJpaTest @@ -25,11 +26,23 @@ protected Member insertMember() { } protected Member insertMember(String username, String nickname) { - return em.persist(Member.builder() + Member member = em.persist(Member.builder() .username(username) .password("password") .nickname(nickname) - .imageUrl("imageUrl-url") + .build()); + + member.setProfileImage(insertMemberProfileImage(member)); + + return member; + } + + protected MemberProfileImage insertMemberProfileImage(Member member) { + return em.persist(MemberProfileImage.builder() + .member(member) + .objectKey("profile/default" + member.getId() + ".jpg") + .originalFileName("default.jpg") + .mimeType("jpg") .build()); } } diff --git a/src/test/java/com/studypals/testModules/testUtils/CleanUp.java b/src/test/java/com/studypals/testModules/testUtils/CleanUp.java index 248b2350..47e88721 100644 --- a/src/test/java/com/studypals/testModules/testUtils/CleanUp.java +++ b/src/test/java/com/studypals/testModules/testUtils/CleanUp.java @@ -4,6 +4,8 @@ import java.util.Set; import java.util.stream.Collectors; +import javax.sql.DataSource; + import jakarta.persistence.Entity; import jakarta.persistence.EntityManager; import jakarta.persistence.Table; @@ -27,6 +29,7 @@ public class CleanUp { private final JdbcTemplate jdbcTemplate; private final EntityManager entityManager; private final StringRedisTemplate stringRedisTemplate; + private final DataSource dataSource; @Transactional public void all() { @@ -46,7 +49,9 @@ public void all() { jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 0"); for (String table : tableNames) { - jdbcTemplate.execute("TRUNCATE TABLE " + table.toLowerCase()); + if (isTableExists(table)) { + jdbcTemplate.execute("TRUNCATE TABLE " + table.toLowerCase()); + } } jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 1"); @@ -55,4 +60,17 @@ public void all() { .serverCommands() .flushAll(); } + + /** + * 실제 DB에 테이블이 존재하는지 쿼리 + */ + private boolean isTableExists(String tableName) { + try { + // MySQL/H2 공용: 테이블 정보 조회 시 에러가 없으면 존재하는 것으로 간주 + jdbcTemplate.execute("SELECT 1 FROM " + tableName + " LIMIT 1"); + return true; + } catch (Exception e) { + return false; + } + } }