chore: 提交所有工作进度 — SaaS 后端增强、Admin UI、桌面端集成

包含大量 SaaS 平台改进、Admin 管理后台更新、桌面端集成完善、
文档同步、测试文件重构等内容。为 QA 测试准备干净工作树。
This commit is contained in:
iven
2026-03-29 10:46:26 +08:00
parent 9a5fad2b59
commit 5fdf96c3f5
268 changed files with 22011 additions and 3886 deletions

View File

@@ -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)
}

View File

@@ -4,17 +4,17 @@ pub mod types;
pub mod service;
pub mod handlers;
use axum::routing::{delete, get, patch, post, put};
use axum::routing::{delete, get, patch, post};
pub fn routes() -> axum::Router<crate::state::AppState> {
axum::Router::new()
.route("/api/v1/accounts", get(handlers::list_accounts))
.route("/api/v1/accounts/{id}", get(handlers::get_account))
.route("/api/v1/accounts/{id}", put(handlers::update_account))
.route("/api/v1/accounts/{id}/status", patch(handlers::update_status))
.route("/api/v1/accounts/:id", get(handlers::get_account))
.route("/api/v1/accounts/:id", patch(handlers::update_account))
.route("/api/v1/accounts/:id/status", patch(handlers::update_status))
.route("/api/v1/tokens", get(handlers::list_tokens))
.route("/api/v1/tokens", post(handlers::create_token))
.route("/api/v1/tokens/{id}", delete(handlers::revoke_token))
.route("/api/v1/tokens/:id", delete(handlers::revoke_token))
.route("/api/v1/logs/operations", get(handlers::list_operation_logs))
.route("/api/v1/stats/dashboard", get(handlers::dashboard_stats))
.route("/api/v1/devices", get(handlers::list_devices))

View File

@@ -1,11 +1,12 @@
//! 账号管理业务逻辑
use sqlx::SqlitePool;
use sqlx::PgPool;
use crate::error::{SaasError, SaasResult};
use crate::common::{PaginatedResponse, normalize_pagination};
use super::types::*;
pub async fn list_accounts(
db: &SqlitePool,
db: &PgPool,
query: &ListAccountsQuery,
) -> SaasResult<PaginatedResponse<serde_json::Value>> {
let page = query.page.unwrap_or(1).max(1);
@@ -14,17 +15,21 @@ pub async fn list_accounts(
let mut where_clauses = Vec::new();
let mut params: Vec<String> = Vec::new();
let mut param_idx = 1usize;
if let Some(role) = &query.role {
where_clauses.push("role = ?".to_string());
where_clauses.push(format!("role = ${}", param_idx));
param_idx += 1;
params.push(role.clone());
}
if let Some(status) = &query.status {
where_clauses.push("status = ?".to_string());
where_clauses.push(format!("status = ${}", param_idx));
param_idx += 1;
params.push(status.clone());
}
if let Some(search) = &query.search {
where_clauses.push("(username LIKE ? OR email LIKE ? OR display_name LIKE ?)".to_string());
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());
@@ -44,10 +49,12 @@ pub async fn list_accounts(
}
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
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>, String)>(&data_sql);
for p in &params {
@@ -69,11 +76,11 @@ pub async fn list_accounts(
Ok(PaginatedResponse { items, total, page, page_size })
}
pub async fn get_account(db: &SqlitePool, account_id: &str) -> SaasResult<serde_json::Value> {
pub async fn get_account(db: &PgPool, account_id: &str) -> SaasResult<serde_json::Value> {
let row: Option<(String, String, String, String, String, String, bool, Option<String>, String)> =
sqlx::query_as(
"SELECT id, username, email, display_name, role, status, totp_enabled, last_login_at, created_at
FROM accounts WHERE id = ?1"
FROM accounts WHERE id = $1"
)
.bind(account_id)
.fetch_optional(db)
@@ -90,28 +97,30 @@ pub async fn get_account(db: &SqlitePool, account_id: &str) -> SaasResult<serde_
}
pub async fn update_account(
db: &SqlitePool,
db: &PgPool,
account_id: &str,
req: &UpdateAccountRequest,
) -> SaasResult<serde_json::Value> {
let now = chrono::Utc::now().to_rfc3339();
let mut updates = Vec::new();
let mut params: Vec<String> = Vec::new();
let mut param_idx = 1usize;
if let Some(ref v) = req.display_name { updates.push("display_name = ?"); params.push(v.clone()); }
if let Some(ref v) = req.email { updates.push("email = ?"); params.push(v.clone()); }
if let Some(ref v) = req.role { updates.push("role = ?"); params.push(v.clone()); }
if let Some(ref v) = req.avatar_url { updates.push("avatar_url = ?"); params.push(v.clone()); }
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("updated_at = ?");
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(", "));
let sql = format!("UPDATE accounts SET {} WHERE id = ${}", updates.join(", "), param_idx);
let mut query = sqlx::query(&sql);
for p in &params {
query = query.bind(p);
@@ -121,7 +130,7 @@ pub async fn update_account(
}
pub async fn update_account_status(
db: &SqlitePool,
db: &PgPool,
account_id: &str,
status: &str,
) -> SaasResult<()> {
@@ -130,7 +139,7 @@ pub async fn update_account_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")
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?;
@@ -141,7 +150,7 @@ pub async fn update_account_status(
}
pub async fn create_api_token(
db: &SqlitePool,
db: &PgPool,
account_id: &str,
req: &CreateTokenRequest,
) -> SaasResult<TokenInfo> {
@@ -163,7 +172,7 @@ pub async fn create_api_token(
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)"
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)"
)
.bind(&token_id)
.bind(account_id)
@@ -189,28 +198,80 @@ pub async fn create_api_token(
}
pub async fn list_api_tokens(
db: &SqlitePool,
db: &PgPool,
account_id: &str,
) -> SaasResult<Vec<TokenInfo>> {
page: Option<u32>,
page_size: Option<u32>,
) -> SaasResult<PaginatedResponse<TokenInfo>> {
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<String>, Option<String>, 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"
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?;
Ok(rows.into_iter().map(|(id, name, token_prefix, perms, last_used, expires, created)| {
let items = rows.into_iter().map(|(id, name, token_prefix, perms, last_used, expires, created)| {
let permissions: Vec<String> = 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())
}).collect();
Ok(PaginatedResponse { items, total: total.0, page: p, page_size: ps })
}
pub async fn revoke_api_token(db: &SqlitePool, token_id: &str, account_id: &str) -> SaasResult<()> {
pub async fn list_devices(
db: &PgPool,
account_id: &str,
page: Option<u32>,
page_size: Option<u32>,
) -> SaasResult<PaginatedResponse<serde_json::Value>> {
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<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 LIMIT $2 OFFSET $3"
)
.bind(account_id)
.bind(ps as i64)
.bind(offset)
.fetch_all(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(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"
"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?;

View File

@@ -2,6 +2,9 @@
use serde::{Deserialize, Serialize};
// Re-export from common module
pub use crate::common::PaginatedResponse;
#[derive(Debug, Deserialize)]
pub struct UpdateAccountRequest {
pub display_name: Option<String>,
@@ -24,14 +27,6 @@ pub struct ListAccountsQuery {
pub search: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct PaginatedResponse<T: Serialize> {
pub items: Vec<T>,
pub total: i64,
pub page: u32,
pub page_size: u32,
}
#[derive(Debug, Deserialize)]
pub struct CreateTokenRequest {
pub name: String,