Skip to content

Commit

Permalink
[FEAT] Project Excel API 구현 완료 (#115)
Browse files Browse the repository at this point in the history
* feat: 프로젝트일괄등록양식 다운로드 API 구현, 테스트 작성

* feat: 프로젝트 엑셀 일괄등록 컨트롤러, 테스트 작성

* feat: 엑셀 일괄 등록 컨트롤러, 테스트 작성

* feat: 엑셀 일괄 등록 구현

* docs: html 문서 업데이트

* chore: gitignore 파일 수정'

* chore: 의미 없는 공백 제거

* fix: 비어있는 행 판별 오류 수정

* fix: 한글 문자열 비교 오류 수정

* fix: 빈 행 오류 수정

* docs: restdocs 문서 전체 업데이트
  • Loading branch information
yesjuhee authored Nov 18, 2024
1 parent 6ae301a commit 0805f73
Show file tree
Hide file tree
Showing 43 changed files with 6,309 additions and 820 deletions.
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,8 @@ out/

/src/main/resources/application-oauth.yml
/src/main/resources/application-minio.yml
/src/main/resources/application-dev.yml
/src/main/resources/application-email.yml
/src/main/resources/application-notion.yml

.DS_Store
10 changes: 9 additions & 1 deletion src/docs/asciidoc/file-controller-test.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,19 @@
---

=== 다중 파일 업로드 (POST /files)

====
operation::file-controller-test/upload-files[snippets="http-request,request-parts,http-response,response-fields"]
====

=== 파일 조회 (GET /files/{fileId})

====
operation::file-controller-test/get-file[snippets="http-request,path-parameters,http-response"]
====
====

=== 프로젝트 일괄 등록 양식 다운로드 (GET /files/form/projects)

====
operation::file-controller-test/get-project-excel-form[snippets="http-request,http-response"]
====
32 changes: 19 additions & 13 deletions src/docs/asciidoc/project.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -3,73 +3,79 @@

---
=== 프로젝트 조회 (GET /projects)
==== 200 OK

====
operation::project-controller-test/get-projects[snippets="http-request,http-response,query-parameters,response-fields"]
====

=== 프로젝트 생성 (POST /projects)
==== 201 Created

====
operation::project-controller-test/create-project[snippets="http-request,http-response,request-fields,response-fields"]
====

=== 프로젝트 엑셀 일괄등록 (POST /projects/excel)

====
operation::project-excel-controller-test/create-project-excel[snippets="http-request,request-parts,http-response,response-fields"]
====

=== 프로젝트 조회 (GET /projects/{projectId})
==== 200 OK

====
operation::project-controller-test/get-project[snippets="http-request,http-response,path-parameters,response-fields"]
====

=== 프로젝트 수정 (PUT /projects/{projectId})
==== 200 OK

====
operation::project-controller-test/update-project[snippets="http-request,http-response,path-parameters,request-fields,response-fields"]
====

=== 프로젝트 삭제 (DELETE /projects/{projectId})
==== 204 No Content

====
operation::project-controller-test/delete-project[snippets="http-request,http-response,path-parameters"]
====

=== 관심 프로젝트 등록 (POST /projects/{projectId}/favorite)
==== 201 Created

====
operation::project-controller-test/create-project-favorite[snippets="http-request,http-response,path-parameters"]
====

=== 관심 프로젝트 삭제 (DELETE /projects/{projectId}/favorite)
==== 204 No Content

====
operation::project-controller-test/delete-project-favorite[snippets="http-request,http-response,path-parameters"]
====

=== 프로젝트 좋아요 등록 (POST /projects/{projectId}/like)
==== 201 Created

====
operation::project-controller-test/create-project-like[snippets="http-request,http-response,path-parameters"]
====

=== 프로젝트 좋아요 삭제 (DELETE /projects/{projectId}/like)
==== 204 No Content

====
operation::project-controller-test/delete-project-like[snippets="http-request,http-response,path-parameters"]
====

=== 프로젝트 댓글 등록 (POST /projects/{projectId}/comment)
==== 201 Created

====
operation::project-controller-test/create-project-comment[snippets="http-request,http-response,path-parameters,request-fields,response-fields"]
====

=== 프로젝트 댓글 삭제 (DELETE /projects/{projectId}/comment)
==== 204 No Content

====
operation::project-controller-test/delete-project-comment[snippets="http-request,http-response,path-parameters"]
====

=== 수상 프로젝트 조회 (GET /projects/award?year={year})
==== 200 No Content

====
operation::project-controller-test/get-award-projects[snippets="http-request,http-response,query-parameters,response-fields"]
====
====
34 changes: 26 additions & 8 deletions src/main/java/com/scg/stop/file/controller/FileController.java
Original file line number Diff line number Diff line change
@@ -1,22 +1,25 @@
package com.scg.stop.file.controller;

import com.scg.stop.auth.annotation.AuthUser;
import com.scg.stop.file.domain.File;
import com.scg.stop.file.dto.response.FileResponse;
import com.scg.stop.file.service.FileService;
import java.io.InputStream;
import java.util.List;
import com.scg.stop.user.domain.AccessType;
import com.scg.stop.user.domain.User;
import lombok.RequiredArgsConstructor;
import org.springframework.core.io.InputStreamResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
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.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.util.UriUtils;

import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.List;

@RestController
@RequiredArgsConstructor
Expand All @@ -39,4 +42,19 @@ public ResponseEntity<InputStreamResource> getFile(@PathVariable("fileId") Long
.contentType(MediaType.parseMediaType(file.getMimeType()))
.body(new InputStreamResource(stream));
}

@GetMapping("/form/projects")
public ResponseEntity<Resource> getProjectExcelForm(@AuthUser(accessType = AccessType.ADMIN) User user) {
String directoryPath = "form/";
String fileName = "project_upload_form.xlsx";
Resource resource = fileService.getLocalFile(directoryPath, fileName);

HttpHeaders headers = new HttpHeaders();
headers.setContentDispositionFormData("attachment", UriUtils.encode(fileName, StandardCharsets.UTF_8));
headers.setContentType(MediaType.APPLICATION_OCTET_STREAM);

return ResponseEntity.ok()
.headers(headers)
.body(resource);
}
}
17 changes: 17 additions & 0 deletions src/main/java/com/scg/stop/file/service/FileService.java
Original file line number Diff line number Diff line change
@@ -1,19 +1,28 @@
package com.scg.stop.file.service;

import static com.scg.stop.global.exception.ExceptionCode.FILE_NOT_FOUND;
import static com.scg.stop.global.exception.ExceptionCode.INVALID_FILE_PATH;

import com.scg.stop.file.domain.File;
import com.scg.stop.file.dto.response.FileResponse;
import com.scg.stop.file.repository.FileRepository;
import com.scg.stop.global.exception.BadRequestException;
import com.scg.stop.global.exception.InternalServerErrorException;
import java.io.InputStream;
import java.net.MalformedURLException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.ResourceUtils;
import org.springframework.web.multipart.MultipartFile;

@Service
Expand Down Expand Up @@ -53,4 +62,12 @@ public InputStream getFile(Long fileId) {
public File getFileMetadata(Long fileId) {
return fileRepository.findById(fileId).orElseThrow(() -> new BadRequestException(FILE_NOT_FOUND));
}

public Resource getLocalFile(String directoryPath, String fileName) {
ClassPathResource file = new ClassPathResource(directoryPath + fileName);
if (!file.exists()) {
throw new InternalServerErrorException(FILE_NOT_FOUND);
}
return file;
}
}
20 changes: 9 additions & 11 deletions src/main/java/com/scg/stop/global/excel/ExcelUtil.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@

import java.lang.reflect.Field;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

Expand All @@ -22,13 +21,12 @@ public Excel toExcel(String filename, SXSSFWorkbook workbook) {
}



public <T> String getFilename(Workbook workbook, Class<T> clazz) {
LocalDateTime time = LocalDateTime.now();
if( workbook instanceof SXSSFWorkbook) {
return String.format("%s-%s.xlsx",clazz.getDeclaredAnnotation(ExcelDownload.class).fileName(), time);
if (workbook instanceof SXSSFWorkbook) {
return String.format("%s-%s.xlsx", clazz.getDeclaredAnnotation(ExcelDownload.class).fileName(), time);
}
return String.format("%s-%s.xls",clazz.getDeclaredAnnotation(ExcelDownload.class).fileName(), time);
return String.format("%s-%s.xls", clazz.getDeclaredAnnotation(ExcelDownload.class).fileName(), time);
}

public <T> SXSSFWorkbook createExcel(List<T> lists, Class<T> clazz) {
Expand All @@ -39,10 +37,10 @@ public <T> SXSSFWorkbook createExcel(List<T> lists, Class<T> clazz) {
Row row = sheet.createRow(rowNum++);
List<String> headers = Arrays.stream(clazz.getDeclaredFields())
.filter(field -> field.isAnnotationPresent(ExcelColumn.class))
.map( field -> field.getAnnotation(ExcelColumn.class).headerName())
.map(field -> field.getAnnotation(ExcelColumn.class).headerName())
.toList();
renderHeaderRow(row, headers);
for(T data: lists) {
for (T data : lists) {
row = sheet.createRow(rowNum++);
renderBodyRow(row, data, clazz);
}
Expand All @@ -55,7 +53,7 @@ public <T> SXSSFWorkbook append(SXSSFWorkbook workbook, List<T> lists, Class<T>
);
int rowNum = sheet.getLastRowNum() + 1;
Row row;
for(T data: lists) {
for (T data : lists) {
row = sheet.createRow(rowNum++);
renderBodyRow(row, data, clazz);
}
Expand All @@ -68,7 +66,7 @@ private SXSSFWorkbook createWorkBook() {

private void renderHeaderRow(Row firstRow, List<String> headers) {
int cellIdx = 0;
for(String header: headers) {
for (String header : headers) {
Cell headerCell = firstRow.createCell(cellIdx++);
setHeaderCellStyle(headerCell);
renderCellValue(headerCell, header);
Expand All @@ -78,7 +76,7 @@ private void renderHeaderRow(Row firstRow, List<String> headers) {
private <T> void renderBodyRow(Row row, T data, Class<T> clazz) {
Field[] fields = clazz.getDeclaredFields();
int cellIdx = 0;
for(Field field: fields) {
for (Field field : fields) {
Cell cell = row.createCell(cellIdx++);
Object value;
field.setAccessible(true);
Expand All @@ -93,7 +91,7 @@ private <T> void renderBodyRow(Row row, T data, Class<T> clazz) {
}

private void renderCellValue(Cell cell, Object value) {
if(value instanceof Number num) {
if (value instanceof Number num) {
cell.setCellValue(num.doubleValue());
return;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,9 @@ public BadRequestException(final ExceptionCode exceptionCode) {
this.code = exceptionCode.getCode();
this.message = exceptionCode.getMessage();
}

public BadRequestException(final ExceptionCode exceptionCode, final String customMessage) {
this.code = exceptionCode.getCode();
this.message = customMessage;
}
}
23 changes: 19 additions & 4 deletions src/main/java/com/scg/stop/global/exception/ExceptionCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@ public enum ExceptionCode {
UNABLE_TO_GET_USER_INFO(2001, "소셜 로그인 공급자로부터 유저 정보를 받아올 수 없습니다."),
UNABLE_TO_GET_ACCESS_TOKEN(2002, "소셜 로그인 공급자로부터 인증 토큰을 받아올 수 없습니다."),
UNAUTHORIZED_ACCESS(3000, "접근할 수 없는 리소스입니다."),
INVALID_REFRESH_TOKEN(3001,"유효하지 않은 Refresh Token 입니다."),
FAILED_TO_VALIDATE_TOKEN(3002,"토큰 검증에 실패했습니다."),
INVALID_ACCESS_TOKEN(3003,"유효하지 않은 Access Token 입니다."),
INVALID_REFRESH_TOKEN(3001, "유효하지 않은 Refresh Token 입니다."),
FAILED_TO_VALIDATE_TOKEN(3002, "토큰 검증에 실패했습니다."),
INVALID_ACCESS_TOKEN(3003, "유효하지 않은 Access Token 입니다."),

// notion domain
FAILED_TO_FETCH_NOTION_DATA(13000, "Notion 데이터를 가져오는데 실패했습니다."),
Expand Down Expand Up @@ -49,11 +49,26 @@ public enum ExceptionCode {
NOT_FOUND_COMMENT(77010, "댓글을 찾을 수 없습니다"),
NOT_MATCH_USER(77011, "유저 정보가 일치하지 않습니다"),

// project excel
INVALID_FILE_SIZE(78000, "엑셀 행의 개수와 업로드한 이미지의 개수가 일치해야합니다."),
INVALID_THUMBNAIL_NAME(78001, "썸네일 이미지 이름을 찾을 수 없습니다."),
INVALID_POSTER_NAME(78002, "포스터 이미지 이름을 찾을 수 없습니다."),
INVALID_AWARD_STATUS_KOREAN_NAME(78003, "수상 내역의 한글 이름이 올바르지 않습니다."),
INVALID_PROJECT_TYPE_KOREAN_NAME(78004, "프로젝트 종류의 한글 이름이 올바르지 않습니다."),
INVALID_PROJECT_CATEGORY_KOREAN_NAME(78005, "프로젝트 분야의 한글 이름이 올바르지 않습니다."),
EMPTY_CELL(78006, "모든 셀은 값이 있어야 합니다."),
INVALID_EXCEL_FORMAT(78007, "엑셀 형식이 올바르지 않습니다."),
DUPLICATE_THUMBNAIL_ID(78008, "중복된 썸네일 이미지 이름이 존재합니다."),
DUPLICATE_POSTER_ID(78009, "중복된 포스터 이미지 이름이 존재합니다."),
INVALID_YEAR_FORMAT(780010, "프로젝트 년도는 숫자만 입력해야 합니다."),
INVALID_EXCEL(780011, "엑셀 파일을 열 수 없습니다."),

// file domain
FAILED_TO_UPLOAD_FILE(5000, "파일 업로드를 실패했습니다."),
FAILED_TO_GET_FILE(5001, "파일 가져오기를 실패했습니다."),
FILE_NOT_FOUND(5002, "요청한 ID에 해당하는 파일이 존재하지 않습니다."),
NOT_FOUND_FILE_ID(5002, "요청한 ID에 해당하는 파일이 존재하지 않습니다."),
INVALID_FILE_PATH(5004, "파일을 찾을 수 없습니다."),

// notice domain
NOTICE_NOT_FOUND(10000, "요청한 ID에 해당하는 공지사항이 존재하지 않습니다."),
Expand All @@ -66,7 +81,7 @@ public enum ExceptionCode {
UNAUTHORIZED_USER(8003, "해당 문의에 대한 권한이 없습니다."),

// video domain
ID_NOT_FOUND(8200,"해당 ID에 해당하는 잡페어 인터뷰가 없습니다."),
ID_NOT_FOUND(8200, "해당 ID에 해당하는 잡페어 인터뷰가 없습니다."),
TALK_ID_NOT_FOUND(8400, "해당 ID에 해당하는 대담 영상이 없습니다."),
NO_QUIZ(8401, "퀴즈 데이터가 존재하지 않습니다."),
NOT_FOUND_USER_QUIZ(8402, "퀴즈 제출 데이터가 존재하지 않습니다."),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.scg.stop.project.controller;

import com.scg.stop.auth.annotation.AuthUser;
import com.scg.stop.project.dto.response.FileResponse;
import com.scg.stop.project.dto.response.ProjectExcelResponse;
import com.scg.stop.project.service.ProjectExcelService;
import com.scg.stop.user.domain.AccessType;
import com.scg.stop.user.domain.User;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

import java.util.List;

@RestController
@RequiredArgsConstructor
@RequestMapping("/projects/excel")
public class ProjectExcelController {

private final ProjectExcelService projectExcelService;

@PostMapping
public ResponseEntity<ProjectExcelResponse> createProjectExcel(
@RequestPart("excel") MultipartFile excelFile,
@RequestPart("thumbnails") List<FileResponse> thumbnails,
@RequestPart("posters") List<FileResponse> posters,
@AuthUser(accessType = {AccessType.ADMIN}) User user
) {
ProjectExcelResponse projectExcelResponse = projectExcelService.createProjectExcel(excelFile, thumbnails, posters);
return ResponseEntity.status(HttpStatus.CREATED).body(projectExcelResponse);
}
}
Loading

0 comments on commit 0805f73

Please sign in to comment.