diff --git a/build.gradle b/build.gradle index bad173c4..ea4663fa 100644 --- a/build.gradle +++ b/build.gradle @@ -36,6 +36,7 @@ dependencies { implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9' implementation 'io.jsonwebtoken:jjwt:0.12.6' implementation 'com.resend:resend-java:4.1.1' + implementation 'com.googlecode.libphonenumber:libphonenumber:9.0.9' compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' diff --git a/src/main/java/project/flipnote/common/security/jwt/JwtComponent.java b/src/main/java/project/flipnote/common/security/jwt/JwtComponent.java index d966861c..41bd9b07 100644 --- a/src/main/java/project/flipnote/common/security/jwt/JwtComponent.java +++ b/src/main/java/project/flipnote/common/security/jwt/JwtComponent.java @@ -60,6 +60,7 @@ private String generateToken(User user, Date expiration) { .subject(user.getEmail()) .id(String.valueOf(user.getId())) .claim(JwtConstants.ROLE, user.getRole().name()) + .claim(JwtConstants.TOKEN_VERSION, user.getTokenVersion()) .issuedAt(now) .expiration(expiration) .signWith(secretKey, Jwts.SIG.HS256) diff --git a/src/main/java/project/flipnote/common/util/PhoneUtil.java b/src/main/java/project/flipnote/common/util/PhoneUtil.java new file mode 100644 index 00000000..3e6158b2 --- /dev/null +++ b/src/main/java/project/flipnote/common/util/PhoneUtil.java @@ -0,0 +1,27 @@ +package project.flipnote.common.util; + +import com.google.i18n.phonenumbers.NumberParseException; +import com.google.i18n.phonenumbers.PhoneNumberUtil; +import com.google.i18n.phonenumbers.Phonenumber; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class PhoneUtil { + + private static final PhoneNumberUtil phoneUtil = PhoneNumberUtil.getInstance(); + + public static String normalize(String phone) { + if (phone == null) { + return null; + } + + try { + Phonenumber.PhoneNumber phoneNumber = phoneUtil.parse(phone, "KR"); + return phoneUtil.format(phoneNumber, PhoneNumberUtil.PhoneNumberFormat.E164); + } catch (NumberParseException e) { + throw new IllegalStateException("전화번호 정규화에 실패하였습니다. phone: " + phone, e); + } + } +} diff --git a/src/main/java/project/flipnote/common/validation/validator/PhoneConstraintValidator.java b/src/main/java/project/flipnote/common/validation/validator/PhoneConstraintValidator.java index 636d294e..43f01845 100644 --- a/src/main/java/project/flipnote/common/validation/validator/PhoneConstraintValidator.java +++ b/src/main/java/project/flipnote/common/validation/validator/PhoneConstraintValidator.java @@ -2,13 +2,17 @@ import java.util.Objects; +import com.google.i18n.phonenumbers.NumberParseException; +import com.google.i18n.phonenumbers.PhoneNumberUtil; +import com.google.i18n.phonenumbers.Phonenumber; + import jakarta.validation.ConstraintValidator; import jakarta.validation.ConstraintValidatorContext; import project.flipnote.common.validation.annotation.ValidPhone; public class PhoneConstraintValidator implements ConstraintValidator { - private static final String PHONE_PATTERN = "^010-\\d{4}-\\d{4}$"; + private final PhoneNumberUtil phoneUtil = PhoneNumberUtil.getInstance(); @Override public boolean isValid(String phone, ConstraintValidatorContext context) { @@ -16,6 +20,11 @@ public boolean isValid(String phone, ConstraintValidatorContext context) { return true; } - return phone.matches(PHONE_PATTERN); + try { + Phonenumber.PhoneNumber number = phoneUtil.parse(phone, "KR"); + return phoneUtil.isValidNumber(number); + } catch (NumberParseException e) { + return false; + } } } diff --git a/src/main/java/project/flipnote/user/controller/UserController.java b/src/main/java/project/flipnote/user/controller/UserController.java index 4a62aef8..ab489245 100644 --- a/src/main/java/project/flipnote/user/controller/UserController.java +++ b/src/main/java/project/flipnote/user/controller/UserController.java @@ -5,6 +5,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -14,6 +15,8 @@ import project.flipnote.common.security.dto.UserAuth; import project.flipnote.user.model.UserRegisterRequest; import project.flipnote.user.model.UserRegisterResponse; +import project.flipnote.user.model.UserUpdateRequest; +import project.flipnote.user.model.UserUpdateResponse; import project.flipnote.user.service.UserService; @RequiredArgsConstructor @@ -34,4 +37,13 @@ public ResponseEntity unregister(@AuthenticationPrincipal UserAuth userAut userService.unregister(userAuth.userId()); return ResponseEntity.noContent().build(); } + + @PutMapping + public ResponseEntity update( + @AuthenticationPrincipal UserAuth userAuth, + @Valid @RequestBody UserUpdateRequest req + ) { + UserUpdateResponse res = userService.update(userAuth.userId(), req); + return ResponseEntity.ok(res); + } } diff --git a/src/main/java/project/flipnote/user/entity/User.java b/src/main/java/project/flipnote/user/entity/User.java index 2fb95cc7..30ba719a 100644 --- a/src/main/java/project/flipnote/user/entity/User.java +++ b/src/main/java/project/flipnote/user/entity/User.java @@ -91,4 +91,11 @@ public void unregister() { public void increaseTokenVersion() { this.tokenVersion++; } + + public void update(String nickname, String phone, boolean smsAgree, String profileImageUrl) { + this.nickname = nickname; + this.phone = phone; + this.smsAgree = smsAgree; + this.profileImageUrl = profileImageUrl; + } } diff --git a/src/main/java/project/flipnote/user/model/UserRegisterRequest.java b/src/main/java/project/flipnote/user/model/UserRegisterRequest.java index 24e1cac5..4e175c24 100644 --- a/src/main/java/project/flipnote/user/model/UserRegisterRequest.java +++ b/src/main/java/project/flipnote/user/model/UserRegisterRequest.java @@ -3,6 +3,7 @@ import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import project.flipnote.common.util.PhoneUtil; import project.flipnote.common.validation.annotation.ValidPassword; import project.flipnote.common.validation.annotation.ValidPhone; @@ -28,7 +29,7 @@ public record UserRegisterRequest( String profileImageUrl ) { - public String getCleanedPhone() { - return phone == null ? null : phone.replaceAll("-", ""); + public String getNormalizedPhone() { + return PhoneUtil.normalize(phone); } } diff --git a/src/main/java/project/flipnote/user/model/UserUpdateRequest.java b/src/main/java/project/flipnote/user/model/UserUpdateRequest.java new file mode 100644 index 00000000..a12374a9 --- /dev/null +++ b/src/main/java/project/flipnote/user/model/UserUpdateRequest.java @@ -0,0 +1,25 @@ +package project.flipnote.user.model; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import project.flipnote.common.util.PhoneUtil; +import project.flipnote.common.validation.annotation.ValidPhone; + +public record UserUpdateRequest( + + @NotEmpty + String nickname, + + @ValidPhone + String phone, + + @NotNull + Boolean smsAgree, + + String profileImageUrl +) { + + public String getNormalizedPhone() { + return PhoneUtil.normalize(phone); + } +} diff --git a/src/main/java/project/flipnote/user/model/UserUpdateResponse.java b/src/main/java/project/flipnote/user/model/UserUpdateResponse.java new file mode 100644 index 00000000..be29cdc4 --- /dev/null +++ b/src/main/java/project/flipnote/user/model/UserUpdateResponse.java @@ -0,0 +1,18 @@ +package project.flipnote.user.model; + +import project.flipnote.user.entity.User; + +public record UserUpdateResponse( + Long userId, + String nickname, + String phone, + Boolean smsAgree, + String profileImageUrl +) { + + public static UserUpdateResponse from(User user) { + return new UserUpdateResponse( + user.getId(), user.getNickname(), user.getPhone(), user.isSmsAgree(), user.getProfileImageUrl() + ); + } +} diff --git a/src/main/java/project/flipnote/user/service/UserService.java b/src/main/java/project/flipnote/user/service/UserService.java index 9f1b63fd..3186954a 100644 --- a/src/main/java/project/flipnote/user/service/UserService.java +++ b/src/main/java/project/flipnote/user/service/UserService.java @@ -15,6 +15,8 @@ import project.flipnote.user.exception.UserErrorCode; import project.flipnote.user.model.UserRegisterRequest; import project.flipnote.user.model.UserRegisterResponse; +import project.flipnote.user.model.UserUpdateRequest; +import project.flipnote.user.model.UserUpdateResponse; import project.flipnote.user.repository.UserRepository; @RequiredArgsConstructor @@ -30,7 +32,7 @@ public class UserService { @Transactional public UserRegisterResponse register(UserRegisterRequest req) { String email = req.email(); - String phone = req.getCleanedPhone(); + String phone = req.getNormalizedPhone(); validateEmailDuplicate(email); validatePhoneDuplicate(phone); @@ -61,6 +63,20 @@ public void unregister(Long userId) { tokenVersionRedisRepository.deleteTokenVersion(userId); } + @Transactional + public UserUpdateResponse update(Long userId, UserUpdateRequest req) { + User user = findActiveUserById(userId); + + String phone = req.getNormalizedPhone(); + if (!Objects.equals(user.getPhone(), phone)) { + validatePhoneDuplicate(phone); + } + + user.update(req.nickname(), phone, req.smsAgree(), req.profileImageUrl()); + + return UserUpdateResponse.from(user); + } + private User findActiveUserById(Long userId) { return userRepository.findByIdAndStatus(userId, UserStatus.ACTIVE) .orElseThrow(() -> new BizException(UserErrorCode.USER_NOT_FOUND)); diff --git a/src/test/java/project/flipnote/common/validator/PhoneConstraintValidatorTest.java b/src/test/java/project/flipnote/common/validator/PhoneConstraintValidatorTest.java deleted file mode 100644 index c8a8ca34..00000000 --- a/src/test/java/project/flipnote/common/validator/PhoneConstraintValidatorTest.java +++ /dev/null @@ -1,71 +0,0 @@ -package project.flipnote.common.validator; - -import static org.assertj.core.api.Assertions.*; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import jakarta.validation.ConstraintValidatorContext; -import project.flipnote.common.validation.validator.PhoneConstraintValidator; - -@DisplayName("휴대폰 번호 유효성 검증기 단위 테스트") -@ExtendWith(MockitoExtension.class) -class PhoneConstraintValidatorTest { - - PhoneConstraintValidator validator = new PhoneConstraintValidator(); - - @Mock - ConstraintValidatorContext context; - - @Test - @DisplayName("010-1234-5678 형식은 true를 반환한다") - void validPhoneWithHyphens() { - assertThat(validator.isValid("010-1234-5678", context)).isTrue(); - assertThat(validator.isValid("010-4567-1238", context)).isTrue(); - } - - @Test - @DisplayName("null은 true를 반환한다") - void validPhoneNull() { - assertThat(validator.isValid(null, context)).isTrue(); - } - - @Test - @DisplayName("01012345678 형식은 false를 반환한다") - void validPhoneWithoutHyphens() { - assertThat(validator.isValid("01012345678", context)).isFalse(); - } - - @Test - @DisplayName("010-123-4567 등 잘못된 자리수는 false") - void invalidPhoneWrongDigits() { - assertThat(validator.isValid("010-123-4567", context)).isFalse(); - assertThat(validator.isValid("010-12345-6789", context)).isFalse(); - assertThat(validator.isValid("010-12345-678", context)).isFalse(); - assertThat(validator.isValid("010-12345-67890", context)).isFalse(); - } - - @Test - @DisplayName("010이 아닌 번호는 false") - void invalidPhoneNot010() { - assertThat(validator.isValid("011-1234-5678", context)).isFalse(); - assertThat(validator.isValid("019-1234-5678", context)).isFalse(); - } - - @Test - @DisplayName("숫자가 아닌 문자가 포함되면 false") - void invalidPhoneWithLetters() { - assertThat(validator.isValid("010-ABCD-5678", context)).isFalse(); - assertThat(validator.isValid("0101234abcd", context)).isFalse(); - } - - @Test - @DisplayName("빈 문자열은 false") - void invalidPhoneEmpty() { - assertThat(validator.isValid("", context)).isFalse(); - assertThat(validator.isValid(" ", context)).isFalse(); - } -} diff --git a/src/test/java/project/flipnote/user/service/UserServiceTest.java b/src/test/java/project/flipnote/user/service/UserServiceTest.java index d64ece9c..0bded745 100644 --- a/src/test/java/project/flipnote/user/service/UserServiceTest.java +++ b/src/test/java/project/flipnote/user/service/UserServiceTest.java @@ -6,7 +6,6 @@ import java.util.Optional; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -26,6 +25,8 @@ import project.flipnote.user.exception.UserErrorCode; import project.flipnote.user.model.UserRegisterRequest; import project.flipnote.user.model.UserRegisterResponse; +import project.flipnote.user.model.UserUpdateRequest; +import project.flipnote.user.model.UserUpdateResponse; import project.flipnote.user.repository.UserRepository; @DisplayName("회원 서비스 단위 테스트") @@ -172,4 +173,86 @@ void fail_userNotFound() { assertThat(exception.getErrorCode()).isEqualTo(UserErrorCode.USER_NOT_FOUND); } } + + @DisplayName("회원 정보 수정 테스트") + @Nested + class Update { + + @DisplayName("성공") + @Test + void success() { + User user = UserFixture.createActiveUser(); + UserUpdateRequest req = new UserUpdateRequest( + "새로운닉네임", "010-9876-5432", true, "new/image.jpg" + ); + String normalizedPhone = req.getNormalizedPhone(); + + given(userRepository.findByIdAndStatus(user.getId(), UserStatus.ACTIVE)).willReturn(Optional.of(user)); + given(userRepository.existsByPhone(normalizedPhone)).willReturn(false); + + UserUpdateResponse res = userService.update(user.getId(), req); + + assertThat(res.userId()).isEqualTo(user.getId()); + assertThat(res.nickname()).isEqualTo(req.nickname()); + assertThat(res.phone()).isEqualTo(normalizedPhone); + assertThat(res.smsAgree()).isEqualTo(req.smsAgree()); + assertThat(res.profileImageUrl()).isEqualTo(req.profileImageUrl()); + + verify(userRepository, times(1)).findByIdAndStatus(anyLong(), any(UserStatus.class)); + verify(userRepository, times(1)).existsByPhone(anyString()); + } + + @DisplayName("동일한 전화번호로 수정 시 성공") + @Test + void success_withSamePhone() { + User user = UserFixture.createActiveUser(); + UserUpdateRequest req = new UserUpdateRequest( + "새로운닉네임", user.getPhone(), true, "new/image.jpg" + ); + String normalizedPhone = req.getNormalizedPhone(); + + given(userRepository.findByIdAndStatus(user.getId(), UserStatus.ACTIVE)).willReturn(Optional.of(user)); + + UserUpdateResponse res = userService.update(user.getId(), req); + + assertThat(res.userId()).isEqualTo(user.getId()); + assertThat(res.nickname()).isEqualTo(req.nickname()); + assertThat(res.phone()).isEqualTo(normalizedPhone); + assertThat(res.smsAgree()).isEqualTo(req.smsAgree()); + assertThat(res.profileImageUrl()).isEqualTo(req.profileImageUrl()); + + verify(userRepository, never()).existsByPhone(anyString()); + } + + @DisplayName("존재하지 않는 회원 수정 시 예외 발생") + @Test + void fail_userNotFound() { + UserUpdateRequest req = new UserUpdateRequest( + "새로운닉네임", "010-9876-5432", true, "new/image.jpg" + ); + + given(userRepository.findByIdAndStatus(anyLong(), any(UserStatus.class))).willReturn(Optional.empty()); + + BizException exception = assertThrows(BizException.class, () -> userService.update(99L, req)); + + assertThat(exception.getErrorCode()).isEqualTo(UserErrorCode.USER_NOT_FOUND); + } + + @DisplayName("중복된 전화번호로 수정 시 예외 발생") + @Test + void fail_duplicatePhone() { + User user = UserFixture.createActiveUser(); + UserUpdateRequest req = new UserUpdateRequest( + "새로운닉네임", "010-9999-9999", true, "new/image.jpg" + ); + String duplicatePhone = req.getNormalizedPhone(); + + given(userRepository.findByIdAndStatus(user.getId(), UserStatus.ACTIVE)).willReturn(Optional.of(user)); + given(userRepository.existsByPhone(duplicatePhone)).willReturn(true); + + BizException exception = assertThrows(BizException.class, () -> userService.update(user.getId(), req)); + + assertThat(exception.getErrorCode()).isEqualTo(UserErrorCode.DUPLICATE_PHONE); + } + } }