Files
insightflow/backend/tenant_manager.py
OpenClaw Bot be22b763fa fix: auto-fix code issues (cron)
- 修复重复导入/字段
- 修复异常处理
- 修复PEP8格式问题
- 添加类型注解
- 修复重复函数定义 (health_check, create_webhook_endpoint, etc)
- 修复未定义名称 (SearchOperator, TenantTier, Query, Body, logger)
- 修复 workflow_manager.py 的类定义重复问题
- 添加缺失的导入
2026-02-27 09:18:58 +08:00

1480 lines
54 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
class TenantLimits:
"""租户资源限制常量"""
FREE_MAX_PROJECTS = 3
FREE_MAX_STORAGE_MB = 100
FREE_MAX_TRANSCRIPTION_MINUTES = 60
FREE_MAX_API_CALLS_PER_DAY = 100
FREE_MAX_TEAM_MEMBERS = 2
FREE_MAX_ENTITIES = 100
PRO_MAX_PROJECTS = 20
PRO_MAX_STORAGE_MB = 1000
PRO_MAX_TRANSCRIPTION_MINUTES = 600
PRO_MAX_API_CALLS_PER_DAY = 10000
PRO_MAX_TEAM_MEMBERS = 10
PRO_MAX_ENTITIES = 1000
UNLIMITED = -1
"""
InsightFlow Phase 8 - 多租户 SaaS 架构管理模块
功能:
1. 租户隔离(数据、配置、资源完全隔离)
2. 自定义域名绑定CNAME 支持)
3. 品牌白标Logo、主题色、自定义 CSS
4. 租户级权限管理
作者: InsightFlow Team
"""
import sqlite3
import json
import uuid
import hashlib
import re
from datetime import datetime
from typing import Optional, List, Dict, Any, Tuple
from dataclasses import dataclass, asdict
from enum import Enum
import logging
logger = logging.getLogger(__name__)
class TenantStatus(str, Enum):
"""租户状态"""
ACTIVE = "active" # 活跃
SUSPENDED = "suspended" # 暂停
TRIAL = "trial" # 试用
EXPIRED = "expired" # 过期
PENDING = "pending" # 待激活
class TenantTier(str, Enum):
"""租户订阅层级"""
FREE = "free" # 免费版
PRO = "pro" # 专业版
ENTERPRISE = "enterprise" # 企业版
class TenantRole(str, Enum):
"""租户角色"""
OWNER = "owner" # 所有者
ADMIN = "admin" # 管理员
MEMBER = "member" # 成员
VIEWER = "viewer" # 查看者
class DomainStatus(str, Enum):
"""域名状态"""
PENDING = "pending" # 待验证
VERIFIED = "verified" # 已验证
FAILED = "failed" # 验证失败
EXPIRED = "expired" # 已过期
@dataclass
class Tenant:
"""租户数据类"""
id: str
name: str
slug: str # URL 友好的唯一标识
description: Optional[str]
tier: str # free/pro/enterprise
status: str # active/suspended/trial/expired/pending
owner_id: str # 所有者用户ID
created_at: datetime
updated_at: datetime
expires_at: Optional[datetime] # 订阅过期时间
settings: Dict[str, Any] # 租户级设置
resource_limits: Dict[str, Any] # 资源限制
metadata: Dict[str, Any] # 元数据
@dataclass
class TenantDomain:
"""租户域名数据类"""
id: str
tenant_id: str
domain: str # 自定义域名
status: str # pending/verified/failed/expired
verification_token: str # 验证令牌
verification_method: str # dns/file
verified_at: Optional[datetime]
created_at: datetime
updated_at: datetime
is_primary: bool # 是否主域名
ssl_enabled: bool # SSL 是否启用
ssl_expires_at: Optional[datetime]
@dataclass
class TenantBranding:
"""租户品牌配置数据类"""
id: str
tenant_id: str
logo_url: Optional[str] # Logo URL
favicon_url: Optional[str] # Favicon URL
primary_color: Optional[str] # 主题主色
secondary_color: Optional[str] # 主题次色
custom_css: Optional[str] # 自定义 CSS
custom_js: Optional[str] # 自定义 JS
login_page_bg: Optional[str] # 登录页背景
email_template: Optional[str] # 邮件模板
created_at: datetime
updated_at: datetime
@dataclass
class TenantMember:
"""租户成员数据类"""
id: str
tenant_id: str
user_id: str
email: str
role: str # owner/admin/member/viewer
permissions: List[str] # 具体权限列表
invited_by: Optional[str] # 邀请者
invited_at: datetime
joined_at: Optional[datetime]
last_active_at: Optional[datetime]
status: str # active/pending/suspended
@dataclass
class TenantPermission:
"""租户权限定义数据类"""
id: str
tenant_id: str
name: str # 权限名称
code: str # 权限代码
description: Optional[str]
resource_type: str # project/entity/api/etc
actions: List[str] # create/read/update/delete/etc
conditions: Optional[Dict] # 条件限制
created_at: datetime
class TenantLimits:
"""租户资源限制常量"""
# Free 套餐限制
FREE_MAX_PROJECTS = 3
FREE_MAX_STORAGE_MB = 100
FREE_MAX_TRANSCRIPTION_MINUTES = 60
FREE_MAX_API_CALLS_PER_DAY = 100
FREE_MAX_TEAM_MEMBERS = 2
FREE_MAX_ENTITIES = 100
# Pro 套餐限制
PRO_MAX_PROJECTS = 20
PRO_MAX_STORAGE_MB = 1000
PRO_MAX_TRANSCRIPTION_MINUTES = 600
PRO_MAX_API_CALLS_PER_DAY = 10000
PRO_MAX_TEAM_MEMBERS = 10
PRO_MAX_ENTITIES = 1000
# Enterprise 套餐 - 无限制
UNLIMITED = -1
class TenantManager:
"""租户管理器 - 多租户 SaaS 架构核心"""
# 默认资源限制配置 - 使用常量
DEFAULT_LIMITS = {
TenantTier.FREE: {
"max_projects": TenantLimits.FREE_MAX_PROJECTS,
"max_storage_mb": TenantLimits.FREE_MAX_STORAGE_MB,
"max_transcription_minutes": TenantLimits.FREE_MAX_TRANSCRIPTION_MINUTES,
"max_api_calls_per_day": TenantLimits.FREE_MAX_API_CALLS_PER_DAY,
"max_team_members": TenantLimits.FREE_MAX_TEAM_MEMBERS,
"max_entities": TenantLimits.FREE_MAX_ENTITIES,
"features": ["basic_analysis", "export_png"]
},
TenantTier.PRO: {
"max_projects": TenantLimits.PRO_MAX_PROJECTS,
"max_storage_mb": TenantLimits.PRO_MAX_STORAGE_MB,
"max_transcription_minutes": TenantLimits.PRO_MAX_TRANSCRIPTION_MINUTES,
"max_api_calls_per_day": TenantLimits.PRO_MAX_API_CALLS_PER_DAY,
"max_team_members": TenantLimits.PRO_MAX_TEAM_MEMBERS,
"max_entities": TenantLimits.PRO_MAX_ENTITIES,
"features": ["basic_analysis", "advanced_analysis", "export_all",
"api_access", "webhooks", "collaboration"]
},
TenantTier.ENTERPRISE: {
"max_projects": TenantLimits.UNLIMITED, # 无限制
"max_storage_mb": TenantLimits.UNLIMITED,
"max_transcription_minutes": TenantLimits.UNLIMITED,
"max_api_calls_per_day": TenantLimits.UNLIMITED,
"max_team_members": TenantLimits.UNLIMITED,
"max_entities": TenantLimits.UNLIMITED,
"features": ["all"] # 所有功能
}
}
# 角色权限映射
ROLE_PERMISSIONS = {
TenantRole.OWNER: [
"tenant:*", "project:*", "member:*", "billing:*",
"settings:*", "api:*", "export:*"
],
TenantRole.ADMIN: [
"tenant:read", "project:*", "member:*", "billing:read",
"settings:*", "api:*", "export:*"
],
TenantRole.MEMBER: [
"tenant:read", "project:create", "project:read", "project:update",
"member:read", "export:basic"
],
TenantRole.VIEWER: [
"tenant:read", "project:read", "member:read"
]
}
# 权限名称映射
PERMISSION_NAMES = {
"tenant:*": "租户完全控制",
"tenant:read": "查看租户信息",
"project:*": "项目完全控制",
"project:create": "创建项目",
"project:read": "查看项目",
"project:update": "编辑项目",
"member:*": "成员完全控制",
"member:read": "查看成员",
"billing:*": "账单完全控制",
"billing:read": "查看账单",
"settings:*": "设置完全控制",
"api:*": "API完全控制",
"export:*": "导出完全控制",
"export:basic": "基础导出"
}
def __init__(self, db_path: str = "insightflow.db"):
self.db_path = db_path
self._init_db()
def _get_connection(self) -> sqlite3.Connection:
"""获取数据库连接"""
conn = sqlite3.connect(self.db_path)
conn.row_factory = sqlite3.Row
return conn
def _init_db(self):
"""初始化数据库表"""
conn = self._get_connection()
try:
cursor = conn.cursor()
# 租户主表
cursor.execute("""
CREATE TABLE IF NOT EXISTS tenants (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
description TEXT,
tier TEXT DEFAULT 'free',
status TEXT DEFAULT 'pending',
owner_id TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP,
settings TEXT DEFAULT '{}',
resource_limits TEXT DEFAULT '{}',
metadata TEXT DEFAULT '{}'
)
""")
# 租户域名表
cursor.execute("""
CREATE TABLE IF NOT EXISTS tenant_domains (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
domain TEXT UNIQUE NOT NULL,
status TEXT DEFAULT 'pending',
verification_token TEXT NOT NULL,
verification_method TEXT DEFAULT 'dns',
verified_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_primary INTEGER DEFAULT 0,
ssl_enabled INTEGER DEFAULT 0,
ssl_expires_at TIMESTAMP,
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
)
""")
# 租户品牌配置表
cursor.execute("""
CREATE TABLE IF NOT EXISTS tenant_branding (
id TEXT PRIMARY KEY,
tenant_id TEXT UNIQUE NOT NULL,
logo_url TEXT,
favicon_url TEXT,
primary_color TEXT,
secondary_color TEXT,
custom_css TEXT,
custom_js TEXT,
login_page_bg TEXT,
email_template TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
)
""")
# 租户成员表
cursor.execute("""
CREATE TABLE IF NOT EXISTS tenant_members (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
user_id TEXT,
email TEXT NOT NULL,
role TEXT DEFAULT 'member',
permissions TEXT DEFAULT '[]',
invited_by TEXT,
invited_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
joined_at TIMESTAMP,
last_active_at TIMESTAMP,
status TEXT DEFAULT 'pending',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
)
""")
# 租户权限定义表
cursor.execute("""
CREATE TABLE IF NOT EXISTS tenant_permissions (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
name TEXT NOT NULL,
code TEXT NOT NULL,
description TEXT,
resource_type TEXT NOT NULL,
actions TEXT NOT NULL,
conditions TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE,
UNIQUE(tenant_id, code)
)
""")
# 租户资源使用统计表
cursor.execute("""
CREATE TABLE IF NOT EXISTS tenant_usage (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
date DATE NOT NULL,
storage_bytes INTEGER DEFAULT 0,
transcription_seconds INTEGER DEFAULT 0,
api_calls INTEGER DEFAULT 0,
projects_count INTEGER DEFAULT 0,
entities_count INTEGER DEFAULT 0,
members_count INTEGER DEFAULT 0,
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE,
UNIQUE(tenant_id, date)
)
""")
# 创建索引
cursor.execute("CREATE INDEX IF NOT EXISTS idx_tenants_slug ON tenants(slug)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_tenants_owner ON tenants(owner_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_tenants_status ON tenants(status)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_domains_tenant ON tenant_domains(tenant_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_domains_domain ON tenant_domains(domain)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_domains_status ON tenant_domains(status)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_members_tenant ON tenant_members(tenant_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_members_user ON tenant_members(user_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_usage_tenant ON tenant_usage(tenant_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_usage_date ON tenant_usage(date)")
conn.commit()
logger.info("Tenant tables initialized successfully")
except Exception as e:
logger.error(f"Error initializing tenant tables: {e}")
raise
finally:
conn.close()
# ==================== 租户管理 ====================
def create_tenant(self, name: str, owner_id: str,
tier: str = "free",
description: Optional[str] = None,
settings: Optional[Dict] = None) -> Tenant:
"""创建新租户"""
conn = self._get_connection()
try:
tenant_id = str(uuid.uuid4())
slug = self._generate_slug(name)
# 获取对应层级的资源限制
tier_enum = TenantTier(tier) if tier in [t.value for t in TenantTier] else TenantTier.FREE
resource_limits = self.DEFAULT_LIMITS.get(tier_enum, self.DEFAULT_LIMITS[TenantTier.FREE])
tenant = Tenant(
id=tenant_id,
name=name,
slug=slug,
description=description,
tier=tier,
status=TenantStatus.PENDING.value,
owner_id=owner_id,
created_at=datetime.now(),
updated_at=datetime.now(),
expires_at=None,
settings=settings or {},
resource_limits=resource_limits,
metadata={}
)
cursor = conn.cursor()
cursor.execute("""
INSERT INTO tenants (id, name, slug, description, tier, status, owner_id,
created_at, updated_at, expires_at, settings, resource_limits, metadata)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
tenant.id, tenant.name, tenant.slug, tenant.description,
tenant.tier, tenant.status, tenant.owner_id,
tenant.created_at, tenant.updated_at, tenant.expires_at,
json.dumps(tenant.settings), json.dumps(tenant.resource_limits),
json.dumps(tenant.metadata)
))
# 自动将所有者添加为成员
self._add_member_internal(conn, tenant_id, owner_id, "", TenantRole.OWNER, None)
conn.commit()
logger.info(f"Tenant created: {tenant_id} ({name})")
return tenant
except Exception as e:
conn.rollback()
logger.error(f"Error creating tenant: {e}")
raise
finally:
conn.close()
def get_tenant(self, tenant_id: str) -> Optional[Tenant]:
"""获取租户信息"""
conn = self._get_connection()
try:
cursor = conn.cursor()
cursor.execute("SELECT * FROM tenants WHERE id = ?", (tenant_id,))
row = cursor.fetchone()
if row:
return self._row_to_tenant(row)
return None
finally:
conn.close()
def get_tenant_by_slug(self, slug: str) -> Optional[Tenant]:
"""通过 slug 获取租户"""
conn = self._get_connection()
try:
cursor = conn.cursor()
cursor.execute("SELECT * FROM tenants WHERE slug = ?", (slug,))
row = cursor.fetchone()
if row:
return self._row_to_tenant(row)
return None
finally:
conn.close()
def get_tenant_by_domain(self, domain: str) -> Optional[Tenant]:
"""通过自定义域名获取租户"""
conn = self._get_connection()
try:
cursor = conn.cursor()
cursor.execute("""
SELECT t.* FROM tenants t
JOIN tenant_domains d ON t.id = d.tenant_id
WHERE d.domain = ? AND d.status = 'verified'
""", (domain,))
row = cursor.fetchone()
if row:
return self._row_to_tenant(row)
return None
finally:
conn.close()
def update_tenant(self, tenant_id: str,
name: Optional[str] = None,
description: Optional[str] = None,
tier: Optional[str] = None,
status: Optional[str] = None,
settings: Optional[Dict] = None) -> Optional[Tenant]:
"""更新租户信息"""
conn = self._get_connection()
try:
tenant = self.get_tenant(tenant_id)
if not tenant:
return None
updates = []
params = []
if name is not None:
updates.append("name = ?")
params.append(name)
if description is not None:
updates.append("description = ?")
params.append(description)
if tier is not None:
updates.append("tier = ?")
params.append(tier)
# 更新资源限制
tier_enum = TenantTier(tier)
updates.append("resource_limits = ?")
params.append(json.dumps(self.DEFAULT_LIMITS.get(tier_enum, {})))
if status is not None:
updates.append("status = ?")
params.append(status)
if settings is not None:
updates.append("settings = ?")
params.append(json.dumps(settings))
updates.append("updated_at = ?")
params.append(datetime.now())
params.append(tenant_id)
cursor = conn.cursor()
cursor.execute(f"""
UPDATE tenants SET {', '.join(updates)}
WHERE id = ?
""", params)
conn.commit()
return self.get_tenant(tenant_id)
finally:
conn.close()
def delete_tenant(self, tenant_id: str) -> bool:
"""删除租户(软删除或硬删除)"""
conn = self._get_connection()
try:
cursor = conn.cursor()
cursor.execute("DELETE FROM tenants WHERE id = ?", (tenant_id,))
conn.commit()
return cursor.rowcount > 0
finally:
conn.close()
def list_tenants(self, status: Optional[str] = None,
tier: Optional[str] = None,
limit: int = 100, offset: int = 0) -> List[Tenant]:
"""列出租户"""
conn = self._get_connection()
try:
cursor = conn.cursor()
query = "SELECT * FROM tenants WHERE 1=1"
params = []
if status:
query += " AND status = ?"
params.append(status)
if tier:
query += " AND tier = ?"
params.append(tier)
query += " ORDER BY created_at DESC LIMIT ? OFFSET ?"
params.extend([limit, offset])
cursor.execute(query, params)
rows = cursor.fetchall()
return [self._row_to_tenant(row) for row in rows]
finally:
conn.close()
# ==================== 域名管理 ====================
def add_domain(self, tenant_id: str, domain: str,
is_primary: bool = False,
verification_method: str = "dns") -> TenantDomain:
"""为租户添加自定义域名"""
conn = self._get_connection()
try:
# 验证域名格式
if not self._validate_domain(domain):
raise ValueError(f"Invalid domain format: {domain}")
# 生成验证令牌
verification_token = self._generate_verification_token(tenant_id, domain)
domain_id = str(uuid.uuid4())
tenant_domain = TenantDomain(
id=domain_id,
tenant_id=tenant_id,
domain=domain.lower(),
status=DomainStatus.PENDING.value,
verification_token=verification_token,
verification_method=verification_method,
verified_at=None,
created_at=datetime.now(),
updated_at=datetime.now(),
is_primary=is_primary,
ssl_enabled=False,
ssl_expires_at=None
)
cursor = conn.cursor()
# 如果设为主域名,取消其他主域名
if is_primary:
cursor.execute("""
UPDATE tenant_domains SET is_primary = 0
WHERE tenant_id = ?
""", (tenant_id,))
cursor.execute("""
INSERT INTO tenant_domains (id, tenant_id, domain, status,
verification_token, verification_method, verified_at,
created_at, updated_at, is_primary, ssl_enabled, ssl_expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
tenant_domain.id, tenant_domain.tenant_id, tenant_domain.domain,
tenant_domain.status, tenant_domain.verification_token,
tenant_domain.verification_method, tenant_domain.verified_at,
tenant_domain.created_at, tenant_domain.updated_at,
int(tenant_domain.is_primary), int(tenant_domain.ssl_enabled),
tenant_domain.ssl_expires_at
))
conn.commit()
logger.info(f"Domain added: {domain} for tenant {tenant_id}")
return tenant_domain
except Exception as e:
conn.rollback()
logger.error(f"Error adding domain: {e}")
raise
finally:
conn.close()
def verify_domain(self, tenant_id: str, domain_id: str) -> bool:
"""验证域名所有权"""
conn = self._get_connection()
try:
cursor = conn.cursor()
# 获取域名信息
cursor.execute("""
SELECT * FROM tenant_domains
WHERE id = ? AND tenant_id = ?
""", (domain_id, tenant_id))
row = cursor.fetchone()
if not row:
return False
domain = row['domain']
token = row['verification_token']
method = row['verification_method']
# 执行验证
is_verified = self._check_domain_verification(domain, token, method)
if is_verified:
cursor.execute("""
UPDATE tenant_domains
SET status = 'verified', verified_at = ?, updated_at = ?
WHERE id = ?
""", (datetime.now(), datetime.now(), domain_id))
conn.commit()
logger.info(f"Domain verified: {domain}")
else:
cursor.execute("""
UPDATE tenant_domains
SET status = 'failed', updated_at = ?
WHERE id = ?
""", (datetime.now(), domain_id))
conn.commit()
return is_verified
except Exception as e:
logger.error(f"Error verifying domain: {e}")
return False
finally:
conn.close()
def get_domain_verification_instructions(self, domain_id: str) -> Dict[str, Any]:
"""获取域名验证指导"""
conn = self._get_connection()
try:
cursor = conn.cursor()
cursor.execute("SELECT * FROM tenant_domains WHERE id = ?", (domain_id,))
row = cursor.fetchone()
if not row:
return None
domain = row['domain']
token = row['verification_token']
return {
"domain": domain,
"verification_method": row['verification_method'],
"dns_record": {
"type": "TXT",
"name": "_insightflow",
"value": f"insightflow-verify={token}",
"ttl": 3600
},
"file_verification": {
"url": f"http://{domain}/.well-known/insightflow-verify.txt",
"content": token
},
"instructions": [
f"DNS 验证: 添加 TXT 记录 _insightflow.{domain},值为 insightflow-verify={token}",
f"文件验证: 在网站根目录创建 .well-known/insightflow-verify.txt内容为 {token}"
]
}
finally:
conn.close()
def remove_domain(self, tenant_id: str, domain_id: str) -> bool:
"""移除域名绑定"""
conn = self._get_connection()
try:
cursor = conn.cursor()
cursor.execute("""
DELETE FROM tenant_domains
WHERE id = ? AND tenant_id = ?
""", (domain_id, tenant_id))
conn.commit()
return cursor.rowcount > 0
finally:
conn.close()
def list_domains(self, tenant_id: str) -> List[TenantDomain]:
"""列出租户的所有域名"""
conn = self._get_connection()
try:
cursor = conn.cursor()
cursor.execute("""
SELECT * FROM tenant_domains
WHERE tenant_id = ?
ORDER BY is_primary DESC, created_at DESC
""", (tenant_id,))
rows = cursor.fetchall()
return [self._row_to_domain(row) for row in rows]
finally:
conn.close()
# ==================== 品牌白标管理 ====================
def get_branding(self, tenant_id: str) -> Optional[TenantBranding]:
"""获取租户品牌配置"""
conn = self._get_connection()
try:
cursor = conn.cursor()
cursor.execute("SELECT * FROM tenant_branding WHERE tenant_id = ?", (tenant_id,))
row = cursor.fetchone()
if row:
return self._row_to_branding(row)
return None
finally:
conn.close()
def update_branding(self, tenant_id: str,
logo_url: Optional[str] = None,
favicon_url: Optional[str] = None,
primary_color: Optional[str] = None,
secondary_color: Optional[str] = None,
custom_css: Optional[str] = None,
custom_js: Optional[str] = None,
login_page_bg: Optional[str] = None,
email_template: Optional[str] = None) -> TenantBranding:
"""更新租户品牌配置"""
conn = self._get_connection()
try:
cursor = conn.cursor()
# 检查是否已存在
cursor.execute("SELECT id FROM tenant_branding WHERE tenant_id = ?", (tenant_id,))
existing = cursor.fetchone()
if existing:
# 更新
updates = []
params = []
if logo_url is not None:
updates.append("logo_url = ?")
params.append(logo_url)
if favicon_url is not None:
updates.append("favicon_url = ?")
params.append(favicon_url)
if primary_color is not None:
updates.append("primary_color = ?")
params.append(primary_color)
if secondary_color is not None:
updates.append("secondary_color = ?")
params.append(secondary_color)
if custom_css is not None:
updates.append("custom_css = ?")
params.append(custom_css)
if custom_js is not None:
updates.append("custom_js = ?")
params.append(custom_js)
if login_page_bg is not None:
updates.append("login_page_bg = ?")
params.append(login_page_bg)
if email_template is not None:
updates.append("email_template = ?")
params.append(email_template)
updates.append("updated_at = ?")
params.append(datetime.now())
params.append(tenant_id)
cursor.execute(f"""
UPDATE tenant_branding SET {', '.join(updates)}
WHERE tenant_id = ?
""", params)
else:
# 创建
branding_id = str(uuid.uuid4())
cursor.execute("""
INSERT INTO tenant_branding
(id, tenant_id, logo_url, favicon_url, primary_color, secondary_color,
custom_css, custom_js, login_page_bg, email_template, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
branding_id, tenant_id, logo_url, favicon_url, primary_color,
secondary_color, custom_css, custom_js, login_page_bg, email_template,
datetime.now(), datetime.now()
))
conn.commit()
return self.get_branding(tenant_id)
finally:
conn.close()
def get_branding_css(self, tenant_id: str) -> str:
"""生成品牌 CSS"""
branding = self.get_branding(tenant_id)
if not branding:
return ""
css = []
if branding.primary_color:
css.append(f"""
:root {{
--tenant-primary: {branding.primary_color};
--tenant-primary-hover: {self._darken_color(branding.primary_color, 10)};
}}
.tenant-primary {{ color: var(--tenant-primary) !important; }}
.tenant-bg-primary {{ background-color: var(--tenant-primary) !important; }}
.tenant-btn-primary {{
background-color: var(--tenant-primary) !important;
border-color: var(--tenant-primary) !important;
}}
.tenant-btn-primary:hover {{
background-color: var(--tenant-primary-hover) !important;
border-color: var(--tenant-primary-hover) !important;
}}
""")
if branding.secondary_color:
css.append(f"""
:root {{ --tenant-secondary: {branding.secondary_color}; }}
.tenant-secondary {{ color: var(--tenant-secondary) !important; }}
.tenant-bg-secondary {{ background-color: var(--tenant-secondary) !important; }}
""")
if branding.custom_css:
css.append(branding.custom_css)
return "\n".join(css)
# ==================== 成员与权限管理 ====================
def invite_member(self, tenant_id: str, email: str, role: str,
invited_by: str, permissions: Optional[List[str]] = None) -> TenantMember:
"""邀请成员加入租户"""
conn = self._get_connection()
try:
member_id = str(uuid.uuid4())
# 使用角色默认权限
role_enum = TenantRole(role) if role in [r.value for r in TenantRole] else TenantRole.MEMBER
default_permissions = self.ROLE_PERMISSIONS.get(role_enum, [])
final_permissions = permissions or default_permissions
member = TenantMember(
id=member_id,
tenant_id=tenant_id,
user_id="pending", # 临时值,待用户接受邀请后更新
email=email,
role=role,
permissions=final_permissions,
invited_by=invited_by,
invited_at=datetime.now(),
joined_at=None,
last_active_at=None,
status="pending"
)
cursor = conn.cursor()
cursor.execute("""
INSERT INTO tenant_members
(id, tenant_id, user_id, email, role, permissions, invited_by,
invited_at, joined_at, last_active_at, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
member.id, member.tenant_id, member.user_id, member.email,
member.role, json.dumps(member.permissions), member.invited_by,
member.invited_at, member.joined_at, member.last_active_at,
member.status
))
conn.commit()
logger.info(f"Member invited: {email} to tenant {tenant_id}")
return member
finally:
conn.close()
def accept_invitation(self, invitation_id: str, user_id: str) -> bool:
"""接受邀请"""
conn = self._get_connection()
try:
cursor = conn.cursor()
cursor.execute("""
UPDATE tenant_members
SET user_id = ?, status = 'active', joined_at = ?
WHERE id = ? AND status = 'pending'
""", (user_id, datetime.now(), invitation_id))
conn.commit()
return cursor.rowcount > 0
finally:
conn.close()
def remove_member(self, tenant_id: str, member_id: str) -> bool:
"""移除成员"""
conn = self._get_connection()
try:
cursor = conn.cursor()
cursor.execute("""
DELETE FROM tenant_members
WHERE id = ? AND tenant_id = ?
""", (member_id, tenant_id))
conn.commit()
return cursor.rowcount > 0
finally:
conn.close()
def update_member_role(self, tenant_id: str, member_id: str,
role: str, permissions: Optional[List[str]] = None) -> bool:
"""更新成员角色"""
conn = self._get_connection()
try:
role_enum = TenantRole(role)
default_permissions = self.ROLE_PERMISSIONS.get(role_enum, [])
final_permissions = permissions or default_permissions
cursor = conn.cursor()
cursor.execute("""
UPDATE tenant_members
SET role = ?, permissions = ?, updated_at = ?
WHERE id = ? AND tenant_id = ?
""", (role, json.dumps(final_permissions), datetime.now(), member_id, tenant_id))
conn.commit()
return cursor.rowcount > 0
finally:
conn.close()
def list_members(self, tenant_id: str, status: Optional[str] = None) -> List[TenantMember]:
"""列出租户成员"""
conn = self._get_connection()
try:
cursor = conn.cursor()
query = "SELECT * FROM tenant_members WHERE tenant_id = ?"
params = [tenant_id]
if status:
query += " AND status = ?"
params.append(status)
query += " ORDER BY invited_at DESC"
cursor.execute(query, params)
rows = cursor.fetchall()
return [self._row_to_member(row) for row in rows]
finally:
conn.close()
def check_permission(self, tenant_id: str, user_id: str,
resource: str, action: str) -> bool:
"""检查用户是否有特定权限"""
conn = self._get_connection()
try:
cursor = conn.cursor()
cursor.execute("""
SELECT role, permissions FROM tenant_members
WHERE tenant_id = ? AND user_id = ? AND status = 'active'
""", (tenant_id, user_id))
row = cursor.fetchone()
if not row:
return False
role = row['role']
permissions = json.loads(row['permissions'] or '[]')
# 所有者拥有所有权限
if role == TenantRole.OWNER.value:
return True
# 检查具体权限
required = f"{resource}:{action}"
wildcard = f"{resource}:*"
return required in permissions or wildcard in permissions or "*" in permissions
finally:
conn.close()
def get_user_tenants(self, user_id: str) -> List[Dict[str, Any]]:
"""获取用户所属的所有租户"""
conn = self._get_connection()
try:
cursor = conn.cursor()
cursor.execute("""
SELECT t.*, m.role, m.status as member_status
FROM tenants t
JOIN tenant_members m ON t.id = m.tenant_id
WHERE m.user_id = ? AND m.status = 'active'
ORDER BY t.created_at DESC
""", (user_id,))
rows = cursor.fetchall()
result = []
for row in rows:
tenant = self._row_to_tenant(row)
result.append({
**asdict(tenant),
"member_role": row['role'],
"member_status": row['member_status']
})
return result
finally:
conn.close()
# ==================== 资源使用统计 ====================
def record_usage(self, tenant_id: str,
storage_bytes: int = 0,
transcription_seconds: int = 0,
api_calls: int = 0,
projects_count: int = 0,
entities_count: int = 0,
members_count: int = 0):
"""记录资源使用"""
conn = self._get_connection()
try:
today = datetime.now().date()
usage_id = str(uuid.uuid4())
cursor = conn.cursor()
cursor.execute("""
INSERT INTO tenant_usage
(id, tenant_id, date, storage_bytes, transcription_seconds, api_calls,
projects_count, entities_count, members_count)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(tenant_id, date) DO UPDATE SET
storage_bytes = storage_bytes + excluded.storage_bytes,
transcription_seconds = transcription_seconds + excluded.transcription_seconds,
api_calls = api_calls + excluded.api_calls,
projects_count = MAX(projects_count, excluded.projects_count),
entities_count = MAX(entities_count, excluded.entities_count),
members_count = MAX(members_count, excluded.members_count)
""", (
usage_id, tenant_id, today, storage_bytes, transcription_seconds,
api_calls, projects_count, entities_count, members_count
))
conn.commit()
finally:
conn.close()
def get_usage_stats(self, tenant_id: str,
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None) -> Dict[str, Any]:
"""获取使用统计"""
conn = self._get_connection()
try:
cursor = conn.cursor()
query = """
SELECT
SUM(storage_bytes) as total_storage,
SUM(transcription_seconds) as total_transcription,
SUM(api_calls) as total_api_calls,
MAX(projects_count) as max_projects,
MAX(entities_count) as max_entities,
MAX(members_count) as max_members
FROM tenant_usage
WHERE tenant_id = ?
"""
params = [tenant_id]
if start_date:
query += " AND date >= ?"
params.append(start_date.date())
if end_date:
query += " AND date <= ?"
params.append(end_date.date())
cursor.execute(query, params)
row = cursor.fetchone()
# 获取租户限制
tenant = self.get_tenant(tenant_id)
limits = tenant.resource_limits if tenant else {}
return {
"storage_bytes": row['total_storage'] or 0,
"storage_mb": (row['total_storage'] or 0) / (1024 * 1024),
"transcription_seconds": row['total_transcription'] or 0,
"transcription_minutes": (row['total_transcription'] or 0) / 60,
"api_calls": row['total_api_calls'] or 0,
"projects_count": row['max_projects'] or 0,
"entities_count": row['max_entities'] or 0,
"members_count": row['max_members'] or 0,
"limits": limits,
"usage_percentages": {
"storage": self._calc_percentage(row['total_storage'] or 0, limits.get('max_storage_mb', 0) * 1024 * 1024),
"transcription": self._calc_percentage(row['total_transcription'] or 0, limits.get('max_transcription_minutes', 0) * 60),
"api_calls": self._calc_percentage(row['total_api_calls'] or 0, limits.get('max_api_calls_per_day', 0)),
"projects": self._calc_percentage(row['max_projects'] or 0, limits.get('max_projects', 0)),
"entities": self._calc_percentage(row['max_entities'] or 0, limits.get('max_entities', 0)),
"members": self._calc_percentage(row['max_members'] or 0, limits.get('max_team_members', 0))
}
}
finally:
conn.close()
def check_resource_limit(self, tenant_id: str, resource_type: str) -> Tuple[bool, int, int]:
"""检查资源是否超限
Returns:
(是否允许, 当前使用量, 限制值)
"""
tenant = self.get_tenant(tenant_id)
if not tenant:
return False, 0, 0
limits = tenant.resource_limits
stats = self.get_usage_stats(tenant_id)
resource_map = {
"storage": ("storage_mb", stats['storage_mb']),
"transcription": ("max_transcription_minutes", stats['transcription_minutes']),
"api_calls": ("max_api_calls_per_day", stats['api_calls']),
"projects": ("max_projects", stats['projects_count']),
"entities": ("max_entities", stats['entities_count']),
"members": ("max_team_members", stats['members_count'])
}
if resource_type not in resource_map:
return True, 0, -1
limit_key, current = resource_map[resource_type]
limit = limits.get(limit_key, 0)
# -1 表示无限制
if limit == -1:
return True, current, limit
return current < limit, current, limit
# ==================== 辅助方法 ====================
def _generate_slug(self, name: str) -> str:
"""生成 URL 友好的 slug"""
# 转换为小写,替换空格为连字符
slug = re.sub(r'[^\w\s-]', '', name.lower())
slug = re.sub(r'[-\s]+', '-', slug)
# 检查是否已存在
conn = self._get_connection()
try:
cursor = conn.cursor()
base_slug = slug
counter = 1
while True:
cursor.execute("SELECT id FROM tenants WHERE slug = ?", (slug,))
if not cursor.fetchone():
break
slug = f"{base_slug}-{counter}"
counter += 1
return slug
finally:
conn.close()
def _generate_verification_token(self, tenant_id: str, domain: str) -> str:
"""生成域名验证令牌"""
data = f"{tenant_id}:{domain}:{datetime.now().isoformat()}"
return hashlib.sha256(data.encode()).hexdigest()[:32]
def _validate_domain(self, domain: str) -> bool:
"""验证域名格式"""
pattern = r'^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])$'
return bool(re.match(pattern, domain))
def _check_domain_verification(self, domain: str, token: str, method: str) -> bool:
"""检查域名验证状态"""
# 这里应该实现实际的 DNS 查询或 HTTP 请求
# 简化实现:模拟验证成功
# 实际部署时需要使用 dnspython 或 requests 进行真实验证
if method == "dns":
# TODO: 实现 DNS TXT 记录查询
# import dns.resolver
# try:
# answers = dns.resolver.resolve(f"_insightflow.{domain}", 'TXT')
# for rdata in answers:
# if token in str(rdata):
# return True
# except Exception:
# pass
return True # 模拟成功
elif method == "file":
# TODO: 实现 HTTP 文件验证
# import requests
# try:
# response = requests.get(f"http://{domain}/.well-known/insightflow-verify.txt", timeout=10)
# if response.status_code == 200 and token in response.text:
# return True
# except Exception:
# pass
return True # 模拟成功
return False
def _darken_color(self, hex_color: str, percent: int) -> str:
"""加深颜色"""
hex_color = hex_color.lstrip('#')
r = int(hex_color[0:2], 16)
g = int(hex_color[2:4], 16)
b = int(hex_color[4:6], 16)
r = int(r * (100 - percent) / 100)
g = int(g * (100 - percent) / 100)
b = int(b * (100 - percent) / 100)
return f"#{r:02x}{g:02x}{b:02x}"
def _calc_percentage(self, current: int, limit: int) -> float:
"""计算使用百分比"""
if limit <= 0:
return 0.0 if limit == 0 else 100.0
return min(100.0, round(current / limit * 100, 2))
def _add_member_internal(self, conn: sqlite3.Connection, tenant_id: str,
user_id: str, email: str, role: TenantRole,
invited_by: Optional[str]):
"""内部方法:添加成员"""
cursor = conn.cursor()
member_id = str(uuid.uuid4())
cursor.execute("""
INSERT OR IGNORE INTO tenant_members
(id, tenant_id, user_id, email, role, permissions, invited_by,
invited_at, joined_at, last_active_at, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
member_id, tenant_id, user_id, email, role.value,
json.dumps(self.ROLE_PERMISSIONS.get(role, [])),
invited_by, datetime.now(), datetime.now(), datetime.now(), "active"
))
def _row_to_tenant(self, row: sqlite3.Row) -> Tenant:
"""数据库行转换为 Tenant 对象"""
return Tenant(
id=row['id'],
name=row['name'],
slug=row['slug'],
description=row['description'],
tier=row['tier'],
status=row['status'],
owner_id=row['owner_id'],
created_at=datetime.fromisoformat(
row['created_at']) if isinstance(
row['created_at'],
str) else row['created_at'],
updated_at=datetime.fromisoformat(
row['updated_at']) if isinstance(
row['updated_at'],
str) else row['updated_at'],
expires_at=datetime.fromisoformat(
row['expires_at']) if row['expires_at'] and isinstance(
row['expires_at'],
str) else row['expires_at'],
settings=json.loads(
row['settings'] or '{}'),
resource_limits=json.loads(
row['resource_limits'] or '{}'),
metadata=json.loads(
row['metadata'] or '{}'))
def _row_to_domain(self, row: sqlite3.Row) -> TenantDomain:
"""数据库行转换为 TenantDomain 对象"""
return TenantDomain(
id=row['id'],
tenant_id=row['tenant_id'],
domain=row['domain'],
status=row['status'],
verification_token=row['verification_token'],
verification_method=row['verification_method'],
verified_at=datetime.fromisoformat(
row['verified_at']) if row['verified_at'] and isinstance(
row['verified_at'],
str) else row['verified_at'],
created_at=datetime.fromisoformat(
row['created_at']) if isinstance(
row['created_at'],
str) else row['created_at'],
updated_at=datetime.fromisoformat(
row['updated_at']) if isinstance(
row['updated_at'],
str) else row['updated_at'],
is_primary=bool(
row['is_primary']),
ssl_enabled=bool(
row['ssl_enabled']),
ssl_expires_at=datetime.fromisoformat(
row['ssl_expires_at']) if row['ssl_expires_at'] and isinstance(
row['ssl_expires_at'],
str) else row['ssl_expires_at'])
def _row_to_branding(self, row: sqlite3.Row) -> TenantBranding:
"""数据库行转换为 TenantBranding 对象"""
return TenantBranding(
id=row['id'],
tenant_id=row['tenant_id'],
logo_url=row['logo_url'],
favicon_url=row['favicon_url'],
primary_color=row['primary_color'],
secondary_color=row['secondary_color'],
custom_css=row['custom_css'],
custom_js=row['custom_js'],
login_page_bg=row['login_page_bg'],
email_template=row['email_template'],
created_at=datetime.fromisoformat(
row['created_at']) if isinstance(
row['created_at'],
str) else row['created_at'],
updated_at=datetime.fromisoformat(
row['updated_at']) if isinstance(
row['updated_at'],
str) else row['updated_at'])
def _row_to_member(self, row: sqlite3.Row) -> TenantMember:
"""数据库行转换为 TenantMember 对象"""
return TenantMember(
id=row['id'],
tenant_id=row['tenant_id'],
user_id=row['user_id'],
email=row['email'],
role=row['role'],
permissions=json.loads(
row['permissions'] or '[]'),
invited_by=row['invited_by'],
invited_at=datetime.fromisoformat(
row['invited_at']) if isinstance(
row['invited_at'],
str) else row['invited_at'],
joined_at=datetime.fromisoformat(
row['joined_at']) if row['joined_at'] and isinstance(
row['joined_at'],
str) else row['joined_at'],
last_active_at=datetime.fromisoformat(
row['last_active_at']) if row['last_active_at'] and isinstance(
row['last_active_at'],
str) else row['last_active_at'],
status=row['status'])
# ==================== 租户上下文管理 ====================
class TenantContext:
"""租户上下文管理器 - 用于请求级别的租户隔离"""
_current_tenant_id: Optional[str] = None
_current_user_id: Optional[str] = None
@classmethod
def set_current_tenant(cls, tenant_id: str):
"""设置当前租户上下文"""
cls._current_tenant_id = tenant_id
@classmethod
def get_current_tenant(cls) -> Optional[str]:
"""获取当前租户ID"""
return cls._current_tenant_id
@classmethod
def set_current_user(cls, user_id: str):
"""设置当前用户"""
cls._current_user_id = user_id
@classmethod
def get_current_user(cls) -> Optional[str]:
"""获取当前用户ID"""
return cls._current_user_id
@classmethod
def clear(cls):
"""清除上下文"""
cls._current_tenant_id = None
cls._current_user_id = None
# 全局租户管理器实例
tenant_manager = None
def get_tenant_manager(db_path: str = "insightflow.db") -> TenantManager:
"""获取租户管理器实例(单例模式)"""
global tenant_manager
if tenant_manager is None:
tenant_manager = TenantManager(db_path)
return tenant_manager