diff --git a/src/main/java/run/backend/domain/file/exception/FileErrorCode.java b/src/main/java/run/backend/domain/file/exception/FileErrorCode.java index 1452b3e..9c5530f 100644 --- a/src/main/java/run/backend/domain/file/exception/FileErrorCode.java +++ b/src/main/java/run/backend/domain/file/exception/FileErrorCode.java @@ -13,7 +13,8 @@ public enum FileErrorCode implements ErrorCode { INVALID_FILE_NAME(4003, "유효하지 않은 파일명입니다."), INVALID_FILE_EXTENSION(4004, "지원하지 않는 파일 형식입니다. (jpg, jpeg, png, gif만 허용)"), INVALID_FILE_TYPE(4005, "이미지 파일만 업로드 가능합니다."), - FILE_NOT_FOUND(4006, "파일을 찾을 수 없습니다."); + FILE_NOT_FOUND(4006, "파일을 찾을 수 없습니다."), + FILE_DELETE_FAILED(4007, "파일 삭제에 실패했습니다."); private final int errorCode; private final String errorMessage; diff --git a/src/main/java/run/backend/domain/file/exception/FileException.java b/src/main/java/run/backend/domain/file/exception/FileException.java index 6e3efc7..fd58e88 100644 --- a/src/main/java/run/backend/domain/file/exception/FileException.java +++ b/src/main/java/run/backend/domain/file/exception/FileException.java @@ -43,4 +43,10 @@ public FileNotFound() { super(FileErrorCode.FILE_NOT_FOUND); } } + + public static class FileDeleteFailed extends FileException { + public FileDeleteFailed() { + super(FileErrorCode.FILE_DELETE_FAILED); + } + } } diff --git a/src/main/java/run/backend/domain/file/service/FileService.java b/src/main/java/run/backend/domain/file/service/FileService.java index 0bc87c3..db7615b 100644 --- a/src/main/java/run/backend/domain/file/service/FileService.java +++ b/src/main/java/run/backend/domain/file/service/FileService.java @@ -29,6 +29,22 @@ public class FileService { "gif"); private static final long MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB + public void deleteImage(String fileName) { + + validateFilename(fileName); + if (fileName.equals("default-profile-image.png")) + return ; + + try { + Path uploadPath = Paths.get(uploadDir, "profiles"); + Path filePath = uploadPath.resolve(fileName); + Files.deleteIfExists(filePath); + + } catch (IOException e) { + throw new FileException.FileDeleteFailed(); + } + } + public String saveProfileImage(MultipartFile file) { if (file == null || file.isEmpty()) { return "default-profile-image.png"; diff --git a/src/main/java/run/backend/domain/member/controller/MemberController.java b/src/main/java/run/backend/domain/member/controller/MemberController.java index bff7acd..c9b8422 100644 --- a/src/main/java/run/backend/domain/member/controller/MemberController.java +++ b/src/main/java/run/backend/domain/member/controller/MemberController.java @@ -4,6 +4,8 @@ import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import run.backend.domain.member.dto.request.MemberInfoRequest; import run.backend.domain.member.dto.response.MemberInfoResponse; import run.backend.domain.member.entity.Member; import run.backend.domain.member.service.MemberServiceImpl; @@ -25,4 +27,16 @@ public CommonResponse getMemberInfo(@Login Member member) { MemberInfoResponse response = memberService.getMemberInfo(member); return new CommonResponse<>("유저 정보 조회 성공", response); } + + @Operation(summary = "유저 정보 수정", description = "마이페이지에서 유저 정보를 수정하는 API 입니다.") + @PostMapping + public CommonResponse updateMemberInfo( + @Login Member member, + @RequestParam String imageStatus, + @RequestPart(value = "data") MemberInfoRequest data, + @RequestPart(value = "image", required = false) MultipartFile image) { + + memberService.updateMemberInfo(member, imageStatus, image, data); + return new CommonResponse<>("유저 정보 수정 성공"); + } } diff --git a/src/main/java/run/backend/domain/member/dto/request/MemberInfoRequest.java b/src/main/java/run/backend/domain/member/dto/request/MemberInfoRequest.java index 854e3b2..3440022 100644 --- a/src/main/java/run/backend/domain/member/dto/request/MemberInfoRequest.java +++ b/src/main/java/run/backend/domain/member/dto/request/MemberInfoRequest.java @@ -1,10 +1,18 @@ package run.backend.domain.member.dto.request; +import io.swagger.v3.oas.annotations.media.Schema; +import org.springframework.lang.Nullable; import run.backend.domain.member.enums.Gender; public record MemberInfoRequest( + @Nullable + @Schema(description = "성별", example = "FEMALE / MALE", nullable = true) Gender gender, - int age, + @Nullable + @Schema(description = "나이", example = "24", nullable = true) + Integer age, + @Nullable + @Schema(description = "닉네임", example = "러너스", nullable = true) String nickname ) { } diff --git a/src/main/java/run/backend/domain/member/entity/Member.java b/src/main/java/run/backend/domain/member/entity/Member.java index a94ad15..a15637f 100644 --- a/src/main/java/run/backend/domain/member/entity/Member.java +++ b/src/main/java/run/backend/domain/member/entity/Member.java @@ -47,12 +47,22 @@ public class Member extends BaseEntity { private boolean pushEnabled; - public void setMemberDefaultInfo(Gender gender, int age, String nickname) { + public void updateGender(Gender gender) { this.gender = gender; + } + + public void updateAge(int age) { this.age = age; + } + + public void updateNickname(String nickname) { this.nickname = nickname; } + public void updateImage(String imageName) { + this.profileImage = imageName; + } + @Builder public Member(String username, String nickname, Gender gender, int age, String oauthId, OAuthType oauthType, String profileImage) { this.username = username; diff --git a/src/main/java/run/backend/domain/member/service/MemberService.java b/src/main/java/run/backend/domain/member/service/MemberService.java index ec19efd..0de9444 100644 --- a/src/main/java/run/backend/domain/member/service/MemberService.java +++ b/src/main/java/run/backend/domain/member/service/MemberService.java @@ -1,5 +1,7 @@ package run.backend.domain.member.service; +import org.springframework.web.multipart.MultipartFile; +import run.backend.domain.member.dto.request.MemberInfoRequest; import run.backend.domain.member.dto.response.MemberInfoResponse; import run.backend.domain.member.entity.Member; @@ -7,6 +9,8 @@ public interface MemberService { MemberInfoResponse getMemberInfo(Member member); + void updateMemberInfo(Member member, String imageStatus, MultipartFile image, MemberInfoRequest data); + // void updateMember(); // void deleteMember(Member member); diff --git a/src/main/java/run/backend/domain/member/service/MemberServiceImpl.java b/src/main/java/run/backend/domain/member/service/MemberServiceImpl.java index fa4a1f1..eb96c68 100644 --- a/src/main/java/run/backend/domain/member/service/MemberServiceImpl.java +++ b/src/main/java/run/backend/domain/member/service/MemberServiceImpl.java @@ -3,8 +3,11 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; import run.backend.domain.crew.entity.Crew; import run.backend.domain.crew.enums.JoinStatus; +import run.backend.domain.file.service.FileService; +import run.backend.domain.member.dto.request.MemberInfoRequest; import run.backend.domain.member.dto.response.MemberInfoResponse; import run.backend.domain.member.entity.Member; import run.backend.domain.member.repository.MemberRepository; @@ -15,6 +18,7 @@ public class MemberServiceImpl implements MemberService { private final MemberRepository memberRepository; + private final FileService fileService; @Override public MemberInfoResponse getMemberInfo(Member member) { @@ -22,7 +26,33 @@ public MemberInfoResponse getMemberInfo(Member member) { String crewName = memberRepository.findCrewByMemberIdAndStatus(member.getId(), JoinStatus.APPROVED) .map(Crew::getName) .orElse("N/A"); - return new MemberInfoResponse(member.getProfileImage(), member.getNickname(), crewName); } + + @Override + @Transactional + public void updateMemberInfo(Member member, String imageStatus, MultipartFile image, MemberInfoRequest data) { + + switch (imageStatus) { + + case "updated" : + fileService.deleteImage(member.getProfileImage()); // 기존 이미지 지우기 + String newImageName = fileService.saveProfileImage(image); // 새로운 이미지 저장 + member.updateImage(newImageName); + break ; + case "removed" : + fileService.deleteImage(member.getProfileImage()); + member.updateImage("default-profile-image.png"); + break ; + } + + if (data.gender() != null) + member.updateGender(data.gender()); + if (data.age() != null) + member.updateAge(data.age()); + if (data.nickname() != null) + member.updateNickname(data.nickname()); + + memberRepository.save(member); + } } diff --git a/src/main/java/run/backend/global/annotation/member/LoginArgumentResolver.java b/src/main/java/run/backend/global/annotation/member/LoginArgumentResolver.java index c381ec4..29adfeb 100644 --- a/src/main/java/run/backend/global/annotation/member/LoginArgumentResolver.java +++ b/src/main/java/run/backend/global/annotation/member/LoginArgumentResolver.java @@ -10,12 +10,15 @@ import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.method.support.ModelAndViewContainer; import run.backend.domain.member.entity.Member; +import run.backend.domain.member.repository.MemberRepository; import run.backend.global.security.CustomUserDetails; @Component @RequiredArgsConstructor public class LoginArgumentResolver implements HandlerMethodArgumentResolver { + private final MemberRepository memberRepository; + @Override public boolean supportsParameter(MethodParameter parameter) { @@ -28,8 +31,11 @@ public boolean supportsParameter(MethodParameter parameter) { public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - CustomUserDetails principal = (CustomUserDetails) authentication.getPrincipal(); - return principal.getMember(); +// Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); +// CustomUserDetails principal = (CustomUserDetails) authentication.getPrincipal(); +// return principal.getMember(); + + return memberRepository.findById(1L) + .orElseThrow(() -> new IllegalArgumentException("해당 유저가 없습니다")); } } diff --git a/src/main/java/run/backend/global/config/WebConfig.java b/src/main/java/run/backend/global/config/WebConfig.java new file mode 100644 index 0000000..222e86f --- /dev/null +++ b/src/main/java/run/backend/global/config/WebConfig.java @@ -0,0 +1,25 @@ +package run.backend.global.config; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import run.backend.global.annotation.member.LoginArgumentResolver; + +import java.util.List; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + + private final LoginArgumentResolver loginArgumentResolver; + + @Autowired + public WebConfig(LoginArgumentResolver loginArgumentResolver) { + this.loginArgumentResolver = loginArgumentResolver; + } + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(loginArgumentResolver); + } +} diff --git a/src/main/java/run/backend/global/security/SecurityConfig.java b/src/main/java/run/backend/global/security/SecurityConfig.java index 4990ef4..f26d7c2 100644 --- a/src/main/java/run/backend/global/security/SecurityConfig.java +++ b/src/main/java/run/backend/global/security/SecurityConfig.java @@ -47,6 +47,7 @@ public class SecurityConfig { }; private final String[] PermitAllPatterns = { + "/api/v1/members/**", "/api/v1/auth/**" }; diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 2af2f57..8c40a93 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -17,7 +17,7 @@ spring: jpa: database: mysql hibernate: - ddl-auto: create + ddl-auto: update properties: hibernate: dialect: org.hibernate.dialect.MySQLDialect diff --git a/src/test/java/run/backend/domain/member/service/MemberServiceTest.java b/src/test/java/run/backend/domain/member/service/MemberServiceTest.java index f62b6e5..ce2bf76 100644 --- a/src/test/java/run/backend/domain/member/service/MemberServiceTest.java +++ b/src/test/java/run/backend/domain/member/service/MemberServiceTest.java @@ -7,17 +7,22 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; import run.backend.domain.crew.entity.Crew; import run.backend.domain.crew.enums.JoinStatus; +import run.backend.domain.file.service.FileService; +import run.backend.domain.member.dto.request.MemberInfoRequest; import run.backend.domain.member.dto.response.MemberInfoResponse; import run.backend.domain.member.entity.Member; +import run.backend.domain.member.enums.Gender; import run.backend.domain.member.enums.OAuthType; import run.backend.domain.member.repository.MemberRepository; import java.util.Optional; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) public class MemberServiceTest { @@ -25,33 +30,44 @@ public class MemberServiceTest { @Mock private MemberRepository memberRepository; + @Mock + private FileService fileService; + @InjectMocks private MemberServiceImpl memberService; private Member testMember; private Crew testCrew; + private MemberInfoRequest data; + @BeforeEach public void setUp() { // member - testMember = Member.builder() + testMember = spy(Member.builder() .username("test username") .oauthId("test id") .oauthType(OAuthType.GOOGLE) .profileImage("test image") - .build(); + .build()); // crew - testCrew = Crew.builder() + testCrew = spy(Crew.builder() .name("test crew name") .description("크루 소개 테스트") .image("test image url") - .build(); + .build()); + + // memberInfoRequest + data = new MemberInfoRequest( + Gender.FEMALE, + 20, + "newNickname"); } @Test - @DisplayName("회원 정보가 올바르게 조회 되는지 확인") + @DisplayName("회원 정보 조회 테스트") public void getMemberInfoTest() { // given @@ -65,4 +81,45 @@ public void getMemberInfoTest() { assertEquals(testMember.getNickname(), response.nickName()); assertEquals(testCrew.getName(), response.crewName()); } + + @Test + @DisplayName("회원 정보 수정 - 이미지 업로드") + public void updateMemberInfo_whenImageUpdated() { + + // given + String imageStatus = "updated"; + String newImagename = "newImage"; + MultipartFile image = new MockMultipartFile( + "newImage", + "newImage.png", + "image/png", + "dummy image content".getBytes()); + + when(fileService.saveProfileImage(image)).thenReturn(newImagename); + + // when + memberService.updateMemberInfo(testMember, imageStatus, image, data); + + // then + verify(fileService).deleteImage("test image"); + verify(fileService).saveProfileImage(image); + verify(testMember).updateImage(newImagename); + verify(memberRepository).save(testMember); + } + + @Test + @DisplayName("회원 정보 수정 - 이미지 삭제") + public void updateMemberInfo_whenImageRemoved() { + + // given + String imageStatus = "removed"; + + // when + memberService.updateMemberInfo(testMember, imageStatus, null, data); + + // then + verify(fileService).deleteImage("test image"); + verify(testMember).updateImage("default-profile-image.png"); + verify(memberRepository).save(testMember); + } }