280 lines
8.8 KiB
TypeScript
280 lines
8.8 KiB
TypeScript
// server/supabase/functions/_shared/quota.ts
|
|
// Phase 3.2: 모델별 쿼터 + 주간/일간 기간 분리
|
|
// Free=Haiku 250/주간, Pro=모델별 일간, Pro+=Haiku 무제한
|
|
|
|
import { createClient } from '@supabase/supabase-js'
|
|
|
|
export type Tier = 'free' | 'pro' | 'pro_plus' | 'team' | 'enterprise'
|
|
|
|
/** 쿼터 추적 키 — 모델별 분리 */
|
|
export type QuotaFeature =
|
|
| 'stt_transcribe'
|
|
| 'llm_haiku'
|
|
| 'llm_sonnet'
|
|
| 'llm_opus'
|
|
| 'realtime_session'
|
|
|
|
export type QuotaPeriod = 'daily' | 'weekly'
|
|
|
|
interface ModelQuota {
|
|
/** -1=무제한, 0=사용불가, 양수=한도 */
|
|
limit: number
|
|
period: QuotaPeriod
|
|
}
|
|
|
|
/** 모델별 쿼터 정책 */
|
|
const MODEL_QUOTA: Record<Tier, Record<QuotaFeature, ModelQuota>> = {
|
|
free: {
|
|
stt_transcribe: { limit: 250, period: 'weekly' },
|
|
llm_haiku: { limit: 250, period: 'weekly' },
|
|
llm_sonnet: { limit: 0, period: 'daily' }, // 사용불가
|
|
llm_opus: { limit: 0, period: 'daily' }, // 사용불가
|
|
realtime_session: { limit: 0, period: 'daily' }, // 사용불가
|
|
},
|
|
pro: {
|
|
stt_transcribe: { limit: -1, period: 'daily' },
|
|
llm_haiku: { limit: 1500, period: 'daily' },
|
|
llm_sonnet: { limit: 300, period: 'daily' },
|
|
llm_opus: { limit: 50, period: 'daily' },
|
|
// 세션 수 기준 (~$0.016/분 mini — 세션당 평균 수 분 가정)
|
|
realtime_session: { limit: 30, period: 'daily' },
|
|
},
|
|
pro_plus: {
|
|
stt_transcribe: { limit: -1, period: 'daily' },
|
|
llm_haiku: { limit: -1, period: 'daily' }, // 무제한
|
|
llm_sonnet: { limit: 1500, period: 'daily' },
|
|
llm_opus: { limit: 300, period: 'daily' },
|
|
realtime_session: { limit: 120, period: 'daily' },
|
|
},
|
|
team: {
|
|
stt_transcribe: { limit: -1, period: 'daily' },
|
|
llm_haiku: { limit: -1, period: 'daily' }, // 무제한
|
|
llm_sonnet: { limit: 3000, period: 'daily' },
|
|
llm_opus: { limit: 600, period: 'daily' },
|
|
realtime_session: { limit: 300, period: 'daily' },
|
|
},
|
|
enterprise: {
|
|
stt_transcribe: { limit: -1, period: 'daily' },
|
|
llm_haiku: { limit: -1, period: 'daily' }, // 무제한
|
|
llm_sonnet: { limit: -1, period: 'daily' }, // 무제한
|
|
llm_opus: { limit: -1, period: 'daily' }, // 무제한
|
|
realtime_session: { limit: -1, period: 'daily' }, // 무제한
|
|
},
|
|
}
|
|
|
|
/** 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: 'base' | 'overage' | 'unlimited' | 'none'
|
|
}
|
|
|
|
export interface SttQuotaReservation {
|
|
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 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.')
|
|
}
|
|
const result = data as Record<string, unknown>
|
|
if (
|
|
typeof result.allowed !== 'boolean'
|
|
|| (result.reservation_id !== null && typeof result.reservation_id !== 'string')
|
|
|| !['reserved', 'completed', 'released', 'denied'].includes(String(result.status))
|
|
|| typeof result.current !== 'number'
|
|
|| typeof result.limit !== 'number'
|
|
|| !['daily', 'weekly'].includes(String(result.period))
|
|
|| !['free', 'pro', 'pro_plus', 'team', 'enterprise'].includes(String(result.tier))
|
|
|| typeof result.overage_credits !== 'number'
|
|
|| !['base', 'overage', 'unlimited', 'none'].includes(String(result.consumed_from))
|
|
) {
|
|
throw new Error('Invalid STT quota reservation response.')
|
|
}
|
|
return {
|
|
allowed: result.allowed,
|
|
reservationId: result.reservation_id as string | null,
|
|
status: result.status as SttQuotaReservation['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 SttQuotaReservation['consumedFrom'],
|
|
}
|
|
}
|
|
|
|
export async function finalizeSttQuota(
|
|
reservationId: string,
|
|
succeeded: boolean,
|
|
serviceRoleClient: ReturnType<typeof createClient>,
|
|
): Promise<'completed' | 'released'> {
|
|
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.')
|
|
}
|
|
const status = (data as Record<string, unknown>).status
|
|
if (status !== 'completed' && status !== 'released') {
|
|
throw new Error('Invalid STT quota finalization response.')
|
|
}
|
|
return status
|
|
}
|
|
|
|
/**
|
|
* 쿼터 확인 — 모델별, 기간별(daily/weekly).
|
|
* weekly인 경우 최근 7일 daily_usage를 합산.
|
|
*/
|
|
export async function checkQuota(
|
|
userId: string,
|
|
feature: QuotaFeature,
|
|
serviceRoleClient: ReturnType<typeof createClient>,
|
|
): Promise<QuotaCheck> {
|
|
// 티어 + overage 조회
|
|
const { data: sub } = await serviceRoleClient
|
|
.from('subscriptions')
|
|
.select('tier, overage_credits')
|
|
.eq('user_id', userId)
|
|
.single()
|
|
|
|
const tier: Tier = (sub?.tier as Tier) ?? 'free'
|
|
const overageCredits = (sub?.overage_credits as number) ?? 0
|
|
const policy = getQuotaPolicy(tier, feature)
|
|
|
|
// 사용불가 (limit=0)
|
|
if (policy.limit === 0) {
|
|
return { allowed: false, current: 0, limit: 0, period: policy.period, tier, overageCredits }
|
|
}
|
|
|
|
// 무제한
|
|
if (policy.limit === -1) {
|
|
return { allowed: true, current: 0, limit: -1, period: policy.period, tier, overageCredits }
|
|
}
|
|
|
|
// 사용량 조회 (daily vs weekly)
|
|
let current: number
|
|
if (policy.period === 'weekly') {
|
|
// 최근 7일 합산
|
|
const weekAgo = new Date()
|
|
weekAgo.setDate(weekAgo.getDate() - 7)
|
|
const { data: rows } = await serviceRoleClient
|
|
.from('daily_usage')
|
|
.select('count')
|
|
.eq('user_id', userId)
|
|
.eq('feature', feature)
|
|
.gte('date', weekAgo.toISOString().slice(0, 10))
|
|
const usageRows = (rows ?? []) as Array<{ count: number | null }>
|
|
current = usageRows.reduce((sum, row) => sum + (row.count ?? 0), 0)
|
|
} else {
|
|
// 오늘만
|
|
const today = new Date().toISOString().slice(0, 10)
|
|
const { data: usage } = await serviceRoleClient
|
|
.from('daily_usage')
|
|
.select('count')
|
|
.eq('user_id', userId)
|
|
.eq('date', today)
|
|
.eq('feature', feature)
|
|
.maybeSingle()
|
|
current = (usage?.count as number) ?? 0
|
|
}
|
|
|
|
return {
|
|
allowed: current < policy.limit || overageCredits > 0,
|
|
current,
|
|
limit: policy.limit,
|
|
period: policy.period,
|
|
tier,
|
|
overageCredits,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 쿼터 소비 — 항상 오늘 날짜의 daily_usage를 +1 증가.
|
|
* (weekly 집계는 checkQuota에서 7일 합산으로 처리)
|
|
* 무제한(-1)이면 카운터만 증가하고 allowed=true.
|
|
* base 소진 + overage 있으면 overage 차감.
|
|
*/
|
|
export async function consumeQuota(
|
|
userId: string,
|
|
feature: QuotaFeature,
|
|
serviceRoleClient: ReturnType<typeof createClient>,
|
|
baseLimit: number,
|
|
): Promise<QuotaConsumeResult> {
|
|
const { data, error } = await serviceRoleClient.rpc('consume_quota', {
|
|
p_user_id: userId,
|
|
p_feature: feature,
|
|
p_base_limit: baseLimit,
|
|
})
|
|
|
|
if (error) {
|
|
throw new Error(`Failed to consume quota: ${error.message}`)
|
|
}
|
|
|
|
const result = data as {
|
|
allowed: boolean
|
|
current: number
|
|
limit: number
|
|
overage_credits: number
|
|
consumed_from: 'base' | 'overage' | 'unlimited' | 'none'
|
|
}
|
|
|
|
return {
|
|
allowed: result.allowed,
|
|
current: result.current,
|
|
limit: result.limit,
|
|
overageCredits: result.overage_credits,
|
|
consumedFrom: result.consumed_from,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 },
|
|
})
|
|
}
|