在昨天的 [Day 17] 中,我們順利架設了 Supabase PostgreSQL + Firebase Auth 多租戶資料庫,並成功對使用者的提煉紀錄進行持久化儲存,同時建立了每月的 Token 額度限制(Usage & Quota Wall)。
當我們的 OmniVibe AI 擁有極致的 UI 體驗、穩健的影音管線與安全的資料存取後,接下來最核心的工程任務就是「商業變現(Monetization)」!
當免費使用者的 50 萬 Tokens 額度用盡,或是想要解鎖更高等級的巨型資產分析(如 2GB 長影片)時,系統必須提供流暢的儲值與訂閱管道。今天我們將引進全球最流行的支付基礎設施 Stripe,實作 Stripe Checkout 託管結帳 與 Stripe Webhook 自動安全充值 管線!
為了確保金流處理的安全與合規(符合 PCI-DSS 標準),我們不會在自己的伺服器上直接處理信用卡號,而是採用 Stripe Hosted Checkout 方案。
整個付款與 Token 額度充值流程如下:
sequenceDiagram
autonumber
actor User as 使用者
participant Client as Next.js Dashboard
participant API as Next.js API Route
participant Stripe as Stripe Payment Gateway
participant Supabase as Supabase DB (user_quotas)
User->>Client: 1. 點擊「升級 Pro 方案 (5,000,000 Tokens)」
Client->>API: 2. POST /api/checkout (攜帶 userId 與 Price ID)
API->>Stripe: 3. stripe.checkout.sessions.create()
Stripe-->>API: 4. 回傳 Checkout Session URL
API-->>Client: 5. 重定向至 Stripe 託管結帳頁面
User->>Stripe: 6. 輸入信用卡資訊並完成付款
Stripe-->>Client: 7. 付款成功,重定向回 /dashboard?payment=success
par 異步安全充值 (Async Webhook)
Stripe->>API: 8. 發送 Webhook (checkout.session.completed)
API->>API: 9. 驗證 Stripe-Signature 簽名防竄改
API->>Supabase: 10. 增加用戶 user_quotas 額度與更新方案狀態
end
src/lib/stripe.ts)首先安裝 Stripe 官方 Node.js 套件:
npm install stripe
在 src/lib/stripe.ts 中建立單例實例(Singleton Instance):
// src/lib/stripe.ts
import Stripe from 'stripe';
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || '', {
apiVersion: '2024-06-20',
typescript: true,
});
在 .env.local 設定對應的環境變數:
STRIPE_SECRET_KEY=sk_test_51...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51...
src/app/api/checkout/route.ts)當使用者點擊「升級」按鈕時,後端會建立一個包含 client_reference_id(綁定 Firebase/Supabase 的 userId)的 Checkout Session:
// src/app/api/checkout/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';
export async function POST(req: NextRequest) {
try {
const { userId, userEmail, priceId } = await req.json();
if (!userId || !priceId) {
return NextResponse.json({ error: '缺少必要的參數 userId 或 priceId' }, { status: 400 });
}
const domain = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000';
// 建立 Stripe 託管結帳 Session
const session = await stripe.checkout.sessions.create({
payment_method_types: ['card'],
line_items: [
{
price: priceId, // Stripe 後台建立的 Price ID
quantity: 1,
},
],
mode: 'subscription', // 或 'payment' (單次儲值)
customer_email: userEmail,
client_reference_id: userId, // 關鍵:將本系統的 userId 附帶至 Stripe Session 中
metadata: {
userId: userId,
planType: 'Pro_Tier',
tokensToGrant: '5000000', // 贈送 500 萬 Tokens
},
success_url: `${domain}/dashboard?payment=success&session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${domain}/dashboard?payment=cancelled`,
});
return NextResponse.json({ url: session.url });
} catch (error: any) {
console.error('[Stripe Checkout Error]:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
src/app/api/webhooks/stripe/route.ts)注意:千萬不能單純依靠前端結帳完成後頁面的重定向來給使用者發放 Token! 使用者可能會隨時關閉頁面,或者遭到惡意偽造請求。唯一的真理來源是伺服器對伺服器的 Stripe Webhook 通知。
我們必須驗證 stripe-signature 標頭以防止黑客冒充 Stripe 發送假結帳成功通知:
// src/app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';
import { supabaseAdmin } from '@/lib/supabase/server';
import Stripe from 'stripe';
export async function POST(req: NextRequest) {
const body = await req.text();
const signature = req.headers.get('stripe-signature') || '';
let event: Stripe.Event;
try {
// 1. 驗證 Webhook 簽名防竄改
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET || ''
);
} catch (err: any) {
console.error(`[Webhook Signature Failed]: ${err.message}`);
return NextResponse.json({ error: `Webhook Error: ${err.message}` }, { status: 400 });
}
// 2. 處理 checkout.session.completed 事件
if (event.type === 'checkout.session.completed') {
const session = event.data.object as Stripe.Checkout.Session;
const userId = session.client_reference_id || session.metadata?.userId;
const tokensToGrant = parseInt(session.metadata?.tokensToGrant || '5000000', 10);
if (userId) {
console.log(`[Stripe Webhook] 收到用戶 ${userId} 付款成功通知,準備充值 ${tokensToGrant} Tokens...`);
// 3. 讀取用戶當前額度並寫入 Supabase DB
const { data: quota } = await supabaseAdmin
.from('user_quotas')
.select('monthly_quota')
.eq('user_id', userId)
.single();
const currentQuota = quota?.monthly_quota || 500000;
const newQuota = currentQuota + tokensToGrant;
const { error } = await supabaseAdmin
.from('user_quotas')
.update({
monthly_quota: newQuota,
})
.eq('user_id', userId);
if (error) {
console.error('[Supabase Top-up Error]:', error);
return NextResponse.json({ error: '資料庫充值失敗' }, { status: 500 });
}
console.log(`[Stripe Webhook] 用戶 ${userId} 額度充值成功!最新總額度: ${newQuota}`);
}
}
return NextResponse.json({ received: true });
}
src/components/dashboard/PricingModal.tsx)現在,當使用者在 Dashboard 發覺額度不足或點擊「升級帳號」時,彈出充滿科技感的方案選擇卡片:
// src/components/dashboard/PricingModal.tsx
'use client';
import React, { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/components/ui/card';
import { Check, Sparkles, Zap } from 'lucide-react';
interface PricingModalProps {
userId: string;
userEmail: string;
}
export function PricingModal({ userId, userEmail }: PricingModalProps) {
const [loading, setLoading] = useState(false);
const handleSubscribe = async (priceId: string) => {
setLoading(true);
try {
const res = await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId, userEmail, priceId }),
});
const data = await res.json();
if (data.url) {
// 重定向至 Stripe 託管結帳頁面
window.location.href = data.url;
} else {
alert('無法啟動結帳程序,請稍後重試');
}
} catch (err) {
console.error(err);
alert('發生預期之外的錯誤');
} finally {
setLoading(false);
}
};
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-4xl mx-auto p-4">
{/* 免費方案 */}
<Card className="bg-slate-900/40 border-slate-800 text-slate-300">
<CardHeader>
<CardTitle className="text-lg text-white">Free 社群體驗版</CardTitle>
<CardDescription>適合體驗 Gemini 1.5 多模態提煉威力</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="text-3xl font-bold text-white">$0 <span className="text-xs font-normal text-slate-500">/ 月</span></div>
<ul className="space-y-2 pt-2">
<li className="flex items-center"><Check className="w-4 h-4 mr-2 text-indigo-400" /> 每月 500,000 Tokens 額度</li>
<li className="flex items-center"><Check className="w-4 h-4 mr-2 text-indigo-400" /> 支援最大 100MB 影音 / PDF 上傳</li>
<li className="flex items-center"><Check className="w-4 h-4 mr-2 text-indigo-400" /> 標準 Gemini Stream 打字機速度</li>
</ul>
</CardContent>
<CardFooter>
<Button variant="outline" disabled className="w-full border-slate-800 text-slate-500">當前方案</Button>
</CardFooter>
</Card>
{/* Pro 創作者訂閱方案 */}
<Card className="bg-slate-900/90 border-indigo-500/50 relative overflow-hidden shadow-2xl shadow-indigo-500/10">
<div className="absolute top-0 right-0 bg-indigo-600 text-white text-[10px] uppercase font-bold px-3 py-1 rounded-bl-lg">
熱門推薦
</div>
<CardHeader>
<CardTitle className="text-lg text-white flex items-center gap-2">
Pro 創作者極速版 <Zap className="w-4 h-4 text-amber-400 fill-amber-400" />
</CardTitle>
<CardDescription>適合專業內容團隊、Podcaster 與影音創作者</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="text-3xl font-bold text-white">$19 <span className="text-xs font-normal text-slate-400">/ 月</span></div>
<ul className="space-y-2 pt-2 text-slate-200">
<li className="flex items-center"><Check className="w-4 h-4 mr-2 text-emerald-400" /> 每月 5,000,000 Tokens 額度</li>
<li className="flex items-center"><Check className="w-4 h-4 mr-2 text-emerald-400" /> 支援最大 2GB 長影音與全集 Podcast</li>
<li className="flex items-center"><Check className="w-4 h-4 mr-2 text-emerald-400" /> 解鎖 Context Caching 上下文快取加速</li>
<li className="flex items-center"><Check className="w-4 h-4 mr-2 text-emerald-400" /> 優先極速 API 推理通道</li>
</ul>
</CardContent>
<CardFooter>
<Button
onClick={() => handleSubscribe('price_1Pxxx_ProPlan_ID')}
disabled={loading}
className="w-full bg-gradient-to-r from-indigo-600 to-violet-600 hover:from-indigo-500 hover:to-violet-500 text-white"
>
{loading ? '轉導至 Stripe 結帳中...' : '立即升級 Pro 方案'}
</Button>
</CardFooter>
</Card>
</div>
);
}
我們使用 Stripe CLI 在開發環境模擬真正的刷卡支付與 Webhook 觸發:
# 1. 登入 Stripe CLI 並監聽本地 Webhook 端點
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# 2. 開啟另一個 Terminal 觸發測試測試結帳成功事件
stripe trigger checkout.session.completed
Ready! Your webhook signing secret is whsec_test_secret_123...
[Stripe Webhook] 收到用戶 usr_omnivibe_99 付款成功通知,準備充值 5000000 Tokens...
[Stripe Webhook] 用戶 usr_omnivibe_99 額度充值成功!最新總額度: 5500000
2026-10-01 23:18:13 [200] POST http://localhost:3000/api/webhooks/stripe
今天我們成功補齊了全棧 AI SaaS 邁向商業化最關鍵的商業變現拼圖:
現在,我們擁有了穩健的技術管道與商業模式。但在實際營運中,AI 的輸出品質往往取決於 Prompt 的精準度。如果 Gemini 1.5 偶爾輸出格式不符的 JSON,或是產生幻想(Hallucination),會直接影響使用者的付費意願。
👉 明天(Day 19),我們將進入【Prompt 護欄工程篇】:實戰 Gemini 1.5 JSON Schema 強制結構化輸出與抗幻覺 Prompt 防護網!看我們如何用代碼確保 AI 輸出 100% 穩定合規!
我們明天見!🔥