24.02-租户模型

要点

  • 租户是 AI SaaS 的核心概念,每个租户是一个独立的组织/公司
  • 租户模型需要支持:注册、配置、隔离、计费
  • 数据隔离从共享 Schema + tenant_id 起步,按需升级
  • 租户配置使用 KV 存储,支持热更新

内容

1. 租户的定义

在 AI SaaS 中,租户(Tenant)通常对应一个组织或公司。一个租户包含:

  • 多个用户:租户内的成员
  • 独立的配额:token、存储、请求次数
  • 独立的配置:模型偏好、Prompt 模板、知识库
  • 独立的计费:订阅计划、账单、发票

租户和用户的区别:

概念含义示例
租户组织/公司Acme Corp
用户租户内的个人[email protected], [email protected]

2. 租户数据模型

2.1 数据库 Schema

-- 租户表
CREATE TABLE tenants (
  id TEXT PRIMARY KEY,
  name TEXT NOT NULL,              -- 租户名称
  slug TEXT NOT NULL UNIQUE,       -- URL 友好标识(如 acme-corp)
  plan TEXT NOT NULL DEFAULT 'free',  -- 订阅计划
  status TEXT NOT NULL DEFAULT 'active',  -- active/suspended/cancelled
  settings TEXT,                   -- JSON 配置
  created_at INTEGER NOT NULL,
  updated_at INTEGER NOT NULL
)
 
-- 租户配置表(可选,也可以放在 settings 字段)
CREATE TABLE tenant_configs (
  tenant_id TEXT PRIMARY KEY,
  preferred_model TEXT DEFAULT 'gpt-3.5-turbo',
  max_tokens_per_request INTEGER DEFAULT 4000,
  enable_rag INTEGER DEFAULT 1,
  custom_domain TEXT,
  FOREIGN KEY (tenant_id) REFERENCES tenants(id)
)

2.2 TypeScript 类型

// src/types/tenant.ts
export interface Tenant {
  id: string
  name: string
  slug: string
  plan: 'free' | 'pro' | 'enterprise'
  status: 'active' | 'suspended' | 'cancelled'
  settings: TenantSettings
  createdAt: number
  updatedAt: number
}
 
export interface TenantSettings {
  preferredModel: string
  maxTokensPerRequest: number
  enableRag: boolean
  customDomain?: string
}
 
export interface TenantConfig {
  tenantId: string
  preferredModel: string
  maxTokensPerRequest: number
  enableRag: boolean
  customDomain?: string
}

3. 租户注册

3.1 注册流程

租户注册通常包含以下步骤:

  1. 用户填写组织信息(名称、邮箱)
  2. 系统创建租户记录
  3. 创建第一个用户(管理员)
  4. 初始化租户配置
  5. 发送欢迎邮件

3.2 注册接口

// src/routes/tenant.ts
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
import { generateId } from '../lib/utils'
 
const app = new Hono()
 
const RegisterSchema = z.object({
  tenantName: z.string().min(2).max(100),
  email: z.string().email(),
  password: z.string().min(8),
})
 
app.post('/api/tenant/register', zValidator('json', RegisterSchema), async (c) => {
  const { tenantName, email, password } = c.req.valid('json')
 
  // 1. 生成 slug
  const slug = generateSlug(tenantName)
 
  // 2. 检查 slug 是否已存在
  const existing = await c.env.DB.prepare(
    'SELECT id FROM tenants WHERE slug = ?'
  ).bind(slug).first()
 
  if (existing) {
    return c.json({ error: '租户名称已存在' }, 409)
  }
 
  // 3. 创建租户
  const tenantId = generateId()
  const now = Date.now()
 
  await c.env.DB.prepare(`
    INSERT INTO tenants (id, name, slug, plan, status, settings, created_at, updated_at)
    VALUES (?, ?, ?, 'free', 'active', ?, ?, ?)
  `).bind(
    tenantId,
    tenantName,
    slug,
    JSON.stringify({
      preferredModel: 'gpt-3.5-turbo',
      maxTokensPerRequest: 4000,
      enableRag: true,
    }),
    now,
    now
  ).run()
 
  // 4. 创建管理员用户
  const userId = generateId()
  const passwordHash = await hashPassword(password)
 
  await c.env.DB.prepare(`
    INSERT INTO users (id, tenant_id, email, password_hash, role, created_at)
    VALUES (?, ?, ?, ?, 'admin', ?)
  `).bind(userId, tenantId, email, passwordHash, now).run()
 
  // 5. 初始化租户配置到 KV(用于快速读取)
  await c.env.TENANT_CONFIG.put(
    `config:${tenantId}`,
    JSON.stringify({
      tenantId,
      preferredModel: 'gpt-3.5-turbo',
      maxTokensPerRequest: 4000,
      enableRag: true,
    })
  )
 
  // 6. 发送欢迎邮件(异步)
  c.executionCtx.waitUntil(sendWelcomeEmail(email, tenantName))
 
  return c.json({
    tenantId,
    userId,
    slug,
  }, 201)
})
 
function generateSlug(name: string): string {
  return name
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/^-|-$/g, '')
}

4. 租户配置管理

4.1 配置存储策略

租户配置有两种存储方式:

方式 1:数据库存储

-- 配置存储在 tenants 表的 settings 字段
SELECT settings FROM tenants WHERE id = ?

优点:事务支持,查询灵活 缺点:每次都需要查询数据库

方式 2:KV 缓存

// 配置存储在 KV 中
const config = await c.env.TENANT_CONFIG.get(`config:${tenantId}`, 'json')

优点:毫秒级读取,适合频繁访问 缺点:需要处理缓存一致性

推荐方案:数据库存储 + KV 缓存

// 读取配置
async function getTenantConfig(tenantId: string, env: Env): Promise<TenantConfig> {
  // 1. 先读 KV 缓存
  const cached = await env.TENANT_CONFIG.get(`config:${tenantId}`, 'json')
  if (cached) return cached
 
  // 2. 缓存未命中,读数据库
  const tenant = await env.DB.prepare(
    'SELECT settings FROM tenants WHERE id = ?'
  ).bind(tenantId).first()
 
  if (!tenant) {
    throw new Error('Tenant not found')
  }
 
  const config = JSON.parse(tenant.settings)
 
  // 3. 写入 KV 缓存(5 分钟过期)
  await env.TENANT_CONFIG.put(
    `config:${tenantId}`,
    JSON.stringify(config),
    { expirationTtl: 300 }
  )
 
  return config
}
 
// 更新配置
async function updateTenantConfig(
  tenantId: string,
  updates: Partial<TenantConfig>,
  env: Env
): Promise<void> {
  // 1. 更新数据库
  const tenant = await env.DB.prepare(
    'SELECT settings FROM tenants WHERE id = ?'
  ).bind(tenantId).first()
 
  const current = JSON.parse(tenant.settings)
  const updated = { ...current, ...updates }
 
  await env.DB.prepare(
    'UPDATE tenants SET settings = ?, updated_at = ? WHERE id = ?'
  ).bind(JSON.stringify(updated), Date.now(), tenantId).run()
 
  // 2. 更新 KV 缓存
  await env.TENANT_CONFIG.put(
    `config:${tenantId}`,
    JSON.stringify(updated),
    { expirationTtl: 300 }
  )
}

4.2 配置更新接口

// src/routes/tenant.ts
app.put('/api/tenant/config', async (c) => {
  const tenantId = c.get('tenantId')
  const body = await c.req.json()
 
  // 验证配置
  const configSchema = z.object({
    preferredModel: z.string().optional(),
    maxTokensPerRequest: z.number().min(100).max(100000).optional(),
    enableRag: z.boolean().optional(),
  })
 
  const updates = configSchema.parse(body)
 
  // 更新配置
  await updateTenantConfig(tenantId, updates, c.env)
 
  return c.json({ success: true })
})

5. 租户隔离

5.1 中间件实现租户隔离

每个请求都需要提取 tenantId,并确保数据隔离:

// src/middleware/tenant.ts
import { Context, Next } from 'hono'
import { verify } from 'hono/jwt'
 
export async function tenantMiddleware(c: Context, next: Next) {
  const authHeader = c.req.header('Authorization')
 
  if (!authHeader?.startsWith('Bearer ')) {
    return c.json({ error: 'Unauthorized' }, 401)
  }
 
  const token = authHeader.slice(7)
 
  try {
    const payload = await verify(token, c.env.JWT_SECRET)
    const userId = payload.userId
    const tenantId = payload.tenantId
 
    // 验证租户状态
    const tenant = await c.env.DB.prepare(
      'SELECT status FROM tenants WHERE id = ?'
    ).bind(tenantId).first()
 
    if (!tenant) {
      return c.json({ error: 'Tenant not found' }, 404)
    }
 
    if (tenant.status !== 'active') {
      return c.json({ error: 'Tenant is suspended' }, 403)
    }
 
    // 存储到上下文
    c.set('userId', userId)
    c.set('tenantId', tenantId)
 
    await next()
  } catch (error) {
    return c.json({ error: 'Invalid token' }, 401)
  }
}

5.2 数据查询必须带上 tenant_id

// ❌ 错误:没有 tenant_id 过滤
const users = await c.env.DB.prepare(
  'SELECT * FROM users WHERE email = ?'
).bind(email).all()
 
// ✅ 正确:带上 tenant_id
const tenantId = c.get('tenantId')
const users = await c.env.DB.prepare(
  'SELECT * FROM users WHERE tenant_id = ? AND email = ?'
).bind(tenantId, email).all()

5.3 封装租户感知的数据库操作

// src/lib/db.ts
export class TenantDB {
  constructor(private db: D1Database, private tenantId: string) {}
 
  async queryUsers(email?: string) {
    if (email) {
      return this.db.prepare(
        'SELECT * FROM users WHERE tenant_id = ? AND email = ?'
      ).bind(this.tenantId, email).all()
    }
 
    return this.db.prepare(
      'SELECT * FROM users WHERE tenant_id = ?'
    ).bind(this.tenantId).all()
  }
 
  async createUser(userData: CreateUserInput) {
    const userId = generateId()
    const now = Date.now()
 
    await this.db.prepare(`
      INSERT INTO users (id, tenant_id, email, password_hash, role, created_at)
      VALUES (?, ?, ?, ?, ?, ?)
    `).bind(
      userId,
      this.tenantId,
      userData.email,
      userData.passwordHash,
      userData.role,
      now
    ).run()
 
    return { id: userId, ...userData, createdAt: now }
  }
}
 
// 使用
const tenantId = c.get('tenantId')
const db = new TenantDB(c.env.DB, tenantId)
const users = await db.queryUsers()

6. 租户状态管理

租户有多种状态:

状态含义允许的操作
active正常所有操作
suspended暂停(欠费/违规)只读,不能创建新资源
cancelled已取消只能导出数据
// 检查租户状态
async function checkTenantStatus(tenantId: string, env: Env) {
  const tenant = await env.DB.prepare(
    'SELECT status FROM tenants WHERE id = ?'
  ).bind(tenantId).first()
 
  if (!tenant) {
    throw new Error('Tenant not found')
  }
 
  return tenant.status
}
 
// 暂停租户(欠费)
async function suspendTenant(tenantId: string, env: Env) {
  await env.DB.prepare(
    'UPDATE tenants SET status = ?, updated_at = ? WHERE id = ?'
  ).bind('suspended', Date.now(), tenantId).run()
 
  // 清除 KV 缓存
  await env.TENANT_CONFIG.delete(`config:${tenantId}`)
}
 
// 恢复租户
async function activateTenant(tenantId: string, env: Env) {
  await env.DB.prepare(
    'UPDATE tenants SET status = ?, updated_at = ? WHERE id = ?'
  ).bind('active', Date.now(), tenantId).run()
 
  // 重新加载配置到 KV
  const config = await getTenantConfig(tenantId, env)
  await env.TENANT_CONFIG.put(
    `config:${tenantId}`,
    JSON.stringify(config),
    { expirationTtl: 300 }
  )
}

7. 实战:完整的租户管理

// src/routes/tenant.ts
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
import { generateId } from '../lib/utils'
import { hashPassword } from '../lib/auth'
import { getTenantConfig, updateTenantConfig } from '../lib/tenant-config'
 
const app = new Hono()
 
// 注册
app.post('/register', zValidator('json', z.object({
  tenantName: z.string().min(2).max(100),
  email: z.string().email(),
  password: z.string().min(8),
})), async (c) => {
  const { tenantName, email, password } = c.req.valid('json')
  const slug = generateSlug(tenantName)
 
  // 检查 slug
  const existing = await c.env.DB.prepare(
    'SELECT id FROM tenants WHERE slug = ?'
  ).bind(slug).first()
 
  if (existing) {
    return c.json({ error: '租户名称已存在' }, 409)
  }
 
  // 创建租户
  const tenantId = generateId()
  const userId = generateId()
  const now = Date.now()
 
  const settings = {
    preferredModel: 'gpt-3.5-turbo',
    maxTokensPerRequest: 4000,
    enableRag: true,
  }
 
  await c.env.DB.batch([
    c.env.DB.prepare(`
      INSERT INTO tenants (id, name, slug, plan, status, settings, created_at, updated_at)
      VALUES (?, ?, ?, 'free', 'active', ?, ?, ?)
    `).bind(tenantId, tenantName, slug, JSON.stringify(settings), now, now),
    c.env.DB.prepare(`
      INSERT INTO users (id, tenant_id, email, password_hash, role, created_at)
      VALUES (?, ?, ?, ?, 'admin', ?)
    `).bind(userId, tenantId, email, await hashPassword(password), now),
  ])
 
  // 初始化 KV 缓存
  await c.env.TENANT_CONFIG.put(
    `config:${tenantId}`,
    JSON.stringify({ tenantId, ...settings }),
    { expirationTtl: 300 }
  )
 
  return c.json({ tenantId, userId, slug }, 201)
})
 
// 获取当前租户信息
app.get('/info', async (c) => {
  const tenantId = c.get('tenantId')
 
  const tenant = await c.env.DB.prepare(`
    SELECT id, name, slug, plan, status, settings, created_at
    FROM tenants WHERE id = ?
  `).bind(tenantId).first()
 
  if (!tenant) {
    return c.json({ error: 'Tenant not found' }, 404)
  }
 
  return c.json({
    ...tenant,
    settings: JSON.parse(tenant.settings),
  })
})
 
// 更新配置
app.put('/config', zValidator('json', z.object({
  preferredModel: z.string().optional(),
  maxTokensPerRequest: z.number().min(100).max(100000).optional(),
  enableRag: z.boolean().optional(),
})), async (c) => {
  const tenantId = c.get('tenantId')
  const updates = c.req.valid('json')
 
  await updateTenantConfig(tenantId, updates, c.env)
 
  return c.json({ success: true })
})
 
// 获取配置
app.get('/config', async (c) => {
  const tenantId = c.get('tenantId')
  const config = await getTenantConfig(tenantId, c.env)
 
  return c.json(config)
})
 
export default app
 
function generateSlug(name: string): string {
  return name
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/^-|-$/g, '')
}

8. 小结

租户模型的关键点:

  1. 租户定义:租户是组织/公司,包含多个用户
  2. 数据模型:tenants 表 + tenant_configs 表(或 settings 字段)
  3. 注册流程:创建租户 → 创建管理员 → 初始化配置 → 发送邮件
  4. 配置管理:数据库存储 + KV 缓存,支持热更新
  5. 数据隔离:所有查询必须带上 tenant_id,中间件统一处理
  6. 状态管理:active/suspended/cancelled,不同状态允许不同操作

一句话带走:

租户是 AI SaaS 的核心概念。从共享 Schema + tenant_id 起步,用 KV 缓存配置,中间件统一处理租户隔离,按需升级到独立数据库。