feat(ext-data): 拉取接口支持 API Key 鉴权 + 扩展数据弹窗加宽可滚动

后端:
- PullConfig 新增 auth (none/bearer/header/query, 与自定义行情源
  AuthConfig 同口径); 拉取/测试/回补共用 _request_json, 鉴权注入
  只有一套口径, 用户自定义 Headers 仍优先于 UA 标识头
- Key 本体存 secrets_store (data/user_data/secrets.json, 0600),
  不落 config.json; EXT_{ID}_API_KEY 环境变量兜底; 配置了鉴权但
  未设 Key 时 fail-closed 拒绝请求
- 新增 GET/PUT /api/ext-data/{id}/api-key (GET 只返回脱敏值);
  删除配置时清理残留 Key; 历史 config.json 无 auth 字段读为 None
  行为不变; PUT /pull 不带 auth 时沿用现有鉴权

前端:
- 拉取面板新增"接口鉴权"区块: 方式下拉 + 请求头名/参数名 + Key
  密码框 (输入新 Key 覆盖, 清空保存删除), 随保存/测试/开关一起生效
- SettingsModal 加 width prop, 扩展数据设置弹窗与 EditExtDialog
  加宽至 max-w-2xl; SettingsModal 加 max-h-[88vh] + 内容区独立
  滚动, 矮视口下不再被截断
This commit is contained in:
shy3130
2026-09-07 22:54:05 +08:00
parent f8a23b7ce5
commit 908b385501
9 changed files with 525 additions and 48 deletions
+61 -16
View File
@@ -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 {
+21
View File
@@ -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,6 +129,21 @@ 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"
)
+58 -18
View File
@@ -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 <key>"},
header → {header: <key>}, query → ?{param}=<key>。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)
+241
View File
@@ -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"
+10 -4
View File
@@ -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 (
<div className="fixed inset-0 z-50 flex items-center justify-center">
@@ -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`}
>
<div className="flex items-center justify-between px-5 py-3 border-b border-border">
<div className="flex items-center justify-between px-5 py-3 border-b border-border shrink-0">
<h3 className="text-sm font-medium text-foreground">{title}</h3>
<button onClick={onClose} className="p-0.5 rounded hover:bg-elevated text-secondary">
<X className="h-4 w-4" />
</button>
</div>
<div className="p-5">
<div className="p-5 flex-1 overflow-y-auto">
{children}
</div>
</motion.div>
@@ -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"
>
<div className="flex items-center justify-between px-5 py-3 border-b border-border">
<h3 className="text-sm font-medium text-foreground"></h3>
@@ -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<ExtPullAuth['type'], string> = {
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<ExtPullAuth['type']>(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 }: {
<div className="text-[10px] text-muted mb-1">Headers (JSON)</div>
<textarea
value={headerStr} onChange={e => setHeaderStr(e.target.value)}
placeholder='{"Authorization": "Bearer xxx"}'
placeholder='{"X-Custom": "value"}'
rows={2}
className="w-full rounded-btn border border-border bg-elevated px-2.5 py-1.5 text-[10px] font-mono text-foreground placeholder:text-muted/40 resize-none"
/>
</div>
{/* ===== 接口鉴权 (API Key) ===== */}
<div className="rounded-card border border-border/60 bg-elevated/30 p-2.5 space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 text-[11px] font-medium text-secondary">
<KeyRound className="h-3 w-3 text-muted" />
<span> (API Key)</span>
</div>
{authType !== 'none' && keyInfo && (
<span className={`text-[9px] ${keyInfo.key_set ? 'text-emerald-500' : 'text-amber-500'}`}>
{keyInfo.key_set ? `已设置 ${keyInfo.masked_key}` : '未设置 Key'}
</span>
)}
</div>
<div className={`grid gap-2 ${authType === 'none' ? '' : 'grid-cols-2'}`}>
<div>
<div className="text-[10px] text-muted mb-1"></div>
<select
value={authType}
onChange={e => setAuthType(e.target.value as ExtPullAuth['type'])}
className="w-full rounded-btn border border-border bg-elevated px-2 py-1.5 text-[11px] text-foreground"
>
{Object.entries(AUTH_TYPE_LABELS).map(([v, label]) => (
<option key={v} value={v}>{label}</option>
))}
</select>
</div>
{authType === 'query' && (
<div>
<div className="text-[10px] text-muted mb-1"></div>
<input
value={authParam} onChange={e => setAuthParam(e.target.value)}
placeholder="token"
className="w-full rounded-btn border border-border bg-elevated px-2 py-1.5 text-[10px] font-mono text-foreground placeholder:text-muted/40"
/>
</div>
)}
{(authType === 'bearer' || authType === 'header') && (
<div>
<div className="text-[10px] text-muted mb-1"></div>
<input
value={authHeader} onChange={e => setAuthHeader(e.target.value)}
placeholder="Authorization"
className="w-full rounded-btn border border-border bg-elevated px-2 py-1.5 text-[10px] font-mono text-foreground placeholder:text-muted/40"
/>
</div>
)}
</div>
{authType !== 'none' && (
<>
<input
type="password"
value={apiKey}
onChange={e => { setApiKey(e.target.value); setKeyDirty(true) }}
autoComplete="new-password"
placeholder={keyInfo?.key_set ? '输入新 Key 覆盖 · 清空后保存 = 删除' : '输入 API Key'}
className="w-full rounded-btn border border-border bg-elevated px-2.5 py-1.5 text-[11px] font-mono text-foreground placeholder:text-muted/40"
/>
<div className="text-[9px] text-muted/70">
Key secrets.json ()"保存配置 / 测试"
</div>
</>
)}
</div>
{method === 'POST' && (
<div>
<div className="text-[10px] text-muted mb-1"> (JSON)</div>
@@ -144,7 +144,7 @@ export function ExtDataStatCard({ config, onDelete, deleting, onEdit }: {
<AnimatePresence>
{settingsOpen && (
<SettingsModal title={`${config.label} · 设置`} onClose={() => setSettingsOpen(false)}>
<SettingsModal title={`${config.label} · 设置`} onClose={() => setSettingsOpen(false)} width="max-w-2xl">
<div className="space-y-3">
{onEdit && (
<button
+22
View File
@@ -2971,12 +2971,26 @@ export const api = {
schedule_minutes?: number; enabled?: boolean;
time_window_start?: string | null; time_window_end?: string | null;
date_param?: string | null;
auth?: ExtPullAuth;
}) =>
request<{ status: string; pull: PullConfig }>(
`/api/ext-data/${id}/pull`,
{ method: 'PUT', body: JSON.stringify(body) },
),
/** 查询拉取接口 API Key 状态 (脱敏, 不返回明文) */
extDataApiKey: (id: string) =>
request<{ key_set: boolean; masked_key: string }>(
`/api/ext-data/${encodeURIComponent(id)}/api-key`,
),
/** 设置 (或空串清除) 拉取接口的 API Key */
extDataApiKeySet: (id: string, key: string) =>
request<{ status: string; key_set: boolean; masked_key: string }>(
`/api/ext-data/${encodeURIComponent(id)}/api-key`,
{ method: 'PUT', body: JSON.stringify({ key }) },
),
extDataPullTest: (id: string) =>
request<{ status: string; total_rows: number; preview: Record<string, unknown>[]; has_symbol: boolean }>(
`/api/ext-data/${id}/pull/test`,
@@ -3688,6 +3702,13 @@ export interface ExtDataField {
label: string
}
/** 拉取接口鉴权方式; Key 本体存 secrets_store, 不出现在配置里 */
export interface ExtPullAuth {
type: 'none' | 'bearer' | 'header' | 'query'
header?: string
param?: string
}
export interface PullConfig {
url: string
method: string
@@ -3706,6 +3727,7 @@ export interface PullConfig {
time_window_end?: string | null
/** 接口按日查询的参数名 (如 "date"): 配置后支持历史回补 */
date_param?: string | null
auth?: ExtPullAuth | null
}
export interface ExtDataBackfillResult {