$ErrorActionPreference = 'Stop' $PSNativeCommandUseErrorActionPreference = $true $projectRoot = Split-Path -Parent $PSScriptRoot Set-Location -LiteralPath $projectRoot $apiBaseUrl = 'http://localhost:58080' $deviceInternalId = '95000000-0000-0000-0000-000000000501' $deviceId = '95000000-0000-0000-0000-000000000511' $commandInternalId = '95000000-0000-0000-0000-000000000601' $commandId = '95000000-0000-0000-0000-000000000611' $deviceSecret = [Text.Encoding]::UTF8.GetBytes('local-simulated-device-secret-32-bytes-minimum') function Assert-Equal([object]$Actual, [object]$Expected, [string]$Message) { if ([string]$Actual -ne [string]$Expected) { throw "$Message。实际值:$Actual;预期值:$Expected" } } function Invoke-Database([string]$Sql) { $result = 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 ($result | Out-String).Trim() } function Invoke-SignedTelemetry([string]$Body, [string]$IdempotencyKey) { $path = '/device/v2/telemetry:batch' $bodyBytes = [Text.Encoding]::UTF8.GetBytes($Body) $digest = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($bodyBytes)).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() } return Invoke-WebRequest -UseBasicParsing -Method Post -Uri ($apiBaseUrl + $path) -ContentType 'application/json' -Body $Body -Headers @{ 'X-Device-Id' = $deviceId 'X-Device-Timestamp' = $timestamp 'X-Device-Nonce' = $nonce 'X-Device-Content-SHA256' = $digest 'X-Device-Signature' = $signature 'Idempotency-Key' = $IdempotencyKey } } function Remove-TelemetryFixture { $cleanup = @" BEGIN; DELETE FROM device_telemetry_event WHERE device_id='$deviceInternalId'::uuid; DELETE FROM idempotency_record WHERE actor_key='device:$deviceId'; DELETE FROM device_presence_state WHERE device_id='$deviceInternalId'::uuid; DELETE FROM device_command WHERE device_id='$deviceInternalId'::uuid; DELETE FROM device_twin WHERE device_id='$deviceInternalId'::uuid; DELETE FROM device WHERE id='$deviceInternalId'::uuid; COMMIT; "@ Invoke-Database $cleanup | Out-Null } try { Remove-TelemetryFixture $fixture = @" BEGIN; INSERT INTO device(id,public_id,tenant_id,serial_digest,model,status,credential_ref) VALUES('$deviceInternalId'::uuid,'$deviceId'::uuid,'10000000-0000-0000-0000-000000000001',repeat('6',64),'telemetry-smoke','registered','local-dev://simulated-terminal-1'); INSERT INTO device_twin(device_id,tenant_id,desired_revision,reported_revision) VALUES('$deviceInternalId'::uuid,'10000000-0000-0000-0000-000000000001',2,1); INSERT INTO device_command(id,public_id,tenant_id,device_id,command_type,payload,status,requested_by,idempotency_key,request_digest,expires_at,delivery_attempts) VALUES('$commandInternalId'::uuid,'$commandId'::uuid,'10000000-0000-0000-0000-000000000001','$deviceInternalId'::uuid,'diagnostics','{}','DELIVERED','10000000-0000-0000-0000-000000000101','telemetry-smoke',repeat('5',64),now()+interval '1 hour',1); COMMIT; "@ Invoke-Database $fixture | Out-Null $baseTime = [DateTime]::UtcNow $bodyObject = @{ events = @( @{ event_key='smoke-status-1'; sequence_no=1; occurred_at=$baseTime.ToString('o'); event_type='status'; status=@{ firmware_version='telemetry-smoke-1.0.0'; reported_config_revision=1; network=@{type='wifi';rssi=-48}; rtc=@{joined=$false;uplink=$false;downlink=$false}; recording_queue=@{pending_segments=3;failed_segments=0} }}, @{ event_key='smoke-error-2'; sequence_no=2; occurred_at=$baseTime.AddSeconds(1).ToString('o'); event_type='error'; error=@{code='RTC_RECONNECTING';severity='warning'} }, @{ event_key='smoke-command-3'; sequence_no=3; occurred_at=$baseTime.AddSeconds(2).ToString('o'); event_type='command_result'; command_result=@{ command_id=$commandId; status='SUCCEEDED'; result_summary=@{result_code='OK'} }} ) } $body = $bodyObject | ConvertTo-Json -Compress -Depth 8 $key = 'smoke-telemetry-' + [Guid]::NewGuid().ToString('N') $response = Invoke-SignedTelemetry $body $key Assert-Equal $response.StatusCode 200 '首次遥测批次失败' $data = ($response.Content | ConvertFrom-Json).data Assert-Equal $data.accepted_count 3 '首次遥测接收数量不正确' Assert-Equal $data.duplicate_count 0 '首次遥测不应出现重复' Assert-Equal $data.actions[0].type 'refresh_config' '设备配置落后时没有返回刷新动作' $replay = Invoke-SignedTelemetry $body $key Assert-Equal $replay.Headers['Idempotency-Replayed'][0] 'true' '遥测幂等重放缺少标记' $duplicate = Invoke-SignedTelemetry $body ('smoke-telemetry-duplicate-' + [Guid]::NewGuid().ToString('N')) $duplicateData = ($duplicate.Content | ConvertFrom-Json).data Assert-Equal $duplicateData.accepted_count 0 '事件级重放被错误重复保存' Assert-Equal $duplicateData.duplicate_count 3 '事件级重放没有全部判重' $conflictObject = $body | ConvertFrom-Json $conflictObject.events[1].error.code = 'RTC_FAILED' try { Invoke-SignedTelemetry ($conflictObject | ConvertTo-Json -Compress -Depth 8) ('smoke-telemetry-conflict-' + [Guid]::NewGuid().ToString('N')) | Out-Null throw '同一遥测业务键对应不同内容不应成功。' } catch { if ($null -eq $_.Exception.Response -or [int]$_.Exception.Response.StatusCode -ne 409) { throw } } $evidence = Invoke-Database @" SELECT dt.telemetry_sequence || '|' || dt.firmware_version || '|' || dt.last_error_code || '|' || dt.last_error_severity || '|' || dc.status || '|' || (SELECT count(*) FROM device_telemetry_event dte WHERE dte.device_id=dt.device_id) FROM device_twin dt JOIN device_command dc ON dc.device_id=dt.device_id WHERE dt.device_id='$deviceInternalId'::uuid; "@ Assert-Equal $evidence '3|telemetry-smoke-1.0.0|RTC_RECONNECTING|warning|SUCCEEDED|3' '设备影子、错误、命令或事件落库结果不正确' Write-Host '设备遥测冒烟通过:状态、错误、命令结果、批次幂等、事件判重、冲突回滚和配置刷新动作均正确。' } finally { Remove-TelemetryFixture Write-Host '已清理遥测合成设备、命令、事件、在线状态和幂等记录。' }