Compare commits

...

45 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
6e0b3ddf8e LINE 로그인 지원 추가
회원 로그인에 LINE 공급자를 추가한다
2026-01-28 20:07:14 +09:00
81f3bc0bad 애플 로그인 검증 로직 추가 2026-01-27 10:09:20 +09:00
8957fd5c3f 예외 로그 한글 표시 2026-01-26 09:14:43 +09:00
f778f68f1f 소셜 로그인 이메일 미필수 정책 적용
소셜 로그인 시 이메일 동의 없이도 계정 생성이 가능하도록 변경합니다.
Member 엔티티의 email 필드를 선택 사항으로 변경하고, 관련 API 응답 및 인증 로직에서 이메일이 없는 경우에 대한 처리를 추가합니다.
2026-01-26 08:56:05 +09:00
744afd7f45 소셜 로그인 리졸버 도입 2026-01-26 07:16:16 +09:00
e1bf54c74b HomeService의 최신 콘텐츠 테마 목록에서 다시듣기 제외
홈 화면의 최신 콘텐츠 테마 리스트(latestContentThemeList)에서
'다시듣기' 테마를 제외하도록 수정한다.
일본어 및 영어 번역이 적용되기 전에 필터링을 수행하여
다양한 언어 환경에서도 정상적으로 제외되도록 보장한다.
AudioContentThemeService의 getActiveThemeOfContent 메서드에
테마 제외 옵션을 추가하여 필요한 곳에서만 선택적으로 사용할 수 있게 한다.
2026-01-22 18:09:49 +09:00
f53dcc32bd 채팅 캐릭터 등록 - 리전 등록 기능 추가 2026-01-22 15:31:02 +09:00
65fc47eff0 라이브 예약 반환 값 - beginDateTimeUtc 추가 2026-01-21 17:50:11 +09:00
36a38d6c78 라이브 예약 Response에 utc 시간 변수 beginDateTimeUtc 추가 2026-01-21 15:33:53 +09:00
0da958f6d4 충전 이벤트 - langContext 문제로 충전이 되지 않는 현상을 langContext를 사용하지 않고 이전 방식으로 기록하도록 롤백하여 임시 해결 2026-01-21 11:23:24 +09:00
ba27cc1fbd 라이브 방 상세 - 날짜 포맷 변경으로 유료방 입장이 불가한 문제를 해결하기 위해 이전으로 롤백 2026-01-21 11:01:42 +09:00
a41bfaa037 라이브 룸 일시 포맷에 다국어 설정 적용
LiveRoomService에서 하드코딩된 날짜 포맷과 Locale을 제거하고,

LangContext를 통해 클라이언트 언어 설정에 따른 포맷과 Locale을

사용하도록 수정한다.
2026-01-20 19:32:57 +09:00
482241f734 memberId가 특정 번호일 때 currency와 관계없이 모든 구매 가능한 캔이 출력되도록 수정 2026-01-16 11:24:48 +09:00
ed2660adc6 푸시 알림 전송 언어 처리 2026-01-15 17:21:22 +09:00
9dc23f0622 회원 국가 코드 저장 2026-01-14 15:34:24 +09:00
b07eada277 국가 컨텍스트로 캔 조회 2026-01-14 15:21:33 +09:00
6683b40425 크리에이터 콘텐츠 - 본인(크리에이터)만 오픈예정 콘텐츠가 보이도록 설정 변경 2026-01-12 11:24:51 +09:00
435010d523 크리에이터 콘텐츠 - 본인(크리에이터)만 오픈예정 콘텐츠가 보이도록 설정 변경 2026-01-12 11:03:48 +09:00
aa9a0bbe82 캔 사용 시 국가 코드에 어떤 표준 국가 코드인지 주석 추가 2026-01-09 11:54:21 +09:00
9b0d1b43d5 캔 사용 시 국가 코드 기록 기능 추가
CloudFront-Viewer-Country 헤더를 통해 국가 코드를 수집하고 캔 사용 내역(UseCan) 저장 시 함께 기록하도록 수정
요청별 국가 정보 관리를 위한 컨텍스트와 인터셉터를 구현
2026-01-09 11:51:42 +09:00
68b5ed7cc2 번역 이벤트 커밋 후 처리 분기 2026-01-07 18:45:53 +09:00
d07c1cc6db 업로드 알림 문구 변경 2026-01-07 16:22:56 +09:00
3d9fa4e88f 일본어 메시지 수정 2026-01-07 16:20:25 +09:00
101 changed files with 1998 additions and 892 deletions

View File

@@ -41,6 +41,8 @@ dependencies {
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.11.5")
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.11.5")
implementation("com.nimbusds:nimbus-jose-jwt:9.37.3")
// querydsl (추가 설정)
implementation("com.querydsl:querydsl-jpa:$querydslVersion")
kapt("com.querydsl:querydsl-apt:$querydslVersion:jpa")

View File

@@ -7,8 +7,6 @@ import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.fcm.FcmEvent
import kr.co.vividnext.sodalive.fcm.FcmEventType
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.utils.generateFileName
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.ApplicationEventPublisher
@@ -24,8 +22,6 @@ class AdminAuditionService(
private val repository: AdminAuditionRepository,
private val roleRepository: AdminAuditionRoleRepository,
private val applicationEventPublisher: ApplicationEventPublisher,
private val langContext: LangContext,
private val messageSource: SodaMessageSource,
@Value("\${cloud.aws.s3.bucket}")
private val bucket: String
@@ -92,14 +88,12 @@ class AdminAuditionService(
}
if (request.status != null && request.status == AuditionStatus.IN_PROGRESS && audition.isActive) {
val title = messageSource.getMessage("admin.audition.fcm.title.new", langContext.lang).orEmpty()
val messageTemplate = messageSource.getMessage("admin.audition.fcm.message.new", langContext.lang).orEmpty()
val message = String.format(messageTemplate, audition.title)
applicationEventPublisher.publishEvent(
FcmEvent(
type = FcmEventType.IN_PROGRESS_AUDITION,
title = title,
message = message,
titleKey = "admin.audition.fcm.title.new",
messageKey = "admin.audition.fcm.message.new",
args = listOf(audition.title),
isAuth = audition.isAdult,
auditionId = audition.id ?: -1
)

View File

@@ -148,6 +148,7 @@ class AdminChatCharacterController(
runCatching { CharacterType.valueOf(it) }
.getOrDefault(CharacterType.Character)
} ?: CharacterType.Character,
region = request.region,
tags = request.tags,
values = request.values,
hobbies = request.hobbies,
@@ -203,6 +204,7 @@ class AdminChatCharacterController(
body["name"] = request.name
body["systemPrompt"] = request.systemPrompt
body["description"] = request.description
body["region"] = request.region
request.age?.let { body["age"] = it }
request.gender?.let { body["gender"] = it }
request.mbti?.let { body["mbti"] = it }
@@ -352,7 +354,8 @@ class AdminChatCharacterController(
applicationEventPublisher.publishEvent(
LanguageTranslationEvent(
id = request.id,
targetType = LanguageTranslationTargetType.CHARACTER
targetType = LanguageTranslationTargetType.CHARACTER,
waitTransactionCommit = true
)
)

View File

@@ -20,6 +20,7 @@ data class ChatCharacterDetailResponse(
val speechPattern: String?,
val speechStyle: String?,
val appearance: String?,
val region: String,
val isActive: Boolean,
val tags: List<String>,
val hobbies: List<String>,
@@ -67,6 +68,7 @@ data class ChatCharacterDetailResponse(
speechPattern = chatCharacter.speechPattern,
speechStyle = chatCharacter.speechStyle,
appearance = chatCharacter.appearance,
region = chatCharacter.region,
isActive = chatCharacter.isActive,
tags = chatCharacter.tagMappings.map { it.tag.tag },
hobbies = chatCharacter.hobbyMappings.map { it.hobby.hobby },

View File

@@ -38,6 +38,7 @@ data class ChatCharacterRegisterRequest(
@JsonProperty("speechPattern") val speechPattern: String?,
@JsonProperty("speechStyle") val speechStyle: String?,
@JsonProperty("appearance") val appearance: String?,
@JsonProperty("region") val region: String = "KR",
@JsonProperty("originalTitle") val originalTitle: String? = null,
@JsonProperty("originalLink") val originalLink: String? = null,
@JsonProperty("originalWorkId") val originalWorkId: Long? = null,

View File

@@ -14,6 +14,7 @@ data class ChatCharacterListResponse(
val mbti: String?,
val speechStyle: String?,
val speechPattern: String?,
val region: String,
val tags: List<String>,
val createdAt: String?,
val updatedAt: String?
@@ -48,6 +49,7 @@ data class ChatCharacterListResponse(
mbti = chatCharacter.mbti,
speechStyle = chatCharacter.speechStyle,
speechPattern = chatCharacter.speechPattern,
region = chatCharacter.region,
tags = chatCharacter.tagMappings.map { it.tag.tag },
createdAt = createdAtStr,
updatedAt = updatedAtStr

View File

@@ -95,7 +95,8 @@ class AdminOriginalWorkService(
applicationEventPublisher.publishEvent(
LanguageTranslationEvent(
id = originalWork.id!!,
targetType = LanguageTranslationTargetType.ORIGINAL_WORK
targetType = LanguageTranslationTargetType.ORIGINAL_WORK,
waitTransactionCommit = true
)
)
}
@@ -165,7 +166,8 @@ class AdminOriginalWorkService(
applicationEventPublisher.publishEvent(
LanguageTranslationEvent(
id = ow.id!!,
targetType = LanguageTranslationTargetType.ORIGINAL_WORK
targetType = LanguageTranslationTargetType.ORIGINAL_WORK,
waitTransactionCommit = true
)
)
}

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

@@ -47,7 +47,8 @@ class AdminContentThemeService(
applicationEventPublisher.publishEvent(
LanguageTranslationEvent(
id = savedTheme.id!!,
targetType = LanguageTranslationTargetType.CONTENT_THEME
targetType = LanguageTranslationTargetType.CONTENT_THEME,
waitTransactionCommit = true
)
)
}

View File

@@ -321,20 +321,17 @@ class AdminLiveService(
}
// 예약현황 취소
val pushTokenListMap = memberRepository.getPushTokenFromReservationList(room.id!!)
val pushTokens = memberRepository.getPushTokenFromReservationList(room.id!!)
reservationRepository.cancelReservation(roomId = room.id!!)
// 라이브 취소 푸시 발송
val cancelMessageTemplate = messageSource
.getMessage("live.room.fcm.message.canceled", langContext.lang)
.orEmpty()
val cancelMessage = String.format(cancelMessageTemplate, room.title)
applicationEventPublisher.publishEvent(
FcmEvent(
type = FcmEventType.CANCEL_LIVE,
title = room.member!!.nickname,
message = cancelMessage,
recipientsMap = pushTokenListMap
messageKey = "live.room.fcm.message.canceled",
args = listOf(room.title),
pushTokens = pushTokens
)
)
}

View File

@@ -108,6 +108,7 @@ class AdminMemberService(
MemberProvider.KAKAO -> messageSource.getMessage("member.provider.kakao", langContext.lang).orEmpty()
MemberProvider.GOOGLE -> messageSource.getMessage("member.provider.google", langContext.lang).orEmpty()
MemberProvider.APPLE -> messageSource.getMessage("member.provider.apple", langContext.lang).orEmpty()
MemberProvider.LINE -> messageSource.getMessage("member.provider.line", langContext.lang).orEmpty()
}
val signUpDate = it.createdAt!!
@@ -126,7 +127,7 @@ class AdminMemberService(
GetAdminMemberListResponseItem(
id = it.id!!,
email = it.email,
email = it.email ?: "",
nickname = it.nickname,
profileUrl = if (it.profileImage != null) {
"$cloudFrontHost/${it.profileImage}"
@@ -160,6 +161,7 @@ class AdminMemberService(
val member = repository.findByIdAndActive(memberId = request.memberId)
?: throw SodaException(messageKey = "admin.member.reset_password_invalid")
member.password = passwordEncoder.encode(member.email.split("@")[0])
val email = member.email ?: throw SodaException(message = "이메일이 없는 계정은 비밀번호 재설정이 불가능합니다.")
member.password = passwordEncoder.encode(email.split("@")[0])
}
}

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

@@ -106,7 +106,8 @@ class HomeService(
val latestContentThemeList = contentThemeService.getActiveThemeOfContent(
isAdult = isAdult,
contentType = contentType
contentType = contentType,
excludeThemes = listOf("다시듣기")
)
val latestContentList = contentService.getLatestContentByTheme(
@@ -316,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

@@ -1,7 +1,6 @@
package kr.co.vividnext.sodalive.can
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.GeoCountry
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import org.springframework.data.domain.Pageable
@@ -10,15 +9,16 @@ import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import javax.servlet.http.HttpServletRequest
@RestController
@RequestMapping("/can")
class CanController(private val service: CanService) {
@GetMapping
fun getCans(request: HttpServletRequest): ApiResponse<List<CanResponse>> {
val geoCountry = request.getAttribute("geoCountry") as? GeoCountry ?: GeoCountry.OTHER
return ApiResponse.ok(service.getCans(geoCountry))
fun getCans(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
): ApiResponse<List<CanResponse>> {
val isNotSelectedCurrency = member != null && member.id == 2L
return ApiResponse.ok(service.getCans(isNotSelectedCurrency = isNotSelectedCurrency))
}
@GetMapping("/status")

View File

@@ -23,7 +23,7 @@ import org.springframework.stereotype.Repository
interface CanRepository : JpaRepository<Can, Long>, CanQueryRepository
interface CanQueryRepository {
fun findAllByStatusAndCurrency(status: CanStatus, currency: String): List<CanResponse>
fun findAllByStatusAndCurrency(status: CanStatus, currency: String?): List<CanResponse>
fun getCanUseStatus(member: Member, pageable: Pageable): List<UseCan>
fun getCanChargeStatus(member: Member, pageable: Pageable, container: String): List<Charge>
fun isExistPaidLiveRoom(memberId: Long, roomId: Long): UseCan?
@@ -32,7 +32,13 @@ interface CanQueryRepository {
@Repository
class CanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : CanQueryRepository {
override fun findAllByStatusAndCurrency(status: CanStatus, currency: String): List<CanResponse> {
override fun findAllByStatusAndCurrency(status: CanStatus, currency: String?): List<CanResponse> {
var where = can1.status.eq(status)
if (currency != null) {
where = where.and(can1.currency.eq(currency))
}
return queryFactory
.select(
QCanResponse(
@@ -46,10 +52,7 @@ class CanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : CanQue
)
)
.from(can1)
.where(
can1.status.eq(status),
can1.currency.eq(currency)
)
.where(where)
.orderBy(can1.can.asc())
.fetch()
}

View File

@@ -3,7 +3,7 @@ package kr.co.vividnext.sodalive.can
import kr.co.vividnext.sodalive.can.charge.ChargeStatus
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
import kr.co.vividnext.sodalive.can.use.CanUsage
import kr.co.vividnext.sodalive.common.GeoCountry
import kr.co.vividnext.sodalive.common.CountryContext
import kr.co.vividnext.sodalive.member.Member
import org.springframework.data.domain.Pageable
import org.springframework.stereotype.Service
@@ -11,11 +11,18 @@ import java.time.ZoneId
import java.time.format.DateTimeFormatter
@Service
class CanService(private val repository: CanRepository) {
fun getCans(geoCountry: GeoCountry): List<CanResponse> {
val currency = when (geoCountry) {
GeoCountry.KR -> "KRW"
else -> "USD"
class CanService(
private val repository: CanRepository,
private val countryContext: CountryContext
) {
fun getCans(isNotSelectedCurrency: Boolean): List<CanResponse> {
val currency = if (isNotSelectedCurrency) {
null
} else {
when (countryContext.countryCode) {
"KR" -> "KRW"
else -> "USD"
}
}
return repository.findAllByStatusAndCurrency(status = CanStatus.SALE, currency = currency)
}

View File

@@ -9,8 +9,6 @@ import kr.co.vividnext.sodalive.can.payment.PaymentStatus
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.fcm.FcmEvent
import kr.co.vividnext.sodalive.fcm.FcmEventType
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.MemberRepository
import kr.co.vividnext.sodalive.member.auth.AuthRepository
@@ -28,9 +26,7 @@ class ChargeEventService(
private val memberRepository: MemberRepository,
private val chargeRepository: ChargeRepository,
private val chargeEventRepository: ChargeEventRepository,
private val applicationEventPublisher: ApplicationEventPublisher,
private val messageSource: SodaMessageSource,
private val langContext: LangContext
private val applicationEventPublisher: ApplicationEventPublisher
) {
@Transactional
fun applyChargeEvent(chargeId: Long, memberId: Long) {
@@ -83,10 +79,8 @@ class ChargeEventService(
FcmEvent(
type = FcmEventType.INDIVIDUAL,
title = chargeEvent.title,
message = formatMessage(
"can.charge.event.additional_can_paid",
additionalCan
),
messageKey = "can.charge.event.additional_can_paid",
args = listOf(additionalCan),
recipients = listOf(member.id!!),
isAuth = null
)
@@ -101,21 +95,15 @@ class ChargeEventService(
additionalCan = additionalCan,
member = member,
paymentGateway = charge.payment?.paymentGateway!!,
method = messageSource
.getMessage("can.charge.event.first_title", langContext.lang)
.orEmpty()
method = "첫 충전 이벤트"
)
applicationEventPublisher.publishEvent(
FcmEvent(
type = FcmEventType.INDIVIDUAL,
title = messageSource
.getMessage("can.charge.event.first_title", langContext.lang)
.orEmpty(),
message = formatMessage(
"can.charge.event.additional_can_paid",
additionalCan
),
titleKey = "can.charge.event.first_title",
messageKey = "can.charge.event.additional_can_paid",
args = listOf(additionalCan),
recipients = listOf(member.id!!),
isAuth = null
)
@@ -124,7 +112,7 @@ class ChargeEventService(
private fun applyEvent(additionalCan: Int, member: Member, paymentGateway: PaymentGateway, method: String) {
val eventCharge = Charge(0, additionalCan, status = ChargeStatus.EVENT)
eventCharge.title = formatMessage("can.charge.title", additionalCan)
eventCharge.title = "$additionalCan"
eventCharge.member = member
val payment = Payment(
@@ -141,9 +129,4 @@ class ChargeEventService(
else -> member.charge(0, additionalCan, "pg")
}
}
private fun formatMessage(key: String, vararg args: Any): String {
val template = messageSource.getMessage(key, langContext.lang) ?: return ""
return String.format(template, *args)
}
}

View File

@@ -5,7 +5,6 @@ import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.core.userdetails.User
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
@@ -29,6 +28,12 @@ class ChargeTempController(private val service: ChargeTempService) {
@PostMapping("/verify")
fun verify(
@RequestBody request: VerifyRequest,
@AuthenticationPrincipal user: User
) = ApiResponse.ok(service.verify(user, request))
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) {
throw SodaException(messageKey = "common.error.bad_credentials")
}
ApiResponse.ok(service.verify(member, request))
}
}

View File

@@ -15,10 +15,8 @@ import kr.co.vividnext.sodalive.extensions.moneyFormat
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.MemberRepository
import org.springframework.beans.factory.annotation.Value
import org.springframework.data.repository.findByIdOrNull
import org.springframework.security.core.userdetails.User
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@@ -26,7 +24,6 @@ import org.springframework.transaction.annotation.Transactional
@Transactional(readOnly = true)
class ChargeTempService(
private val chargeRepository: ChargeRepository,
private val memberRepository: MemberRepository,
private val objectMapper: ObjectMapper,
private val messageSource: SodaMessageSource,
@@ -54,11 +51,9 @@ class ChargeTempService(
}
@Transactional
fun verify(user: User, verifyRequest: VerifyRequest) {
fun verify(member: Member, verifyRequest: VerifyRequest) {
val charge = chargeRepository.findByIdOrNull(verifyRequest.orderId.toLong())
?: throw SodaException(messageKey = "can.charge.invalid_payment_info")
val member = memberRepository.findByEmail(user.username)
?: throw SodaException(messageKey = "common.error.bad_credentials")
if (charge.payment!!.paymentGateway == PaymentGateway.PG) {
val bootpay = Bootpay(bootpayApplicationId, bootpayPrivateKey)

View File

@@ -14,6 +14,7 @@ import kr.co.vividnext.sodalive.can.use.UseCanCalculateRepository
import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus
import kr.co.vividnext.sodalive.can.use.UseCanRepository
import kr.co.vividnext.sodalive.chat.character.image.CharacterImage
import kr.co.vividnext.sodalive.common.CountryContext
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.AudioContent
import kr.co.vividnext.sodalive.content.order.Order
@@ -35,7 +36,8 @@ class CanPaymentService(
private val useCanRepository: UseCanRepository,
private val useCanCalculateRepository: UseCanCalculateRepository,
private val messageSource: SodaMessageSource,
private val langContext: LangContext
private val langContext: LangContext,
private val countryContext: CountryContext
) {
@Transactional
fun spendCan(
@@ -76,7 +78,8 @@ class CanPaymentService(
canUsage = canUsage,
can = useChargeCan?.total ?: 0,
rewardCan = useRewardCan.total,
isSecret = isSecret
isSecret = isSecret,
countryCode = countryContext.countryCode
)
var recipientId: Long? = null
@@ -378,7 +381,8 @@ class CanPaymentService(
canUsage = CanUsage.CHARACTER_IMAGE_PURCHASE,
can = useChargeCan?.total ?: 0,
rewardCan = useRewardCan.total,
isSecret = false
isSecret = false,
countryCode = countryContext.countryCode
)
useCan.member = member
useCan.characterImage = image
@@ -424,7 +428,8 @@ class CanPaymentService(
canUsage = CanUsage.CHAT_MESSAGE_PURCHASE,
can = useChargeCan?.total ?: 0,
rewardCan = useRewardCan.total,
isSecret = false
isSecret = false,
countryCode = countryContext.countryCode
)
useCan.member = member
useCan.chatMessage = message

View File

@@ -34,7 +34,10 @@ data class UseCan(
// 채팅 연동을 위한 식별자 (옵션)
var chatRoomId: Long? = null,
var characterId: Long? = null
var characterId: Long? = null,
// ISO 3166-1 alpha-2 국가 코드
var countryCode: String? = null
) : BaseEntity() {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id", nullable = false)

View File

@@ -67,6 +67,10 @@ class ChatCharacter(
@Column(nullable = false)
var characterType: CharacterType = CharacterType.Character,
// 리전 (기본값 KR, 수정 불가)
@Column(nullable = false)
val region: String = "KR",
var isActive: Boolean = true
) : BaseEntity() {
var imagePath: String? = null

View File

@@ -582,6 +582,7 @@ class ChatCharacterService(
originalTitle: String? = null,
originalLink: String? = null,
characterType: CharacterType = CharacterType.Character,
region: String = "KR",
tags: List<String> = emptyList(),
values: List<String> = emptyList(),
hobbies: List<String> = emptyList(),
@@ -600,7 +601,8 @@ class ChatCharacterService(
appearance = appearance,
originalTitle = originalTitle,
originalLink = originalLink,
characterType = characterType
characterType = characterType,
region = region
)
// 관련 엔티티 연결
@@ -630,6 +632,7 @@ class ChatCharacterService(
originalTitle: String? = null,
originalLink: String? = null,
characterType: CharacterType = CharacterType.Character,
region: String = "KR",
tags: List<String> = emptyList(),
values: List<String> = emptyList(),
hobbies: List<String> = emptyList(),
@@ -653,6 +656,7 @@ class ChatCharacterService(
originalTitle = originalTitle,
originalLink = originalLink,
characterType = characterType,
region = region,
tags = tags,
values = values,
hobbies = hobbies,

View File

@@ -0,0 +1,15 @@
package kr.co.vividnext.sodalive.common
import org.springframework.stereotype.Component
import org.springframework.web.context.annotation.RequestScope
@Component
@RequestScope
class CountryContext {
var countryCode: String? = null
internal set
fun setCountryCode(code: String?) {
this.countryCode = code
}
}

View File

@@ -0,0 +1,21 @@
package kr.co.vividnext.sodalive.common
import org.springframework.stereotype.Component
import org.springframework.web.servlet.HandlerInterceptor
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse
@Component
class CountryInterceptor(
private val countryContext: CountryContext
) : HandlerInterceptor {
override fun preHandle(
request: HttpServletRequest,
response: HttpServletResponse,
handler: Any
): Boolean {
val countryCode = request.getHeader("CloudFront-Viewer-Country")
countryContext.setCountryCode(countryCode)
return true
}
}

View File

@@ -1,8 +0,0 @@
package kr.co.vividnext.sodalive.common
const val WAF_GEO_HEADER = "x-amzn-waf-geo-country"
enum class GeoCountry { KR, OTHER }
fun parseGeo(headerValue: String?): GeoCountry =
if (headerValue?.trim()?.uppercase() == "KR") GeoCountry.KR else GeoCountry.OTHER

View File

@@ -1,20 +0,0 @@
package kr.co.vividnext.sodalive.common
import org.springframework.stereotype.Component
import org.springframework.web.filter.OncePerRequestFilter
import javax.servlet.FilterChain
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse
@Component
class GeoCountryFilter : OncePerRequestFilter() {
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain
) {
val country = parseGeo(request.getHeader(WAF_GEO_HEADER))
request.setAttribute("geoCountry", country)
filterChain.doFilter(request, response)
}
}

View File

@@ -1,5 +1,6 @@
package kr.co.vividnext.sodalive.common
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import org.slf4j.LoggerFactory
@@ -20,10 +21,14 @@ class SodaExceptionHandler(
private val messageSource: SodaMessageSource
) {
private val logger = LoggerFactory.getLogger(this::class.java)
private val logLang = Lang.KO
@ExceptionHandler(SodaException::class)
fun handleSodaException(e: SodaException) = run {
logger.error("API error", e)
val logMessage = e.messageKey?.takeIf { it.isNotBlank() }?.let { messageSource.getMessage(it, logLang) }
?: e.message?.takeIf { it.isNotBlank() }
?: messageSource.getMessage("common.error.unknown", logLang)
logger.error("API error: {}", logMessage, e)
val message = e.messageKey?.takeIf { it.isNotBlank() }?.let { messageSource.getMessage(it, langContext.lang) }
?: e.message?.takeIf { it.isNotBlank() }
?: messageSource.getMessage("common.error.unknown", langContext.lang)
@@ -35,35 +40,40 @@ class SodaExceptionHandler(
@ExceptionHandler(MaxUploadSizeExceededException::class)
fun handleMaxUploadSizeExceededException(e: MaxUploadSizeExceededException) = run {
logger.error("API error", e)
val logMessage = messageSource.getMessage("common.error.max_upload_size", logLang)
logger.error("API error: {}", logMessage, e)
val message = messageSource.getMessage("common.error.max_upload_size", langContext.lang)
ApiResponse.error(message = message)
}
@ExceptionHandler(AccessDeniedException::class)
fun handleAccessDeniedException(e: AccessDeniedException) = run {
logger.error("API error", e)
val logMessage = messageSource.getMessage("common.error.access_denied", logLang)
logger.error("API error: {}", logMessage, e)
val message = messageSource.getMessage("common.error.access_denied", langContext.lang)
ApiResponse.error(message = message)
}
@ExceptionHandler(InternalAuthenticationServiceException::class)
fun handleInternalAuthenticationServiceException(e: InternalAuthenticationServiceException) = run {
logger.error("API error", e)
val logMessage = messageSource.getMessage("common.error.bad_credentials", logLang)
logger.error("API error: {}", logMessage, e)
val message = messageSource.getMessage("common.error.bad_credentials", langContext.lang)
ApiResponse.error(message)
}
@ExceptionHandler(BadCredentialsException::class)
fun handleBadCredentialsException(e: BadCredentialsException) = run {
logger.error("API error", e)
val logMessage = messageSource.getMessage("common.error.bad_credentials", logLang)
logger.error("API error: {}", logMessage, e)
val message = messageSource.getMessage("common.error.bad_credentials", langContext.lang)
ApiResponse.error(message)
}
@ExceptionHandler(DataIntegrityViolationException::class)
fun handleDataIntegrityViolationException(e: DataIntegrityViolationException) = run {
logger.error("API error", e)
val logMessage = messageSource.getMessage("common.error.already_registered", logLang)
logger.error("API error: {}", logMessage, e)
val message = messageSource.getMessage("common.error.already_registered", langContext.lang)
ApiResponse.error(message)
}
@@ -71,7 +81,10 @@ class SodaExceptionHandler(
@ResponseStatus(value = HttpStatus.NOT_FOUND)
@ExceptionHandler(AdsChargeException::class)
fun handleAdsChargeException(e: AdsChargeException) = run {
logger.error("API error - AdsChargeException ::: ", e)
val logMessage = e.messageKey?.takeIf { it.isNotBlank() }?.let { messageSource.getMessage(it, logLang) }
?: e.message?.takeIf { it.isNotBlank() }
?: messageSource.getMessage("common.error.invalid_request", logLang)
logger.error("API error - AdsChargeException: {}", logMessage, e)
val message = e.messageKey?.takeIf { it.isNotBlank() }?.let { messageSource.getMessage(it, langContext.lang) }
?: e.message?.takeIf { it.isNotBlank() }
?: messageSource.getMessage("common.error.invalid_request", langContext.lang)
@@ -81,7 +94,8 @@ class SodaExceptionHandler(
@ExceptionHandler(Exception::class)
fun handleException(e: Exception) = run {
if (e is ResponseStatusException) throw e
logger.error("API error", e)
val logMessage = messageSource.getMessage("common.error.unknown", logLang)
logger.error("API error: {}", logMessage, e)
val message = messageSource.getMessage("common.error.unknown", langContext.lang)
ApiResponse.error(message)
}

View File

@@ -72,6 +72,8 @@ class SecurityConfig(
.antMatchers("/member/login").permitAll()
.antMatchers("/member/login/google").permitAll()
.antMatchers("/member/login/kakao").permitAll()
.antMatchers("/member/login/apple").permitAll()
.antMatchers("/member/login/line").permitAll()
.antMatchers("/creator-admin/member/login").permitAll()
.antMatchers("/member/forgot-password").permitAll()
.antMatchers("/stplat/terms_of_service").permitAll()

View File

@@ -1,5 +1,6 @@
package kr.co.vividnext.sodalive.configs
import kr.co.vividnext.sodalive.common.CountryInterceptor
import kr.co.vividnext.sodalive.i18n.LangInterceptor
import org.springframework.context.annotation.Configuration
import org.springframework.web.servlet.config.annotation.CorsRegistry
@@ -8,10 +9,12 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
@Configuration
class WebConfig(
private val langInterceptor: LangInterceptor
private val langInterceptor: LangInterceptor,
private val countryInterceptor: CountryInterceptor
) : WebMvcConfigurer {
override fun addInterceptors(registry: InterceptorRegistry) {
registry.addInterceptor(langInterceptor).addPathPatterns("/**")
registry.addInterceptor(countryInterceptor).addPathPatterns("/**")
}
override fun addCorsMappings(registry: CorsRegistry) {

View File

@@ -44,6 +44,7 @@ interface AudioContentQueryRepository {
fun findByIdAndCreatorId(contentId: Long, creatorId: Long): AudioContent?
fun findByCreatorId(
creatorId: Long,
isCreator: Boolean = false,
coverImageHost: String,
isAdult: Boolean = false,
contentType: ContentType = ContentType.ALL,
@@ -55,6 +56,7 @@ interface AudioContentQueryRepository {
fun findTotalCountByCreatorId(
creatorId: Long,
isCreator: Boolean = false,
isAdult: Boolean = false,
categoryId: Long = 0,
contentType: ContentType = ContentType.ALL
@@ -230,6 +232,7 @@ class AudioContentQueryRepositoryImpl(
override fun findByCreatorId(
creatorId: Long,
isCreator: Boolean,
coverImageHost: String,
isAdult: Boolean,
contentType: ContentType,
@@ -246,11 +249,18 @@ class AudioContentQueryRepositoryImpl(
}
var where = audioContent.member.id.eq(creatorId)
.and(
where = if (isCreator) {
where.and(
audioContent.releaseDate.isNotNull
.and(audioContent.duration.isNotNull)
)
} else {
where.and(
audioContent.isActive.isTrue
.and(audioContent.duration.isNotNull)
.or(audioContent.releaseDate.isNotNull.and(audioContent.duration.isNotNull))
)
}
if (!isAdult) {
where = where.and(audioContent.isAdult.isFalse)
@@ -332,16 +342,24 @@ class AudioContentQueryRepositoryImpl(
override fun findTotalCountByCreatorId(
creatorId: Long,
isCreator: Boolean,
isAdult: Boolean,
categoryId: Long,
contentType: ContentType
): Int {
var where = audioContent.member.id.eq(creatorId)
.and(
where = if (isCreator) {
where.and(
audioContent.releaseDate.isNotNull
.and(audioContent.duration.isNotNull)
)
} else {
where.and(
audioContent.isActive.isTrue
.and(audioContent.duration.isNotNull)
.or(audioContent.releaseDate.isNotNull.and(audioContent.duration.isNotNull))
)
}
if (!isAdult) {
where = where.and(audioContent.isAdult.isFalse)

View File

@@ -183,7 +183,8 @@ class AudioContentService(
applicationEventPublisher.publishEvent(
LanguageTranslationEvent(
id = request.contentId,
targetType = LanguageTranslationTargetType.CONTENT
targetType = LanguageTranslationTargetType.CONTENT,
waitTransactionCommit = true
)
)
}
@@ -458,7 +459,7 @@ class AudioContentService(
applicationEventPublisher.publishEvent(
FcmEvent(
type = FcmEventType.INDIVIDUAL,
title = formatMessage("content.notification.upload_complete_title"),
titleKey = "content.notification.upload_complete_title",
message = audioContent.title,
recipients = listOf(audioContent.member!!.id!!),
isAuth = null,
@@ -473,23 +474,11 @@ class AudioContentService(
FcmEvent(
type = FcmEventType.UPLOAD_CONTENT,
title = audioContent.member!!.nickname,
message = formatMessage("content.notification.uploaded_message", audioContent.title),
messageKey = "content.notification.uploaded_message",
args = listOf(audioContent.title),
isAuth = audioContent.isAdult,
contentId = contentId,
creatorId = audioContent.member!!.id,
container = "ios"
)
)
applicationEventPublisher.publishEvent(
FcmEvent(
type = FcmEventType.UPLOAD_CONTENT,
title = audioContent.member!!.nickname,
message = formatMessage("content.notification.uploaded_message", audioContent.title),
isAuth = audioContent.isAdult,
contentId = contentId,
creatorId = audioContent.member!!.id,
container = "aos"
creatorId = audioContent.member!!.id
)
)
}
@@ -507,23 +496,11 @@ class AudioContentService(
FcmEvent(
type = FcmEventType.UPLOAD_CONTENT,
title = audioContent.member!!.nickname,
message = formatMessage("content.notification.uploaded_message", audioContent.title),
messageKey = "content.notification.uploaded_message",
args = listOf(audioContent.title),
isAuth = audioContent.isAdult,
contentId = audioContent.id!!,
creatorId = audioContent.member!!.id,
container = "ios"
)
)
applicationEventPublisher.publishEvent(
FcmEvent(
type = FcmEventType.UPLOAD_CONTENT,
title = audioContent.member!!.nickname,
message = formatMessage("content.notification.uploaded_message", audioContent.title),
isAuth = audioContent.isAdult,
contentId = audioContent.id!!,
creatorId = audioContent.member!!.id,
container = "aos"
creatorId = audioContent.member!!.id
)
)
}
@@ -978,9 +955,11 @@ class AudioContentService(
limit: Long
): GetAudioContentListResponse {
val isAdult = member.auth != null && isAdultContentVisible
val isCreator = member.id == creatorId
val totalCount = repository.findTotalCountByCreatorId(
creatorId = creatorId,
isCreator = isCreator,
isAdult = isAdult,
categoryId = categoryId,
contentType = contentType
@@ -988,6 +967,7 @@ class AudioContentService(
val audioContentList = repository.findByCreatorId(
creatorId = creatorId,
isCreator = isCreator,
coverImageHost = coverImageHost,
isAdult = isAdult,
contentType = contentType,

View File

@@ -75,7 +75,8 @@ class CategoryService(
applicationEventPublisher.publishEvent(
LanguageTranslationEvent(
id = request.categoryId,
targetType = LanguageTranslationTargetType.CREATOR_CONTENT_CATEGORY
targetType = LanguageTranslationTargetType.CREATOR_CONTENT_CATEGORY,
waitTransactionCommit = true
)
)
}
@@ -158,7 +159,7 @@ class CategoryService(
val sourceLang = entity.languageCode
if (!sourceLang.isNullOrBlank() && sourceLang != locale) {
val existing = translations[item.categoryId]
if (existing != null && !existing.category.isNullOrBlank()) {
if (existing != null && existing.category.isNotBlank()) {
result.add(GetCategoryListResponse(categoryId = item.categoryId, category = existing.category))
continue
}

View File

@@ -4,7 +4,9 @@ import com.querydsl.core.types.dsl.Expressions
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.fcm.PushTokenInfo
import kr.co.vividnext.sodalive.fcm.QPushToken.pushToken
import kr.co.vividnext.sodalive.fcm.QPushTokenInfo
import kr.co.vividnext.sodalive.member.QMember.member
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@@ -38,7 +40,7 @@ interface AudioContentCommentQueryRepository {
contentId: Long,
commentParentId: Long?,
myMemberId: Long
): List<FindPushTokenByContentIdAndCommentParentIdMyMemberIdResponse>
): List<PushTokenInfo>
}
@Repository
@@ -191,7 +193,7 @@ class AudioContentCommentQueryRepositoryImpl(
contentId: Long,
commentParentId: Long?,
myMemberId: Long
): List<FindPushTokenByContentIdAndCommentParentIdMyMemberIdResponse> {
): List<PushTokenInfo> {
var where = audioContent.id.eq(contentId)
.and(member.id.ne(myMemberId))
@@ -206,9 +208,10 @@ class AudioContentCommentQueryRepositoryImpl(
val response = if (commentParentId != null) {
queryFactory
.select(
QFindPushTokenByContentIdAndCommentParentIdMyMemberIdResponse(
QPushTokenInfo(
pushToken.token,
pushToken.deviceType
pushToken.deviceType,
pushToken.languageCode.coalesce("ko")
)
)
.from(audioContentComment)
@@ -220,9 +223,10 @@ class AudioContentCommentQueryRepositoryImpl(
} else {
queryFactory
.select(
QFindPushTokenByContentIdAndCommentParentIdMyMemberIdResponse(
QPushTokenInfo(
pushToken.token,
pushToken.deviceType
pushToken.deviceType,
pushToken.languageCode.coalesce("ko")
)
)
.from(audioContent)

View File

@@ -83,11 +83,12 @@ class AudioContentCommentService(
} else {
member.nickname
},
message = if (parent != null) {
formatMessage("content.comment.notification.reply", audioContent.title)
messageKey = if (parent != null) {
"content.comment.notification.reply"
} else {
formatMessage("content.comment.notification.new", audioContent.title)
"content.comment.notification.new"
},
args = listOf(audioContent.title),
contentId = audioContentId,
commentParentId = parentId,
myMemberId = member.id

View File

@@ -1,8 +0,0 @@
package kr.co.vividnext.sodalive.content.comment
import com.querydsl.core.annotations.QueryProjection
data class FindPushTokenByContentIdAndCommentParentIdMyMemberIdResponse @QueryProjection constructor(
val pushToken: String,
val container: String
)

View File

@@ -34,15 +34,20 @@ class AudioContentThemeService(
isAdult: Boolean = false,
isFree: Boolean = false,
isPointAvailableOnly: Boolean = false,
contentType: ContentType
contentType: ContentType,
excludeThemes: List<String> = emptyList()
): List<String> {
val themesWithIds = queryRepository.getActiveThemeWithIdsOfContent(
var themesWithIds = queryRepository.getActiveThemeWithIdsOfContent(
isAdult = isAdult,
isFree = isFree,
isPointAvailableOnly = isPointAvailableOnly,
contentType = contentType
)
if (excludeThemes.isNotEmpty()) {
themesWithIds = themesWithIds.filter { it.theme !in excludeThemes }
}
/**
* langContext.lang == Lang.EN || Lang.JA 일 때 번역된 콘텐츠 테마 반환
* 번역이 없으면 번역 API 호출 후 저장하고 반환

View File

@@ -203,7 +203,8 @@ class CreatorAdminContentService(
applicationEventPublisher.publishEvent(
LanguageTranslationEvent(
id = request.id,
targetType = LanguageTranslationTargetType.CONTENT
targetType = LanguageTranslationTargetType.CONTENT,
waitTransactionCommit = true
)
)
}

View File

@@ -117,7 +117,8 @@ class CreatorAdminContentSeriesService(
applicationEventPublisher.publishEvent(
LanguageTranslationEvent(
id = series.id!!,
targetType = LanguageTranslationTargetType.SERIES
targetType = LanguageTranslationTargetType.SERIES,
waitTransactionCommit = true
)
)
}
@@ -211,7 +212,8 @@ class CreatorAdminContentSeriesService(
applicationEventPublisher.publishEvent(
LanguageTranslationEvent(
id = series.id!!,
targetType = LanguageTranslationTargetType.SERIES
targetType = LanguageTranslationTargetType.SERIES,
waitTransactionCommit = true
)
)
}

View File

@@ -87,7 +87,7 @@ class CreatorAdminMemberService(
userId = member.id!!,
token = jwt,
nickname = member.nickname,
email = member.email,
email = member.email ?: "",
profileImage = if (member.profileImage != null) {
"$cloudFrontHost/${member.profileImage}"
} else {

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
@@ -38,6 +40,7 @@ import kr.co.vividnext.sodalive.member.tag.QCreatorTag.creatorTag
import kr.co.vividnext.sodalive.member.tag.QMemberCreatorTag.memberCreatorTag
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Repository
import java.math.BigDecimal
import java.time.Duration
import java.time.LocalDate
import java.time.LocalDateTime
@@ -341,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)
}
@@ -651,6 +669,18 @@ class ExplorerQueryRepository(
.fetchFirst()
}
fun getPaidContentCount(creatorId: Long): Long? {
return queryFactory
.select(audioContent.id.count())
.from(audioContent)
.where(
audioContent.isActive.isTrue
.and(audioContent.member.id.eq(creatorId))
.and(audioContent.price.gt(BigDecimal.ZERO))
)
.fetchFirst()
}
fun getOwnedContentCount(creatorId: Long, memberId: Long): Long {
// 활성 주문 + 대여의 경우 유효기간 내 주문만 포함, 동일 콘텐츠 중복 구매는 1개로 카운트
return queryFactory

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()
@@ -287,9 +290,9 @@ class ExplorerService(
null
}
// 크리에이터의 전체 콘텐츠 개수
// 크리에이터의 전체 유료 콘텐츠 개수
val totalContentCount = if (isCreator) {
queryRepository.getContentCount(creatorId) ?: 0
queryRepository.getPaidContentCount(creatorId) ?: 0
} else {
0
}
@@ -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
)
@@ -578,7 +600,7 @@ class ExplorerService(
FcmEvent(
type = FcmEventType.CHANGE_NOTICE,
title = member.nickname,
message = messageSource.getMessage("explorer.notice.fcm.message", langContext.lang).orEmpty(),
messageKey = "explorer.notice.fcm.message",
creatorId = member.id!!
)
)

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

@@ -123,7 +123,7 @@ class CreatorCommunityService(
FcmEvent(
type = FcmEventType.CHANGE_NOTICE,
title = member.nickname,
message = messageSource.getMessage("creator.community.fcm.new_post", langContext.lang).orEmpty(),
messageKey = "creator.community.fcm.new_post",
creatorId = member.id!!
)
)

View File

@@ -33,17 +33,6 @@ class FcmController(private val applicationEventPublisher: ApplicationEventPubli
type = FcmEventType.ALL,
title = request.title,
message = request.message,
container = "ios",
isAuth = request.isAuth
)
)
applicationEventPublisher.publishEvent(
FcmEvent(
type = FcmEventType.ALL,
title = request.title,
message = request.message,
container = "aos",
isAuth = request.isAuth
)
)

View File

@@ -1,6 +1,9 @@
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
@@ -15,11 +18,14 @@ enum class FcmEventType {
class FcmEvent(
val type: FcmEventType,
val title: String,
val message: String,
val title: String = "",
val message: String = "",
val titleKey: String? = null,
val messageKey: String? = null,
val args: List<Any> = listOf(),
val container: String = "",
val recipients: List<Long> = listOf(),
val recipientsMap: Map<String, List<List<String>>>? = null,
val pushTokens: List<PushTokenInfo>? = null,
val isAuth: Boolean? = null,
val roomId: Long? = null,
val contentId: Long? = null,
@@ -28,14 +34,16 @@ 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
class FcmSendListener(
private val pushService: FcmService,
private val memberRepository: MemberRepository,
private val contentCommentRepository: AudioContentCommentRepository
private val contentCommentRepository: AudioContentCommentRepository,
private val messageSource: SodaMessageSource
) {
@Async
@TransactionalEventListener
@@ -43,21 +51,10 @@ class FcmSendListener(
fun send(fcmEvent: FcmEvent) {
when (fcmEvent.type) {
FcmEventType.ALL -> {
if (fcmEvent.container.isNotBlank()) {
val pushTokens = memberRepository.getAllRecipientPushTokens(
fcmEvent.isAuth,
fcmEvent.container
)
for (tokens in pushTokens) {
pushService.send(
tokens = tokens,
title = fcmEvent.title,
message = fcmEvent.message,
container = fcmEvent.container
)
}
}
val pushTokens = memberRepository.getAllRecipientPushTokens(
fcmEvent.isAuth
)
sendPush(pushTokens, fcmEvent)
}
FcmEventType.INDIVIDUAL -> {
@@ -66,254 +63,119 @@ class FcmSendListener(
recipients = fcmEvent.recipients,
isAuth = fcmEvent.isAuth
)
val iosPushTokens = pushTokens["ios"]
val aosPushToken = pushTokens["aos"]
if (iosPushTokens != null) {
for (tokens in iosPushTokens) {
pushService.send(
tokens = tokens,
title = fcmEvent.title,
message = fcmEvent.message,
container = "ios",
contentId = fcmEvent.contentId
)
}
}
if (aosPushToken != null) {
for (tokens in aosPushToken) {
pushService.send(
tokens = tokens,
title = fcmEvent.title,
message = fcmEvent.message,
container = "aos",
contentId = fcmEvent.contentId
)
}
}
sendPush(pushTokens, fcmEvent)
}
}
FcmEventType.CREATE_LIVE -> {
if (fcmEvent.container.isNotBlank()) {
val pushTokens = memberRepository.getCreateLiveRoomNotificationRecipientPushTokens(
creatorId = fcmEvent.creatorId!!,
isAuth = fcmEvent.isAuth ?: false,
isAvailableJoinCreator = fcmEvent.isAvailableJoinCreator ?: false,
container = fcmEvent.container
)
for (tokens in pushTokens) {
pushService.send(
tokens = tokens,
title = fcmEvent.title,
message = fcmEvent.message,
container = fcmEvent.container,
roomId = fcmEvent.roomId
)
}
}
val pushTokens = memberRepository.getCreateLiveRoomNotificationRecipientPushTokens(
creatorId = fcmEvent.creatorId!!,
isAuth = fcmEvent.isAuth ?: false,
isAvailableJoinCreator = fcmEvent.isAvailableJoinCreator ?: false,
genderRestriction = fcmEvent.genderRestriction
)
sendPush(pushTokens, fcmEvent, roomId = fcmEvent.roomId)
}
FcmEventType.START_LIVE -> {
if (fcmEvent.container.isNotBlank()) {
val pushTokens = memberRepository.getStartLiveRoomNotificationRecipientPushTokens(
creatorId = fcmEvent.creatorId!!,
roomId = fcmEvent.roomId!!,
isAuth = fcmEvent.isAuth ?: false,
isAvailableJoinCreator = fcmEvent.isAvailableJoinCreator ?: false,
container = fcmEvent.container
)
for (tokens in pushTokens) {
pushService.send(
tokens = tokens,
title = fcmEvent.title,
message = fcmEvent.message,
container = fcmEvent.container,
roomId = fcmEvent.roomId
)
}
}
val pushTokens = memberRepository.getStartLiveRoomNotificationRecipientPushTokens(
creatorId = fcmEvent.creatorId!!,
roomId = fcmEvent.roomId!!,
isAuth = fcmEvent.isAuth ?: false,
isAvailableJoinCreator = fcmEvent.isAvailableJoinCreator ?: false,
genderRestriction = fcmEvent.genderRestriction
)
sendPush(pushTokens, fcmEvent, roomId = fcmEvent.roomId)
}
FcmEventType.CANCEL_LIVE -> {
if (fcmEvent.recipientsMap != null) {
val iosPushTokens = fcmEvent.recipientsMap["ios"]
val aosPushToken = fcmEvent.recipientsMap["aos"]
if (iosPushTokens != null) {
for (tokens in iosPushTokens) {
pushService.send(
tokens = tokens,
title = fcmEvent.title,
message = fcmEvent.message,
container = "ios"
)
}
}
if (aosPushToken != null) {
for (tokens in aosPushToken) {
pushService.send(
tokens = tokens,
title = fcmEvent.title,
message = fcmEvent.message,
container = "aos"
)
}
}
if (fcmEvent.pushTokens != null) {
sendPush(fcmEvent.pushTokens, fcmEvent)
}
}
FcmEventType.UPLOAD_CONTENT -> {
if (fcmEvent.container.isNotBlank()) {
val pushTokens = memberRepository.getUploadContentNotificationRecipientPushTokens(
creatorId = fcmEvent.creatorId!!,
isAuth = fcmEvent.isAuth ?: false,
container = fcmEvent.container
)
for (tokens in pushTokens) {
pushService.send(
tokens = tokens,
title = fcmEvent.title,
message = fcmEvent.message,
container = fcmEvent.container,
contentId = fcmEvent.contentId
)
}
}
val pushTokens = memberRepository.getUploadContentNotificationRecipientPushTokens(
creatorId = fcmEvent.creatorId!!,
isAuth = fcmEvent.isAuth ?: false
)
sendPush(pushTokens, fcmEvent, contentId = fcmEvent.contentId)
}
FcmEventType.SEND_MESSAGE -> {
val response = memberRepository.getMessageRecipientPushToken(messageId = fcmEvent.messageId!!)
if (response != null) {
pushService.send(
tokens = listOf(response.pushToken),
title = fcmEvent.title,
message = fcmEvent.message,
container = response.container,
messageId = fcmEvent.messageId
)
val pushToken = memberRepository.getMessageRecipientPushToken(messageId = fcmEvent.messageId!!)
if (pushToken != null) {
sendPush(listOf(pushToken), fcmEvent, messageId = fcmEvent.messageId)
}
}
FcmEventType.CHANGE_NOTICE -> {
if (fcmEvent.creatorId != null) {
val pushTokenList = memberRepository.getChangeNoticeRecipientPushTokens(fcmEvent.creatorId)
val iosPushTokens = pushTokenList["ios"]
val aosPushToken = pushTokenList["aos"]
if (iosPushTokens != null) {
for (tokens in iosPushTokens) {
pushService.send(
tokens = tokens,
title = fcmEvent.title,
message = fcmEvent.message,
container = "ios",
creatorId = fcmEvent.creatorId
)
}
}
if (aosPushToken != null) {
for (tokens in aosPushToken) {
pushService.send(
tokens = tokens,
title = fcmEvent.title,
message = fcmEvent.message,
container = "aos",
creatorId = fcmEvent.creatorId
)
}
}
val pushTokens = memberRepository.getChangeNoticeRecipientPushTokens(fcmEvent.creatorId)
sendPush(pushTokens, fcmEvent, creatorId = fcmEvent.creatorId)
}
}
FcmEventType.CREATE_CONTENT_COMMENT -> {
if (fcmEvent.myMemberId != null && fcmEvent.contentId != null) {
val response = contentCommentRepository.findPushTokenByContentIdAndCommentParentIdMyMemberId(
val pushTokens = contentCommentRepository.findPushTokenByContentIdAndCommentParentIdMyMemberId(
contentId = fcmEvent.contentId,
commentParentId = fcmEvent.commentParentId,
myMemberId = fcmEvent.myMemberId
)
val iosPushTokens = response
.asSequence()
.distinct()
.filter { it.pushToken.isNotBlank() }
.filter { it.container == "ios" }
.map { it.pushToken }
.toList()
val aosPushTokens = response
.asSequence()
.distinct()
.filter { it.pushToken.isNotBlank() }
.filter { it.container == "aos" }
.map { it.pushToken }
.toList()
if (iosPushTokens.isNotEmpty()) {
pushService.send(
tokens = iosPushTokens,
title = fcmEvent.title,
message = fcmEvent.message,
container = "ios",
contentId = fcmEvent.contentId
)
}
if (aosPushTokens.isNotEmpty()) {
pushService.send(
tokens = aosPushTokens,
title = fcmEvent.title,
message = fcmEvent.message,
container = "aos",
contentId = fcmEvent.contentId
)
}
sendPush(pushTokens, fcmEvent, contentId = fcmEvent.contentId)
}
}
FcmEventType.IN_PROGRESS_AUDITION -> {
if (fcmEvent.auditionId != null && fcmEvent.auditionId > 0) {
val pushTokenList = memberRepository.getAuditionNoticeRecipientPushTokens(
val pushTokens = memberRepository.getAuditionNoticeRecipientPushTokens(
isAuth = fcmEvent.isAuth ?: false
)
val iosPushTokens = pushTokenList["ios"]
val aosPushToken = pushTokenList["aos"]
if (iosPushTokens != null) {
for (tokens in iosPushTokens) {
pushService.send(
tokens = tokens,
title = fcmEvent.title,
message = fcmEvent.message,
container = "ios",
auditionId = fcmEvent.auditionId
)
}
}
if (aosPushToken != null) {
for (tokens in aosPushToken) {
pushService.send(
tokens = tokens,
title = fcmEvent.title,
message = fcmEvent.message,
container = "aos",
auditionId = fcmEvent.auditionId
)
}
}
sendPush(pushTokens, fcmEvent, auditionId = fcmEvent.auditionId)
}
}
}
}
private fun sendPush(
pushTokens: List<PushTokenInfo>,
fcmEvent: FcmEvent,
roomId: Long? = null,
contentId: Long? = null,
messageId: Long? = null,
creatorId: Long? = null,
auditionId: Long? = null
) {
val tokensByLang = pushTokens.groupBy { it.languageCode }
for ((langCode, tokens) in tokensByLang) {
val lang = Lang.fromAcceptLanguage(langCode)
val title = translate(fcmEvent.titleKey, fcmEvent.title, lang, fcmEvent.args)
val message = translate(fcmEvent.messageKey, fcmEvent.message, lang, fcmEvent.args)
val tokensByOS = tokens.groupBy { it.deviceType }
for ((os, osTokens) in tokensByOS) {
osTokens.map { it.token }.distinct().chunked(500).forEach { batch ->
pushService.send(
tokens = batch,
title = title,
message = message,
container = os,
roomId = roomId ?: fcmEvent.roomId,
contentId = contentId ?: fcmEvent.contentId,
messageId = messageId ?: fcmEvent.messageId,
creatorId = creatorId ?: fcmEvent.creatorId,
auditionId = auditionId ?: fcmEvent.auditionId
)
}
}
}
}
private fun translate(key: String?, default: String, lang: Lang, args: List<Any>): String {
if (key == null) return default
val template = messageSource.getMessage(key, lang) ?: return default
return String.format(template, *args.toTypedArray())
}
}

View File

@@ -1,8 +0,0 @@
package kr.co.vividnext.sodalive.fcm
import com.querydsl.core.annotations.QueryProjection
data class GetMessageRecipientPushTokenResponse @QueryProjection constructor(
val pushToken: String,
val container: String
)

View File

@@ -10,7 +10,8 @@ import javax.persistence.ManyToOne
@Entity
data class PushToken(
var token: String,
var deviceType: String
var deviceType: String,
var languageCode: String? = null
) : BaseEntity() {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id", nullable = true)

View File

@@ -0,0 +1,9 @@
package kr.co.vividnext.sodalive.fcm
import com.querydsl.core.annotations.QueryProjection
data class PushTokenInfo @QueryProjection constructor(
val token: String,
val deviceType: String,
val languageCode: String
)

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

@@ -1,5 +1,6 @@
package kr.co.vividnext.sodalive.fcm
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.member.MemberRepository
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
@@ -8,7 +9,8 @@ import org.springframework.transaction.annotation.Transactional
@Service
class PushTokenService(
private val repository: PushTokenRepository,
private val memberRepository: MemberRepository
private val memberRepository: MemberRepository,
private val langContext: LangContext
) {
@Transactional
fun registerToken(memberId: Long, token: String, deviceType: String) {
@@ -20,8 +22,9 @@ class PushTokenService(
existing.member = member
existing.token = token
existing.deviceType = deviceType
existing.languageCode = langContext.lang.code
} else {
val newToken = PushToken(token, deviceType)
val newToken = PushToken(token, deviceType, langContext.lang.code)
newToken.member = member
repository.save(newToken)
}

View File

@@ -23,7 +23,7 @@ class SodaMessageSource {
"common.error.adult_verification_required" to mapOf(
Lang.KO to "본인인증을 하셔야 합니다.",
Lang.EN to "Identity verification is required.",
Lang.JA to "本人認が必要です。"
Lang.JA to "本人認が必要です。"
),
"common.error.max_upload_size" to mapOf(
Lang.KO to "파일용량은 최대 1024MB까지 저장할 수 있습니다.",
@@ -66,12 +66,12 @@ class SodaMessageSource {
"content.error.invalid_theme" to mapOf(
Lang.KO to "잘못된 테마입니다. 다시 선택해 주세요.",
Lang.EN to "Invalid theme. Please select again.",
Lang.JA to "不正なテーマです。もう一度選択してください。"
Lang.JA to "不正なテーマです。恐れ入りますが、もう一度選択してください。"
),
"content.error.alarm_theme_price_min" to mapOf(
Lang.KO to "알람, 모닝콜, 슬립콜 테마의 콘텐츠는 5캔 이상의 유료콘텐츠로 등록이 가능합니다.",
Lang.EN to "Alarm, Morning Call, and Sleep Call themes require paid content of at least 5 cans.",
Lang.JA to "アラーム、モーニングコール、スリープコールのテーマは5以上の有料コンテンツのみ登録できます。"
Lang.JA to "アラーム、モーニングコール、スリープコールのテーマは5CAN以上の有料コンテンツのみ登録できます。"
),
"content.error.minimum_price" to mapOf(
Lang.KO to "콘텐츠의 최소금액은 5캔 입니다.",
@@ -160,7 +160,7 @@ class SodaMessageSource {
"content.ranking.sort_type.donation" to mapOf(
Lang.KO to "후원",
Lang.EN to "Donations",
Lang.JA to "支援"
Lang.JA to "ギフト"
)
)
@@ -173,7 +173,7 @@ class SodaMessageSource {
"content.comment.error.secret_requires_purchase" to mapOf(
Lang.KO to "콘텐츠 구매 후 비밀댓글을 등록할 수 있습니다.",
Lang.EN to "You can post a secret comment after purchasing the content.",
Lang.JA to "コンテンツ購入後に秘密コメントを登録できます。"
Lang.JA to "コンテンツ購入後にシークレットコメントを登録できます。"
),
"content.comment.notification.reply" to mapOf(
Lang.KO to "댓글에 답글을 달았습니다.: %s",
@@ -188,7 +188,7 @@ class SodaMessageSource {
"content.comment.error.invalid_access_retry" to mapOf(
Lang.KO to "잘못된 접근 입니다.\n확인 후 다시 시도해 주세요.",
Lang.EN to "Invalid access.\nPlease check and try again.",
Lang.JA to "不正なアクセスです。\n確認して再度お試しください。"
Lang.JA to "不正なアクセスです。\n恐れ入りますが、確認後再度お試しください。"
)
)
@@ -196,7 +196,7 @@ class SodaMessageSource {
"content.donation.error.minimum_can" to mapOf(
Lang.KO to "1캔 이상 후원하실 수 있습니다.",
Lang.EN to "You can donate at least 1 can.",
Lang.JA to "1CAN以上支援できます。"
Lang.JA to "1CAN以上ギフトできます。"
),
"content.donation.error.comment_required" to mapOf(
Lang.KO to "함께 보낼 메시지를 입력하세요.",
@@ -209,7 +209,7 @@ class SodaMessageSource {
"order.error.content_sold_out" to mapOf(
Lang.KO to "해당 콘텐츠가 매진되었습니다.",
Lang.EN to "This content is sold out.",
Lang.JA to "このコンテンツは売り切れです。"
Lang.JA to "このコンテンツは売です。"
),
"order.error.cannot_purchase_own_content" to mapOf(
Lang.KO to "자신이 올린 콘텐츠는 구매할 수 없습니다.",
@@ -252,7 +252,7 @@ class SodaMessageSource {
"playlist.error.not_purchased_content" to mapOf(
Lang.KO to "대여/소장하지 않은 콘텐츠는 재생목록에 추가할 수 없습니다.",
Lang.EN to "Content you haven't rented or purchased cannot be added to the playlist.",
Lang.JA to "レンタルまたは購入していないコンテンツは再生リストに追加できません。"
Lang.JA to "レンタルまたは購入していないコンテンツはプレイリストに追加できません。"
)
)
@@ -307,7 +307,7 @@ class SodaMessageSource {
"admin.audition.invalid_request_retry" to mapOf(
Lang.KO to "잘못된 요청입니다.\n다시 시도해 주세요.",
Lang.EN to "Invalid request.\ntry again.",
Lang.JA to "不正なリクエストです。もう一度やり直してください。"
Lang.JA to "不正なリクエストです。\n恐れ入りますが、もう一度お試しください。"
),
"admin.audition.status_cannot_revert" to mapOf(
Lang.KO to "모집전 상태로 변경할 수 없습니다.",
@@ -338,7 +338,7 @@ class SodaMessageSource {
"admin.audition.fcm.message.new" to mapOf(
Lang.KO to "'%s'이 등록되었습니다. 지금 바로 오리지널 오디오 드라마 오디션에 지원해보세요!",
Lang.EN to "'%s' is now available. Apply for the original audio drama audition now!",
Lang.JA to "「%s」が登録されました。今すぐオリジナルオーディオドラマのオーディションに応募してみてください!"
Lang.JA to "「%s」が登録されました。今すぐオリジナルオーディオドラマのオーディションに押し込みしてみてください!"
)
)
@@ -379,7 +379,7 @@ class SodaMessageSource {
"audition.error.invalid_request_retry" to mapOf(
Lang.KO to "잘못된 요청입니다.\n다시 시도해 주세요.",
Lang.EN to "Invalid request.\ntry again.",
Lang.JA to "不正なリクエストです。\nもう一度お試しください。"
Lang.JA to "不正なリクエストです。\n恐れ入りますが、もう一度お試しください。"
)
)
@@ -395,7 +395,7 @@ class SodaMessageSource {
"audition.vote.max_daily_reached" to mapOf(
Lang.KO to "오늘 응원은 여기까지!\n하루 최대 100회까지 응원이 가능합니다.\n내일 다시 이용해주세요.",
Lang.EN to "That's all for today!\nYou can vote up to 100 times per day.\nPlease try again tomorrow.",
Lang.JA to "今日はここまでです\n1日に最大100回まで応援できます。\n明日またご利用ください。"
Lang.JA to "今日はここまで!\n1日に最大100回まで応援できます。\n明日またご利用ください。"
)
)
@@ -416,7 +416,7 @@ class SodaMessageSource {
"admin.can.min_amount" to mapOf(
Lang.KO to "1 캔 이상 입력하세요.",
Lang.EN to "Please enter at least 1 can.",
Lang.JA to "1以上入力してください。"
Lang.JA to "1CAN以上入力してください。"
),
"admin.can.method_required" to mapOf(
Lang.KO to "기록내용을 입력하세요.",
@@ -444,12 +444,12 @@ class SodaMessageSource {
"can.charge.invalid_request_restart" to mapOf(
Lang.KO to "잘못된 요청입니다\n앱 종료 후 다시 시도해 주세요.",
Lang.EN to "Invalid request.\nPlease restart the app and try again.",
Lang.JA to "不正なリクエストです。\nアプリを終了して再度お試しください。"
Lang.JA to "不正なリクエストです。アプリを終了して再度お試しください。"
),
"can.charge.purchase_failed_contact" to mapOf(
Lang.KO to "구매를 하지 못했습니다.\n고객센터로 문의해 주세요",
Lang.EN to "Purchase could not be completed.\nPlease contact customer support.",
Lang.JA to "購入を完了できませんでした。\nカスタマーサポートへお問い合わせください。"
Lang.JA to "購入を完了できませんでした。\nサポートへお問い合わせください。"
),
"can.charge.payment_incomplete" to mapOf(
Lang.KO to "결제를 완료하지 못했습니다.",
@@ -474,7 +474,7 @@ class SodaMessageSource {
"can.charge.title" to mapOf(
Lang.KO to "%s 캔",
Lang.EN to "%s cans",
Lang.JA to "%s"
Lang.JA to "%s CAN"
)
)
@@ -482,12 +482,12 @@ class SodaMessageSource {
"can.charge.event.not_applied_contact" to mapOf(
Lang.KO to "이벤트가 적용되지 않았습니다.\n고객센터에 문의해 주세요.",
Lang.EN to "The event was not applied.\nPlease contact customer support.",
Lang.JA to "イベントが適用されていません。\nカスタマーサポートへお問い合わせください。"
Lang.JA to "イベントが適用されていません。\nサポートへお問い合わせください。"
),
"can.charge.event.additional_can_paid" to mapOf(
Lang.KO to "%s 캔이 추가 지급되었습니다.",
Lang.EN to "%s cans have been added.",
Lang.JA to "%sが追加で支給されました。"
Lang.JA to "%sCANが追加で支給されました。"
),
"can.charge.event.first_title" to mapOf(
Lang.KO to "첫 충전 이벤트",
@@ -500,7 +500,7 @@ class SodaMessageSource {
"can.coupon.invalid_number_contact" to mapOf(
Lang.KO to "잘못된 쿠폰번호입니다.\n고객센터로 문의해 주시기 바랍니다.",
Lang.EN to "Invalid coupon number.\nPlease contact customer support.",
Lang.JA to "無効なクーポン番号です。\nカスタマーサポートへお問い合わせください。"
Lang.JA to "無効なクーポン番号です。\nサポートへお問い合わせください。"
),
"can.coupon.already_used" to mapOf(
Lang.KO to "이미 사용한 쿠폰번호 입니다.",
@@ -510,7 +510,7 @@ class SodaMessageSource {
"can.coupon.use_complete" to mapOf(
Lang.KO to "쿠폰 사용이 완료되었습니다.\n%s캔이 지급되었습니다.",
Lang.EN to "Coupon redeemed successfully.\n%s cans have been granted.",
Lang.JA to "クーポンの使用が完了しました。\n%sが支給されました。"
Lang.JA to "クーポンの使用が完了しました。\n%sCANが支給されました。"
),
"can.coupon.use_complete_point" to mapOf(
Lang.KO to "쿠폰 사용이 완료되었습니다.\n%s포인트가 지급되었습니다.",
@@ -545,7 +545,7 @@ class SodaMessageSource {
"can.coupon.auth_required" to mapOf(
Lang.KO to "쿠폰은 본인인증을 하셔야 사용이 가능합니다.",
Lang.EN to "You must verify your identity to use coupons.",
Lang.JA to "クーポンの使用には本人認が必要です。"
Lang.JA to "クーポンの使用には本人認が必要です。"
),
"can.coupon.download_failed_retry" to mapOf(
Lang.KO to "다운로드를 하지 못했습니다.\n다시 시도해 주세요.",
@@ -588,7 +588,7 @@ class SodaMessageSource {
"can.payment.invalid_request_retry" to mapOf(
Lang.KO to "잘못된 요청입니다.\n다시 시도해 주세요.",
Lang.EN to "Invalid request.\ntry again.",
Lang.JA to "不正なリクエストです。\nもう一度お試しください。"
Lang.JA to "不正なリクエストです。\n恐れ入りますが、もう一度お試しください。"
),
"can.payment.invalid_reservation" to mapOf(
Lang.KO to "잘못된 예약정보 입니다.",
@@ -603,7 +603,7 @@ class SodaMessageSource {
"can.payment.insufficient_can" to mapOf(
Lang.KO to "%s 캔이 부족합니다. 충전 후 이용해 주세요.",
Lang.EN to "You are short of %s cans. Please recharge and try again.",
Lang.JA to "%sが不足しています。チャージしてからご利用ください。"
Lang.JA to "%sCANが不足しています。チャージしてからご利用ください。"
)
)
@@ -967,7 +967,7 @@ class SodaMessageSource {
"admin.signature_can.min_can" to mapOf(
Lang.KO to "1캔 이상 설정할 수 있습니다.",
Lang.EN to "You can set at least 1 can.",
Lang.JA to "1以上設定できます。"
Lang.JA to "1CAN以上設定できます。"
),
"admin.signature_can.time_range" to mapOf(
Lang.KO to "시간은 3초 이상 20초 이하로 설정할 수 있습니다.",
@@ -980,7 +980,7 @@ class SodaMessageSource {
"admin.media_partner.invalid_access" to mapOf(
Lang.KO to "잘못된 접근입니다",
Lang.EN to "Invalid access.",
Lang.JA to "不正なアクセスです。"
Lang.JA to "無効なアクセスです。"
)
)
@@ -998,7 +998,7 @@ class SodaMessageSource {
"admin.member.reset_password_invalid" to mapOf(
Lang.KO to "잘못된 회원정보입니다.\n다시 시도해 주세요.",
Lang.EN to "Invalid member information.\nPlease try again.",
Lang.JA to "不正な会員情報です。\nもう一度お試しください。"
Lang.JA to "不正な会員情報です。もう一度お試しください。"
),
"admin.member.role.admin" to mapOf(
Lang.KO to "관리자",
@@ -1065,7 +1065,7 @@ class SodaMessageSource {
"message.error.blocked_by_recipient" to mapOf(
Lang.KO to "%s님의 요청으로 메시지를 보낼 수 없습니다.",
Lang.EN to "You cannot send messages at %s's request.",
Lang.JA to "%sの要請によりメッセージを送信できません。"
Lang.JA to "%sさんの要請によりメッセージを送信できません。"
),
"message.fcm.title" to mapOf(
Lang.KO to "메시지",
@@ -1085,7 +1085,7 @@ class SodaMessageSource {
"message.error.not_found_retry" to mapOf(
Lang.KO to "해당하는 메시지가 없습니다.\n다시 확인해 주시기 바랍니다.",
Lang.EN to "Message not found. Please check again.",
Lang.JA to "該当するメッセージがありません。\nもう一度ご確認ください。"
Lang.JA to "該当するメッセージがありません。もう一度ご確認ください。"
),
"message.error.already_kept" to mapOf(
Lang.KO to "이미 보관된 메시지 입니다.",
@@ -1142,25 +1142,24 @@ class SodaMessageSource {
"member.auth.already_verified" to mapOf(
Lang.KO to "이미 인증된 계정입니다.",
Lang.EN to "This account is already verified.",
Lang.JA to "既に認済みのアカウントです。"
Lang.JA to "既に認済みのアカウントです。"
),
"member.auth.certificate_invalid_retry" to mapOf(
Lang.KO to "인증정보에 오류가 있습니다.\n다시 시도해 주세요.",
Lang.EN to "There is an error with the verification information.\nPlease try again.",
Lang.JA to "認証情報にエラーがあります。\nもう一度お試しください。"
Lang.JA to "情報にエラーがあります。恐れ入りますがもう一度お試しください。"
),
"member.auth.max_accounts" to mapOf(
Lang.KO to "이미 본인인증한 계정 %s개 이용중입니다.\n" +
"소다라이브의 본인인증은 최대 3개의 계정만 이용할 수 있습니다.",
Lang.EN to "You are already using %s verified account(s).\n" +
"Identity verification is limited to up to 3 accounts on Sodalive.",
Lang.JA to "本人認済みのアカウントを%s件利用中です。\n" +
"ソダライブの本人認証は最大3アカウントまでです。"
Lang.JA to "本人認済みのアカウントを%s件利用中です。ソダライブの本人確認は最大3アカウントまでです。"
),
"member.auth.age_limit" to mapOf(
Lang.KO to "%s년 1월 1일 이전 출생자만 본인인증이 가능합니다.",
Lang.EN to "Only users born on or before January 1, %s can be verified.",
Lang.JA to "%s年1月1日以前に生まれた方のみ本人認が可能です。"
Lang.JA to "%s年1月1日以前に生まれた方のみ本人認が可能です。"
)
)
@@ -1196,7 +1195,7 @@ class SodaMessageSource {
"member.validation.agree_required" to mapOf(
Lang.KO to "약관에 동의하셔야 회원가입이 가능합니다.",
Lang.EN to "You must agree to the terms to sign up.",
Lang.JA to "会員登録には規約への同意が必要です。"
Lang.JA to "利用規約に同意すると会員登録できます。"
),
"member.validation.user_not_found" to mapOf(
Lang.KO to "없는 사용자 입니다.",
@@ -1211,7 +1210,7 @@ class SodaMessageSource {
"member.validation.inactive_account" to mapOf(
Lang.KO to "탈퇴한 계정입니다.\n고객센터로 문의해 주시기 바랍니다.",
Lang.EN to "This account has been deleted.\nPlease contact customer support.",
Lang.JA to "退会したアカウントです。\nカスタマーサポートお問い合わせください。"
Lang.JA to "退会したアカウントです。\nサポートお問い合わせください。"
),
"member.validation.creator_not_found" to mapOf(
Lang.KO to "크리에이터 정보를 확인해주세요.",
@@ -1241,7 +1240,7 @@ class SodaMessageSource {
"member.validation.nickname_available" to mapOf(
Lang.KO to "사용 가능한 닉네임 입니다.",
Lang.EN to "This nickname is available.",
Lang.JA to "使用可能なニックネームです。"
Lang.JA to "使用可能なハンドルネームです。"
),
"member.validation.email_in_use" to mapOf(
Lang.KO to "이미 사용중인 이메일 입니다.",
@@ -1251,7 +1250,7 @@ class SodaMessageSource {
"member.validation.nickname_in_use" to mapOf(
Lang.KO to "이미 사용중인 닉네임 입니다.",
Lang.EN to "This nickname is already in use.",
Lang.JA to "このニックネームは既に使用されています。"
Lang.JA to "このハンドルネームは既に使用されています。"
),
"member.validation.email_registered_with_provider" to mapOf(
Lang.KO to "해당 이메일은 %s 계정으로 가입되어 있습니다. 해당 소셜 로그인을 사용해 주세요.",
@@ -1274,12 +1273,22 @@ class SodaMessageSource {
"member.social.google_login_failed" to mapOf(
Lang.KO to "구글 로그인을 하지 못했습니다. 다시 시도해 주세요",
Lang.EN to "Google sign-in failed. Please try again.",
Lang.JA to "Googleでログインできませんでした。恐れ入りますが、もう一度お試しください。"
Lang.JA to "Googleでログインできませんでした。もう一度お試しください。"
),
"member.social.kakao_login_failed" to mapOf(
Lang.KO to "카카오 로그인을 하지 못했습니다. 다시 시도해 주세요",
Lang.EN to "Kakao login failed. Please try again.",
Lang.JA to "カカオログインに失敗しました。もう一度お試しください。"
Lang.JA to "Kakaoでログインできませんでした。もう一度お試しください。"
),
"member.social.apple_login_failed" to mapOf(
Lang.KO to "애플 로그인을 하지 못했습니다. 다시 시도해 주세요",
Lang.EN to "Apple sign-in failed. Please try again.",
Lang.JA to "Appleでログインできませんでした。もう一度お試しください。"
),
"member.social.line_login_failed" to mapOf(
Lang.KO to "라인 로그인을 하지 못했습니다. 다시 시도해 주세요",
Lang.EN to "LINE sign-in failed. Please try again.",
Lang.JA to "LINEでログインできませんでした。もう一度お試しください。"
),
"member.social.email_consent_required" to mapOf(
Lang.KO to "이메일 제공에 동의하셔야 서비스 이용이 가능합니다.",
@@ -1328,7 +1337,7 @@ class SodaMessageSource {
"live.reservation.invalid_request_retry" to mapOf(
Lang.KO to "잘못된 요청입니다.\n다시 시도해 주세요.",
Lang.EN to "Invalid request.\ntry again.",
Lang.JA to "不正なリクエストです。\nもう一度やり直してください。"
Lang.JA to "不正なリクエストです。\n恐れ入りますが、もう一度お試しください。"
),
"live.reservation.already_reserved" to mapOf(
Lang.KO to "이미 예약한 라이브 입니다.",
@@ -1376,17 +1385,17 @@ class SodaMessageSource {
"live.roulette.creator_contract_only" to mapOf(
Lang.KO to "주식회사 소다라이브와 계약한\n크리에이터의 룰렛만 사용하실 수 있습니다.",
Lang.EN to "Only roulette from creators contracted with Sodalive Co., Ltd. can be used.",
Lang.JA to "株式会社ソダライブと契約した\nクリエイターのルーレットのみ利用できます。"
Lang.JA to "株式会社ソダライブと契約したクリエイターのルーレットのみ利用できます。"
),
"live.roulette.refund_failed" to mapOf(
Lang.KO to "룰렛 돌리기에 실패한 캔이 환불되지 않았습니다\n고객센터로 문의해주세요.",
Lang.EN to "Cans from the failed roulette spin have not been refunded.\nPlease contact customer support.",
Lang.JA to "ルーレットの失敗分のが返金されていません。\nカスタマーサポートへお問い合わせください。"
Lang.JA to "ルーレットの失敗分のCANが返金されていません。\nサポートへお問い合わせください。"
),
"live.roulette.min_can" to mapOf(
Lang.KO to "룰렛 금액은 최소 5캔 입니다.",
Lang.EN to "Roulette cost is at least 5 cans.",
Lang.JA to "ルーレット金額は最低5です。"
Lang.JA to "ルーレット金額は最低5CANです。"
),
"live.roulette.item_count_range" to mapOf(
Lang.KO to "룰렛 옵션은 최소 2개, 최대 10개까지 설정할 수 있습니다.",
@@ -1406,7 +1415,7 @@ class SodaMessageSource {
"live.roulette.can_title" to mapOf(
Lang.KO to "%s 캔",
Lang.EN to "%s cans",
Lang.JA to "%s "
Lang.JA to "%s CAN"
),
"live.roulette.refund_method" to mapOf(
Lang.KO to "룰렛 환불",
@@ -1439,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.",
@@ -1462,7 +1476,7 @@ class SodaMessageSource {
"live.room.paid_min_can" to mapOf(
Lang.KO to "유료라이브는 10캔부터 설정 가능 합니다.",
Lang.EN to "Paid live can be set from 10 cans.",
Lang.JA to "有料ライブは10から設定できます。"
Lang.JA to "有料ライブは10CANから設定できます。"
),
"live.room.already_ended" to mapOf(
Lang.KO to "이미 종료된 방입니다.",
@@ -1472,7 +1486,7 @@ class SodaMessageSource {
"live.room.adult_verification_required" to mapOf(
Lang.KO to "본인인증이 필요한 서비스 입니다.",
Lang.EN to "This service requires identity verification.",
Lang.JA to "本人認が必要なサービスです。"
Lang.JA to "本人認が必要なサービスです。"
),
"live.room.not_found" to mapOf(
Lang.KO to "해당하는 라이브가 없습니다.",
@@ -1497,12 +1511,12 @@ class SodaMessageSource {
"live.room.enter_blocked_by_host" to mapOf(
Lang.KO to "%s님의 요청으로 라이브에 입장할 수 없습니다.",
Lang.EN to "You cannot enter the live at %s's request.",
Lang.JA to "%sの要請によりライブに入できません。"
Lang.JA to "%sさんの要請によりライブに入できません。"
),
"live.room.participation_blocked_by_host" to mapOf(
Lang.KO to "%s님의 요청으로 라이브에 참여할 수 없습니다.",
Lang.EN to "You cannot participate in the live at %s's request.",
Lang.JA to "%sの要請によりライブに参加できません。"
Lang.JA to "%sさんの要請によりライブに参加できません。"
),
"live.room.full" to mapOf(
Lang.KO to "방이 가득찼습니다.",
@@ -1512,7 +1526,7 @@ class SodaMessageSource {
"live.room.insufficient_can" to mapOf(
Lang.KO to "%d캔이 부족합니다. 충전 후 이용해 주세요.",
Lang.EN to "You need %d more cans. Please top up and try again.",
Lang.JA to "%dが不足しています。チャージしてご利用ください。"
Lang.JA to "%dCANが不足しています。チャージしてご利用ください。"
),
"live.room.recent_not_found" to mapOf(
Lang.KO to "최근 데이터가 없습니다.",
@@ -1547,12 +1561,12 @@ class SodaMessageSource {
"live.room.creator_contract_only_donation" to mapOf(
Lang.KO to "주식회사 소다라이브와 계약한\n크리에이터에게만 후원을 하실 수 있습니다.",
Lang.EN to "You can only donate to creators contracted with Sodalive Co., Ltd.",
Lang.JA to "株式会社ソダライブと契約した\nクリエイターにのみ支援できます。"
Lang.JA to "株式会社ソダライブと契約したクリエイターにのみギフトできます。"
),
"live.room.donation_refund_failed" to mapOf(
Lang.KO to "후원에 실패한 캔이 환불되지 않았습니다\n고객센터로 문의해주세요.",
Lang.EN to "Cans from the failed donation have not been refunded.\nPlease contact customer support.",
Lang.JA to "支援失敗分のが返金されていません。\nカスタマーサポートへお問い合わせください。"
Lang.JA to "ギフト失敗分のCANが返金されていません。\nサポートへお問い合わせください。"
),
"live.room.datetime_format" to mapOf(
Lang.KO to "yyyy년 MM월 dd일 (E) a hh시 mm분",
@@ -1564,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",
@@ -1587,7 +1616,7 @@ class SodaMessageSource {
"live.room.can_title" to mapOf(
Lang.KO to "%d 캔",
Lang.EN to "%d cans",
Lang.JA to "%d "
Lang.JA to "%d CAN"
),
"live.room.refund_method" to mapOf(
Lang.KO to "환불",
@@ -1629,6 +1658,11 @@ class SodaMessageSource {
Lang.KO to "애플",
Lang.EN to "Apple",
Lang.JA to "Apple"
),
"member.provider.line" to mapOf(
Lang.KO to "라인",
Lang.EN to "LINE",
Lang.JA to "LINE"
)
)
@@ -1769,7 +1803,7 @@ class SodaMessageSource {
"creator.admin.member.inactive_account" to mapOf(
Lang.KO to "탈퇴한 계정입니다.\n고객센터로 문의해 주시기 바랍니다.",
Lang.EN to "This account has been deactivated.\nPlease contact customer support.",
Lang.JA to "退会したアカウントです。\nカスタマーサポートにお問い合わせください。"
Lang.JA to "退会したアカウントです。\nサポートにお問い合わせください。"
)
)
@@ -1797,7 +1831,7 @@ class SodaMessageSource {
"creator.admin.signature.min_can" to mapOf(
Lang.KO to "1캔 이상 설정할 수 있습니다.",
Lang.EN to "You can set at least 1 can.",
Lang.JA to "1以上設定できます。"
Lang.JA to "1CAN以上設定できます。"
),
"creator.admin.signature.time_range" to mapOf(
Lang.KO to "시간은 3초 이상 20초 이하로 설정할 수 있습니다.",
@@ -2000,7 +2034,7 @@ class SodaMessageSource {
"chat.character.image.min_price" to mapOf(
Lang.KO to "가격은 0 can 이상이어야 합니다.",
Lang.EN to "Price must be at least 0 can.",
Lang.JA to "価格は0can以上である必要があります。"
Lang.JA to "価格は0CAN以上である必要があります。"
),
"chat.character.image.inactive_update" to mapOf(
Lang.KO to "비활성화된 이미지는 수정할 수 없습니다.",
@@ -2052,7 +2086,7 @@ class SodaMessageSource {
"chat.room.quota.invalid_access" to mapOf(
Lang.KO to "잘못된 접근입니다",
Lang.EN to "Invalid access.",
Lang.JA to "不正なアクセスです。"
Lang.JA to "無効なアクセスです。"
),
"chat.room.quota.not_ai_room" to mapOf(
Lang.KO to "AI 캐릭터 채팅방이 아닙니다.",
@@ -2080,7 +2114,7 @@ class SodaMessageSource {
"chat.room.invalid_access" to mapOf(
Lang.KO to "잘못된 접근입니다",
Lang.EN to "Invalid access.",
Lang.JA to "不正なアクセスです。"
Lang.JA to "無効なアクセスです。"
),
"chat.room.not_ai_room" to mapOf(
Lang.KO to "AI 캐릭터 채팅방이 아닙니다.",
@@ -2125,7 +2159,7 @@ class SodaMessageSource {
"chat.room.session_end_failed" to mapOf(
Lang.KO to "채팅방 세션 종료에 실패했습니다. 다시 시도해 주세요.",
Lang.EN to "Failed to end the chat room session. Please try again.",
Lang.JA to "チャットルームのセッション終了に失敗しました。もう一度お試しください。"
Lang.JA to "チャットルームのセッション終了に失敗しました。恐れ入りますが、もう一度お試しください。"
),
"chat.message.send_failed" to mapOf(
Lang.KO to "메시지 전송을 실패했습니다.",
@@ -2173,22 +2207,22 @@ class SodaMessageSource {
"creator.community.invalid_request_retry" to mapOf(
Lang.KO to "잘못된 요청입니다.\n다시 시도해 주세요.",
Lang.EN to "Invalid request.\ntry again.",
Lang.JA to "不正なリクエストです。\nもう一度お試しください。"
Lang.JA to "不正なリクエストです。恐れ入りますが、もう一度お試しください。"
),
"creator.community.invalid_post_retry" to mapOf(
Lang.KO to "잘못된 게시물 입니다.\n다시 시도해 주세요.",
Lang.EN to "Invalid post.\nPlease try again.",
Lang.JA to "不正な投稿です。\nもう一度お試しください。"
Lang.JA to "不正な投稿です。恐れ入りますが、もう一度お試しください。"
),
"creator.community.secret_comment_purchase_required" to mapOf(
Lang.KO to "게시글을 구매 후 비밀댓글을 등록할 수 있습니다.",
Lang.EN to "You can post a secret comment after purchasing the post.",
Lang.JA to "投稿を購入した後に秘密コメントを登録できます。"
Lang.JA to "投稿を購入した後にシークレットコメントを登録できます。"
),
"creator.community.invalid_access_retry" to mapOf(
Lang.KO to "잘못된 접근 입니다.\n확인 후 다시 시도해 주세요.",
Lang.EN to "Invalid access.\nPlease check and try again.",
Lang.JA to "不正なアクセスです。\n確認して再度お試しください。"
Lang.JA to "不正なアクセスです。\n恐れ入りますが、確認後再度お試しください。"
),
"creator.community.blocked_access" to mapOf(
Lang.KO to "%s님의 요청으로 접근이 제한됩니다.",

View File

@@ -28,6 +28,7 @@ import kr.co.vividnext.sodalive.content.translation.ContentTranslation
import kr.co.vividnext.sodalive.content.translation.ContentTranslationPayload
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService.Companion.getTranslatableLanguageCodes
import org.springframework.context.event.EventListener
import org.springframework.data.repository.findByIdOrNull
import org.springframework.scheduling.annotation.Async
import org.springframework.stereotype.Component
@@ -51,7 +52,8 @@ enum class LanguageTranslationTargetType {
class LanguageTranslationEvent(
val id: Long,
val targetType: LanguageTranslationTargetType
val targetType: LanguageTranslationTargetType,
val waitTransactionCommit: Boolean = false
)
@Component
@@ -76,9 +78,20 @@ class LanguageTranslationListener(
private val translationService: PapagoTranslationService
) {
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@EventListener(condition = "!#event.waitTransactionCommit")
@Transactional(propagation = Propagation.REQUIRES_NEW)
fun translation(event: LanguageTranslationEvent) {
fun translationImmediate(event: LanguageTranslationEvent) {
processTranslation(event)
}
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, condition = "#event.waitTransactionCommit")
@Transactional(propagation = Propagation.REQUIRES_NEW)
fun translationAfterCommit(event: LanguageTranslationEvent) {
processTranslation(event)
}
private fun processTranslation(event: LanguageTranslationEvent) {
when (event.targetType) {
LanguageTranslationTargetType.CONTENT -> handleContentLanguageTranslation(event)
LanguageTranslationTargetType.CHARACTER -> handleCharacterLanguageTranslation(event)
@@ -92,8 +105,7 @@ class LanguageTranslationListener(
private fun handleContentLanguageTranslation(event: LanguageTranslationEvent) {
val audioContent = audioContentRepository.findByIdOrNull(event.id) ?: return
val languageCode = audioContent.languageCode
if (languageCode != null) return
val languageCode = audioContent.languageCode ?: return
getTranslatableLanguageCodes(languageCode).forEach { locale ->
val tags = audioContent.audioContentHashTags
@@ -150,8 +162,7 @@ class LanguageTranslationListener(
private fun handleCharacterLanguageTranslation(event: LanguageTranslationEvent) {
val character = chatCharacterRepository.findByIdOrNull(event.id) ?: return
val languageCode = character.languageCode
if (languageCode != null) return
val languageCode = character.languageCode ?: return
getTranslatableLanguageCodes(languageCode).forEach { locale ->
val personality = character.personalities.firstOrNull()
@@ -285,8 +296,7 @@ class LanguageTranslationListener(
private fun handleSeriesLanguageTranslation(event: LanguageTranslationEvent) {
val series = seriesRepository.findByIdOrNull(event.id) ?: return
val languageCode = series.languageCode
if (languageCode != null) return
val languageCode = series.languageCode ?: return
getTranslatableLanguageCodes(languageCode).forEach { locale ->
val keywords = series.keywordList
@@ -385,8 +395,7 @@ class LanguageTranslationListener(
private fun handleOriginalWorkLanguageTranslation(event: LanguageTranslationEvent) {
val originalWork = originalWorkRepository.findByIdOrNull(event.id) ?: return
val languageCode = originalWork.languageCode
if (languageCode != null) return
val languageCode = originalWork.languageCode ?: return
/**
* handleSeriesLanguageTranslation 참조하여 원작 번역 구현

View File

@@ -8,5 +8,6 @@ data class GetLiveReservationResponse(
val price: Int,
val masterNickname: String,
val beginDateTime: String,
val beginDateTimeUtc: String,
val cancelable: Boolean
)

View File

@@ -83,6 +83,8 @@ class LiveReservationService(
nickname = room.member!!.nickname,
title = room.title,
beginDateString = beginDateTime.format(DateTimeFormatter.ofPattern(reservationDateFormat)),
beginDateTimeUtc = room.beginDateTime
.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME),
price = if (room.price > 0) {
val priceTemplate = messageSource.getMessage("live.room.can_title", langContext.lang).orEmpty()
String.format(priceTemplate, room.price)
@@ -122,6 +124,9 @@ class LiveReservationService(
beginDateTime = beginDateTime.format(
DateTimeFormatter.ofPattern(detailDateFormat)
),
beginDateTimeUtc = it.room!!.beginDateTime
.atZone(ZoneId.of("UTC"))
.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME),
cancelable = beginDateTime.minusHours(4).isAfter(
LocalDateTime.now()
.atZone(ZoneId.of("UTC"))
@@ -158,6 +163,9 @@ class LiveReservationService(
beginDateTime = beginDateTime.format(
DateTimeFormatter.ofPattern(detailDateFormat)
),
beginDateTimeUtc = reservation.room!!.beginDateTime
.atZone(ZoneId.of("UTC"))
.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME),
cancelable = beginDateTime.minusHours(4).isAfter(
LocalDateTime.now()
.atZone(ZoneId.of("UTC"))

View File

@@ -5,6 +5,7 @@ data class MakeLiveReservationResponse(
val nickname: String,
val title: String,
val beginDateString: String,
val beginDateTimeUtc: String,
val price: String,
val haveCan: Int,
val useCan: Int,

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
@@ -70,7 +71,6 @@ import java.time.LocalDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Date
import java.util.Locale
import java.util.concurrent.locks.ReentrantReadWriteLock
import kotlin.concurrent.write
@@ -96,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,
@@ -127,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?,
@@ -136,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(
@@ -151,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!!)
@@ -187,13 +250,18 @@ class LiveRoomService(
.withZoneSameInstant(ZoneId.of(timezone))
.format(
DateTimeFormatter
.ofPattern("yyyy년 MM월 dd일 (E) a hh시 mm분")
.withLocale(Locale.KOREAN)
.ofPattern(messageSource.getMessage("live.room.datetime_format", langContext.lang).orEmpty())
.withLocale(langContext.lang.locale)
)
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,
@@ -214,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 {
@@ -235,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,
@@ -243,7 +308,8 @@ class LiveRoomService(
timezone = timezone,
memberId = memberId,
isCreator = isCreator,
isAdult = isAdult
isAdult = isAdult,
effectiveGender = effectiveGender
)
}
@@ -253,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()
@@ -267,7 +334,8 @@ class LiveRoomService(
limit = pageable.pageSize.toLong(),
memberId = memberId,
isCreator = isCreator,
isAdult = isAdult
isAdult = isAdult,
effectiveGender = effectiveGender
)
}
@@ -275,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
@@ -339,7 +408,8 @@ class LiveRoomService(
},
type = request.type,
password = request.password,
isAvailableJoinCreator = request.isAvailableJoinCreator
isAvailableJoinCreator = request.isAvailableJoinCreator,
genderRestriction = request.genderRestriction
)
room.member = member
@@ -405,35 +475,21 @@ class LiveRoomService(
}
}
val createdMessage = if (createdRoom.channelName != null) {
formatMessage("live.room.fcm.message.started", createdRoom.title)
} else {
formatMessage("live.room.fcm.message.reserved", createdRoom.title)
}
applicationEventPublisher.publishEvent(
FcmEvent(
type = FcmEventType.CREATE_LIVE,
title = createdRoom.member!!.nickname,
message = createdMessage,
messageKey = if (createdRoom.channelName != null) {
"live.room.fcm.message.started"
} else {
"live.room.fcm.message.reserved"
},
args = listOf(createdRoom.title),
isAuth = createdRoom.isAdult,
isAvailableJoinCreator = createdRoom.isAvailableJoinCreator,
roomId = createdRoom.id,
creatorId = createdRoom.member!!.id,
container = "ios"
)
)
applicationEventPublisher.publishEvent(
FcmEvent(
type = FcmEventType.CREATE_LIVE,
title = createdRoom.member!!.nickname,
message = createdMessage,
isAuth = createdRoom.isAdult,
isAvailableJoinCreator = createdRoom.isAvailableJoinCreator,
roomId = createdRoom.id,
creatorId = createdRoom.member!!.id,
container = "aos"
genderRestriction = createdRoom.genderRestriction
)
)
@@ -448,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))
@@ -456,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,
@@ -470,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)
@@ -580,30 +645,17 @@ class LiveRoomService(
room.beginDateTime = nowDateTime
val startedMessage = formatMessage("live.room.fcm.message.started_now", room.title)
applicationEventPublisher.publishEvent(
FcmEvent(
type = FcmEventType.START_LIVE,
title = room.member!!.nickname,
message = startedMessage,
messageKey = "live.room.fcm.message.started",
args = listOf(room.title),
isAuth = room.isAdult,
isAvailableJoinCreator = room.isAvailableJoinCreator,
roomId = room.id,
creatorId = room.member!!.id,
container = "ios"
)
)
applicationEventPublisher.publishEvent(
FcmEvent(
type = FcmEventType.START_LIVE,
title = room.member!!.nickname,
message = startedMessage,
isAuth = room.isAdult,
isAvailableJoinCreator = room.isAvailableJoinCreator,
roomId = room.id,
creatorId = room.member!!.id,
container = "aos"
genderRestriction = room.genderRestriction
)
)
}
@@ -660,16 +712,17 @@ class LiveRoomService(
}
}
val pushTokenListMap = memberRepository.getPushTokenFromReservationList(request.roomId)
val pushTokens = memberRepository.getPushTokenFromReservationList(request.roomId)
reservationRepository.cancelReservation(roomId = room.id!!)
val cancelMessage = formatMessage("live.room.fcm.message.canceled", room.title)
applicationEventPublisher.publishEvent(
FcmEvent(
type = FcmEventType.CANCEL_LIVE,
title = room.member!!.nickname,
message = cancelMessage,
recipientsMap = pushTokenListMap
messageKey = "live.room.fcm.message.canceled",
args = listOf(room.title),
pushTokens = pushTokens
)
)
}
@@ -701,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)
@@ -815,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) {
@@ -856,7 +917,7 @@ class LiveRoomService(
agoraAppId,
agoraAppCertificate,
room.channelName!!,
member.id!!.toInt(),
member.id!!.toString(),
expireTimestamp.toInt()
)
@@ -867,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)
@@ -894,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,
@@ -915,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) {
@@ -931,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
@@ -19,7 +20,7 @@ import javax.persistence.OneToOne
@Entity
data class Member(
val email: String,
var email: String? = null,
var password: String,
var nickname: String,
var profileImage: String? = null,
@@ -27,6 +28,7 @@ data class Member(
val kakaoId: Long? = null,
val googleId: String? = null,
val appleId: String? = null,
val lineId: String? = null,
@Enumerated(EnumType.STRING)
val provider: MemberProvider = MemberProvider.EMAIL,
@@ -45,9 +47,15 @@ data class Member(
var isVisibleDonationRank: Boolean = true,
@Enumerated(value = EnumType.STRING)
var donationRankingPeriod: DonationRankingPeriod? = DonationRankingPeriod.CUMULATIVE,
var isActive: Boolean = true,
var container: String = "web"
var container: String = "web",
// ISO 3166-1 alpha-2 국가 코드
var countryCode: String? = null
) : BaseEntity() {
@OneToMany(mappedBy = "member", cascade = [CascadeType.ALL])
val stipulationAgrees: MutableList<StipulationAgree> = mutableListOf()
@@ -144,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 {
@@ -155,5 +179,9 @@ enum class MemberRole {
}
enum class MemberProvider {
EMAIL, KAKAO, GOOGLE, APPLE
EMAIL, KAKAO, GOOGLE, APPLE, LINE
}
enum class DonationRankingPeriod {
WEEKLY, CUMULATIVE
}

View File

@@ -4,7 +4,7 @@ import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.core.userdetails.User
class MemberAdapter(val member: Member) : User(
member.email,
member.email ?: "member:${member.id}",
member.password,
listOf(SimpleGrantedAuthority("ROLE_${member.role.name}"))
)

View File

@@ -13,8 +13,7 @@ import kr.co.vividnext.sodalive.member.login.LoginResponse
import kr.co.vividnext.sodalive.member.login.SocialLoginRequest
import kr.co.vividnext.sodalive.member.notification.UpdateNotificationSettingRequest
import kr.co.vividnext.sodalive.member.signUp.SignUpRequestV2
import kr.co.vividnext.sodalive.member.social.google.GoogleAuthService
import kr.co.vividnext.sodalive.member.social.kakao.KakaoAuthService
import kr.co.vividnext.sodalive.member.social.SocialAuthServiceResolver
import kr.co.vividnext.sodalive.useraction.ActionType
import kr.co.vividnext.sodalive.useraction.UserActionService
import org.springframework.data.domain.Pageable
@@ -36,8 +35,7 @@ import org.springframework.web.multipart.MultipartFile
@RequestMapping("/member")
class MemberController(
private val service: MemberService,
private val kakaoAuthService: KakaoAuthService,
private val googleAuthService: GoogleAuthService,
private val socialAuthServiceResolver: SocialAuthServiceResolver,
private val trackingService: AdTrackingService,
private val userActionService: UserActionService,
private val messageSource: SodaMessageSource,
@@ -345,31 +343,8 @@ class MemberController(
@RequestHeader("Authorization") authHeader: String,
@RequestBody request: SocialLoginRequest
): ApiResponse<LoginResponse> {
if (!authHeader.startsWith("Bearer ")) {
throw SodaException(messageKey = "member.social.google_login_failed")
}
val token = authHeader.substring(7)
val response = googleAuthService.authenticate(token, request.container, request.marketingPid, request.pushToken)
if (!response.marketingPid.isNullOrBlank()) {
trackingService.saveTrackingHistory(
pid = response.marketingPid,
type = AdTrackingHistoryType.SIGNUP,
memberId = response.memberId
)
}
if (response.isNew) {
userActionService.recordAction(
memberId = response.memberId,
isAuth = false,
actionType = ActionType.SIGN_UP
)
}
val message = messageSource.getMessage("member.signup.success", langContext.lang)
return ApiResponse.ok(message = message, data = response.loginResponse)
val token = extractBearerToken(authHeader, MemberProvider.GOOGLE)
return processSocialLogin(MemberProvider.GOOGLE, token, request, null)
}
@PostMapping("/login/kakao")
@@ -377,12 +352,50 @@ class MemberController(
@RequestHeader("Authorization") authHeader: String,
@RequestBody request: SocialLoginRequest
): ApiResponse<LoginResponse> {
if (!authHeader.startsWith("Bearer ")) {
throw SodaException(messageKey = "member.social.kakao_login_failed")
}
val token = extractBearerToken(authHeader, MemberProvider.KAKAO)
return processSocialLogin(MemberProvider.KAKAO, token, request, null)
}
val token = authHeader.substring(7)
val response = kakaoAuthService.authenticate(token, request.container, request.marketingPid, request.pushToken)
@PostMapping("/login/apple")
fun loginApple(
@RequestBody request: SocialLoginRequest
): ApiResponse<LoginResponse> {
val errorKey = socialLoginErrorKey(MemberProvider.APPLE)
val token = request.identityToken?.takeIf { it.isNotBlank() }
?: throw SodaException(messageKey = errorKey)
val nonce = request.nonce?.takeIf { it.isNotBlank() }
?: throw SodaException(messageKey = errorKey)
return processSocialLogin(MemberProvider.APPLE, token, request, nonce)
}
@PostMapping("/login/line")
fun loginLine(
@RequestBody request: SocialLoginRequest
): ApiResponse<LoginResponse> {
val errorKey = socialLoginErrorKey(MemberProvider.LINE)
val token = request.identityToken?.takeIf { it.isNotBlank() }
?: throw SodaException(messageKey = errorKey)
val nonce = request.nonce?.takeIf { it.isNotBlank() }
?: throw SodaException(messageKey = errorKey)
return processSocialLogin(MemberProvider.LINE, token, request, nonce)
}
private fun processSocialLogin(
provider: MemberProvider,
token: String,
request: SocialLoginRequest,
nonce: String?
): ApiResponse<LoginResponse> {
val authService = socialAuthServiceResolver.resolve(provider)
val response = authService.authenticate(
token = token,
container = request.container,
marketingPid = request.marketingPid,
pushToken = request.pushToken,
nonce = nonce
)
if (!response.marketingPid.isNullOrBlank()) {
trackingService.saveTrackingHistory(
@@ -403,4 +416,22 @@ class MemberController(
val message = messageSource.getMessage("member.signup.success", langContext.lang)
return ApiResponse.ok(message = message, data = response.loginResponse)
}
private fun extractBearerToken(authHeader: String, provider: MemberProvider): String {
val errorKey = socialLoginErrorKey(provider)
if (!authHeader.startsWith("Bearer ")) {
throw SodaException(messageKey = errorKey)
}
return authHeader.substring(7)
}
private fun socialLoginErrorKey(provider: MemberProvider): String {
return when (provider) {
MemberProvider.GOOGLE -> "member.social.google_login_failed"
MemberProvider.KAKAO -> "member.social.kakao_login_failed"
MemberProvider.APPLE -> "member.social.apple_login_failed"
MemberProvider.LINE -> "member.social.line_login_failed"
else -> "common.error.bad_request"
}
}
}

View File

@@ -1,10 +1,11 @@
package kr.co.vividnext.sodalive.member
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.fcm.GetMessageRecipientPushTokenResponse
import kr.co.vividnext.sodalive.fcm.QGetMessageRecipientPushTokenResponse
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
@@ -20,45 +21,46 @@ import org.springframework.stereotype.Repository
@Repository
interface MemberRepository : JpaRepository<Member, Long>, MemberQueryRepository {
fun findByEmail(email: String): Member?
fun findByEmail(email: String?): Member?
fun findByNickname(nickname: String): Member?
fun findByGoogleId(googleId: String): Member?
fun findByKakaoId(kakaoId: Long): Member?
fun findByAppleId(appleId: String): Member?
fun findByLineId(lineId: String): Member?
}
interface MemberQueryRepository {
fun findByNicknameAndOtherCondition(nickname: String, member: Member): List<Member>
fun findCreatorByIdOrNull(memberId: Long): Member?
fun getAllRecipientPushTokens(isAuth: Boolean?, container: String): List<List<String>>
fun getAllRecipientPushTokens(isAuth: Boolean?): List<PushTokenInfo>
fun getCreateLiveRoomNotificationRecipientPushTokens(
creatorId: Long,
isAuth: Boolean,
isAvailableJoinCreator: Boolean,
container: String
): List<List<String>>
genderRestriction: GenderRestriction? = null
): List<PushTokenInfo>
fun getStartLiveRoomNotificationRecipientPushTokens(
creatorId: Long,
roomId: Long,
isAuth: Boolean,
isAvailableJoinCreator: Boolean,
container: String
): List<List<String>>
genderRestriction: GenderRestriction? = null
): List<PushTokenInfo>
fun getUploadContentNotificationRecipientPushTokens(
creatorId: Long,
isAuth: Boolean,
container: String
): List<List<String>>
isAuth: Boolean
): List<PushTokenInfo>
fun getMessageRecipientPushToken(messageId: Long): GetMessageRecipientPushTokenResponse?
fun getIndividualRecipientPushTokens(recipients: List<Long>, isAuth: Boolean?): Map<String, List<List<String>>>
fun getMessageRecipientPushToken(messageId: Long): PushTokenInfo?
fun getIndividualRecipientPushTokens(recipients: List<Long>, isAuth: Boolean?): List<PushTokenInfo>
fun getChangeNicknamePrice(memberId: Long): GetChangeNicknamePriceResponse
fun getMemberByEmail(email: String): Member?
fun getMemberByEmail(email: String?): Member?
fun getChangeNoticeRecipientPushTokens(creatorId: Long): Map<String, List<List<String>>>
fun getPushTokenFromReservationList(roomId: Long): Map<String, List<List<String>>>
fun getAuditionNoticeRecipientPushTokens(isAuth: Boolean): Map<String, List<List<String>>>
fun getChangeNoticeRecipientPushTokens(creatorId: Long): List<PushTokenInfo>
fun getPushTokenFromReservationList(roomId: Long): List<PushTokenInfo>
fun getAuditionNoticeRecipientPushTokens(isAuth: Boolean): List<PushTokenInfo>
fun getMemberProfile(memberId: Long, myMemberId: Long): GetMemberProfileResponse
@@ -103,10 +105,9 @@ class MemberQueryRepositoryImpl(
.fetchFirst()
}
override fun getAllRecipientPushTokens(isAuth: Boolean?, container: String): List<List<String>> {
override fun getAllRecipientPushTokens(isAuth: Boolean?): List<PushTokenInfo> {
var where = member.isActive.isTrue
.and(member.email.notIn("admin@sodalive.net"))
.and(pushToken.deviceType.eq(container))
if (isAuth != null) {
where = if (isAuth) {
@@ -117,22 +118,26 @@ class MemberQueryRepositoryImpl(
}
return queryFactory
.select(pushToken.token)
.select(
QPushTokenInfo(
pushToken.token,
pushToken.deviceType,
pushToken.languageCode.coalesce("ko")
)
)
.from(member)
.innerJoin(pushToken).on(member.id.eq(pushToken.member.id))
.leftJoin(member.auth, auth)
.where(where)
.fetch()
.toSet()
.chunked(500)
}
override fun getCreateLiveRoomNotificationRecipientPushTokens(
creatorId: Long,
isAuth: Boolean,
isAvailableJoinCreator: Boolean,
container: String
): List<List<String>> {
genderRestriction: GenderRestriction?
): List<PushTokenInfo> {
val member = QMember.member
val creator = QMember.member
@@ -147,7 +152,6 @@ class MemberQueryRepositoryImpl(
)
)
.and(creatorFollowing.isNotify.isTrue)
.and(pushToken.deviceType.eq(container))
.or(member.id.eq(4))
if (isAuth) {
@@ -158,8 +162,18 @@ 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(pushToken.token)
.select(
QPushTokenInfo(
pushToken.token,
pushToken.deviceType,
pushToken.languageCode.coalesce("ko")
)
)
.from(creatorFollowing)
.innerJoin(creatorFollowing.creator, creator)
.innerJoin(creatorFollowing.member, member)
@@ -168,8 +182,6 @@ class MemberQueryRepositoryImpl(
.leftJoin(creatorFollowing.member.auth, auth)
.where(where)
.fetch()
.toSet()
.chunked(500)
}
override fun getStartLiveRoomNotificationRecipientPushTokens(
@@ -177,8 +189,8 @@ class MemberQueryRepositoryImpl(
roomId: Long,
isAuth: Boolean,
isAvailableJoinCreator: Boolean,
container: String
): List<List<String>> {
genderRestriction: GenderRestriction?
): List<PushTokenInfo> {
val member = QMember.member
val creator = QMember.member
@@ -193,7 +205,6 @@ class MemberQueryRepositoryImpl(
)
)
.and(creatorFollowing.isNotify.isTrue)
.and(pushToken.deviceType.eq(container))
.or(creatorFollowing.member.id.eq(4))
if (isAuth) {
@@ -204,8 +215,18 @@ 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(pushToken.token)
.select(
QPushTokenInfo(
pushToken.token,
pushToken.deviceType,
pushToken.languageCode.coalesce("ko")
)
)
.from(creatorFollowing)
.innerJoin(creatorFollowing.creator, creator)
.innerJoin(creatorFollowing.member, member)
@@ -224,14 +245,23 @@ class MemberQueryRepositoryImpl(
blockMemberRepository.getBlockedMemberIdList(creatorId)
)
)
.and(pushToken.deviceType.eq(container))
if (isAuth) {
where = where.and(auth.isNotNull)
}
if (genderRestriction != null && genderRestriction != GenderRestriction.ALL) {
where = where.and(getGenderCondition(genderRestriction, liveReservation.member))
}
val reservationMemberPushToken = queryFactory
.select(pushToken.token)
.select(
QPushTokenInfo(
pushToken.token,
pushToken.deviceType,
pushToken.languageCode.coalesce("ko")
)
)
.from(liveReservation)
.innerJoin(liveReservation.member, member)
.innerJoin(liveReservation.member.notification, memberNotification)
@@ -240,16 +270,40 @@ class MemberQueryRepositoryImpl(
.where(where)
.fetch()
return (followingMemberPushToken + reservationMemberPushToken)
.toSet()
.chunked(500)
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,
container: String
): List<List<String>> {
isAuth: Boolean
): List<PushTokenInfo> {
val member = QMember.member
val creator = QMember.member
@@ -263,7 +317,6 @@ class MemberQueryRepositoryImpl(
)
)
.and(creatorFollowing.isNotify.isTrue)
.and(pushToken.deviceType.eq(container))
.or(member.id.eq(4))
if (isAuth) {
@@ -271,7 +324,13 @@ class MemberQueryRepositoryImpl(
}
return queryFactory
.select(pushToken.token)
.select(
QPushTokenInfo(
pushToken.token,
pushToken.deviceType,
pushToken.languageCode.coalesce("ko")
)
)
.from(creatorFollowing)
.innerJoin(creatorFollowing.creator, creator)
.innerJoin(creatorFollowing.member, member)
@@ -280,16 +339,15 @@ class MemberQueryRepositoryImpl(
.leftJoin(member.auth, auth)
.where(where)
.fetch()
.toSet()
.chunked(500)
}
override fun getMessageRecipientPushToken(messageId: Long): GetMessageRecipientPushTokenResponse? {
override fun getMessageRecipientPushToken(messageId: Long): PushTokenInfo? {
return queryFactory
.select(
QGetMessageRecipientPushTokenResponse(
QPushTokenInfo(
pushToken.token,
pushToken.deviceType
pushToken.deviceType,
pushToken.languageCode.coalesce("ko")
)
)
.from(message)
@@ -306,7 +364,7 @@ class MemberQueryRepositoryImpl(
override fun getIndividualRecipientPushTokens(
recipients: List<Long>,
isAuth: Boolean?
): Map<String, List<List<String>>> {
): List<PushTokenInfo> {
var where = member.isActive.isTrue
.and(member.email.notIn("admin@sodalive.net"))
.and(member.id.`in`(*recipients.toTypedArray()))
@@ -319,27 +377,19 @@ class MemberQueryRepositoryImpl(
}
}
val aosPushTokens = queryFactory
.select(pushToken.token)
return queryFactory
.select(
QPushTokenInfo(
pushToken.token,
pushToken.deviceType,
pushToken.languageCode.coalesce("ko")
)
)
.from(member)
.innerJoin(pushToken).on(member.id.eq(pushToken.member.id))
.leftJoin(member.auth, auth)
.where(where.and(pushToken.deviceType.eq("aos")))
.where(where)
.fetch()
.toSet()
.chunked(500)
val iosPushTokens = queryFactory
.select(pushToken.token)
.from(member)
.innerJoin(pushToken).on(member.id.eq(pushToken.member.id))
.leftJoin(member.auth, auth)
.where(where.and(pushToken.deviceType.eq("ios")))
.fetch()
.toSet()
.chunked(500)
return mapOf("aos" to aosPushTokens, "ios" to iosPushTokens)
}
override fun getChangeNicknamePrice(memberId: Long): GetChangeNicknamePriceResponse {
@@ -359,14 +409,15 @@ class MemberQueryRepositoryImpl(
)
}
override fun getMemberByEmail(email: String): Member? {
override fun getMemberByEmail(email: String?): Member? {
if (email == null) return null
return queryFactory
.selectFrom(member)
.where(member.email.eq(email))
.fetchOne()
}
override fun getChangeNoticeRecipientPushTokens(creatorId: Long): Map<String, List<List<String>>> {
override fun getChangeNoticeRecipientPushTokens(creatorId: Long): List<PushTokenInfo> {
val member = QMember.member
val creator = QMember.member
@@ -381,90 +432,63 @@ class MemberQueryRepositoryImpl(
)
.and(creatorFollowing.isNotify.isTrue)
val aosPushTokens = queryFactory
.select(pushToken.token)
return queryFactory
.select(
QPushTokenInfo(
pushToken.token,
pushToken.deviceType,
pushToken.languageCode.coalesce("ko")
)
)
.from(creatorFollowing)
.innerJoin(creatorFollowing.creator, creator)
.innerJoin(creatorFollowing.member, member)
.innerJoin(pushToken).on(creatorFollowing.member.id.eq(pushToken.member.id))
.where(where.and(pushToken.deviceType.eq("aos")))
.where(where)
.fetch()
.toSet()
.chunked(500)
val iosPushTokens = queryFactory
.select(pushToken.token)
.from(creatorFollowing)
.innerJoin(creatorFollowing.creator, creator)
.innerJoin(creatorFollowing.member, member)
.innerJoin(pushToken).on(creatorFollowing.member.id.eq(pushToken.member.id))
.where(where.and(pushToken.deviceType.eq("ios")))
.fetch()
.toSet()
.chunked(500)
return mapOf("aos" to aosPushTokens, "ios" to iosPushTokens)
}
override fun getPushTokenFromReservationList(roomId: Long): Map<String, List<List<String>>> {
override fun getPushTokenFromReservationList(roomId: Long): List<PushTokenInfo> {
val where = liveRoom.id.eq(roomId)
.and(liveReservation.isActive.isTrue)
val aosPushTokens = queryFactory
.select(pushToken.token)
return queryFactory
.select(
QPushTokenInfo(
pushToken.token,
pushToken.deviceType,
pushToken.languageCode.coalesce("ko")
)
)
.from(liveReservation)
.innerJoin(liveReservation.room, liveRoom)
.innerJoin(liveReservation.member, member)
.innerJoin(pushToken).on(member.id.eq(pushToken.member.id))
.where(where.and(pushToken.deviceType.eq("aos")))
.where(where)
.fetch()
.toSet()
.chunked(500)
val iosPushTokens = queryFactory
.select(pushToken.token)
.from(liveReservation)
.innerJoin(liveReservation.room, liveRoom)
.innerJoin(liveReservation.member, member)
.innerJoin(pushToken).on(member.id.eq(pushToken.member.id))
.where(where.and(pushToken.deviceType.eq("ios")))
.fetch()
.toSet()
.chunked(500)
return mapOf("aos" to aosPushTokens, "ios" to iosPushTokens)
}
override fun getAuditionNoticeRecipientPushTokens(isAuth: Boolean): Map<String, List<List<String>>> {
override fun getAuditionNoticeRecipientPushTokens(isAuth: Boolean): List<PushTokenInfo> {
var where = memberNotification.audition.isTrue
if (isAuth) {
where = where.and(auth.isNotNull)
}
val aosPushTokens = queryFactory
.select(pushToken.token)
return queryFactory
.select(
QPushTokenInfo(
pushToken.token,
pushToken.deviceType,
pushToken.languageCode.coalesce("ko")
)
)
.from(member)
.innerJoin(pushToken).on(member.id.eq(pushToken.member.id))
.leftJoin(member.auth, auth)
.leftJoin(memberNotification).on(memberNotification.member.id.eq(member.id))
.where(where.and(pushToken.deviceType.eq("aos")))
.where(where)
.fetch()
.toSet()
.chunked(500)
val iosPushTokens = queryFactory
.select(pushToken.token)
.from(member)
.innerJoin(pushToken).on(member.id.eq(pushToken.member.id))
.leftJoin(member.auth, auth)
.leftJoin(memberNotification).on(memberNotification.member.id.eq(member.id))
.where(where.and(pushToken.deviceType.eq("ios")))
.fetch()
.toSet()
.chunked(500)
return mapOf("aos" to aosPushTokens, "ios" to iosPushTokens)
}
override fun getMemberProfile(memberId: Long, myMemberId: Long): GetMemberProfileResponse {

View File

@@ -7,6 +7,7 @@ import kr.co.vividnext.sodalive.can.charge.ChargeRepository
import kr.co.vividnext.sodalive.can.payment.CanPaymentService
import kr.co.vividnext.sodalive.can.use.CanUsage
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.CountryContext
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.order.OrderService
import kr.co.vividnext.sodalive.email.SendEmailService
@@ -36,8 +37,10 @@ import kr.co.vividnext.sodalive.member.signUp.SignUpRequestV2
import kr.co.vividnext.sodalive.member.signUp.SignUpResponse
import kr.co.vividnext.sodalive.member.signUp.SignUpValidator
import kr.co.vividnext.sodalive.member.social.MemberResolveResult
import kr.co.vividnext.sodalive.member.social.apple.AppleUserInfo
import kr.co.vividnext.sodalive.member.social.google.GoogleUserInfo
import kr.co.vividnext.sodalive.member.social.kakao.KakaoUserInfo
import kr.co.vividnext.sodalive.member.social.line.LineUserInfo
import kr.co.vividnext.sodalive.member.stipulation.Stipulation
import kr.co.vividnext.sodalive.member.stipulation.StipulationAgree
import kr.co.vividnext.sodalive.member.stipulation.StipulationAgreeRepository
@@ -99,6 +102,7 @@ class MemberService(
private val messageSource: SodaMessageSource,
private val langContext: LangContext,
private val countryContext: CountryContext,
private val objectMapper: ObjectMapper,
@@ -126,14 +130,15 @@ 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),
nickname = nickname,
profileImage = "profile/default-profile.png",
gender = Gender.NONE,
container = request.container
container = request.container,
countryCode = countryContext.countryCode
)
if (!request.marketingPid.isNullOrBlank()) {
@@ -343,7 +348,7 @@ class MemberService(
userId = member.id!!,
token = jwt,
nickname = member.nickname,
email = member.email,
email = member.email ?: "",
profileImage = if (member.profileImage != null) {
"$cloudFrontHost/${member.profileImage}"
} else {
@@ -390,7 +395,8 @@ class MemberService(
password = passwordEncoder.encode(request.password),
nickname = request.nickname,
gender = request.gender,
container = request.container
container = request.container,
countryCode = countryContext.countryCode
)
if (!request.marketingPid.isNullOrBlank()) {
@@ -450,8 +456,16 @@ class MemberService(
}
override fun loadUserByUsername(username: String): UserDetails {
val member = repository.findByEmail(email = username)
?: throw UsernameNotFoundException(username)
val member = if (username.startsWith("member:")) {
val id = username.substringAfter("member:").toLongOrNull()
if (id != null) {
repository.findByIdOrNull(id)
} else {
null
}
} else {
repository.findByEmail(email = username)
} ?: throw UsernameNotFoundException(username)
return MemberAdapter(member)
}
@@ -588,7 +602,7 @@ class MemberService(
@Transactional
fun signOut(signOutRequest: SignOutRequest, user: User) {
val member = repository.findByEmail(user.username)
val member = findMemberByUsername(user.username)
?: throw SodaException(messageKey = "common.error.bad_credentials")
if (
member.provider == MemberProvider.EMAIL &&
@@ -616,11 +630,7 @@ class MemberService(
@Transactional
fun updateNickname(profileUpdateRequest: ProfileUpdateRequest, user: User) {
if (profileUpdateRequest.email != user.username) {
throw SodaException(messageKey = "common.error.bad_credentials")
}
val member = repository.findByEmail(user.username)
val member = findMemberByUsername(user.username)
?: throw SodaException(messageKey = "common.error.bad_credentials")
if (profileUpdateRequest.nickname != null) {
@@ -648,11 +658,7 @@ class MemberService(
@Transactional
fun profileUpdate(profileUpdateRequest: ProfileUpdateRequest, user: User): ProfileResponse {
if (profileUpdateRequest.email != user.username) {
throw SodaException(messageKey = "common.error.bad_credentials")
}
val member = repository.findByEmail(user.username)
val member = findMemberByUsername(user.username)
?: throw SodaException(messageKey = "common.error.bad_credentials")
if (profileUpdateRequest.modifyPassword != null) {
@@ -720,12 +726,16 @@ class MemberService(
member.isVisibleDonationRank = profileUpdateRequest.isVisibleDonationRank
}
if (profileUpdateRequest.donationRankingPeriod != null) {
member.donationRankingPeriod = profileUpdateRequest.donationRankingPeriod
}
return ProfileResponse(member, cloudFrontHost, profileUpdateRequest.container)
}
@Transactional
fun profileImageUpdate(multipartFile: MultipartFile, user: User): String {
val member = repository.findByEmail(user.username)
val member = findMemberByUsername(user.username)
?: throw SodaException(messageKey = "common.error.bad_credentials")
val metadata = ObjectMetadata()
@@ -787,6 +797,7 @@ class MemberService(
MemberProvider.KAKAO -> "member.provider.kakao"
MemberProvider.GOOGLE -> "member.provider.google"
MemberProvider.APPLE -> "member.provider.apple"
MemberProvider.LINE -> "member.provider.line"
}
return messageSource.getMessage(key, langContext.lang) ?: provider.name
}
@@ -839,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,
@@ -848,7 +859,8 @@ class MemberService(
profileImage = "profile/default-profile.png",
gender = Gender.NONE,
provider = MemberProvider.GOOGLE,
container = container
container = container,
countryCode = countryContext.countryCode
)
if (!marketingPid.isNullOrBlank()) {
@@ -895,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,
@@ -904,7 +916,8 @@ class MemberService(
profileImage = "profile/default-profile.png",
gender = Gender.NONE,
provider = MemberProvider.KAKAO,
container = container
container = container,
countryCode = countryContext.countryCode
)
if (!marketingPid.isNullOrBlank()) {
@@ -926,7 +939,138 @@ class MemberService(
return MemberResolveResult(member = member, isNew = true)
}
private fun checkEmail(email: String) {
@Transactional
fun findOrRegister(
appleUserInfo: AppleUserInfo,
container: String,
marketingPid: String?,
pushToken: String?
): MemberResolveResult {
val findMember = repository.findByAppleId(appleUserInfo.sub)
if (findMember != null) {
if (findMember.isActive) {
return MemberResolveResult(member = findMember, isNew = false)
} else {
throw SodaException(messageKey = "member.validation.inactive_account")
}
}
val stipulationTermsOfService = stipulationRepository.findByIdOrNull(StipulationIds.TERMS_OF_SERVICE_ID)
?: throw SodaException(messageKey = "member.validation.invalid_request_retry")
val stipulationPrivacyPolicy = stipulationRepository.findByIdOrNull(StipulationIds.PRIVACY_POLICY_ID)
?: throw SodaException(messageKey = "member.validation.invalid_request_retry")
val email = appleUserInfo.email
checkEmail(email)
val nickname = nicknameGenerateService.generateUniqueNickname(langContext.lang)
val member = Member(
appleId = appleUserInfo.sub,
email = email,
password = "",
nickname = nickname,
profileImage = "profile/default-profile.png",
gender = Gender.NONE,
provider = MemberProvider.APPLE,
container = container,
countryCode = countryContext.countryCode
)
if (!marketingPid.isNullOrBlank()) {
member.activePid = marketingPid
member.partnerExpirationDatetime = LocalDateTime.now().plusYears(1)
}
repository.save(member)
agreeTermsOfServiceAndPrivacyPolicy(member, stipulationTermsOfService, stipulationPrivacyPolicy)
if (pushToken != null) {
pushTokenService.registerToken(
memberId = member.id!!,
token = pushToken,
deviceType = container
)
}
return MemberResolveResult(member = member, isNew = true)
}
@Transactional
fun findOrRegister(
lineUserInfo: LineUserInfo,
container: String,
marketingPid: String?,
pushToken: String?
): MemberResolveResult {
val findMember = repository.findByLineId(lineUserInfo.sub)
if (findMember != null) {
if (findMember.isActive) {
return MemberResolveResult(member = findMember, isNew = false)
} else {
throw SodaException(messageKey = "member.validation.inactive_account")
}
}
val stipulationTermsOfService = stipulationRepository.findByIdOrNull(StipulationIds.TERMS_OF_SERVICE_ID)
?: throw SodaException(messageKey = "member.validation.invalid_request_retry")
val stipulationPrivacyPolicy = stipulationRepository.findByIdOrNull(StipulationIds.PRIVACY_POLICY_ID)
?: throw SodaException(messageKey = "member.validation.invalid_request_retry")
val email = lineUserInfo.email
checkEmail(email)
val nickname = nicknameGenerateService.generateUniqueNickname(langContext.lang)
val member = Member(
lineId = lineUserInfo.sub,
email = email,
password = "",
nickname = nickname,
profileImage = "profile/default-profile.png",
gender = Gender.NONE,
provider = MemberProvider.LINE,
container = container,
countryCode = countryContext.countryCode
)
if (!marketingPid.isNullOrBlank()) {
member.activePid = marketingPid
member.partnerExpirationDatetime = LocalDateTime.now().plusYears(1)
}
repository.save(member)
agreeTermsOfServiceAndPrivacyPolicy(member, stipulationTermsOfService, stipulationPrivacyPolicy)
if (pushToken != null) {
pushTokenService.registerToken(
memberId = member.id!!,
token = pushToken,
deviceType = container
)
}
return MemberResolveResult(member = member, isNew = true)
}
private fun findMemberByUsername(username: String): Member? {
return if (username.startsWith("member:")) {
val id = username.substringAfter("member:").toLongOrNull()
if (id != null) {
repository.findByIdOrNull(id)
} else {
null
}
} else {
repository.findByEmail(email = username)
}
}
private fun checkEmail(email: String?) {
if (email.isNullOrBlank()) {
return
}
val member = repository.findByEmail(email)
if (member != null) {

View File

@@ -17,7 +17,7 @@ data class ProfileResponse(
) {
constructor(member: Member, cloudFrontHost: String, container: String) : this(
userId = member.id!!,
email = member.email,
email = member.email ?: "",
nickname = member.nickname,
gender = member.gender,
profileUrl = if (member.profileImage != null) {

View File

@@ -1,7 +1,7 @@
package kr.co.vividnext.sodalive.member
data class ProfileUpdateRequest(
val email: String,
val email: String? = null,
val password: String? = null,
val modifyPassword: String? = null,
val nickname: String? = null,
@@ -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

@@ -10,5 +10,7 @@ data class LoginRequest(
data class SocialLoginRequest(
val container: String,
val pushToken: String? = null,
val marketingPid: String? = null
val marketingPid: String? = null,
val identityToken: String? = null,
val nonce: String? = null
)

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)
}
}

View File

@@ -0,0 +1,14 @@
package kr.co.vividnext.sodalive.member.social
import kr.co.vividnext.sodalive.member.MemberProvider
interface SocialAuthService {
fun getProvider(): MemberProvider
fun authenticate(
token: String,
container: String,
marketingPid: String?,
pushToken: String?,
nonce: String?
): SocialLoginResponse
}

View File

@@ -0,0 +1,15 @@
package kr.co.vividnext.sodalive.member.social
import kr.co.vividnext.sodalive.member.MemberProvider
import org.springframework.stereotype.Component
@Component
class SocialAuthServiceResolver(
val services: List<SocialAuthService>
) {
private val serviceMap: Map<MemberProvider, SocialAuthService> = services.associateBy { it.getProvider() }
fun resolve(provider: MemberProvider): SocialAuthService {
return serviceMap[provider] ?: throw IllegalArgumentException("Unsupported social provider: $provider")
}
}

View File

@@ -0,0 +1,68 @@
package kr.co.vividnext.sodalive.member.social.apple
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.jwt.TokenProvider
import kr.co.vividnext.sodalive.member.MemberAdapter
import kr.co.vividnext.sodalive.member.MemberProvider
import kr.co.vividnext.sodalive.member.MemberService
import kr.co.vividnext.sodalive.member.login.LoginResponse
import kr.co.vividnext.sodalive.member.social.SocialAuthService
import kr.co.vividnext.sodalive.member.social.SocialLoginResponse
import org.springframework.beans.factory.annotation.Value
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.stereotype.Service
@Service
class AppleAuthService(
private val appleIdentityTokenVerifier: AppleIdentityTokenVerifier,
private val memberService: MemberService,
private val tokenProvider: TokenProvider,
@Value("\${cloud.aws.cloud-front.host}")
private val cloudFrontHost: String
) : SocialAuthService {
override fun getProvider(): MemberProvider = MemberProvider.APPLE
override fun authenticate(
token: String,
container: String,
marketingPid: String?,
pushToken: String?,
nonce: String?
): SocialLoginResponse {
val rawNonce = nonce?.takeIf { it.isNotBlank() }
?: throw SodaException(messageKey = "member.social.apple_login_failed")
val appleUserInfo = appleIdentityTokenVerifier.verify(token, rawNonce)
val memberResolveResult = memberService.findOrRegister(appleUserInfo, container, marketingPid, pushToken)
val member = memberResolveResult.member
val principal = MemberAdapter(member)
val authToken = AppleAuthenticationToken(token, principal.authorities)
authToken.setPrincipal(principal)
SecurityContextHolder.getContext().authentication = authToken
val jwt = tokenProvider.createToken(
authentication = authToken,
memberId = member.id!!
)
val loginResponse = LoginResponse(
userId = member.id!!,
token = jwt,
nickname = member.nickname,
email = member.email ?: "",
profileImage = if (member.profileImage != null) {
"$cloudFrontHost/${member.profileImage}"
} else {
"$cloudFrontHost/profile/default-profile.png"
}
)
return SocialLoginResponse(
memberId = member.id!!,
marketingPid = marketingPid,
loginResponse = loginResponse,
isNew = memberResolveResult.isNew
)
}
}

View File

@@ -0,0 +1,23 @@
package kr.co.vividnext.sodalive.member.social.apple
import org.springframework.security.authentication.AbstractAuthenticationToken
import org.springframework.security.core.GrantedAuthority
class AppleAuthenticationToken(
private val idToken: String,
authorities: Collection<GrantedAuthority>? = null
) : AbstractAuthenticationToken(authorities) {
private var principal: Any? = null
init {
isAuthenticated = authorities != null
}
override fun getCredentials(): Any = idToken
override fun getPrincipal(): Any? = principal
fun setPrincipal(principal: Any) {
this.principal = principal
}
}

View File

@@ -0,0 +1,93 @@
package kr.co.vividnext.sodalive.member.social.apple
import com.nimbusds.jose.JWSAlgorithm
import com.nimbusds.jose.jwk.source.JWKSource
import com.nimbusds.jose.jwk.source.JWKSourceBuilder
import com.nimbusds.jose.proc.JWSVerificationKeySelector
import com.nimbusds.jose.proc.SecurityContext
import com.nimbusds.jwt.JWTClaimsSet
import com.nimbusds.jwt.proc.ConfigurableJWTProcessor
import com.nimbusds.jwt.proc.DefaultJWTProcessor
import kr.co.vividnext.sodalive.common.SodaException
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import java.net.URL
import java.nio.charset.StandardCharsets
import java.security.MessageDigest
import java.util.Base64
import java.util.Date
@Service
class AppleIdentityTokenVerifier(
@Value("\${apple.bundle-id}")
private val bundleId: String
) {
private val jwkUrl = URL("https://appleid.apple.com/auth/keys")
private val jwkSource: JWKSource<SecurityContext> = JWKSourceBuilder.create<SecurityContext>(jwkUrl)
.build()
private val jwtProcessor: ConfigurableJWTProcessor<SecurityContext> =
DefaultJWTProcessor<SecurityContext>().apply {
jwsKeySelector = JWSVerificationKeySelector(JWSAlgorithm.RS256, jwkSource)
}
fun verify(identityToken: String, rawNonce: String): AppleUserInfo {
if (bundleId.isBlank()) {
throw SodaException(messageKey = "member.social.apple_login_failed")
}
if (rawNonce.isBlank()) {
throw SodaException(messageKey = "member.social.apple_login_failed")
}
val claims = try {
jwtProcessor.process(identityToken, null)
} catch (_: Exception) {
throw SodaException(messageKey = "member.social.apple_login_failed")
}
validateClaims(claims, rawNonce)
return AppleUserInfo(
sub = claims.subject ?: throw SodaException(messageKey = "member.social.apple_login_failed"),
email = claims.getStringClaim("email")
)
}
private fun validateClaims(claims: JWTClaimsSet, rawNonce: String) {
if (claims.issuer != ISSUER) {
throw SodaException(messageKey = "member.social.apple_login_failed")
}
if (!claims.audience.contains(bundleId)) {
throw SodaException(messageKey = "member.social.apple_login_failed")
}
val now = Date()
val expirationTime = claims.expirationTime ?: throw SodaException(messageKey = "member.social.apple_login_failed")
if (expirationTime.before(now)) {
throw SodaException(messageKey = "member.social.apple_login_failed")
}
val issuedAt = claims.issueTime ?: throw SodaException(messageKey = "member.social.apple_login_failed")
if (issuedAt.after(now)) {
throw SodaException(messageKey = "member.social.apple_login_failed")
}
val nonce = claims.getStringClaim("nonce") ?: throw SodaException(messageKey = "member.social.apple_login_failed")
val expectedNonce = hashNonce(rawNonce)
if (nonce != expectedNonce) {
throw SodaException(messageKey = "member.social.apple_login_failed")
}
}
private fun hashNonce(rawNonce: String): String {
val digest = MessageDigest.getInstance("SHA-256")
val hashed = digest.digest(rawNonce.toByteArray(StandardCharsets.UTF_8))
return Base64.getUrlEncoder().withoutPadding().encodeToString(hashed)
}
companion object {
private const val ISSUER = "https://appleid.apple.com"
}
}

View File

@@ -0,0 +1,6 @@
package kr.co.vividnext.sodalive.member.social.apple
data class AppleUserInfo(
val sub: String,
val email: String?
)

View File

@@ -3,8 +3,10 @@ package kr.co.vividnext.sodalive.member.social.google
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.jwt.TokenProvider
import kr.co.vividnext.sodalive.member.MemberAdapter
import kr.co.vividnext.sodalive.member.MemberProvider
import kr.co.vividnext.sodalive.member.MemberService
import kr.co.vividnext.sodalive.member.login.LoginResponse
import kr.co.vividnext.sodalive.member.social.SocialAuthService
import kr.co.vividnext.sodalive.member.social.SocialLoginResponse
import org.springframework.beans.factory.annotation.Value
import org.springframework.security.core.context.SecurityContextHolder
@@ -18,19 +20,22 @@ class GoogleAuthService(
@Value("\${cloud.aws.cloud-front.host}")
private val cloudFrontHost: String
) {
fun authenticate(
idToken: String,
) : SocialAuthService {
override fun getProvider(): MemberProvider = MemberProvider.GOOGLE
override fun authenticate(
token: String,
container: String,
marketingPid: String?,
pushToken: String?
pushToken: String?,
nonce: String?
): SocialLoginResponse {
val googleUserInfo = googleService.getUserInfo(idToken)
val googleUserInfo = googleService.getUserInfo(token)
?: throw SodaException(messageKey = "member.social.google_login_failed")
val memberResolveResult = memberService.findOrRegister(googleUserInfo, container, marketingPid, pushToken)
val member = memberResolveResult.member
val principal = MemberAdapter(member)
val authToken = GoogleAuthenticationToken(idToken, principal.authorities)
val authToken = GoogleAuthenticationToken(token, principal.authorities)
authToken.setPrincipal(principal)
SecurityContextHolder.getContext().authentication = authToken
@@ -43,7 +48,7 @@ class GoogleAuthService(
userId = member.id!!,
token = jwt,
nickname = member.nickname,
email = member.email,
email = member.email ?: "",
profileImage = if (member.profileImage != null) {
"$cloudFrontHost/${member.profileImage}"
} else {

View File

@@ -3,7 +3,6 @@ package kr.co.vividnext.sodalive.member.social.google
import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier
import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport
import com.google.api.client.json.gson.GsonFactory
import kr.co.vividnext.sodalive.common.SodaException
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
@@ -27,7 +26,7 @@ class GoogleService(
if (token != null) {
val payload = token.payload
val email = payload.email ?: throw SodaException(messageKey = "member.social.email_consent_required")
val email = payload.email
GoogleUserInfo(
sub = payload.subject,

View File

@@ -2,6 +2,6 @@ package kr.co.vividnext.sodalive.member.social.google
data class GoogleUserInfo(
val sub: String,
val email: String,
val email: String?,
val name: String?
)

View File

@@ -3,8 +3,10 @@ package kr.co.vividnext.sodalive.member.social.kakao
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.jwt.TokenProvider
import kr.co.vividnext.sodalive.member.MemberAdapter
import kr.co.vividnext.sodalive.member.MemberProvider
import kr.co.vividnext.sodalive.member.MemberService
import kr.co.vividnext.sodalive.member.login.LoginResponse
import kr.co.vividnext.sodalive.member.social.SocialAuthService
import kr.co.vividnext.sodalive.member.social.SocialLoginResponse
import org.springframework.beans.factory.annotation.Value
import org.springframework.security.core.context.SecurityContextHolder
@@ -18,19 +20,22 @@ class KakaoAuthService(
@Value("\${cloud.aws.cloud-front.host}")
private val cloudFrontHost: String
) {
fun authenticate(
accessToken: String,
) : SocialAuthService {
override fun getProvider(): MemberProvider = MemberProvider.KAKAO
override fun authenticate(
token: String,
container: String,
marketingPid: String?,
pushToken: String?
pushToken: String?,
nonce: String?
): SocialLoginResponse {
val kakaoUserInfo = kakaoService.getUserInfo(accessToken)
val kakaoUserInfo = kakaoService.getUserInfo(token)
?: throw SodaException(messageKey = "member.social.kakao_login_failed")
val memberResolveResult = memberService.findOrRegister(kakaoUserInfo, container, marketingPid, pushToken)
val member = memberResolveResult.member
val principal = MemberAdapter(member)
val authToken = KakaoAuthenticationToken(accessToken, principal.authorities)
val authToken = KakaoAuthenticationToken(token, principal.authorities)
authToken.setPrincipal(principal)
SecurityContextHolder.getContext().authentication = authToken
@@ -43,7 +48,7 @@ class KakaoAuthService(
userId = member.id!!,
token = jwt,
nickname = member.nickname,
email = member.email,
email = member.email ?: "",
profileImage = if (member.profileImage != null) {
"$cloudFrontHost/${member.profileImage}"
} else {

View File

@@ -1,7 +1,6 @@
package kr.co.vividnext.sodalive.member.social.kakao
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.common.SodaException
import org.springframework.http.HttpEntity
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpMethod
@@ -37,7 +36,6 @@ class KakaoService(
val id = jsonNode.get("id").asLong()
val kakaoAccount = jsonNode.get("kakao_account")
val email = kakaoAccount?.get("email")?.asText()
?: throw SodaException(messageKey = "member.social.kakao_login_failed")
val properties = jsonNode.get("properties")
val nickname = properties?.get("nickname")?.asText()

View File

@@ -2,6 +2,6 @@ package kr.co.vividnext.sodalive.member.social.kakao
data class KakaoUserInfo(
val id: Long,
val email: String,
val email: String?,
val nickname: String?
)

View File

@@ -0,0 +1,112 @@
package kr.co.vividnext.sodalive.member.social.line
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.jwt.TokenProvider
import kr.co.vividnext.sodalive.member.MemberAdapter
import kr.co.vividnext.sodalive.member.MemberProvider
import kr.co.vividnext.sodalive.member.MemberService
import kr.co.vividnext.sodalive.member.login.LoginResponse
import kr.co.vividnext.sodalive.member.social.SocialAuthService
import kr.co.vividnext.sodalive.member.social.SocialLoginResponse
import org.springframework.beans.factory.annotation.Value
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.stereotype.Service
import java.time.Instant
@Service
class LineAuthService(
private val lineService: LineService,
private val memberService: MemberService,
private val tokenProvider: TokenProvider,
@Value("\${cloud.aws.cloud-front.host}")
private val cloudFrontHost: String,
@Value("\${line.channel-id}")
private val lineChannelId: String
) : SocialAuthService {
override fun getProvider(): MemberProvider = MemberProvider.LINE
override fun authenticate(
token: String,
container: String,
marketingPid: String?,
pushToken: String?,
nonce: String?
): SocialLoginResponse {
val rawNonce = nonce?.takeIf { it.isNotBlank() }
?: throw SodaException(messageKey = "member.social.line_login_failed")
if (lineChannelId.isBlank()) {
throw SodaException(messageKey = "member.social.line_login_failed")
}
val verifyResponse = lineService.verifyIdToken(token, lineChannelId, rawNonce)
?: throw SodaException(messageKey = "member.social.line_login_failed")
validateVerifyResponse(verifyResponse, lineChannelId, rawNonce)
val lineUserInfo = LineUserInfo(
sub = verifyResponse.sub,
email = verifyResponse.email
)
val memberResolveResult = memberService.findOrRegister(lineUserInfo, container, marketingPid, pushToken)
val member = memberResolveResult.member
val principal = MemberAdapter(member)
val authToken = LineAuthenticationToken(token, principal.authorities)
authToken.setPrincipal(principal)
SecurityContextHolder.getContext().authentication = authToken
val jwt = tokenProvider.createToken(
authentication = authToken,
memberId = member.id!!
)
val loginResponse = LoginResponse(
userId = member.id!!,
token = jwt,
nickname = member.nickname,
email = member.email ?: "",
profileImage = if (member.profileImage != null) {
"$cloudFrontHost/${member.profileImage}"
} else {
"$cloudFrontHost/profile/default-profile.png"
}
)
return SocialLoginResponse(
memberId = member.id!!,
marketingPid = marketingPid,
loginResponse = loginResponse,
isNew = memberResolveResult.isNew
)
}
private fun validateVerifyResponse(response: LineVerifyResponse, clientId: String, nonce: String) {
if (response.iss != ISSUER) {
throw SodaException(messageKey = "member.social.line_login_failed")
}
if (response.aud != clientId) {
throw SodaException(messageKey = "member.social.line_login_failed")
}
val now = Instant.now().epochSecond
if (response.exp <= now) {
throw SodaException(messageKey = "member.social.line_login_failed")
}
if (response.iat > now) {
throw SodaException(messageKey = "member.social.line_login_failed")
}
if (response.nonce != null && response.nonce != nonce) {
throw SodaException(messageKey = "member.social.line_login_failed")
}
}
companion object {
private const val ISSUER = "https://access.line.me"
}
}

View File

@@ -0,0 +1,23 @@
package kr.co.vividnext.sodalive.member.social.line
import org.springframework.security.authentication.AbstractAuthenticationToken
import org.springframework.security.core.GrantedAuthority
class LineAuthenticationToken(
private val idToken: String,
authorities: Collection<GrantedAuthority>? = null
) : AbstractAuthenticationToken(authorities) {
private var principal: Any? = null
init {
isAuthenticated = authorities != null
}
override fun getCredentials(): Any = idToken
override fun getPrincipal(): Any? = principal
fun setPrincipal(principal: Any) {
this.principal = principal
}
}

View File

@@ -0,0 +1,48 @@
package kr.co.vividnext.sodalive.member.social.line
import org.springframework.http.HttpEntity
import org.springframework.http.HttpHeaders
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.stereotype.Service
import org.springframework.util.LinkedMultiValueMap
import org.springframework.web.client.RestTemplate
@Service
class LineService(
private val restTemplate: RestTemplate = RestTemplate()
) {
fun verifyIdToken(idToken: String, clientId: String, nonce: String?): LineVerifyResponse? {
val url = "https://api.line.me/oauth2/v2.1/verify"
val headers = HttpHeaders().apply {
contentType = MediaType.APPLICATION_FORM_URLENCODED
}
val body = LinkedMultiValueMap<String, String>().apply {
add("id_token", idToken)
add("client_id", clientId)
if (!nonce.isNullOrBlank()) {
add("nonce", nonce)
}
}
val entity = HttpEntity(body, headers)
return try {
val response: ResponseEntity<LineVerifyResponse> = restTemplate.postForEntity(
url,
entity,
LineVerifyResponse::class.java
)
if (response.statusCode.is2xxSuccessful) {
response.body
} else {
null
}
} catch (ex: Exception) {
ex.printStackTrace()
null
}
}
}

View File

@@ -0,0 +1,6 @@
package kr.co.vividnext.sodalive.member.social.line
data class LineUserInfo(
val sub: String,
val email: String? = null
)

View File

@@ -0,0 +1,16 @@
package kr.co.vividnext.sodalive.member.social.line
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
@JsonIgnoreProperties(ignoreUnknown = true)
data class LineVerifyResponse(
val iss: String,
val sub: String,
val aud: String,
val exp: Long,
val iat: Long,
val nonce: String? = null,
val name: String? = null,
val picture: String? = null,
val email: String? = null
)

View File

@@ -1,9 +1,10 @@
package kr.co.vividnext.sodalive.menu
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.core.userdetails.User
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@@ -13,5 +14,13 @@ import org.springframework.web.bind.annotation.RestController
class MenuController(private val service: MenuService) {
@GetMapping
@PreAuthorize("hasAnyRole('AGENT', 'ADMIN', 'CREATOR')")
fun getMenus(@AuthenticationPrincipal user: User) = ApiResponse.ok(service.getMenus(user))
fun getMenus(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) {
throw SodaException(messageKey = "common.error.bad_credentials")
}
ApiResponse.ok(service.getMenus(member))
}
}

View File

@@ -1,18 +1,13 @@
package kr.co.vividnext.sodalive.menu
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.MemberRepository
import org.springframework.security.core.userdetails.User
import kr.co.vividnext.sodalive.member.Member
import org.springframework.stereotype.Service
@Service
class MenuService(
private val repository: MenuRepository,
private val memberRepository: MemberRepository
private val repository: MenuRepository
) {
fun getMenus(user: User): List<GetMenuResponse> {
val member = memberRepository.findByEmail(user.username)
?: throw SodaException(messageKey = "common.error.bad_credentials")
fun getMenus(member: Member): List<GetMenuResponse> {
return repository.getMenu(member.role)
}
}

View File

@@ -72,11 +72,9 @@ class MessageService(
applicationEventPublisher.publishEvent(
FcmEvent(
type = FcmEventType.SEND_MESSAGE,
title = messageSource.getMessage("message.fcm.title", langContext.lang).orEmpty(),
message = run {
val messageTemplate = messageSource.getMessage("message.fcm.text_received", langContext.lang).orEmpty()
String.format(messageTemplate, sender.nickname)
},
titleKey = "message.fcm.title",
messageKey = "message.fcm.text_received",
args = listOf(sender.nickname),
messageId = message.id
)
)
@@ -147,11 +145,9 @@ class MessageService(
applicationEventPublisher.publishEvent(
FcmEvent(
type = FcmEventType.SEND_MESSAGE,
title = messageSource.getMessage("message.fcm.title", langContext.lang).orEmpty(),
message = run {
val messageTemplate = messageSource.getMessage("message.fcm.voice_received", langContext.lang).orEmpty()
String.format(messageTemplate, sender.nickname)
},
titleKey = "message.fcm.title",
messageKey = "message.fcm.voice_received",
args = listOf(sender.nickname),
messageId = message.id
)
)

Some files were not shown because too many files have changed in this diff Show More