diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index 348bedf..4bcebfd 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -373,6 +373,7 @@ class DatasetConfigIn(BaseModel): end_param: str = "end_time" asset_type_param: str | None = None freq_param: str | None = None + timeout: float | None = None class AuthConfigIn(BaseModel): diff --git a/backend/app/data_providers/custom/loader.py b/backend/app/data_providers/custom/loader.py index f54e8f0..40dac1d 100644 --- a/backend/app/data_providers/custom/loader.py +++ b/backend/app/data_providers/custom/loader.py @@ -290,6 +290,7 @@ def _config_to_dict(config: CustomSourceConfig) -> dict: "method": ds.method, **({"batch": ds.batch} if ds.batch is not None else {}), **({"rpm": ds.rpm} if ds.rpm is not None else {}), + **({"timeout": ds.timeout} if ds.timeout != 30.0 else {}), "response_path": ds.response_path, "field_map": dict(ds.field_map), **({"transforms": dict(ds.transforms)} if ds.transforms else {}), @@ -382,6 +383,11 @@ def _sanitize_dataset(ds_cfg: dict) -> dict: out["rpm"] = int(ds_cfg["rpm"]) except (TypeError, ValueError): pass + if ds_cfg.get("timeout") is not None: + try: + out["timeout"] = float(ds_cfg["timeout"]) + except (TypeError, ValueError): + pass out["response_path"] = str(ds_cfg.get("response_path", "") or "") field_map = { str(k): str(v) diff --git a/backend/tests/test_custom_minute_config.py b/backend/tests/test_custom_minute_config.py index 316c1c8..d34927e 100644 --- a/backend/tests/test_custom_minute_config.py +++ b/backend/tests/test_custom_minute_config.py @@ -27,3 +27,43 @@ def test_minute_request_parameter_names_survive_config_round_trip(): assert parsed.freq_param == "period" assert exposed["datasets"]["minute"]["asset_type_param"] == "asset" assert exposed["datasets"]["minute"]["freq_param"] == "period" + + +def test_timeout_survives_config_round_trip(): + """timeout 必须在 UI 保存往返中保留 (核心修复), 且默认 30 不污染 YAML。""" + dataset = DatasetConfigIn( + url="https://example.test/daily", + method="POST", + timeout=120.0, + ).model_dump() + + cleaned = _sanitize_for_yaml({ + "name": "test_source", + "display_name": "Test Source", + "datasets": {"daily": dataset}, + }) + parsed = _dataset_from_dict(cleaned["datasets"]["daily"]) + exposed = _config_to_dict(CustomSourceConfig( + name="test_source", + display_name="Test Source", + datasets={"daily": parsed}, + )) + + assert parsed.timeout == 120.0 + assert exposed["datasets"]["daily"]["timeout"] == 120.0 + + # 默认 30 不 emit, 保持 YAML 干净 + default_dataset = DatasetConfigIn(url="https://example.test/realtime", method="GET").model_dump() + cleaned2 = _sanitize_for_yaml({ + "name": "test_source", + "display_name": "Test Source", + "datasets": {"realtime": default_dataset}, + }) + parsed2 = _dataset_from_dict(cleaned2["datasets"]["realtime"]) + exposed2 = _config_to_dict(CustomSourceConfig( + name="test_source", + display_name="Test Source", + datasets={"realtime": parsed2}, + )) + assert parsed2.timeout == 30.0 + assert "timeout" not in exposed2["datasets"]["realtime"] diff --git a/docs/custom-data-source.md b/docs/custom-data-source.md index 1b03f8c..651b2f6 100644 --- a/docs/custom-data-source.md +++ b/docs/custom-data-source.md @@ -149,6 +149,16 @@ freq_param: period 配置后,分钟请求会分别传入 `stock` / `etf` / `index` 和 `1m`;留空时不向上游发送这两个参数,以兼容已有数据源。 +### 请求超时 + +每个数据集可单独配置请求超时(秒),默认 30: + +```yaml +timeout: 60 +``` + +留空或省略时用默认 30 秒;该值对数据同步与「试拉测试」均生效。在设置页编辑数据源时可在「超时」输入框修改(与 批量 / RPM / 响应路径 同行)。 + ## 鉴权 支持三种简单鉴权: diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index b335575..9a985f1 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -842,6 +842,7 @@ export interface DatasetConfig { end_param?: string asset_type_param?: string | null freq_param?: string | null + timeout?: number | null } export interface AuthConfig { diff --git a/frontend/src/pages/settings/DataSourceEditor.tsx b/frontend/src/pages/settings/DataSourceEditor.tsx index d4bca05..4087efc 100644 --- a/frontend/src/pages/settings/DataSourceEditor.tsx +++ b/frontend/src/pages/settings/DataSourceEditor.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useRef } from 'react' import { useMutation, useQuery } from '@tanstack/react-query' import { motion, AnimatePresence } from 'framer-motion' -import { KeyRound, Play, Plus, Save, Trash2, X, Zap, Check } from 'lucide-react' + import { KeyRound, Play, Plus, Save, Trash2, X, Zap, Check, ChevronDown } from 'lucide-react' import { api, type CustomSourceConfig, type DatasetConfig } from '@/lib/api' import { toast } from '@/components/Toast' @@ -319,6 +319,8 @@ function DatasetDetail({ }) { const enabled = !!cfg const [testSymbols, setTestSymbols] = useState('000001.SZ,600000.SH') + const [showParams, setShowParams] = useState(false) + const showTimeParams = datasetKey !== 'realtime' const test = useMutation({ mutationFn: () => api.testDataSource( providerName, @@ -368,7 +370,7 @@ function DatasetDetail({ -
+
+ + onUpdate({ timeout: e.target.value ? Number(e.target.value) : null })} + onWheel={e => e.currentTarget.blur()} + placeholder="30" + className={`${INPUT_CLS} w-full`} + /> +
- {datasetKey === 'minute' && ( -
- - onUpdate({ asset_type_param: e.target.value || null })} - placeholder="asset_type" - className={`${INPUT_CLS} w-full`} - /> - - - onUpdate({ freq_param: e.target.value || null })} - placeholder="period" - className={`${INPUT_CLS} w-full`} - /> - + {/* 请求参数字段映射 — 折叠区 */} +
+ +
+ 改外部接口的参数名(留空用默认)
- )} + + {showParams && ( + +
+ {showTimeParams && ( + <> + + onUpdate({ symbols_param: e.target.value || undefined })} + placeholder="symbols" + className={`${INPUT_CLS} w-full`} + /> + + + onUpdate({ start_param: e.target.value || undefined })} + placeholder="start_time" + className={`${INPUT_CLS} w-full`} + /> + + + onUpdate({ end_param: e.target.value || undefined })} + placeholder="end_time" + className={`${INPUT_CLS} w-full`} + /> + + + )} + {datasetKey === 'minute' && ( + <> + + onUpdate({ asset_type_param: e.target.value || null })} + placeholder="asset_type" + className={`${INPUT_CLS} w-full`} + /> + + + onUpdate({ freq_param: e.target.value || null })} + placeholder="period" + className={`${INPUT_CLS} w-full`} + /> + + + )} + {datasetKey === 'realtime' && ( +
+ 实时行情为全市场快照接口,不逐标的拉取,无需配置请求参数名。 +
+ )} +
+
+ )} +
+
-
字段映射
+
响应参数字段映射