Compare commits

..

42 Commits

Author SHA1 Message Date
de32b537f4 Merge pull request 'test' (#386) from test into main
Reviewed-on: #386
2026-02-08 07:42:31 +00: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
9c271fc1f6 Merge pull request 'test' (#385) from test into main
Reviewed-on: #385
2026-02-06 05:04:37 +00: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
2ddbfbccd6 Merge pull request 'test' (#384) from test into main
Reviewed-on: #384
2026-02-04 12:52:24 +00: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
80786deb72 Merge pull request 'test' (#383) from test into main
Reviewed-on: #383
2026-01-28 15:40:25 +00: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
8ca2e185ac Merge pull request '라이브 예약 반환 값 - beginDateTimeUtc 추가' (#382) from test into main
Reviewed-on: #382
2026-01-21 10:08:55 +00:00
65fc47eff0 라이브 예약 반환 값 - beginDateTimeUtc 추가 2026-01-21 17:50:11 +09:00
484711ad1b Merge pull request '라이브 예약 Response에 utc 시간 변수 beginDateTimeUtc 추가' (#381) from test into main
Reviewed-on: #381
2026-01-21 07:54:05 +00:00
36a38d6c78 라이브 예약 Response에 utc 시간 변수 beginDateTimeUtc 추가 2026-01-21 15:33:53 +09:00
e80ceca0c5 Merge pull request '충전 이벤트 - langContext 문제로 충전이 되지 않는 현상을 langContext를 사용하지 않고 이전 방식으로 기록하도록 롤백하여 임시 해결' (#380) from test into main
Reviewed-on: #380
2026-01-21 02:36:21 +00:00
0da958f6d4 충전 이벤트 - langContext 문제로 충전이 되지 않는 현상을 langContext를 사용하지 않고 이전 방식으로 기록하도록 롤백하여 임시 해결 2026-01-21 11:23:24 +09:00
33293a6533 Merge pull request '라이브 방 상세 - 날짜 포맷 변경으로 유료방 입장이 불가한 문제를 해결하기 위해 이전으로 롤백' (#379) from test into main
Reviewed-on: #379
2026-01-21 02:16:42 +00:00
ba27cc1fbd 라이브 방 상세 - 날짜 포맷 변경으로 유료방 입장이 불가한 문제를 해결하기 위해 이전으로 롤백 2026-01-21 11:01:42 +09:00
f0c1d4e32a Merge pull request '라이브 룸 일시 포맷에 다국어 설정 적용' (#378) from test into main
Reviewed-on: #378
2026-01-20 10:41:23 +00:00
a41bfaa037 라이브 룸 일시 포맷에 다국어 설정 적용
LiveRoomService에서 하드코딩된 날짜 포맷과 Locale을 제거하고,

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

사용하도록 수정한다.
2026-01-20 19:32:57 +09:00
6cd319ec76 Merge pull request 'memberId가 특정 번호일 때 currency와 관계없이 모든 구매 가능한 캔이 출력되도록 수정' (#377) from test into main
Reviewed-on: #377
2026-01-16 02:39:08 +00:00
482241f734 memberId가 특정 번호일 때 currency와 관계없이 모든 구매 가능한 캔이 출력되도록 수정 2026-01-16 11:24:48 +09:00
73 changed files with 1522 additions and 265 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

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

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

@@ -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

@@ -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

@@ -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

@@ -14,8 +14,11 @@ import org.springframework.web.bind.annotation.RestController
@RequestMapping("/can")
class CanController(private val service: CanService) {
@GetMapping
fun getCans(): ApiResponse<List<CanResponse>> {
return ApiResponse.ok(service.getCans())
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

@@ -15,10 +15,14 @@ class CanService(
private val repository: CanRepository,
private val countryContext: CountryContext
) {
fun getCans(): List<CanResponse> {
val currency = when (countryContext.countryCode) {
"KR" -> "KRW"
else -> "USD"
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) {
@@ -99,9 +95,7 @@ class ChargeEventService(
additionalCan = additionalCan,
member = member,
paymentGateway = charge.payment?.paymentGateway!!,
method = messageSource
.getMessage("can.charge.event.first_title", langContext.lang)
.orEmpty()
method = "첫 충전 이벤트"
)
applicationEventPublisher.publishEvent(
@@ -118,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(
@@ -135,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

@@ -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

@@ -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

@@ -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

@@ -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
@@ -342,6 +344,21 @@ class ExplorerQueryRepository(
.and(liveRoom.cancel.id.isNull)
.and(liveRoom.isActive.isTrue)
val effectiveGender = if (userMember.auth != null) {
if (userMember.auth!!.gender == 1) Gender.MALE else Gender.FEMALE
} else {
userMember.gender
}
if (effectiveGender != Gender.NONE) {
val genderCondition = when (effectiveGender) {
Gender.MALE -> liveRoom.genderRestriction.`in`(GenderRestriction.ALL, GenderRestriction.MALE_ONLY)
Gender.FEMALE -> liveRoom.genderRestriction.`in`(GenderRestriction.ALL, GenderRestriction.FEMALE_ONLY)
Gender.NONE -> liveRoom.genderRestriction.isNotNull
}
where = where.and(genderCondition.or(liveRoom.member.id.eq(userMember.id)))
}
if (userMember.auth == null) {
where = where.and(liveRoom.isAdult.isFalse)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1280,6 +1280,16 @@ class SodaMessageSource {
Lang.EN to "Kakao login failed. Please try again.",
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 "이메일 제공에 동의하셔야 서비스 이용이 가능합니다.",
Lang.EN to "You must agree to provide your email to use the service.",
@@ -1438,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.",
@@ -1628,6 +1643,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"
)
)

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
val languageTag = when (languageCode?.lowercase()?.take(2)) {
"ko" -> "한국어"
"ja" -> "일본어"
"en" -> "영어"
else -> null
}
return languageTag
}
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) ->
when (token?.languageCode?.lowercase()?.take(2)) {
"ko" -> "한국어"
"ja" -> "일본어"
"en" -> "영어"
else -> null
}
}
}
@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
@@ -418,7 +488,8 @@ class LiveRoomService(
isAuth = createdRoom.isAdult,
isAvailableJoinCreator = createdRoom.isAvailableJoinCreator,
roomId = createdRoom.id,
creatorId = createdRoom.member!!.id
creatorId = createdRoom.member!!.id,
genderRestriction = createdRoom.genderRestriction
)
)
@@ -433,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))
@@ -441,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,
@@ -455,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)
@@ -574,7 +654,8 @@ class LiveRoomService(
isAuth = room.isAdult,
isAvailableJoinCreator = room.isAvailableJoinCreator,
roomId = room.id,
creatorId = room.member!!.id
creatorId = room.member!!.id,
genderRestriction = room.genderRestriction
)
)
}
@@ -673,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)
@@ -787,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) {
@@ -839,6 +928,13 @@ class LiveRoomService(
expireTimestamp.toInt()
)
val v2vWorkerRtmToken = rtmTokenBuilder.buildToken(
agoraAppId,
agoraAppCertificate,
"v2v-agent-${member.id!!}",
expireTimestamp.toInt()
)
val isFollowing = explorerQueryRepository
.getNotificationUserIds(room.member!!.id!!)
.contains(member.id)
@@ -866,6 +962,12 @@ class LiveRoomService(
}
val menuPan = menuService.getLiveMenu(creatorId = room.member!!.id!!)
val languageTagByMemberId = buildLanguageTagMap(listOfNotNull(room.member?.id))
val tags = room.tags
.filter { it.tag.isActive }
.map { it.tag.tag }
.let { tags -> applyLanguageTagToRoomTags(room.member?.id, tags, languageTagByMemberId) }
return GetRoomInfoResponse(
roomId = roomId,
@@ -887,6 +989,7 @@ class LiveRoomService(
channelName = room.channelName!!,
rtcToken = rtcToken,
rtmToken = rtmToken,
v2vWorkerRtmToken = v2vWorkerRtmToken,
creatorId = room.member!!.id!!,
creatorNickname = room.member!!.nickname,
creatorProfileUrl = if (room.member!!.profileImage != null) {
@@ -903,6 +1006,7 @@ class LiveRoomService(
managerList = roomInfo.managerList,
donationRankingTop3UserIds = donationRankingTop3UserIds,
menuPan = menuPan?.menu ?: "",
tags = tags,
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 v2vWorkerRtmToken: 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 tags: List<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,6 +47,9 @@ data class Member(
var isVisibleDonationRank: Boolean = true,
@Enumerated(value = EnumType.STRING)
var donationRankingPeriod: DonationRankingPeriod? = DonationRankingPeriod.CUMULATIVE,
var isActive: Boolean = true,
var container: String = "web",
@@ -147,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 {
@@ -158,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

@@ -5,6 +5,7 @@ import kr.co.vividnext.sodalive.fcm.PushTokenInfo
import kr.co.vividnext.sodalive.fcm.QPushToken.pushToken
import kr.co.vividnext.sodalive.fcm.QPushTokenInfo
import kr.co.vividnext.sodalive.live.reservation.QLiveReservation.liveReservation
import kr.co.vividnext.sodalive.live.room.GenderRestriction
import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom
import kr.co.vividnext.sodalive.member.QMember.member
import kr.co.vividnext.sodalive.member.auth.QAuth.auth
@@ -20,10 +21,12 @@ 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 {
@@ -33,14 +36,16 @@ interface MemberQueryRepository {
fun getCreateLiveRoomNotificationRecipientPushTokens(
creatorId: Long,
isAuth: Boolean,
isAvailableJoinCreator: Boolean
isAvailableJoinCreator: Boolean,
genderRestriction: GenderRestriction? = null
): List<PushTokenInfo>
fun getStartLiveRoomNotificationRecipientPushTokens(
creatorId: Long,
roomId: Long,
isAuth: Boolean,
isAvailableJoinCreator: Boolean
isAvailableJoinCreator: Boolean,
genderRestriction: GenderRestriction? = null
): List<PushTokenInfo>
fun getUploadContentNotificationRecipientPushTokens(
@@ -51,7 +56,7 @@ interface MemberQueryRepository {
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): List<PushTokenInfo>
fun getPushTokenFromReservationList(roomId: Long): List<PushTokenInfo>
@@ -130,7 +135,8 @@ class MemberQueryRepositoryImpl(
override fun getCreateLiveRoomNotificationRecipientPushTokens(
creatorId: Long,
isAuth: Boolean,
isAvailableJoinCreator: Boolean
isAvailableJoinCreator: Boolean,
genderRestriction: GenderRestriction?
): List<PushTokenInfo> {
val member = QMember.member
val creator = QMember.member
@@ -156,6 +162,10 @@ class MemberQueryRepositoryImpl(
where = where.and(creatorFollowing.member.role.ne(MemberRole.CREATOR))
}
if (genderRestriction != null && genderRestriction != GenderRestriction.ALL) {
where = where.and(getGenderCondition(genderRestriction))
}
return queryFactory
.select(
QPushTokenInfo(
@@ -178,7 +188,8 @@ class MemberQueryRepositoryImpl(
creatorId: Long,
roomId: Long,
isAuth: Boolean,
isAvailableJoinCreator: Boolean
isAvailableJoinCreator: Boolean,
genderRestriction: GenderRestriction?
): List<PushTokenInfo> {
val member = QMember.member
val creator = QMember.member
@@ -204,6 +215,10 @@ class MemberQueryRepositoryImpl(
where = where.and(creatorFollowing.member.role.ne(MemberRole.CREATOR))
}
if (genderRestriction != null && genderRestriction != GenderRestriction.ALL) {
where = where.and(getGenderCondition(genderRestriction))
}
val followingMemberPushToken = queryFactory
.select(
QPushTokenInfo(
@@ -235,6 +250,10 @@ class MemberQueryRepositoryImpl(
where = where.and(auth.isNotNull)
}
if (genderRestriction != null && genderRestriction != GenderRestriction.ALL) {
where = where.and(getGenderCondition(genderRestriction, liveReservation.member))
}
val reservationMemberPushToken = queryFactory
.select(
QPushTokenInfo(
@@ -254,6 +273,33 @@ class MemberQueryRepositoryImpl(
return (followingMemberPushToken + reservationMemberPushToken).distinctBy { it.token }
}
private fun getGenderCondition(
genderRestriction: GenderRestriction,
qMember: QMember = member
) = when (genderRestriction) {
GenderRestriction.MALE_ONLY -> {
auth.isNotNull.and(auth.gender.eq(1))
.or(
auth.isNull.and(
qMember.gender.eq(Gender.MALE)
.or(qMember.gender.eq(Gender.NONE))
)
)
}
GenderRestriction.FEMALE_ONLY -> {
auth.isNotNull.and(auth.gender.eq(0))
.or(
auth.isNull.and(
qMember.gender.eq(Gender.FEMALE)
.or(qMember.gender.eq(Gender.NONE))
)
)
}
else -> null
}
override fun getUploadContentNotificationRecipientPushTokens(
creatorId: Long,
isAuth: Boolean
@@ -363,7 +409,8 @@ 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))

View File

@@ -37,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
@@ -128,7 +130,7 @@ class MemberService(
duplicateCheckEmail(request.email)
validatePassword(request.password)
val nickname = nicknameGenerateService.generateUniqueNickname()
val nickname = nicknameGenerateService.generateUniqueNickname(langContext.lang)
val member = Member(
email = request.email,
password = passwordEncoder.encode(request.password),
@@ -346,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 {
@@ -454,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)
}
@@ -592,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 &&
@@ -620,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) {
@@ -652,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) {
@@ -724,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()
@@ -791,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
}
@@ -843,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,
@@ -900,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,
@@ -932,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

@@ -33,6 +33,10 @@ bootpay:
apple:
iapVerifyUrl: https://buy.itunes.apple.com/verifyReceipt
iapVerifySandboxUrl: https://sandbox.itunes.apple.com/verifyReceipt
bundleId: ${APPLE_BUNDLE_ID}
line:
channelId: ${LINE_CHANNEL_ID}
agora:
appId: ${AGORA_APP_ID}