让预览与确认如实工作

ShellBeginner
立即练习

介绍

房间变更命令应值得组织者信任:预览不留下任何工作,拒绝不做任何操作,批准则恰好应用完整且符合条件的计划。仅声明支持 WhatIf,并不足以保护每一种文件写入方式。

本独立实验从自己的活动数据和未完成脚本开始。运用前一个引导实验中的基于 ID 的验证,以及 JSON、参数和错误处理知识;不需要先前 VM 中的文件。PowerShell 和合成资源已准备好。你将使用默认 Terminal,执行真实原生确认,并重新打开真实保存的活动数据。时长为作者估计,包含阅读和恢复操作。

将每次写入置于同一个决策之后

本步骤将修复一个误导性的 WhatIf 实现,使预览和拒绝确认都不会改变工作区。

看清初始脚本为何不安全

打开 Terminal,初始运行的是 Linux shell。以仅限当前角色会话的 UTF-8 设置启动干净的 PowerShell 会话。env 仅将这些设置传给本程序;-NoLogo 隐藏横幅,-NoProfile 跳过个人配置文件:

env LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 pwsh -NoLogo -NoProfile

等待 PS 提示符。后续所有命令都是 PowerShell 命令。本实验有独立于先前 VM 的准备场景:

Set-Location /home/labex/project/room-changes

源活动数据和请求采用与上一实验相同的格式。修改脚本前先读取:

Get-Content ./requests.json -Raw
./Show-RoomSchedule.ps1 -Path ./event.json

初始 apply-room-changes.ps1 被有意留为未完成状态。它声明支持 WhatIf,却不请求授权就通过 .NET 写入,也没有实现完整房间变更。仅用这个新的一次性演示输出进行尝试:

pwsh -NoLogo -NoProfile -File ./apply-room-changes.ps1 -EventPath ./event.json -PlanPath ./requests.json -OutputPath ./unsafe-preview.json -WhatIf

它会打印 Plan applied.。这条消息不能证明它应用了正确计划或遵守了预览要求。检查真实文件:

Test-Path ./unsafe-preview.json
True

声明通用参数不会自动保护任意 .NET 写入。原始活动数据仍完整,但这次预览本不应创建任何输出。用 Remove-Item -LiteralPath 只删除这个一次性文件,保留输入和笔记:

Remove-Item -LiteralPath ./unsafe-preview.json

保留完整活动数据

输入 event.json、requests.json、small-request.json、bad-request.json 及无关的 notes.txt 必须保留字节、文件模式、所有者和 mtime。不要修改准备好的读取器。可以替换自己的 apply-room-changes.ps1,并创建指定的新结果。提供的活动数据和请求是格式正确的 UTF-8 JSON。活动数据包含唯一的精确 ID、非负整数人数和容量,以及批准列表。请求是包含唯一场次 ID 和目标房间 ID 的非空数组。名称是显示标签;ID 区分大小写。

未知值可以包含嵌套对象、有序数组、null、布尔值、字符串和有界有限 JSON 数值,嵌套不超过 20 层。完全重复的对象键和任意格式不符的数据结构不在范围内。用 ConvertFrom-Json -AsHashtable -DateKind String 保留大小写不同的未知键,并使类似日期的字符串保留精确的原始偏移量。上一实验已介绍这些开关。源数据同时包含 When 和 when;不要将它们合并。用 ConvertTo-Json -Depth 100 序列化完整活动数据,保留未知嵌套对象。默认深度二不够。1 和 1.0 这样的相等有限数值写法可以接受;布尔值必须保持布尔类型,数组顺序必须保持,对象属性顺序只是表现形式。

输出必须是已经存在的真实父目录下的新文件,且与所有输入不同。课程整条目录链都是真实目录;没有其他写入者并发更改这些文件。对于已有文件、目录和链接(包括悬空链接),必须拒绝且不改变它们。无效计划必须在写入前拒绝整次调用:前面某行合法,不能成为接受后续未知 ID、未批准场次、重复请求或超出容量的理由。容量相等合法。目标房间在这些时间已经空闲。

使用原生 ShouldProcess

可复用调用与验证器一样接受 EventPath、PlanPath 和 OutputPath。它必须通过这些参数和已安装命令独立运行,不依赖相邻辅助文件。完整验证应位于授权决策之前,然后保存仅请求的 RoomId 字段改变的完整活动数据。

保存代码前先读懂以下新增内容:

  • [CmdletBinding(SupportsShouldProcess)] 添加原生 -WhatIf 和 -Confirm 通用参数。不要另造一个只打印预览的布尔参数。
  • $PSCmdlet 是 PowerShell 的自动命令对象。$PSCmdlet.ShouldProcess(target, action) 返回是否应执行操作。使用 WhatIf 时,它显示拟执行动作并返回 false;使用 Confirm 时,它显示提示,只有获准才返回 true。
  • 对完整且已验证的计划只调用一次。目标标明输出,动作列出每一个精确场次 ID 以及原、新房间 ID。ForEach-Object 构建描述;-join '; ' 将字符串合并。只有 true 分支可以修改对象或写文件。拒绝时不得创建输出目录、文件或临时工作,也不得改变任何已有进度。
  • 这是一次整体确认,不是逐场次提示。这里的 .NET 方法不会生成第二个原生提示。如果以后使用支持确认的内部 cmdlet,只能在已经授权的分支中抑制其额外提示;绝不能绕过外层决策。
  • 获准后,每个保存的 Session 都引用完整活动数据中匹配的对象。更新它的 RoomId 会保留其他所有字段。.NET WriteAllText 调用及显式 UTF-8 编码与上一实验相同,现在受到 ShouldProcess 保护。

路径解析、原始对象解析、匹配数组、区分大小写的比较、错误处理以及使用前收集全部变更,都沿用上一实验。用字面 here-string 保存完整替换内容;它只替换自己的脚本:

Set-Content -Path ./apply-room-changes.ps1 -Encoding utf8 -Value @'
[CmdletBinding(SupportsShouldProcess)]
param(
    [Parameter(Mandatory)][string]$EventPath,
    [Parameter(Mandatory)][string]$PlanPath,
    [Parameter(Mandatory)][string]$OutputPath
)
try {
    $ErrorActionPreference = 'Stop'
    $fullOutput = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputPath)
    if (Get-Item -LiteralPath $fullOutput -Force -ErrorAction SilentlyContinue) {
        throw 'Output already exists; choose a new path.'
    }
    $parent = Split-Path -LiteralPath $fullOutput
    if (-not (Test-Path -LiteralPath $parent -PathType Container)) {
        throw 'Output parent must already exist.'
    }
    $event = Get-Content -LiteralPath $EventPath -Raw | ConvertFrom-Json -AsHashtable -DateKind String
    $requests = Get-Content -LiteralPath $PlanPath -Raw | ConvertFrom-Json -AsHashtable -DateKind String -NoEnumerate
    if ($requests -isnot [array] -or $requests.Count -eq 0) { throw 'Plan must be a nonempty array.' }
    $changes = @()
    $seen = @()
    foreach ($request in $requests) {
        if ($seen -ccontains $request.SessionId) { throw "Duplicate session ID: $($request.SessionId)" }
        $seen += $request.SessionId
        $sessions = @($event.Sessions | Where-Object { $_.SessionId -ceq $request.SessionId })
        $rooms = @($event.Rooms | Where-Object { $_.RoomId -ceq $request.RoomId })
        if ($sessions.Count -ne 1) { throw "Unknown or ambiguous session ID: $($request.SessionId)" }
        if ($rooms.Count -ne 1) { throw "Unknown or ambiguous room ID: $($request.RoomId)" }
        if ($event.ApprovedSessionIds -cnotcontains $request.SessionId) { throw "Unapproved session ID: $($request.SessionId)" }
        if ($sessions[0].Attendees -gt $rooms[0].Capacity) { throw "Capacity exceeded: $($request.SessionId) -> $($request.RoomId)" }
        $changes += [pscustomobject]@{
            Session = $sessions[0]
            SessionId = $request.SessionId
            OldRoomId = $sessions[0].RoomId
            NewRoomId = $request.RoomId
        }
    }
    $description = ($changes | ForEach-Object { "$($_.SessionId): $($_.OldRoomId) -> $($_.NewRoomId)" }) -join '; '
    if ($PSCmdlet.ShouldProcess($fullOutput, "Apply complete room plan [$description]")) {
        foreach ($change in $changes) { $change.Session.RoomId = $change.NewRoomId }
        $json = ConvertTo-Json -InputObject $event -Depth 100
        [System.IO.File]::WriteAllText($fullOutput, $json + [Environment]::NewLine, [System.Text.UTF8Encoding]::new($false))
        Write-Output "Saved: $fullOutput"
    }
}
catch {
    Write-Error ("Room change failed: {0}" -f $_.Exception.Message) -ErrorAction Continue
    exit 1
}
'@

先预览,再拒绝完整操作

下一条命令必须发出原生 WhatIf 消息,描述 s-a: r-old -> r-equal 和 s-c: r-old -> r-large,并以新输出路径为目标:

pwsh -NoLogo -NoProfile -File ./apply-room-changes.ps1 -EventPath ./event.json -PlanPath ./requests.json -OutputPath ./event-updated.json -WhatIf

立即读取子进程的原生状态:

$LASTEXITCODE
0

现在请求原生确认。等待真实提示出现,读取完整动作,输入 N 并按 Enter。不要在提示出现前粘贴 N:

pwsh -NoLogo -NoProfile -File ./apply-room-changes.ps1 -EventPath ./event.json -PlanPath ./requests.json -OutputPath ./event-updated.json -Confirm

你应看到 Confirm、完整动作,以及包括 [Y] Yes 和 [N] No 的选项。两项变更只询问一次。No 会跳过操作;这不是验证错误,所以子进程成功退出但不做任何工作:

$LASTEXITCODE
0
Test-Path ./event-updated.json
False

重新读取原始文件,不要只相信内存变量:

Get-Content ./event.json -Raw

所有场次的 RoomId 仍为 r-old,嵌套笔记仍然存在。WhatIf 和 No 都不应留下任何文件、目录或临时候选。功能检查读取原始文件,并在一次性 PowerShell PTY 中测试原样脚本,包括实际 N/Y 输入。这些探测测试程序行为,不会创建或声称证明先前实际确认的历史。

路径出错时,检查 Get-Location 和 Get-ChildItem,再用 Set-Location /home/labex/project/room-changes 返回。脚本拼写错误可通过重新保存完整修正后的 here-string 恢复。如果意外提前批准,保留源文件,仅用 Remove-Item -LiteralPath ./event-updated.json 删除自己的 event-updated.json,然后再练习拒绝。绝不要删除或编辑原始活动数据、请求或笔记来掩盖错误。

批准并重新打开真实排期

本步骤将批准完整且已验证的计划,读取保存后的活动数据,并检查对后续无效请求的拒绝。

从同一个 PowerShell Terminal 会话开始,位置为 /home/labex/project/room-changes。上一步已保存修正后的脚本;最终输出仍不存在。如果新开了 Terminal,使用上一步相同的显式 UTF-8 PowerShell 启动命令和 Set-Location。脚本不需要会话变量,因为每条路径都由参数提供。

应用一个完整的获准计划

再次运行确认调用。等待原生提示,读取两个 ID 映射,输入 Y 并按 Enter:

pwsh -NoLogo -NoProfile -File ./apply-room-changes.ps1 -EventPath ./event.json -PlanPath ./requests.json -OutputPath ./event-updated.json -Confirm

预期只有一个问题,随后显示 Saved: 和输出路径。立即读取原生状态:

$LASTEXITCODE
0

这会对完整且已验证的计划确认一次。它不保证在崩溃、断电或并发写入时的事务性。输入保留为原件;结果是独立的完整活动数据。写入失败是错误,不能因此声称排期已应用。

准备好的读取器只读取传给 -Path 的那个文件。它不会重新生成缺失的结果,也不会回退到 event.json:

./Show-RoomSchedule.ps1 -Path ./event-updated.json

真实保存的行现在显示 s-a 位于 r-equal,s-b 仍位于 r-old,s-c 位于 r-large。两个场次仍同名为 Shared maps,两个房间仍同名为 Studio;ID 将它们区分开。

将完整输出重新读为字典,同时将类似时间戳的字符串保持为字符串:

$saved = Get-Content ./event-updated.json -Raw | ConvertFrom-Json -AsHashtable -DateKind String

点号用于逐层访问嵌套属性。第一个场次的原始元数据既有类似日期的字符串,也有大小写不同的键。方括号查找使用每个精确字典键。分别读取:

$saved.Sessions[0].Metadata.contact['When']
2026-11-07T09:30:00+05:30
$saved.Sessions[0].Metadata.contact['when']
Keep this distinct key

再读取另一个未知值和无关的原始笔记:

$saved.Unknown.offset
2027-01-02T12:34:56-07:00
Get-Content ./notes.txt
Venue manager: preserve this personal access note.

这些观察与完整语义比较相互补充;仅两个字段保留,并不能证明所有未知对象都保留下来。不要用只包含显示列的投影重建活动数据。

防止后续无效请求发布部分变更

准备好的 bad-request.json 第一项请求合法,第二项却包含未知房间。使用另一个新输出,保留已完成结果。下一次调用预期在任何确认之前失败:

pwsh -NoLogo -NoProfile -File ./apply-room-changes.ps1 -EventPath ./event.json -PlanPath ./bad-request.json -OutputPath ./rejected-event.json -Confirm

预期出现 Room change failed: 和未知房间的说明,且没有确认问题。立即读取结果:

$LASTEXITCODE
1
Test-Path ./rejected-event.json
False

前面的合法请求没有被单独应用或发布。以后执行合法工作时,将参数改回 requests.json;不要编辑提供的错误示例。原始活动数据和已完成的 event-updated.json 仍可使用。

已有目标也应被明确拒绝。可以用 WhatIf 观察这一点,它仍会在授权任何操作前验证前置条件:

pwsh -NoLogo -NoProfile -File ./apply-room-changes.ps1 -EventPath ./event.json -PlanPath ./requests.json -OutputPath ./event-updated.json -WhatIf
$LASTEXITCODE
1

预期出现 Output already exists; choose a new path.。预览不代表可以忽略无效参数。保留已有结果;新调用需要新的输出路径。如果修正脚本后确实想重建本实验的标准结果,只删除自己的输出,再重复合法确认。原始文件仍是恢复参照。

最终检查读取真实完整结果和保留基线。它会在带有不同 ID、容量相等、同标题记录、嵌套数据和失败路径的隔离活动数据中测试原样脚本字节。等效循环和对象实现都可以。它比较 JSON 值与类型,而不是对象属性顺序、命令拼写、编辑器选择或成功消息。重复检查不会应用、重置或修复你的实际活动数据。

总结

你修复了误导性预览,在写入前验证完整计划,并使用一次原生 ShouldProcess 决策。WhatIf 和 No 保持已有工作不变;Yes 保存了完整活动数据,保留请求的 ID 关系及其他所有嵌套值。你重新打开真实文件,并确认后续无效请求或已有输出会被拒绝,而不会被静默发布或替换。