Skip to content

Commit a703a80

Browse files
committed
feat: 파일 생성 및 수정(덮어쓰기) 기능 추가
1 parent 0ad86d1 commit a703a80

File tree

8 files changed

+219
-92
lines changed

8 files changed

+219
-92
lines changed
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package com.dmu.debug_visual.file_upload;
2+
3+
4+
import com.dmu.debug_visual.file_upload.entity.CodeFile;
5+
import com.dmu.debug_visual.user.User;
6+
import org.springframework.data.jpa.repository.JpaRepository;
7+
8+
import java.util.List;
9+
import java.util.Optional;
10+
11+
public interface CodeFileRepository extends JpaRepository<CodeFile, Long> {
12+
Optional<CodeFile> findByFileUUID(String fileUUID);
13+
List<CodeFile> findByUser(User user); // '/api/files/my' 기능을 위해
14+
}
Lines changed: 35 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
package com.dmu.debug_visual.file_upload;
22

3-
import com.dmu.debug_visual.file_upload.service.S3Uploader;
3+
import com.dmu.debug_visual.file_upload.dto.FileResponseDTO;
4+
import com.dmu.debug_visual.file_upload.dto.UserFileDTO;
5+
import com.dmu.debug_visual.file_upload.service.FileService;
46
import com.dmu.debug_visual.security.CustomUserDetails;
57
import io.swagger.v3.oas.annotations.Operation;
6-
import io.swagger.v3.oas.annotations.media.ArraySchema;
8+
import io.swagger.v3.oas.annotations.Parameter;
79
import io.swagger.v3.oas.annotations.media.Content;
810
import io.swagger.v3.oas.annotations.media.Schema;
911
import io.swagger.v3.oas.annotations.responses.ApiResponse;
1012
import io.swagger.v3.oas.annotations.responses.ApiResponses;
1113
import io.swagger.v3.oas.annotations.tags.Tag;
1214
import lombok.RequiredArgsConstructor;
13-
import org.springframework.http.HttpStatus;
1415
import org.springframework.http.MediaType;
1516
import org.springframework.http.ResponseEntity;
1617
import org.springframework.security.core.annotation.AuthenticationPrincipal;
@@ -20,55 +21,55 @@
2021
import java.io.IOException;
2122
import java.util.List;
2223

23-
@Tag(name = "파일 관리 API", description = "S3 파일 업로드 및 사용자별 파일 목록 조회를 제공하는 컨트롤러")
24+
@Tag(name = "파일 관리 API", description = "S3 파일 생성, 수정(덮어쓰기) 및 사용자별 파일 목록 조회를 제공합니다.")
2425
@RestController
2526
@RequiredArgsConstructor
26-
@RequestMapping("/api/files")
27+
@RequestMapping("/api/file") // 경로를 단수로 통일
2728
public class FileController {
2829

29-
private final S3Uploader s3Uploader;
30+
private final FileService fileService;
3031

31-
@Operation(summary = "파일 업로드", description = "form-data 형식으로 단일 파일을 AWS S3에 업로드하고, 저장된 파일의 URL을 반환합니다. JWT 인증이 필요합니다.")
32+
@Operation(summary = "파일 저장 또는 수정 (덮어쓰기)",
33+
description = "form-data로 파일을 업로드합니다. `fileUUID` 파라미터 유무에 따라 동작이 달라집니다.\n\n" +
34+
"- **`fileUUID`가 없으면**: 신규 파일로 저장하고 새로운 `fileUUID`를 발급합니다.\n" +
35+
"- **`fileUUID`가 있으면**: 해당 `fileUUID`를 가진 기존 파일을 덮어씁니다.")
3236
@ApiResponses({
33-
@ApiResponse(responseCode = "200", description = "업로드 성공",
34-
content = @Content(mediaType = "text/plain", schema = @Schema(type = "string", example = "https://your-bucket.s3.amazonaws.com/.../file.txt"))),
35-
@ApiResponse(responseCode = "401", description = "인증 실패 (JWT 토큰 누락 또는 유효하지 않음)",
36-
content = @Content),
37-
@ApiResponse(responseCode = "500", description = "서버 내부 오류 (파일 업로드 실패)",
38-
content = @Content)
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)
3942
})
4043
@PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
41-
public ResponseEntity<String> uploadFile(
44+
public ResponseEntity<FileResponseDTO> uploadOrUpdateFile(
45+
@Parameter(description = "업로드할 파일")
4246
@RequestParam("file") MultipartFile file,
43-
@AuthenticationPrincipal CustomUserDetails userDetails) {
4447

45-
String userId = userDetails.getUsername();
48+
@Parameter(description = "수정할 파일의 고유 ID. 신규 업로드 시에는 생략합니다.")
49+
@RequestParam(value = "fileUUID", required = false) String fileUUID,
50+
51+
@AuthenticationPrincipal CustomUserDetails userDetails) throws IOException {
4652

47-
try {
48-
String fileUrl = s3Uploader.upload(file, userId + "-codes");
49-
return ResponseEntity.ok(fileUrl);
50-
} catch (IOException e) {
51-
e.printStackTrace();
52-
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("파일 업로드에 실패했습니다.");
53-
}
53+
// CustomUserDetails에서 사용자 ID (Long 타입)를 가져오는 메소드가 있다고 가정합니다.
54+
// 예: userDetails.getId()
55+
String currentUserId = userDetails.getUsername();
56+
57+
FileResponseDTO fileResponse = fileService.saveOrUpdateFile(fileUUID, file, currentUserId);
58+
return ResponseEntity.ok(fileResponse);
5459
}
5560

56-
@Operation(summary = "사용자 파일 목록 조회", description = "특정 사용자가 업로드한 모든 파일의 목록을 조회합니다. 본인의 파일 목록만 조회할 수 있습니다.")
61+
@Operation(summary = " 파일 목록 조회", description = "현재 로그인한 사용자가 생성한 모든 파일의 목록을 조회합니다.")
5762
@ApiResponses({
5863
@ApiResponse(responseCode = "200", description = "조회 성공",
59-
content = @Content(mediaType = "application/json", array = @ArraySchema(schema = @Schema(implementation = S3FileDTO.class)))),
60-
@ApiResponse(responseCode = "401", description = "인증 실패 (JWT 토큰 누락 또는 유효하지 않음)",
61-
content = @Content),
62-
@ApiResponse(responseCode = "403", description = "접근 권한 없음 (다른 사용자의 파일 목록에 접근 시도)",
63-
content = @Content)
64+
content = @Content(mediaType = "application/json", schema = @Schema(implementation = UserFileDTO.class))),
65+
@ApiResponse(responseCode = "401", description = "인증 실패 (JWT 토큰 누락 또는 유효하지 않음)", content = @Content)
6466
})
6567
@GetMapping("/my")
66-
public ResponseEntity<List<S3FileDTO>> listFilesForUser(
68+
public ResponseEntity<List<UserFileDTO>> getMyFiles(
6769
@AuthenticationPrincipal CustomUserDetails userDetails) {
6870

69-
String userId = userDetails.getUsername();
70-
71-
List<S3FileDTO> files = s3Uploader.listFiles(userId);
72-
return ResponseEntity.ok(files);
71+
String currentUserId = userDetails.getUsername();
72+
List<UserFileDTO> myFiles = fileService.getUserFiles(currentUserId);
73+
return ResponseEntity.ok(myFiles);
7374
}
7475
}
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.AllArgsConstructor;
4+
import lombok.Getter;
5+
6+
@Getter
7+
@AllArgsConstructor
8+
public class FileResponseDTO {
9+
private String fileUUID;
10+
private String fileUrl;
11+
}

src/main/java/com/dmu/debug_visual/file_upload/S3FileDTO.java renamed to src/main/java/com/dmu/debug_visual/file_upload/dto/S3FileDTO.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package com.dmu.debug_visual.file_upload;
1+
package com.dmu.debug_visual.file_upload.dto;
22

33
import lombok.Builder;
44
import lombok.Getter;
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package com.dmu.debug_visual.file_upload.dto;
2+
3+
import lombok.Builder;
4+
import lombok.Getter;
5+
6+
//import java.time.LocalDateTime; // 필요 시 추가
7+
8+
@Getter
9+
@Builder
10+
public class UserFileDTO {
11+
private String fileUUID;
12+
private String originalFileName;
13+
// 필요에 따라 파일 URL, 생성일자 등 추가 가능
14+
// private String fileUrl;
15+
// private LocalDateTime createdAt;
16+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package com.dmu.debug_visual.file_upload.entity;
2+
3+
import com.dmu.debug_visual.user.User;
4+
import jakarta.persistence.*;
5+
import lombok.AccessLevel;
6+
import lombok.Builder;
7+
import lombok.Getter;
8+
import lombok.NoArgsConstructor;
9+
import java.util.UUID;
10+
11+
@Entity
12+
@Getter
13+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
14+
public class CodeFile {
15+
16+
@Id
17+
@GeneratedValue(strategy = GenerationType.IDENTITY)
18+
private Long id;
19+
20+
@Column(nullable = false, unique = true)
21+
private String fileUUID; // 프론트와 통신할 때 사용할 고유 ID
22+
23+
@Column(nullable = false)
24+
private String originalFileName;
25+
26+
@Column(nullable = false)
27+
private String s3FilePath; // S3에 저장된 실제 경로
28+
29+
@ManyToOne(fetch = FetchType.LAZY)
30+
@JoinColumn(name = "user_id", nullable = false)
31+
private User user;
32+
33+
@Builder
34+
public CodeFile(String originalFileName, String s3FilePath, User user) {
35+
this.fileUUID = UUID.randomUUID().toString();
36+
this.originalFileName = originalFileName;
37+
this.s3FilePath = s3FilePath;
38+
this.user = user;
39+
}
40+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package com.dmu.debug_visual.file_upload.service;
2+
3+
import com.dmu.debug_visual.file_upload.CodeFileRepository;
4+
import com.dmu.debug_visual.file_upload.dto.FileResponseDTO;
5+
import com.dmu.debug_visual.file_upload.dto.UserFileDTO;
6+
import com.dmu.debug_visual.file_upload.entity.CodeFile;
7+
import com.dmu.debug_visual.user.User;
8+
import com.dmu.debug_visual.user.UserRepository;
9+
import jakarta.persistence.EntityNotFoundException;
10+
import lombok.RequiredArgsConstructor;
11+
import org.springframework.stereotype.Service;
12+
import org.springframework.transaction.annotation.Transactional;
13+
import org.springframework.web.multipart.MultipartFile;
14+
15+
import java.io.IOException;
16+
import java.util.List;
17+
import java.util.UUID;
18+
import java.util.stream.Collectors;
19+
20+
@Service
21+
@RequiredArgsConstructor
22+
public class FileService {
23+
24+
private final S3Uploader s3Uploader; // 역할이 단순화된 S3Uploader 주입
25+
private final CodeFileRepository codeFileRepository;
26+
private final UserRepository userRepository;
27+
28+
@Transactional
29+
public FileResponseDTO saveOrUpdateFile(String fileUUID, MultipartFile file, String userId) throws IOException {
30+
31+
// 1. 요청 보낸 사용자의 엔티티를 조회합니다.
32+
User currentUser = userRepository.findByUserId(userId)
33+
.orElseThrow(() -> new EntityNotFoundException("User not found with id: " + userId));
34+
35+
// 2. fileUUID의 존재 여부로 '최초 저장'과 '수정'을 구분합니다.
36+
if (fileUUID == null || fileUUID.isBlank()) {
37+
38+
// 💡 최초 저장 로직
39+
String originalFileName = file.getOriginalFilename();
40+
// S3에 저장될 고유한 경로 생성 (사용자별로 폴더를 분리하면 관리하기 좋습니다)
41+
String s3FilePath = "user-codes/" + currentUser.getUserId() + "/" + UUID.randomUUID().toString() + "_" + originalFileName;
42+
43+
// S3Uploader를 통해 파일을 S3에 업로드합니다.
44+
String fileUrl = s3Uploader.upload(file, s3FilePath);
45+
46+
// 파일 메타데이터를 DB(CodeFile 테이블)에 저장합니다.
47+
CodeFile newCodeFile = CodeFile.builder()
48+
.originalFileName(originalFileName)
49+
.s3FilePath(s3FilePath)
50+
.user(currentUser)
51+
.build();
52+
codeFileRepository.save(newCodeFile);
53+
54+
// 프론트엔드에 새로 생성된 fileUUID와 파일 URL을 반환합니다.
55+
return new FileResponseDTO(newCodeFile.getFileUUID(), fileUrl);
56+
57+
} 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에 "기존과 동일한 경로"를 전달하여 파일을 덮어쓰게 합니다.
69+
String fileUrl = s3Uploader.upload(file, existingCodeFile.getS3FilePath());
70+
71+
// DB 정보는 그대로 유지합니다. (수정 시간이 필요하다면 엔티티에 필드 추가 후 갱신)
72+
73+
// 프론트엔드에 기존 fileUUID와 갱신된 파일 URL을 반환합니다.
74+
return new FileResponseDTO(existingCodeFile.getFileUUID(), fileUrl);
75+
}
76+
}
77+
78+
@Transactional(readOnly = true)
79+
public List<UserFileDTO> getUserFiles(String userId) {
80+
User user = userRepository.findByUserId(userId)
81+
.orElseThrow(() -> new EntityNotFoundException("User not found with id: " + userId));
82+
83+
List<CodeFile> userCodeFiles = codeFileRepository.findByUser(user);
84+
85+
return userCodeFiles.stream()
86+
.map(codeFile -> UserFileDTO.builder()
87+
.fileUUID(codeFile.getFileUUID())
88+
.originalFileName(codeFile.getOriginalFileName())
89+
.build())
90+
.collect(Collectors.toList());
91+
}
92+
}
Lines changed: 10 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,14 @@
11
package com.dmu.debug_visual.file_upload.service;
22

3-
import com.dmu.debug_visual.file_upload.S3FileDTO;
43
import lombok.RequiredArgsConstructor;
54
import org.springframework.beans.factory.annotation.Value;
65
import org.springframework.stereotype.Service;
76
import org.springframework.web.multipart.MultipartFile;
87
import software.amazon.awssdk.core.sync.RequestBody;
98
import software.amazon.awssdk.services.s3.S3Client;
10-
import software.amazon.awssdk.services.s3.model.ListObjectsV2Response;
119
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
12-
import software.amazon.awssdk.services.s3.model.S3Object;
1310

14-
import software.amazon.awssdk.services.s3.model.ListObjectsV2Request;
15-
16-
import java.time.LocalDateTime;
17-
import java.time.ZoneId;
18-
import java.util.List;
19-
import java.util.stream.Collectors;
2011
import java.io.IOException;
21-
import java.util.UUID;
22-
2312

2413
@Service
2514
@RequiredArgsConstructor
@@ -30,59 +19,23 @@ public class S3Uploader {
3019
@Value("${spring.cloud.aws.s3.bucket}")
3120
private String bucket;
3221

33-
public String upload(MultipartFile file, String dirName) throws IOException {
34-
// 1. 고유한 파일 이름 생성
35-
String originalFilename = file.getOriginalFilename();
36-
String uniqueFileName = dirName + "/" + UUID.randomUUID().toString() + "_" + originalFilename;
37-
38-
// 2. S3에 업로드할 요청 객체 생성
22+
/**
23+
* S3에 파일을 업로드(또는 덮어쓰기)하고 URL을 반환합니다.
24+
* 이제 이 메소드는 파일 경로를 직접 만들지 않고, 파라미터로 받습니다.
25+
*/
26+
public String upload(MultipartFile file, String s3FilePath) throws IOException {
27+
// 1. S3에 업로드할 요청 객체 생성 (전달받은 s3FilePath를 key로 사용)
3928
PutObjectRequest putObjectRequest = PutObjectRequest.builder()
4029
.bucket(bucket)
41-
.key(uniqueFileName)
30+
.key(s3FilePath) // UUID로 새로 만드는 대신, 전달받은 경로를 그대로 사용
4231
.contentType(file.getContentType())
4332
.contentLength(file.getSize())
4433
.build();
4534

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

49-
// 4. 업로드된 파일의 URL 반환
50-
return s3Client.utilities().getUrl(builder -> builder.bucket(bucket).key(uniqueFileName)).toString();
51-
}
52-
53-
/**
54-
* 특정 사용자의 모든 파일 목록을 S3에서 조회합니다.
55-
* @param userId 조회할 사용자의 ID
56-
* @return 파일 정보 DTO 리스트
57-
*/
58-
public List<S3FileDTO> listFiles(String userId) {
59-
// 1. 조회할 폴더(prefix)를 지정합니다. (예: "test-codes/")
60-
String prefix = userId + "-codes/";
61-
62-
// 2. S3 객체 목록을 요청하기 위한 객체를 생성합니다.
63-
ListObjectsV2Request listObjectsV2Request = ListObjectsV2Request.builder()
64-
.bucket(bucket)
65-
.prefix(prefix)
66-
.build();
67-
68-
// 3. S3 클라이언트로 객체 목록을 조회합니다.
69-
ListObjectsV2Response response = s3Client.listObjectsV2(listObjectsV2Request);
70-
List<S3Object> s3Objects = response.contents();
71-
72-
// 4. 조회된 S3Object 목록을 우리가 만든 S3FileDTO 리스트로 변환합니다.
73-
return s3Objects.stream()
74-
.map(s3Object -> {
75-
String fullKey = s3Object.key();
76-
// "test-codes/UUID_원본파일이름" 에서 "원본파일이름"만 추출
77-
String originalFileName = fullKey.substring(fullKey.indexOf('_') + 1);
78-
79-
return S3FileDTO.builder()
80-
.fileName(originalFileName)
81-
.fileUrl(s3Client.utilities().getUrl(builder -> builder.bucket(bucket).key(fullKey)).toString())
82-
.uploadDate(LocalDateTime.ofInstant(s3Object.lastModified(), ZoneId.systemDefault()))
83-
.fileSize(s3Object.size())
84-
.build();
85-
})
86-
.collect(Collectors.toList());
38+
// 3. 업로드된 파일의 URL 반환
39+
return s3Client.utilities().getUrl(builder -> builder.bucket(bucket).key(s3FilePath)).toString();
8740
}
8841
}

0 commit comments

Comments
 (0)