<# .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 $catalogPath = Resolve-CatalogPath -Configured $script:Config.SoftwareCatalog -Root $PSScriptRoot $manifestPath = Join-Path $BackupDir 'manifest.json' $logPath = Start-BaknretLog -Directory $logDir -Prefix 'backup' Write-Log "日志文件:$logPath" Write-Log "备份目录:$BackupDir" Write-Log ("软件名录:{0}{1}" -f $catalogPath, $(if (Test-Path -LiteralPath $catalogPath) { '' } else { '(不存在,将只支持字面路径)' })) 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 $seenBaseNames = @{} $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 roots = @() catalog = $null 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 } # # $SourceGroups 支持"一个软件包含多个目录":每个元素是 # @{ ParentDir; RelativePaths; Label }。7z/RAR 对同一归档多次 `a` 会把内容并入, # 所以按父目录分组、逐组追加,归档里每个目录仍保留自己的名字与层级。 function Invoke-BackupItem { param( [Parameter(Mandatory = $true)][array]$SourceGroups, [Parameter(Mandatory = $true)][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 } $warnings = $false $lastExitCode = 0 $lastParentDir = $null try { if ($SourceGroups.Count -eq 0) { return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = '没有可打包的源目录' } } $groupIndex = 0 foreach ($group in $SourceGroups) { $groupIndex++ $parentDir = $group.ParentDir $relativePaths = @($group.RelativePaths) if ($relativePaths.Count -eq 0) { continue } $lastParentDir = $parentDir # 排除模式的**前缀用这一组的源目录名**(归档里就是这个层级)。 $prefixName = if ($group.Label) { $group.Label } else { Split-Path -Path $relativePaths[0] -Leaf } $excludeArgument = Get-ArchiveExcludeArgument -ItemName $prefixName -Patterns $ExcludePatterns if ($tool.Name -eq '7z') { $probePath = "$($parentDir.TrimEnd('\'))\$($relativePaths[0])" $optimized = Get-Optimized7zArgument -SourcePath $probePath -Level $script:Config.CompressionLevel $argument = @($optimized.Argument) + $toolQuietArgument + $excludeArgument if ($UseEncryption) { if (-not $password) { return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = '需要加密但取不到口令(见 README「加密」)' } } $argument += "-p$password" if ($script:Config.Encryption.EncryptHeaders) { $argument += '-mhe=on' } } $argument += $tempPath foreach ($relative in $relativePaths) { $argument += $relative } $exitCode = Invoke-ExternalCommand -FilePath $tool.Command -ArgumentList $argument -WorkingDirectory $parentDir $lastExitCode = $exitCode # 7z: 0 成功;1 警告(有文件读不到或跳过);2 及以上为失败 if ($exitCode -ne 0 -and $exitCode -ne 1) { return [pscustomobject]@{ Ok = $false; ExitCode = $exitCode; Warnings = $warnings; Reason = "第 $groupIndex 组($prefixName)压缩工具退出码 $exitCode" } } if ($exitCode -eq 1) { $warnings = $true } } 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 foreach ($relative in $relativePaths) { $argument += $relative } $exitCode = Invoke-ExternalCommand -FilePath $tool.Command -ArgumentList $argument -WorkingDirectory $parentDir $lastExitCode = $exitCode if ($exitCode -ne 0) { return [pscustomobject]@{ Ok = $false; ExitCode = $exitCode; Warnings = $warnings; Reason = "第 $groupIndex 组($prefixName)压缩工具退出码 $exitCode" } } } else { if ($UseEncryption) { return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = '内置 ZIP 不支持加密,请改用 7z 或去掉 encrypt 标记' } } # Compress-Archive 不能追加;多组时逐组重打(先把已有临时归档解开再合并会让代码复杂得多, # 而 ZIP 本来就是降级路径,这里只保证内容完整) $fullPaths = @($relativePaths | ForEach-Object { Join-Path $parentDir $_ }) if ($groupIndex -gt 1 -and (Test-Path -LiteralPath $tempPath)) { $staging = Join-Path $env:TEMP ("bnr-zip-" + [guid]::NewGuid().ToString('N')) New-Item -ItemType Directory -Path $staging -Force | Out-Null try { Expand-Archive -LiteralPath $tempPath -DestinationPath $staging -Force $fullPaths += @(Get-ChildItem -LiteralPath $staging -Force | Select-Object -ExpandProperty FullName) Compress-Archive -Path $fullPaths -DestinationPath $tempPath -CompressionLevel Optimal -Force } finally { Remove-Item -LiteralPath $staging -Recurse -Force -ErrorAction SilentlyContinue } } else { Compress-Archive -Path $fullPaths -DestinationPath $tempPath -CompressionLevel Optimal -Force } } } 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 $lastParentDir 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 # 多目录时确认每个目录都真的进了归档:7z 的"警告"可能只体现在某一组里 if ($SourceGroups.Count -gt 1) { $listed = @(Get-ArchiveTopLevelNames -ArchivePath $tempPath -SevenZip $tool.Command -Password $(if ($UseEncryption) { $password } else { $null })) if ($listed.Count -gt 0) { $expected = @($SourceGroups | ForEach-Object { Split-Path -Path $_.RelativePaths[0] -Leaf }) $absent = @($expected | Where-Object { $_ -notin $listed }) if ($absent.Count -gt 0) { Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue return [pscustomobject]@{ Ok = $false; ExitCode = $lastExitCode; Warnings = $true; Reason = ("归档缺少这些目录:{0}(归档内实际有:{1})" -f ($absent -join '、'), ($listed -join '、')) } } } } } # 关键保护:压缩工具报了警告(通常是有文件被占用读不到)时, # 新归档是**不完整**的。用不完整归档覆盖已有的完整归档 = 静默丢数据。 # 实测: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 = $lastExitCode 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 = "$_" } } } # ============================================================================ # 备份前空间预估(只读,不写任何东西) # ============================================================================ # 只做一件事:动手之前告诉用户"这次大概要写多少、盘够不够"。 # 不做更复杂的占用控制 —— 真正拦住某个条目的是主循环里的逐条目守卫。 # # 模型(按清单顺序模拟一遍): # * 每个要重打的条目会先写一份**临时**归档,这时旧归档还在,所以那一刻占用的 # 是"当前累计净增量 + 本次预估"; # * 原子替换之后,本次净增量 = 预估 - 现有归档大小(换成更小的归档会把空间还回来)。 # 于是:峰值新增 = max_i( 第 i 项之前的累计净增量 + 第 i 项的预估大小 )。 $spacePlan = @() $spaceSkipped = 0 $spaceNoSource = 0 foreach ($planLine in $lines) { $planItem = ConvertFrom-BackupListLine -Line $planLine if (-not $planItem) { continue } $planDisplayPath = $planItem.Path $planResolved = Resolve-BackupEntry -Entry $planItem -CatalogPath $catalogPath -MaxDepth $script:Config.CatalogMaxDepth if (-not $planResolved.BaseName) { continue } if (-not (Test-ItemSelected -DisplayPath $planDisplayPath -BaseName $planResolved.BaseName)) { continue } if ($planResolved.Blocking) { continue } $planSources = @($planResolved.Sources | Where-Object { Test-Path -LiteralPath $_.SourcePath }) if ($planSources.Count -eq 0) { $spaceNoSource++; continue } $planSourceBytes = [int64]0 $planSourceFiles = 0 $planLatest = $null foreach ($planSource in $planSources) { $planSummary = Get-FolderSummary -FolderPath $planSource.SourcePath $planSourceBytes += [int64]$planSummary.TotalSize $planSourceFiles += [int]$planSummary.FileCount if ($planSummary.LatestModifiedTime -and (-not $planLatest -or $planSummary.LatestModifiedTime -gt $planLatest)) { $planLatest = $planSummary.LatestModifiedTime } } $planArchiveName = $planResolved.BaseName + $tool.Extension $planArchivePath = Join-Path $BackupDir $planArchiveName $planExistingItem = if (Test-Path -LiteralPath $planArchivePath) { Get-Item -LiteralPath $planArchivePath } else { $null } $planExistingBytes = if ($planExistingItem) { [int64]$planExistingItem.Length } else { [int64]0 } # 与主循环同一套判断:源没更新就不会重打 if (-not $Force -and $planExistingItem -and $planLatest -and $planLatest -le $planExistingItem.LastWriteTime) { $spaceSkipped++ continue } $planEstimate = if ($planExistingBytes -gt 0) { [int64][math]::Min([double]$planSourceBytes, [double]$planExistingBytes * 1.3) } else { # 没有历史归档可比时按"完全不压缩"的悲观值估,宁可报多不报少 $planSourceBytes } $spacePlan += [pscustomobject]@{ Name = $planResolved.BaseName Source = $planDisplayPath Files = $planSourceFiles SourceBytes = $planSourceBytes Existing = $planExistingBytes Estimate = $planEstimate } } $freeNowGB = Get-BaknretFreeSpaceGB -Path $BackupDir if ($spacePlan.Count -eq 0) { Write-Log '备份前空间预估:本次没有需要重打的条目(源未更新或源不存在),不会写入新归档' -Level INFO } else { $spacePeak = [double]0 $spaceCumulative = [double]0 foreach ($plan in $spacePlan) { $spacePeak = [math]::Max($spacePeak, $spaceCumulative + $plan.Estimate) $spaceCumulative += ($plan.Estimate - $plan.Existing) } $peakGB = $spacePeak / 1GB $netGB = $spaceCumulative / 1GB $estimateGB = ((($spacePlan | Measure-Object -Property Estimate -Sum).Sum)) / 1GB $existingGB = ((($spacePlan | Measure-Object -Property Existing -Sum).Sum)) / 1GB Write-Log '==== 备份前空间预估(只读)====' -Level INFO Write-Log (" 目标卷可用空间:{0} GB" -f $freeNowGB) Write-Log (" 本次要重打 {0} 个条目(另有 {1} 个源未更新会跳过、{2} 个源不存在)" -f $spacePlan.Count, $spaceSkipped, $spaceNoSource) Write-Log (" 新归档合计约 {0} GB;其中会替换掉的旧归档 {1} GB" -f [math]::Round($estimateGB, 2), [math]::Round($existingGB, 2)) foreach ($plan in ($spacePlan | Sort-Object Estimate -Descending | Select-Object -First 15)) { Write-Log (" - {0,-22} 源 {1,8:N1} MB / {2,6} 文件 现有 {3,7:N1} MB 预估 {4,7:N1} MB" -f ` $plan.Name, ($plan.SourceBytes / 1MB), $plan.Files, ($plan.Existing / 1MB), ($plan.Estimate / 1MB)) } if ($spacePlan.Count -gt 15) { Write-Log (" …… 另有 {0} 个条目未逐条列出" -f ($spacePlan.Count - 15)) } Write-Log (" 预计峰值新增占用:{0} GB(全程净增量 {1} GB)" -f [math]::Round($peakGB, 2), [math]::Round($netGB, 2)) if ($freeNowGB -lt 0) { Write-Log ' 结论:读不到目标卷可用空间,请自行确认是否够用' -Level WARN } elseif ($peakGB -le $freeNowGB) { Write-Log (" 结论:空间足够(预计用 {0} GB / 可用 {1} GB)" -f [math]::Round($peakGB, 2), $freeNowGB) -Level INFO } else { Write-Log (" 结论:空间可能不够!预计需要 {0} GB,可用只有 {1} GB,差 {2} GB" -f ` [math]::Round($peakGB, 2), $freeNowGB, [math]::Round($peakGB - $freeNowGB, 2)) -Level WARN Write-Log ' 仍会继续执行:真正放不下的条目会被逐条目守卫跳过。建议先腾空间,或用 -Only / -Skip 分批备份。' -Level WARN } Write-Log '============================' -Level INFO } # ============================================================================ # 主流程 # ============================================================================ foreach ($line in $lines) { $item = ConvertFrom-BackupListLine -Line $line if (-not $item) { continue } $displayPath = $item.Path $resolved = Resolve-BackupEntry -Entry $item -CatalogPath $catalogPath -MaxDepth $script:Config.CatalogMaxDepth if (-not $resolved.BaseName) { $record = New-ItemRecord -BaseName ('raw:' + $displayPath) -Source $displayPath -ResolvedSource $displayPath -Phase 'parse' Save-ItemRecord -Record $record -Action 'failed' -Reason '无法生成归档名' | Out-Null $failed++; $failures += $displayPath continue } $baseName = $resolved.BaseName $sourcePath = [Environment]::ExpandEnvironmentVariables($displayPath) 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 if ($resolved.CatalogEntry) { $record.catalog = $resolved.CatalogEntry.Path } $finalPath = Join-Path $BackupDir $record.archive # root= 在 README 里被列为可用标记,但归档内的根目录实际上始终是源目录名 # (见 README「设计取舍」:不套一层软件名目录)。7z 命令行也没有"入库时改名" # 的能力,所以这里明确告警而不是让它静默失效——静默失效正是本次重构要消灭的东西。 if (@($item.Flags | Where-Object { $_ -like 'root=*' }).Count -gt 0) { Write-Log "警告: $displayPath 使用了 root= 标记,该功能尚未实现(归档内的根目录始终是源目录名),本次忽略" -Level WARN } # 备份列表里写重了会生成两个同名归档,互相覆盖 —— 直接报错,不猜。 if ($seenBaseNames.ContainsKey($baseName)) { $reason = "归档名 '$baseName' 与清单中的 '$($seenBaseNames[$baseName])' 重复(由 '$displayPath' 生成),两者会互相覆盖" Write-Log "失败: $displayPath,$reason" -Level ERROR Save-ItemRecord -Record $record -Action 'failed' -Reason $reason | Out-Null $failed++; $failures += $displayPath continue } $seenBaseNames[$baseName] = $displayPath # 归档内顶层同名冲突:明确失败,绝不把两个目录静默搅进同一棵树 if ($resolved.Blocking) { Write-Log "失败: $displayPath,$($resolved.Blocking)" -Level ERROR Save-ItemRecord -Record $record -Action 'failed' -Reason $resolved.Blocking | Out-Null $failed++; $failures += $displayPath continue } # 动手之前先把"这条会打包哪些目录、排除了什么、为什么"讲清楚 Write-BackupEntryPlan -Resolved $resolved -DisplayPath $displayPath ` -ListExcludes @($item.ExcludePatterns) -ConfigExcludes @($script:Config.DefaultExcludes) ` -Comment $item.Comment # Sources 为空 = 解析不出任何源(名录里没这个软件名、或路径拆不出父/子级)。 # 注意不能用 $resolved.Error 判断:名录里的路径不存在时 Error 有值, # 但 Sources 是给出的(恢复端要靠它把内容还原回原位),备份端由下面的 # 存在性检查统一处理。 if ($resolved.Sources.Count -eq 0) { $reason = if ($resolved.Error) { $resolved.Error } else { '解析不出任何源路径' } Write-Log "跳过: $displayPath,$reason" -Level WARN Save-ItemRecord -Record $record -Action 'missing-source' -Reason $reason | Out-Null $skipped++ continue } # 源存在性检查必须在 Get-FolderSummary / Get-Item 之前: # 两者对不存在的路径要么抛异常、要么返回会误导判断的空摘要。 # 注意不能用 Join-Path 探测:目标盘符不存在时它会直接抛异常。 # 源路径存在性以 SourcePath 为准:RelativePaths 是"归档里的名字", # 目前两者一致,但 SourcePath 才是磁盘上的真实位置。 $expectedRoots = 0 $missingRoots = @() foreach ($source in $resolved.Sources) { $expectedRoots++ if (-not (Test-Path -LiteralPath $source.SourcePath)) { $missingRoots += $source.SourcePath } } if ($missingRoots.Count -ge $expectedRoots) { Write-Log "跳过: $displayPath,源路径不存在" -Level WARN Save-ItemRecord -Record $record -Action 'missing-source' -Reason ('源路径不存在:' + ($missingRoots -join ';')) | Out-Null $skipped++ continue } if ($missingRoots.Count -gt 0) { Write-Log ("警告: {0} 有 {1} 个源路径不存在,本次只备份存在的部分:{2}" -f $displayPath, $missingRoots.Count, ($missingRoots -join ';')) -Level WARN } # 归档里只放真实存在的源 $liveSources = @() foreach ($source in $resolved.Sources) { if (Test-Path -LiteralPath $source.SourcePath) { $liveSources += [pscustomobject]@{ RootName = $source.RootName ParentDir = $source.ParentDir RelativePaths = @($source.RelativePaths) SourcePath = $source.SourcePath Description = $source.Description Origin = $source.Origin } } } # 归档内的顶层条目名 = 每个**真实存在**的源在归档里的第一层名字,也就是源目录 # (或源文件)自己的名字。刻意不用 $resolved.Sources[].RootName:那套"归档内套一层 # 软件名"的设想已按设计取舍放弃,实际布局始终是 <源目录名>\...。 # 这里记录可核对的事实,之前写成软件名会让 Edge(实际是 "User Data")之类的条目对不上。 $record.roots = @($liveSources | ForEach-Object { $_.RelativePaths | ForEach-Object { ($_ -split '[\\/]')[0] } } | Select-Object -Unique) $primarySource = $liveSources[0].SourcePath $parentDir = $liveSources[0].ParentDir # 排除模式的前缀始终用**源目录名**(归档里就是这个层级) $itemName = Split-Path -Path $primarySource -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 $primarySource foreach ($source in $liveSources[1..($liveSources.Count - 1)]) { $extra = Get-FolderSummary -FolderPath $source.SourcePath $summary.FileCount += $extra.FileCount $summary.TotalSize += $extra.TotalSize if ($extra.LatestModifiedTime -and $extra.LatestModifiedTime -gt $summary.LatestModifiedTime) { $summary.LatestModifiedTime = $extra.LatestModifiedTime } } $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} 个文件,{3} MB)" -f $displayPath, $record.archive, $summary.FileCount, [math]::Round(($summary.TotalSize / 1MB), 2)) # 空目录时 Get-FolderSummary 拿不到任何条目,回退到源自身的修改时间 # (源路径上面已经确认存在,这里的 Get-Item 不会再抛异常) $sourceLatest = $summary.LatestModifiedTime if (-not $sourceLatest) { $sourceLatest = (Get-Item -LiteralPath $primarySource -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 } } # 多目录:每个源组各带自己的父目录与相对名。7z 会对同一归档逐组追加。 # Label 刻意留空:排除模式的前缀必须是**归档里的那一层名字**,也就是源目录名 # (归档内布局是 `<源目录名>\...`)。若把软件名当 Label 传下去, # 排除模式就会变成 `软件名\skip.bin`,与实际路径对不上而静默失效。 $sourceGroups = @($liveSources | ForEach-Object { [pscustomobject]@{ ParentDir = $_.ParentDir RelativePaths = @($_.RelativePaths) Label = $null } }) $result = Invoke-BackupItem -SourceGroups $sourceGroups ` -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 { # manifest 里写了 archive 的记录,磁盘上就必须真有那个文件 $clearedArchiveFields = Sync-BaknretManifestArchive -Manifest $manifest -BackupDir $BackupDir if ($clearedArchiveFields.Count -gt 0) { Write-Log ("已清空 {0} 条记录里指向不存在归档的 archive 字段:{1}" -f $clearedArchiveFields.Count, ($clearedArchiveFields -join '、')) -Level WARN } Write-BaknretManifest -Path $manifestPath -Manifest $manifest | Out-Null Write-Log "manifest 已更新:$manifestPath" -Level DEBUG } # 孤儿归档审计:磁盘上有、但**当前清单里任何条目都不指向**的归档。 # Restore.ps1 是按清单条目去找归档的,所以孤儿是**恢复不到**的 —— 必须显式点名, # 免得下次清理时把还有用的归档当垃圾删掉(重构前那个 2.8 GB 的归档就是这么成孤儿的)。 # # 判据只用清单,**不能用 manifest**:manifest 会一直留着历史条目, # 于是"从清单里删掉某个条目(或把它合并进另一个条目)"留下的归档会被历史记录遮住, # 审计就永远不会报——那正是最需要报出来的情况。 # 只在整表运行时做:带 -Only/-Skip 时未选中的条目本来就不在 $seenBaseNames 里, # 那种情况下报出来的全是假孤儿。 if (-not $DryRun -and $Only.Count -eq 0 -and $Skip.Count -eq 0) { $known = @{} foreach ($key in $seenBaseNames.Keys) { $known[$key] = $true } $orphanArchives = @(Get-ChildItem -LiteralPath $BackupDir -File -Force -ErrorAction SilentlyContinue | Where-Object { $_.Extension.ToLower() -in @('.7z', '.rar', '.zip', '.tar') -and -not $known.ContainsKey($_.BaseName) }) if ($orphanArchives.Count -gt 0) { Write-Log ("发现 {0} 个孤儿归档(当前清单里没有任何条目指向,恢复不到,注意别误删):" -f $orphanArchives.Count) -Level WARN foreach ($orphan in $orphanArchives) { $inManifest = $manifest.items.Contains($orphan.BaseName) Write-Log (" - {0}({1:N1} MB,{2}){3}" -f $orphan.Name, ($orphan.Length / 1MB), $orphan.LastWriteTime, $(if ($inManifest) { ';manifest 里还留着它的历史记录,但清单里已经没有了' } else { '' })) -Level WARN } } else { Write-Log '孤儿归档审计:没有发现(所有归档都有清单条目指向)' -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