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:
fawney19
2026-03-31 19:19:04 +08:00
parent b5a0070023
commit ddf18fed9a
690 changed files with 235087 additions and 16301 deletions

View 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");
}
}

View 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,
};

View 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
}
}

View 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()]));
}
}