Files
BakNRet/Backup.ps1
T
Shuery 045d51ac9c 引入软件名录:清单写软件名,归档名也用软件名
新功能
- 新增 SoftwareCatalog.psd1 —— "软件名 -> 目录"映射表,BackupList.txt 里
  直接写软件名即可,归档名也就是软件名(FooClolor.7z),
  不再是 FooClolor_from_C_+Programs.7z 这种由路径拼出来的名字。
- 三种写法可混用:软件名、字面路径(现有清单无需改写)、软件名 @pathname。
- 名录支持前缀补全(legendary -> legendary_2.0.4,只认 <名>_* / <名>-*)、
  Variants(同名目录在多处)、Includes(分文件维护)。
- tools/Rename-Archives.ps1:存量归档重命名,默认试运行,逐份大小校验并重建 manifest。
- 归档名重复直接报错,不再静默互相覆盖。

两套测试全绿:单元 42 项、端到端 23 项(新增名录命名/解析/迁移用例)。

过程中修掉的缺陷
- Resolve-BackupEntry 里 @pathname 与 Unresolved 分支顺序错误,
  @pathname 会被静默吃掉(改名后仍用软件名)。
- 源目录被删除时解析器丢掉 Sources,导致恢复端把软件名当路径、
  报 "Cannot bind argument to parameter 'Path' because it is an empty string"。
  恢复的语义恰恰是"源不存在就要还原回去",现在 Sources 照旧给出。
- 源存在性检查曾被漏掉,Get-Item 对不存在路径抛异常会中断整轮备份;
  且不能用 Join-Path 探测——目标盘符不存在时它会直接抛异常。
- 计划任务脚本外的 Caller 需要 -DryRun 才能验,已实跑确认。
2026-09-21 20:55:19 +08:00

612 lines
27 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
$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 }
function Invoke-BackupItem {
param(
[string]$SourcePath,
[string[]]$RelativePaths,
[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 }
# 排除模式用**源目录名**作前缀(归档里就是这个名字),
# 与 7z 的路径匹配语义一致;软件名条目的归档根目录是软件名,但源目录名仍在其下一层。
$excludeArgument = Get-ArchiveExcludeArgument -ItemName $ItemName -Patterns $ExcludePatterns
if ($excludeArgument.Count -gt 0) {
Write-Log ("排除 {0} 项:{1}" -f $excludeArgument.Count, ($excludeArgument -join ' ')) -Level DEBUG
}
$sourceListFile = $null
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
foreach ($relative in $RelativePaths) { $argument += $relative }
$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
# RAR 没有 -spf,退回"直接打包源目录",归档根目录就是源目录名
foreach ($relative in $RelativePaths) { $argument += $relative }
$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 标记' }
}
$fullPaths = @($RelativePaths | ForEach-Object { Join-Path $ParentDir $_ })
Compress-Archive -Path $fullPaths -DestinationPath $tempPath -CompressionLevel Optimal -Force
$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 = "$_" }
} finally {
if ($sourceListFile -and (Test-Path -LiteralPath $sourceListFile)) {
Remove-Item -LiteralPath $sourceListFile -Force -ErrorAction SilentlyContinue
}
}
}
# ============================================================================
# 主流程
# ============================================================================
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
$record.roots = @($resolved.Sources | ForEach-Object { $_.RootName })
if ($resolved.CatalogEntry) { $record.catalog = $resolved.CatalogEntry.Path }
$finalPath = Join-Path $BackupDir $record.archive
# 备份列表里写重了会生成两个同名归档,互相覆盖 —— 直接报错,不猜。
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
# 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 探测:目标盘符不存在时它会直接抛异常。
$expectedRoots = 0
$missingRoots = @()
foreach ($source in $resolved.Sources) {
foreach ($relative in $source.RelativePaths) {
$expectedRoots++
$candidate = "$($source.ParentDir.TrimEnd('\'))\$relative"
if (-not (Test-Path -LiteralPath $candidate)) { $missingRoots += $candidate }
}
}
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) {
$live = @($source.RelativePaths | Where-Object { Test-Path -LiteralPath "$($source.ParentDir.TrimEnd('\'))\$_" })
if ($live.Count -gt 0) {
$liveSources += [pscustomobject]@{
RootName = $source.RootName
ParentDir = $source.ParentDir
RelativePaths = $live
SourcePath = $source.SourcePath
}
}
}
$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
}
}
$firstSource = $liveSources[0]
$result = Invoke-BackupItem -SourcePath $firstSource.SourcePath -RelativePaths $firstSource.RelativePaths `
-ItemName $itemName -ParentDir $firstSource.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