fix(chat): AI 캐릭터 성인 접근 판정을 국가별 정책에 맞춘다

This commit is contained in:
2026-04-02 14:15:28 +09:00
parent 8705389ddd
commit 06acfae1c9
5 changed files with 80 additions and 27 deletions

View File

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

View File

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

View File

@@ -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<PurchaseRoomQuotaResponse> = 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<RoomQuotaStatusResponse> = 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
}
}

View File

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