在昨天的 [Day 16] 中,我們成功完成了秒級回應的 Gemini Stream 與 Server-Sent Events (SSE) 逐字打字機串流,帶給使用者極致流暢的 AI 互動體驗。
然而,至今為止我們的 OmniVibe AI 依然是個「無狀態(Stateless)」的應用。一旦使用者重新整理網頁,剛剛辛苦提煉出的影音洞見、Threads 爆款文案與短影音腳本就會瞬間化為烏有。此外,作為一個商業級 SaaS,我們必須具備多租戶資料隔離(Multi-Tenant Isolation)與使用者 Token / 使用額度控管(Usage & Quota Management)機制,防止 API 被無節制濫用造成帳單暴增。
今天,我們將引進 Supabase (PostgreSQL) 結合 Firebase Auth,為 OmniVibe AI 建立生產級的資料持久化層,實作歷史紀錄存取與額度扣減機制!
為了滿足多租戶隔離與 Token 額度控管的需求,我們設計了三張核心資料表:
erDiagram
USERS ||--o{ ASSETS : "擁有"
USERS ||--o1 USER_QUOTAS : "綁定"
ASSETS ||--o{ DISTILLATION_RESULTS : "產出"
USERS {
string id PK "Firebase UID / Supabase Auth ID"
string email
string display_name
timestamp created_at
}
USER_QUOTAS {
string user_id PK, FK "外鍵關聯 Users"
int monthly_quota "每月免費 / 訂閱 Token 總額"
int used_tokens "已消耗 Token 數量"
timestamp reset_at "下次額度重置時間"
}
ASSETS {
uuid id PK
string user_id FK "擁有者 User ID"
string file_name "原檔案名稱"
string file_uri "Google AI File API URI"
string mime_type "影音 / PDF 格式"
bigint size_bytes "檔案大小"
timestamp created_at
}
DISTILLATION_RESULTS {
uuid id PK
uuid asset_id FK "關聯資產 ID"
string user_id FK "擁有者 User ID"
jsonb takeaways "核心洞見陣列"
string social_posts "社群貼文"
string script "短影音腳本"
int tokens_consumed "此單次提煉消耗之 Token"
timestamp created_at
}
我們在 Supabase PostgreSQL 中執行以下 SQL 建表指令,並開啟 Row Level Security (RLS) 確保資料租戶隔離:
-- 1. 建立用戶額度表
CREATE TABLE user_quotas (
user_id TEXT PRIMARY KEY,
monthly_quota INT DEFAULT 500000, -- 免費層每月 50 萬 Tokens
used_tokens INT DEFAULT 0,
reset_at TIMESTAMPTZ DEFAULT (NOW() + INTERVAL '1 month')
);
-- 2. 建立媒體資產表
CREATE TABLE assets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id TEXT NOT NULL,
file_name TEXT NOT NULL,
file_uri TEXT NOT NULL,
mime_type TEXT NOT NULL,
size_bytes BIGINT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- 3. 建立提煉結果紀錄表
CREATE TABLE distillation_results (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
asset_id UUID REFERENCES assets(id) ON DELETE CASCADE,
user_id TEXT NOT NULL,
takeaways JSONB,
social_posts TEXT,
script TEXT,
tokens_consumed INT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- 4. 開啟 RLS 資料安全隔離
ALTER TABLE assets ENABLE ROW LEVEL SECURITY;
ALTER TABLE distillation_results ENABLE ROW LEVEL SECURITY;
接著在專案中封裝 Supabase Server Client (src/lib/supabase/server.ts):
// src/lib/supabase/server.ts
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || '';
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY || '';
// 使用 Service Role Key 在 Server Actions / Route Handlers 中進行安全存取
export const supabaseAdmin = createClient(supabaseUrl, supabaseServiceKey);
src/lib/services/quota.ts)在調用 Gemini API 前,我們必須驗證使用者是否有足夠的餘額;並在生成結束後,根據 Gemini 回傳的 usageMetadata 進行精準的 Token 扣減:
// src/lib/services/quota.ts
import { supabaseAdmin } from '@/lib/supabase/server';
export interface QuotaCheckResult {
hasQuota: boolean;
remainingTokens: number;
message?: string;
}
/**
* 檢查使用者是否還有足夠的 Token 額度
*/
export async function checkUserQuota(userId: string): Promise<QuotaCheckResult> {
const { data, error } = await supabaseAdmin
.from('user_quotas')
.select('monthly_quota, used_tokens')
.eq('user_id', userId)
.single();
// 若使用者無紀錄,則自動初始化預設免費額度
if (error || !data) {
await supabaseAdmin.from('user_quotas').insert({
user_id: userId,
monthly_quota: 500000,
used_tokens: 0,
});
return { hasQuota: true, remainingTokens: 500000 };
}
const remaining = data.monthly_quota - data.used_tokens;
if (remaining <= 0) {
return {
hasQuota: false,
remainingTokens: 0,
message: '您的本月免費 Token 額度已用盡,請升級 Pro 方案以繼續使用。',
};
}
return { hasQuota: true, remainingTokens: remaining };
}
/**
* 扣減使用者已消耗的 Token 數量
*/
export async function deductUserQuota(userId: string, tokensUsed: number) {
const { data } = await supabaseAdmin
.from('user_quotas')
.select('used_tokens')
.eq('user_id', userId)
.single();
const currentUsed = data?.used_tokens || 0;
await supabaseAdmin
.from('user_quotas')
.update({ used_tokens: currentUsed + tokensUsed })
.eq('user_id', userId);
}
我們升級 Day 16 的 src/app/api/ai/stream-distill/route.ts,加入額度審查、歷史紀錄寫入與 Token 扣減:
// src/app/api/ai/stream-distill/route.ts
import { NextRequest } from 'next/server';
import { GoogleGenerativeAI } from '@google/generative-ai';
import { checkUserQuota, deductUserQuota } from '@/lib/services/quota';
import { supabaseAdmin } from '@/lib/supabase/server';
const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY || '');
export async function POST(req: NextRequest) {
try {
const { userId, assetId, fileUri, mimeType, prompt } = await req.json();
if (!userId || !fileUri) {
return new Response(JSON.stringify({ error: '缺少 userId 或 fileUri' }), { status: 400 });
}
// 1. 前置檢核:檢查使用者 Token 額度
const quotaStatus = await checkUserQuota(userId);
if (!quotaStatus.hasQuota) {
return new Response(JSON.stringify({ error: quotaStatus.message }), { status: 403 });
}
const model = genAI.getGenerativeModel({ model: 'gemini-1.5-flash' });
// 2. 發起 Gemini 串流生成
const streamingResult = await model.generateContentStream([
{ fileData: { fileUri, mimeType } },
prompt || '請將此資產提煉為核心洞見、爆款 Threads 貼文與 60 秒短影音腳本。',
]);
const encoder = new TextEncoder();
let completeText = '';
const stream = new ReadableStream({
async start(controller) {
for await (const chunk of streamingResult.stream) {
const text = chunk.text();
if (text) {
completeText += text;
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ text })}\n\n`));
}
}
// 3. 串流完成後,取得實際 Token 消耗 metadata
const response = await streamingResult.response;
const totalTokens = response.usageMetadata?.totalTokenCount || 0;
// 4. 異步保存提煉結果至 Supabase PostgreSQL
await supabaseAdmin.from('distillation_results').insert({
asset_id: assetId || null,
user_id: userId,
social_posts: completeText,
tokens_consumed: totalTokens,
});
// 5. 扣減使用者額度
await deductUserQuota(userId, totalTokens);
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ done: true, totalTokens })}\n\n`));
controller.close();
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
});
} catch (error: any) {
return new Response(JSON.stringify({ error: error.message }), { status: 500 });
}
}
src/components/dashboard/HistoryList.tsx)現在,使用者隨時可以在控制台瀏覽過去提煉過的所有資產與成果:
// src/components/dashboard/HistoryList.tsx
'use client';
import React, { useEffect, useState } from 'react';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Clock, FileText, Sparkles, ChevronRight } from 'lucide-react';
interface HistoryItem {
id: string;
created_at: string;
tokens_consumed: number;
social_posts: string;
}
export function HistoryList({ userId }: { userId: string }) {
const [history, setHistory] = useState<HistoryItem[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchHistory() {
const res = await fetch(`/api/user/history?userId=${userId}`);
const data = await res.json();
if (data.success) {
setHistory(data.data);
}
setLoading(false);
}
if (userId) fetchHistory();
}, [userId]);
if (loading) {
return <div className="text-sm text-slate-500 animate-pulse">載入專案歷史紀錄中...</div>;
}
return (
<div className="space-y-4">
<h3 className="text-base font-semibold text-slate-200 flex items-center gap-2">
<Clock className="w-4 h-4 text-indigo-400" /> 歷史提煉紀錄
</h3>
{history.length === 0 ? (
<p className="text-sm text-slate-500">尚無歷史紀錄,上傳您的第一個影音檔案開始提煉吧!</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{history.map((item) => (
<Card key={item.id} className="bg-slate-900/60 border-slate-800 hover:border-slate-700 transition-all cursor-pointer">
<CardHeader className="p-4 pb-2">
<CardTitle className="text-sm font-medium text-slate-300 flex justify-between items-center">
<span className="flex items-center gap-1.5">
<FileText className="w-4 h-4 text-indigo-400" /> 歷史專案
</span>
<span className="text-xs text-slate-500">
{new Date(item.created_at).toLocaleDateString()}
</span>
</CardTitle>
</CardHeader>
<CardContent className="p-4 pt-0">
<p className="text-xs text-slate-400 line-clamp-2 mt-1">
{item.social_posts || '全量提煉結果數據...'}
</p>
<div className="mt-3 flex items-center justify-between text-[11px] text-slate-500 border-t border-slate-800/60 pt-2">
<span className="flex items-center gap-1">
<Sparkles className="w-3 h-3 text-amber-400" /> {item.tokens_consumed} Tokens
</span>
<span className="text-indigo-400 flex items-center hover:underline">
檢視內容 <ChevronRight className="w-3 h-3 ml-0.5" />
</span>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}
上線測試時,我們以測試帳號進行 30 分鐘 Podcast 影音提煉:
500,000 Tokens。distillation_results 成功新增一筆歷史紀錄,標記 tokens_consumed: 12,450。user_quotas 的 used_tokens 自動累加 12,450,控制台即時顯示剩餘 487,550 Tokens。今天我們正式將 OmniVibe AI 從「單機 Prototype」進化為「生產級多租戶 SaaS」:
有了完美的歷史紀錄與 Token 額度控管後,當免費用戶的 50 萬 Tokens 用盡時,我們該如何實現流量變現?
👉 明天(Day 18),我們將進入【商業變現篇】:實戰 Stripe Payment + Credit 訂閱制金流整合!看我們如何用代碼實現 Stripe Checkout、 Webhooks 自動充值與 Pro 方案會員變現!
我們明天見!🔥