新功能 - 新增 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 才能验,已实跑确认。
479 lines
19 KiB
PowerShell
479 lines
19 KiB
PowerShell
<#
|
||
.SYNOPSIS
|
||
按 BackupList.txt 执行恢复。
|
||
|
||
.DESCRIPTION
|
||
与旧版相比的核心变化:
|
||
|
||
1. 归档查找以 manifest.json 为准(按归档基础名索引),拿不到才退回
|
||
"从文件名反推路径"。旧版只靠文件名反推,且用 -Filter "$baseName.*" 通配匹配,
|
||
一旦解析出偏差,归档就变成谁都找不到的孤儿。
|
||
2. 退出码可靠:三条解压分支(7z / RAR / tar)统一走 Invoke-ExternalCommand。
|
||
旧版 tar 分支写成 `$LASTEXITCODE -ne 0 -and $proc.ExitCode -ne 0`,
|
||
而 $LASTEXITCODE 是上一条原生命令的残留值,跟 Start-Process 无关,
|
||
恰为 0 时会把解压失败吞掉并报成功。
|
||
3. 支持 -WhatIf / -DryRun:恢复是会覆盖 E:\CodeSpace、Edge User Data 这种
|
||
真实目录的破坏性操作,必须能先看清单再决定。
|
||
4. 支持 -Only / -Skip 只恢复指定条目,-VerifyOnly 只校验不写盘。
|
||
5. 结尾按失败数 exit。
|
||
#>
|
||
|
||
[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')]
|
||
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,
|
||
|
||
# 只打印计划,不解压(等价于 -WhatIf)
|
||
[Parameter()]
|
||
[switch]$DryRun,
|
||
|
||
# 只对归档做 7z t 校验,不解压
|
||
[Parameter()]
|
||
[switch]$VerifyOnly
|
||
)
|
||
|
||
$ErrorActionPreference = 'Stop'
|
||
|
||
if ($DryRun) { $WhatIfPreference = $true }
|
||
|
||
# ============================================================================
|
||
# 载入依赖
|
||
# ============================================================================
|
||
|
||
$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
|
||
$SupportedFormats = @('.7z', '.rar', '.zip', '.tar')
|
||
|
||
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
|
||
$catalogPath = Resolve-CatalogPath -Configured $script:Config.SoftwareCatalog -Root $PSScriptRoot
|
||
$manifestPath = Join-Path $BackupDir 'manifest.json'
|
||
|
||
$logPath = Start-BaknretLog -Directory $logDir -Prefix 'restore'
|
||
Write-Log "日志文件:$logPath"
|
||
Write-Log "备份目录:$BackupDir"
|
||
Write-Log ("软件名录:{0}{1}" -f $catalogPath, $(if (Test-Path -LiteralPath $catalogPath) { '' } else { '(不存在,将只支持字面路径)' }))
|
||
if ($WhatIfPreference) { Write-Log '试运行模式(-WhatIf / -DryRun):不会写入任何文件' -Level WARN }
|
||
|
||
if (-not (Test-Administrator)) {
|
||
Write-Log '建议以管理员身份运行以获取完整的目录访问权限' -Level WARN
|
||
}
|
||
|
||
$passwordFile = if ($KeyFile) { $KeyFile } else { $script:Config.Encryption.PasswordFile }
|
||
$password = Get-BaknretPassword -PasswordFile $passwordFile
|
||
|
||
# ============================================================================
|
||
# 归档查找
|
||
# ============================================================================
|
||
|
||
function Find-ArchiveByBaseName {
|
||
<#
|
||
.SYNOPSIS
|
||
按归档基础名精确定位归档文件。
|
||
|
||
.DESCRIPTION
|
||
旧版用 Get-ChildItem -Filter "$baseName.*",-Filter 会做通配符解释,
|
||
路径里含 `[` `]` 时会失配;这里改为精确比较 BaseName。
|
||
#>
|
||
param([string]$BaseName)
|
||
|
||
$candidate = Get-ChildItem -LiteralPath $BackupDir -File -Force -ErrorAction SilentlyContinue | Where-Object { $_.BaseName -eq $BaseName -and $_.Extension.ToLower() -in $SupportedFormats } |
|
||
Select-Object -First 1
|
||
return $candidate
|
||
}
|
||
|
||
function Get-ArchiveForEntry {
|
||
param($Entry, $Manifest)
|
||
|
||
if ($Manifest -and $Manifest.items.Contains($Entry.baseName)) {
|
||
$record = $Manifest.items[$Entry.baseName]
|
||
$archiveName = $null
|
||
if ($record.PSObject.Properties.Name -contains 'archive') { $archiveName = $record.archive }
|
||
if ($archiveName) {
|
||
$path = Join-Path $BackupDir $archiveName
|
||
if (Test-Path -LiteralPath $path) {
|
||
return [pscustomobject]@{ File = (Get-Item -LiteralPath $path); Source = 'manifest'; Record = $record }
|
||
}
|
||
Write-Log "manifest 记录的归档不存在,回退按文件名查找:$archiveName" -Level WARN
|
||
}
|
||
}
|
||
|
||
$fallback = Find-ArchiveByBaseName -BaseName $Entry.baseName
|
||
if ($fallback) {
|
||
$record = $null
|
||
if ($Manifest -and $Manifest.items.Contains($Entry.baseName)) { $record = $Manifest.items[$Entry.baseName] }
|
||
return [pscustomobject]@{ File = $fallback; Source = 'filename'; Record = $record }
|
||
}
|
||
|
||
return $null
|
||
}
|
||
|
||
function Get-7zExecutable {
|
||
<# .SYNOPSIS 定位 7z.exe(PATH 优先,其次是常见安装位置)。 #>
|
||
$sevenZip = Get-Command 7z -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source
|
||
if (-not $sevenZip) {
|
||
$candidates = @(
|
||
(Join-Path $env:ProgramFiles '7-Zip\7z.exe'),
|
||
(Join-Path ${env:ProgramFiles(x86)} '7-Zip\7z.exe')
|
||
)
|
||
$sevenZip = $candidates | Where-Object { $_ -and (Test-Path -LiteralPath $_) } | Select-Object -First 1
|
||
}
|
||
return $sevenZip
|
||
}
|
||
|
||
function Invoke-Extraction {
|
||
param([object]$ArchiveFile, [string]$DestinationPath)
|
||
|
||
$extension = $ArchiveFile.Extension.ToLower()
|
||
$destParent = Split-Path -Path $DestinationPath -Parent
|
||
|
||
if (-not (Test-Path -LiteralPath $destParent)) {
|
||
New-Item -ItemType Directory -Path $destParent -Force | Out-Null
|
||
}
|
||
|
||
# 归档布局与历史保持一致:根目录就是源目录名(软件名条目也一样,
|
||
# 软件名只用于归档文件名),因此直接整包解压到目标的父目录即可。
|
||
$sevenZip = Get-7zExecutable
|
||
if ($sevenZip) {
|
||
Write-Log '使用 7z 解压' -Level DEBUG
|
||
$argument = @('x', '-bsp2', '-y', "-o$destParent")
|
||
if ($password) { $argument += "-p$password" }
|
||
$argument += $ArchiveFile.FullName
|
||
|
||
$exitCode = Invoke-ExternalCommand -FilePath $sevenZip -ArgumentList $argument
|
||
if ($exitCode -ne 0) { throw "7z 解压失败(退出码:$exitCode)" }
|
||
return $true
|
||
}
|
||
|
||
switch ($extension) {
|
||
'.rar' {
|
||
$rarExe = Get-Command rar, unrar -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source
|
||
if (-not $rarExe) { throw '未找到 RAR 工具' }
|
||
Write-Log '使用 RAR 解压' -Level DEBUG
|
||
$argument = @('x', '-idp', '-idn', '-y', $ArchiveFile.FullName, "$destParent\")
|
||
$exitCode = Invoke-ExternalCommand -FilePath $rarExe -ArgumentList $argument
|
||
if ($exitCode -ne 0) { throw "RAR 解压失败(退出码:$exitCode)" }
|
||
}
|
||
'.zip' {
|
||
Write-Log '使用内置 ZIP 解压' -Level DEBUG
|
||
Expand-Archive -LiteralPath $ArchiveFile.FullName -DestinationPath $destParent -Force
|
||
}
|
||
'.tar' {
|
||
$tarExe = Get-Command tar -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source
|
||
if (-not $tarExe) { throw '未找到 TAR 工具' }
|
||
Write-Log '使用 TAR 解压' -Level DEBUG
|
||
$exitCode = Invoke-ExternalCommand -FilePath $tarExe -ArgumentList @('-xf', $ArchiveFile.FullName, '-C', $destParent)
|
||
if ($exitCode -ne 0) { throw "TAR 解压失败(退出码:$exitCode)" }
|
||
}
|
||
default { throw "不支持的文件格式:$extension" }
|
||
}
|
||
return $true
|
||
}
|
||
|
||
# ============================================================================
|
||
# 准备
|
||
# ============================================================================
|
||
|
||
if (-not (Test-Path -LiteralPath $BackupDir)) {
|
||
Write-Log "备份目录不存在: $BackupDir" -Level ERROR
|
||
Stop-BaknretLog
|
||
exit 1
|
||
}
|
||
|
||
$manifest = Read-BaknretManifest -Path $manifestPath
|
||
|
||
if (-not (Test-Path -LiteralPath $BackupListPath)) {
|
||
Write-Log '未找到配置文件,正在从备份内容生成...' -Level INFO
|
||
|
||
$paths = @()
|
||
|
||
if ($manifest.items.Count -gt 0) {
|
||
foreach ($key in $manifest.items.Keys) {
|
||
$record = $manifest.items[$key]
|
||
if ($record.PSObject.Properties.Name -contains 'source' -and $record.source) {
|
||
$paths += $record.source
|
||
}
|
||
}
|
||
}
|
||
|
||
if ($paths.Count -eq 0) {
|
||
$backupFiles = Get-ChildItem -LiteralPath $BackupDir -File -Force -ErrorAction SilentlyContinue |
|
||
Where-Object { $_.Extension.ToLower() -in $SupportedFormats -and $_.BaseName -match '_from_' }
|
||
foreach ($file in $backupFiles) {
|
||
$original = Convert-BackupFileNameToPath -FileName $file.Name
|
||
if ($original) { $paths += $original }
|
||
}
|
||
}
|
||
|
||
$paths = @($paths | Sort-Object -Unique)
|
||
if ($paths.Count -eq 0) {
|
||
Write-Log '无法从备份内容还原出任何路径。' -Level ERROR
|
||
Stop-BaknretLog
|
||
exit 1
|
||
}
|
||
|
||
$content = "# BackupList.txt(自动生成,排除规则需要手工补回)`n" + (($paths -join [Environment]::NewLine) + [Environment]::NewLine)
|
||
[System.IO.File]::WriteAllText($BackupListPath, $content, [System.Text.UTF8Encoding]::new($false))
|
||
Write-Log "已生成配置,包含 $($paths.Count) 个项目,请检查后重新运行" -Level INFO
|
||
Stop-BaknretLog
|
||
exit 0
|
||
}
|
||
|
||
function Test-EntrySelected {
|
||
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
|
||
}
|
||
|
||
# ============================================================================
|
||
# 主流程
|
||
# ============================================================================
|
||
|
||
$lines = Get-Content -LiteralPath $BackupListPath -ErrorAction Stop
|
||
$stats = @{ restored = 0; skipped = 0; failed = 0; verified = 0; planned = 0 }
|
||
$failures = @()
|
||
$referencedArchives = @()
|
||
|
||
Write-Log '开始执行恢复' -Level INFO
|
||
|
||
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
|
||
$baseName = $resolved.BaseName
|
||
|
||
if (-not $baseName) { $stats.skipped++; continue }
|
||
if (-not (Test-EntrySelected -DisplayPath $displayPath -BaseName $baseName)) { continue }
|
||
|
||
$found = Get-ArchiveForEntry -Entry ([pscustomobject]@{ baseName = $baseName }) -Manifest $manifest
|
||
if (-not $found) {
|
||
Write-Log "跳过: $displayPath,未找到归档 $baseName" -Level WARN
|
||
$stats.skipped++
|
||
continue
|
||
}
|
||
|
||
# 恢复目的地。清单条目可能带多个源(同名目录分散在多处),每个源各恢复各的。
|
||
$targets = @()
|
||
if ($resolved.Sources.Count -gt 0) {
|
||
foreach ($source in $resolved.Sources) {
|
||
$targets += [pscustomobject]@{ DestPath = $source.SourcePath }
|
||
}
|
||
} else {
|
||
$targets += [pscustomobject]@{ DestPath = [Environment]::ExpandEnvironmentVariables($displayPath) }
|
||
}
|
||
|
||
# 防御:解析不出目的地时明确失败,别把空字符串喂给 Split-Path/Test-Path
|
||
# (那种报错是 "Cannot bind argument to parameter 'Path' because it is an empty string")
|
||
$targets = @($targets | Where-Object { $_.DestPath -and -not [string]::IsNullOrWhiteSpace($_.DestPath) })
|
||
if ($targets.Count -eq 0) {
|
||
$reason = "无法确定恢复目的地(清单条目 '$displayPath' 解析不出任何源路径)"
|
||
Write-Log "失败: $displayPath,$reason" -Level ERROR
|
||
$stats.failed++
|
||
$failures += $displayPath
|
||
continue
|
||
}
|
||
|
||
$destPath = $targets[0].DestPath
|
||
|
||
$archiveFile = $found.File
|
||
$referencedArchives += $archiveFile.BaseName
|
||
|
||
# 加密归档在取不到口令时必须直接失败:7z 在没有 -p 时会在控制台等输入,
|
||
# 在计划任务里会静默挂起,比报错更糟。
|
||
$isEncrypted = $false
|
||
if ($found.Record -and ($found.Record.PSObject.Properties.Name -contains 'encrypted')) {
|
||
$isEncrypted = [bool]$found.Record.encrypted
|
||
}
|
||
if ($isEncrypted -and -not $password) {
|
||
Write-Log "失败: $displayPath,归档已加密但取不到口令(设置 BAKNRET_PASSWORD 或用 -KeyFile 指定密码文件)" -Level ERROR
|
||
$stats.failed++
|
||
$failures += $displayPath
|
||
continue
|
||
}
|
||
|
||
if ($VerifyOnly) {
|
||
if ($archiveFile.Extension.ToLower() -ne '.7z') {
|
||
Write-Log "跳过校验(非 7z): $($archiveFile.Name)" -Level DEBUG
|
||
continue
|
||
}
|
||
$verifyTool = Get-7zExecutable
|
||
if (-not $verifyTool) {
|
||
Write-Log '未找到 7z,无法校验' -Level ERROR
|
||
$stats.failed++
|
||
$failures += $displayPath
|
||
continue
|
||
}
|
||
$verifyArgument = @('t', '-bso0', '-bsp0')
|
||
if ($password) { $verifyArgument += "-p$password" }
|
||
$verifyArgument += $archiveFile.FullName
|
||
$verifyCode = Invoke-ExternalCommand -FilePath $verifyTool -ArgumentList $verifyArgument
|
||
if ($verifyCode -eq 0) {
|
||
Write-Log "校验通过: $($archiveFile.Name)" -Level INFO
|
||
$stats.verified++
|
||
} else {
|
||
Write-Log "校验失败: $($archiveFile.Name)(退出码 $verifyCode)" -Level ERROR
|
||
$stats.failed++
|
||
$failures += $displayPath
|
||
}
|
||
continue
|
||
}
|
||
|
||
Write-Log "准备恢复: $displayPath <- $($archiveFile.Name)(来源:$($found.Source))" -Level INFO
|
||
|
||
if ((Test-Path -LiteralPath $destPath) -and -not $Force) {
|
||
try {
|
||
$destSummary = Get-FolderSummary -FolderPath $destPath
|
||
$archiveTime = $archiveFile.LastWriteTime
|
||
if ($destSummary.LatestModifiedTime -and $destSummary.LatestModifiedTime -gt $archiveTime) {
|
||
Write-Log "跳过: $displayPath,目标目录比归档新(用 -Force 覆盖)" -Level WARN
|
||
$stats.skipped++
|
||
continue
|
||
}
|
||
} catch {
|
||
Write-Log "目标目录摘要读取失败,继续恢复:$_" -Level DEBUG
|
||
}
|
||
}
|
||
|
||
$plannedTargets = @($targets | Where-Object { $_.DestPath })
|
||
foreach ($target in $plannedTargets) {
|
||
if (Test-Path -LiteralPath $target.DestPath) { continue }
|
||
# Split-Path -Parent 对根路径(如 "E:\")返回空串,此时无父目录可建
|
||
$targetParent = Split-Path -Path $target.DestPath -Parent
|
||
if ($targetParent) {
|
||
Write-Log "提示: 目标不存在,将新建 $targetParent" -Level DEBUG
|
||
} else {
|
||
Write-Log "提示: 目标不存在,且没有可创建的父目录:$($target.DestPath)" -Level DEBUG
|
||
}
|
||
}
|
||
|
||
$shouldRun = $true
|
||
foreach ($target in $plannedTargets) {
|
||
if (-not $PSCmdlet.ShouldProcess($target.DestPath, "从 $($archiveFile.Name) 解压")) { $shouldRun = $false }
|
||
}
|
||
|
||
if (-not $shouldRun) {
|
||
foreach ($target in $plannedTargets) {
|
||
Write-Log "[试运行] 将解压 $($archiveFile.Name) -> $($target.DestPath)" -Level INFO
|
||
}
|
||
$stats.planned++
|
||
continue
|
||
}
|
||
|
||
$restoreFailed = $false
|
||
try {
|
||
foreach ($target in $plannedTargets) {
|
||
if (-not (Invoke-Extraction -ArchiveFile $archiveFile -DestinationPath $target.DestPath)) {
|
||
$restoreFailed = $true
|
||
break
|
||
}
|
||
}
|
||
|
||
if (-not $restoreFailed) {
|
||
$stats.restored++
|
||
Write-Log "恢复成功: $baseName" -Level INFO
|
||
|
||
if ($manifest.items.Contains($baseName)) {
|
||
$record = $manifest.items[$baseName]
|
||
if ($record -is [System.Collections.IDictionary]) {
|
||
$record['lastRestoreAt'] = (Get-Date).ToString('o')
|
||
} else {
|
||
$record | Add-Member -NotePropertyName lastRestoreAt -NotePropertyValue ((Get-Date).ToString('o')) -Force
|
||
}
|
||
}
|
||
} else {
|
||
$stats.failed++
|
||
$failures += $displayPath
|
||
}
|
||
} catch {
|
||
Write-Log "恢复失败: $displayPath,$_" -Level ERROR
|
||
$stats.failed++
|
||
$failures += $displayPath
|
||
}
|
||
}
|
||
|
||
# ============================================================================
|
||
# 收尾:报告孤儿归档
|
||
# ============================================================================
|
||
|
||
if (-not $VerifyOnly) {
|
||
$orphans = @(Get-ChildItem -LiteralPath $BackupDir -File -Force -ErrorAction SilentlyContinue |
|
||
Where-Object { $_.Extension.ToLower() -in $SupportedFormats -and $_.BaseName -notin $referencedArchives })
|
||
|
||
if ($orphans.Count -gt 0) {
|
||
Write-Log '以下归档没有任何清单条目指向(恢复不到,注意别误删):' -Level WARN
|
||
foreach ($orphan in $orphans) {
|
||
Write-Log (" - {0}({1} MB,{2})" -f $orphan.Name, [math]::Round($orphan.Length / 1MB, 2), $orphan.LastWriteTime) -Level WARN
|
||
}
|
||
}
|
||
}
|
||
|
||
try {
|
||
Write-BaknretManifest -Path $manifestPath -Manifest $manifest | Out-Null
|
||
} catch {
|
||
Write-Log "manifest 写回失败(不影响本次恢复):$_" -Level WARN
|
||
}
|
||
|
||
if ($failures.Count -gt 0) {
|
||
Write-Log '失败条目:' -Level ERROR
|
||
foreach ($failure in $failures) { Write-Log " - $failure" -Level ERROR }
|
||
}
|
||
|
||
$summaryText = "恢复完成 - 成功:$($stats.restored),跳过:$($stats.skipped),失败:$($stats.failed)"
|
||
if ($VerifyOnly) { $summaryText = "校验完成 - 通过:$($stats.verified),失败:$($stats.failed)" }
|
||
if ($stats.planned -gt 0) { $summaryText += ",试运行计划:$($stats.planned)" }
|
||
Write-Log $summaryText -Level INFO
|
||
|
||
$logPath = Get-BaknretLogPath
|
||
if ($logPath) { Write-Log "日志已写入:$logPath" -Level INFO }
|
||
Stop-BaknretLog
|
||
|
||
if ($stats.failed -gt 0) { exit 1 }
|
||
exit 0
|