fix(admin): unify control-plane error responses

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-08-29 20:22:14 +08:00
co-authored by Codex
parent 515487baee
commit ad20064d15
49 changed files with 1434 additions and 353 deletions
+8 -7
View File
@@ -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<Vec<WorkerContribution>, 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, AdminError> {
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 注册表初始化失败")
}
@@ -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<UpstreamSendState>,
message: String,
provider_error_code: Option<String>,
provider_error_type: Option<String>,
+13 -1
View File
@@ -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<String>) -> 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<String>) -> Self {
Self::new(AdminErrorKind::UpstreamResultUnknown, message)
}
#[must_use]
pub fn unavailable(message: impl Into<String>) -> Self {
Self::new(AdminErrorKind::Unavailable, message)
@@ -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 {
@@ -84,7 +84,7 @@ impl AccountGroupService for DefaultAccountGroupService {
command: CreateAccountGroup,
) -> Result<AccountGroupMutation, AdminError> {
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(
@@ -84,9 +84,7 @@ pub trait AccountsService: Send + Sync {
&self,
_account_id: &ProviderAccountId,
) -> Result<ProviderProfileStatistics, AdminError> {
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<ProviderResetCredits, AdminError> {
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<ProviderResetCreditResult, AdminError> {
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<ProviderAccountId>,
) -> Result<AccountExportBundle, AdminError> {
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::<ProviderKind, Vec<ProviderAccountId>>::new();
@@ -385,7 +377,7 @@ impl AccountsService for DefaultAccountsService {
let unique = ids.iter().collect::<std::collections::BTreeSet<_>>();
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<AccountUpdateResult, AdminError> {
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::<Result<Vec<_>, _>>()?;
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")
}
@@ -199,12 +199,12 @@ fn hash_admin_password(password: &str) -> Result<String, AdminError> {
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<bool, AdminError> {
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())
@@ -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("备份服务请求失败"),
}
}
@@ -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<CreatedClientKey, AdminError> {
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 游标不合法"))
}
}
@@ -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,
@@ -186,7 +186,7 @@ impl ObservabilityService for DefaultObservabilityService {
async fn usage_record_detail(&self, request_id: &str) -> Result<UsageDetail, AdminError> {
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<RequestMetricPoint>) -> Result<Trend, Admi
.saturating_add(point.cost_coverage.not_billable_count);
for cost in &point.costs {
if let Some(amount) = costs.get_mut(&cost.currency) {
*amount = amount.checked_add(&cost.amount).ok_or_else(|| {
AdminError::internal("Cost aggregation exceeded numeric bounds")
})?;
*amount = amount
.checked_add(&cost.amount)
.ok_or_else(|| AdminError::internal("成本聚合结果超出数值范围"))?;
} else {
costs.insert(cost.currency.clone(), cost.amount.clone());
}
@@ -123,6 +123,6 @@ fn validate_settings(command: &ReplaceRuntimeSettings) -> Result<(), AdminError>
if valid {
Ok(())
} else {
Err(AdminError::invalid("Runtime settings violate constraints"))
Err(AdminError::invalid("运行时设置不满足约束"))
}
}
@@ -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)
}
@@ -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<AccountProbeResult, AccountProbeError>> {
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,
))
})
}
}
@@ -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<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<ListAccountGroupsQuery>,
AdminQuery(query): AdminQuery<ListAccountGroupsQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -255,7 +255,7 @@ where
async fn create<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<CreateAccountGroupRequest>,
AdminJson(request): AdminJson<CreateAccountGroupRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -281,7 +281,7 @@ where
async fn update<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<UpdateAccountGroupRequest>,
AdminJson(request): AdminJson<UpdateAccountGroupRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -308,7 +308,7 @@ where
async fn enable<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<AccountGroupIdRequest>,
AdminJson(request): AdminJson<AccountGroupIdRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -319,7 +319,7 @@ where
async fn disable<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<AccountGroupIdRequest>,
AdminJson(request): AdminJson<AccountGroupIdRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -355,7 +355,7 @@ where
async fn delete<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<AccountGroupIdRequest>,
AdminJson(request): AdminJson<AccountGroupIdRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -387,12 +387,11 @@ fn mutation_response(
}
fn group_id(value: String) -> Result<AccountGroupId, AdminError> {
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, AdminError> {
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)
}
@@ -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<DomainConnectionTestEvent> 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<DomainConnectionTestEvent> 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<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<BatchUpdateAccountsRequest>,
AdminJson(request): AdminJson<BatchUpdateAccountsRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -1336,7 +1342,7 @@ where
async fn list_accounts<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<ListQuery>,
AdminQuery(query): AdminQuery<ListQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -1357,7 +1363,7 @@ where
async fn account_detail<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<AccountIdQuery>,
AdminQuery(query): AdminQuery<AccountIdQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -1378,7 +1384,7 @@ where
async fn export_accounts<S>(
auth: AdminAuth,
State(state): State<S>,
Query(query): Query<AccountExportQuery>,
AdminQuery(query): AdminQuery<AccountExportQuery>,
) -> Result<Response, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -1401,7 +1407,7 @@ where
async fn import_accounts<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<AccountImportRequest>,
AdminJson(request): AdminJson<AccountImportRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -1429,7 +1435,7 @@ where
async fn start_account_authorization<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<StartAccountAuthorizationRequest>,
AdminJson(request): AdminJson<StartAccountAuthorizationRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -1463,7 +1469,7 @@ where
async fn complete_account_authorization<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<CompleteAccountAuthorizationRequest>,
AdminJson(request): AdminJson<CompleteAccountAuthorizationRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -1497,7 +1503,7 @@ where
async fn rotate_account<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<RotateAccountRequest>,
AdminJson(request): AdminJson<RotateAccountRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -1520,7 +1526,7 @@ where
async fn update_account<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<UpdateAccountRequest>,
AdminJson(request): AdminJson<UpdateAccountRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -1541,7 +1547,7 @@ where
async fn delete_accounts<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<AccountDeletionRequest>,
AdminJson(request): AdminJson<AccountDeletionRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -1563,7 +1569,7 @@ where
async fn refresh_account<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<AccountRefreshRequest>,
AdminJson(request): AdminJson<AccountRefreshRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -1582,7 +1588,7 @@ where
async fn recover_account<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<AccountActionRequest>,
AdminJson(request): AdminJson<AccountActionRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -1601,7 +1607,7 @@ where
async fn account_quota<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<AccountIdQuery>,
AdminQuery(query): AdminQuery<AccountIdQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -1622,7 +1628,7 @@ where
async fn account_profile_statistics<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<AccountIdQuery>,
AdminQuery(query): AdminQuery<AccountIdQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -1643,7 +1649,7 @@ where
async fn refresh_account_quota<S>(
_auth: AdminAuth,
State(state): State<S>,
Json(request): Json<AccountActionRequest>,
AdminJson(request): AdminJson<AccountActionRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -1664,7 +1670,7 @@ where
async fn account_reset_credits<S>(
auth: AdminAuth,
State(state): State<S>,
Query(query): Query<AccountIdQuery>,
AdminQuery(query): AdminQuery<AccountIdQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -1685,7 +1691,7 @@ where
async fn consume_account_reset_credit<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<AccountResetCreditConsumeRequest>,
AdminJson(request): AdminJson<AccountResetCreditConsumeRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -1706,7 +1712,7 @@ where
async fn account_models<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<AccountIdQuery>,
AdminQuery(query): AdminQuery<AccountIdQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -1725,7 +1731,7 @@ where
async fn refresh_account_models<S>(
_auth: AdminAuth,
State(state): State<S>,
Json(request): Json<AccountActionRequest>,
AdminJson(request): AdminJson<AccountActionRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -1744,7 +1750,7 @@ where
async fn test_account_connection<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<AccountTestQuery>,
AdminQuery(query): AdminQuery<AccountTestQuery>,
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -2236,9 +2242,9 @@ fn china_datetime(value: &DateTime<Utc>) -> 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)
}
+10 -13
View File
@@ -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<Self, Self::Rejection> {
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<S>(
State(state): State<S>,
Json(payload): Json<AdminLoginRequest>,
AdminJson(payload): AdminJson<AdminLoginRequest>,
) -> Result<Response, AdminError>
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<String> {
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(),
}
}
+15 -15
View File
@@ -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<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<UpdateBackupStorageRequest>,
AdminJson(request): AdminJson<UpdateBackupStorageRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -304,7 +304,7 @@ where
async fn update_backup_schedule<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<UpdateBackupScheduleRequest>,
AdminJson(request): AdminJson<UpdateBackupScheduleRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -331,28 +331,28 @@ where
async fn backup_records<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<BackupRecordsQuery>,
AdminQuery(query): AdminQuery<BackupRecordsQuery>,
) -> Result<impl IntoResponse, AdminError>
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<S>(
auth: AdminAuth,
State(state): State<S>,
body: Option<Json<CreateBackupRequest>>,
body: Option<AdminJson<CreateBackupRequest>>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -417,7 +417,7 @@ pub struct CreateBackupRequest {
async fn download_backup_url<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<BackupIdRequest>,
AdminJson(request): AdminJson<BackupIdRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -437,7 +437,7 @@ where
async fn delete_backup<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<BackupIdRequest>,
AdminJson(request): AdminJson<BackupIdRequest>,
) -> Result<impl IntoResponse, AdminError>
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)
}
@@ -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<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<ListClientKeysQuery>,
AdminQuery(query): AdminQuery<ListClientKeysQuery>,
) -> Result<impl IntoResponse, AdminError>
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<S>(
auth: AdminAuth,
State(state): State<S>,
Json(payload): Json<CreateClientKeyRequest>,
AdminJson(payload): AdminJson<CreateClientKeyRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -709,7 +708,7 @@ where
async fn reveal_client_key<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<ClientKeyIdQuery>,
AdminQuery(query): AdminQuery<ClientKeyIdQuery>,
) -> Result<Response, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -735,7 +734,7 @@ where
async fn update_client_key<S>(
auth: AdminAuth,
State(state): State<S>,
Json(payload): Json<UpdateClientKeyRequest>,
AdminJson(payload): AdminJson<UpdateClientKeyRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -753,7 +752,7 @@ where
async fn disable_client_key<S>(
auth: AdminAuth,
State(state): State<S>,
Json(payload): Json<ClientKeyMutationRequest>,
AdminJson(payload): AdminJson<ClientKeyMutationRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -774,7 +773,7 @@ where
async fn enable_client_key<S>(
auth: AdminAuth,
State(state): State<S>,
Json(payload): Json<ClientKeyMutationRequest>,
AdminJson(payload): AdminJson<ClientKeyMutationRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -795,7 +794,7 @@ where
async fn delete_client_key<S>(
auth: AdminAuth,
State(state): State<S>,
Json(payload): Json<ClientKeyMutationRequest>,
AdminJson(payload): AdminJson<ClientKeyMutationRequest>,
) -> Result<impl IntoResponse, AdminError>
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)
}
@@ -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<T>(pub T);
impl<T, S> FromRequest<S> for AdminJson<T>
where
T: DeserializeOwned,
S: Send + Sync,
{
type Rejection = AdminError;
async fn from_request(request: Request, state: &S) -> Result<Self, Self::Rejection> {
<Json<T> as FromRequest<S>>::from_request(request, state)
.await
.map(|Json(value)| Self(value))
.map_err(map_json_rejection)
}
}
impl<T, S> OptionalFromRequest<S> for AdminJson<T>
where
T: DeserializeOwned,
S: Send + Sync,
{
type Rejection = AdminError;
async fn from_request(request: Request, state: &S) -> Result<Option<Self>, Self::Rejection> {
<Json<T> as OptionalFromRequest<S>>::from_request(request, state)
.await
.map(|value| value.map(|Json(value)| Self(value)))
.map_err(map_json_rejection)
}
}
/// 将 Axum Query rejection 收口为管理端错误信封。
pub struct AdminQuery<T>(pub T);
impl<T, S> FromRequestParts<S> for AdminQuery<T>
where
T: DeserializeOwned,
S: Send + Sync,
{
type Rejection = AdminError;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
Query::<T>::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("请求参数不合法")
}
@@ -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::<S>())
.merge(settings::router::<S>())
.merge(system::router::<S>())
.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()
@@ -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,
};
@@ -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)
}
@@ -32,7 +32,7 @@ where
pub(crate) async fn dashboard_summary<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<DashboardQuery>,
AdminQuery(query): AdminQuery<DashboardQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -56,7 +56,7 @@ where
pub(crate) async fn dashboard_trend<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<DashboardQuery>,
AdminQuery(query): AdminQuery<DashboardQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -79,7 +79,7 @@ where
pub(crate) async fn usage_records<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<UsageQuery>,
AdminQuery(query): AdminQuery<UsageQuery>,
) -> Result<impl IntoResponse, AdminError>
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<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<DetailQuery>,
AdminQuery(query): AdminQuery<DetailQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -120,7 +119,7 @@ where
pub(crate) async fn usage_records_summary<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<UsageQuery>,
AdminQuery(query): AdminQuery<UsageQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -143,7 +142,7 @@ where
pub(crate) async fn usage_insights_overview<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<UsageQuery>,
AdminQuery(query): AdminQuery<UsageQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -166,7 +165,7 @@ where
pub(crate) async fn usage_insights_diagnostics<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<DiagnosticsQuery>,
AdminQuery(query): AdminQuery<DiagnosticsQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -196,7 +195,7 @@ where
pub(crate) async fn ops_errors<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<OpsQuery>,
AdminQuery(query): AdminQuery<OpsQuery>,
) -> Result<impl IntoResponse, AdminError>
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)))
}
@@ -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<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<UpdateRuntimeSettingsRequest>,
AdminJson(request): AdminJson<UpdateRuntimeSettingsRequest>,
) -> Result<impl IntoResponse, AdminError>
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)
}
+10 -10
View File
@@ -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<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<UpdateDetailQuery>,
AdminQuery(query): AdminQuery<UpdateDetailQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -290,7 +290,7 @@ where
async fn perform_update<S>(
_auth: AdminAuth,
State(state): State<S>,
payload: Option<Json<UpdateRequest>>,
payload: Option<AdminJson<UpdateRequest>>,
) -> Result<impl IntoResponse, AdminError>
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)
}
+132 -53
View File
@@ -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<String>) -> 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<String>) -> 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<String>) -> Self {
@@ -221,47 +310,37 @@ impl AdminError {
Self::new(StatusCode::NOT_FOUND, AdminErrorCode::NOT_FOUND, message)
}
pub fn internal(message: impl Into<String>) -> 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<String>) -> 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<String>) -> 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(),
}
}
@@ -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",
@@ -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::<AdminTestState>().with_state(state)
}
fn request(method: Method, uri: &str, body: Body) -> Request<Body> {
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 无效")
),
]
);
}
@@ -77,6 +77,7 @@ mod account_groups;
mod accounts;
mod auth;
mod client_keys;
mod errors;
mod observability;
mod settings;
mod system;
+10 -8
View File
@@ -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,
),
@@ -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",
@@ -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,
)
}
}
@@ -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<UpstreamSendState>,
upstream_response: Option<AccountProbeUpstreamResponse>,
}
@@ -84,10 +109,14 @@ impl AccountProbeError {
#[must_use]
pub const fn new(
gateway: GatewayError,
source: AccountProbeErrorSource,
send_state: Option<UpstreamSendState>,
upstream_response: Option<AccountProbeUpstreamResponse>,
) -> 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<UpstreamSendState> {
self.send_state
}
#[must_use]
pub fn client_message(&self) -> &str {
self.gateway.client_message()
@@ -120,7 +159,7 @@ impl AccountProbeError {
impl From<GatewayError> for AccountProbeError {
fn from(gateway: GatewayError) -> Self {
Self::new(gateway, None)
Self::new(gateway, AccountProbeErrorSource::Gateway, None, None)
}
}
@@ -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<dyn Provider>])
.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<Vec<ProviderModelCapabilities>, ProviderError> {
Ok(Vec::new())
}
async fn execute(
&self,
_: ProviderRequest,
_: AttemptContext,
) -> Result<ProviderStream, ProviderError> {
Err(ProviderError::new(
ProviderErrorKind::Unsupported,
UpstreamSendState::NotSent,
))
}
}
struct ColdFailingProvider;
#[async_trait]
+2 -1
View File
@@ -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",
),
@@ -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(
+60 -3
View File
@@ -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_...",
+15
View File
@@ -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 与账号分组形成授权范围:
+96
View File
@@ -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<unknown>) {
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<string, unknown>
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})` : '请求失败'
}
}
+5 -17
View File
@@ -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<any>) => {
(error: AxiosError<unknown>) => {
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))
},
)
+3
View File
@@ -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
@@ -193,6 +193,12 @@ function connectionLogClass(tone: string) {
{{ item.text }}
</p>
<div v-if="item.detail" class="mt-2 rounded-lg bg-cp-fill-quaternary px-3 py-2">
<p
v-if="item.tone === 'danger'"
class="mt-0 mb-2 text-cp-xs font-heavy text-cp-text-quaternary"
>
原始诊断
</p>
<BaseScrollbar max-height="138px">
<div>
<pre
@@ -36,19 +36,118 @@ interface ConnectionTestRequestPayload {
input?: Array<{ content?: Array<{ type?: string, text?: string }> }>
}
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<ConnectionTestEvent['type']>([
'test_start',
'request',
'status',
'content',
'test_complete',
'error',
])
const CONNECTION_TEST_FAILURE_TEXT: Record<string, string> = {
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<ConnectionTestFailureSource, string> = {
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<unknown> }) {
@@ -108,7 +207,7 @@ export function useAccountConnectionTest(options: { reload: () => Promise<unknow
if (connectionTestStatus.value === 'error') {
return {
label: '测试失败',
description: '请求未完成,请在下方查看上游错误与事件轨迹',
description: '请求未完成,请在下方查看失败来源与原始诊断',
icon: XCircle,
badge: 'bg-cp-error-bg text-cp-error-text',
iconClass: 'text-cp-error',
@@ -231,9 +330,14 @@ export function useAccountConnectionTest(options: { reload: () => Promise<unknow
clearConnectionTestRun()
}
function recordConnectionTestFailure(key: string, label: string, message: string) {
function recordConnectionTestFailure(
key: string,
label: string,
message: string,
detail?: unknown,
) {
connectionTestError.value = message
setConnectionTestLog(key, label, 'danger', message)
setConnectionTestLog(key, `${label}:${message}`, 'danger', detail)
finishConnectionTest('error')
}
@@ -266,9 +370,10 @@ export function useAccountConnectionTest(options: { reload: () => Promise<unknow
}
else {
recordConnectionTestFailure(
'upstream-response',
'上游响应',
connectionTestErrorText(event),
'test-complete-failure',
'测试失败',
'测试连接失败',
{ error: event.error ?? null },
)
}
clearConnectionTestRun()
@@ -277,9 +382,10 @@ export function useAccountConnectionTest(options: { reload: () => Promise<unknow
}
if (event.type === 'error') {
recordConnectionTestFailure(
'upstream-response',
'上游响应',
connectionTestErrorText(event),
`failure-${event.source || 'unknown'}`,
connectionTestFailureLabel(event),
connectionTestFailureText(event),
connectionTestFailureDiagnostics(event),
)
clearConnectionTestRun()
void options.reload()
@@ -404,7 +510,9 @@ export function useAccountConnectionTest(options: { reload: () => Promise<unknow
if (!message?.raw || !connectionTestRun)
return
try {
handleConnectionTestEvent(JSON.parse(message.raw))
const event = parseConnectionTestEvent(message.raw)
if (event)
handleConnectionTestEvent(event)
}
catch {
failConnectionTest('测试响应解析失败')
@@ -260,10 +260,11 @@ export function useAccountResetCredits(options: {
function isAmbiguousConsumeError(error: unknown) {
if (!(error instanceof ApiError))
return false
if (error.status === 0 || error.status === 408 || error.message.includes('consume result is unknown'))
return true
return error.status >= 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) {
@@ -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<boolean>({ 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<ReturnType<typeof getOpsErrors>>['ite
</span>
</div>
<p class="mt-3 mb-0 text-cp leading-relaxed font-bold text-cp-text">
{{ displayValue(record.message) }}
{{ displayValue(presentation?.summary) }}
</p>
</section>
@@ -96,6 +100,10 @@ function providerKindLabel(record: Awaited<ReturnType<typeof getOpsErrors>>['ite
</div>
</dl>
<section class="mt-3 rounded-cp-card bg-cp-fill-quaternary px-4 py-3.5">
<UsageDetailCodePanel title="原始诊断" max-height="260px" :content="record.message" />
</section>
<section
v-if="metadataText"
class="mt-3 rounded-cp-card bg-cp-fill-quaternary px-4 py-3.5"
@@ -10,6 +10,7 @@ import BaseTablePagination from '@/components/base/BaseTable/BaseTablePagination
import BaseTable from '@/components/base/BaseTable/index.vue'
import { useOpsErrorsTable } from '../composables/useOpsErrorsTable'
import { opsErrorColumns } from '../constants'
import { presentOpsError } from '../utils/opsErrorPresentation'
import OpsErrorDetailModal from './OpsErrorDetailModal.vue'
import UsageStatusCodeBadge from './UsageStatusCodeBadge.vue'
@@ -39,6 +40,14 @@ function showDetail(record: Awaited<ReturnType<typeof getOpsErrors>>['items'][nu
selectedRecord.value = record
detailOpen.value = true
}
function failureClassText(record: Awaited<ReturnType<typeof getOpsErrors>>['items'][number]) {
return presentOpsError(record).failureClassLabel
}
function errorSummary(record: Awaited<ReturnType<typeof getOpsErrors>>['items'][number]) {
return presentOpsError(record).summary
}
</script>
<template>
@@ -101,7 +110,12 @@ function showDetail(record: Awaited<ReturnType<typeof getOpsErrors>>['items'][nu
</template>
<template #failureClass="{ row }">
<span class="font-mono text-cp-sm font-bold text-cp-error-text">
{{ row.failureClass || '—' }}
{{ failureClassText(row) }}
</span>
</template>
<template #message="{ row }">
<span class="block max-w-full truncate text-cp-sm font-emphasis text-cp-text" :title="errorSummary(row)">
{{ errorSummary(row) }}
</span>
</template>
<template #accountId="{ row }">
+1 -1
View File
@@ -42,7 +42,7 @@ export const opsErrorColumns = defineTableColumns<OpsErrorRow>([
{ key: 'route', label: '端点', kind: 'mono' },
{ key: 'model', label: '模型', kind: 'mono' },
{ key: 'requestId', label: '请求 ID', kind: 'mono', size: '2xl' },
{ key: 'message', label: '消息', kind: 'text', size: '3xl' },
{ key: 'message', label: '错误摘要', kind: 'custom', size: '3xl' },
{ key: 'actions', label: '操作', kind: 'actions', size: 'sm' },
])
@@ -0,0 +1,81 @@
import type { OpsError } from '@/api/modules/usage'
const FAILURE_CLASS_LABELS: Record<string, string> = {
invalid_request: '请求不合法',
unsupported: '请求能力不受支持',
unauthorized: '认证失败',
auth_failed: '认证失败',
permission_denied: '上游权限不足',
policy_denied: '策略拒绝',
model_not_found: '模型不存在',
no_available_provider: '没有可用 Provider',
no_eligible_account: '没有符合条件的账号',
account_capacity_unavailable: '账号容量不足',
provider_infrastructure_unavailable: 'Provider 基础设施不可用',
rate_limited: '上游限流',
quota_exhausted: '账号额度已耗尽',
upstream_unavailable: '上游服务不可用',
upstream_error: '上游响应错误',
transport: '上游传输失败',
protocol: '上游协议错误',
unavailable: 'Provider 暂不可用',
timeout: '请求超时',
cancelled: '请求已取消',
process_terminated: '进程终止',
internal_error: '内部错误',
failed: '请求失败',
}
const COMPONENT_LABELS: Record<string, string> = {
model_request: '模型请求',
routing: '路由调度',
account_probe: '账号连接测试',
}
const SOURCE_LABELS: Record<string, string> = {
model_request: '模型请求记录',
ops_event: '运维事件',
}
export interface OpsErrorPresentation {
summary: string
failureClassLabel: string
componentLabel: string
sourceLabel: string
}
/** 将运维错误的稳定机器字段转换为中文摘要;原始诊断仍由调用方单独展示。 */
export function presentOpsError(record: OpsError): OpsErrorPresentation {
const failureClassLabel = mappedOrOriginal(FAILURE_CLASS_LABELS, record.failureClass, '未分类错误')
const componentLabel = mappedOrOriginal(
COMPONENT_LABELS,
record.metadata.component || record.kind,
'未知组件',
)
const sourceLabel = mappedOrOriginal(SOURCE_LABELS, record.metadata.source, '未知来源')
const statuses = [
statusText('客户端', record.clientStatusCode),
statusText('上游', record.upstreamStatusCode),
].filter(Boolean)
const statusSuffix = statuses.length > 0 ? `(${statuses.join(',')})` : ''
return {
summary: `${componentLabel}:${failureClassLabel}${statusSuffix}`,
failureClassLabel,
componentLabel,
sourceLabel,
}
}
function mappedOrOriginal(
labels: Record<string, string>,
value: string | null | undefined,
fallback: string,
) {
const normalized = value?.trim()
return normalized ? labels[normalized] || normalized : fallback
}
function statusText(owner: string, status: number | null) {
return typeof status === 'number' ? `${owner} HTTP ${status}` : ''
}