$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' $tenantId = '10000000-0000-0000-0000-000000000010' $householdId = $null $subjectId = $null $privacyRequestId = $null $exportId = $null $objectRef = $null $subjectName = '删除烟测长者-' + [Guid]::NewGuid().ToString('N').Substring(0, 8) $sentinel = 'privacy-delete-sentinel-' + [Guid]::NewGuid().ToString('N') 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 Invoke-JSON([string]$Method,[string]$Uri,[string]$Token,[object]$Body=$null,[hashtable]$ExtraHeaders=@{}) { $headers=@{Authorization="Bearer $Token"} foreach($key in $ExtraHeaders.Keys){$headers[$key]=$ExtraHeaders[$key]} $arguments=@{UseBasicParsing=$true;Method=$Method;Uri=$Uri;Headers=$headers} if($null-ne $Body){$arguments.ContentType='application/json';$arguments.Body=$Body|ConvertTo-Json -Compress -Depth 8} return Invoke-WebRequest @arguments } function Assert-Equal([object]$Actual,[object]$Expected,[string]$Message) { if([string]$Actual-ne[string]$Expected){throw "$Message。实际值:$Actual;预期值:$Expected"} } function Invoke-ExpectedStatus([scriptblock]$Action,[int]$ExpectedStatus) { try{$response=&$Action;Assert-Equal $response.StatusCode $ExpectedStatus 'HTTP状态不正确'} catch{if($_.Exception.Response.StatusCode.value__-ne$ExpectedStatus){throw}} } function Invoke-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 Remove-SmokeData { if($subjectId-notmatch '^[0-9a-fA-F-]{36}$'){return} $sql=@" BEGIN; CREATE TEMP TABLE cleanup_subject AS SELECT id,public_id,household_id FROM subject_profile WHERE public_id='$subjectId'::uuid; CREATE TEMP TABLE cleanup_privacy AS SELECT id,public_id FROM privacy_request WHERE subject_id IN (SELECT id FROM cleanup_subject); CREATE TEMP TABLE cleanup_exports AS SELECT id,public_id FROM export_task WHERE subject_id IN (SELECT id FROM cleanup_subject); CREATE TEMP TABLE cleanup_conversations AS SELECT id FROM conversation WHERE subject_id IN (SELECT id FROM cleanup_subject); DELETE FROM tombstone WHERE deletion_request_id IN (SELECT id FROM cleanup_privacy); DELETE FROM outbox_event WHERE aggregate_id IN (SELECT id FROM cleanup_privacy UNION SELECT id FROM cleanup_exports); DELETE FROM audit_event WHERE resource_id IN (SELECT public_id FROM cleanup_subject UNION SELECT public_id FROM cleanup_privacy UNION SELECT public_id FROM cleanup_exports); DELETE FROM privacy_request WHERE id IN (SELECT id FROM cleanup_privacy); DELETE FROM export_task WHERE id IN (SELECT id FROM cleanup_exports); DELETE FROM consent_record WHERE subject_id IN (SELECT id FROM cleanup_subject); DELETE FROM subject_fact WHERE subject_id IN (SELECT id FROM cleanup_subject); DELETE FROM provider_event WHERE conversation_id IN (SELECT id FROM cleanup_conversations); DELETE FROM rtc_task WHERE conversation_id IN (SELECT id FROM cleanup_conversations); DELETE FROM conversation_state_history WHERE conversation_id IN (SELECT id FROM cleanup_conversations); DELETE FROM conversation WHERE id IN (SELECT id FROM cleanup_conversations); DELETE FROM context_snapshot WHERE subject_id IN (SELECT id FROM cleanup_subject); DELETE FROM consent_snapshot WHERE subject_id IN (SELECT id FROM cleanup_subject); DELETE FROM subject_profile WHERE id IN (SELECT id FROM cleanup_subject); DELETE FROM household_member WHERE household_id IN (SELECT household_id FROM cleanup_subject); DELETE FROM household WHERE id IN (SELECT household_id FROM cleanup_subject); COMMIT; "@ docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -q -c $sql | Out-Null } try { Assert-Equal (Invoke-WebRequest -UseBasicParsing -Uri "$apiBase/health/ready").StatusCode 200 'API就绪检查失败' $ownerToken=Get-LocalAccessToken 'owner' $editorToken=Get-LocalAccessToken 'editor' $household=(Invoke-JSON 'POST' "$apiBase/api/v1/households" $ownerToken @{name='隐私删除烟测家庭'} @{'X-Tenant-Id'=$tenantId}).Content|ConvertFrom-Json $householdId=[string]$household.data.id $subject=(Invoke-JSON 'POST' "$apiBase/api/v1/households/$householdId/subjects" $ownerToken @{preferred_name=$subjectName;language='zh-CN';timezone='Asia/Shanghai';profile_data=@{sentinel=$sentinel};conversation_preferences=@{note=$sentinel}}).Content|ConvertFrom-Json $subjectId=[string]$subject.data.id Invoke-JSON 'POST' "$apiBase/api/v1/subjects/$subjectId/facts" $ownerToken @{fact_type='privacy_smoke';content=$sentinel;source_type='family_entry';source_ref=$sentinel;confirmation_status='confirmed';sensitivity='normal'} | Out-Null $exportKey='pre-delete-export-'+[Guid]::NewGuid().ToString('N') $export=(Invoke-JSON 'POST' "$apiBase/api/v1/subjects/$subjectId/exports" $ownerToken @{scope=@{categories=@('profile')}} @{'Idempotency-Key'=$exportKey}).Content|ConvertFrom-Json $exportId=[string]$export.data.id $deadline=[DateTime]::UtcNow.AddSeconds(20) do{Start-Sleep -Milliseconds 400;$exportDetail=(Invoke-JSON 'GET' "$apiBase/api/v1/exports/$exportId" $ownerToken).Content|ConvertFrom-Json}while($exportDetail.data.status-notin@('READY','FAILED','EXPIRED')-and[DateTime]::UtcNow-lt$deadline) Assert-Equal $exportDetail.data.status 'READY' '删除前测试导出未就绪' $objectRef=Invoke-Scalar "SELECT object_ref FROM export_task WHERE public_id='$exportId'::uuid" $deleteKey='privacy-delete-'+[Guid]::NewGuid().ToString('N') $body=@{request_type='DELETE';scope=@{};reason=$sentinel;confirmation=$subjectName} Invoke-ExpectedStatus {Invoke-JSON 'POST' "$apiBase/api/v1/subjects/$subjectId/privacy-requests" $ownerToken @{request_type='DELETE';scope=@{};reason='test';confirmation='错误称呼'} @{'Idempotency-Key'=('bad-confirm-'+[Guid]::NewGuid().ToString('N'))}} 409 Invoke-ExpectedStatus {Invoke-JSON 'POST' "$apiBase/api/v1/subjects/$subjectId/privacy-requests" $editorToken $body @{'Idempotency-Key'=('editor-delete-'+[Guid]::NewGuid().ToString('N'))}} 403 $activeRef='privacy-delete-active-'+[Guid]::NewGuid().ToString('N') $activeSql=@" WITH target AS ( SELECT sp.id subject_id,sp.tenant_id,sp.household_id, (SELECT d.id FROM device d WHERE d.tenant_id=sp.tenant_id AND NOT EXISTS (SELECT 1 FROM conversation c WHERE c.device_id=d.id AND c.status NOT IN ('COMPLETED','FAILED','EXPIRED')) LIMIT 1) device_id FROM subject_profile sp WHERE sp.public_id='$subjectId'::uuid ), consent AS ( INSERT INTO consent_snapshot(tenant_id,subject_id,snapshot_version,effective_consents) SELECT tenant_id,subject_id,1,'{}'::jsonb FROM target RETURNING id,tenant_id,subject_id ), context AS ( INSERT INTO context_snapshot(tenant_id,subject_id,profile_version,consent_snapshot_id,prompt_bundle_version,engine_mode,content_digest) SELECT c.tenant_id,c.subject_id,1,c.id,'privacy-delete-smoke','s2s_hybrid',repeat('0',64) FROM consent c RETURNING id,tenant_id,subject_id ) INSERT INTO conversation(tenant_id,household_id,subject_id,device_id,consent_snapshot_id,context_snapshot_id,provider_task_ref,engine_mode,retention_mode,status,allocation_expires_at) SELECT t.tenant_id,t.household_id,t.subject_id,t.device_id,c.id,x.id,'$activeRef','s2s_hybrid','none','ACTIVE',now()+interval '5 minutes' FROM target t JOIN consent c ON c.subject_id=t.subject_id JOIN context x ON x.subject_id=t.subject_id; "@ docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -q -c $activeSql | Out-Null Invoke-ExpectedStatus {Invoke-JSON 'POST' "$apiBase/api/v1/subjects/$subjectId/privacy-requests" $ownerToken $body @{'Idempotency-Key'=('active-conflict-'+[Guid]::NewGuid().ToString('N'))}} 409 docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -q -c "WITH removed AS (DELETE FROM conversation WHERE provider_task_ref='$activeRef' RETURNING context_snapshot_id,consent_snapshot_id) DELETE FROM context_snapshot WHERE id IN (SELECT context_snapshot_id FROM removed); DELETE FROM consent_snapshot WHERE subject_id=(SELECT id FROM subject_profile WHERE public_id='$subjectId'::uuid) AND NOT EXISTS (SELECT 1 FROM conversation WHERE consent_snapshot_id=consent_snapshot.id);" | Out-Null $created=Invoke-JSON 'POST' "$apiBase/api/v1/subjects/$subjectId/privacy-requests" $ownerToken $body @{'Idempotency-Key'=$deleteKey} Assert-Equal $created.StatusCode 202 '删除申请未进入后台队列' $privacyRequestId=[string](($created.Content|ConvertFrom-Json).data.id) Invoke-ExpectedStatus {Invoke-JSON 'GET' "$apiBase/api/v1/subjects/$subjectId" $ownerToken} 403 Invoke-ExpectedStatus {Invoke-JSON 'GET' "$apiBase/api/v1/exports/$exportId" $ownerToken} 403 Invoke-ExpectedStatus {Invoke-JSON 'POST' "$apiBase/api/v1/subjects/$subjectId/exports" $ownerToken @{scope=@{}} @{'Idempotency-Key'=('blocked-export-'+[Guid]::NewGuid().ToString('N'))}} 403 $replay=Invoke-JSON 'POST' "$apiBase/api/v1/subjects/$subjectId/privacy-requests" $ownerToken $body @{'Idempotency-Key'=$deleteKey} Assert-Equal $replay.StatusCode 200 '冻结后的删除幂等重放失败' Assert-Equal (($replay.Content|ConvertFrom-Json).data.id) $privacyRequestId '删除重放创建了第二个请求' $deadline=[DateTime]::UtcNow.AddSeconds(30) do{Start-Sleep -Milliseconds 500;$detail=(Invoke-JSON 'GET' "$apiBase/api/v1/privacy-requests/$privacyRequestId" $ownerToken).Content|ConvertFrom-Json}while($detail.data.status-notin@('COMPLETED','PARTIAL_FAILED')-and[DateTime]::UtcNow-lt$deadline) if ($detail.data.status -ne 'COMPLETED') { $subtaskEvidence = (docker compose exec -T postgres psql -U voice -d voice_platform -t -A -F '|' -c "SELECT ps.subsystem,ps.status,COALESCE(ps.last_error,'') FROM privacy_subtask ps JOIN privacy_request pr ON pr.id=ps.privacy_request_id WHERE pr.public_id='$privacyRequestId'::uuid ORDER BY ps.subsystem" | Out-String).Trim() throw "主体删除对账没有完成。实际值:$($detail.data.status);子任务:$subtaskEvidence" } Assert-Equal $detail.data.subtasks.Count 6 '主体删除子任务数量不正确' if(@($detail.data.subtasks|Where-Object{$_.status-notin@('COMPLETED','SKIPPED')}).Count-ne 0){throw '存在未收敛的删除子任务。'} Assert-Equal $detail.data.result_summary.content_included $false '删除结果不应包含个人正文' Assert-Equal (Invoke-Scalar "SELECT status||'|'||preferred_name||'|'||(profile_data='{}'::jsonb)||'|'||(conversation_preferences='{}'::jsonb) FROM subject_profile WHERE public_id='$subjectId'::uuid") 'deleted|[已删除]|true|true' '主体档案未完成脱敏' Assert-Equal (Invoke-Scalar "SELECT content||'|'||(source_ref IS NULL) FROM subject_fact WHERE subject_id=(SELECT id FROM subject_profile WHERE public_id='$subjectId'::uuid)") '[已按隐私请求删除]|true' '主体事实未完成脱敏' Assert-Equal (Invoke-Scalar "SELECT status||'|'||(object_ref IS NULL) FROM export_task WHERE public_id='$exportId'::uuid") 'EXPIRED|true' '私有导出任务未失效' Assert-Equal (Invoke-Scalar "SELECT count(*) FROM tombstone WHERE deletion_request_id=(SELECT id FROM privacy_request WHERE public_id='$privacyRequestId'::uuid)") 1 '没有生成最小删除证明' Assert-Equal (Invoke-Scalar "SELECT count(*) FROM privacy_request WHERE public_id='$privacyRequestId'::uuid AND scope::text LIKE '%$sentinel%'") 0 '删除原因正文被持久化' docker compose exec -T minio mc alias set smoke http://localhost:9000 local_minio local_minio_password | Out-Null $nativePreference=$PSNativeCommandUseErrorActionPreference;$PSNativeCommandUseErrorActionPreference=$false docker compose exec -T minio mc stat "smoke/private-exports/$objectRef" 2>$null | Out-Null $statExit=$LASTEXITCODE;$PSNativeCommandUseErrorActionPreference=$nativePreference if($statExit-eq 0){throw '主体删除完成后私有导出对象仍存在。'} Write-Host "主体隐私删除端到端冒烟通过:$privacyRequestId" Write-Host '立即冻结、活动通话保护、权限/确认、幂等、六子系统对账、正文脱敏、导出对象删除和最小墓碑均已验证。' } finally { if($objectRef){ $nativePreference=$PSNativeCommandUseErrorActionPreference;$PSNativeCommandUseErrorActionPreference=$false docker compose exec -T minio mc alias set smoke http://localhost:9000 local_minio local_minio_password 2>$null | Out-Null docker compose exec -T minio mc rm --force "smoke/private-exports/$objectRef" 2>$null | Out-Null $PSNativeCommandUseErrorActionPreference=$nativePreference } Remove-SmokeData if($subjectId){Write-Host "已清理主体删除冒烟数据:$subjectId"} }