From 2892c644f1183c2335146e455944eb4ddeaed92c Mon Sep 17 00:00:00 2001 From: shy3130 Date: Mon, 26 Oct 2026 12:13:19 +0800 Subject: [PATCH] =?UTF-8?q?feat(backup):=20=E9=85=8D=E7=BD=AE=E5=A4=87?= =?UTF-8?q?=E4=BB=BD=E4=B8=8E=E8=BF=81=E7=A7=BB=20=E2=80=94=2018=20?= =?UTF-8?q?=E7=B1=BB=E5=88=AB=20ZIP=20=E5=AF=BC=E5=87=BA=E5=AF=BC=E5=85=A5?= =?UTF-8?q?,=20=E5=BD=BB=E5=BA=95=E8=A7=A3=E5=86=B3=E5=A4=9A=E8=AE=BE?= =?UTF-8?q?=E5=A4=87=E9=85=8D=E7=BD=AE=E4=B8=A2=E5=A4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端 config_backup service + /api/config-backup 三端点: 按类别导出/预览/导入 - 覆盖策略/监控/信号/因子/偏好/自选/持仓/模拟盘/挖掘候选/数据源/菜单/扩展页面/扩展数据集/语音包/历史记录 - 密钥与访问凭证默认脱敏不导出, 显式勾选才打包; 偏好/密钥 merge 写入 - ZIP 白名单路径 + 防炸弹 + 策略 AST 安全校验, 冲突默认跳过可选覆盖 - 监控徽标/导航形态/回测列表高度/设置页收起接入 uiPrefs 跨端同步 - 设置页新增「配置备份」标签页, 导出/导入向导式 UI; 16 个专项测试 Signed-off-by: shy3130 --- backend/app/api/config_backup.py | 99 +++ backend/app/main.py | 2 + backend/app/services/auth.py | 11 + backend/app/services/config_backup.py | 683 ++++++++++++++++++ backend/app/services/preferences.py | 5 + backend/tests/test_config_backup.py | 484 +++++++++++++ frontend/src/components/Layout.tsx | 3 +- frontend/src/lib/api.ts | 62 ++ frontend/src/lib/uiPrefs.ts | 5 + frontend/src/pages/Settings.tsx | 7 +- .../src/pages/backtest/StrategyBacktest.tsx | 3 +- frontend/src/pages/settings/Backup.tsx | 539 ++++++++++++++ frontend/src/pages/settings/MenuSettings.tsx | 3 +- 操作说明书.md | 28 +- 14 files changed, 1928 insertions(+), 6 deletions(-) create mode 100644 backend/app/api/config_backup.py create mode 100644 backend/app/services/config_backup.py create mode 100644 backend/tests/test_config_backup.py create mode 100644 frontend/src/pages/settings/Backup.tsx diff --git a/backend/app/api/config_backup.py b/backend/app/api/config_backup.py new file mode 100644 index 00000000..49cbc9e0 --- /dev/null +++ b/backend/app/api/config_backup.py @@ -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() diff --git a/backend/app/main.py b/backend/app/main.py index 794a22d2..fc75d0e2 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/services/auth.py b/backend/app/services/auth.py index 3dcf3eeb..aff30377 100644 --- a/backend/app/services/auth.py +++ b/backend/app/services/auth.py @@ -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: """是否已设置访问密码 (带缓存)。 diff --git a/backend/app/services/config_backup.py b/backend/app/services/config_backup.py new file mode 100644 index 00000000..d44e39dc --- /dev/null +++ b/backend/app/services/config_backup.py @@ -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) diff --git a/backend/app/services/preferences.py b/backend/app/services/preferences.py index c4357b93..9661660f 100644 --- a/backend/app/services/preferences.py +++ b/backend/app/services/preferences.py @@ -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 diff --git a/backend/tests/test_config_backup.py b/backend/tests/test_config_backup.py new file mode 100644 index 00000000..1b40bb7e --- /dev/null +++ b/backend/tests/test_config_backup.py @@ -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 diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index bff7de1e..dab52907 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -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(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' diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index dbabcf69..06efd408 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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 +} + 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('/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('/api/config-backup/apply', { + method: 'POST', + body: fd, + timeoutMs: 120_000, + }) + }, + analysisMenu: (id: string) => request(`/api/analysis-menus/${encodeURIComponent(id)}`), diff --git a/frontend/src/lib/uiPrefs.ts b/frontend/src/lib/uiPrefs.ts index 080ab9d8..09b0f86e 100644 --- a/frontend/src/lib/uiPrefs.ts +++ b/frontend/src/lib/uiPrefs.ts @@ -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])) diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 9e728b9c..8299b4bb 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -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 }) } diff --git a/frontend/src/pages/backtest/StrategyBacktest.tsx b/frontend/src/pages/backtest/StrategyBacktest.tsx index 36154a90..9e8f0387 100644 --- a/frontend/src/pages/backtest/StrategyBacktest.tsx +++ b/frontend/src/pages/backtest/StrategyBacktest.tsx @@ -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) => { diff --git a/frontend/src/pages/settings/Backup.tsx b/frontend/src/pages/settings/Backup.tsx new file mode 100644 index 00000000..ffea9464 --- /dev/null +++ b/frontend/src/pages/settings/Backup.tsx @@ -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, key: string): Set { + 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 ( +
+
+
+ {icon} +
+
+
{title}
+
{subtitle}
+
+
+ {children &&
{children}
} +
+ ) +} + +/** 类别勾选行 — 选中态着色 (敏感类别用警示色)。 */ +function CategoryCheck({ label, desc, checked, onChange, sensitive }: { + label: string + desc: string + checked: boolean + onChange: () => void + sensitive?: boolean +}) { + return ( + + ) +} + +export function SettingsBackupPanel() { + const qc = useQueryClient() + + // ── 导出 ── + const [exportSel, setExportSel] = useState>( + () => new Set(DEFAULT_EXPORT_KEYS), + ) + const exportSecrets = exportSel.has('secrets') || exportSel.has('auth') + + // ── 导入 ── + const fileRef = useRef(null) + const [file, setFile] = useState(null) + const [inspect, setInspect] = useState(null) + const [importSel, setImportSel] = useState>(new Set()) + const [applyResult, setApplyResult] = useState(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) => { + 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 ( +
+ {/* ── 导出 ── */} +
+ } + title="导出配置备份" + subtitle="打包为单个 ZIP 文件, 拷贝到新设备后即可导入恢复" + > + 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' + }`} + > + + 导出 .zip + + + + {/* 备份内容选择 */} +
+
+
+ 备份内容 + 0 ? 'bg-accent/10 text-accent' : 'bg-danger/10 text-danger' + }`}> + 已选 {exportSel.size} / {CATEGORY_OPTIONS.length} + +
+
+ + · + +
+
+
+ {CATEGORY_OPTIONS.filter((c) => !c.sensitive).map((c) => ( + setExportSel((s) => toggleSet(s, c.key))} + /> + ))} + {/* 密钥独立成行, 与普通类别视觉分隔 */} +
+ {CATEGORY_OPTIONS.filter((c) => c.sensitive).map((c) => ( + setExportSel((s) => toggleSet(s, c.key))} + /> + ))} +
+
+
+ + {exportSecrets && ( +
+ + + 已勾选敏感内容: 备份文件将包含明文密钥 (AI Key、数据源 Key、Webhook Secret) + 和/或访问凭证 (密码哈希与会话), 新设备导入后无需重新填写。 + 请妥善保管, 切勿分享或上传到公开位置。 + +
+ )} +
{SCOPE_EXCLUDED}
+
+ + {/* ── 导入 ── */} +
+ } + title="导入配置备份" + subtitle="选择导出的备份文件, 预览确认后恢复到本机" + /> + + + + {/* 选择文件: 未选时为放置区, 已选后显示文件条 */} + {!inspect ? ( + + ) : ( +
+ + {file?.name} + +
+ )} + {!inspect && ( +
+ 同名文件默认跳过 (可选择覆盖); 偏好与密钥按合并写入, 不会清掉本机已有内容 +
+ )} + + {/* 预览 + 类别勾选 */} + {inspect && ( +
+
+ + 备份时间 {fmtTime(inspect.created_at)} + + {inspect.app_version && ( + + 来源 v{inspect.app_version} + + )} + + {inspect.has_secrets ? '包含密钥' : '不含密钥'} + + + 共 {inspect.total_files} 个文件 + +
+ + + + + + + + + + + {inspect.categories.map((c) => ( + + + + + + + ))} + +
+ 导入类别条目冲突
+ setImportSel((s) => toggleSet(s, c.key))} + className="rounded border-border" + aria-label={`导入 ${c.label}`} + /> + + {c.key === 'secrets' && } + {c.label} + {c.count} + {c.conflicts > 0 + ? {c.conflicts} + : —} +
+ + {inspect.conflict_files > 0 && ( +
+
+
+ + 覆盖已存在的同名项 +
+
+ {inspect.conflict_files} 个同名文件默认跳过; 开启后用备份内容覆盖本机同名文件 +
+
+ +
+ )} + +
+
+ {importSel.size === 0 ? '请至少勾选一个类别' : '导入前请确认备份文件来源可信'} +
+ +
+
+ )} + + {/* 导入结果 */} + {applyResult && ( +
+
+ c.failed.length > 0) ? 'text-warning' : 'text-accent'}`} /> + 导入结果 +
+ + + + + + + + + + + {applyResult.categories.map((c) => ( + + + + + + + ))} + +
类别成功跳过冲突失败
{c.label}{c.imported}{c.skipped_conflict || '—'} + {c.failed.length > 0 + ? {c.failed.length} + : —} +
+ {applyResult.categories.some((c) => c.failed.length > 0) && ( +
+ {applyResult.categories.flatMap((c) => + c.failed.map((f) => ( +
+ {f.file}: {f.error} +
+ )), + )} +
+ )} + {(applyResult.skipped_unselected > 0 || applyResult.out_of_scope_files > 0) && ( +
+ {applyResult.skipped_unselected > 0 && ( +
{applyResult.skipped_unselected} 个条目属于未勾选的类别, 已跳过
+ )} + {applyResult.out_of_scope_files > 0 && ( +
{applyResult.out_of_scope_files} 个条目不在当前版本支持的备份范围内, 已忽略
+ )} +
+ )} +
+ 偏好与密钥按合并写入 (备份内容覆盖同名项, 本机新增项保留) +
+
+ )} +
+ + {/* 二次确认 */} + {confirming && inspect && ( + +
+
+ +
确认导入配置备份?
+
+
+
文件: {file?.name}
+
+ 将导入 {importSel.size} 个类别 + {allowOverwrite ? ', 并覆盖已存在的同名项' : ''}。 +
+ {(importSel.has('secrets') || importSel.has('auth')) && ( +
已勾选敏感内容, 导入后将覆盖本机同名密钥/访问凭证。
+ )} +
策略源码会在导入时执行安全校验, 无法通过校验的条目将被跳过。
+
+
+ + +
+
+
+ )} +
+ ) +} diff --git a/frontend/src/pages/settings/MenuSettings.tsx b/frontend/src/pages/settings/MenuSettings.tsx index aedf98a1..11e281cf 100644 --- a/frontend/src/pages/settings/MenuSettings.tsx +++ b/frontend/src/pages/settings/MenuSettings.tsx @@ -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 ( diff --git a/操作说明书.md b/操作说明书.md index cc328b12..88fb3ddd 100644 --- a/操作说明书.md +++ b/操作说明书.md @@ -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 插件化原则