#!/usr/bin/env python # -*- coding: utf-8 -*- """ 配置管理器 - 三级配置获取工具 优先级: Redis 缓存 → 数据库 → env 配置文件 使用方式: from app.config_manager import config_manager # 获取单个配置 value = await config_manager.get("notify_email", "smtp_host") # 获取整个分组 email_config = await config_manager.get_group("notify_email") # 设置配置(同时写入数据库和 Redis) await config_manager.set("notify_email", "smtp_host", "smtp.qq.com", db=db) """ import json import logging from typing import Any, Dict, List, Optional from utils.redis import CacheManager logger = logging.getLogger(__name__) # Redis 缓存过期时间(秒),1 小时 CONFIG_CACHE_EXPIRE = 3600 # 配置分组到 env 配置文件字段的映射 # 格式: { "config_group": { "config_key": "SETTINGS_ATTR_NAME" } } GROUP_ENV_MAPPING: Dict[str, Dict[str, str]] = { # ===== OAuth SSO 配置 ===== "oauth_gitee": { "client_id": "GITEE_CLIENT_ID", "client_secret": "GITEE_CLIENT_SECRET", "redirect_uri": "GITEE_REDIRECT_URI", }, "oauth_github": { "client_id": "GITHUB_CLIENT_ID", "client_secret": "GITHUB_CLIENT_SECRET", "redirect_uri": "GITHUB_REDIRECT_URI", }, "oauth_qq": { "app_id": "QQ_APP_ID", "app_key": "QQ_APP_KEY", "redirect_uri": "QQ_REDIRECT_URI", }, "oauth_google": { "client_id": "GOOGLE_CLIENT_ID", "client_secret": "GOOGLE_CLIENT_SECRET", "redirect_uri": "GOOGLE_REDIRECT_URI", }, "oauth_wechat": { "app_id": "WECHAT_APP_ID", "app_secret": "WECHAT_APP_SECRET", "redirect_uri": "WECHAT_REDIRECT_URI", }, "oauth_microsoft": { "client_id": "MICROSOFT_CLIENT_ID", "client_secret": "MICROSOFT_CLIENT_SECRET", "redirect_uri": "MICROSOFT_REDIRECT_URI", }, "oauth_dingtalk": { "app_id": "DINGTALK_APP_ID", "app_secret": "DINGTALK_APP_SECRET", "redirect_uri": "DINGTALK_REDIRECT_URI", "h5_redirect_uri": "DINGTALK_H5_REDIRECT_URI", }, "oauth_feishu": { "app_id": "FEISHU_APP_ID", "app_secret": "FEISHU_APP_SECRET", "redirect_uri": "FEISHU_REDIRECT_URI", "h5_redirect_uri": "FEISHU_H5_REDIRECT_URI", }, "oauth_wecom": { "corp_id": "WECOM_CORP_ID", "agent_id": "WECOM_AGENT_ID", "app_secret": "WECOM_APP_SECRET", "redirect_uri": "WECOM_REDIRECT_URI", "h5_redirect_uri": "WECOM_H5_REDIRECT_URI", }, # ===== 消息通知配置 ===== "notify_email": { "smtp_host": "SMTP_HOST", "smtp_port": "SMTP_PORT", "smtp_user": "SMTP_USER", "smtp_password": "SMTP_PASSWORD", "smtp_use_tls": "SMTP_USE_TLS", "smtp_from_name": "SMTP_FROM_NAME", "smtp_from_email": "SMTP_FROM_EMAIL", }, "notify_dingtalk": { "webhook_url": "DINGTALK_WEBHOOK_URL", "webhook_secret": "DINGTALK_WEBHOOK_SECRET", "agent_id": "DINGTALK_AGENT_ID", "corp_id": "DINGTALK_CORP_ID", "todo_pc_url": "DINGTALK_TODO_PC_URL", "todo_app_url": "DINGTALK_TODO_APP_URL", }, "notify_feishu": { "webhook_url": "FEISHU_WEBHOOK_URL", "webhook_secret": "FEISHU_WEBHOOK_SECRET", }, "notify_wecom": { "webhook_url": "WECOM_WEBHOOK_URL", }, "notify_wechat_mp": { "template_id": "WECHAT_MP_TEMPLATE_ID", "url": "WECHAT_MP_URL", "mini_appid": "WECHAT_MP_MINI_APPID", "mini_page": "WECHAT_MP_MINI_PAGE", }, # ===== 钉钉组织架构同步配置 ===== "sync_dingtalk": { "corp_id": "DINGTALK_CORP_ID", "app_key": "DINGTALK_APP_KEY", "app_secret": "DINGTALK_APP_SECRET", "sync_dept_id": "", "sync_root_dept_id": "", "enable_dept_event": "", "enable_user_event": "", "callback_token": "", "callback_aes_key": "", "callback_url": "", }, # ===== 企业微信组织架构同步配置 ===== "sync_wecom": { "corp_id": "WECOM_SYNC_CORP_ID", "corp_secret": "WECOM_SYNC_CORP_SECRET", "sync_dept_id": "", "sync_root_dept_id": "", "enable_dept_event": "", "enable_user_event": "", "callback_token": "", "callback_aes_key": "", "callback_url": "", }, # ===== 飞书组织架构同步配置 ===== "sync_feishu": { "app_id": "FEISHU_SYNC_APP_ID", "app_secret": "FEISHU_SYNC_APP_SECRET", "sync_dept_id": "", "sync_root_dept_id": "", "enable_dept_event": "", "enable_user_event": "", "encrypt_key": "", "verification_token": "", "callback_url": "", }, "notify_sms": { "provider": "SMS_PROVIDER", "aliyun_access_key_id": "ALIYUN_SMS_ACCESS_KEY_ID", "aliyun_access_key_secret": "ALIYUN_SMS_ACCESS_KEY_SECRET", "aliyun_sign_name": "ALIYUN_SMS_SIGN_NAME", "aliyun_template_code": "ALIYUN_SMS_TEMPLATE_CODE", "tencent_secret_id": "TENCENT_SMS_SECRET_ID", "tencent_secret_key": "TENCENT_SMS_SECRET_KEY", "tencent_sdk_app_id": "TENCENT_SMS_SDK_APP_ID", "tencent_sign_name": "TENCENT_SMS_SIGN_NAME", "tencent_template_id": "TENCENT_SMS_TEMPLATE_ID", }, } # 敏感字段列表(API 返回时脱敏) SECRET_KEYS = { "client_secret", "app_secret", "app_key", "corp_secret", "smtp_password", "webhook_secret", "aliyun_access_key_secret", "tencent_secret_key", "callback_token", "callback_aes_key", "encrypt_key", "verification_token", } class ConfigManager: """ 三级配置管理器 获取优先级: Redis → 数据库 → env 配置文件 写入: 同时写入数据库 + Redis """ def __init__(self): self._cache = CacheManager(prefix="sys_config:") # ========== 读取 ========== async def get(self, group: str, key: str) -> Optional[str]: """ 获取单个配置值 :param group: 配置分组 :param key: 配置键 :return: 配置值,三级都没有返回 None """ # 1. Redis cache_key = f"{group}:{key}" value = await self._cache.get(cache_key) if value is not None: return value # 2. 数据库 value = await self._get_from_db(group, key) if value is not None: await self._cache.set(cache_key, value, expire=CONFIG_CACHE_EXPIRE) return value # 3. env 配置文件 value = self._get_from_env(group, key) if value is not None: # 回写到 Redis 缓存 await self._cache.set(cache_key, value, expire=CONFIG_CACHE_EXPIRE) return value async def get_group(self, group: str) -> Dict[str, Any]: """ 获取整个分组的配置 :param group: 配置分组 :return: {key: value} 字典 """ # 先尝试从 Redis 获取整个分组 group_cache_key = f"_group_:{group}" cached = await self._cache.get(group_cache_key) if cached is not None and isinstance(cached, dict): return cached # 从数据库获取该分组所有配置 db_configs = await self._get_group_from_db(group) # 获取该分组的 env 映射 env_mapping = GROUP_ENV_MAPPING.get(group, {}) # 合并: 数据库值优先,缺失的用 env 补充 result = {} for key in env_mapping: if key in db_configs and db_configs[key] is not None and db_configs[key] != "": result[key] = db_configs[key] else: env_value = self._get_from_env(group, key) result[key] = env_value # 数据库中可能有 env_mapping 之外的自定义 key for key, value in db_configs.items(): if key not in result: result[key] = value # 缓存整个分组 await self._cache.set(group_cache_key, result, expire=CONFIG_CACHE_EXPIRE) return result async def get_all_groups(self) -> Dict[str, Dict[str, Any]]: """获取所有分组的配置""" result = {} for group in GROUP_ENV_MAPPING: result[group] = await self.get_group(group) return result # ========== 写入 ========== async def set(self, group: str, key: str, value: Optional[str], db_session=None) -> None: """ 设置单个配置值(写入数据库 + 更新 Redis) :param group: 配置分组 :param key: 配置键 :param value: 配置值 :param db_session: 数据库会话(可选,不传则自动创建) """ await self._set_to_db(group, key, value, db_session) # 更新 Redis 单个 key 缓存 cache_key = f"{group}:{key}" if value is not None: await self._cache.set(cache_key, value, expire=CONFIG_CACHE_EXPIRE) else: await self._cache.delete(cache_key) # 清除分组缓存(下次 get_group 会重新加载) await self._invalidate_group_cache(group) async def set_group(self, group: str, configs: Dict[str, Optional[str]], db_session=None) -> None: """ 批量设置分组配置 :param group: 配置分组 :param configs: {key: value} 字典 :param db_session: 数据库会话 """ for key, value in configs.items(): await self._set_to_db(group, key, value, db_session) cache_key = f"{group}:{key}" if value is not None: await self._cache.set(cache_key, value, expire=CONFIG_CACHE_EXPIRE) else: await self._cache.delete(cache_key) # 清除分组缓存 await self._invalidate_group_cache(group) # ========== 缓存管理 ========== async def invalidate(self, group: str, key: str) -> None: """清除单个配置的缓存""" await self._cache.delete(f"{group}:{key}") await self._invalidate_group_cache(group) async def invalidate_group(self, group: str) -> None: """清除整个分组的缓存""" env_mapping = GROUP_ENV_MAPPING.get(group, {}) for key in env_mapping: await self._cache.delete(f"{group}:{key}") await self._invalidate_group_cache(group) async def invalidate_all(self) -> None: """清除所有配置缓存""" await self._cache.delete_pattern("*") async def warmup(self) -> None: """ 预热: 启动时清除旧缓存,然后将数据库配置加载到 Redis """ logger.info("开始预热系统配置到 Redis...") try: await self.invalidate_all() for group in GROUP_ENV_MAPPING: await self.get_group(group) logger.info("系统配置预热完成") except Exception as e: logger.warning(f"系统配置预热失败(将使用 env 配置文件兜底): {e}") # ========== 内部方法 ========== async def _invalidate_group_cache(self, group: str) -> None: """清除分组级缓存""" await self._cache.delete(f"_group_:{group}") async def _get_from_db(self, group: str, key: str) -> Optional[str]: """从数据库获取配置""" try: from app.database import AsyncSessionLocal from core.system_config.model import SystemConfig from sqlalchemy import select async with AsyncSessionLocal() as db: result = await db.execute( select(SystemConfig.config_value).where( SystemConfig.config_group == group, SystemConfig.config_key == key, SystemConfig.status == True, # noqa: E712 SystemConfig.is_deleted == False, # noqa: E712 ) ) row = result.scalar_one_or_none() return row except Exception as e: logger.debug(f"从数据库获取配置失败 {group}.{key}: {e}") return None async def _get_group_from_db(self, group: str) -> Dict[str, str]: """从数据库获取整个分组""" try: from app.database import AsyncSessionLocal from core.system_config.model import SystemConfig from sqlalchemy import select async with AsyncSessionLocal() as db: result = await db.execute( select(SystemConfig.config_key, SystemConfig.config_value).where( SystemConfig.config_group == group, SystemConfig.status == True, # noqa: E712 SystemConfig.is_deleted == False, # noqa: E712 ) ) return {row[0]: row[1] for row in result.all()} except Exception as e: logger.debug(f"从数据库获取分组配置失败 {group}: {e}") return {} async def _set_to_db(self, group: str, key: str, value: Optional[str], db_session=None) -> None: """写入数据库(upsert)""" from core.system_config.model import SystemConfig from sqlalchemy import select async def _do_upsert(db): result = await db.execute( select(SystemConfig).where( SystemConfig.config_group == group, SystemConfig.config_key == key, SystemConfig.is_deleted == False, # noqa: E712 ) ) config = result.scalar_one_or_none() if config: config.config_value = value else: config = SystemConfig( config_group=group, config_key=key, config_value=value, is_secret=key in SECRET_KEYS, status=True, ) db.add(config) await db.commit() if db_session: await _do_upsert(db_session) else: from app.database import AsyncSessionLocal async with AsyncSessionLocal() as db: await _do_upsert(db) def _get_from_env(self, group: str, key: str) -> Optional[str]: """从 env 配置文件获取""" from app.config import settings env_mapping = GROUP_ENV_MAPPING.get(group, {}) attr_name = env_mapping.get(key) if not attr_name: return None value = getattr(settings, attr_name, None) if value is None: return None return str(value) # ========== 工具方法 ========== @staticmethod def mask_value(value: Optional[str]) -> Optional[str]: """脱敏处理""" if not value: return value if len(value) <= 6: return "***" return value[:3] + "***" + value[-3:] @staticmethod def get_group_list() -> List[Dict[str, str]]: """获取所有配置分组定义""" groups = [] for group_key in GROUP_ENV_MAPPING: groups.append({ "key": group_key, "fields": list(GROUP_ENV_MAPPING[group_key].keys()), }) return groups @staticmethod def is_secret_key(key: str) -> bool: """判断是否为敏感字段""" return key in SECRET_KEYS # 全局单例 config_manager = ConfigManager()