Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
- Fix TIMESTAMPTZ decode errors: add ::TEXT cast to all SELECT queries
where Row structs use String for TIMESTAMPTZ columns (~22 locations)
- Fix Axum 0.7 route params: {id} → :id in billing/knowledge/scheduled_task routes
- Fix JSONB bind: scheduled_task INSERT uses ::jsonb cast for input_payload
- Add billing_test.rs (14 tests): plans, subscription, usage, payments, invoices
- Add scheduled_task_test.rs (12 tests): CRUD, validation, isolation
- Add knowledge_test.rs (20 tests): categories, items, versions, search, analytics, permissions
- Fix auth test regression: 6 tests were failing due to TIMESTAMPTZ type mismatch
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
336 lines
14 KiB
Rust
336 lines
14 KiB
Rust
//! 账号管理业务逻辑
|
|
|
|
use sqlx::PgPool;
|
|
use crate::error::{SaasError, SaasResult};
|
|
use crate::common::{PaginatedResponse, normalize_pagination};
|
|
use crate::models::{AccountRow, ApiTokenRow, DeviceRow};
|
|
use super::types::*;
|
|
|
|
pub async fn list_accounts(
|
|
db: &PgPool,
|
|
query: &ListAccountsQuery,
|
|
) -> SaasResult<PaginatedResponse<serde_json::Value>> {
|
|
let page = query.page.unwrap_or(1).max(1);
|
|
let page_size = query.page_size.unwrap_or(20).min(100);
|
|
let offset = (page - 1) * page_size;
|
|
|
|
// Static SQL per combination -- no format!() string interpolation
|
|
let (total, rows) = match (&query.role, &query.status, &query.search) {
|
|
// role + status + search
|
|
(Some(role), Some(status), Some(search)) => {
|
|
let pattern = format!("%{}%", search);
|
|
let total: i64 = sqlx::query_scalar(
|
|
"SELECT COUNT(*) FROM accounts WHERE role = $1 AND status = $2 AND (username LIKE $3 OR email LIKE $3 OR display_name LIKE $3)"
|
|
).bind(role).bind(status).bind(&pattern).fetch_one(db).await?;
|
|
let rows = sqlx::query_as::<_, AccountRow>(
|
|
"SELECT id, username, email, display_name, role, status, totp_enabled, last_login_at::TEXT, created_at::TEXT, llm_routing
|
|
FROM accounts WHERE role = $1 AND status = $2 AND (username LIKE $3 OR email LIKE $3 OR display_name LIKE $3)
|
|
ORDER BY created_at DESC LIMIT $4 OFFSET $5"
|
|
).bind(role).bind(status).bind(&pattern).bind(page_size as i64).bind(offset as i64).fetch_all(db).await?;
|
|
(total, rows)
|
|
}
|
|
// role + status
|
|
(Some(role), Some(status), None) => {
|
|
let total: i64 = sqlx::query_scalar(
|
|
"SELECT COUNT(*) FROM accounts WHERE role = $1 AND status = $2"
|
|
).bind(role).bind(status).fetch_one(db).await?;
|
|
let rows = sqlx::query_as::<_, AccountRow>(
|
|
"SELECT id, username, email, display_name, role, status, totp_enabled, last_login_at::TEXT, created_at::TEXT, llm_routing
|
|
FROM accounts WHERE role = $1 AND status = $2
|
|
ORDER BY created_at DESC LIMIT $3 OFFSET $4"
|
|
).bind(role).bind(status).bind(page_size as i64).bind(offset as i64).fetch_all(db).await?;
|
|
(total, rows)
|
|
}
|
|
// role + search
|
|
(Some(role), None, Some(search)) => {
|
|
let pattern = format!("%{}%", search);
|
|
let total: i64 = sqlx::query_scalar(
|
|
"SELECT COUNT(*) FROM accounts WHERE role = $1 AND (username LIKE $2 OR email LIKE $2 OR display_name LIKE $2)"
|
|
).bind(role).bind(&pattern).fetch_one(db).await?;
|
|
let rows = sqlx::query_as::<_, AccountRow>(
|
|
"SELECT id, username, email, display_name, role, status, totp_enabled, last_login_at::TEXT, created_at::TEXT, llm_routing
|
|
FROM accounts WHERE role = $1 AND (username LIKE $2 OR email LIKE $2 OR display_name LIKE $2)
|
|
ORDER BY created_at DESC LIMIT $3 OFFSET $4"
|
|
).bind(role).bind(&pattern).bind(page_size as i64).bind(offset as i64).fetch_all(db).await?;
|
|
(total, rows)
|
|
}
|
|
// status + search
|
|
(None, Some(status), Some(search)) => {
|
|
let pattern = format!("%{}%", search);
|
|
let total: i64 = sqlx::query_scalar(
|
|
"SELECT COUNT(*) FROM accounts WHERE status = $1 AND (username LIKE $2 OR email LIKE $2 OR display_name LIKE $2)"
|
|
).bind(status).bind(&pattern).fetch_one(db).await?;
|
|
let rows = sqlx::query_as::<_, AccountRow>(
|
|
"SELECT id, username, email, display_name, role, status, totp_enabled, last_login_at::TEXT, created_at::TEXT, llm_routing
|
|
FROM accounts WHERE status = $1 AND (username LIKE $2 OR email LIKE $2 OR display_name LIKE $2)
|
|
ORDER BY created_at DESC LIMIT $3 OFFSET $4"
|
|
).bind(status).bind(&pattern).bind(page_size as i64).bind(offset as i64).fetch_all(db).await?;
|
|
(total, rows)
|
|
}
|
|
// role only
|
|
(Some(role), None, None) => {
|
|
let total: i64 = sqlx::query_scalar(
|
|
"SELECT COUNT(*) FROM accounts WHERE role = $1"
|
|
).bind(role).fetch_one(db).await?;
|
|
let rows = sqlx::query_as::<_, AccountRow>(
|
|
"SELECT id, username, email, display_name, role, status, totp_enabled, last_login_at::TEXT, created_at::TEXT, llm_routing
|
|
FROM accounts WHERE role = $1
|
|
ORDER BY created_at DESC LIMIT $2 OFFSET $3"
|
|
).bind(role).bind(page_size as i64).bind(offset as i64).fetch_all(db).await?;
|
|
(total, rows)
|
|
}
|
|
// status only
|
|
(None, Some(status), None) => {
|
|
let total: i64 = sqlx::query_scalar(
|
|
"SELECT COUNT(*) FROM accounts WHERE status = $1"
|
|
).bind(status).fetch_one(db).await?;
|
|
let rows = sqlx::query_as::<_, AccountRow>(
|
|
"SELECT id, username, email, display_name, role, status, totp_enabled, last_login_at::TEXT, created_at::TEXT, llm_routing
|
|
FROM accounts WHERE status = $1
|
|
ORDER BY created_at DESC LIMIT $2 OFFSET $3"
|
|
).bind(status).bind(page_size as i64).bind(offset as i64).fetch_all(db).await?;
|
|
(total, rows)
|
|
}
|
|
// search only
|
|
(None, None, Some(search)) => {
|
|
let pattern = format!("%{}%", search);
|
|
let total: i64 = sqlx::query_scalar(
|
|
"SELECT COUNT(*) FROM accounts WHERE (username LIKE $1 OR email LIKE $1 OR display_name LIKE $1)"
|
|
).bind(&pattern).fetch_one(db).await?;
|
|
let rows = sqlx::query_as::<_, AccountRow>(
|
|
"SELECT id, username, email, display_name, role, status, totp_enabled, last_login_at::TEXT, created_at::TEXT, llm_routing
|
|
FROM accounts WHERE (username LIKE $1 OR email LIKE $1 OR display_name LIKE $1)
|
|
ORDER BY created_at DESC LIMIT $2 OFFSET $3"
|
|
).bind(&pattern).bind(page_size as i64).bind(offset as i64).fetch_all(db).await?;
|
|
(total, rows)
|
|
}
|
|
// no filter
|
|
(None, None, None) => {
|
|
let total: i64 = sqlx::query_scalar(
|
|
"SELECT COUNT(*) FROM accounts"
|
|
).fetch_one(db).await?;
|
|
let rows = sqlx::query_as::<_, AccountRow>(
|
|
"SELECT id, username, email, display_name, role, status, totp_enabled, last_login_at::TEXT, created_at::TEXT, llm_routing
|
|
FROM accounts ORDER BY created_at DESC LIMIT $1 OFFSET $2"
|
|
).bind(page_size as i64).bind(offset as i64).fetch_all(db).await?;
|
|
(total, rows)
|
|
}
|
|
};
|
|
|
|
let items: Vec<serde_json::Value> = rows
|
|
.into_iter()
|
|
.map(|r| {
|
|
serde_json::json!({
|
|
"id": r.id, "username": r.username, "email": r.email, "display_name": r.display_name,
|
|
"role": r.role, "status": r.status, "totp_enabled": r.totp_enabled,
|
|
"last_login_at": r.last_login_at, "created_at": r.created_at, "llm_routing": r.llm_routing,
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
Ok(PaginatedResponse { items, total, page, page_size })
|
|
}
|
|
|
|
pub async fn get_account(db: &PgPool, account_id: &str) -> SaasResult<serde_json::Value> {
|
|
let row: Option<AccountRow> =
|
|
sqlx::query_as(
|
|
"SELECT id, username, email, display_name, role, status, totp_enabled, last_login_at::TEXT, created_at::TEXT, llm_routing
|
|
FROM accounts WHERE id = $1"
|
|
)
|
|
.bind(account_id)
|
|
.fetch_optional(db)
|
|
.await?;
|
|
|
|
let r = row.ok_or_else(|| SaasError::NotFound(format!("账号 {} 不存在", account_id)))?;
|
|
|
|
Ok(serde_json::json!({
|
|
"id": r.id, "username": r.username, "email": r.email, "display_name": r.display_name,
|
|
"role": r.role, "status": r.status, "totp_enabled": r.totp_enabled,
|
|
"last_login_at": r.last_login_at, "created_at": r.created_at, "llm_routing": r.llm_routing,
|
|
}))
|
|
}
|
|
|
|
pub async fn update_account(
|
|
db: &PgPool,
|
|
account_id: &str,
|
|
req: &UpdateAccountRequest,
|
|
) -> SaasResult<serde_json::Value> {
|
|
let now = chrono::Utc::now();
|
|
|
|
// COALESCE pattern: all updatable fields in a single static SQL.
|
|
// NULL parameters leave the column unchanged.
|
|
sqlx::query(
|
|
"UPDATE accounts SET
|
|
display_name = COALESCE($1, display_name),
|
|
email = COALESCE($2, email),
|
|
role = COALESCE($3, role),
|
|
avatar_url = COALESCE($4, avatar_url),
|
|
llm_routing = COALESCE($5, llm_routing),
|
|
updated_at = $6
|
|
WHERE id = $7"
|
|
)
|
|
.bind(req.display_name.as_deref())
|
|
.bind(req.email.as_deref())
|
|
.bind(req.role.as_deref())
|
|
.bind(req.avatar_url.as_deref())
|
|
.bind(req.llm_routing.as_deref())
|
|
.bind(&now)
|
|
.bind(account_id)
|
|
.execute(db).await?;
|
|
|
|
get_account(db, account_id).await
|
|
}
|
|
|
|
pub async fn update_account_status(
|
|
db: &PgPool,
|
|
account_id: &str,
|
|
status: &str,
|
|
) -> SaasResult<()> {
|
|
let valid = ["active", "disabled", "suspended"];
|
|
if !valid.contains(&status) {
|
|
return Err(SaasError::InvalidInput(format!("无效状态: {},有效值: {:?}", status, valid)));
|
|
}
|
|
let now = chrono::Utc::now();
|
|
let result = sqlx::query("UPDATE accounts SET status = $1, updated_at = $2 WHERE id = $3")
|
|
.bind(status).bind(&now).bind(account_id)
|
|
.execute(db).await?;
|
|
|
|
if result.rows_affected() == 0 {
|
|
return Err(SaasError::NotFound(format!("账号 {} 不存在", account_id)));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn create_api_token(
|
|
db: &PgPool,
|
|
account_id: &str,
|
|
req: &CreateTokenRequest,
|
|
) -> SaasResult<TokenInfo> {
|
|
use sha2::{Sha256, Digest};
|
|
|
|
let mut bytes = [0u8; 48];
|
|
use rand::RngCore;
|
|
rand::thread_rng().fill_bytes(&mut bytes);
|
|
let raw_token = format!("zclaw_{}", hex::encode(bytes));
|
|
let token_hash = hex::encode(Sha256::digest(raw_token.as_bytes()));
|
|
let token_prefix = raw_token[..8].to_string();
|
|
|
|
let now = chrono::Utc::now();
|
|
let expires_at = req.expires_days.map(|d| {
|
|
chrono::Utc::now() + chrono::Duration::days(d)
|
|
});
|
|
let permissions = serde_json::to_string(&req.permissions)?;
|
|
let token_id = uuid::Uuid::new_v4().to_string();
|
|
|
|
sqlx::query(
|
|
"INSERT INTO api_tokens (id, account_id, name, token_hash, token_prefix, permissions, created_at, expires_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)"
|
|
)
|
|
.bind(&token_id)
|
|
.bind(account_id)
|
|
.bind(&req.name)
|
|
.bind(&token_hash)
|
|
.bind(&token_prefix)
|
|
.bind(&permissions)
|
|
.bind(&now)
|
|
.bind(&expires_at)
|
|
.execute(db)
|
|
.await?;
|
|
|
|
Ok(TokenInfo {
|
|
id: token_id,
|
|
name: req.name.clone(),
|
|
token_prefix,
|
|
permissions: req.permissions.clone(),
|
|
last_used_at: None,
|
|
expires_at: expires_at.map(|dt| dt.to_rfc3339()),
|
|
created_at: now.to_rfc3339(),
|
|
token: Some(raw_token),
|
|
})
|
|
}
|
|
|
|
pub async fn list_api_tokens(
|
|
db: &PgPool,
|
|
account_id: &str,
|
|
page: Option<u32>,
|
|
page_size: Option<u32>,
|
|
) -> SaasResult<PaginatedResponse<TokenInfo>> {
|
|
let (p, ps, offset) = normalize_pagination(page, page_size);
|
|
|
|
let total: (i64,) = sqlx::query_as(
|
|
"SELECT COUNT(*) FROM api_tokens WHERE account_id = $1 AND revoked_at IS NULL"
|
|
)
|
|
.bind(account_id)
|
|
.fetch_one(db)
|
|
.await?;
|
|
|
|
let rows: Vec<ApiTokenRow> =
|
|
sqlx::query_as(
|
|
"SELECT id, name, token_prefix, permissions, last_used_at::TEXT, expires_at::TEXT, created_at::TEXT
|
|
FROM api_tokens WHERE account_id = $1 AND revoked_at IS NULL ORDER BY created_at DESC LIMIT $2 OFFSET $3"
|
|
)
|
|
.bind(account_id)
|
|
.bind(ps as i64)
|
|
.bind(offset)
|
|
.fetch_all(db)
|
|
.await?;
|
|
|
|
let items = rows.into_iter().map(|r| {
|
|
let permissions: Vec<String> = serde_json::from_str(&r.permissions).unwrap_or_default();
|
|
TokenInfo { id: r.id, name: r.name, token_prefix: r.token_prefix, permissions, last_used_at: r.last_used_at, expires_at: r.expires_at, created_at: r.created_at, token: None, }
|
|
}).collect();
|
|
|
|
Ok(PaginatedResponse { items, total: total.0, page: p, page_size: ps })
|
|
}
|
|
|
|
pub async fn list_devices(
|
|
db: &PgPool,
|
|
account_id: &str,
|
|
page: Option<u32>,
|
|
page_size: Option<u32>,
|
|
) -> SaasResult<PaginatedResponse<serde_json::Value>> {
|
|
let (p, ps, offset) = normalize_pagination(page, page_size);
|
|
|
|
let total: (i64,) = sqlx::query_as(
|
|
"SELECT COUNT(*) FROM devices WHERE account_id = $1"
|
|
)
|
|
.bind(account_id)
|
|
.fetch_one(db)
|
|
.await?;
|
|
|
|
let rows: Vec<DeviceRow> =
|
|
sqlx::query_as(
|
|
"SELECT id, device_id, device_name, platform, app_version, last_seen_at::TEXT, created_at::TEXT
|
|
FROM devices WHERE account_id = $1 ORDER BY last_seen_at DESC LIMIT $2 OFFSET $3"
|
|
)
|
|
.bind(account_id)
|
|
.bind(ps as i64)
|
|
.bind(offset)
|
|
.fetch_all(db)
|
|
.await?;
|
|
|
|
let items: Vec<serde_json::Value> = rows.into_iter().map(|r| {
|
|
serde_json::json!({
|
|
"id": r.id, "device_id": r.device_id,
|
|
"device_name": r.device_name, "platform": r.platform, "app_version": r.app_version,
|
|
"last_seen_at": r.last_seen_at, "created_at": r.created_at,
|
|
})
|
|
}).collect();
|
|
|
|
Ok(PaginatedResponse { items, total: total.0, page: p, page_size: ps })
|
|
}
|
|
|
|
pub async fn revoke_api_token(db: &PgPool, token_id: &str, account_id: &str) -> SaasResult<()> {
|
|
let now = chrono::Utc::now();
|
|
let result = sqlx::query(
|
|
"UPDATE api_tokens SET revoked_at = $1 WHERE id = $2 AND account_id = $3 AND revoked_at IS NULL"
|
|
)
|
|
.bind(&now).bind(token_id).bind(account_id)
|
|
.execute(db).await?;
|
|
|
|
if result.rows_affected() == 0 {
|
|
return Err(SaasError::NotFound("Token 不存在或已撤销".into()));
|
|
}
|
|
Ok(())
|
|
}
|