mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 02:16:13 +00:00
feat(backup): 配置备份与迁移 — 18 类别 ZIP 导出导入, 彻底解决多设备配置丢失
- 后端 config_backup service + /api/config-backup 三端点: 按类别导出/预览/导入 - 覆盖策略/监控/信号/因子/偏好/自选/持仓/模拟盘/挖掘候选/数据源/菜单/扩展页面/扩展数据集/语音包/历史记录 - 密钥与访问凭证默认脱敏不导出, 显式勾选才打包; 偏好/密钥 merge 写入 - ZIP 白名单路径 + 防炸弹 + 策略 AST 安全校验, 冲突默认跳过可选覆盖 - 监控徽标/导航形态/回测列表高度/设置页收起接入 uiPrefs 跨端同步 - 设置页新增「配置备份」标签页, 导出/导入向导式 UI; 16 个专项测试 Signed-off-by: shy3130 <shy3130@qq.com>
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
"""配置备份 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 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 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)
|
||||
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()
|
||||
@@ -17,6 +17,7 @@ from app.api import (
|
||||
alerts,
|
||||
analysis,
|
||||
backtest,
|
||||
config_backup,
|
||||
data,
|
||||
events,
|
||||
ext_data,
|
||||
@@ -657,6 +658,7 @@ app.include_router(upgrade_guard_api.router)
|
||||
app.include_router(strategy.router)
|
||||
app.include_router(signals.router)
|
||||
app.include_router(monitor_rules.router)
|
||||
app.include_router(config_backup.router)
|
||||
app.include_router(lots.router)
|
||||
app.include_router(alerts.router)
|
||||
app.include_router(events.router)
|
||||
|
||||
@@ -90,6 +90,17 @@ def _verify_password(password: str, salt_hex: str, hash_hex: str) -> bool:
|
||||
# 密码管理
|
||||
# ================================================================
|
||||
|
||||
def invalidate_runtime_caches() -> None:
|
||||
"""auth.json 被外部写入后 (配置备份导入) 失效进程内缓存。
|
||||
|
||||
密码判定缓存 + 会话内存态都基于旧文件; 导入覆盖后必须重读,
|
||||
否则新设备恢复的密码/会话在本进程内不可见。
|
||||
"""
|
||||
global _configured_cache
|
||||
_configured_cache = None
|
||||
_restore_sessions()
|
||||
|
||||
|
||||
def is_configured() -> bool:
|
||||
"""是否已设置访问密码 (带缓存)。
|
||||
|
||||
|
||||
@@ -0,0 +1,683 @@
|
||||
"""配置备份 — 导出/导入用户配置为单个 ZIP, 解决切换设备配置丢失。
|
||||
|
||||
备份文件 = manifest.json + 按 data_dir 相对路径归档的用户配置文件。
|
||||
保持各 store 的原始文件格式 (策略 .py、规则/偏好 JSON、自选 parquet),
|
||||
不做任何格式转换, 避免"每类资产一套序列化"的平行实现。
|
||||
|
||||
导入是高风险文件操作 (CONTRIBUTING §8), 全程 fail-closed:
|
||||
- ZIP 条目名只接受白名单路径形态 (_classify), 拒绝绝对路径/`..`/反斜杠;
|
||||
- 防炸弹: 条目数、单文件、解压总量均有上限, 实际读取按上限截断校验;
|
||||
- 策略源码写入前过与引擎一致的 AST 安全校验, 不安全的条目跳过并报错;
|
||||
- 文件类冲突默认跳过, 显式 allow_overwrite 才覆盖; 偏好/密钥走 merge 不整替。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import zipfile
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import yaml
|
||||
|
||||
from app.services.fs_utils import atomic_write_text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BACKUP_FORMAT = "tick-config-backup"
|
||||
BACKUP_VERSION = 1
|
||||
|
||||
MAX_ARCHIVE_BYTES = 128 * 1024 * 1024 # 上传压缩包上限
|
||||
MAX_ENTRIES = 2_000 # 条目数上限
|
||||
MAX_FILE_BYTES = 10 * 1024 * 1024 # 单文件解压上限
|
||||
MAX_TOTAL_BYTES = 64 * 1024 * 1024 # 全包解压总量上限
|
||||
|
||||
# 导出默认脱敏的偏好键 — webhook URL 本身含 token, 与 secret 同级处理。
|
||||
SENSITIVE_PREFERENCE_KEYS = (
|
||||
"feishu_webhook_url",
|
||||
"feishu_webhook_secret",
|
||||
"wecom_webhook_url",
|
||||
"custom_webhook_url",
|
||||
"wecom_bot_id",
|
||||
"wecom_bot_secret",
|
||||
)
|
||||
# 监控规则内 legacy 的 per-rule webhook 字段 (URL 含 token)。
|
||||
SENSITIVE_RULE_FIELDS = ("webhook_url",)
|
||||
|
||||
_STRATEGY_SOURCES = ("custom", "ai", "composite")
|
||||
|
||||
# 类别注册表 — 导出枚举、导入校验、结果展示共用一份。
|
||||
_CATEGORY_LABELS: dict[str, str] = {
|
||||
"strategies": "策略源码",
|
||||
"strategy_overrides": "策略调参",
|
||||
"custom_signals": "自定义信号",
|
||||
"custom_factors": "自定义因子",
|
||||
"monitor_rules": "监控规则",
|
||||
"watchlist": "自选股",
|
||||
"lots": "批量持仓",
|
||||
"paper": "模拟盘账户",
|
||||
"research_candidates": "挖掘候选",
|
||||
"data_sources": "自定义数据源",
|
||||
"analysis_menus": "分析菜单",
|
||||
"ext_pages": "扩展页面",
|
||||
"ext_data": "扩展数据集配置",
|
||||
"sound_packs": "语音包",
|
||||
"history": "历史记录",
|
||||
"preferences": "偏好设置",
|
||||
"secrets": "密钥",
|
||||
"auth": "访问凭证",
|
||||
}
|
||||
# 全部类别键 (导出/导入按类别勾选的合法值域)。
|
||||
CATEGORY_KEYS = frozenset(_CATEGORY_LABELS)
|
||||
# 敏感类别: 默认不导出 (UI 单独警示)。
|
||||
SENSITIVE_CATEGORIES = frozenset({"secrets", "auth"})
|
||||
# 与 api/settings.py 的 _SOUND_PACK_EXTS 保持一致 (service 不反向依赖 api 层)。
|
||||
_SOUND_PACK_EXTS = frozenset({".mp3", ".wav", ".ogg", ".m4a", ".aac", ".flac", ".opus", ".webm"})
|
||||
# 结果输出顺序 = 重要性顺序 (策略 → 监控 → …, 敏感类别放末尾)。
|
||||
_CATEGORY_ORDER = (
|
||||
"strategies",
|
||||
"strategy_overrides",
|
||||
"custom_signals",
|
||||
"custom_factors",
|
||||
"monitor_rules",
|
||||
"watchlist",
|
||||
"lots",
|
||||
"paper",
|
||||
"research_candidates",
|
||||
"data_sources",
|
||||
"analysis_menus",
|
||||
"ext_pages",
|
||||
"ext_data",
|
||||
"sound_packs",
|
||||
"history",
|
||||
"preferences",
|
||||
"secrets",
|
||||
"auth",
|
||||
)
|
||||
|
||||
# 文件名白名单: 字母/数字/下划线/连字符 (含中文), 不允许 `.` 开头、路径分隔符。
|
||||
_NAME_RE = re.compile(r"^[\w\-]{1,128}$", re.UNICODE)
|
||||
|
||||
# 导入后需要上层 (API 层) 触发重载的状态类别。
|
||||
_RELOAD_HINT_KEYS = ("strategies", "monitor_rules", "data_sources", "watchlist")
|
||||
|
||||
|
||||
class ConfigBackupError(ValueError):
|
||||
"""备份文件不可接受 (格式/安全/超限)。API 层映射为 400。"""
|
||||
|
||||
|
||||
def _data_dir() -> Path:
|
||||
from app.config import settings
|
||||
|
||||
return settings.data_dir
|
||||
|
||||
|
||||
def _app_version() -> str:
|
||||
"""manifest 记录来源版本, 打包环境缺 VERSION 文件时留空即可。"""
|
||||
try:
|
||||
return (Path(__file__).resolve().parents[3] / "VERSION").read_text(encoding="utf-8").strip()
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Entry:
|
||||
"""一个通过白名单校验的备份条目。"""
|
||||
|
||||
name: str # zip 内条目名 (data_dir 相对 POSIX 路径)
|
||||
category: str
|
||||
target: Path # 本机落盘绝对路径
|
||||
|
||||
|
||||
# ── 路径归类: zip 条目名 → (类别, 目标路径); 不在备份范围返回 None ──────────
|
||||
|
||||
def _classify(rel: PurePosixPath, data_dir: Path) -> tuple[str, Path] | None:
|
||||
def _name_ok(name: str, suffix: str) -> bool:
|
||||
return name.endswith(suffix) and bool(_NAME_RE.match(name[: -len(suffix)]))
|
||||
|
||||
def _name_ext_ok(name: str, suffixes: frozenset[str]) -> bool:
|
||||
return any(_name_ok(name, s) for s in suffixes)
|
||||
|
||||
parts = rel.parts
|
||||
if (
|
||||
len(parts) == 3
|
||||
and parts[0] == "strategies"
|
||||
and parts[1] in _STRATEGY_SOURCES
|
||||
and _name_ok(parts[2], ".py")
|
||||
):
|
||||
return "strategies", data_dir / Path(*parts)
|
||||
if (
|
||||
len(parts) == 3
|
||||
and parts[0] == "user_data"
|
||||
and parts[1] in ("strategy_overrides", "custom_signals", "custom_factors", "monitor_rules")
|
||||
and _name_ok(parts[2], ".json")
|
||||
):
|
||||
return parts[1], data_dir / Path(*parts)
|
||||
if (
|
||||
len(parts) == 3
|
||||
and parts[0] == "user_data"
|
||||
and parts[1] == "lots"
|
||||
and parts[2].startswith("lot_")
|
||||
and _name_ok(parts[2], ".json")
|
||||
):
|
||||
return "lots", data_dir / Path(*parts)
|
||||
if len(parts) == 2 and parts[0] == "user_data":
|
||||
name = parts[1]
|
||||
if name == "preferences.json":
|
||||
return "preferences", data_dir / "user_data" / "preferences.json"
|
||||
if name == "secrets.json":
|
||||
return "secrets", data_dir / "user_data" / "secrets.json"
|
||||
if name == "auth.json":
|
||||
return "auth", data_dir / "user_data" / "auth.json"
|
||||
if name in ("watchlist.parquet", "watchlist_groups.json"):
|
||||
return "watchlist", data_dir / "user_data" / name
|
||||
if name == "research_candidates.json":
|
||||
return "research_candidates", data_dir / "user_data" / name
|
||||
if name in ("alerts.jsonl", "assistant_sessions.json"):
|
||||
return "history", data_dir / "user_data" / name
|
||||
if len(parts) == 2 and parts[0] == "data_sources" and (_name_ok(parts[1], ".yaml") or _name_ok(parts[1], ".yml")):
|
||||
return "data_sources", data_dir / Path(*parts)
|
||||
if len(parts) == 2 and parts[0] == "analysis_menus" and _name_ok(parts[1], ".json"):
|
||||
return "analysis_menus", data_dir / Path(*parts)
|
||||
if len(parts) == 2 and parts[0] == "sound_packs" and _name_ext_ok(parts[1], _SOUND_PACK_EXTS):
|
||||
return "sound_packs", data_dir / Path(*parts)
|
||||
if len(parts) == 3 and parts[0] == "ext_data" and parts[2] == "config.json" and bool(_NAME_RE.match(parts[1])):
|
||||
return "ext_data", data_dir / Path(*parts)
|
||||
if (
|
||||
len(parts) == 3
|
||||
and parts[0] == "ext_pages"
|
||||
and bool(_NAME_RE.match(parts[1]))
|
||||
and parts[2] in ("page.json", "bundle.js")
|
||||
):
|
||||
return "ext_pages", data_dir / Path(*parts)
|
||||
# 模拟盘账户: paper/accounts/{acc}/[nav|orders|auto_rules/...]/{file}.json|.jsonl
|
||||
# 中间目录段是纯名称 (nav/orders), 只有末段带扩展名。
|
||||
if (
|
||||
4 <= len(parts) <= 5
|
||||
and parts[0] == "paper"
|
||||
and parts[1] == "accounts"
|
||||
and all(bool(_NAME_RE.match(p)) for p in parts[2:-1])
|
||||
and _name_ext_ok(parts[-1], frozenset({".json", ".jsonl"}))
|
||||
):
|
||||
return "paper", data_dir / Path(*parts)
|
||||
return None
|
||||
|
||||
|
||||
def _safe_rel(entry_name: str) -> PurePosixPath:
|
||||
"""校验条目名可安全转换为相对路径, 否则整体拒绝。"""
|
||||
if not entry_name or entry_name.endswith("/"):
|
||||
raise ConfigBackupError(f"非法条目: {entry_name!r}")
|
||||
if "\\" in entry_name or entry_name.startswith("/") or ":" in entry_name:
|
||||
raise ConfigBackupError(f"非法条目路径: {entry_name!r}")
|
||||
rel = PurePosixPath(entry_name)
|
||||
if rel.is_absolute() or any(p in ("", ".", "..") for p in rel.parts):
|
||||
raise ConfigBackupError(f"非法条目路径: {entry_name!r}")
|
||||
return rel
|
||||
|
||||
|
||||
# ── 导出 ────────────────────────────────────────────────
|
||||
|
||||
def _collect_backup_files(data_dir: Path) -> list[Path]:
|
||||
out: list[Path] = []
|
||||
|
||||
def _glob(directory: Path, pattern: str) -> None:
|
||||
if directory.is_dir():
|
||||
out.extend(sorted(directory.glob(pattern)))
|
||||
|
||||
for sub in _STRATEGY_SOURCES:
|
||||
_glob(data_dir / "strategies" / sub, "*.py")
|
||||
for sub in ("strategy_overrides", "custom_signals", "custom_factors", "monitor_rules"):
|
||||
_glob(data_dir / "user_data" / sub, "*.json")
|
||||
_glob(data_dir / "user_data" / "lots", "lot_*.json")
|
||||
for name in (
|
||||
"watchlist.parquet", "watchlist_groups.json", "preferences.json",
|
||||
"research_candidates.json", "alerts.jsonl", "assistant_sessions.json", "auth.json",
|
||||
):
|
||||
p = data_dir / "user_data" / name
|
||||
if p.is_file():
|
||||
out.append(p)
|
||||
_glob(data_dir / "data_sources", "*.yaml")
|
||||
_glob(data_dir / "data_sources", "*.yml")
|
||||
_glob(data_dir / "analysis_menus", "*.json")
|
||||
if (data_dir / "sound_packs").is_dir():
|
||||
for f in sorted((data_dir / "sound_packs").iterdir()):
|
||||
if f.is_file() and f.suffix.lower() in _SOUND_PACK_EXTS:
|
||||
out.append(f)
|
||||
ext_base = data_dir / "ext_data"
|
||||
if ext_base.is_dir():
|
||||
for sub in sorted(ext_base.iterdir()):
|
||||
cfg = sub / "config.json"
|
||||
if sub.is_dir() and cfg.is_file():
|
||||
out.append(cfg)
|
||||
pages_base = data_dir / "ext_pages"
|
||||
if pages_base.is_dir():
|
||||
for sub in sorted(pages_base.iterdir()):
|
||||
if not sub.is_dir():
|
||||
continue
|
||||
for fname in ("page.json", "bundle.js"):
|
||||
p = sub / fname
|
||||
if p.is_file():
|
||||
out.append(p)
|
||||
accounts_base = data_dir / "paper" / "accounts"
|
||||
if accounts_base.is_dir():
|
||||
for pattern in ("*.json", "*.jsonl"):
|
||||
out.extend(sorted(accounts_base.rglob(pattern)))
|
||||
# 只带走 classify 认可的文件, 保证导出/导入白名单严格对称。
|
||||
return [
|
||||
p for p in out
|
||||
if _classify(PurePosixPath(p.relative_to(data_dir).as_posix()), data_dir) is not None
|
||||
]
|
||||
|
||||
|
||||
def _export_payload(path: Path, category: str, *, include_secrets: bool) -> bytes | None:
|
||||
"""读取文件内容; 偏好/规则在不包含密钥时做脱敏。不可读/超限则跳过并告警。"""
|
||||
try:
|
||||
raw = path.read_bytes()
|
||||
except OSError as e:
|
||||
logger.warning("config backup skip unreadable %s: %s", path.name, e)
|
||||
return None
|
||||
if len(raw) > MAX_FILE_BYTES:
|
||||
logger.warning("config backup skip oversized %s (%d bytes)", path.name, len(raw))
|
||||
return None
|
||||
if not include_secrets:
|
||||
if category == "preferences":
|
||||
data = json.loads(raw)
|
||||
for key in SENSITIVE_PREFERENCE_KEYS:
|
||||
data.pop(key, None)
|
||||
return json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8")
|
||||
if category == "monitor_rules":
|
||||
data = json.loads(raw)
|
||||
for key in SENSITIVE_RULE_FIELDS:
|
||||
data.pop(key, None)
|
||||
return json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8")
|
||||
return raw
|
||||
|
||||
|
||||
def _normalize_selection(categories: Iterable[str] | None) -> frozenset[str] | None:
|
||||
"""类别勾选归一: None → 全量 (不含密钥, 安全默认); 否则取与注册表的交集。
|
||||
|
||||
交集可以为空 (API 直调传了非法键/全不选) → fail-closed, 导出只剩 manifest、
|
||||
导入不写任何文件, 绝不静默回退成"全选"。
|
||||
"""
|
||||
if categories is None:
|
||||
return None
|
||||
return frozenset(k for k in categories if k in _CATEGORY_LABELS)
|
||||
|
||||
|
||||
def build_backup(*, categories: Iterable[str] | None = None) -> tuple[bytes, str]:
|
||||
"""打包用户配置, 返回 (zip 字节, 建议文件名)。
|
||||
|
||||
categories=None 导出全部 (不含密钥); 显式包含 "secrets" 才打包密钥。
|
||||
"""
|
||||
selected = _normalize_selection(categories)
|
||||
include_secrets = selected is not None and "secrets" in selected
|
||||
data_dir = _data_dir()
|
||||
counts: dict[str, int] = {}
|
||||
buf = io.BytesIO()
|
||||
now = datetime.now(ZoneInfo("Asia/Shanghai"))
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for path in _collect_backup_files(data_dir):
|
||||
rel = path.relative_to(data_dir).as_posix()
|
||||
category, _ = _classify(PurePosixPath(rel), data_dir) # collect 已过滤, 必非 None
|
||||
if selected is not None and category not in selected:
|
||||
continue
|
||||
# 敏感类别 (密钥/访问凭证) 只在显式勾选时打包, 默认导出不带。
|
||||
if category in SENSITIVE_CATEGORIES and not (
|
||||
selected is not None and category in selected
|
||||
):
|
||||
continue
|
||||
payload = _export_payload(path, category, include_secrets=include_secrets)
|
||||
if payload is None:
|
||||
continue
|
||||
zf.writestr(rel, payload)
|
||||
counts[category] = counts.get(category, 0) + 1
|
||||
if include_secrets:
|
||||
secrets_path = data_dir / "user_data" / "secrets.json"
|
||||
if secrets_path.is_file():
|
||||
zf.writestr("user_data/secrets.json", secrets_path.read_bytes())
|
||||
counts["secrets"] = counts.get("secrets", 0) + 1
|
||||
manifest = {
|
||||
"format": BACKUP_FORMAT,
|
||||
"version": BACKUP_VERSION,
|
||||
"created_at": now.isoformat(),
|
||||
"app_version": _app_version(),
|
||||
"include_secrets": include_secrets,
|
||||
"categories": [
|
||||
{"key": key, "label": _CATEGORY_LABELS[key], "count": counts[key]}
|
||||
for key in _CATEGORY_ORDER
|
||||
if key in counts
|
||||
],
|
||||
}
|
||||
zf.writestr("manifest.json", json.dumps(manifest, ensure_ascii=False, indent=2))
|
||||
logger.info(
|
||||
"config backup built: include_secrets=%s categories=%s",
|
||||
include_secrets,
|
||||
{k: counts[k] for k in _CATEGORY_ORDER if k in counts},
|
||||
)
|
||||
return buf.getvalue(), f"tsp-config-backup-{now:%Y%m%d-%H%M}.zip"
|
||||
|
||||
|
||||
# ── 解析与校验 (inspect / apply 共用) ─────────────────────
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _ParsedArchive:
|
||||
manifest: dict[str, Any]
|
||||
entries: list[_Entry]
|
||||
out_of_scope: list[str] # 结构安全但不在备份范围内的条目 (忽略, 计数上报)
|
||||
|
||||
|
||||
def _parse_archive(data: bytes) -> _ParsedArchive:
|
||||
if not data:
|
||||
raise ConfigBackupError("空文件")
|
||||
if len(data) > MAX_ARCHIVE_BYTES:
|
||||
raise ConfigBackupError(f"备份文件超过 {MAX_ARCHIVE_BYTES // 1024 // 1024}MB 上限")
|
||||
try:
|
||||
zf = zipfile.ZipFile(io.BytesIO(data))
|
||||
except zipfile.BadZipFile as e:
|
||||
raise ConfigBackupError("不是有效的 ZIP 备份文件") from e
|
||||
with zf:
|
||||
infos = [i for i in zf.infolist() if not i.is_dir()]
|
||||
if len(infos) > MAX_ENTRIES:
|
||||
raise ConfigBackupError(f"备份条目数超过上限 ({len(infos)} > {MAX_ENTRIES})")
|
||||
try:
|
||||
manifest = json.loads(zf.read("manifest.json"))
|
||||
except KeyError as e:
|
||||
raise ConfigBackupError("备份缺少 manifest.json") from e
|
||||
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
||||
raise ConfigBackupError(f"manifest.json 解析失败: {e}") from e
|
||||
if not isinstance(manifest, dict) or manifest.get("format") != BACKUP_FORMAT:
|
||||
raise ConfigBackupError("不是本应用的配置备份文件")
|
||||
version = manifest.get("version")
|
||||
if version != BACKUP_VERSION:
|
||||
raise ConfigBackupError(f"不支持的备份版本 {version!r} (当前支持 {BACKUP_VERSION})")
|
||||
|
||||
data_dir = _data_dir()
|
||||
entries: list[_Entry] = []
|
||||
out_of_scope: list[str] = []
|
||||
total = 0
|
||||
for info in infos:
|
||||
if info.filename == "manifest.json":
|
||||
continue
|
||||
total += info.file_size
|
||||
if total > MAX_TOTAL_BYTES:
|
||||
raise ConfigBackupError(f"备份解压总量超过 {MAX_TOTAL_BYTES // 1024 // 1024}MB 上限")
|
||||
if info.file_size > MAX_FILE_BYTES:
|
||||
raise ConfigBackupError(f"条目 {info.filename} 超过单文件 {MAX_FILE_BYTES // 1024 // 1024}MB 上限")
|
||||
rel = _safe_rel(info.filename)
|
||||
cls = _classify(rel, data_dir)
|
||||
if cls is None:
|
||||
out_of_scope.append(info.filename)
|
||||
continue
|
||||
entries.append(_Entry(name=info.filename, category=cls[0], target=cls[1]))
|
||||
return _ParsedArchive(manifest=manifest, entries=entries, out_of_scope=out_of_scope)
|
||||
|
||||
|
||||
def _read_entry(zf: zipfile.ZipFile, name: str) -> bytes:
|
||||
"""按上限截断读取, 防止伪造 central directory 声明小尺寸的炸弹。"""
|
||||
with zf.open(name) as f:
|
||||
payload = f.read(MAX_FILE_BYTES + 1)
|
||||
if len(payload) > MAX_FILE_BYTES:
|
||||
raise ConfigBackupError(f"条目 {name} 实际大小超过 {MAX_FILE_BYTES // 1024 // 1024}MB 上限")
|
||||
return payload
|
||||
|
||||
|
||||
def inspect_archive(data: bytes) -> dict[str, Any]:
|
||||
"""解析备份并给出预览: 各类别条目数、与本地冲突数。不写任何文件。"""
|
||||
parsed = _parse_archive(data)
|
||||
cats: dict[str, dict[str, Any]] = {}
|
||||
for entry in parsed.entries:
|
||||
c = cats.setdefault(
|
||||
entry.category,
|
||||
{"key": entry.category, "label": _CATEGORY_LABELS[entry.category], "count": 0, "conflicts": 0},
|
||||
)
|
||||
c["count"] += 1
|
||||
# 偏好/密钥是 merge 语义, 不存在冲突概念。
|
||||
if entry.category not in ("preferences", "secrets") and entry.target.exists():
|
||||
c["conflicts"] += 1
|
||||
return {
|
||||
"created_at": parsed.manifest.get("created_at", ""),
|
||||
"app_version": str(parsed.manifest.get("app_version", "")),
|
||||
# 以包内是否真的有 secrets 条目为准 (manifest 声明只作参考)。
|
||||
"has_secrets": any(e.category == "secrets" for e in parsed.entries),
|
||||
"categories": [cats[k] for k in _CATEGORY_ORDER if k in cats],
|
||||
"total_files": len(parsed.entries),
|
||||
"conflict_files": sum(c["conflicts"] for c in cats.values()),
|
||||
"out_of_scope_files": len(parsed.out_of_scope),
|
||||
}
|
||||
|
||||
|
||||
# ── 导入 ────────────────────────────────────────────────
|
||||
|
||||
def _validate_strategy_code(code: str, *, is_dependency: bool, dep_names: frozenset[str]) -> None:
|
||||
"""与 engine._load_file 相同口径的 AST 安全校验 — 备份可能被篡改, 不因
|
||||
'是自己导出的' 免检; 写盘前拦截, 避免给引擎留下每次 reload 都报错的毒文件。"""
|
||||
from app.strategy.ai_generator import AIStrategyGenerator
|
||||
|
||||
if is_dependency:
|
||||
AIStrategyGenerator._validate_safety(
|
||||
code,
|
||||
extra_allowed_import_modules=frozenset({"collections.abc", "types", "typing"}),
|
||||
)
|
||||
else:
|
||||
AIStrategyGenerator._validate_safety(code, extra_allowed_import_modules=dep_names)
|
||||
|
||||
|
||||
def _validate_json_object(payload: bytes, *, what: str) -> dict:
|
||||
try:
|
||||
doc = json.loads(payload)
|
||||
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
||||
raise ConfigBackupError(f"{what} JSON 解析失败: {e}") from e
|
||||
if not isinstance(doc, dict):
|
||||
raise ConfigBackupError(f"{what}内容必须是 JSON 对象")
|
||||
return doc
|
||||
|
||||
|
||||
def _validate_payload(entry: _Entry, payload: bytes) -> None:
|
||||
"""写盘前的内容级校验 (策略的安全校验独立处理)。"""
|
||||
name = entry.target.name
|
||||
if entry.category in ("sound_packs",):
|
||||
return # 音频二进制, 无从校验内容, 路径白名单已限死
|
||||
if entry.category == "ext_pages" and name == "bundle.js":
|
||||
return # 编译产物无法 AST 校验; 仅导入可信备份 (说明书已注明)
|
||||
if name.endswith(".jsonl"):
|
||||
# 逐行 JSON (成交台账/净值/告警); 空行容忍, 坏行 fail-closed。
|
||||
try:
|
||||
text = payload.decode("utf-8")
|
||||
except UnicodeDecodeError as e:
|
||||
raise ConfigBackupError(f"{name} 不是有效 UTF-8: {e}") from e
|
||||
for i, line in enumerate(text.splitlines()):
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
json.loads(line)
|
||||
except json.JSONDecodeError as e:
|
||||
raise ConfigBackupError(f"{name} 第 {i + 1} 行不是有效 JSON: {e}") from e
|
||||
return
|
||||
if entry.category == "watchlist" and name.endswith(".parquet"):
|
||||
import polars as pl
|
||||
|
||||
try:
|
||||
pl.read_parquet_schema(io.BytesIO(payload))
|
||||
except Exception as e: # 转换为备份错误后 re-raise, 不算静默吞异常
|
||||
raise ConfigBackupError(f"自选股 parquet 无法解析: {e}") from e
|
||||
return
|
||||
if entry.category == "data_sources":
|
||||
try:
|
||||
doc = yaml.safe_load(payload)
|
||||
except yaml.YAMLError as e:
|
||||
raise ConfigBackupError(f"自定义数据源 YAML 解析失败: {e}") from e
|
||||
if not isinstance(doc, dict):
|
||||
raise ConfigBackupError("自定义数据源配置必须是 YAML 对象")
|
||||
return
|
||||
if entry.category in ("watchlist", "research_candidates") and (
|
||||
name == "watchlist_groups.json" or name == "research_candidates.json"
|
||||
):
|
||||
# 分组/候选清单是 JSON 数组, 只要求可解析。
|
||||
try:
|
||||
json.loads(payload)
|
||||
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
||||
raise ConfigBackupError(f"JSON 解析失败: {e}") from e
|
||||
return
|
||||
# 其余 (规则/信号/因子/菜单/调参/扩展配置) 均为 JSON 对象。
|
||||
_validate_json_object(payload, what=f"{_CATEGORY_LABELS[entry.category]} ")
|
||||
|
||||
|
||||
def _write_bytes_atomic(target: Path, payload: bytes) -> None:
|
||||
"""二进制原子写: tmp + replace, 与 fs_utils.atomic_write_text 同语义。"""
|
||||
from app.tickflow.repository import replace_with_retry
|
||||
|
||||
tmp = target.with_name(target.name + ".tmp")
|
||||
tmp.write_bytes(payload)
|
||||
replace_with_retry(tmp, target)
|
||||
|
||||
|
||||
# 文本类后缀走 atomic_write_text; 其余 (parquet/音频等二进制) 走字节原子写。
|
||||
_TEXT_SUFFIXES = frozenset({".py", ".json", ".jsonl", ".yaml", ".yml", ".js"})
|
||||
|
||||
|
||||
def _write_payload(target: Path, payload: bytes) -> None:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
if target.suffix.lower() in _TEXT_SUFFIXES:
|
||||
atomic_write_text(target, payload.decode("utf-8"))
|
||||
else:
|
||||
_write_bytes_atomic(target, payload)
|
||||
|
||||
|
||||
def apply_archive(data: bytes, *, allow_overwrite: bool = False, categories: Iterable[str] | None = None) -> dict[str, Any]:
|
||||
"""把备份写入本机。categories 只导入勾选的类别 (None=全部); 文件类默认冲突
|
||||
跳过; 偏好/密钥 merge。逐条容错, 单条失败不影响其余条目, 结果按类别汇总。
|
||||
返回 reload_hints 供 API 层刷新引擎。"""
|
||||
parsed = _parse_archive(data)
|
||||
selected = _normalize_selection(categories)
|
||||
if selected is None:
|
||||
entries = parsed.entries
|
||||
skipped_unselected = 0
|
||||
else:
|
||||
entries = [e for e in parsed.entries if e.category in selected]
|
||||
skipped_unselected = len(parsed.entries) - len(entries)
|
||||
results: dict[str, dict[str, Any]] = {}
|
||||
reload_hints = {key: False for key in _RELOAD_HINT_KEYS}
|
||||
prefs_payload: dict | None = None
|
||||
secrets_payload: dict | None = None
|
||||
|
||||
def _result(category: str) -> dict[str, Any]:
|
||||
return results.setdefault(
|
||||
category,
|
||||
{"key": category, "label": _CATEGORY_LABELS[category],
|
||||
"imported": 0, "skipped_conflict": 0, "failed": []},
|
||||
)
|
||||
|
||||
# 第一遍: 截断读全部条目 + 冲突过滤 + 偏好/密钥提取。
|
||||
# 策略条目先攒批 — 同目录 _*.py 依赖要进主文件的 import 白名单
|
||||
# (与 engine._load_file 的 dependency glob 口径一致), 校验必须看全批。
|
||||
strategy_codes: dict[_Entry, str] = {}
|
||||
plain_entries: list[tuple[_Entry, bytes]] = []
|
||||
try:
|
||||
zf = zipfile.ZipFile(io.BytesIO(data))
|
||||
except zipfile.BadZipFile as e: # _parse_archive 已校验过, 理论不可达
|
||||
raise ConfigBackupError("不是有效的 ZIP 备份文件") from e
|
||||
with zf:
|
||||
for entry in entries:
|
||||
try:
|
||||
payload = _read_entry(zf, entry.name)
|
||||
except ConfigBackupError as e:
|
||||
_result(entry.category)["failed"].append({"file": entry.name, "error": str(e)})
|
||||
continue
|
||||
if entry.category == "preferences":
|
||||
prefs_payload = _validate_json_object(payload, what="偏好设置 ")
|
||||
continue
|
||||
if entry.category == "secrets":
|
||||
secrets_payload = _validate_json_object(payload, what="密钥文件 ")
|
||||
continue
|
||||
if entry.target.exists() and not allow_overwrite:
|
||||
_result(entry.category)["skipped_conflict"] += 1
|
||||
continue
|
||||
if entry.category == "strategies":
|
||||
try:
|
||||
strategy_codes[entry] = payload.decode("utf-8")
|
||||
except UnicodeDecodeError as e:
|
||||
_result("strategies")["failed"].append(
|
||||
{"file": entry.name, "error": f"策略文件不是有效 UTF-8: {e}"}
|
||||
)
|
||||
else:
|
||||
plain_entries.append((entry, payload))
|
||||
|
||||
# 第二遍: 内容校验 + 写盘 (策略 → 其余文件)。
|
||||
for directory, batch in _groupby_dir(strategy_codes):
|
||||
# 依赖名单 = 本批同目录 _*.py + 本机已存在的 _*.py (引擎 reload 时
|
||||
# glob 的是落盘后的目录, 两边取并集才不误杀)。
|
||||
dep_names = {p.name[:-3] for p in directory.glob("_*.py")}
|
||||
dep_names |= {e.target.name[:-3] for e, _ in batch if e.target.name.startswith("_")}
|
||||
for entry, code in batch:
|
||||
is_dep = entry.target.name.startswith("_")
|
||||
try:
|
||||
_validate_strategy_code(code, is_dependency=is_dep, dep_names=frozenset(dep_names))
|
||||
_write_payload(entry.target, code.encode("utf-8"))
|
||||
except (ConfigBackupError, ValueError, SyntaxError, OSError) as e:
|
||||
_result("strategies")["failed"].append({"file": entry.name, "error": str(e)})
|
||||
continue
|
||||
_result("strategies")["imported"] += 1
|
||||
reload_hints["strategies"] = True
|
||||
|
||||
for entry, payload in plain_entries:
|
||||
try:
|
||||
_validate_payload(entry, payload)
|
||||
_write_payload(entry.target, payload)
|
||||
except (ConfigBackupError, ValueError, OSError, UnicodeDecodeError) as e:
|
||||
_result(entry.category)["failed"].append({"file": entry.name, "error": str(e)})
|
||||
continue
|
||||
_result(entry.category)["imported"] += 1
|
||||
if entry.category in reload_hints:
|
||||
reload_hints[entry.category] = True
|
||||
|
||||
# auth.json 是外部写入的凭证文件, 进程内密码/会话缓存需要重读。
|
||||
if results.get("auth", {}).get("imported"):
|
||||
from app.services import auth as auth_store
|
||||
|
||||
auth_store.invalidate_runtime_caches()
|
||||
|
||||
# 偏好/密钥最后落: merge 语义 (备份键覆盖本地同名键, 本地新增键保留),
|
||||
# 走各 store 的 save (自带锁、0600 与缓存失效)。
|
||||
if prefs_payload is not None:
|
||||
from app.services import preferences
|
||||
|
||||
preferences.save(prefs_payload)
|
||||
_result("preferences")["imported"] += 1
|
||||
if secrets_payload is not None:
|
||||
from app import secrets_store
|
||||
|
||||
secrets_store.save(secrets_payload)
|
||||
_result("secrets")["imported"] += 1
|
||||
|
||||
failed_total = sum(len(r["failed"]) for r in results.values())
|
||||
logger.info(
|
||||
"config backup applied: allow_overwrite=%s imported=%d skipped=%d failed=%d",
|
||||
allow_overwrite,
|
||||
sum(r["imported"] for r in results.values()),
|
||||
sum(r["skipped_conflict"] for r in results.values()),
|
||||
failed_total,
|
||||
)
|
||||
return {
|
||||
"categories": [results[k] for k in _CATEGORY_ORDER if k in results],
|
||||
"preferences_merged": prefs_payload is not None,
|
||||
"secrets_merged": secrets_payload is not None,
|
||||
"skipped_unselected": skipped_unselected,
|
||||
"out_of_scope_files": len(parsed.out_of_scope),
|
||||
"reload_hints": reload_hints,
|
||||
}
|
||||
|
||||
|
||||
def _groupby_dir(strategy_codes: dict[_Entry, str]) -> list[tuple[Path, list[tuple[_Entry, str]]]]:
|
||||
grouped: dict[Path, list[tuple[_Entry, str]]] = {}
|
||||
for entry, code in strategy_codes.items():
|
||||
grouped.setdefault(entry.target.parent, []).append((entry, code))
|
||||
# 排序只为结果稳定与写盘顺序可预期 (_ 依赖在前)。
|
||||
for batch in grouped.values():
|
||||
batch.sort(key=lambda item: (not item[0].target.name.startswith("_"), item[0].name))
|
||||
return sorted(grouped.items(), key=lambda kv: kv[0].name)
|
||||
@@ -1390,6 +1390,11 @@ _UI_PREFS_KEYS = (
|
||||
"voice_broadcast_rate",
|
||||
"stock_external_template",
|
||||
"page_zoom",
|
||||
# 界面局部状态的跨端副本 (随配置备份迁移): 监控徽标/设置页收起/回测列表高度/导航形态
|
||||
"monitor_badge_enabled",
|
||||
"settings_nav_collapsed",
|
||||
"bt_strategy_list_maxh",
|
||||
"nav_state",
|
||||
)
|
||||
_UI_PREFS_MAX_JSON = 16 * 1024
|
||||
|
||||
|
||||
@@ -0,0 +1,484 @@
|
||||
"""配置备份 (导出/导入) — 数据契约与安全边界。
|
||||
|
||||
覆盖: 导出打包与脱敏、inspect 预览与冲突统计、恶意 ZIP 拒绝、
|
||||
apply 的冲突/merge/逐条容错语义、策略安全校验 fail-closed。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import polars as pl
|
||||
import pytest
|
||||
|
||||
from app.services import config_backup
|
||||
|
||||
SAFE_STRATEGY = '''\
|
||||
META = {"id": "custom_demo", "name": "demo", "description": "", "tags": []}
|
||||
|
||||
import polars as pl
|
||||
|
||||
def compute(df: pl.DataFrame) -> pl.DataFrame:
|
||||
return df
|
||||
'''
|
||||
|
||||
DEP_STRATEGY_HELPER = "def helper(x):\n return x\n"
|
||||
|
||||
STRATEGY_WITH_DEP = '''\
|
||||
META = {"id": "custom_withdep", "name": "wd", "description": "", "tags": []}
|
||||
|
||||
import _helper
|
||||
|
||||
def compute(df):
|
||||
return _helper.helper(df)
|
||||
'''
|
||||
|
||||
UNSAFE_STRATEGY = '''\
|
||||
META = {"id": "custom_evil", "name": "evil", "description": "", "tags": []}
|
||||
|
||||
import os
|
||||
|
||||
def compute(df):
|
||||
return os.system("rm -rf /")
|
||||
'''
|
||||
|
||||
|
||||
def _write(path: Path, content: str | bytes) -> Path:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if isinstance(content, bytes):
|
||||
path.write_bytes(content)
|
||||
else:
|
||||
path.write_text(content, encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def src_env(tmp_path, monkeypatch):
|
||||
"""源环境: 把全局 data_dir 指到 tmp, 造一份各类别齐全的用户配置。"""
|
||||
from app.config import settings as app_settings
|
||||
|
||||
monkeypatch.setattr(app_settings, "data_dir", tmp_path)
|
||||
root = tmp_path
|
||||
_write(root / "strategies" / "custom" / "custom_demo.py", SAFE_STRATEGY)
|
||||
_write(root / "strategies" / "custom" / "_helper.py", DEP_STRATEGY_HELPER)
|
||||
_write(root / "strategies" / "custom" / "custom_withdep.py", STRATEGY_WITH_DEP)
|
||||
_write(root / "user_data" / "strategy_overrides" / "custom_demo.json", '{"params": {"n": 5}}')
|
||||
_write(root / "user_data" / "custom_signals" / "cs_demo.json", '{"id": "cs_demo", "name": "s"}')
|
||||
_write(root / "user_data" / "custom_factors" / "uf_demo.json", '{"id": "uf_demo", "formula": "close"}')
|
||||
_write(root / "user_data" / "monitor_rules" / "mr_demo.json",
|
||||
json.dumps({"id": "mr_demo", "name": "r", "webhook_url": "https://hook/secret",
|
||||
"webhook_channels": ["feishu"]}))
|
||||
_write(root / "user_data" / "watchlist_groups.json", '[{"name": "g1", "symbols": ["000001.SZ"]}]')
|
||||
pl.DataFrame({"symbol": ["000001.SZ"], "name": ["平安银行"]}).write_parquet(
|
||||
root / "user_data" / "watchlist.parquet"
|
||||
)
|
||||
_write(root / "data_sources" / "my_src.yaml", "name: my_src\nbase_url: https://x\n")
|
||||
_write(root / "analysis_menus" / "am_demo.json", '{"id": "am_demo", "title": "m"}')
|
||||
_write(root / "ext_data" / "ext1" / "config.json", '{"id": "ext1", "url": "https://y"}')
|
||||
_write(root / "user_data" / "preferences.json", json.dumps({
|
||||
"quote_interval": 5,
|
||||
"feishu_webhook_url": "https://open.feishu.cn/hook/tok",
|
||||
"feishu_webhook_secret": "s3cret",
|
||||
"wecom_bot_secret": "bot-s3cret",
|
||||
}))
|
||||
_write(root / "user_data" / "secrets.json", json.dumps({"ai_api_key": "sk-1", "tickflow_api_key": "tf-1"}))
|
||||
# 彻底迁移范围内的其余类别
|
||||
_write(root / "user_data" / "lots" / "lot_a.json", '{"id": "lot_a", "symbol": "000001.SZ"}')
|
||||
_write(root / "paper" / "accounts" / "acc1" / "account.json", '{"id": "acc1", "cash": 100000}')
|
||||
_write(root / "paper" / "accounts" / "acc1" / "fills.jsonl", '{"seq": 1}\n{"seq": 2}\n')
|
||||
_write(root / "paper" / "accounts" / "acc1" / "nav" / "daily.jsonl", '{"nav": 100000}\n')
|
||||
_write(root / "user_data" / "research_candidates.json", '[{"id": "rc1"}]')
|
||||
_write(root / "ext_pages" / "mypage" / "page.json", '{"id": "mypage", "title": "p"}')
|
||||
_write(root / "ext_pages" / "mypage" / "bundle.js", "export default function P() { return null }")
|
||||
_write(root / "sound_packs" / "ding.mp3", b"ID3fakeaudio")
|
||||
_write(root / "user_data" / "alerts.jsonl", '{"msg": "m"}\n')
|
||||
_write(root / "user_data" / "assistant_sessions.json", '{"sessions": []}')
|
||||
_write(root / "user_data" / "auth.json", json.dumps({"password_hash": "h", "sessions": {}}))
|
||||
# 派生/临时数据, 任何情况下都不应进入备份
|
||||
_write(root / "user_data" / "strategy_cache.json", '{"derived": 1}')
|
||||
_write(root / "user_data" / "task_registry.json", '{"tasks": []}')
|
||||
yield root
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_store_caches():
|
||||
"""preferences/secrets 是 mtime 签名缓存, 换 data_dir 前后都要清, 防串台。"""
|
||||
from app.services import preferences
|
||||
|
||||
preferences._invalidate_cache()
|
||||
yield
|
||||
preferences._invalidate_cache()
|
||||
import app.secrets_store as secrets_store
|
||||
|
||||
secrets_store._cache = None
|
||||
secrets_store._cache_sig = None
|
||||
|
||||
|
||||
def _zip_entries(data: bytes) -> dict[str, bytes]:
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as zf:
|
||||
return {name: zf.read(name) for name in zf.namelist()}
|
||||
|
||||
|
||||
def test_export_contains_all_categories_and_manifest(src_env):
|
||||
data, filename = config_backup.build_backup()
|
||||
assert filename.startswith("tsp-config-backup-") and filename.endswith(".zip")
|
||||
entries = _zip_entries(data)
|
||||
assert "manifest.json" in entries
|
||||
expected = {
|
||||
"strategies/custom/custom_demo.py",
|
||||
"strategies/custom/_helper.py",
|
||||
"strategies/custom/custom_withdep.py",
|
||||
"user_data/strategy_overrides/custom_demo.json",
|
||||
"user_data/custom_signals/cs_demo.json",
|
||||
"user_data/custom_factors/uf_demo.json",
|
||||
"user_data/monitor_rules/mr_demo.json",
|
||||
"user_data/watchlist.parquet",
|
||||
"user_data/watchlist_groups.json",
|
||||
"user_data/lots/lot_a.json",
|
||||
"paper/accounts/acc1/account.json",
|
||||
"paper/accounts/acc1/fills.jsonl",
|
||||
"paper/accounts/acc1/nav/daily.jsonl",
|
||||
"user_data/research_candidates.json",
|
||||
"data_sources/my_src.yaml",
|
||||
"analysis_menus/am_demo.json",
|
||||
"ext_pages/mypage/page.json",
|
||||
"ext_pages/mypage/bundle.js",
|
||||
"ext_data/ext1/config.json",
|
||||
"sound_packs/ding.mp3",
|
||||
"user_data/alerts.jsonl",
|
||||
"user_data/assistant_sessions.json",
|
||||
"user_data/preferences.json",
|
||||
}
|
||||
assert expected <= set(entries)
|
||||
# 敏感与派生数据默认不打包
|
||||
assert "user_data/auth.json" not in entries
|
||||
assert "user_data/secrets.json" not in entries
|
||||
assert "user_data/strategy_cache.json" not in entries
|
||||
assert "user_data/task_registry.json" not in entries
|
||||
|
||||
manifest = json.loads(entries["manifest.json"])
|
||||
assert manifest["format"] == config_backup.BACKUP_FORMAT
|
||||
assert manifest["version"] == config_backup.BACKUP_VERSION
|
||||
assert manifest["include_secrets"] is False
|
||||
counts = {c["key"]: c["count"] for c in manifest["categories"]}
|
||||
assert counts["strategies"] == 3
|
||||
assert counts["watchlist"] == 2
|
||||
assert counts["paper"] == 3
|
||||
assert counts["ext_pages"] == 2
|
||||
assert "auth" not in counts and "secrets" not in counts
|
||||
|
||||
|
||||
def test_export_sanitizes_secrets_by_default(src_env):
|
||||
entries = _zip_entries(config_backup.build_backup()[0])
|
||||
prefs = json.loads(entries["user_data/preferences.json"])
|
||||
assert prefs["quote_interval"] == 5
|
||||
for key in config_backup.SENSITIVE_PREFERENCE_KEYS:
|
||||
assert key not in prefs
|
||||
rule = json.loads(entries["user_data/monitor_rules/mr_demo.json"])
|
||||
assert "webhook_url" not in rule
|
||||
assert rule["webhook_channels"] == ["feishu"]
|
||||
|
||||
|
||||
def test_export_with_secrets_keeps_plaintext(src_env):
|
||||
entries = _zip_entries(config_backup.build_backup(categories=config_backup.CATEGORY_KEYS)[0])
|
||||
assert "user_data/secrets.json" in entries
|
||||
prefs = json.loads(entries["user_data/preferences.json"])
|
||||
assert prefs["feishu_webhook_secret"] == "s3cret"
|
||||
rule = json.loads(entries["user_data/monitor_rules/mr_demo.json"])
|
||||
assert rule["webhook_url"] == "https://hook/secret"
|
||||
manifest = json.loads(entries["manifest.json"])
|
||||
assert manifest["include_secrets"] is True
|
||||
|
||||
|
||||
def test_inspect_reports_counts_conflicts_and_secrets(src_env):
|
||||
data, _ = config_backup.build_backup(categories=config_backup.CATEGORY_KEYS)
|
||||
summary = config_backup.inspect_archive(data)
|
||||
assert summary["has_secrets"] is True
|
||||
# 23 个常规配置文件 + secrets + auth
|
||||
assert summary["total_files"] == 25
|
||||
assert summary["conflict_files"] == 25 - 2 # 除偏好/密钥 (merge 语义) 外全部同名冲突
|
||||
cats = {c["key"]: c for c in summary["categories"]}
|
||||
assert cats["strategies"]["count"] == 3
|
||||
assert cats["strategies"]["conflicts"] == 3
|
||||
assert cats["preferences"]["conflicts"] == 0
|
||||
assert cats["auth"]["count"] == 1
|
||||
|
||||
|
||||
def test_reject_invalid_archives(src_env):
|
||||
with pytest.raises(config_backup.ConfigBackupError):
|
||||
config_backup.inspect_archive(b"not a zip at all")
|
||||
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
zf.writestr("user_data/monitor_rules/x.json", "{}")
|
||||
with pytest.raises(config_backup.ConfigBackupError, match="manifest"):
|
||||
config_backup.inspect_archive(buf.getvalue())
|
||||
|
||||
def _zip_with_manifest(filename: str, content: bytes, manifest: object) -> bytes:
|
||||
out = io.BytesIO()
|
||||
with zipfile.ZipFile(out, "w") as zf:
|
||||
if not isinstance(manifest, bytes):
|
||||
manifest = json.dumps(manifest)
|
||||
zf.writestr("manifest.json", manifest)
|
||||
zf.writestr(filename, content)
|
||||
return out.getvalue()
|
||||
|
||||
good_manifest = {
|
||||
"format": config_backup.BACKUP_FORMAT,
|
||||
"version": config_backup.BACKUP_VERSION,
|
||||
}
|
||||
with pytest.raises(config_backup.ConfigBackupError, match="路径"):
|
||||
config_backup.inspect_archive(_zip_with_manifest("../evil.py", "x", good_manifest))
|
||||
with pytest.raises(config_backup.ConfigBackupError, match="路径"):
|
||||
config_backup.inspect_archive(_zip_with_manifest("..\\evil.py", "x", good_manifest))
|
||||
with pytest.raises(config_backup.ConfigBackupError, match="路径"):
|
||||
config_backup.inspect_archive(_zip_with_manifest("/etc/passwd", "x", good_manifest))
|
||||
with pytest.raises(config_backup.ConfigBackupError, match="版本"):
|
||||
config_backup.inspect_archive(_zip_with_manifest(
|
||||
"user_data/monitor_rules/x.json", "{}",
|
||||
{"format": config_backup.BACKUP_FORMAT, "version": 99},
|
||||
))
|
||||
with pytest.raises(config_backup.ConfigBackupError, match="不是本应用"):
|
||||
config_backup.inspect_archive(_zip_with_manifest(
|
||||
"user_data/monitor_rules/x.json", "{}", {"format": "other", "version": 1},
|
||||
))
|
||||
# 防炸弹: 声明尺寸超单文件上限直接拒绝 (重复字节压缩后很小, 测试开销低)
|
||||
with pytest.raises(config_backup.ConfigBackupError, match="单文件"):
|
||||
config_backup.inspect_archive(_zip_with_manifest(
|
||||
"user_data/monitor_rules/big.json",
|
||||
b"x" * (config_backup.MAX_FILE_BYTES + 1),
|
||||
good_manifest,
|
||||
))
|
||||
|
||||
|
||||
def test_inspect_counts_out_of_scope_entries(src_env):
|
||||
out = io.BytesIO()
|
||||
with zipfile.ZipFile(out, "w") as zf:
|
||||
zf.writestr("manifest.json", json.dumps({
|
||||
"format": config_backup.BACKUP_FORMAT,
|
||||
"version": config_backup.BACKUP_VERSION,
|
||||
}))
|
||||
zf.writestr("user_data/unknown_store/x.json", "{}")
|
||||
zf.writestr("strategies/builtin/x.py", "y = 1\n")
|
||||
summary = config_backup.inspect_archive(out.getvalue())
|
||||
assert summary["out_of_scope_files"] == 2
|
||||
assert summary["total_files"] == 0
|
||||
|
||||
|
||||
def _fresh_env(tmp_path_factory, monkeypatch):
|
||||
"""全新环境 (模拟新设备): 只重定向 data_dir, 不造任何文件。"""
|
||||
from app.config import settings as app_settings
|
||||
|
||||
root = tmp_path_factory.mktemp("fresh")
|
||||
monkeypatch.setattr(app_settings, "data_dir", root)
|
||||
return root
|
||||
|
||||
|
||||
def test_apply_into_fresh_env_roundtrip(src_env, tmp_path_factory, monkeypatch):
|
||||
data, _ = config_backup.build_backup(categories=config_backup.CATEGORY_KEYS)
|
||||
root = _fresh_env(tmp_path_factory, monkeypatch)
|
||||
result = config_backup.apply_archive(data)
|
||||
|
||||
imported = {c["key"]: c["imported"] for c in result["categories"]}
|
||||
assert imported["strategies"] == 3
|
||||
assert imported["monitor_rules"] == 1
|
||||
assert imported["watchlist"] == 2
|
||||
assert imported["preferences"] == 1
|
||||
assert imported["secrets"] == 1
|
||||
assert imported["auth"] == 1
|
||||
assert imported["lots"] == 1
|
||||
assert imported["paper"] == 3
|
||||
assert imported["research_candidates"] == 1
|
||||
assert imported["ext_pages"] == 2
|
||||
assert imported["sound_packs"] == 1
|
||||
assert imported["history"] == 2
|
||||
assert all(not c["failed"] for c in result["categories"])
|
||||
assert result["reload_hints"] == {
|
||||
"strategies": True, "monitor_rules": True, "data_sources": True, "watchlist": True,
|
||||
}
|
||||
|
||||
assert (root / "strategies" / "custom" / "custom_demo.py").read_text(encoding="utf-8") == SAFE_STRATEGY
|
||||
assert (root / "user_data" / "watchlist.parquet").is_file()
|
||||
# 嵌套路径与二进制类别按原样落盘
|
||||
assert (root / "paper" / "accounts" / "acc1" / "nav" / "daily.jsonl").is_file()
|
||||
assert (root / "sound_packs" / "ding.mp3").read_bytes() == b"ID3fakeaudio"
|
||||
|
||||
# 回环: 新环境再导出, 条目集合一致
|
||||
data2, _ = config_backup.build_backup(categories=config_backup.CATEGORY_KEYS)
|
||||
assert set(_zip_entries(data2)) == set(_zip_entries(data))
|
||||
|
||||
|
||||
def test_apply_conflict_default_skip_and_overwrite(src_env):
|
||||
data, _ = config_backup.build_backup()
|
||||
local = src_env / "strategies" / "custom" / "custom_demo.py"
|
||||
local.write_text("META = {'id': 'local'}\n", encoding="utf-8")
|
||||
(src_env / "strategies" / "custom" / "_helper.py").unlink()
|
||||
(src_env / "strategies" / "custom" / "custom_withdep.py").unlink()
|
||||
|
||||
result = config_backup.apply_archive(data)
|
||||
strategies = next(c for c in result["categories"] if c["key"] == "strategies")
|
||||
assert strategies["skipped_conflict"] == 1
|
||||
assert strategies["imported"] == 2 # 另两个策略已删, 无冲突
|
||||
assert local.read_text(encoding="utf-8") == "META = {'id': 'local'}\n" # 未被覆盖
|
||||
|
||||
result2 = config_backup.apply_archive(data, allow_overwrite=True)
|
||||
strategies2 = next(c for c in result2["categories"] if c["key"] == "strategies")
|
||||
assert strategies2["imported"] == 3
|
||||
assert local.read_text(encoding="utf-8") == SAFE_STRATEGY
|
||||
|
||||
|
||||
def test_apply_preferences_merges_not_replaces(src_env):
|
||||
from app.services import preferences
|
||||
|
||||
data, _ = config_backup.build_backup() # 备份内 quote_interval=5, 无 local_only_key
|
||||
preferences.save({"local_only_key": "keep", "quote_interval": 1})
|
||||
result = config_backup.apply_archive(data)
|
||||
assert result["preferences_merged"] is True
|
||||
|
||||
after = preferences.load()
|
||||
assert after["quote_interval"] == 5 # 备份键覆盖
|
||||
assert after["local_only_key"] == "keep" # 本地新增键保留 (merge 而非整替)
|
||||
# 备份导出时已脱敏 → 导入不清掉本地已有敏感键, 也不写入新的
|
||||
assert after["feishu_webhook_secret"] == "s3cret"
|
||||
|
||||
|
||||
def test_apply_secrets_via_store(src_env, tmp_path_factory, monkeypatch):
|
||||
data, _ = config_backup.build_backup(categories=config_backup.CATEGORY_KEYS)
|
||||
root = _fresh_env(tmp_path_factory, monkeypatch)
|
||||
(root / "user_data").mkdir(parents=True)
|
||||
(root / "user_data" / "secrets.json").write_text(
|
||||
json.dumps({"local_key": "lv"}), encoding="utf-8"
|
||||
)
|
||||
result = config_backup.apply_archive(data)
|
||||
assert result["secrets_merged"] is True
|
||||
|
||||
import app.secrets_store as secrets_store
|
||||
|
||||
secrets_store._cache = None
|
||||
secrets_store._cache_sig = None
|
||||
merged = secrets_store.load()
|
||||
assert merged["ai_api_key"] == "sk-1"
|
||||
assert merged["local_key"] == "lv"
|
||||
|
||||
|
||||
def test_apply_skips_unsafe_strategy(src_env):
|
||||
out = io.BytesIO()
|
||||
with zipfile.ZipFile(out, "w") as zf:
|
||||
zf.writestr("manifest.json", json.dumps({
|
||||
"format": config_backup.BACKUP_FORMAT,
|
||||
"version": config_backup.BACKUP_VERSION,
|
||||
}))
|
||||
zf.writestr("strategies/custom/custom_evil.py", UNSAFE_STRATEGY)
|
||||
zf.writestr("user_data/custom_signals/cs_ok.json", '{"id": "cs_ok"}')
|
||||
result = config_backup.apply_archive(out.getvalue())
|
||||
strategies = next(c for c in result["categories"] if c["key"] == "strategies")
|
||||
assert strategies["imported"] == 0
|
||||
assert len(strategies["failed"]) == 1 and "custom_evil.py" in strategies["failed"][0]["file"]
|
||||
assert not (src_env / "strategies" / "custom" / "custom_evil.py").exists()
|
||||
# 单条失败不影响其他条目
|
||||
signals = next(c for c in result["categories"] if c["key"] == "custom_signals")
|
||||
assert signals["imported"] == 1
|
||||
|
||||
|
||||
def test_apply_strategy_with_dependency_passes_safety(src_env):
|
||||
data, _ = config_backup.build_backup()
|
||||
# 本地删掉策略后重新导入: 依赖文件参与白名单, 不应被误杀
|
||||
for name in ("custom_demo.py", "_helper.py", "custom_withdep.py"):
|
||||
(src_env / "strategies" / "custom" / name).unlink()
|
||||
result = config_backup.apply_archive(data)
|
||||
strategies = next(c for c in result["categories"] if c["key"] == "strategies")
|
||||
assert strategies["imported"] == 3
|
||||
assert strategies["failed"] == []
|
||||
|
||||
|
||||
def test_apply_invalid_payload_reports_and_continues(src_env):
|
||||
out = io.BytesIO()
|
||||
with zipfile.ZipFile(out, "w") as zf:
|
||||
zf.writestr("manifest.json", json.dumps({
|
||||
"format": config_backup.BACKUP_FORMAT,
|
||||
"version": config_backup.BACKUP_VERSION,
|
||||
}))
|
||||
zf.writestr("user_data/monitor_rules/broken.json", "{not json")
|
||||
zf.writestr("data_sources/bad.yaml", "name: [unclosed")
|
||||
zf.writestr("analysis_menus/ok.json", '{"id": "am_ok"}')
|
||||
result = config_backup.apply_archive(out.getvalue())
|
||||
by_key = {c["key"]: c for c in result["categories"]}
|
||||
assert by_key["monitor_rules"]["failed"] and by_key["monitor_rules"]["imported"] == 0
|
||||
assert by_key["data_sources"]["failed"] and by_key["data_sources"]["imported"] == 0
|
||||
assert by_key["analysis_menus"]["imported"] == 1
|
||||
|
||||
|
||||
def test_export_category_selection(src_env):
|
||||
data, _ = config_backup.build_backup(categories={"custom_signals", "custom_factors", "非法键"})
|
||||
entries = _zip_entries(data)
|
||||
assert set(entries) == {
|
||||
"manifest.json",
|
||||
"user_data/custom_signals/cs_demo.json",
|
||||
"user_data/custom_factors/uf_demo.json",
|
||||
}
|
||||
manifest = json.loads(entries["manifest.json"])
|
||||
assert manifest["include_secrets"] is False
|
||||
counts = {c["key"]: c["count"] for c in manifest["categories"]}
|
||||
assert set(counts) == {"custom_signals", "custom_factors"}
|
||||
|
||||
|
||||
def test_apply_category_selection(src_env, tmp_path_factory, monkeypatch):
|
||||
from app.config import settings as app_settings
|
||||
|
||||
data, _ = config_backup.build_backup() # 全量备份 (不含密钥)
|
||||
root = _fresh_env(tmp_path_factory, monkeypatch)
|
||||
result = config_backup.apply_archive(data, categories={"monitor_rules", "preferences"})
|
||||
|
||||
assert result["skipped_unselected"] > 0
|
||||
assert result["preferences_merged"] is True
|
||||
assert result["secrets_merged"] is False
|
||||
by_key = {c["key"]: c for c in result["categories"]}
|
||||
assert set(by_key) == {"monitor_rules", "preferences"}
|
||||
assert by_key["monitor_rules"]["imported"] == 1
|
||||
assert result["reload_hints"] == {
|
||||
"strategies": False, "monitor_rules": True, "data_sources": False, "watchlist": False,
|
||||
}
|
||||
# 未勾选的类别确实没有落盘
|
||||
assert (root / "user_data" / "monitor_rules" / "mr_demo.json").is_file()
|
||||
assert not (root / "strategies" / "custom" / "custom_demo.py").exists()
|
||||
assert not (root / "user_data" / "watchlist.parquet").exists()
|
||||
|
||||
# 空选择 = fail-closed 什么都不写, 不回退成全量
|
||||
monkeypatch.setattr(app_settings, "data_dir", root)
|
||||
empty = config_backup.apply_archive(data, categories=frozenset())
|
||||
assert empty["categories"] == []
|
||||
assert empty["preferences_merged"] is False
|
||||
|
||||
|
||||
def test_sensitive_categories_require_explicit_selection(src_env):
|
||||
"""密钥/访问凭证只在显式勾选时导出; 显式勾选 auth 时带上 auth.json。"""
|
||||
default = _zip_entries(config_backup.build_backup()[0])
|
||||
assert "user_data/auth.json" not in default
|
||||
assert "user_data/secrets.json" not in default
|
||||
|
||||
data, _ = config_backup.build_backup(categories={"auth"})
|
||||
entries = _zip_entries(data)
|
||||
assert set(entries) == {"manifest.json", "user_data/auth.json"}
|
||||
|
||||
# 导入端: 嵌套白名单外的伪造条目仍被拒绝
|
||||
out = io.BytesIO()
|
||||
with zipfile.ZipFile(out, "w") as zf:
|
||||
zf.writestr("manifest.json", json.dumps({
|
||||
"format": config_backup.BACKUP_FORMAT,
|
||||
"version": config_backup.BACKUP_VERSION,
|
||||
}))
|
||||
zf.writestr("paper/accounts/acc1/evil.sh", "x")
|
||||
zf.writestr("paper/accounts/../escape.json", "{}")
|
||||
with pytest.raises(config_backup.ConfigBackupError, match="路径"):
|
||||
config_backup.inspect_archive(out.getvalue())
|
||||
out2 = io.BytesIO()
|
||||
with zipfile.ZipFile(out2, "w") as zf:
|
||||
zf.writestr("manifest.json", json.dumps({
|
||||
"format": config_backup.BACKUP_FORMAT,
|
||||
"version": config_backup.BACKUP_VERSION,
|
||||
}))
|
||||
zf.writestr("paper/accounts/acc1/evil.sh", "x")
|
||||
summary = config_backup.inspect_archive(out2.getvalue())
|
||||
assert summary["out_of_scope_files"] == 1
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
useToggleRealtimeQuotes,
|
||||
} from '@/lib/useSharedMutations'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { setUiPref } from '@/lib/uiPrefs'
|
||||
import { getPageScale } from '@/lib/pageZoom'
|
||||
import { setKlineBatchLimits } from '@/lib/klineBatchLimits'
|
||||
import { compareByDefaultNav, defaultNavIndex, seedHiddenForNewUser } from '@/lib/navOrder'
|
||||
@@ -426,7 +427,7 @@ export function Layout() {
|
||||
const overlayTimer = useRef<number | undefined>(undefined)
|
||||
const setNavStatePersist = (s: NavState) => {
|
||||
setNavState(s)
|
||||
try { localStorage.setItem('tf-nav-state', s) } catch {}
|
||||
try { setUiPref('tf-nav-state', s) } catch {}
|
||||
}
|
||||
// 图标条形态仅桌面 rail 态成立 (移动端抽屉与 overlay 预览恒为完整形态)
|
||||
const railMode = isDesktop && navState === 'rail'
|
||||
|
||||
@@ -2149,6 +2149,41 @@ export interface SoundPack {
|
||||
ext: string
|
||||
}
|
||||
|
||||
/** 配置备份 — 预览 (inspect) 与导入结果 (apply), 对应后端 config_backup */
|
||||
export interface ConfigBackupCategoryCount {
|
||||
key: string
|
||||
label: string
|
||||
count: number
|
||||
conflicts: number
|
||||
}
|
||||
|
||||
export interface ConfigBackupInspect {
|
||||
created_at: string
|
||||
app_version: string
|
||||
has_secrets: boolean
|
||||
categories: ConfigBackupCategoryCount[]
|
||||
total_files: number
|
||||
conflict_files: number
|
||||
out_of_scope_files: number
|
||||
}
|
||||
|
||||
export interface ConfigBackupApplyCategory {
|
||||
key: string
|
||||
label: string
|
||||
imported: number
|
||||
skipped_conflict: number
|
||||
failed: { file: string; error: string }[]
|
||||
}
|
||||
|
||||
export interface ConfigBackupApplyResult {
|
||||
categories: ConfigBackupApplyCategory[]
|
||||
preferences_merged: boolean
|
||||
secrets_merged: boolean
|
||||
skipped_unselected: number
|
||||
out_of_scope_files: number
|
||||
reload_hints: Record<string, boolean>
|
||||
}
|
||||
|
||||
export interface Preferences {
|
||||
realtime_quotes_enabled: boolean
|
||||
watchlist_groups_in_nav: boolean
|
||||
@@ -3756,6 +3791,33 @@ export const api = {
|
||||
pageAdminBackupUrl: (filename: string) =>
|
||||
`/api/pages-admin/backups/${encodeURIComponent(filename)}`,
|
||||
|
||||
// 配置备份 (切换设备迁移配置; 导出直链下载, 导入先 inspect 预览再 apply)
|
||||
// categories: 勾选的类别键 (后端 config_backup._CATEGORY_LABELS 的键);
|
||||
// 含 'secrets' 才导出/导入密钥, 不含则该类别跳过。
|
||||
configBackupExportUrl: (categoryKeys: readonly string[]) =>
|
||||
`/api/config-backup/export?categories=${encodeURIComponent(categoryKeys.join(','))}`,
|
||||
|
||||
configBackupInspect: (file: File) => {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
return request<ConfigBackupInspect>('/api/config-backup/inspect', {
|
||||
method: 'POST',
|
||||
body: fd,
|
||||
})
|
||||
},
|
||||
|
||||
configBackupApply: (file: File, allowOverwrite: boolean, categoryKeys: readonly string[]) => {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
fd.append('allow_overwrite', allowOverwrite ? 'true' : 'false')
|
||||
fd.append('categories', categoryKeys.join(','))
|
||||
return request<ConfigBackupApplyResult>('/api/config-backup/apply', {
|
||||
method: 'POST',
|
||||
body: fd,
|
||||
timeoutMs: 120_000,
|
||||
})
|
||||
},
|
||||
|
||||
analysisMenu: (id: string) =>
|
||||
request<AnalysisMenu>(`/api/analysis-menus/${encodeURIComponent(id)}`),
|
||||
|
||||
|
||||
@@ -36,6 +36,11 @@ const SPECS: { ls: string; remote: string; codec: Codec }[] = [
|
||||
{ ls: 'voice_broadcast_rate', remote: 'voice_broadcast_rate', codec: 'float' },
|
||||
{ ls: 'stock_external_template', remote: 'stock_external_template', codec: 'json' },
|
||||
{ ls: 'tf-page-zoom', remote: 'page_zoom', codec: 'json' },
|
||||
// 局部界面状态也走跨端副本 (随配置备份迁移): 监控徽标/设置页收起/回测列表高度/导航形态
|
||||
{ ls: 'monitor_badge_enabled', remote: 'monitor_badge_enabled', codec: 'bool01' },
|
||||
{ ls: 'tf-settings-nav-collapsed', remote: 'settings_nav_collapsed', codec: 'bool01' },
|
||||
{ ls: 'bt_strategy_list_maxh', remote: 'bt_strategy_list_maxh', codec: 'int' },
|
||||
{ ls: 'tf-nav-state', remote: 'nav_state', codec: 'string' },
|
||||
]
|
||||
|
||||
const BY_LS = new Map(SPECS.map(s => [s.ls, s]))
|
||||
|
||||
@@ -6,10 +6,11 @@
|
||||
import { Suspense, lazy, useEffect, useState, type ComponentType } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Database, KeyRound, LayoutGrid, Radio, Sparkles, Settings2, PanelLeftClose, PanelLeftOpen, Clock3, Info } from 'lucide-react'
|
||||
import { Database, KeyRound, LayoutGrid, Radio, Sparkles, Settings2, PanelLeftClose, PanelLeftOpen, Clock3, Info, HardDriveDownload } from 'lucide-react'
|
||||
// 面板按 tab 按需加载: 8 个面板源码 270KB+, 同步打包会让设置页 chunk 膨胀
|
||||
const SettingsAIPanel = lazy(() => import('./settings/AI').then(m => ({ default: m.SettingsAIPanel })))
|
||||
const SettingsApiTokensPanel = lazy(() => import('./settings/ApiTokens').then(m => ({ default: m.SettingsApiTokensPanel })))
|
||||
const SettingsBackupPanel = lazy(() => import('./settings/Backup').then(m => ({ default: m.SettingsBackupPanel })))
|
||||
const SettingsMonitoringPanel = lazy(() => import('./settings/Monitoring').then(m => ({ default: m.SettingsMonitoringPanel })))
|
||||
const SettingsPageAdminPanel = lazy(() => import('./settings/PageAdmin').then(m => ({ default: m.SettingsPageAdminPanel })))
|
||||
const SettingsTimeoutPanel = lazy(() => import('./settings/Timeout').then(m => ({ default: m.SettingsTimeoutPanel })))
|
||||
@@ -18,6 +19,7 @@ const SettingsAboutPanel = lazy(() => import('./settings/About').then(m => ({ de
|
||||
const SettingsDataSourcesPanel = lazy(() => import('./settings/DataSources').then(m => ({ default: m.SettingsDataSourcesPanel })))
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { setUiPref } from '@/lib/uiPrefs'
|
||||
|
||||
// ===== Tab 定义 =====
|
||||
|
||||
@@ -36,6 +38,7 @@ const TABS: readonly TabDef[] = [
|
||||
{ key: 'pages', label: '页面管理', icon: LayoutGrid, panel: SettingsPageAdminPanel },
|
||||
{ key: 'timeout', label: '网络设置', icon: Clock3, panel: SettingsTimeoutPanel },
|
||||
{ key: 'api-tokens', label: '开放接口', icon: KeyRound, panel: SettingsApiTokensPanel },
|
||||
{ key: 'backup', label: '配置备份', icon: HardDriveDownload, panel: SettingsBackupPanel },
|
||||
{ key: 'system', label: '系统设置', icon: Settings2, panel: SettingsSystemPanel },
|
||||
{ key: 'about', label: '关于', icon: Info, panel: SettingsAboutPanel },
|
||||
]
|
||||
@@ -73,7 +76,7 @@ export function Settings() {
|
||||
const toggleCollapsed = () => {
|
||||
setCollapsed(prev => {
|
||||
const next = !prev
|
||||
try { localStorage.setItem('tf-settings-nav-collapsed', next ? '1' : '0') } catch {}
|
||||
try { setUiPref('tf-settings-nav-collapsed', next ? '1' : '0') } catch {}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
REGIME_STATE_COLORS,
|
||||
} from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { setUiPref } from '@/lib/uiPrefs'
|
||||
import { groupParams, visibleParams } from '@/lib/strategyParams'
|
||||
import { storage } from '@/lib/storage'
|
||||
import { fmtPct, fmtPrice, priceColorClass } from '@/lib/format'
|
||||
@@ -1662,7 +1663,7 @@ export function StrategyBacktest({ loadCandidate, onLoadConsumed }: {
|
||||
} catch { return 128 }
|
||||
})
|
||||
useEffect(() => {
|
||||
try { localStorage.setItem('bt_strategy_list_maxh', String(strategyListMaxH)) } catch { /* 私有模式等 */ }
|
||||
try { setUiPref('bt_strategy_list_maxh', String(strategyListMaxH)) } catch { /* 私有模式等 */ }
|
||||
}, [strategyListMaxH])
|
||||
const listResize = useRef<{ startY: number; startH: number } | null>(null)
|
||||
const onListResizeStart = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
|
||||
@@ -0,0 +1,539 @@
|
||||
/**
|
||||
* 配置备份面板 — 导出/导入用户配置, 解决切换设备配置丢失。
|
||||
*
|
||||
* 导出: 按类别勾选 (默认全选、密钥除外), 服务端打包 ZIP 直链下载。
|
||||
* 导入: 上传 → inspect 预览 (类别条目数/冲突) → 按类别勾选 + 二次确认 →
|
||||
* apply → 失效受影响的前端查询 (后端已同步刷新引擎与数据源)。
|
||||
*/
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { CheckCircle2, Download, FileArchive, RotateCcw, ShieldAlert, TriangleAlert, Upload } from 'lucide-react'
|
||||
import { api } from '@/lib/api'
|
||||
import type { ConfigBackupApplyResult, ConfigBackupInspect } from '@/lib/api'
|
||||
import { toast } from '@/components/Toast'
|
||||
import { Modal } from '@/components/Modal'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
|
||||
/** 类别选项 — key 与后端 config_backup._CATEGORY_LABELS 一一对应 */
|
||||
const CATEGORY_OPTIONS: { key: string; label: string; desc: string; sensitive?: boolean }[] = [
|
||||
{ key: 'strategies', label: '策略源码', desc: '自定义 / AI / 组合策略' },
|
||||
{ key: 'strategy_overrides', label: '策略调参', desc: '参数覆盖' },
|
||||
{ key: 'custom_signals', label: '自定义信号', desc: '信号库中的自定义信号' },
|
||||
{ key: 'custom_factors', label: '自定义因子', desc: '自定义与组合因子' },
|
||||
{ key: 'monitor_rules', label: '监控规则', desc: '监控中心规则' },
|
||||
{ key: 'watchlist', label: '自选股与分组', desc: '自选列表与分组定义' },
|
||||
{ key: 'lots', label: '批量持仓', desc: '批量交易持仓记录' },
|
||||
{ key: 'paper', label: '模拟盘账户', desc: '账户 / 持仓 / 成交台账' },
|
||||
{ key: 'research_candidates', label: '挖掘候选', desc: '挖掘入选清单' },
|
||||
{ key: 'data_sources', label: '自定义数据源', desc: '数据源连接配置' },
|
||||
{ key: 'analysis_menus', label: '分析菜单', desc: '自定义分析入口' },
|
||||
{ key: 'ext_pages', label: '扩展页面', desc: '自定义页面插件 (含可执行代码)' },
|
||||
{ key: 'ext_data', label: '扩展数据集配置', desc: '不含数据本体与 Key' },
|
||||
{ key: 'sound_packs', label: '语音包', desc: '自定义提示音' },
|
||||
{ key: 'history', label: '历史记录', desc: '告警历史与助手会话' },
|
||||
{ key: 'preferences', label: '偏好设置', desc: '界面与通知偏好 (脱敏)' },
|
||||
{ key: 'secrets', label: '密钥', desc: 'AI Key / 数据源 Key / Webhook Secret', sensitive: true },
|
||||
{ key: 'auth', label: '访问凭证', desc: '访问密码与会话 — 导入后原密码在本机生效', sensitive: true },
|
||||
]
|
||||
|
||||
/** 导出默认勾选: 除密钥外全选 (安全默认)。 */
|
||||
const DEFAULT_EXPORT_KEYS = CATEGORY_OPTIONS.filter((c) => !c.sensitive).map((c) => c.key)
|
||||
|
||||
const SCOPE_EXCLUDED = '行情数据、K 线缓存、回测矩阵、任务记录等可重新同步或派生的运行数据不在备份范围内'
|
||||
|
||||
/** 导入成功后需要失效的查询 — 与备份类别覆盖的领域对应。 */
|
||||
const INVALIDATE_KEYS: readonly (readonly unknown[])[] = [
|
||||
QK.preferences,
|
||||
QK.settings,
|
||||
QK.monitorRules,
|
||||
QK.customSignals,
|
||||
QK.customSignalsOptions,
|
||||
QK.watchlist,
|
||||
QK.watchlistGroups,
|
||||
QK.dataSources,
|
||||
QK.lots,
|
||||
QK.paperAll,
|
||||
QK.soundPacks,
|
||||
['alerts'],
|
||||
// 参数化键按前缀失效 (与 Screener/FactorEditor 的既有做法一致)
|
||||
['screener-strategies'],
|
||||
['factors-library'],
|
||||
['ext-pages'],
|
||||
]
|
||||
|
||||
function toggleSet(set: ReadonlySet<string>, key: string): Set<string> {
|
||||
const next = new Set(set)
|
||||
if (next.has(key)) next.delete(key)
|
||||
else next.add(key)
|
||||
return next
|
||||
}
|
||||
|
||||
/** 卡片头: 图标底板 + 标题 + 说明, 右侧放主操作。 */
|
||||
function CardHeader({ icon, title, subtitle, children }: {
|
||||
icon: React.ReactNode
|
||||
title: string
|
||||
subtitle: string
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-btn bg-accent/10 text-accent">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-foreground">{title}</div>
|
||||
<div className="truncate text-[11px] text-muted">{subtitle}</div>
|
||||
</div>
|
||||
</div>
|
||||
{children && <div className="shrink-0">{children}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 类别勾选行 — 选中态着色 (敏感类别用警示色)。 */
|
||||
function CategoryCheck({ label, desc, checked, onChange, sensitive }: {
|
||||
label: string
|
||||
desc: string
|
||||
checked: boolean
|
||||
onChange: () => void
|
||||
sensitive?: boolean
|
||||
}) {
|
||||
return (
|
||||
<label
|
||||
className={`flex cursor-pointer items-start gap-2.5 rounded-btn border px-2.5 py-2 transition-colors ${
|
||||
sensitive
|
||||
? checked ? 'border-warning/30 bg-warning/[0.06]' : 'border-border/60 hover:border-warning/30'
|
||||
: checked ? 'border-accent/25 bg-accent/[0.05]' : 'border-transparent hover:bg-elevated/60'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
className="mt-0.5 rounded border-border"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1 text-xs text-foreground">
|
||||
{sensitive && <ShieldAlert className="h-3 w-3 shrink-0 text-warning" />}
|
||||
{label}
|
||||
</div>
|
||||
{desc && <div className="mt-0.5 text-[11px] leading-snug text-muted">{desc}</div>}
|
||||
</div>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
export function SettingsBackupPanel() {
|
||||
const qc = useQueryClient()
|
||||
|
||||
// ── 导出 ──
|
||||
const [exportSel, setExportSel] = useState<ReadonlySet<string>>(
|
||||
() => new Set(DEFAULT_EXPORT_KEYS),
|
||||
)
|
||||
const exportSecrets = exportSel.has('secrets') || exportSel.has('auth')
|
||||
|
||||
// ── 导入 ──
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [inspect, setInspect] = useState<ConfigBackupInspect | null>(null)
|
||||
const [importSel, setImportSel] = useState<ReadonlySet<string>>(new Set())
|
||||
const [applyResult, setApplyResult] = useState<ConfigBackupApplyResult | null>(null)
|
||||
const [allowOverwrite, setAllowOverwrite] = useState(false)
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
|
||||
const inspectM = useMutation({
|
||||
mutationFn: (f: File) => api.configBackupInspect(f),
|
||||
onSuccess: (r) => {
|
||||
setInspect(r)
|
||||
// 新备份默认全选 (含密钥 — 导出方已决定打包它)
|
||||
setImportSel(new Set(r.categories.map((c) => c.key)))
|
||||
setApplyResult(null)
|
||||
},
|
||||
onError: () => setFile(null), // 错误提示由 request 层统一 toast
|
||||
})
|
||||
|
||||
const onFilePicked = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const f = e.target.files?.[0]
|
||||
e.target.value = '' // 同名重选也能触发 onChange
|
||||
if (!f) return
|
||||
setFile(f)
|
||||
setInspect(null)
|
||||
setApplyResult(null)
|
||||
inspectM.mutate(f)
|
||||
}, [inspectM])
|
||||
|
||||
const applyM = useMutation({
|
||||
mutationFn: () => api.configBackupApply(file!, allowOverwrite, [...importSel]),
|
||||
onSuccess: (r) => {
|
||||
setApplyResult(r)
|
||||
setConfirming(false)
|
||||
const imported = r.categories.reduce((s, c) => s + c.imported, 0)
|
||||
const failed = r.categories.reduce((s, c) => s + c.failed.length, 0)
|
||||
toast(failed > 0 ? `导入完成: ${imported} 项成功, ${failed} 项失败` : `导入完成: ${imported} 项`, failed > 0 ? 'error' : 'success')
|
||||
INVALIDATE_KEYS.forEach((k) => qc.invalidateQueries({ queryKey: k }))
|
||||
},
|
||||
onError: () => setConfirming(false),
|
||||
})
|
||||
|
||||
const closeConfirm = () => {
|
||||
if (!applyM.isPending) setConfirming(false)
|
||||
}
|
||||
|
||||
const fmtTime = (iso: string) => {
|
||||
try {
|
||||
return iso ? new Date(iso).toLocaleString() : '—'
|
||||
} catch {
|
||||
return iso || '—'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-4">
|
||||
{/* ── 导出 ── */}
|
||||
<div className="rounded-card border border-border bg-surface p-4">
|
||||
<CardHeader
|
||||
icon={<Download className="h-4 w-4" />}
|
||||
title="导出配置备份"
|
||||
subtitle="打包为单个 ZIP 文件, 拷贝到新设备后即可导入恢复"
|
||||
>
|
||||
<a
|
||||
href={exportSel.size > 0 ? api.configBackupExportUrl([...exportSel]) : undefined}
|
||||
download
|
||||
aria-disabled={exportSel.size === 0}
|
||||
onClick={(e) => { if (exportSel.size === 0) e.preventDefault() }}
|
||||
className={`inline-flex items-center gap-1.5 rounded-btn px-3.5 py-1.5 text-xs font-medium transition-colors ${
|
||||
exportSel.size > 0
|
||||
? 'bg-accent text-white hover:bg-accent/90'
|
||||
: 'pointer-events-none bg-accent text-white opacity-40'
|
||||
}`}
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
导出 .zip
|
||||
</a>
|
||||
</CardHeader>
|
||||
|
||||
{/* 备份内容选择 */}
|
||||
<div className="mt-3 rounded-btn border border-border/70 bg-elevated/25 p-2.5">
|
||||
<div className="mb-1.5 flex items-center justify-between px-0.5">
|
||||
<div className="flex items-center gap-2 text-[11px] font-medium text-secondary">
|
||||
备份内容
|
||||
<span className={`rounded-full px-1.5 py-px text-[10px] ${
|
||||
exportSel.size > 0 ? 'bg-accent/10 text-accent' : 'bg-danger/10 text-danger'
|
||||
}`}>
|
||||
已选 {exportSel.size} / {CATEGORY_OPTIONS.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[11px]">
|
||||
<button
|
||||
onClick={() => setExportSel(new Set(DEFAULT_EXPORT_KEYS))}
|
||||
className="text-muted transition-colors hover:text-accent"
|
||||
>
|
||||
全选
|
||||
</button>
|
||||
<span className="text-border">·</span>
|
||||
<button
|
||||
onClick={() => setExportSel(new Set())}
|
||||
className="text-muted transition-colors hover:text-accent"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-1 sm:grid-cols-2">
|
||||
{CATEGORY_OPTIONS.filter((c) => !c.sensitive).map((c) => (
|
||||
<CategoryCheck
|
||||
key={c.key}
|
||||
label={c.label}
|
||||
desc={c.desc}
|
||||
checked={exportSel.has(c.key)}
|
||||
onChange={() => setExportSel((s) => toggleSet(s, c.key))}
|
||||
/>
|
||||
))}
|
||||
{/* 密钥独立成行, 与普通类别视觉分隔 */}
|
||||
<div className="col-span-full mt-1 border-t border-border/60 pt-1">
|
||||
{CATEGORY_OPTIONS.filter((c) => c.sensitive).map((c) => (
|
||||
<CategoryCheck
|
||||
key={c.key}
|
||||
label={c.label}
|
||||
desc={c.desc}
|
||||
sensitive
|
||||
checked={exportSel.has(c.key)}
|
||||
onChange={() => setExportSel((s) => toggleSet(s, c.key))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{exportSecrets && (
|
||||
<div className="mt-2 flex items-start gap-2 rounded-btn border border-warning/25 bg-warning/[0.07] px-3 py-2 text-[11px] leading-relaxed text-warning">
|
||||
<ShieldAlert className="mt-px h-3.5 w-3.5 shrink-0" />
|
||||
<span>
|
||||
已勾选敏感内容: 备份文件将包含明文密钥 (AI Key、数据源 Key、Webhook Secret)
|
||||
和/或访问凭证 (密码哈希与会话), 新设备导入后无需重新填写。
|
||||
请妥善保管, 切勿分享或上传到公开位置。
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2.5 px-0.5 text-[11px] text-muted">{SCOPE_EXCLUDED}</div>
|
||||
</div>
|
||||
|
||||
{/* ── 导入 ── */}
|
||||
<div className="rounded-card border border-border bg-surface p-4">
|
||||
<CardHeader
|
||||
icon={<Upload className="h-4 w-4" />}
|
||||
title="导入配置备份"
|
||||
subtitle="选择导出的备份文件, 预览确认后恢复到本机"
|
||||
/>
|
||||
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".zip"
|
||||
className="hidden"
|
||||
onChange={onFilePicked}
|
||||
/>
|
||||
|
||||
{/* 选择文件: 未选时为放置区, 已选后显示文件条 */}
|
||||
{!inspect ? (
|
||||
<button
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={inspectM.isPending}
|
||||
className="mt-3 flex w-full flex-col items-center justify-center gap-1.5 rounded-btn border border-dashed border-border bg-elevated/40 px-4 py-6 transition-colors hover:bg-elevated/70 disabled:opacity-50"
|
||||
>
|
||||
{inspectM.isPending ? (
|
||||
<>
|
||||
<RotateCcw className="h-5 w-5 animate-spin text-accent" />
|
||||
<span className="text-xs text-secondary">解析备份内容…</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FileArchive className="h-5 w-5 text-accent" />
|
||||
<span className="text-xs text-secondary">点击选择备份文件 (.zip)</span>
|
||||
<span className="text-[11px] text-muted">导入前会先解析预览, 不会直接写入</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<div className="mt-3 flex items-center gap-2 rounded-btn border border-border/70 bg-elevated/30 px-3 py-2">
|
||||
<CheckCircle2 className="h-4 w-4 shrink-0 text-accent" />
|
||||
<span className="min-w-0 flex-1 truncate text-xs text-foreground">{file?.name}</span>
|
||||
<button
|
||||
onClick={() => fileRef.current?.click()}
|
||||
className="shrink-0 text-[11px] text-muted transition-colors hover:text-accent"
|
||||
>
|
||||
重新选择
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!inspect && (
|
||||
<div className="mt-2 px-0.5 text-[11px] leading-relaxed text-muted">
|
||||
同名文件默认跳过 (可选择覆盖); 偏好与密钥按合并写入, 不会清掉本机已有内容
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 预览 + 类别勾选 */}
|
||||
{inspect && (
|
||||
<div className="mt-3 space-y-2.5 rounded-btn border border-border/70 bg-elevated/25 p-3">
|
||||
<div className="flex flex-wrap items-center gap-1.5 text-[11px]">
|
||||
<span className="rounded-full border border-border bg-surface px-2 py-0.5 text-secondary">
|
||||
备份时间 {fmtTime(inspect.created_at)}
|
||||
</span>
|
||||
{inspect.app_version && (
|
||||
<span className="rounded-full border border-border bg-surface px-2 py-0.5 text-secondary">
|
||||
来源 v{inspect.app_version}
|
||||
</span>
|
||||
)}
|
||||
<span className={`rounded-full border px-2 py-0.5 ${
|
||||
inspect.has_secrets
|
||||
? 'border-warning/30 bg-warning/[0.08] text-warning'
|
||||
: 'border-border bg-surface text-muted'
|
||||
}`}>
|
||||
{inspect.has_secrets ? '包含密钥' : '不含密钥'}
|
||||
</span>
|
||||
<span className="rounded-full border border-border bg-surface px-2 py-0.5 text-secondary">
|
||||
共 {inspect.total_files} 个文件
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<table className="w-full text-[11px]">
|
||||
<thead>
|
||||
<tr className="text-muted">
|
||||
<th className="w-8 py-1 font-normal" />
|
||||
<th className="py-1 text-left font-normal">导入类别</th>
|
||||
<th className="py-1 text-right font-normal">条目</th>
|
||||
<th className="py-1 text-right font-normal">冲突</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{inspect.categories.map((c) => (
|
||||
<tr key={c.key} className="border-t border-border/60">
|
||||
<td className="py-1.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={importSel.has(c.key)}
|
||||
onChange={() => setImportSel((s) => toggleSet(s, c.key))}
|
||||
className="rounded border-border"
|
||||
aria-label={`导入 ${c.label}`}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-1.5 text-foreground">
|
||||
{c.key === 'secrets' && <ShieldAlert className="mr-1 inline h-3 w-3 text-warning" />}
|
||||
{c.label}
|
||||
</td>
|
||||
<td className="py-1.5 text-right text-secondary">{c.count}</td>
|
||||
<td className="py-1.5 text-right">
|
||||
{c.conflicts > 0
|
||||
? <span className="rounded-full bg-warning/10 px-1.5 py-px text-warning">{c.conflicts}</span>
|
||||
: <span className="text-muted">—</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{inspect.conflict_files > 0 && (
|
||||
<div className="flex items-center justify-between gap-4 rounded-btn bg-elevated/70 px-2.5 py-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5 text-xs text-foreground">
|
||||
<TriangleAlert className="h-3.5 w-3.5 shrink-0 text-warning" />
|
||||
覆盖已存在的同名项
|
||||
</div>
|
||||
<div className="text-[11px] text-muted">
|
||||
{inspect.conflict_files} 个同名文件默认跳过; 开启后用备份内容覆盖本机同名文件
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setAllowOverwrite((v) => !v)}
|
||||
aria-pressed={allowOverwrite}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors duration-200 ${
|
||||
allowOverwrite ? 'bg-warning' : 'bg-elevated'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow-sm transition-transform duration-200 ${
|
||||
allowOverwrite ? 'translate-x-[18px]' : 'translate-x-[3px]'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between gap-3 pt-0.5">
|
||||
<div className="text-[11px] text-muted">
|
||||
{importSel.size === 0 ? '请至少勾选一个类别' : '导入前请确认备份文件来源可信'}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setConfirming(true)}
|
||||
disabled={importSel.size === 0 || applyM.isPending}
|
||||
className="inline-flex shrink-0 items-center gap-1.5 rounded-btn border border-danger/30 bg-danger/10 px-3.5 py-1.5 text-xs font-medium text-danger transition-colors hover:bg-danger/20 disabled:opacity-50"
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
导入所选 {importSel.size} 个类别
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 导入结果 */}
|
||||
{applyResult && (
|
||||
<div className="mt-3 space-y-2 rounded-btn border border-border/70 bg-elevated/25 p-3">
|
||||
<div className="flex items-center gap-2 text-sm text-foreground">
|
||||
<CheckCircle2 className={`h-4 w-4 ${applyResult.categories.some((c) => c.failed.length > 0) ? 'text-warning' : 'text-accent'}`} />
|
||||
导入结果
|
||||
</div>
|
||||
<table className="w-full text-[11px]">
|
||||
<thead>
|
||||
<tr className="text-muted">
|
||||
<th className="py-1 text-left font-normal">类别</th>
|
||||
<th className="py-1 text-right font-normal">成功</th>
|
||||
<th className="py-1 text-right font-normal">跳过冲突</th>
|
||||
<th className="py-1 text-right font-normal">失败</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{applyResult.categories.map((c) => (
|
||||
<tr key={c.key} className="border-t border-border/60">
|
||||
<td className="py-1.5 text-foreground">{c.label}</td>
|
||||
<td className="py-1.5 text-right text-secondary">{c.imported}</td>
|
||||
<td className="py-1.5 text-right text-muted">{c.skipped_conflict || '—'}</td>
|
||||
<td className="py-1.5 text-right">
|
||||
{c.failed.length > 0
|
||||
? <span className="rounded-full bg-danger/10 px-1.5 py-px text-danger">{c.failed.length}</span>
|
||||
: <span className="text-muted">—</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{applyResult.categories.some((c) => c.failed.length > 0) && (
|
||||
<div className="space-y-1">
|
||||
{applyResult.categories.flatMap((c) =>
|
||||
c.failed.map((f) => (
|
||||
<div key={`${c.key}:${f.file}`} className="truncate text-[11px] text-danger" title={f.error}>
|
||||
{f.file}: {f.error}
|
||||
</div>
|
||||
)),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(applyResult.skipped_unselected > 0 || applyResult.out_of_scope_files > 0) && (
|
||||
<div className="space-y-0.5 text-[11px] text-muted">
|
||||
{applyResult.skipped_unselected > 0 && (
|
||||
<div>{applyResult.skipped_unselected} 个条目属于未勾选的类别, 已跳过</div>
|
||||
)}
|
||||
{applyResult.out_of_scope_files > 0 && (
|
||||
<div>{applyResult.out_of_scope_files} 个条目不在当前版本支持的备份范围内, 已忽略</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-[11px] text-muted">
|
||||
偏好与密钥按合并写入 (备份内容覆盖同名项, 本机新增项保留)
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 二次确认 */}
|
||||
{confirming && inspect && (
|
||||
<Modal onClose={closeConfirm} ariaLabel="确认导入配置备份">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<TriangleAlert className="h-4 w-4 text-warning" />
|
||||
<div className="text-sm font-medium">确认导入配置备份?</div>
|
||||
</div>
|
||||
<div className="mt-2 space-y-1 text-[12px] text-muted">
|
||||
<div>文件: {file?.name}</div>
|
||||
<div>
|
||||
将导入 {importSel.size} 个类别
|
||||
{allowOverwrite ? ', 并覆盖已存在的同名项' : ''}。
|
||||
</div>
|
||||
{(importSel.has('secrets') || importSel.has('auth')) && (
|
||||
<div className="text-warning">已勾选敏感内容, 导入后将覆盖本机同名密钥/访问凭证。</div>
|
||||
)}
|
||||
<div>策略源码会在导入时执行安全校验, 无法通过校验的条目将被跳过。</div>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button
|
||||
onClick={closeConfirm}
|
||||
disabled={applyM.isPending}
|
||||
className="rounded-btn border border-border px-3 py-1.5 text-xs text-muted transition-colors hover:text-foreground disabled:opacity-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={() => applyM.mutate()}
|
||||
disabled={applyM.isPending}
|
||||
className="inline-flex items-center gap-1.5 rounded-btn border border-danger/30 bg-danger/10 px-3 py-1.5 text-xs text-danger transition-colors hover:bg-danger/20 disabled:opacity-50"
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
{applyM.isPending ? '导入中…' : '确认导入'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import { CSS } from '@dnd-kit/utilities'
|
||||
import { Eye, EyeOff, ExternalLink, GripVertical, Settings, Bell, Trash2 } from 'lucide-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { api } from '@/lib/api'
|
||||
import { setUiPref } from '@/lib/uiPrefs'
|
||||
import { toast } from '@/components/Toast'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { usePreferences } from '@/lib/useSharedQueries'
|
||||
@@ -374,7 +375,7 @@ export function SettingsMenuSettingsPanel() {
|
||||
if (id !== '/monitor') return
|
||||
const next = !badgeEnabled
|
||||
setBadgeEnabled(next)
|
||||
try { localStorage.setItem('monitor_badge_enabled', next ? '1' : '0') } catch { /* ignore */ }
|
||||
try { setUiPref('monitor_badge_enabled', next ? '1' : '0') } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -914,7 +914,7 @@ Windows 或受限沙箱中若系统回收站不可用,页面会使用受控的
|
||||
|
||||
## 21. 设置
|
||||
|
||||
设置页包含 TickFlow、AI 设置、实时监控、数据源 Beta、扩展页面、菜单设置和系统设置。信号库已迁为独立页面,见第 11 章。
|
||||
设置页包含 TickFlow、AI 设置、实时监控、数据源 Beta、扩展页面、菜单设置、系统设置和配置备份。信号库已迁为独立页面,见第 11 章。
|
||||
|
||||
### 21.1 TickFlow
|
||||
|
||||
@@ -998,6 +998,32 @@ Free 能力仅实时覆盖自选前 5 个标的。轮询间隔不应低于数据
|
||||
|
||||
“刷新前端缓存”用于解决升级后仍显示旧页面的问题,不会清除用户配置和本地股票数据。
|
||||
|
||||
### 21.8 配置备份
|
||||
|
||||
用于切换设备或重装系统时迁移配置,避免配置丢失。
|
||||
|
||||
**导出**:按类别勾选要备份的内容(默认全选),点击“导出备份文件”生成一个 ZIP 备份文件,妥善保存并拷贝到新设备。可选类别包括:
|
||||
|
||||
- 策略源码(含 AI 策略、组合策略)与策略调参覆盖。
|
||||
- 自定义信号、自定义因子、监控规则。
|
||||
- 偏好设置(含界面局部状态如导航形态、监控徽标开关)、自选股与分组。
|
||||
- 批量持仓、模拟盘账户(账户/持仓/成交台账)、挖掘候选。
|
||||
- 自定义数据源、分析菜单、扩展页面、扩展数据集配置、语音包。
|
||||
- 历史记录(告警历史与助手会话)。
|
||||
- 密钥(AI Key、数据源 Key、Webhook Secret)、访问凭证(访问密码与会话)。
|
||||
|
||||
行情数据、K 线缓存、回测矩阵、任务记录等可重新同步或派生的运行数据不在备份范围内。
|
||||
|
||||
密钥与访问凭证默认不勾选;勾选后可一并带走、新设备无需重新填写(访问凭证导入后原访问密码在新设备生效),但备份文件将包含明文敏感信息,务必妥善保管,切勿分享或上传到公开位置。
|
||||
|
||||
扩展页面包含可执行代码(编译后的页面脚本),策略源码在导入时会执行安全校验,页面脚本无法校验——只导入来源可信的备份文件。
|
||||
|
||||
**导入**:在新设备的配置备份页选择备份文件,系统先解析预览(各类别条目数、与当前环境的同名冲突),可按类别勾选只导入部分内容,确认后再执行导入:
|
||||
|
||||
- 与本机同名的文件默认跳过;可勾选“覆盖已存在的同名项”用备份内容覆盖。
|
||||
- 偏好设置与密钥按合并写入:备份内容覆盖同名项,本机新增项保留。
|
||||
- 策略源码导入时会执行安全校验,无法通过校验的条目会跳过并在结果中说明原因。
|
||||
|
||||
## 22. 数据源、扩展数据与插件
|
||||
|
||||
### 22.1 插件化原则
|
||||
|
||||
Reference in New Issue
Block a user