Files
BakNRet/BakNRet/Public/Write-BakNRetRunSummary.ps1
T
Shuery 187549e2af feat: 运行结尾输出分类详细的结果汇总(成功/跳过/失败/安全/孤儿)
原先结尾只有一行计数加一个失败清单。计数回答有几个,而人真正要读的是哪几个、为什么——尤其当失败或跳过发生在你没盯着屏幕的时候(计划任务)。

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

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

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

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

断言:零依赖套件新增一条,用真日志文件验收汇总内容,并断言空分组不出现。验收:test.ps1 9/9 全绿(7 与 5.1)。
2026-09-27 14:26:59 +08:00

138 lines
7.2 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.
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
}