chore: 提交所有工作进度 — SaaS 后端增强、Admin UI、桌面端集成
包含大量 SaaS 平台改进、Admin 管理后台更新、桌面端集成完善、 文档同步、测试文件重构等内容。为 QA 测试准备干净工作树。
This commit is contained in:
130
crates/zclaw-saas/src/role/handlers.rs
Normal file
130
crates/zclaw-saas/src/role/handlers.rs
Normal file
@@ -0,0 +1,130 @@
|
||||
//! 角色管理 HTTP 处理器
|
||||
|
||||
use axum::{
|
||||
extract::{Extension, Json, Path, State},
|
||||
http::StatusCode,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
use crate::error::SaasResult;
|
||||
use crate::auth::types::AuthContext;
|
||||
use crate::auth::handlers::{check_permission, log_operation};
|
||||
use super::{types::*, service};
|
||||
|
||||
fn require_admin(ctx: &AuthContext) -> SaasResult<()> {
|
||||
check_permission(ctx, "account:admin")
|
||||
}
|
||||
|
||||
/// GET /api/v1/roles
|
||||
pub async fn list_roles(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
) -> SaasResult<Json<Vec<RoleInfo>>> {
|
||||
check_permission(&ctx, "account:read")?;
|
||||
service::list_roles(&state.db).await.map(Json)
|
||||
}
|
||||
|
||||
/// GET /api/v1/roles/:id
|
||||
pub async fn get_role(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
) -> SaasResult<Json<RoleInfo>> {
|
||||
check_permission(&ctx, "account:read")?;
|
||||
service::get_role(&state.db, &id).await.map(Json)
|
||||
}
|
||||
|
||||
/// POST /api/v1/roles
|
||||
pub async fn create_role(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
Json(req): Json<CreateRoleRequest>,
|
||||
) -> SaasResult<(StatusCode, Json<RoleInfo>)> {
|
||||
require_admin(&ctx)?;
|
||||
let role = service::create_role(&state.db, &req).await?;
|
||||
log_operation(&state.db, &ctx.account_id, "role.create", "role", &role.id,
|
||||
Some(serde_json::json!({"name": &req.name})), ctx.client_ip.as_deref()).await?;
|
||||
Ok((StatusCode::CREATED, Json(role)))
|
||||
}
|
||||
|
||||
/// PUT /api/v1/roles/:id
|
||||
pub async fn update_role(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
Json(req): Json<UpdateRoleRequest>,
|
||||
) -> SaasResult<Json<RoleInfo>> {
|
||||
require_admin(&ctx)?;
|
||||
let role = service::update_role(&state.db, &id, &req).await?;
|
||||
log_operation(&state.db, &ctx.account_id, "role.update", "role", &id, None, ctx.client_ip.as_deref()).await?;
|
||||
Ok(Json(role))
|
||||
}
|
||||
|
||||
/// DELETE /api/v1/roles/:id
|
||||
pub async fn delete_role(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
) -> SaasResult<Json<serde_json::Value>> {
|
||||
require_admin(&ctx)?;
|
||||
service::delete_role(&state.db, &id).await?;
|
||||
log_operation(&state.db, &ctx.account_id, "role.delete", "role", &id, None, ctx.client_ip.as_deref()).await?;
|
||||
Ok(Json(serde_json::json!({"ok": true})))
|
||||
}
|
||||
|
||||
/// GET /api/v1/permission-templates
|
||||
pub async fn list_templates(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
) -> SaasResult<Json<Vec<PermissionTemplate>>> {
|
||||
check_permission(&ctx, "account:read")?;
|
||||
service::list_templates(&state.db).await.map(Json)
|
||||
}
|
||||
|
||||
/// GET /api/v1/permission-templates/:id
|
||||
pub async fn get_template(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
) -> SaasResult<Json<PermissionTemplate>> {
|
||||
check_permission(&ctx, "account:read")?;
|
||||
service::get_template(&state.db, &id).await.map(Json)
|
||||
}
|
||||
|
||||
/// POST /api/v1/permission-templates
|
||||
pub async fn create_template(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
Json(req): Json<CreateTemplateRequest>,
|
||||
) -> SaasResult<(StatusCode, Json<PermissionTemplate>)> {
|
||||
require_admin(&ctx)?;
|
||||
let template = service::create_template(&state.db, &req).await?;
|
||||
log_operation(&state.db, &ctx.account_id, "template.create", "permission_template", &template.id,
|
||||
Some(serde_json::json!({"name": &req.name})), ctx.client_ip.as_deref()).await?;
|
||||
Ok((StatusCode::CREATED, Json(template)))
|
||||
}
|
||||
|
||||
/// DELETE /api/v1/permission-templates/:id
|
||||
pub async fn delete_template(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
) -> SaasResult<Json<serde_json::Value>> {
|
||||
require_admin(&ctx)?;
|
||||
service::delete_template(&state.db, &id).await?;
|
||||
log_operation(&state.db, &ctx.account_id, "template.delete", "permission_template", &id, None, ctx.client_ip.as_deref()).await?;
|
||||
Ok(Json(serde_json::json!({"ok": true})))
|
||||
}
|
||||
|
||||
/// POST /api/v1/permission-templates/:id/apply
|
||||
pub async fn apply_template(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
Json(req): Json<ApplyTemplateRequest>,
|
||||
) -> SaasResult<Json<serde_json::Value>> {
|
||||
require_admin(&ctx)?;
|
||||
let count = service::apply_template_to_accounts(&state.db, &id, &req.account_ids).await?;
|
||||
log_operation(&state.db, &ctx.account_id, "template.apply", "permission_template", &id,
|
||||
Some(serde_json::json!({"accounts": &req.account_ids, "applied_count": count})), ctx.client_ip.as_deref()).await?;
|
||||
Ok(Json(serde_json::json!({"ok": true, "applied_count": count})))
|
||||
}
|
||||
34
crates/zclaw-saas/src/role/handlers_ext.rs
Normal file
34
crates/zclaw-saas/src/role/handlers_ext.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
//! 角色管理模块
|
||||
//! handlers_ext - 获取角色权限列表(公开 API)
|
||||
|
||||
use axum::{
|
||||
extract::{Extension, Path, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
use crate::error::SaasResult;
|
||||
use crate::auth::types::AuthContext;
|
||||
use crate::auth::handlers::check_permission;
|
||||
use super::{types::*, service};
|
||||
|
||||
use crate::role::handlers_ext;
|
||||
|
||||
/// GET /api/v1/roles/:id/permissions - 公开 API,无需登录验证
|
||||
pub async fn get_role_permissions(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
) -> SaasResult<Json<Vec<String>>> {
|
||||
check_permission(&ctx, "account:read")?;
|
||||
|
||||
let row: Option<(String,)> = sqlx::query_as(
|
||||
"SELECT permissions FROM roles WHERE id = $1"
|
||||
)
|
||||
.bind(&id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
let permissions: Vec<String> = serde_json::from_str(&permissions_str)?;
|
||||
Ok(permissions)
|
||||
}
|
||||
31
crates/zclaw-saas/src/role/mod.rs
Normal file
31
crates/zclaw-saas/src/role/mod.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
//! 角色管理模块
|
||||
|
||||
pub mod types;
|
||||
pub mod service;
|
||||
pub mod handlers;
|
||||
|
||||
pub mod handlers_ext;
|
||||
|
||||
use axum::routing::{get, post};
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn routes() -> axum::Router<AppState> {
|
||||
axum::Router::new()
|
||||
.route("/api/v1/roles", get(handlers::list_roles).post(handlers::create_role))
|
||||
.route("/api/v1/roles/:id", get(handlers::get_role).put(handlers::update_role).delete(handlers::delete_role))
|
||||
.route("/api/v1/permission-templates", get(handlers::list_templates).post(handlers::create_template))
|
||||
.route("/api/v1/permission-templates/:id", get(handlers::get_template).delete(handlers::delete_template))
|
||||
.route("/api/v1/permission-templates/:id/apply", post(handlers::apply_template))
|
||||
.route("/api/v1/roles/:id/permissions", get(handlers::get_role_permissions))
|
||||
handlers
|
||||
}use axum::routing::{get, post};
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn routes() -> axum::Router<AppState> {
|
||||
axum::Router::new()
|
||||
.route("/api/v1/roles", get(handlers::list_roles).post(handlers::create_role))
|
||||
.route("/api/v1/roles/:id", get(handlers::get_role).put(handlers::update_role).delete(handlers::delete_role))
|
||||
.route("/api/v1/permission-templates", get(handlers::list_templates).post(handlers::create_template))
|
||||
.route("/api/v1/permission-templates/:id", get(handlers::get_template).delete(handlers::delete_template))
|
||||
.route("/api/v1/permission-templates/:id/apply", post(handlers::apply_template))
|
||||
}
|
||||
238
crates/zclaw-saas/src/role/service.rs
Normal file
238
crates/zclaw-saas/src/role/service.rs
Normal file
@@ -0,0 +1,238 @@
|
||||
//! 角色管理业务逻辑
|
||||
|
||||
use sqlx::PgPool;
|
||||
use crate::error::{SaasError, SaasResult};
|
||||
use super::types::*;
|
||||
|
||||
pub async fn list_roles(db: &PgPool) -> SaasResult<Vec<RoleInfo>> {
|
||||
let rows: Vec<(String, String, Option<String>, String, bool, String, String)> =
|
||||
sqlx::query_as(
|
||||
"SELECT id, name, description, permissions, is_system, created_at, updated_at
|
||||
FROM roles ORDER BY
|
||||
CASE id
|
||||
WHEN 'super_admin' THEN 1
|
||||
WHEN 'admin' THEN 2
|
||||
WHEN 'user' THEN 3
|
||||
ELSE 4
|
||||
END"
|
||||
)
|
||||
.fetch_all(db)
|
||||
.await?;
|
||||
|
||||
let roles = rows.into_iter().map(|(id, name, description, perms, is_system, created_at, updated_at)| {
|
||||
let permissions: Vec<String> = serde_json::from_str(&perms).unwrap_or_default();
|
||||
RoleInfo { id, name, description, permissions, is_system, created_at, updated_at }
|
||||
}).collect();
|
||||
|
||||
Ok(roles)
|
||||
}
|
||||
|
||||
pub async fn get_role(db: &PgPool, role_id: &str) -> SaasResult<RoleInfo> {
|
||||
let row: Option<(String, String, Option<String>, String, bool, String, String)> =
|
||||
sqlx::query_as(
|
||||
"SELECT id, name, description, permissions, is_system, created_at, updated_at
|
||||
FROM roles WHERE id = $1"
|
||||
)
|
||||
.bind(role_id)
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
|
||||
let (id, name, description, perms, is_system, created_at, updated_at) =
|
||||
row.ok_or_else(|| SaasError::NotFound(format!("角色 {} 不存在", role_id)))?;
|
||||
|
||||
let permissions: Vec<String> = serde_json::from_str(&perms).unwrap_or_default();
|
||||
Ok(RoleInfo { id, name, description, permissions, is_system, created_at, updated_at })
|
||||
}
|
||||
|
||||
pub async fn create_role(db: &PgPool, req: &CreateRoleRequest) -> SaasResult<RoleInfo> {
|
||||
let existing: Option<(String,)> = sqlx::query_as(
|
||||
"SELECT id FROM roles WHERE id = $1"
|
||||
)
|
||||
.bind(&req.id)
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
|
||||
if existing.is_some() {
|
||||
return Err(SaasError::AlreadyExists(format!("角色 {} 已存在", req.id)));
|
||||
}
|
||||
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let permissions = serde_json::to_string(&req.permissions)?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO roles (id, name, description, permissions, is_system, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, false, $5, $5)"
|
||||
)
|
||||
.bind(&req.id)
|
||||
.bind(&req.name)
|
||||
.bind(&req.description)
|
||||
.bind(&permissions)
|
||||
.bind(&now)
|
||||
.execute(db)
|
||||
.await?;
|
||||
|
||||
Ok(RoleInfo {
|
||||
id: req.id.clone(),
|
||||
name: req.name.clone(),
|
||||
description: req.description.clone(),
|
||||
permissions: req.permissions.clone(),
|
||||
is_system: false,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn update_role(db: &PgPool, role_id: &str, req: &UpdateRoleRequest) -> SaasResult<RoleInfo> {
|
||||
let existing = get_role(db, role_id).await?;
|
||||
|
||||
if existing.is_system {
|
||||
return Err(SaasError::Forbidden("系统角色不可修改".into()));
|
||||
}
|
||||
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let name = req.name.as_ref().unwrap_or(&existing.name);
|
||||
let description = req.description.as_ref().or(existing.description.as_ref());
|
||||
let permissions = req.permissions.as_ref().unwrap_or(&existing.permissions);
|
||||
let permissions_json = serde_json::to_string(permissions)?;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE roles SET name = $1, description = $2, permissions = $3, updated_at = $4 WHERE id = $5"
|
||||
)
|
||||
.bind(name)
|
||||
.bind(description)
|
||||
.bind(&permissions_json)
|
||||
.bind(&now)
|
||||
.bind(role_id)
|
||||
.execute(db)
|
||||
.await?;
|
||||
|
||||
Ok(RoleInfo {
|
||||
id: role_id.to_string(),
|
||||
name: name.clone(),
|
||||
description: description.cloned(),
|
||||
permissions: permissions.clone(),
|
||||
is_system: false,
|
||||
created_at: existing.created_at,
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn delete_role(db: &PgPool, role_id: &str) -> SaasResult<()> {
|
||||
let existing = get_role(db, role_id).await?;
|
||||
|
||||
if existing.is_system {
|
||||
return Err(SaasError::Forbidden("系统角色不可删除".into()));
|
||||
}
|
||||
|
||||
let result = sqlx::query("DELETE FROM roles WHERE id = $1 AND is_system = false")
|
||||
.bind(role_id)
|
||||
.execute(db)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(SaasError::NotFound(format!("角色 {} 不存在", role_id)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn list_templates(db: &PgPool) -> SaasResult<Vec<PermissionTemplate>> {
|
||||
let rows: Vec<(String, String, Option<String>, String, String, String)> =
|
||||
sqlx::query_as(
|
||||
"SELECT id, name, description, permissions, created_at, updated_at
|
||||
FROM permission_templates ORDER BY created_at DESC"
|
||||
)
|
||||
.fetch_all(db)
|
||||
.await?;
|
||||
|
||||
let templates = rows.into_iter().map(|(id, name, description, perms, created_at, updated_at)| {
|
||||
let permissions: Vec<String> = serde_json::from_str(&perms).unwrap_or_default();
|
||||
PermissionTemplate { id, name, description, permissions, created_at, updated_at }
|
||||
}).collect();
|
||||
|
||||
Ok(templates)
|
||||
}
|
||||
|
||||
pub async fn get_template(db: &PgPool, template_id: &str) -> SaasResult<PermissionTemplate> {
|
||||
let row: Option<(String, String, Option<String>, String, String, String)> =
|
||||
sqlx::query_as(
|
||||
"SELECT id, name, description, permissions, created_at, updated_at
|
||||
FROM permission_templates WHERE id = $1"
|
||||
)
|
||||
.bind(template_id)
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
|
||||
let (id, name, description, perms, created_at, updated_at) =
|
||||
row.ok_or_else(|| SaasError::NotFound(format!("权限模板 {} 不存在", template_id)))?;
|
||||
|
||||
let permissions: Vec<String> = serde_json::from_str(&perms).unwrap_or_default();
|
||||
Ok(PermissionTemplate { id, name, description, permissions, created_at, updated_at })
|
||||
}
|
||||
|
||||
pub async fn create_template(db: &PgPool, req: &CreateTemplateRequest) -> SaasResult<PermissionTemplate> {
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let permissions = serde_json::to_string(&req.permissions)?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO permission_templates (id, name, description, permissions, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $5)"
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&req.name)
|
||||
.bind(&req.description)
|
||||
.bind(&permissions)
|
||||
.bind(&now)
|
||||
.execute(db)
|
||||
.await?;
|
||||
|
||||
Ok(PermissionTemplate {
|
||||
id,
|
||||
name: req.name.clone(),
|
||||
description: req.description.clone(),
|
||||
permissions: req.permissions.clone(),
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn delete_template(db: &PgPool, template_id: &str) -> SaasResult<()> {
|
||||
let result = sqlx::query("DELETE FROM permission_templates WHERE id = $1")
|
||||
.bind(template_id)
|
||||
.execute(db)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(SaasError::NotFound(format!("权限模板 {} 不存在", template_id)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn apply_template_to_accounts(
|
||||
db: &PgPool,
|
||||
template_id: &str,
|
||||
account_ids: &[String],
|
||||
) -> SaasResult<usize> {
|
||||
let template = get_template(db, template_id).await?;
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
let mut success_count = 0;
|
||||
for account_id in account_ids {
|
||||
let result = sqlx::query(
|
||||
"UPDATE accounts SET role = $1, updated_at = $2 WHERE id = $3"
|
||||
)
|
||||
.bind(&template.id)
|
||||
.bind(&now)
|
||||
.bind(account_id)
|
||||
.execute(db)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() > 0 {
|
||||
success_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(success_count)
|
||||
}
|
||||
51
crates/zclaw-saas/src/role/types.rs
Normal file
51
crates/zclaw-saas/src/role/types.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
//! 角色管理类型定义
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RoleInfo {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub permissions: Vec<String>,
|
||||
pub is_system: bool,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateRoleRequest {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub permissions: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateRoleRequest {
|
||||
pub name: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub permissions: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PermissionTemplate {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub permissions: Vec<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateTemplateRequest {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub permissions: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ApplyTemplateRequest {
|
||||
pub account_ids: Vec<String>,
|
||||
}
|
||||
Reference in New Issue
Block a user