Files
CC-Switch/src-tauri/src/services/usage_stats.rs
T
Dex Miller de23216e49 feat(usage): refine usage dashboard UI and date range picker (#2002)
* feat(usage): enhance usage stats backend and query hooks

* feat(usage): redesign calendar date range picker with auto-switch and simplified layout

* refactor(usage): streamline dashboard layout and stats components

* refactor(usage): compact request log table with merged cache/multiplier columns and centered layout

* feat(i18n): add cache short labels and usage stats translations for zh/en/ja

* Align usage dashboard stats with range boundaries

The usage dashboard mixed second-precision detail rows with day-level rollups, which caused custom half-day ranges to overcount historical rollup data and left the request log paginator on stale pages after top-level filter changes.

This change limits rollups to fully covered local days, aligns multi-day trend buckets with natural local days, and resets request log pagination when the dashboard range or app filter changes.

Constraint: usage_daily_rollups stores only daily aggregates after pruning old detail rows
Rejected: Include partial boundary rollups proportionally | historical intra-day detail is unavailable after pruning
Rejected: Force RequestLogTable remount on range change | would discard local draft filters unnecessarily
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep summary, trends, provider stats, and model stats on the same rollup-boundary rules
Tested: cargo test --manifest-path src-tauri/Cargo.toml usage_stats
Tested: pnpm exec vitest run tests/components/RequestLogTable.test.tsx
Tested: pnpm typecheck
Not-tested: Manual UI validation in the Tauri app

* Preserve full-day usage filters at minute precision

The latest review surfaced two interaction bugs in the usage dashboard: rollup-backed stats undercounted end days selected via the minute-precision picker, and immediate select changes accidentally applied unsubmitted text drafts from the request log filters.

This change treats 23:59 as a fully selected local end day for rollup inclusion and narrows select-side state syncing so app/status updates do not commit provider/model drafts.

Constraint: The custom range picker emits minute-precision timestamps, while rollups are stored at day granularity
Rejected: Require exact 23:59:59 end timestamps | unreachable from the current picker UI
Rejected: Rebuild applied filters from the full draft state on select changes | silently commits unsaved text input
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep request-log text fields on explicit apply semantics even when select filters remain immediate
Tested: cargo test --manifest-path src-tauri/Cargo.toml usage_stats
Tested: pnpm exec vitest run tests/components/RequestLogTable.test.tsx
Tested: pnpm typecheck
Not-tested: Manual Tauri dashboard interaction

* refactor(usage): move range presets into date picker, single-row layout

- UsageDateRangePicker: add preset shortcuts (今天/1d/7d/14d/30d) inside
  popover top; clicking a preset applies immediately and closes popover
- UsageDashboard: collapse to single row (app filters + refresh + picker);
  remove standalone preset buttons and summary stats bar
- RequestLogTable: replace static Calendar badge with interactive
  UsageDateRangePicker via onRangeChange prop; single filter row

* Keep usage pagination regression coverage aligned with the rendered UI

The new regression test was asserting a non-existent pagination label and page summary text, so it failed before it could verify the real page-reset behavior. This commit switches the assertions to the numbered pagination buttons that the component actually renders and validates the reset through the query hook arguments.

Constraint: RequestLogTable exposes numbered pagination buttons, not a "Next page" label or "2 / 6" summary text
Rejected: Add synthetic pagination labels solely for the test | would couple production markup to a test-only assumption
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Prefer pagination assertions that follow the rendered controls or hook inputs instead of invented summary text
Tested: pnpm vitest run tests/components/RequestLogTable.test.tsx; pnpm typecheck; pnpm test:unit

* refactor(usage): clean up dead code and polish date range picker

- Remove unused exports MAX_CUSTOM_USAGE_RANGE_SECONDS,
  timestampToLocalDatetime, and localDatetimeToTimestamp from
  usageRange.ts (replaced by the calendar picker)
- Deduplicate getPresetLabel from UsageDashboard and
  UsageDateRangePicker into shared getUsageRangePresetLabel helper
- Add aria-label, aria-current and aria-pressed to calendar day
  buttons so screen readers can disambiguate same-numbered days
  across adjacent months
- Drop unused cacheReadShort and cacheWriteShort i18n keys (zh/en/ja);
  the request log table renders R/W prefixes inline
- Align customRangeHint copy with the removed 30-day limit by
  dropping "up to 30 days" wording (zh/en/ja)

* fix(usage): align rollup cutoff to local midnight to keep days complete

`rollup_and_prune` previously used `Utc::now() - retain_days * 86400`
as the cutoff. Because rollups are bucketed by *local* date and detail
rows below the cutoff are pruned, an unaligned cutoff left the youngest
rolled-up day half-rolled-up and half-pruned. Combined with the new
`compute_rollup_date_bounds` boundary trimming (which excludes any
rollup day not fully covered by the requested range), custom range
queries that touch that day silently under-count summary, trend,
provider, and model stats.

Fix the invariant at the source: snap the cutoff to the next local
midnight after `(now - retain_days)`. Every rollup row now reflects a
complete local day, so the boundary trimmer's all-or-nothing assumption
holds.

Includes unit tests for the cutoff math (typical case + already-on-
midnight case). DST gap is handled defensively by bumping forward by
an hour.

Addresses Codex P2 review finding on PR #2002.

---------

Co-authored-by: Jason <farion1231@gmail.com>
2026-04-16 17:00:28 +08:00

1931 lines
70 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 使用统计服务
//!
//! 提供使用量数据的聚合查询功能
use crate::database::{lock_conn, Database};
use crate::error::AppError;
use chrono::{Local, NaiveDate, TimeZone, Timelike};
use rusqlite::{params, Connection, OptionalExtension};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::str::FromStr;
/// 使用量汇总
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UsageSummary {
pub total_requests: u64,
pub total_cost: String,
pub total_input_tokens: u64,
pub total_output_tokens: u64,
pub total_cache_creation_tokens: u64,
pub total_cache_read_tokens: u64,
pub success_rate: f32,
}
/// 每日统计
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DailyStats {
pub date: String,
pub request_count: u64,
pub total_cost: String,
pub total_tokens: u64,
pub total_input_tokens: u64,
pub total_output_tokens: u64,
pub total_cache_creation_tokens: u64,
pub total_cache_read_tokens: u64,
}
/// Provider 统计
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProviderStats {
pub provider_id: String,
pub provider_name: String,
pub request_count: u64,
pub total_tokens: u64,
pub total_cost: String,
pub success_rate: f32,
pub avg_latency_ms: u64,
}
/// 模型统计
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelStats {
pub model: String,
pub request_count: u64,
pub total_tokens: u64,
pub total_cost: String,
pub avg_cost_per_request: String,
}
/// 请求日志过滤器
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LogFilters {
pub app_type: Option<String>,
pub provider_name: Option<String>,
pub model: Option<String>,
pub status_code: Option<u16>,
pub start_date: Option<i64>,
pub end_date: Option<i64>,
}
/// 分页请求日志响应
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PaginatedLogs {
pub data: Vec<RequestLogDetail>,
pub total: u32,
pub page: u32,
pub page_size: u32,
}
/// 请求日志详情
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RequestLogDetail {
pub request_id: String,
pub provider_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub provider_name: Option<String>,
pub app_type: String,
pub model: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub request_model: Option<String>,
pub cost_multiplier: String,
pub input_tokens: u32,
pub output_tokens: u32,
pub cache_read_tokens: u32,
pub cache_creation_tokens: u32,
pub input_cost_usd: String,
pub output_cost_usd: String,
pub cache_read_cost_usd: String,
pub cache_creation_cost_usd: String,
pub total_cost_usd: String,
pub is_streaming: bool,
pub latency_ms: u64,
pub first_token_ms: Option<u64>,
pub duration_ms: Option<u64>,
pub status_code: u16,
pub error_message: Option<String>,
pub created_at: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub data_source: Option<String>,
}
/// SQL fragment: resolve provider_name with fallback for session-based entries.
/// Session logs use placeholder provider_ids (_session, _codex_session, _gemini_session)
/// that don't exist in the providers table — this COALESCE gives them readable names.
fn provider_name_coalesce(log_alias: &str, provider_alias: &str) -> String {
format!(
"COALESCE({provider_alias}.name, CASE {log_alias}.provider_id \
WHEN '_session' THEN 'Claude (Session)' \
WHEN '_codex_session' THEN 'Codex (Session)' \
WHEN '_gemini_session' THEN 'Gemini (Session)' \
ELSE {log_alias}.provider_id END)"
)
}
#[derive(Debug, Clone, Default)]
struct RollupDateBounds {
start: Option<String>,
end: Option<String>,
is_empty: bool,
}
fn local_datetime_from_timestamp(ts: i64) -> Result<chrono::DateTime<Local>, AppError> {
Local
.timestamp_opt(ts, 0)
.single()
.ok_or_else(|| AppError::Database(format!("无法解析本地时间戳: {ts}")))
}
fn compute_rollup_date_bounds(
start_ts: Option<i64>,
end_ts: Option<i64>,
) -> Result<RollupDateBounds, AppError> {
let start = match start_ts {
Some(ts) => {
let local = local_datetime_from_timestamp(ts)?;
let day = local.date_naive();
if local.time().num_seconds_from_midnight() == 0 {
Some(day.format("%Y-%m-%d").to_string())
} else {
day.succ_opt()
.map(|next| next.format("%Y-%m-%d").to_string())
}
}
None => None,
};
let end = match end_ts {
Some(ts) => {
let local = local_datetime_from_timestamp(ts)?;
let day = local.date_naive();
if local.time().hour() == 23 && local.time().minute() == 59 {
Some(day.format("%Y-%m-%d").to_string())
} else {
day.pred_opt()
.map(|prev| prev.format("%Y-%m-%d").to_string())
}
}
None => None,
};
let is_empty = matches!((&start, &end), (Some(start), Some(end)) if start > end);
Ok(RollupDateBounds {
start,
end,
is_empty,
})
}
fn push_rollup_date_filters(
conditions: &mut Vec<String>,
params: &mut Vec<Box<dyn rusqlite::ToSql>>,
column: &str,
bounds: &RollupDateBounds,
) {
if bounds.is_empty {
conditions.push("1 = 0".to_string());
return;
}
if let Some(start) = &bounds.start {
conditions.push(format!("{column} >= ?"));
params.push(Box::new(start.clone()));
}
if let Some(end) = &bounds.end {
conditions.push(format!("{column} <= ?"));
params.push(Box::new(end.clone()));
}
}
fn local_day_start_rfc3339(day: NaiveDate) -> String {
let local_midnight = day
.and_hms_opt(0, 0, 0)
.and_then(|naive| match Local.from_local_datetime(&naive) {
chrono::LocalResult::Single(dt) => Some(dt),
chrono::LocalResult::Ambiguous(earliest, _) => Some(earliest),
chrono::LocalResult::None => None,
})
.unwrap_or_else(Local::now);
local_midnight.to_rfc3339()
}
impl Database {
/// 获取使用量汇总
pub fn get_usage_summary(
&self,
start_date: Option<i64>,
end_date: Option<i64>,
app_type: Option<&str>,
) -> Result<UsageSummary, AppError> {
let conn = lock_conn!(self.conn);
// Build detail WHERE clause
let mut conditions = Vec::new();
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
if let Some(start) = start_date {
conditions.push("created_at >= ?");
params_vec.push(Box::new(start));
}
if let Some(end) = end_date {
conditions.push("created_at <= ?");
params_vec.push(Box::new(end));
}
if let Some(at) = app_type {
conditions.push("app_type = ?");
params_vec.push(Box::new(at.to_string()));
}
let where_clause = if conditions.is_empty() {
String::new()
} else {
format!("WHERE {}", conditions.join(" AND "))
};
// Only include rolled-up rows for full local days that are fully covered by the range.
let mut rollup_conditions: Vec<String> = Vec::new();
let mut rollup_params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
let rollup_bounds = compute_rollup_date_bounds(start_date, end_date)?;
push_rollup_date_filters(
&mut rollup_conditions,
&mut rollup_params,
"date",
&rollup_bounds,
);
if let Some(at) = app_type {
rollup_conditions.push("app_type = ?".to_string());
rollup_params.push(Box::new(at.to_string()));
}
let rollup_where = if rollup_conditions.is_empty() {
String::new()
} else {
format!("WHERE {}", rollup_conditions.join(" AND "))
};
let sql = format!(
"SELECT
COALESCE(d.total_requests, 0) + COALESCE(r.total_requests, 0),
COALESCE(d.total_cost, 0) + COALESCE(r.total_cost, 0),
COALESCE(d.total_input_tokens, 0) + COALESCE(r.total_input_tokens, 0),
COALESCE(d.total_output_tokens, 0) + COALESCE(r.total_output_tokens, 0),
COALESCE(d.total_cache_creation_tokens, 0) + COALESCE(r.total_cache_creation_tokens, 0),
COALESCE(d.total_cache_read_tokens, 0) + COALESCE(r.total_cache_read_tokens, 0),
COALESCE(d.success_count, 0) + COALESCE(r.success_count, 0)
FROM
(SELECT
COUNT(*) as total_requests,
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost,
COALESCE(SUM(input_tokens), 0) as total_input_tokens,
COALESCE(SUM(output_tokens), 0) as total_output_tokens,
COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens,
COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens,
COALESCE(SUM(CASE WHEN status_code >= 200 AND status_code < 300 THEN 1 ELSE 0 END), 0) as success_count
FROM proxy_request_logs {where_clause}) d,
(SELECT
COALESCE(SUM(request_count), 0) as total_requests,
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost,
COALESCE(SUM(input_tokens), 0) as total_input_tokens,
COALESCE(SUM(output_tokens), 0) as total_output_tokens,
COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens,
COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens,
COALESCE(SUM(success_count), 0) as success_count
FROM usage_daily_rollups {rollup_where}) r"
);
// Combine params: detail params first, then rollup params
let mut all_params: Vec<Box<dyn rusqlite::ToSql>> = params_vec;
all_params.extend(rollup_params);
let param_refs: Vec<&dyn rusqlite::ToSql> = all_params.iter().map(|p| p.as_ref()).collect();
let result = conn.query_row(&sql, param_refs.as_slice(), |row| {
let total_requests: i64 = row.get(0)?;
let total_cost: f64 = row.get(1)?;
let total_input_tokens: i64 = row.get(2)?;
let total_output_tokens: i64 = row.get(3)?;
let total_cache_creation_tokens: i64 = row.get(4)?;
let total_cache_read_tokens: i64 = row.get(5)?;
let success_count: i64 = row.get(6)?;
let success_rate = if total_requests > 0 {
(success_count as f32 / total_requests as f32) * 100.0
} else {
0.0
};
Ok(UsageSummary {
total_requests: total_requests as u64,
total_cost: format!("{total_cost:.6}"),
total_input_tokens: total_input_tokens as u64,
total_output_tokens: total_output_tokens as u64,
total_cache_creation_tokens: total_cache_creation_tokens as u64,
total_cache_read_tokens: total_cache_read_tokens as u64,
success_rate,
})
})?;
Ok(result)
}
/// 获取每日趋势(滑动窗口,<=24h 按小时,>24h 按天,窗口与汇总一致)
pub fn get_daily_trends(
&self,
start_date: Option<i64>,
end_date: Option<i64>,
app_type: Option<&str>,
) -> Result<Vec<DailyStats>, AppError> {
let conn = lock_conn!(self.conn);
let end_ts = end_date.unwrap_or_else(|| Local::now().timestamp());
let mut start_ts = start_date.unwrap_or_else(|| end_ts - 24 * 60 * 60);
if start_ts >= end_ts {
start_ts = end_ts - 24 * 60 * 60;
}
let duration = end_ts - start_ts;
if duration <= 24 * 60 * 60 {
let bucket_seconds: i64 = 60 * 60;
let mut bucket_count: i64 = if duration <= 0 {
1
} else {
(duration + bucket_seconds - 1) / bucket_seconds
};
if bucket_count < 1 {
bucket_count = 1;
}
let app_type_filter = if app_type.is_some() {
"AND app_type = ?4"
} else {
""
};
let sql = format!(
"SELECT
CAST((created_at - ?1) / ?3 AS INTEGER) as bucket_idx,
COUNT(*) as request_count,
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost,
COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens,
COALESCE(SUM(input_tokens), 0) as total_input_tokens,
COALESCE(SUM(output_tokens), 0) as total_output_tokens,
COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens,
COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens
FROM proxy_request_logs
WHERE created_at >= ?1 AND created_at <= ?2 {app_type_filter}
GROUP BY bucket_idx
ORDER BY bucket_idx ASC"
);
let mut stmt = conn.prepare(&sql)?;
let row_mapper = |row: &rusqlite::Row| {
Ok((
row.get::<_, i64>(0)?,
DailyStats {
date: String::new(),
request_count: row.get::<_, i64>(1)? as u64,
total_cost: format!("{:.6}", row.get::<_, f64>(2)?),
total_tokens: row.get::<_, i64>(3)? as u64,
total_input_tokens: row.get::<_, i64>(4)? as u64,
total_output_tokens: row.get::<_, i64>(5)? as u64,
total_cache_creation_tokens: row.get::<_, i64>(6)? as u64,
total_cache_read_tokens: row.get::<_, i64>(7)? as u64,
},
))
};
let mut map: HashMap<i64, DailyStats> = HashMap::new();
let rows = if let Some(at) = app_type {
stmt.query_map(params![start_ts, end_ts, bucket_seconds, at], row_mapper)?
} else {
stmt.query_map(params![start_ts, end_ts, bucket_seconds], row_mapper)?
};
for row in rows {
let (mut bucket_idx, stat) = row?;
if bucket_idx < 0 {
continue;
}
if bucket_idx >= bucket_count {
bucket_idx = bucket_count - 1;
}
map.insert(bucket_idx, stat);
}
let mut stats = Vec::with_capacity(bucket_count as usize);
for i in 0..bucket_count {
let bucket_start_ts = start_ts + i * bucket_seconds;
let bucket_start = local_datetime_from_timestamp(bucket_start_ts)?;
let date = bucket_start.to_rfc3339();
if let Some(mut stat) = map.remove(&i) {
stat.date = date;
stats.push(stat);
} else {
stats.push(DailyStats {
date,
request_count: 0,
total_cost: "0.000000".to_string(),
total_tokens: 0,
total_input_tokens: 0,
total_output_tokens: 0,
total_cache_creation_tokens: 0,
total_cache_read_tokens: 0,
});
}
}
return Ok(stats);
}
let start_day = local_datetime_from_timestamp(start_ts)?.date_naive();
let end_day = local_datetime_from_timestamp(end_ts)?.date_naive();
let bucket_count = (end_day.signed_duration_since(start_day).num_days() + 1) as usize;
let app_type_filter = if app_type.is_some() {
"AND app_type = ?3"
} else {
""
};
let detail_sql = format!(
"SELECT
date(created_at, 'unixepoch', 'localtime') as bucket_date,
COUNT(*) as request_count,
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost,
COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens,
COALESCE(SUM(input_tokens), 0) as total_input_tokens,
COALESCE(SUM(output_tokens), 0) as total_output_tokens,
COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens,
COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens
FROM proxy_request_logs
WHERE created_at >= ?1 AND created_at <= ?2 {app_type_filter}
GROUP BY bucket_date
ORDER BY bucket_date ASC"
);
let mut detail_stmt = conn.prepare(&detail_sql)?;
let detail_row_mapper = |row: &rusqlite::Row| {
Ok((
row.get::<_, String>(0)?,
DailyStats {
date: String::new(),
request_count: row.get::<_, i64>(1)? as u64,
total_cost: format!("{:.6}", row.get::<_, f64>(2)?),
total_tokens: row.get::<_, i64>(3)? as u64,
total_input_tokens: row.get::<_, i64>(4)? as u64,
total_output_tokens: row.get::<_, i64>(5)? as u64,
total_cache_creation_tokens: row.get::<_, i64>(6)? as u64,
total_cache_read_tokens: row.get::<_, i64>(7)? as u64,
},
))
};
let mut map: HashMap<NaiveDate, DailyStats> = HashMap::new();
let detail_rows = if let Some(at) = app_type {
detail_stmt.query_map(params![start_ts, end_ts, at], detail_row_mapper)?
} else {
detail_stmt.query_map(params![start_ts, end_ts], detail_row_mapper)?
};
for row in detail_rows {
let (bucket_date, stat) = row?;
let date = NaiveDate::parse_from_str(&bucket_date, "%Y-%m-%d")
.map_err(|err| AppError::Database(format!("解析趋势日期失败: {err}")))?;
map.insert(date, stat);
}
let rollup_bounds = compute_rollup_date_bounds(Some(start_ts), Some(end_ts))?;
let mut rollup_conditions = Vec::new();
let mut rollup_params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
push_rollup_date_filters(
&mut rollup_conditions,
&mut rollup_params,
"date",
&rollup_bounds,
);
if let Some(at) = app_type {
rollup_conditions.push("app_type = ?".to_string());
rollup_params.push(Box::new(at.to_string()));
}
let rollup_where = if rollup_conditions.is_empty() {
String::new()
} else {
format!("WHERE {}", rollup_conditions.join(" AND "))
};
let rollup_sql = format!(
"SELECT
date,
COALESCE(SUM(request_count), 0),
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0),
COALESCE(SUM(input_tokens + output_tokens), 0),
COALESCE(SUM(input_tokens), 0),
COALESCE(SUM(output_tokens), 0),
COALESCE(SUM(cache_creation_tokens), 0),
COALESCE(SUM(cache_read_tokens), 0)
FROM usage_daily_rollups
{rollup_where}
GROUP BY date
ORDER BY date ASC"
);
let mut rollup_stmt = conn.prepare(&rollup_sql)?;
let rollup_row_mapper = |row: &rusqlite::Row| {
Ok((
row.get::<_, String>(0)?,
(
row.get::<_, i64>(1)? as u64,
row.get::<_, f64>(2)?,
row.get::<_, i64>(3)? as u64,
row.get::<_, i64>(4)? as u64,
row.get::<_, i64>(5)? as u64,
row.get::<_, i64>(6)? as u64,
row.get::<_, i64>(7)? as u64,
),
))
};
let rollup_param_refs: Vec<&dyn rusqlite::ToSql> =
rollup_params.iter().map(|param| param.as_ref()).collect();
let rollup_rows = rollup_stmt.query_map(rollup_param_refs.as_slice(), rollup_row_mapper)?;
for row in rollup_rows {
let (bucket_date, (req, cost, tok, inp, out, cc, cr)) = row?;
let date = NaiveDate::parse_from_str(&bucket_date, "%Y-%m-%d")
.map_err(|err| AppError::Database(format!("解析 rollup 趋势日期失败: {err}")))?;
let entry = map.entry(date).or_insert_with(|| DailyStats {
date: String::new(),
request_count: 0,
total_cost: "0.000000".to_string(),
total_tokens: 0,
total_input_tokens: 0,
total_output_tokens: 0,
total_cache_creation_tokens: 0,
total_cache_read_tokens: 0,
});
entry.request_count += req;
let existing_cost: f64 = entry.total_cost.parse().unwrap_or(0.0);
entry.total_cost = format!("{:.6}", existing_cost + cost);
entry.total_tokens += tok;
entry.total_input_tokens += inp;
entry.total_output_tokens += out;
entry.total_cache_creation_tokens += cc;
entry.total_cache_read_tokens += cr;
}
let mut stats = Vec::with_capacity(bucket_count);
let mut current_day = start_day;
for _ in 0..bucket_count {
let date = local_day_start_rfc3339(current_day);
if let Some(mut stat) = map.remove(&current_day) {
stat.date = date;
stats.push(stat);
} else {
stats.push(DailyStats {
date,
request_count: 0,
total_cost: "0.000000".to_string(),
total_tokens: 0,
total_input_tokens: 0,
total_output_tokens: 0,
total_cache_creation_tokens: 0,
total_cache_read_tokens: 0,
});
}
current_day = current_day.succ_opt().unwrap_or(current_day);
}
Ok(stats)
}
/// 获取 Provider 统计
pub fn get_provider_stats(
&self,
start_date: Option<i64>,
end_date: Option<i64>,
app_type: Option<&str>,
) -> Result<Vec<ProviderStats>, AppError> {
let conn = lock_conn!(self.conn);
let mut detail_conditions = Vec::new();
let mut detail_params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
if let Some(start) = start_date {
detail_conditions.push("l.created_at >= ?");
detail_params.push(Box::new(start));
}
if let Some(end) = end_date {
detail_conditions.push("l.created_at <= ?");
detail_params.push(Box::new(end));
}
if let Some(at) = app_type {
detail_conditions.push("l.app_type = ?");
detail_params.push(Box::new(at.to_string()));
}
let detail_where = if detail_conditions.is_empty() {
String::new()
} else {
format!("WHERE {}", detail_conditions.join(" AND "))
};
let mut rollup_conditions = Vec::new();
let mut rollup_params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
let rollup_bounds = compute_rollup_date_bounds(start_date, end_date)?;
push_rollup_date_filters(
&mut rollup_conditions,
&mut rollup_params,
"r.date",
&rollup_bounds,
);
if let Some(at) = app_type {
rollup_conditions.push("r.app_type = ?".to_string());
rollup_params.push(Box::new(at.to_string()));
}
let rollup_where = if rollup_conditions.is_empty() {
String::new()
} else {
format!("WHERE {}", rollup_conditions.join(" AND "))
};
// UNION detail logs + rollup data, then aggregate
let detail_pname = provider_name_coalesce("l", "p");
let rollup_pname = provider_name_coalesce("r", "p2");
let sql = format!(
"SELECT
provider_id, app_type, provider_name,
SUM(request_count) as request_count,
SUM(total_tokens) as total_tokens,
SUM(total_cost) as total_cost,
SUM(success_count) as success_count,
CASE WHEN SUM(request_count) > 0
THEN SUM(latency_sum) / SUM(request_count)
ELSE 0 END as avg_latency
FROM (
SELECT l.provider_id, l.app_type,
{detail_pname} as provider_name,
COUNT(*) as request_count,
COALESCE(SUM(l.input_tokens + l.output_tokens), 0) as total_tokens,
COALESCE(SUM(CAST(l.total_cost_usd AS REAL)), 0) as total_cost,
COALESCE(SUM(CASE WHEN l.status_code >= 200 AND l.status_code < 300 THEN 1 ELSE 0 END), 0) as success_count,
COALESCE(SUM(l.latency_ms), 0) as latency_sum
FROM proxy_request_logs l
LEFT JOIN providers p ON l.provider_id = p.id AND l.app_type = p.app_type
{detail_where}
GROUP BY l.provider_id, l.app_type
UNION ALL
SELECT r.provider_id, r.app_type,
{rollup_pname} as provider_name,
COALESCE(SUM(r.request_count), 0),
COALESCE(SUM(r.input_tokens + r.output_tokens), 0),
COALESCE(SUM(CAST(r.total_cost_usd AS REAL)), 0),
COALESCE(SUM(r.success_count), 0),
COALESCE(SUM(r.avg_latency_ms * r.request_count), 0)
FROM usage_daily_rollups r
LEFT JOIN providers p2 ON r.provider_id = p2.id AND r.app_type = p2.app_type
{rollup_where}
GROUP BY r.provider_id, r.app_type
)
GROUP BY provider_id, app_type
ORDER BY total_cost DESC"
);
let mut stmt = conn.prepare(&sql)?;
let mut params: Vec<Box<dyn rusqlite::ToSql>> = detail_params;
params.extend(rollup_params);
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
let row_mapper = |row: &rusqlite::Row| {
let request_count: i64 = row.get(3)?;
let success_count: i64 = row.get(6)?;
let success_rate = if request_count > 0 {
(success_count as f32 / request_count as f32) * 100.0
} else {
0.0
};
Ok(ProviderStats {
provider_id: row.get(0)?,
provider_name: row.get(2)?,
request_count: request_count as u64,
total_tokens: row.get::<_, i64>(4)? as u64,
total_cost: format!("{:.6}", row.get::<_, f64>(5)?),
success_rate,
avg_latency_ms: row.get::<_, f64>(7)? as u64,
})
};
let rows = stmt.query_map(param_refs.as_slice(), row_mapper)?;
let mut stats = Vec::new();
for row in rows {
stats.push(row?);
}
Ok(stats)
}
/// 获取模型统计
pub fn get_model_stats(
&self,
start_date: Option<i64>,
end_date: Option<i64>,
app_type: Option<&str>,
) -> Result<Vec<ModelStats>, AppError> {
let conn = lock_conn!(self.conn);
let mut detail_conditions = Vec::new();
let mut detail_params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
if let Some(start) = start_date {
detail_conditions.push("l.created_at >= ?");
detail_params.push(Box::new(start));
}
if let Some(end) = end_date {
detail_conditions.push("l.created_at <= ?");
detail_params.push(Box::new(end));
}
if let Some(at) = app_type {
detail_conditions.push("l.app_type = ?");
detail_params.push(Box::new(at.to_string()));
}
let detail_where = if detail_conditions.is_empty() {
String::new()
} else {
format!("WHERE {}", detail_conditions.join(" AND "))
};
let mut rollup_conditions = Vec::new();
let mut rollup_params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
let rollup_bounds = compute_rollup_date_bounds(start_date, end_date)?;
push_rollup_date_filters(
&mut rollup_conditions,
&mut rollup_params,
"r.date",
&rollup_bounds,
);
if let Some(at) = app_type {
rollup_conditions.push("r.app_type = ?".to_string());
rollup_params.push(Box::new(at.to_string()));
}
let rollup_where = if rollup_conditions.is_empty() {
String::new()
} else {
format!("WHERE {}", rollup_conditions.join(" AND "))
};
// UNION detail logs + rollup data
let sql = format!(
"SELECT
model,
SUM(request_count) as request_count,
SUM(total_tokens) as total_tokens,
SUM(total_cost) as total_cost
FROM (
SELECT l.model,
COUNT(*) as request_count,
COALESCE(SUM(l.input_tokens + l.output_tokens), 0) as total_tokens,
COALESCE(SUM(CAST(l.total_cost_usd AS REAL)), 0) as total_cost
FROM proxy_request_logs l
{detail_where}
GROUP BY l.model
UNION ALL
SELECT r.model,
COALESCE(SUM(request_count), 0),
COALESCE(SUM(input_tokens + output_tokens), 0),
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0)
FROM usage_daily_rollups r
{rollup_where}
GROUP BY r.model
)
GROUP BY model
ORDER BY total_cost DESC"
);
let mut stmt = conn.prepare(&sql)?;
let mut params: Vec<Box<dyn rusqlite::ToSql>> = detail_params;
params.extend(rollup_params);
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
let row_mapper = |row: &rusqlite::Row| {
let request_count: i64 = row.get(1)?;
let total_cost: f64 = row.get(3)?;
let avg_cost = if request_count > 0 {
total_cost / request_count as f64
} else {
0.0
};
Ok(ModelStats {
model: row.get(0)?,
request_count: request_count as u64,
total_tokens: row.get::<_, i64>(2)? as u64,
total_cost: format!("{total_cost:.6}"),
avg_cost_per_request: format!("{avg_cost:.6}"),
})
};
let rows = stmt.query_map(param_refs.as_slice(), row_mapper)?;
let mut stats = Vec::new();
for row in rows {
stats.push(row?);
}
Ok(stats)
}
/// 获取请求日志列表(分页)
pub fn get_request_logs(
&self,
filters: &LogFilters,
page: u32,
page_size: u32,
) -> Result<PaginatedLogs, AppError> {
let conn = lock_conn!(self.conn);
let mut conditions = Vec::new();
let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
if let Some(ref app_type) = filters.app_type {
conditions.push("l.app_type = ?");
params.push(Box::new(app_type.clone()));
}
if let Some(ref provider_name) = filters.provider_name {
conditions.push("p.name LIKE ?");
params.push(Box::new(format!("%{provider_name}%")));
}
if let Some(ref model) = filters.model {
conditions.push("l.model LIKE ?");
params.push(Box::new(format!("%{model}%")));
}
if let Some(status) = filters.status_code {
conditions.push("l.status_code = ?");
params.push(Box::new(status as i64));
}
if let Some(start) = filters.start_date {
conditions.push("l.created_at >= ?");
params.push(Box::new(start));
}
if let Some(end) = filters.end_date {
conditions.push("l.created_at <= ?");
params.push(Box::new(end));
}
let where_clause = if conditions.is_empty() {
String::new()
} else {
format!("WHERE {}", conditions.join(" AND "))
};
// 获取总数
let count_sql = format!(
"SELECT COUNT(*) FROM proxy_request_logs l
LEFT JOIN providers p ON l.provider_id = p.id AND l.app_type = p.app_type
{where_clause}"
);
let count_params: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
let total: u32 = conn.query_row(&count_sql, count_params.as_slice(), |row| {
row.get::<_, i64>(0).map(|v| v as u32)
})?;
// 获取数据
let offset = page * page_size;
params.push(Box::new(page_size as i64));
params.push(Box::new(offset as i64));
let logs_pname = provider_name_coalesce("l", "p");
let sql = format!(
"SELECT l.request_id, l.provider_id, {logs_pname} as provider_name, l.app_type, l.model,
l.request_model, l.cost_multiplier,
l.input_tokens, l.output_tokens, l.cache_read_tokens, l.cache_creation_tokens,
l.input_cost_usd, l.output_cost_usd, l.cache_read_cost_usd, l.cache_creation_cost_usd, l.total_cost_usd,
l.is_streaming, l.latency_ms, l.first_token_ms, l.duration_ms,
l.status_code, l.error_message, l.created_at, l.data_source
FROM proxy_request_logs l
LEFT JOIN providers p ON l.provider_id = p.id AND l.app_type = p.app_type
{where_clause}
ORDER BY l.created_at DESC
LIMIT ? OFFSET ?"
);
let mut stmt = conn.prepare(&sql)?;
let params_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
let rows = stmt.query_map(params_refs.as_slice(), |row| {
Ok(RequestLogDetail {
request_id: row.get(0)?,
provider_id: row.get(1)?,
provider_name: row.get(2)?,
app_type: row.get(3)?,
model: row.get(4)?,
request_model: row.get(5)?,
cost_multiplier: row
.get::<_, Option<String>>(6)?
.unwrap_or_else(|| "1".to_string()),
input_tokens: row.get::<_, i64>(7)? as u32,
output_tokens: row.get::<_, i64>(8)? as u32,
cache_read_tokens: row.get::<_, i64>(9)? as u32,
cache_creation_tokens: row.get::<_, i64>(10)? as u32,
input_cost_usd: row.get(11)?,
output_cost_usd: row.get(12)?,
cache_read_cost_usd: row.get(13)?,
cache_creation_cost_usd: row.get(14)?,
total_cost_usd: row.get(15)?,
is_streaming: row.get::<_, i64>(16)? != 0,
latency_ms: row.get::<_, i64>(17)? as u64,
first_token_ms: row.get::<_, Option<i64>>(18)?.map(|v| v as u64),
duration_ms: row.get::<_, Option<i64>>(19)?.map(|v| v as u64),
status_code: row.get::<_, i64>(20)? as u16,
error_message: row.get(21)?,
created_at: row.get(22)?,
data_source: row.get(23)?,
})
})?;
let mut logs = Vec::new();
let mut provider_cache = HashMap::new();
let mut pricing_cache = HashMap::new();
for row in rows {
let mut log = row?;
Self::maybe_backfill_log_costs(
&conn,
&mut log,
&mut provider_cache,
&mut pricing_cache,
)?;
logs.push(log);
}
Ok(PaginatedLogs {
data: logs,
total,
page,
page_size,
})
}
/// 获取单个请求详情
pub fn get_request_detail(
&self,
request_id: &str,
) -> Result<Option<RequestLogDetail>, AppError> {
let conn = lock_conn!(self.conn);
let detail_pname = provider_name_coalesce("l", "p");
let detail_sql = format!(
"SELECT l.request_id, l.provider_id, {detail_pname} as provider_name, l.app_type, l.model,
l.request_model, l.cost_multiplier,
input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,
input_cost_usd, output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd, total_cost_usd,
is_streaming, latency_ms, first_token_ms, duration_ms,
status_code, error_message, created_at, l.data_source
FROM proxy_request_logs l
LEFT JOIN providers p ON l.provider_id = p.id AND l.app_type = p.app_type
WHERE l.request_id = ?"
);
let result = conn.query_row(&detail_sql, [request_id], |row| {
Ok(RequestLogDetail {
request_id: row.get(0)?,
provider_id: row.get(1)?,
provider_name: row.get(2)?,
app_type: row.get(3)?,
model: row.get(4)?,
request_model: row.get(5)?,
cost_multiplier: row
.get::<_, Option<String>>(6)?
.unwrap_or_else(|| "1".to_string()),
input_tokens: row.get::<_, i64>(7)? as u32,
output_tokens: row.get::<_, i64>(8)? as u32,
cache_read_tokens: row.get::<_, i64>(9)? as u32,
cache_creation_tokens: row.get::<_, i64>(10)? as u32,
input_cost_usd: row.get(11)?,
output_cost_usd: row.get(12)?,
cache_read_cost_usd: row.get(13)?,
cache_creation_cost_usd: row.get(14)?,
total_cost_usd: row.get(15)?,
is_streaming: row.get::<_, i64>(16)? != 0,
latency_ms: row.get::<_, i64>(17)? as u64,
first_token_ms: row.get::<_, Option<i64>>(18)?.map(|v| v as u64),
duration_ms: row.get::<_, Option<i64>>(19)?.map(|v| v as u64),
status_code: row.get::<_, i64>(20)? as u16,
error_message: row.get(21)?,
created_at: row.get(22)?,
data_source: row.get(23)?,
})
});
match result {
Ok(mut detail) => {
let mut provider_cache = HashMap::new();
let mut pricing_cache = HashMap::new();
Self::maybe_backfill_log_costs(
&conn,
&mut detail,
&mut provider_cache,
&mut pricing_cache,
)?;
Ok(Some(detail))
}
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(AppError::Database(e.to_string())),
}
}
/// 检查 Provider 使用限额
pub fn check_provider_limits(
&self,
provider_id: &str,
app_type: &str,
) -> Result<ProviderLimitStatus, AppError> {
let conn = lock_conn!(self.conn);
// 获取 provider 的限额设置
let (limit_daily, limit_monthly) = conn
.query_row(
"SELECT meta FROM providers WHERE id = ? AND app_type = ?",
params![provider_id, app_type],
|row| {
let meta_str: String = row.get(0)?;
Ok(meta_str)
},
)
.ok()
.and_then(|meta_str| serde_json::from_str::<serde_json::Value>(&meta_str).ok())
.map(|meta| {
let daily = meta
.get("limitDailyUsd")
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<f64>().ok());
let monthly = meta
.get("limitMonthlyUsd")
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<f64>().ok());
(daily, monthly)
})
.unwrap_or((None, None));
// 计算今日使用量 (detail logs + rollup)
let daily_usage: f64 = conn
.query_row(
"SELECT COALESCE(SUM(cost), 0) FROM (
SELECT CAST(total_cost_usd AS REAL) as cost
FROM proxy_request_logs
WHERE provider_id = ? AND app_type = ?
AND date(datetime(created_at, 'unixepoch', 'localtime')) = date('now', 'localtime')
UNION ALL
SELECT CAST(total_cost_usd AS REAL)
FROM usage_daily_rollups
WHERE provider_id = ? AND app_type = ?
AND date = date('now', 'localtime')
)",
params![provider_id, app_type, provider_id, app_type],
|row| row.get(0),
)
.unwrap_or(0.0);
// 计算本月使用量 (detail logs + rollup)
let monthly_usage: f64 = conn
.query_row(
"SELECT COALESCE(SUM(cost), 0) FROM (
SELECT CAST(total_cost_usd AS REAL) as cost
FROM proxy_request_logs
WHERE provider_id = ? AND app_type = ?
AND strftime('%Y-%m', datetime(created_at, 'unixepoch', 'localtime')) = strftime('%Y-%m', 'now', 'localtime')
UNION ALL
SELECT CAST(total_cost_usd AS REAL)
FROM usage_daily_rollups
WHERE provider_id = ? AND app_type = ?
AND strftime('%Y-%m', date) = strftime('%Y-%m', 'now', 'localtime')
)",
params![provider_id, app_type, provider_id, app_type],
|row| row.get(0),
)
.unwrap_or(0.0);
let daily_exceeded = limit_daily
.map(|limit| daily_usage >= limit)
.unwrap_or(false);
let monthly_exceeded = limit_monthly
.map(|limit| monthly_usage >= limit)
.unwrap_or(false);
Ok(ProviderLimitStatus {
provider_id: provider_id.to_string(),
daily_usage: format!("{daily_usage:.6}"),
daily_limit: limit_daily.map(|l| format!("{l:.2}")),
daily_exceeded,
monthly_usage: format!("{monthly_usage:.6}"),
monthly_limit: limit_monthly.map(|l| format!("{l:.2}")),
monthly_exceeded,
})
}
}
/// Provider 限额状态
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProviderLimitStatus {
pub provider_id: String,
pub daily_usage: String,
pub daily_limit: Option<String>,
pub daily_exceeded: bool,
pub monthly_usage: String,
pub monthly_limit: Option<String>,
pub monthly_exceeded: bool,
}
#[derive(Clone)]
struct PricingInfo {
input: rust_decimal::Decimal,
output: rust_decimal::Decimal,
cache_read: rust_decimal::Decimal,
cache_creation: rust_decimal::Decimal,
}
impl Database {
fn maybe_backfill_log_costs(
conn: &Connection,
log: &mut RequestLogDetail,
provider_cache: &mut HashMap<(String, String), rust_decimal::Decimal>,
pricing_cache: &mut HashMap<String, PricingInfo>,
) -> Result<(), AppError> {
let total_cost = rust_decimal::Decimal::from_str(&log.total_cost_usd)
.unwrap_or(rust_decimal::Decimal::ZERO);
let has_cost = total_cost > rust_decimal::Decimal::ZERO;
let has_usage = log.input_tokens > 0
|| log.output_tokens > 0
|| log.cache_read_tokens > 0
|| log.cache_creation_tokens > 0;
if has_cost || !has_usage {
return Ok(());
}
let pricing = match Self::get_model_pricing_cached(conn, pricing_cache, &log.model)? {
Some(info) => info,
None => return Ok(()),
};
let multiplier = Self::get_cost_multiplier_cached(
conn,
provider_cache,
&log.provider_id,
&log.app_type,
)?;
let million = rust_decimal::Decimal::from(1_000_000u64);
// 与 CostCalculator::calculate 保持一致的计算逻辑:
// 1. input_cost 需要扣除 cache_read_tokens(避免缓存部分被重复计费)
// 2. 各项成本是基础成本(不含倍率)
// 3. 倍率只作用于最终总价
let billable_input_tokens =
(log.input_tokens as u64).saturating_sub(log.cache_read_tokens as u64);
let input_cost =
rust_decimal::Decimal::from(billable_input_tokens) * pricing.input / million;
let output_cost =
rust_decimal::Decimal::from(log.output_tokens as u64) * pricing.output / million;
let cache_read_cost = rust_decimal::Decimal::from(log.cache_read_tokens as u64)
* pricing.cache_read
/ million;
let cache_creation_cost = rust_decimal::Decimal::from(log.cache_creation_tokens as u64)
* pricing.cache_creation
/ million;
// 总成本 = 基础成本之和 × 倍率
let base_total = input_cost + output_cost + cache_read_cost + cache_creation_cost;
let total_cost = base_total * multiplier;
log.input_cost_usd = format!("{input_cost:.6}");
log.output_cost_usd = format!("{output_cost:.6}");
log.cache_read_cost_usd = format!("{cache_read_cost:.6}");
log.cache_creation_cost_usd = format!("{cache_creation_cost:.6}");
log.total_cost_usd = format!("{total_cost:.6}");
conn.execute(
"UPDATE proxy_request_logs
SET input_cost_usd = ?1,
output_cost_usd = ?2,
cache_read_cost_usd = ?3,
cache_creation_cost_usd = ?4,
total_cost_usd = ?5
WHERE request_id = ?6",
params![
log.input_cost_usd,
log.output_cost_usd,
log.cache_read_cost_usd,
log.cache_creation_cost_usd,
log.total_cost_usd,
log.request_id
],
)
.map_err(|e| AppError::Database(format!("更新请求成本失败: {e}")))?;
Ok(())
}
fn get_cost_multiplier_cached(
conn: &Connection,
cache: &mut HashMap<(String, String), rust_decimal::Decimal>,
provider_id: &str,
app_type: &str,
) -> Result<rust_decimal::Decimal, AppError> {
let key = (provider_id.to_string(), app_type.to_string());
if let Some(multiplier) = cache.get(&key) {
return Ok(*multiplier);
}
let meta_json: Option<String> = conn
.query_row(
"SELECT meta FROM providers WHERE id = ? AND app_type = ?",
params![provider_id, app_type],
|row| row.get(0),
)
.optional()
.map_err(|e| AppError::Database(format!("查询 provider meta 失败: {e}")))?;
let multiplier = meta_json
.and_then(|meta| serde_json::from_str::<Value>(&meta).ok())
.and_then(|value| value.get("costMultiplier").cloned())
.and_then(|val| {
val.as_str()
.and_then(|s| rust_decimal::Decimal::from_str(s).ok())
})
.unwrap_or(rust_decimal::Decimal::ONE);
cache.insert(key, multiplier);
Ok(multiplier)
}
fn get_model_pricing_cached(
conn: &Connection,
cache: &mut HashMap<String, PricingInfo>,
model: &str,
) -> Result<Option<PricingInfo>, AppError> {
if let Some(info) = cache.get(model) {
return Ok(Some(info.clone()));
}
let row = find_model_pricing_row(conn, model)?;
let Some((input, output, cache_read, cache_creation)) = row else {
return Ok(None);
};
let pricing = PricingInfo {
input: rust_decimal::Decimal::from_str(&input)
.map_err(|e| AppError::Database(format!("解析输入价格失败: {e}")))?,
output: rust_decimal::Decimal::from_str(&output)
.map_err(|e| AppError::Database(format!("解析输出价格失败: {e}")))?,
cache_read: rust_decimal::Decimal::from_str(&cache_read)
.map_err(|e| AppError::Database(format!("解析缓存读取价格失败: {e}")))?,
cache_creation: rust_decimal::Decimal::from_str(&cache_creation)
.map_err(|e| AppError::Database(format!("解析缓存写入价格失败: {e}")))?,
};
cache.insert(model.to_string(), pricing.clone());
Ok(Some(pricing))
}
}
pub(crate) fn find_model_pricing_row(
conn: &Connection,
model_id: &str,
) -> Result<Option<(String, String, String, String)>, AppError> {
// 清洗模型名称:去前缀(/)、去后缀(:)、@ 替换为 -
// 例如 moonshotai/gpt-5.2-codex@low:v2 → gpt-5.2-codex-low
let cleaned = model_id
.rsplit_once('/')
.map_or(model_id, |(_, r)| r)
.split(':')
.next()
.unwrap_or(model_id)
.trim()
.replace('@', "-");
// 精确匹配清洗后的名称
let exact = conn
.query_row(
"SELECT input_cost_per_million, output_cost_per_million,
cache_read_cost_per_million, cache_creation_cost_per_million
FROM model_pricing
WHERE model_id = ?1",
[&cleaned],
|row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
))
},
)
.optional()
.map_err(|e| AppError::Database(format!("查询模型定价失败: {e}")))?;
if exact.is_none() {
log::warn!("模型 {model_id}(清洗后: {cleaned})未找到定价信息,成本将记录为 0");
}
Ok(exact)
}
#[cfg(test)]
mod tests {
use super::*;
fn local_ts(year: i32, month: u32, day: u32, hour: u32, minute: u32, second: u32) -> i64 {
match Local.with_ymd_and_hms(year, month, day, hour, minute, second) {
chrono::LocalResult::Single(dt) => dt.timestamp(),
chrono::LocalResult::Ambiguous(earliest, _) => earliest.timestamp(),
chrono::LocalResult::None => panic!("valid local datetime"),
}
}
#[test]
fn test_get_usage_summary() -> Result<(), AppError> {
let db = Database::memory()?;
// 插入测试数据
{
let conn = lock_conn!(db.conn);
conn.execute(
"INSERT INTO proxy_request_logs (
request_id, provider_id, app_type, model,
input_tokens, output_tokens, total_cost_usd,
latency_ms, status_code, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
params!["req1", "p1", "claude", "claude-3", 100, 50, "0.01", 100, 200, 1000],
)?;
conn.execute(
"INSERT INTO proxy_request_logs (
request_id, provider_id, app_type, model,
input_tokens, output_tokens, total_cost_usd,
latency_ms, status_code, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
params!["req2", "p1", "claude", "claude-3", 200, 100, "0.02", 150, 200, 2000],
)?;
}
let summary = db.get_usage_summary(None, None, None)?;
assert_eq!(summary.total_requests, 2);
assert_eq!(summary.success_rate, 100.0);
Ok(())
}
#[test]
fn test_get_usage_summary_excludes_partial_rollup_boundary_days() -> Result<(), AppError> {
let db = Database::memory()?;
let start = local_ts(2024, 1, 1, 12, 0, 0);
let end = local_ts(2024, 1, 3, 12, 0, 0);
{
let conn = lock_conn!(db.conn);
conn.execute(
"INSERT INTO usage_daily_rollups (
date, app_type, provider_id, model,
request_count, success_count, input_tokens, output_tokens,
cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
params![
"2024-01-01",
"claude",
"p1",
"claude-3",
10,
10,
1000,
500,
0,
0,
"1.00",
100
],
)?;
conn.execute(
"INSERT INTO usage_daily_rollups (
date, app_type, provider_id, model,
request_count, success_count, input_tokens, output_tokens,
cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
params![
"2024-01-02",
"claude",
"p1",
"claude-3",
20,
19,
2000,
1000,
0,
0,
"2.00",
120
],
)?;
conn.execute(
"INSERT INTO usage_daily_rollups (
date, app_type, provider_id, model,
request_count, success_count, input_tokens, output_tokens,
cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
params![
"2024-01-03",
"claude",
"p1",
"claude-3",
30,
29,
3000,
1500,
0,
0,
"3.00",
140
],
)?;
}
let summary = db.get_usage_summary(Some(start), Some(end), Some("claude"))?;
assert_eq!(summary.total_requests, 20);
assert_eq!(summary.total_input_tokens, 2000);
assert_eq!(summary.total_output_tokens, 1000);
Ok(())
}
#[test]
fn test_get_usage_summary_includes_end_day_rollup_for_minute_precision_end_time(
) -> Result<(), AppError> {
let db = Database::memory()?;
let start = local_ts(2024, 1, 1, 0, 0, 0);
let end = local_ts(2024, 1, 2, 23, 59, 0);
{
let conn = lock_conn!(db.conn);
conn.execute(
"INSERT INTO usage_daily_rollups (
date, app_type, provider_id, model,
request_count, success_count, input_tokens, output_tokens,
cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
params![
"2024-01-01",
"claude",
"p1",
"claude-3",
10,
10,
1000,
500,
0,
0,
"1.00",
100
],
)?;
conn.execute(
"INSERT INTO usage_daily_rollups (
date, app_type, provider_id, model,
request_count, success_count, input_tokens, output_tokens,
cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
params![
"2024-01-02",
"claude",
"p1",
"claude-3",
20,
19,
2000,
1000,
0,
0,
"2.00",
120
],
)?;
}
let summary = db.get_usage_summary(Some(start), Some(end), Some("claude"))?;
assert_eq!(summary.total_requests, 30);
assert_eq!(summary.total_input_tokens, 3000);
assert_eq!(summary.total_output_tokens, 1500);
Ok(())
}
#[test]
fn test_get_model_stats() -> Result<(), AppError> {
let db = Database::memory()?;
// 插入测试数据
{
let conn = lock_conn!(db.conn);
conn.execute(
"INSERT INTO proxy_request_logs (
request_id, provider_id, app_type, model,
input_tokens, output_tokens, total_cost_usd,
latency_ms, status_code, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
params![
"req1",
"p1",
"claude",
"claude-3-sonnet",
100,
50,
"0.01",
100,
200,
1000
],
)?;
}
let stats = db.get_model_stats(None, None, None)?;
assert_eq!(stats.len(), 1);
assert_eq!(stats[0].model, "claude-3-sonnet");
assert_eq!(stats[0].request_count, 1);
Ok(())
}
#[test]
fn test_get_provider_stats_with_time_filter() -> Result<(), AppError> {
let db = Database::memory()?;
{
let conn = lock_conn!(db.conn);
conn.execute(
"INSERT INTO proxy_request_logs (
request_id, provider_id, app_type, model,
input_tokens, output_tokens, total_cost_usd,
latency_ms, status_code, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
params!["old", "p1", "claude", "claude-3", 100, 50, "0.01", 100, 200, 1000],
)?;
conn.execute(
"INSERT INTO proxy_request_logs (
request_id, provider_id, app_type, model,
input_tokens, output_tokens, total_cost_usd,
latency_ms, status_code, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
params!["new", "p1", "claude", "claude-3", 200, 75, "0.02", 120, 200, 2000],
)?;
}
let stats = db.get_provider_stats(Some(1500), Some(2500), Some("claude"))?;
assert_eq!(stats.len(), 1);
assert_eq!(stats[0].provider_id, "p1");
assert_eq!(stats[0].request_count, 1);
assert_eq!(stats[0].total_tokens, 275);
Ok(())
}
#[test]
fn test_get_provider_stats_excludes_partial_rollup_boundary_days() -> Result<(), AppError> {
let db = Database::memory()?;
let start = local_ts(2024, 2, 1, 12, 0, 0);
let end = local_ts(2024, 2, 3, 12, 0, 0);
{
let conn = lock_conn!(db.conn);
conn.execute(
"INSERT INTO usage_daily_rollups (
date, app_type, provider_id, model,
request_count, success_count, input_tokens, output_tokens,
cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
params![
"2024-02-01",
"claude",
"p-rollup",
"claude-3",
5,
5,
500,
250,
0,
0,
"0.50",
100
],
)?;
conn.execute(
"INSERT INTO usage_daily_rollups (
date, app_type, provider_id, model,
request_count, success_count, input_tokens, output_tokens,
cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
params![
"2024-02-02",
"claude",
"p-rollup",
"claude-3",
8,
7,
800,
400,
0,
0,
"0.80",
120
],
)?;
conn.execute(
"INSERT INTO usage_daily_rollups (
date, app_type, provider_id, model,
request_count, success_count, input_tokens, output_tokens,
cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
params![
"2024-02-03",
"claude",
"p-rollup",
"claude-3",
12,
11,
1200,
600,
0,
0,
"1.20",
140
],
)?;
}
let stats = db.get_provider_stats(Some(start), Some(end), Some("claude"))?;
assert_eq!(stats.len(), 1);
assert_eq!(stats[0].provider_id, "p-rollup");
assert_eq!(stats[0].request_count, 8);
assert_eq!(stats[0].total_tokens, 1200);
Ok(())
}
#[test]
fn test_get_daily_trends_respects_shorter_than_24_hours() -> Result<(), AppError> {
let db = Database::memory()?;
{
let conn = lock_conn!(db.conn);
conn.execute(
"INSERT INTO proxy_request_logs (
request_id, provider_id, app_type, model,
input_tokens, output_tokens, total_cost_usd,
latency_ms, status_code, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
params![
"req-short",
"p1",
"claude",
"claude-3",
100,
50,
"0.01",
100,
200,
10_800
],
)?;
}
let stats = db.get_daily_trends(Some(0), Some(15 * 60 * 60), Some("claude"))?;
assert_eq!(stats.len(), 15);
assert_eq!(stats[3].request_count, 1);
Ok(())
}
#[test]
fn test_get_daily_trends_groups_ranges_longer_than_24_hours_by_local_day(
) -> Result<(), AppError> {
let db = Database::memory()?;
let start = local_ts(2024, 3, 1, 12, 0, 0);
let end = local_ts(2024, 3, 3, 12, 0, 0);
{
let conn = lock_conn!(db.conn);
conn.execute(
"INSERT INTO proxy_request_logs (
request_id, provider_id, app_type, model,
input_tokens, output_tokens, total_cost_usd,
latency_ms, status_code, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
params![
"day-1-detail",
"p1",
"claude",
"claude-3",
100,
50,
"0.01",
100,
200,
local_ts(2024, 3, 1, 13, 0, 0)
],
)?;
conn.execute(
"INSERT INTO proxy_request_logs (
request_id, provider_id, app_type, model,
input_tokens, output_tokens, total_cost_usd,
latency_ms, status_code, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
params![
"day-3-detail",
"p1",
"claude",
"claude-3",
200,
75,
"0.02",
110,
200,
local_ts(2024, 3, 3, 10, 0, 0)
],
)?;
conn.execute(
"INSERT INTO usage_daily_rollups (
date, app_type, provider_id, model,
request_count, success_count, input_tokens, output_tokens,
cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
params![
"2024-03-02",
"claude",
"p1",
"claude-3",
4,
4,
400,
200,
0,
0,
"0.40",
120
],
)?;
}
let stats = db.get_daily_trends(Some(start), Some(end), Some("claude"))?;
assert_eq!(stats.len(), 3);
assert_eq!(stats[0].request_count, 1);
assert_eq!(stats[0].total_tokens, 150);
assert_eq!(stats[1].request_count, 4);
assert_eq!(stats[1].total_tokens, 600);
assert_eq!(stats[2].request_count, 1);
assert_eq!(stats[2].total_tokens, 275);
Ok(())
}
#[test]
fn test_get_model_stats_excludes_partial_rollup_boundary_days() -> Result<(), AppError> {
let db = Database::memory()?;
let start = local_ts(2024, 4, 1, 12, 0, 0);
let end = local_ts(2024, 4, 3, 12, 0, 0);
{
let conn = lock_conn!(db.conn);
conn.execute(
"INSERT INTO usage_daily_rollups (
date, app_type, provider_id, model,
request_count, success_count, input_tokens, output_tokens,
cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
params![
"2024-04-01",
"claude",
"p1",
"claude-3-haiku",
6,
6,
600,
300,
0,
0,
"0.60",
100
],
)?;
conn.execute(
"INSERT INTO usage_daily_rollups (
date, app_type, provider_id, model,
request_count, success_count, input_tokens, output_tokens,
cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
params![
"2024-04-02",
"claude",
"p1",
"claude-3-haiku",
9,
8,
900,
450,
0,
0,
"0.90",
110
],
)?;
conn.execute(
"INSERT INTO usage_daily_rollups (
date, app_type, provider_id, model,
request_count, success_count, input_tokens, output_tokens,
cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
params![
"2024-04-03",
"claude",
"p1",
"claude-3-haiku",
12,
11,
1200,
600,
0,
0,
"1.20",
130
],
)?;
}
let stats = db.get_model_stats(Some(start), Some(end), Some("claude"))?;
assert_eq!(stats.len(), 1);
assert_eq!(stats[0].model, "claude-3-haiku");
assert_eq!(stats[0].request_count, 9);
assert_eq!(stats[0].total_tokens, 1350);
Ok(())
}
#[test]
fn test_model_pricing_matching() -> Result<(), AppError> {
let db = Database::memory()?;
let conn = lock_conn!(db.conn);
// 准备额外定价数据,覆盖前缀/后缀清洗场景
conn.execute(
"INSERT OR REPLACE INTO model_pricing (
model_id, display_name, input_cost_per_million, output_cost_per_million,
cache_read_cost_per_million, cache_creation_cost_per_million
) VALUES (?, ?, ?, ?, ?, ?)",
params![
"claude-haiku-4.5",
"Claude Haiku 4.5",
"1.0",
"2.0",
"0.0",
"0.0"
],
)?;
// 测试精确匹配(seed_model_pricing 已预置 claude-sonnet-4-5-20250929
let result = find_model_pricing_row(&conn, "claude-sonnet-4-5-20250929")?;
assert!(
result.is_some(),
"应该能精确匹配 claude-sonnet-4-5-20250929"
);
// 清洗:去除前缀和冒号后缀
let result = find_model_pricing_row(&conn, "anthropic/claude-haiku-4.5")?;
assert!(
result.is_some(),
"带前缀的模型 anthropic/claude-haiku-4.5 应能匹配到 claude-haiku-4.5"
);
let result = find_model_pricing_row(&conn, "moonshotai/kimi-k2-0905:exa")?;
assert!(
result.is_some(),
"带前缀+冒号后缀的模型应清洗后匹配到 kimi-k2-0905"
);
// 清洗:@ 替换为 -seed_model_pricing 已预置 gpt-5.2-codex-low
let result = find_model_pricing_row(&conn, "gpt-5.2-codex@low")?;
assert!(
result.is_some(),
"带 @ 分隔符的模型 gpt-5.2-codex@low 应能匹配到 gpt-5.2-codex-low"
);
// 测试不存在的模型
let result = find_model_pricing_row(&conn, "unknown-model-123")?;
assert!(result.is_none(), "不应该匹配不存在的模型");
Ok(())
}
}