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:
shy3130
2026-08-24 14:21:38 +08:00
parent a4554794fc
commit ea746b97c7
11 changed files with 862 additions and 0 deletions
+52
View File
@@ -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。"""