mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 15:34:16 +08:00
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:
+61
-16
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user