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 () => {
{{ row.extensions.join(", ") || "-" }}
{{ row.status === "active" ? "正常" : "已吊销" }}
- 轮换密码吊销
+ 轮换密码吊销删除
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() }));