feat(quota)!: AI 채팅 쿼터(무료/유료) 도입 및 입장/전송 응답에 상태 포함

- ChatQuota 엔티티/레포/서비스/컨트롤러 추가
- 입장 시 Lazy refill 적용, 전송 시 무료 우선 차감 및 잔여/리필 시간 응답 포함
- ChatRoomEnterResponse에 totalRemaining/nextRechargeAtEpoch 추가
- SendChatMessageResponse 신설 및 send API 응답 스키마 변경
- CanUsage에 CHAT_QUOTA_PURCHASE 추가, CanPaymentService/CanService에 결제 흐름 반영
This commit is contained in:
2025-08-26 13:22:49 +09:00
parent 8b1dd7cb95
commit 6ecac8d331
9 changed files with 224 additions and 7 deletions

View File

@@ -0,0 +1,21 @@
package kr.co.vividnext.sodalive.chat.quota
import java.time.LocalDateTime
import javax.persistence.Entity
import javax.persistence.Id
import javax.persistence.Table
import javax.persistence.Version
@Entity
@Table(name = "chat_quota")
class ChatQuota(
@Id
val memberId: Long,
var remainingFree: Int = 10,
var remainingPaid: Int = 0,
var nextRechargeAt: LocalDateTime? = null,
@Version
var version: Long? = null
) {
fun total(): Int = remainingFree + remainingPaid
}

View File

@@ -0,0 +1,65 @@
package kr.co.vividnext.sodalive.chat.quota
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 org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/api/chat/quota")
class ChatQuotaController(
private val chatQuotaService: ChatQuotaService,
private val canPaymentService: kr.co.vividnext.sodalive.can.payment.CanPaymentService
) {
data class ChatQuotaStatusResponse(
val totalRemaining: Int,
val nextRechargeAtEpoch: Long?
)
data class ChatQuotaPurchaseRequest(
val container: String,
val addPaid: Int = 50,
val needCan: Int = 30
)
@GetMapping("/me")
fun getMyQuota(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
): ApiResponse<ChatQuotaStatusResponse> = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
val s = chatQuotaService.getStatus(member.id!!)
ApiResponse.ok(ChatQuotaStatusResponse(s.totalRemaining, s.nextRechargeAtEpochMillis))
}
@PostMapping("/purchase")
fun purchaseQuota(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
@RequestBody request: ChatQuotaPurchaseRequest
): ApiResponse<Boolean> = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
if (request.container.isBlank()) throw SodaException("container를 확인해주세요.")
// 30캔 차감 처리 (결제 기록 남김)
canPaymentService.spendCan(
memberId = member.id!!,
needCan = if (request.needCan > 0) request.needCan else 30,
canUsage = CanUsage.CHAT_QUOTA_PURCHASE,
container = request.container
)
// 유료 횟수 적립 (기본 50)
val add = if (request.addPaid > 0) request.addPaid else 50
chatQuotaService.purchase(member.id!!, add)
ApiResponse.ok(true)
}
}

View File

@@ -0,0 +1,15 @@
package kr.co.vividnext.sodalive.chat.quota
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Lock
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import javax.persistence.LockModeType
interface ChatQuotaRepository : JpaRepository<ChatQuota, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select q from ChatQuota q where q.memberId = :memberId")
fun findForUpdate(@Param("memberId") memberId: Long): ChatQuota?
fun findByMemberId(memberId: Long): ChatQuota?
}

View File

@@ -0,0 +1,80 @@
package kr.co.vividnext.sodalive.chat.quota
import kr.co.vividnext.sodalive.common.SodaException
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
import java.time.ZoneId
@Service
class ChatQuotaService(
private val repo: ChatQuotaRepository
) {
companion object {
private const val FREE_BUCKET = 10
private const val RECHARGE_HOURS = 6L
}
data class QuotaStatus(
val totalRemaining: Int,
val nextRechargeAtEpochMillis: Long?
)
@Transactional
fun applyRefillOnEnterAndGetStatus(memberId: Long): QuotaStatus {
val now = LocalDateTime.now()
val quota = repo.findForUpdate(memberId) ?: repo.save(ChatQuota(memberId = memberId))
if (quota.remainingFree == 0 && quota.nextRechargeAt != null && !now.isBefore(quota.nextRechargeAt)) {
quota.remainingFree = FREE_BUCKET
quota.nextRechargeAt = null
}
val epoch = quota.nextRechargeAt?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli()
return QuotaStatus(totalRemaining = quota.total(), nextRechargeAtEpochMillis = epoch)
}
@Transactional
fun consumeOne(memberId: Long) {
val now = LocalDateTime.now()
val quota = repo.findForUpdate(memberId) ?: repo.save(ChatQuota(memberId = memberId))
when {
quota.remainingFree > 0 -> {
quota.remainingFree -= 1
if (quota.remainingFree + quota.remainingPaid == 0 && quota.nextRechargeAt == null) {
quota.nextRechargeAt = now.plusHours(RECHARGE_HOURS)
}
}
quota.remainingPaid > 0 -> {
quota.remainingPaid -= 1
if (quota.remainingFree + quota.remainingPaid == 0 && quota.nextRechargeAt == null) {
quota.nextRechargeAt = now.plusHours(RECHARGE_HOURS)
}
}
else -> {
if (quota.nextRechargeAt == null) {
quota.nextRechargeAt = now.plusHours(RECHARGE_HOURS)
}
throw SodaException("채팅 가능 횟수가 모두 소진되었습니다. 다음 무료 충전 이후 이용해주세요.")
}
}
}
@Transactional(readOnly = true)
fun getStatus(memberId: Long): QuotaStatus {
val q = repo.findByMemberId(memberId) ?: return QuotaStatus(
totalRemaining = FREE_BUCKET,
nextRechargeAtEpochMillis = null
)
val total = q.remainingFree + q.remainingPaid
val epoch = q.nextRechargeAt?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli()
return QuotaStatus(totalRemaining = total, nextRechargeAtEpochMillis = epoch)
}
@Transactional
fun purchase(memberId: Long, addPaid: Int) {
val quota = repo.findForUpdate(memberId) ?: repo.save(ChatQuota(memberId = memberId))
quota.remainingPaid += addPaid
}
}