Compare commits

..

22 Commits

Author SHA1 Message Date
7afbf1bff8 라이브방 정보 응답에 방장 언어코드를 제공한다
라이브방 정보 조회 응답에서 tags 필드를 제거한다.
방장이 설정한 언어를 2자리 creatorLanguageCode로 제공한다.
2026-02-08 22:26:34 +09:00
8dec0fe2e5 라이브 언어 태그를 조회 언어로 번역해 노출한다
라이브 목록/상세 응답의 언어 태그를 조회자 언어로 반환한다.
언어 코드를 메시지 키로 매핑해 ko/en/ja 번역값을 제공한다.
2026-02-08 22:18:50 +09:00
4ea7fdc562 방 정보 응답의 v2v 워커 토큰을 RTC로 전환
GetRoomInfoResponse의 v2vWorkerRtmToken 필드를
v2vWorkerToken으로 변경한다.
v2v 워커 토큰은 RTM 대신 채널 기반 RTC 토큰을 반환한다.
2026-02-08 21:01:53 +09:00
37d2e0de73 일별 전체 회원 수에 애플 계정으로 회원 가입 수 추가 2026-02-08 16:26:28 +09:00
9779c1b50b 일본어 닉네임 생성 기능 추가
generateUniqueNickname에 Lang 파라미터를 추가하여
언어 설정이 일본어일 때 일본어 단어 조합으로
닉네임을 생성한다.
2026-02-08 16:15:51 +09:00
23c219c672 닉네임 생성 형용사 및 명사 단어 목록 교체
NicknameGenerateService의 adjectives, nouns 리스트를
새로운 단어 목록으로 전체 교체한다.
형용사 140개, 명사 160개를 신규 단어로 구성한다.
2026-02-08 16:02:32 +09:00
4a2a3cbbf8 GetRoomInfoResponse에 v2v worker용 rtm 토큰 추가 2026-02-06 19:46:57 +09:00
d1512f418f GetRoomInfoResponse에 라이브 관심사 tags 추가 2026-02-06 14:40:14 +09:00
d90a872e79 라이브 리스트 - apple-test, google-test 계정은 isAdult가 true인 방이 항상 보이지 않도록 수정 2026-02-06 13:52:30 +09:00
328be036f7 관리자 콘텐츠 이미지 업로드 시 파일 이름 패턴을 크리에이터가 올리던 패턴과 동일하게 수정 2026-02-05 18:01:33 +09:00
3e41e763e3 관리자 콘텐츠 이미지 업로드 지원 2026-02-05 17:16:54 +09:00
be6f7971c6 지금 라이브 중 - 본인인증을 하지 않아도 19금 방송이 표시되도록 수정 2026-02-04 22:36:37 +09:00
e0024a52ab 크리에이터 후원랭킹 기간 응답 추가 2026-02-03 17:27:49 +09:00
3cabc9de95 후원랭킹 기간 선택 반영
크리에이터 본인 조회 시 후원랭킹 기간을 선택하도록
period 파라미터를 제공한다.
2026-02-03 16:05:26 +09:00
f1f80ae386 후원랭킹 기간 선택 반영
프로필 업데이트에 후원랭킹 기간 선택을 추가하고
프로필 후원랭킹 조회가 선택한 기간을 사용한다
2026-02-03 15:48:42 +09:00
5eca3f770c 최근 방 정보 성별 제한 포함 2026-02-02 18:08:09 +09:00
ac5741b9af 크리에이터 프로필 라이브 성별 제한 적용 2026-02-02 17:51:05 +09:00
04a4b362da 본인 방 성별 제한 예외 적용 2026-02-02 17:22:09 +09:00
96513eef6a 라이브룸 성별 제한 추가
라이브룸 생성/수정 요청에 genderRestriction 필드 추가
라이브룸 상세 응답에 genderRestriction 필드 추가
2026-02-02 14:44:07 +09:00
6b0ceffe06 회원 통계 결과에 LINE 가입자 수 추가 2026-02-02 11:24:24 +09:00
461ee435e0 최신 콘텐츠 조회에서 다시듣기 제외 2026-01-30 17:17:50 +09:00
8c4b599735 라이브 방 태그 언어 우선 적용 2026-01-30 16:41:43 +09:00
29 changed files with 727 additions and 129 deletions

View File

@@ -2,13 +2,15 @@ package kr.co.vividnext.sodalive.admin.content
import kr.co.vividnext.sodalive.common.ApiResponse
import org.springframework.data.domain.Pageable
import org.springframework.http.MediaType
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RequestPart
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile
@RestController
@PreAuthorize("hasRole('ADMIN')")
@@ -38,10 +40,11 @@ class AdminContentController(private val service: AdminContentService) {
)
)
@PutMapping
@PutMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
fun modifyAudioContent(
@RequestBody request: UpdateAdminContentRequest
) = ApiResponse.ok(service.updateAudioContent(request))
@RequestPart("request") requestString: String,
@RequestPart("coverImage", required = false) coverImage: MultipartFile? = null
) = ApiResponse.ok(service.updateAudioContent(coverImage, requestString))
@GetMapping("/main/tab")
fun getContentMainTabList() = ApiResponse.ok(service.getContentMainTabList())

View File

@@ -1,15 +1,21 @@
package kr.co.vividnext.sodalive.admin.content
import com.amazonaws.services.s3.model.ObjectMetadata
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.admin.content.curation.AdminContentCurationRepository
import kr.co.vividnext.sodalive.admin.content.tab.AdminContentMainTabRepository
import kr.co.vividnext.sodalive.admin.content.theme.AdminContentThemeRepository
import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.main.tab.GetContentMainTabItem
import kr.co.vividnext.sodalive.utils.generateFileName
import org.springframework.beans.factory.annotation.Value
import org.springframework.data.domain.Pageable
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.multipart.MultipartFile
@Service
class AdminContentService(
@@ -17,7 +23,11 @@ class AdminContentService(
private val themeRepository: AdminContentThemeRepository,
private val audioContentCloudFront: AudioContentCloudFront,
private val curationRepository: AdminContentCurationRepository,
private val contentMainTabRepository: AdminContentMainTabRepository
private val contentMainTabRepository: AdminContentMainTabRepository,
private val objectMapper: ObjectMapper,
private val s3Uploader: S3Uploader,
@Value("\${cloud.aws.s3.bucket}")
private val bucket: String
) {
fun getAudioContentList(status: ContentReleaseStatus, pageable: Pageable): GetAdminContentListResponse {
val totalCount = repository.getAudioContentTotalCount(status = status)
@@ -82,12 +92,25 @@ class AdminContentService(
}
@Transactional
fun updateAudioContent(request: UpdateAdminContentRequest) {
fun updateAudioContent(coverImage: MultipartFile?, requestString: String) {
val request = objectMapper.readValue(requestString, UpdateAdminContentRequest::class.java)
val audioContent = repository.findByIdOrNull(id = request.id)
?: throw SodaException(messageKey = "admin.content.not_found")
if (request.isDefaultCoverImage) {
audioContent.coverImage = "`profile/default_profile.png`"
if (coverImage != null) {
val metadata = ObjectMetadata()
metadata.contentLength = coverImage.size
val fileName = generateFileName(prefix = "${request.id}-cover")
val imagePath = s3Uploader.upload(
inputStream = coverImage.inputStream,
bucket = bucket,
filePath = "audio_content_cover/${request.id}/$fileName",
metadata = metadata
)
audioContent.coverImage = imagePath
} else if (request.isDefaultCoverImage) {
audioContent.coverImage = "profile/default_profile.png"
}
if (request.isActive != null) {

View File

@@ -68,6 +68,32 @@ class AdminMemberStatisticsRepository(private val queryFactory: JPAQueryFactory)
.size
}
fun getTotalSignUpAppleCount(startDate: LocalDateTime, endDate: LocalDateTime): Int {
return queryFactory
.select(member.id)
.from(member)
.where(
member.createdAt.goe(startDate),
member.createdAt.loe(endDate),
member.provider.eq(MemberProvider.APPLE)
)
.fetch()
.size
}
fun getTotalSignUpLineCount(startDate: LocalDateTime, endDate: LocalDateTime): Int {
return queryFactory
.select(member.id)
.from(member)
.where(
member.createdAt.goe(startDate),
member.createdAt.loe(endDate),
member.provider.eq(MemberProvider.LINE)
)
.fetch()
.size
}
fun getTotalAuthCount(startDate: LocalDateTime, endDate: LocalDateTime): Int {
return queryFactory
.select(auth.id)
@@ -189,6 +215,44 @@ class AdminMemberStatisticsRepository(private val queryFactory: JPAQueryFactory)
.fetch()
}
fun getSignUpAppleCountInRange(startDate: LocalDateTime, endDate: LocalDateTime): List<DateAndMemberCount> {
return queryFactory
.select(
QDateAndMemberCount(
getFormattedDate(member.createdAt),
member.id.countDistinct().castToNum(Int::class.java)
)
)
.from(member)
.where(
member.createdAt.goe(startDate),
member.createdAt.loe(endDate),
member.provider.eq(MemberProvider.APPLE)
)
.groupBy(getFormattedDate(member.createdAt))
.orderBy(getFormattedDate(member.createdAt).desc())
.fetch()
}
fun getSignUpLineCountInRange(startDate: LocalDateTime, endDate: LocalDateTime): List<DateAndMemberCount> {
return queryFactory
.select(
QDateAndMemberCount(
getFormattedDate(member.createdAt),
member.id.countDistinct().castToNum(Int::class.java)
)
)
.from(member)
.where(
member.createdAt.goe(startDate),
member.createdAt.loe(endDate),
member.provider.eq(MemberProvider.LINE)
)
.groupBy(getFormattedDate(member.createdAt))
.orderBy(getFormattedDate(member.createdAt).desc())
.fetch()
}
fun getAuthCountInRange(startDate: LocalDateTime, endDate: LocalDateTime): List<DateAndMemberCount> {
return queryFactory
.select(

View File

@@ -58,6 +58,14 @@ class AdminMemberStatisticsService(private val repository: AdminMemberStatistics
startDate = startDateTime,
endDate = endDateTime
)
val totalSignUpAppleCount = repository.getTotalSignUpAppleCount(
startDate = startDateTime,
endDate = endDateTime
)
val totalSignUpLineCount = repository.getTotalSignUpLineCount(
startDate = startDateTime,
endDate = endDateTime
)
val totalAuthCount = repository.getTotalAuthCount(startDate = startDateTime, endDate = endDateTime)
val totalSignOutCount = repository.getTotalSignOutCount(startDate = startDateTime, endDate = endDateTime)
val totalPaymentMemberCount = repository.getPaymentMemberCount(startDate = startDateTime, endDate = endDateTime)
@@ -92,6 +100,16 @@ class AdminMemberStatisticsService(private val repository: AdminMemberStatistics
endDate = endDateTime
).associateBy({ it.date }, { it.memberCount })
val signUpAppleCountInRange = repository.getSignUpAppleCountInRange(
startDate = startDateTime,
endDate = endDateTime
).associateBy({ it.date }, { it.memberCount })
val signUpLineCountInRange = repository.getSignUpLineCountInRange(
startDate = startDateTime,
endDate = endDateTime
).associateBy({ it.date }, { it.memberCount })
val authCountInRange = repository.getAuthCountInRange(
startDate = startDateTime,
endDate = endDateTime
@@ -121,6 +139,8 @@ class AdminMemberStatisticsService(private val repository: AdminMemberStatistics
signUpEmailCount = signUpEmailCountInRange[date] ?: 0,
signUpKakaoCount = signUpKakaoCountInRange[date] ?: 0,
signUpGoogleCount = signUpGoogleCountInRange[date] ?: 0,
signUpAppleCount = signUpAppleCountInRange[date] ?: 0,
signUpLineCount = signUpLineCountInRange[date] ?: 0,
signOutCount = signOutCountInRange[date] ?: 0,
paymentMemberCount = paymentMemberCountInRangeMap[date] ?: 0
)
@@ -134,6 +154,8 @@ class AdminMemberStatisticsService(private val repository: AdminMemberStatistics
totalSignUpEmailCount = totalSignUpEmailCount,
totalSignUpKakaoCount = totalSignUpKakaoCount,
totalSignUpGoogleCount = totalSignUpGoogleCount,
totalSignUpAppleCount = totalSignUpAppleCount,
totalSignUpLineCount = totalSignUpLineCount,
totalSignOutCount = totalSignOutCount,
totalPaymentMemberCount = totalPaymentMemberCount,
items = items

View File

@@ -7,6 +7,8 @@ data class GetMemberStatisticsResponse(
val totalSignUpEmailCount: Int,
val totalSignUpKakaoCount: Int,
val totalSignUpGoogleCount: Int,
val totalSignUpAppleCount: Int,
val totalSignUpLineCount: Int,
val totalSignOutCount: Int,
val totalPaymentMemberCount: Int,
val items: List<GetMemberStatisticsItem>
@@ -19,6 +21,8 @@ data class GetMemberStatisticsItem(
val signUpEmailCount: Int,
val signUpKakaoCount: Int,
val signUpGoogleCount: Int,
val signUpAppleCount: Int,
val signUpLineCount: Int,
val signOutCount: Int,
val paymentMemberCount: Int
)

View File

@@ -34,15 +34,14 @@ class RtcTokenBuilder {
appId: String,
appCertificate: String,
channelName: String,
uid: Int,
uid: String,
privilegeTs: Int
): String {
val account = if (uid == 0) "" else uid.toString()
return buildTokenWithUserAccount(
appId,
appCertificate,
channelName,
account,
uid,
privilegeTs
)
}

View File

@@ -317,8 +317,9 @@ class HomeService(
val themeList = if (theme.isBlank()) {
contentThemeService.getActiveThemeOfContent(
isAdult = isAdult,
isFree = true,
contentType = contentType
isFree = false,
contentType = contentType,
excludeThemes = listOf("다시듣기")
)
} else {
listOf(theme)

View File

@@ -7,6 +7,7 @@ import kr.co.vividnext.sodalive.explorer.profile.PostWriteCheersRequest
import kr.co.vividnext.sodalive.explorer.profile.PutWriteCheersRequest
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.member.DonationRankingPeriod
import kr.co.vividnext.sodalive.member.Member
import org.springframework.data.domain.Pageable
import org.springframework.security.access.prepost.PreAuthorize
@@ -75,11 +76,12 @@ class ExplorerController(
@GetMapping("/profile/{id}/donation-rank")
fun getCreatorProfileDonationRanking(
@PathVariable("id") creatorId: Long,
@RequestParam("period", required = false) period: DonationRankingPeriod? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
pageable: Pageable
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
ApiResponse.ok(service.getCreatorProfileDonationRanking(creatorId, pageable, member))
ApiResponse.ok(service.getCreatorProfileDonationRanking(creatorId, period, pageable, member))
}
@PostMapping("/profile/cheers")

View File

@@ -22,11 +22,13 @@ import kr.co.vividnext.sodalive.explorer.profile.TimeDifferenceResult
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.room.GenderRestriction
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.visit.QLiveRoomVisit.liveRoomVisit
import kr.co.vividnext.sodalive.member.Gender
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.QMember
@@ -342,6 +344,21 @@ class ExplorerQueryRepository(
.and(liveRoom.cancel.id.isNull)
.and(liveRoom.isActive.isTrue)
val effectiveGender = if (userMember.auth != null) {
if (userMember.auth!!.gender == 1) Gender.MALE else Gender.FEMALE
} else {
userMember.gender
}
if (effectiveGender != Gender.NONE) {
val genderCondition = when (effectiveGender) {
Gender.MALE -> liveRoom.genderRestriction.`in`(GenderRestriction.ALL, GenderRestriction.MALE_ONLY)
Gender.FEMALE -> liveRoom.genderRestriction.`in`(GenderRestriction.ALL, GenderRestriction.FEMALE_ONLY)
Gender.NONE -> liveRoom.genderRestriction.isNotNull
}
where = where.and(genderCondition.or(liveRoom.member.id.eq(userMember.id)))
}
if (userMember.auth == null) {
where = where.and(liveRoom.isAdult.isFalse)
}

View File

@@ -24,6 +24,7 @@ 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.room.detail.GetRoomDetailUser
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
@@ -215,6 +216,7 @@ class ExplorerService(
val notificationUserIds = queryRepository.getNotificationUserIds(creatorId)
val creatorFollowing = queryRepository.getCreatorFollowing(creatorId = creatorId, memberId = member.id!!)
val notificationRecipientCount = notificationUserIds.size
val donationRankingPeriod = creatorAccount.donationRankingPeriod ?: DonationRankingPeriod.CUMULATIVE
// 후원랭킹
val memberDonationRanking = if (
@@ -223,7 +225,8 @@ class ExplorerService(
donationRankingService.getMemberDonationRanking(
creatorId = creatorId,
limit = 10,
withDonationCan = creatorId == member.id!!
withDonationCan = creatorId == member.id!!,
period = donationRankingPeriod
)
} else {
listOf()
@@ -396,23 +399,37 @@ class ExplorerService(
fun getCreatorProfileDonationRanking(
creatorId: Long,
period: DonationRankingPeriod?,
pageable: Pageable,
member: Member
): GetDonationAllResponse {
val creatorAccount = queryRepository.getMember(creatorId)
?: throw SodaException(messageKey = "member.validation.user_not_found")
val donationRankingPeriod = creatorAccount.donationRankingPeriod ?: DonationRankingPeriod.CUMULATIVE
val isCreatorSelf = creatorId == member.id!!
val effectivePeriod = if (isCreatorSelf && period != null) {
period
} else {
donationRankingPeriod
}
val currentDate = LocalDate.now().atTime(0, 0, 0)
val firstDayOfLastWeek = currentDate.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)).minusDays(7)
val firstDayOfMonth = currentDate.with(TemporalAdjusters.firstDayOfMonth())
val donationMemberTotal = donationRankingService.getMemberDonationRankingTotal(creatorId)
val donationMemberTotal = donationRankingService.getMemberDonationRankingTotal(
creatorId,
effectivePeriod
)
val donationRanking = donationRankingService.getMemberDonationRanking(
creatorId = creatorId,
offset = pageable.offset,
limit = pageable.pageSize.toLong(),
withDonationCan = creatorId == member.id!!
withDonationCan = isCreatorSelf,
period = effectivePeriod
)
return GetDonationAllResponse(
accumulatedCansToday = if (creatorId == member.id!!) {
accumulatedCansToday = if (isCreatorSelf) {
queryRepository.getDonationCoinsDateRange(
creatorId,
currentDate,
@@ -421,7 +438,7 @@ class ExplorerService(
} else {
0
},
accumulatedCansLastWeek = if (creatorId == member.id!!) {
accumulatedCansLastWeek = if (isCreatorSelf) {
queryRepository.getDonationCoinsDateRange(
creatorId,
firstDayOfLastWeek,
@@ -430,7 +447,7 @@ class ExplorerService(
} else {
0
},
accumulatedCansThisMonth = if (creatorId == member.id!!) {
accumulatedCansThisMonth = if (isCreatorSelf) {
queryRepository.getDonationCoinsDateRange(
creatorId,
firstDayOfMonth,
@@ -439,11 +456,16 @@ class ExplorerService(
} else {
0
},
isVisibleDonationRank = if (creatorId == member.id!!) {
isVisibleDonationRank = if (isCreatorSelf) {
queryRepository.getVisibleDonationRank(creatorId)
} else {
false
},
donationRankingPeriod = if (isCreatorSelf) {
donationRankingPeriod
} else {
null
},
totalCount = donationMemberTotal,
userDonationRanking = donationRanking
)

View File

@@ -1,6 +1,7 @@
package kr.co.vividnext.sodalive.explorer
import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.member.DonationRankingPeriod
import java.io.Serializable
data class GetDonationAllResponse(
@@ -8,6 +9,7 @@ data class GetDonationAllResponse(
val accumulatedCansLastWeek: Int,
val accumulatedCansThisMonth: Int,
val isVisibleDonationRank: Boolean,
val donationRankingPeriod: DonationRankingPeriod?,
val totalCount: Int,
val userDonationRanking: List<MemberDonationRankingResponse>
) : Serializable

View File

@@ -1,6 +1,7 @@
package kr.co.vividnext.sodalive.explorer.profile
import com.fasterxml.jackson.annotation.JsonProperty
import com.querydsl.core.BooleanBuilder
import com.querydsl.core.annotations.QueryProjection
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.can.use.CanUsage
@@ -8,13 +9,17 @@ import kr.co.vividnext.sodalive.can.use.QUseCan.useCan
import kr.co.vividnext.sodalive.can.use.QUseCanCalculate.useCanCalculate
import kr.co.vividnext.sodalive.member.QMember.member
import org.springframework.stereotype.Repository
import java.time.LocalDateTime
import java.time.ZoneId
@Repository
class CreatorDonationRankingQueryRepository(private val queryFactory: JPAQueryFactory) {
fun getMemberDonationRanking(
creatorId: Long,
offset: Long,
limit: Long
limit: Long,
startDate: LocalDateTime? = null,
endDate: LocalDateTime? = null
): List<DonationRankingProjection> {
val donationCan = useCan.rewardCan.add(useCan.can).sum()
return queryFactory
@@ -38,6 +43,7 @@ class CreatorDonationRankingQueryRepository(private val queryFactory: JPAQueryFa
.or(useCan.canUsage.eq(CanUsage.SPIN_ROULETTE))
.or(useCan.canUsage.eq(CanUsage.LIVE))
)
.and(buildDateRangeCondition(startDate, endDate))
)
.offset(offset)
.limit(limit)
@@ -46,7 +52,11 @@ class CreatorDonationRankingQueryRepository(private val queryFactory: JPAQueryFa
.fetch()
}
fun getMemberDonationRankingTotal(creatorId: Long): Int {
fun getMemberDonationRankingTotal(
creatorId: Long,
startDate: LocalDateTime? = null,
endDate: LocalDateTime? = null
): Int {
return queryFactory
.select(member.id)
.from(useCanCalculate)
@@ -61,11 +71,32 @@ class CreatorDonationRankingQueryRepository(private val queryFactory: JPAQueryFa
.or(useCan.canUsage.eq(CanUsage.SPIN_ROULETTE))
.or(useCan.canUsage.eq(CanUsage.LIVE))
)
.and(buildDateRangeCondition(startDate, endDate))
)
.groupBy(member.id)
.fetch()
.size
}
private fun buildDateRangeCondition(
startDate: LocalDateTime?,
endDate: LocalDateTime?
): BooleanBuilder {
val condition = BooleanBuilder()
if (startDate != null && endDate != null) {
val startUtc = startDate
.atZone(ZoneId.of("Asia/Seoul"))
.withZoneSameInstant(ZoneId.of("UTC"))
.toLocalDateTime()
val endUtc = endDate
.atZone(ZoneId.of("Asia/Seoul"))
.withZoneSameInstant(ZoneId.of("UTC"))
.toLocalDateTime()
condition.and(useCanCalculate.createdAt.goe(startUtc))
condition.and(useCanCalculate.createdAt.lt(endUtc))
}
return condition
}
}
data class DonationRankingProjection @QueryProjection constructor(

View File

@@ -2,11 +2,13 @@ package kr.co.vividnext.sodalive.explorer.profile
import kr.co.vividnext.sodalive.explorer.MemberDonationRankingListResponse
import kr.co.vividnext.sodalive.explorer.MemberDonationRankingResponse
import kr.co.vividnext.sodalive.member.DonationRankingPeriod
import org.springframework.beans.factory.annotation.Value
import org.springframework.data.redis.core.RedisTemplate
import org.springframework.stereotype.Service
import java.time.DayOfWeek
import java.time.Duration
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.temporal.ChronoUnit
@@ -20,14 +22,22 @@ class CreatorDonationRankingService(
@Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String
) {
fun getMemberDonationRankingTotal(creatorId: Long): Int {
val cacheKey = "creator_donation_ranking_member_total_v2:$creatorId"
fun getMemberDonationRankingTotal(
creatorId: Long,
period: DonationRankingPeriod = DonationRankingPeriod.CUMULATIVE
): Int {
val cacheKey = "creator_donation_ranking_member_total_v2:$creatorId:$period"
val cachedTotal = redisTemplate.opsForValue().get(cacheKey) as? Int
if (cachedTotal != null) {
return cachedTotal
}
val total = repository.getMemberDonationRankingTotal(creatorId)
val weeklyDateRange = getWeeklyDateRange(period)
val total = if (weeklyDateRange == null) {
repository.getMemberDonationRankingTotal(creatorId)
} else {
repository.getMemberDonationRankingTotal(creatorId, weeklyDateRange.first, weeklyDateRange.second)
}
val now = LocalDateTime.now()
val nextMonday = now.with(TemporalAdjusters.next(DayOfWeek.MONDAY)).with(LocalTime.MIN)
@@ -46,15 +56,27 @@ class CreatorDonationRankingService(
creatorId: Long,
offset: Long = 0,
limit: Long = 10,
withDonationCan: Boolean
withDonationCan: Boolean,
period: DonationRankingPeriod = DonationRankingPeriod.CUMULATIVE
): List<MemberDonationRankingResponse> {
val cacheKey = "creator_donation_ranking_v2:$creatorId:$offset:$limit:$withDonationCan"
val cacheKey = "creator_donation_ranking_v2:$creatorId:$period:$offset:$limit:$withDonationCan"
val cachedData = redisTemplate.opsForValue().get(cacheKey) as? MemberDonationRankingListResponse
if (cachedData != null) {
return cachedData.rankings
}
val memberDonationRanking = repository.getMemberDonationRanking(creatorId, offset, limit)
val weeklyDateRange = getWeeklyDateRange(period)
val memberDonationRanking = if (weeklyDateRange == null) {
repository.getMemberDonationRanking(creatorId, offset, limit)
} else {
repository.getMemberDonationRanking(
creatorId,
offset,
limit,
weeklyDateRange.first,
weeklyDateRange.second
)
}
val result = memberDonationRanking.map {
MemberDonationRankingResponse(
@@ -77,4 +99,17 @@ class CreatorDonationRankingService(
return result
}
private fun getWeeklyDateRange(period: DonationRankingPeriod): Pair<LocalDateTime, LocalDateTime>? {
if (period != DonationRankingPeriod.WEEKLY) {
return null
}
val currentDate = LocalDate.now()
val lastWeekMonday = currentDate.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)).minusWeeks(1)
val startDate = lastWeekMonday.atStartOfDay()
val endDate = startDate.plusDays(7)
return startDate to endDate
}
}

View File

@@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.fcm
import kr.co.vividnext.sodalive.content.comment.AudioContentCommentRepository
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.live.room.GenderRestriction
import kr.co.vividnext.sodalive.member.MemberRepository
import org.springframework.scheduling.annotation.Async
import org.springframework.stereotype.Component
@@ -33,7 +34,8 @@ class FcmEvent(
val auditionId: Long? = null,
val commentParentId: Long? = null,
val myMemberId: Long? = null,
val isAvailableJoinCreator: Boolean? = null
val isAvailableJoinCreator: Boolean? = null,
val genderRestriction: GenderRestriction? = null
)
@Component
@@ -69,7 +71,8 @@ class FcmSendListener(
val pushTokens = memberRepository.getCreateLiveRoomNotificationRecipientPushTokens(
creatorId = fcmEvent.creatorId!!,
isAuth = fcmEvent.isAuth ?: false,
isAvailableJoinCreator = fcmEvent.isAvailableJoinCreator ?: false
isAvailableJoinCreator = fcmEvent.isAvailableJoinCreator ?: false,
genderRestriction = fcmEvent.genderRestriction
)
sendPush(pushTokens, fcmEvent, roomId = fcmEvent.roomId)
}
@@ -79,7 +82,8 @@ class FcmSendListener(
creatorId = fcmEvent.creatorId!!,
roomId = fcmEvent.roomId!!,
isAuth = fcmEvent.isAuth ?: false,
isAvailableJoinCreator = fcmEvent.isAvailableJoinCreator ?: false
isAvailableJoinCreator = fcmEvent.isAvailableJoinCreator ?: false,
genderRestriction = fcmEvent.genderRestriction
)
sendPush(pushTokens, fcmEvent, roomId = fcmEvent.roomId)
}

View File

@@ -9,6 +9,7 @@ interface PushTokenRepository : JpaRepository<PushToken, Long>, PushTokenQueryRe
interface PushTokenQueryRepository {
fun findByToken(token: String): PushToken?
fun findByMemberId(memberId: Long): List<PushToken>
fun findByMemberIds(memberIds: List<Long>): List<PushToken>
}
class PushTokenQueryRepositoryImpl(
@@ -27,4 +28,12 @@ class PushTokenQueryRepositoryImpl(
.where(pushToken.member.id.eq(memberId))
.fetch()
}
override fun findByMemberIds(memberIds: List<Long>): List<PushToken> {
if (memberIds.isEmpty()) return emptyList()
return queryFactory
.selectFrom(pushToken)
.where(pushToken.member.id.`in`(memberIds))
.fetch()
}
}

View File

@@ -1448,6 +1448,11 @@ class SodaMessageSource {
)
private val liveRoomMessages = mapOf(
"live.room.gender_restricted" to mapOf(
Lang.KO to "입장 가능한 성별이 아닙니다.",
Lang.EN to "Your gender is not allowed to enter this room.",
Lang.JA to "入場可能な性別ではありません。"
),
"live.room.max_reservations" to mapOf(
Lang.KO to "예약 라이브는 최대 3개까지 가능합니다.",
Lang.EN to "You can reserve up to 3 live sessions.",
@@ -1573,6 +1578,21 @@ class SodaMessageSource {
Lang.EN to "yyyy.MM.dd E hh:mm a",
Lang.JA to "yyyy.MM.dd E hh:mm a"
),
"live.room.language_tag.korean" to mapOf(
Lang.KO to "한국어",
Lang.EN to "Korean",
Lang.JA to "韓国語"
),
"live.room.language_tag.japanese" to mapOf(
Lang.KO to "일본어",
Lang.EN to "Japanese",
Lang.JA to "日本語"
),
"live.room.language_tag.english" to mapOf(
Lang.KO to "영어",
Lang.EN to "English",
Lang.JA to "英語"
),
"live.room.fcm.message.started" to mapOf(
Lang.KO to "라이브를 시작했습니다. - %s",
Lang.EN to "Live started. - %s",

View File

@@ -15,5 +15,6 @@ data class CreateLiveRoomRequest(
val menuPanId: Long = 0,
val menuPan: String = "",
val isActiveMenuPan: Boolean = false,
val isAvailableJoinCreator: Boolean = true
val isAvailableJoinCreator: Boolean = true,
val genderRestriction: GenderRestriction = GenderRestriction.ALL
)

View File

@@ -9,5 +9,6 @@ data class EditLiveRoomInfoRequest(
val menuPanId: Long = 0,
val menuPan: String = "",
val isActiveMenuPan: Boolean? = null,
val isAdult: Boolean? = null
val isAdult: Boolean? = null,
val genderRestriction: GenderRestriction? = null
)

View File

@@ -5,5 +5,6 @@ data class GetRecentRoomInfoResponse(
val notice: String,
var coverImageUrl: String,
val coverImagePath: String,
val numberOfPeople: Int
val numberOfPeople: Int,
val genderRestriction: GenderRestriction
)

View File

@@ -32,7 +32,9 @@ data class LiveRoom(
@Enumerated(value = EnumType.STRING)
val type: LiveRoomType = LiveRoomType.OPEN,
@Column(nullable = true)
var password: String? = null
var password: String? = null,
@Enumerated(value = EnumType.STRING)
var genderRestriction: GenderRestriction = GenderRestriction.ALL
) : BaseEntity() {
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id", nullable = false)
@@ -67,3 +69,7 @@ enum class LiveRoomType {
enum class LiveRoomStatus {
NOW, RESERVATION
}
enum class GenderRestriction {
ALL, MALE_ONLY, FEMALE_ONLY
}

View File

@@ -14,8 +14,10 @@ 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 org.springframework.beans.factory.annotation.Value
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@@ -32,7 +34,8 @@ interface LiveRoomQueryRepository {
timezone: String,
memberId: Long?,
isCreator: Boolean,
isAdult: Boolean
isAdult: Boolean,
effectiveGender: Gender?
): List<LiveRoom>
fun getLiveRoomListReservationWithDate(
@@ -41,14 +44,16 @@ interface LiveRoomQueryRepository {
limit: Long,
memberId: Long?,
isCreator: Boolean,
isAdult: Boolean
isAdult: Boolean,
effectiveGender: Gender?
): List<LiveRoom>
fun getLiveRoomListReservationWithoutDate(
timezone: String,
memberId: Long?,
isCreator: Boolean,
isAdult: Boolean
isAdult: Boolean,
effectiveGender: Gender?
): List<LiveRoom>
fun getLiveRoom(id: Long): LiveRoom?
@@ -76,28 +81,55 @@ class LiveRoomQueryRepositoryImpl(
timezone: String,
memberId: Long?,
isCreator: Boolean,
isAdult: Boolean
isAdult: Boolean,
effectiveGender: Gender?
): List<LiveRoom> {
var where = liveRoom.channelName.isNotNull
.and(liveRoom.channelName.isNotEmpty)
.and(liveRoom.isActive.isTrue)
.and(liveRoom.member.isNotNull)
if (!isAdult) {
val isAdultRestricted = !isAdult || memberId == 17L || memberId == 16L
if (isAdultRestricted) {
where = where.and(liveRoom.isAdult.isFalse)
}
if (isCreator && memberId != null) {
val hasMemberId = memberId != null
if (isCreator && hasMemberId) {
where = where.and(
liveRoom.isAvailableJoinCreator.isTrue
.or(liveRoom.member.id.eq(memberId))
)
}
return queryFactory
if (effectiveGender != null && effectiveGender != Gender.NONE) {
val genderCondition = when (effectiveGender) {
Gender.MALE -> liveRoom.genderRestriction.`in`(GenderRestriction.ALL, GenderRestriction.MALE_ONLY)
Gender.FEMALE -> liveRoom.genderRestriction.`in`(GenderRestriction.ALL, GenderRestriction.FEMALE_ONLY)
Gender.NONE -> liveRoom.genderRestriction.isNotNull
}
where = if (hasMemberId) {
where.and(genderCondition.or(liveRoom.member.id.eq(memberId)))
} else {
where.and(genderCondition)
}
}
var select = queryFactory
.selectFrom(liveRoom)
.innerJoin(liveRoom.member, member)
.leftJoin(quarterLiveRankings).on(liveRoom.id.eq(quarterLiveRankings.roomId))
if (hasMemberId) {
val blockMemberCondition = blockMember.member.id.eq(member.id)
.and(blockMember.blockedMember.id.eq(memberId))
.and(blockMember.isActive.isTrue)
select = select.leftJoin(blockMember).on(blockMemberCondition)
where = where.and(blockMember.id.isNull)
}
return select
.where(where)
.offset(offset)
.limit(limit)
@@ -116,7 +148,8 @@ class LiveRoomQueryRepositoryImpl(
limit: Long,
memberId: Long?,
isCreator: Boolean,
isAdult: Boolean
isAdult: Boolean,
effectiveGender: Gender?
): List<LiveRoom> {
var where = liveRoom.beginDateTime.goe(date)
.and(liveRoom.beginDateTime.lt(date.plusDays(1)))
@@ -127,7 +160,8 @@ class LiveRoomQueryRepositoryImpl(
.and(liveRoom.isActive.isTrue)
.and(liveRoom.member.isNotNull)
if (!isAdult) {
val isAdultRestricted = !isAdult || memberId == 17L || memberId == 16L
if (isAdultRestricted) {
where = where.and(liveRoom.isAdult.isFalse)
}
@@ -138,9 +172,33 @@ class LiveRoomQueryRepositoryImpl(
)
}
return queryFactory
if (effectiveGender != null && effectiveGender != Gender.NONE) {
val genderCondition = when (effectiveGender) {
Gender.MALE -> liveRoom.genderRestriction.`in`(GenderRestriction.ALL, GenderRestriction.MALE_ONLY)
Gender.FEMALE -> liveRoom.genderRestriction.`in`(GenderRestriction.ALL, GenderRestriction.FEMALE_ONLY)
Gender.NONE -> liveRoom.genderRestriction.isNotNull
}
where = if (memberId != null) {
where.and(genderCondition.or(liveRoom.member.id.eq(memberId)))
} else {
where.and(genderCondition)
}
}
var select = queryFactory
.selectFrom(liveRoom)
.innerJoin(liveRoom.member, member)
if (memberId != null) {
val blockMemberCondition = blockMember.member.id.eq(member.id)
.and(blockMember.blockedMember.id.eq(memberId))
.and(blockMember.isActive.isTrue)
select = select.leftJoin(blockMember).on(blockMemberCondition)
where = where.and(blockMember.id.isNull)
}
return select
.where(where)
.offset(offset)
.limit(limit)
@@ -152,7 +210,8 @@ class LiveRoomQueryRepositoryImpl(
timezone: String,
memberId: Long?,
isCreator: Boolean,
isAdult: Boolean
isAdult: Boolean,
effectiveGender: Gender?
): List<LiveRoom> {
var where = liveRoom.beginDateTime.gt(
LocalDateTime.now()
@@ -167,7 +226,8 @@ class LiveRoomQueryRepositoryImpl(
.and(liveRoom.isActive.isTrue)
.and(liveRoom.member.isNotNull)
if (!isAdult) {
val isAdultRestricted = !isAdult || memberId == 17L || memberId == 16L
if (isAdultRestricted) {
where = where.and(liveRoom.isAdult.isFalse)
}
@@ -178,6 +238,19 @@ class LiveRoomQueryRepositoryImpl(
)
}
if (effectiveGender != null && effectiveGender != Gender.NONE) {
val genderCondition = when (effectiveGender) {
Gender.MALE -> liveRoom.genderRestriction.`in`(GenderRestriction.ALL, GenderRestriction.MALE_ONLY)
Gender.FEMALE -> liveRoom.genderRestriction.`in`(GenderRestriction.ALL, GenderRestriction.FEMALE_ONLY)
Gender.NONE -> liveRoom.genderRestriction.isNotNull
}
where = if (memberId != null) {
where.and(genderCondition.or(liveRoom.member.id.eq(memberId)))
} else {
where.and(genderCondition)
}
}
val orderBy = if (memberId != null) {
listOf(
CaseBuilder()
@@ -190,10 +263,21 @@ class LiveRoomQueryRepositoryImpl(
listOf(liveRoom.beginDateTime.asc())
}
return queryFactory
var select = queryFactory
.selectFrom(liveRoom)
.innerJoin(liveRoom.member, member)
.limit(10)
if (memberId != null) {
val blockMemberCondition = blockMember.member.id.eq(member.id)
.and(blockMember.blockedMember.id.eq(memberId))
.and(blockMember.isActive.isTrue)
select = select.leftJoin(blockMember).on(blockMemberCondition)
where = where.and(blockMember.id.isNull)
}
return select
.where(where)
.orderBy(*orderBy.toTypedArray())
.fetch()
@@ -231,7 +315,8 @@ class LiveRoomQueryRepositoryImpl(
liveRoom.notice,
liveRoom.coverImage.prepend("/").prepend(cloudFrontHost),
liveRoom.coverImage,
liveRoom.numberOfPeople
liveRoom.numberOfPeople,
liveRoom.genderRestriction
)
)
.from(liveRoom)

View File

@@ -21,6 +21,7 @@ import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository
import kr.co.vividnext.sodalive.extensions.convertLocalDateTime
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.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.live.reservation.LiveReservationRepository
@@ -95,6 +96,7 @@ class LiveRoomService(
private val roomVisitService: LiveRoomVisitService,
private val canPaymentService: CanPaymentService,
private val chargeRepository: ChargeRepository,
private val pushTokenRepository: PushTokenRepository,
private val memberRepository: MemberRepository,
private val tagRepository: LiveTagRepository,
private val canRepository: CanRepository,
@@ -126,6 +128,62 @@ class LiveRoomService(
}
}
private fun applyLanguageTagToRoomTags(
memberId: Long?,
tags: List<String>,
languageTagByMemberId: Map<Long, String?>? = null
): List<String> {
val randomizedTags = tags.shuffled()
val languageTag = getCreatorLanguageTag(memberId, languageTagByMemberId) ?: return randomizedTags
val filteredTags = randomizedTags.filterNot { it == languageTag }
return listOf(languageTag) + filteredTags
}
private fun getCreatorLanguageTag(
memberId: Long?,
languageTagByMemberId: Map<Long, String?>? = null
): String? {
if (memberId == null) return null
if (languageTagByMemberId != null && languageTagByMemberId.containsKey(memberId)) {
return languageTagByMemberId[memberId]
}
val tokens = pushTokenRepository.findByMemberId(memberId)
val languageCode = tokens
.filterNot { it.languageCode.isNullOrBlank() }
.maxByOrNull { it.updatedAt ?: LocalDateTime.MIN }
?.languageCode
return resolveLanguageTag(languageCode)
}
private fun buildLanguageTagMap(memberIds: List<Long>): Map<Long, String?> {
val tokens = pushTokenRepository.findByMemberIds(memberIds)
if (tokens.isEmpty()) return emptyMap()
val latestTokenByMemberId = tokens
.filter { it.member?.id != null }
.groupBy { it.member!!.id!! }
.mapValues { (_, memberTokens) ->
memberTokens.maxByOrNull { it.updatedAt ?: LocalDateTime.MIN }
}
return latestTokenByMemberId.mapValues { (_, token) ->
resolveLanguageTag(token?.languageCode)
}
}
private fun resolveLanguageTag(languageCode: String?): String? {
val key = when (languageCode?.lowercase()?.take(2)) {
"ko" -> "live.room.language_tag.korean"
"ja" -> "live.room.language_tag.japanese"
"en" -> "live.room.language_tag.english"
else -> null
} ?: return null
return messageSource.getMessage(key, langContext.lang)
}
@Transactional(readOnly = true)
fun getRoomList(
dateString: String?,
@@ -135,13 +193,21 @@ class LiveRoomService(
member: Member?,
timezone: String
): List<GetRoomListResponse> {
val effectiveGender = member?.let {
if (it.auth != null) {
if (it.auth!!.gender == 1) Gender.MALE else Gender.FEMALE
} else {
it.gender
}
}
val roomList = if (status == LiveRoomStatus.NOW) {
getLiveRoomListNow(
pageable,
timezone,
memberId = member?.id,
isCreator = member?.role == MemberRole.CREATOR,
isAdult = member?.auth != null && isAdultContentVisible
isAdult = true,
effectiveGender = effectiveGender
)
} else if (dateString != null) {
getLiveRoomListReservationWithDate(
@@ -150,25 +216,23 @@ class LiveRoomService(
timezone,
memberId = member?.id,
isCreator = member?.role == MemberRole.CREATOR,
isAdult = member?.auth != null && isAdultContentVisible
isAdult = member?.auth != null && isAdultContentVisible,
effectiveGender = effectiveGender
)
} else {
getLiveRoomListReservationWithoutDate(
timezone,
isCreator = member?.role == MemberRole.CREATOR,
memberId = member?.id,
isAdult = member?.auth != null && isAdultContentVisible
isAdult = member?.auth != null && isAdultContentVisible,
effectiveGender = effectiveGender
)
}
val creatorIds = roomList.mapNotNull { it.member?.id }.distinct()
val languageTagByMemberId = buildLanguageTagMap(creatorIds)
return roomList
.filter {
if (member?.id != null) {
!blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it.member!!.id!!)
} else {
true
}
}
.map {
val roomInfo = roomInfoRepository.findByIdOrNull(it.id!!)
@@ -193,6 +257,11 @@ class LiveRoomService(
val beginDateTimeUtc = it.beginDateTime
.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
val tags = it.tags
.filter { tag -> tag.tag.isActive }
.map { tag -> tag.tag.tag }
.let { list -> applyLanguageTagToRoomTags(it.member?.id, list, languageTagByMemberId) }
GetRoomListResponse(
roomId = it.id!!,
title = it.title,
@@ -213,11 +282,7 @@ class LiveRoomService(
},
creatorNickname = it.member!!.nickname,
creatorId = it.member!!.id!!,
tags = it.tags
.asSequence()
.filter { tag -> tag.tag.isActive }
.map { tag -> tag.tag.tag }
.toList(),
tags = tags,
coverImageUrl = if (it.coverImage!!.startsWith("https://")) {
it.coverImage!!
} else {
@@ -234,7 +299,8 @@ class LiveRoomService(
timezone: String,
memberId: Long?,
isCreator: Boolean,
isAdult: Boolean
isAdult: Boolean,
effectiveGender: Gender?
): List<LiveRoom> {
return repository.getLiveRoomListNow(
offset = pageable.offset,
@@ -242,7 +308,8 @@ class LiveRoomService(
timezone = timezone,
memberId = memberId,
isCreator = isCreator,
isAdult = isAdult
isAdult = isAdult,
effectiveGender = effectiveGender
)
}
@@ -252,7 +319,8 @@ class LiveRoomService(
timezone: String,
memberId: Long?,
isCreator: Boolean,
isAdult: Boolean
isAdult: Boolean,
effectiveGender: Gender?
): List<LiveRoom> {
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
val date = LocalDate.parse(dateString, dateTimeFormatter).atStartOfDay()
@@ -266,7 +334,8 @@ class LiveRoomService(
limit = pageable.pageSize.toLong(),
memberId = memberId,
isCreator = isCreator,
isAdult = isAdult
isAdult = isAdult,
effectiveGender = effectiveGender
)
}
@@ -274,9 +343,10 @@ class LiveRoomService(
timezone: String,
memberId: Long?,
isCreator: Boolean,
isAdult: Boolean
isAdult: Boolean,
effectiveGender: Gender?
): List<LiveRoom> {
return repository.getLiveRoomListReservationWithoutDate(timezone, memberId, isCreator, isAdult)
return repository.getLiveRoomListReservationWithoutDate(timezone, memberId, isCreator, isAdult, effectiveGender)
}
@Transactional
@@ -338,7 +408,8 @@ class LiveRoomService(
},
type = request.type,
password = request.password,
isAvailableJoinCreator = request.isAvailableJoinCreator
isAvailableJoinCreator = request.isAvailableJoinCreator,
genderRestriction = request.genderRestriction
)
room.member = member
@@ -417,7 +488,8 @@ class LiveRoomService(
isAuth = createdRoom.isAdult,
isAvailableJoinCreator = createdRoom.isAvailableJoinCreator,
roomId = createdRoom.id,
creatorId = createdRoom.member!!.id
creatorId = createdRoom.member!!.id,
genderRestriction = createdRoom.genderRestriction
)
)
@@ -432,6 +504,10 @@ class LiveRoomService(
throw SodaException(messageKey = "live.room.adult_verification_required")
}
if (!member.canEnter(room.genderRestriction) && room.member!!.id!! != member.id!!) {
throw SodaException(messageKey = "live.room.gender_restricted")
}
val beginDateTime = room.beginDateTime
.atZone(ZoneId.of("UTC"))
.withZoneSameInstant(ZoneId.of(timezone))
@@ -440,12 +516,16 @@ class LiveRoomService(
val beginDateTimeUtc = room.beginDateTime
.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
val languageTagByMemberId = buildLanguageTagMap(listOfNotNull(room.member?.id))
val response = GetRoomDetailResponse(
roomId = roomId,
title = room.title,
notice = room.notice,
price = room.price,
tags = room.tags.asSequence().filter { it.tag.isActive }.map { it.tag.tag }.toList(),
tags = room.tags
.filter { it.tag.isActive }
.map { it.tag.tag }
.let { tags -> applyLanguageTagToRoomTags(room.member?.id, tags, languageTagByMemberId) },
numberOfParticipantsTotal = room.numberOfPeople,
numberOfParticipants = 0,
channelName = room.channelName,
@@ -454,6 +534,7 @@ class LiveRoomService(
isPaid = false,
isAdult = room.isAdult,
isPrivateRoom = room.type == LiveRoomType.PRIVATE,
genderRestriction = room.genderRestriction,
password = room.password
)
response.manager = GetRoomDetailManager(room.member!!, cloudFrontHost = cloudFrontHost)
@@ -573,7 +654,8 @@ class LiveRoomService(
isAuth = room.isAdult,
isAvailableJoinCreator = room.isAvailableJoinCreator,
roomId = room.id,
creatorId = room.member!!.id
creatorId = room.member!!.id,
genderRestriction = room.genderRestriction
)
)
}
@@ -672,6 +754,10 @@ class LiveRoomService(
)
}
if (room.member!!.id!! != member.id!! && !member.canEnter(room.genderRestriction)) {
throw SodaException(messageKey = "live.room.gender_restricted")
}
val lock = getOrCreateLock(memberId = member.id!!)
lock.write {
var roomInfo = roomInfoRepository.findByIdOrNull(request.roomId)
@@ -786,6 +872,10 @@ class LiveRoomService(
room.isAdult = request.isAdult
}
if (request.genderRestriction != null) {
room.genderRestriction = request.genderRestriction
}
if (request.isActiveMenuPan != null) {
if (request.isActiveMenuPan) {
if (request.menuPanId > 0) {
@@ -827,7 +917,7 @@ class LiveRoomService(
agoraAppId,
agoraAppCertificate,
room.channelName!!,
member.id!!.toInt(),
member.id!!.toString(),
expireTimestamp.toInt()
)
@@ -838,6 +928,14 @@ class LiveRoomService(
expireTimestamp.toInt()
)
val v2vWorkerToken = rtcTokenBuilder.buildTokenWithUid(
agoraAppId,
agoraAppCertificate,
room.channelName!!,
"${member.id!!}333",
expireTimestamp.toInt()
)
val isFollowing = explorerQueryRepository
.getNotificationUserIds(room.member!!.id!!)
.contains(member.id)
@@ -865,6 +963,12 @@ class LiveRoomService(
}
val menuPan = menuService.getLiveMenu(creatorId = room.member!!.id!!)
val creatorLanguageCode = pushTokenRepository.findByMemberId(room.member!!.id!!)
.filterNot { it.languageCode.isNullOrBlank() }
.maxByOrNull { it.updatedAt ?: LocalDateTime.MIN }
?.languageCode
?.lowercase()
?.take(2)
return GetRoomInfoResponse(
roomId = roomId,
@@ -886,6 +990,7 @@ class LiveRoomService(
channelName = room.channelName!!,
rtcToken = rtcToken,
rtmToken = rtmToken,
v2vWorkerToken = v2vWorkerToken,
creatorId = room.member!!.id!!,
creatorNickname = room.member!!.nickname,
creatorProfileUrl = if (room.member!!.profileImage != null) {
@@ -902,6 +1007,7 @@ class LiveRoomService(
managerList = roomInfo.managerList,
donationRankingTop3UserIds = donationRankingTop3UserIds,
menuPan = menuPan?.menu ?: "",
creatorLanguageCode = creatorLanguageCode,
isPrivateRoom = room.type == LiveRoomType.PRIVATE,
password = room.password,
isActiveRoulette = isActiveRoulette

View File

@@ -1,5 +1,6 @@
package kr.co.vividnext.sodalive.live.room.detail
import kr.co.vividnext.sodalive.live.room.GenderRestriction
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
@@ -11,6 +12,7 @@ data class GetRoomDetailResponse(
var isPaid: Boolean,
val isAdult: Boolean,
val isPrivateRoom: Boolean,
val genderRestriction: GenderRestriction,
val password: String?,
val tags: List<String>,
val channelName: String?,

View File

@@ -8,6 +8,7 @@ data class GetRoomInfoResponse(
val channelName: String,
val rtcToken: String,
val rtmToken: String,
val v2vWorkerToken: String,
val creatorId: Long,
val creatorNickname: String,
val creatorProfileUrl: String,
@@ -20,6 +21,7 @@ data class GetRoomInfoResponse(
val managerList: List<LiveRoomMember>,
val donationRankingTop3UserIds: List<Long>,
val menuPan: String,
val creatorLanguageCode: String?,
val isPrivateRoom: Boolean = false,
val password: String? = null,
val isActiveRoulette: Boolean = false

View File

@@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.member
import kr.co.vividnext.sodalive.common.BaseEntity
import kr.co.vividnext.sodalive.explorer.GetExplorerSectionCreatorResponse
import kr.co.vividnext.sodalive.live.room.GenderRestriction
import kr.co.vividnext.sodalive.member.auth.Auth
import kr.co.vividnext.sodalive.member.following.CreatorFollowing
import kr.co.vividnext.sodalive.member.notification.MemberNotification
@@ -46,6 +47,9 @@ data class Member(
var isVisibleDonationRank: Boolean = true,
@Enumerated(value = EnumType.STRING)
var donationRankingPeriod: DonationRankingPeriod? = DonationRankingPeriod.CUMULATIVE,
var isActive: Boolean = true,
var container: String = "web",
@@ -148,6 +152,22 @@ data class Member(
follow = follow
)
}
fun canEnter(restriction: GenderRestriction): Boolean {
val effectiveGender = if (auth != null) {
if (auth!!.gender == 1) Gender.MALE else Gender.FEMALE
} else {
gender
}
if (effectiveGender == Gender.NONE) return true
return when (restriction) {
GenderRestriction.ALL -> true
GenderRestriction.MALE_ONLY -> effectiveGender == Gender.MALE
GenderRestriction.FEMALE_ONLY -> effectiveGender == Gender.FEMALE
}
}
}
enum class Gender {
@@ -161,3 +181,7 @@ enum class MemberRole {
enum class MemberProvider {
EMAIL, KAKAO, GOOGLE, APPLE, LINE
}
enum class DonationRankingPeriod {
WEEKLY, CUMULATIVE
}

View File

@@ -5,6 +5,7 @@ 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.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.member.QMember.member
import kr.co.vividnext.sodalive.member.auth.QAuth.auth
@@ -35,14 +36,16 @@ interface MemberQueryRepository {
fun getCreateLiveRoomNotificationRecipientPushTokens(
creatorId: Long,
isAuth: Boolean,
isAvailableJoinCreator: Boolean
isAvailableJoinCreator: Boolean,
genderRestriction: GenderRestriction? = null
): List<PushTokenInfo>
fun getStartLiveRoomNotificationRecipientPushTokens(
creatorId: Long,
roomId: Long,
isAuth: Boolean,
isAvailableJoinCreator: Boolean
isAvailableJoinCreator: Boolean,
genderRestriction: GenderRestriction? = null
): List<PushTokenInfo>
fun getUploadContentNotificationRecipientPushTokens(
@@ -132,7 +135,8 @@ class MemberQueryRepositoryImpl(
override fun getCreateLiveRoomNotificationRecipientPushTokens(
creatorId: Long,
isAuth: Boolean,
isAvailableJoinCreator: Boolean
isAvailableJoinCreator: Boolean,
genderRestriction: GenderRestriction?
): List<PushTokenInfo> {
val member = QMember.member
val creator = QMember.member
@@ -158,6 +162,10 @@ class MemberQueryRepositoryImpl(
where = where.and(creatorFollowing.member.role.ne(MemberRole.CREATOR))
}
if (genderRestriction != null && genderRestriction != GenderRestriction.ALL) {
where = where.and(getGenderCondition(genderRestriction))
}
return queryFactory
.select(
QPushTokenInfo(
@@ -180,7 +188,8 @@ class MemberQueryRepositoryImpl(
creatorId: Long,
roomId: Long,
isAuth: Boolean,
isAvailableJoinCreator: Boolean
isAvailableJoinCreator: Boolean,
genderRestriction: GenderRestriction?
): List<PushTokenInfo> {
val member = QMember.member
val creator = QMember.member
@@ -206,6 +215,10 @@ class MemberQueryRepositoryImpl(
where = where.and(creatorFollowing.member.role.ne(MemberRole.CREATOR))
}
if (genderRestriction != null && genderRestriction != GenderRestriction.ALL) {
where = where.and(getGenderCondition(genderRestriction))
}
val followingMemberPushToken = queryFactory
.select(
QPushTokenInfo(
@@ -237,6 +250,10 @@ class MemberQueryRepositoryImpl(
where = where.and(auth.isNotNull)
}
if (genderRestriction != null && genderRestriction != GenderRestriction.ALL) {
where = where.and(getGenderCondition(genderRestriction, liveReservation.member))
}
val reservationMemberPushToken = queryFactory
.select(
QPushTokenInfo(
@@ -256,6 +273,33 @@ class MemberQueryRepositoryImpl(
return (followingMemberPushToken + reservationMemberPushToken).distinctBy { it.token }
}
private fun getGenderCondition(
genderRestriction: GenderRestriction,
qMember: QMember = member
) = when (genderRestriction) {
GenderRestriction.MALE_ONLY -> {
auth.isNotNull.and(auth.gender.eq(1))
.or(
auth.isNull.and(
qMember.gender.eq(Gender.MALE)
.or(qMember.gender.eq(Gender.NONE))
)
)
}
GenderRestriction.FEMALE_ONLY -> {
auth.isNotNull.and(auth.gender.eq(0))
.or(
auth.isNull.and(
qMember.gender.eq(Gender.FEMALE)
.or(qMember.gender.eq(Gender.NONE))
)
)
}
else -> null
}
override fun getUploadContentNotificationRecipientPushTokens(
creatorId: Long,
isAuth: Boolean

View File

@@ -130,7 +130,7 @@ class MemberService(
duplicateCheckEmail(request.email)
validatePassword(request.password)
val nickname = nicknameGenerateService.generateUniqueNickname()
val nickname = nicknameGenerateService.generateUniqueNickname(langContext.lang)
val member = Member(
email = request.email,
password = passwordEncoder.encode(request.password),
@@ -726,6 +726,10 @@ class MemberService(
member.isVisibleDonationRank = profileUpdateRequest.isVisibleDonationRank
}
if (profileUpdateRequest.donationRankingPeriod != null) {
member.donationRankingPeriod = profileUpdateRequest.donationRankingPeriod
}
return ProfileResponse(member, cloudFrontHost, profileUpdateRequest.container)
}
@@ -846,7 +850,7 @@ class MemberService(
val email = googleUserInfo.email
checkEmail(email)
val nickname = nicknameGenerateService.generateUniqueNickname()
val nickname = nicknameGenerateService.generateUniqueNickname(langContext.lang)
val member = Member(
googleId = googleUserInfo.sub,
email = email,
@@ -903,7 +907,7 @@ class MemberService(
val email = kakaoUserInfo.email
checkEmail(email)
val nickname = nicknameGenerateService.generateUniqueNickname()
val nickname = nicknameGenerateService.generateUniqueNickname(langContext.lang)
val member = Member(
kakaoId = kakaoUserInfo.id,
email = email,
@@ -960,7 +964,7 @@ class MemberService(
val email = appleUserInfo.email
checkEmail(email)
val nickname = nicknameGenerateService.generateUniqueNickname()
val nickname = nicknameGenerateService.generateUniqueNickname(langContext.lang)
val member = Member(
appleId = appleUserInfo.sub,
email = email,
@@ -1017,7 +1021,7 @@ class MemberService(
val email = lineUserInfo.email
checkEmail(email)
val nickname = nicknameGenerateService.generateUniqueNickname()
val nickname = nicknameGenerateService.generateUniqueNickname(langContext.lang)
val member = Member(
lineId = lineUserInfo.sub,
email = email,

View File

@@ -14,5 +14,6 @@ data class ProfileUpdateRequest(
val websiteUrl: String? = null,
val blogUrl: String? = null,
val isVisibleDonationRank: Boolean? = null,
val donationRankingPeriod: DonationRankingPeriod? = null,
val container: String
)

View File

@@ -1,6 +1,7 @@
package kr.co.vividnext.sodalive.member.nickname
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.member.MemberRepository
import org.springframework.stereotype.Service
import kotlin.random.Random
@@ -8,59 +9,121 @@ import kotlin.random.Random
@Service
class NicknameGenerateService(private val repository: MemberRepository) {
private val adjectives = listOf(
"따뜻한", "은은", "고요", "푸른", "맑은", "강한", "평온", "깊은", "고독한",
"거친", "빛바랜", "차가운", "꿈꾸는", "숨겨", "고귀", "깨어난", "끝없는", "청명한",
"어두운", "희미", "명한", "눈부신", "불타는", "차분한", "아련한", "선선한", "상쾌한", "온화한",
"포근", "황금빛", "청량", "시원한", "서늘", "우아", "단단한", "투명한", "가벼운", "조용한",
"화려", "찬란", "순수한", "흐릿", "고결", "달콤", "무한", "아득한", "화사한", "평안한",
"웅장한", "황홀한", "빛나는", "쓸쓸", "청순한", "흐르는", "미묘", "그윽", "몽롱", "청아한",
"섬세한", "촉촉한", "강렬한", "싱싱한", "부드런", "아늑한", "매운듯", "고운듯", "느린듯", "밝은듯",
"짧은듯", "달큰", "깊은듯", "기쁜듯", "쌀쌀한", "무거운", "연한듯", "편안", "깨끗한", "말끔",
"뽀얀듯", "푸르른", "붉은", "노오란", "무딘듯", "살짝한", "상큼한", "시큰한", "고소", "나긋한",
"화끈한", "중후", "정겨운", "날렵한", "기묘한", "참신한", "담백", "퉁명한", "꾸밈없", "소박한",
"뾰족", "무심", "도도", "따끔", "무난한", "단호한", "냉정", "따스", "유연", "묵직",
"나른", "몽환적", "돈된", "쾌활", "날카론", "묘한듯", "예쁜듯", "뽀얗게", "다정", "푸근",
"애틋", "낭만적", "", "훈훈", "섹시", "정적인", "유쾌한", "멍한듯", "혼란", "상냥한",
"뚜렷한", "신비한", "허전", "그리운", "들뜬듯", "절실", "반듯", "반가운", "새하얀", "흐린듯",
"엄숙", "깊잖은", "산뜻", "낯선듯"
"활기찬", "명랑", "씩씩", "용감한", "지혜론", "슬기론", "넉넉", "든든한", "알찬듯",
"빠른듯", "느긋한", "당당한", "솔직한", "실한", "겸손", "성실한", "꼼꼼한", "야무진",
"재빠른", "영리", "명한", "현명한", "착실한", "올곧은", "바른듯", "곧은듯", "힘찬듯", "굳센듯",
"의젓", "점잖은", "듬직", "너그런", "관대", "인자", "자애론", "헌신적", "열정적", "적극적",
"능숙", "탁월", "뛰어난", "출중", "비범", "특별", "독특", "개성적", "창의적", "혁신적",
"진취적", "도전적", "패기찬", "호쾌", "시원찬", "통쾌한", "유능", "민첩", "기민", "재치론",
"센스찬", "감각적", "세련된", "품격찬", "격조찬", "기품찬", "위풍찬", "늠름한", "씩씩찬", "호탕한",
"대범한", "거뜬", "가뿐한", "홀가분", "산뜻찬", "깔끔찬", "정갈한", "단정", "반짝찬", "영롱",
"찬란찬", "눈부찬", "환한", "밝은찬", "빛깔찬", "색다른", "새로운", "신선찬", "풋풋", "싱그런",
"생기찬", "발랄", "경쾌한", "리듬찬", "율동적", "역동적", "활발", "생동찬", "약동찬", "힘있는",
"건장", "튼튼", "건강", "탄탄", "단련된", "숙련된", "노련", "원숙", "성숙", "완숙",
"정확", "치밀한", "밀한", "철저", "완벽찬", "흠없는", "나무랄", "빈틈없", "알뜰", "꾸준",
"결찬", "변함없", "건한", "확고", "견고", "탄탄찬", "안정적", "평화론", "온유", "자비론",
"배려찬", "사려찬", "깊숙", "심오한", "오묘한", "현묘", "신묘", "경이론", "놀라운", "대단한",
"훌륭", "멋스런", "근사", "기특한"
)
private val nouns = listOf(
"소리", "울림", "공명", "음색", "감성", "리듬", "바람", "늑대", "태양", "대지",
"", "하늘", "불꽃", "별빛", "나무", "", "달빛", "폭풍", "", "",
"노을", "물결", "노래", "파도", "구름", "사슴", "신비", "영혼", "선율", "평원",
"", "고래", "모래", "사자", "표범", "여우", "", "수달", "판다", "들소",
"까치", "", "솔개", "물총새", "철새", "황새", "은어", "붕어", "산양", "",
"설표", "물개", "자라", "나비", "노루", "해마", "백조", "청어", "호수", "",
"쿼카", "상어", "무드", "나노", "루프", "네온", "모아", "아토", "플로", "루미",
"도트", "비트", "토브", "온기", "클리", "위드", "제로", "", "미오", "시그",
"쿠나", "오로", "폴라", "바움", "포잇", "누아", "오브", "파인", "조이", "아뜰",
"티노", "소마", "하루", "밀크", "아린", "토로", "벨로", "위시", "뮤즈", "노블",
"카노", "미카", "하라", "엘로", "피오", "라임", "노이", "루다", "이브", "마리",
"블루", "시온", "레아", "도르", "하노", "네리", "키노", "쿠키", "라노", "수이",
"우노", "파루", "크리", "포유", "코코", "아라", "토리", "", "보노", "페어",
"", "모리", "세리", "리브", "", "모카", "아이", "르네", "이로", "미노",
"다라", "노바", "디노", "오미", "카라", "", "루아", "네오", "하이", "레인",
"피카", "유카", "제니", "이든", "", "아벨", "솔라", "쿠로", "시라", "리코"
"다람쥐", "청설모", "두루미", "기러기", "올빼미", "부엉이", "딱따구", "꾀꼬리", "직박구", "동박새",
"참새", "종달새", "제비", "뻐꾸기", "앵무새", "공작새", "원앙", "두더지", "고슴도", "족제비",
"오소리", "수리부", "해오라", "갈매기", "펭귄", "코알라", "알파카", "카멜레", "이구아", "플라밍",
"돌고래", "해달", "라쿤", "미어캣", "친칠라", "햄스터", "기니피", "토끼", "강아지", "고양이",
"망아지", "송아지", "병아리", "올챙이", "개구리", "도롱뇽", "거북이", "앵무", "카나리", "둘기",
"참매", "독수리", "콘도르", "벌새", "홍학", "타조", "키위새", "투칸", "앵콩이", "까치",
"루비", "사파이", "에메랄", "자수정", "진주", "산호", "호박", "비취", "오팔", "토파즈",
"다이아", "크리스", "아쿠아", "코발트", "인디고", "라벤더", "마젠타", "터콰", "세룰리", "버밀리",
"카푸치", "에스프", "아메리", "마키아", "바닐라", "캐러멜", "시나몬", "민트", "자스민", "캐모마",
"히비스", "라일락", "프리지", "튤립", "수선화", "동백", "매화", "목련", "벚꽃", "진달래",
"철쭉", "개나리", "무궁화", "해바라", "코스모", "달리아", "작약", "모란", "연꽃", "수련",
"클로버", "민들레", "제비꽃", "은방울", "안개꽃", "라넌큘", "아네모", "델피니", "글라디", "프로테",
"유칼립", "로즈마", "바질", "타임", "오레가", "세이지", "", "파슬", "고수", "루꼴라",
"보카", "블루베", "라즈베", "크랜베", "아사", "망고", "파파야", "리치", "패션후", "구아바",
"석류", "무화과", "살구", "자두", "체리", "복숭", "포도", "감귤", "유자", "한라봉",
"천혜향", "레드향", "금귤", "모과", "", "대추", "", "호두", "", "은행"
)
private fun generateRandomNickname(): String {
private val jaAdjectives = listOf(
"元気な", "明るい", "優しい", "強い", "賢い", "穏やかな", "爽やかな", "楽しい",
"勇敢な", "素敵な", "可愛い", "美しい", "清らかな", "温かい", "輝く", "華やかな",
"凛とした", "朗らかな", "逞しい", "麗しい", "雅な", "粋な", "健やかな", "晴れやかな",
"鮮やかな", "煌めく", "微笑む", "誠実な", "丁寧な", "真っ直ぐな", "気高い", "聡明な",
"快活な", "軽やかな", "しなやかな", "伸びやかな", "瑞々しい", "初々しい", "艶やかな", "柔らかな",
"澄んだ", "静かな", "豊かな", "深い", "広い", "高い", "清い", "涼しい",
"眩しい", "暖かな", "和やかな", "安らかな", "のどかな", "ほがらかな", "すこやかな", "たくましい",
"ひたむきな", "まめな", "きらきらな", "ふわふわな", "にこにこな", "わくわくな", "すくすくな", "のびのびな",
"きりっとした", "はきはきな", "てきぱきな", "しっかりな", "どっしりな", "ゆったりな", "さっぱりな", "すっきりな",
"ぴかぴかな", "つやつやな", "さらさらな", "もちもちな", "ぷるぷるな", "ころころな", "ぽかぽかな", "そよそよな",
"堅実な", "勤勉な", "忠実な", "素直な", "謙虚な", "大胆な", "情熱的な", "積極的な",
"独創的な", "繊細な", "壮大な", "格調高い", "品のある", "風格ある", "趣のある", "奥深い",
"颯爽とした", "堂々とした", "悠々とした", "泰然とした", "毅然とした", "端正な", "清楚な", "典雅な",
"俊敏な", "機敏な", "敏捷な", "軽快な", "活発な", "溌剌とした", "生き生きな", "伸び伸びな",
"揺るぎない", "確かな", "頼もしい", "心強い", "力強い", "逞しき", "雄々しい", "凜々しい",
"慈しみの", "思いやりの", "気配りの", "心優しい", "情け深い", "懐の深い", "器の大きい", "包容力の"
)
private val jaNouns = listOf(
"うさぎ", "ねこ", "いぬ", "たぬき", "きつね", "しか", "りす", "ふくろう",
"つばめ", "すずめ", "ひばり", "うぐいす", "めじろ", "つる", "はと", "かもめ",
"いるか", "くじら", "らっこ", "ペンギン", "コアラ", "パンダ", "アルパカ", "ハムスター",
"かめ", "かえる", "ほたる", "ちょう", "とんぼ", "てんとう", "こねこ", "こいぬ",
"ひよこ", "こじか", "こぐま", "こうさぎ", "こりす", "こだぬき", "こぎつね", "こばと",
"さくら", "うめ", "もみじ", "つばき", "すみれ", "たんぽぽ", "ひまわり", "あじさい",
"コスモス", "ラベンダー", "チューリップ", "カーネーション", "バラ", "ユリ", "ダリア", "マーガレット",
"なでしこ", "あやめ", "ききょう", "はぎ", "ふじ", "ぼたん", "しゃくやく", "れんげ",
"ルビー", "サファイア", "エメラルド", "アメジスト", "パール", "オパール", "トパーズ", "ガーネット",
"ひかり", "そら", "うみ", "かぜ", "つき", "ほし", "にじ", "ゆめ",
"あかね", "みずき", "はるか", "あおい", "ひなた", "こはる", "いろは", "かなで",
"しずく", "つゆ", "あられ", "みぞれ", "こはく", "あかり", "ともしび", "かがやき",
"やまと", "みやび", "まこと", "ちはや", "あさひ", "ゆうひ", "あけぼの", "たそがれ",
"わかば", "あおば", "もえぎ", "ときわ", "さつき", "やよい", "きさらぎ", "むつき",
"抹茶", "桜餅", "団子", "大福", "最中", "羊羹", "煎餅", "饅頭",
"柚子", "梅干し", "味噌", "醤油", "わさび", "生姜", "山椒", "昆布",
"風鈴", "提灯", "扇子", "千鶴", "折鶴", "手毬", "万華鏡", "花火",
"雪うさぎ", "だるま", "こけし", "招き猫", "風車", "独楽", "竹とんぼ", "紙風船",
"朝露", "夕凪", "木漏れ日", "花吹雪", "月明かり", "星空", "天の川", "春風",
"小春日和", "花曇り", "薄紅", "若草", "深緑", "紺碧", "茜色", "藤色"
)
private fun getAdjectives(lang: Lang): List<String> = when (lang) {
Lang.JA -> jaAdjectives
else -> adjectives
}
private fun getNouns(lang: Lang): List<String> = when (lang) {
Lang.JA -> jaNouns
else -> nouns
}
private fun getParticle(lang: Lang): String = when (lang) {
Lang.JA -> ""
else -> ""
}
private fun generateRandomNickname(lang: Lang): String {
val adj = getAdjectives(lang)
val noun = getNouns(lang)
val particle = getParticle(lang)
val formatType = Random.nextInt(3)
return when (formatType) {
0 -> "${adjectives.random()}${nouns.random()}"
1 -> "${nouns.random()}${nouns.random()}"
else -> "${adjectives.random()}${nouns.random()}${nouns.random()}"
0 -> "${adj.random()}${noun.random()}"
1 -> "${noun.random()}${particle}${noun.random()}"
else -> "${adj.random()}${noun.random()}${particle}${noun.random()}"
}
}
private fun generateNonConflictingNickname(usedNicknames: Set<String>): String {
val usedNicknameSet = HashSet(usedNicknames) // 해시셋으로 변환 (O(1) 조회 가능)
private fun generateNonConflictingNickname(usedNicknames: Set<String>, lang: Lang): String {
val usedNicknameSet = HashSet(usedNicknames)
val availableNumbers = (1000..9999).shuffled()
val adj = getAdjectives(lang)
val noun = getNouns(lang)
for (num in availableNumbers) { // 숫자를 먼저 결정 (무작위)
for (adj in adjectives.shuffled()) { // 형용사 순서 랜덤화
for (noun in nouns.shuffled()) { // 명사 순서 랜덤화
val candidate = "$adj$noun$num"
for (num in availableNumbers) {
for (a in adj.shuffled()) {
for (n in noun.shuffled()) {
val candidate = "$a$n$num"
if (!usedNicknameSet.contains(candidate)) {
return candidate
}
@@ -70,13 +133,13 @@ class NicknameGenerateService(private val repository: MemberRepository) {
throw SodaException(messageKey = "member.signup.failed_retry")
}
fun generateUniqueNickname(): String {
fun generateUniqueNickname(lang: Lang = Lang.KO): String {
repeat(5) {
val candidates = (1..10).map { generateRandomNickname() }
val candidates = (1..10).map { generateRandomNickname(lang) }
val available = candidates.firstOrNull { !repository.existsByNickname(it) }
if (available != null) return available
}
return generateNonConflictingNickname(repository.findNicknamesWithPrefix("").toSet())
return generateNonConflictingNickname(repository.findNicknamesWithPrefix("").toSet(), lang)
}
}