diff --git a/backend/app/api/kline.py b/backend/app/api/kline.py index 2018d6ff..2c0a5455 100644 --- a/backend/app/api/kline.py +++ b/backend/app/api/kline.py @@ -25,10 +25,10 @@ def _minute_allowed(capset) -> bool: return True from app.services import preferences provider = preferences.get_minute_data_provider() - if provider == "tickflow": - return False - from app.data_providers import custom as custom_sources - return custom_sources.provider_has_dataset(provider, "minute") + _, fallback, error = kline_sync._resolve_minute_provider(provider) + if error is not None: + logger.warning("minute provider resolution failed while checking access: %s", error) + return not fallback @router.get("/instruments/search") diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index ab0a252c..348bedf1 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -371,6 +371,8 @@ class DatasetConfigIn(BaseModel): symbols_param: str = "symbols" start_param: str = "start_time" end_param: str = "end_time" + asset_type_param: str | None = None + freq_param: str | None = None class AuthConfigIn(BaseModel): @@ -1429,4 +1431,3 @@ def update_review_push(req: ReviewPushIn) -> dict: from app.services import preferences saved = preferences.set_review_push_channels(req.channels) return {"review_push_channels": saved} - diff --git a/backend/app/data_providers/custom/loader.py b/backend/app/data_providers/custom/loader.py index 1b584a0f..f54e8f04 100644 --- a/backend/app/data_providers/custom/loader.py +++ b/backend/app/data_providers/custom/loader.py @@ -296,6 +296,8 @@ def _config_to_dict(config: CustomSourceConfig) -> dict: "symbols_param": ds.symbols_param, "start_param": ds.start_param, "end_param": ds.end_param, + **({"asset_type_param": ds.asset_type_param} if ds.asset_type_param else {}), + **({"freq_param": ds.freq_param} if ds.freq_param else {}), } return out @@ -401,6 +403,10 @@ def _sanitize_dataset(ds_cfg: dict) -> dict: out["start_param"] = str(ds_cfg["start_param"]) if ds_cfg.get("end_param"): out["end_param"] = str(ds_cfg["end_param"]) + if ds_cfg.get("asset_type_param"): + out["asset_type_param"] = str(ds_cfg["asset_type_param"]) + if ds_cfg.get("freq_param"): + out["freq_param"] = str(ds_cfg["freq_param"]) return out @@ -496,4 +502,3 @@ def _load_entry(entry_ref: str): # 模块导入时即扫描一次, 保证 names()/_allowed_data_providers() 在 startup 前可用。 _load_builtin_plugins() - diff --git a/backend/app/services/kline_sync.py b/backend/app/services/kline_sync.py index f7c9f6ad..8db0f113 100644 --- a/backend/app/services/kline_sync.py +++ b/backend/app/services/kline_sync.py @@ -733,13 +733,14 @@ def sync_minute_batch( def intraday_monitor_support(capset: CapabilitySet | None) -> dict[str, object]: """返回分时信号监控可用的数据能力和单轮标的上限。""" provider_name = preferences.get_minute_data_provider() - if provider_name != "tickflow": - from app.data_providers import custom as custom_sources - if custom_sources.provider_has_dataset(provider_name, "minute"): - return { - "available": True, "source": "custom_minute", "max_symbols": 100, - "reason": "使用已配置的分钟数据插件", - } + _, fallback, error = _resolve_minute_provider(provider_name) + if not fallback: + return { + "available": True, "source": "custom_minute", "max_symbols": 100, + "reason": "使用已配置的分钟数据插件", + } + if error is not None: + logger.warning("minute provider resolution failed while checking monitor support: %s", error) if capset is None: return { "available": False, "source": None, "max_symbols": 0, diff --git a/backend/tests/test_custom_minute_config.py b/backend/tests/test_custom_minute_config.py new file mode 100644 index 00000000..316c1c88 --- /dev/null +++ b/backend/tests/test_custom_minute_config.py @@ -0,0 +1,29 @@ +from app.api.settings import DatasetConfigIn +from app.data_providers.custom.config import CustomSourceConfig, _dataset_from_dict +from app.data_providers.custom.loader import _config_to_dict, _sanitize_for_yaml + + +def test_minute_request_parameter_names_survive_config_round_trip(): + dataset = DatasetConfigIn( + url="https://example.test/minute", + method="GET", + asset_type_param="asset", + freq_param="period", + ).model_dump() + + cleaned = _sanitize_for_yaml({ + "name": "test_source", + "display_name": "Test Source", + "datasets": {"minute": dataset}, + }) + parsed = _dataset_from_dict(cleaned["datasets"]["minute"]) + exposed = _config_to_dict(CustomSourceConfig( + name="test_source", + display_name="Test Source", + datasets={"minute": parsed}, + )) + + assert parsed.asset_type_param == "asset" + assert parsed.freq_param == "period" + assert exposed["datasets"]["minute"]["asset_type_param"] == "asset" + assert exposed["datasets"]["minute"]["freq_param"] == "period" diff --git a/backend/tests/test_minute_routing.py b/backend/tests/test_minute_routing.py index 38efc4cf..b009be82 100644 --- a/backend/tests/test_minute_routing.py +++ b/backend/tests/test_minute_routing.py @@ -639,3 +639,44 @@ def test_resolve_minute_provider_success_returns_provider(monkeypatch): assert provider is mock_provider assert fallback is False assert err is None + + +def test_minute_allowed_resolver_exception_returns_false(monkeypatch): + """权限入口复用安全 resolver, 插件注册异常不再穿透为 500。""" + from app.api import kline as kline_api + from app.tickflow.capabilities import CapabilitySet + + monkeypatch.setattr( + "app.services.preferences.get_minute_data_provider", + lambda: "broken", + ) + + def _raising(name, dataset): + raise RuntimeError("registry corrupted") + + monkeypatch.setattr("app.data_providers.custom.provider_has_dataset", _raising) + + assert kline_api._minute_allowed(CapabilitySet()) is False + + +def test_intraday_monitor_support_resolver_exception_falls_back(monkeypatch): + """监控入口解析自定义源失败后继续按 TickFlow 能力判断。""" + from app.tickflow.capabilities import Cap, CapabilitySet + + monkeypatch.setattr( + kline_sync.preferences, + "get_minute_data_provider", + lambda: "broken", + ) + + def _raising(name, dataset): + raise RuntimeError("registry corrupted") + + monkeypatch.setattr("app.data_providers.custom.provider_has_dataset", _raising) + capset = CapabilitySet() + capset.grant(Cap.KLINE_MINUTE_BATCH) + + support = kline_sync.intraday_monitor_support(capset) + + assert support["available"] is True + assert support["source"] == "minute_batch" diff --git a/docs/custom-data-source.md b/docs/custom-data-source.md index 72403b6b..1b03f8cb 100644 --- a/docs/custom-data-source.md +++ b/docs/custom-data-source.md @@ -140,6 +140,15 @@ start_param: start_time end_param: end_time ``` +分钟数据源如果需要区分资产类型或周期,可继续配置: + +```yaml +asset_type_param: asset_type +freq_param: period +``` + +配置后,分钟请求会分别传入 `stock` / `etf` / `index` 和 `1m`;留空时不向上游发送这两个参数,以兼容已有数据源。 + ## 鉴权 支持三种简单鉴权: @@ -295,4 +304,3 @@ datasets: ``` 把这段 YAML 保存为 `data/data_sources/my_source.yaml`,然后在设置页重新加载即可。 - diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index d1febb31..3ac88d49 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -821,6 +821,8 @@ export interface DatasetConfig { symbols_param?: string start_param?: string end_param?: string + asset_type_param?: string | null + freq_param?: string | null } export interface AuthConfig { diff --git a/frontend/src/pages/settings/DataSourceEditor.tsx b/frontend/src/pages/settings/DataSourceEditor.tsx index 3c24d0eb..d4bca05e 100644 --- a/frontend/src/pages/settings/DataSourceEditor.tsx +++ b/frontend/src/pages/settings/DataSourceEditor.tsx @@ -395,6 +395,27 @@ function DatasetDetail({ + {datasetKey === 'minute' && ( +