feat: 初始化考培练系统项目

- 从服务器拉取完整代码
- 按框架规范整理项目结构
- 配置 Drone CI 测试环境部署
- 包含后端(FastAPI)、前端(Vue3)、管理端

技术栈: Vue3 + TypeScript + FastAPI + MySQL
This commit is contained in:
111
2026-01-24 19:33:28 +08:00
commit 998211c483
1197 changed files with 228429 additions and 0 deletions

View 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)