d3ro-voice/server/supabase/functions/_shared/quota.ts

329 lines
11 KiB
TypeScript

// server/supabase/functions/_shared/quota.ts
// Phase 3.2: 모델별 쿼터 + 주간/일간 기간 분리
// Free=Haiku 250/주간, Pro=모델별 일간, Pro+=Haiku 무제한
import { createClient } from '@supabase/supabase-js'
import {
PLAN_QUOTA,
type PlanQuota,
type PlanQuotaFeature,
type PlanQuotaPeriod,
type PlanQuotaTier,
} from './core-contract.generated.ts'
import {
isQuotaAllowed,
parseQuotaConsumeResponse,
type QuotaConsumedFrom,
quotaUsageDate,
quotaWindowStart,
} from './quota-policy.ts'
export type Tier = PlanQuotaTier
/** 쿼터 추적 키 — 모델별 분리 */
export type QuotaFeature = PlanQuotaFeature
export type QuotaPeriod = PlanQuotaPeriod
type ModelQuota = PlanQuota
/** 모델별 쿼터 정책. 정본은 packages/core/src/plan-catalog.ts `PLAN_QUOTA` (생성 사본 경유). */
const MODEL_QUOTA: Readonly<Record<Tier, Readonly<Record<QuotaFeature, ModelQuota>>>> = PLAN_QUOTA
/** Anthropic 모델명 → 쿼터 키 매핑 */
export function modelToQuotaKey(model: string): QuotaFeature {
if (model.includes('haiku')) return 'llm_haiku'
if (model.includes('sonnet')) return 'llm_sonnet'
if (model.includes('opus')) return 'llm_opus'
return 'llm_haiku' // fallback
}
/** 티어+feature → 쿼터 정책 조회 */
export function getQuotaPolicy(tier: Tier, feature: QuotaFeature): ModelQuota {
return MODEL_QUOTA[tier]?.[feature] ?? { limit: 0, period: 'daily' }
}
export interface QuotaCheck {
allowed: boolean
current: number
limit: number
period: QuotaPeriod
tier: Tier
overageCredits: number
}
export interface QuotaConsumeResult {
allowed: boolean
current: number
limit: number
overageCredits: number
consumedFrom: QuotaConsumedFrom
}
/** reserve_*_quota RPC 결과 — 공급자 호출 전에 한 단위를 잡아 두는 리스(lease). */
export interface QuotaReservation {
allowed: boolean
reservationId: string | null
status: 'reserved' | 'completed' | 'released' | 'denied'
current: number
limit: number
period: QuotaPeriod
tier: Tier
overageCredits: number
consumedFrom: 'base' | 'overage' | 'unlimited' | 'none'
}
export type SttQuotaReservation = QuotaReservation
export type QuotaReservationFinalStatus = 'completed' | 'released'
const RESERVATION_STATUSES: readonly unknown[] = ['reserved', 'completed', 'released', 'denied']
const QUOTA_PERIODS: readonly unknown[] = ['daily', 'weekly']
const QUOTA_TIERS: readonly unknown[] = ['free', 'pro', 'pro_plus', 'team', 'enterprise']
const RESERVATION_SOURCES: readonly unknown[] = ['base', 'overage', 'unlimited', 'none']
/** reserve_stt_quota / reserve_llm_quota 공통 응답 검증. 형식이 어긋나면 throw. */
export function parseQuotaReservationResponse(data: unknown, label: string): QuotaReservation {
if (!data || typeof data !== 'object' || Array.isArray(data)) {
throw new Error(`Invalid ${label} quota reservation response.`)
}
const result = data as Record<string, unknown>
if (
typeof result.allowed !== 'boolean'
|| (result.reservation_id !== null && typeof result.reservation_id !== 'string')
|| !RESERVATION_STATUSES.includes(result.status)
|| typeof result.current !== 'number'
|| typeof result.limit !== 'number'
|| !QUOTA_PERIODS.includes(result.period)
|| !QUOTA_TIERS.includes(result.tier)
|| typeof result.overage_credits !== 'number'
|| !RESERVATION_SOURCES.includes(result.consumed_from)
) {
throw new Error(`Invalid ${label} quota reservation response.`)
}
return {
allowed: result.allowed,
reservationId: result.reservation_id as string | null,
status: result.status as QuotaReservation['status'],
current: result.current,
limit: result.limit,
period: result.period as QuotaPeriod,
tier: result.tier as Tier,
overageCredits: result.overage_credits,
consumedFrom: result.consumed_from as QuotaReservation['consumedFrom'],
}
}
/** finalize_*_quota RPC 공통 응답 검증. */
export function parseQuotaFinalizeResponse(data: unknown, label: string): QuotaReservationFinalStatus {
if (!data || typeof data !== 'object' || Array.isArray(data)) {
throw new Error(`Invalid ${label} quota finalization response.`)
}
const status = (data as Record<string, unknown>).status
if (status !== 'completed' && status !== 'released') {
throw new Error(`Invalid ${label} quota finalization response.`)
}
return status
}
export async function reserveSttQuota(
userId: string,
reservationId: string,
serviceRoleClient: ReturnType<typeof createClient>,
): Promise<SttQuotaReservation> {
const { data, error } = await serviceRoleClient.rpc('reserve_stt_quota', {
p_user_id: userId,
p_reservation_id: reservationId,
})
if (error || !data || typeof data !== 'object' || Array.isArray(data)) {
throw new Error('Failed to reserve STT quota.')
}
return parseQuotaReservationResponse(data, 'STT')
}
export async function finalizeSttQuota(
reservationId: string,
succeeded: boolean,
serviceRoleClient: ReturnType<typeof createClient>,
): Promise<QuotaReservationFinalStatus> {
const { data, error } = await serviceRoleClient.rpc('finalize_stt_quota', {
p_reservation_id: reservationId,
p_succeeded: succeeded,
})
if (error || !data || typeof data !== 'object' || Array.isArray(data)) {
throw new Error('Failed to finalize STT quota.')
}
return parseQuotaFinalizeResponse(data, 'STT')
}
/**
* LLM 쿼터 한 단위를 공급자 호출 **전에** 잡는다 (`reserve_llm_quota`).
* consume_quota 와 같은 per-user/feature advisory lock 아래에서 진행 중인 요청까지
* 사용량에 포함하므로, 동시 요청이 한도를 넘어 공급자 비용을 쓰지 못한다.
* 성공/실패는 finalizeLlmQuota 로 확정하고, 확정되지 않은 리스는 10분 뒤 환불된다.
*/
export async function reserveLlmQuota(
userId: string,
reservationId: string,
feature: QuotaFeature,
baseLimit: number,
period: QuotaPeriod,
serviceRoleClient: ReturnType<typeof createClient>,
): Promise<QuotaReservation> {
const { data, error } = await serviceRoleClient.rpc('reserve_llm_quota', {
p_user_id: userId,
p_reservation_id: reservationId,
p_feature: feature,
p_base_limit: baseLimit,
p_period: period,
})
if (error || !data || typeof data !== 'object' || Array.isArray(data)) {
throw new Error('Failed to reserve LLM quota.')
}
return parseQuotaReservationResponse(data, 'LLM')
}
/** 예약을 확정한다 — succeeded=true 면 소비 유지, false 면 사용량과 overage 크레딧 환불. */
export async function finalizeLlmQuota(
reservationId: string,
succeeded: boolean,
serviceRoleClient: ReturnType<typeof createClient>,
): Promise<QuotaReservationFinalStatus> {
const { data, error } = await serviceRoleClient.rpc('finalize_llm_quota', {
p_reservation_id: reservationId,
p_succeeded: succeeded,
})
if (error || !data || typeof data !== 'object' || Array.isArray(data)) {
throw new Error('Failed to finalize LLM quota.')
}
return parseQuotaFinalizeResponse(data, 'LLM')
}
interface SubscriptionQuotaState {
tier: Tier
overageCredits: number
}
/** 구독 티어 + overage 잔액. 행이 없으면 free/0 (기존 checkQuota 동작 유지). */
async function readSubscriptionQuotaState(
userId: string,
serviceRoleClient: ReturnType<typeof createClient>,
): Promise<SubscriptionQuotaState> {
const { data: sub } = await serviceRoleClient
.from('subscriptions')
.select('tier, overage_credits')
.eq('user_id', userId)
.single()
return {
tier: (sub?.tier as Tier) ?? 'free',
overageCredits: (sub?.overage_credits as number) ?? 0,
}
}
/** 구독 티어만 조회 (행이 없으면 free). 사용량은 읽지 않는다. */
export async function readSubscriptionTier(
userId: string,
serviceRoleClient: ReturnType<typeof createClient>,
): Promise<Tier> {
return (await readSubscriptionQuotaState(userId, serviceRoleClient)).tier
}
/** 기간 창(daily=오늘, weekly=오늘-6..오늘) 안의 daily_usage 합계. */
async function readWindowUsage(
userId: string,
feature: QuotaFeature,
period: QuotaPeriod,
serviceRoleClient: ReturnType<typeof createClient>,
now: Date,
): Promise<number> {
const { data: rows } = await serviceRoleClient
.from('daily_usage')
.select('count')
.eq('user_id', userId)
.eq('feature', feature)
.gte('date', quotaWindowStart(period, now))
.lte('date', quotaUsageDate(now))
const usageRows = (rows ?? []) as Array<{ count: number | null }>
return usageRows.reduce((sum, row) => sum + (row.count ?? 0), 0)
}
/**
* 쿼터 확인 — 모델별, 기간별(daily/weekly).
* weekly인 경우 최근 7일(오늘 포함, 오늘-6..오늘) daily_usage를 합산 —
* SQL 쪽 `consume_quota`·`reserve_stt_quota`의 `CURRENT_DATE - 6` 창과 같다.
*/
export async function checkQuota(
userId: string,
feature: QuotaFeature,
serviceRoleClient: ReturnType<typeof createClient>,
now: Date = new Date(),
): Promise<QuotaCheck> {
const { tier, overageCredits } = await readSubscriptionQuotaState(userId, serviceRoleClient)
const policy = getQuotaPolicy(tier, feature)
// 사용불가 (limit=0) / 무제한 (limit=-1) 은 사용량 조회가 필요 없다.
if (policy.limit === 0 || policy.limit === -1) {
return {
allowed: isQuotaAllowed(policy.limit, 0, overageCredits),
current: 0,
limit: policy.limit,
period: policy.period,
tier,
overageCredits,
}
}
const current = await readWindowUsage(userId, feature, policy.period, serviceRoleClient, now)
return {
allowed: isQuotaAllowed(policy.limit, current, overageCredits),
current,
limit: policy.limit,
period: policy.period,
tier,
overageCredits,
}
}
/**
* 쿼터 소비 — 오늘 날짜의 daily_usage를 +1 증가.
* `consume_quota` RPC가 기간 창(weekly=최근 7일 합산) 사용량을 base 한도와 비교해
* base 소진 + overage 있으면 overage 1 크레딧을 원자적으로 차감한다.
* 무제한(-1)이면 카운터만 증가하고 allowed=true.
*
* `period`를 넘기지 않으면 구독 티어로 정책 기간을 다시 조회한다
* (checkQuota 결과의 `period`를 넘기면 조회 1회를 아낀다).
*/
export async function consumeQuota(
userId: string,
feature: QuotaFeature,
serviceRoleClient: ReturnType<typeof createClient>,
baseLimit: number,
period?: QuotaPeriod,
): Promise<QuotaConsumeResult> {
const resolvedPeriod = period
?? getQuotaPolicy((await readSubscriptionQuotaState(userId, serviceRoleClient)).tier, feature).period
const { data, error } = await serviceRoleClient.rpc('consume_quota', {
p_user_id: userId,
p_feature: feature,
p_base_limit: baseLimit,
p_period: resolvedPeriod,
})
if (error) {
throw new Error(`Failed to consume quota: ${error.message}`)
}
return parseQuotaConsumeResponse(data)
}
/**
* service role 클라이언트 생성 헬퍼
*/
export function createServiceRoleClient(): ReturnType<typeof createClient> {
const url = Deno.env.get('SUPABASE_URL') ?? ''
const serviceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
return createClient(url, serviceKey, {
auth: { persistSession: false, autoRefreshToken: false },
})
}