mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 02:16:13 +00:00
feat(guard): 二次开发升级保护 — 程序文件常备备份/升级对比报告/一键恢复
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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。
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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 =====
|
||||
|
||||
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user