"""运行时配置:数据库优先,回落 .env。 后台「数据源」页可在线修改的配置项存 app_settings 表; 读取时 DB 有值用 DB,否则回落同名环境变量(pydantic settings)。 DB 读取失败时也回落环境变量,保证采集不因管理表故障而中断。 """ from __future__ import annotations import logging import re import secrets from dataclasses import dataclass from sqlalchemy import select from sqlalchemy.dialects.postgresql import insert as pg_insert from src.core import crypto from src.core.config import settings from src.db.base import AsyncSessionLocal from src.db.models import AppSetting # 管理员密码哈希在 app_settings 中的键(不进 SETTING_DEFS 白名单: # 只能走专门的改密接口 —— 需验证当前密码,不能被通用配置接口绕过) ADMIN_PASSWORD_HASH_KEY = "ADMIN_PASSWORD_HASH" # .env 明文密码的键名(仅作为初始值;后台改密后以哈希为准) _ADMIN_ENV_KEY = "ADMIN_PASSWORD" logger = logging.getLogger(__name__) @dataclass(frozen=True) class SettingDef: key: str label: str description: str sensitive: bool # 允许在后台查看/修改的配置项白名单(之外的 key 一律拒绝读写) SETTING_DEFS: dict[str, SettingDef] = { "BZZOIRO_KEY": SettingDef( "BZZOIRO_KEY", "Bzzoiro API Key", "比赛赛程/比分/积分榜/统计的数据源凭证。支持多 key 轮换:用逗号、分号或换行分隔多个 key,遇到限流(429)自动切换", sensitive=True, ), "BZZOIRO_BASE": SettingDef( "BZZOIRO_BASE", "Bzzoiro API 地址", "Bzzoiro 接口基础地址", sensitive=False, ), "LLM_API_KEY": SettingDef( "LLM_API_KEY", "LLM API Key", "大模型服务凭证(OpenAI 兼容接口)", sensitive=True, ), "LLM_BASE_URL": SettingDef( "LLM_BASE_URL", "LLM 接口地址", "如 https://api.deepseek.com/v1", sensitive=False, ), "LLM_MODEL": SettingDef( "LLM_MODEL", "LLM 模型", "如 deepseek-chat / gpt-4o", sensitive=False, ), } # ── 按角色独立配置 LLM 的键(5 专家 + 终裁) ── # 每个角色可独立覆盖 模型 / 接口地址 / API Key;留空继承分层默认(见 orchestrator._agent_provider)。 AGENT_META: list[dict] = [ {"id": "form", "label": "近期状态分析专家"}, {"id": "stats", "label": "攻防数据分析专家"}, {"id": "home_away", "label": "主客因素分析专家"}, {"id": "standings", "label": "联赛排名分析专家"}, {"id": "h2h", "label": "历史交锋分析专家"}, {"id": "aggregator", "label": "终裁分析专家"}, ] for _agent in AGENT_META: _u = _agent["id"].upper() SETTING_DEFS[f"AGENT_{_u}_MODEL"] = SettingDef( f"AGENT_{_u}_MODEL", f"{_agent['label']} 模型", "留空继承默认(专家层/全局)", sensitive=False, ) SETTING_DEFS[f"AGENT_{_u}_BASE_URL"] = SettingDef( f"AGENT_{_u}_BASE_URL", f"{_agent['label']} 接口地址", "留空继承全局 LLM_BASE_URL", sensitive=False, ) SETTING_DEFS[f"AGENT_{_u}_API_KEY"] = SettingDef( f"AGENT_{_u}_API_KEY", f"{_agent['label']} API Key", "留空继承全局 LLM_API_KEY", sensitive=True, ) def mask_value(value: str, sensitive: bool) -> str: """脱敏展示:敏感值只留末 4 位;非敏感值原样返回。 多 key(逗号/分号/换行分隔)时显示数量,如 "3 个 key(末段 …XXXX)"。 """ if not value: return "" if not sensitive: return value # 检测多 key keys = [k.strip() for k in re.split(r"[,;\n]", value) if k.strip()] if len(keys) > 1: last = keys[-1] tail = last[-4:] if len(last) >= 4 else last return f"{len(keys)} 个 key(末段 …{tail})" return f"****{value[-4:]}" if len(value) >= 8 else "****" async def get_runtime_value(key: str) -> str: """读运行时配置:DB 覆盖值 → .env 默认值 → 空串。 敏感项入库时是密文,读出后自动解密;旧明文(迁移前)由 decrypt_value 透传。 """ defn = SETTING_DEFS.get(key) try: async with AsyncSessionLocal() as db: row = await db.get(AppSetting, key) if row and row.value: value = crypto.decrypt_value(row.value) if defn and defn.sensitive else row.value if value: return value except Exception: logger.warning("读取运行时配置 %s 失败,回落环境变量", key) return getattr(settings, key, "") or "" async def set_runtime_value(key: str, value: str) -> None: """写入/更新 DB 覆盖值(调用方需先校验 key 在白名单内)。 敏感项(SETTING_DEFS.sensitive)以 Fernet 加密存储,库里不落明文。 """ defn = SETTING_DEFS.get(key) stored = crypto.encrypt_value(value) if defn and defn.sensitive else value async with AsyncSessionLocal() as db: stmt = pg_insert(AppSetting).values(key=key, value=stored) stmt = stmt.on_conflict_do_update(index_elements=["key"], set_={"value": stored}) await db.execute(stmt) await db.commit() logger.info("运行时配置 %s 已更新", key) async def clear_runtime_value(key: str) -> None: """删除 DB 覆盖值,回落 .env(调用方需先校验 key 在白名单内)。""" async with AsyncSessionLocal() as db: row = await db.get(AppSetting, key) if row is not None: await db.delete(row) await db.commit() logger.info("运行时配置 %s 已清除覆盖", key) async def get_setting_origin(key: str) -> tuple[str, str]: """返回 (origin, 当前生效值)。origin ∈ db / env / none。""" defn = SETTING_DEFS.get(key) try: async with AsyncSessionLocal() as db: row = await db.get(AppSetting, key) if row and row.value: value = crypto.decrypt_value(row.value) if defn and defn.sensitive else row.value return "db", value except Exception: logger.warning("读取运行时配置 %s 来源失败,按环境变量处理", key) env_value = getattr(settings, key, "") or "" return ("env", env_value) if env_value else ("none", "") async def migrate_plaintext_sensitive_settings() -> int: """一次性迁移:把库中仍是明文的敏感项加密(幂等,启动时执行)。 返回加密的条数。 """ migrated = 0 sensitive_keys = {k for k, d in SETTING_DEFS.items() if d.sensitive} async with AsyncSessionLocal() as db: rows = (await db.execute(select(AppSetting))).scalars().all() for row in rows: if row.key not in sensitive_keys or crypto.is_encrypted(row.value): continue row.value = crypto.encrypt_value(row.value) migrated += 1 await db.commit() if migrated: logger.info("已加密迁移 %d 条明文敏感配置", migrated) return migrated # ── 管理员密码:只存 scrypt 哈希,永不存明文 ────────────────────── async def get_admin_password_hash() -> str: """库中管理员密码哈希;无则空串。""" try: async with AsyncSessionLocal() as db: row = await db.get(AppSetting, ADMIN_PASSWORD_HASH_KEY) return row.value if row else "" except Exception: logger.warning("读取管理员密码哈希失败") return "" async def set_admin_password_hash(hash_str: str) -> None: async with AsyncSessionLocal() as db: stmt = pg_insert(AppSetting).values(key=ADMIN_PASSWORD_HASH_KEY, value=hash_str) stmt = stmt.on_conflict_do_update(index_elements=["key"], set_={"value": hash_str}) await db.execute(stmt) await db.commit() logger.info("管理员密码哈希已更新") async def verify_admin_password(candidate: str) -> bool: """校验管理员密码:优先哈希;哈希不存在时回落 .env 明文(未迁移的旧部署)。""" stored_hash = await get_admin_password_hash() if stored_hash: return crypto.verify_password(candidate, stored_hash) env_pw = getattr(settings, _ADMIN_ENV_KEY, "") or "" return bool(env_pw) and secrets.compare_digest(candidate, env_pw) async def get_admin_credential_fingerprint() -> str: """管理员凭证指纹(作为会话签名密钥的输入)。 用密码哈希而非密码本身:凭证变化 → 指纹变化 → 全部会话失效。 """ stored_hash = await get_admin_password_hash() if stored_hash: return f"hash:{stored_hash}" env_pw = getattr(settings, _ADMIN_ENV_KEY, "") or "" return f"env:{env_pw}" if env_pw else "" async def _get_raw_setting(key: str) -> str: async with AsyncSessionLocal() as db: row = await db.get(AppSetting, key) return row.value if row else "" async def _delete_setting(key: str) -> None: async with AsyncSessionLocal() as db: row = await db.get(AppSetting, key) if row is not None: await db.delete(row) await db.commit() async def ensure_admin_password_hashed() -> bool: """启动迁移:确保管理员密码只以 scrypt 哈希存在(幂等)。 迁移来源优先级: 1. 库中旧版明文 ADMIN_PASSWORD 行(旧代码写入的当前密码,迁移后删除该明文行) 2. .env 的 ADMIN_PASSWORD 初始值 """ if await get_admin_password_hash(): # 哈希已存在:清除旧版可能残留的明文行 if await _get_raw_setting(_ADMIN_ENV_KEY): await _delete_setting(_ADMIN_ENV_KEY) logger.info("已删除遗留的明文 ADMIN_PASSWORD 行(哈希已存在)") return False legacy_plain = await _get_raw_setting(_ADMIN_ENV_KEY) if legacy_plain: await set_admin_password_hash(crypto.hash_password(legacy_plain)) await _delete_setting(_ADMIN_ENV_KEY) logger.info("已将库中明文管理员密码迁移为 scrypt 哈希,明文行已删除") return True env_pw = getattr(settings, _ADMIN_ENV_KEY, "") or "" if not env_pw: return False await set_admin_password_hash(crypto.hash_password(env_pw)) logger.info( "已将 .env 中的明文管理员密码迁移为 scrypt 哈希。" "建议现在从 .env 中删除 ADMIN_PASSWORD 明文行。" ) return True