diff --git a/src/asciidoc/api/file.adoc b/src/asciidoc/api/file.adoc index 1f5ac738..ce97eed2 100644 --- a/src/asciidoc/api/file.adoc +++ b/src/asciidoc/api/file.adoc @@ -1,4 +1,4 @@ -= ๐Ÿ‘ฅ file API += ๐Ÿ“ File API :doctype: book :icons: font :source-highlighter: highlightjs @@ -6,64 +6,63 @@ :toclevels: 3 :sectlinks: -== ๐Ÿ‘ฅ ๊ทธ๋ฃน ๊ด€๋ จ ์‘๋‹ต ์ฝ”๋“œ (I01) +[[overview]] +== ๊ฐœ์š”: ์ด๋ฏธ์ง€ ํŒŒ์ผ ์—…๋กœ๋“œ + +StudyPals๋Š” `multipart/form-data` ํ˜•์‹์„ ์‚ฌ์šฉํ•˜์—ฌ ์ด๋ฏธ์ง€ ํŒŒ์ผ์„ ์„œ๋ฒ„๋กœ ์ง์ ‘ ์—…๋กœ๋“œํ•ฉ๋‹ˆ๋‹ค. ์ „์ฒด์ ์ธ ํ”„๋กœ์„ธ์Šค๋Š” ๋‹ค์Œ๊ณผ ๊ฐ™์Šต๋‹ˆ๋‹ค. + +. *ํŒŒ์ผ ์ „์†ก*: ํด๋ผ์ด์–ธํŠธ๋Š” `multipart/form-data` ์š”์ฒญ์„ ํ†ตํ•ด ์ด๋ฏธ์ง€ ํŒŒ์ผ๊ณผ ํ•„์š”ํ•œ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ(์ฑ„ํŒ…๋ฐฉ ID ๋“ฑ)๋ฅผ ์„œ๋ฒ„๋กœ ์ „์†กํ•ฉ๋‹ˆ๋‹ค. +. *์„œ๋ฒ„ ์ฒ˜๋ฆฌ*: ์„œ๋ฒ„๋Š” ์ˆ˜์‹ ํ•œ ํŒŒ์ผ์„ ๊ฒ€์ฆํ•˜๊ณ , ์Šคํ† ๋ฆฌ์ง€(MinIO)์— ์—…๋กœ๋“œํ•ฉ๋‹ˆ๋‹ค. +. *๋ฐ์ดํ„ฐ ์ €์žฅ*: ์—…๋กœ๋“œ๊ฐ€ ์„ฑ๊ณตํ•˜๋ฉด ํŒŒ์ผ์˜ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ(๊ฒฝ๋กœ, ์›๋ณธ ์ด๋ฆ„ ๋“ฑ)๋ฅผ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ์ €์žฅํ•ฉ๋‹ˆ๋‹ค. +. *์‘๋‹ต ๋ฐ˜ํ™˜*: ์ €์žฅ๋œ ์ด๋ฏธ์ง€์˜ ์‹๋ณ„์ž(ID)์™€ ์ ‘๊ทผ ๊ฐ€๋Šฅํ•œ URL์„ ํด๋ผ์ด์–ธํŠธ์— ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. + +[[response-codes]] +== ๐Ÿ“„ ํŒŒ์ผ ๊ด€๋ จ ์‘๋‹ต ์ฝ”๋“œ (F01) |=== | ๊ธฐ๋Šฅ ์ฝ”๋“œ | ์„ค๋ช… -| 00 | ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ Presigned URL ์กฐํšŒ -| 01 | ์ฑ„ํŒ… ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ Presigned URL ์กฐํšŒ +| `FILE_IMAGE_UPLOAD` | ์ด๋ฏธ์ง€ ํŒŒ์ผ ์—…๋กœ๋“œ ์„ฑ๊ณต |=== == โœจ API ๋ฌธ์„œ -=== ํŒŒ์ผ API +=== 1. ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ '''' - -==== 1. ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ URL ์กฐํšŒ - *Description* + -''' - -ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ๋ฅผ ์œ„ํ•œ Presigned URL์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. ํ•ด๋‹น Presigned URL์„ ํ†ตํ•ด MinIO์— ์ง์ ‘ ์š”์ฒญํ•˜์—ฌ ์ด๋ฏธ์ง€๋ฅผ ์—…๋กœ๋“œํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. +์‚ฌ์šฉ์ž์˜ ํ”„๋กœํ•„ ์ด๋ฏธ์ง€๋ฅผ ์—…๋กœ๋“œํ•ฉ๋‹ˆ๋‹ค. ๊ธฐ์กด ํ”„๋กœํ•„ ์ด๋ฏธ์ง€๊ฐ€ ์กด์žฌํ•  ๊ฒฝ์šฐ, ์ƒˆ๋กœ์šด ์ด๋ฏธ์ง€๋กœ ๊ต์ฒด๋ฉ๋‹ˆ๋‹ค. +- *HTTP Method*: `POST` +- *Endpoint*: `/files/image/profile` +- *Content-Type*: `multipart/form-data` *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[] +include::{snippets}/image-file-controller-rest-docs-test/get-profile-upload-url_success/request-parts.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 ์กฐํšŒ - +=== 2. ์ฑ„ํŒ… ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ +'''' *Description* + -''' - -์ฑ„ํŒ… ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ๋ฅผ ์œ„ํ•œ Presigned URL์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. ํ•ด๋‹น Presigned URL์„ ํ†ตํ•ด MinIO์— ์ง์ ‘ ์š”์ฒญํ•˜์—ฌ ์ด๋ฏธ์ง€๋ฅผ ์—…๋กœ๋“œํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. +์ฑ„ํŒ…๋ฐฉ์—์„œ ์‚ฌ์šฉํ•  ์ด๋ฏธ์ง€๋ฅผ ์—…๋กœ๋“œํ•ฉ๋‹ˆ๋‹ค. +- *HTTP Method*: `POST` +- *Endpoint*: `/files/image/chat` +- *Content-Type*: `multipart/form-data` +- *Requires Authentication*: Yes *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[] +include::{snippets}/image-file-controller-rest-docs-test/get-chat-upload-url_success/query-parameters.adoc[] +include::{snippets}/image-file-controller-rest-docs-test/get-chat-upload-url_success/request-parts.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/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)์˜ ํŠน์„ฑ์„ ๊ฐ€์ง‘๋‹ˆ๋‹ค. + * + *

์ฃผ์š” ํŠน์ง•: + *

+ * + * @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..321234bd 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.web.multipart.MultipartFile; 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.dto.ImageUploadDto; import com.studypals.global.file.entity.ImageType; +import com.studypals.global.file.entity.ImageVariantKey; /** * ํŒŒ์ผ ์ค‘ ์ฑ„ํŒ… ์ด๋ฏธ์ง€๋ฅผ ์ฒ˜๋ฆฌํ•˜๋Š”๋ฐ ์‚ฌ์šฉํ•˜๋Š” ๊ตฌ์ฒด ํด๋ž˜์Šค์ž…๋‹ˆ๋‹ค. @@ -26,11 +31,11 @@ */ @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; - public ChatImageManager(ObjectStorage objectStorage, ChatRoomReader chatRoomReader) { - super(objectStorage); + public ChatImageManager(ObjectStorage objectStorage, FileProperties properties, ChatRoomReader chatRoomReader) { + super(objectStorage, properties); this.chatRoomReader = chatRoomReader; } @@ -51,6 +56,11 @@ protected String generateObjectKeyDetail(String chatRoomId, String ext) { return CHAT_IMAGE_PATH + "/" + chatRoomId + "/" + UUID.randomUUID() + "." + ext; } + @Override + protected List variants() { + return List.of(ImageVariantKey.SMALL, ImageVariantKey.MEDIUM, ImageVariantKey.LARGE); + } + /** * ์ด ํด๋ž˜์Šค๋Š” ์ฑ„ํŒ… ์ด๋ฏธ์ง€๋ฅผ ์ฒ˜๋ฆฌํ•ฉ๋‹ˆ๋‹ค. * @return ์ฒ˜๋ฆฌํ•˜๋Š” ์ด๋ฏธ์ง€ ์ข…๋ฅ˜ @@ -59,4 +69,22 @@ protected String generateObjectKeyDetail(String chatRoomId, String ext) { public ImageType getFileType() { return ImageType.CHAT_IMAGE; } + + /** + * ์ฑ„ํŒ… ์ด๋ฏธ์ง€๋ฅผ ์—…๋กœ๋“œํ•˜๊ณ , ์ƒ์„ฑ๋œ ObjectKey์™€ ํŒŒ์ผ URL์„ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. + * + * @param file ์—…๋กœ๋“œํ•  ํŒŒ์ผ + * @param chatRoomId ์ฑ„ํŒ…๋ฐฉ ID + * @param userId ์‚ฌ์šฉ์ž ID + * @return ObjectKey์™€ ํŒŒ์ผ URL์„ ๋‹ด์€ ImageUploadDto + */ + public ImageUploadDto upload(MultipartFile file, Long userId, String chatRoomId) { + // ๊ณตํ†ต ์—…๋กœ๋“œ ๋กœ์ง์„ ์ˆ˜ํ–‰ํ•˜๋Š” ๋ถ€๋ชจ ํด๋ž˜์Šค์˜ ํ…œํ”Œ๋ฆฟ ๋ฉ”์„œ๋“œ๋ฅผ ํ˜ธ์ถœํ•ฉ๋‹ˆ๋‹ค. + return performUpload(file, userId, chatRoomId); + } + + @Override + protected boolean usePresignedUrl() { + return true; + } } 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..940255c1 --- /dev/null +++ b/src/main/java/com/studypals/domain/chatManage/worker/ChatImageWriter.java @@ -0,0 +1,54 @@ +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; + + /** + * ์ฑ„ํŒ… ์ด๋ฏธ์ง€์˜ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ๋ฅผ ์ƒ์„ฑํ•˜๊ณ  ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ์ €์žฅํ•ฉ๋‹ˆ๋‹ค. + *

+ * ์ด ๋ฉ”์„œ๋“œ๋Š” ํด๋ผ์ด์–ธํŠธ๊ฐ€ ์„œ๋ฒ„๋ฅผ ํ†ตํ•ด ์ด๋ฏธ์ง€๋ฅผ ์—…๋กœ๋“œํ•  ๋•Œ ํ˜ธ์ถœ๋˜๋ฉฐ, + * ์„œ๋ฒ„๊ฐ€ ํŒŒ์ผ์„ ์ฒ˜๋ฆฌํ•˜๊ณ  ์Šคํ† ๋ฆฌ์ง€์— ์ €์žฅํ•˜๋Š” ํ๋ฆ„ ์†์—์„œ ํ•ด๋‹น ํŒŒ์ผ์˜ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ๋ฅผ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ๊ธฐ๋กํ•ฉ๋‹ˆ๋‹ค. + * + * @param chatRoom ์ด๋ฏธ์ง€๊ฐ€ ์†ํ•œ ์ฑ„ํŒ…๋ฐฉ ์—”ํ‹ฐํ‹ฐ + * @param objectKey ์Šคํ† ๋ฆฌ์ง€์— ์ €์žฅ๋  ํŒŒ์ผ์˜ ๊ณ ์œ  ๊ฐ์ฒด ํ‚ค + * @param fileName ์›๋ณธ ํŒŒ์ผ์˜ ์ด๋ฆ„ + * @return ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ์ €์žฅ๋œ {@link ChatImage}์˜ ๊ณ ์œ  ID + */ + @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/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)์ด ๊ฐ€๋Šฅํ•˜๋ฉฐ, ์ด ๋•Œ ๊ธฐ์กด ๋ ˆ์ฝ”๋“œ๋ฅผ ์žฌํ™œ์šฉํ•˜์—ฌ ์ƒˆ๋กœ์šด ์ด๋ฏธ์ง€ ์ •๋ณด๋กœ ๊ฐฑ์‹ ํ•ฉ๋‹ˆ๋‹ค. + * + *

์ฃผ์š” ํŠน์ง•: + *

+ * + * @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/MemberProfileManager.java b/src/main/java/com/studypals/domain/memberManage/worker/MemberProfileImageManager.java similarity index 52% rename from src/main/java/com/studypals/domain/memberManage/worker/MemberProfileManager.java rename to src/main/java/com/studypals/domain/memberManage/worker/MemberProfileImageManager.java index d0cad9e8..6b8a1201 100644 --- a/src/main/java/com/studypals/domain/memberManage/worker/MemberProfileManager.java +++ b/src/main/java/com/studypals/domain/memberManage/worker/MemberProfileImageManager.java @@ -1,12 +1,17 @@ package com.studypals.domain.memberManage.worker; +import java.util.List; import java.util.UUID; import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; +import com.studypals.global.file.FileProperties; import com.studypals.global.file.ObjectStorage; import com.studypals.global.file.dao.AbstractImageManager; +import com.studypals.global.file.dto.ImageUploadDto; import com.studypals.global.file.entity.ImageType; +import com.studypals.global.file.entity.ImageVariantKey; /** * ํŒŒ์ผ ์ค‘ ํ”„๋กœํ•„ ์ด๋ฏธ์ง€๋ฅผ ์ฒ˜๋ฆฌํ•˜๋Š”๋ฐ ์‚ฌ์šฉํ•˜๋Š” ๊ตฌ์ฒด ํด๋ž˜์Šค์ž…๋‹ˆ๋‹ค. @@ -23,12 +28,12 @@ * @since 2026-01-13 */ @Component -public class MemberProfileManager extends AbstractImageManager { +public class MemberProfileImageManager extends AbstractImageManager { - private static final String PROFILE_PATH = "profile"; + private static final String PROFILE_PATH = "origin/profile"; - public MemberProfileManager(ObjectStorage objectStorage) { - super(objectStorage); + public MemberProfileImageManager(ObjectStorage objectStorage, FileProperties properties) { + super(objectStorage, properties); } /** @@ -40,6 +45,11 @@ protected String generateObjectKeyDetail(String targetId, String ext) { return PROFILE_PATH + "/" + targetId + "/" + UUID.randomUUID() + "." + ext; } + @Override + protected List variants() { + return List.of(ImageVariantKey.SMALL, ImageVariantKey.LARGE); + } + /** * ์ด ํด๋ž˜์Šค๋Š” ํ”„๋กœํ•„ ์ด๋ฏธ์ง€๋ฅผ ์ฒ˜๋ฆฌํ•ฉ๋‹ˆ๋‹ค. * @return ์ฒ˜๋ฆฌํ•˜๋Š” ์ด๋ฏธ์ง€ ์ข…๋ฅ˜ @@ -48,4 +58,20 @@ protected String generateObjectKeyDetail(String targetId, String ext) { public ImageType getFileType() { return ImageType.PROFILE_IMAGE; } + + /** + * ํ”„๋กœํ•„ ์ด๋ฏธ์ง€๋ฅผ ์—…๋กœ๋“œํ•˜๊ณ , ์ƒ์„ฑ๋œ ObjectKey์™€ ํŒŒ์ผ URL์„ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. + * + * @param file ์—…๋กœ๋“œํ•  ํŒŒ์ผ + * @param userId ์‚ฌ์šฉ์ž ID + * @return ObjectKey์™€ ํŒŒ์ผ URL์„ ๋‹ด์€ ImageUploadDto + */ + public ImageUploadDto upload(MultipartFile file, Long userId) { + return performUpload(file, userId, String.valueOf(userId)); + } + + @Override + protected boolean usePresignedUrl() { + return false; + } } 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/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)์€ ์ด ์ธํ„ฐํŽ˜์ด์Šค๋ฅผ ๊ตฌํ˜„ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. + *

+ * ์„ค๊ณ„ ์˜๋„: + *

+ * ์˜ˆ๋ฅผ ๋“ค์–ด, {@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 index ad7d1a7e..00b6f4fd 100644 --- a/src/main/java/com/studypals/global/file/api/ImageFileController.java +++ b/src/main/java/com/studypals/global/file/api/ImageFileController.java @@ -1,19 +1,18 @@ package com.studypals.global.file.api; -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.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; 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.dto.ImageUploadRes; import com.studypals.global.file.service.ImageFileService; import com.studypals.global.responses.CommonResponse; import com.studypals.global.responses.Response; @@ -21,11 +20,11 @@ /** * ํŒŒ์ผ ๊ด€๋ จ ๋กœ์ง์„ ์ฒ˜๋ฆฌํ•˜๋Š” ์ปจํŠธ๋กค๋Ÿฌ์ž…๋‹ˆ๋‹ค. - * ํŒŒ์ผ ์—…๋กœ๋“œ๋Š” ์„œ๋ฒ„ ์ธก์—์„œ presigned url์„ ๋ฐœ๊ธ‰ํ•˜๊ณ  ํด๋ผ์ด์–ธํŠธ ์ธก์—์„œ ์ง„ํ–‰ํ•ฉ๋‹ˆ๋‹ค. + * ํด๋ผ์ด์–ธํŠธ๋กœ๋ถ€ํ„ฐ ํŒŒ์ผ์„ ์ง์ ‘ ๋ฐ›์•„ ์„œ๋ฒ„์—์„œ ์Šคํ† ๋ฆฌ์ง€๋กœ ์—…๋กœ๋“œ๋ฅผ ์ง„ํ–‰ํ•ฉ๋‹ˆ๋‹ค. * *

- *     - POST /files/image/profile : ํ”„๋กœํ•„ ์‚ฌ์ง„ ์—…๋กœ๋“œ๋ฅผ ์œ„ํ•œ URL ๋ฐœ๊ธ‰
- *     - POST /files/image/chat : ์ฑ„ํŒ… ์‚ฌ์ง„ ์—…๋กœ๋“œ๋ฅผ ์œ„ํ•œ URL ๋ฐœ๊ธ‰
+ *     - POST /files/image/profile : ํ”„๋กœํ•„ ์‚ฌ์ง„ ์—…๋กœ๋“œ
+ *     - POST /files/image/chat : ์ฑ„ํŒ… ์‚ฌ์ง„ ์—…๋กœ๋“œ
  * 
* * @author sleepyhoon @@ -37,17 +36,19 @@ 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); + @PostMapping(value = "/profile", 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)); } - @PostMapping("/chat") - public ResponseEntity> getUploadUrl( - @Valid @RequestBody ChatPresignedUrlReq request, @AuthenticationPrincipal Long userId) { - PresignedUrlRes response = imageFileService.getChatUploadUrl(request, userId); + @PostMapping(value = "/chat", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity> uploadChatImage( + @RequestPart("file") MultipartFile file, + @RequestParam("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/global/file/dao/AbstractFileManager.java b/src/main/java/com/studypals/global/file/dao/AbstractFileManager.java index 56944649..0ab293db 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(); /** - * ํŒŒ์ผ ์ด๋ฆ„์—์„œ ํ™•์žฅ์ž๋ฅผ ์ถ”์ถœํ•ฉ๋‹ˆ๋‹ค. - * @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(); + public 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..c7cbcef7 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,165 @@ 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.entity.ImageVariantKey; /** - * * ํŒŒ์ผ์„ ์ฒ˜๋ฆฌํ•˜๋Š”๋ฐ ์‚ฌ์šฉํ•˜๋Š” ์ถ”์ƒ ํด๋ž˜์Šค์ž…๋‹ˆ๋‹ค. - * * - * *

- * * ํŒŒ์ผ์„ ์—…๋กœ๋“œํ•˜๊ธฐ ์œ„ํ•œ UploadUrl์„ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. - * * ํŒŒ์ผ ์กฐํšŒ(๋‹ค์šด๋กœ๋“œ)์˜ ๊ฒฝ์šฐ ์ผ๋ถ€ ๋„๋ฉ”์ธ์€ Public URL์„ ์‚ฌ์šฉํ•˜๊ณ , ์ผ๋ถ€๋Š” Presigned URL์„ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. - * * ํŒŒ์ผ ์‚ญ์ œ ์‹œ, URL์—์„œ ๊ฒฝ๋กœ๋ฅผ ์ถ”์ถœํ•˜์—ฌ ์Šคํ† ๋ฆฌ์ง€์—์„œ ์‚ญ์ œํ•ฉ๋‹ˆ๋‹ค. + * ๋‹ค์–‘ํ•œ ์ข…๋ฅ˜์˜ ์ด๋ฏธ์ง€ ํŒŒ์ผ์„ ์ผ๊ด€๋œ ๋ฐฉ์‹์œผ๋กœ ์ฒ˜๋ฆฌํ•˜๊ธฐ ์œ„ํ•œ ์ถ”์ƒ ํด๋ž˜์Šค์ž…๋‹ˆ๋‹ค. + *

+ * ์ด ํด๋ž˜์Šค๋Š” ํ…œํ”Œ๋ฆฟ ๋ฉ”์„œ๋“œ ํŒจํ„ด์„ ์‚ฌ์šฉํ•˜์—ฌ ์ด๋ฏธ์ง€ ํŒŒ์ผ ๊ด€๋ฆฌ์˜ ์ „์ฒด์ ์ธ ๋กœ์ง ํ๋ฆ„์„ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค. + * {@link #createObjectKey}์™€ {@link #getPresignedGetUrl} ๊ฐ™์€ final ๋ฉ”์„œ๋“œ๊ฐ€ ํ…œํ”Œ๋ฆฟ ์—ญํ• ์„ ํ•˜๋ฉฐ, + * ์„ธ๋ถ€์ ์ธ ๊ตฌํ˜„์ด ํ•„์š”ํ•œ {@link #generateObjectKeyDetail}์€ ํ•˜์œ„ ํด๋ž˜์Šค์—์„œ ๊ตฌํ˜„ํ•˜๋„๋ก ๊ฐ•์ œํ•ฉ๋‹ˆ๋‹ค. + *

+ * ์ด๋ฅผ ํ†ตํ•ด ํ”„๋กœํ•„ ์ด๋ฏธ์ง€, ์ฑ„ํŒ… ์ด๋ฏธ์ง€ ๋“ฑ ๊ฐ๊ธฐ ๋‹ค๋ฅธ ๋„๋ฉ”์ธ์˜ ์ด๋ฏธ์ง€ ๊ด€๋ฆฌ ๋กœ์ง์„ + * ํ‘œ์ค€ํ™”๋œ ํ”„๋กœ์„ธ์Šค์— ๋”ฐ๋ผ ์ฒ˜๋ฆฌํ•˜๋ฉด์„œ๋„, ๋„๋ฉ”์ธ๋ณ„ ๊ฒฝ๋กœ ์ƒ์„ฑ ์ •์ฑ… ๋“ฑ์€ ์œ ์—ฐํ•˜๊ฒŒ ํ™•์žฅํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. * * @author sleepyhoon + * @see AbstractFileManager + * @see com.studypals.domain.memberManage.worker.MemberProfileImageManager + * @see com.studypals.domain.chatManage.worker.ChatImageManager * @since 2026-01-13 */ public abstract class AbstractImageManager extends AbstractFileManager { - @Value("${file.upload.extensions}") - private List acceptableExtensions; - - @Value("${file.upload.presigned-url-expire-time}") - private int presignedUrlExpireTime; - - public AbstractImageManager(ObjectStorage objectStorage) { - super(objectStorage); - } + private final List acceptableExtensions; + private final int presignedUrlExpireTime; /** - * ํŒŒ์ผ ์—…๋กœ๋“œ๋ฅผ ์œ„ํ•œ Presigned URL์„ ๋ฐœ๊ธ‰ํ•ฉ๋‹ˆ๋‹ค. - * ๋‚ด๋ถ€์ ์œผ๋กœ ํŒŒ์ผ ์ด๋ฆ„ ๊ฒ€์ฆ๊ณผ ํƒ€๊ฒŸ ID ๊ฒ€์ฆ์„ ์ˆ˜ํ–‰ํ•ฉ๋‹ˆ๋‹ค. - * ํ•ด๋‹น ๋ฉ”์„œ๋“œ๋Š” ์žฌ์ •์˜ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. - * @param userId ์—…๋กœ๋“œ ์š”์ฒญํ•œ ์‚ฌ์šฉ์ž ID - * @param fileName ์—…๋กœ๋“œํ•  ํŒŒ์ผ ์ด๋ฆ„ - * @param targetId ์—…๋กœ๋“œ ๋Œ€์ƒ ์‹๋ณ„์ž (์˜ˆ: userId, groupId, chatRoomId) - * @return ์—…๋กœ๋“œ ๊ฐ€๋Šฅํ•œ Presigned URL + * {@code AbstractImageManager}์˜ ์ƒ์„ฑ์ž์ž…๋‹ˆ๋‹ค. + * + * @param objectStorage ์Šคํ† ๋ฆฌ์ง€ ์ƒํ˜ธ์ž‘์šฉ์„ ์œ„ํ•œ ์ธํ„ฐํŽ˜์ด์Šค ๊ตฌํ˜„์ฒด + * @param properties ํŒŒ์ผ ๊ด€๋ จ ์„ค์ •๊ฐ’ (ํ—ˆ์šฉ ํ™•์žฅ์ž, 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 AbstractImageManager(ObjectStorage objectStorage, FileProperties properties) { + super(objectStorage); + this.acceptableExtensions = properties.extensions(); + this.presignedUrlExpireTime = properties.presignedUrlExpireTime(); } /** - * MinIO/S3 ์— ์ €์žฅํ•  ๊ฒฝ๋กœ(ObjectKey)๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค. - * @param fileName ์ด๋ฏธ์ง€ ์ด๋ฆ„ - * @param targetId ์—…๋กœ๋“œ ๋Œ€์ƒ ์‹๋ณ„์ž (์˜ˆ: userId, groupId, chatRoomId) - * @return ์ƒ์„ฑ๋œ ObjectKey + * ํŒŒ์ผ ์—…๋กœ๋“œ๋ฅผ ์œ„ํ•œ Presigned URL์„ ์ƒ์„ฑํ•˜์—ฌ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. + *

+ * ์ด ๋ฉ”์„œ๋“œ๋Š” ํ…œํ”Œ๋ฆฟ์˜ ์ผ๋ถ€๋กœ, ๋ชจ๋“  ํ•˜์œ„ ํด๋ž˜์Šค์—์„œ ๋™์ผํ•œ ๋ฐฉ์‹์œผ๋กœ ๋™์ž‘ํ•ด์•ผ ํ•˜๋ฏ€๋กœ {@code final}๋กœ ์„ ์–ธ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. + * ์‹ค์ œ URL ์ƒ์„ฑ์€ {@link ObjectStorage} ๊ตฌํ˜„์ฒด์— ์œ„์ž„ํ•ฉ๋‹ˆ๋‹ค. + * + * @param objectKey ์Šคํ† ๋ฆฌ์ง€์— ์ €์žฅ๋  ๊ฐ์ฒด์˜ ๊ณ ์œ  ํ‚ค + * @return ์—…๋กœ๋“œ ์ „์šฉ Presigned URL */ - private String generateObjectKey(String fileName, String targetId) { - String ext = extractExtension(fileName); - return generateObjectKeyDetail(targetId, ext); + public final String getPresignedGetUrl(String objectKey) { + return objectStorage.createPresignedGetUrl(objectKey, presignedUrlExpireTime); } /** - * ํ”„๋กœํ•„, ์ฑ„ํŒ… ์ด๋ฏธ์ง€์˜ ๊ฒฝ๋กœ(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 final ImageUploadDto performUpload(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 final String createObjectKey(Long userId, String fileName, String targetId) { + validateFileName(fileName); + validateTargetId(userId, targetId); + String extension = FileUtils.extractExtension(fileName); + return generateObjectKeyDetail(targetId, extension); } /** - * ์‚ฌ์ „์— ์ •ํ•ด๋‘” ํŒŒ์ผ ํ™•์žฅ์ž๋ฅผ ๊ฐ€์ง€๋Š”์ง€ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค. - * @param fileName ํ™•์ธํ•  ํŒŒ์ผ ์ด๋ฆ„ + * ํŒŒ์ผ ์ด๋ฆ„์˜ ์œ ํšจ์„ฑ๊ณผ ํ™•์žฅ์ž๋ฅผ ๊ฒ€์ฆํ•ฉ๋‹ˆ๋‹ค. + * ํŒŒ์ผ ์ด๋ฆ„์ด null์ด๊ฑฐ๋‚˜ '.'์„ ํฌํ•จํ•˜์ง€ ์•Š๋Š” ๊ฒฝ์šฐ, ๋˜๋Š” ํ—ˆ์šฉ๋˜์ง€ ์•Š์€ ํ™•์žฅ์ž์ธ ๊ฒฝ์šฐ ์˜ˆ์™ธ๋ฅผ ๋ฐœ์ƒ์‹œํ‚ต๋‹ˆ๋‹ค. + * TODO: ๊ฐ•๋„๋†’์€ ์œ ํšจ์„ฑ ๊ฒ€์ฆ์„ ์œ„ํ•ด Tika, ImageIO์„ ์ถ”๊ฐ€๋กœ ๋„์ž…ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. + * @param fileName ๊ฒ€์ฆํ•  ํŒŒ์ผ ์ด๋ฆ„ + * @throws FileException ์œ ํšจํ•˜์ง€ ์•Š์€ ํŒŒ์ผ ์ด๋ฆ„ ๋˜๋Š” ์ง€์›ํ•˜์ง€ ์•Š๋Š” ํ™•์žฅ์ž์ธ ๊ฒฝ์šฐ */ private void validateFileName(String fileName) { if (fileName == null || !fileName.contains(".")) { throw new FileException(FileErrorCode.INVALID_FILE_NAME); } - String extension = extractExtension(fileName); + String extension = FileUtils.extractExtension(fileName); if (!acceptableExtensions.contains(extension)) { throw new FileException(FileErrorCode.UNSUPPORTED_FILE_IMAGE_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); + + /** + * ์ด Manager๊ฐ€ ์ฒ˜๋ฆฌํ•˜๋Š” ์ด๋ฏธ์ง€์˜ ๋‹ค์–‘ํ•œ ํฌ๊ธฐ ๋ฒ„์ „(Variant) ์ •๋ณด๋ฅผ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. + * ํ•˜์œ„ ํด๋ž˜์Šค๋Š” ์ด ๋ฉ”์„œ๋“œ๋ฅผ ๊ตฌํ˜„ํ•˜์—ฌ ์›๋ณธ, ์ธ๋„ค์ผ ๋“ฑ ํ•„์š”ํ•œ ์ด๋ฏธ์ง€ ์ข…๋ฅ˜๋ฅผ ์ •์˜ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. + * + * @return {@link ImageVariantKey} ๋ฆฌ์ŠคํŠธ + */ + protected abstract List variants(); + + /** + * ์—…๋กœ๋“œ ์™„๋ฃŒ ํ›„ ๋ฐ˜ํ™˜ํ•  URL์„ Presigned URL๋กœ ํ• ์ง€ ์—ฌ๋ถ€๋ฅผ ๊ฒฐ์ •ํ•ฉ๋‹ˆ๋‹ค. + * + * @return true๋ฉด Presigned URL ๋ฐ˜ํ™˜, false๋ฉด ์—…๋กœ๋“œ ์‹œ ๋ฐ˜ํ™˜๋œ URL(Public) ์‚ฌ์šฉ + */ + protected abstract boolean usePresignedUrl(); } 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..d6b2ad9c 100644 --- a/src/main/java/com/studypals/global/file/service/ImageFileServiceImpl.java +++ b/src/main/java/com/studypals/global/file/service/ImageFileServiceImpl.java @@ -6,57 +6,203 @@ import java.util.stream.Collectors; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.web.multipart.MultipartFile; +import com.studypals.domain.chatManage.entity.ChatRoom; +import com.studypals.domain.chatManage.worker.ChatImageManager; +import com.studypals.domain.chatManage.worker.ChatImageWriter; +import com.studypals.domain.chatManage.worker.ChatRoomReader; +import com.studypals.domain.memberManage.entity.Member; +import com.studypals.domain.memberManage.entity.MemberProfileImage; +import com.studypals.domain.memberManage.worker.MemberProfileImageManager; +import com.studypals.domain.memberManage.worker.MemberProfileImageWriter; +import com.studypals.domain.memberManage.worker.MemberReader; 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.FileUtils; 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.ImageUploadDto; +import com.studypals.global.file.dto.ImageUploadRes; import com.studypals.global.file.entity.ImageType; /** - * ํŒŒ์ผ์„ ์ฒ˜๋ฆฌํ•˜๋Š” ๋กœ์ง์„ ์ •์˜ํ•œ ๊ตฌํ˜„ ํด๋ž˜์Šค์ž…๋‹ˆ๋‹ค. - * ํŒŒ์ผ ์—…๋กœ๋“œ๋ฅผ ์œ„ํ•œ presigned url์„ ๋ฐœ๊ธ‰์„ ์ง„ํ–‰ํ•ฉ๋‹ˆ๋‹ค. + * ์ด๋ฏธ์ง€ ํŒŒ์ผ ๊ด€๋ จ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์„ ์ฒ˜๋ฆฌํ•˜๋Š” ์„œ๋น„์Šค ๊ตฌํ˜„์ฒด์ž…๋‹ˆ๋‹ค. + *

+ * ์ด ์„œ๋น„์Šค๋Š” ํด๋ผ์ด์–ธํŠธ๋กœ๋ถ€ํ„ฐ ๋ฐ›์€ ํŒŒ์ผ์„ ์Šคํ† ๋ฆฌ์ง€์— ์ง์ ‘ ์—…๋กœ๋“œํ•˜๋Š” ์—ญํ• ์„ ๋‹ด๋‹นํ•ฉ๋‹ˆ๋‹ค. + * {@link ImageType}์— ๋”ฐ๋ผ ์ ์ ˆํ•œ {@link AbstractImageManager}๋ฅผ ๋™์ ์œผ๋กœ ์„ ํƒํ•˜์—ฌ ๋กœ์ง์„ ์œ„์ž„ํ•˜๋Š” ์ „๋žต ํŒจํ„ด์„ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. + * ์ด๋ฅผ ํ†ตํ•ด ์ƒˆ๋กœ์šด ์ด๋ฏธ์ง€ ํƒ€์ž…์ด ์ถ”๊ฐ€๋˜๋”๋ผ๋„ ์„œ๋น„์Šค ์ฝ”๋“œ์˜ ๋ณ€๊ฒฝ ์—†์ด ์œ ์—ฐํ•˜๊ฒŒ ํ™•์žฅํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. + * + *

์ฃผ์š” ํ๋ฆ„: + *

    + *
  1. ํด๋ผ์ด์–ธํŠธ๋กœ๋ถ€ํ„ฐ ์ด๋ฏธ์ง€ ํŒŒ์ผ๊ณผ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ๋ฅผ ๋ฐ›์Šต๋‹ˆ๋‹ค.
  2. + *
  3. ์š”์ฒญ ํƒ€์ž…์— ๋งž๋Š” Manager๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.
  4. + *
  5. Manager๋ฅผ ํ†ตํ•ด ์Šคํ† ๋ฆฌ์ง€์— ์ €์žฅ๋  ๊ณ ์œ ํ•œ Object Key๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.
  6. + *
  7. ์ด๋ฏธ์ง€ ๋ฆฌ์‚ฌ์ด์ง• ๋กœ์ง์„ ์ˆ˜ํ–‰ํ•ฉ๋‹ˆ๋‹ค.
  8. + *
  9. ObjectStorage๋ฅผ ํ†ตํ•ด ํŒŒ์ผ์„ ์Šคํ† ๋ฆฌ์ง€์— ์—…๋กœ๋“œํ•ฉ๋‹ˆ๋‹ค.
  10. + *
  11. ์—…๋กœ๋“œ๋œ ํŒŒ์ผ ์ •๋ณด์™€ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ๋ฅผ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ์ €์žฅํ•ฉ๋‹ˆ๋‹ค.
  12. + *
  13. ์ €์žฅ๋œ ์ด๋ฏธ์ง€ ID์™€ ์ ‘๊ทผ ๊ฐ€๋Šฅํ•œ URL์„ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค.
  14. + *
* * @author sleepyhoon * @since 2026-01-10 + * @see ImageFileService + * @see AbstractImageManager */ @Service public class ImageFileServiceImpl implements ImageFileService { - private final Map managerMap; + private final Map managerMap; + private final MemberReader memberReader; + private final ChatRoomReader chatRoomReader; + private final MemberProfileImageWriter profileImageWriter; + private final ChatImageWriter chatImageWriter; - public ImageFileServiceImpl(List managers) { + /** + * ์˜์กด์„ฑ ์ฃผ์ž…์„ ์œ„ํ•œ ์ƒ์„ฑ์ž์ž…๋‹ˆ๋‹ค. + *

+ * Spring ์ปจํ…์ŠคํŠธ์— ๋“ฑ๋ก๋œ ๋ชจ๋“  {@link AbstractImageManager} ํƒ€์ž…์˜ ๋นˆ์„ ๋ฆฌ์ŠคํŠธ๋กœ ์ฃผ์ž…๋ฐ›์•„, + * ๊ฐ Manager๊ฐ€ ์ฒ˜๋ฆฌํ•˜๋Š” {@link FileType}์„ ํ‚ค๋กœ ํ•˜๋Š” ๋งต์„ ๊ตฌ์„ฑํ•ฉ๋‹ˆ๋‹ค. + * ๋งŒ์•ฝ ์„œ๋กœ ๋‹ค๋ฅธ Manager๊ฐ€ ๋™์ผํ•œ FileType์„ ์ฒ˜๋ฆฌํ•˜๋ ค๊ณ  ํ•  ๊ฒฝ์šฐ, ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ๊ตฌ๋™ ์‹œ์ ์— + * {@link IllegalStateException}์„ ๋ฐœ์ƒ์‹œ์ผœ ์„ค์ • ์˜ค๋ฅ˜๋ฅผ ๋ฐฉ์ง€ํ•ฉ๋‹ˆ๋‹ค. + * + *

+ * managerMap ์•ˆ์—๋Š” ์•„๋ž˜์™€ ๊ฐ™์ด AbstractFileManager์„ ์ƒ์†ํ•œ ์Šคํ”„๋ง ๋นˆ์ด ์ €์žฅ๋ฉ๋‹ˆ๋‹ค. + *

+     * {
+     *   ImageType.PROFILE_IMAGE : (MemberProfileImageManager ์ธ์Šคํ„ด์Šค),
+     *   ImageType.CHAT_IMAGE    : (ChatImageManager ์ธ์Šคํ„ด์Šค)
+     * }
+     * 
+ * ์ด๋ฅผ ํ†ตํ•ด ์ดํ›„์˜ ์„œ๋น„์Šค ๋ฉ”์„œ๋“œ์—์„œ๋Š” ํŒŒ์ผ ํƒ€์ž…๋งŒ์œผ๋กœ ์ ์ ˆํ•œ Manager๋ฅผ ์ฆ‰์‹œ ์ฐพ์•„ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. + * + * @param managers Spring ์ปจํ…์ŠคํŠธ์— ์˜ํ•ด ์ฃผ์ž…๋˜๋Š” {@code AbstractImageManager}์˜ ๋ชจ๋“  ๊ตฌํ˜„์ฒด ๋ฆฌ์ŠคํŠธ + */ + public ImageFileServiceImpl( + List managers, + MemberReader memberReader, + ChatRoomReader chatRoomReader, + MemberProfileImageWriter profileImageWriter, + ChatImageWriter chatImageWriter) { this.managerMap = managers.stream() .collect(Collectors.toMap( AbstractFileManager::getFileType, Function.identity(), (existing, duplicate) -> { throw new IllegalStateException(String.format( - "ImageType ์ค‘๋ณต ๋“ฑ๋ก ์˜ค๋ฅ˜. '%s' ํƒ€์ž…์ด '%s'์™€ '%s' ํด๋ž˜์Šค์—์„œ ์ค‘๋ณต์œผ๋กœ ์ฒ˜๋ฆฌ๋ฉ๋‹ˆ๋‹ค.", + "FileType ์ค‘๋ณต ๋“ฑ๋ก ์˜ค๋ฅ˜. '%s' ํƒ€์ž…์ด '%s'์™€ '%s' ํด๋ž˜์Šค์—์„œ ์ค‘๋ณต์œผ๋กœ ์ฒ˜๋ฆฌ๋ฉ๋‹ˆ๋‹ค.", existing.getFileType(), existing.getClass().getName(), duplicate.getClass().getName())); })); + this.memberReader = memberReader; + this.chatRoomReader = chatRoomReader; + this.profileImageWriter = profileImageWriter; + this.chatImageWriter = chatImageWriter; } + /** + * ์‚ฌ์šฉ์ž ํ”„๋กœํ•„ ์ด๋ฏธ์ง€๋ฅผ ์—…๋กœ๋“œํ•ฉ๋‹ˆ๋‹ค. + *

+ * {@link ImageType#PROFILE_IMAGE} ํƒ€์ž…์— ๋งž๋Š” Manager๋ฅผ ์ฐพ์•„ ๋‹ค์Œ์„ ์ˆ˜ํ–‰ํ•ฉ๋‹ˆ๋‹ค: + *

    + *
  1. ์‚ฌ์šฉ์ž ID์™€ ํŒŒ์ผ๋ช…์„ ๊ธฐ๋ฐ˜์œผ๋กœ Object Key๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.
  2. + *
  3. ์Šคํ† ๋ฆฌ์ง€์— ํŒŒ์ผ์„ ์—…๋กœ๋“œํ•ฉ๋‹ˆ๋‹ค.
  4. + *
  5. ์ด๋ฏธ์ง€ ์ •๋ณด๋ฅผ DB์— ์ €์žฅํ•ฉ๋‹ˆ๋‹ค.
  6. + *
+ * + * @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); + @Transactional + public ImageUploadRes uploadProfileImage(MultipartFile file, Long userId) { + MemberProfileImageManager manager = getManager(ImageType.PROFILE_IMAGE, MemberProfileImageManager.class); + + // 1. [MinIO] ํ”„๋กœํ•„ ์‚ฌ์ง„ ์—…๋กœ๋“œ, ์—…๋กœ๋“œ๊ฐ€ ์„ฑ๊ณตํ•˜๋ฉด file ์ •๋ณด๋Š” ์ „๋ถ€ ์œ ํšจํ•ฉ๋‹ˆ๋‹ค. + ImageUploadDto uploadDto = manager.upload(file, userId); + + // DB ์—…๋ฐ์ดํŠธ๋ฅผ ์œ„ํ•œ ์ค€๋น„ + Member member = memberReader.get(userId); + String extension = FileUtils.extractExtension(file.getOriginalFilename()); + MemberProfileImage currentProfile = member.getProfileImage(); + Long imageId; + + if (currentProfile != null) { + // 2. [DB] ๊ธฐ์กด ์ •๋ณด๊ฐ€ ์žˆ์œผ๋ฉด -> DB ๋จผ์ € ์—…๋ฐ์ดํŠธ + String oldObjectKey = currentProfile.getObjectKey(); // ์‚ญ์ œํ•  ํ‚ค ๋ฏธ๋ฆฌ ๋ฐฑ์—… + + currentProfile.update(uploadDto.objectKey(), file.getOriginalFilename(), extension); + + imageId = currentProfile.getId(); + + // ์„ฑ๊ณต์ ์œผ๋กœ ์ˆ˜์ •์ด ๋˜์—ˆ์„ ๋•Œ๋งŒ minio์—์„œ ๊ธฐ์กด ํ”„๋กœํ•„์„ delete + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + manager.delete(oldObjectKey); + } + }); + } + } else { + // [DB] ๊ธฐ์กด ํ”„๋กœํ•„์ด ์—†์œผ๋ฉด ๊ทธ๋ƒฅ ์ €์žฅ + MemberProfileImage savedImage = + profileImageWriter.save(member, uploadDto.objectKey(), file.getOriginalFilename()); + + imageId = savedImage.getId(); + + member.setProfileImage(savedImage); + } + + return new ImageUploadRes(imageId, uploadDto.imageUrl()); } + /** + * ์ฑ„ํŒ…๋ฐฉ ๋‚ด ์ด๋ฏธ์ง€๋ฅผ ์—…๋กœ๋“œํ•ฉ๋‹ˆ๋‹ค. + *

+ * {@link ImageType#CHAT_IMAGE} ํƒ€์ž…์— ๋งž๋Š” Manager๋ฅผ ์ฐพ์•„ ๋‹ค์Œ์„ ์ˆ˜ํ–‰ํ•ฉ๋‹ˆ๋‹ค: + *

    + *
  1. ์ฑ„ํŒ…๋ฐฉ ID, ์‚ฌ์šฉ์ž ID, ํŒŒ์ผ๋ช…์„ ๊ธฐ๋ฐ˜์œผ๋กœ Object Key๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.
  2. + *
  3. ์Šคํ† ๋ฆฌ์ง€์— ํŒŒ์ผ์„ ์—…๋กœ๋“œํ•ฉ๋‹ˆ๋‹ค.
  4. + *
  5. ์ด๋ฏธ์ง€ ์ •๋ณด๋ฅผ DB์— ์ €์žฅํ•ฉ๋‹ˆ๋‹ค.
  6. + *
+ * + * @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); + @Transactional + public ImageUploadRes uploadChatImage(MultipartFile file, String chatRoomId, Long userId) { + ChatImageManager manager = getManager(ImageType.CHAT_IMAGE, ChatImageManager.class); + + ImageUploadDto uploadDto = manager.upload(file, userId, chatRoomId); + + ChatRoom chatRoom = chatRoomReader.getById(chatRoomId); + + Long imageId = chatImageWriter.save(chatRoom, uploadDto.objectKey(), file.getOriginalFilename()); + + return new ImageUploadRes(imageId, uploadDto.imageUrl()); } - private T getManager(FileType fileType, Class managerClass) { - AbstractFileManager manager = managerMap.get(fileType); + /** + * ์ง€์ •๋œ {@link FileType}์— ํ•ด๋‹นํ•˜๋Š” {@link AbstractImageManager}์˜ ๊ตฌํ˜„์ฒด๋ฅผ ํƒ€์ž… ์•ˆ์ „ํ•˜๊ฒŒ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. + * + * @param fileType ์กฐํšŒํ•  ํŒŒ์ผ ํƒ€์ž… (์˜ˆ: {@code ImageType.PROFILE_IMAGE}) + * @param managerClass ๋ฐ˜ํ™˜๋ฐ›๊ณ ์ž ํ•˜๋Š” Manager์˜ ํด๋ž˜์Šค ํƒ€์ž… + * @param {@code AbstractImageManager}๋ฅผ ์ƒ์†ํ•˜๋Š” ํŠน์ • Manager ํƒ€์ž… + * @return ์š”์ฒญ๋œ ํƒ€์ž…์˜ Manager ์ธ์Šคํ„ด์Šค + * @throws FileException ํ•ด๋‹น {@code fileType}์„ ์ฒ˜๋ฆฌํ•˜๋Š” Manager๊ฐ€ ๋“ฑ๋ก๋˜์–ด ์žˆ์ง€ ์•Š์„ ๊ฒฝ์šฐ ๋ฐœ์ƒ + * @throws IllegalStateException ์กฐํšŒ๋œ Manager๊ฐ€ ์š”์ฒญ๋œ {@code managerClass} ํƒ€์ž…๊ณผ ์ผ์น˜ํ•˜์ง€ ์•Š์„ ๊ฒฝ์šฐ ๋ฐœ์ƒ. + * ์ด๋Š” ์‹ฌ๊ฐํ•œ ์„ค์ • ์˜ค๋ฅ˜๋ฅผ ์˜๋ฏธํ•ฉ๋‹ˆ๋‹ค. + */ + private T getManager(FileType fileType, Class managerClass) { + AbstractImageManager manager = managerMap.get(fileType); if (manager == null) { throw 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/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..581a5fa2 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; @@ -35,46 +28,8 @@ class ChatImageManagerTest { @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); } @Test diff --git a/src/test/java/com/studypals/domain/chatManage/worker/ChatImageWriterTest.java b/src/test/java/com/studypals/domain/chatManage/worker/ChatImageWriterTest.java new file mode 100644 index 00000000..cae7e5ff --- /dev/null +++ b/src/test/java/com/studypals/domain/chatManage/worker/ChatImageWriterTest.java @@ -0,0 +1,67 @@ +package com.studypals.domain.chatManage.worker; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +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.file.entity.ImageStatus; + +@ExtendWith(MockitoExtension.class) +class ChatImageWriterTest { + + @InjectMocks + private ChatImageWriter chatImageWriter; + + @Mock + private ChatImageRepository chatImageRepository; + + @Test + @DisplayName("์ฑ„ํŒ… ์ด๋ฏธ์ง€ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์ €์žฅ์— ์„ฑ๊ณตํ•œ๋‹ค") + void save_Success() { + // given + ChatRoom chatRoom = mock(ChatRoom.class); + String objectKey = "chat/room1/uuid.jpg"; + String fileName = "chat-image.jpg"; + Long expectedImageId = 100L; + + ChatImage savedImage = ChatImage.builder() + .id(expectedImageId) + .chatRoom(chatRoom) + .objectKey(objectKey) + .originalFileName(fileName) + .mimeType("jpg") + .imageStatus(ImageStatus.PENDING) + .build(); + + given(chatImageRepository.save(any(ChatImage.class))).willReturn(savedImage); + + // when + Long actualImageId = chatImageWriter.save(chatRoom, objectKey, fileName); + + // then + assertThat(actualImageId).isEqualTo(expectedImageId); + + ArgumentCaptor imageCaptor = ArgumentCaptor.forClass(ChatImage.class); + verify(chatImageRepository).save(imageCaptor.capture()); + + ChatImage capturedImage = imageCaptor.getValue(); + assertThat(capturedImage.getChatRoom()).isEqualTo(chatRoom); + assertThat(capturedImage.getObjectKey()).isEqualTo(objectKey); + assertThat(capturedImage.getOriginalFileName()).isEqualTo(fileName); + assertThat(capturedImage.getMimeType()).isEqualTo("jpg"); + assertThat(capturedImage.getImageStatus()).isEqualTo(ImageStatus.PENDING); + } +} 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..c4e12510 100644 --- a/src/test/java/com/studypals/domain/memberManage/restDocsTest/MemberControllerRestDocsTest.java +++ b/src/test/java/com/studypals/domain/memberManage/restDocsTest/MemberControllerRestDocsTest.java @@ -153,7 +153,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 +169,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("์‘๋‹ต ์ƒํƒœ"), 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..c15f5ed8 --- /dev/null +++ b/src/test/java/com/studypals/domain/memberManage/worker/MemberProfileImageManagerTest.java @@ -0,0 +1,41 @@ +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; + + private MemberProfileImageManager memberProfileImageManager; + + @BeforeEach + void setUp() { + FileProperties fileUploadProperties = new FileProperties(List.of("jpg", "png"), 600); + memberProfileImageManager = new MemberProfileImageManager(objectStorage, fileUploadProperties); + } + + @Test + @DisplayName("ํŒŒ์ผ ํƒ€์ž… ๋ฐ˜ํ™˜ ํ™•์ธ") + void getFileType() { + // when + ImageType type = memberProfileImageManager.getFileType(); + + // then + assertThat(type).isEqualTo(ImageType.PROFILE_IMAGE); + } +} diff --git a/src/test/java/com/studypals/domain/memberManage/worker/MemberProfileImageWriterTest.java b/src/test/java/com/studypals/domain/memberManage/worker/MemberProfileImageWriterTest.java new file mode 100644 index 00000000..a3443487 --- /dev/null +++ b/src/test/java/com/studypals/domain/memberManage/worker/MemberProfileImageWriterTest.java @@ -0,0 +1,71 @@ +package com.studypals.domain.memberManage.worker; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +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.file.entity.ImageStatus; + +@ExtendWith(MockitoExtension.class) +class MemberProfileImageWriterTest { + + @InjectMocks + private MemberProfileImageWriter memberProfileImageWriter; + + @Mock + private MemberProfileImageRepository memberProfileImageRepository; + + @Test + @DisplayName("ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์ €์žฅ์— ์„ฑ๊ณตํ•˜๊ณ , Member ๊ฐ์ฒด์— ์—ฐ๊ด€๊ด€๊ณ„๋ฅผ ์„ค์ •ํ•œ๋‹ค") + void save_Success() { + // given + Member member = Member.builder().id(1L).build(); + String objectKey = "profile/1/uuid.jpg"; + String fileName = "my-profile.jpg"; + Long expectedImageId = 99L; + + MemberProfileImage expectedImage = MemberProfileImage.builder() + .id(expectedImageId) + .member(member) + .objectKey(objectKey) + .originalFileName(fileName) + .mimeType("jpg") + .imageStatus(ImageStatus.PENDING) + .build(); + + given(memberProfileImageRepository.save(any(MemberProfileImage.class))).willReturn(expectedImage); + + // when + MemberProfileImage savedImage = memberProfileImageWriter.save(member, objectKey, fileName); + + // then + assertThat(savedImage.getMember()).isEqualTo(expectedImage.getMember()); + assertThat(savedImage.getObjectKey()).isEqualTo(expectedImage.getObjectKey()); + assertThat(savedImage.getOriginalFileName()).isEqualTo(expectedImage.getOriginalFileName()); + assertThat(savedImage.getMimeType()).isEqualTo(expectedImage.getMimeType()); + assertThat(savedImage.getImageStatus()).isEqualTo(expectedImage.getImageStatus()); + + // repository.save()์— ์ „๋‹ฌ๋œ ์ธ์ž ์บก์ฒ˜ ๋ฐ ๊ฒ€์ฆ + ArgumentCaptor imageCaptor = ArgumentCaptor.forClass(MemberProfileImage.class); + verify(memberProfileImageRepository).save(imageCaptor.capture()); + MemberProfileImage capturedImage = imageCaptor.getValue(); + + assertThat(capturedImage.getMember()).isEqualTo(member); + assertThat(capturedImage.getObjectKey()).isEqualTo(objectKey); + assertThat(capturedImage.getOriginalFileName()).isEqualTo(fileName); + assertThat(capturedImage.getMimeType()).isEqualTo("jpg"); + assertThat(capturedImage.getImageStatus()).isEqualTo(ImageStatus.PENDING); + } +} 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..7fa9434a 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); @@ -117,7 +114,7 @@ public ImageType getFileType() { } 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..546b943a 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,138 @@ 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); - } + // ํ…Œ์ŠคํŠธ๋ฅผ ์œ„ํ•œ ๊ตฌ์ฒด ํด๋ž˜์Šค + static class TestImageManager extends AbstractImageManager { + public TestImageManager(ObjectStorage objectStorage, FileProperties fileUploadProperties) { + super(objectStorage, fileUploadProperties); + } - @Test - @DisplayName("์—…๋กœ๋“œ URL ๋ฐœ๊ธ‰ ์„ฑ๊ณต - ํŒŒ์ผ ์ด๋ฆ„ ๊ฒ€์ฆ ํ†ต๊ณผ") - void getUploadUrl_success() { - // given - Long userId = 1L; - String fileName = "image.jpg"; - String targetId = "user1"; - String expectedUrl = "https://example.com/presigned-url"; + @Override + protected String generateObjectKeyDetail(String targetId, String ext) { + return "test-path/" + targetId + "/" + UUID.randomUUID() + "." + ext; + } - given(objectStorage.createPresignedPutUrl(anyString(), anyInt())).willReturn(expectedUrl); + @Override + protected List variants() { + return List.of(); + } - // when - String result = imageManager.getUploadUrl(userId, fileName, targetId); + @Override + public ImageType getFileType() { + return ImageType.PROFILE_IMAGE; + } - // then - assertThat(result).isEqualTo(expectedUrl); + @Override + public boolean usePresignedUrl() { + return false; + } } - @Test - @DisplayName("์—…๋กœ๋“œ URL ๋ฐœ๊ธ‰ ์‹คํŒจ - ๋Œ€๋ฌธ์ž ํ™•์žฅ์ž๋„ ํ—ˆ์šฉ") - void getUploadUrl_upperCase() { + @BeforeEach + void setUp() { // given - Long userId = 1L; - String fileName = "image.PNG"; - String targetId = "user1"; - String expectedUrl = "https://example.com/presigned-url"; + fileUploadProperties = new FileProperties(List.of("jpg", "jpeg", "png", "bmp", "webp"), 600); + imageManager = new TestImageManager(objectStorage, fileUploadProperties); + } - given(objectStorage.createPresignedPutUrl(anyString(), anyInt())).willReturn(expectedUrl); + @Nested + @DisplayName("createObjectKey ๋ฉ”์„œ๋“œ ํ…Œ์ŠคํŠธ") + class CreateObjectKeyTest { - // when - String result = imageManager.getUploadUrl(userId, fileName, targetId); + @Test + @DisplayName("์„ฑ๊ณต: ์œ ํšจํ•œ ์š”์ฒญ ์‹œ ObjectKey๋ฅผ ์ •์ƒ์ ์œผ๋กœ ์ƒ์„ฑํ•œ๋‹ค") + void should_CreateObjectKey_When_RequestIsValid() { + // given + Long userId = 1L; + String fileName = "image.jpg"; + String targetId = "user1"; - // then - assertThat(result).isEqualTo(expectedUrl); - } + // when + String objectKey = imageManager.createObjectKey(userId, fileName, targetId); - @Test - @DisplayName("์—…๋กœ๋“œ URL ๋ฐœ๊ธ‰ ์‹คํŒจ - ์ง€์›ํ•˜์ง€ ์•Š๋Š” ํ™•์žฅ์ž") - void getUploadUrl_invalidExtension() { - // given - Long userId = 1L; - String fileName = "document.txt"; - String targetId = "user1"; - - // when & then - assertThatThrownBy(() -> imageManager.getUploadUrl(userId, fileName, targetId)) - .isInstanceOf(RuntimeException.class); // FileException์ด RuntimeException์„ ์ƒ์†ํ•œ๋‹ค๊ณ  ๊ฐ€์ • - } + // then + assertThat(objectKey).contains("test-path/user1/"); + assertThat(objectKey).endsWith(".jpg"); + } - @Test - @DisplayName("์—…๋กœ๋“œ URL ๋ฐœ๊ธ‰ ์‹คํŒจ - ํ™•์žฅ์ž ์—†์Œ") - void getUploadUrl_noExtension() { - // given - Long userId = 1L; - String fileName = "image"; - String targetId = "user1"; + @Test + @DisplayName("์„ฑ๊ณต: ๋Œ€๋ฌธ์ž ํ™•์žฅ์ž๋„ ํ—ˆ์šฉํ•˜์—ฌ ObjectKey๋ฅผ ์ƒ์„ฑํ•œ๋‹ค") + void should_CreateObjectKey_When_ExtensionIsUpperCase() { + // given + Long userId = 1L; + String fileName = "image.PNG"; + String targetId = "user1"; - // when & then - assertThatThrownBy(() -> imageManager.getUploadUrl(userId, fileName, targetId)) - .isInstanceOf(RuntimeException.class); - } + // when + String objectKey = imageManager.createObjectKey(userId, fileName, targetId); - @Test - @DisplayName("์—…๋กœ๋“œ URL ๋ฐœ๊ธ‰ ์‹คํŒจ - null ํŒŒ์ผ ์ด๋ฆ„") - void getUploadUrl_null() { - // given - Long userId = 1L; - String fileName = null; - String targetId = "user1"; + // then + assertThat(objectKey).contains("test-path/user1/"); + assertThat(objectKey).endsWith(".png"); + } - // when & then - assertThatThrownBy(() -> imageManager.getUploadUrl(userId, fileName, targetId)) - .isInstanceOf(RuntimeException.class); - } + @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); + } - // ํ…Œ์ŠคํŠธ๋ฅผ ์œ„ํ•œ ๊ตฌ์ฒด ํด๋ž˜์Šค - static class TestImageManager extends AbstractImageManager { - public TestImageManager(ObjectStorage objectStorage) { - super(objectStorage); + @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); } - @Override - protected String generateObjectKeyDetail(String targetId, String ext) { - return "key"; + @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); } + } - @Override - public ImageType getFileType() { - return ImageType.PROFILE_IMAGE; + @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 index 2bb66620..cb86ae0f 100644 --- a/src/test/java/com/studypals/global/file/restDocsTest/ImageFileControllerRestDocsTest.java +++ b/src/test/java/com/studypals/global/file/restDocsTest/ImageFileControllerRestDocsTest.java @@ -7,6 +7,10 @@ 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.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.*; @@ -14,14 +18,14 @@ 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.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.dto.ImageUploadRes; import com.studypals.global.file.service.ImageFileService; import com.studypals.global.responses.CommonResponse; import com.studypals.global.responses.Response; @@ -34,24 +38,30 @@ class ImageFileControllerRestDocsTest extends RestDocsSupport { @MockitoBean private ImageFileService imageFileService; + private final MockMultipartFile mockMultipartFile = new MockMultipartFile( + "file", // ์ปจํŠธ๋กค๋Ÿฌ๊ฐ€ ๋ฐ›๋Š” ํŒŒ๋ผ๋ฏธํ„ฐ ๋ณ€์ˆ˜๋ช… (ํ•„์ˆ˜ ํ™•์ธ!) + "chat-image.png", // ์—…๋กœ๋“œํ•  ํŒŒ์ผ๋ช… + "image/png", // ํŒŒ์ผ ํƒ€์ž… + "fake-image-content".getBytes() // ํŒŒ์ผ ๋‚ด์šฉ (๋”๋ฏธ) + ); + @Test @WithMockUser - @DisplayName("ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ์šฉ Presigned URL ์š”์ฒญ") + @DisplayName("ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ ์„ฑ๊ณต") 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=..."; + Long imageId = 1L; + String imageUrl = "http://example.com/image.jpg"; - PresignedUrlRes response = new PresignedUrlRes(presignedUrl); - given(imageFileService.getProfileUploadUrl(any(ProfilePresignedUrlReq.class), any())) + ImageUploadRes response = new ImageUploadRes(imageId, imageUrl); + given(imageFileService.uploadProfileImage(any(MultipartFile.class), any())) .willReturn(response); - Response expected = CommonResponse.success(ResponseCode.FILE_IMAGE_UPLOAD, 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))); + ResultActions result = mockMvc.perform( + multipart("/files/image/profile").file(mockMultipartFile).contentType(MediaType.MULTIPART_FORM_DATA)); // then result.andExpect(status().isOk()) @@ -60,34 +70,34 @@ void getProfileUploadUrl_success() throws Exception { .andDo(restDocs.document( httpRequest(), httpResponse(), - requestFields(fieldWithPath("fileName") - .description("์—…๋กœ๋“œํ•  ํŒŒ์ผ ์ด๋ฆ„ (ํ™•์žฅ์ž ํฌํ•จ)") - .attributes(constraints("not null, not blank"))), + requestParts(partWithName("file").description("์—…๋กœ๋“œํ•  ์ด๋ฏธ์ง€ ํŒŒ์ผ (MultipartFile)")), responseFields( fieldWithPath("code").description("์‘๋‹ต ์ฝ”๋“œ (I01-01)"), fieldWithPath("status").description("์‘๋‹ต ์ƒํƒœ"), fieldWithPath("message").description("์‘๋‹ต ๋ฉ”์‹œ์ง€"), - fieldWithPath("data.url").description("์ƒ์„ฑ๋œ Presigned URL")))); + fieldWithPath("data.imageId").description("์ด๋ฏธ์ง€ ํŒŒ์ผ์˜ ์‹๋ณ„ ID"), + fieldWithPath("data.imageUrl").description("์ €์žฅ๋œ ์ด๋ฏธ์ง€ ์ฃผ์†Œ")))); } @Test @WithMockUser - @DisplayName("์ฑ„ํŒ… ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ์šฉ Presigned URL ์š”์ฒญ") + @DisplayName("์ฑ„ํŒ… ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ ์„ฑ๊ณต") 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); + Long imageId = 1L; + String imageUrl = "http://example.com/presigned-url-image.jpg"; + ImageUploadRes response = new ImageUploadRes(imageId, imageUrl); - given(imageFileService.getChatUploadUrl(any(ChatPresignedUrlReq.class), any())) + given(imageFileService.uploadChatImage(any(MultipartFile.class), any(), any())) .willReturn(response); - Response expected = CommonResponse.success(ResponseCode.FILE_IMAGE_UPLOAD, 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))); + ResultActions result = mockMvc.perform(multipart("/files/image/chat") + .file(mockMultipartFile) + .queryParam("chatRoomId", "chatRoomId-123-456") + .contentType(MediaType.MULTIPART_FORM_DATA)); // then result.andExpect(status().isOk()) @@ -96,17 +106,13 @@ void getChatUploadUrl_success() throws Exception { .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"))), + queryParameters(parameterWithName("chatRoomId").description("์ฑ„ํŒ…๋ฐฉ ์‹๋ณ„์ž ID")), + requestParts(partWithName("file").description("์—…๋กœ๋“œํ•  ์ด๋ฏธ์ง€ ํŒŒ์ผ (MultipartFile)")), responseFields( fieldWithPath("code").description("์‘๋‹ต ์ฝ”๋“œ (I01-01)"), fieldWithPath("status").description("์‘๋‹ต ์ƒํƒœ"), fieldWithPath("message").description("์‘๋‹ต ๋ฉ”์‹œ์ง€"), - fieldWithPath("data.url").description("์ƒ์„ฑ๋œ Presigned URL")))); + fieldWithPath("data.imageId").description("์ด๋ฏธ์ง€ ํŒŒ์ผ์˜ ์‹๋ณ„ ID"), + fieldWithPath("data.imageUrl").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..0a08fa52 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,212 @@ package com.studypals.global.file.service; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; import java.util.List; +import org.junit.jupiter.api.AfterEach; 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.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.web.multipart.MultipartFile; + +import com.studypals.domain.chatManage.entity.ChatRoom; +import com.studypals.domain.chatManage.worker.ChatImageManager; +import com.studypals.domain.chatManage.worker.ChatImageWriter; +import com.studypals.domain.chatManage.worker.ChatRoomReader; +import com.studypals.domain.memberManage.entity.Member; +import com.studypals.domain.memberManage.entity.MemberProfileImage; +import com.studypals.domain.memberManage.worker.MemberProfileImageManager; +import com.studypals.domain.memberManage.worker.MemberProfileImageWriter; +import com.studypals.domain.memberManage.worker.MemberReader; 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.ImageUploadDto; +import com.studypals.global.file.dto.ImageUploadRes; +import com.studypals.global.file.entity.ImageStatus; import com.studypals.global.file.entity.ImageType; @ExtendWith(MockitoExtension.class) class ImageFileServiceImplTest { - // Service under test private ImageFileService imageFileService; @Mock - private AbstractImageManager mockProfileImageManager; + private MemberProfileImageManager profileImageManager; + + @Mock + private ChatImageManager chatImageManager; + + @Mock + private MemberReader memberReader; + + @Mock + private ChatRoomReader chatRoomReader; + + @Mock + private MemberProfileImageWriter profileImageWriter; + + @Mock + private ChatImageWriter chatImageWriter; @Mock - private AbstractImageManager mockChatImageManager; + private MultipartFile multipartFile; @BeforeEach void setUp() { - when(mockProfileImageManager.getFileType()).thenReturn(ImageType.PROFILE_IMAGE); - when(mockChatImageManager.getFileType()).thenReturn(ImageType.CHAT_IMAGE); + TransactionSynchronizationManager.initSynchronization(); + given(profileImageManager.getFileType()).willReturn(ImageType.PROFILE_IMAGE); + given(chatImageManager.getFileType()).willReturn(ImageType.CHAT_IMAGE); + + imageFileService = new ImageFileServiceImpl( + List.of(profileImageManager, chatImageManager), + memberReader, + chatRoomReader, + profileImageWriter, + chatImageWriter); + } - imageFileService = new ImageFileServiceImpl(List.of(mockProfileImageManager, mockChatImageManager)); + @AfterEach + void tearDown() { + TransactionSynchronizationManager.clear(); } @Test - @DisplayName("getProfileUploadUrl ํ˜ธ์ถœ ์‹œ ProfileImageManager์˜ getUploadUrl์„ ํ˜ธ์ถœํ•ด์•ผ ํ•œ๋‹ค") - void getProfileUploadUrl_shouldCallCorrectManager() { + @DisplayName("ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ - ์„ฑ๊ณต (๊ธฐ์กด ํ”„๋กœํ•„ ์—†์Œ)") + void uploadProfileImage_Success_NoExistingProfile() { // given Long userId = 1L; - ProfilePresignedUrlReq request = new ProfilePresignedUrlReq("profile.jpg"); - String expectedUrl = "http://s3.com/profile-upload-url"; + String originalFilename = "test.jpg"; + String objectKey = "profile/1/uuid.jpg"; + String imageUrl = "http://test.com/profile/1/uuid.jpg"; + Member member = Member.builder().id(1L).build(); + + Long expectedImageId = 99L; + MemberProfileImage expectedImage = MemberProfileImage.builder() + .id(expectedImageId) + .member(member) + .objectKey(objectKey) + .originalFileName(originalFilename) + .mimeType("jpg") + .imageStatus(ImageStatus.PENDING) + .build(); + + given(multipartFile.getOriginalFilename()).willReturn(originalFilename); - when(mockProfileImageManager.getUploadUrl(userId, "profile.jpg", "1")).thenReturn(expectedUrl); + ImageUploadDto uploadDto = new ImageUploadDto(objectKey, imageUrl); + given(profileImageManager.upload(multipartFile, userId)).willReturn(uploadDto); + + given(memberReader.get(userId)).willReturn(member); + + given(profileImageWriter.save(eq(member), eq(objectKey), eq(originalFilename))) + .willReturn(expectedImage); // 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.imageId()).isEqualTo(expectedImageId); + assertThat(res.imageUrl()).isEqualTo(imageUrl); + + verify(profileImageManager).upload(multipartFile, userId); + verify(profileImageWriter).save(eq(member), eq(objectKey), eq(originalFilename)); + verify(profileImageManager, never()).delete(anyString()); } @Test - @DisplayName("getChatUploadUrl ํ˜ธ์ถœ ์‹œ ChatImageManager์˜ getUploadUrl์„ ํ˜ธ์ถœํ•ด์•ผ ํ•œ๋‹ค") - void getChatUploadUrl_shouldCallCorrectManager() { + @DisplayName("ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ - ์„ฑ๊ณต (๊ธฐ์กด ํ”„๋กœํ•„ ์กด์žฌ -> ์—…๋ฐ์ดํŠธ ๋ฐ ๊ธฐ์กด ํŒŒ์ผ ์‚ญ์ œ)") + void uploadProfileImage_Success_ExistingProfile() { // given Long userId = 1L; - ChatPresignedUrlReq request = new ChatPresignedUrlReq("chat-image.png", "chat-room-123"); - String expectedUrl = "http://s3.com/chat-upload-url"; + String originalFilename = "new.jpg"; + String newObjectKey = "profile/1/new.jpg"; + String newImageUrl = "http://test.com/profile/1/new.jpg"; + String oldObjectKey = "profile/1/old.jpg"; - when(mockChatImageManager.getUploadUrl(userId, "chat-image.png", "chat-room-123")) - .thenReturn(expectedUrl); + given(multipartFile.getOriginalFilename()).willReturn(originalFilename); + + ImageUploadDto uploadDto = new ImageUploadDto(newObjectKey, newImageUrl); + given(profileImageManager.upload(multipartFile, userId)).willReturn(uploadDto); + + Member member = mock(Member.class); + MemberProfileImage existingProfile = mock(MemberProfileImage.class); + + given(memberReader.get(userId)).willReturn(member); + given(member.getProfileImage()).willReturn(existingProfile); + given(existingProfile.getObjectKey()).willReturn(oldObjectKey); // when - PresignedUrlRes actualResult = imageFileService.getChatUploadUrl(request, userId); + ImageUploadRes res = imageFileService.uploadProfileImage(multipartFile, userId); + + // ํŠธ๋žœ์žญ์…˜ ์ปค๋ฐ‹ ํ›„ ๋™์ž‘(ํŒŒ์ผ ์‚ญ์ œ)์„ ๊ฒ€์ฆํ•˜๊ธฐ ์œ„ํ•ด ์ˆ˜๋™์œผ๋กœ ํŠธ๋ฆฌ๊ฑฐ + TransactionSynchronizationManager.getSynchronizations().forEach(TransactionSynchronization::afterCommit); // 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.imageUrl()).isEqualTo(newImageUrl); + + verify(existingProfile).update(eq(newObjectKey), eq(originalFilename), anyString()); + verify(profileImageManager).delete(oldObjectKey); + } + + @Test + @DisplayName("์ฑ„ํŒ…๋ฐฉ ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ - ์„ฑ๊ณต") + void uploadChatImage_Success() { + // given + Long userId = 1L; + String chatRoomId = "room1"; + String originalFilename = "chat.jpg"; + String objectKey = "chat/room1/uuid.jpg"; + String imageUrl = "http://test.com/chat/room1/uuid.jpg"; + + given(multipartFile.getOriginalFilename()).willReturn(originalFilename); + + ImageUploadDto uploadDto = new ImageUploadDto(objectKey, imageUrl); + given(chatImageManager.upload(multipartFile, userId, chatRoomId)).willReturn(uploadDto); + + ChatRoom chatRoom = mock(ChatRoom.class); + given(chatRoomReader.getById(chatRoomId)).willReturn(chatRoom); + + Long savedImageId = 20L; + given(chatImageWriter.save(eq(chatRoom), eq(objectKey), eq(originalFilename))) + .willReturn(savedImageId); + + // when + ImageUploadRes res = imageFileService.uploadChatImage(multipartFile, chatRoomId, userId); + + // then + assertThat(res.imageId()).isEqualTo(savedImageId); + assertThat(res.imageUrl()).isEqualTo(imageUrl); + + verify(chatImageManager).upload(multipartFile, userId, chatRoomId); + verify(chatImageWriter).save(eq(chatRoom), eq(objectKey), eq(originalFilename)); + } + + @Test + @DisplayName("์ƒ์„ฑ์ž - ์ค‘๋ณต FileType ๋“ฑ๋ก ์‹œ ์˜ˆ์™ธ ๋ฐœ์ƒ") + void constructor_DuplicateFileType() { + // given + AbstractImageManager manager1 = mock(AbstractImageManager.class); + AbstractImageManager manager2 = mock(AbstractImageManager.class); + + given(manager1.getFileType()).willReturn(ImageType.PROFILE_IMAGE); + given(manager2.getFileType()).willReturn(ImageType.PROFILE_IMAGE); // ์ค‘๋ณต ํƒ€์ž… + + List managers = List.of(manager1, manager2); + + // when & then + assertThatThrownBy(() -> new ImageFileServiceImpl( + managers, memberReader, chatRoomReader, profileImageWriter, chatImageWriter)) + .isInstanceOf(IllegalStateException.class); } } 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; + } + } }