diff --git a/backend/design/db_schema.md b/backend/design/db_schema.md index 802da1a..410c236 100644 --- a/backend/design/db_schema.md +++ b/backend/design/db_schema.md @@ -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 | diff --git a/backend/design/db_schema_pgsql.sql b/backend/design/db_schema_pgsql.sql index 516e915..db4181b 100644 --- a/backend/design/db_schema_pgsql.sql +++ b/backend/design/db_schema_pgsql.sql @@ -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 '会议总结提示词模板表'; diff --git a/backend/src/main/java/com/imeeting/controller/biz/HotWordController.java b/backend/src/main/java/com/imeeting/controller/biz/HotWordController.java index ee1a149..06e605e 100644 --- a/backend/src/main/java/com/imeeting/controller/biz/HotWordController.java +++ b/backend/src/main/java/com/imeeting/controller/biz/HotWordController.java @@ -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 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 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 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,32 +80,19 @@ 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 wrapper = new LambdaQueryWrapper() - .eq(HotWord::getTenantId, loginUser.getTenantId()); + .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); - if (!isAdmin) { - // 普通用户:只能看到“已公开”的,或者“自己创建”的 - wrapper.and(w -> w.eq(HotWord::getIsPublic, 1).or().eq(HotWord::getCreatorId, loginUser.getUserId())); - } - - // 增加类型过滤 - if (isPublic != null) { - wrapper.eq(HotWord::getIsPublic, isPublic); - } - - wrapper.like(word != null && !word.isEmpty(), HotWord::getWord, word) - .eq(category != null && !category.isEmpty(), HotWord::getCategory, category) - .orderByDesc(HotWord::getIsPublic) - .orderByDesc(HotWord::getCreatedAt); - Page page = hotWordService.page(new Page<>(current, size), wrapper); List vos = page.getRecords().stream().map(this::toVO).collect(Collectors.toList()); - + PageResult> result = new PageResult<>(); result.setTotal(page.getTotal()); result.setRecords(vos); @@ -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; } } diff --git a/backend/src/main/java/com/imeeting/controller/biz/PromptTemplateController.java b/backend/src/main/java/com/imeeting/controller/biz/PromptTemplateController.java index f431ca2..b988e87 100644 --- a/backend/src/main/java/com/imeeting/controller/biz/PromptTemplateController.java +++ b/backend/src/main/java/com/imeeting/controller/biz/PromptTemplateController.java @@ -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 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()") diff --git a/backend/src/main/java/com/imeeting/dto/biz/HotWordDTO.java b/backend/src/main/java/com/imeeting/dto/biz/HotWordDTO.java index 6f6fc58..f6bb7ce 100644 --- a/backend/src/main/java/com/imeeting/dto/biz/HotWordDTO.java +++ b/backend/src/main/java/com/imeeting/dto/biz/HotWordDTO.java @@ -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 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; } diff --git a/backend/src/main/java/com/imeeting/dto/biz/HotWordVO.java b/backend/src/main/java/com/imeeting/dto/biz/HotWordVO.java index ea87e72..24c017e 100644 --- a/backend/src/main/java/com/imeeting/dto/biz/HotWordVO.java +++ b/backend/src/main/java/com/imeeting/dto/biz/HotWordVO.java @@ -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 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; } diff --git a/backend/src/main/java/com/imeeting/dto/biz/PromptTemplateDTO.java b/backend/src/main/java/com/imeeting/dto/biz/PromptTemplateDTO.java index b52bb75..d239ef8 100644 --- a/backend/src/main/java/com/imeeting/dto/biz/PromptTemplateDTO.java +++ b/backend/src/main/java/com/imeeting/dto/biz/PromptTemplateDTO.java @@ -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 tags; + + @Schema(description = "绑定热词组 ID") + private Long hotWordGroupId; + + @Schema(description = "模板内容") private String promptContent; + + @Schema(description = "状态:1-启用,0-禁用") private Integer status; + + @Schema(description = "备注") private String remark; } diff --git a/backend/src/main/java/com/imeeting/dto/biz/PromptTemplateVO.java b/backend/src/main/java/com/imeeting/dto/biz/PromptTemplateVO.java index 2a47d32..3dbbe4e 100644 --- a/backend/src/main/java/com/imeeting/dto/biz/PromptTemplateVO.java +++ b/backend/src/main/java/com/imeeting/dto/biz/PromptTemplateVO.java @@ -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 tags; + @Schema(description = "绑定热词组 ID") + private Long hotWordGroupId; + @Schema(description = "绑定热词组名称") + private String hotWordGroupName; + @Schema(description = "绑定热词列表") + private List hotWords; @Schema(description = "使用次数") private Integer usageCount; @Schema(description = "提示词正文") diff --git a/backend/src/main/java/com/imeeting/dto/biz/RealtimeMeetingRuntimeProfile.java b/backend/src/main/java/com/imeeting/dto/biz/RealtimeMeetingRuntimeProfile.java index 2e516e3..a86255e 100644 --- a/backend/src/main/java/com/imeeting/dto/biz/RealtimeMeetingRuntimeProfile.java +++ b/backend/src/main/java/com/imeeting/dto/biz/RealtimeMeetingRuntimeProfile.java @@ -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; diff --git a/backend/src/main/java/com/imeeting/entity/biz/HotWord.java b/backend/src/main/java/com/imeeting/entity/biz/HotWord.java index c8a64ca..79cebd2 100644 --- a/backend/src/main/java/com/imeeting/entity/biz/HotWord.java +++ b/backend/src/main/java/com/imeeting/entity/biz/HotWord.java @@ -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; diff --git a/backend/src/main/java/com/imeeting/entity/biz/PromptTemplate.java b/backend/src/main/java/com/imeeting/entity/biz/PromptTemplate.java index 11fa906..d24cd8b 100644 --- a/backend/src/main/java/com/imeeting/entity/biz/PromptTemplate.java +++ b/backend/src/main/java/com/imeeting/entity/biz/PromptTemplate.java @@ -36,6 +36,9 @@ public class PromptTemplate extends BaseEntity { @Schema(description = "业务标签列表") private java.util.List tags; + @Schema(description = "绑定热词组 ID") + private Long hotWordGroupId; + @Schema(description = "使用次数") private Integer usageCount; diff --git a/backend/src/main/java/com/imeeting/mapper/biz/HotWordMapper.java b/backend/src/main/java/com/imeeting/mapper/biz/HotWordMapper.java index a723ca7..11a8832 100644 --- a/backend/src/main/java/com/imeeting/mapper/biz/HotWordMapper.java +++ b/backend/src/main/java/com/imeeting/mapper/biz/HotWordMapper.java @@ -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 { + + @InterceptorIgnore(tenantLine = "true") + @Select({ + "" + }) + List selectEnabledByGroupIdIgnoreTenant(@Param("groupId") Long groupId); + + @InterceptorIgnore(tenantLine = "true") + @Select({ + "" + }) + List selectEnabledByGroupIdAndWordsIgnoreTenant(@Param("groupId") Long groupId, @Param("words") List words); } diff --git a/backend/src/main/java/com/imeeting/service/biz/HotWordService.java b/backend/src/main/java/com/imeeting/service/biz/HotWordService.java index e8f6fc0..be2b8b1 100644 --- a/backend/src/main/java/com/imeeting/service/biz/HotWordService.java +++ b/backend/src/main/java/com/imeeting/service/biz/HotWordService.java @@ -8,7 +8,9 @@ import com.imeeting.entity.biz.HotWord; import java.util.List; public interface HotWordService extends IService { - 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 generatePinyin(String word); + List listEnabledByGroupIdIgnoreTenant(Long groupId); + List listEnabledByGroupIdAndWordsIgnoreTenant(Long groupId, List words); } diff --git a/backend/src/main/java/com/imeeting/service/biz/PromptTemplateService.java b/backend/src/main/java/com/imeeting/service/biz/PromptTemplateService.java index aa59449..f0637c1 100644 --- a/backend/src/main/java/com/imeeting/service/biz/PromptTemplateService.java +++ b/backend/src/main/java/com/imeeting/service/biz/PromptTemplateService.java @@ -11,7 +11,8 @@ import java.util.List; public interface PromptTemplateService extends IService { 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> 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); diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java index d50b719..6a4cda5 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java @@ -275,11 +275,15 @@ public class AiTaskServiceImpl extends ServiceImpl impleme List> hotwords = new ArrayList<>(); Object hotWordsObj = taskRecord.getTaskConfig().get("hotWords"); + Object hotWordGroupIdObj = taskRecord.getTaskConfig().get("hotWordGroupId"); if (hotWordsObj instanceof List) { List words = (List) hotWordsObj; if (!words.isEmpty()) { - List entities = hotWordService.list(new LambdaQueryWrapper() - .eq(HotWord::getTenantId, meeting.getTenantId()).in(HotWord::getWord, words)); + List entities = hotWordGroupIdObj instanceof Number groupId + ? hotWordService.listEnabledByGroupIdAndWordsIgnoreTenant(groupId.longValue(), words) + : hotWordService.list(new LambdaQueryWrapper() + .eq(HotWord::getStatus, 1) + .in(HotWord::getWord, words)); Map weightMap = entities.stream() .collect(Collectors.toMap(HotWord::getWord, HotWord::getWeight, (v1, v2) -> v1)); for (String w : words) { diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/HotWordServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/HotWordServiceImpl.java index 9601c90..9fb7555 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/HotWordServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/HotWordServiceImpl.java @@ -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,34 +26,41 @@ import java.util.stream.Collectors; @Slf4j @Service +@RequiredArgsConstructor public class HotWordServiceImpl extends ServiceImpl 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())); } - + this.save(hotWord); return toVO(hotWord); } @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 impl @Override public List 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 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 impl return combinations.stream().limit(5).collect(Collectors.toList()); } + @Override + public List listEnabledByGroupIdIgnoreTenant(Long groupId) { + if (groupId == null) { + return List.of(); + } + return baseMapper.selectEnabledByGroupIdIgnoreTenant(groupId); + } + + @Override + public List listEnabledByGroupIdAndWordsIgnoreTenant(Long groupId, List 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() + .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> matrix, int index, String current, List result) { if (index == matrix.size()) { result.add(current.trim()); @@ -106,9 +155,10 @@ public class HotWordServiceImpl extends ServiceImpl 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 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; } } diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingCommandServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingCommandServiceImpl.java index c01f002..688b240 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingCommandServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingCommandServiceImpl.java @@ -77,16 +77,18 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { asrConfig.put("useSpkId", runtimeProfile.getResolvedUseSpkId()); asrConfig.put("enableTextRefine", runtimeProfile.getResolvedEnableTextRefine()); - List finalHotWords = command.getHotWords(); + List finalHotWords = runtimeProfile.getResolvedHotWords(); if (finalHotWords == null || finalHotWords.isEmpty()) { finalHotWords = hotWordService.list(new LambdaQueryWrapper() - .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> resolveRealtimeHotwords(List selectedWords, Long tenantId) { - List tenantHotwords = hotWordService.list(new LambdaQueryWrapper() - .eq(HotWord::getTenantId, tenantId) - .eq(HotWord::getStatus, 1)); - if (tenantHotwords == null || tenantHotwords.isEmpty()) { - return List.of(); - } - + private List> resolveRealtimeHotwords(List selectedWords, Long hotWordGroupId) { List 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 resolvedHotwords = hotWordGroupId == null + ? hotWordService.list(new LambdaQueryWrapper() + .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()); } diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingRuntimeProfileResolverImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingRuntimeProfileResolverImpl.java index b9a5a75..5f0b872 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingRuntimeProfileResolverImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingRuntimeProfileResolverImpl.java @@ -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 hotWords) { + List normalized = normalizeHotWords(hotWords); + if (!normalized.isEmpty()) { + return null; + } + return promptTemplate == null ? null : promptTemplate.getHotWordGroupId(); + } + + private List resolveHotWords(PromptTemplate promptTemplate, List hotWords) { + List 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 normalizeHotWords(List 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) { diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/PromptTemplateServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/PromptTemplateServiceImpl.java index bd1a5a2..2f45851 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/PromptTemplateServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/PromptTemplateServiceImpl.java @@ -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 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> pageTemplates(Integer current, Integer size, String name, String category, Long tenantId, Long userId, Boolean isPlatformAdmin, Boolean isTenantAdmin) { - LambdaQueryWrapper 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 page = this.page(new Page<>(current, size), wrapper); List records = page.getRecords(); Map userStatusMap = queryUserStatusMap(tenantId, userId, records.stream().map(PromptTemplate::getId).collect(Collectors.toList())); + Map hotWordGroupMap = queryHotWordGroupMap(records.stream().map(PromptTemplate::getHotWordGroupId).toList()); + List 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> result = new PageResult<>(); @@ -81,6 +88,20 @@ public class PromptTemplateServiceImpl extends ServiceImpl 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 buildVisibilityWrapper(Long tenantId, Long userId, Boolean isPlatformAdmin, Boolean isTenantAdmin) { LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); wrapper.and(w -> w @@ -154,6 +198,30 @@ public class PromptTemplateServiceImpl extends ServiceImpl queryHotWordGroupMap(List hotWordGroupIds) { + List 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 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 hotWordGroupMap) { PromptTemplateVO vo = new PromptTemplateVO(); vo.setId(entity.getId()); vo.setTenantId(entity.getTenantId()); @@ -183,6 +252,10 @@ public class PromptTemplateServiceImpl extends ServiceImpl> 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; } diff --git a/backend/src/test/java/com/imeeting/service/biz/impl/MeetingRuntimeProfileResolverImplTest.java b/backend/src/test/java/com/imeeting/service/biz/impl/MeetingRuntimeProfileResolverImplTest.java index 2d07750..8a24605 100644 --- a/backend/src/test/java/com/imeeting/service/biz/impl/MeetingRuntimeProfileResolverImplTest.java +++ b/backend/src/test/java/com/imeeting/service/biz/impl/MeetingRuntimeProfileResolverImplTest.java @@ -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); diff --git a/frontend/src/api/business/hotword.ts b/frontend/src/api/business/hotword.ts index b301924..0350148 100644 --- a/frontend/src/api/business/hotword.ts +++ b/frontend/src/api/business/hotword.ts @@ -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,22 +19,23 @@ 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; } -export const getHotWordPage = (params: { - current: number; - size: number; - word?: string; +export const getHotWordPage = (params: { + current: number; + 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", diff --git a/frontend/src/api/business/prompt.ts b/frontend/src/api/business/prompt.ts index 6468c90..8128247 100644 --- a/frontend/src/api/business/prompt.ts +++ b/frontend/src/api/business/prompt.ts @@ -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,15 +27,16 @@ export interface PromptTemplateDTO { category: string; isSystem: number; tags?: string[]; + hotWordGroupId?: number; promptContent: string; status: number; remark?: string; } -export const getPromptPage = (params: { - current: number; - size: number; - name?: string; +export const getPromptPage = (params: { + current: number; + size: number; + name?: string; category?: string; }) => { return http.get<{ code: string; data: { records: PromptTemplateVO[]; total: number }; msg: 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", diff --git a/frontend/src/pages/business/HotWords.tsx b/frontend/src/pages/business/HotWords.tsx index 1991a19..9382b17 100644 --- a/frontend/src/pages/business/HotWords.tsx +++ b/frontend/src/pages/business/HotWords.tsx @@ -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(); + const [groupForm] = Form.useForm(); 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([]); 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(undefined); - const [searchType, setSearchType] = useState(undefined); + const [modalVisible, setModalVisible] = useState(false); const [editingId, setEditingId] = useState(null); const [submitLoading, setSubmitLoading] = useState(false); - const userProfile = useMemo(() => { - const profileStr = sessionStorage.getItem("userProfile"); - return profileStr ? JSON.parse(profileStr) : {}; - }, []); + const [groupOptions, setGroupOptions] = useState([]); + const [groupManageVisible, setGroupManageVisible] = useState(false); + const [groupEditorVisible, setGroupEditorVisible] = useState(false); + const [groupLoading, setGroupLoading] = useState(false); + const [groupSubmitLoading, setGroupSubmitLoading] = useState(false); + const [groupData, setGroupData] = useState([]); + const [editingGroupId, setEditingGroupId] = useState(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, + [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) => ( - - {text} - {record.isPublic === 1 ? ( - - - - ) : ( - - - - )} - - ), + render: (text: string) => {text}, }, { title: "拼音", dataIndex: "pinyinList", key: "pinyinList", - render: (list: string[]) => - list?.[0] ? {list[0]} : -, + render: (list: string[]) => list?.[0] ? {list[0]} : -, }, { - 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 ? 公开 : 私有), + title: "热词组", + dataIndex: "hotWordGroupId", + key: "hotWordGroupId", + render: (value?: number, record?: HotWordVO) => { + const name = record?.hotWordGroupName || (value ? groupNameMap[value] : undefined); + return name ? {name} : 未分组; + }, }, { title: "权重", @@ -211,38 +298,77 @@ const HotWords: React.FC = () => { title: "状态", dataIndex: "status", key: "status", - render: (value: number) => - value === 1 ? : , + render: (value: number) => value === 1 ? : , }, { title: "操作", key: "action", - render: (_: unknown, record: HotWordVO) => { - const isMine = record.creatorId === userProfile.userId; - const canEdit = record.isPublic === 1 ? isAdmin : isMine || isAdmin; - - if (!canEdit) { - return 无权操作; - } - - return ( - - + handleDelete(record.id)} + okText={t("common.confirm")} + cancelText={t("common.cancel")} + > + - handleDelete(record.id)} - okText={t("common.confirm")} - cancelText={t("common.cancel")} - > - - - - ); - }, + + + ), + }, + ]; + + const groupColumns = [ + { + title: "热词组名称", + dataIndex: "groupName", + key: "groupName", + render: (value: string) => {value}, + }, + { + title: "热词数量", + dataIndex: "hotWordCount", + key: "hotWordCount", + render: (value: number) => = 200 ? "red" : "processing"}>{value}/200, + }, + { + title: "状态", + dataIndex: "status", + key: "status", + render: (value: number) => value === 1 ? : , + }, + { + title: "备注", + dataIndex: "remark", + key: "remark", + render: (value?: string) => value || "-", + }, + { + title: "操作", + key: "action", + render: (_: unknown, record: HotWordGroupVO) => ( + + + handleDeleteGroup(record.id)} + okText={t("common.confirm")} + cancelText={t("common.cancel")} + > + + + + ), }, ]; @@ -256,19 +382,7 @@ const HotWords: React.FC = () => { extra={ - - {/**/} - {/* */} - {/**/} - @@ -357,6 +455,14 @@ const HotWords: React.FC = () => { + + + @@ -377,16 +480,6 @@ const HotWords: React.FC = () => { - {isAdmin ? ( - - - - - - - - - ) : null} @@ -394,6 +487,46 @@ const HotWords: React.FC = () => { + + setGroupManageVisible(false)} + footer={null} + width={900} + destroyOnHidden + > +
+ +
+ + + + setGroupEditorVisible(false)} + onOk={() => void handleGroupSubmit()} + confirmLoading={groupSubmitLoading} + destroyOnHidden + > +
+ + + + + + + + + + +
); }; diff --git a/frontend/src/pages/business/MeetingDetail.tsx b/frontend/src/pages/business/MeetingDetail.tsx index 1a2608a..9d70e97 100644 --- a/frontend/src/pages/business/MeetingDetail.tsx +++ b/frontend/src/pages/business/MeetingDetail.tsx @@ -984,7 +984,6 @@ const MeetingDetail: React.FC = () => { category: '', weight: 2, status: 1, - isPublic: 0, remark: meeting ? `来源于会议:${meeting.title}` : '来源于会议关键词', }), ), diff --git a/frontend/src/pages/business/PromptTemplates.tsx b/frontend/src/pages/business/PromptTemplates.tsx index c6d3a0b..081d1a0 100644 --- a/frontend/src/pages/business/PromptTemplates.tsx +++ b/frontend/src/pages/business/PromptTemplates.tsx @@ -1,20 +1,40 @@ -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 { useDict } from '../../hooks/useDict'; -import { - getPromptPage, - savePromptTemplate, - updatePromptTemplate, - deletePromptTemplate, - updatePromptStatus, - PromptTemplateVO, - PromptTemplateDTO -} from '../../api/business/prompt'; -import AppPagination from '../../components/shared/AppPagination'; - import { useTranslation } from 'react-i18next'; +import { useDict } from '../../hooks/useDict'; +import { + deletePromptTemplate, + getPromptDetail, + getPromptPage, + savePromptTemplate, + updatePromptStatus, + updatePromptTemplate, + type PromptTemplateVO, +} from '../../api/business/prompt'; +import { getHotWordGroupOptions, type HotWordGroupVO } from '../../api/business/hotwordGroup'; +import AppPagination from '../../components/shared/AppPagination'; const { Option } = Select; const { Text, Title } = Typography; @@ -27,17 +47,19 @@ 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([]); const [total, setTotal] = useState(0); const [current, setCurrent] = useState(1); const [pageSize, setPageSize] = useState(12); - + const [drawerVisible, setDrawerVisible] = useState(false); const [editingId, setEditingId] = useState(null); const [submitLoading, setSubmitLoading] = useState(false); const [previewContent, setPreviewContent] = useState(''); + const [groupOptions, setGroupOptions] = useState([]); const userProfile = React.useMemo(() => { const profileStr = sessionStorage.getItem("userProfile"); @@ -45,43 +67,46 @@ 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); try { - const res = await getPromptPage({ - current, - size: pageSize, - name: values.name, - category: values.category + const res = await getPromptPage({ + current, + size: pageSize, + name: values.name, + 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 + form.setFieldsValue({ + status: 1, + 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: (
- {record.description ? ( + {detail.description ? (
- {record.description} + {detail.description}
) : null} - {record.promptContent} +
+ + {detail.hotWordGroupName ? 热词组:{detail.hotWordGroupName} : 未绑定热词组} + {(detail.tags || []).map((tag) => { + const dictItem = dictTags.find((item) => item.itemValue === tag); + return {dictItem ? dictItem.itemLabel : tag}; + })} + +
+ {detail.hotWords && detail.hotWords.length > 0 ? ( +
+
绑定热词
+ + {detail.hotWords.map((word) => {word})} + +
+ ) : detail.hotWordGroupId ? ( +
+ 该热词组当前没有热词 +
+ ) : null} + {detail.promptContent}
), 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 = {}; - 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 ? ( 平台级 ) : isTenantLevel ? ( @@ -224,7 +256,7 @@ const PromptTemplates: React.FC = () => { ); return ( - showDetail(item)} @@ -233,23 +265,23 @@ const PromptTemplates: React.FC = () => { >
-
{levelTag}
- e.stopPropagation()} style={{ flexShrink: 0, marginLeft: 8 }}> + e.stopPropagation()} style={{ flexShrink: 0, marginLeft: 8 }}> {canEdit && handleOpenDrawer(item)} />} - handleStatusChange(item.id, checked)} - disabled={false} - /> + void handleStatusChange(item.id, checked)} />
@@ -260,29 +292,30 @@ const PromptTemplates: React.FC = () => { {item.description} ) : null} - {/*使用次数: {item.usageCount || 0}*/} -
- {item.tags?.map(tag => { - const dictItem = dictTags.find(dt => dt.itemValue === tag); +
+ {item.hotWordGroupName ? 热词组:{item.hotWordGroupName} : null} + {item.tags?.map((tag) => { + const dictItem = dictTags.find((dt) => dt.itemValue === tag); return ( {dictItem ? dictItem.itemLabel : tag} ); })} + {!item.hotWordGroupName && (!item.tags || item.tags.length === 0) ? 未配置标签 : null}
- e.stopPropagation()}> + e.stopPropagation()}> handleOpenDrawer(item, true)} /> {canEdit && ( - deletePromptTemplate(item.id).then(fetchData)} + deletePromptTemplate(item.id).then(() => fetchData())} okText={t('common.confirm')} cancelText={t('common.cancel')} > @@ -308,17 +341,17 @@ const PromptTemplates: React.FC = () => {
-
+ void fetchData()}> - + @@ -333,8 +366,8 @@ const PromptTemplates: React.FC = () => { ) : ( <>
- {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 (
{catLabel} @@ -369,7 +402,7 @@ const PromptTemplates: React.FC = () => { extra={ - + } destroyOnHidden @@ -377,14 +410,16 @@ const PromptTemplates: React.FC = () => {
- + + + {(isPlatformAdmin || isTenantAdmin) && ( {categories.map(i => )} + @@ -409,6 +446,7 @@ const PromptTemplates: React.FC = () => { + { placeholder="请输入模板描述" /> - - - - + + + + + + + + + +