//! 账号管理业务逻辑 use sqlx::PgPool; use crate::error::{SaasError, SaasResult}; use crate::common::{PaginatedResponse, normalize_pagination}; 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; let mut where_clauses = Vec::new(); let mut params: Vec = Vec::new(); let mut param_idx = 1usize; if let Some(role) = &query.role { where_clauses.push(format!("role = ${}", param_idx)); param_idx += 1; params.push(role.clone()); } if let Some(status) = &query.status { where_clauses.push(format!("status = ${}", param_idx)); param_idx += 1; params.push(status.clone()); } if let Some(search) = &query.search { where_clauses.push(format!("(username LIKE ${} OR email LIKE ${} OR display_name LIKE ${})", param_idx, param_idx + 1, param_idx + 2)); param_idx += 3; let pattern = format!("%{}%", search); params.push(pattern.clone()); params.push(pattern.clone()); params.push(pattern); } let where_sql = if where_clauses.is_empty() { String::new() } else { format!("WHERE {}", where_clauses.join(" AND ")) }; let count_sql = format!("SELECT COUNT(*) as count FROM accounts {}", where_sql); let mut count_query = sqlx::query_scalar::<_, i64>(&count_sql); for p in ¶ms { count_query = count_query.bind(p); } let total: i64 = count_query.fetch_one(db).await?; let limit_idx = param_idx; let offset_idx = param_idx + 1; let data_sql = format!( "SELECT id, username, email, display_name, role, status, totp_enabled, last_login_at, created_at FROM accounts {} ORDER BY created_at DESC LIMIT ${} OFFSET ${}", where_sql, limit_idx, offset_idx ); let mut data_query = sqlx::query_as::<_, (String, String, String, String, String, String, bool, Option, String)>(&data_sql); for p in ¶ms { data_query = data_query.bind(p); } let rows = data_query.bind(page_size as i64).bind(offset as i64).fetch_all(db).await?; let items: Vec = rows .into_iter() .map(|(id, username, email, display_name, role, status, totp_enabled, last_login_at, created_at)| { serde_json::json!({ "id": id, "username": username, "email": email, "display_name": display_name, "role": role, "status": status, "totp_enabled": totp_enabled, "last_login_at": last_login_at, "created_at": created_at, }) }) .collect(); Ok(PaginatedResponse { items, total, page, page_size }) } pub async fn get_account(db: &PgPool, account_id: &str) -> SaasResult { let row: Option<(String, String, String, String, String, String, bool, Option, String)> = sqlx::query_as( "SELECT id, username, email, display_name, role, status, totp_enabled, last_login_at, created_at FROM accounts WHERE id = $1" ) .bind(account_id) .fetch_optional(db) .await?; let (id, username, email, display_name, role, status, totp_enabled, last_login_at, created_at) = row.ok_or_else(|| SaasError::NotFound(format!("账号 {} 不存在", account_id)))?; Ok(serde_json::json!({ "id": id, "username": username, "email": email, "display_name": display_name, "role": role, "status": status, "totp_enabled": totp_enabled, "last_login_at": last_login_at, "created_at": created_at, })) } pub async fn update_account( db: &PgPool, account_id: &str, req: &UpdateAccountRequest, ) -> SaasResult { let now = chrono::Utc::now().to_rfc3339(); let mut updates = Vec::new(); let mut params: Vec = Vec::new(); let mut param_idx = 1usize; if let Some(ref v) = req.display_name { updates.push(format!("display_name = ${}", param_idx)); param_idx += 1; params.push(v.clone()); } if let Some(ref v) = req.email { updates.push(format!("email = ${}", param_idx)); param_idx += 1; params.push(v.clone()); } if let Some(ref v) = req.role { updates.push(format!("role = ${}", param_idx)); param_idx += 1; params.push(v.clone()); } if let Some(ref v) = req.avatar_url { updates.push(format!("avatar_url = ${}", param_idx)); param_idx += 1; params.push(v.clone()); } if updates.is_empty() { return get_account(db, account_id).await; } updates.push(format!("updated_at = ${}", param_idx)); param_idx += 1; params.push(now.clone()); params.push(account_id.to_string()); let sql = format!("UPDATE accounts SET {} WHERE id = ${}", updates.join(", "), param_idx); let mut query = sqlx::query(&sql); for p in ¶ms { query = query.bind(p); } query.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().to_rfc3339(); 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().to_rfc3339(); let expires_at = req.expires_days.map(|d| { (chrono::Utc::now() + chrono::Duration::days(d)).to_rfc3339() }); 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, created_at: now, 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<(String, String, String, String, Option, Option, String)> = sqlx::query_as( "SELECT id, name, token_prefix, permissions, last_used_at, expires_at, created_at 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(|(id, name, token_prefix, perms, last_used, expires, created)| { let permissions: Vec = serde_json::from_str(&perms).unwrap_or_default(); TokenInfo { id, name, token_prefix, permissions, last_used_at: last_used, expires_at: expires, created_at: created, 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<(String, String, Option, Option, Option, String, String)> = sqlx::query_as( "SELECT id, device_id, device_name, platform, app_version, last_seen_at, created_at 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.0, "device_id": r.1, "device_name": r.2, "platform": r.3, "app_version": r.4, "last_seen_at": r.5, "created_at": r.6, }) }).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().to_rfc3339(); 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(()) }