mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 14:24:15 +08:00
feat(plugins): 新增 fuyao 同花顺官方实时行情插件(默认 hidden)与插件 Key 界面配置
- fuyao 插件: 全市场实时快照分页拉取, 字段对齐数据契约(pct 百分制÷100), 兼容官方文档与实际返回两种信封/字段名; 优化完成前 plugin.yaml 标记 hidden: true, 加载器跳过注册不展示, 删除该行即可开放 - plugin.yaml 新增 api_key_env 约定: 设置页提供 Key 输入框, 先探后存 (POST/DELETE /api/settings/plugin-key), secrets.json 优先于 .env - secrets_store.get_env_backed_secret(field, env_name) 支持插件取 Key - loader: 插件状态透出 api_key_env, 新增 probe_plugin_key 委托探测 - 31 个插件测试; docs/plugin-development.md 补充约定说明
This commit is contained in:
@@ -401,6 +401,11 @@ class DataProvidersIn(BaseModel):
|
||||
financial_data_provider: str | None = None
|
||||
|
||||
|
||||
class PluginKeyIn(BaseModel):
|
||||
plugin: str
|
||||
api_key: str
|
||||
|
||||
|
||||
class DataSourceJobTimeoutPrefs(BaseModel):
|
||||
data_source_job_timeout_s: int = Field(ge=60)
|
||||
data_source_long_job_timeout_s: int = Field(ge=60)
|
||||
@@ -536,6 +541,53 @@ def list_data_sources() -> dict:
|
||||
}
|
||||
|
||||
|
||||
@router.post("/plugin-key")
|
||||
def save_plugin_key(req: PluginKeyIn) -> dict:
|
||||
"""保存插件 API Key(先探后存, 对齐 /tickflow-key 语义)。
|
||||
|
||||
流程: probe_plugin_key 用候选 Key 实探 → 有效才写 secrets.json
|
||||
({plugin}_api_key, 优先级高于 .env) → load_all 重扫, 插件即刻变为可切换。
|
||||
"""
|
||||
from app.data_providers import custom as custom_sources
|
||||
|
||||
name = req.plugin.strip().lower()
|
||||
key = req.api_key.strip()
|
||||
if not key:
|
||||
return {"ok": False, "error": "key empty"}
|
||||
ok, message = custom_sources.probe_plugin_key(name, key)
|
||||
if not ok:
|
||||
return {"ok": False, "reason": "invalid", "error": message}
|
||||
secrets_store.save({f"{name}_api_key": key})
|
||||
custom_sources.load_all()
|
||||
status = next((p for p in custom_sources.list_plugins() if p["name"] == name), None)
|
||||
return {
|
||||
"ok": True,
|
||||
"api_key_masked": secrets_store.mask(key),
|
||||
"plugin_available": bool(status and status.get("available")),
|
||||
"plugin": status,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/plugin-key/{name}")
|
||||
def clear_plugin_key(name: str) -> dict:
|
||||
"""清除插件的界面配置 Key(secrets.json);.env 里的同名变量仍然生效。"""
|
||||
from app.data_providers import custom as custom_sources
|
||||
|
||||
manifest = custom_sources.plugin_manifest(name)
|
||||
if manifest is None or not custom_sources.is_builtin(name):
|
||||
raise HTTPException(status_code=404, detail=f"插件 '{name}' 不存在")
|
||||
if not manifest.get("api_key_env"):
|
||||
raise HTTPException(status_code=400, detail=f"插件 '{name}' 不支持在界面配置 Key")
|
||||
secrets_store.clear(f"{name.lower()}_api_key")
|
||||
custom_sources.load_all()
|
||||
status = next((p for p in custom_sources.list_plugins() if p["name"] == name), None)
|
||||
return {
|
||||
"ok": True,
|
||||
"plugin_available": bool(status and status.get("available")),
|
||||
"plugin": status,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/data-sources/reload")
|
||||
def reload_data_sources() -> dict:
|
||||
"""重新加载 data_sources/*.yaml。"""
|
||||
|
||||
@@ -13,6 +13,8 @@ from app.data_providers.custom.loader import (
|
||||
list_sources,
|
||||
load_all,
|
||||
names,
|
||||
plugin_manifest,
|
||||
probe_plugin_key,
|
||||
provider_has_dataset,
|
||||
save_config,
|
||||
uninstall_plugin,
|
||||
@@ -32,6 +34,8 @@ __all__ = [
|
||||
"list_sources",
|
||||
"load_all",
|
||||
"names",
|
||||
"plugin_manifest",
|
||||
"probe_plugin_key",
|
||||
"provider_has_dataset",
|
||||
"save_config",
|
||||
"uninstall_plugin",
|
||||
|
||||
@@ -103,6 +103,33 @@ def plugin_dir_of(name: str) -> Path:
|
||||
return plugins_dir() / (name or "")
|
||||
|
||||
|
||||
def probe_plugin_key(name: str, api_key: str) -> tuple[bool, str]:
|
||||
"""调用插件的 probe_api_key(key) 探测候选 Key(不落盘)。
|
||||
|
||||
约定: 声明了 api_key_env 的插件, 其 entry 模块提供模块级
|
||||
probe_api_key(key) -> (ok, reason)。未声明或未提供 → (False, 原因)。
|
||||
"""
|
||||
manifest = plugin_manifest(name)
|
||||
if manifest is None:
|
||||
return False, f"插件 '{name}' 不存在"
|
||||
if not manifest.get("api_key_env"):
|
||||
return False, f"插件 '{name}' 不支持在界面配置 Key"
|
||||
entry = str(manifest.get("entry") or "")
|
||||
if ":" not in entry:
|
||||
return False, f"插件 '{name}' entry 非法"
|
||||
try:
|
||||
module = importlib.import_module(entry.split(":", 1)[0])
|
||||
except Exception as e:
|
||||
return False, f"插件模块加载失败: {e}"
|
||||
probe = getattr(module, "probe_api_key", None)
|
||||
if probe is None:
|
||||
return False, f"插件 '{name}' 未提供 Key 探测"
|
||||
try:
|
||||
return probe(api_key)
|
||||
except Exception as e:
|
||||
return False, f"探测失败: {e}"
|
||||
|
||||
|
||||
def install_plugin(name: str) -> tuple[bool, str]:
|
||||
"""安装指定插件的依赖。根据 runtime 执行 npm install / pip install。
|
||||
|
||||
@@ -509,6 +536,10 @@ def _register_one_plugin(manifest: dict) -> None:
|
||||
if not name or not _NAME_RE.match(name):
|
||||
logger.warning("插件清单缺少合法 name: %r", name)
|
||||
return
|
||||
# hidden: 已加载但对 UI 隐藏 (功能未完成/暂不开放), 不注册不展示
|
||||
if manifest.get("hidden"):
|
||||
logger.info("插件 %s 标记为 hidden, 跳过注册", name)
|
||||
return
|
||||
runtime = str(manifest.get("runtime", "none")).lower()
|
||||
# 委托检测: 调用插件自己的 check 函数 (node 型/python 型各自实现)
|
||||
available, reason = _call_check(manifest.get("check"))
|
||||
@@ -521,6 +552,7 @@ def _register_one_plugin(manifest: dict) -> None:
|
||||
"status": reason,
|
||||
"description": manifest.get("description", ""),
|
||||
"install_hint": manifest.get("install_hint", ""),
|
||||
"api_key_env": manifest.get("api_key_env", ""),
|
||||
}
|
||||
if not available:
|
||||
return # 依赖没装: 不注册, 但状态已记录供 UI 显示
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""扶摇(同花顺金融数据 API)内置数据源插件。"""
|
||||
@@ -0,0 +1,108 @@
|
||||
"""扶摇(同花顺金融数据 API) HTTP 客户端。
|
||||
|
||||
职责: 认证、统一信封解包、分页拉取快照。不知道 provider / services 层。
|
||||
文档: https://fuyao.aicubes.cn/docs — REST + X-api-key, 响应信封 {code, message, data}。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BASE_URL = "https://fuyao.aicubes.cn"
|
||||
|
||||
# A 股约 5400 只, 500/页约 11 页; 50 页上限防御 count 异常导致的死循环。
|
||||
_SNAPSHOT_PAGE_SIZE = 500
|
||||
_SNAPSHOT_MAX_PAGES = 50
|
||||
_PAGE_INTERVAL_S = 0.15 # 页间隔, 降低触发限频 (code=4001) 的概率
|
||||
|
||||
|
||||
class FuyaoError(Exception):
|
||||
"""扶摇接口错误(配置缺失 / 网络失败 / 信封 code != 0)。"""
|
||||
|
||||
|
||||
class FuyaoClient:
|
||||
"""扶摇 REST 客户端 (线程安全: httpx.Client 可并发复用)。"""
|
||||
|
||||
def __init__(self, api_key: str, base_url: str = BASE_URL, timeout: float = 20.0) -> None:
|
||||
if not api_key:
|
||||
raise FuyaoError("未配置 FUYAO_API_KEY")
|
||||
self.last_server_ts = 0 # 最近一页响应里的服务端时间戳(ms), 供行情归属
|
||||
self._http = httpx.Client(
|
||||
base_url=base_url,
|
||||
headers={"X-api-key": api_key},
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
self._http.close()
|
||||
|
||||
# ---- 内部 ----
|
||||
def _get(self, path: str, params: dict) -> dict:
|
||||
"""GET + 信封解包。code != 0 时抛 FuyaoError(含 code 与 message)。"""
|
||||
try:
|
||||
resp = self._http.get(path, params=params)
|
||||
except httpx.HTTPError as e:
|
||||
raise FuyaoError(f"网络请求失败: {e}") from e
|
||||
if resp.status_code != 200:
|
||||
raise FuyaoError(f"HTTP {resp.status_code}: {path}")
|
||||
try:
|
||||
payload = resp.json()
|
||||
except ValueError as e:
|
||||
raise FuyaoError(f"响应不是 JSON: {path}") from e
|
||||
code = payload.get("code")
|
||||
if code not in (0, "0", None):
|
||||
raise FuyaoError(f"扶摇接口错误 code={code}: {payload.get('message', '')} ({path})")
|
||||
return payload.get("data") or {}
|
||||
|
||||
# ---- 快照 ----
|
||||
def snapshot_page(self, limit: int = _SNAPSHOT_PAGE_SIZE, offset: int = 0) -> tuple[list[dict], int]:
|
||||
"""拉取一页 A 股全市场快照。返回 (rows, total), total 为全市场总数。
|
||||
|
||||
实测响应(2026-08): data={timestamp, total, item}; 官方文档示例为
|
||||
data={count, data}。两者都兼容, 以实测为准。
|
||||
"""
|
||||
data = self._get("/api/a-share/prices/snapshot", {"limit": limit, "offset": offset})
|
||||
try:
|
||||
self.last_server_ts = int(data.get("timestamp") or 0)
|
||||
except (TypeError, ValueError):
|
||||
self.last_server_ts = 0
|
||||
rows = data.get("item")
|
||||
if not isinstance(rows, list):
|
||||
rows = data.get("data") if isinstance(data.get("data"), list) else []
|
||||
raw_total = data.get("total")
|
||||
if raw_total is None:
|
||||
raw_total = data.get("count") or 0
|
||||
try:
|
||||
total = int(raw_total or 0)
|
||||
except (TypeError, ValueError):
|
||||
total = 0
|
||||
return rows, total
|
||||
|
||||
def snapshot_all(self) -> tuple[list[dict], int]:
|
||||
"""分页拉取全市场快照。返回 (rows, 服务端时间戳ms)。
|
||||
|
||||
服务端时间戳用于行情归属; 缺失时返回 0, 由调用方退回本地时间。
|
||||
空数据 / 中途失败时抛 FuyaoError。
|
||||
"""
|
||||
out: list[dict] = []
|
||||
server_ts = 0
|
||||
offset = 0
|
||||
for page in range(_SNAPSHOT_MAX_PAGES):
|
||||
if page > 0:
|
||||
time.sleep(_PAGE_INTERVAL_S)
|
||||
rows, total = self.snapshot_page(offset=offset)
|
||||
if not rows:
|
||||
break
|
||||
out.extend(rows)
|
||||
if not server_ts:
|
||||
server_ts = self.last_server_ts
|
||||
if total and len(out) >= total:
|
||||
break
|
||||
offset += len(rows)
|
||||
if not out:
|
||||
raise FuyaoError("全市场快照为空")
|
||||
return out, server_ts
|
||||
@@ -0,0 +1,14 @@
|
||||
# 扶摇 — 同花顺官方金融数据 API 插件 (https://fuyao.aicubes.cn)
|
||||
# 纯 HTTP REST 源, 无额外依赖 (runtime: none)。
|
||||
# 需在 .env 或环境变量配置 FUYAO_API_KEY, 未配置时插件灰显不可切换。
|
||||
|
||||
name: fuyao
|
||||
display_name: "fuyao"
|
||||
runtime: none
|
||||
entry: app.plugins.fuyao.provider:FuyaoProvider
|
||||
check: app.plugins.fuyao.provider:availability
|
||||
datasets: [realtime]
|
||||
api_key_env: FUYAO_API_KEY # 声明后设置页提供 Key 输入框(先探后存, secrets.json 优先)
|
||||
hidden: true # 优化完成前不在数据源页展示; 删除此行即可恢复
|
||||
description: "同花顺官方 REST 数据 API。当前提供 A 股全市场实时快照(分页拉取);日K/分钟/财务未接入,自动回退 TickFlow。"
|
||||
install_hint: "点击卡片中的输入框配置 API Key(https://fuyao.aicubes.cn 申请),或在 .env 中配置 FUYAO_API_KEY"
|
||||
@@ -0,0 +1,195 @@
|
||||
"""扶摇(同花顺金融数据 API)内置数据源 provider。
|
||||
|
||||
方法签名对齐 custom.GenericHTTPProvider(service 分流点按这套签名调用),
|
||||
注入 custom loader 注册表后, 各 service 无需改动即可路由到本 provider。
|
||||
|
||||
当前实现数据集: realtime (A 股全市场快照, 分页)。
|
||||
未声明 daily / minute / financial → provider_has_dataset 为 False, 自动回退 tickflow。
|
||||
|
||||
单位口径 (CONTRIBUTING §3.1, 不可凭字段名推断):
|
||||
- 扶摇 price_change_ratio_pct 为百分数数值 (1.74 = +1.74%), 本项目 realtime
|
||||
change_pct 契约为小数制 (0.0174 = 1.74%) → 此处显式 / 100。
|
||||
- volume 单位股、turnover 单位元, 与内部契约一致, 直接透传。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from app.plugins.fuyao import client as fuyao_client
|
||||
from app.plugins.fuyao.client import FuyaoClient, FuyaoError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 只声明真实提供的数据集; 其余数据集 provider_has_dataset 返回 False → 回退 tickflow
|
||||
_DATASETS = ("realtime",)
|
||||
|
||||
API_KEY_ENV = "FUYAO_API_KEY"
|
||||
SECRETS_FIELD = "fuyao_api_key" # UI 配置的 Key 存 secrets.json, 优先级高于 .env
|
||||
|
||||
|
||||
def get_api_key() -> str:
|
||||
from app import secrets_store
|
||||
return secrets_store.get_env_backed_secret(SECRETS_FIELD, API_KEY_ENV)
|
||||
|
||||
|
||||
def availability() -> tuple[bool, str]:
|
||||
"""loader 启动自检: API Key 已配置(secrets.json 或 .env)才注册为可切换数据源。不抛异常。"""
|
||||
if get_api_key():
|
||||
return True, "ok"
|
||||
return False, f"未配置 {API_KEY_ENV}(可在设置页数据源卡片中直接填写)"
|
||||
|
||||
|
||||
def probe_api_key(api_key: str) -> tuple[bool, str]:
|
||||
"""用候选 Key 实探一次快照接口(先探后存, 对齐 /tickflow-key 语义)。不落盘。"""
|
||||
client = None
|
||||
try:
|
||||
client = fuyao_client.FuyaoClient(api_key=api_key, timeout=10.0)
|
||||
client.snapshot_page(limit=1)
|
||||
return True, "ok"
|
||||
except FuyaoError as e:
|
||||
return False, f"Key 无效或网络失败: {e}"
|
||||
finally:
|
||||
if client is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
client.close()
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FuyaoConfig:
|
||||
"""轻量 config shim, 让 custom loader 的 provider_has_dataset 能识别本 provider。"""
|
||||
|
||||
name: str = "fuyao"
|
||||
display_name: str = "fuyao"
|
||||
datasets: dict = field(default_factory=lambda: dict.fromkeys(_DATASETS))
|
||||
path: None = None
|
||||
builtin: bool = True
|
||||
|
||||
|
||||
def _to_float(value) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _first(row: dict, *names: str):
|
||||
"""按优先级取第一个非 None 字段。实测字段名与官方文档示例不一致, 两者兼容。"""
|
||||
for n in names:
|
||||
if row.get(n) is not None:
|
||||
return row.get(n)
|
||||
return None
|
||||
|
||||
|
||||
def _map_snapshot_row(row: dict, fetched_ms: int) -> dict | None:
|
||||
"""扶摇快照行 → 内部 realtime record。字段缺失时按依赖推导, 不伪造数据。
|
||||
|
||||
实测字段(2026-08): high_price / low_price / prev_price;
|
||||
官方文档示例: highest_price / lowest_price / prev_close_price。两者都取。
|
||||
"""
|
||||
symbol = row.get("thscode")
|
||||
if not symbol:
|
||||
return None
|
||||
last = _to_float(row.get("last_price"))
|
||||
prev = _to_float(_first(row, "prev_price", "prev_close_price"))
|
||||
|
||||
# 百分数 (1.74 = +1.74%) → 小数制 (0.0174), 契约见模块 docstring
|
||||
pct = _to_float(row.get("price_change_ratio_pct"))
|
||||
change_pct = pct / 100.0 if pct is not None else None
|
||||
|
||||
change_amount = _to_float(row.get("price_change"))
|
||||
if change_amount is None and last is not None and prev is not None:
|
||||
change_amount = last - prev
|
||||
if change_pct is None and change_amount is not None and prev not in (None, 0):
|
||||
# 与 quote_service 的推导同口径: 小数制, 不乘 100
|
||||
change_pct = change_amount / prev
|
||||
|
||||
return {
|
||||
"symbol": symbol,
|
||||
"name": row.get("name"), # 快照无名称, 由下游维表关联
|
||||
"last_price": last,
|
||||
"prev_close": prev,
|
||||
"open": _to_float(row.get("open_price")),
|
||||
"high": _to_float(_first(row, "high_price", "highest_price")),
|
||||
"low": _to_float(_first(row, "low_price", "lowest_price")),
|
||||
"volume": _to_float(row.get("volume")),
|
||||
"amount": _to_float(row.get("turnover")),
|
||||
"change_pct": change_pct,
|
||||
"change_amount": change_amount,
|
||||
"amplitude": None, # 快照未提供, 不启发式计算
|
||||
"turnover_rate": None, # 需股本口径 (§3.4), 交给 enriched 管道用历史股本计算
|
||||
"timestamp": fetched_ms,
|
||||
"session": None,
|
||||
}
|
||||
|
||||
|
||||
class FuyaoProvider:
|
||||
"""扶摇数据源。realtime = A 股全市场快照(quote_service 全市场模式轮询调用)。"""
|
||||
|
||||
name = "fuyao"
|
||||
builtin = True
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.config = _FuyaoConfig()
|
||||
self._client: FuyaoClient | None = None
|
||||
|
||||
def close(self) -> None: # loader.load_all 重建注册表时会对每个 provider 调 close
|
||||
if self._client is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
self._client.close()
|
||||
self._client = None
|
||||
|
||||
def _get_client(self) -> FuyaoClient:
|
||||
if self._client is None:
|
||||
self._client = fuyao_client.FuyaoClient(api_key=get_api_key())
|
||||
return self._client
|
||||
|
||||
# ---- realtime ----
|
||||
def get_realtime(self) -> list[dict]:
|
||||
"""全市场实时快照 → 内部 realtime records。失败软返回空列表(不阻断轮询)。"""
|
||||
try:
|
||||
rows, server_ts = self._get_client().snapshot_all()
|
||||
except FuyaoError as e:
|
||||
logger.warning("扶摇实时行情拉取失败: %s", e)
|
||||
return []
|
||||
|
||||
# 优先用服务端时间戳(行情归属); 缺失时退回本地时间
|
||||
fetched_ms = server_ts or int(time.time() * 1000)
|
||||
|
||||
records = []
|
||||
dropped = 0
|
||||
for row in rows:
|
||||
rec = _map_snapshot_row(row, fetched_ms)
|
||||
if rec is not None:
|
||||
records.append(rec)
|
||||
else:
|
||||
dropped += 1
|
||||
if dropped and not records:
|
||||
# 整页都识别不出 thscode → 大概率接口 schema 变了, 明确告警而非静默空数据
|
||||
logger.warning("扶摇快照 %d 行全部缺少 thscode 字段, 疑似接口结构变化", dropped)
|
||||
return []
|
||||
logger.info("扶摇实时行情拉取完成: %d 条(丢弃 %d 行)", len(records), dropped)
|
||||
return records
|
||||
|
||||
# ---- 测试(设置页试拉) ----
|
||||
def test_dataset(self, dataset: str, symbols: list[str] | None = None) -> dict:
|
||||
if dataset != "realtime":
|
||||
return {"provider": self.name, "dataset": dataset, "rows": 0,
|
||||
"error": f"扶摇插件未接入 {dataset} 数据集(自动回退 TickFlow)"}
|
||||
try:
|
||||
rows, count = self._get_client().snapshot_page(limit=5)
|
||||
except FuyaoError as e:
|
||||
return {"provider": self.name, "dataset": "realtime", "rows": 0, "error": str(e)}
|
||||
fetched_ms = int(time.time() * 1000)
|
||||
head = [r for r in (_map_snapshot_row(row, fetched_ms) for row in rows) if r][:5]
|
||||
return {
|
||||
"provider": self.name,
|
||||
"dataset": "realtime",
|
||||
"rows": count or len(head),
|
||||
"columns": list(head[0].keys()) if head else [],
|
||||
"preview": head,
|
||||
}
|
||||
@@ -99,6 +99,17 @@ def get_ai_config_int(key: str, default: int) -> int:
|
||||
return int(getattr(settings, key, default) or default)
|
||||
|
||||
|
||||
def get_env_backed_secret(field: str, env_name: str) -> str:
|
||||
"""取环境变量后备的密钥(插件 API Key 等):secrets.json 优先,否则环境变量。
|
||||
|
||||
与 get_tickflow_key 同优先级语义:UI 写入 secrets.json 后即覆盖 .env。
|
||||
"""
|
||||
val = load().get(field)
|
||||
if val:
|
||||
return str(val).strip()
|
||||
return os.environ.get(env_name, "").strip()
|
||||
|
||||
|
||||
def mask(key: str, prefix: int = 4, suffix: int = 4) -> str:
|
||||
"""脱敏显示。"""
|
||||
if not key:
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
"""FuyaoProvider 契约与单位标准化测试。
|
||||
|
||||
不依赖真实网络: 用假 FuyaoClient 返回样例快照页, 验证字段映射、
|
||||
百分数→小数制转换 (CONTRIBUTING §3.1)、分页合并、软失败、
|
||||
能力声明 (未声明数据集回退 tickflow) 与设置页试拉。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.plugins.fuyao import client as fc
|
||||
from app.plugins.fuyao import provider as fp
|
||||
from app.plugins.fuyao.provider import FuyaoProvider
|
||||
|
||||
|
||||
class _FakeClient:
|
||||
"""按调用次数返回预置页, 记录调用供分页断言。snapshot_all 同真实客户端语义。"""
|
||||
|
||||
def __init__(self, pages: list[list[dict]], count: int, error: Exception | None = None, server_ts: int = 0):
|
||||
self.pages = pages
|
||||
self.count = count
|
||||
self.error = error
|
||||
self.server_ts = server_ts
|
||||
self.calls: list[dict] = []
|
||||
self.last_server_ts = server_ts
|
||||
|
||||
def snapshot_page(self, limit=500, offset=0):
|
||||
self.calls.append({"limit": limit, "offset": offset})
|
||||
if self.error:
|
||||
raise self.error
|
||||
if not self.pages:
|
||||
return [], self.count
|
||||
# 按调用次数取页(provider 每轮 offset += len(rows), 页序与调用序一致)
|
||||
idx = min(len(self.calls) - 1, len(self.pages) - 1)
|
||||
return list(self.pages[idx]), self.count
|
||||
|
||||
def snapshot_all(self):
|
||||
"""与真实客户端同语义的分页循环 (无页间隔, 测试用)。返回 (rows, server_ts)。"""
|
||||
out: list[dict] = []
|
||||
offset = 0
|
||||
for _ in range(50):
|
||||
rows, count = self.snapshot_page(offset=offset)
|
||||
if not rows:
|
||||
break
|
||||
out.extend(rows)
|
||||
if count and len(out) >= count:
|
||||
break
|
||||
offset += len(rows)
|
||||
if not out:
|
||||
raise fc.FuyaoError("全市场快照为空")
|
||||
return out, self.server_ts
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
def _row(thscode: str = "600519.SH", **over):
|
||||
"""实测快照行结构(2026-08): high_price/low_price/prev_price 命名。"""
|
||||
row = {
|
||||
"thscode": thscode,
|
||||
"last_price": 1480.0,
|
||||
"price_change": 25.0,
|
||||
"price_change_ratio_pct": 1.72,
|
||||
"open_price": 1460.0,
|
||||
"high_price": 1490.5,
|
||||
"low_price": 1455.0,
|
||||
"prev_price": 1455.0,
|
||||
"volume": 1234500,
|
||||
"turnover": 1.83e9,
|
||||
}
|
||||
row.update(over)
|
||||
return row
|
||||
|
||||
|
||||
def _provider_with(monkeypatch, pages, count=None, error=None, **fake_kwargs):
|
||||
fake = _FakeClient(pages, count if count is not None else sum(len(p) for p in pages), error, **fake_kwargs)
|
||||
monkeypatch.setattr(fp, "fuyao_client", type("M", (), {"FuyaoClient": lambda **kw: fake}))
|
||||
monkeypatch.setattr(fp, "get_api_key", lambda: "test-key")
|
||||
return FuyaoProvider(), fake
|
||||
|
||||
|
||||
# ---- 单位与字段映射 ----
|
||||
|
||||
def test_snapshot_units_and_field_mapping(monkeypatch):
|
||||
"""核心口径: price_change_ratio_pct 百分数 → change_pct 小数制 (1.72 → 0.0172)。"""
|
||||
provider, _ = _provider_with(monkeypatch, [[_row()]])
|
||||
records = provider.get_realtime()
|
||||
assert len(records) == 1
|
||||
r = records[0]
|
||||
assert r["symbol"] == "600519.SH"
|
||||
assert r["change_pct"] == pytest.approx(0.0172)
|
||||
assert r["change_amount"] == pytest.approx(25.0)
|
||||
assert r["prev_close"] == 1455.0
|
||||
assert r["open"] == 1460.0
|
||||
assert r["high"] == 1490.5
|
||||
assert r["low"] == 1455.0
|
||||
assert r["volume"] == 1234500
|
||||
assert r["amount"] == 1.83e9
|
||||
assert r["timestamp"] > 0
|
||||
# 快照不提供的字段必须为 None, 不启发式伪造
|
||||
assert r["name"] is None
|
||||
assert r["amplitude"] is None
|
||||
assert r["turnover_rate"] is None
|
||||
|
||||
|
||||
def test_missing_pct_derives_decimal_from_change_amount(monkeypatch):
|
||||
"""涨跌幅缺失时按 change_amount/prev_close 推导, 仍为小数制。"""
|
||||
provider, _ = _provider_with(monkeypatch, [[_row(price_change_ratio_pct=None)]])
|
||||
r = provider.get_realtime()[0]
|
||||
assert r["change_pct"] == pytest.approx(25.0 / 1455.0)
|
||||
assert r["change_amount"] == pytest.approx(25.0)
|
||||
|
||||
|
||||
def test_missing_change_amount_derived_from_prices(monkeypatch):
|
||||
provider, _ = _provider_with(monkeypatch, [[_row(price_change=None)]])
|
||||
r = provider.get_realtime()[0]
|
||||
assert r["change_amount"] == pytest.approx(1480.0 - 1455.0)
|
||||
|
||||
|
||||
def test_row_without_thscode_dropped(monkeypatch):
|
||||
provider, _ = _provider_with(monkeypatch, [[_row(), {"last_price": 1.0}, _row("000001.SZ")]])
|
||||
records = provider.get_realtime()
|
||||
assert [r["symbol"] for r in records] == ["600519.SH", "000001.SZ"]
|
||||
|
||||
|
||||
def test_all_rows_unrecognized_returns_empty_with_no_fake_data(monkeypatch):
|
||||
provider, _ = _provider_with(monkeypatch, [[{"foo": "bar"}, {"baz": 1}]])
|
||||
assert provider.get_realtime() == []
|
||||
|
||||
|
||||
# ---- 客户端信封解析 (实测结构 vs 文档示例) ----
|
||||
|
||||
def _patch_http(monkeypatch, payload, status_code=200):
|
||||
class _Resp:
|
||||
def json(self):
|
||||
return payload
|
||||
_Resp.status_code = status_code
|
||||
|
||||
class _Http:
|
||||
def get(self, path, params=None):
|
||||
return _Resp()
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(fc.httpx, "Client", lambda **kw: _Http())
|
||||
|
||||
|
||||
def test_client_parses_real_world_envelope(monkeypatch):
|
||||
"""实测信封(2026-08): data={timestamp, total, item}。"""
|
||||
_patch_http(monkeypatch, {
|
||||
"code": 0, "message": "success",
|
||||
"data": {"timestamp": 1787542612000, "total": 2,
|
||||
"item": [_row(), _row("000001.SZ")]},
|
||||
})
|
||||
c = fc.FuyaoClient(api_key="k")
|
||||
rows, total = c.snapshot_page()
|
||||
assert total == 2 and len(rows) == 2
|
||||
assert c.last_server_ts == 1787542612000
|
||||
|
||||
|
||||
def test_client_parses_documented_envelope(monkeypatch):
|
||||
"""官方文档示例信封: data={count, data}。"""
|
||||
_patch_http(monkeypatch, {
|
||||
"code": 0, "message": "OK",
|
||||
"data": {"count": 3, "data": [_row()]},
|
||||
})
|
||||
c = fc.FuyaoClient(api_key="k")
|
||||
rows, total = c.snapshot_page()
|
||||
assert total == 3 and len(rows) == 1
|
||||
|
||||
|
||||
def test_client_raises_on_error_code(monkeypatch):
|
||||
_patch_http(monkeypatch, {"code": 4001, "message": "rate limited", "data": None})
|
||||
c = fc.FuyaoClient(api_key="k")
|
||||
with pytest.raises(fc.FuyaoError, match="4001"):
|
||||
c.snapshot_page()
|
||||
|
||||
|
||||
# ---- 字段名兼容与服务端时间戳 ----
|
||||
|
||||
def test_doc_style_field_names_fallback(monkeypatch):
|
||||
"""文档示例字段名 (highest_price/lowest_price/prev_close_price) 也能映射。"""
|
||||
row = {
|
||||
"thscode": "600519.SH", "last_price": 1480.0, "price_change": 25.0,
|
||||
"price_change_ratio_pct": 1.72, "open_price": 1460.0,
|
||||
"highest_price": 1490.5, "lowest_price": 1455.0, "prev_close_price": 1455.0,
|
||||
"volume": 1234500, "turnover": 1.83e9,
|
||||
}
|
||||
provider, _ = _provider_with(monkeypatch, [[row]])
|
||||
r = provider.get_realtime()[0]
|
||||
assert r["high"] == 1490.5
|
||||
assert r["low"] == 1455.0
|
||||
assert r["prev_close"] == 1455.0
|
||||
|
||||
|
||||
def test_realtime_uses_server_timestamp(monkeypatch):
|
||||
provider, _ = _provider_with(monkeypatch, [[_row()]], server_ts=1787542612000)
|
||||
assert provider.get_realtime()[0]["timestamp"] == 1787542612000
|
||||
|
||||
|
||||
def test_realtime_falls_back_to_local_time_without_server_ts(monkeypatch):
|
||||
provider, _ = _provider_with(monkeypatch, [[_row()]], server_ts=0)
|
||||
assert provider.get_realtime()[0]["timestamp"] > 0
|
||||
|
||||
|
||||
# ---- 分页 ----
|
||||
|
||||
def test_snapshot_pagination_merges_pages(monkeypatch):
|
||||
page1 = [_row(f"{600000 + i}.SH") for i in range(2)]
|
||||
page2 = [_row(f"{688000 + i}.SH") for i in range(1)]
|
||||
provider, fake = _provider_with(monkeypatch, [page1, page2], count=3)
|
||||
records = provider.get_realtime()
|
||||
assert len(records) == 3
|
||||
assert len(fake.calls) == 2
|
||||
assert fake.calls[1]["offset"] == 2
|
||||
|
||||
|
||||
def test_snapshot_stops_when_page_empty(monkeypatch):
|
||||
provider, fake = _provider_with(monkeypatch, [[_row()], []], count=0)
|
||||
assert len(provider.get_realtime()) == 1
|
||||
assert len(fake.calls) == 2
|
||||
|
||||
|
||||
# ---- 软失败 ----
|
||||
|
||||
def test_realtime_error_returns_empty_list(monkeypatch):
|
||||
provider, _ = _provider_with(monkeypatch, [[]], error=fc.FuyaoError("扶摇接口错误 code=4001: 频率超限"))
|
||||
assert provider.get_realtime() == []
|
||||
|
||||
|
||||
def test_client_requires_api_key():
|
||||
with pytest.raises(fc.FuyaoError):
|
||||
fc.FuyaoClient(api_key="")
|
||||
|
||||
|
||||
# ---- 能力声明与注册 ----
|
||||
|
||||
def test_datasets_declaration_realtime_only():
|
||||
"""只声明 realtime; 其他数据集 provider_has_dataset 必须为 False (回退 tickflow)。"""
|
||||
config = FuyaoProvider().config
|
||||
assert "realtime" in config.datasets
|
||||
assert "daily" not in config.datasets
|
||||
assert "minute" not in config.datasets
|
||||
assert "financial" not in config.datasets
|
||||
|
||||
|
||||
# ---- API Key 解析 (secrets.json > .env, 对齐 tickflow 语义) ----
|
||||
|
||||
def test_get_api_key_secrets_store_takes_priority(monkeypatch):
|
||||
from app import secrets_store
|
||||
monkeypatch.delenv(fp.API_KEY_ENV, raising=False)
|
||||
monkeypatch.setattr(secrets_store, "load", lambda: {fp.SECRETS_FIELD: "sk-from-ui"})
|
||||
assert fp.get_api_key() == "sk-from-ui"
|
||||
|
||||
|
||||
def test_get_api_key_falls_back_to_env(monkeypatch):
|
||||
from app import secrets_store
|
||||
monkeypatch.setenv(fp.API_KEY_ENV, "sk-from-env")
|
||||
monkeypatch.setattr(secrets_store, "load", lambda: {})
|
||||
assert fp.get_api_key() == "sk-from-env"
|
||||
|
||||
|
||||
def test_availability_accepts_secrets_store_key(monkeypatch):
|
||||
from app import secrets_store
|
||||
monkeypatch.delenv(fp.API_KEY_ENV, raising=False)
|
||||
monkeypatch.setattr(secrets_store, "load", lambda: {fp.SECRETS_FIELD: "sk-from-ui"})
|
||||
assert fp.availability() == (True, "ok")
|
||||
|
||||
|
||||
def test_availability_requires_env_key(monkeypatch):
|
||||
from app import secrets_store
|
||||
monkeypatch.delenv(fp.API_KEY_ENV, raising=False)
|
||||
monkeypatch.setattr(secrets_store, "load", lambda: {})
|
||||
ok, reason = fp.availability()
|
||||
assert ok is False and fp.API_KEY_ENV in reason
|
||||
|
||||
|
||||
# ---- 先探后存 (probe_api_key) ----
|
||||
|
||||
def _patch_client_cls(monkeypatch, fake):
|
||||
monkeypatch.setattr(fp, "fuyao_client", type("M", (), {"FuyaoClient": lambda **kw: fake}))
|
||||
|
||||
|
||||
def test_probe_api_key_ok(monkeypatch):
|
||||
_patch_client_cls(monkeypatch, _FakeClient([[_row()]], 1))
|
||||
ok, reason = fp.probe_api_key("sk-candidate")
|
||||
assert ok is True and reason == "ok"
|
||||
|
||||
|
||||
def test_probe_api_key_invalid_key(monkeypatch):
|
||||
_patch_client_cls(monkeypatch, _FakeClient([[]], 0, error=fc.FuyaoError("扶摇接口错误 code=1001: 无效 api key")))
|
||||
ok, reason = fp.probe_api_key("sk-bad")
|
||||
assert ok is False and "无效" in reason
|
||||
|
||||
|
||||
def test_loader_probe_plugin_key_dispatch(monkeypatch):
|
||||
import app.plugins.fuyao.provider as provider_mod
|
||||
from app.data_providers.custom import loader
|
||||
|
||||
monkeypatch.setattr(provider_mod, "probe_api_key", lambda key: (True, "ok") if key == "good" else (False, "bad"))
|
||||
assert loader.probe_plugin_key("fuyao", "good") == (True, "ok")
|
||||
assert loader.probe_plugin_key("fuyao", "bad") == (False, "bad")
|
||||
|
||||
|
||||
def test_loader_probe_plugin_key_unsupported_plugin():
|
||||
from app.data_providers.custom import loader
|
||||
# stock-sdk 未声明 api_key_env → 不支持界面配 Key
|
||||
ok, reason = loader.probe_plugin_key("stocksdk", "x")
|
||||
assert ok is False and "不支持" in reason
|
||||
ok, reason = loader.probe_plugin_key("no_such_plugin", "x")
|
||||
assert ok is False and "不存在" in reason
|
||||
|
||||
|
||||
# ---- 保存/清除端点 (直接调用 handler, 先探后存语义) ----
|
||||
|
||||
def test_save_plugin_key_invalid_key_not_persisted(monkeypatch):
|
||||
from app.api import settings as settings_api
|
||||
from app.data_providers import custom as custom_sources
|
||||
|
||||
saved: dict = {}
|
||||
monkeypatch.setattr(custom_sources, "probe_plugin_key", lambda n, k: (False, "Key 无效"))
|
||||
monkeypatch.setattr(settings_api.secrets_store, "save", lambda updates: saved.update(updates) or updates)
|
||||
out = settings_api.save_plugin_key(settings_api.PluginKeyIn(plugin="fuyao", api_key="bad"))
|
||||
assert out["ok"] is False and out["reason"] == "invalid"
|
||||
assert saved == {} # 无效 Key 不落盘
|
||||
|
||||
|
||||
def test_save_plugin_key_valid_persists_and_rescans(monkeypatch):
|
||||
from app.api import settings as settings_api
|
||||
from app.data_providers import custom as custom_sources
|
||||
|
||||
saved: dict = {}
|
||||
reloaded = []
|
||||
monkeypatch.setattr(custom_sources, "probe_plugin_key", lambda n, k: (True, "ok"))
|
||||
monkeypatch.setattr(settings_api.secrets_store, "save", lambda updates: saved.update(updates) or updates)
|
||||
monkeypatch.setattr(settings_api.secrets_store, "mask", lambda key, prefix=4, suffix=4: "abcd••••wxyz")
|
||||
monkeypatch.setattr(custom_sources, "load_all", lambda: reloaded.append(1))
|
||||
monkeypatch.setattr(custom_sources, "list_plugins", lambda: [{"name": "fuyao", "available": True}])
|
||||
out = settings_api.save_plugin_key(settings_api.PluginKeyIn(plugin="fuyao", api_key="good-key"))
|
||||
assert out["ok"] is True
|
||||
assert saved == {"fuyao_api_key": "good-key"} # 字段名与 provider.SECRETS_FIELD 一致
|
||||
assert out["plugin_available"] is True
|
||||
assert reloaded == [1] # 保存后重扫, 插件即刻可用
|
||||
|
||||
|
||||
def test_clear_plugin_key(monkeypatch):
|
||||
from app.api import settings as settings_api
|
||||
from app.data_providers import custom as custom_sources
|
||||
|
||||
cleared: list = []
|
||||
monkeypatch.setattr(custom_sources, "is_builtin", lambda n: n == "fuyao")
|
||||
monkeypatch.setattr(settings_api.secrets_store, "clear", lambda *keys: cleared.extend(keys))
|
||||
monkeypatch.setattr(custom_sources, "load_all", lambda: None)
|
||||
monkeypatch.setattr(custom_sources, "list_plugins", lambda: [{"name": "fuyao", "available": False}])
|
||||
out = settings_api.clear_plugin_key("fuyao")
|
||||
assert out["ok"] is True and out["plugin_available"] is False
|
||||
assert cleared == ["fuyao_api_key"]
|
||||
|
||||
|
||||
def test_manifest_declares_realtime_dataset():
|
||||
from app.data_providers.custom import loader
|
||||
manifest = loader.plugin_manifest("fuyao")
|
||||
assert manifest is not None
|
||||
assert manifest["entry"] == "app.plugins.fuyao.provider:FuyaoProvider"
|
||||
assert "realtime" in (manifest.get("datasets") or [])
|
||||
assert manifest.get("runtime") == "none"
|
||||
assert manifest.get("api_key_env") == fp.API_KEY_ENV
|
||||
|
||||
|
||||
def test_hidden_plugin_not_registered():
|
||||
"""hidden: true 的插件不注册、不在数据源页展示 (优化完成前隐藏 fuyao)。"""
|
||||
from app.data_providers.custom import loader
|
||||
manifest = loader.plugin_manifest("fuyao")
|
||||
assert manifest.get("hidden") is True
|
||||
loader._register_one_plugin(manifest)
|
||||
assert "fuyao" not in loader._PLUGIN_STATUS
|
||||
assert "fuyao" not in loader._PROVIDERS
|
||||
|
||||
|
||||
# ---- 设置页试拉 ----
|
||||
|
||||
def test_test_dataset_realtime_preview(monkeypatch):
|
||||
provider, _ = _provider_with(monkeypatch, [[_row(), _row("000001.SZ")]], count=5400)
|
||||
out = provider.test_dataset("realtime")
|
||||
assert out["provider"] == "fuyao"
|
||||
assert out["rows"] == 5400
|
||||
assert out["preview"][0]["symbol"] == "600519.SH"
|
||||
assert out["preview"][0]["change_pct"] == pytest.approx(0.0172)
|
||||
|
||||
|
||||
def test_test_dataset_unsupported_dataset_reports_fallback(monkeypatch):
|
||||
provider, _ = _provider_with(monkeypatch, [[]])
|
||||
out = provider.test_dataset("minute")
|
||||
assert "error" in out and "回退" in out["error"]
|
||||
|
||||
|
||||
def test_close_is_idempotent(monkeypatch):
|
||||
provider, _ = _provider_with(monkeypatch, [[_row()]])
|
||||
provider.close()
|
||||
provider.close()
|
||||
@@ -25,10 +25,25 @@ runtime: python # 运行时类型: node | python | none
|
||||
entry: app.plugins.my_source.provider:MyProvider # provider 类的导入路径
|
||||
check: app.plugins.my_source.bridge:availability # 可用性检测函数(可选)
|
||||
datasets: [daily, adj_factor, minute, realtime] # 支持的数据集
|
||||
api_key_env: MY_SOURCE_API_KEY # (可选)声明后设置页提供 Key 输入框
|
||||
hidden: false # (可选)true = 已加载但对设置页隐藏,不注册不展示
|
||||
description: "数据源描述"
|
||||
install_hint: "pip install xxx" # 未装依赖时显示的安装提示
|
||||
```
|
||||
|
||||
#### api_key_env(界面配置 API Key)
|
||||
|
||||
声明 `api_key_env` 的插件可以在设置页的数据源卡片中直接填写 Key, 对齐
|
||||
TickFlow 的「先探后存」语义:
|
||||
|
||||
1. entry 模块需提供模块级 `probe_api_key(key) -> (ok, reason)`,
|
||||
后端用候选 Key 实探一次, **无效不落盘**
|
||||
2. 有效则写入 `data/user_data/secrets.json` 的 `{name}_api_key` 字段
|
||||
(0600 权限, 优先级高于 `.env` / 环境变量)
|
||||
3. 保存后自动重载数据源注册表, 插件即刻变为可切换
|
||||
4. 插件取 Key 用 `secrets_store.get_env_backed_secret("{name}_api_key", api_key_env)`,
|
||||
保证 secrets.json 与 .env 两条配置路径一致
|
||||
|
||||
### runtime 字段说明
|
||||
|
||||
| runtime | 含义 | 典型场景 |
|
||||
@@ -105,6 +120,12 @@ class MyConfig:
|
||||
|
||||
## 现有插件参考
|
||||
|
||||
- **`backend/app/plugins/fuyao/`** — 同花顺官方 REST 数据源(runtime: none, 纯 HTTP 零依赖)
|
||||
- 当前提供 `realtime`(A 股全市场快照, 分页拉取); Key 在设置页卡片直接配置(先探后存), 或 `.env` 配 `FUYAO_API_KEY`
|
||||
- `client.py` — httpx 客户端(X-api-key 认证 + 统一信封解包 + 分页)
|
||||
- `provider.py` — Provider 实现(字段映射、百分数→小数制单位转换、软失败、Key 探测)
|
||||
- 单位口径注意: 扶摇 `price_change_ratio_pct` 为百分数数值(1.74 = +1.74%),
|
||||
内部 `change_pct` 契约为小数制, provider 内显式 / 100(见 CONTRIBUTING §3.1)
|
||||
- **`backend/app/plugins/stocksdk/`** — Node 型插件, 通过 subprocess 桥接调用 stock-sdk
|
||||
- `bridge.py` — Python↔Node 桥接 + availability 检测
|
||||
- `bridge.mjs` — Node 端(并发池、重试、SDK 解析)
|
||||
|
||||
@@ -1376,6 +1376,7 @@ export interface PluginDataSourceItem {
|
||||
status: string // 可用性原因 (供 UI 显示)
|
||||
description: string
|
||||
install_hint: string // 未装依赖时显示的安装命令
|
||||
api_key_env?: string // 声明后设置页提供 Key 输入框 (先探后存)
|
||||
}
|
||||
|
||||
export interface DataSourceLoadError {
|
||||
@@ -1400,6 +1401,16 @@ export interface DataSourceTestResult {
|
||||
preview: Record<string, unknown>[]
|
||||
}
|
||||
|
||||
/** 插件 Key 保存结果 (先探后存: 无效 Key 返回 ok=false 且不落盘) */
|
||||
export interface PluginKeyResult {
|
||||
ok: boolean
|
||||
reason?: string
|
||||
error?: string
|
||||
api_key_masked?: string
|
||||
plugin_available?: boolean
|
||||
plugin?: PluginDataSourceItem | null
|
||||
}
|
||||
|
||||
export interface DatasetConfig {
|
||||
url: string
|
||||
method: string
|
||||
@@ -1595,6 +1606,18 @@ export const api = {
|
||||
`/api/settings/plugins/${encodeURIComponent(name)}/install`,
|
||||
{ method: 'DELETE' },
|
||||
),
|
||||
savePluginKey: (plugin: string, apiKey: string) => {
|
||||
// 先探后存: 后端会用候选 Key 实探一次, 探测超时 10s + 余量
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), 30_000)
|
||||
return request<PluginKeyResult>('/api/settings/plugin-key', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ plugin, api_key: apiKey }),
|
||||
signal: controller.signal,
|
||||
}).finally(() => clearTimeout(timer))
|
||||
},
|
||||
clearPluginKey: (plugin: string) =>
|
||||
request<PluginKeyResult>(`/api/settings/plugin-key/${encodeURIComponent(plugin)}`, { method: 'DELETE' }),
|
||||
testDataSource: (
|
||||
provider: string,
|
||||
dataset: string,
|
||||
|
||||
Reference in New Issue
Block a user