use super::ADMIN_USERS_DATA_UNAVAILABLE_DETAIL; use crate::handlers::admin::shared::AdminTypedObjectPatch; use crate::handlers::shared::{deserialize_optional_string_list_patch, normalize_ip_rules}; use axum::{ body::Body, http, response::{IntoResponse, Response}, Json, }; use regex::Regex; use serde_json::{json, Value}; #[derive(Debug, serde::Deserialize)] pub(super) struct AdminCreateUserApiKeyRequest { #[serde(default)] pub(super) name: Option, #[serde(default)] pub(super) allowed_providers: Option>, #[serde(default)] pub(super) allowed_api_formats: Option>, #[serde(default)] pub(super) allowed_models: Option>, #[serde(default, alias = "allowed_ips")] pub(super) ip_rules: Option>, #[serde(default)] pub(super) rate_limit: Option, #[serde(default)] pub(super) concurrent_limit: Option, #[serde(default)] pub(super) expire_days: Option, #[serde(default)] pub(super) expires_at: Option, #[serde(default)] pub(super) initial_balance_usd: Option, #[serde(default)] pub(super) unlimited_balance: Option, #[serde(default)] pub(super) is_standalone: Option, #[serde(default)] pub(super) auto_delete_on_expiry: Option, #[serde(default)] pub(super) feature_settings: Option, } #[derive(Debug, serde::Deserialize)] pub(super) struct AdminUpdateUserApiKeyRequest { #[serde(default)] pub(super) name: Option, #[serde(default)] pub(super) rate_limit: Option, #[serde(default)] pub(super) concurrent_limit: Option, #[serde(default)] pub(super) feature_settings: Option>, #[serde( default, alias = "allowed_ips", deserialize_with = "deserialize_optional_string_list_patch" )] pub(super) ip_rules: Option>>, } #[derive(Debug, serde::Deserialize)] pub(super) struct AdminToggleUserApiKeyLockRequest { #[serde(default)] pub(super) locked: Option, } #[derive(Debug, serde::Deserialize)] pub(super) struct AdminCreateUserRequest { pub(super) username: String, pub(super) password: String, #[serde(default)] pub(super) email: Option, #[serde(default)] pub(super) role: Option, #[serde(default)] pub(super) initial_gift_usd: Option, #[serde(default)] pub(super) unlimited: bool, #[serde(default)] pub(super) group_ids: Vec, #[serde(default)] pub(super) feature_settings: Option, } #[derive(Debug, serde::Deserialize)] pub(super) struct AdminUpdateUserRequest { #[serde(default)] pub(super) email: Option, #[serde(default)] pub(super) username: Option, #[serde(default)] pub(super) password: Option, #[serde(default)] pub(super) role: Option, #[serde(default)] pub(super) unlimited: Option, #[serde(default)] pub(super) group_ids: Vec, #[serde(default)] pub(super) is_active: Option, #[serde(default)] pub(super) feature_settings: Option>, } pub(super) type AdminUpdateUserPatch = AdminTypedObjectPatch; const DISABLED_USER_POLICY_FIELDS: &[&str] = &[ "allowed_providers", "allowed_providers_mode", "allowed_api_formats", "allowed_api_formats_mode", "allowed_models", "allowed_models_mode", "rate_limit", "rate_limit_mode", ]; pub(super) fn disabled_user_policy_field( object: &serde_json::Map, ) -> Option<&'static str> { DISABLED_USER_POLICY_FIELDS .iter() .copied() .find(|field| object.contains_key(*field)) } pub(super) fn disabled_user_policy_detail(field: &str) -> String { format!("{field} 已停用,请通过用户分组管理访问权限") } pub(super) fn build_admin_users_data_unavailable_response() -> Response { ( http::StatusCode::SERVICE_UNAVAILABLE, Json(json!({ "detail": ADMIN_USERS_DATA_UNAVAILABLE_DETAIL })), ) .into_response() } pub(super) fn build_admin_users_read_only_response(detail: &'static str) -> Response { ( http::StatusCode::CONFLICT, Json(json!({ "detail": detail, "error_code": "read_only_mode", })), ) .into_response() } pub(super) fn build_admin_users_bad_request_response(detail: impl Into) -> Response { ( http::StatusCode::BAD_REQUEST, Json(json!({ "detail": detail.into() })), ) .into_response() } pub(super) fn normalize_admin_optional_user_email( value: Option<&str>, ) -> Result, String> { let Some(value) = value else { return Ok(None); }; let value = value.trim(); if value.is_empty() { return Ok(None); } let normalized = value.to_ascii_lowercase(); let pattern = Regex::new(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$") .expect("email regex should compile"); if !pattern.is_match(&normalized) { return Err("邮箱格式无效".to_string()); } Ok(Some(normalized)) } pub(super) fn normalize_admin_username(value: &str) -> Result { let value = value.trim(); if value.is_empty() { return Err("用户名不能为空".to_string()); } if value.len() < 3 { return Err("用户名长度至少为3个字符".to_string()); } if value.len() > 30 { return Err("用户名长度不能超过30个字符".to_string()); } let pattern = Regex::new(r"^[a-zA-Z0-9_.-]+$").expect("username regex should compile"); if !pattern.is_match(value) { return Err("用户名只能包含字母、数字、下划线、连字符和点号".to_string()); } Ok(value.to_string()) } pub(super) fn validate_admin_user_password(password: &str, policy: &str) -> Result<(), String> { if password.is_empty() { return Err("密码不能为空".to_string()); } if password.as_bytes().len() > 72 { return Err("密码长度不能超过72字节".to_string()); } let min_len = if matches!(policy, "medium" | "strong") { 8 } else { 6 }; if password.chars().count() < min_len { return Err(format!("密码长度至少为{min_len}个字符")); } if policy == "medium" { if !password.chars().any(|ch| ch.is_ascii_alphabetic()) { return Err("密码必须包含至少一个字母".to_string()); } if !password.chars().any(|ch| ch.is_ascii_digit()) { return Err("密码必须包含至少一个数字".to_string()); } } else if policy == "strong" { if !password.chars().any(|ch| ch.is_ascii_uppercase()) { return Err("密码必须包含至少一个大写字母".to_string()); } if !password.chars().any(|ch| ch.is_ascii_lowercase()) { return Err("密码必须包含至少一个小写字母".to_string()); } if !password.chars().any(|ch| ch.is_ascii_digit()) { return Err("密码必须包含至少一个数字".to_string()); } if !password.chars().any(|ch| !ch.is_ascii_alphanumeric()) { return Err("密码必须包含至少一个特殊字符".to_string()); } } Ok(()) } pub(super) fn normalize_admin_user_role(value: Option<&str>) -> Result { let role = value .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("user"); match crate::roles::normalize_assignable_user_role(role) { Some(role) => Ok(role.to_string()), None => Err("角色参数不合法".to_string()), } } pub(crate) fn normalize_admin_user_string_list( value: Option>, field_name: &str, ) -> Result>, String> { let Some(values) = value else { return Ok(None); }; let mut normalized = Vec::new(); let mut seen = std::collections::BTreeSet::new(); for item in values { let item = item.trim(); if item.is_empty() { return Err(format!("{field_name} 不能为空")); } if seen.insert(item.to_string()) { normalized.push(item.to_string()); } } Ok(Some(normalized)) } pub(crate) fn normalize_admin_user_api_formats( value: Option>, ) -> Result>, String> { let Some(values) = value else { return Ok(None); }; let mut normalized = Vec::new(); let mut seen = std::collections::BTreeSet::new(); for item in values { let item = item.trim(); if item.is_empty() { return Err("allowed_api_formats 不能为空".to_string()); } if !looks_like_admin_api_format_signature(item) { return Err(format!("allowed_api_formats 格式无效: {item}")); } let Some(normalized_item) = crate::api::ai::normalize_admin_endpoint_signature(item) else { return Err(format!("allowed_api_formats 格式无效: {item}")); }; let normalized_item = normalized_item.to_string(); if seen.insert(normalized_item.clone()) { normalized.push(normalized_item); } } Ok(Some(normalized)) } fn looks_like_admin_api_format_signature(value: &str) -> bool { value .split_once(':') .is_some_and(|(family, kind)| !family.trim().is_empty() && !kind.trim().is_empty()) } pub(crate) fn normalize_admin_user_ip_rules( value: Option>, ) -> Result>, String> { normalize_ip_rules(value) } pub(crate) fn normalize_admin_list_policy_mode(value: &str) -> Result { match value.trim().to_ascii_lowercase().as_str() { "inherit" | "unrestricted" | "specific" | "deny_all" => { Ok(value.trim().to_ascii_lowercase()) } _ => Err("权限列表模式不合法".to_string()), } } pub(crate) fn normalize_admin_rate_limit_policy_mode(value: &str) -> Result { match value.trim().to_ascii_lowercase().as_str() { "inherit" | "system" | "custom" => Ok(value.trim().to_ascii_lowercase()), _ => Err("限速模式不合法".to_string()), } } pub(super) fn legacy_admin_list_policy_mode(values: &Option>) -> String { if values.as_ref().is_some_and(|items| !items.is_empty()) { "specific".to_string() } else { "unrestricted".to_string() } } pub(super) fn legacy_admin_rate_limit_policy_mode(value: Option) -> String { if value.is_some() { "custom".to_string() } else { "system".to_string() } } pub(super) fn normalize_admin_user_group_ids(values: Vec) -> Vec { values .into_iter() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) .collect::>() .into_iter() .collect() } pub(super) fn admin_default_user_initial_gift(value: Option<&serde_json::Value>) -> f64 { match value { Some(serde_json::Value::Number(number)) => number.as_f64().unwrap_or(10.0), Some(serde_json::Value::String(value)) => value.parse::().unwrap_or(10.0), _ => 10.0, } } pub(super) fn format_optional_datetime_iso8601( value: Option>, ) -> Option { value.map(|value| value.to_rfc3339()) } #[cfg(test)] mod tests { use super::{normalize_admin_user_api_formats, AdminUpdateUserApiKeyRequest}; use serde_json::json; #[test] fn admin_user_api_formats_accept_current_canonical_signatures() { assert_eq!( normalize_admin_user_api_formats(Some(vec![ " OPENAI:RESPONSES ".to_string(), "claude:messages".to_string(), "gemini:generate_content".to_string(), "jina:rerank".to_string(), "openai:responses".to_string(), ])) .expect("formats should normalize"), Some(vec![ "openai:responses".to_string(), "claude:messages".to_string(), "gemini:generate_content".to_string(), "jina:rerank".to_string(), ]) ); } #[test] fn admin_user_api_formats_reject_unsupported_signatures() { for unsupported in [ "claude", "openai", "unknown:chat", "openai:unknown", "gemini:generate", ] { assert!( normalize_admin_user_api_formats(Some(vec![unsupported.to_string()])).is_err(), "{unsupported} should be rejected" ); } } #[test] fn admin_update_api_key_distinguishes_missing_null_and_present_ip_rules() { let missing = serde_json::from_value::(json!({ "name": "unchanged-ip-rules", })) .expect("missing ip_rules should deserialize"); assert_eq!(missing.ip_rules, None); let cleared = serde_json::from_value::(json!({ "ip_rules": null, })) .expect("null ip_rules should deserialize"); assert_eq!(cleared.ip_rules, Some(None)); let updated = serde_json::from_value::(json!({ "ip_rules": ["203.0.113.10", "10.0.0.0/24"], })) .expect("present ip_rules should deserialize"); assert_eq!( updated.ip_rules, Some(Some(vec![ "203.0.113.10".to_string(), "10.0.0.0/24".to_string(), ])), ); } }