mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-12 14:10:19 +08:00
9492 lines
394 KiB
Rust
9492 lines
394 KiB
Rust
use super::{
|
||
is_interactive_export_private_system_config_key, AdminAppState, SystemExportMode,
|
||
ADMIN_SYSTEM_DATA_EXPORT_VERSION, ADMIN_SYSTEM_EXPORT_CREDENTIALS_NOT_EXPORTED,
|
||
};
|
||
use crate::ai_serving::build_provider_key_pool_score_upsert;
|
||
use crate::api::ai::admin_endpoint_signature_parts;
|
||
use crate::handlers::admin::admin_provider_pool_config;
|
||
use crate::handlers::admin::model::ADMIN_EXTERNAL_MODELS_PROXY_NODE_CONFIG_KEY;
|
||
use crate::handlers::admin::provider::endpoints_admin::payloads::AdminProviderEndpointUpdatePatch;
|
||
use crate::handlers::admin::provider::oauth::provisioning::ensure_codex_credential_generation_rotated;
|
||
use crate::handlers::admin::provider::shared::payloads::{
|
||
AdminProviderCreateRequest, AdminProviderKeyCreateRequest, AdminProviderKeyUpdatePatch,
|
||
AdminProviderUpdatePatch,
|
||
};
|
||
use crate::handlers::admin::provider::write::keys::{
|
||
build_admin_update_provider_key_record_with_existing_keys,
|
||
build_provider_catalog_key_admin_cas_update,
|
||
};
|
||
use crate::handlers::admin::shared::{
|
||
normalize_json_array, normalize_json_object, normalize_string_list,
|
||
};
|
||
use crate::handlers::admin::system::shared::configs::{
|
||
apply_admin_system_config_update, is_sensitive_admin_system_config_key,
|
||
};
|
||
use crate::handlers::admin::users::{
|
||
hash_admin_user_api_key, normalize_admin_feature_settings, normalize_admin_list_policy_mode,
|
||
normalize_admin_rate_limit_policy_mode, normalize_admin_user_api_formats,
|
||
normalize_admin_user_ip_rules, normalize_admin_user_string_list,
|
||
};
|
||
use crate::handlers::public::normalize_admin_base_url;
|
||
use crate::handlers::shared::{
|
||
canonicalize_provider_ops_base_url, ldap_attribute_description_is_valid,
|
||
ldap_distinguished_name_is_valid, ldap_search_filter_is_valid,
|
||
normalize_ldap_transport_server_url, provider_ops_credential_binding_from_config,
|
||
seal_auth_api_key_secret, seal_provider_ops_credential, PROVIDER_OPS_PERSISTENT_SECRET_FIELDS,
|
||
PROVIDER_OPS_TRANSIENT_METADATA_FIELDS, PROVIDER_OPS_TRANSIENT_SECRET_FIELDS,
|
||
};
|
||
use crate::GatewayError;
|
||
use aether_admin::provider::endpoints as admin_provider_endpoints_pure;
|
||
use aether_admin::provider::models_write as admin_provider_models_write_pure;
|
||
use aether_admin::provider::redaction::{
|
||
admin_restore_secret_safe_body_rules, admin_restore_secret_safe_header_rules,
|
||
admin_restore_secret_safe_json, admin_restore_secret_safe_proxy,
|
||
};
|
||
use aether_admin::system::{
|
||
normalize_admin_system_config_key, parse_admin_system_config_array,
|
||
parse_admin_system_config_import_request, parse_admin_system_config_nested_array,
|
||
parse_admin_system_config_optional_object, parse_admin_system_config_update,
|
||
AdminImportMergeMode, AdminSystemConfigEndpoint as ImportedEndpoint,
|
||
AdminSystemConfigEntry as ImportedSystemConfig,
|
||
AdminSystemConfigGlobalModel as ImportedGlobalModel, AdminSystemConfigImportCounter,
|
||
AdminSystemConfigImportStats, AdminSystemConfigLdap as ImportedLdapConfig,
|
||
AdminSystemConfigOAuthProvider as ImportedOAuthProvider,
|
||
AdminSystemConfigProvider as ImportedProvider,
|
||
AdminSystemConfigProviderKey as ImportedProviderKey,
|
||
AdminSystemConfigProviderModel as ImportedProviderModel,
|
||
AdminSystemConfigProxyNode as ImportedProxyNode, ADMIN_SYSTEM_USERS_SUPPORTED_VERSIONS,
|
||
};
|
||
use aether_data::repository::auth_modules::{
|
||
CompareAndSwapLdapConfigResult, LdapBindPasswordUpdate, StoredLdapModuleConfig,
|
||
};
|
||
use aether_data::repository::oauth_providers::{
|
||
EncryptedSecretUpdate, UpsertOAuthProviderConfigRecord,
|
||
};
|
||
use aether_data::repository::system::{
|
||
AdminSystemStatsUserDailyAggregate, AdminSystemUsageAggregateImportMode,
|
||
AdminSystemUsageAggregateImportSummary, AdminSystemUsageAggregateSnapshot,
|
||
};
|
||
use aether_data::repository::wallet::{StoredWalletSnapshot, WalletLookupKey};
|
||
use aether_data_contracts::repository::global_models::{
|
||
CreateAdminGlobalModelRecord, UpdateAdminGlobalModelRecord, UpsertAdminProviderModelRecord,
|
||
};
|
||
use aether_data_contracts::repository::pool_scores::PoolMemberScoreUpsertMode;
|
||
use axum::{body::Bytes, http};
|
||
use serde_json::{json, Map, Value};
|
||
use sha2::{Digest, Sha256};
|
||
use std::collections::{BTreeMap, BTreeSet};
|
||
use std::time::{SystemTime, UNIX_EPOCH};
|
||
use uuid::Uuid;
|
||
|
||
const ADMIN_SYSTEM_USERS_RECOVERY_IMPORT_VERSION: (u32, u32) = (1, 5);
|
||
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
enum SystemImportMode {
|
||
InteractiveUpload,
|
||
RecoveryBackup,
|
||
/// Internal aggregate rollback. Credentials remain redacted, but operational active flags
|
||
/// from the checkpoint are restored exactly like a recovery backup.
|
||
RollbackCheckpoint,
|
||
/// Internal aggregate rollback for a recovery backup. Unlike the interactive checkpoint,
|
||
/// this mode carries the encrypted/decrypted credential fields needed to restore values that
|
||
/// the failed recovery import may already have overwritten.
|
||
RecoveryRollbackCheckpoint,
|
||
}
|
||
|
||
impl SystemImportMode {
|
||
fn restores_credentials(self) -> bool {
|
||
matches!(
|
||
self,
|
||
Self::RecoveryBackup | Self::RecoveryRollbackCheckpoint
|
||
)
|
||
}
|
||
|
||
fn preserves_active_state(self) -> bool {
|
||
matches!(
|
||
self,
|
||
Self::RecoveryBackup | Self::RollbackCheckpoint | Self::RecoveryRollbackCheckpoint
|
||
)
|
||
}
|
||
|
||
fn is_rollback_checkpoint(self) -> bool {
|
||
matches!(
|
||
self,
|
||
Self::RollbackCheckpoint | Self::RecoveryRollbackCheckpoint
|
||
)
|
||
}
|
||
|
||
fn allows_audit_admin_restore(self) -> bool {
|
||
matches!(
|
||
self,
|
||
Self::RecoveryBackup | Self::RecoveryRollbackCheckpoint
|
||
)
|
||
}
|
||
}
|
||
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
struct ImportedApiKeyMaterial {
|
||
key_hash: String,
|
||
key_plaintext: Option<String>,
|
||
}
|
||
|
||
#[derive(Debug, Clone)]
|
||
struct ExistingWalletMutation {
|
||
before: StoredWalletSnapshot,
|
||
// `None` means the import observed an existing wallet but did not receive a
|
||
// verifiable post-write snapshot. Rollback must report that as a failure
|
||
// rather than guessing or applying an owner-blind overwrite.
|
||
after: Option<StoredWalletSnapshot>,
|
||
}
|
||
|
||
#[derive(Debug, Clone)]
|
||
struct ExistingUserMutation {
|
||
before_auth: aether_data::repository::users::StoredUserAuthRecord,
|
||
after_auth: aether_data::repository::users::StoredUserAuthRecord,
|
||
before_export: Option<aether_data::repository::users::StoredUserExportRow>,
|
||
after_export: Option<aether_data::repository::users::StoredUserExportRow>,
|
||
before_model_capability_settings: Option<Value>,
|
||
after_model_capability_settings: Option<Value>,
|
||
before_feature_settings: Option<Value>,
|
||
after_feature_settings: Option<Value>,
|
||
before_group_ids: Vec<String>,
|
||
after_group_ids: Vec<String>,
|
||
}
|
||
|
||
#[derive(Debug, Clone)]
|
||
struct ExistingUserGroupMutation {
|
||
before: aether_data::repository::users::StoredUserGroup,
|
||
after: aether_data::repository::users::StoredUserGroup,
|
||
}
|
||
|
||
#[derive(Debug, Clone)]
|
||
struct ExistingApiKeyMutation {
|
||
before: aether_data::repository::auth::StoredAuthApiKeyExportRecord,
|
||
after: aether_data::repository::auth::StoredAuthApiKeyExportRecord,
|
||
}
|
||
|
||
#[cfg(test)]
|
||
fn synthetic_rollback_export_row(
|
||
auth: &aether_data::repository::users::StoredUserAuthRecord,
|
||
model_capability_settings: Option<Value>,
|
||
feature_settings: Option<Value>,
|
||
) -> Result<aether_data::repository::users::StoredUserExportRow, GatewayError> {
|
||
aether_data::repository::users::StoredUserExportRow::new(
|
||
auth.id.clone(),
|
||
auth.email.clone(),
|
||
auth.email_verified,
|
||
auth.username.clone(),
|
||
auth.password_hash.clone(),
|
||
auth.role.clone(),
|
||
auth.auth_source.clone(),
|
||
auth.allowed_providers.clone().map(Value::from),
|
||
auth.allowed_api_formats.clone().map(Value::from),
|
||
auth.allowed_models.clone().map(Value::from),
|
||
None,
|
||
model_capability_settings,
|
||
auth.is_active,
|
||
)
|
||
.map(|row| row.with_feature_settings(feature_settings))
|
||
.and_then(|row| {
|
||
row.with_policy_modes(
|
||
auth.allowed_providers_mode.clone(),
|
||
auth.allowed_api_formats_mode.clone(),
|
||
auth.allowed_models_mode.clone(),
|
||
"system".to_string(),
|
||
)
|
||
})
|
||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||
}
|
||
|
||
/// Records rows created by one aggregate import invocation. A post-failure full-table diff is
|
||
/// unsafe because ordinary admin mutations may run concurrently with the aggregate operation;
|
||
/// only these IDs are eligible for compensation.
|
||
#[derive(Debug, Default)]
|
||
struct AggregateMutationJournal {
|
||
global_model_ids: BTreeSet<String>,
|
||
provider_ids: BTreeSet<String>,
|
||
provider_endpoint_ids: BTreeSet<(String, String)>,
|
||
provider_key_ids: BTreeSet<(String, String)>,
|
||
provider_model_ids: BTreeSet<(String, String)>,
|
||
oauth_provider_types: BTreeSet<String>,
|
||
system_config_keys: BTreeSet<String>,
|
||
created_ldap_config: Option<StoredLdapModuleConfig>,
|
||
user_group_ids: BTreeSet<String>,
|
||
user_ids: BTreeSet<String>,
|
||
user_wallet_snapshots: BTreeMap<(String, String), StoredWalletSnapshot>,
|
||
api_key_wallet_snapshots: BTreeMap<(String, String), StoredWalletSnapshot>,
|
||
existing_user_wallets: BTreeMap<(String, String), ExistingWalletMutation>,
|
||
existing_api_key_wallets: BTreeMap<(String, String), ExistingWalletMutation>,
|
||
existing_users: BTreeMap<String, ExistingUserMutation>,
|
||
existing_user_groups: BTreeMap<String, ExistingUserGroupMutation>,
|
||
existing_user_api_keys: BTreeMap<(String, String), ExistingApiKeyMutation>,
|
||
existing_standalone_api_keys: BTreeMap<String, ExistingApiKeyMutation>,
|
||
user_api_key_ids: BTreeSet<(String, String)>,
|
||
standalone_api_key_ids: BTreeSet<String>,
|
||
}
|
||
|
||
/// Result of compensating config rows created by one aggregate import. LDAP is tracked
|
||
/// separately because its checkpoint restore can overwrite a configuration written by another
|
||
/// admin while the import was running.
|
||
struct ConfigCleanupOutcome {
|
||
result: Result<(), GatewayError>,
|
||
skip_ldap_restore: bool,
|
||
}
|
||
|
||
fn invalid_request(detail: impl Into<String>) -> (http::StatusCode, Value) {
|
||
(
|
||
http::StatusCode::BAD_REQUEST,
|
||
json!({ "detail": detail.into() }),
|
||
)
|
||
}
|
||
|
||
fn normalize_imported_system_config_key(key: &str) -> String {
|
||
let normalized = normalize_admin_system_config_key(key);
|
||
if normalized.eq_ignore_ascii_case(ADMIN_EXTERNAL_MODELS_PROXY_NODE_CONFIG_KEY) {
|
||
ADMIN_EXTERNAL_MODELS_PROXY_NODE_CONFIG_KEY.to_string()
|
||
} else {
|
||
normalized
|
||
}
|
||
}
|
||
|
||
fn build_admin_system_data_import_part_body(
|
||
root: &Map<String, Value>,
|
||
field_name: &str,
|
||
merge_mode: AdminImportMergeMode,
|
||
) -> Result<Bytes, (http::StatusCode, Value)> {
|
||
let mut part = match root.get(field_name) {
|
||
Some(Value::Object(map)) => map.clone(),
|
||
Some(_) => return Err(invalid_request(format!("{field_name} 必须是对象"))),
|
||
None => return Err(invalid_request(format!("{field_name} 为必填字段"))),
|
||
};
|
||
|
||
let merge_mode_value = serde_json::to_value(merge_mode)
|
||
.map_err(|err| invalid_request(format!("merge_mode 序列化失败: {err}")))?;
|
||
part.insert("merge_mode".to_string(), merge_mode_value);
|
||
|
||
serde_json::to_vec(&Value::Object(part))
|
||
.map(Bytes::from)
|
||
.map_err(|err| invalid_request(format!("{field_name} 序列化失败: {err}")))
|
||
}
|
||
|
||
fn build_aggregate_rollback_body(
|
||
checkpoint: &Value,
|
||
is_config: bool,
|
||
) -> Result<Bytes, GatewayError> {
|
||
build_aggregate_rollback_body_with_options(checkpoint, is_config, false)
|
||
}
|
||
|
||
/// Build the user half of an aggregate rollback without carrying wallet data.
|
||
/// Wallet rows are compensated through the journal's owner-checked CAS path;
|
||
/// feeding the checkpoint wallet fields back through the regular importer
|
||
/// would otherwise perform an unconditional overwrite and could erase a
|
||
/// concurrent recharge or adjustment.
|
||
fn build_aggregate_users_rollback_body(checkpoint: &Value) -> Result<Bytes, GatewayError> {
|
||
let mut object = checkpoint.as_object().cloned().ok_or_else(|| {
|
||
GatewayError::Internal("aggregate rollback checkpoint must be a JSON object".to_string())
|
||
})?;
|
||
object.insert("merge_mode".to_string(), json!("overwrite"));
|
||
|
||
// Usage aggregates and denormalized counters are runtime state. Replaying them during
|
||
// compensation could erase requests completed while the failed import was running.
|
||
object.remove("usage_aggregates");
|
||
|
||
if let Some(users) = object.get_mut("users") {
|
||
let Value::Array(users) = users else {
|
||
return Err(GatewayError::Internal(
|
||
"aggregate users rollback checkpoint users must be an array".to_string(),
|
||
));
|
||
};
|
||
for (index, user) in users.iter_mut().enumerate() {
|
||
let Some(user) = user.as_object_mut() else {
|
||
return Err(GatewayError::Internal(format!(
|
||
"aggregate users rollback checkpoint users[{index}] must be an object"
|
||
)));
|
||
};
|
||
user.remove("request_count");
|
||
user.remove("total_tokens");
|
||
user.remove("wallet");
|
||
if let Some(api_keys) = user.get_mut("api_keys") {
|
||
let Value::Array(api_keys) = api_keys else {
|
||
return Err(GatewayError::Internal(format!(
|
||
"aggregate users rollback checkpoint users[{index}].api_keys must be an array"
|
||
)));
|
||
};
|
||
for (key_index, api_key) in api_keys.iter_mut().enumerate() {
|
||
let Some(api_key) = api_key.as_object_mut() else {
|
||
return Err(GatewayError::Internal(format!(
|
||
"aggregate users rollback checkpoint users[{index}].api_keys[{key_index}] must be an object"
|
||
)));
|
||
};
|
||
api_key.remove("total_requests");
|
||
api_key.remove("total_tokens");
|
||
api_key.remove("total_cost_usd");
|
||
api_key.remove("wallet");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if let Some(standalone_keys) = object.get_mut("standalone_keys") {
|
||
let Value::Array(standalone_keys) = standalone_keys else {
|
||
return Err(GatewayError::Internal(
|
||
"aggregate users rollback checkpoint standalone_keys must be an array".to_string(),
|
||
));
|
||
};
|
||
for (index, key) in standalone_keys.iter_mut().enumerate() {
|
||
let Some(key) = key.as_object_mut() else {
|
||
return Err(GatewayError::Internal(format!(
|
||
"aggregate users rollback checkpoint standalone_keys[{index}] must be an object"
|
||
)));
|
||
};
|
||
key.remove("total_requests");
|
||
key.remove("total_tokens");
|
||
key.remove("total_cost_usd");
|
||
key.remove("wallet");
|
||
}
|
||
}
|
||
|
||
serde_json::to_vec(&Value::Object(object))
|
||
.map(Bytes::from)
|
||
.map_err(|err| {
|
||
GatewayError::Internal(format!(
|
||
"serialize aggregate users rollback checkpoint: {err}"
|
||
))
|
||
})
|
||
}
|
||
|
||
fn build_aggregate_rollback_body_with_options(
|
||
checkpoint: &Value,
|
||
is_config: bool,
|
||
skip_ldap_config: bool,
|
||
) -> Result<Bytes, GatewayError> {
|
||
let mut object = checkpoint.as_object().cloned().ok_or_else(|| {
|
||
GatewayError::Internal("aggregate rollback checkpoint must be a JSON object".to_string())
|
||
})?;
|
||
object.insert("merge_mode".to_string(), json!("overwrite"));
|
||
if is_config {
|
||
// Proxy nodes are deployment-local and are deliberately not restored by the admin
|
||
// config importer. Excluding them also prevents a rollback from changing local routing
|
||
// resources while it is restoring the portable catalog.
|
||
object.insert("proxy_nodes".to_string(), Value::Array(Vec::new()));
|
||
if skip_ldap_config {
|
||
// A failed owner-checked LDAP delete means another writer may have changed or
|
||
// recreated the row. Missing the field makes the config importer leave that row
|
||
// untouched while still restoring every unrelated config section.
|
||
object.remove("ldap_config");
|
||
}
|
||
}
|
||
serde_json::to_vec(&Value::Object(object))
|
||
.map(Bytes::from)
|
||
.map_err(|err| {
|
||
GatewayError::Internal(format!("serialize aggregate rollback checkpoint: {err}"))
|
||
})
|
||
}
|
||
|
||
fn aggregate_rollback_failure(
|
||
phase: &str,
|
||
original_kind: &'static str,
|
||
rollback: GatewayError,
|
||
) -> GatewayError {
|
||
let rollback_kind = gateway_error_kind(&rollback);
|
||
tracing::error!(
|
||
phase,
|
||
original_kind,
|
||
rollback_kind,
|
||
"aggregate system import failed and compensation failed"
|
||
);
|
||
GatewayError::Internal(format!(
|
||
"aggregate system import compensation failed in {phase}"
|
||
))
|
||
}
|
||
|
||
fn gateway_error_kind(error: &GatewayError) -> &'static str {
|
||
match error {
|
||
GatewayError::UpstreamUnavailable { .. } => "upstream_unavailable",
|
||
GatewayError::ControlUnavailable { .. } => "control_unavailable",
|
||
GatewayError::LocalExecutionPlanningTimeout { .. } => "planning_timeout",
|
||
GatewayError::AdmissionTimeout { .. } => "admission_timeout",
|
||
GatewayError::Client { .. } => "client",
|
||
GatewayError::PlanUsageLimited(_) => "plan_usage_limited",
|
||
GatewayError::LastActiveAdminUpdateDenied => "last_admin_update_denied",
|
||
GatewayError::LastActiveAdminDeleteDenied => "last_admin_delete_denied",
|
||
GatewayError::Internal(_) => "internal",
|
||
}
|
||
}
|
||
|
||
fn aggregate_rollback_error(
|
||
phase: &str,
|
||
original: GatewayError,
|
||
rollback: GatewayError,
|
||
) -> GatewayError {
|
||
aggregate_rollback_failure(phase, gateway_error_kind(&original), rollback)
|
||
}
|
||
|
||
fn aggregate_rollback_http_error(
|
||
phase: &str,
|
||
original: &(http::StatusCode, Value),
|
||
rollback: GatewayError,
|
||
) -> GatewayError {
|
||
let original_kind = if original.0.is_client_error() {
|
||
"http_client_error"
|
||
} else {
|
||
"http_server_error"
|
||
};
|
||
aggregate_rollback_failure(phase, original_kind, rollback)
|
||
}
|
||
|
||
fn combine_rollback_results(
|
||
first: Result<(), GatewayError>,
|
||
second: Result<(), GatewayError>,
|
||
phase: &str,
|
||
) -> Result<(), GatewayError> {
|
||
match (first, second) {
|
||
(Ok(()), Ok(())) => Ok(()),
|
||
(Err(error), Ok(())) | (Ok(()), Err(error)) => Err(error),
|
||
(Err(_), Err(_)) => Err(GatewayError::Internal(format!(
|
||
"multiple aggregate rollback operations failed in {phase}"
|
||
))),
|
||
}
|
||
}
|
||
|
||
fn trim_required(value: &str, field_name: &str) -> Result<String, String> {
|
||
let trimmed = value.trim();
|
||
if trimmed.is_empty() {
|
||
return Err(format!("{field_name} 不能为空"));
|
||
}
|
||
Ok(trimmed.to_string())
|
||
}
|
||
|
||
fn normalize_optional_price(value: Option<f64>, field_name: &str) -> Result<Option<f64>, String> {
|
||
let value = admin_provider_models_write_pure::normalize_optional_price(value, field_name)?;
|
||
if let Some(value) = value {
|
||
validate_imported_decimal_storage(value, field_name)?;
|
||
}
|
||
Ok(value)
|
||
}
|
||
|
||
fn normalize_supported_capabilities(value: Option<Vec<String>>) -> Option<Value> {
|
||
normalize_string_list(value).map(|items| json!(items))
|
||
}
|
||
|
||
fn normalize_import_auth_config(value: Option<Value>) -> Result<Option<Value>, String> {
|
||
let Some(value) = value else {
|
||
return Ok(None);
|
||
};
|
||
match value {
|
||
Value::Null => Ok(None),
|
||
Value::String(raw) => {
|
||
let trimmed = raw.trim();
|
||
if trimmed.is_empty() {
|
||
return Ok(None);
|
||
}
|
||
let parsed = serde_json::from_str::<Value>(trimmed)
|
||
.map_err(|_| "auth_config 必须是 JSON 对象或 JSON 字符串".to_string())?;
|
||
normalize_json_object(Some(parsed), "auth_config")
|
||
}
|
||
other => normalize_json_object(Some(other), "auth_config"),
|
||
}
|
||
}
|
||
|
||
fn is_imported_redacted_secret(value: &str) -> bool {
|
||
matches!(value.trim(), "***" | "********")
|
||
}
|
||
|
||
fn imported_value_contains_redacted_secret(value: &Value) -> bool {
|
||
match value {
|
||
Value::String(value) => is_imported_redacted_secret(value),
|
||
Value::Array(items) => items.iter().any(imported_value_contains_redacted_secret),
|
||
Value::Object(object) => object.values().any(imported_value_contains_redacted_secret),
|
||
_ => false,
|
||
}
|
||
}
|
||
|
||
fn imported_config_credentials_not_exported(root: &Map<String, Value>) -> Result<bool, String> {
|
||
let Some(value) = root.get("credential_state") else {
|
||
return Ok(false);
|
||
};
|
||
match value {
|
||
Value::String(value) if value.trim() == ADMIN_SYSTEM_EXPORT_CREDENTIALS_NOT_EXPORTED => {
|
||
Ok(true)
|
||
}
|
||
_ => Err("配置导出 credential_state 无效".to_string()),
|
||
}
|
||
}
|
||
|
||
fn contains_rule_redaction_marker(value: &Value) -> bool {
|
||
match value {
|
||
Value::Array(items) => items.iter().any(contains_rule_redaction_marker),
|
||
Value::Object(object) => {
|
||
object.iter().any(|(key, value)| {
|
||
matches!(
|
||
key.as_str(),
|
||
"has_value" | "has_pattern" | "has_replacement"
|
||
) && value.as_bool() == Some(true)
|
||
}) || object.values().any(contains_rule_redaction_marker)
|
||
}
|
||
_ => false,
|
||
}
|
||
}
|
||
|
||
fn strip_imported_redaction_placeholders(value: Value) -> Option<Value> {
|
||
match value {
|
||
Value::String(value) if is_imported_redacted_secret(&value) => None,
|
||
Value::Array(items) => Some(Value::Array(
|
||
items
|
||
.into_iter()
|
||
.filter(|item| !contains_rule_redaction_marker(item))
|
||
.filter_map(strip_imported_redaction_placeholders)
|
||
.collect(),
|
||
)),
|
||
Value::Object(object) => Some(Value::Object(
|
||
object
|
||
.into_iter()
|
||
.filter(|(key, _)| {
|
||
!matches!(
|
||
key.as_str(),
|
||
"has_credentials" | "has_value" | "has_pattern" | "has_replacement"
|
||
)
|
||
})
|
||
.filter_map(|(key, value)| {
|
||
strip_imported_redaction_placeholders(value).map(|value| (key, value))
|
||
})
|
||
.collect(),
|
||
)),
|
||
value => Some(value),
|
||
}
|
||
}
|
||
|
||
fn prepare_imported_secret_safe_json(
|
||
existing: Option<&Value>,
|
||
incoming: Option<Value>,
|
||
credentials_not_exported: bool,
|
||
) -> Option<Value> {
|
||
let incoming = incoming?;
|
||
if !credentials_not_exported {
|
||
return Some(incoming);
|
||
}
|
||
if let Some(existing) = existing {
|
||
return strip_imported_redaction_placeholders(admin_restore_secret_safe_json(
|
||
Some(existing),
|
||
&incoming,
|
||
));
|
||
}
|
||
strip_imported_redaction_placeholders(incoming)
|
||
}
|
||
|
||
fn prepare_imported_secret_safe_rules(
|
||
existing: Option<&Value>,
|
||
incoming: Option<Value>,
|
||
credentials_not_exported: bool,
|
||
restore: fn(Option<&Value>, &Value) -> Value,
|
||
) -> Option<Value> {
|
||
let incoming = incoming?;
|
||
if !credentials_not_exported {
|
||
return Some(incoming);
|
||
}
|
||
|
||
let restored = existing
|
||
.map(|existing| restore(Some(existing), &incoming))
|
||
.unwrap_or_else(|| incoming.clone());
|
||
let (Some(incoming_rules), Some(restored_rules)) = (incoming.as_array(), restored.as_array())
|
||
else {
|
||
return strip_imported_redaction_placeholders(restored);
|
||
};
|
||
|
||
Some(Value::Array(
|
||
incoming_rules
|
||
.iter()
|
||
.zip(restored_rules)
|
||
.filter_map(|(incoming_rule, restored_rule)| {
|
||
let contains_placeholder = contains_rule_redaction_marker(incoming_rule)
|
||
|| imported_value_contains_redacted_secret(incoming_rule);
|
||
if contains_placeholder
|
||
&& (existing.is_none()
|
||
|| imported_value_contains_redacted_secret(restored_rule))
|
||
{
|
||
return None;
|
||
}
|
||
strip_imported_redaction_placeholders(restored_rule.clone())
|
||
})
|
||
.collect(),
|
||
))
|
||
}
|
||
|
||
fn prepare_imported_secret_safe_header_rules(
|
||
existing: Option<&Value>,
|
||
incoming: Option<Value>,
|
||
credentials_not_exported: bool,
|
||
) -> Option<Value> {
|
||
prepare_imported_secret_safe_rules(
|
||
existing,
|
||
incoming,
|
||
credentials_not_exported,
|
||
admin_restore_secret_safe_header_rules,
|
||
)
|
||
}
|
||
|
||
fn prepare_imported_secret_safe_body_rules(
|
||
existing: Option<&Value>,
|
||
incoming: Option<Value>,
|
||
credentials_not_exported: bool,
|
||
) -> Option<Value> {
|
||
prepare_imported_secret_safe_rules(
|
||
existing,
|
||
incoming,
|
||
credentials_not_exported,
|
||
admin_restore_secret_safe_body_rules,
|
||
)
|
||
}
|
||
|
||
fn prepare_imported_secret_safe_proxy(
|
||
existing: Option<&Value>,
|
||
incoming: Option<Value>,
|
||
credentials_not_exported: bool,
|
||
node_id_map: &BTreeMap<String, String>,
|
||
) -> Option<Value> {
|
||
let incoming = remap_import_proxy(incoming, node_id_map)?;
|
||
let incoming = if credentials_not_exported {
|
||
if existing.is_some() {
|
||
admin_restore_secret_safe_proxy(existing, &incoming)
|
||
} else {
|
||
strip_imported_redaction_placeholders(incoming)?
|
||
}
|
||
} else {
|
||
incoming
|
||
};
|
||
Some(incoming)
|
||
}
|
||
|
||
fn prepare_imported_provider_config(
|
||
state: &AdminAppState<'_>,
|
||
provider_id: &str,
|
||
fallback_base_url: Option<&str>,
|
||
existing: Option<&Value>,
|
||
incoming: Option<Value>,
|
||
credentials_not_exported: bool,
|
||
) -> Result<Option<Value>, String> {
|
||
if credentials_not_exported {
|
||
return normalize_json_object(
|
||
prepare_imported_secret_safe_json(existing, incoming, true),
|
||
"config",
|
||
);
|
||
}
|
||
encrypt_imported_provider_config(state, provider_id, fallback_base_url, incoming)
|
||
}
|
||
|
||
fn imported_provider_ops_fallback_base_url(raw_provider: &Map<String, Value>) -> Option<String> {
|
||
raw_provider
|
||
.get("endpoints")
|
||
.and_then(Value::as_array)
|
||
.and_then(|endpoints| endpoints.first())
|
||
.and_then(Value::as_object)
|
||
.and_then(|endpoint| endpoint.get("base_url"))
|
||
.and_then(Value::as_str)
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.map(ToOwned::to_owned)
|
||
.or_else(|| {
|
||
raw_provider
|
||
.get("website")
|
||
.and_then(Value::as_str)
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.map(ToOwned::to_owned)
|
||
})
|
||
}
|
||
|
||
fn imported_provider_key_credentials_not_exported(item: &ImportedProviderKey) -> bool {
|
||
item.credential_state.as_deref().map(str::trim)
|
||
== Some(ADMIN_SYSTEM_EXPORT_CREDENTIALS_NOT_EXPORTED)
|
||
}
|
||
|
||
fn validate_imported_provider_key_credential_state(
|
||
item: &ImportedProviderKey,
|
||
) -> Result<bool, String> {
|
||
let credentials_not_exported = imported_provider_key_credentials_not_exported(item);
|
||
if item.credential_state.is_some() && !credentials_not_exported {
|
||
return Err("Provider Key credential_state 无效".to_string());
|
||
}
|
||
if credentials_not_exported
|
||
&& (item
|
||
.api_key
|
||
.as_deref()
|
||
.is_some_and(|value| !value.trim().is_empty())
|
||
|| item.auth_config.is_some())
|
||
{
|
||
return Err("credential_state=not_exported 的 Provider Key 不允许包含凭据字段".to_string());
|
||
}
|
||
if item
|
||
.api_key
|
||
.as_deref()
|
||
.is_some_and(is_imported_redacted_secret)
|
||
|| item
|
||
.auth_config
|
||
.as_ref()
|
||
.is_some_and(imported_value_contains_redacted_secret)
|
||
{
|
||
return Err("Provider Key 脱敏占位符不能作为凭据导入".to_string());
|
||
}
|
||
Ok(credentials_not_exported)
|
||
}
|
||
|
||
fn encrypt_imported_provider_config(
|
||
state: &AdminAppState<'_>,
|
||
provider_id: &str,
|
||
fallback_base_url: Option<&str>,
|
||
config: Option<Value>,
|
||
) -> Result<Option<Value>, String> {
|
||
let Some(mut config) = normalize_json_object(config, "config")? else {
|
||
return Ok(None);
|
||
};
|
||
let Some(provider_ops) = config
|
||
.get_mut("provider_ops")
|
||
.and_then(Value::as_object_mut)
|
||
else {
|
||
return Ok(Some(config));
|
||
};
|
||
let raw_base_url = provider_ops
|
||
.get("base_url")
|
||
.and_then(Value::as_str)
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.or(fallback_base_url)
|
||
.ok_or_else(|| "RecoveryBackup Provider Ops 缺少 base_url".to_string())?;
|
||
let destination =
|
||
canonicalize_provider_ops_base_url(raw_base_url).map_err(ToString::to_string)?;
|
||
provider_ops.insert(
|
||
"base_url".to_string(),
|
||
Value::String(destination.base_url().to_string()),
|
||
);
|
||
let binding = provider_ops_credential_binding_from_config(
|
||
provider_id,
|
||
provider_ops,
|
||
destination.base_url(),
|
||
)
|
||
.map_err(ToString::to_string)?;
|
||
let Some(credentials) = provider_ops
|
||
.get_mut("connector")
|
||
.and_then(Value::as_object_mut)
|
||
.and_then(|connector| connector.get_mut("credentials"))
|
||
.and_then(Value::as_object_mut)
|
||
else {
|
||
return Ok(Some(config));
|
||
};
|
||
|
||
for field in PROVIDER_OPS_TRANSIENT_SECRET_FIELDS
|
||
.iter()
|
||
.chain(PROVIDER_OPS_TRANSIENT_METADATA_FIELDS)
|
||
{
|
||
credentials.remove(*field);
|
||
}
|
||
for field in PROVIDER_OPS_PERSISTENT_SECRET_FIELDS {
|
||
let Some(Value::String(raw)) = credentials.get_mut(*field) else {
|
||
continue;
|
||
};
|
||
if raw.is_empty() {
|
||
continue;
|
||
}
|
||
if is_imported_redacted_secret(raw) {
|
||
return Err("Provider Ops 脱敏占位符不能作为凭据导入".to_string());
|
||
}
|
||
if raw.starts_with("aether-") {
|
||
return Err(
|
||
"RecoveryBackup Provider Ops 凭据必须是明文,不能包含密文 envelope".to_string(),
|
||
);
|
||
}
|
||
let encrypted = seal_provider_ops_credential(state.app(), &binding, field, raw)
|
||
.map_err(ToString::to_string)?;
|
||
*raw = encrypted;
|
||
}
|
||
|
||
Ok(Some(config))
|
||
}
|
||
|
||
fn remap_import_proxy(
|
||
proxy: Option<Value>,
|
||
node_id_map: &BTreeMap<String, String>,
|
||
) -> Option<Value> {
|
||
let proxy = match proxy {
|
||
Some(Value::Object(map)) if map.is_empty() => return None,
|
||
Some(Value::Object(map)) => map,
|
||
_ => return None,
|
||
};
|
||
let Some(Value::String(old_node_id)) = proxy.get("node_id") else {
|
||
return Some(Value::Object(proxy));
|
||
};
|
||
let old_node_id = old_node_id.trim();
|
||
if old_node_id.is_empty() {
|
||
return Some(Value::Object(proxy));
|
||
}
|
||
let new_node_id = node_id_map.get(old_node_id)?;
|
||
let mut remapped = proxy;
|
||
remapped.insert("node_id".to_string(), json!(new_node_id));
|
||
Some(Value::Object(remapped))
|
||
}
|
||
|
||
fn normalize_import_endpoint_format(value: &str) -> Result<String, String> {
|
||
let normalized = match value.trim().to_ascii_lowercase().as_str() {
|
||
"openai:cli" => "openai:responses",
|
||
"openai:compact" => "openai:responses:compact",
|
||
"openai_image" | "images" | "image" | "/v1/images/generations" | "/v1/images/edits" => {
|
||
"openai:image"
|
||
}
|
||
"claude:chat" | "claude:cli" => "claude:messages",
|
||
"gemini:chat" | "gemini:cli" => "gemini:generate_content",
|
||
_ => value.trim(),
|
||
};
|
||
admin_endpoint_signature_parts(normalized)
|
||
.map(|(signature, _, _)| signature.to_string())
|
||
.ok_or_else(|| format!("无效的 api_format: {value}"))
|
||
}
|
||
|
||
fn fixed_provider_import_endpoint_supported(provider_type: &str, api_format: &str) -> bool {
|
||
crate::provider_transport::provider_types::fixed_provider_template(provider_type).is_none()
|
||
|| crate::provider_transport::provider_types::fixed_provider_endpoint_template_by_api_format(
|
||
provider_type,
|
||
api_format,
|
||
)
|
||
.is_some()
|
||
}
|
||
|
||
fn normalize_import_key_formats(
|
||
item: &ImportedProviderKey,
|
||
provider_endpoint_formats: &BTreeSet<String>,
|
||
) -> (Vec<String>, Vec<String>) {
|
||
let source = item
|
||
.api_formats
|
||
.clone()
|
||
.filter(|items| !items.is_empty())
|
||
.or_else(|| {
|
||
item.supported_endpoints
|
||
.clone()
|
||
.filter(|items| !items.is_empty())
|
||
})
|
||
.unwrap_or_else(|| provider_endpoint_formats.iter().cloned().collect());
|
||
|
||
let mut normalized = Vec::new();
|
||
let mut missing = Vec::new();
|
||
let mut seen = BTreeSet::new();
|
||
for raw in source {
|
||
let trimmed = raw.trim();
|
||
if trimmed.is_empty() {
|
||
continue;
|
||
}
|
||
let Ok(api_format) = normalize_import_endpoint_format(trimmed) else {
|
||
missing.push(trimmed.to_string());
|
||
continue;
|
||
};
|
||
if !seen.insert(api_format.clone()) {
|
||
continue;
|
||
}
|
||
if !provider_endpoint_formats.is_empty() && !provider_endpoint_formats.contains(&api_format)
|
||
{
|
||
missing.push(api_format);
|
||
continue;
|
||
}
|
||
normalized.push(api_format);
|
||
}
|
||
|
||
(normalized, missing)
|
||
}
|
||
|
||
fn imported_key_auth_type(item: &ImportedProviderKey) -> String {
|
||
item.auth_type
|
||
.as_deref()
|
||
.unwrap_or("api_key")
|
||
.trim()
|
||
.to_ascii_lowercase()
|
||
}
|
||
|
||
fn imported_service_account_email(config: Option<&Value>) -> Option<String> {
|
||
match config {
|
||
Some(Value::Object(map)) => map
|
||
.get("client_email")
|
||
.and_then(Value::as_str)
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.map(ToOwned::to_owned),
|
||
Some(Value::String(raw)) => serde_json::from_str::<Value>(raw)
|
||
.ok()
|
||
.and_then(|value| imported_service_account_email(Some(&value))),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
fn imported_provider_credential_identity(
|
||
imported_key: &ImportedProviderKey,
|
||
auth_type: &str,
|
||
normalized_auth_config: Option<&Value>,
|
||
) -> Option<String> {
|
||
if matches!(auth_type, "api_key" | "bearer") {
|
||
return imported_key
|
||
.api_key
|
||
.as_deref()
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.map(|value| format!("secret:{value}"));
|
||
}
|
||
if matches!(auth_type, "service_account" | "vertex_ai") {
|
||
return imported_service_account_email(normalized_auth_config)
|
||
.map(|email| format!("service_account:{email}"));
|
||
}
|
||
None
|
||
}
|
||
|
||
fn build_import_key_match_name(item: &ImportedProviderKey) -> Option<String> {
|
||
item.name
|
||
.as_deref()
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.map(ToOwned::to_owned)
|
||
}
|
||
|
||
fn normalize_selected_import_key_format(
|
||
value: &str,
|
||
allowed_formats: &BTreeSet<String>,
|
||
) -> Option<String> {
|
||
let normalized = normalize_import_endpoint_format(value).ok()?;
|
||
allowed_formats.contains(&normalized).then_some(normalized)
|
||
}
|
||
|
||
fn normalize_import_key_format_scoped_list(
|
||
value: Option<&Value>,
|
||
normalized_api_formats: &[String],
|
||
) -> Option<Value> {
|
||
let value = value?;
|
||
let Value::Array(items) = value else {
|
||
return Some(value.clone());
|
||
};
|
||
let allowed_formats = normalized_api_formats
|
||
.iter()
|
||
.cloned()
|
||
.collect::<BTreeSet<_>>();
|
||
let mut seen = BTreeSet::new();
|
||
let mut normalized = Vec::new();
|
||
for item in items {
|
||
let Some(raw) = item
|
||
.as_str()
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
else {
|
||
continue;
|
||
};
|
||
let Some(api_format) = normalize_selected_import_key_format(raw, &allowed_formats) else {
|
||
continue;
|
||
};
|
||
if seen.insert(api_format.clone()) {
|
||
normalized.push(json!(api_format));
|
||
}
|
||
}
|
||
Some(Value::Array(normalized))
|
||
}
|
||
|
||
fn normalize_import_key_format_scoped_object(
|
||
value: Option<&Value>,
|
||
normalized_api_formats: &[String],
|
||
) -> Option<Value> {
|
||
let value = value?;
|
||
let Value::Object(map) = value else {
|
||
return Some(value.clone());
|
||
};
|
||
let allowed_formats = normalized_api_formats
|
||
.iter()
|
||
.cloned()
|
||
.collect::<BTreeSet<_>>();
|
||
let mut normalized = Map::new();
|
||
for (key, value) in map {
|
||
let Some(api_format) = normalize_selected_import_key_format(key, &allowed_formats) else {
|
||
continue;
|
||
};
|
||
normalized.insert(api_format, value.clone());
|
||
}
|
||
Some(Value::Object(normalized))
|
||
}
|
||
|
||
fn normalize_import_key_raw_payload(
|
||
raw_key: &Map<String, Value>,
|
||
auth_type: &str,
|
||
normalized_api_formats: &[String],
|
||
normalized_auth_config: Option<Value>,
|
||
credentials_not_exported: bool,
|
||
) -> Map<String, Value> {
|
||
let mut payload = raw_key.clone();
|
||
payload.remove("credential_state");
|
||
if credentials_not_exported {
|
||
payload.remove("api_key");
|
||
payload.remove("auth_config");
|
||
}
|
||
if auth_type == "oauth" {
|
||
payload.remove("api_key");
|
||
}
|
||
payload.insert("api_formats".to_string(), json!(normalized_api_formats));
|
||
if let Some(auth_type_by_format) = normalize_import_key_format_scoped_object(
|
||
raw_key.get("auth_type_by_format"),
|
||
normalized_api_formats,
|
||
) {
|
||
payload.insert("auth_type_by_format".to_string(), auth_type_by_format);
|
||
}
|
||
if let Some(allow_auth_channel_mismatch_formats) = normalize_import_key_format_scoped_list(
|
||
raw_key.get("allow_auth_channel_mismatch_formats"),
|
||
normalized_api_formats,
|
||
) {
|
||
payload.insert(
|
||
"allow_auth_channel_mismatch_formats".to_string(),
|
||
allow_auth_channel_mismatch_formats,
|
||
);
|
||
}
|
||
if !credentials_not_exported {
|
||
if let Some(auth_config) = normalized_auth_config {
|
||
payload.insert("auth_config".to_string(), auth_config);
|
||
} else if raw_key.contains_key("auth_config") {
|
||
payload.insert("auth_config".to_string(), Value::Null);
|
||
}
|
||
}
|
||
payload
|
||
}
|
||
|
||
fn apply_imported_oauth_key_credentials(
|
||
state: &AdminAppState<'_>,
|
||
provider_type: &str,
|
||
previous_codex_credential_generation: Option<&str>,
|
||
raw_key: &Map<String, Value>,
|
||
normalized_auth_config: Option<&Value>,
|
||
record: &mut aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey,
|
||
) -> Result<bool, String> {
|
||
let previous_encrypted_api_key = record.encrypted_api_key.clone();
|
||
let previous_encrypted_auth_config = record.encrypted_auth_config.clone();
|
||
let mut credentials_supplied = false;
|
||
let mut api_key_supplied = false;
|
||
if let Some(api_key_value) = raw_key.get("api_key") {
|
||
let plaintext = api_key_value
|
||
.as_str()
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty());
|
||
if plaintext.is_some_and(is_imported_redacted_secret) {
|
||
return Err("Provider Key 脱敏占位符不能作为凭据导入".to_string());
|
||
}
|
||
record.encrypted_api_key = match plaintext {
|
||
Some(plaintext) => {
|
||
credentials_supplied = true;
|
||
api_key_supplied = true;
|
||
Some(
|
||
state
|
||
.app()
|
||
.seal_provider_catalog_key_api_key(
|
||
&record.provider_id,
|
||
&record.id,
|
||
plaintext,
|
||
)
|
||
.map_err(GatewayError::into_message)?,
|
||
)
|
||
}
|
||
None => None,
|
||
};
|
||
}
|
||
|
||
if raw_key.contains_key("auth_config") {
|
||
record.encrypted_auth_config = match normalized_auth_config {
|
||
Some(auth_config) => {
|
||
credentials_supplied |= imported_oauth_auth_config_has_credentials(auth_config);
|
||
let plaintext =
|
||
serde_json::to_string(auth_config).map_err(|err| err.to_string())?;
|
||
Some(
|
||
state
|
||
.app()
|
||
.seal_provider_catalog_key_auth_config(
|
||
&record.provider_id,
|
||
&record.id,
|
||
&plaintext,
|
||
)
|
||
.map_err(GatewayError::into_message)?,
|
||
)
|
||
}
|
||
None => None,
|
||
};
|
||
}
|
||
record.expires_at_unix_secs = imported_oauth_expiry_after_import(
|
||
record.expires_at_unix_secs,
|
||
raw_key.contains_key("auth_config"),
|
||
normalized_auth_config,
|
||
api_key_supplied,
|
||
);
|
||
|
||
let credential_material_changed = record.encrypted_api_key != previous_encrypted_api_key
|
||
|| record.encrypted_auth_config != previous_encrypted_auth_config;
|
||
if credentials_supplied {
|
||
record.oauth_invalid_at_unix_secs = None;
|
||
record.oauth_invalid_reason = None;
|
||
}
|
||
if credential_material_changed {
|
||
ensure_codex_credential_generation_rotated(
|
||
record,
|
||
provider_type,
|
||
previous_codex_credential_generation,
|
||
);
|
||
}
|
||
|
||
Ok(credentials_supplied)
|
||
}
|
||
|
||
fn imported_oauth_auth_config_has_credentials(value: &Value) -> bool {
|
||
const CREDENTIAL_FIELDS: &[&str] = &[
|
||
"access_token",
|
||
"accessToken",
|
||
"api_key",
|
||
"apiKey",
|
||
"auth_token",
|
||
"authToken",
|
||
"cf_clearance",
|
||
"cfClearance",
|
||
"cf_cookies",
|
||
"cfCookies",
|
||
"cookie",
|
||
"cookieHeader",
|
||
"cookies",
|
||
"id_token",
|
||
"idToken",
|
||
"refresh_token",
|
||
"refreshToken",
|
||
"session_token",
|
||
"sessionToken",
|
||
"sso_rw_token",
|
||
"ssoRwToken",
|
||
"sso_token",
|
||
"ssoToken",
|
||
"token",
|
||
];
|
||
|
||
match value {
|
||
Value::Object(object) => object.iter().any(|(key, value)| {
|
||
(CREDENTIAL_FIELDS.contains(&key.as_str()) && imported_credential_value_present(value))
|
||
|| imported_oauth_auth_config_has_credentials(value)
|
||
}),
|
||
Value::Array(items) => items.iter().any(imported_oauth_auth_config_has_credentials),
|
||
_ => false,
|
||
}
|
||
}
|
||
|
||
fn imported_credential_value_present(value: &Value) -> bool {
|
||
match value {
|
||
Value::String(value) => !value.trim().is_empty(),
|
||
Value::Array(items) => !items.is_empty(),
|
||
Value::Object(object) => !object.is_empty(),
|
||
_ => false,
|
||
}
|
||
}
|
||
|
||
fn imported_oauth_expires_at_unix_secs(normalized_auth_config: Option<&Value>) -> Option<u64> {
|
||
let object = normalized_auth_config?.as_object()?;
|
||
for field in ["expires_at", "expiresAt", "expiry", "exp"] {
|
||
let Some(value) = object.get(field) else {
|
||
continue;
|
||
};
|
||
match value {
|
||
Value::Number(number) => {
|
||
if let Some(expires_at) = number.as_u64() {
|
||
return Some(expires_at);
|
||
}
|
||
}
|
||
Value::String(raw) => {
|
||
if let Ok(expires_at) = raw.trim().parse::<u64>() {
|
||
return Some(expires_at);
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
fn imported_oauth_expiry_after_import(
|
||
current: Option<u64>,
|
||
auth_config_present: bool,
|
||
normalized_auth_config: Option<&Value>,
|
||
api_key_supplied: bool,
|
||
) -> Option<u64> {
|
||
if auth_config_present {
|
||
imported_oauth_expires_at_unix_secs(normalized_auth_config)
|
||
} else if api_key_supplied {
|
||
None
|
||
} else {
|
||
current
|
||
}
|
||
}
|
||
|
||
async fn seed_imported_oauth_pool_score(
|
||
state: &AdminAppState<'_>,
|
||
provider_id: &str,
|
||
key: &aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey,
|
||
now_unix_secs: u64,
|
||
) -> Result<(), GatewayError> {
|
||
let provider_id = provider_id.to_string();
|
||
let provider = state
|
||
.read_provider_catalog_providers_by_ids(std::slice::from_ref(&provider_id))
|
||
.await?
|
||
.pop();
|
||
let Some(provider) = provider else {
|
||
return Ok(());
|
||
};
|
||
let Some(pool_config) = admin_provider_pool_config(&provider) else {
|
||
return Ok(());
|
||
};
|
||
if !key.is_active || key.provider_id != provider.id {
|
||
return Ok(());
|
||
}
|
||
|
||
let upsert = build_provider_key_pool_score_upsert(
|
||
key,
|
||
provider.provider_type.as_str(),
|
||
None,
|
||
now_unix_secs,
|
||
pool_config.score_rules,
|
||
);
|
||
state
|
||
.app()
|
||
.data
|
||
.upsert_pool_member_score_with_mode(upsert, PoolMemberScoreUpsertMode::OAuthRecovery)
|
||
.await
|
||
.map_err(|error| {
|
||
GatewayError::Internal(format!(
|
||
"failed to recover OAuth pool score for key '{}': {error}",
|
||
key.id
|
||
))
|
||
})?;
|
||
Ok(())
|
||
}
|
||
|
||
fn build_import_provider_model_record(
|
||
provider_id: &str,
|
||
existing_id: Option<&str>,
|
||
existing: Option<&aether_data_contracts::repository::global_models::StoredAdminProviderModel>,
|
||
global_model_id: &str,
|
||
item: &ImportedProviderModel,
|
||
credentials_not_exported: bool,
|
||
) -> Result<UpsertAdminProviderModelRecord, String> {
|
||
let provider_model_name = trim_required(&item.provider_model_name, "provider_model_name")?;
|
||
let provider_model_mappings = normalize_json_array(
|
||
prepare_imported_secret_safe_json(
|
||
existing.and_then(|model| model.provider_model_mappings.as_ref()),
|
||
item.provider_model_mappings.clone(),
|
||
credentials_not_exported,
|
||
),
|
||
"provider_model_mappings",
|
||
)?;
|
||
let price_per_request = normalize_optional_price(item.price_per_request, "price_per_request")?;
|
||
let tiered_pricing = normalize_json_object(
|
||
prepare_imported_secret_safe_json(
|
||
existing.and_then(|model| model.tiered_pricing.as_ref()),
|
||
item.tiered_pricing.clone(),
|
||
credentials_not_exported,
|
||
),
|
||
"tiered_pricing",
|
||
)?;
|
||
let config = normalize_json_object(
|
||
prepare_imported_secret_safe_json(
|
||
existing.and_then(|model| model.config.as_ref()),
|
||
item.config.clone(),
|
||
credentials_not_exported,
|
||
),
|
||
"config",
|
||
)?;
|
||
|
||
UpsertAdminProviderModelRecord::new(
|
||
existing_id
|
||
.map(ToOwned::to_owned)
|
||
.unwrap_or_else(|| Uuid::new_v4().to_string()),
|
||
provider_id.to_string(),
|
||
global_model_id.to_string(),
|
||
provider_model_name,
|
||
provider_model_mappings,
|
||
price_per_request,
|
||
tiered_pricing,
|
||
item.supports_vision,
|
||
item.supports_function_calling,
|
||
item.supports_streaming,
|
||
item.supports_extended_thinking,
|
||
item.supports_image_generation,
|
||
item.is_active,
|
||
true,
|
||
config,
|
||
)
|
||
.map_err(|err| err.to_string())
|
||
}
|
||
|
||
fn build_imported_oauth_provider_record(
|
||
oauth_provider: &ImportedOAuthProvider,
|
||
client_secret_encrypted: EncryptedSecretUpdate,
|
||
) -> Result<UpsertOAuthProviderConfigRecord, String> {
|
||
let record = UpsertOAuthProviderConfigRecord {
|
||
provider_type: trim_required(&oauth_provider.provider_type, "provider_type")?,
|
||
display_name: trim_required(&oauth_provider.display_name, "display_name")?,
|
||
client_id: trim_required(&oauth_provider.client_id, "client_id")?,
|
||
client_secret_encrypted,
|
||
authorization_url_override: oauth_provider
|
||
.authorization_url_override
|
||
.as_deref()
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.map(ToOwned::to_owned),
|
||
token_url_override: oauth_provider
|
||
.token_url_override
|
||
.as_deref()
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.map(ToOwned::to_owned),
|
||
userinfo_url_override: oauth_provider
|
||
.userinfo_url_override
|
||
.as_deref()
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.map(ToOwned::to_owned),
|
||
scopes: normalize_string_list(oauth_provider.scopes.clone()),
|
||
redirect_uri: trim_required(&oauth_provider.redirect_uri, "redirect_uri")?,
|
||
frontend_callback_url: trim_required(
|
||
&oauth_provider.frontend_callback_url,
|
||
"frontend_callback_url",
|
||
)?,
|
||
attribute_mapping: normalize_json_object(
|
||
oauth_provider.attribute_mapping.clone(),
|
||
"attribute_mapping",
|
||
)?,
|
||
extra_config: normalize_json_object(oauth_provider.extra_config.clone(), "extra_config")?,
|
||
icon_url: None,
|
||
is_enabled: oauth_provider.is_enabled,
|
||
};
|
||
record.validate().map_err(|err| err.to_string())?;
|
||
Ok(record)
|
||
}
|
||
|
||
fn is_custom_identity_oauth_provider_type(provider_type: &str) -> bool {
|
||
let provider_type = provider_type.trim().to_ascii_lowercase();
|
||
provider_type == "custom_oidc"
|
||
|| provider_type.starts_with("custom_oidc_")
|
||
|| provider_type.starts_with("custom_")
|
||
|| provider_type.starts_with("oidc_")
|
||
}
|
||
|
||
fn legacy_custom_oauth_provider_type(provider_type: &str) -> String {
|
||
let normalized = provider_type.trim().to_ascii_lowercase();
|
||
let mut suffix = String::with_capacity(normalized.len());
|
||
let mut previous_was_separator = false;
|
||
for character in normalized.chars() {
|
||
if character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-' {
|
||
suffix.push(character);
|
||
previous_was_separator = false;
|
||
} else if !previous_was_separator {
|
||
suffix.push('_');
|
||
previous_was_separator = true;
|
||
}
|
||
}
|
||
let suffix = suffix.trim_matches(['_', '-']);
|
||
let candidate = format!("custom_{suffix}");
|
||
if !suffix.is_empty() && candidate.len() <= 64 {
|
||
candidate
|
||
} else {
|
||
let digest = format!("{:x}", Sha256::digest(normalized.as_bytes()));
|
||
format!("custom_legacy_{}", &digest[..16])
|
||
}
|
||
}
|
||
|
||
fn legacy_oauth_endpoint_domains(
|
||
oauth_provider: &ImportedOAuthProvider,
|
||
) -> Result<Vec<String>, String> {
|
||
let mut domains = BTreeSet::new();
|
||
for (field, value) in [
|
||
(
|
||
"authorization_url_override",
|
||
oauth_provider.authorization_url_override.as_deref(),
|
||
),
|
||
(
|
||
"token_url_override",
|
||
oauth_provider.token_url_override.as_deref(),
|
||
),
|
||
(
|
||
"userinfo_url_override",
|
||
oauth_provider.userinfo_url_override.as_deref(),
|
||
),
|
||
] {
|
||
let value = value
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.ok_or_else(|| format!("legacy custom OAuth provider is missing {field}"))?;
|
||
let parsed = url::Url::parse(value)
|
||
.map_err(|_| format!("legacy custom OAuth provider has invalid {field}"))?;
|
||
let host = parsed
|
||
.host_str()
|
||
.map(|host| host.trim_end_matches('.').to_ascii_lowercase())
|
||
.filter(|host| !host.is_empty())
|
||
.ok_or_else(|| format!("legacy custom OAuth provider has invalid {field}"))?;
|
||
domains.insert(host);
|
||
}
|
||
Ok(domains.into_iter().collect())
|
||
}
|
||
|
||
fn normalize_legacy_imported_oauth_provider(
|
||
mut oauth_provider: ImportedOAuthProvider,
|
||
source_version: &str,
|
||
) -> Result<ImportedOAuthProvider, String> {
|
||
if !matches!(source_version.trim(), "2.0" | "2.1" | "2.2") {
|
||
return Ok(oauth_provider);
|
||
}
|
||
|
||
let original_provider_type =
|
||
trim_required(&oauth_provider.provider_type, "provider_type")?.to_ascii_lowercase();
|
||
if original_provider_type == "linuxdo" {
|
||
oauth_provider.provider_type = original_provider_type;
|
||
return Ok(oauth_provider);
|
||
}
|
||
|
||
let mut requires_review = false;
|
||
if is_custom_identity_oauth_provider_type(&original_provider_type) {
|
||
oauth_provider.provider_type = original_provider_type;
|
||
} else {
|
||
oauth_provider.provider_type = legacy_custom_oauth_provider_type(&original_provider_type);
|
||
requires_review = true;
|
||
}
|
||
|
||
let mut extra_config = match oauth_provider.extra_config.take() {
|
||
Some(Value::Object(config)) => config,
|
||
Some(_) => return Err("extra_config must be an object".to_string()),
|
||
None => Map::new(),
|
||
};
|
||
let has_allowed_domains = extra_config
|
||
.get("allowed_domains")
|
||
.or_else(|| extra_config.get("oauth_allowed_domains"))
|
||
.and_then(Value::as_array)
|
||
.is_some_and(|domains| !domains.is_empty());
|
||
if !has_allowed_domains {
|
||
extra_config.insert(
|
||
"allowed_domains".to_string(),
|
||
serde_json::to_value(legacy_oauth_endpoint_domains(&oauth_provider)?)
|
||
.map_err(|err| err.to_string())?,
|
||
);
|
||
requires_review = true;
|
||
}
|
||
oauth_provider.extra_config = Some(Value::Object(extra_config));
|
||
if requires_review {
|
||
oauth_provider.is_enabled = false;
|
||
}
|
||
Ok(oauth_provider)
|
||
}
|
||
|
||
fn find_imported_provider_key_index(
|
||
state: &AdminAppState<'_>,
|
||
imported_key: &ImportedProviderKey,
|
||
auth_type: &str,
|
||
normalized_auth_config: Option<&Value>,
|
||
existing_keys: &[aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey],
|
||
) -> Result<Option<usize>, String> {
|
||
if auth_type == "api_key" {
|
||
let target_key = imported_key
|
||
.api_key
|
||
.as_deref()
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty());
|
||
for (index, existing_key) in existing_keys.iter().enumerate() {
|
||
let decrypted_existing = state
|
||
.app()
|
||
.decrypt_provider_catalog_key_api_key(existing_key)
|
||
.map_err(GatewayError::into_message)?;
|
||
if target_key
|
||
.zip(decrypted_existing.as_deref())
|
||
.is_some_and(|(target, decrypted)| decrypted == target)
|
||
{
|
||
return Ok(Some(index));
|
||
}
|
||
}
|
||
Ok(None)
|
||
} else if matches!(auth_type, "service_account" | "vertex_ai") {
|
||
let target_email = imported_service_account_email(normalized_auth_config);
|
||
for (index, existing_key) in existing_keys.iter().enumerate() {
|
||
let existing_email = imported_existing_provider_auth_config(state, existing_key)?
|
||
.and_then(|config| {
|
||
config
|
||
.get("client_email")
|
||
.and_then(Value::as_str)
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.map(ToOwned::to_owned)
|
||
});
|
||
if target_email
|
||
.as_deref()
|
||
.zip(existing_email.as_deref())
|
||
.is_some_and(|(target, existing)| target == existing)
|
||
{
|
||
return Ok(Some(index));
|
||
}
|
||
}
|
||
Ok(None)
|
||
} else {
|
||
Ok(
|
||
build_import_key_match_name(imported_key).and_then(|target_name| {
|
||
existing_keys.iter().position(|existing_key| {
|
||
existing_key
|
||
.auth_type
|
||
.trim()
|
||
.eq_ignore_ascii_case(auth_type)
|
||
&& existing_key.name == target_name
|
||
})
|
||
}),
|
||
)
|
||
}
|
||
}
|
||
|
||
fn imported_existing_provider_auth_config(
|
||
state: &AdminAppState<'_>,
|
||
key: &aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey,
|
||
) -> Result<Option<Map<String, Value>>, String> {
|
||
let Some(plaintext) = state
|
||
.app()
|
||
.decrypt_provider_catalog_key_auth_config(key)
|
||
.map_err(GatewayError::into_message)?
|
||
else {
|
||
return Ok(None);
|
||
};
|
||
let value = serde_json::from_str::<Value>(&plaintext).map_err(|_| {
|
||
format!(
|
||
"Provider Key '{}' 已保存的 auth_config 不是有效 JSON",
|
||
key.name
|
||
)
|
||
})?;
|
||
value.as_object().cloned().map(Some).ok_or_else(|| {
|
||
format!(
|
||
"Provider Key '{}' 已保存的 auth_config 不是 JSON 对象",
|
||
key.name
|
||
)
|
||
})
|
||
}
|
||
|
||
fn prevalidate_imported_provider_key_uniqueness(
|
||
state: &AdminAppState<'_>,
|
||
imported_key: &ImportedProviderKey,
|
||
auth_type: &str,
|
||
normalized_auth_config: Option<&Value>,
|
||
existing_keys: &[aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey],
|
||
) -> Result<(), String> {
|
||
if matches!(auth_type, "api_key" | "bearer") {
|
||
let Some(target_key) = imported_key
|
||
.api_key
|
||
.as_deref()
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
else {
|
||
return Ok(());
|
||
};
|
||
let target_key = target_key.to_string();
|
||
for existing in existing_keys.iter().filter(|existing| {
|
||
matches!(
|
||
existing.auth_type.trim().to_ascii_lowercase().as_str(),
|
||
"api_key" | "bearer"
|
||
)
|
||
}) {
|
||
let Some(decrypted) = state
|
||
.app()
|
||
.decrypt_provider_catalog_key_api_key(existing)
|
||
.map_err(GatewayError::into_message)?
|
||
else {
|
||
continue;
|
||
};
|
||
if decrypted != "__placeholder__" && decrypted == target_key {
|
||
return Err(format!(
|
||
"该 API Key 已存在于当前 Provider 中(名称: {})",
|
||
existing.name
|
||
));
|
||
}
|
||
}
|
||
}
|
||
|
||
if auth_type == "service_account" {
|
||
let Some(target_email) = imported_service_account_email(normalized_auth_config) else {
|
||
return Ok(());
|
||
};
|
||
let target_email = target_email.to_string();
|
||
for existing in existing_keys.iter().filter(|existing| {
|
||
matches!(
|
||
existing.auth_type.trim().to_ascii_lowercase().as_str(),
|
||
"service_account" | "vertex_ai"
|
||
)
|
||
}) {
|
||
let Some(existing_email) = imported_existing_provider_auth_config(state, existing)?
|
||
.and_then(|config| {
|
||
config
|
||
.get("client_email")
|
||
.and_then(Value::as_str)
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.map(ToOwned::to_owned)
|
||
})
|
||
else {
|
||
continue;
|
||
};
|
||
if existing_email == target_email {
|
||
return Err(format!(
|
||
"该 Service Account ({target_email}) 已存在于当前 Provider 中(名称: {})",
|
||
existing.name
|
||
));
|
||
}
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
#[derive(Debug, Clone, Default, serde::Serialize)]
|
||
struct AdminSystemUsersImportStats {
|
||
user_groups: AdminSystemConfigImportCounter,
|
||
users: AdminSystemConfigImportCounter,
|
||
api_keys: AdminSystemConfigImportCounter,
|
||
standalone_keys: AdminSystemConfigImportCounter,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
usage_aggregates: Option<AdminSystemUsageAggregateImportSummary>,
|
||
errors: Vec<String>,
|
||
}
|
||
|
||
#[derive(Debug, Clone)]
|
||
struct ImportedWalletTarget {
|
||
recharge_balance: f64,
|
||
gift_balance: f64,
|
||
limit_mode: String,
|
||
currency: String,
|
||
status: String,
|
||
total_recharged: f64,
|
||
total_consumed: f64,
|
||
total_refunded: f64,
|
||
total_adjusted: f64,
|
||
updated_at_unix_secs: Option<u64>,
|
||
}
|
||
|
||
#[derive(Debug, Clone)]
|
||
struct SimulatedImportedUser {
|
||
id: String,
|
||
email: Option<String>,
|
||
username: String,
|
||
role: String,
|
||
existed_before_import: bool,
|
||
}
|
||
|
||
#[derive(Debug, Clone)]
|
||
struct SimulatedImportedApiKey {
|
||
owner_id: String,
|
||
is_standalone: bool,
|
||
target_id: String,
|
||
existed_before_import: bool,
|
||
}
|
||
|
||
fn replace_simulated_imported_user(
|
||
users_by_id: &mut BTreeMap<String, SimulatedImportedUser>,
|
||
email_owners: &mut BTreeMap<String, String>,
|
||
username_owners: &mut BTreeMap<String, String>,
|
||
released_emails: &mut BTreeSet<String>,
|
||
released_usernames: &mut BTreeSet<String>,
|
||
user: SimulatedImportedUser,
|
||
) {
|
||
if let Some(previous) = users_by_id.remove(&user.id) {
|
||
if previous.email != user.email {
|
||
if previous
|
||
.email
|
||
.as_ref()
|
||
.is_some_and(|email| email_owners.get(email) == Some(&previous.id))
|
||
{
|
||
let previous_email = previous.email.as_deref().unwrap();
|
||
email_owners.remove(previous_email);
|
||
released_emails.insert(previous_email.to_string());
|
||
}
|
||
}
|
||
if previous.username != user.username
|
||
&& username_owners.get(&previous.username) == Some(&previous.id)
|
||
{
|
||
username_owners.remove(&previous.username);
|
||
released_usernames.insert(previous.username);
|
||
}
|
||
}
|
||
if let Some(email) = user.email.as_ref() {
|
||
released_emails.remove(email);
|
||
email_owners.insert(email.clone(), user.id.clone());
|
||
}
|
||
released_usernames.remove(&user.username);
|
||
username_owners.insert(user.username.clone(), user.id.clone());
|
||
users_by_id.insert(user.id.clone(), user);
|
||
}
|
||
|
||
fn simulated_imported_user_id_by_identifier(
|
||
email_owners: &BTreeMap<String, String>,
|
||
username_owners: &BTreeMap<String, String>,
|
||
identifier: &str,
|
||
) -> Option<String> {
|
||
email_owners
|
||
.get(identifier)
|
||
.or_else(|| username_owners.get(identifier))
|
||
.cloned()
|
||
}
|
||
|
||
fn simulated_imported_user_from_auth_record(
|
||
user: &aether_data::repository::users::StoredUserAuthRecord,
|
||
) -> SimulatedImportedUser {
|
||
SimulatedImportedUser {
|
||
id: user.id.clone(),
|
||
email: user.email.clone(),
|
||
username: user.username.clone(),
|
||
role: user.role.clone(),
|
||
existed_before_import: true,
|
||
}
|
||
}
|
||
|
||
fn imported_system_export_version(version: Option<&Value>) -> Result<(u32, u32), String> {
|
||
let Some(Value::String(version)) = version else {
|
||
return Err("version 必须是 x.y 字符串".to_string());
|
||
};
|
||
let version = version.trim();
|
||
if version.is_empty() {
|
||
return Err("version 必须是 x.y 字符串".to_string());
|
||
}
|
||
let mut parts = version.split('.');
|
||
let Some(major) = parts.next().and_then(|value| value.parse::<u32>().ok()) else {
|
||
return Err("version 必须是 x.y 字符串".to_string());
|
||
};
|
||
let Some(minor) = parts.next().and_then(|value| value.parse::<u32>().ok()) else {
|
||
return Err("version 必须是 x.y 字符串".to_string());
|
||
};
|
||
Ok((major, minor))
|
||
}
|
||
|
||
fn validate_imported_system_users_export_version(
|
||
version: Option<&Value>,
|
||
) -> Result<(u32, u32), String> {
|
||
let Some(Value::String(raw_version)) = version else {
|
||
return Err("version 必须是 x.y 字符串".to_string());
|
||
};
|
||
let normalized = raw_version.trim();
|
||
if normalized.is_empty() {
|
||
return Err("version 必须是 x.y 字符串".to_string());
|
||
}
|
||
let parsed = imported_system_export_version(version)?;
|
||
if !ADMIN_SYSTEM_USERS_SUPPORTED_VERSIONS.contains(&normalized) {
|
||
return Err(format!(
|
||
"不支持的用户数据版本: {normalized},支持的版本: {}",
|
||
ADMIN_SYSTEM_USERS_SUPPORTED_VERSIONS.join(", ")
|
||
));
|
||
}
|
||
Ok(parsed)
|
||
}
|
||
|
||
fn validate_imported_system_users_export_version_for_mode(
|
||
version: Option<&Value>,
|
||
mode: SystemImportMode,
|
||
) -> Result<(u32, u32), String> {
|
||
let parsed = validate_imported_system_users_export_version(version)?;
|
||
if mode.restores_credentials() && parsed != ADMIN_SYSTEM_USERS_RECOVERY_IMPORT_VERSION {
|
||
return Err(format!(
|
||
"恢复备份仅支持用户数据版本 {}.{}",
|
||
ADMIN_SYSTEM_USERS_RECOVERY_IMPORT_VERSION.0,
|
||
ADMIN_SYSTEM_USERS_RECOVERY_IMPORT_VERSION.1,
|
||
));
|
||
}
|
||
Ok(parsed)
|
||
}
|
||
|
||
fn usage_aggregate_import_mode(
|
||
merge_mode: AdminImportMergeMode,
|
||
) -> AdminSystemUsageAggregateImportMode {
|
||
match merge_mode {
|
||
AdminImportMergeMode::Skip => AdminSystemUsageAggregateImportMode::Skip,
|
||
AdminImportMergeMode::Overwrite => AdminSystemUsageAggregateImportMode::Overwrite,
|
||
AdminImportMergeMode::Error => AdminSystemUsageAggregateImportMode::Error,
|
||
}
|
||
}
|
||
|
||
fn imported_object_field<'a>(
|
||
value: &'a Value,
|
||
field_name: &str,
|
||
) -> Result<&'a Map<String, Value>, String> {
|
||
value
|
||
.as_object()
|
||
.ok_or_else(|| format!("{field_name} 必须是对象"))
|
||
}
|
||
|
||
fn imported_optional_string(value: Option<&Value>) -> Result<Option<String>, String> {
|
||
match value {
|
||
None | Some(Value::Null) => Ok(None),
|
||
Some(Value::String(raw)) => {
|
||
let trimmed = raw.trim();
|
||
if trimmed.is_empty() {
|
||
Ok(None)
|
||
} else {
|
||
Ok(Some(trimmed.to_string()))
|
||
}
|
||
}
|
||
_ => Err("字段必须是字符串".to_string()),
|
||
}
|
||
}
|
||
|
||
fn normalize_imported_system_user_role(
|
||
value: Option<&Value>,
|
||
mode: SystemImportMode,
|
||
) -> Result<Option<String>, String> {
|
||
let raw_role = imported_optional_string(value)?.unwrap_or_else(|| "user".to_string());
|
||
let role = crate::roles::normalize_assignable_user_role(&raw_role)
|
||
.ok_or_else(|| format!("不支持的用户角色: {raw_role}"))?;
|
||
|
||
if crate::roles::is_full_admin_role(role)
|
||
|| (crate::roles::is_audit_admin_role(role) && !mode.allows_audit_admin_restore())
|
||
{
|
||
return Ok(None);
|
||
}
|
||
|
||
Ok(Some(role.to_string()))
|
||
}
|
||
|
||
fn imported_existing_user_is_protected(role: &str, mode: SystemImportMode) -> bool {
|
||
crate::roles::is_full_admin_role(role)
|
||
|| (crate::roles::is_audit_admin_role(role) && !mode.allows_audit_admin_restore())
|
||
}
|
||
|
||
fn validate_rollback_user_source_id(
|
||
mode: SystemImportMode,
|
||
source_user_id: Option<&str>,
|
||
) -> Result<(), String> {
|
||
if mode.is_rollback_checkpoint() && source_user_id.is_none() {
|
||
return Err(
|
||
"回滚检查点中的用户必须包含稳定的 users[].id;拒绝按 email/username 猜测用户"
|
||
.to_string(),
|
||
);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
const IMPORTED_CREDENTIAL_TOMBSTONE_PREFIX: &str = "$aether-import-revoked$";
|
||
|
||
fn imported_credential_tombstone(identity: &str) -> String {
|
||
let digest = format!("{:x}", Sha256::digest(identity.as_bytes()));
|
||
let digest_length = 64usize.saturating_sub(IMPORTED_CREDENTIAL_TOMBSTONE_PREFIX.len());
|
||
format!(
|
||
"{IMPORTED_CREDENTIAL_TOMBSTONE_PREFIX}{}",
|
||
&digest[..digest_length]
|
||
)
|
||
}
|
||
|
||
fn imported_api_key_tombstone(api_key_id: &str) -> String {
|
||
imported_credential_tombstone(&format!("api-key-id:{api_key_id}"))
|
||
}
|
||
|
||
fn imported_api_key_id_for_mode(source_api_key_id: Option<&str>, mode: SystemImportMode) -> String {
|
||
if mode.is_rollback_checkpoint() {
|
||
if let Some(source_api_key_id) = source_api_key_id {
|
||
return source_api_key_id.to_string();
|
||
}
|
||
}
|
||
Uuid::new_v4().to_string()
|
||
}
|
||
|
||
fn imported_password_tombstone() -> String {
|
||
imported_credential_tombstone(&format!("password:{}", Uuid::new_v4()))
|
||
}
|
||
|
||
fn resolve_imported_password_hash(
|
||
user: &Map<String, Value>,
|
||
users_export_version: (u32, u32),
|
||
mode: SystemImportMode,
|
||
) -> Result<Option<String>, String> {
|
||
if users_export_version >= (1, 6) && user.contains_key("password_hash") {
|
||
return Err("用户数据 1.6+ 不允许包含 password_hash 凭据字段".to_string());
|
||
}
|
||
let password_hash = imported_optional_string(user.get("password_hash"))?;
|
||
if !mode.restores_credentials() {
|
||
return Ok(password_hash.map(|_| imported_password_tombstone()));
|
||
}
|
||
if password_hash
|
||
.as_deref()
|
||
.is_some_and(|value| !aether_data::repository::users::is_valid_bcrypt_hash(value))
|
||
{
|
||
return Err("恢复备份中的 password_hash 不是有效的 bcrypt 哈希".to_string());
|
||
}
|
||
Ok(password_hash)
|
||
}
|
||
|
||
fn imported_optional_bool(value: Option<&Value>) -> Result<Option<bool>, String> {
|
||
match value {
|
||
None | Some(Value::Null) => Ok(None),
|
||
Some(Value::Bool(value)) => Ok(Some(*value)),
|
||
_ => Err("字段必须是布尔值".to_string()),
|
||
}
|
||
}
|
||
|
||
fn imported_optional_i32(value: Option<&Value>, field_name: &str) -> Result<Option<i32>, String> {
|
||
match value {
|
||
None | Some(Value::Null) => Ok(None),
|
||
Some(Value::Number(number)) => number
|
||
.as_i64()
|
||
.ok_or_else(|| format!("{field_name} 必须是整数"))
|
||
.and_then(|value| i32::try_from(value).map_err(|_| format!("{field_name} 超出范围")))
|
||
.map(Some),
|
||
_ => Err(format!("{field_name} 必须是整数")),
|
||
}
|
||
}
|
||
|
||
fn imported_optional_u64(value: Option<&Value>, field_name: &str) -> Result<Option<u64>, String> {
|
||
match value {
|
||
None | Some(Value::Null) => Ok(None),
|
||
Some(Value::Number(number)) => number
|
||
.as_u64()
|
||
.ok_or_else(|| format!("{field_name} 必须是非负整数"))
|
||
.map(Some),
|
||
_ => Err(format!("{field_name} 必须是非负整数")),
|
||
}
|
||
}
|
||
|
||
fn validate_imported_u64_storage(value: u64, field_name: &str) -> Result<(), String> {
|
||
i64::try_from(value)
|
||
.map(|_| ())
|
||
.map_err(|_| format!("{field_name} 超出数据库整数范围"))
|
||
}
|
||
|
||
fn validate_imported_request_count(value: u64, field_name: &str) -> Result<(), String> {
|
||
i32::try_from(value)
|
||
.map(|_| ())
|
||
.map_err(|_| format!("{field_name} 超出数据库请求计数范围"))
|
||
}
|
||
|
||
fn validate_imported_timestamp(value: u64, field_name: &str) -> Result<(), String> {
|
||
validate_imported_u64_storage(value, field_name)?;
|
||
chrono::DateTime::<chrono::Utc>::from_timestamp(value as i64, 0)
|
||
.map(|_| ())
|
||
.ok_or_else(|| format!("{field_name} 超出数据库时间范围"))
|
||
}
|
||
|
||
fn validate_imported_decimal_storage(value: f64, field_name: &str) -> Result<(), String> {
|
||
if !value.is_finite() {
|
||
return Err(format!("{field_name} 必须是有限数值"));
|
||
}
|
||
// PostgreSQL persists imported monetary values as NUMERIC(20,8).
|
||
if value.abs() >= 1_000_000_000_000.0 {
|
||
return Err(format!("{field_name} 超出数据库金额范围"));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn imported_optional_f64(value: Option<&Value>, field_name: &str) -> Result<Option<f64>, String> {
|
||
let parsed = match value {
|
||
None | Some(Value::Null) => Ok(None),
|
||
Some(Value::Number(number)) => number
|
||
.as_f64()
|
||
.filter(|value| value.is_finite())
|
||
.ok_or_else(|| format!("{field_name} 必须是有限数值"))
|
||
.map(Some),
|
||
Some(Value::String(value)) => value
|
||
.trim()
|
||
.parse::<f64>()
|
||
.ok()
|
||
.filter(|value| value.is_finite())
|
||
.ok_or_else(|| format!("{field_name} 必须是有限数值"))
|
||
.map(Some),
|
||
_ => Err(format!("{field_name} 必须是有限数值")),
|
||
}?;
|
||
if let Some(value) = parsed {
|
||
validate_imported_decimal_storage(value, field_name)?;
|
||
}
|
||
Ok(parsed)
|
||
}
|
||
|
||
fn imported_optional_json_object(
|
||
value: Option<&Value>,
|
||
field_name: &str,
|
||
) -> Result<Option<Value>, String> {
|
||
match value {
|
||
None | Some(Value::Null) => Ok(None),
|
||
Some(Value::Object(map)) => Ok(Some(Value::Object(map.clone()))),
|
||
_ => Err(format!("{field_name} 必须是对象")),
|
||
}
|
||
}
|
||
|
||
fn imported_optional_value(value: Option<&Value>) -> Option<Value> {
|
||
value.cloned().filter(|value| !value.is_null())
|
||
}
|
||
|
||
fn imported_optional_list_policy_mode(
|
||
value: Option<&Value>,
|
||
field_name: &str,
|
||
) -> Result<Option<String>, String> {
|
||
let Some(value) = imported_optional_string(value)? else {
|
||
return Ok(None);
|
||
};
|
||
let value = value.to_ascii_lowercase();
|
||
normalize_admin_list_policy_mode(&value)
|
||
.map(Some)
|
||
.map_err(|_| format!("{field_name} 不合法"))
|
||
}
|
||
|
||
fn imported_optional_rate_limit_policy_mode(
|
||
value: Option<&Value>,
|
||
field_name: &str,
|
||
) -> Result<Option<String>, String> {
|
||
let Some(value) = imported_optional_string(value)? else {
|
||
return Ok(None);
|
||
};
|
||
let value = value.to_ascii_lowercase();
|
||
normalize_admin_rate_limit_policy_mode(&value)
|
||
.map(Some)
|
||
.map_err(|_| format!("{field_name} 不合法"))
|
||
}
|
||
|
||
fn legacy_imported_list_policy_mode(values: &Option<Vec<String>>) -> String {
|
||
if values.as_ref().is_some_and(|items| !items.is_empty()) {
|
||
"specific".to_string()
|
||
} else {
|
||
"unrestricted".to_string()
|
||
}
|
||
}
|
||
|
||
fn legacy_imported_rate_limit_policy_mode(value: Option<i32>) -> String {
|
||
if value.is_some() {
|
||
"custom".to_string()
|
||
} else {
|
||
"system".to_string()
|
||
}
|
||
}
|
||
|
||
fn imported_user_list_policy_mode(
|
||
object: &Map<String, Value>,
|
||
mode_field: &str,
|
||
value_field: &str,
|
||
values: &Option<Vec<String>>,
|
||
) -> Result<Option<String>, String> {
|
||
imported_optional_list_policy_mode(object.get(mode_field), mode_field).map(|mode| {
|
||
mode.or_else(|| {
|
||
object
|
||
.contains_key(value_field)
|
||
.then(|| legacy_imported_list_policy_mode(values))
|
||
})
|
||
})
|
||
}
|
||
|
||
fn imported_user_rate_limit_policy_mode(
|
||
object: &Map<String, Value>,
|
||
mode_field: &str,
|
||
value_field: &str,
|
||
value: Option<i32>,
|
||
) -> Result<Option<String>, String> {
|
||
imported_optional_rate_limit_policy_mode(object.get(mode_field), mode_field).map(|mode| {
|
||
mode.or_else(|| {
|
||
object
|
||
.contains_key(value_field)
|
||
.then(|| legacy_imported_rate_limit_policy_mode(value))
|
||
})
|
||
})
|
||
}
|
||
|
||
fn build_imported_user_usage_total_aggregates(
|
||
users: &[Value],
|
||
exported_at: Option<&Value>,
|
||
) -> Result<Vec<AdminSystemStatsUserDailyAggregate>, String> {
|
||
let date_unix_secs = imported_export_day_unix_secs(exported_at);
|
||
let mut rows = Vec::new();
|
||
for (index, raw_user) in users.iter().enumerate() {
|
||
let user = imported_object_field(raw_user, &format!("users[{index}]"))?;
|
||
let Some(user_id) = imported_optional_string(user.get("id"))? else {
|
||
continue;
|
||
};
|
||
let request_count = imported_optional_u64(user.get("request_count"), "request_count")?;
|
||
let total_tokens = imported_optional_u64(user.get("total_tokens"), "total_tokens")?;
|
||
if request_count.is_none() && total_tokens.is_none() {
|
||
continue;
|
||
}
|
||
let total_requests = request_count.unwrap_or(0);
|
||
let input_tokens = total_tokens.unwrap_or(0);
|
||
if total_requests == 0 && input_tokens == 0 {
|
||
continue;
|
||
}
|
||
rows.push(AdminSystemStatsUserDailyAggregate {
|
||
user_id,
|
||
username: imported_optional_string(user.get("username"))?,
|
||
date_unix_secs,
|
||
total_requests,
|
||
success_requests: total_requests,
|
||
error_requests: 0,
|
||
input_tokens,
|
||
output_tokens: 0,
|
||
cache_creation_tokens: 0,
|
||
cache_read_tokens: 0,
|
||
total_cost: 0.0,
|
||
});
|
||
}
|
||
Ok(rows)
|
||
}
|
||
|
||
fn build_imported_usage_aggregate_snapshot(
|
||
value: Option<&Value>,
|
||
supplemental_user_daily: &[AdminSystemStatsUserDailyAggregate],
|
||
) -> Result<AdminSystemUsageAggregateSnapshot, String> {
|
||
let mut snapshot = match value {
|
||
Some(value) if !value.is_null() => {
|
||
serde_json::from_value::<AdminSystemUsageAggregateSnapshot>(value.clone())
|
||
.map_err(|err| format!("usage_aggregates 格式无效: {err}"))?
|
||
}
|
||
_ => AdminSystemUsageAggregateSnapshot::default(),
|
||
};
|
||
let mut existing_user_totals = BTreeMap::<String, (u64, u64)>::new();
|
||
for row in &snapshot.stats_user_daily {
|
||
let total_tokens = row
|
||
.input_tokens
|
||
.saturating_add(row.output_tokens)
|
||
.saturating_add(row.cache_creation_tokens)
|
||
.saturating_add(row.cache_read_tokens);
|
||
let entry = existing_user_totals
|
||
.entry(row.user_id.clone())
|
||
.or_insert((0, 0));
|
||
entry.0 = entry.0.saturating_add(row.total_requests);
|
||
entry.1 = entry.1.saturating_add(total_tokens);
|
||
}
|
||
for row in supplemental_user_daily {
|
||
let existing = existing_user_totals
|
||
.get(&row.user_id)
|
||
.copied()
|
||
.unwrap_or_default();
|
||
let request_delta = row.total_requests.saturating_sub(existing.0);
|
||
let token_delta = row.input_tokens.saturating_sub(existing.1);
|
||
if request_delta == 0 && token_delta == 0 {
|
||
continue;
|
||
}
|
||
if let Some(existing_row) = snapshot
|
||
.stats_user_daily
|
||
.iter_mut()
|
||
.rev()
|
||
.find(|existing_row| existing_row.user_id == row.user_id)
|
||
{
|
||
existing_row.total_requests = existing_row.total_requests.saturating_add(request_delta);
|
||
existing_row.success_requests =
|
||
existing_row.success_requests.saturating_add(request_delta);
|
||
existing_row.input_tokens = existing_row.input_tokens.saturating_add(token_delta);
|
||
} else {
|
||
let mut row = row.clone();
|
||
row.total_requests = request_delta;
|
||
row.success_requests = request_delta;
|
||
row.input_tokens = token_delta;
|
||
snapshot.stats_user_daily.push(row);
|
||
}
|
||
}
|
||
Ok(snapshot)
|
||
}
|
||
|
||
fn validate_imported_usage_aggregate_storage(
|
||
snapshot: &AdminSystemUsageAggregateSnapshot,
|
||
) -> Result<(), String> {
|
||
macro_rules! validate_fields {
|
||
($row:expr, $prefix:expr, [$($field:ident),+ $(,)?]) => {
|
||
$(validate_imported_u64_storage(
|
||
$row.$field,
|
||
&format!("{}.{}", $prefix, stringify!($field)),
|
||
)?;)+
|
||
};
|
||
}
|
||
|
||
for (index, row) in snapshot.stats_daily.iter().enumerate() {
|
||
let prefix = format!("usage_aggregates.stats_daily[{index}]");
|
||
validate_imported_timestamp(row.date_unix_secs, &format!("{prefix}.date_unix_secs"))?;
|
||
validate_imported_request_count(row.total_requests, &format!("{prefix}.total_requests"))?;
|
||
validate_imported_request_count(
|
||
row.success_requests,
|
||
&format!("{prefix}.success_requests"),
|
||
)?;
|
||
validate_imported_request_count(row.error_requests, &format!("{prefix}.error_requests"))?;
|
||
validate_fields!(
|
||
row,
|
||
prefix,
|
||
[
|
||
input_tokens,
|
||
output_tokens,
|
||
cache_creation_tokens,
|
||
cache_read_tokens,
|
||
]
|
||
);
|
||
if let Some(value) = row.aggregated_at_unix_secs {
|
||
validate_imported_timestamp(value, &format!("{prefix}.aggregated_at_unix_secs"))?;
|
||
}
|
||
validate_imported_decimal_storage(row.total_cost, &format!("{prefix}.total_cost"))?;
|
||
validate_imported_decimal_storage(
|
||
row.actual_total_cost,
|
||
&format!("{prefix}.actual_total_cost"),
|
||
)?;
|
||
}
|
||
for (index, row) in snapshot.stats_user_daily.iter().enumerate() {
|
||
let prefix = format!("usage_aggregates.stats_user_daily[{index}]");
|
||
validate_imported_timestamp(row.date_unix_secs, &format!("{prefix}.date_unix_secs"))?;
|
||
validate_imported_request_count(row.total_requests, &format!("{prefix}.total_requests"))?;
|
||
validate_imported_request_count(
|
||
row.success_requests,
|
||
&format!("{prefix}.success_requests"),
|
||
)?;
|
||
validate_imported_request_count(row.error_requests, &format!("{prefix}.error_requests"))?;
|
||
validate_fields!(
|
||
row,
|
||
prefix,
|
||
[
|
||
input_tokens,
|
||
output_tokens,
|
||
cache_creation_tokens,
|
||
cache_read_tokens,
|
||
]
|
||
);
|
||
validate_imported_decimal_storage(row.total_cost, &format!("{prefix}.total_cost"))?;
|
||
}
|
||
for (index, row) in snapshot.stats_daily_api_key.iter().enumerate() {
|
||
let prefix = format!("usage_aggregates.stats_daily_api_key[{index}]");
|
||
validate_imported_timestamp(row.date_unix_secs, &format!("{prefix}.date_unix_secs"))?;
|
||
validate_imported_request_count(row.total_requests, &format!("{prefix}.total_requests"))?;
|
||
validate_imported_request_count(
|
||
row.success_requests,
|
||
&format!("{prefix}.success_requests"),
|
||
)?;
|
||
validate_imported_request_count(row.error_requests, &format!("{prefix}.error_requests"))?;
|
||
validate_fields!(
|
||
row,
|
||
prefix,
|
||
[
|
||
input_tokens,
|
||
output_tokens,
|
||
cache_creation_tokens,
|
||
cache_read_tokens,
|
||
]
|
||
);
|
||
validate_imported_decimal_storage(row.total_cost, &format!("{prefix}.total_cost"))?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn validate_imported_usage_aggregate_dimensions(
|
||
snapshot: &AdminSystemUsageAggregateSnapshot,
|
||
user_id_map: &BTreeMap<String, String>,
|
||
api_key_id_map: &BTreeMap<String, String>,
|
||
) -> Result<(), String> {
|
||
let mut seen_daily = BTreeSet::new();
|
||
for row in &snapshot.stats_daily {
|
||
if !seen_daily.insert(row.date_unix_secs) {
|
||
return Err(format!(
|
||
"stats_daily aggregate already exists for date_unix_secs={}",
|
||
row.date_unix_secs
|
||
));
|
||
}
|
||
}
|
||
let mut seen_user_daily = BTreeSet::new();
|
||
for row in &snapshot.stats_user_daily {
|
||
let Some(target_user_id) = user_id_map.get(&row.user_id) else {
|
||
continue;
|
||
};
|
||
if !seen_user_daily.insert((target_user_id.clone(), row.date_unix_secs)) {
|
||
return Err(format!(
|
||
"stats_user_daily aggregate already exists for date_unix_secs={}",
|
||
row.date_unix_secs
|
||
));
|
||
}
|
||
}
|
||
let mut seen_api_key_daily = BTreeSet::new();
|
||
for row in &snapshot.stats_daily_api_key {
|
||
let Some(target_api_key_id) = api_key_id_map.get(&row.api_key_id) else {
|
||
continue;
|
||
};
|
||
if !seen_api_key_daily.insert((target_api_key_id.clone(), row.date_unix_secs)) {
|
||
return Err(format!(
|
||
"stats_daily_api_key aggregate already exists for date_unix_secs={}",
|
||
row.date_unix_secs
|
||
));
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn insert_imported_id_mapping(
|
||
mappings: &mut BTreeMap<String, String>,
|
||
source_id: String,
|
||
target_id: String,
|
||
field_name: &str,
|
||
) -> Result<(), String> {
|
||
if mappings.contains_key(&source_id) {
|
||
return Err(format!("{field_name} 在导入文档中重复: {source_id}"));
|
||
}
|
||
mappings.insert(source_id, target_id);
|
||
Ok(())
|
||
}
|
||
|
||
fn imported_export_day_unix_secs(exported_at: Option<&Value>) -> u64 {
|
||
imported_optional_string(exported_at)
|
||
.ok()
|
||
.flatten()
|
||
.and_then(|value| chrono::DateTime::parse_from_rfc3339(&value).ok())
|
||
.map(|value| unix_day_start_secs(value.timestamp()))
|
||
.unwrap_or_else(|| unix_day_start_secs(chrono::Utc::now().timestamp()))
|
||
}
|
||
|
||
fn unix_day_start_secs(timestamp: i64) -> u64 {
|
||
let timestamp = timestamp.max(0) as u64;
|
||
timestamp - (timestamp % 86_400)
|
||
}
|
||
|
||
fn imported_rfc3339_to_unix_secs(
|
||
value: Option<&Value>,
|
||
field_name: &str,
|
||
) -> Result<Option<u64>, String> {
|
||
let Some(value) = imported_optional_string(value)? else {
|
||
return Ok(None);
|
||
};
|
||
let parsed_timestamp = chrono::DateTime::parse_from_rfc3339(&value)
|
||
.map(|parsed| parsed.timestamp())
|
||
.or_else(|_| {
|
||
chrono::NaiveDateTime::parse_from_str(&value, "%Y-%m-%dT%H:%M:%S%.f")
|
||
.map(|parsed| parsed.and_utc().timestamp())
|
||
})
|
||
.map_err(|_| format!("{field_name} 必须是 RFC3339 时间"))?;
|
||
Ok(Some(parsed_timestamp.max(0) as u64))
|
||
}
|
||
|
||
fn imported_string_list_from_value(
|
||
value: Option<&Value>,
|
||
field_name: &str,
|
||
) -> Result<Option<Vec<String>>, String> {
|
||
let Some(value) = value else {
|
||
return Ok(None);
|
||
};
|
||
match value {
|
||
Value::Null => Ok(None),
|
||
Value::Array(items) => Ok(Some(
|
||
items
|
||
.iter()
|
||
.filter_map(Value::as_str)
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.map(ToOwned::to_owned)
|
||
.collect(),
|
||
)),
|
||
_ => Err(format!("{field_name} 必须是字符串列表")),
|
||
}
|
||
}
|
||
|
||
fn normalize_imported_user_string_list(
|
||
object: &Map<String, Value>,
|
||
field_name: &str,
|
||
) -> Result<Option<Vec<String>>, String> {
|
||
normalize_admin_user_string_list(
|
||
imported_string_list_from_value(object.get(field_name), field_name)?,
|
||
field_name,
|
||
)
|
||
}
|
||
|
||
fn normalize_imported_user_api_formats(
|
||
object: &Map<String, Value>,
|
||
field_name: &str,
|
||
) -> Result<Option<Vec<String>>, String> {
|
||
normalize_admin_user_api_formats(imported_string_list_from_value(
|
||
object.get(field_name),
|
||
field_name,
|
||
)?)
|
||
}
|
||
|
||
fn imported_ip_rules_field<'a>(
|
||
object: &'a Map<String, Value>,
|
||
) -> (&'static str, Option<&'a Value>) {
|
||
if let Some(value) = object.get("ip_rules") {
|
||
("ip_rules", Some(value))
|
||
} else {
|
||
("allowed_ips", object.get("allowed_ips"))
|
||
}
|
||
}
|
||
|
||
fn imported_ip_rules_present(object: &Map<String, Value>) -> bool {
|
||
object.contains_key("ip_rules") || object.contains_key("allowed_ips")
|
||
}
|
||
|
||
fn normalize_imported_user_ip_rules(
|
||
object: &Map<String, Value>,
|
||
) -> Result<Option<Vec<String>>, String> {
|
||
let (field_name, value) = imported_ip_rules_field(object);
|
||
normalize_admin_user_ip_rules(imported_string_list_from_value(value, field_name)?)
|
||
}
|
||
|
||
fn build_imported_user_group_record(
|
||
group: &Map<String, Value>,
|
||
field_name: &str,
|
||
) -> Result<
|
||
(
|
||
Option<String>,
|
||
String,
|
||
aether_data::repository::users::UpsertUserGroupRecord,
|
||
),
|
||
String,
|
||
> {
|
||
let export_id = imported_optional_string(group.get("id"))?;
|
||
let name = imported_optional_string(group.get("name"))?
|
||
.ok_or_else(|| format!("{field_name}.name 不能为空"))?;
|
||
let name = aether_data::repository::users::normalize_user_group_name(&name);
|
||
if name.is_empty() {
|
||
return Err(format!("{field_name}.name 不能为空"));
|
||
}
|
||
let description = imported_optional_string(group.get("description"))?;
|
||
let allowed_providers = normalize_imported_user_string_list(group, "allowed_providers")?;
|
||
let allowed_api_formats = normalize_imported_user_api_formats(group, "allowed_api_formats")?;
|
||
let allowed_models = normalize_imported_user_string_list(group, "allowed_models")?;
|
||
let rate_limit = imported_optional_i32(group.get("rate_limit"), "rate_limit")?;
|
||
|
||
let allowed_providers_mode = imported_optional_list_policy_mode(
|
||
group.get("allowed_providers_mode"),
|
||
"allowed_providers_mode",
|
||
)?
|
||
.unwrap_or_else(|| {
|
||
if group.contains_key("allowed_providers") {
|
||
legacy_imported_list_policy_mode(&allowed_providers)
|
||
} else {
|
||
"inherit".to_string()
|
||
}
|
||
});
|
||
let allowed_api_formats_mode = imported_optional_list_policy_mode(
|
||
group.get("allowed_api_formats_mode"),
|
||
"allowed_api_formats_mode",
|
||
)?
|
||
.unwrap_or_else(|| {
|
||
if group.contains_key("allowed_api_formats") {
|
||
legacy_imported_list_policy_mode(&allowed_api_formats)
|
||
} else {
|
||
"inherit".to_string()
|
||
}
|
||
});
|
||
let allowed_models_mode = imported_optional_list_policy_mode(
|
||
group.get("allowed_models_mode"),
|
||
"allowed_models_mode",
|
||
)?
|
||
.unwrap_or_else(|| {
|
||
if group.contains_key("allowed_models") {
|
||
legacy_imported_list_policy_mode(&allowed_models)
|
||
} else {
|
||
"inherit".to_string()
|
||
}
|
||
});
|
||
let rate_limit_mode =
|
||
imported_optional_rate_limit_policy_mode(group.get("rate_limit_mode"), "rate_limit_mode")?
|
||
.unwrap_or_else(|| {
|
||
if group.contains_key("rate_limit") {
|
||
legacy_imported_rate_limit_policy_mode(rate_limit)
|
||
} else {
|
||
"inherit".to_string()
|
||
}
|
||
});
|
||
|
||
let normalized_name = name.to_ascii_lowercase();
|
||
|
||
Ok((
|
||
export_id,
|
||
normalized_name,
|
||
aether_data::repository::users::UpsertUserGroupRecord {
|
||
name,
|
||
description,
|
||
priority: 0,
|
||
allowed_providers,
|
||
allowed_providers_mode,
|
||
allowed_api_formats,
|
||
allowed_api_formats_mode,
|
||
allowed_models,
|
||
allowed_models_mode,
|
||
rate_limit,
|
||
rate_limit_mode,
|
||
},
|
||
))
|
||
}
|
||
|
||
fn resolve_imported_user_group_ids(
|
||
user: &Map<String, Value>,
|
||
imported_group_id_map: &BTreeMap<String, String>,
|
||
imported_group_name_map: &BTreeMap<String, String>,
|
||
groups_by_name: &BTreeMap<String, aether_data::repository::users::StoredUserGroup>,
|
||
) -> Result<Vec<String>, String> {
|
||
let raw_group_ids =
|
||
imported_string_list_from_value(user.get("group_ids"), "group_ids")?.unwrap_or_default();
|
||
let raw_group_names = imported_string_list_from_value(user.get("group_names"), "group_names")?
|
||
.unwrap_or_default();
|
||
let mut group_ids = BTreeSet::new();
|
||
for raw_group_id in raw_group_ids {
|
||
if let Some(group_id) = imported_group_id_map.get(&raw_group_id) {
|
||
group_ids.insert(group_id.clone());
|
||
continue;
|
||
}
|
||
group_ids.insert(raw_group_id);
|
||
}
|
||
for raw_group_name in raw_group_names {
|
||
let normalized_name =
|
||
aether_data::repository::users::normalize_user_group_name(&raw_group_name)
|
||
.to_ascii_lowercase();
|
||
if normalized_name.is_empty() {
|
||
continue;
|
||
}
|
||
if let Some(group_id) = imported_group_name_map.get(&normalized_name) {
|
||
group_ids.insert(group_id.clone());
|
||
continue;
|
||
}
|
||
if let Some(group) = groups_by_name.get(&normalized_name) {
|
||
group_ids.insert(group.id.clone());
|
||
}
|
||
}
|
||
Ok(group_ids.into_iter().collect())
|
||
}
|
||
|
||
fn normalize_imported_wallet_target(
|
||
wallet: Option<&Map<String, Value>>,
|
||
unlimited: bool,
|
||
) -> Result<ImportedWalletTarget, String> {
|
||
let gift_balance = imported_optional_f64(
|
||
wallet.and_then(|map| map.get("gift_balance")),
|
||
"wallet.gift_balance",
|
||
)?
|
||
.unwrap_or(0.0)
|
||
.max(0.0);
|
||
let recharge_balance = if let Some(map) = wallet {
|
||
if map.contains_key("recharge_balance") {
|
||
imported_optional_f64(map.get("recharge_balance"), "wallet.recharge_balance")?
|
||
.unwrap_or(0.0)
|
||
} else if map.contains_key("refundable_balance") {
|
||
imported_optional_f64(map.get("refundable_balance"), "wallet.refundable_balance")?
|
||
.unwrap_or(0.0)
|
||
} else {
|
||
let total_balance =
|
||
imported_optional_f64(map.get("balance"), "wallet.balance")?.unwrap_or(0.0);
|
||
total_balance - gift_balance
|
||
}
|
||
} else {
|
||
0.0
|
||
};
|
||
let limit_mode = if let Some(map) = wallet {
|
||
if let Some(mode) = imported_optional_string(map.get("limit_mode"))? {
|
||
match mode.to_ascii_lowercase().as_str() {
|
||
"finite" => "finite".to_string(),
|
||
"unlimited" => "unlimited".to_string(),
|
||
_ => return Err("wallet.limit_mode 仅支持 finite / unlimited".to_string()),
|
||
}
|
||
} else if imported_optional_bool(map.get("unlimited"))?.unwrap_or(unlimited) {
|
||
"unlimited".to_string()
|
||
} else {
|
||
"finite".to_string()
|
||
}
|
||
} else if unlimited {
|
||
"unlimited".to_string()
|
||
} else {
|
||
"finite".to_string()
|
||
};
|
||
let currency = imported_optional_string(wallet.and_then(|map| map.get("currency")))?
|
||
.unwrap_or_else(|| "USD".to_string());
|
||
let status = imported_optional_string(wallet.and_then(|map| map.get("status")))?
|
||
.unwrap_or_else(|| "active".to_string());
|
||
if currency.chars().count() > 3 {
|
||
return Err("wallet.currency 最多允许 3 个字符".to_string());
|
||
}
|
||
if status.chars().count() > 20 {
|
||
return Err("wallet.status 最多允许 20 个字符".to_string());
|
||
}
|
||
let imported_total_recharged = imported_optional_f64(
|
||
wallet.and_then(|map| map.get("total_recharged")),
|
||
"wallet.total_recharged",
|
||
)?;
|
||
let total_recharged = imported_total_recharged.unwrap_or_else(|| recharge_balance.max(0.0));
|
||
if total_recharged < 0.0 {
|
||
return Err("wallet.total_recharged 必须是非负有限数值".to_string());
|
||
}
|
||
let imported_total_consumed = imported_optional_f64(
|
||
wallet.and_then(|map| map.get("total_consumed")),
|
||
"wallet.total_consumed",
|
||
)?;
|
||
let total_consumed = imported_total_consumed.unwrap_or(0.0);
|
||
if total_consumed < 0.0 {
|
||
return Err("wallet.total_consumed 必须是非负有限数值".to_string());
|
||
}
|
||
let imported_total_refunded = imported_optional_f64(
|
||
wallet.and_then(|map| map.get("total_refunded")),
|
||
"wallet.total_refunded",
|
||
)?;
|
||
let total_refunded = imported_total_refunded.unwrap_or(0.0);
|
||
if total_refunded < 0.0 {
|
||
return Err("wallet.total_refunded 必须是非负有限数值".to_string());
|
||
}
|
||
let total_adjusted = imported_optional_f64(
|
||
wallet.and_then(|map| map.get("total_adjusted")),
|
||
"wallet.total_adjusted",
|
||
)?
|
||
.unwrap_or(gift_balance);
|
||
let updated_at_unix_secs = imported_rfc3339_to_unix_secs(
|
||
wallet.and_then(|map| map.get("updated_at")),
|
||
"wallet.updated_at",
|
||
)?;
|
||
|
||
for (field_name, value) in [
|
||
("wallet.recharge_balance", recharge_balance),
|
||
("wallet.gift_balance", gift_balance),
|
||
("wallet.total_recharged", total_recharged),
|
||
("wallet.total_consumed", total_consumed),
|
||
("wallet.total_refunded", total_refunded),
|
||
("wallet.total_adjusted", total_adjusted),
|
||
] {
|
||
validate_imported_decimal_storage(value, field_name)?;
|
||
}
|
||
|
||
Ok(ImportedWalletTarget {
|
||
recharge_balance,
|
||
gift_balance,
|
||
limit_mode,
|
||
currency,
|
||
status,
|
||
total_recharged,
|
||
total_consumed,
|
||
total_refunded,
|
||
total_adjusted,
|
||
updated_at_unix_secs,
|
||
})
|
||
}
|
||
|
||
impl<'a> AdminAppState<'a> {
|
||
async fn prevalidate_admin_system_config_import(
|
||
&self,
|
||
request_body: &[u8],
|
||
mode: SystemImportMode,
|
||
) -> Result<Result<(), (http::StatusCode, Value)>, GatewayError> {
|
||
macro_rules! invalid {
|
||
($expr:expr) => {
|
||
match $expr {
|
||
Ok(value) => value,
|
||
Err(detail) => return Ok(Err(invalid_request(detail))),
|
||
}
|
||
};
|
||
}
|
||
macro_rules! routed {
|
||
($expr:expr) => {
|
||
match $expr {
|
||
Ok(value) => value,
|
||
Err(err) => return Ok(Err(err)),
|
||
}
|
||
};
|
||
}
|
||
|
||
let parsed = routed!(parse_admin_system_config_import_request(request_body));
|
||
let source_version = parsed.request.document.version.clone();
|
||
let root = parsed.root;
|
||
let credentials_not_exported = invalid!(imported_config_credentials_not_exported(&root));
|
||
let merge_mode = parsed.request.merge_mode;
|
||
let imported_global_models = routed!(
|
||
parse_admin_system_config_array::<ImportedGlobalModel>(&root, "global_models")
|
||
);
|
||
let imported_providers = routed!(parse_admin_system_config_array::<ImportedProvider>(
|
||
&root,
|
||
"providers",
|
||
));
|
||
let imported_proxy_nodes = routed!(parse_admin_system_config_array::<ImportedProxyNode>(
|
||
&root,
|
||
"proxy_nodes",
|
||
));
|
||
if mode.restores_credentials() && !imported_proxy_nodes.is_empty() {
|
||
return Ok(Err(invalid_request(
|
||
"恢复备份包含 proxy_nodes,但当前恢复入口不支持安全恢复代理节点",
|
||
)));
|
||
}
|
||
let imported_ldap = routed!(parse_admin_system_config_optional_object::<
|
||
ImportedLdapConfig,
|
||
>(&root, "ldap_config"));
|
||
let imported_oauth_providers = routed!(parse_admin_system_config_array::<
|
||
ImportedOAuthProvider,
|
||
>(&root, "oauth_providers"));
|
||
let imported_system_configs = routed!(parse_admin_system_config_array::<
|
||
ImportedSystemConfig,
|
||
>(&root, "system_configs"));
|
||
let (imported_external_models_configs, mut imported_system_configs): (Vec<_>, Vec<_>) =
|
||
imported_system_configs.into_iter().partition(|item| {
|
||
normalize_imported_system_config_key(&item.value.key)
|
||
== ADMIN_EXTERNAL_MODELS_PROXY_NODE_CONFIG_KEY
|
||
});
|
||
// Destination-bound secrets must be applied after their destination fields. Recovery
|
||
// documents are user-controlled JSON and do not guarantee any ordering.
|
||
imported_system_configs.sort_by_key(|item| {
|
||
matches!(
|
||
normalize_imported_system_config_key(&item.value.key).as_str(),
|
||
"smtp_password" | "module.bark_push.device_key"
|
||
)
|
||
});
|
||
let mut existing_system_config_keys = self
|
||
.list_system_config_entries()
|
||
.await?
|
||
.into_iter()
|
||
.map(|entry| normalize_imported_system_config_key(&entry.key))
|
||
.collect::<BTreeSet<_>>();
|
||
for imported_config_item in imported_external_models_configs {
|
||
let config = imported_config_item.value;
|
||
let exists =
|
||
existing_system_config_keys.contains(ADMIN_EXTERNAL_MODELS_PROXY_NODE_CONFIG_KEY);
|
||
match (exists, merge_mode) {
|
||
(true, AdminImportMergeMode::Skip) => continue,
|
||
(true, AdminImportMergeMode::Error) => {
|
||
return Ok(Err(invalid_request(format!(
|
||
"SystemConfig '{ADMIN_EXTERNAL_MODELS_PROXY_NODE_CONFIG_KEY}' 已存在"
|
||
))));
|
||
}
|
||
_ => {}
|
||
}
|
||
match config.value {
|
||
Value::Null => {}
|
||
Value::String(value) if !value.trim().is_empty() => {}
|
||
Value::String(_) => {
|
||
return Ok(Err(invalid_request(
|
||
"external_models_proxy_node_id 不能为空",
|
||
)));
|
||
}
|
||
_ => {
|
||
return Ok(Err(invalid_request(
|
||
"external_models_proxy_node_id 必须是字符串或 null",
|
||
)));
|
||
}
|
||
}
|
||
existing_system_config_keys
|
||
.insert(ADMIN_EXTERNAL_MODELS_PROXY_NODE_CONFIG_KEY.to_string());
|
||
}
|
||
|
||
let mut global_models_by_name = self
|
||
.list_all_admin_global_models_for_system_transfer()
|
||
.await?
|
||
.into_iter()
|
||
.map(|model| (model.name.clone(), (model.id.clone(), Some(model))))
|
||
.collect::<BTreeMap<_, _>>();
|
||
for imported_model_item in &imported_global_models {
|
||
let model = &imported_model_item.value;
|
||
let name = invalid!(trim_required(&model.name, "name"));
|
||
let display_name = invalid!(trim_required(&model.display_name, "display_name"));
|
||
let default_price_per_request = invalid!(normalize_optional_price(
|
||
model.default_price_per_request,
|
||
"default_price_per_request",
|
||
));
|
||
let existing_model = global_models_by_name
|
||
.get(&name)
|
||
.and_then(|(_, model)| model.as_ref());
|
||
let default_tiered_pricing = invalid!(normalize_json_object(
|
||
prepare_imported_secret_safe_json(
|
||
existing_model.and_then(|model| model.default_tiered_pricing.as_ref()),
|
||
model.default_tiered_pricing.clone(),
|
||
credentials_not_exported,
|
||
),
|
||
"default_tiered_pricing",
|
||
));
|
||
let supported_capabilities =
|
||
normalize_supported_capabilities(model.supported_capabilities.clone());
|
||
let config = invalid!(normalize_json_object(
|
||
prepare_imported_secret_safe_json(
|
||
existing_model.and_then(|model| model.config.as_ref()),
|
||
model.config.clone(),
|
||
credentials_not_exported,
|
||
),
|
||
"config",
|
||
));
|
||
if let Some((existing_id, _)) = global_models_by_name.get(&name).cloned() {
|
||
match merge_mode {
|
||
AdminImportMergeMode::Skip => continue,
|
||
AdminImportMergeMode::Error => {
|
||
return Ok(Err(invalid_request(format!("GlobalModel '{name}' 已存在"))));
|
||
}
|
||
AdminImportMergeMode::Overwrite => {
|
||
invalid!(UpdateAdminGlobalModelRecord::new(
|
||
existing_id,
|
||
display_name,
|
||
model.is_active,
|
||
default_price_per_request,
|
||
default_tiered_pricing,
|
||
supported_capabilities,
|
||
config,
|
||
)
|
||
.map_err(|err| err.to_string()));
|
||
}
|
||
}
|
||
} else {
|
||
let id = Uuid::new_v4().to_string();
|
||
invalid!(CreateAdminGlobalModelRecord::new(
|
||
id.clone(),
|
||
name.clone(),
|
||
display_name,
|
||
model.is_active,
|
||
default_price_per_request,
|
||
default_tiered_pricing,
|
||
supported_capabilities,
|
||
config,
|
||
)
|
||
.map_err(|err| err.to_string()));
|
||
global_models_by_name.insert(name, (id, None));
|
||
}
|
||
}
|
||
|
||
let mut providers_by_name = self
|
||
.list_provider_catalog_providers(false)
|
||
.await?
|
||
.into_iter()
|
||
.map(|provider| (provider.name.clone(), provider))
|
||
.collect::<BTreeMap<_, _>>();
|
||
let mut endpoints_by_provider = BTreeMap::<
|
||
String,
|
||
BTreeMap<
|
||
String,
|
||
aether_data_contracts::repository::provider_catalog::StoredProviderCatalogEndpoint,
|
||
>,
|
||
>::new();
|
||
let mut keys_by_provider = BTreeMap::<
|
||
String,
|
||
Vec<aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey>,
|
||
>::new();
|
||
let mut models_by_provider = BTreeMap::<
|
||
String,
|
||
BTreeMap<
|
||
String,
|
||
(
|
||
String,
|
||
Option<
|
||
aether_data_contracts::repository::global_models::StoredAdminProviderModel,
|
||
>,
|
||
),
|
||
>,
|
||
>::new();
|
||
let node_id_map = BTreeMap::<String, String>::new();
|
||
|
||
for imported_provider_item in &imported_providers {
|
||
let raw_provider = &imported_provider_item.raw;
|
||
let imported_provider = &imported_provider_item.value;
|
||
let provider_name = invalid!(trim_required(&imported_provider.name, "name"));
|
||
invalid!(
|
||
crate::provider_transport::validate_anthropic_compatibility_profile_config(
|
||
imported_provider.config.as_ref(),
|
||
)
|
||
.map_err(|_| "无效的 Anthropic compatibility profile".to_string())
|
||
);
|
||
|
||
let existing_provider = providers_by_name.get(&provider_name).cloned();
|
||
if existing_provider.is_some() && merge_mode == AdminImportMergeMode::Error {
|
||
return Ok(Err(invalid_request(format!(
|
||
"Provider '{provider_name}' 已存在"
|
||
))));
|
||
}
|
||
let provider = if let Some(existing) = existing_provider {
|
||
if merge_mode == AdminImportMergeMode::Skip {
|
||
existing
|
||
} else {
|
||
let patch = AdminProviderUpdatePatch::from_object(raw_provider.clone())
|
||
.map_err(|_| "Provider 配置格式无效".to_string());
|
||
let patch = invalid!(patch);
|
||
let mut updated = invalid!(
|
||
self.build_admin_update_provider_record(&existing, patch)
|
||
.await
|
||
);
|
||
updated.proxy = prepare_imported_secret_safe_proxy(
|
||
existing.proxy.as_ref(),
|
||
imported_provider.proxy.clone(),
|
||
credentials_not_exported,
|
||
&node_id_map,
|
||
);
|
||
let provider_ops_fallback_base_url =
|
||
imported_provider_ops_fallback_base_url(raw_provider);
|
||
updated.config = invalid!(prepare_imported_provider_config(
|
||
self,
|
||
&updated.id,
|
||
provider_ops_fallback_base_url.as_deref(),
|
||
existing.config.as_ref(),
|
||
imported_provider.config.clone(),
|
||
credentials_not_exported,
|
||
));
|
||
updated
|
||
}
|
||
} else {
|
||
let payload = serde_json::from_value::<AdminProviderCreateRequest>(Value::Object(
|
||
raw_provider.clone(),
|
||
))
|
||
.map_err(|_| format!("Provider '{provider_name}' 配置格式无效"));
|
||
let payload = invalid!(payload);
|
||
let (mut record, _) =
|
||
invalid!(self.build_admin_create_provider_record(payload).await);
|
||
record.name = provider_name.clone();
|
||
if let Some(enable_format_conversion) = imported_provider.enable_format_conversion {
|
||
record.enable_format_conversion = enable_format_conversion;
|
||
}
|
||
record.proxy = prepare_imported_secret_safe_proxy(
|
||
None,
|
||
imported_provider.proxy.clone(),
|
||
credentials_not_exported,
|
||
&node_id_map,
|
||
);
|
||
let provider_ops_fallback_base_url =
|
||
imported_provider_ops_fallback_base_url(raw_provider);
|
||
record.config = invalid!(prepare_imported_provider_config(
|
||
self,
|
||
&record.id,
|
||
provider_ops_fallback_base_url.as_deref(),
|
||
None,
|
||
imported_provider.config.clone(),
|
||
credentials_not_exported,
|
||
));
|
||
record
|
||
};
|
||
|
||
let mut existing_endpoints_by_format =
|
||
match endpoints_by_provider.remove(&provider_name) {
|
||
Some(endpoints) => endpoints,
|
||
None => self
|
||
.list_provider_catalog_endpoints_by_provider_ids(std::slice::from_ref(
|
||
&provider.id,
|
||
))
|
||
.await?
|
||
.into_iter()
|
||
.map(|endpoint| (endpoint.api_format.clone(), endpoint))
|
||
.collect(),
|
||
};
|
||
let imported_endpoints = routed!(parse_admin_system_config_nested_array::<
|
||
ImportedEndpoint,
|
||
>(raw_provider, "endpoints"));
|
||
for imported_endpoint_item in imported_endpoints {
|
||
let (raw_endpoint, imported_endpoint) = imported_endpoint_item.into_parts();
|
||
let normalized_api_format = invalid!(normalize_import_endpoint_format(
|
||
&imported_endpoint.api_format,
|
||
));
|
||
invalid!(
|
||
crate::provider_transport::validate_anthropic_compatibility_profile_config(
|
||
imported_endpoint.config.as_ref(),
|
||
)
|
||
.map_err(|_| "无效的 Anthropic compatibility profile".to_string())
|
||
);
|
||
if !fixed_provider_import_endpoint_supported(
|
||
&provider.provider_type,
|
||
&normalized_api_format,
|
||
) {
|
||
existing_endpoints_by_format.remove(&normalized_api_format);
|
||
continue;
|
||
}
|
||
let existing_endpoint = existing_endpoints_by_format
|
||
.get(&normalized_api_format)
|
||
.cloned();
|
||
if existing_endpoint.is_some() {
|
||
if merge_mode == AdminImportMergeMode::Error {
|
||
return Ok(Err(invalid_request(format!(
|
||
"Endpoint '{normalized_api_format}' 已存在于 Provider '{provider_name}'"
|
||
))));
|
||
}
|
||
if merge_mode == AdminImportMergeMode::Skip {
|
||
continue;
|
||
}
|
||
}
|
||
let Some((signature, api_family, endpoint_kind)) =
|
||
admin_endpoint_signature_parts(&normalized_api_format)
|
||
else {
|
||
return Ok(Err(invalid_request(format!(
|
||
"无效的 api_format: {}",
|
||
imported_endpoint.api_format
|
||
))));
|
||
};
|
||
let endpoint = if let Some(existing) = existing_endpoint.as_ref() {
|
||
let patch = AdminProviderEndpointUpdatePatch::from_object(raw_endpoint)
|
||
.map_err(|_| "Provider Endpoint 配置格式无效".to_string());
|
||
let patch = invalid!(patch);
|
||
let (fields, payload) = patch.into_parts();
|
||
let normalized_base_url = payload
|
||
.base_url
|
||
.as_deref()
|
||
.map(normalize_admin_base_url)
|
||
.transpose();
|
||
let normalized_base_url = invalid!(normalized_base_url);
|
||
let update_fields =
|
||
admin_provider_endpoints_pure::AdminProviderEndpointUpdateFields {
|
||
base_url: normalized_base_url,
|
||
custom_path: payload.custom_path,
|
||
header_rules: prepare_imported_secret_safe_header_rules(
|
||
existing.header_rules.as_ref(),
|
||
payload.header_rules,
|
||
credentials_not_exported,
|
||
),
|
||
body_rules: prepare_imported_secret_safe_body_rules(
|
||
existing.body_rules.as_ref(),
|
||
payload.body_rules,
|
||
credentials_not_exported,
|
||
),
|
||
max_retries: payload.max_retries,
|
||
is_active: payload.is_active,
|
||
config: prepare_imported_secret_safe_json(
|
||
existing.config.as_ref(),
|
||
payload.config,
|
||
credentials_not_exported,
|
||
),
|
||
proxy: payload.proxy,
|
||
format_acceptance_config: prepare_imported_secret_safe_json(
|
||
existing.format_acceptance_config.as_ref(),
|
||
payload.format_acceptance_config,
|
||
credentials_not_exported,
|
||
),
|
||
};
|
||
let mut updated = invalid!(
|
||
admin_provider_endpoints_pure::apply_admin_provider_endpoint_update_fields(
|
||
existing,
|
||
|field| fields.contains(field),
|
||
|field| fields.is_null(field),
|
||
&update_fields,
|
||
)
|
||
);
|
||
updated.api_format = signature.to_string();
|
||
updated.api_family = Some(api_family.to_string());
|
||
updated.endpoint_kind = Some(endpoint_kind.to_string());
|
||
updated
|
||
} else {
|
||
invalid!(
|
||
admin_provider_endpoints_pure::build_admin_provider_endpoint_record(
|
||
Uuid::new_v4().to_string(),
|
||
provider.id.clone(),
|
||
signature.to_string(),
|
||
api_family.to_string(),
|
||
endpoint_kind.to_string(),
|
||
invalid!(normalize_admin_base_url(&imported_endpoint.base_url)),
|
||
imported_endpoint.custom_path,
|
||
prepare_imported_secret_safe_header_rules(
|
||
None,
|
||
imported_endpoint.header_rules,
|
||
credentials_not_exported,
|
||
),
|
||
prepare_imported_secret_safe_body_rules(
|
||
None,
|
||
imported_endpoint.body_rules,
|
||
credentials_not_exported,
|
||
),
|
||
imported_endpoint.max_retries.unwrap_or(2),
|
||
prepare_imported_secret_safe_json(
|
||
None,
|
||
imported_endpoint.config,
|
||
credentials_not_exported,
|
||
),
|
||
prepare_imported_secret_safe_proxy(
|
||
None,
|
||
imported_endpoint.proxy,
|
||
credentials_not_exported,
|
||
&node_id_map,
|
||
),
|
||
prepare_imported_secret_safe_json(
|
||
None,
|
||
imported_endpoint.format_acceptance_config,
|
||
credentials_not_exported,
|
||
),
|
||
0,
|
||
)
|
||
)
|
||
};
|
||
existing_endpoints_by_format.insert(normalized_api_format, endpoint);
|
||
}
|
||
|
||
let endpoint_formats = existing_endpoints_by_format
|
||
.keys()
|
||
.cloned()
|
||
.collect::<BTreeSet<_>>();
|
||
let mut existing_keys = match keys_by_provider.remove(&provider_name) {
|
||
Some(keys) => keys,
|
||
None => {
|
||
self.list_provider_catalog_keys_by_provider_ids(std::slice::from_ref(
|
||
&provider.id,
|
||
))
|
||
.await?
|
||
}
|
||
};
|
||
let imported_keys = routed!(parse_admin_system_config_nested_array::<
|
||
ImportedProviderKey,
|
||
>(raw_provider, "api_keys"));
|
||
let mut imported_credential_identities = BTreeSet::new();
|
||
for imported_key_item in imported_keys {
|
||
let (raw_key, imported_key) = imported_key_item.into_parts();
|
||
let (normalized_api_formats, _) =
|
||
normalize_import_key_formats(&imported_key, &endpoint_formats);
|
||
if normalized_api_formats.is_empty() {
|
||
continue;
|
||
}
|
||
let normalized_auth_config = invalid!(normalize_import_auth_config(
|
||
imported_key.auth_config.clone()
|
||
));
|
||
let auth_type = imported_key_auth_type(&imported_key);
|
||
let credentials_not_exported = invalid!(
|
||
validate_imported_provider_key_credential_state(&imported_key)
|
||
);
|
||
if imported_provider_credential_identity(
|
||
&imported_key,
|
||
&auth_type,
|
||
normalized_auth_config.as_ref(),
|
||
)
|
||
.is_some_and(|identity| !imported_credential_identities.insert(identity))
|
||
{
|
||
return Ok(Err(invalid_request(format!(
|
||
"Provider '{provider_name}' 的凭据在导入文档中重复"
|
||
))));
|
||
}
|
||
let normalized_raw_key = normalize_import_key_raw_payload(
|
||
&raw_key,
|
||
&auth_type,
|
||
&normalized_api_formats,
|
||
normalized_auth_config.clone(),
|
||
credentials_not_exported,
|
||
);
|
||
let existing_key_index = if credentials_not_exported {
|
||
build_import_key_match_name(&imported_key).and_then(|target_name| {
|
||
existing_keys.iter().position(|existing_key| {
|
||
existing_key
|
||
.auth_type
|
||
.trim()
|
||
.eq_ignore_ascii_case(&auth_type)
|
||
&& existing_key.name == target_name
|
||
})
|
||
})
|
||
} else {
|
||
invalid!(find_imported_provider_key_index(
|
||
self,
|
||
&imported_key,
|
||
&auth_type,
|
||
normalized_auth_config.as_ref(),
|
||
&existing_keys,
|
||
))
|
||
};
|
||
if credentials_not_exported && existing_key_index.is_none() {
|
||
continue;
|
||
}
|
||
if existing_key_index.is_some() && merge_mode == AdminImportMergeMode::Skip {
|
||
continue;
|
||
}
|
||
let mut record = if let Some(existing_index) = existing_key_index {
|
||
if merge_mode == AdminImportMergeMode::Error {
|
||
return Ok(Err(invalid_request(format!(
|
||
"Provider '{provider_name}' 中存在重复 Key"
|
||
))));
|
||
}
|
||
let patch = AdminProviderKeyUpdatePatch::from_object(normalized_raw_key)
|
||
.map_err(|_| "Provider Key 配置格式无效".to_string());
|
||
let patch = invalid!(patch);
|
||
let mut record =
|
||
invalid!(build_admin_update_provider_key_record_with_existing_keys(
|
||
self,
|
||
&provider,
|
||
&existing_keys[existing_index],
|
||
&existing_keys,
|
||
patch,
|
||
));
|
||
if credentials_not_exported {
|
||
record.encrypted_api_key =
|
||
existing_keys[existing_index].encrypted_api_key.clone();
|
||
record.encrypted_auth_config =
|
||
existing_keys[existing_index].encrypted_auth_config.clone();
|
||
}
|
||
record
|
||
} else {
|
||
invalid!(prevalidate_imported_provider_key_uniqueness(
|
||
self,
|
||
&imported_key,
|
||
&auth_type,
|
||
normalized_auth_config.as_ref(),
|
||
&existing_keys,
|
||
));
|
||
let payload = serde_json::from_value::<AdminProviderKeyCreateRequest>(
|
||
Value::Object(normalized_raw_key),
|
||
)
|
||
.map_err(|_| "Provider Key 配置格式无效".to_string());
|
||
invalid!(
|
||
self.build_admin_create_provider_key_record(&provider, invalid!(payload))
|
||
.await
|
||
)
|
||
};
|
||
if auth_type == "oauth" {
|
||
invalid!(apply_imported_oauth_key_credentials(
|
||
self,
|
||
&provider.provider_type,
|
||
None,
|
||
&raw_key,
|
||
normalized_auth_config.as_ref(),
|
||
&mut record,
|
||
));
|
||
}
|
||
if existing_key_index.is_none() || merge_mode != AdminImportMergeMode::Skip {
|
||
invalid!(normalize_json_object(
|
||
imported_key.global_priority_by_format.clone(),
|
||
"global_priority_by_format",
|
||
));
|
||
invalid!(normalize_json_object(
|
||
imported_key.fingerprint.clone(),
|
||
"fingerprint",
|
||
));
|
||
}
|
||
if let Some(index) = existing_key_index {
|
||
existing_keys[index] = record;
|
||
} else {
|
||
existing_keys.push(record);
|
||
}
|
||
}
|
||
|
||
let imported_models = routed!(parse_admin_system_config_nested_array::<
|
||
ImportedProviderModel,
|
||
>(raw_provider, "models"));
|
||
let mut existing_models_by_name = match models_by_provider.remove(&provider_name) {
|
||
Some(models) => models,
|
||
None => self
|
||
.list_all_admin_provider_models_for_system_transfer(&provider.id)
|
||
.await?
|
||
.into_iter()
|
||
.map(|model| {
|
||
(
|
||
model.provider_model_name.clone(),
|
||
(model.id.clone(), Some(model)),
|
||
)
|
||
})
|
||
.collect::<BTreeMap<_, _>>(),
|
||
};
|
||
for imported_model_item in imported_models {
|
||
let imported_model = imported_model_item.value;
|
||
let Some(global_model_name) = imported_model
|
||
.global_model_name
|
||
.as_deref()
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
else {
|
||
continue;
|
||
};
|
||
let Some((global_model_id, _)) = global_models_by_name.get(global_model_name)
|
||
else {
|
||
continue;
|
||
};
|
||
let provider_model_name = invalid!(trim_required(
|
||
&imported_model.provider_model_name,
|
||
"provider_model_name",
|
||
));
|
||
if existing_models_by_name.contains_key(&provider_model_name) {
|
||
if merge_mode == AdminImportMergeMode::Error {
|
||
return Ok(Err(invalid_request(format!(
|
||
"Model '{provider_model_name}' 已存在于 Provider '{provider_name}'"
|
||
))));
|
||
}
|
||
if merge_mode == AdminImportMergeMode::Skip {
|
||
continue;
|
||
}
|
||
}
|
||
let existing_model = existing_models_by_name
|
||
.get(&provider_model_name)
|
||
.and_then(|(_, model)| model.as_ref());
|
||
invalid!(build_import_provider_model_record(
|
||
&provider.id,
|
||
existing_models_by_name
|
||
.get(&provider_model_name)
|
||
.map(|(id, _)| id.as_str()),
|
||
existing_model,
|
||
global_model_id,
|
||
&imported_model,
|
||
credentials_not_exported,
|
||
));
|
||
existing_models_by_name
|
||
.entry(provider_model_name)
|
||
.or_insert_with(|| (Uuid::new_v4().to_string(), None));
|
||
}
|
||
|
||
providers_by_name.insert(provider_name.clone(), provider);
|
||
endpoints_by_provider.insert(provider_name.clone(), existing_endpoints_by_format);
|
||
keys_by_provider.insert(provider_name.clone(), existing_keys);
|
||
models_by_provider.insert(provider_name, existing_models_by_name);
|
||
}
|
||
|
||
if let Some(imported_ldap) = imported_ldap.filter(|_| self.has_auth_module_writer()) {
|
||
let ldap_config = imported_ldap.value;
|
||
let existing = self.get_ldap_module_config().await?;
|
||
let server_url = invalid!(trim_required(&ldap_config.server_url, "LDAP 服务器地址"));
|
||
let server_url = invalid!(normalize_ldap_transport_server_url(
|
||
&server_url,
|
||
ldap_config.use_starttls,
|
||
)
|
||
.ok_or_else(|| {
|
||
"LDAP 服务器地址必须使用 ldaps://,或在启用 StartTLS 时使用 ldap://;不得包含凭据、查询参数或片段"
|
||
.to_string()
|
||
}));
|
||
let bind_dn = invalid!(trim_required(&ldap_config.bind_dn, "绑定 DN"));
|
||
let base_dn = invalid!(trim_required(&ldap_config.base_dn, "Base DN"));
|
||
if !ldap_distinguished_name_is_valid(&bind_dn)
|
||
|| !ldap_distinguished_name_is_valid(&base_dn)
|
||
{
|
||
return Ok(Err(invalid_request(
|
||
"LDAP 绑定 DN 或 Base DN 格式无效或过长",
|
||
)));
|
||
}
|
||
let user_search_filter = invalid!(trim_required(
|
||
ldap_config
|
||
.user_search_filter
|
||
.as_deref()
|
||
.unwrap_or("(uid={username})"),
|
||
"搜索过滤器",
|
||
));
|
||
if !ldap_search_filter_is_valid(&user_search_filter) {
|
||
return Ok(Err(invalid_request(
|
||
"LDAP 搜索过滤器格式无效,必须包含 {username} 且使用有限的括号结构",
|
||
)));
|
||
}
|
||
let username_attr = invalid!(trim_required(
|
||
ldap_config.username_attr.as_deref().unwrap_or("uid"),
|
||
"用户名属性",
|
||
));
|
||
let email_attr = invalid!(trim_required(
|
||
ldap_config.email_attr.as_deref().unwrap_or("mail"),
|
||
"邮箱属性",
|
||
));
|
||
let display_name_attr = invalid!(trim_required(
|
||
ldap_config.display_name_attr.as_deref().unwrap_or("cn"),
|
||
"显示名称属性",
|
||
));
|
||
if [
|
||
username_attr.as_str(),
|
||
email_attr.as_str(),
|
||
display_name_attr.as_str(),
|
||
]
|
||
.into_iter()
|
||
.any(|attribute| !ldap_attribute_description_is_valid(attribute))
|
||
{
|
||
return Ok(Err(invalid_request(
|
||
"LDAP 用户名、邮箱或显示名称属性格式无效",
|
||
)));
|
||
}
|
||
let connect_timeout = ldap_config.connect_timeout.unwrap_or(10);
|
||
if !(1..=60).contains(&connect_timeout) {
|
||
return Ok(Err(invalid_request(
|
||
"LDAP connect_timeout 必须在 1 到 60 秒之间",
|
||
)));
|
||
}
|
||
let config = StoredLdapModuleConfig {
|
||
server_url,
|
||
bind_dn,
|
||
// Password mutation is explicit and separate from the replacement snapshot.
|
||
// In particular, Preserve never copies a previously read ciphertext here.
|
||
bind_password_encrypted: None,
|
||
base_dn,
|
||
user_search_filter: Some(user_search_filter),
|
||
username_attr: Some(username_attr),
|
||
email_attr: Some(email_attr),
|
||
display_name_attr: Some(display_name_attr),
|
||
is_enabled: ldap_config.is_enabled,
|
||
is_exclusive: ldap_config.is_exclusive,
|
||
use_starttls: ldap_config.use_starttls,
|
||
connect_timeout: Some(connect_timeout),
|
||
};
|
||
let bind_password = ldap_config
|
||
.bind_password
|
||
.as_deref()
|
||
.map(str::trim)
|
||
.map(ToOwned::to_owned);
|
||
if bind_password
|
||
.as_deref()
|
||
.is_some_and(is_imported_redacted_secret)
|
||
{
|
||
return Ok(Err(invalid_request("LDAP 脱敏占位符不能作为绑定密码导入")));
|
||
}
|
||
let bind_password_update = match bind_password {
|
||
Some(password) if password.is_empty() => LdapBindPasswordUpdate::Clear,
|
||
Some(password) => LdapBindPasswordUpdate::Set(invalid!(self
|
||
.encrypt_ldap_bind_password(&config, &password)
|
||
.ok_or_else(|| {
|
||
"LDAP 绑定密码加密失败,请检查 Rust 数据加密配置".to_string()
|
||
}))),
|
||
None => LdapBindPasswordUpdate::Preserve,
|
||
};
|
||
if matches!(&bind_password_update, LdapBindPasswordUpdate::Preserve) {
|
||
if let Some(existing) = existing.as_ref() {
|
||
if existing
|
||
.bind_password_encrypted
|
||
.as_deref()
|
||
.is_some_and(|value| !value.trim().is_empty())
|
||
{
|
||
let binding_matches =
|
||
invalid!(crate::handlers::shared::ldap_bind_password_binding_matches(
|
||
existing, &config,
|
||
));
|
||
if !binding_matches {
|
||
return Ok(Err(invalid_request(
|
||
"导入 LDAP 时修改了服务器、StartTLS、bind DN 或 Base DN,必须提供绑定密码",
|
||
)));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
let will_have_password = match &bind_password_update {
|
||
LdapBindPasswordUpdate::Set(ciphertext) => !ciphertext.trim().is_empty(),
|
||
LdapBindPasswordUpdate::Clear => false,
|
||
LdapBindPasswordUpdate::Preserve => existing
|
||
.as_ref()
|
||
.and_then(|config| config.bind_password_encrypted.as_deref())
|
||
.map(str::trim)
|
||
.is_some_and(|value| !value.is_empty()),
|
||
};
|
||
if existing.is_none()
|
||
&& !matches!(&bind_password_update, LdapBindPasswordUpdate::Set(_))
|
||
{
|
||
return Ok(Err(invalid_request("首次配置 LDAP 时必须设置绑定密码")));
|
||
}
|
||
if ldap_config.is_exclusive && !ldap_config.is_enabled {
|
||
return Ok(Err(invalid_request(
|
||
"仅允许 LDAP 登录 需要先启用 LDAP 认证",
|
||
)));
|
||
}
|
||
if ldap_config.is_enabled && !will_have_password {
|
||
return Ok(Err(invalid_request("启用 LDAP 认证 需要先设置绑定密码")));
|
||
}
|
||
if ldap_config.is_enabled && ldap_config.is_exclusive {
|
||
let admin_count = self
|
||
.count_active_local_admin_users_with_valid_password()
|
||
.await?;
|
||
if admin_count < 1 {
|
||
return Ok(Err(invalid_request(
|
||
"启用 LDAP 独占模式前,必须至少保留 1 个有效的本地管理员账户(含有效密码)作为紧急恢复通道",
|
||
)));
|
||
}
|
||
}
|
||
if existing.is_some() && merge_mode == AdminImportMergeMode::Error {
|
||
return Ok(Err(invalid_request("LDAP 配置已存在")));
|
||
}
|
||
}
|
||
|
||
let existing_oauth_providers = self.list_oauth_provider_configs().await?;
|
||
let existing_oauth_by_type = existing_oauth_providers
|
||
.iter()
|
||
.map(|provider| (provider.provider_type.clone(), provider))
|
||
.collect::<BTreeMap<_, _>>();
|
||
let mut oauth_provider_types = existing_oauth_by_type
|
||
.keys()
|
||
.cloned()
|
||
.collect::<BTreeSet<_>>();
|
||
for imported_oauth_item in imported_oauth_providers {
|
||
let oauth_provider = invalid!(normalize_legacy_imported_oauth_provider(
|
||
imported_oauth_item.value,
|
||
&source_version,
|
||
));
|
||
let provider_type = invalid!(trim_required(
|
||
&oauth_provider.provider_type,
|
||
"provider_type",
|
||
));
|
||
if oauth_provider_types.contains(&provider_type) {
|
||
match merge_mode {
|
||
AdminImportMergeMode::Skip => continue,
|
||
AdminImportMergeMode::Error => {
|
||
return Ok(Err(invalid_request(format!(
|
||
"OAuth Provider '{provider_type}' 已存在"
|
||
))));
|
||
}
|
||
AdminImportMergeMode::Overwrite => {}
|
||
}
|
||
}
|
||
// Construct and validate the complete record before sealing the secret. The
|
||
// envelope binding includes client_id, redirect URI, and endpoint overrides;
|
||
// sealing against provider_type alone would allow a secret to be replayed after
|
||
// those fields change.
|
||
let mut record = invalid!(build_imported_oauth_provider_record(
|
||
&oauth_provider,
|
||
EncryptedSecretUpdate::Preserve,
|
||
));
|
||
let client_secret_update = match oauth_provider.client_secret.as_deref().map(str::trim)
|
||
{
|
||
Some(secret) if is_imported_redacted_secret(secret) => {
|
||
EncryptedSecretUpdate::Preserve
|
||
}
|
||
Some(secret) if !secret.is_empty() => EncryptedSecretUpdate::Set(invalid!(
|
||
crate::handlers::shared::seal_identity_oauth_provider_client_secret(
|
||
self.as_ref(),
|
||
&record,
|
||
secret,
|
||
)
|
||
.map_err(str::to_string)
|
||
)),
|
||
_ => EncryptedSecretUpdate::Preserve,
|
||
};
|
||
record.client_secret_encrypted = client_secret_update;
|
||
if matches!(
|
||
&record.client_secret_encrypted,
|
||
EncryptedSecretUpdate::Preserve
|
||
) {
|
||
if let Some(existing) = existing_oauth_by_type.get(&provider_type) {
|
||
if existing
|
||
.client_secret_encrypted
|
||
.as_deref()
|
||
.is_some_and(|value| !value.trim().is_empty())
|
||
{
|
||
let binding_matches = invalid!(
|
||
crate::handlers::shared::identity_oauth_provider_secret_binding_matches(
|
||
existing, &record,
|
||
)
|
||
);
|
||
if !binding_matches {
|
||
return Ok(Err(invalid_request(
|
||
"导入 OAuth Provider 时修改了 Client ID、端点或 redirect_uri,必须提供 client_secret",
|
||
)));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
oauth_provider_types.insert(provider_type);
|
||
}
|
||
|
||
for imported_config_item in imported_system_configs {
|
||
let config = imported_config_item.value;
|
||
let normalized_key = normalize_imported_system_config_key(&config.key);
|
||
if credentials_not_exported
|
||
&& (is_sensitive_admin_system_config_key(&normalized_key)
|
||
|| is_interactive_export_private_system_config_key(&normalized_key))
|
||
{
|
||
continue;
|
||
}
|
||
let exists = existing_system_config_keys.contains(&normalized_key);
|
||
match (exists, merge_mode) {
|
||
(true, AdminImportMergeMode::Skip) => continue,
|
||
(true, AdminImportMergeMode::Error) => {
|
||
return Ok(Err(invalid_request(format!(
|
||
"SystemConfig '{normalized_key}' 已存在"
|
||
))));
|
||
}
|
||
_ => {}
|
||
}
|
||
let request_body = serde_json::to_vec(&json!({
|
||
"value": config.value,
|
||
"description": config.description,
|
||
}))
|
||
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
||
let update = routed!(parse_admin_system_config_update(&config.key, &request_body));
|
||
if is_sensitive_admin_system_config_key(&update.normalized_key)
|
||
&& update.value.as_str().is_some_and(|raw| !raw.is_empty())
|
||
{
|
||
let Some(_) = self.encrypt_system_config_secret(
|
||
&update.normalized_key,
|
||
update
|
||
.value
|
||
.as_str()
|
||
.expect("sensitive imported config value was a string"),
|
||
) else {
|
||
return Ok(Err((
|
||
http::StatusCode::SERVICE_UNAVAILABLE,
|
||
json!({ "detail": "系统配置写入需要可用的加密密钥" }),
|
||
)));
|
||
};
|
||
}
|
||
existing_system_config_keys.insert(normalized_key);
|
||
}
|
||
|
||
Ok(Ok(()))
|
||
}
|
||
|
||
async fn prevalidate_admin_system_users_import(
|
||
&self,
|
||
request_body: &[u8],
|
||
operator_id: Option<&str>,
|
||
mode: SystemImportMode,
|
||
) -> Result<Result<(), (http::StatusCode, Value)>, GatewayError> {
|
||
macro_rules! invalid {
|
||
($expr:expr) => {
|
||
match $expr {
|
||
Ok(value) => value,
|
||
Err(detail) => return Ok(Err(invalid_request(detail))),
|
||
}
|
||
};
|
||
}
|
||
|
||
let root = match serde_json::from_slice::<Value>(request_body) {
|
||
Ok(Value::Object(map)) => map,
|
||
_ => return Ok(Err(invalid_request("请求数据验证失败"))),
|
||
};
|
||
let merge_mode = match serde_json::from_value::<AdminImportMergeMode>(
|
||
root.get("merge_mode").cloned().unwrap_or(Value::Null),
|
||
) {
|
||
Ok(value) => value,
|
||
Err(_) => {
|
||
return Ok(Err(invalid_request(
|
||
"merge_mode 仅支持 skip / overwrite / error",
|
||
)))
|
||
}
|
||
};
|
||
let users_export_version = invalid!(
|
||
validate_imported_system_users_export_version_for_mode(root.get("version"), mode)
|
||
);
|
||
let empty = Vec::new();
|
||
let users = match root.get("users") {
|
||
Some(Value::Array(items)) => items,
|
||
Some(_) => return Ok(Err(invalid_request("users 必须是数组"))),
|
||
None => &empty,
|
||
};
|
||
let standalone_keys = match root.get("standalone_keys") {
|
||
Some(Value::Array(items)) => items,
|
||
Some(_) => return Ok(Err(invalid_request("standalone_keys 必须是数组"))),
|
||
None => &empty,
|
||
};
|
||
let imported_user_groups = match root.get("user_groups") {
|
||
Some(Value::Array(items)) => items,
|
||
Some(_) => return Ok(Err(invalid_request("user_groups 必须是数组"))),
|
||
None => &empty,
|
||
};
|
||
// Aggregate rollback restores identity/configuration only. Runtime usage rows are left
|
||
// untouched so a request that completes concurrently cannot be overwritten by the
|
||
// checkpoint. Skip both parsing and conflict validation in this mode; the rollback body
|
||
// also strips these fields before it reaches the regular importer.
|
||
let usage_aggregate_snapshot = if mode.is_rollback_checkpoint() {
|
||
AdminSystemUsageAggregateSnapshot::default()
|
||
} else {
|
||
let supplemental = invalid!(build_imported_user_usage_total_aggregates(
|
||
users,
|
||
root.get("exported_at")
|
||
));
|
||
let snapshot = invalid!(build_imported_usage_aggregate_snapshot(
|
||
root.get("usage_aggregates"),
|
||
&supplemental,
|
||
));
|
||
invalid!(validate_imported_usage_aggregate_storage(&snapshot));
|
||
snapshot
|
||
};
|
||
|
||
let default_group_id = self.effective_default_user_group_id().await?;
|
||
let mut groups_by_name = self
|
||
.list_user_groups()
|
||
.await?
|
||
.into_iter()
|
||
.map(|group| {
|
||
(
|
||
aether_data::repository::users::normalize_user_group_name(&group.name)
|
||
.to_ascii_lowercase(),
|
||
group,
|
||
)
|
||
})
|
||
.collect::<BTreeMap<_, _>>();
|
||
let mut imported_group_id_map = BTreeMap::<String, String>::new();
|
||
let mut imported_group_name_map = BTreeMap::<String, String>::new();
|
||
for (index, raw_group) in imported_user_groups.iter().enumerate() {
|
||
let group = invalid!(imported_object_field(
|
||
raw_group,
|
||
&format!("user_groups[{index}]"),
|
||
));
|
||
let (export_id, normalized_name, record) = invalid!(build_imported_user_group_record(
|
||
group,
|
||
&format!("user_groups[{index}]")
|
||
));
|
||
if default_group_id
|
||
.as_deref()
|
||
.is_some_and(|group_id| export_id.as_deref() == Some(group_id))
|
||
|| normalized_name == "default"
|
||
{
|
||
if let Some(default_group_id) = default_group_id.as_ref() {
|
||
if let Some(export_id) = export_id {
|
||
imported_group_id_map.insert(export_id, default_group_id.clone());
|
||
}
|
||
imported_group_name_map.insert(normalized_name, default_group_id.clone());
|
||
}
|
||
continue;
|
||
}
|
||
let existing_by_id = mode
|
||
.is_rollback_checkpoint()
|
||
.then(|| {
|
||
export_id.as_deref().and_then(|export_id| {
|
||
groups_by_name.values().find(|group| group.id == export_id)
|
||
})
|
||
})
|
||
.flatten();
|
||
if mode.is_rollback_checkpoint() && export_id.is_some() && existing_by_id.is_none() {
|
||
return Ok(Err(invalid_request(format!(
|
||
"回滚检查点用户组 '{}' 不存在;拒绝按名称匹配",
|
||
export_id.as_deref().unwrap_or_default()
|
||
))));
|
||
}
|
||
if let Some(existing) = existing_by_id.or_else(|| groups_by_name.get(&normalized_name))
|
||
{
|
||
if merge_mode == AdminImportMergeMode::Error {
|
||
return Ok(Err(invalid_request(format!(
|
||
"用户组 '{}' 已存在",
|
||
existing.name
|
||
))));
|
||
}
|
||
if let Some(export_id) = export_id {
|
||
imported_group_id_map.insert(export_id, existing.id.clone());
|
||
}
|
||
imported_group_name_map.insert(normalized_name, existing.id.clone());
|
||
} else {
|
||
let synthetic_id = format!("prevalidated-group-{index}");
|
||
let stored = aether_data::repository::users::StoredUserGroup::new(
|
||
synthetic_id.clone(),
|
||
record.name.clone(),
|
||
normalized_name.clone(),
|
||
record.description.clone(),
|
||
record.priority,
|
||
record.allowed_providers.clone().map(Value::from),
|
||
record.allowed_providers_mode.clone(),
|
||
record.allowed_api_formats.clone().map(Value::from),
|
||
record.allowed_api_formats_mode.clone(),
|
||
record.allowed_models.clone().map(Value::from),
|
||
record.allowed_models_mode.clone(),
|
||
record.rate_limit,
|
||
record.rate_limit_mode.clone(),
|
||
None,
|
||
None,
|
||
)
|
||
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
||
if let Some(export_id) = export_id {
|
||
imported_group_id_map.insert(export_id, synthetic_id.clone());
|
||
}
|
||
imported_group_name_map.insert(normalized_name.clone(), synthetic_id);
|
||
groups_by_name.insert(normalized_name, stored);
|
||
}
|
||
}
|
||
|
||
let standalone_owner_id = match operator_id {
|
||
Some(candidate) => match self.find_user_auth_by_id(candidate).await? {
|
||
Some(user) if crate::roles::is_full_admin_role(&user.role) => Some(user.id),
|
||
_ => None,
|
||
},
|
||
None => None,
|
||
};
|
||
let mut simulated_users_by_id = BTreeMap::<String, SimulatedImportedUser>::new();
|
||
let mut simulated_email_owners = BTreeMap::<String, String>::new();
|
||
let mut simulated_username_owners = BTreeMap::<String, String>::new();
|
||
let mut released_emails = BTreeSet::<String>::new();
|
||
let mut released_usernames = BTreeSet::<String>::new();
|
||
let mut api_keys_by_hash = BTreeMap::<String, SimulatedImportedApiKey>::new();
|
||
let mut imported_api_key_hashes = BTreeSet::<String>::new();
|
||
let mut imported_user_id_map = BTreeMap::<String, String>::new();
|
||
let mut imported_api_key_id_map = BTreeMap::<String, String>::new();
|
||
for user in self.list_export_users().await? {
|
||
replace_simulated_imported_user(
|
||
&mut simulated_users_by_id,
|
||
&mut simulated_email_owners,
|
||
&mut simulated_username_owners,
|
||
&mut released_emails,
|
||
&mut released_usernames,
|
||
SimulatedImportedUser {
|
||
id: user.id,
|
||
email: user.email,
|
||
username: user.username,
|
||
role: user.role,
|
||
existed_before_import: true,
|
||
},
|
||
);
|
||
}
|
||
#[cfg(test)]
|
||
if let Some(store) = self.app().auth_user_store.as_ref() {
|
||
for user in store.lock().expect("auth user store should lock").values() {
|
||
replace_simulated_imported_user(
|
||
&mut simulated_users_by_id,
|
||
&mut simulated_email_owners,
|
||
&mut simulated_username_owners,
|
||
&mut released_emails,
|
||
&mut released_usernames,
|
||
simulated_imported_user_from_auth_record(user),
|
||
);
|
||
}
|
||
}
|
||
for record in self.list_auth_api_key_export_standalone_records().await? {
|
||
let simulated = SimulatedImportedApiKey {
|
||
owner_id: record.user_id,
|
||
is_standalone: true,
|
||
target_id: record.api_key_id.clone(),
|
||
existed_before_import: true,
|
||
};
|
||
api_keys_by_hash.insert(record.key_hash, simulated.clone());
|
||
if mode.is_rollback_checkpoint() {
|
||
api_keys_by_hash
|
||
.entry(imported_api_key_tombstone(&record.api_key_id))
|
||
.or_insert(simulated);
|
||
}
|
||
}
|
||
let now_unix_secs = SystemTime::now()
|
||
.duration_since(UNIX_EPOCH)
|
||
.ok()
|
||
.map(|duration| duration.as_secs())
|
||
.unwrap_or(0);
|
||
|
||
for (index, raw_user) in users.iter().enumerate() {
|
||
let user = invalid!(imported_object_field(raw_user, &format!("users[{index}]")));
|
||
let source_user_id = invalid!(imported_optional_string(user.get("id")));
|
||
invalid!(validate_rollback_user_source_id(
|
||
mode,
|
||
source_user_id.as_deref(),
|
||
));
|
||
let Some(role) = invalid!(normalize_imported_system_user_role(user.get("role"), mode))
|
||
else {
|
||
invalid!(imported_optional_string(user.get("email")));
|
||
invalid!(imported_optional_string(user.get("username")));
|
||
continue;
|
||
};
|
||
let email = invalid!(imported_optional_string(user.get("email")))
|
||
.map(|value| value.to_ascii_lowercase());
|
||
let username = invalid!(imported_optional_string(user.get("username")))
|
||
.or_else(|| {
|
||
email.as_ref().map(|value| {
|
||
value
|
||
.split('@')
|
||
.next()
|
||
.unwrap_or(value.as_str())
|
||
.to_string()
|
||
})
|
||
})
|
||
.unwrap_or_else(|| format!("imported-user-{index}"));
|
||
invalid!(imported_optional_bool(user.get("email_verified")));
|
||
invalid!(resolve_imported_password_hash(
|
||
user,
|
||
users_export_version,
|
||
mode,
|
||
));
|
||
let allowed_providers = invalid!(normalize_imported_user_string_list(
|
||
user,
|
||
"allowed_providers"
|
||
));
|
||
let allowed_api_formats = invalid!(normalize_imported_user_api_formats(
|
||
user,
|
||
"allowed_api_formats"
|
||
));
|
||
let allowed_models =
|
||
invalid!(normalize_imported_user_string_list(user, "allowed_models"));
|
||
let rate_limit = invalid!(imported_optional_i32(user.get("rate_limit"), "rate_limit"));
|
||
invalid!(imported_user_list_policy_mode(
|
||
user,
|
||
"allowed_providers_mode",
|
||
"allowed_providers",
|
||
&allowed_providers,
|
||
));
|
||
invalid!(imported_user_list_policy_mode(
|
||
user,
|
||
"allowed_api_formats_mode",
|
||
"allowed_api_formats",
|
||
&allowed_api_formats,
|
||
));
|
||
invalid!(imported_user_list_policy_mode(
|
||
user,
|
||
"allowed_models_mode",
|
||
"allowed_models",
|
||
&allowed_models,
|
||
));
|
||
invalid!(imported_user_rate_limit_policy_mode(
|
||
user,
|
||
"rate_limit_mode",
|
||
"rate_limit",
|
||
rate_limit,
|
||
));
|
||
let group_ids = invalid!(resolve_imported_user_group_ids(
|
||
user,
|
||
&imported_group_id_map,
|
||
&imported_group_name_map,
|
||
&groups_by_name,
|
||
));
|
||
if user.contains_key("group_ids") || user.contains_key("group_names") {
|
||
let group_ids = self.include_default_user_group_ids(&group_ids).await?;
|
||
let known_group_ids = groups_by_name
|
||
.values()
|
||
.map(|group| group.id.as_str())
|
||
.collect::<BTreeSet<_>>();
|
||
if group_ids
|
||
.iter()
|
||
.any(|group_id| !known_group_ids.contains(group_id.as_str()))
|
||
{
|
||
return Ok(Err(invalid_request(format!(
|
||
"用户 '{}' 的用户组不存在",
|
||
email.clone().unwrap_or(username.clone())
|
||
))));
|
||
}
|
||
}
|
||
invalid!(imported_optional_bool(user.get("is_active")));
|
||
invalid!(imported_optional_json_object(
|
||
user.get("model_capability_settings"),
|
||
"model_capability_settings",
|
||
));
|
||
invalid!(imported_optional_json_object(
|
||
user.get("feature_settings"),
|
||
"feature_settings",
|
||
)
|
||
.and_then(normalize_admin_feature_settings));
|
||
let wallet = match user.get("wallet") {
|
||
Some(Value::Object(map)) => Some(map),
|
||
Some(Value::Null) | None => None,
|
||
Some(_) => return Ok(Err(invalid_request("wallet 必须是对象"))),
|
||
};
|
||
if let Some(wallet) = wallet {
|
||
invalid!(normalize_imported_wallet_target(Some(wallet), false));
|
||
}
|
||
|
||
// Rollback checkpoints originate from this deployment and carry stable user IDs.
|
||
// Never fall back to mutable email/username fields: if the checkpoint ID disappeared
|
||
// concurrently, guessing could overwrite an unrelated user.
|
||
let existing_user = if mode.is_rollback_checkpoint() {
|
||
let mut existing = source_user_id
|
||
.as_deref()
|
||
.and_then(|user_id| simulated_users_by_id.get(user_id).cloned());
|
||
if existing.is_none() {
|
||
if let Some(source_user_id) = source_user_id.as_deref() {
|
||
if let Some(record) = self.find_user_auth_by_id(source_user_id).await? {
|
||
let simulated = simulated_imported_user_from_auth_record(&record);
|
||
replace_simulated_imported_user(
|
||
&mut simulated_users_by_id,
|
||
&mut simulated_email_owners,
|
||
&mut simulated_username_owners,
|
||
&mut released_emails,
|
||
&mut released_usernames,
|
||
simulated.clone(),
|
||
);
|
||
existing = Some(simulated);
|
||
}
|
||
}
|
||
}
|
||
if existing.is_none() {
|
||
let source_user_id = source_user_id.as_deref().unwrap_or_default();
|
||
return Ok(Err(invalid_request(format!(
|
||
"回滚检查点用户 '{source_user_id}' 不存在;拒绝按 email/username 匹配"
|
||
))));
|
||
}
|
||
existing
|
||
} else {
|
||
let mut existing_user = None;
|
||
if let Some(email) = email.as_deref() {
|
||
if let Some(user_id) = simulated_imported_user_id_by_identifier(
|
||
&simulated_email_owners,
|
||
&simulated_username_owners,
|
||
email,
|
||
) {
|
||
existing_user = simulated_users_by_id.get(&user_id).cloned();
|
||
} else if !released_emails.contains(email)
|
||
&& !released_usernames.contains(email)
|
||
{
|
||
if let Some(record) = self.find_user_auth_by_identifier(email).await? {
|
||
let simulated = simulated_imported_user_from_auth_record(&record);
|
||
replace_simulated_imported_user(
|
||
&mut simulated_users_by_id,
|
||
&mut simulated_email_owners,
|
||
&mut simulated_username_owners,
|
||
&mut released_emails,
|
||
&mut released_usernames,
|
||
simulated.clone(),
|
||
);
|
||
existing_user = Some(simulated);
|
||
}
|
||
}
|
||
}
|
||
if existing_user.is_none() {
|
||
if let Some(user_id) = simulated_imported_user_id_by_identifier(
|
||
&simulated_email_owners,
|
||
&simulated_username_owners,
|
||
&username,
|
||
) {
|
||
existing_user = simulated_users_by_id.get(&user_id).cloned();
|
||
} else if !released_emails.contains(&username)
|
||
&& !released_usernames.contains(&username)
|
||
{
|
||
if let Some(record) = self.find_user_auth_by_identifier(&username).await? {
|
||
let simulated = simulated_imported_user_from_auth_record(&record);
|
||
replace_simulated_imported_user(
|
||
&mut simulated_users_by_id,
|
||
&mut simulated_email_owners,
|
||
&mut simulated_username_owners,
|
||
&mut released_emails,
|
||
&mut released_usernames,
|
||
simulated.clone(),
|
||
);
|
||
existing_user = Some(simulated);
|
||
}
|
||
}
|
||
}
|
||
existing_user
|
||
};
|
||
|
||
let label = email.clone().unwrap_or(username.clone());
|
||
let simulated_user = if let Some(existing) = existing_user {
|
||
if imported_existing_user_is_protected(&existing.role, mode) {
|
||
continue;
|
||
}
|
||
match merge_mode {
|
||
AdminImportMergeMode::Skip => continue,
|
||
AdminImportMergeMode::Error => {
|
||
return Ok(Err(invalid_request(format!("用户 '{label}' 已存在"))));
|
||
}
|
||
AdminImportMergeMode::Overwrite => {}
|
||
}
|
||
if let Some(email) = email.as_deref() {
|
||
let taken_in_simulation = simulated_email_owners
|
||
.get(email)
|
||
.is_some_and(|owner_id| owner_id != &existing.id);
|
||
let taken_in_database = !released_emails.contains(email)
|
||
&& self
|
||
.is_other_user_auth_email_taken(email, &existing.id)
|
||
.await?;
|
||
if taken_in_simulation || taken_in_database {
|
||
return Ok(Err(invalid_request(format!("邮箱已存在: {email}"))));
|
||
}
|
||
}
|
||
let username_taken_in_simulation = simulated_username_owners
|
||
.get(&username)
|
||
.is_some_and(|owner_id| owner_id != &existing.id);
|
||
let username_taken_in_database = !released_usernames.contains(&username)
|
||
&& self
|
||
.is_other_user_auth_username_taken(&username, &existing.id)
|
||
.await?;
|
||
if username_taken_in_simulation || username_taken_in_database {
|
||
return Ok(Err(invalid_request(format!("用户名已存在: {username}"))));
|
||
}
|
||
SimulatedImportedUser {
|
||
id: existing.id,
|
||
email: email.clone().or(existing.email),
|
||
username,
|
||
role,
|
||
existed_before_import: existing.existed_before_import,
|
||
}
|
||
} else {
|
||
if email.as_ref().is_some_and(|email| {
|
||
simulated_email_owners.contains_key(email)
|
||
|| (!released_emails.contains(email)
|
||
&& simulated_username_owners.contains_key(email))
|
||
}) || simulated_username_owners.contains_key(&username)
|
||
{
|
||
return Ok(Err(invalid_request(format!("用户 '{label}' 已存在"))));
|
||
}
|
||
SimulatedImportedUser {
|
||
id: format!("prevalidated-user-{index}"),
|
||
email,
|
||
username,
|
||
role,
|
||
existed_before_import: false,
|
||
}
|
||
};
|
||
let user_id = simulated_user.id.clone();
|
||
let existed_before_import = simulated_user.existed_before_import;
|
||
replace_simulated_imported_user(
|
||
&mut simulated_users_by_id,
|
||
&mut simulated_email_owners,
|
||
&mut simulated_username_owners,
|
||
&mut released_emails,
|
||
&mut released_usernames,
|
||
simulated_user,
|
||
);
|
||
if let Some(source_user_id) = source_user_id {
|
||
invalid!(insert_imported_id_mapping(
|
||
&mut imported_user_id_map,
|
||
source_user_id,
|
||
user_id.clone(),
|
||
"users[].id",
|
||
));
|
||
}
|
||
|
||
if existed_before_import {
|
||
for record in self
|
||
.list_auth_api_key_export_records_by_user_ids(std::slice::from_ref(&user_id))
|
||
.await?
|
||
.into_iter()
|
||
.filter(|record| !record.is_standalone)
|
||
{
|
||
let simulated = SimulatedImportedApiKey {
|
||
owner_id: user_id.clone(),
|
||
is_standalone: false,
|
||
target_id: record.api_key_id.clone(),
|
||
existed_before_import: true,
|
||
};
|
||
api_keys_by_hash
|
||
.entry(record.key_hash)
|
||
.or_insert_with(|| simulated.clone());
|
||
if mode.is_rollback_checkpoint() {
|
||
api_keys_by_hash
|
||
.entry(imported_api_key_tombstone(&record.api_key_id))
|
||
.or_insert(simulated);
|
||
}
|
||
}
|
||
}
|
||
|
||
let imported_api_keys = match user.get("api_keys") {
|
||
Some(Value::Array(items)) => items,
|
||
Some(_) => return Ok(Err(invalid_request("api_keys 必须是数组"))),
|
||
None => &empty,
|
||
};
|
||
for (key_index, raw_key) in imported_api_keys.iter().enumerate() {
|
||
let key = invalid!(imported_object_field(
|
||
raw_key,
|
||
&format!("users[{index}].api_keys[{key_index}]"),
|
||
));
|
||
invalid!(self.prevalidate_imported_auth_api_key(key, users_export_version, mode,));
|
||
let source_api_key_id = invalid!(imported_optional_string(key.get("api_key_id")));
|
||
let Some(key_material) = invalid!(self
|
||
.resolve_imported_system_user_api_key_material(
|
||
key,
|
||
users_export_version,
|
||
mode,
|
||
))
|
||
else {
|
||
continue;
|
||
};
|
||
let key_hash = key_material.key_hash;
|
||
if !imported_api_key_hashes.insert(key_hash.clone()) {
|
||
return Ok(Err(invalid_request("API Key 在导入文档中重复")));
|
||
}
|
||
if !api_keys_by_hash.contains_key(&key_hash) {
|
||
if let Some(snapshot) = self
|
||
.app()
|
||
.data
|
||
.read_auth_api_key_snapshot_by_key_hash_strong(&key_hash, now_unix_secs)
|
||
.await
|
||
.map_err(|err| GatewayError::Internal(err.to_string()))?
|
||
{
|
||
api_keys_by_hash.insert(
|
||
key_hash.clone(),
|
||
SimulatedImportedApiKey {
|
||
owner_id: snapshot.user_id,
|
||
is_standalone: snapshot.api_key_is_standalone,
|
||
target_id: snapshot.api_key_id,
|
||
existed_before_import: true,
|
||
},
|
||
);
|
||
}
|
||
}
|
||
if let Some(existing_key) = api_keys_by_hash.get(&key_hash) {
|
||
if existing_key.owner_id != user_id || existing_key.is_standalone {
|
||
return Ok(Err(invalid_request(
|
||
"API Key 已存在且属于其他用户或独立余额 Key",
|
||
)));
|
||
}
|
||
if merge_mode == AdminImportMergeMode::Error {
|
||
return Ok(Err(invalid_request(format!(
|
||
"用户 '{label}' 的 API Key 已存在"
|
||
))));
|
||
}
|
||
if merge_mode == AdminImportMergeMode::Overwrite {
|
||
if let Some(source_api_key_id) = source_api_key_id {
|
||
invalid!(insert_imported_id_mapping(
|
||
&mut imported_api_key_id_map,
|
||
source_api_key_id,
|
||
existing_key.target_id.clone(),
|
||
"api_key_id",
|
||
));
|
||
}
|
||
}
|
||
} else {
|
||
let target_id = format!("prevalidated-api-key-{index}-{key_index}");
|
||
if let Some(source_api_key_id) = source_api_key_id {
|
||
invalid!(insert_imported_id_mapping(
|
||
&mut imported_api_key_id_map,
|
||
source_api_key_id,
|
||
target_id.clone(),
|
||
"api_key_id",
|
||
));
|
||
}
|
||
api_keys_by_hash.insert(
|
||
key_hash,
|
||
SimulatedImportedApiKey {
|
||
owner_id: user_id.clone(),
|
||
is_standalone: false,
|
||
target_id,
|
||
existed_before_import: false,
|
||
},
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
if let Some(standalone_owner_id) = standalone_owner_id {
|
||
for (index, raw_key) in standalone_keys.iter().enumerate() {
|
||
let key = invalid!(imported_object_field(
|
||
raw_key,
|
||
&format!("standalone_keys[{index}]"),
|
||
));
|
||
invalid!(self.prevalidate_imported_auth_api_key(key, users_export_version, mode,));
|
||
let source_api_key_id = invalid!(imported_optional_string(key.get("api_key_id")));
|
||
let Some(key_material) = invalid!(self
|
||
.resolve_imported_system_user_api_key_material(
|
||
key,
|
||
users_export_version,
|
||
mode,
|
||
))
|
||
else {
|
||
continue;
|
||
};
|
||
let key_hash = key_material.key_hash;
|
||
if !imported_api_key_hashes.insert(key_hash.clone()) {
|
||
return Ok(Err(invalid_request("API Key 在导入文档中重复")));
|
||
}
|
||
let wallet = match key.get("wallet") {
|
||
Some(Value::Object(map)) => Some(map),
|
||
Some(Value::Null) | None => None,
|
||
Some(_) => return Ok(Err(invalid_request("wallet 必须是对象"))),
|
||
};
|
||
let unlimited =
|
||
invalid!(imported_optional_bool(key.get("unlimited"))).unwrap_or(false);
|
||
if let Some(wallet) = wallet {
|
||
invalid!(normalize_imported_wallet_target(Some(wallet), unlimited));
|
||
}
|
||
if !api_keys_by_hash.contains_key(&key_hash) {
|
||
if let Some(snapshot) = self
|
||
.app()
|
||
.data
|
||
.read_auth_api_key_snapshot_by_key_hash_strong(&key_hash, now_unix_secs)
|
||
.await
|
||
.map_err(|err| GatewayError::Internal(err.to_string()))?
|
||
{
|
||
api_keys_by_hash.insert(
|
||
key_hash.clone(),
|
||
SimulatedImportedApiKey {
|
||
owner_id: snapshot.user_id,
|
||
is_standalone: snapshot.api_key_is_standalone,
|
||
target_id: snapshot.api_key_id,
|
||
existed_before_import: true,
|
||
},
|
||
);
|
||
}
|
||
}
|
||
if let Some(existing_key) = api_keys_by_hash.get(&key_hash) {
|
||
if !existing_key.is_standalone {
|
||
return Ok(Err(invalid_request("独立余额 Key 已存在且属于普通用户")));
|
||
}
|
||
if merge_mode == AdminImportMergeMode::Error {
|
||
return Ok(Err(invalid_request("独立余额 Key 已存在")));
|
||
}
|
||
if merge_mode == AdminImportMergeMode::Overwrite {
|
||
if let Some(source_api_key_id) = source_api_key_id {
|
||
invalid!(insert_imported_id_mapping(
|
||
&mut imported_api_key_id_map,
|
||
source_api_key_id,
|
||
existing_key.target_id.clone(),
|
||
"api_key_id",
|
||
));
|
||
}
|
||
}
|
||
} else {
|
||
let target_id = format!("prevalidated-standalone-key-{index}");
|
||
if let Some(source_api_key_id) = source_api_key_id {
|
||
invalid!(insert_imported_id_mapping(
|
||
&mut imported_api_key_id_map,
|
||
source_api_key_id,
|
||
target_id.clone(),
|
||
"api_key_id",
|
||
));
|
||
}
|
||
api_keys_by_hash.insert(
|
||
key_hash,
|
||
SimulatedImportedApiKey {
|
||
owner_id: standalone_owner_id.clone(),
|
||
is_standalone: true,
|
||
target_id,
|
||
existed_before_import: false,
|
||
},
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
invalid!(validate_imported_usage_aggregate_dimensions(
|
||
&usage_aggregate_snapshot,
|
||
&imported_user_id_map,
|
||
&imported_api_key_id_map,
|
||
));
|
||
|
||
if merge_mode == AdminImportMergeMode::Error {
|
||
let persisted_user_target_ids = simulated_users_by_id
|
||
.values()
|
||
.filter(|user| user.existed_before_import)
|
||
.map(|user| user.id.clone())
|
||
.collect::<BTreeSet<_>>();
|
||
let persisted_api_key_target_ids = api_keys_by_hash
|
||
.values()
|
||
.filter(|key| key.existed_before_import)
|
||
.map(|key| key.target_id.clone())
|
||
.collect::<BTreeSet<_>>();
|
||
invalid!(
|
||
self.prevalidate_imported_usage_aggregate_conflicts(
|
||
&usage_aggregate_snapshot,
|
||
&imported_user_id_map,
|
||
&imported_api_key_id_map,
|
||
&persisted_user_target_ids,
|
||
&persisted_api_key_target_ids,
|
||
)
|
||
.await
|
||
);
|
||
}
|
||
|
||
Ok(Ok(()))
|
||
}
|
||
|
||
fn prevalidate_imported_auth_api_key(
|
||
&self,
|
||
key: &Map<String, Value>,
|
||
users_export_version: (u32, u32),
|
||
mode: SystemImportMode,
|
||
) -> Result<(), String> {
|
||
if self
|
||
.resolve_imported_system_user_api_key_material(key, users_export_version, mode)?
|
||
.is_none()
|
||
{
|
||
return Ok(());
|
||
}
|
||
imported_optional_string(key.get("api_key_id"))?;
|
||
imported_optional_string(key.get("name"))?;
|
||
normalize_imported_user_string_list(key, "allowed_providers")?;
|
||
normalize_imported_user_api_formats(key, "allowed_api_formats")?;
|
||
normalize_imported_user_string_list(key, "allowed_models")?;
|
||
normalize_imported_user_ip_rules(key)?;
|
||
imported_optional_i32(key.get("rate_limit"), "rate_limit")?;
|
||
let concurrent_limit =
|
||
imported_optional_i32(key.get("concurrent_limit"), "concurrent_limit")?;
|
||
if concurrent_limit.is_some_and(|value| value < 0) {
|
||
return Err("concurrent_limit 必须是非负整数".to_string());
|
||
}
|
||
imported_optional_bool(key.get("is_active"))?;
|
||
imported_rfc3339_to_unix_secs(key.get("expires_at"), "expires_at")?;
|
||
imported_optional_bool(key.get("auto_delete_on_expiry"))?;
|
||
if let Some(value) = imported_optional_u64(key.get("total_requests"), "total_requests")? {
|
||
validate_imported_u64_storage(value, "total_requests")?;
|
||
}
|
||
if let Some(value) = imported_optional_u64(key.get("total_tokens"), "total_tokens")? {
|
||
validate_imported_u64_storage(value, "total_tokens")?;
|
||
}
|
||
imported_optional_f64(key.get("total_cost_usd"), "total_cost_usd")?;
|
||
imported_optional_json_object(key.get("feature_settings"), "feature_settings")
|
||
.and_then(normalize_admin_feature_settings)?;
|
||
Ok(())
|
||
}
|
||
|
||
async fn prevalidate_imported_usage_aggregate_conflicts(
|
||
&self,
|
||
snapshot: &AdminSystemUsageAggregateSnapshot,
|
||
user_id_map: &BTreeMap<String, String>,
|
||
api_key_id_map: &BTreeMap<String, String>,
|
||
persisted_user_target_ids: &BTreeSet<String>,
|
||
persisted_api_key_target_ids: &BTreeSet<String>,
|
||
) -> Result<(), String> {
|
||
if snapshot.stats_daily.is_empty()
|
||
&& snapshot.stats_user_daily.is_empty()
|
||
&& snapshot.stats_daily_api_key.is_empty()
|
||
{
|
||
return Ok(());
|
||
}
|
||
|
||
if !self.app().data.has_backends() {
|
||
return Ok(());
|
||
}
|
||
|
||
let persisted_user_ids = user_id_map
|
||
.iter()
|
||
.filter(|(_, target_id)| persisted_user_target_ids.contains(*target_id))
|
||
.map(|(source_id, target_id)| (source_id.clone(), target_id.clone()))
|
||
.collect::<BTreeMap<_, _>>();
|
||
let persisted_api_key_ids = api_key_id_map
|
||
.iter()
|
||
.filter(|(_, target_id)| persisted_api_key_target_ids.contains(*target_id))
|
||
.map(|(source_id, target_id)| (source_id.clone(), target_id.clone()))
|
||
.collect::<BTreeMap<_, _>>();
|
||
|
||
match self
|
||
.import_admin_system_usage_aggregates(
|
||
snapshot,
|
||
&persisted_user_ids,
|
||
&persisted_api_key_ids,
|
||
AdminSystemUsageAggregateImportMode::ValidateError,
|
||
)
|
||
.await
|
||
{
|
||
Err(GatewayError::Client { message, .. }) => Err(message),
|
||
Err(_) => {
|
||
tracing::error!(
|
||
event_name = "admin_system_import_usage_prevalidation_error",
|
||
operation = "prevalidate_usage_aggregate_conflicts",
|
||
error_category = "repository_unavailable",
|
||
"admin system import usage prevalidation failed"
|
||
);
|
||
Err("Usage aggregate data temporarily unavailable".to_string())
|
||
}
|
||
Ok(_) => Ok(()),
|
||
}
|
||
}
|
||
|
||
pub(crate) async fn import_admin_system_data(
|
||
&self,
|
||
request_body: &Bytes,
|
||
operator_id: Option<&str>,
|
||
) -> Result<Result<Value, (http::StatusCode, Value)>, GatewayError> {
|
||
self.import_admin_system_data_with_mode(
|
||
request_body,
|
||
operator_id,
|
||
SystemImportMode::InteractiveUpload,
|
||
)
|
||
.await
|
||
}
|
||
|
||
pub(crate) async fn restore_admin_system_data_backup(
|
||
&self,
|
||
request_body: &Bytes,
|
||
operator_id: Option<&str>,
|
||
_authority: crate::backup::executor::BackupRestoreAuthority,
|
||
) -> Result<Result<Value, (http::StatusCode, Value)>, GatewayError> {
|
||
self.import_admin_system_data_with_mode(
|
||
request_body,
|
||
operator_id,
|
||
SystemImportMode::RecoveryBackup,
|
||
)
|
||
.await
|
||
}
|
||
|
||
async fn import_admin_system_data_with_mode(
|
||
&self,
|
||
request_body: &Bytes,
|
||
operator_id: Option<&str>,
|
||
mode: SystemImportMode,
|
||
) -> Result<Result<Value, (http::StatusCode, Value)>, GatewayError> {
|
||
if !self.has_global_model_data_reader()
|
||
|| !self.has_global_model_data_writer()
|
||
|| !self.has_provider_catalog_data_reader()
|
||
|| !self.has_provider_catalog_data_writer()
|
||
|| !self.has_auth_user_write_capability()
|
||
|| !self.has_auth_wallet_write_capability()
|
||
|| !self.has_auth_api_key_writer()
|
||
{
|
||
return Ok(Err((
|
||
http::StatusCode::SERVICE_UNAVAILABLE,
|
||
json!({ "detail": "Admin system data unavailable" }),
|
||
)));
|
||
}
|
||
|
||
let root = match serde_json::from_slice::<Value>(request_body) {
|
||
Ok(Value::Object(map)) => map,
|
||
_ => return Ok(Err(invalid_request("请求数据验证失败"))),
|
||
};
|
||
|
||
let version = root
|
||
.get("version")
|
||
.and_then(Value::as_str)
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.ok_or_else(|| invalid_request("version 为必填字段"));
|
||
let version = match version {
|
||
Ok(value) => value,
|
||
Err(err) => return Ok(Err(err)),
|
||
};
|
||
if version != ADMIN_SYSTEM_DATA_EXPORT_VERSION {
|
||
return Ok(Err(invalid_request(format!(
|
||
"不支持的聚合数据版本: {version},支持的版本: {ADMIN_SYSTEM_DATA_EXPORT_VERSION}"
|
||
))));
|
||
}
|
||
|
||
let merge_mode = match serde_json::from_value::<AdminImportMergeMode>(
|
||
root.get("merge_mode").cloned().unwrap_or(Value::Null),
|
||
) {
|
||
Ok(value) => value,
|
||
Err(_) => {
|
||
return Ok(Err(invalid_request(
|
||
"merge_mode 仅支持 skip / overwrite / error",
|
||
)))
|
||
}
|
||
};
|
||
|
||
let config_body =
|
||
match build_admin_system_data_import_part_body(&root, "config_data", merge_mode) {
|
||
Ok(value) => value,
|
||
Err(err) => return Ok(Err(err)),
|
||
};
|
||
let users_body =
|
||
match build_admin_system_data_import_part_body(&root, "user_data", merge_mode) {
|
||
Ok(value) => value,
|
||
Err(err) => return Ok(Err(err)),
|
||
};
|
||
|
||
match self
|
||
.prevalidate_admin_system_config_import(&config_body, mode)
|
||
.await?
|
||
{
|
||
Ok(()) => {}
|
||
Err(err) => return Ok(Err(err)),
|
||
}
|
||
match self
|
||
.prevalidate_admin_system_users_import(&users_body, operator_id, mode)
|
||
.await?
|
||
{
|
||
Ok(()) => {}
|
||
Err(err) => return Ok(Err(err)),
|
||
}
|
||
|
||
// The config and users repositories are intentionally exposed as independent write
|
||
// handles, so this aggregate operation cannot share a database transaction across all
|
||
// supported drivers. Capture both sides immediately before the first write. Interactive
|
||
// imports use a redacted checkpoint. Recovery restores are authorized to
|
||
// hold a credential-bearing checkpoint briefly in memory; otherwise a failed restore
|
||
// could not put an overwritten secret back. The import lock held by the route serializes
|
||
// other aggregate imports while this checkpoint is being applied.
|
||
let checkpoint_export_mode = if mode == SystemImportMode::RecoveryBackup {
|
||
SystemExportMode::RecoveryBackup
|
||
} else {
|
||
SystemExportMode::RollbackCheckpoint
|
||
};
|
||
let rollback_mode = if mode == SystemImportMode::RecoveryBackup {
|
||
SystemImportMode::RecoveryRollbackCheckpoint
|
||
} else {
|
||
SystemImportMode::RollbackCheckpoint
|
||
};
|
||
let config_checkpoint = self
|
||
.build_admin_system_config_export_payload(checkpoint_export_mode)
|
||
.await?;
|
||
let users_checkpoint = self
|
||
.build_admin_system_users_export_payload(checkpoint_export_mode)
|
||
.await?;
|
||
|
||
let mut mutation_journal = AggregateMutationJournal::default();
|
||
let config_result = match self
|
||
.import_admin_system_config_with_mode(&config_body, mode, Some(&mut mutation_journal))
|
||
.await
|
||
{
|
||
Ok(Ok(payload)) => payload,
|
||
Ok(Err(original)) => {
|
||
match self
|
||
.rollback_aggregate_config(&config_checkpoint, rollback_mode, &mutation_journal)
|
||
.await
|
||
{
|
||
Ok(()) => return Ok(Err(original)),
|
||
Err(rollback_error) => {
|
||
return Err(aggregate_rollback_http_error(
|
||
"配置阶段",
|
||
&original,
|
||
rollback_error,
|
||
));
|
||
}
|
||
}
|
||
}
|
||
Err(error) => {
|
||
let original_error = error.clone();
|
||
self.rollback_aggregate_config(
|
||
&config_checkpoint,
|
||
rollback_mode,
|
||
&mutation_journal,
|
||
)
|
||
.await
|
||
.map_err(|rollback_error| {
|
||
aggregate_rollback_error("配置阶段", original_error, rollback_error)
|
||
})?;
|
||
return Err(error);
|
||
}
|
||
};
|
||
|
||
let users_result = match self
|
||
.import_admin_system_users_with_mode(
|
||
&users_body,
|
||
operator_id,
|
||
mode,
|
||
Some(&mut mutation_journal),
|
||
)
|
||
.await
|
||
{
|
||
Ok(Ok(payload)) => payload,
|
||
Ok(Err(original)) => {
|
||
match self
|
||
.rollback_aggregate_import(
|
||
&config_checkpoint,
|
||
&users_checkpoint,
|
||
operator_id,
|
||
rollback_mode,
|
||
&mutation_journal,
|
||
)
|
||
.await
|
||
{
|
||
Ok(()) => return Ok(Err(original)),
|
||
Err(rollback_error) => {
|
||
return Err(aggregate_rollback_http_error(
|
||
"用户阶段",
|
||
&original,
|
||
rollback_error,
|
||
));
|
||
}
|
||
}
|
||
}
|
||
Err(error) => {
|
||
let original_error = error.clone();
|
||
self.rollback_aggregate_import(
|
||
&config_checkpoint,
|
||
&users_checkpoint,
|
||
operator_id,
|
||
rollback_mode,
|
||
&mutation_journal,
|
||
)
|
||
.await
|
||
.map_err(|rollback_error| {
|
||
aggregate_rollback_error("用户阶段", original_error, rollback_error)
|
||
})?;
|
||
return Err(error);
|
||
}
|
||
};
|
||
|
||
Ok(Ok(json!({
|
||
"message": "聚合数据导入成功",
|
||
"config": config_result,
|
||
"users": users_result,
|
||
})))
|
||
}
|
||
|
||
/// Restore a config checkpoint using overwrite semantics. A redacted checkpoint preserves
|
||
/// existing encrypted values; a recovery checkpoint carries the original credentials.
|
||
async fn rollback_aggregate_config(
|
||
&self,
|
||
checkpoint: &Value,
|
||
rollback_mode: SystemImportMode,
|
||
mutation_journal: &AggregateMutationJournal,
|
||
) -> Result<(), GatewayError> {
|
||
let cleanup_result = self.rollback_created_config(mutation_journal).await;
|
||
let body = build_aggregate_rollback_body_with_options(
|
||
checkpoint,
|
||
true,
|
||
cleanup_result.skip_ldap_restore,
|
||
)?;
|
||
let restore_result = match self
|
||
.import_admin_system_config_with_mode(&body, rollback_mode, None)
|
||
.await
|
||
{
|
||
Ok(Ok(_)) => Ok(()),
|
||
Ok(Err(_)) => Err(GatewayError::Internal(
|
||
"aggregate config rollback rejected".to_string(),
|
||
)),
|
||
Err(error) => Err(error),
|
||
};
|
||
combine_rollback_results(cleanup_result.result, restore_result, "config")
|
||
}
|
||
|
||
async fn rollback_created_config(
|
||
&self,
|
||
journal: &AggregateMutationJournal,
|
||
) -> ConfigCleanupOutcome {
|
||
let mut failures = Vec::new();
|
||
let mut skip_ldap_restore = false;
|
||
|
||
if let Some(expected) = journal.created_ldap_config.as_ref() {
|
||
match self.delete_ldap_module_config_if_matches(expected).await {
|
||
Ok(true) => {}
|
||
Ok(false) => {
|
||
// Even if the follow-up read observes no row, another writer can create one
|
||
// between that read and the checkpoint restore. Skip LDAP restoration for
|
||
// every non-successful compare/delete result to avoid a TOCTOU overwrite.
|
||
skip_ldap_restore = true;
|
||
// A missing row means another cleanup attempt already removed it. If a row
|
||
// remains, however, it was changed concurrently and must not be deleted by
|
||
// an owner-blind rollback.
|
||
match self.get_ldap_module_config().await {
|
||
Ok(None) => {}
|
||
Ok(Some(_)) => failures.push(GatewayError::Internal(
|
||
"LDAP configuration changed during aggregate rollback".to_string(),
|
||
)),
|
||
Err(error) => failures.push(error),
|
||
}
|
||
}
|
||
Err(error) => {
|
||
skip_ldap_restore = true;
|
||
failures.push(error);
|
||
}
|
||
}
|
||
}
|
||
|
||
for (provider_id, model_id) in &journal.provider_model_ids {
|
||
if let Err(error) = self
|
||
.delete_admin_provider_model(provider_id, model_id)
|
||
.await
|
||
{
|
||
failures.push(error);
|
||
}
|
||
}
|
||
for (_, key_id) in &journal.provider_key_ids {
|
||
if let Err(error) = self.delete_provider_catalog_key(key_id).await {
|
||
failures.push(error);
|
||
}
|
||
}
|
||
for (_, endpoint_id) in &journal.provider_endpoint_ids {
|
||
if let Err(error) = self.delete_provider_catalog_endpoint(endpoint_id).await {
|
||
failures.push(error);
|
||
}
|
||
}
|
||
for provider_id in &journal.provider_ids {
|
||
let endpoint_ids = journal
|
||
.provider_endpoint_ids
|
||
.iter()
|
||
.filter(|(owner_id, _)| owner_id == provider_id)
|
||
.map(|(_, id)| id.clone())
|
||
.collect::<Vec<_>>();
|
||
let key_ids = journal
|
||
.provider_key_ids
|
||
.iter()
|
||
.filter(|(owner_id, _)| owner_id == provider_id)
|
||
.map(|(_, id)| id.clone())
|
||
.collect::<Vec<_>>();
|
||
if let Err(error) = self
|
||
.cleanup_deleted_provider_catalog_refs(provider_id, true, &endpoint_ids, &key_ids)
|
||
.await
|
||
{
|
||
failures.push(error);
|
||
}
|
||
if let Err(error) = self
|
||
.app()
|
||
.delete_provider_catalog_provider(provider_id)
|
||
.await
|
||
{
|
||
failures.push(error);
|
||
}
|
||
}
|
||
for global_model_id in &journal.global_model_ids {
|
||
if let Err(error) = self.delete_admin_global_model(global_model_id).await {
|
||
failures.push(error);
|
||
}
|
||
}
|
||
for provider_type in &journal.oauth_provider_types {
|
||
match self
|
||
.delete_oauth_provider_config_if_unlinked(provider_type)
|
||
.await
|
||
{
|
||
Ok(_) => {}
|
||
Err(error) => failures.push(error),
|
||
}
|
||
}
|
||
for key in &journal.system_config_keys {
|
||
if let Err(error) = self.delete_system_config_value(key).await {
|
||
failures.push(error);
|
||
}
|
||
}
|
||
|
||
let result = if failures.is_empty() {
|
||
Ok(())
|
||
} else {
|
||
Err(GatewayError::Internal(format!(
|
||
"aggregate config mutation cleanup failed for {} object(s)",
|
||
failures.len()
|
||
)))
|
||
};
|
||
ConfigCleanupOutcome {
|
||
result,
|
||
skip_ldap_restore,
|
||
}
|
||
}
|
||
|
||
async fn rollback_created_users(
|
||
&self,
|
||
journal: &AggregateMutationJournal,
|
||
) -> Result<(), GatewayError> {
|
||
let mut failures = Vec::new();
|
||
let mut blocked_user_ids = BTreeSet::new();
|
||
let mut blocked_api_key_ids = BTreeSet::new();
|
||
|
||
// Remove wallets before their owning API keys/users. The owner predicate is part of the
|
||
// delete operation, so a wallet cannot be detached and accidentally reclaimed by another
|
||
// object between the journal lookup and compensation.
|
||
for ((user_id, wallet_id), expected) in &journal.user_wallet_snapshots {
|
||
match self
|
||
.delete_wallet_if_snapshot_matches_and_unreferenced(
|
||
expected,
|
||
WalletLookupKey::UserId(user_id.as_str()),
|
||
)
|
||
.await
|
||
{
|
||
Ok(true) => {}
|
||
Ok(false) => {
|
||
blocked_user_ids.insert(user_id.clone());
|
||
failures.push(GatewayError::Internal(format!(
|
||
"import rollback could not delete wallet {wallet_id} for user {user_id}"
|
||
)));
|
||
}
|
||
Err(error) => {
|
||
blocked_user_ids.insert(user_id.clone());
|
||
failures.push(error);
|
||
}
|
||
}
|
||
}
|
||
for ((api_key_id, wallet_id), expected) in &journal.api_key_wallet_snapshots {
|
||
match self
|
||
.delete_wallet_if_snapshot_matches_and_unreferenced(
|
||
expected,
|
||
WalletLookupKey::ApiKeyId(api_key_id.as_str()),
|
||
)
|
||
.await
|
||
{
|
||
Ok(true) => {}
|
||
Ok(false) => {
|
||
blocked_api_key_ids.insert(api_key_id.clone());
|
||
if let Some((user_id, _)) = journal
|
||
.user_api_key_ids
|
||
.iter()
|
||
.find(|(_, candidate_api_key_id)| candidate_api_key_id == api_key_id)
|
||
{
|
||
// A failed API-key-wallet compensation also blocks
|
||
// deleting the owning user. Otherwise the later
|
||
// user rollback can remove the key while leaving its
|
||
// funded wallet orphaned.
|
||
blocked_user_ids.insert(user_id.clone());
|
||
}
|
||
failures.push(GatewayError::Internal(format!(
|
||
"import rollback could not delete wallet {wallet_id} for API key {api_key_id}"
|
||
)));
|
||
}
|
||
Err(error) => {
|
||
blocked_api_key_ids.insert(api_key_id.clone());
|
||
if let Some((user_id, _)) = journal
|
||
.user_api_key_ids
|
||
.iter()
|
||
.find(|(_, candidate_api_key_id)| candidate_api_key_id == api_key_id)
|
||
{
|
||
blocked_user_ids.insert(user_id.clone());
|
||
}
|
||
failures.push(error);
|
||
}
|
||
}
|
||
}
|
||
for (user_id, api_key_id) in &journal.user_api_key_ids {
|
||
if blocked_user_ids.contains(user_id) || blocked_api_key_ids.contains(api_key_id) {
|
||
continue;
|
||
}
|
||
if let Err(error) = self.delete_user_api_key(user_id, api_key_id).await {
|
||
failures.push(error);
|
||
}
|
||
}
|
||
for api_key_id in &journal.standalone_api_key_ids {
|
||
if blocked_api_key_ids.contains(api_key_id) {
|
||
continue;
|
||
}
|
||
if let Err(error) = self.delete_standalone_api_key(api_key_id).await {
|
||
failures.push(error);
|
||
}
|
||
}
|
||
for user_id in &journal.user_ids {
|
||
if blocked_user_ids.contains(user_id) {
|
||
continue;
|
||
}
|
||
if let Err(error) = self
|
||
.app()
|
||
.rollback_provisional_auth_user_with_wallet(user_id, None)
|
||
.await
|
||
{
|
||
failures.push(error);
|
||
}
|
||
}
|
||
for group_id in &journal.user_group_ids {
|
||
if let Err(error) = self.delete_user_group(group_id).await {
|
||
failures.push(error);
|
||
}
|
||
}
|
||
|
||
if failures.is_empty() {
|
||
Ok(())
|
||
} else {
|
||
Err(GatewayError::Internal(format!(
|
||
"aggregate user mutation cleanup failed for {} object(s)",
|
||
failures.len()
|
||
)))
|
||
}
|
||
}
|
||
|
||
async fn rollback_existing_wallets(
|
||
&self,
|
||
journal: &AggregateMutationJournal,
|
||
) -> Result<(), GatewayError> {
|
||
let mut failures = Vec::new();
|
||
|
||
for ((user_id, wallet_id), mutation) in &journal.existing_user_wallets {
|
||
let Some(after) = mutation.after.as_ref() else {
|
||
failures.push(GatewayError::Internal(format!(
|
||
"import rollback has no verified post-state for existing user wallet {wallet_id} ({user_id})"
|
||
)));
|
||
continue;
|
||
};
|
||
match self
|
||
.restore_wallet_if_snapshot_matches(
|
||
&mutation.before,
|
||
after,
|
||
WalletLookupKey::UserId(user_id.as_str()),
|
||
)
|
||
.await
|
||
{
|
||
Ok(true) => {}
|
||
Ok(false) => failures.push(GatewayError::Internal(format!(
|
||
"import rollback wallet CAS conflict for user {user_id}, wallet {wallet_id}"
|
||
))),
|
||
Err(error) => failures.push(error),
|
||
}
|
||
}
|
||
|
||
for ((api_key_id, wallet_id), mutation) in &journal.existing_api_key_wallets {
|
||
let Some(after) = mutation.after.as_ref() else {
|
||
failures.push(GatewayError::Internal(format!(
|
||
"import rollback has no verified post-state for existing API-key wallet {wallet_id} ({api_key_id})"
|
||
)));
|
||
continue;
|
||
};
|
||
match self
|
||
.restore_wallet_if_snapshot_matches(
|
||
&mutation.before,
|
||
after,
|
||
WalletLookupKey::ApiKeyId(api_key_id.as_str()),
|
||
)
|
||
.await
|
||
{
|
||
Ok(true) => {}
|
||
Ok(false) => failures.push(GatewayError::Internal(format!(
|
||
"import rollback wallet CAS conflict for API key {api_key_id}, wallet {wallet_id}"
|
||
))),
|
||
Err(error) => failures.push(error),
|
||
}
|
||
}
|
||
|
||
if failures.is_empty() {
|
||
Ok(())
|
||
} else {
|
||
Err(GatewayError::Internal(format!(
|
||
"aggregate existing wallet rollback failed for {} object(s)",
|
||
failures.len()
|
||
)))
|
||
}
|
||
}
|
||
|
||
async fn capture_existing_user_mutation(
|
||
&self,
|
||
journal: &mut AggregateMutationJournal,
|
||
user: &aether_data::repository::users::StoredUserAuthRecord,
|
||
) -> Result<(), GatewayError> {
|
||
if journal.existing_users.contains_key(&user.id) {
|
||
return Ok(());
|
||
}
|
||
let mut before_export = self.find_export_user_by_id(&user.id).await?;
|
||
let before_model_capability_settings = self
|
||
.app()
|
||
.read_user_model_capability_settings(&user.id)
|
||
.await?;
|
||
let before_feature_settings = before_export
|
||
.as_ref()
|
||
.and_then(|record| record.feature_settings.clone())
|
||
.or(self.app().read_user_feature_settings(&user.id).await?);
|
||
#[cfg(test)]
|
||
if before_export.is_none() {
|
||
before_export = Some(synthetic_rollback_export_row(
|
||
user,
|
||
before_model_capability_settings.clone(),
|
||
before_feature_settings.clone(),
|
||
)?);
|
||
}
|
||
let mut before_group_ids = self
|
||
.list_user_groups_for_user(&user.id)
|
||
.await?
|
||
.into_iter()
|
||
.map(|group| group.id)
|
||
.collect::<Vec<_>>();
|
||
before_group_ids.sort();
|
||
before_group_ids.dedup();
|
||
// A role/active-state update revokes every key owned by the user. Capture those keys before
|
||
// the first user write so a later import failure can restore them through their own CAS
|
||
// path instead of leaving an unrelated key permanently disabled.
|
||
let existing_api_keys = self
|
||
.list_auth_api_key_export_records_by_user_ids(std::slice::from_ref(&user.id))
|
||
.await?;
|
||
for record in existing_api_keys {
|
||
if record.is_standalone || record.user_id != user.id {
|
||
continue;
|
||
}
|
||
let key = (user.id.clone(), record.api_key_id.clone());
|
||
journal
|
||
.existing_user_api_keys
|
||
.entry(key)
|
||
.or_insert_with(|| ExistingApiKeyMutation {
|
||
before: record.clone(),
|
||
after: record,
|
||
});
|
||
}
|
||
journal.existing_users.insert(
|
||
user.id.clone(),
|
||
ExistingUserMutation {
|
||
before_auth: user.clone(),
|
||
after_auth: user.clone(),
|
||
before_export: before_export.clone(),
|
||
after_export: before_export,
|
||
before_model_capability_settings: before_model_capability_settings.clone(),
|
||
after_model_capability_settings: before_model_capability_settings,
|
||
before_feature_settings: before_feature_settings.clone(),
|
||
after_feature_settings: before_feature_settings,
|
||
before_group_ids: before_group_ids.clone(),
|
||
after_group_ids: before_group_ids,
|
||
},
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
/// Refresh a journal entry after each successful user mutation. Keeping the latest
|
||
/// post-state lets compensation recover even when a later setter in the same user fails.
|
||
async fn refresh_existing_user_mutation(
|
||
&self,
|
||
mutation_journal: Option<&mut AggregateMutationJournal>,
|
||
user_id: &str,
|
||
) -> Result<(), GatewayError> {
|
||
let Some(journal) = mutation_journal else {
|
||
return Ok(());
|
||
};
|
||
if !journal.existing_users.contains_key(user_id) {
|
||
return Ok(());
|
||
}
|
||
let Some(auth) = self.find_user_auth_by_id(user_id).await? else {
|
||
return Ok(());
|
||
};
|
||
let security_state_changed = journal.existing_users.get(user_id).is_some_and(|mutation| {
|
||
mutation.after_auth.role != auth.role || mutation.after_auth.is_active != auth.is_active
|
||
});
|
||
let model_capability_settings = self
|
||
.app()
|
||
.read_user_model_capability_settings(user_id)
|
||
.await?;
|
||
let mut export = self.find_export_user_by_id(user_id).await?;
|
||
let feature_settings = export
|
||
.as_ref()
|
||
.and_then(|record| record.feature_settings.clone())
|
||
.or(self.app().read_user_feature_settings(user_id).await?);
|
||
#[cfg(test)]
|
||
if export.is_none() {
|
||
export = Some(synthetic_rollback_export_row(
|
||
&auth,
|
||
model_capability_settings.clone(),
|
||
feature_settings.clone(),
|
||
)?);
|
||
}
|
||
let mut group_ids = self
|
||
.list_user_groups_for_user(user_id)
|
||
.await?
|
||
.into_iter()
|
||
.map(|group| group.id)
|
||
.collect::<Vec<_>>();
|
||
group_ids.sort();
|
||
group_ids.dedup();
|
||
if let Some(mutation) = journal.existing_users.get_mut(user_id) {
|
||
mutation.after_auth = auth;
|
||
mutation.after_export = export;
|
||
mutation.after_model_capability_settings = model_capability_settings;
|
||
mutation.after_feature_settings = feature_settings;
|
||
mutation.after_group_ids = group_ids;
|
||
}
|
||
if security_state_changed {
|
||
// The user CAS revokes all active API keys in the same database transaction. Refresh
|
||
// the post-state of every pre-captured key so the later key CAS can undo that exact
|
||
// revocation while still refusing any key changed by another writer.
|
||
for record in self
|
||
.list_auth_api_key_export_records_by_user_ids(&[user_id.to_string()])
|
||
.await?
|
||
{
|
||
if record.is_standalone || record.user_id != user_id {
|
||
continue;
|
||
}
|
||
if let Some(key_mutation) = journal
|
||
.existing_user_api_keys
|
||
.get_mut(&(user_id.to_string(), record.api_key_id.clone()))
|
||
{
|
||
key_mutation.after = record;
|
||
}
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
async fn refresh_existing_api_key_mutation(
|
||
&self,
|
||
mutation_journal: Option<&mut AggregateMutationJournal>,
|
||
user_id: Option<&str>,
|
||
api_key_id: &str,
|
||
standalone: bool,
|
||
) -> Result<(), GatewayError> {
|
||
let Some(journal) = mutation_journal else {
|
||
return Ok(());
|
||
};
|
||
let record = if standalone {
|
||
self.find_auth_api_key_export_standalone_record_by_id(api_key_id)
|
||
.await?
|
||
} else {
|
||
self.list_auth_api_key_export_records_by_ids(&[api_key_id.to_string()])
|
||
.await?
|
||
.into_iter()
|
||
.find(|record| {
|
||
!record.is_standalone
|
||
&& user_id.is_none_or(|expected| record.user_id == expected)
|
||
})
|
||
};
|
||
let Some(record) = record else {
|
||
return Ok(());
|
||
};
|
||
if standalone {
|
||
if let Some(mutation) = journal.existing_standalone_api_keys.get_mut(api_key_id) {
|
||
mutation.after = record;
|
||
}
|
||
} else if let Some(user_id) = user_id {
|
||
if let Some(mutation) = journal
|
||
.existing_user_api_keys
|
||
.get_mut(&(user_id.to_string(), api_key_id.to_string()))
|
||
{
|
||
mutation.after = record;
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
async fn rollback_existing_user_groups(
|
||
&self,
|
||
journal: &AggregateMutationJournal,
|
||
) -> Result<(), GatewayError> {
|
||
let mut failures = Vec::new();
|
||
for (group_id, mutation) in &journal.existing_user_groups {
|
||
if mutation.before == mutation.after {
|
||
continue;
|
||
}
|
||
match self
|
||
.restore_user_group_if_matches(&mutation.after, &mutation.before)
|
||
.await
|
||
{
|
||
Ok(true) => {}
|
||
Ok(false) => {
|
||
tracing::warn!(
|
||
group_id,
|
||
"skipping missing or concurrently changed user group rollback"
|
||
);
|
||
}
|
||
Err(error) => failures.push(error),
|
||
}
|
||
}
|
||
if failures.is_empty() {
|
||
Ok(())
|
||
} else {
|
||
Err(GatewayError::Internal(format!(
|
||
"existing user group rollback failed for {} object(s)",
|
||
failures.len()
|
||
)))
|
||
}
|
||
}
|
||
|
||
async fn rollback_existing_users(
|
||
&self,
|
||
journal: &AggregateMutationJournal,
|
||
) -> Result<(), GatewayError> {
|
||
let mut failures = Vec::new();
|
||
for (user_id, mutation) in &journal.existing_users {
|
||
let before = &mutation.before_auth;
|
||
let after = &mutation.after_auth;
|
||
// Passwords are intentionally excluded from the aggregate CAS because a nullable
|
||
// password hash has its own compare-and-write operation below.
|
||
let auth_state_changed = !before.matches_restore_state(after);
|
||
let export_state_changed = match (&mutation.before_export, &mutation.after_export) {
|
||
(Some(before_export), Some(after_export)) => {
|
||
!before_export.matches_restore_state(after_export)
|
||
|| before_export.rate_limit != after_export.rate_limit
|
||
|| before_export.rate_limit_mode != after_export.rate_limit_mode
|
||
}
|
||
(None, None) => false,
|
||
_ => true,
|
||
};
|
||
let model_settings_changed = mutation.before_model_capability_settings
|
||
!= mutation.after_model_capability_settings;
|
||
let feature_settings_changed =
|
||
mutation.before_feature_settings != mutation.after_feature_settings;
|
||
|
||
if auth_state_changed
|
||
|| export_state_changed
|
||
|| model_settings_changed
|
||
|| feature_settings_changed
|
||
{
|
||
let restore_result = match (&mutation.after_export, &mutation.before_export) {
|
||
(Some(expected_export), Some(restored_export)) => {
|
||
self.restore_local_auth_user_state_if_matches(
|
||
after,
|
||
before,
|
||
expected_export,
|
||
restored_export,
|
||
mutation.after_model_capability_settings.as_ref(),
|
||
mutation.before_model_capability_settings.clone(),
|
||
mutation.after_feature_settings.as_ref(),
|
||
mutation.before_feature_settings.clone(),
|
||
)
|
||
.await
|
||
}
|
||
_ => {
|
||
tracing::warn!(
|
||
user_id,
|
||
"skipping user state rollback because export snapshot is unavailable"
|
||
);
|
||
Ok(false)
|
||
}
|
||
};
|
||
match restore_result {
|
||
Ok(true) => {}
|
||
Ok(false) => {
|
||
tracing::warn!(
|
||
user_id,
|
||
"skipping concurrently changed user state rollback"
|
||
);
|
||
}
|
||
Err(error) => failures.push(error),
|
||
}
|
||
}
|
||
|
||
if before.password_hash != after.password_hash {
|
||
match self
|
||
.restore_local_auth_user_password_hash_if_matches(
|
||
user_id,
|
||
after.password_hash.as_deref(),
|
||
before.password_hash.clone(),
|
||
chrono::Utc::now(),
|
||
)
|
||
.await
|
||
{
|
||
Ok(true) => {}
|
||
Ok(false) => {
|
||
tracing::warn!(user_id, "skipping concurrently changed user password");
|
||
}
|
||
Err(error) => failures.push(error),
|
||
}
|
||
}
|
||
if mutation.before_group_ids != mutation.after_group_ids {
|
||
match self
|
||
.restore_user_groups_if_matches(
|
||
user_id,
|
||
&mutation.after_group_ids,
|
||
&mutation.before_group_ids,
|
||
)
|
||
.await
|
||
{
|
||
Ok(true) => {}
|
||
Ok(false) => {
|
||
tracing::warn!(
|
||
user_id,
|
||
"skipping concurrently changed user groups rollback"
|
||
);
|
||
}
|
||
Err(error) => failures.push(error),
|
||
}
|
||
}
|
||
}
|
||
if failures.is_empty() {
|
||
Ok(())
|
||
} else {
|
||
Err(GatewayError::Internal(format!(
|
||
"existing user rollback failed for {} object(s)",
|
||
failures.len()
|
||
)))
|
||
}
|
||
}
|
||
|
||
async fn rollback_existing_api_keys(
|
||
&self,
|
||
journal: &AggregateMutationJournal,
|
||
) -> Result<(), GatewayError> {
|
||
let mut failures = Vec::new();
|
||
for ((user_id, api_key_id), mutation) in &journal.existing_user_api_keys {
|
||
self.rollback_one_existing_api_key(
|
||
Some(user_id),
|
||
api_key_id,
|
||
false,
|
||
mutation,
|
||
&mut failures,
|
||
)
|
||
.await?;
|
||
}
|
||
for (api_key_id, mutation) in &journal.existing_standalone_api_keys {
|
||
self.rollback_one_existing_api_key(None, api_key_id, true, mutation, &mut failures)
|
||
.await?;
|
||
}
|
||
if failures.is_empty() {
|
||
Ok(())
|
||
} else {
|
||
Err(GatewayError::Internal(format!(
|
||
"existing API key rollback failed for {} object(s)",
|
||
failures.len()
|
||
)))
|
||
}
|
||
}
|
||
|
||
async fn rollback_one_existing_api_key(
|
||
&self,
|
||
user_id: Option<&str>,
|
||
api_key_id: &str,
|
||
standalone: bool,
|
||
mutation: &ExistingApiKeyMutation,
|
||
failures: &mut Vec<GatewayError>,
|
||
) -> Result<(), GatewayError> {
|
||
let _ = (user_id, standalone);
|
||
if mutation.before == mutation.after {
|
||
return Ok(());
|
||
}
|
||
|
||
match self
|
||
.restore_api_key_if_matches(&mutation.after, &mutation.before)
|
||
.await
|
||
{
|
||
Ok(true) => {}
|
||
Ok(false) => {
|
||
tracing::warn!(
|
||
api_key_id,
|
||
"skipping API key rollback after deletion, identity, or concurrent-state conflict"
|
||
);
|
||
}
|
||
Err(error) => failures.push(error),
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Compensate only objects touched by this import, then restore config. Replaying the whole
|
||
/// user checkpoint is intentionally avoided because it could overwrite a concurrent admin
|
||
/// change made after the import wrote a row.
|
||
async fn rollback_aggregate_import(
|
||
&self,
|
||
config_checkpoint: &Value,
|
||
_users_checkpoint: &Value,
|
||
_operator_id: Option<&str>,
|
||
rollback_mode: SystemImportMode,
|
||
mutation_journal: &AggregateMutationJournal,
|
||
) -> Result<(), GatewayError> {
|
||
let cleanup_result = self.rollback_created_users(mutation_journal).await;
|
||
let existing_wallet_result = self.rollback_existing_wallets(mutation_journal).await;
|
||
let existing_users_result = self.rollback_existing_users(mutation_journal).await;
|
||
let existing_groups_result = self.rollback_existing_user_groups(mutation_journal).await;
|
||
let existing_api_keys_result = self.rollback_existing_api_keys(mutation_journal).await;
|
||
let config_result = self
|
||
.rollback_aggregate_config(config_checkpoint, rollback_mode, mutation_journal)
|
||
.await;
|
||
let restore_result = combine_rollback_results(
|
||
existing_users_result,
|
||
existing_groups_result,
|
||
"aggregate existing users/groups",
|
||
);
|
||
let restore_result = combine_rollback_results(
|
||
existing_api_keys_result,
|
||
restore_result,
|
||
"aggregate existing API keys",
|
||
);
|
||
let restore_result = combine_rollback_results(restore_result, config_result, "aggregate");
|
||
let restore_result =
|
||
combine_rollback_results(existing_wallet_result, restore_result, "aggregate wallets");
|
||
combine_rollback_results(cleanup_result, restore_result, "aggregate")
|
||
}
|
||
|
||
pub(crate) async fn import_admin_system_config(
|
||
&self,
|
||
request_body: &Bytes,
|
||
) -> Result<Result<Value, (http::StatusCode, Value)>, GatewayError> {
|
||
self.import_admin_system_config_with_mode(
|
||
request_body,
|
||
SystemImportMode::InteractiveUpload,
|
||
None,
|
||
)
|
||
.await
|
||
}
|
||
|
||
pub(crate) async fn restore_admin_system_config_backup(
|
||
&self,
|
||
request_body: &Bytes,
|
||
_authority: crate::backup::executor::BackupRestoreAuthority,
|
||
) -> Result<Result<Value, (http::StatusCode, Value)>, GatewayError> {
|
||
self.import_admin_system_config_with_mode(
|
||
request_body,
|
||
SystemImportMode::RecoveryBackup,
|
||
None,
|
||
)
|
||
.await
|
||
}
|
||
|
||
async fn import_admin_system_config_with_mode(
|
||
&self,
|
||
request_body: &Bytes,
|
||
mode: SystemImportMode,
|
||
mut mutation_journal: Option<&mut AggregateMutationJournal>,
|
||
) -> Result<Result<Value, (http::StatusCode, Value)>, GatewayError> {
|
||
macro_rules! invalid {
|
||
($expr:expr) => {
|
||
match $expr {
|
||
Ok(value) => value,
|
||
Err(detail) => return Ok(Err(invalid_request(detail))),
|
||
}
|
||
};
|
||
}
|
||
macro_rules! routed {
|
||
($expr:expr) => {
|
||
match $expr {
|
||
Ok(value) => value,
|
||
Err(err) => return Ok(Err(err)),
|
||
}
|
||
};
|
||
}
|
||
|
||
if !self.has_global_model_data_reader()
|
||
|| !self.has_global_model_data_writer()
|
||
|| !self.has_provider_catalog_data_reader()
|
||
|| !self.has_provider_catalog_data_writer()
|
||
{
|
||
return Ok(Err((
|
||
http::StatusCode::SERVICE_UNAVAILABLE,
|
||
json!({ "detail": "Admin system data unavailable" }),
|
||
)));
|
||
}
|
||
match self
|
||
.prevalidate_admin_system_config_import(request_body, mode)
|
||
.await?
|
||
{
|
||
Ok(()) => {}
|
||
Err(err) => return Ok(Err(err)),
|
||
}
|
||
let parsed = routed!(parse_admin_system_config_import_request(request_body));
|
||
let source_version = parsed.request.document.version.clone();
|
||
let root = parsed.root;
|
||
let credentials_not_exported = invalid!(imported_config_credentials_not_exported(&root));
|
||
let merge_mode = parsed.request.merge_mode;
|
||
|
||
let imported_global_models = routed!(
|
||
parse_admin_system_config_array::<ImportedGlobalModel>(&root, "global_models")
|
||
);
|
||
let imported_providers = routed!(parse_admin_system_config_array::<ImportedProvider>(
|
||
&root,
|
||
"providers"
|
||
));
|
||
let imported_proxy_nodes = routed!(parse_admin_system_config_array::<ImportedProxyNode>(
|
||
&root,
|
||
"proxy_nodes"
|
||
));
|
||
let imported_ldap = routed!(parse_admin_system_config_optional_object::<
|
||
ImportedLdapConfig,
|
||
>(&root, "ldap_config"));
|
||
let imported_oauth_providers = routed!(parse_admin_system_config_array::<
|
||
ImportedOAuthProvider,
|
||
>(&root, "oauth_providers",));
|
||
let imported_system_configs = routed!(parse_admin_system_config_array::<
|
||
ImportedSystemConfig,
|
||
>(&root, "system_configs",));
|
||
|
||
let mut stats = AdminSystemConfigImportStats::default();
|
||
|
||
// Proxy nodes are deployment-local resources and are intentionally not imported by the
|
||
// Rust admin backend. Apply the external catalog selector before importing any other
|
||
// object, and turn a non-empty exported node reference into direct mode. This keeps a
|
||
// clean-environment restore portable and prevents a late selector validation failure from
|
||
// leaving the rest of the document partially imported.
|
||
let (imported_external_models_configs, mut imported_system_configs): (Vec<_>, Vec<_>) =
|
||
imported_system_configs.into_iter().partition(|item| {
|
||
normalize_imported_system_config_key(&item.value.key)
|
||
== ADMIN_EXTERNAL_MODELS_PROXY_NODE_CONFIG_KEY
|
||
});
|
||
// Destination-bound secrets must be applied after their destination fields. Recovery
|
||
// documents are user-controlled JSON and do not guarantee any ordering.
|
||
imported_system_configs.sort_by_key(|item| {
|
||
matches!(
|
||
normalize_imported_system_config_key(&item.value.key).as_str(),
|
||
"smtp_password" | "module.bark_push.device_key"
|
||
)
|
||
});
|
||
let mut existing_system_config_keys = self
|
||
.list_system_config_entries()
|
||
.await?
|
||
.into_iter()
|
||
.map(|entry| normalize_imported_system_config_key(&entry.key))
|
||
.collect::<BTreeSet<_>>();
|
||
for imported_config_item in imported_external_models_configs {
|
||
let (_, system_config) = imported_config_item.into_parts();
|
||
let exists =
|
||
existing_system_config_keys.contains(ADMIN_EXTERNAL_MODELS_PROXY_NODE_CONFIG_KEY);
|
||
match (exists, merge_mode) {
|
||
(true, AdminImportMergeMode::Skip) => {
|
||
stats.system_configs.skipped += 1;
|
||
continue;
|
||
}
|
||
(true, AdminImportMergeMode::Error) => {
|
||
return Ok(Err(invalid_request(format!(
|
||
"SystemConfig '{ADMIN_EXTERNAL_MODELS_PROXY_NODE_CONFIG_KEY}' 已存在"
|
||
))));
|
||
}
|
||
_ => {}
|
||
}
|
||
|
||
let imported_proxy_node_id = match system_config.value {
|
||
Value::Null => None,
|
||
Value::String(value) => {
|
||
let value = value.trim();
|
||
if value.is_empty() {
|
||
return Ok(Err(invalid_request(
|
||
"external_models_proxy_node_id 不能为空",
|
||
)));
|
||
}
|
||
Some(value.to_string())
|
||
}
|
||
_ => {
|
||
return Ok(Err(invalid_request(
|
||
"external_models_proxy_node_id 必须是字符串或 null",
|
||
)))
|
||
}
|
||
};
|
||
// A portable import must not retain a deployment-local node reference. Rollback is
|
||
// different: it runs on the same deployment and should restore the selector when
|
||
// that node still exists, so a failed aggregate operation does not silently switch
|
||
// the catalog to direct mode.
|
||
let selector = if mode.is_rollback_checkpoint() {
|
||
match imported_proxy_node_id.as_deref() {
|
||
Some(node_id) if self.find_proxy_node(node_id).await?.is_some() => {
|
||
Some(node_id)
|
||
}
|
||
_ => None,
|
||
}
|
||
} else {
|
||
None
|
||
};
|
||
let request_bytes = Bytes::from(
|
||
serde_json::to_vec(&json!({ "proxy_node_id": selector }))
|
||
.map_err(|err| GatewayError::Internal(err.to_string()))?,
|
||
);
|
||
match self
|
||
.apply_admin_external_models_config_update(&request_bytes)
|
||
.await?
|
||
{
|
||
Ok(_) => {
|
||
if exists {
|
||
stats.system_configs.updated += 1;
|
||
} else {
|
||
stats.system_configs.created += 1;
|
||
existing_system_config_keys
|
||
.insert(ADMIN_EXTERNAL_MODELS_PROXY_NODE_CONFIG_KEY.to_string());
|
||
if let Some(journal) = mutation_journal.as_deref_mut() {
|
||
journal
|
||
.system_config_keys
|
||
.insert(ADMIN_EXTERNAL_MODELS_PROXY_NODE_CONFIG_KEY.to_string());
|
||
}
|
||
}
|
||
if !mode.is_rollback_checkpoint() {
|
||
if let Some(node_id) = imported_proxy_node_id {
|
||
stats.errors.push(format!(
|
||
"外部模型目录代理节点 '{node_id}' 是当前部署的本地引用;代理节点未导入,已切换为直连"
|
||
));
|
||
}
|
||
}
|
||
}
|
||
Err((status, payload)) => return Ok(Err((status, payload))),
|
||
}
|
||
}
|
||
|
||
let mut global_models_by_name = self
|
||
.list_all_admin_global_models_for_system_transfer()
|
||
.await?
|
||
.into_iter()
|
||
.map(|model| (model.name.clone(), model))
|
||
.collect::<BTreeMap<_, _>>();
|
||
|
||
if !imported_proxy_nodes.is_empty() {
|
||
let empty_proxy_node_ids = imported_proxy_nodes
|
||
.iter()
|
||
.filter(|node| {
|
||
node.value
|
||
.id
|
||
.as_deref()
|
||
.map(str::trim)
|
||
.is_none_or(|value| value.is_empty())
|
||
})
|
||
.count();
|
||
stats.proxy_nodes.skipped = imported_proxy_nodes.len() as u64;
|
||
if empty_proxy_node_ids > 0 {
|
||
stats.errors.push(format!(
|
||
"检测到 {empty_proxy_node_ids} 个无效 proxy_nodes 项;当前 Rust 管理后端暂不支持导入代理节点"
|
||
));
|
||
} else {
|
||
stats.errors.push(
|
||
"当前 Rust 管理后端暂不支持导入代理节点;仅引用这些节点(node_id)的自动连接代理配置会被清除,手动 URL 代理配置会保留"
|
||
.to_string(),
|
||
);
|
||
}
|
||
}
|
||
let node_id_map = BTreeMap::<String, String>::new();
|
||
|
||
for imported_model in imported_global_models {
|
||
let (_, model) = imported_model.into_parts();
|
||
let name = invalid!(trim_required(&model.name, "name"));
|
||
let display_name = invalid!(trim_required(&model.display_name, "display_name"));
|
||
let default_price_per_request = invalid!(normalize_optional_price(
|
||
model.default_price_per_request,
|
||
"default_price_per_request",
|
||
));
|
||
let existing_model = global_models_by_name.get(&name);
|
||
let default_tiered_pricing = invalid!(normalize_json_object(
|
||
prepare_imported_secret_safe_json(
|
||
existing_model.and_then(|model| model.default_tiered_pricing.as_ref()),
|
||
model.default_tiered_pricing,
|
||
credentials_not_exported,
|
||
),
|
||
"default_tiered_pricing",
|
||
));
|
||
let supported_capabilities =
|
||
normalize_supported_capabilities(model.supported_capabilities);
|
||
let config = invalid!(normalize_json_object(
|
||
prepare_imported_secret_safe_json(
|
||
existing_model.and_then(|model| model.config.as_ref()),
|
||
model.config,
|
||
credentials_not_exported,
|
||
),
|
||
"config",
|
||
));
|
||
|
||
if let Some(existing) = global_models_by_name.get(&name).cloned() {
|
||
match merge_mode {
|
||
AdminImportMergeMode::Skip => {
|
||
stats.global_models.skipped += 1;
|
||
}
|
||
AdminImportMergeMode::Error => {
|
||
return Ok(Err(invalid_request(format!("GlobalModel '{name}' 已存在"))));
|
||
}
|
||
AdminImportMergeMode::Overwrite => {
|
||
let mut record = invalid!(UpdateAdminGlobalModelRecord::new(
|
||
existing.id.clone(),
|
||
display_name,
|
||
model.is_active,
|
||
default_price_per_request,
|
||
default_tiered_pricing,
|
||
supported_capabilities,
|
||
config,
|
||
)
|
||
.map_err(|err| err.to_string()));
|
||
record.usage_count = model.usage_count;
|
||
let Some(updated) = self.update_admin_global_model(&record).await? else {
|
||
return Ok(Err(invalid_request(format!(
|
||
"更新 GlobalModel '{name}' 失败"
|
||
))));
|
||
};
|
||
global_models_by_name.insert(name, updated);
|
||
stats.global_models.updated += 1;
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
|
||
let mut record = invalid!(CreateAdminGlobalModelRecord::new(
|
||
Uuid::new_v4().to_string(),
|
||
name.clone(),
|
||
display_name,
|
||
model.is_active,
|
||
default_price_per_request,
|
||
default_tiered_pricing,
|
||
supported_capabilities,
|
||
config,
|
||
)
|
||
.map_err(|err| err.to_string()));
|
||
record.usage_count = model.usage_count;
|
||
let Some(created) = self.create_admin_global_model(&record).await? else {
|
||
return Ok(Err(invalid_request(format!(
|
||
"创建 GlobalModel '{name}' 失败"
|
||
))));
|
||
};
|
||
let created_global_model_id = created.id.clone();
|
||
global_models_by_name.insert(name, created);
|
||
stats.global_models.created += 1;
|
||
if let Some(journal) = mutation_journal.as_deref_mut() {
|
||
journal.global_model_ids.insert(created_global_model_id);
|
||
}
|
||
}
|
||
|
||
let mut providers_by_name = self
|
||
.list_provider_catalog_providers(false)
|
||
.await?
|
||
.into_iter()
|
||
.map(|provider| (provider.name.clone(), provider))
|
||
.collect::<BTreeMap<_, _>>();
|
||
|
||
for imported_provider_item in imported_providers {
|
||
let (raw_provider, imported_provider) = imported_provider_item.into_parts();
|
||
let provider_name = invalid!(trim_required(&imported_provider.name, "name"));
|
||
invalid!(
|
||
crate::provider_transport::validate_anthropic_compatibility_profile_config(
|
||
imported_provider.config.as_ref(),
|
||
)
|
||
.map_err(|_| "无效的 Anthropic compatibility profile".to_string())
|
||
);
|
||
let existing_provider = providers_by_name.get(&provider_name).cloned();
|
||
|
||
let provider = if let Some(existing) = existing_provider {
|
||
match merge_mode {
|
||
AdminImportMergeMode::Skip => {
|
||
stats.providers.skipped += 1;
|
||
existing
|
||
}
|
||
AdminImportMergeMode::Error => {
|
||
return Ok(Err(invalid_request(format!(
|
||
"Provider '{provider_name}' 已存在"
|
||
))));
|
||
}
|
||
AdminImportMergeMode::Overwrite => {
|
||
let patch =
|
||
match AdminProviderUpdatePatch::from_object(raw_provider.clone()) {
|
||
Ok(patch) => patch,
|
||
Err(_) => {
|
||
return Ok(Err(invalid_request(format!(
|
||
"Provider '{provider_name}' 配置格式无效"
|
||
))));
|
||
}
|
||
};
|
||
let mut updated = invalid!(
|
||
self.build_admin_update_provider_record(&existing, patch)
|
||
.await
|
||
);
|
||
updated.proxy = prepare_imported_secret_safe_proxy(
|
||
existing.proxy.as_ref(),
|
||
imported_provider.proxy.clone(),
|
||
credentials_not_exported,
|
||
&node_id_map,
|
||
);
|
||
let provider_ops_fallback_base_url =
|
||
imported_provider_ops_fallback_base_url(&raw_provider);
|
||
updated.config = invalid!(prepare_imported_provider_config(
|
||
self,
|
||
&updated.id,
|
||
provider_ops_fallback_base_url.as_deref(),
|
||
existing.config.as_ref(),
|
||
imported_provider.config.clone(),
|
||
credentials_not_exported,
|
||
));
|
||
let Some(persisted) =
|
||
self.update_provider_catalog_provider(&updated).await?
|
||
else {
|
||
return Ok(Err(invalid_request(format!(
|
||
"更新 Provider '{provider_name}' 失败"
|
||
))));
|
||
};
|
||
providers_by_name.insert(provider_name.clone(), persisted.clone());
|
||
stats.providers.updated += 1;
|
||
persisted
|
||
}
|
||
}
|
||
} else {
|
||
let payload = match serde_json::from_value::<AdminProviderCreateRequest>(
|
||
Value::Object(raw_provider.clone()),
|
||
) {
|
||
Ok(payload) => payload,
|
||
Err(_) => {
|
||
return Ok(Err(invalid_request(format!(
|
||
"Provider '{provider_name}' 配置格式无效"
|
||
))));
|
||
}
|
||
};
|
||
let (mut record, shift_existing_priorities_from) =
|
||
invalid!(self.build_admin_create_provider_record(payload).await);
|
||
if let Some(enable_format_conversion) = imported_provider.enable_format_conversion {
|
||
record.enable_format_conversion = enable_format_conversion;
|
||
}
|
||
record.proxy = prepare_imported_secret_safe_proxy(
|
||
None,
|
||
imported_provider.proxy.clone(),
|
||
credentials_not_exported,
|
||
&node_id_map,
|
||
);
|
||
let provider_ops_fallback_base_url =
|
||
imported_provider_ops_fallback_base_url(&raw_provider);
|
||
record.config = invalid!(prepare_imported_provider_config(
|
||
self,
|
||
&record.id,
|
||
provider_ops_fallback_base_url.as_deref(),
|
||
None,
|
||
imported_provider.config.clone(),
|
||
credentials_not_exported,
|
||
));
|
||
let Some(created) = self
|
||
.create_provider_catalog_provider(&record, shift_existing_priorities_from)
|
||
.await?
|
||
else {
|
||
return Ok(Err(invalid_request(format!(
|
||
"创建 Provider '{provider_name}' 失败"
|
||
))));
|
||
};
|
||
providers_by_name.insert(provider_name.clone(), created.clone());
|
||
stats.providers.created += 1;
|
||
if let Some(journal) = mutation_journal.as_deref_mut() {
|
||
journal.provider_ids.insert(created.id.clone());
|
||
}
|
||
created
|
||
};
|
||
|
||
let imported_endpoints = routed!(parse_admin_system_config_nested_array::<
|
||
ImportedEndpoint,
|
||
>(&raw_provider, "endpoints"));
|
||
let mut existing_endpoints_by_format = self
|
||
.list_provider_catalog_endpoints_by_provider_ids(std::slice::from_ref(&provider.id))
|
||
.await?
|
||
.into_iter()
|
||
.map(|endpoint| (endpoint.api_format.clone(), endpoint))
|
||
.collect::<BTreeMap<_, _>>();
|
||
|
||
for imported_endpoint_item in imported_endpoints {
|
||
let (raw_endpoint, imported_endpoint) = imported_endpoint_item.into_parts();
|
||
let normalized_api_format = invalid!(normalize_import_endpoint_format(
|
||
&imported_endpoint.api_format
|
||
));
|
||
invalid!(
|
||
crate::provider_transport::validate_anthropic_compatibility_profile_config(
|
||
imported_endpoint.config.as_ref(),
|
||
)
|
||
.map_err(|_| "无效的 Anthropic compatibility profile".to_string())
|
||
);
|
||
if !fixed_provider_import_endpoint_supported(
|
||
&provider.provider_type,
|
||
&normalized_api_format,
|
||
) {
|
||
let retired = existing_endpoints_by_format.remove(&normalized_api_format);
|
||
if let Some(mut retired) = retired {
|
||
if retired.is_active {
|
||
retired.is_active = false;
|
||
retired.updated_at_unix_secs = SystemTime::now()
|
||
.duration_since(UNIX_EPOCH)
|
||
.ok()
|
||
.map(|duration| duration.as_secs());
|
||
let Some(_) = self.update_provider_catalog_endpoint(&retired).await?
|
||
else {
|
||
return Ok(Err(invalid_request(format!(
|
||
"停用 Provider '{provider_name}' 的已移除 Endpoint '{normalized_api_format}' 失败"
|
||
))));
|
||
};
|
||
stats.endpoints.updated += 1;
|
||
} else {
|
||
stats.endpoints.skipped += 1;
|
||
}
|
||
} else {
|
||
stats.endpoints.skipped += 1;
|
||
}
|
||
stats.errors.push(format!(
|
||
"固定 Provider '{provider_name}' 不再支持 Endpoint '{normalized_api_format}',已跳过或停用"
|
||
));
|
||
continue;
|
||
}
|
||
let existing_endpoint = existing_endpoints_by_format
|
||
.get(&normalized_api_format)
|
||
.cloned();
|
||
|
||
if let Some(existing_endpoint) = existing_endpoint {
|
||
match merge_mode {
|
||
AdminImportMergeMode::Skip => {
|
||
stats.endpoints.skipped += 1;
|
||
}
|
||
AdminImportMergeMode::Error => {
|
||
return Ok(Err(invalid_request(format!(
|
||
"Endpoint '{normalized_api_format}' 已存在于 Provider '{provider_name}'"
|
||
))));
|
||
}
|
||
AdminImportMergeMode::Overwrite => {
|
||
let Some((normalized_signature, api_family, endpoint_kind)) =
|
||
admin_endpoint_signature_parts(&normalized_api_format)
|
||
else {
|
||
return Ok(Err(invalid_request(format!(
|
||
"无效的 api_format: {}",
|
||
imported_endpoint.api_format
|
||
))));
|
||
};
|
||
let patch = match AdminProviderEndpointUpdatePatch::from_object(
|
||
raw_endpoint.clone(),
|
||
) {
|
||
Ok(patch) => patch,
|
||
Err(_) => {
|
||
return Ok(Err(invalid_request(
|
||
"Provider Endpoint 配置格式无效",
|
||
)));
|
||
}
|
||
};
|
||
let (fields, payload) = patch.into_parts();
|
||
let normalized_base_url = match payload.base_url.as_deref() {
|
||
Some(base_url) => {
|
||
Some(invalid!(normalize_admin_base_url(base_url)))
|
||
}
|
||
None => None,
|
||
};
|
||
let update_fields =
|
||
admin_provider_endpoints_pure::AdminProviderEndpointUpdateFields {
|
||
base_url: normalized_base_url,
|
||
custom_path: payload.custom_path,
|
||
header_rules: prepare_imported_secret_safe_header_rules(
|
||
existing_endpoint.header_rules.as_ref(),
|
||
payload.header_rules,
|
||
credentials_not_exported,
|
||
),
|
||
body_rules: prepare_imported_secret_safe_body_rules(
|
||
existing_endpoint.body_rules.as_ref(),
|
||
payload.body_rules,
|
||
credentials_not_exported,
|
||
),
|
||
max_retries: payload.max_retries,
|
||
is_active: payload.is_active,
|
||
config: prepare_imported_secret_safe_json(
|
||
existing_endpoint.config.as_ref(),
|
||
payload.config,
|
||
credentials_not_exported,
|
||
),
|
||
proxy: payload.proxy,
|
||
format_acceptance_config: prepare_imported_secret_safe_json(
|
||
existing_endpoint.format_acceptance_config.as_ref(),
|
||
payload.format_acceptance_config,
|
||
credentials_not_exported,
|
||
),
|
||
};
|
||
let mut updated = invalid!(
|
||
admin_provider_endpoints_pure::apply_admin_provider_endpoint_update_fields(
|
||
&existing_endpoint,
|
||
|field| fields.contains(field),
|
||
|field| fields.is_null(field),
|
||
&update_fields,
|
||
)
|
||
);
|
||
if fields.contains("proxy") {
|
||
updated.proxy = prepare_imported_secret_safe_proxy(
|
||
existing_endpoint.proxy.as_ref(),
|
||
imported_endpoint.proxy.clone(),
|
||
credentials_not_exported,
|
||
&node_id_map,
|
||
);
|
||
}
|
||
updated.api_format = normalized_signature.to_string();
|
||
updated.api_family = Some(api_family.to_string());
|
||
updated.endpoint_kind = Some(endpoint_kind.to_string());
|
||
updated.updated_at_unix_secs = SystemTime::now()
|
||
.duration_since(UNIX_EPOCH)
|
||
.ok()
|
||
.map(|duration| duration.as_secs());
|
||
let Some(persisted) =
|
||
self.update_provider_catalog_endpoint(&updated).await?
|
||
else {
|
||
return Ok(Err(invalid_request(format!(
|
||
"更新 Endpoint '{normalized_api_format}' 失败"
|
||
))));
|
||
};
|
||
existing_endpoints_by_format
|
||
.insert(normalized_api_format.clone(), persisted);
|
||
stats.endpoints.updated += 1;
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
|
||
let Some((normalized_signature, api_family, endpoint_kind)) =
|
||
admin_endpoint_signature_parts(&normalized_api_format)
|
||
else {
|
||
return Ok(Err(invalid_request(format!(
|
||
"无效的 api_format: {}",
|
||
imported_endpoint.api_format
|
||
))));
|
||
};
|
||
let now_unix_secs = SystemTime::now()
|
||
.duration_since(UNIX_EPOCH)
|
||
.ok()
|
||
.map(|duration| duration.as_secs())
|
||
.unwrap_or(0);
|
||
let mut record = invalid!(
|
||
admin_provider_endpoints_pure::build_admin_provider_endpoint_record(
|
||
Uuid::new_v4().to_string(),
|
||
provider.id.clone(),
|
||
normalized_signature.to_string(),
|
||
api_family.to_string(),
|
||
endpoint_kind.to_string(),
|
||
invalid!(normalize_admin_base_url(&imported_endpoint.base_url)),
|
||
imported_endpoint.custom_path.clone(),
|
||
prepare_imported_secret_safe_header_rules(
|
||
None,
|
||
imported_endpoint.header_rules.clone(),
|
||
credentials_not_exported,
|
||
),
|
||
prepare_imported_secret_safe_body_rules(
|
||
None,
|
||
imported_endpoint.body_rules.clone(),
|
||
credentials_not_exported,
|
||
),
|
||
imported_endpoint.max_retries.unwrap_or(2),
|
||
prepare_imported_secret_safe_json(
|
||
None,
|
||
imported_endpoint.config.clone(),
|
||
credentials_not_exported,
|
||
),
|
||
prepare_imported_secret_safe_proxy(
|
||
None,
|
||
imported_endpoint.proxy.clone(),
|
||
credentials_not_exported,
|
||
&node_id_map,
|
||
),
|
||
prepare_imported_secret_safe_json(
|
||
None,
|
||
imported_endpoint.format_acceptance_config.clone(),
|
||
credentials_not_exported,
|
||
),
|
||
now_unix_secs,
|
||
)
|
||
);
|
||
record = record.with_health_score(1.0);
|
||
record.is_active = imported_endpoint.is_active;
|
||
let Some(created) = self.create_provider_catalog_endpoint(&record).await? else {
|
||
return Ok(Err(invalid_request(format!(
|
||
"创建 Endpoint '{normalized_api_format}' 失败"
|
||
))));
|
||
};
|
||
let created_endpoint_id = created.id.clone();
|
||
existing_endpoints_by_format.insert(normalized_api_format.clone(), created);
|
||
stats.endpoints.created += 1;
|
||
if let Some(journal) = mutation_journal.as_deref_mut() {
|
||
journal
|
||
.provider_endpoint_ids
|
||
.insert((provider.id.clone(), created_endpoint_id));
|
||
}
|
||
}
|
||
|
||
let provider_endpoint_formats = existing_endpoints_by_format
|
||
.keys()
|
||
.cloned()
|
||
.collect::<BTreeSet<_>>();
|
||
let now_unix_secs = SystemTime::now()
|
||
.duration_since(UNIX_EPOCH)
|
||
.ok()
|
||
.map(|duration| duration.as_secs())
|
||
.unwrap_or(0);
|
||
|
||
let imported_keys = routed!(parse_admin_system_config_nested_array::<
|
||
ImportedProviderKey,
|
||
>(&raw_provider, "api_keys"));
|
||
let mut existing_keys = self
|
||
.list_provider_catalog_keys_by_provider_ids(std::slice::from_ref(&provider.id))
|
||
.await?;
|
||
|
||
for imported_key_item in imported_keys {
|
||
let (raw_key, imported_key) = imported_key_item.into_parts();
|
||
let (normalized_api_formats, missing_formats) =
|
||
normalize_import_key_formats(&imported_key, &provider_endpoint_formats);
|
||
if !missing_formats.is_empty() {
|
||
stats.errors.push(format!(
|
||
"Key (Provider: {provider_name}) 的 api_formats 未配置对应 Endpoint,已跳过: {:?}",
|
||
missing_formats
|
||
));
|
||
}
|
||
if normalized_api_formats.is_empty() {
|
||
stats.keys.skipped += 1;
|
||
continue;
|
||
}
|
||
|
||
let normalized_auth_config = invalid!(normalize_import_auth_config(
|
||
imported_key.auth_config.clone()
|
||
));
|
||
let auth_type = imported_key_auth_type(&imported_key);
|
||
let credentials_not_exported = invalid!(
|
||
validate_imported_provider_key_credential_state(&imported_key)
|
||
);
|
||
let normalized_raw_key = normalize_import_key_raw_payload(
|
||
&raw_key,
|
||
&auth_type,
|
||
&normalized_api_formats,
|
||
normalized_auth_config.clone(),
|
||
credentials_not_exported,
|
||
);
|
||
let existing_key_index = if credentials_not_exported {
|
||
build_import_key_match_name(&imported_key).and_then(|target_name| {
|
||
existing_keys.iter().position(|existing_key| {
|
||
existing_key
|
||
.auth_type
|
||
.trim()
|
||
.eq_ignore_ascii_case(&auth_type)
|
||
&& existing_key.name == target_name
|
||
})
|
||
})
|
||
} else {
|
||
invalid!(find_imported_provider_key_index(
|
||
self,
|
||
&imported_key,
|
||
&auth_type,
|
||
normalized_auth_config.as_ref(),
|
||
&existing_keys,
|
||
))
|
||
};
|
||
|
||
if credentials_not_exported && existing_key_index.is_none() {
|
||
stats.keys.skipped += 1;
|
||
continue;
|
||
}
|
||
|
||
if let Some(existing_index) = existing_key_index {
|
||
let existing_key = existing_keys[existing_index].clone();
|
||
let previous_codex_credential_generation = existing_key
|
||
.upstream_metadata
|
||
.as_ref()
|
||
.and_then(Value::as_object)
|
||
.and_then(|metadata| metadata.get("codex"))
|
||
.and_then(|codex| {
|
||
aether_admin::provider::quota::codex_credential_generation(Some(codex))
|
||
})
|
||
.map(ToOwned::to_owned);
|
||
match merge_mode {
|
||
AdminImportMergeMode::Skip => {
|
||
stats.keys.skipped += 1;
|
||
}
|
||
AdminImportMergeMode::Error => {
|
||
return Ok(Err(invalid_request(format!(
|
||
"Provider '{provider_name}' 中存在重复 Key"
|
||
))));
|
||
}
|
||
AdminImportMergeMode::Overwrite => {
|
||
let patch = match AdminProviderKeyUpdatePatch::from_object(
|
||
normalized_raw_key.clone(),
|
||
) {
|
||
Ok(patch) => patch,
|
||
Err(_) => {
|
||
return Ok(Err(invalid_request("Provider Key 配置格式无效")));
|
||
}
|
||
};
|
||
let mut updated = invalid!(
|
||
self.build_admin_update_provider_key_record(
|
||
&provider,
|
||
&existing_key,
|
||
patch,
|
||
)
|
||
.await
|
||
);
|
||
if credentials_not_exported {
|
||
updated.encrypted_api_key = existing_key.encrypted_api_key.clone();
|
||
updated.encrypted_auth_config =
|
||
existing_key.encrypted_auth_config.clone();
|
||
}
|
||
let oauth_credentials_supplied = if auth_type == "oauth" {
|
||
invalid!(apply_imported_oauth_key_credentials(
|
||
self,
|
||
&provider.provider_type,
|
||
previous_codex_credential_generation.as_deref(),
|
||
&raw_key,
|
||
normalized_auth_config.as_ref(),
|
||
&mut updated,
|
||
))
|
||
} else {
|
||
false
|
||
};
|
||
updated.proxy = prepare_imported_secret_safe_proxy(
|
||
existing_key.proxy.as_ref(),
|
||
imported_key.proxy.clone(),
|
||
credentials_not_exported,
|
||
&node_id_map,
|
||
);
|
||
updated.fingerprint = invalid!(normalize_json_object(
|
||
prepare_imported_secret_safe_json(
|
||
existing_key.fingerprint.as_ref(),
|
||
imported_key.fingerprint.clone(),
|
||
credentials_not_exported,
|
||
),
|
||
"fingerprint",
|
||
));
|
||
let admin_update = build_provider_catalog_key_admin_cas_update(
|
||
&existing_key,
|
||
updated.clone(),
|
||
&provider.provider_type,
|
||
);
|
||
if !self
|
||
.compare_and_update_provider_catalog_key_admin_state(&admin_update)
|
||
.await?
|
||
{
|
||
return Ok(Err((
|
||
http::StatusCode::CONFLICT,
|
||
json!({
|
||
"detail": format!(
|
||
"Provider '{provider_name}' 的 Key 已被其他请求更新,请重试"
|
||
)
|
||
}),
|
||
)));
|
||
}
|
||
let Some(mut persisted) = self
|
||
.read_provider_catalog_keys_by_ids(std::slice::from_ref(
|
||
&updated.id,
|
||
))
|
||
.await?
|
||
.into_iter()
|
||
.next()
|
||
else {
|
||
return Ok(Err(invalid_request(format!(
|
||
"更新 Provider '{provider_name}' 的 Key 失败"
|
||
))));
|
||
};
|
||
if updated.learned_rpm_limit != existing_key.learned_rpm_limit {
|
||
let Some(reloaded) = self
|
||
.set_provider_catalog_key_learned_rpm_limit(
|
||
&updated.id,
|
||
updated.learned_rpm_limit,
|
||
updated.updated_at_unix_secs,
|
||
)
|
||
.await?
|
||
else {
|
||
return Ok(Err(invalid_request(format!(
|
||
"更新 Provider '{provider_name}' 的 Key 失败"
|
||
))));
|
||
};
|
||
persisted = reloaded;
|
||
}
|
||
if oauth_credentials_supplied {
|
||
let Some(reloaded) = self
|
||
.reset_provider_catalog_key_recovery_state_fenced(
|
||
&updated.id,
|
||
updated.encrypted_auth_config.as_deref().ok_or_else(|| {
|
||
GatewayError::Internal(format!(
|
||
"OAuth Provider '{provider_name}' imported without auth_config"
|
||
))
|
||
})?,
|
||
)
|
||
.await?
|
||
else {
|
||
return Ok(Err(invalid_request(format!(
|
||
"更新 Provider '{provider_name}' 的 Key 失败"
|
||
))));
|
||
};
|
||
persisted = reloaded;
|
||
let _ = self
|
||
.app()
|
||
.invalidate_local_oauth_refresh_entry(&updated.id)
|
||
.await;
|
||
seed_imported_oauth_pool_score(
|
||
self,
|
||
&provider.id,
|
||
&persisted,
|
||
now_unix_secs,
|
||
)
|
||
.await?;
|
||
}
|
||
existing_keys[existing_index] = persisted;
|
||
stats.keys.updated += 1;
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
|
||
let payload = match serde_json::from_value::<AdminProviderKeyCreateRequest>(
|
||
Value::Object(normalized_raw_key.clone()),
|
||
) {
|
||
Ok(payload) => payload,
|
||
Err(_) => return Ok(Err(invalid_request("Provider Key 配置格式无效"))),
|
||
};
|
||
let mut record = invalid!(
|
||
self.build_admin_create_provider_key_record(&provider, payload)
|
||
.await
|
||
);
|
||
let oauth_credentials_supplied = if auth_type == "oauth" {
|
||
invalid!(apply_imported_oauth_key_credentials(
|
||
self,
|
||
&provider.provider_type,
|
||
None,
|
||
&raw_key,
|
||
normalized_auth_config.as_ref(),
|
||
&mut record,
|
||
))
|
||
} else {
|
||
false
|
||
};
|
||
record.is_active = imported_key.is_active;
|
||
record.global_priority_by_format = invalid!(normalize_json_object(
|
||
imported_key.global_priority_by_format.clone(),
|
||
"global_priority_by_format",
|
||
));
|
||
record.proxy = prepare_imported_secret_safe_proxy(
|
||
None,
|
||
imported_key.proxy.clone(),
|
||
credentials_not_exported,
|
||
&node_id_map,
|
||
);
|
||
record.fingerprint = invalid!(normalize_json_object(
|
||
prepare_imported_secret_safe_json(
|
||
None,
|
||
imported_key.fingerprint.clone(),
|
||
credentials_not_exported,
|
||
),
|
||
"fingerprint",
|
||
));
|
||
let Some(created) = self.create_provider_catalog_key(&record).await? else {
|
||
return Ok(Err(invalid_request(format!(
|
||
"创建 Provider '{provider_name}' 的 Key 失败"
|
||
))));
|
||
};
|
||
// Journal the row immediately after creation. The pool-score seed below is a
|
||
// separate write and may fail; recording first ensures aggregate compensation
|
||
// can still remove this key when that follow-up operation aborts the import.
|
||
if let Some(journal) = mutation_journal.as_deref_mut() {
|
||
journal
|
||
.provider_key_ids
|
||
.insert((provider.id.clone(), created.id.clone()));
|
||
}
|
||
if oauth_credentials_supplied {
|
||
seed_imported_oauth_pool_score(self, &provider.id, &created, now_unix_secs)
|
||
.await?;
|
||
}
|
||
existing_keys.push(created);
|
||
stats.keys.created += 1;
|
||
}
|
||
|
||
let imported_models = routed!(parse_admin_system_config_nested_array::<
|
||
ImportedProviderModel,
|
||
>(&raw_provider, "models"));
|
||
let mut existing_models_by_name = self
|
||
.list_all_admin_provider_models_for_system_transfer(&provider.id)
|
||
.await?
|
||
.into_iter()
|
||
.map(|model| (model.provider_model_name.clone(), model))
|
||
.collect::<BTreeMap<_, _>>();
|
||
|
||
for imported_model_item in imported_models {
|
||
let (_, imported_model) = imported_model_item.into_parts();
|
||
let Some(global_model_name) = imported_model
|
||
.global_model_name
|
||
.as_deref()
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
else {
|
||
stats.errors.push(format!(
|
||
"跳过无 global_model_name 的模型 (Provider: {provider_name})"
|
||
));
|
||
continue;
|
||
};
|
||
let Some(global_model_id) = global_models_by_name
|
||
.get(global_model_name)
|
||
.map(|model| model.id.clone())
|
||
else {
|
||
stats.errors.push(format!(
|
||
"GlobalModel '{global_model_name}' 不存在,跳过模型"
|
||
));
|
||
continue;
|
||
};
|
||
|
||
let provider_model_name = invalid!(trim_required(
|
||
&imported_model.provider_model_name,
|
||
"provider_model_name"
|
||
));
|
||
let existing_model = existing_models_by_name.get(&provider_model_name).cloned();
|
||
|
||
if let Some(existing_model) = existing_model {
|
||
match merge_mode {
|
||
AdminImportMergeMode::Skip => {
|
||
stats.models.skipped += 1;
|
||
}
|
||
AdminImportMergeMode::Error => {
|
||
return Ok(Err(invalid_request(format!(
|
||
"Model '{provider_model_name}' 已存在于 Provider '{provider_name}'"
|
||
))));
|
||
}
|
||
AdminImportMergeMode::Overwrite => {
|
||
let record = invalid!(build_import_provider_model_record(
|
||
&provider.id,
|
||
Some(&existing_model.id),
|
||
Some(&existing_model),
|
||
&global_model_id,
|
||
&imported_model,
|
||
credentials_not_exported,
|
||
));
|
||
let Some(updated) = self.update_admin_provider_model(&record).await?
|
||
else {
|
||
return Ok(Err(invalid_request(format!(
|
||
"更新 Provider '{provider_name}' 的模型 '{provider_model_name}' 失败"
|
||
))));
|
||
};
|
||
existing_models_by_name.insert(provider_model_name, updated);
|
||
stats.models.updated += 1;
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
|
||
let record = invalid!(build_import_provider_model_record(
|
||
&provider.id,
|
||
None,
|
||
None,
|
||
&global_model_id,
|
||
&imported_model,
|
||
credentials_not_exported,
|
||
));
|
||
let Some(created) = self.create_admin_provider_model(&record).await? else {
|
||
return Ok(Err(invalid_request(format!(
|
||
"创建 Provider '{provider_name}' 的模型 '{provider_model_name}' 失败"
|
||
))));
|
||
};
|
||
if let Some(journal) = mutation_journal.as_deref_mut() {
|
||
journal
|
||
.provider_model_ids
|
||
.insert((provider.id.clone(), created.id.clone()));
|
||
}
|
||
existing_models_by_name.insert(provider_model_name, created);
|
||
stats.models.created += 1;
|
||
}
|
||
}
|
||
|
||
if let Some(imported_ldap_item) = imported_ldap {
|
||
let (_, ldap_config) = imported_ldap_item.into_parts();
|
||
if !self.has_auth_module_writer() {
|
||
stats.ldap.skipped += 1;
|
||
stats
|
||
.errors
|
||
.push("当前运行环境不支持写入 LDAP 配置,已跳过 ldap_config".to_string());
|
||
} else {
|
||
let existing = self.get_ldap_module_config().await?;
|
||
let server_url =
|
||
invalid!(trim_required(&ldap_config.server_url, "LDAP 服务器地址"));
|
||
let server_url = invalid!(normalize_ldap_transport_server_url(
|
||
&server_url,
|
||
ldap_config.use_starttls,
|
||
)
|
||
.ok_or_else(|| {
|
||
"LDAP 服务器地址必须使用 ldaps://,或在启用 StartTLS 时使用 ldap://;不得包含凭据、查询参数或片段"
|
||
.to_string()
|
||
}));
|
||
let bind_dn = invalid!(trim_required(&ldap_config.bind_dn, "绑定 DN"));
|
||
let base_dn = invalid!(trim_required(&ldap_config.base_dn, "Base DN"));
|
||
if !ldap_distinguished_name_is_valid(&bind_dn)
|
||
|| !ldap_distinguished_name_is_valid(&base_dn)
|
||
{
|
||
return Ok(Err(invalid_request(
|
||
"LDAP 绑定 DN 或 Base DN 格式无效或过长",
|
||
)));
|
||
}
|
||
let user_search_filter = invalid!(trim_required(
|
||
ldap_config
|
||
.user_search_filter
|
||
.as_deref()
|
||
.unwrap_or("(uid={username})"),
|
||
"搜索过滤器",
|
||
));
|
||
if !ldap_search_filter_is_valid(&user_search_filter) {
|
||
return Ok(Err(invalid_request(
|
||
"LDAP 搜索过滤器格式无效,必须包含 {username} 且使用有限的括号结构",
|
||
)));
|
||
}
|
||
let username_attr = invalid!(trim_required(
|
||
ldap_config.username_attr.as_deref().unwrap_or("uid"),
|
||
"用户名属性",
|
||
));
|
||
let email_attr = invalid!(trim_required(
|
||
ldap_config.email_attr.as_deref().unwrap_or("mail"),
|
||
"邮箱属性",
|
||
));
|
||
let display_name_attr = invalid!(trim_required(
|
||
ldap_config.display_name_attr.as_deref().unwrap_or("cn"),
|
||
"显示名称属性",
|
||
));
|
||
if [
|
||
username_attr.as_str(),
|
||
email_attr.as_str(),
|
||
display_name_attr.as_str(),
|
||
]
|
||
.into_iter()
|
||
.any(|attribute| !ldap_attribute_description_is_valid(attribute))
|
||
{
|
||
return Ok(Err(invalid_request(
|
||
"LDAP 用户名、邮箱或显示名称属性格式无效",
|
||
)));
|
||
}
|
||
let connect_timeout = ldap_config.connect_timeout.unwrap_or(10);
|
||
if !(1..=60).contains(&connect_timeout) {
|
||
return Ok(Err(invalid_request(
|
||
"LDAP connect_timeout 必须在 1 到 60 秒之间",
|
||
)));
|
||
}
|
||
let config = StoredLdapModuleConfig {
|
||
server_url,
|
||
bind_dn,
|
||
// Password mutation is explicit and separate from the replacement snapshot.
|
||
// In particular, Preserve never copies a previously read ciphertext here.
|
||
bind_password_encrypted: None,
|
||
base_dn,
|
||
user_search_filter: Some(user_search_filter),
|
||
username_attr: Some(username_attr),
|
||
email_attr: Some(email_attr),
|
||
display_name_attr: Some(display_name_attr),
|
||
is_enabled: ldap_config.is_enabled,
|
||
is_exclusive: ldap_config.is_exclusive,
|
||
use_starttls: ldap_config.use_starttls,
|
||
connect_timeout: Some(connect_timeout),
|
||
};
|
||
let bind_password = ldap_config
|
||
.bind_password
|
||
.as_deref()
|
||
.map(str::trim)
|
||
.map(ToOwned::to_owned);
|
||
if bind_password
|
||
.as_deref()
|
||
.is_some_and(is_imported_redacted_secret)
|
||
{
|
||
return Ok(Err(invalid_request("LDAP 脱敏占位符不能作为绑定密码导入")));
|
||
}
|
||
let bind_password_update = match bind_password {
|
||
Some(password) if password.is_empty() => LdapBindPasswordUpdate::Clear,
|
||
Some(password) => LdapBindPasswordUpdate::Set(routed!(self
|
||
.encrypt_ldap_bind_password(&config, &password)
|
||
.ok_or_else(|| {
|
||
invalid_request("LDAP 绑定密码加密失败,请检查 Rust 数据加密配置")
|
||
}))),
|
||
None => LdapBindPasswordUpdate::Preserve,
|
||
};
|
||
if matches!(&bind_password_update, LdapBindPasswordUpdate::Preserve) {
|
||
if let Some(existing) = existing.as_ref() {
|
||
if existing
|
||
.bind_password_encrypted
|
||
.as_deref()
|
||
.is_some_and(|value| !value.trim().is_empty())
|
||
{
|
||
let binding_matches = invalid!(
|
||
crate::handlers::shared::ldap_bind_password_binding_matches(
|
||
existing, &config,
|
||
)
|
||
);
|
||
if !binding_matches {
|
||
return Ok(Err(invalid_request(
|
||
"导入 LDAP 时修改了服务器、StartTLS、bind DN 或 Base DN,必须提供绑定密码",
|
||
)));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
let will_have_password = match &bind_password_update {
|
||
LdapBindPasswordUpdate::Set(ciphertext) => !ciphertext.trim().is_empty(),
|
||
LdapBindPasswordUpdate::Clear => false,
|
||
LdapBindPasswordUpdate::Preserve => existing
|
||
.as_ref()
|
||
.and_then(|config| config.bind_password_encrypted.as_deref())
|
||
.map(str::trim)
|
||
.is_some_and(|value| !value.is_empty()),
|
||
};
|
||
if existing.is_none()
|
||
&& !matches!(&bind_password_update, LdapBindPasswordUpdate::Set(_))
|
||
{
|
||
return Ok(Err(invalid_request("首次配置 LDAP 时必须设置绑定密码")));
|
||
}
|
||
if ldap_config.is_exclusive && !ldap_config.is_enabled {
|
||
return Ok(Err(invalid_request(
|
||
"仅允许 LDAP 登录 需要先启用 LDAP 认证",
|
||
)));
|
||
}
|
||
if ldap_config.is_enabled && !will_have_password {
|
||
return Ok(Err(invalid_request("启用 LDAP 认证 需要先设置绑定密码")));
|
||
}
|
||
if ldap_config.is_enabled && ldap_config.is_exclusive {
|
||
let admin_count = self
|
||
.count_active_local_admin_users_with_valid_password()
|
||
.await?;
|
||
if admin_count < 1 {
|
||
return Ok(Err(invalid_request(
|
||
"启用 LDAP 独占模式前,必须至少保留 1 个有效的本地管理员账户(含有效密码)作为紧急恢复通道",
|
||
)));
|
||
}
|
||
}
|
||
match (existing.is_some(), merge_mode) {
|
||
(true, AdminImportMergeMode::Skip) => stats.ldap.skipped += 1,
|
||
(true, AdminImportMergeMode::Error) => {
|
||
return Ok(Err(invalid_request("LDAP 配置已存在")));
|
||
}
|
||
(true, AdminImportMergeMode::Overwrite) => {
|
||
let Some(result) = self
|
||
.compare_and_swap_ldap_module_config(
|
||
existing.as_ref(),
|
||
&config,
|
||
&bind_password_update,
|
||
)
|
||
.await?
|
||
else {
|
||
return Ok(Err(invalid_request("更新 LDAP 配置失败")));
|
||
};
|
||
if result == CompareAndSwapLdapConfigResult::Conflict {
|
||
return Ok(Err((
|
||
http::StatusCode::CONFLICT,
|
||
json!({
|
||
"detail": "LDAP 配置已被其他请求更新,请重试"
|
||
}),
|
||
)));
|
||
}
|
||
stats.ldap.updated += 1;
|
||
}
|
||
(false, _) => {
|
||
let Some(result) = self
|
||
.compare_and_swap_ldap_module_config(
|
||
None,
|
||
&config,
|
||
&bind_password_update,
|
||
)
|
||
.await?
|
||
else {
|
||
return Ok(Err(invalid_request("创建 LDAP 配置失败")));
|
||
};
|
||
let CompareAndSwapLdapConfigResult::Applied(created) = result else {
|
||
return Ok(Err((
|
||
http::StatusCode::CONFLICT,
|
||
json!({
|
||
"detail": "LDAP 配置已被其他请求创建,请重试"
|
||
}),
|
||
)));
|
||
};
|
||
// Record the exact persisted snapshot before any later phase can fail.
|
||
// Compensation will delete it only if no concurrent write changed it.
|
||
if let Some(journal) = mutation_journal.as_deref_mut() {
|
||
journal.created_ldap_config = Some(created);
|
||
}
|
||
stats.ldap.created += 1;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if !imported_oauth_providers.is_empty() {
|
||
let imported_oauth_provider_count = imported_oauth_providers.len();
|
||
let mut oauth_by_type = self
|
||
.list_oauth_provider_configs()
|
||
.await?
|
||
.into_iter()
|
||
.map(|provider| (provider.provider_type.clone(), provider))
|
||
.collect::<BTreeMap<_, _>>();
|
||
|
||
for (index, imported_oauth_item) in imported_oauth_providers.into_iter().enumerate() {
|
||
let (_, oauth_provider) = imported_oauth_item.into_parts();
|
||
let original_provider_type = oauth_provider.provider_type.clone();
|
||
let original_enabled = oauth_provider.is_enabled;
|
||
let oauth_provider = invalid!(normalize_legacy_imported_oauth_provider(
|
||
oauth_provider,
|
||
&source_version,
|
||
));
|
||
let provider_type = invalid!(trim_required(
|
||
&oauth_provider.provider_type,
|
||
"provider_type",
|
||
));
|
||
let existed = oauth_by_type.contains_key(&provider_type);
|
||
if existed {
|
||
match merge_mode {
|
||
AdminImportMergeMode::Skip => {
|
||
stats.oauth.skipped += 1;
|
||
continue;
|
||
}
|
||
AdminImportMergeMode::Error => {
|
||
return Ok(Err(invalid_request(format!(
|
||
"OAuth Provider '{provider_type}' 已存在"
|
||
))));
|
||
}
|
||
AdminImportMergeMode::Overwrite => {}
|
||
}
|
||
}
|
||
|
||
let display_name =
|
||
invalid!(trim_required(&oauth_provider.display_name, "display_name"));
|
||
let client_id = invalid!(trim_required(&oauth_provider.client_id, "client_id"));
|
||
let redirect_uri =
|
||
invalid!(trim_required(&oauth_provider.redirect_uri, "redirect_uri"));
|
||
let frontend_callback_url = invalid!(trim_required(
|
||
&oauth_provider.frontend_callback_url,
|
||
"frontend_callback_url",
|
||
));
|
||
let mut record = invalid!(build_imported_oauth_provider_record(
|
||
&oauth_provider,
|
||
EncryptedSecretUpdate::Preserve,
|
||
));
|
||
record.provider_type = provider_type.clone();
|
||
record.display_name = display_name;
|
||
record.client_id = client_id;
|
||
record.redirect_uri = redirect_uri;
|
||
record.frontend_callback_url = frontend_callback_url;
|
||
|
||
// Bind imported plaintext to the final normalized record, including all
|
||
// endpoint and redirect fields. Never seal using provider_type alone.
|
||
record.client_secret_encrypted =
|
||
match oauth_provider.client_secret.as_deref().map(str::trim) {
|
||
Some(secret) if is_imported_redacted_secret(secret) => {
|
||
EncryptedSecretUpdate::Preserve
|
||
}
|
||
Some(secret) if !secret.is_empty() => EncryptedSecretUpdate::Set(routed!(
|
||
crate::handlers::shared::seal_identity_oauth_provider_client_secret(
|
||
self.as_ref(),
|
||
&record,
|
||
secret,
|
||
)
|
||
.map_err(|message| invalid_request(message))
|
||
)),
|
||
_ => EncryptedSecretUpdate::Preserve,
|
||
};
|
||
|
||
// A redacted/omitted secret may preserve an existing value only when the
|
||
// complete OAuth binding is unchanged. Otherwise the old secret would be
|
||
// replayed against a different client or endpoint after an overwrite import.
|
||
if matches!(
|
||
&record.client_secret_encrypted,
|
||
EncryptedSecretUpdate::Preserve
|
||
) {
|
||
if let Some(existing) = oauth_by_type.get(&provider_type) {
|
||
if existing
|
||
.client_secret_encrypted
|
||
.as_deref()
|
||
.is_some_and(|value| !value.trim().is_empty())
|
||
{
|
||
let binding_matches = invalid!(
|
||
crate::handlers::shared::identity_oauth_provider_secret_binding_matches(
|
||
existing,
|
||
&record,
|
||
)
|
||
);
|
||
if !binding_matches {
|
||
return Ok(Err(invalid_request(
|
||
"导入 OAuth Provider 时修改了 Client ID、端点或 redirect_uri,必须提供 client_secret",
|
||
)));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
let Some(persisted) = self.upsert_oauth_provider_config(&record).await? else {
|
||
stats.oauth.skipped += (imported_oauth_provider_count - index) as u64;
|
||
stats.errors.push(
|
||
"当前运行环境不支持 OAuth Provider 配置读写,已跳过 oauth_providers"
|
||
.to_string(),
|
||
);
|
||
break;
|
||
};
|
||
oauth_by_type.insert(provider_type.clone(), persisted);
|
||
if original_enabled && !oauth_provider.is_enabled {
|
||
stats.errors.push(format!(
|
||
"旧版 OAuth Provider '{original_provider_type}' 已安全迁移并停用,请复核域名白名单后重新启用"
|
||
));
|
||
}
|
||
if existed {
|
||
stats.oauth.updated += 1;
|
||
} else {
|
||
stats.oauth.created += 1;
|
||
if let Some(journal) = mutation_journal.as_deref_mut() {
|
||
journal.oauth_provider_types.insert(provider_type.clone());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
for imported_config_item in imported_system_configs {
|
||
let (_, system_config) = imported_config_item.into_parts();
|
||
let ImportedSystemConfig {
|
||
key,
|
||
value,
|
||
description,
|
||
} = system_config;
|
||
let normalized_key = normalize_imported_system_config_key(&key);
|
||
if credentials_not_exported
|
||
&& (is_sensitive_admin_system_config_key(&normalized_key)
|
||
|| is_interactive_export_private_system_config_key(&normalized_key))
|
||
{
|
||
stats.system_configs.skipped += 1;
|
||
continue;
|
||
}
|
||
let exists = existing_system_config_keys.contains(&normalized_key);
|
||
match (exists, merge_mode) {
|
||
(true, AdminImportMergeMode::Skip) => {
|
||
stats.system_configs.skipped += 1;
|
||
continue;
|
||
}
|
||
(true, AdminImportMergeMode::Error) => {
|
||
return Ok(Err(invalid_request(format!(
|
||
"SystemConfig '{normalized_key}' 已存在"
|
||
))));
|
||
}
|
||
_ => {}
|
||
}
|
||
|
||
let request_bytes = Bytes::from(
|
||
serde_json::to_vec(&json!({
|
||
"value": value,
|
||
"description": description,
|
||
}))
|
||
.map_err(|err| GatewayError::Internal(err.to_string()))?,
|
||
);
|
||
let update_result =
|
||
apply_admin_system_config_update(self, &key, &request_bytes).await?;
|
||
match update_result {
|
||
Ok(_) => {
|
||
if exists {
|
||
stats.system_configs.updated += 1;
|
||
} else {
|
||
stats.system_configs.created += 1;
|
||
existing_system_config_keys.insert(normalized_key.clone());
|
||
if let Some(journal) = mutation_journal.as_deref_mut() {
|
||
journal.system_config_keys.insert(normalized_key);
|
||
}
|
||
}
|
||
}
|
||
Err((status, payload)) => return Ok(Err((status, payload))),
|
||
}
|
||
}
|
||
|
||
Ok(Ok(json!({
|
||
"message": "配置导入成功",
|
||
"stats": stats,
|
||
})))
|
||
}
|
||
|
||
pub(crate) async fn import_admin_system_users(
|
||
&self,
|
||
request_body: &Bytes,
|
||
operator_id: Option<&str>,
|
||
) -> Result<Result<Value, (http::StatusCode, Value)>, GatewayError> {
|
||
let mut mutation_journal = AggregateMutationJournal::default();
|
||
let result = self
|
||
.import_admin_system_users_with_mode(
|
||
request_body,
|
||
operator_id,
|
||
SystemImportMode::InteractiveUpload,
|
||
Some(&mut mutation_journal),
|
||
)
|
||
.await;
|
||
self.finish_standalone_users_import(result, &mutation_journal)
|
||
.await
|
||
}
|
||
|
||
pub(crate) async fn restore_admin_system_users_backup(
|
||
&self,
|
||
request_body: &Bytes,
|
||
operator_id: Option<&str>,
|
||
_authority: crate::backup::executor::BackupRestoreAuthority,
|
||
) -> Result<Result<Value, (http::StatusCode, Value)>, GatewayError> {
|
||
let mut mutation_journal = AggregateMutationJournal::default();
|
||
let result = self
|
||
.import_admin_system_users_with_mode(
|
||
request_body,
|
||
operator_id,
|
||
SystemImportMode::RecoveryBackup,
|
||
Some(&mut mutation_journal),
|
||
)
|
||
.await;
|
||
self.finish_standalone_users_import(result, &mutation_journal)
|
||
.await
|
||
}
|
||
|
||
/// Interactive and standalone recovery imports do not have the aggregate config checkpoint
|
||
/// available to their caller. Compensate rows created by this invocation and restore existing
|
||
/// rows only when their mutable fields still match the recorded post-state.
|
||
async fn finish_standalone_users_import(
|
||
&self,
|
||
result: Result<Result<Value, (http::StatusCode, Value)>, GatewayError>,
|
||
mutation_journal: &AggregateMutationJournal,
|
||
) -> Result<Result<Value, (http::StatusCode, Value)>, GatewayError> {
|
||
match result {
|
||
Ok(Ok(payload)) => Ok(Ok(payload)),
|
||
Ok(Err(original)) => match self
|
||
.rollback_standalone_users_mutations(mutation_journal)
|
||
.await
|
||
{
|
||
Ok(()) => Ok(Err(original)),
|
||
Err(rollback_error) => Err(aggregate_rollback_http_error(
|
||
"用户阶段",
|
||
&original,
|
||
rollback_error,
|
||
)),
|
||
},
|
||
Err(original) => match self
|
||
.rollback_standalone_users_mutations(mutation_journal)
|
||
.await
|
||
{
|
||
Ok(()) => Err(original),
|
||
Err(rollback_error) => Err(aggregate_rollback_error(
|
||
"用户阶段",
|
||
original,
|
||
rollback_error,
|
||
)),
|
||
},
|
||
}
|
||
}
|
||
|
||
async fn rollback_standalone_users_mutations(
|
||
&self,
|
||
mutation_journal: &AggregateMutationJournal,
|
||
) -> Result<(), GatewayError> {
|
||
// Run both compensations even when one fails. Newly-created rows are removed first, while
|
||
// pre-existing wallets are restored through an owner- and snapshot-checked CAS so a
|
||
// concurrent recharge cannot be overwritten by an import failure.
|
||
let created_result = self.rollback_created_users(mutation_journal).await;
|
||
let existing_wallet_result = self.rollback_existing_wallets(mutation_journal).await;
|
||
let existing_users_result = self.rollback_existing_users(mutation_journal).await;
|
||
let existing_groups_result = self.rollback_existing_user_groups(mutation_journal).await;
|
||
let existing_api_keys_result = self.rollback_existing_api_keys(mutation_journal).await;
|
||
let existing_result = combine_rollback_results(
|
||
existing_users_result,
|
||
existing_groups_result,
|
||
"standalone existing users/groups",
|
||
);
|
||
let existing_result = combine_rollback_results(
|
||
existing_api_keys_result,
|
||
existing_result,
|
||
"standalone existing API keys",
|
||
);
|
||
let restore_result = combine_rollback_results(
|
||
created_result,
|
||
existing_wallet_result,
|
||
"standalone users wallets",
|
||
);
|
||
combine_rollback_results(restore_result, existing_result, "standalone users")
|
||
}
|
||
|
||
async fn import_admin_system_users_with_mode(
|
||
&self,
|
||
request_body: &Bytes,
|
||
operator_id: Option<&str>,
|
||
mode: SystemImportMode,
|
||
mut mutation_journal: Option<&mut AggregateMutationJournal>,
|
||
) -> Result<Result<Value, (http::StatusCode, Value)>, GatewayError> {
|
||
if !self.has_auth_user_write_capability()
|
||
|| !self.has_auth_wallet_write_capability()
|
||
|| !self.has_auth_api_key_writer()
|
||
{
|
||
return Ok(Err((
|
||
http::StatusCode::SERVICE_UNAVAILABLE,
|
||
json!({ "detail": "Admin system data unavailable" }),
|
||
)));
|
||
}
|
||
match self
|
||
.prevalidate_admin_system_users_import(request_body, operator_id, mode)
|
||
.await?
|
||
{
|
||
Ok(()) => {}
|
||
Err(err) => return Ok(Err(err)),
|
||
}
|
||
let root = match serde_json::from_slice::<Value>(request_body) {
|
||
Ok(Value::Object(map)) => map,
|
||
_ => return Ok(Err(invalid_request("请求数据验证失败"))),
|
||
};
|
||
let merge_mode = match serde_json::from_value::<AdminImportMergeMode>(
|
||
root.get("merge_mode").cloned().unwrap_or(Value::Null),
|
||
) {
|
||
Ok(value) => value,
|
||
Err(_) => {
|
||
return Ok(Err(invalid_request(
|
||
"merge_mode 仅支持 skip / overwrite / error",
|
||
)));
|
||
}
|
||
};
|
||
let empty = Vec::new();
|
||
let users = match root.get("users") {
|
||
Some(Value::Array(items)) => items,
|
||
Some(_) => return Ok(Err(invalid_request("users 必须是数组"))),
|
||
None => &empty,
|
||
};
|
||
let standalone_keys = match root.get("standalone_keys") {
|
||
Some(Value::Array(items)) => items,
|
||
Some(_) => return Ok(Err(invalid_request("standalone_keys 必须是数组"))),
|
||
None => &empty,
|
||
};
|
||
let imported_user_groups = match root.get("user_groups") {
|
||
Some(Value::Array(items)) => items,
|
||
Some(_) => return Ok(Err(invalid_request("user_groups 必须是数组"))),
|
||
None => &empty,
|
||
};
|
||
let standalone_owner_id = match operator_id {
|
||
Some(candidate) => match self.find_user_auth_by_id(candidate).await? {
|
||
Some(user) if crate::roles::is_full_admin_role(&user.role) => Some(user.id),
|
||
_ => None,
|
||
},
|
||
None => None,
|
||
};
|
||
|
||
macro_rules! invalid_value {
|
||
($expr:expr) => {
|
||
match $expr {
|
||
Ok(value) => value,
|
||
Err(detail) => return Ok(Err(invalid_request(detail))),
|
||
}
|
||
};
|
||
}
|
||
|
||
// Repository write helpers return `None` when the corresponding writer is unavailable
|
||
// or the target row disappeared. Treat that as a failed import step so the caller's
|
||
// mutation journal can compensate newly-created rows instead of returning a half-imported
|
||
// success.
|
||
macro_rules! require_persisted {
|
||
($expr:expr) => {
|
||
match $expr {
|
||
Some(value) => value,
|
||
None => {
|
||
return Ok(Err((
|
||
http::StatusCode::SERVICE_UNAVAILABLE,
|
||
json!({ "detail": "Admin system data unavailable" }),
|
||
)))
|
||
}
|
||
}
|
||
};
|
||
}
|
||
|
||
let users_export_version = invalid_value!(
|
||
validate_imported_system_users_export_version_for_mode(root.get("version"), mode)
|
||
);
|
||
|
||
let supplemental_user_usage_aggregates = if mode.is_rollback_checkpoint() {
|
||
// Rollback checkpoints must not mutate runtime usage state. In particular, do not
|
||
// synthesize daily rows from the denormalized counters on the user records.
|
||
Vec::new()
|
||
} else {
|
||
invalid_value!(build_imported_user_usage_total_aggregates(
|
||
users,
|
||
root.get("exported_at")
|
||
))
|
||
};
|
||
let mut stats = AdminSystemUsersImportStats::default();
|
||
let mut imported_user_id_map = BTreeMap::<String, String>::new();
|
||
let mut imported_api_key_id_map = BTreeMap::<String, String>::new();
|
||
let default_group_id = self.effective_default_user_group_id().await?;
|
||
let existing_groups = self.list_user_groups().await?;
|
||
let mut groups_by_name = existing_groups
|
||
.into_iter()
|
||
.map(|group| {
|
||
(
|
||
aether_data::repository::users::normalize_user_group_name(&group.name)
|
||
.to_ascii_lowercase(),
|
||
group,
|
||
)
|
||
})
|
||
.collect::<BTreeMap<_, _>>();
|
||
let mut imported_group_id_map = BTreeMap::<String, String>::new();
|
||
let mut imported_group_name_map = BTreeMap::<String, String>::new();
|
||
|
||
for (index, raw_group) in imported_user_groups.iter().enumerate() {
|
||
let group = match imported_object_field(raw_group, &format!("user_groups[{index}]")) {
|
||
Ok(value) => value,
|
||
Err(detail) => return Ok(Err(invalid_request(detail))),
|
||
};
|
||
let (export_id, normalized_name, record) = invalid_value!(
|
||
build_imported_user_group_record(group, &format!("user_groups[{index}]"))
|
||
);
|
||
if default_group_id
|
||
.as_deref()
|
||
.is_some_and(|group_id| export_id.as_deref() == Some(group_id))
|
||
|| normalized_name == "default"
|
||
{
|
||
if let Some(default_group_id) = default_group_id.as_ref() {
|
||
if let Some(export_id) = export_id {
|
||
imported_group_id_map.insert(export_id, default_group_id.clone());
|
||
}
|
||
imported_group_name_map.insert(normalized_name, default_group_id.clone());
|
||
}
|
||
stats.user_groups.skipped += 1;
|
||
continue;
|
||
}
|
||
let existing_by_id = mode
|
||
.is_rollback_checkpoint()
|
||
.then(|| {
|
||
export_id.as_deref().and_then(|export_id| {
|
||
groups_by_name
|
||
.values()
|
||
.find(|group| group.id == export_id)
|
||
.cloned()
|
||
})
|
||
})
|
||
.flatten();
|
||
if mode.is_rollback_checkpoint() && export_id.is_some() && existing_by_id.is_none() {
|
||
return Ok(Err(invalid_request(format!(
|
||
"回滚检查点用户组 '{}' 不存在;拒绝按名称匹配",
|
||
export_id.as_deref().unwrap_or_default()
|
||
))));
|
||
}
|
||
if let Some(existing) =
|
||
existing_by_id.or_else(|| groups_by_name.get(&normalized_name).cloned())
|
||
{
|
||
if let Some(export_id) = export_id {
|
||
imported_group_id_map.insert(export_id, existing.id.clone());
|
||
}
|
||
imported_group_name_map.insert(normalized_name.clone(), existing.id.clone());
|
||
match merge_mode {
|
||
AdminImportMergeMode::Skip => {
|
||
stats.user_groups.skipped += 1;
|
||
}
|
||
AdminImportMergeMode::Error => {
|
||
return Ok(Err(invalid_request(format!(
|
||
"用户组 '{}' 已存在",
|
||
existing.name
|
||
))));
|
||
}
|
||
AdminImportMergeMode::Overwrite => {
|
||
if let Some(journal) = mutation_journal.as_deref_mut() {
|
||
journal
|
||
.existing_user_groups
|
||
.entry(existing.id.clone())
|
||
.or_insert_with(|| ExistingUserGroupMutation {
|
||
before: existing.clone(),
|
||
after: existing.clone(),
|
||
});
|
||
}
|
||
let Some(updated) = self.update_user_group(&existing.id, record).await?
|
||
else {
|
||
return Ok(Err((
|
||
http::StatusCode::SERVICE_UNAVAILABLE,
|
||
json!({ "detail": "Admin system data unavailable" }),
|
||
)));
|
||
};
|
||
if let Some(journal) = mutation_journal.as_deref_mut() {
|
||
if let Some(mutation) =
|
||
journal.existing_user_groups.get_mut(&existing.id)
|
||
{
|
||
mutation.after = updated.clone();
|
||
}
|
||
}
|
||
groups_by_name.retain(|_, group| group.id != existing.id);
|
||
groups_by_name.insert(normalized_name, updated);
|
||
stats.user_groups.updated += 1;
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
|
||
let Some(created) = self.create_user_group(record).await? else {
|
||
return Ok(Err((
|
||
http::StatusCode::SERVICE_UNAVAILABLE,
|
||
json!({ "detail": "Admin system data unavailable" }),
|
||
)));
|
||
};
|
||
if let Some(journal) = mutation_journal.as_deref_mut() {
|
||
journal.user_group_ids.insert(created.id.clone());
|
||
}
|
||
if let Some(export_id) = export_id {
|
||
imported_group_id_map.insert(export_id, created.id.clone());
|
||
}
|
||
imported_group_name_map.insert(normalized_name.clone(), created.id.clone());
|
||
groups_by_name.insert(normalized_name, created);
|
||
stats.user_groups.created += 1;
|
||
}
|
||
|
||
for (index, raw_user) in users.iter().enumerate() {
|
||
let user = match imported_object_field(raw_user, &format!("users[{index}]")) {
|
||
Ok(value) => value,
|
||
Err(detail) => return Ok(Err(invalid_request(detail))),
|
||
};
|
||
let source_user_id = invalid_value!(imported_optional_string(user.get("id")));
|
||
invalid_value!(validate_rollback_user_source_id(
|
||
mode,
|
||
source_user_id.as_deref(),
|
||
));
|
||
let Some(role) =
|
||
invalid_value!(normalize_imported_system_user_role(user.get("role"), mode,))
|
||
else {
|
||
let skipped_email = invalid_value!(imported_optional_string(user.get("email")));
|
||
let skipped_username =
|
||
invalid_value!(imported_optional_string(user.get("username")));
|
||
stats.users.skipped += 1;
|
||
stats.errors.push(format!(
|
||
"跳过受保护的管理员用户: {}",
|
||
skipped_email
|
||
.or(skipped_username)
|
||
.unwrap_or_else(|| format!("users[{index}]"))
|
||
));
|
||
continue;
|
||
};
|
||
|
||
let email = invalid_value!(imported_optional_string(user.get("email")))
|
||
.map(|value| value.to_ascii_lowercase());
|
||
let email_verified =
|
||
invalid_value!(imported_optional_bool(user.get("email_verified"))).unwrap_or(true);
|
||
let username = invalid_value!(imported_optional_string(user.get("username")))
|
||
.or_else(|| {
|
||
email.as_ref().map(|value| {
|
||
value
|
||
.split('@')
|
||
.next()
|
||
.unwrap_or(value.as_str())
|
||
.to_string()
|
||
})
|
||
})
|
||
.unwrap_or_else(|| format!("imported-user-{index}"));
|
||
let password_hash = invalid_value!(resolve_imported_password_hash(
|
||
user,
|
||
users_export_version,
|
||
mode,
|
||
));
|
||
let allowed_providers = invalid_value!(normalize_imported_user_string_list(
|
||
user,
|
||
"allowed_providers"
|
||
));
|
||
let allowed_api_formats = invalid_value!(normalize_imported_user_api_formats(
|
||
user,
|
||
"allowed_api_formats"
|
||
));
|
||
let allowed_models =
|
||
invalid_value!(normalize_imported_user_string_list(user, "allowed_models"));
|
||
let rate_limit =
|
||
invalid_value!(imported_optional_i32(user.get("rate_limit"), "rate_limit"));
|
||
let allowed_providers_mode = invalid_value!(imported_user_list_policy_mode(
|
||
user,
|
||
"allowed_providers_mode",
|
||
"allowed_providers",
|
||
&allowed_providers,
|
||
));
|
||
let allowed_api_formats_mode = invalid_value!(imported_user_list_policy_mode(
|
||
user,
|
||
"allowed_api_formats_mode",
|
||
"allowed_api_formats",
|
||
&allowed_api_formats,
|
||
));
|
||
let allowed_models_mode = invalid_value!(imported_user_list_policy_mode(
|
||
user,
|
||
"allowed_models_mode",
|
||
"allowed_models",
|
||
&allowed_models,
|
||
));
|
||
let rate_limit_mode = invalid_value!(imported_user_rate_limit_policy_mode(
|
||
user,
|
||
"rate_limit_mode",
|
||
"rate_limit",
|
||
rate_limit,
|
||
));
|
||
let imported_user_group_ids = invalid_value!(resolve_imported_user_group_ids(
|
||
user,
|
||
&imported_group_id_map,
|
||
&imported_group_name_map,
|
||
&groups_by_name,
|
||
));
|
||
let group_ids = if user.contains_key("group_ids") || user.contains_key("group_names") {
|
||
let group_ids = self
|
||
.include_default_user_group_ids(&imported_user_group_ids)
|
||
.await?;
|
||
if !group_ids.is_empty() {
|
||
let existing_groups = self.list_user_groups_by_ids(&group_ids).await?;
|
||
if existing_groups.len() != group_ids.len() {
|
||
return Ok(Err(invalid_request(format!(
|
||
"用户 '{}' 的用户组不存在",
|
||
email.clone().unwrap_or(username.clone())
|
||
))));
|
||
}
|
||
}
|
||
Some(group_ids)
|
||
} else {
|
||
None
|
||
};
|
||
let is_active =
|
||
invalid_value!(imported_optional_bool(user.get("is_active"))).unwrap_or(true);
|
||
let model_capability_settings = invalid_value!(imported_optional_json_object(
|
||
user.get("model_capability_settings"),
|
||
"model_capability_settings"
|
||
));
|
||
let feature_settings = invalid_value!(imported_optional_json_object(
|
||
user.get("feature_settings"),
|
||
"feature_settings"
|
||
)
|
||
.and_then(normalize_admin_feature_settings));
|
||
let wallet_payload = match user.get("wallet") {
|
||
Some(Value::Object(map)) => Some(map),
|
||
Some(Value::Null) | None => None,
|
||
Some(_) => return Ok(Err(invalid_request("wallet 必须是对象"))),
|
||
};
|
||
let wallet_target = match wallet_payload {
|
||
Some(wallet) => Some(invalid_value!(normalize_imported_wallet_target(
|
||
Some(wallet),
|
||
false,
|
||
))),
|
||
None => None,
|
||
};
|
||
|
||
// Checkpoint IDs are the only safe identity during compensation. Email and username
|
||
// are mutable fields and may have been changed by the failed import itself. Refuse to
|
||
// guess if the stable row disappeared instead of overwriting an unrelated account.
|
||
let existing_user = if mode.is_rollback_checkpoint() {
|
||
let source_user_id = source_user_id.as_deref().unwrap_or_default();
|
||
let existing = self.find_user_auth_by_id(source_user_id).await?;
|
||
if existing.is_none() {
|
||
return Ok(Err(invalid_request(format!(
|
||
"回滚检查点用户 '{source_user_id}' 不存在;拒绝按 email/username 匹配"
|
||
))));
|
||
}
|
||
existing
|
||
} else {
|
||
let mut existing = if let Some(email) = email.as_deref() {
|
||
self.find_user_auth_by_identifier(email).await?
|
||
} else {
|
||
None
|
||
};
|
||
if existing.is_none() {
|
||
existing = self.find_user_auth_by_identifier(&username).await?;
|
||
}
|
||
existing
|
||
};
|
||
|
||
let user_id = if let Some(existing) = existing_user {
|
||
if imported_existing_user_is_protected(&existing.role, mode) {
|
||
stats.users.skipped += 1;
|
||
stats.errors.push(format!(
|
||
"跳过受保护的管理员用户记录: {}",
|
||
email.clone().unwrap_or(username.clone())
|
||
));
|
||
continue;
|
||
}
|
||
match merge_mode {
|
||
AdminImportMergeMode::Skip => {
|
||
stats.users.skipped += 1;
|
||
continue;
|
||
}
|
||
AdminImportMergeMode::Error => {
|
||
return Ok(Err(invalid_request(format!(
|
||
"用户 '{}' 已存在",
|
||
email.clone().unwrap_or(username.clone())
|
||
))));
|
||
}
|
||
AdminImportMergeMode::Overwrite => {
|
||
if let Some(journal) = mutation_journal.as_deref_mut() {
|
||
self.capture_existing_user_mutation(journal, &existing)
|
||
.await?;
|
||
}
|
||
if let Some(email) = email.as_deref() {
|
||
if self
|
||
.is_other_user_auth_email_taken(email, &existing.id)
|
||
.await?
|
||
{
|
||
return Ok(Err(invalid_request(format!("邮箱已存在: {email}"))));
|
||
}
|
||
}
|
||
if self
|
||
.is_other_user_auth_username_taken(&username, &existing.id)
|
||
.await?
|
||
{
|
||
return Ok(Err(invalid_request(format!("用户名已存在: {username}"))));
|
||
}
|
||
let email_present = email.is_some() || mode.is_rollback_checkpoint();
|
||
let email_verified_update = if mode.is_rollback_checkpoint() {
|
||
user.contains_key("email_verified")
|
||
.then_some(email_verified)
|
||
} else {
|
||
email.as_deref().and_then(|email| {
|
||
existing
|
||
.email
|
||
.as_deref()
|
||
.is_none_or(|current| {
|
||
!current.trim().eq_ignore_ascii_case(email.trim())
|
||
})
|
||
.then_some(email_verified)
|
||
})
|
||
};
|
||
let updated_profile = self
|
||
.update_local_auth_user_profile(
|
||
&existing.id,
|
||
email_present,
|
||
email.clone(),
|
||
email_verified_update,
|
||
Some(username.clone()),
|
||
)
|
||
.await?;
|
||
if updated_profile.is_none() {
|
||
return Ok(Err((
|
||
http::StatusCode::SERVICE_UNAVAILABLE,
|
||
json!({ "detail": "Admin system data unavailable" }),
|
||
)));
|
||
}
|
||
self.refresh_existing_user_mutation(
|
||
mutation_journal.as_deref_mut(),
|
||
&existing.id,
|
||
)
|
||
.await?;
|
||
if let Some(password_hash) =
|
||
password_hash.as_deref().filter(|value| !value.is_empty())
|
||
{
|
||
let updated_password = self
|
||
.reset_local_auth_user_password_and_revoke_sessions(
|
||
&existing.id,
|
||
password_hash.to_string(),
|
||
chrono::Utc::now(),
|
||
)
|
||
.await?;
|
||
if !updated_password {
|
||
return Ok(Err((
|
||
http::StatusCode::SERVICE_UNAVAILABLE,
|
||
json!({ "detail": "Admin system data unavailable" }),
|
||
)));
|
||
}
|
||
self.refresh_existing_user_mutation(
|
||
mutation_journal.as_deref_mut(),
|
||
&existing.id,
|
||
)
|
||
.await?;
|
||
}
|
||
let updated_admin_fields = self
|
||
.update_local_auth_user_admin_fields(
|
||
&existing.id,
|
||
Some(role.clone()),
|
||
user.contains_key("allowed_providers"),
|
||
allowed_providers.clone(),
|
||
user.contains_key("allowed_api_formats"),
|
||
allowed_api_formats.clone(),
|
||
user.contains_key("allowed_models"),
|
||
allowed_models.clone(),
|
||
user.contains_key("rate_limit"),
|
||
rate_limit,
|
||
Some(is_active),
|
||
)
|
||
.await?;
|
||
if updated_admin_fields.is_none() {
|
||
return Ok(Err((
|
||
http::StatusCode::SERVICE_UNAVAILABLE,
|
||
json!({ "detail": "Admin system data unavailable" }),
|
||
)));
|
||
}
|
||
self.refresh_existing_user_mutation(
|
||
mutation_journal.as_deref_mut(),
|
||
&existing.id,
|
||
)
|
||
.await?;
|
||
if user.contains_key("email_verified") {
|
||
stats.errors.push(format!(
|
||
"用户 '{}' 的 email_verified 当前不会覆盖已有值",
|
||
email.clone().unwrap_or(username.clone())
|
||
));
|
||
}
|
||
if user.contains_key("model_capability_settings") {
|
||
let updated = self
|
||
.update_user_model_capability_settings(
|
||
&existing.id,
|
||
model_capability_settings.clone(),
|
||
)
|
||
.await?;
|
||
if model_capability_settings.is_some() && updated.is_none() {
|
||
return Ok(Err((
|
||
http::StatusCode::SERVICE_UNAVAILABLE,
|
||
json!({ "detail": "Admin system data unavailable" }),
|
||
)));
|
||
}
|
||
self.refresh_existing_user_mutation(
|
||
mutation_journal.as_deref_mut(),
|
||
&existing.id,
|
||
)
|
||
.await?;
|
||
}
|
||
if user.contains_key("feature_settings") {
|
||
let updated = self
|
||
.update_user_feature_settings(
|
||
&existing.id,
|
||
feature_settings.clone(),
|
||
)
|
||
.await?;
|
||
if feature_settings.is_some() && updated.is_none() {
|
||
return Ok(Err((
|
||
http::StatusCode::SERVICE_UNAVAILABLE,
|
||
json!({ "detail": "Admin system data unavailable" }),
|
||
)));
|
||
}
|
||
self.refresh_existing_user_mutation(
|
||
mutation_journal.as_deref_mut(),
|
||
&existing.id,
|
||
)
|
||
.await?;
|
||
}
|
||
if allowed_providers_mode.is_some()
|
||
|| allowed_api_formats_mode.is_some()
|
||
|| allowed_models_mode.is_some()
|
||
|| rate_limit_mode.is_some()
|
||
{
|
||
let updated_policy_modes = self
|
||
.update_local_auth_user_policy_modes(
|
||
&existing.id,
|
||
allowed_providers_mode.clone(),
|
||
allowed_api_formats_mode.clone(),
|
||
allowed_models_mode.clone(),
|
||
rate_limit_mode.clone(),
|
||
)
|
||
.await?;
|
||
if updated_policy_modes.is_none() {
|
||
return Ok(Err((
|
||
http::StatusCode::SERVICE_UNAVAILABLE,
|
||
json!({ "detail": "Admin system data unavailable" }),
|
||
)));
|
||
}
|
||
self.refresh_existing_user_mutation(
|
||
mutation_journal.as_deref_mut(),
|
||
&existing.id,
|
||
)
|
||
.await?;
|
||
}
|
||
if let Some(group_ids) = group_ids.as_ref() {
|
||
let persisted_groups = self
|
||
.replace_user_groups_for_user(&existing.id, group_ids)
|
||
.await?;
|
||
if persisted_groups.len() != group_ids.len() {
|
||
return Ok(Err(invalid_request(format!(
|
||
"用户 '{}' 的用户组未能完整写入",
|
||
email.clone().unwrap_or(username.clone())
|
||
))));
|
||
}
|
||
self.refresh_existing_user_mutation(
|
||
mutation_journal.as_deref_mut(),
|
||
&existing.id,
|
||
)
|
||
.await?;
|
||
}
|
||
if let Some(wallet_target) = wallet_target.as_ref() {
|
||
self.sync_imported_user_wallet(
|
||
&existing.id,
|
||
wallet_target,
|
||
&email.clone().unwrap_or(username.clone()),
|
||
mutation_journal.as_deref_mut(),
|
||
)
|
||
.await?;
|
||
}
|
||
stats.users.updated += 1;
|
||
existing.id
|
||
}
|
||
}
|
||
} else {
|
||
let created = self
|
||
.create_local_auth_user_with_settings(
|
||
email.clone(),
|
||
email_verified,
|
||
username.clone(),
|
||
password_hash.unwrap_or_else(imported_password_tombstone),
|
||
role.clone(),
|
||
allowed_providers.clone(),
|
||
allowed_api_formats.clone(),
|
||
allowed_models.clone(),
|
||
rate_limit,
|
||
)
|
||
.await?;
|
||
let Some(created) = created else {
|
||
return Ok(Err((
|
||
http::StatusCode::SERVICE_UNAVAILABLE,
|
||
json!({ "detail": "Admin system data unavailable" }),
|
||
)));
|
||
};
|
||
if let Some(journal) = mutation_journal.as_deref_mut() {
|
||
journal.user_ids.insert(created.id.clone());
|
||
}
|
||
if user.contains_key("model_capability_settings") {
|
||
let updated = self
|
||
.update_user_model_capability_settings(
|
||
&created.id,
|
||
model_capability_settings.clone(),
|
||
)
|
||
.await?;
|
||
if model_capability_settings.is_some() && updated.is_none() {
|
||
return Ok(Err((
|
||
http::StatusCode::SERVICE_UNAVAILABLE,
|
||
json!({ "detail": "Admin system data unavailable" }),
|
||
)));
|
||
}
|
||
}
|
||
if user.contains_key("feature_settings") {
|
||
let updated = self
|
||
.update_user_feature_settings(&created.id, feature_settings.clone())
|
||
.await?;
|
||
if feature_settings.is_some() && updated.is_none() {
|
||
return Ok(Err((
|
||
http::StatusCode::SERVICE_UNAVAILABLE,
|
||
json!({ "detail": "Admin system data unavailable" }),
|
||
)));
|
||
}
|
||
}
|
||
let created = if allowed_providers_mode.is_some()
|
||
|| allowed_api_formats_mode.is_some()
|
||
|| allowed_models_mode.is_some()
|
||
|| rate_limit_mode.is_some()
|
||
{
|
||
let Some(updated_policy_modes) = self
|
||
.update_local_auth_user_policy_modes(
|
||
&created.id,
|
||
allowed_providers_mode.clone(),
|
||
allowed_api_formats_mode.clone(),
|
||
allowed_models_mode.clone(),
|
||
rate_limit_mode.clone(),
|
||
)
|
||
.await?
|
||
else {
|
||
return Ok(Err((
|
||
http::StatusCode::SERVICE_UNAVAILABLE,
|
||
json!({ "detail": "Admin system data unavailable" }),
|
||
)));
|
||
};
|
||
updated_policy_modes
|
||
} else {
|
||
created
|
||
};
|
||
if let Some(group_ids) = group_ids.as_ref() {
|
||
let persisted_groups = self
|
||
.replace_user_groups_for_user(&created.id, group_ids)
|
||
.await?;
|
||
if persisted_groups.len() != group_ids.len() {
|
||
return Ok(Err(invalid_request(format!(
|
||
"用户 '{}' 的用户组未能完整写入",
|
||
email.clone().unwrap_or(username.clone())
|
||
))));
|
||
}
|
||
}
|
||
if let Some(wallet_target) = wallet_target.as_ref() {
|
||
self.sync_imported_user_wallet(
|
||
&created.id,
|
||
wallet_target,
|
||
&email.clone().unwrap_or(username.clone()),
|
||
mutation_journal.as_deref_mut(),
|
||
)
|
||
.await?;
|
||
}
|
||
stats.users.created += 1;
|
||
created.id
|
||
};
|
||
if let Some(source_user_id) = source_user_id {
|
||
imported_user_id_map.insert(source_user_id, user_id.clone());
|
||
}
|
||
|
||
let existing_api_keys = self
|
||
.list_auth_api_key_export_records_by_user_ids(std::slice::from_ref(&user_id))
|
||
.await?
|
||
.into_iter()
|
||
.filter(|record| !record.is_standalone)
|
||
.collect::<Vec<_>>();
|
||
let imported_api_keys = match user.get("api_keys") {
|
||
Some(Value::Array(items)) => items,
|
||
Some(_) => return Ok(Err(invalid_request("api_keys 必须是数组"))),
|
||
None => &empty,
|
||
};
|
||
let mut existing_api_keys_by_hash = BTreeMap::new();
|
||
for record in existing_api_keys {
|
||
let api_key_id = record.api_key_id.clone();
|
||
existing_api_keys_by_hash.insert(record.key_hash.clone(), record.clone());
|
||
if mode.is_rollback_checkpoint() {
|
||
existing_api_keys_by_hash
|
||
.entry(imported_api_key_tombstone(&api_key_id))
|
||
.or_insert(record);
|
||
}
|
||
}
|
||
|
||
for (key_index, raw_key) in imported_api_keys.iter().enumerate() {
|
||
let key = match imported_object_field(
|
||
raw_key,
|
||
&format!("users[{index}].api_keys[{key_index}]"),
|
||
) {
|
||
Ok(value) => value,
|
||
Err(detail) => return Ok(Err(invalid_request(detail))),
|
||
};
|
||
let Some(key_material) = invalid_value!(self
|
||
.resolve_imported_system_user_api_key_material(
|
||
key,
|
||
users_export_version,
|
||
mode,
|
||
))
|
||
else {
|
||
stats.api_keys.skipped += 1;
|
||
stats.errors.push(format!(
|
||
"跳过无效 API Key: 用户 '{}'",
|
||
email.clone().unwrap_or(username.clone())
|
||
));
|
||
continue;
|
||
};
|
||
let key_hash = key_material.key_hash;
|
||
let key_plaintext = key_material.key_plaintext;
|
||
let source_api_key_id =
|
||
invalid_value!(imported_optional_string(key.get("api_key_id")));
|
||
let name = invalid_value!(imported_optional_string(key.get("name")));
|
||
let allowed_providers = invalid_value!(normalize_imported_user_string_list(
|
||
key,
|
||
"allowed_providers"
|
||
));
|
||
let allowed_api_formats = invalid_value!(normalize_imported_user_api_formats(
|
||
key,
|
||
"allowed_api_formats"
|
||
));
|
||
let allowed_models =
|
||
invalid_value!(normalize_imported_user_string_list(key, "allowed_models"));
|
||
let ip_rules = invalid_value!(normalize_imported_user_ip_rules(key));
|
||
let imported_rate_limit =
|
||
invalid_value!(imported_optional_i32(key.get("rate_limit"), "rate_limit"));
|
||
// Legacy uploads historically normalize an omitted rate limit to zero. Rollback
|
||
// checkpoints instead preserve the nullable database value exactly.
|
||
let rate_limit = imported_rate_limit.unwrap_or(0);
|
||
let rate_limit_value = if mode.is_rollback_checkpoint() {
|
||
imported_rate_limit
|
||
} else {
|
||
Some(rate_limit)
|
||
};
|
||
let concurrent_limit = invalid_value!(imported_optional_i32(
|
||
key.get("concurrent_limit"),
|
||
"concurrent_limit"
|
||
));
|
||
if concurrent_limit.is_some_and(|value| value < 0) {
|
||
return Ok(Err(invalid_request("concurrent_limit 必须是非负整数")));
|
||
}
|
||
let force_capabilities = imported_optional_value(key.get("force_capabilities"));
|
||
let is_active =
|
||
invalid_value!(imported_optional_bool(key.get("is_active"))).unwrap_or(false);
|
||
let expires_at_unix_secs = invalid_value!(imported_rfc3339_to_unix_secs(
|
||
key.get("expires_at"),
|
||
"expires_at"
|
||
));
|
||
let auto_delete_on_expiry =
|
||
invalid_value!(imported_optional_bool(key.get("auto_delete_on_expiry")))
|
||
.unwrap_or(false);
|
||
let imported_total_requests = invalid_value!(imported_optional_u64(
|
||
key.get("total_requests"),
|
||
"total_requests"
|
||
));
|
||
let total_requests = imported_total_requests.unwrap_or(0);
|
||
let imported_total_tokens = invalid_value!(imported_optional_u64(
|
||
key.get("total_tokens"),
|
||
"total_tokens"
|
||
));
|
||
let total_tokens = imported_total_tokens.unwrap_or(0);
|
||
let imported_total_cost_usd = invalid_value!(imported_optional_f64(
|
||
key.get("total_cost_usd"),
|
||
"total_cost_usd"
|
||
));
|
||
let total_cost_usd = imported_total_cost_usd.unwrap_or(0.0);
|
||
let feature_settings = invalid_value!(imported_optional_json_object(
|
||
key.get("feature_settings"),
|
||
"feature_settings"
|
||
)
|
||
.and_then(normalize_admin_feature_settings));
|
||
|
||
if let Some(existing_key) = existing_api_keys_by_hash.get(&key_hash).cloned() {
|
||
match merge_mode {
|
||
AdminImportMergeMode::Skip => {
|
||
stats.api_keys.skipped += 1;
|
||
}
|
||
AdminImportMergeMode::Error => {
|
||
return Ok(Err(invalid_request(format!(
|
||
"用户 '{}' 的 API Key 已存在",
|
||
email.clone().unwrap_or(username.clone())
|
||
))));
|
||
}
|
||
AdminImportMergeMode::Overwrite => {
|
||
let key_encrypted = invalid_value!(self
|
||
.seal_imported_auth_api_key_secret(
|
||
key_plaintext.as_deref(),
|
||
&user_id,
|
||
&existing_key.api_key_id,
|
||
&key_hash,
|
||
false,
|
||
));
|
||
if let Some(journal) = mutation_journal.as_deref_mut() {
|
||
let key = (user_id.clone(), existing_key.api_key_id.clone());
|
||
journal
|
||
.existing_user_api_keys
|
||
.entry(key)
|
||
.or_insert_with(|| ExistingApiKeyMutation {
|
||
before: existing_key.clone(),
|
||
after: existing_key.clone(),
|
||
});
|
||
}
|
||
let updated = self
|
||
.update_user_api_key_basic(
|
||
aether_data::repository::auth::UpdateUserApiKeyBasicRecord {
|
||
user_id: user_id.clone(),
|
||
api_key_id: existing_key.api_key_id.clone(),
|
||
key_encrypted: key_encrypted.clone(),
|
||
key_encrypted_present: key_encrypted.is_some()
|
||
|| mode == SystemImportMode::RecoveryRollbackCheckpoint,
|
||
name: name.clone(),
|
||
name_present: name.is_some()
|
||
|| mode.is_rollback_checkpoint(),
|
||
rate_limit: rate_limit_value,
|
||
rate_limit_present: true,
|
||
concurrent_limit: if key.contains_key("concurrent_limit")
|
||
|| mode.is_rollback_checkpoint()
|
||
{
|
||
concurrent_limit
|
||
} else {
|
||
None
|
||
},
|
||
concurrent_limit_present: key
|
||
.contains_key("concurrent_limit")
|
||
|| mode.is_rollback_checkpoint(),
|
||
ip_rules: imported_ip_rules_present(key)
|
||
.then(|| ip_rules.clone()),
|
||
feature_settings: key
|
||
.contains_key("feature_settings")
|
||
.then(|| feature_settings.clone()),
|
||
},
|
||
)
|
||
.await?;
|
||
if updated.is_none() {
|
||
return Ok(Err((
|
||
http::StatusCode::SERVICE_UNAVAILABLE,
|
||
json!({ "detail": "Admin system data unavailable" }),
|
||
)));
|
||
}
|
||
self.refresh_existing_api_key_mutation(
|
||
mutation_journal.as_deref_mut(),
|
||
Some(&user_id),
|
||
&existing_key.api_key_id,
|
||
false,
|
||
)
|
||
.await?;
|
||
let _ = require_persisted!(
|
||
self.set_user_api_key_allowed_providers(
|
||
&user_id,
|
||
&existing_key.api_key_id,
|
||
allowed_providers.clone(),
|
||
)
|
||
.await?
|
||
);
|
||
self.refresh_existing_api_key_mutation(
|
||
mutation_journal.as_deref_mut(),
|
||
Some(&user_id),
|
||
&existing_key.api_key_id,
|
||
false,
|
||
)
|
||
.await?;
|
||
let _ = require_persisted!(
|
||
self.set_user_api_key_force_capabilities(
|
||
&user_id,
|
||
&existing_key.api_key_id,
|
||
force_capabilities.clone(),
|
||
)
|
||
.await?
|
||
);
|
||
self.refresh_existing_api_key_mutation(
|
||
mutation_journal.as_deref_mut(),
|
||
Some(&user_id),
|
||
&existing_key.api_key_id,
|
||
false,
|
||
)
|
||
.await?;
|
||
let _ = require_persisted!(
|
||
self.set_user_api_key_active(
|
||
&user_id,
|
||
&existing_key.api_key_id,
|
||
mode.preserves_active_state() && is_active,
|
||
)
|
||
.await?
|
||
);
|
||
self.refresh_existing_api_key_mutation(
|
||
mutation_journal.as_deref_mut(),
|
||
Some(&user_id),
|
||
&existing_key.api_key_id,
|
||
false,
|
||
)
|
||
.await?;
|
||
if imported_total_requests.is_some()
|
||
|| imported_total_tokens.is_some()
|
||
|| imported_total_cost_usd.is_some()
|
||
{
|
||
let updated_usage = self
|
||
.set_api_key_usage_totals(
|
||
&existing_key.api_key_id,
|
||
imported_total_requests
|
||
.unwrap_or(existing_key.total_requests),
|
||
imported_total_tokens.unwrap_or(existing_key.total_tokens),
|
||
imported_total_cost_usd
|
||
.unwrap_or(existing_key.total_cost_usd),
|
||
)
|
||
.await?;
|
||
if updated_usage.is_none() {
|
||
return Ok(Err((
|
||
http::StatusCode::SERVICE_UNAVAILABLE,
|
||
json!({ "detail": "Admin system data unavailable" }),
|
||
)));
|
||
}
|
||
self.refresh_existing_api_key_mutation(
|
||
mutation_journal.as_deref_mut(),
|
||
Some(&user_id),
|
||
&existing_key.api_key_id,
|
||
false,
|
||
)
|
||
.await?;
|
||
}
|
||
if key.contains_key("allowed_api_formats")
|
||
|| key.contains_key("allowed_models")
|
||
|| key.contains_key("expires_at")
|
||
|| key.contains_key("auto_delete_on_expiry")
|
||
{
|
||
stats.errors.push(format!(
|
||
"用户 '{}' 的现有 API Key 仅覆盖基础字段;高级导入字段保持原值",
|
||
email.clone().unwrap_or(username.clone())
|
||
));
|
||
}
|
||
stats.api_keys.updated += 1;
|
||
if let Some(source_api_key_id) = source_api_key_id.clone() {
|
||
imported_api_key_id_map
|
||
.insert(source_api_key_id, existing_key.api_key_id.clone());
|
||
}
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
|
||
let api_key_id = imported_api_key_id_for_mode(source_api_key_id.as_deref(), mode);
|
||
let key_encrypted = invalid_value!(self.seal_imported_auth_api_key_secret(
|
||
key_plaintext.as_deref(),
|
||
&user_id,
|
||
&api_key_id,
|
||
&key_hash,
|
||
false,
|
||
));
|
||
let created = self
|
||
.create_user_api_key(aether_data::repository::auth::CreateUserApiKeyRecord {
|
||
user_id: user_id.clone(),
|
||
// Preserve a checkpoint API-key ID when a missing row must be recreated;
|
||
// ordinary imports continue to receive fresh IDs.
|
||
api_key_id,
|
||
key_hash: key_hash.clone(),
|
||
key_encrypted,
|
||
name,
|
||
allowed_providers,
|
||
allowed_api_formats,
|
||
allowed_models,
|
||
ip_rules,
|
||
rate_limit,
|
||
concurrent_limit,
|
||
force_capabilities,
|
||
feature_settings: feature_settings.clone(),
|
||
is_active: mode.preserves_active_state() && is_active,
|
||
expires_at_unix_secs,
|
||
auto_delete_on_expiry,
|
||
total_requests,
|
||
total_tokens,
|
||
total_cost_usd,
|
||
})
|
||
.await?;
|
||
let Some(created) = created else {
|
||
return Ok(Err((
|
||
http::StatusCode::SERVICE_UNAVAILABLE,
|
||
json!({ "detail": "Admin system data unavailable" }),
|
||
)));
|
||
};
|
||
let created_api_key_id = created.api_key_id.clone();
|
||
if let Some(journal) = mutation_journal.as_deref_mut() {
|
||
journal
|
||
.user_api_key_ids
|
||
.insert((user_id.clone(), created_api_key_id.clone()));
|
||
}
|
||
existing_api_keys_by_hash.insert(key_hash, created);
|
||
if let Some(source_api_key_id) = source_api_key_id {
|
||
imported_api_key_id_map.insert(source_api_key_id, created_api_key_id);
|
||
}
|
||
stats.api_keys.created += 1;
|
||
}
|
||
}
|
||
|
||
if !standalone_keys.is_empty() {
|
||
let Some(standalone_owner_id) = standalone_owner_id else {
|
||
stats.standalone_keys.skipped += standalone_keys.len() as u64;
|
||
stats
|
||
.errors
|
||
.push("无法导入独立余额 Key: 当前管理员用户记录不存在".to_string());
|
||
if !mode.is_rollback_checkpoint() {
|
||
if let Some(summary) = self
|
||
.import_admin_system_user_usage_aggregates(
|
||
root.get("usage_aggregates"),
|
||
&supplemental_user_usage_aggregates,
|
||
&imported_user_id_map,
|
||
&imported_api_key_id_map,
|
||
merge_mode,
|
||
)
|
||
.await?
|
||
{
|
||
stats.usage_aggregates = Some(summary);
|
||
}
|
||
}
|
||
return Ok(Ok(json!({
|
||
"message": "用户数据导入成功",
|
||
"stats": stats,
|
||
})));
|
||
};
|
||
|
||
let existing_standalone_keys = self
|
||
.list_auth_api_key_export_standalone_records()
|
||
.await?
|
||
.into_iter()
|
||
.collect::<Vec<_>>();
|
||
let mut existing_standalone_by_hash = BTreeMap::new();
|
||
for record in existing_standalone_keys {
|
||
let api_key_id = record.api_key_id.clone();
|
||
existing_standalone_by_hash.insert(record.key_hash.clone(), record.clone());
|
||
if mode.is_rollback_checkpoint() {
|
||
existing_standalone_by_hash
|
||
.entry(imported_api_key_tombstone(&api_key_id))
|
||
.or_insert(record);
|
||
}
|
||
}
|
||
|
||
for (index, raw_key) in standalone_keys.iter().enumerate() {
|
||
let key = match imported_object_field(raw_key, &format!("standalone_keys[{index}]"))
|
||
{
|
||
Ok(value) => value,
|
||
Err(detail) => return Ok(Err(invalid_request(detail))),
|
||
};
|
||
let Some(key_material) = invalid_value!(self
|
||
.resolve_imported_system_user_api_key_material(
|
||
key,
|
||
users_export_version,
|
||
mode,
|
||
))
|
||
else {
|
||
stats.standalone_keys.skipped += 1;
|
||
stats
|
||
.errors
|
||
.push(format!("跳过无效独立余额 Key: standalone_keys[{index}]"));
|
||
continue;
|
||
};
|
||
let key_hash = key_material.key_hash;
|
||
let key_plaintext = key_material.key_plaintext;
|
||
let source_api_key_id =
|
||
invalid_value!(imported_optional_string(key.get("api_key_id")));
|
||
let name = invalid_value!(imported_optional_string(key.get("name")));
|
||
let allowed_providers = invalid_value!(normalize_imported_user_string_list(
|
||
key,
|
||
"allowed_providers"
|
||
));
|
||
let allowed_api_formats = invalid_value!(normalize_imported_user_api_formats(
|
||
key,
|
||
"allowed_api_formats"
|
||
));
|
||
let allowed_models =
|
||
invalid_value!(normalize_imported_user_string_list(key, "allowed_models"));
|
||
let ip_rules = invalid_value!(normalize_imported_user_ip_rules(key));
|
||
let rate_limit =
|
||
invalid_value!(imported_optional_i32(key.get("rate_limit"), "rate_limit"))
|
||
.unwrap_or(0);
|
||
let concurrent_limit = invalid_value!(imported_optional_i32(
|
||
key.get("concurrent_limit"),
|
||
"concurrent_limit"
|
||
));
|
||
if concurrent_limit.is_some_and(|value| value < 0) {
|
||
return Ok(Err(invalid_request("concurrent_limit 必须是非负整数")));
|
||
}
|
||
let force_capabilities = imported_optional_value(key.get("force_capabilities"));
|
||
let is_active =
|
||
invalid_value!(imported_optional_bool(key.get("is_active"))).unwrap_or(false);
|
||
let expires_at_unix_secs = invalid_value!(imported_rfc3339_to_unix_secs(
|
||
key.get("expires_at"),
|
||
"expires_at"
|
||
));
|
||
let auto_delete_on_expiry =
|
||
invalid_value!(imported_optional_bool(key.get("auto_delete_on_expiry")))
|
||
.unwrap_or(false);
|
||
let imported_total_requests = invalid_value!(imported_optional_u64(
|
||
key.get("total_requests"),
|
||
"total_requests"
|
||
));
|
||
let total_requests = imported_total_requests.unwrap_or(0);
|
||
let imported_total_tokens = invalid_value!(imported_optional_u64(
|
||
key.get("total_tokens"),
|
||
"total_tokens"
|
||
));
|
||
let total_tokens = imported_total_tokens.unwrap_or(0);
|
||
let imported_total_cost_usd = invalid_value!(imported_optional_f64(
|
||
key.get("total_cost_usd"),
|
||
"total_cost_usd"
|
||
));
|
||
let total_cost_usd = imported_total_cost_usd.unwrap_or(0.0);
|
||
let feature_settings = invalid_value!(imported_optional_json_object(
|
||
key.get("feature_settings"),
|
||
"feature_settings"
|
||
)
|
||
.and_then(normalize_admin_feature_settings));
|
||
let wallet_payload = match key.get("wallet") {
|
||
Some(Value::Object(map)) => Some(map),
|
||
Some(Value::Null) | None => None,
|
||
Some(_) => return Ok(Err(invalid_request("wallet 必须是对象"))),
|
||
};
|
||
let unlimited =
|
||
invalid_value!(imported_optional_bool(key.get("unlimited"))).unwrap_or(false);
|
||
let wallet_target = match wallet_payload {
|
||
Some(wallet) => Some(invalid_value!(normalize_imported_wallet_target(
|
||
Some(wallet),
|
||
unlimited,
|
||
))),
|
||
None => None,
|
||
};
|
||
|
||
if let Some(existing_key) = existing_standalone_by_hash.get(&key_hash).cloned() {
|
||
match merge_mode {
|
||
AdminImportMergeMode::Skip => {
|
||
stats.standalone_keys.skipped += 1;
|
||
}
|
||
AdminImportMergeMode::Error => {
|
||
return Ok(Err(invalid_request("独立余额 Key 已存在")));
|
||
}
|
||
AdminImportMergeMode::Overwrite => {
|
||
let key_encrypted = invalid_value!(self
|
||
.seal_imported_auth_api_key_secret(
|
||
key_plaintext.as_deref(),
|
||
&existing_key.user_id,
|
||
&existing_key.api_key_id,
|
||
&key_hash,
|
||
true,
|
||
));
|
||
if let Some(journal) = mutation_journal.as_deref_mut() {
|
||
journal
|
||
.existing_standalone_api_keys
|
||
.entry(existing_key.api_key_id.clone())
|
||
.or_insert_with(|| ExistingApiKeyMutation {
|
||
before: existing_key.clone(),
|
||
after: existing_key.clone(),
|
||
});
|
||
}
|
||
let updated = self
|
||
.update_standalone_api_key_basic(
|
||
aether_data::repository::auth::UpdateStandaloneApiKeyBasicRecord {
|
||
api_key_id: existing_key.api_key_id.clone(),
|
||
key_encrypted: key_encrypted.clone(),
|
||
key_encrypted_present: key_encrypted.is_some()
|
||
|| mode == SystemImportMode::RecoveryRollbackCheckpoint,
|
||
name: name.clone(),
|
||
name_present: name.is_some(),
|
||
force_capabilities: None,
|
||
rate_limit_present: true,
|
||
rate_limit: Some(rate_limit),
|
||
concurrent_limit_present: key.contains_key("concurrent_limit"),
|
||
concurrent_limit,
|
||
allowed_providers: Some(allowed_providers.clone()),
|
||
allowed_api_formats: Some(allowed_api_formats.clone()),
|
||
allowed_models: Some(allowed_models.clone()),
|
||
ip_rules: imported_ip_rules_present(key)
|
||
.then(|| ip_rules.clone()),
|
||
expires_at_present: false,
|
||
expires_at_unix_secs: None,
|
||
auto_delete_on_expiry_present: false,
|
||
auto_delete_on_expiry: false,
|
||
},
|
||
)
|
||
.await?;
|
||
if updated.is_none() {
|
||
return Ok(Err((
|
||
http::StatusCode::SERVICE_UNAVAILABLE,
|
||
json!({ "detail": "Admin system data unavailable" }),
|
||
)));
|
||
}
|
||
self.refresh_existing_api_key_mutation(
|
||
mutation_journal.as_deref_mut(),
|
||
None,
|
||
&existing_key.api_key_id,
|
||
true,
|
||
)
|
||
.await?;
|
||
let _ = require_persisted!(
|
||
self.set_standalone_api_key_active(
|
||
&existing_key.api_key_id,
|
||
mode.preserves_active_state() && is_active,
|
||
)
|
||
.await?
|
||
);
|
||
self.refresh_existing_api_key_mutation(
|
||
mutation_journal.as_deref_mut(),
|
||
None,
|
||
&existing_key.api_key_id,
|
||
true,
|
||
)
|
||
.await?;
|
||
if key.contains_key("feature_settings") {
|
||
let _ = require_persisted!(
|
||
self.set_standalone_api_key_feature_settings(
|
||
&existing_key.api_key_id,
|
||
feature_settings.clone(),
|
||
)
|
||
.await?
|
||
);
|
||
self.refresh_existing_api_key_mutation(
|
||
mutation_journal.as_deref_mut(),
|
||
None,
|
||
&existing_key.api_key_id,
|
||
true,
|
||
)
|
||
.await?;
|
||
}
|
||
if imported_total_requests.is_some()
|
||
|| imported_total_tokens.is_some()
|
||
|| imported_total_cost_usd.is_some()
|
||
{
|
||
let updated_usage = self
|
||
.set_api_key_usage_totals(
|
||
&existing_key.api_key_id,
|
||
imported_total_requests
|
||
.unwrap_or(existing_key.total_requests),
|
||
imported_total_tokens.unwrap_or(existing_key.total_tokens),
|
||
imported_total_cost_usd
|
||
.unwrap_or(existing_key.total_cost_usd),
|
||
)
|
||
.await?;
|
||
if updated_usage.is_none() {
|
||
return Ok(Err((
|
||
http::StatusCode::SERVICE_UNAVAILABLE,
|
||
json!({ "detail": "Admin system data unavailable" }),
|
||
)));
|
||
}
|
||
self.refresh_existing_api_key_mutation(
|
||
mutation_journal.as_deref_mut(),
|
||
None,
|
||
&existing_key.api_key_id,
|
||
true,
|
||
)
|
||
.await?;
|
||
}
|
||
if key.contains_key("expires_at")
|
||
|| key.contains_key("auto_delete_on_expiry")
|
||
|| key.contains_key("force_capabilities")
|
||
{
|
||
stats.errors.push(
|
||
"现有独立余额 Key 仅覆盖基础字段;高级导入字段保持原值"
|
||
.to_string(),
|
||
);
|
||
}
|
||
if let Some(wallet_target) = wallet_target.as_ref() {
|
||
self.sync_imported_api_key_wallet(
|
||
&existing_key.api_key_id,
|
||
wallet_target,
|
||
key.get("name")
|
||
.and_then(Value::as_str)
|
||
.unwrap_or("独立余额 Key"),
|
||
mutation_journal.as_deref_mut(),
|
||
)
|
||
.await?;
|
||
}
|
||
stats.standalone_keys.updated += 1;
|
||
if let Some(source_api_key_id) = source_api_key_id.clone() {
|
||
imported_api_key_id_map
|
||
.insert(source_api_key_id, existing_key.api_key_id.clone());
|
||
}
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
|
||
let api_key_id = imported_api_key_id_for_mode(source_api_key_id.as_deref(), mode);
|
||
let key_encrypted = invalid_value!(self.seal_imported_auth_api_key_secret(
|
||
key_plaintext.as_deref(),
|
||
&standalone_owner_id,
|
||
&api_key_id,
|
||
&key_hash,
|
||
true,
|
||
));
|
||
let created = self
|
||
.create_standalone_api_key(
|
||
aether_data::repository::auth::CreateStandaloneApiKeyRecord {
|
||
user_id: standalone_owner_id.clone(),
|
||
api_key_id,
|
||
key_hash: key_hash.clone(),
|
||
key_encrypted,
|
||
name,
|
||
allowed_providers,
|
||
allowed_api_formats,
|
||
allowed_models,
|
||
ip_rules,
|
||
rate_limit: Some(rate_limit),
|
||
concurrent_limit,
|
||
force_capabilities,
|
||
is_active: mode.preserves_active_state() && is_active,
|
||
expires_at_unix_secs,
|
||
auto_delete_on_expiry,
|
||
total_requests,
|
||
total_tokens,
|
||
total_cost_usd,
|
||
},
|
||
)
|
||
.await?;
|
||
let Some(created) = created else {
|
||
return Ok(Err((
|
||
http::StatusCode::SERVICE_UNAVAILABLE,
|
||
json!({ "detail": "Admin system data unavailable" }),
|
||
)));
|
||
};
|
||
let created_api_key_id = created.api_key_id.clone();
|
||
if let Some(journal) = mutation_journal.as_deref_mut() {
|
||
journal
|
||
.standalone_api_key_ids
|
||
.insert(created_api_key_id.clone());
|
||
}
|
||
if key.contains_key("feature_settings") {
|
||
let _ = require_persisted!(
|
||
self.set_standalone_api_key_feature_settings(
|
||
&created.api_key_id,
|
||
feature_settings.clone(),
|
||
)
|
||
.await?
|
||
);
|
||
}
|
||
if let Some(wallet_target) = wallet_target.as_ref() {
|
||
self.sync_imported_api_key_wallet(
|
||
&created.api_key_id,
|
||
wallet_target,
|
||
created.name.as_deref().unwrap_or("独立余额 Key"),
|
||
mutation_journal.as_deref_mut(),
|
||
)
|
||
.await?;
|
||
}
|
||
existing_standalone_by_hash.insert(key_hash, created);
|
||
if let Some(source_api_key_id) = source_api_key_id {
|
||
imported_api_key_id_map.insert(source_api_key_id, created_api_key_id);
|
||
}
|
||
stats.standalone_keys.created += 1;
|
||
}
|
||
}
|
||
|
||
if !mode.is_rollback_checkpoint() {
|
||
if let Some(summary) = self
|
||
.import_admin_system_user_usage_aggregates(
|
||
root.get("usage_aggregates"),
|
||
&supplemental_user_usage_aggregates,
|
||
&imported_user_id_map,
|
||
&imported_api_key_id_map,
|
||
merge_mode,
|
||
)
|
||
.await?
|
||
{
|
||
stats.usage_aggregates = Some(summary);
|
||
}
|
||
}
|
||
|
||
Ok(Ok(json!({
|
||
"message": "用户数据导入成功",
|
||
"stats": stats,
|
||
})))
|
||
}
|
||
|
||
async fn import_admin_system_user_usage_aggregates(
|
||
&self,
|
||
value: Option<&Value>,
|
||
supplemental_user_daily: &[AdminSystemStatsUserDailyAggregate],
|
||
user_id_map: &BTreeMap<String, String>,
|
||
api_key_id_map: &BTreeMap<String, String>,
|
||
merge_mode: AdminImportMergeMode,
|
||
) -> Result<Option<AdminSystemUsageAggregateImportSummary>, GatewayError> {
|
||
let snapshot = build_imported_usage_aggregate_snapshot(value, supplemental_user_daily)
|
||
.map_err(|message| GatewayError::Client {
|
||
status: http::StatusCode::BAD_REQUEST,
|
||
message,
|
||
})?;
|
||
if snapshot.stats_daily.is_empty()
|
||
&& snapshot.stats_user_daily.is_empty()
|
||
&& snapshot.stats_daily_api_key.is_empty()
|
||
{
|
||
return Ok(None);
|
||
}
|
||
self.import_admin_system_usage_aggregates(
|
||
&snapshot,
|
||
user_id_map,
|
||
api_key_id_map,
|
||
usage_aggregate_import_mode(merge_mode),
|
||
)
|
||
.await
|
||
.map(Some)
|
||
}
|
||
|
||
async fn sync_imported_user_wallet(
|
||
&self,
|
||
user_id: &str,
|
||
wallet_target: &ImportedWalletTarget,
|
||
label: &str,
|
||
mut mutation_journal: Option<&mut AggregateMutationJournal>,
|
||
) -> Result<(), GatewayError> {
|
||
let initialized = self
|
||
.initialize_auth_user_wallet_with_outcome(user_id, 0.0, false)
|
||
.await?;
|
||
let Some(initialized) = initialized else {
|
||
return Err(GatewayError::Internal(format!(
|
||
"failed to initialize imported wallet for {label}"
|
||
)));
|
||
};
|
||
if initialized.wallet.user_id.as_deref() != Some(user_id)
|
||
|| initialized.wallet.api_key_id.is_some()
|
||
{
|
||
return Err(GatewayError::Internal(format!(
|
||
"imported user wallet owner does not match {label}"
|
||
)));
|
||
}
|
||
let created_wallet_id = initialized.created.then(|| initialized.wallet.id.clone());
|
||
let existing_wallet_key =
|
||
(!initialized.created).then(|| (user_id.to_string(), initialized.wallet.id.clone()));
|
||
// Record only a row this invocation actually created. The repository returns this bit
|
||
// from the same atomic operation, so a concurrent initializer's wallet is never treated
|
||
// as import-owned and later deleted during compensation. The initial snapshot is a
|
||
// fallback for a failure before the imported values are persisted; a later successful
|
||
// sync replaces it with the complete post-import snapshot.
|
||
if let Some(wallet_id) = created_wallet_id.as_ref() {
|
||
if let Some(journal) = mutation_journal.as_deref_mut() {
|
||
journal.user_wallet_snapshots.insert(
|
||
(user_id.to_string(), wallet_id.clone()),
|
||
initialized.wallet.clone(),
|
||
);
|
||
}
|
||
}
|
||
if let Some(key) = existing_wallet_key.as_ref() {
|
||
if let Some(journal) = mutation_journal.as_deref_mut() {
|
||
journal
|
||
.existing_user_wallets
|
||
.entry(key.clone())
|
||
.or_insert_with(|| ExistingWalletMutation {
|
||
before: initialized.wallet.clone(),
|
||
after: None,
|
||
});
|
||
}
|
||
}
|
||
let synced = self
|
||
.sync_wallet_snapshot(WalletOwner::User(user_id), wallet_target, label)
|
||
.await?;
|
||
if let Some(key) = existing_wallet_key.as_ref() {
|
||
if let Some(journal) = mutation_journal.as_deref_mut() {
|
||
if let Some(mutation) = journal.existing_user_wallets.get_mut(key) {
|
||
mutation.after = Some(synced.clone());
|
||
}
|
||
}
|
||
}
|
||
if !Self::imported_wallet_snapshot_matches_target(&synced, wallet_target) {
|
||
return Err(GatewayError::Internal(format!(
|
||
"persisted imported wallet snapshot changed during sync for {label}"
|
||
)));
|
||
}
|
||
if let Some(wallet_id) = created_wallet_id {
|
||
if let Some(journal) = mutation_journal {
|
||
journal
|
||
.user_wallet_snapshots
|
||
.insert((user_id.to_string(), wallet_id), synced);
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
async fn sync_imported_api_key_wallet(
|
||
&self,
|
||
api_key_id: &str,
|
||
wallet_target: &ImportedWalletTarget,
|
||
label: &str,
|
||
mut mutation_journal: Option<&mut AggregateMutationJournal>,
|
||
) -> Result<(), GatewayError> {
|
||
let initialized = self
|
||
.initialize_auth_api_key_wallet_with_outcome(api_key_id, 0.0, false)
|
||
.await?;
|
||
let Some(initialized) = initialized else {
|
||
return Err(GatewayError::Internal(format!(
|
||
"failed to initialize imported wallet for {label}"
|
||
)));
|
||
};
|
||
if initialized.wallet.api_key_id.as_deref() != Some(api_key_id)
|
||
|| initialized.wallet.user_id.is_some()
|
||
{
|
||
return Err(GatewayError::Internal(format!(
|
||
"imported API-key wallet owner does not match {label}"
|
||
)));
|
||
}
|
||
let created_wallet_id = initialized.created.then(|| initialized.wallet.id.clone());
|
||
let existing_wallet_key =
|
||
(!initialized.created).then(|| (api_key_id.to_string(), initialized.wallet.id.clone()));
|
||
if let Some(wallet_id) = created_wallet_id.as_ref() {
|
||
if let Some(journal) = mutation_journal.as_deref_mut() {
|
||
journal.api_key_wallet_snapshots.insert(
|
||
(api_key_id.to_string(), wallet_id.clone()),
|
||
initialized.wallet.clone(),
|
||
);
|
||
}
|
||
}
|
||
if let Some(key) = existing_wallet_key.as_ref() {
|
||
if let Some(journal) = mutation_journal.as_deref_mut() {
|
||
journal
|
||
.existing_api_key_wallets
|
||
.entry(key.clone())
|
||
.or_insert_with(|| ExistingWalletMutation {
|
||
before: initialized.wallet.clone(),
|
||
after: None,
|
||
});
|
||
}
|
||
}
|
||
let synced = self
|
||
.sync_wallet_snapshot(WalletOwner::ApiKey(api_key_id), wallet_target, label)
|
||
.await?;
|
||
if let Some(key) = existing_wallet_key.as_ref() {
|
||
if let Some(journal) = mutation_journal.as_deref_mut() {
|
||
if let Some(mutation) = journal.existing_api_key_wallets.get_mut(key) {
|
||
mutation.after = Some(synced.clone());
|
||
}
|
||
}
|
||
}
|
||
if !Self::imported_wallet_snapshot_matches_target(&synced, wallet_target) {
|
||
return Err(GatewayError::Internal(format!(
|
||
"persisted imported wallet snapshot changed during sync for {label}"
|
||
)));
|
||
}
|
||
if let Some(wallet_id) = created_wallet_id {
|
||
if let Some(journal) = mutation_journal {
|
||
journal
|
||
.api_key_wallet_snapshots
|
||
.insert((api_key_id.to_string(), wallet_id), synced);
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
async fn sync_wallet_snapshot(
|
||
&self,
|
||
owner: WalletOwner<'_>,
|
||
wallet_target: &ImportedWalletTarget,
|
||
label: &str,
|
||
) -> Result<StoredWalletSnapshot, GatewayError> {
|
||
let updated = match owner {
|
||
WalletOwner::User(user_id) => {
|
||
self.update_auth_user_wallet_snapshot(
|
||
user_id,
|
||
wallet_target.recharge_balance,
|
||
wallet_target.gift_balance,
|
||
&wallet_target.limit_mode,
|
||
&wallet_target.currency,
|
||
&wallet_target.status,
|
||
wallet_target.total_recharged,
|
||
wallet_target.total_consumed,
|
||
wallet_target.total_refunded,
|
||
wallet_target.total_adjusted,
|
||
wallet_target.updated_at_unix_secs,
|
||
)
|
||
.await?
|
||
}
|
||
WalletOwner::ApiKey(api_key_id) => {
|
||
self.update_auth_api_key_wallet_snapshot(
|
||
api_key_id,
|
||
wallet_target.recharge_balance,
|
||
wallet_target.gift_balance,
|
||
&wallet_target.limit_mode,
|
||
&wallet_target.currency,
|
||
&wallet_target.status,
|
||
wallet_target.total_recharged,
|
||
wallet_target.total_consumed,
|
||
wallet_target.total_refunded,
|
||
wallet_target.total_adjusted,
|
||
wallet_target.updated_at_unix_secs,
|
||
)
|
||
.await?
|
||
}
|
||
};
|
||
let Some(updated) = updated else {
|
||
return Err(GatewayError::Internal(format!(
|
||
"failed to persist imported wallet snapshot for {label}"
|
||
)));
|
||
};
|
||
Ok(updated)
|
||
}
|
||
|
||
fn imported_wallet_snapshot_matches_target(
|
||
snapshot: &StoredWalletSnapshot,
|
||
target: &ImportedWalletTarget,
|
||
) -> bool {
|
||
const AMOUNT_EPSILON_USD: f64 = 0.00000001;
|
||
let amount_matches = |actual: f64, expected: f64| {
|
||
actual.is_finite()
|
||
&& expected.is_finite()
|
||
&& (actual - expected).abs() <= AMOUNT_EPSILON_USD
|
||
};
|
||
amount_matches(snapshot.balance, target.recharge_balance)
|
||
&& amount_matches(snapshot.gift_balance, target.gift_balance)
|
||
&& snapshot.limit_mode == target.limit_mode
|
||
&& snapshot.currency == target.currency
|
||
&& snapshot.status == target.status
|
||
&& amount_matches(snapshot.total_recharged, target.total_recharged)
|
||
&& amount_matches(snapshot.total_consumed, target.total_consumed)
|
||
&& amount_matches(snapshot.total_refunded, target.total_refunded)
|
||
&& amount_matches(snapshot.total_adjusted, target.total_adjusted)
|
||
&& target
|
||
.updated_at_unix_secs
|
||
.is_none_or(|expected| snapshot.updated_at_unix_secs == expected)
|
||
}
|
||
|
||
fn resolve_imported_system_user_api_key_material(
|
||
&self,
|
||
key: &Map<String, Value>,
|
||
users_export_version: (u32, u32),
|
||
mode: SystemImportMode,
|
||
) -> Result<Option<ImportedApiKeyMaterial>, String> {
|
||
let source_api_key_id = imported_optional_string(key.get("api_key_id"))?;
|
||
let plaintext_key = imported_optional_string(key.get("key"))?;
|
||
let key_hash = imported_optional_string(key.get("key_hash"))?;
|
||
let key_encrypted = imported_optional_string(key.get("key_encrypted"))?;
|
||
|
||
if users_export_version >= (1, 6) {
|
||
if key.contains_key("key")
|
||
|| key.contains_key("key_hash")
|
||
|| key.contains_key("key_encrypted")
|
||
{
|
||
return Err(
|
||
"用户数据 1.6+ 不允许包含 key、key_hash 或 key_encrypted 凭据字段".to_string(),
|
||
);
|
||
}
|
||
let credential_state = imported_optional_string(key.get("credential_state"))?;
|
||
if credential_state.as_deref() != Some("not_exported") {
|
||
return Err(
|
||
"用户数据 1.6+ API Key 必须标记 credential_state=not_exported".to_string(),
|
||
);
|
||
}
|
||
let source_api_key_id = source_api_key_id
|
||
.filter(|value| !value.is_empty())
|
||
.ok_or_else(|| "用户数据 1.6+ API Key 必须包含 api_key_id".to_string())?;
|
||
return Ok(Some(ImportedApiKeyMaterial {
|
||
key_hash: imported_credential_tombstone(&format!("api-key-id:{source_api_key_id}")),
|
||
key_plaintext: None,
|
||
}));
|
||
}
|
||
|
||
if mode.restores_credentials() {
|
||
let key_hash = key_hash
|
||
.filter(|value| !value.is_empty())
|
||
.ok_or_else(|| "恢复备份中的 API Key 必须包含 key_hash".to_string())?;
|
||
if key_hash.len() != 64
|
||
|| !key_hash
|
||
.bytes()
|
||
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
|
||
{
|
||
return Err("恢复备份中的 key_hash 必须是规范的小写 SHA-256 十六进制".to_string());
|
||
}
|
||
if plaintext_key.is_some() && key_encrypted.is_some() {
|
||
return Err("恢复备份中的 API Key 不能同时包含 key 和 key_encrypted".to_string());
|
||
}
|
||
let plaintext = match (plaintext_key, key_encrypted) {
|
||
(Some(plaintext), None) => Some(plaintext),
|
||
(None, Some(ciphertext)) => Some(
|
||
self.decrypt_catalog_secret_with_fallbacks(&ciphertext)
|
||
.ok_or_else(|| {
|
||
"恢复备份中的 API Key 旧密文无法使用当前或历史数据密钥解密".to_string()
|
||
})?,
|
||
),
|
||
(None, None) => None,
|
||
(Some(_), Some(_)) => unreachable!(),
|
||
};
|
||
if let Some(plaintext) = plaintext.as_deref() {
|
||
if hash_admin_user_api_key(plaintext) != key_hash {
|
||
return Err("恢复备份中的 API Key 明文与 key_hash 不匹配".to_string());
|
||
}
|
||
}
|
||
return Ok(Some(ImportedApiKeyMaterial {
|
||
key_hash,
|
||
key_plaintext: plaintext,
|
||
}));
|
||
}
|
||
|
||
let identity = source_api_key_id
|
||
.map(|value| format!("api-key-id:{value}"))
|
||
.or_else(|| key_hash.map(|value| format!("legacy-key-hash:{value}")))
|
||
.or_else(|| plaintext_key.map(|value| format!("legacy-key:{value}")))
|
||
.or_else(|| key_encrypted.map(|value| format!("legacy-key-encrypted:{value}")));
|
||
Ok(identity.map(|identity| ImportedApiKeyMaterial {
|
||
key_hash: imported_credential_tombstone(&identity),
|
||
key_plaintext: None,
|
||
}))
|
||
}
|
||
|
||
fn seal_imported_auth_api_key_secret(
|
||
&self,
|
||
plaintext: Option<&str>,
|
||
user_id: &str,
|
||
api_key_id: &str,
|
||
key_hash: &str,
|
||
is_standalone: bool,
|
||
) -> Result<Option<String>, String> {
|
||
plaintext
|
||
.map(|plaintext| {
|
||
seal_auth_api_key_secret(
|
||
self.app(),
|
||
user_id,
|
||
api_key_id,
|
||
key_hash,
|
||
is_standalone,
|
||
plaintext,
|
||
)
|
||
.map_err(|_| "gateway 无法为目的 API Key 记录加密恢复凭据".to_string())
|
||
})
|
||
.transpose()
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, Copy)]
|
||
enum WalletOwner<'a> {
|
||
User(&'a str),
|
||
ApiKey(&'a str),
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use std::sync::Arc;
|
||
|
||
use aether_data::repository::pool_scores::SqlitePoolMemberScoreRepository;
|
||
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
|
||
use aether_data_contracts::repository::provider_catalog::{
|
||
StoredProviderCatalogKey, StoredProviderCatalogProvider,
|
||
};
|
||
use serde_json::json;
|
||
|
||
use super::{
|
||
build_imported_user_usage_total_aggregates, imported_api_key_id_for_mode,
|
||
imported_credential_tombstone, imported_existing_user_is_protected,
|
||
imported_oauth_auth_config_has_credentials, imported_oauth_expiry_after_import,
|
||
imported_optional_bool, imported_optional_f64, imported_optional_i32,
|
||
imported_optional_u64, imported_rfc3339_to_unix_secs, imported_string_list_from_value,
|
||
normalize_import_endpoint_format, normalize_import_key_formats,
|
||
normalize_import_key_raw_payload, normalize_imported_system_user_role,
|
||
normalize_imported_wallet_target, prepare_imported_secret_safe_body_rules,
|
||
prepare_imported_secret_safe_header_rules, prepare_imported_secret_safe_json,
|
||
resolve_imported_password_hash, seed_imported_oauth_pool_score,
|
||
validate_imported_provider_key_credential_state,
|
||
validate_imported_system_users_export_version,
|
||
validate_imported_system_users_export_version_for_mode, ImportedProviderKey,
|
||
SystemImportMode,
|
||
};
|
||
use crate::admin_api::AdminAppState;
|
||
use crate::data::GatewayDataState;
|
||
use crate::AppState;
|
||
|
||
#[test]
|
||
fn users_import_requires_supported_export_version() {
|
||
assert!(validate_imported_system_users_export_version(Some(&json!("1.3"))).is_ok());
|
||
assert!(validate_imported_system_users_export_version(Some(&json!("1.4"))).is_ok());
|
||
assert!(validate_imported_system_users_export_version(Some(&json!("1.5"))).is_ok());
|
||
assert!(validate_imported_system_users_export_version(Some(&json!("1.6"))).is_ok());
|
||
assert_eq!(
|
||
validate_imported_system_users_export_version(Some(&json!("2.2"))).unwrap_err(),
|
||
"不支持的用户数据版本: 2.2,支持的版本: 1.3, 1.4, 1.5, 1.6"
|
||
);
|
||
assert_eq!(
|
||
validate_imported_system_users_export_version(Some(&json!(null))).unwrap_err(),
|
||
"version 必须是 x.y 字符串"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn recovery_users_import_requires_v15_and_valid_bcrypt() {
|
||
assert_eq!(
|
||
validate_imported_system_users_export_version_for_mode(
|
||
Some(&json!("1.5")),
|
||
SystemImportMode::RecoveryBackup,
|
||
),
|
||
Ok((1, 5)),
|
||
);
|
||
assert!(validate_imported_system_users_export_version_for_mode(
|
||
Some(&json!("1.4")),
|
||
SystemImportMode::RecoveryBackup,
|
||
)
|
||
.is_err());
|
||
assert!(resolve_imported_password_hash(
|
||
json!({ "password_hash": "attacker-controlled" })
|
||
.as_object()
|
||
.expect("fixture should be an object"),
|
||
(1, 5),
|
||
SystemImportMode::RecoveryBackup,
|
||
)
|
||
.is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn system_user_role_interactive_import_cannot_assign_admin_console_roles() {
|
||
assert_eq!(
|
||
normalize_imported_system_user_role(None, SystemImportMode::InteractiveUpload),
|
||
Ok(Some("user".to_string()))
|
||
);
|
||
assert_eq!(
|
||
normalize_imported_system_user_role(
|
||
Some(&json!(" ADMIN ")),
|
||
SystemImportMode::InteractiveUpload,
|
||
),
|
||
Ok(None)
|
||
);
|
||
assert_eq!(
|
||
normalize_imported_system_user_role(
|
||
Some(&json!("audit_admin")),
|
||
SystemImportMode::InteractiveUpload,
|
||
),
|
||
Ok(None)
|
||
);
|
||
assert_eq!(
|
||
normalize_imported_system_user_role(
|
||
Some(&json!("audit_admin")),
|
||
SystemImportMode::RollbackCheckpoint,
|
||
),
|
||
Ok(None)
|
||
);
|
||
assert!(normalize_imported_system_user_role(
|
||
Some(&json!("owner")),
|
||
SystemImportMode::InteractiveUpload,
|
||
)
|
||
.expect_err("unknown roles must be rejected before import writes")
|
||
.contains("不支持的用户角色"));
|
||
}
|
||
|
||
#[test]
|
||
fn system_user_role_authenticated_recovery_restores_only_audit_admin() {
|
||
for mode in [
|
||
SystemImportMode::RecoveryBackup,
|
||
SystemImportMode::RecoveryRollbackCheckpoint,
|
||
] {
|
||
assert_eq!(
|
||
normalize_imported_system_user_role(Some(&json!("audit_admin")), mode),
|
||
Ok(Some("audit_admin".to_string()))
|
||
);
|
||
assert_eq!(
|
||
normalize_imported_system_user_role(Some(&json!("admin")), mode),
|
||
Ok(None)
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn system_user_role_ordinary_import_protects_existing_admin_console_users() {
|
||
for mode in [
|
||
SystemImportMode::InteractiveUpload,
|
||
SystemImportMode::RollbackCheckpoint,
|
||
] {
|
||
assert!(imported_existing_user_is_protected("admin", mode));
|
||
assert!(imported_existing_user_is_protected("audit_admin", mode));
|
||
assert!(!imported_existing_user_is_protected("user", mode));
|
||
}
|
||
|
||
assert!(imported_existing_user_is_protected(
|
||
"admin",
|
||
SystemImportMode::RecoveryBackup,
|
||
));
|
||
assert!(!imported_existing_user_is_protected(
|
||
"audit_admin",
|
||
SystemImportMode::RecoveryBackup,
|
||
));
|
||
assert!(!imported_existing_user_is_protected(
|
||
"audit_admin",
|
||
SystemImportMode::RecoveryRollbackCheckpoint,
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn rollback_checkpoint_body_forces_overwrite_and_omits_local_proxy_nodes() {
|
||
let checkpoint = json!({
|
||
"version": "2.3",
|
||
"credential_state": "not_exported",
|
||
"proxy_nodes": [{"id": "local-node"}],
|
||
"system_configs": []
|
||
});
|
||
let body = super::build_aggregate_rollback_body(&checkpoint, true)
|
||
.expect("rollback body should serialize");
|
||
let parsed: serde_json::Value = serde_json::from_slice(&body).expect("body is JSON");
|
||
|
||
assert_eq!(parsed["merge_mode"], json!("overwrite"));
|
||
assert_eq!(parsed["proxy_nodes"], json!([]));
|
||
assert_eq!(parsed["credential_state"], json!("not_exported"));
|
||
}
|
||
|
||
#[test]
|
||
fn rollback_checkpoint_can_skip_ldap_without_dropping_other_config_sections() {
|
||
let checkpoint = json!({
|
||
"version": "2.3",
|
||
"ldap_config": {
|
||
"server_url": "ldaps://checkpoint.example.test",
|
||
"bind_dn": "cn=admin,dc=example,dc=test",
|
||
"base_dn": "dc=example,dc=test"
|
||
},
|
||
"system_configs": [{"key": "module.example.enabled", "value": true}],
|
||
});
|
||
let body = super::build_aggregate_rollback_body_with_options(&checkpoint, true, true)
|
||
.expect("rollback body should serialize");
|
||
let parsed: serde_json::Value = serde_json::from_slice(&body).expect("body is JSON");
|
||
|
||
assert!(parsed.get("ldap_config").is_none());
|
||
assert_eq!(parsed["system_configs"], checkpoint["system_configs"]);
|
||
assert_eq!(parsed["merge_mode"], json!("overwrite"));
|
||
assert_eq!(parsed["proxy_nodes"], json!([]));
|
||
}
|
||
|
||
#[test]
|
||
fn rollback_checkpoint_body_rejects_non_object() {
|
||
let error = super::build_aggregate_rollback_body(&json!([1, 2, 3]), false)
|
||
.expect_err("non-object checkpoint must be rejected");
|
||
assert!(error
|
||
.into_message()
|
||
.contains("checkpoint must be a JSON object"));
|
||
}
|
||
|
||
#[test]
|
||
fn aggregate_users_rollback_body_excludes_all_wallet_snapshots() {
|
||
let checkpoint = json!({
|
||
"version": "1.5",
|
||
"users": [{
|
||
"id": "user-1",
|
||
"username": "checkpoint-user",
|
||
"request_count": 100,
|
||
"total_tokens": 200,
|
||
"wallet": {"balance": 10.0},
|
||
"api_keys": [{
|
||
"api_key_id": "key-1",
|
||
"name": "user key",
|
||
"total_requests": 101,
|
||
"total_tokens": 201,
|
||
"total_cost_usd": 1.25,
|
||
"wallet": {"balance": 20.0}
|
||
}]
|
||
}],
|
||
"standalone_keys": [{
|
||
"api_key_id": "standalone-1",
|
||
"name": "standalone key",
|
||
"total_requests": 102,
|
||
"total_tokens": 202,
|
||
"total_cost_usd": 2.5,
|
||
"wallet": {"balance": 30.0}
|
||
}],
|
||
"usage_aggregates": {
|
||
"stats_daily": [{"date_unix_secs": 1}]
|
||
}
|
||
});
|
||
|
||
let body = super::build_aggregate_users_rollback_body(&checkpoint)
|
||
.expect("users rollback body should serialize");
|
||
let parsed: serde_json::Value = serde_json::from_slice(&body).expect("body is JSON");
|
||
|
||
assert_eq!(parsed["merge_mode"], json!("overwrite"));
|
||
assert_eq!(parsed["users"][0]["username"], json!("checkpoint-user"));
|
||
assert!(parsed["users"][0].get("request_count").is_none());
|
||
assert!(parsed["users"][0].get("total_tokens").is_none());
|
||
assert!(parsed["users"][0].get("wallet").is_none());
|
||
assert!(parsed["users"][0]["api_keys"][0].get("wallet").is_none());
|
||
assert!(parsed["users"][0]["api_keys"][0]
|
||
.get("total_requests")
|
||
.is_none());
|
||
assert!(parsed["users"][0]["api_keys"][0]
|
||
.get("total_tokens")
|
||
.is_none());
|
||
assert!(parsed["users"][0]["api_keys"][0]
|
||
.get("total_cost_usd")
|
||
.is_none());
|
||
assert!(parsed["standalone_keys"][0].get("wallet").is_none());
|
||
assert!(parsed["standalone_keys"][0].get("total_requests").is_none());
|
||
assert!(parsed["standalone_keys"][0].get("total_tokens").is_none());
|
||
assert!(parsed["standalone_keys"][0].get("total_cost_usd").is_none());
|
||
assert!(parsed.get("usage_aggregates").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn imported_api_key_tombstones_fit_legacy_columns_and_cannot_authenticate() {
|
||
use sha2::{Digest, Sha256};
|
||
|
||
let identity = "api-key-id:public-source-key-id";
|
||
let tombstone = imported_credential_tombstone(identity);
|
||
let normal_auth_hash = format!("{:x}", Sha256::digest(identity.as_bytes()));
|
||
|
||
assert_eq!(tombstone.len(), 64);
|
||
assert!(tombstone.starts_with("$aether-import-revoked$"));
|
||
assert!(!tombstone
|
||
.chars()
|
||
.all(|character| character.is_ascii_hexdigit()));
|
||
assert_ne!(tombstone, normal_auth_hash);
|
||
}
|
||
|
||
#[test]
|
||
fn rollback_checkpoint_reuses_api_key_id_while_interactive_imports_rotate_it() {
|
||
let source_id = Some("checkpoint-api-key-id");
|
||
let rollback_id =
|
||
imported_api_key_id_for_mode(source_id, SystemImportMode::RollbackCheckpoint);
|
||
assert_eq!(rollback_id, "checkpoint-api-key-id");
|
||
|
||
let interactive_id =
|
||
imported_api_key_id_for_mode(source_id, SystemImportMode::InteractiveUpload);
|
||
assert_ne!(interactive_id, "checkpoint-api-key-id");
|
||
assert!(!interactive_id.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn recovery_rollback_checkpoint_keeps_credentials_and_stable_ids() {
|
||
let mode = SystemImportMode::RecoveryRollbackCheckpoint;
|
||
assert!(mode.restores_credentials());
|
||
assert!(mode.preserves_active_state());
|
||
assert!(mode.is_rollback_checkpoint());
|
||
assert_eq!(
|
||
imported_api_key_id_for_mode(Some("recovery-key-id"), mode),
|
||
"recovery-key-id"
|
||
);
|
||
assert!(!SystemImportMode::InteractiveUpload.restores_credentials());
|
||
}
|
||
|
||
#[test]
|
||
fn rollback_checkpoint_requires_stable_user_id_instead_of_identifier_guessing() {
|
||
assert!(super::validate_rollback_user_source_id(
|
||
SystemImportMode::RollbackCheckpoint,
|
||
None,
|
||
)
|
||
.expect_err("rollback without a source ID must be rejected")
|
||
.contains("拒绝按 email/username 猜测用户"));
|
||
assert!(super::validate_rollback_user_source_id(
|
||
SystemImportMode::RecoveryRollbackCheckpoint,
|
||
None,
|
||
)
|
||
.is_err());
|
||
assert!(super::validate_rollback_user_source_id(
|
||
SystemImportMode::RollbackCheckpoint,
|
||
Some("stable-user-id"),
|
||
)
|
||
.is_ok());
|
||
assert!(
|
||
super::validate_rollback_user_source_id(SystemImportMode::InteractiveUpload, None,)
|
||
.is_ok()
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn users_import_builds_supplemental_usage_aggregates_from_summary_fields() {
|
||
let users = vec![
|
||
json!({
|
||
"id": "source-user-1",
|
||
"username": "alice",
|
||
"request_count": 12,
|
||
"total_tokens": 3456
|
||
}),
|
||
json!({
|
||
"id": "source-user-zero",
|
||
"username": "zero",
|
||
"request_count": 0,
|
||
"total_tokens": 0
|
||
}),
|
||
json!({
|
||
"username": "no-source-id",
|
||
"request_count": 5,
|
||
"total_tokens": 6
|
||
}),
|
||
];
|
||
|
||
let rows = build_imported_user_usage_total_aggregates(
|
||
&users,
|
||
Some(&json!("2026-05-25T12:34:56Z")),
|
||
)
|
||
.expect("supplemental usage aggregates should build");
|
||
|
||
assert_eq!(rows.len(), 1);
|
||
assert_eq!(rows[0].user_id, "source-user-1");
|
||
assert_eq!(rows[0].username.as_deref(), Some("alice"));
|
||
assert_eq!(rows[0].total_requests, 12);
|
||
assert_eq!(rows[0].success_requests, 12);
|
||
assert_eq!(rows[0].input_tokens, 3456);
|
||
assert_eq!(rows[0].date_unix_secs % 86_400, 0);
|
||
}
|
||
|
||
#[test]
|
||
fn config_import_normalizes_python_cli_api_format_aliases() {
|
||
for (raw, expected) in [
|
||
("openai:cli", "openai:responses"),
|
||
("openai:compact", "openai:responses:compact"),
|
||
("openai_image", "openai:image"),
|
||
("images", "openai:image"),
|
||
("/v1/images/generations", "openai:image"),
|
||
("/v1/images/edits", "openai:image"),
|
||
("claude:chat", "claude:messages"),
|
||
("claude:cli", "claude:messages"),
|
||
("gemini:chat", "gemini:generate_content"),
|
||
("gemini:cli", "gemini:generate_content"),
|
||
] {
|
||
assert_eq!(normalize_import_endpoint_format(raw).unwrap(), expected);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn config_import_normalizes_key_formats_against_imported_endpoint_aliases() {
|
||
let endpoint_formats = ["claude:messages", "openai:responses:compact"]
|
||
.into_iter()
|
||
.map(ToOwned::to_owned)
|
||
.collect();
|
||
let item = ImportedProviderKey {
|
||
api_key: None,
|
||
auth_type: None,
|
||
auth_config: None,
|
||
name: None,
|
||
note: None,
|
||
api_formats: Some(vec!["claude:cli".to_string(), "openai:compact".to_string()]),
|
||
supported_endpoints: None,
|
||
rate_multipliers: None,
|
||
internal_priority: None,
|
||
global_priority_by_format: None,
|
||
auth_type_by_format: None,
|
||
allow_auth_channel_mismatch_formats: None,
|
||
rpm_limit: None,
|
||
allowed_models: None,
|
||
capabilities: None,
|
||
cache_ttl_minutes: None,
|
||
max_probe_interval_minutes: None,
|
||
auto_fetch_models: None,
|
||
locked_models: None,
|
||
model_include_patterns: None,
|
||
model_exclude_patterns: None,
|
||
is_active: true,
|
||
proxy: None,
|
||
fingerprint: None,
|
||
credential_state: None,
|
||
};
|
||
|
||
let (formats, missing) = normalize_import_key_formats(&item, &endpoint_formats);
|
||
|
||
assert_eq!(formats, vec!["claude:messages", "openai:responses:compact"]);
|
||
assert!(missing.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn config_import_filters_key_format_scoped_fields_to_selected_api_formats() {
|
||
let raw_key = json!({
|
||
"name": "test-key",
|
||
"api_key": "sk-test",
|
||
"api_formats": ["openai:responses", "openai:video"],
|
||
"auth_type_by_format": {
|
||
"openai:responses": "api_key",
|
||
"openai:video": "bearer"
|
||
},
|
||
"allow_auth_channel_mismatch_formats": [
|
||
"openai:responses",
|
||
"openai:video"
|
||
]
|
||
});
|
||
let raw_key = raw_key.as_object().expect("key should be object");
|
||
|
||
let payload = normalize_import_key_raw_payload(
|
||
raw_key,
|
||
"api_key",
|
||
&["openai:responses".to_string()],
|
||
None,
|
||
false,
|
||
);
|
||
|
||
assert_eq!(payload["api_formats"], json!(["openai:responses"]));
|
||
assert_eq!(
|
||
payload["auth_type_by_format"],
|
||
json!({ "openai:responses": "api_key" })
|
||
);
|
||
assert_eq!(
|
||
payload["allow_auth_channel_mismatch_formats"],
|
||
json!(["openai:responses"])
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn config_import_preserves_explicit_empty_mismatch_scope_after_filtering() {
|
||
let raw_key = json!({
|
||
"name": "test-key",
|
||
"api_key": "sk-test",
|
||
"api_formats": ["openai:responses"],
|
||
"allow_auth_channel_mismatch_formats": ["openai:video"]
|
||
});
|
||
let raw_key = raw_key.as_object().expect("key should be object");
|
||
|
||
let payload = normalize_import_key_raw_payload(
|
||
raw_key,
|
||
"api_key",
|
||
&["openai:responses".to_string()],
|
||
None,
|
||
false,
|
||
);
|
||
|
||
assert_eq!(payload["allow_auth_channel_mismatch_formats"], json!([]));
|
||
}
|
||
|
||
#[test]
|
||
fn config_import_never_turns_redaction_markers_into_credentials() {
|
||
let mut item = ImportedProviderKey {
|
||
api_key: None,
|
||
auth_type: Some("api_key".to_string()),
|
||
auth_config: None,
|
||
name: Some("primary".to_string()),
|
||
note: None,
|
||
api_formats: Some(vec!["openai:chat".to_string()]),
|
||
supported_endpoints: None,
|
||
rate_multipliers: None,
|
||
internal_priority: None,
|
||
global_priority_by_format: None,
|
||
auth_type_by_format: None,
|
||
allow_auth_channel_mismatch_formats: None,
|
||
rpm_limit: None,
|
||
allowed_models: None,
|
||
capabilities: None,
|
||
cache_ttl_minutes: None,
|
||
max_probe_interval_minutes: None,
|
||
auto_fetch_models: None,
|
||
locked_models: None,
|
||
model_include_patterns: None,
|
||
model_exclude_patterns: None,
|
||
is_active: false,
|
||
proxy: None,
|
||
fingerprint: None,
|
||
credential_state: Some("not_exported".to_string()),
|
||
};
|
||
|
||
assert!(validate_imported_provider_key_credential_state(&item).unwrap());
|
||
item.api_key = Some("***".to_string());
|
||
assert!(validate_imported_provider_key_credential_state(&item).is_err());
|
||
item.api_key = None;
|
||
item.credential_state = Some("unknown".to_string());
|
||
assert!(validate_imported_provider_key_credential_state(&item).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn config_import_restores_existing_secret_safe_json_and_strips_new_placeholders() {
|
||
let existing = json!({
|
||
"credentials": {"refresh_token": "old-refresh"},
|
||
"region": "us-east-1"
|
||
});
|
||
let incoming = json!({
|
||
"credentials": "***",
|
||
"region": "eu-west-1"
|
||
});
|
||
|
||
let restored =
|
||
prepare_imported_secret_safe_json(Some(&existing), Some(incoming.clone()), true)
|
||
.expect("existing config should restore");
|
||
assert_eq!(
|
||
restored["credentials"],
|
||
json!({"refresh_token": "old-refresh"})
|
||
);
|
||
assert_eq!(restored["region"], "eu-west-1");
|
||
|
||
let created = prepare_imported_secret_safe_json(None, Some(incoming), true)
|
||
.expect("safe config should remain");
|
||
assert!(created.get("credentials").is_none());
|
||
assert_eq!(created["region"], "eu-west-1");
|
||
}
|
||
|
||
#[test]
|
||
fn config_import_restores_matching_endpoint_rules_without_persisting_markers() {
|
||
let existing_headers = json!([{
|
||
"action": "set",
|
||
"key": "Authorization",
|
||
"value": "Bearer old-secret"
|
||
}]);
|
||
let incoming_headers = json!([{
|
||
"action": "set",
|
||
"key": "Authorization",
|
||
"value": "***",
|
||
"has_value": true
|
||
}]);
|
||
let restored_headers = prepare_imported_secret_safe_header_rules(
|
||
Some(&existing_headers),
|
||
Some(incoming_headers),
|
||
true,
|
||
)
|
||
.expect("matching header rule should remain");
|
||
assert_eq!(restored_headers[0]["value"], "Bearer old-secret");
|
||
assert!(restored_headers[0].get("has_value").is_none());
|
||
|
||
let existing_body = json!([{
|
||
"action": "set",
|
||
"path": "$.credentials.token",
|
||
"value": "old-body-secret"
|
||
}]);
|
||
let incoming_body = json!([{
|
||
"action": "set",
|
||
"path": "$.credentials.token",
|
||
"value": "***",
|
||
"has_value": true
|
||
}]);
|
||
let restored_body = prepare_imported_secret_safe_body_rules(
|
||
Some(&existing_body),
|
||
Some(incoming_body),
|
||
true,
|
||
)
|
||
.expect("matching body rule should remain");
|
||
assert_eq!(restored_body[0]["value"], "old-body-secret");
|
||
assert!(restored_body[0].get("has_value").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn config_import_drops_unrecoverable_endpoint_rule_placeholders() {
|
||
let incoming_headers = json!([{
|
||
"action": "set",
|
||
"key": "Authorization",
|
||
"value": "***",
|
||
"has_value": true
|
||
}]);
|
||
assert_eq!(
|
||
prepare_imported_secret_safe_header_rules(None, Some(incoming_headers.clone()), true),
|
||
Some(json!([]))
|
||
);
|
||
let markerless_placeholder = json!([{
|
||
"action": "set",
|
||
"key": "Authorization",
|
||
"value": "***"
|
||
}]);
|
||
assert_eq!(
|
||
prepare_imported_secret_safe_header_rules(None, Some(markerless_placeholder), true,),
|
||
Some(json!([]))
|
||
);
|
||
|
||
let different_existing = json!([{
|
||
"action": "set",
|
||
"key": "X-Different",
|
||
"value": "must-not-move"
|
||
}]);
|
||
assert_eq!(
|
||
prepare_imported_secret_safe_header_rules(
|
||
Some(&different_existing),
|
||
Some(incoming_headers),
|
||
true,
|
||
),
|
||
Some(json!([]))
|
||
);
|
||
|
||
let incoming_body = json!([{
|
||
"action": "regex_replace",
|
||
"path": "$.credentials.token",
|
||
"pattern": "***",
|
||
"replacement": "***",
|
||
"has_pattern": true,
|
||
"has_replacement": true
|
||
}]);
|
||
assert_eq!(
|
||
prepare_imported_secret_safe_body_rules(None, Some(incoming_body), true),
|
||
Some(json!([]))
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn oauth_import_only_treats_non_empty_secret_fields_as_credentials() {
|
||
assert!(!imported_oauth_auth_config_has_credentials(&json!({})));
|
||
assert!(!imported_oauth_auth_config_has_credentials(&json!({
|
||
"provider_type": "codex",
|
||
"expires_at": 4_102_444_800u64,
|
||
"account_id": "acct-1",
|
||
"refresh_token": " "
|
||
})));
|
||
assert!(imported_oauth_auth_config_has_credentials(&json!({
|
||
"provider_type": "codex",
|
||
"refresh_token": "refresh-1"
|
||
})));
|
||
assert!(imported_oauth_auth_config_has_credentials(&json!({
|
||
"session": {"sso_token": "sso-1"}
|
||
})));
|
||
for field in [
|
||
"sso_rw_token",
|
||
"ssoRwToken",
|
||
"cf_cookies",
|
||
"cfCookies",
|
||
"cf_clearance",
|
||
"cfClearance",
|
||
"cookieHeader",
|
||
] {
|
||
let mut config = serde_json::Map::new();
|
||
config.insert(field.to_string(), json!("credential-1"));
|
||
assert!(
|
||
imported_oauth_auth_config_has_credentials(&serde_json::Value::Object(config)),
|
||
"{field} is transport credential material"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn oauth_import_expiry_tracks_the_supplied_credential_source() {
|
||
let old_expiry = Some(1_700_000_000);
|
||
assert_eq!(
|
||
imported_oauth_expiry_after_import(old_expiry, false, None, true),
|
||
None,
|
||
"a new top-level api_key replaces the old session and clears its expiry"
|
||
);
|
||
assert_eq!(
|
||
imported_oauth_expiry_after_import(old_expiry, false, None, false),
|
||
old_expiry,
|
||
"metadata-only imports preserve the current OAuth expiry"
|
||
);
|
||
assert_eq!(
|
||
imported_oauth_expiry_after_import(
|
||
old_expiry,
|
||
true,
|
||
Some(&json!({"expires_at": 4_102_444_800u64})),
|
||
false,
|
||
),
|
||
Some(4_102_444_800),
|
||
"an explicit auth_config owns the replacement expiry"
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn oauth_pool_score_persistence_failure_is_propagated() {
|
||
let mut provider = StoredProviderCatalogProvider::new(
|
||
"provider-1".to_string(),
|
||
"Provider One".to_string(),
|
||
None,
|
||
"codex".to_string(),
|
||
)
|
||
.expect("provider should build");
|
||
provider.config = Some(json!({"pool_advanced": {}}));
|
||
let key = StoredProviderCatalogKey::new(
|
||
"key-1".to_string(),
|
||
provider.id.clone(),
|
||
"OAuth Key".to_string(),
|
||
"oauth".to_string(),
|
||
None,
|
||
true,
|
||
)
|
||
.expect("key should build");
|
||
let provider_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||
vec![provider],
|
||
Vec::new(),
|
||
vec![key.clone()],
|
||
));
|
||
let no_writer_app = AppState::new()
|
||
.expect("app state should build")
|
||
.with_data_state_for_tests(
|
||
GatewayDataState::with_provider_catalog_repository_for_tests(Arc::clone(
|
||
&provider_repository,
|
||
)),
|
||
);
|
||
seed_imported_oauth_pool_score(&AdminAppState::new(&no_writer_app), "provider-1", &key, 99)
|
||
.await
|
||
.expect("a disabled score writer remains an allowed no-op");
|
||
|
||
let pool = sqlx::sqlite::SqlitePoolOptions::new()
|
||
.max_connections(1)
|
||
.connect("sqlite::memory:")
|
||
.await
|
||
.expect("sqlite pool should connect");
|
||
let score_repository = Arc::new(SqlitePoolMemberScoreRepository::new(pool.clone()));
|
||
pool.close().await;
|
||
let app = AppState::new()
|
||
.expect("app state should build")
|
||
.with_data_state_for_tests(
|
||
GatewayDataState::with_provider_catalog_repository_for_tests(provider_repository)
|
||
.with_pool_score_repository_for_tests(score_repository),
|
||
);
|
||
|
||
let error =
|
||
seed_imported_oauth_pool_score(&AdminAppState::new(&app), "provider-1", &key, 100)
|
||
.await
|
||
.expect_err("closed pool must fail OAuth score recovery");
|
||
assert!(error
|
||
.into_message()
|
||
.contains("failed to recover OAuth pool score for key 'key-1'"));
|
||
}
|
||
|
||
#[test]
|
||
fn import_handles_legacy_string_scalars() {
|
||
assert_eq!(
|
||
imported_optional_bool(Some(&json!("true"))).unwrap_err(),
|
||
"字段必须是布尔值"
|
||
);
|
||
assert_eq!(
|
||
imported_optional_i32(Some(&json!("5")), "rate_limit").unwrap_err(),
|
||
"rate_limit 必须是整数"
|
||
);
|
||
assert_eq!(
|
||
imported_optional_u64(Some(&json!("5")), "total_requests").unwrap_err(),
|
||
"total_requests 必须是非负整数"
|
||
);
|
||
assert_eq!(
|
||
imported_optional_f64(Some(&json!("1.25000000")), "total_cost_usd").unwrap(),
|
||
Some(1.25)
|
||
);
|
||
assert_eq!(
|
||
imported_optional_f64(Some(&json!("not-a-number")), "total_cost_usd").unwrap_err(),
|
||
"total_cost_usd 必须是有限数值"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn import_handles_python_isoformat_timestamps() {
|
||
assert_eq!(
|
||
imported_rfc3339_to_unix_secs(Some(&json!("2099-01-01T00:00:00+00:00")), "expires_at")
|
||
.unwrap(),
|
||
Some(4_070_908_800)
|
||
);
|
||
assert_eq!(
|
||
imported_rfc3339_to_unix_secs(Some(&json!("2099-01-01T00:00:00")), "expires_at")
|
||
.unwrap(),
|
||
Some(4_070_908_800)
|
||
);
|
||
assert_eq!(
|
||
imported_rfc3339_to_unix_secs(Some(&json!("invalid")), "expires_at").unwrap_err(),
|
||
"expires_at 必须是 RFC3339 时间"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn import_preserves_python_wallet_negative_recharge_balance() {
|
||
let wallet = json!({
|
||
"balance": -4.5,
|
||
"recharge_balance": -5.25,
|
||
"gift_balance": 0.75,
|
||
"limit_mode": "finite"
|
||
});
|
||
let wallet = wallet.as_object().expect("wallet should be object");
|
||
|
||
let target = normalize_imported_wallet_target(Some(wallet), false).unwrap();
|
||
assert_eq!(target.recharge_balance, -5.25);
|
||
assert_eq!(target.gift_balance, 0.75);
|
||
assert_eq!(target.total_recharged, 0.0);
|
||
}
|
||
|
||
#[test]
|
||
fn import_preserves_python_wallet_negative_balance_fallback() {
|
||
let wallet = json!({
|
||
"balance": -4.5,
|
||
"gift_balance": 0.75,
|
||
"limit_mode": "finite"
|
||
});
|
||
let wallet = wallet.as_object().expect("wallet should be object");
|
||
|
||
let target = normalize_imported_wallet_target(Some(wallet), false).unwrap();
|
||
assert_eq!(target.recharge_balance, -5.25);
|
||
assert_eq!(target.gift_balance, 0.75);
|
||
}
|
||
|
||
#[test]
|
||
fn import_rejects_negative_wallet_history_totals() {
|
||
for (field, value) in [
|
||
("total_recharged", -1.0),
|
||
("total_consumed", -1.0),
|
||
("total_refunded", -1.0),
|
||
] {
|
||
let mut wallet = serde_json::Map::new();
|
||
wallet.insert(field.to_string(), json!(value));
|
||
let error = normalize_imported_wallet_target(Some(&wallet), false)
|
||
.expect_err("negative wallet history total must be rejected");
|
||
assert!(error.contains(field), "error should identify {field}");
|
||
assert!(
|
||
error.contains("非负"),
|
||
"error should require non-negative {field}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn import_allows_signed_wallet_adjustment_total() {
|
||
let wallet = json!({"total_adjusted": -3.5});
|
||
let wallet = wallet.as_object().expect("wallet should be object");
|
||
let target = normalize_imported_wallet_target(Some(wallet), false)
|
||
.expect("signed adjustment history should remain valid");
|
||
assert_eq!(target.total_adjusted, -3.5);
|
||
}
|
||
|
||
#[test]
|
||
fn import_rejects_legacy_string_lists() {
|
||
assert_eq!(
|
||
imported_string_list_from_value(Some(&json!("openai")), "allowed_providers")
|
||
.unwrap_err(),
|
||
"allowed_providers 必须是字符串列表"
|
||
);
|
||
assert_eq!(
|
||
imported_string_list_from_value(
|
||
Some(&json!("[\"openai:chat\"]")),
|
||
"allowed_api_formats"
|
||
)
|
||
.unwrap_err(),
|
||
"allowed_api_formats 必须是字符串列表"
|
||
);
|
||
}
|
||
}
|