mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
fix: correct API key expiry and dashboard savings (#361)
This commit is contained in:
@@ -45,8 +45,8 @@ pub(super) fn build_admin_user_api_key_detail_payload(
|
||||
"rate_limit": record.rate_limit,
|
||||
"concurrent_limit": record.concurrent_limit,
|
||||
"expires_at": format_optional_unix_secs_iso8601(record.expires_at_unix_secs),
|
||||
"last_used_at": serde_json::Value::Null,
|
||||
"created_at": serde_json::Value::Null,
|
||||
"last_used_at": format_optional_unix_secs_iso8601(record.last_used_at_unix_secs),
|
||||
"created_at": format_optional_unix_secs_iso8601(record.created_at_unix_secs),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -171,7 +171,8 @@ pub(crate) async fn build_admin_create_user_api_key_response(
|
||||
"rate_limit": created.rate_limit,
|
||||
"concurrent_limit": created.concurrent_limit,
|
||||
"expires_at": format_optional_unix_secs_iso8601(created.expires_at_unix_secs),
|
||||
"created_at": chrono::Utc::now().to_rfc3339(),
|
||||
"last_used_at": format_optional_unix_secs_iso8601(created.last_used_at_unix_secs),
|
||||
"created_at": format_optional_unix_secs_iso8601(created.created_at_unix_secs),
|
||||
"message": "API Key创建成功,请妥善保存完整密钥",
|
||||
}))
|
||||
.into_response(),
|
||||
|
||||
@@ -64,8 +64,8 @@ pub(crate) async fn build_admin_list_user_api_keys_response(
|
||||
"rate_limit": record.rate_limit,
|
||||
"concurrent_limit": record.concurrent_limit,
|
||||
"expires_at": format_optional_unix_secs_iso8601(record.expires_at_unix_secs),
|
||||
"last_used_at": serde_json::Value::Null,
|
||||
"created_at": serde_json::Value::Null,
|
||||
"last_used_at": format_optional_unix_secs_iso8601(record.last_used_at_unix_secs),
|
||||
"created_at": format_optional_unix_secs_iso8601(record.created_at_unix_secs),
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
@@ -3,8 +3,9 @@ use super::{
|
||||
GatewayError, GatewayPublicRequestContext,
|
||||
};
|
||||
use aether_data_contracts::repository::usage::{
|
||||
StoredUsageDashboardDailyBreakdownRow, StoredUsageDashboardSummary,
|
||||
UsageAuditAggregationGroupBy, UsageAuditAggregationQuery, UsageDashboardDailyBreakdownQuery,
|
||||
StoredUsageCostSavingsSummary, StoredUsageDashboardDailyBreakdownRow,
|
||||
StoredUsageDashboardSummary, UsageAuditAggregationGroupBy, UsageAuditAggregationQuery,
|
||||
UsageCostSavingsSummaryQuery, UsageDashboardDailyBreakdownQuery,
|
||||
UsageDashboardProviderCountsQuery, UsageDashboardSummaryQuery,
|
||||
};
|
||||
use axum::{
|
||||
@@ -428,7 +429,10 @@ fn dashboard_parse_daily_range(query: Option<&str>) -> Result<DashboardDateRange
|
||||
fn dashboard_range_bounds_unix(range: DashboardDateRange) -> Option<(u64, u64)> {
|
||||
let offset = chrono::Duration::minutes(i64::from(range.tz_offset_minutes));
|
||||
let start_local = range.start_date.and_hms_opt(0, 0, 0)?;
|
||||
let end_local = range.end_date.and_hms_opt(23, 59, 59)?;
|
||||
let end_local = range
|
||||
.end_date
|
||||
.checked_add_signed(chrono::Duration::days(1))?
|
||||
.and_hms_opt(0, 0, 0)?;
|
||||
let start_utc = (start_local - offset).and_utc().timestamp();
|
||||
let end_utc = (end_local - offset).and_utc().timestamp();
|
||||
Some((start_utc.max(0) as u64, end_utc.max(0) as u64))
|
||||
@@ -1501,6 +1505,41 @@ async fn dashboard_load_user_counts(
|
||||
Ok((count, count))
|
||||
}
|
||||
|
||||
fn dashboard_cache_savings_usd(summary: &StoredUsageCostSavingsSummary) -> f64 {
|
||||
let estimated_full_cost =
|
||||
if summary.estimated_full_cost_usd <= 0.0 && summary.cache_read_cost_usd > 0.0 {
|
||||
summary.cache_read_cost_usd * 10.0
|
||||
} else {
|
||||
summary.estimated_full_cost_usd
|
||||
};
|
||||
dashboard_round_f64(
|
||||
(estimated_full_cost - summary.cache_read_cost_usd).max(0.0),
|
||||
4,
|
||||
)
|
||||
}
|
||||
|
||||
async fn dashboard_load_cache_savings(
|
||||
state: &AppState,
|
||||
range: DashboardDateRange,
|
||||
user_id: Option<&str>,
|
||||
) -> Result<f64, GatewayError> {
|
||||
let Some((created_from_unix_secs, created_until_unix_secs)) =
|
||||
dashboard_range_bounds_unix(range)
|
||||
else {
|
||||
return Ok(0.0);
|
||||
};
|
||||
let summary = state
|
||||
.summarize_usage_cost_savings(&UsageCostSavingsSummaryQuery {
|
||||
created_from_unix_secs,
|
||||
created_until_unix_secs,
|
||||
user_id: user_id.map(ToOwned::to_owned),
|
||||
provider_name: None,
|
||||
model: None,
|
||||
})
|
||||
.await?;
|
||||
Ok(dashboard_cache_savings_usd(&summary))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_dashboard_stats_get(
|
||||
state: &AppState,
|
||||
request_context: &GatewayPublicRequestContext,
|
||||
@@ -1630,10 +1669,17 @@ pub(super) async fn handle_dashboard_stats_get(
|
||||
/ today_totals.requests as f64
|
||||
* 100.0
|
||||
};
|
||||
let cost_savings = dashboard_round_f64(
|
||||
period_totals.total_cost_usd - period_totals.actual_total_cost_usd,
|
||||
4,
|
||||
);
|
||||
let cost_savings =
|
||||
match dashboard_load_cache_savings(state, summary_range, user_filter).await {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
return build_auth_error_response(
|
||||
http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("dashboard cache savings lookup failed: {err:?}"),
|
||||
false,
|
||||
);
|
||||
}
|
||||
};
|
||||
let stats = json!([
|
||||
{
|
||||
"name": "今日请求",
|
||||
|
||||
@@ -148,8 +148,8 @@ fn build_users_me_api_key_list_payload(
|
||||
"key_display": users_me_masked_api_key_display(state, record.key_encrypted.as_deref()),
|
||||
"is_active": record.is_active,
|
||||
"is_locked": is_locked,
|
||||
"last_used_at": serde_json::Value::Null,
|
||||
"created_at": serde_json::Value::Null,
|
||||
"last_used_at": format_users_me_optional_unix_secs_iso8601(record.last_used_at_unix_secs),
|
||||
"created_at": format_users_me_optional_unix_secs_iso8601(record.created_at_unix_secs),
|
||||
"total_requests": record.total_requests,
|
||||
"total_cost_usd": record.total_cost_usd,
|
||||
"rate_limit": record.rate_limit,
|
||||
@@ -174,9 +174,9 @@ fn build_users_me_api_key_detail_payload(
|
||||
"force_capabilities": record.force_capabilities,
|
||||
"rate_limit": record.rate_limit,
|
||||
"concurrent_limit": record.concurrent_limit,
|
||||
"last_used_at": serde_json::Value::Null,
|
||||
"last_used_at": format_users_me_optional_unix_secs_iso8601(record.last_used_at_unix_secs),
|
||||
"expires_at": format_users_me_optional_unix_secs_iso8601(record.expires_at_unix_secs),
|
||||
"created_at": serde_json::Value::Null,
|
||||
"created_at": format_users_me_optional_unix_secs_iso8601(record.created_at_unix_secs),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -575,8 +575,14 @@ pub(super) async fn handle_users_me_api_key_create(
|
||||
"name": created.name,
|
||||
"key": plaintext_key,
|
||||
"key_display": users_me_masked_api_key_display(state, created.key_encrypted.as_deref()),
|
||||
"is_active": created.is_active,
|
||||
"is_locked": false,
|
||||
"rate_limit": created.rate_limit,
|
||||
"concurrent_limit": created.concurrent_limit,
|
||||
"last_used_at": format_users_me_optional_unix_secs_iso8601(created.last_used_at_unix_secs),
|
||||
"created_at": format_users_me_optional_unix_secs_iso8601(created.created_at_unix_secs),
|
||||
"total_requests": created.total_requests,
|
||||
"total_cost_usd": created.total_cost_usd,
|
||||
"message": "API密钥创建成功",
|
||||
}))
|
||||
.into_response()
|
||||
|
||||
@@ -678,7 +678,13 @@ async fn gateway_handles_admin_user_api_key_routes_locally_with_trusted_admin_pr
|
||||
1.5,
|
||||
false,
|
||||
)
|
||||
.expect("export record should build")]),
|
||||
.expect("export record should build")
|
||||
.with_activity_timestamps(
|
||||
Some(1_711_000_102),
|
||||
Some(1_711_000_100),
|
||||
Some(1_711_000_101),
|
||||
)
|
||||
.expect("export activity timestamps should build")]),
|
||||
);
|
||||
let user_repository = Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![
|
||||
sample_admin_user("user-1"),
|
||||
@@ -724,6 +730,11 @@ async fn gateway_handles_admin_user_api_key_routes_locally_with_trusted_admin_pr
|
||||
create_payload["message"],
|
||||
"API Key创建成功,请妥善保存完整密钥"
|
||||
);
|
||||
let created_at = create_payload["created_at"]
|
||||
.as_str()
|
||||
.expect("created_at should be string");
|
||||
assert!(chrono::DateTime::parse_from_rfc3339(created_at).is_ok());
|
||||
assert!(!created_at.starts_with("1970-01-01"));
|
||||
assert!(create_payload["id"]
|
||||
.as_str()
|
||||
.is_some_and(|value| !value.is_empty()));
|
||||
@@ -757,6 +768,7 @@ async fn gateway_handles_admin_user_api_key_routes_locally_with_trusted_admin_pr
|
||||
assert_eq!(update_payload["is_locked"], false);
|
||||
assert_eq!(update_payload["rate_limit"], 120);
|
||||
assert_eq!(update_payload["concurrent_limit"], 9);
|
||||
assert_eq!(update_payload["created_at"], "2024-03-21T05:48:20+00:00");
|
||||
assert_eq!(update_payload["message"], "API Key更新成功");
|
||||
|
||||
let lock_response = client
|
||||
@@ -1247,7 +1259,13 @@ async fn gateway_lists_admin_user_api_keys_locally_with_trusted_admin_principal(
|
||||
1.5,
|
||||
false,
|
||||
)
|
||||
.expect("export record should build")]),
|
||||
.expect("export record should build")
|
||||
.with_activity_timestamps(
|
||||
Some(1_711_000_102),
|
||||
Some(1_711_000_100),
|
||||
Some(1_711_000_101),
|
||||
)
|
||||
.expect("export activity timestamps should build")]),
|
||||
);
|
||||
let user_repository = Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![
|
||||
sample_admin_user("user-1"),
|
||||
@@ -1287,6 +1305,14 @@ async fn gateway_lists_admin_user_api_keys_locally_with_trusted_admin_principal(
|
||||
assert_eq!(payload["api_keys"][0]["total_requests"], 9);
|
||||
assert_eq!(payload["api_keys"][0]["total_cost_usd"], 1.5);
|
||||
assert_eq!(payload["api_keys"][0]["rate_limit"], 60);
|
||||
assert_eq!(
|
||||
payload["api_keys"][0]["created_at"],
|
||||
"2024-03-21T05:48:20+00:00"
|
||||
);
|
||||
assert_eq!(
|
||||
payload["api_keys"][0]["last_used_at"],
|
||||
"2024-03-21T05:48:22+00:00"
|
||||
);
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
|
||||
@@ -5839,7 +5839,13 @@ async fn gateway_handles_users_me_api_keys_locally_without_proxying_upstream() {
|
||||
1.5,
|
||||
false,
|
||||
)
|
||||
.expect("export record should build")]),
|
||||
.expect("export record should build")
|
||||
.with_activity_timestamps(
|
||||
Some(1_711_000_102),
|
||||
Some(1_711_000_100),
|
||||
Some(1_711_000_101),
|
||||
)
|
||||
.expect("export activity timestamps should build")]),
|
||||
);
|
||||
let user_repository = Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![user]));
|
||||
|
||||
@@ -5881,6 +5887,8 @@ async fn gateway_handles_users_me_api_keys_locally_without_proxying_upstream() {
|
||||
assert_eq!(api_keys[0]["key_display"], "sk-user-li...ve-1");
|
||||
assert_eq!(api_keys[0]["total_requests"], 9);
|
||||
assert_eq!(api_keys[0]["total_cost_usd"], 1.5);
|
||||
assert_eq!(api_keys[0]["created_at"], "2024-03-21T05:48:20+00:00");
|
||||
assert_eq!(api_keys[0]["last_used_at"], "2024-03-21T05:48:22+00:00");
|
||||
|
||||
let detail_response = client
|
||||
.get(format!(
|
||||
@@ -6212,6 +6220,11 @@ async fn gateway_handles_users_me_api_key_writes_locally_without_proxying_upstre
|
||||
assert_eq!(create_payload["rate_limit"], 120);
|
||||
assert_eq!(create_payload["concurrent_limit"], serde_json::Value::Null);
|
||||
assert_eq!(create_payload["message"], "API密钥创建成功");
|
||||
let created_at = create_payload["created_at"]
|
||||
.as_str()
|
||||
.expect("created_at should be string");
|
||||
assert!(chrono::DateTime::parse_from_rfc3339(created_at).is_ok());
|
||||
assert!(!created_at.starts_with("1970-01-01"));
|
||||
assert!(create_payload["key"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
@@ -6317,6 +6330,7 @@ async fn gateway_handles_users_me_api_key_writes_locally_without_proxying_upstre
|
||||
);
|
||||
assert_eq!(detail_payload["concurrent_limit"], 4);
|
||||
assert_eq!(detail_payload["force_capabilities"], json!({}));
|
||||
assert_eq!(detail_payload["created_at"], created_at);
|
||||
|
||||
let delete_response = client
|
||||
.delete(format!("{gateway_url}/api/users/me/api-keys/{created_id}"))
|
||||
|
||||
@@ -173,6 +173,93 @@ async fn gateway_handles_dashboard_stats_locally_without_proxying_upstream() {
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_dashboard_stats_include_end_of_day_boundary() {
|
||||
let now = stable_dashboard_now();
|
||||
let user = sample_auth_user(now);
|
||||
let access_token = build_test_auth_token(
|
||||
"access",
|
||||
serde_json::Map::from_iter([
|
||||
("user_id".to_string(), json!(user.id)),
|
||||
("role".to_string(), json!(user.role)),
|
||||
(
|
||||
"created_at".to_string(),
|
||||
json!(user.created_at.map(|value| value.to_rfc3339())),
|
||||
),
|
||||
("session_id".to_string(), json!("session-dashboard-day-end")),
|
||||
]),
|
||||
chrono::Utc::now() + chrono::Duration::hours(1),
|
||||
);
|
||||
let session = sample_auth_session(
|
||||
"user-auth-1",
|
||||
"session-dashboard-day-end",
|
||||
"device-dashboard-day-end",
|
||||
"refresh-dashboard-day-end",
|
||||
now,
|
||||
);
|
||||
let day = now.date_naive();
|
||||
let end_of_day = day
|
||||
.and_hms_opt(23, 59, 59)
|
||||
.expect("end of day should build")
|
||||
.and_utc();
|
||||
let next_midnight = day
|
||||
.checked_add_signed(chrono::Duration::days(1))
|
||||
.expect("next day should build")
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.expect("next midnight should build")
|
||||
.and_utc();
|
||||
let usage_repository = Arc::new(InMemoryUsageReadRepository::seed(vec![
|
||||
sample_user_usage_audit(
|
||||
"usage-dashboard-day-end",
|
||||
"req-dashboard-day-end",
|
||||
"user-auth-1",
|
||||
"gpt-5",
|
||||
"openai",
|
||||
"completed",
|
||||
end_of_day,
|
||||
),
|
||||
sample_user_usage_audit(
|
||||
"usage-dashboard-next-midnight",
|
||||
"req-dashboard-next-midnight",
|
||||
"user-auth-1",
|
||||
"gpt-5",
|
||||
"openai",
|
||||
"completed",
|
||||
next_midnight,
|
||||
),
|
||||
]));
|
||||
|
||||
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
|
||||
start_auth_gateway_with_usage_state(
|
||||
user,
|
||||
sample_auth_wallet("user-auth-1", now),
|
||||
[session],
|
||||
usage_repository,
|
||||
)
|
||||
.await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.get(format!(
|
||||
"{gateway_url}/api/dashboard/stats?start_date={day}&end_date={day}"
|
||||
))
|
||||
.header("authorization", format!("Bearer {access_token}"))
|
||||
.header("x-client-device-id", "device-dashboard-day-end")
|
||||
.header("user-agent", "AetherTest/1.0")
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
assert_eq!(payload["today"]["requests"], 1);
|
||||
assert_eq!(payload["monthly_cost"], json!(1.25));
|
||||
assert_eq!(payload["stats"][1]["value"], json!("1"));
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_handles_admin_dashboard_stats_locally_without_proxying_upstream() {
|
||||
let now = stable_dashboard_now();
|
||||
@@ -232,6 +319,8 @@ async fn gateway_handles_admin_dashboard_stats_locally_without_proxying_upstream
|
||||
openai_usage.cache_creation_ephemeral_5m_input_tokens = 600;
|
||||
openai_usage.cache_creation_ephemeral_1h_input_tokens = 600;
|
||||
openai_usage.cache_read_input_tokens = 800;
|
||||
openai_usage.cache_read_cost_usd = 0.01;
|
||||
openai_usage.output_price_per_1m = Some(100.0);
|
||||
|
||||
let mut claude_usage = sample_user_usage_audit(
|
||||
"usage-dashboard-admin-2",
|
||||
@@ -251,8 +340,10 @@ async fn gateway_handles_admin_dashboard_stats_locally_without_proxying_upstream
|
||||
claude_usage.cache_creation_ephemeral_5m_input_tokens = 20;
|
||||
claude_usage.cache_creation_ephemeral_1h_input_tokens = 30;
|
||||
claude_usage.cache_read_input_tokens = 200;
|
||||
claude_usage.cache_read_cost_usd = 0.005;
|
||||
claude_usage.output_price_per_1m = Some(100.0);
|
||||
|
||||
let streaming_usage = sample_user_usage_audit(
|
||||
let mut streaming_usage = sample_user_usage_audit(
|
||||
"usage-dashboard-admin-3",
|
||||
"req-dashboard-admin-3",
|
||||
"user-auth-3",
|
||||
@@ -261,6 +352,8 @@ async fn gateway_handles_admin_dashboard_stats_locally_without_proxying_upstream
|
||||
"streaming",
|
||||
now - chrono::Duration::minutes(1),
|
||||
);
|
||||
streaming_usage.cache_read_input_tokens = 0;
|
||||
streaming_usage.cache_read_cost_usd = 0.0;
|
||||
|
||||
let usage_repository = Arc::new(InMemoryUsageReadRepository::seed(vec![
|
||||
openai_usage,
|
||||
@@ -407,6 +500,8 @@ async fn gateway_handles_admin_dashboard_stats_locally_without_proxying_upstream
|
||||
assert_eq!(payload["today"]["requests"], 2);
|
||||
assert_eq!(payload["today"]["tokens"], 17_450);
|
||||
assert_eq!(payload["today"]["cost"], json!(2.5));
|
||||
assert_eq!(payload["cost_stats"]["cost_savings"], json!(0.085));
|
||||
assert_eq!(payload["stats"][2]["subValue"], json!("节省 $0.09"));
|
||||
assert_eq!(payload["stats"][0]["value"], json!("2"));
|
||||
assert_eq!(payload["stats"][1]["value"], json!("17.4K"));
|
||||
assert_eq!(
|
||||
|
||||
Reference in New Issue
Block a user