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>
This commit is contained in:
congsh
2026-06-15 17:01:57 +08:00
commit ba6e7669e8
82 changed files with 6859 additions and 0 deletions
+227
View File
@@ -0,0 +1,227 @@
"""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("配置已重置为环境变量默认值")