mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 10:26:13 +00:00
按 enriched 写入最早影响日期维护有界区间版本,保留全局发布一致性校验,使历史读取与回测缓存复用不受较晚行情更新影响;历史修订和未知范围变更仍失效。 将扩展数据解析、落盘与备份/策略导入移出事件循环;串行限制扩展数据写入,并在请求取消后保持执行中的写入限额及临时文件清理。 拆分 11 个扩展页面为轻量注册清单和按需页面,助手抽屉首次使用时加载;并发获取扩展后按固定顺序注册,为运行时脚本增加超时隔离。保持金融计算、业务数据格式与扩展 v1 契约不变。 验证:隔离全量后端 3923 项通过、7 项跳过、1 项预期失败;前端 447 项通过,TypeScript 与生产构建通过。Ruff 无新增告警,git diff --check 通过。浏览器验证 11 个扩展路由与脚本超时隔离。 性能:1000 标的、120 天、12 万行、30 次更新,历史面板读取中位数 7.19ms -> 0.14ms,计算 31 -> 1 次,结果一致。注入 250ms 解析耗时的心跳间隔中位最大值 269ms -> 11.3ms。启动必需 JS 2.33MB -> 0.65MB;10Mbps/40ms 网络与固定 API 响应下,登录页 3 次冷启动中位数 3.15s -> 1.12s。 Signed-off-by: shy3130 <shy3130@qq.com>
105 lines
3.8 KiB
Python
105 lines
3.8 KiB
Python
"""配置备份 API — 导出下载 / 导入预览 / 导入应用。
|
|
|
|
薄层: 参数接收 → service (config_backup.py) → 按 reload_hints 刷新进程内
|
|
引擎与数据源状态。路径白名单、内容校验、原子写、脱敏均在 service 层。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
|
|
from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile
|
|
from fastapi.responses import Response
|
|
from starlette.concurrency import run_in_threadpool
|
|
|
|
from app.data_providers.custom import loader as custom_loader
|
|
from app.services import config_backup
|
|
from app.strategy import monitor_rules
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/api/config-backup", tags=["config-backup"])
|
|
|
|
# B008: FastAPI 要求参数默认值为 File(...) 调用, 提为模块级单例复用。
|
|
_UPLOAD_FILE = File(...)
|
|
|
|
|
|
def _category_param(raw: str) -> frozenset[str] | None:
|
|
"""categories 逗号串 → 类别集合。空 = 不传 = 全量; 非法键由 service 丢弃。"""
|
|
raw = (raw or "").strip()
|
|
if not raw:
|
|
return None
|
|
return frozenset(k.strip() for k in raw.split(",") if k.strip())
|
|
|
|
|
|
@router.get("/export")
|
|
def export_backup(categories: str = "") -> Response:
|
|
"""导出用户配置为 ZIP 下载。categories 逗号分隔类别键, 不传导出全部 (不含密钥);
|
|
显式包含 "secrets" 才打包明文密钥。"""
|
|
data, filename = config_backup.build_backup(categories=_category_param(categories))
|
|
return Response(
|
|
content=data,
|
|
media_type="application/zip",
|
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
|
)
|
|
|
|
|
|
async def _read_upload(file: UploadFile) -> bytes:
|
|
data = await file.read()
|
|
if not data:
|
|
raise HTTPException(status_code=400, detail="空文件")
|
|
if len(data) > config_backup.MAX_ARCHIVE_BYTES:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"备份文件超过 {config_backup.MAX_ARCHIVE_BYTES // 1024 // 1024}MB 上限",
|
|
)
|
|
return data
|
|
|
|
|
|
@router.post("/inspect")
|
|
async def inspect_backup(file: UploadFile = _UPLOAD_FILE) -> dict:
|
|
"""解析备份给出预览 (类别/数量/冲突), 不写任何文件。"""
|
|
data = await _read_upload(file)
|
|
try:
|
|
return await run_in_threadpool(config_backup.inspect_archive, data)
|
|
except config_backup.ConfigBackupError as e:
|
|
raise HTTPException(status_code=400, detail=str(e)) from e
|
|
|
|
|
|
@router.post("/apply")
|
|
async def apply_backup(
|
|
request: Request,
|
|
file: UploadFile = _UPLOAD_FILE,
|
|
allow_overwrite: bool = Form(False),
|
|
categories: str = Form(""),
|
|
) -> dict:
|
|
data = await _read_upload(file)
|
|
return await run_in_threadpool(_apply_backup, request, data, allow_overwrite, categories)
|
|
|
|
|
|
def _apply_backup(request: Request, data: bytes, allow_overwrite: bool, categories: str) -> dict:
|
|
try:
|
|
result = config_backup.apply_archive(
|
|
data,
|
|
allow_overwrite=allow_overwrite,
|
|
categories=_category_param(categories),
|
|
)
|
|
except config_backup.ConfigBackupError as e:
|
|
raise HTTPException(status_code=400, detail=str(e)) from e
|
|
_reload_states(request, result.get("reload_hints") or {})
|
|
return result
|
|
|
|
|
|
def _reload_states(request: Request, hints: dict) -> None:
|
|
"""按导入结果刷新进程内状态 (与 monitor_rules API 的 _sync_engine 同模式)。"""
|
|
if hints.get("strategies"):
|
|
engine = getattr(request.app.state, "strategy_engine", None)
|
|
if engine is not None:
|
|
engine.reload()
|
|
if hints.get("monitor_rules"):
|
|
monitor_engine = getattr(request.app.state, "monitor_engine", None)
|
|
if monitor_engine is not None:
|
|
rules = monitor_rules.load_all(request.app.state.repo.store.data_dir)
|
|
monitor_engine.set_rules(rules)
|
|
if hints.get("data_sources"):
|
|
custom_loader.load_all()
|