Files
012-kaopeilian/backend/app/core/tenant_config.py
111 998211c483 feat: 初始化考培练系统项目
- 从服务器拉取完整代码
- 按框架规范整理项目结构
- 配置 Drone CI 测试环境部署
- 包含后端(FastAPI)、前端(Vue3)、管理端

技术栈: Vue3 + TypeScript + FastAPI + MySQL
2026-01-24 19:33:28 +08:00

422 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
租户配置加载器
功能:
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)