fix(usage): 完善 cache-affinity 时间线的用户名回退与模型空值兜底

- 当 auth 用户查询失败时回退到历史 username,避免时间线丢失用户信息
- SQL 投影对 usage.model 使用 COALESCE 兜底空值
- IntervalTimelineCard 在刷新间隔为 0 时跳过定时刷新
- Usage 页面关闭时间线卡片的自动轮询
This commit is contained in:
fawney19
2026-04-19 16:07:01 +08:00
parent 41b51f10a9
commit c302dfe42d
5 changed files with 107 additions and 6 deletions

View File

@@ -86,11 +86,20 @@ pub(super) async fn build_admin_usage_cache_affinity_interval_timeline_response(
if include_user_info && user_id.is_none() { if include_user_info && user_id.is_none() {
let user_ids: Vec<_> = grouped.keys().cloned().collect(); let user_ids: Vec<_> = grouped.keys().cloned().collect();
let user_map = load_usage_cache_affinity_usernames(state, &user_ids).await?; let mut should_use_legacy_usernames = !state.has_auth_user_data_reader();
for (user_id, username) in user_map { match load_usage_cache_affinity_usernames(state, &user_ids).await {
usernames_by_user_id.insert(user_id, username); Ok(user_map) => {
usernames_by_user_id.extend(user_map);
}
Err(err) => {
tracing::warn!(
error = ?err,
"admin usage cache affinity interval timeline user lookup failed"
);
should_use_legacy_usernames = true;
}
} }
if !state.has_auth_user_data_reader() { if should_use_legacy_usernames {
for (user_id, username) in legacy_usernames_by_user_id { for (user_id, username) in legacy_usernames_by_user_id {
usernames_by_user_id.entry(user_id).or_insert(username); usernames_by_user_id.entry(user_id).or_insert(username);
} }

View File

@@ -2605,6 +2605,90 @@ async fn gateway_handles_admin_usage_cache_affinity_interval_timeline_without_le
upstream_handle.abort(); upstream_handle.abort();
} }
#[tokio::test]
async fn gateway_handles_admin_usage_cache_affinity_interval_timeline_with_legacy_username_fallback_when_auth_user_lookup_fails(
) {
let (_upstream_url, upstream_hits, upstream_handle) =
start_usage_upstream("/api/admin/usage/cache-affinity/interval-timeline").await;
let mut usage_one = sample_usage_row(
"usage-auth-lookup-fail-1",
"req-auth-lookup-fail-1",
Some("user-1"),
Some("key-1"),
Some("primary"),
"OpenAI",
"gpt-5",
"completed",
10,
2,
0.01,
0.012,
recent_unix_secs(55),
);
usage_one.username = Some("stale-alice".to_string());
let mut usage_two = sample_usage_row(
"usage-auth-lookup-fail-2",
"req-auth-lookup-fail-2",
Some("user-1"),
Some("key-1"),
Some("primary"),
"OpenAI",
"gpt-5",
"completed",
12,
3,
0.01,
0.012,
recent_unix_secs(50),
);
usage_two.username = Some("stale-alice".to_string());
let usage_repository = Arc::new(InMemoryUsageReadRepository::seed(vec![
usage_one, usage_two,
]));
let gateway = build_router_with_state(
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(GatewayDataState::with_usage_reader_for_tests(
usage_repository,
))
.with_auth_users_for_tests([StoredUserAuthRecord {
id: "user-1".to_string(),
email: None,
email_verified: false,
username: " ".to_string(),
password_hash: None,
role: "user".to_string(),
auth_source: "local".to_string(),
allowed_providers: None,
allowed_api_formats: None,
allowed_models: None,
is_active: true,
is_deleted: false,
created_at: None,
last_login_at: None,
}]),
);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let response = admin_request(reqwest::Client::new().get(format!(
"{gateway_url}/api/admin/usage/cache-affinity/interval-timeline?hours=24&limit=100&include_user_info=true"
)))
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["total_points"], 1);
assert_eq!(payload["points"][0]["user_id"], "user-1");
assert_eq!(payload["users"]["user-1"], "stale-alice");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test] #[tokio::test]
async fn gateway_handles_admin_usage_cache_affinity_ttl_analysis_locally_with_trusted_admin_principal( async fn gateway_handles_admin_usage_cache_affinity_ttl_analysis_locally_with_trusted_admin_principal(
) { ) {

View File

@@ -2495,7 +2495,7 @@ WITH filtered_usage AS (
builder.push( builder.push(
r#" AS group_id, r#" AS group_id,
"usage".username AS username, "usage".username AS username,
"usage".model AS model, COALESCE("usage".model, '') AS model,
"usage".created_at AS created_at, "usage".created_at AS created_at,
"usage".id AS usage_id "usage".id AS usage_id
FROM "usage" FROM "usage"
@@ -6188,6 +6188,11 @@ mod tests {
assert!(super::LIST_RECENT_USAGE_AUDITS_PREFIX.contains("FROM \"usage\"")); assert!(super::LIST_RECENT_USAGE_AUDITS_PREFIX.contains("FROM \"usage\""));
} }
#[test]
fn usage_sql_cache_affinity_interval_query_coalesces_nullable_model_values() {
assert!(include_str!("sql.rs").contains("COALESCE(\"usage\".model, '') AS model"));
}
#[test] #[test]
fn usage_sql_reads_http_audits_for_single_record_fetches() { fn usage_sql_reads_http_audits_for_single_record_fetches() {
assert!(super::FIND_BY_REQUEST_ID_SQL.contains("LEFT JOIN usage_http_audits")); assert!(super::FIND_BY_REQUEST_ID_SQL.contains("LEFT JOIN usage_http_audits"));

View File

@@ -347,6 +347,9 @@ function handleVisibilityChange() {
stopRefreshTimer() stopRefreshTimer()
return return
} }
if (!props.refreshIntervalMs || props.refreshIntervalMs <= 0) {
return
}
void loadData() void loadData()
scheduleNextRefresh() scheduleNextRefresh()
} }

View File

@@ -38,7 +38,7 @@
:title="intervalTimelineTitle" :title="intervalTimelineTitle"
:is-admin="isAdminPage" :is-admin="isAdminPage"
:hours="intervalTimelineHours" :hours="intervalTimelineHours"
:refresh-interval-ms="30000" :refresh-interval-ms="0"
/> />
</div> </div>