Skip to content

Commit 94f4b5f

Browse files
authored
Merge pull request #44 from DMU-DebugVisual/feat/s3-integration
feat: 파일 삭제 및 내용 조회 기능 추가(#43)
2 parents 5cfeb78 + f7f7dc3 commit 94f4b5f

File tree

4 files changed

+208
-55
lines changed

4 files changed

+208
-55
lines changed
Lines changed: 56 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.dmu.debug_visual.file_upload;
22

3+
import com.dmu.debug_visual.file_upload.dto.FileContentResponse;
34
import com.dmu.debug_visual.file_upload.dto.FileResponseDTO;
45
import com.dmu.debug_visual.file_upload.dto.UserFileDTO;
56
import com.dmu.debug_visual.file_upload.service.FileService;
@@ -21,55 +22,87 @@
2122
import java.io.IOException;
2223
import java.util.List;
2324

24-
@Tag(name = "파일 관리 API", description = "S3 파일 생성, 수정(덮어쓰기) 및 사용자별 파일 목록 조회를 제공합니다.")
25+
@Tag(name = "파일 관리 API", description = "S3 파일 생성, 수정, 조회 및 삭제 기능을 제공합니다.")
2526
@RestController
2627
@RequiredArgsConstructor
27-
@RequestMapping("/api/file") // 경로를 단수로 통일
28+
@RequestMapping("/api/file")
2829
public class FileController {
2930

3031
private final FileService fileService;
3132

33+
// =================================================================================
34+
// == 1. 파일 생성 및 수정 (Create & Update)
35+
// =================================================================================
36+
3237
@Operation(summary = "파일 저장 또는 수정 (덮어쓰기)",
3338
description = "form-data로 파일을 업로드합니다. `fileUUID` 파라미터 유무에 따라 동작이 달라집니다.\n\n" +
3439
"- **`fileUUID`가 없으면**: 신규 파일로 저장하고 새로운 `fileUUID`를 발급합니다.\n" +
3540
"- **`fileUUID`가 있으면**: 해당 `fileUUID`를 가진 기존 파일을 덮어씁니다.")
3641
@ApiResponses({
37-
@ApiResponse(responseCode = "200", description = "요청 성공 (생성 또는 수정 완료)",
38-
content = @Content(mediaType = "application/json", schema = @Schema(implementation = FileResponseDTO.class))),
39-
@ApiResponse(responseCode = "401", description = "인증 실패 (JWT 토큰 누락 또는 유효하지 않음)", content = @Content),
40-
@ApiResponse(responseCode = "403", description = "권한 없음 (타인의 파일 수정을 시도)", content = @Content),
41-
@ApiResponse(responseCode = "404", description = "존재하지 않는 `fileUUID`로 수정 요청", content = @Content)
42+
@ApiResponse(responseCode = "200", description = "요청 성공", content = @Content(mediaType = "application/json", schema = @Schema(implementation = FileResponseDTO.class))),
43+
@ApiResponse(responseCode = "401", description = "인증 실패"),
44+
@ApiResponse(responseCode = "403", description = "권한 없음"),
45+
@ApiResponse(responseCode = "404", description = "존재하지 않는 `fileUUID`로 수정 요청")
4246
})
4347
@PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
4448
public ResponseEntity<FileResponseDTO> uploadOrUpdateFile(
45-
@Parameter(description = "업로드할 파일")
46-
@RequestParam("file") MultipartFile file,
47-
48-
@Parameter(description = "수정할 파일의 고유 ID. 신규 업로드 시에는 생략합니다.")
49-
@RequestParam(value = "fileUUID", required = false) String fileUUID,
50-
49+
@Parameter(description = "업로드할 파일") @RequestParam("file") MultipartFile file,
50+
@Parameter(description = "수정할 파일의 고유 ID. 신규 업로드 시에는 생략합니다.") @RequestParam(value = "fileUUID", required = false) String fileUUID,
5151
@AuthenticationPrincipal CustomUserDetails userDetails) throws IOException {
5252

53-
// CustomUserDetails에서 사용자 ID (Long 타입)를 가져오는 메소드가 있다고 가정합니다.
54-
// 예: userDetails.getId()
5553
String currentUserId = userDetails.getUsername();
56-
5754
FileResponseDTO fileResponse = fileService.saveOrUpdateFile(fileUUID, file, currentUserId);
5855
return ResponseEntity.ok(fileResponse);
5956
}
6057

58+
// =================================================================================
59+
// == 2. 파일 조회 (Read)
60+
// =================================================================================
61+
6162
@Operation(summary = "내 파일 목록 조회", description = "현재 로그인한 사용자가 생성한 모든 파일의 목록을 조회합니다.")
6263
@ApiResponses({
63-
@ApiResponse(responseCode = "200", description = "조회 성공",
64-
content = @Content(mediaType = "application/json", schema = @Schema(implementation = UserFileDTO.class))),
65-
@ApiResponse(responseCode = "401", description = "인증 실패 (JWT 토큰 누락 또는 유효하지 않음)", content = @Content)
64+
@ApiResponse(responseCode = "200", description = "조회 성공", content = @Content(mediaType = "application/json", schema = @Schema(implementation = UserFileDTO.class))),
65+
@ApiResponse(responseCode = "401", description = "인증 실패")
6666
})
6767
@GetMapping("/my")
68-
public ResponseEntity<List<UserFileDTO>> getMyFiles(
69-
@AuthenticationPrincipal CustomUserDetails userDetails) {
70-
68+
public ResponseEntity<List<UserFileDTO>> getMyFiles(@AuthenticationPrincipal CustomUserDetails userDetails) {
7169
String currentUserId = userDetails.getUsername();
7270
List<UserFileDTO> myFiles = fileService.getUserFiles(currentUserId);
7371
return ResponseEntity.ok(myFiles);
7472
}
75-
}
73+
74+
@Operation(summary = "파일 내용 조회", description = "특정 파일의 내용을 조회합니다. 본인의 파일만 조회할 수 있습니다.")
75+
@ApiResponses({
76+
@ApiResponse(responseCode = "200", description = "조회 성공", content = @Content(schema = @Schema(implementation = FileContentResponse.class))),
77+
@ApiResponse(responseCode = "401", description = "인증 실패"),
78+
@ApiResponse(responseCode = "403", description = "접근 권한 없음"),
79+
@ApiResponse(responseCode = "404", description = "파일을 찾을 수 없음")
80+
})
81+
@GetMapping("/{fileUUID}/content")
82+
public ResponseEntity<FileContentResponse> getFileContent(
83+
@Parameter(description = "내용을 조회할 파일의 고유 ID") @PathVariable String fileUUID,
84+
@AuthenticationPrincipal CustomUserDetails userDetails) {
85+
FileContentResponse response = fileService.getFileContent(fileUUID, userDetails.getUsername());
86+
return ResponseEntity.ok(response);
87+
}
88+
89+
// =================================================================================
90+
// == 3. 파일 삭제 (Delete)
91+
// =================================================================================
92+
93+
@Operation(summary = "파일 삭제", description = "특정 파일을 S3와 DB에서 모두 삭제합니다. 본인의 파일만 삭제할 수 있습니다.")
94+
@ApiResponses({
95+
@ApiResponse(responseCode = "200", description = "삭제 성공"),
96+
@ApiResponse(responseCode = "401", description = "인증 실패"),
97+
@ApiResponse(responseCode = "403", description = "삭제 권한 없음"),
98+
@ApiResponse(responseCode = "404", description = "파일을 찾을 수 없음")
99+
})
100+
@DeleteMapping("/{fileUUID}")
101+
public ResponseEntity<Void> deleteFile(
102+
@Parameter(description = "삭제할 파일의 고유 ID") @PathVariable String fileUUID,
103+
@AuthenticationPrincipal CustomUserDetails userDetails) {
104+
fileService.deleteFile(fileUUID, userDetails.getUsername());
105+
return ResponseEntity.ok().build();
106+
}
107+
}
108+
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package com.dmu.debug_visual.file_upload.dto;
2+
3+
import lombok.Builder;
4+
import lombok.Getter;
5+
6+
@Getter
7+
@Builder
8+
public class FileContentResponse {
9+
private String originalFileName;
10+
private String content;
11+
}
Lines changed: 86 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.dmu.debug_visual.file_upload.service;
22

33
import com.dmu.debug_visual.file_upload.CodeFileRepository;
4+
import com.dmu.debug_visual.file_upload.dto.FileContentResponse;
45
import com.dmu.debug_visual.file_upload.dto.FileResponseDTO;
56
import com.dmu.debug_visual.file_upload.dto.UserFileDTO;
67
import com.dmu.debug_visual.file_upload.entity.CodeFile;
@@ -17,68 +18,72 @@
1718
import java.util.UUID;
1819
import java.util.stream.Collectors;
1920

21+
/**
22+
* 파일 관련 비즈니스 로직을 처리하는 서비스 클래스.
23+
* S3Uploader와 DB(CodeFileRepository)를 함께 사용하여 파일의 생성, 수정, 조회, 삭제를 관리합니다.
24+
*/
2025
@Service
2126
@RequiredArgsConstructor
2227
public class FileService {
2328

24-
private final S3Uploader s3Uploader; // 역할이 단순화된 S3Uploader 주입
29+
private final S3Uploader s3Uploader;
2530
private final CodeFileRepository codeFileRepository;
2631
private final UserRepository userRepository;
2732

33+
// =================================================================================
34+
// == 1. 파일 생성 및 수정 (Create & Update)
35+
// =================================================================================
36+
37+
/**
38+
* 파일을 새로 저장하거나 기존 파일을 덮어씁니다.
39+
* @param fileUUID 수정할 파일의 ID (신규 저장 시 null)
40+
* @param file 업로드된 파일 데이터
41+
* @param userId 요청을 보낸 사용자의 ID
42+
* @return 생성 또는 수정된 파일의 정보
43+
*/
2844
@Transactional
2945
public FileResponseDTO saveOrUpdateFile(String fileUUID, MultipartFile file, String userId) throws IOException {
30-
31-
// 1. 요청 보낸 사용자의 엔티티를 조회합니다.
3246
User currentUser = userRepository.findByUserId(userId)
33-
.orElseThrow(() -> new EntityNotFoundException("User not found with id: " + userId));
47+
.orElseThrow(() -> new EntityNotFoundException("User not found: " + userId));
3448

35-
// 2. fileUUID의 존재 여부로 '최초 저장'과 '수정'을 구분합니다.
3649
if (fileUUID == null || fileUUID.isBlank()) {
37-
38-
// 💡 최초 저장 로직
50+
// --- 신규 파일 생성 ---
3951
String originalFileName = file.getOriginalFilename();
40-
// S3에 저장될 고유한 경로 생성 (사용자별로 폴더를 분리하면 관리하기 좋습니다)
4152
String s3FilePath = "user-codes/" + currentUser.getUserId() + "/" + UUID.randomUUID().toString() + "_" + originalFileName;
4253

43-
// S3Uploader를 통해 파일을 S3에 업로드합니다.
4454
String fileUrl = s3Uploader.upload(file, s3FilePath);
4555

46-
// 파일 메타데이터를 DB(CodeFile 테이블)에 저장합니다.
4756
CodeFile newCodeFile = CodeFile.builder()
4857
.originalFileName(originalFileName)
4958
.s3FilePath(s3FilePath)
5059
.user(currentUser)
5160
.build();
5261
codeFileRepository.save(newCodeFile);
5362

54-
// 프론트엔드에 새로 생성된 fileUUID와 파일 URL을 반환합니다.
5563
return new FileResponseDTO(newCodeFile.getFileUUID(), fileUrl);
5664

5765
} else {
58-
59-
// 💡 수정(덮어쓰기) 로직
60-
CodeFile existingCodeFile = codeFileRepository.findByFileUUID(fileUUID)
61-
.orElseThrow(() -> new EntityNotFoundException("File not found with UUID: " + fileUUID));
62-
63-
// (보안) 파일을 수정하려는 사용자가 실제 소유자인지 확인합니다.
64-
if (!existingCodeFile.getUser().getUserId().equals(currentUser.getUserId())) {
65-
throw new IllegalStateException("You do not have permission to modify this file.");
66-
}
67-
68-
// S3Uploader에 "기존과 동일한 경로"를 전달하여 파일을 덮어쓰게 합니다.
66+
// --- 기존 파일 수정 ---
67+
CodeFile existingCodeFile = findAndVerifyOwner(fileUUID, userId);
6968
String fileUrl = s3Uploader.upload(file, existingCodeFile.getS3FilePath());
7069

71-
// DB 정보는 그대로 유지합니다. (수정 시간이 필요하다면 엔티티에 필드 추가 후 갱신)
72-
73-
// 프론트엔드에 기존 fileUUID와 갱신된 파일 URL을 반환합니다.
7470
return new FileResponseDTO(existingCodeFile.getFileUUID(), fileUrl);
7571
}
7672
}
7773

74+
// =================================================================================
75+
// == 2. 파일 조회 (Read)
76+
// =================================================================================
77+
78+
/**
79+
* 특정 사용자가 소유한 모든 파일의 목록을 조회합니다.
80+
* @param userId 조회할 사용자의 ID
81+
* @return 파일 목록 DTO 리스트
82+
*/
7883
@Transactional(readOnly = true)
7984
public List<UserFileDTO> getUserFiles(String userId) {
8085
User user = userRepository.findByUserId(userId)
81-
.orElseThrow(() -> new EntityNotFoundException("User not found with id: " + userId));
86+
.orElseThrow(() -> new EntityNotFoundException("User not found: " + userId));
8287

8388
List<CodeFile> userCodeFiles = codeFileRepository.findByUser(user);
8489

@@ -89,4 +94,59 @@ public List<UserFileDTO> getUserFiles(String userId) {
8994
.build())
9095
.collect(Collectors.toList());
9196
}
97+
98+
/**
99+
* 특정 파일의 내용을 조회합니다.
100+
* @param fileUUID 조회할 파일의 ID
101+
* @param userId 요청을 보낸 사용자의 ID
102+
* @return 파일의 원본 이름과 내용이 담긴 DTO
103+
*/
104+
@Transactional(readOnly = true)
105+
public FileContentResponse getFileContent(String fileUUID, String userId) {
106+
CodeFile codeFile = findAndVerifyOwner(fileUUID, userId);
107+
String content = s3Uploader.getFileContent(codeFile.getS3FilePath());
108+
109+
return FileContentResponse.builder()
110+
.originalFileName(codeFile.getOriginalFileName())
111+
.content(content)
112+
.build();
113+
}
114+
115+
// =================================================================================
116+
// == 3. 파일 삭제 (Delete)
117+
// =================================================================================
118+
119+
/**
120+
* 특정 파일을 S3와 DB에서 모두 삭제합니다.
121+
* @param fileUUID 삭제할 파일의 ID
122+
* @param userId 요청을 보낸 사용자의 ID
123+
*/
124+
@Transactional
125+
public void deleteFile(String fileUUID, String userId) {
126+
CodeFile codeFile = findAndVerifyOwner(fileUUID, userId);
127+
128+
s3Uploader.deleteFile(codeFile.getS3FilePath());
129+
codeFileRepository.delete(codeFile);
130+
}
131+
132+
// =================================================================================
133+
// == Private Helper Methods
134+
// =================================================================================
135+
136+
/**
137+
* 파일 ID로 파일을 조회하고, 요청한 사용자가 파일의 소유주인지 검증하는 private 헬퍼 메소드
138+
* @param fileUUID 조회할 파일의 ID
139+
* @param userId 요청을 보낸 사용자의 ID
140+
* @return 검증된 CodeFile 엔티티
141+
*/
142+
private CodeFile findAndVerifyOwner(String fileUUID, String userId) {
143+
CodeFile codeFile = codeFileRepository.findByFileUUID(fileUUID)
144+
.orElseThrow(() -> new EntityNotFoundException("File not found with UUID: " + fileUUID));
145+
146+
if (!codeFile.getUser().getUserId().equals(userId)) {
147+
throw new IllegalStateException("You do not have permission to access this file.");
148+
}
149+
return codeFile;
150+
}
92151
}
152+

src/main/java/com/dmu/debug_visual/file_upload/service/S3Uploader.java

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,21 @@
44
import org.springframework.beans.factory.annotation.Value;
55
import org.springframework.stereotype.Service;
66
import org.springframework.web.multipart.MultipartFile;
7+
import software.amazon.awssdk.core.ResponseBytes;
78
import software.amazon.awssdk.core.sync.RequestBody;
89
import software.amazon.awssdk.services.s3.S3Client;
10+
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
11+
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
12+
import software.amazon.awssdk.services.s3.model.GetObjectResponse;
913
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
1014

1115
import java.io.IOException;
16+
import java.nio.charset.StandardCharsets;
1217

18+
/**
19+
* AWS S3와의 직접적인 통신(업로드, 조회, 삭제)을 담당하는 서비스 클래스.
20+
* 이 클래스는 비즈니스 로직을 포함하지 않고, 순수하게 S3 작업만 처리합니다.
21+
*/
1322
@Service
1423
@RequiredArgsConstructor
1524
public class S3Uploader {
@@ -19,23 +28,63 @@ public class S3Uploader {
1928
@Value("${spring.cloud.aws.s3.bucket}")
2029
private String bucket;
2130

31+
// =================================================================================
32+
// == 1. 파일 생성 및 수정 (Create & Update)
33+
// =================================================================================
34+
2235
/**
2336
* S3에 파일을 업로드(또는 덮어쓰기)하고 URL을 반환합니다.
24-
* 이제 이 메소드는 파일 경로를 직접 만들지 않고, 파라미터로 받습니다.
37+
* @param file 업로드할 파일
38+
* @param s3FilePath S3에 저장될 경로 (key)
39+
* @return 업로드된 파일의 S3 URL
2540
*/
2641
public String upload(MultipartFile file, String s3FilePath) throws IOException {
27-
// 1. S3에 업로드할 요청 객체 생성 (전달받은 s3FilePath를 key로 사용)
2842
PutObjectRequest putObjectRequest = PutObjectRequest.builder()
2943
.bucket(bucket)
30-
.key(s3FilePath) // UUID로 새로 만드는 대신, 전달받은 경로를 그대로 사용
44+
.key(s3FilePath)
3145
.contentType(file.getContentType())
3246
.contentLength(file.getSize())
3347
.build();
3448

35-
// 2. 파일의 InputStream을 RequestBody로 만들어 S3에 업로드
3649
s3Client.putObject(putObjectRequest, RequestBody.fromInputStream(file.getInputStream(), file.getSize()));
3750

38-
// 3. 업로드된 파일의 URL 반환
3951
return s3Client.utilities().getUrl(builder -> builder.bucket(bucket).key(s3FilePath)).toString();
4052
}
41-
}
53+
54+
// =================================================================================
55+
// == 2. 파일 조회 (Read)
56+
// =================================================================================
57+
58+
/**
59+
* S3에서 특정 파일의 내용을 문자열로 읽어옵니다.
60+
* @param s3FilePath 조회할 파일의 S3 경로 (key)
61+
* @return 파일 내용 (UTF-8 문자열)
62+
*/
63+
public String getFileContent(String s3FilePath) {
64+
GetObjectRequest getObjectRequest = GetObjectRequest.builder()
65+
.bucket(bucket)
66+
.key(s3FilePath)
67+
.build();
68+
69+
ResponseBytes<GetObjectResponse> objectBytes = s3Client.getObjectAsBytes(getObjectRequest);
70+
return objectBytes.asString(StandardCharsets.UTF_8);
71+
}
72+
73+
// =================================================================================
74+
// == 3. 파일 삭제 (Delete)
75+
// =================================================================================
76+
77+
/**
78+
* S3에서 특정 파일을 삭제합니다.
79+
* @param s3FilePath 삭제할 파일의 S3 경로 (key)
80+
*/
81+
public void deleteFile(String s3FilePath) {
82+
DeleteObjectRequest deleteObjectRequest = DeleteObjectRequest.builder()
83+
.bucket(bucket)
84+
.key(s3FilePath)
85+
.build();
86+
87+
s3Client.deleteObject(deleteObjectRequest);
88+
}
89+
}
90+

0 commit comments

Comments
 (0)