feat(hotword): 实现热词权限管理和搜索功能
- 添加管理员权限判断方法支持平台管理员和租户管理员 - 实现公开热词仅管理员可创建、修改、删除的权限控制 - 支持私有热词本人或管理员可操作的权限验证 - 添加前端搜索类型筛选功能支持公开/私有分类 - 更新前端权限检查逻辑统一使用管理员身份判断 - 修复后端查询构造器逻辑确保权限过滤正确执行 - 添加租户管理员身份到JWT认证信息中 - 配置数据访问层忽略租户隔离注解优化查询性能dev_na
parent
f6ba04ebd8
commit
70e739818a
|
|
@ -140,7 +140,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean isTenantAdmin = authScopeService.isTenantAdmin(userId, activeTenantId);
|
boolean isTenantAdmin = authScopeService.isTenantAdmin(userId, activeTenantId);
|
||||||
LoginUser loginUser = new LoginUser(userId, activeTenantId, username, user.getIsPlatformAdmin(), isTenantAdmin, permissions);
|
LoginUser loginUser = new LoginUser(userId, activeTenantId, username, displayName,user.getIsPlatformAdmin(), isTenantAdmin, permissions);
|
||||||
|
|
||||||
UsernamePasswordAuthenticationToken authentication =
|
UsernamePasswordAuthenticationToken authentication =
|
||||||
new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
|
new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
|
||||||
|
|
|
||||||
|
|
@ -26,17 +26,23 @@ public class HotWordController {
|
||||||
this.hotWordService = hotWordService;
|
this.hotWordService = hotWordService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断当前用户是否具备管理员权限 (平台管理员或租户管理员)
|
||||||
|
*/
|
||||||
|
private boolean isCurrentUserAdmin(LoginUser user) {
|
||||||
|
return Boolean.TRUE.equals(user.getIsPlatformAdmin()) || Boolean.TRUE.equals(user.getIsTenantAdmin());
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
@PreAuthorize("isAuthenticated()")
|
@PreAuthorize("isAuthenticated()")
|
||||||
public ApiResponse<HotWordVO> save(@RequestBody HotWordDTO hotWordDTO) {
|
public ApiResponse<HotWordVO> save(@RequestBody HotWordDTO hotWordDTO) {
|
||||||
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
|
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
|
||||||
// 只有管理员可以创建公开热词,普通用户强制为私有
|
|
||||||
if (Integer.valueOf(1).equals(hotWordDTO.getIsPublic()) && !Boolean.TRUE.equals(loginUser.getIsPlatformAdmin())) {
|
// 核心校验:只有管理员可以创建公开热词
|
||||||
// 这里根据需求:租户内管理员可编辑。由于目前权限模型暂未细化到租户管理员字段,
|
if (Integer.valueOf(1).equals(hotWordDTO.getIsPublic()) && !isCurrentUserAdmin(loginUser)) {
|
||||||
// 我们暂定具有 ADMIN 角色或 PlatformAdmin 的为管理员。
|
return ApiResponse.error("无权创建租户公开热词,请设为个人私有");
|
||||||
// 简单处理:普通用户强制设为 0。
|
|
||||||
hotWordDTO.setIsPublic(0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return ApiResponse.ok(hotWordService.saveHotWord(hotWordDTO, loginUser.getUserId()));
|
return ApiResponse.ok(hotWordService.saveHotWord(hotWordDTO, loginUser.getUserId()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -47,13 +53,20 @@ public class HotWordController {
|
||||||
HotWord existing = hotWordService.getById(hotWordDTO.getId());
|
HotWord existing = hotWordService.getById(hotWordDTO.getId());
|
||||||
if (existing == null) return ApiResponse.error("热词不存在");
|
if (existing == null) return ApiResponse.error("热词不存在");
|
||||||
|
|
||||||
// 权限校验:公开热词仅管理员可改,私有热词仅本人可改
|
boolean isAdmin = isCurrentUserAdmin(loginUser);
|
||||||
|
|
||||||
|
// 核心校验逻辑:
|
||||||
|
// 1. 如果用户尝试将热词设为公开,必须具备管理员权限
|
||||||
|
if (Integer.valueOf(1).equals(hotWordDTO.getIsPublic()) && !isAdmin) {
|
||||||
|
return ApiResponse.error("无权将热词设为公开");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 如果是公开热词,只有管理员能改
|
||||||
if (Integer.valueOf(1).equals(existing.getIsPublic())) {
|
if (Integer.valueOf(1).equals(existing.getIsPublic())) {
|
||||||
if (!Boolean.TRUE.equals(loginUser.getIsPlatformAdmin())) {
|
if (!isAdmin) return ApiResponse.error("无权修改公开热词");
|
||||||
return ApiResponse.error("无权修改公开热词");
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
if (!existing.getCreatorId().equals(loginUser.getUserId())) {
|
// 3. 如果是私有热词,本人或管理员能改
|
||||||
|
if (!existing.getCreatorId().equals(loginUser.getUserId()) && !isAdmin) {
|
||||||
return ApiResponse.error("无权修改他人私有热词");
|
return ApiResponse.error("无权修改他人私有热词");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -68,12 +81,13 @@ public class HotWordController {
|
||||||
HotWord existing = hotWordService.getById(id);
|
HotWord existing = hotWordService.getById(id);
|
||||||
if (existing == null) return ApiResponse.ok(true);
|
if (existing == null) return ApiResponse.ok(true);
|
||||||
|
|
||||||
|
boolean isAdmin = isCurrentUserAdmin(loginUser);
|
||||||
|
|
||||||
|
// 权限校验:公开热词管理员可删,私有热词本人或管理员可删
|
||||||
if (Integer.valueOf(1).equals(existing.getIsPublic())) {
|
if (Integer.valueOf(1).equals(existing.getIsPublic())) {
|
||||||
if (!Boolean.TRUE.equals(loginUser.getIsPlatformAdmin())) {
|
if (!isAdmin) return ApiResponse.error("无权删除公开热词");
|
||||||
return ApiResponse.error("无权删除公开热词");
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
if (!existing.getCreatorId().equals(loginUser.getUserId())) {
|
if (!existing.getCreatorId().equals(loginUser.getUserId()) && !isAdmin) {
|
||||||
return ApiResponse.error("无权删除他人私有热词");
|
return ApiResponse.error("无权删除他人私有热词");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -87,20 +101,33 @@ public class HotWordController {
|
||||||
@RequestParam(defaultValue = "1") Integer current,
|
@RequestParam(defaultValue = "1") Integer current,
|
||||||
@RequestParam(defaultValue = "10") Integer size,
|
@RequestParam(defaultValue = "10") Integer size,
|
||||||
@RequestParam(required = false) String word,
|
@RequestParam(required = false) String word,
|
||||||
@RequestParam(required = false) String category) {
|
@RequestParam(required = false) String category,
|
||||||
|
@RequestParam(required = false) Integer isPublic) {
|
||||||
|
|
||||||
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
|
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
|
||||||
|
boolean isAdmin = isCurrentUserAdmin(loginUser);
|
||||||
|
|
||||||
Page<HotWord> page = hotWordService.page(new Page<>(current, size),
|
LambdaQueryWrapper<HotWord> wrapper = new LambdaQueryWrapper<HotWord>()
|
||||||
new LambdaQueryWrapper<HotWord>()
|
.eq(HotWord::getTenantId, loginUser.getTenantId());
|
||||||
.eq(HotWord::getTenantId, loginUser.getTenantId())
|
|
||||||
.and(w -> w.eq(HotWord::getCreatorId, loginUser.getUserId()).or().eq(HotWord::getIsPublic, 1))
|
|
||||||
.like(word != null && !word.isEmpty(), HotWord::getWord, word)
|
|
||||||
.eq(category != null && !category.isEmpty(), HotWord::getCategory, category)
|
|
||||||
.orderByDesc(HotWord::getIsPublic) // 公开的排在前面
|
|
||||||
.orderByDesc(HotWord::getCreatedAt));
|
|
||||||
|
|
||||||
|
if (!isAdmin) {
|
||||||
|
// 普通用户:只能看到“已公开”的,或者“自己创建”的
|
||||||
|
wrapper.and(w -> w.eq(HotWord::getIsPublic, 1).or().eq(HotWord::getCreatorId, loginUser.getUserId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 增加类型过滤
|
||||||
|
if (isPublic != null) {
|
||||||
|
wrapper.eq(HotWord::getIsPublic, isPublic);
|
||||||
|
}
|
||||||
|
|
||||||
|
wrapper.like(word != null && !word.isEmpty(), HotWord::getWord, word)
|
||||||
|
.eq(category != null && !category.isEmpty(), HotWord::getCategory, category)
|
||||||
|
.orderByDesc(HotWord::getIsPublic)
|
||||||
|
.orderByDesc(HotWord::getCreatedAt);
|
||||||
|
|
||||||
|
Page<HotWord> page = hotWordService.page(new Page<>(current, size), wrapper);
|
||||||
List<HotWordVO> vos = page.getRecords().stream().map(this::toVO).collect(Collectors.toList());
|
List<HotWordVO> vos = page.getRecords().stream().map(this::toVO).collect(Collectors.toList());
|
||||||
|
|
||||||
PageResult<List<HotWordVO>> result = new PageResult<>();
|
PageResult<List<HotWordVO>> result = new PageResult<>();
|
||||||
result.setTotal(page.getTotal());
|
result.setTotal(page.getTotal());
|
||||||
result.setRecords(vos);
|
result.setRecords(vos);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package com.imeeting.mapper;
|
package com.imeeting.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
|
||||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
import com.imeeting.entity.SysUserRole;
|
import com.imeeting.entity.SysUserRole;
|
||||||
import org.apache.ibatis.annotations.Param;
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
@ -10,6 +11,7 @@ import java.util.List;
|
||||||
|
|
||||||
@Mapper
|
@Mapper
|
||||||
public interface SysUserRoleMapper extends BaseMapper<SysUserRole> {
|
public interface SysUserRoleMapper extends BaseMapper<SysUserRole> {
|
||||||
|
@InterceptorIgnore(tenantLine = "true")
|
||||||
@Select("""
|
@Select("""
|
||||||
SELECT COUNT(1)
|
SELECT COUNT(1)
|
||||||
FROM sys_user_role ur
|
FROM sys_user_role ur
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ export const getHotWordPage = (params: {
|
||||||
size: number;
|
size: number;
|
||||||
word?: string;
|
word?: string;
|
||||||
category?: string;
|
category?: string;
|
||||||
matchStrategy?: number
|
isPublic?: number;
|
||||||
}) => {
|
}) => {
|
||||||
return http.get<any, { code: string; data: { records: HotWordVO[]; total: number }; msg: string }>(
|
return http.get<any, { code: string; data: { records: HotWordVO[]; total: number }; msg: string }>(
|
||||||
"/api/biz/hotword/page",
|
"/api/biz/hotword/page",
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,6 @@ const { Text } = Typography;
|
||||||
|
|
||||||
const HotWords: React.FC = () => {
|
const HotWords: React.FC = () => {
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [searchForm] = Form.useForm();
|
|
||||||
const { items: categories, loading: dictLoading } = useDict('biz_hotword_category');
|
const { items: categories, loading: dictLoading } = useDict('biz_hotword_category');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [data, setData] = useState<HotWordVO[]>([]);
|
const [data, setData] = useState<HotWordVO[]>([]);
|
||||||
|
|
@ -33,6 +32,7 @@ const HotWords: React.FC = () => {
|
||||||
const [size, setSize] = useState(10);
|
const [size, setSize] = useState(10);
|
||||||
const [searchWord, setSearchWord] = useState('');
|
const [searchWord, setSearchWord] = useState('');
|
||||||
const [searchCategory, setSearchCategory] = useState<string | undefined>(undefined);
|
const [searchCategory, setSearchCategory] = useState<string | undefined>(undefined);
|
||||||
|
const [searchType, setSearchType] = useState<number | undefined>(undefined);
|
||||||
|
|
||||||
const [modalVisible, setModalVisible] = useState(false);
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
const [editingId, setEditingId] = useState<number | null>(null);
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
|
|
@ -44,11 +44,14 @@ const HotWords: React.FC = () => {
|
||||||
return profileStr ? JSON.parse(profileStr) : {};
|
return profileStr ? JSON.parse(profileStr) : {};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const isPlatformAdmin = userProfile.isPlatformAdmin === true;
|
// 判定是否具有管理员权限 (平台管理员或租户管理员)
|
||||||
|
const isAdmin = React.useMemo(() => {
|
||||||
|
return userProfile.isPlatformAdmin === true || userProfile.isTenantAdmin === true;
|
||||||
|
}, [userProfile]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData();
|
fetchData();
|
||||||
}, [current, size, searchWord, searchCategory]);
|
}, [current, size, searchWord, searchCategory, searchType]);
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
@ -57,7 +60,8 @@ const HotWords: React.FC = () => {
|
||||||
current,
|
current,
|
||||||
size,
|
size,
|
||||||
word: searchWord,
|
word: searchWord,
|
||||||
category: searchCategory
|
category: searchCategory,
|
||||||
|
isPublic: searchType
|
||||||
});
|
});
|
||||||
if (res.data && res.data.data) {
|
if (res.data && res.data.data) {
|
||||||
setData(res.data.data.records);
|
setData(res.data.data.records);
|
||||||
|
|
@ -72,8 +76,7 @@ const HotWords: React.FC = () => {
|
||||||
|
|
||||||
const handleOpenModal = (record?: HotWordVO) => {
|
const handleOpenModal = (record?: HotWordVO) => {
|
||||||
if (record) {
|
if (record) {
|
||||||
// 权限校验
|
if (record.isPublic === 1 && !isAdmin) {
|
||||||
if (record.isPublic === 1 && !isPlatformAdmin) {
|
|
||||||
message.error('公开热词仅限管理员修改');
|
message.error('公开热词仅限管理员修改');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -142,9 +145,9 @@ const HotWords: React.FC = () => {
|
||||||
<Space>
|
<Space>
|
||||||
<Text strong>{text}</Text>
|
<Text strong>{text}</Text>
|
||||||
{record.isPublic === 1 ? (
|
{record.isPublic === 1 ? (
|
||||||
<Tooltip title="全租户公开"><GlobalOutlined style={{ color: '#52c41a' }} /></Tooltip>
|
<Tooltip title="租户公开"><GlobalOutlined style={{ color: '#52c41a' }} /></Tooltip>
|
||||||
) : (
|
) : (
|
||||||
<Tooltip title="仅个人私有"><UserOutlined style={{ color: '#1890ff' }} /></Tooltip>
|
<Tooltip title="个人私有"><UserOutlined style={{ color: '#1890ff' }} /></Tooltip>
|
||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
)
|
)
|
||||||
|
|
@ -166,7 +169,7 @@ const HotWords: React.FC = () => {
|
||||||
render: (val: string) => categories.find(i => i.itemValue === val)?.itemLabel || val
|
render: (val: string) => categories.find(i => i.itemValue === val)?.itemLabel || val
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '类型',
|
title: '范围',
|
||||||
dataIndex: 'isPublic',
|
dataIndex: 'isPublic',
|
||||||
key: 'isPublic',
|
key: 'isPublic',
|
||||||
render: (val: number) => val === 1 ? <Tag color="green">公开</Tag> : <Tag color="blue">私有</Tag>
|
render: (val: number) => val === 1 ? <Tag color="green">公开</Tag> : <Tag color="blue">私有</Tag>
|
||||||
|
|
@ -181,7 +184,8 @@ const HotWords: React.FC = () => {
|
||||||
title: '操作',
|
title: '操作',
|
||||||
key: 'action',
|
key: 'action',
|
||||||
render: (_: any, record: HotWordVO) => {
|
render: (_: any, record: HotWordVO) => {
|
||||||
const canEdit = record.isPublic === 1 ? isPlatformAdmin : record.creatorId === userProfile.userId;
|
const isMine = record.creatorId === userProfile.userId;
|
||||||
|
const canEdit = record.isPublic === 1 ? isAdmin : (isMine || isAdmin);
|
||||||
return (
|
return (
|
||||||
<Space size="middle">
|
<Space size="middle">
|
||||||
{canEdit ? (
|
{canEdit ? (
|
||||||
|
|
@ -205,12 +209,21 @@ const HotWords: React.FC = () => {
|
||||||
<Card
|
<Card
|
||||||
title="热词库管理"
|
title="热词库管理"
|
||||||
extra={
|
extra={
|
||||||
<Space>
|
<Space wrap>
|
||||||
|
<Select
|
||||||
|
placeholder="热词类型"
|
||||||
|
style={{ width: 110 }}
|
||||||
|
allowClear
|
||||||
|
onChange={v => {setSearchType(v); setCurrent(1);}}
|
||||||
|
>
|
||||||
|
<Option value={1}>租户公开</Option>
|
||||||
|
<Option value={0}>个人私有</Option>
|
||||||
|
</Select>
|
||||||
<Select
|
<Select
|
||||||
placeholder="按类别筛选"
|
placeholder="按类别筛选"
|
||||||
style={{ width: 140 }}
|
style={{ width: 130 }}
|
||||||
allowClear
|
allowClear
|
||||||
onChange={setSearchCategory}
|
onChange={v => {setSearchCategory(v); setCurrent(1);}}
|
||||||
>
|
>
|
||||||
{categories.map(c => <Option key={c.itemValue} value={c.itemValue}>{c.itemLabel}</Option>)}
|
{categories.map(c => <Option key={c.itemValue} value={c.itemValue}>{c.itemLabel}</Option>)}
|
||||||
</Select>
|
</Select>
|
||||||
|
|
@ -219,7 +232,7 @@ const HotWords: React.FC = () => {
|
||||||
prefix={<SearchOutlined />}
|
prefix={<SearchOutlined />}
|
||||||
allowClear
|
allowClear
|
||||||
onPressEnter={(e) => {setSearchWord((e.target as any).value); setCurrent(1);}}
|
onPressEnter={(e) => {setSearchWord((e.target as any).value); setCurrent(1);}}
|
||||||
style={{ width: 200 }}
|
style={{ width: 180 }}
|
||||||
/>
|
/>
|
||||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => handleOpenModal()}>
|
<Button type="primary" icon={<PlusOutlined />} onClick={() => handleOpenModal()}>
|
||||||
新增热词
|
新增热词
|
||||||
|
|
@ -252,10 +265,10 @@ const HotWords: React.FC = () => {
|
||||||
>
|
>
|
||||||
<Form form={form} layout="vertical" style={{ marginTop: '16px' }}>
|
<Form form={form} layout="vertical" style={{ marginTop: '16px' }}>
|
||||||
<Form.Item name="word" label="热词原文" rules={[{ required: true, message: '请输入热词原文' }]}>
|
<Form.Item name="word" label="热词原文" rules={[{ required: true, message: '请输入热词原文' }]}>
|
||||||
<Input placeholder="输入中文或英文关键词" onBlur={handleWordBlur} />
|
<Input placeholder="输入识别关键词" onBlur={handleWordBlur} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item name="pinyinList" label="拼音 (多音字用逗号分隔)" tooltip="留空将根据原文自动生成">
|
<Form.Item name="pinyinList" label="拼音 (多音字用逗号分隔)" tooltip="留空自动生成">
|
||||||
<Input.TextArea placeholder="例如: chong qing, zhong qing" rows={2} />
|
<Input.TextArea placeholder="例如: chong qing, zhong qing" rows={2} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
|
|
@ -283,7 +296,7 @@ const HotWords: React.FC = () => {
|
||||||
</Select>
|
</Select>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
{isPlatformAdmin && (
|
{isAdmin && (
|
||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
<Form.Item name="isPublic" label="租户公开" tooltip="开启后,租户内所有成员均可共享此热词">
|
<Form.Item name="isPublic" label="租户公开" tooltip="开启后,租户内所有成员均可共享此热词">
|
||||||
<Radio.Group>
|
<Radio.Group>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue