1661 lines
55 KiB
Python
1661 lines
55 KiB
Python
"""
|
||
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
|