SaaS
SaaS 的最新技術 — 多租戶、用量計費、產品驅動成長 (PLG)、AI 功能整合、現代可觀測性、合規性與安全性,專為生產級「軟體即服務 (SaaS)」產品而設計。
指示
多租戶
為你的使用情境選擇合適的隔離模型:
| 模型 | 描述 | 使用時機 |
|---|---|---|
| 共享 schema | 所有租戶共用資料表,透過 tenant_id 欄位區分資料列 | 早期階段、注重成本、法規要求單純 |
| 每個租戶獨立 schema | 每個租戶擁有獨立的資料庫 schema | 中等隔離需求、便於每個租戶的遷移 |
| 每個租戶獨立資料庫 | 每個租戶擁有獨立的資料庫 | 企業級、嚴格的資料駐留要求、高合規性 (SOC 2, HIPAA) |
| 混合模式 | 核心資料共享,敏感資料隔離 | 受監管行業,提供混合服務層級 |
共享 schema 租戶的規則:
- 每個查詢必須根據
tenant_id進行過濾 — 應透過基礎儲存庫 (Repository) 或 ORM 中介層強制執行,而非依賴開發人員的自律性 - 使用 PostgreSQL 資料列層級安全 (RLS) 作為第二層防禦:
ALTER TABLE orders ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation ON orders USING (tenant_id = current_setting('app.current_tenant')::uuid); - 在每個租戶範圍的資料表上為
tenant_id建立索引 - 撰寫整合測試來驗證跨租戶資料隔離
- 所有物件儲存路徑都應有範圍限制:
s3://bucket/{tenantId}/uploads/{fileId} - 絕不暴露可預測的循序 ID — 必須使用 UUID(v7 以利排序)
訂閱與計費
固定費率訂閱
- 將計費業務委託給供應商 (Stripe, Paddle, LemonSqueezy) — 永遠不要自行實作支付處理
- 資料庫中只儲存
customerId和subscriptionId;絕不儲存信用卡資料 - 透過 Webhook 同步狀態 — 將你的資料庫視為計費供應商最終狀態的「最終一致性唯讀副本」:
customer.subscription.created → 啟用方案 customer.subscription.updated → 升級 / 降級 / 席位變更 customer.subscription.deleted → 取消 + 開始寬限期 invoice.payment_failed → 催款流程:通知 → 限制 → 取消 - 使用 webhook 事件的
id作為冪等鍵 — 在處理前先儲存;若已處理則跳過 - 儲存原始 webhook 負載以供稽核與重播
用量計費(現代預設方案)
- 即時追蹤用量事件(API 呼叫、席位、消耗的 Token、儲存 GB):
await stripe.subscriptionItems.createUsageRecord(subscriptionItemId, { quantity: tokensUsed, timestamp: Math.floor(Date.now() / 1000), action: 'increment', }); - 向客戶提供即時用量儀表板 — 帳單日出現意外會導致客戶流失
- 在每個計費週期內實作「軟限制」(用量達 80% 時警告)和「硬限制」(用量達 100% 時封鎖)
- 考慮將「免費層級 + 用量計費」作為產品驅動成長 (PLG) 的入門方案
產品驅動成長 (PLG)
- 免費層級 / 免費增值 — 讓使用者先體驗價值,再要求付費;設定限制,讓付費方案顯得物超所值
- 價值實現時間 (TTV) — 衡量並最小化從註冊到首次有意義操作(頓悟時刻)的時間
- 應用程式內升級提示 — 在用戶觸及方案限制的當下,於情境中顯示升級呼籲,而非放在一般的設定頁面
- 產品合格線索 (PQL) — 根據產品使用行為(而非僅是 Demo 請求)為用戶評分,以轉導給銷售團隊
- 病毒式 / 協作功能 — 邀請團隊成員、分享報告、公開頁面 — 每個動作都是一個散佈管道
AI 功能整合
現代 SaaS 產品被期望具備 AI 原生能力。請應用以下模式:
AI 助手與 Copilot:
- 使用串流回應 (SSE /
ReadableStream),讓用戶即時看到 Token 出現 — 絕不讓他們等待完整回應 - 務必讓 AI 輸出內容可編輯 — 用戶需要能夠修正、改寫並擁有最終成果
- 顯示信心指標並引用來源 (RAG),以建立信任
檢索增強生成 (RAG):
- 將每個租戶的向量嵌入儲存在隔離的名稱空間中 (Pinecone, pgvector, Qdrant) — 絕不混合不同租戶的資料
- 在寫入時嵌入內容,在讀取時使用餘弦相似度進行查詢
- 智慧地切割文件(512–1024 個 token,並有重疊);當來源內容變更時重新嵌入
用量與成本控制:
- 追蹤每個租戶的 LLM Token 消耗 — 據此計費或限制速率
- 快取確定性提示(相同輸入 → 相同輸出)以降低成本
- 使用較便宜的模型進行分類/分流處理;將頂尖模型保留給生成任務
資料隱私:
- 提供資料處理附錄 (DPA),明確說明租戶資料是否用於模型訓練
- 提供退出(Opt-out)選項;企業客戶會要求此功能
功能開關與方案門控
- 在服務層進行功能門控 — 客戶端隱藏僅是 UI 美化,而非安全機制
- 將權限邏輯集中至單一的
can(tenant, feature)輔助函式:function can(tenant: Tenant, feature: Feature): boolean { return tenant.plan.features.includes(feature); } - 使用旗標系統 (LaunchDarkly, Unleash, GrowthBook, 或簡單的 DB-backed 資料表),將功能的部署與發布解耦
- 旗標可用於:漸進式推出(% 的租戶)、Beta 計劃、A/B 測試、緊急關閉開關
入門引導
- 最小化 TTV:引導用戶在 < 5 分鐘內完成首次有意義操作
- 使用清單驅動的引導流程,搭配清晰的進度指示
- 預先填充範例 / 示範資料,讓產品初次使用時感覺不空虛
- 前端僅收集最少必要資料;其餘事項延後處理
- 根據產品事件(而非僅是時間)觸發引導電子郵件:註冊時發送歡迎信,若 24 小時後用戶未完成第二步則發送提醒,達成第一個里程碑時慶祝
- 提供互動式產品導覽(而非影片),讓用戶邊做邊學
可觀測性與營運
結構化日誌 — 每行日誌必須包含:
{ "level": "info", "msg": "...", "tenantId": "...", "userId": "...", "requestId": "...", "timestamp": "..." }
使用 OpenTelemetry 進行分散式追蹤:
- 在框架層級進行插樁 (Next.js
instrumentation.ts, Express 中介層) - 在服務邊界傳遞追蹤上下文 (
traceparentheader) - 匯出到你選擇的後端 (Jaeger, Honeycomb, Datadog, Grafana Tempo)
需要追蹤的每個租戶指標:
- 活躍用戶 (DAU/MAU)、API 呼叫量、錯誤率、p50/p95/p99 延遲、功能採用率、已消耗儲存空間
警報:
- 針對租戶級別異常發出警報 — 突然的錯誤峰值、用量下降、計費失敗
- 為每個服務層級定義 SLO:免費層級(盡力而為)、專業版 (99.5%)、企業版 (99.9%)
管理工具(內部用):
- 模擬某個租戶身份以進行支援(每次模擬都需稽核)
- 檢視稽核日誌、管理訂閱、覆蓋功能開關、觸發重新引導
- 建立健康狀態儀表板:每個租戶的狀態、近期錯誤、用量趨勢
資料、分析與合規性
事件追蹤:
- 將結構化產品事件 (
user.signed_up,feature.used,subscription.upgraded) 發送到資料倉儲 (Snowflake, BigQuery) 或客戶資料平台 (Segment, PostHog, RudderStack) - 使用這些事件進行流失預測、同期群分析以及 PLG 評分
GDPR / 隱私:
- 提供資料匯出端點和帳戶刪除端點 — 這是 GDPR 的要求,也是企業採購者的期望
- 記錄你的資料保留政策;實作自動清除機制
- 對行銷 Cookie 和分析工具使用同意管理
SOC 2 / 合規衛生(企業級準備):
- 維護所有變更狀態的使用者動作的稽核日誌(誰、做了什麼、何時、從哪個 IP)
- 實作基於角色的存取控制 (RBAC),預設採用最小權限原則
- 對所有管理員帳戶強制使用 MFA;為企業租戶提供 SSO (SAML/OIDC)
- 定期進行依賴項漏洞掃描與滲透測試
- 維護一個漏洞揭露政策
資料隔離與安全
- 在每次 API 呼叫時,根據已認證的工作階段驗證
tenant_id— 永遠不要信任請求體的內容 - 對每個租戶進行速率限制,以防止吵鄰效應
- 資料庫中 API 金鑰應進行雜湊處理 (bcrypt 或 Argon2);絕不儲存純文字
- 要求使用 HTTPS;設定
Strict-Transport-Security,並使用較長的max-age - 工作階段 Cookie 應使用
HttpOnly; Secure; SameSite=Lax屬性
範例
// 具有 RLS 上下文的租戶範圍儲存庫
class TenantRepository<T> {
constructor(
private readonly tenantId: string,
private readonly db: PrismaClient,
) {}
protected async withTenantContext<R>(fn: () => Promise<R>): Promise<R> {
return this.db.$transaction(async (tx) => {
await tx.$executeRaw`SELECT set_config('app.current_tenant', ${this.tenantId}, true)`;
return fn();
});
}
async findById(id: string): Promise<T | null> {
return this.withTenantContext(() =>
(this.db as any).findFirst({ where: { id, tenantId: this.tenantId } })
);
}
}
// 具有每個租戶用量計量的 AI 串流端點
export async function POST(req: Request) {
const session = await getSession(req);
const { prompt } = await req.json();
const stream = await openai.chat.completions.create({
model: 'gpt-4o',
messages: [{ role: 'user', content: prompt }],
stream: true,
});
let tokensUsed = 0;
const readable = new ReadableStream({
async start(controller) {
for await (const chunk of stream) {
const text = chunk.choices[0]?.delta?.content ?? '';
controller.enqueue(new TextEncoder().encode(text));
tokensUsed += chunk.usage?.completion_tokens ?? 0;
}
controller.close();
// 串流完成後記錄用量
await meteringService.record(session.tenantId, 'ai_tokens', tokensUsed);
},
});
return new Response(readable, { headers: { 'Content-Type': 'text/plain; charset=utf-8' } });
}
// 具有冪等性的 Stripe Webhook
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
const event = stripe.webhooks.constructEvent(
req.body, req.headers['stripe-signature']!, process.env.STRIPE_WEBHOOK_SECRET!
);
const seen = await db.webhookEvent.findUnique({ where: { stripeEventId: event.id } });
if (seen) return res.sendStatus(200);
await db.webhookEvent.create({ data: { stripeEventId: event.id, payload: event } });
switch (event.type) {
case 'customer.subscription.updated':
await subscriptionService.sync(event.data.object);
break;
case 'customer.subscription.deleted':
await subscriptionService.cancel(event.data.object.metadata.tenantId);
break;
}
res.sendStatus(200);
});