From 06acfae1c9ab625cf0df52adf9ec2fb493f5c6af Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 2 Apr 2026 14:15:28 +0900 Subject: [PATCH] =?UTF-8?q?fix(chat):=20AI=20=EC=BA=90=EB=A6=AD=ED=84=B0?= =?UTF-8?q?=20=EC=84=B1=EC=9D=B8=20=EC=A0=91=EA=B7=BC=20=ED=8C=90=EC=A0=95?= =?UTF-8?q?=EC=9D=84=20=EA=B5=AD=EA=B0=80=EB=B3=84=20=EC=A0=95=EC=B1=85?= =?UTF-8?q?=EC=97=90=20=EB=A7=9E=EC=B6=98=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...20260402_AI캐릭터본인인증국가별분기적용.md | 22 ++++++++++++++ .../controller/OriginalWorkController.kt | 14 +++++++-- .../chat/quota/ChatQuotaController.kt | 18 +++++++---- .../quota/room/ChatRoomQuotaController.kt | 23 +++++++------- .../room/controller/ChatRoomController.kt | 30 ++++++++++++------- 5 files changed, 80 insertions(+), 27 deletions(-) create mode 100644 docs/20260402_AI캐릭터본인인증국가별분기적용.md diff --git a/docs/20260402_AI캐릭터본인인증국가별분기적용.md b/docs/20260402_AI캐릭터본인인증국가별분기적용.md new file mode 100644 index 00000000..137c88c4 --- /dev/null +++ b/docs/20260402_AI캐릭터본인인증국가별분기적용.md @@ -0,0 +1,22 @@ +- [x] chat 패키지의 AI 캐릭터 상세/채팅 본인인증 적용 지점을 확인한다. +- [x] 기존 캐릭터 상세의 국가별 본인인증 분기 방식을 확인한다. +- [x] chat 패키지의 AI 캐릭터 및 AI 캐릭터 채팅 로직에 동일한 국가별 인증 방식을 반영한다. +- [x] 변경 사항에 대한 진단 및 관련 검증을 수행한다. + +## 검증 기록 + +### 1차 구현 +- 무엇을: `ChatRoomController`, `ChatQuotaController`, `ChatRoomQuotaController`의 본인인증 체크를 `member.auth` 직접 검사에서 `MemberContentPreferenceService.getStoredPreference(member).isAdult` 기반 국가별 판정으로 변경했다. +- 왜: AI 캐릭터 상세와 동일하게 한국은 본인인증이 필요하고, 그 외 국가는 저장된 성인 노출 설정 기준으로 접근하도록 맞추기 위해서다. +- 어떻게: + - `./gradlew compileKotlin` → 성공 + - `./gradlew test` → 성공 + - 변경 컨트롤러 3개에서 `member.auth == null` 직접 검사가 제거되고 `resolveIsAdultAccessible(...)`로 치환된 것을 확인함 + +### 2차 수정 +- 무엇을: `OriginalWorkController`의 목록/상세 본인인증 체크도 동일한 국가별 판정으로 변경했다. +- 왜: `chat/original` 하위에 `member.auth` 직접 검사 잔여 지점이 남아 있어, 최초 요청 범위인 `chat` 패키지 전체 기준으로 정책이 완전히 일치하지 않았기 때문이다. +- 어떻게: + - `./gradlew compileKotlin` → 성공 + - `./gradlew test` → 성공 + - `src/main/kotlin/kr/co/vividnext/sodalive/chat` 전체에서 `member.auth == null|member?.auth != null` 검색 → 결과 없음 diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/controller/OriginalWorkController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/controller/OriginalWorkController.kt index ce5232b2..73fab1f6 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/controller/OriginalWorkController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/controller/OriginalWorkController.kt @@ -14,6 +14,7 @@ import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService import org.springframework.beans.factory.annotation.Value import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.GetMapping @@ -33,6 +34,7 @@ import java.time.LocalDateTime class OriginalWorkController( private val queryService: OriginalWorkQueryService, private val characterImageRepository: CharacterImageRepository, + private val memberContentPreferenceService: MemberContentPreferenceService, private val langContext: LangContext, @@ -58,7 +60,7 @@ class OriginalWorkController( @RequestParam(defaultValue = "20") size: Int, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - val includeAdult = member?.auth != null + val includeAdult = resolveIsAdultAccessible(member) val pageRes = queryService.listForAppPage(includeAdult, page, size) val content = pageRes.content.map { OriginalWorkListItemResponse.from(it, imageHost) } @@ -127,7 +129,7 @@ class OriginalWorkController( @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") - if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required") + if (!resolveIsAdultAccessible(member)) throw SodaException(messageKey = "common.error.adult_verification_required") val ow = queryService.getOriginalWork(id) val chars = queryService.getActiveCharactersPage(id, page = 0, size = 20).content @@ -196,4 +198,12 @@ class OriginalWorkController( ) ) } + + private fun resolveIsAdultAccessible(member: Member?): Boolean { + if (member == null) { + return false + } + + return memberContentPreferenceService.getStoredPreference(member).isAdult + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaController.kt index 01bc064d..e981f46b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaController.kt @@ -5,6 +5,7 @@ import kr.co.vividnext.sodalive.can.use.CanUsage import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PostMapping @@ -16,7 +17,8 @@ import org.springframework.web.bind.annotation.RestController @RequestMapping("/api/chat/quota") class ChatQuotaController( private val chatQuotaService: ChatQuotaService, - private val canPaymentService: CanPaymentService + private val canPaymentService: CanPaymentService, + private val memberContentPreferenceService: MemberContentPreferenceService ) { data class ChatQuotaStatusResponse( @@ -33,7 +35,7 @@ class ChatQuotaController( @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ): ApiResponse = run { if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") - if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required") + if (!resolveIsAdultAccessible(member)) throw SodaException(messageKey = "common.error.adult_verification_required") val s = chatQuotaService.getStatus(member.id!!) ApiResponse.ok(ChatQuotaStatusResponse(s.totalRemaining, s.nextRechargeAtEpochMillis)) @@ -45,10 +47,9 @@ class ChatQuotaController( @RequestBody request: ChatQuotaPurchaseRequest ): ApiResponse = run { if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") - if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required") + if (!resolveIsAdultAccessible(member)) throw SodaException(messageKey = "common.error.adult_verification_required") if (request.container.isBlank()) throw SodaException(messageKey = "chat.quota.container_required") - // 30캔 차감 처리 (결제 기록 남김) canPaymentService.spendCan( memberId = member.id!!, needCan = 30, @@ -56,8 +57,15 @@ class ChatQuotaController( container = request.container ) - // 글로벌 유료 개념 제거됨: 구매 성공 시에도 글로벌 쿼터 증액 없음 val s = chatQuotaService.getStatus(member.id!!) ApiResponse.ok(ChatQuotaStatusResponse(s.totalRemaining, s.nextRechargeAtEpochMillis)) } + + private fun resolveIsAdultAccessible(member: Member?): Boolean { + if (member == null) { + return false + } + + return memberContentPreferenceService.getStoredPreference(member).isAdult + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaController.kt index c1d8f543..e886de05 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaController.kt @@ -7,6 +7,7 @@ import kr.co.vividnext.sodalive.chat.room.repository.ChatRoomRepository import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable @@ -21,7 +22,8 @@ class ChatRoomQuotaController( private val chatRoomRepository: ChatRoomRepository, private val participantRepository: ChatParticipantRepository, private val chatRoomQuotaService: ChatRoomQuotaService, - private val chatQuotaService: ChatQuotaService + private val chatQuotaService: ChatQuotaService, + private val memberContentPreferenceService: MemberContentPreferenceService ) { data class PurchaseRoomQuotaRequest( @@ -53,17 +55,15 @@ class ChatRoomQuotaController( @RequestBody req: PurchaseRoomQuotaRequest ): ApiResponse = run { if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") - if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required") + if (!resolveIsAdultAccessible(member)) throw SodaException(messageKey = "common.error.adult_verification_required") if (req.container.isBlank()) throw SodaException(messageKey = "chat.room.quota.invalid_access") val room = chatRoomRepository.findByIdAndIsActiveTrue(chatRoomId) ?: throw SodaException(messageKey = "chat.error.room_not_found") - // 내 참여 여부 확인 participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member) ?: throw SodaException(messageKey = "chat.room.quota.invalid_access") - // 캐릭터 참여자 확인(유효한 AI 캐릭터 방인지 체크 및 characterId 기본값 보조) val characterParticipant = participantRepository .findByChatRoomAndParticipantTypeAndIsActiveTrue(room, ParticipantType.CHARACTER) ?: throw SodaException(messageKey = "chat.room.quota.not_ai_room") @@ -74,7 +74,6 @@ class ChatRoomQuotaController( val characterId = character.id ?: throw SodaException(messageKey = "chat.room.quota.character_required") - // 서비스에서 결제 포함하여 처리 val status = chatRoomQuotaService.purchase( memberId = member.id!!, chatRoomId = chatRoomId, @@ -99,24 +98,20 @@ class ChatRoomQuotaController( @PathVariable chatRoomId: Long ): ApiResponse = run { if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") - if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required") + if (!resolveIsAdultAccessible(member)) throw SodaException(messageKey = "common.error.adult_verification_required") val room = chatRoomRepository.findByIdAndIsActiveTrue(chatRoomId) ?: throw SodaException(messageKey = "chat.error.room_not_found") - // 내 참여 여부 확인 participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member) ?: throw SodaException(messageKey = "chat.room.quota.invalid_access") - // 캐릭터 확인 val characterParticipant = participantRepository .findByChatRoomAndParticipantTypeAndIsActiveTrue(room, ParticipantType.CHARACTER) ?: throw SodaException(messageKey = "chat.room.quota.not_ai_room") val character = characterParticipant.character ?: throw SodaException(messageKey = "chat.room.quota.not_ai_room") - // 글로벌 Lazy refill val globalStatus = chatQuotaService.getStatus(member.id!!) - // 룸 Lazy refill 상태 val roomStatus = chatRoomQuotaService.applyRefillOnEnterAndGetStatus( memberId = member.id!!, chatRoomId = chatRoomId, @@ -136,4 +131,12 @@ class ChatRoomQuotaController( ) ) } + + private fun resolveIsAdultAccessible(member: Member?): Boolean { + if (member == null) { + return false + } + + return memberContentPreferenceService.getStoredPreference(member).isAdult + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt index d2b80fbb..0220c39b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt @@ -8,6 +8,7 @@ import kr.co.vividnext.sodalive.chat.room.service.ChatRoomService import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable @@ -20,7 +21,8 @@ import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/api/chat/room") class ChatRoomController( - private val chatRoomService: ChatRoomService + private val chatRoomService: ChatRoomService, + private val memberContentPreferenceService: MemberContentPreferenceService ) { /** @@ -43,7 +45,7 @@ class ChatRoomController( @RequestBody request: CreateChatRoomRequest ) = run { if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") - if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required") + if (!resolveIsAdultAccessible(member)) throw SodaException(messageKey = "common.error.adult_verification_required") val response = chatRoomService.createOrGetChatRoom(member, request.characterId) ApiResponse.ok(response) @@ -59,7 +61,7 @@ class ChatRoomController( @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, @RequestParam(defaultValue = "0") page: Int ) = run { - if (member == null || member.auth == null) { + if (member == null || !resolveIsAdultAccessible(member)) { ApiResponse.ok(emptyList()) } else { val response = chatRoomService.listMyChatRooms(member, page) @@ -78,7 +80,7 @@ class ChatRoomController( @PathVariable chatRoomId: Long ) = run { if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") - if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required") + if (!resolveIsAdultAccessible(member)) throw SodaException(messageKey = "common.error.adult_verification_required") val isActive = chatRoomService.isMyRoomSessionActive(member, chatRoomId) ApiResponse.ok(isActive) @@ -96,7 +98,7 @@ class ChatRoomController( @RequestParam(required = false) characterImageId: Long? ) = run { if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") - if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required") + if (!resolveIsAdultAccessible(member)) throw SodaException(messageKey = "common.error.adult_verification_required") val response = chatRoomService.enterChatRoom(member, chatRoomId, characterImageId) ApiResponse.ok(response) @@ -115,7 +117,7 @@ class ChatRoomController( @PathVariable chatRoomId: Long ) = run { if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") - if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required") + if (!resolveIsAdultAccessible(member)) throw SodaException(messageKey = "common.error.adult_verification_required") chatRoomService.leaveChatRoom(member, chatRoomId) ApiResponse.ok(true) @@ -135,7 +137,7 @@ class ChatRoomController( @RequestParam(required = false) cursor: Long? ) = run { if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") - if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required") + if (!resolveIsAdultAccessible(member)) throw SodaException(messageKey = "common.error.adult_verification_required") val response = chatRoomService.getChatMessages(member, chatRoomId, cursor, limit) ApiResponse.ok(response) @@ -154,7 +156,7 @@ class ChatRoomController( @RequestBody request: SendChatMessageRequest ) = run { if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") - if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required") + if (!resolveIsAdultAccessible(member)) throw SodaException(messageKey = "common.error.adult_verification_required") if (request.message.isBlank()) { ApiResponse.error() @@ -177,7 +179,7 @@ class ChatRoomController( @RequestBody request: ChatMessagePurchaseRequest ) = run { if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") - if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required") + if (!resolveIsAdultAccessible(member)) throw SodaException(messageKey = "common.error.adult_verification_required") val result = chatRoomService.purchaseMessage(member, chatRoomId, messageId, request.container) ApiResponse.ok(result) @@ -196,9 +198,17 @@ class ChatRoomController( @RequestBody request: ChatRoomResetRequest ) = run { if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") - if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required") + if (!resolveIsAdultAccessible(member)) throw SodaException(messageKey = "common.error.adult_verification_required") val response = chatRoomService.resetChatRoom(member, chatRoomId, request.container) ApiResponse.ok(response) } + + private fun resolveIsAdultAccessible(member: Member?): Boolean { + if (member == null) { + return false + } + + return memberContentPreferenceService.getStoredPreference(member).isAdult + } }