diff --git a/.gitignore b/.gitignore
index 009f7f8b..4a44c233 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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
diff --git a/src/docs/asciidoc/file-controller-test.adoc b/src/docs/asciidoc/file-controller-test.adoc
index c41dbc9a..92ca466a 100644
--- a/src/docs/asciidoc/file-controller-test.adoc
+++ b/src/docs/asciidoc/file-controller-test.adoc
@@ -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"]
-====
\ No newline at end of file
+====
+
+=== 프로젝트 일괄 등록 양식 다운로드 (GET /files/form/projects)
+
+====
+operation::file-controller-test/get-project-excel-form[snippets="http-request,http-response"]
+====
diff --git a/src/docs/asciidoc/project.adoc b/src/docs/asciidoc/project.adoc
index c601d947..d4767b92 100644
--- a/src/docs/asciidoc/project.adoc
+++ b/src/docs/asciidoc/project.adoc
@@ -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"]
-====
\ No newline at end of file
+====
diff --git a/src/main/java/com/scg/stop/file/controller/FileController.java b/src/main/java/com/scg/stop/file/controller/FileController.java
index 03e3916f..fe4ac30f 100644
--- a/src/main/java/com/scg/stop/file/controller/FileController.java
+++ b/src/main/java/com/scg/stop/file/controller/FileController.java
@@ -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
@@ -39,4 +42,19 @@ public ResponseEntity getFile(@PathVariable("fileId") Long
.contentType(MediaType.parseMediaType(file.getMimeType()))
.body(new InputStreamResource(stream));
}
+
+ @GetMapping("/form/projects")
+ public ResponseEntity 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);
+ }
}
diff --git a/src/main/java/com/scg/stop/file/service/FileService.java b/src/main/java/com/scg/stop/file/service/FileService.java
index 243474d7..78346fff 100644
--- a/src/main/java/com/scg/stop/file/service/FileService.java
+++ b/src/main/java/com/scg/stop/file/service/FileService.java
@@ -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
@@ -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;
+ }
}
diff --git a/src/main/java/com/scg/stop/global/excel/ExcelUtil.java b/src/main/java/com/scg/stop/global/excel/ExcelUtil.java
index 03304c9e..22c640ee 100644
--- a/src/main/java/com/scg/stop/global/excel/ExcelUtil.java
+++ b/src/main/java/com/scg/stop/global/excel/ExcelUtil.java
@@ -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;
@@ -22,13 +21,12 @@ public Excel toExcel(String filename, SXSSFWorkbook workbook) {
}
-
public String getFilename(Workbook workbook, Class 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 SXSSFWorkbook createExcel(List lists, Class clazz) {
@@ -39,10 +37,10 @@ public SXSSFWorkbook createExcel(List lists, Class clazz) {
Row row = sheet.createRow(rowNum++);
List 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);
}
@@ -55,7 +53,7 @@ public SXSSFWorkbook append(SXSSFWorkbook workbook, List lists, Class
);
int rowNum = sheet.getLastRowNum() + 1;
Row row;
- for(T data: lists) {
+ for (T data : lists) {
row = sheet.createRow(rowNum++);
renderBodyRow(row, data, clazz);
}
@@ -68,7 +66,7 @@ private SXSSFWorkbook createWorkBook() {
private void renderHeaderRow(Row firstRow, List headers) {
int cellIdx = 0;
- for(String header: headers) {
+ for (String header : headers) {
Cell headerCell = firstRow.createCell(cellIdx++);
setHeaderCellStyle(headerCell);
renderCellValue(headerCell, header);
@@ -78,7 +76,7 @@ private void renderHeaderRow(Row firstRow, List headers) {
private void renderBodyRow(Row row, T data, Class 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);
@@ -93,7 +91,7 @@ private void renderBodyRow(Row row, T data, Class clazz) {
}
private void renderCellValue(Cell cell, Object value) {
- if(value instanceof Number num) {
+ if (value instanceof Number num) {
cell.setCellValue(num.doubleValue());
return;
}
diff --git a/src/main/java/com/scg/stop/global/exception/BadRequestException.java b/src/main/java/com/scg/stop/global/exception/BadRequestException.java
index 18b78f47..793781f5 100644
--- a/src/main/java/com/scg/stop/global/exception/BadRequestException.java
+++ b/src/main/java/com/scg/stop/global/exception/BadRequestException.java
@@ -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;
+ }
}
diff --git a/src/main/java/com/scg/stop/global/exception/ExceptionCode.java b/src/main/java/com/scg/stop/global/exception/ExceptionCode.java
index 43483cf1..ee9e5258 100644
--- a/src/main/java/com/scg/stop/global/exception/ExceptionCode.java
+++ b/src/main/java/com/scg/stop/global/exception/ExceptionCode.java
@@ -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 데이터를 가져오는데 실패했습니다."),
@@ -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에 해당하는 공지사항이 존재하지 않습니다."),
@@ -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, "퀴즈 제출 데이터가 존재하지 않습니다."),
diff --git a/src/main/java/com/scg/stop/project/controller/ProjectExcelController.java b/src/main/java/com/scg/stop/project/controller/ProjectExcelController.java
new file mode 100644
index 00000000..99b2bdbf
--- /dev/null
+++ b/src/main/java/com/scg/stop/project/controller/ProjectExcelController.java
@@ -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 createProjectExcel(
+ @RequestPart("excel") MultipartFile excelFile,
+ @RequestPart("thumbnails") List thumbnails,
+ @RequestPart("posters") List posters,
+ @AuthUser(accessType = {AccessType.ADMIN}) User user
+ ) {
+ ProjectExcelResponse projectExcelResponse = projectExcelService.createProjectExcel(excelFile, thumbnails, posters);
+ return ResponseEntity.status(HttpStatus.CREATED).body(projectExcelResponse);
+ }
+}
diff --git a/src/main/java/com/scg/stop/project/domain/AwardStatus.java b/src/main/java/com/scg/stop/project/domain/AwardStatus.java
index 98dd5f78..c6c3ef93 100644
--- a/src/main/java/com/scg/stop/project/domain/AwardStatus.java
+++ b/src/main/java/com/scg/stop/project/domain/AwardStatus.java
@@ -1,11 +1,38 @@
package com.scg.stop.project.domain;
+import com.scg.stop.global.exception.BadRequestException;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import static com.scg.stop.global.exception.ExceptionCode.INVALID_AWARD_STATUS_KOREAN_NAME;
+
public enum AwardStatus {
- NONE,
- FIRST, // 대상
- SECOND, // 최우수상
- THIRD, // 우수상
- FOURTH, // 장려상
- FIFTH // 인기상
+ NONE("없음"),
+ FIRST("대상"),
+ SECOND("최우수상"),
+ THIRD("우수상"),
+ FOURTH("장려상"),
+ FIFTH("인기상");
+
+ private static final Map KOREAN_NAME_MAP = new HashMap<>();
+ private final String koreanName;
+
+ static {
+ for (AwardStatus awardStatus : AwardStatus.values()) {
+ KOREAN_NAME_MAP.put(awardStatus.koreanName, awardStatus);
+ }
+ }
+
+ AwardStatus(String koreanName) {
+ this.koreanName = koreanName;
+ }
+
+ public static AwardStatus fromKoreanName(String koreanName) {
+ if (!KOREAN_NAME_MAP.containsKey(koreanName)) {
+ throw new BadRequestException(INVALID_AWARD_STATUS_KOREAN_NAME, String.format("수상 내역의 한글 이름이 올바르지 않습니다 : %s", koreanName));
+ }
+ return KOREAN_NAME_MAP.get(koreanName);
+ }
}
diff --git a/src/main/java/com/scg/stop/project/domain/ProjectCategory.java b/src/main/java/com/scg/stop/project/domain/ProjectCategory.java
index a6cedcdb..f375d5b0 100644
--- a/src/main/java/com/scg/stop/project/domain/ProjectCategory.java
+++ b/src/main/java/com/scg/stop/project/domain/ProjectCategory.java
@@ -1,13 +1,40 @@
package com.scg.stop.project.domain;
+import com.scg.stop.global.exception.BadRequestException;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import static com.scg.stop.global.exception.ExceptionCode.INVALID_PROJECT_CATEGORY_KOREAN_NAME;
+
public enum ProjectCategory {
- COMPUTER_VISION,
- SYSTEM_NETWORK,
- WEB_APPLICATION,
- SECURITY_SOFTWARE_ENGINEERING,
- NATURAL_LANGUAGE_PROCESSING,
- BIG_DATA_ANALYSIS,
- AI_MACHINE_LEARNING,
- INTERACTION_AUGMENTED_REALITY
+ COMPUTER_VISION("컴퓨터비전"),
+ SYSTEM_NETWORK("시스템/네트워크"),
+ WEB_APPLICATION("웹/어플리케이션"),
+ SECURITY_SOFTWARE_ENGINEERING("보안/SW공학"),
+ NATURAL_LANGUAGE_PROCESSING("자연어처리"),
+ BIG_DATA_ANALYSIS("빅데이터분석"),
+ AI_MACHINE_LEARNING("AI/머신러닝"),
+ INTERACTION_AUGMENTED_REALITY("인터렉션/증강현실");
+
+ private static final Map KOREAN_NAME_MAP = new HashMap<>();
+ private final String koreanName;
+
+ static {
+ for (ProjectCategory projectCategory : ProjectCategory.values()) {
+ KOREAN_NAME_MAP.put(projectCategory.koreanName, projectCategory);
+ }
+ }
+
+ ProjectCategory(String koreanName) {
+ this.koreanName = koreanName;
+ }
+
+ public static ProjectCategory fromKoreanName(String koreanName) {
+ if (!KOREAN_NAME_MAP.containsKey(koreanName)) {
+ throw new BadRequestException(INVALID_PROJECT_CATEGORY_KOREAN_NAME, String.format("프로젝트 분야의 한글 이름이 올바르지 않습니다 : %s", koreanName));
+ }
+ return KOREAN_NAME_MAP.get(koreanName);
+ }
}
diff --git a/src/main/java/com/scg/stop/project/domain/ProjectType.java b/src/main/java/com/scg/stop/project/domain/ProjectType.java
index a0239bfe..437649c4 100644
--- a/src/main/java/com/scg/stop/project/domain/ProjectType.java
+++ b/src/main/java/com/scg/stop/project/domain/ProjectType.java
@@ -1,9 +1,36 @@
package com.scg.stop.project.domain;
+import com.scg.stop.global.exception.BadRequestException;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import static com.scg.stop.global.exception.ExceptionCode.INVALID_PROJECT_TYPE_KOREAN_NAME;
+
public enum ProjectType {
- RESEARCH_AND_BUSINESS_FOUNDATION,
- LAB,
- STARTUP,
- CLUB
+ RESEARCH_AND_BUSINESS_FOUNDATION("산학"),
+ LAB("연구실"),
+ STARTUP("창업/SPARK"),
+ CLUB("동아리");
+
+ private static final Map KOREAN_NAME_MAP = new HashMap<>();
+ private final String koreanName;
+
+ static {
+ for (ProjectType projectType : ProjectType.values()) {
+ KOREAN_NAME_MAP.put(projectType.koreanName, projectType);
+ }
+ }
+
+ ProjectType(String koreanName) {
+ this.koreanName = koreanName;
+ }
+
+ public static ProjectType fromKoreanName(String koreanName) {
+ if (!KOREAN_NAME_MAP.containsKey(koreanName)) {
+ throw new BadRequestException(INVALID_PROJECT_TYPE_KOREAN_NAME, String.format("프로젝트 종류의 한글 이름이 올바르지 않습니다 : %s", koreanName));
+ }
+ return KOREAN_NAME_MAP.get(koreanName);
+ }
}
diff --git a/src/main/java/com/scg/stop/project/dto/request/ProjectRequest.java b/src/main/java/com/scg/stop/project/dto/request/ProjectRequest.java
index dc0513f2..385fb30d 100644
--- a/src/main/java/com/scg/stop/project/dto/request/ProjectRequest.java
+++ b/src/main/java/com/scg/stop/project/dto/request/ProjectRequest.java
@@ -2,9 +2,6 @@
import com.scg.stop.file.domain.File;
import com.scg.stop.project.domain.*;
-import com.scg.stop.global.exception.BadRequestException;
-import com.scg.stop.global.exception.ExceptionCode;
-import com.scg.stop.project.domain.*;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
@@ -82,7 +79,7 @@ public ProjectRequest(
}
public Project toEntity(Long id, File thumbnail, File poster) {
- Project project = new Project(
+ Project project = new Project(
id,
projectName,
projectType,
@@ -110,4 +107,4 @@ public Project toEntity(Long id, File thumbnail, File poster) {
return project;
}
-}
\ No newline at end of file
+}
diff --git a/src/main/java/com/scg/stop/project/dto/response/ProjectDetailResponse.java b/src/main/java/com/scg/stop/project/dto/response/ProjectDetailResponse.java
index fbc22f23..10c3195b 100644
--- a/src/main/java/com/scg/stop/project/dto/response/ProjectDetailResponse.java
+++ b/src/main/java/com/scg/stop/project/dto/response/ProjectDetailResponse.java
@@ -5,7 +5,6 @@
import lombok.AllArgsConstructor;
import lombok.Getter;
-import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
@@ -32,7 +31,7 @@ public class ProjectDetailResponse {
private String url;
private String description;
- public static ProjectDetailResponse of(User user, Project project){
+ public static ProjectDetailResponse of(User user, Project project) {
List studentNames = project.getMembers().stream()
.filter(member -> member.getRole() == Role.STUDENT)
.map(Member::getName)
diff --git a/src/main/java/com/scg/stop/project/dto/response/ProjectExcelResponse.java b/src/main/java/com/scg/stop/project/dto/response/ProjectExcelResponse.java
new file mode 100644
index 00000000..78fa67b3
--- /dev/null
+++ b/src/main/java/com/scg/stop/project/dto/response/ProjectExcelResponse.java
@@ -0,0 +1,10 @@
+package com.scg.stop.project.dto.response;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+@Getter
+@AllArgsConstructor
+public class ProjectExcelResponse {
+ private int successCount;
+}
diff --git a/src/main/java/com/scg/stop/project/dto/response/ProjectResponse.java b/src/main/java/com/scg/stop/project/dto/response/ProjectResponse.java
index 97609c49..3ab9626d 100644
--- a/src/main/java/com/scg/stop/project/dto/response/ProjectResponse.java
+++ b/src/main/java/com/scg/stop/project/dto/response/ProjectResponse.java
@@ -5,7 +5,6 @@
import lombok.AllArgsConstructor;
import lombok.Getter;
-import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
@@ -28,7 +27,7 @@ public class ProjectResponse {
private String url;
private String description;
- public static ProjectResponse of(User user, Project project){
+ public static ProjectResponse of(User user, Project project) {
List studentNames = project.getMembers().stream()
.filter(member -> member.getRole() == Role.STUDENT)
.map(Member::getName)
diff --git a/src/main/java/com/scg/stop/project/service/ProjectExcelService.java b/src/main/java/com/scg/stop/project/service/ProjectExcelService.java
new file mode 100644
index 00000000..af923ae5
--- /dev/null
+++ b/src/main/java/com/scg/stop/project/service/ProjectExcelService.java
@@ -0,0 +1,206 @@
+package com.scg.stop.project.service;
+
+import com.scg.stop.global.exception.BadRequestException;
+import com.scg.stop.global.exception.InternalServerErrorException;
+import com.scg.stop.project.domain.AwardStatus;
+import com.scg.stop.project.domain.ProjectCategory;
+import com.scg.stop.project.domain.ProjectType;
+import com.scg.stop.project.domain.Role;
+import com.scg.stop.project.dto.request.MemberRequest;
+import com.scg.stop.project.dto.request.ProjectRequest;
+import com.scg.stop.project.dto.response.FileResponse;
+import com.scg.stop.project.dto.response.ProjectExcelResponse;
+import lombok.RequiredArgsConstructor;
+import org.apache.poi.ss.usermodel.Cell;
+import org.apache.poi.ss.usermodel.CellType;
+import org.apache.poi.ss.usermodel.Row;
+import org.apache.poi.ss.usermodel.Sheet;
+import org.apache.poi.xssf.usermodel.XSSFSheet;
+import org.apache.poi.xssf.usermodel.XSSFWorkbook;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.IOException;
+import java.text.Normalizer;
+import java.util.*;
+
+import static com.scg.stop.global.exception.ExceptionCode.*;
+
+@Service
+@Transactional
+@RequiredArgsConstructor
+public class ProjectExcelService {
+
+ private final ProjectService projectService;
+
+ private static final int COLUMN_SIZE = 13;
+
+ public ProjectExcelResponse createProjectExcel(MultipartFile excelFile, List thumbnails, List posters) {
+ try (XSSFWorkbook workbook = new XSSFWorkbook(excelFile.getInputStream())) {
+ XSSFSheet sheet = workbook.getSheetAt(0);
+ validateRequestSize(sheet, thumbnails.size(), posters.size());
+
+ // ProjectRequest DTO로 변환
+ Map headerMap = getHeaderMap(sheet);
+ List projectRequests = convertExcelToDtoList(sheet, headerMap, thumbnails, posters);
+ validateNoDuplicateFile(projectRequests);
+
+ // 프로젝트 생성
+ projectRequests.forEach(projectRequest -> projectService.createProject(projectRequest, null));
+ return new ProjectExcelResponse(projectRequests.size());
+ } catch (IOException e) {
+ throw new InternalServerErrorException(INVALID_EXCEL);
+ } catch (BadRequestException e) {
+ throw new BadRequestException(INVALID_EXCEL_FORMAT, e.getMessage());
+ }
+ }
+
+ private List convertExcelToDtoList(XSSFSheet sheet, Map headerMap, List thumbnails, List posters) {
+ List projectRequests = new ArrayList<>();
+ for (int rowIndex = 1; rowIndex <= sheet.getLastRowNum(); rowIndex++) {
+ Row row = sheet.getRow(rowIndex);
+ if (isRowEmpty(row)) {
+ continue;
+ }
+ try {
+ validateNoEmptyShell(row);
+ projectRequests.add(convertRowToDto(row, headerMap, thumbnails, posters));
+ } catch (BadRequestException e) {
+ throw new BadRequestException(INVALID_EXCEL_FORMAT, String.format("%d행의 형식이 올바르지 않습니다: %s", rowIndex + 1, e.getMessage()));
+ }
+ }
+ return projectRequests;
+ }
+
+ private ProjectRequest convertRowToDto(Row row, Map headerMap, List thumbnails, List posters) {
+ String projectName = row.getCell(headerMap.get("프로젝트명")).getStringCellValue().strip();
+ String projectTypeStr = row.getCell(headerMap.get("프로젝트 종류")).getStringCellValue().strip();
+ String projectCategoryStr = row.getCell(headerMap.get("프로젝트 분야")).getStringCellValue().strip();
+ String teamName = row.getCell(headerMap.get("참가팀명")).getStringCellValue().strip();
+ String professors = row.getCell(headerMap.get("담당 교수")).getStringCellValue().strip();
+ String students = row.getCell(headerMap.get("학생")).getStringCellValue().strip();
+ String youtubeId = row.getCell(headerMap.get("유튜브 아이디")).getStringCellValue().strip();
+ String url = row.getCell(headerMap.get("웹사이트")).getStringCellValue().strip();
+ String description = row.getCell(headerMap.get("간략 설명")).getStringCellValue().strip();
+ Integer year = validateAndParseNumericCellValue(row.getCell(headerMap.get("프로젝트 년도")));
+ String awardStatusStr = row.getCell(headerMap.get("수상 내역")).getStringCellValue().strip();
+ String thumbnailImageName = row.getCell(headerMap.get("썸네일 이미지")).getStringCellValue().strip();
+ String posterImageName = row.getCell(headerMap.get("포스터 이미지")).getStringCellValue().strip();
+
+ // 이미지 이름으로 아이디 검색
+ Long thumbnailId = thumbnails.stream()
+ .filter(thumbnail -> normalize(thumbnail.getName()).equals(normalize(thumbnailImageName)))
+ .map(FileResponse::getId)
+ .findFirst()
+ .orElseThrow(() -> new BadRequestException(INVALID_THUMBNAIL_NAME));
+ Long posterId = posters.stream()
+ .filter(poster -> normalize(poster.getName()).equals(normalize(posterImageName)))
+ .map(FileResponse::getId)
+ .findFirst()
+ .orElseThrow(() -> new BadRequestException(INVALID_POSTER_NAME));
+
+ // enum 변환
+ ProjectType projectType = ProjectType.fromKoreanName(projectTypeStr);
+ ProjectCategory projectCategory = ProjectCategory.fromKoreanName(projectCategoryStr);
+ AwardStatus awardStatus = AwardStatus.fromKoreanName(awardStatusStr);
+
+ // 교수 이름, 학생 이름 -> List
+ List members = new ArrayList<>();
+ Arrays.stream(professors.split(",")).forEach(name -> members.add(new MemberRequest(name.strip(), Role.PROFESSOR)));
+ Arrays.stream(students.split(",")).forEach(name -> members.add(new MemberRequest(name.strip(), Role.STUDENT)));
+
+ return new ProjectRequest(
+ thumbnailId,
+ posterId,
+ projectName,
+ projectType,
+ projectCategory,
+ teamName,
+ youtubeId,
+ year,
+ awardStatus,
+ members,
+ url,
+ description);
+ }
+
+ private Integer validateAndParseNumericCellValue(Cell cell) {
+ try {
+ return (int) cell.getNumericCellValue();
+ } catch (Exception e) {
+ throw new BadRequestException(INVALID_YEAR_FORMAT);
+ }
+ }
+
+ private void validateNoEmptyShell(Row row) {
+ short lastCellNum = row.getLastCellNum();
+ if (lastCellNum != COLUMN_SIZE) {
+ throw new BadRequestException(EMPTY_CELL);
+ }
+ for (int cellIndex = 0; cellIndex < lastCellNum; cellIndex++) {
+ Cell cell = row.getCell(cellIndex);
+ if (cell == null || cell.getCellType() == CellType.BLANK) {
+ throw new BadRequestException(EMPTY_CELL);
+ }
+ }
+ }
+
+ private void validateRequestSize(Sheet sheet, int thumbnails, int posters) {
+ int dataRowCount = 0;
+ for (int rowIndex = 1; rowIndex <= sheet.getLastRowNum(); rowIndex++) {
+ if (!isRowEmpty(sheet.getRow(rowIndex))) {
+ dataRowCount++;
+ }
+ }
+ if (dataRowCount != thumbnails || dataRowCount != posters) {
+ throw new BadRequestException(INVALID_FILE_SIZE);
+ }
+ }
+
+ private void validateNoDuplicateFile(List projectRequests) {
+ List thumbnailIdList = projectRequests.stream().map(ProjectRequest::getThumbnailId).toList();
+ Set thumbnailIdSet = Set.copyOf(thumbnailIdList);
+ if (thumbnailIdList.size() != thumbnailIdSet.size()) {
+ throw new BadRequestException(DUPLICATE_THUMBNAIL_ID);
+ }
+
+ List posterIdList = projectRequests.stream().map(ProjectRequest::getPosterId).toList();
+ Set posterIdSet = Set.copyOf(posterIdList);
+ if (posterIdList.size() != posterIdSet.size()) {
+ throw new BadRequestException(DUPLICATE_POSTER_ID);
+ }
+ }
+
+ private boolean isRowEmpty(Row row) {
+ if (row == null) {
+ return true;
+ }
+ for (Cell cell : row) {
+ if (!isCellEmpty(cell)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private boolean isCellEmpty(Cell cell) {
+ return cell == null || cell.getCellType() == CellType.BLANK;
+ }
+
+ /**
+ * 문자열 유니코드 정규화
+ */
+ private String normalize(String input) {
+ return Normalizer.normalize(input, Normalizer.Form.NFC);
+ }
+
+ private Map getHeaderMap(XSSFSheet sheet) {
+ Map headerMap = new HashMap<>();
+ Row headerRow = sheet.getRow(0);
+ for (Cell cell : headerRow) {
+ headerMap.put(cell.getStringCellValue(), cell.getColumnIndex());
+ }
+ return headerMap;
+ }
+}
diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml
index 3318df98..d24c6f97 100644
--- a/src/main/resources/application-local.yml
+++ b/src/main/resources/application-local.yml
@@ -10,7 +10,7 @@ spring:
open-in-view: false
show-sql: true
hibernate:
- ddl-auto: create
+ ddl-auto: update
properties:
hibernate:
default_batch_fetch_size: 15
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index d0bf015c..7ef1b5b5 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -1,7 +1,7 @@
spring:
- profiles:
- default: local
- group:
- local: common, oauth, minio, notion, email
- prod: common, oauth, minio, notion, email
-
+ profiles:
+ default: local
+ group:
+ local: common, oauth, minio, notion, email
+ prod: common, oauth, minio, notion, email
+ dev: common, oauth, minio, notion, email
diff --git a/src/main/resources/form/project_upload_form.xlsx b/src/main/resources/form/project_upload_form.xlsx
new file mode 100644
index 00000000..178849af
Binary files /dev/null and b/src/main/resources/form/project_upload_form.xlsx differ
diff --git a/src/main/resources/static/docs/aiHub-controller-test.html b/src/main/resources/static/docs/aihub-controller-test.html
similarity index 99%
rename from src/main/resources/static/docs/aiHub-controller-test.html
rename to src/main/resources/static/docs/aihub-controller-test.html
index 5025b31d..2e5d3de5 100644
--- a/src/main/resources/static/docs/aiHub-controller-test.html
+++ b/src/main/resources/static/docs/aihub-controller-test.html
@@ -566,10 +566,8 @@ HTTP response
Content-Length: 1221
{
- "totalElements" : 2,
"totalPages" : 1,
- "first" : true,
- "last" : true,
+ "totalElements" : 2,
"size" : 10,
"content" : [ {
"title" : "title",
@@ -600,7 +598,6 @@ HTTP response
"sorted" : false,
"unsorted" : true
},
- "numberOfElements" : 2,
"pageable" : {
"pageNumber" : 0,
"pageSize" : 10,
@@ -613,6 +610,9 @@ HTTP response
"paged" : true,
"unpaged" : false
},
+ "numberOfElements" : 2,
+ "first" : true,
+ "last" : true,
"empty" : false
}
@@ -944,10 +944,8 @@ HTTP response
Content-Length: 1217
{
- "totalElements" : 2,
"totalPages" : 1,
- "first" : true,
- "last" : true,
+ "totalElements" : 2,
"size" : 10,
"content" : [ {
"title" : "title",
@@ -978,7 +976,6 @@ HTTP response
"sorted" : false,
"unsorted" : true
},
- "numberOfElements" : 2,
"pageable" : {
"pageNumber" : 0,
"pageSize" : 10,
@@ -991,6 +988,9 @@ HTTP response
"paged" : true,
"unpaged" : false
},
+ "numberOfElements" : 2,
+ "first" : true,
+ "last" : true,
"empty" : false
}
@@ -1206,7 +1206,7 @@ Response fields
+
+
+
+
학과 API
+
+
+
+
학과 리스트 조회 (GET /departments)
+
+
+
+
HTTP request
+
+
+
GET /departments HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Host: localhost:8080
+
+
+
+
+
HTTP response
+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json
+Content-Length: 92
+
+[ {
+ "id" : 1,
+ "name" : "소프트웨어학과"
+}, {
+ "id" : 2,
+ "name" : "학과2"
+} ]
+
+
+
+
+
Response fields
+
+
+
+
+
+
+
+
+Path |
+Type |
+Description |
+
+
+
+
+[].id
|
+Number
|
+학과 ID |
+
+
+[].name
|
+String
|
+학과 이름 |
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main/resources/static/docs/application.html b/src/main/resources/static/docs/application.html
index 5bef031e..6fa4003b 100644
--- a/src/main/resources/static/docs/application.html
+++ b/src/main/resources/static/docs/application.html
@@ -497,13 +497,11 @@
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
Content-Type: application/json
-Content-Length: 1176
+Content-Length: 1178
{
- "totalElements" : 3,
"totalPages" : 1,
- "first" : true,
- "last" : true,
+ "totalElements" : 3,
"size" : 10,
"content" : [ {
"id" : 1,
@@ -511,24 +509,24 @@
"division" : "배민",
"position" : null,
"userType" : "INACTIVE_COMPANY",
- "createdAt" : "2024-10-29T22:38:57.12596",
- "updatedAt" : "2024-10-29T22:38:57.125962"
+ "createdAt" : "2024-11-18T19:55:54.259219",
+ "updatedAt" : "2024-11-18T19:55:54.259222"
}, {
"id" : 2,
"name" : "김교수",
"division" : "솦융대",
"position" : "교수",
"userType" : "INACTIVE_PROFESSOR",
- "createdAt" : "2024-10-29T22:38:57.12597",
- "updatedAt" : "2024-10-29T22:38:57.125972"
+ "createdAt" : "2024-11-18T19:55:54.259235",
+ "updatedAt" : "2024-11-18T19:55:54.259235"
}, {
"id" : 3,
"name" : "박교수",
"division" : "정통대",
"position" : "교수",
"userType" : "INACTIVE_PROFESSOR",
- "createdAt" : "2024-10-29T22:38:57.125975",
- "updatedAt" : "2024-10-29T22:38:57.125975"
+ "createdAt" : "2024-11-18T19:55:54.259245",
+ "updatedAt" : "2024-11-18T19:55:54.259246"
} ],
"number" : 0,
"sort" : {
@@ -536,7 +534,6 @@
"sorted" : false,
"unsorted" : true
},
- "numberOfElements" : 3,
"pageable" : {
"pageNumber" : 0,
"pageSize" : 10,
@@ -549,6 +546,9 @@
"paged" : true,
"unpaged" : false
},
+ "numberOfElements" : 3,
+ "first" : true,
+ "last" : true,
"empty" : false
}
@@ -796,8 +796,8 @@
"division" : "배민",
"position" : "CEO",
"userType" : "INACTIVE_COMPANY",
- "createdAt" : "2024-10-29T22:38:57.150487",
- "updatedAt" : "2024-10-29T22:38:57.150489"
+ "createdAt" : "2024-11-18T19:55:54.344894",
+ "updatedAt" : "2024-11-18T19:55:54.344897"
}
@@ -926,7 +926,7 @@
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
Content-Type: application/json
-Content-Length: 262
+Content-Length: 263
{
"id" : 1,
@@ -936,8 +936,8 @@
"division" : "배민",
"position" : "CEO",
"userType" : "COMPANY",
- "createdAt" : "2024-10-29T22:38:57.14394",
- "updatedAt" : "2024-10-29T22:38:57.143942"
+ "createdAt" : "2024-11-18T19:55:54.328644",
+ "updatedAt" : "2024-11-18T19:55:54.328648"
}
@@ -1077,7 +1077,7 @@
diff --git a/src/main/resources/static/docs/auth-controller-test.html b/src/main/resources/static/docs/auth-controller-test.html
index 870be9e4..355afd9e 100644
--- a/src/main/resources/static/docs/auth-controller-test.html
+++ b/src/main/resources/static/docs/auth-controller-test.html
@@ -489,17 +489,35 @@
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
-Set-Cookie: refresh-token=refresh_token; Path=/; Max-Age=604800; Expires=Tue, 5 Nov 2024 13:38:54 GMT; Secure; HttpOnly; SameSite=None
-Set-Cookie: access-token=access_token; Path=/; Max-Age=604800; Expires=Tue, 5 Nov 2024 13:38:54 GMT; Secure; SameSite=None
+Set-Cookie: refresh-token=refresh_token; Path=/; Max-Age=604800; Expires=Mon, 25 Nov 2024 10:55:46 GMT; Secure; HttpOnly; SameSite=None
+Set-Cookie: access-token=access_token; Path=/; Max-Age=604800; Expires=Mon, 25 Nov 2024 10:55:46 GMT; Secure; SameSite=None
Location: https://localhost:3000/login/kakao
diff --git a/src/main/resources/static/docs/department.html b/src/main/resources/static/docs/department.html
new file mode 100644
index 00000000..2679ba6e
--- /dev/null
+++ b/src/main/resources/static/docs/department.html
@@ -0,0 +1,524 @@
+
+
+