diff --git a/src/docs/asciidoc/index.adoc b/src/docs/asciidoc/index.adoc index dc6be2fd..cc318977 100644 --- a/src/docs/asciidoc/index.adoc +++ b/src/docs/asciidoc/index.adoc @@ -144,6 +144,24 @@ include::{snippets}/validate_nickname/fail/invalid/response-fields.adoc[] include::{snippets}/validate_nickname/fail/duplicate/response-body.adoc[] include::{snippets}/validate_nickname/fail/duplicate/response-fields.adoc[] +=== 사용자 정보 수정 + +==== PATCH /api/v1/members/me + +===== 요청 +include::{snippets}/update-my-profile/success/http-request.adoc[] +include::{snippets}/update-my-profile/success/request-headers.adoc[] +include::{snippets}/update-my-profile/success/request-parts.adoc[] +include::{snippets}/update-my-profile/success/query-parameters.adoc[] + +===== 응답 성공 (200) +include::{snippets}/update-my-profile/success/response-body.adoc[] +include::{snippets}/update-my-profile/success/response-fields.adoc[] + +===== 응답 실패 (400) +include::{snippets}/update-my-profile/fail/invalid-request/response-body.adoc[] +include::{snippets}/update-my-profile/fail/invalid-request/response-fields.adoc[] + == 분야 관리 === 분야 정보 전체 조회 diff --git a/src/main/java/com/stumeet/server/common/annotation/validator/NullOrImageFile.java b/src/main/java/com/stumeet/server/common/annotation/validator/NullOrImageFile.java new file mode 100644 index 00000000..9ed05407 --- /dev/null +++ b/src/main/java/com/stumeet/server/common/annotation/validator/NullOrImageFile.java @@ -0,0 +1,18 @@ +package com.stumeet.server.common.annotation.validator; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.*; + +@Documented +@Constraint(validatedBy = NullOrImageFileValidator.class) +@Target({ElementType.FIELD }) +@Retention(RetentionPolicy.RUNTIME) +public @interface NullOrImageFile { + String message() default "값이 전달되지 않거나 이미지 파일이어야 합니다."; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/src/main/java/com/stumeet/server/common/annotation/validator/NullOrImageFileValidator.java b/src/main/java/com/stumeet/server/common/annotation/validator/NullOrImageFileValidator.java new file mode 100644 index 00000000..ffad078a --- /dev/null +++ b/src/main/java/com/stumeet/server/common/annotation/validator/NullOrImageFileValidator.java @@ -0,0 +1,17 @@ +package com.stumeet.server.common.annotation.validator; + +import com.stumeet.server.common.util.FileUtil; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import org.springframework.web.multipart.MultipartFile; + +public class NullOrImageFileValidator implements ConstraintValidator { + @Override + public boolean isValid(MultipartFile value, ConstraintValidatorContext context) { + if (value == null) { + return true; + } + + return FileUtil.isImageFile(value.getOriginalFilename()); + } +} diff --git a/src/main/java/com/stumeet/server/common/annotation/validator/NullOrNotBlank.java b/src/main/java/com/stumeet/server/common/annotation/validator/NullOrNotBlank.java new file mode 100644 index 00000000..0bc976da --- /dev/null +++ b/src/main/java/com/stumeet/server/common/annotation/validator/NullOrNotBlank.java @@ -0,0 +1,18 @@ +package com.stumeet.server.common.annotation.validator; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.*; + +@Documented +@Constraint(validatedBy = NullOrNotBlankValidator.class) +@Target({ElementType.FIELD }) +@Retention(RetentionPolicy.RUNTIME) +public @interface NullOrNotBlank { + String message() default "값이 전달되지 않거나 빈 문자열이 아니어야 합니다."; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/src/main/java/com/stumeet/server/common/annotation/validator/NullOrNotBlankValidator.java b/src/main/java/com/stumeet/server/common/annotation/validator/NullOrNotBlankValidator.java new file mode 100644 index 00000000..4653e7cf --- /dev/null +++ b/src/main/java/com/stumeet/server/common/annotation/validator/NullOrNotBlankValidator.java @@ -0,0 +1,16 @@ +package com.stumeet.server.common.annotation.validator; + +import io.micrometer.common.util.StringUtils; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +public class NullOrNotBlankValidator implements ConstraintValidator { + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + if (value == null) { + return true; + } + + return StringUtils.isNotBlank(value); + } +} diff --git a/src/main/java/com/stumeet/server/common/annotation/validator/NullOrPositive.java b/src/main/java/com/stumeet/server/common/annotation/validator/NullOrPositive.java new file mode 100644 index 00000000..00b83781 --- /dev/null +++ b/src/main/java/com/stumeet/server/common/annotation/validator/NullOrPositive.java @@ -0,0 +1,18 @@ +package com.stumeet.server.common.annotation.validator; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.*; + +@Documented +@Constraint(validatedBy = NullOrPositiveValidator.class) +@Target({ElementType.FIELD }) +@Retention(RetentionPolicy.RUNTIME) +public @interface NullOrPositive { + String message() default "값이 전달되지 않거나 양수여야 합니다."; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/src/main/java/com/stumeet/server/common/annotation/validator/NullOrPositiveValidator.java b/src/main/java/com/stumeet/server/common/annotation/validator/NullOrPositiveValidator.java new file mode 100644 index 00000000..9a0a7f7b --- /dev/null +++ b/src/main/java/com/stumeet/server/common/annotation/validator/NullOrPositiveValidator.java @@ -0,0 +1,15 @@ +package com.stumeet.server.common.annotation.validator; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +public class NullOrPositiveValidator implements ConstraintValidator { + @Override + public boolean isValid(Long value, ConstraintValidatorContext context) { + if (value == null) { + return true; + } + + return value > 0; + } +} diff --git a/src/main/java/com/stumeet/server/common/annotation/validator/NullOrValidSize.java b/src/main/java/com/stumeet/server/common/annotation/validator/NullOrValidSize.java new file mode 100644 index 00000000..f84dbe48 --- /dev/null +++ b/src/main/java/com/stumeet/server/common/annotation/validator/NullOrValidSize.java @@ -0,0 +1,22 @@ +package com.stumeet.server.common.annotation.validator; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.*; + + +@Documented +@Constraint(validatedBy = NullOrValidSizeValidator.class) +@Target({ElementType.FIELD }) +@Retention(RetentionPolicy.RUNTIME) +public @interface NullOrValidSize { + String message() default "값이 전달되지 않거나 유효한 크기여야 합니다."; + + Class[] groups() default {}; + + Class[] payload() default {}; + + int min() default 0; + int max() default 255; +} diff --git a/src/main/java/com/stumeet/server/common/annotation/validator/NullOrValidSizeValidator.java b/src/main/java/com/stumeet/server/common/annotation/validator/NullOrValidSizeValidator.java new file mode 100644 index 00000000..ac5c5eca --- /dev/null +++ b/src/main/java/com/stumeet/server/common/annotation/validator/NullOrValidSizeValidator.java @@ -0,0 +1,23 @@ +package com.stumeet.server.common.annotation.validator; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +public class NullOrValidSizeValidator implements ConstraintValidator { + private int min; + private int max; + + @Override + public void initialize(NullOrValidSize constraintAnnotation) { + min = constraintAnnotation.min(); + max = constraintAnnotation.max(); + } + + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + if (value == null) { + return true; + } + return value.length() >= min && value.length() <= max; + } +} diff --git a/src/main/java/com/stumeet/server/common/auth/config/SecurityConfig.java b/src/main/java/com/stumeet/server/common/auth/config/SecurityConfig.java index 0eb66a6f..4a96d6b1 100644 --- a/src/main/java/com/stumeet/server/common/auth/config/SecurityConfig.java +++ b/src/main/java/com/stumeet/server/common/auth/config/SecurityConfig.java @@ -9,6 +9,7 @@ import com.stumeet.server.common.auth.service.JwtAuthenticationService; import com.stumeet.server.common.auth.service.OAuthAuthenticationProvider; import com.stumeet.server.common.token.JwtTokenProvider; +import com.stumeet.server.member.domain.UserRole; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -69,8 +70,9 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { auth.requestMatchers(HttpMethod.POST, "/api/v1/tokens").permitAll(); auth.requestMatchers("/h2-console/**").permitAll(); auth.requestMatchers("/docs/**").permitAll(); - auth.requestMatchers("/api/v1/signup").hasAnyAuthority("FIRST_LOGIN"); - auth.anyRequest().authenticated(); + auth.requestMatchers("/api/v1/signup").hasAnyAuthority(UserRole.FIRST_LOGIN.toString()); + auth.requestMatchers("/api/v1/professions").hasAnyAuthority(UserRole.FIRST_LOGIN.toString(), UserRole.MEMBER.toString()); + auth.anyRequest().hasAnyAuthority(UserRole.MEMBER.toString()); }); http.securityContext(securityContext -> securityContext.securityContextRepository(securityContextRepository())); diff --git a/src/main/java/com/stumeet/server/common/util/FileUtil.java b/src/main/java/com/stumeet/server/common/util/FileUtil.java index 8548d9df..08bcb31a 100644 --- a/src/main/java/com/stumeet/server/common/util/FileUtil.java +++ b/src/main/java/com/stumeet/server/common/util/FileUtil.java @@ -29,21 +29,31 @@ public static String getContentType(String fileName) { throw new BusinessException(ErrorCode.INVALID_IMAGE_EXCEPTION); } - String contentType = fileName - .substring(fileName.lastIndexOf(".") + 1) - .toLowerCase(Locale.ROOT); + String contentType = extractContentType(fileName); - if (!VALID_CONTENT_TYPES.contains(contentType)) { + if (!VALID_CONTENT_TYPES.contains(contentType)) { throw new BusinessException(ErrorCode.INVALID_FILE_EXTENSION_EXCEPTION); } return contentType; } - public static String createFileName(String directoryPath, String fileName) { + private static String extractContentType(String fileName) { + return fileName + .substring(fileName.lastIndexOf(".") + 1) + .toLowerCase(Locale.ROOT); + } + + public static String createFileName(String directoryPath, String fileName) { String dateTime = LocalDateTime.now() .format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")); return String.format("%s/%s%s-%s", directoryPath, dateTime, UUID.randomUUID(), fileName); } + + public static boolean isImageFile(String fileName) { + String contentType = extractContentType(fileName); + + return VALID_CONTENT_TYPES.contains(contentType); + } } diff --git a/src/main/java/com/stumeet/server/member/adapter/in/web/MemberAuthApi.java b/src/main/java/com/stumeet/server/member/adapter/in/web/MemberAuthApi.java index 58aa855a..dd41a8c4 100644 --- a/src/main/java/com/stumeet/server/member/adapter/in/web/MemberAuthApi.java +++ b/src/main/java/com/stumeet/server/member/adapter/in/web/MemberAuthApi.java @@ -4,9 +4,10 @@ import com.stumeet.server.common.auth.model.LoginMember; import com.stumeet.server.common.model.ApiResponse; import com.stumeet.server.member.adapter.in.web.response.TokenResponse; -import com.stumeet.server.member.application.port.in.MemberAuthUseCase; -import com.stumeet.server.member.application.port.in.MemberSignupCommand; -import com.stumeet.server.member.application.port.in.TokenRenewCommand; +import com.stumeet.server.member.application.port.in.MemberProfileUseCase; +import com.stumeet.server.member.application.port.in.MemberTokenUseCase; +import com.stumeet.server.member.application.port.in.command.MemberSignupCommand; +import com.stumeet.server.member.application.port.in.command.TokenRenewCommand; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; @@ -21,14 +22,15 @@ @RequiredArgsConstructor public class MemberAuthApi { - private final MemberAuthUseCase memberAuthUseCase; + private final MemberTokenUseCase memberTokenUseCase; + private final MemberProfileUseCase memberProfileUseCase; @PostMapping("/signup") public ResponseEntity> signup( @AuthenticationPrincipal LoginMember loginMember, @Valid MemberSignupCommand request ) { - memberAuthUseCase.signup(loginMember.getMember(), request); + memberProfileUseCase.signup(loginMember.getMember(), request); return new ResponseEntity<>( ApiResponse.success(HttpStatus.OK.value(), "회원가입에 성공했습니다."), @@ -40,7 +42,7 @@ public ResponseEntity> signup( public ResponseEntity> renewAccessToken( @RequestBody @Valid TokenRenewCommand request ) { - TokenResponse response = memberAuthUseCase.renewAccessToken(request); + TokenResponse response = memberTokenUseCase.renewAccessToken(request); return new ResponseEntity<>( ApiResponse.success(HttpStatus.OK.value(), "액세스 토큰 재발급에 성공했습니다.", response), diff --git a/src/main/java/com/stumeet/server/member/adapter/in/web/MemberProfileApi.java b/src/main/java/com/stumeet/server/member/adapter/in/web/MemberProfileApi.java new file mode 100644 index 00000000..ee56e226 --- /dev/null +++ b/src/main/java/com/stumeet/server/member/adapter/in/web/MemberProfileApi.java @@ -0,0 +1,34 @@ +package com.stumeet.server.member.adapter.in.web; + +import com.stumeet.server.common.annotation.WebAdapter; +import com.stumeet.server.common.auth.model.LoginMember; +import com.stumeet.server.common.model.ApiResponse; +import com.stumeet.server.member.application.port.in.MemberProfileUseCase; +import com.stumeet.server.member.application.port.in.command.MemberUpdateCommand; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +@WebAdapter +@RequestMapping("/api/v1/members") +@RequiredArgsConstructor +public class MemberProfileApi { + + private final MemberProfileUseCase memberProfileUseCase; + + @PatchMapping("/me") + public ResponseEntity> updateMyProfile( + @AuthenticationPrincipal LoginMember member, + @Valid MemberUpdateCommand request + ) { + memberProfileUseCase.updateProfile(member.getMember(), request); + return new ResponseEntity<>( + ApiResponse.success(HttpStatus.OK.value(), "내 프로필 수정에 성공했습니다."), + HttpStatus.OK + ); + } +} diff --git a/src/main/java/com/stumeet/server/member/adapter/out/persistence/MemberPersistenceAdapter.java b/src/main/java/com/stumeet/server/member/adapter/out/persistence/MemberPersistenceAdapter.java index 5866900e..2d4bf852 100644 --- a/src/main/java/com/stumeet/server/member/adapter/out/persistence/MemberPersistenceAdapter.java +++ b/src/main/java/com/stumeet/server/member/adapter/out/persistence/MemberPersistenceAdapter.java @@ -21,6 +21,11 @@ public Member save(Member member) { return memberMapper.toDomain(entity); } + @Override + public void update(Member member) { + save(member); + } + @Override public Member getByOAuthProviderId(String oAuthProviderId, OAuthProvider provider) { return memberMapper.toDomain( diff --git a/src/main/java/com/stumeet/server/member/application/port/in/MemberProfileUseCase.java b/src/main/java/com/stumeet/server/member/application/port/in/MemberProfileUseCase.java new file mode 100644 index 00000000..821dc99a --- /dev/null +++ b/src/main/java/com/stumeet/server/member/application/port/in/MemberProfileUseCase.java @@ -0,0 +1,11 @@ +package com.stumeet.server.member.application.port.in; + +import com.stumeet.server.member.application.port.in.command.MemberSignupCommand; +import com.stumeet.server.member.application.port.in.command.MemberUpdateCommand; +import com.stumeet.server.member.domain.Member; + +public interface MemberProfileUseCase { + void signup(Member member, MemberSignupCommand request); + + void updateProfile(Member member, MemberUpdateCommand request); +} diff --git a/src/main/java/com/stumeet/server/member/application/port/in/MemberAuthUseCase.java b/src/main/java/com/stumeet/server/member/application/port/in/MemberTokenUseCase.java similarity index 57% rename from src/main/java/com/stumeet/server/member/application/port/in/MemberAuthUseCase.java rename to src/main/java/com/stumeet/server/member/application/port/in/MemberTokenUseCase.java index cfb1fa1a..510f686f 100644 --- a/src/main/java/com/stumeet/server/member/application/port/in/MemberAuthUseCase.java +++ b/src/main/java/com/stumeet/server/member/application/port/in/MemberTokenUseCase.java @@ -1,11 +1,11 @@ package com.stumeet.server.member.application.port.in; import com.stumeet.server.member.adapter.in.web.response.TokenResponse; -import com.stumeet.server.member.domain.Member; +import com.stumeet.server.member.application.port.in.command.TokenRenewCommand; + +public interface MemberTokenUseCase { -public interface MemberAuthUseCase { - void signup(Member member, MemberSignupCommand request); TokenResponse renewAccessToken(TokenRenewCommand request); } diff --git a/src/main/java/com/stumeet/server/member/application/port/in/command/MemberProfileCommand.java b/src/main/java/com/stumeet/server/member/application/port/in/command/MemberProfileCommand.java new file mode 100644 index 00000000..60706d57 --- /dev/null +++ b/src/main/java/com/stumeet/server/member/application/port/in/command/MemberProfileCommand.java @@ -0,0 +1,14 @@ +package com.stumeet.server.member.application.port.in.command; + +import com.stumeet.server.profession.domain.Profession; +import lombok.Builder; + + +@Builder +public record MemberProfileCommand( + Profession profession, + String url, + String nickname, + String region +) { +} diff --git a/src/main/java/com/stumeet/server/member/application/port/in/MemberSignupCommand.java b/src/main/java/com/stumeet/server/member/application/port/in/command/MemberSignupCommand.java similarity index 76% rename from src/main/java/com/stumeet/server/member/application/port/in/MemberSignupCommand.java rename to src/main/java/com/stumeet/server/member/application/port/in/command/MemberSignupCommand.java index ab2ce88a..69853fc5 100644 --- a/src/main/java/com/stumeet/server/member/application/port/in/MemberSignupCommand.java +++ b/src/main/java/com/stumeet/server/member/application/port/in/command/MemberSignupCommand.java @@ -1,7 +1,8 @@ -package com.stumeet.server.member.application.port.in; +package com.stumeet.server.member.application.port.in.command; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.Size; import org.springframework.web.multipart.MultipartFile; @@ -16,7 +17,7 @@ public record MemberSignupCommand( @NotBlank(message = "지역을 입력해주세요") String region, - @NotNull(message = "분야를 선택해주세요") + @Positive(message = "분야를 선택해주세요") Long profession ) { } diff --git a/src/main/java/com/stumeet/server/member/application/port/in/command/MemberUpdateCommand.java b/src/main/java/com/stumeet/server/member/application/port/in/command/MemberUpdateCommand.java new file mode 100644 index 00000000..3912e569 --- /dev/null +++ b/src/main/java/com/stumeet/server/member/application/port/in/command/MemberUpdateCommand.java @@ -0,0 +1,22 @@ +package com.stumeet.server.member.application.port.in.command; + +import com.stumeet.server.common.annotation.validator.NullOrImageFile; +import com.stumeet.server.common.annotation.validator.NullOrNotBlank; +import com.stumeet.server.common.annotation.validator.NullOrPositive; +import com.stumeet.server.common.annotation.validator.NullOrValidSize; +import org.springframework.web.multipart.MultipartFile; + +public record MemberUpdateCommand( + @NullOrImageFile(message = "이미지 파일을 첨부해주세요") + MultipartFile image, + + @NullOrValidSize(min = 2, max = 10, message = "닉네임을 2 ~ 10자로 입력해주세요") + String nickname, + + @NullOrNotBlank(message = "지역을 입력해주세요") + String region, + + @NullOrPositive(message = "존재하지 않는 분야입니다.") + Long profession +) { +} diff --git a/src/main/java/com/stumeet/server/member/application/port/in/TokenRenewCommand.java b/src/main/java/com/stumeet/server/member/application/port/in/command/TokenRenewCommand.java similarity index 82% rename from src/main/java/com/stumeet/server/member/application/port/in/TokenRenewCommand.java rename to src/main/java/com/stumeet/server/member/application/port/in/command/TokenRenewCommand.java index 22f3edee..4f88b15a 100644 --- a/src/main/java/com/stumeet/server/member/application/port/in/TokenRenewCommand.java +++ b/src/main/java/com/stumeet/server/member/application/port/in/command/TokenRenewCommand.java @@ -1,4 +1,4 @@ -package com.stumeet.server.member.application.port.in; +package com.stumeet.server.member.application.port.in.command; import jakarta.validation.constraints.NotBlank; diff --git a/src/main/java/com/stumeet/server/member/application/port/out/MemberCommandPort.java b/src/main/java/com/stumeet/server/member/application/port/out/MemberCommandPort.java index 33655c55..b92a4806 100644 --- a/src/main/java/com/stumeet/server/member/application/port/out/MemberCommandPort.java +++ b/src/main/java/com/stumeet/server/member/application/port/out/MemberCommandPort.java @@ -6,4 +6,5 @@ public interface MemberCommandPort { Member save(Member member); + void update(Member member); } diff --git a/src/main/java/com/stumeet/server/member/application/service/MemberProfileService.java b/src/main/java/com/stumeet/server/member/application/service/MemberProfileService.java new file mode 100644 index 00000000..b1e214e8 --- /dev/null +++ b/src/main/java/com/stumeet/server/member/application/service/MemberProfileService.java @@ -0,0 +1,64 @@ +package com.stumeet.server.member.application.service; + +import com.stumeet.server.common.annotation.UseCase; +import com.stumeet.server.file.application.port.in.FileUploadUseCase; +import com.stumeet.server.member.application.port.in.MemberProfileUseCase; +import com.stumeet.server.member.application.port.in.command.MemberProfileCommand; +import com.stumeet.server.member.application.port.in.command.MemberSignupCommand; +import com.stumeet.server.member.application.port.in.command.MemberUpdateCommand; +import com.stumeet.server.member.application.port.out.MemberCommandPort; +import com.stumeet.server.member.domain.Member; +import com.stumeet.server.profession.application.port.in.ProfessionQueryUseCase; +import com.stumeet.server.profession.domain.Profession; +import lombok.RequiredArgsConstructor; +import org.springframework.transaction.annotation.Transactional; + +@UseCase +@RequiredArgsConstructor +@Transactional +public class MemberProfileService implements MemberProfileUseCase { + + private final ProfessionQueryUseCase professionQueryUseCase; + private final FileUploadUseCase fileUploadUseCase; + private final MemberCommandPort memberCommandPort; + + @Override + public void signup(Member member, MemberSignupCommand request) { + Profession profession = professionQueryUseCase.getById(request.profession()); + String url = fileUploadUseCase.uploadUserProfileImage(member.getId(), request.image()).url(); + + MemberProfileCommand command = MemberProfileCommand.builder() + .profession(profession) + .url(url) + .nickname(request.nickname()) + .region(request.region()) + .build(); + + member.signup(command); + + memberCommandPort.save(member); + } + + @Override + public void updateProfile(Member member, MemberUpdateCommand request) { + Profession profession = request.profession() != null ? + professionQueryUseCase.getById(request.profession()) : + null; + + String url = request.image() != null ? + fileUploadUseCase.uploadUserProfileImage(member.getId(), request.image()).url() : + null; + + MemberProfileCommand command = MemberProfileCommand.builder() + .profession(profession) + .url(url) + .nickname(request.nickname()) + .region(request.region()) + .build(); + + member.updateProfile(command); + + memberCommandPort.update(member); + } + +} diff --git a/src/main/java/com/stumeet/server/member/application/service/MemberAuthService.java b/src/main/java/com/stumeet/server/member/application/service/MemberTokenService.java similarity index 55% rename from src/main/java/com/stumeet/server/member/application/service/MemberAuthService.java rename to src/main/java/com/stumeet/server/member/application/service/MemberTokenService.java index 885c8412..7399dd77 100644 --- a/src/main/java/com/stumeet/server/member/application/service/MemberAuthService.java +++ b/src/main/java/com/stumeet/server/member/application/service/MemberTokenService.java @@ -4,41 +4,22 @@ import com.stumeet.server.common.auth.model.LoginMember; import com.stumeet.server.common.token.JwtTokenProvider; import com.stumeet.server.common.token.service.RefreshTokenService; -import com.stumeet.server.file.application.port.in.FileUploadUseCase; -import com.stumeet.server.file.application.port.out.FileUrl; import com.stumeet.server.member.adapter.in.web.response.TokenResponse; -import com.stumeet.server.member.application.port.in.MemberAuthUseCase; -import com.stumeet.server.member.application.port.in.MemberSignupCommand; -import com.stumeet.server.member.application.port.in.TokenRenewCommand; -import com.stumeet.server.member.application.port.out.MemberCommandPort; +import com.stumeet.server.member.application.port.in.*; +import com.stumeet.server.member.application.port.in.command.TokenRenewCommand; import com.stumeet.server.member.application.port.out.MemberQueryPort; import com.stumeet.server.member.domain.Member; -import com.stumeet.server.profession.application.port.in.ProfessionQueryUseCase; -import com.stumeet.server.profession.domain.Profession; import lombok.RequiredArgsConstructor; import org.springframework.transaction.annotation.Transactional; @UseCase @Transactional @RequiredArgsConstructor -public class MemberAuthService implements MemberAuthUseCase { +public class MemberTokenService implements MemberTokenUseCase { - private final MemberCommandPort memberCommandPort; private final MemberQueryPort memberQueryPort; - private final FileUploadUseCase fileUploadUseCase; private final RefreshTokenService refreshTokenService; private final JwtTokenProvider jwtTokenProvider; - private final ProfessionQueryUseCase professionQueryUseCase; - - @Override - public void signup(Member member, MemberSignupCommand request) { - Profession profession = professionQueryUseCase.getById(request.profession()); - FileUrl url = fileUploadUseCase.uploadUserProfileImage(member.getId(), request.image()); - - member.registerWithAdditionalDetails(request, url, profession); - - memberCommandPort.save(member); - } @Override public TokenResponse renewAccessToken(TokenRenewCommand request) { diff --git a/src/main/java/com/stumeet/server/member/domain/Member.java b/src/main/java/com/stumeet/server/member/domain/Member.java index 0d48dd11..2c058409 100644 --- a/src/main/java/com/stumeet/server/member/domain/Member.java +++ b/src/main/java/com/stumeet/server/member/domain/Member.java @@ -1,7 +1,6 @@ package com.stumeet.server.member.domain; -import com.stumeet.server.file.application.port.out.FileUrl; -import com.stumeet.server.member.application.port.in.MemberSignupCommand; +import com.stumeet.server.member.application.port.in.command.MemberProfileCommand; import com.stumeet.server.profession.domain.Profession; import lombok.AccessLevel; import lombok.AllArgsConstructor; @@ -28,11 +27,18 @@ public class Member { private UserRole role; - public void registerWithAdditionalDetails(MemberSignupCommand request, FileUrl profileImage, Profession profession) { - this.image = profileImage.url(); - this.name = request.nickname(); - this.region = request.region(); - this.profession = profession; + public void signup(MemberProfileCommand command) { + this.image = command.url(); + this.name = command.nickname(); + this.region = command.region(); + this.profession = command.profession(); this.role = UserRole.MEMBER; } + + public void updateProfile(MemberProfileCommand command) { + this.image = command.url() == null ? this.image : command.url(); + this.name = command.nickname() == null ? this.name : command.nickname(); + this.region = command.region() == null ? this.region : command.region(); + this.profession = command.profession() == null ? this.profession : command.profession(); + } } diff --git a/src/test/java/com/stumeet/server/member/adapter/in/web/MemberAuthApiTest.java b/src/test/java/com/stumeet/server/member/adapter/in/web/MemberAuthApiTest.java index 622708e6..21b6f425 100644 --- a/src/test/java/com/stumeet/server/member/adapter/in/web/MemberAuthApiTest.java +++ b/src/test/java/com/stumeet/server/member/adapter/in/web/MemberAuthApiTest.java @@ -4,8 +4,8 @@ import com.stumeet.server.helper.WithMockMember; import com.stumeet.server.member.adapter.out.persistence.JpaMemberRepository; import com.stumeet.server.member.adapter.out.persistence.MemberJpaEntity; -import com.stumeet.server.member.application.port.in.MemberSignupCommand; -import com.stumeet.server.member.application.port.in.TokenRenewCommand; +import com.stumeet.server.member.application.port.in.command.MemberSignupCommand; +import com.stumeet.server.member.application.port.in.command.TokenRenewCommand; import com.stumeet.server.member.domain.UserRole; import com.stumeet.server.stub.MemberStub; import com.stumeet.server.stub.TokenStub; @@ -305,7 +305,7 @@ void invalidRequestTest() throws Exception { @DisplayName("[실패] 전달한 분야 정보가 존재하지 않으면 회원가입에 실패합니다.") void notExistsProfessionTest() throws Exception { MemberSignupCommand request = MemberStub.getMemberSignupCommand(); - String notExistsProfession = "0"; + String notExistsProfession = "9999"; mockMvc.perform(multipart(path) .file((MockMultipartFile) request.image()) diff --git a/src/test/java/com/stumeet/server/member/adapter/in/web/MemberProfileApiTest.java b/src/test/java/com/stumeet/server/member/adapter/in/web/MemberProfileApiTest.java new file mode 100644 index 00000000..f0e38065 --- /dev/null +++ b/src/test/java/com/stumeet/server/member/adapter/in/web/MemberProfileApiTest.java @@ -0,0 +1,115 @@ +package com.stumeet.server.member.adapter.in.web; + +import com.stumeet.server.common.auth.model.AuthenticationHeader; +import com.stumeet.server.helper.WithMockMember; +import com.stumeet.server.member.adapter.out.persistence.JpaMemberRepository; +import com.stumeet.server.member.application.port.in.command.MemberUpdateCommand; +import com.stumeet.server.stub.MemberStub; +import com.stumeet.server.stub.TokenStub; +import com.stumeet.server.template.ApiTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.web.servlet.request.RequestPostProcessor; +import org.springframework.transaction.annotation.Transactional; + +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.operation.preprocess.Preprocessors.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.request.RequestDocumentation.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@Transactional +class MemberProfileApiTest extends ApiTest { + + @Autowired + private JpaMemberRepository memberRepository; + + @Nested + @DisplayName("내 프로필 수정") + class UpdateMyProfile { + + private final String path = "/api/v1/members/me"; + + @BeforeEach + void setUp() { + memberRepository.save(MemberStub.getMemberEntity()); + } + + @Test + @WithMockMember + @DisplayName("[성공] 회원 프로필 수정에 성공한다.") + void successTest() throws Exception { + MemberUpdateCommand request = MemberStub.getMemberUpdateCommand(); + + RequestPostProcessor patchMethod = http -> { + http.setMethod("PATCH"); + return http; + }; + mockMvc.perform(multipart(path) + .file((MockMultipartFile) request.image()) + .with(patchMethod) + .header(AuthenticationHeader.ACCESS_TOKEN.getName(), TokenStub.getMockAccessToken()) + .queryParam("nickname", request.nickname()) + .queryParam("region", request.region()) + .queryParam("profession", String.valueOf(request.profession()))) + .andExpect(status().isOk()) + .andDo(document("update-my-profile/success", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders(headerWithName(AuthenticationHeader.ACCESS_TOKEN.getName()).description("서버로부터 전달받은 액세스 토큰")), + requestParts(partWithName("image").description("프로필 이미지 파일 (Optional)")), + queryParameters( + parameterWithName("nickname").description("닉네임 (Optional)"), + parameterWithName("region").description("지역 (Optional)"), + parameterWithName("profession").description("직업 ID (Optional)") + ), + responseFields( + fieldWithPath("code").description("응답 상태"), + fieldWithPath("message").description("응답 메시지") + ))); + } + + @Test + @WithMockMember + @DisplayName("[실패] 요청이 유효하지 않은 경우 프로필 수정에 실패한다.") + void invalidRequestTest() throws Exception { + MemberUpdateCommand request = MemberStub.getInvalidMemberUpdateCommand(); + + RequestPostProcessor patchMethod = http -> { + http.setMethod("PATCH"); + return http; + }; + mockMvc.perform(multipart(path) + .file((MockMultipartFile) request.image()) + .with(patchMethod) + .header(AuthenticationHeader.ACCESS_TOKEN.getName(), TokenStub.getMockAccessToken()) + .queryParam("nickname", request.nickname()) + .queryParam("region", request.region()) + .queryParam("profession", String.valueOf(request.profession()))) + .andExpect(status().isBadRequest()) + .andDo(document("update-my-profile/fail/invalid-request", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders(headerWithName(AuthenticationHeader.ACCESS_TOKEN.getName()).description("서버로부터 전달받은 액세스 토큰")), + requestParts(partWithName("image").description("프로필 이미지 파일 (Optional)")), + queryParameters( + parameterWithName("nickname").description("닉네임 (Optional)"), + parameterWithName("region").description("지역 (Optional)"), + parameterWithName("profession").description("직업 ID (Optional)") + ), + responseFields( + fieldWithPath("code").description("응답 상태"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data[].message").description("유효하지 않은 요청에 대한 상세 메시지") + ))); + } + } +} \ No newline at end of file diff --git a/src/test/java/com/stumeet/server/member/application/service/MemberProfileServiceTest.java b/src/test/java/com/stumeet/server/member/application/service/MemberProfileServiceTest.java new file mode 100644 index 00000000..70d64ee1 --- /dev/null +++ b/src/test/java/com/stumeet/server/member/application/service/MemberProfileServiceTest.java @@ -0,0 +1,57 @@ +package com.stumeet.server.member.application.service; + +import com.stumeet.server.file.application.port.in.FileUploadUseCase; +import com.stumeet.server.member.application.port.in.command.MemberUpdateCommand; +import com.stumeet.server.member.application.port.out.MemberCommandPort; +import com.stumeet.server.member.domain.Member; +import com.stumeet.server.profession.application.port.in.ProfessionQueryUseCase; +import com.stumeet.server.stub.FileStub; +import com.stumeet.server.stub.MemberStub; +import com.stumeet.server.stub.ProfessionStub; +import com.stumeet.server.template.UnitTest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; + + +class MemberProfileServiceTest extends UnitTest { + + @InjectMocks + private MemberProfileService memberProfileService; + + @Mock + private MemberCommandPort memberCommandPort; + + @Mock + private FileUploadUseCase fileUploadUseCase; + + @Mock + private ProfessionQueryUseCase professionQueryUseCase; + + @Nested + @DisplayName("멤버 프로필 업데이트") + class UpdateProfile { + + @Test + @DisplayName("[성공] 멤버 프로필 업데이트에 성공한다.") + void successTest() { + MemberUpdateCommand command = MemberStub.getMemberUpdateCommand(); + Member member = MemberStub.getMember(); + + given(professionQueryUseCase.getById(command.profession())) + .willReturn(ProfessionStub.getProfession()); + given(fileUploadUseCase.uploadUserProfileImage(member.getId(), command.image())) + .willReturn(FileStub.getFileUrl()); + + memberProfileService.updateProfile(member, command); + + then(memberCommandPort).should().update(member); + } + } + +} \ No newline at end of file diff --git a/src/test/java/com/stumeet/server/stub/FileStub.java b/src/test/java/com/stumeet/server/stub/FileStub.java new file mode 100644 index 00000000..6bba814a --- /dev/null +++ b/src/test/java/com/stumeet/server/stub/FileStub.java @@ -0,0 +1,14 @@ +package com.stumeet.server.stub; + +import com.stumeet.server.file.application.port.out.FileUrl; + +public class FileStub { + private FileStub() { + + } + + + public static FileUrl getFileUrl() { + return new FileUrl("http://localhost:4572/user/1/profile/2024030416531039839905-b7e8-4ad3-9552-7d9cbc01cb14-test.jpg"); + } +} diff --git a/src/test/java/com/stumeet/server/stub/MemberStub.java b/src/test/java/com/stumeet/server/stub/MemberStub.java index 6a51e41b..b602e344 100644 --- a/src/test/java/com/stumeet/server/stub/MemberStub.java +++ b/src/test/java/com/stumeet/server/stub/MemberStub.java @@ -1,7 +1,8 @@ package com.stumeet.server.stub; import com.stumeet.server.member.adapter.out.persistence.MemberJpaEntity; -import com.stumeet.server.member.application.port.in.MemberSignupCommand; +import com.stumeet.server.member.application.port.in.command.MemberSignupCommand; +import com.stumeet.server.member.application.port.in.command.MemberUpdateCommand; import com.stumeet.server.member.domain.AuthType; import com.stumeet.server.member.domain.Member; import com.stumeet.server.member.domain.UserRole; @@ -51,4 +52,27 @@ public static Member getMember(WithMockMember annotation) { .image(null) .build(); } + + public static Member getMember() { + return Member.builder() + .id(1L) + .name("test") + .role(UserRole.MEMBER) + .authType(AuthType.OAUTH) + .sugarContents(0.0) + .profession(null) + .region(null) + .image(null) + .build(); + } + + public static MemberUpdateCommand getMemberUpdateCommand() { + MockMultipartFile image = new MockMultipartFile("image", "test.jpg", "image/jpeg", "test".getBytes()); + return new MemberUpdateCommand(image, "test2", "서울", 1L); + } + + public static MemberUpdateCommand getInvalidMemberUpdateCommand() { + MockMultipartFile invalidImage = new MockMultipartFile("image", "test.jpa", "plain/text", "test".getBytes()); + return new MemberUpdateCommand(invalidImage, "닉", " ", -1L); + } } diff --git a/src/test/java/com/stumeet/server/stub/ProfessionStub.java b/src/test/java/com/stumeet/server/stub/ProfessionStub.java new file mode 100644 index 00000000..0c6b53ca --- /dev/null +++ b/src/test/java/com/stumeet/server/stub/ProfessionStub.java @@ -0,0 +1,17 @@ +package com.stumeet.server.stub; + +import com.stumeet.server.profession.domain.Profession; + +public class ProfessionStub { + + private ProfessionStub() { + } + + public static Profession getProfession() { + return Profession.builder() + .id(1L) + .name("경영사무") + .parent(null) + .build(); + } +}