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

View File

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

View File

@ -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;

View File

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

View File

@ -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;

View File

@ -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 {

View File

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