From f53dcc32bd2526c669c24cc5edbc1504c563c20f Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 22 Jan 2026 15:31:02 +0900 Subject: [PATCH 1/7] =?UTF-8?q?=EC=B1=84=ED=8C=85=20=EC=BA=90=EB=A6=AD?= =?UTF-8?q?=ED=84=B0=20=EB=93=B1=EB=A1=9D=20-=20=EB=A6=AC=EC=A0=84=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/chat/character/AdminChatCharacterController.kt | 2 ++ .../admin/chat/character/dto/ChatCharacterDetailResponse.kt | 2 ++ .../sodalive/admin/chat/character/dto/ChatCharacterDto.kt | 1 + .../admin/chat/character/dto/ChatCharacterListResponse.kt | 2 ++ .../co/vividnext/sodalive/chat/character/ChatCharacter.kt | 4 ++++ .../sodalive/chat/character/service/ChatCharacterService.kt | 6 +++++- 6 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterController.kt index 5e5b79b2..f04c6e7f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterController.kt @@ -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 } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterDetailResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterDetailResponse.kt index 32338e66..934ff252 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterDetailResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterDetailResponse.kt @@ -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, val hobbies: List, @@ -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 }, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterDto.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterDto.kt index ca203ebd..dc1a4ef9 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterDto.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterDto.kt @@ -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, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterListResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterListResponse.kt index bf0977c7..6bad1303 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterListResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterListResponse.kt @@ -14,6 +14,7 @@ data class ChatCharacterListResponse( val mbti: String?, val speechStyle: String?, val speechPattern: String?, + val region: String, val tags: List, 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 diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacter.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacter.kt index 5a628513..87369943 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacter.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacter.kt @@ -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 diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt index 60974827..47ac7565 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt @@ -582,6 +582,7 @@ class ChatCharacterService( originalTitle: String? = null, originalLink: String? = null, characterType: CharacterType = CharacterType.Character, + region: String = "KR", tags: List = emptyList(), values: List = emptyList(), hobbies: List = 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 = emptyList(), values: List = emptyList(), hobbies: List = emptyList(), @@ -653,6 +656,7 @@ class ChatCharacterService( originalTitle = originalTitle, originalLink = originalLink, characterType = characterType, + region = region, tags = tags, values = values, hobbies = hobbies, From e1bf54c74b3826111a44638bfbc1fab2f1a29782 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 22 Jan 2026 18:09:49 +0900 Subject: [PATCH 2/7] =?UTF-8?q?HomeService=EC=9D=98=20=EC=B5=9C=EC=8B=A0?= =?UTF-8?q?=20=EC=BD=98=ED=85=90=EC=B8=A0=20=ED=85=8C=EB=A7=88=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=EC=97=90=EC=84=9C=20=EB=8B=A4=EC=8B=9C=EB=93=A3?= =?UTF-8?q?=EA=B8=B0=20=EC=A0=9C=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 홈 화면의 최신 콘텐츠 테마 리스트(latestContentThemeList)에서 '다시듣기' 테마를 제외하도록 수정한다. 일본어 및 영어 번역이 적용되기 전에 필터링을 수행하여 다양한 언어 환경에서도 정상적으로 제외되도록 보장한다. AudioContentThemeService의 getActiveThemeOfContent 메서드에 테마 제외 옵션을 추가하여 필요한 곳에서만 선택적으로 사용할 수 있게 한다. --- .../kr/co/vividnext/sodalive/api/home/HomeService.kt | 3 ++- .../sodalive/content/theme/AudioContentThemeService.kt | 9 +++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt index 11a3fe78..b5476b55 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt @@ -106,7 +106,8 @@ class HomeService( val latestContentThemeList = contentThemeService.getActiveThemeOfContent( isAdult = isAdult, - contentType = contentType + contentType = contentType, + excludeThemes = listOf("다시듣기") ) val latestContentList = contentService.getLatestContentByTheme( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeService.kt index 0301f86e..f34e2b9c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeService.kt @@ -34,15 +34,20 @@ class AudioContentThemeService( isAdult: Boolean = false, isFree: Boolean = false, isPointAvailableOnly: Boolean = false, - contentType: ContentType + contentType: ContentType, + excludeThemes: List = emptyList() ): List { - 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 호출 후 저장하고 반환 From 744afd7f45173c1c60b714d237244297cf198f4a Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 26 Jan 2026 07:16:16 +0900 Subject: [PATCH 3/7] =?UTF-8?q?=EC=86=8C=EC=85=9C=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EB=A6=AC=EC=A1=B8=EB=B2=84=20=EB=8F=84=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/member/MemberController.kt | 51 ++++++++----------- .../member/social/SocialAuthService.kt | 13 +++++ .../social/SocialAuthServiceResolver.kt | 15 ++++++ .../member/social/google/GoogleAuthService.kt | 14 +++-- .../member/social/kakao/KakaoAuthService.kt | 14 +++-- 5 files changed, 66 insertions(+), 41 deletions(-) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/member/social/SocialAuthService.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/member/social/SocialAuthServiceResolver.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt index 3bc333ae..06fa296d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt @@ -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,7 @@ class MemberController( @RequestHeader("Authorization") authHeader: String, @RequestBody request: SocialLoginRequest ): ApiResponse { - 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) + return processSocialLogin(MemberProvider.GOOGLE, authHeader, request) } @PostMapping("/login/kakao") @@ -377,12 +351,27 @@ class MemberController( @RequestHeader("Authorization") authHeader: String, @RequestBody request: SocialLoginRequest ): ApiResponse { + return processSocialLogin(MemberProvider.KAKAO, authHeader, request) + } + + private fun processSocialLogin( + provider: MemberProvider, + authHeader: String, + request: SocialLoginRequest + ): ApiResponse { + val errorKey = when (provider) { + MemberProvider.GOOGLE -> "member.social.google_login_failed" + MemberProvider.KAKAO -> "member.social.kakao_login_failed" + else -> "common.error.bad_request" + } + if (!authHeader.startsWith("Bearer ")) { - throw SodaException(messageKey = "member.social.kakao_login_failed") + throw SodaException(messageKey = errorKey) } val token = authHeader.substring(7) - val response = kakaoAuthService.authenticate(token, request.container, request.marketingPid, request.pushToken) + val authService = socialAuthServiceResolver.resolve(provider) + val response = authService.authenticate(token, request.container, request.marketingPid, request.pushToken) if (!response.marketingPid.isNullOrBlank()) { trackingService.saveTrackingHistory( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/SocialAuthService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/SocialAuthService.kt new file mode 100644 index 00000000..bb500dff --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/SocialAuthService.kt @@ -0,0 +1,13 @@ +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? + ): SocialLoginResponse +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/SocialAuthServiceResolver.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/SocialAuthServiceResolver.kt new file mode 100644 index 00000000..f89529fe --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/SocialAuthServiceResolver.kt @@ -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 +) { + private val serviceMap: Map = services.associateBy { it.getProvider() } + + fun resolve(provider: MemberProvider): SocialAuthService { + return serviceMap[provider] ?: throw IllegalArgumentException("Unsupported social provider: $provider") + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/google/GoogleAuthService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/google/GoogleAuthService.kt index 99530a6a..7d243442 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/google/GoogleAuthService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/google/GoogleAuthService.kt @@ -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,21 @@ 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? ): 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 diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/kakao/KakaoAuthService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/kakao/KakaoAuthService.kt index ed4cf0c5..bf7c393f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/kakao/KakaoAuthService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/kakao/KakaoAuthService.kt @@ -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,21 @@ 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? ): 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 From f778f68f1f820ab13694728cdf69b2f140e2a1fd Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 26 Jan 2026 08:56:05 +0900 Subject: [PATCH 4/7] =?UTF-8?q?=EC=86=8C=EC=85=9C=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20=EB=AF=B8=ED=95=84?= =?UTF-8?q?=EC=88=98=20=EC=A0=95=EC=B1=85=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 소셜 로그인 시 이메일 동의 없이도 계정 생성이 가능하도록 변경합니다. Member 엔티티의 email 필드를 선택 사항으로 변경하고, 관련 API 응답 및 인증 로직에서 이메일이 없는 경우에 대한 처리를 추가합니다. --- .../admin/member/AdminMemberService.kt | 5 +- .../can/charge/temp/ChargeTempController.kt | 11 +++-- .../can/charge/temp/ChargeTempService.kt | 7 +-- .../admin/member/CreatorAdminMemberService.kt | 2 +- .../kr/co/vividnext/sodalive/member/Member.kt | 2 +- .../sodalive/member/MemberAdapter.kt | 2 +- .../sodalive/member/MemberRepository.kt | 7 +-- .../sodalive/member/MemberService.kt | 49 +++++++++++++------ .../sodalive/member/ProfileResponse.kt | 2 +- .../sodalive/member/ProfileUpdateRequest.kt | 2 +- .../member/social/google/GoogleAuthService.kt | 2 +- .../member/social/google/GoogleService.kt | 3 +- .../member/social/google/GoogleUserInfo.kt | 2 +- .../member/social/kakao/KakaoAuthService.kt | 2 +- .../member/social/kakao/KakaoService.kt | 2 - .../member/social/kakao/KakaoUserInfo.kt | 2 +- .../vividnext/sodalive/menu/MenuController.kt | 13 ++++- .../co/vividnext/sodalive/menu/MenuService.kt | 11 ++--- 18 files changed, 73 insertions(+), 53 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberService.kt index 29e59710..11820065 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberService.kt @@ -126,7 +126,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 +160,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]) } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/temp/ChargeTempController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/temp/ChargeTempController.kt index b1943d43..7f0ab270 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/temp/ChargeTempController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/temp/ChargeTempController.kt @@ -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)) + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/temp/ChargeTempService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/temp/ChargeTempService.kt index 2eaa248a..4a206375 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/temp/ChargeTempService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/temp/ChargeTempService.kt @@ -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) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/member/CreatorAdminMemberService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/member/CreatorAdminMemberService.kt index 9bfd9b76..d46f1dd5 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/member/CreatorAdminMemberService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/member/CreatorAdminMemberService.kt @@ -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 { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/Member.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/Member.kt index a30b31c6..49295f15 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/Member.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/Member.kt @@ -19,7 +19,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, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberAdapter.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberAdapter.kt index ce42b377..c81e1c20 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberAdapter.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberAdapter.kt @@ -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}")) ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRepository.kt index 9487cdf2..add3d49d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRepository.kt @@ -20,7 +20,7 @@ import org.springframework.stereotype.Repository @Repository interface MemberRepository : JpaRepository, 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? @@ -51,7 +51,7 @@ interface MemberQueryRepository { fun getMessageRecipientPushToken(messageId: Long): PushTokenInfo? fun getIndividualRecipientPushTokens(recipients: List, isAuth: Boolean?): List fun getChangeNicknamePrice(memberId: Long): GetChangeNicknamePriceResponse - fun getMemberByEmail(email: String): Member? + fun getMemberByEmail(email: String?): Member? fun getChangeNoticeRecipientPushTokens(creatorId: Long): List fun getPushTokenFromReservationList(roomId: Long): List @@ -363,7 +363,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)) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt index 5d053cc1..497fe4d5 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt @@ -346,7 +346,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 +454,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 +600,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 +628,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 +656,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) { @@ -729,7 +729,7 @@ class MemberService( @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() @@ -932,7 +932,24 @@ class MemberService( return MemberResolveResult(member = member, isNew = true) } - private fun checkEmail(email: String) { + 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) { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/ProfileResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/ProfileResponse.kt index 17ad822c..12475347 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/ProfileResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/ProfileResponse.kt @@ -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) { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/ProfileUpdateRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/ProfileUpdateRequest.kt index aeaac650..806f00d8 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/ProfileUpdateRequest.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/ProfileUpdateRequest.kt @@ -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, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/google/GoogleAuthService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/google/GoogleAuthService.kt index 7d243442..cf2b72f1 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/google/GoogleAuthService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/google/GoogleAuthService.kt @@ -47,7 +47,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 { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/google/GoogleService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/google/GoogleService.kt index e6ac4088..c1f3e4c0 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/google/GoogleService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/google/GoogleService.kt @@ -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, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/google/GoogleUserInfo.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/google/GoogleUserInfo.kt index e54db6ad..2d98ebca 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/google/GoogleUserInfo.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/google/GoogleUserInfo.kt @@ -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? ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/kakao/KakaoAuthService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/kakao/KakaoAuthService.kt index bf7c393f..45f96ad8 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/kakao/KakaoAuthService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/kakao/KakaoAuthService.kt @@ -47,7 +47,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 { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/kakao/KakaoService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/kakao/KakaoService.kt index 8ad65c56..13d89f07 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/kakao/KakaoService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/kakao/KakaoService.kt @@ -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() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/kakao/KakaoUserInfo.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/kakao/KakaoUserInfo.kt index 9ddcc199..9c6596ba 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/kakao/KakaoUserInfo.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/kakao/KakaoUserInfo.kt @@ -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? ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/menu/MenuController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/menu/MenuController.kt index d9b8e7b8..2e768433 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/menu/MenuController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/menu/MenuController.kt @@ -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)) + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/menu/MenuService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/menu/MenuService.kt index 3ebd9d39..957b7bb8 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/menu/MenuService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/menu/MenuService.kt @@ -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 { - val member = memberRepository.findByEmail(user.username) - ?: throw SodaException(messageKey = "common.error.bad_credentials") + fun getMenus(member: Member): List { return repository.getMenu(member.role) } } From 8957fd5c3f3fca74ca59ceb4a931f9027c921f98 Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 26 Jan 2026 09:14:43 +0900 Subject: [PATCH 5/7] =?UTF-8?q?=EC=98=88=EC=99=B8=20=EB=A1=9C=EA=B7=B8=20?= =?UTF-8?q?=ED=95=9C=EA=B8=80=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/common/SodaExceptionHandler.kt | 30 ++++++++++++++----- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/common/SodaExceptionHandler.kt b/src/main/kotlin/kr/co/vividnext/sodalive/common/SodaExceptionHandler.kt index 443e58cc..ecb706c0 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/common/SodaExceptionHandler.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/common/SodaExceptionHandler.kt @@ -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) } From 81f3bc0bad50ff191f7f2414344c41ebc306b9c2 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 27 Jan 2026 10:09:20 +0900 Subject: [PATCH 6/7] =?UTF-8?q?=EC=95=A0=ED=94=8C=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EA=B2=80=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 2 + .../sodalive/configs/SecurityConfig.kt | 1 + .../sodalive/i18n/SodaMessageSource.kt | 5 + .../sodalive/member/MemberController.kt | 60 ++++++++---- .../sodalive/member/MemberRepository.kt | 1 + .../sodalive/member/MemberService.kt | 58 ++++++++++++ .../sodalive/member/login/LoginRequest.kt | 4 +- .../member/social/SocialAuthService.kt | 3 +- .../member/social/apple/AppleAuthService.kt | 68 ++++++++++++++ .../social/apple/AppleAuthenticationToken.kt | 23 +++++ .../apple/AppleIdentityTokenVerifier.kt | 93 +++++++++++++++++++ .../member/social/apple/AppleUserInfo.kt | 6 ++ .../member/social/google/GoogleAuthService.kt | 3 +- .../member/social/kakao/KakaoAuthService.kt | 3 +- src/main/resources/application.yml | 1 + 15 files changed, 311 insertions(+), 20 deletions(-) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/member/social/apple/AppleAuthService.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/member/social/apple/AppleAuthenticationToken.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/member/social/apple/AppleIdentityTokenVerifier.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/member/social/apple/AppleUserInfo.kt diff --git a/build.gradle.kts b/build.gradle.kts index 6397fe8b..37cdf45a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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") diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt b/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt index d143d4d0..3eddd75c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt @@ -72,6 +72,7 @@ class SecurityConfig( .antMatchers("/member/login").permitAll() .antMatchers("/member/login/google").permitAll() .antMatchers("/member/login/kakao").permitAll() + .antMatchers("/member/login/apple").permitAll() .antMatchers("/creator-admin/member/login").permitAll() .antMatchers("/member/forgot-password").permitAll() .antMatchers("/stplat/terms_of_service").permitAll() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt index 4d72b4f2..ead65727 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt @@ -1280,6 +1280,11 @@ 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.email_consent_required" to mapOf( Lang.KO to "이메일 제공에 동의하셔야 서비스 이용이 가능합니다.", Lang.EN to "You must agree to provide your email to use the service.", diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt index 06fa296d..988d05ea 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt @@ -343,7 +343,8 @@ class MemberController( @RequestHeader("Authorization") authHeader: String, @RequestBody request: SocialLoginRequest ): ApiResponse { - return processSocialLogin(MemberProvider.GOOGLE, authHeader, request) + val token = extractBearerToken(authHeader, MemberProvider.GOOGLE) + return processSocialLogin(MemberProvider.GOOGLE, token, request, null) } @PostMapping("/login/kakao") @@ -351,27 +352,37 @@ class MemberController( @RequestHeader("Authorization") authHeader: String, @RequestBody request: SocialLoginRequest ): ApiResponse { - return processSocialLogin(MemberProvider.KAKAO, authHeader, request) + val token = extractBearerToken(authHeader, MemberProvider.KAKAO) + return processSocialLogin(MemberProvider.KAKAO, token, request, null) + } + + @PostMapping("/login/apple") + fun loginApple( + @RequestBody request: SocialLoginRequest + ): ApiResponse { + 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) } private fun processSocialLogin( provider: MemberProvider, - authHeader: String, - request: SocialLoginRequest + token: String, + request: SocialLoginRequest, + nonce: String? ): ApiResponse { - val errorKey = when (provider) { - MemberProvider.GOOGLE -> "member.social.google_login_failed" - MemberProvider.KAKAO -> "member.social.kakao_login_failed" - else -> "common.error.bad_request" - } - - if (!authHeader.startsWith("Bearer ")) { - throw SodaException(messageKey = errorKey) - } - - val token = authHeader.substring(7) val authService = socialAuthServiceResolver.resolve(provider) - val response = authService.authenticate(token, request.container, request.marketingPid, request.pushToken) + val response = authService.authenticate( + token = token, + container = request.container, + marketingPid = request.marketingPid, + pushToken = request.pushToken, + nonce = nonce + ) if (!response.marketingPid.isNullOrBlank()) { trackingService.saveTrackingHistory( @@ -392,4 +403,21 @@ 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" + else -> "common.error.bad_request" + } + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRepository.kt index add3d49d..f71a47a8 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRepository.kt @@ -24,6 +24,7 @@ interface MemberRepository : JpaRepository, MemberQueryRepository fun findByNickname(nickname: String): Member? fun findByGoogleId(googleId: String): Member? fun findByKakaoId(kakaoId: Long): Member? + fun findByAppleId(appleId: String): Member? } interface MemberQueryRepository { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt index 497fe4d5..ca30f1ec 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt @@ -37,6 +37,7 @@ 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.stipulation.Stipulation @@ -932,6 +933,63 @@ class MemberService( return MemberResolveResult(member = member, isNew = true) } + @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() + 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) + } + private fun findMemberByUsername(username: String): Member? { return if (username.startsWith("member:")) { val id = username.substringAfter("member:").toLongOrNull() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/login/LoginRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/login/LoginRequest.kt index 0895b915..a20b6992 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/login/LoginRequest.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/login/LoginRequest.kt @@ -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 ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/SocialAuthService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/SocialAuthService.kt index bb500dff..d0501ca0 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/SocialAuthService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/SocialAuthService.kt @@ -8,6 +8,7 @@ interface SocialAuthService { token: String, container: String, marketingPid: String?, - pushToken: String? + pushToken: String?, + nonce: String? ): SocialLoginResponse } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/apple/AppleAuthService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/apple/AppleAuthService.kt new file mode 100644 index 00000000..259f1599 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/apple/AppleAuthService.kt @@ -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 + ) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/apple/AppleAuthenticationToken.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/apple/AppleAuthenticationToken.kt new file mode 100644 index 00000000..f63f17b4 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/apple/AppleAuthenticationToken.kt @@ -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? = 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 + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/apple/AppleIdentityTokenVerifier.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/apple/AppleIdentityTokenVerifier.kt new file mode 100644 index 00000000..a0df3d66 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/apple/AppleIdentityTokenVerifier.kt @@ -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 = JWKSourceBuilder.create(jwkUrl) + .build() + + private val jwtProcessor: ConfigurableJWTProcessor = + DefaultJWTProcessor().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" + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/apple/AppleUserInfo.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/apple/AppleUserInfo.kt new file mode 100644 index 00000000..68e03439 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/apple/AppleUserInfo.kt @@ -0,0 +1,6 @@ +package kr.co.vividnext.sodalive.member.social.apple + +data class AppleUserInfo( + val sub: String, + val email: String? +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/google/GoogleAuthService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/google/GoogleAuthService.kt index cf2b72f1..7455b70d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/google/GoogleAuthService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/google/GoogleAuthService.kt @@ -27,7 +27,8 @@ class GoogleAuthService( token: String, container: String, marketingPid: String?, - pushToken: String? + pushToken: String?, + nonce: String? ): SocialLoginResponse { val googleUserInfo = googleService.getUserInfo(token) ?: throw SodaException(messageKey = "member.social.google_login_failed") diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/kakao/KakaoAuthService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/kakao/KakaoAuthService.kt index 45f96ad8..6641d07d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/kakao/KakaoAuthService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/kakao/KakaoAuthService.kt @@ -27,7 +27,8 @@ class KakaoAuthService( token: String, container: String, marketingPid: String?, - pushToken: String? + pushToken: String?, + nonce: String? ): SocialLoginResponse { val kakaoUserInfo = kakaoService.getUserInfo(token) ?: throw SodaException(messageKey = "member.social.kakao_login_failed") diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index aa3ad691..303a1229 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -33,6 +33,7 @@ bootpay: apple: iapVerifyUrl: https://buy.itunes.apple.com/verifyReceipt iapVerifySandboxUrl: https://sandbox.itunes.apple.com/verifyReceipt + bundleId: ${APPLE_BUNDLE_ID} agora: appId: ${AGORA_APP_ID} From 6e0b3ddf8ef3aa2a68fa3f5de4a54b3c4ed14a4e Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 28 Jan 2026 20:07:14 +0900 Subject: [PATCH 7/7] =?UTF-8?q?LINE=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=A7=80=EC=9B=90=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 회원 로그인에 LINE 공급자를 추가한다 --- .../admin/member/AdminMemberService.kt | 1 + .../sodalive/configs/SecurityConfig.kt | 1 + .../sodalive/i18n/SodaMessageSource.kt | 10 ++ .../kr/co/vividnext/sodalive/member/Member.kt | 3 +- .../sodalive/member/MemberController.kt | 14 +++ .../sodalive/member/MemberRepository.kt | 1 + .../sodalive/member/MemberService.kt | 59 +++++++++ .../member/social/line/LineAuthService.kt | 112 ++++++++++++++++++ .../social/line/LineAuthenticationToken.kt | 23 ++++ .../member/social/line/LineService.kt | 48 ++++++++ .../member/social/line/LineUserInfo.kt | 6 + .../member/social/line/LineVerifyResponse.kt | 16 +++ src/main/resources/application.yml | 3 + 13 files changed, 296 insertions(+), 1 deletion(-) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/member/social/line/LineAuthService.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/member/social/line/LineAuthenticationToken.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/member/social/line/LineService.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/member/social/line/LineUserInfo.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/member/social/line/LineVerifyResponse.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberService.kt index 11820065..6b6334be 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberService.kt @@ -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!! diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt b/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt index 3eddd75c..3b847923 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt @@ -73,6 +73,7 @@ class SecurityConfig( .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() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt index ead65727..a95615d7 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt @@ -1285,6 +1285,11 @@ class SodaMessageSource { 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.", @@ -1633,6 +1638,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" ) ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/Member.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/Member.kt index 49295f15..0a588b94 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/Member.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/Member.kt @@ -27,6 +27,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, @@ -158,5 +159,5 @@ enum class MemberRole { } enum class MemberProvider { - EMAIL, KAKAO, GOOGLE, APPLE + EMAIL, KAKAO, GOOGLE, APPLE, LINE } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt index 988d05ea..e5d9368f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt @@ -369,6 +369,19 @@ class MemberController( return processSocialLogin(MemberProvider.APPLE, token, request, nonce) } + @PostMapping("/login/line") + fun loginLine( + @RequestBody request: SocialLoginRequest + ): ApiResponse { + 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, @@ -417,6 +430,7 @@ class MemberController( 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" } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRepository.kt index f71a47a8..7b09a29e 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRepository.kt @@ -25,6 +25,7 @@ interface MemberRepository : JpaRepository, MemberQueryRepository fun findByGoogleId(googleId: String): Member? fun findByKakaoId(kakaoId: Long): Member? fun findByAppleId(appleId: String): Member? + fun findByLineId(lineId: String): Member? } interface MemberQueryRepository { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt index ca30f1ec..02684ef2 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt @@ -40,6 +40,7 @@ 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 @@ -792,6 +793,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 } @@ -990,6 +992,63 @@ class MemberService( 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() + 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() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/line/LineAuthService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/line/LineAuthService.kt new file mode 100644 index 00000000..10851acc --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/line/LineAuthService.kt @@ -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" + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/line/LineAuthenticationToken.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/line/LineAuthenticationToken.kt new file mode 100644 index 00000000..d9dedac3 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/line/LineAuthenticationToken.kt @@ -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? = 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 + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/line/LineService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/line/LineService.kt new file mode 100644 index 00000000..cfb87ae3 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/line/LineService.kt @@ -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().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 = restTemplate.postForEntity( + url, + entity, + LineVerifyResponse::class.java + ) + + if (response.statusCode.is2xxSuccessful) { + response.body + } else { + null + } + } catch (ex: Exception) { + ex.printStackTrace() + null + } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/line/LineUserInfo.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/line/LineUserInfo.kt new file mode 100644 index 00000000..80d29507 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/line/LineUserInfo.kt @@ -0,0 +1,6 @@ +package kr.co.vividnext.sodalive.member.social.line + +data class LineUserInfo( + val sub: String, + val email: String? = null +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/line/LineVerifyResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/line/LineVerifyResponse.kt new file mode 100644 index 00000000..9de18aa2 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/line/LineVerifyResponse.kt @@ -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 +) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 303a1229..378e5137 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -35,6 +35,9 @@ apple: iapVerifySandboxUrl: https://sandbox.itunes.apple.com/verifyReceipt bundleId: ${APPLE_BUNDLE_ID} +line: + channelId: ${LINE_CHANNEL_ID} + agora: appId: ${AGORA_APP_ID} appCertificate: ${AGORA_APP_CERTIFICATE}