$ErrorActionPreference = 'Stop' $PSNativeCommandUseErrorActionPreference = $true $projectRoot = Split-Path -Parent $PSScriptRoot Set-Location -LiteralPath $projectRoot $apiBaseUrl = 'http://localhost:58080' $deviceId = '10000000-0000-0000-0000-000000000511' $deviceSecret = [Text.Encoding]::UTF8.GetBytes('local-simulated-device-secret-32-bytes-minimum') $conversationId = $null $memoryConversationId = $null $disabledMemoryConversationId = $null $deletedMemoryConversationId = $null $tokenUrl = 'http://localhost:58180/realms/voice-local/protocol/openid-connect/token' function Get-LocalAccessToken([string]$Username) { return (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' }).access_token } function Assert-Equal([object]$Actual, [object]$Expected, [string]$Message) { if ([string]$Actual -ne [string]$Expected) { throw "$Message。实际值:$Actual;预期值:$Expected" } } function Invoke-SignedPost([string]$Path, [string]$Body, [string]$IdempotencyKey) { $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() } $headers = @{ 'X-Device-Id' = $deviceId 'X-Device-Timestamp' = $timestamp 'X-Device-Nonce' = $nonce 'X-Device-Content-SHA256' = $digest 'X-Device-Signature' = $signature } if ($IdempotencyKey) { $headers['Idempotency-Key'] = $IdempotencyKey } return Invoke-WebRequest -UseBasicParsing -Method Post -Uri ($apiBaseUrl + $Path) ` -ContentType 'application/json' -Body $Body -Headers $headers } function Invoke-ScalarQuery([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 Remove-SmokeConversation([string]$PublicId) { if ($PublicId -notmatch '^[0-9a-fA-F-]{36}$') { throw '拒绝清理格式不正确的通话编号。' } $cleanupSql = @" BEGIN; CREATE TEMP TABLE smoke_cleanup AS SELECT id, public_id, device_id, context_snapshot_id, consent_snapshot_id FROM conversation WHERE public_id = '$PublicId'::uuid; CREATE TEMP TABLE smoke_story_cleanup AS SELECT id, public_id, current_revision_id FROM story_draft WHERE conversation_id IN (SELECT id FROM smoke_cleanup); CREATE TEMP TABLE smoke_memory_cleanup AS SELECT DISTINCT cm.id, cm.public_id, cm.current_version_id FROM curated_memory cm JOIN memory_version mv ON mv.memory_id=cm.id WHERE mv.source_refs @> ('[{"type":"story","story_id":"' || (SELECT sd.public_id::text FROM story_draft sd WHERE sd.id IN (SELECT id FROM smoke_story_cleanup) LIMIT 1) || '"}]')::jsonb; DELETE FROM outbox_event WHERE aggregate_id IN (SELECT id FROM smoke_memory_cleanup); DELETE FROM audit_event WHERE resource_id IN (SELECT public_id FROM smoke_memory_cleanup); DELETE FROM audit_event WHERE resource_id IN (SELECT public_id FROM smoke_story_cleanup); DELETE FROM web_event WHERE resource_public_id IN (SELECT public_id FROM smoke_story_cleanup UNION SELECT public_id FROM smoke_cleanup) OR resource_public_id IN (SELECT public_id FROM device WHERE id IN (SELECT device_id FROM smoke_cleanup)); DELETE FROM device_presence_state WHERE device_id IN (SELECT device_id FROM smoke_cleanup); DELETE FROM memory_index_entry WHERE memory_id IN (SELECT id FROM smoke_memory_cleanup); UPDATE curated_memory SET current_version_id=NULL WHERE id IN (SELECT id FROM smoke_memory_cleanup); DELETE FROM memory_version WHERE memory_id IN (SELECT id FROM smoke_memory_cleanup); DELETE FROM curated_memory WHERE id IN (SELECT id FROM smoke_memory_cleanup); DELETE FROM fact_candidate WHERE story_id IN (SELECT id FROM smoke_story_cleanup); DELETE FROM story_source WHERE story_revision_id IN (SELECT id FROM story_revision WHERE story_id IN (SELECT id FROM smoke_story_cleanup)); UPDATE story_draft SET current_revision_id=NULL WHERE id IN (SELECT id FROM smoke_story_cleanup); DELETE FROM story_revision WHERE story_id IN (SELECT id FROM smoke_story_cleanup); DELETE FROM story_draft WHERE id IN (SELECT id FROM smoke_story_cleanup); DELETE FROM conversation_content_assessment WHERE conversation_id IN (SELECT id FROM smoke_cleanup); DELETE FROM conversation_summary WHERE conversation_id IN (SELECT id FROM smoke_cleanup); DELETE FROM conversation_turn WHERE conversation_id IN (SELECT id FROM smoke_cleanup); DELETE FROM provider_event WHERE conversation_id IN (SELECT id FROM smoke_cleanup); DELETE FROM conversation_state_history WHERE conversation_id IN (SELECT id FROM smoke_cleanup); DELETE FROM rtc_task WHERE conversation_id IN (SELECT id FROM smoke_cleanup); DELETE FROM outbox_event WHERE aggregate_id IN (SELECT id FROM smoke_cleanup); DELETE FROM audit_event WHERE resource_id IN (SELECT id FROM smoke_cleanup); DELETE FROM idempotency_record WHERE resource_id IN (SELECT id FROM smoke_cleanup); DELETE FROM conversation WHERE id IN (SELECT id FROM smoke_cleanup); DELETE FROM context_snapshot WHERE id IN (SELECT context_snapshot_id FROM smoke_cleanup); DELETE FROM consent_snapshot WHERE id IN (SELECT consent_snapshot_id FROM smoke_cleanup); COMMIT; "@ docker compose exec -T postgres psql -U voice -d voice_platform ` -v ON_ERROR_STOP=1 -q -c $cleanupSql | Out-Null if ($LASTEXITCODE -ne 0) { throw '冒烟数据清理失败。' } docker compose exec -T redis redis-cli DEL ` "voice-platform:conversation:lease:$PublicId" ` "voice-platform:device:presence:$deviceId" | Out-Null } try { foreach ($healthUrl in @( 'http://localhost:58080/health/live', 'http://localhost:58081/health/live', 'http://localhost:58082/health/live', 'http://localhost:59200/health/live' )) { $health = Invoke-WebRequest -UseBasicParsing -Uri $healthUrl Assert-Equal $health.StatusCode 200 "$healthUrl 存活检查失败" } $createBody = @{ device_time = [DateTime]::UtcNow.ToString('o') monotonic_ms = 12345 capabilities = @{ rtc = $true audio_tracks = @('near', 'far') upload_resume = $true } network = @{ type = 'wifi'; rssi = -48 } requested_goal = 'open_conversation' } | ConvertTo-Json -Compress -Depth 5 $createKey = 'smoke-create-' + [Guid]::NewGuid().ToString('N') $createResponse = Invoke-SignedPost '/device/v2/conversations' $createBody $createKey Assert-Equal $createResponse.StatusCode 201 '通话创建失败' $created = ($createResponse.Content | ConvertFrom-Json).data $conversationId = [string]$created.conversation_id if ($conversationId -notmatch '^[0-9a-fA-F-]{36}$') { throw "服务端返回了格式不正确的通话编号:$conversationId" } $activationPath = "/device/v2/conversations/$conversationId/activate" $activationBody = @{ 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 = 'local-e2e-smoke' } | ConvertTo-Json -Compress $activationKey = 'smoke-activate-' + [Guid]::NewGuid().ToString('N') $activationResponse = Invoke-SignedPost $activationPath $activationBody $activationKey Assert-Equal $activationResponse.StatusCode 202 '首次激活失败' Assert-Equal (($activationResponse.Content | ConvertFrom-Json).data.status) 'STARTING_AGENT' '首次激活状态不正确' $replayedResponse = Invoke-SignedPost $activationPath $activationBody $activationKey Assert-Equal $replayedResponse.StatusCode 202 '激活幂等重放失败' Assert-Equal $replayedResponse.Headers['Idempotency-Replayed'][0] 'true' '激活响应缺少幂等重放标记' $deadline = [DateTime]::UtcNow.AddSeconds(15) do { Start-Sleep -Milliseconds 500 $currentState = Invoke-ScalarQuery "SELECT status FROM conversation WHERE public_id='$conversationId'::uuid;" } while ($currentState -ne 'ACTIVE' -and [DateTime]::UtcNow -lt $deadline) Assert-Equal $currentState 'ACTIVE' '供应商启动回调未使通话进入ACTIVE' $heartbeatPath = "/device/v2/conversations/$conversationId/heartbeat" $heartbeatBody = @{ sequence_no = 2 device_time = [DateTime]::UtcNow.ToString('o') monotonic_ms = 20000 conversation_status = 'ACTIVE' rtc = @{ joined = $true; uplink = $true; downlink = $true } network = @{ type = 'wifi'; rssi = -51 } firmware_version = 'local-smoke-1.0.0' reported_config_revision = 1 rtc_credential_expires_at = [string]$created.rtc.expires_at recording_queue = @{ pending_segments = 2; failed_segments = 0 } command_results = @() } | ConvertTo-Json -Compress -Depth 5 $heartbeatResponse = Invoke-SignedPost $heartbeatPath $heartbeatBody '' Assert-Equal $heartbeatResponse.StatusCode 200 '通话心跳失败' $heartbeatData = ($heartbeatResponse.Content | ConvertFrom-Json).data Assert-Equal $heartbeatData.actions[0].type 'continue' 'ACTIVE心跳没有返回continue动作' $originalLease = Invoke-ScalarQuery "SELECT lease_expires_at::text FROM conversation WHERE public_id='$conversationId'::uuid;" $staleHeartbeatBody = $heartbeatBody | ConvertFrom-Json $staleHeartbeatBody.sequence_no = 1 $staleHeartbeatBody.device_time = [DateTime]::UtcNow.ToString('o') $staleHeartbeatResponse = Invoke-SignedPost $heartbeatPath ($staleHeartbeatBody | ConvertTo-Json -Compress -Depth 5) '' Assert-Equal $staleHeartbeatResponse.StatusCode 200 '乱序心跳没有安全返回' $leaseAfterStale = Invoke-ScalarQuery "SELECT lease_expires_at::text FROM conversation WHERE public_id='$conversationId'::uuid;" Assert-Equal $leaseAfterStale $originalLease '乱序心跳错误延长了数据库租约' $cachedSequence = docker compose exec -T redis redis-cli --raw HGET ` "voice-platform:conversation:lease:$conversationId" sequence_no Assert-Equal (($cachedSequence | Out-String).Trim()) 2 'Redis租约心跳序号不正确' $repeatResponse = Invoke-SignedPost $activationPath $activationBody ` ('smoke-activate-repeat-' + [Guid]::NewGuid().ToString('N')) Assert-Equal $repeatResponse.StatusCode 202 'ACTIVE状态下重复确认失败' Assert-Equal (($repeatResponse.Content | ConvertFrom-Json).data.status) 'ACTIVE' '重复确认导致状态回退' $stateEvidence = Invoke-ScalarQuery @" SELECT c.status || '|' || rt.status || '|' || rt.start_attempts FROM conversation c JOIN rtc_task rt ON rt.conversation_id=c.id WHERE c.public_id='$conversationId'::uuid; "@ Assert-Equal $stateEvidence 'ACTIVE|ACTIVE|1' '通话、RTC任务或供应商调用次数不正确' $historyCount = Invoke-ScalarQuery @" SELECT count(*) FROM conversation_state_history h JOIN conversation c ON c.id=h.conversation_id WHERE c.public_id='$conversationId'::uuid; "@ Assert-Equal $historyCount 4 '通话状态历史不完整或出现重复迁移' $providerEventCount = Invoke-ScalarQuery @" SELECT count(*) FROM provider_event pe JOIN conversation c ON c.id=pe.conversation_id WHERE c.public_id='$conversationId'::uuid AND pe.event_type='voice_chat.started' AND pe.processing_status='processed'; "@ Assert-Equal $providerEventCount 1 '重复供应商回调没有正确去重' $startOutboxCount = Invoke-ScalarQuery @" SELECT count(*) FROM outbox_event o JOIN conversation c ON c.id=o.aggregate_id WHERE c.public_id='$conversationId'::uuid AND o.event_type='conversation.agent_start_requested' AND o.status='completed' AND o.attempt_count=1; "@ Assert-Equal $startOutboxCount 1 '语音供应商启动待办执行次数不正确' $stopPath = "/device/v2/conversations/$conversationId/stop" $stopBody = @{ reason = 'handset_on_hook' device_time = [DateTime]::UtcNow.ToString('o') last_heartbeat_sequence = 2 rtc_left = $true recording = @{ near_segments = 2; far_segments = 2 } } | ConvertTo-Json -Compress -Depth 4 $stopKey = 'smoke-stop-' + [Guid]::NewGuid().ToString('N') $stopResponse = Invoke-SignedPost $stopPath $stopBody $stopKey Assert-Equal $stopResponse.StatusCode 202 '停止通话请求失败' Assert-Equal (($stopResponse.Content | ConvertFrom-Json).data.status) 'STOPPING' '停止请求没有进入STOPPING' $stopReplay = Invoke-SignedPost $stopPath $stopBody $stopKey Assert-Equal $stopReplay.StatusCode 202 '停止请求幂等重放失败' Assert-Equal $stopReplay.Headers['Idempotency-Replayed'][0] 'true' '停止响应缺少幂等重放标记' $deadline = [DateTime]::UtcNow.AddSeconds(15) do { Start-Sleep -Milliseconds 500 $currentState = Invoke-ScalarQuery "SELECT status FROM conversation WHERE public_id='$conversationId'::uuid;" } while ($currentState -ne 'COMPLETED' -and [DateTime]::UtcNow -lt $deadline) Assert-Equal $currentState 'COMPLETED' '供应商停止回调未使通话进入COMPLETED' $deadline = [DateTime]::UtcNow.AddSeconds(20) do { Start-Sleep -Milliseconds 500 $contentEvidence = Invoke-ScalarQuery @" SELECT (SELECT count(*) FROM conversation_turn ct JOIN conversation c ON c.id=ct.conversation_id WHERE c.public_id='$conversationId'::uuid)::text || '|' || (SELECT count(*) FROM conversation_summary cs JOIN conversation c ON c.id=cs.conversation_id WHERE c.public_id='$conversationId'::uuid)::text || '|' || (SELECT count(*) FROM story_draft sd JOIN conversation c ON c.id=sd.conversation_id WHERE c.public_id='$conversationId'::uuid)::text; "@ } while ($contentEvidence -ne '2|2|1' -and [DateTime]::UtcNow -lt $deadline) Assert-Equal $contentEvidence '2|2|1' '最终转写、摘要或故事候选没有按期生成' $autoMemoryCount = Invoke-ScalarQuery "SELECT count(*) FROM curated_memory cm JOIN story_draft sd ON sd.subject_id=cm.subject_id JOIN conversation c ON c.id=sd.conversation_id WHERE c.public_id='$conversationId'::uuid AND cm.created_at>=sd.created_at;" Assert-Equal $autoMemoryCount 0 '未经人工确认不应自动生成长期记忆' $transcriptPath = "/device/v2/conversations/$conversationId/turns:batch" $transcriptBody = Invoke-ScalarQuery @" SELECT jsonb_build_object('turns',jsonb_agg(jsonb_build_object( 'turn_key',ct.turn_key,'sequence_no',ct.sequence_no,'speaker',ct.speaker, 'start_at',ct.start_at,'end_at',ct.end_at,'final_text',ct.final_text,'interrupted',ct.interrupted ) ORDER BY ct.sequence_no))::text FROM conversation_turn ct JOIN conversation c ON c.id=ct.conversation_id WHERE c.public_id='$conversationId'::uuid; "@ $transcriptKey = 'smoke-transcript-' + [Guid]::NewGuid().ToString('N') $transcriptResponse = Invoke-SignedPost $transcriptPath $transcriptBody $transcriptKey Assert-Equal $transcriptResponse.StatusCode 200 '设备终稿补传去重请求失败' $transcriptData = ($transcriptResponse.Content | ConvertFrom-Json).data Assert-Equal $transcriptData.accepted_count 0 '已有供应商终稿被错误重复保存' Assert-Equal $transcriptData.duplicate_count 2 '已有供应商终稿没有全部判定为重复' $transcriptReplay = Invoke-SignedPost $transcriptPath $transcriptBody $transcriptKey Assert-Equal $transcriptReplay.Headers['Idempotency-Replayed'][0] 'true' '终稿补传响应缺少幂等重放标记' $conflictingTranscript = $transcriptBody | ConvertFrom-Json $conflictingTranscript.turns[0].final_text = ([string]$conflictingTranscript.turns[0].final_text) + '(冲突)' try { Invoke-SignedPost $transcriptPath ($conflictingTranscript | ConvertTo-Json -Compress -Depth 5) ` ('smoke-transcript-conflict-' + [Guid]::NewGuid().ToString('N')) | Out-Null throw '不同内容占用同一终稿业务键不应成功。' } catch { if ($null -eq $_.Exception.Response -or [int]$_.Exception.Response.StatusCode -ne 409) { throw } } Assert-Equal (Invoke-ScalarQuery "SELECT count(*) FROM conversation_turn ct JOIN conversation c ON c.id=ct.conversation_id WHERE c.public_id='$conversationId'::uuid;") 2 '终稿冲突请求没有整批回滚' $repeatStop = Invoke-SignedPost $stopPath $stopBody ` ('smoke-stop-repeat-' + [Guid]::NewGuid().ToString('N')) Assert-Equal $repeatStop.StatusCode 202 '终态重复停止没有安全返回' Assert-Equal (($repeatStop.Content | ConvertFrom-Json).data.status) 'COMPLETED' '终态重复停止返回状态不正确' $finalEvidence = Invoke-ScalarQuery @" SELECT c.status || '|' || rt.status || '|' || rt.start_attempts || '|' || rt.stop_attempts || '|' || (c.lease_expires_at IS NULL)::text FROM conversation c JOIN rtc_task rt ON rt.conversation_id=c.id WHERE c.public_id='$conversationId'::uuid; "@ Assert-Equal $finalEvidence 'COMPLETED|COMPLETED|1|1|true' '停止后的通话、任务、调用次数或租约不正确' $finalHistoryCount = Invoke-ScalarQuery @" SELECT count(*) FROM conversation_state_history h JOIN conversation c ON c.id=h.conversation_id WHERE c.public_id='$conversationId'::uuid; "@ Assert-Equal $finalHistoryCount 6 '完整创建到停止状态链不正确' $stopProviderEventCount = Invoke-ScalarQuery @" SELECT count(*) FROM provider_event pe JOIN conversation c ON c.id=pe.conversation_id WHERE c.public_id='$conversationId'::uuid AND pe.event_type='voice_chat.stopped' AND pe.processing_status='processed'; "@ Assert-Equal $stopProviderEventCount 1 '重复停止回调没有正确去重' $stopOutboxCount = Invoke-ScalarQuery @" SELECT count(*) FROM outbox_event o JOIN conversation c ON c.id=o.aggregate_id WHERE c.public_id='$conversationId'::uuid AND o.event_type='conversation.agent_stop_requested' AND o.status='completed' AND o.attempt_count=1; "@ Assert-Equal $stopOutboxCount 1 '供应商停止待办执行次数不正确' $cachedLeaseExists = docker compose exec -T redis redis-cli --raw EXISTS ` "voice-platform:conversation:lease:$conversationId" Assert-Equal (($cachedLeaseExists | Out-String).Trim()) 0 '停止后Redis热租约未失效' $ownerToken = Get-LocalAccessToken 'owner' $queryHeaders = @{ Authorization = "Bearer $ownerToken" } $queryResponse = Invoke-WebRequest -Uri "$apiBaseUrl/api/v1/subjects/10000000-0000-0000-0000-000000000411/conversations?limit=1&status=COMPLETED" -Headers $queryHeaders $queryData = $queryResponse.Content | ConvertFrom-Json Assert-Equal $queryData.data.items[0].id $conversationId '网页通话列表没有返回刚完成的通话' $detailResponse = Invoke-WebRequest -Uri "$apiBaseUrl/api/v1/conversations/$conversationId" -Headers $queryHeaders $detailData = $detailResponse.Content | ConvertFrom-Json Assert-Equal $detailData.data.history.Count 6 '网页通话详情状态历史不完整' Assert-Equal $detailData.data.snapshots.consent_snapshot_version 2 '网页通话详情授权快照版本不正确' if ($detailResponse.Content -match 'token|provider_task_ref|room_id') { throw '网页通话详情泄露了禁止返回的运行凭据或供应商引用。' } $turnResponse = Invoke-WebRequest -Uri "$apiBaseUrl/api/v1/conversations/$conversationId/turns?limit=1" -Headers $queryHeaders $turnData = ($turnResponse.Content | ConvertFrom-Json).data Assert-Equal $turnData.items.Count 1 '话轮分页第一页数量不正确' if (-not $turnData.next_cursor) { throw '话轮分页缺少下一页游标。' } $turnPage2 = (Invoke-WebRequest -Uri "$apiBaseUrl/api/v1/conversations/$conversationId/turns?limit=1&cursor=$([uri]::EscapeDataString($turnData.next_cursor))" -Headers $queryHeaders).Content | ConvertFrom-Json Assert-Equal $turnPage2.data.items.Count 1 '话轮分页第二页数量不正确' $summaryData = (Invoke-WebRequest -Uri "$apiBaseUrl/api/v1/conversations/$conversationId/summaries" -Headers $queryHeaders).Content | ConvertFrom-Json Assert-Equal $summaryData.data.items.Count 2 '通话摘要和开放话题数量不正确' $storiesData = (Invoke-WebRequest -Uri "$apiBaseUrl/api/v1/subjects/10000000-0000-0000-0000-000000000411/stories?status=CANDIDATE" -Headers $queryHeaders).Content | ConvertFrom-Json Assert-Equal $storiesData.data.items.Count 1 '故事候选列表数量不正确' $storyId = [string]$storiesData.data.items[0].id $storyResponse = Invoke-WebRequest -Uri "$apiBaseUrl/api/v1/stories/$storyId" -Headers $queryHeaders $storyData = ($storyResponse.Content | ConvertFrom-Json).data Assert-Equal $storyData.sources.Count 1 '故事候选没有保留长者原话来源' Assert-Equal $storyData.facts.Count 1 '故事候选没有生成可审核事实' $confirmHeaders = @{ Authorization = "Bearer $ownerToken"; 'If-Match' = $storyResponse.Headers['ETag'][0] } $confirmBody = @{ reason = '端到端冒烟人工确认' } | ConvertTo-Json -Compress $confirmData = (Invoke-WebRequest -Method Post -Uri "$apiBaseUrl/api/v1/stories/$storyId/confirm" -Headers $confirmHeaders -ContentType 'application/json' -Body $confirmBody).Content | ConvertFrom-Json Assert-Equal $confirmData.data.story.status 'CONFIRMED' '故事人工确认状态不正确' $memoryId = [string]$confirmData.data.memory.id if (-not $memoryId) { throw '人工确认后没有生成长期记忆。' } $deadline = [DateTime]::UtcNow.AddSeconds(20) do { Start-Sleep -Milliseconds 500 $memoryData = (Invoke-WebRequest -Uri "$apiBaseUrl/api/v1/memories/$memoryId" -Headers $queryHeaders).Content | ConvertFrom-Json } while ($memoryData.data.index_status -ne 'ACTIVE' -and [DateTime]::UtcNow -lt $deadline) Assert-Equal $memoryData.data.index_status 'ACTIVE' '人工确认后的记忆没有完成本地开发索引' $memoryList = (Invoke-WebRequest -Uri "$apiBaseUrl/api/v1/subjects/10000000-0000-0000-0000-000000000411/memories" -Headers $queryHeaders).Content | ConvertFrom-Json Assert-Equal $memoryList.data.items.Count 1 '长者记忆列表没有返回已确认记忆' $memorySearchTerm = if ([string]$memoryData.data.content -and ([string]$memoryData.data.content).Length -gt 4) { ([string]$memoryData.data.content).Substring(0, 4) } else { [string]$memoryData.data.content } $memorySearchHit = (Invoke-WebRequest -Uri "$apiBaseUrl/api/v1/subjects/10000000-0000-0000-0000-000000000411/memories?q=$([uri]::EscapeDataString($memorySearchTerm))" -Headers $queryHeaders).Content | ConvertFrom-Json Assert-Equal $memorySearchHit.data.items.Count 1 '记忆文本搜索没有命中已确认记忆' $memorySearchMissTerm = 'smoke-no-match-' + [Guid]::NewGuid().ToString('N') $memorySearchMiss = (Invoke-WebRequest -Uri "$apiBaseUrl/api/v1/subjects/10000000-0000-0000-0000-000000000411/memories?q=$memorySearchMissTerm" -Headers $queryHeaders).Content | ConvertFrom-Json Assert-Equal $memorySearchMiss.data.items.Count 0 '记忆文本搜索对不相关查询返回了误命中' $memoryCreateKey = 'smoke-memory-create-' + [Guid]::NewGuid().ToString('N') $memoryConversationResponse = Invoke-SignedPost '/device/v2/conversations' $createBody $memoryCreateKey Assert-Equal $memoryConversationResponse.StatusCode 201 '确认记忆后的下一次通话创建失败' $memoryConversationId = [string](($memoryConversationResponse.Content | ConvertFrom-Json).data.conversation_id) $memoryContextEvidence = Invoke-ScalarQuery @" SELECT jsonb_array_length(cs.memory_versions)::text || '|' || cs.prompt_bundle_version FROM conversation c JOIN context_snapshot cs ON cs.id=c.context_snapshot_id WHERE c.public_id='$memoryConversationId'::uuid; "@ Assert-Equal $memoryContextEvidence '1|minimal-v2' '下一次通话没有按服务端固定边界冻结已确认记忆版本' Remove-SmokeConversation $memoryConversationId Write-Host "已清理记忆召回验证通话:$memoryConversationId" $memoryConversationId = $null $memoryEtag = (Invoke-WebRequest -Uri "$apiBaseUrl/api/v1/memories/$memoryId" -Headers $queryHeaders).Headers['ETag'][0] $editMemoryBody = @{ content = '我小时候常和家人在河边散步,这条内容经过家庭成员再次核对。'; reason = '端到端冒烟修订记忆' } | ConvertTo-Json -Compress $editMemoryResponse = Invoke-WebRequest -Method Patch -Uri "$apiBaseUrl/api/v1/memories/$memoryId" -Headers @{ Authorization = "Bearer $ownerToken"; 'If-Match' = $memoryEtag } -ContentType 'application/json' -Body $editMemoryBody $editedMemory = ($editMemoryResponse.Content | ConvertFrom-Json).data Assert-Equal $editedMemory.version_no 2 '修改记忆没有生成新版本' $deadline = [DateTime]::UtcNow.AddSeconds(20) do { Start-Sleep -Milliseconds 500 $editedMemory = ((Invoke-WebRequest -Uri "$apiBaseUrl/api/v1/memories/$memoryId" -Headers $queryHeaders).Content | ConvertFrom-Json).data } while ($editedMemory.index_status -ne 'ACTIVE' -and [DateTime]::UtcNow -lt $deadline) Assert-Equal $editedMemory.index_status 'ACTIVE' '记忆新版本没有完成重新索引' $disableMemoryBody = @{ status = 'DISABLED'; reason = '端到端冒烟验证停用' } | ConvertTo-Json -Compress $disabledMemoryResponse = Invoke-WebRequest -Method Patch -Uri "$apiBaseUrl/api/v1/memories/$memoryId" -Headers @{ Authorization = "Bearer $ownerToken"; 'If-Match' = '"v2"' } -ContentType 'application/json' -Body $disableMemoryBody $disabledMemory = ($disabledMemoryResponse.Content | ConvertFrom-Json).data Assert-Equal $disabledMemory.status 'DISABLED' '记忆停用状态不正确' Assert-Equal $disabledMemory.index_status 'STALE' '停用记忆的索引没有立即失效' $disabledCreateKey = 'smoke-disabled-memory-create-' + [Guid]::NewGuid().ToString('N') $disabledConversationResponse = Invoke-SignedPost '/device/v2/conversations' $createBody $disabledCreateKey Assert-Equal $disabledConversationResponse.StatusCode 201 '停用记忆后的通话创建失败' $disabledMemoryConversationId = [string](($disabledConversationResponse.Content | ConvertFrom-Json).data.conversation_id) $disabledContextCount = Invoke-ScalarQuery "SELECT jsonb_array_length(cs.memory_versions) FROM conversation c JOIN context_snapshot cs ON cs.id=c.context_snapshot_id WHERE c.public_id='$disabledMemoryConversationId'::uuid;" Assert-Equal $disabledContextCount 0 '停用记忆仍被召回到新通话' $enableMemoryBody = @{ status = 'ACTIVE'; reason = '端到端冒烟恢复有效记忆' } | ConvertTo-Json -Compress $enabledMemoryResponse = Invoke-WebRequest -Method Patch -Uri "$apiBaseUrl/api/v1/memories/$memoryId" -Headers @{ Authorization = "Bearer $ownerToken"; 'If-Match' = '"v3"' } -ContentType 'application/json' -Body $enableMemoryBody Assert-Equal (($enabledMemoryResponse.Content | ConvertFrom-Json).data.status) 'ACTIVE' '记忆重新启用状态不正确' $deadline = [DateTime]::UtcNow.AddSeconds(20) do { Start-Sleep -Milliseconds 500 $enabledMemory = ((Invoke-WebRequest -Uri "$apiBaseUrl/api/v1/memories/$memoryId" -Headers $queryHeaders).Content | ConvertFrom-Json).data } while ($enabledMemory.index_status -ne 'ACTIVE' -and [DateTime]::UtcNow -lt $deadline) Assert-Equal $enabledMemory.index_status 'ACTIVE' '重新启用的记忆没有恢复索引' Remove-SmokeConversation $disabledMemoryConversationId Write-Host "已清理停用记忆验证通话:$disabledMemoryConversationId" $disabledMemoryConversationId = $null $deleteMemoryBody = @{ reason = '端到端冒烟永久删除记忆' } | ConvertTo-Json -Compress $deleteMemoryResponse = Invoke-WebRequest -Method Delete -Uri "$apiBaseUrl/api/v1/memories/$memoryId" -Headers @{ Authorization = "Bearer $ownerToken"; 'If-Match' = '"v4"' } -ContentType 'application/json' -Body $deleteMemoryBody Assert-Equal $deleteMemoryResponse.StatusCode 202 '记忆删除请求未被受理' $deadline = [DateTime]::UtcNow.AddSeconds(20) do { Start-Sleep -Milliseconds 500 $deletedMemory = ((Invoke-WebRequest -Uri "$apiBaseUrl/api/v1/memories/$memoryId" -Headers $queryHeaders).Content | ConvertFrom-Json).data } while ($deletedMemory.status -ne 'DELETED' -and [DateTime]::UtcNow -lt $deadline) Assert-Equal $deletedMemory.status 'DELETED' '记忆后台删除没有完成' Assert-Equal $deletedMemory.index_status 'DELETED' '记忆索引没有完成删除' Assert-Equal $deletedMemory.content '[已按用户请求删除]' '记忆数据库正文没有被清除' $deletedCreateKey = 'smoke-deleted-memory-create-' + [Guid]::NewGuid().ToString('N') $deletedConversationResponse = Invoke-SignedPost '/device/v2/conversations' $createBody $deletedCreateKey Assert-Equal $deletedConversationResponse.StatusCode 201 '删除记忆后的通话创建失败' $deletedMemoryConversationId = [string](($deletedConversationResponse.Content | ConvertFrom-Json).data.conversation_id) $deletedContextCount = Invoke-ScalarQuery "SELECT jsonb_array_length(cs.memory_versions) FROM conversation c JOIN context_snapshot cs ON cs.id=c.context_snapshot_id WHERE c.public_id='$deletedMemoryConversationId'::uuid;" Assert-Equal $deletedContextCount 0 '已删除记忆仍被召回到新通话' $outsiderToken = Get-LocalAccessToken 'outsider' try { Invoke-WebRequest -Uri "$apiBaseUrl/api/v1/conversations/$conversationId" -Headers @{ Authorization = "Bearer $outsiderToken" } | Out-Null throw '跨租户查看通话详情不应成功。' } catch { if ($null -eq $_.Exception.Response -or [int]$_.Exception.Response.StatusCode -ne 403) { throw } } Write-Host "端到端通话冒烟通过:$conversationId" Write-Host '状态链:PREPARING -> ROOM_ISSUED -> STARTING_AGENT -> ACTIVE -> STOPPING -> COMPLETED' Write-Host '供应商启动调用1次;重复回调仅处理1次;激活幂等重放通过。' Write-Host '心跳刷新PostgreSQL/Redis租约;乱序心跳未延长租约。' Write-Host '供应商停止调用1次;重复停止回调仅处理1次;最终租约已释放。' Write-Host '网页通话列表、脱敏详情、六段状态历史和跨租户403验证通过。' Write-Host '最终话轮去重、摘要、故事来源、人工确认、记忆生成与本地开发索引验证通过。' Write-Host '设备终稿补传对供应商终稿完成语义去重、幂等重放和冲突整批回滚。' Write-Host '新确认记忆已在下一次通话创建时按租户、长者、授权和状态边界冻结到背景快照。' Write-Host '记忆修订生成新版本并重新索引;停用后新通话召回为0;重新启用进入索引流程。' Write-Host '永久删除任务完成索引清理和数据库正文清除;删除后的新通话召回为0。' } finally { if ($deletedMemoryConversationId) { Remove-SmokeConversation $deletedMemoryConversationId Write-Host "已清理删除记忆验证通话:$deletedMemoryConversationId" } if ($disabledMemoryConversationId) { Remove-SmokeConversation $disabledMemoryConversationId Write-Host "已清理停用记忆验证通话:$disabledMemoryConversationId" } if ($memoryConversationId) { Remove-SmokeConversation $memoryConversationId Write-Host "已清理记忆召回验证通话:$memoryConversationId" } if ($conversationId) { Remove-SmokeConversation $conversationId Write-Host "已清理本次合成通话数据:$conversationId" } }