@Entity
@Getter
@Setter
@NoArgsConstructor
public class File extends Timestamped {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id; // 파일 ID
@Column(name = "scheduleId", nullable = false)
private Long scheduleId; // 일정 ID
@Column(name = "fileName", nullable = false)
private String fileName; // 원본 파일명
@Column(name = "saveName", nullable = false)
private String saveName; // 저장되는 파일명
@Column(name = "size", nullable = false)
private long size; // 파일의 크기
@Column(name = "isDeleted")
private int isDeleted ; // 삭제 여부
public File(FileRequestDto fileRequestDto) {
this.scheduleId = fileRequestDto.getScheduleId();
this.fileName = fileRequestDto.getFileName();
this.saveName = fileRequestDto.getSaveName();
this.size = fileRequestDto.getSize();
}
}
/**
* Entity 작성일, 수정일 자동생성 클래스
*/
@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public class Timestamped {
@CreatedDate
@Column(updatable = false)
@Temporal(TemporalType.TIMESTAMP)
private LocalDateTime createdAt;
@LastModifiedDate
@Column
@Temporal(TemporalType.TIMESTAMP)
private LocalDateTime modifiedAt;
}
// 스프링부트 애플리케이션에 해당 애노테이션을 지정해야 Timestamped를 사용할 수 있다.
@EnableJpaAuditing
@SpringBootApplication
public class NbcampSpringPersonalTaskApplication {
RequestDto
@Getter
@Setter // 파일은 일정이 생성된 후에 처리되어야 해서 scheduleId setter를 위해 지정
public class FileRequestDto {
private Long scheduleId; // 일정 번호
private String fileName; // 파일 이름
private String saveName; // 저장할 파일 이름
private Long size; // 파일 크기
@Builder
public FileRequestDto(String fileName, String saveName, Long size) {
this.fileName = fileName;
this.saveName = saveName;
this.size = size;
}
}
ResponseDto
@Getter
public class FileResponseDto {
private Long id; // 파일 번호
private Long scheduleId; // 일정 번호
private String fileName; // 파일 이름
private String saveName; // 저장할 파일 이름
private Long size; // 파일 크기
private LocalDateTime createdAt; // 생성일
private LocalDateTime modifiedAt; // 수정일
public FileResponseDto(File file) {
this.id = file.getId();
this.scheduleId = file.getScheduleId();
this.fileName = file.getFileName();
this.saveName = file.getSaveName();
this.size = file.getSize();
this.createdAt = file.getCreatedAt();
this.modifiedAt = file.getModifiedAt();
}
}
public interface FileRepository extends JpaRepository<File, Long> {
// 일정 ID에 따른 모든 파일 정보 찾기
List<File> findAllByScheduleId(Long scheduleId);
// 일정 ID에 따른 모든 파일 정보 삭제
void deleteAllByScheduleId(Long scheduleId);
}
@Service
@RequiredArgsConstructor
public class FileService {
private final FileRepository fileRepository;
/**
* 파일 정보 저장 메서드
* @param scheduleId 일정 ID
* @param files 파일 목록
*/
public void savaFiles(Long scheduleId, List<FileRequestDto> files) {
// 파일 목록이 비었는지 확인
if (CollectionUtils.isEmpty(files)) {
return;
}
// 파일 객체에 일정 ID를 주입해준다.
for (FileRequestDto file : files) {
file.setScheduleId(scheduleId);
}
List<File> fileList = files.stream().map(File::new).toList();
fileRepository.saveAll(fileList);
}
/**
* 일정 첨부파일 목록 조회 메서드
* @param scheduleId 일정 ID
* @return 파일 목록
*/
public List<FileResponseDto> findAllFilesByScheduleId(Long scheduleId) {
return fileRepository.findAllByScheduleId(scheduleId).stream().map(FileResponseDto::new).toList();
}
/**
* 파일 객체 반환 메서드
* @param id 파일 ID
* @return 파일
*/
public FileResponseDto findFileById(Long id) {
File file = fileRepository.findById(id).orElseThrow(()-> new ScheduleException(ErrorCode.FILE_NOT_FOUND));
return new FileResponseDto(file);
}
/**
* 파일 정보 삭제 메서드
* @param id 파일 ID
*/
public void deleteAllByScheduleId(Long id) {
fileRepository.deleteAllByScheduleId(id);
}
}
일정 Service (일정 삭제 시 파일도 함께 삭제)
/**
* 일정,파일 삭제 메서드
* @param id 일정 ID
* @param requestDto 일정 정보
* @return 일정 ID
*/
@Transactional
public Long deleteSchedule(Long id, ScheduleRequestDto requestDto) {
Schedule schedule = findScheduleById(id);
schedule.validatePassword(requestDto);
scheduleRepository.delete(schedule);
List<FileResponseDto> files = fileService.findAllFilesByScheduleId(id); // 삭제할 파일 정보 조회
fileUtils.deleteFiles(files); // 디스크에서 파일 삭제
fileService.deleteAllByScheduleId(id); // DB에서 파일 정보 삭제
return schedule.getId();
}
package com.sparta.nbcampspringpersonaltask.utils;
import com.sparta.nbcampspringpersonaltask.dto.FileRequestDto;
import com.sparta.nbcampspringpersonaltask.dto.FileResponseDto;
import com.sparta.nbcampspringpersonaltask.enumType.ImgFileType;
import com.sparta.nbcampspringpersonaltask.enumType.ErrorCode;
import com.sparta.nbcampspringpersonaltask.exception.ScheduleException;
import lombok.extern.slf4j.Slf4j;
import org.apache.tika.Tika;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.MalformedURLException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
@Component
@Slf4j
public class FileUtils {
// 파일 변조 체크
private final Tika tika = new Tika();
// 저장될 디스크 경로
private final String uploadPath = Paths.get("/Users","yudonghyeon","Desktop","내일배움캠프","개인과제","upload-files").toString();
/**
* 다중 파일 업로드 메서드
* @param multipartFiles 파일 목록
* @return DB에 저장할 파일 정보 목록
*/
public List<FileRequestDto> uploadFiles(List<MultipartFile> multipartFiles) {
List<FileRequestDto> files = new ArrayList<>();
for (MultipartFile multipartFile : multipartFiles) {
if (multipartFile.isEmpty()) {
continue;
}
files.add(uploadFile(multipartFile));
}
return files;
}
/**
* 단일 파일 업로드 메서드
* @param multipartFile 파일
* @return DB에 저장할 파일 정보
*/
private FileRequestDto uploadFile(MultipartFile multipartFile) {
// 파일 유무 체크
if (multipartFile.isEmpty()) {
return null;
}
// 이미지 파일인지 확인
validImgFile(multipartFile);
// 저장할 파일명
String saveName = generateSaveFilename(multipartFile.getOriginalFilename());
// 저장할 폴더명
String today = LocalDate.now().format(DateTimeFormatter.ofPattern("yyMMdd"));
// 저장될 경로
String uploadPath = getUploadPath(today) + File.separator + saveName;
File uploadFile = new File(uploadPath);
try {
// 디스크에 파일 생성
multipartFile.transferTo(uploadFile);
} catch (IOException e) {
throw new RuntimeException(e);
}
return FileRequestDto.builder()
.fileName(multipartFile.getOriginalFilename())
.saveName(saveName)
.size(multipartFile.getSize())
.build();
}
/**
* 저장할 파일명 생성 메서드
* @param filename 원본 파일명
* @return 디스크에 저장할 파일명
*/
private String generateSaveFilename(final String filename) {
// 32자리의 랜덤 문자열
String uuid = UUID.randomUUID().toString().replaceAll("-", "");
// 업로드할 파일의 확장자
String extension = StringUtils.getFilenameExtension(filename);
return uuid + "." + extension;
}
/**
* 업로드 경로 반환 메서드
* @param addPath 추가 경로
* @return 업로드 경로
*/
private String getUploadPath(String addPath) {
return makeDirectories(uploadPath + File.separator + addPath);
}
/**
* 업로드 폴더 생성 메서드
* @param path 업로드 경로
* @return 업로드 경로
*/
private String makeDirectories(String path) {
File dir = new File(path);
if (dir.exists() == false) {
dir.mkdirs();
}
return dir.getPath();
}
/**
* 파일 변조 체크 메서드
* @param file 파일
* @return boolean
*/
private void validImgFile(MultipartFile file) {
try {
InputStream inputStream = file.getInputStream();
// mime type(확장자)을 체크
// 파일의 확장자를 변경하여도 mime type을 체크해준다.
String mimeType = tika.detect(inputStream);
// enum 클래스를 통해 업로드 가능한 확장자인지 체크(예외 발생)
ImgFileType.getImgFileType(mimeType);
}catch (IOException e){ // getInputStream() 예외 처리
log.error(e.getMessage());
}
}
/**
* 다운로드할 첨부파일 리소스 조회
* @param file 첨부파일 상세정보
* @return 첨부파일 리소스
*/
public Resource readFileAsResource(FileResponseDto file) {
String uploadDate = file.getCreatedAt().toLocalDate().format(DateTimeFormatter.ofPattern("yyMMdd"));
String fileName = file.getSaveName();
Path filePath = Paths.get(uploadPath,uploadDate, fileName);
System.out.println(filePath);
try {
Resource resource = new UrlResource(filePath.toUri());
if (!resource.exists() || !resource.isFile()) {
throw new ScheduleException(ErrorCode.FILE_NOT_FOUND);
}
return resource;
} catch (MalformedURLException e) {
throw new ScheduleException(ErrorCode.FILE_NOT_FOUND);
}
}
/**
* 다중 파일 삭제 (from Disk)
* @param files 삭제할 파일 정보 목록
*/
public void deleteFiles(List<FileResponseDto> files) {
if (CollectionUtils.isEmpty(files)) {
return;
}
for (FileResponseDto file : files) {
String uploadDate = file.getCreatedAt().toLocalDate().format(DateTimeFormatter.ofPattern("yyMMdd"));
deleteFile(uploadDate, file.getSaveName());
}
}
/**
* 단일 파일 삭제 (from Disk)
* @param uploadDate 추가 경로
* @param fileName 저장한 파일명
*/
private void deleteFile(String uploadDate, String fileName) {
String filePath = Paths.get(uploadPath,uploadDate,fileName).toString();
File file = new File(filePath);
if (file.exists()) {
file.delete();
}
}
}
일정 컨트롤러 (파일 업로드)
@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class ScheduleController {
private final ScheduleService scheduleService;
private final FileService fileService;
private final FileUtils fileUtils;
/**
* 일정 등록하는 메서드
* @param requestDto 일정 요청 DTO(제목, 담당자, 내용, 비밀번호, 첨부파일)
* @return 일정 응답 DTO
*/
@PostMapping("/schedules")
public ScheduleResponseDto createSchedule(@Valid ScheduleRequestDto requestDto) {
ScheduleResponseDto responseDto = scheduleService.createSchedule(requestDto);
// 파일 업로드
List<FileRequestDto> files = fileUtils.uploadFiles(requestDto.getFiles());
// 파일 정보 DB에 저장
fileService.savaFiles(responseDto.getId(), files);
return responseDto;
}
}
파일 컨트롤러
package com.sparta.nbcampspringpersonaltask.controller;
import com.sparta.nbcampspringpersonaltask.dto.FileResponseDto;
import com.sparta.nbcampspringpersonaltask.service.FileService;
import com.sparta.nbcampspringpersonaltask.utils.FileUtils;
import lombok.RequiredArgsConstructor;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.util.List;
@RestController
@RequestMapping("/api/files")
@RequiredArgsConstructor
public class FileController {
private final FileService fileService;
private final FileUtils fileUtils;
/**
* 파일 목록 조회 메서드
* @param ScheduleId 일정 ID
* @return 파일 목록
*/
@GetMapping
public List<FileResponseDto> getAllFiles(Long ScheduleId) {
return fileService.findAllFilesByScheduleId(ScheduleId);
}
/**
* 파일 다운로드 메서드
* @param id 파일 ID
* @return 파일 다운로드
*/
@GetMapping("/download/{id}")
public ResponseEntity<Resource> downloadFile(@PathVariable Long id){
// 파일 상세 정보
FileResponseDto file = fileService.findFileById(id);
// 파일 리소스
Resource resource = fileUtils.readFileAsResource(file);
try {
String filename = URLEncoder.encode(file.getFileName(), "UTF-8");
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; fileName=\"" + filename + "\";")
.header(HttpHeaders.CONTENT_LENGTH, file.getSize()+"")
.body(resource);
} catch (UnsupportedEncodingException e) {
throw new RuntimeException("filename encoding failed : " + file.getFileName());
}
}
}
// 일정 조회 HTML
function addHTML(id, title, content, writer, modifiedAt) {
// 1. HTML 태그를 만듭니다.
let tempHtml = `<div class="card">
<!-- date/username 영역 -->
<div class="metadata">
<div id="${id}-title" class="title">
${title}
</div>
<input type="text" value="${title}" id="${id}-editTitle" style="display: none">
</div>
<div class="metadata">
<div id="${id}-writer" class="writer">
<span>담당자 : </span>${writer}
</div>
<input type="text" value="${writer}" id="${id}-editWriter" style="display: none">
<div class="date">
<span>수정 : </span>${modifiedAt}
</div>
</div>
<!-- contents 조회/수정 영역-->
<div class="contents">
<div id="${id}-contents" class="text">
${content}
</div>
<div id="${id}-editarea" class="edit">
<textarea id="${id}-textarea" class="te-edit" name="" id="" cols="30" rows="5"></textarea>
</div>
</div>
<input type="text" id="${id}-editPassword" style="display: none" placeholder="비밀번호를 입력하세요.">
<!-- 첨부파일 영역 -->
<button id="${id}-fileBtn"token interpolation">${id}')">첨부파일 보기</button>
<div id="${id}-files-box">
</div>
<!-- 버튼 영역-->
<div class="footer">
<img id="${id}-edit" class="icon-start-edit" src="images/edit.png" alt=""token interpolation">${id}')">
<img id="${id}-delete" class="icon-delete" src="images/delete.png" alt=""token interpolation">${id}')">
<img id="${id}-submit" class="icon-end-edit" src="images/done.png" alt=""token interpolation">${id}')">
<img id="${id}-submitDelete" class="icon-end-edit" src="images/done.png" alt=""token interpolation">${id}')">
</div>
</div>`;
// 2. #cards-box 에 HTML을 붙인다.
$('#cards-box').append(tempHtml);
}
// 일정 등록
function writeSchedule() {
let title = $('#title').val();
let writer = $('#writer').val();
let password = $('#password').val();
let content = $('#content').val();
// formData 생성
let formData = new FormData();
formData.append("title",title);
formData.append("writer",writer);
formData.append("password",password);
formData.append("content",content);
let fileCount = $("input[name='files']").length;
if (fileCount > 0) {
for (var i=0; i<fileCount; i++) {
if ($("input[name='files']")[i].files[0] != null) {
formData.append("files", $("input[name='files']")[i].files[0]);
}
}
}
$.ajax({
type: "POST",
url: "/api/schedules",
contentType: false, // formData 사용 시 꼭 필요
processData: false, // formData 사용 시 꼭 필요
data: formData,
success: function (response) {
alert('일정이 성공적으로 작성되었습니다.');
window.location.reload();
},
error: err => {
alert(err.responseJSON.message);
}
});
}
// 파일 선택
function selectFile(element) {
const file = element.files[0];
const filename = element.closest('.file_input').firstElementChild;
// 1. 파일 선택 창에서 취소 버튼이 클릭된 경우
if ( !file ) {
filename.value = '';
return false;
}
// 2. 파일 크기가 10MB를 초과하는 경우
const fileSize = Math.floor(file.size / 1024 / 1024);
if (fileSize > 10) {
alert('10MB 이하의 파일로 업로드해 주세요.');
filename.value = '';
element.value = '';
return false;
}
// 3. 파일명 지정
filename.value = file.name;
}
let fileCnt = 1;
let totalCnt = 10;
// 파일 추가
function addFile() {
if (fileCnt >= totalCnt) {
alert("10개 이하의 파일만 업로드할 수 있습니다.")
}else {
const fileDiv = document.createElement('div');
fileDiv.innerHTML =`
<div class="file_input">
<input type="text" readonly />
<label> 첨부파일
<input type="file" name="files" />
</label>
</div>
<button type="button" class="btns del_btn"><span>삭제</span></button>
`;
document.querySelector('.file_list').appendChild(fileDiv);
fileCnt++;
}
}
// 파일 삭제
function removeFile(element) {
const fileAddBtn = element.nextElementSibling;
if (fileAddBtn) {
const inputs = element.previousElementSibling.querySelectorAll('input');
inputs.forEach(input => input.value = '')
return false;
}
element.parentElement.remove();
}
// 파일 목록 조회
function getFileList(ScheduleId) {
$(`#${ScheduleId}-files-box`).empty();
$.ajax({
type: 'GET',
url: '/api/files',
data: {"ScheduleId":ScheduleId},
success: function (response) {
for (let i = 0; i < response.length; i++) {
let message = response[i];
let id = message['id'];
let scheduleId = message['scheduleId'];
let fileName = message['fileName'];
let saveName = message['saveName'];
let size = message['size'];
addFileHTML(id, scheduleId, fileName, saveName, size);
}
},error: err => {
alert(err.responseJSON.message);
}
})
}
// 파일 목록 HTML
function addFileHTML(id, scheduleId, fileName, saveName, size) {
// 1. HTML 태그를 만듭니다.
let tempHtml = `<div id="${id}-file">
<a href="http://localhost:8080/api/files/download/${id}">${fileName}</a>
</div>`;
// 2. #cards-box 에 HTML을 붙인다.
$(`#${scheduleId}-files-box`).append(tempHtml);
}