""" 租户配置加载器 功能: 1. 从数据库 tenant_configs 表加载租户配置 2. 支持 Redis 缓存 3. 数据库不可用时回退到环境变量 4. 支持配置热更新 """ import os import json import logging from typing import Optional, Dict, Any from functools import lru_cache import aiomysql import redis.asyncio as redis logger = logging.getLogger(__name__) # ============================================ # 平台管理库连接配置 # # 注意:敏感信息必须通过环境变量传递,禁止硬编码 # 参考:瑞小美系统技术栈标准与字符标准.md - 敏感信息管理 # ============================================ ADMIN_DB_CONFIG = { "host": os.getenv("ADMIN_DB_HOST", "prod-mysql"), "port": int(os.getenv("ADMIN_DB_PORT", "3306")), "user": os.getenv("ADMIN_DB_USER", "root"), "password": os.getenv("ADMIN_DB_PASSWORD"), # 必须从环境变量获取 "db": os.getenv("ADMIN_DB_NAME", "kaopeilian_admin"), "charset": "utf8mb4", } # 校验必填环境变量 if not ADMIN_DB_CONFIG["password"]: logger.warning( "ADMIN_DB_PASSWORD 环境变量未设置,租户配置加载功能将不可用。" "请在 .env.admin 文件中配置此变量。" ) # Redis 缓存配置 CACHE_PREFIX = "tenant_config:" CACHE_TTL = 300 # 5分钟缓存 class TenantConfigLoader: """租户配置加载器""" def __init__(self, tenant_code: str, redis_client: Optional[redis.Redis] = None): """ 初始化租户配置加载器 Args: tenant_code: 租户编码(如 hua, yy, hl) redis_client: Redis 客户端(可选) """ self.tenant_code = tenant_code self.redis_client = redis_client self._config_cache: Dict[str, Any] = {} self._tenant_id: Optional[int] = None async def get_config(self, config_group: str, config_key: str, default: Any = None) -> Any: """ 获取配置项 优先级: 1. 内存缓存 2. Redis 缓存 3. 数据库 4. 环境变量 5. 默认值 Args: config_group: 配置分组(database, redis, coze, ai, yanji, security) config_key: 配置键 default: 默认值 Returns: 配置值 """ cache_key = f"{config_group}.{config_key}" # 1. 内存缓存 if cache_key in self._config_cache: return self._config_cache[cache_key] # 2. Redis 缓存 if self.redis_client: try: redis_key = f"{CACHE_PREFIX}{self.tenant_code}:{cache_key}" cached_value = await self.redis_client.get(redis_key) if cached_value: value = json.loads(cached_value) self._config_cache[cache_key] = value return value except Exception as e: logger.warning(f"Redis 缓存读取失败: {e}") # 3. 数据库 try: value = await self._get_from_database(config_group, config_key) if value is not None: self._config_cache[cache_key] = value # 写入 Redis 缓存 if self.redis_client: try: redis_key = f"{CACHE_PREFIX}{self.tenant_code}:{cache_key}" await self.redis_client.setex( redis_key, CACHE_TTL, json.dumps(value) ) except Exception as e: logger.warning(f"Redis 缓存写入失败: {e}") return value except Exception as e: logger.warning(f"数据库配置读取失败: {e}") # 4. 环境变量 env_value = os.getenv(config_key) if env_value is not None: return env_value # 5. 默认值 return default async def _get_from_database(self, config_group: str, config_key: str) -> Optional[Any]: """从数据库获取配置""" conn = None try: conn = await aiomysql.connect(**ADMIN_DB_CONFIG) async with conn.cursor(aiomysql.DictCursor) as cursor: # 获取租户 ID if self._tenant_id is None: await cursor.execute( "SELECT id FROM tenants WHERE code = %s AND status = 'active'", (self.tenant_code,) ) row = await cursor.fetchone() if row: self._tenant_id = row['id'] else: return None # 获取配置值 await cursor.execute( """ SELECT config_value, value_type, is_encrypted FROM tenant_configs WHERE tenant_id = %s AND config_group = %s AND config_key = %s """, (self._tenant_id, config_group, config_key) ) row = await cursor.fetchone() if row: return self._parse_value(row['config_value'], row['value_type'], row['is_encrypted']) # 如果租户没有配置,获取默认值 await cursor.execute( """ SELECT default_value, value_type FROM config_templates WHERE config_group = %s AND config_key = %s """, (config_group, config_key) ) row = await cursor.fetchone() if row and row['default_value']: return self._parse_value(row['default_value'], row['value_type'], False) return None finally: if conn: conn.close() def _parse_value(self, value: str, value_type: str, is_encrypted: bool) -> Any: """解析配置值""" if value is None: return None # TODO: 如果是加密值,先解密 if is_encrypted: # 这里可以实现解密逻辑 pass if value_type == 'int': return int(value) elif value_type == 'bool': return value.lower() in ('true', '1', 'yes') elif value_type == 'json': return json.loads(value) elif value_type == 'float': return float(value) else: return value async def get_all_configs(self) -> Dict[str, Any]: """获取租户的所有配置""" configs = {} conn = None try: conn = await aiomysql.connect(**ADMIN_DB_CONFIG) async with conn.cursor(aiomysql.DictCursor) as cursor: # 获取租户 ID await cursor.execute( "SELECT id FROM tenants WHERE code = %s AND status = 'active'", (self.tenant_code,) ) row = await cursor.fetchone() if not row: return configs tenant_id = row['id'] # 获取所有配置 await cursor.execute( """ SELECT config_group, config_key, config_value, value_type, is_encrypted FROM tenant_configs WHERE tenant_id = %s """, (tenant_id,) ) rows = await cursor.fetchall() for row in rows: key = f"{row['config_group']}.{row['config_key']}" configs[key] = self._parse_value( row['config_value'], row['value_type'], row['is_encrypted'] ) return configs finally: if conn: conn.close() async def refresh_cache(self): """刷新缓存""" self._config_cache.clear() if self.redis_client: try: # 删除该租户的所有缓存 pattern = f"{CACHE_PREFIX}{self.tenant_code}:*" cursor = 0 while True: cursor, keys = await self.redis_client.scan(cursor, match=pattern, count=100) if keys: await self.redis_client.delete(*keys) if cursor == 0: break except Exception as e: logger.warning(f"Redis 缓存刷新失败: {e}") async def is_feature_enabled(self, feature_code: str) -> bool: """ 检查功能是否启用 Args: feature_code: 功能编码 Returns: 是否启用 """ conn = None try: conn = await aiomysql.connect(**ADMIN_DB_CONFIG) async with conn.cursor(aiomysql.DictCursor) as cursor: # 获取租户 ID if self._tenant_id is None: await cursor.execute( "SELECT id FROM tenants WHERE code = %s AND status = 'active'", (self.tenant_code,) ) row = await cursor.fetchone() if row: self._tenant_id = row['id'] # 先查租户级别的配置 if self._tenant_id: await cursor.execute( """ SELECT is_enabled FROM feature_switches WHERE tenant_id = %s AND feature_code = %s """, (self._tenant_id, feature_code) ) row = await cursor.fetchone() if row: return bool(row['is_enabled']) # 再查全局默认配置 await cursor.execute( """ SELECT is_enabled FROM feature_switches WHERE tenant_id IS NULL AND feature_code = %s """, (feature_code,) ) row = await cursor.fetchone() if row: return bool(row['is_enabled']) return True # 默认启用 except Exception as e: logger.warning(f"功能开关查询失败: {e}, 默认启用") return True finally: if conn: conn.close() class TenantConfigManager: """租户配置管理器(单例)""" _instances: Dict[str, TenantConfigLoader] = {} _redis_client: Optional[redis.Redis] = None @classmethod async def init_redis(cls, redis_url: str): """初始化 Redis 连接""" try: cls._redis_client = redis.from_url(redis_url) await cls._redis_client.ping() logger.info("TenantConfigManager Redis 连接成功") except Exception as e: logger.warning(f"TenantConfigManager Redis 连接失败: {e}") cls._redis_client = None @classmethod def get_loader(cls, tenant_code: str) -> TenantConfigLoader: """获取租户配置加载器""" if tenant_code not in cls._instances: cls._instances[tenant_code] = TenantConfigLoader( tenant_code, cls._redis_client ) return cls._instances[tenant_code] @classmethod async def refresh_tenant_cache(cls, tenant_code: str): """刷新指定租户的缓存""" if tenant_code in cls._instances: await cls._instances[tenant_code].refresh_cache() @classmethod async def refresh_all_cache(cls): """刷新所有租户的缓存""" for loader in cls._instances.values(): await loader.refresh_cache() # ============================================ # 辅助函数 # ============================================ def get_tenant_code_from_domain(domain: str) -> str: """ 从域名提取租户编码 Examples: hua.ireborn.com.cn -> hua yy.ireborn.com.cn -> yy aiedu.ireborn.com.cn -> demo """ if not domain: return "demo" # 移除 https:// 或 http:// domain = domain.replace("https://", "").replace("http://", "") # 获取子域名 parts = domain.split(".") if len(parts) >= 3: subdomain = parts[0] # 特殊处理 if subdomain == "aiedu": return "demo" return subdomain return "demo" async def get_tenant_config(tenant_code: str, config_group: str, config_key: str, default: Any = None) -> Any: """ 快捷函数:获取租户配置 Args: tenant_code: 租户编码 config_group: 配置分组 config_key: 配置键 default: 默认值 Returns: 配置值 """ loader = TenantConfigManager.get_loader(tenant_code) return await loader.get_config(config_group, config_key, default) async def is_tenant_feature_enabled(tenant_code: str, feature_code: str) -> bool: """ 快捷函数:检查租户功能是否启用 Args: tenant_code: 租户编码 feature_code: 功能编码 Returns: 是否启用 """ loader = TenantConfigManager.get_loader(tenant_code) return await loader.is_feature_enabled(feature_code)