From ad20064d1518cfdd2e1acc371739943f571d0bd5 Mon Sep 17 00:00:00 2001 From: Zyy Date: Sat, 29 Aug 2026 20:22:14 +0800 Subject: [PATCH] fix(admin): unify control-plane error responses Co-authored-by: Codex --- backend/crates/gateway-admin/src/lib.rs | 15 +- .../gateway-admin/src/model/accounts.rs | 9 +- backend/crates/gateway-admin/src/model/mod.rs | 14 +- .../gateway-admin/src/ports/provider.rs | 6 +- .../src/use_case/account_groups.rs | 2 +- .../gateway-admin/src/use_case/accounts.rs | 35 +- .../crates/gateway-admin/src/use_case/auth.rs | 4 +- .../gateway-admin/src/use_case/backup.rs | 6 +- .../gateway-admin/src/use_case/client_keys.rs | 8 +- .../crates/gateway-admin/src/use_case/mod.rs | 50 +-- .../src/use_case/observability.rs | 21 +- .../gateway-admin/src/use_case/settings.rs | 2 +- .../gateway-admin/src/use_case/system.rs | 9 +- .../gateway-admin/tests/use_case/accounts.rs | 77 ++++- .../gateway-api/src/admin/account_groups.rs | 31 +- .../crates/gateway-api/src/admin/accounts.rs | 58 ++-- backend/crates/gateway-api/src/admin/auth.rs | 23 +- .../crates/gateway-api/src/admin/backups.rs | 30 +- .../gateway-api/src/admin/client_keys.rs | 36 +- .../crates/gateway-api/src/admin/extract.rs | 87 +++++ backend/crates/gateway-api/src/admin/mod.rs | 14 + .../src/admin/observability/mod.rs | 10 +- .../src/admin/observability/presenter.rs | 22 +- .../src/admin/observability/routes.rs | 22 +- .../crates/gateway-api/src/admin/settings.rs | 22 +- .../crates/gateway-api/src/admin/system.rs | 20 +- backend/crates/gateway-api/src/admin/wire.rs | 185 +++++++--- .../gateway-api/tests/admin/accounts.rs | 13 +- .../crates/gateway-api/tests/admin/errors.rs | 320 ++++++++++++++++++ backend/crates/gateway-api/tests/admin/mod.rs | 1 + .../crates/gateway-api/tests/admin/wire.rs | 18 +- .../crates/gateway-api/tests/architecture.rs | 2 + .../gateway-core/src/engine/execution.rs | 34 +- .../crates/gateway-core/src/engine/probe.rs | 43 ++- .../gateway-core/tests/engine/execution.rs | 64 +++- backend/crates/providers/openai/src/admin.rs | 3 +- .../crates/providers/openai/tests/admin.rs | 2 +- docs/api.md | 63 +++- docs/architecture.md | 15 + frontend/src/api/error.ts | 96 ++++++ frontend/src/api/request.ts | 22 +- frontend/src/utils/async.ts | 3 + .../components/AccountConnectionTestModal.vue | 6 + .../composables/useAccountConnectionTest.ts | 142 +++++++- .../composables/useAccountResetCredits.ts | 9 +- .../usage/components/OpsErrorDetailModal.vue | 14 +- .../views/usage/components/OpsErrorPanel.vue | 16 +- frontend/src/views/usage/constants.ts | 2 +- .../views/usage/utils/opsErrorPresentation.ts | 81 +++++ 49 files changed, 1434 insertions(+), 353 deletions(-) create mode 100644 backend/crates/gateway-api/src/admin/extract.rs create mode 100644 backend/crates/gateway-api/tests/admin/errors.rs create mode 100644 frontend/src/api/error.ts create mode 100644 frontend/src/views/usage/utils/opsErrorPresentation.ts diff --git a/backend/crates/gateway-admin/src/lib.rs b/backend/crates/gateway-admin/src/lib.rs index d77a9e8ee..c31d8d8c4 100644 --- a/backend/crates/gateway-admin/src/lib.rs +++ b/backend/crates/gateway-admin/src/lib.rs @@ -134,9 +134,9 @@ impl fmt::Debug for AdminConfig { /// Admin-owned 启动配置错误;不回显任何配置值。 #[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] pub enum AdminConfigError { - #[error("configuration field `{0}` is invalid")] + #[error("配置字段 `{0}` 不合法")] InvalidField(&'static str), - #[error("admin.default_password does not meet the initial password policy")] + #[error("admin.default_password 不符合初始密码策略")] WeakInitialPassword, } @@ -322,9 +322,9 @@ fn backup_worker_contribution( task: backup::task::BackupTask, ) -> Result, AdminError> { let id = WorkerId::try_new(WorkerKind::Backup, BACKUP_WORKER_OWNER) - .map_err(|_| AdminError::internal("Backup worker id is invalid"))?; + .map_err(|_| AdminError::internal("备份 Worker ID 不合法"))?; let restart = DaemonRestartPolicy::try_new(Duration::from_secs(1), Duration::from_secs(60)) - .map_err(|_| AdminError::internal("Backup worker restart policy is invalid"))?; + .map_err(|_| AdminError::internal("备份 Worker 重启策略不合法"))?; let registration = WorkerRegistration::try_new( id, WorkerRunnable::Daemon { @@ -332,12 +332,12 @@ fn backup_worker_contribution( task: Box::new(task), }, ) - .map_err(|_| AdminError::internal("Backup worker registration is invalid"))?; + .map_err(|_| AdminError::internal("备份 Worker 注册信息不合法"))?; Ok(vec![WorkerContribution::Registration(registration)]) } fn provider_kind(value: &'static str) -> Result { - ProviderKind::new(value).map_err(|_| AdminError::internal("Built-in Provider kind is invalid")) + ProviderKind::new(value).map_err(|_| AdminError::internal("内置 Provider 类型不合法")) } fn map_provider_registry_error(error: ProviderAdminError) -> AdminError { @@ -347,11 +347,12 @@ fn map_provider_registry_error(error: ProviderAdminError) -> AdminError { } ProviderAdminErrorKind::NotFound => AdminErrorKind::NotFound, ProviderAdminErrorKind::Conflict => AdminErrorKind::Conflict, + ProviderAdminErrorKind::Ambiguous => AdminErrorKind::UpstreamResultUnknown, ProviderAdminErrorKind::Unavailable | ProviderAdminErrorKind::CredentialRefreshRequired => { AdminErrorKind::Unavailable } ProviderAdminErrorKind::BadGateway => AdminErrorKind::BadGateway, ProviderAdminErrorKind::Internal => AdminErrorKind::Internal, }; - AdminError::new(kind, "Provider registry initialization failed") + AdminError::new(kind, "Provider 注册表初始化失败") } diff --git a/backend/crates/gateway-admin/src/model/accounts.rs b/backend/crates/gateway-admin/src/model/accounts.rs index 0bd95eb58..24fd63a8d 100644 --- a/backend/crates/gateway-admin/src/model/accounts.rs +++ b/backend/crates/gateway-admin/src/model/accounts.rs @@ -5,7 +5,11 @@ use std::pin::Pin; use chrono::{DateTime, Utc}; use futures::Stream; -use gateway_core::routing::ProviderKind; +use gateway_core::{ + engine::{UpstreamSendState, probe::AccountProbeErrorSource}, + error::GatewayErrorKind, + routing::ProviderKind, +}; use super::{PageSize, Revision, account_groups::AccountGroupRef, observability::TimeRange}; @@ -249,6 +253,9 @@ pub enum AccountConnectionTestEvent { }, Completed, Failed { + source: AccountProbeErrorSource, + gateway_error_code: GatewayErrorKind, + send_state: Option, message: String, provider_error_code: Option, provider_error_type: Option, diff --git a/backend/crates/gateway-admin/src/model/mod.rs b/backend/crates/gateway-admin/src/model/mod.rs index d6f50d3da..892908d73 100644 --- a/backend/crates/gateway-admin/src/model/mod.rs +++ b/backend/crates/gateway-admin/src/model/mod.rs @@ -21,11 +21,12 @@ pub enum AdminErrorKind { Conflict, RateLimited, BadGateway, + UpstreamResultUnknown, Unavailable, Internal, } -/// 不携带基础设施细节的管理用例错误。 +/// 不携带基础设施细节、可安全返回给管理员的管理用例错误。 #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] #[error("{message}")] pub struct AdminError { @@ -47,6 +48,12 @@ impl AdminError { self.kind } + /// 返回已脱敏、可公开给管理员的文案。 + #[must_use] + pub fn message(&self) -> &str { + &self.message + } + #[must_use] pub fn invalid(message: impl Into) -> Self { Self::new(AdminErrorKind::Invalid, message) @@ -72,6 +79,11 @@ impl AdminError { Self::new(AdminErrorKind::BadGateway, message) } + #[must_use] + pub fn upstream_result_unknown(message: impl Into) -> Self { + Self::new(AdminErrorKind::UpstreamResultUnknown, message) + } + #[must_use] pub fn unavailable(message: impl Into) -> Self { Self::new(AdminErrorKind::Unavailable, message) diff --git a/backend/crates/gateway-admin/src/ports/provider.rs b/backend/crates/gateway-admin/src/ports/provider.rs index ff63e4642..385db7ee1 100644 --- a/backend/crates/gateway-admin/src/ports/provider.rs +++ b/backend/crates/gateway-admin/src/ports/provider.rs @@ -28,6 +28,8 @@ pub enum ProviderAdminErrorKind { Unsupported, NotFound, Conflict, + /// Provider 已发起不可逆操作,但无法确认最终执行结果。 + Ambiguous, Unavailable, CredentialRefreshRequired, BadGateway, @@ -36,8 +38,8 @@ pub enum ProviderAdminErrorKind { /// 不携带 OAuth 请求材料的管理错误。 /// -/// `message` 用于当前认证管理请求展示;上游没有标准 `error.message` 时, -/// Provider 可以按其官方契约回退为完整的非成功响应正文。 +/// `message` 是 Provider 局部诊断,可能包含原始上游正文;通用管理用例不得自动把它作为公开文案。 +/// 只有明确拥有原始诊断合同的调用方才能读取,`Debug` 始终只记录是否存在。 #[derive(Clone, PartialEq, Eq, thiserror::Error)] #[error("provider admin operation failed: {kind:?}")] pub struct ProviderAdminError { diff --git a/backend/crates/gateway-admin/src/use_case/account_groups.rs b/backend/crates/gateway-admin/src/use_case/account_groups.rs index 07fa093b4..a87ec506d 100644 --- a/backend/crates/gateway-admin/src/use_case/account_groups.rs +++ b/backend/crates/gateway-admin/src/use_case/account_groups.rs @@ -84,7 +84,7 @@ impl AccountGroupService for DefaultAccountGroupService { command: CreateAccountGroup, ) -> Result { let id = AccountGroupId::new(format!("grp_{}", Uuid::now_v7().simple())) - .map_err(|_| AdminError::internal("Failed to create account group ID"))?; + .map_err(|_| AdminError::internal("创建账号组 ID 失败"))?; self.publish( self.store .create_account_group( diff --git a/backend/crates/gateway-admin/src/use_case/accounts.rs b/backend/crates/gateway-admin/src/use_case/accounts.rs index 556bc92d1..517dea13f 100644 --- a/backend/crates/gateway-admin/src/use_case/accounts.rs +++ b/backend/crates/gateway-admin/src/use_case/accounts.rs @@ -84,9 +84,7 @@ pub trait AccountsService: Send + Sync { &self, _account_id: &ProviderAccountId, ) -> Result { - Err(AdminError::invalid( - "Provider profile statistics are not supported", - )) + Err(AdminError::invalid("当前 Provider 不支持账号统计")) } async fn reset_credits( @@ -94,9 +92,7 @@ pub trait AccountsService: Send + Sync { _context: &MutationContext, _account_id: ProviderAccountId, ) -> Result { - Err(AdminError::invalid( - "Provider reset credits are not supported", - )) + Err(AdminError::invalid("当前 Provider 不支持重置额度")) } async fn consume_reset_credit( @@ -104,9 +100,7 @@ pub trait AccountsService: Send + Sync { _context: &MutationContext, _command: ConsumeProviderResetCredit, ) -> Result { - Err(AdminError::invalid( - "Provider reset credits are not supported", - )) + Err(AdminError::invalid("当前 Provider 不支持重置额度")) } async fn models( @@ -168,7 +162,7 @@ impl DefaultAccountsService { .load_account(account_id.as_str()) .await .map_err(|error| map_store_error(error, "provider account"))? - .ok_or_else(|| AdminError::not_found("Provider account was not found")) + .ok_or_else(|| AdminError::not_found("Provider 账号不存在")) } async fn provider_for_account( @@ -302,7 +296,7 @@ impl AccountsService for DefaultAccountsService { let mut quotas = futures::future::join_all(page.items.iter().map(|item| async { let account = &item.account; let account_id = ProviderAccountId::new(account.id.clone()) - .map_err(|_| AdminError::invalid("Invalid provider account ID"))?; + .map_err(|_| AdminError::invalid("Provider 账号 ID 不合法"))?; // 单个账号的 quota 投影失败(Provider 未注册或 quota 读取失败)不拖垮整页: // 该账号降级为空额度投影,其余账号与页面状态照常返回。 let provider = match self.providers.require(&account.provider_kind) { @@ -368,9 +362,7 @@ impl AccountsService for DefaultAccountsService { account_ids: Vec, ) -> Result { if account_ids.is_empty() || account_ids.len() > 200 { - return Err(AdminError::invalid( - "Account export requires between 1 and 200 accounts", - )); + return Err(AdminError::invalid("账号导出数量必须在 1 到 200 之间")); } let exported_ids = account_ids.clone(); let mut grouped = BTreeMap::>::new(); @@ -385,7 +377,7 @@ impl AccountsService for DefaultAccountsService { let unique = ids.iter().collect::>(); unique.len() != ids.len() }) { - return Err(AdminError::invalid("Account export contains duplicate IDs")); + return Err(AdminError::invalid("账号导出列表包含重复 ID")); } let mut documents = Vec::with_capacity(grouped.len()); for (provider_kind, ids) in grouped { @@ -475,7 +467,7 @@ impl AccountsService for DefaultAccountsService { command: UpdateAccount, ) -> Result { let account_id = ProviderAccountId::new(command.account_id.clone()) - .map_err(|_| AdminError::invalid("Invalid provider account ID"))?; + .map_err(|_| AdminError::invalid("Provider 账号 ID 不合法"))?; let (_, provider) = self.provider_for_account(&account_id).await?; let enabled = command.enabled; let result = self @@ -503,7 +495,7 @@ impl AccountsService for DefaultAccountsService { .iter() .map(|id| { ProviderAccountId::new(id.clone()) - .map_err(|_| AdminError::invalid("Invalid provider account ID")) + .map_err(|_| AdminError::invalid("Provider 账号 ID 不合法")) }) .collect::, _>>()?; let mut providers = BTreeMap::< @@ -674,6 +666,9 @@ impl AccountsService for DefaultAccountsService { .map(|response| String::from_utf8_lossy(response.body()).into_owned()); let message = error.client_message().to_owned(); vec![AccountConnectionTestEvent::Failed { + source: error.source(), + gateway_error_code: error.kind(), + send_state: error.send_state(), message, provider_error_code: error.client_error_code().map(ToOwned::to_owned), provider_error_type: error.client_error_type().map(ToOwned::to_owned), @@ -693,11 +688,7 @@ fn map_reset_credits_error_after_refresh( error: crate::ports::provider::ProviderAdminError, ) -> AdminError { if error.kind() == crate::ports::provider::ProviderAdminErrorKind::CredentialRefreshRequired { - return AdminError::bad_gateway( - error - .message() - .unwrap_or("OpenAI reset-credit upstream rejected the refreshed credential"), - ); + return AdminError::bad_gateway("上游服务拒绝了刷新后的凭据"); } map_provider_error(error, "provider reset credits") } diff --git a/backend/crates/gateway-admin/src/use_case/auth.rs b/backend/crates/gateway-admin/src/use_case/auth.rs index 100dd4687..f56114d5d 100644 --- a/backend/crates/gateway-admin/src/use_case/auth.rs +++ b/backend/crates/gateway-admin/src/use_case/auth.rs @@ -199,12 +199,12 @@ fn hash_admin_password(password: &str) -> Result { Argon2::default() .hash_password(password.as_bytes(), &salt) .map(|hash| hash.to_string()) - .map_err(|_| AdminError::internal("Failed to hash administrator password")) + .map_err(|_| AdminError::internal("管理员密码哈希失败")) } fn verify_admin_password(password: &str, encoded: &str) -> Result { let hash = PasswordHash::new(encoded) - .map_err(|_| AdminError::internal("Stored administrator password hash is invalid"))?; + .map_err(|_| AdminError::internal("已保存的管理员密码哈希不合法"))?; Ok(Argon2::default() .verify_password(password.as_bytes(), &hash) .is_ok()) diff --git a/backend/crates/gateway-admin/src/use_case/backup.rs b/backend/crates/gateway-admin/src/use_case/backup.rs index 2db0de26f..87334b13c 100644 --- a/backend/crates/gateway-admin/src/use_case/backup.rs +++ b/backend/crates/gateway-admin/src/use_case/backup.rs @@ -429,9 +429,7 @@ fn map_backup_error(error: BackupError) -> AdminError { code::ACTIVE_TASK_CONFLICT | code::STATE_CONFLICT | code::STORAGE_IDENTITY_LOCKED => { AdminError::conflict(error.message().to_owned()) } - code::STORE_UNAVAILABLE => { - AdminError::new(AdminErrorKind::Unavailable, error.message().to_owned()) - } - _ => AdminError::bad_gateway(error.message().to_owned()), + code::STORE_UNAVAILABLE => AdminError::new(AdminErrorKind::Unavailable, "备份服务暂不可用"), + _ => AdminError::bad_gateway("备份服务请求失败"), } } diff --git a/backend/crates/gateway-admin/src/use_case/client_keys.rs b/backend/crates/gateway-admin/src/use_case/client_keys.rs index 271b9c141..ef1f38bdf 100644 --- a/backend/crates/gateway-admin/src/use_case/client_keys.rs +++ b/backend/crates/gateway-admin/src/use_case/client_keys.rs @@ -77,7 +77,7 @@ impl ClientKeyService for DefaultClientKeyService { .reveal_client_key(id) .await .map_err(|error| map_store_error(error, "client API key"))? - .ok_or_else(|| AdminError::not_found("Client API key was not found")) + .ok_or_else(|| AdminError::not_found("Client API Key 不存在")) } async fn create( @@ -86,7 +86,7 @@ impl ClientKeyService for DefaultClientKeyService { command: CreateClientKey, ) -> Result { let id = ClientApiKeyId::new(format!("key_{}", Uuid::now_v7().simple())) - .map_err(|_| AdminError::internal("Failed to create Client API key ID"))?; + .map_err(|_| AdminError::internal("创建 Client API Key ID 失败"))?; let mut bytes = [0_u8; 32]; OsRng.fill_bytes(&mut bytes); let plaintext = format!("sk_{}", URL_SAFE_NO_PAD.encode(bytes)); @@ -176,7 +176,7 @@ fn validate_cursor(query: &ClientKeyListQuery) -> Result<(), AdminError> { }; if cursor.sort != query.sort { return Err(AdminError::invalid( - "Client API key cursor sort does not match the query", + "Client API Key 游标排序与查询条件不一致", )); } let matches = matches!( @@ -198,6 +198,6 @@ fn validate_cursor(query: &ClientKeyListQuery) -> Result<(), AdminError> { if matches { Ok(()) } else { - Err(AdminError::invalid("Invalid Client API key cursor")) + Err(AdminError::invalid("Client API Key 游标不合法")) } } diff --git a/backend/crates/gateway-admin/src/use_case/mod.rs b/backend/crates/gateway-admin/src/use_case/mod.rs index 9105dbb97..6eae1a6ea 100644 --- a/backend/crates/gateway-admin/src/use_case/mod.rs +++ b/backend/crates/gateway-admin/src/use_case/mod.rs @@ -42,7 +42,15 @@ fn map_store_error(error: AdminStoreError, resource: &'static str) -> AdminError } AdminStoreErrorKind::Unavailable => AdminErrorKind::Unavailable, }; - AdminError::new(kind, format!("{resource} operation failed")) + tracing::warn!(resource, error_kind = ?error.kind(), "admin store operation failed"); + let message = match kind { + AdminErrorKind::Invalid => "请求参数不合法", + AdminErrorKind::NotFound => "请求的资源不存在", + AdminErrorKind::Conflict => "当前资源状态冲突,请刷新后重试", + AdminErrorKind::Unavailable => "依赖服务暂不可用", + _ => "服务内部错误", + }; + AdminError::new(kind, message) } fn map_provider_error( @@ -56,15 +64,23 @@ fn map_provider_error( ProviderAdminErrorKind::Unsupported => AdminErrorKind::Invalid, ProviderAdminErrorKind::NotFound => AdminErrorKind::NotFound, ProviderAdminErrorKind::Conflict => AdminErrorKind::Conflict, + ProviderAdminErrorKind::Ambiguous => AdminErrorKind::UpstreamResultUnknown, ProviderAdminErrorKind::Unavailable => AdminErrorKind::Unavailable, ProviderAdminErrorKind::CredentialRefreshRequired => AdminErrorKind::Unavailable, ProviderAdminErrorKind::BadGateway => AdminErrorKind::BadGateway, ProviderAdminErrorKind::Internal => AdminErrorKind::Internal, }; - let message = error - .message() - .map(ToOwned::to_owned) - .unwrap_or_else(|| format!("{resource} operation failed")); + tracing::warn!(resource, error_kind = ?error.kind(), "admin provider operation failed"); + let message = match kind { + AdminErrorKind::Invalid => "Provider 请求不合法", + AdminErrorKind::NotFound => "Provider 资源不存在", + AdminErrorKind::Conflict => "Provider 资源状态冲突,请刷新后重试", + AdminErrorKind::BadGateway => "上游服务请求失败", + AdminErrorKind::UpstreamResultUnknown => "上游执行结果未知,请刷新状态后再决定是否重试", + AdminErrorKind::Unavailable => "Provider 服务暂不可用", + AdminErrorKind::Internal => "服务内部错误", + _ => "Provider 操作失败", + }; AdminError::new(kind, message) } @@ -73,7 +89,7 @@ async fn publish_committed( revision: crate::model::Revision, ) -> Result<(), AdminError> { let revision = ConfigRevision::new(revision.get()) - .map_err(|_| AdminError::internal("Committed configuration revision is invalid"))?; + .map_err(|_| AdminError::internal("已提交的配置版本不合法"))?; snapshot.publish_committed(revision).await; Ok(()) } @@ -88,7 +104,7 @@ async fn required_credential( .credential_details(provider_kind, account_id) .await .map_err(|error| map_store_error(error, resource))? - .ok_or_else(|| AdminError::not_found(format!("{resource} was not found"))) + .ok_or_else(|| AdminError::not_found("Provider 凭据不存在")) } async fn pending_authorization( @@ -120,7 +136,7 @@ async fn pending_authorization( fn validate_prepared_import( provider_kind: &ProviderKind, prepared: &PreparedCredentialImport, - resource: &'static str, + _resource: &'static str, ) -> Result<(), AdminError> { if prepared.provider_kind != *provider_kind || prepared @@ -128,9 +144,7 @@ fn validate_prepared_import( .iter() .any(|credential| credential.provider_kind != *provider_kind) { - return Err(AdminError::conflict(format!( - "{resource} prepared facts do not match the requested Provider scope" - ))); + return Err(AdminError::conflict("Provider 准备结果与请求范围不一致")); } Ok(()) } @@ -138,15 +152,13 @@ fn validate_prepared_import( fn validate_prepared_rotation( account: &crate::model::accounts::AccountRecord, prepared: &PreparedCredentialRotation, - resource: &'static str, + _resource: &'static str, ) -> Result<(), AdminError> { let facts = prepared.facts(); if facts.account_id.as_str() != account.id.as_str() || facts.provider_kind != account.provider_kind { - return Err(AdminError::conflict(format!( - "{resource} prepared facts do not match the current credential" - ))); + return Err(AdminError::conflict("Provider 准备结果与当前凭据不一致")); } Ok(()) } @@ -160,9 +172,7 @@ async fn validate_authorization_commit( if prepared.pending.provider_kind() != provider_kind || !prepared.pending.owner_binding().matches_context(context) { - let validation_error = AdminError::conflict(format!( - "{resource} pending authorization binding is invalid" - )); + let validation_error = AdminError::conflict("OAuth 授权绑定已失效,请重新发起授权"); if let Err(error) = prepared.abort().await { tracing::warn!( resource, @@ -188,9 +198,7 @@ async fn validate_authorization_commit( _ => false, }; if !matches_target { - let validation_error = AdminError::conflict(format!( - "{resource} prepared credential does not match its pending target" - )); + let validation_error = AdminError::conflict("OAuth 凭据与待提交目标不一致"); if let Err(error) = prepared.abort().await { tracing::warn!( resource, diff --git a/backend/crates/gateway-admin/src/use_case/observability.rs b/backend/crates/gateway-admin/src/use_case/observability.rs index ea9296023..17c5afe70 100644 --- a/backend/crates/gateway-admin/src/use_case/observability.rs +++ b/backend/crates/gateway-admin/src/use_case/observability.rs @@ -186,7 +186,7 @@ impl ObservabilityService for DefaultObservabilityService { async fn usage_record_detail(&self, request_id: &str) -> Result { if request_id.trim().is_empty() { - return Err(AdminError::invalid("Usage record ID is required")); + return Err(AdminError::invalid("用量记录 ID 不能为空")); } let mut detail = self .store @@ -286,9 +286,7 @@ fn build_usage_insights( |point| point.granularity, ); if trend.iter().any(|point| point.granularity != granularity) { - return Err(AdminError::internal( - "Usage insight trend contains mixed granularities", - )); + return Err(AdminError::internal("用量洞察趋势包含不一致的时间粒度")); } let requests = &overview.requests; @@ -405,14 +403,15 @@ impl UsageStandardCosts { self.total = Some(match self.total.take() { Some(current) => current .checked_add(&amount) - .ok_or_else(|| AdminError::internal("Usage standard cost exceeds decimal range"))?, + .ok_or_else(|| AdminError::internal("用量标准成本超出数值范围"))?, None => amount.clone(), }); match self.by_bucket.entry(bucket_start) { Entry::Occupied(mut entry) => { - let sum = entry.get().checked_add(&amount).ok_or_else(|| { - AdminError::internal("Usage standard cost exceeds decimal range") - })?; + let sum = entry + .get() + .checked_add(&amount) + .ok_or_else(|| AdminError::internal("用量标准成本超出数值范围"))?; entry.insert(sum); } Entry::Vacant(entry) => { @@ -612,9 +611,9 @@ fn trend(kind: TrendKind, points: Vec) -> Result Result<(), AdminError> if valid { Ok(()) } else { - Err(AdminError::invalid("Runtime settings violate constraints")) + Err(AdminError::invalid("运行时设置不满足约束")) } } diff --git a/backend/crates/gateway-admin/src/use_case/system.rs b/backend/crates/gateway-admin/src/use_case/system.rs index e7fdc8342..0fcc4c69d 100644 --- a/backend/crates/gateway-admin/src/use_case/system.rs +++ b/backend/crates/gateway-admin/src/use_case/system.rs @@ -94,5 +94,12 @@ fn map_system_error(error: SystemOperationError) -> AdminError { SystemOperationErrorKind::Upstream => AdminErrorKind::BadGateway, SystemOperationErrorKind::Internal => AdminErrorKind::Internal, }; - AdminError::new(kind, error.message()) + let message = match kind { + AdminErrorKind::Invalid => "系统操作请求不合法", + AdminErrorKind::Conflict => "系统当前状态不允许执行该操作", + AdminErrorKind::BadGateway => "系统更新服务请求失败", + AdminErrorKind::Internal => "系统操作失败", + _ => "系统操作失败", + }; + AdminError::new(kind, message) } diff --git a/backend/crates/gateway-admin/tests/use_case/accounts.rs b/backend/crates/gateway-admin/tests/use_case/accounts.rs index c2eab1085..144bee271 100644 --- a/backend/crates/gateway-admin/tests/use_case/accounts.rs +++ b/backend/crates/gateway-admin/tests/use_case/accounts.rs @@ -5,11 +5,15 @@ use chrono::{TimeDelta, Utc}; use futures::{StreamExt as _, future::BoxFuture}; use gateway_core::{ engine::{ + UpstreamSendState, credential::{ AccountStatusFacts, CredentialState, OpaqueProviderData, ProviderAccountId, QuotaEvidence, QuotaState, resolve_account_status, }, - probe::{AccountProbe, AccountProbeError, AccountProbeRequest, AccountProbeResult}, + probe::{ + AccountProbe, AccountProbeError, AccountProbeErrorSource, AccountProbeRequest, + AccountProbeResult, + }, }, error::{ClientVisibleUpstreamError, GatewayError, GatewayErrorKind}, operation::{GenerateRequest, Operation, ProtocolPayload}, @@ -950,6 +954,9 @@ async fn connection_test_should_probe_unavailable_account() { assert!(matches!( events.last(), Some(AccountConnectionTestEvent::Failed { + source: AccountProbeErrorSource::Upstream, + gateway_error_code: GatewayErrorKind::RateLimited, + send_state: Some(UpstreamSendState::NotSent), message, provider_error_code: Some(code), provider_error_type: Some(error_type), @@ -984,6 +991,9 @@ async fn connection_test_rate_limited_probe_returns_provider_failure() { assert!(matches!( events.last(), Some(AccountConnectionTestEvent::Failed { + source: AccountProbeErrorSource::Provider, + gateway_error_code: GatewayErrorKind::RateLimited, + send_state: Some(UpstreamSendState::NotSent), message, .. }) @@ -1722,7 +1732,37 @@ async fn reset_credit_oauth_refresh_should_reuse_the_exact_consume_command() { } #[tokio::test] -async fn accounts_refresh_should_preserve_the_provider_failure_message() { +async fn reset_credit_unknown_result_should_keep_a_stable_admin_kind() { + let events = events(); + let provider = FakeProviderAdmin::new("openai", events.clone()); + provider.fail_next(ProviderAdminErrorKind::Ambiguous); + let store = FakeAccountStore::new("openai", events); + let services = accounts_service(provider, store).await; + let command = ConsumeProviderResetCredit { + account_id: ProviderAccountId::new("acct_test").expect("account ID"), + credit_id: Some("credit_1".to_owned()), + redeem_request_id: uuid::Uuid::parse_str("244e790c-42a3-4ec9-a45d-a32b218bc8ac") + .expect("UUID v4"), + }; + + let error = services + .accounts() + .consume_reset_credit(&context("reset-credit-ambiguous"), command) + .await + .expect_err("ambiguous consume result must remain classified"); + + assert_eq!( + error.kind(), + gateway_admin::model::AdminErrorKind::UpstreamResultUnknown + ); + assert_eq!( + error.to_string(), + "上游执行结果未知,请刷新状态后再决定是否重试" + ); +} + +#[tokio::test] +async fn accounts_refresh_should_not_expose_the_provider_failure_message() { let events = events(); let provider = FakeProviderAdmin::new("openai", events.clone()); let upstream_message = "Your refresh token has already been used."; @@ -1740,7 +1780,8 @@ async fn accounts_refresh_should_preserve_the_provider_failure_message() { .expect_err("Provider preparation must fail"); assert_eq!(error.kind(), gateway_admin::model::AdminErrorKind::Conflict); - assert_eq!(error.to_string(), upstream_message); + assert_eq!(error.to_string(), "Provider 资源状态冲突,请刷新后重试"); + assert!(!error.to_string().contains(upstream_message)); assert_eq!( recorded(&events), ["store.load_account", "provider.prepare_refresh"] @@ -1950,12 +1991,16 @@ impl AccountProbe for FailingAccountProbe { Some("usage_exhausted".to_owned()), Some("invalid_request_error".to_owned()), ); - Err(GatewayError::new( - GatewayErrorKind::RateLimited, - "upstream capacity is temporarily unavailable", - ) - .with_client_visible_upstream_error(detail) - .into()) + Err(AccountProbeError::new( + GatewayError::new( + GatewayErrorKind::RateLimited, + "upstream capacity is temporarily unavailable", + ) + .with_client_visible_upstream_error(detail), + AccountProbeErrorSource::Upstream, + Some(UpstreamSendState::NotSent), + None, + )) }) } } @@ -1971,11 +2016,15 @@ impl AccountProbe for FreeModelQuotaProbe { ) -> BoxFuture<'_, Result> { Box::pin(async move { self.store.set_account_after_probe(account_record("xai")); - Err(GatewayError::new( - GatewayErrorKind::RateLimited, - "xAI free model quota is exhausted", - ) - .into()) + Err(AccountProbeError::new( + GatewayError::new( + GatewayErrorKind::RateLimited, + "xAI free model quota is exhausted", + ), + AccountProbeErrorSource::Provider, + Some(UpstreamSendState::NotSent), + None, + )) }) } } diff --git a/backend/crates/gateway-api/src/admin/account_groups.rs b/backend/crates/gateway-api/src/admin/account_groups.rs index 439586394..15ce80965 100644 --- a/backend/crates/gateway-api/src/admin/account_groups.rs +++ b/backend/crates/gateway-api/src/admin/account_groups.rs @@ -3,8 +3,8 @@ use std::collections::BTreeMap; use axum::{ - Json, Router, - extract::{Query, State}, + Router, + extract::State, http::StatusCode, response::IntoResponse, routing::{get, post}, @@ -22,8 +22,8 @@ use gateway_core::routing::AccountGroupId; use serde::{Deserialize, Serialize}; use super::{ - AdminAuth, AdminEnvelope, AdminError, AdminResponse, AdminSessionState, PageMeta, - WireValidationError, wire::map_admin_service_error, + AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse, AdminSessionState, + PageMeta, WireValidationError, wire::map_admin_service_error, }; const DEFAULT_PAGE_SIZE: u32 = 50; @@ -235,7 +235,7 @@ where async fn list( _auth: AdminAuth, State(state): State, - Query(query): Query, + AdminQuery(query): AdminQuery, ) -> Result where S: AdminSessionState + Send + Sync, @@ -255,7 +255,7 @@ where async fn create( auth: AdminAuth, State(state): State, - Json(request): Json, + AdminJson(request): AdminJson, ) -> Result where S: AdminSessionState + Send + Sync, @@ -281,7 +281,7 @@ where async fn update( auth: AdminAuth, State(state): State, - Json(request): Json, + AdminJson(request): AdminJson, ) -> Result where S: AdminSessionState + Send + Sync, @@ -308,7 +308,7 @@ where async fn enable( auth: AdminAuth, State(state): State, - Json(request): Json, + AdminJson(request): AdminJson, ) -> Result where S: AdminSessionState + Send + Sync, @@ -319,7 +319,7 @@ where async fn disable( auth: AdminAuth, State(state): State, - Json(request): Json, + AdminJson(request): AdminJson, ) -> Result where S: AdminSessionState + Send + Sync, @@ -355,7 +355,7 @@ where async fn delete( auth: AdminAuth, State(state): State, - Json(request): Json, + AdminJson(request): AdminJson, ) -> Result where S: AdminSessionState + Send + Sync, @@ -387,12 +387,11 @@ fn mutation_response( } fn group_id(value: String) -> Result { - AccountGroupId::new(value).map_err(|_| AdminError::bad_request("Invalid account group ID")) + AccountGroupId::new(value).map_err(|_| AdminError::bad_request("账号组 ID 不合法")) } fn group_color(value: &str) -> Result { - AccountGroupColor::parse(value) - .ok_or_else(|| AdminError::bad_request("Invalid account group color")) + AccountGroupColor::parse(value).ok_or_else(|| AdminError::bad_request("账号组颜色不合法")) } fn validate_group_fields(name: &str, description: Option<&str>) -> Result<(), AdminError> { @@ -403,15 +402,15 @@ fn validate_group_fields(name: &str, description: Option<&str>) -> Result<(), Ad || description .is_some_and(|value| value.len() > 4096 || value.chars().any(char::is_control)) { - return Err(AdminError::bad_request("Invalid account group request")); + return Err(AdminError::bad_request("账号组请求不合法")); } Ok(()) } fn map_wire_error(_: WireValidationError) -> AdminError { - AdminError::bad_request("Invalid account group query") + AdminError::bad_request("账号组查询参数不合法") } fn map_service_error(error: gateway_admin::model::AdminError) -> AdminError { - map_admin_service_error(error, "Account group repository unavailable") + map_admin_service_error(error) } diff --git a/backend/crates/gateway-api/src/admin/accounts.rs b/backend/crates/gateway-api/src/admin/accounts.rs index 55c578336..2288dd65b 100644 --- a/backend/crates/gateway-api/src/admin/accounts.rs +++ b/backend/crates/gateway-api/src/admin/accounts.rs @@ -3,8 +3,8 @@ use std::{collections::BTreeSet, convert::Infallible, fmt}; use axum::{ - Json, Router, - extract::{Query, State}, + Router, + extract::State, http::{HeaderValue, StatusCode, header::CACHE_CONTROL}, response::{ IntoResponse, Response, @@ -44,8 +44,8 @@ use uuid::{Uuid, Version}; use super::presenter::{format_compact_number, format_decimal_currency, format_number}; use super::{ - AdminAuth, AdminEnvelope, AdminError, AdminResponse, AdminSessionState, PageMeta, - WireValidationError, + AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse, AdminSessionState, + PageMeta, WireValidationError, }; const DEFAULT_PAGE_SIZE: u32 = 50; @@ -841,6 +841,9 @@ impl From for AccountConnectionTestEvent { "success": true }), DomainConnectionTestEvent::Failed { + source, + gateway_error_code, + send_state, message, provider_error_code, provider_error_type, @@ -849,6 +852,9 @@ impl From for AccountConnectionTestEvent { upstream_body, } => serde_json::json!({ "type": "error", + "source": source.as_str(), + "gatewayErrorCode": gateway_error_code.as_str(), + "sendState": send_state.map(gateway_core::engine::UpstreamSendState::as_str), "error": message, "providerErrorCode": provider_error_code, "providerErrorType": provider_error_type, @@ -1315,7 +1321,7 @@ where async fn batch_update_accounts( auth: AdminAuth, State(state): State, - Json(request): Json, + AdminJson(request): AdminJson, ) -> Result where S: AdminSessionState + Send + Sync, @@ -1336,7 +1342,7 @@ where async fn list_accounts( _auth: AdminAuth, State(state): State, - Query(query): Query, + AdminQuery(query): AdminQuery, ) -> Result where S: AdminSessionState + Send + Sync, @@ -1357,7 +1363,7 @@ where async fn account_detail( _auth: AdminAuth, State(state): State, - Query(query): Query, + AdminQuery(query): AdminQuery, ) -> Result where S: AdminSessionState + Send + Sync, @@ -1378,7 +1384,7 @@ where async fn export_accounts( auth: AdminAuth, State(state): State, - Query(query): Query, + AdminQuery(query): AdminQuery, ) -> Result where S: AdminSessionState + Send + Sync, @@ -1401,7 +1407,7 @@ where async fn import_accounts( auth: AdminAuth, State(state): State, - Json(request): Json, + AdminJson(request): AdminJson, ) -> Result where S: AdminSessionState + Send + Sync, @@ -1429,7 +1435,7 @@ where async fn start_account_authorization( auth: AdminAuth, State(state): State, - Json(request): Json, + AdminJson(request): AdminJson, ) -> Result where S: AdminSessionState + Send + Sync, @@ -1463,7 +1469,7 @@ where async fn complete_account_authorization( auth: AdminAuth, State(state): State, - Json(request): Json, + AdminJson(request): AdminJson, ) -> Result where S: AdminSessionState + Send + Sync, @@ -1497,7 +1503,7 @@ where async fn rotate_account( auth: AdminAuth, State(state): State, - Json(request): Json, + AdminJson(request): AdminJson, ) -> Result where S: AdminSessionState + Send + Sync, @@ -1520,7 +1526,7 @@ where async fn update_account( auth: AdminAuth, State(state): State, - Json(request): Json, + AdminJson(request): AdminJson, ) -> Result where S: AdminSessionState + Send + Sync, @@ -1541,7 +1547,7 @@ where async fn delete_accounts( auth: AdminAuth, State(state): State, - Json(request): Json, + AdminJson(request): AdminJson, ) -> Result where S: AdminSessionState + Send + Sync, @@ -1563,7 +1569,7 @@ where async fn refresh_account( auth: AdminAuth, State(state): State, - Json(request): Json, + AdminJson(request): AdminJson, ) -> Result where S: AdminSessionState + Send + Sync, @@ -1582,7 +1588,7 @@ where async fn recover_account( auth: AdminAuth, State(state): State, - Json(request): Json, + AdminJson(request): AdminJson, ) -> Result where S: AdminSessionState + Send + Sync, @@ -1601,7 +1607,7 @@ where async fn account_quota( _auth: AdminAuth, State(state): State, - Query(query): Query, + AdminQuery(query): AdminQuery, ) -> Result where S: AdminSessionState + Send + Sync, @@ -1622,7 +1628,7 @@ where async fn account_profile_statistics( _auth: AdminAuth, State(state): State, - Query(query): Query, + AdminQuery(query): AdminQuery, ) -> Result where S: AdminSessionState + Send + Sync, @@ -1643,7 +1649,7 @@ where async fn refresh_account_quota( _auth: AdminAuth, State(state): State, - Json(request): Json, + AdminJson(request): AdminJson, ) -> Result where S: AdminSessionState + Send + Sync, @@ -1664,7 +1670,7 @@ where async fn account_reset_credits( auth: AdminAuth, State(state): State, - Query(query): Query, + AdminQuery(query): AdminQuery, ) -> Result where S: AdminSessionState + Send + Sync, @@ -1685,7 +1691,7 @@ where async fn consume_account_reset_credit( auth: AdminAuth, State(state): State, - Json(request): Json, + AdminJson(request): AdminJson, ) -> Result where S: AdminSessionState + Send + Sync, @@ -1706,7 +1712,7 @@ where async fn account_models( _auth: AdminAuth, State(state): State, - Query(query): Query, + AdminQuery(query): AdminQuery, ) -> Result where S: AdminSessionState + Send + Sync, @@ -1725,7 +1731,7 @@ where async fn refresh_account_models( _auth: AdminAuth, State(state): State, - Json(request): Json, + AdminJson(request): AdminJson, ) -> Result where S: AdminSessionState + Send + Sync, @@ -1744,7 +1750,7 @@ where async fn test_account_connection( _auth: AdminAuth, State(state): State, - Query(query): Query, + AdminQuery(query): AdminQuery, ) -> Result>>, AdminError> where S: AdminSessionState + Send + Sync, @@ -2236,9 +2242,9 @@ fn china_datetime(value: &DateTime) -> String { } fn map_wire_error(error: WireValidationError) -> AdminError { - AdminError::bad_request(format!("Invalid {}", error.field())) + AdminError::bad_request(format!("{} 字段不合法", error.field())) } fn map_service_error(error: AdminServiceError) -> AdminError { - super::wire::map_admin_service_error(error, "Account directory unavailable") + super::wire::map_admin_service_error(error) } diff --git a/backend/crates/gateway-api/src/admin/auth.rs b/backend/crates/gateway-api/src/admin/auth.rs index 72df75344..675778472 100644 --- a/backend/crates/gateway-api/src/admin/auth.rs +++ b/backend/crates/gateway-api/src/admin/auth.rs @@ -3,7 +3,7 @@ use std::fmt; use axum::{ - Json, Router, + Router, extract::{FromRequestParts, State}, http::{HeaderMap, HeaderValue, StatusCode, header::SET_COOKIE, request::Parts}, response::{IntoResponse, Response}, @@ -16,7 +16,7 @@ use gateway_admin::{ use serde::{Deserialize, Serialize}; use tower_http::request_id::RequestId; -use super::{AdminEnvelope, AdminError, AdminResponse}; +use super::{AdminEnvelope, AdminError, AdminJson, AdminResponse}; const REQUEST_ID_HEADER: &str = "x-request-id"; const ADMIN_SESSION_COOKIE: &str = "cpr_admin_session"; @@ -52,8 +52,7 @@ where async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { let principal = require_admin_auth(state, &parts.headers).await?; - let request_id = admin_request_id(parts) - .ok_or_else(|| AdminError::internal("Missing admin request context"))?; + let request_id = admin_request_id(parts).ok_or_else(AdminError::internal)?; Ok(Self { context: AdminRequestContext { principal, @@ -90,7 +89,7 @@ where { Ok(Some(admin_user_id)) => Ok(admin_user_id), Ok(None) => Err(AdminError::admin_session_required()), - Err(_) => Err(AdminError::internal("Failed to validate admin session")), + Err(_) => Err(AdminError::internal()), } } @@ -107,7 +106,7 @@ where { Ok(true) => Ok(AdminPrincipal::ApiKey), Ok(false) => Err(AdminError::invalid_admin_api_key()), - Err(_) => Err(AdminError::internal("Failed to validate admin API key")), + Err(_) => Err(AdminError::internal()), }; } @@ -209,7 +208,7 @@ where async fn login( State(state): State, - Json(payload): Json, + AdminJson(payload): AdminJson, ) -> Result where S: AdminSessionState + Send + Sync, @@ -232,8 +231,7 @@ where ); response.headers_mut().insert( SET_COOKIE, - HeaderValue::from_str(&cookie) - .map_err(|_| AdminError::internal("Failed to create admin session cookie"))?, + HeaderValue::from_str(&cookie).map_err(|_| AdminError::internal())?, ); Ok(response) } @@ -250,7 +248,7 @@ where .auth() .validate_session(admin_session_cookie(&headers).as_deref()) .await - .map_err(|_| AdminError::internal("Failed to validate admin session"))?; + .map_err(|_| AdminError::internal())?; Ok(AdminResponse::new( StatusCode::OK, AdminEnvelope::ok(AdminSessionStatusData::new(authenticated)), @@ -270,8 +268,7 @@ where let cookie = format!("{ADMIN_SESSION_COOKIE}=; {ADMIN_SESSION_COOKIE_ATTRS}; Max-Age=0"); response.headers_mut().insert( SET_COOKIE, - HeaderValue::from_str(&cookie) - .map_err(|_| AdminError::internal("Failed to clear admin session cookie"))?, + HeaderValue::from_str(&cookie).map_err(|_| AdminError::internal())?, ); Ok(response) } @@ -287,6 +284,6 @@ fn admin_session_cookie(headers: &HeaderMap) -> Option { fn map_login_error(error: LoginError) -> AdminError { match error { LoginError::InvalidCredentials => AdminError::invalid_admin_credentials(), - LoginError::Unavailable => AdminError::internal("Failed to create admin session"), + LoginError::Unavailable => AdminError::internal(), } } diff --git a/backend/crates/gateway-api/src/admin/backups.rs b/backend/crates/gateway-api/src/admin/backups.rs index 23934f658..2a0a20482 100644 --- a/backend/crates/gateway-api/src/admin/backups.rs +++ b/backend/crates/gateway-api/src/admin/backups.rs @@ -3,8 +3,8 @@ //! 路径统一位于 `/api/admin/settings/backups/*`,内部由独立 `BackupService` 承担业务。 use axum::{ - Json, Router, - extract::{Query, State}, + Router, + extract::State, http::StatusCode, response::IntoResponse, routing::{get, post}, @@ -18,8 +18,8 @@ use secrecy::{ExposeSecret as _, SecretString}; use serde::{Deserialize, Serialize}; use super::{ - AdminAuth, AdminEnvelope, AdminError, AdminPageData, AdminResponse, AdminSessionState, - PageMeta, wire::map_admin_service_error, + AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminPageData, AdminQuery, AdminResponse, + AdminSessionState, PageMeta, wire::map_admin_service_error, }; /// 备份设置视图;`secretAccessKey` 返回已保存的明文凭据,由前端掩码显示。 @@ -256,7 +256,7 @@ where async fn update_backup_storage( auth: AdminAuth, State(state): State, - Json(request): Json, + AdminJson(request): AdminJson, ) -> Result where S: AdminSessionState + Send + Sync, @@ -304,7 +304,7 @@ where async fn update_backup_schedule( auth: AdminAuth, State(state): State, - Json(request): Json, + AdminJson(request): AdminJson, ) -> Result where S: AdminSessionState + Send + Sync, @@ -331,28 +331,28 @@ where async fn backup_records( _auth: AdminAuth, State(state): State, - Query(query): Query, + AdminQuery(query): AdminQuery, ) -> Result where S: AdminSessionState + Send + Sync, { let page_size = gateway_admin::model::PageSize::new(query.page_size.unwrap_or(20)) - .map_err(|_| AdminError::bad_request("Invalid pageSize"))?; + .map_err(|_| AdminError::bad_request("pageSize 不合法"))?; let page = query.page.unwrap_or(1); if page == 0 { - return Err(AdminError::bad_request("Invalid page")); + return Err(AdminError::bad_request("page 不合法")); } let status = match query.status.as_deref() { None => None, Some(value) => BackupStatus::parse(value) .map(Some) - .ok_or_else(|| AdminError::bad_request("Invalid status"))?, + .ok_or_else(|| AdminError::bad_request("status 不合法"))?, }; let trigger = match query.trigger.as_deref() { None => None, Some(value) => BackupTriggerKind::parse(value) .map(Some) - .ok_or_else(|| AdminError::bad_request("Invalid trigger"))?, + .ok_or_else(|| AdminError::bad_request("trigger 不合法"))?, }; let domain_query = gateway_admin::model::backup::BackupRecordListQuery { page, @@ -386,7 +386,7 @@ where async fn create_backup( auth: AdminAuth, State(state): State, - body: Option>, + body: Option>, ) -> Result where S: AdminSessionState + Send + Sync, @@ -417,7 +417,7 @@ pub struct CreateBackupRequest { async fn download_backup_url( auth: AdminAuth, State(state): State, - Json(request): Json, + AdminJson(request): AdminJson, ) -> Result where S: AdminSessionState + Send + Sync, @@ -437,7 +437,7 @@ where async fn delete_backup( auth: AdminAuth, State(state): State, - Json(request): Json, + AdminJson(request): AdminJson, ) -> Result where S: AdminSessionState + Send + Sync, @@ -455,5 +455,5 @@ where } fn map_service_error(error: gateway_admin::model::AdminError) -> AdminError { - map_admin_service_error(error, "Backup service unavailable") + map_admin_service_error(error) } diff --git a/backend/crates/gateway-api/src/admin/client_keys.rs b/backend/crates/gateway-api/src/admin/client_keys.rs index 57e6b32b8..c72d23ec6 100644 --- a/backend/crates/gateway-api/src/admin/client_keys.rs +++ b/backend/crates/gateway-api/src/admin/client_keys.rs @@ -17,16 +17,16 @@ use gateway_core::{ use serde::{Deserialize, Serialize}; use axum::{ - Json, Router, - extract::{Query, State}, + Router, + extract::State, http::{HeaderValue, StatusCode, header::CACHE_CONTROL}, response::{IntoResponse, Response}, routing::{get, post}, }; use super::{ - AdminAuth, AdminEnvelope, AdminError, AdminResponse, AdminSessionState, WireValidationError, - wire::map_admin_service_error, + AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse, AdminSessionState, + WireValidationError, wire::map_admin_service_error, }; const MAX_CURSOR_BYTES: usize = 512; @@ -669,7 +669,7 @@ where async fn list_client_keys( _auth: AdminAuth, State(state): State, - Query(query): Query, + AdminQuery(query): AdminQuery, ) -> Result where S: AdminSessionState + Send + Sync, @@ -680,15 +680,14 @@ where .list(query.into_command().map_err(map_wire_error)?) .await .map_err(map_service_error)?; - let data = ClientKeyListData::try_from(result) - .map_err(|_| AdminError::internal("Failed to encode client key cursor"))?; + let data = ClientKeyListData::try_from(result).map_err(|_| AdminError::internal())?; Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data))) } async fn create_client_key( auth: AdminAuth, State(state): State, - Json(payload): Json, + AdminJson(payload): AdminJson, ) -> Result where S: AdminSessionState + Send + Sync, @@ -709,7 +708,7 @@ where async fn reveal_client_key( _auth: AdminAuth, State(state): State, - Query(query): Query, + AdminQuery(query): AdminQuery, ) -> Result where S: AdminSessionState + Send + Sync, @@ -735,7 +734,7 @@ where async fn update_client_key( auth: AdminAuth, State(state): State, - Json(payload): Json, + AdminJson(payload): AdminJson, ) -> Result where S: AdminSessionState + Send + Sync, @@ -753,7 +752,7 @@ where async fn disable_client_key( auth: AdminAuth, State(state): State, - Json(payload): Json, + AdminJson(payload): AdminJson, ) -> Result where S: AdminSessionState + Send + Sync, @@ -774,7 +773,7 @@ where async fn enable_client_key( auth: AdminAuth, State(state): State, - Json(payload): Json, + AdminJson(payload): AdminJson, ) -> Result where S: AdminSessionState + Send + Sync, @@ -795,7 +794,7 @@ where async fn delete_client_key( auth: AdminAuth, State(state): State, - Json(payload): Json, + AdminJson(payload): AdminJson, ) -> Result where S: AdminSessionState + Send + Sync, @@ -822,13 +821,14 @@ fn mutation_response( fn map_wire_error(error: WireValidationError) -> AdminError { match error.field() { - "cursor" => AdminError::bad_request("Invalid client key cursor"), - "clientKeyRevealNotFound" => AdminError::not_found("Client API key was not found"), - "clientKeyMutationNotFound" => AdminError::not_found("client API key was not found"), - _ => AdminError::bad_request("Invalid client API key request"), + "cursor" => AdminError::bad_request("Client API Key 游标不合法"), + "clientKeyRevealNotFound" | "clientKeyMutationNotFound" => { + AdminError::not_found("Client API Key 不存在") + } + _ => AdminError::bad_request("Client API Key 请求不合法"), } } fn map_service_error(error: gateway_admin::model::AdminError) -> AdminError { - map_admin_service_error(error, "Configuration repository unavailable") + map_admin_service_error(error) } diff --git a/backend/crates/gateway-api/src/admin/extract.rs b/backend/crates/gateway-api/src/admin/extract.rs new file mode 100644 index 000000000..fc54fab41 --- /dev/null +++ b/backend/crates/gateway-api/src/admin/extract.rs @@ -0,0 +1,87 @@ +//! 管理端 JSON 与 Query extractor 的稳定 rejection 映射。 + +use axum::{ + Json, + extract::{ + FromRequest, FromRequestParts, OptionalFromRequest, Query, Request, + rejection::{JsonRejection, QueryRejection}, + }, + http::{StatusCode, request::Parts}, +}; +use serde::de::DeserializeOwned; + +use super::AdminError; + +/// 将 Axum JSON rejection 收口为管理端错误信封。 +pub struct AdminJson(pub T); + +impl FromRequest for AdminJson +where + T: DeserializeOwned, + S: Send + Sync, +{ + type Rejection = AdminError; + + async fn from_request(request: Request, state: &S) -> Result { + as FromRequest>::from_request(request, state) + .await + .map(|Json(value)| Self(value)) + .map_err(map_json_rejection) + } +} + +impl OptionalFromRequest for AdminJson +where + T: DeserializeOwned, + S: Send + Sync, +{ + type Rejection = AdminError; + + async fn from_request(request: Request, state: &S) -> Result, Self::Rejection> { + as OptionalFromRequest>::from_request(request, state) + .await + .map(|value| value.map(|Json(value)| Self(value))) + .map_err(map_json_rejection) + } +} + +/// 将 Axum Query rejection 收口为管理端错误信封。 +pub struct AdminQuery(pub T); + +impl FromRequestParts for AdminQuery +where + T: DeserializeOwned, + S: Send + Sync, +{ + type Rejection = AdminError; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + Query::::from_request_parts(parts, state) + .await + .map(|Query(value)| Self(value)) + .map_err(map_query_rejection) + } +} + +fn map_json_rejection(rejection: JsonRejection) -> AdminError { + let status = rejection.status(); + match rejection { + JsonRejection::JsonSyntaxError(_) => AdminError::malformed_json(), + JsonRejection::JsonDataError(_) => { + AdminError::invalid_request(StatusCode::UNPROCESSABLE_ENTITY, "请求字段不合法") + } + JsonRejection::MissingJsonContentType(_) => AdminError::invalid_request( + StatusCode::UNSUPPORTED_MEDIA_TYPE, + "请求必须使用 application/json", + ), + JsonRejection::BytesRejection(_) if status == StatusCode::PAYLOAD_TOO_LARGE => { + AdminError::invalid_request(status, "请求体过大") + } + JsonRejection::BytesRejection(_) => AdminError::internal(), + _ => AdminError::invalid_request(status, "请求体不合法"), + } +} + +fn map_query_rejection(_: QueryRejection) -> AdminError { + AdminError::bad_request("请求参数不合法") +} diff --git a/backend/crates/gateway-api/src/admin/mod.rs b/backend/crates/gateway-api/src/admin/mod.rs index bc3c334ee..850fd0dfa 100644 --- a/backend/crates/gateway-api/src/admin/mod.rs +++ b/backend/crates/gateway-api/src/admin/mod.rs @@ -5,6 +5,7 @@ use axum::{ http::{HeaderValue, header}, middleware, response::Response, + routing::any, }; pub mod account_groups; @@ -12,6 +13,7 @@ pub mod accounts; pub mod auth; pub mod backups; pub mod client_keys; +mod extract; pub mod observability; pub mod presenter; pub mod settings; @@ -19,6 +21,7 @@ pub mod system; pub mod wire; pub use auth::{AdminAuth, AdminSessionState}; +pub use extract::{AdminJson, AdminQuery}; pub use wire::{ ADMIN_OK_CODE, ADMIN_OK_MESSAGE, AdminEnvelope, AdminError, AdminErrorBody, AdminErrorCode, AdminPageData, AdminResponse, PageMeta, WireValidationError, @@ -38,9 +41,20 @@ where .merge(observability::router::()) .merge(settings::router::()) .merge(system::router::()) + .method_not_allowed_fallback(method_not_allowed) + .route("/api/admin", any(admin_not_found)) + .route("/api/admin/{*path}", any(admin_not_found)) .layer(middleware::map_response(no_store)) } +async fn method_not_allowed() -> AdminError { + AdminError::method_not_allowed() +} + +async fn admin_not_found() -> AdminError { + AdminError::admin_route_not_found() +} + async fn no_store(mut response: Response) -> Response { response .headers_mut() diff --git a/backend/crates/gateway-api/src/admin/observability/mod.rs b/backend/crates/gateway-api/src/admin/observability/mod.rs index 5c917eab5..a1b0a4305 100644 --- a/backend/crates/gateway-api/src/admin/observability/mod.rs +++ b/backend/crates/gateway-api/src/admin/observability/mod.rs @@ -2,13 +2,7 @@ use std::{collections::BTreeMap, num::NonZeroU32}; -use axum::{ - Router, - extract::{Query, State}, - http::StatusCode, - response::IntoResponse, - routing::get, -}; +use axum::{Router, extract::State, http::StatusCode, response::IntoResponse, routing::get}; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use chrono::{DateTime, Duration, Utc}; use gateway_admin::model::{PageSize as DomainPageSize, observability as domain}; @@ -17,7 +11,7 @@ use serde_json::Value; use super::presenter::{format_compact_number, format_decimal_currency, format_number}; use super::{ - AdminAuth, AdminEnvelope, AdminError, AdminResponse, AdminSessionState, PageMeta, + AdminAuth, AdminEnvelope, AdminError, AdminQuery, AdminResponse, AdminSessionState, PageMeta, WireValidationError, wire::map_admin_service_error, }; diff --git a/backend/crates/gateway-api/src/admin/observability/presenter.rs b/backend/crates/gateway-api/src/admin/observability/presenter.rs index f6b5955cb..b87ef4440 100644 --- a/backend/crates/gateway-api/src/admin/observability/presenter.rs +++ b/backend/crates/gateway-api/src/admin/observability/presenter.rs @@ -1001,21 +1001,21 @@ pub(crate) fn diagnostics_view( } pub(crate) fn map_wire_error(error: WireValidationError) -> AdminError { + if error.field() == "timeRange" { + return AdminError::invalid_time_range(); + } let message = match error.field() { - "timeRange" => "Invalid time range", - "statusCode" => "Status code must be between 100 and 599", - "attemptIndex" => "Attempt index is out of range", - "kind" => "Invalid dashboard trend kind", - "dimension" => "Invalid diagnostics dimension", - "outcome" => "Invalid Observability query", - "id" => "Usage record ID is required", - "page" | "pageSize" => "Invalid Observability query", - "cursor" => "Invalid observability cursor", - _ => "Invalid observability query", + "statusCode" => "statusCode 必须在 100 到 599 之间", + "attemptIndex" => "attemptIndex 超出有效范围", + "kind" => "Dashboard 趋势类型不合法", + "dimension" => "诊断维度不合法", + "id" => "用量记录 ID 不能为空", + "cursor" => "观测数据游标不合法", + _ => "观测查询参数不合法", }; AdminError::bad_request(message) } pub(crate) fn map_service_error(error: gateway_admin::model::AdminError) -> AdminError { - map_admin_service_error(error, "Observability repository unavailable") + map_admin_service_error(error) } diff --git a/backend/crates/gateway-api/src/admin/observability/routes.rs b/backend/crates/gateway-api/src/admin/observability/routes.rs index 5f3f249d2..c7b784b35 100644 --- a/backend/crates/gateway-api/src/admin/observability/routes.rs +++ b/backend/crates/gateway-api/src/admin/observability/routes.rs @@ -32,7 +32,7 @@ where pub(crate) async fn dashboard_summary( _auth: AdminAuth, State(state): State, - Query(query): Query, + AdminQuery(query): AdminQuery, ) -> Result where S: AdminSessionState + Send + Sync, @@ -56,7 +56,7 @@ where pub(crate) async fn dashboard_trend( _auth: AdminAuth, State(state): State, - Query(query): Query, + AdminQuery(query): AdminQuery, ) -> Result where S: AdminSessionState + Send + Sync, @@ -79,7 +79,7 @@ where pub(crate) async fn usage_records( _auth: AdminAuth, State(state): State, - Query(query): Query, + AdminQuery(query): AdminQuery, ) -> Result where S: AdminSessionState + Send + Sync, @@ -91,15 +91,14 @@ where .usage_records(command) .await .map_err(map_service_error)?; - let data = usage_page_view(result, page, page_size) - .map_err(|_| AdminError::internal("Failed to encode observability cursor"))?; + let data = usage_page_view(result, page, page_size).map_err(|_| AdminError::internal())?; Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data))) } pub(crate) async fn usage_record_detail( _auth: AdminAuth, State(state): State, - Query(query): Query, + AdminQuery(query): AdminQuery, ) -> Result where S: AdminSessionState + Send + Sync, @@ -120,7 +119,7 @@ where pub(crate) async fn usage_records_summary( _auth: AdminAuth, State(state): State, - Query(query): Query, + AdminQuery(query): AdminQuery, ) -> Result where S: AdminSessionState + Send + Sync, @@ -143,7 +142,7 @@ where pub(crate) async fn usage_insights_overview( _auth: AdminAuth, State(state): State, - Query(query): Query, + AdminQuery(query): AdminQuery, ) -> Result where S: AdminSessionState + Send + Sync, @@ -166,7 +165,7 @@ where pub(crate) async fn usage_insights_diagnostics( _auth: AdminAuth, State(state): State, - Query(query): Query, + AdminQuery(query): AdminQuery, ) -> Result where S: AdminSessionState + Send + Sync, @@ -196,7 +195,7 @@ where pub(crate) async fn ops_errors( _auth: AdminAuth, State(state): State, - Query(query): Query, + AdminQuery(query): AdminQuery, ) -> Result where S: AdminSessionState + Send + Sync, @@ -208,7 +207,6 @@ where .ops_errors(command) .await .map_err(map_service_error)?; - let data = ops_page_view(result, page, page_size) - .map_err(|_| AdminError::internal("Failed to encode observability cursor"))?; + let data = ops_page_view(result, page, page_size).map_err(|_| AdminError::internal())?; Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data))) } diff --git a/backend/crates/gateway-api/src/admin/settings.rs b/backend/crates/gateway-api/src/admin/settings.rs index de3de60e3..eb4753030 100644 --- a/backend/crates/gateway-api/src/admin/settings.rs +++ b/backend/crates/gateway-api/src/admin/settings.rs @@ -3,7 +3,7 @@ use std::{collections::BTreeMap, fmt}; use axum::{ - Json, Router, + Router, extract::State, http::StatusCode, response::IntoResponse, @@ -17,8 +17,8 @@ use gateway_core::routing::{PublicModelId, UpstreamModelId}; use serde::{Deserialize, Serialize}; use super::{ - AdminAuth, AdminEnvelope, AdminError, AdminResponse, AdminSessionState, WireValidationError, - wire::map_admin_service_error, + AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminResponse, AdminSessionState, + WireValidationError, wire::map_admin_service_error, }; /// 客户端模型到上游模型的全局精确映射。 @@ -197,7 +197,7 @@ where async fn update_settings( auth: AdminAuth, State(state): State, - Json(request): Json, + AdminJson(request): AdminJson, ) -> Result where S: AdminSessionState + Send + Sync, @@ -324,16 +324,16 @@ fn valid_model_name(value: &str, max_len: usize) -> bool { fn map_wire_error(error: WireValidationError) -> AdminError { let message = match error.field() { - "settingsRefreshConcurrencyOverflow" => "Invalid refreshConcurrency".to_owned(), - "settingsMaxConcurrencyOverflow" => "Invalid maxConcurrentPerAccount".to_owned(), - "settingsUsageRetentionOverflow" => "Invalid usageRetentionDays".to_owned(), - "settingsOpsRetentionOverflow" => "Invalid opsEventRetentionDays".to_owned(), - "settingsAuditRetentionOverflow" => "Invalid auditRetentionDays".to_owned(), - field => format!("Invalid field: {field}"), + "settingsRefreshConcurrencyOverflow" => "refreshConcurrency 不合法".to_owned(), + "settingsMaxConcurrencyOverflow" => "maxConcurrentPerAccount 不合法".to_owned(), + "settingsUsageRetentionOverflow" => "usageRetentionDays 不合法".to_owned(), + "settingsOpsRetentionOverflow" => "opsEventRetentionDays 不合法".to_owned(), + "settingsAuditRetentionOverflow" => "auditRetentionDays 不合法".to_owned(), + field => format!("{field} 字段不合法"), }; AdminError::bad_request(message) } fn map_service_error(error: gateway_admin::model::AdminError) -> AdminError { - map_admin_service_error(error, "Settings repository unavailable") + map_admin_service_error(error) } diff --git a/backend/crates/gateway-api/src/admin/system.rs b/backend/crates/gateway-api/src/admin/system.rs index dda8bb698..c3dd44b5b 100644 --- a/backend/crates/gateway-api/src/admin/system.rs +++ b/backend/crates/gateway-api/src/admin/system.rs @@ -5,8 +5,8 @@ use std::convert::Infallible; use axum::{ - Json, Router, - extract::{Query, State}, + Router, + extract::State, http::StatusCode, response::{ IntoResponse, @@ -23,7 +23,7 @@ use gateway_admin::model::system::{ use serde::{Deserialize, Serialize}; use super::{ - AdminAuth, AdminEnvelope, AdminError, AdminResponse, AdminSessionState, + AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse, AdminSessionState, wire::map_admin_service_error, }; @@ -251,7 +251,7 @@ where async fn update_detail( _auth: AdminAuth, State(state): State, - Query(query): Query, + AdminQuery(query): AdminQuery, ) -> Result where S: AdminSessionState + Send + Sync, @@ -290,7 +290,7 @@ where async fn perform_update( _auth: AdminAuth, State(state): State, - payload: Option>, + payload: Option>, ) -> Result where S: AdminSessionState + Send + Sync, @@ -298,7 +298,7 @@ where let result = state .admin_services() .system() - .perform_update(payload.map(|Json(value)| value.into_target_version())) + .perform_update(payload.map(|AdminJson(value)| value.into_target_version())) .await .map_err(map_system_error)?; let SystemOperationAccepted::Update { @@ -309,7 +309,7 @@ where target_version, } = result else { - return Err(AdminError::internal("Invalid system update result")); + return Err(AdminError::internal()); }; Ok(AdminResponse::new( StatusCode::OK, @@ -361,7 +361,7 @@ where need_restart, } = result else { - return Err(AdminError::internal("Invalid system rollback result")); + return Err(AdminError::internal()); }; Ok(AdminResponse::new( StatusCode::OK, @@ -391,7 +391,7 @@ where message, } = result else { - return Err(AdminError::internal("Invalid system restart result")); + return Err(AdminError::internal()); }; Ok(AdminResponse::new( StatusCode::OK, @@ -477,5 +477,5 @@ const fn update_event_level_name(level: SystemUpdateEventLevel) -> &'static str } fn map_system_error(error: gateway_admin::model::AdminError) -> AdminError { - map_admin_service_error(error, "System operation unavailable") + map_admin_service_error(error) } diff --git a/backend/crates/gateway-api/src/admin/wire.rs b/backend/crates/gateway-api/src/admin/wire.rs index 74e165bb4..87b05b689 100644 --- a/backend/crates/gateway-api/src/admin/wire.rs +++ b/backend/crates/gateway-api/src/admin/wire.rs @@ -116,6 +116,8 @@ impl AdminErrorCode { pub const USAGE_RECORD_ACCOUNTS: Self = Self(50002); /// 上游网关失败。 pub const BAD_GATEWAY: Self = Self(50201); + /// 不可逆上游操作的执行结果未知。 + pub const UPSTREAM_RESULT_UNKNOWN: Self = Self(50202); /// 依赖的服务暂不可用。 pub const SERVICE_UNAVAILABLE: Self = Self(50301); @@ -165,6 +167,89 @@ pub struct AdminError { body: AdminErrorBody, } +#[derive(Debug, Clone, Copy)] +struct AdminErrorSpec { + status: StatusCode, + code: AdminErrorCode, + message: &'static str, +} + +impl AdminErrorSpec { + const fn new(status: StatusCode, code: AdminErrorCode, message: &'static str) -> Self { + Self { + status, + code, + message, + } + } +} + +const MALFORMED_JSON: AdminErrorSpec = AdminErrorSpec::new( + StatusCode::BAD_REQUEST, + AdminErrorCode::MALFORMED_JSON, + "请求体不是合法 JSON", +); +const BAD_REQUEST: AdminErrorSpec = AdminErrorSpec::new( + StatusCode::BAD_REQUEST, + AdminErrorCode::BAD_REQUEST, + "请求参数不合法", +); +const INVALID_TIME_RANGE: AdminErrorSpec = AdminErrorSpec::new( + StatusCode::BAD_REQUEST, + AdminErrorCode::INVALID_TIME_RANGE, + "时间范围不合法", +); +const SESSION_REQUIRED: AdminErrorSpec = AdminErrorSpec::new( + StatusCode::UNAUTHORIZED, + AdminErrorCode::SESSION_REQUIRED, + "需要管理员登录", +); +const INVALID_CREDENTIALS: AdminErrorSpec = AdminErrorSpec::new( + StatusCode::UNAUTHORIZED, + AdminErrorCode::INVALID_CREDENTIALS, + "管理员用户名或密码错误", +); +const INVALID_API_KEY: AdminErrorSpec = AdminErrorSpec::new( + StatusCode::UNAUTHORIZED, + AdminErrorCode::INVALID_API_KEY, + "管理 API Key 无效", +); +const ADMIN_ROUTE_NOT_FOUND: AdminErrorSpec = AdminErrorSpec::new( + StatusCode::NOT_FOUND, + AdminErrorCode::NOT_FOUND, + "管理接口不存在", +); +const METHOD_NOT_ALLOWED: AdminErrorSpec = AdminErrorSpec::new( + StatusCode::METHOD_NOT_ALLOWED, + AdminErrorCode::BAD_REQUEST, + "请求方法不受支持", +); +const TOO_MANY_LOGIN_ATTEMPTS: AdminErrorSpec = AdminErrorSpec::new( + StatusCode::TOO_MANY_REQUESTS, + AdminErrorCode::TOO_MANY_LOGIN_ATTEMPTS, + "登录尝试过多,请稍后重试", +); +const INTERNAL: AdminErrorSpec = AdminErrorSpec::new( + StatusCode::INTERNAL_SERVER_ERROR, + AdminErrorCode::INTERNAL, + "服务内部错误", +); +const BAD_GATEWAY: AdminErrorSpec = AdminErrorSpec::new( + StatusCode::BAD_GATEWAY, + AdminErrorCode::BAD_GATEWAY, + "上游服务请求失败", +); +const UPSTREAM_RESULT_UNKNOWN: AdminErrorSpec = AdminErrorSpec::new( + StatusCode::BAD_GATEWAY, + AdminErrorCode::UPSTREAM_RESULT_UNKNOWN, + "上游执行结果未知,请刷新状态后再决定是否重试", +); +const SERVICE_UNAVAILABLE: AdminErrorSpec = AdminErrorSpec::new( + StatusCode::SERVICE_UNAVAILABLE, + AdminErrorCode::SERVICE_UNAVAILABLE, + "依赖服务暂不可用", +); + impl AdminError { fn new(status: StatusCode, code: AdminErrorCode, message: impl Into) -> Self { Self { @@ -173,44 +258,48 @@ impl AdminError { } } + fn from_spec(spec: AdminErrorSpec) -> Self { + Self::new(spec.status, spec.code, spec.message) + } + + pub fn malformed_json() -> Self { + Self::from_spec(MALFORMED_JSON) + } + + pub fn invalid_request(status: StatusCode, message: &'static str) -> Self { + Self::new(status, BAD_REQUEST.code, message) + } + + pub fn invalid_time_range() -> Self { + Self::from_spec(INVALID_TIME_RANGE) + } + + pub fn admin_route_not_found() -> Self { + Self::from_spec(ADMIN_ROUTE_NOT_FOUND) + } + + pub fn method_not_allowed() -> Self { + Self::from_spec(METHOD_NOT_ALLOWED) + } + pub fn bad_request(message: impl Into) -> Self { - Self::new( - StatusCode::BAD_REQUEST, - AdminErrorCode::BAD_REQUEST, - message, - ) + Self::new(BAD_REQUEST.status, BAD_REQUEST.code, message) } pub fn admin_session_required() -> Self { - Self::new( - StatusCode::UNAUTHORIZED, - AdminErrorCode::SESSION_REQUIRED, - "Admin session required", - ) + Self::from_spec(SESSION_REQUIRED) } pub fn invalid_admin_credentials() -> Self { - Self::new( - StatusCode::UNAUTHORIZED, - AdminErrorCode::INVALID_CREDENTIALS, - "Invalid admin credentials", - ) + Self::from_spec(INVALID_CREDENTIALS) } pub fn invalid_admin_api_key() -> Self { - Self::new( - StatusCode::UNAUTHORIZED, - AdminErrorCode::INVALID_API_KEY, - "Invalid admin API key", - ) + Self::from_spec(INVALID_API_KEY) } pub fn too_many_login_attempts() -> Self { - Self::new( - StatusCode::TOO_MANY_REQUESTS, - AdminErrorCode::TOO_MANY_LOGIN_ATTEMPTS, - "Too many admin login attempts", - ) + Self::from_spec(TOO_MANY_LOGIN_ATTEMPTS) } pub fn conflict(message: impl Into) -> Self { @@ -221,47 +310,37 @@ impl AdminError { Self::new(StatusCode::NOT_FOUND, AdminErrorCode::NOT_FOUND, message) } - pub fn internal(message: impl Into) -> Self { - Self::new( - StatusCode::INTERNAL_SERVER_ERROR, - AdminErrorCode::INTERNAL, - message, - ) + pub fn internal() -> Self { + Self::from_spec(INTERNAL) } - pub fn bad_gateway(message: impl Into) -> Self { - Self::new( - StatusCode::BAD_GATEWAY, - AdminErrorCode::BAD_GATEWAY, - message, - ) + pub fn bad_gateway() -> Self { + Self::from_spec(BAD_GATEWAY) } - pub fn service_unavailable(message: impl Into) -> Self { - Self::new( - StatusCode::SERVICE_UNAVAILABLE, - AdminErrorCode::SERVICE_UNAVAILABLE, - message, - ) + pub fn upstream_result_unknown() -> Self { + Self::from_spec(UPSTREAM_RESULT_UNKNOWN) + } + + pub fn service_unavailable() -> Self { + Self::from_spec(SERVICE_UNAVAILABLE) } } /// 把管理用例的稳定错误分类映射到既有 HTTP 错误 contract。 -pub(crate) fn map_admin_service_error( - error: gateway_admin::model::AdminError, - unavailable_message: &'static str, -) -> AdminError { +pub(crate) fn map_admin_service_error(error: gateway_admin::model::AdminError) -> AdminError { use gateway_admin::model::AdminErrorKind; match error.kind() { - AdminErrorKind::Invalid => AdminError::bad_request(error.to_string()), + AdminErrorKind::Invalid => AdminError::bad_request(error.message()), AdminErrorKind::Unauthorized => AdminError::admin_session_required(), - AdminErrorKind::NotFound => AdminError::not_found(error.to_string()), - AdminErrorKind::Conflict => AdminError::conflict(error.to_string()), + AdminErrorKind::NotFound => AdminError::not_found(error.message()), + AdminErrorKind::Conflict => AdminError::conflict(error.message()), AdminErrorKind::RateLimited => AdminError::too_many_login_attempts(), - AdminErrorKind::BadGateway => AdminError::bad_gateway(error.to_string()), - AdminErrorKind::Unavailable => AdminError::service_unavailable(unavailable_message), - AdminErrorKind::Internal => AdminError::internal(error.to_string()), + AdminErrorKind::BadGateway => AdminError::bad_gateway(), + AdminErrorKind::UpstreamResultUnknown => AdminError::upstream_result_unknown(), + AdminErrorKind::Unavailable => AdminError::service_unavailable(), + AdminErrorKind::Internal => AdminError::internal(), } } diff --git a/backend/crates/gateway-api/tests/admin/accounts.rs b/backend/crates/gateway-api/tests/admin/accounts.rs index 9c19e12b1..45f311835 100644 --- a/backend/crates/gateway-api/tests/admin/accounts.rs +++ b/backend/crates/gateway-api/tests/admin/accounts.rs @@ -335,7 +335,12 @@ mod actions { AccountResetCreditConsumeRequest, AccountTestQuery, CompleteAccountAuthorizationRequest, RotateAccountRequest, StartAccountAuthorizationRequest, }; - use gateway_core::engine::credential::ProviderAccountId; + use gateway_core::{ + engine::{ + UpstreamSendState, credential::ProviderAccountId, probe::AccountProbeErrorSource, + }, + error::GatewayErrorKind, + }; use serde_json::json; #[test] @@ -620,6 +625,9 @@ mod actions { }, DomainConnectionTestEvent::Completed {}, DomainConnectionTestEvent::Failed { + source: AccountProbeErrorSource::Upstream, + gateway_error_code: GatewayErrorKind::RateLimited, + send_state: Some(UpstreamSendState::Sent), message: "upstream unavailable".to_owned(), provider_error_code: Some("usage_exhausted".to_owned()), provider_error_type: Some("invalid_request_error".to_owned()), @@ -653,6 +661,9 @@ mod actions { json!({ "type": "test_complete", "success": true }), json!({ "type": "error", + "source": "upstream", + "gatewayErrorCode": "rate_limited", + "sendState": "sent", "error": "upstream unavailable", "providerErrorCode": "usage_exhausted", "providerErrorType": "invalid_request_error", diff --git a/backend/crates/gateway-api/tests/admin/errors.rs b/backend/crates/gateway-api/tests/admin/errors.rs new file mode 100644 index 000000000..39c39abc5 --- /dev/null +++ b/backend/crates/gateway-api/tests/admin/errors.rs @@ -0,0 +1,320 @@ +use axum::{ + Router, + body::{Body, to_bytes}, + http::{Method, Request, StatusCode, header}, +}; +use gateway_api::admin; +use serde_json::{Value, json}; +use tower::ServiceExt as _; + +use super::{AdminTestFixture, AdminTestState}; + +const SESSION_COOKIE: &str = "cpr_admin_session=valid-session"; + +fn app(state: AdminTestState) -> Router { + admin::router::().with_state(state) +} + +fn request(method: Method, uri: &str, body: Body) -> Request { + Request::builder() + .method(method) + .uri(uri) + .header("x-request-id", "req_admin_errors") + .body(body) + .expect("build admin error request") +} + +async fn response_json(response: axum::response::Response) -> (StatusCode, String, Value) { + let status = response.status(); + let content_type = response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_owned(); + let body = to_bytes(response.into_body(), 64 * 1024) + .await + .expect("read admin error response body"); + let body = serde_json::from_slice(&body).expect("parse admin error response JSON"); + (status, content_type, body) +} + +#[tokio::test] +async fn malformed_login_json_should_use_the_admin_error_envelope() { + let fixture = AdminTestFixture::new().await; + let mut request = request( + Method::POST, + "/api/admin/auth/login", + Body::from(r#"{"password":"secret""#), + ); + request + .headers_mut() + .insert(header::CONTENT_TYPE, "application/json".parse().unwrap()); + + let response = app(fixture.state()) + .oneshot(request) + .await + .expect("malformed JSON response"); + let actual = response_json(response).await; + + assert_eq!( + actual, + ( + StatusCode::BAD_REQUEST, + "application/json".to_owned(), + json!({ + "code": 40000, + "message": "请求体不是合法 JSON", + "data": null + }) + ) + ); +} + +#[tokio::test] +async fn invalid_login_json_data_should_not_echo_the_submitted_value() { + let fixture = AdminTestFixture::new().await; + let submitted = "password-must-not-leak"; + let mut request = request( + Method::POST, + "/api/admin/auth/login", + Body::from(json!({ "password": submitted, "rememberMe": true }).to_string()), + ); + request + .headers_mut() + .insert(header::CONTENT_TYPE, "application/json".parse().unwrap()); + + let response = app(fixture.state()) + .oneshot(request) + .await + .expect("invalid JSON data response"); + let actual = response_json(response).await; + + assert_eq!( + actual, + ( + StatusCode::UNPROCESSABLE_ENTITY, + "application/json".to_owned(), + json!({ + "code": 40001, + "message": "请求字段不合法", + "data": null + }) + ) + ); + assert!(!actual.2.to_string().contains(submitted)); +} + +#[tokio::test] +async fn login_without_json_content_type_should_keep_415_with_an_admin_envelope() { + let fixture = AdminTestFixture::new().await; + let response = app(fixture.state()) + .oneshot(request( + Method::POST, + "/api/admin/auth/login", + Body::from(json!({ "password": "secret" }).to_string()), + )) + .await + .expect("missing JSON content type response"); + let actual = response_json(response).await; + + assert_eq!( + actual, + ( + StatusCode::UNSUPPORTED_MEDIA_TYPE, + "application/json".to_owned(), + json!({ + "code": 40001, + "message": "请求必须使用 application/json", + "data": null + }) + ) + ); +} + +#[tokio::test] +async fn malformed_admin_query_should_use_a_safe_bad_request_envelope() { + let fixture = AdminTestFixture::new().await; + fixture.auth.insert_session("valid-session"); + let mut request = request( + Method::GET, + "/api/admin/usage/records?page=not-a-number", + Body::empty(), + ); + request + .headers_mut() + .insert(header::COOKIE, SESSION_COOKIE.parse().unwrap()); + + let response = app(fixture.state()) + .oneshot(request) + .await + .expect("invalid query response"); + let actual = response_json(response).await; + + assert_eq!( + actual, + ( + StatusCode::BAD_REQUEST, + "application/json".to_owned(), + json!({ + "code": 40001, + "message": "请求参数不合法", + "data": null + }) + ) + ); +} + +#[tokio::test] +async fn invalid_time_range_should_use_its_published_business_code() { + let fixture = AdminTestFixture::new().await; + fixture.auth.insert_session("valid-session"); + let mut request = request( + Method::GET, + "/api/admin/dashboard/summary?startTime=not-a-time", + Body::empty(), + ); + request + .headers_mut() + .insert(header::COOKIE, SESSION_COOKIE.parse().unwrap()); + + let response = app(fixture.state()) + .oneshot(request) + .await + .expect("invalid time range response"); + let actual = response_json(response).await; + + assert_eq!( + actual, + ( + StatusCode::BAD_REQUEST, + "application/json".to_owned(), + json!({ + "code": 40002, + "message": "时间范围不合法", + "data": null + }) + ) + ); +} + +#[tokio::test] +async fn unknown_admin_path_should_not_fall_through_to_the_spa() { + let fixture = AdminTestFixture::new().await; + let response = app(fixture.state()) + .oneshot(request( + Method::GET, + "/api/admin/not-a-real-route", + Body::empty(), + )) + .await + .expect("unknown admin path response"); + let actual = response_json(response).await; + + assert_eq!( + actual, + ( + StatusCode::NOT_FOUND, + "application/json".to_owned(), + json!({ + "code": 40401, + "message": "管理接口不存在", + "data": null + }) + ) + ); +} + +#[tokio::test] +async fn unsupported_admin_method_should_keep_allow_and_return_an_envelope() { + let fixture = AdminTestFixture::new().await; + let response = app(fixture.state()) + .oneshot(request( + Method::POST, + "/api/admin/auth/status", + Body::empty(), + )) + .await + .expect("unsupported admin method response"); + let allow = response + .headers() + .get(header::ALLOW) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_owned(); + let actual = response_json(response).await; + + assert_eq!( + (allow, actual), + ( + "GET,HEAD".to_owned(), + ( + StatusCode::METHOD_NOT_ALLOWED, + "application/json".to_owned(), + json!({ + "code": 40001, + "message": "请求方法不受支持", + "data": null + }) + ) + ) + ); +} + +#[tokio::test] +async fn admin_auth_failures_should_use_stable_chinese_contracts() { + let fixture = AdminTestFixture::new().await; + let unauthenticated = app(fixture.state()) + .clone() + .oneshot(request(Method::GET, "/api/admin/accounts", Body::empty())) + .await + .expect("missing session response"); + let mut invalid_api_key = request(Method::GET, "/api/admin/accounts", Body::empty()); + invalid_api_key + .headers_mut() + .insert("x-api-key", "admin-invalid".parse().unwrap()); + let invalid_api_key = app(fixture.state()) + .clone() + .oneshot(invalid_api_key) + .await + .expect("invalid API key response"); + let mut invalid_login = request( + Method::POST, + "/api/admin/auth/login", + Body::from(json!({ "password": "wrong-password" }).to_string()), + ); + invalid_login + .headers_mut() + .insert(header::CONTENT_TYPE, "application/json".parse().unwrap()); + let invalid_login = app(fixture.state()) + .oneshot(invalid_login) + .await + .expect("invalid credentials response"); + + let actual = [ + response_json(unauthenticated).await, + response_json(invalid_login).await, + response_json(invalid_api_key).await, + ]; + + assert_eq!( + actual.map(|(status, _, body)| (status, body["code"].clone(), body["message"].clone())), + [ + ( + StatusCode::UNAUTHORIZED, + json!(40101), + json!("需要管理员登录") + ), + ( + StatusCode::UNAUTHORIZED, + json!(40102), + json!("管理员用户名或密码错误") + ), + ( + StatusCode::UNAUTHORIZED, + json!(40103), + json!("管理 API Key 无效") + ), + ] + ); +} diff --git a/backend/crates/gateway-api/tests/admin/mod.rs b/backend/crates/gateway-api/tests/admin/mod.rs index afd3d0f14..5734f414d 100644 --- a/backend/crates/gateway-api/tests/admin/mod.rs +++ b/backend/crates/gateway-api/tests/admin/mod.rs @@ -77,6 +77,7 @@ mod account_groups; mod accounts; mod auth; mod client_keys; +mod errors; mod observability; mod settings; mod system; diff --git a/backend/crates/gateway-api/tests/admin/wire.rs b/backend/crates/gateway-api/tests/admin/wire.rs index 9632bbfc4..36d4be5ac 100644 --- a/backend/crates/gateway-api/tests/admin/wire.rs +++ b/backend/crates/gateway-api/tests/admin/wire.rs @@ -40,6 +40,7 @@ mod error { AdminErrorCode::INTERNAL, AdminErrorCode::USAGE_RECORD_ACCOUNTS, AdminErrorCode::BAD_GATEWAY, + AdminErrorCode::UPSTREAM_RESULT_UNKNOWN, AdminErrorCode::SERVICE_UNAVAILABLE, ] .map(AdminErrorCode::value); @@ -48,7 +49,7 @@ mod error { actual, [ 40000, 40001, 40002, 40003, 40101, 40102, 40103, 40401, 40901, 42901, 50000, 50001, - 50002, 50201, 50301, + 50002, 50201, 50202, 50301, ] ); } @@ -57,7 +58,7 @@ mod error { fn invalid_admin_api_key_should_keep_code_and_null_data() { let value = serde_json::to_value(AdminErrorBody::new( AdminErrorCode::INVALID_API_KEY, - "Invalid admin API key", + "管理 API Key 无效", )) .expect("admin error body should serialize"); @@ -65,7 +66,7 @@ mod error { value, json!({ "code": 40103, - "message": "Invalid admin API key", + "message": "管理 API Key 无效", "data": null }) ); @@ -158,7 +159,7 @@ mod response { StatusCode::UNAUTHORIZED, json!({ "code": 40103, - "message": "Invalid admin API key", + "message": "管理 API Key 无效", "data": null }) ) @@ -199,17 +200,18 @@ mod response { 40401, ), ( - AdminError::internal("internal"), + AdminError::internal(), StatusCode::INTERNAL_SERVER_ERROR, 50001, ), + (AdminError::bad_gateway(), StatusCode::BAD_GATEWAY, 50201), ( - AdminError::bad_gateway("upstream"), + AdminError::upstream_result_unknown(), StatusCode::BAD_GATEWAY, - 50201, + 50202, ), ( - AdminError::service_unavailable("storage"), + AdminError::service_unavailable(), StatusCode::SERVICE_UNAVAILABLE, 50301, ), diff --git a/backend/crates/gateway-api/tests/architecture.rs b/backend/crates/gateway-api/tests/architecture.rs index 9d270d1a6..ec27c5bcf 100644 --- a/backend/crates/gateway-api/tests/architecture.rs +++ b/backend/crates/gateway-api/tests/architecture.rs @@ -38,6 +38,7 @@ fn source_tree_should_match_frozen_machine_manifest() { "src/admin/auth.rs", "src/admin/backups.rs", "src/admin/client_keys.rs", + "src/admin/extract.rs", "src/admin/mod.rs", "src/admin/observability/mod.rs", "src/admin/observability/presenter.rs", @@ -80,6 +81,7 @@ fn test_tree_should_match_frozen_rust_mirror() { "tests/admin/accounts.rs", "tests/admin/auth.rs", "tests/admin/client_keys.rs", + "tests/admin/errors.rs", "tests/admin/mod.rs", "tests/admin/observability/mod.rs", "tests/admin/observability/query.rs", diff --git a/backend/crates/gateway-core/src/engine/execution.rs b/backend/crates/gateway-core/src/engine/execution.rs index eb8ce1603..ecebca3a6 100644 --- a/backend/crates/gateway-core/src/engine/execution.rs +++ b/backend/crates/gateway-core/src/engine/execution.rs @@ -20,8 +20,8 @@ use crate::engine::continuation::{ }; use crate::engine::coordinator::ResponseExecutionSession; use crate::engine::probe::{ - AccountProbe, AccountProbeError, AccountProbeRequest, AccountProbeResult, - AccountProbeUpstreamResponse, + AccountProbe, AccountProbeError, AccountProbeErrorSource, AccountProbeRequest, + AccountProbeResult, AccountProbeUpstreamResponse, }; use crate::engine::provider::ProviderRegistry; use crate::engine::{ @@ -726,11 +726,24 @@ impl DefaultExecutionService { started_at: SystemTime, error: &EngineError, ) -> AccountProbeError { - let upstream_response = match error { - EngineError::Provider(provider_error) => provider_error - .client_visible_upstream_response() - .map(AccountProbeUpstreamResponse::from_client_response), - _ => None, + let (source, send_state, upstream_response) = match error { + EngineError::Provider(provider_error) => { + let upstream_response = provider_error + .client_visible_upstream_response() + .map(AccountProbeUpstreamResponse::from_client_response); + let has_upstream_facts = provider_error.send_state() != UpstreamSendState::NotSent + || provider_error.upstream_status().is_some() + || provider_error.upstream_code().is_some() + || provider_error.client_visible_upstream_error().is_some() + || upstream_response.is_some(); + let source = if has_upstream_facts { + AccountProbeErrorSource::Upstream + } else { + AccountProbeErrorSource::Provider + }; + (source, Some(provider_error.send_state()), upstream_response) + } + _ => (AccountProbeErrorSource::Gateway, None, None), }; if let EngineError::Provider(provider_error) = error { let latency = started_at.elapsed().unwrap_or_default(); @@ -767,7 +780,12 @@ impl DefaultExecutionService { ); } } - AccountProbeError::new(gateway_error_from_engine(error), upstream_response) + AccountProbeError::new( + gateway_error_from_engine(error), + source, + send_state, + upstream_response, + ) } } diff --git a/backend/crates/gateway-core/src/engine/probe.rs b/backend/crates/gateway-core/src/engine/probe.rs index 61b5b6e01..5986b9d42 100644 --- a/backend/crates/gateway-core/src/engine/probe.rs +++ b/backend/crates/gateway-core/src/engine/probe.rs @@ -7,7 +7,10 @@ use futures::future::BoxFuture; use crate::error::{ClientVisibleUpstreamResponse, GatewayError, GatewayErrorKind}; use crate::routing::{ProviderKind, UpstreamModelId}; -use crate::{engine::credential::ProviderAccountId, operation::Operation}; +use crate::{ + engine::{UpstreamSendState, credential::ProviderAccountId}, + operation::Operation, +}; #[derive(Debug, Clone, PartialEq)] pub struct AccountProbeRequest { @@ -71,12 +74,34 @@ impl fmt::Debug for AccountProbeUpstreamResponse { } } +/// 连接测试失败发生的稳定责任边界。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AccountProbeErrorSource { + Gateway, + Provider, + Upstream, +} + +impl AccountProbeErrorSource { + /// 返回管理端 wire 使用的稳定值。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Gateway => "gateway", + Self::Provider => "provider", + Self::Upstream => "upstream", + } + } +} + /// 管理端连接测试的终态错误。 /// /// `gateway` 保留稳定分类,`upstream_response` 只面向本次认证管理请求展示源响应。 #[derive(Debug)] pub struct AccountProbeError { gateway: GatewayError, + source: AccountProbeErrorSource, + send_state: Option, upstream_response: Option, } @@ -84,10 +109,14 @@ impl AccountProbeError { #[must_use] pub const fn new( gateway: GatewayError, + source: AccountProbeErrorSource, + send_state: Option, upstream_response: Option, ) -> Self { Self { gateway, + source, + send_state, upstream_response, } } @@ -97,6 +126,16 @@ impl AccountProbeError { self.gateway.kind() } + #[must_use] + pub const fn source(&self) -> AccountProbeErrorSource { + self.source + } + + #[must_use] + pub const fn send_state(&self) -> Option { + self.send_state + } + #[must_use] pub fn client_message(&self) -> &str { self.gateway.client_message() @@ -120,7 +159,7 @@ impl AccountProbeError { impl From for AccountProbeError { fn from(gateway: GatewayError) -> Self { - Self::new(gateway, None) + Self::new(gateway, AccountProbeErrorSource::Gateway, None, None) } } diff --git a/backend/crates/gateway-core/tests/engine/execution.rs b/backend/crates/gateway-core/tests/engine/execution.rs index 381db9567..32e294455 100644 --- a/backend/crates/gateway-core/tests/engine/execution.rs +++ b/backend/crates/gateway-core/tests/engine/execution.rs @@ -25,7 +25,7 @@ use gateway_core::engine::execution::{ ExecutionService, ProviderCircuitDecision, ProviderCircuitError, ProviderCircuitPort, StartExecution, StartProviderExecution, provider_failure_affects_circuit, }; -use gateway_core::engine::probe::{AccountProbe, AccountProbeRequest}; +use gateway_core::engine::probe::{AccountProbe, AccountProbeErrorSource, AccountProbeRequest}; use gateway_core::engine::provider::{ Provider, ProviderCallMetadata, ProviderCatalogGeneration, ProviderModelCapabilities, ProviderRegistry, ProviderRequest, ProviderResource, ProviderStream, UpstreamTransport, @@ -87,6 +87,8 @@ fn account_probe_should_not_write_to_the_persistent_execution_store() { .expect_err("empty Provider registry should stop the probe after it starts"); assert_eq!(error.kind(), GatewayErrorKind::NoAvailableProvider); + assert_eq!(error.source(), AccountProbeErrorSource::Gateway); + assert_eq!(error.send_state(), None); assert!(!store.touched.load(Ordering::SeqCst)); } @@ -114,6 +116,8 @@ fn probe_failures_should_be_observable_without_a_model_request_row() { .expect_err("the provider rejects every probe"); assert_eq!(error.kind(), GatewayErrorKind::UpstreamUnavailable); + assert_eq!(error.source(), AccountProbeErrorSource::Upstream); + assert_eq!(error.send_state(), Some(UpstreamSendState::NotSent)); let upstream = error .upstream_response() .expect("probe must preserve its request-local upstream response"); @@ -131,6 +135,34 @@ fn probe_failures_should_be_observable_without_a_model_request_row() { assert_eq!(store.probe_failures(), vec!["transport".to_owned()]); } +#[test] +fn provider_local_probe_failure_should_remain_distinct_from_upstream() { + let store = Arc::new(TrackingExecutionStore::default()); + let providers = ProviderRegistry::new([Arc::new(LocalFailingProvider) as Arc]) + .expect("provider registry"); + let service = DefaultExecutionService::new( + RuntimeSnapshotHandle::new(probe_snapshot()), + store, + providers, + Arc::new(UnusedAdmissions), + Arc::new(UnusedCircuits), + Arc::new(UnusedContinuation), + Arc::new(RecordingClientApiKeyUsage::default()), + ); + + let error = block_on(service.probe(AccountProbeRequest { + account_id: ProviderAccountId::new("acct_probe").expect("account ID"), + provider_kind: ProviderKind::new("openai").expect("provider kind"), + upstream_model: UpstreamModelId::new("gpt-probe").expect("model ID"), + operation: probe_operation(), + })) + .expect_err("the Provider rejects the probe before sending it"); + + assert_eq!(error.source(), AccountProbeErrorSource::Provider); + assert_eq!(error.send_state(), Some(UpstreamSendState::NotSent)); + assert!(error.upstream_response().is_none()); +} + #[test] fn probe_observation_store_failure_preserves_the_provider_error() { let store = Arc::new(TrackingExecutionStore { @@ -196,6 +228,36 @@ impl Provider for FailingProvider { } } +struct LocalFailingProvider; + +#[async_trait] +impl Provider for LocalFailingProvider { + fn name(&self) -> &'static str { + "openai" + } + + fn catalog_generation(&self) -> ProviderCatalogGeneration { + ProviderCatalogGeneration::default() + } + + async fn query_model_capabilities( + &self, + ) -> Result, ProviderError> { + Ok(Vec::new()) + } + + async fn execute( + &self, + _: ProviderRequest, + _: AttemptContext, + ) -> Result { + Err(ProviderError::new( + ProviderErrorKind::Unsupported, + UpstreamSendState::NotSent, + )) + } +} + struct ColdFailingProvider; #[async_trait] diff --git a/backend/crates/providers/openai/src/admin.rs b/backend/crates/providers/openai/src/admin.rs index 77a5a5d04..426a0777e 100644 --- a/backend/crates/providers/openai/src/admin.rs +++ b/backend/crates/providers/openai/src/admin.rs @@ -1404,6 +1404,7 @@ const fn provider_admin_error_code(kind: ProviderAdminErrorKind) -> &'static str ProviderAdminErrorKind::Unsupported => "unsupported", ProviderAdminErrorKind::NotFound => "not_found", ProviderAdminErrorKind::Conflict => "conflict", + ProviderAdminErrorKind::Ambiguous => "ambiguous", ProviderAdminErrorKind::Unavailable => "unavailable", ProviderAdminErrorKind::CredentialRefreshRequired => "credential_refresh_required", ProviderAdminErrorKind::BadGateway => "bad_gateway", @@ -1530,7 +1531,7 @@ fn map_reset_credits_error(error: CodexResetCreditsError) -> ProviderAdminError bounded_upstream_body(&body) )) } - Error::ConsumeResultUnknown => provider_admin_error(ProviderAdminErrorKind::BadGateway) + Error::ConsumeResultUnknown => provider_admin_error(ProviderAdminErrorKind::Ambiguous) .with_message( "OpenAI reset-credit consume result is unknown; refresh the credit list before retrying", ), diff --git a/backend/crates/providers/openai/tests/admin.rs b/backend/crates/providers/openai/tests/admin.rs index dee55376d..9212c8b39 100644 --- a/backend/crates/providers/openai/tests/admin.rs +++ b/backend/crates/providers/openai/tests/admin.rs @@ -273,7 +273,7 @@ async fn reset_credit_success_with_invalid_body_should_remain_an_unknown_consume .await .expect_err("invalid success body must be ambiguous"); - assert_eq!(error.kind(), ProviderAdminErrorKind::BadGateway); + assert_eq!(error.kind(), ProviderAdminErrorKind::Ambiguous); assert_eq!( error.message(), Some( diff --git a/docs/api.md b/docs/api.md index 85fb20c9b..a3273b663 100644 --- a/docs/api.md +++ b/docs/api.md @@ -41,9 +41,36 @@ Client Key 通过账号分组限定路由范围:未绑定分组时可使用全 } ``` -错误响应仍使用 `code`、`message`、`data`,其中 `data` 为 `null`。稳定业务码包括参数错误 -`40001`、会话缺失 `40101`、登录凭据错误 `40102`、管理 API Key 错误 `40103`、资源不存在 -`40401`、业务冲突 `40901`、内部错误 `50001`、上游失败 `50201` 和依赖不可用 `50301`。 +所有 `/api/admin/*` 错误(包括 JSON/Query rejection、未知路由和错误 HTTP method)统一返回 +`application/json`: + +```json +{ + "code": 40001, + "message": "请求参数不合法", + "data": null +} +``` + +管理端本地产生的 `message` 是可安全展示的中文文案;Store、Serde、Provider 内部 `Display` 和原始上游 +body 不进入这个通用信封。稳定业务码如下: + +| HTTP | `code` | 含义 | +| ---: | ---: | --- | +| 400 | `40000` | 请求体不是合法 JSON | +| 400 / 405 / 415 / 422 | `40001` | 通用请求、方法、Content-Type 或字段错误;HTTP 状态保留具体语义 | +| 400 | `40002` | 时间范围不合法 | +| 401 | `40101` / `40102` / `40103` | 缺少管理员会话 / 登录凭据错误 / 管理 API Key 错误 | +| 404 | `40401` | 资源或管理接口不存在 | +| 409 | `40901` | 资源状态冲突 | +| 429 | `42901` | 登录尝试过多 | +| 500 | `50001` | 服务内部错误 | +| 502 | `50201` | 上游服务请求失败 | +| 502 | `50202` | 不可逆上游操作的执行结果未知;刷新状态后再决定是否重试 | +| 503 | `50301` | 依赖服务暂不可用 | + +未知 `/api/admin/*` 路径使用 `40401`,不会落入 SPA;已存在路径使用错误 method 时返回 `405`、 +`40001`,并保留标准 `Allow` header。request ID 继续通过配置的响应 header 返回。 ### 管理写入一致性 @@ -129,6 +156,33 @@ Grok wire 与 Responses wire 之间的协议转换层,转换只在 xAI Provide - `sortBy`: `email`、`status`、`planType`、`usage`、`lastUsedAt`、`expiresAt`; - `sortDirection`: `asc`、`desc`。 +### 账号连接测试 SSE + +`GET /api/admin/accounts/connection-test` 固定探测请求指定的账号,不参与普通账号轮换。成功流沿用 +`test_start`、`request`、`content`、`test_complete` 事件;失败事件为: + +```json +{ + "type": "error", + "source": "upstream", + "gatewayErrorCode": "rate_limited", + "sendState": "sent", + "error": "upstream unavailable", + "providerErrorCode": "usage_exhausted", + "providerErrorType": "invalid_request_error", + "upstreamStatus": 429, + "upstreamContentType": "application/json", + "upstreamBody": "{\"error\":{...}}" +} +``` + +- `source` 为 `gateway`、`provider` 或 `upstream`:分别表示尚未进入 Provider、Provider 本地且未发送、 + 已发送/可能已发送或已经捕获到上游事实。 +- `gatewayErrorCode` 是 `GatewayErrorKind` 的稳定机器值,管理端据此生成中文摘要。 +- `sendState` 为 `not_sent`、`sent`、`ambiguous`,非 Provider 错误为 `null`。 +- `error`、`providerErrorCode`、`providerErrorType`、`upstreamStatus`、`upstreamContentType` 和 + `upstreamBody` 是实际捕获的原始诊断字段;缺失时为 `null`,不会由本地猜测或翻译。 + 导入的 `data` 必须是 JSON object,Admin API 请求上限为 64 MiB;Provider 可以收紧限制, 当前 xAI 导入上限为 16 MiB。内部 schema 由目标 Provider 独占解释: @@ -266,6 +320,9 @@ PostgreSQL 或 Redis。管理端只在用户打开弹窗或点击刷新时调用 账号。服务在单副本进程内按账号串行消费,并在 credential 需要刷新时以同一命令重试一次;它不会对不明 结果自动创建新消费。 +若服务无法确认不可逆消费是否完成,返回 HTTP `502` / 业务码 `50202`;客户端应先刷新卡片与额度状态, +并在确需重试时复用原 `redeemRequestId`。明确的上游 HTTP 拒绝仍使用 `50201`,不会误标为结果未知。 + ```json { "accountId": "acct_...", diff --git a/docs/architecture.md b/docs/architecture.md index 5b4183fe7..b3f1656a5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -124,6 +124,21 @@ Core 只理解 `Operation`、能力要求、Provider 候选、稳定错误和 ca - 请求画像以配置为启动基线。OpenAI Desktop 与 xAI CLI 的官方版本检查只更新各自负责的运行时画像, 不回写 `config.yaml`。 +### 错误与诊断三层边界 + +错误信息按用途分成三层,不能用同一个 `message` 同时承担协议、界面和诊断职责: + +1. **数据面协议错误**:`/v1/*` 继续遵守 OpenAI/xAI wire 合同。可交付原始上游响应时保留其状态、headers、 + content type 和 body;本地 fallback 使用数据面稳定机器码与安全英文,不受管理端中文化影响。 +2. **控制面展示错误**:`/api/admin/*` 由 API 统一 HTTP 状态与数值业务码,由 Admin/API owner 提供安全中文 + 文案。extractor rejection、namespace 404 和 method 405 也使用同一 JSON 信封;任意 Store、Serde、 + Provider `Display` 不得直接跨越 HTTP 边界。 +3. **原始上游诊断**:只在连接测试、运维错误详情等明确诊断界面展示已经捕获的原始字段,不翻译、不补造, + 也不塞入普通 Admin 错误信封。原始 body 不进入 Debug、普通日志或持久化错误消息。 + +连接测试的 `gateway` / `provider` / `upstream` 来源以及 `not_sent` / `sent` / `ambiguous` 发送状态由 Core 在 +仍持有完整执行错误时一次判定;Vue 只能根据稳定字段生成摘要,不能匹配英文错误句子反推来源。 + ## 6. 路由、账号范围与 continuation Client Key 与账号分组形成授权范围: diff --git a/frontend/src/api/error.ts b/frontend/src/api/error.ts new file mode 100644 index 000000000..51106ae4e --- /dev/null +++ b/frontend/src/api/error.ts @@ -0,0 +1,96 @@ +import type { AxiosError, AxiosResponseHeaders, RawAxiosResponseHeaders } from 'axios' + +export type ApiErrorKind = 'api' | 'timeout' | 'network' | 'http' | 'cancelled' + +interface AdminErrorEnvelope { + code: number + message: string +} + +export class ApiError extends Error { + constructor( + message: string, + public readonly status: number, + public readonly code?: number, + public readonly requestId?: string, + public readonly kind: ApiErrorKind = 'api', + ) { + super(message) + this.name = 'ApiError' + } +} + +export function normalizeApiError(error: AxiosError) { + const status = error.response?.status ?? 0 + const requestId = responseHeader(error.response?.headers, 'x-request-id') + const envelope = adminErrorEnvelope(error.response?.data) + + if (envelope) { + return new ApiError(envelope.message, status, envelope.code, requestId, 'api') + } + if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT' || error.code === 'ERR_TIMEOUT') { + return new ApiError('请求超时,请稍后重试', status, undefined, requestId, 'timeout') + } + if (error.code === 'ERR_CANCELED') { + return new ApiError('请求已取消', status, undefined, requestId, 'cancelled') + } + if (!error.response) { + return new ApiError('网络连接失败,请检查网络后重试', 0, undefined, requestId, 'network') + } + return new ApiError(httpFallback(status), status, undefined, requestId, 'http') +} + +function adminErrorEnvelope(value: unknown): AdminErrorEnvelope | null { + if (!value || typeof value !== 'object') + return null + const record = value as Record + if (typeof record.code !== 'number' || typeof record.message !== 'string') + return null + const message = record.message.trim() + return message ? { code: record.code, message } : null +} + +function responseHeader( + headers: RawAxiosResponseHeaders | AxiosResponseHeaders | undefined, + name: string, +) { + if (!headers) + return undefined + const value = typeof headers.get === 'function' ? headers.get(name) : headers[name] + return typeof value === 'string' && value ? value : undefined +} + +function httpFallback(status: number) { + switch (status) { + case 400: + return '请求参数不合法' + case 401: + return '需要管理员登录' + case 403: + return '没有权限执行此操作' + case 404: + return '请求的接口不存在' + case 408: + return '请求超时,请稍后重试' + case 409: + return '资源状态冲突,请刷新后重试' + case 413: + return '请求内容过大' + case 415: + return '请求格式不受支持' + case 422: + return '请求内容不合法' + case 429: + return '请求过于频繁,请稍后重试' + case 500: + return '服务内部错误' + case 502: + return '上游服务请求失败' + case 503: + return '服务暂不可用,请稍后重试' + case 504: + return '上游服务响应超时' + default: + return status > 0 ? `请求失败(HTTP ${status})` : '请求失败' + } +} diff --git a/frontend/src/api/request.ts b/frontend/src/api/request.ts index eff1b87b5..a0d954f46 100644 --- a/frontend/src/api/request.ts +++ b/frontend/src/api/request.ts @@ -7,18 +7,9 @@ import type { import axios from 'axios' import { API_BASE_URL, API_TIMEOUT_MS } from './constants' +import { normalizeApiError } from './error' -export class ApiError extends Error { - constructor( - message: string, - public readonly status: number, - public readonly code?: number, - public readonly requestId?: string, - ) { - super(message) - this.name = 'ApiError' - } -} +export { ApiError } from './error' const http: AxiosInstance = axios.create({ baseURL: API_BASE_URL, @@ -54,19 +45,16 @@ http.interceptors.response.use( (response: AxiosResponse) => { return response }, - (error: AxiosError) => { + (error: AxiosError) => { const { response } = error - const status = response?.status || 0 - const message = response?.data?.message || error.message || '请求失败' - const code = response?.data?.code - const requestId = response?.headers?.['x-request-id'] + const status = response?.status ?? 0 if (status === 401 && !isAuthenticationRequest(error.config?.url)) { handleUnauthorizedOnce() } - return Promise.reject(new ApiError(message, status, code, requestId)) + return Promise.reject(normalizeApiError(error)) }, ) diff --git a/frontend/src/utils/async.ts b/frontend/src/utils/async.ts index 86499ee36..980dcbb9c 100644 --- a/frontend/src/utils/async.ts +++ b/frontend/src/utils/async.ts @@ -1,6 +1,9 @@ +import { isAxiosError } from 'axios' import { delay } from 'es-toolkit' export function errorMessage(error: unknown, fallback = '请求失败') { + if (isAxiosError(error)) + return fallback const message = error instanceof Error ? error.message : error && typeof error === 'object' && 'message' in error diff --git a/frontend/src/views/accounts/components/AccountConnectionTestModal.vue b/frontend/src/views/accounts/components/AccountConnectionTestModal.vue index 21da5d9c2..a9d4f19e2 100644 --- a/frontend/src/views/accounts/components/AccountConnectionTestModal.vue +++ b/frontend/src/views/accounts/components/AccountConnectionTestModal.vue @@ -193,6 +193,12 @@ function connectionLogClass(tone: string) { {{ item.text }}

+

+ 原始诊断 +

 }>
 }
 
-interface ConnectionTestEvent {
-  type: string
+interface ConnectionTestStartEvent {
+  type: 'test_start'
   text?: string
   model?: string
+}
+
+interface ConnectionTestRequestEvent {
+  type: 'request'
   payload?: ConnectionTestRequestPayload
-  success?: boolean
+}
+
+interface ConnectionTestStatusEvent {
+  type: 'status'
+  text?: string
+}
+
+interface ConnectionTestContentEvent {
+  type: 'content'
+  text?: string
+}
+
+interface ConnectionTestCompleteEvent {
+  type: 'test_complete'
+  success: boolean
   error?: string
 }
 
-function connectionTestErrorText(event: ConnectionTestEvent) {
-  if (typeof event.error === 'string' && event.error.trim().length > 0)
-    return event.error
-  return '测试连接失败'
+type ConnectionTestFailureSource = 'gateway' | 'provider' | 'upstream'
+type ConnectionTestSendState = 'not_sent' | 'sent' | 'ambiguous'
+
+interface ConnectionTestFailureEvent {
+  type: 'error'
+  source?: ConnectionTestFailureSource
+  gatewayErrorCode?: string
+  sendState?: ConnectionTestSendState | null
+  error?: string
+  providerErrorCode?: string | null
+  providerErrorType?: string | null
+  upstreamStatus?: number | null
+  upstreamContentType?: string | null
+  upstreamBody?: string | null
+}
+
+type ConnectionTestEvent
+  = | ConnectionTestStartEvent
+    | ConnectionTestRequestEvent
+    | ConnectionTestStatusEvent
+    | ConnectionTestContentEvent
+    | ConnectionTestCompleteEvent
+    | ConnectionTestFailureEvent
+
+const CONNECTION_TEST_EVENT_TYPES = new Set([
+  'test_start',
+  'request',
+  'status',
+  'content',
+  'test_complete',
+  'error',
+])
+
+const CONNECTION_TEST_FAILURE_TEXT: Record = {
+  invalid_request: '测试请求不合法',
+  unsupported: '当前 Provider 不支持连接测试',
+  unauthorized: '账号凭据无效',
+  policy_denied: '测试请求被网关策略拒绝',
+  model_not_found: '测试模型不存在',
+  no_available_provider: '指定账号当前不可用于连接测试',
+  account_capacity_unavailable: '指定账号当前没有可用容量',
+  provider_infrastructure_unavailable: 'Provider 本地基础设施暂不可用',
+  rate_limited: '上游请求过于频繁',
+  upstream_unavailable: '上游服务暂不可用',
+  timeout: '上游请求超时',
+  cancelled: '测试请求已取消',
+  internal_error: '网关内部错误',
+}
+
+const CONNECTION_TEST_SOURCE_LABEL: Record = {
+  gateway: '网关校验',
+  provider: 'Provider 本地准备',
+  upstream: '上游响应',
+}
+
+function parseConnectionTestEvent(raw: string): ConnectionTestEvent | null {
+  const value: unknown = JSON.parse(raw)
+  if (!value || typeof value !== 'object' || !('type' in value) || typeof value.type !== 'string')
+    throw new TypeError('invalid connection-test event')
+  if (!CONNECTION_TEST_EVENT_TYPES.has(value.type as ConnectionTestEvent['type']))
+    return null
+  return value as ConnectionTestEvent
+}
+
+function connectionTestFailureText(event: ConnectionTestFailureEvent) {
+  return event.gatewayErrorCode
+    ? CONNECTION_TEST_FAILURE_TEXT[event.gatewayErrorCode] || '未分类错误'
+    : '测试连接失败'
+}
+
+function connectionTestFailureLabel(event: ConnectionTestFailureEvent) {
+  return event.source ? CONNECTION_TEST_SOURCE_LABEL[event.source] || '测试失败' : '测试失败'
+}
+
+function connectionTestFailureDiagnostics(event: ConnectionTestFailureEvent) {
+  return {
+    error: event.error ?? null,
+    gatewayErrorCode: event.gatewayErrorCode ?? null,
+    sendState: event.sendState ?? null,
+    upstreamStatus: event.upstreamStatus ?? null,
+    providerErrorCode: event.providerErrorCode ?? null,
+    providerErrorType: event.providerErrorType ?? null,
+    upstreamContentType: event.upstreamContentType ?? null,
+    upstreamBody: event.upstreamBody ?? null,
+  }
 }
 
 export function useAccountConnectionTest(options: { reload: () => Promise }) {
@@ -108,7 +207,7 @@ export function useAccountConnectionTest(options: { reload: () => Promise Promise Promise Promise Promise= 500
-    && !error.message.startsWith('OpenAI reset-credit upstream returned HTTP ')
+  return error.code === 50202
+    || error.status === 0
+    || error.status === 408
+    || error.kind === 'timeout'
+    || error.kind === 'network'
 }
 
 function resetResultMessage(code: string) {
diff --git a/frontend/src/views/usage/components/OpsErrorDetailModal.vue b/frontend/src/views/usage/components/OpsErrorDetailModal.vue
index 0f08f323b..f9dfe53de 100644
--- a/frontend/src/views/usage/components/OpsErrorDetailModal.vue
+++ b/frontend/src/views/usage/components/OpsErrorDetailModal.vue
@@ -4,6 +4,7 @@ import type { getOpsErrors } from '@/api'
 import { computed } from 'vue'
 import BaseModal from '@/components/base/BaseModal/index.vue'
 import { displayValue } from '../utils/detail'
+import { presentOpsError } from '../utils/opsErrorPresentation'
 import UsageDetailCodePanel from './UsageDetailCodePanel.vue'
 import UsageStatusCodeBadge from './UsageStatusCodeBadge.vue'
 
@@ -13,11 +14,14 @@ const props = defineProps<{
 
 const open = defineModel({ default: false })
 
+const presentation = computed(() => props.record ? presentOpsError(props.record) : null)
+
 const fields = computed(() => [
   { label: '账号', value: props.record?.metadata.accountLabel },
   { label: '时间', value: props.record?.createdAtDisplay },
-  { label: '事件', value: props.record?.kind },
-  { label: '失败分类', value: props.record?.failureClass },
+  { label: '记录来源', value: presentation.value?.sourceLabel },
+  { label: '组件', value: presentation.value?.componentLabel },
+  { label: '失败分类', value: presentation.value?.failureClassLabel },
   { label: '平台/类型', value: providerKindLabel(props.record) },
   { label: '端点', value: props.record?.route },
   { label: '模型', value: props.record?.model },
@@ -71,7 +75,7 @@ function providerKindLabel(record: Awaited>['ite
           
         

- {{ displayValue(record.message) }} + {{ displayValue(presentation?.summary) }}

@@ -96,6 +100,10 @@ function providerKindLabel(record: Awaited>['ite
+
+ +
+
>['items'][nu selectedRecord.value = record detailOpen.value = true } + +function failureClassText(record: Awaited>['items'][number]) { + return presentOpsError(record).failureClassLabel +} + +function errorSummary(record: Awaited>['items'][number]) { + return presentOpsError(record).summary +} +