$ErrorActionPreference = 'Stop' $PSNativeCommandUseErrorActionPreference = $true $root = Split-Path -Parent $PSScriptRoot Set-Location -LiteralPath $root $api = 'http://localhost:58080' $tokenUrl = 'http://localhost:58180/realms/voice-local/protocol/openid-connect/token' $deviceId = '10000000-0000-0000-0000-000000000511' $subjectId = '10000000-0000-0000-0000-000000000411' $subjectInternalId = '10000000-0000-0000-0000-000000000401' $consentId = '10000000-0000-0000-0000-000000000812' $deviceSecret = [Text.Encoding]::UTF8.GetBytes('local-simulated-device-secret-32-bytes-minimum') $conversationId = $null $withdrawalSnapshotId = $null $originalConsentVersion = $null $withdrawRequestId = 'consent-stop-smoke-' + [Guid]::NewGuid().ToString('N') function Scalar([string]$Sql) { $value = docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -t -A -c $Sql if ($LASTEXITCODE -ne 0) { throw '数据库查询失败。' } return ($value | Out-String).Trim() } function Token() { return (Invoke-RestMethod -Method Post -Uri $tokenUrl -ContentType 'application/x-www-form-urlencoded' -Body @{ grant_type = 'password'; client_id = 'voice-platform-web'; username = 'owner' password = 'LocalOnly123!'; scope = 'openid' }).access_token } function SignedPost([string]$Path, [string]$Body, [string]$Key) { $bytes = [Text.Encoding]::UTF8.GetBytes($Body) $digest = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($bytes)).ToLowerInvariant() $timestamp = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ') $nonce = [Guid]::NewGuid().ToString('N') $canonical = "POST`n$Path`n$timestamp`n$nonce`n$digest" $hmac = [Security.Cryptography.HMACSHA256]::new($deviceSecret) try { $signature = [Convert]::ToBase64String($hmac.ComputeHash([Text.Encoding]::UTF8.GetBytes($canonical))).TrimEnd('=').Replace('+', '-').Replace('/', '_') } finally { $hmac.Dispose() } $headers = @{ 'X-Device-Id' = $deviceId; 'X-Device-Timestamp' = $timestamp; 'X-Device-Nonce' = $nonce 'X-Device-Content-SHA256' = $digest; 'X-Device-Signature' = $signature } if ($Key) { $headers['Idempotency-Key'] = $Key } return Invoke-WebRequest -UseBasicParsing -Method Post -Uri ($api + $Path) -ContentType 'application/json' -Body $Body -Headers $headers } try { if ((Scalar "SELECT count(*) FROM conversation c JOIN device d ON d.id=c.device_id WHERE d.public_id='$deviceId'::uuid AND c.status NOT IN('COMPLETED','FAILED','EXPIRED')") -ne '0') { throw '测试设备存在未结束通话。' } $originalConsentVersion = Scalar "SELECT version FROM consent_record WHERE public_id='$consentId'::uuid AND withdrawn_at IS NULL" if (-not $originalConsentVersion) { throw '故事整理授权不是有效状态。' } $createBody = @{ device_time = [DateTime]::UtcNow.ToString('o'); monotonic_ms = 51001 capabilities = @{ rtc = $true; audio_tracks = @('near', 'far'); upload_resume = $true } network = @{ type = 'wifi'; rssi = -48 }; requested_goal = 'open_conversation' } | ConvertTo-Json -Compress -Depth 6 $created = ((SignedPost '/device/v2/conversations' $createBody ('consent-stop-create-' + [Guid]::NewGuid().ToString('N'))).Content | ConvertFrom-Json).data $conversationId = [string]$created.conversation_id $activatePath = "/device/v2/conversations/$conversationId/activate" $activateBody = @{ joined_at = [DateTime]::UtcNow.ToString('o'); room_id = $created.rtc.room_id rtc_user_id = $created.rtc.user_id; rtc_join_result = 'success'; config_revision = 1 connection_reference = 'consent-withdrawal-smoke' } | ConvertTo-Json -Compress SignedPost $activatePath $activateBody ('consent-stop-activate-' + [Guid]::NewGuid().ToString('N')) | Out-Null $deadline = [DateTime]::UtcNow.AddSeconds(15) do { Start-Sleep -Milliseconds 300 $state = Scalar "SELECT status FROM conversation WHERE public_id='$conversationId'::uuid" } while ($state -ne 'ACTIVE' -and [DateTime]::UtcNow -lt $deadline) if ($state -ne 'ACTIVE') { throw "通话没有进入ACTIVE:$state" } $accessToken = Token $withdraw = Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/subjects/$subjectId/consents/$consentId/withdraw" ` -Headers @{ Authorization = "Bearer $accessToken"; 'X-Request-Id' = $withdrawRequestId } if ($withdraw.StatusCode -ne 200 -or -not (($withdraw.Content | ConvertFrom-Json).data.withdrawn_at)) { throw '授权撤回接口未成功。' } $withdrawalSnapshotId = Scalar "SELECT cs.public_id::text FROM consent_snapshot cs WHERE cs.subject_id='$subjectInternalId'::uuid AND cs.id<>(SELECT consent_snapshot_id FROM conversation WHERE public_id='$conversationId'::uuid) ORDER BY cs.created_at DESC,cs.id DESC LIMIT 1" $stopEvidence = Scalar "SELECT c.stop_reason||'|'||(SELECT count(*) FROM conversation_state_history h WHERE h.conversation_id=c.id AND h.from_status='ACTIVE' AND h.to_status='STOPPING')||'|'||(SELECT count(*) FROM outbox_event o WHERE o.aggregate_id=c.id AND o.event_type='conversation.agent_stop_requested') FROM conversation c WHERE c.public_id='$conversationId'::uuid" if ($stopEvidence -ne 'consent_withdrawn:story_extraction|1|1') { throw "授权撤回停止意图不完整:$stopEvidence" } $deadline = [DateTime]::UtcNow.AddSeconds(15) do { Start-Sleep -Milliseconds 300 $finalState = Scalar "SELECT c.status||'|'||rt.status||'|'||rt.stop_attempts FROM conversation c JOIN rtc_task rt ON rt.conversation_id=c.id WHERE c.public_id='$conversationId'::uuid" } while ($finalState -ne 'COMPLETED|COMPLETED|1' -and [DateTime]::UtcNow -lt $deadline) if ($finalState -ne 'COMPLETED|COMPLETED|1') { throw "Agent没有完成可靠停止:$finalState" } $heartbeatBody = @{ sequence_no = 3; device_time = [DateTime]::UtcNow.ToString('o'); monotonic_ms = 52001 conversation_status = 'ACTIVE'; rtc = @{ joined = $true; uplink = $true; downlink = $true } network = @{ type = 'wifi'; rssi = -50 }; firmware_version = '0.1.0-sim'; reported_config_revision = 1 recording_queue = @{ pending_segments = 0; failed_segments = 0 }; command_results = @() rtc_credential_expires_at = [string]$created.rtc.expires_at } | ConvertTo-Json -Compress -Depth 6 $heartbeat = ((SignedPost "/device/v2/conversations/$conversationId/heartbeat" $heartbeatBody '').Content | ConvertFrom-Json).data if ($heartbeat.actions[0].type -ne 'stop') { throw '设备心跳没有收到停止动作。' } $detail = (Invoke-RestMethod -Uri "$api/api/v1/conversations/$conversationId" -Headers @{ Authorization = "Bearer $accessToken" }).data if ($detail.status -ne 'COMPLETED' -or $detail.stop_reason -ne 'consent_withdrawn:story_extraction') { throw '家庭通话详情没有展示授权撤回后的权威终态。' } Write-Host '授权撤回停止烟测通过:SaaS事务意图、Agent可靠停止、设备stop动作和家庭通话终态一致。' } finally { if ($originalConsentVersion) { docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -q -c ` "UPDATE consent_record SET withdrawn_at=NULL,version=$originalConsentVersion,updated_at=now() WHERE public_id='$consentId'::uuid;" | Out-Null } if ($conversationId -match '^[0-9a-fA-F-]{36}$') { $cleanup = @" BEGIN; CREATE TEMP TABLE consent_stop_cleanup AS SELECT id,public_id,device_id,context_snapshot_id,consent_snapshot_id FROM conversation WHERE public_id='$conversationId'::uuid; DELETE FROM web_event WHERE resource_public_id IN(SELECT public_id FROM consent_stop_cleanup) OR resource_public_id IN(SELECT public_id FROM device WHERE id IN(SELECT device_id FROM consent_stop_cleanup)); DELETE FROM conversation_summary WHERE conversation_id IN(SELECT id FROM consent_stop_cleanup); DELETE FROM conversation_turn WHERE conversation_id IN(SELECT id FROM consent_stop_cleanup); DELETE FROM provider_event WHERE conversation_id IN(SELECT id FROM consent_stop_cleanup); DELETE FROM conversation_state_history WHERE conversation_id IN(SELECT id FROM consent_stop_cleanup); DELETE FROM rtc_task WHERE conversation_id IN(SELECT id FROM consent_stop_cleanup); DELETE FROM outbox_event WHERE aggregate_id IN(SELECT id FROM consent_stop_cleanup); DELETE FROM audit_event WHERE request_id='$withdrawRequestId' OR resource_id IN(SELECT id FROM consent_stop_cleanup) OR resource_id IN(SELECT public_id FROM consent_stop_cleanup); DELETE FROM idempotency_record WHERE resource_id IN(SELECT id FROM consent_stop_cleanup); DELETE FROM conversation WHERE id IN(SELECT id FROM consent_stop_cleanup); DELETE FROM context_snapshot WHERE id IN(SELECT context_snapshot_id FROM consent_stop_cleanup); DELETE FROM consent_snapshot WHERE id IN(SELECT consent_snapshot_id FROM consent_stop_cleanup); COMMIT; "@ docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -q -c $cleanup | Out-Null docker compose exec -T redis redis-cli DEL "voice-platform:conversation:lease:$conversationId" "voice-platform:device:presence:$deviceId" | Out-Null } if ($withdrawalSnapshotId -match '^[0-9a-fA-F-]{36}$') { docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -q -c ` "DELETE FROM consent_snapshot WHERE public_id='$withdrawalSnapshotId'::uuid AND NOT EXISTS(SELECT 1 FROM conversation WHERE consent_snapshot_id=consent_snapshot.id);" | Out-Null } Write-Host '已恢复合成授权并清理通话、快照、任务、审计和幂等记录。' }