From 187549e2af5151a6afbe0b66574c16adf4eda8a5 Mon Sep 17 00:00:00 2001 From: Shuery <2463253700@qq.com> Date: Sun, 27 Sep 2026 14:26:59 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=BF=90=E8=A1=8C=E7=BB=93=E5=B0=BE?= =?UTF-8?q?=E8=BE=93=E5=87=BA=E5=88=86=E7=B1=BB=E8=AF=A6=E7=BB=86=E7=9A=84?= =?UTF-8?q?=E7=BB=93=E6=9E=9C=E6=B1=87=E6=80=BB=EF=BC=88=E6=88=90=E5=8A=9F?= =?UTF-8?q?/=E8=B7=B3=E8=BF=87/=E5=A4=B1=E8=B4=A5/=E5=AE=89=E5=85=A8/?= =?UTF-8?q?=E5=AD=A4=E5=84=BF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原先结尾只有一行计数加一个失败清单。计数回答有几个,而人真正要读的是哪几个、为什么——尤其当失败或跳过发生在你没盯着屏幕的时候(计划任务)。 新的 Write-BakNRetRunSummary 按动作分组逐条列出:成功组带归档名/体积/耗时/sha256 前 12 位、以及有文件没打进归档的警告标记;跳过组带具体原因(源未更新/源不存在/路径无效);失败组带退出码与原因原文,并且再单列一遍。另有安全描述符与孤儿归档两段,空分组不打印,末尾给耗时。 数据来源是 manifest 里本次运行写下的记录(按 finishedAt 落在运行窗口内筛),而不是让调用方另维护一份清单。 搬的过程里翻车一次并被自己的断言拦住:Get-RecField 只用 PSObject.Properties.Name 判字段存在,而那是 PSCustomObject 的形态;本进程新造的记录是 [ordered] 字典,于是字段一律读成 $null,现象是"本次运行没有写下任何条目记录"(记录明明在)。现在两种形态都认。 一个已知没做的:排除规则只在内联逐条打印,没进汇总——我没定位到那个打印点,不愿意凭猜往条目循环里插桩。 断言:零依赖套件新增一条,用真日志文件验收汇总内容,并断言空分组不出现。验收:test.ps1 9/9 全绿(7 与 5.1)。 --- Backup.ps1 | 19 +-- BakNRet/BakNRet.psd1 | 1 + BakNRet/BakNRet.psm1 | 3 + BakNRet/Public/Write-BakNRetRunSummary.ps1 | 137 +++++++++++++++++++++ Restore.ps1 | 15 +-- tests/Run-Tests.ps1 | 36 ++++++ 6 files changed, 186 insertions(+), 25 deletions(-) create mode 100644 BakNRet/Public/Write-BakNRetRunSummary.ps1 diff --git a/Backup.ps1 b/Backup.ps1 index 629d817..0c88c53 100644 --- a/Backup.ps1 +++ b/Backup.ps1 @@ -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 } diff --git a/BakNRet/BakNRet.psd1 b/BakNRet/BakNRet.psd1 index 239d0cc..ce9de90 100644 --- a/BakNRet/BakNRet.psd1 +++ b/BakNRet/BakNRet.psd1 @@ -19,6 +19,7 @@ CompatiblePSEditions = @('Desktop', 'Core') FunctionsToExport = @( + 'Write-BakNRetRunSummary', 'Save-BakNRetItemRecord', 'New-BakNRetItemRecord', 'Find-BakNRet7zExecutable', diff --git a/BakNRet/BakNRet.psm1 b/BakNRet/BakNRet.psm1 index 1559181..19a7195 100644 --- a/BakNRet/BakNRet.psm1 +++ b/BakNRet/BakNRet.psm1 @@ -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', diff --git a/BakNRet/Public/Write-BakNRetRunSummary.ps1 b/BakNRet/Public/Write-BakNRetRunSummary.ps1 new file mode 100644 index 0000000..4f7cc1d --- /dev/null +++ b/BakNRet/Public/Write-BakNRetRunSummary.ps1 @@ -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 +} diff --git a/Restore.ps1 b/Restore.ps1 index 1e552cb..85239be 100644 --- a/Restore.ps1 +++ b/Restore.ps1 @@ -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 } diff --git a/tests/Run-Tests.ps1 b/tests/Run-Tests.ps1 index c4bd481..cd30bbf 100644 --- a/tests/Run-Tests.ps1 +++ b/tests/Run-Tests.ps1 @@ -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