feat: 添加热词组管理功能并优化热词控制器逻辑

- 在 `HotWordController` 中添加 `HotWordGroupService` 依赖,并更新相关方法以支持租户ID
- 重构权限校验逻辑,移除 `isCurrentUserAdmin` 方法,改为使用 `resolveTargetTenantId` 方法
dev_na
chenhao 2026-04-22 15:28:06 +08:00
parent 324e283f41
commit 2d788bac75
27 changed files with 962 additions and 374 deletions

View File

@ -253,6 +253,7 @@
| pinyin_list | JSONB | | 鎷奸煶鏁扮粍 |
| match_strategy | SMALLINT | DEFAULT 1 | 鍖归厤绛栫暐 (1:绮剧‘, 2:妯$硦) |
| category | VARCHAR(50) | | 绫诲埆 (浜哄悕銆佹湳璇瓑) |
| hot_word_group_id | BIGINT | | 所属热词组 ID |
| weight | INTEGER | DEFAULT 10 | 鏉冮噸 (1-100) |
| status | SMALLINT | DEFAULT 1 | 鐘舵€?(1:鍚敤, 0:绂佺敤) |
| is_synced | SMALLINT | DEFAULT 0 | 宸插悓姝ョ涓夋柟鏍囪 |
@ -264,8 +265,26 @@
绱㈠紩锛?
- `idx_hotword_tenant`: `(tenant_id)`
- `idx_hotword_word`: `(word) WHERE is_deleted = 0`
- `idx_hotword_group`: `(hot_word_group_id) WHERE is_deleted = 0`
### 5.3 `biz_prompt_templates`锛堟彁绀鸿瘝妯℃澘琛級
### 5.3 `biz_hot_word_groups`(热词组表)
| 字段 | 类型 | 约束 | 说明 |
| --- | --- | --- | --- |
| id | BIGSERIAL | PK | 主键ID |
| tenant_id | BIGINT | NOT NULL | 租户ID |
| group_name | VARCHAR(100) | NOT NULL | 热词组名称 |
| creator_id | BIGINT | | 创建人ID |
| status | SMALLINT | DEFAULT 1 | 状态(1:启用, 0:禁用) |
| remark | VARCHAR(255) | | 备注 |
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 创建时间 |
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 更新时间 |
| is_deleted | SMALLINT | DEFAULT 0 | 逻辑删除 |
索引:
- `idx_hot_word_group_tenant`: `(tenant_id) WHERE is_deleted = 0`
- `uk_hot_word_group_name_scope`: `(tenant_id, group_name) WHERE is_deleted = 0`
### 5.4 `biz_prompt_templates`锛堟彁绀鸿瘝妯℃澘琛級
| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
| --- | --- | --- | --- |
| id | BIGSERIAL | PK | 涓婚敭ID |
@ -275,6 +294,7 @@
| is_system | SMALLINT | DEFAULT 0 | 鏄惁棰勭疆 (1:鏄? 0:鍚? |
| creator_id | BIGINT | | 鍒涘缓浜篒D |
| tags | JSONB | | 鏍囩鏁扮粍 |
| hot_word_group_id | BIGINT | | 绑定热词组 ID |
| usage_count | INTEGER | DEFAULT 0 | 浣跨敤娆℃暟 |
| prompt_content | TEXT | NOT NULL | 鎻愮ず璇嶅唴瀹?|
| status | SMALLINT | DEFAULT 1 | 鐘舵€?(1:鍚敤, 0:绂佺敤) |
@ -286,8 +306,9 @@
绱㈠紩锛?
- `idx_prompt_tenant`: `(tenant_id)`
- `idx_prompt_system`: `(is_system) WHERE is_deleted = 0`
- `idx_prompt_group`: `(hot_word_group_id) WHERE is_deleted = 0`
### 5.4 `biz_asr_models`ASR 模型管理表)
### 5.5 `biz_asr_models`ASR 模型管理表)
| 字段 | 类型 | 约束 | 说明 |
| --- | --- | --- | --- |
| id | BIGSERIAL | PK | 主键ID |

View File

@ -262,6 +262,24 @@ COMMENT ON TABLE biz_speakers IS '声纹发言人基础信息表 (声纹库资
-- ----------------------------
-- 7. 业务模块 - 热词管理
-- ----------------------------
DROP TABLE IF EXISTS biz_hot_word_groups CASCADE;
CREATE TABLE biz_hot_word_groups (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL, -- 租户ID
group_name VARCHAR(100) NOT NULL, -- 热词组名称
creator_id BIGINT, -- 创建人ID
status SMALLINT DEFAULT 1, -- 状态: 1:启用, 0:禁用
remark VARCHAR(255), -- 备注
created_at TIMESTAMP(6) NOT NULL DEFAULT now(),
updated_at TIMESTAMP(6) NOT NULL DEFAULT now(),
is_deleted SMALLINT NOT NULL DEFAULT 0
);
CREATE INDEX idx_hot_word_group_tenant ON biz_hot_word_groups (tenant_id) WHERE is_deleted = 0;
CREATE UNIQUE INDEX uk_hot_word_group_name_scope ON biz_hot_word_groups (tenant_id, group_name) WHERE is_deleted = 0;
COMMENT ON TABLE biz_hot_word_groups IS '热词组表';
DROP TABLE IF EXISTS biz_hot_words CASCADE;
CREATE TABLE biz_hot_words (
id BIGSERIAL PRIMARY KEY,
@ -272,6 +290,7 @@ CREATE TABLE biz_hot_words (
pinyin_list text, -- 拼音数组(支持多音字, 如 ["i mi ting", "i mei ting"])
match_strategy SMALLINT DEFAULT 1, -- 匹配策略: 1:精确匹配, 2:拼音模糊匹配
category VARCHAR(50), -- 类别 (人名、术语、地名)
hot_word_group_id BIGINT, -- 所属热词组 ID
weight INTEGER DEFAULT 10, -- 权重 (1-100)
status SMALLINT DEFAULT 1, -- 状态: 1:启用, 0:禁用
is_synced SMALLINT DEFAULT 0, -- 是否已同步至第三方引擎: 0:未同步, 1:已同步
@ -283,6 +302,7 @@ CREATE TABLE biz_hot_words (
CREATE INDEX idx_hotword_tenant ON biz_hot_words (tenant_id);
CREATE INDEX idx_hotword_word ON biz_hot_words (word) WHERE is_deleted = 0;
CREATE INDEX idx_hotword_group ON biz_hot_words (hot_word_group_id) WHERE is_deleted = 0;
COMMENT ON TABLE biz_hot_words IS '语音识别热词表';
@ -299,6 +319,7 @@ CREATE TABLE biz_prompt_templates (
is_system SMALLINT DEFAULT 0, -- 是否系统预置 (1:是, 0:否)
creator_id BIGINT, -- 创建人ID
tags text, -- 标签数组 (JSONB)
hot_word_group_id BIGINT, -- 绑定热词组 ID
usage_count INTEGER DEFAULT 0, -- 使用次数
prompt_content TEXT NOT NULL, -- 提示词内容
status SMALLINT DEFAULT 1, -- 状态: 1:启用, 0:禁用
@ -310,6 +331,7 @@ CREATE TABLE biz_prompt_templates (
CREATE INDEX idx_prompt_tenant ON biz_prompt_templates (tenant_id);
CREATE INDEX idx_prompt_system ON biz_prompt_templates (is_system) WHERE is_deleted = 0;
CREATE INDEX idx_prompt_group ON biz_prompt_templates (hot_word_group_id) WHERE is_deleted = 0;
COMMENT ON TABLE biz_prompt_templates IS '会议总结提示词模板表';

View File

@ -2,11 +2,11 @@ package com.imeeting.controller.biz;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.imeeting.dto.biz.HotWordDTO;
import com.imeeting.dto.biz.HotWordVO;
import com.imeeting.entity.biz.HotWord;
import com.imeeting.entity.biz.HotWordGroup;
import com.imeeting.service.biz.HotWordGroupService;
import com.imeeting.service.biz.HotWordService;
import com.unisbase.common.ApiResponse;
import com.unisbase.dto.PageResult;
@ -26,16 +26,18 @@ import java.util.stream.Collectors;
public class HotWordController {
private final HotWordService hotWordService;
private final HotWordGroupService hotWordGroupService;
public HotWordController(HotWordService hotWordService) {
public HotWordController(HotWordService hotWordService, HotWordGroupService hotWordGroupService) {
this.hotWordService = hotWordService;
this.hotWordGroupService = hotWordGroupService;
}
/**
* ()
*/
private boolean isCurrentUserAdmin(LoginUser user) {
return Boolean.TRUE.equals(user.getIsPlatformAdmin()) || Boolean.TRUE.equals(user.getIsTenantAdmin());
private Long resolveTargetTenantId(LoginUser loginUser, Long tenantId) {
if (Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()) && Long.valueOf(0L).equals(tenantId)) {
return 0L;
}
return loginUser.getTenantId();
}
@Operation(summary = "新增热词")
@ -43,63 +45,30 @@ public class HotWordController {
@PreAuthorize("isAuthenticated()")
public ApiResponse<HotWordVO> save(@RequestBody HotWordDTO hotWordDTO) {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
// 核心校验:只有管理员可以创建公开热词
if (Integer.valueOf(1).equals(hotWordDTO.getIsPublic()) && !isCurrentUserAdmin(loginUser)) {
return ApiResponse.error("无权创建租户公开热词,请设为个人私有");
}
return ApiResponse.ok(hotWordService.saveHotWord(hotWordDTO, loginUser.getUserId()));
Long targetTenantId = resolveTargetTenantId(loginUser, hotWordDTO.getTenantId());
return ApiResponse.ok(hotWordService.saveHotWord(hotWordDTO, loginUser.getUserId(), targetTenantId));
}
@Operation(summary = "修改热词")
@PutMapping
@PreAuthorize("isAuthenticated()")
public ApiResponse<HotWordVO> update(@RequestBody HotWordDTO hotWordDTO) {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
HotWord existing = hotWordService.getById(hotWordDTO.getId());
if (existing == null) return ApiResponse.error("热词不存在");
boolean isAdmin = isCurrentUserAdmin(loginUser);
// 核心校验逻辑:
// 1. 如果用户尝试将热词设为公开,必须具备管理员权限
if (Integer.valueOf(1).equals(hotWordDTO.getIsPublic()) && !isAdmin) {
return ApiResponse.error("无权将热词设为公开");
if (existing == null) {
return ApiResponse.error("热词不存在");
}
// 2. 如果是公开热词,只有管理员能改
if (Integer.valueOf(1).equals(existing.getIsPublic())) {
if (!isAdmin) return ApiResponse.error("无权修改公开热词");
} else {
// 3. 如果是私有热词,本人或管理员能改
if (!existing.getCreatorId().equals(loginUser.getUserId()) && !isAdmin) {
return ApiResponse.error("无权修改他人私有热词");
}
}
return ApiResponse.ok(hotWordService.updateHotWord(hotWordDTO));
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
return ApiResponse.ok(hotWordService.updateHotWord(hotWordDTO, loginUser.getUserId(), existing.getTenantId()));
}
@Operation(summary = "删除热词")
@DeleteMapping("/{id}")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Boolean> delete(@PathVariable Long id) {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
HotWord existing = hotWordService.getById(id);
if (existing == null) return ApiResponse.ok(true);
boolean isAdmin = isCurrentUserAdmin(loginUser);
// 权限校验:公开热词管理员可删,私有热词本人或管理员可删
if (Integer.valueOf(1).equals(existing.getIsPublic())) {
if (!isAdmin) return ApiResponse.error("无权删除公开热词");
} else {
if (!existing.getCreatorId().equals(loginUser.getUserId()) && !isAdmin) {
return ApiResponse.error("无权删除他人私有热词");
}
if (existing == null) {
return ApiResponse.ok(true);
}
return ApiResponse.ok(hotWordService.removeById(id));
}
@ -111,28 +80,15 @@ public class HotWordController {
@RequestParam(defaultValue = "10") Integer size,
@RequestParam(required = false) String word,
@RequestParam(required = false) String category,
@RequestParam(required = false) Integer isPublic) {
@RequestParam(required = false) Long tenantId) {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
boolean isAdmin = isCurrentUserAdmin(loginUser);
Long targetTenantId = Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()) ? tenantId : null;
LambdaQueryWrapper<HotWord> wrapper = new LambdaQueryWrapper<HotWord>()
.eq(HotWord::getTenantId, loginUser.getTenantId());
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);
.like(word != null && !word.isEmpty(), HotWord::getWord, word)
.eq(category != null && !category.isEmpty(), HotWord::getCategory, category)
.orderByDesc(HotWord::getCreatedAt);
wrapper.eq(targetTenantId != null, HotWord::getTenantId, targetTenantId);
Page<HotWord> page = hotWordService.page(new Page<>(current, size), wrapper);
List<HotWordVO> vos = page.getRecords().stream().map(this::toVO).collect(Collectors.toList());
@ -157,14 +113,19 @@ public class HotWordController {
vo.setPinyinList(entity.getPinyinList());
vo.setMatchStrategy(entity.getMatchStrategy());
vo.setCategory(entity.getCategory());
vo.setHotWordGroupId(entity.getHotWordGroupId());
vo.setWeight(entity.getWeight());
vo.setStatus(entity.getStatus());
vo.setIsPublic(entity.getIsPublic());
vo.setIsPublic(1);
vo.setCreatorId(entity.getCreatorId());
vo.setIsSynced(entity.getIsSynced());
vo.setRemark(entity.getRemark());
vo.setCreatedAt(entity.getCreatedAt());
vo.setUpdatedAt(entity.getUpdatedAt());
if (entity.getHotWordGroupId() != null) {
HotWordGroup group = hotWordGroupService.getById(entity.getHotWordGroupId());
vo.setHotWordGroupName(group == null ? null : group.getGroupName());
}
return vo;
}
}

View File

@ -74,7 +74,7 @@ public class PromptTemplateController {
return ApiResponse.error("No permission to modify this template");
}
return ApiResponse.ok(promptTemplateService.updateTemplate(dto));
return ApiResponse.ok(promptTemplateService.updateTemplate(dto, loginUser.getUserId(), loginUser.getTenantId()));
}
@Operation(summary = "更新提示词模板状态")
@ -143,6 +143,20 @@ public class PromptTemplateController {
return ApiResponse.ok(promptTemplateService.removeById(id));
}
@Operation(summary = "查询提示词模板详情")
@GetMapping("/{id}")
@PreAuthorize("isAuthenticated()")
public ApiResponse<PromptTemplateVO> detail(@PathVariable Long id) {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
return ApiResponse.ok(promptTemplateService.getTemplateDetail(
id,
loginUser.getTenantId(),
loginUser.getUserId(),
loginUser.getIsPlatformAdmin(),
loginUser.getIsTenantAdmin()
));
}
@Operation(summary = "分页查询提示词模板")
@GetMapping("/page")
@PreAuthorize("isAuthenticated()")

View File

@ -1,17 +1,44 @@
package com.imeeting.dto.biz;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.List;
@Data
@Schema(description = "热词请求参数")
public class HotWordDTO {
@Schema(description = "热词 ID")
private Long id;
@Schema(description = "租户 ID平台管理员可传 0 表示平台范围")
private Long tenantId;
@Schema(description = "热词内容")
private String word;
@Schema(description = "拼音列表")
private List<String> pinyinList;
@Schema(description = "匹配策略")
private Integer matchStrategy;
@Schema(description = "热词分类")
private String category;
@Schema(description = "所属热词组 ID")
private Long hotWordGroupId;
@Schema(description = "权重")
private Integer weight;
@Schema(description = "状态1-启用0-禁用")
private Integer status;
@Schema(description = "是否公开,当前固定为公开")
private Integer isPublic;
@Schema(description = "备注")
private String remark;
}

View File

@ -1,22 +1,57 @@
package com.imeeting.dto.biz;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
@Data
@Schema(description = "热词信息")
public class HotWordVO {
@Schema(description = "热词 ID")
private Long id;
@Schema(description = "热词内容")
private String word;
@Schema(description = "拼音列表")
private List<String> pinyinList;
@Schema(description = "是否公开,当前固定为公开")
private Integer isPublic;
@Schema(description = "创建人 ID")
private Long creatorId;
@Schema(description = "匹配策略")
private Integer matchStrategy;
@Schema(description = "热词分类")
private String category;
@Schema(description = "所属热词组 ID")
private Long hotWordGroupId;
@Schema(description = "所属热词组名称")
private String hotWordGroupName;
@Schema(description = "权重")
private Integer weight;
@Schema(description = "状态1-启用0-禁用")
private Integer status;
@Schema(description = "是否已同步")
private Integer isSynced;
@Schema(description = "备注")
private String remark;
@Schema(description = "创建时间")
private LocalDateTime createdAt;
@Schema(description = "更新时间")
private LocalDateTime updatedAt;
}

View File

@ -1,17 +1,42 @@
package com.imeeting.dto.biz;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "会议总结模板请求参数")
public class PromptTemplateDTO {
@Schema(description = "模板 ID")
private Long id;
@Schema(description = "租户 ID")
private Long tenantId;
@Schema(description = "模板名称")
private String templateName;
@Schema(description = "模板描述")
private String description;
@Schema(description = "模板分类")
private String category;
@Schema(description = "是否系统模板1-是0-否")
private Integer isSystem;
@Schema(description = "标签列表")
private java.util.List<String> tags;
@Schema(description = "绑定热词组 ID")
private Long hotWordGroupId;
@Schema(description = "模板内容")
private String promptContent;
@Schema(description = "状态1-启用0-禁用")
private Integer status;
@Schema(description = "备注")
private String remark;
}

View File

@ -3,6 +3,7 @@ package com.imeeting.dto.biz;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
@Schema(description = "提示词模板信息")
@Data
@ -23,6 +24,12 @@ public class PromptTemplateVO {
private Integer isSystem;
@Schema(description = "标签列表")
private java.util.List<String> tags;
@Schema(description = "绑定热词组 ID")
private Long hotWordGroupId;
@Schema(description = "绑定热词组名称")
private String hotWordGroupName;
@Schema(description = "绑定热词列表")
private List<String> hotWords;
@Schema(description = "使用次数")
private Integer usageCount;
@Schema(description = "提示词正文")

View File

@ -12,6 +12,7 @@ public class RealtimeMeetingRuntimeProfile {
private String resolvedSummaryModelName;
private Long resolvedPromptId;
private String resolvedPromptName;
private Long resolvedHotWordGroupId;
private String resolvedMode;
private String resolvedLanguage;
private Integer resolvedUseSpkId;

View File

@ -40,6 +40,9 @@ public class HotWord extends BaseEntity {
@Schema(description = "热词分类")
private String category;
@Schema(description = "所属热词组 ID")
private Long hotWordGroupId;
@Schema(description = "权重")
private Integer weight;

View File

@ -36,6 +36,9 @@ public class PromptTemplate extends BaseEntity {
@Schema(description = "业务标签列表")
private java.util.List<String> tags;
@Schema(description = "绑定热词组 ID")
private Long hotWordGroupId;
@Schema(description = "使用次数")
private Integer usageCount;

View File

@ -1,9 +1,46 @@
package com.imeeting.mapper.biz;
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.imeeting.entity.biz.HotWord;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface HotWordMapper extends BaseMapper<HotWord> {
@InterceptorIgnore(tenantLine = "true")
@Select({
"<script>",
"SELECT id, tenant_id, word, hot_word_group_id, weight, status, is_synced, remark, created_at, updated_at, is_deleted",
"FROM biz_hot_words",
"WHERE is_deleted = 0",
"AND status = 1",
"AND hot_word_group_id = #{groupId}",
"ORDER BY created_at DESC",
"</script>"
})
List<HotWord> selectEnabledByGroupIdIgnoreTenant(@Param("groupId") Long groupId);
@InterceptorIgnore(tenantLine = "true")
@Select({
"<script>",
"SELECT id, tenant_id, word, hot_word_group_id, weight, status, is_synced, remark, created_at, updated_at, is_deleted",
"FROM biz_hot_words",
"WHERE is_deleted = 0",
"AND status = 1",
"AND hot_word_group_id = #{groupId}",
"<if test='words != null and words.size > 0'>",
"AND word IN",
"<foreach collection='words' item='word' open='(' separator=',' close=')'>",
"#{word}",
"</foreach>",
"</if>",
"ORDER BY created_at DESC",
"</script>"
})
List<HotWord> selectEnabledByGroupIdAndWordsIgnoreTenant(@Param("groupId") Long groupId, @Param("words") List<String> words);
}

View File

@ -8,7 +8,9 @@ import com.imeeting.entity.biz.HotWord;
import java.util.List;
public interface HotWordService extends IService<HotWord> {
HotWordVO saveHotWord(HotWordDTO hotWordDTO, Long userId);
HotWordVO updateHotWord(HotWordDTO hotWordDTO);
HotWordVO saveHotWord(HotWordDTO hotWordDTO, Long userId, Long tenantId);
HotWordVO updateHotWord(HotWordDTO hotWordDTO, Long userId, Long tenantId);
List<String> generatePinyin(String word);
List<HotWord> listEnabledByGroupIdIgnoreTenant(Long groupId);
List<HotWord> listEnabledByGroupIdAndWordsIgnoreTenant(Long groupId, List<String> words);
}

View File

@ -11,7 +11,8 @@ import java.util.List;
public interface PromptTemplateService extends IService<PromptTemplate> {
PromptTemplateVO saveTemplate(PromptTemplateDTO dto, Long userId, Long tenantId);
PromptTemplateVO updateTemplate(PromptTemplateDTO dto);
PromptTemplateVO updateTemplate(PromptTemplateDTO dto, Long userId, Long tenantId);
PromptTemplateVO getTemplateDetail(Long templateId, Long tenantId, Long userId, Boolean isPlatformAdmin, Boolean isTenantAdmin);
PageResult<List<PromptTemplateVO>> pageTemplates(Integer current, Integer size, String name, String category,
Long tenantId, Long userId, Boolean isPlatformAdmin, Boolean isTenantAdmin);
boolean updateUserTemplateStatus(Long templateId, Integer status, Long tenantId, Long userId, Boolean isPlatformAdmin, Boolean isTenantAdmin);

View File

@ -275,11 +275,15 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
List<Map<String, Object>> hotwords = new ArrayList<>();
Object hotWordsObj = taskRecord.getTaskConfig().get("hotWords");
Object hotWordGroupIdObj = taskRecord.getTaskConfig().get("hotWordGroupId");
if (hotWordsObj instanceof List) {
List<String> words = (List<String>) hotWordsObj;
if (!words.isEmpty()) {
List<HotWord> entities = hotWordService.list(new LambdaQueryWrapper<HotWord>()
.eq(HotWord::getTenantId, meeting.getTenantId()).in(HotWord::getWord, words));
List<HotWord> entities = hotWordGroupIdObj instanceof Number groupId
? hotWordService.listEnabledByGroupIdAndWordsIgnoreTenant(groupId.longValue(), words)
: hotWordService.list(new LambdaQueryWrapper<HotWord>()
.eq(HotWord::getStatus, 1)
.in(HotWord::getWord, words));
Map<String, Integer> weightMap = entities.stream()
.collect(Collectors.toMap(HotWord::getWord, HotWord::getWeight, (v1, v2) -> v1));
for (String w : words) {

View File

@ -1,13 +1,15 @@
package com.imeeting.service.biz.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.imeeting.dto.biz.HotWordDTO;
import com.imeeting.dto.biz.HotWordVO;
import com.imeeting.entity.biz.HotWord;
import com.imeeting.entity.biz.HotWordGroup;
import com.imeeting.mapper.biz.HotWordGroupMapper;
import com.imeeting.mapper.biz.HotWordMapper;
import com.imeeting.service.biz.HotWordService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import net.sourceforge.pinyin4j.PinyinHelper;
import net.sourceforge.pinyin4j.format.HanyuPinyinCaseType;
@ -24,14 +26,20 @@ import java.util.stream.Collectors;
@Slf4j
@Service
@RequiredArgsConstructor
public class HotWordServiceImpl extends ServiceImpl<HotWordMapper, HotWord> implements HotWordService {
private static final int MAX_HOT_WORDS_PER_GROUP = 200;
private final HotWordGroupMapper hotWordGroupMapper;
@Override
@Transactional(rollbackFor = Exception.class)
public HotWordVO saveHotWord(HotWordDTO hotWordDTO, Long userId) {
public HotWordVO saveHotWord(HotWordDTO hotWordDTO, Long userId, Long tenantId) {
HotWord hotWord = new HotWord();
copyProperties(hotWordDTO, hotWord);
hotWord.setCreatorId(userId);
hotWord.setHotWordGroupId(validateGroup(hotWordDTO.getHotWordGroupId(), tenantId, null));
if (hotWord.getPinyinList() == null || hotWord.getPinyinList().isEmpty()) {
hotWord.setPinyinList(generatePinyin(hotWord.getWord()));
@ -43,14 +51,15 @@ public class HotWordServiceImpl extends ServiceImpl<HotWordMapper, HotWord> impl
@Override
@Transactional(rollbackFor = Exception.class)
public HotWordVO updateHotWord(HotWordDTO hotWordDTO) {
public HotWordVO updateHotWord(HotWordDTO hotWordDTO, Long userId, Long tenantId) {
HotWord hotWord = this.getById(hotWordDTO.getId());
if (hotWord == null) {
throw new RuntimeException("Hotword not found");
throw new IllegalArgumentException("热词不存在");
}
String oldWord = hotWord.getWord();
copyProperties(hotWordDTO, hotWord);
hotWord.setHotWordGroupId(validateGroup(hotWordDTO.getHotWordGroupId(), tenantId, hotWord.getId()));
if (!oldWord.equals(hotWord.getWord()) && (hotWordDTO.getPinyinList() == null || hotWordDTO.getPinyinList().isEmpty())) {
hotWord.setPinyinList(generatePinyin(hotWord.getWord()));
@ -62,7 +71,9 @@ public class HotWordServiceImpl extends ServiceImpl<HotWordMapper, HotWord> impl
@Override
public List<String> generatePinyin(String word) {
if (word == null || word.isEmpty()) return Collections.emptyList();
if (word == null || word.isEmpty()) {
return Collections.emptyList();
}
HanyuPinyinOutputFormat format = new HanyuPinyinOutputFormat();
format.setCaseType(HanyuPinyinCaseType.LOWERCASE);
@ -75,7 +86,9 @@ public class HotWordServiceImpl extends ServiceImpl<HotWordMapper, HotWord> impl
String[] pinyins = PinyinHelper.toHanyuPinyinStringArray(c, format);
if (pinyins != null) {
for (String py : pinyins) {
if (!charPinyins.contains(py)) charPinyins.add(py);
if (!charPinyins.contains(py)) {
charPinyins.add(py);
}
}
} else {
charPinyins.add(String.valueOf(c));
@ -91,6 +104,42 @@ public class HotWordServiceImpl extends ServiceImpl<HotWordMapper, HotWord> impl
return combinations.stream().limit(5).collect(Collectors.toList());
}
@Override
public List<HotWord> listEnabledByGroupIdIgnoreTenant(Long groupId) {
if (groupId == null) {
return List.of();
}
return baseMapper.selectEnabledByGroupIdIgnoreTenant(groupId);
}
@Override
public List<HotWord> listEnabledByGroupIdAndWordsIgnoreTenant(Long groupId, List<String> words) {
if (groupId == null) {
return List.of();
}
return baseMapper.selectEnabledByGroupIdAndWordsIgnoreTenant(groupId, words);
}
private Long validateGroup(Long groupId, Long tenantId, Long currentHotWordId) {
if (groupId == null) {
return null;
}
HotWordGroup group = hotWordGroupMapper.selectById(groupId);
if (group == null || !tenantId.equals(group.getTenantId())) {
throw new IllegalArgumentException("热词组不存在");
}
if (!Integer.valueOf(1).equals(group.getStatus())) {
throw new IllegalArgumentException("热词组已禁用");
}
long currentCount = this.count(new LambdaQueryWrapper<HotWord>()
.eq(HotWord::getHotWordGroupId, groupId)
.ne(currentHotWordId != null, HotWord::getId, currentHotWordId));
if (currentCount >= MAX_HOT_WORDS_PER_GROUP) {
throw new IllegalArgumentException("热词组最多只能包含 200 个热词");
}
return group.getId();
}
private void generateCombinations(List<List<String>> matrix, int index, String current, List<String> result) {
if (index == matrix.size()) {
result.add(current.trim());
@ -106,9 +155,10 @@ public class HotWordServiceImpl extends ServiceImpl<HotWordMapper, HotWord> impl
entity.setPinyinList(dto.getPinyinList());
entity.setMatchStrategy(dto.getMatchStrategy());
entity.setCategory(dto.getCategory());
entity.setHotWordGroupId(dto.getHotWordGroupId());
entity.setWeight(dto.getWeight());
entity.setStatus(dto.getStatus());
entity.setIsPublic(dto.getIsPublic());
entity.setIsPublic(1);
entity.setRemark(dto.getRemark());
}
@ -119,14 +169,19 @@ public class HotWordServiceImpl extends ServiceImpl<HotWordMapper, HotWord> impl
vo.setPinyinList(entity.getPinyinList());
vo.setMatchStrategy(entity.getMatchStrategy());
vo.setCategory(entity.getCategory());
vo.setHotWordGroupId(entity.getHotWordGroupId());
vo.setWeight(entity.getWeight());
vo.setStatus(entity.getStatus());
vo.setIsPublic(entity.getIsPublic());
vo.setIsPublic(1);
vo.setCreatorId(entity.getCreatorId());
vo.setIsSynced(entity.getIsSynced());
vo.setRemark(entity.getRemark());
vo.setCreatedAt(entity.getCreatedAt());
vo.setUpdatedAt(entity.getUpdatedAt());
if (entity.getHotWordGroupId() != null) {
HotWordGroup group = hotWordGroupMapper.selectById(entity.getHotWordGroupId());
vo.setHotWordGroupName(group == null ? null : group.getGroupName());
}
return vo;
}
}

View File

@ -77,16 +77,18 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
asrConfig.put("useSpkId", runtimeProfile.getResolvedUseSpkId());
asrConfig.put("enableTextRefine", runtimeProfile.getResolvedEnableTextRefine());
List<String> finalHotWords = command.getHotWords();
List<String> finalHotWords = runtimeProfile.getResolvedHotWords();
if (finalHotWords == null || finalHotWords.isEmpty()) {
finalHotWords = hotWordService.list(new LambdaQueryWrapper<HotWord>()
.eq(HotWord::getTenantId, meeting.getTenantId())
.eq(HotWord::getStatus, 1))
.stream()
.map(HotWord::getWord)
.collect(Collectors.toList());
}
asrConfig.put("hotWords", finalHotWords);
if (runtimeProfile.getResolvedHotWordGroupId() != null) {
asrConfig.put("hotWordGroupId", runtimeProfile.getResolvedHotWordGroupId());
}
asrTask.setTaskConfig(asrConfig);
aiTaskService.save(asrTask);
@ -579,7 +581,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
resumeConfig.setEnableItn(runtimeProfile.getResolvedEnableItn());
resumeConfig.setEnableTextRefine(runtimeProfile.getResolvedEnableTextRefine());
resumeConfig.setSaveAudio(runtimeProfile.getResolvedSaveAudio());
resumeConfig.setHotwords(resolveRealtimeHotwords(runtimeProfile.getResolvedHotWords(), tenantId));
resumeConfig.setHotwords(resolveRealtimeHotwords(runtimeProfile.getResolvedHotWords(), runtimeProfile.getResolvedHotWordGroupId()));
return resumeConfig;
}
@ -617,14 +619,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
);
}
private List<Map<String, Object>> resolveRealtimeHotwords(List<String> selectedWords, Long tenantId) {
List<HotWord> tenantHotwords = hotWordService.list(new LambdaQueryWrapper<HotWord>()
.eq(HotWord::getTenantId, tenantId)
.eq(HotWord::getStatus, 1));
if (tenantHotwords == null || tenantHotwords.isEmpty()) {
return List.of();
}
private List<Map<String, Object>> resolveRealtimeHotwords(List<String> selectedWords, Long hotWordGroupId) {
List<String> effectiveWords = selectedWords == null
? List.of()
: selectedWords.stream()
@ -632,9 +627,15 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
.map(String::trim)
.filter(word -> !word.isEmpty())
.toList();
return tenantHotwords.stream()
.filter(item -> effectiveWords.isEmpty() || effectiveWords.contains(item.getWord()))
List<HotWord> resolvedHotwords = hotWordGroupId == null
? hotWordService.list(new LambdaQueryWrapper<HotWord>()
.eq(HotWord::getStatus, 1)
.in(!effectiveWords.isEmpty(), HotWord::getWord, effectiveWords))
: hotWordService.listEnabledByGroupIdAndWordsIgnoreTenant(hotWordGroupId, effectiveWords);
if (resolvedHotwords == null || resolvedHotwords.isEmpty()) {
return List.of();
}
return resolvedHotwords.stream()
.map(this::toRealtimeHotword)
.collect(Collectors.toList());
}

View File

@ -3,12 +3,14 @@ package com.imeeting.service.biz.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.imeeting.dto.biz.AiModelVO;
import com.imeeting.dto.biz.RealtimeMeetingRuntimeProfile;
import com.imeeting.entity.biz.HotWord;
import com.imeeting.entity.biz.AsrModel;
import com.imeeting.entity.biz.LlmModel;
import com.imeeting.entity.biz.PromptTemplate;
import com.imeeting.mapper.biz.AsrModelMapper;
import com.imeeting.mapper.biz.LlmModelMapper;
import com.imeeting.service.biz.AiModelService;
import com.imeeting.service.biz.HotWordService;
import com.imeeting.service.biz.MeetingRuntimeProfileResolver;
import com.imeeting.service.biz.PromptTemplateService;
import lombok.RequiredArgsConstructor;
@ -23,6 +25,7 @@ public class MeetingRuntimeProfileResolverImpl implements MeetingRuntimeProfileR
private final AiModelService aiModelService;
private final PromptTemplateService promptTemplateService;
private final HotWordService hotWordService;
private final AsrModelMapper asrModelMapper;
private final LlmModelMapper llmModelMapper;
@ -51,6 +54,7 @@ public class MeetingRuntimeProfileResolverImpl implements MeetingRuntimeProfileR
profile.setResolvedSummaryModelName(summaryModel.getModelName());
profile.setResolvedPromptId(promptTemplate.getId());
profile.setResolvedPromptName(promptTemplate.getTemplateName());
profile.setResolvedHotWordGroupId(resolveHotWordGroupId(promptTemplate, hotWords));
profile.setResolvedMode(nonBlank(mode, "2pass"));
profile.setResolvedLanguage(nonBlank(language, "auto"));
profile.setResolvedUseSpkId(useSpkId != null ? useSpkId : 1);
@ -58,13 +62,43 @@ public class MeetingRuntimeProfileResolverImpl implements MeetingRuntimeProfileR
profile.setResolvedEnableItn(enableItn != null ? enableItn : Boolean.TRUE);
profile.setResolvedEnableTextRefine(Boolean.TRUE.equals(enableTextRefine));
profile.setResolvedSaveAudio(Boolean.TRUE.equals(saveAudio));
profile.setResolvedHotWords(hotWords == null ? List.of() : hotWords.stream()
profile.setResolvedHotWords(resolveHotWords(promptTemplate, hotWords));
return profile;
}
private Long resolveHotWordGroupId(PromptTemplate promptTemplate, List<String> hotWords) {
List<String> normalized = normalizeHotWords(hotWords);
if (!normalized.isEmpty()) {
return null;
}
return promptTemplate == null ? null : promptTemplate.getHotWordGroupId();
}
private List<String> resolveHotWords(PromptTemplate promptTemplate, List<String> hotWords) {
List<String> normalized = normalizeHotWords(hotWords);
if (!normalized.isEmpty()) {
return normalized;
}
if (promptTemplate == null || promptTemplate.getHotWordGroupId() == null) {
return List.of();
}
return hotWordService.listEnabledByGroupIdIgnoreTenant(promptTemplate.getHotWordGroupId())
.stream()
.map(HotWord::getWord)
.filter(Objects::nonNull)
.map(String::trim)
.filter(item -> !item.isEmpty())
.distinct()
.toList());
return profile;
.toList();
}
private List<String> normalizeHotWords(List<String> hotWords) {
return hotWords == null ? List.of() : hotWords.stream()
.filter(Objects::nonNull)
.map(String::trim)
.filter(item -> !item.isEmpty())
.distinct()
.toList();
}
private AiModelVO resolveModel(String type, Long requestedId, Long tenantId) {

View File

@ -3,14 +3,18 @@ package com.imeeting.service.biz.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.unisbase.dto.PageResult;
import com.imeeting.dto.biz.PromptTemplateDTO;
import com.imeeting.dto.biz.PromptTemplateVO;
import com.imeeting.entity.biz.HotWord;
import com.imeeting.entity.biz.HotWordGroup;
import com.imeeting.entity.biz.PromptTemplate;
import com.imeeting.entity.biz.PromptTemplateUserConfig;
import com.imeeting.mapper.biz.HotWordGroupMapper;
import com.imeeting.mapper.biz.PromptTemplateMapper;
import com.imeeting.mapper.biz.PromptTemplateUserConfigMapper;
import com.imeeting.service.biz.HotWordService;
import com.imeeting.service.biz.PromptTemplateService;
import com.unisbase.dto.PageResult;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@ -18,6 +22,7 @@ import org.springframework.transaction.annotation.Transactional;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
@Service
@ -25,44 +30,44 @@ import java.util.stream.Collectors;
public class PromptTemplateServiceImpl extends ServiceImpl<PromptTemplateMapper, PromptTemplate> implements PromptTemplateService {
private final PromptTemplateUserConfigMapper userConfigMapper;
private final HotWordGroupMapper hotWordGroupMapper;
private final HotWordService hotWordService;
@Override
@Transactional(rollbackFor = Exception.class)
public PromptTemplateVO saveTemplate(PromptTemplateDTO dto, Long userId, Long tenantId) {
PromptTemplate entity = new PromptTemplate();
copyProperties(dto, entity);
entity.setCreatorId(userId);
if (dto.getTenantId() != null && dto.getTenantId() == 0L) {
entity.setTenantId(0L);
} else {
entity.setTenantId(tenantId);
}
validateHotWordGroupBinding(dto.getHotWordGroupId(), entity.getTenantId());
entity.setUsageCount(0);
this.save(entity);
return toVO(entity, entity.getStatus());
return toVO(entity, entity.getStatus(), queryHotWordGroupMap(java.util.Collections.singletonList(entity.getHotWordGroupId())));
}
@Override
@Transactional(rollbackFor = Exception.class)
public PromptTemplateVO updateTemplate(PromptTemplateDTO dto) {
public PromptTemplateVO updateTemplate(PromptTemplateDTO dto, Long userId, Long tenantId) {
PromptTemplate entity = this.getById(dto.getId());
if (entity == null) {
throw new RuntimeException("Template not found");
throw new IllegalArgumentException("模板不存在");
}
Long targetTenantId = Long.valueOf(0L).equals(entity.getTenantId()) ? 0L : tenantId;
validateHotWordGroupBinding(dto.getHotWordGroupId(), targetTenantId);
copyProperties(dto, entity);
this.updateById(entity);
return toVO(entity, entity.getStatus());
return toVO(entity, entity.getStatus(), queryHotWordGroupMap(java.util.Collections.singletonList(entity.getHotWordGroupId())));
}
@Override
public PageResult<List<PromptTemplateVO>> pageTemplates(Integer current, Integer size, String name, String category,
Long tenantId, Long userId, Boolean isPlatformAdmin, Boolean isTenantAdmin) {
LambdaQueryWrapper<PromptTemplate> wrapper = buildVisibilityWrapper(tenantId, userId, isPlatformAdmin, isTenantAdmin);
wrapper.like(name != null && !name.isEmpty(), PromptTemplate::getTemplateName, name)
.eq(category != null && !category.isEmpty(), PromptTemplate::getCategory, category)
.orderByDesc(PromptTemplate::getIsSystem)
@ -71,8 +76,10 @@ public class PromptTemplateServiceImpl extends ServiceImpl<PromptTemplateMapper,
Page<PromptTemplate> page = this.page(new Page<>(current, size), wrapper);
List<PromptTemplate> records = page.getRecords();
Map<Long, Integer> userStatusMap = queryUserStatusMap(tenantId, userId, records.stream().map(PromptTemplate::getId).collect(Collectors.toList()));
Map<Long, HotWordGroup> hotWordGroupMap = queryHotWordGroupMap(records.stream().map(PromptTemplate::getHotWordGroupId).toList());
List<PromptTemplateVO> vos = records.stream()
.map(template -> toVO(template, effectiveStatus(template.getStatus(), userStatusMap.get(template.getId()))))
.map(template -> toVO(template, effectiveStatus(template.getStatus(), userStatusMap.get(template.getId())), hotWordGroupMap))
.collect(Collectors.toList());
PageResult<List<PromptTemplateVO>> result = new PageResult<>();
@ -81,6 +88,20 @@ public class PromptTemplateServiceImpl extends ServiceImpl<PromptTemplateMapper,
return result;
}
@Override
public PromptTemplateVO getTemplateDetail(Long templateId, Long tenantId, Long userId, Boolean isPlatformAdmin, Boolean isTenantAdmin) {
PromptTemplate template = this.getOne(buildVisibilityWrapper(tenantId, userId, isPlatformAdmin, isTenantAdmin)
.eq(PromptTemplate::getId, templateId)
.last("LIMIT 1"));
if (template == null) {
throw new IllegalArgumentException("模板不存在");
}
Map<Long, HotWordGroup> hotWordGroupMap = queryHotWordGroupMap(List.of(template.getHotWordGroupId()));
PromptTemplateVO vo = toVO(template, template.getStatus(), hotWordGroupMap);
vo.setHotWords(resolveHotWords(template.getHotWordGroupId()));
return vo;
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean updateUserTemplateStatus(Long templateId, Integer status, Long tenantId, Long userId, Boolean isPlatformAdmin, Boolean isTenantAdmin) {
@ -128,6 +149,29 @@ public class PromptTemplateServiceImpl extends ServiceImpl<PromptTemplateMapper,
return effectiveStatus(template.getStatus(), userStatus) == 1;
}
private void validateHotWordGroupBinding(Long hotWordGroupId, Long templateTenantId) {
if (hotWordGroupId == null) {
return;
}
if (Long.valueOf(0L).equals(templateTenantId)) {
HotWordGroup group = hotWordGroupMapper.selectById(hotWordGroupId);
if (group == null || !Long.valueOf(0L).equals(group.getTenantId())) {
throw new IllegalArgumentException("平台级模板只能绑定平台级热词组");
}
if (!Integer.valueOf(1).equals(group.getStatus())) {
throw new IllegalArgumentException("绑定的热词组已禁用");
}
return;
}
HotWordGroup group = hotWordGroupMapper.selectById(hotWordGroupId);
if (group == null || !templateTenantId.equals(group.getTenantId())) {
throw new IllegalArgumentException("绑定的热词组不存在");
}
if (!Integer.valueOf(1).equals(group.getStatus())) {
throw new IllegalArgumentException("绑定的热词组已禁用");
}
}
private LambdaQueryWrapper<PromptTemplate> buildVisibilityWrapper(Long tenantId, Long userId, Boolean isPlatformAdmin, Boolean isTenantAdmin) {
LambdaQueryWrapper<PromptTemplate> wrapper = new LambdaQueryWrapper<>();
wrapper.and(w -> w
@ -154,6 +198,30 @@ public class PromptTemplateServiceImpl extends ServiceImpl<PromptTemplateMapper,
return statusMap;
}
private Map<Long, HotWordGroup> queryHotWordGroupMap(List<Long> hotWordGroupIds) {
List<Long> ids = hotWordGroupIds == null ? List.of() : hotWordGroupIds.stream()
.filter(Objects::nonNull)
.distinct()
.toList();
if (ids.isEmpty()) {
return Map.of();
}
return hotWordGroupMapper.selectByIdsIgnoreTenant(ids).stream()
.collect(Collectors.toMap(HotWordGroup::getId, item -> item));
}
private List<String> resolveHotWords(Long hotWordGroupId) {
if (hotWordGroupId == null) {
return List.of();
}
return hotWordService.listEnabledByGroupIdIgnoreTenant(hotWordGroupId).stream()
.map(HotWord::getWord)
.filter(Objects::nonNull)
.map(String::trim)
.filter(item -> !item.isEmpty())
.toList();
}
private Integer effectiveStatus(Integer templateStatus, Integer userStatus) {
if (userStatus != null) {
return userStatus;
@ -167,13 +235,14 @@ public class PromptTemplateServiceImpl extends ServiceImpl<PromptTemplateMapper,
entity.setCategory(dto.getCategory());
entity.setIsSystem(dto.getIsSystem());
entity.setTenantId(dto.getTenantId());
entity.setPromptContent(dto.getPromptContent());
entity.setTags(dto.getTags());
entity.setHotWordGroupId(dto.getHotWordGroupId());
entity.setPromptContent(dto.getPromptContent());
entity.setStatus(dto.getStatus());
entity.setRemark(dto.getRemark());
}
private PromptTemplateVO toVO(PromptTemplate entity, Integer status) {
private PromptTemplateVO toVO(PromptTemplate entity, Integer status, Map<Long, HotWordGroup> hotWordGroupMap) {
PromptTemplateVO vo = new PromptTemplateVO();
vo.setId(entity.getId());
vo.setTenantId(entity.getTenantId());
@ -183,6 +252,10 @@ public class PromptTemplateServiceImpl extends ServiceImpl<PromptTemplateMapper,
vo.setCategory(entity.getCategory());
vo.setIsSystem(entity.getIsSystem());
vo.setTags(entity.getTags());
Long hotWordGroupId = entity.getHotWordGroupId();
vo.setHotWordGroupId(hotWordGroupId);
HotWordGroup group = hotWordGroupId == null ? null : hotWordGroupMap.get(hotWordGroupId);
vo.setHotWordGroupName(group == null ? null : group.getGroupName());
vo.setUsageCount(entity.getUsageCount());
vo.setPromptContent(entity.getPromptContent());
vo.setStatus(status);

View File

@ -72,7 +72,13 @@ public class RealtimeMeetingSocketSessionServiceImpl implements RealtimeMeetingS
resumeConfig.setEnableItn(enableItn);
resumeConfig.setEnableTextRefine(enableTextRefine);
resumeConfig.setSaveAudio(saveAudio);
resumeConfig.setHotwords(hotwords);
RealtimeMeetingResumeConfig existingConfig = realtimeMeetingSessionStateService.getStatus(meetingId) == null
? null
: realtimeMeetingSessionStateService.getStatus(meetingId).getResumeConfig();
List<Map<String, Object>> effectiveHotwords = (hotwords == null || hotwords.isEmpty())
? (existingConfig == null ? List.of() : existingConfig.getHotwords())
: hotwords;
resumeConfig.setHotwords(effectiveHotwords);
realtimeMeetingSessionStateService.rememberResumeConfig(meetingId, resumeConfig);
RealtimeSocketSessionData sessionData = new RealtimeSocketSessionData();
@ -107,7 +113,7 @@ public class RealtimeMeetingSocketSessionServiceImpl implements RealtimeMeetingS
enableItn,
enableTextRefine,
saveAudio,
hotwords
effectiveHotwords
));
return vo;
}

View File

@ -1,11 +1,14 @@
package com.imeeting.service.biz.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.imeeting.dto.biz.AiModelVO;
import com.imeeting.dto.biz.RealtimeMeetingRuntimeProfile;
import com.imeeting.entity.biz.HotWord;
import com.imeeting.entity.biz.PromptTemplate;
import com.imeeting.mapper.biz.AsrModelMapper;
import com.imeeting.mapper.biz.LlmModelMapper;
import com.imeeting.service.biz.AiModelService;
import com.imeeting.service.biz.HotWordService;
import com.imeeting.service.biz.PromptTemplateService;
import org.junit.jupiter.api.Test;
@ -24,9 +27,11 @@ class MeetingRuntimeProfileResolverImplTest {
void resolveShouldUseRequestedResourcesAndNormalizeHotWords() {
AiModelService aiModelService = mock(AiModelService.class);
PromptTemplateService promptTemplateService = mock(PromptTemplateService.class);
HotWordService hotWordService = mock(HotWordService.class);
MeetingRuntimeProfileResolverImpl resolver = new MeetingRuntimeProfileResolverImpl(
aiModelService,
promptTemplateService,
hotWordService,
mock(AsrModelMapper.class),
mock(LlmModelMapper.class)
);
@ -70,9 +75,11 @@ class MeetingRuntimeProfileResolverImplTest {
void resolveShouldRejectCrossTenantModel() {
AiModelService aiModelService = mock(AiModelService.class);
PromptTemplateService promptTemplateService = mock(PromptTemplateService.class);
HotWordService hotWordService = mock(HotWordService.class);
MeetingRuntimeProfileResolverImpl resolver = new MeetingRuntimeProfileResolverImpl(
aiModelService,
promptTemplateService,
hotWordService,
mock(AsrModelMapper.class),
mock(LlmModelMapper.class)
);
@ -95,6 +102,50 @@ class MeetingRuntimeProfileResolverImplTest {
));
}
@Test
void resolveShouldUseTemplateBoundGroupWhenNoExplicitHotWords() {
AiModelService aiModelService = mock(AiModelService.class);
PromptTemplateService promptTemplateService = mock(PromptTemplateService.class);
HotWordService hotWordService = mock(HotWordService.class);
MeetingRuntimeProfileResolverImpl resolver = new MeetingRuntimeProfileResolverImpl(
aiModelService,
promptTemplateService,
hotWordService,
mock(AsrModelMapper.class),
mock(LlmModelMapper.class)
);
when(aiModelService.getModelById(11L, "ASR")).thenReturn(enabledModel(11L, 1L, "ASR-Model"));
when(aiModelService.getModelById(22L, "LLM")).thenReturn(enabledModel(22L, 1L, "LLM-Model"));
PromptTemplate template = enabledPrompt(33L, 0L, "Platform Prompt");
template.setHotWordGroupId(99L);
when(promptTemplateService.getById(33L)).thenReturn(template);
HotWord hotWord1 = new HotWord();
hotWord1.setWord("OpenAI");
HotWord hotWord2 = new HotWord();
hotWord2.setWord("Codex");
when(hotWordService.listEnabledByGroupIdIgnoreTenant(99L)).thenReturn(List.of(hotWord1, hotWord2));
RealtimeMeetingRuntimeProfile profile = resolver.resolve(
1L,
11L,
22L,
33L,
null,
null,
null,
null,
null,
Boolean.FALSE,
Boolean.FALSE,
null
);
assertEquals(99L, profile.getResolvedHotWordGroupId());
assertIterableEquals(List.of("OpenAI", "Codex"), profile.getResolvedHotWords());
}
private AiModelVO enabledModel(Long id, Long tenantId, String name) {
AiModelVO model = new AiModelVO();
model.setId(id);

View File

@ -4,10 +4,11 @@ export interface HotWordVO {
id: number;
word: string;
pinyinList: string[];
isPublic: number;
creatorId: number;
matchStrategy: number;
category: string;
hotWordGroupId?: number;
hotWordGroupName?: string;
weight: number;
status: number;
isSynced: number;
@ -18,13 +19,14 @@ export interface HotWordVO {
export interface HotWordDTO {
id?: number;
tenantId?: number;
word: string;
pinyinList?: string[];
matchStrategy: number;
category?: string;
hotWordGroupId?: number;
weight: number;
status: number;
isPublic?: number;
remark?: string;
}
@ -33,7 +35,7 @@ export const getHotWordPage = (params: {
size: number;
word?: string;
category?: string;
isPublic?: number;
tenantId?: number;
}) => {
return http.get<{ code: string; data: { records: HotWordVO[]; total: number }; msg: string }>(
"/api/biz/hotword/page",

View File

@ -9,6 +9,9 @@ export interface PromptTemplateVO {
category: string;
isSystem: number;
tags?: string[];
hotWordGroupId?: number;
hotWordGroupName?: string;
hotWords?: string[];
usageCount: number;
promptContent: string;
status: number;
@ -24,6 +27,7 @@ export interface PromptTemplateDTO {
category: string;
isSystem: number;
tags?: string[];
hotWordGroupId?: number;
promptContent: string;
status: number;
remark?: string;
@ -41,6 +45,12 @@ export const getPromptPage = (params: {
);
};
export const getPromptDetail = (id: number) => {
return http.get<{ code: string; data: PromptTemplateVO; msg: string }>(
`/api/biz/prompt/${id}`
);
};
export const savePromptTemplate = (data: PromptTemplateDTO) => {
return http.post<{ code: string; data: PromptTemplateVO; msg: string }>(
"/api/biz/prompt",

View File

@ -1,12 +1,28 @@
import React, { useEffect, useMemo, useState } from "react";
import { Badge, Button, Card, Col, Form, Input, InputNumber, Modal, Popconfirm, Radio, Row, Select, Space, Table, Tag, Tooltip, Typography, App } from 'antd';
import {
App,
Badge,
Button,
Card,
Col,
Form,
Input,
InputNumber,
Modal,
Popconfirm,
Row,
Select,
Space,
Table,
Tag,
Typography,
} from "antd";
import {
DeleteOutlined,
EditOutlined,
GlobalOutlined,
FolderOpenOutlined,
PlusOutlined,
SearchOutlined,
UserOutlined,
} from "@ant-design/icons";
import { useTranslation } from "react-i18next";
import { useDict } from "../../hooks/useDict";
@ -18,6 +34,14 @@ import {
updateHotWord,
type HotWordVO,
} from "../../api/business/hotword";
import {
deleteHotWordGroup,
getHotWordGroupOptions,
getHotWordGroupPage,
saveHotWordGroup,
updateHotWordGroup,
type HotWordGroupVO,
} from "../../api/business/hotwordGroup";
import AppPagination from "../../components/shared/AppPagination";
const { Option } = Select;
@ -27,9 +51,15 @@ type HotWordFormValues = {
word: string;
pinyin?: string;
category?: string;
hotWordGroupId?: number;
weight: number;
status: number;
isPublic: number;
remark?: string;
};
type HotWordGroupFormValues = {
groupName: string;
status: number;
remark?: string;
};
@ -37,7 +67,15 @@ const HotWords: React.FC = () => {
const { message } = App.useApp();
const { t } = useTranslation();
const [form] = Form.useForm<HotWordFormValues>();
const [groupForm] = Form.useForm<HotWordGroupFormValues>();
const { items: categories } = useDict("biz_hotword_category");
const userProfile = useMemo(() => {
const profileStr = sessionStorage.getItem("userProfile");
return profileStr ? JSON.parse(profileStr) : {};
}, []);
const activeTenantId = useMemo(() => Number(localStorage.getItem("activeTenantId") || 0), []);
const isPlatformAdmin = userProfile.isPlatformAdmin === true;
const [loading, setLoading] = useState(false);
const [data, setData] = useState<HotWordVO[]>([]);
const [total, setTotal] = useState(0);
@ -45,23 +83,31 @@ const HotWords: React.FC = () => {
const [size, setSize] = useState(10);
const [searchWord, setSearchWord] = useState("");
const [searchCategory, setSearchCategory] = useState<string | undefined>(undefined);
const [searchType, setSearchType] = useState<number | undefined>(undefined);
const [modalVisible, setModalVisible] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [submitLoading, setSubmitLoading] = useState(false);
const userProfile = useMemo(() => {
const profileStr = sessionStorage.getItem("userProfile");
return profileStr ? JSON.parse(profileStr) : {};
}, []);
const [groupOptions, setGroupOptions] = useState<HotWordGroupVO[]>([]);
const [groupManageVisible, setGroupManageVisible] = useState(false);
const [groupEditorVisible, setGroupEditorVisible] = useState(false);
const [groupLoading, setGroupLoading] = useState(false);
const [groupSubmitLoading, setGroupSubmitLoading] = useState(false);
const [groupData, setGroupData] = useState<HotWordGroupVO[]>([]);
const [editingGroupId, setEditingGroupId] = useState<number | null>(null);
const isAdmin = useMemo(() => {
return userProfile.isPlatformAdmin === true || userProfile.isTenantAdmin === true;
}, [userProfile]);
const groupNameMap = useMemo(
() => Object.fromEntries(groupOptions.map((item) => [item.id, item.groupName])) as Record<number, string>,
[groupOptions]
);
useEffect(() => {
void fetchData();
}, [current, searchCategory, searchType, searchWord, size]);
}, [current, searchCategory, searchWord, size]);
useEffect(() => {
void loadGroupOptions();
}, []);
const fetchData = async () => {
setLoading(true);
@ -71,7 +117,7 @@ const HotWords: React.FC = () => {
size,
word: searchWord,
category: searchCategory,
isPublic: searchType,
tenantId: isPlatformAdmin ? activeTenantId : undefined,
});
if (res.data?.data) {
setData(res.data.data.records);
@ -82,53 +128,62 @@ const HotWords: React.FC = () => {
}
};
const handleOpenModal = (record?: HotWordVO) => {
if (record?.isPublic === 1 && !isAdmin) {
message.error("公开热词仅限管理员修改");
return;
}
const loadGroupOptions = async () => {
const res = await getHotWordGroupOptions(isPlatformAdmin ? activeTenantId : undefined);
setGroupOptions(res.data?.data || []);
};
const loadGroupPage = async () => {
setGroupLoading(true);
try {
const res = await getHotWordGroupPage({ current: 1, size: 200 });
if (isPlatformAdmin) {
const scoped = await getHotWordGroupPage({ current: 1, size: 200, tenantId: activeTenantId });
setGroupData(scoped.data?.data?.records || []);
return;
}
setGroupData(res.data?.data?.records || []);
} finally {
setGroupLoading(false);
}
};
const handleOpenModal = (record?: HotWordVO) => {
if (record) {
setEditingId(record.id);
form.setFieldsValue({
word: record.word,
pinyin: record.pinyinList?.[0] || "",
category: record.category,
hotWordGroupId: record.hotWordGroupId,
weight: record.weight,
status: record.status,
isPublic: record.isPublic,
remark: record.remark,
});
} else {
setEditingId(null);
form.resetFields();
form.setFieldsValue({ weight: 2, status: 1, isPublic: 0 });
form.setFieldsValue({ weight: 2, status: 1 });
}
setModalVisible(true);
};
const handleDelete = async (id: number) => {
try {
await deleteHotWord(id);
message.success("删除成功");
await fetchData();
} catch {
// handled by interceptor
}
await deleteHotWord(id);
message.success("删除成功");
await fetchData();
};
const handleSubmit = async () => {
try {
const values = await form.validateFields();
setSubmitLoading(true);
const payload = {
...values,
tenantId: isPlatformAdmin ? activeTenantId : undefined,
matchStrategy: 1,
pinyinList: values.pinyin ? [values.pinyin.trim()] : [],
};
if (editingId) {
await updateHotWord({ ...payload, id: editingId });
message.success("更新成功");
@ -136,9 +191,8 @@ const HotWords: React.FC = () => {
await saveHotWord(payload);
message.success("新增成功");
}
setModalVisible(false);
await fetchData();
await Promise.all([fetchData(), loadGroupOptions(), groupManageVisible ? loadGroupPage() : Promise.resolve()]);
} finally {
setSubmitLoading(false);
}
@ -149,7 +203,6 @@ const HotWords: React.FC = () => {
if (!word || form.getFieldValue("pinyin")) {
return;
}
try {
const res = await getPinyinSuggestion(word);
const firstPinyin = res.data?.data?.[0];
@ -161,45 +214,79 @@ const HotWords: React.FC = () => {
}
};
const openGroupManager = async () => {
setGroupManageVisible(true);
await loadGroupPage();
};
const openGroupEditor = (record?: HotWordGroupVO) => {
if (record) {
setEditingGroupId(record.id);
groupForm.setFieldsValue({
groupName: record.groupName,
status: record.status,
remark: record.remark,
});
} else {
setEditingGroupId(null);
groupForm.resetFields();
groupForm.setFieldsValue({ status: 1 });
}
setGroupEditorVisible(true);
};
const handleGroupSubmit = async () => {
try {
const values = await groupForm.validateFields();
setGroupSubmitLoading(true);
if (editingGroupId) {
await updateHotWordGroup({ ...values, id: editingGroupId, tenantId: isPlatformAdmin ? activeTenantId : undefined });
message.success("热词组更新成功");
} else {
await saveHotWordGroup({ ...values, tenantId: isPlatformAdmin ? activeTenantId : undefined });
message.success("热词组创建成功");
}
setGroupEditorVisible(false);
await Promise.all([loadGroupOptions(), loadGroupPage()]);
} finally {
setGroupSubmitLoading(false);
}
};
const handleDeleteGroup = async (id: number) => {
await deleteHotWordGroup(id, isPlatformAdmin ? activeTenantId : undefined);
message.success("热词组删除成功");
await Promise.all([loadGroupOptions(), loadGroupPage(), fetchData()]);
};
const columns = [
{
title: "热词原文",
dataIndex: "word",
key: "word",
ellipsis: true,
render: (text: string, record: HotWordVO) => (
<Space style={{ display: 'flex', width: '100%', overflow: 'hidden' }}>
<Text strong ellipsis={{ tooltip: text }} style={{ maxWidth: 200, display: 'block' }}>{text}</Text>
{record.isPublic === 1 ? (
<Tooltip title="租户公开">
<GlobalOutlined style={{ color: "#52c41a", flexShrink: 0 }} />
</Tooltip>
) : (
<Tooltip title="个人私有">
<UserOutlined style={{ color: "#1890ff", flexShrink: 0 }} />
</Tooltip>
)}
</Space>
),
render: (text: string) => <Text strong ellipsis={{ tooltip: text }}>{text}</Text>,
},
{
title: "拼音",
dataIndex: "pinyinList",
key: "pinyinList",
render: (list: string[]) =>
list?.[0] ? <Tag style={{ borderRadius: 4 }}>{list[0]}</Tag> : <Text type="secondary">-</Text>,
render: (list: string[]) => list?.[0] ? <Tag style={{ borderRadius: 4 }}>{list[0]}</Tag> : <Text type="secondary">-</Text>,
},
{
title: "类别",
title: "分类",
dataIndex: "category",
key: "category",
render: (value: string) => categories.find((item) => item.itemValue === value)?.itemLabel || value || "-",
},
{
title: "范围",
dataIndex: "isPublic",
key: "isPublic",
render: (value: number) => (value === 1 ? <Tag color="green"></Tag> : <Tag color="blue"></Tag>),
title: "热词组",
dataIndex: "hotWordGroupId",
key: "hotWordGroupId",
render: (value?: number, record?: HotWordVO) => {
const name = record?.hotWordGroupName || (value ? groupNameMap[value] : undefined);
return name ? <Tag color="blue">{name}</Tag> : <Text type="secondary"></Text>;
},
},
{
title: "权重",
@ -211,38 +298,77 @@ const HotWords: React.FC = () => {
title: "状态",
dataIndex: "status",
key: "status",
render: (value: number) =>
value === 1 ? <Badge status="success" text="启用" /> : <Badge status="default" text="禁用" />,
render: (value: number) => value === 1 ? <Badge status="success" text="启用" /> : <Badge status="default" text="禁用" />,
},
{
title: "操作",
key: "action",
render: (_: unknown, record: HotWordVO) => {
const isMine = record.creatorId === userProfile.userId;
const canEdit = record.isPublic === 1 ? isAdmin : isMine || isAdmin;
if (!canEdit) {
return <Text type="secondary"></Text>;
}
return (
<Space size="middle">
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => handleOpenModal(record)}>
render: (_: unknown, record: HotWordVO) => (
<Space size="middle">
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => handleOpenModal(record)}>
</Button>
<Popconfirm
title="确定删除这条热词吗?"
onConfirm={() => handleDelete(record.id)}
okText={t("common.confirm")}
cancelText={t("common.cancel")}
>
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
</Button>
<Popconfirm
title="确定删除这条热词吗?"
onConfirm={() => handleDelete(record.id)}
okText={t("common.confirm")}
cancelText={t("common.cancel")}
>
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</Space>
);
},
</Popconfirm>
</Space>
),
},
];
const groupColumns = [
{
title: "热词组名称",
dataIndex: "groupName",
key: "groupName",
render: (value: string) => <Text strong>{value}</Text>,
},
{
title: "热词数量",
dataIndex: "hotWordCount",
key: "hotWordCount",
render: (value: number) => <Tag color={value >= 200 ? "red" : "processing"}>{value}/200</Tag>,
},
{
title: "状态",
dataIndex: "status",
key: "status",
render: (value: number) => value === 1 ? <Badge status="success" text="启用" /> : <Badge status="default" text="禁用" />,
},
{
title: "备注",
dataIndex: "remark",
key: "remark",
render: (value?: string) => value || "-",
},
{
title: "操作",
key: "action",
render: (_: unknown, record: HotWordGroupVO) => (
<Space>
<Button type="link" size="small" onClick={() => openGroupEditor(record)}>
</Button>
<Popconfirm
title="确定删除这个热词组吗?"
description="删除前必须先解除模板引用并清空组内热词。"
onConfirm={() => handleDeleteGroup(record.id)}
okText={t("common.confirm")}
cancelText={t("common.cancel")}
>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
</Space>
),
},
];
@ -256,19 +382,7 @@ const HotWords: React.FC = () => {
extra={
<Space wrap>
<Select
placeholder="热词类型"
style={{ width: 110 }}
allowClear
onChange={(value) => {
setSearchType(value);
setCurrent(1);
}}
>
<Option value={1}></Option>
<Option value={0}></Option>
</Select>
<Select
placeholder="按类别筛选"
placeholder="按分类筛选"
style={{ width: 150 }}
allowClear
onChange={(value) => {
@ -292,6 +406,9 @@ const HotWords: React.FC = () => {
}}
style={{ width: 200 }}
/>
<Button icon={<FolderOpenOutlined />} onClick={() => void openGroupManager()}>
</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={() => handleOpenModal()}>
</Button>
@ -299,14 +416,7 @@ const HotWords: React.FC = () => {
}
>
<div className="app-page__table-wrap" style={{ flex: 1, minHeight: 0, overflow: "auto", padding: "24px 24px 0" }}>
<Table
columns={columns}
dataSource={data}
rowKey="id"
loading={loading}
scroll={{ x: "max-content" }}
pagination={false}
/>
<Table columns={columns} dataSource={data} rowKey="id" loading={loading} scroll={{ x: "max-content" }} pagination={false} />
</div>
<AppPagination
current={current}
@ -322,29 +432,17 @@ const HotWords: React.FC = () => {
<Modal
title={editingId ? "编辑热词" : "新增热词"}
open={modalVisible}
onOk={handleSubmit}
onOk={() => void handleSubmit()}
onCancel={() => setModalVisible(false)}
confirmLoading={submitLoading}
width={560}
destroyOnHidden
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item
name="word"
label="热词原文"
rules={[{ required: true, message: "请输入热词原文" }]}
>
<Form.Item name="word" label="热词原文" rules={[{ required: true, message: "请输入热词原文" }]}>
<Input placeholder="输入识别关键词" onBlur={handleWordBlur} />
</Form.Item>
{/*<Form.Item*/}
{/* name="pinyin"*/}
{/* label="拼音"*/}
{/* tooltip="仅保留一个拼音值,失焦后会自动带出推荐结果"*/}
{/*>*/}
{/* <Input placeholder="例如hui yi" />*/}
{/*</Form.Item>*/}
<Row gutter={16}>
<Col span={12}>
<Form.Item name="category" label="热词分类">
@ -357,6 +455,14 @@ const HotWords: React.FC = () => {
</Select>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="hotWordGroupId" label="所属热词组">
<Select placeholder="请选择热词组" allowClear options={groupOptions.map((item) => ({ label: `${item.groupName} (${item.hotWordCount}/200)`, value: item.id }))} />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="weight"
@ -366,9 +472,6 @@ const HotWords: React.FC = () => {
<InputNumber min={1} max={5} precision={1} step={0.1} style={{ width: "100%" }} />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="status" label="使用状态">
<Select>
@ -377,16 +480,6 @@ const HotWords: React.FC = () => {
</Select>
</Form.Item>
</Col>
{isAdmin ? (
<Col span={12}>
<Form.Item name="isPublic" label="租户公开" tooltip="开启后,租户内成员都可以共享这条热词">
<Radio.Group>
<Radio value={1}></Radio>
<Radio value={0}></Radio>
</Radio.Group>
</Form.Item>
</Col>
) : null}
</Row>
<Form.Item name="remark" label="备注">
@ -394,6 +487,46 @@ const HotWords: React.FC = () => {
</Form.Item>
</Form>
</Modal>
<Modal
title="热词组管理"
open={groupManageVisible}
onCancel={() => setGroupManageVisible(false)}
footer={null}
width={900}
destroyOnHidden
>
<div style={{ marginBottom: 16, display: "flex", justifyContent: "flex-end" }}>
<Button type="primary" icon={<PlusOutlined />} onClick={() => openGroupEditor()}>
</Button>
</div>
<Table rowKey="id" columns={groupColumns} dataSource={groupData} loading={groupLoading} pagination={false} />
</Modal>
<Modal
title={editingGroupId ? "编辑热词组" : "新增热词组"}
open={groupEditorVisible}
onCancel={() => setGroupEditorVisible(false)}
onOk={() => void handleGroupSubmit()}
confirmLoading={groupSubmitLoading}
destroyOnHidden
>
<Form form={groupForm} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="groupName" label="热词组名称" rules={[{ required: true, message: "请输入热词组名称" }]}>
<Input placeholder="例如:项目术语、客户名单" maxLength={100} />
</Form.Item>
<Form.Item name="status" label="状态">
<Select>
<Option value={1}></Option>
<Option value={0}></Option>
</Select>
</Form.Item>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={3} placeholder="说明这个热词组的适用范围" />
</Form.Item>
</Form>
</Modal>
</div>
);
};

View File

@ -984,7 +984,6 @@ const MeetingDetail: React.FC = () => {
category: '',
weight: 2,
status: 1,
isPublic: 0,
remark: meeting ? `来源于会议:${meeting.title}` : '来源于会议关键词',
}),
),

View File

@ -1,21 +1,41 @@
import React, { useState, useEffect } from 'react';
import { Card, Button, Input, Space, Drawer, Form, Select, Tag, Popconfirm, Typography, Divider, Tooltip, Row, Col, List, Empty, Skeleton, Switch, Modal, App } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, CopyOutlined, SearchOutlined, SaveOutlined, StarFilled } from '@ant-design/icons';
import React, { useEffect, useState } from 'react';
import {
App,
Button,
Card,
Col,
Divider,
Drawer,
Empty,
Form,
Input,
Modal,
Popconfirm,
Row,
Select,
Skeleton,
Space,
Switch,
Tag,
Tooltip,
Typography,
} from 'antd';
import { CopyOutlined, DeleteOutlined, EditOutlined, PlusOutlined, SaveOutlined, StarFilled } from '@ant-design/icons';
import ReactMarkdown from 'react-markdown';
import { useTranslation } from 'react-i18next';
import { useDict } from '../../hooks/useDict';
import {
deletePromptTemplate,
getPromptDetail,
getPromptPage,
savePromptTemplate,
updatePromptTemplate,
deletePromptTemplate,
updatePromptStatus,
PromptTemplateVO,
PromptTemplateDTO
updatePromptTemplate,
type PromptTemplateVO,
} from '../../api/business/prompt';
import { getHotWordGroupOptions, type HotWordGroupVO } from '../../api/business/hotwordGroup';
import AppPagination from '../../components/shared/AppPagination';
import { useTranslation } from 'react-i18next';
const { Option } = Select;
const { Text, Title } = Typography;
@ -27,6 +47,7 @@ const PromptTemplates: React.FC = () => {
const { items: categories, loading: dictLoading } = useDict('biz_prompt_category');
const { items: dictTags } = useDict('biz_prompt_tag');
const { items: promptLevels } = useDict('biz_prompt_level');
const templateLevel = Form.useWatch('isSystem', form);
const [loading, setLoading] = useState(false);
const [data, setData] = useState<PromptTemplateVO[]>([]);
@ -38,6 +59,7 @@ const PromptTemplates: React.FC = () => {
const [editingId, setEditingId] = useState<number | null>(null);
const [submitLoading, setSubmitLoading] = useState(false);
const [previewContent, setPreviewContent] = useState('');
const [groupOptions, setGroupOptions] = useState<HotWordGroupVO[]>([]);
const userProfile = React.useMemo(() => {
const profileStr = sessionStorage.getItem("userProfile");
@ -45,14 +67,23 @@ const PromptTemplates: React.FC = () => {
}, []);
const activeTenantId = React.useMemo(() => Number(localStorage.getItem("activeTenantId") || 0), []);
const isPlatformAdmin = userProfile.isPlatformAdmin === true;
const isTenantAdmin = userProfile.isTenantAdmin === true;
useEffect(() => {
fetchData();
void fetchData();
}, [current, pageSize]);
useEffect(() => {
void loadGroupOptions();
}, [isPlatformAdmin, templateLevel, activeTenantId]);
const loadGroupOptions = async () => {
const targetTenantId = isPlatformAdmin && Number(templateLevel) === 1 ? 0 : undefined;
const res = await getHotWordGroupOptions(targetTenantId ?? (isPlatformAdmin && activeTenantId === 0 ? 0 : undefined));
setGroupOptions(res.data?.data || []);
};
const fetchData = async () => {
const values = searchForm.getFieldsValue();
setLoading(true);
@ -61,27 +92,21 @@ const PromptTemplates: React.FC = () => {
current,
size: pageSize,
name: values.name,
category: values.category
category: values.category,
});
if (res.data && res.data.data) {
if (res.data?.data) {
setData(res.data.data.records);
setTotal(res.data.data.total);
}
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
const handleStatusChange = async (id: number, checked: boolean) => {
try {
await updatePromptStatus(id, checked ? 1 : 0);
message.success(checked ? '模板已启用' : '模板已停用');
fetchData();
} catch (err) {
console.error(err);
}
await updatePromptStatus(id, checked ? 1 : 0);
message.success(checked ? '模板已启用' : '模板已停用');
await fetchData();
};
const handleOpenDrawer = (record?: PromptTemplateVO, isClone = false) => {
@ -91,16 +116,15 @@ const PromptTemplates: React.FC = () => {
form.setFieldsValue({
...record,
templateName: `${record.templateName} (副本)`,
isSystem: 0, // 副本强制设为普通模板
isSystem: 0,
id: undefined,
tenantId: undefined
tenantId: undefined,
});
setPreviewContent(record.promptContent);
} else {
const isPlatformLevel = Number(record.tenantId) === 0 && Number(record.isSystem) === 1;
const currentUserId = userProfile.userId ? Number(userProfile.userId) : -1;
// 权限判定逻辑
let canEdit = false;
if (Number(record.isSystem) === 0) {
canEdit = Number(record.creatorId) === currentUserId;
@ -108,8 +132,6 @@ const PromptTemplates: React.FC = () => {
canEdit = isPlatformLevel;
} else if (isTenantAdmin) {
canEdit = Number(record.tenantId) === activeTenantId;
} else {
canEdit = false;
}
if (!canEdit) {
@ -124,10 +146,9 @@ const PromptTemplates: React.FC = () => {
} else {
setEditingId(null);
form.resetFields();
// 租户管理员或平台管理员新增默认选系统/租户预置
form.setFieldsValue({
status: 1,
isSystem: (isTenantAdmin || isPlatformAdmin) ? 1 : 0
isSystem: (isTenantAdmin || isPlatformAdmin) ? 1 : 0,
});
setPreviewContent('');
}
@ -135,35 +156,57 @@ const PromptTemplates: React.FC = () => {
};
const showDetail = (record: PromptTemplateVO) => {
Modal.info({
void (async () => {
const detailRes = await getPromptDetail(record.id);
const detail = detailRes.data?.data || record;
Modal.info({
title: record.templateName,
width: 800,
icon: null,
content: (
<div style={{ maxHeight: '65vh', overflowY: 'auto', padding: '12px 0' }}>
{record.description ? (
{detail.description ? (
<div style={{ marginBottom: 16, padding: '12px 16px', borderRadius: 8, background: 'var(--app-bg-surface-soft)' }}>
<Text type="secondary">{record.description}</Text>
<Text type="secondary">{detail.description}</Text>
</div>
) : null}
<ReactMarkdown>{record.promptContent}</ReactMarkdown>
<div style={{ marginBottom: 16 }}>
<Space wrap>
{detail.hotWordGroupName ? <Tag color="blue">{detail.hotWordGroupName}</Tag> : <Tag></Tag>}
{(detail.tags || []).map((tag) => {
const dictItem = dictTags.find((item) => item.itemValue === tag);
return <Tag key={tag}>{dictItem ? dictItem.itemLabel : tag}</Tag>;
})}
</Space>
</div>
{detail.hotWords && detail.hotWords.length > 0 ? (
<div style={{ marginBottom: 16 }}>
<div style={{ marginBottom: 8, fontWeight: 600 }}></div>
<Space wrap>
{detail.hotWords.map((word) => <Tag key={word}>{word}</Tag>)}
</Space>
</div>
) : detail.hotWordGroupId ? (
<div style={{ marginBottom: 16 }}>
<Text type="secondary"></Text>
</div>
) : null}
<ReactMarkdown>{detail.promptContent}</ReactMarkdown>
</div>
),
okText: '关闭',
maskClosable: true
});
maskClosable: true,
});
})();
};
const handleSubmit = async () => {
try {
const values = await form.validateFields();
setSubmitLoading(true);
// 处理 tenantId如果是新增且是平台管理员设为系统模板手动设置 tenantId 为 0
if (!editingId && isPlatformAdmin && values.isSystem === 1) {
values.tenantId = 0;
}
if (editingId) {
await updatePromptTemplate({ ...values, id: editingId });
message.success('更新成功');
@ -172,9 +215,7 @@ const PromptTemplates: React.FC = () => {
message.success('模板已创建');
}
setDrawerVisible(false);
fetchData();
} catch (err) {
console.error(err);
await fetchData();
} finally {
setSubmitLoading(false);
}
@ -182,7 +223,7 @@ const PromptTemplates: React.FC = () => {
const groupedData = React.useMemo(() => {
const groups: Record<string, PromptTemplateVO[]> = {};
data.forEach(item => {
data.forEach((item) => {
const cat = item.category || 'default';
if (!groups[cat]) groups[cat] = [];
groups[cat].push(item);
@ -195,26 +236,17 @@ const PromptTemplates: React.FC = () => {
const isPlatformLevel = Number(item.tenantId) === 0 && isSystem;
const isTenantLevel = Number(item.tenantId) > 0 && isSystem;
const isPersonalLevel = !isSystem;
// 权限判定逻辑 (使用 Number 强制转换防止类型不匹配)
let canEdit = false;
const currentUserId = userProfile.userId ? Number(userProfile.userId) : -1;
let canEdit = false;
if (isPersonalLevel) {
// 个人模板仅本人可编辑
canEdit = Number(item.creatorId) === currentUserId;
} else if (isPlatformAdmin) {
// 平台管理员管理平台公开模板 (tenantId = 0)
canEdit = Number(item.tenantId) === 0;
} else if (isTenantAdmin) {
// 租户管理员管理本租户公开模板
canEdit = Number(item.tenantId) === activeTenantId;
} else {
// 普通用户不可编辑公开模板
canEdit = false;
}
// 标签颜色与文字
const levelTag = isPlatformLevel ? (
<Tag color="gold" style={{ borderRadius: 4 }}></Tag>
) : isTenantLevel ? (
@ -234,22 +266,22 @@ const PromptTemplates: React.FC = () => {
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0, flex: 1 }}>
<div style={{
width: 40, height: 40, borderRadius: 10,
width: 40,
height: 40,
borderRadius: 10,
background: isPlatformLevel ? 'color-mix(in srgb, #f5c542 14%, var(--app-bg-surface-strong))' : (isTenantLevel ? 'color-mix(in srgb, var(--app-primary-color) 12%, var(--app-bg-surface-strong))' : 'color-mix(in srgb, #13c2c2 12%, var(--app-bg-surface-strong))'),
display: 'flex', justifyContent: 'center', alignItems: 'center', flexShrink: 0
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
flexShrink: 0,
}}>
<StarFilled style={{ fontSize: 20, color: isPlatformLevel ? '#faad14' : (isTenantLevel ? '#1890ff' : '#13c2c2') }} />
</div>
<div style={{ flexShrink: 0 }}>{levelTag}</div>
</div>
<Space onClick={e => e.stopPropagation()} style={{ flexShrink: 0, marginLeft: 8 }}>
<Space onClick={(e) => e.stopPropagation()} style={{ flexShrink: 0, marginLeft: 8 }}>
{canEdit && <EditOutlined style={{ fontSize: 18, color: '#bfbfbf', cursor: 'pointer' }} onClick={() => handleOpenDrawer(item)} />}
<Switch
size="small"
checked={item.status === 1}
onChange={(checked) => handleStatusChange(item.id, checked)}
disabled={false}
/>
<Switch size="small" checked={item.status === 1} onChange={(checked) => void handleStatusChange(item.id, checked)} />
</Space>
</div>
@ -260,29 +292,30 @@ const PromptTemplates: React.FC = () => {
{item.description}
</Text>
) : null}
{/*<Text type="secondary" style={{ fontSize: 12 }}>使用次数: {item.usageCount || 0}</Text>*/}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 20, height: 22, overflow: 'hidden' }}>
{item.tags?.map(tag => {
const dictItem = dictTags.find(dt => dt.itemValue === tag);
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 12, minHeight: 22 }}>
{item.hotWordGroupName ? <Tag color="blue" style={{ margin: 0 }}>{item.hotWordGroupName}</Tag> : null}
{item.tags?.map((tag) => {
const dictItem = dictTags.find((dt) => dt.itemValue === tag);
return (
<Tag key={tag} style={{ margin: 0, border: '1px solid var(--app-border-color)', background: 'var(--app-bg-surface-soft)', color: 'var(--app-text-main)', borderRadius: 4, fontSize: 10, maxWidth: 100, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{dictItem ? dictItem.itemLabel : tag}
</Tag>
);
})}
{!item.hotWordGroupName && (!item.tags || item.tags.length === 0) ? <Text type="secondary"></Text> : null}
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', paddingTop: 12, borderTop: '1px solid #f5f5f5' }}>
<Space onClick={e => e.stopPropagation()}>
<Space onClick={(e) => e.stopPropagation()}>
<Tooltip title="以此创建">
<CopyOutlined style={{ color: '#bfbfbf', cursor: 'pointer', fontSize: 16 }} onClick={() => handleOpenDrawer(item, true)} />
</Tooltip>
{canEdit && (
<Popconfirm
title="确定删除?"
onConfirm={() => deletePromptTemplate(item.id).then(fetchData)}
onConfirm={() => deletePromptTemplate(item.id).then(() => fetchData())}
okText={t('common.confirm')}
cancelText={t('common.cancel')}
>
@ -308,17 +341,17 @@ const PromptTemplates: React.FC = () => {
</div>
<Card variant="borderless" style={{ borderRadius: 12, marginBottom: 32, background: 'var(--app-bg-card)', border: '1px solid var(--app-border-color)', boxShadow: 'var(--app-shadow)', backdropFilter: 'blur(16px)' }} styles={{ body: { padding: '20px 24px' } }}>
<Form form={searchForm} layout="inline" onFinish={fetchData}>
<Form form={searchForm} layout="inline" onFinish={() => void fetchData()}>
<Form.Item name="name" label="模板名称"><Input placeholder="请输入..." style={{ width: 180 }} /></Form.Item>
<Form.Item name="category" label="分类">
<Select placeholder="选择分类" style={{ width: 160 }} allowClear>
{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>
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit"></Button>
<Button onClick={() => { searchForm.resetFields(); fetchData(); }}></Button>
<Button onClick={() => { searchForm.resetFields(); void fetchData(); }}></Button>
</Space>
</Form.Item>
</Form>
@ -333,8 +366,8 @@ const PromptTemplates: React.FC = () => {
) : (
<>
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto', padding: '24px 24px 0' }}>
{Object.keys(groupedData).map(catKey => {
const catLabel = categories.find(c => c.itemValue === catKey)?.itemLabel || catKey;
{Object.keys(groupedData).map((catKey) => {
const catLabel = categories.find((c) => c.itemValue === catKey)?.itemLabel || catKey;
return (
<div key={catKey} style={{ marginBottom: 40 }}>
<Title level={4} style={{ marginBottom: 24, paddingLeft: 8, borderLeft: '4px solid #1890ff' }}>{catLabel}</Title>
@ -369,7 +402,7 @@ const PromptTemplates: React.FC = () => {
extra={
<Space>
<Button onClick={() => setDrawerVisible(false)}></Button>
<Button type="primary" icon={<SaveOutlined />} loading={submitLoading} onClick={handleSubmit}></Button>
<Button type="primary" icon={<SaveOutlined />} loading={submitLoading} onClick={() => void handleSubmit()}></Button>
</Space>
}
destroyOnHidden
@ -377,14 +410,16 @@ const PromptTemplates: React.FC = () => {
<Form form={form} layout="vertical">
<Row gutter={24}>
<Col span={(isPlatformAdmin || isTenantAdmin) ? 8 : 12}>
<Form.Item name="templateName" label="模板名称" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="templateName" label="模板名称" rules={[{ required: true }]}>
<Input />
</Form.Item>
</Col>
{(isPlatformAdmin || isTenantAdmin) && (
<Col span={6}>
<Form.Item name="isSystem" label="模板属性" rules={[{ required: true }]}>
<Select placeholder="选择属性">
{promptLevels.length > 0 ? (
promptLevels.map(i => <Option key={i.itemValue} value={Number(i.itemValue)}>{i.itemLabel}</Option>)
promptLevels.map((i) => <Option key={i.itemValue} value={Number(i.itemValue)}>{i.itemLabel}</Option>)
) : (
<>
<Option value={1}>{isPlatformAdmin ? '系统预置 (全局)' : '租户预置 (全员)'}</Option>
@ -397,7 +432,9 @@ const PromptTemplates: React.FC = () => {
)}
<Col span={(isPlatformAdmin || isTenantAdmin) ? 5 : 6}>
<Form.Item name="category" label="分类" rules={[{ required: true }]}>
<Select loading={dictLoading}>{categories.map(i => <Option key={i.itemValue} value={i.itemValue}>{i.itemLabel}</Option>)}</Select>
<Select loading={dictLoading}>
{categories.map((i) => <Option key={i.itemValue} value={i.itemValue}>{i.itemLabel}</Option>)}
</Select>
</Form.Item>
</Col>
<Col span={isPlatformAdmin ? 5 : 6}>
@ -409,6 +446,7 @@ const PromptTemplates: React.FC = () => {
</Form.Item>
</Col>
</Row>
<Form.Item name="description" label="模板描述">
<Input.TextArea
maxLength={255}
@ -417,29 +455,52 @@ const PromptTemplates: React.FC = () => {
placeholder="请输入模板描述"
/>
</Form.Item>
<Form.Item name="tags" label="业务标签" tooltip="可从现有标签中选择,也可输入新内容按回车保存">
<Select
mode="tags"
placeholder="选择或输入新标签"
allowClear
tokenSeparators={[',', ' ', ';']}
>
{dictTags.map(t => <Option key={t.itemValue} value={t.itemValue}>{t.itemLabel}</Option>)}
</Select>
</Form.Item>
<Row gutter={24}>
<Col span={12}>
<Form.Item name="tags" label="业务标签" tooltip="可从现有标签中选择,也可输入新内容按回车保存">
<Select mode="tags" placeholder="选择或输入新标签" allowClear tokenSeparators={[',', ' ', ';']}>
{dictTags.map((item) => <Option key={item.itemValue} value={item.itemValue}>{item.itemLabel}</Option>)}
</Select>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="hotWordGroupId"
label="绑定热词组"
tooltip="可选,未绑定则保持兼容"
>
<Select
placeholder="选择热词组"
allowClear
options={groupOptions.map((item) => ({ label: `${item.groupName} (${item.hotWordCount}/200)`, value: item.id }))}
/>
</Form.Item>
</Col>
</Row>
<Divider orientation="left"> (Markdown )</Divider>
<Row gutter={24} style={{ height: 'calc(100vh - 400px)' }}>
<Col span={12} style={{ height: '100%' }}>
<Form.Item name="promptContent" noStyle rules={[{ required: true }]}>
<Input.TextArea
onChange={e => setPreviewContent(e.target.value)}
onChange={(e) => setPreviewContent(e.target.value)}
style={{ height: '100%', fontFamily: 'monospace', resize: 'none', border: '1px solid #d9d9d9', borderRadius: 8, padding: 12 }}
placeholder="在此输入 Markdown 指令..."
/>
</Form.Item>
</Col>
<Col span={12} style={{ height: '100%', overflowY: 'auto', background: 'var(--app-bg-surface-soft)', border: '1px solid var(--app-border-color)', borderRadius: 8, padding: '16px 24px' }}>
<div style={{ marginBottom: 12 }}>
{form.getFieldValue('hotWordGroupId') ? (
<Tag color="blue">
{groupOptions.find((item) => item.id === form.getFieldValue('hotWordGroupId'))?.groupName || '已选择'}
</Tag>
) : (
<Tag></Tag>
)}
</div>
<div className="markdown-preview"><ReactMarkdown>{previewContent}</ReactMarkdown></div>
</Col>
</Row>

View File

@ -251,7 +251,7 @@ export function RealtimeAsrSession() {
setMeeting(detail);
setSessionStatus(realtimeStatus);
const fallbackDraft = buildDraftFromStatus(meetingId, detail, realtimeStatus);
const resolvedDraft = parsedDraft || fallbackDraft;
const resolvedDraft = fallbackDraft || parsedDraft;
setSessionDraft(resolvedDraft);
if (resolvedDraft) {
sessionStorage.setItem(getSessionKey(meetingId), JSON.stringify(resolvedDraft));