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:
shy3130
2026-10-26 12:13:19 +08:00
parent b1a4dc6e38
commit 2892c644f1
14 changed files with 1928 additions and 6 deletions
+99
View File
@@ -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()
+2
View File
@@ -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)
+11
View File
@@ -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:
"""是否已设置访问密码 (带缓存)。
+683
View File
@@ -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)
+5
View File
@@ -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
+484
View File
@@ -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
+2 -1
View File
@@ -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'
+62
View File
@@ -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)}`),
+5
View File
@@ -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]))
+5 -2
View File
@@ -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>) => {
+539
View File
@@ -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>
)
}
+2 -1
View File
@@ -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 (
+27 -1
View File
@@ -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 插件化原则