//! 账号管理业务逻辑 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> { 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 = 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 { let row: Option = 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 { 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 { 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, page_size: Option, ) -> SaasResult> { 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 = 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 = 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, page_size: Option, ) -> SaasResult> { 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 = 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 = 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(()) }