feat: 初始化考培练系统项目
- 从服务器拉取完整代码 - 按框架规范整理项目结构 - 配置 Drone CI 测试环境部署 - 包含后端(FastAPI)、前端(Vue3)、管理端 技术栈: Vue3 + TypeScript + FastAPI + MySQL
This commit is contained in:
421
backend/app/core/tenant_config.py
Normal file
421
backend/app/core/tenant_config.py
Normal file
@@ -0,0 +1,421 @@
|
||||
"""
|
||||
租户配置加载器
|
||||
|
||||
功能:
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user