diff --git a/backend/app/api/ext_data.py b/backend/app/api/ext_data.py index 5facb6a..a076dd5 100644 --- a/backend/app/api/ext_data.py +++ b/backend/app/api/ext_data.py @@ -24,13 +24,15 @@ from app.services.ext_data import ( apply_config_mapping, detect_symbol_candidates, ensure_utf8_csv, + ext_api_key_field, fix_symbol_format, + get_ext_api_key, infer_fields_from_df, parse_upload_file, write_ext_parquet, rows_to_parquet, ) -from app.services.ext_pull import fetch_and_ingest, pull_scheduler +from app.services.ext_pull import _request_json, fetch_and_ingest, pull_scheduler logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/ext-data", tags=["ext-data"]) @@ -70,6 +72,16 @@ class IngestReq(BaseModel): rows: list[dict] = Field(..., min_length=1) +class PullAuthReq(BaseModel): + """拉取接口鉴权方式 (与自定义行情源 AuthConfig 同口径)。 + + Key 本体存 secrets_store (secrets.json), 不写入 config.json。 + """ + type: Literal["none", "bearer", "header", "query"] = "none" + header: str = Field("Authorization", min_length=1, max_length=64) # bearer/header 用 + param: str = Field("token", min_length=1, max_length=64) # query 用 + + class PullConfigReq(BaseModel): """定时拉取配置请求。""" url: str = Field(..., min_length=1) @@ -84,6 +96,13 @@ class PullConfigReq(BaseModel): time_window_end: str | None = None # "HH:MM", None=不限 # 接口按日查询的参数名 (如 "date"): 配置后支持历史回补, 且当日拉取也带日期参数 date_param: str | None = Field(None, min_length=1, max_length=16, pattern=r"^[A-Za-z_][A-Za-z0-9_]*$") + # 鉴权方式; 请求中缺省 (None) = 保留现有配置, {"type":"none"} = 关闭鉴权 + auth: PullAuthReq | None = None + + +class ApiKeyReq(BaseModel): + """设置拉取接口 API Key; 空串 = 清除。""" + key: str = Field(..., max_length=4096) class DetectUrlReq(BaseModel): @@ -386,6 +405,10 @@ def delete_config(request: Request, config_id: str): store = _store(request) if not store.delete(config_id): raise HTTPException(404, f"配置 '{config_id}' 不存在") + # 同步清掉 secrets.json 里残留的拉取 API Key, 避免同名重建配置时误用旧 Key + from app import secrets_store + + secrets_store.clear(ext_api_key_field(config_id)) _refresh_views(request) return {"status": "deleted"} @@ -819,7 +842,7 @@ def configure_pull(request: Request, config_id: str, body: PullConfigReq): if not config: raise HTTPException(404, f"配置 '{config_id}' 不存在") - # 保留历史状态字段 + # 保留历史状态字段; auth 缺省时沿用现有配置 (关闭鉴权需显式传 {"type":"none"}) old_pull = config.pull config.pull = PullConfig( url=body.url, @@ -833,6 +856,7 @@ def configure_pull(request: Request, config_id: str, body: PullConfigReq): time_window_start=body.time_window_start, time_window_end=body.time_window_end, date_param=body.date_param, + auth=body.auth.model_dump() if body.auth else (old_pull.auth if old_pull else None), last_run=old_pull.last_run if old_pull else None, last_status=old_pull.last_status if old_pull else None, last_message=old_pull.last_message if old_pull else None, @@ -853,6 +877,38 @@ def configure_pull(request: Request, config_id: str, body: PullConfigReq): return {"status": "ok", "pull": config.pull.to_dict()} +@router.get("/{config_id}/api-key") +def get_pull_api_key(request: Request, config_id: str): + """查询拉取接口 API Key 状态。只返回脱敏值, 不返回明文。""" + store = _store(request) + config = store.get(config_id) + if not config: + raise HTTPException(404, f"配置 '{config_id}' 不存在") + + from app import secrets_store + + key = get_ext_api_key(config_id) + return {"key_set": bool(key), "masked_key": secrets_store.mask(key) if key else ""} + + +@router.put("/{config_id}/api-key") +def set_pull_api_key(request: Request, config_id: str, body: ApiKeyReq): + """设置 (或空串清除) 拉取接口的 API Key, 存 secrets.json (权限 0600)。""" + store = _store(request) + config = store.get(config_id) + if not config: + raise HTTPException(404, f"配置 '{config_id}' 不存在") + + from app import secrets_store + + value = body.key.strip() + if value: + secrets_store.save({ext_api_key_field(config_id): value}) + else: + secrets_store.clear(ext_api_key_field(config_id)) + return {"status": "ok", "key_set": bool(value), "masked_key": secrets_store.mask(value) if value else ""} + + @router.post("/{config_id}/pull/test") async def test_pull(request: Request, config_id: str): """测试拉取:请求外部 API 并返回预览数据,不写入。""" @@ -863,23 +919,12 @@ async def test_pull(request: Request, config_id: str): if not config.pull or not config.pull.url: raise HTTPException(400, "拉取未配置或 URL 为空") - # 临时构建一个带新配置的 config 用于测试 - from app.services.ext_pull import _extract_rows, _apply_field_map, outbound_headers - import httpx + # 复用正式拉取的请求实现 (UA 标识头 + 鉴权注入同一套口径), 不带日期参数 + from app.services.ext_pull import _apply_field_map, _extract_rows pull = config.pull try: - async with httpx.AsyncClient(timeout=30) as client: - headers = outbound_headers(pull.headers) - kwargs: dict = {"headers": headers} - if pull.method.upper() == "POST" and pull.body: - kwargs["content"] = pull.body - if "content-type" not in {k.lower() for k in headers}: - kwargs["headers"]["Content-Type"] = "application/json" - resp = await client.request(pull.method.upper(), pull.url, **kwargs) - resp.raise_for_status() - data = resp.json() - + data = await _request_json(pull, config.id) rows = _extract_rows(data, pull.response_path) preview = _apply_field_map(rows[:5], pull.field_map) return { diff --git a/backend/app/services/ext_data.py b/backend/app/services/ext_data.py index 2f081b6..099a696 100644 --- a/backend/app/services/ext_data.py +++ b/backend/app/services/ext_data.py @@ -42,6 +42,7 @@ class PullConfig: "field_map", "schedule_minutes", "enabled", "last_run", "last_status", "last_message", "last_rows", "next_run", "time_window_start", "time_window_end", "date_param", + "auth", ) def __init__( @@ -62,6 +63,7 @@ class PullConfig: time_window_start: str | None = None, time_window_end: str | None = None, date_param: str | None = None, + auth: dict | None = None, ) -> None: self.url = url self.method = method # GET | POST @@ -81,6 +83,9 @@ class PullConfig: # 接口按日期查询的参数名 (如 "date"): 非 None 时请求 # 带 ?{date_param}=YYYY-MM-DD, 支持历史回补; None = 接口只有当日快照 self.date_param = date_param + # 拉取接口鉴权方式 {"type": "none|bearer|header|query", "header": ..., "param": ...}, + # 与自定义行情源 AuthConfig 同口径; Key 本体存 secrets_store, 不落 config.json + self.auth = auth def to_dict(self) -> dict: return { @@ -100,6 +105,7 @@ class PullConfig: "time_window_start": self.time_window_start, "time_window_end": self.time_window_end, "date_param": self.date_param, + "auth": self.auth, } @classmethod @@ -123,9 +129,24 @@ class PullConfig: time_window_start=d.get("time_window_start"), time_window_end=d.get("time_window_end"), date_param=d.get("date_param"), + auth=d.get("auth"), ) +def ext_api_key_field(config_id: str) -> str: + """扩展数据拉取 API Key 在 secrets.json 中的字段名。""" + return f"ext_{config_id}_api_key" + + +def get_ext_api_key(config_id: str) -> str: + """取扩展数据拉取接口的 API Key: secrets.json 优先, 环境变量 EXT_{ID}_API_KEY 兜底。""" + from app import secrets_store + + return secrets_store.get_env_backed_secret( + ext_api_key_field(config_id), f"EXT_{config_id.upper()}_API_KEY" + ) + + class ExtConfig: """一个扩展数据源的完整配置。""" __slots__ = ( diff --git a/backend/app/services/ext_pull.py b/backend/app/services/ext_pull.py index eb791d0..3126da7 100644 --- a/backend/app/services/ext_pull.py +++ b/backend/app/services/ext_pull.py @@ -15,6 +15,7 @@ import httpx from app.services.ext_data import ( ExtConfig, ExtConfigStore, + PullConfig, rows_to_parquet, ) @@ -131,6 +132,37 @@ def _with_date_param(url: str, date_param: str | None, day: date) -> str: return f"{url}{sep}{date_param}={day.isoformat()}" +def _apply_auth(config_id: str, auth: dict | None, url: str, headers: dict[str, str]) -> str: + """把 secrets_store 里的 API Key 注入出站请求。 + + 鉴权三型与自定义行情源 AuthConfig 同口径: bearer → {header: "Bearer "}, + header → {header: }, query → ?{param}=。Key 只存 secrets.json, + 不落 config.json; 配置了鉴权但未设置 Key 时 fail-closed 直接报错, + 避免不带凭据请求被服务端记成无效调用。返回 (可能追加了参数的) url。 + """ + from urllib.parse import quote + + from app.services.ext_data import get_ext_api_key + + auth_type = str((auth or {}).get("type") or "none").lower() + if auth_type == "none": + return url + key = get_ext_api_key(config_id) + if not key: + raise ValueError(f"已配置 {auth_type} 鉴权但未设置 API Key, 请在拉取设置中填写") + if auth_type == "bearer": + headers[str(auth.get("header") or "Authorization")] = f"Bearer {key}" + elif auth_type == "header": + headers[str(auth.get("header") or "Authorization")] = key + elif auth_type == "query": + name = str(auth.get("param") or "token") + sep = "&" if "?" in url else "?" + url = f"{url}{sep}{name}={quote(key, safe='')}" + else: + raise ValueError(f"未知鉴权类型: {auth_type!r} (可选 none/bearer/header/query)") + return url + + def _assert_rows_date(rows: list[dict], day: date) -> None: """金融契约: 响应行的 date 字段 (若提供) 必须与请求日期一致。 @@ -152,6 +184,31 @@ def _assert_rows_date(rows: list[dict], day: date) -> None: ) +async def _request_json(pull: PullConfig, config_id: str, day: date | None = None) -> Any: + """发起一次拉取请求并返回解析后的 JSON。 + + 正式拉取 (带日期参数) 与设置页"测试" (不带) 共用同一实现, + 保证 UA 标识头与 API Key 鉴权注入只有一套口径。 + """ + url = _with_date_param(pull.url, pull.date_param, day) if day else pull.url + async with httpx.AsyncClient(timeout=30) as client: + headers = outbound_headers(pull.headers) + url = _apply_auth(config_id, pull.auth, url, headers) + kwargs: dict[str, Any] = {"headers": headers} + + if pull.method.upper() == "POST" and pull.body: + kwargs["content"] = pull.body + if "content-type" not in {k.lower() for k in headers}: + kwargs["headers"]["Content-Type"] = "application/json" + + resp = await client.request(pull.method.upper(), url, **kwargs) + resp.raise_for_status() + try: + return resp.json() + except Exception as e: + raise ValueError(f"响应不是有效 JSON: {e}") from e + + async def fetch_rows_for_date(config: ExtConfig, target_date: date) -> list[dict]: """按日期请求外部 API 并解析为行 (不写盘)。空数据返回 []。 @@ -162,24 +219,7 @@ async def fetch_rows_for_date(config: ExtConfig, target_date: date) -> list[dict if not pull or not pull.url: raise ValueError("拉取未配置或 URL 为空") - url = _with_date_param(pull.url, pull.date_param, target_date) - async with httpx.AsyncClient(timeout=30) as client: - headers = outbound_headers(pull.headers) - kwargs: dict[str, Any] = {"headers": headers} - - if pull.method.upper() == "POST" and pull.body: - kwargs["content"] = pull.body - if "content-type" not in {k.lower() for k in headers}: - kwargs["headers"]["Content-Type"] = "application/json" - - resp = await client.request(pull.method.upper(), url, **kwargs) - resp.raise_for_status() - - # 解析 JSON - try: - data = resp.json() - except Exception as e: - raise ValueError(f"响应不是有效 JSON: {e}") from e + data = await _request_json(pull, config.id, day=target_date) # 提取行 rows = _extract_rows(data, pull.response_path) diff --git a/backend/tests/test_ext_pull_auth.py b/backend/tests/test_ext_pull_auth.py new file mode 100644 index 0000000..4eaf4de --- /dev/null +++ b/backend/tests/test_ext_pull_auth.py @@ -0,0 +1,241 @@ +"""扩展数据拉取的 API Key 鉴权注入与密钥管理端点。 + +覆盖: PullConfig.auth 序列化兼容 (历史配置无 auth 字段)、_apply_auth 三型 +注入与缺 Key fail-closed、_request_json 共用请求链 (标识头+鉴权)、 +api-key GET/PUT 端点的脱敏语义、删除配置时清理残留密钥。 +""" +from __future__ import annotations + +import asyncio +from types import SimpleNamespace + +import pytest + +from app.api.ext_data import ( + ApiKeyReq, + PullConfigReq, + configure_pull, + get_pull_api_key, + set_pull_api_key, +) +from app.services import ext_pull +from app.services.ext_data import ( + ExtConfig, + ExtConfigStore, + ExtField, + PullConfig, + ext_api_key_field, + get_ext_api_key, +) + + +def _auth_config(auth: dict | None, **pull_kwargs) -> ExtConfig: + return ExtConfig( + id="demo", + label="demo", + mode="snapshot", + fields=[ExtField("symbol"), ExtField("score", "float")], + pull=PullConfig(url="https://api.example.test/data", auth=auth, **pull_kwargs), + ) + + +# --------------------------------------------------------------------------- +# PullConfig.auth 序列化 +# --------------------------------------------------------------------------- + +def test_pull_config_auth_roundtrip() -> None: + auth = {"type": "bearer", "header": "X-Api-Key", "param": "token"} + pull = PullConfig.from_dict(PullConfig(url="https://x.test", auth=auth).to_dict()) + assert pull.auth == auth + + +def test_pull_config_without_auth_field_reads_as_none() -> None: + """历史 config.json 没有 auth 字段 → None (不加鉴权, 行为不变)。""" + legacy = {"url": "https://x.test", "method": "GET", "headers": {}, "enabled": True} + assert PullConfig.from_dict(legacy).auth is None + + +# --------------------------------------------------------------------------- +# _apply_auth 注入 +# --------------------------------------------------------------------------- + +def _seed_key(monkeypatch, key: str) -> None: + monkeypatch.setattr("app.secrets_store.load", lambda: {ext_api_key_field("demo"): key}) + monkeypatch.delenv("EXT_DEMO_API_KEY", raising=False) + + +def test_apply_auth_bearer_injects_header(monkeypatch) -> None: + _seed_key(monkeypatch, "sk-12345678") + headers: dict[str, str] = {} + url = ext_pull._apply_auth("demo", {"type": "bearer"}, "https://x.test/d", headers) + assert url == "https://x.test/d" + assert headers["Authorization"] == "Bearer sk-12345678" + + +def test_apply_auth_header_uses_custom_name(monkeypatch) -> None: + _seed_key(monkeypatch, "sk-12345678") + headers: dict[str, str] = {} + ext_pull._apply_auth("demo", {"type": "header", "header": "X-Api-Key"}, "https://x.test/d", headers) + assert headers["X-Api-Key"] == "sk-12345678" + + +def test_apply_auth_query_appends_param_url_encoded(monkeypatch) -> None: + _seed_key(monkeypatch, "sk+a&b=1") + headers: dict[str, str] = {} + url = ext_pull._apply_auth("demo", {"type": "query", "param": "apikey"}, "https://x.test/d?page=1", headers) + assert url == "https://x.test/d?page=1&apikey=sk%2Ba%26b%3D1" + assert headers == {} + + +def test_apply_auth_without_key_fails_closed(monkeypatch) -> None: + _seed_key(monkeypatch, "") + with pytest.raises(ValueError, match="未设置 API Key"): + ext_pull._apply_auth("demo", {"type": "bearer"}, "https://x.test/d", {}) + + +def test_apply_auth_unknown_type_rejected(monkeypatch) -> None: + _seed_key(monkeypatch, "sk-12345678") + with pytest.raises(ValueError, match="未知鉴权类型"): + ext_pull._apply_auth("demo", {"type": "digest"}, "https://x.test/d", {}) + + +# --------------------------------------------------------------------------- +# _request_json 共用请求链 (标识头 + 鉴权 + UA 不被覆盖) +# --------------------------------------------------------------------------- + +class _FakeResp: + def raise_for_status(self) -> None: + pass + + def json(self): + return [{"symbol": "600000.SH", "score": 1.0}] + + +class _FakeClient: + last: tuple | None = None + + def __init__(self, timeout=None) -> None: + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + return False + + async def request(self, method, url, **kwargs): + _FakeClient.last = (method, url, kwargs) + return _FakeResp() + + +def test_request_json_injects_auth_and_keeps_user_headers(monkeypatch) -> None: + _seed_key(monkeypatch, "sk-12345678") + monkeypatch.setattr(ext_pull.httpx, "AsyncClient", _FakeClient) + + pull = PullConfig( + url="https://x.test/d", + headers={"User-Agent": "my-agent/1.0"}, + auth={"type": "bearer"}, + date_param="date", + ) + data = asyncio.run(ext_pull._request_json(pull, "demo", day=__import__("datetime").date(2026, 9, 1))) + + assert data == [{"symbol": "600000.SH", "score": 1.0}] + method, url, kwargs = _FakeClient.last + assert method == "GET" + assert url == "https://x.test/d?date=2026-09-01" + assert kwargs["headers"]["Authorization"] == "Bearer sk-12345678" + # 用户显式设置的 User-Agent 优先, 不被标识头覆盖 + assert kwargs["headers"]["User-Agent"] == "my-agent/1.0" + + +# --------------------------------------------------------------------------- +# api-key 端点 + 删除清理 +# --------------------------------------------------------------------------- + +def _request(tmp_path) -> SimpleNamespace: + state = SimpleNamespace(repo=SimpleNamespace(store=SimpleNamespace(data_dir=tmp_path))) + return SimpleNamespace(app=SimpleNamespace(state=state)) + + +def _make_config(tmp_path) -> ExtConfigStore: + store = ExtConfigStore(tmp_path) + store.upsert(ExtConfig( + id="demo", label="demo", mode="snapshot", + fields=[ExtField("symbol"), ExtField("score", "float")], + )) + return store + + +def test_api_key_set_get_and_clear(monkeypatch, tmp_path) -> None: + _make_config(tmp_path) + req = _request(tmp_path) + written: dict = {} + monkeypatch.setattr("app.secrets_store.save", lambda updates: written.update(updates) or updates) + monkeypatch.setattr("app.secrets_store.load", lambda: written) + monkeypatch.setattr( + "app.secrets_store.clear", + lambda *keys: [written.pop(k, None) for k in keys] or {}, + ) + + result = set_pull_api_key(req, "demo", ApiKeyReq(key="sk-12345678")) + assert result["key_set"] is True + # 脱敏: 前缀4 + 掩码 + 后缀4, 不含完整明文 + assert "sk-12345678" not in result["masked_key"] + assert result["masked_key"].startswith("sk-1") + assert written == {"ext_demo_api_key": "sk-12345678"} + + status = get_pull_api_key(req, "demo") + assert status["key_set"] is True + assert "sk-12345678" not in status["masked_key"] + + cleared = set_pull_api_key(req, "demo", ApiKeyReq(key=" ")) + assert cleared["key_set"] is False + assert cleared["masked_key"] == "" + assert written == {} + + +def test_api_key_endpoint_unknown_config_404(tmp_path) -> None: + req = _request(tmp_path) + with pytest.raises(Exception, match="不存在"): + set_pull_api_key(req, "nope", ApiKeyReq(key="sk-x")) + + +def test_configure_pull_omitted_auth_preserves_existing(monkeypatch, tmp_path) -> None: + """PUT /pull 请求不带 auth → 沿用现有鉴权; 显式 none → 关闭。""" + monkeypatch.setattr("app.api.ext_data.pull_scheduler.refresh", lambda *a, **k: None) + store = _make_config(tmp_path) + req = _request(tmp_path) + + body = PullConfigReq(url="https://x.test/d", auth=None) + configure_pull(req, "demo", body) + assert store.get("demo").pull.auth is None + + # 显式设置 bearer 后, 后续不带 auth 的保存不应清掉它 + configure_pull(req, "demo", PullConfigReq(url="https://x.test/d", auth={"type": "bearer", "header": "X-Key"})) + configure_pull(req, "demo", PullConfigReq(url="https://x.test/d2")) + # model_dump 带全默认值 (param 对 bearer 无效但保留, from_dict 可原样读回) + assert store.get("demo").pull.auth == {"type": "bearer", "header": "X-Key", "param": "token"} + assert store.get("demo").pull.url == "https://x.test/d2" + + configure_pull(req, "demo", PullConfigReq(url="https://x.test/d", auth={"type": "none"})) + assert store.get("demo").pull.auth == {"type": "none", "header": "Authorization", "param": "token"} + + +def test_delete_config_clears_residual_key(monkeypatch, tmp_path) -> None: + from app.api.ext_data import delete_config + + _make_config(tmp_path) + req = _request(tmp_path) + monkeypatch.setattr("app.api.ext_data._refresh_views", lambda request: None) + cleared: list[str] = [] + monkeypatch.setattr("app.secrets_store.clear", lambda *keys: cleared.extend(keys) or {}) + + assert delete_config(req, "demo") == {"status": "deleted"} + assert cleared == ["ext_demo_api_key"] + + +def test_get_ext_api_key_env_fallback(monkeypatch) -> None: + monkeypatch.setattr("app.secrets_store.load", lambda: {}) + monkeypatch.setenv("EXT_DEMO_API_KEY", "env-key-123456") + assert get_ext_api_key("demo") == "env-key-123456" diff --git a/frontend/src/components/data/SettingsModal.tsx b/frontend/src/components/data/SettingsModal.tsx index d3a11fb..d5bb48b 100644 --- a/frontend/src/components/data/SettingsModal.tsx +++ b/frontend/src/components/data/SettingsModal.tsx @@ -2,7 +2,13 @@ import { motion } from 'framer-motion' import { X } from 'lucide-react' import { useDialogBackdrop } from '@/lib/useDialogBackdrop' -export function SettingsModal({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) { +export function SettingsModal({ title, onClose, children, width = 'max-w-md' }: { + title: string + onClose: () => void + children: React.ReactNode + /** 弹窗最大宽度 (Tailwind max-w-* 类), 默认 max-w-md */ + width?: string +}) { const backdrop = useDialogBackdrop(onClose) return (
@@ -12,15 +18,15 @@ export function SettingsModal({ title, onClose, children }: { title: string; onC animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.97, y: 8 }} transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }} - className="relative rounded-card border border-border bg-surface shadow-2xl mx-4 w-full max-w-md overflow-hidden" + className={`relative rounded-card border border-border bg-surface shadow-2xl mx-4 w-full ${width} max-h-[88vh] flex flex-col overflow-hidden`} > -
+

{title}

-
+
{children}
diff --git a/frontend/src/components/ext-data/EditExtDialog.tsx b/frontend/src/components/ext-data/EditExtDialog.tsx index abe8080..0a40905 100644 --- a/frontend/src/components/ext-data/EditExtDialog.tsx +++ b/frontend/src/components/ext-data/EditExtDialog.tsx @@ -87,7 +87,7 @@ export function EditExtDialog({ config, onClose }: { config: ExtDataConfig; onCl animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.97, y: 8 }} transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }} - className="relative rounded-2xl border border-border bg-surface shadow-2xl mx-4 w-full max-w-lg max-h-[85vh] flex flex-col overflow-hidden" + className="relative rounded-2xl border border-border bg-surface shadow-2xl mx-4 w-full max-w-2xl max-h-[85vh] flex flex-col overflow-hidden" >

编辑扩展数据

diff --git a/frontend/src/components/ext-data/ExtDataPullPanel.tsx b/frontend/src/components/ext-data/ExtDataPullPanel.tsx index 2434b0b..d470c76 100644 --- a/frontend/src/components/ext-data/ExtDataPullPanel.tsx +++ b/frontend/src/components/ext-data/ExtDataPullPanel.tsx @@ -1,8 +1,15 @@ -import { useState } from 'react' -import { Loader2, Search, Check, Clock, Zap, Settings2, AlertCircle, CheckCircle2, Calendar, History } from 'lucide-react' -import { api, type ExtDataBackfillResult, type ExtDataConfig } from '@/lib/api' +import { useEffect, useState } from 'react' +import { Loader2, Search, Check, Clock, Zap, Settings2, AlertCircle, CheckCircle2, Calendar, History, KeyRound } from 'lucide-react' +import { api, type ExtDataBackfillResult, type ExtDataConfig, type ExtPullAuth } from '@/lib/api' import { toast } from '@/components/Toast' +const AUTH_TYPE_LABELS: Record = { + none: '无', + bearer: 'Bearer Token', + header: '自定义请求头', + query: 'URL 查询参数', +} + export function ExtDataPullPanel({ config, onSaved }: { config: ExtDataConfig onSaved: () => void @@ -23,6 +30,19 @@ export function ExtDataPullPanel({ config, onSaved }: { const [timeWindowEnd, setTimeWindowEnd] = useState(pull?.time_window_end ?? '') const [dateParam, setDateParam] = useState(pull?.date_param ?? '') const [enabled, setEnabled] = useState(pull?.enabled ?? false) + + // 接口鉴权: 方式入 pull 配置; Key 本体只存后端 secrets.json + const [authType, setAuthType] = useState(pull?.auth?.type ?? 'none') + const [authHeader, setAuthHeader] = useState(pull?.auth?.header ?? 'Authorization') + const [authParam, setAuthParam] = useState(pull?.auth?.param ?? 'token') + const [apiKey, setApiKey] = useState('') + const [keyDirty, setKeyDirty] = useState(false) + const [keyInfo, setKeyInfo] = useState<{ key_set: boolean; masked_key: string } | null>(null) + + useEffect(() => { + api.extDataApiKey(config.id).then(setKeyInfo).catch(() => {}) + }, [config.id]) + const [saving, setSaving] = useState(false) const [testing, setTesting] = useState(false) const [running, setRunning] = useState(false) @@ -51,9 +71,14 @@ export function ExtDataPullPanel({ config, onSaved }: { if (headers === null) return null const field_map = parseJson(fieldMapStr, '字段映射') if (field_map === null) return null + const auth: ExtPullAuth = { + type: authType, + header: authHeader.trim() || 'Authorization', + param: authParam.trim() || 'token', + } return { url, method, headers, body: body || undefined, - response_path: responsePath, field_map, + response_path: responsePath, field_map, auth, schedule_minutes: schedule, enabled: enabledOverride ?? enabled, time_window_start: timeWindowStart || null, time_window_end: timeWindowEnd || null, @@ -61,11 +86,22 @@ export function ExtDataPullPanel({ config, onSaved }: { } } + // Key 输入有改动时随配置一起保存 (空输入=清除); 未改动则跳过 + const saveKeyIfNeeded = () => + keyDirty + ? api.extDataApiKeySet(config.id, apiKey).then(r => { + setKeyInfo({ key_set: r.key_set, masked_key: r.masked_key }) + setKeyDirty(false) + setApiKey('') + }) + : Promise.resolve() + const handleSave = (silent = false) => { const payload = buildPayload() if (!payload) return setSaving(true); setError('') - api.extDataPullConfig(config.id, payload) + saveKeyIfNeeded() + .then(() => api.extDataPullConfig(config.id, payload)) .then(() => { onSaved() if (!silent) toast('配置已保存', 'success') @@ -78,7 +114,8 @@ export function ExtDataPullPanel({ config, onSaved }: { setTesting(true); setError(''); setTestResult(null) const payload = buildPayload() if (!payload) { setTesting(false); return } - api.extDataPullConfig(config.id, payload) + saveKeyIfNeeded() + .then(() => api.extDataPullConfig(config.id, payload)) .then(() => api.extDataPullTest(config.id)) .then(r => { setTestResult(r); onSaved() }) .catch(e => setError(e.message || '测试失败')) @@ -121,7 +158,8 @@ export function ExtDataPullPanel({ config, onSaved }: { const payload = buildPayload(next) if (!payload) return setToggling(true); setError(''); setEnabled(next) - api.extDataPullConfig(config.id, payload) + saveKeyIfNeeded() + .then(() => api.extDataPullConfig(config.id, payload)) .then(() => { onSaved() toast(next ? '定时拉取已启用 · 立即执行首次拉取' : '定时拉取已关闭', 'success') @@ -173,12 +211,76 @@ export function ExtDataPullPanel({ config, onSaved }: {
Headers (JSON,可选)