feat(quota)!: AI 채팅 쿼터(무료/유료) 도입 및 입장/전송 응답에 상태 포함
- ChatQuota 엔티티/레포/서비스/컨트롤러 추가 - 입장 시 Lazy refill 적용, 전송 시 무료 우선 차감 및 잔여/리필 시간 응답 포함 - ChatRoomEnterResponse에 totalRemaining/nextRechargeAtEpoch 추가 - SendChatMessageResponse 신설 및 send API 응답 스키마 변경 - CanUsage에 CHAT_QUOTA_PURCHASE 추가, CanPaymentService/CanService에 결제 흐름 반영
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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?
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user