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 5ba1a5c..fd3d2e5 100644 --- a/backend/src/main/java/com/imeeting/controller/biz/HotWordController.java +++ b/backend/src/main/java/com/imeeting/controller/biz/HotWordController.java @@ -2,6 +2,8 @@ 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.HotWordBatchCreateDTO; +import com.imeeting.dto.biz.HotWordBatchCreateResultVO; import com.imeeting.dto.biz.HotWordBatchGroupDTO; import com.imeeting.dto.biz.HotWordDTO; import com.imeeting.dto.biz.HotWordVO; @@ -52,6 +54,18 @@ public class HotWordController { return ApiResponse.ok(hotWordService.saveHotWord(hotWordDTO, loginUser.getUserId(), targetTenantId)); } + @Operation(summary = "批量新增热词") + @PostMapping("/batch") + @PreAuthorize("isAuthenticated()") + @Log(value = "批量新增热词", type = "热词管理") + public ApiResponse saveBatch(@RequestBody HotWordBatchCreateDTO dto) { + LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + Long targetTenantId = resolveTargetTenantId(loginUser, dto.getTenantId()); + boolean platformAdmin = Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()); + return ApiResponse.ok( + hotWordService.saveHotWordsBatch(dto, loginUser.getUserId(), targetTenantId, platformAdmin)); + } + @Operation(summary = "修改热词") @PutMapping @PreAuthorize("isAuthenticated()") diff --git a/backend/src/main/java/com/imeeting/controller/biz/HotWordGroupController.java b/backend/src/main/java/com/imeeting/controller/biz/HotWordGroupController.java index bfc1d54..0e82755 100644 --- a/backend/src/main/java/com/imeeting/controller/biz/HotWordGroupController.java +++ b/backend/src/main/java/com/imeeting/controller/biz/HotWordGroupController.java @@ -26,11 +26,8 @@ public class HotWordGroupController { this.hotWordGroupService = hotWordGroupService; } - private Long resolveTargetTenantId(LoginUser loginUser, Long tenantId) { - if (Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()) && Long.valueOf(0L).equals(tenantId)) { - return 0L; - } - return null; + private Long resolveTargetTenantId(LoginUser loginUser) { + return loginUser.getTenantId(); } @Operation(summary = "新增热词组") @@ -39,7 +36,7 @@ public class HotWordGroupController { @Log(value = "新增热词组", type = "热词组管理") public ApiResponse save(@RequestBody HotWordGroupDTO dto) { LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); - Long targetTenantId = resolveTargetTenantId(loginUser, dto.getTenantId()); + Long targetTenantId = resolveTargetTenantId(loginUser); return ApiResponse.ok(hotWordGroupService.saveGroup(dto, loginUser.getUserId(), targetTenantId)); } @@ -49,7 +46,7 @@ public class HotWordGroupController { @Log(value = "修改热词组", type = "热词组管理") public ApiResponse update(@RequestBody HotWordGroupDTO dto) { LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); - Long targetTenantId = resolveTargetTenantId(loginUser, dto.getTenantId()); + Long targetTenantId = resolveTargetTenantId(loginUser); HotWordGroupVO existing = hotWordGroupService.listVisibleOptions(targetTenantId).stream() .filter(item -> item.getId().equals(dto.getId())) .findFirst() @@ -57,7 +54,6 @@ public class HotWordGroupController { if (existing == null) { return ApiResponse.error("热词组不存在"); } - dto.setTenantId(targetTenantId); return ApiResponse.ok(hotWordGroupService.updateGroup(dto)); } @@ -65,9 +61,9 @@ public class HotWordGroupController { @DeleteMapping("/{id}") @PreAuthorize("isAuthenticated()") @Log(value = "删除热词组", type = "热词组管理") - public ApiResponse delete(@PathVariable Long id, @RequestParam(required = false) Long tenantId) { + public ApiResponse delete(@PathVariable Long id) { LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); - Long targetTenantId = resolveTargetTenantId(loginUser, tenantId); + Long targetTenantId = resolveTargetTenantId(loginUser); return ApiResponse.ok(hotWordGroupService.removeGroupById(id, targetTenantId)); } @@ -78,19 +74,18 @@ public class HotWordGroupController { @RequestParam(defaultValue = "1") Integer current, @RequestParam(defaultValue = "10") Integer size, @RequestParam(required = false) String name, - @RequestParam(required = false) Integer status, - @RequestParam(required = false) Long tenantId) { + @RequestParam(required = false) Integer status) { LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); - Long targetTenantId = resolveTargetTenantId(loginUser, tenantId); + Long targetTenantId = resolveTargetTenantId(loginUser); return ApiResponse.ok(hotWordGroupService.pageGroups(current, size, name, status, targetTenantId)); } @Operation(summary = "查询热词组选项") @GetMapping("/options") @PreAuthorize("isAuthenticated()") - public ApiResponse> options(@RequestParam(required = false) Long tenantId) { + public ApiResponse> options() { LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); - Long targetTenantId = resolveTargetTenantId(loginUser, tenantId); + Long targetTenantId = resolveTargetTenantId(loginUser); return ApiResponse.ok(hotWordGroupService.listVisibleOptions(targetTenantId)); } } diff --git a/backend/src/main/java/com/imeeting/controller/qt/QtMeetingController.java b/backend/src/main/java/com/imeeting/controller/qt/QtMeetingController.java new file mode 100644 index 0000000..d3d37f3 --- /dev/null +++ b/backend/src/main/java/com/imeeting/controller/qt/QtMeetingController.java @@ -0,0 +1,96 @@ +package com.imeeting.controller.qt; + +import com.imeeting.dto.android.AndroidAuthContext; +import com.imeeting.common.MeetingConstants; +import com.imeeting.dto.biz.CreateMeetingCommand; +import com.imeeting.dto.biz.MeetingVO; +import com.imeeting.enums.MeetingTerminalEnum; +import com.imeeting.service.android.AndroidAuthService; +import com.imeeting.service.biz.MeetingAuthorizationService; +import com.imeeting.service.biz.MeetingCommandService; +import com.imeeting.service.biz.PromptTemplateService; +import com.imeeting.support.AndroidRequestLogHelper; +import com.unisbase.annotation.Anonymous; +import com.unisbase.common.ApiResponse; +import com.unisbase.common.annotation.Log; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Qt会议管理") +@RestController +@RequestMapping("/api/qt/meetings") +@RequiredArgsConstructor +@Slf4j +public class QtMeetingController { + + private final AndroidAuthService androidAuthService; + private final MeetingAuthorizationService meetingAuthorizationService; + private final MeetingCommandService meetingCommandService; + private final PromptTemplateService promptTemplateService; + + @Operation(summary = "创建 Qt 离线会议") + @PostMapping + @Anonymous + @Log(value = "新增 Qt 离线会议", type = "Qt会议管理") + public ApiResponse createMeeting(HttpServletRequest request, + @Valid @RequestBody CreateMeetingCommand command) { + AndroidRequestLogHelper.logRequest(log, "Qt会议", "创建离线会议接口", "request", command); + AndroidAuthContext authContext = androidAuthService.authenticateHttp(request); + meetingAuthorizationService.assertCanCreateMeeting(authContext); + assertPromptAvailable(command.getPromptId(), authContext); + MeetingVO meeting = meetingCommandService.createMeeting( + command, + authContext.getTenantId(), + authContext.getUserId(), + resolveCreatorName(authContext), + MeetingTerminalEnum.resolve(authContext.getPlatform()).getCode(), + authContext.getDeviceId(), + resolveSourceDeviceMode(authContext) + ); + return ApiResponse.ok(meeting); + } + + private String resolveCreatorName(AndroidAuthContext authContext) { + if (hasText(authContext.getDisplayName())) { + return authContext.getDisplayName().trim(); + } + if (hasText(authContext.getUsername())) { + return authContext.getUsername().trim(); + } + return hasText(authContext.getDeviceId()) ? "qt:" + authContext.getDeviceId().trim() : "qt"; + } + + private void assertPromptAvailable(Long promptId, AndroidAuthContext authContext) { + if (promptId == null) { + return; + } + boolean enabled = promptTemplateService.isTemplateEnabledForUser( + promptId, + authContext.getTenantId(), + authContext.getUserId(), + authContext.getPlatformAdmin(), + authContext.getTenantAdmin() + ); + if (!enabled) { + throw new RuntimeException("总结模板不可用"); + } + } + + private String resolveSourceDeviceMode(AndroidAuthContext authContext) { + return authContext.isAnonymous() + ? MeetingConstants.DEVICE_MODE_PUBLIC + : MeetingConstants.DEVICE_MODE_PRIVATE; + } + + private boolean hasText(String value) { + return value != null && !value.isBlank(); + } +} diff --git a/backend/src/main/java/com/imeeting/dto/biz/HotWordBatchCreateDTO.java b/backend/src/main/java/com/imeeting/dto/biz/HotWordBatchCreateDTO.java new file mode 100644 index 0000000..b6e949d --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/biz/HotWordBatchCreateDTO.java @@ -0,0 +1,23 @@ +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 HotWordBatchCreateDTO { + + @Schema(description = "租户 ID,平台管理员可传 0 表示平台范围") + private Long tenantId; + + @Schema(description = "待新增的热词内容列表") + private List words; + + @Schema(description = "所属热词组 ID,为空表示未分组") + private Long hotWordGroupId; + + @Schema(description = "备注") + private String remark; +} diff --git a/backend/src/main/java/com/imeeting/dto/biz/HotWordBatchCreateResultVO.java b/backend/src/main/java/com/imeeting/dto/biz/HotWordBatchCreateResultVO.java new file mode 100644 index 0000000..0b30944 --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/biz/HotWordBatchCreateResultVO.java @@ -0,0 +1,17 @@ +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 HotWordBatchCreateResultVO { + + @Schema(description = "实际新增热词数量") + private Integer createdCount; + + @Schema(description = "目标热词组中已存在的热词") + private List existingWords; +} diff --git a/backend/src/main/java/com/imeeting/dto/biz/MeetingVO.java b/backend/src/main/java/com/imeeting/dto/biz/MeetingVO.java index 603e04c..da4de7a 100644 --- a/backend/src/main/java/com/imeeting/dto/biz/MeetingVO.java +++ b/backend/src/main/java/com/imeeting/dto/biz/MeetingVO.java @@ -72,9 +72,15 @@ public class MeetingVO { @Schema(description = "总结模型ID") private Long summaryModelId; + @Schema(description = "总结模型名称") + private String summaryModelName; + @Schema(description = "总结模板ID") private Long promptId; + @Schema(description = "总结模板名称") + private String promptName; + @Schema(description = "最终生效热词组ID") private Long hotWordGroupId; diff --git a/backend/src/main/java/com/imeeting/enums/MeetingTerminalEnum.java b/backend/src/main/java/com/imeeting/enums/MeetingTerminalEnum.java new file mode 100644 index 0000000..860327e --- /dev/null +++ b/backend/src/main/java/com/imeeting/enums/MeetingTerminalEnum.java @@ -0,0 +1,56 @@ +package com.imeeting.enums; + +import com.unisbase.common.exception.BusinessException; +import lombok.Getter; + +@Getter +public enum MeetingTerminalEnum { + WINDOWS("WINDOWS", "Windows"), + MACOS("MACOS", "macOS"), + KYLIN("KYLIN", "麒麟"), + UOS("UOS", "统信"), + HARMONYOS("HARMONYOS", "鸿蒙"), + WEB("WEB", "Web端"), + CUSTOM_TERMINAL("CUSTOM_TERMINAL", "定制终端"); + + private static final String LEGACY_ANDROID_CODE = "ANDROID"; + + private final String code; + private final String description; + + MeetingTerminalEnum(String code, String description) { + this.code = code; + this.description = description; + } + + public static boolean isCustomTerminalSource(String source) { + return CUSTOM_TERMINAL.matches(source) || LEGACY_ANDROID_CODE.equalsIgnoreCase(source); + } + + public static MeetingTerminalEnum resolve(String source) { + if (source == null || source.isBlank()) { + throw new BusinessException("会议终端类型不能为空"); + } + String normalized = source.trim(); + if (LEGACY_ANDROID_CODE.equalsIgnoreCase(normalized)) { + return CUSTOM_TERMINAL; + } + for (MeetingTerminalEnum terminal : values()) { + if (terminal.matches(normalized)) { + return terminal; + } + } + throw new BusinessException("会议终端类型无效: " + source); + } + + public static boolean isSameTerminal(String source, String target) { + if (isCustomTerminalSource(source) && isCustomTerminalSource(target)) { + return true; + } + return source != null && target != null && source.equalsIgnoreCase(target); + } + + public boolean matches(String source) { + return source != null && code.equalsIgnoreCase(source); + } +} 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 46da4e8..f00625b 100644 --- a/backend/src/main/java/com/imeeting/service/biz/HotWordService.java +++ b/backend/src/main/java/com/imeeting/service/biz/HotWordService.java @@ -1,6 +1,8 @@ package com.imeeting.service.biz; import com.baomidou.mybatisplus.extension.service.IService; +import com.imeeting.dto.biz.HotWordBatchCreateDTO; +import com.imeeting.dto.biz.HotWordBatchCreateResultVO; import com.imeeting.dto.biz.HotWordDTO; import com.imeeting.dto.biz.HotWordVO; import com.imeeting.entity.biz.HotWord; @@ -9,6 +11,8 @@ import java.util.List; public interface HotWordService extends IService { HotWordVO saveHotWord(HotWordDTO hotWordDTO, Long userId, Long tenantId); + + HotWordBatchCreateResultVO saveHotWordsBatch(HotWordBatchCreateDTO dto, Long userId, Long tenantId, boolean platformAdmin); HotWordVO updateHotWord(HotWordDTO hotWordDTO, Long userId, Long tenantId); Integer updateHotWordGroupBatch(List ids, Long hotWordGroupId, Long tenantId); List generatePinyin(String word); 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 75e1c6a..78e40d5 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 @@ -3,6 +3,8 @@ package com.imeeting.service.biz.impl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.imeeting.dto.biz.HotWordBatchCreateDTO; +import com.imeeting.dto.biz.HotWordBatchCreateResultVO; import com.imeeting.dto.biz.HotWordDTO; import com.imeeting.dto.biz.HotWordVO; import com.imeeting.entity.biz.HotWord; @@ -10,6 +12,7 @@ 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 com.unisbase.common.exception.BusinessException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import net.sourceforge.pinyin4j.PinyinHelper; @@ -33,6 +36,9 @@ import java.util.stream.Collectors; public class HotWordServiceImpl extends ServiceImpl implements HotWordService { private static final int MAX_HOT_WORDS_PER_GROUP = 200; + private static final int DEFAULT_MATCH_STRATEGY = 1; + private static final int DEFAULT_WEIGHT = 2; + private static final int ENABLED_STATUS = 1; private final HotWordGroupMapper hotWordGroupMapper; @@ -52,12 +58,39 @@ public class HotWordServiceImpl extends ServiceImpl impl return toVO(hotWord); } + @Override + @Transactional(rollbackFor = Exception.class) + public HotWordBatchCreateResultVO saveHotWordsBatch(HotWordBatchCreateDTO dto, Long userId, Long tenantId, boolean platformAdmin) { + Set words = normalizeWords(dto.getWords()); + if (words.isEmpty()) { + throw new BusinessException("请选择有效热词"); + } + + Long groupId = validateGroupForBatchCreate(dto.getHotWordGroupId(), tenantId, platformAdmin); + Set existingWords = findExistingWords(words, groupId); + List existingWordList = words.stream().filter(existingWords::contains).toList(); + List hotWords = words.stream() + .filter(word -> !existingWords.contains(word)) + .map(word -> buildBatchHotWord(word, groupId, dto.getRemark(), userId)) + .toList(); + HotWordBatchCreateResultVO result = new HotWordBatchCreateResultVO(); + result.setExistingWords(existingWordList); + if (hotWords.isEmpty()) { + result.setCreatedCount(0); + return result; + } + + validateGroupCapacityForBatchCreate(groupId, hotWords.size()); + result.setCreatedCount(this.saveBatch(hotWords) ? hotWords.size() : 0); + return result; + } + @Override @Transactional(rollbackFor = Exception.class) public HotWordVO updateHotWord(HotWordDTO hotWordDTO, Long userId, Long tenantId) { HotWord hotWord = this.getById(hotWordDTO.getId()); if (hotWord == null) { - throw new IllegalArgumentException("热词不存在"); + throw new BusinessException("热词不存在"); } String oldWord = hotWord.getWord(); @@ -76,20 +109,20 @@ public class HotWordServiceImpl extends ServiceImpl impl @Transactional(rollbackFor = Exception.class) public Integer updateHotWordGroupBatch(List ids, Long hotWordGroupId, Long tenantId) { if (ids == null || ids.isEmpty()) { - throw new IllegalArgumentException("请选择热词"); + throw new BusinessException("请选择热词"); } Set uniqueIds = ids.stream() .filter(id -> id != null) .collect(Collectors.toCollection(LinkedHashSet::new)); if (uniqueIds.isEmpty()) { - throw new IllegalArgumentException("请选择热词"); + throw new BusinessException("请选择热词"); } List hotWords = this.list(new LambdaQueryWrapper() .in(HotWord::getId, uniqueIds) .eq(HotWord::getTenantId, tenantId)); if (hotWords.size() != uniqueIds.size()) { - throw new IllegalArgumentException("部分热词不存在或无权操作"); + throw new BusinessException("部分热词不存在或无权操作"); } if (hotWordGroupId != null) { @@ -160,16 +193,16 @@ public class HotWordServiceImpl extends ServiceImpl impl } HotWordGroup group = hotWordGroupMapper.selectById(groupId); if (group == null || !tenantId.equals(group.getTenantId())) { - throw new IllegalArgumentException("热词组不存在"); + throw new BusinessException("热词组不存在"); } if (!Integer.valueOf(1).equals(group.getStatus())) { - throw new IllegalArgumentException("热词组已禁用"); + throw new BusinessException("热词组已禁用"); } 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 个热词"); + throw new BusinessException("热词组最多只能包含 200 个热词"); } return group.getId(); } @@ -177,10 +210,10 @@ public class HotWordServiceImpl extends ServiceImpl impl private void validateGroupCapacity(Long groupId, Long tenantId, List movingHotWords) { HotWordGroup group = hotWordGroupMapper.selectById(groupId); if (group == null || !tenantId.equals(group.getTenantId())) { - throw new IllegalArgumentException("热词组不存在"); + throw new BusinessException("热词组不存在"); } if (!Integer.valueOf(1).equals(group.getStatus())) { - throw new IllegalArgumentException("热词组已禁用"); + throw new BusinessException("热词组已禁用"); } Set movingIds = movingHotWords.stream().map(HotWord::getId).collect(Collectors.toSet()); long currentCount = this.count(new LambdaQueryWrapper() @@ -190,10 +223,71 @@ public class HotWordServiceImpl extends ServiceImpl impl .filter(item -> !groupId.equals(item.getHotWordGroupId())) .count(); if (currentCount + incomingCount > MAX_HOT_WORDS_PER_GROUP) { - throw new IllegalArgumentException("热词组最多只能包含 200 个热词"); + throw new BusinessException("热词组最多只能包含 200 个热词"); } } + private Set normalizeWords(List words) { + if (words == null || words.isEmpty()) { + return Collections.emptySet(); + } + return words.stream() + .filter(word -> word != null) + .map(String::trim) + .filter(word -> !word.isEmpty() && !"无".equals(word)) + .collect(Collectors.toCollection(LinkedHashSet::new)); + } + + private Set findExistingWords(Set words, Long groupId) { + return this.list(new LambdaQueryWrapper() + .eq(groupId != null, HotWord::getHotWordGroupId, groupId) + .isNull(groupId == null, HotWord::getHotWordGroupId) + .in(HotWord::getWord, words)) + .stream() + .map(HotWord::getWord) + .collect(Collectors.toSet()); + } + + private Long validateGroupForBatchCreate(Long groupId, Long tenantId, boolean platformAdmin) { + if (groupId == null) { + return null; + } + HotWordGroup group = hotWordGroupMapper.selectById(groupId); + if (group == null || (!platformAdmin && !tenantId.equals(group.getTenantId()))) { + throw new BusinessException("热词组不存在"); + } + if (!Integer.valueOf(ENABLED_STATUS).equals(group.getStatus())) { + throw new BusinessException("热词组已禁用"); + } + return group.getId(); + } + + private void validateGroupCapacityForBatchCreate(Long groupId, int incomingCount) { + if (groupId == null) { + return; + } + long currentCount = this.count(new LambdaQueryWrapper() + .eq(HotWord::getHotWordGroupId, groupId)); + if (currentCount + incomingCount > MAX_HOT_WORDS_PER_GROUP) { + throw new BusinessException("热词组最多只能包含 200 个热词"); + } + } + + private HotWord buildBatchHotWord(String word, Long groupId, String remark, Long userId) { + HotWord hotWord = new HotWord(); + hotWord.setWord(word); + hotWord.setPinyinList(generatePinyin(word)); + hotWord.setMatchStrategy(DEFAULT_MATCH_STRATEGY); + hotWord.setCategory(""); + hotWord.setHotWordGroupId(groupId); + hotWord.setWeight(DEFAULT_WEIGHT); + hotWord.setStatus(ENABLED_STATUS); + hotWord.setIsPublic(1); + hotWord.setCreatorId(userId); + hotWord.setRemark(remark); + return hotWord; + } + private void generateCombinations(List> matrix, int index, String current, List result) { if (index == matrix.size()) { result.add(current.trim()); diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingDomainSupport.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingDomainSupport.java index 41cacd5..6d2b05c 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingDomainSupport.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingDomainSupport.java @@ -8,11 +8,15 @@ import com.imeeting.entity.biz.AiTask; import com.imeeting.entity.biz.HotWordGroup; import com.imeeting.entity.biz.Meeting; import com.imeeting.entity.biz.MeetingTranscript; +import com.imeeting.entity.biz.PromptTemplate; import com.imeeting.event.MeetingCreatedEvent; import com.imeeting.mapper.biz.MeetingTranscriptMapper; +import com.imeeting.dto.biz.AiModelVO; +import com.imeeting.service.biz.AiModelService; import com.imeeting.service.biz.AiTaskService; import com.imeeting.service.biz.HotWordGroupService; import com.imeeting.service.biz.MeetingPointsService; +import com.imeeting.service.biz.PromptTemplateService; import com.imeeting.service.biz.RealtimeMeetingSessionStateService; import com.imeeting.service.biz.MeetingSummaryFileService; import com.imeeting.service.realtime.RealtimeMeetingAudioStorageService; @@ -62,6 +66,8 @@ public class MeetingDomainSupport { private final MeetingPlaybackAudioResolver meetingPlaybackAudioResolver; private final HotWordGroupService hotWordGroupService; private final RealtimeMeetingSessionStateService realtimeMeetingSessionStateService; + private final AiModelService aiModelService; + private final PromptTemplateService promptTemplateService; private final SysParamService sysParamService; @Value("${unisbase.app.upload-path}") @@ -433,6 +439,7 @@ public class MeetingDomainSupport { vo.setOfflineRecordingStatus(meeting.getOfflineRecordingStatus()); vo.setSummaryModelId(meeting.getSummaryModelId()); vo.setPromptId(meeting.getPromptId()); + fillSummaryConfigurationNames(meeting, vo); fillEffectiveHotWordGroup(meeting, vo); vo.setAiCatalogEnabled(resolveAiCatalogEnabled()); vo.setSummaryDetailLevel(normalizeSummaryDetailLevel(meeting.getSummaryDetailLevel())); @@ -475,6 +482,21 @@ public class MeetingDomainSupport { } } + private void fillSummaryConfigurationNames(Meeting meeting, com.imeeting.dto.biz.MeetingVO vo) { + if (meeting.getSummaryModelId() != null) { + AiModelVO summaryModel = aiModelService.getModelById(meeting.getSummaryModelId(), "LLM"); + if (summaryModel != null) { + vo.setSummaryModelName(summaryModel.getModelName()); + } + } + if (meeting.getPromptId() != null) { + PromptTemplate promptTemplate = promptTemplateService.getById(meeting.getPromptId()); + if (promptTemplate != null) { + vo.setPromptName(promptTemplate.getTemplateName()); + } + } + } + private void fillEffectiveHotWordGroup(Meeting meeting, com.imeeting.dto.biz.MeetingVO vo) { Long hotWordGroupId = resolveEffectiveHotWordGroupId(meeting); vo.setHotWordGroupId(hotWordGroupId); diff --git a/frontend/src/api/business/hotword.ts b/frontend/src/api/business/hotword.ts index 02bdf27..7c71380 100644 --- a/frontend/src/api/business/hotword.ts +++ b/frontend/src/api/business/hotword.ts @@ -36,6 +36,18 @@ export interface HotWordBatchGroupDTO { hotWordGroupId?: number; } +export interface HotWordBatchCreateDTO { + tenantId?: number; + words: string[]; + hotWordGroupId?: number; + remark?: string; +} + +export interface HotWordBatchCreateResultVO { + createdCount: number; + existingWords: string[]; +} + export const getHotWordPage = (params: { current: number; size: number; @@ -64,6 +76,13 @@ export const saveHotWord = (data: HotWordDTO) => { ); }; +export const createHotWordBatch = (data: HotWordBatchCreateDTO) => { + return http.post<{ code: string; data: HotWordBatchCreateResultVO; msg: string }>( + "/api/biz/hotword/batch", + data + ); +}; + export const updateHotWord = (data: HotWordDTO) => { return http.put<{ code: string; data: HotWordVO; msg: string }>( "/api/biz/hotword", diff --git a/frontend/src/api/business/hotwordGroup.ts b/frontend/src/api/business/hotwordGroup.ts index d6b7efc..4461fb9 100644 --- a/frontend/src/api/business/hotwordGroup.ts +++ b/frontend/src/api/business/hotwordGroup.ts @@ -14,7 +14,6 @@ export interface HotWordGroupVO { export interface HotWordGroupDTO { id?: number; - tenantId?: number; groupName: string; status: number; remark?: string; @@ -25,7 +24,6 @@ export const getHotWordGroupPage = (params: { size: number; name?: string; status?: number; - tenantId?: number; }) => { return http.get<{ code: string; data: { records: HotWordGroupVO[]; total: number }; msg: string }>( "/api/biz/hotword-group/page", @@ -33,10 +31,9 @@ export const getHotWordGroupPage = (params: { ); }; -export const getHotWordGroupOptions = (tenantId?: number) => { +export const getHotWordGroupOptions = () => { return http.get<{ code: string; data: HotWordGroupVO[]; msg: string }>( "/api/biz/hotword-group/options", - { params: { tenantId } } ); }; @@ -54,9 +51,8 @@ export const updateHotWordGroup = (data: HotWordGroupDTO) => { ); }; -export const deleteHotWordGroup = (id: number, tenantId?: number) => { +export const deleteHotWordGroup = (id: number) => { return http.delete<{ code: string; data: boolean; msg: string }>( - `/api/biz/hotword-group/${id}`, - { params: { tenantId } } + `/api/biz/hotword-group/${id}` ); }; diff --git a/frontend/src/api/business/meeting.ts b/frontend/src/api/business/meeting.ts index 2df2373..ed19cd4 100644 --- a/frontend/src/api/business/meeting.ts +++ b/frontend/src/api/business/meeting.ts @@ -36,7 +36,9 @@ export interface MeetingVO { sourceDeviceMode?: "PUBLIC" | "PRIVATE"; summaryDetailLevel?: SummaryDetailLevel; summaryModelId: number; + summaryModelName?: string; promptId?: number; + promptName?: string; hotWordGroupId?: number; hotWordGroupName?: string; aiCatalogEnabled?: boolean; diff --git a/frontend/src/pages/business/HotWords.tsx b/frontend/src/pages/business/HotWords.tsx index 3311030..506b198 100644 --- a/frontend/src/pages/business/HotWords.tsx +++ b/frontend/src/pages/business/HotWords.tsx @@ -112,7 +112,7 @@ const HotWords: React.FC = () => { const [searchWord, setSearchWord] = useState(""); const [searchCategory, setSearchCategory] = useState(undefined); const [searchGroupId, setSearchGroupId] = useState(undefined); - const [hotWordGroupFilter, setHotWordGroupFilter] = useState("all"); + const [hotWordGroupFilter, setHotWordGroupFilter] = useState("ungrouped"); const [modalVisible, setModalVisible] = useState(false); const [editingId, setEditingId] = useState(null); @@ -196,7 +196,7 @@ const HotWords: React.FC = () => { }; const loadGroupOptions = async () => { - const res = await getHotWordGroupOptions(isPlatformAdmin ? activeTenantId : undefined); + const res = await getHotWordGroupOptions(); setGroupOptions(res.data?.data || []); }; @@ -208,7 +208,6 @@ const HotWords: React.FC = () => { size: groupSize, name: groupSearchName || undefined, status: groupSearchStatus, - tenantId: isPlatformAdmin ? activeTenantId : undefined, }); setGroupData(res.data?.data?.records || []); setGroupTotal(res.data?.data?.total || 0); @@ -315,10 +314,10 @@ const HotWords: React.FC = () => { const values = await groupForm.validateFields(); setGroupSubmitLoading(true); if (editingGroupId) { - await updateHotWordGroup({ ...values, id: editingGroupId, tenantId: isPlatformAdmin ? activeTenantId : undefined }); + await updateHotWordGroup({...values, id: editingGroupId}); message.success("热词组更新成功"); } else { - await saveHotWordGroup({ ...values, tenantId: isPlatformAdmin ? activeTenantId : undefined }); + await saveHotWordGroup(values); message.success("热词组创建成功"); } setGroupEditorVisible(false); @@ -330,7 +329,7 @@ const HotWords: React.FC = () => { const handleDeleteGroup = async (id: number, e?: React.MouseEvent) => { e?.stopPropagation(); - await deleteHotWordGroup(id, isPlatformAdmin ? activeTenantId : undefined); + await deleteHotWordGroup(id); message.success("热词组删除成功"); if (searchGroupId === id) { setSearchGroupId(undefined); @@ -401,23 +400,25 @@ const HotWords: React.FC = () => { setSearchCategory(undefined); setSearchGroupId(undefined); setSelectedGroupName(undefined); - setHotWordGroupFilter("all"); + setHotWordGroupFilter("ungrouped"); bulkGroupForm.resetFields(); setBulkGroupEditorVisible(false); setCurrent(1); - void fetchData({ current: 1, word: "", category: null, groupFilter: "all", searchGroupId: null }); + void fetchData({current: 1, word: "", category: null, groupFilter: "ungrouped", searchGroupId: null}); }; const handleSelectGroup = (item: GroupListItem) => { setSearchGroupId(item.id); setSelectedGroupName(item.id ? item.groupName : undefined); - setHotWordGroupFilter(item.id ?? "all"); + setHotWordGroupFilter(item.id ?? "ungrouped"); setCurrent(1); }; - const hotWordGroupTitle = searchGroupId - ? selectedGroupName || groupData.find((item) => item.id === searchGroupId)?.groupName || groupNameMap[searchGroupId] || "热词列表" - : "全部热词"; + const hotWordGroupTitle = hotWordGroupFilter === "ungrouped" + ? "未分组" + : typeof hotWordGroupFilter === "number" + ? selectedGroupName || groupData.find((item) => item.id === hotWordGroupFilter)?.groupName || groupNameMap[hotWordGroupFilter] || "热词列表" + : "热词列表"; const groupFilterOptions = useMemo( () => [ @@ -428,7 +429,7 @@ const HotWords: React.FC = () => { [groupOptions] ); - const groupListData: GroupListItem[] = [{ id: undefined, groupName: "全部热词" }, ...groupData]; + const groupListData: GroupListItem[] = [{id: undefined, groupName: "未分组"}, ...groupData]; const columns = [ { @@ -558,7 +559,7 @@ const HotWords: React.FC = () => { loading={groupLoading} dataSource={groupListData} renderItem={(item) => { - const isSelected = searchGroupId === item.id; + const isSelected = item.id ? hotWordGroupFilter === item.id : hotWordGroupFilter === "ungrouped"; const actions = []; if (item.id) { actions.push( @@ -611,7 +612,7 @@ const HotWords: React.FC = () => { {item.remark || "暂无备注"} ) - : "查看所有热词" + : "查看未分组热词" } /> @@ -689,7 +690,8 @@ const HotWords: React.FC = () => { className="hotwords-search__category" options={categories.map((c) => ({ label: c.itemLabel, value: c.itemValue }))} /> - diff --git a/frontend/src/pages/business/MeetingDetail.tsx b/frontend/src/pages/business/MeetingDetail.tsx index ab2c211..eccf8ca 100644 --- a/frontend/src/pages/business/MeetingDetail.tsx +++ b/frontend/src/pages/business/MeetingDetail.tsx @@ -49,7 +49,8 @@ import { updateSpeakerInfo, } from '../../api/business/meeting'; import { getAiModelDefault, getAiModelPage, AiModelVO } from '../../api/business/aimodel'; -import { getHotWordPage, getPinyinSuggestion, saveHotWord } from '../../api/business/hotword'; +import {createHotWordBatch} from '../../api/business/hotword'; +import {getHotWordGroupOptions, type HotWordGroupVO} from '../../api/business/hotwordGroup'; import { getPromptPage, PromptTemplateVO } from '../../api/business/prompt'; import { listUsers } from '../../api'; import { useDict } from '../../hooks/useDict'; @@ -248,18 +249,20 @@ const parseBulletList = (content?: string | null) => const parseOverviewSection = (markdown: string) => extractSection(markdown, ['全文概要', '概要', '摘要', '概览']) || markdown.replace(/^---[\s\S]*?---/, '').trim(); +const isValidKeyword = (value: string) => value.trim() !== '' && value.trim() !== '无'; + const parseKeywordsSection = (markdown: string, tags: string) => { const section = extractSection(markdown, ['关键词', '关键字', '标签']); const fromSection = parseBulletList(section) .flatMap((line) => line.split(/[,,、/]/)) .map((item) => item.trim()) - .filter(Boolean); + .filter(isValidKeyword); if (fromSection.length) { return Array.from(new Set(fromSection)).slice(0, 12); } - return Array.from(new Set((tags || '').split(',').map((item) => item.trim()).filter(Boolean))).slice(0, 12); + return Array.from(new Set((tags || '').split(',').map((item) => item.trim()).filter(isValidKeyword))).slice(0, 12); }; const buildMeetingAnalysis = ( @@ -280,7 +283,7 @@ const buildMeetingAnalysis = ( return { overview: String(parsed.overview || '').trim(), keywords: Array.from( - new Set((Array.isArray(parsed.keywords) ? parsed.keywords : []).map((item) => String(item).trim()).filter(Boolean)), + new Set((Array.isArray(parsed.keywords) ? parsed.keywords : []).map((item) => String(item).trim()).filter(isValidKeyword)), ).slice(0, 12), chapters: chapters .map((item: any) => ({ @@ -1227,6 +1230,10 @@ const MeetingDetail: React.FC = () => { const [isEditingSummary, setIsEditingSummary] = useState(false); const [summaryDraft, setSummaryDraft] = useState(''); const [selectedKeywords, setSelectedKeywords] = useState([]); + const [hotWordGroupModalOpen, setHotWordGroupModalOpen] = useState(false); + const [hotWordGroupOptions, setHotWordGroupOptions] = useState([]); + const [selectedHotWordGroupId, setSelectedHotWordGroupId] = useState(); + const [hotWordGroupLoading, setHotWordGroupLoading] = useState(false); const [workspaceTab, setWorkspaceTab] = useState('transcript'); const [addingHotwords, setAddingHotwords] = useState(false); const [editingTranscriptId, setEditingTranscriptId] = useState(null); @@ -1288,15 +1295,20 @@ const MeetingDetail: React.FC = () => { setLoading(false); } }, []); - + 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 analysis = useMemo( () => buildMeetingAnalysis(meeting?.analysis, meeting?.summaryContent, meeting?.tags || ''), [meeting?.analysis, meeting?.summaryContent, meeting?.tags], ); - const expandKeywords = false; + const expandKeywords = true; const visibleKeywords = expandKeywords ? analysis.keywords : analysis.keywords.slice(0, 9); const meetingTags = useMemo( - () => (meeting?.tags?.split(',').map((item) => item.trim()).filter(Boolean) || []), + () => (meeting?.tags?.split(',').map((item) => item.trim()).filter(isValidKeyword) || []), [meeting?.tags], ); const discussionItems = useMemo(() => { @@ -1388,25 +1400,23 @@ const MeetingDetail: React.FC = () => { return buildMeetingPreviewUrl(meetingShareBaseUrl, meetingId); }, [meetingShareBaseUrl, meeting?.id, id]); const summaryModelDisplayName = useMemo(() => { - const matchedModel = llmModels.find((item) => item.id === meeting?.summaryModelId); - if (matchedModel?.modelName) { - return matchedModel.modelName; + if (meeting?.summaryModelName?.trim()) { + return meeting.summaryModelName.trim(); } if (meeting?.summaryModelId) { return `模型 #${meeting.summaryModelId}`; } return '未配置'; - }, [llmModels, meeting?.summaryModelId]); + }, [meeting?.summaryModelId, meeting?.summaryModelName]); const promptDisplayName = useMemo(() => { - const matchedPrompt = prompts.find((item) => item.id === meeting?.promptId); - if (matchedPrompt?.templateName) { - return matchedPrompt.templateName; + if (meeting?.promptName?.trim()) { + return meeting.promptName.trim(); } if (meeting?.promptId) { return `模板 #${meeting.promptId}`; } return '未配置'; - }, [meeting?.promptId, prompts]); + }, [meeting?.promptId, meeting?.promptName]); const hotWordGroupDisplayName = useMemo(() => { if (meeting?.hotWordGroupName?.trim()) { return meeting.hotWordGroupName.trim(); @@ -1582,7 +1592,6 @@ const MeetingDetail: React.FC = () => { useEffect(() => { if (!id) return; fetchData(Number(id)); - loadAiConfigs(); loadUsers(); }, [id, fetchData]); @@ -1648,11 +1657,13 @@ const MeetingDetail: React.FC = () => { getPromptPage({ current: 1, size: 100 }), getAiModelDefault('LLM'), ]); - setLlmModels((modelRes.data?.data?.records || []).filter((item) => item.status === 1)); - setPrompts((promptRes.data?.data?.records || []).filter((item) => item.status === 1)); - summaryForm.setFieldsValue({ summaryModelId: defaultRes.data.data?.id }); + const models = (modelRes.data?.data?.records || []).filter((item) => item.status === 1); + const promptTemplates = (promptRes.data?.data?.records || []).filter((item) => item.status === 1); + setLlmModels(models); + setPrompts(promptTemplates); + return {models, promptTemplates, defaultModelId: defaultRes.data.data?.id}; } catch { - // ignore + return {models: [], promptTemplates: [], defaultModelId: undefined}; } }; @@ -1743,17 +1754,19 @@ const MeetingDetail: React.FC = () => { } }; - const handleOpenSummaryDrawer = () => { + const handleOpenSummaryDrawer = async () => { + const {models, promptTemplates, defaultModelId} = await loadAiConfigs(); summaryForm.setFieldsValue({ summaryModelId: summaryForm.getFieldValue('summaryModelId') ?? meeting?.summaryModelId ?? - llmModels.find((model) => model.isDefault === 1)?.id ?? - llmModels[0]?.id, + defaultModelId ?? + models.find((model) => model.isDefault === 1)?.id ?? + models[0]?.id, promptId: summaryForm.getFieldValue('promptId') ?? meeting?.promptId ?? - prompts[0]?.id, + promptTemplates[0]?.id, userPrompt: meeting?.lastUserPrompt ?? '', summaryDetailLevel: summaryForm.getFieldValue('summaryDetailLevel') ?? @@ -1868,58 +1881,51 @@ const MeetingDetail: React.FC = () => { }); }; + const handleOpenHotWordGroupModal = async () => { + if (!selectedKeywords.length) { + message.warning('请先选择关键词'); + return; + } + + setHotWordGroupLoading(true); + try { + const response = await getHotWordGroupOptions(); + const options = (response.data?.data || []).filter((item) => item.status === 1 && item.hotWordCount < 200); + setHotWordGroupOptions(options); + setSelectedHotWordGroupId(options.some((item) => item.id === meeting?.hotWordGroupId) ? meeting?.hotWordGroupId : options[0]?.id); + setHotWordGroupModalOpen(true); + } catch (error) { + console.error(error); + } finally { + setHotWordGroupLoading(false); + } + }; + const handleAddSelectedHotwords = async () => { - const keywords = selectedKeywords.map((item) => item.trim()).filter(Boolean); + const keywords = selectedKeywords.map((item) => item.trim()).filter(isValidKeyword); if (!keywords.length) { message.warning('请先选择关键词'); return; } + if (selectedHotWordGroupId === undefined) { + message.warning('请选择热词组'); + return; + } setAddingHotwords(true); try { - const existingRes = await getHotWordPage({ current: 1, size: 500, word: '' }); - const existingWords = new Set( - (existingRes.data?.data?.records || []) - .map((item) => item.word?.trim()) - .filter(Boolean), - ); - const toCreate = keywords.filter((item) => !existingWords.has(item)); - - if (!toCreate.length) { - message.info('所选关键词已存在于热词库'); - return; - } - - await Promise.all( - toCreate.map((word) => - (async () => { - let pinyinList: string[] = []; - try { - const pinyinRes = await getPinyinSuggestion(word); - pinyinList = (pinyinRes.data?.data || []).map((item) => item.trim()).filter(Boolean); - } catch { - pinyinList = []; - } - return saveHotWord({ - word, - pinyinList, - matchStrategy: 1, - category: '', - weight: 2, - status: 1, - remark: meeting ? `来源于会议:${meeting.title}` : '来源于会议关键词', - }); - })(), - ), - ); - - const skippedCount = keywords.length - toCreate.length; + const response = await createHotWordBatch({ + words: keywords, + hotWordGroupId: selectedHotWordGroupId || undefined, + remark: meeting ? `来源于会议:${meeting.title}` : '来源于会议关键词', + }); + const result = response.data?.data; + const existingWords = result?.existingWords || []; message.success( - skippedCount > 0 - ? `已新增 ${toCreate.length} 个热词,跳过 ${skippedCount} 个重复项` - : `已新增 ${toCreate.length} 个热词`, + `新增 ${result?.createdCount || 0} 个热词${existingWords.length ? `,[${existingWords.join(', ')}]已存在热词组` : ''}`, ); setSelectedKeywords([]); + setHotWordGroupModalOpen(false); } catch (error) { console.error(error); } finally { @@ -2392,7 +2398,7 @@ const MeetingDetail: React.FC = () => { )} {canRetrySummary && ( - )} @@ -2578,9 +2584,9 @@ const MeetingDetail: React.FC = () => { ghost disabled={!selectedKeywords.length} loading={addingHotwords} - onClick={handleAddSelectedHotwords} + onClick={() => void handleOpenHotWordGroupModal()} > - 加入热词 {selectedKeywords.length > 0 ? `(${selectedKeywords.length})` : ''} + 加入热词组 {selectedKeywords.length > 0 ? `(${selectedKeywords.length})` : ''} )} @@ -4238,6 +4244,33 @@ const MeetingDetail: React.FC = () => { } `} + setHotWordGroupModalOpen(false)} + onOk={() => void handleAddSelectedHotwords()} + confirmLoading={addingHotwords} + okText="加入" + destroyOnHidden + > +
+ +