diff --git a/nect-api/src/main/java/com/nect/api/domain/mypage/controller/MypageController.java b/nect-api/src/main/java/com/nect/api/domain/mypage/controller/MypageController.java index cd27c70..bc62608 100644 --- a/nect-api/src/main/java/com/nect/api/domain/mypage/controller/MypageController.java +++ b/nect-api/src/main/java/com/nect/api/domain/mypage/controller/MypageController.java @@ -1,22 +1,18 @@ package com.nect.api.domain.mypage.controller; -import com.nect.api.domain.mypage.dto.*; -import com.nect.api.domain.matching.service.RecruitmentService; -import com.nect.api.domain.mypage.dto.MyProjectStringListRequest; import com.nect.api.domain.matching.dto.RecruitmentReqDto; import com.nect.api.domain.matching.dto.RecruitmentResDto; +import com.nect.api.domain.matching.service.RecruitmentService; +import com.nect.api.domain.mypage.dto.MyProjectStringListRequest; import com.nect.api.domain.mypage.dto.MyProjectsResponseDto; import com.nect.api.domain.mypage.dto.ProfileSettingsDto; -import com.nect.api.domain.mypage.dto.ProfileSettingsDto.*; -import com.nect.api.domain.mypage.service.*; import com.nect.api.domain.mypage.dto.ProfileSettingsDto.ProfileSettingsRequestDto; import com.nect.api.domain.mypage.dto.ProfileSettingsDto.ProfileSettingsResponseDto; -import com.nect.api.domain.team.project.dto.ProjectUserFieldReqDto; -import com.nect.api.domain.team.project.dto.ProjectUserFieldResDto; -import com.nect.api.domain.team.project.dto.ProjectUserResDto; -import com.nect.api.domain.team.project.dto.ProjectUserTypeReqDto; -import com.nect.api.domain.team.project.dto.ProjectMemberStatisticResponse; +import com.nect.api.domain.mypage.dto.TeamRoleAddRequestDto; +import com.nect.api.domain.mypage.service.*; +import com.nect.api.domain.team.project.dto.*; import com.nect.api.domain.team.project.service.ProjectMemberStatisticService; +import com.nect.api.domain.team.project.service.ProjectService; import com.nect.api.domain.team.project.service.ProjectUserService; import com.nect.api.global.response.ApiResponse; import com.nect.api.global.security.UserDetailsImpl; @@ -46,6 +42,7 @@ public class MypageController { private final UserTeamRoleQueryService userTeamRoleQueryService; private final ProjectMemberStatisticService projectMemberStatisticService; private final ProjectDeleteService projectDeleteService; + private final ProjectService projectService; /** * 프로필 조회 @@ -336,5 +333,12 @@ public ApiResponse deleteProject( return ApiResponse.ok(); } - + @PostMapping("{projectId}/image") + public ApiResponse uploadImage( + @AuthenticationPrincipal UserDetailsImpl user, + @PathVariable Long projectId, + @RequestParam("image") MultipartFile image + ) { + return ApiResponse.ok(projectService.uploadImage(user.getUserId(), projectId, image)); + } } diff --git a/nect-api/src/main/java/com/nect/api/domain/team/project/enums/code/ProjectErrorCode.java b/nect-api/src/main/java/com/nect/api/domain/team/project/enums/code/ProjectErrorCode.java index 365d2a5..2e6a8df 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/project/enums/code/ProjectErrorCode.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/project/enums/code/ProjectErrorCode.java @@ -19,7 +19,7 @@ public enum ProjectErrorCode implements ResponseCode { WEEK_MISSION_ALREADY_INITIALIZED("P400_7", "위크미션이 이미 생성되어 있습니다."), INVALID_WEEK_MISSION_UPDATE("P400_8", "수정할 수 없는 항목이 포함되어 있습니다."), PROJECT_CREATE_LIMIT_EXCEEDED("P400_9", "생성할 수 있는 프로젝트는 최대 2개입니다."), - + INVALID_IMAGE("P400_10", "올바르지 않은 이미지 형식입니다."), PROJECT_PART_NOT_FOUND("P400_9", "해당 프로젝트 파트(팀 역할)를 찾을 수 없습니다."), DUPLICATE_PART("P400_10", "이미 존재하는 파트입니다."), INVALID_CUSTOM_PART_NAME("P400_11", "CUSTOM 파트 이름이 올바르지 않습니다."), @@ -27,7 +27,8 @@ public enum ProjectErrorCode implements ResponseCode { PROJECT_MEMBER_FORBIDDEN("P403_0", "프로젝트 멤버만 접근할 수 있습니다."), LEADER_ONLY_ACTION("P403_1", "리더만 할 수 있는 요청입니다."), - ; + + IMAGE_UPLOAD_FAILED("P500_1", "이미지 업로드에 실패했습니다."); private final String statusCode; private final String message; diff --git a/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectService.java b/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectService.java index 1bba7a5..4a61ae0 100644 --- a/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectService.java +++ b/nect-api/src/main/java/com/nect/api/domain/team/project/service/ProjectService.java @@ -6,6 +6,7 @@ import com.nect.api.domain.team.project.exception.ProjectException; import com.nect.api.domain.user.enums.UserErrorCode; import com.nect.api.domain.user.service.UserService; +import com.nect.api.global.infra.S3Service; import com.nect.core.entity.analysis.ProjectIdeaAnalysis; import com.nect.core.entity.analysis.ProjectImprovementPoint; import com.nect.core.entity.analysis.ProjectWeeklyPlan; @@ -38,7 +39,9 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; +import java.io.IOException; import java.lang.reflect.Field; import java.util.List; import java.util.stream.Collectors; @@ -61,6 +64,7 @@ public class ProjectService { private final UserService userService; private final ProjectInterestFieldRepository projectInterestFieldRepository; private final UserTeamRoleRepository userTeamRoleRepository; + private final S3Service s3Service; public Project getProject(Long projectId){ return projectRepository.findById(projectId) @@ -350,4 +354,25 @@ private void saveUserTeamRoles(Project project, ProjectIdeaAnalysis analysis) { } + public String uploadImage(Long userId, Long projectId, MultipartFile image) { + Project project = getProject(projectId); + + if(!(userId.equals(projectUserRepository.findLeaderByProject(project)))){ + throw new ProjectException(ProjectErrorCode.LEADER_ONLY_ACTION); + } + + if (image == null || image.isEmpty()) { + throw new ProjectException(ProjectErrorCode.INVALID_IMAGE); + } + + try { + String imageName = s3Service.uploadFile(image); + project.setImageName(imageName); + return s3Service.getPresignedGetUrl(imageName); + } catch (IOException e) { + throw new ProjectException( + ProjectErrorCode.IMAGE_UPLOAD_FAILED, e.getMessage() + ); + } + } } diff --git a/nect-api/src/test/java/com/nect/api/domain/mypage/controller/MypageControllerTest.java b/nect-api/src/test/java/com/nect/api/domain/mypage/controller/MypageControllerTest.java index e9af705..603e98a 100644 --- a/nect-api/src/test/java/com/nect/api/domain/mypage/controller/MypageControllerTest.java +++ b/nect-api/src/test/java/com/nect/api/domain/mypage/controller/MypageControllerTest.java @@ -9,17 +9,18 @@ import com.nect.api.domain.mypage.dto.ProfileSettingsDto; import com.nect.api.domain.mypage.dto.TeamRoleAddRequestDto; import com.nect.api.domain.mypage.service.*; +import com.nect.api.domain.team.project.dto.ProjectMemberStatisticResponse; import com.nect.api.domain.team.project.dto.ProjectUserFieldReqDto; import com.nect.api.domain.team.project.dto.ProjectUserFieldResDto; import com.nect.api.domain.team.project.dto.ProjectUserResDto; import com.nect.api.domain.team.project.service.ProjectMemberStatisticService; +import com.nect.api.domain.team.project.service.ProjectService; import com.nect.api.domain.team.project.service.ProjectUserService; import com.nect.core.entity.team.enums.PlanFileType; import com.nect.core.entity.team.enums.ProjectMemberStatus; import com.nect.core.entity.team.enums.ProjectMemberType; import com.nect.core.entity.team.enums.RecruitmentStatus; import com.nect.core.entity.user.enums.InterestField; -import com.nect.api.domain.team.project.dto.ProjectMemberStatisticResponse; import com.nect.core.entity.user.enums.Role; import com.nect.core.entity.user.enums.RoleField; import org.junit.jupiter.api.Test; @@ -38,6 +39,7 @@ import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.doNothing; import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.*; import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; @@ -71,6 +73,9 @@ class MypageControllerTest extends NectDocumentApiTester { @MockitoBean private ProjectDeleteService projectDeleteService; + @MockitoBean + private ProjectService projectService; + @Test void getProfile() throws Exception { ProfileSettingsDto.ProfileSettingsResponseDto mockResponse = new ProfileSettingsDto.ProfileSettingsResponseDto( @@ -439,6 +444,56 @@ void uploadPlanFile_FILE() throws Exception { )); } + @Test + void uploadImage() throws Exception { + long projectId = 1L; + long userId = 1L; + + MockMultipartFile image = new MockMultipartFile( + "image", + "project-image.png", + MediaType.IMAGE_PNG_VALUE, + "dummy image bytes".getBytes() + ); + + String uploadedUrl = "https://cdn.example.com/projects/1/project-image.png"; + + given(projectService.uploadImage(anyLong(), eq(projectId), any())).willReturn(uploadedUrl); + + mockMvc.perform(multipart("/api/v1/mypage/{projectId}/image", projectId) + .file(image) + .header(AUTH_HEADER, TEST_ACCESS_TOKEN) + .contentType(MediaType.MULTIPART_FORM_DATA) + .accept(MediaType.APPLICATION_JSON) + ) + .andExpect(status().isOk()) + .andDo(document("mypage-upload-project-image", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName(AUTH_HEADER).description("Bearer Access Token") + ), + requestParts( + partWithName("image").description("업로드할 이미지(MultipartFile)") + ), + resource(ResourceSnippetParameters.builder() + .tag("Mypage") + .summary("프로젝트 이미지 업로드") + .description("프로젝트 대표 이미지를 업로드합니다. 업로드 성공 시 이미지 URL을 반환합니다.") + .pathParameters( + parameterWithName("projectId").description("프로젝트 ID") + ) + .responseFields( + fieldWithPath("status.statusCode").type(JsonFieldType.STRING).description("상태 코드"), + fieldWithPath("status.message").type(JsonFieldType.STRING).description("상태 메시지"), + fieldWithPath("status.description").optional().type(JsonFieldType.STRING).description("상태 설명"), + fieldWithPath("body").type(JsonFieldType.STRING).description("업로드된 이미지 URL") + ) + .build() + ) + )); + } + @Test void editPlanFile_LINK() throws Exception { long projectId = 1L; diff --git a/nect-core/src/main/java/com/nect/core/entity/team/Project.java b/nect-core/src/main/java/com/nect/core/entity/team/Project.java index 58825f6..53783f0 100644 --- a/nect-core/src/main/java/com/nect/core/entity/team/Project.java +++ b/nect-core/src/main/java/com/nect/core/entity/team/Project.java @@ -105,4 +105,8 @@ public void setProjectPeriod(LocalDate startDate, LocalDate endDate) { this.plannedStartedOn = startDate; this.plannedEndedOn = endDate; } + + public void setImageName(String imageName){ + this.imageName = imageName; + } }