From 1665ed16b16df4c8babcdd005b1717091bf75d49 Mon Sep 17 00:00:00 2001 From: nanxun Date: Tue, 11 Aug 2026 18:04:45 +0800 Subject: [PATCH] feat: add shared PostgreSQL client deletion --- fnos-postgresql/manifest | 2 +- frontend/postgres-admin/App.vue | 17 +++++++- scripts/smoke-postgresql-fnos-package.sh | 16 +++++++ .../PostgresAdminService.cs | 42 +++++++++++++++++++ src/PostgresService.WebApi/Program.cs | 6 +++ 5 files changed, 81 insertions(+), 2 deletions(-) diff --git a/fnos-postgresql/manifest b/fnos-postgresql/manifest index 5dbb707..a9f4054 100644 --- a/fnos-postgresql/manifest +++ b/fnos-postgresql/manifest @@ -1,5 +1,5 @@ appname=nxsir.postgresql -version=15.1.1 +version=15.1.2 display_name=PostgreSQL 共享服务 desc=面向 fnOS 应用的原生 PostgreSQL 15 共享数据库服务,包含 pgvector、安全凭据签发、管理面板和手动备份恢复 platform=x86 diff --git a/frontend/postgres-admin/App.vue b/frontend/postgres-admin/App.vue index d1678a3..5b52a98 100644 --- a/frontend/postgres-admin/App.vue +++ b/frontend/postgres-admin/App.vue @@ -161,6 +161,21 @@ async function revokeClient(client: Client) { await loadAll(); } +async function deleteClient(client: Client) { + await ElMessageBox.confirm( + `删除后 ${client.displayName} 的数据库 ${client.databaseName} 和角色 ${client.roleName} 将被彻底销毁,不可恢复。`, + "删除接入应用", + { type: "warning", confirmButtonText: "确认删除", confirmButtonClass: "el-button--danger" } + ); + const { value } = await ElMessageBox.prompt( + `请输入 ${client.appId} 确认删除。`, + "二次确认", + { type: "warning" } + ); + await api(`/api/v1/clients/${encodeURIComponent(client.appId)}?confirmation=${encodeURIComponent(value)}`, { method: "DELETE" }); + await loadAll(); +} + async function createDatabase() { const { value: name } = await ElMessageBox.prompt("仅允许小写字母、数字和下划线。", "新建数据库", { inputPattern: /^[a-z][a-z0-9_]{2,62}$/, inputErrorMessage: "数据库名称格式无效" }); await api("/api/v1/databases", { method: "POST", body: JSON.stringify({ name }) }); @@ -285,7 +300,7 @@ onMounted(async () => { - + diff --git a/scripts/smoke-postgresql-fnos-package.sh b/scripts/smoke-postgresql-fnos-package.sh index 46bb406..10aeb8e 100755 --- a/scripts/smoke-postgresql-fnos-package.sh +++ b/scripts/smoke-postgresql-fnos-package.sh @@ -87,6 +87,7 @@ enroll() { enroll liverecorder 'Live Recorder' '[]' "$WORK_DIR/live.json" enroll imagefind-test 'ImageFind Test Client' '["vector"]' "$WORK_DIR/image.json" +enroll delete-test 'Disposable Test Client' '[]' "$WORK_DIR/delete.json" json_field() { field=$1 @@ -100,8 +101,11 @@ LIVE_PASSWORD=$(json_field password "$WORK_DIR/live.json") IMAGE_DB=$(json_field database "$WORK_DIR/image.json") IMAGE_USER=$(json_field username "$WORK_DIR/image.json") IMAGE_PASSWORD=$(json_field password "$WORK_DIR/image.json") +DELETE_DB=$(json_field database "$WORK_DIR/delete.json") +DELETE_USER=$(json_field username "$WORK_DIR/delete.json") test -n "$LIVE_DB" && test -n "$LIVE_USER" && test -n "$LIVE_PASSWORD" test -n "$IMAGE_DB" && test -n "$IMAGE_USER" && test -n "$IMAGE_PASSWORD" +test -n "$DELETE_DB" && test -n "$DELETE_USER" test "$LIVE_DB" != "$IMAGE_DB" test "$LIVE_USER" != "$IMAGE_USER" @@ -124,6 +128,18 @@ if run_client_psql "$LIVE_PASSWORD" -h 127.0.0.1 -p "$PG_PORT" -U "$LIVE_USER" - exit 1 fi +if curl -fsS -b "$COOKIE_JAR" -X DELETE \ + "$BASE_URL/api/v1/clients/delete-test?confirmation=wrong-name" >/dev/null 2>&1; then + printf '%s\n' 'client deletion accepted a mismatched confirmation' >&2 + exit 1 +fi +curl -fsS -b "$COOKIE_JAR" -X DELETE \ + "$BASE_URL/api/v1/clients/delete-test?confirmation=delete-test" >/dev/null +test -z "$(run_client_psql "$LIVE_PASSWORD" -h 127.0.0.1 -p "$PG_PORT" -U "$LIVE_USER" -d postgres \ + -Atqc "SELECT 1 FROM pg_database WHERE datname='$DELETE_DB'")" +test -z "$(run_client_psql "$LIVE_PASSWORD" -h 127.0.0.1 -p "$PG_PORT" -U "$LIVE_USER" -d postgres \ + -Atqc "SELECT 1 FROM pg_roles WHERE rolname='$DELETE_USER'")" + curl -fsS -b "$COOKIE_JAR" -H 'Content-Type: application/json' \ --data "{\"database\":\"$IMAGE_DB\",\"sql\":\"SELECT count(*) FROM smoke_vectors\"}" \ "$BASE_URL/api/v1/query" | grep -q '"rowCount":1' diff --git a/src/PostgresService.WebApi/PostgresAdminService.cs b/src/PostgresService.WebApi/PostgresAdminService.cs index e451e54..494d190 100644 --- a/src/PostgresService.WebApi/PostgresAdminService.cs +++ b/src/PostgresService.WebApi/PostgresAdminService.cs @@ -265,6 +265,48 @@ public sealed class PostgresAdminService await AuditAsync("client.revoke", appId, new { client.RoleName }, cancellationToken); } + public async Task DeleteClientAsync(string appId, string confirmation, CancellationToken cancellationToken) + { + if (confirmation != appId) + { + throw new ArgumentException("确认名称不匹配。"); + } + + var client = await FindClientAsync(appId, cancellationToken) + ?? throw new KeyNotFoundException("客户端不存在。"); + + await _provisionLock.WaitAsync(cancellationToken); + try + { + await using (var connection = await OpenAsync("postgres", cancellationToken)) + { + await ExecuteNonQueryAsync( + connection, + $"DROP DATABASE IF EXISTS {QuoteIdentifier(client.DatabaseName)} WITH (FORCE)", + cancellationToken); + await ExecuteNonQueryAsync( + connection, + $"DROP ROLE IF EXISTS {QuoteIdentifier(client.RoleName)}", + cancellationToken); + } + + await using (var metadata = await OpenAsync(MetadataDatabase, cancellationToken)) + await using (var command = new NpgsqlCommand( + "DELETE FROM managed_clients WHERE app_id = @appId", + metadata)) + { + command.Parameters.AddWithValue("appId", appId); + await command.ExecuteNonQueryAsync(cancellationToken); + } + + await AuditAsync("client.delete", appId, new { client.DatabaseName, client.RoleName }, cancellationToken); + } + finally + { + _provisionLock.Release(); + } + } + public async Task> ListDatabasesAsync(CancellationToken cancellationToken) { var clients = await ListClientsAsync(cancellationToken); diff --git a/src/PostgresService.WebApi/Program.cs b/src/PostgresService.WebApi/Program.cs index 462cd14..cdece6f 100644 --- a/src/PostgresService.WebApi/Program.cs +++ b/src/PostgresService.WebApi/Program.cs @@ -146,6 +146,12 @@ app.MapPost("/api/v1/clients/{appId}/revoke", async (string appId, CancellationT await postgres.RevokeClientAsync(appId, cancellationToken); return Results.NoContent(); }); +app.MapDelete("/api/v1/clients/{appId}", async ( + string appId, string confirmation, CancellationToken cancellationToken) => +{ + await postgres.DeleteClientAsync(appId, confirmation, cancellationToken); + return Results.NoContent(); +}); app.MapPost("/api/v1/enrollment-token/rotate", () => Results.Ok(new { token = secretStore.RotateEnrollmentToken() }));