diff --git a/framey-application/member-service/src/main/java/framey/member/application/dto/CreateMember.java b/framey-application/member-service/src/main/java/framey/member/application/dto/CreateMember.java new file mode 100644 index 0000000..e82f600 --- /dev/null +++ b/framey-application/member-service/src/main/java/framey/member/application/dto/CreateMember.java @@ -0,0 +1,17 @@ +package framey.member.application.dto; + +import framey.member.domain.model.Member; + +public record CreateMember( + String nickname, + String email, + String profileImageUrl +) { + public static CreateMember from(final Member member) { + return new CreateMember( + member.getNickname(), + member.getEmail(), + member.getProfileImageUrl() + ); + } +} diff --git a/framey-application/member-service/src/main/java/framey/member/application/service/MemberService.java b/framey-application/member-service/src/main/java/framey/member/application/service/MemberService.java new file mode 100644 index 0000000..da25d4a --- /dev/null +++ b/framey-application/member-service/src/main/java/framey/member/application/service/MemberService.java @@ -0,0 +1,63 @@ +package framey.member.application.service; + +import framey.common.exception.BusinessException; +import framey.member.application.dto.CreateMember; +import framey.member.config.MemberProperties; +import framey.member.domain.constant.NicknameConstant; +import framey.member.domain.constant.Provider; +import framey.member.domain.exception.MemberErrorCode; +import framey.member.domain.model.Member; +import framey.member.domain.model.NicknameGenerator; +import framey.member.domain.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MemberService { + private final MemberRepository memberRepository; + private final MemberProperties memberProperties; + + @Transactional + public Member createMember(CreateMember createMember) { + // TODO: 추후 AuthService와 같은 createMember를 처리하는 쪽에서 Transaction 재시도 로직 추가 + + Member member = Member.signUp( + createMember.email(), + resolveNickname(createMember.nickname()), + resolveProfileImageUrl(createMember.profileImageUrl()), + "123", // TODO: 임시 providerId + Provider.GOOGLE // TODO: 임시 provider + ); + + return memberRepository.save(member); + } + + private String resolveNickname(String nickname) { + String baseNickname = NicknameGenerator.normalize(nickname); + + if (!baseNickname.equals(NicknameConstant.BASE_NICKNAME) && !memberRepository.existsByNickname(baseNickname)) { + return baseNickname; + } + + for (int i = 0; i < NicknameConstant.MAX_RETRY_COUNT; i++) { + String generatedNickname = NicknameGenerator.appendSuffix(baseNickname); + + if (!memberRepository.existsByNickname(generatedNickname)) { + return generatedNickname; + } + } + + throw new BusinessException(MemberErrorCode.NICKNAME_GENERATION_FAILED); + } + + private String resolveProfileImageUrl(String profileImageUrl) { + if (profileImageUrl == null || profileImageUrl.isBlank()) { + return memberProperties.defaultProfileImageUrl(); + } + + return profileImageUrl; + } +} diff --git a/framey-application/member-service/src/main/java/framey/member/config/MemberProperties.java b/framey-application/member-service/src/main/java/framey/member/config/MemberProperties.java new file mode 100644 index 0000000..a4faf71 --- /dev/null +++ b/framey-application/member-service/src/main/java/framey/member/config/MemberProperties.java @@ -0,0 +1,7 @@ +package framey.member.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "member") +public record MemberProperties(String defaultProfileImageUrl) { +} 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 index e397788..67c2be2 100644 --- 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 @@ -13,4 +13,5 @@ public final class NicknameConstant { ); public static final String BASE_NICKNAME = "framey"; public static final int SUFFIX_DIGITS = 4; + public static final int MAX_RETRY_COUNT = 5; } 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 index af570d8..3a85999 100644 --- 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 @@ -9,7 +9,8 @@ @RequiredArgsConstructor public enum MemberErrorCode implements ErrorCode { NICKNAME_REQUIRED("MB-001", "닉네임을 입력해 주세요.", HttpStatus.BAD_REQUEST), - MAX_NICKNAME_LENGTH("MB-002", "닉네임은 10자 이내로 입력해 주세요.", HttpStatus.BAD_REQUEST); + MAX_NICKNAME_LENGTH("MB-002", "닉네임은 10자 이내로 입력해 주세요.", HttpStatus.BAD_REQUEST), + NICKNAME_GENERATION_FAILED("MB-003", "닉네임 생성에 실패했습니다. 다시 시도해 주세요.", HttpStatus.INTERNAL_SERVER_ERROR); private final String value; private final String message; 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 index cc20430..634932a 100644 --- 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 @@ -3,7 +3,6 @@ import lombok.AccessLevel; import lombok.NoArgsConstructor; -import java.util.Random; import java.util.concurrent.ThreadLocalRandom; import static framey.member.domain.constant.NicknameConstant.BASE_NICKNAME; diff --git a/framey-application/member-service/src/main/java/framey/member/domain/repository/MemberRepository.java b/framey-application/member-service/src/main/java/framey/member/domain/repository/MemberRepository.java new file mode 100644 index 0000000..b26cf60 --- /dev/null +++ b/framey-application/member-service/src/main/java/framey/member/domain/repository/MemberRepository.java @@ -0,0 +1,8 @@ +package framey.member.domain.repository; + +import framey.member.domain.model.Member; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MemberRepository extends JpaRepository { + boolean existsByNickname(String nickname); +} diff --git a/framey-application/member-service/src/main/resources/application-dev.yaml b/framey-application/member-service/src/main/resources/application-dev.yaml index e6eaa41..091fa2d 100644 --- a/framey-application/member-service/src/main/resources/application-dev.yaml +++ b/framey-application/member-service/src/main/resources/application-dev.yaml @@ -31,3 +31,6 @@ management: health: probes: enabled: true + +member: + default-profile-image-url: https://dev-swagger.framey.kr/assets/members/default-profile-image.png diff --git a/framey-application/member-service/src/main/resources/application.yaml b/framey-application/member-service/src/main/resources/application.yaml index 383a600..8249f25 100644 --- a/framey-application/member-service/src/main/resources/application.yaml +++ b/framey-application/member-service/src/main/resources/application.yaml @@ -9,3 +9,5 @@ server: springdoc: api-docs: enabled: true +member: + default-profile-image-url: http://localhost:8081/assets/members/default-profile-image.png diff --git a/framey-application/member-service/src/main/resources/static/assets/members/default-profile-image.png b/framey-application/member-service/src/main/resources/static/assets/members/default-profile-image.png new file mode 100644 index 0000000..bcddb96 Binary files /dev/null and b/framey-application/member-service/src/main/resources/static/assets/members/default-profile-image.png differ diff --git a/framey-application/member-service/src/test/java/framey/member/application/service/MemberServiceTest.java b/framey-application/member-service/src/test/java/framey/member/application/service/MemberServiceTest.java new file mode 100644 index 0000000..14cdd31 --- /dev/null +++ b/framey-application/member-service/src/test/java/framey/member/application/service/MemberServiceTest.java @@ -0,0 +1,152 @@ +package framey.member.application.service; + +import framey.common.exception.BusinessException; +import framey.member.application.dto.CreateMember; +import framey.member.config.MemberProperties; +import framey.member.domain.constant.NicknameConstant; +import framey.member.domain.exception.MemberErrorCode; +import framey.member.domain.model.Member; +import framey.member.domain.repository.MemberRepository; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; + +@ExtendWith(MockitoExtension.class) +class MemberServiceTest { + private static final String DEFAULT_PROFILE_IMAGE_URL = "https://test.framey/default-profile-image.png"; + + @Mock + private MemberRepository memberRepository; + + private MemberService memberService; + + @BeforeEach + void setUp() { + memberService = new MemberService(memberRepository, new MemberProperties(DEFAULT_PROFILE_IMAGE_URL)); + } + + @Test + @DisplayName("소셜 닉네임이 사용 중이 아니면 해당 닉네임을 그대로 사용한다") + void useSocialNicknameWhenNotDuplicated() { + given(memberRepository.existsByNickname("프레이미")).willReturn(false); + given(memberRepository.save(any(Member.class))).willAnswer(inv -> inv.getArgument(0)); + + CreateMember member = new CreateMember("프레이미", "framey@exam.com", ""); + + Member newMember = memberService.createMember(member); + + Assertions.assertThat(newMember.getNickname()).isEqualTo("프레이미"); + } + + @Test + @DisplayName("소셜 닉네임이 사용 중이라면, 기존 닉네임 뒤에 10자리를 맞춰 임의 숫자를 넣어 사용한다") + void useAppendSuffixNicknameWhenDuplicated() { + given(memberRepository.existsByNickname("프레이미")).willReturn(true); + given(memberRepository.save(any(Member.class))).willAnswer(inv -> inv.getArgument(0)); + + CreateMember member = new CreateMember("프레이미", "framey@exam.com", ""); + + Member newMember = memberService.createMember(member); + + Assertions.assertThat(newMember.getNickname()).matches("^프레이미\\d{6}"); + } + + @Test + @DisplayName("소셜 닉네임이 기본 닉네임과 같으면 뒤에 임의 숫자를 붙인다") + void appendSuffixWhenNicknameEqualsBaseNickname() { + given(memberRepository.save(any(Member.class))).willAnswer(inv -> inv.getArgument(0)); + + CreateMember member = new CreateMember("framey", "framey@exam.com", ""); + + Member newMember = memberService.createMember(member); + + Assertions.assertThat(newMember.getNickname()).matches("^framey\\d{4}"); + then(memberRepository).should(never()).existsByNickname("framey"); + } + + @ParameterizedTest + @ValueSource(strings = {" ", "\u3000", "\u00A0"}) + @NullAndEmptySource + @DisplayName("소셜 닉네임이 없으면 기본 닉네임에 임의 숫자를 붙인다") + void useBaseNicknameWithSuffixWhenNicknameIsBlank(String nickname) { + given(memberRepository.save(any(Member.class))).willAnswer(inv -> inv.getArgument(0)); + CreateMember member = new CreateMember(nickname, "framey@exam.com", ""); + + Member newMember = memberService.createMember(member); + + Assertions.assertThat(newMember.getNickname()).matches("^framey\\d{4}"); + then(memberRepository).should(never()).existsByNickname("framey"); + } + + @Test + @DisplayName("임의 숫자를 붙인 닉네임도 모두 사용 중이면 닉네임 생성에 실패한다") + void failWhenAllGeneratedNicknamesAreDuplicated() { + given(memberRepository.existsByNickname(anyString())).willReturn(true); + + CreateMember member = new CreateMember("프레이미", "framey@exam.com", ""); + + Assertions.assertThatThrownBy(() -> memberService.createMember(member)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(MemberErrorCode.NICKNAME_GENERATION_FAILED); + + then(memberRepository).should(never()).save(any()); + then(memberRepository).should(times(1 + NicknameConstant.MAX_RETRY_COUNT)).existsByNickname(anyString()); + } + + @Test + @DisplayName("임의 숫자를 붙인 닉네임도 사용 중이라면 새 숫자를 붙여 다시 시도한다") + void retryWithNewSuffixWhenGeneratedNicknameIsDuplicated() { + given(memberRepository.existsByNickname(anyString())).willReturn(true, true, false); + given(memberRepository.save(any(Member.class))).willAnswer(inv -> inv.getArgument(0)); + + CreateMember member = new CreateMember("프레이미", "framey@exam.com", ""); + + Member newMember = memberService.createMember(member); + + Assertions.assertThat(newMember.getNickname()).matches("^프레이미\\d{6}"); + then(memberRepository).should(times(3)).existsByNickname(anyString()); + } + + @ParameterizedTest + @ValueSource(strings = " ") + @NullAndEmptySource + @DisplayName("프로필 이미지가 없으면 기본 프로필 이미지를 사용한다") + void useDefaultProfileImageWhenProfileImageIsBlank(String profileImageUrl) { + given(memberRepository.save(any(Member.class))).willAnswer(inv -> inv.getArgument(0)); + + CreateMember member = new CreateMember("프레이미", "framey@exam.com", profileImageUrl); + + Member newMember = memberService.createMember(member); + + Assertions.assertThat(newMember.getProfileImageUrl()).isEqualTo(DEFAULT_PROFILE_IMAGE_URL); + } + + @Test + @DisplayName("프로필 이미지가 있으면 그대로 사용한다") + void useSocialProfileImageWhenPresent() { + given(memberRepository.save(any(Member.class))).willAnswer(inv -> inv.getArgument(0)); + + String profileImageUrl = "https://test.framey/profile.png"; + + CreateMember member = new CreateMember("프레이미", "framey@exam.com", profileImageUrl); + + Member newMember = memberService.createMember(member); + + Assertions.assertThat(newMember.getProfileImageUrl()).isEqualTo(profileImageUrl); + } +} 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 deleted file mode 100644 index af8052f..0000000 --- a/framey-application/member-service/src/test/java/framey/member/domain/member/NicknameGeneratorTest.java +++ /dev/null @@ -1,34 +0,0 @@ -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("프레이미"); - } - -} 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/model/member/MemberTest.java similarity index 98% rename from framey-application/member-service/src/test/java/framey/member/domain/member/MemberTest.java rename to framey-application/member-service/src/test/java/framey/member/domain/model/member/MemberTest.java index 64ae982..8dbb709 100644 --- 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/model/member/MemberTest.java @@ -1,4 +1,4 @@ -package framey.member.domain.member; +package framey.member.domain.model.member; import framey.common.exception.BusinessException; import framey.member.domain.constant.Provider; diff --git a/framey-application/member-service/src/test/java/framey/member/domain/model/member/NicknameGeneratorTest.java b/framey-application/member-service/src/test/java/framey/member/domain/model/member/NicknameGeneratorTest.java new file mode 100644 index 0000000..6a2e81d --- /dev/null +++ b/framey-application/member-service/src/test/java/framey/member/domain/model/member/NicknameGeneratorTest.java @@ -0,0 +1,87 @@ +package framey.member.domain.model.member; + +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; + +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).matches("^framey\\d{4}$"); + } + + @ParameterizedTest + @ValueSource(strings = { " 프레이미 ", " 프레이미", "프레이미 ", "\u3000프레이미\u3000"}) + @DisplayName("닉네임의 앞 뒤 공백을 제거한다.") + void stripsNicknameWhiteSpaces(String nickname) { + String generatedNickname = normalize(nickname); + + Assertions.assertThat(generatedNickname).isEqualTo("프레이미"); + } + + @Test + @DisplayName("닉네임이 10자를 넘어가면 10자에 맞게 자른다") + void truncateNicknameWhenLengthOver10() { + String nickname = "가나다라마바사아자차카타파"; + String generatedNickname = normalize(nickname); + + Assertions.assertThat(generatedNickname.codePointCount(0, generatedNickname.length())).isEqualTo(10); + } + + @Test + @DisplayName("중복 방지용 임의 숫자를 붙일 때, 닉네임이 길면 앞부분을 잘라 최소 4자리를 확보한다") + void truncateBaseToKeepMinimumSuffixDigits() { + String nickname = "가나다라마바사아자차"; + String result = appendSuffix(normalize(nickname)); + + Assertions.assertThat(result).matches("^가나다라마바\\d{4}"); + } + + @Test + @DisplayName("짧은 닉네임은 전체 10자가 되도록 숫자를 채운다") + void fillSuffixToMaxLengthWhenBaseIsShort() { + String nickname = "가나"; + String result = appendSuffix(normalize(nickname)); + + Assertions.assertThat(result).matches("^가나\\d{8}"); + } + + @Test + @DisplayName("이모지가 포함된 닉네임은 깨지지 않도록 코드 포인트 기준으로 10자까지 자른다") + void truncateNicknameByCodePoint() { + String nickname = "🙂🙂🙂🙂🙂🙂🙂🙂🙂🙂🙂"; + String result = normalize(nickname); + + Assertions.assertThat(result).isEqualTo("🙂🙂🙂🙂🙂🙂🙂🙂🙂🙂"); + Assertions.assertThat(result.codePointCount(0, result.length())).isEqualTo(10); + } + + @Test + @DisplayName("이모지 닉네임에 숫자를 붙일 때 깨지지 않도록 앞부분을 자른다") + void truncateEmojiBaseByCodePointWhenAppendingSuffix() { + String nickname = "🙂🙂🙂🙂🙂🙂🙂🙂🙂🙂"; + String result = appendSuffix(normalize(nickname)); + + Assertions.assertThat(result).matches("^🙂🙂🙂🙂🙂🙂\\d{4}"); + } + + @Test + @DisplayName("닉네임 중간의 공백은 제거하지 않는다") + void keepWhitespaceInsideNickname() { + String nickname = "프 레 이 미"; + String result = normalize(nickname); + + Assertions.assertThat(result).isEqualTo("프 레 이 미"); + } +} diff --git a/framey-infrastructure/nginx/dev/default.conf b/framey-infrastructure/nginx/dev/default.conf index 534e17b..29e3098 100644 --- a/framey-infrastructure/nginx/dev/default.conf +++ b/framey-infrastructure/nginx/dev/default.conf @@ -67,6 +67,16 @@ server { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } + # ========================= + # Member Static Assets + # ========================= + location ^~ /assets/members/ { + proxy_pass http://member-dev:8081; + + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + } + # ========================= # Collage Service # =========================