feat(desktop+server): Phase 3.2 Premium LLM — Anthropic Claude 프리미엄 파이프라인 + 모델별 쿼터 + SaaS UI
빅뱅 8/8 마지막 성공 기준 달성. Supabase Edge Function(llm-proxy)을 통해 Anthropic Claude를 호출하는 PremiumLLMService 신규 구현. 사용자가 Settings에서 Local/Premium 백엔드를 선택하면 VoiceModeService가 자동 분기하고, Premium 실패 시 Local로 silent fallback + 상단 중앙 배너 알림. 실측: Claude Haiku refine 1.6~3.2초 (이전 qwen3 42.9초 → 13~27배 빠름). 주요 변경: - PremiumLLMService 신규 (싱글톤+EventEmitter, processText/chatStream, Supabase functions.invoke 기반, _ensureAuth 가드) - llm-prompts.ts: SYSTEM_PROMPTS를 Local/Premium 공유 모듈로 추출 (resolveSystemPrompt 헬퍼) - VoiceModeService: _getLLMProcessor → _runProcessorWithFallback 라우터 + premium-llm-fallback 이벤트 - CloudSyncService: getAccessToken(async), getAnonKey, invokeFunction(auth 헤더 자동 처리, 에러 body 파싱) - IPC: LLM.PREMIUM_* 채널 6개 + preload API + llm-handlers 이벤트 전달 (safeSendToRenderer 헬퍼) - AppConfig.llmBackend: 'local' | 'premium' (기본 'local') - Settings UI: Backend 드롭다운 + Premium 선택 시 Ollama UI 숨김 + 라이선스 모달 자동 오픈 - AppLayout: 상단 중앙 Snackbar fallback 배너 (8초, warning filled) - LicenseModal: 라이선스 키 입력 제거 → SaaS 구독 관리 UI 전환 (Free/Pro/Pro+ 업그레이드 버튼, Payple 준비 중 스텁) - 등급 비교 표: featureLabel i18n 번역 수정 서버 (Supabase Edge Functions): - quota.ts: 모델별 쿼터 구조 (llm_haiku/sonnet/opus × free/pro/pro_plus), 주간/일간 기간 분리, modelToQuotaKey 매핑, consumeQuota baseLimit 파라미터화 - llm-proxy: 모델별 쿼터 체크 + 소비 (checkQuota → consumeQuota 원자적), verify_jwt=false (2026 sb_publishable_ 키 호환) - config.toml: llm-proxy verify_jwt = false - migration 20260412000001: tier team→pro_plus 통일, subscriptions.overage_credits 컬럼, consume_quota RPC (원자적 base→overage fallback) Tier/쿼터: - free: Haiku 250/주간, Sonnet/Opus 불가 - pro ₩9,900: Haiku 1500/일, Sonnet 300/일, Opus 50/일 - pro_plus ₩29,900: Haiku 무제한, Sonnet 1500/일, Opus 300/일 - api-client SubscriptionTier: team→pro_plus, overage_credits 필드 추가
This commit is contained in:
parent
d397bcbf57
commit
6e52c18e5b
23 changed files with 1111 additions and 311 deletions
|
|
@ -82,7 +82,8 @@ inspector_port = 8083
|
|||
verify_jwt = true
|
||||
|
||||
[functions.llm-proxy]
|
||||
verify_jwt = true
|
||||
# 2026 sb_publishable_ 키와 Gateway JWT 검증 비호환 — requireUser()에서 직접 인증
|
||||
verify_jwt = false
|
||||
|
||||
[functions.stripe-checkout]
|
||||
verify_jwt = true
|
||||
|
|
|
|||
|
|
@ -1,97 +1,182 @@
|
|||
// server/supabase/functions/_shared/quota.ts
|
||||
// 티어별 기능 쿼터 확인 + 증가
|
||||
// Phase 3.2: 모델별 쿼터 + 주간/일간 기간 분리
|
||||
// Free=Haiku 250/주간, Pro=모델별 일간, Pro+=Haiku 무제한
|
||||
|
||||
// @ts-expect-error — Deno 런타임 import
|
||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.39.7'
|
||||
|
||||
export type Tier = 'free' | 'pro' | 'team'
|
||||
export type Feature = 'stt_transcribe' | 'llm_process'
|
||||
export type Tier = 'free' | 'pro' | 'pro_plus'
|
||||
|
||||
/** 일일 쿼터 정책 (-1 = 무제한) */
|
||||
const DAILY_QUOTA: Record<Tier, Record<Feature, number>> = {
|
||||
/** 쿼터 추적 키 — 모델별 분리 */
|
||||
export type QuotaFeature =
|
||||
| 'stt_transcribe'
|
||||
| 'llm_haiku'
|
||||
| 'llm_sonnet'
|
||||
| 'llm_opus'
|
||||
|
||||
export type QuotaPeriod = 'daily' | 'weekly'
|
||||
|
||||
interface ModelQuota {
|
||||
/** -1=무제한, 0=사용불가, 양수=한도 */
|
||||
limit: number
|
||||
period: QuotaPeriod
|
||||
}
|
||||
|
||||
/** 모델별 쿼터 정책 */
|
||||
const MODEL_QUOTA: Record<Tier, Record<QuotaFeature, ModelQuota>> = {
|
||||
free: {
|
||||
stt_transcribe: 50,
|
||||
llm_process: 50
|
||||
stt_transcribe: { limit: 250, period: 'weekly' },
|
||||
llm_haiku: { limit: 250, period: 'weekly' },
|
||||
llm_sonnet: { limit: 0, period: 'daily' }, // 사용불가
|
||||
llm_opus: { limit: 0, period: 'daily' }, // 사용불가
|
||||
},
|
||||
pro: {
|
||||
stt_transcribe: -1,
|
||||
llm_process: -1
|
||||
stt_transcribe: { limit: -1, period: 'daily' },
|
||||
llm_haiku: { limit: 1500, period: 'daily' },
|
||||
llm_sonnet: { limit: 300, period: 'daily' },
|
||||
llm_opus: { limit: 50, period: 'daily' },
|
||||
},
|
||||
team: {
|
||||
stt_transcribe: -1,
|
||||
llm_process: -1
|
||||
}
|
||||
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' },
|
||||
},
|
||||
}
|
||||
|
||||
/** 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'
|
||||
}
|
||||
|
||||
/**
|
||||
* 유저의 오늘 사용량을 확인하고 쿼터 초과 여부를 반환.
|
||||
* 실제 증가는 performQuotaConsume 호출 시 수행.
|
||||
* 쿼터 확인 — 모델별, 기간별(daily/weekly).
|
||||
* weekly인 경우 최근 7일 daily_usage를 합산.
|
||||
*/
|
||||
export async function checkQuota(
|
||||
userId: string,
|
||||
feature: Feature,
|
||||
serviceRoleClient: ReturnType<typeof createClient>
|
||||
feature: QuotaFeature,
|
||||
serviceRoleClient: ReturnType<typeof createClient>,
|
||||
): Promise<QuotaCheck> {
|
||||
// 티어 조회
|
||||
// 티어 + overage 조회
|
||||
const { data: sub } = await serviceRoleClient
|
||||
.from('subscriptions')
|
||||
.select('tier')
|
||||
.select('tier, overage_credits')
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
|
||||
const tier: Tier = (sub?.tier as Tier) ?? 'free'
|
||||
const limit = DAILY_QUOTA[tier][feature]
|
||||
const overageCredits = (sub?.overage_credits as number) ?? 0
|
||||
const policy = getQuotaPolicy(tier, feature)
|
||||
|
||||
if (limit === -1) {
|
||||
return { allowed: true, current: 0, limit, tier }
|
||||
// 사용불가 (limit=0)
|
||||
if (policy.limit === 0) {
|
||||
return { allowed: false, current: 0, limit: 0, period: policy.period, tier, overageCredits }
|
||||
}
|
||||
|
||||
// 오늘 사용량 조회
|
||||
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()
|
||||
// 무제한
|
||||
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))
|
||||
current = rows?.reduce((sum: number, row: { count: number }) => 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
|
||||
}
|
||||
|
||||
const current = (usage?.count as number) ?? 0
|
||||
return {
|
||||
allowed: current < limit,
|
||||
allowed: current < policy.limit || overageCredits > 0,
|
||||
current,
|
||||
limit,
|
||||
tier
|
||||
limit: policy.limit,
|
||||
period: policy.period,
|
||||
tier,
|
||||
overageCredits,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 쿼터 소비. increment_daily_usage 함수 호출 (service_role 전용).
|
||||
* 쿼터 소비 — 항상 오늘 날짜의 daily_usage를 +1 증가.
|
||||
* (weekly 집계는 checkQuota에서 7일 합산으로 처리)
|
||||
* 무제한(-1)이면 카운터만 증가하고 allowed=true.
|
||||
* base 소진 + overage 있으면 overage 차감.
|
||||
*/
|
||||
export async function consumeQuota(
|
||||
userId: string,
|
||||
feature: Feature,
|
||||
feature: QuotaFeature,
|
||||
serviceRoleClient: ReturnType<typeof createClient>,
|
||||
amount: number = 1
|
||||
): Promise<number> {
|
||||
const { data, error } = await serviceRoleClient.rpc('increment_daily_usage', {
|
||||
baseLimit: number,
|
||||
): Promise<QuotaConsumeResult> {
|
||||
const { data, error } = await serviceRoleClient.rpc('consume_quota', {
|
||||
p_user_id: userId,
|
||||
p_feature: feature,
|
||||
p_amount: amount
|
||||
p_base_limit: baseLimit,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Failed to increment quota: ${error.message}`)
|
||||
throw new Error(`Failed to consume quota: ${error.message}`)
|
||||
}
|
||||
|
||||
return (data as number) ?? 0
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -103,6 +188,6 @@ export function createServiceRoleClient(): ReturnType<typeof createClient> {
|
|||
// @ts-expect-error — Deno.env는 Deno 런타임 전역
|
||||
const serviceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
|
||||
return createClient(url, serviceKey, {
|
||||
auth: { persistSession: false, autoRefreshToken: false }
|
||||
auth: { persistSession: false, autoRefreshToken: false },
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,19 @@
|
|||
// server/supabase/functions/llm-proxy/index.ts
|
||||
// Anthropic Claude Messages API 프록시.
|
||||
// Phase 3.2: 모델별 쿼터 (Haiku/Sonnet/Opus × Free/Pro/Pro+)
|
||||
// 요청: application/json { messages, system?, max_tokens?, model? }
|
||||
// 응답: JSON (non-stream) 또는 SSE (stream=true)
|
||||
|
||||
import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts'
|
||||
import { requireUser, authErrorResponse, type AuthError } from '../_shared/auth.ts'
|
||||
import { checkQuota, consumeQuota, createServiceRoleClient, type Tier } from '../_shared/quota.ts'
|
||||
import {
|
||||
checkQuota,
|
||||
consumeQuota,
|
||||
createServiceRoleClient,
|
||||
modelToQuotaKey,
|
||||
getQuotaPolicy,
|
||||
type Tier,
|
||||
} from '../_shared/quota.ts'
|
||||
|
||||
interface LlmRequest {
|
||||
messages: Array<{ role: 'user' | 'assistant'; content: string }>
|
||||
|
|
@ -15,17 +23,17 @@ interface LlmRequest {
|
|||
stream?: boolean
|
||||
}
|
||||
|
||||
/** 티어별 허용 모델 */
|
||||
/** 티어별 허용 모델 — free는 Haiku만, pro는 +Sonnet, pro_plus는 +Opus */
|
||||
const TIER_MODELS: Record<Tier, string[]> = {
|
||||
free: ['claude-haiku-4-5-20251001'],
|
||||
pro: ['claude-haiku-4-5-20251001', 'claude-sonnet-4-6'],
|
||||
team: ['claude-haiku-4-5-20251001', 'claude-sonnet-4-6', 'claude-opus-4-6']
|
||||
pro: ['claude-haiku-4-5-20251001', 'claude-sonnet-4-6', 'claude-opus-4-6'],
|
||||
pro_plus: ['claude-haiku-4-5-20251001', 'claude-sonnet-4-6', 'claude-opus-4-6'],
|
||||
}
|
||||
|
||||
const DEFAULT_MODEL: Record<Tier, string> = {
|
||||
free: 'claude-haiku-4-5-20251001',
|
||||
pro: 'claude-sonnet-4-6',
|
||||
team: 'claude-sonnet-4-6'
|
||||
pro_plus: 'claude-sonnet-4-6',
|
||||
}
|
||||
|
||||
// @ts-expect-error — Deno 런타임 전역
|
||||
|
|
@ -36,45 +44,75 @@ Deno.serve(async (req: Request) => {
|
|||
if (req.method !== 'POST') {
|
||||
return new Response(JSON.stringify({ error: 'Method not allowed' }), {
|
||||
status: 405,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await requireUser(req)
|
||||
const serviceClient = createServiceRoleClient()
|
||||
const quota = await checkQuota(user.id, 'llm_process', serviceClient)
|
||||
if (!quota.allowed) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: 'quota_exceeded',
|
||||
current: quota.current,
|
||||
limit: quota.limit,
|
||||
tier: quota.tier
|
||||
}),
|
||||
{
|
||||
status: 429,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const body = (await req.json()) as LlmRequest
|
||||
|
||||
// 1단계: 티어 조회 (어떤 모델이든 한 번만 읽으면 됨 — haiku로 대리 조회)
|
||||
const tierCheck = await checkQuota(user.id, 'llm_haiku', serviceClient)
|
||||
const tier = tierCheck.tier
|
||||
|
||||
// 모델 선택 + 티어 검증
|
||||
const requestedModel = body.model ?? DEFAULT_MODEL[quota.tier]
|
||||
if (!TIER_MODELS[quota.tier].includes(requestedModel)) {
|
||||
const requestedModel = body.model ?? DEFAULT_MODEL[tier]
|
||||
if (!TIER_MODELS[tier].includes(requestedModel)) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: 'model_not_allowed',
|
||||
tier: quota.tier,
|
||||
tier,
|
||||
requested: requestedModel,
|
||||
allowed: TIER_MODELS[quota.tier]
|
||||
allowed: TIER_MODELS[tier],
|
||||
}),
|
||||
{
|
||||
status: 403,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||
}
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// 2단계: 해당 모델의 쿼터 확인 (모델별 일간/주간)
|
||||
const quotaKey = modelToQuotaKey(requestedModel)
|
||||
const modelQuota = await checkQuota(user.id, quotaKey, serviceClient)
|
||||
if (!modelQuota.allowed) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: 'quota_exceeded',
|
||||
model: requestedModel,
|
||||
current: modelQuota.current,
|
||||
limit: modelQuota.limit,
|
||||
period: modelQuota.period,
|
||||
tier,
|
||||
overage_credits: modelQuota.overageCredits,
|
||||
}),
|
||||
{
|
||||
status: 429,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// 3단계: 쿼터 소비 (원자적 base → overage fallback)
|
||||
const policy = getQuotaPolicy(tier, quotaKey)
|
||||
const consume = await consumeQuota(user.id, quotaKey, serviceClient, policy.limit)
|
||||
if (!consume.allowed) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: 'quota_exceeded',
|
||||
model: requestedModel,
|
||||
current: consume.current,
|
||||
limit: consume.limit,
|
||||
tier,
|
||||
overage_credits: consume.overageCredits,
|
||||
}),
|
||||
{
|
||||
status: 429,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -82,12 +120,9 @@ Deno.serve(async (req: Request) => {
|
|||
// @ts-expect-error — Deno.env
|
||||
const anthropicKey = Deno.env.get('ANTHROPIC_API_KEY') ?? ''
|
||||
|
||||
await consumeQuota(user.id, 'llm_process', serviceClient, 1)
|
||||
|
||||
if (!anthropicKey) {
|
||||
// Placeholder 응답 (키 미설정 시)
|
||||
if (body.stream) {
|
||||
// 스트리밍 placeholder — SSE로 "설정되지 않음" 메시지 전송
|
||||
const encoder = new TextEncoder()
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
|
|
@ -95,13 +130,13 @@ Deno.serve(async (req: Request) => {
|
|||
for (const ch of msg) {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify({ type: 'content_block_delta', delta: { type: 'text_delta', text: ch } })}\n\n`
|
||||
)
|
||||
`data: ${JSON.stringify({ type: 'content_block_delta', delta: { type: 'text_delta', text: ch } })}\n\n`,
|
||||
),
|
||||
)
|
||||
}
|
||||
controller.enqueue(encoder.encode('data: [DONE]\n\n'))
|
||||
controller.close()
|
||||
}
|
||||
},
|
||||
})
|
||||
return new Response(stream, {
|
||||
status: 200,
|
||||
|
|
@ -109,8 +144,8 @@ Deno.serve(async (req: Request) => {
|
|||
...corsHeaders,
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive'
|
||||
}
|
||||
Connection: 'keep-alive',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -122,16 +157,16 @@ Deno.serve(async (req: Request) => {
|
|||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: '[llm-proxy placeholder — ANTHROPIC_API_KEY 미설정]'
|
||||
}
|
||||
text: '[llm-proxy placeholder — ANTHROPIC_API_KEY 미설정]',
|
||||
},
|
||||
],
|
||||
stop_reason: 'end_turn',
|
||||
usage: { input_tokens: 0, output_tokens: 0 }
|
||||
usage: { input_tokens: 0, output_tokens: 0 },
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||
}
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -141,15 +176,15 @@ Deno.serve(async (req: Request) => {
|
|||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': anthropicKey,
|
||||
'anthropic-version': '2023-06-01'
|
||||
'anthropic-version': '2023-06-01',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: requestedModel,
|
||||
max_tokens: body.max_tokens ?? 2048,
|
||||
system: body.system,
|
||||
messages: body.messages,
|
||||
stream: body.stream ?? false
|
||||
})
|
||||
stream: body.stream ?? false,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!anthropicResp.ok) {
|
||||
|
|
@ -158,22 +193,21 @@ Deno.serve(async (req: Request) => {
|
|||
}
|
||||
|
||||
if (body.stream && anthropicResp.body) {
|
||||
// SSE 스트림을 그대로 전달
|
||||
return new Response(anthropicResp.body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
...corsHeaders,
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive'
|
||||
}
|
||||
Connection: 'keep-alive',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const data = await anthropicResp.json()
|
||||
return new Response(JSON.stringify(data), {
|
||||
status: 200,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
} catch (err) {
|
||||
if (err && typeof err === 'object' && 'status' in err && 'message' in err) {
|
||||
|
|
@ -182,7 +216,7 @@ Deno.serve(async (req: Request) => {
|
|||
const message = err instanceof Error ? err.message : 'Unknown error'
|
||||
return new Response(JSON.stringify({ error: message }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ Deno.serve(async (req: Request) => {
|
|||
}
|
||||
|
||||
// 5) 쿼터 소비
|
||||
await consumeQuota(user.id, 'stt_transcribe', serviceClient, 1)
|
||||
await consumeQuota(user.id, 'stt_transcribe', serviceClient, quota.limit)
|
||||
|
||||
return new Response(JSON.stringify(placeholder), {
|
||||
status: 200,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,150 @@
|
|||
-- ============================================================================
|
||||
-- Phase 3.2: Tier 통일 (team → pro_plus) + 오버리지 크레딧 데이터 모델
|
||||
-- ============================================================================
|
||||
-- 데스크톱 코드가 이미 'pro_plus'를 사용 중이고 서버만 'team'이 남아있어
|
||||
-- 발생한 불일치를 해소. 팀 협업 feature(teams/team_members 테이블)와
|
||||
-- 가격제(tier) 개념은 분리 — 이 마이그레이션은 가격제만 건드린다.
|
||||
--
|
||||
-- 추가로 SaaS 오버리지 구매 모델을 위한 subscriptions.overage_credits 컬럼
|
||||
-- 도입. 실제 Stripe 연결은 Phase 3.3 이월, 이번 마이그레이션은 데이터 모델과
|
||||
-- 읽기 경로만 준비.
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- 1. profiles.tier: 기존 CHECK 제약 제거 → 값 마이그레이션 → 새 CHECK
|
||||
-- ----------------------------------------------------------------------------
|
||||
ALTER TABLE public.profiles DROP CONSTRAINT IF EXISTS profiles_tier_check;
|
||||
|
||||
UPDATE public.profiles SET tier = 'pro_plus' WHERE tier = 'team';
|
||||
|
||||
ALTER TABLE public.profiles
|
||||
ADD CONSTRAINT profiles_tier_check
|
||||
CHECK (tier IN ('free', 'pro', 'pro_plus'));
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- 2. subscriptions.tier: 동일 처리
|
||||
-- ----------------------------------------------------------------------------
|
||||
ALTER TABLE public.subscriptions DROP CONSTRAINT IF EXISTS subscriptions_tier_check;
|
||||
|
||||
UPDATE public.subscriptions SET tier = 'pro_plus' WHERE tier = 'team';
|
||||
|
||||
ALTER TABLE public.subscriptions
|
||||
ADD CONSTRAINT subscriptions_tier_check
|
||||
CHECK (tier IN ('free', 'pro', 'pro_plus'));
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- 3. subscriptions.overage_credits: SaaS 오버리지 구매 모델
|
||||
-- ----------------------------------------------------------------------------
|
||||
ALTER TABLE public.subscriptions
|
||||
ADD COLUMN IF NOT EXISTS overage_credits integer NOT NULL DEFAULT 0;
|
||||
|
||||
COMMENT ON COLUMN public.subscriptions.overage_credits IS
|
||||
'추가 크레딧 (베이스 일일 쿼터 소진 시 차감). Phase 3.2는 데이터 모델만, 실제 Stripe 구매 경로는 Phase 3.3.';
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- 4. consume_quota RPC: 원자적 base → overage fallback 소비
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- 기존 increment_daily_usage는 유지 (다른 경로에서 쓰일 수 있음).
|
||||
-- llm-proxy는 이 새 RPC를 사용해 원자적으로 base → overage 순차 소비.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.consume_quota(
|
||||
p_user_id uuid,
|
||||
p_feature text,
|
||||
p_base_limit integer
|
||||
) RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_current integer;
|
||||
v_overage integer;
|
||||
v_new_count integer;
|
||||
v_new_overage integer;
|
||||
BEGIN
|
||||
-- 오늘 사용량 조회 (없으면 0)
|
||||
SELECT count INTO v_current
|
||||
FROM public.daily_usage
|
||||
WHERE user_id = p_user_id
|
||||
AND feature = p_feature
|
||||
AND date = CURRENT_DATE;
|
||||
v_current := COALESCE(v_current, 0);
|
||||
|
||||
-- 오버리지 크레딧 조회 (없으면 0)
|
||||
SELECT overage_credits INTO v_overage
|
||||
FROM public.subscriptions
|
||||
WHERE user_id = p_user_id;
|
||||
v_overage := COALESCE(v_overage, 0);
|
||||
|
||||
-- 무제한(-1): 그냥 카운터만 증가
|
||||
IF p_base_limit = -1 THEN
|
||||
INSERT INTO public.daily_usage (user_id, date, feature, count)
|
||||
VALUES (p_user_id, CURRENT_DATE, p_feature, 1)
|
||||
ON CONFLICT (user_id, date, feature) DO UPDATE
|
||||
SET count = public.daily_usage.count + 1
|
||||
RETURNING count INTO v_new_count;
|
||||
RETURN jsonb_build_object(
|
||||
'allowed', true,
|
||||
'current', v_new_count,
|
||||
'limit', -1,
|
||||
'overage_credits', v_overage,
|
||||
'consumed_from', 'unlimited'
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- base 잔여 여부 체크
|
||||
IF v_current < p_base_limit THEN
|
||||
-- base 소비
|
||||
INSERT INTO public.daily_usage (user_id, date, feature, count)
|
||||
VALUES (p_user_id, CURRENT_DATE, p_feature, 1)
|
||||
ON CONFLICT (user_id, date, feature) DO UPDATE
|
||||
SET count = public.daily_usage.count + 1
|
||||
RETURNING count INTO v_new_count;
|
||||
RETURN jsonb_build_object(
|
||||
'allowed', true,
|
||||
'current', v_new_count,
|
||||
'limit', p_base_limit,
|
||||
'overage_credits', v_overage,
|
||||
'consumed_from', 'base'
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- base 소진 → 오버리지 체크
|
||||
IF v_overage <= 0 THEN
|
||||
RETURN jsonb_build_object(
|
||||
'allowed', false,
|
||||
'current', v_current,
|
||||
'limit', p_base_limit,
|
||||
'overage_credits', 0,
|
||||
'consumed_from', 'none'
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- 오버리지 소비 (daily_usage 증가 + overage_credits 감소)
|
||||
UPDATE public.subscriptions
|
||||
SET overage_credits = overage_credits - 1,
|
||||
updated_at = now()
|
||||
WHERE user_id = p_user_id
|
||||
RETURNING overage_credits INTO v_new_overage;
|
||||
|
||||
INSERT INTO public.daily_usage (user_id, date, feature, count)
|
||||
VALUES (p_user_id, CURRENT_DATE, p_feature, 1)
|
||||
ON CONFLICT (user_id, date, feature) DO UPDATE
|
||||
SET count = public.daily_usage.count + 1
|
||||
RETURNING count INTO v_new_count;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'allowed', true,
|
||||
'current', v_new_count,
|
||||
'limit', p_base_limit,
|
||||
'overage_credits', v_new_overage,
|
||||
'consumed_from', 'overage'
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION public.consume_quota(uuid, text, integer) IS
|
||||
'원자적 쿼터 소비: base 먼저 → 소진 시 overage. Phase 3.2 llm-proxy 전용.';
|
||||
|
||||
-- RPC는 service_role만 호출 가능
|
||||
REVOKE ALL ON FUNCTION public.consume_quota(uuid, text, integer) FROM public;
|
||||
GRANT EXECUTE ON FUNCTION public.consume_quota(uuid, text, integer) TO service_role;
|
||||
Loading…
Add table
Add a link
Reference in a new issue