From 144a28f544a88102466cfc2c12854f53bd6a3b7c Mon Sep 17 00:00:00 2001 From: ZheFox <77232781+zhefox@users.noreply.github.com> Date: Wed, 2 Sep 2026 11:23:24 +0800 Subject: [PATCH] feat(admin-users): add plan entitlement revocation flow --- .../route/admin/operations_families.rs | 12 ++ .../src/control/tests/admin_users.rs | 16 ++ apps/aether-gateway/src/data/state/runtime.rs | 15 ++ .../src/handlers/admin/users/billing.rs | 70 +++++++ .../src/handlers/admin/users/mod.rs | 1 + .../src/handlers/admin/users/routes.rs | 16 +- .../src/state/runtime/billing/admin.rs | 26 ++- .../aether-data/adapters/mysql/src/billing.rs | 76 +++++++- .../adapters/mysql/src/settlement.rs | 47 ++++- .../adapters/postgres/src/billing.rs | 71 ++++++- .../adapters/postgres/src/settlement.rs | 42 ++++- .../adapters/sqlite/src/billing.rs | 177 +++++++++++++++++- .../adapters/sqlite/src/settlement.rs | 99 +++++++++- .../contracts/src/repository/billing/types.rs | 9 + .../runtime/src/repository/billing/memory.rs | 54 +++++- frontend/src/api/users.ts | 10 + .../users/components/UserPlanDialog.vue | 20 +- frontend/src/i18n/messages.ts | 7 + frontend/src/views/admin/Users.vue | 31 +++ 19 files changed, 726 insertions(+), 73 deletions(-) diff --git a/apps/aether-gateway/src/control/route/admin/operations_families.rs b/apps/aether-gateway/src/control/route/admin/operations_families.rs index 7ceca03cd..69cf18354 100644 --- a/apps/aether-gateway/src/control/route/admin/operations_families.rs +++ b/apps/aether-gateway/src/control/route/admin/operations_families.rs @@ -797,6 +797,18 @@ pub(super) fn classify_admin_operations_family_route( "admin:users", false, )) + } else if method == http::Method::DELETE + && normalized_path_no_trailing.starts_with("/api/admin/users/") + && normalized_path_no_trailing.contains("/billing/entitlements/") + && normalized_path_no_trailing.matches('/').count() == 7 + { + Some(classified( + "admin_proxy", + "users_manage", + "revoke_user_billing_entitlement", + "admin:users", + false, + )) } else if method == http::Method::GET && normalized_path.starts_with("/api/admin/users/") && normalized_path.ends_with("/sessions") diff --git a/apps/aether-gateway/src/control/tests/admin_users.rs b/apps/aether-gateway/src/control/tests/admin_users.rs index 17831adfc..4dcac4598 100644 --- a/apps/aether-gateway/src/control/tests/admin_users.rs +++ b/apps/aether-gateway/src/control/tests/admin_users.rs @@ -103,6 +103,22 @@ fn classifies_admin_user_billing_routes_as_admin_proxy_route() { Some("admin:users") ); + let revoke_uri: Uri = "/api/admin/users/user-1/billing/entitlements/entitlement-1" + .parse() + .expect("uri should parse"); + let revoke = classify_control_route(&http::Method::DELETE, &revoke_uri, &headers) + .expect("route should classify"); + assert_eq!(revoke.route_class.as_deref(), Some("admin_proxy")); + assert_eq!(revoke.route_family.as_deref(), Some("users_manage")); + assert_eq!( + revoke.route_kind.as_deref(), + Some("revoke_user_billing_entitlement") + ); + assert_eq!( + revoke.auth_endpoint_signature.as_deref(), + Some("admin:users") + ); + let context = GatewayPublicRequestContext::from_request_parts( "trace-user-billing-grant", &http::Method::POST, diff --git a/apps/aether-gateway/src/data/state/runtime.rs b/apps/aether-gateway/src/data/state/runtime.rs index 96cb79121..e8cf3cad6 100644 --- a/apps/aether-gateway/src/data/state/runtime.rs +++ b/apps/aether-gateway/src/data/state/runtime.rs @@ -2578,6 +2578,21 @@ impl GatewayDataState { } } + pub(crate) async fn revoke_user_plan_entitlement( + &self, + user_id: &str, + entitlement_id: &str, + ) -> Result, DataLayerError> { + match &self.billing_reader { + Some(repository) => { + repository + .revoke_user_plan_entitlement(user_id, entitlement_id) + .await + } + None => Ok(AdminBillingMutationOutcome::Unavailable), + } + } + pub(crate) async fn find_user_daily_quota_availability( &self, user_id: &str, diff --git a/apps/aether-gateway/src/handlers/admin/users/billing.rs b/apps/aether-gateway/src/handlers/admin/users/billing.rs index b12c67593..ae11fe53e 100644 --- a/apps/aether-gateway/src/handlers/admin/users/billing.rs +++ b/apps/aether-gateway/src/handlers/admin/users/billing.rs @@ -34,6 +34,22 @@ fn admin_user_id_from_billing_path(request_path: &str, suffix: &str) -> Option Option<(String, String)> { + let rest = request_path + .trim_end_matches('/') + .strip_prefix("/api/admin/users/")?; + let mut parts = rest.split('/'); + let user_id = parts.next()?.trim(); + if parts.next()? != "billing" || parts.next()? != "entitlements" { + return None; + } + let entitlement_id = parts.next()?.trim(); + if user_id.is_empty() || entitlement_id.is_empty() || parts.next().is_some() { + return None; + } + Some((user_id.to_string(), entitlement_id.to_string())) +} + fn admin_user_billing_operator_id(request_context: &AdminRequestContext<'_>) -> Option { request_context .decision() @@ -202,6 +218,60 @@ pub(in super::super) async fn build_admin_list_user_billing_entitlements_respons } } +pub(in super::super) async fn build_admin_revoke_user_billing_entitlement_response( + state: &AdminAppState<'_>, + request_context: &AdminRequestContext<'_>, +) -> Result, GatewayError> { + let Some((user_id, entitlement_id)) = + admin_user_entitlement_ids_from_path(request_context.path()) + else { + return Ok(build_admin_users_bad_request_response("缺少套餐权益 ID")); + }; + if state.find_user_auth_by_id(&user_id).await?.is_none() { + return Ok(( + http::StatusCode::NOT_FOUND, + Json(json!({ "detail": "用户不存在" })), + ) + .into_response()); + } + match state + .app() + .revoke_user_plan_entitlement(&user_id, &entitlement_id) + .await? + { + crate::LocalMutationOutcome::Applied(()) => {} + crate::LocalMutationOutcome::NotFound => { + return Ok(( + http::StatusCode::NOT_FOUND, + Json(json!({ "detail": "套餐权益不存在或已失效" })), + ) + .into_response()); + } + crate::LocalMutationOutcome::Invalid(detail) => { + return Ok(build_admin_users_bad_request_response(detail)); + } + crate::LocalMutationOutcome::Unavailable => { + return Ok(build_admin_users_data_unavailable_response()); + } + } + let entitlements = match load_admin_user_entitlements_payload(state, &user_id).await? { + Some(value) => value, + None => return Ok(build_admin_users_data_unavailable_response()), + }; + Ok(attach_admin_audit_response( + Json(json!({ + "items": entitlements["items"].clone(), + "entitlements": entitlements["items"].clone(), + "total": entitlements["total"].clone(), + })) + .into_response(), + "admin_user_plan_revoked", + "revoke_user_billing_entitlement", + "user_plan_entitlement", + &entitlement_id, + )) +} + pub(in super::super) async fn build_admin_grant_user_billing_plan_response( state: &AdminAppState<'_>, request_context: &AdminRequestContext<'_>, diff --git a/apps/aether-gateway/src/handlers/admin/users/mod.rs b/apps/aether-gateway/src/handlers/admin/users/mod.rs index 957cb7cf0..e5a854059 100644 --- a/apps/aether-gateway/src/handlers/admin/users/mod.rs +++ b/apps/aether-gateway/src/handlers/admin/users/mod.rs @@ -28,6 +28,7 @@ use self::batch::{ use self::billing::{ build_admin_grant_user_billing_plan_response, build_admin_list_user_billing_entitlements_response, + build_admin_revoke_user_billing_entitlement_response, }; use self::groups::{ build_admin_create_user_group_response, build_admin_delete_user_group_response, diff --git a/apps/aether-gateway/src/handlers/admin/users/routes.rs b/apps/aether-gateway/src/handlers/admin/users/routes.rs index ed0c2e4ca..9ec0a0186 100644 --- a/apps/aether-gateway/src/handlers/admin/users/routes.rs +++ b/apps/aether-gateway/src/handlers/admin/users/routes.rs @@ -8,10 +8,11 @@ use super::{ build_admin_list_user_group_members_response, build_admin_list_user_groups_response, build_admin_list_user_sessions_response, build_admin_list_users_response, build_admin_replace_user_group_members_response, build_admin_resolve_user_selection_response, - build_admin_reveal_user_api_key_response, build_admin_set_default_user_group_response, - build_admin_toggle_user_api_key_lock_response, build_admin_update_user_api_key_response, - build_admin_update_user_group_response, build_admin_update_user_response, - build_admin_user_batch_action_response, build_admin_users_data_unavailable_response, + build_admin_reveal_user_api_key_response, build_admin_revoke_user_billing_entitlement_response, + build_admin_set_default_user_group_response, build_admin_toggle_user_api_key_lock_response, + build_admin_update_user_api_key_response, build_admin_update_user_group_response, + build_admin_update_user_response, build_admin_user_batch_action_response, + build_admin_users_data_unavailable_response, }; use crate::handlers::admin::request::{AdminAppState, AdminRequestContext}; use crate::GatewayError; @@ -58,6 +59,10 @@ fn is_admin_users_route(request_context: &AdminRequestContext<'_>) -> bool { && path.starts_with("/api/admin/users/") && path.ends_with("/billing/grant-plan") && path.matches('/').count() == 6) + || (request_context.method() == http::Method::DELETE + && path.starts_with("/api/admin/users/") + && path.contains("/billing/entitlements/") + && path.matches('/').count() == 7) || ((request_context.method() == http::Method::GET || request_context.method() == http::Method::PUT || request_context.method() == http::Method::DELETE) @@ -155,6 +160,9 @@ pub(super) async fn maybe_build_local_admin_users_routes_response( build_admin_grant_user_billing_plan_response(state, request_context, request_body) .await?, )), + Some("revoke_user_billing_entitlement") => Ok(Some( + build_admin_revoke_user_billing_entitlement_response(state, request_context).await?, + )), Some("get_user") => Ok(Some( build_admin_get_user_response(state, request_context).await?, )), diff --git a/apps/aether-gateway/src/state/runtime/billing/admin.rs b/apps/aether-gateway/src/state/runtime/billing/admin.rs index e40b9f671..7eaeb4452 100644 --- a/apps/aether-gateway/src/state/runtime/billing/admin.rs +++ b/apps/aether-gateway/src/state/runtime/billing/admin.rs @@ -499,11 +499,16 @@ impl AppState { plan_id: &str, input: &BillingPlanWriteInput, ) -> Result, GatewayError> { - self.data + let outcome = self + .data .update_billing_plan(plan_id, input) .await .map(local_mutation_outcome) - .map_err(data_error) + .map_err(data_error)?; + if matches!(&outcome, LocalMutationOutcome::Applied(_)) { + self.invalidate_auth_context_cache(); + } + Ok(outcome) } pub(crate) async fn set_billing_plan_enabled( @@ -539,6 +544,23 @@ impl AppState { .map_err(data_error) } + pub(crate) async fn revoke_user_plan_entitlement( + &self, + user_id: &str, + entitlement_id: &str, + ) -> Result, GatewayError> { + let outcome = self + .data + .revoke_user_plan_entitlement(user_id, entitlement_id) + .await + .map(local_mutation_outcome) + .map_err(data_error)?; + if matches!(&outcome, LocalMutationOutcome::Applied(_)) { + self.invalidate_auth_context_cache(); + } + Ok(outcome) + } + pub(crate) async fn find_user_daily_quota_availability( &self, user_id: &str, diff --git a/crates/aether-data/adapters/mysql/src/billing.rs b/crates/aether-data/adapters/mysql/src/billing.rs index 0b38c435d..885b4286d 100644 --- a/crates/aether-data/adapters/mysql/src/billing.rs +++ b/crates/aether-data/adapters/mysql/src/billing.rs @@ -920,6 +920,39 @@ ORDER BY expires_at ASC, created_at ASC )) } + async fn revoke_user_plan_entitlement( + &self, + user_id: &str, + entitlement_id: &str, + ) -> Result, DataLayerError> { + let now = current_unix_secs_i64(); + let result = sqlx::query( + r#" +UPDATE user_plan_entitlements +SET status = 'revoked', + expires_at = LEAST(expires_at, ?), + updated_at = ? +WHERE id = ? + AND user_id = ? + AND status = 'active' + AND expires_at > ? + "#, + ) + .bind(now) + .bind(now) + .bind(entitlement_id) + .bind(user_id) + .bind(now) + .execute(&self.pool) + .await + .map_sql_err()?; + if result.rows_affected() == 0 { + Ok(AdminBillingMutationOutcome::NotFound) + } else { + Ok(AdminBillingMutationOutcome::Applied(())) + } + } + async fn find_user_daily_quota_availability( &self, user_id: &str, @@ -927,13 +960,19 @@ ORDER BY expires_at ASC, created_at ASC let now_unix_secs = current_unix_secs_i64(); let rows = sqlx::query( r#" -SELECT id, entitlements_snapshot +SELECT + user_plan_entitlements.id, + user_plan_entitlements.entitlements_snapshot, + billing_plans.entitlements_json AS plan_entitlements_json FROM user_plan_entitlements -WHERE user_id = ? - AND status = 'active' - AND starts_at <= ? - AND expires_at > ? -ORDER BY expires_at ASC, created_at ASC, id ASC +JOIN billing_plans ON billing_plans.id = user_plan_entitlements.plan_id +WHERE user_plan_entitlements.user_id = ? + AND user_plan_entitlements.status = 'active' + AND user_plan_entitlements.starts_at <= ? + AND user_plan_entitlements.expires_at > ? +ORDER BY user_plan_entitlements.expires_at ASC, + user_plan_entitlements.created_at ASC, + user_plan_entitlements.id ASC "#, ) .bind(user_id) @@ -948,9 +987,13 @@ ORDER BY expires_at ASC, created_at ASC, id ASC let entitlement_id: String = row.try_get("id").map_sql_err()?; let entitlements = parse_json(row.try_get("entitlements_snapshot").ok().flatten())? .unwrap_or_else(|| serde_json::json!([])); + let plan_entitlements = + parse_json(row.try_get("plan_entitlements_json").ok().flatten())? + .unwrap_or_else(|| serde_json::json!([])); grants.extend(daily_quota_grants_from_entitlement( &entitlement_id, &entitlements, + daily_quota_wallet_overage_policy(&plan_entitlements), now, )?); } @@ -1180,6 +1223,7 @@ fn daily_quota_usage_date( fn daily_quota_grants_from_entitlement( entitlement_id: &str, entitlements: &serde_json::Value, + current_allow_wallet_overage: Option, now: chrono::DateTime, ) -> Result, DataLayerError> { let mut grants = Vec::new(); @@ -1205,15 +1249,27 @@ fn daily_quota_grants_from_entitlement( .and_then(serde_json::Value::as_str), now, )?, - allow_wallet_overage: item - .get("allow_wallet_overage") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false), + allow_wallet_overage: current_allow_wallet_overage.unwrap_or_else(|| { + item.get("allow_wallet_overage") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) + }), }); } Ok(grants) } +fn daily_quota_wallet_overage_policy(entitlements: &serde_json::Value) -> Option { + entitlements.as_array()?.iter().find_map(|item| { + (item.get("type").and_then(serde_json::Value::as_str) == Some("daily_quota")) + .then(|| { + item.get("allow_wallet_overage") + .and_then(serde_json::Value::as_bool) + }) + .flatten() + }) +} + fn read_count_mysql(row: &MySqlRow) -> Result { Ok(row.try_get::("total").map_sql_err()?.max(0) as u64) } diff --git a/crates/aether-data/adapters/mysql/src/settlement.rs b/crates/aether-data/adapters/mysql/src/settlement.rs index 02e0d8e93..c6321e7ca 100644 --- a/crates/aether-data/adapters/mysql/src/settlement.rs +++ b/crates/aether-data/adapters/mysql/src/settlement.rs @@ -205,6 +205,7 @@ fn daily_quota_usage_date( fn daily_quota_grants_from_entitlement( entitlement_id: &str, entitlements: &serde_json::Value, + current_allow_wallet_overage: Option, now: chrono::DateTime, ) -> Result, DataLayerError> { let mut grants = Vec::new(); @@ -230,15 +231,27 @@ fn daily_quota_grants_from_entitlement( .and_then(serde_json::Value::as_str), now, )?, - allow_wallet_overage: item - .get("allow_wallet_overage") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false), + allow_wallet_overage: current_allow_wallet_overage.unwrap_or_else(|| { + item.get("allow_wallet_overage") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) + }), }); } Ok(grants) } +fn daily_quota_wallet_overage_policy(entitlements: &serde_json::Value) -> Option { + entitlements.as_array()?.iter().find_map(|item| { + (item.get("type").and_then(serde_json::Value::as_str) == Some("daily_quota")) + .then(|| { + item.get("allow_wallet_overage") + .and_then(serde_json::Value::as_bool) + }) + .flatten() + }) +} + async fn consume_daily_quota_mysql( tx: &mut sqlx::Transaction<'_, sqlx::MySql>, user_id: &str, @@ -253,13 +266,19 @@ async fn consume_daily_quota_mysql( } let rows = sqlx::query( r#" -SELECT id, entitlements_snapshot +SELECT + user_plan_entitlements.id, + user_plan_entitlements.entitlements_snapshot, + billing_plans.entitlements_json AS plan_entitlements_json FROM user_plan_entitlements -WHERE user_id = ? - AND status = 'active' - AND starts_at <= ? - AND expires_at > ? -ORDER BY expires_at ASC, created_at ASC, id ASC +JOIN billing_plans ON billing_plans.id = user_plan_entitlements.plan_id +WHERE user_plan_entitlements.user_id = ? + AND user_plan_entitlements.status = 'active' + AND user_plan_entitlements.starts_at <= ? + AND user_plan_entitlements.expires_at > ? +ORDER BY user_plan_entitlements.expires_at ASC, + user_plan_entitlements.created_at ASC, + user_plan_entitlements.id ASC FOR UPDATE "#, ) @@ -280,9 +299,17 @@ FOR UPDATE "user_plan_entitlements.entitlements_snapshot invalid json: {err}" )) })?; + let plan_entitlements_raw: String = row.try_get("plan_entitlements_json").map_sql_err()?; + let plan_entitlements = serde_json::from_str::(&plan_entitlements_raw) + .map_err(|err| { + DataLayerError::UnexpectedValue(format!( + "billing_plans.entitlements_json invalid json: {err}" + )) + })?; grants.extend(daily_quota_grants_from_entitlement( &entitlement_id, &entitlements, + daily_quota_wallet_overage_policy(&plan_entitlements), now, )?); } diff --git a/crates/aether-data/adapters/postgres/src/billing.rs b/crates/aether-data/adapters/postgres/src/billing.rs index d598fde12..30be5d238 100644 --- a/crates/aether-data/adapters/postgres/src/billing.rs +++ b/crates/aether-data/adapters/postgres/src/billing.rs @@ -999,19 +999,54 @@ ORDER BY expires_at ASC, created_at ASC )) } + async fn revoke_user_plan_entitlement( + &self, + user_id: &str, + entitlement_id: &str, + ) -> Result, DataLayerError> { + let result = sqlx::query( + r#" +UPDATE user_plan_entitlements +SET status = 'revoked', + expires_at = LEAST(expires_at, NOW()), + updated_at = NOW() +WHERE id = $1 + AND user_id = $2 + AND status = 'active' + AND expires_at > NOW() + "#, + ) + .bind(entitlement_id) + .bind(user_id) + .execute(&self.pool) + .await + .map_postgres_err()?; + if result.rows_affected() == 0 { + Ok(AdminBillingMutationOutcome::NotFound) + } else { + Ok(AdminBillingMutationOutcome::Applied(())) + } + } + async fn find_user_daily_quota_availability( &self, user_id: &str, ) -> Result, DataLayerError> { let rows = sqlx::query( r#" -SELECT id, entitlements_snapshot +SELECT + user_plan_entitlements.id, + user_plan_entitlements.entitlements_snapshot, + billing_plans.entitlements_json AS plan_entitlements_json FROM user_plan_entitlements -WHERE user_id = $1 - AND status = 'active' - AND starts_at <= NOW() - AND expires_at > NOW() -ORDER BY expires_at ASC, created_at ASC, id ASC +JOIN billing_plans ON billing_plans.id = user_plan_entitlements.plan_id +WHERE user_plan_entitlements.user_id = $1 + AND user_plan_entitlements.status = 'active' + AND user_plan_entitlements.starts_at <= NOW() + AND user_plan_entitlements.expires_at > NOW() +ORDER BY user_plan_entitlements.expires_at ASC, + user_plan_entitlements.created_at ASC, + user_plan_entitlements.id ASC "#, ) .bind(user_id) @@ -1024,9 +1059,12 @@ ORDER BY expires_at ASC, created_at ASC, id ASC let entitlement_id: String = row.try_get("id").map_postgres_err()?; let entitlements: serde_json::Value = row.try_get("entitlements_snapshot").map_postgres_err()?; + let plan_entitlements: serde_json::Value = + row.try_get("plan_entitlements_json").map_postgres_err()?; grants.extend(daily_quota_grants_from_entitlement( &entitlement_id, &entitlements, + daily_quota_wallet_overage_policy(&plan_entitlements), now, )?); } @@ -1160,6 +1198,7 @@ fn daily_quota_usage_date( fn daily_quota_grants_from_entitlement( entitlement_id: &str, entitlements: &serde_json::Value, + current_allow_wallet_overage: Option, now: chrono::DateTime, ) -> Result, DataLayerError> { let mut grants = Vec::new(); @@ -1185,15 +1224,27 @@ fn daily_quota_grants_from_entitlement( .and_then(serde_json::Value::as_str), now, )?, - allow_wallet_overage: item - .get("allow_wallet_overage") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false), + allow_wallet_overage: current_allow_wallet_overage.unwrap_or_else(|| { + item.get("allow_wallet_overage") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) + }), }); } Ok(grants) } +fn daily_quota_wallet_overage_policy(entitlements: &serde_json::Value) -> Option { + entitlements.as_array()?.iter().find_map(|item| { + (item.get("type").and_then(serde_json::Value::as_str) == Some("daily_quota")) + .then(|| { + item.get("allow_wallet_overage") + .and_then(serde_json::Value::as_bool) + }) + .flatten() + }) +} + fn map_payment_gateway_config_row( row: &sqlx::postgres::PgRow, ) -> Result { diff --git a/crates/aether-data/adapters/postgres/src/settlement.rs b/crates/aether-data/adapters/postgres/src/settlement.rs index f594c0413..4ff198a3c 100644 --- a/crates/aether-data/adapters/postgres/src/settlement.rs +++ b/crates/aether-data/adapters/postgres/src/settlement.rs @@ -272,6 +272,7 @@ fn daily_quota_usage_date( fn daily_quota_grants_from_entitlement( entitlement_id: &str, entitlements: &serde_json::Value, + current_allow_wallet_overage: Option, now: chrono::DateTime, ) -> Result, DataLayerError> { let mut grants = Vec::new(); @@ -298,15 +299,27 @@ fn daily_quota_grants_from_entitlement( entitlement_id: entitlement_id.to_string(), daily_quota_usd, usage_date, - allow_wallet_overage: item - .get("allow_wallet_overage") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false), + allow_wallet_overage: current_allow_wallet_overage.unwrap_or_else(|| { + item.get("allow_wallet_overage") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) + }), }); } Ok(grants) } +fn daily_quota_wallet_overage_policy(entitlements: &serde_json::Value) -> Option { + entitlements.as_array()?.iter().find_map(|item| { + (item.get("type").and_then(serde_json::Value::as_str) == Some("daily_quota")) + .then(|| { + item.get("allow_wallet_overage") + .and_then(serde_json::Value::as_bool) + }) + .flatten() + }) +} + async fn consume_daily_quota_postgres( tx: &mut crate::PostgresTransaction, user_id: &str, @@ -321,13 +334,19 @@ async fn consume_daily_quota_postgres( let now = chrono::Utc::now(); let entitlement_rows = sqlx::query( r#" -SELECT id, entitlements_snapshot +SELECT + user_plan_entitlements.id, + user_plan_entitlements.entitlements_snapshot, + billing_plans.entitlements_json AS plan_entitlements_json FROM user_plan_entitlements -WHERE user_id = $1 - AND status = 'active' - AND starts_at <= NOW() - AND expires_at > NOW() -ORDER BY expires_at ASC, created_at ASC, id ASC +JOIN billing_plans ON billing_plans.id = user_plan_entitlements.plan_id +WHERE user_plan_entitlements.user_id = $1 + AND user_plan_entitlements.status = 'active' + AND user_plan_entitlements.starts_at <= NOW() + AND user_plan_entitlements.expires_at > NOW() +ORDER BY user_plan_entitlements.expires_at ASC, + user_plan_entitlements.created_at ASC, + user_plan_entitlements.id ASC FOR UPDATE "#, ) @@ -340,9 +359,12 @@ FOR UPDATE let entitlement_id: String = row.try_get("id").map_postgres_err()?; let entitlements: serde_json::Value = row.try_get("entitlements_snapshot").map_postgres_err()?; + let plan_entitlements: serde_json::Value = + row.try_get("plan_entitlements_json").map_postgres_err()?; grants.extend(daily_quota_grants_from_entitlement( &entitlement_id, &entitlements, + daily_quota_wallet_overage_policy(&plan_entitlements), now, )?); } diff --git a/crates/aether-data/adapters/sqlite/src/billing.rs b/crates/aether-data/adapters/sqlite/src/billing.rs index f5242fb84..63a822b13 100644 --- a/crates/aether-data/adapters/sqlite/src/billing.rs +++ b/crates/aether-data/adapters/sqlite/src/billing.rs @@ -920,6 +920,40 @@ ORDER BY expires_at ASC, created_at ASC )) } + async fn revoke_user_plan_entitlement( + &self, + user_id: &str, + entitlement_id: &str, + ) -> Result, DataLayerError> { + let now = current_unix_secs_i64(); + let result = sqlx::query( + r#" +UPDATE user_plan_entitlements +SET status = 'revoked', + expires_at = CASE WHEN expires_at > ? THEN ? ELSE expires_at END, + updated_at = ? +WHERE id = ? + AND user_id = ? + AND status = 'active' + AND expires_at > ? + "#, + ) + .bind(now) + .bind(now) + .bind(now) + .bind(entitlement_id) + .bind(user_id) + .bind(now) + .execute(&self.pool) + .await + .map_sql_err()?; + if result.rows_affected() == 0 { + Ok(AdminBillingMutationOutcome::NotFound) + } else { + Ok(AdminBillingMutationOutcome::Applied(())) + } + } + async fn find_user_daily_quota_availability( &self, user_id: &str, @@ -927,13 +961,19 @@ ORDER BY expires_at ASC, created_at ASC let now_unix_secs = current_unix_secs_i64(); let rows = sqlx::query( r#" -SELECT id, entitlements_snapshot +SELECT + user_plan_entitlements.id, + user_plan_entitlements.entitlements_snapshot, + billing_plans.entitlements_json AS plan_entitlements_json FROM user_plan_entitlements -WHERE user_id = ? - AND status = 'active' - AND starts_at <= ? - AND expires_at > ? -ORDER BY expires_at ASC, created_at ASC, id ASC +JOIN billing_plans ON billing_plans.id = user_plan_entitlements.plan_id +WHERE user_plan_entitlements.user_id = ? + AND user_plan_entitlements.status = 'active' + AND user_plan_entitlements.starts_at <= ? + AND user_plan_entitlements.expires_at > ? +ORDER BY user_plan_entitlements.expires_at ASC, + user_plan_entitlements.created_at ASC, + user_plan_entitlements.id ASC "#, ) .bind(user_id) @@ -948,9 +988,13 @@ ORDER BY expires_at ASC, created_at ASC, id ASC let entitlement_id: String = row.try_get("id").map_sql_err()?; let entitlements = parse_json(row.try_get("entitlements_snapshot").ok().flatten())? .unwrap_or_else(|| serde_json::json!([])); + let plan_entitlements = + parse_json(row.try_get("plan_entitlements_json").ok().flatten())? + .unwrap_or_else(|| serde_json::json!([])); grants.extend(daily_quota_grants_from_entitlement( &entitlement_id, &entitlements, + daily_quota_wallet_overage_policy(&plan_entitlements), now, )?); } @@ -1174,6 +1218,7 @@ fn daily_quota_usage_date( fn daily_quota_grants_from_entitlement( entitlement_id: &str, entitlements: &serde_json::Value, + current_allow_wallet_overage: Option, now: chrono::DateTime, ) -> Result, DataLayerError> { let mut grants = Vec::new(); @@ -1199,15 +1244,27 @@ fn daily_quota_grants_from_entitlement( .and_then(serde_json::Value::as_str), now, )?, - allow_wallet_overage: item - .get("allow_wallet_overage") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false), + allow_wallet_overage: current_allow_wallet_overage.unwrap_or_else(|| { + item.get("allow_wallet_overage") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) + }), }); } Ok(grants) } +fn daily_quota_wallet_overage_policy(entitlements: &serde_json::Value) -> Option { + entitlements.as_array()?.iter().find_map(|item| { + (item.get("type").and_then(serde_json::Value::as_str) == Some("daily_quota")) + .then(|| { + item.get("allow_wallet_overage") + .and_then(serde_json::Value::as_bool) + }) + .flatten() + }) +} + fn read_count_sqlite(row: &SqliteRow) -> Result { Ok(row.try_get::("total").map_sql_err()?.max(0) as u64) } @@ -1558,6 +1615,106 @@ mod tests { assert_eq!(preset.errors, Vec::::new()); } + #[tokio::test] + async fn sqlite_repository_revokes_active_user_plan_entitlement() { + let pool = sqlx::sqlite::SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .expect("sqlite pool should connect"); + run_migrations(&pool) + .await + .expect("sqlite migrations should run"); + let now = super::current_unix_secs_i64(); + sqlx::query( + r#" +INSERT INTO users ( + id, username, email, role, auth_source, password_hash, is_active, + is_deleted, created_at, updated_at +) VALUES ( + 'user-revoke', 'revoke-user', 'revoke@example.com', 'user', 'local', + 'hash', 1, 0, 1, 1 +); +INSERT INTO wallets ( + id, user_id, balance, gift_balance, limit_mode, created_at, updated_at +) VALUES ( + 'wallet-revoke', 'user-revoke', 5.0, 0.0, 'finite', 1, 1 +); +INSERT INTO billing_plans ( + id, title, price_amount, price_currency, duration_unit, + duration_value, entitlements_json, created_at, updated_at +) VALUES ( + 'plan-revoke', 'Revocable Plan', 0.0, 'USD', 'month', 1, + '[{"type":"daily_quota","daily_quota_usd":10.0,"allow_wallet_overage":true}]', + 1, 1 +); +INSERT INTO payment_orders ( + id, order_no, wallet_id, user_id, amount_usd, refunded_amount_usd, + refundable_amount_usd, payment_method, gateway_response, status, created_at +) VALUES ( + 'order-revoke', 'order-revoke', 'wallet-revoke', 'user-revoke', 0.0, 0.0, + 0.0, 'admin_manual', '{}', 'credited', 1 +); +INSERT INTO user_plan_entitlements ( + id, user_id, plan_id, payment_order_id, status, starts_at, expires_at, + entitlements_snapshot, created_at, updated_at +) VALUES ( + 'entitlement-revoke', 'user-revoke', 'plan-revoke', 'order-revoke', + 'active', ?, ?, + '[{"type":"daily_quota","daily_quota_usd":10.0,"allow_wallet_overage":false}]', + ?, ? +); +"#, + ) + .bind(now - 60) + .bind(now + 3600) + .bind(now - 60) + .bind(now - 60) + .execute(&pool) + .await + .expect("revocable entitlement should seed"); + let repository = SqliteBillingReadRepository::new(pool.clone()); + + let quota = repository + .find_user_daily_quota_availability("user-revoke") + .await + .expect("quota should load") + .expect("quota should be available"); + assert!(quota.has_active_daily_quota); + assert!(quota.allow_wallet_overage); + + let wrong_user = repository + .revoke_user_plan_entitlement("other-user", "entitlement-revoke") + .await + .expect("ownership check should run"); + assert_eq!(wrong_user, AdminBillingMutationOutcome::NotFound); + + let outcome = repository + .revoke_user_plan_entitlement("user-revoke", "entitlement-revoke") + .await + .expect("entitlement revoke should run"); + assert_eq!(outcome, AdminBillingMutationOutcome::Applied(())); + let active = repository + .list_user_plan_entitlements("user-revoke") + .await + .expect("entitlements should load") + .expect("entitlements should be available"); + assert!(active.is_empty()); + let quota = repository + .find_user_daily_quota_availability("user-revoke") + .await + .expect("quota should load") + .expect("quota should be available"); + assert!(!quota.has_active_daily_quota); + let status: String = sqlx::query_scalar( + "SELECT status FROM user_plan_entitlements WHERE id = 'entitlement-revoke'", + ) + .fetch_one(&pool) + .await + .expect("entitlement status should load"); + assert_eq!(status, "revoked"); + } + #[tokio::test] async fn sqlite_repository_deletes_unused_billing_plans_only() { let pool = sqlx::sqlite::SqlitePoolOptions::new() diff --git a/crates/aether-data/adapters/sqlite/src/settlement.rs b/crates/aether-data/adapters/sqlite/src/settlement.rs index ef77b96d5..f1c0878a0 100644 --- a/crates/aether-data/adapters/sqlite/src/settlement.rs +++ b/crates/aether-data/adapters/sqlite/src/settlement.rs @@ -219,6 +219,7 @@ fn daily_quota_usage_date( fn daily_quota_grants_from_entitlement( entitlement_id: &str, entitlements: &serde_json::Value, + current_allow_wallet_overage: Option, now: chrono::DateTime, ) -> Result, DataLayerError> { let mut grants = Vec::new(); @@ -244,15 +245,27 @@ fn daily_quota_grants_from_entitlement( .and_then(serde_json::Value::as_str), now, )?, - allow_wallet_overage: item - .get("allow_wallet_overage") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false), + allow_wallet_overage: current_allow_wallet_overage.unwrap_or_else(|| { + item.get("allow_wallet_overage") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) + }), }); } Ok(grants) } +fn daily_quota_wallet_overage_policy(entitlements: &serde_json::Value) -> Option { + entitlements.as_array()?.iter().find_map(|item| { + (item.get("type").and_then(serde_json::Value::as_str) == Some("daily_quota")) + .then(|| { + item.get("allow_wallet_overage") + .and_then(serde_json::Value::as_bool) + }) + .flatten() + }) +} + async fn consume_daily_quota_sqlite( tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, user_id: &str, @@ -267,13 +280,19 @@ async fn consume_daily_quota_sqlite( } let rows = sqlx::query( r#" -SELECT id, entitlements_snapshot +SELECT + user_plan_entitlements.id, + user_plan_entitlements.entitlements_snapshot, + billing_plans.entitlements_json AS plan_entitlements_json FROM user_plan_entitlements -WHERE user_id = ? - AND status = 'active' - AND starts_at <= ? - AND expires_at > ? -ORDER BY expires_at ASC, created_at ASC, id ASC +JOIN billing_plans ON billing_plans.id = user_plan_entitlements.plan_id +WHERE user_plan_entitlements.user_id = ? + AND user_plan_entitlements.status = 'active' + AND user_plan_entitlements.starts_at <= ? + AND user_plan_entitlements.expires_at > ? +ORDER BY user_plan_entitlements.expires_at ASC, + user_plan_entitlements.created_at ASC, + user_plan_entitlements.id ASC "#, ) .bind(user_id) @@ -293,9 +312,17 @@ ORDER BY expires_at ASC, created_at ASC, id ASC "user_plan_entitlements.entitlements_snapshot invalid json: {err}" )) })?; + let plan_entitlements_raw: String = row.try_get("plan_entitlements_json").map_sql_err()?; + let plan_entitlements = serde_json::from_str::(&plan_entitlements_raw) + .map_err(|err| { + DataLayerError::UnexpectedValue(format!( + "billing_plans.entitlements_json invalid json: {err}" + )) + })?; grants.extend(daily_quota_grants_from_entitlement( &entitlement_id, &entitlements, + daily_quota_wallet_overage_policy(&plan_entitlements), now, )?); } @@ -1012,6 +1039,58 @@ WHERE request_id = 'request-1' assert_eq!(quota_used, 10.0); } + #[tokio::test] + async fn sqlite_repository_uses_current_plan_wallet_overage_policy() { + let pool = sqlx::sqlite::SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .expect("sqlite pool should connect"); + run_migrations(&pool) + .await + .expect("sqlite migrations should run"); + seed_quota_covered_settlement_rows(&pool).await; + sqlx::query( + r#" +UPDATE wallets SET balance = 5.0 WHERE id = 'wallet-quota'; +UPDATE billing_plans +SET entitlements_json = '[{"type":"daily_quota","daily_quota_usd":10.0,"reset_timezone":"Asia/Shanghai","allow_wallet_overage":true}]' +WHERE id = 'plan-quota'; +"#, + ) + .execute(&pool) + .await + .expect("plan overage policy should update"); + + let repository = SqliteSettlementRepository::new(pool.clone()); + let settlement = repository + .settle_usage(UsageSettlementInput { + request_id: "request-quota-overrun".to_string(), + user_id: Some("user-quota".to_string()), + api_key_id: Some("key-quota".to_string()), + api_key_is_standalone: false, + provider_id: None, + status: "completed".to_string(), + billing_status: "pending".to_string(), + total_cost_usd: 12.0, + actual_total_cost_usd: 12.0, + finalized_at_unix_secs: Some(1_261), + }) + .await + .expect("settlement should run") + .expect("usage should exist"); + + assert_eq!(settlement.billing_status, "settled"); + assert_eq!(settlement.wallet_balance_after, Some(3.0)); + let quota_used: f64 = sqlx::query_scalar( + "SELECT CAST(COALESCE(SUM(amount_usd), 0) AS REAL) FROM entitlement_usage_ledgers WHERE request_id = 'request-quota-overrun'", + ) + .fetch_one(&pool) + .await + .expect("quota ledger should load"); + assert_eq!(quota_used, 10.0); + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn sqlite_repository_exhausts_strict_quota_across_concurrent_requests() { let database_path = std::env::temp_dir().join(format!( diff --git a/crates/aether-data/contracts/src/repository/billing/types.rs b/crates/aether-data/contracts/src/repository/billing/types.rs index 2ec998899..6ec2ed60b 100644 --- a/crates/aether-data/contracts/src/repository/billing/types.rs +++ b/crates/aether-data/contracts/src/repository/billing/types.rs @@ -436,6 +436,15 @@ pub trait BillingReadRepository: Send + Sync { Ok(None) } + async fn revoke_user_plan_entitlement( + &self, + user_id: &str, + entitlement_id: &str, + ) -> Result, crate::DataLayerError> { + let _ = (user_id, entitlement_id); + Ok(AdminBillingMutationOutcome::Unavailable) + } + async fn find_user_daily_quota_availability( &self, user_id: &str, diff --git a/crates/aether-data/runtime/src/repository/billing/memory.rs b/crates/aether-data/runtime/src/repository/billing/memory.rs index 8f07894df..aae6cc4f9 100644 --- a/crates/aether-data/runtime/src/repository/billing/memory.rs +++ b/crates/aether-data/runtime/src/repository/billing/memory.rs @@ -75,6 +75,7 @@ fn billing_plan_from_input( fn daily_quota_availability_from_entitlements( entitlements: impl IntoIterator, + billing_plans: &BTreeMap, now: u64, ) -> UserDailyQuotaAvailabilityRecord { let mut has_active_daily_quota = false; @@ -92,6 +93,9 @@ fn daily_quota_availability_from_entitlements( let Some(items) = entitlement.entitlements_snapshot.as_array() else { continue; }; + let current_allow_wallet_overage = billing_plans + .get(&entitlement.plan_id) + .and_then(|plan| daily_quota_wallet_overage_policy(&plan.entitlements_json)); for item in items { if item.get("type").and_then(serde_json::Value::as_str) != Some("daily_quota") { continue; @@ -106,10 +110,11 @@ fn daily_quota_availability_from_entitlements( has_active_daily_quota = true; total_quota_usd += daily_quota_usd; remaining_usd += daily_quota_usd; - allow_wallet_overage &= item - .get("allow_wallet_overage") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false); + allow_wallet_overage &= current_allow_wallet_overage.unwrap_or_else(|| { + item.get("allow_wallet_overage") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) + }); } } UserDailyQuotaAvailabilityRecord { @@ -121,6 +126,17 @@ fn daily_quota_availability_from_entitlements( } } +fn daily_quota_wallet_overage_policy(entitlements: &serde_json::Value) -> Option { + entitlements.as_array()?.iter().find_map(|item| { + (item.get("type").and_then(serde_json::Value::as_str) == Some("daily_quota")) + .then(|| { + item.get("allow_wallet_overage") + .and_then(serde_json::Value::as_bool) + }) + .flatten() + }) +} + #[async_trait] impl BillingReadRepository for InMemoryBillingReadRepository { async fn find_model_context( @@ -366,6 +382,31 @@ impl BillingReadRepository for InMemoryBillingReadRepository { Ok(Some(items)) } + async fn revoke_user_plan_entitlement( + &self, + user_id: &str, + entitlement_id: &str, + ) -> Result, DataLayerError> { + let now = current_unix_secs(); + let mut entitlements = self + .entitlements_by_id + .write() + .expect("billing repository lock"); + let Some(entitlement) = entitlements.get_mut(entitlement_id) else { + return Ok(AdminBillingMutationOutcome::NotFound); + }; + if entitlement.user_id != user_id + || entitlement.status != "active" + || entitlement.expires_at_unix_secs <= now + { + return Ok(AdminBillingMutationOutcome::NotFound); + } + entitlement.status = "revoked".to_string(); + entitlement.expires_at_unix_secs = entitlement.expires_at_unix_secs.min(now); + entitlement.updated_at_unix_secs = now; + Ok(AdminBillingMutationOutcome::Applied(())) + } + async fn find_user_daily_quota_availability( &self, user_id: &str, @@ -379,8 +420,13 @@ impl BillingReadRepository for InMemoryBillingReadRepository { .filter(|item| item.user_id == user_id) .cloned() .collect::>(); + let billing_plans = self + .billing_plans_by_id + .read() + .expect("billing repository lock"); Ok(Some(daily_quota_availability_from_entitlements( entitlements, + &billing_plans, now, ))) } diff --git a/frontend/src/api/users.ts b/frontend/src/api/users.ts index bc4b682d0..fed901bdb 100644 --- a/frontend/src/api/users.ts +++ b/frontend/src/api/users.ts @@ -448,6 +448,16 @@ export const usersApi = { return response.data }, + async revokeUserPlanEntitlement( + userId: string, + entitlementId: string + ): Promise { + const response = await apiClient.delete( + `/api/admin/users/${userId}/billing/entitlements/${entitlementId}` + ) + return response.data + }, + async revokeUserSession(userId: string, sessionId: string): Promise<{ message: string }> { const response = await apiClient.delete<{ message: string }>(`/api/admin/users/${userId}/sessions/${sessionId}`) return response.data diff --git a/frontend/src/features/users/components/UserPlanDialog.vue b/frontend/src/features/users/components/UserPlanDialog.vue index 5787e4369..db7b2f41a 100644 --- a/frontend/src/features/users/components/UserPlanDialog.vue +++ b/frontend/src/features/users/components/UserPlanDialog.vue @@ -88,9 +88,21 @@ -
-
{{ legacyT('开始:') }}{{ formatDateTime(item.starts_at) }}
-
{{ legacyT('到期:') }}{{ formatDateTime(item.expires_at) }}
+
+
+
{{ legacyT('开始:') }}{{ formatDateTime(item.starts_at) }}
+
{{ legacyT('到期:') }}{{ formatDateTime(item.expires_at) }}
+
+
@@ -199,6 +211,7 @@ defineProps<{ loadingEntitlements: boolean loadingPlans: boolean granting: boolean + revokingEntitlementId?: string | null formatDateTime: (value?: string | null) => string formatPlanPrice: (plan: BillingPlan) => string formatPlanDuration: (plan: BillingPlan) => string @@ -211,6 +224,7 @@ defineEmits<{ 'update:grantReason': [value: string] 'refresh-entitlements': [userId: string] grant: [] + revoke: [entitlement: AdminUserPlanEntitlement] }>() const { legacyT } = useI18n() diff --git a/frontend/src/i18n/messages.ts b/frontend/src/i18n/messages.ts index ca8d0cef4..35554e192 100644 --- a/frontend/src/i18n/messages.ts +++ b/frontend/src/i18n/messages.ts @@ -1237,6 +1237,13 @@ const legacyExactEnglishMessages: Record = { '加载套餐列表失败': 'Failed to load plan list', '套餐已发放': 'Plan granted', '发放套餐失败': 'Failed to grant plan', + '撤销套餐': 'Revoke plan', + '撤销中...': 'Revoking...', + '撤销用户套餐': 'Revoke user plan', + '确认撤销': 'Confirm revoke', + '套餐已撤销': 'Plan revoked', + '撤销套餐失败': 'Failed to revoke plan', + '撤销后该用户将立即失去该套餐的剩余额度和会员权益,历史订单与使用记录会保留。': 'The user will immediately lose the plan’s remaining quota and membership benefits. Historical orders and usage records will be retained.', '请输入密钥名称': 'Enter a key name', '更新 API Key 失败': 'Failed to update API key', '创建 API Key 失败': 'Failed to create API key', diff --git a/frontend/src/views/admin/Users.vue b/frontend/src/views/admin/Users.vue index 2c3f68cf6..d38ff4139 100644 --- a/frontend/src/views/admin/Users.vue +++ b/frontend/src/views/admin/Users.vue @@ -120,6 +120,7 @@ :loading-entitlements="loadingUserPlans" :loading-plans="loadingBillingPlans" :granting="grantingUserPlan" + :revoking-entitlement-id="revokingUserPlanEntitlementId" :format-date-time="formatDateTime" :format-plan-price="formatPlanPrice" :format-plan-duration="formatPlanDuration" @@ -129,6 +130,7 @@ @update:grant-reason="grantReason = $event" @refresh-entitlements="loadUserPlanEntitlements" @grant="grantPlanToSelectedUser" + @revoke="revokePlanFromSelectedUser" /> (null) const sessionDialogActionLoading = ref(null) const editingUserApiKey = ref(null) const userApiKeyForm = ref({ @@ -820,6 +823,7 @@ async function manageUserSessions(user: User) { async function manageUserPlans(user: User) { selectedUser.value = user showUserPlansDialog.value = true + revokingUserPlanEntitlementId.value = null selectedGrantPlanId.value = '' grantReason.value = '' await Promise.all([ @@ -881,6 +885,33 @@ async function grantPlanToSelectedUser() { } } +async function revokePlanFromSelectedUser(entitlement: AdminUserPlanEntitlement) { + if (!selectedUser.value || revokingUserPlanEntitlementId.value) return + const userId = selectedUser.value.id + const planTitle = entitlement.plan_title || entitlement.plan?.title || entitlement.plan_id + const confirmed = await confirmDanger( + `${planTitle}\n\n${legacyT('撤销后该用户将立即失去该套餐的剩余额度和会员权益,历史订单与使用记录会保留。')}`, + legacyT('撤销用户套餐'), + legacyT('确认撤销'), + ) + if (!confirmed) return + revokingUserPlanEntitlementId.value = entitlement.id + try { + const response = await usersApi.revokeUserPlanEntitlement( + userId, + entitlement.id, + ) + if (selectedUser.value?.id === userId) { + userPlanEntitlements.value = response.items + } + success(legacyT('套餐已撤销')) + } catch (err) { + error(localizedApiError(err, '撤销套餐失败'), legacyT('撤销套餐失败')) + } finally { + revokingUserPlanEntitlementId.value = null + } +} + async function loadUserApiKeys(userId: string) { const requestId = ++userApiKeysRequestId try {