fix: align user group access controls

This commit is contained in:
fawney19
2026-05-10 17:28:23 +08:00
parent a8ac944959
commit d246f6b42c
33 changed files with 1958 additions and 1018 deletions

View File

@@ -61,6 +61,8 @@ pub(crate) const TRUSTED_ADMIN_SESSION_ID_HEADER: &str = "x-aether-admin-session
pub(crate) const TRUSTED_ADMIN_MANAGEMENT_TOKEN_ID_HEADER: &str = pub(crate) const TRUSTED_ADMIN_MANAGEMENT_TOKEN_ID_HEADER: &str =
"x-aether-admin-management-token-id"; "x-aether-admin-management-token-id";
pub(crate) const TRUSTED_RATE_LIMIT_PREFLIGHT_HEADER: &str = "x-aether-rate-limit-preflight"; pub(crate) const TRUSTED_RATE_LIMIT_PREFLIGHT_HEADER: &str = "x-aether-rate-limit-preflight";
pub(crate) const DEFAULT_USER_GROUP_CONFIG_KEY: &str = "default_user_group_id";
pub(crate) const BUILTIN_DEFAULT_USER_GROUP_ID: &str = "00000000-0000-0000-0000-000000000001";
pub(crate) const FRONTDOOR_REPLACEABLE_ROUTE_GROUPS: &[&str] = &["frontdoor_compat_router"]; pub(crate) const FRONTDOOR_REPLACEABLE_ROUTE_GROUPS: &[&str] = &["frontdoor_compat_router"];
pub(crate) const FRONTDOOR_REPLACEABLE_MIDDLEWARE_GROUPS: &[&str] = &["cors"]; pub(crate) const FRONTDOOR_REPLACEABLE_MIDDLEWARE_GROUPS: &[&str] = &["cors"];

View File

@@ -1663,14 +1663,12 @@ impl GatewayDataState {
.list_user_groups_for_user(&snapshot.user_id) .list_user_groups_for_user(&snapshot.user_id)
.await?; .await?;
groups.sort_by(|left, right| { groups.sort_by(|left, right| {
right left.name
.priority .cmp(&right.name)
.cmp(&left.priority)
.then_with(|| left.name.cmp(&right.name))
.then_with(|| left.id.cmp(&right.id)) .then_with(|| left.id.cmp(&right.id))
}); });
let allowed_providers = resolve_effective_list_policy( let mut allowed_providers = resolve_effective_list_policy(
user.allowed_providers, user.allowed_providers,
&user.allowed_providers_mode, &user.allowed_providers_mode,
&groups, &groups,
@@ -1681,7 +1679,7 @@ impl GatewayDataState {
) )
}, },
); );
let allowed_api_formats = resolve_effective_list_policy( let mut allowed_api_formats = resolve_effective_list_policy(
user.allowed_api_formats, user.allowed_api_formats,
&user.allowed_api_formats_mode, &user.allowed_api_formats_mode,
&groups, &groups,
@@ -1692,7 +1690,7 @@ impl GatewayDataState {
) )
}, },
); );
let allowed_models = resolve_effective_list_policy( let mut allowed_models = resolve_effective_list_policy(
user.allowed_models, user.allowed_models,
&user.allowed_models_mode, &user.allowed_models_mode,
&groups, &groups,
@@ -1717,6 +1715,20 @@ impl GatewayDataState {
user_rate_limit_mode, user_rate_limit_mode,
&groups, &groups,
); );
if !snapshot.api_key_is_standalone {
constrain_api_key_list_policy_to_user_policy(
&mut allowed_providers,
&mut snapshot.api_key_allowed_providers,
);
constrain_api_key_list_policy_to_user_policy(
&mut allowed_api_formats,
&mut snapshot.api_key_allowed_api_formats,
);
constrain_api_key_list_policy_to_user_policy(
&mut allowed_models,
&mut snapshot.api_key_allowed_models,
);
}
snapshot.apply_user_policy( snapshot.apply_user_policy(
allowed_providers, allowed_providers,
allowed_api_formats, allowed_api_formats,
@@ -1735,64 +1747,135 @@ fn resolve_effective_list_policy(
&aether_data::repository::users::StoredUserGroup, &aether_data::repository::users::StoredUserGroup,
) -> (&str, Option<Vec<String>>), ) -> (&str, Option<Vec<String>>),
) -> Option<Vec<String>> { ) -> Option<Vec<String>> {
match user_mode { let group_policy = groups.iter().fold(None, |effective, group| {
"unrestricted" => None, let (mode, values) = group_field(group);
"specific" => Some(user_values.unwrap_or_default()), intersect_list_policies(effective, list_restriction_from_mode(mode, values))
});
let user_policy = list_restriction_from_mode(user_mode, user_values);
intersect_list_policies(group_policy, user_policy)
}
fn list_restriction_from_mode(mode: &str, values: Option<Vec<String>>) -> Option<Vec<String>> {
match mode {
"specific" => Some(values.unwrap_or_default()),
"deny_all" => Some(Vec::new()), "deny_all" => Some(Vec::new()),
"inherit" => groups
.iter()
.find_map(|group| {
let (mode, values) = group_field(group);
match mode {
"unrestricted" => Some(None),
"specific" => Some(Some(values.unwrap_or_default())),
"deny_all" => Some(Some(Vec::new())),
_ => None,
}
})
.flatten_or_unrestricted(),
_ => None, _ => None,
} }
} }
trait FlattenPolicyOption<T> {
fn flatten_or_unrestricted(self) -> Option<T>;
}
impl<T> FlattenPolicyOption<T> for Option<Option<T>> {
fn flatten_or_unrestricted(self) -> Option<T> {
self.unwrap_or_default()
}
}
fn resolve_effective_rate_limit_policy( fn resolve_effective_rate_limit_policy(
user_rate_limit: Option<i32>, user_rate_limit: Option<i32>,
user_mode: &str, user_mode: &str,
groups: &[aether_data::repository::users::StoredUserGroup], groups: &[aether_data::repository::users::StoredUserGroup],
) -> Option<i32> { ) -> Option<i32> {
match user_mode { let group_policy = groups.iter().fold(None, |effective, group| {
"custom" => Some(user_rate_limit.unwrap_or(0)), intersect_rate_limit_policies(
"system" => None, effective,
"inherit" => groups rate_limit_restriction_from_mode(&group.rate_limit_mode, group.rate_limit),
.iter() )
.find_map(|group| match group.rate_limit_mode.as_str() { });
"custom" => Some(Some(group.rate_limit.unwrap_or(0))), let user_policy = rate_limit_restriction_from_mode(user_mode, user_rate_limit);
"system" => Some(None), rate_limit_policy_value(intersect_rate_limit_policies(group_policy, user_policy))
_ => None, }
})
.flatten_or_unrestricted(), #[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum RateLimitRestriction {
Unlimited,
Limited(i32),
}
fn rate_limit_restriction_from_mode(
mode: &str,
rate_limit: Option<i32>,
) -> Option<RateLimitRestriction> {
match mode {
"custom" => {
let rate_limit = rate_limit.unwrap_or(0).max(0);
if rate_limit == 0 {
Some(RateLimitRestriction::Unlimited)
} else {
Some(RateLimitRestriction::Limited(rate_limit))
}
}
_ => None, _ => None,
} }
} }
fn intersect_list_policies(
left: Option<Vec<String>>,
right: Option<Vec<String>>,
) -> Option<Vec<String>> {
match (left, right) {
(None, None) => None,
(Some(values), None) | (None, Some(values)) => Some(values),
(Some(left_values), Some(right_values)) => {
let right_values = right_values
.into_iter()
.collect::<std::collections::BTreeSet<_>>();
Some(
left_values
.into_iter()
.filter(|value| right_values.contains(value))
.collect(),
)
}
}
}
fn intersect_rate_limit_policies(
left: Option<RateLimitRestriction>,
right: Option<RateLimitRestriction>,
) -> Option<RateLimitRestriction> {
match (left, right) {
(None, None) => None,
(Some(value), None) | (None, Some(value)) => Some(value),
(Some(RateLimitRestriction::Unlimited), Some(RateLimitRestriction::Unlimited)) => {
Some(RateLimitRestriction::Unlimited)
}
(Some(RateLimitRestriction::Limited(value)), Some(RateLimitRestriction::Unlimited))
| (Some(RateLimitRestriction::Unlimited), Some(RateLimitRestriction::Limited(value))) => {
Some(RateLimitRestriction::Limited(value))
}
(Some(RateLimitRestriction::Limited(left)), Some(RateLimitRestriction::Limited(right))) => {
Some(RateLimitRestriction::Limited(left.min(right)))
}
}
}
fn rate_limit_policy_value(policy: Option<RateLimitRestriction>) -> Option<i32> {
match policy {
None => None,
Some(RateLimitRestriction::Unlimited) => Some(0),
Some(RateLimitRestriction::Limited(value)) => Some(value),
}
}
fn constrain_api_key_list_policy_to_user_policy(
user_policy: &mut Option<Vec<String>>,
api_key_policy: &mut Option<Vec<String>>,
) {
let Some(api_key_values) = api_key_policy.as_ref().filter(|values| !values.is_empty()) else {
return;
};
let Some(user_values) = user_policy.clone() else {
return;
};
let effective = intersect_list_policies(Some(api_key_values.to_vec()), Some(user_values))
.unwrap_or_default();
*user_policy = Some(effective.clone());
*api_key_policy = Some(effective);
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::sync::Arc; use std::sync::Arc;
use super::*;
use aether_data::repository::auth::{ use aether_data::repository::auth::{
InMemoryAuthApiKeySnapshotRepository, StoredAuthApiKeyExportRecord, InMemoryAuthApiKeySnapshotRepository, StoredAuthApiKeyExportRecord,
StoredAuthApiKeySnapshot, StoredAuthApiKeySnapshot,
}; };
use aether_data::repository::users::StoredUserGroup;
use crate::data::GatewayDataState; use crate::data::GatewayDataState;
@@ -1823,6 +1906,125 @@ mod tests {
.expect("snapshot should build") .expect("snapshot should build")
} }
fn sample_group(
id: &str,
priority: i32,
allowed_models: Option<Vec<&str>>,
allowed_models_mode: &str,
rate_limit: Option<i32>,
rate_limit_mode: &str,
) -> StoredUserGroup {
StoredUserGroup {
id: id.to_string(),
name: id.to_string(),
normalized_name: id.to_string(),
description: None,
priority,
allowed_providers: None,
allowed_providers_mode: "unrestricted".to_string(),
allowed_api_formats: None,
allowed_api_formats_mode: "unrestricted".to_string(),
allowed_models: allowed_models.map(|values| {
values
.into_iter()
.map(ToOwned::to_owned)
.collect::<Vec<_>>()
}),
allowed_models_mode: allowed_models_mode.to_string(),
rate_limit,
rate_limit_mode: rate_limit_mode.to_string(),
created_at: None,
updated_at: None,
}
}
#[test]
fn list_policy_intersects_group_and_user_restrictions() {
let groups = vec![
sample_group("default", 0, None, "unrestricted", None, "system"),
sample_group(
"restricted",
10,
Some(vec!["gpt-5", "gpt-4.1"]),
"specific",
None,
"system",
),
];
let policy = resolve_effective_list_policy(
Some(vec!["gpt-4.1".to_string(), "gemini-2.5-pro".to_string()]),
"specific",
&groups,
|group| (&group.allowed_models_mode, group.allowed_models.clone()),
);
assert_eq!(policy, Some(vec!["gpt-4.1".to_string()]));
}
#[test]
fn user_unrestricted_does_not_bypass_group_restrictions() {
let groups = vec![sample_group(
"restricted",
10,
Some(vec!["gpt-5"]),
"specific",
None,
"system",
)];
let policy = resolve_effective_list_policy(None, "unrestricted", &groups, |group| {
(&group.allowed_models_mode, group.allowed_models.clone())
});
assert_eq!(policy, Some(vec!["gpt-5".to_string()]));
}
#[test]
fn rate_limit_policy_uses_most_restrictive_custom_limit() {
let groups = vec![sample_group(
"restricted",
10,
None,
"unrestricted",
Some(60),
"custom",
)];
assert_eq!(
resolve_effective_rate_limit_policy(Some(120), "custom", &groups),
Some(60)
);
}
#[test]
fn rate_limit_unlimited_does_not_bypass_limited_group() {
let groups = vec![sample_group(
"restricted",
10,
None,
"unrestricted",
Some(60),
"custom",
)];
assert_eq!(
resolve_effective_rate_limit_policy(Some(0), "custom", &groups),
Some(60)
);
}
#[test]
fn api_key_specific_policy_cannot_expand_user_policy() {
let mut user_policy = Some(vec!["gpt-5".to_string()]);
let mut api_key_policy = Some(vec!["gpt-4.1".to_string()]);
constrain_api_key_list_policy_to_user_policy(&mut user_policy, &mut api_key_policy);
assert_eq!(user_policy, Some(Vec::<String>::new()));
assert_eq!(api_key_policy, Some(Vec::<String>::new()));
}
#[tokio::test] #[tokio::test]
async fn data_state_lists_auth_api_key_export_records() { async fn data_state_lists_auth_api_key_export_records() {
let repository = Arc::new( let repository = Arc::new(

View File

@@ -172,6 +172,10 @@ impl<'a> AdminAppState<'a> {
let user_api_keys = self let user_api_keys = self
.list_auth_api_key_export_records_by_user_ids(&user_ids) .list_auth_api_key_export_records_by_user_ids(&user_ids)
.await?; .await?;
let groups = self.list_user_groups().await?;
let memberships = self
.list_user_group_memberships_by_user_ids(&user_ids)
.await?;
let standalone_api_keys = self.list_auth_api_key_export_standalone_records().await?; let standalone_api_keys = self.list_auth_api_key_export_standalone_records().await?;
let standalone_api_key_ids = standalone_api_keys let standalone_api_key_ids = standalone_api_keys
.iter() .iter()
@@ -205,12 +209,49 @@ impl<'a> AdminAppState<'a> {
.or_default() .or_default()
.push(key); .push(key);
} }
let mut memberships_by_user_id = BTreeMap::<
String,
Vec<aether_data::repository::users::StoredUserGroupMembership>,
>::new();
for membership in memberships {
memberships_by_user_id
.entry(membership.user_id.clone())
.or_default()
.push(membership);
}
let user_groups_data = groups
.iter()
.map(|group| {
json!({
"id": group.id.clone(),
"name": group.name.clone(),
"description": group.description.clone(),
"allowed_providers": group.allowed_providers.clone(),
"allowed_providers_mode": group.allowed_providers_mode.clone(),
"allowed_api_formats": group.allowed_api_formats.clone(),
"allowed_api_formats_mode": group.allowed_api_formats_mode.clone(),
"allowed_models": group.allowed_models.clone(),
"allowed_models_mode": group.allowed_models_mode.clone(),
"rate_limit": group.rate_limit,
"rate_limit_mode": group.rate_limit_mode.clone(),
})
})
.collect::<Vec<_>>();
let users_data = users let users_data = users
.iter() .iter()
.map(|user| { .map(|user| {
let wallet = wallets_by_user_id.get(&user.id); let wallet = wallets_by_user_id.get(&user.id);
let wallet_payload = serialize_admin_system_users_export_wallet(wallet); let wallet_payload = serialize_admin_system_users_export_wallet(wallet);
let memberships = memberships_by_user_id.remove(&user.id).unwrap_or_default();
let group_ids = memberships
.iter()
.map(|membership| membership.group_id.clone())
.collect::<Vec<_>>();
let group_names = memberships
.iter()
.map(|membership| membership.group_name.clone())
.collect::<Vec<_>>();
let api_keys = api_keys_by_user_id.remove(&user.id).unwrap_or_default(); let api_keys = api_keys_by_user_id.remove(&user.id).unwrap_or_default();
let api_keys_payload = api_keys let api_keys_payload = api_keys
.iter() .iter()
@@ -226,10 +267,16 @@ impl<'a> AdminAppState<'a> {
"password_hash": user.password_hash.clone(), "password_hash": user.password_hash.clone(),
"role": user.role.clone(), "role": user.role.clone(),
"allowed_providers": user.allowed_providers.clone(), "allowed_providers": user.allowed_providers.clone(),
"allowed_providers_mode": user.allowed_providers_mode.clone(),
"allowed_api_formats": user.allowed_api_formats.clone(), "allowed_api_formats": user.allowed_api_formats.clone(),
"allowed_api_formats_mode": user.allowed_api_formats_mode.clone(),
"allowed_models": user.allowed_models.clone(), "allowed_models": user.allowed_models.clone(),
"allowed_models_mode": user.allowed_models_mode.clone(),
"rate_limit": user.rate_limit, "rate_limit": user.rate_limit,
"rate_limit_mode": user.rate_limit_mode.clone(),
"model_capability_settings": user.model_capability_settings.clone(), "model_capability_settings": user.model_capability_settings.clone(),
"group_ids": group_ids,
"group_names": group_names,
"unlimited": wallet "unlimited": wallet
.map(|entry| entry.limit_mode.eq_ignore_ascii_case("unlimited")) .map(|entry| entry.limit_mode.eq_ignore_ascii_case("unlimited"))
.unwrap_or(false), .unwrap_or(false),
@@ -254,6 +301,7 @@ impl<'a> AdminAppState<'a> {
Ok(json!({ Ok(json!({
"version": ADMIN_SYSTEM_USERS_EXPORT_VERSION, "version": ADMIN_SYSTEM_USERS_EXPORT_VERSION,
"exported_at": Utc::now().to_rfc3339(), "exported_at": Utc::now().to_rfc3339(),
"user_groups": user_groups_data,
"users": users_data, "users": users_data,
"standalone_keys": standalone_keys_data, "standalone_keys": standalone_keys_data,
})) }))

View File

@@ -10,7 +10,9 @@ use crate::handlers::admin::shared::{
}; };
use crate::handlers::admin::system::shared::configs::apply_admin_system_config_update; use crate::handlers::admin::system::shared::configs::apply_admin_system_config_update;
use crate::handlers::admin::users::{ use crate::handlers::admin::users::{
hash_admin_user_api_key, normalize_admin_user_api_formats, normalize_admin_user_string_list, hash_admin_user_api_key, normalize_admin_list_policy_mode,
normalize_admin_rate_limit_policy_mode, normalize_admin_user_api_formats,
normalize_admin_user_string_list,
}; };
use crate::handlers::public::normalize_admin_base_url; use crate::handlers::public::normalize_admin_base_url;
use crate::GatewayError; use crate::GatewayError;
@@ -397,6 +399,7 @@ fn build_import_provider_model_record(
#[derive(Debug, Clone, Default, serde::Serialize)] #[derive(Debug, Clone, Default, serde::Serialize)]
struct AdminSystemUsersImportStats { struct AdminSystemUsersImportStats {
user_groups: AdminSystemConfigImportCounter,
users: AdminSystemConfigImportCounter, users: AdminSystemConfigImportCounter,
api_keys: AdminSystemConfigImportCounter, api_keys: AdminSystemConfigImportCounter,
standalone_keys: AdminSystemConfigImportCounter, standalone_keys: AdminSystemConfigImportCounter,
@@ -542,6 +545,78 @@ fn imported_optional_value(value: Option<&Value>) -> Option<Value> {
value.cloned().filter(|value| !value.is_null()) value.cloned().filter(|value| !value.is_null())
} }
fn imported_optional_list_policy_mode(
value: Option<&Value>,
field_name: &str,
) -> Result<Option<String>, String> {
let Some(value) = imported_optional_string(value)? else {
return Ok(None);
};
let value = value.to_ascii_lowercase();
normalize_admin_list_policy_mode(&value)
.map(Some)
.map_err(|_| format!("{field_name} 不合法"))
}
fn imported_optional_rate_limit_policy_mode(
value: Option<&Value>,
field_name: &str,
) -> Result<Option<String>, String> {
let Some(value) = imported_optional_string(value)? else {
return Ok(None);
};
let value = value.to_ascii_lowercase();
normalize_admin_rate_limit_policy_mode(&value)
.map(Some)
.map_err(|_| format!("{field_name} 不合法"))
}
fn legacy_imported_list_policy_mode(values: &Option<Vec<String>>) -> String {
if values.is_some() {
"specific".to_string()
} else {
"unrestricted".to_string()
}
}
fn legacy_imported_rate_limit_policy_mode(value: Option<i32>) -> String {
if value.is_some() {
"custom".to_string()
} else {
"system".to_string()
}
}
fn imported_user_list_policy_mode(
object: &Map<String, Value>,
mode_field: &str,
value_field: &str,
values: &Option<Vec<String>>,
) -> Result<Option<String>, String> {
imported_optional_list_policy_mode(object.get(mode_field), mode_field).map(|mode| {
mode.or_else(|| {
object
.contains_key(value_field)
.then(|| legacy_imported_list_policy_mode(values))
})
})
}
fn imported_user_rate_limit_policy_mode(
object: &Map<String, Value>,
mode_field: &str,
value_field: &str,
value: Option<i32>,
) -> Result<Option<String>, String> {
imported_optional_rate_limit_policy_mode(object.get(mode_field), mode_field).map(|mode| {
mode.or_else(|| {
object
.contains_key(value_field)
.then(|| legacy_imported_rate_limit_policy_mode(value))
})
})
}
fn imported_rfc3339_to_unix_secs( fn imported_rfc3339_to_unix_secs(
value: Option<&Value>, value: Option<&Value>,
field_name: &str, field_name: &str,
@@ -601,6 +676,130 @@ fn normalize_imported_user_api_formats(
)?) )?)
} }
fn build_imported_user_group_record(
group: &Map<String, Value>,
field_name: &str,
) -> Result<
(
Option<String>,
String,
aether_data::repository::users::UpsertUserGroupRecord,
),
String,
> {
let export_id = imported_optional_string(group.get("id"))?;
let name = imported_optional_string(group.get("name"))?
.ok_or_else(|| format!("{field_name}.name 不能为空"))?;
let name = aether_data::repository::users::normalize_user_group_name(&name);
if name.is_empty() {
return Err(format!("{field_name}.name 不能为空"));
}
let description = imported_optional_string(group.get("description"))?;
let allowed_providers = normalize_imported_user_string_list(group, "allowed_providers")?;
let allowed_api_formats = normalize_imported_user_api_formats(group, "allowed_api_formats")?;
let allowed_models = normalize_imported_user_string_list(group, "allowed_models")?;
let rate_limit = imported_optional_i32(group.get("rate_limit"), "rate_limit")?;
let allowed_providers_mode = imported_optional_list_policy_mode(
group.get("allowed_providers_mode"),
"allowed_providers_mode",
)?
.unwrap_or_else(|| {
if group.contains_key("allowed_providers") {
legacy_imported_list_policy_mode(&allowed_providers)
} else {
"inherit".to_string()
}
});
let allowed_api_formats_mode = imported_optional_list_policy_mode(
group.get("allowed_api_formats_mode"),
"allowed_api_formats_mode",
)?
.unwrap_or_else(|| {
if group.contains_key("allowed_api_formats") {
legacy_imported_list_policy_mode(&allowed_api_formats)
} else {
"inherit".to_string()
}
});
let allowed_models_mode = imported_optional_list_policy_mode(
group.get("allowed_models_mode"),
"allowed_models_mode",
)?
.unwrap_or_else(|| {
if group.contains_key("allowed_models") {
legacy_imported_list_policy_mode(&allowed_models)
} else {
"inherit".to_string()
}
});
let rate_limit_mode =
imported_optional_rate_limit_policy_mode(group.get("rate_limit_mode"), "rate_limit_mode")?
.unwrap_or_else(|| {
if group.contains_key("rate_limit") {
legacy_imported_rate_limit_policy_mode(rate_limit)
} else {
"inherit".to_string()
}
});
let normalized_name = name.to_ascii_lowercase();
Ok((
export_id,
normalized_name,
aether_data::repository::users::UpsertUserGroupRecord {
name,
description,
priority: 0,
allowed_providers,
allowed_providers_mode,
allowed_api_formats,
allowed_api_formats_mode,
allowed_models,
allowed_models_mode,
rate_limit,
rate_limit_mode,
},
))
}
fn resolve_imported_user_group_ids(
user: &Map<String, Value>,
imported_group_id_map: &BTreeMap<String, String>,
imported_group_name_map: &BTreeMap<String, String>,
groups_by_name: &BTreeMap<String, aether_data::repository::users::StoredUserGroup>,
) -> Result<Vec<String>, String> {
let raw_group_ids =
imported_string_list_from_value(user.get("group_ids"), "group_ids")?.unwrap_or_default();
let raw_group_names = imported_string_list_from_value(user.get("group_names"), "group_names")?
.unwrap_or_default();
let mut group_ids = BTreeSet::new();
for raw_group_id in raw_group_ids {
if let Some(group_id) = imported_group_id_map.get(&raw_group_id) {
group_ids.insert(group_id.clone());
continue;
}
group_ids.insert(raw_group_id);
}
for raw_group_name in raw_group_names {
let normalized_name =
aether_data::repository::users::normalize_user_group_name(&raw_group_name)
.to_ascii_lowercase();
if normalized_name.is_empty() {
continue;
}
if let Some(group_id) = imported_group_name_map.get(&normalized_name) {
group_ids.insert(group_id.clone());
continue;
}
if let Some(group) = groups_by_name.get(&normalized_name) {
group_ids.insert(group.id.clone());
}
}
Ok(group_ids.into_iter().collect())
}
fn normalize_imported_wallet_target( fn normalize_imported_wallet_target(
wallet: Option<&Map<String, Value>>, wallet: Option<&Map<String, Value>>,
unlimited: bool, unlimited: bool,
@@ -1686,6 +1885,11 @@ impl<'a> AdminAppState<'a> {
Some(_) => return Ok(Err(invalid_request("standalone_keys 必须是数组"))), Some(_) => return Ok(Err(invalid_request("standalone_keys 必须是数组"))),
None => &empty, None => &empty,
}; };
let imported_user_groups = match root.get("user_groups") {
Some(Value::Array(items)) => items,
Some(_) => return Ok(Err(invalid_request("user_groups 必须是数组"))),
None => &empty,
};
let standalone_owner_id = match operator_id { let standalone_owner_id = match operator_id {
Some(candidate) => match self.find_user_auth_by_id(candidate).await? { Some(candidate) => match self.find_user_auth_by_id(candidate).await? {
@@ -1709,6 +1913,86 @@ impl<'a> AdminAppState<'a> {
)); ));
let mut stats = AdminSystemUsersImportStats::default(); let mut stats = AdminSystemUsersImportStats::default();
let default_group_id = self.effective_default_user_group_id().await?;
let existing_groups = self.list_user_groups().await?;
let mut groups_by_name = existing_groups
.into_iter()
.map(|group| {
(
aether_data::repository::users::normalize_user_group_name(&group.name)
.to_ascii_lowercase(),
group,
)
})
.collect::<BTreeMap<_, _>>();
let mut imported_group_id_map = BTreeMap::<String, String>::new();
let mut imported_group_name_map = BTreeMap::<String, String>::new();
for (index, raw_group) in imported_user_groups.iter().enumerate() {
let group = match imported_object_field(raw_group, &format!("user_groups[{index}]")) {
Ok(value) => value,
Err(detail) => return Ok(Err(invalid_request(detail))),
};
let (export_id, normalized_name, record) = invalid_value!(
build_imported_user_group_record(group, &format!("user_groups[{index}]"))
);
if default_group_id
.as_deref()
.is_some_and(|group_id| export_id.as_deref() == Some(group_id))
|| normalized_name == "default"
{
if let Some(default_group_id) = default_group_id.as_ref() {
if let Some(export_id) = export_id {
imported_group_id_map.insert(export_id, default_group_id.clone());
}
imported_group_name_map.insert(normalized_name, default_group_id.clone());
}
stats.user_groups.skipped += 1;
continue;
}
if let Some(existing) = groups_by_name.get(&normalized_name).cloned() {
if let Some(export_id) = export_id {
imported_group_id_map.insert(export_id, existing.id.clone());
}
imported_group_name_map.insert(normalized_name.clone(), existing.id.clone());
match merge_mode {
AdminImportMergeMode::Skip => {
stats.user_groups.skipped += 1;
}
AdminImportMergeMode::Error => {
return Ok(Err(invalid_request(format!(
"用户组 '{}' 已存在",
existing.name
))));
}
AdminImportMergeMode::Overwrite => {
let Some(updated) = self.update_user_group(&existing.id, record).await?
else {
return Ok(Err((
http::StatusCode::SERVICE_UNAVAILABLE,
json!({ "detail": "Admin system data unavailable" }),
)));
};
groups_by_name.insert(normalized_name, updated);
stats.user_groups.updated += 1;
}
}
continue;
}
let Some(created) = self.create_user_group(record).await? else {
return Ok(Err((
http::StatusCode::SERVICE_UNAVAILABLE,
json!({ "detail": "Admin system data unavailable" }),
)));
};
if let Some(export_id) = export_id {
imported_group_id_map.insert(export_id, created.id.clone());
}
imported_group_name_map.insert(normalized_name.clone(), created.id.clone());
groups_by_name.insert(normalized_name, created);
stats.user_groups.created += 1;
}
for (index, raw_user) in users.iter().enumerate() { for (index, raw_user) in users.iter().enumerate() {
let user = match imported_object_field(raw_user, &format!("users[{index}]")) { let user = match imported_object_field(raw_user, &format!("users[{index}]")) {
@@ -1760,6 +2044,53 @@ impl<'a> AdminAppState<'a> {
invalid_value!(normalize_imported_user_string_list(user, "allowed_models")); invalid_value!(normalize_imported_user_string_list(user, "allowed_models"));
let rate_limit = let rate_limit =
invalid_value!(imported_optional_i32(user.get("rate_limit"), "rate_limit")); invalid_value!(imported_optional_i32(user.get("rate_limit"), "rate_limit"));
let allowed_providers_mode = invalid_value!(imported_user_list_policy_mode(
user,
"allowed_providers_mode",
"allowed_providers",
&allowed_providers,
));
let allowed_api_formats_mode = invalid_value!(imported_user_list_policy_mode(
user,
"allowed_api_formats_mode",
"allowed_api_formats",
&allowed_api_formats,
));
let allowed_models_mode = invalid_value!(imported_user_list_policy_mode(
user,
"allowed_models_mode",
"allowed_models",
&allowed_models,
));
let rate_limit_mode = invalid_value!(imported_user_rate_limit_policy_mode(
user,
"rate_limit_mode",
"rate_limit",
rate_limit,
));
let imported_user_group_ids = invalid_value!(resolve_imported_user_group_ids(
user,
&imported_group_id_map,
&imported_group_name_map,
&groups_by_name,
));
let group_ids = if user.contains_key("group_ids") || user.contains_key("group_names") {
let group_ids = self
.include_default_user_group_ids(&imported_user_group_ids)
.await?;
if !group_ids.is_empty() {
let existing_groups = self.list_user_groups_by_ids(&group_ids).await?;
if existing_groups.len() != group_ids.len() {
return Ok(Err(invalid_request(format!(
"用户 '{}' 的用户组不存在",
email.clone().unwrap_or(username.clone())
))));
}
}
Some(group_ids)
} else {
None
};
let is_active = let is_active =
invalid_value!(imported_optional_bool(user.get("is_active"))).unwrap_or(true); invalid_value!(imported_optional_bool(user.get("is_active"))).unwrap_or(true);
let model_capability_settings = invalid_value!(imported_optional_json_object( let model_capability_settings = invalid_value!(imported_optional_json_object(
@@ -1883,6 +2214,31 @@ impl<'a> AdminAppState<'a> {
) )
.await?; .await?;
} }
if allowed_providers_mode.is_some()
|| allowed_api_formats_mode.is_some()
|| allowed_models_mode.is_some()
|| rate_limit_mode.is_some()
{
let updated_policy_modes = self
.update_local_auth_user_policy_modes(
&existing.id,
allowed_providers_mode.clone(),
allowed_api_formats_mode.clone(),
allowed_models_mode.clone(),
rate_limit_mode.clone(),
)
.await?;
if updated_policy_modes.is_none() {
return Ok(Err((
http::StatusCode::SERVICE_UNAVAILABLE,
json!({ "detail": "Admin system data unavailable" }),
)));
}
}
if let Some(group_ids) = group_ids.as_ref() {
self.replace_user_groups_for_user(&existing.id, group_ids)
.await?;
}
self.sync_imported_user_wallet( self.sync_imported_user_wallet(
&existing.id, &existing.id,
&wallet_target, &wallet_target,
@@ -1921,6 +2277,34 @@ impl<'a> AdminAppState<'a> {
) )
.await?; .await?;
} }
let created = if allowed_providers_mode.is_some()
|| allowed_api_formats_mode.is_some()
|| allowed_models_mode.is_some()
|| rate_limit_mode.is_some()
{
let Some(updated_policy_modes) = self
.update_local_auth_user_policy_modes(
&created.id,
allowed_providers_mode.clone(),
allowed_api_formats_mode.clone(),
allowed_models_mode.clone(),
rate_limit_mode.clone(),
)
.await?
else {
return Ok(Err((
http::StatusCode::SERVICE_UNAVAILABLE,
json!({ "detail": "Admin system data unavailable" }),
)));
};
updated_policy_modes
} else {
created
};
if let Some(group_ids) = group_ids.as_ref() {
self.replace_user_groups_for_user(&created.id, group_ids)
.await?;
}
self.sync_imported_user_wallet( self.sync_imported_user_wallet(
&created.id, &created.id,
&wallet_target, &wallet_target,
@@ -2454,9 +2838,10 @@ mod tests {
#[test] #[test]
fn users_import_requires_supported_export_version() { fn users_import_requires_supported_export_version() {
assert!(validate_imported_system_users_export_version(Some(&json!("1.3"))).is_ok()); assert!(validate_imported_system_users_export_version(Some(&json!("1.3"))).is_ok());
assert!(validate_imported_system_users_export_version(Some(&json!("1.4"))).is_ok());
assert_eq!( assert_eq!(
validate_imported_system_users_export_version(Some(&json!("2.2"))).unwrap_err(), validate_imported_system_users_export_version(Some(&json!("2.2"))).unwrap_err(),
"不支持的用户数据版本: 2.2,支持的版本: 1.3" "不支持的用户数据版本: 2.2,支持的版本: 1.3, 1.4"
); );
assert_eq!( assert_eq!(
validate_imported_system_users_export_version(Some(&json!(null))).unwrap_err(), validate_imported_system_users_export_version(Some(&json!(null))).unwrap_err(),

View File

@@ -145,6 +145,31 @@ impl<'a> AdminAppState<'a> {
.await .await
} }
pub(crate) async fn include_default_user_group_ids(
&self,
group_ids: &[String],
) -> Result<Vec<String>, GatewayError> {
self.app.include_default_user_group_ids(group_ids).await
}
pub(crate) async fn effective_default_user_group_id(
&self,
) -> Result<Option<String>, GatewayError> {
self.app.effective_default_user_group_id().await
}
pub(crate) async fn add_user_to_group(
&self,
group_id: &str,
user_id: &str,
) -> Result<bool, GatewayError> {
self.app.add_user_to_group(group_id, user_id).await
}
pub(crate) async fn add_all_users_to_group(&self, group_id: &str) -> Result<(), GatewayError> {
self.app.add_all_users_to_group(group_id).await
}
pub(crate) async fn is_other_user_auth_email_taken( pub(crate) async fn is_other_user_auth_email_taken(
&self, &self,
email: &str, email: &str,

View File

@@ -3,6 +3,7 @@ use super::{
format_optional_datetime_iso8601, normalize_admin_user_api_formats, format_optional_datetime_iso8601, normalize_admin_user_api_formats,
normalize_admin_user_string_list, normalize_admin_user_string_list,
}; };
use crate::constants::DEFAULT_USER_GROUP_CONFIG_KEY;
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext}; use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
use crate::handlers::admin::shared::attach_admin_audit_response; use crate::handlers::admin::shared::attach_admin_audit_response;
use crate::GatewayError; use crate::GatewayError;
@@ -14,16 +15,12 @@ use axum::{
}; };
use serde_json::json; use serde_json::json;
const DEFAULT_USER_GROUP_CONFIG_KEY: &str = "default_user_group_id";
#[derive(Debug, serde::Deserialize)] #[derive(Debug, serde::Deserialize)]
struct AdminUserGroupPayload { struct AdminUserGroupPayload {
name: String, name: String,
#[serde(default)] #[serde(default)]
description: Option<String>, description: Option<String>,
#[serde(default)] #[serde(default)]
priority: Option<i32>,
#[serde(default)]
allowed_providers: Option<Vec<String>>, allowed_providers: Option<Vec<String>>,
#[serde(default = "default_list_mode")] #[serde(default = "default_list_mode")]
allowed_providers_mode: String, allowed_providers_mode: String,
@@ -121,6 +118,11 @@ pub(in super::super) async fn build_admin_update_user_group_response(
Ok(value) => value, Ok(value) => value,
Err(detail) => return Ok(bad_request_owned(detail)), Err(detail) => return Ok(bad_request_owned(detail)),
}; };
if read_default_user_group_id(state).await?.as_deref() == Some(group_id.as_str())
&& !is_unrestricted_default_group_record(&record)
{
return Ok(bad_request_owned("默认用户组不能配置访问限制".to_string()));
}
let group = match state.update_user_group(&group_id, record).await { let group = match state.update_user_group(&group_id, record).await {
Ok(Some(group)) => group, Ok(Some(group)) => group,
Ok(None) => return Ok(not_found("用户分组不存在")), Ok(None) => return Ok(not_found("用户分组不存在")),
@@ -152,9 +154,7 @@ pub(in super::super) async fn build_admin_delete_user_group_response(
return Ok(build_admin_users_bad_request_response("缺少 group_id")); return Ok(build_admin_users_bad_request_response("缺少 group_id"));
}; };
if read_default_user_group_id(state).await?.as_deref() == Some(group_id.as_str()) { if read_default_user_group_id(state).await?.as_deref() == Some(group_id.as_str()) {
state return Ok(bad_request_owned("默认用户组不能删除".to_string()));
.delete_system_config_value(DEFAULT_USER_GROUP_CONFIG_KEY)
.await?;
} }
if !state.delete_user_group(&group_id).await? { if !state.delete_user_group(&group_id).await? {
return Ok(not_found("用户分组不存在")); return Ok(not_found("用户分组不存在"));
@@ -214,6 +214,9 @@ pub(in super::super) async fn build_admin_replace_user_group_members_response(
if state.find_user_group_by_id(&group_id).await?.is_none() { if state.find_user_group_by_id(&group_id).await?.is_none() {
return Ok(not_found("用户分组不存在")); return Ok(not_found("用户分组不存在"));
} }
if read_default_user_group_id(state).await?.as_deref() == Some(group_id.as_str()) {
return Ok(bad_request_owned("默认用户组成员由系统维护".to_string()));
}
let payload = match parse_members_payload(request_body) { let payload = match parse_members_payload(request_body) {
Ok(value) => value, Ok(value) => value,
Err(detail) => return Ok(bad_request_owned(detail)), Err(detail) => return Ok(bad_request_owned(detail)),
@@ -272,8 +275,11 @@ pub(in super::super) async fn build_admin_set_default_user_group_response(
.map(|value| value.trim().to_string()) .map(|value| value.trim().to_string())
.filter(|value| !value.is_empty()); .filter(|value| !value.is_empty());
if let Some(group_id) = group_id.as_deref() { if let Some(group_id) = group_id.as_deref() {
if state.find_user_group_by_id(group_id).await?.is_none() { let Some(group) = state.find_user_group_by_id(group_id).await? else {
return Ok(bad_request_owned("默认用户组不存在".to_string())); return Ok(bad_request_owned("默认用户组不存在".to_string()));
};
if !is_unrestricted_default_group(&group) {
return Ok(bad_request_owned("默认用户组不能配置访问限制".to_string()));
} }
state state
.upsert_system_config_json_value( .upsert_system_config_json_value(
@@ -287,18 +293,23 @@ pub(in super::super) async fn build_admin_set_default_user_group_response(
.delete_system_config_value(DEFAULT_USER_GROUP_CONFIG_KEY) .delete_system_config_value(DEFAULT_USER_GROUP_CONFIG_KEY)
.await?; .await?;
} }
Ok(Json(json!({ "default_group_id": group_id })).into_response()) let effective_group_id = read_default_user_group_id(state).await?;
if let Some(group_id) = effective_group_id.as_deref() {
state.add_all_users_to_group(group_id).await?;
}
Ok(attach_admin_audit_response(
Json(json!({ "default_group_id": effective_group_id })).into_response(),
"admin_default_user_group_set",
"set_default_user_group",
"user_group",
group_id.as_deref().unwrap_or("default_user_group"),
))
} }
pub(crate) async fn read_default_user_group_id( pub(crate) async fn read_default_user_group_id(
state: &AdminAppState<'_>, state: &AdminAppState<'_>,
) -> Result<Option<String>, GatewayError> { ) -> Result<Option<String>, GatewayError> {
Ok(state state.effective_default_user_group_id().await
.read_system_config_json_value(DEFAULT_USER_GROUP_CONFIG_KEY)
.await?
.and_then(|value| value.as_str().map(str::to_string))
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty()))
} }
fn parse_group_record( fn parse_group_record(
@@ -327,7 +338,7 @@ fn parse_group_record(
.description .description
.map(|value| value.trim().to_string()) .map(|value| value.trim().to_string())
.filter(|value| !value.is_empty()), .filter(|value| !value.is_empty()),
priority: payload.priority.unwrap_or_default(), priority: 0,
allowed_providers, allowed_providers,
allowed_providers_mode: normalize_list_mode(&payload.allowed_providers_mode)?, allowed_providers_mode: normalize_list_mode(&payload.allowed_providers_mode)?,
allowed_api_formats, allowed_api_formats,
@@ -358,7 +369,6 @@ fn user_group_payload(
"name": group.name, "name": group.name,
"normalized_name": group.normalized_name, "normalized_name": group.normalized_name,
"description": group.description, "description": group.description,
"priority": group.priority,
"allowed_providers": group.allowed_providers, "allowed_providers": group.allowed_providers,
"allowed_providers_mode": group.allowed_providers_mode, "allowed_providers_mode": group.allowed_providers_mode,
"allowed_api_formats": group.allowed_api_formats, "allowed_api_formats": group.allowed_api_formats,
@@ -407,6 +417,30 @@ fn normalize_ids(values: Vec<String>) -> Vec<String> {
.collect() .collect()
} }
fn is_unrestricted_default_group(group: &aether_data::repository::users::StoredUserGroup) -> bool {
list_mode_has_no_restriction(&group.allowed_providers_mode)
&& list_mode_has_no_restriction(&group.allowed_api_formats_mode)
&& list_mode_has_no_restriction(&group.allowed_models_mode)
&& rate_mode_has_no_restriction(&group.rate_limit_mode)
}
fn is_unrestricted_default_group_record(
record: &aether_data::repository::users::UpsertUserGroupRecord,
) -> bool {
list_mode_has_no_restriction(&record.allowed_providers_mode)
&& list_mode_has_no_restriction(&record.allowed_api_formats_mode)
&& list_mode_has_no_restriction(&record.allowed_models_mode)
&& rate_mode_has_no_restriction(&record.rate_limit_mode)
}
fn list_mode_has_no_restriction(mode: &str) -> bool {
matches!(mode, "inherit" | "unrestricted")
}
fn rate_mode_has_no_restriction(mode: &str) -> bool {
matches!(mode, "inherit" | "system")
}
fn user_group_id_from_path(request_path: &str) -> Option<String> { fn user_group_id_from_path(request_path: &str) -> Option<String> {
let value = request_path let value = request_path
.strip_prefix("/api/admin/user-groups/")? .strip_prefix("/api/admin/user-groups/")?

View File

@@ -190,7 +190,10 @@ pub(in super::super) async fn build_admin_create_user_response(
}, },
None => legacy_admin_rate_limit_policy_mode(payload.rate_limit), None => legacy_admin_rate_limit_policy_mode(payload.rate_limit),
}; };
let group_ids = normalize_admin_user_group_ids(payload.group_ids); let requested_group_ids = normalize_admin_user_group_ids(payload.group_ids);
let group_ids = state
.include_default_user_group_ids(&requested_group_ids)
.await?;
let groups = if group_ids.is_empty() { let groups = if group_ids.is_empty() {
Vec::new() Vec::new()
} else { } else {

View File

@@ -131,7 +131,6 @@ pub(super) fn user_group_badge_payload(
json!({ json!({
"id": group.id, "id": group.id,
"name": group.name, "name": group.name,
"priority": group.priority,
}) })
} }
@@ -149,10 +148,8 @@ fn effective_policy_payload(
) -> serde_json::Value { ) -> serde_json::Value {
let mut sorted_groups = groups.to_vec(); let mut sorted_groups = groups.to_vec();
sorted_groups.sort_by(|left, right| { sorted_groups.sort_by(|left, right| {
right left.name
.priority .cmp(&right.name)
.cmp(&left.priority)
.then_with(|| left.name.cmp(&right.name))
.then_with(|| left.id.cmp(&right.id)) .then_with(|| left.id.cmp(&right.id))
}); });
json!({ json!({
@@ -186,50 +183,28 @@ fn effective_list_policy_payload(
&aether_data::repository::users::StoredUserGroup, &aether_data::repository::users::StoredUserGroup,
) -> (&String, Option<&Vec<String>>), ) -> (&String, Option<&Vec<String>>),
) -> serde_json::Value { ) -> serde_json::Value {
match user_mode { let mut effective = None;
"unrestricted" => policy_payload("unrestricted", serde_json::Value::Null, "user", None), let mut group_sources = Vec::new();
"specific" => policy_payload( for group in groups {
"specific", let (mode, values) = group_field(group);
json!(user_values.cloned().unwrap_or_default()), if let Some(restriction) = list_restriction_from_mode(mode, values.cloned()) {
"user", effective = intersect_list_policies(effective, Some(restriction));
None, group_sources.push(group);
),
"deny_all" => policy_payload("deny_all", json!(Vec::<String>::new()), "user", None),
"inherit" => {
for group in groups {
let (mode, values) = group_field(group);
match mode.as_str() {
"unrestricted" => {
return policy_payload(
"unrestricted",
serde_json::Value::Null,
"group",
Some(group),
)
}
"specific" => {
return policy_payload(
"specific",
json!(values.cloned().unwrap_or_default()),
"group",
Some(group),
)
}
"deny_all" => {
return policy_payload(
"deny_all",
json!(Vec::<String>::new()),
"group",
Some(group),
)
}
_ => {}
}
}
policy_payload("unrestricted", serde_json::Value::Null, "fallback", None)
} }
_ => policy_payload("unrestricted", serde_json::Value::Null, "fallback", None),
} }
let mut has_user_source = false;
if let Some(restriction) = list_restriction_from_mode(user_mode, user_values.cloned()) {
effective = intersect_list_policies(effective, Some(restriction));
has_user_source = true;
}
let (mode, value) = match effective {
Some(values) if values.is_empty() => ("deny_all", json!(Vec::<String>::new())),
Some(values) => ("specific", json!(values)),
None => ("unrestricted", serde_json::Value::Null),
};
let source = combined_policy_source(has_user_source, group_sources.len(), "fallback");
policy_payload(mode, value, source, group_sources.as_slice())
} }
fn effective_rate_limit_policy_payload( fn effective_rate_limit_policy_payload(
@@ -237,34 +212,26 @@ fn effective_rate_limit_policy_payload(
user_mode: &str, user_mode: &str,
groups: &[aether_data::repository::users::StoredUserGroup], groups: &[aether_data::repository::users::StoredUserGroup],
) -> serde_json::Value { ) -> serde_json::Value {
match user_mode { let mut effective = None;
"custom" => policy_payload("custom", json!(user_rate_limit.unwrap_or(0)), "user", None), let mut group_sources = Vec::new();
"system" => policy_payload("system", serde_json::Value::Null, "user", None), for group in groups {
"inherit" => { if let Some(restriction) =
for group in groups { rate_limit_restriction_from_mode(&group.rate_limit_mode, group.rate_limit)
match group.rate_limit_mode.as_str() { {
"custom" => { effective = intersect_rate_limit_policies(effective, Some(restriction));
return policy_payload( group_sources.push(group);
"custom",
json!(group.rate_limit.unwrap_or(0)),
"group",
Some(group),
)
}
"system" => {
return policy_payload(
"system",
serde_json::Value::Null,
"group",
Some(group),
)
}
_ => {}
}
}
policy_payload("system", serde_json::Value::Null, "fallback", None)
} }
_ => policy_payload("system", serde_json::Value::Null, "fallback", None), }
let mut has_user_source = false;
if let Some(restriction) = rate_limit_restriction_from_mode(user_mode, user_rate_limit) {
effective = intersect_rate_limit_policies(effective, Some(restriction));
has_user_source = true;
}
let source = combined_policy_source(has_user_source, group_sources.len(), "fallback");
match rate_limit_policy_value(effective) {
Some(rate_limit) => policy_payload("custom", json!(rate_limit), source, &group_sources),
None => policy_payload("system", serde_json::Value::Null, source, &group_sources),
} }
} }
@@ -272,17 +239,113 @@ fn policy_payload(
mode: &str, mode: &str,
value: serde_json::Value, value: serde_json::Value,
source: &str, source: &str,
group: Option<&aether_data::repository::users::StoredUserGroup>, groups: &[&aether_data::repository::users::StoredUserGroup],
) -> serde_json::Value { ) -> serde_json::Value {
let single_group = groups.first().copied().filter(|_| groups.len() == 1);
json!({ json!({
"mode": mode, "mode": mode,
"value": value, "value": value,
"source": source, "source": source,
"group_id": group.map(|group| group.id.as_str()), "group_id": single_group.map(|group| group.id.as_str()),
"group_name": group.map(|group| group.name.as_str()), "group_name": single_group.map(|group| group.name.as_str()),
"group_ids": groups.iter().map(|group| group.id.as_str()).collect::<Vec<_>>(),
"group_names": groups.iter().map(|group| group.name.as_str()).collect::<Vec<_>>(),
}) })
} }
fn list_restriction_from_mode(mode: &str, values: Option<Vec<String>>) -> Option<Vec<String>> {
match mode {
"specific" => Some(values.unwrap_or_default()),
"deny_all" => Some(Vec::new()),
_ => None,
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum RateLimitRestriction {
Unlimited,
Limited(i32),
}
fn rate_limit_restriction_from_mode(
mode: &str,
rate_limit: Option<i32>,
) -> Option<RateLimitRestriction> {
match mode {
"custom" => {
let rate_limit = rate_limit.unwrap_or(0).max(0);
if rate_limit == 0 {
Some(RateLimitRestriction::Unlimited)
} else {
Some(RateLimitRestriction::Limited(rate_limit))
}
}
_ => None,
}
}
fn intersect_list_policies(
left: Option<Vec<String>>,
right: Option<Vec<String>>,
) -> Option<Vec<String>> {
match (left, right) {
(None, None) => None,
(Some(values), None) | (None, Some(values)) => Some(values),
(Some(left_values), Some(right_values)) => {
let right_values = right_values
.into_iter()
.collect::<std::collections::BTreeSet<_>>();
Some(
left_values
.into_iter()
.filter(|value| right_values.contains(value))
.collect(),
)
}
}
}
fn intersect_rate_limit_policies(
left: Option<RateLimitRestriction>,
right: Option<RateLimitRestriction>,
) -> Option<RateLimitRestriction> {
match (left, right) {
(None, None) => None,
(Some(value), None) | (None, Some(value)) => Some(value),
(Some(RateLimitRestriction::Unlimited), Some(RateLimitRestriction::Unlimited)) => {
Some(RateLimitRestriction::Unlimited)
}
(Some(RateLimitRestriction::Limited(value)), Some(RateLimitRestriction::Unlimited))
| (Some(RateLimitRestriction::Unlimited), Some(RateLimitRestriction::Limited(value))) => {
Some(RateLimitRestriction::Limited(value))
}
(Some(RateLimitRestriction::Limited(left)), Some(RateLimitRestriction::Limited(right))) => {
Some(RateLimitRestriction::Limited(left.min(right)))
}
}
}
fn rate_limit_policy_value(policy: Option<RateLimitRestriction>) -> Option<i32> {
match policy {
None => None,
Some(RateLimitRestriction::Unlimited) => Some(0),
Some(RateLimitRestriction::Limited(value)) => Some(value),
}
}
fn combined_policy_source(
has_user_source: bool,
group_source_count: usize,
fallback_source: &'static str,
) -> &'static str {
match (has_user_source, group_source_count) {
(true, 0) => "user",
(false, 1) => "group",
(false, 0) => fallback_source,
_ => "combined",
}
}
pub(super) fn admin_user_id_from_detail_path(request_path: &str) -> Option<String> { pub(super) fn admin_user_id_from_detail_path(request_path: &str) -> Option<String> {
let value = request_path let value = request_path
.strip_prefix("/api/admin/users/")? .strip_prefix("/api/admin/users/")?

View File

@@ -248,7 +248,12 @@ pub(in super::super) async fn build_admin_update_user_response(
None None
}; };
let group_ids = if field_presence.contains("group_ids") { let group_ids = if field_presence.contains("group_ids") {
Some(normalize_admin_user_group_ids(payload.group_ids)) let requested_group_ids = normalize_admin_user_group_ids(payload.group_ids);
Some(
state
.include_default_user_group_ids(&requested_group_ids)
.await?,
)
} else { } else {
None None
}; };
@@ -302,29 +307,6 @@ pub(in super::super) async fn build_admin_update_user_response(
.into_response()); .into_response());
} }
} }
if allowed_providers_mode.is_some()
|| allowed_api_formats_mode.is_some()
|| allowed_models_mode.is_some()
|| rate_limit_mode.is_some()
{
if state
.update_local_auth_user_policy_modes(
&user_id,
allowed_providers_mode,
allowed_api_formats_mode,
allowed_models_mode,
rate_limit_mode,
)
.await?
.is_none()
{
return Ok((
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": "用户不存在" })),
)
.into_response());
}
}
if let Some(group_ids) = group_ids.as_ref() { if let Some(group_ids) = group_ids.as_ref() {
state state
.replace_user_groups_for_user(&user_id, group_ids) .replace_user_groups_for_user(&user_id, group_ids)
@@ -394,6 +376,29 @@ pub(in super::super) async fn build_admin_update_user_response(
.into_response()); .into_response());
} }
} }
if allowed_providers_mode.is_some()
|| allowed_api_formats_mode.is_some()
|| allowed_models_mode.is_some()
|| rate_limit_mode.is_some()
{
if state
.update_local_auth_user_policy_modes(
&user_id,
allowed_providers_mode,
allowed_api_formats_mode,
allowed_models_mode,
rate_limit_mode,
)
.await?
.is_none()
{
return Ok((
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": "用户不存在" })),
)
.into_response());
}
}
if let Some(unlimited) = payload.unlimited { if let Some(unlimited) = payload.unlimited {
match state match state

View File

@@ -44,13 +44,15 @@ use self::shared::{
admin_default_user_initial_gift, build_admin_users_bad_request_response, admin_default_user_initial_gift, build_admin_users_bad_request_response,
build_admin_users_data_unavailable_response, build_admin_users_read_only_response, build_admin_users_data_unavailable_response, build_admin_users_read_only_response,
format_optional_datetime_iso8601, legacy_admin_list_policy_mode, format_optional_datetime_iso8601, legacy_admin_list_policy_mode,
legacy_admin_rate_limit_policy_mode, normalize_admin_list_policy_mode, legacy_admin_rate_limit_policy_mode, normalize_admin_optional_user_email,
normalize_admin_optional_user_email, normalize_admin_rate_limit_policy_mode,
normalize_admin_user_group_ids, normalize_admin_user_role, normalize_admin_username, normalize_admin_user_group_ids, normalize_admin_user_role, normalize_admin_username,
validate_admin_user_password, AdminCreateUserApiKeyRequest, AdminCreateUserRequest, validate_admin_user_password, AdminCreateUserApiKeyRequest, AdminCreateUserRequest,
AdminToggleUserApiKeyLockRequest, AdminUpdateUserApiKeyRequest, AdminToggleUserApiKeyLockRequest, AdminUpdateUserApiKeyRequest,
}; };
pub(crate) use self::shared::{normalize_admin_user_api_formats, normalize_admin_user_string_list}; pub(crate) use self::shared::{
normalize_admin_list_policy_mode, normalize_admin_rate_limit_policy_mode,
normalize_admin_user_api_formats, normalize_admin_user_string_list,
};
pub(crate) async fn maybe_build_local_admin_users_response( pub(crate) async fn maybe_build_local_admin_users_response(
request: AdminRouteRequest<'_>, request: AdminRouteRequest<'_>,

View File

@@ -283,7 +283,7 @@ pub(crate) fn normalize_admin_user_api_formats(
Ok(Some(normalized)) Ok(Some(normalized))
} }
pub(super) fn normalize_admin_list_policy_mode(value: &str) -> Result<String, String> { pub(crate) fn normalize_admin_list_policy_mode(value: &str) -> Result<String, String> {
match value.trim().to_ascii_lowercase().as_str() { match value.trim().to_ascii_lowercase().as_str() {
"inherit" | "unrestricted" | "specific" | "deny_all" => { "inherit" | "unrestricted" | "specific" | "deny_all" => {
Ok(value.trim().to_ascii_lowercase()) Ok(value.trim().to_ascii_lowercase())
@@ -292,7 +292,7 @@ pub(super) fn normalize_admin_list_policy_mode(value: &str) -> Result<String, St
} }
} }
pub(super) fn normalize_admin_rate_limit_policy_mode(value: &str) -> Result<String, String> { pub(crate) fn normalize_admin_rate_limit_policy_mode(value: &str) -> Result<String, String> {
match value.trim().to_ascii_lowercase().as_str() { match value.trim().to_ascii_lowercase().as_str() {
"inherit" | "system" | "custom" => Ok(value.trim().to_ascii_lowercase()), "inherit" | "system" | "custom" => Ok(value.trim().to_ascii_lowercase()),
_ => Err("限速模式不合法".to_string()), _ => Err("限速模式不合法".to_string()),

View File

@@ -1,5 +1,6 @@
use std::collections::{BTreeMap, BTreeSet}; use std::collections::{BTreeMap, BTreeSet};
use crate::constants::{BUILTIN_DEFAULT_USER_GROUP_ID, DEFAULT_USER_GROUP_CONFIG_KEY};
use crate::{AppState, GatewayError}; use crate::{AppState, GatewayError};
impl AppState { impl AppState {
@@ -7,20 +8,10 @@ impl AppState {
&self, &self,
user_id: &str, user_id: &str,
) -> Result<(), GatewayError> { ) -> Result<(), GatewayError> {
let group_id = self let group_id = self.effective_default_user_group_id().await?;
.read_system_config_json_value("default_user_group_id")
.await?
.and_then(|value| value.as_str().map(str::to_string))
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
let Some(group_id) = group_id else { let Some(group_id) = group_id else {
return Ok(()); return Ok(());
}; };
if self.find_user_group_by_id(&group_id).await?.is_none() {
return Err(GatewayError::Internal(format!(
"default_user_group_id points to missing group: {group_id}"
)));
}
if !self.add_user_to_group(&group_id, user_id).await? { if !self.add_user_to_group(&group_id, user_id).await? {
return Err(GatewayError::Internal(format!( return Err(GatewayError::Internal(format!(
"failed to add user {user_id} to default group {group_id}" "failed to add user {user_id} to default group {group_id}"
@@ -29,6 +20,61 @@ impl AppState {
Ok(()) Ok(())
} }
pub(crate) async fn configured_default_user_group_id(
&self,
) -> Result<Option<String>, GatewayError> {
Ok(self
.read_system_config_json_value(DEFAULT_USER_GROUP_CONFIG_KEY)
.await?
.and_then(|value| value.as_str().map(str::to_string))
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty()))
}
pub(crate) async fn effective_default_user_group_id(
&self,
) -> Result<Option<String>, GatewayError> {
if let Some(group_id) = self.configured_default_user_group_id().await? {
if self.find_user_group_by_id(&group_id).await?.is_none() {
return Err(GatewayError::Internal(format!(
"{DEFAULT_USER_GROUP_CONFIG_KEY} points to missing group: {group_id}"
)));
}
return Ok(Some(group_id));
}
if self
.find_user_group_by_id(BUILTIN_DEFAULT_USER_GROUP_ID)
.await?
.is_some()
{
return Ok(Some(BUILTIN_DEFAULT_USER_GROUP_ID.to_string()));
}
Ok(None)
}
pub(crate) async fn include_default_user_group_ids(
&self,
group_ids: &[String],
) -> Result<Vec<String>, GatewayError> {
let mut group_ids = group_ids
.iter()
.map(|value| value.trim())
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.collect::<BTreeSet<_>>();
if let Some(default_group_id) = self.effective_default_user_group_id().await? {
group_ids.insert(default_group_id);
}
Ok(group_ids.into_iter().collect())
}
pub(crate) async fn add_all_users_to_group(&self, group_id: &str) -> Result<(), GatewayError> {
for user in self.list_export_users().await? {
self.add_user_to_group(group_id, &user.id).await?;
}
Ok(())
}
pub(crate) async fn resolve_auth_user_summaries_by_ids( pub(crate) async fn resolve_auth_user_summaries_by_ids(
&self, &self,
user_ids: &[String], user_ids: &[String],

View File

@@ -13,7 +13,9 @@ use aether_data::repository::global_models::InMemoryGlobalModelReadRepository;
use aether_data::repository::oauth_providers::InMemoryOAuthProviderRepository; use aether_data::repository::oauth_providers::InMemoryOAuthProviderRepository;
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository; use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
use aether_data::repository::proxy_nodes::InMemoryProxyNodeRepository; use aether_data::repository::proxy_nodes::InMemoryProxyNodeRepository;
use aether_data::repository::users::{InMemoryUserReadRepository, StoredUserExportRow}; use aether_data::repository::users::{
InMemoryUserReadRepository, StoredUserAuthRecord, UpsertUserGroupRecord, UserReadRepository,
};
use aether_data::repository::wallet::{InMemoryWalletRepository, StoredWalletSnapshot}; use aether_data::repository::wallet::{InMemoryWalletRepository, StoredWalletSnapshot};
use aether_data_contracts::repository::global_models::StoredPublicGlobalModel; use aether_data_contracts::repository::global_models::StoredPublicGlobalModel;
use axum::body::Body; use axum::body::Body;
@@ -596,8 +598,8 @@ async fn gateway_handles_admin_system_users_export_locally_with_trusted_admin_pr
}), }),
); );
let user_repository = Arc::new(InMemoryUserReadRepository::seed_export_users(vec![ let user_repository = Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![
StoredUserExportRow::new( StoredUserAuthRecord::new(
"user-1".to_string(), "user-1".to_string(),
Some("alice@example.com".to_string()), Some("alice@example.com".to_string()),
true, true,
@@ -608,12 +610,40 @@ async fn gateway_handles_admin_system_users_export_locally_with_trusted_admin_pr
Some(json!(["openai"])), Some(json!(["openai"])),
Some(json!(["openai:chat"])), Some(json!(["openai:chat"])),
Some(json!(["gpt-5"])), Some(json!(["gpt-5"])),
Some(120),
Some(json!({"gpt-5": {"cache_1h": true}})),
true, true,
false,
Some(chrono::Utc::now()),
None,
) )
.expect("user export row should build"), .expect("user export row should build")
.with_policy_modes(
"specific".to_string(),
"specific".to_string(),
"specific".to_string(),
)
.expect("user policy modes should build"),
])); ]));
let user_group = user_repository
.create_user_group(UpsertUserGroupRecord {
name: "Restricted GPT".to_string(),
description: Some("GPT-only users".to_string()),
priority: 10,
allowed_providers: Some(vec!["openai".to_string()]),
allowed_providers_mode: "specific".to_string(),
allowed_api_formats: Some(vec!["openai:chat".to_string()]),
allowed_api_formats_mode: "specific".to_string(),
allowed_models: Some(vec!["gpt-5".to_string()]),
allowed_models_mode: "specific".to_string(),
rate_limit: Some(60),
rate_limit_mode: "custom".to_string(),
})
.await
.expect("user group should create")
.expect("user group should exist");
user_repository
.replace_user_groups_for_user("user-1", std::slice::from_ref(&user_group.id))
.await
.expect("user group membership should create");
let auth_repository = Arc::new( let auth_repository = Arc::new(
InMemoryAuthApiKeySnapshotRepository::default().with_export_records(vec![ InMemoryAuthApiKeySnapshotRepository::default().with_export_records(vec![
StoredAuthApiKeyExportRecord::new( StoredAuthApiKeyExportRecord::new(
@@ -728,9 +758,24 @@ async fn gateway_handles_admin_system_users_export_locally_with_trusted_admin_pr
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::OK);
let payload: serde_json::Value = response.json().await.expect("json body should parse"); let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["version"], "1.3"); assert_eq!(payload["version"], "1.4");
assert!(payload["exported_at"].as_str().is_some()); assert!(payload["exported_at"].as_str().is_some());
assert_eq!(payload["user_groups"][0]["name"], "Restricted GPT");
assert!(payload["user_groups"][0].get("priority").is_none());
assert_eq!(
payload["user_groups"][0]["allowed_models"],
json!(["gpt-5"])
);
assert_eq!(payload["users"][0]["email"], "alice@example.com"); assert_eq!(payload["users"][0]["email"], "alice@example.com");
assert_eq!(
payload["users"][0]["allowed_models_mode"],
json!("specific")
);
assert_eq!(payload["users"][0]["rate_limit_mode"], json!("system"));
assert_eq!(
payload["users"][0]["group_names"],
json!(["Restricted GPT"])
);
assert_eq!(payload["users"][0]["wallet"]["balance"], json!(12.5)); assert_eq!(payload["users"][0]["wallet"]["balance"], json!(12.5));
assert_eq!( assert_eq!(
payload["users"][0]["wallet"]["recharge_balance"], payload["users"][0]["wallet"]["recharge_balance"],

View File

@@ -12,7 +12,7 @@ use aether_data::repository::oauth_providers::{
InMemoryOAuthProviderRepository, OAuthProviderReadRepository, StoredOAuthProviderConfig, InMemoryOAuthProviderRepository, OAuthProviderReadRepository, StoredOAuthProviderConfig,
}; };
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository; use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
use aether_data::repository::users::StoredUserAuthRecord; use aether_data::repository::users::{StoredUserAuthRecord, UserReadRepository};
use aether_data::repository::wallet::{StoredWalletSnapshot, WalletLookupKey}; use aether_data::repository::wallet::{StoredWalletSnapshot, WalletLookupKey};
use aether_data_contracts::repository::global_models::{ use aether_data_contracts::repository::global_models::{
AdminGlobalModelListQuery, AdminProviderModelListQuery, GlobalModelReadRepository, AdminGlobalModelListQuery, AdminProviderModelListQuery, GlobalModelReadRepository,
@@ -556,11 +556,14 @@ async fn gateway_imports_admin_system_users_locally_and_persists_data() {
})); }));
let auth_repository = Arc::new(InMemoryAuthApiKeySnapshotRepository::default()); let auth_repository = Arc::new(InMemoryAuthApiKeySnapshotRepository::default());
let user_repository =
Arc::new(aether_data::repository::users::InMemoryUserReadRepository::default());
let (upstream_url, upstream_handle) = start_server(upstream).await; let (upstream_url, upstream_handle) = start_server(upstream).await;
let state = AppState::new() let state = AppState::new()
.expect("gateway should build") .expect("gateway should build")
.with_data_state_for_tests( .with_data_state_for_tests(
GatewayDataState::with_auth_api_key_repository_for_tests(Arc::clone(&auth_repository)) GatewayDataState::with_auth_api_key_repository_for_tests(Arc::clone(&auth_repository))
.with_user_reader(user_repository)
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY), .with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
) )
.with_auth_users_for_tests([sample_import_admin_user("admin-user-123")]) .with_auth_users_for_tests([sample_import_admin_user("admin-user-123")])
@@ -575,8 +578,21 @@ async fn gateway_imports_admin_system_users_locally_and_persists_data() {
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin") .header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123") .header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
.json(&json!({ .json(&json!({
"version": "1.3", "version": "1.4",
"merge_mode": "overwrite", "merge_mode": "overwrite",
"user_groups": [{
"id": "source-group-1",
"name": "GPT Import",
"description": "Imported group",
"allowed_providers": ["openai"],
"allowed_providers_mode": "specific",
"allowed_api_formats": ["openai:chat"],
"allowed_api_formats_mode": "specific",
"allowed_models": ["gpt-5"],
"allowed_models_mode": "specific",
"rate_limit": 44,
"rate_limit_mode": "custom"
}],
"users": [{ "users": [{
"email": "alice@example.com", "email": "alice@example.com",
"email_verified": true, "email_verified": true,
@@ -587,6 +603,10 @@ async fn gateway_imports_admin_system_users_locally_and_persists_data() {
"allowed_api_formats": ["openai:chat"], "allowed_api_formats": ["openai:chat"],
"allowed_models": ["gpt-5"], "allowed_models": ["gpt-5"],
"rate_limit": 77, "rate_limit": 77,
"allowed_models_mode": "specific",
"rate_limit_mode": "custom",
"group_ids": ["source-group-1"],
"group_names": ["GPT Import"],
"is_active": true, "is_active": true,
"wallet": { "wallet": {
"balance": 20.0, "balance": 20.0,
@@ -654,6 +674,7 @@ async fn gateway_imports_admin_system_users_locally_and_persists_data() {
let payload: Value = response.json().await.expect("json body should parse"); let payload: Value = response.json().await.expect("json body should parse");
assert_eq!(status, StatusCode::OK, "payload={payload}"); assert_eq!(status, StatusCode::OK, "payload={payload}");
assert_eq!(payload["message"], "用户数据导入成功"); assert_eq!(payload["message"], "用户数据导入成功");
assert_eq!(payload["stats"]["user_groups"]["created"], json!(1));
assert_eq!(payload["stats"]["users"]["created"], json!(1)); assert_eq!(payload["stats"]["users"]["created"], json!(1));
assert_eq!(payload["stats"]["api_keys"]["created"], json!(1)); assert_eq!(payload["stats"]["api_keys"]["created"], json!(1));
assert_eq!(payload["stats"]["standalone_keys"]["created"], json!(1)); assert_eq!(payload["stats"]["standalone_keys"]["created"], json!(1));
@@ -683,8 +704,22 @@ async fn gateway_imports_admin_system_users_locally_and_persists_data() {
imported_user.allowed_models, imported_user.allowed_models,
Some(vec!["gpt-5".to_string()]) Some(vec!["gpt-5".to_string()])
); );
assert_eq!(imported_user.allowed_models_mode, "specific");
assert!(imported_user.is_active); assert!(imported_user.is_active);
let imported_groups = state
.list_user_groups_for_user(&imported_user.id)
.await
.expect("user groups should load");
assert_eq!(imported_groups.len(), 1);
assert_eq!(imported_groups[0].name, "GPT Import");
assert_eq!(imported_groups[0].allowed_models_mode, "specific");
assert_eq!(
imported_groups[0].allowed_models,
Some(vec!["gpt-5".to_string()])
);
assert_eq!(imported_groups[0].rate_limit, Some(44));
let user_wallet = state let user_wallet = state
.find_wallet(WalletLookupKey::UserId(&imported_user.id)) .find_wallet(WalletLookupKey::UserId(&imported_user.id))
.await .await

View File

@@ -43,8 +43,9 @@ pub struct AdminEmailTemplateUpdate {
pub const ADMIN_SYSTEM_CONFIG_EXPORT_VERSION: &str = "2.2"; pub const ADMIN_SYSTEM_CONFIG_EXPORT_VERSION: &str = "2.2";
pub const ADMIN_SYSTEM_CONFIG_SUPPORTED_VERSIONS: &[&str] = &[ADMIN_SYSTEM_CONFIG_EXPORT_VERSION]; pub const ADMIN_SYSTEM_CONFIG_SUPPORTED_VERSIONS: &[&str] = &[ADMIN_SYSTEM_CONFIG_EXPORT_VERSION];
pub const ADMIN_SYSTEM_USERS_EXPORT_VERSION: &str = "1.3"; pub const ADMIN_SYSTEM_USERS_EXPORT_VERSION: &str = "1.4";
pub const ADMIN_SYSTEM_USERS_SUPPORTED_VERSIONS: &[&str] = &[ADMIN_SYSTEM_USERS_EXPORT_VERSION]; pub const ADMIN_SYSTEM_USERS_SUPPORTED_VERSIONS: &[&str] =
&["1.3", ADMIN_SYSTEM_USERS_EXPORT_VERSION];
pub const ADMIN_SYSTEM_PROVIDER_OPS_SENSITIVE_CREDENTIAL_FIELDS: &[&str] = &[ pub const ADMIN_SYSTEM_PROVIDER_OPS_SENSITIVE_CREDENTIAL_FIELDS: &[&str] = &[
"api_key", "api_key",
"password", "password",

View File

@@ -51,3 +51,52 @@ CREATE TABLE IF NOT EXISTS user_group_members (
CONSTRAINT user_group_members_user_id_fk CONSTRAINT user_group_members_user_id_fk
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
); );
INSERT IGNORE INTO user_groups (
id,
name,
normalized_name,
description,
priority,
allowed_providers_mode,
allowed_api_formats_mode,
allowed_models_mode,
rate_limit_mode,
created_at,
updated_at
)
VALUES (
'00000000-0000-0000-0000-000000000001',
'Default',
'default',
'Default unrestricted group for all users',
0,
'unrestricted',
'unrestricted',
'unrestricted',
'system',
UNIX_TIMESTAMP(),
UNIX_TIMESTAMP()
);
INSERT IGNORE INTO system_configs (
id,
`key`,
value,
description,
created_at,
updated_at
)
VALUES (
'00000000-0000-0000-0000-000000000002',
'default_user_group_id',
'"00000000-0000-0000-0000-000000000001"',
'Default unrestricted user group',
UNIX_TIMESTAMP(),
UNIX_TIMESTAMP()
);
INSERT IGNORE INTO user_group_members (group_id, user_id, created_at)
SELECT '00000000-0000-0000-0000-000000000001', id, UNIX_TIMESTAMP()
FROM users
WHERE is_deleted = 0;

View File

@@ -58,3 +58,42 @@ CREATE INDEX IF NOT EXISTS user_group_members_user_id_idx
CREATE INDEX IF NOT EXISTS user_groups_priority_name_idx CREATE INDEX IF NOT EXISTS user_groups_priority_name_idx
ON public.user_groups (priority DESC, name ASC, id ASC); ON public.user_groups (priority DESC, name ASC, id ASC);
INSERT INTO public.user_groups (
id,
name,
normalized_name,
description,
priority,
allowed_providers_mode,
allowed_api_formats_mode,
allowed_models_mode,
rate_limit_mode
)
VALUES (
'00000000-0000-0000-0000-000000000001',
'Default',
'default',
'Default unrestricted group for all users',
0,
'unrestricted',
'unrestricted',
'unrestricted',
'system'
)
ON CONFLICT (id) DO NOTHING;
INSERT INTO public.system_configs (id, key, value, description)
VALUES (
'00000000-0000-0000-0000-000000000002',
'default_user_group_id',
'"00000000-0000-0000-0000-000000000001"'::json,
'Default unrestricted user group'
)
ON CONFLICT (key) DO NOTHING;
INSERT INTO public.user_group_members (group_id, user_id)
SELECT '00000000-0000-0000-0000-000000000001', id
FROM public.users
WHERE is_deleted IS FALSE
ON CONFLICT (group_id, user_id) DO NOTHING;

View File

@@ -49,3 +49,52 @@ CREATE INDEX IF NOT EXISTS user_group_members_user_id_idx
CREATE INDEX IF NOT EXISTS user_groups_priority_name_idx CREATE INDEX IF NOT EXISTS user_groups_priority_name_idx
ON user_groups (priority DESC, name ASC, id ASC); ON user_groups (priority DESC, name ASC, id ASC);
INSERT OR IGNORE INTO user_groups (
id,
name,
normalized_name,
description,
priority,
allowed_providers_mode,
allowed_api_formats_mode,
allowed_models_mode,
rate_limit_mode,
created_at,
updated_at
)
VALUES (
'00000000-0000-0000-0000-000000000001',
'Default',
'default',
'Default unrestricted group for all users',
0,
'unrestricted',
'unrestricted',
'unrestricted',
'system',
CAST(strftime('%s', 'now') AS INTEGER),
CAST(strftime('%s', 'now') AS INTEGER)
);
INSERT OR IGNORE INTO system_configs (
id,
key,
value,
description,
created_at,
updated_at
)
VALUES (
'00000000-0000-0000-0000-000000000002',
'default_user_group_id',
'"00000000-0000-0000-0000-000000000001"',
'Default unrestricted user group',
CAST(strftime('%s', 'now') AS INTEGER),
CAST(strftime('%s', 'now') AS INTEGER)
);
INSERT OR IGNORE INTO user_group_members (group_id, user_id, created_at)
SELECT '00000000-0000-0000-0000-000000000001', id, CAST(strftime('%s', 'now') AS INTEGER)
FROM users
WHERE is_deleted = 0;

View File

@@ -1,5 +1,44 @@
-- Restore a normal lookup path before sqlx records this migration in the -- Restore a normal lookup path before sqlx records this migration in the
-- same transaction. sqlx inserts into `_sqlx_migrations` unqualified. -- same transaction. sqlx inserts into `_sqlx_migrations` unqualified.
INSERT INTO public.user_groups (
id,
name,
normalized_name,
description,
priority,
allowed_providers_mode,
allowed_api_formats_mode,
allowed_models_mode,
rate_limit_mode
)
VALUES (
'00000000-0000-0000-0000-000000000001',
'Default',
'default',
'Default unrestricted group for all users',
0,
'unrestricted',
'unrestricted',
'unrestricted',
'system'
)
ON CONFLICT (id) DO NOTHING;
INSERT INTO public.system_configs (id, key, value, description)
VALUES (
'00000000-0000-0000-0000-000000000002',
'default_user_group_id',
'"00000000-0000-0000-0000-000000000001"'::json,
'Default unrestricted user group'
)
ON CONFLICT (key) DO NOTHING;
INSERT INTO public.user_group_members (group_id, user_id)
SELECT '00000000-0000-0000-0000-000000000001', id
FROM public.users
WHERE is_deleted IS FALSE
ON CONFLICT (group_id, user_id) DO NOTHING;
SELECT pg_catalog.set_config('search_path', 'public', true); SELECT pg_catalog.set_config('search_path', 'public', true);

View File

@@ -293,7 +293,7 @@ mod tests {
.await .await
.expect("system config should list") .expect("system config should list")
.len(), .len(),
1 2
); );
assert!(backend assert!(backend
.delete_system_config_value("feature.local") .delete_system_config_value("feature.local")

View File

@@ -23,6 +23,8 @@ pub enum ExportDomain {
AuthModules, AuthModules,
OAuthProviders, OAuthProviders,
UserOAuthLinks, UserOAuthLinks,
UserGroups,
UserGroupMembers,
ProxyNodes, ProxyNodes,
SystemConfigs, SystemConfigs,
Wallets, Wallets,
@@ -43,6 +45,8 @@ impl ExportDomain {
Self::AuthModules => "auth_modules", Self::AuthModules => "auth_modules",
Self::OAuthProviders => "oauth_providers", Self::OAuthProviders => "oauth_providers",
Self::UserOAuthLinks => "user_oauth_links", Self::UserOAuthLinks => "user_oauth_links",
Self::UserGroups => "user_groups",
Self::UserGroupMembers => "user_group_members",
Self::ProxyNodes => "proxy_nodes", Self::ProxyNodes => "proxy_nodes",
Self::SystemConfigs => "system_configs", Self::SystemConfigs => "system_configs",
Self::Wallets => "wallets", Self::Wallets => "wallets",
@@ -265,6 +269,8 @@ pub fn sqlite_core_export_domains() -> Vec<ExportDomain> {
ExportDomain::AuthModules, ExportDomain::AuthModules,
ExportDomain::OAuthProviders, ExportDomain::OAuthProviders,
ExportDomain::UserOAuthLinks, ExportDomain::UserOAuthLinks,
ExportDomain::UserGroups,
ExportDomain::UserGroupMembers,
ExportDomain::ProxyNodes, ExportDomain::ProxyNodes,
ExportDomain::SystemConfigs, ExportDomain::SystemConfigs,
ExportDomain::Wallets, ExportDomain::Wallets,
@@ -367,19 +373,11 @@ pub async fn export_sqlite_jsonl(
continue; continue;
} }
let (table_name, id_column) = sqlite_domain_table(domain)?; let (table_name, id_column) = sqlite_domain_table(domain)?;
let sql = format!("SELECT * FROM {table_name} ORDER BY {id_column} ASC"); let order_by = export_order_by(domain, id_column);
let sql = format!("SELECT * FROM {table_name} ORDER BY {order_by}");
let rows = sqlx::query(&sql).fetch_all(pool).await.map_sql_err()?; let rows = sqlx::query(&sql).fetch_all(pool).await.map_sql_err()?;
for row in rows { for row in rows {
let id = row let id = sqlite_export_row_id(domain, &row, id_column)?;
.try_get::<Option<String>, _>(id_column)
.map_sql_err()?
.ok_or_else(|| {
DataLayerError::UnexpectedValue(format!(
"{} export row has null id column '{}'",
domain.as_str(),
id_column
))
})?;
records.push(DataExportRecord::row(domain, id, sqlite_row_payload(&row)?)); records.push(DataExportRecord::row(domain, id, sqlite_row_payload(&row)?));
} }
} }
@@ -456,8 +454,10 @@ pub async fn export_postgres_jsonl(
continue; continue;
} }
let (table_name, id_column) = postgres_domain_table(domain)?; let (table_name, id_column) = postgres_domain_table(domain)?;
let export_id_sql = postgres_export_id_sql(domain, id_column);
let order_by = export_order_by(domain, id_column);
let sql = format!( let sql = format!(
"SELECT {id_column}::text AS export_id, to_jsonb(t) AS payload FROM {table_name} AS t ORDER BY {id_column} ASC" "SELECT {export_id_sql} AS export_id, to_jsonb(t) AS payload FROM {table_name} AS t ORDER BY {order_by}"
); );
let rows = sqlx::query(&sql).fetch_all(pool).await.map_sql_err()?; let rows = sqlx::query(&sql).fetch_all(pool).await.map_sql_err()?;
for row in rows { for row in rows {
@@ -500,6 +500,7 @@ pub async fn import_postgres_plan(
continue; continue;
} }
let (table_name, id_column) = postgres_domain_table(*domain)?; let (table_name, id_column) = postgres_domain_table(*domain)?;
let conflict_columns = postgres_conflict_columns(*domain, id_column);
let rows = plan.rows(*domain); let rows = plan.rows(*domain);
if rows.is_empty() { if rows.is_empty() {
continue; continue;
@@ -507,7 +508,15 @@ pub async fn import_postgres_plan(
let target_columns = let target_columns =
postgres_import_columns_cached(pool, &mut column_cache, table_name).await?; postgres_import_columns_cached(pool, &mut column_cache, table_name).await?;
for row in rows { for row in rows {
import_postgres_row(pool, table_name, id_column, *domain, row, &target_columns).await?; import_postgres_row(
pool,
table_name,
&conflict_columns,
*domain,
row,
&target_columns,
)
.await?;
imported = imported.saturating_add(1); imported = imported.saturating_add(1);
} }
} }
@@ -543,19 +552,11 @@ pub async fn export_mysql_jsonl(
continue; continue;
} }
let (table_name, id_column) = mysql_domain_table(domain)?; let (table_name, id_column) = mysql_domain_table(domain)?;
let sql = format!("SELECT * FROM {table_name} ORDER BY {id_column} ASC"); let order_by = export_order_by(domain, id_column);
let sql = format!("SELECT * FROM {table_name} ORDER BY {order_by}");
let rows = sqlx::query(&sql).fetch_all(pool).await.map_sql_err()?; let rows = sqlx::query(&sql).fetch_all(pool).await.map_sql_err()?;
for row in rows { for row in rows {
let id = row let id = mysql_export_row_id(domain, &row, id_column)?;
.try_get::<Option<String>, _>(id_column)
.map_sql_err()?
.ok_or_else(|| {
DataLayerError::UnexpectedValue(format!(
"{} export row has null id column '{}'",
domain.as_str(),
id_column
))
})?;
records.push(DataExportRecord::row(domain, id, mysql_row_payload(&row)?)); records.push(DataExportRecord::row(domain, id, mysql_row_payload(&row)?));
} }
} }
@@ -617,6 +618,8 @@ fn sqlite_domain_table(
ExportDomain::AuthModules => Ok(("auth_modules", "id")), ExportDomain::AuthModules => Ok(("auth_modules", "id")),
ExportDomain::OAuthProviders => Ok(("oauth_providers", "provider_type")), ExportDomain::OAuthProviders => Ok(("oauth_providers", "provider_type")),
ExportDomain::UserOAuthLinks => Ok(("user_oauth_links", "id")), ExportDomain::UserOAuthLinks => Ok(("user_oauth_links", "id")),
ExportDomain::UserGroups => Ok(("user_groups", "id")),
ExportDomain::UserGroupMembers => Ok(("user_group_members", "group_id")),
ExportDomain::ProxyNodes => Ok(("proxy_nodes", "id")), ExportDomain::ProxyNodes => Ok(("proxy_nodes", "id")),
ExportDomain::SystemConfigs => Ok(("system_configs", "id")), ExportDomain::SystemConfigs => Ok(("system_configs", "id")),
ExportDomain::Wallets => Err(DataLayerError::InvalidInput( ExportDomain::Wallets => Err(DataLayerError::InvalidInput(
@@ -630,6 +633,43 @@ fn sqlite_domain_table(
} }
} }
fn export_order_by(domain: ExportDomain, id_column: &str) -> String {
if domain == ExportDomain::UserGroupMembers {
"group_id ASC, user_id ASC".to_string()
} else {
format!("{id_column} ASC")
}
}
fn sqlite_export_row_id(
domain: ExportDomain,
row: &sqlx::sqlite::SqliteRow,
id_column: &str,
) -> Result<String, DataLayerError> {
if domain == ExportDomain::UserGroupMembers {
let group_id = sqlite_required_export_text(row, "group_id", domain)?;
let user_id = sqlite_required_export_text(row, "user_id", domain)?;
return Ok(format!("{group_id}:{user_id}"));
}
sqlite_required_export_text(row, id_column, domain)
}
fn sqlite_required_export_text(
row: &sqlx::sqlite::SqliteRow,
column: &str,
domain: ExportDomain,
) -> Result<String, DataLayerError> {
row.try_get::<Option<String>, _>(column)
.map_sql_err()?
.ok_or_else(|| {
DataLayerError::UnexpectedValue(format!(
"{} export row has null id column '{}'",
domain.as_str(),
column
))
})
}
async fn export_sqlite_billing_records( async fn export_sqlite_billing_records(
pool: &crate::driver::sqlite::SqlitePool, pool: &crate::driver::sqlite::SqlitePool,
records: &mut Vec<DataExportRecord>, records: &mut Vec<DataExportRecord>,
@@ -898,6 +938,8 @@ fn postgres_domain_table(
ExportDomain::AuthModules => Ok(("public.auth_modules", "id")), ExportDomain::AuthModules => Ok(("public.auth_modules", "id")),
ExportDomain::OAuthProviders => Ok(("public.oauth_providers", "provider_type")), ExportDomain::OAuthProviders => Ok(("public.oauth_providers", "provider_type")),
ExportDomain::UserOAuthLinks => Ok(("public.user_oauth_links", "id")), ExportDomain::UserOAuthLinks => Ok(("public.user_oauth_links", "id")),
ExportDomain::UserGroups => Ok(("public.user_groups", "id")),
ExportDomain::UserGroupMembers => Ok(("public.user_group_members", "group_id")),
ExportDomain::ProxyNodes => Ok(("public.proxy_nodes", "id")), ExportDomain::ProxyNodes => Ok(("public.proxy_nodes", "id")),
ExportDomain::SystemConfigs => Ok(("public.system_configs", "id")), ExportDomain::SystemConfigs => Ok(("public.system_configs", "id")),
ExportDomain::Wallets => Err(DataLayerError::InvalidInput( ExportDomain::Wallets => Err(DataLayerError::InvalidInput(
@@ -912,6 +954,22 @@ fn postgres_domain_table(
} }
} }
fn postgres_export_id_sql(domain: ExportDomain, id_column: &str) -> String {
if domain == ExportDomain::UserGroupMembers {
"group_id::text || ':' || user_id::text".to_string()
} else {
format!("{id_column}::text")
}
}
fn postgres_conflict_columns(domain: ExportDomain, id_column: &str) -> Vec<&str> {
if domain == ExportDomain::UserGroupMembers {
vec!["group_id", "user_id"]
} else {
vec![id_column]
}
}
async fn postgres_import_columns_cached( async fn postgres_import_columns_cached(
pool: &crate::driver::postgres::PostgresPool, pool: &crate::driver::postgres::PostgresPool,
cache: &mut BTreeMap<String, PostgresImportColumns>, cache: &mut BTreeMap<String, PostgresImportColumns>,
@@ -1045,7 +1103,7 @@ async fn export_postgres_wallet_records(
async fn import_postgres_row( async fn import_postgres_row(
pool: &crate::driver::postgres::PostgresPool, pool: &crate::driver::postgres::PostgresPool,
table_name: &str, table_name: &str,
id_column: &str, conflict_columns: &[&str],
domain: ExportDomain, domain: ExportDomain,
row: &ExportRow, row: &ExportRow,
target_columns: &PostgresImportColumns, target_columns: &PostgresImportColumns,
@@ -1060,23 +1118,22 @@ async fn import_postgres_row(
.join(", "); .join(", ");
let update_sql = columns let update_sql = columns
.iter() .iter()
.filter(|column| **column != id_column) .filter(|column| !conflict_columns.contains(column))
.map(|column| { .map(|column| {
let quoted = postgres_quote_identifier(column)?; let quoted = postgres_quote_identifier(column)?;
Ok(format!("{quoted} = EXCLUDED.{quoted}")) Ok(format!("{quoted} = EXCLUDED.{quoted}"))
}) })
.collect::<Result<Vec<_>, DataLayerError>>()? .collect::<Result<Vec<_>, DataLayerError>>()?
.join(", "); .join(", ");
let conflict_target_sql = conflict_columns
.iter()
.map(|column| postgres_quote_identifier(column))
.collect::<Result<Vec<_>, _>>()?
.join(", ");
let conflict_sql = if update_sql.is_empty() { let conflict_sql = if update_sql.is_empty() {
format!( format!("ON CONFLICT ({conflict_target_sql}) DO NOTHING")
"ON CONFLICT ({}) DO NOTHING",
postgres_quote_identifier(id_column)?
)
} else { } else {
format!( format!("ON CONFLICT ({conflict_target_sql}) DO UPDATE SET {update_sql}")
"ON CONFLICT ({}) DO UPDATE SET {update_sql}",
postgres_quote_identifier(id_column)?
)
}; };
let sql = format!( let sql = format!(
"INSERT INTO {table_name} ({column_sql}) SELECT {column_sql} FROM jsonb_populate_record(NULL::{table_name}, $1::jsonb) {conflict_sql}" "INSERT INTO {table_name} ({column_sql}) SELECT {column_sql} FROM jsonb_populate_record(NULL::{table_name}, $1::jsonb) {conflict_sql}"
@@ -1276,7 +1333,7 @@ async fn import_postgres_billing_row(
import_postgres_row( import_postgres_row(
pool, pool,
table_name, table_name,
"id", &["id"],
ExportDomain::Billing, ExportDomain::Billing,
&ExportRow { &ExportRow {
id: row.id.clone(), id: row.id.clone(),
@@ -1309,7 +1366,7 @@ async fn import_postgres_wallet_row(
import_postgres_row( import_postgres_row(
pool, pool,
table_name, table_name,
id_column, &[id_column],
ExportDomain::Wallets, ExportDomain::Wallets,
&ExportRow { &ExportRow {
id: row.id.clone(), id: row.id.clone(),
@@ -1382,6 +1439,8 @@ fn mysql_domain_table(
ExportDomain::AuthModules => Ok(("auth_modules", "id")), ExportDomain::AuthModules => Ok(("auth_modules", "id")),
ExportDomain::OAuthProviders => Ok(("oauth_providers", "provider_type")), ExportDomain::OAuthProviders => Ok(("oauth_providers", "provider_type")),
ExportDomain::UserOAuthLinks => Ok(("user_oauth_links", "id")), ExportDomain::UserOAuthLinks => Ok(("user_oauth_links", "id")),
ExportDomain::UserGroups => Ok(("user_groups", "id")),
ExportDomain::UserGroupMembers => Ok(("user_group_members", "group_id")),
ExportDomain::ProxyNodes => Ok(("proxy_nodes", "id")), ExportDomain::ProxyNodes => Ok(("proxy_nodes", "id")),
ExportDomain::SystemConfigs => Ok(("system_configs", "id")), ExportDomain::SystemConfigs => Ok(("system_configs", "id")),
ExportDomain::Wallets => Err(DataLayerError::InvalidInput( ExportDomain::Wallets => Err(DataLayerError::InvalidInput(
@@ -1394,6 +1453,35 @@ fn mysql_domain_table(
} }
} }
fn mysql_export_row_id(
domain: ExportDomain,
row: &sqlx::mysql::MySqlRow,
id_column: &str,
) -> Result<String, DataLayerError> {
if domain == ExportDomain::UserGroupMembers {
let group_id = mysql_required_export_text(row, "group_id", domain)?;
let user_id = mysql_required_export_text(row, "user_id", domain)?;
return Ok(format!("{group_id}:{user_id}"));
}
mysql_required_export_text(row, id_column, domain)
}
fn mysql_required_export_text(
row: &sqlx::mysql::MySqlRow,
column: &str,
domain: ExportDomain,
) -> Result<String, DataLayerError> {
row.try_get::<Option<String>, _>(column)
.map_sql_err()?
.ok_or_else(|| {
DataLayerError::UnexpectedValue(format!(
"{} export row has null id column '{}'",
domain.as_str(),
column
))
})
}
async fn export_mysql_billing_records( async fn export_mysql_billing_records(
pool: &crate::driver::mysql::MysqlPool, pool: &crate::driver::mysql::MysqlPool,
records: &mut Vec<DataExportRecord>, records: &mut Vec<DataExportRecord>,
@@ -2094,6 +2182,10 @@ not-json"#,
r#" r#"
INSERT INTO users (id, email, username, auth_source, created_at, updated_at) INSERT INTO users (id, email, username, auth_source, created_at, updated_at)
VALUES ('user-1', 'owner@example.com', 'owner', 'local', 1, 2); VALUES ('user-1', 'owner@example.com', 'owner', 'local', 1, 2);
INSERT INTO user_groups (id, name, normalized_name, description, priority, allowed_models, allowed_models_mode, created_at, updated_at)
VALUES ('group-1', 'Export Group', 'export group', 'Exported group', 10, '["gpt-test"]', 'specific', 1, 2);
INSERT INTO user_group_members (group_id, user_id, created_at)
VALUES ('group-1', 'user-1', 1);
INSERT INTO api_keys (id, user_id, key_hash, key_encrypted, name, created_at, updated_at) INSERT INTO api_keys (id, user_id, key_hash, key_encrypted, name, created_at, updated_at)
VALUES ('api-key-1', 'user-1', 'hash-1', 'ciphertext-1', 'Default', 1, 2); VALUES ('api-key-1', 'user-1', 'hash-1', 'ciphertext-1', 'Default', 1, 2);
INSERT INTO providers (id, name, provider_type, created_at, updated_at) INSERT INTO providers (id, name, provider_type, created_at, updated_at)
@@ -2136,6 +2228,16 @@ VALUES ('request-1', 'request-1', 'user-1', 'Provider One', 'gpt-test', 'complet
import_plan.rows(ExportDomain::Users)[0].payload["email"], import_plan.rows(ExportDomain::Users)[0].payload["email"],
"owner@example.com" "owner@example.com"
); );
assert!(import_plan
.rows(ExportDomain::UserGroups)
.iter()
.any(|row| row.id == "group-1" && row.payload["name"] == "Export Group"));
assert!(import_plan
.rows(ExportDomain::UserGroupMembers)
.iter()
.any(|row| row.id == "group-1:user-1"
&& row.payload["group_id"] == "group-1"
&& row.payload["user_id"] == "user-1"));
assert_eq!( assert_eq!(
import_plan.rows(ExportDomain::ApiKeys)[0].payload["key_encrypted"], import_plan.rows(ExportDomain::ApiKeys)[0].payload["key_encrypted"],
"ciphertext-1" "ciphertext-1"
@@ -2166,7 +2268,7 @@ VALUES ('request-1', 'request-1', 'user-1', 'Provider One', 'gpt-test', 'complet
let imported = import_sqlite_jsonl(&target_pool, &encoded) let imported = import_sqlite_jsonl(&target_pool, &encoded)
.await .await
.expect("sqlite import should load exported rows"); .expect("sqlite import should load exported rows");
assert_eq!(imported, 12); assert_eq!(imported, 16);
let imported_api_key = sqlx::query_as::<_, (String,)>( let imported_api_key = sqlx::query_as::<_, (String,)>(
"SELECT key_encrypted FROM api_keys WHERE id = 'api-key-1'", "SELECT key_encrypted FROM api_keys WHERE id = 'api-key-1'",
@@ -2184,6 +2286,15 @@ VALUES ('request-1', 'request-1', 'user-1', 'Provider One', 'gpt-test', 'complet
.expect("imported usage should load"); .expect("imported usage should load");
assert_eq!(imported_usage.0, "request-1"); assert_eq!(imported_usage.0, "request-1");
let imported_group_member = sqlx::query_as::<_, (String, String)>(
"SELECT group_id, user_id FROM user_group_members WHERE group_id = 'group-1' AND user_id = 'user-1'",
)
.fetch_one(&target_pool)
.await
.expect("imported user group member should load");
assert_eq!(imported_group_member.0, "group-1");
assert_eq!(imported_group_member.1, "user-1");
let imported_billing_rule = sqlx::query_as::<_, (String,)>( let imported_billing_rule = sqlx::query_as::<_, (String,)>(
"SELECT expression FROM billing_rules WHERE id = 'billing-rule-1'", "SELECT expression FROM billing_rules WHERE id = 'billing-rule-1'",
) )
@@ -2217,7 +2328,7 @@ VALUES ('request-1', 'request-1', 'user-1', 'Provider One', 'gpt-test', 'complet
let imported = import_postgres_jsonl(&postgres_pool, &encoded) let imported = import_postgres_jsonl(&postgres_pool, &encoded)
.await .await
.expect("postgres import should load exported rows"); .expect("postgres import should load exported rows");
assert_eq!(imported, 12); assert_eq!(imported, 16);
let imported_api_key = sqlx::query_as::<_, (String,)>( let imported_api_key = sqlx::query_as::<_, (String,)>(
"SELECT key_encrypted FROM api_keys WHERE id = 'api-key-1'", "SELECT key_encrypted FROM api_keys WHERE id = 'api-key-1'",
@@ -2273,6 +2384,7 @@ VALUES ('request-1', 'request-1', 'user-1', 'Provider One', 'gpt-test', 'complet
let config_key = format!("export.config.{suffix}"); let config_key = format!("export.config.{suffix}");
let wallet_id = format!("export-wallet-{suffix}"); let wallet_id = format!("export-wallet-{suffix}");
let request_id = format!("export-request-{suffix}"); let request_id = format!("export-request-{suffix}");
let group_id = format!("export-group-{suffix}");
sqlx::query( sqlx::query(
"INSERT INTO users (id, email, username, auth_source, email_verified, created_at, updated_at) VALUES ($1, $2, $3, 'local', TRUE, to_timestamp(1), to_timestamp(2))", "INSERT INTO users (id, email, username, auth_source, email_verified, created_at, updated_at) VALUES ($1, $2, $3, 'local', TRUE, to_timestamp(1), to_timestamp(2))",
@@ -2283,6 +2395,23 @@ VALUES ('request-1', 'request-1', 'user-1', 'Provider One', 'gpt-test', 'complet
.execute(&pool) .execute(&pool)
.await .await
.expect("user should seed"); .expect("user should seed");
sqlx::query(
"INSERT INTO user_groups (id, name, normalized_name, priority, allowed_models, allowed_models_mode, created_at, updated_at) VALUES ($1, $2, $3, 10, '[\"provider-model\"]', 'specific', to_timestamp(1), to_timestamp(2))",
)
.bind(&group_id)
.bind(format!("Export Group {suffix}"))
.bind(format!("export group {suffix}"))
.execute(&pool)
.await
.expect("user group should seed");
sqlx::query(
"INSERT INTO user_group_members (group_id, user_id, created_at) VALUES ($1, $2, to_timestamp(1))",
)
.bind(&group_id)
.bind(&user_id)
.execute(&pool)
.await
.expect("user group member should seed");
sqlx::query( sqlx::query(
"INSERT INTO api_keys (id, user_id, key_hash, key_encrypted, name, created_at, updated_at) VALUES ($1, $2, $3, 'ciphertext-1', 'Default', to_timestamp(1), to_timestamp(2))", "INSERT INTO api_keys (id, user_id, key_hash, key_encrypted, name, created_at, updated_at) VALUES ($1, $2, $3, 'ciphertext-1', 'Default', to_timestamp(1), to_timestamp(2))",
) )
@@ -2389,6 +2518,14 @@ VALUES ('request-1', 'request-1', 'user-1', 'Provider One', 'gpt-test', 'complet
.rows(ExportDomain::Users) .rows(ExportDomain::Users)
.iter() .iter()
.any(|row| row.id == user_id)); .any(|row| row.id == user_id));
assert!(import_plan
.rows(ExportDomain::UserGroups)
.iter()
.any(|row| row.id == group_id));
assert!(import_plan
.rows(ExportDomain::UserGroupMembers)
.iter()
.any(|row| row.id == format!("{group_id}:{user_id}")));
assert!(import_plan assert!(import_plan
.rows(ExportDomain::ApiKeys) .rows(ExportDomain::ApiKeys)
.iter() .iter()
@@ -2432,6 +2569,16 @@ VALUES ('request-1', 'request-1', 'user-1', 'Provider One', 'gpt-test', 'complet
.await .await
.expect("imported sqlite api key should load"); .expect("imported sqlite api key should load");
assert_eq!(imported_api_key.0, "ciphertext-1"); assert_eq!(imported_api_key.0, "ciphertext-1");
let imported_group_member = sqlx::query_as::<_, (String, String)>(
"SELECT group_id, user_id FROM user_group_members WHERE group_id = ? AND user_id = ?",
)
.bind(&group_id)
.bind(&user_id)
.fetch_one(&target_pool)
.await
.expect("imported sqlite user group member should load");
assert_eq!(imported_group_member.0, group_id);
assert_eq!(imported_group_member.1, user_id);
} }
#[tokio::test] #[tokio::test]
@@ -2466,6 +2613,7 @@ VALUES ('request-1', 'request-1', 'user-1', 'Provider One', 'gpt-test', 'complet
let config_id = format!("export-config-{suffix}"); let config_id = format!("export-config-{suffix}");
let wallet_id = format!("export-wallet-{suffix}"); let wallet_id = format!("export-wallet-{suffix}");
let request_id = format!("export-request-{suffix}"); let request_id = format!("export-request-{suffix}");
let group_id = format!("export-group-{suffix}");
sqlx::query( sqlx::query(
"INSERT INTO users (id, email, username, auth_source, created_at, updated_at) VALUES (?, ?, ?, 'local', 1, 2)", "INSERT INTO users (id, email, username, auth_source, created_at, updated_at) VALUES (?, ?, ?, 'local', 1, 2)",
@@ -2476,6 +2624,23 @@ VALUES ('request-1', 'request-1', 'user-1', 'Provider One', 'gpt-test', 'complet
.execute(&pool) .execute(&pool)
.await .await
.expect("user should seed"); .expect("user should seed");
sqlx::query(
"INSERT INTO user_groups (id, name, normalized_name, priority, allowed_models, allowed_models_mode, created_at, updated_at) VALUES (?, ?, ?, 10, '[\"provider-model\"]', 'specific', 1, 2)",
)
.bind(&group_id)
.bind(format!("Export Group {suffix}"))
.bind(format!("export group {suffix}"))
.execute(&pool)
.await
.expect("user group should seed");
sqlx::query(
"INSERT INTO user_group_members (group_id, user_id, created_at) VALUES (?, ?, 1)",
)
.bind(&group_id)
.bind(&user_id)
.execute(&pool)
.await
.expect("user group member should seed");
sqlx::query( sqlx::query(
"INSERT INTO api_keys (id, user_id, key_hash, key_encrypted, name, created_at, updated_at) VALUES (?, ?, ?, 'ciphertext-1', 'Default', 1, 2)", "INSERT INTO api_keys (id, user_id, key_hash, key_encrypted, name, created_at, updated_at) VALUES (?, ?, ?, 'ciphertext-1', 'Default', 1, 2)",
) )
@@ -2566,6 +2731,14 @@ VALUES ('request-1', 'request-1', 'user-1', 'Provider One', 'gpt-test', 'complet
.rows(ExportDomain::Users) .rows(ExportDomain::Users)
.iter() .iter()
.any(|row| row.id == user_id)); .any(|row| row.id == user_id));
assert!(import_plan
.rows(ExportDomain::UserGroups)
.iter()
.any(|row| row.id == group_id));
assert!(import_plan
.rows(ExportDomain::UserGroupMembers)
.iter()
.any(|row| row.id == format!("{group_id}:{user_id}")));
assert!(import_plan assert!(import_plan
.rows(ExportDomain::ApiKeys) .rows(ExportDomain::ApiKeys)
.iter() .iter()
@@ -2585,6 +2758,8 @@ VALUES ('request-1', 'request-1', 'user-1', 'Provider One', 'gpt-test', 'complet
&pool, &pool,
vec![ vec![
ExportDomain::Users, ExportDomain::Users,
ExportDomain::UserGroups,
ExportDomain::UserGroupMembers,
ExportDomain::ApiKeys, ExportDomain::ApiKeys,
ExportDomain::ProviderKeys, ExportDomain::ProviderKeys,
ExportDomain::Usage, ExportDomain::Usage,
@@ -2596,7 +2771,7 @@ VALUES ('request-1', 'request-1', 'user-1', 'Provider One', 'gpt-test', 'complet
let imported = import_mysql_jsonl(&pool, &selected_export) let imported = import_mysql_jsonl(&pool, &selected_export)
.await .await
.expect("mysql import should be idempotent"); .expect("mysql import should be idempotent");
assert!(imported >= 4); assert!(imported >= 6);
let imported_api_key = let imported_api_key =
sqlx::query_as::<_, (String,)>("SELECT key_encrypted FROM api_keys WHERE id = ?") sqlx::query_as::<_, (String,)>("SELECT key_encrypted FROM api_keys WHERE id = ?")

View File

@@ -356,6 +356,41 @@ fn memory_group_members(
.collect() .collect()
} }
fn memory_export_row_from_auth_user(
repository: &InMemoryUserReadRepository,
user: &StoredUserAuthRecord,
) -> Result<StoredUserExportRow, DataLayerError> {
let model_capability_settings = repository
.model_settings_by_user_id
.read()
.expect("user repository lock")
.get(&user.id)
.cloned();
StoredUserExportRow::new(
user.id.clone(),
user.email.clone(),
user.email_verified,
user.username.clone(),
user.password_hash.clone(),
user.role.clone(),
user.auth_source.clone(),
user.allowed_providers.clone().map(serde_json::Value::from),
user.allowed_api_formats
.clone()
.map(serde_json::Value::from),
user.allowed_models.clone().map(serde_json::Value::from),
None,
model_capability_settings,
user.is_active,
)?
.with_policy_modes(
user.allowed_providers_mode.clone(),
user.allowed_api_formats_mode.clone(),
user.allowed_models_mode.clone(),
"system".to_string(),
)
}
#[async_trait] #[async_trait]
impl UserReadRepository for InMemoryUserReadRepository { impl UserReadRepository for InMemoryUserReadRepository {
async fn list_users_by_ids( async fn list_users_by_ids(
@@ -395,22 +430,36 @@ impl UserReadRepository for InMemoryUserReadRepository {
async fn list_non_admin_export_users( async fn list_non_admin_export_users(
&self, &self,
) -> Result<Vec<StoredUserExportRow>, DataLayerError> { ) -> Result<Vec<StoredUserExportRow>, DataLayerError> {
let rows = self.export_rows.read().expect("user repository lock");
if !rows.is_empty() {
return Ok(rows
.iter()
.filter(|row| !row.role.eq_ignore_ascii_case("admin"))
.cloned()
.collect());
}
Ok(self Ok(self
.export_rows .auth_by_id
.read() .read()
.expect("user repository lock") .expect("user repository lock")
.iter() .iter()
.filter(|row| !row.role.eq_ignore_ascii_case("admin")) .filter(|(_, user)| !user.role.eq_ignore_ascii_case("admin"))
.cloned() .map(|(_, user)| memory_export_row_from_auth_user(self, user))
.collect()) .collect::<Result<Vec<_>, _>>()?)
} }
async fn list_export_users(&self) -> Result<Vec<StoredUserExportRow>, DataLayerError> { async fn list_export_users(&self) -> Result<Vec<StoredUserExportRow>, DataLayerError> {
let rows = self.export_rows.read().expect("user repository lock");
if !rows.is_empty() {
return Ok(rows.clone());
}
Ok(self Ok(self
.export_rows .auth_by_id
.read() .read()
.expect("user repository lock") .expect("user repository lock")
.clone()) .values()
.map(|user| memory_export_row_from_auth_user(self, user))
.collect::<Result<Vec<_>, _>>()?)
} }
async fn list_export_users_page( async fn list_export_users_page(
@@ -500,10 +549,8 @@ impl UserReadRepository for InMemoryUserReadRepository {
.cloned() .cloned()
.collect::<Vec<_>>(); .collect::<Vec<_>>();
groups.sort_by(|left, right| { groups.sort_by(|left, right| {
right left.name
.priority .cmp(&right.name)
.cmp(&left.priority)
.then_with(|| left.name.cmp(&right.name))
.then_with(|| left.id.cmp(&right.id)) .then_with(|| left.id.cmp(&right.id))
}); });
Ok(groups) Ok(groups)
@@ -687,7 +734,6 @@ impl UserReadRepository for InMemoryUserReadRepository {
memberships.sort_by(|left, right| { memberships.sort_by(|left, right| {
left.user_id left.user_id
.cmp(&right.user_id) .cmp(&right.user_id)
.then_with(|| right.group_priority.cmp(&left.group_priority))
.then_with(|| left.group_name.cmp(&right.group_name)) .then_with(|| left.group_name.cmp(&right.group_name))
.then_with(|| left.group_id.cmp(&right.group_id)) .then_with(|| left.group_id.cmp(&right.group_id))
}); });

View File

@@ -370,7 +370,7 @@ WHERE is_deleted = 0
async fn list_user_groups(&self) -> Result<Vec<StoredUserGroup>, DataLayerError> { async fn list_user_groups(&self) -> Result<Vec<StoredUserGroup>, DataLayerError> {
let mut builder = QueryBuilder::<MySql>::new(USER_GROUP_COLUMNS); let mut builder = QueryBuilder::<MySql>::new(USER_GROUP_COLUMNS);
builder.push(" ORDER BY priority DESC, name ASC, id ASC"); builder.push(" ORDER BY name ASC, id ASC");
self.fetch_group_rows(builder).await self.fetch_group_rows(builder).await
} }
@@ -401,7 +401,7 @@ WHERE is_deleted = 0
separated.push_bind(group_id); separated.push_bind(group_id);
} }
} }
builder.push(") ORDER BY priority DESC, name ASC, id ASC"); builder.push(") ORDER BY name ASC, id ASC");
self.fetch_group_rows(builder).await self.fetch_group_rows(builder).await
} }
@@ -568,7 +568,7 @@ WHERE id = ?
builder builder
.push(" WHERE id IN (SELECT group_id FROM user_group_members WHERE user_id = ") .push(" WHERE id IN (SELECT group_id FROM user_group_members WHERE user_id = ")
.push_bind(user_id) .push_bind(user_id)
.push(") ORDER BY priority DESC, name ASC, id ASC"); .push(") ORDER BY name ASC, id ASC");
self.fetch_group_rows(builder).await self.fetch_group_rows(builder).await
} }
@@ -598,7 +598,9 @@ WHERE user_group_members.user_id IN (
separated.push_bind(user_id); separated.push_bind(user_id);
} }
} }
builder.push(") ORDER BY user_group_members.user_id ASC, user_groups.priority DESC, user_groups.name ASC, user_groups.id ASC"); builder.push(
") ORDER BY user_group_members.user_id ASC, user_groups.name ASC, user_groups.id ASC",
);
let rows = builder.build().fetch_all(&self.pool).await.map_sql_err()?; let rows = builder.build().fetch_all(&self.pool).await.map_sql_err()?;
rows.iter().map(map_user_group_membership_row).collect() rows.iter().map(map_user_group_membership_row).collect()
} }

View File

@@ -684,7 +684,7 @@ impl SqlxUserReadRepository {
pub async fn list_user_groups(&self) -> Result<Vec<StoredUserGroup>, DataLayerError> { pub async fn list_user_groups(&self) -> Result<Vec<StoredUserGroup>, DataLayerError> {
let mut builder = QueryBuilder::<Postgres>::new(USER_GROUP_COLUMNS); let mut builder = QueryBuilder::<Postgres>::new(USER_GROUP_COLUMNS);
builder.push(" ORDER BY priority DESC, name ASC, id ASC"); builder.push(" ORDER BY name ASC, id ASC");
collect_query_rows(builder.build().fetch(&self.pool), map_user_group_row).await collect_query_rows(builder.build().fetch(&self.pool), map_user_group_row).await
} }
@@ -720,7 +720,7 @@ impl SqlxUserReadRepository {
separated.push_bind(group_id); separated.push_bind(group_id);
} }
} }
builder.push(") ORDER BY priority DESC, name ASC, id ASC"); builder.push(") ORDER BY name ASC, id ASC");
collect_query_rows(builder.build().fetch(&self.pool), map_user_group_row).await collect_query_rows(builder.build().fetch(&self.pool), map_user_group_row).await
} }
@@ -872,7 +872,7 @@ WHERE id = $1
builder builder
.push(" WHERE id IN (SELECT group_id FROM user_group_members WHERE user_id = ") .push(" WHERE id IN (SELECT group_id FROM user_group_members WHERE user_id = ")
.push_bind(user_id) .push_bind(user_id)
.push(") ORDER BY priority DESC, name ASC, id ASC"); .push(") ORDER BY name ASC, id ASC");
collect_query_rows(builder.build().fetch(&self.pool), map_user_group_row).await collect_query_rows(builder.build().fetch(&self.pool), map_user_group_row).await
} }
@@ -902,7 +902,9 @@ WHERE user_group_members.user_id IN (
separated.push_bind(user_id); separated.push_bind(user_id);
} }
} }
builder.push(") ORDER BY user_group_members.user_id ASC, user_groups.priority DESC, user_groups.name ASC, user_groups.id ASC"); builder.push(
") ORDER BY user_group_members.user_id ASC, user_groups.name ASC, user_groups.id ASC",
);
collect_query_rows( collect_query_rows(
builder.build().fetch(&self.pool), builder.build().fetch(&self.pool),
map_user_group_membership_row, map_user_group_membership_row,

View File

@@ -370,7 +370,7 @@ WHERE is_deleted = 0
async fn list_user_groups(&self) -> Result<Vec<StoredUserGroup>, DataLayerError> { async fn list_user_groups(&self) -> Result<Vec<StoredUserGroup>, DataLayerError> {
let mut builder = QueryBuilder::<Sqlite>::new(USER_GROUP_COLUMNS); let mut builder = QueryBuilder::<Sqlite>::new(USER_GROUP_COLUMNS);
builder.push(" ORDER BY priority DESC, name ASC, id ASC"); builder.push(" ORDER BY name ASC, id ASC");
self.fetch_group_rows(builder).await self.fetch_group_rows(builder).await
} }
@@ -401,7 +401,7 @@ WHERE is_deleted = 0
separated.push_bind(group_id); separated.push_bind(group_id);
} }
} }
builder.push(") ORDER BY priority DESC, name ASC, id ASC"); builder.push(") ORDER BY name ASC, id ASC");
self.fetch_group_rows(builder).await self.fetch_group_rows(builder).await
} }
@@ -568,7 +568,7 @@ WHERE id = ?
builder builder
.push(" WHERE id IN (SELECT group_id FROM user_group_members WHERE user_id = ") .push(" WHERE id IN (SELECT group_id FROM user_group_members WHERE user_id = ")
.push_bind(user_id) .push_bind(user_id)
.push(") ORDER BY priority DESC, name ASC, id ASC"); .push(") ORDER BY name ASC, id ASC");
self.fetch_group_rows(builder).await self.fetch_group_rows(builder).await
} }
@@ -598,7 +598,9 @@ WHERE user_group_members.user_id IN (
separated.push_bind(user_id); separated.push_bind(user_id);
} }
} }
builder.push(") ORDER BY user_group_members.user_id ASC, user_groups.priority DESC, user_groups.name ASC, user_groups.id ASC"); builder.push(
") ORDER BY user_group_members.user_id ASC, user_groups.name ASC, user_groups.id ASC",
);
let rows = builder.build().fetch_all(&self.pool).await.map_sql_err()?; let rows = builder.build().fetch_all(&self.pool).await.map_sql_err()?;
rows.iter().map(map_user_group_membership_row).collect() rows.iter().map(map_user_group_membership_row).collect()
} }

View File

@@ -73,10 +73,25 @@ export interface ProxyNodeExport {
export interface UsersExportData { export interface UsersExportData {
version: string version: string
exported_at: string exported_at: string
user_groups?: UserGroupExport[]
users: UserExport[] users: UserExport[]
standalone_keys?: StandaloneKeyExport[] standalone_keys?: StandaloneKeyExport[]
} }
export interface UserGroupExport {
id?: string
name: string
description?: string | null
allowed_providers?: string[] | null
allowed_providers_mode?: 'inherit' | 'unrestricted' | 'specific' | 'deny_all'
allowed_api_formats?: string[] | null
allowed_api_formats_mode?: 'inherit' | 'unrestricted' | 'specific' | 'deny_all'
allowed_models?: string[] | null
allowed_models_mode?: 'inherit' | 'unrestricted' | 'specific' | 'deny_all'
rate_limit?: number | null
rate_limit_mode?: 'inherit' | 'system' | 'custom'
}
export interface UserExport { export interface UserExport {
email: string email: string
email_verified?: boolean email_verified?: boolean
@@ -84,10 +99,16 @@ export interface UserExport {
password_hash: string password_hash: string
role: string role: string
allowed_providers?: string[] | null allowed_providers?: string[] | null
allowed_providers_mode?: 'inherit' | 'unrestricted' | 'specific' | 'deny_all'
allowed_api_formats?: string[] | null allowed_api_formats?: string[] | null
allowed_api_formats_mode?: 'inherit' | 'unrestricted' | 'specific' | 'deny_all'
allowed_models?: string[] | null allowed_models?: string[] | null
allowed_models_mode?: 'inherit' | 'unrestricted' | 'specific' | 'deny_all'
rate_limit?: number | null // null = 跟随系统默认0 = 不限制 rate_limit?: number | null // null = 跟随系统默认0 = 不限制
rate_limit_mode?: 'inherit' | 'system' | 'custom'
model_capability_settings?: Record<string, Record<string, boolean>> model_capability_settings?: Record<string, Record<string, boolean>>
group_ids?: string[]
group_names?: string[]
unlimited?: boolean unlimited?: boolean
wallet?: BillingSummary | null wallet?: BillingSummary | null
is_active: boolean is_active: boolean
@@ -340,9 +361,10 @@ export interface UsersImportRequest extends UsersExportData {
export interface UsersImportResponse { export interface UsersImportResponse {
message: string message: string
stats: { stats: {
user_groups?: { created: number; updated: number; skipped: number }
users: { created: number; updated: number; skipped: number } users: { created: number; updated: number; skipped: number }
api_keys: { created: number; skipped: number } api_keys: { created: number; updated?: number; skipped: number }
standalone_keys?: { created: number; skipped: number } standalone_keys?: { created: number; updated?: number; skipped: number }
errors: string[] errors: string[]
} }
} }

View File

@@ -9,7 +9,6 @@ export type RateLimitPolicyMode = 'inherit' | 'system' | 'custom'
export interface UserGroupSummary { export interface UserGroupSummary {
id: string id: string
name: string name: string
priority: number
} }
export interface EffectivePolicyField<T> { export interface EffectivePolicyField<T> {
@@ -18,6 +17,8 @@ export interface EffectivePolicyField<T> {
source: 'user' | 'group' | 'fallback' | string source: 'user' | 'group' | 'fallback' | string
group_id?: string | null group_id?: string | null
group_name?: string | null group_name?: string | null
group_ids?: string[]
group_names?: string[]
} }
export interface UserEffectivePolicy { export interface UserEffectivePolicy {
@@ -58,14 +59,6 @@ export interface CreateUserRequest {
role?: UserRole role?: UserRole
initial_gift_usd?: number | null initial_gift_usd?: number | null
unlimited?: boolean unlimited?: boolean
allowed_providers?: string[] | null
allowed_providers_mode?: ListPolicyMode
allowed_api_formats?: string[] | null
allowed_api_formats_mode?: ListPolicyMode
allowed_models?: string[] | null
allowed_models_mode?: ListPolicyMode
rate_limit?: number | null
rate_limit_mode?: RateLimitPolicyMode
group_ids?: string[] group_ids?: string[]
} }
@@ -75,14 +68,6 @@ export interface UpdateUserRequest {
role?: UserRole role?: UserRole
unlimited?: boolean unlimited?: boolean
password?: string password?: string
allowed_providers?: string[] | null
allowed_providers_mode?: ListPolicyMode
allowed_api_formats?: string[] | null
allowed_api_formats_mode?: ListPolicyMode
allowed_models?: string[] | null
allowed_models_mode?: ListPolicyMode
rate_limit?: number | null
rate_limit_mode?: RateLimitPolicyMode
group_ids?: string[] group_ids?: string[]
} }
@@ -121,10 +106,6 @@ export interface ResolveUserBatchSelectionResponse {
} }
export interface UserBatchAccessControlPayload { export interface UserBatchAccessControlPayload {
allowed_providers?: string[] | null
allowed_api_formats?: string[] | null
allowed_models?: string[] | null
rate_limit?: number | null
unlimited?: boolean unlimited?: boolean
} }
@@ -179,7 +160,6 @@ export interface UserGroup {
name: string name: string
normalized_name?: string normalized_name?: string
description?: string | null description?: string | null
priority: number
allowed_providers?: string[] | null allowed_providers?: string[] | null
allowed_providers_mode: ListPolicyMode allowed_providers_mode: ListPolicyMode
allowed_api_formats?: string[] | null allowed_api_formats?: string[] | null
@@ -196,7 +176,6 @@ export interface UserGroup {
export interface UpsertUserGroupRequest { export interface UpsertUserGroupRequest {
name: string name: string
description?: string | null description?: string | null
priority?: number
allowed_providers?: string[] | null allowed_providers?: string[] | null
allowed_providers_mode?: ListPolicyMode allowed_providers_mode?: ListPolicyMode
allowed_api_formats?: string[] | null allowed_api_formats?: string[] | null

View File

@@ -2,7 +2,7 @@
<Dialog <Dialog
:model-value="open" :model-value="open"
title="用户批量操作" title="用户批量操作"
description="按当前选择批量调整用户状态、角色、访问控制和额度" description="按当前选择批量调整用户状态、角色和额度"
size="2xl" size="2xl"
persistent persistent
@update:model-value="handleDialogUpdate" @update:model-value="handleDialogUpdate"
@@ -19,7 +19,10 @@
{{ selectAllFiltered ? '目标为当前筛选条件匹配的全部用户,执行前后端会重新解析。' : '目标为当前已勾选的用户,重复 ID 会自动去重。' }} {{ selectAllFiltered ? '目标为当前筛选条件匹配的全部用户,执行前后端会重新解析。' : '目标为当前已勾选的用户,重复 ID 会自动去重。' }}
</p> </p>
</div> </div>
<Badge variant="secondary" class="shrink-0"> <Badge
variant="secondary"
class="shrink-0"
>
{{ selectAllFiltered ? '全选筛选结果' : '手动选择' }} {{ selectAllFiltered ? '全选筛选结果' : '手动选择' }}
</Badge> </Badge>
</div> </div>
@@ -55,7 +58,9 @@
<div class="grid gap-2 rounded-xl border border-border/70 bg-muted/20 p-3 sm:grid-cols-[9rem_minmax(0,1fr)] sm:items-start"> <div class="grid gap-2 rounded-xl border border-border/70 bg-muted/20 p-3 sm:grid-cols-[9rem_minmax(0,1fr)] sm:items-start">
<div> <div>
<Label class="text-sm font-medium">按分组选择</Label> <Label class="text-sm font-medium">按分组选择</Label>
<p class="mt-1 text-[11px] text-muted-foreground">可与直接用户或筛选条件混合</p> <p class="mt-1 text-[11px] text-muted-foreground">
可与直接用户或筛选条件混合
</p>
</div> </div>
<MultiSelect <MultiSelect
v-model="selectedGroupIds" v-model="selectedGroupIds"
@@ -80,7 +85,10 @@
> >
<span class="flex items-center gap-2"> <span class="flex items-center gap-2">
<span :class="actionIconClass(action.value)"> <span :class="actionIconClass(action.value)">
<component :is="action.icon" class="h-4 w-4" /> <component
:is="action.icon"
class="h-4 w-4"
/>
</span> </span>
<span class="font-medium text-foreground">{{ action.label }}</span> <span class="font-medium text-foreground">{{ action.label }}</span>
</span> </span>
@@ -100,7 +108,9 @@
<UserCog class="h-4 w-4" /> <UserCog class="h-4 w-4" />
</div> </div>
<div class="min-w-0 space-y-1"> <div class="min-w-0 space-y-1">
<h4 class="text-sm font-semibold text-foreground">批量修改用户角色</h4> <h4 class="text-sm font-semibold text-foreground">
批量修改用户角色
</h4>
<p class="text-xs leading-relaxed text-muted-foreground"> <p class="text-xs leading-relaxed text-muted-foreground">
将所选用户统一调整为同一个角色管理员角色拥有后台管理权限请确认选择范围 将所选用户统一调整为同一个角色管理员角色拥有后台管理权限请确认选择范围
</p> </p>
@@ -110,15 +120,21 @@
<div class="grid gap-3 rounded-xl border border-border/70 bg-muted/25 p-3 sm:grid-cols-[10rem_minmax(0,1fr)] sm:items-center"> <div class="grid gap-3 rounded-xl border border-border/70 bg-muted/25 p-3 sm:grid-cols-[10rem_minmax(0,1fr)] sm:items-center">
<div> <div>
<Label class="text-sm font-medium">目标角色</Label> <Label class="text-sm font-medium">目标角色</Label>
<p class="mt-1 text-[11px] text-muted-foreground">对所有目标用户生效</p> <p class="mt-1 text-[11px] text-muted-foreground">
对所有目标用户生效
</p>
</div> </div>
<Select v-model="targetRole"> <Select v-model="targetRole">
<SelectTrigger class="h-10 w-full"> <SelectTrigger class="h-10 w-full">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="user">普通用户</SelectItem> <SelectItem value="user">
<SelectItem value="admin">管理员</SelectItem> 普通用户
</SelectItem>
<SelectItem value="admin">
管理员
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -137,151 +153,39 @@
<ShieldCheck class="h-4 w-4" /> <ShieldCheck class="h-4 w-4" />
</div> </div>
<div class="min-w-0 space-y-1"> <div class="min-w-0 space-y-1">
<h4 class="text-sm font-semibold text-foreground">批量设置访问控制与额度</h4> <h4 class="text-sm font-semibold text-foreground">
批量设置额度
</h4>
<p class="text-xs leading-relaxed text-muted-foreground"> <p class="text-xs leading-relaxed text-muted-foreground">
每个字段都可独立选择不修改 / 不限制 / 指定列表指定列表为空表示全部禁用 额度仍然属于用户账户属性模型端点提供商和限速请通过用户组管理
</p> </p>
</div> </div>
</div> </div>
<div class="grid gap-3"> <div class="rounded-xl border border-border/70 bg-muted/20 p-3">
<div class="rounded-xl border border-border/70 bg-muted/20 p-3"> <div class="space-y-2">
<div class="grid gap-3 lg:grid-cols-[9rem_minmax(0,1fr)] lg:items-start"> <div>
<div> <Label class="text-sm font-medium">额度</Label>
<Label class="text-sm font-medium">允许的提供商</Label> <p class="mt-1 text-[11px] text-muted-foreground">
<p class="mt-1 text-[11px] text-muted-foreground">控制可使用的供应商</p> 对所有目标用户生效
</div> </p>
<div class="grid gap-2 sm:grid-cols-[9rem_minmax(0,1fr)]">
<Select v-model="providerMode">
<SelectTrigger class="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="skip">不修改</SelectItem>
<SelectItem value="unrestricted">不限制</SelectItem>
<SelectItem value="specific">指定列表</SelectItem>
</SelectContent>
</Select>
<MultiSelect
v-model="allowedProviders"
:options="providerOptions"
:disabled="providerMode !== 'specific'"
:search-threshold="0"
placeholder="未选择时表示全部禁用"
empty-text="暂无可用提供商"
/>
</div>
</div>
</div>
<div class="rounded-xl border border-border/70 bg-muted/20 p-3">
<div class="grid gap-3 lg:grid-cols-[9rem_minmax(0,1fr)] lg:items-start">
<div>
<Label class="text-sm font-medium">允许的端点</Label>
<p class="mt-1 text-[11px] text-muted-foreground">控制 API 格式入口</p>
</div>
<div class="grid gap-2 sm:grid-cols-[9rem_minmax(0,1fr)]">
<Select v-model="apiFormatMode">
<SelectTrigger class="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="skip">不修改</SelectItem>
<SelectItem value="unrestricted">不限制</SelectItem>
<SelectItem value="specific">指定列表</SelectItem>
</SelectContent>
</Select>
<MultiSelect
v-model="allowedApiFormats"
:options="apiFormatOptions"
:disabled="apiFormatMode !== 'specific'"
:search-threshold="0"
placeholder="未选择时表示全部禁用"
empty-text="暂无可用端点"
/>
</div>
</div>
</div>
<div class="rounded-xl border border-border/70 bg-muted/20 p-3">
<div class="grid gap-3 lg:grid-cols-[9rem_minmax(0,1fr)] lg:items-start">
<div>
<Label class="text-sm font-medium">允许的模型</Label>
<p class="mt-1 text-[11px] text-muted-foreground">控制模型白名单</p>
</div>
<div class="grid gap-2 sm:grid-cols-[9rem_minmax(0,1fr)]">
<Select v-model="modelMode">
<SelectTrigger class="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="skip">不修改</SelectItem>
<SelectItem value="unrestricted">不限制</SelectItem>
<SelectItem value="specific">指定列表</SelectItem>
</SelectContent>
</Select>
<MultiSelect
v-model="allowedModels"
:options="modelOptions"
:disabled="modelMode !== 'specific'"
:search-threshold="0"
placeholder="未选择时表示全部禁用"
empty-text="暂无可用模型"
/>
</div>
</div>
</div>
<div class="grid gap-3 md:grid-cols-2">
<div class="rounded-xl border border-border/70 bg-muted/20 p-3">
<div class="space-y-2">
<div>
<Label class="text-sm font-medium">速率限制</Label>
<p class="mt-1 text-[11px] text-muted-foreground">请求/分钟0 表示不限速</p>
</div>
<div class="grid gap-2 sm:grid-cols-[9rem_minmax(0,1fr)] md:grid-cols-1 xl:grid-cols-[9rem_minmax(0,1fr)]">
<Select v-model="rateLimitMode">
<SelectTrigger class="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="skip">不修改</SelectItem>
<SelectItem value="inherit">跟随默认</SelectItem>
<SelectItem value="custom">指定数值</SelectItem>
</SelectContent>
</Select>
<Input
:model-value="rateLimit ?? ''"
type="number"
min="0"
max="10000"
class="h-9"
:disabled="rateLimitMode !== 'custom'"
placeholder="0 = 不限速"
@update:model-value="(value) => rateLimit = parseNumberInput(value, { min: 0, max: 10000 })"
/>
</div>
</div>
</div>
<div class="rounded-xl border border-border/70 bg-muted/20 p-3">
<div class="space-y-2">
<div>
<Label class="text-sm font-medium">额度</Label>
<p class="mt-1 text-[11px] text-muted-foreground">与单用户编辑保持一致</p>
</div>
<Select v-model="quotaMode">
<SelectTrigger class="h-9 w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="skip">不修改</SelectItem>
<SelectItem value="wallet">按钱包余额限制</SelectItem>
<SelectItem value="unlimited">无限额度</SelectItem>
</SelectContent>
</Select>
</div>
</div> </div>
<Select v-model="quotaMode">
<SelectTrigger class="h-9 w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="skip">
不修改
</SelectItem>
<SelectItem value="wallet">
按钱包余额限制
</SelectItem>
<SelectItem value="unlimited">
无限额度
</SelectItem>
</SelectContent>
</Select>
</div> </div>
</div> </div>
</div> </div>
@@ -328,7 +232,6 @@ import {
Dialog, Dialog,
Button, Button,
Badge, Badge,
Input,
Label, Label,
Select, Select,
SelectTrigger, SelectTrigger,
@@ -340,9 +243,7 @@ import { MultiSelect } from '@/components/common'
import { useUsersStore } from '@/stores/users' import { useUsersStore } from '@/stores/users'
import { useToast } from '@/composables/useToast' import { useToast } from '@/composables/useToast'
import { parseApiError } from '@/utils/errorParser' import { parseApiError } from '@/utils/errorParser'
import { parseNumberInput } from '@/utils/form'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useUserAccessControlOptions } from '@/features/users/composables/useUserAccessControlOptions'
import type { import type {
UserBatchAccessControlPayload, UserBatchAccessControlPayload,
UserBatchAction, UserBatchAction,
@@ -356,8 +257,6 @@ import type {
UserGroup, UserGroup,
} from '@/api/users' } from '@/api/users'
type AccessFieldMode = 'skip' | 'unrestricted' | 'specific'
type RateLimitMode = 'skip' | 'inherit' | 'custom'
type QuotaMode = 'skip' | 'wallet' | 'unlimited' type QuotaMode = 'skip' | 'wallet' | 'unlimited'
interface ActionOption { interface ActionOption {
@@ -383,12 +282,6 @@ const emit = defineEmits<{
const usersStore = useUsersStore() const usersStore = useUsersStore()
const { success, warning, error } = useToast() const { success, warning, error } = useToast()
const {
providerOptions,
apiFormatOptions,
modelOptions,
loadAccessControlOptions,
} = useUserAccessControlOptions()
const actionOptions: ActionOption[] = [ const actionOptions: ActionOption[] = [
{ {
@@ -405,8 +298,8 @@ const actionOptions: ActionOption[] = [
}, },
{ {
value: 'update_access_control', value: 'update_access_control',
label: '访问控制', label: '额度',
description: '提供商、端点、模型、限速和额度', description: '批量调整用户额度模式',
icon: ShieldCheck, icon: ShieldCheck,
}, },
{ {
@@ -419,16 +312,8 @@ const actionOptions: ActionOption[] = [
const selectedAction = ref<UserBatchAction>('enable') const selectedAction = ref<UserBatchAction>('enable')
const targetRole = ref<UserRole>('user') const targetRole = ref<UserRole>('user')
const providerMode = ref<AccessFieldMode>('skip')
const apiFormatMode = ref<AccessFieldMode>('skip')
const modelMode = ref<AccessFieldMode>('skip')
const rateLimitMode = ref<RateLimitMode>('skip')
const quotaMode = ref<QuotaMode>('skip') const quotaMode = ref<QuotaMode>('skip')
const selectedGroupIds = ref<string[]>([]) const selectedGroupIds = ref<string[]>([])
const allowedProviders = ref<string[]>([])
const allowedApiFormats = ref<string[]>([])
const allowedModels = ref<string[]>([])
const rateLimit = ref<number | undefined>(undefined)
const previewLoading = ref(false) const previewLoading = ref(false)
const previewItems = ref<UserBatchSelectionItem[]>([]) const previewItems = ref<UserBatchSelectionItem[]>([])
const resolvedTotal = ref<number | null>(null) const resolvedTotal = ref<number | null>(null)
@@ -452,9 +337,6 @@ watch(
(open) => { (open) => {
if (!open) return if (!open) return
resetLocalState() resetLocalState()
void loadAccessControlOptions().catch((err) => {
error(parseApiError(err, '加载访问控制选项失败'))
})
void resolvePreview() void resolvePreview()
}, },
) )
@@ -473,16 +355,8 @@ function handleDialogUpdate(value: boolean): void {
function resetLocalState(): void { function resetLocalState(): void {
selectedAction.value = 'enable' selectedAction.value = 'enable'
targetRole.value = 'user' targetRole.value = 'user'
providerMode.value = 'skip'
apiFormatMode.value = 'skip'
modelMode.value = 'skip'
rateLimitMode.value = 'skip'
quotaMode.value = 'skip' quotaMode.value = 'skip'
selectedGroupIds.value = [] selectedGroupIds.value = []
allowedProviders.value = []
allowedApiFormats.value = []
allowedModels.value = []
rateLimit.value = undefined
lastResult.value = null lastResult.value = null
} }
@@ -538,14 +412,6 @@ watch(selectedGroupIds, () => {
function buildAccessControlPayload(): UserBatchAccessControlPayload | null { function buildAccessControlPayload(): UserBatchAccessControlPayload | null {
const payload: UserBatchAccessControlPayload = {} const payload: UserBatchAccessControlPayload = {}
if (providerMode.value === 'unrestricted') payload.allowed_providers = null
if (providerMode.value === 'specific') payload.allowed_providers = [...allowedProviders.value]
if (apiFormatMode.value === 'unrestricted') payload.allowed_api_formats = null
if (apiFormatMode.value === 'specific') payload.allowed_api_formats = [...allowedApiFormats.value]
if (modelMode.value === 'unrestricted') payload.allowed_models = null
if (modelMode.value === 'specific') payload.allowed_models = [...allowedModels.value]
if (rateLimitMode.value === 'inherit') payload.rate_limit = null
if (rateLimitMode.value === 'custom' && rateLimit.value != null) payload.rate_limit = rateLimit.value
if (quotaMode.value === 'wallet') payload.unlimited = false if (quotaMode.value === 'wallet') payload.unlimited = false
if (quotaMode.value === 'unlimited') payload.unlimited = true if (quotaMode.value === 'unlimited') payload.unlimited = true
return Object.keys(payload).length > 0 ? payload : null return Object.keys(payload).length > 0 ? payload : null
@@ -557,16 +423,12 @@ function buildRolePayload(): UserBatchRolePayload {
async function executeBatchAction(): Promise<void> { async function executeBatchAction(): Promise<void> {
if (!canExecute.value) return if (!canExecute.value) return
if (selectedAction.value === 'update_access_control' && rateLimitMode.value === 'custom' && rateLimit.value == null) {
warning('请输入速率限制数值0 表示不限速')
return
}
const selection = buildSelection() const selection = buildSelection()
let request: UserBatchActionRequest let request: UserBatchActionRequest
if (selectedAction.value === 'update_access_control') { if (selectedAction.value === 'update_access_control') {
const payload = buildAccessControlPayload() const payload = buildAccessControlPayload()
if (payload === null) { if (payload === null) {
warning('请至少选择一个要修改的访问控制或额度字段') warning('请选择要修改的额度')
return return
} }
request = { selection, action: 'update_access_control', payload } request = { selection, action: 'update_access_control', payload }

View File

@@ -1,7 +1,7 @@
<template> <template>
<Dialog <Dialog
:model-value="isOpen" :model-value="isOpen"
size="2xl" size="xl"
@update:model-value="handleDialogUpdate" @update:model-value="handleDialogUpdate"
> >
<template #header> <template #header>
@@ -35,13 +35,8 @@
autocomplete="off" autocomplete="off"
@submit.prevent="handleSubmit" @submit.prevent="handleSubmit"
> >
<div class="grid grid-cols-2 gap-0"> <div class="space-y-5">
<!-- 左侧基础设置 --> <div class="grid gap-4 sm:grid-cols-2">
<div class="pr-6 space-y-4">
<div class="flex items-center gap-2 pb-2 border-b border-border/60">
<span class="text-sm font-medium">基础设置</span>
</div>
<div class="space-y-2"> <div class="space-y-2">
<Label <Label
for="form-username" for="form-username"
@@ -63,97 +58,6 @@
> >
{{ usernameError }} {{ usernameError }}
</p> </p>
<p
v-else
class="text-xs text-muted-foreground"
>
3-30个字符允许字母数字下划线连字符和点号
</p>
</div>
<div class="space-y-2">
<Label class="text-sm font-medium">
{{ isEditMode ? '新密码 (留空保持不变)' : '密码' }}
<span
v-if="!isEditMode"
class="text-muted-foreground"
>*</span>
</Label>
<Input
:id="`pwd-${formNonce}`"
v-model="form.password"
type="text"
masked
autocomplete="new-password"
disable-autofill
:name="`field-${formNonce}`"
:required="!isEditMode"
minlength="6"
:placeholder="isEditMode ? '留空保持原密码' : getPasswordPolicyPlaceholder(passwordPolicyLevel)"
class="h-10"
:class="[
passwordError ? 'border-destructive' : '',
]"
/>
<p
v-if="passwordError"
class="text-xs text-destructive"
>
{{ passwordError }}
</p>
<p
v-else-if="!isEditMode"
class="text-xs text-muted-foreground"
>
{{ passwordHint }}
</p>
</div>
<div
v-if="isEditMode && form.password.length > 0"
class="space-y-2"
>
<Label class="text-sm font-medium">
确认新密码 <span class="text-muted-foreground">*</span>
</Label>
<Input
:id="`pwd-confirm-${formNonce}`"
v-model="form.confirmPassword"
type="text"
masked
autocomplete="new-password"
data-form-type="other"
data-lpignore="true"
:name="`confirm-${formNonce}`"
required
minlength="6"
placeholder="再次输入新密码"
class="h-10"
/>
<p
v-if="
form.confirmPassword.length > 0 &&
form.password !== form.confirmPassword
"
class="text-xs text-destructive"
>
两次输入的密码不一致
</p>
</div>
<div class="space-y-2">
<Label
for="form-email"
class="text-sm font-medium"
>邮箱</Label>
<Input
id="form-email"
v-model="form.email"
type="email"
autocomplete="off"
data-form-type="other"
class="h-10"
/>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
@@ -180,168 +84,129 @@
</Select> </Select>
</div> </div>
</div> </div>
<div class="space-y-2">
<Label class="text-sm font-medium">所属分组</Label>
<MultiSelect
v-model="form.group_ids"
:options="groupOptions"
:search-threshold="0"
placeholder="可选择多个分组"
empty-text="暂无分组"
no-results-text="未找到匹配的分组"
/>
</div>
</div> </div>
<!-- 右侧:访问限制 --> <div class="space-y-2">
<div class="pl-6 space-y-4 border-l border-border"> <Label
<div class="flex items-center gap-2 pb-2 border-b border-border/60"> for="form-email"
<span class="text-sm font-medium">访问限制</span> class="text-sm font-medium"
</div> >邮箱</Label>
<Input
id="form-email"
v-model="form.email"
type="email"
autocomplete="off"
data-form-type="other"
class="h-10"
/>
</div>
<!-- 提供商 --> <div class="space-y-2">
<div class="space-y-2"> <Label class="text-sm font-medium">
<Label class="text-sm font-medium">允许的提供商</Label> {{ isEditMode ? '新密码 (留空保持不变)' : '密码' }}
<div class="grid gap-2 sm:grid-cols-[7rem_minmax(0,1fr)]"> <span
<Select v-model="form.allowed_providers_mode"> v-if="!isEditMode"
<SelectTrigger class="h-10"> class="text-muted-foreground"
<SelectValue /> >*</span>
</SelectTrigger> </Label>
<SelectContent> <Input
<SelectItem value="inherit">继承</SelectItem> :id="`pwd-${formNonce}`"
<SelectItem value="unrestricted">不限制</SelectItem> v-model="form.password"
<SelectItem value="specific">指定列表</SelectItem> type="text"
<SelectItem value="deny_all">全部禁用</SelectItem> masked
</SelectContent> autocomplete="new-password"
</Select> disable-autofill
<MultiSelect :name="`field-${formNonce}`"
v-model="form.allowed_providers" :required="!isEditMode"
:options="providerOptions" minlength="6"
:search-threshold="0" :placeholder="isEditMode ? '留空保持原密码' : getPasswordPolicyPlaceholder(passwordPolicyLevel)"
:disabled="form.allowed_providers_mode !== 'specific'" class="h-10"
placeholder="未选择时表示全部禁用" :class="[
empty-text="暂无可用提供商" passwordError ? 'border-destructive' : '',
no-results-text="未找到匹配的提供商" ]"
search-placeholder="搜索提供商名称..." />
/> <p
</div> v-if="passwordError"
</div> class="text-xs text-destructive"
>
<!-- 端点 --> {{ passwordError }}
<div class="space-y-2"> </p>
<Label class="text-sm font-medium">允许的端点</Label> <p
<div class="grid gap-2 sm:grid-cols-[7rem_minmax(0,1fr)]"> v-else-if="!isEditMode"
<Select v-model="form.allowed_api_formats_mode"> class="text-xs text-muted-foreground"
<SelectTrigger class="h-10"> >
<SelectValue /> {{ passwordHint }}
</SelectTrigger> </p>
<SelectContent> </div>
<SelectItem value="inherit">继承</SelectItem>
<SelectItem value="unrestricted">不限制</SelectItem> <div
<SelectItem value="specific">指定列表</SelectItem> v-if="isEditMode && form.password.length > 0"
<SelectItem value="deny_all">全部禁用</SelectItem> class="space-y-2"
</SelectContent> >
</Select> <Label class="text-sm font-medium">
<MultiSelect 确认新密码 <span class="text-muted-foreground">*</span>
v-model="form.allowed_api_formats" </Label>
:options="apiFormatOptions" <Input
:search-threshold="0" :id="`pwd-confirm-${formNonce}`"
:disabled="form.allowed_api_formats_mode !== 'specific'" v-model="form.confirmPassword"
placeholder="未选择时表示全部禁用" type="text"
empty-text="暂无可用端点" masked
no-results-text="未找到匹配的端点" autocomplete="new-password"
search-placeholder="搜索端点..." data-form-type="other"
/> data-lpignore="true"
</div> :name="`confirm-${formNonce}`"
</div> required
minlength="6"
<!-- 模型 --> placeholder="再次输入新密码"
<div class="space-y-2"> class="h-10"
<Label class="text-sm font-medium">允许的模型</Label> />
<div class="grid gap-2 sm:grid-cols-[7rem_minmax(0,1fr)]"> <p
<Select v-model="form.allowed_models_mode"> v-if="
<SelectTrigger class="h-10"> form.confirmPassword.length > 0 &&
<SelectValue /> form.password !== form.confirmPassword
</SelectTrigger> "
<SelectContent> class="text-xs text-destructive"
<SelectItem value="inherit">继承</SelectItem> >
<SelectItem value="unrestricted">不限制</SelectItem> 两次输入的密码不一致
<SelectItem value="specific">指定列表</SelectItem> </p>
<SelectItem value="deny_all">全部禁用</SelectItem> </div>
</SelectContent>
</Select> <div class="space-y-2">
<MultiSelect <Label class="text-sm font-medium">所属分组</Label>
v-model="form.allowed_models" <MultiSelect
:options="modelOptions" v-model="form.group_ids"
:search-threshold="0" :options="groupOptions"
:disabled="form.allowed_models_mode !== 'specific'" :search-threshold="0"
placeholder="选择时表示全部禁用" placeholder="选择多个分组"
empty-text="暂无可用模型" empty-text="暂无分组"
no-results-text="未找到匹配的模型" no-results-text="未找到匹配的分组"
search-placeholder="输入模型名搜索..." />
/> </div>
</div>
</div> <div class="space-y-2">
<Label class="text-sm font-medium">额度</Label>
<div class="space-y-2"> <div class="flex items-center gap-3">
<Label <div class="flex-1 min-w-0">
for="form-rate-limit" <Input
class="text-sm font-medium" v-if="!isEditMode && !form.unlimited"
>速率限制 (请求/分钟)</Label> id="form-initial-gift"
<div class="flex items-center gap-3"> :model-value="form.initial_gift_usd ?? ''"
<Select v-model="form.rate_limit_mode"> type="number"
<SelectTrigger class="h-10 w-28"> step="0.01"
<SelectValue /> min="0.01"
</SelectTrigger> placeholder="初始额度 (USD)"
<SelectContent> class="h-10"
<SelectItem value="inherit">继承</SelectItem> @update:model-value="(v) => form.initial_gift_usd = parseNumberInput(v, { allowFloat: true, min: 0.01 })"
<SelectItem value="system">系统默认</SelectItem>
<SelectItem value="custom">指定数值</SelectItem>
</SelectContent>
</Select>
<div class="flex-1 min-w-0">
<Input
id="form-rate-limit"
:model-value="form.rate_limit ?? ''"
type="number"
min="0"
max="10000"
placeholder="0 = 不限速"
class="h-10"
:disabled="form.rate_limit_mode !== 'custom'"
@update:model-value="(v) => form.rate_limit = parseNumberInput(v, { min: 0, max: 10000 })"
/>
</div>
</div>
</div>
<!-- 额度 -->
<div class="space-y-2">
<Label class="text-sm font-medium">额度</Label>
<div class="flex items-center gap-3">
<div class="flex-1 min-w-0">
<Input
v-if="!isEditMode && !form.unlimited"
id="form-initial-gift"
:model-value="form.initial_gift_usd ?? ''"
type="number"
step="0.01"
min="0.01"
placeholder="初始额度 (USD)"
class="h-10"
@update:model-value="(v) => form.initial_gift_usd = parseNumberInput(v, { allowFloat: true, min: 0.01 })"
/>
<span
v-else
class="flex h-10 w-full items-center rounded-lg border bg-background px-3 text-sm text-muted-foreground opacity-60"
>{{ form.unlimited ? '无限制' : '按钱包余额限制' }}</span>
</div>
<Switch
v-model="form.unlimited"
class="shrink-0"
/> />
<span
v-else
class="flex h-10 w-full items-center rounded-lg border bg-background px-3 text-sm text-muted-foreground opacity-60"
>{{ form.unlimited ? '无限制' : '按钱包余额限制' }}</span>
</div> </div>
<Switch
v-model="form.unlimited"
class="shrink-0"
/>
</div> </div>
</div> </div>
</div> </div>
@@ -387,7 +252,6 @@ import { MultiSelect } from '@/components/common'
import { adminApi } from '@/api/admin' import { adminApi } from '@/api/admin'
import { log } from '@/utils/logger' import { log } from '@/utils/logger'
import { parseNumberInput } from '@/utils/form' import { parseNumberInput } from '@/utils/form'
import { useUserAccessControlOptions } from '@/features/users/composables/useUserAccessControlOptions'
import { import {
getPasswordPolicyHint, getPasswordPolicyHint,
getPasswordPolicyPlaceholder, getPasswordPolicyPlaceholder,
@@ -395,7 +259,7 @@ import {
validatePasswordByPolicy, validatePasswordByPolicy,
type PasswordPolicyLevel, type PasswordPolicyLevel,
} from '@/utils/passwordPolicy' } from '@/utils/passwordPolicy'
import type { ListPolicyMode, RateLimitPolicyMode, UserGroup } from '@/api/users' import type { UserGroup } from '@/api/users'
export interface UserFormData { export interface UserFormData {
id?: string id?: string
@@ -405,14 +269,6 @@ export interface UserFormData {
unlimited?: boolean unlimited?: boolean
role: 'admin' | 'user' role: 'admin' | 'user'
is_active?: boolean is_active?: boolean
allowed_providers?: string[] | null
allowed_providers_mode?: ListPolicyMode
allowed_api_formats?: string[] | null
allowed_api_formats_mode?: ListPolicyMode
allowed_models?: string[] | null
allowed_models_mode?: ListPolicyMode
rate_limit?: number | null
rate_limit_mode?: RateLimitPolicyMode
group_ids?: string[] group_ids?: string[]
} }
@@ -432,13 +288,6 @@ const saving = ref(false)
const formNonce = ref(createFieldNonce()) const formNonce = ref(createFieldNonce())
const passwordPolicyLevel = ref<PasswordPolicyLevel>('weak') const passwordPolicyLevel = ref<PasswordPolicyLevel>('weak')
const {
providerOptions,
apiFormatOptions,
modelOptions,
loadAccessControlOptions: loadAccessControlOptionLists,
} = useUserAccessControlOptions()
// 表单数据 // 表单数据
const form = ref({ const form = ref({
username: '', username: '',
@@ -449,14 +298,6 @@ const form = ref({
role: 'user' as 'admin' | 'user', role: 'user' as 'admin' | 'user',
unlimited: false, unlimited: false,
is_active: true, is_active: true,
allowed_providers_mode: 'unrestricted' as ListPolicyMode,
allowed_api_formats_mode: 'unrestricted' as ListPolicyMode,
allowed_models_mode: 'unrestricted' as ListPolicyMode,
rate_limit_mode: 'system' as RateLimitPolicyMode,
allowed_providers: [] as string[],
allowed_api_formats: [] as string[],
allowed_models: [] as string[],
rate_limit: undefined as number | undefined,
group_ids: [] as string[], group_ids: [] as string[],
}) })
@@ -480,14 +321,6 @@ function resetForm() {
role: 'user', role: 'user',
unlimited: false, unlimited: false,
is_active: true, is_active: true,
allowed_providers_mode: 'unrestricted',
allowed_api_formats_mode: 'unrestricted',
allowed_models_mode: 'unrestricted',
rate_limit_mode: 'system',
allowed_providers: [],
allowed_api_formats: [],
allowed_models: [],
rate_limit: undefined,
group_ids: [], group_ids: [],
} }
} }
@@ -505,14 +338,6 @@ function loadUserData() {
role: props.user.role, role: props.user.role,
unlimited: props.user.unlimited ?? false, unlimited: props.user.unlimited ?? false,
is_active: props.user.is_active ?? true, is_active: props.user.is_active ?? true,
allowed_providers_mode: props.user.allowed_providers_mode ?? (props.user.allowed_providers == null ? 'unrestricted' : 'specific'),
allowed_api_formats_mode: props.user.allowed_api_formats_mode ?? (props.user.allowed_api_formats == null ? 'unrestricted' : 'specific'),
allowed_models_mode: props.user.allowed_models_mode ?? (props.user.allowed_models == null ? 'unrestricted' : 'specific'),
rate_limit_mode: props.user.rate_limit_mode ?? (props.user.rate_limit == null ? 'system' : 'custom'),
allowed_providers: props.user.allowed_providers ? [...props.user.allowed_providers] : [],
allowed_api_formats: props.user.allowed_api_formats ? [...props.user.allowed_api_formats] : [],
allowed_models: props.user.allowed_models ? [...props.user.allowed_models] : [],
rate_limit: props.user.rate_limit ?? undefined,
group_ids: props.user.group_ids ? [...props.user.group_ids] : [], group_ids: props.user.group_ids ? [...props.user.group_ids] : [],
} }
} }
@@ -566,16 +391,14 @@ const isFormValid = computed(() => {
}) })
// 加载访问控制选项 async function loadPasswordPolicy(): Promise<void> {
async function loadAccessControlOptions(): Promise<void> {
try { try {
const [, passwordPolicyResponse] = await Promise.all([ const passwordPolicyResponse = await adminApi
loadAccessControlOptionLists(), .getSystemConfig('password_policy_level')
adminApi.getSystemConfig('password_policy_level').catch(() => ({ value: 'weak' })), .catch(() => ({ value: 'weak' }))
])
passwordPolicyLevel.value = normalizePasswordPolicyLevel(passwordPolicyResponse.value) passwordPolicyLevel.value = normalizePasswordPolicyLevel(passwordPolicyResponse.value)
} catch (err) { } catch (err) {
log.error('加载访问限制选项失败:', err) log.error('加载密码策略失败:', err)
passwordPolicyLevel.value = 'weak' passwordPolicyLevel.value = 'weak'
} }
} }
@@ -589,20 +412,6 @@ async function handleSubmit() {
email: form.value.email.trim() || '', email: form.value.email.trim() || '',
unlimited: form.value.unlimited, unlimited: form.value.unlimited,
role: form.value.role, role: form.value.role,
allowed_providers: form.value.allowed_providers_mode === 'specific'
? [...form.value.allowed_providers]
: null,
allowed_providers_mode: form.value.allowed_providers_mode,
allowed_api_formats: form.value.allowed_api_formats_mode === 'specific'
? [...form.value.allowed_api_formats]
: null,
allowed_api_formats_mode: form.value.allowed_api_formats_mode,
allowed_models: form.value.allowed_models_mode === 'specific'
? [...form.value.allowed_models]
: null,
allowed_models_mode: form.value.allowed_models_mode,
rate_limit: form.value.rate_limit_mode === 'custom' ? (form.value.rate_limit ?? 0) : null,
rate_limit_mode: form.value.rate_limit_mode,
group_ids: [...form.value.group_ids], group_ids: [...form.value.group_ids],
} }
@@ -638,7 +447,7 @@ function setSaving(value: boolean) {
// 监听打开状态,加载选项数据 // 监听打开状态,加载选项数据
watch(isOpen, (val) => { watch(isOpen, (val) => {
if (val) { if (val) {
loadAccessControlOptions() loadPasswordPolicy()
} }
}) })

View File

@@ -3,21 +3,22 @@
:model-value="open" :model-value="open"
title="用户分组" title="用户分组"
description="管理用户组、默认注册组、成员和组级访问控制" description="管理用户组、默认注册组、成员和组级访问控制"
size="6xl" size="4xl"
persistent persistent
@update:model-value="handleDialogUpdate" @update:model-value="handleDialogUpdate"
> >
<div class="grid min-h-[560px] gap-4 lg:grid-cols-[17rem_minmax(0,1fr)]"> <div class="grid gap-4 lg:min-h-[560px] lg:grid-cols-[17rem_minmax(0,1fr)]">
<div class="rounded-xl border border-border/70 bg-muted/20 p-3"> <div class="rounded-xl border border-border/70 bg-muted/20 p-3">
<div class="mb-3 flex items-center justify-between gap-2"> <div class="mb-3 flex items-center justify-between gap-2">
<Label class="text-sm font-semibold">分组</Label> <Label class="text-sm font-semibold">分组</Label>
<Button <Button
size="sm" variant="ghost"
class="h-8 px-2 text-xs" size="icon"
class="h-8 w-8"
title="新建分组"
@click="startCreate" @click="startCreate"
> >
<Plus class="mr-1.5 h-3.5 w-3.5" /> <Plus class="h-4 w-4" />
新建
</Button> </Button>
</div> </div>
@@ -35,7 +36,7 @@
</div> </div>
<div <div
v-else v-else
class="space-y-1.5" class="max-h-60 space-y-1.5 overflow-y-auto lg:max-h-none lg:overflow-visible"
> >
<button <button
v-for="group in groups" v-for="group in groups"
@@ -55,81 +56,58 @@
默认 默认
</Badge> </Badge>
</span> </span>
<span class="mt-0.5 block text-[11px] text-muted-foreground">
优先级 {{ group.priority }}
</span>
</span> </span>
<ChevronRight class="h-4 w-4 shrink-0 text-muted-foreground" /> <ChevronRight class="h-4 w-4 shrink-0 text-muted-foreground" />
</button> </button>
</div> </div>
</div> </div>
<div class="min-w-0 rounded-xl border border-border/70 bg-background p-4"> <div class="min-w-0 rounded-xl border border-border/70 bg-background p-3 sm:p-4">
<div class="mb-4 flex flex-wrap items-center justify-between gap-3"> <div class="mb-4 flex flex-wrap items-center justify-between gap-3">
<div class="min-w-0"> <div class="min-w-0">
<h4 class="truncate text-base font-semibold text-foreground"> <h4 class="truncate text-base font-semibold text-foreground">
{{ editingGroupId ? '编辑分组' : '新建分组' }} {{ editingGroupId ? '编辑分组' : '新建分组' }}
</h4> </h4>
<p class="text-xs text-muted-foreground"> <p class="text-xs text-muted-foreground">
{{ selectedGroup?.is_default ? '当前为自助注册默认组' : '默认组只影响本地注册和 OAuth 自动创建用户' }} {{ selectedGroup?.is_default ? '当前为所有用户的默认组' : '通过额外分组配置访问限制' }}
</p> </p>
</div> </div>
<div class="flex items-center gap-2"> <div
v-if="editingGroupId"
class="flex items-center gap-1"
>
<Button <Button
v-if="editingGroupId" variant="ghost"
variant="outline" size="icon"
size="sm" class="h-8 w-8"
class="h-8 border-rose-200 px-2 text-xs text-rose-600 hover:bg-rose-50 dark:border-rose-900/60 dark:hover:bg-rose-950/40" :class="selectedGroup?.is_default ? 'text-emerald-500 hover:text-emerald-500' : ''"
:disabled="saving" :disabled="saving || selectedGroup?.is_default"
:title="selectedGroup?.is_default ? '默认注册组' : '设为默认注册组'"
@click="toggleDefault"
>
<BadgeCheck class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
:disabled="saving || selectedGroup?.is_default"
title="删除分组"
@click="deleteSelectedGroup" @click="deleteSelectedGroup"
> >
<Trash2 class="mr-1.5 h-3.5 w-3.5" /> <Trash2 class="h-4 w-4" />
删除
</Button> </Button>
</div> </div>
</div> </div>
<div class="grid gap-5 lg:grid-cols-2"> <div class="space-y-5">
<div class="space-y-4"> <div class="space-y-4">
<div class="grid gap-3 sm:grid-cols-[minmax(0,1fr)_8rem]">
<div class="space-y-2">
<Label class="text-sm font-medium">名称</Label>
<Input
v-model="form.name"
class="h-10"
placeholder="例如:生产团队"
/>
</div>
<div class="space-y-2">
<Label class="text-sm font-medium">优先级</Label>
<Input
:model-value="form.priority"
type="number"
class="h-10"
@update:model-value="(value) => form.priority = parseNumberInput(value, { min: -10000, max: 10000 }) ?? 0"
/>
</div>
</div>
<div class="flex items-center justify-between gap-3 rounded-lg border border-border/70 bg-muted/20 px-3 py-2">
<div class="min-w-0">
<Label class="text-sm font-medium">默认注册组</Label>
<div class="mt-0.5 text-[11px] text-muted-foreground">
本地注册 / OAuth 自动创建
</div>
</div>
<Switch
v-model="form.is_default"
class="shrink-0"
/>
</div>
<div class="space-y-2"> <div class="space-y-2">
<Label class="text-sm font-medium">描述</Label> <Label class="text-sm font-medium">名称</Label>
<Textarea <Input
v-model="form.description" v-model="form.name"
class="min-h-20" class="h-10"
placeholder="可选" placeholder="例如:生产团队"
/> />
</div> </div>
@@ -139,6 +117,7 @@
v-model="memberUserIds" v-model="memberUserIds"
:options="userOptions" :options="userOptions"
:search-threshold="0" :search-threshold="0"
:disabled="selectedGroup?.is_default"
placeholder="选择用户" placeholder="选择用户"
empty-text="暂无用户" empty-text="暂无用户"
no-results-text="未找到匹配用户" no-results-text="未找到匹配用户"
@@ -146,47 +125,92 @@
</div> </div>
</div> </div>
<div class="space-y-4 lg:border-l lg:border-border/60 lg:pl-5"> <div class="space-y-4 border-t border-border/60 pt-5">
<div class="flex items-baseline justify-between gap-2 pb-2 border-b border-border/60"> <div class="flex flex-wrap items-baseline justify-between gap-x-2 gap-y-1 pb-2 border-b border-border/60">
<span class="text-sm font-medium">组权限</span> <span class="text-sm font-medium">组权限</span>
<span class="text-[11px] text-muted-foreground"> <span class="text-[11px] text-muted-foreground">
用户选择继承时按优先级取首个已配置组 多个组与用户额外限制取交集
</span> </span>
</div> </div>
<PolicyFieldEditor <div class="space-y-2">
v-model:mode="form.allowed_providers_mode" <Label class="text-sm font-medium">允许的提供商</Label>
v-model:values="form.allowed_providers" <div class="flex flex-col gap-2 sm:flex-row sm:items-center">
label="允许的提供商" <div class="flex w-full items-center sm:w-auto sm:shrink-0">
:options="providerOptions" <Switch
/> :model-value="form.allowed_providers_mode === 'unrestricted'"
<PolicyFieldEditor :disabled="selectedGroup?.is_default"
v-model:mode="form.allowed_api_formats_mode" @update:model-value="(v) => (form.allowed_providers_mode = v ? 'unrestricted' : 'specific')"
v-model:values="form.allowed_api_formats" />
label="允许的端点" </div>
:options="apiFormatOptions" <div class="min-w-0 flex-1">
/> <MultiSelect
<PolicyFieldEditor v-model="form.allowed_providers"
v-model:mode="form.allowed_models_mode" :options="providerOptions"
v-model:values="form.allowed_models" :search-threshold="0"
label="允许的模型" :disabled="selectedGroup?.is_default || form.allowed_providers_mode === 'unrestricted'"
:options="modelOptions" :placeholder="form.allowed_providers_mode === 'unrestricted' ? '不限制所有选项' : '选择提供商'"
/> empty-text="暂无选项"
/>
</div>
</div>
</div>
<div class="space-y-2">
<Label class="text-sm font-medium">允许的端点</Label>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
<div class="flex w-full items-center sm:w-auto sm:shrink-0">
<Switch
:model-value="form.allowed_api_formats_mode === 'unrestricted'"
:disabled="selectedGroup?.is_default"
@update:model-value="(v) => (form.allowed_api_formats_mode = v ? 'unrestricted' : 'specific')"
/>
</div>
<div class="min-w-0 flex-1">
<MultiSelect
v-model="form.allowed_api_formats"
:options="apiFormatOptions"
:search-threshold="0"
:disabled="selectedGroup?.is_default || form.allowed_api_formats_mode === 'unrestricted'"
:placeholder="form.allowed_api_formats_mode === 'unrestricted' ? '不限制所有选项' : '选择端点'"
empty-text="暂无选项"
/>
</div>
</div>
</div>
<div class="space-y-2">
<Label class="text-sm font-medium">允许的模型</Label>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
<div class="flex w-full items-center sm:w-auto sm:shrink-0">
<Switch
:model-value="form.allowed_models_mode === 'unrestricted'"
:disabled="selectedGroup?.is_default"
@update:model-value="(v) => (form.allowed_models_mode = v ? 'unrestricted' : 'specific')"
/>
</div>
<div class="min-w-0 flex-1">
<MultiSelect
v-model="form.allowed_models"
:options="modelOptions"
:search-threshold="0"
:disabled="selectedGroup?.is_default || form.allowed_models_mode === 'unrestricted'"
:placeholder="form.allowed_models_mode === 'unrestricted' ? '不限制所有选项' : '选择模型'"
empty-text="暂无选项"
/>
</div>
</div>
</div>
<div class="space-y-2"> <div class="space-y-2">
<Label class="text-sm font-medium">速率限制 (请求/分钟)</Label> <Label class="text-sm font-medium">速率限制 (请求/分钟)</Label>
<div class="flex items-start gap-2"> <div class="flex flex-col gap-2 sm:flex-row sm:items-center">
<div class="w-28 shrink-0"> <div class="flex w-full items-center sm:w-auto sm:shrink-0">
<Select v-model="form.rate_limit_mode"> <Switch
<SelectTrigger class="h-10 w-full"> :model-value="form.rate_limit_mode === 'system'"
<SelectValue /> :disabled="selectedGroup?.is_default"
</SelectTrigger> @update:model-value="(v) => (form.rate_limit_mode = v ? 'system' : 'custom')"
<SelectContent> />
<SelectItem value="inherit">不配置</SelectItem>
<SelectItem value="system">系统默认</SelectItem>
<SelectItem value="custom">指定数值</SelectItem>
</SelectContent>
</Select>
</div> </div>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<Input <Input
@@ -195,8 +219,8 @@
min="0" min="0"
max="10000" max="10000"
class="h-10" class="h-10"
:disabled="form.rate_limit_mode !== 'custom'" :disabled="selectedGroup?.is_default || form.rate_limit_mode === 'system'"
:placeholder="rateLimitPlaceholder" :placeholder="form.rate_limit_mode === 'system' ? '使用系统默认' : '0 = 不限速'"
@update:model-value="(value) => form.rate_limit = parseNumberInput(value, { min: 0, max: 10000 })" @update:model-value="(value) => form.rate_limit = parseNumberInput(value, { min: 0, max: 10000 })"
/> />
</div> </div>
@@ -216,31 +240,25 @@
关闭 关闭
</Button> </Button>
<Button <Button
:disabled="saving || !form.name.trim()" :disabled="saving || !form.name.trim() || defaultGroupHasRestrictions"
@click="saveGroup" @click="saveGroup"
> >
{{ saving ? '保存中...' : '保存分组' }} 保存
</Button> </Button>
</template> </template>
</Dialog> </Dialog>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, defineComponent, h, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { ChevronRight, Plus, Trash2 } from 'lucide-vue-next' import { BadgeCheck, ChevronRight, Plus, Trash2 } from 'lucide-vue-next'
import { import {
Badge, Badge,
Button, Button,
Dialog, Dialog,
Input, Input,
Label, Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Switch, Switch,
Textarea,
} from '@/components/ui' } from '@/components/ui'
import { MultiSelect } from '@/components/common' import { MultiSelect } from '@/components/common'
import { useUsersStore } from '@/stores/users' import { useUsersStore } from '@/stores/users'
@@ -258,64 +276,6 @@ import type {
UserGroup, UserGroup,
} from '@/api/users' } from '@/api/users'
const PolicyFieldEditor = defineComponent({
name: 'PolicyFieldEditor',
props: {
label: { type: String, required: true },
mode: { type: String as () => ListPolicyMode, required: true },
values: { type: Array as () => string[], required: true },
options: { type: Array as () => Array<{ label: string; value: string }>, required: true },
},
emits: ['update:mode', 'update:values'],
setup(props, { emit }) {
return () => h('div', { class: 'space-y-2' }, [
h(Label, { class: 'text-sm font-medium' }, () => props.label),
h('div', { class: 'flex items-start gap-2' }, [
h('div', { class: 'w-28 shrink-0' }, [
h(Select, {
modelValue: props.mode,
'onUpdate:modelValue': (value: string) => emit('update:mode', value),
}, () => [
h(SelectTrigger, { class: 'h-10 w-full' }, () => h(SelectValue)),
h(SelectContent, null, () => [
h(SelectItem, { value: 'inherit' }, () => '不配置'),
h(SelectItem, { value: 'unrestricted' }, () => '不限制'),
h(SelectItem, { value: 'specific' }, () => '指定列表'),
h(SelectItem, { value: 'deny_all' }, () => '全部禁用'),
]),
]),
]),
h('div', { class: 'min-w-0 flex-1' }, [
h(MultiSelect, {
modelValue: props.values,
'onUpdate:modelValue': (value: string[]) => emit('update:values', value),
options: props.options,
disabled: props.mode !== 'specific',
searchThreshold: 0,
placeholder: listPolicyValuePlaceholder(props.mode),
emptyText: '暂无选项',
dropdownMinWidth: '16rem',
}),
]),
]),
])
},
})
function listPolicyValuePlaceholder(mode: ListPolicyMode): string {
switch (mode) {
case 'inherit':
return '该组不配置此项'
case 'unrestricted':
return '不限制所有选项'
case 'deny_all':
return '全部禁用'
case 'specific':
default:
return '未选择时表示全部禁用'
}
}
const props = defineProps<{ const props = defineProps<{
open: boolean open: boolean
users: User[] users: User[]
@@ -328,7 +288,7 @@ const emit = defineEmits<{
const usersStore = useUsersStore() const usersStore = useUsersStore()
const { success, error } = useToast() const { success, error } = useToast()
const { confirmDanger } = useConfirm() const { confirmDanger, confirmInfo } = useConfirm()
const { const {
providerOptions, providerOptions,
apiFormatOptions, apiFormatOptions,
@@ -345,30 +305,23 @@ const memberUserIds = ref<string[]>([])
const form = ref({ const form = ref({
name: '', name: '',
description: '', allowed_providers_mode: 'unrestricted' as ListPolicyMode,
priority: 0, allowed_api_formats_mode: 'unrestricted' as ListPolicyMode,
is_default: false, allowed_models_mode: 'unrestricted' as ListPolicyMode,
allowed_providers_mode: 'inherit' as ListPolicyMode,
allowed_api_formats_mode: 'inherit' as ListPolicyMode,
allowed_models_mode: 'inherit' as ListPolicyMode,
allowed_providers: [] as string[], allowed_providers: [] as string[],
allowed_api_formats: [] as string[], allowed_api_formats: [] as string[],
allowed_models: [] as string[], allowed_models: [] as string[],
rate_limit_mode: 'inherit' as RateLimitPolicyMode, rate_limit_mode: 'system' as RateLimitPolicyMode,
rate_limit: undefined as number | undefined, rate_limit: undefined as number | undefined,
}) })
const selectedGroup = computed(() => groups.value.find((group) => group.id === editingGroupId.value) ?? null) const selectedGroup = computed(() => groups.value.find((group) => group.id === editingGroupId.value) ?? null)
const rateLimitPlaceholder = computed(() => { const defaultGroupHasRestrictions = computed(() => {
switch (form.value.rate_limit_mode) { if (!selectedGroup.value?.is_default) return false
case 'inherit': return form.value.allowed_providers_mode !== 'unrestricted'
return '该组不配置速率' || form.value.allowed_api_formats_mode !== 'unrestricted'
case 'system': || form.value.allowed_models_mode !== 'unrestricted'
return '使用系统默认' || form.value.rate_limit_mode !== 'system'
case 'custom':
default:
return '0 = 不限速'
}
}) })
const userOptions = computed(() => props.users.map((user) => ({ const userOptions = computed(() => props.users.map((user) => ({
label: `${user.username}${user.email ? ` (${user.email})` : ''}`, label: `${user.username}${user.email ? ` (${user.email})` : ''}`,
@@ -420,16 +373,13 @@ async function selectGroup(groupId: string): Promise<void> {
editingGroupId.value = group.id editingGroupId.value = group.id
form.value = { form.value = {
name: group.name, name: group.name,
description: group.description ?? '', allowed_providers_mode: normalizeListMode(group.allowed_providers_mode),
priority: group.priority, allowed_api_formats_mode: normalizeListMode(group.allowed_api_formats_mode),
is_default: group.is_default === true, allowed_models_mode: normalizeListMode(group.allowed_models_mode),
allowed_providers_mode: group.allowed_providers_mode,
allowed_api_formats_mode: group.allowed_api_formats_mode,
allowed_models_mode: group.allowed_models_mode,
allowed_providers: group.allowed_providers ? [...group.allowed_providers] : [], allowed_providers: group.allowed_providers ? [...group.allowed_providers] : [],
allowed_api_formats: group.allowed_api_formats ? [...group.allowed_api_formats] : [], allowed_api_formats: group.allowed_api_formats ? [...group.allowed_api_formats] : [],
allowed_models: group.allowed_models ? [...group.allowed_models] : [], allowed_models: group.allowed_models ? [...group.allowed_models] : [],
rate_limit_mode: group.rate_limit_mode, rate_limit_mode: normalizeRateMode(group.rate_limit_mode),
rate_limit: group.rate_limit ?? undefined, rate_limit: group.rate_limit ?? undefined,
} }
try { try {
@@ -441,20 +391,25 @@ async function selectGroup(groupId: string): Promise<void> {
} }
} }
function normalizeListMode(mode: ListPolicyMode): ListPolicyMode {
return mode === 'specific' ? 'specific' : 'unrestricted'
}
function normalizeRateMode(mode: RateLimitPolicyMode): RateLimitPolicyMode {
return mode === 'custom' ? 'custom' : 'system'
}
function startCreate(): void { function startCreate(): void {
editingGroupId.value = null editingGroupId.value = null
form.value = { form.value = {
name: '', name: '',
description: '', allowed_providers_mode: 'unrestricted',
priority: 0, allowed_api_formats_mode: 'unrestricted',
is_default: false, allowed_models_mode: 'unrestricted',
allowed_providers_mode: 'inherit',
allowed_api_formats_mode: 'inherit',
allowed_models_mode: 'inherit',
allowed_providers: [], allowed_providers: [],
allowed_api_formats: [], allowed_api_formats: [],
allowed_models: [], allowed_models: [],
rate_limit_mode: 'inherit', rate_limit_mode: 'system',
rate_limit: undefined, rate_limit: undefined,
} }
memberUserIds.value = [] memberUserIds.value = []
@@ -469,11 +424,30 @@ function groupButtonClass(groupId: string): string {
) )
} }
async function toggleDefault(): Promise<void> {
const group = selectedGroup.value
if (!group || group.is_default) return
const confirmed = await confirmInfo(
`确定将「${group.name}」设为默认注册组吗?后续本地注册和 OAuth 自动创建的用户将加入该分组。`,
'设为默认注册组',
)
if (!confirmed) return
saving.value = true
try {
await usersStore.setDefaultUserGroup(group.id)
success('已更新默认注册组')
emit('changed')
await loadDialogData()
} catch (err) {
error(parseApiError(err, '设置默认注册组失败'))
} finally {
saving.value = false
}
}
function buildPayload(): UpsertUserGroupRequest { function buildPayload(): UpsertUserGroupRequest {
return { return {
name: form.value.name.trim(), name: form.value.name.trim(),
description: form.value.description.trim() || null,
priority: form.value.priority,
allowed_providers_mode: form.value.allowed_providers_mode, allowed_providers_mode: form.value.allowed_providers_mode,
allowed_api_formats_mode: form.value.allowed_api_formats_mode, allowed_api_formats_mode: form.value.allowed_api_formats_mode,
allowed_models_mode: form.value.allowed_models_mode, allowed_models_mode: form.value.allowed_models_mode,
@@ -497,16 +471,11 @@ async function saveGroup(): Promise<void> {
if (!form.value.name.trim()) return if (!form.value.name.trim()) return
saving.value = true saving.value = true
try { try {
const wasDefault = selectedGroup.value?.is_default === true
const wantsDefault = form.value.is_default
const saved = editingGroupId.value const saved = editingGroupId.value
? await usersStore.updateUserGroup(editingGroupId.value, buildPayload()) ? await usersStore.updateUserGroup(editingGroupId.value, buildPayload())
: await usersStore.createUserGroup(buildPayload()) : await usersStore.createUserGroup(buildPayload())
await usersStore.replaceUserGroupMembers(saved.id, memberUserIds.value) if (!saved.is_default) {
if (wantsDefault) { await usersStore.replaceUserGroupMembers(saved.id, memberUserIds.value)
await usersStore.setDefaultUserGroup(saved.id)
} else if (wasDefault) {
await usersStore.setDefaultUserGroup(null)
} }
success('用户分组已保存') success('用户分组已保存')
emit('changed') emit('changed')

View File

@@ -1552,6 +1552,10 @@ function formatUserEffectiveRateLimitSource(user: User): string {
if (source.source === 'group' && source.group_name) { if (source.source === 'group' && source.group_name) {
return `继承自分组:${source.group_name}` return `继承自分组:${source.group_name}`
} }
if (source.source === 'combined') {
const groupNames = Array.isArray(source.group_names) ? source.group_names.join('、') : ''
return groupNames ? `用户额外限制与分组叠加:${groupNames}` : '用户额外限制与分组叠加'
}
if (source.source === 'user') { if (source.source === 'user') {
return '用户单独配置' return '用户单独配置'
} }
@@ -1596,14 +1600,6 @@ function editUser(user: User) {
unlimited: user.unlimited, unlimited: user.unlimited,
role: user.role, role: user.role,
is_active: user.is_active, is_active: user.is_active,
allowed_providers: user.allowed_providers == null ? null : [...user.allowed_providers],
allowed_api_formats: user.allowed_api_formats == null ? null : [...user.allowed_api_formats],
allowed_models: user.allowed_models == null ? null : [...user.allowed_models],
rate_limit: user.rate_limit ?? null,
allowed_providers_mode: user.allowed_providers_mode ?? (user.allowed_providers == null ? 'unrestricted' : 'specific'),
allowed_api_formats_mode: user.allowed_api_formats_mode ?? (user.allowed_api_formats == null ? 'unrestricted' : 'specific'),
allowed_models_mode: user.allowed_models_mode ?? (user.allowed_models == null ? 'unrestricted' : 'specific'),
rate_limit_mode: user.rate_limit_mode ?? (user.rate_limit == null ? 'system' : 'custom'),
group_ids: (user.groups || []).map(group => group.id), group_ids: (user.groups || []).map(group => group.id),
} }
showUserFormDialog.value = true showUserFormDialog.value = true
@@ -1624,14 +1620,6 @@ async function handleUserFormSubmit(data: UserFormData & { password?: string; un
email: data.email || undefined, email: data.email || undefined,
unlimited: data.unlimited, unlimited: data.unlimited,
role: data.role, role: data.role,
allowed_providers: data.allowed_providers,
allowed_providers_mode: data.allowed_providers_mode,
allowed_api_formats: data.allowed_api_formats,
allowed_api_formats_mode: data.allowed_api_formats_mode,
allowed_models: data.allowed_models,
allowed_models_mode: data.allowed_models_mode,
rate_limit: data.rate_limit ?? null,
rate_limit_mode: data.rate_limit_mode,
group_ids: data.group_ids ?? [], group_ids: data.group_ids ?? [],
} }
if (data.password) { if (data.password) {
@@ -1649,14 +1637,6 @@ async function handleUserFormSubmit(data: UserFormData & { password?: string; un
initial_gift_usd: data.initial_gift_usd, initial_gift_usd: data.initial_gift_usd,
unlimited: data.unlimited, unlimited: data.unlimited,
role: data.role, role: data.role,
allowed_providers: data.allowed_providers,
allowed_providers_mode: data.allowed_providers_mode,
allowed_api_formats: data.allowed_api_formats,
allowed_api_formats_mode: data.allowed_api_formats_mode,
allowed_models: data.allowed_models,
allowed_models_mode: data.allowed_models_mode,
rate_limit: data.rate_limit ?? null,
rate_limit_mode: data.rate_limit_mode,
group_ids: data.group_ids ?? [], group_ids: data.group_ids ?? [],
}) })
// 如果创建时指定为禁用,则更新状态 // 如果创建时指定为禁用,则更新状态

View File

@@ -15,6 +15,9 @@
数据预览 数据预览
</p> </p>
<ul class="space-y-1 text-muted-foreground"> <ul class="space-y-1 text-muted-foreground">
<li v-if="importUsersPreview.user_groups?.length">
用户组: {{ importUsersPreview.user_groups.length }}
</li>
<li>用户: {{ importUsersPreview.users?.length || 0 }} </li> <li>用户: {{ importUsersPreview.users?.length || 0 }} </li>
<li> <li>
API Keys: {{ importUsersPreview.users?.reduce((sum: number, u: { api_keys?: unknown[] }) => sum + (u.api_keys?.length || 0), 0) }} API Keys: {{ importUsersPreview.users?.reduce((sum: number, u: { api_keys?: unknown[] }) => sum + (u.api_keys?.length || 0), 0) }}
@@ -93,6 +96,16 @@
class="space-y-4" class="space-y-4"
> >
<div class="grid grid-cols-2 gap-4 text-sm"> <div class="grid grid-cols-2 gap-4 text-sm">
<div v-if="importUsersResult.stats.user_groups">
<p class="font-medium">
用户组
</p>
<p class="text-muted-foreground">
创建: {{ importUsersResult.stats.user_groups.created }},
更新: {{ importUsersResult.stats.user_groups.updated }},
跳过: {{ importUsersResult.stats.user_groups.skipped }}
</p>
</div>
<div> <div>
<p class="font-medium"> <p class="font-medium">
用户 用户

View File

@@ -36,7 +36,9 @@ function looksLikeConfigExport(value: JsonObject): boolean {
} }
function looksLikeUsersExport(value: JsonObject): boolean { function looksLikeUsersExport(value: JsonObject): boolean {
return hasArrayField(value, 'users') || hasArrayField(value, 'standalone_keys') return hasArrayField(value, 'users')
|| hasArrayField(value, 'standalone_keys')
|| hasArrayField(value, 'user_groups')
} }
export function useConfigExportImport(systemConfig: { value: SystemConfig }) { export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
@@ -231,6 +233,11 @@ export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
return return
} }
if (root.user_groups != null && !Array.isArray(root.user_groups)) {
error('无效的用户数据文件user_groups 必须是数组')
return
}
if (root.standalone_keys != null && !Array.isArray(root.standalone_keys)) { if (root.standalone_keys != null && !Array.isArray(root.standalone_keys)) {
error('无效的用户数据文件standalone_keys 必须是数组') error('无效的用户数据文件standalone_keys 必须是数组')
return return