228 lines
8.2 KiB
Python
228 lines
8.2 KiB
Python
|
|
"""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("配置已重置为环境变量默认值")
|