mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 09:20:22 +08:00
feat: 扩展 Rust gateway 全功能模块,新增 billing/crypto/wallet crate 及完整数据层
- 新增 aether-billing、aether-crypto、aether-wallet 独立 crate - aether-data 扩展 repository 层:announcements、auth_modules、billing、 candidate_selection、gemini_file_mappings、global_models、management_tokens、 oauth_providers、proxy_nodes、quota、users、wallet 等模块 - aether-gateway 新增 api/auth/billing/control/middleware/scheduler/usage/ video_tasks/hooks/maintenance/model_fetch/provider_transport 等功能模块 - 重构 executor decision 和 gateway state 为模块目录结构 - 新增 gateway router、frontdoor 路由层及对应测试 - Python 侧 API 路由重构,新增 compat/support 模块 - 前端 Logo 组件更新及 Provider 管理页面调整
This commit is contained in:
363
crates/aether-data/src/repository/users/memory.rs
Normal file
363
crates/aether-data/src/repository/users/memory.rs
Normal file
@@ -0,0 +1,363 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::RwLock;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use super::types::{
|
||||
StoredUserAuthRecord, StoredUserExportRow, StoredUserSummary, UserExportListQuery,
|
||||
UserExportSummary, UserReadRepository,
|
||||
};
|
||||
use crate::DataLayerError;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct InMemoryUserReadRepository {
|
||||
by_id: RwLock<BTreeMap<String, StoredUserSummary>>,
|
||||
auth_by_id: RwLock<BTreeMap<String, StoredUserAuthRecord>>,
|
||||
auth_by_identifier: RwLock<BTreeMap<String, String>>,
|
||||
export_rows: RwLock<Vec<StoredUserExportRow>>,
|
||||
}
|
||||
|
||||
impl InMemoryUserReadRepository {
|
||||
pub fn seed<I>(items: I) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = StoredUserSummary>,
|
||||
{
|
||||
let mut by_id = BTreeMap::new();
|
||||
for item in items {
|
||||
by_id.insert(item.id.clone(), item);
|
||||
}
|
||||
Self {
|
||||
by_id: RwLock::new(by_id),
|
||||
auth_by_id: RwLock::new(BTreeMap::new()),
|
||||
auth_by_identifier: RwLock::new(BTreeMap::new()),
|
||||
export_rows: RwLock::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn seed_auth_users<I>(items: I) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = StoredUserAuthRecord>,
|
||||
{
|
||||
let mut by_id = BTreeMap::new();
|
||||
let mut auth_by_id = BTreeMap::new();
|
||||
let mut auth_by_identifier = BTreeMap::new();
|
||||
for item in items {
|
||||
let summary = item
|
||||
.to_summary()
|
||||
.expect("in-memory auth user should convert to summary");
|
||||
by_id.insert(summary.id.clone(), summary);
|
||||
auth_by_identifier.insert(item.username.clone(), item.id.clone());
|
||||
if let Some(email) = item.email.as_ref() {
|
||||
auth_by_identifier.insert(email.clone(), item.id.clone());
|
||||
}
|
||||
auth_by_id.insert(item.id.clone(), item);
|
||||
}
|
||||
Self {
|
||||
by_id: RwLock::new(by_id),
|
||||
auth_by_id: RwLock::new(auth_by_id),
|
||||
auth_by_identifier: RwLock::new(auth_by_identifier),
|
||||
export_rows: RwLock::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn seed_export_users<I>(items: I) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = StoredUserExportRow>,
|
||||
{
|
||||
Self {
|
||||
by_id: RwLock::new(BTreeMap::new()),
|
||||
auth_by_id: RwLock::new(BTreeMap::new()),
|
||||
auth_by_identifier: RwLock::new(BTreeMap::new()),
|
||||
export_rows: RwLock::new(items.into_iter().collect()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_export_users<I>(self, items: I) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = StoredUserExportRow>,
|
||||
{
|
||||
let rows = items.into_iter().collect();
|
||||
*self.export_rows.write().expect("user repository lock") = rows;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UserReadRepository for InMemoryUserReadRepository {
|
||||
async fn list_users_by_ids(
|
||||
&self,
|
||||
user_ids: &[String],
|
||||
) -> Result<Vec<StoredUserSummary>, DataLayerError> {
|
||||
let index = self.by_id.read().expect("user repository lock");
|
||||
Ok(user_ids
|
||||
.iter()
|
||||
.filter_map(|user_id| index.get(user_id).cloned())
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn list_non_admin_export_users(
|
||||
&self,
|
||||
) -> Result<Vec<StoredUserExportRow>, DataLayerError> {
|
||||
Ok(self
|
||||
.export_rows
|
||||
.read()
|
||||
.expect("user repository lock")
|
||||
.iter()
|
||||
.filter(|row| !row.role.eq_ignore_ascii_case("admin"))
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn list_export_users(&self) -> Result<Vec<StoredUserExportRow>, DataLayerError> {
|
||||
Ok(self
|
||||
.export_rows
|
||||
.read()
|
||||
.expect("user repository lock")
|
||||
.clone())
|
||||
}
|
||||
|
||||
async fn list_export_users_page(
|
||||
&self,
|
||||
query: &UserExportListQuery,
|
||||
) -> Result<Vec<StoredUserExportRow>, DataLayerError> {
|
||||
let mut rows = self
|
||||
.export_rows
|
||||
.read()
|
||||
.expect("user repository lock")
|
||||
.clone();
|
||||
if let Some(role) = query.role.as_deref() {
|
||||
rows.retain(|row| row.role.eq_ignore_ascii_case(role));
|
||||
}
|
||||
if let Some(is_active) = query.is_active {
|
||||
rows.retain(|row| row.is_active == is_active);
|
||||
}
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.skip(query.skip)
|
||||
.take(query.limit)
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn summarize_export_users(&self) -> Result<UserExportSummary, DataLayerError> {
|
||||
let rows = self.export_rows.read().expect("user repository lock");
|
||||
Ok(UserExportSummary {
|
||||
total: rows.len() as u64,
|
||||
active: rows.iter().filter(|row| row.is_active).count() as u64,
|
||||
})
|
||||
}
|
||||
|
||||
async fn find_export_user_by_id(
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> Result<Option<StoredUserExportRow>, DataLayerError> {
|
||||
Ok(self
|
||||
.export_rows
|
||||
.read()
|
||||
.expect("user repository lock")
|
||||
.iter()
|
||||
.find(|row| row.id == user_id)
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn find_user_auth_by_id(
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> Result<Option<StoredUserAuthRecord>, DataLayerError> {
|
||||
Ok(self
|
||||
.auth_by_id
|
||||
.read()
|
||||
.expect("user repository lock")
|
||||
.get(user_id)
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn list_user_auth_by_ids(
|
||||
&self,
|
||||
user_ids: &[String],
|
||||
) -> Result<Vec<StoredUserAuthRecord>, DataLayerError> {
|
||||
let auth_by_id = self.auth_by_id.read().expect("user repository lock");
|
||||
Ok(user_ids
|
||||
.iter()
|
||||
.filter_map(|user_id| auth_by_id.get(user_id).cloned())
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn find_user_auth_by_identifier(
|
||||
&self,
|
||||
identifier: &str,
|
||||
) -> Result<Option<StoredUserAuthRecord>, DataLayerError> {
|
||||
let auth_by_identifier = self
|
||||
.auth_by_identifier
|
||||
.read()
|
||||
.expect("user repository lock");
|
||||
let Some(user_id) = auth_by_identifier.get(identifier) else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(self
|
||||
.auth_by_id
|
||||
.read()
|
||||
.expect("user repository lock")
|
||||
.get(user_id)
|
||||
.cloned())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::repository::users::{UserExportListQuery, UserReadRepository};
|
||||
|
||||
#[tokio::test]
|
||||
async fn lists_seeded_users() {
|
||||
let user = StoredUserSummary::new(
|
||||
"user-1".to_string(),
|
||||
"alice".to_string(),
|
||||
Some("alice@example.com".to_string()),
|
||||
"user".to_string(),
|
||||
true,
|
||||
false,
|
||||
)
|
||||
.expect("user should build");
|
||||
let repository = InMemoryUserReadRepository::seed(vec![user.clone()]);
|
||||
let rows = repository
|
||||
.list_users_by_ids(&["user-1".to_string()])
|
||||
.await
|
||||
.expect("lookup should succeed");
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0], user);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lists_seeded_non_admin_export_users() {
|
||||
let user = StoredUserExportRow::new(
|
||||
"user-1".to_string(),
|
||||
Some("alice@example.com".to_string()),
|
||||
true,
|
||||
"alice".to_string(),
|
||||
Some("hash".to_string()),
|
||||
"user".to_string(),
|
||||
"local".to_string(),
|
||||
Some(serde_json::json!(["openai"])),
|
||||
Some(serde_json::json!(["openai:chat"])),
|
||||
Some(serde_json::json!(["gpt-4.1"])),
|
||||
Some(60),
|
||||
Some(serde_json::json!({"gpt-4.1": {"cache_1h": true}})),
|
||||
true,
|
||||
)
|
||||
.expect("user export row should build");
|
||||
let repository = InMemoryUserReadRepository::seed_export_users(vec![user.clone()]);
|
||||
|
||||
let rows = repository
|
||||
.list_non_admin_export_users()
|
||||
.await
|
||||
.expect("export should succeed");
|
||||
|
||||
assert_eq!(rows, vec![user]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn finds_seeded_auth_user_by_id_and_identifier() {
|
||||
let user = StoredUserAuthRecord::new(
|
||||
"user-1".to_string(),
|
||||
Some("alice@example.com".to_string()),
|
||||
true,
|
||||
"alice".to_string(),
|
||||
Some("hash".to_string()),
|
||||
"user".to_string(),
|
||||
"local".to_string(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
true,
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("auth user should build");
|
||||
let repository = InMemoryUserReadRepository::seed_auth_users(vec![user.clone()]);
|
||||
|
||||
let by_id = repository
|
||||
.find_user_auth_by_id("user-1")
|
||||
.await
|
||||
.expect("lookup by id should succeed");
|
||||
let by_email = repository
|
||||
.find_user_auth_by_identifier("alice@example.com")
|
||||
.await
|
||||
.expect("lookup by email should succeed");
|
||||
let by_username = repository
|
||||
.find_user_auth_by_identifier("alice")
|
||||
.await
|
||||
.expect("lookup by username should succeed");
|
||||
|
||||
assert_eq!(by_id, Some(user.clone()));
|
||||
assert_eq!(by_email, Some(user.clone()));
|
||||
assert_eq!(by_username, Some(user));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn paginates_export_users_in_memory() {
|
||||
let repository = InMemoryUserReadRepository::seed_export_users(vec![
|
||||
StoredUserExportRow::new(
|
||||
"user-1".to_string(),
|
||||
Some("alice@example.com".to_string()),
|
||||
true,
|
||||
"alice".to_string(),
|
||||
Some("hash".to_string()),
|
||||
"user".to_string(),
|
||||
"local".to_string(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(60),
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.expect("user export row should build"),
|
||||
StoredUserExportRow::new(
|
||||
"user-2".to_string(),
|
||||
Some("bob@example.com".to_string()),
|
||||
true,
|
||||
"bob".to_string(),
|
||||
Some("hash".to_string()),
|
||||
"admin".to_string(),
|
||||
"local".to_string(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(30),
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.expect("user export row should build"),
|
||||
StoredUserExportRow::new(
|
||||
"user-3".to_string(),
|
||||
Some("carol@example.com".to_string()),
|
||||
true,
|
||||
"carol".to_string(),
|
||||
Some("hash".to_string()),
|
||||
"user".to_string(),
|
||||
"local".to_string(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(10),
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.expect("user export row should build"),
|
||||
]);
|
||||
|
||||
let rows = repository
|
||||
.list_export_users_page(&UserExportListQuery {
|
||||
skip: 0,
|
||||
limit: 10,
|
||||
role: Some("user".to_string()),
|
||||
is_active: Some(true),
|
||||
})
|
||||
.await
|
||||
.expect("paged export should succeed");
|
||||
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].id, "user-1");
|
||||
}
|
||||
}
|
||||
10
crates/aether-data/src/repository/users/mod.rs
Normal file
10
crates/aether-data/src/repository/users/mod.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
mod memory;
|
||||
mod sql;
|
||||
mod types;
|
||||
|
||||
pub use memory::InMemoryUserReadRepository;
|
||||
pub use sql::SqlxUserReadRepository;
|
||||
pub use types::{
|
||||
StoredUserAuthRecord, StoredUserExportRow, StoredUserSummary, UserExportListQuery,
|
||||
UserExportSummary, UserReadRepository,
|
||||
};
|
||||
408
crates/aether-data/src/repository/users/sql.rs
Normal file
408
crates/aether-data/src/repository/users/sql.rs
Normal file
@@ -0,0 +1,408 @@
|
||||
use async_trait::async_trait;
|
||||
use sqlx::{PgPool, Postgres, QueryBuilder, Row};
|
||||
|
||||
use super::types::{
|
||||
StoredUserAuthRecord, StoredUserExportRow, StoredUserSummary, UserExportListQuery,
|
||||
UserExportSummary, UserReadRepository,
|
||||
};
|
||||
use crate::DataLayerError;
|
||||
|
||||
const LIST_USERS_BY_IDS_SQL: &str = r#"
|
||||
SELECT
|
||||
id,
|
||||
username,
|
||||
email,
|
||||
role::text AS role,
|
||||
is_active,
|
||||
is_deleted
|
||||
FROM users
|
||||
WHERE id = ANY($1::text[])
|
||||
ORDER BY id ASC
|
||||
"#;
|
||||
|
||||
const LIST_NON_ADMIN_EXPORT_USERS_SQL: &str = r#"
|
||||
SELECT
|
||||
id,
|
||||
email,
|
||||
email_verified,
|
||||
username,
|
||||
password_hash,
|
||||
role::text AS role,
|
||||
auth_source::text AS auth_source,
|
||||
allowed_providers,
|
||||
allowed_api_formats,
|
||||
allowed_models,
|
||||
rate_limit,
|
||||
model_capability_settings,
|
||||
is_active
|
||||
FROM users
|
||||
WHERE is_deleted IS FALSE
|
||||
AND role::text != 'admin'
|
||||
ORDER BY id ASC
|
||||
"#;
|
||||
|
||||
const LIST_EXPORT_USERS_SQL: &str = r#"
|
||||
SELECT
|
||||
id,
|
||||
email,
|
||||
email_verified,
|
||||
username,
|
||||
password_hash,
|
||||
role::text AS role,
|
||||
auth_source::text AS auth_source,
|
||||
allowed_providers,
|
||||
allowed_api_formats,
|
||||
allowed_models,
|
||||
rate_limit,
|
||||
model_capability_settings,
|
||||
is_active
|
||||
FROM users
|
||||
WHERE is_deleted IS FALSE
|
||||
ORDER BY id ASC
|
||||
"#;
|
||||
|
||||
const LIST_EXPORT_USERS_PAGE_PREFIX: &str = r#"
|
||||
SELECT
|
||||
id,
|
||||
email,
|
||||
email_verified,
|
||||
username,
|
||||
password_hash,
|
||||
role::text AS role,
|
||||
auth_source::text AS auth_source,
|
||||
allowed_providers,
|
||||
allowed_api_formats,
|
||||
allowed_models,
|
||||
rate_limit,
|
||||
model_capability_settings,
|
||||
is_active
|
||||
FROM users
|
||||
WHERE is_deleted IS FALSE
|
||||
"#;
|
||||
|
||||
const SUMMARIZE_EXPORT_USERS_SQL: &str = r#"
|
||||
SELECT
|
||||
COUNT(*)::BIGINT AS total,
|
||||
COUNT(*) FILTER (WHERE is_active = TRUE)::BIGINT AS active
|
||||
FROM users
|
||||
WHERE is_deleted IS FALSE
|
||||
"#;
|
||||
|
||||
const FIND_EXPORT_USER_BY_ID_SQL: &str = r#"
|
||||
SELECT
|
||||
id,
|
||||
email,
|
||||
email_verified,
|
||||
username,
|
||||
password_hash,
|
||||
role::text AS role,
|
||||
auth_source::text AS auth_source,
|
||||
allowed_providers,
|
||||
allowed_api_formats,
|
||||
allowed_models,
|
||||
rate_limit,
|
||||
model_capability_settings,
|
||||
is_active
|
||||
FROM users
|
||||
WHERE is_deleted IS FALSE
|
||||
AND id = $1
|
||||
LIMIT 1
|
||||
"#;
|
||||
|
||||
const FIND_USER_AUTH_BY_ID_SQL: &str = r#"
|
||||
SELECT
|
||||
id,
|
||||
email,
|
||||
email_verified,
|
||||
username,
|
||||
password_hash,
|
||||
role::text AS role,
|
||||
auth_source::text AS auth_source,
|
||||
allowed_providers,
|
||||
allowed_api_formats,
|
||||
allowed_models,
|
||||
is_active,
|
||||
is_deleted,
|
||||
created_at,
|
||||
last_login_at
|
||||
FROM users
|
||||
WHERE id = $1
|
||||
LIMIT 1
|
||||
"#;
|
||||
|
||||
const LIST_USER_AUTH_BY_IDS_SQL: &str = r#"
|
||||
SELECT
|
||||
id,
|
||||
email,
|
||||
email_verified,
|
||||
username,
|
||||
password_hash,
|
||||
role::text AS role,
|
||||
auth_source::text AS auth_source,
|
||||
allowed_providers,
|
||||
allowed_api_formats,
|
||||
allowed_models,
|
||||
is_active,
|
||||
is_deleted,
|
||||
created_at,
|
||||
last_login_at
|
||||
FROM users
|
||||
WHERE id = ANY($1::text[])
|
||||
ORDER BY id ASC
|
||||
"#;
|
||||
|
||||
const FIND_USER_AUTH_BY_IDENTIFIER_SQL: &str = r#"
|
||||
SELECT
|
||||
id,
|
||||
email,
|
||||
email_verified,
|
||||
username,
|
||||
password_hash,
|
||||
role::text AS role,
|
||||
auth_source::text AS auth_source,
|
||||
allowed_providers,
|
||||
allowed_api_formats,
|
||||
allowed_models,
|
||||
is_active,
|
||||
is_deleted,
|
||||
created_at,
|
||||
last_login_at
|
||||
FROM users
|
||||
WHERE email = $1 OR username = $1
|
||||
LIMIT 1
|
||||
"#;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SqlxUserReadRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl SqlxUserReadRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
pub async fn list_users_by_ids(
|
||||
&self,
|
||||
user_ids: &[String],
|
||||
) -> Result<Vec<StoredUserSummary>, DataLayerError> {
|
||||
if user_ids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let rows = sqlx::query(LIST_USERS_BY_IDS_SQL)
|
||||
.bind(user_ids)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
rows.iter().map(map_user_row).collect()
|
||||
}
|
||||
|
||||
pub async fn list_non_admin_export_users(
|
||||
&self,
|
||||
) -> Result<Vec<StoredUserExportRow>, DataLayerError> {
|
||||
let rows = sqlx::query(LIST_NON_ADMIN_EXPORT_USERS_SQL)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
rows.iter().map(map_user_export_row).collect()
|
||||
}
|
||||
|
||||
pub async fn list_export_users(&self) -> Result<Vec<StoredUserExportRow>, DataLayerError> {
|
||||
let rows = sqlx::query(LIST_EXPORT_USERS_SQL)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
rows.iter().map(map_user_export_row).collect()
|
||||
}
|
||||
|
||||
pub async fn list_export_users_page(
|
||||
&self,
|
||||
query: &UserExportListQuery,
|
||||
) -> Result<Vec<StoredUserExportRow>, DataLayerError> {
|
||||
let mut builder = QueryBuilder::<Postgres>::new(LIST_EXPORT_USERS_PAGE_PREFIX);
|
||||
|
||||
if let Some(role) = query.role.as_deref() {
|
||||
builder
|
||||
.push(" AND LOWER(role::text) = ")
|
||||
.push_bind(role.trim().to_ascii_lowercase());
|
||||
}
|
||||
if let Some(is_active) = query.is_active {
|
||||
builder.push(" AND is_active = ").push_bind(is_active);
|
||||
}
|
||||
|
||||
builder
|
||||
.push(" ORDER BY id ASC OFFSET ")
|
||||
.push_bind(i64::try_from(query.skip).map_err(|_| {
|
||||
DataLayerError::InvalidInput(format!("invalid user export skip: {}", query.skip))
|
||||
})?)
|
||||
.push(" LIMIT ")
|
||||
.push_bind(i64::try_from(query.limit).map_err(|_| {
|
||||
DataLayerError::InvalidInput(format!("invalid user export limit: {}", query.limit))
|
||||
})?);
|
||||
|
||||
let rows = builder.build().fetch_all(&self.pool).await?;
|
||||
rows.iter().map(map_user_export_row).collect()
|
||||
}
|
||||
|
||||
pub async fn summarize_export_users(&self) -> Result<UserExportSummary, DataLayerError> {
|
||||
let row = sqlx::query(SUMMARIZE_EXPORT_USERS_SQL)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(UserExportSummary {
|
||||
total: row.try_get::<i64, _>("total")?.max(0) as u64,
|
||||
active: row.try_get::<i64, _>("active")?.max(0) as u64,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn find_export_user_by_id(
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> Result<Option<StoredUserExportRow>, DataLayerError> {
|
||||
let row = sqlx::query(FIND_EXPORT_USER_BY_ID_SQL)
|
||||
.bind(user_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
row.as_ref().map(map_user_export_row).transpose()
|
||||
}
|
||||
|
||||
pub async fn list_user_auth_by_ids(
|
||||
&self,
|
||||
user_ids: &[String],
|
||||
) -> Result<Vec<StoredUserAuthRecord>, DataLayerError> {
|
||||
if user_ids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let rows = sqlx::query(LIST_USER_AUTH_BY_IDS_SQL)
|
||||
.bind(user_ids)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
rows.iter().map(map_user_auth_row).collect()
|
||||
}
|
||||
|
||||
pub async fn find_user_auth_by_id(
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> Result<Option<StoredUserAuthRecord>, DataLayerError> {
|
||||
let row = sqlx::query(FIND_USER_AUTH_BY_ID_SQL)
|
||||
.bind(user_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
row.as_ref().map(map_user_auth_row).transpose()
|
||||
}
|
||||
|
||||
pub async fn find_user_auth_by_identifier(
|
||||
&self,
|
||||
identifier: &str,
|
||||
) -> Result<Option<StoredUserAuthRecord>, DataLayerError> {
|
||||
let row = sqlx::query(FIND_USER_AUTH_BY_IDENTIFIER_SQL)
|
||||
.bind(identifier)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
row.as_ref().map(map_user_auth_row).transpose()
|
||||
}
|
||||
}
|
||||
|
||||
fn map_user_row(row: &sqlx::postgres::PgRow) -> Result<StoredUserSummary, DataLayerError> {
|
||||
StoredUserSummary::new(
|
||||
row.try_get("id")?,
|
||||
row.try_get("username")?,
|
||||
row.try_get("email")?,
|
||||
row.try_get("role")?,
|
||||
row.try_get("is_active")?,
|
||||
row.try_get("is_deleted")?,
|
||||
)
|
||||
}
|
||||
|
||||
fn map_user_export_row(row: &sqlx::postgres::PgRow) -> Result<StoredUserExportRow, DataLayerError> {
|
||||
StoredUserExportRow::new(
|
||||
row.try_get("id")?,
|
||||
row.try_get("email")?,
|
||||
row.try_get("email_verified")?,
|
||||
row.try_get("username")?,
|
||||
row.try_get("password_hash")?,
|
||||
row.try_get("role")?,
|
||||
row.try_get("auth_source")?,
|
||||
row.try_get("allowed_providers")?,
|
||||
row.try_get("allowed_api_formats")?,
|
||||
row.try_get("allowed_models")?,
|
||||
row.try_get("rate_limit")?,
|
||||
row.try_get("model_capability_settings")?,
|
||||
row.try_get("is_active")?,
|
||||
)
|
||||
}
|
||||
|
||||
fn map_user_auth_row(row: &sqlx::postgres::PgRow) -> Result<StoredUserAuthRecord, DataLayerError> {
|
||||
StoredUserAuthRecord::new(
|
||||
row.try_get("id")?,
|
||||
row.try_get("email")?,
|
||||
row.try_get("email_verified")?,
|
||||
row.try_get("username")?,
|
||||
row.try_get("password_hash")?,
|
||||
row.try_get("role")?,
|
||||
row.try_get("auth_source")?,
|
||||
row.try_get("allowed_providers")?,
|
||||
row.try_get("allowed_api_formats")?,
|
||||
row.try_get("allowed_models")?,
|
||||
row.try_get("is_active")?,
|
||||
row.try_get("is_deleted")?,
|
||||
row.try_get("created_at")?,
|
||||
row.try_get("last_login_at")?,
|
||||
)
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UserReadRepository for SqlxUserReadRepository {
|
||||
async fn list_users_by_ids(
|
||||
&self,
|
||||
user_ids: &[String],
|
||||
) -> Result<Vec<StoredUserSummary>, DataLayerError> {
|
||||
self.list_users_by_ids(user_ids).await
|
||||
}
|
||||
|
||||
async fn list_non_admin_export_users(
|
||||
&self,
|
||||
) -> Result<Vec<StoredUserExportRow>, DataLayerError> {
|
||||
self.list_non_admin_export_users().await
|
||||
}
|
||||
|
||||
async fn list_export_users(&self) -> Result<Vec<StoredUserExportRow>, DataLayerError> {
|
||||
self.list_export_users().await
|
||||
}
|
||||
|
||||
async fn list_export_users_page(
|
||||
&self,
|
||||
query: &UserExportListQuery,
|
||||
) -> Result<Vec<StoredUserExportRow>, DataLayerError> {
|
||||
self.list_export_users_page(query).await
|
||||
}
|
||||
|
||||
async fn summarize_export_users(&self) -> Result<UserExportSummary, DataLayerError> {
|
||||
self.summarize_export_users().await
|
||||
}
|
||||
|
||||
async fn find_export_user_by_id(
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> Result<Option<StoredUserExportRow>, DataLayerError> {
|
||||
self.find_export_user_by_id(user_id).await
|
||||
}
|
||||
|
||||
async fn find_user_auth_by_id(
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> Result<Option<StoredUserAuthRecord>, DataLayerError> {
|
||||
self.find_user_auth_by_id(user_id).await
|
||||
}
|
||||
|
||||
async fn list_user_auth_by_ids(
|
||||
&self,
|
||||
user_ids: &[String],
|
||||
) -> Result<Vec<StoredUserAuthRecord>, DataLayerError> {
|
||||
self.list_user_auth_by_ids(user_ids).await
|
||||
}
|
||||
|
||||
async fn find_user_auth_by_identifier(
|
||||
&self,
|
||||
identifier: &str,
|
||||
) -> Result<Option<StoredUserAuthRecord>, DataLayerError> {
|
||||
self.find_user_auth_by_identifier(identifier).await
|
||||
}
|
||||
}
|
||||
450
crates/aether-data/src/repository/users/types.rs
Normal file
450
crates/aether-data/src/repository/users/types.rs
Normal file
@@ -0,0 +1,450 @@
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct StoredUserSummary {
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
pub email: Option<String>,
|
||||
pub role: String,
|
||||
pub is_active: bool,
|
||||
pub is_deleted: bool,
|
||||
}
|
||||
|
||||
impl StoredUserSummary {
|
||||
pub fn new(
|
||||
id: String,
|
||||
username: String,
|
||||
email: Option<String>,
|
||||
role: String,
|
||||
is_active: bool,
|
||||
is_deleted: bool,
|
||||
) -> Result<Self, crate::DataLayerError> {
|
||||
if id.trim().is_empty() {
|
||||
return Err(crate::DataLayerError::UnexpectedValue(
|
||||
"users.id is empty".to_string(),
|
||||
));
|
||||
}
|
||||
if username.trim().is_empty() {
|
||||
return Err(crate::DataLayerError::UnexpectedValue(
|
||||
"users.username is empty".to_string(),
|
||||
));
|
||||
}
|
||||
if role.trim().is_empty() {
|
||||
return Err(crate::DataLayerError::UnexpectedValue(
|
||||
"users.role is empty".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(Self {
|
||||
id,
|
||||
username,
|
||||
email,
|
||||
role,
|
||||
is_active,
|
||||
is_deleted,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct StoredUserAuthRecord {
|
||||
pub id: String,
|
||||
pub email: Option<String>,
|
||||
pub email_verified: bool,
|
||||
pub username: String,
|
||||
pub password_hash: Option<String>,
|
||||
pub role: String,
|
||||
pub auth_source: String,
|
||||
pub allowed_providers: Option<Vec<String>>,
|
||||
pub allowed_api_formats: Option<Vec<String>>,
|
||||
pub allowed_models: Option<Vec<String>>,
|
||||
pub is_active: bool,
|
||||
pub is_deleted: bool,
|
||||
pub created_at: Option<DateTime<Utc>>,
|
||||
pub last_login_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl StoredUserAuthRecord {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
id: String,
|
||||
email: Option<String>,
|
||||
email_verified: bool,
|
||||
username: String,
|
||||
password_hash: Option<String>,
|
||||
role: String,
|
||||
auth_source: String,
|
||||
allowed_providers: Option<Value>,
|
||||
allowed_api_formats: Option<Value>,
|
||||
allowed_models: Option<Value>,
|
||||
is_active: bool,
|
||||
is_deleted: bool,
|
||||
created_at: Option<DateTime<Utc>>,
|
||||
last_login_at: Option<DateTime<Utc>>,
|
||||
) -> Result<Self, crate::DataLayerError> {
|
||||
if id.trim().is_empty() {
|
||||
return Err(crate::DataLayerError::UnexpectedValue(
|
||||
"users.id is empty".to_string(),
|
||||
));
|
||||
}
|
||||
if username.trim().is_empty() {
|
||||
return Err(crate::DataLayerError::UnexpectedValue(
|
||||
"users.username is empty".to_string(),
|
||||
));
|
||||
}
|
||||
if role.trim().is_empty() {
|
||||
return Err(crate::DataLayerError::UnexpectedValue(
|
||||
"users.role is empty".to_string(),
|
||||
));
|
||||
}
|
||||
if auth_source.trim().is_empty() {
|
||||
return Err(crate::DataLayerError::UnexpectedValue(
|
||||
"users.auth_source is empty".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
id,
|
||||
email,
|
||||
email_verified,
|
||||
username,
|
||||
password_hash,
|
||||
role,
|
||||
auth_source,
|
||||
allowed_providers: parse_string_list(allowed_providers, "users.allowed_providers")?,
|
||||
allowed_api_formats: parse_string_list(
|
||||
allowed_api_formats,
|
||||
"users.allowed_api_formats",
|
||||
)?,
|
||||
allowed_models: parse_string_list(allowed_models, "users.allowed_models")?,
|
||||
is_active,
|
||||
is_deleted,
|
||||
created_at,
|
||||
last_login_at,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn to_summary(&self) -> Result<StoredUserSummary, crate::DataLayerError> {
|
||||
StoredUserSummary::new(
|
||||
self.id.clone(),
|
||||
self.username.clone(),
|
||||
self.email.clone(),
|
||||
self.role.clone(),
|
||||
self.is_active,
|
||||
self.is_deleted,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct StoredUserExportRow {
|
||||
pub id: String,
|
||||
pub email: Option<String>,
|
||||
pub email_verified: bool,
|
||||
pub username: String,
|
||||
pub password_hash: Option<String>,
|
||||
pub role: String,
|
||||
pub auth_source: String,
|
||||
pub allowed_providers: Option<Vec<String>>,
|
||||
pub allowed_api_formats: Option<Vec<String>>,
|
||||
pub allowed_models: Option<Vec<String>>,
|
||||
pub rate_limit: Option<i32>,
|
||||
pub model_capability_settings: Option<Value>,
|
||||
pub is_active: bool,
|
||||
}
|
||||
|
||||
impl StoredUserExportRow {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
id: String,
|
||||
email: Option<String>,
|
||||
email_verified: bool,
|
||||
username: String,
|
||||
password_hash: Option<String>,
|
||||
role: String,
|
||||
auth_source: String,
|
||||
allowed_providers: Option<Value>,
|
||||
allowed_api_formats: Option<Value>,
|
||||
allowed_models: Option<Value>,
|
||||
rate_limit: Option<i32>,
|
||||
model_capability_settings: Option<Value>,
|
||||
is_active: bool,
|
||||
) -> Result<Self, crate::DataLayerError> {
|
||||
if id.trim().is_empty() {
|
||||
return Err(crate::DataLayerError::UnexpectedValue(
|
||||
"users.id is empty".to_string(),
|
||||
));
|
||||
}
|
||||
if username.trim().is_empty() {
|
||||
return Err(crate::DataLayerError::UnexpectedValue(
|
||||
"users.username is empty".to_string(),
|
||||
));
|
||||
}
|
||||
if role.trim().is_empty() {
|
||||
return Err(crate::DataLayerError::UnexpectedValue(
|
||||
"users.role is empty".to_string(),
|
||||
));
|
||||
}
|
||||
if auth_source.trim().is_empty() {
|
||||
return Err(crate::DataLayerError::UnexpectedValue(
|
||||
"users.auth_source is empty".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
id,
|
||||
email,
|
||||
email_verified,
|
||||
username,
|
||||
password_hash,
|
||||
role,
|
||||
auth_source,
|
||||
allowed_providers: parse_string_list(allowed_providers, "users.allowed_providers")?,
|
||||
allowed_api_formats: parse_string_list(
|
||||
allowed_api_formats,
|
||||
"users.allowed_api_formats",
|
||||
)?,
|
||||
allowed_models: parse_string_list(allowed_models, "users.allowed_models")?,
|
||||
rate_limit,
|
||||
model_capability_settings: normalize_optional_json(model_capability_settings),
|
||||
is_active,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct UserExportListQuery {
|
||||
pub skip: usize,
|
||||
pub limit: usize,
|
||||
pub role: Option<String>,
|
||||
pub is_active: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
|
||||
pub struct UserExportSummary {
|
||||
pub total: u64,
|
||||
pub active: u64,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait UserReadRepository: Send + Sync {
|
||||
async fn list_users_by_ids(
|
||||
&self,
|
||||
user_ids: &[String],
|
||||
) -> Result<Vec<StoredUserSummary>, crate::DataLayerError>;
|
||||
|
||||
async fn list_export_users(&self) -> Result<Vec<StoredUserExportRow>, crate::DataLayerError>;
|
||||
|
||||
async fn list_export_users_page(
|
||||
&self,
|
||||
query: &UserExportListQuery,
|
||||
) -> Result<Vec<StoredUserExportRow>, crate::DataLayerError>;
|
||||
|
||||
async fn summarize_export_users(&self) -> Result<UserExportSummary, crate::DataLayerError>;
|
||||
|
||||
async fn find_export_user_by_id(
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> Result<Option<StoredUserExportRow>, crate::DataLayerError>;
|
||||
|
||||
async fn list_non_admin_export_users(
|
||||
&self,
|
||||
) -> Result<Vec<StoredUserExportRow>, crate::DataLayerError>;
|
||||
|
||||
async fn find_user_auth_by_id(
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> Result<Option<StoredUserAuthRecord>, crate::DataLayerError>;
|
||||
|
||||
async fn list_user_auth_by_ids(
|
||||
&self,
|
||||
user_ids: &[String],
|
||||
) -> Result<Vec<StoredUserAuthRecord>, crate::DataLayerError>;
|
||||
|
||||
async fn find_user_auth_by_identifier(
|
||||
&self,
|
||||
identifier: &str,
|
||||
) -> Result<Option<StoredUserAuthRecord>, crate::DataLayerError>;
|
||||
}
|
||||
|
||||
fn normalize_optional_json(value: Option<Value>) -> Option<Value> {
|
||||
match value {
|
||||
Some(Value::Null) | None => None,
|
||||
Some(value) => Some(value),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_string_list(
|
||||
value: Option<Value>,
|
||||
field_name: &str,
|
||||
) -> Result<Option<Vec<String>>, crate::DataLayerError> {
|
||||
let Some(value) = value else {
|
||||
return Ok(None);
|
||||
};
|
||||
parse_string_list_value(&value, field_name)
|
||||
}
|
||||
|
||||
fn parse_string_list_value(
|
||||
value: &Value,
|
||||
field_name: &str,
|
||||
) -> Result<Option<Vec<String>>, crate::DataLayerError> {
|
||||
match value {
|
||||
Value::Null => Ok(None),
|
||||
Value::Array(array) => parse_string_list_array(array, field_name).map(Some),
|
||||
Value::String(raw) => parse_embedded_string_list(raw, field_name),
|
||||
_ => Err(crate::DataLayerError::UnexpectedValue(format!(
|
||||
"{field_name} is not a JSON array"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_embedded_string_list(
|
||||
raw: &str,
|
||||
field_name: &str,
|
||||
) -> Result<Option<Vec<String>>, crate::DataLayerError> {
|
||||
let raw = raw.trim();
|
||||
if raw.is_empty() || raw.eq_ignore_ascii_case("null") {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if let Ok(decoded) = serde_json::from_str::<Value>(raw) {
|
||||
return parse_string_list_value(&decoded, field_name);
|
||||
}
|
||||
|
||||
Ok(Some(vec![raw.to_string()]))
|
||||
}
|
||||
|
||||
fn parse_string_list_array(
|
||||
array: &[Value],
|
||||
field_name: &str,
|
||||
) -> Result<Vec<String>, crate::DataLayerError> {
|
||||
let mut items = Vec::with_capacity(array.len());
|
||||
for item in array {
|
||||
let Some(item) = item.as_str() else {
|
||||
return Err(crate::DataLayerError::UnexpectedValue(format!(
|
||||
"{field_name} contains a non-string item"
|
||||
)));
|
||||
};
|
||||
let item = item.trim();
|
||||
if !item.is_empty() {
|
||||
items.push(item.to_string());
|
||||
}
|
||||
}
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::Value;
|
||||
|
||||
use super::{StoredUserAuthRecord, StoredUserExportRow};
|
||||
|
||||
#[test]
|
||||
fn builds_user_export_row_with_allowed_lists() {
|
||||
let row = StoredUserExportRow::new(
|
||||
"user-1".to_string(),
|
||||
Some("alice@example.com".to_string()),
|
||||
true,
|
||||
"alice".to_string(),
|
||||
Some("hash".to_string()),
|
||||
"user".to_string(),
|
||||
"local".to_string(),
|
||||
Some(serde_json::json!(["openai", "anthropic"])),
|
||||
Some(serde_json::json!(["openai:chat"])),
|
||||
Some(serde_json::json!(["gpt-4.1"])),
|
||||
Some(60),
|
||||
Some(serde_json::json!({"gpt-4.1": {"cache_1h": true}})),
|
||||
true,
|
||||
)
|
||||
.expect("row should build");
|
||||
|
||||
assert_eq!(
|
||||
row.allowed_providers,
|
||||
Some(vec!["openai".to_string(), "anthropic".to_string()])
|
||||
);
|
||||
assert_eq!(
|
||||
row.allowed_api_formats,
|
||||
Some(vec!["openai:chat".to_string()])
|
||||
);
|
||||
assert_eq!(row.allowed_models, Some(vec!["gpt-4.1".to_string()]));
|
||||
assert_eq!(
|
||||
row.model_capability_settings,
|
||||
Some(serde_json::json!({"gpt-4.1": {"cache_1h": true}}))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_embedded_string_lists_for_user_export_row() {
|
||||
let row = StoredUserExportRow::new(
|
||||
"user-1".to_string(),
|
||||
None,
|
||||
false,
|
||||
"alice".to_string(),
|
||||
None,
|
||||
"user".to_string(),
|
||||
"local".to_string(),
|
||||
Some(serde_json::json!("[\"openai\"]")),
|
||||
Some(serde_json::json!("null")),
|
||||
Some(serde_json::json!("gpt-4.1")),
|
||||
None,
|
||||
Some(Value::Null),
|
||||
true,
|
||||
)
|
||||
.expect("row should build");
|
||||
|
||||
assert_eq!(row.allowed_providers, Some(vec!["openai".to_string()]));
|
||||
assert_eq!(row.allowed_api_formats, None);
|
||||
assert_eq!(row.allowed_models, Some(vec!["gpt-4.1".to_string()]));
|
||||
assert_eq!(row.model_capability_settings, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_object_allowed_providers_for_user_export_row() {
|
||||
let result = StoredUserExportRow::new(
|
||||
"user-1".to_string(),
|
||||
None,
|
||||
false,
|
||||
"alice".to_string(),
|
||||
None,
|
||||
"user".to_string(),
|
||||
"local".to_string(),
|
||||
Some(serde_json::json!({"bad": true})),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
true,
|
||||
);
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_user_auth_record_with_allowed_lists() {
|
||||
let row = StoredUserAuthRecord::new(
|
||||
"user-1".to_string(),
|
||||
Some("alice@example.com".to_string()),
|
||||
true,
|
||||
"alice".to_string(),
|
||||
Some("hash".to_string()),
|
||||
"user".to_string(),
|
||||
"local".to_string(),
|
||||
Some(serde_json::json!(["openai"])),
|
||||
Some(serde_json::json!(["openai:chat"])),
|
||||
Some(serde_json::json!(["gpt-4.1"])),
|
||||
true,
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("auth row should build");
|
||||
|
||||
assert_eq!(row.allowed_providers, Some(vec!["openai".to_string()]));
|
||||
assert_eq!(
|
||||
row.allowed_api_formats,
|
||||
Some(vec!["openai:chat".to_string()])
|
||||
);
|
||||
assert_eq!(row.allowed_models, Some(vec!["gpt-4.1".to_string()]));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user