Files
BakNRet/Backup.ps1
T
Shuery dbc0c00554 重构为可核对、可恢复的备份工具(P0-P3)
修复(P0)
- 退出码:改用 .NET Process 继承控制台启动外部命令。Start-Process -PassThru 的
  ExitCode 在 PowerShell 7.7.0-preview.4 上恒为 $null,会把成功的压缩判成失败,
  并让 "exit 2 -> 删档重试" 的自愈分支永远不可达。
- BackupList.txt 解析:先按第一个 :: 切开再处理引号,修正整行被引号包住时
  排除表被吞进路径的问题(该条目此前被静默跳过,其 2.8 GB 归档成了孤儿)。
- 排除分隔符同时接受 , 与 ;:此前解析器只认 ; 而清单里写的是 ,,
  等于所有排除规则都没生效。
- 7z 排除参数不再嵌引号,含空格的模式自动转成 ?:旧写法 -x!"路径" 会让引号
  成为模式的一部分,导致排除对所有条目都失效。

加固(P1)
- 先写临时归档 -> 7z t 校验 -> 原子替换,中断不再污染正式归档。
- 放弃 7z 的更新模式 u:固实压缩下收益极小,却让排除规则改动与已删文件
  永远进不了归档。
- 新增 Backups/manifest.json 与 logs/*.log,跳过/失败有据可查。
- 结尾按失败数 exit;恢复支持 -WhatIf / -DryRun / -VerifyOnly / -Only。
- 恢复优先用 manifest 定位归档,并精确比较 BaseName(不再用 -Filter 通配)。
- 修正 tar 分支用 $LASTEXITCODE 判断成功与否的缺陷。
- 有警告(文件被占用)时拒绝用不完整的归档覆盖完整归档,需显式 -AcceptWarnings。

策略与安全(P2)
- Edge 条目加排除规则:解压后 4.22 GB 中 3.79 GB 是可再生的缓存/遥测/扩展本体,
  保留书签、密码、偏好、历史与站点数据。
- 可选 7z 加密(@encrypt 标记或全局开关),取不到口令时明确失败,绝不写明文。
- 磁盘空间守卫:放不下就跳过该条目,低于阈值告警。

工程化(P3)
- 新增 BackupConfig.psd1、README.md、.gitignore。
- tests/Run-Tests.ps1(32 项)与 tests/Run-E2E.ps1(16 项端到端验收)。
- tools/Register-BackupTask.ps1 注册每日计划任务。
- 归档命名算法保持不变,已有归档不会失联。
2026-09-21 20:10:18 +08:00

529 lines
22 KiB
PowerShell
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<#
.SYNOPSIS
按 BackupList.txt 执行备份。
.DESCRIPTION
与旧版相比的核心变化:
1. 退出码可靠 —— 不再用 Start-Process -PassThru(在 PowerShell 7.7.0-preview.4 上
ExitCode 恒为 $null,会把成功的压缩判成失败),改用 Invoke-ExternalCommand。
2. 先写临时归档 → 校验 → 原子替换。中断或断电只会留下 .tmp 文件,
不会污染正式归档;也不会再出现"半个归档被下次增量续写"的情况。
3. 不再使用 7z 的 u(更新)模式。7z 默认是固实压缩,u 本来就要重压大部分数据,
收益极小,却让排除规则和删除操作永远无法生效(旧归档里会一直留着已删文件)。
现在每次都从零打包,于是"排除规则改动"和"源里删掉的文件"都能真正反映到归档。
4. 每个条目写进 manifest.json:源、归档、时间、退出码、校验结果、失败原因。
跳过和失败从此有据可查,而不是只剩一行滚过去的控制台告警。
5. 结尾按失败数 exit,并写日志文件,计划任务能正确判断成败。
6. 磁盘空间守卫:放不下就拒绝该条目,低于阈值则告警。
#>
[CmdletBinding()]
param(
[Parameter()]
[string]$BackupListPath = (Join-Path $PSScriptRoot 'BackupList.txt'),
[Parameter()]
[string]$BackupDir,
[Parameter()]
[string]$ConfigPath = (Join-Path $PSScriptRoot 'BackupConfig.psd1'),
[Parameter()]
[string]$KeyFile,
# 只处理匹配这些通配符的条目(匹配原始路径或归档基础名)
[Parameter()]
[string[]]$Only = @(),
# 跳过匹配这些通配符的条目
[Parameter()]
[string[]]$Skip = @(),
# 忽略"源未更新"判断,强制重新打包
[Parameter()]
[switch]$Force,
# 成功后在 snapshots 目录留一份带时间戳的副本
[Parameter()]
[switch]$Snapshot,
# 额外计算归档的 SHA256 写入 manifest(大归档会更慢)
[Parameter()]
[switch]$Hash,
# 抑制压缩工具的实时输出(日志与 manifest 不受影响)
[Parameter()]
[switch]$QuietTool,
# 允许用"有警告"的不完整归档覆盖已有的完整归档(默认拒绝)
[Parameter()]
[switch]$AcceptWarnings,
# 只打印将要做什么,不实际写入
[Parameter()]
[switch]$DryRun
)
$ErrorActionPreference = 'Stop'
# ============================================================================
# 载入依赖
# ============================================================================
$modulePath = Join-Path $PSScriptRoot 'Common.psm1'
if (-not (Test-Path -LiteralPath $modulePath)) {
Write-Error "找不到依赖模块:$modulePath,请确保所有文件在同一目录。"
exit 1
}
Import-Module $modulePath -Force
if ($PSBoundParameters.ContainsKey('Verbose')) { Set-BaknretDebug }
$script:Config = Get-BaknretConfig -Path $ConfigPath
function Resolve-ConfigPath {
param([string]$Path, [string]$Default)
$value = if ($Path) { $Path } else { $Default }
if (-not [System.IO.Path]::IsPathRooted($value)) {
$value = Join-Path $PSScriptRoot $value
}
return $value
}
if (-not $BackupDir) { $BackupDir = Resolve-ConfigPath -Path $null -Default $script:Config.BackupDir }
$logDir = Resolve-ConfigPath -Path $null -Default $script:Config.LogDir
$snapshotDir = Resolve-ConfigPath -Path $null -Default $script:Config.SnapshotDir
$manifestPath = Join-Path $BackupDir 'manifest.json'
$logPath = Start-BaknretLog -Directory $logDir -Prefix 'backup'
Write-Log "日志文件:$logPath"
Write-Log "备份目录:$BackupDir"
if (-not (Test-Administrator)) {
Write-Log '建议以管理员身份运行以获取完整的目录访问权限' -Level WARN
}
# ============================================================================
# 准备
# ============================================================================
if (-not (Test-Path -LiteralPath $BackupDir)) {
New-Item -ItemType Directory -Path $BackupDir -Force | Out-Null
Write-Log "创建备份目录: $BackupDir" -Level DEBUG
}
if (-not (Test-Path -LiteralPath $BackupListPath)) {
$template = "# BackupList.txt`n# 语法: <路径> [ :: <排除模式>[,<排除模式>...] ] [ @<标记> ]`n# 示例: %UserProfile%\.ssh`n"
[System.IO.File]::WriteAllText($BackupListPath, $template, [System.Text.UTF8Encoding]::new($false))
Write-Log '模板 BackupList.txt 已创建,请编辑后重试。' -Level INFO
Stop-BaknretLog
exit 0
}
$tool = Resolve-CompressionTool
if (-not $tool) {
Write-Log '没有找到可用的压缩工具。' -Level ERROR
Stop-BaknretLog
exit 1
}
$toolVersion = try {
$info = (Get-Item -LiteralPath $tool.Command -ErrorAction Stop).VersionInfo
if ($info.ProductVersion) { $info.ProductVersion } elseif ($info.FileVersion) { $info.FileVersion } else { $null }
} catch { $null }
Write-Log ("压缩工具:{0}{1}" -f $tool.Name, $(if ($toolVersion) { "($toolVersion)" } else { '' }))
$manifest = Read-BaknretManifest -Path $manifestPath
$manifest.compressor = [pscustomobject]@{ name = $tool.Name; command = $tool.Command; extension = $tool.Extension; version = $toolVersion }
$passwordFile = if ($KeyFile) { $KeyFile } else { $script:Config.Encryption.PasswordFile }
$password = Get-BaknretPassword -PasswordFile $passwordFile
$encryptAll = [bool]$script:Config.Encryption.Enabled
$showToolOutput = (-not $QuietTool) -and ($script:Config.ToolOutput -ne 'quiet')
$toolQuietArgument = if ($showToolOutput) { @() } else { @('-bso0', '-bsp0') }
$lines = Get-Content -LiteralPath $BackupListPath
$processed = 0; $skipped = 0; $failed = 0; $planned = 0
$failures = @()
$freeSpaceGB = Get-BaknretFreeSpaceGB -Path $BackupDir
if ($freeSpaceGB -ge 0) {
Write-Log ("备份目录所在卷剩余空间:{0} GB" -f $freeSpaceGB)
if ($freeSpaceGB -lt $script:Config.MinFreeSpaceGB) {
Write-Log ("剩余空间低于阈值 {0} GB,大条目可能失败" -f $script:Config.MinFreeSpaceGB) -Level WARN
}
}
function Test-ItemSelected {
param([string]$DisplayPath, [string]$BaseName)
if ($Only.Count -gt 0) {
$matched = $false
foreach ($pattern in $Only) {
if ($DisplayPath -like $pattern -or $BaseName -like $pattern) { $matched = $true; break }
}
if (-not $matched) { return $false }
}
foreach ($pattern in $Skip) {
if ($DisplayPath -like $pattern -or $BaseName -like $pattern) { return $false }
}
return $true
}
function New-ItemRecord {
param([string]$BaseName, [string]$Source, [string]$ResolvedSource, [string]$Phase)
return [ordered]@{
baseName = $BaseName
source = $Source
resolvedSource = $ResolvedSource
archive = $null
action = $null
reason = $null
phase = $Phase
attemptedAt = (Get-Date).ToString('o')
finishedAt = $null
durationSec = $null
exitCode = $null
verified = $false
warnings = $false
attemptWarnings = $false
encrypted = $false
sourceFiles = $null
sourceBytes = $null
archiveBytes = $null
sha256 = $null
lastSuccessAt = $null
successCount = 0
failCount = 0
}
}
function Save-ItemRecord {
param($Record, [string]$Action, [string]$Reason, [bool]$ArchiveWarnings = $false)
$previous = $null
if ($manifest.items.Contains($Record.baseName)) { $previous = $manifest.items[$Record.baseName] }
$Record.action = $Action
$Record.reason = $Reason
$Record.finishedAt = (Get-Date).ToString('o')
# warnings 描述的是"当前在位的归档",不是"这次尝试"。
# 只有真正换掉了归档才更新它;否则沿用上一条记录,
# 否则"因为不完整而保留旧归档"之后,下一次就失去保护了。
if ($Action -eq 'backed-up') {
$Record.warnings = $ArchiveWarnings
} elseif ($previous -and ($previous.PSObject.Properties.Name -contains 'warnings')) {
$Record.warnings = [bool]$previous.warnings
}
if ($previous) {
if ($previous.PSObject.Properties.Name -contains 'lastSuccessAt') { $Record.lastSuccessAt = $previous.lastSuccessAt }
if ($previous.PSObject.Properties.Name -contains 'successCount') { $Record.successCount = [int]$previous.successCount }
if ($previous.PSObject.Properties.Name -contains 'failCount') { $Record.failCount = [int]$previous.failCount }
}
if ($Action -eq 'backed-up') {
$Record.lastSuccessAt = $Record.finishedAt
$Record.successCount = [int]$Record.successCount + 1
} elseif ($Action -eq 'failed') {
$Record.failCount = [int]$Record.failCount + 1
}
$manifest.items[$Record.baseName] = $Record
return $Record
}
# 压缩 + 校验 + 原子替换;返回 @{ Ok; ExitCode; Warnings; Reason }
function Invoke-BackupItem {
param(
[string]$SourcePath,
[string]$ItemName,
[string]$ParentDir,
[string]$FinalPath,
[string[]]$ExcludePatterns,
[switch]$UseEncryption,
[switch]$ProtectPrevious,
[switch]$AcceptWarnings
)
$tempPath = "$FinalPath.tmp$($tool.Extension)"
if (Test-Path -LiteralPath $tempPath) { Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue }
$excludeArgument = Get-ArchiveExcludeArgument -ItemName $ItemName -Patterns $ExcludePatterns
if ($excludeArgument.Count -gt 0) {
Write-Log ("排除 {0} 项:{1}" -f $excludeArgument.Count, ($excludeArgument -join ' ')) -Level DEBUG
}
try {
if ($tool.Name -eq '7z') {
$optimized = Get-Optimized7zArgument -SourcePath $SourcePath -Level $script:Config.CompressionLevel
$argument = @($optimized.Argument) + $toolQuietArgument + $excludeArgument
if ($UseEncryption) {
if (-not $password) {
return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = '需要加密但取不到口令(设置 BAKNRET_PASSWORD 或用 -KeyFile 指定密码文件)' }
}
$argument += "-p$password"
if ($script:Config.Encryption.EncryptHeaders) { $argument += '-mhe=on' }
}
$argument += $tempPath
$argument += $ItemName
$exitCode = Invoke-ExternalCommand -FilePath $tool.Command -ArgumentList $argument -WorkingDirectory $ParentDir
# 7z: 0 成功;1 警告(有文件读不到或跳过);2 及以上为失败
if ($exitCode -ne 0 -and $exitCode -ne 1) {
return [pscustomobject]@{ Ok = $false; ExitCode = $exitCode; Warnings = $false; Reason = "压缩工具退出码 $exitCode" }
}
$warnings = ($exitCode -eq 1)
}
elseif ($tool.Name -eq 'RAR') {
$argument = @('a', '-m5', '-idp', '-idn') + $toolQuietArgument + $excludeArgument
if ($UseEncryption) {
if (-not $password) {
return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = '需要加密但取不到口令' }
}
$argument += "-p$password"
}
$argument += $tempPath
$argument += $ItemName
$exitCode = Invoke-ExternalCommand -FilePath $tool.Command -ArgumentList $argument -WorkingDirectory $ParentDir
if ($exitCode -ne 0) {
return [pscustomobject]@{ Ok = $false; ExitCode = $exitCode; Warnings = $false; Reason = "压缩工具退出码 $exitCode" }
}
$warnings = $false
}
else {
if ($UseEncryption) {
return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = '内置 ZIP 不支持加密,请改用 7z 或去掉 encrypt 标记' }
}
Push-Location $ParentDir
try {
Compress-Archive -Path $ItemName -DestinationPath $tempPath -CompressionLevel Optimal -Force
} finally {
Pop-Location
}
$warnings = $false
}
if (-not (Test-Path -LiteralPath $tempPath)) {
return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = '压缩结束但没有生成临时归档' }
}
# 校验:确认归档可读且内容 CRC 正确
if ($script:Config.VerifyArchive -and $tool.Name -eq '7z') {
$verifyArgument = @('t', '-bso0', '-bsp0')
if ($UseEncryption -and $password) { $verifyArgument += "-p$password" }
$verifyArgument += $tempPath
$verifyCode = Invoke-ExternalCommand -FilePath $tool.Command -ArgumentList $verifyArgument -WorkingDirectory $ParentDir
if ($verifyCode -ne 0) {
Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue
return [pscustomobject]@{ Ok = $false; ExitCode = $verifyCode; Warnings = $false; Reason = "归档校验失败(7z t 退出码 $verifyCode),已丢弃临时文件" }
}
Write-Log '归档校验通过(7z t)' -Level DEBUG
}
# 关键保护:压缩工具报了警告(通常是有文件被占用读不到)时,
# 新归档是**不完整**的。用不完整归档覆盖已有的完整归档 = 静默丢数据。
# 实测:Edge 运行时备份,118 个文件读不到,其中包含 Login Data(密码)、
# Cookies、History、Web Data —— 恰恰是最不可再生的那部分。
if ($warnings -and $ProtectPrevious -and -not $AcceptWarnings) {
Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue
return [pscustomobject]@{
Ok = $false
ExitCode = $exitCode
Warnings = $true
Reason = '压缩工具报告有文件被占用而读不到,新归档不完整。为避免覆盖现有的完整归档已保留旧归档;请关闭占用该目录的程序后重跑,或确认可以接受后用 -AcceptWarnings 强制覆盖'
}
}
Move-BaknretArchiveIntoPlace -TempPath $tempPath -DestinationPath $FinalPath
return [pscustomobject]@{ Ok = $true; ExitCode = 0; Warnings = $warnings; Reason = $null }
} catch {
if (Test-Path -LiteralPath $tempPath) {
Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue
}
return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = "$_" }
}
}
# ============================================================================
# 主流程
# ============================================================================
foreach ($line in $lines) {
$item = ConvertFrom-BackupListLine -Line $line
if (-not $item) { continue }
$displayPath = $item.Path
$sourcePath = [Environment]::ExpandEnvironmentVariables($item.Path)
$baseName = Get-BackupBaseName -RawPath $item.Path
if (-not $baseName) {
$record = New-ItemRecord -BaseName ('raw:' + $displayPath) -Source $displayPath -ResolvedSource $sourcePath -Phase 'parse'
Save-ItemRecord -Record $record -Action 'failed' -Reason '无法从路径生成归档名' | Out-Null
$failed++; $failures += $displayPath
continue
}
if (-not (Test-ItemSelected -DisplayPath $displayPath -BaseName $baseName)) {
Write-Log "跳过(未选中): $displayPath" -Level DEBUG
continue
}
$record = New-ItemRecord -BaseName $baseName -Source $displayPath -ResolvedSource $sourcePath -Phase 'backup'
$record.archive = $baseName + $tool.Extension
$finalPath = Join-Path $BackupDir $record.archive
if (-not (Test-Path -LiteralPath $sourcePath)) {
Write-Log "跳过: $displayPath,路径不存在" -Level WARN
Save-ItemRecord -Record $record -Action 'missing-source' -Reason '源路径不存在' | Out-Null
$skipped++
continue
}
$parentDir = Split-Path -Path $sourcePath -Parent
$itemName = Split-Path -Path $sourcePath -Leaf
if (-not $parentDir -or -not $itemName) {
Write-Log "跳过: $displayPath,无法处理根目录" -Level WARN
Save-ItemRecord -Record $record -Action 'invalid-path' -Reason '无法拆出父目录或末级名' | Out-Null
$skipped++
continue
}
$summary = Get-FolderSummary -FolderPath $sourcePath
$record.sourceFiles = $summary.FileCount
$record.sourceBytes = $summary.TotalSize
$archiveExists = Test-Path -LiteralPath $finalPath
$archiveItem = if ($archiveExists) { Get-Item -LiteralPath $finalPath } else { $null }
Write-Log ("开始备份: {0}({1} 个文件,{2} MB)" -f $displayPath, $summary.FileCount, [math]::Round(($summary.TotalSize / 1MB), 2))
# 空目录时 Get-FolderSummary 拿不到任何条目,回退到源自身的修改时间
$sourceLatest = $summary.LatestModifiedTime
if (-not $sourceLatest) {
$sourceLatest = (Get-Item -LiteralPath $sourcePath -Force).LastWriteTime
}
if (-not $Force -and $archiveItem -and $sourceLatest -and $sourceLatest -le $archiveItem.LastWriteTime) {
Write-Log "跳过: $displayPath,源目录未更新" -Level INFO
$record.archiveBytes = $archiveItem.Length
Save-ItemRecord -Record $record -Action 'skip-unchanged' -Reason ('源最新修改时间 {0} 不晚于归档时间 {1}' -f $sourceLatest, $archiveItem.LastWriteTime) | Out-Null
$skipped++
continue
}
# 空间守卫:临时归档与正式归档会同时存在,因此按"新归档预估大小"要求剩余空间
$estimatedGB = $summary.TotalSize / 1GB
if ($archiveItem) {
$archiveGB = $archiveItem.Length / 1GB
$estimatedGB = [math]::Min($estimatedGB, $archiveGB * 1.3)
}
$freeSpaceGB = Get-BaknretFreeSpaceGB -Path $BackupDir
if ($freeSpaceGB -ge 0 -and $estimatedGB -gt 0 -and $freeSpaceGB -lt $estimatedGB) {
$reason = ('剩余空间 {0} GB 不足以写入预估 {1} GB 的新归档' -f $freeSpaceGB, [math]::Round($estimatedGB, 2))
Write-Log "失败: $displayPath,$reason" -Level ERROR
Save-ItemRecord -Record $record -Action 'failed' -Reason $reason | Out-Null
$failed++; $failures += $displayPath
continue
}
if ($DryRun) {
Write-Log ("[试运行] 将打包 {0} -> {1}" -f $sourcePath, $finalPath) -Level INFO
$record.reason = '试运行,未执行压缩'
Save-ItemRecord -Record $record -Action 'planned' -Reason '试运行,未执行压缩' | Out-Null
$planned++
continue
}
$useEncryption = $encryptAll -or ($item.Flags -contains 'encrypt')
$record.encrypted = [bool]$useEncryption
$startedAt = Get-Date
$record.attemptedAt = $startedAt.ToString('o')
# 配置里的全局排除 + 本条目的排除
$effectiveExcludes = @($script:Config.DefaultExcludes) + @($item.ExcludePatterns)
# 只有在"现有归档是完整的"时才值得保护它。没有 manifest 记录
# (本次重构之前留下的归档)时按完整处理——宁可保守。
$protectPrevious = [bool]$archiveExists
if ($archiveExists -and $manifest.items.Contains($baseName)) {
$previousRecord = $manifest.items[$baseName]
if (($previousRecord.PSObject.Properties.Name -contains 'warnings') -and $previousRecord.warnings) {
$protectPrevious = $false
}
}
$result = Invoke-BackupItem -SourcePath $sourcePath -ItemName $itemName -ParentDir $parentDir `
-FinalPath $finalPath -ExcludePatterns $effectiveExcludes -UseEncryption:$useEncryption `
-ProtectPrevious:$protectPrevious -AcceptWarnings:$AcceptWarnings
$record.exitCode = $result.ExitCode
$record.attemptWarnings = [bool]$result.Warnings
$record.verified = [bool]$result.Ok
$record.durationSec = [math]::Round(((Get-Date) - $startedAt).TotalSeconds, 1)
if (-not $result.Ok) {
Write-Log "备份失败: $displayPath,$($result.Reason)" -Level ERROR
Save-ItemRecord -Record $record -Action 'failed' -Reason $result.Reason | Out-Null
$failed++; $failures += $displayPath
continue
}
$written = Get-Item -LiteralPath $finalPath
$record.archiveBytes = $written.Length
if ($result.Warnings) {
Write-Log "备份成功(压缩工具报告了警告,可能有文件被占用而没打进归档): $displayPath" -Level WARN
Write-Log ' 该归档在 manifest 里标记为 warnings=true;如果以后现有归档是完整的,会拒绝被它覆盖' -Level WARN
} else {
Write-Log "备份成功: $baseName" -Level INFO
}
if ($Hash -or $script:Config.ComputeHash) {
$record.sha256 = (Get-FileHash -LiteralPath $finalPath -Algorithm SHA256).Hash
Write-Log "SHA256: $($record.sha256)" -Level DEBUG
}
if ($Snapshot -or $script:Config.Snapshot.Enabled) {
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$target = Join-Path (Join-Path $snapshotDir $stamp) $record.archive
$targetDir = Split-Path -Parent $target
if (-not (Test-Path -LiteralPath $targetDir)) { New-Item -ItemType Directory -Path $targetDir -Force | Out-Null }
Copy-Item -LiteralPath $finalPath -Destination $target -Force
Write-Log "已留存快照: $target" -Level INFO
}
Save-ItemRecord -Record $record -Action 'backed-up' -Reason $null -ArchiveWarnings $result.Warnings | Out-Null
$processed++
}
# ============================================================================
# 收尾
# ============================================================================
if ($DryRun) {
Write-Log '试运行:manifest 与归档都不会被写入' -Level INFO
} else {
Write-BaknretManifest -Path $manifestPath -Manifest $manifest | Out-Null
Write-Log "manifest 已更新:$manifestPath" -Level DEBUG
}
if ($failures.Count -gt 0) {
Write-Log '失败条目:' -Level ERROR
foreach ($failure in $failures) { Write-Log " - $failure" -Level ERROR }
}
$summaryText = "备份完成。成功: $processed, 跳过: $skipped, 失败: $failed"
if ($DryRun) { $summaryText += ", 试运行计划: $planned" }
Write-Log $summaryText -Level INFO
$logPath = Get-BaknretLogPath
if ($logPath) { Write-Log "日志已写入:$logPath" -Level INFO }
Stop-BaknretLog
if ($failed -gt 0) { exit 1 }
exit 0