Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
46ffd35
Feat: image 사이즈 정의
sleepyhoon Jan 16, 2026
6154b5e
Refactor: extractExtension 유틸 메서드로 분리
sleepyhoon Jan 16, 2026
c1b3fd8
Refactor: @Value 어노테이션 대신 @ConfigurationProperties 사용
sleepyhoon Jan 16, 2026
b891d4b
Feat: 이미지 공통 필드를 담는 ImageFile 구현
sleepyhoon Jan 16, 2026
c31d155
Feat: 이미지 상태 enum 추가
sleepyhoon Jan 16, 2026
b2d13a1
Refactor: 디렉토리 이동
sleepyhoon Jan 16, 2026
d488ade
Feat: 채팅 이미지 엔티티 구현
sleepyhoon Jan 16, 2026
9e121f4
Feat: 유저 프로필 엔티티 구현
sleepyhoon Jan 16, 2026
30bdc32
Fix: 반환값에 이미지 id 추가
sleepyhoon Jan 16, 2026
76a078e
Fix: 서비스 로직 수정
sleepyhoon Jan 16, 2026
3891ea4
Test: Test 수정
sleepyhoon Jan 16, 2026
8790f85
Fix: 원본 이미지는 origin 경로에 저장되도록 수정
sleepyhoon Jan 16, 2026
419e4f6
Feat: index 적용
sleepyhoon Jan 17, 2026
24b6493
Docs: 주석 추가
sleepyhoon Jan 17, 2026
14e679e
Docs: 문서화 보강
sleepyhoon Jan 17, 2026
90c92c4
Fix: 업로드 시 presigned url 사용하지 않고 File 직접 업로드로 변경
sleepyhoon Jan 20, 2026
cc32e41
Fix: 사진 업로드 반영한 추상화 완료
sleepyhoon Jan 21, 2026
64a3da2
Fix: 프로필 사진 업로드 로직 완성
sleepyhoon Jan 23, 2026
12fe24c
Fix: 프로필 수정 로직 변경
sleepyhoon Jan 27, 2026
5b04a29
Fix: objectKey 반환 시 domain + bucket 이름 추가 로직 구현
sleepyhoon Jan 27, 2026
4d775dc
Test: Test 수정
sleepyhoon Jan 27, 2026
0722fb5
Docs: 문서화 내용 수정
sleepyhoon Jan 27, 2026
b37518b
Docs: 주석 보강
sleepyhoon Jan 27, 2026
f8615bf
Fix: copilot PR 반영
sleepyhoon Jan 27, 2026
643f05f
Fix: 문서 수정
sleepyhoon Jan 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 29 additions & 30 deletions src/asciidoc/api/file.adoc
Original file line number Diff line number Diff line change
@@ -1,69 +1,68 @@
= 👥 file API
= 📁 File API
:doctype: book
:icons: font
:source-highlighter: highlightjs
:toc: left
: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[]
Original file line number Diff line number Diff line change
@@ -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<ChatImage, Long> {}
Original file line number Diff line number Diff line change
Expand Up @@ -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 객체와 최신 메시지 조회 결과를 기반으로
Expand All @@ -35,7 +40,7 @@ public interface ChatRoomMapper {
* @param latestInfos 채팅방별 최신 메시지 및 언리드 정보
* @return ChatRoomListRes.ChatRoomInfo 변환 결과
*/
default ChatRoomListRes.ChatRoomInfo toChatRoomInfo(
public ChatRoomListRes.ChatRoomInfo toChatRoomInfo(
ChatRoomMember chatRoomMember, Map<String, ChatroomLatestInfo> latestInfos) {
String chatRoomId = chatRoomMember.getChatRoom().getId();
ChatroomLatestInfo info = latestInfos.get(chatRoomId);
Expand Down
Original file line number Diff line number Diff line change
@@ -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) 내에서 전송된 이미지의 메타데이터를 관리하는 엔티티입니다.
* <p>
* 이 엔티티는 {@link ImageFile}을 상속받아 이미지 파일의 공통 속성을 관리하며,
* {@link ChatRoom}과 다대일(Many-to-One) 관계를 맺습니다.
* 채팅 이미지는 한 번 생성되면 수정되지 않는 불변(Immutable)의 특성을 가집니다.
*
* <p><b>주요 특징:</b>
* <ul>
* <li><b>상속 관계:</b> {@link ImageFile}의 모든 속성을 상속받습니다.</li>
* <li><b>연관 관계:</b> 여러 개의 채팅 이미지가 하나의 {@link ChatRoom}에 속합니다.</li>
* <li><b>불변성:</b> 생성 후 상태가 변경되지 않습니다. (수정 기능 없음)</li>
* <li><b>인덱싱:</b> 이미지 처리 상태({@code imageStatus})와 생성일({@code createdAt})에 복합 인덱스가 설정되어 있어,
* 리사이징 등 비동기 처리 대상 조회 시 성능을 향상시킵니다.</li>
* </ul>
*
* @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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
* 채팅방 진입 시 필요한 정보를 조회하는 서비스 구현 클래스입니다.
Expand Down Expand Up @@ -53,6 +54,8 @@ public class ChatRoomServiceImpl implements ChatRoomService {
private final ChatMessageReader chatMessageReader;
private final MemberReader memberReader;

private final ObjectStorage objectStorage;

/**
* 특정 유저가 특정 채팅방에 입장할 때 필요한 전체 정보를 조회합니다.
* <p>
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/**
* 파일 중 채팅 이미지를 처리하는데 사용하는 구체 클래스입니다.
Expand All @@ -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;
}

Expand All @@ -51,6 +56,11 @@ protected String generateObjectKeyDetail(String chatRoomId, String ext) {
return CHAT_IMAGE_PATH + "/" + chatRoomId + "/" + UUID.randomUUID() + "." + ext;
}

@Override
protected List<ImageVariantKey> variants() {
return List.of(ImageVariantKey.SMALL, ImageVariantKey.MEDIUM, ImageVariantKey.LARGE);
}

/**
* 이 클래스는 채팅 이미지를 처리합니다.
* @return 처리하는 이미지 종류
Expand All @@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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 클래스입니다.
* <p>
* 이 클래스는 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;

/**
* 채팅 이미지의 메타데이터를 생성하고 데이터베이스에 저장합니다.
* <p>
* 이 메서드는 클라이언트가 서버를 통해 이미지를 업로드할 때 호출되며,
* 서버가 파일을 처리하고 스토리지에 저장하는 흐름 속에서 해당 파일의 메타데이터를 데이터베이스에 기록합니다.
*
* @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();
}
}
Loading