Files
Aether/apps/aether-gateway/src/handlers/admin/users/shared.rs
T

431 lines
14 KiB
Rust
Raw Normal View History

use super::ADMIN_USERS_DATA_UNAVAILABLE_DETAIL;
use crate::handlers::admin::shared::AdminTypedObjectPatch;
2026-05-20 16:11:49 +08:00
use crate::handlers::shared::{deserialize_optional_string_list_patch, normalize_ip_rules};
use axum::{
body::Body,
http,
response::{IntoResponse, Response},
Json,
};
use regex::Regex;
2026-05-14 11:14:20 +08:00
use serde_json::{json, Value};
#[derive(Debug, serde::Deserialize)]
pub(super) struct AdminCreateUserApiKeyRequest {
#[serde(default)]
pub(super) name: Option<String>,
#[serde(default)]
pub(super) allowed_providers: Option<Vec<String>>,
#[serde(default)]
pub(super) allowed_api_formats: Option<Vec<String>>,
#[serde(default)]
pub(super) allowed_models: Option<Vec<String>>,
2026-05-20 16:11:49 +08:00
#[serde(default, alias = "allowed_ips")]
pub(super) ip_rules: Option<Vec<String>>,
#[serde(default)]
pub(super) rate_limit: Option<i32>,
#[serde(default)]
2026-04-17 14:21:43 +08:00
pub(super) concurrent_limit: Option<i32>,
#[serde(default)]
pub(super) expire_days: Option<i32>,
#[serde(default)]
pub(super) expires_at: Option<String>,
#[serde(default)]
pub(super) initial_balance_usd: Option<f64>,
#[serde(default)]
pub(super) unlimited_balance: Option<bool>,
#[serde(default)]
pub(super) is_standalone: Option<bool>,
#[serde(default)]
pub(super) auto_delete_on_expiry: Option<bool>,
2026-05-14 11:14:20 +08:00
#[serde(default)]
pub(super) feature_settings: Option<Value>,
}
#[derive(Debug, serde::Deserialize)]
pub(super) struct AdminUpdateUserApiKeyRequest {
#[serde(default)]
pub(super) name: Option<String>,
#[serde(default)]
pub(super) rate_limit: Option<i32>,
2026-04-17 14:21:43 +08:00
#[serde(default)]
pub(super) concurrent_limit: Option<i32>,
2026-05-14 11:14:20 +08:00
#[serde(default)]
pub(super) feature_settings: Option<Option<Value>>,
2026-05-20 16:11:49 +08:00
#[serde(
default,
alias = "allowed_ips",
deserialize_with = "deserialize_optional_string_list_patch"
)]
pub(super) ip_rules: Option<Option<Vec<String>>>,
}
#[derive(Debug, serde::Deserialize)]
pub(super) struct AdminToggleUserApiKeyLockRequest {
#[serde(default)]
pub(super) locked: Option<bool>,
}
#[derive(Debug, serde::Deserialize)]
pub(super) struct AdminCreateUserRequest {
pub(super) username: String,
pub(super) password: String,
#[serde(default)]
pub(super) email: Option<String>,
#[serde(default)]
pub(super) role: Option<String>,
#[serde(default)]
pub(super) initial_gift_usd: Option<f64>,
#[serde(default)]
pub(super) unlimited: bool,
#[serde(default)]
pub(super) group_ids: Vec<String>,
2026-05-14 11:14:20 +08:00
#[serde(default)]
pub(super) feature_settings: Option<Value>,
}
#[derive(Debug, serde::Deserialize)]
pub(super) struct AdminUpdateUserRequest {
#[serde(default)]
pub(super) email: Option<String>,
#[serde(default)]
pub(super) username: Option<String>,
#[serde(default)]
pub(super) password: Option<String>,
#[serde(default)]
pub(super) role: Option<String>,
#[serde(default)]
pub(super) unlimited: Option<bool>,
#[serde(default)]
pub(super) group_ids: Vec<String>,
#[serde(default)]
pub(super) is_active: Option<bool>,
2026-05-14 11:14:20 +08:00
#[serde(default)]
pub(super) feature_settings: Option<Option<Value>>,
}
pub(super) type AdminUpdateUserPatch = AdminTypedObjectPatch<AdminUpdateUserRequest>;
2026-05-13 17:56:18 +08:00
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<String, serde_json::Value>,
) -> 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<Body> {
(
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<Body> {
(
http::StatusCode::CONFLICT,
Json(json!({
"detail": detail,
"error_code": "read_only_mode",
})),
)
.into_response()
}
2026-05-13 01:18:38 +08:00
pub(super) fn build_admin_users_bad_request_response(detail: impl Into<String>) -> Response<Body> {
(
http::StatusCode::BAD_REQUEST,
2026-05-13 01:18:38 +08:00
Json(json!({ "detail": detail.into() })),
)
.into_response()
}
pub(super) fn normalize_admin_optional_user_email(
value: Option<&str>,
) -> Result<Option<String>, 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<String, String> {
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<String, String> {
2026-05-13 18:12:32 +08:00
let role = value
.map(str::trim)
.filter(|value| !value.is_empty())
2026-05-13 18:12:32 +08:00
.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<Vec<String>>,
field_name: &str,
) -> Result<Option<Vec<String>>, 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<Vec<String>>,
) -> Result<Option<Vec<String>>, 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());
}
2026-06-02 23:06:29 +08:00
if !looks_like_admin_api_format_signature(item) {
return Err(format!("allowed_api_formats 格式无效: {item}"));
}
2026-04-30 00:26:33 +08:00
let Some(normalized_item) = crate::api::ai::normalize_admin_endpoint_signature(item) else {
return Err(format!("allowed_api_formats 格式无效: {item}"));
2026-04-30 00:26:33 +08:00
};
let normalized_item = normalized_item.to_string();
if seen.insert(normalized_item.clone()) {
normalized.push(normalized_item);
}
}
Ok(Some(normalized))
}
2026-06-02 23:06:29 +08:00
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())
}
2026-05-20 16:11:49 +08:00
pub(crate) fn normalize_admin_user_ip_rules(
value: Option<Vec<String>>,
) -> Result<Option<Vec<String>>, String> {
2026-05-20 16:11:49 +08:00
normalize_ip_rules(value)
}
2026-05-10 17:28:23 +08:00
pub(crate) fn normalize_admin_list_policy_mode(value: &str) -> Result<String, String> {
match value.trim().to_ascii_lowercase().as_str() {
"inherit" | "unrestricted" | "specific" | "deny_all" => {
Ok(value.trim().to_ascii_lowercase())
}
_ => Err("权限列表模式不合法".to_string()),
}
}
2026-05-10 17:28:23 +08:00
pub(crate) fn normalize_admin_rate_limit_policy_mode(value: &str) -> Result<String, String> {
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<Vec<String>>) -> 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<i32>) -> String {
if value.is_some() {
"custom".to_string()
} else {
"system".to_string()
}
}
pub(super) fn normalize_admin_user_group_ids(values: Vec<String>) -> Vec<String> {
values
.into_iter()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.collect::<std::collections::BTreeSet<_>>()
.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::<f64>().unwrap_or(10.0),
_ => 10.0,
}
}
pub(super) fn format_optional_datetime_iso8601(
value: Option<chrono::DateTime<chrono::Utc>>,
) -> Option<String> {
value.map(|value| value.to_rfc3339())
}
2026-04-30 00:26:33 +08:00
#[cfg(test)]
mod tests {
use super::{normalize_admin_user_api_formats, AdminUpdateUserApiKeyRequest};
use serde_json::json;
2026-04-30 00:26:33 +08:00
#[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(),
2026-05-03 17:32:41 +08:00
"jina:rerank".to_string(),
2026-04-30 00:26:33 +08:00
"openai:responses".to_string(),
]))
.expect("formats should normalize"),
Some(vec![
"openai:responses".to_string(),
"claude:messages".to_string(),
"gemini:generate_content".to_string(),
2026-05-03 17:32:41 +08:00
"jina:rerank".to_string(),
2026-04-30 00:26:33 +08:00
])
);
}
#[test]
fn admin_user_api_formats_reject_unsupported_signatures() {
for unsupported in [
"claude",
"openai",
"unknown:chat",
"openai:unknown",
"gemini:generate",
2026-04-30 00:26:33 +08:00
] {
assert!(
normalize_admin_user_api_formats(Some(vec![unsupported.to_string()])).is_err(),
"{unsupported} should be rejected"
2026-04-30 00:26:33 +08:00
);
}
}
#[test]
2026-05-20 16:11:49 +08:00
fn admin_update_api_key_distinguishes_missing_null_and_present_ip_rules() {
let missing = serde_json::from_value::<AdminUpdateUserApiKeyRequest>(json!({
2026-05-20 16:11:49 +08:00
"name": "unchanged-ip-rules",
}))
2026-05-20 16:11:49 +08:00
.expect("missing ip_rules should deserialize");
assert_eq!(missing.ip_rules, None);
let cleared = serde_json::from_value::<AdminUpdateUserApiKeyRequest>(json!({
2026-05-20 16:11:49 +08:00
"ip_rules": null,
}))
2026-05-20 16:11:49 +08:00
.expect("null ip_rules should deserialize");
assert_eq!(cleared.ip_rules, Some(None));
let updated = serde_json::from_value::<AdminUpdateUserApiKeyRequest>(json!({
2026-05-20 16:11:49 +08:00
"ip_rules": ["203.0.113.10", "10.0.0.0/24"],
}))
2026-05-20 16:11:49 +08:00
.expect("present ip_rules should deserialize");
assert_eq!(
2026-05-20 16:11:49 +08:00
updated.ip_rules,
Some(Some(vec![
"203.0.113.10".to_string(),
"10.0.0.0/24".to_string(),
])),
);
}
2026-04-30 00:26:33 +08:00
}