Add multi-database data layer

Introduce aether-data-schema and driver-specific schema generation for Postgres, MySQL, and SQLite.

Split data backends, lifecycle, repositories, and gateway runtime integration across database drivers.

Verified with cargo fmt --all --check, cargo clippy --workspace --all-targets -- -D warnings, and cargo test --workspace.
This commit is contained in:
fawney19
2026-05-05 18:27:36 +08:00
parent 099653f732
commit fce7e959e5
372 changed files with 86217 additions and 21160 deletions

View File

@@ -71,7 +71,7 @@ use self::support_test_connection::maybe_build_local_test_connection_response;
use self::support_user_me::maybe_build_local_users_me_response;
use self::support_wallet::{
maybe_build_local_wallet_response, sanitize_wallet_gateway_response,
wallet_normalize_optional_string_field, wallet_payment_order_payload_from_row,
wallet_normalize_optional_string_field,
};
pub(crate) fn build_unhandled_public_support_response(

View File

@@ -18,18 +18,6 @@ use chrono::Datelike;
use serde_json::json;
use std::collections::{BTreeMap, BTreeSet};
use crate::query::dashboard_stats::{
list_admin_dashboard_daily_model_aggregates, list_admin_dashboard_daily_provider_aggregates,
list_admin_dashboard_daily_totals_aggregates, list_admin_dashboard_hourly_model_aggregates,
list_admin_dashboard_hourly_provider_aggregates, list_admin_dashboard_hourly_totals_aggregates,
list_user_dashboard_daily_model_aggregates, list_user_dashboard_daily_totals_aggregates,
list_user_dashboard_hourly_model_aggregates, list_user_dashboard_hourly_totals_aggregates,
read_stats_hourly_cutoff, summarize_dashboard_usage_from_daily_aggregates,
DashboardDailyModelAggregateRow, DashboardDailyProviderAggregateRow,
DashboardDailyTotalsAggregateRow,
};
use crate::query::usage_heatmap::read_stats_daily_cutoff_date;
#[derive(Debug, Clone, Copy)]
struct DashboardDateRange {
start_date: chrono::NaiveDate,
@@ -438,93 +426,6 @@ fn dashboard_range_bounds_unix(range: DashboardDateRange) -> Option<(u64, u64)>
Some((start_utc.max(0) as u64, end_utc.max(0) as u64))
}
fn dashboard_range_bounds_utc(
range: DashboardDateRange,
) -> Option<(chrono::DateTime<chrono::Utc>, chrono::DateTime<chrono::Utc>)> {
let (created_from_unix_secs, created_until_unix_secs) = dashboard_range_bounds_unix(range)?;
let start_utc =
chrono::DateTime::<chrono::Utc>::from_timestamp(created_from_unix_secs as i64, 0)?;
let end_utc =
chrono::DateTime::<chrono::Utc>::from_timestamp(created_until_unix_secs as i64, 0)?;
Some((start_utc, end_utc))
}
fn dashboard_local_day_bounds_utc_exclusive(
range: DashboardDateRange,
) -> Option<(chrono::DateTime<chrono::Utc>, chrono::DateTime<chrono::Utc>)> {
let offset = chrono::Duration::minutes(i64::from(range.tz_offset_minutes));
let start_local = range.start_date.and_hms_opt(0, 0, 0)?;
let end_exclusive_local = range
.end_date
.checked_add_signed(chrono::Duration::days(1))?
.and_hms_opt(0, 0, 0)?;
let start_utc = chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(
start_local.checked_sub_signed(offset)?,
chrono::Utc,
);
let end_exclusive_utc = chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(
end_exclusive_local.checked_sub_signed(offset)?,
chrono::Utc,
);
Some((start_utc, end_exclusive_utc))
}
fn dashboard_utc_midnight(value: chrono::DateTime<chrono::Utc>) -> chrono::DateTime<chrono::Utc> {
chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(
value
.date_naive()
.and_hms_opt(0, 0, 0)
.expect("midnight should be valid"),
chrono::Utc,
)
}
fn dashboard_next_utc_midnight(
value: chrono::DateTime<chrono::Utc>,
) -> chrono::DateTime<chrono::Utc> {
let midnight = dashboard_utc_midnight(value);
if value == midnight {
midnight
} else {
midnight + chrono::Duration::days(1)
}
}
fn dashboard_unix_secs(value: chrono::DateTime<chrono::Utc>) -> u64 {
value.timestamp().max(0) as u64
}
fn dashboard_absorb_dashboard_summary(
target: &mut StoredUsageDashboardSummary,
part: &StoredUsageDashboardSummary,
) {
target.total_requests = target.total_requests.saturating_add(part.total_requests);
target.input_tokens = target.input_tokens.saturating_add(part.input_tokens);
target.effective_input_tokens = target
.effective_input_tokens
.saturating_add(part.effective_input_tokens);
target.output_tokens = target.output_tokens.saturating_add(part.output_tokens);
target.total_tokens = target.total_tokens.saturating_add(part.total_tokens);
target.cache_creation_tokens = target
.cache_creation_tokens
.saturating_add(part.cache_creation_tokens);
target.cache_read_tokens = target
.cache_read_tokens
.saturating_add(part.cache_read_tokens);
target.total_input_context = target
.total_input_context
.saturating_add(part.total_input_context);
target.cache_creation_cost_usd += part.cache_creation_cost_usd;
target.cache_read_cost_usd += part.cache_read_cost_usd;
target.total_cost_usd += part.total_cost_usd;
target.actual_total_cost_usd += part.actual_total_cost_usd;
target.error_requests = target.error_requests.saturating_add(part.error_requests);
target.response_time_sum_ms += part.response_time_sum_ms;
target.response_time_samples = target
.response_time_samples
.saturating_add(part.response_time_samples);
}
async fn dashboard_summary_for_unix_range_raw(
state: &AppState,
created_from_unix_secs: u64,
@@ -607,87 +508,7 @@ async fn dashboard_summary_for_range(
user_id: Option<&str>,
error_context: &str,
) -> Result<StoredUsageDashboardSummary, Response<Body>> {
let Some(pool) = state.postgres_pool() else {
return dashboard_summary_for_range_raw(state, range, user_id, error_context).await;
};
let cutoff_date = match read_stats_daily_cutoff_date(&pool).await {
Ok(value) => value,
Err(err) => {
return Err(build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("{error_context}: {err:?}"),
false,
))
}
};
let Some(cutoff_date) = cutoff_date else {
return dashboard_summary_for_range_raw(state, range, user_id, error_context).await;
};
let Some((start_utc, end_utc)) = dashboard_range_bounds_utc(range) else {
return Err(build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("{error_context}: invalid time range"),
false,
));
};
let aggregate_start = dashboard_next_utc_midnight(start_utc);
let aggregate_end = dashboard_utc_midnight(end_utc).min(cutoff_date);
let mut summary = StoredUsageDashboardSummary::default();
if aggregate_start < aggregate_end {
let leading_end = aggregate_start.min(end_utc);
if start_utc < leading_end {
let raw = dashboard_summary_for_unix_range_raw(
state,
dashboard_unix_secs(start_utc),
dashboard_unix_secs(leading_end),
user_id,
error_context,
)
.await?;
dashboard_absorb_dashboard_summary(&mut summary, &raw);
}
let aggregate = summarize_dashboard_usage_from_daily_aggregates(
&pool,
aggregate_start,
aggregate_end,
user_id,
)
.await
.map_err(|err| {
build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("{error_context}: {err:?}"),
false,
)
})?;
dashboard_absorb_dashboard_summary(&mut summary, &aggregate);
if aggregate_end < end_utc {
let raw = dashboard_summary_for_unix_range_raw(
state,
dashboard_unix_secs(aggregate_end),
dashboard_unix_secs(end_utc),
user_id,
error_context,
)
.await?;
dashboard_absorb_dashboard_summary(&mut summary, &raw);
}
} else if start_utc < end_utc {
let raw = dashboard_summary_for_unix_range_raw(
state,
dashboard_unix_secs(start_utc),
dashboard_unix_secs(end_utc),
user_id,
error_context,
)
.await?;
dashboard_absorb_dashboard_summary(&mut summary, &raw);
}
Ok(summary)
dashboard_summary_for_range_raw(state, range, user_id, error_context).await
}
async fn dashboard_daily_breakdown_for_range(
@@ -753,75 +574,6 @@ fn dashboard_apply_daily_breakdown_rows(
}
}
fn dashboard_record_daily_totals_aggregate(
by_date: &mut std::collections::BTreeMap<chrono::NaiveDate, DashboardDailyAggregate>,
row: &DashboardDailyTotalsAggregateRow,
) {
let Ok(date) = chrono::NaiveDate::parse_from_str(&row.date, "%Y-%m-%d") else {
return;
};
let aggregate = by_date.entry(date).or_default();
aggregate.totals.requests = aggregate.totals.requests.saturating_add(row.requests);
aggregate.totals.total_tokens = aggregate
.totals
.total_tokens
.saturating_add(row.total_tokens);
aggregate.totals.total_cost_usd += row.total_cost_usd;
aggregate.totals.response_time_sum_ms += row.response_time_sum_ms;
aggregate.totals.response_time_samples = aggregate
.totals
.response_time_samples
.saturating_add(row.response_time_samples);
}
fn dashboard_record_daily_model_aggregate(
by_date: &mut std::collections::BTreeMap<chrono::NaiveDate, DashboardDailyAggregate>,
model_summary: &mut std::collections::BTreeMap<String, DashboardModelAggregate>,
row: &DashboardDailyModelAggregateRow,
) {
let Ok(date) = chrono::NaiveDate::parse_from_str(&row.date, "%Y-%m-%d") else {
return;
};
let aggregate = by_date.entry(date).or_default();
let model = aggregate.models.entry(row.model.clone()).or_default();
model.requests = model.requests.saturating_add(row.requests);
model.tokens = model.tokens.saturating_add(row.total_tokens);
model.cost += row.total_cost_usd;
model.response_time_sum_ms += row.response_time_sum_ms;
model.response_time_samples = model
.response_time_samples
.saturating_add(row.response_time_samples);
let summary = model_summary.entry(row.model.clone()).or_default();
summary.requests = summary.requests.saturating_add(row.requests);
summary.tokens = summary.tokens.saturating_add(row.total_tokens);
summary.cost += row.total_cost_usd;
summary.response_time_sum_ms += row.response_time_sum_ms;
summary.response_time_samples = summary
.response_time_samples
.saturating_add(row.response_time_samples);
}
fn dashboard_record_daily_provider_aggregate(
by_date: &mut std::collections::BTreeMap<chrono::NaiveDate, DashboardDailyAggregate>,
provider_summary: &mut std::collections::BTreeMap<String, DashboardProviderAggregate>,
row: &DashboardDailyProviderAggregateRow,
) {
let Ok(date) = chrono::NaiveDate::parse_from_str(&row.date, "%Y-%m-%d") else {
return;
};
let aggregate = by_date.entry(date).or_default();
let provider = aggregate.providers.entry(row.provider.clone()).or_default();
provider.requests = provider.requests.saturating_add(row.requests);
provider.tokens = provider.tokens.saturating_add(row.total_tokens);
provider.cost += row.total_cost_usd;
let summary = provider_summary.entry(row.provider.clone()).or_default();
summary.requests = summary.requests.saturating_add(row.requests);
summary.tokens = summary.tokens.saturating_add(row.total_tokens);
summary.cost += row.total_cost_usd;
}
fn dashboard_build_daily_stats_payload(
range: DashboardDateRange,
is_admin: bool,
@@ -972,477 +724,6 @@ fn dashboard_build_daily_stats_payload(
payload
}
fn dashboard_range_supports_hourly_rollup(range: DashboardDateRange) -> bool {
range.tz_offset_minutes % 60 == 0
}
async fn dashboard_admin_hourly_stats_aggregate_payload(
state: &AppState,
range: DashboardDateRange,
error_context: &str,
) -> Result<Option<serde_json::Value>, Response<Body>> {
if !dashboard_range_supports_hourly_rollup(range) {
return Ok(None);
}
let Some(pool) = state.postgres_pool() else {
return Ok(None);
};
let cutoff_utc = match read_stats_hourly_cutoff(&pool).await {
Ok(value) => value,
Err(err) => {
return Err(build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("{error_context}: {err:?}"),
false,
))
}
};
let Some(cutoff_utc) = cutoff_utc else {
return Ok(None);
};
let Some((range_start_utc, range_end_exclusive_utc)) =
dashboard_local_day_bounds_utc_exclusive(range)
else {
return Ok(None);
};
let aggregate_end_utc = range_end_exclusive_utc.min(cutoff_utc);
if range_start_utc >= aggregate_end_utc {
return Ok(None);
}
let daily_totals = list_admin_dashboard_hourly_totals_aggregates(
&pool,
range_start_utc,
aggregate_end_utc,
range.tz_offset_minutes,
)
.await
.map_err(|err| {
build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("{error_context}: {err:?}"),
false,
)
})?;
let daily_models = list_admin_dashboard_hourly_model_aggregates(
&pool,
range_start_utc,
aggregate_end_utc,
range.tz_offset_minutes,
)
.await
.map_err(|err| {
build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("{error_context}: {err:?}"),
false,
)
})?;
let daily_providers = list_admin_dashboard_hourly_provider_aggregates(
&pool,
range_start_utc,
aggregate_end_utc,
range.tz_offset_minutes,
)
.await
.map_err(|err| {
build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("{error_context}: {err:?}"),
false,
)
})?;
let mut by_date =
std::collections::BTreeMap::<chrono::NaiveDate, DashboardDailyAggregate>::new();
let mut model_summary = std::collections::BTreeMap::<String, DashboardModelAggregate>::new();
let mut provider_summary =
std::collections::BTreeMap::<String, DashboardProviderAggregate>::new();
for row in &daily_totals {
dashboard_record_daily_totals_aggregate(&mut by_date, row);
}
for row in &daily_models {
dashboard_record_daily_model_aggregate(&mut by_date, &mut model_summary, row);
}
for row in &daily_providers {
dashboard_record_daily_provider_aggregate(&mut by_date, &mut provider_summary, row);
}
if aggregate_end_utc < range_end_exclusive_utc {
let raw_rows = dashboard_daily_breakdown_for_unix_range_raw(
state,
dashboard_unix_secs(aggregate_end_utc),
dashboard_unix_secs(range_end_exclusive_utc),
range.tz_offset_minutes,
None,
error_context,
)
.await?;
dashboard_apply_daily_breakdown_rows(
&raw_rows,
&mut by_date,
&mut model_summary,
&mut provider_summary,
);
}
Ok(Some(dashboard_build_daily_stats_payload(
range,
true,
&by_date,
&model_summary,
&provider_summary,
)))
}
async fn dashboard_user_hourly_stats_aggregate_payload(
state: &AppState,
range: DashboardDateRange,
user_id: &str,
error_context: &str,
) -> Result<Option<serde_json::Value>, Response<Body>> {
if !dashboard_range_supports_hourly_rollup(range) {
return Ok(None);
}
let Some(pool) = state.postgres_pool() else {
return Ok(None);
};
let cutoff_utc = match read_stats_hourly_cutoff(&pool).await {
Ok(value) => value,
Err(err) => {
return Err(build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("{error_context}: {err:?}"),
false,
))
}
};
let Some(cutoff_utc) = cutoff_utc else {
return Ok(None);
};
let Some((range_start_utc, range_end_exclusive_utc)) =
dashboard_local_day_bounds_utc_exclusive(range)
else {
return Ok(None);
};
let aggregate_end_utc = range_end_exclusive_utc.min(cutoff_utc);
if range_start_utc >= aggregate_end_utc {
return Ok(None);
}
let daily_totals = list_user_dashboard_hourly_totals_aggregates(
&pool,
range_start_utc,
aggregate_end_utc,
range.tz_offset_minutes,
user_id,
)
.await
.map_err(|err| {
build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("{error_context}: {err:?}"),
false,
)
})?;
let daily_models = list_user_dashboard_hourly_model_aggregates(
&pool,
range_start_utc,
aggregate_end_utc,
range.tz_offset_minutes,
user_id,
)
.await
.map_err(|err| {
build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("{error_context}: {err:?}"),
false,
)
})?;
let mut by_date =
std::collections::BTreeMap::<chrono::NaiveDate, DashboardDailyAggregate>::new();
let mut model_summary = std::collections::BTreeMap::<String, DashboardModelAggregate>::new();
let mut provider_summary =
std::collections::BTreeMap::<String, DashboardProviderAggregate>::new();
for row in &daily_totals {
dashboard_record_daily_totals_aggregate(&mut by_date, row);
}
for row in &daily_models {
dashboard_record_daily_model_aggregate(&mut by_date, &mut model_summary, row);
}
if aggregate_end_utc < range_end_exclusive_utc {
let raw_rows = dashboard_daily_breakdown_for_unix_range_raw(
state,
dashboard_unix_secs(aggregate_end_utc),
dashboard_unix_secs(range_end_exclusive_utc),
range.tz_offset_minutes,
Some(user_id),
error_context,
)
.await?;
dashboard_apply_daily_breakdown_rows(
&raw_rows,
&mut by_date,
&mut model_summary,
&mut provider_summary,
);
}
Ok(Some(dashboard_build_daily_stats_payload(
range,
false,
&by_date,
&model_summary,
&provider_summary,
)))
}
async fn dashboard_admin_daily_stats_aggregate_payload(
state: &AppState,
range: DashboardDateRange,
error_context: &str,
) -> Result<Option<serde_json::Value>, Response<Body>> {
if range.tz_offset_minutes != 0 {
return dashboard_admin_hourly_stats_aggregate_payload(state, range, error_context).await;
}
let Some(pool) = state.postgres_pool() else {
return Ok(None);
};
let cutoff_date = match read_stats_daily_cutoff_date(&pool).await {
Ok(value) => value,
Err(err) => {
return Err(build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("{error_context}: {err:?}"),
false,
))
}
};
let Some(cutoff_date) = cutoff_date else {
return Ok(None);
};
let Some(range_end_exclusive) = range.end_date.checked_add_signed(chrono::Duration::days(1))
else {
return Ok(None);
};
let aggregate_end_exclusive = range_end_exclusive.min(cutoff_date.date_naive());
if range.start_date >= aggregate_end_exclusive {
return Ok(None);
}
let aggregate_start_utc = chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(
range
.start_date
.and_hms_opt(0, 0, 0)
.expect("midnight should be valid"),
chrono::Utc,
);
let aggregate_end_utc = chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(
aggregate_end_exclusive
.and_hms_opt(0, 0, 0)
.expect("midnight should be valid"),
chrono::Utc,
);
let daily_totals =
list_admin_dashboard_daily_totals_aggregates(&pool, aggregate_start_utc, aggregate_end_utc)
.await
.map_err(|err| {
build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("{error_context}: {err:?}"),
false,
)
})?;
let daily_models =
list_admin_dashboard_daily_model_aggregates(&pool, aggregate_start_utc, aggregate_end_utc)
.await
.map_err(|err| {
build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("{error_context}: {err:?}"),
false,
)
})?;
let daily_providers = list_admin_dashboard_daily_provider_aggregates(
&pool,
aggregate_start_utc,
aggregate_end_utc,
)
.await
.map_err(|err| {
build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("{error_context}: {err:?}"),
false,
)
})?;
let mut by_date =
std::collections::BTreeMap::<chrono::NaiveDate, DashboardDailyAggregate>::new();
let mut model_summary = std::collections::BTreeMap::<String, DashboardModelAggregate>::new();
let mut provider_summary =
std::collections::BTreeMap::<String, DashboardProviderAggregate>::new();
for row in &daily_totals {
dashboard_record_daily_totals_aggregate(&mut by_date, row);
}
for row in &daily_models {
dashboard_record_daily_model_aggregate(&mut by_date, &mut model_summary, row);
}
for row in &daily_providers {
dashboard_record_daily_provider_aggregate(&mut by_date, &mut provider_summary, row);
}
if aggregate_end_exclusive <= range.end_date {
let raw_range = DashboardDateRange {
start_date: aggregate_end_exclusive,
end_date: range.end_date,
tz_offset_minutes: range.tz_offset_minutes,
};
let raw_rows =
dashboard_daily_breakdown_for_range(state, raw_range, None, error_context).await?;
dashboard_apply_daily_breakdown_rows(
&raw_rows,
&mut by_date,
&mut model_summary,
&mut provider_summary,
);
}
Ok(Some(dashboard_build_daily_stats_payload(
range,
true,
&by_date,
&model_summary,
&provider_summary,
)))
}
async fn dashboard_user_daily_stats_aggregate_payload(
state: &AppState,
range: DashboardDateRange,
user_id: &str,
error_context: &str,
) -> Result<Option<serde_json::Value>, Response<Body>> {
if range.tz_offset_minutes != 0 {
return dashboard_user_hourly_stats_aggregate_payload(state, range, user_id, error_context)
.await;
}
let Some(pool) = state.postgres_pool() else {
return Ok(None);
};
let cutoff_date = match read_stats_daily_cutoff_date(&pool).await {
Ok(value) => value,
Err(err) => {
return Err(build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("{error_context}: {err:?}"),
false,
))
}
};
let Some(cutoff_date) = cutoff_date else {
return Ok(None);
};
let Some(range_end_exclusive) = range.end_date.checked_add_signed(chrono::Duration::days(1))
else {
return Ok(None);
};
let aggregate_end_exclusive = range_end_exclusive.min(cutoff_date.date_naive());
if range.start_date >= aggregate_end_exclusive {
return Ok(None);
}
let aggregate_start_utc = chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(
range
.start_date
.and_hms_opt(0, 0, 0)
.expect("midnight should be valid"),
chrono::Utc,
);
let aggregate_end_utc = chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(
aggregate_end_exclusive
.and_hms_opt(0, 0, 0)
.expect("midnight should be valid"),
chrono::Utc,
);
let daily_totals = list_user_dashboard_daily_totals_aggregates(
&pool,
aggregate_start_utc,
aggregate_end_utc,
user_id,
)
.await
.map_err(|err| {
build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("{error_context}: {err:?}"),
false,
)
})?;
let daily_models = list_user_dashboard_daily_model_aggregates(
&pool,
aggregate_start_utc,
aggregate_end_utc,
user_id,
)
.await
.map_err(|err| {
build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("{error_context}: {err:?}"),
false,
)
})?;
let mut by_date =
std::collections::BTreeMap::<chrono::NaiveDate, DashboardDailyAggregate>::new();
let mut model_summary = std::collections::BTreeMap::<String, DashboardModelAggregate>::new();
let mut provider_summary =
std::collections::BTreeMap::<String, DashboardProviderAggregate>::new();
for row in &daily_totals {
dashboard_record_daily_totals_aggregate(&mut by_date, row);
}
for row in &daily_models {
dashboard_record_daily_model_aggregate(&mut by_date, &mut model_summary, row);
}
if aggregate_end_exclusive <= range.end_date {
let raw_range = DashboardDateRange {
start_date: aggregate_end_exclusive,
end_date: range.end_date,
tz_offset_minutes: range.tz_offset_minutes,
};
let raw_rows =
dashboard_daily_breakdown_for_range(state, raw_range, Some(user_id), error_context)
.await?;
dashboard_apply_daily_breakdown_rows(
&raw_rows,
&mut by_date,
&mut model_summary,
&mut provider_summary,
);
}
Ok(Some(dashboard_build_daily_stats_payload(
range,
false,
&by_date,
&model_summary,
&provider_summary,
)))
}
fn dashboard_usage_totals_from_summary(
summary: &StoredUsageDashboardSummary,
) -> DashboardUsageTotals {
@@ -1888,37 +1169,6 @@ pub(super) async fn handle_dashboard_daily_stats_get(
Err(detail) => return dashboard_bad_request_response(detail),
};
let user_filter = (!is_admin).then_some(auth.user.id.as_str());
if is_admin {
match dashboard_admin_daily_stats_aggregate_payload(
state,
range,
"dashboard daily stats lookup failed",
)
.await
{
Ok(Some(payload)) => {
return dashboard_cached_json_response(state, cache_key, cache_ttl, &payload)
}
Ok(None) => {}
Err(response) => return response,
}
} else {
match dashboard_user_daily_stats_aggregate_payload(
state,
range,
auth.user.id.as_str(),
"dashboard daily stats lookup failed",
)
.await
{
Ok(Some(payload)) => {
return dashboard_cached_json_response(state, cache_key, cache_ttl, &payload)
}
Ok(None) => {}
Err(response) => return response,
}
}
let usage = match dashboard_daily_breakdown_for_range(
state,
range,

View File

@@ -8,7 +8,6 @@ use chrono::Utc;
use serde_json::{json, Value};
use crate::handlers::shared::query_param_value;
use crate::query::monitoring as monitoring_query;
use super::{
build_auth_error_response, resolve_authenticated_local_user, AppState,
@@ -112,27 +111,16 @@ pub(super) async fn handle_user_audit_logs(
}
};
let Some(pool) = state.postgres_pool() else {
return build_user_monitoring_audit_logs_payload(
Vec::new(),
0,
let cutoff_time = Utc::now() - chrono::Duration::days(days);
let (items, total) = match state
.list_user_audit_logs(
&auth.user.id,
cutoff_time,
event_type.as_deref(),
limit,
offset,
event_type,
days,
);
};
let cutoff_time = Utc::now() - chrono::Duration::days(days);
let (items, total) = match monitoring_query::list_user_audit_logs(
&pool,
&auth.user.id,
cutoff_time,
event_type.as_deref(),
limit,
offset,
)
.await
)
.await
{
Ok(value) => value,
Err(err) => {

View File

@@ -4,8 +4,8 @@ pub(super) use super::{build_auth_error_response, AppState, GatewayPublicRequest
#[path = "payment/gateway.rs"]
pub(super) mod payment_gateway;
#[path = "payment/postgres.rs"]
mod payment_postgres;
#[path = "payment/repository.rs"]
mod payment_repository;
#[path = "payment/route.rs"]
mod payment_route;
#[path = "payment/shared.rs"]
@@ -14,7 +14,7 @@ mod payment_shared;
#[path = "payment/test_support.rs"]
mod payment_test_support;
use self::payment_postgres::handle_payment_callback_with_postgres;
use self::payment_repository::handle_payment_callback_with_wallet_repository;
use self::payment_shared::NormalizedPaymentCallbackRequest;
const PAYMENT_CALLBACK_STORAGE_UNAVAILABLE_DETAIL: &str = "支付回调存储暂不可用";

View File

@@ -11,14 +11,14 @@ use super::{
GatewayPublicRequestContext,
};
pub(super) async fn handle_payment_callback_with_postgres(
pub(super) async fn handle_payment_callback_with_wallet_repository(
state: &AppState,
payment_method: &str,
request_context: &GatewayPublicRequestContext,
payload: &NormalizedPaymentCallbackRequest,
signature_valid: bool,
) -> Response<Body> {
if state.postgres_pool().is_none() {
if !state.has_database_wallet_data_writer() {
return build_payment_callback_storage_unavailable_response();
}
@@ -128,7 +128,7 @@ pub(super) async fn handle_payment_callback_with_postgres(
#[cfg(test)]
mod tests {
use super::{
handle_payment_callback_with_postgres, AppState, NormalizedPaymentCallbackRequest,
handle_payment_callback_with_wallet_repository, AppState, NormalizedPaymentCallbackRequest,
};
use crate::control::GatewayPublicRequestContext;
use crate::handlers::public::support::support_payment::PAYMENT_CALLBACK_STORAGE_UNAVAILABLE_DETAIL;
@@ -137,10 +137,10 @@ mod tests {
use serde_json::json;
#[tokio::test]
async fn payment_callback_postgres_handler_returns_explicit_503_without_pool() {
async fn payment_callback_repository_handler_returns_explicit_503_without_wallet_writer() {
let state = AppState::new().expect("state should build");
let request_context = GatewayPublicRequestContext::from_request_parts(
"trace-payment-callback-postgres-missing",
"trace-payment-callback-wallet-writer-missing",
&Method::POST,
&"/api/payment/callback/alipay"
.parse::<Uri>()
@@ -159,7 +159,7 @@ mod tests {
payload: json!({ "status": "paid" }),
};
let response = handle_payment_callback_with_postgres(
let response = handle_payment_callback_with_wallet_repository(
&state,
"alipay",
&request_context,

View File

@@ -7,7 +7,7 @@ use super::payment_shared::{
};
use super::{
build_auth_error_response, build_payment_callback_storage_unavailable_response,
handle_payment_callback_with_postgres, AppState, GatewayPublicRequestContext,
handle_payment_callback_with_wallet_repository, AppState, GatewayPublicRequestContext,
};
pub(super) async fn maybe_build_local_payment_callback_route_response(
@@ -104,9 +104,9 @@ pub(super) async fn maybe_build_local_payment_callback_route_response(
}
};
if state.postgres_pool().is_some() {
if state.has_database_wallet_data_writer() {
return Some(
handle_payment_callback_with_postgres(
handle_payment_callback_with_wallet_repository(
state,
&payment_method,
request_context,

View File

@@ -18,9 +18,6 @@ use axum::{
use chrono::Utc;
use serde_json::json;
use crate::query::usage_heatmap::{
list_usage_heatmap_aggregate_rows, read_stats_daily_cutoff_date,
};
use crate::GatewayError;
use super::{
@@ -1204,8 +1201,8 @@ pub(super) async fn handle_users_me_usage_heatmap_get(
async fn build_usage_heatmap_summaries(
state: &AppState,
created_from_unix_secs: u64,
start_date: chrono::NaiveDate,
today: chrono::NaiveDate,
_start_date: chrono::NaiveDate,
_today: chrono::NaiveDate,
user_id: Option<&str>,
) -> Result<Vec<StoredUsageDailySummary>, GatewayError> {
let query = aether_data_contracts::repository::usage::UsageDailyHeatmapQuery {
@@ -1213,43 +1210,7 @@ async fn build_usage_heatmap_summaries(
user_id: user_id.map(ToOwned::to_owned),
admin_mode: user_id.is_none(),
};
let Some(pool) = state.postgres_pool() else {
return state.summarize_usage_daily_heatmap(&query).await;
};
let Some(cutoff_date) = read_stats_daily_cutoff_date(&pool).await? else {
return state.summarize_usage_daily_heatmap(&query).await;
};
let cutoff_day = cutoff_date.date_naive().min(today);
let mut summaries =
list_usage_heatmap_aggregate_rows(&pool, start_date, cutoff_day, user_id).await?;
let raw_start_date = start_date.max(cutoff_day);
if raw_start_date <= today {
let raw_start_of_day = raw_start_date
.and_hms_opt(0, 0, 0)
.expect("heatmap day start should be valid");
let raw_created_from_unix_secs = u64::try_from(
chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(
raw_start_of_day,
chrono::Utc,
)
.timestamp(),
)
.unwrap_or_default();
summaries.extend(
state
.summarize_usage_daily_heatmap(
&aether_data_contracts::repository::usage::UsageDailyHeatmapQuery {
created_from_unix_secs: raw_created_from_unix_secs,
user_id: user_id.map(ToOwned::to_owned),
admin_mode: user_id.is_none(),
},
)
.await?,
);
}
let mut summaries = state.summarize_usage_daily_heatmap(&query).await?;
summaries.sort_by(|left, right| left.date.cmp(&right.date));
Ok(summaries)
}

View File

@@ -34,13 +34,11 @@ use self::reads::{
parse_wallet_limit, parse_wallet_offset, wallet_fixed_offset, wallet_today_billing_date_string,
wallet_transaction_payload_from_record,
};
pub(crate) use self::recharge::sanitize_wallet_gateway_response;
use self::recharge::{
handle_wallet_create_recharge, handle_wallet_recharge_detail, handle_wallet_recharge_list,
wallet_recharge_detail_path_matches,
};
pub(crate) use self::recharge::{
sanitize_wallet_gateway_response, wallet_payment_order_payload_from_row,
};
use self::redeem::handle_wallet_redeem;
use self::refunds::{
handle_wallet_create_refund, handle_wallet_refund_detail, handle_wallet_refunds_list,

View File

@@ -5,8 +5,8 @@ use super::{
build_auth_error_response, build_auth_json_response, build_wallet_payload,
build_wallet_recharge_storage_unavailable_response, http, parse_wallet_limit,
parse_wallet_offset, resolve_authenticated_local_user, unix_secs_to_rfc3339,
wallet_normalize_optional_string_field, AppState, Body, GatewayError,
GatewayPublicRequestContext, Response, WALLET_SAFE_GATEWAY_RESPONSE_KEYS,
wallet_normalize_optional_string_field, AppState, Body, GatewayPublicRequestContext, Response,
WALLET_SAFE_GATEWAY_RESPONSE_KEYS,
};
#[cfg(test)]
use super::{
@@ -16,7 +16,6 @@ use super::{
use chrono::Utc;
use serde::Deserialize;
use serde_json::json;
use sqlx::Row;
use uuid::Uuid;
#[derive(Debug, Deserialize)]
@@ -152,65 +151,6 @@ fn build_wallet_payment_order_payload(
})
}
pub(crate) fn wallet_payment_order_payload_from_row(
row: &sqlx::postgres::PgRow,
) -> Result<serde_json::Value, GatewayError> {
let created_at = row
.try_get::<Option<i64>, _>("created_at_unix_ms")
.map_err(|err| GatewayError::Internal(err.to_string()))?
.and_then(|value| u64::try_from(value).ok())
.and_then(unix_secs_to_rfc3339);
let paid_at = row
.try_get::<Option<i64>, _>("paid_at_unix_secs")
.map_err(|err| GatewayError::Internal(err.to_string()))?
.and_then(|value| u64::try_from(value).ok())
.and_then(unix_secs_to_rfc3339);
let credited_at = row
.try_get::<Option<i64>, _>("credited_at_unix_secs")
.map_err(|err| GatewayError::Internal(err.to_string()))?
.and_then(|value| u64::try_from(value).ok())
.and_then(unix_secs_to_rfc3339);
let expires_at = row
.try_get::<Option<i64>, _>("expires_at_unix_secs")
.map_err(|err| GatewayError::Internal(err.to_string()))?
.and_then(|value| u64::try_from(value).ok())
.and_then(unix_secs_to_rfc3339);
Ok(build_wallet_payment_order_payload(
row.try_get::<String, _>("id")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
row.try_get::<String, _>("order_no")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
row.try_get::<String, _>("wallet_id")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
row.try_get::<Option<String>, _>("user_id")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
row.try_get::<f64, _>("amount_usd")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
row.try_get::<Option<f64>, _>("pay_amount")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
row.try_get::<Option<String>, _>("pay_currency")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
row.try_get::<Option<f64>, _>("exchange_rate")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
row.try_get::<f64, _>("refunded_amount_usd")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
row.try_get::<f64, _>("refundable_amount_usd")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
row.try_get::<String, _>("payment_method")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
row.try_get::<Option<String>, _>("gateway_order_id")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
row.try_get::<Option<serde_json::Value>, _>("gateway_response")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
row.try_get::<String, _>("effective_status")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
created_at,
paid_at,
credited_at,
expires_at,
))
}
fn wallet_payment_order_payload_from_record(
record: &aether_data::repository::wallet::StoredAdminPaymentOrder,
) -> serde_json::Value {
@@ -285,7 +225,7 @@ pub(super) async fn handle_wallet_create_recharge(
}
};
if state.postgres_pool().is_none() {
if !state.has_database_wallet_data_writer() {
#[cfg(test)]
{
let Some(wallet) = wallet else {
@@ -485,11 +425,12 @@ pub(super) async fn handle_wallet_recharge_list(
}
};
#[cfg(test)]
let (items, total) = if state.postgres_pool().is_none() && items.is_empty() && total == 0 {
wallet_test_recharge_orders_for_user(&auth.user.id, limit, offset)
} else {
(items, total)
};
let (items, total) =
if !state.has_database_wallet_data_writer() && items.is_empty() && total == 0 {
wallet_test_recharge_orders_for_user(&auth.user.id, limit, offset)
} else {
(items, total)
};
let mut payload = json!({
"items": items,

View File

@@ -2,8 +2,7 @@ use super::{
build_auth_error_response, build_auth_json_response, build_wallet_payload,
build_wallet_refund_storage_unavailable_response, http, parse_wallet_limit,
parse_wallet_offset, resolve_authenticated_local_user, unix_secs_to_rfc3339,
wallet_normalize_optional_string_field, AppState, Body, GatewayError,
GatewayPublicRequestContext, Response,
wallet_normalize_optional_string_field, AppState, Body, GatewayPublicRequestContext, Response,
};
#[cfg(test)]
use super::{
@@ -13,7 +12,6 @@ use super::{
use chrono::Utc;
use serde::Deserialize;
use serde_json::json;
use sqlx::Row;
use uuid::Uuid;
#[derive(Debug, Deserialize)]
@@ -94,51 +92,6 @@ pub(super) fn wallet_refund_detail_path_matches(request_path: &str) -> bool {
wallet_refund_id_from_path(request_path).is_some()
}
fn wallet_refund_payload_from_row(
row: &sqlx::postgres::PgRow,
) -> Result<serde_json::Value, GatewayError> {
let created_at = row
.try_get::<Option<i64>, _>("created_at_unix_ms")
.map_err(|err| GatewayError::Internal(err.to_string()))?
.and_then(|value| u64::try_from(value).ok())
.and_then(unix_secs_to_rfc3339);
let updated_at = row
.try_get::<Option<i64>, _>("updated_at_unix_secs")
.map_err(|err| GatewayError::Internal(err.to_string()))?
.and_then(|value| u64::try_from(value).ok())
.and_then(unix_secs_to_rfc3339);
let processed_at = row
.try_get::<Option<i64>, _>("processed_at_unix_secs")
.map_err(|err| GatewayError::Internal(err.to_string()))?
.and_then(|value| u64::try_from(value).ok())
.and_then(unix_secs_to_rfc3339);
let completed_at = row
.try_get::<Option<i64>, _>("completed_at_unix_secs")
.map_err(|err| GatewayError::Internal(err.to_string()))?
.and_then(|value| u64::try_from(value).ok())
.and_then(unix_secs_to_rfc3339);
Ok(json!({
"id": row.try_get::<String, _>("id").map_err(|err| GatewayError::Internal(err.to_string()))?,
"refund_no": row.try_get::<String, _>("refund_no").map_err(|err| GatewayError::Internal(err.to_string()))?,
"payment_order_id": row.try_get::<Option<String>, _>("payment_order_id").map_err(|err| GatewayError::Internal(err.to_string()))?,
"source_type": row.try_get::<String, _>("source_type").map_err(|err| GatewayError::Internal(err.to_string()))?,
"source_id": row.try_get::<Option<String>, _>("source_id").map_err(|err| GatewayError::Internal(err.to_string()))?,
"refund_mode": row.try_get::<String, _>("refund_mode").map_err(|err| GatewayError::Internal(err.to_string()))?,
"amount_usd": row.try_get::<f64, _>("amount_usd").map_err(|err| GatewayError::Internal(err.to_string()))?,
"status": row.try_get::<String, _>("status").map_err(|err| GatewayError::Internal(err.to_string()))?,
"reason": row.try_get::<Option<String>, _>("reason").map_err(|err| GatewayError::Internal(err.to_string()))?,
"failure_reason": row.try_get::<Option<String>, _>("failure_reason").map_err(|err| GatewayError::Internal(err.to_string()))?,
"gateway_refund_id": row.try_get::<Option<String>, _>("gateway_refund_id").map_err(|err| GatewayError::Internal(err.to_string()))?,
"payout_method": row.try_get::<Option<String>, _>("payout_method").map_err(|err| GatewayError::Internal(err.to_string()))?,
"payout_reference": row.try_get::<Option<String>, _>("payout_reference").map_err(|err| GatewayError::Internal(err.to_string()))?,
"payout_proof": row.try_get::<Option<serde_json::Value>, _>("payout_proof").map_err(|err| GatewayError::Internal(err.to_string()))?,
"created_at": created_at,
"updated_at": updated_at,
"processed_at": processed_at,
"completed_at": completed_at,
}))
}
fn wallet_refund_payload_from_record(
record: &aether_data::repository::wallet::StoredAdminWalletRefund,
) -> serde_json::Value {
@@ -255,18 +208,19 @@ pub(super) async fn handle_wallet_refunds_list(
})
.collect::<Vec<_>>();
#[cfg(test)]
let (items, total) = if state.postgres_pool().is_none() && items.is_empty() && total == 0 {
let all_items = wallet_test_refunds_for_wallet(&wallet.id);
let total = all_items.len() as u64;
let items = all_items
.into_iter()
.skip(offset)
.take(limit)
.collect::<Vec<_>>();
(items, total)
} else {
(items, total)
};
let (items, total) =
if !state.has_database_wallet_data_writer() && items.is_empty() && total == 0 {
let all_items = wallet_test_refunds_for_wallet(&wallet.id);
let total = all_items.len() as u64;
let items = all_items
.into_iter()
.skip(offset)
.take(limit)
.collect::<Vec<_>>();
(items, total)
} else {
(items, total)
};
let mut payload = json!({
"items": items,
@@ -394,7 +348,7 @@ pub(super) async fn handle_wallet_create_refund(
);
};
if state.postgres_pool().is_none() {
if !state.has_database_wallet_data_writer() {
#[cfg(test)]
{
if let Some(idempotency_key) = payload.idempotency_key.as_deref() {