feat(guard): 二次开发升级保护 — 程序文件常备备份/升级对比报告/一键恢复

This commit is contained in:
shy3130
2026-10-16 16:45:03 +08:00
parent be7db8d8b8
commit d6f6962909
10 changed files with 978 additions and 2 deletions
+10
View File
@@ -144,6 +144,16 @@ jobs:
working-directory: backend
run: uv run pyinstaller ../packaging/tsp.spec --noconfirm
- name: 生成升级保护清单
# 二次开发升级对比基线: 对打包产出的 _internal 全量文件做 sha256。
# 相对路径与运行时 _MEIPASS 布局一致, 清单落 _internal/app/ 后随
# Inno/DMG/tar 一起分发, 运行时由 upgrade_guard 读取。
# uv run --no-project: 跨平台用 uv 管的 Python; 版本号唯一权威 =
# frontend/package.json (与 app/__init__._load_version 同源)。
run: |
APP_VER=$(node -p "require('./frontend/package.json').version")
uv run --no-project python scripts/generate_manifest.py --root backend/dist/TSP/_internal --output backend/dist/TSP/_internal/app/upgrade_manifest.json --version "$APP_VER"
- name: 检查 Polars 兼容内核 (仅 Windows)
if: matrix.platform == 'windows'
shell: pwsh
+8
View File
@@ -101,6 +101,14 @@ ENV STATIC_DIR=/app/static \
# Frontend 静态产物
COPY --from=frontend-builder /build/dist ./static
# 升级保护清单: 全量程序文件 sha256 基线 (二次开发升级对比用)。
# 必须在 app/ 与 static/ 都落位之后生成; .venv/data 等由脚本排除规则剔除。
# 版本号唯一权威 = frontend/package.json (与 app/__init__._load_version 同源)。
COPY scripts/generate_manifest.py /tmp/generate_manifest.py
COPY frontend/package.json /tmp/package.json
RUN APP_VER=$(python3 -c "import json;print(json.load(open('/tmp/package.json'))['version'])") && \
python3 /tmp/generate_manifest.py --root /app --output /app/app/upgrade_manifest.json --version "$APP_VER"
# Codex CLI 使用官方 npm 包携带的当前平台原生二进制,无需运行时 Node.js。
COPY --from=codex-builder /opt/codex-native /usr/local/bin/codex
RUN codex --version
+70
View File
@@ -0,0 +1,70 @@
"""二次开发升级保护 API — 修改文件常备备份状态 / 扫描 / 升级报告 / 恢复。
见 app/services/upgrade_guard.py: 源码运行 (无清单) 时 enabled=false,
本组端点仍可调用, 返回停用原因。
"""
from __future__ import annotations
from pathlib import Path
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from app.services.upgrade_guard import UpgradeGuard
router = APIRouter(prefix="/api/upgrade-guard", tags=["upgrade-guard"])
def _guard(request: Request) -> UpgradeGuard:
guard = getattr(request.app.state, "upgrade_guard", None)
if guard is None: # lifespan 未跑到 (测试直挂路由等) — 惰性兜底
from app.config import settings
guard = UpgradeGuard(Path(settings.data_dir))
request.app.state.upgrade_guard = guard
return guard
@router.get("/status")
def upgrade_guard_status(request: Request) -> dict:
"""守卫状态: 是否启用 / 当前修改与新增文件 / 待处理升级报告。"""
guard = _guard(request)
status = guard.status() if not guard.is_scanning() else {"enabled": True, "scanning": True}
status["scanning"] = guard.is_scanning()
return status
@router.post("/scan")
def upgrade_guard_scan(request: Request) -> dict:
"""立即重扫 (增量缓存, 秒级); 修改程序文件后可手动刷新常备备份。"""
guard = _guard(request)
if guard.is_scanning():
raise HTTPException(status_code=409, detail="扫描正在进行中")
status = guard.scan()
status["scanning"] = False
return status
class RestoreIn(BaseModel):
report_id: str = Field(min_length=1, max_length=200)
relpath: str = Field(min_length=1, max_length=500)
@router.post("/restore")
def upgrade_guard_restore(req: RestoreIn, request: Request) -> dict:
"""把升级报告归档的用户版本复制回程序目录 (仅限报告清单内文件)。"""
guard = _guard(request)
try:
restored = guard.restore(req.report_id, req.relpath)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e)) from e
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
return {"ok": True, "restored": str(restored)}
@router.post("/dismiss-report")
def upgrade_guard_dismiss(request: Request) -> dict:
"""确认升级报告 (报告文件仍保留在 data/upgrade_guard/reports/ 供回查)。"""
_guard(request).dismiss_report()
return {"ok": True}
+9
View File
@@ -45,6 +45,7 @@ from app.api import (
)
from app.api import auth as auth_api
from app.api import settings as settings_api
from app.api import upgrade_guard as upgrade_guard_api
from app.api.routes import router as core_router
from app.config import settings
from app.enriched_generation import EnrichedGenerationUnavailableError
@@ -176,6 +177,13 @@ async def _application_lifespan(app: FastAPI):
except Exception as e: # noqa: BLE001 — 仅日志, 失败不影响启动
logger.warning("能力路由日志输出失败: %s", e)
# 二次开发升级保护: 后台常备备份用户修改的程序文件 (无清单时自动停用)
from app.services.upgrade_guard import UpgradeGuard
upgrade_guard = UpgradeGuard(store.data_dir)
app.state.upgrade_guard = upgrade_guard
upgrade_guard.start_background_scan()
# 全局行情服务
qs = QuoteService()
app.state.quote_service = qs
@@ -592,6 +600,7 @@ app.include_router(financials.router)
app.include_router(stock_analysis.router)
app.include_router(market_recap.router)
app.include_router(settings_api.router)
app.include_router(upgrade_guard_api.router)
app.include_router(strategy.router)
app.include_router(signals.router)
app.include_router(monitor_rules.router)
+376
View File
@@ -0,0 +1,376 @@
"""二次开发升级保护 — 用户修改的程序文件常备备份与升级对比报告。
机制 (docs/secondary-development.md §8):
- 构建时生成全量程序文件清单 (app/upgrade_manifest.json, sha256 基线);
- 每次启动后台扫描程序目录, 与清单对比 → 用户「修改/新增」的程序文件
常备备份到 data/upgrade_guard/live/ (data 不随升级覆盖, 天然安全);
- 检测到版本变化 (上次运行版本 ≠ 当前版本) 时, 用 data 里幸存的旧清单
快照 + 旧备份生成升级对比报告:
restorable 用户改过、新版没动 → 升级覆盖丢失, 可一键恢复;
conflict 用户与新版都改过 → 保留三方材料供手工合并 (不自动改代码);
match 新版内容与用户修改后的内容一致, 无需处理;
added_lost 用户新建的文件新版没有 (如换镜像), 可恢复;
- 不自动合并代码: 自动改写程序文件的合并风险不可控, 与项目
「扩展点优先、直改源码属 L3」的分层原则一致。
源码运行 (无清单) 时功能整体停用, 二开者走 git + scripts/upgrade_check.py。
"""
from __future__ import annotations
import hashlib
import json
import logging
import shutil
import sys
import threading
import time
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from app import __version__
logger = logging.getLogger(__name__)
MANIFEST_NAME = "upgrade_manifest.json"
# 扫描与清单共用的排除规则 (scripts/generate_manifest.py 保持同集):
# data/ 是用户数据 (含本守卫自身备份), .venv/node_modules 是依赖,
# __pycache__/*.pyc 每次运行都变, .env 含密钥不入备份, 日志无合并价值。
EXCLUDED_DIRS = {"data", ".venv", "venv", "node_modules", "__pycache__", ".git", "logs"}
EXCLUDED_NAMES = {".env", ".DS_Store", MANIFEST_NAME}
EXCLUDED_SUFFIXES = {".pyc", ".log", ".tmp"}
_REPORT_CATEGORIES = ("restorable", "conflict", "match", "added_lost")
def _utcnow_iso() -> str:
return datetime.now(UTC).isoformat(timespec="seconds")
def _sha256(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
def _is_excluded(rel: str, name: str) -> bool:
if rel in EXCLUDED_DIRS or rel.split("/", 1)[0] in EXCLUDED_DIRS:
return True
if name in EXCLUDED_NAMES:
return True
return Path(name).suffix in EXCLUDED_SUFFIXES
def manifest_path() -> Path:
"""随程序落盘的清单位置 (是否存在由调用方判断)。"""
if getattr(sys, "frozen", False):
base = Path(getattr(sys, "_MEIPASS", Path(sys.executable).parent))
return base / "app" / MANIFEST_NAME
# 源码布局: app/services/upgrade_guard.py → app/upgrade_manifest.json
return Path(__file__).resolve().parent.parent / MANIFEST_NAME
def program_root() -> Path | None:
"""程序文件根目录 (清单存在的父布局); 无清单 (源码运行) 返回 None。
frozen (PyInstaller onedir): 清单落在 _internal/app/, 根 = _MEIPASS;
Docker: /app/app/upgrade_manifest.json, 根 = /app;
源码: backend/app/upgrade_manifest.json (仅 CI 生成过才有), 根 = backend/。
"""
manifest = manifest_path()
return manifest.parent.parent if manifest.is_file() else None
class UpgradeGuard:
"""扫描/备份/报告/恢复的单一入口; 全部状态落在 data/upgrade_guard/。"""
def __init__(self, data_dir: Path, *, root: Path | None = None) -> None:
self.data_dir = Path(data_dir)
self.state_dir = self.data_dir / "upgrade_guard"
self.live_dir = self.state_dir / "live"
self.reports_dir = self.state_dir / "reports"
self._lock = threading.Lock()
self._last_status: dict[str, Any] | None = None
self._scanning = False
self._thread: threading.Thread | None = None
# root 注入口仅供测试; 运行时由 program_root() 按清单位置推导。
self.root = Path(root).resolve() if root is not None else program_root()
self.manifest_version: str | None = None
self.manifest: dict[str, str] = {}
if self.root is not None:
try:
payload = json.loads((self.root / "app" / MANIFEST_NAME).read_text(encoding="utf-8"))
self.manifest = {str(k): str(v) for k, v in payload.get("files", {}).items()}
self.manifest_version = str(payload.get("version") or "") or None
except Exception as e: # noqa: BLE001 — 清单损坏按未启用处理
logger.warning("upgrade guard manifest load failed, disabled: %s", e)
self.root = None
self.manifest = {}
# ── 状态文件 ────────────────────────────────────────────────
@property
def _state_path(self) -> Path:
return self.state_dir / "state.json"
def _load_state(self) -> dict[str, Any]:
try:
return json.loads(self._state_path.read_text(encoding="utf-8"))
except Exception: # noqa: BLE001 — 首次/损坏都按空状态起步
return {}
def _save_state(self, state: dict[str, Any]) -> None:
self.state_dir.mkdir(parents=True, exist_ok=True)
tmp = self._state_path.with_suffix(".tmp")
tmp.write_text(json.dumps(state, ensure_ascii=False, indent=1), encoding="utf-8")
tmp.replace(self._state_path)
# ── 扫描 ────────────────────────────────────────────────────
def _classify(self, cache: dict[str, Any]) -> tuple[list[str], list[str]]:
"""磁盘 vs 当前清单 → (用户修改的, 用户新增的)。命中缓存免重哈希。"""
modified: list[str] = []
added: list[str] = []
for path in self.root.rglob("*"): # type: ignore[union-attr]
if not path.is_file():
continue
rel = path.relative_to(self.root).as_posix() # type: ignore[arg-type]
if _is_excluded(rel, path.name):
continue
try:
stat = path.stat()
except OSError:
continue
entry = cache.get(rel)
if entry and entry.get("s") == stat.st_size and entry.get("m") == stat.st_mtime_ns:
digest = str(entry.get("h", ""))
else:
try:
digest = _sha256(path)
except OSError:
continue
cache[rel] = {"s": stat.st_size, "m": stat.st_mtime_ns, "h": digest}
expected = self.manifest.get(rel)
if expected is None:
added.append(rel)
elif digest != expected:
modified.append(rel)
return sorted(modified), sorted(added)
def _sync_live(self, files: list[str]) -> None:
"""live/ 常备备份对齐当前修改集: 复制现存, 清掉已还原/已删除的陈旧项。"""
wanted: set[str] = set()
for rel in files:
src = self.root / rel # type: ignore[operator]
dst = self.live_dir / rel
try:
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
wanted.add(rel)
except OSError as e:
logger.warning("upgrade guard backup copy failed %s: %s", rel, e)
if self.live_dir.exists():
for old in self.live_dir.rglob("*"):
if old.is_file():
rel = old.relative_to(self.live_dir).as_posix()
if rel not in wanted:
old.unlink(missing_ok=True)
def _build_report(self, from_version: str, prev_manifest: dict[str, str]) -> dict[str, Any] | None:
"""版本变化时, 用旧 live 备份 + 旧清单快照 + 新磁盘生成对比报告。"""
rows: list[dict[str, Any]] = []
if not self.live_dir.exists():
return None
for backup in sorted(self.live_dir.rglob("*")):
if not backup.is_file():
continue
rel = backup.relative_to(self.live_dir).as_posix()
if _is_excluded(rel, backup.name):
continue
try:
user_sha = _sha256(backup)
except OSError:
continue
disk = self.root / rel # type: ignore[operator]
disk_sha = _sha256(disk) if disk.is_file() else None
prev_sha = prev_manifest.get(rel)
if disk_sha == user_sha:
category = "match"
elif disk_sha is None:
category = "added_lost"
elif prev_sha is not None and disk_sha == prev_sha:
# 新版内容 == 旧版基线 → 新版没动此文件, 用户的修改被升级覆盖
category = "restorable"
else:
category = "conflict"
rows.append({
"relpath": rel,
"category": category,
"user_modified": True,
"in_old_manifest": prev_sha is not None,
"current_exists": disk_sha is not None,
})
if not rows:
return None
report_id = f"{from_version}_to_{__version__}_{time.strftime('%Y%m%d_%H%M%S')}"
report_dir = self.reports_dir / report_id
# 三方材料: files/<rel> = 用户版本 (旧备份原样归档)
for row in rows:
src = self.live_dir / row["relpath"]
dst = report_dir / "files" / row["relpath"]
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
counts = {c: 0 for c in _REPORT_CATEGORIES}
for row in rows:
counts[row["category"]] += 1
report = {
"id": report_id,
"from_version": from_version,
"to_version": __version__,
"created_at": _utcnow_iso(),
"counts": counts,
"files": rows,
"dir": str(report_dir),
}
report_dir.mkdir(parents=True, exist_ok=True)
(report_dir / "report.json").write_text(
json.dumps(report, ensure_ascii=False, indent=1), encoding="utf-8",
)
return report
def scan(self) -> dict[str, Any]:
"""完整扫描: 分类 → (版本变化时) 报告 → 重对齐常备备份 → 状态落盘。"""
with self._lock:
if self.root is None:
self._last_status = {"enabled": False, "reason": "manifest_missing"}
return self._last_status
self._scanning = True
try:
state = self._load_state()
cache = state.get("file_cache") if isinstance(state.get("file_cache"), dict) else {}
cache = {str(k): v for k, v in cache.items()}
modified, added = self._classify(cache)
report: dict[str, Any] | None = None
prev_version = state.get("last_version")
if prev_version and prev_version != __version__:
prev_manifest = state.get("manifest_snapshot")
prev_manifest = {str(k): str(v) for k, v in prev_manifest.items()} if isinstance(prev_manifest, dict) else {}
try:
report = self._build_report(str(prev_version), prev_manifest)
except Exception as e: # noqa: BLE001 — 报告失败不阻断扫描
logger.warning("upgrade guard report build failed: %s", e)
self._sync_live(modified + added)
if report is not None:
state["pending_report"] = report["id"]
state["last_version"] = __version__
state["manifest_snapshot"] = self.manifest
state["file_cache"] = cache
state["last_scan"] = _utcnow_iso()
self._save_state(state)
status = {
"enabled": True,
"program_root": str(self.root),
"manifest_version": self.manifest_version,
"current_version": __version__,
"last_scan": state["last_scan"],
"modified": modified,
"added": added,
"backup_dir": str(self.live_dir),
"reports_dir": str(self.reports_dir),
"pending_report": self._load_report(report["id"]) if report else self._pending_report(state),
}
with self._lock:
self._last_status = status
return status
finally:
with self._lock:
self._scanning = False
# ── 报告读取 / 恢复 / 确认 ──────────────────────────────────
def _pending_report(self, state: dict[str, Any]) -> dict[str, Any] | None:
report_id = state.get("pending_report")
return self._load_report(str(report_id)) if report_id else None
def _load_report(self, report_id: str) -> dict[str, Any] | None:
path = self.reports_dir / report_id / "report.json"
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception: # noqa: BLE001
return None
def status(self) -> dict[str, Any]:
with self._lock:
if self._last_status is not None:
return dict(self._last_status)
return self.scan()
def is_scanning(self) -> bool:
with self._lock:
return self._scanning
def restore(self, report_id: str, relpath: str) -> Path:
"""把报告归档的用户版本复制回程序目录 (relpath 必须在报告清单内)。"""
report = self._load_report(report_id)
if report is None:
raise FileNotFoundError(f"报告不存在: {report_id}")
if not any(row["relpath"] == relpath for row in report.get("files", [])):
raise ValueError(f"文件不在报告清单内: {relpath}")
src = self.reports_dir / report_id / "files" / relpath
if not src.is_file():
raise FileNotFoundError(f"备份副本缺失: {relpath}")
dst = self.root / relpath # type: ignore[operator]
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
logger.info("upgrade guard restored %s from report %s", relpath, report_id)
return dst
def dismiss_report(self) -> None:
state = self._load_state()
if state.get("pending_report"):
state.pop("pending_report", None)
self._save_state(state)
with self._lock:
if self._last_status is not None:
self._last_status["pending_report"] = None
# ── 后台线程 ────────────────────────────────────────────────
def start_background_scan(self) -> None:
"""启动后台守护线程执行首扫 (增量缓存后秒级); 重复调用只跑一个。"""
with self._lock:
if self._thread is not None and self._thread.is_alive():
return
self._thread = threading.Thread(target=self._scan_worker, name="upgrade-guard", daemon=True)
self._thread.start()
def _scan_worker(self) -> None:
try:
status = self.scan()
if status.get("enabled"):
logger.info(
"upgrade guard: %d modified, %d added files backed up (manifest %s)",
len(status.get("modified", [])), len(status.get("added", [])),
status.get("manifest_version"),
)
report = status.get("pending_report")
if report:
logger.warning(
"upgrade guard: upgrade %s → %s detected: %s",
report.get("from_version"), report.get("to_version"),
report.get("counts"),
)
else:
logger.info("upgrade guard disabled (no shipped manifest)")
except Exception:
logger.exception("upgrade guard scan failed")
+176
View File
@@ -0,0 +1,176 @@
"""二次开发升级保护 — 扫描分类 / 常备备份 / 升级报告 / 恢复的契约测试。
模拟完整生命周期: v1 基线 → 用户改动 (v1 扫描备份) → 模拟升级到 v2
(改写磁盘与清单) → v2 扫描生成对比报告 → 按类恢复。
"""
from __future__ import annotations
import importlib.util
import json
import sys
from pathlib import Path
import pytest
from app.services import upgrade_guard
_REPO_ROOT = Path(__file__).resolve().parents[2]
_SPEC = importlib.util.spec_from_file_location(
"generate_manifest", _REPO_ROOT / "scripts" / "generate_manifest.py",
)
assert _SPEC is not None and _SPEC.loader is not None
_generate_manifest = importlib.util.module_from_spec(_SPEC)
sys.modules.setdefault("generate_manifest", _generate_manifest)
_SPEC.loader.exec_module(_generate_manifest)
def _write_manifest(prog: Path, version: str) -> None:
manifest = _generate_manifest.generate(prog, version=version)
out = prog / "app" / "upgrade_manifest.json"
out.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8")
@pytest.fixture
def prog(tmp_path: Path) -> Path:
"""v1 程序目录: 三个清单内文件 + 静态页。"""
p = tmp_path / "prog"
(p / "app" / "custom").mkdir(parents=True)
(p / "app" / "a.py").write_text("A_ORIG\n", encoding="utf-8")
(p / "app" / "b.py").write_text("B_ORIG\n", encoding="utf-8")
(p / "app" / "c.py").write_text("C_ORIG\n", encoding="utf-8")
(p / "static").mkdir()
(p / "static" / "index.html").write_text("<html></html>", encoding="utf-8")
_write_manifest(p, "v1.0.0")
return p
def test_disabled_without_manifest(tmp_path: Path) -> None:
prog = tmp_path / "prog"
(prog / "app").mkdir(parents=True)
guard = upgrade_guard.UpgradeGuard(tmp_path / "data", root=prog)
assert guard.root is None
status = guard.scan()
assert status["enabled"] is False
assert status["reason"] == "manifest_missing"
def test_scan_classifies_and_backs_up(prog: Path, tmp_path: Path, monkeypatch) -> None:
monkeypatch.setattr(upgrade_guard, "__version__", "v1.0.0")
data = tmp_path / "data"
(prog / "app" / "a.py").write_text("A_USER\n", encoding="utf-8") # 修改
(prog / "app" / "custom" / "ext.py").write_text("EXT\n", encoding="utf-8") # 新增
(prog / "app" / "__pycache__").mkdir()
(prog / "app" / "__pycache__" / "a.pyc").write_bytes(b"\x00") # 排除项
(prog / "data").mkdir(exist_ok=True)
(prog / "data" / "inside.txt").write_text("x", encoding="utf-8") # data 排除
guard = upgrade_guard.UpgradeGuard(data, root=prog)
status = guard.scan()
assert status["enabled"] is True
assert status["modified"] == ["app/a.py"]
# 静态文件在清单内且未改 → 不出现; __pycache__/data 不算新增
assert status["added"] == ["app/custom/ext.py"]
backup = data / "upgrade_guard" / "live" / "app" / "a.py"
assert backup.read_text(encoding="utf-8") == "A_USER\n"
def test_live_backup_follows_reverts(prog: Path, tmp_path: Path, monkeypatch) -> None:
monkeypatch.setattr(upgrade_guard, "__version__", "v1.0.0")
data = tmp_path / "data"
f = prog / "app" / "a.py"
f.write_text("A_USER\n", encoding="utf-8")
guard = upgrade_guard.UpgradeGuard(data, root=prog)
guard.scan()
assert (data / "upgrade_guard" / "live" / "app" / "a.py").exists()
f.write_text("A_ORIG\n", encoding="utf-8") # 用户还原 → 备份应清掉该条目
guard.scan()
assert not (data / "upgrade_guard" / "live" / "app" / "a.py").exists()
def test_upgrade_report_and_restore(prog: Path, tmp_path: Path, monkeypatch) -> None:
data = tmp_path / "data"
# ── v1 阶段: 用户修改 a/b/c, 新增 e ──
monkeypatch.setattr(upgrade_guard, "__version__", "v1.0.0")
(prog / "app" / "a.py").write_text("A_USER\n", encoding="utf-8")
(prog / "app" / "b.py").write_text("B_USER\n", encoding="utf-8")
(prog / "app" / "c.py").write_text("C_USER\n", encoding="utf-8")
(prog / "app" / "e.py").write_text("E_USER\n", encoding="utf-8")
guard1 = upgrade_guard.UpgradeGuard(data, root=prog)
s1 = guard1.scan()
assert s1["pending_report"] is None
assert s1["modified"] == ["app/a.py", "app/b.py", "app/c.py"]
# ── 模拟升级到 v2: 安装器覆盖程序目录 + 新清单 ──
# a: 新版也改 (conflict); b: 新版没动, 被覆盖回基线 (restorable);
# c: 新版内容 == 用户修改后内容 (match); e: 用户新增, 新版没有 (added_lost)
monkeypatch.setattr(upgrade_guard, "__version__", "v2.0.0")
(prog / "app" / "a.py").write_text("A_V2\n", encoding="utf-8")
(prog / "app" / "b.py").write_text("B_ORIG\n", encoding="utf-8")
(prog / "app" / "c.py").write_text("C_USER\n", encoding="utf-8")
(prog / "app" / "e.py").unlink()
_write_manifest(prog, "v2.0.0")
guard2 = upgrade_guard.UpgradeGuard(data, root=prog)
s2 = guard2.scan()
report = s2["pending_report"]
assert report is not None
assert report["from_version"] == "v1.0.0"
assert report["to_version"] == "v2.0.0"
assert report["counts"] == {"restorable": 1, "conflict": 1, "match": 1, "added_lost": 1}
cats = {row["relpath"]: row["category"] for row in report["files"]}
assert cats == {
"app/a.py": "conflict",
"app/b.py": "restorable",
"app/c.py": "match",
"app/e.py": "added_lost",
}
# ── 恢复: b (restorable) 与 e (added_lost) 可回填用户版本 ──
guard2.restore(report["id"], "app/b.py")
assert (prog / "app" / "b.py").read_text(encoding="utf-8") == "B_USER\n"
guard2.restore(report["id"], "app/e.py")
assert (prog / "app" / "e.py").read_text(encoding="utf-8") == "E_USER\n"
# 报告目录里保留了用户版本归档 (冲突项 a 的三方材料)
archived = data / "upgrade_guard" / "reports" / report["id"] / "files" / "app" / "a.py"
assert archived.read_text(encoding="utf-8") == "A_USER\n"
# 确认后 pending_report 清空, 报告文件仍在
guard2.dismiss_report()
assert guard2.status()["pending_report"] is None
assert archived.exists()
def test_restore_rejects_path_outside_report(prog: Path, tmp_path: Path, monkeypatch) -> None:
monkeypatch.setattr(upgrade_guard, "__version__", "v1.0.0")
data = tmp_path / "data"
(prog / "app" / "a.py").write_text("A_USER\n", encoding="utf-8")
guard = upgrade_guard.UpgradeGuard(data, root=prog)
guard.scan()
monkeypatch.setattr(upgrade_guard, "__version__", "v2.0.0")
(prog / "app" / "a.py").write_text("A_V2\n", encoding="utf-8")
_write_manifest(prog, "v2.0.0")
guard2 = upgrade_guard.UpgradeGuard(data, root=prog)
report = guard2.scan()["pending_report"]
assert report is not None
with pytest.raises(ValueError):
guard2.restore(report["id"], "../../etc/passwd")
with pytest.raises(FileNotFoundError):
guard2.restore("v0.0.0_to_v9.9.9_20990101_000000", "app/a.py")
def test_manifest_script_exclusions(prog: Path) -> None:
"""生成脚本与运行守卫的排除规则同集 — 运行期产物不入基线。"""
(prog / "app" / "__pycache__").mkdir()
(prog / "app" / "__pycache__" / "x.pyc").write_bytes(b"\x00")
(prog / ".env").write_text("SECRET=1", encoding="utf-8")
manifest = _generate_manifest.generate(prog, version="v0")
rels = set(manifest["files"])
assert "app/__pycache__/x.pyc" not in rels
assert ".env" not in rels
assert {"app/a.py", "static/index.html"} <= rels
+13
View File
@@ -381,6 +381,19 @@ python3 scripts/upgrade_check.py <目标Tag或分支>
脚本不会执行 merge、修改索引或工作区。它会报告共同基线、双方修改的同一文件以及 Git 三方预演可识别的文本冲突。未提交内容不会进入预演,因此正式评估前应先提交到临时二开分支。
### 8.1 安装包升级保护(非 Git 用户)
桌面安装包与 Docker 镜像的发布携带全量程序文件清单(`app/upgrade_manifest.json`,构建时生成)。后端每次启动会扫描程序目录并与清单对比,把用户「修改/新增」的程序文件常备备份到 `data/upgrade_guard/live/`——`data/` 不随升级覆盖,因此升级不会丢失这些文件。
检测到版本变化(上次运行版本 ≠ 当前版本)时生成升级对比报告,在「设置 → 系统设置 → 升级保护」展示:
- **可恢复**:用户改过、新版没动 → 升级时被覆盖,可一键恢复;
- **需手工合并**:用户与新版都改过 → 用户版本归档在 `data/upgrade_guard/reports/<报告>/files/`,磁盘上是新版文件,对照手动合并;
- **已一致**:新版内容与用户修改后的内容相同;
- **新增文件可恢复**:用户新建的文件新版没有(如更换 Docker 镜像)。
守卫不自动合并代码(自动改写程序文件风险不可控)。修改程序文件后重启或点「重新扫描」即可刷新常备备份;源码运行(无清单)时功能自动停用,Git 工作流仍以 `upgrade_check.py` 预检为准。
## 9. 完成检查表
- [ ] 已确认需求属于 L1、L2 或 L3。
+45
View File
@@ -2282,6 +2282,42 @@ export interface RuntimePagePack {
error: string
}
// ===== 升级保护 (二次开发) =====
/** 报告文件分类: restorable=新版没动可恢复 / conflict=双方都改需手工合并 /
* match=内容已一致 / added_lost=用户新建文件新版没有 */
export type UpgradeGuardCategory = 'restorable' | 'conflict' | 'match' | 'added_lost'
export interface UpgradeGuardReportFile {
relpath: string
category: UpgradeGuardCategory
current_exists: boolean
}
export interface UpgradeGuardReport {
id: string
from_version: string
to_version: string
created_at: string
counts: Record<UpgradeGuardCategory, number>
files: UpgradeGuardReportFile[]
dir: string
}
export interface UpgradeGuardStatus {
enabled: boolean
reason?: string
scanning?: boolean
program_root?: string
manifest_version?: string | null
current_version?: string
last_scan?: string
modified?: string[]
added?: string[]
backup_dir?: string
reports_dir?: string
pending_report?: UpgradeGuardReport | null
}
// ===== API surface =====
export const api = {
health: () => request<{ status: string; version: string; mode: string }>('/health'),
@@ -2340,6 +2376,15 @@ export const api = {
body: JSON.stringify(ai),
}),
upgradeGuardStatus: () => request<UpgradeGuardStatus>('/api/upgrade-guard/status'),
upgradeGuardScan: () => request<UpgradeGuardStatus>('/api/upgrade-guard/scan', { method: 'POST' }),
upgradeGuardRestore: (reportId: string, relpath: string) =>
request<{ ok: boolean; restored: string }>('/api/upgrade-guard/restore', {
method: 'POST',
body: JSON.stringify({ report_id: reportId, relpath }),
}),
upgradeGuardDismiss: () => request<{ ok: boolean }>('/api/upgrade-guard/dismiss-report', { method: 'POST' }),
/** 赞助商(RunningHub)模型列表(后端代理, 规避其网关按 Origin 过滤) */
sponsorModels: () =>
request<{ models: string[]; items?: SponsorModelItem[] }>('/api/settings/ai/sponsor-models'),
+186 -2
View File
@@ -4,10 +4,12 @@
* 独立于实时监控, 放置影响整体应用行为的开关项。
*/
import { useState, useCallback, useEffect } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { Settings2, Trash2, RefreshCw, Bell, Volume2, ExternalLink, ZoomIn, Minus, Plus } from 'lucide-react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Settings2, Trash2, RefreshCw, Bell, Volume2, ExternalLink, ZoomIn, Minus, Plus, ShieldCheck, RotateCcw, ChevronDown } from 'lucide-react'
import { usePreferences } from '@/lib/useSharedQueries'
import { api } from '@/lib/api'
import type { UpgradeGuardCategory } from '@/lib/api'
import { toast } from '@/components/Toast'
import { QK } from '@/lib/queryKeys'
import { PageHeader } from '@/components/PageHeader'
import { ExtensionSlot } from '@/extensions/ExtensionSlot'
@@ -402,10 +404,192 @@ export function SettingsSystemPanel() {
</div>
<ExtensionSlot name="settings.system.section" context={{}} compact />
</section>
<UpgradeGuardCard />
</>
)
}
// ===== 升级保护 (二次开发) =====
// 用户修改/新增的程序文件在每次启动时备份到 data/upgrade_guard/, 升级不丢;
// 检测到版本变化时给出对比报告: 可恢复项一键还原, 冲突项保留三方材料手工合并。
const UPGRADE_CATEGORY_LABEL: Record<UpgradeGuardCategory, string> = {
restorable: '可恢复',
conflict: '需手工合并',
match: '已一致',
added_lost: '新增文件可恢复',
}
function UpgradeGuardCard() {
const qc = useQueryClient()
const { data: st, isLoading } = useQuery({
queryKey: ['upgrade-guard', 'status'],
queryFn: api.upgradeGuardStatus,
staleTime: 30_000,
retry: 1,
})
const [busy, setBusy] = useState<'scan' | 'restore-all' | string | null>(null)
const [listOpen, setListOpen] = useState(false)
const refresh = () => qc.invalidateQueries({ queryKey: ['upgrade-guard', 'status'] })
const scan = async () => {
setBusy('scan')
try {
await api.upgradeGuardScan()
await refresh()
} catch (e: any) {
toast(`扫描失败: ${e?.message ?? e}`, 'error')
} finally {
setBusy(null)
}
}
const restore = async (reportId: string, relpath: string) => {
setBusy(relpath)
try {
await api.upgradeGuardRestore(reportId, relpath)
toast(`已恢复 ${relpath}`, 'success')
await refresh()
} catch (e: any) {
toast(`恢复失败: ${e?.message ?? e}`, 'error')
} finally {
setBusy(null)
}
}
const restoreAll = async (reportId: string, files: { relpath: string }[]) => {
setBusy('restore-all')
let ok = 0
for (const f of files) {
try {
await api.upgradeGuardRestore(reportId, f.relpath)
ok += 1
} catch { /* 单个失败继续其余 */ }
}
toast(`已恢复 ${ok}/${files.length} 个文件`, ok === files.length ? 'success' : 'error')
await refresh()
setBusy(null)
}
const dismiss = async () => {
setBusy('dismiss')
try {
await api.upgradeGuardDismiss()
await refresh()
} finally {
setBusy(null)
}
}
const report = st?.pending_report ?? null
const reportFiles = report?.files ?? []
const restorableFiles = reportFiles.filter(f => f.category === 'restorable' || f.category === 'added_lost')
return (
<section className="rounded-card border border-border bg-surface p-5 mt-6">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<ShieldCheck className="h-4 w-4 text-secondary" />
<h3 className="text-sm font-medium text-foreground">升级保护(二次开发)</h3>
</div>
<div className="mt-1.5 text-[11px] leading-relaxed text-muted">
{isLoading ? '加载中…' : st?.enabled
? <>修改/新增的程序文件每次启动自动备份到 <code className="text-secondary">{st?.backup_dir}</code>,升级不会丢失</>
: '当前安装未携带版本清单(源码运行)— 请使用 Git 管理二次开发改动,配合 scripts/upgrade_check.py 做升级预检'}
</div>
</div>
{st?.enabled && (
<div className="flex shrink-0 items-center gap-2">
<span className="text-[10px] text-muted">清单 {st?.manifest_version ?? '—'} · 当前 {st?.current_version}</span>
<button onClick={scan} disabled={busy === 'scan' || !!st?.scanning}
title="重新扫描程序目录, 刷新常备备份 (修改程序文件后可手动执行)"
className="inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1 text-xs text-secondary transition-colors hover:bg-elevated hover:text-foreground disabled:opacity-50">
<RefreshCw className={`h-3 w-3 ${busy === 'scan' || st?.scanning ? 'animate-spin' : ''}`} />
{busy === 'scan' || st?.scanning ? '扫描中' : '重新扫描'}
</button>
</div>
)}
</div>
{st?.enabled && (
<div className="mt-3 border-t border-border/60 pt-3">
<button onClick={() => setListOpen(v => !v)} className="flex w-full items-center gap-1.5 text-left text-xs text-secondary hover:text-foreground">
<ChevronDown className={`h-3 w-3 transition-transform ${listOpen ? '' : '-rotate-90'}`} />
当前检测到 <b className="text-foreground">{st?.modified?.length ?? 0}</b> 个修改文件、<b className="text-foreground">{st?.added?.length ?? 0}</b> 个新增文件
<span className="text-[10px] text-muted">(上次扫描 {st?.last_scan?.replace('T', ' ').slice(0, 19) ?? '—'})</span>
</button>
{listOpen && (
<div className="mt-2 max-h-48 overflow-y-auto rounded-lg border border-border/60 bg-base/40 p-2 font-mono text-[10px] leading-5">
{st?.modified?.map(f => <div key={f} className="truncate text-warning">✎ {f}</div>)}
{st?.added?.map(f => <div key={f} className="truncate text-bear">+ {f}</div>)}
{(st?.modified?.length ?? 0) + (st?.added?.length ?? 0) === 0 && (
<div className="text-muted">无二次开发改动</div>
)}
</div>
)}
</div>
)}
{report && (
<div className="mt-3 rounded-lg border border-warning/25 bg-warning/[0.05] p-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 text-xs">
<div className="font-medium text-foreground">
升级检测: {report.from_version} → {report.to_version}
</div>
<div className="mt-1 text-[11px] leading-relaxed text-muted">
你的 {reportFiles.length} 个二次开发文件已归档比对 —
可恢复 <b className="text-bear">{report.counts.restorable + report.counts.added_lost}</b> ·
需手工合并 <b className="text-warning">{report.counts.conflict}</b> ·
已一致 <b className="text-muted">{report.counts.match}</b>
</div>
</div>
<div className="flex shrink-0 gap-2">
{restorableFiles.length > 0 && (
<button onClick={() => restoreAll(report.id, restorableFiles)} disabled={!!busy}
className="inline-flex items-center gap-1.5 rounded-lg bg-accent px-2.5 py-1 text-[11px] text-white hover:bg-accent/90 disabled:opacity-50">
<RotateCcw className="h-3 w-3" />
恢复可恢复项 ({restorableFiles.length})
</button>
)}
<button onClick={dismiss} disabled={busy === 'dismiss'}
className="rounded-lg px-2.5 py-1 text-[11px] text-secondary hover:bg-elevated hover:text-foreground disabled:opacity-50">
知道了
</button>
</div>
</div>
<div className="mt-2 max-h-56 overflow-y-auto rounded-md border border-border/40 bg-base/40 p-2">
{reportFiles.map(f => (
<div key={f.relpath} className="flex items-center justify-between gap-2 py-0.5">
<span className="min-w-0 truncate font-mono text-[10px] text-secondary" title={f.relpath}>{f.relpath}</span>
<span className="flex shrink-0 items-center gap-1.5">
<span className={`text-[10px] ${f.category === 'conflict' ? 'text-warning' : f.category === 'match' ? 'text-muted' : 'text-bear'}`}>
{UPGRADE_CATEGORY_LABEL[f.category]}
</span>
{(f.category === 'restorable' || f.category === 'added_lost') && (
<button onClick={() => restore(report.id, f.relpath)} disabled={busy === f.relpath}
className="rounded px-1.5 py-0.5 text-[10px] text-accent hover:bg-accent/10 disabled:opacity-50">
{busy === f.relpath ? '恢复中' : '恢复'}
</button>
)}
</span>
</div>
))}
</div>
{report.counts.conflict > 0 && (
<div className="mt-2 text-[10px] leading-relaxed text-muted">
冲突项不自动合并: 你的版本已归档在 <code>{report.dir}</code>, 磁盘上是新版文件, 请对照手动合并后重新扫描。
</div>
)}
</div>
)}
</section>
)
}
// ===== ToggleRow =====
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""生成升级保护清单 — 全量程序文件 sha256 基线 (app/upgrade_manifest.json)。
供发布流水线调用 (release.yml 桌面打包 / Dockerfile 镜像构建), 运行时由
app/services/upgrade_guard.py 读取:
scripts/generate_manifest.py --root /app --output /app/app/upgrade_manifest.json \
--version-file VERSION
排除规则必须与 upgrade_guard.EXCLUDED_* 保持同集, 否则会把运行期产物
当成用户改动。清单自身也不入清单。
"""
from __future__ import annotations
import argparse
import hashlib
import json
import sys
from datetime import datetime, timezone
from pathlib import Path
EXCLUDED_DIRS = {"data", ".venv", "venv", "node_modules", "__pycache__", ".git", "logs"}
EXCLUDED_NAMES = {".env", ".DS_Store", "upgrade_manifest.json"}
EXCLUDED_SUFFIXES = {".pyc", ".log", ".tmp"}
def _is_excluded(rel: str, name: str) -> bool:
if rel in EXCLUDED_DIRS or rel.split("/", 1)[0] in EXCLUDED_DIRS:
return True
if name in EXCLUDED_NAMES:
return True
return Path(name).suffix in EXCLUDED_SUFFIXES
def _sha256(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
def generate(root: Path, *, version: str) -> dict[str, object]:
files: dict[str, str] = {}
for path in sorted(root.rglob("*")):
if not path.is_file():
continue
rel = path.relative_to(root).as_posix()
if _is_excluded(rel, path.name):
continue
files[rel] = _sha256(path)
return {
"version": version,
"generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"file_count": len(files),
"files": files,
}
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--root", required=True, type=Path, help="程序文件根目录 (清单的对比基准)")
parser.add_argument("--output", required=True, type=Path, help="清单输出路径 (应位于 <root>/app/ 下)")
parser.add_argument("--version", default="", help="版本号 (与 --version-file 二选一)")
parser.add_argument("--version-file", type=Path, help="从文件读版本 (如仓库根 VERSION)")
args = parser.parse_args(argv)
version = args.version.strip()
if not version and args.version_file and args.version_file.is_file():
version = args.version_file.read_text(encoding="utf-8").strip()
if not version:
parser.error("需要 --version 或可读的 --version-file")
root = args.root.resolve()
if not root.is_dir():
parser.error(f"--root 不是目录: {root}")
manifest = generate(root, version=version)
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(manifest, ensure_ascii=False, indent=1), encoding="utf-8")
print(f"manifest written: {args.output} ({manifest['file_count']} files, version {version})")
return 0
if __name__ == "__main__":
sys.exit(main())