- 从服务器拉取完整代码 - 按框架规范整理项目结构 - 配置 Drone CI 测试环境部署 - 包含后端(FastAPI)、前端(Vue3)、管理端 技术栈: Vue3 + TypeScript + FastAPI + MySQL
422 lines
14 KiB
Python
422 lines
14 KiB
Python
"""
|
||
租户配置加载器
|
||
|
||
功能:
|
||
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)
|
||
|