feat(role): 添加角色用户绑定功能并完善权限验证
- 扩展前端HTTP拦截器支持403状态码重定向到登录页带超时标识 - 添加角色绑定和解绑用户的API接口函数 - 在JWT认证过滤器中增加令牌过期和无效令牌的异常处理 - 登录页面添加URL参数检测显示登录超时警告信息 - 角色管理页面添加用户绑定功能和用户选择弹窗界面 - 实现后端角色控制器的角色用户绑定和解绑接口逻辑 - 更新项目设计文档说明后续内容变更同步要求master
parent
5fe3b53680
commit
26c2b977d6
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
## 1. 项目概述
|
## 1. 项目概述
|
||||||
本项目为“智能会议语音识别与总结系统”的管理后台,提供用户、角色、权限、设备与任务等管理能力。
|
本项目为“智能会议语音识别与总结系统”的管理后台,提供用户、角色、权限、设备与任务等管理能力。
|
||||||
后端为 Java 服务,前端为后台管理 Web。
|
后端为 Java 服务,前端为后台管理 Web。 后续所有内容变更都必须同步更新该文档
|
||||||
|
|
||||||
## 2. 技术栈
|
## 2. 技术栈
|
||||||
### 后端
|
### 后端
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,18 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||||
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
||||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||||
}
|
}
|
||||||
|
} catch (io.jsonwebtoken.ExpiredJwtException e) {
|
||||||
|
SecurityContextHolder.clearContext();
|
||||||
|
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||||
|
response.setContentType("application/json;charset=UTF-8");
|
||||||
|
response.getWriter().write("{\"code\":\"401\",\"msg\":\"Token expired\"}");
|
||||||
|
return;
|
||||||
|
} catch (io.jsonwebtoken.JwtException e) {
|
||||||
|
SecurityContextHolder.clearContext();
|
||||||
|
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||||
|
response.setContentType("application/json;charset=UTF-8");
|
||||||
|
response.getWriter().write("{\"code\":\"401\",\"msg\":\"Invalid token\"}");
|
||||||
|
return;
|
||||||
} catch (Exception ignored) {
|
} catch (Exception ignored) {
|
||||||
SecurityContextHolder.clearContext();
|
SecurityContextHolder.clearContext();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,9 @@ import com.imeeting.common.annotation.Log;
|
||||||
import com.imeeting.entity.SysRole;
|
import com.imeeting.entity.SysRole;
|
||||||
import com.imeeting.entity.SysRolePermission;
|
import com.imeeting.entity.SysRolePermission;
|
||||||
import com.imeeting.entity.SysUser;
|
import com.imeeting.entity.SysUser;
|
||||||
|
import com.imeeting.entity.SysUserRole;
|
||||||
import com.imeeting.mapper.SysRolePermissionMapper;
|
import com.imeeting.mapper.SysRolePermissionMapper;
|
||||||
|
import com.imeeting.mapper.SysUserRoleMapper;
|
||||||
import com.imeeting.service.SysRoleService;
|
import com.imeeting.service.SysRoleService;
|
||||||
import com.imeeting.service.SysUserService;
|
import com.imeeting.service.SysUserService;
|
||||||
import org.springframework.security.access.prepost.PreAuthorize;
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
|
@ -21,11 +23,13 @@ public class RoleController {
|
||||||
private final SysRoleService sysRoleService;
|
private final SysRoleService sysRoleService;
|
||||||
private final SysUserService sysUserService;
|
private final SysUserService sysUserService;
|
||||||
private final SysRolePermissionMapper sysRolePermissionMapper;
|
private final SysRolePermissionMapper sysRolePermissionMapper;
|
||||||
|
private final SysUserRoleMapper sysUserRoleMapper;
|
||||||
|
|
||||||
public RoleController(SysRoleService sysRoleService, SysUserService sysUserService, SysRolePermissionMapper sysRolePermissionMapper) {
|
public RoleController(SysRoleService sysRoleService, SysUserService sysUserService, SysRolePermissionMapper sysRolePermissionMapper, SysUserRoleMapper sysUserRoleMapper) {
|
||||||
this.sysRoleService = sysRoleService;
|
this.sysRoleService = sysRoleService;
|
||||||
this.sysUserService = sysUserService;
|
this.sysUserService = sysUserService;
|
||||||
this.sysRolePermissionMapper = sysRolePermissionMapper;
|
this.sysRolePermissionMapper = sysRolePermissionMapper;
|
||||||
|
this.sysUserRoleMapper = sysUserRoleMapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
|
|
@ -103,6 +107,42 @@ public class RoleController {
|
||||||
return ApiResponse.ok(true);
|
return ApiResponse.ok(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/users")
|
||||||
|
@PreAuthorize("@ss.hasPermi('sys_role:update')")
|
||||||
|
@Log(value = "角色关联用户", type = "角色管理")
|
||||||
|
public ApiResponse<Boolean> bindUsers(@PathVariable Long id, @RequestBody UserBindingPayload payload) {
|
||||||
|
if (payload == null || payload.getUserIds() == null) {
|
||||||
|
return ApiResponse.ok(true);
|
||||||
|
}
|
||||||
|
for (Long userId : payload.getUserIds()) {
|
||||||
|
QueryWrapper<SysUserRole> qw = new QueryWrapper<>();
|
||||||
|
qw.eq("role_id", id).eq("user_id", userId);
|
||||||
|
if (sysUserRoleMapper.selectCount(qw) == 0) {
|
||||||
|
SysUserRole ur = new SysUserRole();
|
||||||
|
ur.setRoleId(id);
|
||||||
|
ur.setUserId(userId);
|
||||||
|
sysUserRoleMapper.insert(ur);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ApiResponse.ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}/users/{userId}")
|
||||||
|
@PreAuthorize("@ss.hasPermi('sys_role:update')")
|
||||||
|
@Log(value = "角色取消关联用户", type = "角色管理")
|
||||||
|
public ApiResponse<Boolean> unbindUser(@PathVariable Long id, @PathVariable Long userId) {
|
||||||
|
QueryWrapper<SysUserRole> qw = new QueryWrapper<>();
|
||||||
|
qw.eq("role_id", id).eq("user_id", userId);
|
||||||
|
sysUserRoleMapper.delete(qw);
|
||||||
|
return ApiResponse.ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class UserBindingPayload {
|
||||||
|
private List<Long> userIds;
|
||||||
|
public List<Long> getUserIds() { return userIds; }
|
||||||
|
public void setUserIds(List<Long> userIds) { this.userIds = userIds; }
|
||||||
|
}
|
||||||
|
|
||||||
public static class PermissionBindingPayload {
|
public static class PermissionBindingPayload {
|
||||||
private List<Long> permIds;
|
private List<Long> permIds;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,13 +23,13 @@ http.interceptors.response.use(
|
||||||
return resp;
|
return resp;
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
if (error.response && error.response.status === 401) {
|
if (error.response && (error.response.status === 401 || error.response.status === 403)) {
|
||||||
// Clear session/local storage
|
// Clear session/local storage
|
||||||
localStorage.removeItem("accessToken");
|
localStorage.removeItem("accessToken");
|
||||||
localStorage.removeItem("refreshToken");
|
localStorage.removeItem("refreshToken");
|
||||||
sessionStorage.removeItem("userProfile");
|
sessionStorage.removeItem("userProfile");
|
||||||
// Force redirect to login
|
// Force redirect to login with timeout flag
|
||||||
window.location.href = "/login";
|
window.location.href = "/login?timeout=1";
|
||||||
}
|
}
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -127,6 +127,16 @@ export async function fetchUsersByRoleId(roleId: number) {
|
||||||
return resp.data.data as SysUser[];
|
return resp.data.data as SysUser[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function bindUsersToRole(roleId: number, userIds: number[]) {
|
||||||
|
const resp = await http.post(`/api/roles/${roleId}/users`, { userIds });
|
||||||
|
return resp.data.data as boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unbindUserFromRole(roleId: number, userId: number) {
|
||||||
|
const resp = await http.delete(`/api/roles/${roleId}/users/${userId}`);
|
||||||
|
return resp.data.data as boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchLogs(params: any) {
|
export async function fetchLogs(params: any) {
|
||||||
const resp = await http.get("/api/logs", { params });
|
const resp = await http.get("/api/logs", { params });
|
||||||
return resp.data.data;
|
return resp.data.data;
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,15 @@ export default function Login() {
|
||||||
init();
|
init();
|
||||||
}, [loadCaptcha]);
|
}, [loadCaptcha]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const searchParams = new URLSearchParams(window.location.search);
|
||||||
|
if (searchParams.get("timeout") === "1") {
|
||||||
|
message.warning(t('login.loginTimeout'));
|
||||||
|
// Clean up the URL to avoid repeated messages on refresh
|
||||||
|
window.history.replaceState({}, document.title, window.location.pathname);
|
||||||
|
}
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
const onFinish = async (values: any) => {
|
const onFinish = async (values: any) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,8 @@ import {
|
||||||
Col,
|
Col,
|
||||||
Tabs,
|
Tabs,
|
||||||
Empty,
|
Empty,
|
||||||
Select
|
Select,
|
||||||
|
Modal
|
||||||
} from "antd";
|
} from "antd";
|
||||||
import type { DataNode } from "antd/es/tree";
|
import type { DataNode } from "antd/es/tree";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
@ -28,7 +29,10 @@ import {
|
||||||
saveRolePermissions,
|
saveRolePermissions,
|
||||||
updateRole,
|
updateRole,
|
||||||
deleteRole,
|
deleteRole,
|
||||||
fetchUsersByRoleId
|
fetchUsersByRoleId,
|
||||||
|
bindUsersToRole,
|
||||||
|
unbindUserFromRole,
|
||||||
|
listUsers
|
||||||
} from "../api";
|
} from "../api";
|
||||||
import type { SysPermission, SysRole, SysUser } from "../types";
|
import type { SysPermission, SysRole, SysUser } from "../types";
|
||||||
import { usePermission } from "../hooks/usePermission";
|
import { usePermission } from "../hooks/usePermission";
|
||||||
|
|
@ -40,7 +44,8 @@ import {
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
KeyOutlined,
|
KeyOutlined,
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
SaveOutlined
|
SaveOutlined,
|
||||||
|
UserAddOutlined
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import "./Roles.css";
|
import "./Roles.css";
|
||||||
|
|
||||||
|
|
@ -107,6 +112,12 @@ export default function Roles() {
|
||||||
const [roleUsers, setRoleUsers] = useState<SysUser[]>([]);
|
const [roleUsers, setRoleUsers] = useState<SysUser[]>([]);
|
||||||
const [loadingUsers, setLoadingUsers] = useState(false);
|
const [loadingUsers, setLoadingUsers] = useState(false);
|
||||||
|
|
||||||
|
// User selection states
|
||||||
|
const [allUsers, setAllUsers] = useState<SysUser[]>([]);
|
||||||
|
const [userModalOpen, setUserModalOpen] = useState(false);
|
||||||
|
const [selectedUserKeys, setSelectedUserKeys] = useState<number[]>([]);
|
||||||
|
const [userSearchText, setUserSearchText] = useState("");
|
||||||
|
|
||||||
// Search
|
// Search
|
||||||
const [searchText, setSearchText] = useState("");
|
const [searchText, setSearchText] = useState("");
|
||||||
|
|
||||||
|
|
@ -122,6 +133,53 @@ export default function Roles() {
|
||||||
[permissions, t]
|
[permissions, t]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const loadAllUsers = async () => {
|
||||||
|
try {
|
||||||
|
const list = await listUsers();
|
||||||
|
setAllUsers(list || []);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openUserModal = () => {
|
||||||
|
loadAllUsers();
|
||||||
|
setSelectedUserKeys([]);
|
||||||
|
setUserModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddUsers = async () => {
|
||||||
|
if (!selectedRole || selectedUserKeys.length === 0) return;
|
||||||
|
try {
|
||||||
|
await bindUsersToRole(selectedRole.roleId, selectedUserKeys);
|
||||||
|
message.success(t('common.success'));
|
||||||
|
setUserModalOpen(false);
|
||||||
|
selectRole(selectedRole);
|
||||||
|
} catch (e) {
|
||||||
|
message.error(t('common.error'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUnbindUser = async (userId: number) => {
|
||||||
|
if (!selectedRole) return;
|
||||||
|
try {
|
||||||
|
await unbindUserFromRole(selectedRole.roleId, userId);
|
||||||
|
message.success(t('common.success'));
|
||||||
|
selectRole(selectedRole);
|
||||||
|
} catch (e) {
|
||||||
|
message.error(t('common.error'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredModalUsers = useMemo(() => {
|
||||||
|
const existingIds = new Set(roleUsers.map(u => u.userId));
|
||||||
|
return allUsers.filter(u =>
|
||||||
|
!existingIds.has(u.userId) &&
|
||||||
|
(u.username.toLowerCase().includes(userSearchText.toLowerCase()) ||
|
||||||
|
u.displayName.toLowerCase().includes(userSearchText.toLowerCase()))
|
||||||
|
);
|
||||||
|
}, [allUsers, roleUsers, userSearchText]);
|
||||||
|
|
||||||
const loadPermissions = async () => {
|
const loadPermissions = async () => {
|
||||||
try {
|
try {
|
||||||
const list = await listPermissions();
|
const list = await listPermissions();
|
||||||
|
|
@ -400,6 +458,16 @@ export default function Roles() {
|
||||||
tab={<Space><UserOutlined aria-hidden="true" />{t('roles.assignedUsers')} ({roleUsers.length})</Space>}
|
tab={<Space><UserOutlined aria-hidden="true" />{t('roles.assignedUsers')} ({roleUsers.length})</Space>}
|
||||||
key="users"
|
key="users"
|
||||||
>
|
>
|
||||||
|
<div className="mb-4 flex justify-end">
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<UserAddOutlined aria-hidden="true" />}
|
||||||
|
onClick={openUserModal}
|
||||||
|
disabled={!can("sys_role:update")}
|
||||||
|
>
|
||||||
|
{t('common.create')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<Table
|
<Table
|
||||||
rowKey="userId"
|
rowKey="userId"
|
||||||
size="small"
|
size="small"
|
||||||
|
|
@ -426,6 +494,26 @@ export default function Roles() {
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
width: 80,
|
width: 80,
|
||||||
render: s => <Tag color={s === 1 ? 'green' : 'red'}>{s === 1 ? '正常' : '禁用'}</Tag>
|
render: s => <Tag color={s === 1 ? 'green' : 'red'}>{s === 1 ? '正常' : '禁用'}</Tag>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('common.action'),
|
||||||
|
key: 'action',
|
||||||
|
width: 100,
|
||||||
|
render: (_, record) => (
|
||||||
|
<Popconfirm
|
||||||
|
title={t('common.delete') + "?"}
|
||||||
|
onConfirm={() => handleUnbindUser(record.userId)}
|
||||||
|
disabled={!can("sys_role:update")}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
danger
|
||||||
|
size="small"
|
||||||
|
icon={<DeleteOutlined aria-hidden="true" />}
|
||||||
|
disabled={!can("sys_role:update")}
|
||||||
|
/>
|
||||||
|
</Popconfirm>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
@ -440,6 +528,41 @@ export default function Roles() {
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
|
{/* User Selection Modal */}
|
||||||
|
<Modal
|
||||||
|
title={t('roles.assignedUsers')}
|
||||||
|
open={userModalOpen}
|
||||||
|
onCancel={() => setUserModalOpen(false)}
|
||||||
|
onOk={handleAddUsers}
|
||||||
|
width={600}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<div className="mb-4">
|
||||||
|
<Input
|
||||||
|
placeholder={t('users.searchPlaceholder')}
|
||||||
|
prefix={<SearchOutlined aria-hidden="true" />}
|
||||||
|
value={userSearchText}
|
||||||
|
onChange={e => setUserSearchText(e.target.value)}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Table
|
||||||
|
rowKey="userId"
|
||||||
|
size="small"
|
||||||
|
dataSource={filteredModalUsers}
|
||||||
|
pagination={{ pageSize: 5 }}
|
||||||
|
rowSelection={{
|
||||||
|
selectedRowKeys: selectedUserKeys,
|
||||||
|
onChange: (keys) => setSelectedUserKeys(keys as number[])
|
||||||
|
}}
|
||||||
|
columns={[
|
||||||
|
{ title: t('users.displayName'), dataIndex: 'displayName' },
|
||||||
|
{ title: t('users.username'), dataIndex: 'username' },
|
||||||
|
{ title: t('users.org'), dataIndex: 'orgId', render: () => '-' } // Simplification
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
{/* Basic Info Drawer */}
|
{/* Basic Info Drawer */}
|
||||||
<Drawer
|
<Drawer
|
||||||
title={editing ? t('roles.drawerTitleEdit') : t('roles.drawerTitleCreate')}
|
title={editing ? t('roles.drawerTitleEdit') : t('roles.drawerTitleCreate')}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue