chore: 提交所有工作进度 — SaaS 后端增强、Admin UI、桌面端集成
包含大量 SaaS 平台改进、Admin 管理后台更新、桌面端集成完善、 文档同步、测试文件重构等内容。为 QA 测试准备干净工作树。
This commit is contained in:
@@ -37,7 +37,7 @@ pub async fn get_account(
|
||||
service::get_account(&state.db, &id).await.map(Json)
|
||||
}
|
||||
|
||||
/// PUT /api/v1/accounts/:id (admin or self for limited fields)
|
||||
/// PATCH /api/v1/accounts/:id (admin or self for limited fields)
|
||||
pub async fn update_account(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
@@ -80,12 +80,15 @@ pub async fn update_status(
|
||||
Ok(Json(serde_json::json!({"ok": true})))
|
||||
}
|
||||
|
||||
/// GET /api/v1/tokens
|
||||
/// GET /api/v1/tokens?page=1&page_size=20
|
||||
pub async fn list_tokens(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
) -> SaasResult<Json<Vec<TokenInfo>>> {
|
||||
service::list_api_tokens(&state.db, &ctx.account_id).await.map(Json)
|
||||
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
|
||||
@@ -94,9 +97,24 @@ pub async fn create_token(
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
Json(req): Json<CreateTokenRequest>,
|
||||
) -> SaasResult<Json<TokenInfo>> {
|
||||
let token = service::create_api_token(&state.db, &ctx.account_id, &req).await?;
|
||||
// 权限校验: 创建的 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": &req.name})), ctx.client_ip.as_deref()).await?;
|
||||
Some(serde_json::json!({"name": &filtered_req.name})), ctx.client_ip.as_deref()).await?;
|
||||
Ok(Json(token))
|
||||
}
|
||||
|
||||
@@ -116,18 +134,21 @@ pub async fn list_operation_logs(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<std::collections::HashMap<String, String>>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
) -> SaasResult<Json<Vec<serde_json::Value>>> {
|
||||
) -> SaasResult<Json<PaginatedResponse<serde_json::Value>>> {
|
||||
require_admin(&ctx)?;
|
||||
let page: i64 = params.get("page").and_then(|v| v.parse().ok()).unwrap_or(1);
|
||||
let page_size: i64 = params.get("page_size").and_then(|v| v.parse().ok()).unwrap_or(50);
|
||||
let offset = (page - 1) * page_size;
|
||||
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<(i64, Option<String>, String, Option<String>, Option<String>, Option<String>, Option<String>, String)> =
|
||||
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"
|
||||
FROM operation_logs ORDER BY created_at DESC LIMIT $1 OFFSET $2"
|
||||
)
|
||||
.bind(page_size)
|
||||
.bind(page_size as i64)
|
||||
.bind(offset)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
@@ -141,7 +162,7 @@ pub async fn list_operation_logs(
|
||||
})
|
||||
}).collect();
|
||||
|
||||
Ok(Json(items))
|
||||
Ok(Json(PaginatedResponse { items, total, page, page_size }))
|
||||
}
|
||||
|
||||
/// GET /api/v1/stats/dashboard — 仪表盘聚合统计 (需要 admin 权限)
|
||||
@@ -151,32 +172,34 @@ pub async fn dashboard_stats(
|
||||
) -> SaasResult<Json<serde_json::Value>> {
|
||||
require_admin(&ctx)?;
|
||||
|
||||
let total_accounts: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM accounts")
|
||||
.fetch_one(&state.db).await?;
|
||||
let active_accounts: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM accounts WHERE status = 'active'")
|
||||
.fetch_one(&state.db).await?;
|
||||
let tasks_today: (i64,) = sqlx::query_as(
|
||||
"SELECT COUNT(*) FROM relay_tasks WHERE date(created_at) = date('now')"
|
||||
).fetch_one(&state.db).await?;
|
||||
let active_providers: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM providers WHERE enabled = 1")
|
||||
.fetch_one(&state.db).await?;
|
||||
let active_models: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM models WHERE enabled = 1")
|
||||
.fetch_one(&state.db).await?;
|
||||
let tokens_today_input: (i64,) = sqlx::query_as(
|
||||
"SELECT COALESCE(SUM(input_tokens), 0) FROM usage_records WHERE date(created_at) = date('now')"
|
||||
).fetch_one(&state.db).await?;
|
||||
let tokens_today_output: (i64,) = sqlx::query_as(
|
||||
"SELECT COALESCE(SUM(output_tokens), 0) FROM usage_records WHERE date(created_at) = date('now')"
|
||||
// 查询 1: 账号 + Provider + Model 聚合 (一次查询)
|
||||
let stats_row: (i64, i64, i64, i64) = 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?;
|
||||
let (total_accounts, active_accounts, active_providers, active_models) = stats_row;
|
||||
|
||||
// 查询 2: 今日中转统计 (一次查询)
|
||||
let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
|
||||
let today_row: (i64, i64, i64) = sqlx::query_as(
|
||||
"SELECT
|
||||
(SELECT COUNT(*) FROM relay_tasks WHERE SUBSTRING(created_at, 1, 10) = $1) as tasks_today,
|
||||
COALESCE((SELECT SUM(input_tokens) FROM usage_records WHERE SUBSTRING(created_at, 1, 10) = $1), 0) as tokens_input,
|
||||
COALESCE((SELECT SUM(output_tokens) FROM usage_records WHERE SUBSTRING(created_at, 1, 10) = $1), 0) as tokens_output"
|
||||
).bind(&today).fetch_one(&state.db).await?;
|
||||
let (tasks_today, tokens_today_input, tokens_today_output) = today_row;
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"total_accounts": total_accounts.0,
|
||||
"active_accounts": active_accounts.0,
|
||||
"tasks_today": tasks_today.0,
|
||||
"active_providers": active_providers.0,
|
||||
"active_models": active_models.0,
|
||||
"tokens_today_input": tokens_today_input.0,
|
||||
"tokens_today_output": tokens_today_output.0,
|
||||
"total_accounts": total_accounts,
|
||||
"active_accounts": active_accounts,
|
||||
"tasks_today": tasks_today,
|
||||
"active_providers": active_providers,
|
||||
"active_models": active_models,
|
||||
"tokens_today_input": tokens_today_input,
|
||||
"tokens_today_output": tokens_today_output,
|
||||
})))
|
||||
}
|
||||
|
||||
@@ -201,9 +224,9 @@ pub async fn register_device(
|
||||
// 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)
|
||||
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"
|
||||
device_name = $4, platform = $5, app_version = $6, last_seen_at = $7"
|
||||
)
|
||||
.bind(&device_uuid)
|
||||
.bind(&ctx.account_id)
|
||||
@@ -233,14 +256,32 @@ pub async fn device_heartbeat(
|
||||
.ok_or_else(|| SaasError::InvalidInput("缺少 device_id".into()))?;
|
||||
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let result = 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?;
|
||||
|
||||
// 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()));
|
||||
@@ -249,27 +290,13 @@ pub async fn device_heartbeat(
|
||||
Ok(Json(serde_json::json!({"ok": true})))
|
||||
}
|
||||
|
||||
/// GET /api/v1/devices — 列出当前用户的设备
|
||||
/// GET /api/v1/devices?page=1&page_size=20 — 列出当前用户的设备
|
||||
pub async fn list_devices(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
) -> SaasResult<Json<Vec<serde_json::Value>>> {
|
||||
let rows: Vec<(String, String, Option<String>, Option<String>, Option<String>, 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"
|
||||
)
|
||||
.bind(&ctx.account_id)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
|
||||
let items: Vec<serde_json::Value> = 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(Json(items))
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user