P1-G runtime_config:DB 故障回落 env;解密失败在生产环境抛出(ValueError), 非生产回落;DB 异常与解密异常不再共用 except Exception。 P1-H Redis 预测缓存:去掉 pickle 改用 JSON + dataclasses.asdict, datetime 字段 ISO 序列化。 P1-I eval get_eval_summary:默认 limit=None(不冒充全集);新增 run_type(默认 live) 与 season 过滤;_build_filters 同步扩展。 P1-K Team/League get_or_create:INSERT 改为 PG UPSERT(ON CONFLICT DO NOTHING) 防并发重复插入。 测试 test_p1_g_runtime_config(4/4) + test_p1_k_upsert(4/4);全量绿。
270 lines
10 KiB
Python
270 lines
10 KiB
Python
"""运行时配置:数据库优先,回落 .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 透传。
|
|
P1-G:DB 故障回落 env;但解密失败在生产环境必须抛出(不得与 DB 异常共用 except)。
|
|
"""
|
|
defn = SETTING_DEFS.get(key)
|
|
db_value = None
|
|
try:
|
|
async with AsyncSessionLocal() as db:
|
|
row = await db.get(AppSetting, key)
|
|
if row and row.value:
|
|
db_value = row.value
|
|
except Exception:
|
|
logger.warning("读取运行时配置 %s 失败(DB 故障),回落环境变量", key)
|
|
return getattr(settings, key, "") or ""
|
|
|
|
if db_value is None:
|
|
return getattr(settings, key, "") or ""
|
|
|
|
# 解密逻辑独立于 DB 异常处理(P1-G:解密失败生产环境必须抛出)
|
|
value = crypto.decrypt_value(db_value) if defn and defn.sensitive else db_value
|
|
if value:
|
|
return value
|
|
return getattr(settings, key, "") or ""
|
|
|
|
|
|
async def get_setting_origin(key: str) -> tuple[str, str]:
|
|
"""返回 (origin, 当前生效值)。origin ∈ db / env / none。
|
|
|
|
P1-G:DB 故障回落 env;解密失败在生产环境抛出。
|
|
"""
|
|
defn = SETTING_DEFS.get(key)
|
|
db_value = None
|
|
try:
|
|
async with AsyncSessionLocal() as db:
|
|
row = await db.get(AppSetting, key)
|
|
if row and row.value:
|
|
db_value = row.value
|
|
except Exception:
|
|
logger.warning("读取运行时配置 %s 来源失败(DB 故障),按环境变量处理", key)
|
|
env_value = getattr(settings, key, "") or ""
|
|
return ("env", env_value) if env_value else ("none", "")
|
|
|
|
if db_value is None:
|
|
return ("none", "")
|
|
|
|
try:
|
|
value = crypto.decrypt_value(db_value) if defn and defn.sensitive else db_value
|
|
return "db", value
|
|
except ValueError as e:
|
|
# P1-G:解密失败(SECRET_KEY 不一致)在生产环境必须抛出,不得静默回落
|
|
if settings.APP_ENV == "production":
|
|
raise
|
|
logger.warning("解密 %s 失败(非生产环境回落): %s", key, e)
|
|
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
|