param( [ValidateRange(1, 100)][int]$Count = 100, [ValidateRange(10, 600)][int]$CompletionTimeoutSeconds = 180 ) $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' $run = [Guid]::NewGuid().ToString('N') $prefix = "privacy-capacity-$run" $fixtureFileName = ".privacy-delete-$run.json" $fixtureFile = Join-Path $projectRoot "test/load/$fixtureFileName" $householdPublicId = $null $householdInternalId = $null function Invoke-Sql([string]$Sql) { $result = $Sql | docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -q -t -A if ($LASTEXITCODE -ne 0) { throw 'Privacy capacity SQL failed' } return ($result | Out-String).Trim() } function Get-OwnerToken { 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 } try { $ownerToken = Get-OwnerToken $householdResponse = Invoke-RestMethod -Method Post -Uri "$apiBase/api/v1/households" -Headers @{ Authorization = "Bearer $ownerToken" 'X-Tenant-Id' = $tenantId } -ContentType 'application/json' -Body (@{ name = "$prefix-household" } | ConvertTo-Json -Compress) $householdPublicId = [string]$householdResponse.data.id $householdEvidence = Invoke-Sql "SELECT h.id::text||'|'||h.owner_user_id::text||'|'||h.tenant_id::text FROM household h WHERE h.public_id='$householdPublicId'::uuid" $householdParts = $householdEvidence -split '\|' if ($householdParts.Count -ne 3) { throw "Cannot resolve capacity household: $householdEvidence" } $householdInternalId = $householdParts[0] $ownerInternalId = $householdParts[1] $tenantId = $householdParts[2] $setup = @" INSERT INTO subject_profile(tenant_id,household_id,preferred_name,language,timezone,profile_data,conversation_preferences,created_by,updated_by) SELECT '$tenantId'::uuid,'$householdInternalId'::uuid,'并发删除主体-'||lpad(g::text,3,'0'),'zh-CN','Asia/Shanghai',jsonb_build_object('capacity_run','$run'),'{}'::jsonb,'$ownerInternalId'::uuid,'$ownerInternalId'::uuid FROM generate_series(1,$Count) g; SELECT json_agg(json_build_object('subject_id',public_id::text,'preferred_name',preferred_name) ORDER BY preferred_name)::text FROM subject_profile WHERE household_id='$householdInternalId'::uuid; "@ $fixtureJson = ((Invoke-Sql $setup) -split "`n" | Select-Object -Last 1).Trim() $fixtures = $fixtureJson | ConvertFrom-Json if ($fixtures.Count -ne $Count) { throw "Expected $Count subjects but created $($fixtures.Count)" } [IO.File]::WriteAllText($fixtureFile, $fixtureJson, [Text.UTF8Encoding]::new($false)) $startedAt = [DateTime]::UtcNow docker run --rm -v "${projectRoot}/test/load:/scripts:ro" grafana/k6 run ` -e "API_BASE=http://host.docker.internal:58080" ` -e "ACCESS_TOKEN=$ownerToken" ` -e "FIXTURE_FILE=/scripts/$fixtureFileName" ` -e "VUS=$Count" ` /scripts/privacy-deletes.js if ($LASTEXITCODE -ne 0) { throw 'Concurrent privacy delete submission thresholds failed' } $submissionSeconds = ([DateTime]::UtcNow - $startedAt).TotalSeconds $deadline = [DateTime]::UtcNow.AddSeconds($CompletionTimeoutSeconds) do { $evidence = Invoke-Sql @" SELECT count(*)||'|'|| count(*) FILTER(WHERE pr.status='COMPLETED')||'|'|| count(*) FILTER(WHERE pr.status='PARTIAL_FAILED')||'|'|| count(*) FILTER(WHERE pr.status NOT IN('COMPLETED','PARTIAL_FAILED'))||'|'|| (SELECT count(*) FROM privacy_subtask ps JOIN privacy_request px ON px.id=ps.privacy_request_id JOIN subject_profile spx ON spx.id=px.subject_id WHERE spx.household_id='$householdInternalId'::uuid)||'|'|| (SELECT count(*) FROM tombstone t JOIN privacy_request px ON px.id=t.deletion_request_id JOIN subject_profile spx ON spx.id=px.subject_id WHERE spx.household_id='$householdInternalId'::uuid) FROM privacy_request pr JOIN subject_profile sp ON sp.id=pr.subject_id WHERE sp.household_id='$householdInternalId'::uuid; "@ $parts = $evidence -split '\|' if ($parts.Count -eq 6 -and [int]$parts[1] -eq $Count) { break } Start-Sleep -Milliseconds 500 } while ([DateTime]::UtcNow -lt $deadline) $completionSeconds = ([DateTime]::UtcNow - $startedAt).TotalSeconds $expectedSubtasks = $Count * 6 if ($parts.Count -ne 6 -or [int]$parts[0] -ne $Count -or [int]$parts[1] -ne $Count -or [int]$parts[2] -ne 0 -or [int]$parts[3] -ne 0 -or [int]$parts[4] -ne $expectedSubtasks -or [int]$parts[5] -ne $Count) { $failed = Invoke-Sql "SELECT pr.status||':'||count(*) FROM privacy_request pr JOIN subject_profile sp ON sp.id=pr.subject_id WHERE sp.household_id='$householdInternalId'::uuid GROUP BY pr.status ORDER BY pr.status" throw "Privacy delete reconciliation mismatch: evidence=$evidence statuses=$failed" } $profileEvidence = Invoke-Sql "SELECT count(*)||'|'||count(*) FILTER(WHERE status='deleted' AND preferred_name='[已删除]' AND profile_data='{}'::jsonb AND conversation_preferences='{}'::jsonb) FROM subject_profile WHERE household_id='$householdInternalId'::uuid" if ($profileEvidence -ne "$Count|$Count") { throw "Subject redaction mismatch: $profileEvidence" } $subtaskEvidence = Invoke-Sql "SELECT count(*)||'|'||count(*) FILTER(WHERE ps.status IN('COMPLETED','SKIPPED')) FROM privacy_subtask ps JOIN privacy_request pr ON pr.id=ps.privacy_request_id JOIN subject_profile sp ON sp.id=pr.subject_id WHERE sp.household_id='$householdInternalId'::uuid" if ($subtaskEvidence -ne "$expectedSubtasks|$expectedSubtasks") { throw "Privacy subtask convergence mismatch: $subtaskEvidence" } Write-Host ("Concurrent privacy deletion passed: requests={0}, submitted_seconds={1:N2}, completed_seconds={2:N2}, profiles_redacted={3}, subtasks={4}, tombstones={5}" -f $Count, $submissionSeconds, $completionSeconds, $Count, $expectedSubtasks, $Count) } finally { if ($householdInternalId) { $cleanup = @" BEGIN; CREATE TEMP TABLE capacity_subject ON COMMIT DROP AS SELECT id,public_id FROM subject_profile WHERE household_id='$householdInternalId'::uuid; CREATE TEMP TABLE capacity_privacy ON COMMIT DROP AS SELECT id,public_id FROM privacy_request WHERE subject_id IN(SELECT id FROM capacity_subject); DELETE FROM alert_incident WHERE resource_public_id IN(SELECT public_id FROM capacity_privacy); DELETE FROM tombstone WHERE deletion_request_id IN(SELECT id FROM capacity_privacy); DELETE FROM outbox_event WHERE aggregate_id IN(SELECT id FROM capacity_privacy); DELETE FROM audit_event WHERE resource_id IN(SELECT public_id FROM capacity_privacy UNION SELECT public_id FROM capacity_subject UNION SELECT '$householdPublicId'::uuid); DELETE FROM privacy_request WHERE id IN(SELECT id FROM capacity_privacy); DELETE FROM subject_profile WHERE id IN(SELECT id FROM capacity_subject); DELETE FROM household_member WHERE household_id='$householdInternalId'::uuid; DELETE FROM household WHERE id='$householdInternalId'::uuid; COMMIT; "@ try { Invoke-Sql $cleanup | Out-Null } catch { Write-Warning "Capacity cleanup failed: $_" } } if (Test-Path -LiteralPath $fixtureFile) { Remove-Item -LiteralPath $fixtureFile -Force } }