use aether_data::repository::{ auth_modules::{StoredLdapModuleConfig, StoredOAuthProviderModuleConfig}, proxy_nodes::{ ProxyNodeMetricsStep, StoredProxyFleetMetricsBucket, StoredProxyNode, StoredProxyNodeEvent, StoredProxyNodeMetricsBucket, }, system::StoredSystemConfigEntry, wallet::StoredWalletSnapshot, }; use aether_data_contracts::repository::provider_catalog::{ StoredProviderCatalogEndpoint, StoredProviderCatalogKey, }; use axum::http; use axum::{ body::Body, response::{IntoResponse, Response}, Json, }; use regex::Regex; use semver::Version; use serde::{de, de::DeserializeOwned, Deserialize, Serialize}; use serde_json::{json, Map, Value}; use std::collections::BTreeSet; #[derive(Debug, Clone)] pub struct AdminSystemSettingsUpdate { pub default_provider: Option>, pub default_model: Option>, pub enable_usage_tracking: Option, pub password_policy_level: Option, } #[derive(Debug, Clone)] pub struct AdminSystemConfigUpdate { pub normalized_key: String, pub value: serde_json::Value, pub description: Option, } #[derive(Debug, Clone)] pub struct AdminEmailTemplateUpdate { pub subject: Option, pub html: Option, } pub const ADMIN_SYSTEM_CONFIG_EXPORT_VERSION: &str = "2.3"; pub const ADMIN_SYSTEM_CONFIG_SUPPORTED_VERSIONS: &[&str] = &["2.0", "2.1", "2.2", ADMIN_SYSTEM_CONFIG_EXPORT_VERSION]; pub const ADMIN_SYSTEM_USERS_EXPORT_VERSION: &str = "1.5"; pub const ADMIN_SYSTEM_USERS_SUPPORTED_VERSIONS: &[&str] = &["1.3", "1.4", ADMIN_SYSTEM_USERS_EXPORT_VERSION]; pub const ADMIN_SYSTEM_PROVIDER_OPS_SENSITIVE_CREDENTIAL_FIELDS: &[&str] = &[ "api_key", "password", "refresh_token", "session_token", "session_cookie", "token_cookie", "auth_cookie", "cookie_string", "cookie", ]; #[derive(Debug, Clone, PartialEq, Eq)] pub struct AdminSystemUpdateRelease { pub version: String, pub release_url: Option, pub release_notes: Option, pub published_at: Option, pub tarball_url: Option, pub sha256sums_url: Option, } fn default_true() -> bool { true } fn chat_pii_redaction_default_rules() -> serde_json::Value { json!([ { "id": "email", "name": "邮箱", "pattern": "(?i)[A-Z0-9._%+-]{1,64}@[A-Z0-9.-]{1,253}\\.[A-Z]{2,63}", "enabled": true, "features": {"validator": "email"}, "system": true }, { "id": "cn_phone", "name": "手机号", "pattern": "(?:\\+?86[- ]?)?(?:1[3-9]\\d[- ]?\\d{4}[- ]?\\d{4}|0\\d{2,3}[- ]\\d{7,8}(?:-\\d{1,6})?)", "enabled": true, "features": {"validator": "cn_phone"}, "system": true }, { "id": "global_phone", "name": "国际号码", "pattern": "\\+[1-9]\\d(?:[ -]?\\d){6,13}\\d", "enabled": true, "features": {"validator": "global_phone"}, "system": true }, { "id": "cn_id", "name": "身份证号", "pattern": "(?i)\\b\\d{17}[\\dX]\\b", "enabled": true, "features": {"validator": "cn_id"}, "system": true }, { "id": "payment_card", "name": "银行卡号", "pattern": "\\b(?:\\d[ -]?){12,18}\\d\\b", "enabled": true, "features": {"validator": "payment_card"}, "system": true }, { "id": "ipv4", "name": "IPv4", "pattern": "\\b(?:\\d{1,3}\\.){3}\\d{1,3}\\b", "enabled": true, "features": {"validator": "ipv4"}, "system": true }, { "id": "ipv6", "name": "IPv6", "pattern": "\\b(?:[0-9A-Fa-f]{1,4}:){2,7}[0-9A-Fa-f:.]{1,39}\\b", "enabled": true, "features": {"validator": "ipv6"}, "system": true }, { "id": "api_key", "name": "API Key", "pattern": "\\b(?:sk-(?:proj-)?[A-Za-z0-9_-]{20,}|sk-ant-[A-Za-z0-9_-]{20,}|(?:gh[pousr]_[A-Za-z0-9_]{30,}|github_pat_[A-Za-z0-9_]{30,})|xox[baprs]-[A-Za-z0-9-]{20,}|(?:AKIA|ASIA)[0-9A-Z]{16}|[A-Za-z0-9_-]{32,})\\b", "enabled": true, "features": {"validator": "api_key"}, "system": true }, { "id": "access_token", "name": "Access Token", "pattern": "(?i)\\baccess[_-]?token\\s*[:=]\\s*[\"']?[A-Za-z0-9._~+/=-]{20,}", "enabled": true, "features": {"validator": "access_token"}, "system": true }, { "id": "secret_key", "name": "Secret Key", "pattern": "(?i)\\bsecret[_-]?key\\s*[:=]\\s*[\"']?[A-Za-z0-9._~+/=-]{20,}", "enabled": true, "features": {"validator": "secret_key"}, "system": true }, { "id": "bearer_token", "name": "Bearer Token", "pattern": "(?i)\\bBearer\\s+[A-Za-z0-9._~+/=-]{20,}", "enabled": true, "features": {"validator": "bearer_token"}, "system": true }, { "id": "jwt", "name": "JWT", "pattern": "\\b[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\b", "enabled": true, "features": {"validator": "jwt"}, "system": true }, ]) } fn notification_service_default_items() -> serde_json::Value { json!([ { "key": "provider_quota_alert", "name": "号池额度不足", "enabled": true, "channel": "global", "title_template": "", "markdown_template": "", "text_template": "", "user_email_enabled": false, "system": true }, { "key": "provider_pool_abnormal", "name": "号池异常", "enabled": true, "channel": "global", "title_template": "号池异常:{provider_name}", "markdown_template": "号池 `{provider_name}` 出现异常,请检查服务状态。", "text_template": "号池 {provider_name} 出现异常,请检查服务状态。", "user_email_enabled": false, "system": true }, { "key": "user_balance_low", "name": "用户余额不足", "enabled": true, "channel": "email", "title_template": "余额不足提醒", "markdown_template": "你的账户余额已低于提醒阈值,请及时处理。", "text_template": "你的账户余额已低于提醒阈值,请及时处理。", "user_email_enabled": true, "system": true } ]) } fn normalize_chat_pii_redaction_placeholder_prefix(raw: &str) -> Option { let value = raw.trim(); if value.is_empty() || value.len() > 32 { return None; } if !value .chars() .all(|ch| ch.is_ascii_alphanumeric() || ch == '_') { return None; } Some(value.to_ascii_uppercase()) } fn invalid_request(detail: impl Into) -> (http::StatusCode, serde_json::Value) { ( http::StatusCode::BAD_REQUEST, json!({ "detail": detail.into() }), ) } fn parse_finite_f64_import_value(raw: &str) -> Result where E: de::Error, { let value = raw .trim() .parse::() .map_err(|_| E::custom("expected a finite number or numeric string"))?; if value.is_finite() { Ok(value) } else { Err(E::custom("expected a finite number or numeric string")) } } fn deserialize_optional_f64_from_number<'de, D>(deserializer: D) -> Result, D::Error> where D: serde::Deserializer<'de>, { let value = Option::::deserialize(deserializer)?; match value { None | Some(Value::Null) => Ok(None), Some(Value::Number(number)) => number .as_f64() .filter(|value| value.is_finite()) .map(Some) .ok_or_else(|| de::Error::custom("expected a finite number or numeric string")), Some(Value::String(raw)) if !raw.trim().is_empty() => { parse_finite_f64_import_value::(&raw).map(Some) } Some(_) => Err(de::Error::custom( "expected a finite number or numeric string", )), } } fn deserialize_optional_u64_from_number<'de, D>(deserializer: D) -> Result, D::Error> where D: serde::Deserializer<'de>, { let value = Option::::deserialize(deserializer)?; match value { None | Some(Value::Null) => Ok(None), Some(Value::Number(number)) => number .as_u64() .map(Some) .ok_or_else(|| de::Error::custom("expected a non-negative integer or numeric string")), Some(Value::String(raw)) if !raw.trim().is_empty() => raw .trim() .parse::() .map(Some) .map_err(|_| de::Error::custom("expected a non-negative integer or numeric string")), Some(_) => Err(de::Error::custom( "expected a non-negative integer or numeric string", )), } } #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] pub enum AdminImportMergeMode { #[default] Skip, Overwrite, Error, } impl AdminImportMergeMode { fn parse_json_value( value: Option<&serde_json::Value>, ) -> Result { match value .and_then(serde_json::Value::as_str) .unwrap_or("skip") .trim() { "" | "skip" => Ok(Self::Skip), "overwrite" => Ok(Self::Overwrite), "error" => Ok(Self::Error), _ => Err(invalid_request( "merge_mode 仅支持 skip / overwrite / error", )), } } } impl<'de> Deserialize<'de> for AdminImportMergeMode { fn deserialize(deserializer: D) -> Result where D: serde::Deserializer<'de>, { let value = Option::::deserialize(deserializer)?; match value { None | Some(serde_json::Value::Null) => Ok(Self::Skip), Some(serde_json::Value::String(raw)) => match raw.trim() { "" | "skip" => Ok(Self::Skip), "overwrite" => Ok(Self::Overwrite), "error" => Ok(Self::Error), _ => Err(de::Error::custom( "merge_mode 仅支持 skip / overwrite / error", )), }, Some(_) => Err(de::Error::custom( "merge_mode 仅支持 skip / overwrite / error", )), } } } #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] pub struct AdminSystemConfigImportCounter { pub created: u64, pub updated: u64, pub skipped: u64, } #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] pub struct AdminSystemConfigImportStats { pub global_models: AdminSystemConfigImportCounter, pub proxy_nodes: AdminSystemConfigImportCounter, pub providers: AdminSystemConfigImportCounter, pub endpoints: AdminSystemConfigImportCounter, pub keys: AdminSystemConfigImportCounter, pub models: AdminSystemConfigImportCounter, pub ldap: AdminSystemConfigImportCounter, pub oauth: AdminSystemConfigImportCounter, pub system_configs: AdminSystemConfigImportCounter, pub errors: Vec, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct AdminSystemConfigGlobalModel { pub name: String, pub display_name: String, #[serde(default, deserialize_with = "deserialize_optional_u64_from_number")] pub usage_count: Option, #[serde(default, deserialize_with = "deserialize_optional_f64_from_number")] pub default_price_per_request: Option, #[serde(default)] pub default_tiered_pricing: Option, #[serde(default)] pub supported_capabilities: Option>, #[serde(default)] pub config: Option, #[serde(default = "default_true")] pub is_active: bool, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct AdminSystemConfigEndpoint { pub api_format: String, pub base_url: String, #[serde(default)] pub header_rules: Option, #[serde(default)] pub body_rules: Option, #[serde(default)] pub max_retries: Option, #[serde(default = "default_true")] pub is_active: bool, #[serde(default)] pub custom_path: Option, #[serde(default)] pub config: Option, #[serde(default)] pub format_acceptance_config: Option, #[serde(default)] pub proxy: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct AdminSystemConfigProviderKey { #[serde(default)] pub api_key: Option, #[serde(default)] pub auth_type: Option, #[serde(default)] pub auth_config: Option, #[serde(default)] pub name: Option, #[serde(default)] pub note: Option, #[serde(default)] pub api_formats: Option>, #[serde(default)] pub supported_endpoints: Option>, #[serde(default)] pub rate_multipliers: Option, #[serde(default)] pub internal_priority: Option, #[serde(default)] pub global_priority_by_format: Option, #[serde(default)] pub auth_type_by_format: Option, #[serde(default)] pub allow_auth_channel_mismatch_formats: Option>, #[serde(default)] pub rpm_limit: Option, #[serde(default)] pub allowed_models: Option>, #[serde(default)] pub capabilities: Option, #[serde(default)] pub cache_ttl_minutes: Option, #[serde(default)] pub max_probe_interval_minutes: Option, #[serde(default)] pub auto_fetch_models: Option, #[serde(default)] pub locked_models: Option>, #[serde(default)] pub model_include_patterns: Option>, #[serde(default)] pub model_exclude_patterns: Option>, #[serde(default = "default_true")] pub is_active: bool, #[serde(default)] pub proxy: Option, #[serde(default)] pub fingerprint: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct AdminSystemConfigProviderModel { #[serde(default)] pub global_model_name: Option, pub provider_model_name: String, #[serde(default)] pub provider_model_mappings: Option, #[serde(default, deserialize_with = "deserialize_optional_f64_from_number")] pub price_per_request: Option, #[serde(default)] pub tiered_pricing: Option, #[serde(default)] pub supports_vision: Option, #[serde(default)] pub supports_function_calling: Option, #[serde(default)] pub supports_streaming: Option, #[serde(default)] pub supports_extended_thinking: Option, #[serde(default)] pub supports_image_generation: Option, #[serde(default = "default_true")] pub is_active: bool, #[serde(default)] pub config: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct AdminSystemConfigProvider { pub name: String, #[serde(default)] pub description: Option, #[serde(default)] pub website: Option, #[serde(default)] pub provider_type: Option, #[serde(default)] pub billing_type: Option, #[serde(default, deserialize_with = "deserialize_optional_f64_from_number")] pub monthly_quota_usd: Option, #[serde(default)] pub quota_reset_day: Option, #[serde(default)] pub provider_priority: Option, #[serde(default)] pub keep_priority_on_conversion: Option, #[serde(default)] pub enable_format_conversion: Option, #[serde(default = "default_true")] pub is_active: bool, #[serde(default)] pub concurrent_limit: Option, #[serde(default)] pub max_retries: Option, #[serde(default, deserialize_with = "deserialize_optional_f64_from_number")] pub stream_first_byte_timeout: Option, #[serde(default, deserialize_with = "deserialize_optional_f64_from_number")] pub request_timeout: Option, #[serde(default)] pub proxy: Option, #[serde(default)] pub config: Option, #[serde(default)] pub endpoints: Vec, #[serde(default)] pub api_keys: Vec, #[serde(default)] pub models: Vec, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct AdminSystemConfigProxyNode { #[serde(default)] pub id: Option, #[serde(default)] pub name: Option, #[serde(default)] pub ip: Option, #[serde(default)] pub port: Option, #[serde(default)] pub region: Option, #[serde(default)] pub is_manual: Option, #[serde(default)] pub proxy_url: Option, #[serde(default)] pub proxy_username: Option, #[serde(default)] pub proxy_password: Option, #[serde(default)] pub tunnel_mode: Option, #[serde(default)] pub heartbeat_interval: Option, #[serde(default)] pub remote_config: Option, #[serde(default)] pub config_version: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct AdminSystemConfigLdap { pub server_url: String, pub bind_dn: String, #[serde(default)] pub bind_password: Option, pub base_dn: String, #[serde(default)] pub user_search_filter: Option, #[serde(default)] pub username_attr: Option, #[serde(default)] pub email_attr: Option, #[serde(default)] pub display_name_attr: Option, #[serde(default)] pub is_enabled: bool, #[serde(default)] pub is_exclusive: bool, #[serde(default)] pub use_starttls: bool, #[serde(default)] pub connect_timeout: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct AdminSystemConfigOAuthProvider { pub provider_type: String, pub display_name: String, pub client_id: String, #[serde(default)] pub client_secret: Option, #[serde(default)] pub authorization_url_override: Option, #[serde(default)] pub token_url_override: Option, #[serde(default)] pub userinfo_url_override: Option, #[serde(default)] pub scopes: Option>, pub redirect_uri: String, pub frontend_callback_url: String, #[serde(default)] pub attribute_mapping: Option, #[serde(default)] pub extra_config: Option, #[serde(default)] pub is_enabled: bool, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct AdminSystemConfigEntry { pub key: String, #[serde(default)] pub value: Value, #[serde(default)] pub description: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct AdminSystemConfigDocument { pub version: String, #[serde(default)] pub exported_at: String, #[serde(default)] pub global_models: Vec, #[serde(default)] pub providers: Vec, #[serde(default)] pub proxy_nodes: Vec, #[serde(default)] pub ldap_config: Option, #[serde(default)] pub oauth_providers: Vec, #[serde(default)] pub system_configs: Vec, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct AdminSystemConfigImportRequest { #[serde(flatten)] pub document: AdminSystemConfigDocument, #[serde(default)] pub merge_mode: AdminImportMergeMode, } #[derive(Debug, Clone)] pub struct ParsedAdminSystemConfigImportRequest { pub request: AdminSystemConfigImportRequest, pub root: Map, } #[derive(Debug, Clone)] pub struct ParsedAdminSystemConfigObject { pub raw: Map, pub value: T, } impl ParsedAdminSystemConfigObject { pub fn into_parts(self) -> (Map, T) { (self.raw, self.value) } } fn parse_admin_system_config_object( item: Value, field_name: &str, ) -> Result, (http::StatusCode, Value)> { let raw = item .as_object() .cloned() .ok_or_else(|| invalid_request(format!("{field_name} 项必须是对象")))?; let value = serde_json::from_value::(Value::Object(raw.clone())) .map_err(|_| invalid_request(format!("{field_name} 项格式无效")))?; Ok(ParsedAdminSystemConfigObject { raw, value }) } pub fn parse_admin_system_config_array( root: &Map, field_name: &str, ) -> Result>, (http::StatusCode, Value)> { let Some(value) = root.get(field_name) else { return Ok(Vec::new()); }; let items = value .as_array() .ok_or_else(|| invalid_request(format!("{field_name} 必须是数组")))?; items .iter() .cloned() .map(|item| parse_admin_system_config_object(item, field_name)) .collect() } pub fn parse_admin_system_config_optional_object( root: &Map, field_name: &str, ) -> Result>, (http::StatusCode, Value)> { let Some(value) = root.get(field_name) else { return Ok(None); }; if value.is_null() { return Ok(None); } parse_admin_system_config_object(value.clone(), field_name).map(Some) } pub fn parse_admin_system_config_nested_array( parent: &Map, field_name: &str, ) -> Result>, (http::StatusCode, Value)> { let Some(value) = parent.get(field_name) else { return Ok(Vec::new()); }; let items = value .as_array() .ok_or_else(|| invalid_request(format!("{field_name} 必须是数组")))?; items .iter() .cloned() .map(|item| parse_admin_system_config_object(item, field_name)) .collect() } #[derive(Debug, Clone, Copy)] struct AdminApiFormatDefinition { value: &'static str, label: &'static str, default_path: &'static str, aliases: &'static [&'static str], } const REQUEST_RECORD_LEVEL_KEY: &str = "request_record_level"; const LEGACY_REQUEST_LOG_LEVEL_KEY: &str = "request_log_level"; const DEFAULT_BARK_API_BASE: &str = "https://api.day.app"; const SENSITIVE_SYSTEM_CONFIG_KEYS: &[&str] = &[ "smtp_password", "turnstile_secret_key", "backup_s3_secret_access_key", "module.server_chan_push.send_key", "module.important_notification.server_chan_send_key", "module.bark_push.device_key", ]; const ADMIN_API_FORMAT_DEFINITIONS: &[AdminApiFormatDefinition] = &[ AdminApiFormatDefinition { value: "openai:chat", label: "OpenAI Chat", default_path: "/v1/chat/completions", aliases: &[ "openai", "openai_compatible", "deepseek", "grok", "moonshot", "zhipu", "qwen", "baichuan", "minimax", ], }, AdminApiFormatDefinition { value: "openai:responses", label: "OpenAI Responses", default_path: "/v1/responses", aliases: &["responses"], }, AdminApiFormatDefinition { value: "openai:responses:compact", label: "OpenAI Responses Compact", default_path: "/v1/responses/compact", aliases: &["responses_compact"], }, AdminApiFormatDefinition { value: "openai:search", label: "OpenAI Search", default_path: "/v1/alpha/search", aliases: &["openai_search", "search"], }, AdminApiFormatDefinition { value: "openai:embedding", label: "OpenAI Embedding", default_path: "/v1/embeddings", aliases: &["openai_embedding", "embeddings"], }, AdminApiFormatDefinition { value: "openai:rerank", label: "OpenAI Rerank", default_path: "/v1/rerank", aliases: &["openai_rerank", "rerank"], }, AdminApiFormatDefinition { value: "openai:image", label: "OpenAI Image", default_path: "/v1/images/generations", aliases: &["openai_image", "images"], }, AdminApiFormatDefinition { value: "openai:video", label: "OpenAI Video", default_path: "/v1/videos", aliases: &["openai_video", "sora"], }, AdminApiFormatDefinition { value: "claude:messages", label: "Claude Messages", default_path: "/v1/messages", aliases: &["claude", "claude_compatible"], }, AdminApiFormatDefinition { value: "gemini:generate_content", label: "Gemini Generate Content", default_path: "/v1beta/models/{model}:{action}", aliases: &["gemini", "google", "vertex"], }, AdminApiFormatDefinition { value: "gemini:interactions", label: "Gemini Interactions", default_path: "/v1/interactions", aliases: &["gemini_interactions", "interactions"], }, AdminApiFormatDefinition { value: "gemini:embedding", label: "Gemini Embedding", default_path: "/v1beta/models/{model}:{action}", aliases: &["gemini_embedding"], }, AdminApiFormatDefinition { value: "gemini:video", label: "Gemini Video", default_path: "/v1beta/models/{model}:predictLongRunning", aliases: &["gemini_video", "veo"], }, AdminApiFormatDefinition { value: "jina:embedding", label: "Jina Embedding", default_path: "/v1/embeddings", aliases: &["jina_embedding"], }, AdminApiFormatDefinition { value: "jina:rerank", label: "Jina Rerank", default_path: "/v1/rerank", aliases: &["jina_rerank"], }, AdminApiFormatDefinition { value: "doubao:embedding", label: "Doubao Embedding", default_path: "/v1/embeddings", aliases: &["doubao_embedding"], }, AdminApiFormatDefinition { value: "aliyun:multimodal_embedding", label: "Aliyun Multimodal Embedding", default_path: "/api/v1/services/embeddings/multimodal-embedding/multimodal-embedding", aliases: &[ "aliyun_embedding", "aliyun_multimodal_embedding", "dashscope_embedding", "dashscope_multimodal_embedding", "dashscope:multimodal_embedding", ], }, ]; pub fn build_admin_system_check_update_payload(current_version: String) -> serde_json::Value { build_admin_system_check_update_payload_with_release( current_version, None, Some("检查更新需要 Rust 管理后端".to_string()), ) } pub fn build_admin_system_check_update_payload_with_release( current_version: String, latest_release: Option, error: Option, ) -> serde_json::Value { let has_update = latest_release .as_ref() .is_some_and(|release| admin_system_update_available(¤t_version, &release.version)); let update_blocker = latest_release .as_ref() .and_then(admin_system_update_blocker); let updatable = latest_release .as_ref() .is_some_and(|release| admin_system_update_blocker(release).is_none()); json!({ "current_version": current_version, "latest_version": latest_release.as_ref().map(|release| release.version.clone()), "has_update": has_update, "updatable": updatable, "update_blocker": update_blocker, "release_url": latest_release.as_ref().and_then(|release| release.release_url.clone()), "release_notes": latest_release.as_ref().and_then(|release| release.release_notes.clone()), "published_at": latest_release.as_ref().and_then(|release| release.published_at.clone()), "error": error, }) } pub fn build_admin_system_releases_payload( current_version: String, releases: Vec, error: Option, ) -> serde_json::Value { let entries: Vec = releases .iter() .map(|release| { let is_current = { let norm_current = normalized_admin_system_version(¤t_version); let norm_release = normalized_admin_system_version(&release.version); norm_current == norm_release }; let is_newer = admin_system_update_available(¤t_version, &release.version); let update_blocker = admin_system_update_blocker(release); json!({ "version": release.version, "release_url": release.release_url, "release_notes": release.release_notes, "published_at": release.published_at, "tarball_url": release.tarball_url, "sha256sums_url": release.sha256sums_url, "is_current": is_current, "is_newer": is_newer, "updatable": update_blocker.is_none(), "update_blocker": update_blocker, }) }) .collect(); json!({ "current_version": current_version, "releases": entries, "error": error, }) } fn admin_system_update_blocker(release: &AdminSystemUpdateRelease) -> Option<&'static str> { if release.tarball_url.is_none() { Some("当前平台暂无安装包") } else if release.sha256sums_url.is_none() { Some("缺少 SHA256SUMS 校验文件") } else { None } } fn normalized_admin_system_version(version: &str) -> String { let trimmed = version.trim(); trimmed .strip_prefix('v') .or_else(|| trimmed.strip_prefix('V')) .unwrap_or(trimmed) .to_string() } fn admin_system_update_available(current_version: &str, latest_release_version: &str) -> bool { match ( parse_admin_system_version_for_update(current_version), parse_admin_system_version_for_update(latest_release_version), ) { (Some(current), Some(latest)) => latest > current, _ => false, } } fn parse_admin_system_version_for_update(version: &str) -> Option { let base = admin_system_release_base_version(version); let normalized = normalize_admin_system_rc_prerelease(&base); Version::parse(&normalized).ok() } fn admin_system_release_base_version(version: &str) -> String { let normalized = normalized_admin_system_version(version); let without_dirty = normalized.strip_suffix("-dirty").unwrap_or(&normalized); git_describe_base_version(without_dirty) .unwrap_or(without_dirty) .to_string() } fn git_describe_base_version(version: &str) -> Option<&str> { let (before_hash, hash) = version.rsplit_once("-g")?; if hash.is_empty() || !hash.chars().all(|ch| ch.is_ascii_hexdigit()) { return None; } let (base, commit_count) = before_hash.rsplit_once('-')?; if commit_count.is_empty() || !commit_count.chars().all(|ch| ch.is_ascii_digit()) { return None; } Some(base) } fn normalize_admin_system_rc_prerelease(version: &str) -> String { let Some((core, prerelease)) = version.split_once('-') else { return version.to_string(); }; let Some(rc_number) = prerelease.strip_prefix("rc") else { return version.to_string(); }; if rc_number.is_empty() || !rc_number.chars().all(|ch| ch.is_ascii_digit()) { return version.to_string(); } format!("{core}-rc.{rc_number}") } pub fn build_admin_system_stats_payload( total_users: u64, active_users: u64, total_providers: u64, active_providers: u64, total_api_keys: u64, total_requests: u64, usage_counter: serde_json::Value, ) -> serde_json::Value { json!({ "users": { "total": total_users, "active": active_users, }, "providers": { "total": total_providers, "active": active_providers, }, "api_keys": total_api_keys, "requests": total_requests, "usage_counter": usage_counter, }) } pub fn build_admin_system_settings_payload( default_provider: Option, default_model: Option, enable_usage_tracking: bool, password_policy_level: String, ) -> serde_json::Value { json!({ "default_provider": default_provider, "default_model": default_model, "enable_usage_tracking": enable_usage_tracking, "password_policy_level": password_policy_level, }) } pub fn parse_admin_system_settings_update( request_body: &[u8], ) -> Result { let payload = match serde_json::from_slice::(request_body) { Ok(serde_json::Value::Object(payload)) => payload, Ok(_) | Err(_) => { return Err(( http::StatusCode::BAD_REQUEST, json!({ "detail": "请求数据验证失败" }), )); } }; let default_provider = match payload.get("default_provider") { Some(serde_json::Value::String(value)) => { let value = value.trim(); if value.is_empty() { Some(None) } else { Some(Some(value.to_string())) } } Some(serde_json::Value::Null) => Some(None), Some(_) => { return Err(( http::StatusCode::BAD_REQUEST, json!({ "detail": "请求数据验证失败" }), )); } None => None, }; let default_model = match payload.get("default_model") { Some(serde_json::Value::String(value)) => { let value = value.trim(); if value.is_empty() { Some(None) } else { Some(Some(value.to_string())) } } Some(serde_json::Value::Null) => Some(None), Some(_) => { return Err(( http::StatusCode::BAD_REQUEST, json!({ "detail": "请求数据验证失败" }), )); } None => None, }; let enable_usage_tracking = match payload.get("enable_usage_tracking") { Some(serde_json::Value::Bool(value)) => Some(*value), Some(serde_json::Value::Null) => { return Err(( http::StatusCode::BAD_REQUEST, json!({ "detail": "请求数据验证失败" }), )); } Some(_) => { return Err(( http::StatusCode::BAD_REQUEST, json!({ "detail": "请求数据验证失败" }), )); } None => None, }; let password_policy_level = match payload.get("password_policy_level") { Some(serde_json::Value::String(value)) => { let value = value.trim(); if matches!(value, "weak" | "medium" | "strong") { Some(value.to_string()) } else { return Err(( http::StatusCode::BAD_REQUEST, json!({ "detail": "请求数据验证失败" }), )); } } Some(_) => { return Err(( http::StatusCode::BAD_REQUEST, json!({ "detail": "请求数据验证失败" }), )); } None => None, }; Ok(AdminSystemSettingsUpdate { default_provider, default_model, enable_usage_tracking, password_policy_level, }) } pub fn build_admin_system_settings_updated_payload() -> serde_json::Value { json!({ "message": "系统设置更新成功" }) } pub fn build_admin_email_templates_payload(templates: Vec) -> serde_json::Value { json!({ "templates": templates }) } pub fn admin_email_template_not_found_error( template_type: &str, ) -> (http::StatusCode, serde_json::Value) { ( http::StatusCode::NOT_FOUND, json!({ "detail": format!("模板类型 '{template_type}' 不存在") }), ) } pub fn parse_admin_email_template_update( request_body: &[u8], ) -> Result { let payload = match serde_json::from_slice::(request_body) { Ok(serde_json::Value::Object(payload)) => payload, _ => { return Err(( http::StatusCode::BAD_REQUEST, json!({ "detail": "请求数据验证失败" }), )); } }; let subject = match payload.get("subject") { Some(serde_json::Value::String(value)) => Some(value.clone()), Some(serde_json::Value::Null) | None => None, Some(_) => { return Err(( http::StatusCode::BAD_REQUEST, json!({ "detail": "请求数据验证失败" }), )); } }; let html = match payload.get("html") { Some(serde_json::Value::String(value)) => Some(value.clone()), Some(serde_json::Value::Null) | None => None, Some(_) => { return Err(( http::StatusCode::BAD_REQUEST, json!({ "detail": "请求数据验证失败" }), )); } }; if subject.is_none() && html.is_none() { return Err(( http::StatusCode::BAD_REQUEST, json!({ "detail": "请提供 subject 或 html" }), )); } Ok(AdminEmailTemplateUpdate { subject, html }) } pub fn parse_admin_email_template_preview_payload( request_body: Option<&[u8]>, ) -> Result, (http::StatusCode, serde_json::Value)> { match request_body { Some(bytes) => match serde_json::from_slice::(bytes) { Ok(serde_json::Value::Object(payload)) => Ok(payload), Ok(serde_json::Value::Null) => Ok(serde_json::Map::new()), _ => Err(( http::StatusCode::BAD_REQUEST, json!({ "detail": "请求数据验证失败" }), )), }, None => Ok(serde_json::Map::new()), } } pub fn build_admin_email_template_saved_payload() -> serde_json::Value { json!({ "message": "模板保存成功" }) } pub fn build_admin_email_template_preview_payload( rendered_html: String, preview_variables: std::collections::BTreeMap, ) -> serde_json::Value { json!({ "html": rendered_html, "variables": preview_variables, }) } pub fn build_admin_email_template_reset_payload( template_type: &str, name: &str, default_subject: &str, default_html: &str, ) -> serde_json::Value { json!({ "message": "模板已重置为默认值", "template": { "type": template_type, "name": name, "subject": default_subject, "html": default_html, } }) } pub fn build_admin_api_formats_payload() -> serde_json::Value { json!({ "formats": ADMIN_API_FORMAT_DEFINITIONS .iter() .map(|definition| json!({ "value": definition.value, "label": definition.label, "default_path": definition.default_path, "aliases": definition.aliases, })) .collect::>(), }) } pub fn admin_module_name_from_status_path(request_path: &str) -> Option { request_path .strip_prefix("/api/admin/modules/status/") .map(str::trim) .filter(|value| !value.is_empty() && !value.contains('/')) .map(ToOwned::to_owned) } pub fn admin_module_name_from_enabled_path(request_path: &str) -> Option { request_path .strip_prefix("/api/admin/modules/status/") .and_then(|value| value.strip_suffix("/enabled")) .map(str::trim) .filter(|value| !value.is_empty() && !value.contains('/')) .map(ToOwned::to_owned) } pub fn oauth_module_config_is_valid(providers: &[StoredOAuthProviderModuleConfig]) -> bool { !providers.is_empty() && providers.iter().all(|provider| { !provider.client_id.trim().is_empty() && provider .client_secret_encrypted .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .is_some() && !provider.redirect_uri.trim().is_empty() }) } pub fn ldap_module_config_is_valid(config: Option<&StoredLdapModuleConfig>) -> bool { let Some(config) = config else { return false; }; !config.server_url.trim().is_empty() && !config.bind_dn.trim().is_empty() && !config.base_dn.trim().is_empty() && config .bind_password_encrypted .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .is_some() } pub struct AdminModuleValidationInput<'a> { pub module_name: &'a str, pub oauth_providers: &'a [StoredOAuthProviderModuleConfig], pub ldap_config: Option<&'a StoredLdapModuleConfig>, pub gemini_files_has_capable_key: bool, pub important_notification_configured: bool, pub server_chan_push_configured: bool, pub bark_push_configured: bool, pub s3_backup_configured: bool, } pub fn build_admin_module_validation_result( input: AdminModuleValidationInput<'_>, ) -> (bool, Option) { let AdminModuleValidationInput { module_name, oauth_providers, ldap_config, gemini_files_has_capable_key, important_notification_configured, server_chan_push_configured, bark_push_configured, s3_backup_configured, } = input; match module_name { "oauth" => { if oauth_providers.is_empty() { return ( false, Some("请先配置并启用至少一个 OAuth Provider".to_string()), ); } for provider in oauth_providers { if provider.client_id.trim().is_empty() { return ( false, Some(format!( "Provider [{}] 未配置 Client ID", provider.display_name )), ); } if provider .client_secret_encrypted .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .is_none() { return ( false, Some(format!( "Provider [{}] 未配置 Client Secret", provider.display_name )), ); } if provider.redirect_uri.trim().is_empty() { return ( false, Some(format!( "Provider [{}] 未配置回调地址", provider.display_name )), ); } } (true, None) } "ldap" => { let Some(config) = ldap_config else { return (false, Some("请先配置 LDAP 连接信息".to_string())); }; if config.server_url.trim().is_empty() { return (false, Some("请配置 LDAP 服务器地址".to_string())); } if config.bind_dn.trim().is_empty() { return (false, Some("请配置绑定 DN".to_string())); } if config.base_dn.trim().is_empty() { return (false, Some("请配置搜索基准 DN".to_string())); } if config .bind_password_encrypted .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .is_none() { return (false, Some("请配置绑定密码".to_string())); } (true, None) } "important_notification" | "notification_email" => { if important_notification_configured { (true, None) } else { (false, Some("请先完成通知服务推送渠道配置".to_string())) } } "server_chan_push" => { if server_chan_push_configured { (true, None) } else { (false, Some("请先配置 Server 酱 SendKey".to_string())) } } "bark_push" => { if bark_push_configured { (true, None) } else { (false, Some("请先配置 Bark Device Key".to_string())) } } "s3_backup" => { if s3_backup_configured { (true, None) } else { (false, Some("请先完成 S3 备份配置".to_string())) } } "gemini_files" => { if gemini_files_has_capable_key { (true, None) } else { ( false, Some("至少启用一个具有「Gemini 文件 API」能力的 Key".to_string()), ) } } "management_tokens" | "model_directives" | "proxy_nodes" => (true, None), _ => (true, None), } } pub fn build_admin_module_health( module_name: &str, gemini_files_has_capable_key: bool, ) -> &'static str { match module_name { "management_tokens" | "model_directives" | "proxy_nodes" | "important_notification" | "bark_push" | "server_chan_push" | "s3_backup" => "healthy", "gemini_files" => { if gemini_files_has_capable_key { "healthy" } else { "degraded" } } _ => "unknown", } } #[allow(clippy::too_many_arguments)] pub fn build_admin_module_status_payload( name: &str, display_name: &str, description: &str, category: &str, admin_route: Option<&str>, admin_menu_icon: Option<&str>, admin_menu_group: Option<&str>, admin_menu_order: i32, available: bool, enabled: bool, config_validated: bool, config_error: Option, health: &str, ) -> serde_json::Value { let active = available && enabled && config_validated; json!({ "name": name, "available": available, "enabled": enabled, "active": active, "config_validated": config_validated, "config_error": if config_validated { serde_json::Value::Null } else { json!(config_error) }, "display_name": display_name, "description": description, "category": category, "admin_route": if available { json!(admin_route) } else { serde_json::Value::Null }, "admin_menu_icon": admin_menu_icon, "admin_menu_group": admin_menu_group, "admin_menu_order": admin_menu_order, "health": health, }) } pub fn normalize_admin_system_export_api_formats( raw_formats: Option<&serde_json::Value>, mut signature_for: impl FnMut(&str) -> Option, ) -> Vec { let Some(raw_formats) = raw_formats.and_then(serde_json::Value::as_array) else { return Vec::new(); }; let mut normalized = Vec::new(); let mut seen = BTreeSet::new(); for raw in raw_formats { let Some(value) = raw .as_str() .map(str::trim) .filter(|value| !value.is_empty()) else { continue; }; let Some(signature) = signature_for(value) else { continue; }; if seen.insert(signature.clone()) { normalized.push(signature); } } normalized } pub fn resolve_admin_system_export_key_api_formats( raw_formats: Option<&serde_json::Value>, provider_endpoint_formats: &[String], signature_for: impl FnMut(&str) -> Option, ) -> Vec { let normalized = normalize_admin_system_export_api_formats(raw_formats, signature_for); if !normalized.is_empty() { return normalized; } if raw_formats.is_none() { return provider_endpoint_formats.to_vec(); } Vec::new() } pub fn collect_admin_system_export_provider_endpoint_formats( endpoints: &[StoredProviderCatalogEndpoint], mut signature_for: impl FnMut(&str) -> Option, ) -> Vec { endpoints .iter() .filter_map(|endpoint| signature_for(&endpoint.api_format)) .collect::>() .into_iter() .collect() } pub fn serialize_admin_system_users_export_wallet( wallet: Option<&StoredWalletSnapshot>, ) -> Option { let wallet = wallet?; let recharge_balance = wallet.balance; let gift_balance = wallet.gift_balance; let spendable_balance = recharge_balance + gift_balance; let unlimited = wallet.limit_mode.eq_ignore_ascii_case("unlimited"); Some(json!({ "id": wallet.id.clone(), "balance": spendable_balance, "recharge_balance": recharge_balance, "gift_balance": gift_balance, "refundable_balance": recharge_balance, "currency": wallet.currency.clone(), "status": wallet.status.clone(), "limit_mode": wallet.limit_mode.clone(), "unlimited": unlimited, "total_recharged": wallet.total_recharged, "total_consumed": wallet.total_consumed, "total_refunded": wallet.total_refunded, "total_adjusted": wallet.total_adjusted, "updated_at": unix_secs_to_rfc3339(wallet.updated_at_unix_secs), })) } pub fn parse_admin_system_config_import_request( request_body: &[u8], ) -> Result { let root = match serde_json::from_slice::(request_body) { Ok(serde_json::Value::Object(root)) => root, _ => return Err(invalid_request("请求数据验证失败")), }; let version = root .get("version") .and_then(serde_json::Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| invalid_request("version 为必填字段"))?; if !ADMIN_SYSTEM_CONFIG_SUPPORTED_VERSIONS.contains(&version) { return Err(invalid_request(format!( "不支持的配置版本: {version},支持的版本: {}", ADMIN_SYSTEM_CONFIG_SUPPORTED_VERSIONS.join(", ") ))); } let merge_mode = AdminImportMergeMode::parse_json_value(root.get("merge_mode"))?; let document = serde_path_to_error::deserialize::<_, AdminSystemConfigDocument>( serde_json::Value::Object(root.clone()), ) .map_err(|err| { let path = err.path().to_string(); let inner = err.into_inner(); let detail = if path.is_empty() { format!("配置文件格式无效: {inner}") } else { format!("配置文件格式无效: {path}: {inner}") }; invalid_request(detail) })?; Ok(ParsedAdminSystemConfigImportRequest { request: AdminSystemConfigImportRequest { document, merge_mode, }, root, }) } pub fn normalize_admin_system_config_key(requested_key: &str) -> String { let trimmed = requested_key.trim(); if trimmed.eq_ignore_ascii_case(LEGACY_REQUEST_LOG_LEVEL_KEY) { REQUEST_RECORD_LEVEL_KEY.to_string() } else if trimmed.eq_ignore_ascii_case("module.notification_email.enabled") { "module.important_notification.enabled".to_string() } else if trimmed.eq_ignore_ascii_case("module.important_notification.server_chan_enabled") { "module.server_chan_push.enabled".to_string() } else if trimmed.eq_ignore_ascii_case("module.important_notification.server_chan_send_key") { "module.server_chan_push.send_key".to_string() } else if trimmed.eq_ignore_ascii_case("module.important_notification.server_chan_template") { "module.server_chan_push.template".to_string() } else { trimmed.to_string() } } pub fn admin_system_config_delete_keys(requested_key: &str) -> Vec { let normalized = normalize_admin_system_config_key(requested_key); if normalized == REQUEST_RECORD_LEVEL_KEY { vec![ REQUEST_RECORD_LEVEL_KEY.to_string(), LEGACY_REQUEST_LOG_LEVEL_KEY.to_string(), ] } else if normalized == "module.important_notification.enabled" { vec![ "module.important_notification.enabled".to_string(), "module.notification_email.enabled".to_string(), ] } else if normalized == "module.server_chan_push.enabled" { vec![ "module.server_chan_push.enabled".to_string(), "module.important_notification.server_chan_enabled".to_string(), ] } else if normalized == "module.server_chan_push.send_key" { vec![ "module.server_chan_push.send_key".to_string(), "module.important_notification.server_chan_send_key".to_string(), ] } else if normalized == "module.server_chan_push.template" { vec![ "module.server_chan_push.template".to_string(), "module.important_notification.server_chan_template".to_string(), ] } else { vec![normalized] } } pub fn is_sensitive_admin_system_config_key(key: &str) -> bool { SENSITIVE_SYSTEM_CONFIG_KEYS .iter() .any(|candidate| candidate.eq_ignore_ascii_case(key)) } pub fn admin_system_config_default_value(key: &str) -> Option { match key { "site_name" => Some(json!("Aether")), "site_subtitle" => Some(json!("AI Gateway")), "default_user_initial_gift_usd" => Some(json!(10.0)), "password_policy_level" => Some(json!("weak")), REQUEST_RECORD_LEVEL_KEY => Some(json!("full")), "max_request_body_size" => Some(json!(5_242_880)), "max_response_body_size" => Some(json!(5_242_880)), "sensitive_headers" => Some(json!([ "authorization", "x-api-key", "api-key", "cookie", "set-cookie" ])), "detail_log_retention_days" => Some(json!(7)), "compressed_log_retention_days" => Some(json!(30)), "header_retention_days" => Some(json!(90)), "log_retention_days" => Some(json!(365)), "enable_auto_cleanup" => Some(json!(true)), "cleanup_batch_size" => Some(json!(1000)), "request_candidates_retention_days" => Some(json!(30)), "request_candidates_cleanup_batch_size" => Some(json!(5000)), "proxy_node_metrics_1m_retention_days" => Some(json!(30)), "proxy_node_metrics_1h_retention_days" => Some(json!(180)), "proxy_node_metrics_cleanup_batch_size" => Some(json!(5000)), "enable_provider_checkin" => Some(json!(true)), "provider_checkin_time" => Some(json!("01:05")), "provider_priority_mode" => Some(json!("provider")), "scheduling_mode" => Some(json!("cache_affinity")), "auto_delete_expired_keys" => Some(json!(false)), "turnstile_enabled" => Some(json!(false)), "turnstile_site_key" => Some(serde_json::Value::Null), "turnstile_secret_key" => Some(serde_json::Value::Null), "turnstile_allowed_hostnames" => Some(json!([])), "backup_s3_enabled" => Some(json!(false)), "backup_s3_scope" => Some(json!("data")), "backup_s3_endpoint" => Some(serde_json::Value::Null), "backup_s3_region" => Some(json!("auto")), "backup_s3_user_agent" => Some(json!("rclone/v1.68.0")), "backup_s3_bucket" => Some(serde_json::Value::Null), "backup_s3_prefix" => Some(json!("aether/backups/")), "backup_s3_access_key_id" => Some(serde_json::Value::Null), "backup_s3_secret_access_key" => Some(serde_json::Value::Null), "backup_s3_path_style" => Some(json!(true)), "backup_s3_compression" => Some(json!("zstd")), "backup_s3_schedule_unit" => Some(json!("days")), "backup_s3_schedule_interval" => Some(json!(1)), "backup_s3_schedule_minute" => Some(json!(0)), "backup_s3_schedule_hour" => Some(json!(3)), "backup_s3_schedule_weekday" => Some(json!(1)), "backup_s3_schedule_month_day" => Some(json!(1)), "backup_s3_retention_count" => Some(json!(7)), "backup_s3_last_slot" => Some(serde_json::Value::Null), "email_suffix_mode" => Some(json!("none")), "email_suffix_list" => Some(json!([])), "enable_format_conversion" => Some(json!(false)), "cyber_continue_failover" => Some(json!(false)), "enable_model_directives" => Some(json!(false)), "model_directives" => Some(aether_ai_formats::default_model_directives_config()), "keep_priority_on_conversion" => Some(json!(false)), "audit_log_retention_days" => Some(json!(30)), "enable_db_maintenance" => Some(json!(true)), "system_proxy_node_id" => Some(serde_json::Value::Null), "smtp_host" => Some(serde_json::Value::Null), "smtp_port" => Some(json!(587)), "smtp_user" => Some(serde_json::Value::Null), "smtp_password" => Some(serde_json::Value::Null), "smtp_use_tls" => Some(json!(true)), "smtp_use_ssl" => Some(json!(false)), "smtp_from_email" => Some(serde_json::Value::Null), "smtp_from_name" => Some(json!("Aether")), "enable_oauth_token_refresh" => Some(json!(true)), "module.important_notification.enabled" => Some(json!(false)), "module.important_notification.email_enabled" => Some(json!(false)), "module.important_notification.email_recipients" => Some(json!("")), "module.important_notification.default_channel" => Some(json!("all")), "module.important_notification.items" => Some(notification_service_default_items()), "module.server_chan_push.enabled" => Some(json!(false)), "module.server_chan_push.send_key" => Some(serde_json::Value::Null), "module.server_chan_push.template" => Some(json!("")), "module.bark_push.enabled" => Some(json!(false)), "module.bark_push.device_key" => Some(serde_json::Value::Null), "module.bark_push.server_url" => Some(json!(DEFAULT_BARK_API_BASE)), "module.bark_push.template" => Some(json!("")), "module.chat_pii_redaction.enabled" => Some(json!(false)), "module.chat_pii_redaction.rules" => Some(chat_pii_redaction_default_rules()), "module.chat_pii_redaction.cache_ttl_seconds" => Some(json!(300)), "module.chat_pii_redaction.placeholder_prefix" => Some(json!("AETHER")), _ => None, } } pub fn build_admin_system_configs_payload( entries: &[StoredSystemConfigEntry], ) -> serde_json::Value { let canonical_keys = entries .iter() .filter_map(|entry| { let normalized = normalize_admin_system_config_key(&entry.key); entry .key .eq_ignore_ascii_case(&normalized) .then(|| normalized.to_ascii_lowercase()) }) .collect::>(); json!(entries .iter() .filter_map(|entry| { let normalized_key = normalize_admin_system_config_key(&entry.key); let is_legacy = !entry.key.eq_ignore_ascii_case(&normalized_key); if is_legacy && canonical_keys.contains(&normalized_key.to_ascii_lowercase()) { return None; } Some(build_admin_system_config_list_item( &normalized_key, &entry.value, entry.description.as_deref(), entry.updated_at_unix_secs, )) }) .collect::>()) } pub fn build_admin_system_config_detail_payload( requested_key: &str, value: Option, ) -> Result { let normalized_key = normalize_admin_system_config_key(requested_key); let value = value.or_else(|| admin_system_config_default_value(&normalized_key)); let Some(value) = value else { return Err(( http::StatusCode::NOT_FOUND, json!({ "detail": format!("配置项 '{requested_key}' 不存在") }), )); }; if is_sensitive_admin_system_config_key(&normalized_key) { return Ok(json!({ "key": requested_key, "value": serde_json::Value::Null, "is_set": system_config_is_set(&value), })); } Ok(json!({ "key": requested_key, "value": value, })) } fn normalize_chat_pii_redaction_rules_value( value: serde_json::Value, ) -> Result { let Some(raw_rules) = value.as_array() else { return Err(()); }; let mut rules = Vec::with_capacity(raw_rules.len()); for raw_rule in raw_rules { let Some(raw_rule) = raw_rule.as_object() else { return Err(()); }; let id = raw_rule .get("id") .and_then(serde_json::Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .ok_or(())?; let name = raw_rule .get("name") .and_then(serde_json::Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .ok_or(())?; let pattern = raw_rule .get("pattern") .and_then(serde_json::Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .ok_or(())?; Regex::new(pattern).map_err(|_| ())?; let enabled = raw_rule .get("enabled") .and_then(serde_json::Value::as_bool) .unwrap_or(true); let system = raw_rule .get("system") .and_then(serde_json::Value::as_bool) .unwrap_or(false); let features = normalize_chat_pii_redaction_rule_features(raw_rule)?; rules.push(json!({ "id": id, "name": name, "pattern": pattern, "enabled": enabled, "system": system, "features": features, })); } Ok(serde_json::Value::Array(rules)) } fn normalize_chat_pii_redaction_rule_features( raw_rule: &Map, ) -> Result { let mut features = match raw_rule.get("features") { Some(Value::Object(features)) => features.clone(), Some(Value::Null) | None => Map::new(), Some(_) => return Err(()), }; if !features.contains_key("validator") { if let Some(Value::String(value)) = raw_rule.get("kind") { let value = value.trim(); if !value.is_empty() { features.insert("validator".to_string(), json!(value)); } } else if raw_rule.get("kind").is_some_and(|value| !value.is_null()) { return Err(()); } } match features.get("validator") { Some(Value::String(value)) => { let value = value.trim(); if value.is_empty() { features.remove("validator"); } else { features.insert("validator".to_string(), json!(value)); } } Some(Value::Null) => { features.remove("validator"); } Some(_) => return Err(()), None => {} } Ok(Value::Object(features)) } fn normalize_string_list_config_value(value: serde_json::Value) -> Result { match value { Value::Null => Ok(json!("")), Value::String(raw) => Ok(json!(raw.trim())), Value::Array(items) => { let mut normalized = Vec::with_capacity(items.len()); for item in items { let Some(raw) = item.as_str() else { return Err(()); }; let raw = raw.trim(); if !raw.is_empty() { normalized.push(raw.to_string()); } } Ok(json!(normalized)) } _ => Err(()), } } fn normalize_nullable_string_config_value( value: serde_json::Value, ) -> Result { match value { Value::Null => Ok(Value::Null), Value::String(raw) => { let raw = raw.trim(); if raw.is_empty() { Ok(Value::Null) } else { Ok(json!(raw)) } } _ => Err(()), } } fn normalize_bark_server_url_config_value( value: serde_json::Value, ) -> Result { match value { Value::Null => Ok(json!(DEFAULT_BARK_API_BASE)), Value::String(raw) => { let raw = raw.trim().trim_end_matches('/'); if raw.is_empty() { return Ok(json!(DEFAULT_BARK_API_BASE)); } if !raw.starts_with("https://") && !raw.starts_with("http://") { return Err(()); } Ok(json!(raw)) } _ => Err(()), } } fn normalize_notification_channel_value(value: serde_json::Value) -> Result { match value { Value::Null => Ok(json!("all")), Value::String(raw) => { let normalized = normalize_notification_channel(raw.trim(), false)?; Ok(json!(normalized)) } _ => Err(()), } } fn normalize_notification_channel(raw: &str, allow_global: bool) -> Result<&'static str, ()> { match raw.to_ascii_lowercase().as_str() { "all" => Ok("all"), "email" => Ok("email"), "server_chan" | "serverchan" | "serve_chan" => Ok("server_chan"), "bark" => Ok("bark"), "global" | "" if allow_global => Ok("global"), _ => Err(()), } } fn normalize_notification_service_items_value( value: serde_json::Value, ) -> Result { let Value::Array(items) = value else { return Err(()); }; let mut normalized_items = Vec::with_capacity(items.len()); let mut keys = BTreeSet::new(); for item in items { let Value::Object(raw_item) = item else { return Err(()); }; let key = normalize_notification_item_key(raw_item.get("key"))?; if !keys.insert(key.clone()) { return Err(()); } let name = normalize_optional_bounded_string(raw_item.get("name"), 80)? .unwrap_or_else(|| key.clone()); let enabled = raw_item .get("enabled") .and_then(Value::as_bool) .unwrap_or(true); let channel = raw_item .get("channel") .and_then(Value::as_str) .map(|raw| normalize_notification_channel(raw.trim(), true)) .transpose()? .unwrap_or("global"); let title_template = normalize_optional_bounded_string(raw_item.get("title_template"), 256)? .unwrap_or_default(); let markdown_template = normalize_optional_bounded_string(raw_item.get("markdown_template"), 8_000)? .unwrap_or_default(); let text_template = normalize_optional_bounded_string(raw_item.get("text_template"), 8_000)? .unwrap_or_default(); let user_email_enabled = raw_item .get("user_email_enabled") .and_then(Value::as_bool) .unwrap_or(false); let system = raw_item .get("system") .and_then(Value::as_bool) .unwrap_or(false); normalized_items.push(json!({ "key": key, "name": name, "enabled": enabled, "channel": channel, "title_template": title_template, "markdown_template": markdown_template, "text_template": text_template, "user_email_enabled": user_email_enabled, "system": system, })); } Ok(Value::Array(normalized_items)) } fn normalize_notification_item_key(value: Option<&Value>) -> Result { let Some(raw) = value.and_then(Value::as_str).map(str::trim) else { return Err(()); }; if raw.is_empty() || raw.len() > 64 { return Err(()); } if !raw .chars() .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.' | ':')) { return Err(()); } Ok(raw.to_string()) } fn normalize_optional_bounded_string( value: Option<&Value>, max_len: usize, ) -> Result, ()> { match value { None | Some(Value::Null) => Ok(None), Some(Value::String(raw)) => { let trimmed = raw.trim(); if trimmed.len() > max_len { return Err(()); } if trimmed.is_empty() { Ok(None) } else { Ok(Some(trimmed.to_string())) } } Some(_) => Err(()), } } fn validate_model_directives_config_value(value: &Value) -> Result<(), ()> { let root = value.as_object().ok_or(())?; let reasoning = root .get("reasoning_effort") .and_then(Value::as_object) .ok_or(())?; if reasoning .get("enabled") .is_some_and(|value| !value.is_boolean()) { return Err(()); } let Some(api_formats) = reasoning.get("api_formats") else { return Ok(()); }; let api_formats = api_formats.as_object().ok_or(())?; let mut canonical_api_formats = BTreeSet::new(); for (api_format, raw_config) in api_formats { if api_format.trim().is_empty() { return Err(()); } let canonical_api_format = aether_ai_formats::normalize_api_format_alias(api_format); if !canonical_api_formats.insert(canonical_api_format) { return Err(()); } if raw_config.is_boolean() { continue; } let config = raw_config.as_object().ok_or(())?; if config .get("enabled") .is_some_and(|value| !value.is_boolean()) { return Err(()); } if let Some(suffixes) = config.get("suffixes") { let suffixes = suffixes.as_array().ok_or(())?; let mut canonical_suffixes = BTreeSet::new(); for suffix in suffixes { let suffix = suffix.as_str().map(str::trim).ok_or(())?; if suffix.is_empty() || suffix.starts_with('-') || suffix.ends_with('-') || !canonical_suffixes.insert(suffix.to_ascii_lowercase()) { return Err(()); } } } if let Some(mappings) = config.get("mappings") { let mappings = mappings.as_object().ok_or(())?; let mut canonical_suffixes = BTreeSet::new(); for (suffix, mapping) in mappings { let suffix = suffix.trim(); if suffix.is_empty() || suffix.starts_with('-') || suffix.ends_with('-') || !canonical_suffixes.insert(suffix.to_ascii_lowercase()) || !mapping.is_object() { return Err(()); } } } } Ok(()) } pub fn parse_admin_system_config_update( requested_key: &str, request_body: &[u8], ) -> Result { let payload = match serde_json::from_slice::(request_body) { Ok(serde_json::Value::Object(payload)) => payload, _ => { return Err(( http::StatusCode::BAD_REQUEST, json!({ "detail": "请求数据验证失败" }), )); } }; let normalized_key = normalize_admin_system_config_key(requested_key); let mut value = payload .get("value") .cloned() .unwrap_or(serde_json::Value::Null); let description = match payload.get("description") { Some(serde_json::Value::String(value)) => Some(value.trim().to_string()), Some(serde_json::Value::Null) | None => None, Some(_) => { return Err(( http::StatusCode::BAD_REQUEST, json!({ "detail": "请求数据验证失败" }), )); } }; if normalized_key == "password_policy_level" { match value.as_str().map(str::trim) { Some("weak" | "medium" | "strong") => { value = json!(value.as_str().unwrap().trim()); } Some(_) => { return Err(( http::StatusCode::BAD_REQUEST, json!({ "detail": "请求数据验证失败" }), )); } None if value.is_null() => { value = json!("weak"); } None => { return Err(( http::StatusCode::BAD_REQUEST, json!({ "detail": "请求数据验证失败" }), )); } } } match normalized_key.as_str() { "cyber_continue_failover" | "enable_model_directives" | "module.important_notification.enabled" | "module.important_notification.email_enabled" | "module.server_chan_push.enabled" | "module.bark_push.enabled" => match value.as_bool() { Some(enabled) => value = json!(enabled), None if value.is_null() => { value = admin_system_config_default_value(&normalized_key).unwrap_or(json!(false)); } None => { return Err(( http::StatusCode::BAD_REQUEST, json!({ "detail": "请求数据验证失败" }), )); } }, "module.important_notification.email_recipients" => { value = normalize_string_list_config_value(value).map_err(|_| { ( http::StatusCode::BAD_REQUEST, json!({ "detail": "请求数据验证失败" }), ) })?; } "module.important_notification.default_channel" => { value = normalize_notification_channel_value(value).map_err(|_| { ( http::StatusCode::BAD_REQUEST, json!({ "detail": "请求数据验证失败" }), ) })?; } "module.important_notification.items" => { if value.is_null() { value = notification_service_default_items(); } else { value = normalize_notification_service_items_value(value).map_err(|_| { ( http::StatusCode::BAD_REQUEST, json!({ "detail": "请求数据验证失败" }), ) })?; } } "module.server_chan_push.send_key" => { value = normalize_nullable_string_config_value(value).map_err(|_| { ( http::StatusCode::BAD_REQUEST, json!({ "detail": "请求数据验证失败" }), ) })?; } "module.server_chan_push.template" => { value = match value { Value::Null => json!(""), Value::String(raw) => json!(raw), _ => { return Err(( http::StatusCode::BAD_REQUEST, json!({ "detail": "请求数据验证失败" }), )); } }; } "module.bark_push.device_key" => { value = normalize_nullable_string_config_value(value).map_err(|_| { ( http::StatusCode::BAD_REQUEST, json!({ "detail": "请求数据验证失败" }), ) })?; } "module.bark_push.server_url" => { value = normalize_bark_server_url_config_value(value).map_err(|_| { ( http::StatusCode::BAD_REQUEST, json!({ "detail": "请求数据验证失败" }), ) })?; } "module.bark_push.template" => { value = match value { Value::Null => json!(""), Value::String(raw) => json!(raw), _ => { return Err(( http::StatusCode::BAD_REQUEST, json!({ "detail": "请求数据验证失败" }), )); } }; } "model_directives" => { if value.is_null() { value = aether_ai_formats::default_model_directives_config(); } else { validate_model_directives_config_value(&value).map_err(|_| { ( http::StatusCode::BAD_REQUEST, json!({ "detail": "模型后缀参数配置格式无效" }), ) })?; } } "module.chat_pii_redaction.enabled" => match value.as_bool() { Some(enabled) => value = json!(enabled), None if value.is_null() => { value = admin_system_config_default_value(&normalized_key).unwrap(); } None => { return Err(( http::StatusCode::BAD_REQUEST, json!({ "detail": "请求数据验证失败" }), )); } }, "module.chat_pii_redaction.rules" => { if value.is_null() { value = chat_pii_redaction_default_rules(); } else { value = normalize_chat_pii_redaction_rules_value(value).map_err(|_| { ( http::StatusCode::BAD_REQUEST, json!({ "detail": "请求数据验证失败" }), ) })?; } } "module.chat_pii_redaction.cache_ttl_seconds" => match value.as_u64() { Some(300 | 3600) => value = json!(value.as_u64().unwrap()), Some(_) => { return Err(( http::StatusCode::BAD_REQUEST, json!({ "detail": "请求数据验证失败" }), )); } None if value.is_null() => value = json!(300), None => { return Err(( http::StatusCode::BAD_REQUEST, json!({ "detail": "请求数据验证失败" }), )); } }, "module.chat_pii_redaction.placeholder_prefix" => match value.as_str() { Some(raw) => { let Some(normalized) = normalize_chat_pii_redaction_placeholder_prefix(raw) else { return Err(( http::StatusCode::BAD_REQUEST, json!({ "detail": "请求数据验证失败" }), )); }; value = json!(normalized); } None if value.is_null() => value = json!("AETHER"), None => { return Err(( http::StatusCode::BAD_REQUEST, json!({ "detail": "请求数据验证失败" }), )); } }, _ => {} } Ok(AdminSystemConfigUpdate { normalized_key, value, description, }) } pub fn build_admin_system_config_updated_payload( key: String, value: serde_json::Value, description: Option, updated_at_unix_secs: Option, ) -> serde_json::Value { json!({ "key": key, "value": value, "description": description, "updated_at": updated_at_unix_secs.and_then(unix_secs_to_rfc3339), }) } pub fn build_admin_system_config_deleted_payload(requested_key: &str) -> serde_json::Value { json!({ "message": format!("配置项 '{}' 已删除", requested_key.trim()), }) } pub fn is_admin_management_tokens_root(request_path: &str) -> bool { matches!( request_path, "/api/admin/management-tokens" | "/api/admin/management-tokens/" ) } pub fn is_admin_system_configs_root(request_path: &str) -> bool { matches!( request_path, "/api/admin/system/configs" | "/api/admin/system/configs/" ) } pub fn is_admin_system_email_templates_root(request_path: &str) -> bool { matches!( request_path, "/api/admin/system/email/templates" | "/api/admin/system/email/templates/" ) } pub fn admin_system_config_key_from_path(request_path: &str) -> Option { path_identifier_from_path(request_path, "/api/admin/system/configs/") } pub fn admin_system_email_template_type_from_path(request_path: &str) -> Option { path_identifier_from_path(request_path, "/api/admin/system/email/templates/") } pub fn admin_system_email_template_preview_type_from_path(request_path: &str) -> Option { suffixed_path_identifier_from_path( request_path, "/api/admin/system/email/templates/", "/preview", ) } pub fn admin_system_email_template_reset_type_from_path(request_path: &str) -> Option { suffixed_path_identifier_from_path(request_path, "/api/admin/system/email/templates/", "/reset") } pub fn admin_management_token_id_from_path(request_path: &str) -> Option { path_identifier_from_path(request_path, "/api/admin/management-tokens/") } pub fn admin_management_token_status_id_from_path(request_path: &str) -> Option { suffixed_path_identifier_from_path(request_path, "/api/admin/management-tokens/", "/status") } pub fn admin_management_token_regenerate_id_from_path(request_path: &str) -> Option { suffixed_path_identifier_from_path(request_path, "/api/admin/management-tokens/", "/regenerate") } pub fn admin_adaptive_effective_limit(key: &StoredProviderCatalogKey) -> Option { if key.rpm_limit.is_none() { key.learned_rpm_limit } else { key.rpm_limit } } pub fn admin_adaptive_adjustment_items( value: Option<&serde_json::Value>, ) -> Vec> { value .and_then(serde_json::Value::as_array) .into_iter() .flatten() .filter_map(serde_json::Value::as_object) .cloned() .collect() } pub fn admin_adaptive_key_payload(key: &StoredProviderCatalogKey) -> serde_json::Value { json!({ "id": key.id, "name": key.name, "provider_id": key.provider_id, "api_formats": key .api_formats .as_ref() .and_then(serde_json::Value::as_array) .map(|items| { items .iter() .filter_map(serde_json::Value::as_str) .map(ToOwned::to_owned) .collect::>() }) .unwrap_or_default(), "is_adaptive": key.rpm_limit.is_none(), "rpm_limit": key.rpm_limit, "effective_limit": admin_adaptive_effective_limit(key), "learned_rpm_limit": key.learned_rpm_limit, "concurrent_429_count": key.concurrent_429_count.unwrap_or(0), "rpm_429_count": key.rpm_429_count.unwrap_or(0), }) } pub fn build_admin_adaptive_summary_payload( keys: &[StoredProviderCatalogKey], ) -> serde_json::Value { let adaptive_keys = keys .iter() .filter(|key| key.rpm_limit.is_none()) .collect::>(); let total_keys = adaptive_keys.len() as u64; let total_concurrent_429 = adaptive_keys .iter() .map(|key| u64::from(key.concurrent_429_count.unwrap_or(0))) .sum::(); let total_rpm_429 = adaptive_keys .iter() .map(|key| u64::from(key.rpm_429_count.unwrap_or(0))) .sum::(); let mut recent_adjustments = Vec::new(); let mut total_adjustments = 0usize; for key in adaptive_keys { let history = admin_adaptive_adjustment_items(key.adjustment_history.as_ref()); total_adjustments += history.len(); for adjustment in history.into_iter().rev().take(3) { let mut payload = adjustment; payload.insert("key_id".to_string(), json!(key.id)); payload.insert("key_name".to_string(), json!(key.name)); recent_adjustments.push(serde_json::Value::Object(payload)); } } recent_adjustments.sort_by(|left, right| { let lhs = left .get("timestamp") .and_then(serde_json::Value::as_str) .unwrap_or_default(); let rhs = right .get("timestamp") .and_then(serde_json::Value::as_str) .unwrap_or_default(); rhs.cmp(lhs) }); json!({ "total_adaptive_keys": total_keys, "total_concurrent_429_errors": total_concurrent_429, "total_rpm_429_errors": total_rpm_429, "total_adjustments": total_adjustments, "recent_adjustments": recent_adjustments.into_iter().take(10).collect::>(), }) } pub fn build_admin_adaptive_stats_payload(key: &StoredProviderCatalogKey) -> serde_json::Value { let status_snapshot = key .status_snapshot .as_ref() .and_then(serde_json::Value::as_object); let adjustments = admin_adaptive_adjustment_items(key.adjustment_history.as_ref()); let adjustment_count = adjustments.len(); let recent_adjustments = adjustments .into_iter() .rev() .take(10) .map(serde_json::Value::Object) .collect::>(); json!({ "adaptive_mode": key.rpm_limit.is_none(), "rpm_limit": key.rpm_limit, "effective_limit": admin_adaptive_effective_limit(key), "learned_limit": key.learned_rpm_limit, "concurrent_429_count": key.concurrent_429_count.unwrap_or(0), "rpm_429_count": key.rpm_429_count.unwrap_or(0), "last_429_at": key.last_429_at_unix_secs.and_then(unix_secs_to_rfc3339), "last_429_type": key.last_429_type, "adjustment_count": adjustment_count, "recent_adjustments": recent_adjustments, "learning_confidence": status_snapshot.and_then(|value| value.get("learning_confidence")).cloned(), "enforcement_active": status_snapshot.and_then(|value| value.get("enforcement_active")).cloned(), "observation_count": status_snapshot .and_then(|value| value.get("observation_count")) .and_then(serde_json::Value::as_u64) .unwrap_or(0), "header_observation_count": status_snapshot .and_then(|value| value.get("header_observation_count")) .and_then(serde_json::Value::as_u64) .unwrap_or(0), "latest_upstream_limit": status_snapshot .and_then(|value| value.get("latest_upstream_limit")) .and_then(serde_json::Value::as_u64), }) } pub fn build_admin_adaptive_toggle_mode_payload( updated: &StoredProviderCatalogKey, message: String, ) -> serde_json::Value { json!({ "message": message, "key_id": updated.id, "is_adaptive": updated.rpm_limit.is_none(), "rpm_limit": updated.rpm_limit, "effective_limit": admin_adaptive_effective_limit(updated), }) } pub fn build_admin_adaptive_set_limit_payload( updated: &StoredProviderCatalogKey, was_adaptive: bool, limit: u32, ) -> serde_json::Value { json!({ "message": format!("已设置为固定限制模式,RPM 限制为 {limit}"), "key_id": updated.id, "is_adaptive": false, "rpm_limit": updated.rpm_limit, "previous_mode": if was_adaptive { "adaptive" } else { "fixed" }, }) } pub fn build_admin_adaptive_reset_learning_payload(key_id: &str) -> serde_json::Value { json!({ "message": "学习状态已重置", "key_id": key_id, }) } pub fn admin_adaptive_key_not_found_response(key_id: &str) -> Response { ( http::StatusCode::NOT_FOUND, Json(json!({ "detail": format!("Key {key_id} 不存在") })), ) .into_response() } pub fn admin_adaptive_dispatcher_not_found_response() -> Response { ( http::StatusCode::NOT_FOUND, Json(json!({ "detail": "Adaptive route not found" })), ) .into_response() } pub fn admin_adaptive_key_id_from_path(path: &str) -> Option { let normalized = path.trim_end_matches('/'); let mut segments = normalized.split('/').filter(|segment| !segment.is_empty()); match ( segments.next(), segments.next(), segments.next(), segments.next(), segments.next(), ) { (Some("api"), Some("admin"), Some("adaptive"), Some("keys"), Some(key_id)) if !key_id.is_empty() => { Some(key_id.to_string()) } _ => None, } } pub const ADMIN_PROXY_NODES_DATA_UNAVAILABLE_DETAIL: &str = "Admin proxy nodes data unavailable"; pub fn build_admin_proxy_nodes_data_unavailable_response() -> Response { ( http::StatusCode::SERVICE_UNAVAILABLE, Json(json!({ "detail": ADMIN_PROXY_NODES_DATA_UNAVAILABLE_DETAIL })), ) .into_response() } pub fn build_admin_proxy_nodes_invalid_status_response() -> Response { ( http::StatusCode::BAD_REQUEST, Json(json!({ "detail": "status 必须是以下之一: ['offline', 'online']" })), ) .into_response() } pub fn build_admin_proxy_nodes_not_found_response() -> Response { ( http::StatusCode::NOT_FOUND, Json(json!({ "detail": "Proxy node 不存在" })), ) .into_response() } pub fn build_admin_proxy_node_payload(node: &StoredProxyNode) -> serde_json::Value { let mut payload = serde_json::Map::from_iter([ ("id".to_string(), json!(node.id)), ("name".to_string(), json!(node.name)), ("ip".to_string(), json!(node.ip)), ("port".to_string(), json!(node.port)), ("region".to_string(), json!(node.region)), ("status".to_string(), json!(node.status)), ("is_manual".to_string(), json!(node.is_manual)), ("tunnel_mode".to_string(), json!(node.tunnel_mode)), ("tunnel_connected".to_string(), json!(node.tunnel_connected)), ( "tunnel_connected_at".to_string(), json!(node .tunnel_connected_at_unix_secs .and_then(unix_secs_to_rfc3339)), ), ("registered_by".to_string(), json!(node.registered_by)), ( "last_heartbeat_at".to_string(), json!(node .last_heartbeat_at_unix_secs .and_then(unix_secs_to_rfc3339)), ), ( "heartbeat_interval".to_string(), json!(node.heartbeat_interval), ), ( "active_connections".to_string(), json!(node.active_connections), ), ("total_requests".to_string(), json!(node.total_requests)), ("avg_latency_ms".to_string(), json!(node.avg_latency_ms)), ("failed_requests".to_string(), json!(node.failed_requests)), ("dns_failures".to_string(), json!(node.dns_failures)), ("stream_errors".to_string(), json!(node.stream_errors)), ("proxy_metadata".to_string(), json!(node.proxy_metadata)), ("hardware_info".to_string(), json!(node.hardware_info)), ( "estimated_max_concurrency".to_string(), json!(node.estimated_max_concurrency), ), ("remote_config".to_string(), json!(node.remote_config)), ("config_version".to_string(), json!(node.config_version)), ( "created_at".to_string(), json!(node.created_at_unix_ms.and_then(unix_secs_to_rfc3339)), ), ( "updated_at".to_string(), json!(node.updated_at_unix_secs.and_then(unix_secs_to_rfc3339)), ), ]); if node.is_manual { payload.insert("proxy_url".to_string(), json!(node.proxy_url)); payload.insert("proxy_username".to_string(), json!(node.proxy_username)); payload.insert( "proxy_password".to_string(), json!(mask_admin_proxy_node_password( node.proxy_password.as_deref() )), ); } serde_json::Value::Object(payload) } pub fn build_admin_proxy_node_event_payload(event: &StoredProxyNodeEvent) -> serde_json::Value { json!({ "id": event.id, "event_type": event.event_type, "detail": event.detail, "event_metadata": event.event_metadata, "created_at": event.created_at_unix_ms.and_then(unix_secs_to_rfc3339), }) } pub fn admin_proxy_node_event_node_id_from_path(request_path: &str) -> Option<&str> { let request_path = request_path.trim_end_matches('/'); let node_id = request_path.strip_prefix("/api/admin/proxy-nodes/")?; let node_id = node_id.strip_suffix("/events")?; if node_id.is_empty() || node_id.contains('/') { None } else { Some(node_id) } } pub fn admin_proxy_node_metrics_node_id_from_path(request_path: &str) -> Option<&str> { let request_path = request_path.trim_end_matches('/'); let node_id = request_path.strip_prefix("/api/admin/proxy-nodes/")?; let node_id = node_id.strip_suffix("/metrics")?; if node_id.is_empty() || node_id.contains('/') { None } else { Some(node_id) } } pub fn build_admin_proxy_node_metrics_payload_response( step: ProxyNodeMetricsStep, from_unix_secs: u64, to_unix_secs: u64, items: Vec, ) -> Response { let summary = summarize_proxy_node_metric_buckets(items.iter().map(|item| { ( item.samples, item.uptime_samples, item.active_connections_sum, item.active_connections_max, item.heartbeat_rtt_ms_sum, item.heartbeat_rtt_ms_max, item.connect_errors_delta, item.disconnects_delta, item.error_events_delta, item.ws_in_bytes_delta, item.ws_out_bytes_delta, item.ws_in_frames_delta, item.ws_out_frames_delta, ) })); let items = items .into_iter() .map(build_admin_proxy_node_metrics_bucket_payload) .collect::>(); Json(json!({ "step": step.as_api_value(), "from": from_unix_secs, "to": to_unix_secs, "items": items, "summary": summary, })) .into_response() } pub fn build_admin_proxy_fleet_metrics_payload_response( step: ProxyNodeMetricsStep, from_unix_secs: u64, to_unix_secs: u64, items: Vec, ) -> Response { let summary = summarize_proxy_node_metric_buckets(items.iter().map(|item| { ( item.samples, item.uptime_samples, item.active_connections_sum, item.active_connections_max, item.heartbeat_rtt_ms_sum, item.heartbeat_rtt_ms_max, item.connect_errors_delta, item.disconnects_delta, item.error_events_delta, item.ws_in_bytes_delta, item.ws_out_bytes_delta, item.ws_in_frames_delta, item.ws_out_frames_delta, ) })); let items = items .into_iter() .map(build_admin_proxy_fleet_metrics_bucket_payload) .collect::>(); Json(json!({ "step": step.as_api_value(), "from": from_unix_secs, "to": to_unix_secs, "items": items, "summary": summary, })) .into_response() } fn build_admin_proxy_node_metrics_bucket_payload( item: StoredProxyNodeMetricsBucket, ) -> serde_json::Value { json!({ "node_id": item.node_id, "bucket_start_unix_secs": item.bucket_start_unix_secs, "bucket_start": unix_secs_to_rfc3339(item.bucket_start_unix_secs), "samples": item.samples, "uptime_samples": item.uptime_samples, "uptime_ratio": ratio(item.uptime_samples, item.samples), "active_connections_sum": item.active_connections_sum, "active_connections_max": item.active_connections_max, "active_connections_avg": ratio(item.active_connections_sum, item.samples), "heartbeat_rtt_ms_sum": item.heartbeat_rtt_ms_sum, "heartbeat_rtt_ms_max": item.heartbeat_rtt_ms_max, "heartbeat_rtt_ms_avg": ratio(item.heartbeat_rtt_ms_sum, item.samples), "connect_errors_delta": item.connect_errors_delta, "disconnects_delta": item.disconnects_delta, "error_events_delta": item.error_events_delta, "ws_in_bytes_delta": item.ws_in_bytes_delta, "ws_out_bytes_delta": item.ws_out_bytes_delta, "ws_in_frames_delta": item.ws_in_frames_delta, "ws_out_frames_delta": item.ws_out_frames_delta, }) } fn build_admin_proxy_fleet_metrics_bucket_payload( item: StoredProxyFleetMetricsBucket, ) -> serde_json::Value { json!({ "bucket_start_unix_secs": item.bucket_start_unix_secs, "bucket_start": unix_secs_to_rfc3339(item.bucket_start_unix_secs), "samples": item.samples, "uptime_samples": item.uptime_samples, "uptime_ratio": ratio(item.uptime_samples, item.samples), "active_connections_sum": item.active_connections_sum, "active_connections_max": item.active_connections_max, "active_connections_avg": ratio(item.active_connections_sum, item.samples), "heartbeat_rtt_ms_sum": item.heartbeat_rtt_ms_sum, "heartbeat_rtt_ms_max": item.heartbeat_rtt_ms_max, "heartbeat_rtt_ms_avg": ratio(item.heartbeat_rtt_ms_sum, item.samples), "connect_errors_delta": item.connect_errors_delta, "disconnects_delta": item.disconnects_delta, "error_events_delta": item.error_events_delta, "ws_in_bytes_delta": item.ws_in_bytes_delta, "ws_out_bytes_delta": item.ws_out_bytes_delta, "ws_in_frames_delta": item.ws_in_frames_delta, "ws_out_frames_delta": item.ws_out_frames_delta, }) } fn summarize_proxy_node_metric_buckets(items: I) -> serde_json::Value where I: IntoIterator< Item = ( i64, i64, i64, i64, i64, i64, i64, i64, i64, i64, i64, i64, i64, ), >, { let mut samples = 0; let mut uptime_samples = 0; let mut active_connections_sum = 0; let mut active_connections_max = 0; let mut heartbeat_rtt_ms_sum = 0; let mut heartbeat_rtt_ms_max = 0; let mut connect_errors_delta = 0; let mut disconnects_delta = 0; let mut error_events_delta = 0; let mut ws_in_bytes_delta = 0; let mut ws_out_bytes_delta = 0; let mut ws_in_frames_delta = 0; let mut ws_out_frames_delta = 0; for item in items { samples += item.0; uptime_samples += item.1; active_connections_sum += item.2; active_connections_max = active_connections_max.max(item.3); heartbeat_rtt_ms_sum += item.4; heartbeat_rtt_ms_max = heartbeat_rtt_ms_max.max(item.5); connect_errors_delta += item.6; disconnects_delta += item.7; error_events_delta += item.8; ws_in_bytes_delta += item.9; ws_out_bytes_delta += item.10; ws_in_frames_delta += item.11; ws_out_frames_delta += item.12; } json!({ "samples": samples, "uptime_samples": uptime_samples, "uptime_ratio": ratio(uptime_samples, samples), "active_connections_sum": active_connections_sum, "active_connections_max": active_connections_max, "active_connections_avg": ratio(active_connections_sum, samples), "heartbeat_rtt_ms_sum": heartbeat_rtt_ms_sum, "heartbeat_rtt_ms_max": heartbeat_rtt_ms_max, "heartbeat_rtt_ms_avg": ratio(heartbeat_rtt_ms_sum, samples), "connect_errors_delta": connect_errors_delta, "disconnects_delta": disconnects_delta, "error_events_delta": error_events_delta, "ws_in_bytes_delta": ws_in_bytes_delta, "ws_out_bytes_delta": ws_out_bytes_delta, "ws_in_frames_delta": ws_in_frames_delta, "ws_out_frames_delta": ws_out_frames_delta, }) } fn ratio(numerator: i64, denominator: i64) -> Option { if denominator <= 0 { return None; } Some(numerator as f64 / denominator as f64) } pub fn build_admin_proxy_nodes_list_payload_response( items: Vec, total: usize, skip: usize, limit: usize, rollout: Option, ) -> Response { Json(json!({ "items": items, "total": total, "skip": skip, "limit": limit, "rollout": rollout, })) .into_response() } pub fn build_admin_proxy_node_events_payload_response( items: Vec, ) -> Response { Json(json!({ "items": items })).into_response() } fn system_config_is_set(value: &serde_json::Value) -> bool { match value { serde_json::Value::Null => false, serde_json::Value::Bool(value) => *value, serde_json::Value::Number(value) => value .as_i64() .map(|value| value != 0) .or_else(|| value.as_u64().map(|value| value != 0)) .or_else(|| value.as_f64().map(|value| value != 0.0)) .unwrap_or(false), serde_json::Value::String(value) => !value.trim().is_empty(), serde_json::Value::Array(value) => !value.is_empty(), serde_json::Value::Object(value) => !value.is_empty(), } } fn build_admin_system_config_list_item( key: &str, value: &serde_json::Value, description: Option<&str>, updated_at_unix_secs: Option, ) -> serde_json::Value { let masked_value = if is_sensitive_admin_system_config_key(key) { serde_json::Value::Null } else { value.clone() }; let is_set = is_sensitive_admin_system_config_key(key).then(|| system_config_is_set(value)); let mut payload = json!({ "key": key, "description": description, "updated_at": updated_at_unix_secs.and_then(unix_secs_to_rfc3339), "value": masked_value, }); if let Some(is_set) = is_set { payload["is_set"] = json!(is_set); } payload } fn unix_secs_to_rfc3339(unix_secs: u64) -> Option { chrono::DateTime::::from_timestamp(unix_secs as i64, 0) .map(|value| value.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)) } fn path_identifier_from_path(request_path: &str, prefix: &str) -> Option { let value = request_path .strip_prefix(prefix)? .trim() .trim_matches('/') .to_string(); if value.is_empty() || value.contains('/') { None } else { Some(value) } } fn suffixed_path_identifier_from_path( request_path: &str, prefix: &str, suffix: &str, ) -> Option { request_path .strip_prefix(prefix)? .strip_suffix(suffix) .map(|value| value.trim().trim_matches('/').to_string()) .filter(|value| !value.is_empty() && !value.contains('/')) } fn mask_admin_proxy_node_password(password: Option<&str>) -> Option { let password = password?; if password.is_empty() { return None; } if password.len() < 8 { return Some("****".to_string()); } Some(format!( "{}****{}", &password[..2], &password[password.len() - 2..] )) } #[cfg(test)] mod tests { use super::*; #[test] fn build_admin_system_check_update_payload_reports_available_release() { let payload = build_admin_system_check_update_payload_with_release( "0.7.0-rc27".to_string(), Some(AdminSystemUpdateRelease { version: "v0.7.0-rc28".to_string(), release_url: Some( "https://github.com/fawney19/Aether/releases/tag/v0.7.0-rc28".to_string(), ), release_notes: Some("release notes".to_string()), published_at: Some("2026-05-13T00:00:00Z".to_string()), tarball_url: None, sha256sums_url: None, }), None, ); assert_eq!(payload["current_version"], "0.7.0-rc27"); assert_eq!(payload["latest_version"], "v0.7.0-rc28"); assert_eq!(payload["has_update"], true); assert_eq!( payload["release_url"], "https://github.com/fawney19/Aether/releases/tag/v0.7.0-rc28" ); assert_eq!(payload["release_notes"], "release notes"); assert_eq!(payload["published_at"], "2026-05-13T00:00:00Z"); assert_eq!(payload["updatable"], false); assert_eq!(payload["update_blocker"], "当前平台暂无安装包"); assert_eq!(payload["error"], serde_json::Value::Null); } #[test] fn build_admin_system_check_update_payload_reports_updatable_release() { let payload = build_admin_system_check_update_payload_with_release( "0.7.0-rc27".to_string(), Some(AdminSystemUpdateRelease { version: "v0.7.0-rc28".to_string(), release_url: None, release_notes: None, published_at: None, tarball_url: Some("https://github.com/fawney19/Aether/releases/download/v0.7.0-rc28/aether.tar.gz".to_string()), sha256sums_url: Some("https://github.com/fawney19/Aether/releases/download/v0.7.0-rc28/SHA256SUMS".to_string()), }), None, ); assert_eq!(payload["has_update"], true); assert_eq!(payload["updatable"], true); assert_eq!(payload["update_blocker"], serde_json::Value::Null); } #[test] fn build_admin_system_check_update_payload_normalizes_v_prefix() { let payload = build_admin_system_check_update_payload_with_release( "0.7.0-rc28".to_string(), Some(AdminSystemUpdateRelease { version: "v0.7.0-rc28".to_string(), release_url: None, release_notes: None, published_at: None, tarball_url: None, sha256sums_url: None, }), None, ); assert_eq!(payload["has_update"], false); assert_eq!(payload["error"], serde_json::Value::Null); } #[test] fn build_admin_system_check_update_payload_ignores_git_describe_build_on_latest_release() { let payload = build_admin_system_check_update_payload_with_release( "0.7.0-rc28-11-g63149fe2-dirty".to_string(), Some(AdminSystemUpdateRelease { version: "v0.7.0-rc28".to_string(), release_url: None, release_notes: None, published_at: None, tarball_url: None, sha256sums_url: None, }), None, ); assert_eq!(payload["has_update"], false); assert_eq!(payload["error"], serde_json::Value::Null); } #[test] fn build_admin_system_check_update_payload_ignores_newer_local_release() { let payload = build_admin_system_check_update_payload_with_release( "0.7.0-rc29".to_string(), Some(AdminSystemUpdateRelease { version: "v0.7.0-rc28".to_string(), release_url: None, release_notes: None, published_at: None, tarball_url: None, sha256sums_url: None, }), None, ); assert_eq!(payload["has_update"], false); assert_eq!(payload["error"], serde_json::Value::Null); } #[test] fn build_admin_system_check_update_payload_compares_rc_versions_numerically() { let payload = build_admin_system_check_update_payload_with_release( "0.7.0-rc9".to_string(), Some(AdminSystemUpdateRelease { version: "v0.7.0-rc10".to_string(), release_url: None, release_notes: None, published_at: None, tarball_url: None, sha256sums_url: None, }), None, ); assert_eq!(payload["has_update"], true); assert_eq!(payload["error"], serde_json::Value::Null); } #[test] fn parse_admin_system_config_import_request_accepts_supported_versions() { for version in ADMIN_SYSTEM_CONFIG_SUPPORTED_VERSIONS { let parsed = parse_admin_system_config_import_request( json!({ "version": version, "global_models": [], "providers": [], }) .to_string() .as_bytes(), ) .expect("supported version should parse"); assert_eq!(parsed.request.document.version, *version); assert_eq!(parsed.request.merge_mode, AdminImportMergeMode::Skip); assert!(parsed.request.document.oauth_providers.is_empty()); assert!(parsed.request.document.system_configs.is_empty()); assert!(parsed.request.document.ldap_config.is_none()); } } #[test] fn parse_admin_system_config_import_request_rejects_unknown_versions() { for version in ["1.9", "2.4"] { let err = parse_admin_system_config_import_request( json!({ "version": version, "global_models": [], "providers": [], }) .to_string() .as_bytes(), ) .expect_err("unknown versions should fail"); assert_eq!(err.0, http::StatusCode::BAD_REQUEST); assert_eq!( err.1["detail"], format!( "不支持的配置版本: {version},支持的版本: {}", ADMIN_SYSTEM_CONFIG_SUPPORTED_VERSIONS.join(", ") ) ); } } #[test] fn parse_admin_system_config_import_request_rejects_invalid_merge_mode() { let err = parse_admin_system_config_import_request( json!({ "version": "2.2", "merge_mode": "replace_all", }) .to_string() .as_bytes(), ) .expect_err("invalid merge mode should fail"); assert_eq!(err.0, http::StatusCode::BAD_REQUEST); assert_eq!( err.1["detail"], "merge_mode 仅支持 skip / overwrite / error" ); } #[test] fn parse_admin_system_config_import_request_reports_field_path_for_shape_errors() { let err = parse_admin_system_config_import_request( json!({ "version": "2.2", "global_models": [], "providers": [{ "name": "import-openai", "endpoints": [{ "api_format": "openai:chat", "base_url": "https://api.example.com", "is_active": "yes" }] }], }) .to_string() .as_bytes(), ) .expect_err("invalid endpoint shape should fail"); assert_eq!(err.0, http::StatusCode::BAD_REQUEST); let detail = err.1["detail"].as_str().expect("detail should be a string"); assert!(detail.contains("配置文件格式无效")); assert!(detail.contains("providers[0].endpoints[0].is_active")); } #[test] fn parse_admin_system_config_import_request_accepts_numeric_string_fields() { let parsed = parse_admin_system_config_import_request( json!({ "version": "2.2", "global_models": [{ "name": "veo3.1", "display_name": "Veo 3.1", "usage_count": "42", "default_price_per_request": "1.80000000", }], "providers": [{ "name": "undyapi", "monthly_quota_usd": "12.50", "stream_first_byte_timeout": "60", "request_timeout": "120", "models": [{ "global_model_name": "veo3.1", "provider_model_name": "veo3.1", "price_per_request": "0.70000000", }] }], }) .to_string() .as_bytes(), ) .expect("numeric string fields from Python exports should parse"); let global_model = &parsed.request.document.global_models[0]; assert_eq!(global_model.usage_count, Some(42)); assert_eq!(global_model.default_price_per_request, Some(1.8)); let provider = &parsed.request.document.providers[0]; assert_eq!(provider.monthly_quota_usd, Some(12.5)); assert_eq!(provider.stream_first_byte_timeout, Some(60.0)); assert_eq!(provider.request_timeout, Some(120.0)); assert_eq!(provider.models[0].price_per_request, Some(0.7)); } #[test] fn parse_admin_system_config_import_request_rejects_invalid_numeric_string_fields() { let err = parse_admin_system_config_import_request( json!({ "version": "2.2", "global_models": [{ "name": "veo3.1", "display_name": "Veo 3.1", "default_price_per_request": "not-a-number", }], "providers": [], }) .to_string() .as_bytes(), ) .expect_err("invalid numeric string fields should fail"); assert_eq!(err.0, http::StatusCode::BAD_REQUEST); let detail = err.1["detail"].as_str().expect("detail should be a string"); assert!(detail.contains("配置文件格式无效")); assert!(detail.contains("default_price_per_request")); } #[test] fn resolve_admin_system_export_key_api_formats_uses_endpoint_fallback() { let provider_formats = vec!["openai:chat".to_string(), "claude:messages".to_string()]; let resolved = resolve_admin_system_export_key_api_formats(None, &provider_formats, |value| { Some(value.to_string()) }); assert_eq!(resolved, provider_formats); } #[test] fn sensitive_admin_system_config_keys_are_case_insensitive() { assert!(is_sensitive_admin_system_config_key("smtp_password")); assert!(is_sensitive_admin_system_config_key("SMTP_PASSWORD")); assert!(is_sensitive_admin_system_config_key("turnstile_secret_key")); assert!(is_sensitive_admin_system_config_key("TURNSTILE_SECRET_KEY")); assert!(is_sensitive_admin_system_config_key( "module.server_chan_push.send_key" )); assert!(is_sensitive_admin_system_config_key( "module.important_notification.server_chan_send_key" )); assert!(is_sensitive_admin_system_config_key( "module.bark_push.device_key" )); assert!(!is_sensitive_admin_system_config_key("site_name")); } #[test] fn s3_backup_secret_access_key_is_sensitive() { assert!(is_sensitive_admin_system_config_key( "backup_s3_secret_access_key" )); assert!(is_sensitive_admin_system_config_key( "BACKUP_S3_SECRET_ACCESS_KEY" )); assert!(!is_sensitive_admin_system_config_key("backup_s3_bucket")); } #[test] fn s3_backup_defaults_match_admin_ui_contract() { assert_eq!( admin_system_config_default_value("backup_s3_scope"), Some(json!("data")) ); assert_eq!( admin_system_config_default_value("backup_s3_schedule_unit"), Some(json!("days")) ); assert_eq!( admin_system_config_default_value("backup_s3_schedule_interval"), Some(json!(1)) ); assert_eq!( admin_system_config_default_value("backup_s3_retention_count"), Some(json!(7)) ); assert_eq!( admin_system_config_default_value("backup_s3_path_style"), Some(json!(true)) ); assert_eq!( admin_system_config_default_value("backup_s3_user_agent"), Some(json!("rclone/v1.68.0")) ); } #[test] fn cyber_continue_failover_defaults_to_disabled() { assert_eq!( admin_system_config_default_value("cyber_continue_failover"), Some(json!(false)) ); } #[test] fn cyber_continue_failover_update_requires_a_boolean() { let update = parse_admin_system_config_update("cyber_continue_failover", br#"{"value":true}"#) .expect("boolean Cyber failover setting should parse"); assert_eq!(update.value, json!(true)); assert!(parse_admin_system_config_update( "cyber_continue_failover", br#"{"value":"true"}"#, ) .is_err()); } #[test] fn model_directives_update_accepts_legacy_and_current_config_shapes() { for body in [ r#"{ "value": { "reasoning_effort": { "enabled": true, "api_formats": { "openai:responses": { "enabled": true, "mappings": { "low": { "reasoning": { "effort": "low" } } } } } } } }"#, r#"{ "value": { "reasoning_effort": { "api_formats": { "openai:responses": { "suffixes": ["low", "VendorFuture"], "mappings": { "VendorFuture": { "reasoning": { "context": "all_turns" } } } } } } } }"#, r#"{ "value": { "reasoning_effort": { "api_formats": { "/v1/responses": { "suffixes": ["low"], "mappings": {} } } } } }"#, ] { let update = parse_admin_system_config_update("model_directives", body.as_bytes()) .expect("valid model directive config should parse"); assert!(update.value["reasoning_effort"].is_object()); } let reset = parse_admin_system_config_update("model_directives", br#"{"value":null}"#) .expect("null should reset model directives to defaults"); assert_eq!( reset.value, aether_ai_formats::default_model_directives_config() ); } #[test] fn model_directives_update_rejects_unsafe_mapping_shapes() { for body in [ r#"{"value":[]}"#, r#"{"value":{"reasoning_effort":true}}"#, r#"{"value":{"reasoning_effort":{"enabled":"true"}}}"#, r#"{"value":{"reasoning_effort":{"api_formats":[]}}}"#, r#"{"value":{"reasoning_effort":{"api_formats":{"openai:responses":{"suffixes":["low",42]}}}}}"#, r#"{"value":{"reasoning_effort":{"api_formats":{"openai:responses":{"mappings":{"low":"replace-body"}}}}}}"#, ] { let error = parse_admin_system_config_update("model_directives", body.as_bytes()) .expect_err("invalid model directive config should fail"); assert_eq!(error.0, http::StatusCode::BAD_REQUEST); assert_eq!(error.1["detail"], "模型后缀参数配置格式无效"); } } #[test] fn model_directives_update_rejects_ambiguous_aliases_and_suffixes() { for body in [ r#"{"value":{"reasoning_effort":{"api_formats":{"openai:responses":true,"/v1/responses":false}}}}"#, r#"{"value":{"reasoning_effort":{"api_formats":{"openai:responses":{"suffixes":["low","LOW"]}}}}}"#, r#"{"value":{"reasoning_effort":{"api_formats":{"openai:responses":{"mappings":{"VendorFuture":{},"vendorfuture":{}}}}}}}"#, ] { let error = parse_admin_system_config_update("model_directives", body.as_bytes()) .expect_err("ambiguous model directive config should fail"); assert_eq!(error.0, http::StatusCode::BAD_REQUEST); assert_eq!(error.1["detail"], "模型后缀参数配置格式无效"); } } #[test] fn enable_model_directives_update_requires_a_boolean() { let update = parse_admin_system_config_update("enable_model_directives", br#"{"value":true}"#) .expect("boolean model directives flag should parse"); assert_eq!(update.value, json!(true)); assert!(parse_admin_system_config_update( "enable_model_directives", br#"{"value":"true"}"#, ) .is_err()); } #[test] fn legacy_notification_email_config_key_normalizes_to_important_notification() { assert_eq!( normalize_admin_system_config_key("module.notification_email.enabled"), "module.important_notification.enabled" ); assert_eq!( admin_system_config_delete_keys("module.important_notification.enabled"), vec![ "module.important_notification.enabled".to_string(), "module.notification_email.enabled".to_string(), ] ); } #[test] fn s3_backup_secret_detail_is_write_only() { let payload = build_admin_system_config_detail_payload( "backup_s3_secret_access_key", Some(json!("encrypted-secret")), ) .expect("sensitive backup key should render"); assert_eq!(payload["key"], json!("backup_s3_secret_access_key")); assert_eq!(payload["value"], serde_json::Value::Null); assert_eq!(payload["is_set"], json!(true)); } #[test] fn legacy_server_chan_config_keys_normalize_to_push_module() { assert_eq!( normalize_admin_system_config_key("module.important_notification.server_chan_send_key"), "module.server_chan_push.send_key" ); assert_eq!( admin_system_config_delete_keys("module.server_chan_push.send_key"), vec![ "module.server_chan_push.send_key".to_string(), "module.important_notification.server_chan_send_key".to_string(), ] ); } #[test] fn system_config_list_normalizes_legacy_keys_and_prefers_canonical_rows() { let entries = vec![ StoredSystemConfigEntry { key: "module.important_notification.server_chan_send_key".to_string(), value: json!("legacy-secret"), description: None, updated_at_unix_secs: None, }, StoredSystemConfigEntry { key: "module.server_chan_push.send_key".to_string(), value: json!("canonical-secret"), description: None, updated_at_unix_secs: None, }, StoredSystemConfigEntry { key: "module.notification_email.enabled".to_string(), value: json!(true), description: None, updated_at_unix_secs: None, }, ]; let payload = build_admin_system_configs_payload(&entries); let rows = payload.as_array().expect("config list should be an array"); assert_eq!(rows.len(), 2); assert!(rows.iter().any(|row| { row["key"] == json!("module.server_chan_push.send_key") && row["is_set"] == json!(true) })); assert!(rows.iter().any(|row| { row["key"] == json!("module.important_notification.enabled") && row["value"] == json!(true) })); } #[test] fn notification_service_items_are_normalized() { let update = parse_admin_system_config_update( "module.important_notification.items", r#"{ "value": [ { "key": "user_balance_low", "name": " 用户余额不足 ", "enabled": true, "channel": "serverchan", "title_template": " 余额提醒 ", "markdown_template": " {body} ", "text_template": null, "user_email_enabled": true, "system": true } ] }"# .as_bytes(), ) .expect("items should parse"); assert_eq!(update.normalized_key, "module.important_notification.items"); assert_eq!(update.value[0]["channel"], json!("server_chan")); assert_eq!(update.value[0]["name"], json!("用户余额不足")); assert_eq!(update.value[0]["text_template"], json!("")); assert_eq!(update.value[0]["user_email_enabled"], json!(true)); } #[test] fn bark_push_config_values_are_normalized() { let update = parse_admin_system_config_update( "module.bark_push.server_url", r#"{ "value": " https://api.day.app/ " }"#.as_bytes(), ) .expect("server url should parse"); assert_eq!(update.normalized_key, "module.bark_push.server_url"); assert_eq!(update.value, json!("https://api.day.app")); let err = parse_admin_system_config_update( "module.bark_push.server_url", r#"{ "value": "api.day.app" }"#.as_bytes(), ) .expect_err("server url without scheme should fail"); assert_eq!(err.0, http::StatusCode::BAD_REQUEST); } #[test] fn build_admin_system_config_detail_masks_turnstile_secret_key() { let payload = build_admin_system_config_detail_payload( "turnstile_secret_key", Some(json!("encrypted-turnstile-secret")), ) .expect("turnstile secret detail should build"); assert_eq!(payload["key"], "turnstile_secret_key"); assert_eq!(payload["value"], serde_json::Value::Null); assert_eq!(payload["is_set"], json!(true)); } }