feat(hotword): 实现热词权限管理和搜索功能

- 添加管理员权限判断方法支持平台管理员和租户管理员
- 实现公开热词仅管理员可创建、修改、删除的权限控制
- 支持私有热词本人或管理员可操作的权限验证
- 添加前端搜索类型筛选功能支持公开/私有分类
- 更新前端权限检查逻辑统一使用管理员身份判断
- 修复后端查询构造器逻辑确保权限过滤正确执行
- 添加租户管理员身份到JWT认证信息中
- 配置数据访问层忽略租户隔离注解优化查询性能
dev_na
chenhao 2026-03-03 10:08:07 +08:00
parent f6ba04ebd8
commit 70e739818a
5 changed files with 86 additions and 44 deletions

View File

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

View File

@ -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);
if (Integer.valueOf(1).equals(existing.getIsPublic())) {
if (!Boolean.TRUE.equals(loginUser.getIsPlatformAdmin())) { // 核心校验逻辑:
return ApiResponse.error("无权修改公开热词"); // 1. 如果用户尝试将热词设为公开,必须具备管理员权限
if (Integer.valueOf(1).equals(hotWordDTO.getIsPublic()) && !isAdmin) {
return ApiResponse.error("无权将热词设为公开");
} }
// 2. 如果是公开热词,只有管理员能改
if (Integer.valueOf(1).equals(existing.getIsPublic())) {
if (!isAdmin) 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)) if (!isAdmin) {
.like(word != null && !word.isEmpty(), HotWord::getWord, word) // 普通用户:只能看到“已公开”的,或者“自己创建”的
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) .eq(category != null && !category.isEmpty(), HotWord::getCategory, category)
.orderByDesc(HotWord::getIsPublic) // 公开的排在前面 .orderByDesc(HotWord::getIsPublic)
.orderByDesc(HotWord::getCreatedAt)); .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);

View File

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

View File

@ -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",

View File

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