总工复核 🔴:安全边界要求新增观测功能必须可关闭、关闭后现有功能完全照旧, 但此前实现没有任何运行时开关。修复: - settings.py: 新增 Settings.observability_enabled 字段,沿用既有 DATAHUB_SCHEDULER 的环境变量模式,读取 DATAHUB_OBSERVABILITY (0/false/off 关闭,默认开启)。 - hub.py: Hub.__init__ 把 settings.observability_enabled 挂到 self.db 上,让 pipeline/realtime_serve/steward/admin_api 已经 在传的 db 参数直接带上开关,零额外改造。 - observability.py: 新增 is_enabled(db),缺失该属性时默认按启用处理 (向后兼容裸 HubDB 用例/测试)。关闭时 observe() 变成纯 透传(不计时、不分类、不碰数据库),record_call() 直接 no-op。 - admin_api.py: 4 个新只读端点关闭时返回明确的 {"enabled": false, ...空结构} 而不是静默返回旧数据。 - 新增 10 个测试:开关默认值/环境变量解析、关闭后 observe() 的透传语义 (含异常原样重新抛出)、关闭后 record_call() 零写入、关闭后重新开启恢复 记录、4 个 admin 端点在关闭态的响应结构。 全量测试 183/183 通过(新增 10 个,含此前 173 个零回归)。 Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: multica-agent <github@multica.ai>
136 lines
5.0 KiB
Python
136 lines
5.0 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
DEFAULT_DB_PATH = Path(os.environ.get("DATAHUB_DB_PATH") or (ROOT / "data" / "datahub.db"))
|
|
DEFAULT_BACKUP_DIR = Path(os.environ.get("DATAHUB_BACKUP_DIR") or (ROOT / "data" / "backups"))
|
|
DEFAULT_CONFIG_PATH = ROOT / "config" / "hub-quality.config.json"
|
|
|
|
|
|
def _load_quality(path: Path) -> dict[str, Any]:
|
|
if not path.is_file():
|
|
return {}
|
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
|
|
|
|
@dataclass
|
|
class Settings:
|
|
host: str = "127.0.0.1"
|
|
port: int = 8766
|
|
encryption_key: str = ""
|
|
api_token: str = ""
|
|
admin_password: str = ""
|
|
tushare_token: str = ""
|
|
ifind_refresh_token: str = ""
|
|
ifind_access_token: str = ""
|
|
db_path: Path = DEFAULT_DB_PATH
|
|
backup_dir: Path = DEFAULT_BACKUP_DIR
|
|
quality: dict[str, Any] = field(default_factory=dict)
|
|
log_level: str = "INFO"
|
|
scheduler_enabled: bool = True
|
|
# HEL-543 kill switch: off disables the provider_call_log/provider_health
|
|
# side channel entirely (observe()/record_call() become no-ops and the
|
|
# new read-only admin endpoints report {"enabled": false}). Default on;
|
|
# existing routing/fetch/publish behavior is identical either way.
|
|
observability_enabled: bool = True
|
|
|
|
@property
|
|
def tushare_rate_per_minute(self) -> int:
|
|
return int(self.quality.get("tushare_rate_per_minute") or 300)
|
|
|
|
@property
|
|
def max_publish_attempts(self) -> int:
|
|
return int(self.quality.get("max_publish_attempts") or 5)
|
|
|
|
@property
|
|
def list_limit_default(self) -> int:
|
|
return int(self.quality.get("list_limit_default") or 5000)
|
|
|
|
@property
|
|
def list_limit_max(self) -> int:
|
|
return int(self.quality.get("list_limit_max") or 5000)
|
|
|
|
@property
|
|
def calendar_start(self) -> str:
|
|
return str(self.quality.get("calendar_start") or "20160101")
|
|
|
|
@property
|
|
def index_history_trading_days(self) -> int:
|
|
return int(self.quality.get("index_history_trading_days") or 260)
|
|
|
|
@property
|
|
def daily_history_trading_days(self) -> int:
|
|
return int(self.quality.get("daily_history_trading_days") or 250)
|
|
|
|
@property
|
|
def moneyflow_history_trading_days(self) -> int:
|
|
return int(self.quality.get("moneyflow_history_trading_days") or 60)
|
|
|
|
@property
|
|
def stocks_refresh_times(self) -> tuple[str, ...]:
|
|
raw = self.quality.get("stocks_refresh_times") or ["20:00", "23:10"]
|
|
if isinstance(raw, str):
|
|
raw = [raw]
|
|
return tuple(str(item) for item in raw)
|
|
|
|
@property
|
|
def realtime_warmup_interval_seconds(self) -> int:
|
|
return max(30, int(self.quality.get("realtime_warmup_interval_seconds") or 120))
|
|
|
|
@property
|
|
def eod_retry_start(self) -> str:
|
|
return str(self.quality.get("eod_retry_start") or "15:15")
|
|
|
|
@property
|
|
def eod_retry_interval_minutes(self) -> int:
|
|
return int(self.quality.get("eod_retry_interval_minutes") or 30)
|
|
|
|
@property
|
|
def eod_retry_cutoff(self) -> str:
|
|
return str(self.quality.get("eod_retry_cutoff") or "23:30")
|
|
|
|
@property
|
|
def revision_review_start(self) -> str:
|
|
# Before the 21:00 website shadow observation.
|
|
return str(self.quality.get("revision_review_start") or "20:00")
|
|
|
|
@property
|
|
def revision_review_interval_minutes(self) -> int:
|
|
return int(self.quality.get("revision_review_interval_minutes") or 30)
|
|
|
|
@property
|
|
def revision_review_cutoff(self) -> str:
|
|
# Last light review ~23:00; cutoff before the 23:30 observation.
|
|
return str(self.quality.get("revision_review_cutoff") or "23:20")
|
|
|
|
|
|
def load_settings(
|
|
env: dict[str, str] | None = None,
|
|
config_path: Path | None = None,
|
|
) -> Settings:
|
|
environ = env if env is not None else dict(os.environ)
|
|
quality_path = config_path or DEFAULT_CONFIG_PATH
|
|
db_path = Path(environ.get("DATAHUB_DB_PATH") or DEFAULT_DB_PATH)
|
|
backup_dir = Path(environ.get("DATAHUB_BACKUP_DIR") or DEFAULT_BACKUP_DIR)
|
|
return Settings(
|
|
host=environ.get("DATAHUB_HOST") or "127.0.0.1",
|
|
port=int(environ.get("DATAHUB_PORT") or 8766),
|
|
encryption_key=str(environ.get("DATAHUB_ENCRYPTION_KEY") or "").strip(),
|
|
api_token=str(environ.get("DATAHUB_TOKEN") or "").strip(),
|
|
admin_password=str(environ.get("DATAHUB_ADMIN_PASSWORD") or "").strip(),
|
|
tushare_token=str(environ.get("TUSHARE_TOKEN") or "").strip(),
|
|
ifind_refresh_token=str(environ.get("IFIND_REFRESH_TOKEN") or "").strip(),
|
|
ifind_access_token=str(environ.get("IFIND_ACCESS_TOKEN") or "").strip(),
|
|
db_path=db_path,
|
|
backup_dir=backup_dir,
|
|
quality=_load_quality(quality_path),
|
|
log_level=environ.get("DATAHUB_LOG_LEVEL") or "INFO",
|
|
scheduler_enabled=str(environ.get("DATAHUB_SCHEDULER") or "1") not in {"0", "false", "False"},
|
|
observability_enabled=str(environ.get("DATAHUB_OBSERVABILITY") or "1") not in {"0", "false", "False", "off", "OFF"},
|
|
)
|