mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-12 06:00:20 +08:00
feat(admin-users): add plan entitlement revocation flow
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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<AdminBillingMutationOutcome<()>, 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<bool>,
|
||||
now: chrono::DateTime<chrono::Utc>,
|
||||
) -> Result<Vec<DailyQuotaGrant>, 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<bool> {
|
||||
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<u64, DataLayerError> {
|
||||
Ok(row.try_get::<i64, _>("total").map_sql_err()?.max(0) as u64)
|
||||
}
|
||||
|
||||
@@ -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<bool>,
|
||||
now: chrono::DateTime<chrono::Utc>,
|
||||
) -> Result<Vec<DailyQuotaGrant>, 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<bool> {
|
||||
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::<serde_json::Value>(&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,
|
||||
)?);
|
||||
}
|
||||
|
||||
@@ -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<AdminBillingMutationOutcome<()>, 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<Option<UserDailyQuotaAvailabilityRecord>, 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<bool>,
|
||||
now: chrono::DateTime<chrono::Utc>,
|
||||
) -> Result<Vec<DailyQuotaGrant>, 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<bool> {
|
||||
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<PaymentGatewayConfigRecord, DataLayerError> {
|
||||
|
||||
@@ -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<bool>,
|
||||
now: chrono::DateTime<chrono::Utc>,
|
||||
) -> Result<Vec<DailyQuotaGrant>, 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<bool> {
|
||||
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,
|
||||
)?);
|
||||
}
|
||||
|
||||
@@ -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<AdminBillingMutationOutcome<()>, 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<bool>,
|
||||
now: chrono::DateTime<chrono::Utc>,
|
||||
) -> Result<Vec<DailyQuotaGrant>, 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<bool> {
|
||||
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<u64, DataLayerError> {
|
||||
Ok(row.try_get::<i64, _>("total").map_sql_err()?.max(0) as u64)
|
||||
}
|
||||
@@ -1558,6 +1615,106 @@ mod tests {
|
||||
assert_eq!(preset.errors, Vec::<String>::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()
|
||||
|
||||
@@ -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<bool>,
|
||||
now: chrono::DateTime<chrono::Utc>,
|
||||
) -> Result<Vec<DailyQuotaGrant>, 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<bool> {
|
||||
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::<serde_json::Value>(&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!(
|
||||
|
||||
@@ -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<AdminBillingMutationOutcome<()>, crate::DataLayerError> {
|
||||
let _ = (user_id, entitlement_id);
|
||||
Ok(AdminBillingMutationOutcome::Unavailable)
|
||||
}
|
||||
|
||||
async fn find_user_daily_quota_availability(
|
||||
&self,
|
||||
user_id: &str,
|
||||
|
||||
@@ -75,6 +75,7 @@ fn billing_plan_from_input(
|
||||
|
||||
fn daily_quota_availability_from_entitlements(
|
||||
entitlements: impl IntoIterator<Item = UserPlanEntitlementRecord>,
|
||||
billing_plans: &BTreeMap<String, BillingPlanRecord>,
|
||||
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<bool> {
|
||||
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<AdminBillingMutationOutcome<()>, 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::<Vec<_>>();
|
||||
let billing_plans = self
|
||||
.billing_plans_by_id
|
||||
.read()
|
||||
.expect("billing repository lock");
|
||||
Ok(Some(daily_quota_availability_from_entitlements(
|
||||
entitlements,
|
||||
&billing_plans,
|
||||
now,
|
||||
)))
|
||||
}
|
||||
|
||||
@@ -448,6 +448,16 @@ export const usersApi = {
|
||||
return response.data
|
||||
},
|
||||
|
||||
async revokeUserPlanEntitlement(
|
||||
userId: string,
|
||||
entitlementId: string
|
||||
): Promise<AdminUserPlanEntitlementsResponse> {
|
||||
const response = await apiClient.delete<AdminUserPlanEntitlementsResponse>(
|
||||
`/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
|
||||
|
||||
@@ -88,9 +88,21 @@
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-left text-[11px] text-muted-foreground sm:text-right">
|
||||
<div>{{ legacyT('开始:') }}{{ formatDateTime(item.starts_at) }}</div>
|
||||
<div>{{ legacyT('到期:') }}{{ formatDateTime(item.expires_at) }}</div>
|
||||
<div class="flex shrink-0 items-start gap-2">
|
||||
<div class="text-left text-[11px] text-muted-foreground sm:text-right">
|
||||
<div>{{ legacyT('开始:') }}{{ formatDateTime(item.starts_at) }}</div>
|
||||
<div>{{ legacyT('到期:') }}{{ formatDateTime(item.expires_at) }}</div>
|
||||
</div>
|
||||
<Button
|
||||
v-if="item.active"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
class="h-7 px-2 text-[11px]"
|
||||
:disabled="revokingEntitlementId === item.id"
|
||||
@click="$emit('revoke', item)"
|
||||
>
|
||||
{{ revokingEntitlementId === item.id ? legacyT('撤销中...') : legacyT('撤销套餐') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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()
|
||||
|
||||
@@ -1237,6 +1237,13 @@ const legacyExactEnglishMessages: Record<string, string> = {
|
||||
'加载套餐列表失败': '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',
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
<UserApiKeysDialog
|
||||
@@ -285,6 +287,7 @@ const loadingUserSessions = ref(false)
|
||||
const loadingUserPlans = ref(false)
|
||||
const loadingBillingPlans = ref(false)
|
||||
const grantingUserPlan = ref(false)
|
||||
const revokingUserPlanEntitlementId = ref<string | null>(null)
|
||||
const sessionDialogActionLoading = ref<string | null>(null)
|
||||
const editingUserApiKey = ref<ApiKey | null>(null)
|
||||
const userApiKeyForm = ref<UserApiKeyFormState>({
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user