feat(role): 添加角色用户绑定功能并完善权限验证

- 扩展前端HTTP拦截器支持403状态码重定向到登录页带超时标识
- 添加角色绑定和解绑用户的API接口函数
- 在JWT认证过滤器中增加令牌过期和无效令牌的异常处理
- 登录页面添加URL参数检测显示登录超时警告信息
- 角色管理页面添加用户绑定功能和用户选择弹窗界面
- 实现后端角色控制器的角色用户绑定和解绑接口逻辑
- 更新项目设计文档说明后续内容变更同步要求
master
chenhao 2026-02-25 13:43:00 +08:00
parent 5fe3b53680
commit 26c2b977d6
7 changed files with 202 additions and 8 deletions

View File

@ -2,7 +2,7 @@
## 1. 项目概述
本项目为“智能会议语音识别与总结系统”的管理后台,提供用户、角色、权限、设备与任务等管理能力。
后端为 Java 服务,前端为后台管理 Web。
后端为 Java 服务,前端为后台管理 Web。 后续所有内容变更都必须同步更新该文档
## 2. 技术栈
### 后端

View File

@ -116,6 +116,18 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
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) {
SecurityContextHolder.clearContext();
}

View File

@ -6,7 +6,9 @@ import com.imeeting.common.annotation.Log;
import com.imeeting.entity.SysRole;
import com.imeeting.entity.SysRolePermission;
import com.imeeting.entity.SysUser;
import com.imeeting.entity.SysUserRole;
import com.imeeting.mapper.SysRolePermissionMapper;
import com.imeeting.mapper.SysUserRoleMapper;
import com.imeeting.service.SysRoleService;
import com.imeeting.service.SysUserService;
import org.springframework.security.access.prepost.PreAuthorize;
@ -21,11 +23,13 @@ public class RoleController {
private final SysRoleService sysRoleService;
private final SysUserService sysUserService;
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.sysUserService = sysUserService;
this.sysRolePermissionMapper = sysRolePermissionMapper;
this.sysUserRoleMapper = sysUserRoleMapper;
}
@GetMapping
@ -103,6 +107,42 @@ public class RoleController {
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 {
private List<Long> permIds;

View File

@ -23,13 +23,13 @@ http.interceptors.response.use(
return resp;
},
(error) => {
if (error.response && error.response.status === 401) {
if (error.response && (error.response.status === 401 || error.response.status === 403)) {
// Clear session/local storage
localStorage.removeItem("accessToken");
localStorage.removeItem("refreshToken");
sessionStorage.removeItem("userProfile");
// Force redirect to login
window.location.href = "/login";
// Force redirect to login with timeout flag
window.location.href = "/login?timeout=1";
}
return Promise.reject(error);
}

View File

@ -127,6 +127,16 @@ export async function fetchUsersByRoleId(roleId: number) {
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) {
const resp = await http.get("/api/logs", { params });
return resp.data.data;

View File

@ -44,6 +44,15 @@ export default function Login() {
init();
}, [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) => {
setLoading(true);
try {

View File

@ -15,7 +15,8 @@ import {
Col,
Tabs,
Empty,
Select
Select,
Modal
} from "antd";
import type { DataNode } from "antd/es/tree";
import { useEffect, useMemo, useState } from "react";
@ -28,7 +29,10 @@ import {
saveRolePermissions,
updateRole,
deleteRole,
fetchUsersByRoleId
fetchUsersByRoleId,
bindUsersToRole,
unbindUserFromRole,
listUsers
} from "../api";
import type { SysPermission, SysRole, SysUser } from "../types";
import { usePermission } from "../hooks/usePermission";
@ -40,7 +44,8 @@ import {
DeleteOutlined,
KeyOutlined,
UserOutlined,
SaveOutlined
SaveOutlined,
UserAddOutlined
} from "@ant-design/icons";
import "./Roles.css";
@ -107,6 +112,12 @@ export default function Roles() {
const [roleUsers, setRoleUsers] = useState<SysUser[]>([]);
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
const [searchText, setSearchText] = useState("");
@ -122,6 +133,53 @@ export default function Roles() {
[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 () => {
try {
const list = await listPermissions();
@ -400,6 +458,16 @@ export default function Roles() {
tab={<Space><UserOutlined aria-hidden="true" />{t('roles.assignedUsers')} ({roleUsers.length})</Space>}
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
rowKey="userId"
size="small"
@ -426,6 +494,26 @@ export default function Roles() {
dataIndex: 'status',
width: 80,
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>
</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 */}
<Drawer
title={editing ? t('roles.drawerTitleEdit') : t('roles.drawerTitleCreate')}