Files
zclaw_openfang/crates/zclaw-saas/src/account/handlers.rs
iven 834aa12076
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: P0 panic风险修复 + P1编译warnings清零 + P2代码/文档清理
P0 安全性:
- account/handlers.rs: .unwrap() → .expect() 语义化错误信息
- relay/handlers.rs: SSE Response .unwrap() → .expect()

P1 编译质量 (6 warnings → 0):
- kernel.rs: 移除未使用的 Capability import 和 config_clone 变量
- pipeline_commands.rs: 未使用变量 id → _id
- db.rs: 移除多余括号
- relay/service.rs: 移除未使用的 StreamExt import
- telemetry/service.rs: 抑制 param_idx 未读赋值警告
- main.rs: TcpKeepalive::with_retries() Linux-only 条件编译

P2 代码清理:
- 移除 handStore/HandsPanel/HandTaskPanel/gateway-api/SchedulerPanel 调试 console.log
- SchedulerPanel: 修复 updateWorkflow 未解构导致 TS 编译错误
- 文档清理 zclaw-channels 已移除 crate 的引用
2026-03-30 11:33:47 +08:00

311 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 账号管理 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<AppState>,
Query(query): Query<ListAccountsQuery>,
Extension(ctx): Extension<AuthContext>,
) -> SaasResult<Json<PaginatedResponse<serde_json::Value>>> {
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<AppState>,
Path(id): Path<String>,
Extension(ctx): Extension<AuthContext>,
) -> SaasResult<Json<serde_json::Value>> {
// 只能查看自己,或 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<AppState>,
Path(id): Path<String>,
Extension(ctx): Extension<AuthContext>,
Json(req): Json<UpdateAccountRequest>,
) -> SaasResult<Json<serde_json::Value>> {
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<AppState>,
Path(id): Path<String>,
Extension(ctx): Extension<AuthContext>,
Json(req): Json<UpdateStatusRequest>,
) -> SaasResult<Json<serde_json::Value>> {
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<AppState>,
Extension(ctx): Extension<AuthContext>,
Query(params): Query<std::collections::HashMap<String, String>>,
) -> SaasResult<Json<PaginatedResponse<TokenInfo>>> {
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<AppState>,
Extension(ctx): Extension<AuthContext>,
Json(req): Json<CreateTokenRequest>,
) -> SaasResult<Json<TokenInfo>> {
// 权限校验: 创建的 token 不能超出创建者已有的权限
let allowed_permissions: Vec<String> = 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<AppState>,
Path(id): Path<String>,
Extension(ctx): Extension<AuthContext>,
) -> SaasResult<Json<serde_json::Value>> {
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<AppState>,
Query(params): Query<std::collections::HashMap<String, String>>,
Extension(ctx): Extension<AuthContext>,
) -> SaasResult<Json<PaginatedResponse<serde_json::Value>>> {
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<OperationLogRow> =
sqlx::query_as(
"SELECT id, account_id, action, target_type, target_id, details, ip_address, created_at
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<serde_json::Value> = 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::<serde_json::Value>(&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<AppState>,
Extension(ctx): Extension<AuthContext>,
) -> SaasResult<Json<serde_json::Value>> {
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()
.to_rfc3339();
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()
.to_rfc3339();
let today_row: DashboardTodayRow = sqlx::query_as(
"SELECT
(SELECT COUNT(*) FROM relay_tasks WHERE created_at >= $1 AND created_at < $2) as tasks_today,
COALESCE((SELECT SUM(input_tokens) FROM usage_records WHERE created_at >= $1 AND created_at < $2), 0) as tokens_input,
COALESCE((SELECT SUM(output_tokens) FROM usage_records WHERE created_at >= $1 AND created_at < $2), 0) 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 ============
/// POST /api/v1/devices/register — 注册或更新设备
pub async fn register_device(
State(state): State<AppState>,
Extension(ctx): Extension<AuthContext>,
Json(req): Json<serde_json::Value>,
) -> SaasResult<Json<serde_json::Value>> {
let device_id = req.get("device_id")
.and_then(|v| v.as_str())
.ok_or_else(|| SaasError::InvalidInput("缺少 device_id".into()))?;
let device_name = req.get("device_name").and_then(|v| v.as_str()).unwrap_or("Unknown");
let platform = req.get("platform").and_then(|v| v.as_str()).unwrap_or("unknown");
let app_version = req.get("app_version").and_then(|v| v.as_str()).unwrap_or("");
let now = chrono::Utc::now().to_rfc3339();
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(device_id)
.bind(device_name)
.bind(platform)
.bind(app_version)
.bind(&now)
.execute(&state.db)
.await?;
log_operation(&state.db, &ctx.account_id, "device.register", "device", 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": device_id})))
}
/// POST /api/v1/devices/heartbeat — 设备心跳
pub async fn device_heartbeat(
State(state): State<AppState>,
Extension(ctx): Extension<AuthContext>,
Json(req): Json<serde_json::Value>,
) -> SaasResult<Json<serde_json::Value>> {
let device_id = req.get("device_id")
.and_then(|v| v.as_str())
.ok_or_else(|| SaasError::InvalidInput("缺少 device_id".into()))?;
let now = chrono::Utc::now().to_rfc3339();
// 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<AppState>,
Extension(ctx): Extension<AuthContext>,
Query(params): Query<std::collections::HashMap<String, String>>,
) -> SaasResult<Json<PaginatedResponse<serde_json::Value>>> {
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)
}