From 6fd0288e7c505ffc33877f3f45f8091c577663aa Mon Sep 17 00:00:00 2001 From: iven Date: Sat, 11 Apr 2026 03:46:54 +0800 Subject: [PATCH] feat(auth): add role/permission management (backend + frontend) - RoleService: CRUD, assign_permissions, get_role_permissions - PermissionService: list all tenant permissions - Role handlers: 8 endpoints with RBAC permission checks - Frontend Roles page: table, create/edit modal, permission assignment - Frontend Roles API: full CRUD + permission operations - Routes registered in AuthModule protected_routes --- apps/web/src/App.tsx | 3 +- apps/web/src/api/roles.ts | 73 ++++ apps/web/src/pages/Roles.tsx | 272 +++++++++++++++ crates/erp-auth/src/handler/mod.rs | 1 + crates/erp-auth/src/handler/role_handler.rs | 217 ++++++++++++ crates/erp-auth/src/module.rs | 21 +- crates/erp-auth/src/service/mod.rs | 2 + .../src/service/permission_service.rs | 38 +++ crates/erp-auth/src/service/role_service.rs | 321 ++++++++++++++++++ 9 files changed, 946 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/api/roles.ts create mode 100644 apps/web/src/pages/Roles.tsx create mode 100644 crates/erp-auth/src/handler/role_handler.rs create mode 100644 crates/erp-auth/src/service/permission_service.rs create mode 100644 crates/erp-auth/src/service/role_service.rs diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 08c47af..b15a130 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -5,6 +5,7 @@ import zhCN from 'antd/locale/zh_CN'; import MainLayout from './layouts/MainLayout'; import Login from './pages/Login'; import Home from './pages/Home'; +import Roles from './pages/Roles'; import { useAuthStore } from './stores/auth'; import { useAppStore } from './stores/app'; @@ -40,7 +41,7 @@ export default function App() { } /> 用户管理(开发中)} /> - 权限管理(开发中)} /> + } /> 系统设置(开发中)} /> diff --git a/apps/web/src/api/roles.ts b/apps/web/src/api/roles.ts new file mode 100644 index 0000000..02a50f3 --- /dev/null +++ b/apps/web/src/api/roles.ts @@ -0,0 +1,73 @@ +import client from './client'; +import type { PaginatedResponse } from './users'; + +export interface RoleInfo { + id: string; + name: string; + code: string; + description?: string; + is_system: boolean; +} + +export interface PermissionInfo { + id: string; + code: string; + name: string; + resource: string; + action: string; + description?: string; +} + +export interface CreateRoleRequest { + name: string; + code: string; + description?: string; +} + +export interface UpdateRoleRequest { + name?: string; + description?: string; +} + +export async function listRoles(page = 1, pageSize = 20) { + const { data } = await client.get<{ success: boolean; data: PaginatedResponse }>( + '/roles', + { params: { page, page_size: pageSize } }, + ); + return data.data; +} + +export async function getRole(id: string) { + const { data } = await client.get<{ success: boolean; data: RoleInfo }>(`/roles/${id}`); + return data.data; +} + +export async function createRole(req: CreateRoleRequest) { + const { data } = await client.post<{ success: boolean; data: RoleInfo }>('/roles', req); + return data.data; +} + +export async function updateRole(id: string, req: UpdateRoleRequest) { + const { data } = await client.put<{ success: boolean; data: RoleInfo }>(`/roles/${id}`, req); + return data.data; +} + +export async function deleteRole(id: string) { + await client.delete(`/roles/${id}`); +} + +export async function assignPermissions(roleId: string, permissionIds: string[]) { + await client.post(`/roles/${roleId}/permissions`, { permission_ids: permissionIds }); +} + +export async function getRolePermissions(roleId: string) { + const { data } = await client.get<{ success: boolean; data: PermissionInfo[] }>( + `/roles/${roleId}/permissions`, + ); + return data.data; +} + +export async function listPermissions() { + const { data } = await client.get<{ success: boolean; data: PermissionInfo[] }>('/permissions'); + return data.data; +} diff --git a/apps/web/src/pages/Roles.tsx b/apps/web/src/pages/Roles.tsx new file mode 100644 index 0000000..8be9fae --- /dev/null +++ b/apps/web/src/pages/Roles.tsx @@ -0,0 +1,272 @@ +import { useState, useEffect, useCallback } from 'react'; +import { + Table, + Button, + Space, + Modal, + Form, + Input, + Tag, + Popconfirm, + Checkbox, + message, + Typography, +} from 'antd'; +import { PlusOutlined } from '@ant-design/icons'; +import { + listRoles, + createRole, + updateRole, + deleteRole, + assignPermissions, + getRolePermissions, + listPermissions, + type RoleInfo, + type PermissionInfo, +} from '../api/roles'; + +export default function Roles() { + const [roles, setRoles] = useState([]); + const [permissions, setPermissions] = useState([]); + const [loading, setLoading] = useState(false); + const [createModalOpen, setCreateModalOpen] = useState(false); + const [editRole, setEditRole] = useState(null); + const [permModalOpen, setPermModalOpen] = useState(false); + const [selectedRole, setSelectedRole] = useState(null); + const [selectedPermIds, setSelectedPermIds] = useState([]); + const [form] = Form.useForm(); + + const fetchRoles = useCallback(async () => { + setLoading(true); + try { + const result = await listRoles(); + setRoles(result.data); + } catch { + message.error('加载角色失败'); + } + setLoading(false); + }, []); + + const fetchPermissions = useCallback(async () => { + try { + setPermissions(await listPermissions()); + } catch { + // Permissions may not be seeded yet; silently ignore + } + }, []); + + useEffect(() => { + fetchRoles(); + fetchPermissions(); + }, [fetchRoles, fetchPermissions]); + + const handleCreate = async (values: { + name: string; + code: string; + description?: string; + }) => { + try { + if (editRole) { + await updateRole(editRole.id, values); + message.success('角色更新成功'); + } else { + await createRole(values); + message.success('角色创建成功'); + } + setCreateModalOpen(false); + setEditRole(null); + form.resetFields(); + fetchRoles(); + } catch (err: unknown) { + const errorMsg = + (err as { response?: { data?: { message?: string } } })?.response?.data + ?.message || '操作失败'; + message.error(errorMsg); + } + }; + + const handleDelete = async (id: string) => { + try { + await deleteRole(id); + message.success('角色已删除'); + fetchRoles(); + } catch { + message.error('删除失败'); + } + }; + + const openPermModal = async (role: RoleInfo) => { + setSelectedRole(role); + try { + const rolePerms = await getRolePermissions(role.id); + setSelectedPermIds(rolePerms.map((p) => p.id)); + } catch { + setSelectedPermIds([]); + } + setPermModalOpen(true); + }; + + const savePermissions = async () => { + if (!selectedRole) return; + try { + await assignPermissions(selectedRole.id, selectedPermIds); + message.success('权限分配成功'); + setPermModalOpen(false); + } catch { + message.error('权限分配失败'); + } + }; + + const openEditModal = (role: RoleInfo) => { + setEditRole(role); + form.setFieldsValue({ + name: role.name, + code: role.code, + description: role.description, + }); + setCreateModalOpen(true); + }; + + const openCreateModal = () => { + setEditRole(null); + form.resetFields(); + setCreateModalOpen(true); + }; + + const closeCreateModal = () => { + setCreateModalOpen(false); + setEditRole(null); + form.resetFields(); + }; + + const columns = [ + { title: '名称', dataIndex: 'name', key: 'name' }, + { title: '编码', dataIndex: 'code', key: 'code' }, + { + title: '描述', + dataIndex: 'description', + key: 'description', + ellipsis: true, + }, + { + title: '类型', + dataIndex: 'is_system', + key: 'is_system', + render: (v: boolean) => + v ? 系统 : 自定义, + }, + { + title: '操作', + key: 'actions', + render: (_: unknown, record: RoleInfo) => ( + + + {!record.is_system && ( + <> + + handleDelete(record.id)} + > + + + + )} + + ), + }, + ]; + + // Group permissions by resource for better UX + const groupedPermissions = permissions.reduce>( + (acc, p) => { + if (!acc[p.resource]) acc[p.resource] = []; + acc[p.resource].push(p); + return acc; + }, + {}, + ); + + return ( +
+
+ + 角色管理 + + +
+ + + + form.submit()} + > +
+ + + + + + + + + + +
+ + setPermModalOpen(false)} + onOk={savePermissions} + width={600} + > + {Object.entries(groupedPermissions).map(([resource, perms]) => ( +
+ + {resource} + +
+ setSelectedPermIds(values as string[])} + options={perms.map((p) => ({ label: p.name, value: p.id }))} + /> +
+
+ ))} +
+ + ); +} diff --git a/crates/erp-auth/src/handler/mod.rs b/crates/erp-auth/src/handler/mod.rs index 48477be..40c8051 100644 --- a/crates/erp-auth/src/handler/mod.rs +++ b/crates/erp-auth/src/handler/mod.rs @@ -1,2 +1,3 @@ pub mod auth_handler; +pub mod role_handler; pub mod user_handler; diff --git a/crates/erp-auth/src/handler/role_handler.rs b/crates/erp-auth/src/handler/role_handler.rs new file mode 100644 index 0000000..ef308e8 --- /dev/null +++ b/crates/erp-auth/src/handler/role_handler.rs @@ -0,0 +1,217 @@ +use axum::Extension; +use axum::extract::{FromRef, Path, Query, State}; +use axum::response::Json; +use validator::Validate; + +use erp_core::error::AppError; +use erp_core::types::{ApiResponse, PaginatedResponse, Pagination, TenantContext}; +use uuid::Uuid; + +use crate::auth_state::AuthState; +use crate::dto::{AssignPermissionsReq, CreateRoleReq, PermissionResp, RoleResp, UpdateRoleReq}; +use crate::middleware::rbac::require_permission; +use crate::service::permission_service::PermissionService; +use crate::service::role_service::RoleService; + +/// GET /api/v1/roles +/// +/// List roles within the current tenant with pagination. +/// Requires the `role.list` permission. +pub async fn list_roles( + State(state): State, + Extension(ctx): Extension, + Query(pagination): Query, +) -> Result>>, AppError> +where + AuthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "role.list")?; + + let (roles, total) = RoleService::list(ctx.tenant_id, &pagination, &state.db).await?; + + let page = pagination.page.unwrap_or(1); + let page_size = pagination.limit(); + let total_pages = (total + page_size - 1) / page_size; + + Ok(Json(ApiResponse::ok(PaginatedResponse { + data: roles, + total, + page, + page_size, + total_pages, + }))) +} + +/// POST /api/v1/roles +/// +/// Create a new role within the current tenant. +/// Requires the `role.create` permission. +pub async fn create_role( + State(state): State, + Extension(ctx): Extension, + Json(req): Json, +) -> Result>, AppError> +where + AuthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "role.create")?; + + req.validate() + .map_err(|e| AppError::Validation(e.to_string()))?; + + let role = RoleService::create( + ctx.tenant_id, + ctx.user_id, + &req.name, + &req.code, + &req.description, + &state.db, + &state.event_bus, + ) + .await?; + + Ok(Json(ApiResponse::ok(role))) +} + +/// GET /api/v1/roles/:id +/// +/// Fetch a single role by ID within the current tenant. +/// Requires the `role.read` permission. +pub async fn get_role( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, +) -> Result>, AppError> +where + AuthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "role.read")?; + + let role = RoleService::get_by_id(id, ctx.tenant_id, &state.db).await?; + Ok(Json(ApiResponse::ok(role))) +} + +/// PUT /api/v1/roles/:id +/// +/// Update editable role fields (name, description). +/// Requires the `role.update` permission. +pub async fn update_role( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, + Json(req): Json, +) -> Result>, AppError> +where + AuthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "role.update")?; + + let role = RoleService::update( + id, + ctx.tenant_id, + ctx.user_id, + &req.name, + &req.description, + &state.db, + ) + .await?; + Ok(Json(ApiResponse::ok(role))) +} + +/// DELETE /api/v1/roles/:id +/// +/// Soft-delete a role by ID within the current tenant. +/// System roles cannot be deleted. +/// Requires the `role.delete` permission. +pub async fn delete_role( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, +) -> Result>, AppError> +where + AuthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "role.delete")?; + + RoleService::delete(id, ctx.tenant_id, ctx.user_id, &state.db, &state.event_bus).await?; + + Ok(Json(ApiResponse { + success: true, + data: None, + message: Some("角色已删除".to_string()), + })) +} + +/// POST /api/v1/roles/:id/permissions +/// +/// Replace all permission assignments for a role. +/// Requires the `role.update` permission. +pub async fn assign_permissions( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, + Json(req): Json, +) -> Result>, AppError> +where + AuthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "role.update")?; + + RoleService::assign_permissions( + id, + ctx.tenant_id, + ctx.user_id, + &req.permission_ids, + &state.db, + ) + .await?; + + Ok(Json(ApiResponse { + success: true, + data: None, + message: Some("权限分配成功".to_string()), + })) +} + +/// GET /api/v1/roles/:id/permissions +/// +/// Fetch all permissions assigned to a role. +/// Requires the `role.read` permission. +pub async fn get_role_permissions( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, +) -> Result>>, AppError> +where + AuthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "role.read")?; + + let perms = RoleService::get_role_permissions(id, ctx.tenant_id, &state.db).await?; + Ok(Json(ApiResponse::ok(perms))) +} + +/// GET /api/v1/permissions +/// +/// List all permissions within the current tenant. +/// Requires the `permission.list` permission. +pub async fn list_permissions( + State(state): State, + Extension(ctx): Extension, +) -> Result>>, AppError> +where + AuthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "permission.list")?; + + let perms = PermissionService::list(ctx.tenant_id, &state.db).await?; + Ok(Json(ApiResponse::ok(perms))) +} diff --git a/crates/erp-auth/src/module.rs b/crates/erp-auth/src/module.rs index 0e10bbe..134d79d 100644 --- a/crates/erp-auth/src/module.rs +++ b/crates/erp-auth/src/module.rs @@ -5,7 +5,7 @@ use erp_core::error::AppResult; use erp_core::events::EventBus; use erp_core::module::ErpModule; -use crate::handler::{auth_handler, user_handler}; +use crate::handler::{auth_handler, role_handler, user_handler}; /// Auth module implementing the `ErpModule` trait. /// @@ -53,6 +53,25 @@ impl AuthModule { .put(user_handler::update_user) .delete(user_handler::delete_user), ) + .route( + "/roles", + axum::routing::get(role_handler::list_roles).post(role_handler::create_role), + ) + .route( + "/roles/{id}", + axum::routing::get(role_handler::get_role) + .put(role_handler::update_role) + .delete(role_handler::delete_role), + ) + .route( + "/roles/{id}/permissions", + axum::routing::get(role_handler::get_role_permissions) + .post(role_handler::assign_permissions), + ) + .route( + "/permissions", + axum::routing::get(role_handler::list_permissions), + ) } } diff --git a/crates/erp-auth/src/service/mod.rs b/crates/erp-auth/src/service/mod.rs index b0392f3..45373d1 100644 --- a/crates/erp-auth/src/service/mod.rs +++ b/crates/erp-auth/src/service/mod.rs @@ -1,5 +1,7 @@ pub mod auth_service; pub mod password; +pub mod permission_service; +pub mod role_service; pub mod seed; pub mod token_service; pub mod user_service; diff --git a/crates/erp-auth/src/service/permission_service.rs b/crates/erp-auth/src/service/permission_service.rs new file mode 100644 index 0000000..3b9f6b2 --- /dev/null +++ b/crates/erp-auth/src/service/permission_service.rs @@ -0,0 +1,38 @@ +use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; +use uuid::Uuid; + +use crate::dto::PermissionResp; +use crate::entity::permission; +use crate::error::AuthResult; + +/// Permission read-only service — list permissions within a tenant. +/// +/// Permissions are seeded by the system and not typically created via API. +pub struct PermissionService; + +impl PermissionService { + /// List all active permissions within a tenant. + pub async fn list( + tenant_id: Uuid, + db: &sea_orm::DatabaseConnection, + ) -> AuthResult> { + let perms = permission::Entity::find() + .filter(permission::Column::TenantId.eq(tenant_id)) + .filter(permission::Column::DeletedAt.is_null()) + .all(db) + .await + .map_err(|e| crate::error::AuthError::Validation(e.to_string()))?; + + Ok(perms + .iter() + .map(|p| PermissionResp { + id: p.id, + code: p.code.clone(), + name: p.name.clone(), + resource: p.resource.clone(), + action: p.action.clone(), + description: p.description.clone(), + }) + .collect()) + } +} diff --git a/crates/erp-auth/src/service/role_service.rs b/crates/erp-auth/src/service/role_service.rs new file mode 100644 index 0000000..f1deeec --- /dev/null +++ b/crates/erp-auth/src/service/role_service.rs @@ -0,0 +1,321 @@ +use chrono::Utc; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set, +}; +use uuid::Uuid; + +use crate::dto::{PermissionResp, RoleResp}; +use crate::entity::{permission, role, role_permission}; +use crate::error::AuthError; +use crate::error::AuthResult; +use erp_core::events::EventBus; +use erp_core::types::Pagination; + +/// Role CRUD service — create, read, update, soft-delete roles within a tenant, +/// and manage role-permission assignments. +pub struct RoleService; + +impl RoleService { + /// List roles within a tenant with pagination. + /// + /// Returns `(roles, total_count)`. + pub async fn list( + tenant_id: Uuid, + pagination: &Pagination, + db: &sea_orm::DatabaseConnection, + ) -> AuthResult<(Vec, u64)> { + let paginator = role::Entity::find() + .filter(role::Column::TenantId.eq(tenant_id)) + .filter(role::Column::DeletedAt.is_null()) + .paginate(db, pagination.limit()); + + let total = paginator + .num_items() + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + + let page_index = pagination.page.unwrap_or(1).saturating_sub(1) as u64; + let models = paginator + .fetch_page(page_index) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + + let resps: Vec = models + .iter() + .map(|m| RoleResp { + id: m.id, + name: m.name.clone(), + code: m.code.clone(), + description: m.description.clone(), + is_system: m.is_system, + }) + .collect(); + + Ok((resps, total)) + } + + /// Fetch a single role by ID, scoped to the given tenant. + pub async fn get_by_id( + id: Uuid, + tenant_id: Uuid, + db: &sea_orm::DatabaseConnection, + ) -> AuthResult { + let model = role::Entity::find_by_id(id) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))? + .filter(|r| r.tenant_id == tenant_id && r.deleted_at.is_none()) + .ok_or_else(|| AuthError::Validation("角色不存在".to_string()))?; + + Ok(RoleResp { + id: model.id, + name: model.name.clone(), + code: model.code.clone(), + description: model.description.clone(), + is_system: model.is_system, + }) + } + + /// Create a new role within the current tenant. + /// + /// Validates code uniqueness, then inserts the record and publishes + /// a `role.created` domain event. + pub async fn create( + tenant_id: Uuid, + operator_id: Uuid, + name: &str, + code: &str, + description: &Option, + db: &sea_orm::DatabaseConnection, + event_bus: &EventBus, + ) -> AuthResult { + // Check code uniqueness within tenant + let existing = role::Entity::find() + .filter(role::Column::TenantId.eq(tenant_id)) + .filter(role::Column::Code.eq(code)) + .filter(role::Column::DeletedAt.is_null()) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + if existing.is_some() { + return Err(AuthError::Validation("角色编码已存在".to_string())); + } + + let now = Utc::now(); + let id = Uuid::now_v7(); + let model = role::ActiveModel { + id: Set(id), + tenant_id: Set(tenant_id), + name: Set(name.to_string()), + code: Set(code.to_string()), + description: Set(description.clone()), + is_system: Set(false), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(operator_id), + updated_by: Set(operator_id), + deleted_at: Set(None), + version: Set(1), + }; + model + .insert(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + + event_bus.publish(erp_core::events::DomainEvent::new( + "role.created", + tenant_id, + serde_json::json!({ "role_id": id, "code": code }), + )); + + Ok(RoleResp { + id, + name: name.to_string(), + code: code.to_string(), + description: description.clone(), + is_system: false, + }) + } + + /// Update editable role fields (name and description). + /// + /// Code and is_system cannot be changed after creation. + pub async fn update( + id: Uuid, + tenant_id: Uuid, + operator_id: Uuid, + name: &Option, + description: &Option, + db: &sea_orm::DatabaseConnection, + ) -> AuthResult { + let model = role::Entity::find_by_id(id) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))? + .filter(|r| r.tenant_id == tenant_id && r.deleted_at.is_none()) + .ok_or_else(|| AuthError::Validation("角色不存在".to_string()))?; + + let mut active: role::ActiveModel = model.into(); + + if let Some(name) = name { + active.name = Set(name.clone()); + } + if let Some(desc) = description { + active.description = Set(Some(desc.clone())); + } + + active.updated_at = Set(Utc::now()); + active.updated_by = Set(operator_id); + + let updated = active + .update(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + + Ok(RoleResp { + id: updated.id, + name: updated.name.clone(), + code: updated.code.clone(), + description: updated.description.clone(), + is_system: updated.is_system, + }) + } + + /// Soft-delete a role by setting the `deleted_at` timestamp. + /// + /// System roles cannot be deleted. + pub async fn delete( + id: Uuid, + tenant_id: Uuid, + operator_id: Uuid, + db: &sea_orm::DatabaseConnection, + event_bus: &EventBus, + ) -> AuthResult<()> { + let model = role::Entity::find_by_id(id) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))? + .filter(|r| r.tenant_id == tenant_id && r.deleted_at.is_none()) + .ok_or_else(|| AuthError::Validation("角色不存在".to_string()))?; + + if model.is_system { + return Err(AuthError::Validation("系统角色不可删除".to_string())); + } + + let mut active: role::ActiveModel = model.into(); + active.deleted_at = Set(Some(Utc::now())); + active.updated_at = Set(Utc::now()); + active.updated_by = Set(operator_id); + active + .update(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + + event_bus.publish(erp_core::events::DomainEvent::new( + "role.deleted", + tenant_id, + serde_json::json!({ "role_id": id }), + )); + Ok(()) + } + + /// Replace all permission assignments for a role. + /// + /// Soft-deletes existing assignments and creates new ones. + pub async fn assign_permissions( + role_id: Uuid, + tenant_id: Uuid, + operator_id: Uuid, + permission_ids: &[Uuid], + db: &sea_orm::DatabaseConnection, + ) -> AuthResult<()> { + // Verify the role exists and belongs to this tenant + let _role = role::Entity::find_by_id(role_id) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))? + .filter(|r| r.tenant_id == tenant_id && r.deleted_at.is_none()) + .ok_or_else(|| AuthError::Validation("角色不存在".to_string()))?; + + // Soft-delete existing role_permission rows + let existing = role_permission::Entity::find() + .filter(role_permission::Column::RoleId.eq(role_id)) + .filter(role_permission::Column::TenantId.eq(tenant_id)) + .filter(role_permission::Column::DeletedAt.is_null()) + .all(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + + let now = Utc::now(); + for rp in existing { + let mut active: role_permission::ActiveModel = rp.into(); + active.deleted_at = Set(Some(now)); + active.updated_at = Set(now); + active.updated_by = Set(operator_id); + active + .update(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + } + + // Insert new role_permission rows + for perm_id in permission_ids { + let rp = role_permission::ActiveModel { + role_id: Set(role_id), + permission_id: Set(*perm_id), + tenant_id: Set(tenant_id), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(operator_id), + updated_by: Set(operator_id), + deleted_at: Set(None), + version: Set(1), + }; + rp.insert(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + } + Ok(()) + } + + /// Fetch all permissions assigned to a role. + /// + /// Resolves through the role_permission join table. + pub async fn get_role_permissions( + role_id: Uuid, + tenant_id: Uuid, + db: &sea_orm::DatabaseConnection, + ) -> AuthResult> { + let rp_rows = role_permission::Entity::find() + .filter(role_permission::Column::RoleId.eq(role_id)) + .filter(role_permission::Column::TenantId.eq(tenant_id)) + .filter(role_permission::Column::DeletedAt.is_null()) + .all(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + + let perm_ids: Vec = rp_rows.iter().map(|rp| rp.permission_id).collect(); + if perm_ids.is_empty() { + return Ok(vec![]); + } + + let perms = permission::Entity::find() + .filter(permission::Column::Id.is_in(perm_ids)) + .filter(permission::Column::TenantId.eq(tenant_id)) + .all(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + + Ok(perms + .iter() + .map(|p| PermissionResp { + id: p.id, + code: p.code.clone(), + name: p.name.clone(), + resource: p.resource.clone(), + action: p.action.clone(), + description: p.description.clone(), + }) + .collect()) + } +}