feat(admin-users): add plan entitlement revocation flow

This commit is contained in:
ZheFox
2026-09-02 11:23:24 +08:00
parent 6540c1e46a
commit 144a28f544
19 changed files with 726 additions and 73 deletions
@@ -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")
@@ -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,
@@ -2578,6 +2578,21 @@ impl GatewayDataState {
}
}
pub(crate) async fn revoke_user_plan_entitlement(
&self,
user_id: &str,
entitlement_id: &str,
) -> Result<AdminBillingMutationOutcome<()>, 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,
@@ -34,6 +34,22 @@ fn admin_user_id_from_billing_path(request_path: &str, suffix: &str) -> Option<S
}
}
fn admin_user_entitlement_ids_from_path(request_path: &str) -> 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<String> {
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<Response<Body>, 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<'_>,
@@ -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,
@@ -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?,
)),
@@ -499,11 +499,16 @@ impl AppState {
plan_id: &str,
input: &BillingPlanWriteInput,
) -> Result<LocalMutationOutcome<BillingPlanRecord>, 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<LocalMutationOutcome<()>, 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,