diff --git a/framey-application/member-service/src/main/java/framey/member/domain/constant/NicknameConstant.java b/framey-application/member-service/src/main/java/framey/member/domain/constant/NicknameConstant.java new file mode 100644 index 0000000..e397788 --- /dev/null +++ b/framey-application/member-service/src/main/java/framey/member/domain/constant/NicknameConstant.java @@ -0,0 +1,16 @@ +package framey.member.domain.constant; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +import java.util.regex.Pattern; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class NicknameConstant { + public static final int MAX_NICKNAME_LENGTH = 10; + public static final Pattern EDGE_WHITESPACE = Pattern.compile( + "^[\\p{javaWhitespace}\\p{IsWhite_Space}]+|[\\p{javaWhitespace}\\p{IsWhite_Space}]+$" + ); + public static final String BASE_NICKNAME = "framey"; + public static final int SUFFIX_DIGITS = 4; +} diff --git a/framey-application/member-service/src/main/java/framey/member/domain/constant/Provider.java b/framey-application/member-service/src/main/java/framey/member/domain/constant/Provider.java new file mode 100644 index 0000000..7eedcf5 --- /dev/null +++ b/framey-application/member-service/src/main/java/framey/member/domain/constant/Provider.java @@ -0,0 +1,7 @@ +package framey.member.domain.constant; + +public enum Provider { + GOOGLE, + KAKAO, + APPLE +} diff --git a/framey-application/member-service/src/main/java/framey/member/domain/exception/MemberErrorCode.java b/framey-application/member-service/src/main/java/framey/member/domain/exception/MemberErrorCode.java new file mode 100644 index 0000000..af570d8 --- /dev/null +++ b/framey-application/member-service/src/main/java/framey/member/domain/exception/MemberErrorCode.java @@ -0,0 +1,17 @@ +package framey.member.domain.exception; + +import framey.common.response.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum MemberErrorCode implements ErrorCode { + NICKNAME_REQUIRED("MB-001", "닉네임을 입력해 주세요.", HttpStatus.BAD_REQUEST), + MAX_NICKNAME_LENGTH("MB-002", "닉네임은 10자 이내로 입력해 주세요.", HttpStatus.BAD_REQUEST); + + private final String value; + private final String message; + private final HttpStatus httpStatus; +} diff --git a/framey-application/member-service/src/main/java/framey/member/domain/model/Member.java b/framey-application/member-service/src/main/java/framey/member/domain/model/Member.java new file mode 100644 index 0000000..0932dbb --- /dev/null +++ b/framey-application/member-service/src/main/java/framey/member/domain/model/Member.java @@ -0,0 +1,112 @@ +package framey.member.domain.model; + +import framey.common.exception.BusinessException; +import framey.common.jpa.BaseEntity; +import framey.member.domain.constant.Provider; +import framey.member.domain.exception.MemberErrorCode; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Column; +import jakarta.persistence.Enumerated; +import jakarta.persistence.EnumType; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +import static framey.member.domain.constant.NicknameConstant.EDGE_WHITESPACE; +import static framey.member.domain.constant.NicknameConstant.MAX_NICKNAME_LENGTH; + +@Entity +@Table( + uniqueConstraints = { + @UniqueConstraint(name = "uk_member_provider", columnNames = {"provider", "provider_id"}), + @UniqueConstraint(name = "uk_member_nickname", columnNames = {"nickname"}), + @UniqueConstraint(name = "uk_member_email", columnNames = {"email"}), + } +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class Member extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long memberId; + + @Column(updatable = false, nullable = false) + @NotBlank + @Email + private String email; + + @Column(length = MAX_NICKNAME_LENGTH, nullable = false) + @NotBlank + private String nickname; + + @Column(updatable = false, unique = true, nullable = false) + private UUID entityId; + + @Column(length = 2048, nullable = false) + @NotBlank + private String profileImageUrl; + + @Column(updatable = false, nullable = false) + @NotBlank + private String providerId; + + @Enumerated(EnumType.STRING) + @Column(updatable = false, nullable = false) + @NotNull + private Provider provider; + + private Member( + String email, + String nickname, + String profileImageUrl, + String providerId, + Provider provider + ) { + this.email = email; + this.nickname = validateNickname(nickname); + this.profileImageUrl = profileImageUrl; + this.providerId = providerId; + this.provider = provider; + this.entityId = UUID.randomUUID(); + } + + public static Member signUp( + String email, + String nickname, + String profileImageUrl, + String providerId, + Provider provider + ) { + return new Member(email, nickname, profileImageUrl, providerId, provider); + } + + private static String validateNickname(String nickname) { + if (nickname == null) { + throw new BusinessException(MemberErrorCode.NICKNAME_REQUIRED); + } + + String stripedNickname = EDGE_WHITESPACE.matcher(nickname).replaceAll(""); + + if (stripedNickname.isEmpty()) { + throw new BusinessException(MemberErrorCode.NICKNAME_REQUIRED); + } + + + // NOTE: 유니코드 코드 포인트 기준으로 길이 검증 + if (stripedNickname.codePointCount(0, stripedNickname.length()) > MAX_NICKNAME_LENGTH) { + throw new BusinessException(MemberErrorCode.MAX_NICKNAME_LENGTH); + } + + return stripedNickname; + } +} diff --git a/framey-application/member-service/src/main/java/framey/member/domain/model/NicknameGenerator.java b/framey-application/member-service/src/main/java/framey/member/domain/model/NicknameGenerator.java new file mode 100644 index 0000000..cc20430 --- /dev/null +++ b/framey-application/member-service/src/main/java/framey/member/domain/model/NicknameGenerator.java @@ -0,0 +1,47 @@ +package framey.member.domain.model; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +import java.util.Random; +import java.util.concurrent.ThreadLocalRandom; + +import static framey.member.domain.constant.NicknameConstant.BASE_NICKNAME; +import static framey.member.domain.constant.NicknameConstant.EDGE_WHITESPACE; +import static framey.member.domain.constant.NicknameConstant.MAX_NICKNAME_LENGTH; +import static framey.member.domain.constant.NicknameConstant.SUFFIX_DIGITS; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class NicknameGenerator { + public static String normalize(String nickname) { + if (nickname == null) { + return BASE_NICKNAME; + } + + String stripedNickname = EDGE_WHITESPACE.matcher(nickname).replaceAll(""); + + if (stripedNickname.isEmpty()) { + return BASE_NICKNAME; + } + + return truncate(stripedNickname, MAX_NICKNAME_LENGTH); + } + + public static String appendSuffix(String base) { + int baseLength = base.codePointCount(0, base.length()); + int suffixLength = Math.max(SUFFIX_DIGITS, MAX_NICKNAME_LENGTH - baseLength); + + StringBuilder nickname = new StringBuilder(truncate(base, MAX_NICKNAME_LENGTH - suffixLength)); + for (int i = 0; i < suffixLength; i++) { + nickname.append(ThreadLocalRandom.current().nextInt(10)); + } + return nickname.toString(); + } + + private static String truncate(String value, int maxLength) { + if (value.codePointCount(0, value.length()) <= maxLength) { + return value; + } + return value.substring(0, value.offsetByCodePoints(0, maxLength)); + } +} diff --git a/framey-application/member-service/src/test/java/framey/member/domain/member/MemberTest.java b/framey-application/member-service/src/test/java/framey/member/domain/member/MemberTest.java new file mode 100644 index 0000000..64ae982 --- /dev/null +++ b/framey-application/member-service/src/test/java/framey/member/domain/member/MemberTest.java @@ -0,0 +1,73 @@ +package framey.member.domain.member; + +import framey.common.exception.BusinessException; +import framey.member.domain.constant.Provider; +import framey.member.domain.model.Member; +import framey.member.domain.exception.MemberErrorCode; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +class MemberTest { + + @ParameterizedTest + @ValueSource(strings = {" ", " ", "\u3000"}) + @NullAndEmptySource + @DisplayName("닉네임이 비어 있거나 null이면 가입할 수 없다") + void signUpFailWhenNicknameIsBlank(String nickname) { + Assertions.assertThatThrownBy(() -> + Member.signUp("framey@test.com", nickname, "", "test-1", Provider.GOOGLE) + ) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(MemberErrorCode.NICKNAME_REQUIRED); + } + + @ParameterizedTest + @ValueSource(strings = {" Framey ", "\u3000Framey\u3000"}) + @DisplayName("닉네임에 공백(유니코드 공백 포함)이 있으면 제거된다") + void signUpStripsNicknameWhitespace(String nickname) { + Member member = Member.signUp("framey@test.com", nickname, "", "test-1", Provider.GOOGLE); + + Assertions.assertThat(member.getEntityId()).isNotNull(); + Assertions.assertThat(member.getNickname()).isEqualTo(nickname.strip()); + } + + @Test + @DisplayName("닉네임이 10자 이내면 가입할 수 있다") + void signUpSuccessWhenNicknameIsWithinMaxLength() { + String nickname = "0123456789"; + Member member = Member.signUp("framey@test.com", nickname, "", "test-1", Provider.GOOGLE); + + Assertions.assertThat(nickname.length()).isEqualTo(10); + Assertions.assertThat(member.getNickname()).isEqualTo(nickname); + Assertions.assertThat(member.getEntityId()).isNotNull(); + } + + @Test + @DisplayName("닉네임의 이모지는 1글자로 센다") + void signUpCountsEmojiAsOneChar() { + String nickname = "🙂🙂🙂🙂🙂🙂🙂🙂🙂🙂"; + Member member = Member.signUp("framey@test.com", nickname, "", "test-1", Provider.GOOGLE); + + Assertions.assertThat(nickname.codePointCount(0, nickname.length())).isEqualTo(10); + Assertions.assertThat(member.getNickname()).isEqualTo(nickname); + Assertions.assertThat(member.getEntityId()).isNotNull(); + } + + @ParameterizedTest + @ValueSource(strings = {"01234567890", "🙂🙂🙂🙂🙂🙂🙂🙂🙂🙂🙂"}) + @DisplayName("닉네임 길이는 10자를 초과할 수 없다") + void signUpFailWhenNicknameOverMaxLength(String nickname) { + Assertions.assertThat(nickname.codePointCount(0, nickname.length())).isEqualTo(11); + Assertions.assertThatThrownBy(() -> + Member.signUp("framey@test.com", nickname, "", "test-1", Provider.GOOGLE) + ) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(MemberErrorCode.MAX_NICKNAME_LENGTH); + } +} diff --git a/framey-application/member-service/src/test/java/framey/member/domain/member/NicknameGeneratorTest.java b/framey-application/member-service/src/test/java/framey/member/domain/member/NicknameGeneratorTest.java new file mode 100644 index 0000000..af8052f --- /dev/null +++ b/framey-application/member-service/src/test/java/framey/member/domain/member/NicknameGeneratorTest.java @@ -0,0 +1,34 @@ +package framey.member.domain.member; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +import static framey.member.domain.model.NicknameGenerator.appendSuffix; +import static framey.member.domain.model.NicknameGenerator.normalize; + +class NicknameGeneratorTest { + @ParameterizedTest + @ValueSource(strings = {" ", "\u3000", "\u00A0"}) + @NullAndEmptySource + @DisplayName("닉네임이 비어있을 경우 기본 닉네임을 사용한다") + void useDefaultNicknameWhenNicknameIsBlank(String nickname) { + String generatedNickname = appendSuffix(normalize(nickname)); + + Assertions.assertThat(generatedNickname).isNotBlank(); + Assertions.assertThat(generatedNickname).matches("^framey\\d{4}$"); + } + + @ParameterizedTest + @ValueSource(strings = { " 프레이미 ", " 프레이미", "프레이미 ", "\u3000프레이미\u3000"}) + @DisplayName("닉네임의 공백을 제거한다.") + void stripsNicknameWhiteSpaces(String nickname) { + String generatedNickname = normalize(nickname); + + Assertions.assertThat(generatedNickname).isNotBlank(); + Assertions.assertThat(generatedNickname).isEqualTo("프레이미"); + } + +}