mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 16:06:08 +00:00
fix: include account quota stats
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -7,23 +7,36 @@ use axum::{
|
||||
response::IntoResponse,
|
||||
Extension, Json,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use secrecy::ExposeSecret;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{
|
||||
admin::accounts::service::{AdminAccountError, AdminAccountMetadata},
|
||||
admin::auth::session::require_admin_session,
|
||||
admin::response::{AdminEnvelope, AdminError, AdminPageEnvelope, AdminResponse},
|
||||
admin::{
|
||||
accounts::service::{AdminAccountError, AdminAccountMetadata},
|
||||
monitoring::{
|
||||
billing,
|
||||
event_store::AdminLogFilter,
|
||||
events::{EventLevel, EventLog},
|
||||
service::AdminUsageRecord,
|
||||
},
|
||||
},
|
||||
http::middleware::request_id::RequestId,
|
||||
infra::{
|
||||
json::{clamp_limit, clamp_page, Page},
|
||||
time::{china_datetime, china_rfc3339, china_rfc3339_str},
|
||||
time::{china_datetime, china_relative_time, china_rfc3339, china_rfc3339_str},
|
||||
},
|
||||
runtime::state::AppState,
|
||||
upstream::accounts::store::StoredAccount,
|
||||
};
|
||||
|
||||
const ACCOUNT_STATS_PAGE_LIMIT: u32 = 200;
|
||||
|
||||
// ============================================================================
|
||||
// Query / Request types
|
||||
// ============================================================================
|
||||
@@ -105,10 +118,23 @@ pub struct AdminAccountData {
|
||||
pub added_at_display: String,
|
||||
pub updated_at: String,
|
||||
pub updated_at_display: String,
|
||||
pub quota: AdminAccountQuotaData,
|
||||
pub usage: AdminAccountUsageData,
|
||||
}
|
||||
|
||||
impl From<AdminAccountMetadata> for AdminAccountData {
|
||||
fn from(a: AdminAccountMetadata) -> Self {
|
||||
Self::from_parts(a, None, None, None)
|
||||
}
|
||||
}
|
||||
|
||||
impl AdminAccountData {
|
||||
fn from_parts(
|
||||
a: AdminAccountMetadata,
|
||||
usage: Option<&AdminUsageRecord>,
|
||||
quota: Option<AdminAccountQuotaData>,
|
||||
model_top: Option<AdminAccountModelUsageData>,
|
||||
) -> Self {
|
||||
let access_token_expires_at = a.access_token_expires_at.as_ref().map(china_rfc3339);
|
||||
let access_token_expires_at_display =
|
||||
a.access_token_expires_at.as_ref().map(china_datetime);
|
||||
@@ -126,10 +152,155 @@ impl From<AdminAccountMetadata> for AdminAccountData {
|
||||
added_at_display: china_datetime(&a.added_at),
|
||||
updated_at: china_rfc3339(&a.updated_at),
|
||||
updated_at_display: china_datetime(&a.updated_at),
|
||||
quota: quota.unwrap_or_default(),
|
||||
usage: AdminAccountUsageData::from_usage(usage, model_top),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminAccountQuotaData {
|
||||
pub used_percent: Option<f64>,
|
||||
pub used_percent_display: String,
|
||||
pub reset_at_display: String,
|
||||
pub refreshed_at_display: String,
|
||||
pub window_used_display: String,
|
||||
}
|
||||
|
||||
impl Default for AdminAccountQuotaData {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
used_percent: None,
|
||||
used_percent_display: "-".to_string(),
|
||||
reset_at_display: "-".to_string(),
|
||||
refreshed_at_display: "-".to_string(),
|
||||
window_used_display: "-".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminAccountUsageData {
|
||||
pub request_count: i64,
|
||||
pub request_count_display: String,
|
||||
pub empty_response_count: i64,
|
||||
pub input_tokens: i64,
|
||||
pub input_tokens_display: String,
|
||||
pub output_tokens: i64,
|
||||
pub output_tokens_display: String,
|
||||
pub cached_tokens: i64,
|
||||
pub cached_tokens_display: String,
|
||||
pub reasoning_tokens: i64,
|
||||
pub total_tokens: i64,
|
||||
pub total_tokens_display: String,
|
||||
pub image_input_tokens: i64,
|
||||
pub image_output_tokens: i64,
|
||||
pub image_tokens_display: String,
|
||||
pub image_request_count: i64,
|
||||
pub image_request_failed_count: i64,
|
||||
pub created_tokens: i64,
|
||||
pub created_tokens_display: String,
|
||||
pub read_tokens: i64,
|
||||
pub read_tokens_display: String,
|
||||
pub last_used_at: Option<String>,
|
||||
pub last_used_at_display: String,
|
||||
pub model_top: Option<AdminAccountModelUsageData>,
|
||||
}
|
||||
|
||||
impl AdminAccountUsageData {
|
||||
fn from_usage(
|
||||
usage: Option<&AdminUsageRecord>,
|
||||
model_top: Option<AdminAccountModelUsageData>,
|
||||
) -> Self {
|
||||
let request_count = usage.map_or(0, |usage| usage.request_count);
|
||||
let empty_response_count = usage.map_or(0, |usage| usage.empty_response_count);
|
||||
let input_tokens = usage.map_or(0, |usage| usage.input_tokens);
|
||||
let output_tokens = usage.map_or(0, |usage| usage.output_tokens);
|
||||
let cached_tokens = usage.map_or(0, |usage| usage.cached_tokens);
|
||||
let reasoning_tokens = usage.map_or(0, |usage| usage.reasoning_tokens);
|
||||
let total_tokens = usage.map_or(0, |usage| usage.total_tokens);
|
||||
let image_input_tokens = usage.map_or(0, |usage| usage.image_input_tokens);
|
||||
let image_output_tokens = usage.map_or(0, |usage| usage.image_output_tokens);
|
||||
let image_request_count = usage.map_or(0, |usage| usage.image_request_count);
|
||||
let image_request_failed_count = usage.map_or(0, |usage| usage.image_request_failed_count);
|
||||
let created_tokens = input_tokens.saturating_sub(cached_tokens);
|
||||
let read_tokens = cached_tokens;
|
||||
let last_used_at = usage.and_then(|usage| usage.last_used_at);
|
||||
|
||||
Self {
|
||||
request_count,
|
||||
request_count_display: format_count(nonnegative_i64_to_u64(request_count)),
|
||||
empty_response_count,
|
||||
input_tokens,
|
||||
input_tokens_display: format_tokens(nonnegative_i64_to_u64(input_tokens)),
|
||||
output_tokens,
|
||||
output_tokens_display: format_tokens(nonnegative_i64_to_u64(output_tokens)),
|
||||
cached_tokens,
|
||||
cached_tokens_display: format_tokens(nonnegative_i64_to_u64(cached_tokens)),
|
||||
reasoning_tokens,
|
||||
total_tokens,
|
||||
total_tokens_display: format_tokens(nonnegative_i64_to_u64(total_tokens)),
|
||||
image_input_tokens,
|
||||
image_output_tokens,
|
||||
image_tokens_display: format_tokens(nonnegative_i64_to_u64(
|
||||
image_input_tokens + image_output_tokens,
|
||||
)),
|
||||
image_request_count,
|
||||
image_request_failed_count,
|
||||
created_tokens,
|
||||
created_tokens_display: format_tokens(nonnegative_i64_to_u64(created_tokens)),
|
||||
read_tokens,
|
||||
read_tokens_display: format_tokens(nonnegative_i64_to_u64(read_tokens)),
|
||||
last_used_at: last_used_at.map(|value| china_rfc3339(&value)),
|
||||
last_used_at_display: china_relative_time(last_used_at, Utc::now()),
|
||||
model_top,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminAccountModelUsageData {
|
||||
pub model: String,
|
||||
pub request_count: u64,
|
||||
pub request_count_display: String,
|
||||
pub success_rate: f64,
|
||||
pub success_rate_display: String,
|
||||
pub input_tokens: u64,
|
||||
pub input_tokens_display: String,
|
||||
pub output_tokens: u64,
|
||||
pub output_tokens_display: String,
|
||||
pub cached_tokens: u64,
|
||||
pub cached_tokens_display: String,
|
||||
pub total_tokens: u64,
|
||||
pub total_tokens_display: String,
|
||||
pub total_cost_usd: f64,
|
||||
pub total_cost_usd_display: String,
|
||||
pub last_used_at: Option<String>,
|
||||
pub last_used_at_display: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct AccountListStats {
|
||||
usage_by_account: HashMap<String, AdminUsageRecord>,
|
||||
quota_by_account: HashMap<String, AdminAccountQuotaData>,
|
||||
model_top_by_account: HashMap<String, AdminAccountModelUsageData>,
|
||||
}
|
||||
|
||||
impl AccountListStats {
|
||||
fn data_for(&self, account: AdminAccountMetadata) -> AdminAccountData {
|
||||
let account_id = account.id.clone();
|
||||
AdminAccountData::from_parts(
|
||||
account,
|
||||
self.usage_by_account.get(&account_id),
|
||||
self.quota_by_account.get(&account_id).cloned(),
|
||||
self.model_top_by_account.get(&account_id).cloned(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BatchDeleteAccountsData {
|
||||
@@ -246,6 +417,7 @@ pub async fn accounts(
|
||||
require_admin_session(&state, &headers, &request_id).await?;
|
||||
let limit = clamp_limit(params.page_size.or(params.limit).unwrap_or(50));
|
||||
let use_numbered_page = params.page.is_some() || params.page_size.is_some();
|
||||
let stats = account_list_stats(&state).await;
|
||||
|
||||
if use_numbered_page {
|
||||
return match state
|
||||
@@ -256,7 +428,11 @@ pub async fn accounts(
|
||||
{
|
||||
Ok(page) => {
|
||||
let page = crate::infra::json::NumberedPage {
|
||||
items: page.items.into_iter().map(AdminAccountData::from).collect(),
|
||||
items: page
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|item| stats.data_for(item))
|
||||
.collect(),
|
||||
total: page.total,
|
||||
page: page.page,
|
||||
page_size: page.page_size,
|
||||
@@ -278,7 +454,11 @@ pub async fn accounts(
|
||||
{
|
||||
Ok(page) => {
|
||||
let page = Page {
|
||||
items: page.items.into_iter().map(AdminAccountData::from).collect(),
|
||||
items: page
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|item| stats.data_for(item))
|
||||
.collect(),
|
||||
next_cursor: page.next_cursor,
|
||||
};
|
||||
Ok(AdminResponse::new(
|
||||
@@ -667,6 +847,270 @@ pub async fn update_account(
|
||||
}
|
||||
}
|
||||
|
||||
async fn account_list_stats(state: &AppState) -> AccountListStats {
|
||||
let usage_records = list_all_usage_records(state).await;
|
||||
let quota_snapshots = state
|
||||
.services
|
||||
.background_tasks
|
||||
.accounts
|
||||
.list_quota_snapshots()
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let logs = list_all_event_logs(state).await;
|
||||
|
||||
AccountListStats {
|
||||
usage_by_account: usage_records
|
||||
.into_iter()
|
||||
.map(|usage| (usage.account_id.clone(), usage))
|
||||
.collect(),
|
||||
quota_by_account: quota_snapshots
|
||||
.into_iter()
|
||||
.map(|snapshot| {
|
||||
(
|
||||
snapshot.account_id,
|
||||
quota_data(&snapshot.quota_json, snapshot.quota_fetched_at),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
model_top_by_account: model_top_by_account(&logs),
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_all_usage_records(state: &AppState) -> Vec<AdminUsageRecord> {
|
||||
let mut cursor = None;
|
||||
let mut records = Vec::new();
|
||||
loop {
|
||||
let Ok(page) = state
|
||||
.services
|
||||
.usage
|
||||
.list(cursor, ACCOUNT_STATS_PAGE_LIMIT)
|
||||
.await
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
records.extend(page.items);
|
||||
let Some(next_cursor) = page.next_cursor else {
|
||||
return records;
|
||||
};
|
||||
cursor = Some(next_cursor);
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_all_event_logs(state: &AppState) -> Vec<EventLog> {
|
||||
let mut cursor = None;
|
||||
let mut logs = Vec::new();
|
||||
loop {
|
||||
let Ok(page) = state
|
||||
.services
|
||||
.logs
|
||||
.list(cursor, ACCOUNT_STATS_PAGE_LIMIT, AdminLogFilter::default())
|
||||
.await
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
logs.extend(page.items);
|
||||
let Some(next_cursor) = page.next_cursor else {
|
||||
return logs;
|
||||
};
|
||||
cursor = Some(next_cursor);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct ModelUsageAggregate {
|
||||
model: String,
|
||||
request_count: u64,
|
||||
error_count: u64,
|
||||
input_tokens: u64,
|
||||
output_tokens: u64,
|
||||
cached_tokens: u64,
|
||||
last_used_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
fn model_top_by_account(logs: &[EventLog]) -> HashMap<String, AdminAccountModelUsageData> {
|
||||
let mut by_account_model = HashMap::<(String, String), ModelUsageAggregate>::new();
|
||||
for log in logs {
|
||||
let Some(account_id) = log
|
||||
.account_id
|
||||
.as_ref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(model) = log.model.as_ref().filter(|value| !value.trim().is_empty()) else {
|
||||
continue;
|
||||
};
|
||||
let aggregate = by_account_model
|
||||
.entry((account_id.clone(), model.clone()))
|
||||
.or_insert_with(|| ModelUsageAggregate {
|
||||
model: model.clone(),
|
||||
..ModelUsageAggregate::default()
|
||||
});
|
||||
aggregate.request_count += 1;
|
||||
if log.level == EventLevel::Error || log.status_code.is_some_and(|status| status >= 400) {
|
||||
aggregate.error_count += 1;
|
||||
}
|
||||
aggregate.input_tokens += metadata_usage_number(&log.metadata, "inputTokens");
|
||||
aggregate.output_tokens += metadata_usage_number(&log.metadata, "outputTokens");
|
||||
aggregate.cached_tokens += metadata_usage_number(&log.metadata, "cachedTokens");
|
||||
aggregate.last_used_at = Some(
|
||||
aggregate
|
||||
.last_used_at
|
||||
.map_or(log.created_at, |last| last.max(log.created_at)),
|
||||
);
|
||||
}
|
||||
|
||||
let mut by_account = HashMap::<String, ModelUsageAggregate>::new();
|
||||
for ((account_id, _), aggregate) in by_account_model {
|
||||
let replace = by_account
|
||||
.get(&account_id)
|
||||
.is_none_or(|current| aggregate.request_count > current.request_count);
|
||||
if replace {
|
||||
by_account.insert(account_id, aggregate);
|
||||
}
|
||||
}
|
||||
|
||||
by_account
|
||||
.into_iter()
|
||||
.map(|(account_id, aggregate)| (account_id, model_usage_data(aggregate)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn model_usage_data(usage: ModelUsageAggregate) -> AdminAccountModelUsageData {
|
||||
let total_tokens = usage.input_tokens + usage.output_tokens;
|
||||
let success_rate = if usage.request_count > 0 {
|
||||
((usage.request_count - usage.error_count) as f64 / usage.request_count as f64 * 1000.0)
|
||||
.round()
|
||||
/ 10.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let total_cost_usd = billing::calculate_cost(
|
||||
usage.input_tokens,
|
||||
usage.output_tokens,
|
||||
usage.cached_tokens,
|
||||
&usage.model,
|
||||
None,
|
||||
);
|
||||
|
||||
AdminAccountModelUsageData {
|
||||
model: usage.model,
|
||||
request_count: usage.request_count,
|
||||
request_count_display: format_count(usage.request_count),
|
||||
success_rate,
|
||||
success_rate_display: format_percent(success_rate),
|
||||
input_tokens: usage.input_tokens,
|
||||
input_tokens_display: format_tokens(usage.input_tokens),
|
||||
output_tokens: usage.output_tokens,
|
||||
output_tokens_display: format_tokens(usage.output_tokens),
|
||||
cached_tokens: usage.cached_tokens,
|
||||
cached_tokens_display: format_tokens(usage.cached_tokens),
|
||||
total_tokens,
|
||||
total_tokens_display: format_tokens(total_tokens),
|
||||
total_cost_usd,
|
||||
total_cost_usd_display: format_cost(total_cost_usd),
|
||||
last_used_at: usage.last_used_at.map(|value| china_rfc3339(&value)),
|
||||
last_used_at_display: china_relative_time(usage.last_used_at, Utc::now()),
|
||||
}
|
||||
}
|
||||
|
||||
fn quota_data(quota_json: &str, fetched_at: Option<DateTime<Utc>>) -> AdminAccountQuotaData {
|
||||
let quota = serde_json::from_str::<Value>(quota_json).unwrap_or(Value::Null);
|
||||
let used_percent = quota
|
||||
.pointer("/rate_limit/used_percent")
|
||||
.and_then(number_value)
|
||||
.map(|value| value.clamp(0.0, 100.0));
|
||||
let reset_at = quota
|
||||
.pointer("/rate_limit/reset_at")
|
||||
.and_then(Value::as_i64)
|
||||
.and_then(|value| DateTime::<Utc>::from_timestamp(value, 0));
|
||||
let window_seconds = quota
|
||||
.pointer("/rate_limit/limit_window_seconds")
|
||||
.and_then(Value::as_u64);
|
||||
|
||||
AdminAccountQuotaData {
|
||||
used_percent,
|
||||
used_percent_display: used_percent
|
||||
.map(format_percent)
|
||||
.unwrap_or_else(|| "-".to_string()),
|
||||
reset_at_display: reset_at
|
||||
.as_ref()
|
||||
.map(china_datetime)
|
||||
.unwrap_or_else(|| "-".to_string()),
|
||||
refreshed_at_display: china_relative_time(fetched_at, Utc::now()),
|
||||
window_used_display: quota_window_used_display(reset_at, window_seconds),
|
||||
}
|
||||
}
|
||||
|
||||
fn quota_window_used_display(
|
||||
reset_at: Option<DateTime<Utc>>,
|
||||
window_seconds: Option<u64>,
|
||||
) -> String {
|
||||
let (Some(reset_at), Some(window_seconds)) = (reset_at, window_seconds) else {
|
||||
return "-".to_string();
|
||||
};
|
||||
let remaining = reset_at
|
||||
.signed_duration_since(Utc::now())
|
||||
.num_seconds()
|
||||
.max(0) as u64;
|
||||
let used = window_seconds.saturating_sub(remaining);
|
||||
format!(
|
||||
"{} / {}",
|
||||
format_duration_days(used),
|
||||
format_duration_days(window_seconds)
|
||||
)
|
||||
}
|
||||
|
||||
fn format_duration_days(seconds: u64) -> String {
|
||||
let days = seconds as f64 / 86_400.0;
|
||||
if days >= 1.0 {
|
||||
format!("{days:.1}d")
|
||||
} else {
|
||||
format!("{:.1}h", seconds as f64 / 3_600.0)
|
||||
}
|
||||
}
|
||||
|
||||
fn metadata_usage_number(metadata: &Value, field: &str) -> u64 {
|
||||
metadata
|
||||
.get("usage")
|
||||
.and_then(|usage| usage.get(field))
|
||||
.or_else(|| metadata.get(field))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn number_value(value: &Value) -> Option<f64> {
|
||||
value
|
||||
.as_f64()
|
||||
.or_else(|| value.as_str().and_then(|value| value.parse::<f64>().ok()))
|
||||
}
|
||||
|
||||
fn nonnegative_i64_to_u64(value: i64) -> u64 {
|
||||
u64::try_from(value).unwrap_or(0)
|
||||
}
|
||||
|
||||
fn format_count(value: u64) -> String {
|
||||
value.to_string()
|
||||
}
|
||||
|
||||
fn format_tokens(value: u64) -> String {
|
||||
if value >= 1_000_000 {
|
||||
format!("{:.1}M", value as f64 / 1_000_000.0)
|
||||
} else if value >= 1_000 {
|
||||
format!("{:.1}K", value as f64 / 1_000.0)
|
||||
} else {
|
||||
value.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn format_percent(value: f64) -> String {
|
||||
format!("{value:.1}%")
|
||||
}
|
||||
|
||||
fn format_cost(value: f64) -> String {
|
||||
format!("${value:.2}")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Error handling
|
||||
// ============================================================================
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! 管理面监控模块:日志、用量统计和 Dashboard 聚合。
|
||||
|
||||
mod billing;
|
||||
pub(crate) mod billing;
|
||||
pub mod dashboard;
|
||||
pub mod diagnostics;
|
||||
pub mod event_store;
|
||||
|
||||
@@ -15,7 +15,7 @@ use crate::{
|
||||
http::middleware::request_id::RequestId,
|
||||
infra::{
|
||||
json::{clamp_limit, clamp_page, Page},
|
||||
time::china_rfc3339,
|
||||
time::{china_relative_time, china_rfc3339},
|
||||
},
|
||||
runtime::state::AppState,
|
||||
};
|
||||
@@ -50,6 +50,7 @@ pub struct AdminUsageStatsData {
|
||||
pub image_request_count: i64,
|
||||
pub image_request_failed_count: i64,
|
||||
pub last_used_at: Option<String>,
|
||||
pub last_used_at_display: String,
|
||||
}
|
||||
|
||||
/// 管理端账号用量汇总响应。
|
||||
@@ -174,6 +175,7 @@ impl From<AdminUsageRecord> for AdminUsageStatsData {
|
||||
image_output_tokens: usage.image_output_tokens,
|
||||
image_request_count: usage.image_request_count,
|
||||
image_request_failed_count: usage.image_request_failed_count,
|
||||
last_used_at_display: china_relative_time(usage.last_used_at, chrono::Utc::now()),
|
||||
last_used_at: usage.last_used_at.map(|value| china_rfc3339(&value)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use codex_proxy_rs::admin::monitoring::events::{EventLevel, EventLog};
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_accounts_list_should_not_decrypt_account_tokens() {
|
||||
@@ -55,6 +56,80 @@ async fn admin_accounts_list_should_not_decrypt_account_tokens() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_accounts_list_should_include_usage_quota_and_model_stats() {
|
||||
let (app, _state, pool, _dir, _secret_box) =
|
||||
admin_accounts_test_app("admin-accounts-stats.sqlite", 68).await;
|
||||
seed_usage_account(
|
||||
&pool,
|
||||
"acct_stats",
|
||||
"stats@example.com",
|
||||
"stats",
|
||||
"free",
|
||||
41,
|
||||
5,
|
||||
3_500_000,
|
||||
13_900,
|
||||
3_400_000,
|
||||
"2026-06-23T08:50:13Z",
|
||||
)
|
||||
.await;
|
||||
sqlx::query("update accounts set quota_json = ?, quota_fetched_at = ? where id = ?")
|
||||
.bind(
|
||||
json!({
|
||||
"rate_limit": {
|
||||
"used_percent": 87.8,
|
||||
"reset_at": 1782737460,
|
||||
"limit_window_seconds": 604800
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.bind("2026-06-23T08:51:09Z")
|
||||
.bind("acct_stats")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let store = SqliteEventLogStore::new(pool.clone());
|
||||
let mut log = EventLog::new("response", EventLevel::Info, "ok");
|
||||
log.account_id = Some("acct_stats".to_string());
|
||||
log.model = Some("gpt-5.5".to_string());
|
||||
log.created_at = "2026-06-23T08:50:13Z".parse().unwrap();
|
||||
log.metadata = json!({
|
||||
"usage": {
|
||||
"inputTokens": 3_500_000u64,
|
||||
"outputTokens": 13_900u64,
|
||||
"cachedTokens": 3_400_000u64
|
||||
}
|
||||
});
|
||||
store.append(&log).await.unwrap();
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/admin/accounts?page=1&pageSize=10")
|
||||
.header("cookie", "cpr_admin_session=session_1")
|
||||
.header("x-request-id", "req_accounts_stats")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = response_json(response).await;
|
||||
let item = &body["data"]["items"][0];
|
||||
assert_eq!(item["usage"]["requestCount"], 41);
|
||||
assert_eq!(item["quota"]["usedPercentDisplay"], "87.8%");
|
||||
assert!(item["quota"]["windowUsedDisplay"]
|
||||
.as_str()
|
||||
.is_some_and(|value| value.contains(" / 7.0d")));
|
||||
assert_eq!(item["usage"]["createdTokens"], 100_000);
|
||||
assert_eq!(item["usage"]["readTokens"], 3_400_000);
|
||||
assert_eq!(item["usage"]["modelTop"]["model"], "gpt-5.5");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_accounts_list_should_return_numbered_page_with_search_total() {
|
||||
let (app, _state, pool, _dir, _secret_box) =
|
||||
|
||||
@@ -15,6 +15,63 @@ export interface Account {
|
||||
addedAtDisplay: string
|
||||
updatedAt: string
|
||||
updatedAtDisplay: string
|
||||
quota: AccountQuota
|
||||
usage: AccountUsage
|
||||
}
|
||||
|
||||
export interface AccountQuota {
|
||||
usedPercent?: number | null
|
||||
usedPercentDisplay: string
|
||||
resetAtDisplay: string
|
||||
refreshedAtDisplay: string
|
||||
windowUsedDisplay: string
|
||||
}
|
||||
|
||||
export interface AccountUsage {
|
||||
requestCount: number
|
||||
requestCountDisplay: string
|
||||
emptyResponseCount: number
|
||||
inputTokens: number
|
||||
inputTokensDisplay: string
|
||||
outputTokens: number
|
||||
outputTokensDisplay: string
|
||||
cachedTokens: number
|
||||
cachedTokensDisplay: string
|
||||
reasoningTokens: number
|
||||
totalTokens: number
|
||||
totalTokensDisplay: string
|
||||
imageInputTokens: number
|
||||
imageOutputTokens: number
|
||||
imageTokensDisplay: string
|
||||
imageRequestCount: number
|
||||
imageRequestFailedCount: number
|
||||
createdTokens: number
|
||||
createdTokensDisplay: string
|
||||
readTokens: number
|
||||
readTokensDisplay: string
|
||||
lastUsedAt?: string
|
||||
lastUsedAtDisplay: string
|
||||
modelTop?: AccountModelUsage | null
|
||||
}
|
||||
|
||||
export interface AccountModelUsage {
|
||||
model: string
|
||||
requestCount: number
|
||||
requestCountDisplay: string
|
||||
successRate: number
|
||||
successRateDisplay: string
|
||||
inputTokens: number
|
||||
inputTokensDisplay: string
|
||||
outputTokens: number
|
||||
outputTokensDisplay: string
|
||||
cachedTokens: number
|
||||
cachedTokensDisplay: string
|
||||
totalTokens: number
|
||||
totalTokensDisplay: string
|
||||
totalCostUsd: number
|
||||
totalCostUsdDisplay: string
|
||||
lastUsedAt?: string
|
||||
lastUsedAtDisplay: string
|
||||
}
|
||||
|
||||
export interface AccountsQuery {
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface AccountUsageStats {
|
||||
imageRequestCount: number
|
||||
imageRequestFailedCount: number
|
||||
lastUsedAt?: string
|
||||
lastUsedAtDisplay: string
|
||||
}
|
||||
|
||||
export interface UsageStatsQuery {
|
||||
|
||||
@@ -40,10 +40,12 @@ const props = withDefaults(
|
||||
emptyText?: string
|
||||
maxHeight?: string
|
||||
pagination?: BaseTablePagination
|
||||
expandedRowKeys?: Array<string | number>
|
||||
}>(),
|
||||
{
|
||||
rowKey: 'id',
|
||||
selectedRowKeys: () => [],
|
||||
expandedRowKeys: () => [],
|
||||
stripe: true,
|
||||
loading: false,
|
||||
emptyText: '暂无数据',
|
||||
@@ -201,6 +203,10 @@ function isRowSelected(row: TableRow, index: number) {
|
||||
return props.selectedRowKeys.includes(getRowKey(row, index))
|
||||
}
|
||||
|
||||
function isRowExpanded(row: TableRow, index: number) {
|
||||
return props.expandedRowKeys.includes(getRowKey(row, index))
|
||||
}
|
||||
|
||||
function alignClass(column: BaseTableColumn) {
|
||||
if (column.align === 'center') {
|
||||
return 'text-center'
|
||||
@@ -342,36 +348,45 @@ function paginationPageClass(page: number) {
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr
|
||||
v-for="(row, index) in rows"
|
||||
:key="getRowKey(row, index)"
|
||||
:class="rowClass(row, index)"
|
||||
role="row"
|
||||
>
|
||||
<td
|
||||
v-for="(column, columnIndex) in computedColumns"
|
||||
:key="column.key"
|
||||
:class="[
|
||||
bodyCellClass,
|
||||
alignClass(column),
|
||||
isLastColumn(columnIndex) ? 'rounded-r-lg pr-3' : undefined,
|
||||
column.cellClass,
|
||||
]"
|
||||
role="cell"
|
||||
<template v-for="(row, index) in rows" :key="getRowKey(row, index)">
|
||||
<tr
|
||||
:class="rowClass(row, index)"
|
||||
role="row"
|
||||
>
|
||||
<div :class="cellContentClass(column)">
|
||||
<slot
|
||||
:name="column.key"
|
||||
:row="row"
|
||||
:value="cellValue(row, column.key)"
|
||||
:index="index"
|
||||
>
|
||||
{{ cellValue(row, column.key) }}
|
||||
</slot>
|
||||
</div>
|
||||
</td>
|
||||
<td class="p-0" aria-hidden="true" />
|
||||
</tr>
|
||||
<td
|
||||
v-for="(column, columnIndex) in computedColumns"
|
||||
:key="column.key"
|
||||
:class="[
|
||||
bodyCellClass,
|
||||
alignClass(column),
|
||||
isLastColumn(columnIndex) ? 'rounded-r-lg pr-3' : undefined,
|
||||
column.cellClass,
|
||||
]"
|
||||
role="cell"
|
||||
>
|
||||
<div :class="cellContentClass(column)">
|
||||
<slot
|
||||
:name="column.key"
|
||||
:row="row"
|
||||
:value="cellValue(row, column.key)"
|
||||
:index="index"
|
||||
>
|
||||
{{ cellValue(row, column.key) }}
|
||||
</slot>
|
||||
</div>
|
||||
</td>
|
||||
<td class="p-0" aria-hidden="true" />
|
||||
</tr>
|
||||
<tr v-if="isRowExpanded(row, index)" role="row">
|
||||
<td
|
||||
:colspan="computedColumns.length + 1"
|
||||
class="rounded-lg bg-(--cp-info-bg) p-0"
|
||||
role="cell"
|
||||
>
|
||||
<slot name="expanded" :row="row" :index="index" />
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</BaseScrollbar>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { Plus, RefreshCw, Trash2, Download, Upload, Search } from '@lucide/vue'
|
||||
import { ChevronDown, Plus, RefreshCw, Trash2, Download, Upload, Search } from '@lucide/vue'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import BaseCard from '@/components/base/BaseCard.vue'
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
batchDeleteAccounts,
|
||||
createAccount,
|
||||
deleteAccount,
|
||||
getAccountQuota,
|
||||
getAccounts,
|
||||
refreshAccount,
|
||||
} from '@/api'
|
||||
@@ -29,12 +30,14 @@ const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const searchQuery = ref('')
|
||||
const selectedIds = ref<Set<string>>(new Set())
|
||||
const expandedAccountIds = ref<Set<string>>(new Set())
|
||||
const showCreateModal = ref(false)
|
||||
const showDeleteModal = ref(false)
|
||||
const showSingleDeleteModal = ref(false)
|
||||
const pendingDeleteAccount = ref<Account | null>(null)
|
||||
const refreshingList = ref(false)
|
||||
const refreshingAccountIds = ref<Set<string>>(new Set())
|
||||
const refreshingQuotaAccountIds = ref<Set<string>>(new Set())
|
||||
const deletingAccount = ref(false)
|
||||
const batchDeleting = ref(false)
|
||||
let searchTimer: number | undefined
|
||||
@@ -196,6 +199,23 @@ async function handleRefresh(accountId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRefreshQuota(accountId: string) {
|
||||
if (refreshingQuotaAccountIds.value.has(accountId)) return
|
||||
refreshingQuotaAccountIds.value = new Set(refreshingQuotaAccountIds.value).add(accountId)
|
||||
try {
|
||||
await withMinimumDuration(async () => {
|
||||
await getAccountQuota(accountId)
|
||||
await loadAccounts()
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh account quota:', error)
|
||||
} finally {
|
||||
const next = new Set(refreshingQuotaAccountIds.value)
|
||||
next.delete(accountId)
|
||||
refreshingQuotaAccountIds.value = next
|
||||
}
|
||||
}
|
||||
|
||||
function statusTone(status: Account['status']) {
|
||||
return statusTones[status]
|
||||
}
|
||||
@@ -212,6 +232,16 @@ function toggleSelection(accountId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function toggleExpanded(accountId: string) {
|
||||
const next = new Set(expandedAccountIds.value)
|
||||
if (next.has(accountId)) {
|
||||
next.delete(accountId)
|
||||
} else {
|
||||
next.add(accountId)
|
||||
}
|
||||
expandedAccountIds.value = next
|
||||
}
|
||||
|
||||
function toggleAll() {
|
||||
if (allSelected.value) {
|
||||
accounts.value.forEach((account) => selectedIds.value.delete(account.id))
|
||||
@@ -231,6 +261,10 @@ function handlePageSizeChange(nextPageSize: number) {
|
||||
void loadAccounts()
|
||||
}
|
||||
|
||||
function quotaBarWidth(account: Account) {
|
||||
return `${Math.max(0, Math.min(account.quota.usedPercent ?? 0, 100))}%`
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadAccounts()
|
||||
})
|
||||
@@ -319,6 +353,7 @@ onBeforeUnmount(() => {
|
||||
:columns="accountColumns"
|
||||
:rows="filteredAccounts"
|
||||
:selected-row-keys="selectedRowKeys"
|
||||
:expanded-row-keys="[...expandedAccountIds]"
|
||||
:pagination="accountPagination"
|
||||
empty-text="暂无账号数据"
|
||||
@page-change="handlePageChange"
|
||||
@@ -335,12 +370,25 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
|
||||
<template #selection="{ row }">
|
||||
<BaseCheckbox
|
||||
:model-value="selectedIds.has(row.id)"
|
||||
label="选择账号"
|
||||
size="table"
|
||||
@update:model-value="toggleSelection(row.id)"
|
||||
/>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex size-6 cursor-pointer items-center justify-center rounded-md border-0 bg-transparent text-(--cp-text-secondary) transition hover:bg-(--cp-default-bg-hover) hover:text-(--cp-text-primary)"
|
||||
:title="expandedAccountIds.has(row.id) ? '收起统计' : '展开统计'"
|
||||
@click.stop="toggleExpanded(row.id)"
|
||||
>
|
||||
<ChevronDown
|
||||
class="size-3.5 transition-transform"
|
||||
:class="expandedAccountIds.has(row.id) ? 'rotate-180' : '-rotate-90'"
|
||||
/>
|
||||
</button>
|
||||
<BaseCheckbox
|
||||
:model-value="selectedIds.has(row.id)"
|
||||
label="选择账号"
|
||||
size="table"
|
||||
@update:model-value="toggleSelection(row.id)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #identity="{ row }">
|
||||
@@ -370,12 +418,16 @@ onBeforeUnmount(() => {
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #requests>
|
||||
<span class="font-mono text-[14px] text-(--cp-text-secondary)"> — </span>
|
||||
<template #requests="{ row }">
|
||||
<span class="font-mono text-[14px] text-(--cp-text-primary)">
|
||||
{{ row.usage.requestCountDisplay }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #tokens>
|
||||
<span class="font-mono text-[14px] text-(--cp-text-secondary)"> — </span>
|
||||
<template #tokens="{ row }">
|
||||
<span class="font-mono text-[14px] text-(--cp-text-primary)">
|
||||
{{ row.usage.totalTokensDisplay }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #updatedAt="{ row }">
|
||||
@@ -412,6 +464,121 @@ onBeforeUnmount(() => {
|
||||
</BaseIconButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #expanded="{ row }">
|
||||
<div class="grid gap-3 p-4 lg:grid-cols-[1.05fr_2.45fr]">
|
||||
<section class="rounded-lg bg-(--cp-bg-surface) p-4 shadow-(--cp-shadow-control)">
|
||||
<div class="mb-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="m-0 text-[14px] font-[760] text-(--cp-text-primary)">账号额度</h3>
|
||||
<p class="m-0 mt-1 text-[12px] font-[620] text-(--cp-text-secondary)">
|
||||
Codex 额度 · 套餐: {{ row.planType || 'Free' }} · 最近刷新:
|
||||
{{ row.quota.refreshedAtDisplay }}
|
||||
</p>
|
||||
</div>
|
||||
<BaseIconButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
title="刷新额度"
|
||||
:loading="refreshingQuotaAccountIds.has(row.id)"
|
||||
@click="handleRefreshQuota(row.id)"
|
||||
>
|
||||
<RefreshCw class="size-3.5" />
|
||||
</BaseIconButton>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between text-[12px] font-[720]">
|
||||
<span class="text-(--cp-text-secondary)">周限额</span>
|
||||
<span class="text-(--cp-text-primary)">{{ row.quota.usedPercentDisplay }}</span>
|
||||
</div>
|
||||
<div class="mt-2 h-2 overflow-hidden rounded-full bg-(--cp-bg-tertiary)">
|
||||
<div
|
||||
class="h-full rounded-full bg-(--cp-info)"
|
||||
:style="{ width: quotaBarWidth(row) }"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-3 flex justify-between text-[12px] font-[620] text-(--cp-text-secondary)">
|
||||
<span>重置时间: {{ row.quota.resetAtDisplay }}</span>
|
||||
<span>窗口已用: {{ row.quota.windowUsedDisplay }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class="grid gap-4 rounded-lg bg-(--cp-bg-surface) p-4 shadow-(--cp-shadow-control) xl:grid-cols-[0.52fr_1.48fr]"
|
||||
>
|
||||
<div>
|
||||
<h3 class="m-0 mb-3 text-[14px] font-[760] text-(--cp-text-primary)">
|
||||
Token 结构
|
||||
</h3>
|
||||
<div class="grid gap-2">
|
||||
<div class="flex items-center justify-between rounded-lg bg-green-50 px-3 py-2">
|
||||
<span class="text-[12px] font-[700] text-green-700">输入 Tokens</span>
|
||||
<strong class="font-mono text-[13px] text-(--cp-text-primary)">
|
||||
{{ row.usage.inputTokensDisplay }}
|
||||
</strong>
|
||||
</div>
|
||||
<div class="flex items-center justify-between rounded-lg bg-amber-50 px-3 py-2">
|
||||
<span class="text-[12px] font-[700] text-amber-700">输出 Tokens</span>
|
||||
<strong class="font-mono text-[13px] text-(--cp-text-primary)">
|
||||
{{ row.usage.outputTokensDisplay }}
|
||||
</strong>
|
||||
</div>
|
||||
<div class="flex items-center justify-between rounded-lg bg-cyan-50 px-3 py-2">
|
||||
<span class="text-[12px] font-[700] text-cyan-700">缓存 Tokens</span>
|
||||
<strong class="font-mono text-[13px] text-(--cp-text-primary)">
|
||||
{{ row.usage.cachedTokensDisplay }}
|
||||
</strong>
|
||||
</div>
|
||||
<div class="flex items-center justify-between rounded-lg bg-sky-50 px-3 py-2">
|
||||
<span class="text-[12px] font-[700] text-sky-700">创建</span>
|
||||
<strong class="font-mono text-[13px] text-(--cp-text-primary)">
|
||||
{{ row.usage.createdTokensDisplay }}
|
||||
</strong>
|
||||
</div>
|
||||
<div class="flex items-center justify-between rounded-lg bg-sky-50 px-3 py-2">
|
||||
<span class="text-[12px] font-[700] text-sky-700">读取</span>
|
||||
<strong class="font-mono text-[13px] text-(--cp-text-primary)">
|
||||
{{ row.usage.readTokensDisplay }}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 pt-4 shadow-[inset_0_1px_0_rgba(216,224,234,0.42)] xl:pt-0 xl:pl-4 xl:shadow-[inset_1px_0_0_rgba(216,224,234,0.42)]">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h3 class="m-0 text-[14px] font-[760] text-(--cp-text-primary)">
|
||||
模型使用 Top 1
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-[1.2fr_0.7fr_0.8fr_1fr_1fr_1fr_1fr_1fr_1.4fr] gap-3 pb-2 text-[11px] font-[760] text-(--cp-text-muted) shadow-[inset_0_-1px_0_rgba(216,224,234,0.42)]">
|
||||
<span>模型</span>
|
||||
<span>调用</span>
|
||||
<span>成功率</span>
|
||||
<span>输入</span>
|
||||
<span>输出</span>
|
||||
<span>缓存</span>
|
||||
<span>总TOKEN</span>
|
||||
<span>总花费</span>
|
||||
<span>最近请求时间</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-[1.2fr_0.7fr_0.8fr_1fr_1fr_1fr_1fr_1fr_1.4fr] gap-3 pt-3 text-[12px] font-[650] text-(--cp-text-primary)">
|
||||
<span class="truncate">{{ row.usage.modelTop?.model || '-' }}</span>
|
||||
<span>{{ row.usage.modelTop?.requestCountDisplay || '-' }}</span>
|
||||
<span class="text-amber-600">
|
||||
{{ row.usage.modelTop?.successRateDisplay || '-' }}
|
||||
</span>
|
||||
<span>{{ row.usage.modelTop?.inputTokensDisplay || '-' }}</span>
|
||||
<span>{{ row.usage.modelTop?.outputTokensDisplay || '-' }}</span>
|
||||
<span>{{ row.usage.modelTop?.cachedTokensDisplay || '-' }}</span>
|
||||
<span>{{ row.usage.modelTop?.totalTokensDisplay || '-' }}</span>
|
||||
<span>{{ row.usage.modelTop?.totalCostUsdDisplay || '-' }}</span>
|
||||
<span>{{ row.usage.modelTop?.lastUsedAtDisplay || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
</BaseTable>
|
||||
</BaseCard>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user