Files
dataClean/app/settings_manager.py
2026-06-12 16:04:03 +08:00

189 lines
6.9 KiB
Python

"""运行时配置管理:支持环境变量作为默认值,数据库覆盖"""
import logging
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional
from sqlalchemy.orm import Session
from config import settings
from models import AppSetting
logger = logging.getLogger(__name__)
# 可在 Web UI 中编辑的配置项清单
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},
}
def _get_env_default(key: str) -> str:
"""从 Pydantic Settings 获取环境变量默认值"""
value = getattr(settings, key, "")
return str(value) if value is not None else ""
def _mask_sensitive(value: str) -> str:
"""对敏感值做部分脱敏"""
if not value:
return ""
if len(value) <= 8:
return "*" * len(value)
return value[:4] + "..." + value[-4:]
def init_default_settings(db: Session) -> None:
"""若配置表为空,使用环境变量初始化默认配置"""
existing_count = db.query(AppSetting).count()
if existing_count > 0:
return
for key, meta in EDITABLE_SETTINGS.items():
default_value = _get_env_default(key)
db.add(
AppSetting(
key=key,
value=default_value,
description=meta["description"],
is_sensitive=meta["sensitive"],
)
)
db.commit()
logger.info("已初始化默认配置项: %d", len(EDITABLE_SETTINGS))
def get_setting(db: Session, key: str, default: Any = None) -> Any:
"""从数据库读取配置,若不存在则返回环境变量默认值"""
setting = db.query(AppSetting).filter(AppSetting.key == key).first()
if setting:
return setting.value
return _get_env_default(key) if default is None else default
def get_setting_value(key: str, default: Any = None) -> Any:
"""不依赖 Session,直接创建临时会话读取"""
from database import SessionLocal
db = SessionLocal()
try:
return get_setting(db, key, default)
finally:
db.close()
def set_setting(db: Session, key: str, value: str) -> bool:
"""更新单个配置项"""
if key not in EDITABLE_SETTINGS:
return False
setting = db.query(AppSetting).filter(AppSetting.key == key).first()
if setting:
setting.value = str(value)
setting.updated_at = datetime.now(timezone.utc)
else:
meta = EDITABLE_SETTINGS[key]
db.add(
AppSetting(
key=key,
value=str(value),
description=meta["description"],
is_sensitive=meta["sensitive"],
)
)
db.commit()
logger.info("配置已更新: %s", key)
return True
def list_settings(db: Session, mask_sensitive: bool = True) -> List[Dict[str, Any]]:
"""列出所有可编辑配置"""
db_settings = {s.key: s for s in db.query(AppSetting).all()}
result = []
for key, meta in EDITABLE_SETTINGS.items():
setting = db_settings.get(key)
value = setting.value if setting else _get_env_default(key)
is_sensitive = meta["sensitive"]
if is_sensitive and mask_sensitive:
display_value = _mask_sensitive(value)
is_masked = True
else:
display_value = value
is_masked = False
result.append({
"key": key,
"value": display_value,
"real_value": value if not mask_sensitive else None,
"description": meta["description"],
"is_sensitive": is_sensitive,
"is_masked": is_masked,
"updated_at": setting.updated_at.isoformat() if setting else None,
})
return result
def reset_settings(db: Session) -> None:
"""将所有配置重置为环境变量默认值"""
for key in EDITABLE_SETTINGS:
set_setting(db, key, _get_env_default(key))
logger.info("配置已重置为环境变量默认值")
def apply_db_settings_to_config(db: Session = None) -> None:
"""将数据库中的配置覆盖到全局 settings 对象,重启后生效"""
close_db = False
if db is None:
from database import SessionLocal
db = SessionLocal()
close_db = True
try:
for key in EDITABLE_SETTINGS:
db_value = 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")
elif target_type is Path:
converted = Path(db_value)
else:
converted = db_value
setattr(settings, key, converted)
logger.debug("已应用配置: %s=%s", key, converted)
except Exception as exc:
logger.error("应用配置 %s=%s 失败: %s", key, db_value, exc)
raise ValueError(f"配置项 {key} 的值无效: {db_value}") from exc
finally:
if close_db:
db.close()