iT邦幫忙

2026 iThome 鐵人賽

DAY 18
0
Build on Google AI

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

Day 18 -【商業變現】實戰 Stripe Payment + Credit 訂閱制金流整合:Checkout 結帳與 Webhook 自動充值變現

  • 分享至 

  • xImage
  •  

在昨天的 [Day 17] 中,我們順利架設了 Supabase PostgreSQL + Firebase Auth 多租戶資料庫,並成功對使用者的提煉紀錄進行持久化儲存,同時建立了每月的 Token 額度限制(Usage & Quota Wall)。

當我們的 OmniVibe AI 擁有極致的 UI 體驗、穩健的影音管線與安全的資料存取後,接下來最核心的工程任務就是「商業變現(Monetization)」!

當免費使用者的 50 萬 Tokens 額度用盡,或是想要解鎖更高等級的巨型資產分析(如 2GB 長影片)時,系統必須提供流暢的儲值與訂閱管道。今天我們將引進全球最流行的支付基礎設施 Stripe,實作 Stripe Checkout 託管結帳 與 Stripe Webhook 自動安全充值 管線!


💳 商業金流架構設計 (Stripe + Webhook + Supabase)

為了確保金流處理的安全與合規(符合 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


🛠️ 第一步:安裝與初始化 Stripe SDK (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...


🛒 第二步:建立 Stripe Checkout Session 端點 (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 });
  }
}


⚡ 第三步:實作安全的 Stripe Webhook 監聽器 (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 });
}


🎨 第四步:實作前端 Pricing Modal 訂閱卡片 (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 調試

我們使用 Stripe CLI 在開發環境模擬真正的刷卡支付與 Webhook 觸發:

# 1. 登入 Stripe CLI 並監聽本地 Webhook 端點
stripe listen --forward-to localhost:3000/api/webhooks/stripe

# 2. 開啟另一個 Terminal 觸發測試測試結帳成功事件
stripe trigger checkout.session.completed

後端 Terminal 輸出日誌:

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 邁向商業化最關鍵的商業變現拼圖:

  1. 整合了 Stripe Checkout API,提供安全性符合 PCI-DSS 的託管結帳流程。
  2. 實作了防偽造簽名的 Stripe Webhook 監聽器,確保付款成功後能在毫秒級內自動為用戶充值 Tokens。
  3. 封裝了高質感的 Pricing Card 訂閱 UI,打通了從免費體驗(Freemium)到付費變現(Monetization)的完整閉環!

現在,我們擁有了穩健的技術管道與商業模式。但在實際營運中,AI 的輸出品質往往取決於 Prompt 的精準度。如果 Gemini 1.5 偶爾輸出格式不符的 JSON,或是產生幻想(Hallucination),會直接影響使用者的付費意願。

👉 明天(Day 19),我們將進入【Prompt 護欄工程篇】:實戰 Gemini 1.5 JSON Schema 強制結構化輸出與抗幻覺 Prompt 防護網!看我們如何用代碼確保 AI 輸出 100% 穩定合規!

我們明天見!🔥


上一篇
Day 17 -【資料持久化】實戰 Supabase PostgreSQL + Firebase Auth 多租戶架構:歷史紀錄保存與 Token 額度控管
系列文
用 Google AI 生態系 30 天從零打造一個全棧 AI SaaS 服務 共 18 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言