feat: 运行结尾输出分类详细的结果汇总(成功/跳过/失败/安全/孤儿)

原先结尾只有一行计数加一个失败清单。计数回答有几个,而人真正要读的是哪几个、为什么——尤其当失败或跳过发生在你没盯着屏幕的时候(计划任务)。

新的 Write-BakNRetRunSummary 按动作分组逐条列出:成功组带归档名/体积/耗时/sha256 前 12 位、以及有文件没打进归档的警告标记;跳过组带具体原因(源未更新/源不存在/路径无效);失败组带退出码与原因原文,并且再单列一遍。另有安全描述符与孤儿归档两段,空分组不打印,末尾给耗时。

数据来源是 manifest 里本次运行写下的记录(按 finishedAt 落在运行窗口内筛),而不是让调用方另维护一份清单。

搬的过程里翻车一次并被自己的断言拦住:Get-RecField 只用 PSObject.Properties.Name 判字段存在,而那是 PSCustomObject 的形态;本进程新造的记录是 [ordered] 字典,于是字段一律读成 $null,现象是"本次运行没有写下任何条目记录"(记录明明在)。现在两种形态都认。

一个已知没做的:排除规则只在内联逐条打印,没进汇总——我没定位到那个打印点,不愿意凭猜往条目循环里插桩。

断言:零依赖套件新增一条,用真日志文件验收汇总内容,并断言空分组不出现。验收:test.ps1 9/9 全绿(7 与 5.1)。
This commit is contained in:
Shuery committed 2026-09-27 14:26:59 +08:00
1 parent e8f0a5ae19
commit 187549e2af
6 files changed
+186 -25

No files matched your search

+4 -15
View File
@@ -103,6 +103,7 @@ $catalogPath = Resolve-BakNRetCatalogPath -Configured $script:Config.SoftwareCat
$manifestPath = Join-Path $BackupDir 'manifest.json' $manifestPath = Join-Path $BackupDir 'manifest.json'
$logPath = Start-BakNRetLog -Directory $logDir -Prefix 'backup' $logPath = Start-BakNRetLog -Directory $logDir -Prefix 'backup'
$runStartedAt = Get-Date
Write-BakNRetLog "日志文件:$logPath" Write-BakNRetLog "日志文件:$logPath"
Write-BakNRetLog "备份目录:$BackupDir" Write-BakNRetLog "备份目录:$BackupDir"
Write-BakNRetLog ("软件名录:{0}{1}" -f $catalogPath, $(if (Test-Path -LiteralPath $catalogPath) { '' } else { '(不存在,将只支持字面路径)' })) Write-BakNRetLog ("软件名录:{0}{1}" -f $catalogPath, $(if (Test-Path -LiteralPath $catalogPath) { '' } else { '(不存在,将只支持字面路径)' }))
@@ -826,21 +827,9 @@ if (-not $DryRun -and $Only.Count -eq 0 -and $Skip.Count -eq 0) {
} }
} }
if ($failures.Count -gt 0) { $counterText = @{ 成功 = $processed; 跳过 = $skipped; 失败 = $failed }
Write-BakNRetLog '失败条目:' -Level ERROR if ($DryRun) { $counterText['试运行计划'] = $planned }
foreach ($failure in $failures) { Write-BakNRetLog " - $failure" -Level ERROR } Write-BakNRetRunSummary -Mode 'backup' -Manifest $manifest -StartedAt $runStartedAt -Failures $failures -Counters $counterText -OrphanArchives @($orphanArchives | Where-Object { $_ }) -SecurityFailed $securityFailed -SecurityErrorCount $securityErrorCount
}
if ($securityFailed -gt 0) {
Write-BakNRetLog ("有 {0} 个条目的安全描述符完全没能存下来(manifest 的 security.error 里有原文)" -f $securityFailed) -Level WARN
}
if ($securityErrorCount -gt 0) {
Write-BakNRetLog ("有 {0} 个条目存在'读不到安全描述符'的对象;恢复后这些对象的属主/ACL 是新建对象的默认值,可查 manifest 的 security.errors" -f $securityErrorCount) -Level WARN
}
$summaryText = "备份完成。成功: $processed, 跳过: $skipped, 失败: $failed"
if ($DryRun) { $summaryText += ", 试运行计划: $planned" }
Write-BakNRetLog $summaryText -Level INFO
$logPath = Get-BakNRetLogPath $logPath = Get-BakNRetLogPath
if ($logPath) { Write-BakNRetLog "日志已写入:$logPath" -Level INFO } if ($logPath) { Write-BakNRetLog "日志已写入:$logPath" -Level INFO }
+1
View File
@@ -19,6 +19,7 @@
CompatiblePSEditions = @('Desktop', 'Core') CompatiblePSEditions = @('Desktop', 'Core')
FunctionsToExport = @( FunctionsToExport = @(
'Write-BakNRetRunSummary',
'Save-BakNRetItemRecord', 'Save-BakNRetItemRecord',
'New-BakNRetItemRecord', 'New-BakNRetItemRecord',
'Find-BakNRet7zExecutable', 'Find-BakNRet7zExecutable',
+3
View File
@@ -142,7 +142,10 @@
. (Join-Path $PSScriptRoot 'Public\Save-BakNRetItemRecord.ps1') . (Join-Path $PSScriptRoot 'Public\Save-BakNRetItemRecord.ps1')
. (Join-Path $PSScriptRoot 'Public\Write-BakNRetRunSummary.ps1')
Export-ModuleMember -Function @( Export-ModuleMember -Function @(
'Write-BakNRetRunSummary',
'Save-BakNRetItemRecord', 'Save-BakNRetItemRecord',
'New-BakNRetItemRecord', 'New-BakNRetItemRecord',
'Find-BakNRet7zExecutable', 'Find-BakNRet7zExecutable',
+137
View File
@@ -0,0 +1,137 @@
function Write-BakNRetRunSummary {
<#
.SYNOPSIS
在运行结尾打印分类详细的结果汇总(成功 / 跳过 / 失败 / 安全 / 孤儿归档)。
.DESCRIPTION
为什么需要它:结尾原先只有一行计数加一个失败清单。计数回答"有几个",而人真正要读的是
"哪几个、为什么" —— 尤其当失败或跳过发生在你没盯着屏幕的时候(计划任务)。
数据来源是 manifest 里**本次运行**写下的记录(按 finishedAt 落在运行窗口内筛),而不是让
调用方另外维护一份清单:那正是"两份数据会不一致"的老问题,也是这个仓库踩过的坑。
按动作分组,每组逐条列出,条目带原因与体量;失败额外带退出码与原因原文。
#>
param(
[Parameter(Mandatory = $true)][ValidateSet('backup', 'restore', 'verify')][string]$Mode,
$Manifest,
[Parameter(Mandatory = $true)][datetime]$StartedAt,
[string[]]$Failures = @(),
[hashtable]$Counters = @{},
$OrphanArchives = @(),
[int]$SecurityFailed = 0,
[int]$SecurityErrorCount = 0,
[int]$SecurityApplied = 0
)
function Get-RecField {
param($Record, [string]$Name)
# 两种数据形态都要认:manifest 从 JSON 读回来是 PSCustomObject(走 PSObject.Properties),
# 而本进程新造的记录是 [ordered] 字典(走 Contains + 索引器)。
# 第一版只写了 PSObject 那条路,于是对字典型的记录一律返回 $null —— 表现为
# "本次运行没有写下任何条目记录",而记录其实好端端地在就不会被列出来。
if ($Record -is [System.Collections.IDictionary]) {
if ($Record.Contains($Name)) { return $Record[$Name] }
return $null
}
if ($Record -and ($Record.PSObject.Properties.Name -contains $Name)) { return $Record.$Name }
return $null
}
Write-BakNRetLog '================ 结果汇总 ================' -Level INFO
# 本次运行的记录:按 finishedAt 落在运行窗口内筛(manifest 里还有历次运行的记录)
$thisRun = @()
if ($Manifest -and (Get-RecField $Manifest 'items')) {
foreach ($key in @($Manifest.items.Keys)) {
$record = $Manifest.items[$key]
$finished = Get-RecField $record 'finishedAt'
$stamp = $null
if ($finished) { $stamp = $finished -as [datetime] }
if ($stamp -and $stamp -ge $StartedAt) { $thisRun += $record }
}
}
$groups = [ordered]@{
'backed-up' = @{ Title = '成功'; Level = 'INFO' }
'planned' = @{ Title = '试运行计划(没有真的打包)'; Level = 'INFO' }
'skip-unchanged' = @{ Title = '跳过:源未更新'; Level = 'INFO' }
'missing-source' = @{ Title = '跳过:源不存在'; Level = 'WARN' }
'invalid-path' = @{ Title = '跳过:路径无效'; Level = 'WARN' }
'failed' = @{ Title = '失败'; Level = 'ERROR' }
}
foreach ($action in $groups.Keys) {
$title = $groups[$action].Title
$level = $groups[$action].Level
$bucket = @($thisRun | Where-Object { (Get-RecField $_ 'action') -eq $action })
if ($bucket.Count -eq 0) { continue }
# 成功的那组带上合计体量:这是"这次到底备了多少"最直接的答案
$head = "$title $($bucket.Count) 个"
if ($action -eq 'backed-up') {
$bytes = ($bucket | ForEach-Object { [long](Get-RecField $_ 'sourceBytes') } | Measure-Object -Sum).Sum
if ($bytes -gt 0) { $head += ('(源合计 {0:N1} MB)' -f ($bytes / 1MB)) }
}
Write-BakNRetLog $head -Level $level
foreach ($record in ($bucket | Sort-Object { Get-RecField $_ 'baseName' })) {
$name = Get-RecField $record 'baseName'
$detail = @()
$archive = Get-RecField $record 'archive'
if ($archive) { $detail += $archive }
$archiveBytes = Get-RecField $record 'archiveBytes'
if ($archiveBytes) { $detail += ('{0:N1} MB' -f ([long]$archiveBytes / 1MB)) }
$duration = Get-RecField $record 'durationSec'
if ($duration) { $detail += ('{0:N1} 秒' -f [double]$duration) }
$sha = Get-RecField $record 'sha256'
if ($sha) { $detail += ('sha256 ' + $sha.Substring(0, [Math]::Min(12, $sha.Length))) }
if (Get-RecField $record 'warnings') { $detail += '有文件没打进归档(归档可能不完整)' }
$reason = Get-RecField $record 'reason'
if ($reason) { $detail += $reason }
$roots = Get-RecField $record 'roots'
if ($roots -and @($roots).Count -gt 0) { $detail += ('归档顶层:' + (@($roots) -join '、')) }
$suffix = if ($detail.Count -gt 0) { ' —— ' + ($detail -join ',') } else { '' }
Write-BakNRetLog " - $name$suffix" -Level $level
}
}
if ($thisRun.Count -eq 0) {
Write-BakNRetLog '本次运行没有写下任何条目记录(可能清单为空、或全部被 -Only / -Skip 筛掉)' -Level WARN
}
# 失败清单单独再列一遍:它是这份日志里最该被读到的东西,值得重复且带 Level=ERROR
if ($Failures.Count -gt 0) {
Write-BakNRetLog "失败明细($($Failures.Count) 条,退出码非 0 的判据):" -Level ERROR
foreach ($failure in $Failures) { Write-BakNRetLog " ✗ $failure" -Level ERROR }
}
if ($SecurityFailed -gt 0) {
Write-BakNRetLog "安全描述符:有 $SecurityFailed 个条目完全没存下来(manifest 的 security.error 里有原文)" -Level WARN
}
if ($SecurityErrorCount -gt 0) {
Write-BakNRetLog "安全描述符:有 $SecurityErrorCount 个条目存在读不到安全描述符的对象;那些对象恢复后的属主/ACL 是新建对象的默认值" -Level WARN
}
if ($SecurityApplied -gt 0) {
Write-BakNRetLog "安全描述符:本次回放成功 $SecurityApplied 个对象" -Level INFO
}
if (@($OrphanArchives).Count -gt 0) {
Write-BakNRetLog "孤儿归档:$(@($OrphanArchives).Count) 个(当前清单没有任何条目指向,恢复不到)" -Level WARN
foreach ($orphan in @($OrphanArchives)) {
$note = ''
if ($Manifest -and (Get-RecField $Manifest 'items') -and $Manifest.items.Contains($orphan.BaseName)) { $note = ';manifest 里还留着历史记录' }
Write-BakNRetLog (' - {0}({1:N1} MB,{2}){3}' -f $orphan.Name, ($orphan.Length / 1MB), $orphan.LastWriteTime, $note) -Level WARN
}
}
# 计数:分组之外的"没进记录"的那些(被 -Only / -Skip 筛掉、试运行里没走到等)靠它兜底
if ($Counters.Count -gt 0) {
$pairs = foreach ($k in $Counters.Keys) { '{0} {1}' -f $k, $Counters[$k] }
Write-BakNRetLog ('计数:' + ($pairs -join ',')) -Level INFO
}
$elapsed = (Get-Date) - $StartedAt
Write-BakNRetLog ('耗时:{0:N1} 分钟(开始于 {1:yyyy-MM-dd HH:mm:ss})' -f $elapsed.TotalMinutes, $StartedAt) -Level INFO
Write-BakNRetLog '=========================================' -Level INFO
}
+5 -10
View File
@@ -93,6 +93,7 @@ $catalogPath = Resolve-BakNRetCatalogPath -Configured $script:Config.SoftwareCat
$manifestPath = Join-Path $BackupDir 'manifest.json' $manifestPath = Join-Path $BackupDir 'manifest.json'
$logPath = Start-BakNRetLog -Directory $logDir -Prefix 'restore' $logPath = Start-BakNRetLog -Directory $logDir -Prefix 'restore'
$runStartedAt = Get-Date
Write-BakNRetLog "日志文件:$logPath" Write-BakNRetLog "日志文件:$logPath"
Write-BakNRetLog "备份目录:$BackupDir" Write-BakNRetLog "备份目录:$BackupDir"
Write-BakNRetLog ("软件名录:{0}{1}" -f $catalogPath, $(if (Test-Path -LiteralPath $catalogPath) { '' } else { '(不存在,将只支持字面路径)' })) Write-BakNRetLog ("软件名录:{0}{1}" -f $catalogPath, $(if (Test-Path -LiteralPath $catalogPath) { '' } else { '(不存在,将只支持字面路径)' }))
@@ -843,16 +844,10 @@ catch {
Write-BakNRetLog "manifest 写回失败(不影响本次恢复):$_" -Level WARN Write-BakNRetLog "manifest 写回失败(不影响本次恢复):$_" -Level WARN
} }
if ($failures.Count -gt 0) { $summaryMode = if ($VerifyOnly) { 'verify' } else { 'restore' }
Write-BakNRetLog '失败条目:' -Level ERROR $counterText = @{ 成功 = $stats.restored; 跳过 = $stats.skipped; 失败 = $stats.failed; 校验通过 = $stats.verified }
foreach ($failure in $failures) { Write-BakNRetLog " - $failure" -Level ERROR } if ($stats.planned -gt 0) { $counterText['试运行计划'] = $stats.planned }
} Write-BakNRetRunSummary -Mode $summaryMode -Manifest $manifest -StartedAt $runStartedAt -Failures $failures -Counters $counterText -SecurityApplied $securityApplied
$summaryText = "恢复完成 - 成功:$($stats.restored),跳过:$($stats.skipped),失败:$($stats.failed)"
if ($securityApplied -gt 0) { $summaryText += ",安全描述符:$securityApplied 个对象" }
if ($VerifyOnly) { $summaryText = "校验完成 - 通过:$($stats.verified),失败:$($stats.failed)" }
if ($stats.planned -gt 0) { $summaryText += ",试运行计划:$($stats.planned)" }
Write-BakNRetLog $summaryText -Level INFO
$logPath = Get-BakNRetLogPath $logPath = Get-BakNRetLogPath
if ($logPath) { Write-BakNRetLog "日志已写入:$logPath" -Level INFO } if ($logPath) { Write-BakNRetLog "日志已写入:$logPath" -Level INFO }
+36
View File
@@ -1384,6 +1384,42 @@ Test-Case '条目记录的字段名与顺序是 manifest 的 schema(顺序变
Assert-True ($record.attemptedAt -ne $again.attemptedAt) '两条记录的 attemptedAt 不该相同' Assert-True ($record.attemptedAt -ne $again.attemptedAt) '两条记录的 attemptedAt 不该相同'
} }
Test-Case '结果汇总:分类列出成功/跳过/失败,带原因与失败明细,空分组不打印' {
$dir = Join-Path $env:TEMP ("bnr-summary-" + [guid]::NewGuid().ToString('N').Substring(0, 8))
New-Item -ItemType Directory -Path $dir -Force | Out-Null
$manifest = @{ items = [ordered]@{} }
$started = (Get-Date).AddSeconds(-30)
$specs = @(
@{ N = 'GoodApp'; A = 'backed-up'; R = $null; X = @{ archive = 'GoodApp.7z'; sha256 = 'abcdef1234567890'; sourceBytes = 1048576 } },
@{ N = 'OldApp'; A = 'skip-unchanged'; R = '源最新修改时间不晚于归档时间'; X = @{} },
@{ N = 'BadApp'; A = 'failed'; R = '压缩失败(退出码 2)'; X = @{} }
)
foreach ($spec in $specs) {
$rec = New-BakNRetItemRecord -BaseName $spec.N -Source 'x' -ResolvedSource 'y' -Phase 'backup'
$rec.action = $spec.A
$rec.reason = $spec.R
foreach ($k in $spec.X.Keys) { $rec[$k] = $spec.X[$k] }
$rec.finishedAt = (Get-Date).ToString('o')
$manifest.items[$spec.N] = $rec
}
try {
Start-BakNRetLog -Directory $dir -Prefix 'summary' | Out-Null
Write-BakNRetRunSummary -Mode 'backup' -Manifest $manifest -StartedAt $started -Failures @('BadApp:压缩失败(退出码 2)') -Counters @{ 失败 = 1 }
$logPath = Get-BakNRetLogPath
Stop-BakNRetLog
$text = Get-Content -Encoding UTF8 -LiteralPath $logPath -Raw
Assert-True ($text -match '结果汇总') '应当打印汇总标题'
Assert-True ($text -match 'GoodApp') '应当列出成功的条目'
Assert-True ($text -match '源未更新') '应当有跳过分组'
Assert-True ($text -match '压缩失败(退出码 2)') '应当带上失败原因原文'
Assert-True ($text -match '失败明细') '应当单列失败明细'
Assert-True ($text -notmatch 'missing-source') '空分组不该出现'
} finally {
if (Get-BakNRetLogPath) { Stop-BakNRetLog }
Remove-Item -LiteralPath $dir -Recurse -Force -ErrorAction SilentlyContinue
}
}
# ============================================================================ # ============================================================================
Remove-Item -LiteralPath $sandbox -Recurse -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $sandbox -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $itemSandbox -Recurse -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $itemSandbox -Recurse -Force -ErrorAction SilentlyContinue