mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
fix: align user group access controls
This commit is contained in:
@@ -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"];
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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/")?
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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/")?
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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<'_>,
|
||||||
|
|||||||
@@ -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()),
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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 = ?")
|
||||||
|
|||||||
@@ -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))
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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[]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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 ?? [],
|
||||||
})
|
})
|
||||||
// 如果创建时指定为禁用,则更新状态
|
// 如果创建时指定为禁用,则更新状态
|
||||||
|
|||||||
@@ -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">
|
||||||
用户
|
用户
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user