Files
tianxuan/scripts/apply_update.py
PM-pinou 3617b7aabc release: v1.1.3 — 13 fixes across build, frontend, pipeline, and data handling
Build: fix incremental package (update_info.json SHA256, __delete__, test exclude, files/ prefix), fix bump_version for 3-segment, PYTHONUTF8 in update.bat
Frontend: upload→manual redirect, LLM→cluster_overview redirect, filter UI before clustering, 地球→态势 rename, fix globe drag direction+inertia
Pipeline: LLM→auto clustering+PCA, column union handling (keep extra cols, no dropping), FFT spectral analysis tool
Quality: fix 24 bare except:pass patterns, enrich LLM context with TlsDB metadata, auto-save workflows with pinning, TlsDB-based column types
2026-07-21 12:01:52 +08:00

400 lines
14 KiB
Python

#!/usr/bin/env python
"""
天璇增量更新应用脚本 — 在离线机上运行
用于将增量更新包应用到离线机上的项目目录。
Usage:
runtime\\python\\python.exe scripts\\apply_update.py <update.zip>
runtime\\python\\python.exe scripts\\apply_update.py --rollback
安全保证:
1. 更新前完整备份当前配置
2. 永不覆盖 %APPDATA%/TianXuan/ 下的用户数据
3. config.yaml 用户编辑通过合并保留
4. 完整 SHA256 校验防止传输损坏
5. 可回滚到最近一次备份
"""
import hashlib
import json
import os
import shutil
import subprocess
import sys
import zipfile
from datetime import datetime
from pathlib import Path
# ── Constants ──────────────────────────────────────────────────────────
PROJECT_DIR = Path(__file__).resolve().parent.parent
PYTHON = str(PROJECT_DIR / 'runtime' / 'python' / 'python.exe')
if not os.path.exists(PYTHON):
# Fallback: use system python if portable not found
PYTHON = sys.executable
APPDATA_DIR = Path(os.environ.get('APPDATA', str(Path.home() / '.tianxuan'))) / 'TianXuan'
BACKUP_DIR = APPDATA_DIR / 'backups'
VERSION_FILE = PROJECT_DIR / 'VERSION'
CONFIG_FILE = PROJECT_DIR / 'config' / 'config.yaml'
SCRIPTS_DIR = PROJECT_DIR / 'scripts'
MERGE_SCRIPT = SCRIPTS_DIR / 'merge_config.py'
# ── Helper Functions ──────────────────────────────────────────────────
def log(msg: str):
print(f" {msg}")
def step(n: int, total: int, msg: str):
print(f"\n[{n}/{total}] {msg}")
def verify_sha256(filepath: Path, expected: str) -> bool:
"""Verify file SHA256 matches expected hash."""
h = hashlib.sha256()
with open(filepath, 'rb') as f:
for chunk in iter(lambda: f.read(65536), b''):
h.update(chunk)
return h.hexdigest() == expected
def clean_pycache(root_dir: Path):
"""Recursively remove all __pycache__ directories."""
count = 0
for dirpath, dirnames, _ in os.walk(str(root_dir)):
if '__pycache__' in dirnames:
cache_dir = os.path.join(dirpath, '__pycache__')
try:
shutil.rmtree(cache_dir)
count += 1
except PermissionError:
log(f" [WARN] 无法删除 {cache_dir} (权限拒绝)")
if count > 0:
log(f" 清理了 {count} 个 __pycache__ 目录")
def get_current_version() -> str:
"""Read current version from VERSION file."""
if VERSION_FILE.exists():
return VERSION_FILE.read_text(encoding='utf-8').strip()
return 'v0.0.0'
# ── Main Logic ────────────────────────────────────────────────────────
def do_rollback():
"""Restore from the most recent backup."""
if not BACKUP_DIR.exists():
print("[ERROR] 没有找到备份目录")
return 1
backups = sorted(BACKUP_DIR.iterdir(), key=lambda p: p.stat().st_mtime, reverse=True)
if not backups:
print("[ERROR] 备份目录为空")
return 1
latest = backups[0]
config_backup = latest / 'config.yaml'
version_backup = latest / 'VERSION'
print(f"{latest.name} 回滚...")
if config_backup.exists():
shutil.copy2(str(config_backup), str(CONFIG_FILE))
log("配置已恢复")
if version_backup.exists():
shutil.copy2(str(version_backup), str(VERSION_FILE))
log("VERSION 已恢复")
# Restore project snapshot (if exists)
snapshot_dir = latest / 'project_snapshot'
if snapshot_dir.exists():
log("恢复项目文件快照...")
for item in snapshot_dir.iterdir():
dest = PROJECT_DIR / item.name
if item.is_dir():
if dest.exists():
shutil.rmtree(str(dest))
shutil.copytree(str(item), str(dest))
else:
shutil.copy2(str(item), str(dest))
log("项目文件已恢复")
print("\n✅ 回滚完成!请重启天璇服务")
return 0
def apply_update(zip_path: Path):
"""Apply an update package."""
total_steps = 10
print("=" * 50)
print(" 天璇 增量更新 — 应用工具")
print("=" * 50)
log(f"更新包: {zip_path.name}")
log(f"当前版本: {get_current_version()}")
log(f"备份目录: {BACKUP_DIR}")
# Step 1: Validate package
step(1, total_steps, "验证更新包完整性")
if not zip_path.exists():
print(f"[ERROR] 更新包不存在: {zip_path}")
return 1
try:
with zipfile.ZipFile(zip_path, 'r') as zf:
if zf.testzip() is not None:
print(f"[CORRUPT] 更新包损坏!第一个损坏文件: {zf.testzip()}")
return 1
# Read manifest
if 'files/update_info.json' not in zf.namelist():
print("[ERROR] 更新包缺少 files/update_info.json")
return 1
manifest = json.loads(zf.read('files/update_info.json'))
except Exception as e:
print(f"[ERROR] 无法读取更新包: {e}")
return 1
log(f" {zip_path.stat().st_size / 1024:.1f} KB — 校验通过")
# Step 2: Check version compatibility
step(2, total_steps, "检查版本兼容性")
current_ver = get_current_version()
from_ver = manifest.get('from_version', '')
if current_ver != from_ver:
print(f"[VERSION_MISMATCH] 版本不匹配!")
print(f" 当前: {current_ver}")
print(f" 更新包起始: {from_ver}")
print(f" 更新包目标: {manifest.get('to_version', '?')}")
print(f"\n提示: 如需强制应用,请更新 VERSION 文件后重试")
return 1
log(f" 版本一致: {current_ver}{manifest.get('to_version', '?')}")
# Step 3: Verify file checksums
step(3, total_steps, "校验文件 SHA256")
with zipfile.ZipFile(zip_path, 'r') as zf:
for rel_path, file_info in manifest.get('files', {}).items():
arcname = f'files/{rel_path}'
if arcname not in zf.namelist():
print(f"[ERROR] 包中缺少文件: {arcname}")
return 1
# Extract to temp and verify
zf.extract(arcname, str(PROJECT_DIR))
extracted = PROJECT_DIR / arcname
if extracted.exists() and not verify_sha256(extracted, file_info['sha256']):
print(f"[CORRUPT] 文件校验失败: {rel_path}")
extracted.unlink()
return 1
extracted.unlink()
log(f" {len(manifest.get('files', {}))} 个文件校验通过")
# Step 4: Backup current config
step(4, total_steps, "备份当前配置")
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
version_dir = BACKUP_DIR / f'{manifest.get("to_version", "unknown")}_{timestamp}'
version_dir.mkdir(parents=True, exist_ok=True)
if CONFIG_FILE.exists():
shutil.copy2(str(CONFIG_FILE), str(version_dir / 'config.yaml'))
log("config.yaml 已备份")
if VERSION_FILE.exists():
shutil.copy2(str(VERSION_FILE), str(version_dir / 'VERSION'))
log("VERSION 已备份")
# Step 5: Delete removed files
step(5, total_steps, "删除已废弃的文件")
for rel_path in manifest.get('deleted', []):
target = PROJECT_DIR / rel_path
if target.exists():
try:
if target.is_dir():
shutil.rmtree(str(target))
else:
target.unlink()
log(f" 删除: {rel_path}")
except Exception as e:
log(f" [WARN] 删除失败 {rel_path}: {e}")
# Also process __delete__ (explicit removal list, may overlap with deleted)
for rel_path in manifest.get('__delete__', []):
target = PROJECT_DIR / rel_path
if target.exists():
try:
if target.is_dir():
shutil.rmtree(str(target))
else:
target.unlink()
log(f" 删除: {rel_path}")
except Exception as e:
log(f" [WARN] 删除失败 {rel_path}: {e}")
# Step 6: Extract changed files
# config.yaml is extracted but NOT moved — left in files/ for the merge step
step(6, total_steps, "解压更新文件")
extracted_count = 0
new_config_yaml = None # will hold path to extracted new config.yaml
with zipfile.ZipFile(zip_path, 'r') as zf:
for name in zf.namelist():
if not name.startswith('files/'):
continue
rel_path = name[6:] # strip 'files/' prefix
zf.extract(name, str(PROJECT_DIR))
if rel_path == 'config/config.yaml':
# Don't move — keep in files/ for merge step 7
new_config_yaml = PROJECT_DIR / name
extracted_count += 1
continue
# Move from files/ subdir to project root
src = PROJECT_DIR / name
dst = PROJECT_DIR / rel_path
if src != dst:
dst.parent.mkdir(parents=True, exist_ok=True)
if src.exists():
shutil.move(str(src), str(dst))
extracted_count += 1
log(f" 已解压 {extracted_count} 个文件")
# Step 7: Merge config.yaml
step(7, total_steps, "合并配置文件")
user_config = version_dir / 'config.yaml' # backed-up user config with edits
if user_config.exists() and new_config_yaml and new_config_yaml.exists() and MERGE_SCRIPT.exists():
log("合并用户配置与更新配置...")
# --old: user's backed-up config (preserves their edits)
# --new: config.yaml from the update package (new keys to add)
# --output: the live config file
result = subprocess.run(
[sys.executable, str(MERGE_SCRIPT),
'--old', str(user_config),
'--new', str(new_config_yaml),
'--output', str(CONFIG_FILE)],
capture_output=True, text=True, cwd=str(PROJECT_DIR)
)
print(result.stdout.strip())
if result.returncode != 0:
log(f" [WARN] 配置合并异常: {result.stderr.strip()}")
elif user_config.exists() and MERGE_SCRIPT.exists():
# New config not in package — keep user config unchanged
log(" 更新包不含 config.yaml,保留现有配置")
# Restore the backup since nothing new to merge
shutil.copy2(str(user_config), str(CONFIG_FILE))
else:
log(" 无备份配置,保留现有 config.yaml")
# Clean up extracted files/ directory
files_dir = PROJECT_DIR / 'files'
if files_dir.exists():
shutil.rmtree(str(files_dir))
# Step 8: Clean __pycache__
step(8, total_steps, "清理 Python 缓存")
clean_pycache(PROJECT_DIR)
# Also clean in runtime (portable python)
runtime_dir = PROJECT_DIR / 'runtime'
if runtime_dir.exists():
clean_pycache(runtime_dir)
# Step 9: Run Django migrations (always, since migrate is idempotent)
step(9, total_steps, "运行数据库迁移")
python_exe = PYTHON
migrate_cmd = [python_exe, 'manage.py', 'migrate']
log(f" 执行: {' '.join(migrate_cmd)}")
result = subprocess.run(migrate_cmd, capture_output=True, text=True, cwd=str(PROJECT_DIR))
print(result.stdout.strip())
if result.returncode != 0:
print(f"[MIGRATE_ERROR] 迁移失败: {result.stderr.strip()}")
print(" 请运行 python scripts/apply_update.py --rollback 回滚")
return 1
if 'Applying' in result.stdout or manifest.get('run_migrate'):
log(" 数据库 schema 已更新 ✓")
else:
log(" 数据库已是最新 ✓")
# ── Ensure tls_ref_data table ─────────────────────────────────────
# import_tlsdb is idempotent — safe to run every update.
log(" 检查 tls_ref_data 引用数据表...")
import_result = subprocess.run(
[python_exe, 'manage.py', 'import_tlsdb'],
capture_output=True, text=True, cwd=str(PROJECT_DIR)
)
if import_result.returncode == 0:
log(" 引用数据已就绪 ✓")
else:
# import_tlsdb may warn about existing data; that's fine
if 'already exists' in import_result.stderr.lower() or '0 row' in import_result.stdout.lower():
log(" 引用数据已存在 ✓")
else:
log(f" [INFO] import_tlsdb 输出: {import_result.stdout.strip()[:200]}")
# Step 10: Update VERSION and finalize
step(10, total_steps, "完成更新")
to_version = manifest.get('to_version', '')
if to_version:
VERSION_FILE.write_text(to_version + '\n', encoding='utf-8')
log(f"VERSION → {to_version}")
# Print runtime warning if needed
if manifest.get('runtime_changed'):
print("\n" + "!" * 50)
print(" ⚠️ 依赖已变更!")
print(" 请手动将开发机的 runtime/ 目录同步到离线机")
print(" (使用 U 盘复制 %APPDATA%/TianXuan/runtime/ 或直接覆盖)")
print("!" * 50)
print(f"\n{'=' * 50}")
print(f"✅ 更新完成!")
print(f" {from_ver}{to_version}")
print(f" {extracted_count} 个文件已更新")
print(f" {len(manifest.get('deleted', []))} 个文件已删除")
print(f"\n 请关闭当前窗口,重新双击 run.bat 启动新版本")
print(f"{'=' * 50}")
return 0
def main():
if len(sys.argv) < 2:
print("用法:")
print(" python scripts/apply_update.py <update.zip> — 应用更新")
print(" python scripts/apply_update.py --rollback — 回滚到最近备份")
return 1
if sys.argv[1] == '--rollback':
return do_rollback()
zip_path = Path(sys.argv[1])
if not zip_path.exists():
# Try relative to project dir
zip_path = PROJECT_DIR / sys.argv[1]
if not zip_path.exists():
print(f"[ERROR] 找不到更新包: {sys.argv[1]}")
return 1
return apply_update(zip_path.resolve())
if __name__ == '__main__':
sys.exit(main())