diff --git a/backend/core/build.gradle b/backend/core/build.gradle index f35c4a4fc..7520ee105 100644 --- a/backend/core/build.gradle +++ b/backend/core/build.gradle @@ -74,9 +74,6 @@ dependencies { //aop implementation 'org.springframework.boot:spring-boot-starter-aop' - //jnanoid - implementation 'com.aventrix.jnanoid:jnanoid:2.0.0' - compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/auth/service/MessageVerificationService.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/auth/service/MessageVerificationService.java index 69cd6f204..54e7a1afa 100644 --- a/backend/core/src/main/java/site/timecapsulearchive/core/domain/auth/service/MessageVerificationService.java +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/auth/service/MessageVerificationService.java @@ -3,6 +3,7 @@ import java.nio.charset.StandardCharsets; import java.security.SecureRandom; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import site.timecapsulearchive.core.domain.auth.data.response.TokenResponse; @@ -20,6 +21,7 @@ import site.timecapsulearchive.core.infra.sms.data.response.SmsApiResponse; import site.timecapsulearchive.core.infra.sms.manager.SmsApiManager; +@Slf4j @Service @Transactional @RequiredArgsConstructor @@ -109,6 +111,14 @@ private Long updateToVerifiedMember(final Long memberId, final byte[] plain) { memberTemporaryRepository.delete(memberTemporary); + boolean isDuplicateTag = memberRepository.checkTagDuplication(memberTemporary.getTag()); + if (isDuplicateTag) { + log.warn("member tag duplicate - email:{}, tag:{}", memberTemporary.getEmail(), + memberTemporary.getTag()); + memberTemporary.updateTagLowerCaseSocialType(); + log.warn("member tag update - tag: {}", memberTemporary.getTag()); + } + final Member verifiedMember = memberTemporary.toMember(hashEncryptionManager.encrypt(plain), aesEncryptionManager.encryptWithPrefixIV(plain)); diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/friend/api/query/FriendQueryApi.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/friend/api/query/FriendQueryApi.java index 3813489e0..0cd05496a 100644 --- a/backend/core/src/main/java/site/timecapsulearchive/core/domain/friend/api/query/FriendQueryApi.java +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/friend/api/query/FriendQueryApi.java @@ -104,8 +104,12 @@ ResponseEntity> searchMembersByPhones( ); @Operation( - summary = "찬구 검색", - description = "친구의 tag로 친구 검색을 한다.", + summary = "친구 검색", + description = """ + 친구의 tag로 친구 검색을 한다. +
+ 태그가 일치하면 일치하는 태그를 가진 사용자를 일치하지 않으면 가장 비슷한 태그를 가진 사용자를 반환한다. + """, security = {@SecurityRequirement(name = "user_token")}, tags = {"friend"} ) diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/friend/api/query/FriendQueryApiController.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/friend/api/query/FriendQueryApiController.java index 4e990d0ae..f9856fb3b 100644 --- a/backend/core/src/main/java/site/timecapsulearchive/core/domain/friend/api/query/FriendQueryApiController.java +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/friend/api/query/FriendQueryApiController.java @@ -15,6 +15,7 @@ import org.springframework.web.bind.annotation.RestController; import site.timecapsulearchive.core.domain.friend.data.dto.FriendSummaryDto; import site.timecapsulearchive.core.domain.friend.data.dto.SearchFriendSummaryDto; +import site.timecapsulearchive.core.domain.friend.data.dto.SearchFriendSummaryDtoByTag; import site.timecapsulearchive.core.domain.friend.data.request.FriendBeforeGroupInviteRequest; import site.timecapsulearchive.core.domain.friend.data.request.SearchFriendsRequest; import site.timecapsulearchive.core.domain.friend.data.response.FriendRequestsSliceResponse; @@ -127,10 +128,13 @@ public ResponseEntity> searchFriendByTag @AuthenticationPrincipal Long memberId, @RequestParam(value = "friend_tag") final String tag ) { + SearchFriendSummaryDtoByTag searchResult = friendQueryService.searchFriend( + memberId, tag); + return ResponseEntity.ok( ApiSpec.success( SuccessCode.SUCCESS, - friendQueryService.searchFriend(memberId, tag) + searchResult.toResponse() ) ); } diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/friend/repository/member_friend/MemberFriendQueryRepositoryImpl.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/friend/repository/member_friend/MemberFriendQueryRepositoryImpl.java index 3943520e4..7bc29fdd2 100644 --- a/backend/core/src/main/java/site/timecapsulearchive/core/domain/friend/repository/member_friend/MemberFriendQueryRepositoryImpl.java +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/friend/repository/member_friend/MemberFriendQueryRepositoryImpl.java @@ -6,7 +6,11 @@ import static site.timecapsulearchive.core.domain.member.entity.QMember.member; import static site.timecapsulearchive.core.domain.member_group.entity.QMemberGroup.memberGroup; +import com.querydsl.core.types.OrderSpecifier; import com.querydsl.core.types.Projections; +import com.querydsl.core.types.dsl.CaseBuilder; +import com.querydsl.core.types.dsl.Expressions; +import com.querydsl.core.types.dsl.NumberTemplate; import com.querydsl.jpa.impl.JPAQueryFactory; import java.time.ZonedDateTime; import java.util.List; @@ -27,6 +31,11 @@ @RequiredArgsConstructor public class MemberFriendQueryRepositoryImpl implements MemberFriendQueryRepository { + private static final double MATCH_THRESHOLD = 0; + private static final String MATCH_AGAINST_FUNCTION = "function('match_against', {0}, {1})"; + private static final String FRIEND_INVITE_TO_ME_PATH = "friendInviteToMe"; + private static final String FRIEND_INVITE_TO_FRIEND_PATH = "friendInviteToFriend"; + private final JPAQueryFactory jpaQueryFactory; public Slice findFriendsSlice( @@ -121,8 +130,8 @@ public List findFriendsByPhone( final Long memberId, final List hashes ) { - final QFriendInvite friendInviteToFriend = new QFriendInvite("friendInviteToFriend"); - final QFriendInvite friendInviteToMe = new QFriendInvite("friendInviteToMe"); + final QFriendInvite friendInviteToFriend = new QFriendInvite(FRIEND_INVITE_TO_FRIEND_PATH); + final QFriendInvite friendInviteToMe = new QFriendInvite(FRIEND_INVITE_TO_ME_PATH); return jpaQueryFactory .select( @@ -157,8 +166,21 @@ public Optional findFriendsByTag( final Long memberId, final String tag ) { - final QFriendInvite friendInviteToFriend = new QFriendInvite("friendInviteToFriend"); - final QFriendInvite friendInviteToMe = new QFriendInvite("friendInviteToMe"); + if (tag != null && tag.isBlank()) { + return Optional.empty(); + } + + final QFriendInvite friendInviteToFriend = new QFriendInvite(FRIEND_INVITE_TO_FRIEND_PATH); + final QFriendInvite friendInviteToMe = new QFriendInvite(FRIEND_INVITE_TO_ME_PATH); + + NumberTemplate tagFullTextSearchTemplate = Expressions.numberTemplate(Double.class, + MATCH_AGAINST_FUNCTION, + member.tag, + tag); + + OrderSpecifier tagFullyMatchFirstOrder = new CaseBuilder().when(member.tag.eq(tag)) + .then(Boolean.TRUE) + .otherwise(Boolean.FALSE).desc(); return Optional.ofNullable(jpaQueryFactory .select( @@ -179,9 +201,11 @@ public Optional findFriendsByTag( .on(friendInviteToFriend.friend.id.eq(member.id) .and(friendInviteToFriend.owner.id.eq(memberId))) .leftJoin(friendInviteToMe) - .on(friendInviteToMe.friend.id.eq(memberId) - .and(friendInviteToMe.owner.id.eq(member.id))) - .where(member.tag.eq(tag)) + .on(friendInviteToMe.owner.id.eq(member.id) + .and(friendInviteToMe.friend.id.eq(memberId))) + .where(tagFullTextSearchTemplate.gt(MATCH_THRESHOLD)) + .orderBy(tagFullyMatchFirstOrder, tagFullTextSearchTemplate.desc()) + .limit(1L) .fetchOne() ); } diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/friend/service/query/FriendQueryService.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/friend/service/query/FriendQueryService.java index d13efda75..d34230f0a 100644 --- a/backend/core/src/main/java/site/timecapsulearchive/core/domain/friend/service/query/FriendQueryService.java +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/friend/service/query/FriendQueryService.java @@ -10,7 +10,6 @@ import site.timecapsulearchive.core.domain.friend.data.dto.SearchFriendSummaryDto; import site.timecapsulearchive.core.domain.friend.data.dto.SearchFriendSummaryDtoByTag; import site.timecapsulearchive.core.domain.friend.data.request.FriendBeforeGroupInviteRequest; -import site.timecapsulearchive.core.domain.friend.data.response.SearchTagFriendSummaryResponse; import site.timecapsulearchive.core.domain.friend.exception.FriendNotFoundException; import site.timecapsulearchive.core.domain.friend.repository.member_friend.MemberFriendRepository; import site.timecapsulearchive.core.global.common.wrapper.ByteArrayWrapper; @@ -53,12 +52,8 @@ public List findFriendsByPhone( return memberFriendRepository.findFriendsByPhone(memberId, hashes); } - public SearchTagFriendSummaryResponse searchFriend(final Long memberId, final String tag) { - final SearchFriendSummaryDtoByTag friendSummaryDto = memberFriendRepository - .findFriendsByTag(memberId, tag).orElseThrow(FriendNotFoundException::new); - - return friendSummaryDto.toResponse(); + public SearchFriendSummaryDtoByTag searchFriend(final Long memberId, final String tag) { + return memberFriendRepository.findFriendsByTag(memberId, tag) + .orElseThrow(FriendNotFoundException::new); } - - } diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/member/data/dto/SignUpRequestDto.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/member/data/dto/SignUpRequestDto.java index 4ec2aa7c3..bf7969ca9 100644 --- a/backend/core/src/main/java/site/timecapsulearchive/core/domain/member/data/dto/SignUpRequestDto.java +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/member/data/dto/SignUpRequestDto.java @@ -1,6 +1,5 @@ package site.timecapsulearchive.core.domain.member.data.dto; -import com.aventrix.jnanoid.jnanoid.NanoIdUtils; import site.timecapsulearchive.core.domain.member.entity.MemberTemporary; import site.timecapsulearchive.core.domain.member.entity.SocialType; import site.timecapsulearchive.core.global.util.nickname.MakeRandomNickNameUtil; @@ -12,14 +11,14 @@ public record SignUpRequestDto( SocialType socialType ) { - public MemberTemporary toMemberTemporary() { + public MemberTemporary toMemberTemporary(final String tag) { return MemberTemporary.builder() .authId(authId) .nickname(MakeRandomNickNameUtil.makeRandomNickName()) .email(email) .profileUrl(profileUrl) .socialType(socialType) - .tag(NanoIdUtils.randomNanoId()) + .tag(tag) .build(); } diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/member/data/mapper/MemberMapper.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/member/data/mapper/MemberMapper.java index d25037621..b24b883ae 100644 --- a/backend/core/src/main/java/site/timecapsulearchive/core/domain/member/data/mapper/MemberMapper.java +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/member/data/mapper/MemberMapper.java @@ -1,6 +1,5 @@ package site.timecapsulearchive.core.domain.member.data.mapper; -import com.aventrix.jnanoid.jnanoid.NanoIdUtils; import java.time.ZoneId; import java.util.List; import java.util.UUID; @@ -12,6 +11,7 @@ import site.timecapsulearchive.core.domain.member.entity.Member; import site.timecapsulearchive.core.domain.member.entity.SocialType; import site.timecapsulearchive.core.global.security.oauth.dto.OAuth2UserInfo; +import site.timecapsulearchive.core.global.util.TagGenerator; import site.timecapsulearchive.core.global.util.nickname.MakeRandomNickNameUtil; import site.timecapsulearchive.core.infra.s3.manager.S3PreSignedUrlManager; @@ -33,7 +33,7 @@ public Member OAuthToEntity( .email(oAuth2UserInfo.getEmail()) .profileUrl(oAuth2UserInfo.getImageUrl()) .socialType(socialType) - .tag(NanoIdUtils.randomNanoId()) + .tag(TagGenerator.generate(oAuth2UserInfo.getEmail(), socialType)) .build(); } @@ -64,13 +64,15 @@ public MemberNotificationSliceResponse notificationSliceToResponse( } public Member createMemberWithEmail(String email, String password) { + SocialType socialType = SocialType.EMAIL; + return Member.builder() .email(email) .password(password) .authId(String.valueOf(UUID.randomUUID())) .profileUrl("") - .socialType(SocialType.EMAIL) - .tag(NanoIdUtils.randomNanoId()) + .socialType(socialType) + .tag(TagGenerator.generate(email, socialType)) .build(); } } \ No newline at end of file diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/member/entity/Member.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/member/entity/Member.java index 31cdfff02..e94e51515 100644 --- a/backend/core/src/main/java/site/timecapsulearchive/core/domain/member/entity/Member.java +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/member/entity/Member.java @@ -23,6 +23,7 @@ import site.timecapsulearchive.core.domain.member_group.entity.MemberGroup; import site.timecapsulearchive.core.global.entity.BaseEntity; import site.timecapsulearchive.core.global.util.NullCheck; +import site.timecapsulearchive.core.global.util.TagGenerator; @Entity @Getter @@ -107,4 +108,7 @@ private Member(String profileUrl, String nickname, SocialType socialType, String this.phone_hash = phone_hash; } + public void updateTagLowerCaseSocialType() { + this.tag = TagGenerator.lowercase(email, socialType); + } } diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/member/entity/MemberTemporary.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/member/entity/MemberTemporary.java index 7ecb0e197..741faa054 100644 --- a/backend/core/src/main/java/site/timecapsulearchive/core/domain/member/entity/MemberTemporary.java +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/member/entity/MemberTemporary.java @@ -15,6 +15,7 @@ import lombok.NoArgsConstructor; import site.timecapsulearchive.core.global.entity.BaseEntity; import site.timecapsulearchive.core.global.util.NullCheck; +import site.timecapsulearchive.core.global.util.TagGenerator; @Entity @Getter @@ -73,4 +74,8 @@ public Member toMember(final byte[] phone_hash, final byte[] phone) { .phone(phone) .build(); } + + public void updateTagLowerCaseSocialType() { + this.tag = TagGenerator.lowercase(email, socialType); + } } diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/member/repository/MemberQueryRepository.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/member/repository/MemberQueryRepository.java index 469bb89a5..13827ea1f 100644 --- a/backend/core/src/main/java/site/timecapsulearchive/core/domain/member/repository/MemberQueryRepository.java +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/member/repository/MemberQueryRepository.java @@ -44,4 +44,6 @@ List findMemberNotificationDtos( Optional findIsAlarmByMemberId(final Long memberId); List findMemberIdsByIds(List ids); + + boolean checkTagDuplication(String tag); } diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/member/repository/MemberQueryRepositoryImpl.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/member/repository/MemberQueryRepositoryImpl.java index 27453148a..0afced1fb 100644 --- a/backend/core/src/main/java/site/timecapsulearchive/core/domain/member/repository/MemberQueryRepositoryImpl.java +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/member/repository/MemberQueryRepositoryImpl.java @@ -30,6 +30,7 @@ public class MemberQueryRepositoryImpl implements MemberQueryRepository { private final JPAQueryFactory query; + @Override public Boolean findIsVerifiedByAuthIdAndSocialType( final String authId, final SocialType socialType @@ -40,6 +41,7 @@ public Boolean findIsVerifiedByAuthIdAndSocialType( .fetchOne(); } + @Override public Optional findVerifiedCheckDtoByAuthIdAndSocialType( final String authId, final SocialType socialType @@ -59,6 +61,7 @@ public Optional findVerifiedCheckDtoByAuthIdAndSocialType( ); } + @Override public Optional findMemberDetailResponseDtoById(final Long memberId) { return Optional.ofNullable( query @@ -85,6 +88,7 @@ private NumberExpression countDistinct(final NumberExpression expres return Expressions.numberTemplate(Long.class, "COUNT(DISTINCT {0})", expression); } + @Override public Slice findNotificationSliceByMemberId( final Long memberId, final int size, @@ -101,6 +105,7 @@ public Slice findNotificationSliceByMemberId( return new SliceImpl<>(notifications, Pageable.ofSize(size), hasNext); } + @Override public List findMemberNotificationDtos( final Long memberId, final int size, @@ -126,6 +131,7 @@ public List findMemberNotificationDtos( .fetch(); } + @Override public Optional findEmailVerifiedCheckDtoByEmail( final String email ) { @@ -146,6 +152,7 @@ public Optional findEmailVerifiedCheckDtoByEmail( ); } + @Override public Boolean checkEmailDuplication(final String email) { final Integer count = query.selectOne() .from(member) @@ -155,6 +162,7 @@ public Boolean checkEmailDuplication(final String email) { return count != null; } + @Override public Optional findIsAlarmByMemberId(final Long memberId) { return Optional.ofNullable( query.select(member.notificationEnabled) @@ -164,6 +172,7 @@ public Optional findIsAlarmByMemberId(final Long memberId) { ); } + @Override public List findMemberIdsByIds(List ids) { return query .select(member.id) @@ -171,4 +180,14 @@ public List findMemberIdsByIds(List ids) { .where(member.id.in(ids)) .fetch(); } + + @Override + public boolean checkTagDuplication(String tag) { + final Integer count = query.selectOne() + .from(member) + .where(member.tag.eq(tag)) + .fetchFirst(); + + return count != null; + } } diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/member/service/MemberService.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/member/service/MemberService.java index 7799528d0..023ac2d41 100644 --- a/backend/core/src/main/java/site/timecapsulearchive/core/domain/member/service/MemberService.java +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/member/service/MemberService.java @@ -27,6 +27,7 @@ import site.timecapsulearchive.core.domain.member.exception.NotVerifiedMemberException; import site.timecapsulearchive.core.domain.member.repository.MemberRepository; import site.timecapsulearchive.core.domain.member.repository.MemberTemporaryRepository; +import site.timecapsulearchive.core.global.util.TagGenerator; @Slf4j @Service @@ -42,7 +43,8 @@ public class MemberService { @Transactional public Long createMember(final SignUpRequestDto dto) { - final MemberTemporary member = dto.toMemberTemporary(); + final String tag = TagGenerator.generate(dto.email(), dto.socialType()); + final MemberTemporary member = dto.toMemberTemporary(tag); final MemberTemporary savedMember = memberTemporaryRepository.save(member); @@ -165,6 +167,14 @@ public Long createMemberWithEmailAndPassword(final String email, final String pa final String encodedPassword = passwordEncoder.encode(password); final Member member = memberMapper.createMemberWithEmail(email, encodedPassword); + boolean isDuplicateTag = memberRepository.checkTagDuplication(member.getTag()); + if (isDuplicateTag) { + log.warn("member tag duplicate - email:{}, tag:{}", member.getEmail(), + member.getTag()); + member.updateTagLowerCaseSocialType(); + log.warn("member tag update - tag: {}", member.getTag()); + } + final Member savedMember = memberRepository.save(member); return savedMember.getId(); @@ -214,8 +224,4 @@ public Member findMemberById(final Long memberId) { return memberRepository.findMemberById(memberId) .orElseThrow(MemberNotFoundException::new); } - - public List findMemberIdsByIds(List ids) { - return memberRepository.findMemberIdsByIds(ids); - } } diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/global/config/JpaAuditingConfig.java b/backend/core/src/main/java/site/timecapsulearchive/core/global/config/repository/JpaAuditingConfig.java similarity index 90% rename from backend/core/src/main/java/site/timecapsulearchive/core/global/config/JpaAuditingConfig.java rename to backend/core/src/main/java/site/timecapsulearchive/core/global/config/repository/JpaAuditingConfig.java index 2d7527fbe..7decacfb8 100644 --- a/backend/core/src/main/java/site/timecapsulearchive/core/global/config/JpaAuditingConfig.java +++ b/backend/core/src/main/java/site/timecapsulearchive/core/global/config/repository/JpaAuditingConfig.java @@ -1,4 +1,4 @@ -package site.timecapsulearchive.core.global.config; +package site.timecapsulearchive.core.global.config.repository; import java.time.ZoneId; import java.time.ZonedDateTime; diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/global/config/repository/MySQLFunctionContributor.java b/backend/core/src/main/java/site/timecapsulearchive/core/global/config/repository/MySQLFunctionContributor.java new file mode 100644 index 000000000..c441b50f5 --- /dev/null +++ b/backend/core/src/main/java/site/timecapsulearchive/core/global/config/repository/MySQLFunctionContributor.java @@ -0,0 +1,19 @@ +package site.timecapsulearchive.core.global.config.repository; + +import org.hibernate.boot.model.FunctionContributions; +import org.hibernate.boot.model.FunctionContributor; +import org.hibernate.type.StandardBasicTypes; + +public class MySQLFunctionContributor implements FunctionContributor { + + private static final String FUNCTION_NAME = "match_against"; + private static final String FUNCTION_PATTERN = "match (?1) against (?2 in boolean mode)"; + + @Override + public void contributeFunctions(final FunctionContributions functionContributions) { + functionContributions.getFunctionRegistry() + .registerPattern(FUNCTION_NAME, FUNCTION_PATTERN, + functionContributions.getTypeConfiguration().getBasicTypeRegistry() + .resolve(StandardBasicTypes.DOUBLE)); + } +} diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/global/config/QueryDSLConfig.java b/backend/core/src/main/java/site/timecapsulearchive/core/global/config/repository/QueryDSLConfig.java similarity index 87% rename from backend/core/src/main/java/site/timecapsulearchive/core/global/config/QueryDSLConfig.java rename to backend/core/src/main/java/site/timecapsulearchive/core/global/config/repository/QueryDSLConfig.java index 094973e71..6e292a16e 100644 --- a/backend/core/src/main/java/site/timecapsulearchive/core/global/config/QueryDSLConfig.java +++ b/backend/core/src/main/java/site/timecapsulearchive/core/global/config/repository/QueryDSLConfig.java @@ -1,4 +1,4 @@ -package site.timecapsulearchive.core.global.config; +package site.timecapsulearchive.core.global.config.repository; import com.querydsl.jpa.JPQLTemplates; import com.querydsl.jpa.impl.JPAQueryFactory; diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/global/config/TransactionTemplateConfig.java b/backend/core/src/main/java/site/timecapsulearchive/core/global/config/repository/TransactionTemplateConfig.java similarity index 91% rename from backend/core/src/main/java/site/timecapsulearchive/core/global/config/TransactionTemplateConfig.java rename to backend/core/src/main/java/site/timecapsulearchive/core/global/config/repository/TransactionTemplateConfig.java index 8927ee60f..5c249962f 100644 --- a/backend/core/src/main/java/site/timecapsulearchive/core/global/config/TransactionTemplateConfig.java +++ b/backend/core/src/main/java/site/timecapsulearchive/core/global/config/repository/TransactionTemplateConfig.java @@ -1,4 +1,4 @@ -package site.timecapsulearchive.core.global.config; +package site.timecapsulearchive.core.global.config.repository; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/global/security/oauth/service/CustomOAuth2UserService.java b/backend/core/src/main/java/site/timecapsulearchive/core/global/security/oauth/service/CustomOAuth2UserService.java index cddd7fa14..4030aca79 100644 --- a/backend/core/src/main/java/site/timecapsulearchive/core/global/security/oauth/service/CustomOAuth2UserService.java +++ b/backend/core/src/main/java/site/timecapsulearchive/core/global/security/oauth/service/CustomOAuth2UserService.java @@ -76,6 +76,15 @@ private Member saveMember(final SocialType socialType, final OAuthAttributes att socialType, attributes.getOauth2UserInfo() ); + + boolean isDuplicateTag = memberRepository.checkTagDuplication(createMember.getTag()); + if (isDuplicateTag) { + log.warn("member tag duplicate - email:{}, tag:{}", createMember.getEmail(), + createMember.getTag()); + createMember.updateTagLowerCaseSocialType(); + log.warn("member tag update - tag: {}", createMember.getTag()); + } + return memberRepository.save(createMember); } } diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/global/util/TagGenerator.java b/backend/core/src/main/java/site/timecapsulearchive/core/global/util/TagGenerator.java new file mode 100644 index 000000000..e1dab1ae9 --- /dev/null +++ b/backend/core/src/main/java/site/timecapsulearchive/core/global/util/TagGenerator.java @@ -0,0 +1,59 @@ +package site.timecapsulearchive.core.global.util; + +import java.security.SecureRandom; +import java.util.stream.Collectors; +import site.timecapsulearchive.core.domain.member.entity.SocialType; + +public final class TagGenerator { + + private static final int SIZE = 6; + private static final int BOUND = 10; + private static final String EMAIL_DELIMITER = "@"; + private static final String HYPHEN = "-"; + private static final SecureRandom secureRandom = new SecureRandom(); + + /** + * 이메일과 소셜 타입(대문자)으로 태그를 생성한다. + * + * @param email 이메일 + * @param socialType 소셜 타입 + * @return 생성된 태그 ex) {@code test1234@gmail.com, SocialType.GOOGLE} -> {@code "test1234-123456GG"} + */ + public static String generate(final String email, final SocialType socialType) { + final String randomInts = generateRandomInts(); + + final String[] splitEmail = email.split(EMAIL_DELIMITER); + + return splitEmail[0] + + HYPHEN + + randomInts + + Character.toUpperCase(splitEmail[1].charAt(0)) + + socialType.name().charAt(0); + } + + private static String generateRandomInts() { + return secureRandom + .ints(SIZE, 0, BOUND) + .mapToObj(String::valueOf) + .collect(Collectors.joining()); + } + + /** + * 이메일과 소셜 타입(소문자)으로 태그를 생성한다. + * + * @param email 이메일 + * @param socialType 소셜 타입 + * @return 생성된 태그 ex) {@code test1234@gmail.com, SocialType.GOOGLE} -> {@code "test1234-123456gg"} + */ + public static String lowercase(final String email, final SocialType socialType) { + final String randomInts = generateRandomInts(); + + final String[] splitEmail = email.split(EMAIL_DELIMITER); + + return splitEmail[0] + + HYPHEN + + randomInts + + Character.toLowerCase(splitEmail[1].charAt(0)) + + Character.toLowerCase(socialType.name().charAt(0)); + } +} diff --git a/backend/core/src/main/resources/META-INF/services/org.hibernate.boot.model.FunctionContributor b/backend/core/src/main/resources/META-INF/services/org.hibernate.boot.model.FunctionContributor new file mode 100644 index 000000000..adc3ea649 --- /dev/null +++ b/backend/core/src/main/resources/META-INF/services/org.hibernate.boot.model.FunctionContributor @@ -0,0 +1 @@ +site.timecapsulearchive.core.global.config.repository.MySQLFunctionContributor \ No newline at end of file diff --git a/backend/core/src/main/resources/db/migration/V28__member_tag_fulltext_index.sql b/backend/core/src/main/resources/db/migration/V28__member_tag_fulltext_index.sql new file mode 100644 index 000000000..f4e5104a8 --- /dev/null +++ b/backend/core/src/main/resources/db/migration/V28__member_tag_fulltext_index.sql @@ -0,0 +1 @@ +alter table member add fulltext index fx_tag(tag) with parser ngram; \ No newline at end of file diff --git a/backend/core/src/test/java/site/timecapsulearchive/core/common/RepositoryTest.java b/backend/core/src/test/java/site/timecapsulearchive/core/common/RepositoryTest.java index c0430087e..18ada1717 100644 --- a/backend/core/src/test/java/site/timecapsulearchive/core/common/RepositoryTest.java +++ b/backend/core/src/test/java/site/timecapsulearchive/core/common/RepositoryTest.java @@ -7,8 +7,8 @@ import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.context.annotation.Import; import org.springframework.test.context.ActiveProfiles; -import site.timecapsulearchive.core.global.config.JpaAuditingConfig; -import site.timecapsulearchive.core.global.config.QueryDSLConfig; +import site.timecapsulearchive.core.global.config.repository.JpaAuditingConfig; +import site.timecapsulearchive.core.global.config.repository.QueryDSLConfig; @Import(value = {JpaAuditingConfig.class, QueryDSLConfig.class}) @DataJpaTest diff --git a/backend/core/src/test/java/site/timecapsulearchive/core/domain/friend/repository/MemberFriendQueryRepositoryTest.java b/backend/core/src/test/java/site/timecapsulearchive/core/domain/friend/repository/MemberFriendQueryRepositoryTest.java index 7831111fe..09ae4cf3d 100644 --- a/backend/core/src/test/java/site/timecapsulearchive/core/domain/friend/repository/MemberFriendQueryRepositoryTest.java +++ b/backend/core/src/test/java/site/timecapsulearchive/core/domain/friend/repository/MemberFriendQueryRepositoryTest.java @@ -1,6 +1,7 @@ package site.timecapsulearchive.core.domain.friend.repository; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.SoftAssertions.assertSoftly; import com.querydsl.jpa.impl.JPAQueryFactory; @@ -11,6 +12,8 @@ import java.util.Collections; import java.util.List; import java.util.Objects; +import java.util.Optional; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -19,7 +22,8 @@ import org.springframework.data.domain.Slice; import org.springframework.test.context.TestConstructor; import org.springframework.test.context.TestConstructor.AutowireMode; -import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; import site.timecapsulearchive.core.common.RepositoryTest; import site.timecapsulearchive.core.common.fixture.domain.FriendInviteFixture; import site.timecapsulearchive.core.common.fixture.domain.MemberFixture; @@ -36,61 +40,89 @@ @TestConstructor(autowireMode = AutowireMode.ALL) class MemberFriendQueryRepositoryTest extends RepositoryTest { + private static final String PROPAGATION_REQUIRES_NEW = "PROPAGATION_REQUIRES_NEW"; private static final int MAX_COUNT = 10; - private static final Long FRIEND_START_ID = 1L; - private static final Long FRIEND_ID_TO_INVITE_OWNER = 11L; - private static final Long NOT_FRIEND_MEMBER_START_ID = 12L; + private static final Long FRIEND_START_ID = 2L; + private static final Long FRIEND_ID_TO_INVITE_OWNER = 12L; + private static final Long NOT_FRIEND_MEMBER_START_ID = 13L; + + private final MemberFriendQueryRepository memberFriendQueryRepository; private final List hashedNotMemberPhones = new ArrayList<>(); private final List hashedFriendPhones = new ArrayList<>(); private final List hashedNotFriendPhones = new ArrayList<>(); - private final MemberFriendQueryRepository memberFriendQueryRepository; private Long ownerId; private Long friendId; + private String friendTag; + private String notFriendTag; + private String friendInviteTag; + private String notFriendInviteTag; - MemberFriendQueryRepositoryTest(EntityManager entityManager) { + MemberFriendQueryRepositoryTest(@Autowired EntityManager entityManager) { this.memberFriendQueryRepository = new MemberFriendQueryRepositoryImpl( new JPAQueryFactory(entityManager)); } - @Transactional @BeforeEach - void setup(@Autowired EntityManager entityManager) { - Member owner = MemberFixture.member(0); - entityManager.persist(owner); - ownerId = owner.getId(); - - // owner와 친구 관계를 맺는 데이터 - List friends = MemberFixture.members(FRIEND_START_ID.intValue(), MAX_COUNT); - for (Member friend : friends) { - entityManager.persist(friend); - hashedFriendPhones.add(friend.getPhone_hash()); - - MemberFriend memberFriend = MemberFriendFixture.memberFriend(owner, friend); - entityManager.persist(memberFriend); - - MemberFriend friendMember = MemberFriendFixture.memberFriend(friend, owner); - entityManager.persist(friendMember); - } - friendId = friends.get(0).getId(); - - // owner에게 요청만 보낸 데이터 - Member inviteFriendToOwner = MemberFixture.member(FRIEND_ID_TO_INVITE_OWNER.intValue()); - entityManager.persist(inviteFriendToOwner); - - FriendInvite friendInvite = FriendInviteFixture.friendInvite(inviteFriendToOwner, owner); - entityManager.persist(friendInvite); - - //owner와 친구가 아닌 멤버 데이터 - List notFriendMembers = MemberFixture.members(NOT_FRIEND_MEMBER_START_ID.intValue(), - MAX_COUNT); - for (Member notFriend : notFriendMembers) { - entityManager.persist(notFriend); - hashedNotFriendPhones.add(notFriend.getPhone_hash()); - } - - //회원이 아닌 휴대전화번호 데이터 - hashedNotMemberPhones.addAll(MemberFixture.getPhoneBytesList(23, MAX_COUNT)); + void setup(@Autowired EntityManager entityManager, + @Autowired PlatformTransactionManager platformTransactionManager) { + TransactionTemplate transactionTemplate = new TransactionTemplate( + platformTransactionManager); + transactionTemplate.setPropagationBehaviorName(PROPAGATION_REQUIRES_NEW); + + transactionTemplate.executeWithoutResult(status -> { + // owner 데이터 + Member owner = MemberFixture.member(1); + entityManager.persist(owner); + ownerId = owner.getId(); + + // owner와 친구 관계를 맺는 데이터 + List friends = MemberFixture.members(FRIEND_START_ID.intValue(), MAX_COUNT); + for (Member friend : friends) { + entityManager.persist(friend); + hashedFriendPhones.add(friend.getPhone_hash()); + + MemberFriend memberFriend = MemberFriendFixture.memberFriend(owner, friend); + entityManager.persist(memberFriend); + + MemberFriend friendMember = MemberFriendFixture.memberFriend(friend, owner); + entityManager.persist(friendMember); + } + friendId = friends.get(0).getId(); + friendTag = friends.get(0).getTag(); + + // owner에게 친구 요청만 보낸 멤버 데이터 + Member inviteFriendToOwner = MemberFixture.member(FRIEND_ID_TO_INVITE_OWNER.intValue()); + entityManager.persist(inviteFriendToOwner); + friendInviteTag =inviteFriendToOwner.getTag(); + + FriendInvite friendInvite = FriendInviteFixture.friendInvite(inviteFriendToOwner, + owner); + entityManager.persist(friendInvite); + + //owner와 친구가 아닌 멤버 데이터 + List notFriendMembers = MemberFixture.members( + NOT_FRIEND_MEMBER_START_ID.intValue(), + MAX_COUNT); + for (Member notFriend : notFriendMembers) { + entityManager.persist(notFriend); + hashedNotFriendPhones.add(notFriend.getPhone_hash()); + } + + //owner에게 친구 요청을 보내지 않은 데이터 + notFriendInviteTag = notFriendMembers.get(0).getTag(); + //owner와 친구가 아닌 데이터 + notFriendTag = notFriendMembers.get(0).getTag(); + }); + } + + @AfterEach + void clear(@Autowired EntityManager entityManager) { + entityManager.createNativeQuery("SET FOREIGN_KEY_CHECKS=0").executeUpdate(); + entityManager.createNativeQuery("TRUNCATE TABLE friend_invite").executeUpdate(); + entityManager.createNativeQuery("TRUNCATE TABLE member_friend").executeUpdate(); + entityManager.createNativeQuery("TRUNCATE TABLE member").executeUpdate(); + entityManager.createNativeQuery("SET FOREIGN_KEY_CHECKS=1").executeUpdate(); } @ParameterizedTest @@ -134,7 +166,7 @@ void setup(@Autowired EntityManager entityManager) { } @Test - void 친구_요청만_보낸_사용자가_친구_목록_조회하면_빈_리스트가_나온다() { + void 친구_요청만_보낸_사용자가_친구_목록_조회하면_리스트가_나온다() { //given int size = 20; ZonedDateTime now = ZonedDateTime.now(ZoneId.of("UTC")).plusDays(5); @@ -274,31 +306,82 @@ void setup(@Autowired EntityManager entityManager) { assertThat(friends).isEmpty(); } - @ParameterizedTest - @ValueSource(ints = {3, 6, 10, 5}) - void 사용자가_친구_태그로_친구관계를_조회하면_친구인_경우_True를_반환한다(int friendId) { + @Test + void 태그로_검색하면_가장_비슷한_태그를_가진_사용자_한_명만_반환한다() { + //given + String tag = "testTag"; + + //when + Optional dto = memberFriendQueryRepository.findFriendsByTag( + ownerId, tag); + + //then + assertThat(dto).isPresent(); + } + + @Test + void 일치하는_태그로_검색하면_일치하는_태그를_가진_사용자_한_명만만_나온다() { + //given + //when + SearchFriendSummaryDtoByTag dto = memberFriendQueryRepository.findFriendsByTag( + ownerId, friendTag).orElseThrow(); + + //then + assertThat(dto.id()).isEqualTo(friendId); + } + + @Test + void 일치하지_않는_태그로_검색하면_결과가_나오지_않는다() { //given + String tag = "trash"; + + //when + //then + assertThatThrownBy(() -> memberFriendQueryRepository.findFriendsByTag( + ownerId, tag).orElseThrow()); + } + + @Test + void 사용자가_친구_태그로_친구관계를_조회하면_친구인_경우_True를_반환한다() { + //given + //when SearchFriendSummaryDtoByTag dto = memberFriendQueryRepository.findFriendsByTag( - ownerId, friendId + "testTag").orElseThrow(); + ownerId, friendTag).orElseThrow(); + + //then + assertThat(dto.isFriend()).isTrue(); + } + @Test + void 사용자가_친구_태그로_친구관계를_조회하면_친구가_아닌_경우_False를_반환한다() { + //given //when - Boolean isFriend = dto.isFriend(); + SearchFriendSummaryDtoByTag dto = memberFriendQueryRepository.findFriendsByTag( + ownerId, notFriendTag).orElseThrow(); //then - assertThat(isFriend).isTrue(); + assertThat(dto.isFriend()).isFalse(); } - @ParameterizedTest - @ValueSource(ints = {12, 15, 19, 21}) - void 사용자가_친구_태그로_친구관계를_조회하면_친구가_아닌_경우_False를_반환한다(int friendId) { + @Test + void 사용자가_친구_태그로_친구초대관계를_조회하면_친구_초대한_경우_True를_반환한다() { //given + //when SearchFriendSummaryDtoByTag dto = memberFriendQueryRepository.findFriendsByTag( - ownerId, friendId + "testTag").orElseThrow(); + ownerId, friendInviteTag).orElseThrow(); + //then + assertThat(dto.isFriendInviteToMe()).isTrue(); + } + + @Test + void 사용자가_친구_태그로_친구초대관계를_조회하면_친구_초대하지_않은_경우_False를_반환한다() { + //given //when - Boolean isFriend = dto.isFriend(); + SearchFriendSummaryDtoByTag dto = memberFriendQueryRepository.findFriendsByTag( + ownerId, notFriendInviteTag).orElseThrow(); //then - assertThat(isFriend).isFalse(); + assertThat(dto.isFriendInviteToMe()).isFalse(); } } \ No newline at end of file diff --git a/backend/core/src/test/java/site/timecapsulearchive/core/domain/friend/service/FriendQueryServiceTest.java b/backend/core/src/test/java/site/timecapsulearchive/core/domain/friend/service/FriendQueryServiceTest.java index 8eea61d4b..6c940d657 100644 --- a/backend/core/src/test/java/site/timecapsulearchive/core/domain/friend/service/FriendQueryServiceTest.java +++ b/backend/core/src/test/java/site/timecapsulearchive/core/domain/friend/service/FriendQueryServiceTest.java @@ -77,7 +77,7 @@ class FriendQueryServiceTest { .willReturn(summaryDtoByTag); //when - SearchTagFriendSummaryResponse actualResponse = friendQueryService.searchFriend( + SearchFriendSummaryDtoByTag actualResponse = friendQueryService.searchFriend( memberId, tag); //then