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
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 的引用
311 lines
12 KiB
Rust
311 lines
12 KiB
Rust
//! 账号管理 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)
|
||
}
|