""" InsightFlow Phase 8 - 多租户 SaaS 架构管理模块 功能: 1. 租户隔离(数据、配置、资源完全隔离) 2. 自定义域名绑定(CNAME 支持) 3. 品牌白标(Logo、主题色、自定义 CSS) 4. 租户级权限管理 作者: InsightFlow Team """ import hashlib import json import logging import re import sqlite3 import uuid from dataclasses import asdict, dataclass from datetime import datetime from enum import StrEnum from typing import Any logger = logging.getLogger(__name__) 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 class TenantStatus(StrEnum): """租户状态""" ACTIVE = "active" # 活跃 SUSPENDED = "suspended" # 暂停 TRIAL = "trial" # 试用 EXPIRED = "expired" # 过期 PENDING = "pending" # 待激活 class TenantTier(StrEnum): """租户订阅层级""" FREE = "free" # 免费版 PRO = "pro" # 专业版 ENTERPRISE = "enterprise" # 企业版 class TenantRole(StrEnum): """租户角色""" OWNER = "owner" # 所有者 ADMIN = "admin" # 管理员 MEMBER = "member" # 成员 VIEWER = "viewer" # 查看者 class DomainStatus(StrEnum): """域名状态""" PENDING = "pending" # 待验证 VERIFIED = "verified" # 已验证 FAILED = "failed" # 验证失败 EXPIRED = "expired" # 已过期 @dataclass class Tenant: """租户数据类""" id: str name: str slug: str # URL 友好的唯一标识 description: str | None tier: str # free/pro/enterprise status: str # active/suspended/trial/expired/pending owner_id: str # 所有者用户ID created_at: datetime updated_at: datetime expires_at: datetime | None # 订阅过期时间 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: datetime | None created_at: datetime updated_at: datetime is_primary: bool # 是否主域名 ssl_enabled: bool # SSL 是否启用 ssl_expires_at: datetime | None @dataclass class TenantBranding: """租户品牌配置数据类""" id: str tenant_id: str logo_url: str | None # Logo URL favicon_url: str | None # Favicon URL primary_color: str | None # 主题主色 secondary_color: str | None # 主题次色 custom_css: str | None # 自定义 CSS custom_js: str | None # 自定义 JS login_page_bg: str | None # 登录页背景 email_template: str | None # 邮件模板 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: str | None # 邀请者 invited_at: datetime joined_at: datetime | None last_active_at: datetime | None status: str # active/pending/suspended @dataclass class TenantPermission: """租户权限定义数据类""" id: str tenant_id: str name: str # 权限名称 code: str # 权限代码 description: str | None resource_type: str # project/entity/api/etc actions: list[str] # create/read/update/delete/etc conditions: dict | None # 条件限制 created_at: datetime 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") -> None: 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) -> None: """初始化数据库表""" 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 (RuntimeError, ValueError, TypeError) 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: str | None = None, settings: dict | None = 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) -> Tenant | None: """获取租户信息""" 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) -> Tenant | None: """通过 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) -> Tenant | None: """通过自定义域名获取租户""" 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: str | None = None, description: str | None = None, tier: str | None = None, status: str | None = None, settings: dict | None = None, ) -> Tenant | None: """更新租户信息""" 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: str | None = None, tier: str | None = 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 (RuntimeError, ValueError, TypeError) 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) -> TenantBranding | None: """获取租户品牌配置""" 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: str | None = None, favicon_url: str | None = None, primary_color: str | None = None, secondary_color: str | None = None, custom_css: str | None = None, custom_js: str | None = None, login_page_bg: str | None = None, email_template: str | None = 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: list[str] | None = 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: list[str] | None = 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: str | None = 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, ) -> None: """记录资源使用""" 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: datetime | None = None, end_date: datetime | None = 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])?\.)*" r"[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 (ImportError, 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 (ImportError, 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: str | None, ) -> None: """内部方法:添加成员""" 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: str | None = None _current_user_id: str | None = None @classmethod def set_current_tenant(cls, tenant_id: str) -> None: """设置当前租户上下文""" cls._current_tenant_id = tenant_id @classmethod def get_current_tenant(cls) -> str | None: """获取当前租户ID""" return cls._current_tenant_id @classmethod def set_current_user(cls, user_id: str) -> None: """设置当前用户""" cls._current_user_id = user_id @classmethod def get_current_user(cls) -> str | None: """获取当前用户ID""" return cls._current_user_id @classmethod def clear(cls) -> None: """清除上下文""" 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