iT邦幫忙

2026 iThome 鐵人賽

DAY 17
0
Build on Google AI

用 Google AI 生態系 30 天從零打造一個全棧 AI SaaS 服務系列 第 17 篇

Day 17 -【資料持久化】實戰 Supabase PostgreSQL + Firebase Auth 多租戶架構:歷史紀錄保存與 Token 額度控管

  • 分享至 

  • xImage
  •  

在昨天的 [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 建立生產級的資料持久化層,實作歷史紀錄存取與額度扣減機制!


🏛️ 多租戶資料庫架構設計 (Entity-Relationship)

為了滿足多租戶隔離與 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
    }


🛠️ 第一步:資料庫 Schema 與 Supabase 用戶端設定

我們在 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);


🔐 第二步: Token 額度檢查與扣減服務 (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);
}


⚡ 第三步:將持久化與額度控制整合至 Gemini SSE 串流 API

我們升級 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>
  );
}


🧪 實測驗證:多租戶數據隔離與 Token 精準扣減

上線測試時,我們以測試帳號進行 30 分鐘 Podcast 影音提煉:

  1. Quota 檢查:系統確認帳號剩餘 500,000 Tokens。
  2. 串流提煉:Gemini 1.5 噴發 1,800 字的提煉內容。
  3. 資料寫入與扣減:
  • distillation_results 成功新增一筆歷史紀錄,標記 tokens_consumed: 12,450。
  • user_quotas 的 used_tokens 自動累加 12,450,控制台即時顯示剩餘 487,550 Tokens。

🎯 總結與明日預告

今天我們正式將 OmniVibe AI 從「單機 Prototype」進化為「生產級多租戶 SaaS」:

  1. 設計了完整的 PostgreSQL Multi-Tenant Entity Schema。
  2. 實作了開箱即用的 Token Usage 監控與 Quota 額度牆(Paywall Prerequisite)。
  3. 整合了 Supabase 歷史紀錄存取 API,讓用戶的所有資產提煉成果都能安全、永久且跨裝置地保存。

有了完美的歷史紀錄與 Token 額度控管後,當免費用戶的 50 萬 Tokens 用盡時,我們該如何實現流量變現?

👉 明天(Day 18),我們將進入【商業變現篇】:實戰 Stripe Payment + Credit 訂閱制金流整合!看我們如何用代碼實現 Stripe Checkout、 Webhooks 自動充值與 Pro 方案會員變現!

我們明天見!🔥


上一篇
Day 16 -【流式傳輸】實戰 Server-Sent Events 與 Gemini Stream:打造秒級回應、逐字噴發的絲滑 AI 互動體驗
下一篇
Day 18 -【商業變現】實戰 Stripe Payment + Credit 訂閱制金流整合:Checkout 結帳與 Webhook 自動充值變現
系列文
用 Google AI 生態系 30 天從零打造一個全棧 AI SaaS 服務 共 18 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言