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:
1 parent
e8f0a5ae19
commit
187549e2af
6 files changed
+186
-25
No files matched your search
+4
-15
@@ -103,6 +103,7 @@ $catalogPath = Resolve-BakNRetCatalogPath -Configured $script:Config.SoftwareCat
|
||||
$manifestPath = Join-Path $BackupDir 'manifest.json'
|
||||
|
||||
$logPath = Start-BakNRetLog -Directory $logDir -Prefix 'backup'
|
||||
$runStartedAt = Get-Date
|
||||
Write-BakNRetLog "日志文件:$logPath"
|
||||
Write-BakNRetLog "备份目录:$BackupDir"
|
||||
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) {
|
||||
Write-BakNRetLog '失败条目:' -Level ERROR
|
||||
foreach ($failure in $failures) { Write-BakNRetLog " - $failure" -Level ERROR }
|
||||
}
|
||||
|
||||
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
|
||||
$counterText = @{ 成功 = $processed; 跳过 = $skipped; 失败 = $failed }
|
||||
if ($DryRun) { $counterText['试运行计划'] = $planned }
|
||||
Write-BakNRetRunSummary -Mode 'backup' -Manifest $manifest -StartedAt $runStartedAt -Failures $failures -Counters $counterText -OrphanArchives @($orphanArchives | Where-Object { $_ }) -SecurityFailed $securityFailed -SecurityErrorCount $securityErrorCount
|
||||
|
||||
$logPath = Get-BakNRetLogPath
|
||||
if ($logPath) { Write-BakNRetLog "日志已写入:$logPath" -Level INFO }
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
CompatiblePSEditions = @('Desktop', 'Core')
|
||||
|
||||
FunctionsToExport = @(
|
||||
'Write-BakNRetRunSummary',
|
||||
'Save-BakNRetItemRecord',
|
||||
'New-BakNRetItemRecord',
|
||||
'Find-BakNRet7zExecutable',
|
||||
|
||||
@@ -142,7 +142,10 @@
|
||||
|
||||
. (Join-Path $PSScriptRoot 'Public\Save-BakNRetItemRecord.ps1')
|
||||
|
||||
. (Join-Path $PSScriptRoot 'Public\Write-BakNRetRunSummary.ps1')
|
||||
|
||||
Export-ModuleMember -Function @(
|
||||
'Write-BakNRetRunSummary',
|
||||
'Save-BakNRetItemRecord',
|
||||
'New-BakNRetItemRecord',
|
||||
'Find-BakNRet7zExecutable',
|
||||
|
||||
@@ -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
@@ -93,6 +93,7 @@ $catalogPath = Resolve-BakNRetCatalogPath -Configured $script:Config.SoftwareCat
|
||||
$manifestPath = Join-Path $BackupDir 'manifest.json'
|
||||
|
||||
$logPath = Start-BakNRetLog -Directory $logDir -Prefix 'restore'
|
||||
$runStartedAt = Get-Date
|
||||
Write-BakNRetLog "日志文件:$logPath"
|
||||
Write-BakNRetLog "备份目录:$BackupDir"
|
||||
Write-BakNRetLog ("软件名录:{0}{1}" -f $catalogPath, $(if (Test-Path -LiteralPath $catalogPath) { '' } else { '(不存在,将只支持字面路径)' }))
|
||||
@@ -843,16 +844,10 @@ catch {
|
||||
Write-BakNRetLog "manifest 写回失败(不影响本次恢复):$_" -Level WARN
|
||||
}
|
||||
|
||||
if ($failures.Count -gt 0) {
|
||||
Write-BakNRetLog '失败条目:' -Level ERROR
|
||||
foreach ($failure in $failures) { Write-BakNRetLog " - $failure" -Level ERROR }
|
||||
}
|
||||
|
||||
$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
|
||||
$summaryMode = if ($VerifyOnly) { 'verify' } else { 'restore' }
|
||||
$counterText = @{ 成功 = $stats.restored; 跳过 = $stats.skipped; 失败 = $stats.failed; 校验通过 = $stats.verified }
|
||||
if ($stats.planned -gt 0) { $counterText['试运行计划'] = $stats.planned }
|
||||
Write-BakNRetRunSummary -Mode $summaryMode -Manifest $manifest -StartedAt $runStartedAt -Failures $failures -Counters $counterText -SecurityApplied $securityApplied
|
||||
|
||||
$logPath = Get-BakNRetLogPath
|
||||
if ($logPath) { Write-BakNRetLog "日志已写入:$logPath" -Level INFO }
|
||||
|
||||
@@ -1384,6 +1384,42 @@ Test-Case '条目记录的字段名与顺序是 manifest 的 schema(顺序变
|
||||
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 $itemSandbox -Recurse -Force -ErrorAction SilentlyContinue
|
||||
|
||||
Reference in new issue
Block a user