Files
tick-stock-panel/backend/app/api/config_backup.py
T
shy3130 ab2b82da37 perf: 收窄缓存失效并优化异步任务与首屏加载
按 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>
2026-11-01 22:32:34 +08:00

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()