Files
congsh ba6e7669e8 Initial commit: RSS platform phase 1 skeleton with code review fixes
Features:
- FastAPI + SQLAlchemy 2.0 async + PostgreSQL/pgvector + Redis backend
- Vue 3 + TypeScript + Element Plus frontend
- JWT auth with access/refresh tokens and revocation
- Admin/member RBAC
- RSS feed CRUD and article listing
- Settings management with Fernet encryption for sensitive values
- Redis distributed lock service
- Alembic initial migration
- Docker Compose development environment

Fixes from code review:
- Fix DB session leak in dependency injection
- Restrict registration to admin only
- Add default admin password warning
- Implement JWT refresh tokens and jti blacklist
- Strengthen password policy
- Use func.count for pagination totals
- Replace NullPool with AsyncAdaptedQueuePool
- Remove init_db from lifespan to enforce alembic migrations
- Add request_id middleware and logging filter
- Fix vite.config.ts env loading
- Add frontend token refresh interceptor
- Add Vue error handler

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 17:01:57 +08:00

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