"""Application settings management service.""" from typing import Any from cryptography.fernet import Fernet, InvalidToken from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.config import settings from app.core.logging import get_logger from app.models.setting import AppSetting logger = get_logger(__name__) EDITABLE_SETTINGS = { "RSSKEEPER_BASE_URL": {"description": "rssKeeper 服务地址", "sensitive": False}, "OPENAI_API_KEY": {"description": "LLM API Key", "sensitive": True}, "OPENAI_BASE_URL": {"description": "LLM API 基础地址", "sensitive": False}, "OPENAI_MODEL": {"description": "LLM 模型名", "sensitive": False}, "OPENAI_TIMEOUT": {"description": "LLM 调用超时(秒)", "sensitive": False}, "OPENAI_MAX_RETRIES": {"description": "LLM 最大重试次数", "sensitive": False}, "SUMMARIZE_INTERVAL_MINUTES": {"description": "摘要任务间隔(分钟)", "sensitive": False}, "TAG_SCORE_INTERVAL_MINUTES": {"description": "分类/打分/去重任务间隔(分钟)", "sensitive": False}, "DAILY_BRIEF_HOUR": {"description": "每日简报生成小时", "sensitive": False}, "DAILY_BRIEF_MINUTE": {"description": "每日简报生成分钟", "sensitive": False}, "TITLE_SIMILARITY_THRESHOLD": {"description": "标题相似度阈值", "sensitive": False}, "CONTENT_SIMILARITY_THRESHOLD": {"description": "内容相似度阈值", "sensitive": False}, "MAX_AI_SUMMARY_LENGTH": {"description": "AI 摘要最大长度", "sensitive": False}, "MIN_ORIGINAL_SUMMARY_LENGTH": {"description": "原始摘要最小长度", "sensitive": False}, "BRIEF_TOP_N_PER_CATEGORY": {"description": "简报每分类显示文章数", "sensitive": False}, "LOG_LEVEL": {"description": "日志级别", "sensitive": False}, "API_TOKEN": {"description": "API 鉴权 Token(为空时不启用)", "sensitive": True}, "CORS_ALLOWED_ORIGINS": {"description": "CORS 允许来源(逗号分隔)", "sensitive": False}, } # Prefix to detect encrypted values _ENC_PREFIX = "enc:" def _get_fernet() -> Fernet | None: """Get Fernet instance if encryption key is configured.""" key = settings.SETTINGS_ENCRYPTION_KEY if not key: return None try: return Fernet(key.encode() if isinstance(key, str) else key) except Exception as exc: logger.error("SETTINGS_ENCRYPTION_KEY 无效: %s", exc) return None def _encrypt(value: str) -> str: """Encrypt a sensitive value if encryption is enabled.""" if not value: return value fernet = _get_fernet() if fernet is None: return value return _ENC_PREFIX + fernet.encrypt(value.encode()).decode() def _decrypt(value: str) -> str: """Decrypt a sensitive value if it was encrypted.""" if not value or not value.startswith(_ENC_PREFIX): return value fernet = _get_fernet() if fernet is None: logger.warning("发现加密配置值但 SETTINGS_ENCRYPTION_KEY 未配置,无法解密") return value try: ciphertext = value[len(_ENC_PREFIX):].encode() return fernet.decrypt(ciphertext).decode() except InvalidToken: logger.warning("配置值解密失败(token 无效)") return value except Exception as exc: logger.error("配置值解密失败: %s", exc) return value def _get_env_default(key: str) -> str: """Get default value from environment/settings.""" value = getattr(settings, key, "") return str(value) if value is not None else "" def _mask_sensitive(value: str) -> str: """Mask sensitive value for display.""" if not value: return "" if len(value) <= 8: return "*" * len(value) return f"{value[:4]}...{value[-4:]}" async def init_default_settings(db: AsyncSession) -> None: """Initialize default settings from environment if table is empty.""" result = await db.execute(select(AppSetting)) existing = result.scalars().first() if existing: return for key, meta in EDITABLE_SETTINGS.items(): default_value = _get_env_default(key) stored_value = _encrypt(default_value) if meta["sensitive"] else default_value db.add( AppSetting( key=key, value=stored_value, description=meta["description"], is_sensitive=meta["sensitive"], ) ) await db.commit() logger.info("已初始化默认配置项: %d 条", len(EDITABLE_SETTINGS)) async def _get_raw_setting(db: AsyncSession, key: str) -> AppSetting | None: """Get setting row from DB.""" result = await db.execute(select(AppSetting).where(AppSetting.key == key)) return result.scalar_one_or_none() async def get_setting(db: AsyncSession, key: str, default: Any = None) -> Any: """Get decrypted setting value from DB or env default.""" setting = await _get_raw_setting(db, key) if setting: return _decrypt(setting.value) if setting.is_sensitive else setting.value return _get_env_default(key) if default is None else default async def set_setting(db: AsyncSession, key: str, value: str) -> bool: """Update a setting (encrypt sensitive values).""" if key not in EDITABLE_SETTINGS: return False meta = EDITABLE_SETTINGS[key] stored_value = _encrypt(str(value)) if meta["sensitive"] else str(value) setting = await _get_raw_setting(db, key) if setting: setting.value = stored_value else: setting = AppSetting( key=key, value=stored_value, description=meta["description"], is_sensitive=meta["sensitive"], ) db.add(setting) await db.commit() logger.info("配置已更新: %s", key) return True async def list_settings(db: AsyncSession, mask_sensitive: bool = True) -> list[dict[str, Any]]: """List all settings.""" result = await db.execute(select(AppSetting)) db_settings = {s.key: s for s in result.scalars().all()} output = [] for key, meta in EDITABLE_SETTINGS.items(): setting = db_settings.get(key) is_sensitive = meta["sensitive"] if setting: raw_value = setting.value updated_at = setting.updated_at.isoformat() if setting.updated_at else None else: raw_value = _get_env_default(key) updated_at = None decrypted_value = _decrypt(raw_value) if is_sensitive else raw_value if is_sensitive and mask_sensitive: display_value = _mask_sensitive(decrypted_value) is_masked = True else: display_value = decrypted_value is_masked = False output.append({ "key": key, "value": display_value, "real_value": decrypted_value if not mask_sensitive else None, "description": meta["description"], "is_sensitive": is_sensitive, "is_masked": is_masked, "updated_at": updated_at, }) return output async def apply_db_settings_to_config(db: AsyncSession) -> None: """Apply DB settings to runtime config.""" for key in EDITABLE_SETTINGS: db_value = await get_setting(db, key) if db_value is None or db_value == "": continue field_info = settings.model_fields.get(key) if field_info is None: continue target_type = field_info.annotation try: if target_type is int: converted = int(db_value) elif target_type is float: converted = float(db_value) elif target_type is bool: converted = db_value.lower() in ("true", "1", "yes") else: converted = db_value setattr(settings, key, converted) except Exception as exc: logger.error("应用配置 %s=%s 失败: %s", key, db_value, exc) raise ValueError(f"配置项 {key} 的值无效: {db_value}") from exc async def reset_settings(db: AsyncSession) -> None: """Reset all settings to env defaults.""" for key in EDITABLE_SETTINGS: await set_setting(db, key, _get_env_default(key)) logger.info("配置已重置为环境变量默认值")