fix(admin): 修复系统配置导入的多项兼容性问题

- 引入 serde_path_to_error,反序列化失败时返回精确字段路径
- 为所有浮点字段新增数字字符串兼容反序列化器,支持 Python 序列化格式
- 修复 OAuth provider key 导入:正确写入加密的 api_key 和 auth_config
- 优化代理节点跳过提示,区分手动 URL 代理与 node_id 引用代理
- 新增测试覆盖上述场景
This commit is contained in:
fawney19
2026-04-11 23:10:25 +08:00
parent a9f610fa69
commit 7c5bb7f383
9 changed files with 656 additions and 25 deletions

View File

@@ -1,11 +1,16 @@
use crate::handlers::admin::shared::AdminTypedObjectPatch;
use crate::handlers::admin::shared::{
deserialize_optional_f64_from_number_or_string, AdminTypedObjectPatch,
};
use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub(crate) struct AdminGlobalModelCreateRequest {
pub(crate) name: String,
pub(crate) display_name: String,
#[serde(default)]
#[serde(
default,
deserialize_with = "deserialize_optional_f64_from_number_or_string"
)]
pub(crate) default_price_per_request: Option<f64>,
#[serde(default)]
pub(crate) default_tiered_pricing: Option<serde_json::Value>,
@@ -23,7 +28,10 @@ pub(crate) struct AdminGlobalModelUpdateRequest {
pub(crate) display_name: Option<String>,
#[serde(default)]
pub(crate) is_active: Option<bool>,
#[serde(default)]
#[serde(
default,
deserialize_with = "deserialize_optional_f64_from_number_or_string"
)]
pub(crate) default_price_per_request: Option<f64>,
#[serde(default)]
pub(crate) default_tiered_pricing: Option<serde_json::Value>,

View File

@@ -1,4 +1,6 @@
use crate::handlers::admin::shared::AdminTypedObjectPatch;
use crate::handlers::admin::shared::{
deserialize_optional_f64_from_number_or_string, AdminTypedObjectPatch,
};
use serde::Deserialize;
#[derive(Debug, Deserialize)]
@@ -108,7 +110,10 @@ pub(crate) struct AdminProviderCreateRequest {
pub(crate) website: Option<String>,
#[serde(default)]
pub(crate) billing_type: Option<String>,
#[serde(default)]
#[serde(
default,
deserialize_with = "deserialize_optional_f64_from_number_or_string"
)]
pub(crate) monthly_quota_usd: Option<f64>,
#[serde(default)]
pub(crate) quota_reset_day: Option<u64>,
@@ -128,9 +133,15 @@ pub(crate) struct AdminProviderCreateRequest {
pub(crate) max_retries: Option<i32>,
#[serde(default)]
pub(crate) proxy: Option<serde_json::Value>,
#[serde(default)]
#[serde(
default,
deserialize_with = "deserialize_optional_f64_from_number_or_string"
)]
pub(crate) stream_first_byte_timeout: Option<f64>,
#[serde(default)]
#[serde(
default,
deserialize_with = "deserialize_optional_f64_from_number_or_string"
)]
pub(crate) request_timeout: Option<f64>,
#[serde(default)]
pub(crate) pool_advanced: Option<serde_json::Value>,
@@ -154,7 +165,10 @@ pub(crate) struct AdminProviderUpdateRequest {
pub(crate) website: Option<String>,
#[serde(default)]
pub(crate) billing_type: Option<String>,
#[serde(default)]
#[serde(
default,
deserialize_with = "deserialize_optional_f64_from_number_or_string"
)]
pub(crate) monthly_quota_usd: Option<f64>,
#[serde(default)]
pub(crate) quota_reset_day: Option<u64>,
@@ -174,9 +188,15 @@ pub(crate) struct AdminProviderUpdateRequest {
pub(crate) max_retries: Option<i32>,
#[serde(default)]
pub(crate) proxy: Option<serde_json::Value>,
#[serde(default)]
#[serde(
default,
deserialize_with = "deserialize_optional_f64_from_number_or_string"
)]
pub(crate) stream_first_byte_timeout: Option<f64>,
#[serde(default)]
#[serde(
default,
deserialize_with = "deserialize_optional_f64_from_number_or_string"
)]
pub(crate) request_timeout: Option<f64>,
#[serde(default)]
pub(crate) pool_advanced: Option<serde_json::Value>,
@@ -207,7 +227,10 @@ pub(crate) struct AdminProviderModelCreateRequest {
#[serde(default)]
pub(crate) provider_model_mappings: Option<serde_json::Value>,
pub(crate) global_model_id: String,
#[serde(default)]
#[serde(
default,
deserialize_with = "deserialize_optional_f64_from_number_or_string"
)]
pub(crate) price_per_request: Option<f64>,
#[serde(default)]
pub(crate) tiered_pricing: Option<serde_json::Value>,
@@ -233,7 +256,10 @@ pub(crate) struct AdminProviderModelUpdateRequest {
pub(crate) provider_model_mappings: Option<serde_json::Value>,
#[serde(default)]
pub(crate) global_model_id: Option<String>,
#[serde(default)]
#[serde(
default,
deserialize_with = "deserialize_optional_f64_from_number_or_string"
)]
pub(crate) price_per_request: Option<f64>,
#[serde(default)]
pub(crate) tiered_pricing: Option<serde_json::Value>,
@@ -266,6 +292,9 @@ pub(crate) struct AdminImportProviderModelsRequest {
pub(crate) model_ids: Vec<String>,
#[serde(default)]
pub(crate) tiered_pricing: Option<serde_json::Value>,
#[serde(default)]
#[serde(
default,
deserialize_with = "deserialize_optional_f64_from_number_or_string"
)]
pub(crate) price_per_request: Option<f64>,
}

View File

@@ -221,10 +221,14 @@ fn build_import_key_match_name(item: &ImportedProviderKey) -> Option<String> {
fn normalize_import_key_raw_payload(
raw_key: &Map<String, Value>,
auth_type: &str,
normalized_api_formats: &[String],
normalized_auth_config: Option<Value>,
) -> Map<String, Value> {
let mut payload = raw_key.clone();
if auth_type == "oauth" {
payload.remove("api_key");
}
payload.insert("api_formats".to_string(), json!(normalized_api_formats));
if let Some(auth_config) = normalized_auth_config {
payload.insert("auth_config".to_string(), auth_config);
@@ -234,6 +238,47 @@ fn normalize_import_key_raw_payload(
payload
}
fn apply_imported_oauth_key_credentials(
state: &AdminAppState<'_>,
raw_key: &Map<String, Value>,
normalized_auth_config: Option<&Value>,
record: &mut aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey,
) -> Result<(), String> {
if let Some(api_key_value) = raw_key.get("api_key") {
let plaintext = match api_key_value {
Value::String(raw) => {
let trimmed = raw.trim();
if trimmed.is_empty() {
"__placeholder__"
} else {
trimmed
}
}
_ => "__placeholder__",
};
record.encrypted_api_key = state
.encrypt_catalog_secret_with_fallbacks(plaintext)
.ok_or_else(|| "gateway 未配置 provider key 加密密钥".to_string())?;
}
if raw_key.contains_key("auth_config") {
record.encrypted_auth_config = match normalized_auth_config {
Some(auth_config) => {
let plaintext =
serde_json::to_string(auth_config).map_err(|err| err.to_string())?;
Some(
state
.encrypt_catalog_secret_with_fallbacks(&plaintext)
.ok_or_else(|| "gateway 未配置 provider key 加密密钥".to_string())?,
)
}
None => None,
};
}
Ok(())
}
fn build_import_provider_model_record(
provider_id: &str,
existing_id: Option<&str>,
@@ -365,7 +410,7 @@ impl<'a> AdminAppState<'a> {
));
} else {
stats.errors.push(
"当前 Rust 管理后端暂不支持导入代理节点;引用这些节点代理配置被清除"
"当前 Rust 管理后端暂不支持导入代理节点;引用这些节点(node_id)的自动连接代理配置被清除,手动 URL 代理配置会保留"
.to_string(),
);
}
@@ -703,12 +748,13 @@ impl<'a> AdminAppState<'a> {
let normalized_auth_config = invalid!(normalize_import_auth_config(
imported_key.auth_config.clone()
));
let auth_type = imported_key_auth_type(&imported_key);
let normalized_raw_key = normalize_import_key_raw_payload(
&raw_key,
&auth_type,
&normalized_api_formats,
normalized_auth_config.clone(),
);
let auth_type = imported_key_auth_type(&imported_key);
let existing_key_index = if auth_type == "api_key" {
let target_key = imported_key
.api_key
@@ -785,6 +831,14 @@ impl<'a> AdminAppState<'a> {
)
.await
);
if auth_type == "oauth" {
invalid!(apply_imported_oauth_key_credentials(
self,
&raw_key,
normalized_auth_config.as_ref(),
&mut updated,
));
}
updated.proxy =
remap_import_proxy(imported_key.proxy.clone(), &node_id_map);
updated.fingerprint = invalid!(normalize_json_object(
@@ -815,6 +869,14 @@ impl<'a> AdminAppState<'a> {
self.build_admin_create_provider_key_record(&provider, payload)
.await
);
if auth_type == "oauth" {
invalid!(apply_imported_oauth_key_credentials(
self,
&raw_key,
normalized_auth_config.as_ref(),
&mut record,
));
}
record.is_active = imported_key.is_active;
record.global_priority_by_format = invalid!(normalize_json_object(
imported_key.global_priority_by_format.clone(),

View File

@@ -1,7 +1,34 @@
use serde::de::DeserializeOwned;
use serde::{de, de::DeserializeOwned, Deserialize};
use serde_json::{Map, Value};
use std::collections::BTreeMap;
pub(crate) fn deserialize_optional_f64_from_number_or_string<'de, D>(
deserializer: D,
) -> Result<Option<f64>, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = Option::<Value>::deserialize(deserializer)?;
match value {
None | Some(Value::Null) => Ok(None),
Some(Value::Number(number)) => number
.as_f64()
.filter(|value| value.is_finite())
.map(Some)
.ok_or_else(|| de::Error::custom("expected a finite number")),
Some(Value::String(raw)) => raw
.trim()
.parse::<f64>()
.ok()
.filter(|value| value.is_finite())
.map(Some)
.ok_or_else(|| de::Error::custom("expected a finite number or numeric string")),
Some(_) => Err(de::Error::custom(
"expected a finite number or numeric string",
)),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum AdminJsonFieldState {
Missing,