//! 账号管理 HTTP 处理器 use axum::{ extract::{Extension, Path, Query, State}, Json, }; use crate::state::AppState; use crate::error::{SaasError, SaasResult}; use crate::auth::types::AuthContext; use crate::auth::handlers::{log_operation, check_permission}; use crate::models::{OperationLogRow, DashboardStatsRow, DashboardTodayRow}; use super::{types::*, service}; fn require_admin(ctx: &AuthContext) -> SaasResult<()> { check_permission(ctx, "account:admin") } /// GET /api/v1/accounts (admin only) pub async fn list_accounts( State(state): State, Query(query): Query, Extension(ctx): Extension, ) -> SaasResult>> { require_admin(&ctx)?; service::list_accounts(&state.db, &query).await.map(Json) } /// GET /api/v1/accounts/:id pub async fn get_account( State(state): State, Path(id): Path, Extension(ctx): Extension, ) -> SaasResult> { // 只能查看自己,或 admin 查看任何人 if id != ctx.account_id { require_admin(&ctx)?; } service::get_account(&state.db, &id).await.map(Json) } /// PATCH /api/v1/accounts/:id (admin or self for limited fields) pub async fn update_account( State(state): State, Path(id): Path, Extension(ctx): Extension, Json(req): Json, ) -> SaasResult> { let is_self_update = id == ctx.account_id; // 非管理员只能修改自己的资料 if !is_self_update { require_admin(&ctx)?; } // 安全限制: 非管理员修改自己时,剥离 role 字段防止自角色提升 let safe_req = if is_self_update && !ctx.permissions.contains(&"admin:full".to_string()) { UpdateAccountRequest { role: None, ..req } } else { req }; let result = service::update_account(&state.db, &id, &safe_req).await?; log_operation(&state.db, &ctx.account_id, "account.update", "account", &id, None, ctx.client_ip.as_deref()).await?; Ok(Json(result)) } /// PATCH /api/v1/accounts/:id/status (admin only) pub async fn update_status( State(state): State, Path(id): Path, Extension(ctx): Extension, Json(req): Json, ) -> SaasResult> { require_admin(&ctx)?; service::update_account_status(&state.db, &id, &req.status).await?; log_operation(&state.db, &ctx.account_id, "account.update_status", "account", &id, Some(serde_json::json!({"status": &req.status})), ctx.client_ip.as_deref()).await?; Ok(Json(serde_json::json!({"ok": true}))) } /// GET /api/v1/tokens?page=1&page_size=20 pub async fn list_tokens( State(state): State, Extension(ctx): Extension, Query(params): Query>, ) -> SaasResult>> { let page = params.get("page").and_then(|v| v.parse().ok()); let page_size = params.get("page_size").and_then(|v| v.parse().ok()); service::list_api_tokens(&state.db, &ctx.account_id, page, page_size).await.map(Json) } /// POST /api/v1/tokens pub async fn create_token( State(state): State, Extension(ctx): Extension, Json(req): Json, ) -> SaasResult> { // 权限校验: 创建的 token 不能超出创建者已有的权限 let allowed_permissions: Vec = req.permissions .into_iter() .filter(|p| ctx.permissions.contains(p)) .collect(); if allowed_permissions.is_empty() { return Err(SaasError::InvalidInput("请求的权限均不被允许".into())); } let filtered_req = CreateTokenRequest { name: req.name, permissions: allowed_permissions, expires_days: req.expires_days, }; let token = service::create_api_token(&state.db, &ctx.account_id, &filtered_req).await?; log_operation(&state.db, &ctx.account_id, "token.create", "api_token", &token.id, Some(serde_json::json!({"name": &filtered_req.name})), ctx.client_ip.as_deref()).await?; Ok(Json(token)) } /// DELETE /api/v1/tokens/:id pub async fn revoke_token( State(state): State, Path(id): Path, Extension(ctx): Extension, ) -> SaasResult> { service::revoke_api_token(&state.db, &id, &ctx.account_id).await?; log_operation(&state.db, &ctx.account_id, "token.revoke", "api_token", &id, None, ctx.client_ip.as_deref()).await?; Ok(Json(serde_json::json!({"ok": true}))) } /// GET /api/v1/logs/operations (admin only) pub async fn list_operation_logs( State(state): State, Query(params): Query>, Extension(ctx): Extension, ) -> SaasResult>> { require_admin(&ctx)?; let page: u32 = params.get("page").and_then(|v| v.parse().ok()).unwrap_or(1).max(1); let page_size: u32 = params.get("page_size").and_then(|v| v.parse().ok()).unwrap_or(50).min(100); let offset = ((page - 1) * page_size) as i64; let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM operation_logs") .fetch_one(&state.db).await?; let rows: Vec = sqlx::query_as( "SELECT id, account_id, action, target_type, target_id, details, ip_address, created_at::TEXT FROM operation_logs ORDER BY created_at DESC LIMIT $1 OFFSET $2" ) .bind(page_size as i64) .bind(offset) .fetch_all(&state.db) .await?; let items: Vec = rows.into_iter().map(|r| { serde_json::json!({ "id": r.id, "account_id": r.account_id, "action": r.action, "target_type": r.target_type, "target_id": r.target_id, "details": r.details.and_then(|d| serde_json::from_str::(&d).ok()), "ip_address": r.ip_address, "created_at": r.created_at, }) }).collect(); Ok(Json(PaginatedResponse { items, total, page, page_size })) } /// GET /api/v1/stats/dashboard — 仪表盘聚合统计 (需要 admin 权限) pub async fn dashboard_stats( State(state): State, Extension(ctx): Extension, ) -> SaasResult> { require_admin(&ctx)?; // 查询 1: 账号 + Provider + Model 聚合 (一次查询) let stats_row: DashboardStatsRow = sqlx::query_as( "SELECT (SELECT COUNT(*) FROM accounts) as total_accounts, (SELECT COUNT(*) FROM accounts WHERE status = 'active') as active_accounts, (SELECT COUNT(*) FROM providers WHERE enabled = true) as active_providers, (SELECT COUNT(*) FROM models WHERE enabled = true) as active_models" ).fetch_one(&state.db).await?; // 查询 2: 今日中转统计 — 使用范围查询走 B-tree 索引 let today_start = chrono::Utc::now() .date_naive() .and_hms_opt(0, 0, 0).expect("midnight is always valid") .and_utc(); let tomorrow_start = (chrono::Utc::now() + chrono::Duration::days(1)) .date_naive() .and_hms_opt(0, 0, 0).expect("midnight is always valid") .and_utc(); let today_row: DashboardTodayRow = sqlx::query_as( "SELECT (SELECT COUNT(*) FROM relay_tasks WHERE created_at::timestamptz >= $1 AND created_at::timestamptz < $2) as tasks_today, COALESCE((SELECT SUM(input_tokens) FROM usage_records WHERE created_at::timestamptz >= $1 AND created_at::timestamptz < $2), 0)::bigint as tokens_input, COALESCE((SELECT SUM(output_tokens) FROM usage_records WHERE created_at::timestamptz >= $1 AND created_at::timestamptz < $2), 0)::bigint as tokens_output" ).bind(&today_start).bind(&tomorrow_start).fetch_one(&state.db).await?; Ok(Json(serde_json::json!({ "total_accounts": stats_row.total_accounts, "active_accounts": stats_row.active_accounts, "tasks_today": today_row.tasks_today, "active_providers": stats_row.active_providers, "active_models": stats_row.active_models, "tokens_today_input": today_row.tokens_input, "tokens_today_output": today_row.tokens_output, }))) } // ============ Devices ============ #[derive(serde::Deserialize)] pub struct RegisterDeviceRequest { #[serde(default)] device_id: String, #[serde(default)] device_name: String, #[serde(default)] platform: String, #[serde(default)] app_version: String, } /// POST /api/v1/devices/register — 注册或更新设备 pub async fn register_device( State(state): State, Extension(ctx): Extension, Json(req): Json, ) -> SaasResult> { // 输入验证 if req.device_id.is_empty() || req.device_id.len() > 64 { return Err(SaasError::InvalidInput("device_id 必须为 1-64 个字符".into())); } if req.device_name.len() > 128 { return Err(SaasError::InvalidInput("device_name 最多 128 个字符".into())); } if req.platform.len() > 32 { return Err(SaasError::InvalidInput("platform 最多 32 个字符".into())); } if req.app_version.len() > 32 { return Err(SaasError::InvalidInput("app_version 最多 32 个字符".into())); } let device_name = if req.device_name.is_empty() { "Unknown" } else { &req.device_name }; let platform = if req.platform.is_empty() { "unknown" } else { &req.platform }; let now = chrono::Utc::now(); let device_uuid = uuid::Uuid::new_v4().to_string(); // UPSERT: 已存在则更新 last_seen_at,不存在则插入 sqlx::query( "INSERT INTO devices (id, account_id, device_id, device_name, platform, app_version, last_seen_at, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $7) ON CONFLICT(account_id, device_id) DO UPDATE SET device_name = $4, platform = $5, app_version = $6, last_seen_at = $7" ) .bind(&device_uuid) .bind(&ctx.account_id) .bind(&req.device_id) .bind(device_name) .bind(platform) .bind(&req.app_version) .bind(&now) .execute(&state.db) .await?; log_operation(&state.db, &ctx.account_id, "device.register", "device", &req.device_id, Some(serde_json::json!({"device_name": device_name, "platform": platform})), ctx.client_ip.as_deref()).await?; Ok(Json(serde_json::json!({"ok": true, "device_id": req.device_id}))) } /// POST /api/v1/devices/heartbeat — 设备心跳 pub async fn device_heartbeat( State(state): State, Extension(ctx): Extension, Json(req): Json, ) -> SaasResult> { let device_id = req.get("device_id") .and_then(|v| v.as_str()) .ok_or_else(|| SaasError::InvalidInput("缺少 device_id".into()))?; // Validate device_id length (must match register endpoint constraints) if device_id.is_empty() || device_id.len() > 64 { return Err(SaasError::InvalidInput("device_id 长度必须在 1-64 个字符之间".into())); } let now = chrono::Utc::now(); // Also update platform/app_version if provided (supports client upgrades) let platform = req.get("platform").and_then(|v| v.as_str()); let app_version = req.get("app_version").and_then(|v| v.as_str()); let result = if platform.is_some() || app_version.is_some() { sqlx::query( "UPDATE devices SET last_seen_at = $1, platform = COALESCE($4, platform), app_version = COALESCE($5, app_version) WHERE account_id = $2 AND device_id = $3" ) .bind(&now) .bind(&ctx.account_id) .bind(device_id) .bind(platform) .bind(app_version) .execute(&state.db) .await? } else { sqlx::query( "UPDATE devices SET last_seen_at = $1 WHERE account_id = $2 AND device_id = $3" ) .bind(&now) .bind(&ctx.account_id) .bind(device_id) .execute(&state.db) .await? }; if result.rows_affected() == 0 { return Err(SaasError::NotFound("设备未注册".into())); } Ok(Json(serde_json::json!({"ok": true}))) } /// GET /api/v1/devices?page=1&page_size=20 — 列出当前用户的设备 pub async fn list_devices( State(state): State, Extension(ctx): Extension, Query(params): Query>, ) -> SaasResult>> { let page = params.get("page").and_then(|v| v.parse().ok()); let page_size = params.get("page_size").and_then(|v| v.parse().ok()); service::list_devices(&state.db, &ctx.account_id, page, page_size).await.map(Json) }