From a1c6df0fe7a3e9803a00f37b4e56e1aa85970a28 Mon Sep 17 00:00:00 2001 From: RWDai <27391645+RWDai@users.noreply.github.com> Date: Thu, 28 May 2026 12:55:46 +0800 Subject: [PATCH 01/23] Add candidate failure details to usage payload --- .../aether-admin/src/observability/usage.rs | 508 +++++++++++++++++- 1 file changed, 498 insertions(+), 10 deletions(-) diff --git a/crates/aether-admin/src/observability/usage.rs b/crates/aether-admin/src/observability/usage.rs index 8275306c0..66177f6f3 100644 --- a/crates/aether-admin/src/observability/usage.rs +++ b/crates/aether-admin/src/observability/usage.rs @@ -6,6 +6,7 @@ use aether_billing::{ }; use aether_data::repository::users::StoredUserSummary; use aether_data_contracts::repository::{ + candidates::{RequestCandidateStatus, StoredRequestCandidate}, provider_catalog::{StoredProviderCatalogEndpoint, StoredProviderCatalogProvider}, usage::{StoredRequestUsageAudit, StoredUsageAuditSummary, UsageBodyField}, }; @@ -474,12 +475,300 @@ fn admin_usage_local_runtime_miss_reason_label(reason: &str) -> &'static str { match reason { "all_candidates_skipped" => "所有候选均被跳过", "candidate_list_empty" => "没有可调度候选", + "execution_runtime_candidates_exhausted" => "候选执行失败且已耗尽", "local_runtime_unavailable" => "本地执行运行时不可用", "provider_transport_unavailable" => "提供商传输不可用", _ => "本地调度未命中", } } +#[derive(Debug, Clone, Copy, Default)] +struct AdminUsageCandidateFailureSummary { + total: usize, + failed: usize, + skipped: usize, + retried: usize, +} + +impl AdminUsageCandidateFailureSummary { + fn to_json(self) -> Value { + if self.total == 0 { + Value::Null + } else { + json!({ + "total": self.total, + "failed": self.failed, + "skipped": self.skipped, + "retried": self.retried, + }) + } + } +} + +fn admin_usage_candidate_was_attempted(candidate: &StoredRequestCandidate) -> bool { + candidate.status.is_attempted(candidate.started_at_unix_ms) + || candidate.status_code.is_some() + || candidate + .error_message + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) +} + +fn admin_usage_candidate_failed(candidate: &StoredRequestCandidate) -> bool { + matches!( + candidate.status, + RequestCandidateStatus::Failed | RequestCandidateStatus::Cancelled + ) || candidate.status_code.is_some_and(|code| code >= 400) + || candidate + .error_message + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) +} + +fn admin_usage_candidate_failure_summary( + candidates: &[StoredRequestCandidate], +) -> AdminUsageCandidateFailureSummary { + let mut summary = AdminUsageCandidateFailureSummary { + total: candidates.len(), + ..AdminUsageCandidateFailureSummary::default() + }; + for candidate in candidates { + if admin_usage_candidate_failed(candidate) { + summary.failed += 1; + } + if matches!(candidate.status, RequestCandidateStatus::Skipped) { + summary.skipped += 1; + } + if admin_usage_candidate_was_attempted(candidate) { + summary.retried += 1; + } + } + summary +} + +fn admin_usage_scheduling_title( + reason: &str, + summary: AdminUsageCandidateFailureSummary, +) -> String { + if summary.total == 0 || reason == "candidate_list_empty" { + return "没有找到可调度候选".to_string(); + } + if summary.failed == 1 && summary.total == 1 { + return "唯一候选执行失败,已无可重试上游".to_string(); + } + if summary.failed > 0 && summary.failed + summary.skipped >= summary.total { + return "所有候选已完成重试,但全部执行失败".to_string(); + } + if summary.skipped == summary.total { + return "所有候选都被调度规则跳过".to_string(); + } + format!( + "本地调度失败:{}", + admin_usage_local_runtime_miss_reason_label(reason) + ) +} + +fn admin_usage_first_non_empty_string<'a>( + values: impl IntoIterator>, +) -> Option { + values + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(str::trim) + .find(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +fn admin_usage_body_object_from_upstream_response(upstream_response: &Value) -> Option<&Value> { + upstream_response.get("body") +} + +fn admin_usage_upstream_response_status_code(upstream_response: &Value) -> Option { + upstream_response + .get("status_code") + .or_else(|| upstream_response.get("statusCode")) + .and_then(Value::as_u64) + .and_then(|value| u16::try_from(value).ok()) +} + +fn admin_usage_friendly_upstream_user_message(upstream: &Value) -> Option { + let message = upstream + .get("message") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty())?; + let haystack = [ + upstream + .get("type") + .and_then(Value::as_str) + .unwrap_or_default(), + upstream + .get("param") + .and_then(Value::as_str) + .unwrap_or_default(), + message, + ] + .join(" ") + .to_ascii_lowercase(); + let model = upstream + .get("model") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("当前模型"); + + if haystack.contains("maximum context length") + || haystack.contains("context length") + || haystack.contains("input_tokens") + { + return Some(format!( + "输入上下文超过模型 {model} 的最大长度限制。请减少输入内容、缩短历史上下文或调整客户端上下文裁剪策略。" + )); + } + if haystack.contains("insufficient_quota") + || haystack.contains("quota exceeded") + || haystack.contains("insufficient quota") + { + return Some( + "上游账号额度不足或配额已耗尽,请更换 Key、补充额度或调整路由策略。".to_string(), + ); + } + if haystack.contains("rate limit") || haystack.contains("429") { + return Some("上游触发限流,请稍后重试或切换到其他可用上游。".to_string()); + } + if haystack.contains("invalid api key") + || haystack.contains("unauthorized") + || haystack.contains("401") + { + return Some("上游鉴权失败,请检查 Provider API Key 是否有效。".to_string()); + } + if haystack.contains("model not found") { + return Some(format!( + "上游未找到模型 {model},请检查模型映射或端点配置。" + )); + } + None +} + +fn admin_usage_candidate_upstream_failure_json( + item: &StoredRequestUsageAudit, + candidate: &StoredRequestCandidate, + provider_key_name: Option<&str>, +) -> Value { + let extra = candidate.extra_data.as_ref().and_then(Value::as_object); + let upstream_response = extra + .and_then(|object| object.get("upstream_response")) + .filter(|value| value.is_object()); + let error_flow = extra + .and_then(|object| object.get("error_flow")) + .filter(|value| value.is_object()); + let body = upstream_response.and_then(admin_usage_body_object_from_upstream_response); + let message = error_flow + .and_then(|value| admin_usage_string_field(value, "message").map(ToOwned::to_owned)) + .or_else(|| body.and_then(admin_usage_error_message_from_body)) + .or_else(|| { + body.and_then(|value| admin_usage_string_field(value, "message").map(ToOwned::to_owned)) + }) + .or_else(|| { + candidate + .error_message + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + }); + let status_code = upstream_response + .and_then(admin_usage_upstream_response_status_code) + .or(candidate.status_code) + .or(item.status_code.and_then(|value| u16::try_from(value).ok())); + let error_type = body.and_then(admin_usage_error_type_from_body).or_else(|| { + candidate + .error_type + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + }); + let param = body + .and_then(|value| admin_usage_error_field(value, "param")) + .map(ToOwned::to_owned); + let mapped_model = extra + .and_then(|object| { + admin_usage_first_non_empty_string([ + object.get("mapped_model"), + object.get("model"), + object.get("target_model"), + ]) + }) + .or_else(|| item.target_model.clone()) + .or_else(|| Some(item.model.clone())); + let key_name = extra + .and_then(|object| object.get("key_name")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .or_else(|| provider_key_name.map(ToOwned::to_owned)) + .or_else(|| item.routing_key_name().map(ToOwned::to_owned)); + + if message.is_none() + && status_code.is_none() + && error_type.is_none() + && upstream_response.is_none() + { + return Value::Null; + } + + let mut upstream = json!({ + "provider_name": admin_usage_provider_display_name(item), + "endpoint_id": candidate.endpoint_id.as_ref().or(item.provider_endpoint_id.as_ref()), + "key_name": key_name, + "model": mapped_model, + "status_code": status_code, + "type": error_type, + "param": param, + "message": message, + }); + let user_message = admin_usage_friendly_upstream_user_message(&upstream); + upstream["user_message"] = user_message.map(Value::String).unwrap_or(Value::Null); + upstream +} + +fn admin_usage_primary_upstream_failure_json( + item: &StoredRequestUsageAudit, + candidates: &[StoredRequestCandidate], + provider_key_name: Option<&str>, +) -> Value { + let mut failed_candidates = candidates + .iter() + .filter(|candidate| admin_usage_candidate_failed(candidate)) + .collect::>(); + failed_candidates.sort_by(|left, right| { + left.candidate_index + .cmp(&right.candidate_index) + .then(left.retry_index.cmp(&right.retry_index)) + }); + for candidate in failed_candidates { + let upstream = + admin_usage_candidate_upstream_failure_json(item, candidate, provider_key_name); + if !upstream.is_null() { + return upstream; + } + } + Value::Null +} + +fn admin_usage_candidates_have_upstream_status(candidates: &[StoredRequestCandidate]) -> bool { + candidates.iter().any(|candidate| { + let extra = candidate.extra_data.as_ref().and_then(Value::as_object); + extra + .and_then(|object| object.get("upstream_response")) + .and_then(admin_usage_upstream_response_status_code) + .is_some() + }) +} + fn admin_usage_extract_local_runtime_miss_reason_summary(message: &str) -> Option { let without_reason_code = message .split_once("(原因代码:") @@ -496,6 +785,8 @@ fn admin_usage_extract_local_runtime_miss_reason_summary(message: &str) -> Optio fn admin_usage_scheduling_failure_json( item: &StoredRequestUsageAudit, client_error: &Value, + candidates: &[StoredRequestCandidate], + provider_key_name: Option<&str>, ) -> Value { if item.routing_execution_path() != Some("local_execution_runtime_miss") { return Value::Null; @@ -514,20 +805,56 @@ fn admin_usage_scheduling_failure_json( .filter(|value| !value.is_empty()); let reason_summary = raw_message.and_then(admin_usage_extract_local_runtime_miss_reason_summary); + let candidate_failure_summary = admin_usage_candidate_failure_summary(candidates); + let upstream_failure = + admin_usage_primary_upstream_failure_json(item, candidates, provider_key_name); + let no_upstream_attempt = if admin_usage_candidates_have_upstream_status(candidates) { + false + } else if !candidates.is_empty() { + candidate_failure_summary.failed == 0 + && (candidate_failure_summary.total == 0 + || candidate_failure_summary.skipped == candidate_failure_summary.total) + } else { + item.candidate_id.is_none() + && item.provider_api_key_id.is_none() + && item.provider_request_headers.is_none() + && item.provider_request_body.is_none() + && item.provider_request_body_ref.is_none() + }; + let mut display_message = message.clone(); + if !upstream_failure.is_null() { + display_message = upstream_failure + .get("user_message") + .and_then(Value::as_str) + .map(ToOwned::to_owned) + .or(display_message) + .or_else(|| { + upstream_failure + .get("message") + .and_then(Value::as_str) + .map(ToOwned::to_owned) + }); + } + let title = if candidates.is_empty() { + format!( + "本地调度失败:{}", + admin_usage_local_runtime_miss_reason_label(reason) + ) + } else { + admin_usage_scheduling_title(reason, candidate_failure_summary) + }; json!({ "source": "local_execution_runtime_miss", "reason": reason, "reason_label": admin_usage_local_runtime_miss_reason_label(reason), - "title": format!("本地调度失败:{}", admin_usage_local_runtime_miss_reason_label(reason)), - "message": message, + "title": title, + "message": display_message, "reason_summary": reason_summary, "status_code": item.status_code, - "no_upstream_attempt": item.candidate_id.is_none() - && item.provider_api_key_id.is_none() - && item.provider_request_headers.is_none() - && item.provider_request_body.is_none() - && item.provider_request_body_ref.is_none(), + "no_upstream_attempt": no_upstream_attempt, + "upstream_failure": upstream_failure, + "candidate_failure_summary": candidate_failure_summary.to_json(), }) } @@ -2354,6 +2681,33 @@ pub fn build_admin_usage_detail_payload( include_bodies: bool, request_body: Option, default_headers: &BTreeMap, +) -> Value { + build_admin_usage_detail_payload_with_candidates( + item, + users_by_id, + api_key_names, + auth_user_reader_available, + auth_api_key_reader_available, + provider_key_name, + include_bodies, + request_body, + default_headers, + &[], + ) +} + +#[allow(clippy::too_many_arguments)] +pub fn build_admin_usage_detail_payload_with_candidates( + item: &StoredRequestUsageAudit, + users_by_id: &BTreeMap, + api_key_names: &BTreeMap, + auth_user_reader_available: bool, + auth_api_key_reader_available: bool, + provider_key_name: Option<&str>, + include_bodies: bool, + request_body: Option, + default_headers: &BTreeMap, + candidates: &[StoredRequestCandidate], ) -> Value { let mut payload = admin_usage_record_json( item, @@ -2415,8 +2769,12 @@ pub fn build_admin_usage_detail_payload( payload["upstream_error"] = error_domains["upstream_error"].clone(); payload["client_error"] = error_domains["client_error"].clone(); payload["failure_summary"] = error_domains["failure_summary"].clone(); - payload["scheduling_failure"] = - admin_usage_scheduling_failure_json(item, &error_domains["client_error"]); + payload["scheduling_failure"] = admin_usage_scheduling_failure_json( + item, + &error_domains["client_error"], + candidates, + provider_key_name, + ); payload["error_flow"] = error_flow; payload["has_request_body"] = json!(admin_usage_has_body_value( item, @@ -2511,8 +2869,12 @@ mod tests { admin_usage_matches_search, admin_usage_matches_status, admin_usage_matches_username, admin_usage_record_json, admin_usage_resolve_request_capture_body, admin_usage_total_tokens, admin_usage_upstream_is_stream, build_admin_usage_detail_payload, + build_admin_usage_detail_payload_with_candidates, + }; + use aether_data_contracts::repository::{ + candidates::{RequestCandidateStatus, StoredRequestCandidate}, + usage::{StoredRequestUsageAudit, UsageBodyField}, }; - use aether_data_contracts::repository::usage::{StoredRequestUsageAudit, UsageBodyField}; fn sample_usage( status: &str, @@ -2560,6 +2922,40 @@ mod tests { .expect("usage should build") } + fn sample_candidate( + status: RequestCandidateStatus, + status_code: Option, + extra_data: Option, + ) -> StoredRequestCandidate { + StoredRequestCandidate::new( + "candidate-1".to_string(), + "req-1".to_string(), + Some("user-1".to_string()), + Some("api-key-1".to_string()), + Some("alice".to_string()), + Some("default".to_string()), + 0, + 0, + Some("provider-1".to_string()), + Some("endpoint-1".to_string()), + Some("provider-key-1".to_string()), + status, + None, + false, + status_code, + None, + None, + Some(120), + None, + extra_data, + None, + 100, + Some(101), + Some(102), + ) + .expect("candidate should build") + } + #[test] fn explicit_completed_status_wins_over_legacy_failure_fields() { let item = sample_usage( @@ -3311,6 +3707,98 @@ mod tests { assert_eq!(payload["scheduling_failure"]["no_upstream_attempt"], true); } + #[test] + fn detail_payload_adds_candidate_upstream_failure_for_exhausted_local_runtime() { + let item = StoredRequestUsageAudit { + execution_path: Some("local_execution_runtime_miss".to_string()), + local_execution_runtime_miss_reason: Some( + "execution_runtime_candidates_exhausted".to_string(), + ), + target_model: Some("qwen3.6-27b".to_string()), + error_category: Some("http_error".to_string()), + client_response_body: Some(json!({ + "error": { + "type": "http_error", + "message": "local execution runtime exhausted" + } + })), + ..sample_usage( + "failed", + Some(503), + Some("已尝试所有本地执行候选提供商,但没有任何候选成功完成请求(原因代码: execution_runtime_candidates_exhausted)"), + ) + }; + let candidate = sample_candidate( + RequestCandidateStatus::Failed, + Some(400), + Some(json!({ + "key_name": "key1", + "mapped_model": "qwen3.6-27b", + "upstream_response": { + "status_code": 400, + "body": { + "error": { + "type": "BadRequestError", + "param": "input_tokens", + "message": "This model's maximum context length is 131072 tokens. However, your prompt contains at least 131073 input tokens." + } + } + } + })), + ); + + let payload = build_admin_usage_detail_payload_with_candidates( + &item, + &BTreeMap::new(), + &BTreeMap::new(), + false, + false, + Some("key1"), + true, + Some(json!({"model": "qwen3.6-27b"})), + &BTreeMap::new(), + &[candidate], + ); + + assert_eq!( + payload["scheduling_failure"]["reason_label"], + "候选执行失败且已耗尽" + ); + assert_eq!( + payload["scheduling_failure"]["title"], + "唯一候选执行失败,已无可重试上游" + ); + assert_eq!(payload["scheduling_failure"]["no_upstream_attempt"], false); + assert_eq!( + payload["scheduling_failure"]["candidate_failure_summary"]["total"], + 1 + ); + assert_eq!( + payload["scheduling_failure"]["candidate_failure_summary"]["failed"], + 1 + ); + assert_eq!( + payload["scheduling_failure"]["candidate_failure_summary"]["retried"], + 1 + ); + assert_eq!( + payload["scheduling_failure"]["upstream_failure"]["status_code"], + 400 + ); + assert_eq!( + payload["scheduling_failure"]["upstream_failure"]["type"], + "BadRequestError" + ); + assert_eq!( + payload["scheduling_failure"]["upstream_failure"]["param"], + "input_tokens" + ); + assert_eq!( + payload["scheduling_failure"]["upstream_failure"]["user_message"], + "输入上下文超过模型 qwen3.6-27b 的最大长度限制。请减少输入内容、缩短历史上下文或调整客户端上下文裁剪策略。" + ); + } + #[test] fn detail_payload_preserves_legacy_body_capture_metadata_keys() { let item = StoredRequestUsageAudit { From 6959f9a614309f9302654bd5f0b5c77d10f8fe8b Mon Sep 17 00:00:00 2001 From: RWDai <27391645+RWDai@users.noreply.github.com> Date: Thu, 28 May 2026 12:56:11 +0800 Subject: [PATCH 02/23] Wire usage detail candidates through gateway --- .../observability/usage/detail_routes.rs | 12 +- .../admin/observability/usage/replay.rs | 28 ++++ .../handlers/admin/request/observability.rs | 12 ++ .../src/tests/control/admin/usage.rs | 132 ++++++++++++++++++ 4 files changed, 182 insertions(+), 2 deletions(-) diff --git a/apps/aether-gateway/src/handlers/admin/observability/usage/detail_routes.rs b/apps/aether-gateway/src/handlers/admin/observability/usage/detail_routes.rs index a2ae6fd1a..4b51a7f3f 100644 --- a/apps/aether-gateway/src/handlers/admin/observability/usage/detail_routes.rs +++ b/apps/aether-gateway/src/handlers/admin/observability/usage/detail_routes.rs @@ -5,7 +5,7 @@ use super::replay::{ admin_usage_id_from_action_path, admin_usage_id_from_detail_path, admin_usage_resolve_body_value, admin_usage_resolve_request_capture_body, admin_usage_resolve_request_capture_body_for_item, build_admin_usage_curl_response, - build_admin_usage_detail_payload, build_admin_usage_replay_response, + build_admin_usage_detail_payload_with_candidates, build_admin_usage_replay_response, }; use crate::handlers::admin::request::{AdminAppState, AdminRequestContext}; use crate::handlers::admin::shared::{attach_admin_audit_response, query_param_bool}; @@ -172,6 +172,13 @@ pub(super) async fn maybe_build_local_admin_usage_detail_response( admin_usage_api_key_names(state, std::slice::from_ref(&item)), )?; let provider_key_name = admin_usage_provider_key_name(&item, &provider_key_names); + let candidates = if state.has_request_candidate_data_reader() { + state + .read_request_candidates_by_request_id(&item.request_id) + .await? + } else { + Vec::new() + }; let mut detail_item = item.clone(); let request_body = if include_bodies { @@ -207,7 +214,7 @@ pub(super) async fn maybe_build_local_admin_usage_detail_response( // request_body 已通过 request capture 解析;其余 detached body 在上方并行加载。 } let default_headers = admin_usage_curl_headers(); - let payload = build_admin_usage_detail_payload( + let payload = build_admin_usage_detail_payload_with_candidates( &detail_item, &users_by_id, &api_key_names, @@ -217,6 +224,7 @@ pub(super) async fn maybe_build_local_admin_usage_detail_response( include_bodies, request_body, &default_headers, + &candidates, ); return Ok(Some(attach_admin_audit_response( diff --git a/apps/aether-gateway/src/handlers/admin/observability/usage/replay.rs b/apps/aether-gateway/src/handlers/admin/observability/usage/replay.rs index e468161b8..da5fb75f4 100644 --- a/apps/aether-gateway/src/handlers/admin/observability/usage/replay.rs +++ b/apps/aether-gateway/src/handlers/admin/observability/usage/replay.rs @@ -4,6 +4,7 @@ use aether_admin::observability::usage::{ admin_usage_bad_request_response, admin_usage_data_unavailable_response, ADMIN_USAGE_DATA_UNAVAILABLE_DETAIL, }; +use aether_data_contracts::repository::candidates::StoredRequestCandidate; use aether_data_contracts::repository::{ provider_catalog::StoredProviderCatalogEndpoint, usage::{StoredRequestUsageAudit, UsageBodyCaptureState, UsageBodyField}, @@ -140,6 +141,33 @@ pub(super) fn build_admin_usage_detail_payload( ) } +#[allow(clippy::too_many_arguments)] +pub(super) fn build_admin_usage_detail_payload_with_candidates( + item: &StoredRequestUsageAudit, + users_by_id: &BTreeMap, + api_key_names: &BTreeMap, + auth_user_reader_available: bool, + auth_api_key_reader_available: bool, + provider_key_name: Option<&str>, + include_bodies: bool, + request_body: Option, + default_headers: &BTreeMap, + candidates: &[StoredRequestCandidate], +) -> Value { + aether_admin::observability::usage::build_admin_usage_detail_payload_with_candidates( + item, + users_by_id, + api_key_names, + auth_user_reader_available, + auth_api_key_reader_available, + provider_key_name, + include_bodies, + request_body, + default_headers, + candidates, + ) +} + pub(super) fn build_admin_usage_replay_plan_response( item: &StoredRequestUsageAudit, target_provider: &aether_data_contracts::repository::provider_catalog::StoredProviderCatalogProvider, diff --git a/apps/aether-gateway/src/handlers/admin/request/observability.rs b/apps/aether-gateway/src/handlers/admin/request/observability.rs index d33b2fbda..b972c11a7 100644 --- a/apps/aether-gateway/src/handlers/admin/request/observability.rs +++ b/apps/aether-gateway/src/handlers/admin/request/observability.rs @@ -32,6 +32,18 @@ impl<'a> AdminAppState<'a> { self.app.read_recent_request_candidates(limit).await } + pub(crate) async fn read_request_candidates_by_request_id( + &self, + request_id: &str, + ) -> Result< + Vec, + GatewayError, + > { + self.app + .read_request_candidates_by_request_id(request_id) + .await + } + pub(crate) async fn list_usage_audits( &self, query: &aether_data_contracts::repository::usage::UsageAuditListQuery, diff --git a/apps/aether-gateway/src/tests/control/admin/usage.rs b/apps/aether-gateway/src/tests/control/admin/usage.rs index 865e28999..3f415b9d4 100644 --- a/apps/aether-gateway/src/tests/control/admin/usage.rs +++ b/apps/aether-gateway/src/tests/control/admin/usage.rs @@ -1925,6 +1925,138 @@ async fn gateway_handles_admin_usage_detail_locally_with_trusted_admin_principal upstream_handle.abort(); } +#[tokio::test] +async fn gateway_usage_detail_includes_candidate_upstream_failure() { + let (_upstream_url, upstream_hits, upstream_handle) = + start_usage_upstream("/api/admin/usage/usage-candidate-failure").await; + + let mut usage = sample_usage_row( + "usage-candidate-failure", + "req-candidate-failure", + Some("user-1"), + Some("key-1"), + Some("primary"), + "OpenAI", + "gpt-5", + "failed", + 120, + 0, + 0.0, + 0.0, + DAY_1_UNIX_SECS, + ); + usage.error_message = Some( + "已尝试所有本地执行候选提供商,但没有任何候选成功完成请求(原因代码: execution_runtime_candidates_exhausted)" + .to_string(), + ); + usage.request_metadata = Some(json!({ + "trace_id": "trace-candidate-failure", + "candidate_id": "cand-candidate-failure", + "candidate_index": 0, + "key_name": "detail-key", + "execution_path": "local_execution_runtime_miss", + "local_execution_runtime_miss_reason": "execution_runtime_candidates_exhausted" + })); + + let mut candidate = sample_request_candidate( + "cand-candidate-failure", + "req-candidate-failure", + 0, + 0, + RequestCandidateStatus::Failed, + ); + candidate.extra_data = Some(json!({ + "key_name": "detail-key", + "mapped_model": "gpt-5-target", + "upstream_response": { + "status_code": 400, + "body": { + "error": { + "type": "BadRequestError", + "param": "input_tokens", + "message": "This model's maximum context length is 131072 tokens. However, your prompt contains at least 131073 input tokens." + } + } + } + })); + + let usage_repository = Arc::new(InMemoryUsageReadRepository::seed(vec![usage])); + let request_candidate_repository = + Arc::new(InMemoryRequestCandidateRepository::seed(vec![candidate])); + let gateway = build_router_with_state( + AppState::new() + .expect("gateway should build") + .with_data_state_for_tests( + GatewayDataState::with_request_candidate_and_usage_repository_for_tests( + request_candidate_repository, + usage_repository, + ), + ), + ); + let (gateway_url, gateway_handle) = start_server(gateway).await; + + let response = admin_request(reqwest::Client::new().get(format!( + "{gateway_url}/api/admin/usage/usage-candidate-failure?include_bodies=false" + ))) + .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["id"], "usage-candidate-failure"); + assert_eq!(payload["routing"]["candidate_id"], "cand-candidate-failure"); + assert_eq!( + payload["scheduling_failure"]["reason_label"], + "候选执行失败且已耗尽" + ); + assert_eq!( + payload["scheduling_failure"]["title"], + "唯一候选执行失败,已无可重试上游" + ); + assert_eq!(payload["scheduling_failure"]["no_upstream_attempt"], false); + assert_eq!( + payload["scheduling_failure"]["candidate_failure_summary"]["total"], + 1 + ); + assert_eq!( + payload["scheduling_failure"]["candidate_failure_summary"]["failed"], + 1 + ); + assert_eq!( + payload["scheduling_failure"]["candidate_failure_summary"]["retried"], + 1 + ); + assert_eq!( + payload["scheduling_failure"]["upstream_failure"]["key_name"], + "detail-key" + ); + assert_eq!( + payload["scheduling_failure"]["upstream_failure"]["model"], + "gpt-5-target" + ); + assert_eq!( + payload["scheduling_failure"]["upstream_failure"]["status_code"], + 400 + ); + assert_eq!( + payload["scheduling_failure"]["upstream_failure"]["type"], + "BadRequestError" + ); + assert_eq!( + payload["scheduling_failure"]["upstream_failure"]["param"], + "input_tokens" + ); + assert_eq!( + payload["scheduling_failure"]["upstream_failure"]["user_message"], + "输入上下文超过模型 gpt-5-target 的最大长度限制。请减少输入内容、缩短历史上下文或调整客户端上下文裁剪策略。" + ); + assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0); + + gateway_handle.abort(); + upstream_handle.abort(); +} + #[tokio::test] async fn gateway_handles_admin_usage_detail_with_ref_backed_bodies() { let (_upstream_url, upstream_hits, upstream_handle) = From b2c70d5b5f383070b49716c994d6104ade8f744c Mon Sep 17 00:00:00 2001 From: RWDai <27391645+RWDai@users.noreply.github.com> Date: Thu, 28 May 2026 12:56:32 +0800 Subject: [PATCH 03/23] Type request failure candidate payloads --- frontend/src/api/dashboard.ts | 21 +++++++++++++++++++++ frontend/src/api/requestTrace.ts | 15 +++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/frontend/src/api/dashboard.ts b/frontend/src/api/dashboard.ts index 5850ee2dd..d679dba20 100644 --- a/frontend/src/api/dashboard.ts +++ b/frontend/src/api/dashboard.ts @@ -142,6 +142,25 @@ export interface RequestErrorFlow { summary_source?: string | null } +export interface RequestUpstreamFailure { + provider_name?: string | null + endpoint_id?: string | null + key_name?: string | null + model?: string | null + status_code?: number | null + type?: string | null + param?: string | null + message?: string | null + user_message?: string | null +} + +export interface RequestCandidateFailureSummary { + total?: number | null + failed?: number | null + skipped?: number | null + retried?: number | null +} + export interface RequestSchedulingFailure { source?: string | null reason?: string | null @@ -151,6 +170,8 @@ export interface RequestSchedulingFailure { reason_summary?: string | null status_code?: number | null no_upstream_attempt?: boolean | null + upstream_failure?: RequestUpstreamFailure | null + candidate_failure_summary?: RequestCandidateFailureSummary | null } export interface RequestDetail { diff --git a/frontend/src/api/requestTrace.ts b/frontend/src/api/requestTrace.ts index 83934763b..af64a4c96 100644 --- a/frontend/src/api/requestTrace.ts +++ b/frontend/src/api/requestTrace.ts @@ -31,6 +31,20 @@ export interface CandidateResponseBoundary { body_state?: string | null } +export interface CandidateErrorFlow { + source?: string | null + status_code?: number | null + statusCode?: number | null + type?: string | null + message?: string | null + param?: string | null + classification?: string | null + decision?: string | null + propagation?: string | null + retryable?: boolean | null + safe_to_expose?: boolean | null +} + export interface CandidateRecord { id: string request_id: string @@ -71,6 +85,7 @@ export interface CandidateRecord { image_progress?: ImageProgress | null extra_data?: Record & { upstream_response?: CandidateResponseBoundary + error_flow?: CandidateErrorFlow image_progress?: ImageProgress | null } created_at: string From 0e81cd7aaae98dc4ecbef3bec07564cfb19eb984 Mon Sep 17 00:00:00 2001 From: RWDai <27391645+RWDai@users.noreply.github.com> Date: Thu, 28 May 2026 12:56:57 +0800 Subject: [PATCH 04/23] Prioritize upstream failures in usage notices --- .../usage/utils/__tests__/errorNotice.spec.ts | 39 +++++++++++++++++++ .../src/features/usage/utils/errorNotice.ts | 39 ++++++++++++++++++- 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/frontend/src/features/usage/utils/__tests__/errorNotice.spec.ts b/frontend/src/features/usage/utils/__tests__/errorNotice.spec.ts index 63f99418f..c234b0406 100644 --- a/frontend/src/features/usage/utils/__tests__/errorNotice.spec.ts +++ b/frontend/src/features/usage/utils/__tests__/errorNotice.spec.ts @@ -91,6 +91,45 @@ describe('request failure notice', () => { }) }) + it('prefers structured upstream failure details when scheduling failure includes them', () => { + const notice = resolveRequestFailureNotice(buildRequestDetail({ + scheduling_failure: { + source: 'local_execution_runtime_miss', + reason: 'execution_runtime_candidates_exhausted', + reason_label: '候选执行失败且已耗尽', + title: '唯一候选执行失败,已无可重试上游', + message: 'gpustack 返回 HTTP 400', + reason_summary: '候选 1 个', + status_code: 503, + no_upstream_attempt: false, + upstream_failure: { + provider_name: 'gpustack', + endpoint_id: 'endpoint-1', + key_name: 'key-1', + model: 'qwen3.6-27b', + status_code: 400, + type: 'BadRequestError', + param: 'input_tokens', + message: 'This model\'s maximum context length is 131072 tokens.', + user_message: '输入上下文超过模型 qwen3.6-27b 的最大长度限制。', + }, + candidate_failure_summary: { + total: 1, + failed: 1, + skipped: 0, + retried: 1, + }, + }, + })) + + expect(notice).toEqual({ + title: '唯一候选执行失败,已无可重试上游', + message: '输入上下文超过模型 qwen3.6-27b 的最大长度限制。', + isSchedulingFailure: true, + meta: ['HTTP 400', 'BadRequestError', 'input_tokens', 'gpustack', 'qwen3.6-27b'], + }) + }) + it('does not present HTTP 200 as the cause of stream terminal failures', () => { const notice = resolveRequestFailureNotice(buildRequestDetail({ status_code: 200, diff --git a/frontend/src/features/usage/utils/errorNotice.ts b/frontend/src/features/usage/utils/errorNotice.ts index c8d1d8882..b2a64a5d0 100644 --- a/frontend/src/features/usage/utils/errorNotice.ts +++ b/frontend/src/features/usage/utils/errorNotice.ts @@ -39,6 +39,23 @@ function schedulingFailureMessage( ?? nonEmptyString(failure.reason) } +function schedulingFailureTitle(failure: RequestSchedulingFailure): string { + const summary = failure.candidate_failure_summary + if (!summary) return nonEmptyString(failure.title) ?? '本地调度失败' + const total = typeof summary.total === 'number' ? summary.total : null + const failed = typeof summary.failed === 'number' ? summary.failed : 0 + const skipped = typeof summary.skipped === 'number' ? summary.skipped : 0 + if (total === 0) return nonEmptyString(failure.title) ?? '没有找到可调度候选' + if (failed === 1 && total === 1) return nonEmptyString(failure.title) ?? '唯一候选执行失败,已无可重试上游' + if (total != null && failed > 0 && failed + skipped >= total) { + return nonEmptyString(failure.title) ?? '所有候选已完成重试,但全部执行失败' + } + if (total != null && total > 0 && skipped === total) { + return nonEmptyString(failure.title) ?? '所有候选都被调度规则跳过' + } + return nonEmptyString(failure.title) ?? '本地调度失败' +} + export function resolveRequestFailureNotice(detail: RequestDetail | null | undefined): RequestFailureNotice | null { if (!detail) return null @@ -50,10 +67,30 @@ export function resolveRequestFailureNotice(detail: RequestDetail | null | undef const schedulingFailure = detail.scheduling_failure ?? null if (schedulingFailure) { + const upstreamFailure = schedulingFailure.upstream_failure ?? null + const upstreamMessage = nonEmptyString(upstreamFailure?.user_message) + ?? nonEmptyString(schedulingFailure.message) + ?? nonEmptyString(upstreamFailure?.message) + if (upstreamFailure && upstreamMessage) { + return { + title: schedulingFailureTitle(schedulingFailure), + message: upstreamMessage, + isSchedulingFailure: true, + meta: uniqueMeta([ + formatHttpStatus(upstreamFailure.status_code ?? schedulingFailure.status_code ?? detail.status_code), + nonEmptyString(upstreamFailure.type), + nonEmptyString(upstreamFailure.param), + nonEmptyString(upstreamFailure.provider_name), + nonEmptyString(upstreamFailure.model), + schedulingFailure.no_upstream_attempt ? '未进入上游执行' : null, + ]), + } + } + const message = schedulingFailureMessage(schedulingFailure, fallbackDomain, fallbackErrorMessage) if (message) { return { - title: nonEmptyString(schedulingFailure.title) ?? '本地调度失败', + title: schedulingFailureTitle(schedulingFailure), message, isSchedulingFailure: true, meta: uniqueMeta([ From cd3f8a2e148ce2862c7c8b8de89794acf34a9e86 Mon Sep 17 00:00:00 2001 From: RWDai <27391645+RWDai@users.noreply.github.com> Date: Thu, 28 May 2026 12:57:22 +0800 Subject: [PATCH 05/23] Show upstream candidate errors in request timeline --- .../components/HorizontalRequestTimeline.vue | 126 +++++++++++++++++- .../HorizontalRequestTimeline.spec.ts | 52 +++++++- 2 files changed, 168 insertions(+), 10 deletions(-) diff --git a/frontend/src/features/usage/components/HorizontalRequestTimeline.vue b/frontend/src/features/usage/components/HorizontalRequestTimeline.vue index f122b2985..a7397d670 100644 --- a/frontend/src/features/usage/components/HorizontalRequestTimeline.vue +++ b/frontend/src/features/usage/components/HorizontalRequestTimeline.vue @@ -483,6 +483,19 @@ > {{ currentAttemptRequestError.message }} +
+
+ {{ field.label }} + {{ field.value }} +
+
, key: string): number | un return undefined } +const readNestedValue = (value: unknown, ...path: string[]): unknown => { + let current: unknown = value + for (const key of path) { + const object = extractObject(current) + if (!object) return undefined + current = object[key] + } + return current +} + +const readNestedString = (value: unknown, ...path: string[]): string | undefined => { + const nested = readNestedValue(value, ...path) + return typeof nested === 'string' && nested.trim() ? nested.trim() : undefined +} + const hasRenderableValue = (value: unknown): boolean => { if (value == null) return false if (typeof value === 'string') return value.trim().length > 0 @@ -1421,9 +1449,45 @@ const formatAttemptErrorMessage = (message: string, statusCode?: number): string return normalized } +const buildCurrentAttemptErrorFields = ( + attempt: CandidateRecord, + upstreamResponse: Record | null, + upstreamBody: unknown, + errorFlow: Record | null, + errorType?: string, + errorParam?: string, + statusCode?: number, +): Array<{ label: string, value: string }> => { + const providerName = attempt.provider_name + const keyName = attempt.key_name || attempt.key_account_label || attempt.key_preview + const fields = [ + providerName ? { label: '供应商', value: providerName } : null, + keyName ? { label: 'Key', value: keyName } : null, + statusCode != null ? { label: 'HTTP 状态', value: String(statusCode) } : null, + errorType ? { label: '错误类型', value: errorType } : null, + errorParam ? { label: '错误参数', value: errorParam } : null, + readStringField(upstreamResponse ?? {}, 'body_ref') + ? { label: '响应 Body', value: readStringField(upstreamResponse ?? {}, 'body_ref') as string } + : null, + readStringField(errorFlow ?? {}, 'source') ? { label: '错误来源', value: readStringField(errorFlow ?? {}, 'source') as string } : null, + ].filter((field): field is { label: string, value: string } => Boolean(field)) + + const bodyState = readStringField(upstreamResponse ?? {}, 'body_state') + ?? readStringField(upstreamResponse ?? {}, 'bodyState') + if (bodyState && bodyState.toLowerCase() !== 'none') { + fields.push({ label: 'Body 状态', value: bodyState }) + } + const bodyErrorCode = readNestedValue(upstreamBody, 'error', 'code') + if (bodyErrorCode != null && typeof bodyErrorCode !== 'object') { + fields.push({ label: '错误代码', value: String(bodyErrorCode) }) + } + return fields +} + const currentAttemptRequestError = computed<{ message: string statusCode?: number + fields: Array<{ label: string, value: string }> upstreamResponse: Record | null } | null>(() => { const attempt = currentAttempt.value @@ -1432,6 +1496,7 @@ const currentAttemptRequestError = computed<{ const extra = extractObject(attempt.extra_data) const upstreamResponse = extractObject(extra?.upstream_response) const errorFlow = extractObject(extra?.error_flow) + const upstreamBody = upstreamResponse?.body const statusCode = readNumberField(upstreamResponse ?? {}, 'status_code') ?? readNumberField(upstreamResponse ?? {}, 'statusCode') ?? readNumberField(errorFlow ?? {}, 'status_code') @@ -1440,19 +1505,36 @@ const currentAttemptRequestError = computed<{ const flowMessage = errorFlow ? readStringField(errorFlow, 'message') : '' + const upstreamErrorMessage = readNestedString(upstreamBody, 'error', 'message') + ?? readNestedString(upstreamBody, 'message') + ?? readNestedString(upstreamBody, 'detail') + const upstreamErrorType = readNestedString(upstreamBody, 'error', 'type') + ?? readNestedString(upstreamBody, 'type') + ?? readStringField(errorFlow ?? {}, 'type') + ?? (typeof attempt.error_type === 'string' && attempt.error_type.trim() ? attempt.error_type.trim() : undefined) + const upstreamErrorParam = readNestedString(upstreamBody, 'error', 'param') + ?? readNestedString(upstreamBody, 'param') + ?? readStringField(errorFlow ?? {}, 'param') const fallbackMessage = typeof attempt.error_message === 'string' && attempt.error_message.trim() ? attempt.error_message.trim() : '' - const fallbackType = typeof attempt.error_type === 'string' && attempt.error_type.trim() - ? attempt.error_type.trim() - : '' - const message = formatAttemptErrorMessage(flowMessage || fallbackMessage, statusCode) || fallbackType + const message = formatAttemptErrorMessage(flowMessage || upstreamErrorMessage || fallbackMessage, statusCode) || upstreamErrorType || '' const upstreamResponseDisplay = normalizeUpstreamResponseDisplay(extra?.upstream_response) - if (!message && statusCode == null && !upstreamResponseDisplay) return null + const fields = buildCurrentAttemptErrorFields( + attempt, + upstreamResponse, + upstreamBody, + errorFlow, + upstreamErrorType, + upstreamErrorParam, + statusCode, + ) + if (!message && statusCode == null && !upstreamResponseDisplay && fields.length === 0) return null return { - message: upstreamResponseDisplay ? '' : (message || '未知错误'), + message: message || '未知错误', statusCode, + fields, upstreamResponse: upstreamResponseDisplay, } }) @@ -2783,6 +2865,38 @@ function getDisplayStatus(attempt: CandidateRecord | null | undefined): string { word-break: break-word; } +.error-fields { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; + margin-top: 0.625rem; +} + +.error-field { + display: inline-flex; + align-items: center; + gap: 0.25rem; + max-width: 100%; + padding: 0.15rem 0.45rem; + border: 1px solid #ef44442f; + border-radius: 999px; + background: hsl(var(--background) / 0.72); + font-size: 0.72rem; +} + +.error-field-label { + color: hsl(var(--muted-foreground)); +} + +.error-field-value { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: hsl(var(--foreground)); + font-family: ui-monospace, monospace; +} + .error-json { margin-top: 0.75rem; } diff --git a/frontend/src/features/usage/components/__tests__/HorizontalRequestTimeline.spec.ts b/frontend/src/features/usage/components/__tests__/HorizontalRequestTimeline.spec.ts index a71dea634..4ac806378 100644 --- a/frontend/src/features/usage/components/__tests__/HorizontalRequestTimeline.spec.ts +++ b/frontend/src/features/usage/components/__tests__/HorizontalRequestTimeline.spec.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { createApp, defineComponent, h, nextTick, type App } from 'vue' +import { createApp, nextTick, type App } from 'vue' import type { CandidateRecord, RequestTrace } from '@/api/requestTrace' import HorizontalRequestTimeline from '../HorizontalRequestTimeline.vue' @@ -373,7 +373,7 @@ describe('HorizontalRequestTimeline', () => { }) await nextTick() - const lastCall = onTraceState.mock.calls.at(-1)?.[0] + const lastCall = onTraceState.mock.calls[onTraceState.mock.calls.length - 1]?.[0] expect(lastCall).toMatchObject({ finalStatus: 'streaming', }) @@ -429,7 +429,7 @@ describe('HorizontalRequestTimeline', () => { expect(requestPathCode?.textContent).toContain('/v1/images/generations') }) - it('shows upstream response JSON inside the error block on trace nodes', async () => { + it('shows structured upstream response details inside the error block on trace nodes', async () => { const trace = buildTrace([ buildCandidate({ id: 'cand-upstream-response', @@ -468,7 +468,12 @@ describe('HorizontalRequestTimeline', () => { expect(root.textContent).toContain('错误信息') expect(root.textContent).toContain('HTTP 302') - expect(root.textContent).not.toContain('上游返回非成功状态 302') + expect(root.textContent).toContain('上游返回非成功状态 302') + expect(root.textContent).toContain('供应商') + expect(root.textContent).toContain('Provider Upstream') + expect(root.textContent).toContain('Key') + expect(root.textContent).toContain('Upstream Key') + expect(root.textContent).toContain('HTTP 状态') expect(root.querySelector('.error-block .error-json')?.textContent).toContain('"status_code":302') expect(root.querySelector('.error-block .error-json')?.textContent).toContain('"headers"') expect(root.textContent).not.toContain('上游真实响应') @@ -482,6 +487,45 @@ describe('HorizontalRequestTimeline', () => { expect(root.textContent).not.toContain('该错误被标记为敏感上游错误') }) + it('shows upstream body error message and typed fields by default', async () => { + const trace = buildTrace([ + buildCandidate({ + id: 'cand-upstream-body-error', + provider_id: 'provider-body-error', + provider_name: 'GPUStack', + key_id: 'key-body-error', + key_name: 'key1', + candidate_index: 0, + status: 'failed', + status_code: 400, + extra_data: { + upstream_response: { + status_code: 400, + body: { + error: { + type: 'BadRequestError', + param: 'input_tokens', + message: 'This model\'s maximum context length is 131072 tokens.', + }, + }, + }, + }, + }), + ]) + + const root = mountTimeline(trace) + await nextTick() + + expect(root.textContent).toContain('This model\'s maximum context length is 131072 tokens.') + expect(root.textContent).toContain('GPUStack') + expect(root.textContent).toContain('key1') + expect(root.textContent).toContain('错误类型') + expect(root.textContent).toContain('BadRequestError') + expect(root.textContent).toContain('错误参数') + expect(root.textContent).toContain('input_tokens') + expect(root.querySelector('.error-block .error-json')?.textContent).toContain('"status_code":400') + }) + it('keeps the failure message when upstream response only records an empty body state', async () => { const trace = buildTrace([ buildCandidate({ From d2d28c30d9ca6782ef7422dfbe8d5cf3965833f0 Mon Sep 17 00:00:00 2001 From: RWDai <27391645+RWDai@users.noreply.github.com> Date: Thu, 28 May 2026 16:53:00 +0800 Subject: [PATCH 06/23] Use bearer auth for OpenAI embedding passthrough --- .../provider/family/request/policy.rs | 4 + .../provider/family/request/prepare.rs | 15 +++- .../src/same_format_provider/mod.rs | 74 ++++++++++++++++++- 3 files changed, 87 insertions(+), 6 deletions(-) diff --git a/apps/aether-gateway/src/ai_serving/planner/passthrough/provider/family/request/policy.rs b/apps/aether-gateway/src/ai_serving/planner/passthrough/provider/family/request/policy.rs index 57444495a..ba709cf42 100644 --- a/apps/aether-gateway/src/ai_serving/planner/passthrough/provider/family/request/policy.rs +++ b/apps/aether-gateway/src/ai_serving/planner/passthrough/provider/family/request/policy.rs @@ -60,11 +60,13 @@ pub(super) fn should_try_same_format_provider_oauth_auth( behavior: &SameFormatProviderRequestBehavior, transport: &GatewayProviderTransportSnapshot, family: LocalSameFormatProviderFamily, + provider_api_format: &str, ) -> bool { should_try_same_format_provider_oauth_auth_impl( behavior, transport, same_format_provider_family(family), + provider_api_format, ) } @@ -72,11 +74,13 @@ pub(super) fn resolve_same_format_provider_direct_auth( behavior: &SameFormatProviderRequestBehavior, transport: &GatewayProviderTransportSnapshot, family: LocalSameFormatProviderFamily, + provider_api_format: &str, ) -> Option<(String, String)> { resolve_same_format_provider_direct_auth_impl( behavior, transport, same_format_provider_family(family), + provider_api_format, ) } diff --git a/apps/aether-gateway/src/ai_serving/planner/passthrough/provider/family/request/prepare.rs b/apps/aether-gateway/src/ai_serving/planner/passthrough/provider/family/request/prepare.rs index c75e1923a..5f993ca65 100644 --- a/apps/aether-gateway/src/ai_serving/planner/passthrough/provider/family/request/prepare.rs +++ b/apps/aether-gateway/src/ai_serving/planner/passthrough/provider/family/request/prepare.rs @@ -92,8 +92,12 @@ pub(super) async fn prepare_local_same_format_provider_candidate( } else { None }; - let should_try_oauth_auth = - should_try_same_format_provider_oauth_auth(&behavior, &transport, spec.family); + let should_try_oauth_auth = should_try_same_format_provider_oauth_auth( + &behavior, + &transport, + spec.family, + provider_api_format, + ); let oauth_auth = if should_try_oauth_auth { resolve_candidate_oauth_auth( planner_state, @@ -118,7 +122,12 @@ pub(super) async fn prepare_local_same_format_provider_candidate( { Some((name.clone(), value.clone())) } else { - resolve_same_format_provider_direct_auth(&behavior, &transport, spec.family) + resolve_same_format_provider_direct_auth( + &behavior, + &transport, + spec.family, + provider_api_format, + ) }; let (auth_header, auth_value) = match auth { Some((name, value)) => (Some(name), Some(value)), diff --git a/crates/aether-provider-transport/src/same_format_provider/mod.rs b/crates/aether-provider-transport/src/same_format_provider/mod.rs index c477096f8..4a9c7796d 100644 --- a/crates/aether-provider-transport/src/same_format_provider/mod.rs +++ b/crates/aether-provider-transport/src/same_format_provider/mod.rs @@ -5,7 +5,7 @@ use serde_json::Value; use crate::antigravity::is_antigravity_provider_transport; use crate::auth::{ build_complete_passthrough_headers, build_complete_passthrough_headers_with_auth, - resolve_local_gemini_auth, resolve_local_standard_auth, + resolve_local_gemini_auth, resolve_local_openai_bearer_auth, resolve_local_standard_auth, }; use crate::claude_code::build_claude_code_passthrough_headers; use crate::claude_code::local_claude_code_transport_unsupported_reason_with_network; @@ -438,10 +438,13 @@ pub fn should_try_same_format_provider_oauth_auth( behavior: &SameFormatProviderRequestBehavior, transport: &GatewayProviderTransportSnapshot, family: SameFormatProviderFamily, + provider_api_format: &str, ) -> bool { + let provider_api_format = aether_ai_formats::normalize_api_format_alias(provider_api_format); behavior.is_kiro || matches!(family, SameFormatProviderFamily::Standard) - && resolve_local_standard_auth(transport).is_none() + && resolve_same_format_standard_direct_auth(transport, provider_api_format.as_str()) + .is_none() || matches!(family, SameFormatProviderFamily::Gemini) && behavior.is_vertex && is_vertex_service_account_transport_context(transport) @@ -454,6 +457,7 @@ pub fn resolve_same_format_provider_direct_auth( behavior: &SameFormatProviderRequestBehavior, transport: &GatewayProviderTransportSnapshot, family: SameFormatProviderFamily, + provider_api_format: &str, ) -> Option<(String, String)> { if is_grok_provider_transport(transport) && matches!(family, SameFormatProviderFamily::Standard) { @@ -463,12 +467,25 @@ pub fn resolve_same_format_provider_direct_auth( None } else { match family { - SameFormatProviderFamily::Standard => resolve_local_standard_auth(transport), + SameFormatProviderFamily::Standard => { + resolve_same_format_standard_direct_auth(transport, provider_api_format) + } SameFormatProviderFamily::Gemini => resolve_local_gemini_auth(transport), } } } +fn resolve_same_format_standard_direct_auth( + transport: &GatewayProviderTransportSnapshot, + provider_api_format: &str, +) -> Option<(String, String)> { + if aether_ai_formats::api_format_alias_matches(provider_api_format, "openai:embedding") { + resolve_local_openai_bearer_auth(transport) + } else { + resolve_local_standard_auth(transport) + } +} + #[cfg(test)] mod tests { use std::collections::BTreeMap; @@ -750,6 +767,57 @@ mod tests { &behavior, &transport, SameFormatProviderFamily::Standard, + "openai:chat", + ), + Some(("x-api-key".to_string(), "secret".to_string())) + ); + } + + #[test] + fn resolves_openai_embedding_direct_auth_with_bearer_header() { + let mut transport = sample_transport("custom"); + transport.endpoint.api_format = "openai:embedding".to_string(); + transport.key.auth_type = "api_key".to_string(); + let behavior = classify_same_format_provider_request_behavior( + &transport, + SameFormatProviderRequestBehaviorParams { + require_streaming: false, + provider_api_format: "openai:embedding", + report_kind: "openai_embedding_sync_success", + }, + ); + + assert_eq!( + resolve_same_format_provider_direct_auth( + &behavior, + &transport, + SameFormatProviderFamily::Standard, + "openai:embedding", + ), + Some(("authorization".to_string(), "Bearer secret".to_string())) + ); + } + + #[test] + fn keeps_claude_same_format_api_key_on_x_api_key_header() { + let mut transport = sample_transport("custom"); + transport.endpoint.api_format = "claude:messages".to_string(); + transport.key.auth_type = "api_key".to_string(); + let behavior = classify_same_format_provider_request_behavior( + &transport, + SameFormatProviderRequestBehaviorParams { + require_streaming: false, + provider_api_format: "claude:messages", + report_kind: "claude_chat_sync_success", + }, + ); + + assert_eq!( + resolve_same_format_provider_direct_auth( + &behavior, + &transport, + SameFormatProviderFamily::Standard, + "claude:messages", ), Some(("x-api-key".to_string(), "secret".to_string())) ); From bca4746cb465e7d302e3a27c9c5dd05b41ff27e4 Mon Sep 17 00:00:00 2001 From: RWDai <27391645+RWDai@users.noreply.github.com> Date: Thu, 28 May 2026 19:30:34 +0800 Subject: [PATCH 07/23] Normalize usage failure messages --- .../usage/utils/__tests__/errorNotice.spec.ts | 11 ++++ .../src/features/usage/utils/errorNotice.ts | 21 +++--- .../features/usage/utils/failureDisplay.ts | 64 +++++++++++++++++++ 3 files changed, 88 insertions(+), 8 deletions(-) create mode 100644 frontend/src/features/usage/utils/failureDisplay.ts diff --git a/frontend/src/features/usage/utils/__tests__/errorNotice.spec.ts b/frontend/src/features/usage/utils/__tests__/errorNotice.spec.ts index c234b0406..55ac6480e 100644 --- a/frontend/src/features/usage/utils/__tests__/errorNotice.spec.ts +++ b/frontend/src/features/usage/utils/__tests__/errorNotice.spec.ts @@ -165,4 +165,15 @@ describe('request failure notice', () => { expect(notice).toBeNull() }) + + it('normalizes equivalent upstream timeout messages to Chinese wording', () => { + const notice = resolveRequestFailureNotice(buildRequestDetail({ + status_code: 503, + status: 'failed', + error_message: 'UpstreamRequest("provider stream first byte timeout after 10000 ms")', + })) + + expect(notice?.message).toBe('请求超时(10秒)') + expect(notice?.meta).toEqual([]) + }) }) diff --git a/frontend/src/features/usage/utils/errorNotice.ts b/frontend/src/features/usage/utils/errorNotice.ts index b2a64a5d0..1827b4eb8 100644 --- a/frontend/src/features/usage/utils/errorNotice.ts +++ b/frontend/src/features/usage/utils/errorNotice.ts @@ -1,4 +1,5 @@ import type { RequestDetail, RequestErrorDomain, RequestSchedulingFailure } from '@/api/dashboard' +import { normalizeFailureMessage } from './failureDisplay' export interface RequestFailureNotice { title: string @@ -17,6 +18,10 @@ function normalizeErrorDomain(domain: RequestErrorDomain | null | undefined): Re return domain ?? null } +function normalizeDomainMessage(domain: RequestErrorDomain | null | undefined): string | null { + return normalizeFailureMessage(domain?.message ?? null, domain?.status_code ?? null) +} + function formatHttpStatus(statusCode: number | null | undefined): string | null { return typeof statusCode === 'number' && (statusCode < 200 || statusCode >= 300) ? `HTTP ${statusCode}` @@ -32,8 +37,8 @@ function schedulingFailureMessage( fallbackDomain: RequestErrorDomain | null, fallbackErrorMessage: string | null, ): string | null { - return nonEmptyString(failure.message) - ?? nonEmptyString(fallbackDomain?.message) + return normalizeFailureMessage(failure.message, failure.status_code) + ?? normalizeDomainMessage(fallbackDomain) ?? fallbackErrorMessage ?? nonEmptyString(failure.reason_label) ?? nonEmptyString(failure.reason) @@ -63,14 +68,14 @@ export function resolveRequestFailureNotice(detail: RequestDetail | null | undef ?? normalizeErrorDomain(detail.client_error) ?? normalizeErrorDomain(detail.upstream_error) ?? normalizeErrorDomain(detail.request_error) - const fallbackErrorMessage = nonEmptyString(detail.error_message ?? null) + const fallbackErrorMessage = normalizeFailureMessage(detail.error_message ?? null, detail.status_code) const schedulingFailure = detail.scheduling_failure ?? null if (schedulingFailure) { const upstreamFailure = schedulingFailure.upstream_failure ?? null - const upstreamMessage = nonEmptyString(upstreamFailure?.user_message) - ?? nonEmptyString(schedulingFailure.message) - ?? nonEmptyString(upstreamFailure?.message) + const upstreamMessage = normalizeFailureMessage(upstreamFailure?.user_message, upstreamFailure?.status_code) + ?? normalizeFailureMessage(schedulingFailure.message, schedulingFailure.status_code) + ?? normalizeFailureMessage(upstreamFailure?.message, upstreamFailure?.status_code) if (upstreamFailure && upstreamMessage) { return { title: schedulingFailureTitle(schedulingFailure), @@ -105,7 +110,7 @@ export function resolveRequestFailureNotice(detail: RequestDetail | null | undef } const domain = fallbackDomain - const message = nonEmptyString(domain?.message) ?? fallbackErrorMessage + const message = normalizeDomainMessage(domain) ?? fallbackErrorMessage if (!message) return null return { @@ -113,7 +118,7 @@ export function resolveRequestFailureNotice(detail: RequestDetail | null | undef message, isSchedulingFailure: false, meta: uniqueMeta([ - formatHttpStatus(domain?.status_code ?? detail.status_code), + formatHttpStatus(domain ? domain.status_code ?? detail.status_code : undefined), nonEmptyString(domain?.type), nonEmptyString(domain?.source), ]), diff --git a/frontend/src/features/usage/utils/failureDisplay.ts b/frontend/src/features/usage/utils/failureDisplay.ts new file mode 100644 index 000000000..7c435a045 --- /dev/null +++ b/frontend/src/features/usage/utils/failureDisplay.ts @@ -0,0 +1,64 @@ +function nonEmptyString(value: string | null | undefined): string | null { + const trimmed = value?.trim() + return trimmed ? trimmed : null +} + +function formatSecondsFromMsText(value: string): string { + const milliseconds = Number(value) + if (!Number.isFinite(milliseconds) || milliseconds <= 0) return '10秒' + const seconds = milliseconds / 1000 + return Number.isInteger(seconds) ? `${seconds}秒` : `${seconds.toFixed(1)}秒` +} + +function unwrapRustErrorMessage(message: string): string { + const upstreamRequest = message.match(/^UpstreamRequest\("([\s\S]*)"\)$/) + if (!upstreamRequest) return message + return upstreamRequest[1] + .replace(/\\"/g, '"') + .replace(/\\n/g, '\n') + .trim() +} + +export function normalizeFailureMessage(message: string | null | undefined, statusCode?: number | null): string | null { + const normalized = nonEmptyString(message) + const unwrapped = normalized ? unwrapRustErrorMessage(normalized) : null + if (!unwrapped) return null + + const firstByteTimeout = unwrapped.match(/provider stream first byte timeout after\s+(\d+)\s*ms/i) + ?? unwrapped.match(/stream first byte timeout after\s+(\d+)\s*ms/i) + if (firstByteTimeout) { + return `请求超时(${formatSecondsFromMsText(firstByteTimeout[1])})` + } + + const genericTimeout = unwrapped.match(/(?:request|upstream request|operation)\s+timeout(?:ed)?\s+after\s+(\d+)\s*ms/i) + ?? unwrapped.match(/timeout(?:ed)?\s+after\s+(\d+)\s*ms/i) + if (genericTimeout) { + return `请求超时(${formatSecondsFromMsText(genericTimeout[1])})` + } + + if (/stream first byte timeout/i.test(unwrapped)) { + return '请求超时(等待上游首字超时)' + } + + if (/execution runtime (stream )?returned non-success status \d+/i.test(unwrapped)) { + return statusCode != null ? `上游返回非成功状态 ${statusCode}` : '上游返回非成功状态' + } + + const chineseTimeout = unwrapped.match(/^请求超时[((]\s*(\d+(?:\.\d+)?)\s*秒\s*[))]$/) + if (chineseTimeout) { + return `请求超时(${chineseTimeout[1]}秒)` + } + + return unwrapped +} + +export function isHttpLikeErrorCode(value: unknown): boolean { + if (typeof value === 'number') { + return Number.isInteger(value) && value >= 100 && value <= 599 + } + if (typeof value === 'string') { + const trimmed = value.trim() + return /^\d{3}$/.test(trimmed) && Number(trimmed) >= 100 && Number(trimmed) <= 599 + } + return false +} From 4ba29d49af7a632315c090ddb3bf97dc38f9c6cb Mon Sep 17 00:00:00 2001 From: RWDai <27391645+RWDai@users.noreply.github.com> Date: Thu, 28 May 2026 19:31:08 +0800 Subject: [PATCH 08/23] Mark active usage errors as failed --- .../__tests__/UsageRecordsTable.spec.ts | 13 +++++++++++ .../usage/utils/__tests__/status.spec.ts | 22 +++++++++++++++++-- frontend/src/features/usage/utils/status.ts | 14 +++++++----- 3 files changed, 42 insertions(+), 7 deletions(-) diff --git a/frontend/src/features/usage/components/__tests__/UsageRecordsTable.spec.ts b/frontend/src/features/usage/components/__tests__/UsageRecordsTable.spec.ts index 9b482995c..89e28ddfa 100644 --- a/frontend/src/features/usage/components/__tests__/UsageRecordsTable.spec.ts +++ b/frontend/src/features/usage/components/__tests__/UsageRecordsTable.spec.ts @@ -270,6 +270,19 @@ describe('UsageRecordsTable', () => { expect(root.textContent).not.toContain('等待中') }) + it('shows failed instead of streaming when an active row has a timeout error message', () => { + const root = mountUsageRecordsTable([buildRecord({ + status: 'streaming', + status_code: 200, + error_message: 'UpstreamRequest("provider stream first byte timeout after 10000 ms")', + response_time_ms: null, + first_byte_time_ms: 500, + })]) + + expect(root.textContent).toContain('失败') + expect(root.textContent).not.toContain('传输中') + }) + it('renders output TPS in the non-admin usage table', () => { const root = mountUsageRecordsTable([buildRecord()], { isAdmin: false }) diff --git a/frontend/src/features/usage/utils/__tests__/status.spec.ts b/frontend/src/features/usage/utils/__tests__/status.spec.ts index 83f5a6daf..34bb8affc 100644 --- a/frontend/src/features/usage/utils/__tests__/status.spec.ts +++ b/frontend/src/features/usage/utils/__tests__/status.spec.ts @@ -91,6 +91,18 @@ describe('usage status helpers', () => { expect(isUsageRecordFailed(record)).toBe(true) }) + it('treats active stream records with 2xx transport and an error message as failed', () => { + const record = buildUsageRecord({ + status: 'streaming', + status_code: 200, + error_message: 'UpstreamRequest("provider stream first byte timeout after 10000 ms")', + first_byte_time_ms: 120, + }) + + expect(resolveDisplayRequestStatus(record)).toBe('failed') + expect(isUsageRecordFailed(record)).toBe(true) + }) + it('treats failed image progress as failed before the usage record finalizes', () => { const record = buildUsageRecord({ status: 'streaming', @@ -141,7 +153,7 @@ describe('usage status helpers', () => { })).toBe('failed') }) - it('keeps active request lifecycle status authoritative over detail status code inference', () => { + it('keeps active request lifecycle status unless terminal failure signals are present', () => { expect(resolveTimelineFinalStatus({ requestStatus: 'streaming', statusCode: 200, @@ -151,7 +163,13 @@ describe('usage status helpers', () => { requestStatus: 'streaming', statusCode: 503, traceFinalStatus: 'failed', - })).toBe('streaming') + })).toBe('failed') + + expect(resolveTimelineFinalStatus({ + requestStatus: 'streaming', + statusCode: 200, + traceFinalStatus: 'failed', + })).toBe('failed') expect(resolveTimelineFinalStatus({ requestStatus: 'pending', diff --git a/frontend/src/features/usage/utils/status.ts b/frontend/src/features/usage/utils/status.ts index c4355c4c1..cd0951264 100644 --- a/frontend/src/features/usage/utils/status.ts +++ b/frontend/src/features/usage/utils/status.ts @@ -180,7 +180,7 @@ export function isUsageRecordFailed(record: UsageFailureSignal & Pick Date: Thu, 28 May 2026 19:31:37 +0800 Subject: [PATCH 09/23] Reduce noisy timeline failure details --- .../components/HorizontalRequestTimeline.vue | 50 ++++++---- .../HorizontalRequestTimeline.spec.ts | 99 ++++++++++++++++++- 2 files changed, 126 insertions(+), 23 deletions(-) diff --git a/frontend/src/features/usage/components/HorizontalRequestTimeline.vue b/frontend/src/features/usage/components/HorizontalRequestTimeline.vue index a7397d670..bfb8646b7 100644 --- a/frontend/src/features/usage/components/HorizontalRequestTimeline.vue +++ b/frontend/src/features/usage/components/HorizontalRequestTimeline.vue @@ -464,7 +464,7 @@
@@ -559,6 +559,7 @@ import { formatTokens } from '@/utils/format' import { formatApiFormat } from '@/api/endpoints/types/api-format' import { useDarkMode } from '@/composables/useDarkMode' import { resolveTimelineFinalStatus } from '../utils/status' +import { isHttpLikeErrorCode, normalizeFailureMessage } from '../utils/failureDisplay' import { buildPoolGroupVisibleAttempts, buildPoolParticipatedCandidates, @@ -1246,7 +1247,6 @@ const normalizeUpstreamResponseDisplay = (value: unknown): Record { - const normalized = message.trim() - if (!normalized) return '' - if (/execution runtime (stream )?returned non-success status \d+/i.test(normalized)) { - return statusCode != null ? `上游返回非成功状态 ${statusCode}` : '上游返回非成功状态' - } - return normalized + return normalizeFailureMessage(message, statusCode) ?? '' } const buildCurrentAttemptErrorFields = ( @@ -1460,25 +1453,22 @@ const buildCurrentAttemptErrorFields = ( ): Array<{ label: string, value: string }> => { const providerName = attempt.provider_name const keyName = attempt.key_name || attempt.key_account_label || attempt.key_preview + const bodyState = readStringField(upstreamResponse ?? {}, 'body_state') + ?? readStringField(upstreamResponse ?? {}, 'bodyState') + const meaningfulBodyState = bodyState && bodyState.toLowerCase() !== 'none' const fields = [ providerName ? { label: '供应商', value: providerName } : null, keyName ? { label: 'Key', value: keyName } : null, - statusCode != null ? { label: 'HTTP 状态', value: String(statusCode) } : null, errorType ? { label: '错误类型', value: errorType } : null, errorParam ? { label: '错误参数', value: errorParam } : null, - readStringField(upstreamResponse ?? {}, 'body_ref') - ? { label: '响应 Body', value: readStringField(upstreamResponse ?? {}, 'body_ref') as string } - : null, readStringField(errorFlow ?? {}, 'source') ? { label: '错误来源', value: readStringField(errorFlow ?? {}, 'source') as string } : null, ].filter((field): field is { label: string, value: string } => Boolean(field)) - const bodyState = readStringField(upstreamResponse ?? {}, 'body_state') - ?? readStringField(upstreamResponse ?? {}, 'bodyState') - if (bodyState && bodyState.toLowerCase() !== 'none') { + if (meaningfulBodyState) { fields.push({ label: 'Body 状态', value: bodyState }) } const bodyErrorCode = readNestedValue(upstreamBody, 'error', 'code') - if (bodyErrorCode != null && typeof bodyErrorCode !== 'object') { + if (bodyErrorCode != null && typeof bodyErrorCode !== 'object' && !isHttpLikeErrorCode(bodyErrorCode)) { fields.push({ label: '错误代码', value: String(bodyErrorCode) }) } return fields @@ -1491,7 +1481,7 @@ const currentAttemptRequestError = computed<{ upstreamResponse: Record | null } | null>(() => { const attempt = currentAttempt.value - if (!attempt || attempt.status !== 'failed') return null + if (!attempt || getDisplayStatus(attempt) !== 'failed') return null const extra = extractObject(attempt.extra_data) const upstreamResponse = extractObject(extra?.upstream_response) @@ -1906,11 +1896,31 @@ const getStatusColorClass = (status: string) => { return classes[status] || 'status-available' } -// 展示状态:进行中态优先(包括 started 但未 finished 的中间态),再按 HTTP 状态码兜底 +// 展示状态:错误信号优先,其次进行中态,再按 HTTP 状态码兜底 +function readImageProgressPhase(value: unknown): string { + if (!value || typeof value !== 'object' || Array.isArray(value)) return '' + const phase = (value as { phase?: unknown }).phase + return typeof phase === 'string' ? phase.trim().toLowerCase() : '' +} + +function hasCandidateFailureSignal(attempt: CandidateRecord): boolean { + if (typeof attempt.status_code === 'number' && attempt.status_code >= 300) return true + if (typeof attempt.error_message === 'string' && attempt.error_message.trim()) return true + const extraData = attempt.extra_data + const extraProgress = extraData && typeof extraData === 'object' && !Array.isArray(extraData) + ? (extraData as { image_progress?: unknown }).image_progress + : null + return readImageProgressPhase(attempt.image_progress) === 'failed' || + readImageProgressPhase(extraProgress) === 'failed' +} + function getDisplayStatus(attempt: CandidateRecord | null | undefined): string { if (!attempt) return 'available' const code = attempt.status_code const isTerminalSuccessCode = typeof code === 'number' && code >= 200 && code < 300 + if ((attempt.status === 'pending' || attempt.status === 'streaming') && hasCandidateFailureSignal(attempt)) { + return 'failed' + } if (attempt.status === 'success') { if (typeof code === 'number' && !isTerminalSuccessCode) { diff --git a/frontend/src/features/usage/components/__tests__/HorizontalRequestTimeline.spec.ts b/frontend/src/features/usage/components/__tests__/HorizontalRequestTimeline.spec.ts index 4ac806378..c9559017a 100644 --- a/frontend/src/features/usage/components/__tests__/HorizontalRequestTimeline.spec.ts +++ b/frontend/src/features/usage/components/__tests__/HorizontalRequestTimeline.spec.ts @@ -350,7 +350,7 @@ describe('HorizontalRequestTimeline', () => { expect(nodeDot?.classList.contains('status-success')).toBe(false) }) - it('keeps emitted trace state active while the request lifecycle is still streaming', async () => { + it('emits failed trace state when a streaming lifecycle already has terminal failure evidence', async () => { const onTraceState = vi.fn() const trace = buildTrace([ buildCandidate({ @@ -375,10 +375,36 @@ describe('HorizontalRequestTimeline', () => { const lastCall = onTraceState.mock.calls[onTraceState.mock.calls.length - 1]?.[0] expect(lastCall).toMatchObject({ - finalStatus: 'streaming', + finalStatus: 'failed', }) }) + it('shows active streaming candidate error signals as failed on the node and detail panel', async () => { + const trace = buildTrace([ + buildCandidate({ + id: 'cand-active-timeout', + provider_id: 'provider-active-timeout', + provider_name: 'Provider Active Timeout', + key_id: 'key-active-timeout', + key_name: 'Active Timeout Key', + candidate_index: 0, + status: 'streaming', + status_code: 200, + error_message: 'UpstreamRequest("provider stream first byte timeout after 10000 ms")', + finished_at: undefined, + }), + ]) + + const root = mountTimeline(trace) + await nextTick() + + const nodeDot = root.querySelector('.node-dot') + expect(nodeDot?.classList.contains('status-failed')).toBe(true) + expect(root.textContent).toContain('错误信息') + expect(root.textContent).toContain('请求超时(10秒)') + expect(root.textContent).not.toContain('传输中') + }) + it('shows request path from request metadata', async () => { const trace = buildTrace([ buildCandidate({ @@ -473,7 +499,7 @@ describe('HorizontalRequestTimeline', () => { expect(root.textContent).toContain('Provider Upstream') expect(root.textContent).toContain('Key') expect(root.textContent).toContain('Upstream Key') - expect(root.textContent).toContain('HTTP 状态') + expect(root.textContent).not.toContain('HTTP 状态') expect(root.querySelector('.error-block .error-json')?.textContent).toContain('"status_code":302') expect(root.querySelector('.error-block .error-json')?.textContent).toContain('"headers"') expect(root.textContent).not.toContain('上游真实响应') @@ -554,4 +580,71 @@ describe('HorizontalRequestTimeline', () => { expect(root.querySelector('.error-block .error-json')).toBeNull() expect(root.textContent).not.toContain('"body_state":"none"') }) + + it('normalizes stream first byte timeout errors and hides empty body refs', async () => { + const trace = buildTrace([ + buildCandidate({ + id: 'cand-timeout-body-ref', + provider_id: 'provider-timeout', + provider_name: 'Provider Timeout', + key_id: 'key-timeout', + key_name: 'Timeout Key', + candidate_index: 0, + status: 'failed', + status_code: 503, + error_message: 'UpstreamRequest("provider stream first byte timeout after 10000 ms")', + extra_data: { + upstream_response: { + body_ref: 'usage://request/c676f8a0-3185-46a0-af06-e7c6f5873c39/response_body', + body_state: 'none', + }, + }, + }), + ]) + + const root = mountTimeline(trace) + await nextTick() + + expect(root.textContent).toContain('请求超时(10秒)') + expect(root.textContent).not.toContain('UpstreamRequest') + expect(root.textContent).not.toContain('响应 Body') + expect(root.textContent).not.toContain('usage://request/c676f8a0') + expect(root.querySelector('.error-block .error-json')).toBeNull() + }) + + it('does not show numeric upstream body error codes as a competing HTTP status', async () => { + const trace = buildTrace([ + buildCandidate({ + id: 'cand-wrapper-503-provider-404', + provider_id: 'provider-mixed-status', + provider_name: 'Provider Mixed Status', + key_id: 'key-mixed-status', + key_name: 'Mixed Status Key', + candidate_index: 0, + status: 'failed', + status_code: 503, + extra_data: { + upstream_response: { + status_code: 503, + body: { + error: { + code: 404, + type: 'not_found', + message: 'model not found', + }, + }, + }, + }, + }), + ]) + + const root = mountTimeline(trace) + await nextTick() + + expect(root.textContent).toContain('HTTP 503') + expect(root.textContent).toContain('model not found') + expect(root.textContent).toContain('错误类型') + expect(root.textContent).toContain('not_found') + expect(root.textContent).not.toContain('错误代码') + }) }) From 195c3268d8a9518e6c9f4c06987c23210716ca65 Mon Sep 17 00:00:00 2001 From: RWDai <27391645+RWDai@users.noreply.github.com> Date: Thu, 28 May 2026 20:34:01 +0800 Subject: [PATCH 10/23] Localize usage failure type labels --- .../usage/utils/__tests__/errorNotice.spec.ts | 40 +++++++++++++- .../src/features/usage/utils/errorNotice.ts | 6 +-- .../features/usage/utils/failureDisplay.ts | 53 +++++++++++++++++++ 3 files changed, 94 insertions(+), 5 deletions(-) diff --git a/frontend/src/features/usage/utils/__tests__/errorNotice.spec.ts b/frontend/src/features/usage/utils/__tests__/errorNotice.spec.ts index 55ac6480e..f463d2fba 100644 --- a/frontend/src/features/usage/utils/__tests__/errorNotice.spec.ts +++ b/frontend/src/features/usage/utils/__tests__/errorNotice.spec.ts @@ -87,7 +87,43 @@ describe('request failure notice', () => { title: '执行失败原因', message: 'quota exceeded', isSchedulingFailure: false, - meta: ['HTTP 429', 'insufficient_quota', 'upstream_response'], + meta: ['HTTP 429', '额度不足', 'upstream_response'], + }) + }) + + it('localizes internal request failure error types', () => { + const notice = resolveRequestFailureNotice(buildRequestDetail({ + failure_summary: { + source: 'local_candidate', + status_code: 504, + type: 'local_stream_candidate_watchdog_timeout', + message: 'Stream first byte timeout', + }, + })) + + expect(notice).toEqual({ + title: '执行失败原因', + message: '请求超时(等待上游首字超时)', + isSchedulingFailure: false, + meta: ['HTTP 504', '本地流式候选首字超时', 'local_candidate'], + }) + }) + + it('localizes internal usage failure types in the notice metadata', () => { + const notice = resolveRequestFailureNotice(buildRequestDetail({ + failure_summary: { + source: 'local_execution_runtime', + status_code: 504, + type: 'local_stream_candidate_watchdog_timeout', + message: 'Stream first byte timeout', + }, + })) + + expect(notice).toEqual({ + title: '执行失败原因', + message: '请求超时(等待上游首字超时)', + isSchedulingFailure: false, + meta: ['HTTP 504', '本地流式候选首字超时', 'local_execution_runtime'], }) }) @@ -147,7 +183,7 @@ describe('request failure notice', () => { title: '执行失败原因', message: 'This content was flagged for possible cybersecurity risk', isSchedulingFailure: false, - meta: ['stream_terminal_error', 'client_response'], + meta: ['流式响应结束异常', 'client_response'], }) }) diff --git a/frontend/src/features/usage/utils/errorNotice.ts b/frontend/src/features/usage/utils/errorNotice.ts index 1827b4eb8..d7afa34cc 100644 --- a/frontend/src/features/usage/utils/errorNotice.ts +++ b/frontend/src/features/usage/utils/errorNotice.ts @@ -1,5 +1,5 @@ import type { RequestDetail, RequestErrorDomain, RequestSchedulingFailure } from '@/api/dashboard' -import { normalizeFailureMessage } from './failureDisplay' +import { formatFailureTypeLabel, normalizeFailureMessage } from './failureDisplay' export interface RequestFailureNotice { title: string @@ -83,7 +83,7 @@ export function resolveRequestFailureNotice(detail: RequestDetail | null | undef isSchedulingFailure: true, meta: uniqueMeta([ formatHttpStatus(upstreamFailure.status_code ?? schedulingFailure.status_code ?? detail.status_code), - nonEmptyString(upstreamFailure.type), + formatFailureTypeLabel(upstreamFailure.type), nonEmptyString(upstreamFailure.param), nonEmptyString(upstreamFailure.provider_name), nonEmptyString(upstreamFailure.model), @@ -119,7 +119,7 @@ export function resolveRequestFailureNotice(detail: RequestDetail | null | undef isSchedulingFailure: false, meta: uniqueMeta([ formatHttpStatus(domain ? domain.status_code ?? detail.status_code : undefined), - nonEmptyString(domain?.type), + formatFailureTypeLabel(domain?.type), nonEmptyString(domain?.source), ]), } diff --git a/frontend/src/features/usage/utils/failureDisplay.ts b/frontend/src/features/usage/utils/failureDisplay.ts index 7c435a045..0f79aa876 100644 --- a/frontend/src/features/usage/utils/failureDisplay.ts +++ b/frontend/src/features/usage/utils/failureDisplay.ts @@ -19,6 +19,59 @@ function unwrapRustErrorMessage(message: string): string { .trim() } +const FAILURE_TYPE_LABELS: Record = { + local_stream_candidate_watchdog_timeout: '本地流式候选首字超时', + stream_missing_terminal_event: '流式响应缺少结束事件', + stream_terminal_error: '流式响应结束异常', + stream_http_error: '流式上游 HTTP 错误', + stream_missing_terminal_event_after_usage: '流式响应计费后缺少结束事件', + execution_runtime_unavailable: '执行通道不可用', + execution_runtime_http_error: '执行通道 HTTP 错误', + execution_runtime_stream_non_success_status: '上游流式响应返回非成功状态', + downstream_disconnect: '客户端连接已断开', + success_failover_pattern: '成功响应触发备用路径', + retryable_upstream_status: '上游返回可重试错误', + control_fallback: '调度切换到备用通道', + upstream_timeout: '上游请求超时', + upstream_request_timeout: '上游请求超时', + stream_first_byte_timeout: '流式首字超时', + insufficient_quota: '额度不足', + rate_limit_exceeded: '请求频率超限', + invalid_request_error: '请求参数错误', + authentication_error: '认证失败', + permission_error: '权限不足', + model_not_found: '模型不存在', + not_found: '资源不存在', + server_error: '上游服务异常', + grok_execution_unavailable: 'Grok 执行通道不可用', + windsurf_native_execution_unavailable: 'Windsurf 原生执行通道不可用', + kiro_web_search_mcp_unavailable: 'Kiro Web Search MCP 不可用', + chatgpt_web_image_execution_unavailable: 'ChatGPT Web 图片执行通道不可用', +} + +function looksInternalErrorType(value: string): boolean { + return /^[a-z][a-z0-9_]+$/.test(value) +} + +function inferInternalFailureTypeLabel(value: string): string | null { + if (!looksInternalErrorType(value)) return null + if (value.includes('timeout')) return '请求超时' + if (value.includes('missing_terminal_event')) return '流式响应缺少结束事件' + if (value.includes('terminal_error')) return '流式响应结束异常' + if (value.includes('http_error') || value.includes('non_success_status')) return '上游 HTTP 错误' + if (value.includes('unavailable')) return '执行通道不可用' + if (value.includes('disconnect')) return '连接已断开' + if (value.includes('fallback')) return '触发备用通道' + return '内部执行错误' +} + +export function formatFailureTypeLabel(value: string | null | undefined): string | null { + const normalized = nonEmptyString(value) + if (!normalized) return null + const key = normalized.toLowerCase() + return FAILURE_TYPE_LABELS[key] ?? inferInternalFailureTypeLabel(normalized) ?? normalized +} + export function normalizeFailureMessage(message: string | null | undefined, statusCode?: number | null): string | null { const normalized = nonEmptyString(message) const unwrapped = normalized ? unwrapRustErrorMessage(normalized) : null From dff8ef77518dbfbe522bb544c6d0c3d6d8ee16ef Mon Sep 17 00:00:00 2001 From: RWDai <27391645+RWDai@users.noreply.github.com> Date: Thu, 28 May 2026 20:34:41 +0800 Subject: [PATCH 11/23] Remove timeline error context noise --- .../components/HorizontalRequestTimeline.vue | 13 ++-- .../HorizontalRequestTimeline.spec.ts | 61 +++++++++++++++---- 2 files changed, 54 insertions(+), 20 deletions(-) diff --git a/frontend/src/features/usage/components/HorizontalRequestTimeline.vue b/frontend/src/features/usage/components/HorizontalRequestTimeline.vue index bfb8646b7..6a725603c 100644 --- a/frontend/src/features/usage/components/HorizontalRequestTimeline.vue +++ b/frontend/src/features/usage/components/HorizontalRequestTimeline.vue @@ -559,7 +559,7 @@ import { formatTokens } from '@/utils/format' import { formatApiFormat } from '@/api/endpoints/types/api-format' import { useDarkMode } from '@/composables/useDarkMode' import { resolveTimelineFinalStatus } from '../utils/status' -import { isHttpLikeErrorCode, normalizeFailureMessage } from '../utils/failureDisplay' +import { formatFailureTypeLabel, isHttpLikeErrorCode, normalizeFailureMessage } from '../utils/failureDisplay' import { buildPoolGroupVisibleAttempts, buildPoolParticipatedCandidates, @@ -1451,15 +1451,12 @@ const buildCurrentAttemptErrorFields = ( errorParam?: string, statusCode?: number, ): Array<{ label: string, value: string }> => { - const providerName = attempt.provider_name - const keyName = attempt.key_name || attempt.key_account_label || attempt.key_preview const bodyState = readStringField(upstreamResponse ?? {}, 'body_state') ?? readStringField(upstreamResponse ?? {}, 'bodyState') const meaningfulBodyState = bodyState && bodyState.toLowerCase() !== 'none' + const errorTypeLabel = formatFailureTypeLabel(errorType) const fields = [ - providerName ? { label: '供应商', value: providerName } : null, - keyName ? { label: 'Key', value: keyName } : null, - errorType ? { label: '错误类型', value: errorType } : null, + errorTypeLabel ? { label: '错误类型', value: errorTypeLabel } : null, errorParam ? { label: '错误参数', value: errorParam } : null, readStringField(errorFlow ?? {}, 'source') ? { label: '错误来源', value: readStringField(errorFlow ?? {}, 'source') as string } : null, ].filter((field): field is { label: string, value: string } => Boolean(field)) @@ -1508,7 +1505,9 @@ const currentAttemptRequestError = computed<{ const fallbackMessage = typeof attempt.error_message === 'string' && attempt.error_message.trim() ? attempt.error_message.trim() : '' - const message = formatAttemptErrorMessage(flowMessage || upstreamErrorMessage || fallbackMessage, statusCode) || upstreamErrorType || '' + const message = formatAttemptErrorMessage(flowMessage || upstreamErrorMessage || fallbackMessage, statusCode) + || formatFailureTypeLabel(upstreamErrorType) + || '' const upstreamResponseDisplay = normalizeUpstreamResponseDisplay(extra?.upstream_response) const fields = buildCurrentAttemptErrorFields( attempt, diff --git a/frontend/src/features/usage/components/__tests__/HorizontalRequestTimeline.spec.ts b/frontend/src/features/usage/components/__tests__/HorizontalRequestTimeline.spec.ts index c9559017a..c74263115 100644 --- a/frontend/src/features/usage/components/__tests__/HorizontalRequestTimeline.spec.ts +++ b/frontend/src/features/usage/components/__tests__/HorizontalRequestTimeline.spec.ts @@ -495,10 +495,11 @@ describe('HorizontalRequestTimeline', () => { expect(root.textContent).toContain('错误信息') expect(root.textContent).toContain('HTTP 302') expect(root.textContent).toContain('上游返回非成功状态 302') - expect(root.textContent).toContain('供应商') - expect(root.textContent).toContain('Provider Upstream') - expect(root.textContent).toContain('Key') - expect(root.textContent).toContain('Upstream Key') + const errorBlock = root.querySelector('.error-block') + expect(errorBlock?.textContent).not.toContain('供应商') + expect(errorBlock?.textContent).not.toContain('Provider Upstream') + expect(errorBlock?.textContent).not.toContain('Key') + expect(errorBlock?.textContent).not.toContain('Upstream Key') expect(root.textContent).not.toContain('HTTP 状态') expect(root.querySelector('.error-block .error-json')?.textContent).toContain('"status_code":302') expect(root.querySelector('.error-block .error-json')?.textContent).toContain('"headers"') @@ -541,17 +542,50 @@ describe('HorizontalRequestTimeline', () => { const root = mountTimeline(trace) await nextTick() - - expect(root.textContent).toContain('This model\'s maximum context length is 131072 tokens.') - expect(root.textContent).toContain('GPUStack') - expect(root.textContent).toContain('key1') - expect(root.textContent).toContain('错误类型') - expect(root.textContent).toContain('BadRequestError') - expect(root.textContent).toContain('错误参数') - expect(root.textContent).toContain('input_tokens') + const errorBlockText = root.querySelector('.error-block')?.textContent ?? '' + + expect(errorBlockText).toContain('This model\'s maximum context length is 131072 tokens.') + expect(errorBlockText).toContain('错误类型') + expect(errorBlockText).toContain('BadRequestError') + expect(errorBlockText).toContain('错误参数') + expect(errorBlockText).toContain('input_tokens') + expect(errorBlockText).not.toContain('供应商') + expect(errorBlockText).not.toContain('Key') + expect(errorBlockText).not.toContain('GPUStack') + expect(errorBlockText).not.toContain('key1') expect(root.querySelector('.error-block .error-json')?.textContent).toContain('"status_code":400') }) + it('localizes internal error types and keeps repeated provider context out of the error block', async () => { + const trace = buildTrace([ + buildCandidate({ + id: 'cand-watchdog-timeout', + provider_id: 'provider-watchdog', + provider_name: 'aether 公益', + key_id: 'key-watchdog', + key_name: '公益 Key', + candidate_index: 0, + status: 'failed', + status_code: 504, + error_type: 'local_stream_candidate_watchdog_timeout', + error_message: 'Stream first byte timeout', + }), + ]) + + const root = mountTimeline(trace) + await nextTick() + const errorBlockText = root.querySelector('.error-block')?.textContent ?? '' + + expect(errorBlockText).toContain('请求超时(等待上游首字超时)') + expect(errorBlockText).toContain('错误类型') + expect(errorBlockText).toContain('本地流式候选首字超时') + expect(errorBlockText).not.toContain('local_stream_candidate_watchdog_timeout') + expect(errorBlockText).not.toContain('供应商') + expect(errorBlockText).not.toContain('Key') + expect(errorBlockText).not.toContain('aether 公益') + expect(errorBlockText).not.toContain('公益 Key') + }) + it('keeps the failure message when upstream response only records an empty body state', async () => { const trace = buildTrace([ buildCandidate({ @@ -644,7 +678,8 @@ describe('HorizontalRequestTimeline', () => { expect(root.textContent).toContain('HTTP 503') expect(root.textContent).toContain('model not found') expect(root.textContent).toContain('错误类型') - expect(root.textContent).toContain('not_found') + expect(root.textContent).toContain('资源不存在') expect(root.textContent).not.toContain('错误代码') }) + }) From 6d285410c296e04ad0286b0972a48a8c3917d0f8 Mon Sep 17 00:00:00 2001 From: RWDai <27391645+RWDai@users.noreply.github.com> Date: Thu, 28 May 2026 22:02:42 +0800 Subject: [PATCH 12/23] Preserve dashboard daily breakdown rows --- .../frontdoor/public_support/dashboard.rs | 46 ++++++++++++++----- .../src/repository/usage/postgres/mod.rs | 45 +----------------- .../src/repository/usage/postgres/tests.rs | 22 ++++++++- 3 files changed, 56 insertions(+), 57 deletions(-) diff --git a/apps/aether-gateway/src/tests/frontdoor/public_support/dashboard.rs b/apps/aether-gateway/src/tests/frontdoor/public_support/dashboard.rs index 890c2f589..da9ea517e 100644 --- a/apps/aether-gateway/src/tests/frontdoor/public_support/dashboard.rs +++ b/apps/aether-gateway/src/tests/frontdoor/public_support/dashboard.rs @@ -729,6 +729,16 @@ async fn gateway_handles_dashboard_daily_stats_locally_without_proxying_upstream now - chrono::Duration::hours(2), ); today_claude_usage.total_tokens = 160; + let mut today_bailian_usage = sample_user_usage_audit( + "usage-dashboard-daily-4", + "req-dashboard-daily-4", + "user-auth-4", + "qwen3.6-27b", + "bailian", + "completed", + now - chrono::Duration::hours(3), + ); + today_bailian_usage.total_tokens = 160; let mut prior_usage = sample_user_usage_audit( "usage-dashboard-daily-3", "req-dashboard-daily-3", @@ -742,6 +752,7 @@ async fn gateway_handles_dashboard_daily_stats_locally_without_proxying_upstream let usage_repository = Arc::new(InMemoryUsageReadRepository::seed(vec![ today_openai_usage, today_claude_usage, + today_bailian_usage, prior_usage, ])); @@ -789,30 +800,41 @@ async fn gateway_handles_dashboard_daily_stats_locally_without_proxying_upstream assert_eq!(daily_stats[0]["requests"], 1); assert_eq!(daily_stats[0]["unique_providers"], 1); assert_eq!(daily_stats[1]["date"], json!(now.date_naive().to_string())); - assert_eq!(daily_stats[1]["requests"], 2); - assert_eq!(daily_stats[1]["tokens"], 320); - assert_eq!(daily_stats[1]["unique_models"], 2); - assert_eq!(daily_stats[1]["unique_providers"], 2); + assert_eq!(daily_stats[1]["requests"], 3); + assert_eq!(daily_stats[1]["tokens"], 480); + assert_eq!(daily_stats[1]["unique_models"], 3); + assert_eq!(daily_stats[1]["unique_providers"], 3); + let today_model_breakdown = daily_stats[1]["model_breakdown"] + .as_array() + .expect("today model breakdown should be an array"); + assert_eq!(today_model_breakdown.len(), 3); + let today_models = today_model_breakdown + .iter() + .filter_map(|item| item["model"].as_str()) + .collect::>(); assert_eq!( - daily_stats[1]["model_breakdown"].as_array().map(Vec::len), - Some(2) + today_models, + std::collections::BTreeSet::from(["claude-3-7", "gpt-5", "qwen3.6-27b"]) ); let model_summary = payload["model_summary"] .as_array() .expect("model summary should exist"); - assert_eq!(model_summary.len(), 2); + assert_eq!(model_summary.len(), 3); assert_eq!(model_summary[0]["model"], "gpt-5"); assert_eq!(model_summary[0]["requests"], 2); let provider_summary = payload["provider_summary"] .as_array() .expect("provider summary should exist"); - assert_eq!(provider_summary.len(), 2); - assert_eq!(provider_summary[0]["provider"], "openai"); - assert_eq!(provider_summary[0]["requests"], 2); - assert_eq!(provider_summary[1]["provider"], "claude"); - assert_eq!(provider_summary[1]["requests"], 1); + assert_eq!(provider_summary.len(), 3); + let provider_requests = provider_summary + .iter() + .filter_map(|item| Some((item["provider"].as_str()?, item["requests"].as_u64()?))) + .collect::>(); + assert_eq!(provider_requests.get("openai"), Some(&2)); + assert_eq!(provider_requests.get("claude"), Some(&1)); + assert_eq!(provider_requests.get("bailian"), Some(&1)); assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0); gateway_handle.abort(); diff --git a/crates/aether-data/src/repository/usage/postgres/mod.rs b/crates/aether-data/src/repository/usage/postgres/mod.rs index 83601b012..02da165ed 100644 --- a/crates/aether-data/src/repository/usage/postgres/mod.rs +++ b/crates/aether-data/src/repository/usage/postgres/mod.rs @@ -4389,7 +4389,7 @@ ORDER BY date ASC, total_cost_usd DESC, model ASC, provider_name ASC r#" SELECT TO_CHAR( - date_trunc('day', "usage".created_at + ( + date_trunc('day', ("usage".created_at AT TIME ZONE 'UTC') + ( "#, ); builder.push_bind(query.tz_offset_minutes); @@ -4484,53 +4484,12 @@ ORDER BY date ASC, total_cost_usd DESC, "usage".model ASC, "usage".provider_name Ok(items) } - async fn list_dashboard_daily_breakdown_aggregate_segments( - &self, - query: &UsageDashboardDailyBreakdownQuery, - ) -> Result, DataLayerError> { - let cutoff_utc = match self.read_stats_daily_cutoff_date().await { - Ok(value) => value, - Err(err) if dashboard_should_fallback_to_raw_on_aggregate_error(&err) => { - return Ok(Vec::new()); - } - Err(err) => return Err(err), - }; - let Some(cutoff_utc) = cutoff_utc else { - return Ok(Vec::new()); - }; - let start_utc = dashboard_unix_secs_to_utc(query.created_from_unix_secs); - let end_utc = dashboard_unix_secs_to_utc(query.created_until_unix_secs); - let split = split_dashboard_daily_aggregate_range(start_utc, end_utc, cutoff_utc); - let Some((aggregate_start, aggregate_end)) = split.aggregate else { - return Ok(Vec::new()); - }; - - self.list_dashboard_daily_breakdown_from_daily_aggregates( - aggregate_start, - aggregate_end, - query.user_id.as_deref(), - ) - .await - } - pub async fn list_dashboard_daily_breakdown( &self, query: &UsageDashboardDailyBreakdownQuery, ) -> Result, DataLayerError> { if query.tz_offset_minutes != 0 { - let mut items = self - .list_dashboard_daily_breakdown_aggregate_segments(query) - .await?; - let mut aggregate_dates = items - .iter() - .map(|item| item.date.clone()) - .collect::>(); - for item in self.list_dashboard_daily_breakdown_raw(query).await? { - if aggregate_dates.insert(item.date.clone()) { - items.push(item); - } - } - return Ok(finalize_dashboard_daily_breakdown_rows(items)); + return self.list_dashboard_daily_breakdown_raw(query).await; } let cutoff_utc = match self.read_stats_daily_cutoff_date().await { diff --git a/crates/aether-data/src/repository/usage/postgres/tests.rs b/crates/aether-data/src/repository/usage/postgres/tests.rs index ed6e9dfc8..9ccbe3e8b 100644 --- a/crates/aether-data/src/repository/usage/postgres/tests.rs +++ b/crates/aether-data/src/repository/usage/postgres/tests.rs @@ -452,14 +452,32 @@ fn usage_sql_daily_cutoff_falls_back_to_imported_stats_daily() { #[test] fn usage_sql_dashboard_daily_breakdown_falls_back_to_daily_totals() { let source = include_str!("mod.rs"); - assert!(source.contains("list_dashboard_daily_breakdown_aggregate_segments")); assert!(source.contains("list_dashboard_daily_breakdown_from_daily_totals")); assert!(source.contains("'aggregate'::TEXT AS model")); assert!(source.contains("FROM stats_daily")); assert!(source.contains("FROM stats_user_daily")); assert!(source.contains("detailed_dates.contains(&item.date)")); assert!(source.contains("query.tz_offset_minutes != 0")); - assert!(source.contains("aggregate_dates.insert(item.date.clone())")); + assert!(source.contains("return self.list_dashboard_daily_breakdown_raw(query).await;")); + assert!(!source.contains("aggregate_dates.insert(item.date.clone())")); +} + +#[test] +fn usage_sql_dashboard_daily_breakdown_keeps_all_local_day_model_provider_rows() { + let source = include_str!("mod.rs"); + let raw_breakdown = source + .split("async fn list_dashboard_daily_breakdown_raw") + .nth(1) + .and_then(|tail| { + tail.split("pub async fn list_dashboard_daily_breakdown") + .next() + }) + .expect("raw daily breakdown function should be present"); + assert!(raw_breakdown.contains("GROUP BY date, \"usage\".model, \"usage\".provider_name")); + assert!(raw_breakdown.contains("ORDER BY date ASC, total_cost_usd DESC")); + assert!(raw_breakdown.contains("(\"usage\".created_at AT TIME ZONE 'UTC')")); + assert!(!raw_breakdown.contains("date_trunc('day', \"usage\".created_at +")); + assert!(!source.contains("if aggregate_dates.insert(item.date.clone())")); } #[test] From f81e668bc81a48a2764545a3259c56dec6870edd Mon Sep 17 00:00:00 2001 From: RWDai <27391645+RWDai@users.noreply.github.com> Date: Thu, 28 May 2026 22:11:02 +0800 Subject: [PATCH 13/23] Summarize usage failure reasons --- .../usage/utils/__tests__/errorNotice.spec.ts | 29 +++++++++++--- .../src/features/usage/utils/errorNotice.ts | 23 ++++++----- .../features/usage/utils/failureDisplay.ts | 40 +++++++++++++++++++ 3 files changed, 78 insertions(+), 14 deletions(-) diff --git a/frontend/src/features/usage/utils/__tests__/errorNotice.spec.ts b/frontend/src/features/usage/utils/__tests__/errorNotice.spec.ts index f463d2fba..9aa008fee 100644 --- a/frontend/src/features/usage/utils/__tests__/errorNotice.spec.ts +++ b/frontend/src/features/usage/utils/__tests__/errorNotice.spec.ts @@ -87,7 +87,7 @@ describe('request failure notice', () => { title: '执行失败原因', message: 'quota exceeded', isSchedulingFailure: false, - meta: ['HTTP 429', '额度不足', 'upstream_response'], + meta: ['HTTP 429', '额度不足'], }) }) @@ -105,7 +105,7 @@ describe('request failure notice', () => { title: '执行失败原因', message: '请求超时(等待上游首字超时)', isSchedulingFailure: false, - meta: ['HTTP 504', '本地流式候选首字超时', 'local_candidate'], + meta: ['HTTP 504', '本地流式候选首字超时'], }) }) @@ -123,7 +123,7 @@ describe('request failure notice', () => { title: '执行失败原因', message: '请求超时(等待上游首字超时)', isSchedulingFailure: false, - meta: ['HTTP 504', '本地流式候选首字超时', 'local_execution_runtime'], + meta: ['HTTP 504', '本地流式候选首字超时'], }) }) @@ -162,7 +162,7 @@ describe('request failure notice', () => { title: '唯一候选执行失败,已无可重试上游', message: '输入上下文超过模型 qwen3.6-27b 的最大长度限制。', isSchedulingFailure: true, - meta: ['HTTP 400', 'BadRequestError', 'input_tokens', 'gpustack', 'qwen3.6-27b'], + meta: ['HTTP 400', 'BadRequestError', 'input_tokens'], }) }) @@ -183,7 +183,26 @@ describe('request failure notice', () => { title: '执行失败原因', message: 'This content was flagged for possible cybersecurity risk', isSchedulingFailure: false, - meta: ['流式响应结束异常', 'client_response'], + meta: ['流式响应结束异常'], + }) + }) + + it('summarizes structured context length errors without raw source tags', () => { + const notice = resolveRequestFailureNotice(buildRequestDetail({ + failure_summary: { + source: 'upstream_response', + status_code: 400, + type: 'invalid_request_error', + code: 'context_length_exceeded', + message: 'Input exceeds the context window.', + }, + })) + + expect(notice).toEqual({ + title: '执行失败原因', + message: '上下文长度超出限制', + isSchedulingFailure: false, + meta: ['HTTP 400'], }) }) diff --git a/frontend/src/features/usage/utils/errorNotice.ts b/frontend/src/features/usage/utils/errorNotice.ts index d7afa34cc..5a8f52123 100644 --- a/frontend/src/features/usage/utils/errorNotice.ts +++ b/frontend/src/features/usage/utils/errorNotice.ts @@ -1,5 +1,5 @@ import type { RequestDetail, RequestErrorDomain, RequestSchedulingFailure } from '@/api/dashboard' -import { formatFailureTypeLabel, normalizeFailureMessage } from './failureDisplay' +import { formatFailureCodeLabel, formatFailureTypeLabel, normalizeFailureMessage, resolveFailureReason } from './failureDisplay' export interface RequestFailureNotice { title: string @@ -14,12 +14,17 @@ function nonEmptyString(value: string | null | undefined): string | null { } function normalizeErrorDomain(domain: RequestErrorDomain | null | undefined): RequestErrorDomain | null { - if (!nonEmptyString(domain?.message)) return null + if (!nonEmptyString(domain?.message) && !nonEmptyString(domain?.type) && domain?.code == null) return null return domain ?? null } function normalizeDomainMessage(domain: RequestErrorDomain | null | undefined): string | null { - return normalizeFailureMessage(domain?.message ?? null, domain?.status_code ?? null) + return resolveFailureReason({ + message: domain?.message ?? null, + type: domain?.type ?? null, + code: domain?.code ?? null, + statusCode: domain?.status_code ?? null, + }) } function formatHttpStatus(statusCode: number | null | undefined): string | null { @@ -74,8 +79,11 @@ export function resolveRequestFailureNotice(detail: RequestDetail | null | undef if (schedulingFailure) { const upstreamFailure = schedulingFailure.upstream_failure ?? null const upstreamMessage = normalizeFailureMessage(upstreamFailure?.user_message, upstreamFailure?.status_code) - ?? normalizeFailureMessage(schedulingFailure.message, schedulingFailure.status_code) - ?? normalizeFailureMessage(upstreamFailure?.message, upstreamFailure?.status_code) + ?? resolveFailureReason({ + message: schedulingFailure.message ?? upstreamFailure?.message ?? null, + type: upstreamFailure?.type ?? null, + statusCode: upstreamFailure?.status_code ?? schedulingFailure.status_code ?? null, + }) if (upstreamFailure && upstreamMessage) { return { title: schedulingFailureTitle(schedulingFailure), @@ -85,8 +93,6 @@ export function resolveRequestFailureNotice(detail: RequestDetail | null | undef formatHttpStatus(upstreamFailure.status_code ?? schedulingFailure.status_code ?? detail.status_code), formatFailureTypeLabel(upstreamFailure.type), nonEmptyString(upstreamFailure.param), - nonEmptyString(upstreamFailure.provider_name), - nonEmptyString(upstreamFailure.model), schedulingFailure.no_upstream_attempt ? '未进入上游执行' : null, ]), } @@ -119,8 +125,7 @@ export function resolveRequestFailureNotice(detail: RequestDetail | null | undef isSchedulingFailure: false, meta: uniqueMeta([ formatHttpStatus(domain ? domain.status_code ?? detail.status_code : undefined), - formatFailureTypeLabel(domain?.type), - nonEmptyString(domain?.source), + formatFailureCodeLabel(domain?.code) ? null : formatFailureTypeLabel(domain?.type), ]), } } diff --git a/frontend/src/features/usage/utils/failureDisplay.ts b/frontend/src/features/usage/utils/failureDisplay.ts index 0f79aa876..c6ee6e0b3 100644 --- a/frontend/src/features/usage/utils/failureDisplay.ts +++ b/frontend/src/features/usage/utils/failureDisplay.ts @@ -49,6 +49,23 @@ const FAILURE_TYPE_LABELS: Record = { chatgpt_web_image_execution_unavailable: 'ChatGPT Web 图片执行通道不可用', } +const FAILURE_CODE_LABELS: Record = { + context_length_exceeded: '上下文长度超出限制', + max_context_length_exceeded: '上下文长度超出限制', + prompt_too_long: '输入内容过长', + request_too_large: '请求内容过大', + model_not_found: '模型不存在', + not_found: '资源不存在', + invalid_request_error: '请求参数错误', + invalid_api_key: 'API Key 无效', + authentication_error: '认证失败', + permission_error: '权限不足', + insufficient_quota: '额度不足', + rate_limit_exceeded: '请求频率超限', + content_policy_violation: '内容安全策略拦截', + server_error: '上游服务异常', +} + function looksInternalErrorType(value: string): boolean { return /^[a-z][a-z0-9_]+$/.test(value) } @@ -72,6 +89,29 @@ export function formatFailureTypeLabel(value: string | null | undefined): string return FAILURE_TYPE_LABELS[key] ?? inferInternalFailureTypeLabel(normalized) ?? normalized } +export function formatFailureCodeLabel(value: unknown): string | null { + if (typeof value !== 'string') return null + const normalized = nonEmptyString(value) + if (!normalized) return null + const key = normalized.toLowerCase() + return FAILURE_CODE_LABELS[key] ?? FAILURE_TYPE_LABELS[key] ?? inferInternalFailureTypeLabel(normalized) +} + +export function resolveFailureReason(input: { + message?: string | null + type?: string | null + code?: unknown + statusCode?: number | null +}): string | null { + const codeLabel = formatFailureCodeLabel(input.code) + if (codeLabel) return codeLabel + + const message = normalizeFailureMessage(input.message, input.statusCode) + if (message) return message + + return formatFailureTypeLabel(input.type) +} + export function normalizeFailureMessage(message: string | null | undefined, statusCode?: number | null): string | null { const normalized = nonEmptyString(message) const unwrapped = normalized ? unwrapRustErrorMessage(normalized) : null From 5a1c39ca5eb1bef8f60ea5f2974d880551bc6265 Mon Sep 17 00:00:00 2001 From: RWDai <27391645+RWDai@users.noreply.github.com> Date: Thu, 28 May 2026 22:11:43 +0800 Subject: [PATCH 14/23] Hide noisy timeline error fields --- .../components/HorizontalRequestTimeline.vue | 82 +++++++++++-------- .../HorizontalRequestTimeline.spec.ts | 51 +++++++++++- 2 files changed, 96 insertions(+), 37 deletions(-) diff --git a/frontend/src/features/usage/components/HorizontalRequestTimeline.vue b/frontend/src/features/usage/components/HorizontalRequestTimeline.vue index 6a725603c..d575ad1aa 100644 --- a/frontend/src/features/usage/components/HorizontalRequestTimeline.vue +++ b/frontend/src/features/usage/components/HorizontalRequestTimeline.vue @@ -559,7 +559,7 @@ import { formatTokens } from '@/utils/format' import { formatApiFormat } from '@/api/endpoints/types/api-format' import { useDarkMode } from '@/composables/useDarkMode' import { resolveTimelineFinalStatus } from '../utils/status' -import { formatFailureTypeLabel, isHttpLikeErrorCode, normalizeFailureMessage } from '../utils/failureDisplay' +import { formatFailureCodeLabel, formatFailureTypeLabel, resolveFailureReason } from '../utils/failureDisplay' import { buildPoolGroupVisibleAttempts, buildPoolParticipatedCandidates, @@ -1241,22 +1241,46 @@ watch( { immediate: true }, ) +const normalizeUpstreamBodyDisplay = (value: unknown): unknown => { + if (!hasRenderableValue(value)) return null + + const body = extractObject(value) + if (!body) return value + + const display: Record = { ...body } + const error = extractObject(display.error) + if (error) { + const errorDisplay: Record = { ...error } + delete errorDisplay.code + delete errorDisplay.type + delete errorDisplay.message + delete errorDisplay.param + if (Object.keys(errorDisplay).length > 0) { + display.error = errorDisplay + } else { + delete display.error + } + } + + delete display.code + delete display.type + delete display.message + delete display.detail + delete display.param + + return Object.keys(display).length > 0 ? display : null +} + const normalizeUpstreamResponseDisplay = (value: unknown): Record | null => { const raw = extractObject(value) if (!raw) return null const statusCode = readNumberField(raw, 'status_code') ?? readNumberField(raw, 'statusCode') const headers = raw.headers - const body = raw.body - const bodyState = readStringField(raw, 'body_state') ?? readStringField(raw, 'bodyState') - const meaningfulBodyState = bodyState && bodyState.toLowerCase() !== 'none' - ? bodyState - : '' + const body = normalizeUpstreamBodyDisplay(raw.body) if ( - statusCode == null && !hasRenderableValue(headers) && - !hasRenderableValue(body) && - !meaningfulBodyState + !hasRenderableValue(body) ) { return null } @@ -1265,7 +1289,6 @@ const normalizeUpstreamResponseDisplay = (value: unknown): Record { - return normalizeFailureMessage(message, statusCode) ?? '' -} - const buildCurrentAttemptErrorFields = ( - attempt: CandidateRecord, - upstreamResponse: Record | null, - upstreamBody: unknown, errorFlow: Record | null, errorType?: string, errorParam?: string, - statusCode?: number, + hideErrorType = false, ): Array<{ label: string, value: string }> => { - const bodyState = readStringField(upstreamResponse ?? {}, 'body_state') - ?? readStringField(upstreamResponse ?? {}, 'bodyState') - const meaningfulBodyState = bodyState && bodyState.toLowerCase() !== 'none' const errorTypeLabel = formatFailureTypeLabel(errorType) const fields = [ - errorTypeLabel ? { label: '错误类型', value: errorTypeLabel } : null, + errorTypeLabel && !hideErrorType ? { label: '错误类型', value: errorTypeLabel } : null, errorParam ? { label: '错误参数', value: errorParam } : null, readStringField(errorFlow ?? {}, 'source') ? { label: '错误来源', value: readStringField(errorFlow ?? {}, 'source') as string } : null, ].filter((field): field is { label: string, value: string } => Boolean(field)) - - if (meaningfulBodyState) { - fields.push({ label: 'Body 状态', value: bodyState }) - } - const bodyErrorCode = readNestedValue(upstreamBody, 'error', 'code') - if (bodyErrorCode != null && typeof bodyErrorCode !== 'object' && !isHttpLikeErrorCode(bodyErrorCode)) { - fields.push({ label: '错误代码', value: String(bodyErrorCode) }) - } return fields } @@ -1499,24 +1504,29 @@ const currentAttemptRequestError = computed<{ ?? readNestedString(upstreamBody, 'type') ?? readStringField(errorFlow ?? {}, 'type') ?? (typeof attempt.error_type === 'string' && attempt.error_type.trim() ? attempt.error_type.trim() : undefined) + const upstreamErrorCode = readNestedValue(upstreamBody, 'error', 'code') + ?? readNestedValue(upstreamBody, 'code') + ?? readNestedValue(errorFlow, 'code') + const upstreamErrorCodeLabel = formatFailureCodeLabel(upstreamErrorCode) const upstreamErrorParam = readNestedString(upstreamBody, 'error', 'param') ?? readNestedString(upstreamBody, 'param') ?? readStringField(errorFlow ?? {}, 'param') const fallbackMessage = typeof attempt.error_message === 'string' && attempt.error_message.trim() ? attempt.error_message.trim() : '' - const message = formatAttemptErrorMessage(flowMessage || upstreamErrorMessage || fallbackMessage, statusCode) - || formatFailureTypeLabel(upstreamErrorType) + const message = resolveFailureReason({ + message: flowMessage || upstreamErrorMessage || fallbackMessage, + type: upstreamErrorType, + code: upstreamErrorCode, + statusCode, + }) || '' const upstreamResponseDisplay = normalizeUpstreamResponseDisplay(extra?.upstream_response) const fields = buildCurrentAttemptErrorFields( - attempt, - upstreamResponse, - upstreamBody, errorFlow, upstreamErrorType, upstreamErrorParam, - statusCode, + Boolean(upstreamErrorCodeLabel), ) if (!message && statusCode == null && !upstreamResponseDisplay && fields.length === 0) return null diff --git a/frontend/src/features/usage/components/__tests__/HorizontalRequestTimeline.spec.ts b/frontend/src/features/usage/components/__tests__/HorizontalRequestTimeline.spec.ts index c74263115..09f30c9a7 100644 --- a/frontend/src/features/usage/components/__tests__/HorizontalRequestTimeline.spec.ts +++ b/frontend/src/features/usage/components/__tests__/HorizontalRequestTimeline.spec.ts @@ -553,7 +553,7 @@ describe('HorizontalRequestTimeline', () => { expect(errorBlockText).not.toContain('Key') expect(errorBlockText).not.toContain('GPUStack') expect(errorBlockText).not.toContain('key1') - expect(root.querySelector('.error-block .error-json')?.textContent).toContain('"status_code":400') + expect(root.querySelector('.error-block .error-json')).toBeNull() }) it('localizes internal error types and keeps repeated provider context out of the error block', async () => { @@ -682,4 +682,53 @@ describe('HorizontalRequestTimeline', () => { expect(root.textContent).not.toContain('错误代码') }) + it('summarizes context length errors without machine tags or body refs', async () => { + const trace = buildTrace([ + buildCandidate({ + id: 'c4220779-9f86-4fb0-a18e-389c8c131bf5', + provider_id: 'provider-aether-public', + provider_name: 'aether 公益', + key_id: 'key-aether-public', + key_name: 'key', + candidate_index: 0, + status: 'failed', + status_code: 400, + extra_data: { + upstream_response: { + status_code: 400, + body_ref: 'usage://request/c4220779-9f86-4fb0-a18e-389c8c131bf5/response_body', + body_state: 'inline', + body: { + error: { + type: 'invalid_request_error', + code: 'context_length_exceeded', + message: 'Input exceeds the context window.', + }, + }, + }, + }, + }), + ]) + + const root = mountTimeline(trace) + await nextTick() + const errorBlockText = root.querySelector('.error-block')?.textContent ?? '' + + expect(errorBlockText).toContain('HTTP 400') + expect(errorBlockText).toContain('上下文长度超出限制') + expect(errorBlockText).not.toContain('供应商') + expect(errorBlockText).not.toContain('aether 公益') + expect(errorBlockText).not.toContain('Key') + expect(errorBlockText).not.toContain('key') + expect(errorBlockText).not.toContain('错误类型') + expect(errorBlockText).not.toContain('invalid_request_error') + expect(errorBlockText).not.toContain('错误代码') + expect(errorBlockText).not.toContain('context_length_exceeded') + expect(errorBlockText).not.toContain('响应 Body') + expect(errorBlockText).not.toContain('usage://request/c4220779') + expect(errorBlockText).not.toContain('Body 状态') + expect(errorBlockText).not.toContain('inline') + expect(root.querySelector('.error-block .error-json')).toBeNull() + }) + }) From e328d49e3cbe9b18c331947f4208f067c8c80696 Mon Sep 17 00:00:00 2001 From: RWDai <27391645+RWDai@users.noreply.github.com> Date: Fri, 29 May 2026 17:46:47 +0800 Subject: [PATCH 15/23] =?UTF-8?q?feat:=20=E5=88=9B=E5=BB=BA=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E6=97=B6=E8=87=AA=E5=8A=A8=E7=94=9F=E6=88=90=E7=AC=A6?= =?UTF-8?q?=E5=90=88=E5=AF=86=E7=A0=81=E7=AD=96=E7=95=A5=E7=9A=84=E9=9A=8F?= =?UTF-8?q?=E6=9C=BA=E5=AF=86=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 generatePasswordByPolicy() 函数,根据策略级别生成安全随机密码 - 使用 crypto.getRandomValues() 确保密码安全性 - 创建用户对话框自动填充生成的密码并显示提示文案 - 用户点击密码框可清空自动生成的密码并手动输入 - 密码策略变更时自动刷新生成的密码 - 添加相关单元测试覆盖 --- .../users/components/UserFormDialog.vue | 34 +++++++++- .../utils/__tests__/passwordPolicy.spec.ts | 18 +++++- frontend/src/utils/passwordPolicy.ts | 62 +++++++++++++++++++ 3 files changed, 111 insertions(+), 3 deletions(-) diff --git a/frontend/src/features/users/components/UserFormDialog.vue b/frontend/src/features/users/components/UserFormDialog.vue index a73c9fba8..52b637250 100644 --- a/frontend/src/features/users/components/UserFormDialog.vue +++ b/frontend/src/features/users/components/UserFormDialog.vue @@ -127,6 +127,8 @@ :class="[ passwordError ? 'border-destructive' : '', ]" + @focus="clearGeneratedPassword" + @click="clearGeneratedPassword" />

- {{ passwordHint }} + {{ passwordHelperText }}

@@ -290,6 +292,7 @@ import { import { getPasswordPolicyHint, getPasswordPolicyPlaceholder, + generatePasswordByPolicy, normalizePasswordPolicyLevel, validatePasswordByPolicy, type PasswordPolicyLevel, @@ -323,6 +326,7 @@ const isOpen = computed(() => props.open) const saving = ref(false) const formNonce = ref(createFieldNonce()) const passwordPolicyLevel = ref('weak') +const isGeneratedPasswordActive = ref(false) // 表单数据 const form = ref({ @@ -351,9 +355,11 @@ function createFieldNonce(): string { function resetForm() { formNonce.value = createFieldNonce() + const generatedPassword = generatePasswordByPolicy(passwordPolicyLevel.value) + isGeneratedPasswordActive.value = true form.value = { username: '', - password: '', + password: generatedPassword, confirmPassword: '', email: '', initial_gift_usd: 10, @@ -370,6 +376,7 @@ function resetForm() { function loadUserData() { if (!props.user) return formNonce.value = createFieldNonce() + isGeneratedPasswordActive.value = false const redactionFeature = readChatPiiRedactionFeatureSettings(props.user.feature_settings) const notificationPushFeature = readNotificationPushServiceFeatureSettings(props.user.feature_settings) // 创建数组副本,避免与 props 数据共享引用 @@ -411,6 +418,12 @@ const usernameError = computed(() => { }) const passwordHint = computed(() => getPasswordPolicyHint(passwordPolicyLevel.value)) +const passwordHelperText = computed(() => { + if (isGeneratedPasswordActive.value) { + return '已自动生成符合要求的随机密码;点击密码框可清空后手动输入' + } + return passwordHint.value +}) const passwordError = computed(() => { if (!form.value.password) { @@ -444,10 +457,27 @@ async function loadPasswordPolicy(): Promise { .getSystemConfig('password_policy_level') .catch(() => ({ value: 'weak' })) passwordPolicyLevel.value = normalizePasswordPolicyLevel(passwordPolicyResponse.value) + refreshGeneratedPassword() } catch (err) { log.error('加载密码策略失败:', err) passwordPolicyLevel.value = 'weak' + refreshGeneratedPassword() + } +} + +function refreshGeneratedPassword(): void { + if (isEditMode.value || !isGeneratedPasswordActive.value) { + return + } + form.value.password = generatePasswordByPolicy(passwordPolicyLevel.value) +} + +function clearGeneratedPassword(): void { + if (isEditMode.value || !isGeneratedPasswordActive.value) { + return } + form.value.password = '' + isGeneratedPasswordActive.value = false } // 提交表单 diff --git a/frontend/src/utils/__tests__/passwordPolicy.spec.ts b/frontend/src/utils/__tests__/passwordPolicy.spec.ts index ba6b12758..0de731081 100644 --- a/frontend/src/utils/__tests__/passwordPolicy.spec.ts +++ b/frontend/src/utils/__tests__/passwordPolicy.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { getPasswordPolicyErrors, validatePasswordByPolicy } from '../passwordPolicy' +import { generatePasswordByPolicy, getPasswordPolicyErrors, validatePasswordByPolicy } from '../passwordPolicy' describe('passwordPolicy utils', () => { it('rejects passwords longer than 72 bytes', () => { @@ -15,4 +15,20 @@ describe('passwordPolicy utils', () => { '密码需要:至少 8 个字符、包含大写字母、包含数字、包含特殊字符', ) }) + + it('generates passwords that satisfy every policy level', () => { + for (const level of ['weak', 'medium', 'strong']) { + const password = generatePasswordByPolicy(level) + expect(validatePasswordByPolicy(password, level)).toBe('') + } + }) + + it('generates strong passwords with all required character classes', () => { + const password = generatePasswordByPolicy('strong') + + expect(password).toMatch(/[A-Z]/) + expect(password).toMatch(/[a-z]/) + expect(password).toMatch(/[0-9]/) + expect(password).toMatch(/[!@#$%^&*()_+\-=[\]{};:'",.<>?/\\|`~]/) + }) }) diff --git a/frontend/src/utils/passwordPolicy.ts b/frontend/src/utils/passwordPolicy.ts index 490545353..783ebcc06 100644 --- a/frontend/src/utils/passwordPolicy.ts +++ b/frontend/src/utils/passwordPolicy.ts @@ -2,6 +2,11 @@ export type PasswordPolicyLevel = 'weak' | 'medium' | 'strong' export const PASSWORD_MAX_BYTES = 72 const textEncoder = new TextEncoder() +const PASSWORD_RANDOM_LENGTH = 14 +const LOWERCASE_CHARS = 'abcdefghijkmnopqrstuvwxyz' +const UPPERCASE_CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ' +const NUMBER_CHARS = '23456789' +const SPECIAL_CHARS = '!@#$%^&*_-+=?' function getPasswordByteLength(password: string): number { return textEncoder.encode(password).length @@ -108,3 +113,60 @@ export function validatePasswordByPolicy(password: string, level: unknown): stri } return `密码需要:${ errors.join('、')}` } + +function randomInt(maxExclusive: number): number { + if (maxExclusive <= 0) return 0 + + const cryptoApi = globalThis.crypto + if (cryptoApi?.getRandomValues) { + const randomValues = new Uint32Array(1) + cryptoApi.getRandomValues(randomValues) + return randomValues[0] % maxExclusive + } + + return Math.floor(Math.random() * maxExclusive) +} + +function pickRandomChar(chars: string): string { + return chars[randomInt(chars.length)] +} + +function shuffleChars(chars: string[]): string[] { + const shuffled = [...chars] + for (let index = shuffled.length - 1; index > 0; index -= 1) { + const swapIndex = randomInt(index + 1) + const current = shuffled[index] + shuffled[index] = shuffled[swapIndex] + shuffled[swapIndex] = current + } + return shuffled +} + +export function generatePasswordByPolicy(level: unknown): string { + const normalized = normalizePasswordPolicyLevel(level) + const requiredChars: string[] = [] + + if (normalized === 'strong') { + requiredChars.push( + pickRandomChar(LOWERCASE_CHARS), + pickRandomChar(UPPERCASE_CHARS), + pickRandomChar(NUMBER_CHARS), + pickRandomChar(SPECIAL_CHARS), + ) + } else if (normalized === 'medium') { + requiredChars.push( + pickRandomChar(LOWERCASE_CHARS + UPPERCASE_CHARS), + pickRandomChar(NUMBER_CHARS), + ) + } + + const characterPool = normalized === 'strong' + ? LOWERCASE_CHARS + UPPERCASE_CHARS + NUMBER_CHARS + SPECIAL_CHARS + : LOWERCASE_CHARS + UPPERCASE_CHARS + NUMBER_CHARS + + while (requiredChars.length < PASSWORD_RANDOM_LENGTH) { + requiredChars.push(pickRandomChar(characterPool)) + } + + return shuffleChars(requiredChars).join('') +} From 4a147220f6348ec4a61388776d8877fb47ab4764 Mon Sep 17 00:00:00 2001 From: RWDai <27391645+RWDai@users.noreply.github.com> Date: Sat, 30 May 2026 13:59:10 +0800 Subject: [PATCH 16/23] =?UTF-8?q?refactor:=20=E7=A7=BB=E9=99=A4=E5=AF=86?= =?UTF-8?q?=E7=A0=81=E6=A1=86=E7=82=B9=E5=87=BB=E6=B8=85=E7=A9=BA=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=EF=BC=8C=E4=BF=9D=E7=95=99=E8=87=AA=E5=8A=A8=E7=94=9F?= =?UTF-8?q?=E6=88=90=E7=9A=84=E5=AF=86=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除密码输入框的 @focus 和 @click 清空事件 - 移除 clearGeneratedPassword 函数 - 简化提示文案为「已自动生成符合要求的随机密码」 --- .../src/features/users/components/UserFormDialog.vue | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/frontend/src/features/users/components/UserFormDialog.vue b/frontend/src/features/users/components/UserFormDialog.vue index 52b637250..545802807 100644 --- a/frontend/src/features/users/components/UserFormDialog.vue +++ b/frontend/src/features/users/components/UserFormDialog.vue @@ -127,8 +127,6 @@ :class="[ passwordError ? 'border-destructive' : '', ]" - @focus="clearGeneratedPassword" - @click="clearGeneratedPassword" />

{ const passwordHint = computed(() => getPasswordPolicyHint(passwordPolicyLevel.value)) const passwordHelperText = computed(() => { if (isGeneratedPasswordActive.value) { - return '已自动生成符合要求的随机密码;点击密码框可清空后手动输入' + return '已自动生成符合要求的随机密码' } return passwordHint.value }) @@ -472,14 +470,6 @@ function refreshGeneratedPassword(): void { form.value.password = generatePasswordByPolicy(passwordPolicyLevel.value) } -function clearGeneratedPassword(): void { - if (isEditMode.value || !isGeneratedPasswordActive.value) { - return - } - form.value.password = '' - isGeneratedPasswordActive.value = false -} - // 提交表单 async function handleSubmit() { saving.value = true From 5c7b4e14cf611f76a69686245ccd1269ceeb0639 Mon Sep 17 00:00:00 2001 From: RWDai <27391645+RWDai@users.noreply.github.com> Date: Sat, 30 May 2026 17:04:03 +0800 Subject: [PATCH 17/23] =?UTF-8?q?fix:=20=E7=A7=BB=E9=99=A4=20usage=20?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E7=A0=81=E5=86=97=E4=BD=99=E8=BD=AC=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/aether-admin/src/observability/usage.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/aether-admin/src/observability/usage.rs b/crates/aether-admin/src/observability/usage.rs index 66177f6f3..2ab88f655 100644 --- a/crates/aether-admin/src/observability/usage.rs +++ b/crates/aether-admin/src/observability/usage.rs @@ -681,7 +681,7 @@ fn admin_usage_candidate_upstream_failure_json( let status_code = upstream_response .and_then(admin_usage_upstream_response_status_code) .or(candidate.status_code) - .or(item.status_code.and_then(|value| u16::try_from(value).ok())); + .or(item.status_code); let error_type = body.and_then(admin_usage_error_type_from_body).or_else(|| { candidate .error_type From c40f76c35faaafbe3b7bbb7b17a9213405467e41 Mon Sep 17 00:00:00 2001 From: RWDai <27391645+RWDai@users.noreply.github.com> Date: Sat, 30 May 2026 18:33:28 +0800 Subject: [PATCH 18/23] =?UTF-8?q?feat:=20=E4=B8=BA=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E8=A1=A8=E5=A2=9E=E5=8A=A0=E5=A4=87=E6=B3=A8=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migrations/mysql/20260403000000_baseline.sql | 1 + .../migrations/mysql/20260530000000_add_user_remark.sql | 2 ++ .../migrations/postgres/20260403000000_baseline.sql | 1 + .../migrations/postgres/20260530000000_add_user_remark.sql | 2 ++ .../migrations/sqlite/20260403000000_baseline.sql | 1 + .../migrations/sqlite/20260530000000_add_user_remark.sql | 1 + .../schema/bootstrap/postgres/001_types_and_tables.sql | 1 + .../schema/drivers/mysql/baseline/001_identity.sql | 1 + .../drivers/postgres/baseline/001_types_and_tables.sql | 1 + .../schema/drivers/sqlite/baseline/001_identity.sql | 1 + .../schema/generated/mysql/baseline/001_identity.sql | 1 + .../schema/generated/postgres/baseline/001_identity.sql | 1 + .../schema/generated/sqlite/baseline/001_identity.sql | 1 + crates/aether-data/schema/logical/001_identity.toml | 6 ++++++ 14 files changed, 21 insertions(+) create mode 100644 crates/aether-data/migrations/mysql/20260530000000_add_user_remark.sql create mode 100644 crates/aether-data/migrations/postgres/20260530000000_add_user_remark.sql create mode 100644 crates/aether-data/migrations/sqlite/20260530000000_add_user_remark.sql diff --git a/crates/aether-data/migrations/mysql/20260403000000_baseline.sql b/crates/aether-data/migrations/mysql/20260403000000_baseline.sql index 9c539a0ea..2a464f09f 100644 --- a/crates/aether-data/migrations/mysql/20260403000000_baseline.sql +++ b/crates/aether-data/migrations/mysql/20260403000000_baseline.sql @@ -2,6 +2,7 @@ CREATE TABLE IF NOT EXISTS users ( id VARCHAR(64) PRIMARY KEY, external_id VARCHAR(255), email VARCHAR(320), + remark VARCHAR(500), username VARCHAR(255), password_hash VARCHAR(255), role VARCHAR(64), diff --git a/crates/aether-data/migrations/mysql/20260530000000_add_user_remark.sql b/crates/aether-data/migrations/mysql/20260530000000_add_user_remark.sql new file mode 100644 index 000000000..311e974af --- /dev/null +++ b/crates/aether-data/migrations/mysql/20260530000000_add_user_remark.sql @@ -0,0 +1,2 @@ +ALTER TABLE users + ADD COLUMN remark VARCHAR(500) NULL AFTER email; diff --git a/crates/aether-data/migrations/postgres/20260403000000_baseline.sql b/crates/aether-data/migrations/postgres/20260403000000_baseline.sql index 7a8cc3bff..dedb507a8 100644 --- a/crates/aether-data/migrations/postgres/20260403000000_baseline.sql +++ b/crates/aether-data/migrations/postgres/20260403000000_baseline.sql @@ -1263,6 +1263,7 @@ CREATE TABLE IF NOT EXISTS public.users ( id character varying(36) NOT NULL, external_id character varying(255), email character varying(255), + remark character varying(500), username character varying(100) NOT NULL, password_hash character varying(255), role public.userrole DEFAULT 'user'::public.userrole NOT NULL, diff --git a/crates/aether-data/migrations/postgres/20260530000000_add_user_remark.sql b/crates/aether-data/migrations/postgres/20260530000000_add_user_remark.sql new file mode 100644 index 000000000..4446a10b7 --- /dev/null +++ b/crates/aether-data/migrations/postgres/20260530000000_add_user_remark.sql @@ -0,0 +1,2 @@ +ALTER TABLE public.users + ADD COLUMN IF NOT EXISTS remark character varying(500); diff --git a/crates/aether-data/migrations/sqlite/20260403000000_baseline.sql b/crates/aether-data/migrations/sqlite/20260403000000_baseline.sql index 741957f48..92cfff82d 100644 --- a/crates/aether-data/migrations/sqlite/20260403000000_baseline.sql +++ b/crates/aether-data/migrations/sqlite/20260403000000_baseline.sql @@ -2,6 +2,7 @@ CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, external_id TEXT, email TEXT UNIQUE, + remark TEXT, username TEXT UNIQUE, password_hash TEXT, role TEXT, diff --git a/crates/aether-data/migrations/sqlite/20260530000000_add_user_remark.sql b/crates/aether-data/migrations/sqlite/20260530000000_add_user_remark.sql new file mode 100644 index 000000000..2da022a41 --- /dev/null +++ b/crates/aether-data/migrations/sqlite/20260530000000_add_user_remark.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD COLUMN remark TEXT; diff --git a/crates/aether-data/schema/bootstrap/postgres/001_types_and_tables.sql b/crates/aether-data/schema/bootstrap/postgres/001_types_and_tables.sql index 44f1df04e..512ed8b9a 100644 --- a/crates/aether-data/schema/bootstrap/postgres/001_types_and_tables.sql +++ b/crates/aether-data/schema/bootstrap/postgres/001_types_and_tables.sql @@ -1393,6 +1393,7 @@ CREATE TABLE IF NOT EXISTS public.users ( id character varying(36) NOT NULL, external_id character varying(255), email character varying(255), + remark character varying(500), username character varying(100) NOT NULL, password_hash character varying(255), role public.userrole DEFAULT 'user'::public.userrole NOT NULL, diff --git a/crates/aether-data/schema/drivers/mysql/baseline/001_identity.sql b/crates/aether-data/schema/drivers/mysql/baseline/001_identity.sql index 66546f5f4..007135274 100644 --- a/crates/aether-data/schema/drivers/mysql/baseline/001_identity.sql +++ b/crates/aether-data/schema/drivers/mysql/baseline/001_identity.sql @@ -2,6 +2,7 @@ CREATE TABLE IF NOT EXISTS users ( id VARCHAR(64) PRIMARY KEY, external_id VARCHAR(255), email VARCHAR(320), + remark VARCHAR(500), username VARCHAR(255), password_hash VARCHAR(255), role VARCHAR(64), diff --git a/crates/aether-data/schema/drivers/postgres/baseline/001_types_and_tables.sql b/crates/aether-data/schema/drivers/postgres/baseline/001_types_and_tables.sql index 999b93d56..42784f994 100644 --- a/crates/aether-data/schema/drivers/postgres/baseline/001_types_and_tables.sql +++ b/crates/aether-data/schema/drivers/postgres/baseline/001_types_and_tables.sql @@ -1263,6 +1263,7 @@ CREATE TABLE IF NOT EXISTS public.users ( id character varying(36) NOT NULL, external_id character varying(255), email character varying(255), + remark character varying(500), username character varying(100) NOT NULL, password_hash character varying(255), role public.userrole DEFAULT 'user'::public.userrole NOT NULL, diff --git a/crates/aether-data/schema/drivers/sqlite/baseline/001_identity.sql b/crates/aether-data/schema/drivers/sqlite/baseline/001_identity.sql index d67ba57cb..23f115380 100644 --- a/crates/aether-data/schema/drivers/sqlite/baseline/001_identity.sql +++ b/crates/aether-data/schema/drivers/sqlite/baseline/001_identity.sql @@ -2,6 +2,7 @@ CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, external_id TEXT, email TEXT UNIQUE, + remark TEXT, username TEXT UNIQUE, password_hash TEXT, role TEXT, diff --git a/crates/aether-data/schema/generated/mysql/baseline/001_identity.sql b/crates/aether-data/schema/generated/mysql/baseline/001_identity.sql index dae1c01da..c3461a72d 100644 --- a/crates/aether-data/schema/generated/mysql/baseline/001_identity.sql +++ b/crates/aether-data/schema/generated/mysql/baseline/001_identity.sql @@ -5,6 +5,7 @@ CREATE TABLE IF NOT EXISTS users ( `id` VARCHAR(64) NOT NULL, `external_id` VARCHAR(255), `email` VARCHAR(320), + `remark` VARCHAR(500), `username` VARCHAR(255), `password_hash` VARCHAR(255), `role` VARCHAR(64), diff --git a/crates/aether-data/schema/generated/postgres/baseline/001_identity.sql b/crates/aether-data/schema/generated/postgres/baseline/001_identity.sql index 51353e7a3..7a286efe0 100644 --- a/crates/aether-data/schema/generated/postgres/baseline/001_identity.sql +++ b/crates/aether-data/schema/generated/postgres/baseline/001_identity.sql @@ -5,6 +5,7 @@ CREATE TABLE IF NOT EXISTS public.users ( id character varying(64) NOT NULL, external_id character varying(255), email character varying(320), + remark character varying(500), username character varying(255), password_hash character varying(255), role character varying(64), diff --git a/crates/aether-data/schema/generated/sqlite/baseline/001_identity.sql b/crates/aether-data/schema/generated/sqlite/baseline/001_identity.sql index f153cd643..dc530b91d 100644 --- a/crates/aether-data/schema/generated/sqlite/baseline/001_identity.sql +++ b/crates/aether-data/schema/generated/sqlite/baseline/001_identity.sql @@ -5,6 +5,7 @@ CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY NOT NULL, external_id TEXT, email TEXT, + remark TEXT, username TEXT, password_hash TEXT, role TEXT, diff --git a/crates/aether-data/schema/logical/001_identity.toml b/crates/aether-data/schema/logical/001_identity.toml index e99fb74db..a3282f2c4 100644 --- a/crates/aether-data/schema/logical/001_identity.toml +++ b/crates/aether-data/schema/logical/001_identity.toml @@ -20,6 +20,12 @@ type = "text" length = 320 nullable = true +[[table.users.columns]] +name = "remark" +type = "text" +length = 500 +nullable = true + [[table.users.columns]] name = "username" type = "text" From d11cf172c1d3783ee814e6f60c0c6b15ce85adc0 Mon Sep 17 00:00:00 2001 From: RWDai <27391645+RWDai@users.noreply.github.com> Date: Sat, 30 May 2026 18:34:08 +0800 Subject: [PATCH 19/23] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E5=A4=87=E6=B3=A8=E8=AF=BB=E5=86=99=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/aether-gateway/src/data/state/auth.rs | 11 ++++++ .../src/handlers/admin/request/users.rs | 8 ++++ .../handlers/admin/users/lifecycle/create.rs | 19 +++++++++- .../handlers/admin/users/lifecycle/reads.rs | 5 +++ .../handlers/admin/users/lifecycle/support.rs | 1 + .../handlers/admin/users/lifecycle/update.rs | 30 +++++++++++++-- .../src/handlers/admin/users/mod.rs | 7 ++-- .../src/handlers/admin/users/shared.rs | 20 ++++++++++ .../state/runtime/auth/user_provisioning.rs | 11 ++++++ .../src/repository/users/memory.rs | 37 +++++++++++++++++++ .../aether-data/src/repository/users/mysql.rs | 21 +++++++++++ .../src/repository/users/postgres.rs | 31 ++++++++++++++++ .../src/repository/users/sqlite.rs | 21 +++++++++++ .../aether-data/src/repository/users/types.rs | 13 +++++++ 14 files changed, 227 insertions(+), 8 deletions(-) diff --git a/apps/aether-gateway/src/data/state/auth.rs b/apps/aether-gateway/src/data/state/auth.rs index 38b0d0b95..686d3bb1c 100644 --- a/apps/aether-gateway/src/data/state/auth.rs +++ b/apps/aether-gateway/src/data/state/auth.rs @@ -467,6 +467,17 @@ impl GatewayDataState { .await } + pub(crate) async fn update_user_remark( + &self, + user_id: &str, + remark: Option, + ) -> Result>, DataLayerError> { + let Some(repository) = self.user_reader.as_ref() else { + return Ok(None); + }; + repository.update_user_remark(user_id, remark).await + } + pub(crate) async fn update_local_auth_user_profile( &self, user_id: &str, diff --git a/apps/aether-gateway/src/handlers/admin/request/users.rs b/apps/aether-gateway/src/handlers/admin/request/users.rs index 12c802676..e5aa18339 100644 --- a/apps/aether-gateway/src/handlers/admin/request/users.rs +++ b/apps/aether-gateway/src/handlers/admin/request/users.rs @@ -438,6 +438,14 @@ impl<'a> AdminAppState<'a> { .await } + pub(crate) async fn update_user_remark( + &self, + user_id: &str, + remark: Option, + ) -> Result>, GatewayError> { + self.app.update_user_remark(user_id, remark).await + } + pub(crate) async fn count_user_pending_refunds( &self, user_id: &str, diff --git a/apps/aether-gateway/src/handlers/admin/users/lifecycle/create.rs b/apps/aether-gateway/src/handlers/admin/users/lifecycle/create.rs index ad5ea3f30..a6c31cddf 100644 --- a/apps/aether-gateway/src/handlers/admin/users/lifecycle/create.rs +++ b/apps/aether-gateway/src/handlers/admin/users/lifecycle/create.rs @@ -1,8 +1,9 @@ use super::super::{ admin_default_user_initial_gift, build_admin_users_read_only_response, disabled_user_policy_detail, disabled_user_policy_field, normalize_admin_feature_settings, - normalize_admin_optional_user_email, normalize_admin_user_group_ids, normalize_admin_user_role, - normalize_admin_username, validate_admin_user_password, AdminCreateUserRequest, + normalize_admin_optional_user_email, normalize_admin_optional_user_remark, + normalize_admin_user_group_ids, normalize_admin_user_role, normalize_admin_username, + validate_admin_user_password, AdminCreateUserRequest, }; use super::support::{admin_user_password_policy, build_admin_user_payload_with_groups}; use crate::handlers::admin::request::{AdminAppState, AdminRequestContext}; @@ -87,6 +88,16 @@ pub(in super::super) async fn build_admin_create_user_response( .into_response()) } }; + let remark = match normalize_admin_optional_user_remark(payload.remark.as_deref()) { + Ok(value) => value, + Err(detail) => { + return Ok(( + http::StatusCode::BAD_REQUEST, + Json(json!({ "detail": detail })), + ) + .into_response()) + } + }; let username = match normalize_admin_username(&payload.username) { Ok(value) => value, Err(detail) => { @@ -220,6 +231,9 @@ pub(in super::super) async fn build_admin_create_user_response( .replace_user_groups_for_user(&user.id, &group_ids) .await?; } + if remark.is_some() { + state.update_user_remark(&user.id, remark.clone()).await?; + } let feature_settings = if feature_settings.is_some() { state .update_user_feature_settings(&user.id, feature_settings.clone()) @@ -231,6 +245,7 @@ pub(in super::super) async fn build_admin_create_user_response( let mut payload = build_admin_user_payload_with_groups(&user, None, None, payload.unlimited, &groups); + payload["remark"] = remark.map(Value::String).unwrap_or(Value::Null); payload["feature_settings"] = feature_settings.unwrap_or(Value::Null); Ok(attach_admin_audit_response( diff --git a/apps/aether-gateway/src/handlers/admin/users/lifecycle/reads.rs b/apps/aether-gateway/src/handlers/admin/users/lifecycle/reads.rs index 7e67fa755..4224e244c 100644 --- a/apps/aether-gateway/src/handlers/admin/users/lifecycle/reads.rs +++ b/apps/aether-gateway/src/handlers/admin/users/lifecycle/reads.rs @@ -176,5 +176,10 @@ pub(in super::super) async fn build_admin_get_user_response( .as_ref() .and_then(|row| row.feature_settings.clone()) .unwrap_or(serde_json::Value::Null); + payload["remark"] = export_row + .as_ref() + .and_then(|row| row.remark.clone()) + .map(serde_json::Value::String) + .unwrap_or(serde_json::Value::Null); Ok(Json(payload).into_response()) } diff --git a/apps/aether-gateway/src/handlers/admin/users/lifecycle/support.rs b/apps/aether-gateway/src/handlers/admin/users/lifecycle/support.rs index 4b5dfbd08..ce0fe32eb 100644 --- a/apps/aether-gateway/src/handlers/admin/users/lifecycle/support.rs +++ b/apps/aether-gateway/src/handlers/admin/users/lifecycle/support.rs @@ -93,6 +93,7 @@ pub(super) fn build_admin_user_export_payload( json!({ "id": row.id, "email": row.email, + "remark": row.remark, "username": row.username, "role": row.role, "allowed_providers": row.allowed_providers, diff --git a/apps/aether-gateway/src/handlers/admin/users/lifecycle/update.rs b/apps/aether-gateway/src/handlers/admin/users/lifecycle/update.rs index 127fd2379..8c1ac5eef 100644 --- a/apps/aether-gateway/src/handlers/admin/users/lifecycle/update.rs +++ b/apps/aether-gateway/src/handlers/admin/users/lifecycle/update.rs @@ -2,8 +2,9 @@ use super::super::{ build_admin_users_bad_request_response, build_admin_users_data_unavailable_response, build_admin_users_read_only_response, disabled_user_policy_detail, disabled_user_policy_field, normalize_admin_feature_settings, normalize_admin_optional_user_email, - normalize_admin_user_group_ids, normalize_admin_user_role, normalize_admin_username, - validate_admin_user_password, AdminUpdateUserPatch, + normalize_admin_optional_user_remark, normalize_admin_user_group_ids, + normalize_admin_user_role, normalize_admin_username, validate_admin_user_password, + AdminUpdateUserPatch, }; use super::support::{ admin_user_id_from_detail_path, admin_user_password_policy, @@ -98,6 +99,20 @@ pub(in super::super) async fn build_admin_update_user_response( }, None => None, }; + let remark = if field_presence.contains("remark") { + match normalize_admin_optional_user_remark(payload.remark.as_deref()) { + Ok(value) => Some(value), + Err(detail) => { + return Ok(( + http::StatusCode::BAD_REQUEST, + Json(json!({ "detail": detail })), + ) + .into_response()) + } + } + } else { + None + }; if let Some(email) = email.as_deref() { if state .is_other_user_auth_email_taken(email, &user_id) @@ -186,7 +201,8 @@ pub(in super::super) async fn build_admin_update_user_response( || role.is_some() || payload.is_active.is_some() || group_ids.is_some() - || feature_settings.is_some(); + || feature_settings.is_some() + || remark.is_some(); if needs_auth_user_write && !state.has_auth_user_write_capability() { return Ok(build_admin_users_read_only_response( "当前为只读模式,无法更新用户", @@ -309,6 +325,9 @@ pub(in super::super) async fn build_admin_update_user_response( .update_user_feature_settings(&user_id, feature_settings) .await?; } + if let Some(remark) = remark { + state.update_user_remark(&user_id, remark).await?; + } let Some(user) = state.find_user_auth_by_id(&user_id).await? else { return Ok(( @@ -340,6 +359,11 @@ pub(in super::super) async fn build_admin_update_user_response( .as_ref() .and_then(|row| row.feature_settings.clone()) .unwrap_or(Value::Null); + payload["remark"] = export_row + .as_ref() + .and_then(|row| row.remark.clone()) + .map(Value::String) + .unwrap_or(Value::Null); Ok(attach_admin_audit_response( Json(payload).into_response(), diff --git a/apps/aether-gateway/src/handlers/admin/users/mod.rs b/apps/aether-gateway/src/handlers/admin/users/mod.rs index 957cb7cf0..a3b249d1e 100644 --- a/apps/aether-gateway/src/handlers/admin/users/mod.rs +++ b/apps/aether-gateway/src/handlers/admin/users/mod.rs @@ -50,9 +50,10 @@ use self::shared::{ build_admin_users_data_unavailable_response, build_admin_users_read_only_response, disabled_user_policy_detail, disabled_user_policy_field, format_optional_datetime_iso8601, legacy_admin_list_policy_mode, legacy_admin_rate_limit_policy_mode, - normalize_admin_optional_user_email, normalize_admin_user_group_ids, normalize_admin_user_role, - normalize_admin_username, validate_admin_user_password, AdminCreateUserApiKeyRequest, - AdminCreateUserRequest, AdminToggleUserApiKeyLockRequest, AdminUpdateUserApiKeyRequest, + normalize_admin_optional_user_email, normalize_admin_optional_user_remark, + normalize_admin_user_group_ids, normalize_admin_user_role, normalize_admin_username, + validate_admin_user_password, AdminCreateUserApiKeyRequest, AdminCreateUserRequest, + AdminToggleUserApiKeyLockRequest, AdminUpdateUserApiKeyRequest, }; pub(crate) use self::shared::{ normalize_admin_list_policy_mode, normalize_admin_rate_limit_policy_mode, diff --git a/apps/aether-gateway/src/handlers/admin/users/shared.rs b/apps/aether-gateway/src/handlers/admin/users/shared.rs index 615f2daf2..4dd422d9b 100644 --- a/apps/aether-gateway/src/handlers/admin/users/shared.rs +++ b/apps/aether-gateway/src/handlers/admin/users/shared.rs @@ -73,6 +73,8 @@ pub(super) struct AdminCreateUserRequest { #[serde(default)] pub(super) email: Option, #[serde(default)] + pub(super) remark: Option, + #[serde(default)] pub(super) role: Option, #[serde(default)] pub(super) initial_gift_usd: Option, @@ -89,6 +91,8 @@ pub(super) struct AdminUpdateUserRequest { #[serde(default)] pub(super) email: Option, #[serde(default)] + pub(super) remark: Option, + #[serde(default)] pub(super) username: Option, #[serde(default)] pub(super) password: Option, @@ -176,6 +180,22 @@ pub(super) fn normalize_admin_optional_user_email( Ok(Some(normalized)) } +pub(super) fn normalize_admin_optional_user_remark( + value: Option<&str>, +) -> Result, String> { + let Some(value) = value else { + return Ok(None); + }; + let value = value.trim(); + if value.is_empty() { + return Ok(None); + } + if value.chars().count() > 500 { + return Err("备注不能超过500个字符".to_string()); + } + Ok(Some(value.to_string())) +} + pub(super) fn normalize_admin_username(value: &str) -> Result { let value = value.trim(); if value.is_empty() { diff --git a/apps/aether-gateway/src/state/runtime/auth/user_provisioning.rs b/apps/aether-gateway/src/state/runtime/auth/user_provisioning.rs index 50ef5c378..43fbc4797 100644 --- a/apps/aether-gateway/src/state/runtime/auth/user_provisioning.rs +++ b/apps/aether-gateway/src/state/runtime/auth/user_provisioning.rs @@ -78,6 +78,17 @@ impl AppState { Ok(updated) } + pub(crate) async fn update_user_remark( + &self, + user_id: &str, + remark: Option, + ) -> Result>, GatewayError> { + self.data + .update_user_remark(user_id, remark) + .await + .map_err(|err| GatewayError::Internal(err.to_string())) + } + pub(crate) async fn find_active_provider_name( &self, provider_id: &str, diff --git a/crates/aether-data/src/repository/users/memory.rs b/crates/aether-data/src/repository/users/memory.rs index 1562b427d..fe2d245da 100644 --- a/crates/aether-data/src/repository/users/memory.rs +++ b/crates/aether-data/src/repository/users/memory.rs @@ -1631,6 +1631,43 @@ impl UserReadRepository for InMemoryUserReadRepository { Ok(normalized) } + async fn update_user_remark( + &self, + user_id: &str, + remark: Option, + ) -> Result>, DataLayerError> { + if self.read_only { + return Ok(None); + } + + let user_exists = self + .auth_by_id + .read() + .expect("user repository lock") + .contains_key(user_id) + || self + .export_rows + .read() + .expect("user repository lock") + .iter() + .any(|row| row.id == user_id); + if !user_exists { + return Ok(None); + } + + if let Some(row) = self + .export_rows + .write() + .expect("user repository lock") + .iter_mut() + .find(|row| row.id == user_id) + { + row.remark = remark.clone(); + } + + Ok(Some(remark)) + } + async fn create_local_auth_user( &self, email: Option, diff --git a/crates/aether-data/src/repository/users/mysql.rs b/crates/aether-data/src/repository/users/mysql.rs index 5b6fb7cb6..3abb15341 100644 --- a/crates/aether-data/src/repository/users/mysql.rs +++ b/crates/aether-data/src/repository/users/mysql.rs @@ -28,6 +28,7 @@ const USER_EXPORT_COLUMNS: &str = r#" SELECT id, email, + remark, email_verified, username, password_hash, @@ -1259,6 +1260,25 @@ WHERE id = ? Ok(normalized) } + async fn update_user_remark( + &self, + user_id: &str, + remark: Option, + ) -> Result>, DataLayerError> { + sqlx::query("UPDATE users SET remark = ?, updated_at = ? WHERE id = ?") + .bind(remark) + .bind(chrono::Utc::now().timestamp()) + .bind(user_id) + .execute(&self.pool) + .await + .map_sql_err()?; + sqlx::query_scalar("SELECT remark FROM users WHERE id = ?") + .bind(user_id) + .fetch_optional(&self.pool) + .await + .map_sql_err() + } + async fn create_local_auth_user( &self, email: Option, @@ -1962,6 +1982,7 @@ fn map_user_export_row(row: &MySqlRow) -> Result, + ) -> Result>, DataLayerError> { + sqlx::query("UPDATE users SET remark = $1, updated_at = $2 WHERE id = $3") + .bind(remark) + .bind(chrono::Utc::now()) + .bind(user_id) + .execute(&self.pool) + .await + .map_postgres_err()?; + sqlx::query_scalar("SELECT remark FROM users WHERE id = $1") + .bind(user_id) + .fetch_optional(&self.pool) + .await + .map_postgres_err() + } + pub async fn create_local_auth_user( &self, email: Option, @@ -2228,6 +2250,7 @@ fn map_user_export_row(row: &sqlx::postgres::PgRow) -> Result, + ) -> Result>, DataLayerError> { + self.update_user_remark(user_id, remark).await + } + async fn create_local_auth_user( &self, email: Option, diff --git a/crates/aether-data/src/repository/users/sqlite.rs b/crates/aether-data/src/repository/users/sqlite.rs index 7abcb3220..da4d78486 100644 --- a/crates/aether-data/src/repository/users/sqlite.rs +++ b/crates/aether-data/src/repository/users/sqlite.rs @@ -28,6 +28,7 @@ const USER_EXPORT_COLUMNS: &str = r#" SELECT id, email, + remark, email_verified, username, password_hash, @@ -1259,6 +1260,25 @@ WHERE id = ? Ok(normalized) } + async fn update_user_remark( + &self, + user_id: &str, + remark: Option, + ) -> Result>, DataLayerError> { + sqlx::query("UPDATE users SET remark = ?, updated_at = ? WHERE id = ?") + .bind(remark) + .bind(chrono::Utc::now().timestamp()) + .bind(user_id) + .execute(&self.pool) + .await + .map_sql_err()?; + sqlx::query_scalar("SELECT remark FROM users WHERE id = ?") + .bind(user_id) + .fetch_optional(&self.pool) + .await + .map_sql_err() + } + async fn create_local_auth_user( &self, email: Option, @@ -1966,6 +1986,7 @@ fn map_user_export_row(row: &SqliteRow) -> Result, + pub remark: Option, pub email_verified: bool, pub username: String, pub password_hash: Option, @@ -281,6 +282,7 @@ impl StoredUserExportRow { Ok(Self { id, email, + remark: None, email_verified, username, password_hash, @@ -329,6 +331,11 @@ impl StoredUserExportRow { self } + pub fn with_remark(mut self, remark: Option) -> Self { + self.remark = remark; + self + } + fn with_legacy_policy_modes(mut self) -> Self { self.allowed_providers_mode = legacy_list_policy_mode(&self.allowed_providers); self.allowed_api_formats_mode = legacy_list_policy_mode(&self.allowed_api_formats); @@ -939,6 +946,12 @@ pub trait UserReadRepository: Send + Sync { settings: Option, ) -> Result, crate::DataLayerError>; + async fn update_user_remark( + &self, + user_id: &str, + remark: Option, + ) -> Result>, crate::DataLayerError>; + async fn create_local_auth_user( &self, email: Option, From 537d4f44f6f6d2d212a177455933cbee89849da5 Mon Sep 17 00:00:00 2001 From: RWDai <27391645+RWDai@users.noreply.github.com> Date: Sat, 30 May 2026 18:34:43 +0800 Subject: [PATCH 20/23] =?UTF-8?q?feat:=20=E7=94=A8=E6=88=B7=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E6=94=AF=E6=8C=81=E5=A4=87=E6=B3=A8=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/api/users.ts | 3 +++ .../users/components/UserFormDialog.vue | 21 +++++++++++++++++++ frontend/src/views/admin/Users.vue | 21 +++++++++++++++++++ 3 files changed, 45 insertions(+) diff --git a/frontend/src/api/users.ts b/frontend/src/api/users.ts index 10bf5419d..8b070ea05 100644 --- a/frontend/src/api/users.ts +++ b/frontend/src/api/users.ts @@ -36,6 +36,7 @@ export interface User { id: string // UUID username: string email: string + remark?: string | null role: UserRole is_active: boolean unlimited: boolean @@ -61,6 +62,7 @@ export interface CreateUserRequest { username: string password: string email: string + remark?: string | null role?: UserRole initial_gift_usd?: number | null unlimited?: boolean @@ -70,6 +72,7 @@ export interface CreateUserRequest { export interface UpdateUserRequest { email?: string + remark?: string | null is_active?: boolean role?: UserRole unlimited?: boolean diff --git a/frontend/src/features/users/components/UserFormDialog.vue b/frontend/src/features/users/components/UserFormDialog.vue index 545802807..3b2c3a0ac 100644 --- a/frontend/src/features/users/components/UserFormDialog.vue +++ b/frontend/src/features/users/components/UserFormDialog.vue @@ -104,6 +104,21 @@ />

+
+ +