From eef2e2bf5516b0422a09119a831352c09bf564c3 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 16 Oct 2026 14:04:17 +0900 Subject: [PATCH] =?UTF-8?q?feat(creatorname):=20=EB=A0=88=EA=B1=B0?= =?UTF-8?q?=EC=8B=9C=20=EC=A1=B0=ED=9A=8C=EC=99=80=20=EA=B2=80=EC=83=89?= =?UTF-8?q?=EC=97=90=20=EC=96=B8=EC=96=B4=EB=B3=84=20=EC=9D=B4=EB=A6=84?= =?UTF-8?q?=EC=9D=84=20=EC=A0=81=EC=9A=A9=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/admin/live/AdminLiveService.kt | 1 + .../sodalive/api/home/HomeService.kt | 14 +- .../applicant/AuditionApplicantRepository.kt | 14 +- .../applicant/AuditionApplicantService.kt | 5 +- .../vividnext/sodalive/can/CanRepository.kt | 22 +- .../co/vividnext/sodalive/can/CanService.kt | 6 +- .../content/AudioContentController.kt | 11 +- .../content/AudioContentRepository.kt | 30 +- .../sodalive/content/AudioContentService.kt | 15 +- .../comment/AudioContentCommentController.kt | 10 +- .../comment/AudioContentCommentRepository.kt | 42 +- .../comment/AudioContentCommentService.kt | 20 +- .../sodalive/content/order/OrderRepository.kt | 31 +- .../sodalive/content/order/OrderService.kt | 8 +- .../playlist/AudioContentPlaylistService.kt | 7 +- .../content/theme/AudioContentThemeService.kt | 3 +- .../explorer/ExplorerQueryRepository.kt | 108 +++- .../sodalive/explorer/ExplorerService.kt | 53 +- .../ChannelDonationMessageRepository.kt | 25 +- .../channelDonation/ChannelDonationService.kt | 8 +- .../CreatorCommunityService.kt | 2 + .../live/recommend/LiveRecommendRepository.kt | 41 +- .../live/recommend/LiveRecommendService.kt | 19 +- .../reservation/LiveReservationListRow.kt | 13 + .../reservation/LiveReservationRepository.kt | 78 ++- .../reservation/LiveReservationService.kt | 49 +- .../room/GetLiveRoomUserProfileResponse.kt | 4 + .../sodalive/live/room/LiveRoomListRow.kt | 3 + .../sodalive/live/room/LiveRoomRepository.kt | 117 +++- .../sodalive/live/room/LiveRoomService.kt | 140 +++-- .../live/room/detail/GetRoomDetailResponse.kt | 4 +- .../room/like/GetLiveRoomHeartListResponse.kt | 4 +- .../room/visit/LiveRoomVisitRepository.kt | 29 +- .../live/room/visit/LiveRoomVisitService.kt | 11 +- .../sodalive/member/MemberRepository.kt | 36 +- .../sodalive/member/MemberService.kt | 15 +- .../member/block/BlockMemberRepository.kt | 10 +- .../sodalive/rank/RankingRepository.kt | 22 +- .../vividnext/sodalive/rank/RankingService.kt | 1 - .../sodalive/search/SearchRepository.kt | 44 +- .../sodalive/search/SearchService.kt | 21 +- .../sodalive/api/home/HomeServiceTest.kt | 4 +- .../vividnext/sodalive/can/CanServiceTest.kt | 3 +- ...AudioContentRepositoryLocalizedNameTest.kt | 328 +++++++++++ .../content/AudioContentServiceTest.kt | 2 + .../AudioContentCommentNameMetadataTest.kt | 91 +++ .../ExplorerLocalizedNameEndToEndTest.kt | 259 +++++++++ ...xplorerQueryRepositoryLocalizedNameTest.kt | 90 +++ .../sodalive/explorer/ExplorerServiceTest.kt | 29 +- .../ChannelDonationMessageRepositoryTest.kt | 19 +- .../ChannelDonationServiceTest.kt | 25 +- .../CreatorCommunityServiceTest.kt | 1 + .../recommend/LiveRecommendRepositoryTest.kt | 3 +- .../recommend/LiveRecommendServiceTest.kt | 4 +- .../LiveRoomLocalizedNameIntegrationTest.kt | 523 ++++++++++++++++++ ...iveRoomServiceAdultVisibilityPolicyTest.kt | 21 +- .../sodalive/live/room/LiveRoomServiceTest.kt | 2 + .../MemberRepositoryLocalizedNameTest.kt | 108 ++++ .../member/MemberServiceCacheEvictionTest.kt | 2 + .../MemberServiceContentPreferenceTest.kt | 2 + .../osiv/OsivLazyLoadingRegressionTest.kt | 2 +- .../SearchRepositoryLocalizedNameTest.kt | 297 ++++++++++ .../sodalive/search/SearchServiceTest.kt | 3 +- ...LegacyCommunityPostCharacterizationTest.kt | 1 + .../LegacyFanTalkReplyCharacterizationTest.kt | 13 +- .../LegacyCurrentNameReadIntegrationTest.kt | 467 ++++++++++++++++ 66 files changed, 3022 insertions(+), 373 deletions(-) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationListRow.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomListRow.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepositoryLocalizedNameTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentNameMetadataTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerLocalizedNameEndToEndTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepositoryLocalizedNameTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomLocalizedNameIntegrationTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/member/MemberRepositoryLocalizedNameTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/search/SearchRepositoryLocalizedNameTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/creatorname/application/LegacyCurrentNameReadIntegrationTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/live/AdminLiveService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/live/AdminLiveService.kt index bb6d442c..213d4eda 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/live/AdminLiveService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/live/AdminLiveService.kt @@ -342,6 +342,7 @@ class AdminLiveService( type = FcmEventType.CANCEL_LIVE, category = PushNotificationCategory.LIVE, title = room.member!!.nickname, + titleMemberId = room.member!!.id, messageKey = "live.room.fcm.message.canceled", senderMemberId = room.member!!.id, args = listOf(room.title), diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt index e93ed984..bc444c2e 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt @@ -24,6 +24,7 @@ import kr.co.vividnext.sodalive.query.recommend.RecommendChannelQueryService import kr.co.vividnext.sodalive.rank.ContentRankingSortType import kr.co.vividnext.sodalive.rank.RankingRepository import kr.co.vividnext.sodalive.rank.RankingService +import kr.co.vividnext.sodalive.v2.creatorname.application.CreatorLocalizedNameService import org.springframework.beans.factory.annotation.Value import org.springframework.data.domain.Pageable import org.springframework.stereotype.Service @@ -49,6 +50,7 @@ class HomeService( private val explorerQueryRepository: ExplorerQueryRepository, private val langContext: LangContext, + private val localizedNameService: CreatorLocalizedNameService, private val memberContentPreferenceService: MemberContentPreferenceService, @Value("\${cloud.aws.cloud-front.host}") @@ -87,7 +89,7 @@ class HomeService( timezone = timezone ) - val creatorRankingMembers = rankingRepository.getCreatorRankings(memberId = memberId) + val creatorRankingMembers = rankingRepository.getCreatorRankings(memberId = memberId, lang = langContext.lang) val creatorRankingIds = creatorRankingMembers.mapNotNull { it.id } val followedCreatorIds = if (memberId != null) { explorerQueryRepository.getFollowedCreatorIds(creatorRankingIds, memberId) @@ -99,7 +101,7 @@ class HomeService( val creatorId = creator.id!! val follow = memberId != null && followedCreatorIds.contains(creatorId) - creator.toExplorerSectionCreator(imageHost, follow) + creator.copy(follow = follow) } val latestContentThemeList = contentThemeService.getActiveThemeOfContent( @@ -171,6 +173,10 @@ class HomeService( isAdult = isAdult, contentType = resolvedContentType ) + val recommendedNames = localizedNameService.findCurrentNames( + recommendChannelList.map { it.channelId }, + langContext.lang + ) val freeContentList = getRandomizedContentList( memberId = memberId, @@ -212,7 +218,9 @@ class HomeService( dayOfWeekSeriesList = translatedDayOfWeekSeriesList, popularCharacters = translatedPopularCharacters, contentRanking = contentRanking, - recommendChannelList = recommendChannelList, + recommendChannelList = recommendChannelList.map { + it.copy(creatorNickname = recommendedNames[it.channelId] ?: it.creatorNickname) + }, freeContentList = freeContentList, pointAvailableContentList = pointAvailableContentList, recommendContentList = getRecommendContentListByPreference( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/audition/applicant/AuditionApplicantRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/audition/applicant/AuditionApplicantRepository.kt index 7d56c8a3..f4fe070b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/audition/applicant/AuditionApplicantRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/audition/applicant/AuditionApplicantRepository.kt @@ -5,7 +5,9 @@ import kr.co.vividnext.sodalive.audition.AuditionApplicant import kr.co.vividnext.sodalive.audition.QAuditionApplicant.auditionApplicant import kr.co.vividnext.sodalive.audition.QAuditionRole.auditionRole import kr.co.vividnext.sodalive.audition.QAuditionVote.auditionVote +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.QMember.member +import kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence.CreatorNameJoin import org.springframework.beans.factory.annotation.Value import org.springframework.data.jpa.repository.JpaRepository @@ -18,7 +20,8 @@ interface AuditionApplicantQueryRepository { auditionRoleId: Long, sortType: AuditionApplicantSortType, offset: Long, - limit: Long + limit: Long, + lang: Lang = Lang.KO ): List fun findActiveApplicantByMemberIdAndRoleId(memberId: Long, roleId: Long): AuditionApplicant? @@ -64,8 +67,10 @@ class AuditionApplicantQueryRepositoryImpl( auditionRoleId: Long, sortType: AuditionApplicantSortType, offset: Long, - limit: Long + limit: Long, + lang: Lang ): List { + val creatorName = CreatorNameJoin(member, lang, "applicantMemberName") val orderBy = if (sortType == AuditionApplicantSortType.LIKES) { auditionVote.id.count().desc() } else { @@ -77,7 +82,7 @@ class AuditionApplicantQueryRepositoryImpl( QGetAuditionRoleApplicantItem( auditionApplicant.id, member.id, - member.nickname, + creatorName.displayName, member.profileImage.prepend("/").prepend(cloudFrontHost), auditionApplicant.voicePath.prepend("/").prepend(cloudFrontHost), auditionVote.id.count() @@ -85,6 +90,7 @@ class AuditionApplicantQueryRepositoryImpl( ) .from(auditionApplicant) .innerJoin(auditionApplicant.member, member) + .leftJoin(creatorName.name).on(creatorName.condition) .innerJoin(auditionApplicant.role, auditionRole) .leftJoin(auditionVote).on(auditionApplicant.id.eq(auditionVote.applicant.id)) .where( @@ -92,7 +98,7 @@ class AuditionApplicantQueryRepositoryImpl( auditionApplicant.isActive.isTrue, member.isActive.isTrue ) - .groupBy(auditionApplicant.id) + .groupBy(auditionApplicant.id, member.id, creatorName.name.displayName) .orderBy(orderBy) .offset(offset) .limit(limit) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/audition/applicant/AuditionApplicantService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/audition/applicant/AuditionApplicantService.kt index d7a8e7de..c66d781c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/audition/applicant/AuditionApplicantService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/audition/applicant/AuditionApplicantService.kt @@ -6,6 +6,7 @@ import kr.co.vividnext.sodalive.audition.AuditionApplicant import kr.co.vividnext.sodalive.audition.role.AuditionRoleRepository import kr.co.vividnext.sodalive.aws.s3.S3Uploader import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.utils.generateFileName import org.springframework.beans.factory.annotation.Value @@ -21,6 +22,7 @@ class AuditionApplicantService( private val s3Uploader: S3Uploader, private val objectMapper: ObjectMapper, + private val langContext: LangContext, @Value("\${cloud.aws.s3.bucket}") private val bucket: String @@ -36,7 +38,8 @@ class AuditionApplicantService( auditionRoleId = auditionRoleId, sortType = sortType, offset = offset, - limit = limit + limit = limit, + lang = langContext.lang ) return GetAuditionApplicantListResponse( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanRepository.kt index eba01100..676b5fa2 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanRepository.kt @@ -19,10 +19,12 @@ import kr.co.vividnext.sodalive.chat.character.QChatCharacter.chatCharacter import kr.co.vividnext.sodalive.chat.character.image.QCharacterImage.characterImage import kr.co.vividnext.sodalive.content.QAudioContent.audioContent import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.QCreatorCommunity.creatorCommunity +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.QMember import kr.co.vividnext.sodalive.member.QMember.member +import kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence.CreatorNameJoin import org.springframework.data.domain.Pageable import org.springframework.data.jpa.repository.JpaRepository import org.springframework.stereotype.Repository @@ -32,7 +34,7 @@ interface CanRepository : JpaRepository, CanQueryRepository interface CanQueryRepository { fun findAllByStatusAndCurrency(status: CanStatus, currency: String?): List - fun getCanUseStatus(member: Member, pageable: Pageable, container: String): List + fun getCanUseStatus(member: Member, pageable: Pageable, container: String, lang: Lang = Lang.KO): List fun getCanChargeStatus(member: Member, pageable: Pageable, container: String): List fun isExistPaidLiveRoom(memberId: Long, roomId: Long): UseCan? fun getCanUsedForLiveRoomNotRefund(memberId: Long, roomId: Long, canUsage: CanUsage = CanUsage.LIVE): UseCan? @@ -66,11 +68,15 @@ class CanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : CanQue .fetch() } - override fun getCanUseStatus(member: Member, pageable: Pageable, container: String): List { + override fun getCanUseStatus(member: Member, pageable: Pageable, container: String, lang: Lang): List { val qRoomMember = QMember("roomMember") val qAudioContentMember = QMember("audioContentMember") val qCommunityPostMember = QMember("communityPostMember") val qRecipientMember = QMember("recipientMember") + val roomName = CreatorNameJoin(qRoomMember, lang, "receiptRoomCreatorName") + val audioName = CreatorNameJoin(qAudioContentMember, lang, "receiptAudioCreatorName") + val communityName = CreatorNameJoin(qCommunityPostMember, lang, "receiptCommunityCreatorName") + val recipientName = CreatorNameJoin(qRecipientMember, lang, "receiptRecipientCreatorName") val gatewayCondition = when (container) { "aos" -> useCanCalculate.paymentGateway.`in`( @@ -96,23 +102,26 @@ class CanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : CanQue useCan.can, useCan.rewardCan, useCan.createdAt, - qRoomMember.nickname, + roomName.displayName, liveRoom.title, - qAudioContentMember.nickname, + audioName.displayName, audioContent.title, - qCommunityPostMember.nickname, + communityName.displayName, audition.title, chatCharacter.name, - qRecipientMember.nickname + recipientName.displayName ) ) .from(useCan) .leftJoin(useCan.room, liveRoom) .leftJoin(liveRoom.member, qRoomMember) + .leftJoin(roomName.name).on(roomName.condition) .leftJoin(useCan.audioContent, audioContent) .leftJoin(audioContent.member, qAudioContentMember) + .leftJoin(audioName.name).on(audioName.condition) .leftJoin(useCan.communityPost, creatorCommunity) .leftJoin(creatorCommunity.member, qCommunityPostMember) + .leftJoin(communityName.name).on(communityName.condition) .leftJoin(useCan.auditionApplicant, auditionApplicant) .leftJoin(auditionApplicant.role, auditionRole) .leftJoin(auditionRole.audition, audition) @@ -120,6 +129,7 @@ class CanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : CanQue .leftJoin(characterImage.chatCharacter, chatCharacter) .innerJoin(useCan.useCanCalculates, useCanCalculate) .leftJoin(qRecipientMember).on(useCanCalculate.recipientCreatorId.eq(qRecipientMember.id)) + .leftJoin(recipientName.name).on(recipientName.condition) .where( useCan.member.id.eq(member.id) .and(useCan.isRefund.isFalse) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt index d51443a3..10431274 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt @@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.can import kr.co.vividnext.sodalive.can.charge.ChargeStatus import kr.co.vividnext.sodalive.can.use.CanUsage import kr.co.vividnext.sodalive.common.CountryContext +import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.member.Member import org.springframework.data.domain.Pageable import org.springframework.stereotype.Service @@ -14,7 +15,8 @@ import java.time.format.DateTimeFormatter @Transactional(readOnly = true) class CanService( private val repository: CanRepository, - private val countryContext: CountryContext + private val countryContext: CountryContext, + private val langContext: LangContext ) { fun getCans(forcedCurrency: String? = null): List { val currency = forcedCurrency ?: when (countryContext.countryCode) { @@ -43,7 +45,7 @@ class CanService( ZoneId.of("UTC") } - return repository.getCanUseStatus(member, pageable, container) + return repository.getCanUseStatus(member, pageable, container, langContext.lang) .map { val title: String = when (it.canUsage) { CanUsage.HEART, CanUsage.DONATION, CanUsage.SPIN_ROULETTE -> { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentController.kt index fd52954e..0f86c596 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentController.kt @@ -3,9 +3,11 @@ package kr.co.vividnext.sodalive.content import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.content.like.PutAudioContentLikeRequest +import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService import kr.co.vividnext.sodalive.member.contentpreference.ViewerContentPreference +import kr.co.vividnext.sodalive.v2.creatorname.application.CreatorLocalizedNameService import org.springframework.data.domain.Pageable import org.springframework.lang.Nullable import org.springframework.security.access.prepost.PreAuthorize @@ -29,7 +31,9 @@ import java.time.temporal.TemporalAdjusters @RequestMapping("/audio-content") class AudioContentController( private val service: AudioContentService, - private val memberContentPreferenceService: MemberContentPreferenceService + private val memberContentPreferenceService: MemberContentPreferenceService, + private val localizedNameService: CreatorLocalizedNameService, + private val langContext: LangContext ) { @PostMapping @PreAuthorize("hasRole('CREATOR')") @@ -214,7 +218,10 @@ class AudioContentController( offset = pageable.offset, limit = pageable.pageSize.toLong(), sortType = sortType ?: "매출" - ) + ).let { response -> + val names = localizedNameService.findCurrentNames(response.items.map { it.creatorId }, langContext.lang) + response.copy(items = response.items.map { it.copy(creatorNickname = names[it.creatorId] ?: it.creatorNickname) }) + } ) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepository.kt index cf2ea86c..26905664 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepository.kt @@ -29,9 +29,11 @@ import kr.co.vividnext.sodalive.content.translation.QContentTranslation.contentT import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seriesContent import kr.co.vividnext.sodalive.event.QEvent.event +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.QMember.member import kr.co.vividnext.sodalive.member.block.QBlockMember.blockMember +import kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence.CreatorNameJoin import org.springframework.beans.factory.annotation.Value import org.springframework.data.jpa.repository.JpaRepository import org.springframework.stereotype.Repository @@ -85,7 +87,8 @@ interface AudioContentQueryRepository { contentType: ContentType, offset: Long = 0, limit: Long = 20, - isFree: Boolean = false + isFree: Boolean = false, + lang: Lang = Lang.KO ): List fun findAlarmContentByTheme( @@ -167,7 +170,7 @@ interface AudioContentQueryRepository { fun isContentCreator(contentId: Long, memberId: Long): Boolean - fun fetchContentForPlaylist(contentIdList: List): List + fun fetchContentForPlaylist(contentIdList: List, lang: Lang = Lang.KO): List fun getCoverImageById(id: Long): String? fun findPreviousContent(seriesId: Long, title: String, isAdult: Boolean): OtherContentResponse? fun findNextContent(seriesId: Long, title: String, isAdult: Boolean): OtherContentResponse? @@ -467,8 +470,10 @@ class AudioContentQueryRepositoryImpl( contentType: ContentType, offset: Long, limit: Long, - isFree: Boolean + isFree: Boolean, + lang: Lang ): List { + val creatorName = CreatorNameJoin(member, lang, "themeCreatorName") val blockMemberCondition = blockMember.isActive.isTrue .and( blockMember.member.id.eq(member.id) @@ -535,7 +540,7 @@ class AudioContentQueryRepositoryImpl( where = where.and(audioContent.price.loe(0)) } - return queryFactory + val query = queryFactory .select( QGetAudioContentMainItem( audioContent.id, @@ -543,7 +548,7 @@ class AudioContentQueryRepositoryImpl( audioContent.title, member.id, member.profileImage.prepend("/").prepend(imageHost), - member.nickname, + creatorName.displayName, audioContent.price, audioContent.duration, audioContent.isPointAvailable @@ -557,7 +562,8 @@ class AudioContentQueryRepositoryImpl( .offset(offset) .limit(limit) .orderBy(*orderBy.toTypedArray()) - .fetch() + query.leftJoin(creatorName.name).on(creatorName.condition) + return query.fetch() } override fun findAlarmContentByTheme( @@ -1210,7 +1216,8 @@ class AudioContentQueryRepositoryImpl( return foundedContentId != null && foundedContentId == contentId } - override fun fetchContentForPlaylist(contentIdList: List): List { + override fun fetchContentForPlaylist(contentIdList: List, lang: Lang): List { + val creatorName = CreatorNameJoin(member, lang, "playlistCreatorName") return queryFactory .select( QAudioContentPlaylistContent( @@ -1219,12 +1226,13 @@ class AudioContentQueryRepositoryImpl( audioContentTheme.theme, audioContent.coverImage.prepend("/").prepend(imageHost), audioContent.duration, - member.nickname, + creatorName.displayName, member.profileImage.prepend("/").prepend(imageHost) ) ) .from(audioContent) .innerJoin(audioContent.member, member) + .leftJoin(creatorName.name).on(creatorName.condition) .innerJoin(audioContent.theme, audioContentTheme) .where(audioContent.id.`in`(contentIdList)) .fetch() @@ -1466,6 +1474,7 @@ class AudioContentQueryRepositoryImpl( } val coverImageUrl = audioContent.coverImage.prepend("/").prepend(imageHost) + val creatorName = CreatorNameJoin(member, Lang.fromCode(locale), "latestCreatorName") var select = queryFactory .select( @@ -1474,11 +1483,12 @@ class AudioContentQueryRepositoryImpl( audioContent.title, contentTranslation.renderedPayload, coverImageUrl, - member.nickname, + creatorName.displayName, audioContent.isPointAvailable ) .from(audioContent) .innerJoin(audioContent.member, member) + .leftJoin(creatorName.name).on(creatorName.condition) .innerJoin(audioContent.theme, audioContentTheme) .leftJoin(contentTranslation) .on(contentTranslation.contentId.eq(audioContent.id).and(contentTranslation.locale.eq(locale))) @@ -1523,7 +1533,7 @@ class AudioContentQueryRepositoryImpl( val payload = row.get(contentTranslation.renderedPayload) val translatedTitle = payload?.title val imageUrl = row.get(coverImageUrl)!! - val creatorNickname = row.get(member.nickname)!! + val creatorNickname = row.get(creatorName.displayName)!! val isPointAvailableValue = row.get(audioContent.isPointAvailable) ?: false AudioContentMainItem( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt index 38949296..2b18766e 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt @@ -40,6 +40,7 @@ import kr.co.vividnext.sodalive.member.MemberRepository import kr.co.vividnext.sodalive.member.block.BlockMemberRepository import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService import kr.co.vividnext.sodalive.utils.generateFileName +import kr.co.vividnext.sodalive.v2.creatorname.application.CreatorLocalizedNameService import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService import kr.co.vividnext.sodalive.v2.recommendation.application.CreatorContentViewHistoryService import org.slf4j.LoggerFactory @@ -87,6 +88,7 @@ class AudioContentService( private val messageSource: SodaMessageSource, private val langContext: LangContext, + private val localizedNameService: CreatorLocalizedNameService, private val contentThemeTranslationRepository: ContentThemeTranslationRepository, private val homeFollowingNewsPublishService: HomeFollowingNewsPublishService, @@ -512,6 +514,7 @@ class AudioContentService( type = FcmEventType.UPLOAD_CONTENT, category = PushNotificationCategory.CONTENT, title = audioContent.member!!.nickname, + titleMemberId = audioContent.member!!.id, messageKey = "content.notification.uploaded_message", senderMemberId = audioContent.member!!.id, args = listOf(audioContent.title), @@ -542,6 +545,7 @@ class AudioContentService( type = FcmEventType.UPLOAD_CONTENT, category = PushNotificationCategory.CONTENT, title = audioContent.member!!.nickname, + titleMemberId = audioContent.member!!.id, messageKey = "content.notification.uploaded_message", senderMemberId = audioContent.member!!.id, args = listOf(audioContent.title), @@ -702,7 +706,8 @@ class AudioContentService( isContentCreator = creatorId == member.id!!, timezone = timezone, offset = 0, - limit = 1 + limit = 1, + lang = langContext.lang ) } else { listOf() @@ -844,7 +849,7 @@ class AudioContentService( audioContent.limited != null && audioContent.remaining != null ) { - orderRepository.findBuyerListByContentId(id) + orderRepository.findBuyerListByContentId(id, langContext.lang) } else { listOf() } @@ -920,6 +925,10 @@ class AudioContentService( ) } + val names = localizedNameService.findLocalizedNames( + listOf(creatorId), + langContext.lang + ) return GetAudioContentDetailResponse( contentId = audioContent.id!!, title = audioContent.title, @@ -955,7 +964,7 @@ class AudioContentService( isAvailablePin = isAvailablePin, creator = AudioContentCreator( creatorId = creatorId, - nickname = creator.nickname, + nickname = names[creatorId] ?: creator.nickname, profileImageUrl = if (creator.profileImage != null) { "$coverImageHost/${creator.profileImage}" } else { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentController.kt index 7f2b8af5..3e9ef1ba 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentController.kt @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.content.comment import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.useraction.ActionType import kr.co.vividnext.sodalive.useraction.UserActionService @@ -18,7 +19,8 @@ import org.springframework.web.bind.annotation.RestController @RestController class AudioContentCommentController( private val service: AudioContentCommentService, - private val userActionService: UserActionService + private val userActionService: UserActionService, + private val langContext: LangContext ) { @PostMapping("/audio-content/comment") fun registerComment( @@ -79,7 +81,8 @@ class AudioContentCommentController( audioContentId = audioContentId, memberId = member.id!!, timezone = timezone, - pageable = pageable + pageable = pageable, + lang = langContext.lang ) ) } @@ -98,7 +101,8 @@ class AudioContentCommentController( commentId = commentId, memberId = member.id!!, timezone = timezone, - pageable = pageable + pageable = pageable, + lang = langContext.lang ) ) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentRepository.kt index 90cd4530..c6aaeeb2 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentRepository.kt @@ -1,16 +1,18 @@ package kr.co.vividnext.sodalive.content.comment +import com.querydsl.core.types.dsl.CaseBuilder import com.querydsl.core.types.dsl.Expressions import com.querydsl.jpa.JPAExpressions import com.querydsl.jpa.impl.JPAQueryFactory import kr.co.vividnext.sodalive.content.QAudioContent.audioContent import kr.co.vividnext.sodalive.content.comment.QAudioContentComment.audioContentComment -import kr.co.vividnext.sodalive.extensions.removeDeletedNicknamePrefix import kr.co.vividnext.sodalive.fcm.PushTokenInfo import kr.co.vividnext.sodalive.fcm.QPushToken.pushToken import kr.co.vividnext.sodalive.fcm.QPushTokenInfo +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.QMember.member import kr.co.vividnext.sodalive.member.block.QBlockMember.blockMember +import kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence.CreatorNameJoin import org.springframework.data.jpa.repository.JpaRepository import org.springframework.stereotype.Repository import java.time.LocalDateTime @@ -26,7 +28,8 @@ interface AudioContentCommentQueryRepository { isContentCreator: Boolean, timezone: String, offset: Long, - limit: Int + limit: Int, + lang: Lang? = null ): List fun totalCountCommentByContentId(contentId: Long, memberId: Long, isContentCreator: Boolean): Int @@ -37,7 +40,8 @@ interface AudioContentCommentQueryRepository { memberId: Long, timezone: String, offset: Long, - limit: Int + limit: Int, + lang: Lang? = null ): List fun findPushTokenByContentIdAndCommentParentIdMyMemberId( @@ -58,8 +62,13 @@ class AudioContentCommentQueryRepositoryImpl( isContentCreator: Boolean, timezone: String, offset: Long, - limit: Int + limit: Int, + lang: Lang? ): List { + val creatorName = lang?.let { CreatorNameJoin(member, it, "audioCommentWriterName") } + val originalName = CaseBuilder().`when`(member.nickname.startsWith("deleted_")) + .then(member.nickname.substring(8)).otherwise(member.nickname) + val displayName = creatorName?.name?.displayName?.coalesce(originalName) ?: originalName var where = audioContentComment.audioContent.id.eq(contentId) .and(audioContentComment.isActive.isTrue) .and(audioContentComment.parent.isNull) @@ -85,12 +94,12 @@ class AudioContentCommentQueryRepositoryImpl( "%Y.%m.%d %W %h:%i %p" ) - return queryFactory + val query = queryFactory .select( QGetAudioContentCommentListItem( audioContentComment.id, audioContentComment.member.id, - audioContentComment.member.nickname, + displayName, audioContentComment.member.profileImage.prepend("/").prepend(cloudFrontHost), audioContentComment.comment, audioContentComment.languageCode, @@ -107,10 +116,10 @@ class AudioContentCommentQueryRepositoryImpl( .offset(offset) .limit(limit.toLong()) .orderBy(audioContentComment.createdAt.desc()) - .fetch() + if (creatorName != null) query.leftJoin(creatorName.name).on(creatorName.condition) + return query.fetch() .map { it.copy( - nickname = it.nickname.removeDeletedNicknamePrefix(), replyCount = commentReplyCountByAudioContentCommentId(it.id, memberId) ) } @@ -160,8 +169,13 @@ class AudioContentCommentQueryRepositoryImpl( memberId: Long, timezone: String, offset: Long, - limit: Int + limit: Int, + lang: Lang? ): List { + val creatorName = lang?.let { CreatorNameJoin(member, it, "audioReplyWriterName") } + val originalName = CaseBuilder().`when`(member.nickname.startsWith("deleted_")) + .then(member.nickname.substring(8)).otherwise(member.nickname) + val displayName = creatorName?.name?.displayName?.coalesce(originalName) ?: originalName val formattedDate = Expressions.stringTemplate( "DATE_FORMAT({0}, {1})", Expressions.dateTimeTemplate( @@ -174,12 +188,12 @@ class AudioContentCommentQueryRepositoryImpl( "%Y.%m.%d %W %h:%i %p" ) - return queryFactory + val query = queryFactory .select( QGetAudioContentCommentListItem( audioContentComment.id, audioContentComment.member.id, - audioContentComment.member.nickname, + displayName, audioContentComment.member.profileImage.prepend("/").prepend(cloudFrontHost), audioContentComment.comment, audioContentComment.languageCode, @@ -201,10 +215,8 @@ class AudioContentCommentQueryRepositoryImpl( .offset(offset) .limit(limit.toLong()) .orderBy(audioContentComment.createdAt.desc()) - .fetch() - .map { - it.copy(nickname = it.nickname.removeDeletedNicknamePrefix()) - } + if (creatorName != null) query.leftJoin(creatorName.name).on(creatorName.condition) + return query.fetch() } override fun findPushTokenByContentIdAndCommentParentIdMyMemberId( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentService.kt index 4d4d3743..a9f4e2ea 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentService.kt @@ -9,10 +9,12 @@ import kr.co.vividnext.sodalive.fcm.FcmDeepLinkValue import kr.co.vividnext.sodalive.fcm.FcmEvent import kr.co.vividnext.sodalive.fcm.FcmEventType import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.block.BlockMemberRepository +import kr.co.vividnext.sodalive.v2.creatorname.application.CreatorLocalizedNameService import org.springframework.beans.factory.annotation.Value import org.springframework.context.ApplicationEventPublisher import org.springframework.data.domain.Pageable @@ -30,6 +32,7 @@ class AudioContentCommentService( private val orderRepository: OrderRepository, private val messageSource: SodaMessageSource, private val langContext: LangContext, + private val localizedNameService: CreatorLocalizedNameService, @Value("\${cloud.aws.cloud-front.host}") private val cloudFrontHost: String @@ -49,7 +52,9 @@ class AudioContentCommentService( val creator = audioContent.member!! val isBlocked = isBlockedBetweenMembers(memberId = member.id!!, creatorId = creator.id!!) if (isBlocked) { - throw SodaException(formatMessage("content.comment.error.blocked_by_creator", creator.nickname)) + val name = localizedNameService.findLocalizedNames(listOf(creator.id!!), langContext.lang)[creator.id] + ?: creator.nickname + throw SodaException(formatMessage("content.comment.error.blocked_by_creator", name)) } val (isExistsAudioContent, _) = orderRepository.isExistOrderedAndOrderType( @@ -86,6 +91,7 @@ class AudioContentCommentService( } else { member.nickname }, + titleMemberId = parent?.member?.id ?: member.id, messageKey = if (parent != null) { "content.comment.notification.reply" } else { @@ -140,7 +146,8 @@ class AudioContentCommentService( audioContentId: Long, memberId: Long, timezone: String, - pageable: Pageable + pageable: Pageable, + lang: Lang? = null ): GetAudioContentCommentListResponse { val audioContent = audioContentRepository.findByIdOrNull(id = audioContentId) ?: return GetAudioContentCommentListResponse(totalCount = 0, items = listOf()) @@ -157,7 +164,8 @@ class AudioContentCommentService( isContentCreator = isContentCreator, timezone = timezone, offset = pageable.offset, - limit = pageable.pageSize + limit = pageable.pageSize, + lang = lang ) val totalCount = repository.totalCountCommentByContentId( contentId = audioContentId, @@ -172,7 +180,8 @@ class AudioContentCommentService( commentId: Long, memberId: Long, timezone: String, - pageable: Pageable + pageable: Pageable, + lang: Lang? = null ): GetAudioContentCommentListResponse { val parentComment = repository.findByIdOrNull(id = commentId) ?: return GetAudioContentCommentListResponse(totalCount = 0, items = listOf()) @@ -187,7 +196,8 @@ class AudioContentCommentService( memberId = memberId, timezone = timezone, offset = pageable.offset, - limit = pageable.pageSize + limit = pageable.pageSize, + lang = lang ) val totalCount = repository.commentReplyCountByAudioContentCommentId(commentId, memberId) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/order/OrderRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/order/OrderRepository.kt index faa75229..d1a3a489 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/order/OrderRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/order/OrderRepository.kt @@ -10,7 +10,9 @@ import kr.co.vividnext.sodalive.content.main.QGetAudioContentMainItem import kr.co.vividnext.sodalive.content.order.QOrder.order import kr.co.vividnext.sodalive.content.playlist.ContentIdAndEndDateData import kr.co.vividnext.sodalive.content.playlist.QContentIdAndEndDateData +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.QMember.member +import kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence.CreatorNameJoin import org.springframework.beans.factory.annotation.Value import org.springframework.data.jpa.repository.JpaRepository import org.springframework.stereotype.Repository @@ -29,7 +31,8 @@ interface OrderQueryRepository { coverImageHost: String, memberId: Long, offset: Long = 0, - limit: Long = 10 + limit: Long = 10, + lang: Lang = Lang.KO ): List fun totalAudioContentOrderListCount(memberId: Long, dateTime: LocalDateTime): Int @@ -38,12 +41,13 @@ interface OrderQueryRepository { coverImageHost: String, memberId: Long, offset: Long = 0, - limit: Long = 20 + limit: Long = 20, + lang: Lang = Lang.KO ): List fun findOrderedContent(contentIdList: List, memberId: Long): List fun findEndDateByContentId(contentIdList: List, memberId: Long): List - fun findBuyerListByContentId(contentId: Long): List + fun findBuyerListByContentId(contentId: Long, lang: Lang = Lang.KO): List fun findByMemberIdAndContentId(memberId: Long, contentId: Long, createdAt: LocalDateTime): Order? } @@ -134,8 +138,10 @@ class OrderQueryRepositoryImpl( coverImageHost: String, memberId: Long, offset: Long, - limit: Long + limit: Long, + lang: Lang ): List { + val creatorName = CreatorNameJoin(member, lang, "orderCreatorName") val where = order.member.id.eq(memberId) .and(order.isActive.isTrue) .and( @@ -153,7 +159,7 @@ class OrderQueryRepositoryImpl( audioContent.id, audioContent.coverImage.prepend("/").prepend(coverImageHost), audioContent.member.id, - audioContent.member.nickname, + creatorName.displayName, audioContent.title, audioContent.theme.theme, audioContent.duration, @@ -165,6 +171,8 @@ class OrderQueryRepositoryImpl( ) .from(order) .innerJoin(order.audioContent, audioContent) + .innerJoin(audioContent.member, member) + .leftJoin(creatorName.name).on(creatorName.condition) .where(where) .offset(offset) .limit(limit) @@ -197,8 +205,10 @@ class OrderQueryRepositoryImpl( coverImageHost: String, memberId: Long, offset: Long, - limit: Long + limit: Long, + lang: Lang ): List { + val creatorName = CreatorNameJoin(member, lang, "mainOrderCreatorName") return queryFactory .select( QGetAudioContentMainItem( @@ -209,7 +219,7 @@ class OrderQueryRepositoryImpl( member.profileImage .prepend("/") .prepend(coverImageHost), - member.nickname, + creatorName.displayName, audioContent.price, audioContent.duration, audioContent.isPointAvailable @@ -218,6 +228,7 @@ class OrderQueryRepositoryImpl( .from(order) .innerJoin(order.audioContent, audioContent) .innerJoin(audioContent.member, member) + .leftJoin(creatorName.name).on(creatorName.condition) .where( order.member.id.eq(memberId) .and(order.isActive.isTrue) @@ -266,16 +277,18 @@ class OrderQueryRepositoryImpl( .fetch() } - override fun findBuyerListByContentId(contentId: Long): List { + override fun findBuyerListByContentId(contentId: Long, lang: Lang): List { + val creatorName = CreatorNameJoin(member, lang, "contentBuyerName") return queryFactory .select( QContentBuyer( - member.nickname, + creatorName.displayName, member.profileImage.prepend("/").prepend(imageHost) ) ) .from(order) .innerJoin(order.member, member) + .leftJoin(creatorName.name).on(creatorName.condition) .where( order.isActive.isTrue, order.audioContent.id.eq(contentId), diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/order/OrderService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/order/OrderService.kt index 6db421aa..4ee63f68 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/order/OrderService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/order/OrderService.kt @@ -9,6 +9,7 @@ import kr.co.vividnext.sodalive.content.PurchaseOption import kr.co.vividnext.sodalive.content.comment.AudioContentCommentRepository import kr.co.vividnext.sodalive.content.like.AudioContentLikeRepository import kr.co.vividnext.sodalive.content.main.GetAudioContentMainItem +import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.point.PointUsageService import org.springframework.beans.factory.annotation.Value @@ -26,6 +27,7 @@ class OrderService( private val audioContentCommentQueryRepository: AudioContentCommentRepository, private val audioContentLikeQueryRepository: AudioContentLikeRepository, private val limitedEditionOrderRepository: LimitedEditionOrderRepository, + private val langContext: LangContext, @Value("\${cloud.aws.cloud-front.host}") private val audioContentCoverImageHost: String @@ -123,7 +125,8 @@ class OrderService( coverImageHost = audioContentCoverImageHost, memberId = member.id!!, offset = offset, - limit = limit + limit = limit, + lang = langContext.lang ) .map { val commentCount = audioContentCommentQueryRepository @@ -154,7 +157,8 @@ class OrderService( coverImageHost = audioContentCoverImageHost, memberId = memberId, offset = 0, - limit = 20 + limit = 20, + lang = langContext.lang ) } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/playlist/AudioContentPlaylistService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/playlist/AudioContentPlaylistService.kt index 367eae3c..5c6cc5c7 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/playlist/AudioContentPlaylistService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/playlist/AudioContentPlaylistService.kt @@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.content.playlist import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.content.AudioContentRepository import kr.co.vividnext.sodalive.content.order.OrderRepository +import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.live.roulette.RedisIdGenerator import kr.co.vividnext.sodalive.member.Member import org.springframework.data.repository.findByIdOrNull @@ -16,7 +17,8 @@ class AudioContentPlaylistService( private val idGenerator: RedisIdGenerator, private val orderRepository: OrderRepository, private val audioContentRepository: AudioContentRepository, - private val redisRepository: AudioContentPlaylistRedisRepository + private val redisRepository: AudioContentPlaylistRedisRepository, + private val langContext: LangContext ) { fun createPlaylist(request: CreatePlaylistRequest, member: Member) { if (request.contentIdAndOrderList.size >= 30) { @@ -173,7 +175,8 @@ class AudioContentPlaylistService( } val contentList = audioContentRepository.fetchContentForPlaylist( - contentIdList = contentIdAndOrderList.map { it.contentId } + contentIdList = contentIdAndOrderList.map { it.contentId }, + lang = langContext.lang ) val orderMap = contentIdAndOrderList.sortedBy { it.order } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeService.kt index f76dbda3..ece624e8 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeService.kt @@ -112,7 +112,8 @@ class AudioContentThemeService( isAdult = memberContentPreferenceService.isAdultVisibleForQuery(member, isAdultContentVisible), contentType = contentType, offset = offset, - limit = limit + limit = limit, + lang = langContext.lang ) return GetContentByThemeResponse( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt index 930e11af..a91f8c37 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt @@ -29,6 +29,7 @@ import kr.co.vividnext.sodalive.live.room.LiveRoom import kr.co.vividnext.sodalive.live.room.LiveRoomType import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom import kr.co.vividnext.sodalive.live.room.cancel.QLiveRoomCancel.liveRoomCancel +import kr.co.vividnext.sodalive.live.room.detail.GetRoomDetailUser import kr.co.vividnext.sodalive.live.room.visit.QLiveRoomVisit.liveRoomVisit import kr.co.vividnext.sodalive.member.Gender import kr.co.vividnext.sodalive.member.Member @@ -40,6 +41,8 @@ import kr.co.vividnext.sodalive.member.block.QBlockMember.blockMember import kr.co.vividnext.sodalive.member.following.QCreatorFollowing.creatorFollowing import kr.co.vividnext.sodalive.member.tag.QCreatorTag.creatorTag import kr.co.vividnext.sodalive.member.tag.QMemberCreatorTag.memberCreatorTag +import kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence.CreatorNameJoin +import kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence.creatorNameContains import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Repository import java.math.BigDecimal @@ -122,13 +125,16 @@ class ExplorerQueryRepository( creatorId: Long, limit: Long, offset: Long = 0, - withDonationCan: Boolean + withDonationCan: Boolean, + lang: Lang? = null ): List { val member = QMember("user") + val creatorName = lang?.let { CreatorNameJoin(member, it, "legacyDonationRankName") } + val displayName = creatorName?.displayName ?: member.nickname val donation = useCan.rewardCan.add(useCan.can).sum() - return queryFactory - .select(member, donation) + val query = queryFactory + .select(member, donation, displayName) .from(useCanCalculate) .innerJoin(useCanCalculate.useCan, useCan) .innerJoin(useCan.member, member) @@ -144,16 +150,17 @@ class ExplorerQueryRepository( .or(useCan.canUsage.eq(CanUsage.LIVE)) ) ) - .groupBy(useCan.member.id) + .groupBy(useCan.member.id, member.id, displayName) .orderBy(donation.desc(), member.id.desc()) - .fetch() + if (creatorName != null) query.leftJoin(creatorName.name).on(creatorName.condition) + return query.fetch() .asSequence() .map { val account = it.get(member)!! val donationCan = it.get(donation)!! MemberDonationRankingResponse( account.id!!, - account.nickname, + it.get(displayName)!!, if (account.profileImage != null) { "$cloudFrontHost/${account.profileImage}" } else { @@ -165,9 +172,12 @@ class ExplorerQueryRepository( .toList() } - fun getNewCreators(): List { + fun getNewCreators(lang: Lang = Lang.KO): List { + val creatorName = CreatorNameJoin(member, lang, "newExplorerCreatorName") return queryFactory - .selectFrom(member) + .select(member, creatorName.displayName) + .from(member) + .leftJoin(creatorName.name).on(creatorName.condition) .where( member.role.eq(MemberRole.CREATOR) .and(member.createdAt.goe(LocalDateTime.now().minusDays(30))) @@ -176,9 +186,13 @@ class ExplorerQueryRepository( .orderBy(Expressions.numberTemplate(Double::class.java, "function('rand')").asc()) .limit(20) .fetch() + .map { row -> + row.get(member)!!.toExplorerSectionCreator(cloudFrontHost).copy(nickname = row.get(creatorName.displayName)!!) + } } - fun findCreatorByGender(gender: Int): List { + fun findCreatorByGender(gender: Int, lang: Lang = Lang.KO): List { + val creatorName = CreatorNameJoin(member, lang, "genderExplorerCreatorName") val today = LocalDate.now() val offset = (today.dayOfMonth - 1) % 26 val characters = ('A'..'Z').map { it.toString() }.plus((0..9).map { it.toString() }) @@ -192,7 +206,9 @@ class ExplorerQueryRepository( val randomExpression = Expressions.numberTemplate(Double::class.java, "function('rand')").asc() return queryFactory - .selectFrom(member) + .select(member, creatorName.displayName) + .from(member) + .leftJoin(creatorName.name).on(creatorName.condition) .innerJoin(member.auth, auth) .where( member.role.eq(MemberRole.CREATOR) @@ -204,17 +220,30 @@ class ExplorerQueryRepository( .offset(0) .limit(10) .fetch() + .map { row -> + row.get(member)!!.toExplorerSectionCreator(cloudFrontHost).copy(nickname = row.get(creatorName.displayName)!!) + } } - fun getSearchChannel(channel: String, memberId: Long): List { - return queryFactory.selectFrom(member) + fun getSearchChannel(channel: String, memberId: Long, lang: Lang? = null): List { + val creatorName = lang?.let { CreatorNameJoin(member, it, "channelSearchCreatorName") } + val displayName = creatorName?.displayName ?: member.nickname + val query = queryFactory.select( + Projections.constructor( + GetRoomDetailUser::class.java, + member.id, + displayName, + member.profileImage.coalesce("profile/default-profile.png").prepend("/").prepend(cloudFrontHost) + ) + ).from(member) .where( - member.nickname.containsIgnoreCase(channel) + creatorNameContains(member, channel) .and(member.isActive.isTrue) .and(member.id.ne(memberId)) .and(member.role.eq(MemberRole.CREATOR)) ) - .fetch() + if (creatorName != null) query.leftJoin(creatorName.name).on(creatorName.condition) + return query.fetch() } fun getMember(memberId: Long): Member? { @@ -224,7 +253,8 @@ class ExplorerQueryRepository( .fetchFirst() } - fun getSimilarCreatorList(creatorId: Long): List { + fun getSimilarCreatorList(creatorId: Long, lang: Lang = Lang.KO): List { + val creatorName = CreatorNameJoin(member, lang, "similarCreatorName") val creator = queryFactory .selectFrom(member) .where(member.id.eq(creatorId)) @@ -242,14 +272,15 @@ class ExplorerQueryRepository( .and(member.tags.size().gt(0)) .and(memberCreatorTag.tag.isActive.isTrue) - return getRandomCreatorList(where = where, limit = 3) + return getRandomCreatorList(where = where, limit = 3, lang = lang) } val cnt = memberCreatorTag.member.count() val similarCreators = queryFactory - .select(member, cnt) + .select(member, cnt, creatorName.displayName) .from(memberCreatorTag) .innerJoin(memberCreatorTag.member, member) + .leftJoin(creatorName.name).on(creatorName.condition) .innerJoin(memberCreatorTag.tag, creatorTag) .leftJoin(member.auth, auth) .where( @@ -265,7 +296,7 @@ class ExplorerQueryRepository( ) ) ) - .groupBy(memberCreatorTag.member.id) + .groupBy(memberCreatorTag.member.id, member.id, creatorName.name.displayName) .orderBy(cnt.desc()) .limit(3) .fetch() @@ -273,7 +304,7 @@ class ExplorerQueryRepository( val account = it.get(member)!! SimilarCreatorResponse( account.id!!, - account.nickname, + it.get(creatorName.displayName)!!, if (account.profileImage != null) { "$cloudFrontHost/${account.profileImage}" } else { @@ -298,29 +329,32 @@ class ExplorerQueryRepository( where = where.and(member.id.ne(userId)) } - val additionalCreator = getRandomCreatorList(where = where, limit = (3 - similarCreators.size).toLong()) + val additionalCreator = getRandomCreatorList(where = where, limit = (3 - similarCreators.size).toLong(), lang = lang) return similarCreators + additionalCreator } else { return similarCreators } } - private fun getRandomCreatorList(where: Predicate, limit: Long): List { + private fun getRandomCreatorList(where: Predicate, limit: Long, lang: Lang): List { + val creatorName = CreatorNameJoin(member, lang, "randomCreatorName") val result = queryFactory - .select(member) + .select(member, creatorName.displayName) .from(memberCreatorTag) .join(memberCreatorTag.member, member) + .leftJoin(creatorName.name).on(creatorName.condition) .innerJoin(memberCreatorTag.tag, creatorTag) .leftJoin(member.auth, auth) - .groupBy(memberCreatorTag.member.id) + .groupBy(memberCreatorTag.member.id, member.id, creatorName.name.displayName) .where(where) .limit(limit) .orderBy(Expressions.numberTemplate(Double::class.java, "function('rand')").asc()) .fetch() - .map { + .map { row -> + val it = row.get(member)!! SimilarCreatorResponse( it.id!!, - it.nickname, + row.get(creatorName.displayName)!!, if (it.profileImage != null) { "$cloudFrontHost/${it.profileImage}" } else { @@ -474,7 +508,13 @@ class ExplorerQueryRepository( .fetchFirst() ?: "" } - fun getCheersList(creatorId: Long, memberId: Long, timezone: String, offset: Long, limit: Long): GetCheersResponse { + fun getCheersList( + creatorId: Long, + memberId: Long, + timezone: String, + offset: Long, + limit: Long + ): GetCheersResponse { val cheersDatePattern = messageSource .getMessage("explorer.date.cheers.format", langContext.lang) ?: messageSource.getMessage("explorer.date.cheers.format", Lang.KO).orEmpty() @@ -671,8 +711,10 @@ class ExplorerQueryRepository( fun getFollowerList( creatorId: Long, offset: Long, - limit: Long + limit: Long, + lang: Lang = Lang.KO ): List { + val creatorName = CreatorNameJoin(member, lang, "followerMemberName") val blockMemberCondition = blockMember.member.id.eq(creatorId) .and(blockMember.isActive.isTrue) .and(blockMember.blockedMember.id.eq(member.id)) @@ -682,12 +724,13 @@ class ExplorerQueryRepository( QGetFollowerListDto( member.id, member.profileImage.prepend("/").prepend(cloudFrontHost), - member.nickname, + creatorName.displayName, member.role ) ) .from(creatorFollowing) .innerJoin(creatorFollowing.member, member) + .leftJoin(creatorName.name).on(creatorName.condition) .leftJoin(blockMember).on(blockMemberCondition) .where( member.isActive.isTrue @@ -805,12 +848,17 @@ class ExplorerQueryRepository( .fetchFirst() } - fun getCreatorRankings(): List { + fun getCreatorRankings(lang: Lang = Lang.KO): List { + val creatorName = CreatorNameJoin(member, lang, "explorerRankCreatorName") return queryFactory - .select(member) + .select(member, creatorName.displayName) .from(creatorRanking) .innerJoin(creatorRanking.member, member) + .leftJoin(creatorName.name).on(creatorName.condition) .orderBy(creatorRanking.ranking.asc()) .fetch() + .map { row -> + row.get(member)!!.toExplorerSectionCreator(cloudFrontHost).copy(nickname = row.get(creatorName.displayName)!!) + } } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt index f632cfe1..63f93ec9 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt @@ -32,6 +32,7 @@ import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.MemberService import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService import kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.dto.CreatorChannelFanTalkResponse +import kr.co.vividnext.sodalive.v2.creatorname.application.CreatorLocalizedNameService import org.springframework.beans.factory.annotation.Value import org.springframework.context.ApplicationEventPublisher import org.springframework.data.domain.Pageable @@ -65,15 +66,15 @@ class ExplorerService( private val messageSource: SodaMessageSource, private val langContext: LangContext, + private val localizedNameService: CreatorLocalizedNameService, @Value("\${cloud.aws.cloud-front.host}") private val cloudFrontHost: String ) { fun getCreatorRank(memberId: Long): GetExplorerSectionResponse { val creatorRankings = queryRepository - .getCreatorRankings() + .getCreatorRankings(langContext.lang) .filter { !isBlockedBetweenMembers(memberId = memberId, otherMemberId = it.id!!) } - .map { it.toExplorerSectionCreator(cloudFrontHost) } val currentDateTime = LocalDateTime.now() val lastMonday = currentDateTime @@ -107,9 +108,8 @@ class ExplorerService( // 인기 크리에이터 val creatorRankings = queryRepository - .getCreatorRankings() + .getCreatorRankings(langContext.lang) .filter { !isBlockedBetweenMembers(memberId = member.id!!, otherMemberId = it.id!!) } - .map { it.toExplorerSectionCreator(cloudFrontHost) } val currentDateTime = LocalDateTime.now() val lastMonday = currentDateTime @@ -140,9 +140,8 @@ class ExplorerService( // 새로 시작 (newCreators) val newCreators = queryRepository - .getNewCreators() + .getNewCreators(langContext.lang) .filter { !isBlockedBetweenMembers(memberId = member.id!!, otherMemberId = it.id!!) } - .map { it.toExplorerSectionCreator(cloudFrontHost) } val newCreatorsSection = GetExplorerSectionResponse( title = messageSource.getMessage("explorer.section.new_creators.title", langContext.lang).orEmpty(), @@ -159,9 +158,8 @@ class ExplorerService( .orEmpty(), color = "39abde", creators = queryRepository - .findCreatorByGender(1) + .findCreatorByGender(1, langContext.lang) .filter { !isBlockedBetweenMembers(memberId = member.id!!, otherMemberId = it.id!!) } - .map { it.toExplorerSectionCreator(cloudFrontHost) } ) val femaleCreatorSection = GetExplorerSectionResponse( @@ -170,9 +168,8 @@ class ExplorerService( .orEmpty(), color = "ffa517", creators = queryRepository - .findCreatorByGender(0) + .findCreatorByGender(0, langContext.lang) .filter { !isBlockedBetweenMembers(memberId = member.id!!, otherMemberId = it.id!!) } - .map { it.toExplorerSectionCreator(cloudFrontHost) } ) val randomInt = Random.nextInt(0, 2) @@ -192,22 +189,23 @@ class ExplorerService( throw SodaException(messageKey = "explorer.search.channel.min_length") } - return queryRepository.getSearchChannel(channel, member.id!!) + return queryRepository.getSearchChannel(channel, member.id!!, langContext.lang) .asSequence() .filter { !isBlockedBetweenMembers(memberId = member.id!!, otherMemberId = it.id!!) } - .map { GetRoomDetailUser(it, cloudFrontHost) } .toList() } fun getCreatorDetail(creatorId: Long, memberId: Long): GetCreatorDetailResponse { val creatorAccount = queryRepository.getMember(creatorId) ?: throw SodaException(messageKey = "member.validation.user_not_found") + val creatorName = localizedNameService.findLocalizedNames(listOf(creatorId), langContext.lang)[creatorId] + ?: creatorAccount.nickname if (isBlockedBetweenMembers(memberId = memberId, otherMemberId = creatorId)) { val messageTemplate = messageSource .getMessage("explorer.creator.blocked_access", langContext.lang) .orEmpty() - throw SodaException(message = String.format(messageTemplate, creatorAccount.nickname)) + throw SodaException(message = String.format(messageTemplate, creatorName)) } if (creatorAccount.role != MemberRole.CREATOR) { @@ -239,7 +237,7 @@ class ExplorerService( } return GetCreatorDetailResponse( - nickname = creatorAccount.nickname, + nickname = creatorName, profileImageUrl = if (creatorAccount.profileImage != null) { "$cloudFrontHost/${creatorAccount.profileImage}" } else { @@ -266,6 +264,8 @@ class ExplorerService( // 크리에이터(유저) 정보 val creatorAccount = queryRepository.getMember(creatorId) ?: throw SodaException(messageKey = "member.validation.user_not_found") + val creatorName = localizedNameService.findLocalizedNames(listOf(creatorId), langContext.lang)[creatorId] + ?: creatorAccount.nickname // 차단된 사용자 체크 val isBlockedByCreator = memberService.isBlocked(blockedMemberId = member.id!!, memberId = creatorId) @@ -274,7 +274,7 @@ class ExplorerService( val messageTemplate = messageSource .getMessage("explorer.creator.blocked_access", langContext.lang) .orEmpty() - throw SodaException(message = String.format(messageTemplate, creatorAccount.nickname)) + throw SodaException(message = String.format(messageTemplate, creatorName)) } val isCreator = creatorAccount.role == MemberRole.CREATOR @@ -300,7 +300,7 @@ class ExplorerService( // 추천 크리에이터 val similarCreatorList = if (isCreator) { - queryRepository.getSimilarCreatorList(creatorId) + queryRepository.getSimilarCreatorList(creatorId, langContext.lang) } else { listOf() } @@ -437,7 +437,7 @@ class ExplorerService( } else { "$cloudFrontHost/profile/default-profile.png" }, - nickname = creatorAccount.nickname, + nickname = creatorName, tags = creatorAccount.tags.asSequence().filter { it.tag.isActive }.map { it.tag.tag }.toList(), introduce = creatorAccount.introduce, instagramUrl = creatorAccount.instagramUrl, @@ -450,9 +450,9 @@ class ExplorerService( isNotification = creatorFollowing?.isFollow ?: false, notificationRecipientCount = notificationRecipientCount ), - userDonationRanking = memberDonationRanking, + userDonationRanking = renderDonationRanking(memberDonationRanking), similarCreatorList = similarCreatorList, - liveRoomList = liveRoomList, + liveRoomList = liveRoomList.map { it.copy(managerNickname = creatorName) }, contentList = translatedContentList, latestContent = latestContent, totalContentCount = totalContentCount, @@ -537,7 +537,7 @@ class ExplorerService( null }, totalCount = donationMemberTotal, - userDonationRanking = donationRanking + userDonationRanking = renderDonationRanking(donationRanking) ) } @@ -548,7 +548,12 @@ class ExplorerService( ): GetFollowerListResponse { val totalCount = queryRepository.getFollowerListTotalCount(creatorId) - val followerList = queryRepository.getFollowerList(creatorId, pageable.offset, pageable.pageSize.toLong()) + val followerList = queryRepository.getFollowerList( + creatorId, + pageable.offset, + pageable.pageSize.toLong(), + langContext.lang + ) .map { val creatorFollowing = if (it.role == MemberRole.CREATOR) { queryRepository.getCreatorFollowing( @@ -684,6 +689,7 @@ class ExplorerService( type = FcmEventType.CHANGE_NOTICE, category = PushNotificationCategory.COMMUNITY, title = member.nickname, + titleMemberId = member.id, messageKey = "explorer.notice.fcm.message", senderMemberId = member.id, creatorId = member.id!!, @@ -693,6 +699,11 @@ class ExplorerService( ) } + private fun renderDonationRanking(items: List): List { + val names = localizedNameService.findCurrentNames(items.map { it.userId }, langContext.lang) + return items.map { it.copy(nickname = names[it.userId] ?: it.nickname) } + } + private fun isBlockedBetweenMembers(memberId: Long, otherMemberId: Long): Boolean { return memberService.isBlocked(blockedMemberId = memberId, memberId = otherMemberId) || memberService.isBlocked(blockedMemberId = otherMemberId, memberId = memberId) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationMessageRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationMessageRepository.kt index b5f18b33..dc00b1a7 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationMessageRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationMessageRepository.kt @@ -1,7 +1,11 @@ package kr.co.vividnext.sodalive.explorer.profile.channelDonation +import com.querydsl.core.types.dsl.CaseBuilder import com.querydsl.jpa.impl.JPAQueryFactory import kr.co.vividnext.sodalive.explorer.profile.channelDonation.QChannelDonationMessage.channelDonationMessage +import kr.co.vividnext.sodalive.i18n.Lang +import kr.co.vividnext.sodalive.member.QMember.member +import kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence.CreatorNameJoin import org.springframework.data.jpa.repository.JpaRepository import org.springframework.stereotype.Repository import java.time.LocalDateTime @@ -18,8 +22,9 @@ interface ChannelDonationMessageQueryRepository { offset: Long, limit: Long, startDateTime: LocalDateTime, - endDateTime: LocalDateTime - ): List + endDateTime: LocalDateTime, + lang: Lang = Lang.KO + ): List> fun getChannelDonationMessageTotalCount( creatorId: Long, @@ -40,8 +45,13 @@ class ChannelDonationMessageQueryRepositoryImpl( offset: Long, limit: Long, startDateTime: LocalDateTime, - endDateTime: LocalDateTime - ): List { + endDateTime: LocalDateTime, + lang: Lang + ): List> { + val creatorName = CreatorNameJoin(member, lang, "channelDonorName") + val originalName = CaseBuilder().`when`(member.nickname.startsWith("deleted_")) + .then(member.nickname.substring(8)).otherwise(member.nickname) + val displayName = creatorName.name.displayName.coalesce(originalName) val where = whereCondition( creatorId = creatorId, memberId = memberId, @@ -51,8 +61,10 @@ class ChannelDonationMessageQueryRepositoryImpl( ) return queryFactory - .selectFrom(channelDonationMessage) - .leftJoin(channelDonationMessage.member).fetchJoin() + .select(channelDonationMessage, displayName) + .from(channelDonationMessage) + .leftJoin(channelDonationMessage.member, member).fetchJoin() + .leftJoin(creatorName.name).on(creatorName.condition) .where(where) .offset(offset) .limit(limit) @@ -61,6 +73,7 @@ class ChannelDonationMessageQueryRepositoryImpl( channelDonationMessage.id.desc() ) .fetch() + .map { row -> row.get(channelDonationMessage)!! to row.get(displayName)!! } } override fun getChannelDonationMessageTotalCount( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationService.kt index 9cb46f3a..b4f1cd57 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationService.kt @@ -3,7 +3,6 @@ package kr.co.vividnext.sodalive.explorer.profile.channelDonation import kr.co.vividnext.sodalive.can.payment.CanPaymentService import kr.co.vividnext.sodalive.can.use.CanUsage import kr.co.vividnext.sodalive.common.SodaException -import kr.co.vividnext.sodalive.extensions.removeDeletedNicknamePrefix import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.member.Member @@ -99,12 +98,13 @@ class ChannelDonationService( offset = offset, limit = limit, startDateTime = startDateTime, - endDateTime = endDateTime - ).map { + endDateTime = endDateTime, + lang = langContext.lang + ).map { (it, nickname) -> GetChannelDonationListItem( id = it.id!!, memberId = it.member!!.id!!, - nickname = it.member!!.nickname.removeDeletedNicknamePrefix(), + nickname = nickname, profileUrl = if (it.member!!.profileImage != null) { "$cloudFrontHost/${it.member!!.profileImage}" } else { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt index 9eb1805b..ada466e7 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt @@ -140,6 +140,7 @@ class CreatorCommunityService( type = FcmEventType.CHANGE_NOTICE, category = PushNotificationCategory.COMMUNITY, title = member.nickname, + titleMemberId = member.id, messageKey = "creator.community.fcm.new_post", senderMemberId = member.id, creatorId = member.id!!, @@ -533,6 +534,7 @@ class CreatorCommunityService( type = FcmEventType.INDIVIDUAL, category = PushNotificationCategory.COMMUNITY, title = member.nickname, + titleMemberId = member.id, messageKey = "creator.community.fcm.new_comment", senderMemberId = member.id, recipients = listOf(creatorId), diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendRepository.kt index cece4a56..fc641568 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendRepository.kt @@ -10,6 +10,7 @@ import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.QMember.member import kr.co.vividnext.sodalive.member.block.QBlockMember.blockMember import kr.co.vividnext.sodalive.member.following.QCreatorFollowing.creatorFollowing +import kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence.CreatorNameJoin import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Repository import java.time.LocalDateTime @@ -70,8 +71,10 @@ class LiveRecommendRepository( fun getOnAirRecommendChannelList( isBlocked: (Long) -> Boolean, isCreator: Boolean, - isAdult: Boolean + isAdult: Boolean, + lang: Lang ): List { + val creatorName = CreatorNameJoin(member, lang, "onAirRecommendedCreatorName") var where = member.role.eq(MemberRole.CREATOR) .and(member.isActive.isTrue) @@ -88,20 +91,21 @@ class LiveRecommendRepository( Projections.constructor( GetRecommendChannelResponse::class.java, member.id, - member.nickname, + creatorName.displayName, member.profileImage.prepend("/").prepend(cloudFrontHost), Expressions.asBoolean(true) ) ) .from(liveRoom) .rightJoin(liveRoom.member, member) + .leftJoin(creatorName.name).on(creatorName.condition) .where( where .and(liveRoom.isActive.isTrue) .and(liveRoom.channelName.isNotNull) .and(liveRoom.channelName.isNotEmpty) ) - .groupBy(member.id) + .groupBy(member.id, member.nickname, member.profileImage, creatorName.name.displayName) .orderBy(Expressions.numberTemplate(Double::class.java, "function('rand')").asc()) .limit(20) .fetch() @@ -113,8 +117,10 @@ class LiveRecommendRepository( fun getRecommendChannelList( withOutCreatorList: List, limit: Long, - isBlocked: (Long) -> Boolean + isBlocked: (Long) -> Boolean, + lang: Lang ): List { + val creatorName = CreatorNameJoin(member, lang, "recommendedCreatorName") val where = member.role.eq(MemberRole.CREATOR) .and(member.isActive.isTrue) @@ -123,12 +129,13 @@ class LiveRecommendRepository( Projections.constructor( GetRecommendChannelResponse::class.java, member.id, - member.nickname, + creatorName.displayName, member.profileImage.prepend("/").prepend(cloudFrontHost), Expressions.asBoolean(false) ) ) .from(member) + .leftJoin(creatorName.name).on(creatorName.condition) .where(where.and(member.id.notIn(withOutCreatorList))) .orderBy(Expressions.numberTemplate(Double::class.java, "function('rand')").asc()) .limit(limit) @@ -140,8 +147,10 @@ class LiveRecommendRepository( memberId: Long, isBlocked: (Long) -> Boolean, isCreator: Boolean, - isAdult: Boolean + isAdult: Boolean, + lang: Lang ): List { + val creatorName = CreatorNameJoin(member, lang, "onAirFollowingCreatorName") var where = member.role.eq(MemberRole.CREATOR) .and(member.isActive.isTrue) @@ -158,7 +167,7 @@ class LiveRecommendRepository( Projections.constructor( GetRecommendChannelResponse::class.java, member.id, - member.nickname, + creatorName.displayName, member.profileImage.prepend("/").prepend(cloudFrontHost), Expressions.asBoolean(true) ) @@ -166,6 +175,7 @@ class LiveRecommendRepository( .from(liveRoom, creatorFollowing) .rightJoin(liveRoom.member, member) .innerJoin(creatorFollowing.creator, member) + .leftJoin(creatorName.name).on(creatorName.condition) .where( where .and(liveRoom.member.id.eq(creatorFollowing.creator.id)) @@ -175,7 +185,7 @@ class LiveRecommendRepository( .and(liveRoom.channelName.isNotNull) .and(liveRoom.channelName.isNotEmpty) ) - .groupBy(member.id) + .groupBy(member.id, member.nickname, member.profileImage, creatorName.name.displayName) .orderBy(Expressions.numberTemplate(Double::class.java, "function('rand')").asc()) .limit(20) .fetch() @@ -188,8 +198,10 @@ class LiveRecommendRepository( memberId: Long, withOutCreatorList: List, limit: Long, - isBlocked: (Long) -> Boolean + isBlocked: (Long) -> Boolean, + lang: Lang ): List { + val creatorName = CreatorNameJoin(member, lang, "followingCreatorName") val where = member.role.eq(MemberRole.CREATOR) .and(member.isActive.isTrue) @@ -198,13 +210,14 @@ class LiveRecommendRepository( Projections.constructor( GetRecommendChannelResponse::class.java, member.id, - member.nickname, + creatorName.displayName, member.profileImage.prepend("/").prepend(cloudFrontHost), Expressions.asBoolean(false) ) ) .from(creatorFollowing) .innerJoin(creatorFollowing.creator, member) + .leftJoin(creatorName.name).on(creatorName.condition) .where( where .and(member.id.notIn(withOutCreatorList)) @@ -244,8 +257,10 @@ class LiveRecommendRepository( memberId: Long, offset: Long, limit: Long, - isBlocked: (Long) -> Boolean + isBlocked: (Long) -> Boolean, + lang: Lang ): List { + val creatorName = CreatorNameJoin(member, lang, "allFollowingCreatorName") val where = member.role.eq(MemberRole.CREATOR) .and(member.isActive.isTrue) .and(creatorFollowing.isActive.isTrue) @@ -256,7 +271,7 @@ class LiveRecommendRepository( .select( QGetCreatorFollowingAllListItem( member.id, - member.nickname, + creatorName.displayName, member.profileImage.prepend("/").prepend(cloudFrontHost), Expressions.asBoolean(true), creatorFollowing.isNotify @@ -264,10 +279,12 @@ class LiveRecommendRepository( ) .from(creatorFollowing) .innerJoin(creatorFollowing.creator, member) + .leftJoin(creatorName.name).on(creatorName.condition) .where(where) .groupBy( member.id, member.nickname, + creatorName.name.displayName, member.profileImage, creatorFollowing.isNotify ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendService.kt index 0a2a36fe..703df1c7 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendService.kt @@ -1,6 +1,7 @@ package kr.co.vividnext.sodalive.live.recommend import kr.co.vividnext.sodalive.i18n.Lang +import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.block.BlockMemberRepository @@ -13,7 +14,8 @@ class LiveRecommendService( private val repository: LiveRecommendRepository, private val blockMemberRepository: BlockMemberRepository, private val memberContentPreferenceService: MemberContentPreferenceService, - private val liveRecommendCacheService: LiveRecommendCacheService + private val liveRecommendCacheService: LiveRecommendCacheService, + private val langContext: LangContext ) { fun getRecommendLive(member: Member?, lang: Lang): List { val isAdult = if (member != null) { @@ -45,7 +47,8 @@ class LiveRecommendService( } }, isCreator = member?.role == MemberRole.CREATOR, - isAdult = isAdult + isAdult = isAdult, + lang = langContext.lang ) if (onAirChannelList.size >= 20) { @@ -63,7 +66,8 @@ class LiveRecommendService( } else { false } - } + }, + lang = langContext.lang ) return onAirChannelList + notOnAirCreatorList @@ -76,7 +80,8 @@ class LiveRecommendService( memberId = member.id!!, isBlocked = { isBlockedBetweenMembers(memberId = member.id!!, creatorId = it) }, isCreator = member.role == MemberRole.CREATOR, - isAdult = isAdult + isAdult = isAdult, + lang = langContext.lang ) if (onAirFollowingChannelList.size >= 20) { @@ -89,7 +94,8 @@ class LiveRecommendService( memberId = member.id!!, withOutCreatorList = onAirCreatorIdList, limit = (20 - onAirCreatorIdList.size).toLong(), - isBlocked = { isBlockedBetweenMembers(memberId = member.id!!, creatorId = it) } + isBlocked = { isBlockedBetweenMembers(memberId = member.id!!, creatorId = it) }, + lang = langContext.lang ) return onAirFollowingChannelList + notOnAirFollowingChannelList @@ -105,7 +111,8 @@ class LiveRecommendService( memberId = member.id!!, offset = pageable.offset, limit = pageable.pageSize.toLong(), - isBlocked = { isBlockedBetweenMembers(memberId = member.id!!, creatorId = it) } + isBlocked = { isBlockedBetweenMembers(memberId = member.id!!, creatorId = it) }, + lang = langContext.lang ) return GetCreatorFollowingAllListResponse( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationListRow.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationListRow.kt new file mode 100644 index 00000000..f2a904c0 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationListRow.kt @@ -0,0 +1,13 @@ +package kr.co.vividnext.sodalive.live.reservation + +import java.time.LocalDateTime + +data class LiveReservationListRow( + val id: Long, + val roomId: Long, + val title: String, + val coverImage: String?, + val price: Int, + val masterNickname: String, + val beginDateTime: LocalDateTime +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationRepository.kt index b161730a..7ea1cdbe 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationRepository.kt @@ -1,10 +1,17 @@ package kr.co.vividnext.sodalive.live.reservation +import com.querydsl.core.types.Projections +import com.querydsl.jpa.impl.JPAQuery import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.live.reservation.QLiveReservation.liveReservation import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom +import kr.co.vividnext.sodalive.live.room.detail.GetRoomDetailUser import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.QMember import kr.co.vividnext.sodalive.member.QMember.member +import kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence.CreatorNameJoin +import org.springframework.beans.factory.annotation.Value import org.springframework.data.jpa.repository.JpaRepository import org.springframework.stereotype.Repository @@ -12,29 +19,45 @@ import org.springframework.stereotype.Repository interface LiveReservationRepository : JpaRepository, LiveReservationQueryRepository interface LiveReservationQueryRepository { - fun getReservationList(roomId: Long): List + fun getReservationList(roomId: Long, lang: Lang): List fun cancelReservation(roomId: Long) fun getReservationBookerList(roomId: Long): List fun isExistsReservation(roomId: Long, memberId: Long): Boolean - fun getReservationListByMemberId(memberId: Long, active: Boolean): List + fun getReservationListByMemberId(memberId: Long, active: Boolean, lang: Lang): List fun getReservationByReservationAndMemberId( reservationId: Long, - memberId: Long - ): LiveReservation? + memberId: Long, + lang: Lang + ): LiveReservationListRow? fun getReservationCount(memberId: Long): Int } @Repository -class LiveReservationQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : LiveReservationQueryRepository { - override fun getReservationList(roomId: Long): List { +class LiveReservationQueryRepositoryImpl( + private val queryFactory: JPAQueryFactory, + @Value("\${cloud.aws.cloud-front.host}") + private val cloudFrontHost: String +) : LiveReservationQueryRepository { + override fun getReservationList(roomId: Long, lang: Lang): List { + val bookerName = CreatorNameJoin(member, lang, "liveReservationBookerName") return queryFactory - .selectFrom(liveReservation) + .select( + Projections.constructor( + GetRoomDetailUser::class.java, + member.id, + bookerName.displayName, + member.profileImage.coalesce("profile/default-profile.png").prepend("/").prepend(cloudFrontHost) + ) + ) + .from(liveReservation) .innerJoin(liveReservation.room, liveRoom) + .innerJoin(liveReservation.member, member) + .leftJoin(bookerName.name).on(bookerName.condition) .where( liveRoom.id.eq(roomId) .and(liveReservation.isActive.isTrue) @@ -76,11 +99,8 @@ class LiveReservationQueryRepositoryImpl(private val queryFactory: JPAQueryFacto .fetchFirst() != null } - override fun getReservationListByMemberId(memberId: Long, active: Boolean): List { - return queryFactory - .selectFrom(liveReservation) - .innerJoin(liveReservation.member, member) - .innerJoin(liveReservation.room, liveRoom) + override fun getReservationListByMemberId(memberId: Long, active: Boolean, lang: Lang): List { + return reservationRows(lang) .where( liveRoom.isActive.isTrue .and(liveReservation.isActive.eq(active)) @@ -89,11 +109,12 @@ class LiveReservationQueryRepositoryImpl(private val queryFactory: JPAQueryFacto .fetch() } - override fun getReservationByReservationAndMemberId(reservationId: Long, memberId: Long): LiveReservation? { - return queryFactory - .selectFrom(liveReservation) - .innerJoin(liveReservation.member, member) - .innerJoin(liveReservation.room, liveRoom) + override fun getReservationByReservationAndMemberId( + reservationId: Long, + memberId: Long, + lang: Lang + ): LiveReservationListRow? { + return reservationRows(lang) .where( liveReservation.id.eq(reservationId) .and(member.id.eq(memberId)) @@ -101,6 +122,29 @@ class LiveReservationQueryRepositoryImpl(private val queryFactory: JPAQueryFacto .fetchFirst() } + private fun reservationRows(lang: Lang): JPAQuery { + val owner = QMember("reservationRoomOwner") + val ownerName = CreatorNameJoin(owner, lang, "reservationRoomOwnerName") + return queryFactory + .select( + Projections.constructor( + LiveReservationListRow::class.java, + liveReservation.id, + liveRoom.id, + liveRoom.title, + liveRoom.coverImage, + liveRoom.price, + ownerName.displayName, + liveRoom.beginDateTime + ) + ) + .from(liveReservation) + .innerJoin(liveReservation.member, member) + .innerJoin(liveReservation.room, liveRoom) + .innerJoin(liveRoom.member, owner) + .leftJoin(ownerName.name).on(ownerName.condition) + } + override fun getReservationCount(memberId: Long): Int { return queryFactory .selectFrom(liveReservation) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationService.kt index de3abde4..332e02af 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationService.kt @@ -8,6 +8,7 @@ import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.live.room.LiveRoomRepository import kr.co.vividnext.sodalive.live.room.LiveRoomType import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.v2.creatorname.application.CreatorLocalizedNameService import org.springframework.beans.factory.annotation.Value import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service @@ -25,6 +26,7 @@ class LiveReservationService( private val liveReservationCancelRepository: LiveReservationCancelRepository, private val messageSource: SodaMessageSource, private val langContext: LangContext, + private val localizedNameService: CreatorLocalizedNameService, @Value("\${cloud.aws.cloud-front.host}") private val cloudFrontHost: String @@ -78,10 +80,11 @@ class LiveReservationService( "live.reservation.datetime_format", langContext.lang ).orEmpty() + val names = localizedNameService.findLocalizedNames(listOf(room.member!!.id!!), langContext.lang) return MakeLiveReservationResponse( reservationId = reservation.id!!, - nickname = room.member!!.nickname, + nickname = names[room.member!!.id] ?: room.member!!.nickname, title = room.title, beginDateString = beginDateTime.format(DateTimeFormatter.ofPattern(reservationDateFormat)), beginDateTimeUtc = room.beginDateTime @@ -98,34 +101,35 @@ class LiveReservationService( ) } + @Transactional(readOnly = true) fun getReservationList(memberId: Long, active: Boolean, timezone: String): List { val detailDateFormat = messageSource.getMessage( "live.room.datetime_format_detail", langContext.lang ).orEmpty() - return repository - .getReservationListByMemberId(memberId, active) + val reservations = repository.getReservationListByMemberId(memberId, active, langContext.lang) + return reservations .asSequence() .map { - val beginDateTime = it.room!!.beginDateTime + val beginDateTime = it.beginDateTime .atZone(ZoneId.of("UTC")) .withZoneSameInstant(ZoneId.of(timezone)) GetLiveReservationResponse( - reservationId = it.id!!, - roomId = it.room!!.id!!, - title = it.room!!.title, - coverImageUrl = if (it.room!!.coverImage != null) { - "$cloudFrontHost/${it.room!!.coverImage}" + reservationId = it.id, + roomId = it.roomId, + title = it.title, + coverImageUrl = if (it.coverImage != null) { + "$cloudFrontHost/${it.coverImage}" } else { "$cloudFrontHost/profile/default-profile.png" }, - price = it.room!!.price, - masterNickname = it.room!!.member!!.nickname, + price = it.price, + masterNickname = it.masterNickname, beginDateTime = beginDateTime.format( DateTimeFormatter.ofPattern(detailDateFormat) ), - beginDateTimeUtc = it.room!!.beginDateTime + beginDateTimeUtc = it.beginDateTime .atZone(ZoneId.of("UTC")) .format(DateTimeFormatter.ISO_LOCAL_DATE_TIME), cancelable = beginDateTime.minusHours(4).isAfter( @@ -138,33 +142,34 @@ class LiveReservationService( .toList() } + @Transactional(readOnly = true) fun getReservation(reservationId: Long, memberId: Long, timezone: String): GetLiveReservationResponse { - val reservation = repository.getReservationByReservationAndMemberId(reservationId, memberId) + val reservation = repository.getReservationByReservationAndMemberId(reservationId, memberId, langContext.lang) ?: throw SodaException(messageKey = "live.reservation.invalid_reservation") val detailDateFormat = messageSource.getMessage( "live.room.datetime_format_detail", langContext.lang ).orEmpty() - val beginDateTime = reservation.room!!.beginDateTime + val beginDateTime = reservation.beginDateTime .atZone(ZoneId.of("UTC")) .withZoneSameInstant(ZoneId.of(timezone)) return GetLiveReservationResponse( - reservationId = reservation.id!!, - roomId = reservation.room!!.id!!, - title = reservation.room!!.title, - coverImageUrl = if (reservation.room!!.coverImage != null) { - "$cloudFrontHost/${reservation.room!!.coverImage}" + reservationId = reservation.id, + roomId = reservation.roomId, + title = reservation.title, + coverImageUrl = if (reservation.coverImage != null) { + "$cloudFrontHost/${reservation.coverImage}" } else { "$cloudFrontHost/profile/default-profile.png" }, - price = reservation.room!!.price, - masterNickname = reservation.room!!.member!!.nickname, + price = reservation.price, + masterNickname = reservation.masterNickname, beginDateTime = beginDateTime.format( DateTimeFormatter.ofPattern(detailDateFormat) ), - beginDateTimeUtc = reservation.room!!.beginDateTime + beginDateTimeUtc = reservation.beginDateTime .atZone(ZoneId.of("UTC")) .format(DateTimeFormatter.ISO_LOCAL_DATE_TIME), cancelable = beginDateTime.minusHours(4).isAfter( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/GetLiveRoomUserProfileResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/GetLiveRoomUserProfileResponse.kt index b2d2cabb..7b01146c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/GetLiveRoomUserProfileResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/GetLiveRoomUserProfileResponse.kt @@ -1,5 +1,7 @@ package kr.co.vividnext.sodalive.live.room +import kr.co.vividnext.sodalive.member.Member + data class GetLiveRoomUserProfileResponse( val userId: Long, val nickname: String, @@ -19,3 +21,5 @@ data class GetLiveRoomUserProfileResponse( val isFollowing: Boolean?, val isBlock: Boolean ) + +data class LiveRoomUserProfileRow(val member: Member, val nickname: String) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomListRow.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomListRow.kt new file mode 100644 index 00000000..1946d422 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomListRow.kt @@ -0,0 +1,3 @@ +package kr.co.vividnext.sodalive.live.room + +data class LiveRoomListRow(val room: LiveRoom, val creatorNickname: String) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomRepository.kt index f7fa5f88..106bd3d0 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomRepository.kt @@ -8,17 +8,19 @@ import com.querydsl.jpa.impl.JPAQueryFactory import kr.co.vividnext.sodalive.can.use.CanUsage import kr.co.vividnext.sodalive.can.use.QUseCan.useCan import kr.co.vividnext.sodalive.can.use.QUseCanCalculate.useCanCalculate +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom import kr.co.vividnext.sodalive.live.room.QQuarterLiveRankings.quarterLiveRankings import kr.co.vividnext.sodalive.live.room.donation.GetLiveRoomDonationItem import kr.co.vividnext.sodalive.live.room.donation.QGetLiveRoomDonationItem import kr.co.vividnext.sodalive.live.room.like.GetLiveRoomHeartListItem -import kr.co.vividnext.sodalive.live.room.like.QGetLiveRoomHeartListItem import kr.co.vividnext.sodalive.member.Gender import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.QMember.member import kr.co.vividnext.sodalive.member.block.QBlockMember.blockMember +import kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence.CreatorNameJoin import org.springframework.beans.factory.annotation.Value +import org.springframework.cache.annotation.Cacheable import org.springframework.data.jpa.repository.JpaRepository import org.springframework.stereotype.Repository import java.time.LocalDateTime @@ -35,8 +37,9 @@ interface LiveRoomQueryRepository { memberId: Long?, isCreator: Boolean, isAdult: Boolean, - effectiveGender: Gender? - ): List + effectiveGender: Gender?, + lang: Lang + ): List fun getLiveRoomListReservationWithDate( date: LocalDateTime, @@ -45,30 +48,40 @@ interface LiveRoomQueryRepository { memberId: Long?, isCreator: Boolean, isAdult: Boolean, - effectiveGender: Gender? - ): List + effectiveGender: Gender?, + lang: Lang + ): List fun getLiveRoomListReservationWithoutDate( timezone: String, memberId: Long?, isCreator: Boolean, isAdult: Boolean, - effectiveGender: Gender? - ): List + effectiveGender: Gender?, + lang: Lang + ): List fun getLiveRoom(id: Long): LiveRoom? + fun getLiveRoomDetail(id: Long, lang: Lang): LiveRoomListRow? + fun getLiveRoomUserProfile(userId: Long, lang: Lang): LiveRoomUserProfileRow? fun getLiveRoomAndAccountId(roomId: Long, memberId: Long): LiveRoom? fun getRecentRoomInfo(memberId: Long): GetRecentRoomInfoResponse? fun getDonationTotal(roomId: Long): Int? - fun getDonationList(roomId: Long, isLiveCreator: Boolean, memberId: Long): List + fun getDonationList(roomId: Long, isLiveCreator: Boolean, memberId: Long, lang: Lang): List fun getRoomActiveAndChannelNameIsNotNull(memberId: Long): List fun getActiveRoomIdList(memberId: Long): Int fun getTotalHeartCount(roomId: Long): Int? fun getLiveRoomCreatorId(roomId: Long): Long? - fun getHeartList(roomId: Long): List + fun getHeartList(roomId: Long, lang: Lang): List + + @Cacheable( + cacheNames = ["cache_ttl_10_minutes"], + key = "'getLatestFinishedLive:' + (#memberId ?: 'guest')" + ) fun getLatestFinishedLive(memberId: Long?): List } +@Repository class LiveRoomQueryRepositoryImpl( private val queryFactory: JPAQueryFactory, @@ -82,8 +95,10 @@ class LiveRoomQueryRepositoryImpl( memberId: Long?, isCreator: Boolean, isAdult: Boolean, - effectiveGender: Gender? - ): List { + effectiveGender: Gender?, + lang: Lang + ): List { + val creatorName = CreatorNameJoin(member, lang, "liveRoomNowCreatorName") var where = liveRoom.channelName.isNotNull .and(liveRoom.channelName.isNotEmpty) .and(liveRoom.isActive.isTrue) @@ -116,8 +131,10 @@ class LiveRoomQueryRepositoryImpl( } var select = queryFactory - .selectFrom(liveRoom) - .innerJoin(liveRoom.member, member) + .select(liveRoom, creatorName.displayName) + .from(liveRoom) + .innerJoin(liveRoom.member, member).fetchJoin() + .leftJoin(creatorName.name).on(creatorName.condition) .leftJoin(quarterLiveRankings).on(liveRoom.id.eq(quarterLiveRankings.roomId)) if (hasMemberId) { @@ -146,6 +163,7 @@ class LiveRoomQueryRepositoryImpl( liveRoom.member.createdAt.desc() ) .fetch() + .map { LiveRoomListRow(it.get(liveRoom)!!, it.get(creatorName.displayName)!!) } } override fun getLiveRoomListReservationWithDate( @@ -155,8 +173,10 @@ class LiveRoomQueryRepositoryImpl( memberId: Long?, isCreator: Boolean, isAdult: Boolean, - effectiveGender: Gender? - ): List { + effectiveGender: Gender?, + lang: Lang + ): List { + val creatorName = CreatorNameJoin(member, lang, "liveRoomReservationDateCreatorName") var where = liveRoom.beginDateTime.goe(date) .and(liveRoom.beginDateTime.lt(date.plusDays(1))) .and( @@ -192,8 +212,10 @@ class LiveRoomQueryRepositoryImpl( } var select = queryFactory - .selectFrom(liveRoom) - .innerJoin(liveRoom.member, member) + .select(liveRoom, creatorName.displayName) + .from(liveRoom) + .innerJoin(liveRoom.member, member).fetchJoin() + .leftJoin(creatorName.name).on(creatorName.condition) if (memberId != null) { val blockMemberCondition = blockMember.isActive.isTrue @@ -216,6 +238,7 @@ class LiveRoomQueryRepositoryImpl( .limit(limit) .orderBy(liveRoom.beginDateTime.asc()) .fetch() + .map { LiveRoomListRow(it.get(liveRoom)!!, it.get(creatorName.displayName)!!) } } override fun getLiveRoomListReservationWithoutDate( @@ -223,8 +246,10 @@ class LiveRoomQueryRepositoryImpl( memberId: Long?, isCreator: Boolean, isAdult: Boolean, - effectiveGender: Gender? - ): List { + effectiveGender: Gender?, + lang: Lang + ): List { + val creatorName = CreatorNameJoin(member, lang, "liveRoomReservationCreatorName") var where = liveRoom.beginDateTime.gt( LocalDateTime.now() .atZone(ZoneId.of(timezone)) @@ -276,8 +301,10 @@ class LiveRoomQueryRepositoryImpl( } var select = queryFactory - .selectFrom(liveRoom) - .innerJoin(liveRoom.member, member) + .select(liveRoom, creatorName.displayName) + .from(liveRoom) + .innerJoin(liveRoom.member, member).fetchJoin() + .leftJoin(creatorName.name).on(creatorName.condition) .limit(10) if (memberId != null) { @@ -299,6 +326,7 @@ class LiveRoomQueryRepositoryImpl( .where(where) .orderBy(*orderBy.toTypedArray()) .fetch() + .map { LiveRoomListRow(it.get(liveRoom)!!, it.get(creatorName.displayName)!!) } } override fun getLiveRoom(id: Long): LiveRoom? { @@ -312,6 +340,29 @@ class LiveRoomQueryRepositoryImpl( .fetchFirst() } + override fun getLiveRoomDetail(id: Long, lang: Lang): LiveRoomListRow? { + val creatorName = CreatorNameJoin(member, lang, "liveRoomDetailCreatorName") + val row = queryFactory + .select(liveRoom, creatorName.displayName) + .from(liveRoom) + .innerJoin(liveRoom.member, member).fetchJoin() + .leftJoin(creatorName.name).on(creatorName.condition) + .where(liveRoom.id.eq(id).and(liveRoom.isActive.isTrue)) + .fetchFirst() ?: return null + return LiveRoomListRow(row.get(liveRoom)!!, row.get(creatorName.displayName)!!) + } + + override fun getLiveRoomUserProfile(userId: Long, lang: Lang): LiveRoomUserProfileRow? { + val profileName = CreatorNameJoin(member, lang, "liveRoomUserProfileName") + val row = queryFactory + .select(member, profileName.displayName) + .from(member) + .leftJoin(profileName.name).on(profileName.condition) + .where(member.id.eq(userId)) + .fetchOne() ?: return null + return LiveRoomUserProfileRow(row.get(member)!!, row.get(profileName.displayName)!!) + } + override fun getLiveRoomAndAccountId(roomId: Long, memberId: Long): LiveRoom? { return queryFactory .selectFrom(liveRoom) @@ -359,7 +410,13 @@ class LiveRoomQueryRepositoryImpl( .fetchOne() } - override fun getDonationList(roomId: Long, isLiveCreator: Boolean, memberId: Long): List { + override fun getDonationList( + roomId: Long, + isLiveCreator: Boolean, + memberId: Long, + lang: Lang + ): List { + val donorName = CreatorNameJoin(member, lang, "liveRoomDonorName") val where = liveRoom.id.eq(roomId) .and(useCan.canUsage.eq(CanUsage.DONATION).or(useCan.canUsage.eq(CanUsage.SPIN_ROULETTE))) .and(useCan.isRefund.isFalse) @@ -380,7 +437,7 @@ class LiveRoomQueryRepositoryImpl( .coalesce("profile/default-profile.png") .prepend("/") .prepend(cloudFrontHost), - member.nickname, + donorName.displayName, member.id.coalesce(0), Expressions.asNumber( CaseBuilder() @@ -406,7 +463,8 @@ class LiveRoomQueryRepositoryImpl( .from(useCan) .innerJoin(useCan.room, liveRoom) .innerJoin(useCan.member, member) - .groupBy(useCan.member.id) + .leftJoin(donorName.name).on(donorName.condition) + .groupBy(useCan.member.id, member.nickname, donorName.name.displayName) .where(where) .orderBy(sortExpression.sum().desc()) .fetch() @@ -461,26 +519,29 @@ class LiveRoomQueryRepositoryImpl( .fetchFirst() } - override fun getHeartList(roomId: Long): List { + override fun getHeartList(roomId: Long, lang: Lang): List { + val donorName = CreatorNameJoin(member, lang, "liveRoomHeartDonorName") val where = liveRoom.id.eq(roomId) .and(useCan.canUsage.eq(CanUsage.HEART)) .and(useCan.isRefund.isFalse) return queryFactory .select( - QGetLiveRoomHeartListItem( + Projections.constructor( + GetLiveRoomHeartListItem::class.java, member.profileImage .coalesce("profile/default-profile.png") .prepend("/") .prepend(cloudFrontHost), - member.nickname, + donorName.displayName, useCan.can.add(useCan.rewardCan).sum() ) ) .from(useCan) .innerJoin(useCan.room, liveRoom) .innerJoin(useCan.member, member) - .groupBy(useCan.member.id) + .leftJoin(donorName.name).on(donorName.condition) + .groupBy(useCan.member.id, member.nickname, donorName.name.displayName) .where(where) .orderBy(useCan.can.add(useCan.rewardCan).sum().desc()) .fetch() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt index 736cab4a..611d770d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt @@ -26,6 +26,7 @@ import kr.co.vividnext.sodalive.fcm.FcmEvent import kr.co.vividnext.sodalive.fcm.FcmEventType import kr.co.vividnext.sodalive.fcm.PushTokenRepository import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.live.reservation.LiveReservationRepository @@ -67,8 +68,8 @@ import kr.co.vividnext.sodalive.member.block.BlockMemberRepository import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService import kr.co.vividnext.sodalive.member.contentpreference.ViewerContentPreference import kr.co.vividnext.sodalive.utils.generateFileName +import kr.co.vividnext.sodalive.v2.creatorname.application.CreatorLocalizedNameService import org.springframework.beans.factory.annotation.Value -import org.springframework.cache.annotation.Cacheable import org.springframework.context.ApplicationEventPublisher import org.springframework.data.domain.Pageable import org.springframework.data.repository.findByIdOrNull @@ -89,6 +90,7 @@ class LiveRoomService( private val menuService: LiveRoomMenuService, private val messageSource: SodaMessageSource, private val langContext: LangContext, + private val localizedNameService: CreatorLocalizedNameService, private val repository: LiveRoomRepository, private val rouletteRepository: NewRouletteRepository, @@ -247,14 +249,14 @@ class LiveRoomService( ) } - val creatorIds = roomList.mapNotNull { it.member?.id }.distinct() + val creatorIds = roomList.mapNotNull { it.room.member?.id }.distinct() val languageTagByMemberId = buildLanguageTagMap(creatorIds) return roomList - .map { - val roomInfo = roomInfoRepository.findByIdOrNull(it.id!!) + .map { (room, creatorNickname) -> + val roomInfo = roomInfoRepository.findByIdOrNull(room.id!!) - val reservations = it.reservations + val reservations = room.reservations .filter { reservation -> if (member?.id != null) { reservation.member!!.id!! == member.id!! && reservation.isActive @@ -263,7 +265,7 @@ class LiveRoomService( } } - val beginDateTime = it.beginDateTime + val beginDateTime = room.beginDateTime .atZone(ZoneId.of("UTC")) .withZoneSameInstant(ZoneId.of(timezone)) .format( @@ -272,42 +274,42 @@ class LiveRoomService( .withLocale(langContext.lang.locale) ) - val beginDateTimeUtc = it.beginDateTime + val beginDateTimeUtc = room.beginDateTime .format(DateTimeFormatter.ISO_LOCAL_DATE_TIME) - val tags = it.tags + val tags = room.tags .filter { tag -> tag.tag.isActive } .map { tag -> tag.tag.tag } - .let { list -> applyLanguageTagToRoomTags(it.member?.id, list, languageTagByMemberId) } + .let { list -> applyLanguageTagToRoomTags(room.member?.id, list, languageTagByMemberId) } GetRoomListResponse( - roomId = it.id!!, - title = it.title, - content = it.notice, + roomId = room.id!!, + title = room.title, + content = room.notice, beginDateTime = beginDateTime, beginDateTimeUtc = beginDateTimeUtc, numberOfParticipate = (roomInfo?.listenerCount ?: 0) + (roomInfo?.speakerCount ?: 0) + (roomInfo?.managerCount ?: 0), - numberOfPeople = it.numberOfPeople, - isAdult = it.isAdult, - price = it.price, - channelName = it.channelName, - creatorProfileImage = if (it.member!!.profileImage != null) { - "$cloudFrontHost/${it.member!!.profileImage}" + numberOfPeople = room.numberOfPeople, + isAdult = room.isAdult, + price = room.price, + channelName = room.channelName, + creatorProfileImage = if (room.member!!.profileImage != null) { + "$cloudFrontHost/${room.member!!.profileImage}" } else { "$cloudFrontHost/profile/default-profile.png" }, - creatorNickname = it.member!!.nickname, - creatorId = it.member!!.id!!, + creatorNickname = creatorNickname, + creatorId = room.member!!.id!!, tags = tags, - coverImageUrl = if (it.coverImage!!.startsWith("https://")) { - it.coverImage!! + coverImageUrl = if (room.coverImage!!.startsWith("https://")) { + room.coverImage!! } else { - "$cloudFrontHost/${it.coverImage!!}" + "$cloudFrontHost/${room.coverImage!!}" }, isReservation = reservations.isNotEmpty(), - isPrivateRoom = it.type == LiveRoomType.PRIVATE + isPrivateRoom = room.type == LiveRoomType.PRIVATE ) } } @@ -319,7 +321,7 @@ class LiveRoomService( isCreator: Boolean, isAdult: Boolean, effectiveGender: Gender? - ): List { + ): List { return repository.getLiveRoomListNow( offset = pageable.offset, limit = pageable.pageSize.toLong(), @@ -327,7 +329,8 @@ class LiveRoomService( memberId = memberId, isCreator = isCreator, isAdult = isAdult, - effectiveGender = effectiveGender + effectiveGender = effectiveGender, + lang = langContext.lang ) } @@ -339,7 +342,7 @@ class LiveRoomService( isCreator: Boolean, isAdult: Boolean, effectiveGender: Gender? - ): List { + ): List { val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") val date = LocalDate.parse(dateString, dateTimeFormatter).atStartOfDay() .atZone(ZoneId.of(timezone)) @@ -353,7 +356,8 @@ class LiveRoomService( memberId = memberId, isCreator = isCreator, isAdult = isAdult, - effectiveGender = effectiveGender + effectiveGender = effectiveGender, + lang = langContext.lang ) } @@ -363,8 +367,15 @@ class LiveRoomService( isCreator: Boolean, isAdult: Boolean, effectiveGender: Gender? - ): List { - return repository.getLiveRoomListReservationWithoutDate(timezone, memberId, isCreator, isAdult, effectiveGender) + ): List { + return repository.getLiveRoomListReservationWithoutDate( + timezone, + memberId, + isCreator, + isAdult, + effectiveGender, + langContext.lang + ) } @Transactional @@ -504,6 +515,7 @@ class LiveRoomService( type = FcmEventType.CREATE_LIVE, category = PushNotificationCategory.LIVE, title = createdRoom.member!!.nickname, + titleMemberId = createdRoom.member!!.id, messageKey = if (createdRoom.channelName != null) { "live.room.fcm.message.started" } else { @@ -525,7 +537,7 @@ class LiveRoomService( } fun getRoomDetail(roomId: Long, member: Member, timezone: String): GetRoomDetailResponse { - val room = repository.getLiveRoom(id = roomId) + val (room, creatorNickname) = repository.getLiveRoomDetail(id = roomId, lang = langContext.lang) ?: throw SodaException(messageKey = "live.room.already_ended") val creatorId = room.member!!.id!! @@ -573,7 +585,7 @@ class LiveRoomService( genderRestriction = room.genderRestriction, password = room.password ) - response.manager = GetRoomDetailManager(room.member!!, cloudFrontHost = cloudFrontHost) + response.manager = GetRoomDetailManager(room.member!!, cloudFrontHost, creatorNickname) if (!room.channelName.isNullOrBlank()) { val roomInfo = roomInfoRepository.findByIdOrNull(roomId) @@ -618,21 +630,14 @@ class LiveRoomService( } ) - response.participatingUsers = users + val names = findCurrentNames(users.map { it.id }, langContext.lang) + response.participatingUsers = users.map { it.copy(nickname = names[it.id] ?: it.nickname) } response.numberOfParticipants = users.size } } else { - val reservationList = reservationRepository.getReservationList(roomId) + val reservationList = reservationRepository.getReservationList(roomId, langContext.lang) + response.isPaid = reservationList.any { it.id == member.id } response.participatingUsers = reservationList - .asSequence() - .map { - if (it.member!!.id!! == member.id!!) { - response.isPaid = true - } - - GetRoomDetailUser(it.member!!, cloudFrontHost) - } - .toList() response.numberOfParticipants = reservationList.size } @@ -686,6 +691,7 @@ class LiveRoomService( type = FcmEventType.START_LIVE, category = PushNotificationCategory.LIVE, title = room.member!!.nickname, + titleMemberId = room.member!!.id, messageKey = "live.room.fcm.message.started", senderMemberId = room.member!!.id, args = listOf(room.title), @@ -761,6 +767,7 @@ class LiveRoomService( type = FcmEventType.CANCEL_LIVE, category = PushNotificationCategory.LIVE, title = room.member!!.nickname, + titleMemberId = room.member!!.id, messageKey = "live.room.fcm.message.canceled", senderMemberId = room.member!!.id, args = listOf(room.title), @@ -792,8 +799,9 @@ class LiveRoomService( val isBlocked = blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = room.member!!.id!!) if (isBlocked) { + val names = localizedNameService.findLocalizedNames(listOf(room.member!!.id!!), langContext.lang) throw SodaException( - message = formatMessage("live.room.enter_blocked_by_host", room.member!!.nickname) + message = formatMessage("live.room.enter_blocked_by_host", names[room.member!!.id] ?: room.member!!.nickname) ) } @@ -807,8 +815,12 @@ class LiveRoomService( val kickOutCount = kickOutService.getKickOutCount(roomId = room.id!!, userId = member.id!!) if (kickOutCount >= 2) { + val names = localizedNameService.findLocalizedNames(listOf(room.member!!.id!!), langContext.lang) throw SodaException( - message = formatMessage("live.room.participation_blocked_by_host", room.member!!.nickname) + message = formatMessage( + "live.room.participation_blocked_by_host", + names[room.member!!.id] ?: room.member!!.nickname + ) ) } @@ -1057,6 +1069,10 @@ class LiveRoomService( ?.lowercase() ?.take(2) + val names = findCurrentNames( + listOf(creatorId) + (roomInfo.speakerList + roomInfo.listenerList + roomInfo.managerList).map { it.id }, + langContext.lang + ) return GetRoomInfoResponse( roomId = roomId, title = room.title, @@ -1079,7 +1095,7 @@ class LiveRoomService( rtmToken = rtmToken, v2vWorkerToken = v2vWorkerToken, creatorId = room.member!!.id!!, - creatorNickname = room.member!!.nickname, + creatorNickname = names[creatorId] ?: room.member!!.nickname, creatorProfileUrl = if (room.member!!.profileImage != null) { "$cloudFrontHost/${room.member!!.profileImage}" } else { @@ -1090,9 +1106,9 @@ class LiveRoomService( isCaptureRecordingAvailable = room.isCaptureRecordingAvailable, participantsCount = roomInfo.listenerCount + roomInfo.speakerCount + roomInfo.managerCount, totalAvailableParticipantsCount = room.numberOfPeople, - speakerList = roomInfo.speakerList, - listenerList = roomInfo.listenerList, - managerList = roomInfo.managerList, + speakerList = roomInfo.speakerList.map { it.copy(nickname = names[it.id] ?: it.nickname).apply { role = it.role } }, + listenerList = roomInfo.listenerList.map { it.copy(nickname = names[it.id] ?: it.nickname).apply { role = it.role } }, + managerList = roomInfo.managerList.map { it.copy(nickname = names[it.id] ?: it.nickname).apply { role = it.role } }, donationRankingTop3UserIds = donationRankingTop3UserIds, menuPan = menuPan?.menu ?: "", creatorLanguageCode = creatorLanguageCode, @@ -1129,12 +1145,14 @@ class LiveRoomService( val roomInfo = roomInfoRepository.findByIdOrNull(roomId) ?: throw SodaException(messageKey = "live.room.info_not_found") - return if (liveRoomCreatorId != member.id!!) { + val messages = if (liveRoomCreatorId != member.id!!) { roomInfo.donationMessageList .filter { !it.isSecret || it.memberId == member.id!! } } else { roomInfo.donationMessageList } + val names = findCurrentNames(messages.map { it.memberId }, langContext.lang) + return messages.map { it.copy(nickname = names[it.memberId] ?: it.nickname) } } fun deleteDonationMessage(request: DeleteLiveRoomDonationMessage, member: Member) { @@ -1161,7 +1179,7 @@ class LiveRoomService( val roomInfo = roomInfoRepository.findByIdOrNull(roomId) ?: throw SodaException(messageKey = "live.room.info_not_found") - val user = memberRepository.findByIdOrNull(userId) + val (user, nickname) = repository.getLiveRoomUserProfile(userId, langContext.lang) ?: throw SodaException(messageKey = "common.error.invalid_request") val isFollowing = if (user.role == MemberRole.CREATOR) { @@ -1194,7 +1212,7 @@ class LiveRoomService( return GetLiveRoomUserProfileResponse( userId = user.id!!, - nickname = user.nickname, + nickname = nickname, profileUrl = if (user.profileImage != null) { "$cloudFrontHost/${user.profileImage}" } else { @@ -1439,7 +1457,8 @@ class LiveRoomService( val donationList = repository.getDonationList( roomId = room.id!!, isLiveCreator = isLiveCreator, - memberId = memberId + memberId = memberId, + lang = langContext.lang ).filter { it.secretCan > 0 || it.can > 0 } val totalCan = donationList.sumOf { it.can } @@ -1492,6 +1511,10 @@ class LiveRoomService( return tokenLocks.computeIfAbsent(memberId) { ReentrantReadWriteLock() } } + private fun findCurrentNames(memberIds: Collection, lang: Lang): Map { + return localizedNameService.findCurrentNames(memberIds.distinct(), lang) + } + private fun resolvePreference(member: Member?): ViewerContentPreference { if (member == null) { return ViewerContentPreference( @@ -1531,7 +1554,7 @@ class LiveRoomService( } fun getHeartList(roomId: Long): GetLiveRoomHeartListResponse { - val heartList = repository.getHeartList(roomId = roomId) + val heartList = repository.getHeartList(roomId = roomId, lang = langContext.lang) return GetLiveRoomHeartListResponse( heartList = heartList, @@ -1541,11 +1564,10 @@ class LiveRoomService( } @Transactional(readOnly = true) - @Cacheable( - cacheNames = ["cache_ttl_10_minutes"], - key = "'getLatestFinishedLive:' + (#member?.id ?: 'guest')" - ) fun getLatestFinishedLive(member: Member?): List { - return repository.getLatestFinishedLive(memberId = member?.id) + val items = repository.getLatestFinishedLive(memberId = member?.id) + val memberIds = items.map { it.memberId }.distinct() + val names = findCurrentNames(memberIds, langContext.lang) + return items.map { it.copy(nickname = names[it.memberId] ?: it.nickname) } } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/detail/GetRoomDetailResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/detail/GetRoomDetailResponse.kt index a0def2f7..6c932644 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/detail/GetRoomDetailResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/detail/GetRoomDetailResponse.kt @@ -37,9 +37,9 @@ data class GetRoomDetailManager( val profileImageUrl: String, val isCreator: Boolean ) { - constructor(member: Member, cloudFrontHost: String) : this( + constructor(member: Member, cloudFrontHost: String, nickname: String) : this( id = member.id!!, - nickname = member.nickname, + nickname = nickname, introduce = member.introduce, youtubeUrl = member.youtubeUrl, instagramUrl = member.instagramUrl, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/like/GetLiveRoomHeartListResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/like/GetLiveRoomHeartListResponse.kt index 882df29c..dcc0d642 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/like/GetLiveRoomHeartListResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/like/GetLiveRoomHeartListResponse.kt @@ -1,14 +1,12 @@ package kr.co.vividnext.sodalive.live.room.like -import com.querydsl.core.annotations.QueryProjection - data class GetLiveRoomHeartListResponse( val heartList: List, val totalCount: Int, val totalHeart: Int ) -data class GetLiveRoomHeartListItem @QueryProjection constructor( +data class GetLiveRoomHeartListItem( val profileImage: String, val nickname: String, val heart: Int diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/visit/LiveRoomVisitRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/visit/LiveRoomVisitRepository.kt index 3ab4ce04..a01fbabb 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/visit/LiveRoomVisitRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/visit/LiveRoomVisitRepository.kt @@ -1,12 +1,17 @@ package kr.co.vividnext.sodalive.live.room.visit +import com.querydsl.core.types.Projections import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom +import kr.co.vividnext.sodalive.live.room.detail.GetRoomDetailUser import kr.co.vividnext.sodalive.live.room.visit.QLiveRoomVisit.liveRoomVisit import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.QMember import kr.co.vividnext.sodalive.member.QMember.member +import kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence.CreatorNameJoin +import org.springframework.beans.factory.annotation.Value import org.springframework.data.jpa.repository.JpaRepository import org.springframework.stereotype.Repository @@ -16,11 +21,15 @@ interface LiveRoomVisitRepository : JpaRepository, LiveRoom interface LiveRoomVisitQueryRepository { fun findByRoomIdAndMemberId(roomId: Long, memberId: Long): LiveRoomVisit? fun findFirstByMemberIdOrderByUpdatedAtDesc(memberId: Long): LiveRoomVisit? - fun getRecentVisitRoomUsers(roomId: Long, member: Member): List + fun getRecentVisitRoomUsers(roomId: Long, member: Member, lang: Lang): List } @Repository -class LiveRoomVisitQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : LiveRoomVisitQueryRepository { +class LiveRoomVisitQueryRepositoryImpl( + private val queryFactory: JPAQueryFactory, + @Value("\${cloud.aws.cloud-front.host}") + private val cloudFrontHost: String +) : LiveRoomVisitQueryRepository { override fun findByRoomIdAndMemberId(roomId: Long, memberId: Long): LiveRoomVisit? { return queryFactory .selectFrom(liveRoomVisit) @@ -44,7 +53,9 @@ class LiveRoomVisitQueryRepositoryImpl(private val queryFactory: JPAQueryFactory .fetchFirst() } - override fun getRecentVisitRoomUsers(roomId: Long, member: Member): List { + override fun getRecentVisitRoomUsers(roomId: Long, member: Member, lang: Lang): List { + val visitor = QMember.member + val visitorName = CreatorNameJoin(visitor, lang, "recentLiveRoomVisitorName") var where = liveRoomVisit.room.id.eq(roomId) .and(liveRoomVisit.member.isActive.isTrue) .and(liveRoomVisit.member.id.ne(member.id)) @@ -58,10 +69,18 @@ class LiveRoomVisitQueryRepositoryImpl(private val queryFactory: JPAQueryFactory } return queryFactory - .select(QMember.member) + .select( + Projections.constructor( + GetRoomDetailUser::class.java, + visitor.id, + visitorName.displayName, + visitor.profileImage.coalesce("profile/default-profile.png").prepend("/").prepend(cloudFrontHost) + ) + ) .from(liveRoomVisit) - .innerJoin(liveRoomVisit.member, QMember.member) + .innerJoin(liveRoomVisit.member, visitor) .innerJoin(liveRoomVisit.room, liveRoom) + .leftJoin(visitorName.name).on(visitorName.condition) .where(where) .fetch() } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/visit/LiveRoomVisitService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/visit/LiveRoomVisitService.kt index 9beb448f..13aad844 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/visit/LiveRoomVisitService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/visit/LiveRoomVisitService.kt @@ -1,9 +1,9 @@ package kr.co.vividnext.sodalive.live.room.visit +import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.live.room.LiveRoom import kr.co.vividnext.sodalive.live.room.detail.GetRoomDetailUser import kr.co.vividnext.sodalive.member.Member -import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.time.LocalDateTime @@ -12,9 +12,7 @@ import java.time.LocalDateTime @Transactional(readOnly = true) class LiveRoomVisitService( private val repository: LiveRoomVisitRepository, - - @Value("\${cloud.aws.cloud-front.host}") - private val cloudFrontHost: String + private val langContext: LangContext ) { @Transactional fun roomVisit(room: LiveRoom, member: Member) { @@ -34,9 +32,6 @@ class LiveRoomVisitService( val roomVisit = repository.findFirstByMemberIdOrderByUpdatedAtDesc(member.id!!) ?: return emptyList() - return repository.getRecentVisitRoomUsers(roomVisit.room!!.id!!, member) - .asSequence() - .map { GetRoomDetailUser(it, cloudFrontHost) } - .toList() + return repository.getRecentVisitRoomUsers(roomVisit.room!!.id!!, member, langContext.lang) } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRepository.kt index 5ea67a34..1d6a7f69 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRepository.kt @@ -1,12 +1,15 @@ package kr.co.vividnext.sodalive.member +import com.querydsl.core.types.Projections import com.querydsl.jpa.impl.JPAQueryFactory import kr.co.vividnext.sodalive.fcm.PushTokenInfo import kr.co.vividnext.sodalive.fcm.QPushToken.pushToken import kr.co.vividnext.sodalive.fcm.QPushTokenInfo +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.live.reservation.QLiveReservation.liveReservation import kr.co.vividnext.sodalive.live.room.GenderRestriction import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom +import kr.co.vividnext.sodalive.live.room.detail.GetRoomDetailUser import kr.co.vividnext.sodalive.member.QMember.member import kr.co.vividnext.sodalive.member.auth.QAuth.auth import kr.co.vividnext.sodalive.member.block.BlockMemberRepository @@ -15,6 +18,8 @@ import kr.co.vividnext.sodalive.member.following.QCreatorFollowing.creatorFollow import kr.co.vividnext.sodalive.member.nickname.QNicknameChangeLog.nicknameChangeLog import kr.co.vividnext.sodalive.member.notification.QMemberNotification.memberNotification import kr.co.vividnext.sodalive.message.QMessage.message +import kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence.CreatorNameJoin +import kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence.creatorNameContains import org.springframework.beans.factory.annotation.Value import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.Lock @@ -38,7 +43,7 @@ interface MemberRepository : JpaRepository, MemberQueryRepository } interface MemberQueryRepository { - fun findByNicknameAndOtherCondition(nickname: String, member: Member): List + fun findByNicknameAndOtherCondition(nickname: String, member: Member, lang: Lang? = null): List fun findCreatorByIdOrNull(memberId: Long): Member? fun getAllRecipientPushTokens(isAuth: Boolean?): List fun getCreateLiveRoomNotificationRecipientPushTokens( @@ -70,7 +75,7 @@ interface MemberQueryRepository { fun getPushTokenFromReservationList(roomId: Long): List fun getAuditionNoticeRecipientPushTokens(isAuth: Boolean): List - fun getMemberProfile(memberId: Long, myMemberId: Long): GetMemberProfileResponse + fun getMemberProfile(memberId: Long, myMemberId: Long, lang: Lang = Lang.KO): GetMemberProfileResponse fun existsByNickname(nickname: String): Boolean fun findNicknamesWithPrefix(prefix: String): List @@ -86,8 +91,10 @@ class MemberQueryRepositoryImpl( @Value("\${cloud.aws.cloud-front.host}") private val cloudFrontHost: String ) : MemberQueryRepository { - override fun findByNicknameAndOtherCondition(nickname: String, member: Member): List { - var where = QMember.member.nickname.containsIgnoreCase(nickname) + override fun findByNicknameAndOtherCondition(nickname: String, member: Member, lang: Lang?): List { + val creatorName = lang?.let { CreatorNameJoin(QMember.member, it, "memberSearchName") } + val displayName = creatorName?.displayName ?: QMember.member.nickname + var where = creatorNameContains(QMember.member, nickname) .and(QMember.member.id.ne(member.id)) .and(QMember.member.role.ne(MemberRole.ADMIN)) .and(QMember.member.role.ne(MemberRole.AGENT)) @@ -97,10 +104,19 @@ class MemberQueryRepositoryImpl( where = where.and(QMember.member.role.ne(MemberRole.USER)) } - return queryFactory - .selectFrom(QMember.member) + val query = queryFactory + .select( + Projections.constructor( + GetRoomDetailUser::class.java, + QMember.member.id, + displayName, + QMember.member.profileImage.coalesce("profile/default-profile.png").prepend("/").prepend(cloudFrontHost) + ) + ) + .from(QMember.member) .where(where) - .fetch() + if (creatorName != null) query.leftJoin(creatorName.name).on(creatorName.condition) + return query.fetch() } override fun findCreatorByIdOrNull(memberId: Long): Member? { @@ -499,17 +515,19 @@ class MemberQueryRepositoryImpl( .fetch() } - override fun getMemberProfile(memberId: Long, myMemberId: Long): GetMemberProfileResponse { + override fun getMemberProfile(memberId: Long, myMemberId: Long, lang: Lang): GetMemberProfileResponse { + val creatorName = CreatorNameJoin(member, lang, "profileMemberName") return queryFactory .select( QGetMemberProfileResponse( member.id, - member.nickname, + creatorName.displayName, member.profileImage.prepend("/").prepend(cloudFrontHost), blockMember.id.isNotNull ) ) .from(member) + .leftJoin(creatorName.name).on(creatorName.condition) .leftJoin(blockMember) .on( member.id.eq(blockMember.blockedMember.id) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt index 71eb01ca..34645bfd 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt @@ -53,6 +53,7 @@ import kr.co.vividnext.sodalive.member.token.MemberTokenRepository import kr.co.vividnext.sodalive.point.MemberPointRepository import kr.co.vividnext.sodalive.utils.generateFileName import kr.co.vividnext.sodalive.utils.generatePassword +import kr.co.vividnext.sodalive.v2.creatorname.application.CreatorLocalizedNameService import kr.co.vividnext.sodalive.v2.home.following.port.out.HomeFollowingNewsInboxPort import org.springframework.beans.factory.annotation.Value import org.springframework.cache.CacheManager @@ -105,6 +106,7 @@ class MemberService( private val messageSource: SodaMessageSource, private val langContext: LangContext, + private val localizedNameService: CreatorLocalizedNameService, private val countryContext: CountryContext, private val memberContentPreferenceService: MemberContentPreferenceService, @@ -308,7 +310,8 @@ class MemberService( ).sumOf { it.point } return MyPageResponse( - nickname = member.nickname, + nickname = localizedNameService.findLocalizedNames(listOf(member.id!!), langContext.lang)[member.id] + ?: member.nickname, profileUrl = if (member.profileImage != null) { "$cloudFrontHost/${member.profileImage}" } else { @@ -542,8 +545,7 @@ class MemberService( fun getBlockedMemberList(memberId: Long, offset: Long, limit: Long): GetBlockedMemberListResponse { val totalCount = blockMemberRepository.getBlockedMemberTotalCount(memberId) - val items = blockMemberRepository.getBlockedMemberList(offset = offset, limit = limit, memberId = memberId) - + val items = blockMemberRepository.getBlockedMemberList(offset, limit, memberId, langContext.lang) return GetBlockedMemberListResponse(totalCount = totalCount, items = items) } @@ -610,14 +612,11 @@ class MemberService( throw SodaException(messageKey = "member.validation.nickname_min_length") } - return repository.findByNicknameAndOtherCondition(nickname, member) + return repository.findByNicknameAndOtherCondition(nickname, member, langContext.lang) .filter { !blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it.id!!) && !blockMemberRepository.isBlocked(blockedMemberId = it.id!!, memberId = member.id!!) } - .map { - GetRoomDetailUser(it, cloudFrontHost) - } } @Transactional @@ -837,7 +836,7 @@ class MemberService( } fun getMemberProfile(memberId: Long, myMemberId: Long): GetMemberProfileResponse { - return repository.getMemberProfile(memberId, myMemberId) + return repository.getMemberProfile(memberId, myMemberId, langContext.lang) } private fun formatMessage(key: String, vararg args: Any): String { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/block/BlockMemberRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/block/BlockMemberRepository.kt index 155c454a..a3affa80 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/block/BlockMemberRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/block/BlockMemberRepository.kt @@ -1,8 +1,10 @@ package kr.co.vividnext.sodalive.member.block import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.QMember.member import kr.co.vividnext.sodalive.member.block.QBlockMember.blockMember +import kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence.CreatorNameJoin import org.springframework.beans.factory.annotation.Value import org.springframework.data.jpa.repository.JpaRepository import org.springframework.stereotype.Repository @@ -14,7 +16,7 @@ interface BlockMemberQueryRepository { fun getBlockAccount(blockedMemberId: Long, memberId: Long): BlockMember? fun isBlocked(blockedMemberId: Long, memberId: Long): Boolean fun getBlockedMemberIdList(memberId: Long): List - fun getBlockedMemberList(offset: Long, limit: Long, memberId: Long): List + fun getBlockedMemberList(offset: Long, limit: Long, memberId: Long, lang: Lang = Lang.KO): List fun getBlockedMemberTotalCount(memberId: Long): Int } @@ -61,18 +63,20 @@ class BlockMemberQueryRepositoryImpl( .fetch() } - override fun getBlockedMemberList(offset: Long, limit: Long, memberId: Long): List { + override fun getBlockedMemberList(offset: Long, limit: Long, memberId: Long, lang: Lang): List { + val creatorName = CreatorNameJoin(member, lang, "blockedMemberName") return queryFactory .select( QGetBlockedMemberListItem( blockMember.blockedMember.id, - blockMember.blockedMember.nickname, + creatorName.displayName, blockMember.blockedMember.profileImage.prepend("/").prepend(cloudFrontHost), blockMember.isActive ) ) .from(blockMember) .innerJoin(blockMember.blockedMember, member) + .leftJoin(creatorName.name).on(creatorName.condition) .where( blockMember.member.id.eq(memberId) .and(blockMember.isActive.isTrue) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingRepository.kt index 3fcb3a16..52c4c3aa 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingRepository.kt @@ -18,13 +18,15 @@ import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seriesContent import kr.co.vividnext.sodalive.creator.admin.content.series.Series import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesState +import kr.co.vividnext.sodalive.explorer.GetExplorerSectionCreatorResponse import kr.co.vividnext.sodalive.explorer.QCreatorRanking.creatorRanking -import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.QMember.member import kr.co.vividnext.sodalive.member.block.QBlockMember.blockMember import kr.co.vividnext.sodalive.member.tag.QCreatorTag.creatorTag import kr.co.vividnext.sodalive.member.tag.QMemberCreatorTag.memberCreatorTag +import kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence.CreatorNameJoin import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Repository import java.time.LocalDateTime @@ -36,7 +38,9 @@ class RankingRepository( @Value("\${cloud.aws.cloud-front.host}") private val imageHost: String ) { - fun getCreatorRankings(memberId: Long? = null): List { + fun getCreatorRankings(memberId: Long? = null, lang: Lang = Lang.KO): List { + val creatorName = CreatorNameJoin(member, lang, "rankingCreatorName") + val displayName = creatorName.displayName val blockMemberCondition = if (memberId != null) { blockMember.isActive.isTrue .and( @@ -52,11 +56,12 @@ class RankingRepository( } var select = queryFactory - .select(member) + .select(member, displayName) .from(creatorRanking) .innerJoin(creatorRanking.member, member) .leftJoin(member.tags, memberCreatorTag).fetchJoin() .leftJoin(memberCreatorTag.tag, creatorTag).fetchJoin() + select.leftJoin(creatorName.name).on(creatorName.condition) if (memberId != null) { select = select.leftJoin(blockMember).on(blockMemberCondition) @@ -69,6 +74,9 @@ class RankingRepository( return select .orderBy(creatorRanking.ranking.asc()) .fetch() + .map { row -> + row.get(member)!!.toExplorerSectionCreator(imageHost).copy(nickname = row.get(displayName)!!) + } .distinctBy { it.id } } @@ -84,6 +92,8 @@ class RankingRepository( theme: String = "", locale: String? = null ): List { + val creatorName = locale?.let { CreatorNameJoin(member, Lang.fromCode(it), "rankingContentCreatorName") } + val displayName = creatorName?.displayName ?: member.nickname val blockMemberCondition = if (memberId != null) { blockMember.isActive.isTrue .and( @@ -148,7 +158,7 @@ class RankingRepository( audioContent.price, audioContent.duration, member.id, - member.nickname, + displayName, audioContent.isPointAvailable, creatorProfileImageUrl ) @@ -199,6 +209,7 @@ class RankingRepository( select = select.leftJoin(contentTranslation) .on(contentTranslation.contentId.eq(audioContent.id).and(contentTranslation.locale.eq(locale))) } + if (creatorName != null) select.leftJoin(creatorName.name).on(creatorName.condition) if (memberId != null) { where = where.and(blockMember.id.isNull) @@ -269,6 +280,7 @@ class RankingRepository( } val results = select + .groupBy(member.id, displayName) .offset(offset) .limit(limit) .fetch() @@ -283,7 +295,7 @@ class RankingRepository( val price = row.get(audioContent.price) ?: 0 val duration = row.get(audioContent.duration) ?: "" val creatorId = row.get(member.id)!! - val creatorNickname = row.get(member.nickname)!! + val creatorNickname = row.get(displayName)!! val isPointAvailable = row.get(audioContent.isPointAvailable) ?: false val creatorProfileImageUrlValue = row.get(creatorProfileImageUrl)!! diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingService.kt index 49d59bfb..702d89ed 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingService.kt @@ -26,7 +26,6 @@ class RankingService( fun getCreatorRanking(memberId: Long?, rankingDate: String): GetExplorerSectionResponse { val creatorRankings = repository .getCreatorRankings(memberId = memberId) - .map { it.toExplorerSectionCreator(imageHost) } return GetExplorerSectionResponse( title = "인기 크리에이터", diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/search/SearchRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/search/SearchRepository.kt index dee7a1c7..7b78cb92 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/search/SearchRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/search/SearchRepository.kt @@ -10,11 +10,14 @@ import kr.co.vividnext.sodalive.content.theme.QAudioContentTheme.audioContentThe import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seriesContent import kr.co.vividnext.sodalive.creator.admin.content.series.keyword.QSeriesKeyword.seriesKeyword +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.QMember.member import kr.co.vividnext.sodalive.member.block.QBlockMember.blockMember import kr.co.vividnext.sodalive.member.tag.QCreatorTag.creatorTag import kr.co.vividnext.sodalive.member.tag.QMemberCreatorTag.memberCreatorTag +import kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence.CreatorNameJoin +import kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence.creatorNameContains import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Repository @@ -50,7 +53,7 @@ class SearchRepository( .and(member.role.eq(MemberRole.CREATOR)) .and(creatorTag.isActive.isTrue.or(creatorTag.id.isNull)) .and( - member.nickname.containsIgnoreCase(keyword) + creatorNameContains(member, keyword) .or(member.introduce.containsIgnoreCase(keyword)) .or(creatorTag.tag.containsIgnoreCase(keyword)) ) @@ -65,7 +68,8 @@ class SearchRepository( keyword: String, memberId: Long, offset: Long, - limit: Long + limit: Long, + lang: Lang = Lang.KO ): List { val blockMemberCondition = blockMember.isActive.isTrue .and( @@ -77,16 +81,18 @@ class SearchRepository( ) ) + val creatorName = CreatorNameJoin(member, lang, "searchCreatorName") return queryFactory .select( QSearchResponseItem( member.id, member.profileImage.prepend("/").prepend(imageHost), - member.nickname, - member.nickname + creatorName.displayName, + creatorName.displayName ) ) .from(member) + .leftJoin(creatorName.name).on(creatorName.condition) .leftJoin(memberCreatorTag).on(memberCreatorTag.member.id.eq(member.id)) .leftJoin(creatorTag).on(memberCreatorTag.tag.id.eq(creatorTag.id)) .leftJoin(blockMember).on(blockMemberCondition) @@ -95,13 +101,13 @@ class SearchRepository( .and(member.role.eq(MemberRole.CREATOR)) .and(creatorTag.isActive.isTrue.or(creatorTag.id.isNull)) .and( - member.nickname.containsIgnoreCase(keyword) + creatorNameContains(member, keyword) .or(member.introduce.containsIgnoreCase(keyword)) .or(creatorTag.tag.containsIgnoreCase(keyword)) ) .and(blockMember.id.isNull) ) - .groupBy(member.id) + .groupBy(member.id, creatorName.name.displayName) .orderBy(member.id.desc()) .offset(offset) .limit(limit) @@ -131,7 +137,7 @@ class SearchRepository( .and(audioContent.duration.isNotNull) .and( audioContent.title.containsIgnoreCase(keyword) - .or(member.nickname.containsIgnoreCase(keyword)) + .or(creatorNameContains(member, keyword)) .or(audioContent.detail.containsIgnoreCase(keyword)) .or(audioContent.theme.theme.containsIgnoreCase(keyword)) .or(hashTag.tag.containsIgnoreCase(keyword).and(audioContentHashTag.isActive.isTrue)) @@ -177,7 +183,8 @@ class SearchRepository( isAdult: Boolean, contentType: ContentType, offset: Long, - limit: Long + limit: Long, + lang: Lang = Lang.KO ): List { val blockMemberCondition = blockMember.isActive.isTrue .and( @@ -196,7 +203,7 @@ class SearchRepository( .and(audioContent.duration.isNotNull) .and( audioContent.title.containsIgnoreCase(keyword) - .or(member.nickname.containsIgnoreCase(keyword)) + .or(creatorNameContains(member, keyword)) .or(audioContent.detail.containsIgnoreCase(keyword)) .or(audioContent.theme.theme.containsIgnoreCase(keyword)) .or(hashTag.tag.containsIgnoreCase(keyword).and(audioContentHashTag.isActive.isTrue)) @@ -222,23 +229,25 @@ class SearchRepository( } } + val creatorName = CreatorNameJoin(member, lang, "searchContentCreatorName") return queryFactory .select( QSearchResponseItem( audioContent.id, audioContent.coverImage.prepend("/").prepend(imageHost), audioContent.title, - audioContent.member.nickname + creatorName.displayName ) ) .from(audioContent) .innerJoin(audioContent.member, member) + .leftJoin(creatorName.name).on(creatorName.condition) .leftJoin(audioContentTheme).on(audioContentTheme.id.eq(audioContent.theme.id)) .leftJoin(audioContentHashTag).on(audioContentHashTag.audioContent.id.eq(audioContent.id)) .leftJoin(hashTag).on(audioContentHashTag.hashTag.id.eq(hashTag.id)) .leftJoin(blockMember).on(blockMemberCondition) .where(where) - .groupBy(audioContent.id) + .groupBy(audioContent.id, member.id, creatorName.name.displayName) .orderBy(audioContent.id.desc()) .offset(offset) .limit(limit) @@ -270,7 +279,7 @@ class SearchRepository( .and(audioContent.limited.isNull) .and( series.title.containsIgnoreCase(keyword) - .or(member.nickname.containsIgnoreCase(keyword)) + .or(creatorNameContains(member, keyword)) .or(series.introduction.containsIgnoreCase(keyword)) .or(seriesGenre.genre.containsIgnoreCase(keyword)) .or(hashTag.tag.containsIgnoreCase(keyword).and(seriesKeyword.isActive.isTrue)) @@ -319,7 +328,8 @@ class SearchRepository( isAdult: Boolean, contentType: ContentType, offset: Long, - limit: Long + limit: Long, + lang: Lang = Lang.KO ): List { val blockMemberCondition = blockMember.isActive.isTrue .and( @@ -340,7 +350,7 @@ class SearchRepository( .and(audioContent.limited.isNull) .and( series.title.containsIgnoreCase(keyword) - .or(member.nickname.containsIgnoreCase(keyword)) + .or(creatorNameContains(member, keyword)) .or(series.introduction.containsIgnoreCase(keyword)) .or(seriesGenre.genre.containsIgnoreCase(keyword)) .or(hashTag.tag.containsIgnoreCase(keyword).and(seriesKeyword.isActive.isTrue)) @@ -367,25 +377,27 @@ class SearchRepository( } } + val creatorName = CreatorNameJoin(member, lang, "searchSeriesCreatorName") return queryFactory .select( QSearchResponseItem( series.id, series.coverImage.prepend("/").prepend(imageHost), series.title, - series.member.nickname + creatorName.displayName ) ) .from(seriesContent) .innerJoin(seriesContent.series, series) .innerJoin(seriesContent.content, audioContent) .innerJoin(series.member, member) + .leftJoin(creatorName.name).on(creatorName.condition) .leftJoin(seriesGenre).on(seriesGenre.id.eq(series.genre.id)) .leftJoin(seriesKeyword).on(seriesKeyword.series.id.eq(series.id)) .leftJoin(hashTag).on(hashTag.id.eq(seriesKeyword.keyword.id)) .leftJoin(blockMember).on(blockMemberCondition) .where(where) - .groupBy(series.id, series.orders) + .groupBy(series.id, series.orders, member.id, creatorName.name.displayName) .orderBy(series.orders.asc()) .offset(offset) .limit(limit) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/search/SearchService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/search/SearchService.kt index ed66bfe2..a4ab6f76 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/search/SearchService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/search/SearchService.kt @@ -1,11 +1,12 @@ package kr.co.vividnext.sodalive.search import kr.co.vividnext.sodalive.content.ContentType +import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.member.Member import org.springframework.stereotype.Service @Service -class SearchService(private val repository: SearchRepository) { +class SearchService(private val repository: SearchRepository, private val langContext: LangContext) { fun searchUnified( keyword: String, isAdult: Boolean, @@ -16,7 +17,8 @@ class SearchService(private val repository: SearchRepository) { keyword = keyword, memberId = member.id!!, offset = 0, - limit = 3 + limit = 3, + lang = langContext.lang ) .map { it.type = SearchResponseType.CREATOR @@ -29,7 +31,8 @@ class SearchService(private val repository: SearchRepository) { isAdult = isAdult, contentType = contentType, offset = 0, - limit = 3 + limit = 3, + lang = langContext.lang ) .map { it.type = SearchResponseType.CONTENT @@ -42,7 +45,8 @@ class SearchService(private val repository: SearchRepository) { isAdult = isAdult, contentType = contentType, offset = 0, - limit = 3 + limit = 3, + lang = langContext.lang ) .map { it.type = SearchResponseType.SERIES @@ -67,7 +71,8 @@ class SearchService(private val repository: SearchRepository) { keyword = keyword, memberId = member.id!!, offset = offset, - limit = limit + limit = limit, + lang = langContext.lang ) .map { it.type = SearchResponseType.CREATOR @@ -98,7 +103,8 @@ class SearchService(private val repository: SearchRepository) { isAdult = isAdult, contentType = contentType, offset = offset, - limit = limit + limit = limit, + lang = langContext.lang ) .map { it.type = SearchResponseType.CONTENT @@ -129,7 +135,8 @@ class SearchService(private val repository: SearchRepository) { isAdult = isAdult, contentType = contentType, offset = offset, - limit = limit + limit = limit, + lang = langContext.lang ) .map { it.type = SearchResponseType.SERIES diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/api/home/HomeServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/api/home/HomeServiceTest.kt index 245e857f..8e21b222 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/api/home/HomeServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/api/home/HomeServiceTest.kt @@ -18,6 +18,7 @@ import kr.co.vividnext.sodalive.query.recommend.RecommendChannelQueryService import kr.co.vividnext.sodalive.rank.ContentRankingSortType import kr.co.vividnext.sodalive.rank.RankingRepository import kr.co.vividnext.sodalive.rank.RankingService +import kr.co.vividnext.sodalive.support.originalLocalizedNameService import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test @@ -68,6 +69,7 @@ class HomeServiceTest { rankingRepository = rankingRepository, explorerQueryRepository = explorerQueryRepository, langContext = LangContext().apply { setLang(Lang.JA) }, + localizedNameService = originalLocalizedNameService(), memberContentPreferenceService = Mockito.mock( kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService::class.java ), @@ -99,7 +101,7 @@ class HomeServiceTest { Mockito.doReturn(emptyList()).`when`(liveRoomService) .getRoomList(null, LiveRoomStatus.NOW, Pageable.ofSize(10), null, timezone) - Mockito.`when`(rankingRepository.getCreatorRankings(null)).thenReturn(emptyList()) + Mockito.`when`(rankingRepository.getCreatorRankings(null, Lang.JA)).thenReturn(emptyList()) Mockito.`when`( contentThemeService.getActiveThemeOfContent( false, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/can/CanServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/can/CanServiceTest.kt index 53f2b4a9..c689b2d1 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/can/CanServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/can/CanServiceTest.kt @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.can import kr.co.vividnext.sodalive.can.use.CanUsage import kr.co.vividnext.sodalive.common.CountryContext +import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.member.Member import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeEach @@ -22,7 +23,7 @@ class CanServiceTest { fun setUp() { repository = mock(CanRepository::class.java) countryContext = mock(CountryContext::class.java) - service = CanService(repository, countryContext) + service = CanService(repository, countryContext, LangContext()) } @Test diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepositoryLocalizedNameTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepositoryLocalizedNameTest.kt new file mode 100644 index 00000000..8160bebc --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepositoryLocalizedNameTest.kt @@ -0,0 +1,328 @@ +package kr.co.vividnext.sodalive.content + +import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre +import kr.co.vividnext.sodalive.configs.QueryDslConfig +import kr.co.vividnext.sodalive.content.main.curation.AudioContentCuration +import kr.co.vividnext.sodalive.content.main.curation.AudioContentCurationItem +import kr.co.vividnext.sodalive.content.main.curation.AudioContentCurationQueryRepository +import kr.co.vividnext.sodalive.content.order.Order +import kr.co.vividnext.sodalive.content.order.OrderType +import kr.co.vividnext.sodalive.content.series.ContentSeriesRepository +import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslation +import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslationPayload +import kr.co.vividnext.sodalive.content.theme.AudioContentTheme +import kr.co.vividnext.sodalive.content.translation.ContentTranslation +import kr.co.vividnext.sodalive.content.translation.ContentTranslationPayload +import kr.co.vividnext.sodalive.creator.admin.content.series.Series +import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent +import kr.co.vividnext.sodalive.explorer.CreatorRanking +import kr.co.vividnext.sodalive.i18n.Lang +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.member.block.BlockMember +import kr.co.vividnext.sodalive.rank.RankingRepository +import org.junit.jupiter.api.Assertions.assertAll +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.context.annotation.Import +import org.springframework.jdbc.core.JdbcTemplate +import java.time.LocalDateTime +import javax.persistence.EntityManager + +@DataJpaTest( + properties = [ + "spring.cache.type=none", + "spring.datasource.url=jdbc:h2:mem:legacy-content-owner-name;MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_ON_EXIT=FALSE" + ] +) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import(QueryDslConfig::class, RankingRepository::class, AudioContentCurationQueryRepository::class) +class AudioContentRepositoryLocalizedNameTest @Autowired constructor( + private val repository: AudioContentRepository, + private val seriesRepository: ContentSeriesRepository, + private val rankingRepository: RankingRepository, + private val curationRepository: AudioContentCurationQueryRepository, + private val entityManager: EntityManager, + private val jdbcTemplate: JdbcTemplate +) { + @Test + @DisplayName("JA 메인 콘텐츠 조회는 콘텐츠 ID가 아닌 실제 소유자 ID로 크리에이터 이름만 대체한다") + fun shouldLocalizeCurrentOwnerNameByCreatorIdWithoutChangingContentTitleOrUserName() { + val viewer = saveMember("owner-query-viewer", MemberRole.USER) + val creator = saveMember("owner-original", MemberRole.CREATOR) + val user = saveMember("user-original", MemberRole.USER) + val creatorContent = saveContent(creator, "Original audio title").takeIf { it.id != creator.id } + ?: saveContent(creator, "Original audio title") + val userContent = saveContent(user, "User audio title") + saveAlias(creator, "Japanese owner") + saveAlias(user, "Must not replace USER") + entityManager.clear() + assertNotEquals(creator.id, creatorContent.id) + + val items = findLatest(viewer.id!!, "ja").associateBy { it.contentId } + + assertAll( + { assertEquals("Japanese owner", items[creatorContent.id]!!.creatorNickname) }, + { assertEquals(creator.id, items[creatorContent.id]!!.creatorId) }, + { assertEquals("Original audio title", items[creatorContent.id]!!.title) }, + { assertEquals("user-original", items[userContent.id]!!.creatorNickname) }, + { assertEquals("User audio title", items[userContent.id]!!.title) }, + { + assertEquals( + "owner-original", + findLatest(viewer.id!!, "ko").first { it.contentId == creatorContent.id }.creatorNickname + ) + }, + { + assertEquals( + "owner-original", + findLatest(viewer.id!!, "en").first { it.contentId == creatorContent.id }.creatorNickname + ) + } + ) + } + + @Test + @DisplayName("메인 콘텐츠 이름 대체는 공개 필터를 유지하고 해제 후 변경된 현재 원본으로 돌아간다") + fun shouldPreserveVisibilityFiltersAndFallbackToRenamedOriginalAfterSoftClear() { + val viewer = saveMember("visibility-query-viewer", MemberRole.USER) + val creator = saveMember("before-rename", MemberRole.CREATOR) + val visible = saveContent(creator, "visible title") + saveContent(creator, "future title").releaseDate = LocalDateTime.now().plusDays(1) + saveContent(creator, "adult title").isAdult = true + saveContent(creator, "inactive title").isActive = false + saveContent(creator, "unfinished title").duration = null + val blockedCreator = saveMember("blocked-owner", MemberRole.CREATOR) + saveContent(blockedCreator, "blocked title") + entityManager.persist( + BlockMember(isActive = true).apply { + member = blockedCreator + blockedMember = viewer + } + ) + saveAlias(creator, "Retained owner alias") + saveAlias(blockedCreator, "Blocked owner alias") + creator.nickname = "renamed-original" + entityManager.flush() + entityManager.clear() + + val activeItems = findLatest(viewer.id!!, "ja") + assertEquals(listOf(visible.id), activeItems.map { it.contentId }) + assertEquals("Retained owner alias", activeItems.single().creatorNickname) + + jdbcTemplate.update( + "update creator_localized_name set is_active = false where member_id = ? and lang = 'JA'", + creator.id!! + ) + entityManager.clear() + assertEquals("renamed-original", findLatest(viewer.id!!, "ja").single().creatorNickname) + } + + @Test + @DisplayName("테마 공유 조회는 요청한 KO·JA 별칭과 기존 제목을 반환한다") + fun shouldLocalizeThemeNamesByRequestedLanguageWithoutChangingTitle() { + val viewer = saveMember("raw-theme-viewer", MemberRole.USER) + val creator = saveMember("raw-theme-original", MemberRole.CREATOR) + val content = saveContent(creator, "Unchanged theme title") + saveAlias(creator, "Japanese theme owner") + saveAlias(creator, "Korean theme owner", Lang.KO) + entityManager.clear() + + val names = listOf( + Lang.KO to "Korean theme owner", + Lang.JA to "Japanese theme owner" + ) + for ((lang, expected) in names) { + val item = repository.findByTheme(memberId = viewer.id!!, contentType = ContentType.ALL, lang = lang).single() + assertEquals(content.id, item.contentId) + assertEquals(expected, item.creatorNickname) + assertEquals("Unchanged theme title", item.title) + } + } + + @Test + @DisplayName("큐레이션 조회는 활성 KO·JA 별칭이 있어도 원본 닉네임을 반환한다") + fun shouldKeepCurationNamesRawWithActiveAliases() { + val creator = saveMember("raw-curation-original", MemberRole.CREATOR) + val curation = AudioContentCuration(title = "curation", description = "description") + .also { entityManager.persist(it) } + val content = saveContent(creator, "Unchanged curation title").apply { this.curation = curation } + entityManager.persist( + AudioContentCurationItem().apply { + this.curation = curation + this.content = content + } + ) + saveAlias(creator, "Japanese curation owner") + saveAlias(creator, "Korean curation owner", Lang.KO) + entityManager.clear() + + val results = listOf( + repository.findContentByCurationId(curation.id!!, false, ContentType.ALL), + curationRepository.findByCurationId(curation.id!!, "https://cdn.test", contentType = ContentType.ALL) + ) + for (items in results) { + assertEquals(content.id, items.single().contentId) + assertEquals("raw-curation-original", items.single().creatorNickname) + assertEquals("Unchanged curation title", items.single().title) + } + } + + @Test + @DisplayName("랭킹 공유 조회는 요청한 KO·JA 별칭과 제목 번역·원본 fallback을 반환한다") + fun shouldLocalizeRankingNamesAndTitlesByRequestedLocale() { + val viewer = saveMember("raw-rank-viewer", MemberRole.USER) + val creator = saveMember("raw-rank-original", MemberRole.CREATOR).apply { profileImage = "profile.png" } + val content = saveContent(creator, "Original ranking title") + entityManager.persist(CreatorRanking(ranking = 1).apply { member = creator }) + entityManager.persist( + Order(type = OrderType.KEEP).apply { + member = viewer + this.creator = creator + audioContent = content + } + ) + entityManager.persist( + ContentTranslation(content.id!!, "ja", ContentTranslationPayload("Translated ranking title", "detail", "")) + ) + saveAlias(creator, "Japanese ranking owner") + saveAlias(creator, "Korean ranking owner", Lang.KO) + // H2 JSON 컬럼에 문자열이 아닌 JSON 객체를 적재한다. + jdbcTemplate.update( + "update content_translation set rendered_payload = ? format json where content_id = ? and locale = 'ja'", + """{"title":"Translated ranking title","detail":"detail","tags":""}""", + content.id!! + ) + entityManager.clear() + + val names = listOf( + Triple(Lang.KO, "Korean ranking owner", "Original ranking title"), + Triple(Lang.JA, "Japanese ranking owner", "Translated ranking title") + ) + for ((lang, expected, expectedTitle) in names) { + assertEquals(expected, rankingRepository.getCreatorRankings(viewer.id, lang).single().nickname) + val item = rankingRepository.getAudioContentRanking( + memberId = viewer.id, + isAdult = false, + contentType = ContentType.ALL, + startDate = LocalDateTime.now().minusDays(1), + endDate = LocalDateTime.now().plusDays(1), + offset = 0, + limit = 10, + sortType = "판매량", + locale = lang.code + ).single() + assertEquals(expected, item.creatorNickname) + assertEquals(expectedTitle, item.title) + } + assertEquals( + "Korean ranking owner", + rankingRepository.getCreatorRankings(viewer.id).single().nickname + ) + } + + @Test + @DisplayName("오리지널 시리즈 공유 조회는 요청한 언어의 제목 번역·원본 fallback을 반환한다") + fun shouldLocalizeOriginalSeriesTitlesByRequestedLocale() { + val creator = saveMember("raw-series-original", MemberRole.CREATOR) + val content = saveContent(creator, "Series content") + val genre = SeriesGenre(genre = "genre").also { entityManager.persist(it) } + val series = Series(title = "Original series title", introduction = "introduction", isOriginal = true).apply { + member = creator + this.genre = genre + coverImage = "series.png" + entityManager.persist(this) + } + entityManager.persist( + SeriesContent().apply { + this.series = series + this.content = content + } + ) + entityManager.persist( + SeriesTranslation(series.id!!, "ja", SeriesTranslationPayload("Translated series title", "intro", emptyList())) + ) + entityManager.flush() + // H2 JSON 컬럼에 문자열이 아닌 JSON 객체를 적재한다. + jdbcTemplate.update( + "update series_translation set rendered_payload = ? format json where series_id = ? and locale = 'ja'", + """{"title":"Translated series title","introduction":"intro","keywords":[]}""", + series.id!! + ) + entityManager.clear() + + val titles = listOf( + "ja" to "Translated series title", + "ko" to "Original series title" + ) + for ((locale, expectedTitle) in titles) { + val localized = seriesRepository.getOriginalAudioDramaList( + imageHost = "https://cdn.test", + isAdult = false, + contentType = ContentType.ALL, + locale = locale + ).single() + assertEquals(expectedTitle, localized.title) + } + } + + private fun findLatest(viewerId: Long, locale: String): List { + return repository.getLatestContentByTheme( + memberId = viewerId, + theme = emptyList(), + contentType = ContentType.ALL, + offset = 0, + limit = 20, + sortType = SortType.NEWEST, + isFree = false, + isAdult = false, + locale = locale + ) + } + + private fun saveMember(nickname: String, role: MemberRole): Member { + return Member( + email = "$nickname@test.com", + password = "password", + nickname = nickname, + profileImage = "profile.png", + role = role + ).also { + entityManager.persist(it) + } + } + + private fun saveContent(creator: Member, title: String): AudioContent { + val theme = AudioContentTheme(theme = "theme-$title", image = "theme.png") + entityManager.persist(theme) + return AudioContent( + title = title, + detail = "detail", + languageCode = "ko", + releaseDate = LocalDateTime.now().minusDays(1) + ).apply { + member = creator + this.theme = theme + isActive = true + coverImage = "audio.png" + duration = "00:10:00" + entityManager.persist(this) + } + } + + private fun saveAlias(member: Member, displayName: String, lang: Lang = Lang.JA) { + entityManager.flush() + jdbcTemplate.update( + "insert into creator_localized_name (member_id, lang, display_name, is_active, created_at, updated_at) " + + "values (?, ?, ?, true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", + member.id!!, + lang.name, + displayName + ) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt index b19c7bdd..b04bc186 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt @@ -24,6 +24,7 @@ import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRepository import kr.co.vividnext.sodalive.member.block.BlockMemberRepository import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService +import kr.co.vividnext.sodalive.support.originalLocalizedNameService import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService import kr.co.vividnext.sodalive.v2.recommendation.application.CreatorContentViewHistoryService import org.junit.jupiter.api.Assertions.assertEquals @@ -114,6 +115,7 @@ class AudioContentServiceTest { applicationEventPublisher = applicationEventPublisher, messageSource = SodaMessageSource(), langContext = LangContext(), + localizedNameService = originalLocalizedNameService(), contentThemeTranslationRepository = contentThemeTranslationRepository, homeFollowingNewsPublishService = homeFollowingNewsPublishService, memberContentPreferenceService = memberContentPreferenceService, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentNameMetadataTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentNameMetadataTest.kt new file mode 100644 index 00000000..000f9644 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentNameMetadataTest.kt @@ -0,0 +1,91 @@ +package kr.co.vividnext.sodalive.content.comment + +import kr.co.vividnext.sodalive.configs.QueryDslConfig +import kr.co.vividnext.sodalive.content.AudioContent +import kr.co.vividnext.sodalive.content.AudioContentRepository +import kr.co.vividnext.sodalive.content.order.OrderRepository +import kr.co.vividnext.sodalive.content.theme.AudioContentTheme +import kr.co.vividnext.sodalive.fcm.FcmEvent +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.member.block.BlockMemberRepository +import kr.co.vividnext.sodalive.support.LegacyCreatorNameTestConfiguration +import kr.co.vividnext.sodalive.v2.creatorname.application.CreatorLocalizedNameService +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import org.mockito.ArgumentCaptor +import org.mockito.Mockito +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.context.ApplicationEventPublisher +import org.springframework.context.annotation.Import +import javax.persistence.EntityManager + +@DataJpaTest(properties = ["spring.cache.type=none"]) +@Import(QueryDslConfig::class, LegacyCreatorNameTestConfiguration::class) +class AudioContentCommentNameMetadataTest @Autowired constructor( + private val entityManager: EntityManager, + private val repository: AudioContentCommentRepository, + private val blockRepository: BlockMemberRepository, + private val contentRepository: AudioContentRepository, + private val orderRepository: OrderRepository, + private val localizedNameService: CreatorLocalizedNameService +) { + @ParameterizedTest + @CsvSource("CREATOR, USER", "USER, CREATOR") + @DisplayName("답글 알림 제목은 부모 작성자 ID이고 snapshot 발신자 ID와 콘텐츠 제목 인자는 그대로다") + fun shouldKeepParentTitleOwnerSeparateFromActualCommenterAndResourceTitle( + parentRole: MemberRole, + commenterRole: MemberRole + ) { + val parentWriter = Member(nickname = "parent-original", password = "password", role = parentRole) + val commenter = Member(nickname = "commenter-original", password = "password", role = commenterRole) + val contentOwner = Member(nickname = "content-owner-original", password = "password", role = MemberRole.CREATOR) + entityManager.persist(parentWriter) + entityManager.persist(commenter) + entityManager.persist(contentOwner) + val theme = AudioContentTheme(theme = "metadata-theme", image = "theme.png") + entityManager.persist(theme) + val content = AudioContent(title = "Unchanged content title", detail = "detail", languageCode = "ko").apply { + member = contentOwner + this.theme = theme + isActive = true + entityManager.persist(this) + } + val parent = AudioContentComment(comment = "parent comment", languageCode = "ko").apply { + member = parentWriter + audioContent = content + entityManager.persist(this) + } + entityManager.flush() + val publisher = Mockito.mock(ApplicationEventPublisher::class.java) + val service = AudioContentCommentService( + repository, + blockRepository, + contentRepository, + publisher, + orderRepository, + SodaMessageSource(), + LangContext(), + localizedNameService, + "https://cdn.test" + ) + + service.registerComment(commenter, "reply", content.id!!, parent.id!!, false, "ko") + + val captor = ArgumentCaptor.forClass(FcmEvent::class.java) + Mockito.verify(publisher).publishEvent(captor.capture()) + val event = captor.value + assertNotEquals(parentWriter.id, commenter.id) + assertEquals(parentWriter.id, event.titleMemberId) + assertEquals(commenter.id, event.senderMemberId) + assertEquals("parent-original", event.title) + assertEquals(listOf("Unchanged content title"), event.args) + assertEquals(emptyMap(), event.nameArgumentMemberIds) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerLocalizedNameEndToEndTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerLocalizedNameEndToEndTest.kt new file mode 100644 index 00000000..51c681cd --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerLocalizedNameEndToEndTest.kt @@ -0,0 +1,259 @@ +package kr.co.vividnext.sodalive.explorer + +import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre +import kr.co.vividnext.sodalive.common.CountryContext +import kr.co.vividnext.sodalive.content.AudioContent +import kr.co.vividnext.sodalive.content.theme.AudioContentTheme +import kr.co.vividnext.sodalive.creator.admin.content.series.Series +import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent +import kr.co.vividnext.sodalive.explorer.profile.CreatorCheers +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberAdapter +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.context.transaction.TestTransaction +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime +import javax.persistence.EntityManager + +@SpringBootTest( + properties = [ + "cloud.aws.cloud-front.host=https://cdn.test", + "spring.cache.type=none", + "spring.datasource.url=jdbc:h2:mem:legacy-creator-name-e2e;MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_ON_EXIT=FALSE" + ] +) +@AutoConfigureMockMvc +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +@Transactional +class ExplorerLocalizedNameEndToEndTest @Autowired constructor( + private val mockMvc: MockMvc, + private val entityManager: EntityManager, + private val jdbcTemplate: JdbcTemplate +) { + @MockBean + private lateinit var countryContext: CountryContext + + @BeforeEach + fun setUp() { + Mockito.doReturn("US").`when`(countryContext).countryCode + } + + @Test + @DisplayName("레거시 프로필 이름은 현지화하되 팬톡 작성자·답글은 활성 JA·KO 별칭이 있어도 원본을 유지한다") + fun shouldLocalizeProfileButKeepOriginalFanTalkWriterAndReplyNamesDespiteActiveAliases() { + val viewer = saveMember("profile-viewer", MemberRole.USER) + val creator = saveMember("profile-original", MemberRole.CREATOR).apply { isVisibleDonationRank = false } + val fan = saveMember("fan-original", MemberRole.USER) + val peer = saveMember("deleted_peer-original", MemberRole.CREATOR) + val fanTalk = saveCheers(fan, creator, "fan talk") + saveCheers(peer, creator, "creator fan talk") + saveCheers(creator, creator, "creator reply", parent = fanTalk) + saveAlias(creator, "Japanese profile") + saveAlias(peer, "Japanese peer") + saveAlias(fan, "Must not replace fan") + saveAlias(creator, "Korean profile", lang = "KO") + saveAlias(peer, "Korean peer", lang = "KO") + entityManager.clear() + commitFixture() + + mockMvc.perform( + get("/explorer/profile/${creator.id}") + .param("timezone", "Asia/Seoul") + .header("Accept-Language", "ja") + .with(user(MemberAdapter(viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.creator.creatorId").value(creator.id!!)) + .andExpect(jsonPath("$.data.creator.nickname").value("Japanese profile")) + .andExpect(jsonPath("$.data.cheers.totalCount").value(2)) + .andExpect(jsonPath("$.data.cheers.cheers[0].memberId").value(peer.id!!)) + .andExpect(jsonPath("$.data.cheers.cheers[0].nickname").value("peer-original")) + .andExpect(jsonPath("$.data.cheers.cheers[1].nickname").value("fan-original")) + .andExpect(jsonPath("$.data.cheers.cheers[1].replyList[0].memberId").value(creator.id!!)) + .andExpect(jsonPath("$.data.cheers.cheers[1].replyList[0].nickname").value("profile-original")) + + listOf("ko", "en").forEach { locale -> + mockMvc.perform( + get("/explorer/profile/${creator.id}") + .param("timezone", "Asia/Seoul") + .header("Accept-Language", locale) + .with(user(MemberAdapter(viewer))) + ) + .andExpect(status().isOk) + .andExpect( + jsonPath("$.data.creator.nickname").value(if (locale == "ko") "Korean profile" else "profile-original") + ) + .andExpect(jsonPath("$.data.cheers.cheers[0].nickname").value("peer-original")) + .andExpect(jsonPath("$.data.cheers.cheers[1].nickname").value("fan-original")) + .andExpect(jsonPath("$.data.cheers.cheers[1].replyList[0].nickname").value("profile-original")) + } + assertEquals("profile-original", entityManager.find(Member::class.java, creator.id).nickname) + } + + @Test + @DisplayName("JA 상세는 원본 변경 후에도 별칭을 유지하고 해제·미등록이면 현재 원본을 반환한다") + fun shouldKeepAliasAfterOriginalRenameAndFallbackAfterSoftClearOrMissingAlias() { + val viewer = saveMember("detail-viewer", MemberRole.USER) + val creator = saveMember("before-rename", MemberRole.CREATOR) + val unregistered = saveMember("unregistered-original", MemberRole.CREATOR) + saveAlias(creator, "Retained profile alias") + creator.nickname = "renamed-original" + entityManager.flush() + entityManager.clear() + commitFixture() + + mockMvc.perform( + get("/explorer/profile/${creator.id}/detail") + .header("Accept-Language", "ja") + .with(user(MemberAdapter(viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.nickname").value("Retained profile alias")) + + jdbcTemplate.update( + "update creator_localized_name set is_active = false where member_id = ? and lang = 'JA'", + creator.id!! + ) + entityManager.clear() + mockMvc.perform( + get("/explorer/profile/${creator.id}/detail") + .header("Accept-Language", "ja") + .with(user(MemberAdapter(viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.nickname").value("renamed-original")) + mockMvc.perform( + get("/explorer/profile/${unregistered.id}/detail") + .header("Accept-Language", "ja") + .with(user(MemberAdapter(viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.nickname").value("unregistered-original")) + } + + @Test + @DisplayName("통합 검색은 creator 제목·닉네임만 JA 이름으로 바꾸고 콘텐츠·시리즈 제목과 소유자 ID를 구분한다") + fun shouldLocalizeCreatorSearchTitleAndAllOwnerNamesWithoutReplacingContentOrSeriesTitles() { + val viewer = saveMember("unified-viewer", MemberRole.USER) + saveMember("unused-member", MemberRole.USER) + val creator = saveMember("unified-original", MemberRole.CREATOR) + val theme = AudioContentTheme(theme = "unified-theme", image = "theme.png") + entityManager.persist(theme) + val content = AudioContent( + title = "Unchanged content title", + detail = "description", + languageCode = "ko", + releaseDate = LocalDateTime.now().minusDays(1) + ).apply { + member = creator + this.theme = theme + isActive = true + coverImage = "audio.png" + duration = "00:10:00" + entityManager.persist(this) + } + val genre = SeriesGenre(genre = "unified-genre", isAdult = false, isActive = true) + entityManager.persist(genre) + val series = Series(title = "Unchanged series title", introduction = "introduction").apply { + member = creator + this.genre = genre + coverImage = "series.png" + entityManager.persist(this) + } + entityManager.persist( + SeriesContent().apply { + this.series = series + this.content = content + } + ) + saveAlias(creator, "Japanese unified creator") + entityManager.clear() + assertNotEquals(creator.id, content.id) + assertNotEquals(creator.id, series.id) + commitFixture() + + mockMvc.perform( + get("/search") + .param("keyword", "unified-original") + .header("Accept-Language", "ja") + .with(user(MemberAdapter(viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.creatorList[0].id").value(creator.id!!)) + .andExpect(jsonPath("$.data.creatorList[0].title").value("Japanese unified creator")) + .andExpect(jsonPath("$.data.creatorList[0].nickname").value("Japanese unified creator")) + .andExpect(jsonPath("$.data.contentList[0].id").value(content.id!!)) + .andExpect(jsonPath("$.data.contentList[0].title").value("Unchanged content title")) + .andExpect(jsonPath("$.data.contentList[0].nickname").value("Japanese unified creator")) + .andExpect(jsonPath("$.data.seriesList[0].id").value(series.id!!)) + .andExpect(jsonPath("$.data.seriesList[0].title").value("Unchanged series title")) + .andExpect(jsonPath("$.data.seriesList[0].nickname").value("Japanese unified creator")) + + mockMvc.perform( + get("/search/creators") + .param("keyword", "Japanese unified") + .header("Accept-Language", "ko") + .with(user(MemberAdapter(viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.totalCount").value(1)) + .andExpect(jsonPath("$.data.items[0].title").value("unified-original")) + .andExpect(jsonPath("$.data.items[0].nickname").value("unified-original")) + } + + private fun commitFixture() { + TestTransaction.flagForCommit() + TestTransaction.end() + TestTransaction.start() + } + + private fun saveMember(nickname: String, role: MemberRole): Member { + return Member( + email = "$nickname@test.com", + password = "password", + nickname = nickname, + profileImage = "$nickname.png", + role = role + ).also { entityManager.persist(it) } + } + + private fun saveCheers(writer: Member, creator: Member, text: String, parent: CreatorCheers? = null): CreatorCheers { + return CreatorCheers(cheers = text, languageCode = "ko").apply { + member = writer + this.creator = creator + this.parent = parent + entityManager.persist(this) + } + } + + private fun saveAlias(member: Member, displayName: String, lang: String = "JA") { + entityManager.flush() + jdbcTemplate.update( + "insert into creator_localized_name (member_id, lang, display_name, is_active, created_at, updated_at) " + + "values (?, ?, ?, true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", + member.id!!, + lang, + displayName + ) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepositoryLocalizedNameTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepositoryLocalizedNameTest.kt new file mode 100644 index 00000000..32332ada --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepositoryLocalizedNameTest.kt @@ -0,0 +1,90 @@ +package kr.co.vividnext.sodalive.explorer + +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.configs.QueryDslConfig +import kr.co.vividnext.sodalive.i18n.Lang +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRole +import org.junit.jupiter.api.Assertions.assertAll +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.context.annotation.Import +import org.springframework.jdbc.core.JdbcTemplate +import javax.persistence.EntityManager + +@DataJpaTest(properties = ["spring.cache.type=none"]) +@Import(QueryDslConfig::class) +class ExplorerQueryRepositoryLocalizedNameTest @Autowired constructor( + private val entityManager: EntityManager, + private val jdbcTemplate: JdbcTemplate, + queryFactory: JPAQueryFactory +) { + private val langContext = LangContext().apply { setLang(Lang.KO) } + private val repository = ExplorerQueryRepository(queryFactory, SodaMessageSource(), langContext, "https://cdn.test") + + @Test + @DisplayName("KO 채널 검색도 원본 또는 활성 JA 이름을 찾고 self·비활성·USER를 제외한다") + fun shouldSearchOriginalOrActiveJaAliasInKoWithoutBypassingChannelFilters() { + val viewer = saveMember("Comet-viewer") + val original = saveMember("Comet-original") + val aliasOnly = saveMember("another-original") + val inactive = saveMember("Comet-inactive").apply { isActive = false } + val user = saveMember("Comet-user", MemberRole.USER) + listOf(viewer, original, aliasOnly, inactive, user).forEach { saveAlias(it, "Comet alias") } + entityManager.clear() + + assertAll( + { + assertEquals( + setOf(original.id, aliasOnly.id), + repository.getSearchChannel("cOmEt", viewer.id!!).map { it.id }.toSet() + ) + }, + { assertEquals(2, repository.getSearchChannel("cOmEt", viewer.id!!).size) }, + { assertEquals(listOf(original.id), repository.getSearchChannel("Comet-original", viewer.id!!).map { it.id }) } + ) + } + + @Test + @DisplayName("채널 검색은 해제한 JA와 EN 이름을 제외하고 동일 행 재활성화 후 별칭을 다시 찾는다") + fun shouldIgnoreSoftClearedAndNonJaChannelAliasesAndSearchReactivatedAlias() { + val viewer = saveMember("channel-viewer", MemberRole.USER) + val creator = saveMember("channel-original") + saveAlias(creator, "ChannelAlias", isActive = false) + saveAlias(creator, "EnglishChannel", lang = "EN") + entityManager.clear() + + assertEquals(emptyList(), repository.getSearchChannel("ChannelAlias", viewer.id!!).map { it.id }) + assertEquals(emptyList(), repository.getSearchChannel("EnglishChannel", viewer.id!!).map { it.id }) + assertEquals(listOf(creator.id), repository.getSearchChannel("channel-original", viewer.id!!).map { it.id }) + jdbcTemplate.update( + "update creator_localized_name set is_active = true where member_id = ? and lang = 'JA'", + creator.id!! + ) + + assertEquals(listOf(creator.id), repository.getSearchChannel("ChannelAlias", viewer.id!!).map { it.id }) + } + + private fun saveMember(nickname: String, role: MemberRole = MemberRole.CREATOR): Member { + return Member(email = "$nickname@test.com", password = "password", nickname = nickname, role = role).also { + entityManager.persist(it) + } + } + + private fun saveAlias(member: Member, displayName: String, isActive: Boolean = true, lang: String = "JA") { + entityManager.flush() + jdbcTemplate.update( + "insert into creator_localized_name (member_id, lang, display_name, is_active, created_at, updated_at) " + + "values (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", + member.id!!, + lang, + displayName, + isActive + ) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerServiceTest.kt index e365ce82..b3143abb 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerServiceTest.kt @@ -11,12 +11,15 @@ import kr.co.vividnext.sodalive.explorer.profile.CreatorDonationRankingService import kr.co.vividnext.sodalive.explorer.profile.PutWriteCheersRequest import kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationService import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityService +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.MemberService import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService +import kr.co.vividnext.sodalive.v2.creatorname.application.CreatorLocalizedNameService +import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test @@ -42,7 +45,28 @@ class ExplorerServiceTest { } } - private fun createService(queryRepository: ExplorerQueryRepository): ExplorerService { + @Test + @DisplayName("팬톡 수정 응답은 별칭 대신 작성자 원본 이름을 반환하고 deleted_ 접두사만 제거한다") + fun shouldKeepOriginalWriterNameAndRemoveDeletedPrefixWhenModifyingCheers() { + val queryRepository = Mockito.mock(ExplorerQueryRepository::class.java) + val localizedNameService = Mockito.mock(CreatorLocalizedNameService::class.java) + val service = createService(queryRepository, localizedNameService) + val writer = createMember(id = 10L, nickname = "deleted_writer", role = MemberRole.CREATOR) + val cheers = createCheers(writer, createMember(id = 20L, nickname = "creator", role = MemberRole.CREATOR)) + Mockito.doReturn(cheers).`when`(queryRepository).getCheers(101L) + Mockito.`when`(localizedNameService.findLocalizedNames(listOf(10L), Lang.JA)).thenReturn(mapOf(10L to "alias")) + + val response = service.modifyCheers(PutWriteCheersRequest(cheersId = 101L, content = "modified"), writer) + + assertEquals("writer", response.writerNickname) + assertEquals("modified", response.content) + Mockito.verifyNoInteractions(localizedNameService) + } + + private fun createService( + queryRepository: ExplorerQueryRepository, + localizedNameService: CreatorLocalizedNameService = Mockito.mock(CreatorLocalizedNameService::class.java) + ): ExplorerService { return ExplorerService( memberService = Mockito.mock(MemberService::class.java), memberContentPreferenceService = Mockito.mock(MemberContentPreferenceService::class.java), @@ -57,7 +81,8 @@ class ExplorerServiceTest { applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java), contentTranslationRepository = Mockito.mock(ContentTranslationRepository::class.java), messageSource = Mockito.mock(SodaMessageSource::class.java), - langContext = Mockito.mock(LangContext::class.java), + langContext = LangContext().apply { setLang(Lang.JA) }, + localizedNameService = localizedNameService, cloudFrontHost = "https://cdn.test" ) } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationMessageRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationMessageRepositoryTest.kt index 3b8f9148..d48dde39 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationMessageRepositoryTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationMessageRepositoryTest.kt @@ -72,9 +72,9 @@ class ChannelDonationMessageRepositoryTest @Autowired constructor( // then: 비공개 타인 메시지는 제외되고, createdAt desc/id desc 정렬이 적용돼야 한다. assertEquals(3, list.size) - assertEquals(secretMine.id, list[0].id) - assertEquals(publicTieSecond.id, list[1].id) - assertEquals(publicTieFirst.id, list[2].id) + assertEquals(secretMine.id, list[0].first.id) + assertEquals(publicTieSecond.id, list[1].first.id) + assertEquals(publicTieFirst.id, list[2].first.id) assertEquals(3, totalCount) } @@ -127,9 +127,9 @@ class ChannelDonationMessageRepositoryTest @Autowired constructor( // then: 본인 조회는 비공개 메시지를 포함하고 최신순으로 반환해야 한다. assertEquals(3, list.size) - assertEquals(recentSecretOther.id, list[0].id) - assertEquals(recentSecretMine.id, list[1].id) - assertEquals(recentPublic.id, list[2].id) + assertEquals(recentSecretOther.id, list[0].first.id) + assertEquals(recentSecretMine.id, list[1].first.id) + assertEquals(recentPublic.id, list[2].first.id) assertEquals(3, totalCount) } @@ -138,7 +138,7 @@ class ChannelDonationMessageRepositoryTest @Autowired constructor( fun shouldFetchMemberWithDonationMessages() { // given: 크리에이터/후원자와 조회 대상 메시지를 준비한다. val creator = saveMember(nickname = "creator3", role = MemberRole.CREATOR) - val viewer = saveMember(nickname = "viewer3", role = MemberRole.USER) + val viewer = saveMember(nickname = "deleted_viewer3", role = MemberRole.USER) val message = saveMessage(member = viewer, creator = creator, can = 1, isSecret = false) val monthStart = LocalDateTime.now().withDayOfMonth(1).toLocalDate().atStartOfDay() val nextMonthStart = monthStart.plusMonths(1) @@ -160,8 +160,9 @@ class ChannelDonationMessageRepositoryTest @Autowired constructor( // then: 조회 결과의 후원자 정보는 세션 없이도 접근 가능해야 한다. assertEquals(1, list.size) - assertEquals(viewer.id, list[0].member!!.id) - assertEquals("viewer3", list[0].member!!.nickname) + assertEquals(viewer.id, list[0].first.member!!.id) + assertEquals("deleted_viewer3", list[0].first.member!!.nickname) + assertEquals("viewer3", list[0].second) } private fun saveMember(nickname: String, role: MemberRole): Member { diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationServiceTest.kt index 625ded9a..cde61cf0 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationServiceTest.kt @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.explorer.profile.channelDonation import kr.co.vividnext.sodalive.can.payment.CanPaymentService import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.member.Member @@ -97,9 +98,10 @@ class ChannelDonationServiceTest { Mockito.eq(0L), Mockito.eq(5L), anyLocalDateTime(), - anyLocalDateTime() + anyLocalDateTime(), + Mockito.eq(Lang.KO) ?: Lang.KO ) - ).thenReturn(listOf(message)) + ).thenReturn(listOf(message to viewer.nickname)) // when: 채널 후원 목록 조회를 실행한다. val result = service.getChannelDonationList( @@ -129,7 +131,8 @@ class ChannelDonationServiceTest { Mockito.eq(0L), Mockito.eq(5L), anyLocalDateTime(), - anyLocalDateTime() + anyLocalDateTime(), + Mockito.eq(Lang.KO) ?: Lang.KO ) // then: 월 시작/다음 달 시작 범위를 사용해야 한다. @@ -181,9 +184,10 @@ class ChannelDonationServiceTest { Mockito.eq(0L), Mockito.eq(5L), anyLocalDateTime(), - anyLocalDateTime() + anyLocalDateTime(), + Mockito.eq(Lang.KO) ?: Lang.KO ) - ).thenReturn(listOf(message)) + ).thenReturn(listOf(message to "withdrawn")) // when: 채널 후원 목록 조회를 실행한다. val result = service.getChannelDonationList( @@ -228,9 +232,10 @@ class ChannelDonationServiceTest { Mockito.eq(0L), Mockito.eq(5L), anyLocalDateTime(), - anyLocalDateTime() + anyLocalDateTime(), + Mockito.eq(Lang.KO) ?: Lang.KO ) - ).thenReturn(listOf(message)) + ).thenReturn(listOf(message to viewer.nickname)) // when: 목록 조회를 실행한다. val result = service.getChannelDonationList( @@ -275,7 +280,8 @@ class ChannelDonationServiceTest { Mockito.eq(0L), Mockito.eq(5L), anyLocalDateTime(), - anyLocalDateTime() + anyLocalDateTime(), + Mockito.eq(Lang.KO) ?: Lang.KO ) ).thenReturn(emptyList()) @@ -302,7 +308,8 @@ class ChannelDonationServiceTest { Mockito.eq(0L), Mockito.eq(5L), anyLocalDateTime(), - anyLocalDateTime() + anyLocalDateTime(), + Mockito.eq(Lang.KO) ?: Lang.KO ) // then: 월 시작/다음 달 시작 범위를 사용해야 한다. diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt index 93283d6b..0487c7cd 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt @@ -150,6 +150,7 @@ class CreatorCommunityServiceTest { assertEquals(FcmEventType.INDIVIDUAL, event.type) assertEquals(PushNotificationCategory.COMMUNITY, event.category) assertEquals("creator.community.fcm.new_comment", event.messageKey) + assertEquals(commenter.id, event.titleMemberId) assertEquals(commenter.id, event.senderMemberId) assertEquals(listOf(creator.id!!), event.recipients) assertEquals(FcmDeepLinkValue.COMMUNITY, event.deepLinkValue) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendRepositoryTest.kt index a1de892a..f4790ce6 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendRepositoryTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendRepositoryTest.kt @@ -94,7 +94,8 @@ class LiveRecommendRepositoryTest @Autowired constructor( memberId = viewer.id!!, offset = 0, limit = 20, - isBlocked = { false } + isBlocked = { false }, + lang = Lang.KO ) assertEquals(2, result.size) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendServiceTest.kt index db60bf77..79694871 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendServiceTest.kt @@ -1,6 +1,7 @@ package kr.co.vividnext.sodalive.live.recommend import kr.co.vividnext.sodalive.i18n.Lang +import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.auth.Auth import kr.co.vividnext.sodalive.member.block.BlockMemberRepository @@ -28,7 +29,8 @@ class LiveRecommendServiceTest { repository = repository, blockMemberRepository = blockMemberRepository, memberContentPreferenceService = memberContentPreferenceService, - liveRecommendCacheService = liveRecommendCacheService + liveRecommendCacheService = liveRecommendCacheService, + langContext = LangContext() ) } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomLocalizedNameIntegrationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomLocalizedNameIntegrationTest.kt new file mode 100644 index 00000000..830247d3 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomLocalizedNameIntegrationTest.kt @@ -0,0 +1,523 @@ +package kr.co.vividnext.sodalive.live.room + +import kr.co.vividnext.sodalive.aws.s3.S3Uploader +import kr.co.vividnext.sodalive.can.use.CanUsage +import kr.co.vividnext.sodalive.can.use.UseCan +import kr.co.vividnext.sodalive.can.use.UseCanRepository +import kr.co.vividnext.sodalive.fcm.FcmService +import kr.co.vividnext.sodalive.i18n.Lang +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.live.recommend.LiveRecommendService +import kr.co.vividnext.sodalive.live.reservation.LiveReservation +import kr.co.vividnext.sodalive.live.reservation.LiveReservationRepository +import kr.co.vividnext.sodalive.live.reservation.LiveReservationService +import kr.co.vividnext.sodalive.live.reservation.MakeLiveReservationRequest +import kr.co.vividnext.sodalive.live.room.info.LiveRoomInfo +import kr.co.vividnext.sodalive.live.room.info.LiveRoomInfoRedisRepository +import kr.co.vividnext.sodalive.live.room.info.LiveRoomMemberRole +import kr.co.vividnext.sodalive.live.room.visit.LiveRoomVisitService +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.member.following.CreatorFollowing +import kr.co.vividnext.sodalive.member.following.CreatorFollowingRepository +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import org.hibernate.SessionFactory +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertAll +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.cache.CacheManager +import org.springframework.data.domain.PageRequest +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.mock.web.MockHttpServletRequest +import org.springframework.test.annotation.DirtiesContext +import org.springframework.test.context.ContextConfiguration +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.context.request.RequestContextHolder +import org.springframework.web.context.request.ServletRequestAttributes +import java.time.LocalDateTime +import javax.persistence.EntityManagerFactory + +@SpringBootTest( + properties = [ + "spring.cache.type=none", + "spring.jpa.properties.hibernate.generate_statistics=true", + "jwt.secret=MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWYwMTIzNDU2Nzg5YWJjZGVmMDEyMzQ1Njc4OWFiY2RlZg==", + "agora.app-id=0123456789abcdef0123456789abcdef", + "agora.app-certificate=0123456789abcdef0123456789abcdef" + ] +) +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +@Transactional +class LiveRoomLocalizedNameIntegrationTest @Autowired constructor( + private val service: LiveRoomService, + private val reservationService: LiveReservationService, + private val reservationRepository: LiveReservationRepository, + private val visitService: LiveRoomVisitService, + private val recommendService: LiveRecommendService, + private val followingRepository: CreatorFollowingRepository, + private val useCanRepository: UseCanRepository, + private val roomRepository: LiveRoomRepository, + private val roomInfoRepository: LiveRoomInfoRedisRepository, + private val memberRepository: MemberRepository, + private val jdbcTemplate: JdbcTemplate, + private val cacheManager: CacheManager, + private val entityManagerFactory: EntityManagerFactory, + private val langContext: LangContext +) { + @MockBean + private lateinit var fcmService: FcmService + + @MockBean + private lateinit var s3Uploader: S3Uploader + + private val roomIds = mutableListOf() + private val finishedLiveCacheKeys = mutableListOf() + + @BeforeEach + fun setUpRequest() { + RequestContextHolder.setRequestAttributes(ServletRequestAttributes(MockHttpServletRequest())) + } + + @AfterEach + fun resetRequestAndRemoveRoomInfo() { + roomIds.forEach { roomInfoRepository.deleteById(it) } + finishedLiveCacheKeys.forEach { cacheManager.getCache("cache_ttl_10_minutes")?.evict(it) } + RequestContextHolder.resetRequestAttributes() + } + + @Test + @DisplayName("라이브 info는 모든 멤버 목록을 요청 언어로 복사하고 역할과 공유 Redis 원본을 보존한다") + fun shouldRenderRoomInfoMemberNamesWithoutMutatingSharedRedisMembership() { + val fixture = createFixture() + langContext.setLang(Lang.JA) + val japanese = service.getRoomInfo(fixture.roomId, fixture.viewer) + langContext.setLang(Lang.KO) + val korean = service.getRoomInfo(fixture.roomId, fixture.viewer) + val stored = roomInfoRepository.findById(fixture.roomId).orElseThrow() + + assertAll( + { assertEquals("日本-host", japanese.creatorNickname) }, + { assertEquals("日本-host", japanese.speakerList.single().nickname) }, + { assertEquals("日本-manager", japanese.managerList.single().nickname) }, + { assertEquals(listOf("viewer", "fallback"), japanese.listenerList.map { it.nickname }) }, + { assertEquals(LiveRoomMemberRole.SPEAKER, japanese.speakerList.single().role) }, + { assertEquals(LiveRoomMemberRole.MANAGER, japanese.managerList.single().role) }, + { assertEquals("host", korean.creatorNickname) }, + { assertEquals("host", korean.speakerList.single().nickname) }, + { assertEquals("manager", korean.managerList.single().nickname) }, + { assertEquals("host", stored.speakerList.single().nickname) }, + { assertEquals("manager", stored.managerList.single().nickname) }, + { assertEquals("host", memberRepository.findById(fixture.host.id!!).orElseThrow().nickname) } + ) + } + + @Test + @DisplayName("라이브 상세와 멤버 프로필의 현재 이름은 JA 별칭이며 USER와 비활성 별칭은 원본이다") + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @DirtiesContext(methodMode = DirtiesContext.MethodMode.AFTER_METHOD) + fun shouldRenderCurrentRoomDetailAndMemberProfileNames() { + val fixture = createFixture() + langContext.setLang(Lang.JA) + + val detail = service.getRoomDetail(fixture.roomId, fixture.viewer, "UTC") + val hostProfile = service.getUserProfile(fixture.roomId, fixture.host.id!!, fixture.viewer) + val viewerProfile = service.getUserProfile(fixture.roomId, fixture.viewer.id!!, fixture.viewer) + + assertAll( + { assertEquals("日本-host", detail.manager!!.nickname) }, + { assertEquals("日本-host", detail.participatingUsers.single { it.id == fixture.host.id }.nickname) }, + { assertEquals("日本-manager", detail.participatingUsers.single { it.id == fixture.manager.id }.nickname) }, + { assertEquals("viewer", detail.participatingUsers.single { it.id == fixture.viewer.id }.nickname) }, + { assertEquals("fallback", detail.participatingUsers.single { it.id == fixture.fallback.id }.nickname) }, + { assertEquals("日本-host", hostProfile.nickname) }, + { assertEquals("viewer", viewerProfile.nickname) } + ) + } + + @Test + @DisplayName("현재 후원 룰렛 메시지 이름은 요청별로 렌더링하며 비밀 필터와 본문 UUID 및 Redis 원본은 보존한다") + fun shouldRenderDonationAndRouletteNamesOnCopiesAfterSecretFiltering() { + val fixture = createFixture() + val roomInfo = roomInfoRepository.findById(fixture.roomId).orElseThrow() + roomInfo.addDonationMessage(fixture.manager.id!!, "manager", false, 10, "manager 후원 본문") + roomInfo.addRouletteMessage(fixture.manager.id!!, "manager", "manager 룰렛 결과") + roomInfo.addDonationMessage(fixture.viewer.id!!, "viewer", false, 10, "viewer 본문") + roomInfo.addDonationMessage(fixture.manager.id!!, "manager", true, 10, "비밀 본문") + roomInfoRepository.save(roomInfo) + langContext.setLang(Lang.JA) + val japanese = service.getDonationMessageList(fixture.roomId, fixture.viewer) + langContext.setLang(Lang.KO) + val korean = service.getDonationMessageList(fixture.roomId, fixture.viewer) + val stored = roomInfoRepository.findById(fixture.roomId).orElseThrow().donationMessageList + + assertAll( + { assertEquals(listOf("日本-manager", "日本-manager", "viewer"), japanese.map { it.nickname }) }, + { assertEquals(listOf("manager", "manager", "viewer"), korean.map { it.nickname }) }, + { assertEquals(listOf("manager 후원 본문", "manager 룰렛 결과", "viewer 본문"), japanese.map { it.donationMessage }) }, + { assertEquals(korean.map { it.uuid }, japanese.map { it.uuid }) }, + { assertEquals(listOf("manager", "manager", "viewer", "manager"), stored.map { it.nickname }) }, + { assertEquals(4, stored.size) } + ) + } + + @Test + @DisplayName("최근 종료 라이브의 현재 이름은 같은 회원의 KO 조회 캐시 이후에도 JA 요청을 따른다") + fun shouldRenderJapaneseFinishedLiveNameAfterKoreanCacheRead() { + val fixture = createFixture() + val room = roomRepository.findById(fixture.roomId).orElseThrow() + room.isActive = false + fixture.host.profileImage = "profile/host.png" + roomRepository.flush() + val cacheKey = "getLatestFinishedLive:${fixture.viewer.id}" + finishedLiveCacheKeys.add(cacheKey) + val cache = requireNotNull(cacheManager.getCache("cache_ttl_10_minutes")) + cache.evict(cacheKey) + val statistics = entityManagerFactory.unwrap(SessionFactory::class.java).statistics + statistics.clear() + langContext.setLang(Lang.KO) + val korean = service.getLatestFinishedLive(fixture.viewer).single { it.memberId == fixture.host.id } + assertEquals(1L, currentNameQueryCount()) + val roomListQuery = statistics.queries.single { it.contains("from LiveRoom") } + val roomListExecutions = statistics.getQueryStatistics(roomListQuery).executionCount + val cached = cache.get(cacheKey)?.get() + assertNotNull(cached) + assertEquals(1L, roomListExecutions) + assertEquals("host", (cached as List<*>).filterIsInstance().single().nickname) + langContext.setLang(Lang.JA) + val nameQueryCountBeforeHit = currentNameQueryCount() + val japanese = service.getLatestFinishedLive(fixture.viewer).single { it.memberId == fixture.host.id } + assertEquals(1L, currentNameQueryCount() - nameQueryCountBeforeHit) + assertEquals(roomListExecutions, statistics.getQueryStatistics(roomListQuery).executionCount) + assertEquals(cached, cache.get(cacheKey)?.get()) + fixture.host.nickname = "updated-host" + memberRepository.flush() + jdbcTemplate.update("UPDATE creator_localized_name SET is_active = false WHERE member_id = ?", fixture.host.id!!) + val nameQueryCountBeforeFallback = currentNameQueryCount() + val fallback = service.getLatestFinishedLive(fixture.viewer).single { it.memberId == fixture.host.id } + assertEquals(1L, currentNameQueryCount() - nameQueryCountBeforeFallback) + + assertEquals("host", korean.nickname) + assertEquals("日本-host", japanese.nickname) + assertEquals("updated-host", fallback.nickname) + assertEquals("host", korean.nickname) + assertEquals(roomListExecutions, statistics.getQueryStatistics(roomListQuery).executionCount) + assertEquals(cached, cache.get(cacheKey)?.get()) + jdbcTemplate.update( + "INSERT INTO creator_localized_name (member_id, lang, display_name, is_active, created_at, updated_at) " + + "VALUES (?, 'EN', 'English host', true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", + fixture.host.id!! + ) + langContext.setLang(Lang.EN) + val nameQueryCountBeforeEnglishHit = currentNameQueryCount() + val english = service.getLatestFinishedLive(fixture.viewer).single { it.memberId == fixture.host.id } + assertEquals("English host", english.nickname) + assertEquals(1L, currentNameQueryCount() - nameQueryCountBeforeEnglishHit) + assertEquals(roomListExecutions, statistics.getQueryStatistics(roomListQuery).executionCount) + assertEquals(cached, cache.get(cacheKey)?.get()) + } + + @Test + @DisplayName("언어 중립 live feed와 종료 캐시는 별칭 해제 후 변경된 현재 원본으로 fallback한다") + fun shouldFallBackToCurrentOriginalWithoutRewritingNeutralRedisNames() { + val fixture = createFixture() + val info = roomInfoRepository.findById(fixture.roomId).orElseThrow() + info.addDonationMessage(fixture.host.id!!, "host", false, 10, "host 본문") + roomInfoRepository.save(info) + fixture.host.nickname = "updated-host" + memberRepository.flush() + jdbcTemplate.update("UPDATE creator_localized_name SET is_active = false WHERE member_id = ?", fixture.host.id!!) + langContext.setLang(Lang.JA) + + val response = service.getRoomInfo(fixture.roomId, fixture.viewer) + val messages = service.getDonationMessageList(fixture.roomId, fixture.viewer) + val stored = roomInfoRepository.findById(fixture.roomId).orElseThrow() + + assertEquals("updated-host", response.creatorNickname) + assertEquals("updated-host", response.speakerList.single().nickname) + assertEquals("updated-host", messages.single().nickname) + assertEquals("host", stored.speakerList.single().nickname) + assertEquals("host", stored.donationMessageList.single().nickname) + assertEquals("host 본문", messages.single().donationMessage) + } + + @Test + @DisplayName("예약 생성·목록·상세와 최근 방문자 조회는 실제 크리에이터 소유 ID로 JA 이름을 표시한다") + fun shouldLocalizeReservationAndRecentVisitorNames() { + val fixture = createFixture() + langContext.setLang(Lang.JA) + val reservation = reservationService.makeReservation( + MakeLiveReservationRequest(fixture.roomId, "web", "UTC"), + fixture.viewer.id!! + ) + val statistics = entityManagerFactory.unwrap(SessionFactory::class.java).statistics + statistics.clear() + val listed = reservationService.getReservationList(fixture.viewer.id!!, true, "UTC").single() + val detail = reservationService.getReservation(reservation.reservationId, fixture.viewer.id!!, "UTC") + assertEquals(2L, statistics.queryExecutionCount, statistics.queries.joinToString("\n")) + val room = roomRepository.findById(fixture.roomId).orElseThrow() + visitService.roomVisit(room, fixture.viewer) + visitService.roomVisit(room, fixture.manager) + statistics.clear() + val visitor = visitService.getRecentVisitRoomUsers(fixture.viewer).single() + assertEquals(2L, statistics.queryExecutionCount, statistics.queries.joinToString("\n")) + + assertEquals("日本-host", reservation.nickname) + assertEquals("日本-host", listed.masterNickname) + assertEquals("日本-host", detail.masterNickname) + assertEquals(fixture.manager.id, visitor.id) + assertEquals("日本-manager", visitor.nickname) + langContext.setLang(Lang.KO) + assertEquals("manager", visitService.getRecentVisitRoomUsers(fixture.viewer).single().nickname) + } + + @Test + @DisplayName("추천과 팔로잉의 on-air 및 off-air 분기는 요청별 이름을 표시하고 count와 순서를 보존한다") + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @DirtiesContext(methodMode = DirtiesContext.MethodMode.AFTER_METHOD) + fun shouldLocalizeRecommendedAndFollowingChannels() { + val fixture = createFixture() + followingRepository.saveAndFlush( + CreatorFollowing().apply { + member = fixture.viewer + creator = fixture.host + } + ) + followingRepository.saveAndFlush( + CreatorFollowing().apply { + member = fixture.viewer + creator = fixture.manager + } + ) + langContext.setLang(Lang.JA) + + val statistics = entityManagerFactory.unwrap(SessionFactory::class.java).statistics + statistics.clear() + val recommended = recommendService.getRecommendChannelList(null).associateBy { it.creatorId } + val aliasQueries = statistics.queries.filter { + Regex("""(?is)^select\s+\w+\.memberId,\s*\w+\.displayName\s+from\s+CreatorLocalizedNameEntity\b""") + .containsMatchIn(it) + } + val aliasQueryCount = aliasQueries.sumOf { statistics.getQueryStatistics(it).executionCount } + val queryCount = statistics.queryExecutionCount + val following = recommendService.getFollowingChannelList(fixture.viewer).associateBy { it.creatorId } + val all = recommendService.getFollowingAllChannelList(fixture.viewer, PageRequest.of(0, 20)) + + assertEquals("日本-host", recommended.getValue(fixture.host.id!!).nickname) + assertEquals("日本-manager", recommended.getValue(fixture.manager.id!!).nickname) + assertEquals("fallback", recommended.getValue(fixture.fallback.id!!).nickname) + assertEquals("日本-host", following.getValue(fixture.host.id!!).nickname) + assertEquals("日本-manager", following.getValue(fixture.manager.id!!).nickname) + assertEquals(2, all.totalCount) + assertEquals(setOf("日本-host", "日本-manager"), all.items.map { it.nickname }.toSet()) + langContext.setLang(Lang.KO) + assertEquals( + setOf("host", "manager"), + recommendService.getFollowingAllChannelList(fixture.viewer, PageRequest.of(0, 20)).items.map { it.nickname }.toSet() + ) + assertEquals(0L, aliasQueryCount, "Standalone alias SELECTs (total queries=$queryCount): $aliasQueries") + assertNoStandaloneNameQueries() + jdbcTemplate.update( + "INSERT INTO creator_localized_name (member_id, lang, display_name, is_active, created_at, updated_at) " + + "VALUES (?, 'EN', 'English host', true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", + fixture.host.id!! + ) + langContext.setLang(Lang.EN) + assertEquals( + setOf("English host", "manager"), + recommendService.getFollowingAllChannelList(fixture.viewer, PageRequest.of(0, 20)).items.map { it.nickname }.toSet() + ) + assertNoStandaloneNameQueries() + } + + @Test + @DisplayName("후원과 하트 집계는 donor ID로 이름만 바꾸고 USER·금액·비밀 합계와 공개 DTO 필드를 보존한다") + fun shouldLocalizeDonationAndHeartDonorNamesWithoutChangingAmounts() { + val fixture = createFixture() + val room = roomRepository.findById(fixture.roomId).orElseThrow() + listOf(CanUsage.DONATION, CanUsage.HEART).forEach { usage -> + useCanRepository.saveAndFlush( + UseCan(usage, 10, 2).apply { + member = fixture.manager + this.room = room + } + ) + useCanRepository.saveAndFlush( + UseCan(usage, 5, 1).apply { + member = fixture.viewer + this.room = room + } + ) + } + langContext.setLang(Lang.JA) + + val statistics = entityManagerFactory.unwrap(SessionFactory::class.java).statistics + statistics.clear() + val donations = service.getDonationStatus(fixture.roomId, fixture.host.id!!) + val hearts = service.getHeartList(fixture.roomId) + assertEquals(3L, statistics.queryExecutionCount, statistics.queries.joinToString("\n")) + + assertEquals(listOf("日本-manager", "viewer"), donations.donationList.map { it.nickname }) + assertEquals(listOf("日本-manager", "viewer"), hearts.heartList.map { it.nickname }) + assertEquals(18, donations.totalCan) + assertEquals(0, donations.totalSecretCan) + assertEquals(18, hearts.totalHeart) + assertEquals(2, hearts.totalCount) + } + + @Test + @DisplayName("진행·날짜별 예약·예정 라이브 목록은 EN 이름을 조인하고 USER·미등록·비활성 소유자를 유지한다") + fun shouldJoinEnglishNamesInEveryDatabaseRoomListWithoutMutatingOwners() { + val fixture = createFixture() + val owners = listOf(fixture.host, fixture.manager, fixture.viewer, fixture.fallback) + listOf(fixture.host, fixture.viewer, fixture.fallback).forEach { + jdbcTemplate.update( + "INSERT INTO creator_localized_name (member_id, lang, display_name, is_active, created_at, updated_at) " + + "VALUES (?, 'EN', ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", + it.id!!, + "EN-${it.nickname}", + it != fixture.fallback + ) + } + val rooms = listOf(roomRepository.findById(fixture.roomId).orElseThrow()) + owners.drop(1).map { owner -> + roomRepository.saveAndFlush( + LiveRoom("${owner.nickname} room", "notice", LocalDateTime.now(), 10, "cover.png", isAdult = false).apply { + member = owner + channelName = "live_${owner.id}" + } + ) + } + val expected = mapOf( + fixture.host.id to "EN-host", + fixture.manager.id to "manager", + fixture.viewer.id to "viewer", + fixture.fallback.id to "fallback" + ) + val statistics = entityManagerFactory.unwrap(SessionFactory::class.java).statistics + statistics.clear() + + val current = roomRepository.getLiveRoomListNow(0, 20, "UTC", null, false, true, null, Lang.EN) + assertEquals(expected, current.associate { it.room.member!!.id to it.creatorNickname }) + assertEquals(1L, statistics.queryExecutionCount) + + owners.forEach { owner -> + reservationRepository.saveAndFlush( + LiveReservation().apply { + member = owner + room = rooms.first() + } + ) + } + statistics.clear() + val bookers = reservationRepository.getReservationList(fixture.roomId, Lang.EN) + assertEquals(expected, bookers.associate { it.id to it.nickname }) + assertEquals(1L, statistics.queryExecutionCount) + statistics.clear() + val reservation = reservationRepository.getReservationListByMemberId(fixture.viewer.id!!, true, Lang.EN).single() + val reserved = reservationRepository.getReservationByReservationAndMemberId(reservation.id, fixture.viewer.id!!, Lang.EN) + assertEquals("EN-host", reservation.masterNickname) + assertEquals("EN-host", reserved!!.masterNickname) + assertEquals(2L, statistics.queryExecutionCount) + + val date = LocalDateTime.now().plusDays(1).withHour(12).withMinute(0) + rooms.forEach { + it.channelName = null + it.beginDateTime = date + } + roomRepository.flush() + statistics.clear() + val dated = roomRepository.getLiveRoomListReservationWithDate( + date.toLocalDate().atStartOfDay(), + 0, + 20, + null, + false, + true, + null, + Lang.EN + ) + val upcoming = roomRepository.getLiveRoomListReservationWithoutDate("UTC", null, false, true, null, Lang.EN) + + assertEquals(expected, dated.associate { it.room.member!!.id to it.creatorNickname }) + assertEquals(expected, upcoming.associate { it.room.member!!.id to it.creatorNickname }) + assertEquals(2L, statistics.queryExecutionCount) + assertEquals(listOf("host", "manager", "viewer", "fallback"), owners.map { it.nickname }) + assertNoStandaloneNameQueries() + } + + private fun assertNoStandaloneNameQueries() { + val statistics = entityManagerFactory.unwrap(SessionFactory::class.java).statistics + val nameQueries = statistics.queries.filter { + Regex("""(?is)\bfrom\s+CreatorLocalizedNameEntity\b""").containsMatchIn(it) || + Regex("""(?is)^select\s+\w+\.id,\s*coalesce\([^)]*\)\s+from\s+Member\b""").containsMatchIn(it) + } + assertEquals(0L, nameQueries.sumOf { statistics.getQueryStatistics(it).executionCount }, nameQueries.joinToString("\n")) + } + + private fun currentNameQueryCount(): Long { + val statistics = entityManagerFactory.unwrap(SessionFactory::class.java).statistics + return statistics.queries.filter { it.contains("from Member") && it.contains("CreatorLocalizedNameEntity") } + .sumOf { statistics.getQueryStatistics(it).executionCount } + } + + private fun createFixture(): Fixture { + val host = savedMember("host", MemberRole.CREATOR) + val manager = savedMember("manager", MemberRole.CREATOR) + val viewer = savedMember("viewer", MemberRole.USER) + val fallback = savedMember("fallback", MemberRole.CREATOR, activeAlias = false) + val room = roomRepository.saveAndFlush( + LiveRoom( + title = "room title", + notice = "notice", + beginDateTime = LocalDateTime.now().minusMinutes(1), + numberOfPeople = 10, + coverImage = "cover.png", + isAdult = false + ).apply { + member = host + channelName = "localized_live_${host.id}" + } + ) + roomIds.add(room.id!!) + roomInfoRepository.save( + LiveRoomInfo(room.id!!).apply { + addSpeaker(host, "https://cdn.test") + addManager(manager, "https://cdn.test") + addListener(viewer, "https://cdn.test") + addListener(fallback, "https://cdn.test") + } + ) + return Fixture(room.id!!, host, manager, viewer, fallback) + } + + private fun savedMember(nickname: String, role: MemberRole, activeAlias: Boolean = true): Member { + val member = memberRepository.saveAndFlush( + Member( + email = "$nickname@localized-live.test", + password = "pw", + nickname = nickname, + profileImage = "profile/$nickname.png", + role = role, + isVisibleDonationRank = false + ) + ) + jdbcTemplate.update( + "INSERT INTO creator_localized_name (member_id, lang, display_name, is_active, created_at, updated_at) " + + "VALUES (?, 'JA', ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", + member.id!!, + "日本-$nickname", + activeAlias + ) + return member + } + + private data class Fixture(val roomId: Long, val host: Member, val manager: Member, val viewer: Member, val fallback: Member) +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomServiceAdultVisibilityPolicyTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomServiceAdultVisibilityPolicyTest.kt index 5abbbeb2..0d94acdd 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomServiceAdultVisibilityPolicyTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomServiceAdultVisibilityPolicyTest.kt @@ -12,6 +12,7 @@ import kr.co.vividnext.sodalive.content.ContentType import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository import kr.co.vividnext.sodalive.explorer.profile.CreatorDonationRankingService import kr.co.vividnext.sodalive.fcm.PushTokenRepository +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.live.reservation.LiveReservationRepository @@ -30,6 +31,7 @@ import kr.co.vividnext.sodalive.member.MemberRepository import kr.co.vividnext.sodalive.member.block.BlockMemberRepository import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService import kr.co.vividnext.sodalive.member.contentpreference.ViewerContentPreference +import kr.co.vividnext.sodalive.v2.creatorname.application.CreatorLocalizedNameService import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName @@ -106,6 +108,7 @@ class LiveRoomServiceAdultVisibilityPolicyTest { menuService = menuService, messageSource = messageSource, langContext = langContext, + localizedNameService = mock(), repository = repository, rouletteRepository = rouletteRepository, roomInfoRepository = roomInfoRepository, @@ -152,7 +155,8 @@ class LiveRoomServiceAdultVisibilityPolicyTest { memberId = 100L, isCreator = false, isAdult = true, - effectiveGender = Gender.NONE + effectiveGender = Gender.NONE, + lang = Lang.KO ) ).thenReturn(emptyList()) @@ -172,7 +176,8 @@ class LiveRoomServiceAdultVisibilityPolicyTest { memberId = 100L, isCreator = false, isAdult = true, - effectiveGender = Gender.NONE + effectiveGender = Gender.NONE, + lang = Lang.KO ) } @@ -187,7 +192,8 @@ class LiveRoomServiceAdultVisibilityPolicyTest { memberId = null, isCreator = false, isAdult = true, - effectiveGender = null + effectiveGender = null, + lang = Lang.KO ) ).thenReturn(emptyList()) @@ -207,7 +213,8 @@ class LiveRoomServiceAdultVisibilityPolicyTest { memberId = null, isCreator = false, isAdult = true, - effectiveGender = null + effectiveGender = null, + lang = Lang.KO ) } @@ -222,7 +229,8 @@ class LiveRoomServiceAdultVisibilityPolicyTest { memberId = 200L, isCreator = false, isAdult = false, - effectiveGender = Gender.NONE + effectiveGender = Gender.NONE, + lang = Lang.KO ) ).thenReturn(emptyList()) @@ -240,7 +248,8 @@ class LiveRoomServiceAdultVisibilityPolicyTest { memberId = 200L, isCreator = false, isAdult = false, - effectiveGender = Gender.NONE + effectiveGender = Gender.NONE, + lang = Lang.KO ) } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomServiceTest.kt index 3ff85a8f..8e3dac67 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomServiceTest.kt @@ -31,6 +31,7 @@ import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.auth.Auth import kr.co.vividnext.sodalive.member.block.BlockMemberRepository import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService +import kr.co.vividnext.sodalive.v2.creatorname.application.CreatorLocalizedNameService import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName @@ -70,6 +71,7 @@ class LiveRoomServiceTest { menuService = mock(), messageSource = mock(), langContext = LangContext(), + localizedNameService = mock(), repository = repository, rouletteRepository = rouletteRepository, roomInfoRepository = roomInfoRepository, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/member/MemberRepositoryLocalizedNameTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/member/MemberRepositoryLocalizedNameTest.kt new file mode 100644 index 00000000..f1078873 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/member/MemberRepositoryLocalizedNameTest.kt @@ -0,0 +1,108 @@ +package kr.co.vividnext.sodalive.member + +import kr.co.vividnext.sodalive.configs.QueryDslConfig +import org.junit.jupiter.api.Assertions.assertAll +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.context.annotation.Import +import org.springframework.jdbc.core.JdbcTemplate +import javax.persistence.EntityManager + +@DataJpaTest(properties = ["spring.cache.type=none"]) +@Import(QueryDslConfig::class) +class MemberRepositoryLocalizedNameTest @Autowired constructor( + private val repository: MemberRepository, + private val entityManager: EntityManager, + private val jdbcTemplate: JdbcTemplate +) { + @Test + @DisplayName("크리에이터 요청의 회원 검색은 USER 원본 매칭과 CREATOR 별칭을 함께 유지한다") + fun shouldRetainUserOriginalMatchesAndAddOnlyCreatorAliasesForCreatorRequester() { + val requester = saveMember("needle-requester", MemberRole.CREATOR) + val creator = saveMember("creator-original", MemberRole.CREATOR) + val user = saveMember("needle-user", MemberRole.USER) + val aiUser = saveMember("ai-user-original", MemberRole.USER).apply { memberKind = MemberKind.AI_CHARACTER } + val admin = saveMember("needle-admin", MemberRole.ADMIN) + val agent = saveMember("needle-agent", MemberRole.AGENT) + val bot = saveMember("needle-bot", MemberRole.BOT) + listOf(requester, creator, user, aiUser, admin, agent, bot).forEach { saveAlias(it, "needle-alias") } + entityManager.clear() + + val matches = repository.findByNicknameAndOtherCondition("NeEdLe", requester) + + assertEquals(setOf(creator.id, user.id), matches.map { it.id }.toSet()) + assertEquals(2, matches.size) + assertEquals(listOf(creator.id), repository.findByNicknameAndOtherCondition("creator-original", requester).map { it.id }) + } + + @Test + @DisplayName("USER 요청의 회원 검색은 기존 USER·self·관리 역할 제외 조건을 별칭에도 적용한다") + fun shouldPreserveUserRequesterRoleAndSelfExclusionsForAliases() { + val requester = saveMember("needle-viewer", MemberRole.USER) + val creator = saveMember("creator-original", MemberRole.CREATOR) + val user = saveMember("needle-user", MemberRole.USER) + val admin = saveMember("needle-admin", MemberRole.ADMIN) + val agent = saveMember("needle-agent", MemberRole.AGENT) + val bot = saveMember("needle-bot", MemberRole.BOT) + listOf(requester, creator, user, admin, agent, bot).forEach { saveAlias(it, "needle-alias") } + entityManager.clear() + + assertEquals(listOf(creator.id), repository.findByNicknameAndOtherCondition("needle", requester).map { it.id }) + } + + @Test + @DisplayName("비활성 JA와 다른 언어 이름은 회원 별칭 검색에서 제외하고 현재 원본은 유지한다") + fun shouldExcludeInactiveAndNonJaAliasesWhileRetainingCurrentOriginalNickname() { + val requester = saveMember("member-clear-viewer", MemberRole.CREATOR) + val creator = saveMember("before-rename", MemberRole.CREATOR) + saveAlias(creator, "RetainedAlias", isActive = false) + saveAlias(creator, "EnglishAlias", lang = "EN") + creator.nickname = "after-rename" + entityManager.flush() + entityManager.clear() + + assertEquals(emptyList(), repository.findByNicknameAndOtherCondition("RetainedAlias", requester).map { it.id }) + assertEquals(emptyList(), repository.findByNicknameAndOtherCondition("EnglishAlias", requester).map { it.id }) + assertEquals(listOf(creator.id), repository.findByNicknameAndOtherCondition("after-rename", requester).map { it.id }) + jdbcTemplate.update( + "update creator_localized_name set is_active = true where member_id = ? and lang = 'JA'", + creator.id!! + ) + + assertAll( + { + assertEquals( + listOf(creator.id), + repository.findByNicknameAndOtherCondition("RetainedAlias", requester).map { it.id } + ) + }, + { + assertEquals( + listOf(creator.id), + repository.findByNicknameAndOtherCondition("after-rename", requester).map { it.id } + ) + } + ) + } + + private fun saveMember(nickname: String, role: MemberRole): Member { + return Member(email = "$nickname@test.com", password = "password", nickname = nickname, role = role).also { + entityManager.persist(it) + } + } + + private fun saveAlias(member: Member, displayName: String, isActive: Boolean = true, lang: String = "JA") { + entityManager.flush() + jdbcTemplate.update( + "insert into creator_localized_name (member_id, lang, display_name, is_active, created_at, updated_at) " + + "values (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", + member.id!!, + lang, + displayName, + isActive + ) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/member/MemberServiceCacheEvictionTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/member/MemberServiceCacheEvictionTest.kt index 556af54e..88fbc6f6 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/member/MemberServiceCacheEvictionTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/member/MemberServiceCacheEvictionTest.kt @@ -10,6 +10,7 @@ import kr.co.vividnext.sodalive.member.block.BlockMember import kr.co.vividnext.sodalive.member.block.BlockMemberRepository import kr.co.vividnext.sodalive.member.block.MemberBlockRequest import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService +import kr.co.vividnext.sodalive.support.originalLocalizedNameService import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -62,6 +63,7 @@ class MemberServiceCacheEvictionTest { authenticationManagerBuilder = mock(), messageSource = SodaMessageSource(), langContext = LangContext(), + localizedNameService = originalLocalizedNameService(), countryContext = CountryContext(), memberContentPreferenceService = mock(), objectMapper = ObjectMapper(), diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/member/MemberServiceContentPreferenceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/member/MemberServiceContentPreferenceTest.kt index fe7d6b2d..794b6882 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/member/MemberServiceContentPreferenceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/member/MemberServiceContentPreferenceTest.kt @@ -13,6 +13,7 @@ import kr.co.vividnext.sodalive.member.stipulation.Stipulation import kr.co.vividnext.sodalive.member.stipulation.StipulationAgreeRepository import kr.co.vividnext.sodalive.member.stipulation.StipulationIds import kr.co.vividnext.sodalive.member.stipulation.StipulationRepository +import kr.co.vividnext.sodalive.support.originalLocalizedNameService import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach @@ -71,6 +72,7 @@ class MemberServiceContentPreferenceTest { authenticationManagerBuilder = mock(), messageSource = SodaMessageSource(), langContext = LangContext(), + localizedNameService = originalLocalizedNameService(), countryContext = CountryContext(), memberContentPreferenceService = memberContentPreferenceService, objectMapper = ObjectMapper(), diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/osiv/OsivLazyLoadingRegressionTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/osiv/OsivLazyLoadingRegressionTest.kt index 76f43e5e..6d1ced60 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/osiv/OsivLazyLoadingRegressionTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/osiv/OsivLazyLoadingRegressionTest.kt @@ -250,7 +250,7 @@ class OsivLazyLoadingRegressionTest @Autowired constructor( val creators = rankingRepository.getCreatorRankings(memberId = null) - val response = creators.single().toExplorerSectionCreator("https://cdn.test") + val response = creators.single() assertEquals("#creator-tag", response.tags) } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/search/SearchRepositoryLocalizedNameTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/search/SearchRepositoryLocalizedNameTest.kt new file mode 100644 index 00000000..d392b657 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/search/SearchRepositoryLocalizedNameTest.kt @@ -0,0 +1,297 @@ +package kr.co.vividnext.sodalive.search + +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre +import kr.co.vividnext.sodalive.configs.QueryDslConfig +import kr.co.vividnext.sodalive.content.AudioContent +import kr.co.vividnext.sodalive.content.ContentType +import kr.co.vividnext.sodalive.content.theme.AudioContentTheme +import kr.co.vividnext.sodalive.creator.admin.content.series.Series +import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent +import kr.co.vividnext.sodalive.i18n.Lang +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.member.block.BlockMember +import kr.co.vividnext.sodalive.member.tag.CreatorTag +import kr.co.vividnext.sodalive.member.tag.MemberCreatorTag +import org.hibernate.SessionFactory +import org.junit.jupiter.api.Assertions.assertAll +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.context.annotation.Import +import org.springframework.jdbc.core.JdbcTemplate +import java.time.LocalDateTime +import javax.persistence.EntityManager + +@DataJpaTest(properties = ["spring.cache.type=none", "spring.jpa.properties.hibernate.generate_statistics=true"]) +@Import(QueryDslConfig::class) +class SearchRepositoryLocalizedNameTest @Autowired constructor( + private val entityManager: EntityManager, + private val jdbcTemplate: JdbcTemplate, + queryFactory: JPAQueryFactory +) { + private val repository = SearchRepository(queryFactory, "https://cdn.test") + + @Test + @DisplayName("크리에이터 검색은 원본과 활성 JA 별칭을 함께 찾고 중복 별칭도 회원별로 한 번씩 센다") + fun shouldSearchOriginalOrActiveJaAliasWithDistinctOwnersAndMatchingCount() { + val viewer = saveMember("search-viewer", MemberRole.USER) + val original = saveMember("Orion-original") + val aliasOnly = saveMember("second-original") + saveAlias(original, "Orion Japanese") + saveAlias(aliasOnly, "Orion Japanese") + entityManager.clear() + + val items = repository.searchCreatorList("oRiOn", viewer.id!!, 0, 20) + val page = repository.searchCreatorList("oRiOn", viewer.id!!, 1, 1) + + assertAll( + { assertEquals(2, repository.searchCreatorTotalCount("oRiOn", viewer.id!!)) }, + { assertEquals(listOf(aliasOnly.id, original.id), items.map { it.id }) }, + { assertEquals(listOf(original.id), page.map { it.id }) }, + { + assertEquals( + listOf(original.id), + repository.searchCreatorList("Orion-original", viewer.id!!, 0, 20).map { it.id } + ) + } + ) + } + + @Test + @DisplayName("별칭 검색은 회원 활성·역할·태그 활성·양방향 차단 필터를 우회하지 않는다") + fun shouldPreserveCreatorVisibilityTagAndBidirectionalBlockFiltersForAliasMatches() { + val viewer = saveMember("filter-viewer", MemberRole.USER) + val visible = saveMember("visible-original") + val inactive = saveMember("inactive-original").apply { isActive = false } + val user = saveMember("user-original", MemberRole.USER) + val blockedByViewer = saveMember("blocked-original") + val blockingViewer = saveMember("blocking-original") + val inactiveTagOwner = saveMember("inactive-tag-original") + saveBlock(viewer, blockedByViewer) + saveBlock(blockingViewer, viewer) + val inactiveTag = CreatorTag(tag = "inactive-tag", isActive = false) + entityManager.persist(inactiveTag) + entityManager.persist(MemberCreatorTag(member = inactiveTagOwner, tag = inactiveTag)) + listOf(visible, inactive, user, blockedByViewer, blockingViewer, inactiveTagOwner).forEach { + saveAlias(it, "AliasVisibility") + } + entityManager.clear() + + assertAll( + { assertEquals(1, repository.searchCreatorTotalCount("AliasVisibility", viewer.id!!)) }, + { + assertEquals( + listOf(visible.id), + repository.searchCreatorList("AliasVisibility", viewer.id!!, 0, 20).map { it.id } + ) + } + ) + } + + @Test + @DisplayName("비활성 JA와 다른 언어 별칭은 제외하고 원본 검색과 같은 행 재활성화는 유지한다") + fun shouldExcludeSoftClearedAndNonJaAliasesWithoutLosingOriginalSearch() { + val viewer = saveMember("clear-viewer", MemberRole.USER) + val creator = saveMember("current-original") + val content = saveContent(creator, "clear content title") + val series = saveSeries(creator, "clear series title", content) + saveAlias(creator, "ClearedAlias", isActive = false) + saveAlias(creator, "EnglishAlias", lang = "EN") + entityManager.clear() + + assertEquals(0, repository.searchCreatorTotalCount("ClearedAlias", viewer.id!!)) + assertEquals(emptyList(), repository.searchCreatorList("ClearedAlias", viewer.id!!, 0, 20).map { it.id }) + assertEquals(0, repository.searchCreatorTotalCount("EnglishAlias", viewer.id!!)) + assertEquals(1, repository.searchCreatorTotalCount("current-original", viewer.id!!)) + assertEquals(0, repository.searchContentTotalCount("ClearedAlias", viewer.id!!, false, ContentType.ALL)) + assertEquals(0, repository.searchSeriesTotalCount("ClearedAlias", viewer.id!!, false, ContentType.ALL)) + assertEquals(1, repository.searchContentTotalCount("current-original", viewer.id!!, false, ContentType.ALL)) + assertEquals(1, repository.searchSeriesTotalCount("current-original", viewer.id!!, false, ContentType.ALL)) + + repeat(2) { + jdbcTemplate.update( + "update creator_localized_name set is_active = true where member_id = ? and lang = 'JA'", + creator.id!! + ) + assertAll( + { assertEquals(1, repository.searchCreatorTotalCount("ClearedAlias", viewer.id!!)) }, + { assertEquals(1, repository.searchContentTotalCount("ClearedAlias", viewer.id!!, false, ContentType.ALL)) }, + { assertEquals(1, repository.searchSeriesTotalCount("ClearedAlias", viewer.id!!, false, ContentType.ALL)) }, + { + assertEquals( + listOf(content.id), + repository.searchContentList("ClearedAlias", viewer.id!!, false, ContentType.ALL, 0, 20).map { it.id } + ) + }, + { + assertEquals( + listOf(series.id), + repository.searchSeriesList("ClearedAlias", viewer.id!!, false, ContentType.ALL, 0, 20).map { it.id } + ) + }, + { + assertEquals( + listOf(creator.id), + repository.searchCreatorList("ClearedAlias", viewer.id!!, 0, 20).map { it.id } + ) + } + ) + jdbcTemplate.update( + "update creator_localized_name set is_active = false where member_id = ? and lang = 'JA'", + creator.id!! + ) + assertEquals(0, repository.searchCreatorTotalCount("ClearedAlias", viewer.id!!)) + assertEquals(0, repository.searchContentTotalCount("ClearedAlias", viewer.id!!, false, ContentType.ALL)) + assertEquals(0, repository.searchSeriesTotalCount("ClearedAlias", viewer.id!!, false, ContentType.ALL)) + } + } + + @Test + @DisplayName("콘텐츠 검색은 소유자 별칭을 찾되 성인·활성·완성·한정·차단 필터와 콘텐츠 제목을 보존한다") + fun shouldSearchContentOwnerAliasWithMatchingCountAndExistingContentFilters() { + val viewer = saveMember("content-search-viewer", MemberRole.USER) + val creator = saveMember("audio-owner-original") + val visible = saveContent(creator, "Unchanged audio title") + saveContent(creator, "adult audio").isAdult = true + saveContent(creator, "inactive audio").isActive = false + saveContent(creator, "unfinished audio").duration = null + saveContent(creator, "limited audio", limited = 5) + val blocked = saveMember("blocked-audio-owner") + saveContent(blocked, "blocked audio") + saveBlock(viewer, blocked) + saveAlias(creator, "AudioAlias") + saveAlias(creator, "English Audio Owner", lang = "EN") + saveAlias(blocked, "AudioAlias") + entityManager.clear() + + val statistics = entityManager.entityManagerFactory.unwrap(SessionFactory::class.java).statistics + statistics.clear() + val items = repository.searchContentList("AudioAlias", viewer.id!!, false, ContentType.ALL, 0, 20, Lang.JA) + val aliasQueries = statistics.queries.filter { + Regex("""(?is)^select\s+\w+\.memberId,\s*\w+\.displayName\s+from\s+CreatorLocalizedNameEntity\b""") + .containsMatchIn(it) + } + val aliasQueryCount = aliasQueries.sumOf { statistics.getQueryStatistics(it).executionCount } + val queryCount = statistics.queryExecutionCount + + assertAll( + { assertEquals(1, repository.searchContentTotalCount("AudioAlias", viewer.id!!, false, ContentType.ALL)) }, + { assertEquals(listOf(visible.id), items.map { it.id }) }, + { assertEquals(listOf("Unchanged audio title"), items.map { it.title }) }, + { assertEquals(listOf("AudioAlias"), items.map { it.nickname }) }, + { assertEquals(1, repository.searchContentTotalCount("audio-owner-original", viewer.id!!, false, ContentType.ALL)) }, + { assertEquals(1, repository.searchContentTotalCount("Unchanged audio title", viewer.id!!, false, ContentType.ALL)) } + ) + assertEquals(0L, aliasQueryCount, "Standalone alias SELECTs (total queries=$queryCount): $aliasQueries") + val englishItems = repository.searchContentList("AudioAlias", viewer.id!!, false, ContentType.ALL, 0, 20, Lang.EN) + assertEquals(listOf("English Audio Owner"), englishItems.map { it.nickname }) + assertEquals(0, repository.searchContentTotalCount("English Audio Owner", viewer.id!!, false, ContentType.ALL)) + } + + @Test + @DisplayName("시리즈 검색은 시리즈 소유자 별칭을 찾고 콘텐츠 조인 중복과 기존 공개 필터를 보존한다") + fun shouldSearchSeriesOwnerAliasWithoutDuplicatingJoinedContentsOrChangingTitles() { + val viewer = saveMember("series-search-viewer", MemberRole.USER) + val creator = saveMember("series-owner-original") + val audioOwner = saveMember("different-audio-owner") + val visible = saveSeries(creator, "Unchanged series title", saveContent(audioOwner, "first episode")) + saveSeriesContent(visible, saveContent(audioOwner, "second episode")) + saveSeries(creator, "adult series", saveContent(creator, "public episode")).isAdult = true + saveSeries(creator, "adult episode series", saveContent(creator, "adult episode").apply { isAdult = true }) + saveSeries(creator, "inactive series", saveContent(creator, "active episode")).isActive = false + saveSeries(creator, "unfinished series", saveContent(creator, "unfinished episode").apply { duration = null }) + val blocked = saveMember("blocked-series-owner") + saveSeries(blocked, "blocked series", saveContent(blocked, "blocked episode")) + saveBlock(blocked, viewer) + saveAlias(creator, "SeriesAlias") + saveAlias(audioOwner, "EpisodeOwnerAlias") + saveAlias(blocked, "SeriesAlias") + entityManager.clear() + + val items = repository.searchSeriesList("SeriesAlias", viewer.id!!, false, ContentType.ALL, 0, 20) + + assertAll( + { assertEquals(1, repository.searchSeriesTotalCount("SeriesAlias", viewer.id!!, false, ContentType.ALL)) }, + { assertEquals(listOf(visible.id), items.map { it.id }) }, + { assertEquals(listOf("Unchanged series title"), items.map { it.title }) }, + { assertEquals(0, repository.searchSeriesTotalCount("EpisodeOwnerAlias", viewer.id!!, false, ContentType.ALL)) }, + { assertEquals(1, repository.searchSeriesTotalCount("series-owner-original", viewer.id!!, false, ContentType.ALL)) }, + { assertEquals(1, repository.searchSeriesTotalCount("Unchanged series title", viewer.id!!, false, ContentType.ALL)) } + ) + } + + private fun saveMember(nickname: String, role: MemberRole = MemberRole.CREATOR): Member { + return Member( + email = "$nickname@test.com", + password = "password", + nickname = nickname, + profileImage = "$nickname.png", + role = role + ).also { entityManager.persist(it) } + } + + private fun saveAlias(member: Member, displayName: String, isActive: Boolean = true, lang: String = "JA") { + entityManager.flush() + jdbcTemplate.update( + "insert into creator_localized_name (member_id, lang, display_name, is_active, created_at, updated_at) " + + "values (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", + member.id!!, + lang, + displayName, + isActive + ) + } + + private fun saveBlock(member: Member, blockedMember: Member) { + entityManager.persist( + BlockMember(isActive = true).apply { + this.member = member + this.blockedMember = blockedMember + } + ) + } + + private fun saveContent(creator: Member, title: String, limited: Int? = null): AudioContent { + val theme = AudioContentTheme(theme = "theme-$title", image = "theme.png") + entityManager.persist(theme) + return AudioContent( + title = title, + detail = "description", + languageCode = "ko", + releaseDate = LocalDateTime.now().minusDays(1), + limited = limited + ).apply { + member = creator + this.theme = theme + isActive = true + coverImage = "audio.png" + duration = "00:10:00" + entityManager.persist(this) + } + } + + private fun saveSeries(creator: Member, title: String, content: AudioContent): Series { + val genre = SeriesGenre(genre = "genre-$title", isAdult = false, isActive = true) + entityManager.persist(genre) + return Series(title = title, introduction = "introduction", languageCode = "ko").apply { + member = creator + this.genre = genre + coverImage = "series.png" + entityManager.persist(this) + saveSeriesContent(this, content) + } + } + + private fun saveSeriesContent(series: Series, content: AudioContent) { + entityManager.persist( + SeriesContent().apply { + this.series = series + this.content = content + } + ) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/search/SearchServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/search/SearchServiceTest.kt index 47366f99..e9e3ebd7 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/search/SearchServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/search/SearchServiceTest.kt @@ -1,6 +1,7 @@ package kr.co.vividnext.sodalive.search import kr.co.vividnext.sodalive.content.ContentType +import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.member.Member import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.DisplayName @@ -9,7 +10,7 @@ import org.mockito.Mockito class SearchServiceTest { private val repository: SearchRepository = Mockito.mock(SearchRepository::class.java) - private val service = SearchService(repository) + private val service = SearchService(repository, LangContext()) @Test @DisplayName("콘텐츠 검색은 전달받은 isAdult 값을 그대로 사용한다") diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/LegacyCommunityPostCharacterizationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/LegacyCommunityPostCharacterizationTest.kt index e3978075..ecc42b79 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/LegacyCommunityPostCharacterizationTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/LegacyCommunityPostCharacterizationTest.kt @@ -132,6 +132,7 @@ class LegacyCommunityPostCharacterizationTest { assertEquals(FcmEventType.CHANGE_NOTICE, event.type) assertEquals(PushNotificationCategory.COMMUNITY, event.category) assertEquals("creator.community.fcm.new_post", event.messageKey) + assertEquals(creator.id, event.titleMemberId) assertEquals(creator.id, event.creatorId) assertEquals(FcmDeepLinkValue.COMMUNITY, event.deepLinkValue) assertEquals(creator.id, event.deepLinkId) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/LegacyFanTalkReplyCharacterizationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/LegacyFanTalkReplyCharacterizationTest.kt index 8e3755f6..9f146e9a 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/LegacyFanTalkReplyCharacterizationTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/LegacyFanTalkReplyCharacterizationTest.kt @@ -22,6 +22,7 @@ import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.MemberService import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService +import kr.co.vividnext.sodalive.v2.creatorname.application.CreatorLocalizedNameService import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertNull @@ -42,6 +43,7 @@ class LegacyFanTalkReplyCharacterizationTest { private lateinit var cheersRepository: CreatorCheersRepository private lateinit var applicationEventPublisher: ApplicationEventPublisher private lateinit var messageSource: SodaMessageSource + private lateinit var localizedNameService: CreatorLocalizedNameService private lateinit var service: ExplorerService @BeforeEach @@ -51,6 +53,7 @@ class LegacyFanTalkReplyCharacterizationTest { cheersRepository = Mockito.mock(CreatorCheersRepository::class.java) applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java) messageSource = Mockito.mock(SodaMessageSource::class.java) + localizedNameService = Mockito.mock(CreatorLocalizedNameService::class.java) service = ExplorerService( memberService = memberService, memberContentPreferenceService = Mockito.mock(MemberContentPreferenceService::class.java), @@ -66,6 +69,7 @@ class LegacyFanTalkReplyCharacterizationTest { contentTranslationRepository = Mockito.mock(ContentTranslationRepository::class.java), messageSource = messageSource, langContext = LangContext().apply { setLang(Lang.KO) }, + localizedNameService = localizedNameService, cloudFrontHost = "https://cdn.test" ) } @@ -177,6 +181,8 @@ class LegacyFanTalkReplyCharacterizationTest { val root = createCheers(id = 300L, content = "root", member = creator, creator = creator) Mockito.`when`(queryRepository.getMember(creator.id!!)).thenReturn(creator) Mockito.`when`(queryRepository.getCreatorCheers(root.id!!)).thenReturn(root) + Mockito.`when`(localizedNameService.findLocalizedNames(listOf(creator.id!!), Lang.KO)) + .thenReturn(mapOf(creator.id!! to "Korean creator alias")) var nextId = 301L Mockito.`when`(cheersRepository.save(Mockito.any(CreatorCheers::class.java))).thenAnswer { invocation -> invocation.getArgument(0).also { cheers -> @@ -185,7 +191,7 @@ class LegacyFanTalkReplyCharacterizationTest { } } - service.writeCheers( + val response = service.writeCheers( PostWriteCheersRequest(root.id, creator.id!!, "first reply", "ko"), creator ) @@ -198,6 +204,8 @@ class LegacyFanTalkReplyCharacterizationTest { Mockito.verify(cheersRepository, Mockito.times(2)).save(cheersCaptor.capture()) assertEquals(listOf("first reply", "second reply"), cheersCaptor.allValues.map { it.cheers }) assertTrue(cheersCaptor.allValues.all { it.parent === root }) + assertEquals("duplicate-creator", response.writerNickname) + Mockito.verifyNoInteractions(localizedNameService) } @Test @@ -222,6 +230,8 @@ class LegacyFanTalkReplyCharacterizationTest { Mockito.`when`(queryRepository.getMember(creator.id!!)).thenReturn(creator) Mockito.`when`(memberService.isBlocked(writer.id!!, creator.id!!)).thenReturn(true) Mockito.`when`(messageSource.getMessage("explorer.creator.blocked_cheers", Lang.KO)).thenReturn("blocked %s") + Mockito.`when`(localizedNameService.findLocalizedNames(listOf(creator.id!!), Lang.KO)) + .thenReturn(mapOf(creator.id!! to "Korean blocked alias")) val exception = assertThrows(SodaException::class.java) { service.writeCheers(PostWriteCheersRequest(creatorId = creator.id!!, content = "reply"), writer) @@ -229,6 +239,7 @@ class LegacyFanTalkReplyCharacterizationTest { assertNull(exception.messageKey) assertEquals("blocked blocked-creator", exception.message) + Mockito.verifyNoInteractions(localizedNameService) Mockito.verifyNoInteractions(cheersRepository, applicationEventPublisher) } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creatorname/application/LegacyCurrentNameReadIntegrationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creatorname/application/LegacyCurrentNameReadIntegrationTest.kt new file mode 100644 index 00000000..4011f226 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creatorname/application/LegacyCurrentNameReadIntegrationTest.kt @@ -0,0 +1,467 @@ +package kr.co.vividnext.sodalive.v2.creatorname.application + +import kr.co.vividnext.sodalive.audition.Audition +import kr.co.vividnext.sodalive.audition.AuditionApplicant +import kr.co.vividnext.sodalive.audition.AuditionRole +import kr.co.vividnext.sodalive.audition.applicant.AuditionApplicantService +import kr.co.vividnext.sodalive.audition.applicant.AuditionApplicantSortType +import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront +import kr.co.vividnext.sodalive.can.CanService +import kr.co.vividnext.sodalive.can.payment.PaymentGateway +import kr.co.vividnext.sodalive.can.use.CanUsage +import kr.co.vividnext.sodalive.can.use.UseCan +import kr.co.vividnext.sodalive.can.use.UseCanCalculate +import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus +import kr.co.vividnext.sodalive.common.CountryContext +import kr.co.vividnext.sodalive.content.AudioContent +import kr.co.vividnext.sodalive.content.AudioContentService +import kr.co.vividnext.sodalive.content.order.Order +import kr.co.vividnext.sodalive.content.order.OrderService +import kr.co.vividnext.sodalive.content.order.OrderType +import kr.co.vividnext.sodalive.content.playlist.AudioContentPlaylist +import kr.co.vividnext.sodalive.content.playlist.AudioContentPlaylistRedisRepository +import kr.co.vividnext.sodalive.content.playlist.AudioContentPlaylistService +import kr.co.vividnext.sodalive.content.playlist.PlaylistContentIdAndOrder +import kr.co.vividnext.sodalive.content.theme.AudioContentTheme +import kr.co.vividnext.sodalive.explorer.ExplorerService +import kr.co.vividnext.sodalive.explorer.MemberDonationRankingListResponse +import kr.co.vividnext.sodalive.explorer.MemberDonationRankingResponse +import kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationMessage +import kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationService +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityService +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.CreatorCommunityComment +import kr.co.vividnext.sodalive.i18n.Lang +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.member.DonationRankingPeriod +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.member.MemberService +import kr.co.vividnext.sodalive.member.block.BlockMember +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.data.domain.PageRequest +import org.springframework.data.redis.core.RedisTemplate +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.test.context.ContextConfiguration +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime +import javax.persistence.EntityManager + +@SpringBootTest( + properties = [ + "spring.cache.type=none", + "cloud.aws.cloud-front.host=https://cdn.test", + "spring.datasource.url=jdbc:h2:mem:legacy-current-name-reads;MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_ON_EXIT=FALSE" + ] +) +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +@Transactional +class LegacyCurrentNameReadIntegrationTest @Autowired constructor( + private val entityManager: EntityManager, + private val jdbcTemplate: JdbcTemplate, + private val memberService: MemberService, + private val orderService: OrderService, + private val playlistService: AudioContentPlaylistService, + private val playlistRepository: AudioContentPlaylistRedisRepository, + private val communityService: CreatorCommunityService, + private val donationService: ChannelDonationService, + private val explorerService: ExplorerService, + private val canService: CanService, + private val contentService: AudioContentService, + private val applicantService: AuditionApplicantService, + private val redisTemplate: RedisTemplate +) { + @MockBean + private lateinit var langContext: LangContext + + @MockBean + private lateinit var countryContext: CountryContext + + @MockBean + private lateinit var cloudFront: AudioContentCloudFront + + @BeforeEach + fun setUp() { + jdbcTemplate.execute( + "CREATE ALIAS IF NOT EXISTS DATE_FORMAT FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.dateFormat'" + ) + jdbcTemplate.execute( + "CREATE ALIAS IF NOT EXISTS CONVERT_TZ FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.convertTz'" + ) + Mockito.doReturn(Lang.JA).`when`(langContext).lang + Mockito.doReturn("US").`when`(countryContext).countryCode + Mockito.doReturn("https://cdn.test/signed-audio").`when`(cloudFront) + .generateSignedURL(Mockito.anyString(), Mockito.anyLong()) + } + + @Test + @DisplayName("다른 회원 프로필과 차단 목록은 JA 이름을 표시하고 편집용 원본 프로필과 USER는 보존한다") + fun shouldLocalizeMemberDisplayAndBlockedListWithoutChangingRawEditorOrUserName() { + val viewer = saveMember("blocked-viewer", MemberRole.USER) + val creator = saveMember("blocked-creator", MemberRole.CREATOR) + val user = saveMember("blocked-user", MemberRole.USER) + listOf(creator, user).forEach { target -> + entityManager.persist( + BlockMember().apply { + member = viewer + blockedMember = target + } + ) + } + saveAlias(creator, "Japanese blocked creator") + saveAlias(user, "Must not replace blocked USER") + entityManager.clear() + + assertEquals("Japanese blocked creator", memberService.getMemberProfile(creator.id!!, viewer.id!!).nickname) + assertEquals("blocked-creator", memberService.getMember(creator.id!!, "web").nickname) + assertEquals("Japanese blocked creator", memberService.getMyPage(creator, "web").nickname) + val blocked = memberService.getBlockedMemberList(viewer.id!!, 0, 20) + assertEquals(2, blocked.totalCount) + assertEquals( + mapOf(creator.id to "Japanese blocked creator", user.id to "blocked-user"), + blocked.items.associate { it.memberId to it.nickname } + ) + assertEquals("blocked-creator", entityManager.find(Member::class.java, creator.id).nickname) + } + + @Test + @DisplayName("주문과 Redis 플레이리스트 상세는 콘텐츠 ID와 다른 소유자 이름을 현재 언어로 표시한다") + fun shouldLocalizeOrderAndPlaylistOwnersWithoutChangingTitlesOrStoredPlaylist() { + val viewer = saveMember("playlist-viewer", MemberRole.USER) + val creator = saveMember("playlist-creator", MemberRole.CREATOR) + val content = saveContent(creator, "Unchanged playlist title") + entityManager.persist( + Order(type = OrderType.KEEP).apply { + member = viewer + this.creator = creator + audioContent = content + } + ) + saveAlias(creator, "Japanese playlist creator") + entityManager.clear() + assertNotEquals(creator.id, content.id) + val playlist = AudioContentPlaylist( + id = 100000L + viewer.id!!, + memberId = viewer.id!!, + title = "playlist metadata", + contentIdAndOrderList = listOf(PlaylistContentIdAndOrder(content.id!!, 1)) + ) + playlistRepository.save(playlist) + try { + val order = orderService.getAudioContentOrderList(viewer, 0, 20) + assertEquals("Japanese playlist creator", order.items.single().creatorNickname) + assertEquals("Unchanged playlist title", order.items.single().title) + val detail = playlistService.getPlaylistDetail(playlist.id, viewer) + assertEquals("Japanese playlist creator", detail.contentList.single().creatorNickname) + assertEquals("Unchanged playlist title", detail.contentList.single().title) + assertEquals(playlist, playlistRepository.findById(playlist.id).orElseThrow()) + Mockito.doReturn(Lang.KO).`when`(langContext).lang + val korean = playlistService.getPlaylistDetail(playlist.id, viewer) + assertEquals("playlist-creator", korean.contentList.single().creatorNickname) + assertEquals("Japanese playlist creator", detail.contentList.single().creatorNickname) + } finally { + playlistRepository.delete(playlist) + } + } + + @Test + @DisplayName("한정 콘텐츠 상세의 구매자 목록은 콘텐츠 소유자가 아닌 각 구매자 CREATOR 이름만 대체한다") + fun shouldLocalizeMixedBuyerNamesByBuyerIdWithoutAddingPublicOwnerFields() { + val owner = saveMember("buyer-content-owner", MemberRole.CREATOR) + val creatorBuyer = saveMember("buyer-creator", MemberRole.CREATOR) + val userBuyer = saveMember("buyer-user", MemberRole.USER) + val content = saveContent(owner, "Unchanged limited content title", limited = 5) + listOf(creatorBuyer, userBuyer).forEach { buyer -> + entityManager.persist( + Order(type = OrderType.KEEP).apply { + member = buyer + creator = owner + audioContent = content + } + ) + } + saveAlias(owner, "Japanese content owner") + saveAlias(creatorBuyer, "Japanese buyer") + saveAlias(userBuyer, "Must not replace buyer USER") + entityManager.clear() + + val detail = contentService.getDetail(content.id!!, owner, false, "Asia/Seoul") + + assertEquals("Japanese content owner", detail.creator.nickname) + assertEquals(setOf("Japanese buyer", "buyer-user"), detail.buyerList.map { it.nickname }.toSet()) + assertEquals("Unchanged limited content title", detail.title) + assertEquals("https://cdn.test/signed-audio", detail.contentUrl) + } + + @Test + @DisplayName("레거시 커뮤니티 게시글·댓글·답글·구매 응답은 활성 JA·KO 별칭이 있어도 원본 이름을 유지한다") + fun shouldKeepOriginalCommunityNamesDespiteActiveJaAndKoAliases() { + val viewer = saveMember("community-viewer", MemberRole.USER) + val creator = saveMember("community-owner", MemberRole.CREATOR) + val peer = saveMember("deleted_community-peer", MemberRole.CREATOR) + val post = savePost(creator) + val parent = CreatorCommunityComment(comment = "unchanged creator comment").apply { + member = peer + creatorCommunity = post + entityManager.persist(this) + } + val reply = CreatorCommunityComment(comment = "unchanged USER reply").apply { + member = viewer + creatorCommunity = post + this.parent = parent + entityManager.persist(this) + } + val creatorReply = CreatorCommunityComment(comment = "unchanged creator reply").apply { + member = creator + creatorCommunity = post + this.parent = parent + entityManager.persist(this) + } + saveReceipt(viewer, CanUsage.PAID_COMMUNITY_POST, creator).communityPost = post + saveAlias(creator, "Japanese community owner") + saveAlias(peer, "Japanese community writer") + saveAlias(viewer, "Must not replace community USER") + listOf(creator, peer).forEach { writer -> + jdbcTemplate.update( + "insert into creator_localized_name (member_id, lang, display_name, is_active, created_at, updated_at) " + + "values (?, 'KO', ?, true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", + writer.id!!, + "Korean ${writer.nickname}" + ) + } + entityManager.clear() + + listOf(Lang.JA, Lang.KO).forEach { lang -> + Mockito.doReturn(lang).`when`(langContext).lang + val posts = communityService.getCommunityPostList(creator.id!!, viewer.id!!, "Asia/Seoul", 0, 20, false) + assertEquals("community-owner", posts.single().creatorNickname) + assertEquals("community-peer", posts.single().firstComment!!.nickname) + assertEquals("unchanged post content", posts.single().content) + val detail = communityService.getCommunityPostDetail(post.id!!, viewer.id!!, "Asia/Seoul", false) + assertEquals("community-owner", detail.creatorNickname) + val comments = communityService.getCommunityPostCommentList(post.id!!, viewer.id!!, "Asia/Seoul", 0, 20, false) + assertEquals("community-peer", comments.items.single().nickname) + val replies = communityService.getCommentReplyList(parent.id!!, viewer.id!!, "Asia/Seoul", 0, 20, false) + assertEquals( + mapOf(reply.id to "community-viewer", creatorReply.id to "community-owner"), + replies.items.associate { it.id to it.nickname } + ) + assertEquals("unchanged USER reply", replies.items.single { it.id == reply.id }.comment) + val purchased = communityService.purchasePost(post.id!!, viewer.id!!, "Asia/Seoul", false, "web") + assertEquals("community-owner", purchased.creatorNickname) + assertEquals("community-peer", purchased.firstComment!!.nickname) + } + } + + @Test + @DisplayName("채널 후원 목록의 이름 소유자는 채널이 아니라 후원자이며 USER는 원본이다") + fun shouldLocalizeCreatorDonorsIndependentlyOfTheChannelOwner() { + val viewer = saveMember("donation-viewer", MemberRole.USER) + val creator = saveMember("donation-channel", MemberRole.CREATOR) + val donor = saveMember("donation-creator-donor", MemberRole.CREATOR) + listOf(viewer, donor).forEach { writer -> + entityManager.persist( + ChannelDonationMessage(can = 100, isSecret = false).apply { + member = writer + this.creator = creator + } + ) + } + saveAlias(creator, "Wrong channel owner name") + saveAlias(donor, "Japanese donor") + saveAlias(viewer, "Must not replace donor USER") + entityManager.clear() + + val response = donationService.getChannelDonationList(creator.id!!, viewer, 0, 20) + assertEquals(2, response.totalCount) + assertEquals( + mapOf(donor.id to "Japanese donor", viewer.id to "donation-viewer"), + response.items.associate { it.memberId to it.nickname } + ) + assertEquals(listOf(100, 100), response.items.map { it.can }) + } + + @Test + @DisplayName("receipt는 각 recipient·audio·community 소유자의 현재 이름을 쓰고 리소스 제목과 금액을 보존한다") + fun shouldLocalizeReceiptOwnerNamesWithoutReplacingResourceTitlesOrAmounts() { + val viewer = saveMember("receipt-viewer", MemberRole.USER) + val owner = saveMember("receipt-owner", MemberRole.CREATOR) + val recipient = saveMember("receipt-recipient", MemberRole.CREATOR) + val content = saveContent(owner, "Unchanged receipt content title") + val post = savePost(owner) + saveReceipt(viewer, CanUsage.DONATION, owner).audioContent = content + saveReceipt(viewer, CanUsage.PAID_COMMUNITY_POST, owner).communityPost = post + saveReceipt(viewer, CanUsage.CHANNEL_DONATION, recipient) + saveReceipt(viewer, CanUsage.CHANNEL_DONATION, viewer) + saveReceipt(viewer, CanUsage.ORDER_CONTENT, owner).audioContent = content + saveAlias(owner, "Japanese receipt owner") + saveAlias(recipient, "Japanese recipient") + saveAlias(viewer, "Must not replace receipt USER") + entityManager.clear() + + val rows = canService.getCanUseStatus(viewer, PageRequest.of(0, 20), "UTC", "web") + assertEquals( + setOf( + "[콘텐츠 후원] Japanese receipt owner", + "[게시글 보기] Japanese receipt owner", + "[채널 후원] Japanese recipient", + "[채널 후원] receipt-viewer", + "[콘텐츠 구매] Unchanged receipt content title" + ), + rows.map { it.title }.toSet() + ) + assertEquals(5, rows.size) + assertEquals(listOf(100, 100, 100, 100, 100), rows.map { it.can }) + } + + @Test + @DisplayName("오디션 지원자는 applicant ID가 아닌 member ID로 이름을 표시하고 USER·count·정렬을 보존한다") + fun shouldLocalizeAuditionApplicantsByMemberIdAndKeepUserNamesAndCount() { + saveMember("applicant-unused-viewer", MemberRole.USER) + val creator = saveMember("applicant-creator", MemberRole.CREATOR) + val user = saveMember("applicant-user", MemberRole.USER) + val audition = Audition(title = "Unchanged audition title", information = "information") + entityManager.persist(audition) + val role = AuditionRole(name = "Unchanged role name", information = "information").apply { + this.audition = audition + entityManager.persist(this) + } + val creatorApplicant = AuditionApplicant(phoneNumber = "01000000000", voicePath = "voice.m4a").apply { + member = creator + this.role = role + entityManager.persist(this) + } + val userApplicant = AuditionApplicant(phoneNumber = "01000000001", voicePath = "voice.m4a").apply { + member = user + this.role = role + entityManager.persist(this) + } + saveAlias(creator, "Japanese applicant") + saveAlias(user, "Must not replace applicant USER") + entityManager.clear() + assertNotEquals(creator.id, creatorApplicant.id) + + val response = applicantService.getAuditionApplicantList(role.id!!, AuditionApplicantSortType.NEWEST, 0, 20) + + assertEquals(2, response.totalCount) + assertEquals(listOf(userApplicant.id, creatorApplicant.id), response.items.map { it.applicantId }) + assertEquals(listOf("applicant-user", "Japanese applicant"), response.items.map { it.nickname }) + } + + @Test + @DisplayName("중립 후원랭킹 cache hit는 요청별 현재 이름을 복사하고 원본 변경·해제 후 현재 원본으로 돌아간다") + fun shouldRenderCachedDonorNamesAfterReadAndKeepCachedRowsImmutable() { + val viewer = saveMember("cached-viewer", MemberRole.USER) + val channel = saveMember("cached-channel", MemberRole.CREATOR) + val donor = saveMember("cached-donor-original", MemberRole.CREATOR) + saveAlias(donor, "Japanese cached donor") + entityManager.clear() + val key = "creator_donation_ranking_v2:${channel.id}:CUMULATIVE:0:20:false" + val totalKey = "creator_donation_ranking_member_total_v2:${channel.id}:CUMULATIVE" + val cached = MemberDonationRankingListResponse( + listOf(MemberDonationRankingResponse(donor.id!!, "stale cached original", "image.png", 0)) + ) + redisTemplate.opsForValue().set(key, cached) + redisTemplate.opsForValue().set(totalKey, 1) + try { + val japanese = explorerService.getCreatorProfileDonationRanking( + channel.id!!, + DonationRankingPeriod.CUMULATIVE, + PageRequest.of(0, 20), + viewer + ) + assertEquals("Japanese cached donor", japanese.userDonationRanking.single().nickname) + jdbcTemplate.update("update member set nickname = ? where id = ?", "renamed donor", donor.id!!) + jdbcTemplate.update("update creator_localized_name set is_active = false where member_id = ?", donor.id!!) + entityManager.clear() + val cleared = explorerService.getCreatorProfileDonationRanking( + channel.id!!, + DonationRankingPeriod.CUMULATIVE, + PageRequest.of(0, 20), + viewer + ) + assertEquals("renamed donor", cleared.userDonationRanking.single().nickname) + assertEquals("Japanese cached donor", japanese.userDonationRanking.single().nickname) + assertEquals(cached, redisTemplate.opsForValue().get(key)) + } finally { + redisTemplate.delete(listOf(key, totalKey)) + } + } + + private fun saveMember(nickname: String, role: MemberRole): Member = + Member( + email = "$nickname@test.com", + password = "password", + nickname = nickname, + profileImage = "profile.png", + role = role + ).also { entityManager.persist(it) } + + private fun saveContent(owner: Member, title: String, limited: Int? = null): AudioContent { + val theme = AudioContentTheme(theme = "theme-$title", image = "theme.png") + entityManager.persist(theme) + return AudioContent( + title = title, + detail = "detail", + languageCode = "ko", + releaseDate = LocalDateTime.now().minusDays(1), + limited = limited, + remaining = limited + ) + .apply { + member = owner + this.theme = theme + isActive = true + content = "output/audio.m4a" + duration = "00:10:00" + coverImage = "content.png" + entityManager.persist(this) + } + } + + private fun savePost(owner: Member): CreatorCommunity = + CreatorCommunity( + content = "unchanged post content", + price = 0, + isCommentAvailable = true, + isAdult = false, + languageCode = "ko" + ).apply { + member = owner + entityManager.persist(this) + } + + private fun saveReceipt(viewer: Member, usage: CanUsage, recipient: Member): UseCan { + entityManager.flush() + val receipt = UseCan(canUsage = usage, can = 100, rewardCan = 0).apply { + member = viewer + entityManager.persist(this) + } + entityManager.persist( + UseCanCalculate(100, PaymentGateway.PG, UseCanCalculateStatus.RECEIVED).apply { + useCan = receipt + recipientCreatorId = recipient.id!! + } + ) + return receipt + } + + private fun saveAlias(member: Member, name: String) { + entityManager.flush() + jdbcTemplate.update( + "insert into creator_localized_name (member_id, lang, display_name, is_active, created_at, updated_at) " + + "values (?, 'JA', ?, true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", + member.id!!, + name + ) + } +}