1. 多租戶架構概述
企業聊天機器人平台為數百個組織提供服務——每個租戶都有自己的數據、配置、品牌、模型和配額。多租戶是決定因素 單位經濟 的平台。
┌────────── MULTI-TENANT ISOLATION MODEL ──────────────┐
│ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │Tenant A │ │Tenant B │ │Tenant C │ (Customers) │
│ └────┬────┘ └────┬────┘ └────┬────┘ │
│ │ │ │ │
│ ═════╪═══════════╪═══════════╪═══════ API Gateway │
│ │ │ │ (Auth + Route) │
│ │ │ │ │
│ ┌────▼───────────▼───────────▼────┐ │
│ │ SHARED APPLICATION LAYER │ │
│ │ (Same code, different config) │ │
│ └────┬───────────┬───────────┬────┘ │
│ │ │ │ │
│ Level 1: Shared DB + Row-Level Security │
│ Level 2: Separate schemas per tenant │
│ Level 3: Separate databases (enterprise tier) │
│ │
│ ┌────▼───────────▼───────────▼────┐ │
│ │ tenant_a tenant_b tenant_c│ Data Layer │
│ │ ┌──────┐ ┌──────┐ ┌──────┐ │ │
│ │ │ data │ │ data │ │ data │ │ │
│ │ └──────┘ └──────┘ └──────┘ │ │
│ └─────────────────────────────────┘ │
└───────────────────────────────────────────────────────┘
2. 租戶配置系統
interface TenantConfig {
id: string;
name: string;
slug: string;
plan: 'starter' | 'professional' | 'enterprise';
// AI Configuration
ai: {
defaultModel: string;
allowedModels: string[];
temperature: number;
maxTokensPerRequest: number;
systemPromptOverride?: string;
};
// Feature flags
features: {
multiAgent: boolean;
voiceAgent: boolean;
textToSQL: boolean;
customTools: boolean;
analyticsDashboard: boolean;
sso: boolean;
customDomain: boolean;
};
// Guardrails
guardrails: {
piiMasking: boolean;
toxicityFilter: boolean;
jailbreakDetection: boolean;
allowedTopics: string[];
forbiddenTopics: string[];
brandVoice: string;
};
// Branding
branding: {
botName: string;
botAvatar: string;
primaryColor: string;
welcomeMessage: string;
};
// Resource quotas
quotas: {
maxConversationsPerMonth: number;
maxTokensPerMonth: number;
maxDocuments: number;
maxStorageMB: number;
maxConcurrentUsers: number;
maxToolsCustom: number;
};
}
class TenantConfigService {
private cache = new Map<string, { config: TenantConfig; expiresAt: number }>();
async getConfig(tenantId: string): Promise<TenantConfig> {
// Check cache (5 min TTL)
const cached = this.cache.get(tenantId);
if (cached && cached.expiresAt > Date.now()) {
return cached.config;
}
const config = await this.db.tenantConfig.findUnique({ where: { id: tenantId } });
if (!config) throw new TenantNotFoundError(tenantId);
this.cache.set(tenantId, {
config,
expiresAt: Date.now() + 5 * 60 * 1000,
});
return config;
}
async updateConfig(tenantId: string, updates: Partial<TenantConfig>): Promise<void> {
await this.db.tenantConfig.update({
where: { id: tenantId },
data: updates,
});
// Invalidate cache
this.cache.delete(tenantId);
// Notify all instances
await this.redis.publish('tenant:config:updated', JSON.stringify({ tenantId }));
}
}
3. 資料隔離-行級安全
-- PostgreSQL Row-Level Security for multi-tenant
-- Enable RLS on all tables
ALTER TABLE conversations ENABLE ROW LEVEL SECURITY;
ALTER TABLE messages ENABLE ROW LEVEL SECURITY;
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
ALTER TABLE analytics_events ENABLE ROW LEVEL SECURITY;
-- Create policies
CREATE POLICY tenant_isolation_conversations ON conversations
USING (tenant_id = current_setting('app.tenant_id')::uuid);
CREATE POLICY tenant_isolation_messages ON messages
USING (tenant_id = current_setting('app.tenant_id')::uuid);
CREATE POLICY tenant_isolation_documents ON documents
USING (tenant_id = current_setting('app.tenant_id')::uuid);
-- Application sets tenant context per request
-- SET app.tenant_id = 'tenant-uuid-here';
// Middleware to set tenant context
class TenantMiddleware {
async handle(req: Request, res: Response, next: NextFunction): Promise<void> {
const tenantId = this.extractTenantId(req);
if (!tenantId) {
res.status(401).json({ error: 'Missing tenant identification' });
return;
}
// Validate tenant exists and is active
const tenant = await this.tenantService.getConfig(tenantId);
if (!tenant || tenant.status === 'suspended') {
res.status(403).json({ error: 'Tenant suspended or not found' });
return;
}
// Set context for downstream services
req.tenantId = tenantId;
req.tenantConfig = tenant;
// Set RLS context for database queries
await this.db.$executeRaw`SET app.tenant_id = ${tenantId}`;
next();
}
private extractTenantId(req: Request): string | null {
// Strategy 1: From JWT token
if (req.user?.tenantId) return req.user.tenantId;
// Strategy 2: From subdomain (tenant-a.chatbot.example.com)
const subdomain = req.hostname.split('.')[0];
return this.tenantBySubdomain.get(subdomain) ?? null;
// Strategy 3: From API key header
}
}
4. 資源配額與使用計量
class QuotaManager {
async checkQuota(
tenantId: string,
resource: keyof TenantConfig['quotas'],
amount: number = 1,
): Promise<{ allowed: boolean; remaining: number }> {
const config = await this.tenantService.getConfig(tenantId);
const limit = config.quotas[resource];
// Get current usage from Redis (real-time counter)
const currentMonth = new Date().toISOString().slice(0, 7); // YYYY-MM
const key = `quota:${tenantId}:${resource}:${currentMonth}`;
const currentUsage = parseInt(await this.redis.get(key) ?? '0', 10);
const remaining = limit - currentUsage;
const allowed = remaining >= amount;
if (!allowed) {
await this.notifyQuotaExceeded(tenantId, resource, currentUsage, limit);
}
return { allowed, remaining };
}
async recordUsage(
tenantId: string,
resource: keyof TenantConfig['quotas'],
amount: number = 1,
): Promise<void> {
const currentMonth = new Date().toISOString().slice(0, 7);
const key = `quota:${tenantId}:${resource}:${currentMonth}`;
// Atomic increment in Redis
await this.redis.incrby(key, amount);
// Set expiry (end of next month as safety)
await this.redis.expire(key, 62 * 24 * 3600);
// Persist to DB for billing (async)
await this.usageQueue.publish('usage.recorded', {
tenantId,
resource,
amount,
timestamp: new Date(),
});
}
}
class BillingMeter {
async calculateMonthlyBill(tenantId: string, month: string): Promise<Bill> {
const usage = await this.db.usageRecord.aggregate({
where: { tenantId, month },
groupBy: ['resource'],
_sum: { amount: true },
});
const config = await this.tenantService.getConfig(tenantId);
const basePlanPrice = this.getPlanPrice(config.plan);
const overageCharges = usage.map(u => {
const limit = config.quotas[u.resource];
const overage = Math.max(0, u._sum.amount - limit);
return {
resource: u.resource,
used: u._sum.amount,
included: limit,
overage,
charge: overage * this.getOverageRate(u.resource),
};
});
return {
tenantId,
month,
basePlanPrice,
overageCharges,
total: basePlanPrice + overageCharges.reduce((sum, o) => sum + o.charge, 0),
};
}
}
5. 自動化租戶入職
class TenantOnboardingService {
async onboard(input: OnboardingInput): Promise<TenantConfig> {
// 1. Create tenant record
const tenant = await this.db.tenant.create({
data: {
id: crypto.randomUUID(),
name: input.organizationName,
slug: this.slugify(input.organizationName),
plan: input.plan,
status: 'active',
createdAt: new Date(),
},
});
// 2. Create default config based on plan
const config = await this.createDefaultConfig(tenant.id, input.plan);
// 3. Provision resources
await Promise.all([
this.provisionVectorNamespace(tenant.id),
this.provisionRedisNamespace(tenant.id),
this.createDefaultPersona(tenant.id),
this.createAPIKeys(tenant.id),
]);
// 4. Import initial knowledge (if provided)
if (input.initialDocuments?.length) {
await this.knowledgeService.bulkIngest(tenant.id, input.initialDocuments);
}
// 5. Setup default guardrails
await this.guardrailService.applyDefaults(tenant.id, input.industry);
return config;
}
}
第 14 課總結
- 隔離等級:共享資料庫+RLS(入門版)→單獨模式(專業版)→單獨資料庫(企業版)
- 租戶配置:AI 模型、功能、護欄、品牌、配額 — 全部針對每位租戶
- 行級安全性: PostgreSQL RLS + 中介軟體
SET app.tenant_id確保資料隔離 - 配額與計費:Redis原子計數器用於即時限制,非同步持久化到資料庫進行計費
- 入職自動化:一鍵配置 — DB、向量儲存、Redis、角色、護欄
下一篇: 分析和可觀察性——對話分析、法學碩士指標、儀表板、警報、成本追蹤。