$ErrorActionPreference = 'Stop' $PSNativeCommandUseErrorActionPreference = $true $projectRoot = Split-Path -Parent $PSScriptRoot Set-Location -LiteralPath $projectRoot $apiBase = 'http://localhost:58080' $tokenUrl = 'http://localhost:58180/realms/voice-local/protocol/openid-connect/token' $deviceId = '10000000-0000-0000-0000-000000000511' $originalTwin = $null $patchRequestId = $null $claimBindingId = $null $claimRequestId = $null function Get-LocalAccessToken([string]$Username) { $response = Invoke-RestMethod -Method Post -Uri $tokenUrl -ContentType 'application/x-www-form-urlencoded' -Body @{ grant_type = 'password'; client_id = 'voice-platform-web'; username = $Username password = 'LocalOnly123!'; scope = 'openid' } return $response.access_token } function Invoke-JSONRequest([string]$Method, [string]$Uri, [string]$Token, [hashtable]$Headers = @{}, [object]$Body = $null) { $requestHeaders = @{ Authorization = "Bearer $Token" } foreach ($key in $Headers.Keys) { $requestHeaders[$key] = $Headers[$key] } $arguments = @{ Method = $Method; Uri = $Uri; Headers = $requestHeaders } if ($null -ne $Body) { $arguments.ContentType = 'application/json; charset=utf-8' $arguments.Body = $Body | ConvertTo-Json -Depth 12 -Compress } return Invoke-WebRequest @arguments } function Assert-Status([scriptblock]$Operation, [int]$ExpectedStatus) { try { & $Operation | Out-Null; throw "请求不应成功,预期HTTP $ExpectedStatus。" } catch { if ($null -eq $_.Exception.Response -or [int]$_.Exception.Response.StatusCode -ne $ExpectedStatus) { throw } } } try { $ownerToken = Get-LocalAccessToken 'owner' $editorToken = Get-LocalAccessToken 'editor' $outsiderToken = Get-LocalAccessToken 'outsider' $devices = (Invoke-JSONRequest Get "$apiBase/api/v1/devices" $ownerToken).Content | ConvertFrom-Json if ($deviceId -notin @($devices.data.items.id)) { throw '所有者设备列表未包含合成设备。' } Assert-Status { Invoke-JSONRequest Get "$apiBase/api/v1/devices/$deviceId" $outsiderToken } 403 $twinResponse = Invoke-JSONRequest Get "$apiBase/api/v1/devices/$deviceId/twin" $editorToken $originalTwin = $twinResponse.Content | ConvertFrom-Json $originalETag = [string]$twinResponse.Headers.ETag $presenceResponse = Invoke-JSONRequest Get "$apiBase/api/v1/devices/$deviceId/presence" $ownerToken $presence = $presenceResponse.Content | ConvertFrom-Json if ($presence.data.device_id -ne $deviceId -or [string]$presenceResponse.Headers.'Cache-Control' -ne 'no-store') { throw '设备在线状态响应不符合契约。' } Assert-Status { Invoke-JSONRequest Patch "$apiBase/api/v1/devices/$deviceId/twin" $editorToken @{ 'If-Match' = $originalETag } @{ desired_config = @{ upload = @{ api_token = 'forbidden' } } } } 400 $patchedResponse = Invoke-JSONRequest Patch "$apiBase/api/v1/devices/$deviceId/twin" $editorToken @{ 'If-Match' = $originalETag } @{ desired_config = @{ volume = @{ speaker = 60 } interaction = @{ pace = 'normal' } upload = @{ policy = 'wifi_preferred' } policy_refs = @{ retention = 'local-v1' } } } $patched = $patchedResponse.Content | ConvertFrom-Json $patchRequestId = $patched.request_id if ($patched.data.desired_revision -ne ($originalTwin.data.desired_revision + 1) -or $patched.data.sync_state -ne 'pending') { throw '设备影子期望版本或同步状态不正确。' } Assert-Status { Invoke-JSONRequest Patch "$apiBase/api/v1/devices/$deviceId/twin" $editorToken @{ 'If-Match' = $originalETag } @{ desired_config = @{} } } 412 $claimBody = @{ claim_code = 'LOCAL-CLAIM-0001' household_id = '10000000-0000-0000-0000-000000000211' subject_id = '10000000-0000-0000-0000-000000000411' } Assert-Status { Invoke-JSONRequest Post "$apiBase/api/v1/device-claims" $outsiderToken @{} $claimBody } 403 $claimResponse = Invoke-JSONRequest Post "$apiBase/api/v1/device-claims" $ownerToken @{} $claimBody $claim = $claimResponse.Content | ConvertFrom-Json $claimBindingId = $claim.data.binding_id $claimRequestId = $claim.request_id $claimReplay = Invoke-JSONRequest Post "$apiBase/api/v1/device-claims" $ownerToken @{} $claimBody $claimReplayBody = $claimReplay.Content | ConvertFrom-Json if ([string]$claimReplay.Headers.'Idempotency-Replayed' -ne 'true' -or $claimReplayBody.data.binding_id -ne $claimBindingId) { throw '一次性设备绑定码安全重试结果不正确。' } Write-Host "设备管理烟测通过:设备 $deviceId,在线状态来源 $($presence.data.source),绑定 $claimBindingId。" } finally { if (-not [string]::IsNullOrWhiteSpace($claimBindingId)) { $deleteClaimAudit = if ([string]::IsNullOrWhiteSpace($claimRequestId)) { '' } else { "DELETE FROM audit_event WHERE request_id='$claimRequestId';" } docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -c ` "BEGIN; UPDATE device_claim SET used_at=NULL,used_binding_id=NULL WHERE public_id='10000000-0000-0000-0000-000000000911'::uuid; DELETE FROM device_binding WHERE public_id='$claimBindingId'::uuid; $deleteClaimAudit COMMIT;" | Out-Null } if ($null -ne $originalTwin) { $desiredJSON = ($originalTwin.data.desired_config | ConvertTo-Json -Depth 12 -Compress).Replace("'", "''") $updatedAt = ([DateTimeOffset]$originalTwin.data.updated_at).ToString('o') $deleteAudit = if ([string]::IsNullOrWhiteSpace($patchRequestId)) { '' } else { "DELETE FROM audit_event WHERE request_id='$patchRequestId';" } docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -c ` "BEGIN; UPDATE device_twin SET desired_revision=$($originalTwin.data.desired_revision),desired_config='$desiredJSON'::jsonb,updated_at='$updatedAt'::timestamptz WHERE device_id=(SELECT id FROM device WHERE public_id='$deviceId'::uuid); $deleteAudit COMMIT;" | Out-Null } }