mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
fix(admin): 修复系统配置导入的多项兼容性问题
- 引入 serde_path_to_error,反序列化失败时返回精确字段路径 - 为所有浮点字段新增数字字符串兼容反序列化器,支持 Python 序列化格式 - 修复 OAuth provider key 导入:正确写入加密的 api_key 和 auth_config - 优化代理节点跳过提示,区分手动 URL 代理与 node_id 引用代理 - 新增测试覆盖上述场景
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -34,6 +34,7 @@ dependencies = [
|
|||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"serde_path_to_error",
|
||||||
"sha2",
|
"sha2",
|
||||||
"url",
|
"url",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ regex = "1"
|
|||||||
rustls = { version = "0.23", features = ["ring"] }
|
rustls = { version = "0.23", features = ["ring"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = { version = "1", features = ["preserve_order"] }
|
serde_json = { version = "1", features = ["preserve_order"] }
|
||||||
|
serde_path_to_error = "0.1"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
sqlx = { version = "0.8", default-features = false, features = ["postgres", "runtime-tokio-rustls", "chrono"] }
|
sqlx = { version = "0.8", default-features = false, features = ["postgres", "runtime-tokio-rustls", "chrono"] }
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
|
|||||||
@@ -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;
|
use serde::Deserialize;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub(crate) struct AdminGlobalModelCreateRequest {
|
pub(crate) struct AdminGlobalModelCreateRequest {
|
||||||
pub(crate) name: String,
|
pub(crate) name: String,
|
||||||
pub(crate) display_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>,
|
pub(crate) default_price_per_request: Option<f64>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub(crate) default_tiered_pricing: Option<serde_json::Value>,
|
pub(crate) default_tiered_pricing: Option<serde_json::Value>,
|
||||||
@@ -23,7 +28,10 @@ pub(crate) struct AdminGlobalModelUpdateRequest {
|
|||||||
pub(crate) display_name: Option<String>,
|
pub(crate) display_name: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub(crate) is_active: Option<bool>,
|
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>,
|
pub(crate) default_price_per_request: Option<f64>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub(crate) default_tiered_pricing: Option<serde_json::Value>,
|
pub(crate) default_tiered_pricing: Option<serde_json::Value>,
|
||||||
|
|||||||
@@ -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;
|
use serde::Deserialize;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -108,7 +110,10 @@ pub(crate) struct AdminProviderCreateRequest {
|
|||||||
pub(crate) website: Option<String>,
|
pub(crate) website: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub(crate) billing_type: Option<String>,
|
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>,
|
pub(crate) monthly_quota_usd: Option<f64>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub(crate) quota_reset_day: Option<u64>,
|
pub(crate) quota_reset_day: Option<u64>,
|
||||||
@@ -128,9 +133,15 @@ pub(crate) struct AdminProviderCreateRequest {
|
|||||||
pub(crate) max_retries: Option<i32>,
|
pub(crate) max_retries: Option<i32>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub(crate) proxy: Option<serde_json::Value>,
|
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>,
|
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>,
|
pub(crate) request_timeout: Option<f64>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub(crate) pool_advanced: Option<serde_json::Value>,
|
pub(crate) pool_advanced: Option<serde_json::Value>,
|
||||||
@@ -154,7 +165,10 @@ pub(crate) struct AdminProviderUpdateRequest {
|
|||||||
pub(crate) website: Option<String>,
|
pub(crate) website: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub(crate) billing_type: Option<String>,
|
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>,
|
pub(crate) monthly_quota_usd: Option<f64>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub(crate) quota_reset_day: Option<u64>,
|
pub(crate) quota_reset_day: Option<u64>,
|
||||||
@@ -174,9 +188,15 @@ pub(crate) struct AdminProviderUpdateRequest {
|
|||||||
pub(crate) max_retries: Option<i32>,
|
pub(crate) max_retries: Option<i32>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub(crate) proxy: Option<serde_json::Value>,
|
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>,
|
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>,
|
pub(crate) request_timeout: Option<f64>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub(crate) pool_advanced: Option<serde_json::Value>,
|
pub(crate) pool_advanced: Option<serde_json::Value>,
|
||||||
@@ -207,7 +227,10 @@ pub(crate) struct AdminProviderModelCreateRequest {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub(crate) provider_model_mappings: Option<serde_json::Value>,
|
pub(crate) provider_model_mappings: Option<serde_json::Value>,
|
||||||
pub(crate) global_model_id: String,
|
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>,
|
pub(crate) price_per_request: Option<f64>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub(crate) tiered_pricing: Option<serde_json::Value>,
|
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>,
|
pub(crate) provider_model_mappings: Option<serde_json::Value>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub(crate) global_model_id: Option<String>,
|
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>,
|
pub(crate) price_per_request: Option<f64>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub(crate) tiered_pricing: Option<serde_json::Value>,
|
pub(crate) tiered_pricing: Option<serde_json::Value>,
|
||||||
@@ -266,6 +292,9 @@ pub(crate) struct AdminImportProviderModelsRequest {
|
|||||||
pub(crate) model_ids: Vec<String>,
|
pub(crate) model_ids: Vec<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub(crate) tiered_pricing: Option<serde_json::Value>,
|
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>,
|
pub(crate) price_per_request: Option<f64>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -221,10 +221,14 @@ fn build_import_key_match_name(item: &ImportedProviderKey) -> Option<String> {
|
|||||||
|
|
||||||
fn normalize_import_key_raw_payload(
|
fn normalize_import_key_raw_payload(
|
||||||
raw_key: &Map<String, Value>,
|
raw_key: &Map<String, Value>,
|
||||||
|
auth_type: &str,
|
||||||
normalized_api_formats: &[String],
|
normalized_api_formats: &[String],
|
||||||
normalized_auth_config: Option<Value>,
|
normalized_auth_config: Option<Value>,
|
||||||
) -> Map<String, Value> {
|
) -> Map<String, Value> {
|
||||||
let mut payload = raw_key.clone();
|
let mut payload = raw_key.clone();
|
||||||
|
if auth_type == "oauth" {
|
||||||
|
payload.remove("api_key");
|
||||||
|
}
|
||||||
payload.insert("api_formats".to_string(), json!(normalized_api_formats));
|
payload.insert("api_formats".to_string(), json!(normalized_api_formats));
|
||||||
if let Some(auth_config) = normalized_auth_config {
|
if let Some(auth_config) = normalized_auth_config {
|
||||||
payload.insert("auth_config".to_string(), auth_config);
|
payload.insert("auth_config".to_string(), auth_config);
|
||||||
@@ -234,6 +238,47 @@ fn normalize_import_key_raw_payload(
|
|||||||
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(
|
fn build_import_provider_model_record(
|
||||||
provider_id: &str,
|
provider_id: &str,
|
||||||
existing_id: Option<&str>,
|
existing_id: Option<&str>,
|
||||||
@@ -365,7 +410,7 @@ impl<'a> AdminAppState<'a> {
|
|||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
stats.errors.push(
|
stats.errors.push(
|
||||||
"当前 Rust 管理后端暂不支持导入代理节点;引用这些节点的代理配置将被清除"
|
"当前 Rust 管理后端暂不支持导入代理节点;仅引用这些节点(node_id)的自动连接代理配置会被清除,手动 URL 代理配置会保留"
|
||||||
.to_string(),
|
.to_string(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -703,12 +748,13 @@ impl<'a> AdminAppState<'a> {
|
|||||||
let normalized_auth_config = invalid!(normalize_import_auth_config(
|
let normalized_auth_config = invalid!(normalize_import_auth_config(
|
||||||
imported_key.auth_config.clone()
|
imported_key.auth_config.clone()
|
||||||
));
|
));
|
||||||
|
let auth_type = imported_key_auth_type(&imported_key);
|
||||||
let normalized_raw_key = normalize_import_key_raw_payload(
|
let normalized_raw_key = normalize_import_key_raw_payload(
|
||||||
&raw_key,
|
&raw_key,
|
||||||
|
&auth_type,
|
||||||
&normalized_api_formats,
|
&normalized_api_formats,
|
||||||
normalized_auth_config.clone(),
|
normalized_auth_config.clone(),
|
||||||
);
|
);
|
||||||
let auth_type = imported_key_auth_type(&imported_key);
|
|
||||||
let existing_key_index = if auth_type == "api_key" {
|
let existing_key_index = if auth_type == "api_key" {
|
||||||
let target_key = imported_key
|
let target_key = imported_key
|
||||||
.api_key
|
.api_key
|
||||||
@@ -785,6 +831,14 @@ impl<'a> AdminAppState<'a> {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
);
|
);
|
||||||
|
if auth_type == "oauth" {
|
||||||
|
invalid!(apply_imported_oauth_key_credentials(
|
||||||
|
self,
|
||||||
|
&raw_key,
|
||||||
|
normalized_auth_config.as_ref(),
|
||||||
|
&mut updated,
|
||||||
|
));
|
||||||
|
}
|
||||||
updated.proxy =
|
updated.proxy =
|
||||||
remap_import_proxy(imported_key.proxy.clone(), &node_id_map);
|
remap_import_proxy(imported_key.proxy.clone(), &node_id_map);
|
||||||
updated.fingerprint = invalid!(normalize_json_object(
|
updated.fingerprint = invalid!(normalize_json_object(
|
||||||
@@ -815,6 +869,14 @@ impl<'a> AdminAppState<'a> {
|
|||||||
self.build_admin_create_provider_key_record(&provider, payload)
|
self.build_admin_create_provider_key_record(&provider, payload)
|
||||||
.await
|
.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.is_active = imported_key.is_active;
|
||||||
record.global_priority_by_format = invalid!(normalize_json_object(
|
record.global_priority_by_format = invalid!(normalize_json_object(
|
||||||
imported_key.global_priority_by_format.clone(),
|
imported_key.global_priority_by_format.clone(),
|
||||||
|
|||||||
@@ -1,7 +1,34 @@
|
|||||||
use serde::de::DeserializeOwned;
|
use serde::{de, de::DeserializeOwned, Deserialize};
|
||||||
use serde_json::{Map, Value};
|
use serde_json::{Map, Value};
|
||||||
use std::collections::BTreeMap;
|
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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub(crate) enum AdminJsonFieldState {
|
pub(crate) enum AdminJsonFieldState {
|
||||||
Missing,
|
Missing,
|
||||||
|
|||||||
@@ -172,6 +172,37 @@ fn sample_system_import_payload() -> Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn sample_oauth_system_import_payload(access_token: &str, refresh_token: &str) -> Value {
|
||||||
|
json!({
|
||||||
|
"version": "2.2",
|
||||||
|
"merge_mode": "overwrite",
|
||||||
|
"global_models": [],
|
||||||
|
"providers": [{
|
||||||
|
"name": "oauth-import-provider",
|
||||||
|
"provider_type": "codex",
|
||||||
|
"website": "https://example.com",
|
||||||
|
"is_active": true,
|
||||||
|
"endpoints": [{
|
||||||
|
"api_format": "openai:cli",
|
||||||
|
"base_url": "https://chatgpt.com",
|
||||||
|
"is_active": true
|
||||||
|
}],
|
||||||
|
"api_keys": [{
|
||||||
|
"name": "oauth-primary",
|
||||||
|
"auth_type": "oauth",
|
||||||
|
"api_key": access_token,
|
||||||
|
"auth_config": format!(
|
||||||
|
"{{\"provider_type\":\"codex\",\"refresh_token\":\"{}\",\"email\":\"alice@example.com\",\"account_id\":\"acct-codex-123\",\"plan_type\":\"plus\"}}",
|
||||||
|
refresh_token
|
||||||
|
),
|
||||||
|
"api_formats": ["openai:cli"],
|
||||||
|
"is_active": true
|
||||||
|
}],
|
||||||
|
"models": []
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn fixture_system_import_payload(name: &str) -> Value {
|
fn fixture_system_import_payload(name: &str) -> Value {
|
||||||
let raw = match name {
|
let raw = match name {
|
||||||
"v20" => include_str!("../../fixtures/admin_system/config_export_v20.json"),
|
"v20" => include_str!("../../fixtures/admin_system/config_export_v20.json"),
|
||||||
@@ -506,6 +537,255 @@ async fn gateway_imports_admin_system_config_fixtures_from_legacy_exports() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn gateway_reports_field_path_for_invalid_admin_system_config_import_shape() {
|
||||||
|
let gateway = build_router_with_state(
|
||||||
|
AppState::new()
|
||||||
|
.expect("gateway should build")
|
||||||
|
.with_data_state_for_tests(build_empty_admin_system_data_state()),
|
||||||
|
);
|
||||||
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||||
|
|
||||||
|
let response = reqwest::Client::new()
|
||||||
|
.post(format!("{gateway_url}/api/admin/system/config/import"))
|
||||||
|
.header(GATEWAY_HEADER, "rust-phase3b")
|
||||||
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
||||||
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
||||||
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
||||||
|
.json(&json!({
|
||||||
|
"version": "2.2",
|
||||||
|
"providers": [{
|
||||||
|
"name": "import-openai",
|
||||||
|
"endpoints": [{
|
||||||
|
"api_format": "openai:chat",
|
||||||
|
"base_url": "https://api.example.com",
|
||||||
|
"is_active": "yes"
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("request should succeed");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||||
|
let payload: Value = response.json().await.expect("json body should parse");
|
||||||
|
let detail = payload["detail"]
|
||||||
|
.as_str()
|
||||||
|
.expect("detail should be a string");
|
||||||
|
assert!(detail.contains("配置文件格式无效"));
|
||||||
|
assert!(detail.contains("providers[0].endpoints[0].is_active"));
|
||||||
|
|
||||||
|
gateway_handle.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn gateway_imports_admin_system_config_with_numeric_string_prices() {
|
||||||
|
let gateway = build_router_with_state(
|
||||||
|
AppState::new()
|
||||||
|
.expect("gateway should build")
|
||||||
|
.with_data_state_for_tests(build_empty_admin_system_data_state()),
|
||||||
|
);
|
||||||
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||||
|
|
||||||
|
let mut payload = sample_system_import_payload();
|
||||||
|
payload["global_models"][0]["default_price_per_request"] = json!("1.80000000");
|
||||||
|
payload["providers"][0]["request_timeout"] = json!("30");
|
||||||
|
payload["providers"][0]["stream_first_byte_timeout"] = json!("15");
|
||||||
|
payload["providers"][0]["models"][0]["price_per_request"] = json!("0.70000000");
|
||||||
|
|
||||||
|
let response = reqwest::Client::new()
|
||||||
|
.post(format!("{gateway_url}/api/admin/system/config/import"))
|
||||||
|
.header(GATEWAY_HEADER, "rust-phase3b")
|
||||||
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
||||||
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
||||||
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
||||||
|
.json(&payload)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("request should succeed");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body: Value = response.json().await.expect("json body should parse");
|
||||||
|
assert_eq!(body["message"], "配置导入成功");
|
||||||
|
assert_eq!(body["stats"]["global_models"]["created"], json!(1));
|
||||||
|
assert_eq!(body["stats"]["providers"]["created"], json!(1));
|
||||||
|
assert_eq!(body["stats"]["models"]["created"], json!(1));
|
||||||
|
|
||||||
|
gateway_handle.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn gateway_imports_oauth_provider_key_credentials_from_admin_system_config() {
|
||||||
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||||
|
Vec::new(),
|
||||||
|
Vec::new(),
|
||||||
|
Vec::new(),
|
||||||
|
));
|
||||||
|
let global_model_repository = Arc::new(InMemoryGlobalModelReadRepository::seed(Vec::<
|
||||||
|
StoredPublicGlobalModel,
|
||||||
|
>::new()));
|
||||||
|
let auth_module_repository = Arc::new(InMemoryAuthModuleReadRepository::seed(
|
||||||
|
Vec::<StoredOAuthProviderModuleConfig>::new(),
|
||||||
|
None,
|
||||||
|
));
|
||||||
|
let oauth_provider_repository = Arc::new(InMemoryOAuthProviderRepository::seed(Vec::<
|
||||||
|
StoredOAuthProviderConfig,
|
||||||
|
>::new()));
|
||||||
|
|
||||||
|
let data_state = GatewayDataState::with_provider_catalog_repository_for_tests(Arc::clone(
|
||||||
|
&provider_catalog_repository,
|
||||||
|
))
|
||||||
|
.with_global_model_repository_for_tests(Arc::clone(&global_model_repository))
|
||||||
|
.attach_auth_module_repository_for_tests(Arc::clone(&auth_module_repository))
|
||||||
|
.attach_oauth_provider_repository_for_tests(Arc::clone(&oauth_provider_repository))
|
||||||
|
.with_system_config_values_for_tests(Vec::<(String, Value)>::new())
|
||||||
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY);
|
||||||
|
|
||||||
|
let gateway = build_router_with_state(
|
||||||
|
AppState::new()
|
||||||
|
.expect("gateway should build")
|
||||||
|
.with_data_state_for_tests(data_state),
|
||||||
|
);
|
||||||
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||||
|
|
||||||
|
let response = reqwest::Client::new()
|
||||||
|
.post(format!("{gateway_url}/api/admin/system/config/import"))
|
||||||
|
.header(GATEWAY_HEADER, "rust-phase3b")
|
||||||
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
||||||
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
||||||
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
||||||
|
.json(&sample_oauth_system_import_payload(
|
||||||
|
"oauth-access-token-1",
|
||||||
|
"oauth-refresh-token-1",
|
||||||
|
))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("request should succeed");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let providers = provider_catalog_repository
|
||||||
|
.list_providers(false)
|
||||||
|
.await
|
||||||
|
.expect("providers should load");
|
||||||
|
assert_eq!(providers.len(), 1);
|
||||||
|
|
||||||
|
let keys = provider_catalog_repository
|
||||||
|
.list_keys_by_provider_ids(std::slice::from_ref(&providers[0].id))
|
||||||
|
.await
|
||||||
|
.expect("keys should load");
|
||||||
|
assert_eq!(keys.len(), 1);
|
||||||
|
assert_eq!(keys[0].auth_type, "oauth");
|
||||||
|
assert_eq!(
|
||||||
|
decrypt_python_fernet_ciphertext(DEVELOPMENT_ENCRYPTION_KEY, &keys[0].encrypted_api_key)
|
||||||
|
.expect("oauth access token should decrypt"),
|
||||||
|
"oauth-access-token-1"
|
||||||
|
);
|
||||||
|
let auth_config = decrypt_python_fernet_ciphertext(
|
||||||
|
DEVELOPMENT_ENCRYPTION_KEY,
|
||||||
|
keys[0]
|
||||||
|
.encrypted_auth_config
|
||||||
|
.as_deref()
|
||||||
|
.expect("oauth auth config should exist"),
|
||||||
|
)
|
||||||
|
.expect("oauth auth config should decrypt");
|
||||||
|
let auth_config: Value =
|
||||||
|
serde_json::from_str(&auth_config).expect("oauth auth config json should parse");
|
||||||
|
assert_eq!(auth_config["provider_type"], "codex");
|
||||||
|
assert_eq!(auth_config["refresh_token"], "oauth-refresh-token-1");
|
||||||
|
assert_eq!(auth_config["email"], "alice@example.com");
|
||||||
|
|
||||||
|
gateway_handle.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn gateway_overwrites_oauth_provider_key_credentials_from_admin_system_import() {
|
||||||
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||||
|
Vec::new(),
|
||||||
|
Vec::new(),
|
||||||
|
Vec::new(),
|
||||||
|
));
|
||||||
|
let global_model_repository = Arc::new(InMemoryGlobalModelReadRepository::seed(Vec::<
|
||||||
|
StoredPublicGlobalModel,
|
||||||
|
>::new()));
|
||||||
|
let auth_module_repository = Arc::new(InMemoryAuthModuleReadRepository::seed(
|
||||||
|
Vec::<StoredOAuthProviderModuleConfig>::new(),
|
||||||
|
None,
|
||||||
|
));
|
||||||
|
let oauth_provider_repository = Arc::new(InMemoryOAuthProviderRepository::seed(Vec::<
|
||||||
|
StoredOAuthProviderConfig,
|
||||||
|
>::new()));
|
||||||
|
|
||||||
|
let data_state = GatewayDataState::with_provider_catalog_repository_for_tests(Arc::clone(
|
||||||
|
&provider_catalog_repository,
|
||||||
|
))
|
||||||
|
.with_global_model_repository_for_tests(Arc::clone(&global_model_repository))
|
||||||
|
.attach_auth_module_repository_for_tests(Arc::clone(&auth_module_repository))
|
||||||
|
.attach_oauth_provider_repository_for_tests(Arc::clone(&oauth_provider_repository))
|
||||||
|
.with_system_config_values_for_tests(Vec::<(String, Value)>::new())
|
||||||
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY);
|
||||||
|
|
||||||
|
let gateway = build_router_with_state(
|
||||||
|
AppState::new()
|
||||||
|
.expect("gateway should build")
|
||||||
|
.with_data_state_for_tests(data_state),
|
||||||
|
);
|
||||||
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
for (access_token, refresh_token) in [
|
||||||
|
("oauth-access-token-old", "oauth-refresh-token-old"),
|
||||||
|
("oauth-access-token-new", "oauth-refresh-token-new"),
|
||||||
|
] {
|
||||||
|
let response = client
|
||||||
|
.post(format!("{gateway_url}/api/admin/system/config/import"))
|
||||||
|
.header(GATEWAY_HEADER, "rust-phase3b")
|
||||||
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
||||||
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
||||||
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
||||||
|
.json(&sample_oauth_system_import_payload(
|
||||||
|
access_token,
|
||||||
|
refresh_token,
|
||||||
|
))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("request should succeed");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
let providers = provider_catalog_repository
|
||||||
|
.list_providers(false)
|
||||||
|
.await
|
||||||
|
.expect("providers should load");
|
||||||
|
assert_eq!(providers.len(), 1);
|
||||||
|
|
||||||
|
let keys = provider_catalog_repository
|
||||||
|
.list_keys_by_provider_ids(std::slice::from_ref(&providers[0].id))
|
||||||
|
.await
|
||||||
|
.expect("keys should load");
|
||||||
|
assert_eq!(keys.len(), 1);
|
||||||
|
assert_eq!(keys[0].name, "oauth-primary");
|
||||||
|
assert_eq!(
|
||||||
|
decrypt_python_fernet_ciphertext(DEVELOPMENT_ENCRYPTION_KEY, &keys[0].encrypted_api_key)
|
||||||
|
.expect("oauth access token should decrypt"),
|
||||||
|
"oauth-access-token-new"
|
||||||
|
);
|
||||||
|
let auth_config = decrypt_python_fernet_ciphertext(
|
||||||
|
DEVELOPMENT_ENCRYPTION_KEY,
|
||||||
|
keys[0]
|
||||||
|
.encrypted_auth_config
|
||||||
|
.as_deref()
|
||||||
|
.expect("oauth auth config should exist"),
|
||||||
|
)
|
||||||
|
.expect("oauth auth config should decrypt");
|
||||||
|
let auth_config: Value =
|
||||||
|
serde_json::from_str(&auth_config).expect("oauth auth config json should parse");
|
||||||
|
assert_eq!(auth_config["refresh_token"], "oauth-refresh-token-new");
|
||||||
|
|
||||||
|
gateway_handle.abort();
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn gateway_skips_proxy_nodes_during_admin_system_config_import() {
|
async fn gateway_skips_proxy_nodes_during_admin_system_config_import() {
|
||||||
let gateway = build_router_with_state(
|
let gateway = build_router_with_state(
|
||||||
@@ -550,3 +830,100 @@ async fn gateway_skips_proxy_nodes_during_admin_system_config_import() {
|
|||||||
|
|
||||||
gateway_handle.abort();
|
gateway_handle.abort();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn gateway_preserves_manual_proxy_configs_while_skipping_proxy_nodes_during_import() {
|
||||||
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||||
|
Vec::new(),
|
||||||
|
Vec::new(),
|
||||||
|
Vec::new(),
|
||||||
|
));
|
||||||
|
let global_model_repository = Arc::new(InMemoryGlobalModelReadRepository::seed(Vec::<
|
||||||
|
StoredPublicGlobalModel,
|
||||||
|
>::new()));
|
||||||
|
let auth_module_repository = Arc::new(InMemoryAuthModuleReadRepository::seed(
|
||||||
|
Vec::<StoredOAuthProviderModuleConfig>::new(),
|
||||||
|
None,
|
||||||
|
));
|
||||||
|
let oauth_provider_repository = Arc::new(InMemoryOAuthProviderRepository::seed(Vec::<
|
||||||
|
StoredOAuthProviderConfig,
|
||||||
|
>::new()));
|
||||||
|
|
||||||
|
let data_state = GatewayDataState::with_provider_catalog_repository_for_tests(Arc::clone(
|
||||||
|
&provider_catalog_repository,
|
||||||
|
))
|
||||||
|
.with_global_model_repository_for_tests(Arc::clone(&global_model_repository))
|
||||||
|
.attach_auth_module_repository_for_tests(Arc::clone(&auth_module_repository))
|
||||||
|
.attach_oauth_provider_repository_for_tests(Arc::clone(&oauth_provider_repository))
|
||||||
|
.with_system_config_values_for_tests(Vec::<(String, Value)>::new())
|
||||||
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY);
|
||||||
|
|
||||||
|
let gateway = build_router_with_state(
|
||||||
|
AppState::new()
|
||||||
|
.expect("gateway should build")
|
||||||
|
.with_data_state_for_tests(data_state),
|
||||||
|
);
|
||||||
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||||
|
|
||||||
|
let response = reqwest::Client::new()
|
||||||
|
.post(format!("{gateway_url}/api/admin/system/config/import"))
|
||||||
|
.header(GATEWAY_HEADER, "rust-phase3b")
|
||||||
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
||||||
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
||||||
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
||||||
|
.json(&json!({
|
||||||
|
"version": "2.2",
|
||||||
|
"merge_mode": "overwrite",
|
||||||
|
"global_models": [],
|
||||||
|
"providers": [{
|
||||||
|
"name": "manual-proxy-provider",
|
||||||
|
"provider_type": "custom",
|
||||||
|
"is_active": true,
|
||||||
|
"proxy": {
|
||||||
|
"enabled": true,
|
||||||
|
"url": "https://proxy.example"
|
||||||
|
},
|
||||||
|
"endpoints": [{
|
||||||
|
"api_format": "openai:chat",
|
||||||
|
"base_url": "https://api.example.com",
|
||||||
|
"is_active": true
|
||||||
|
}],
|
||||||
|
"api_keys": [],
|
||||||
|
"models": []
|
||||||
|
}],
|
||||||
|
"proxy_nodes": [{
|
||||||
|
"id": "legacy-node-1",
|
||||||
|
"name": "Legacy Node",
|
||||||
|
"ip": "127.0.0.1",
|
||||||
|
"port": 8080
|
||||||
|
}]
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("request should succeed");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let payload: Value = response.json().await.expect("json body should parse");
|
||||||
|
assert!(payload["stats"]["errors"]
|
||||||
|
.as_array()
|
||||||
|
.expect("errors should be an array")
|
||||||
|
.iter()
|
||||||
|
.any(|item| item
|
||||||
|
.as_str()
|
||||||
|
.is_some_and(|value| value.contains("手动 URL 代理配置会保留"))));
|
||||||
|
|
||||||
|
let providers = provider_catalog_repository
|
||||||
|
.list_providers(false)
|
||||||
|
.await
|
||||||
|
.expect("providers should load");
|
||||||
|
assert_eq!(providers.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
providers[0].proxy,
|
||||||
|
Some(json!({
|
||||||
|
"enabled": true,
|
||||||
|
"url": "https://proxy.example"
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
gateway_handle.abort();
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ http.workspace = true
|
|||||||
reqwest.workspace = true
|
reqwest.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
serde_path_to_error.workspace = true
|
||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
url.workspace = true
|
url.workspace = true
|
||||||
uuid.workspace = true
|
uuid.workspace = true
|
||||||
|
|||||||
@@ -63,6 +63,33 @@ fn invalid_request(detail: impl Into<String>) -> (http::StatusCode, serde_json::
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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, Default, PartialEq, Eq, Serialize)]
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum AdminImportMergeMode {
|
pub enum AdminImportMergeMode {
|
||||||
@@ -139,7 +166,10 @@ pub struct AdminSystemConfigImportStats {
|
|||||||
pub struct AdminSystemConfigGlobalModel {
|
pub struct AdminSystemConfigGlobalModel {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub display_name: String,
|
pub display_name: String,
|
||||||
#[serde(default)]
|
#[serde(
|
||||||
|
default,
|
||||||
|
deserialize_with = "deserialize_optional_f64_from_number_or_string"
|
||||||
|
)]
|
||||||
pub default_price_per_request: Option<f64>,
|
pub default_price_per_request: Option<f64>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub default_tiered_pricing: Option<Value>,
|
pub default_tiered_pricing: Option<Value>,
|
||||||
@@ -228,7 +258,10 @@ pub struct AdminSystemConfigProviderModel {
|
|||||||
pub provider_model_name: String,
|
pub provider_model_name: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub provider_model_mappings: Option<Value>,
|
pub provider_model_mappings: Option<Value>,
|
||||||
#[serde(default)]
|
#[serde(
|
||||||
|
default,
|
||||||
|
deserialize_with = "deserialize_optional_f64_from_number_or_string"
|
||||||
|
)]
|
||||||
pub price_per_request: Option<f64>,
|
pub price_per_request: Option<f64>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub tiered_pricing: Option<Value>,
|
pub tiered_pricing: Option<Value>,
|
||||||
@@ -259,7 +292,10 @@ pub struct AdminSystemConfigProvider {
|
|||||||
pub provider_type: Option<String>,
|
pub provider_type: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub billing_type: Option<String>,
|
pub billing_type: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(
|
||||||
|
default,
|
||||||
|
deserialize_with = "deserialize_optional_f64_from_number_or_string"
|
||||||
|
)]
|
||||||
pub monthly_quota_usd: Option<f64>,
|
pub monthly_quota_usd: Option<f64>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub quota_reset_day: Option<u64>,
|
pub quota_reset_day: Option<u64>,
|
||||||
@@ -275,9 +311,15 @@ pub struct AdminSystemConfigProvider {
|
|||||||
pub concurrent_limit: Option<i32>,
|
pub concurrent_limit: Option<i32>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub max_retries: Option<i32>,
|
pub max_retries: Option<i32>,
|
||||||
#[serde(default)]
|
#[serde(
|
||||||
|
default,
|
||||||
|
deserialize_with = "deserialize_optional_f64_from_number_or_string"
|
||||||
|
)]
|
||||||
pub stream_first_byte_timeout: Option<f64>,
|
pub stream_first_byte_timeout: Option<f64>,
|
||||||
#[serde(default)]
|
#[serde(
|
||||||
|
default,
|
||||||
|
deserialize_with = "deserialize_optional_f64_from_number_or_string"
|
||||||
|
)]
|
||||||
pub request_timeout: Option<f64>,
|
pub request_timeout: Option<f64>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub proxy: Option<Value>,
|
pub proxy: Option<Value>,
|
||||||
@@ -1129,10 +1171,19 @@ pub fn parse_admin_system_config_import_request(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let merge_mode = AdminImportMergeMode::parse_json_value(root.get("merge_mode"))?;
|
let merge_mode = AdminImportMergeMode::parse_json_value(root.get("merge_mode"))?;
|
||||||
let document = serde_json::from_value::<AdminSystemConfigDocument>(serde_json::Value::Object(
|
let document = serde_path_to_error::deserialize::<_, AdminSystemConfigDocument>(
|
||||||
root.clone(),
|
serde_json::Value::Object(root.clone()),
|
||||||
))
|
)
|
||||||
.map_err(|_| invalid_request("请求数据验证失败"))?;
|
.map_err(|err| {
|
||||||
|
let path = err.path().to_string();
|
||||||
|
let inner = err.into_inner();
|
||||||
|
let detail = if path.is_empty() {
|
||||||
|
format!("配置文件格式无效: {inner}")
|
||||||
|
} else {
|
||||||
|
format!("配置文件格式无效: {path}: {inner}")
|
||||||
|
};
|
||||||
|
invalid_request(detail)
|
||||||
|
})?;
|
||||||
|
|
||||||
Ok(ParsedAdminSystemConfigImportRequest {
|
Ok(ParsedAdminSystemConfigImportRequest {
|
||||||
request: AdminSystemConfigImportRequest {
|
request: AdminSystemConfigImportRequest {
|
||||||
@@ -1874,6 +1925,80 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_admin_system_config_import_request_reports_field_path_for_shape_errors() {
|
||||||
|
let err = parse_admin_system_config_import_request(
|
||||||
|
json!({
|
||||||
|
"version": "2.2",
|
||||||
|
"global_models": [],
|
||||||
|
"providers": [{
|
||||||
|
"name": "import-openai",
|
||||||
|
"endpoints": [{
|
||||||
|
"api_format": "openai:chat",
|
||||||
|
"base_url": "https://api.example.com",
|
||||||
|
"is_active": "yes"
|
||||||
|
}]
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
.to_string()
|
||||||
|
.as_bytes(),
|
||||||
|
)
|
||||||
|
.expect_err("invalid endpoint shape should fail");
|
||||||
|
|
||||||
|
assert_eq!(err.0, http::StatusCode::BAD_REQUEST);
|
||||||
|
let detail = err.1["detail"].as_str().expect("detail should be a string");
|
||||||
|
assert!(detail.contains("配置文件格式无效"));
|
||||||
|
assert!(detail.contains("providers[0].endpoints[0].is_active"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_admin_system_config_import_request_accepts_numeric_string_fields() {
|
||||||
|
let parsed = parse_admin_system_config_import_request(
|
||||||
|
json!({
|
||||||
|
"version": "2.2",
|
||||||
|
"global_models": [{
|
||||||
|
"name": "veo3.1",
|
||||||
|
"display_name": "Veo 3.1",
|
||||||
|
"default_price_per_request": "1.80000000",
|
||||||
|
}],
|
||||||
|
"providers": [{
|
||||||
|
"name": "undyapi",
|
||||||
|
"monthly_quota_usd": "12.50",
|
||||||
|
"stream_first_byte_timeout": "60",
|
||||||
|
"request_timeout": "120",
|
||||||
|
"models": [{
|
||||||
|
"global_model_name": "veo3.1",
|
||||||
|
"provider_model_name": "veo3.1",
|
||||||
|
"price_per_request": "0.70000000",
|
||||||
|
}]
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
.to_string()
|
||||||
|
.as_bytes(),
|
||||||
|
)
|
||||||
|
.expect("numeric string fields should parse");
|
||||||
|
|
||||||
|
let global_model = parsed
|
||||||
|
.request
|
||||||
|
.document
|
||||||
|
.global_models
|
||||||
|
.first()
|
||||||
|
.expect("global model should exist");
|
||||||
|
assert_eq!(global_model.default_price_per_request, Some(1.8));
|
||||||
|
|
||||||
|
let provider = parsed
|
||||||
|
.request
|
||||||
|
.document
|
||||||
|
.providers
|
||||||
|
.first()
|
||||||
|
.expect("provider should exist");
|
||||||
|
assert_eq!(provider.monthly_quota_usd, Some(12.5));
|
||||||
|
assert_eq!(provider.stream_first_byte_timeout, Some(60.0));
|
||||||
|
assert_eq!(provider.request_timeout, Some(120.0));
|
||||||
|
assert_eq!(provider.models.len(), 1);
|
||||||
|
assert_eq!(provider.models[0].price_per_request, Some(0.7));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolve_admin_system_export_key_api_formats_uses_endpoint_fallback() {
|
fn resolve_admin_system_export_key_api_formats_uses_endpoint_fallback() {
|
||||||
let provider_formats = vec!["openai:chat".to_string(), "claude:chat".to_string()];
|
let provider_formats = vec!["openai:chat".to_string(), "claude:chat".to_string()];
|
||||||
|
|||||||
Reference in New Issue
Block a user