Compare commits

..

No commits in common. "master" and "fix_0727" have entirely different histories.

72 changed files with 1003 additions and 4735 deletions

View File

@ -0,0 +1,227 @@
# AGENTS.mdBackend
## 一、项目定位
这是一个 **智能语音识别与总结系统的后台服务**,主要职责包括:
* 后台管理(用户 / 角色 / 权限)
* 设备接入与管理
* 任务调度与数据管理
* 对接外部 AI 转录服务(仅接口调用,不实现 AI
本模块为 **Java 后端服务**,不包含前端页面逻辑。
---
## 二、技术栈(必须遵守)
* Java: **17**
* Spring Boot: **3.x**
* Web: Spring MVC
* Security: **Spring Security + JWT**
* ORM: **MyBatis / MyBatis-Plus禁止 Hibernate / JPA**
* Database: **PostgreSQL**
* Cache: Redis
* Build Tool: Maven
⚠️ 禁止引入与以上技术选型冲突的框架与中间件。
---
## 三、架构与包结构约定
### 基础包结构
```
com.xxx.project
├── common # 通用工具、常量、异常
├── config # Spring / 安全 / Web 配置
├── security # JWT、Filter、Security 配置
├── auth # 登录、鉴权
├── user # 用户管理
├── role # 角色管理
├── permission # 权限管理
├── device # 设备管理
├── dict # 字典/配置
└── task # 转录/业务任务
```
### 分层规范
* Controller仅负责协议与参数校验
* Service业务编排与事务边界
* Mapper只写数据库访问
* DTO/VO显式数据模型不透传实体
* 禁止 Controller 直接调用 Mapper
---
## 四、角色与定位
你是一位**务实型后端开发者 Agent**,目标是:
> 以最清晰、最朴素、最可验证的方式交付可工作的 Java 服务。
> 基本原则
> 1. 生成内容必须完整、可运行、不可省略。
> 2. 不允许伪代码。
> 3. 不允许使用"示例代码"字样。
> 4. 不允许省略 import。
> 5. 不允许省略异常处理。
> 6. 所有写操作必须考虑事务控制。
> 7. 所有删除操作必须为逻辑删除is_deleted
> 8. 所有表必须包含:
> - created_at TIMESTAMP(6)
> - updated_at TIMESTAMP(6)
> - is_deleted SMALLINT DEFAULT 0
### 核心理念
* 清晰的意图胜于巧妙的代码
* 显而易见 > 精妙复杂
* 奥卡姆剃刀:不应无必要地增加复杂度
* 组合优于继承
* 接口优于单例
* 显式数据流优于隐式魔法
### 风格约束
* 准确、简洁、可维护
* 小修改**不输出摘要**
* 不炫技、不做“聪明设计”
---
## 五、工作流程(强制)
### 5.1 规划阶段(复杂任务必需)
### 行为约束
1. 在执行任何修改前,必须**阅读并遵守**本项目的设计文档(位于 `docs/design/`)。
2. 所有功能改动都必须更新设计文档
3. 遵循代码风格、目录结构和 Git 工作流规则
需求必须先创建:
`IMPLEMENTATION_PLAN.md`
```
## Stage N: [Name]
Goal:
- 明确可交付物
Success Criteria:
- 可测试的验收标准
Tests:
- 具体测试用例
Status:
- Not Started | In Progress | Complete
```
规则:
* 35 个阶段
* 未完成前不得删除
* 未规划禁止直接写实现
---
### 5.2 实现循环TDD Only
严格顺序:
1. 理解
* 查找 ≥3 个相似实现
* 遵循现有项目约定
2. 测试Red
* 先写失败测试
* 只描述行为
3. 实现Green
* 最小代码通过
* 拒绝过度设计
4. 重构Refactor
* 在测试保护下清理
---
### 5.3 三次机会规则
同一问题最多尝试 **3 次**
若失败,必须停止并输出:
* 已尝试操作
* 完整错误
* 23 个相似方案
* 根本性反思
---
### 5.4. 变更同步规则
当数据库结构发生变更时,必须同步生成:
- Entity
- Mapper
- Service
- Controller
- DTO
- VO
- 前端类型定义
- API 封装
- 权限校验调整
同步修改backend/design/db_schema.md和backend/design/db_schema_pgsql.sql
禁止只修改数据库而不同步代码。
## 六、质量关卡DoD
交付前必须:
* 可编译
* 通过全部测试
* 新功能必有测试
* 无警告
* 不得随意引入新依赖
---
## 七、后端设计准则
* 显式优于隐式
* 数据流可追踪
* 依赖可替换
* 行为可测试
* 错误可观测
**禁止:**
* 魔法单例
* 全局状态
* 过早抽象
* 与技术栈冲突的框架
---
## 八、接口与安全规范
* 统一返回:`Result<T>`
* 必须参数校验
* 认证JWT
* 权限Spring Security
* 日志:结构化
* 异常:统一处理
---
**一句话原则:**
> 用最朴素的设计 + 最小的改动 + 最确定的测试,
> 构建显而易见正确的 Java 后端。

View File

@ -24,7 +24,6 @@
<protobuf.version>3.25.8</protobuf.version>
<protobuf.plugin.version>0.6.1</protobuf.plugin.version>
<os.maven.plugin.version>1.7.1</os.maven.plugin.version>
<unisbase.version>1.0.1</unisbase.version>
</properties>
<dependencies>
@ -167,7 +166,7 @@
<dependency>
<groupId>com.unisbase</groupId>
<artifactId>unisbase-spring-boot-starter</artifactId>
<version>${unisbase.version}</version>
<version>0.1.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
@ -189,10 +188,6 @@
<artifactId>tencentcloud-sdk-java-asr</artifactId>
<version>3.1.1470</version>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
</dependencies>
<build>

View File

@ -4,6 +4,9 @@ public final class MeetingConstants {
public static final String TYPE_OFFLINE = "OFFLINE";
public static final String TYPE_REALTIME = "REALTIME";
public static final String SOURCE_WEB = "WEB";
public static final String SOURCE_ANDROID = "ANDROID";
public static final String DEVICE_MODE_PUBLIC = "PUBLIC";
public static final String DEVICE_MODE_PRIVATE = "PRIVATE";

View File

@ -1,25 +1,15 @@
package com.imeeting.config;
import cn.hutool.core.util.StrUtil;
import com.unisbase.common.ApiResponse;
import com.unisbase.common.exception.BusinessException;
import com.unisbase.common.exception.ErrorCodeEnum;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.support.DefaultMessageSourceResolvable;
import org.springframework.core.MethodParameter;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
import java.util.stream.Collectors;
@RestControllerAdvice
@Slf4j
public class ApiResponseSuccessCodeAdvice implements ResponseBodyAdvice<Object> {
private static final String LEGACY_SUCCESS_CODE = "0";
@ -42,13 +32,4 @@ public class ApiResponseSuccessCodeAdvice implements ResponseBodyAdvice<Object>
}
return body;
}
@ExceptionHandler({MethodArgumentNotValidException.class})
public ApiResponse<Void> handleBusinessException(MethodArgumentNotValidException ex) {
String msg = ex.getBindingResult().getAllErrors()
.stream()
.map(DefaultMessageSourceResolvable::getDefaultMessage)
.collect(Collectors.joining(", "));
return new ApiResponse(ErrorCodeEnum.SYSTEM_ERROR.getCode(), msg, (Object) null);
}
}

View File

@ -6,7 +6,6 @@ import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactor
import org.springframework.boot.web.server.WebServerFactoryCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.boot.web.servlet.ServletContextInitializer;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
@ -16,9 +15,6 @@ import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry
@RequiredArgsConstructor
public class RealtimeMeetingWebSocketConfig implements WebSocketConfigurer {
private static final String TOMCAT_WS_TEXT_BUFFER_SIZE = "org.apache.tomcat.websocket.textBufferSize";
private static final String WS_TEXT_BUFFER_SIZE = "1048576";
private final RealtimeMeetingProxyWebSocketHandler realtimeMeetingProxyWebSocketHandler;
@Override
@ -47,9 +43,4 @@ public class RealtimeMeetingWebSocketConfig implements WebSocketConfigurer {
}
});
}
@Bean
public ServletContextInitializer realtimeWebSocketBufferInitializer() {
return servletContext -> servletContext.setInitParameter(TOMCAT_WS_TEXT_BUFFER_SIZE, WS_TEXT_BUFFER_SIZE);
}
}

View File

@ -91,7 +91,7 @@ public class AndroidAuthController {
try {
refresh = authService.refresh(resolveRefreshToken(request, authorization, androidAccessToken));
} catch (Exception e) {
throw new IllegalArgumentException("刷新令牌已失效,请重新登录");
throw new IllegalArgumentException(e.getMessage());
}
return ApiResponse.ok(refresh);
}

View File

@ -8,7 +8,6 @@ import com.imeeting.dto.android.AndroidAuthContext;
import com.imeeting.dto.android.AndroidOfflineMeetingCreateCommand;
import com.imeeting.dto.android.AndroidMeetingCreateResponse;
import com.imeeting.dto.android.AndroidMeetingConfigVo;
import com.imeeting.dto.android.QtMeetingUpdateCommand;
import com.imeeting.dto.android.AndroidMeetingListItemVO;
import com.imeeting.dto.android.AndroidOfflineMeetingConflictVO;
import com.imeeting.dto.android.AndroidOfflineMeetingFinishRequest;
@ -52,7 +51,6 @@ import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
@ -78,7 +76,6 @@ import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.Arrays;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@ -294,7 +291,7 @@ public class AndroidMeetingController {
loginUser.getTenantId(),
loginUser.getUserId(),
AndroidLoginUserSupport.resolveDisplayName(authContext),
"created",
"all",
null,
AndroidLoginUserSupport.isAdmin(authContext)
);
@ -410,28 +407,6 @@ public class AndroidMeetingController {
return ApiResponse.ok(password);
}
@Operation(summary = "QT更新会议信息")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "更新会议标题、参会人和总结内容",
content = @Content(schema = @Schema(implementation = Boolean.class))
)
})
@PutMapping("/{meetingId}/info")
@Log(value = "QT修改会议信息", type = "Android会议管理")
public ApiResponse<Boolean> updateMeetingForQt(HttpServletRequest request,
@PathVariable Long meetingId,
@Valid @RequestBody QtMeetingUpdateCommand command) {
AndroidRequestLogHelper.logRequest(log, "QT会议", "修改会议信息接口", "meetingId", meetingId);
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
LoginUser loginUser = AndroidLoginUserSupport.requireLoginUser(authContext);
Meeting meeting = meetingAccessService.requireMeeting(meetingId);
meetingAccessService.assertCanEditMeeting(meeting, loginUser);
meetingCommandService.updateMeetingForQt(meetingId, command);
return ApiResponse.ok(true);
}
@Operation(summary = "删除Android会议")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
@ -479,13 +454,7 @@ public class AndroidMeetingController {
? List.of()
: promptTemplateList.getRecords().stream()
.filter(item -> Integer.valueOf(1).equals(item.getStatus()))
.collect(Collectors.toList());
PromptTemplate effectiveDefault = promptTemplateService.findEffectiveUserDefaultTemplate(tenantId, userId);
if (effectiveDefault != null) {
enabledTemplates.sort(Comparator.comparing(
item -> !Objects.equals(item.getId(), effectiveDefault.getId())
));
}
.toList();
resultVo.setTemplateList(enabledTemplates);
PageResult<List<AiModelVO>> modelList = aiModelService.pageModels(1, 1000, null, "LLM", tenantId, false);
List<AiModelVO> enabledModels = modelList.getRecords() == null

View File

@ -12,7 +12,6 @@ import com.imeeting.dto.biz.RealtimeMeetingCompleteDTO;
import com.imeeting.dto.biz.RealtimeMeetingRuntimeProfile;
import com.imeeting.dto.biz.RealtimeMeetingSessionStatusVO;
import com.imeeting.entity.biz.Meeting;
import com.imeeting.enums.MeetingTerminalEnum;
import com.imeeting.service.android.AndroidAuthService;
import com.imeeting.service.biz.MeetingAccessService;
import com.imeeting.service.biz.MeetingAuthorizationService;
@ -78,7 +77,6 @@ public class AndroidMeetingRealtimeController {
meetingAuthorizationService.assertCanCreateMeeting(authContext);
RealtimeMeetingRuntimeProfile runtimeProfile = meetingRuntimeProfileResolver.resolve(
authContext.getTenantId(),
authContext.getUserId(),
command == null ? null : command.getAsrModelId(),
command == null ? null : command.getSummaryModelId(),
command == null ? null : command.getPromptId(),
@ -98,7 +96,7 @@ public class AndroidMeetingRealtimeController {
authContext.getTenantId(),
authContext.getUserId(),
resolveCreatorName(authContext),
MeetingTerminalEnum.CUSTOM_TERMINAL.getCode()
MeetingConstants.SOURCE_ANDROID
);
RealtimeMeetingSessionStatusVO status = realtimeMeetingSessionStateService.getStatus(meeting.getId());
@ -168,7 +166,7 @@ public class AndroidMeetingRealtimeController {
AndroidRequestLogHelper.logRequest(log, "Android实时会议", "暂停实时会议接口", "meetingId", id);
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
Meeting meeting = meetingAccessService.requireMeeting(id);
meetingAuthorizationService.assertCanControlRealtimeMeeting(meeting, authContext, MeetingTerminalEnum.CUSTOM_TERMINAL.getCode());
meetingAuthorizationService.assertCanControlRealtimeMeeting(meeting, authContext, MeetingConstants.SOURCE_ANDROID);
return ApiResponse.ok(realtimeMeetingSessionStateService.pause(id));
}
@ -187,7 +185,7 @@ public class AndroidMeetingRealtimeController {
AndroidRequestLogHelper.logRequest(log, "Android实时会议", "完成实时会议接口", "meetingId", id, "request", dto);
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
Meeting meeting = meetingAccessService.requireMeeting(id);
meetingAuthorizationService.assertCanControlRealtimeMeeting(meeting, authContext, MeetingTerminalEnum.CUSTOM_TERMINAL.getCode());
meetingAuthorizationService.assertCanControlRealtimeMeeting(meeting, authContext, MeetingConstants.SOURCE_ANDROID);
meetingCommandService.completeRealtimeMeeting(
id,
dto != null ? dto.getAudioUrl() : null,

View File

@ -2,8 +2,6 @@ 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;
@ -54,18 +52,6 @@ public class HotWordController {
return ApiResponse.ok(hotWordService.saveHotWord(hotWordDTO, loginUser.getUserId(), targetTenantId));
}
@Operation(summary = "批量新增热词")
@PostMapping("/batch")
@PreAuthorize("isAuthenticated()")
@Log(value = "批量新增热词", type = "热词管理")
public ApiResponse<HotWordBatchCreateResultVO> 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()")

View File

@ -26,8 +26,11 @@ public class HotWordGroupController {
this.hotWordGroupService = hotWordGroupService;
}
private Long resolveTargetTenantId(LoginUser loginUser) {
return loginUser.getTenantId();
private Long resolveTargetTenantId(LoginUser loginUser, Long tenantId) {
if (Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()) && Long.valueOf(0L).equals(tenantId)) {
return 0L;
}
return null;
}
@Operation(summary = "新增热词组")
@ -36,7 +39,7 @@ public class HotWordGroupController {
@Log(value = "新增热词组", type = "热词组管理")
public ApiResponse<HotWordGroupVO> save(@RequestBody HotWordGroupDTO dto) {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
Long targetTenantId = resolveTargetTenantId(loginUser);
Long targetTenantId = resolveTargetTenantId(loginUser, dto.getTenantId());
return ApiResponse.ok(hotWordGroupService.saveGroup(dto, loginUser.getUserId(), targetTenantId));
}
@ -46,7 +49,7 @@ public class HotWordGroupController {
@Log(value = "修改热词组", type = "热词组管理")
public ApiResponse<HotWordGroupVO> update(@RequestBody HotWordGroupDTO dto) {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
Long targetTenantId = resolveTargetTenantId(loginUser);
Long targetTenantId = resolveTargetTenantId(loginUser, dto.getTenantId());
HotWordGroupVO existing = hotWordGroupService.listVisibleOptions(targetTenantId).stream()
.filter(item -> item.getId().equals(dto.getId()))
.findFirst()
@ -54,6 +57,7 @@ public class HotWordGroupController {
if (existing == null) {
return ApiResponse.error("热词组不存在");
}
dto.setTenantId(targetTenantId);
return ApiResponse.ok(hotWordGroupService.updateGroup(dto));
}
@ -61,9 +65,9 @@ public class HotWordGroupController {
@DeleteMapping("/{id}")
@PreAuthorize("isAuthenticated()")
@Log(value = "删除热词组", type = "热词组管理")
public ApiResponse<Boolean> delete(@PathVariable Long id) {
public ApiResponse<Boolean> delete(@PathVariable Long id, @RequestParam(required = false) Long tenantId) {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
Long targetTenantId = resolveTargetTenantId(loginUser);
Long targetTenantId = resolveTargetTenantId(loginUser, tenantId);
return ApiResponse.ok(hotWordGroupService.removeGroupById(id, targetTenantId));
}
@ -74,18 +78,19 @@ 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) Integer status,
@RequestParam(required = false) Long tenantId) {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
Long targetTenantId = resolveTargetTenantId(loginUser);
Long targetTenantId = resolveTargetTenantId(loginUser, tenantId);
return ApiResponse.ok(hotWordGroupService.pageGroups(current, size, name, status, targetTenantId));
}
@Operation(summary = "查询热词组选项")
@GetMapping("/options")
@PreAuthorize("isAuthenticated()")
public ApiResponse<List<HotWordGroupVO>> options() {
public ApiResponse<List<HotWordGroupVO>> options(@RequestParam(required = false) Long tenantId) {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
Long targetTenantId = resolveTargetTenantId(loginUser);
Long targetTenantId = resolveTargetTenantId(loginUser, tenantId);
return ApiResponse.ok(hotWordGroupService.listVisibleOptions(targetTenantId));
}
}

View File

@ -1,6 +1,5 @@
package com.imeeting.controller.biz;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.imeeting.common.MeetingConstants;
import com.imeeting.common.SysParamKeys;
@ -25,7 +24,6 @@ import com.imeeting.dto.biz.UpdateMeetingSummaryCommand;
import com.imeeting.dto.biz.UpdateMeetingTranscriptCommand;
import com.imeeting.entity.biz.AiTask;
import com.imeeting.entity.biz.Meeting;
import com.imeeting.enums.MeetingTerminalEnum;
import com.imeeting.service.biz.AiTaskService;
import com.imeeting.service.biz.MeetingAccessService;
import com.imeeting.service.biz.MeetingCommandService;
@ -222,16 +220,13 @@ public class MeetingController {
@Operation(summary = "获取会议分享配置")
@GetMapping("/share-config")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Map<String, String>> getShareConfig(@RequestParam(name = "meetingId", required = false) Long meetingId) {
public ApiResponse<Map<String, String>> getShareConfig() {
String baseUrl = StringUtils.hasText(h5BaseUrl) ? h5BaseUrl.trim() : "";
if (baseUrl.endsWith("/")) {
baseUrl = baseUrl.substring(0, baseUrl.length() - 1);
}
Map<String, String> result = new HashMap<>();
result.put("h5BaseUrl", baseUrl);
if (meetingId != null) {
result.put("h5PreviewUrl", baseUrl + StrUtil.format("/meetings/{}/preview", meetingId));
}
return ApiResponse.ok(result);
}
@ -247,7 +242,7 @@ public class MeetingController {
loginUser.getTenantId(),
loginUser.getUserId(),
resolveCreatorName(loginUser),
MeetingTerminalEnum.WEB.getCode()
MeetingConstants.SOURCE_WEB
));
}
@ -263,7 +258,7 @@ public class MeetingController {
loginUser.getTenantId(),
loginUser.getUserId(),
resolveCreatorName(loginUser),
MeetingTerminalEnum.WEB.getCode()
MeetingConstants.SOURCE_WEB
));
}
@ -410,7 +405,7 @@ public class MeetingController {
public ApiResponse<RealtimeMeetingSessionStatusVO> pauseRealtimeMeeting(@PathVariable Long id) {
LoginUser loginUser = currentLoginUser();
Meeting meeting = meetingAccessService.requireMeeting(id);
meetingAccessService.assertCanControlRealtimeMeeting(meeting, loginUser, MeetingTerminalEnum.WEB.getCode());
meetingAccessService.assertCanControlRealtimeMeeting(meeting, loginUser, MeetingConstants.SOURCE_WEB);
return ApiResponse.ok(realtimeMeetingSessionStateService.pause(id));
}
@ -430,7 +425,7 @@ public class MeetingController {
command.getEnableItn(),
command.getEnableTextRefine(),
command.getSaveAudio(),
command.getHotWordGroupId(),
command.getHotwords(),
loginUser
));
}
@ -441,7 +436,7 @@ public class MeetingController {
public ApiResponse<Boolean> completeRealtimeMeeting(@PathVariable Long id, @RequestBody(required = false) RealtimeMeetingCompleteDTO dto) {
LoginUser loginUser = currentLoginUser();
Meeting meeting = meetingAccessService.requireMeeting(id);
meetingAccessService.assertCanControlRealtimeMeeting(meeting, loginUser, MeetingTerminalEnum.WEB.getCode());
meetingAccessService.assertCanControlRealtimeMeeting(meeting, loginUser, MeetingConstants.SOURCE_WEB);
meetingCommandService.completeRealtimeMeeting(
id,
dto != null ? dto.getAudioUrl() : null,

View File

@ -119,37 +119,6 @@ public class PromptTemplateController {
return ApiResponse.ok(true);
}
@Operation(summary = "设置默认提示词模板")
@PutMapping("/{id}/default")
@PreAuthorize("isAuthenticated()")
@Log(value = "设置默认提示词模板", type = "提示词模板管理")
public ApiResponse<Boolean> setDefault(@PathVariable Long id) {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
boolean success = promptTemplateService.setUserDefaultTemplate(
id,
loginUser.getTenantId(),
loginUser.getUserId(),
loginUser.getIsPlatformAdmin(),
loginUser.getIsTenantAdmin()
);
return success ? ApiResponse.ok(true) : ApiResponse.error("模板不存在、不可用或无权限访问");
}
@Operation(summary = "取消默认提示词模板")
@DeleteMapping("/{id}/default")
@PreAuthorize("isAuthenticated()")
@Log(value = "取消默认提示词模板", type = "提示词模板管理")
public ApiResponse<Boolean> clearDefault(@PathVariable Long id) {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
return ApiResponse.ok(promptTemplateService.clearUserDefaultTemplate(
id,
loginUser.getTenantId(),
loginUser.getUserId(),
loginUser.getIsPlatformAdmin(),
loginUser.getIsTenantAdmin()
));
}
@Operation(summary = "删除提示词模板")
@DeleteMapping("/{id}")
@PreAuthorize("isAuthenticated()")

View File

@ -1,96 +0,0 @@
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<MeetingVO> 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();
}
}

View File

@ -29,7 +29,7 @@ public class CreateMeetingCommand {
@NotBlank(message = "音频地址不能为空")
private String audioUrl;
// @NotNull(message = "asrModelId must not be null")
@NotNull(message = "asrModelId must not be null")
private Long asrModelId;
@NotNull(message = "summaryModelId must not be null")
@ -37,7 +37,7 @@ public class CreateMeetingCommand {
private Long chapterModelId;
@NotNull(message = "总结模板不能为空")
@NotNull(message = "promptId must not be null")
private Long promptId;
private Long hotWordGroupId;

View File

@ -1,23 +0,0 @@
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<String> words;
@Schema(description = "所属热词组 ID为空表示未分组")
private Long hotWordGroupId;
@Schema(description = "备注")
private String remark;
}

View File

@ -1,17 +0,0 @@
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<String> existingWords;
}

View File

@ -9,10 +9,10 @@ import java.util.List;
@Schema(description = "热词请求参数")
public class HotWordDTO {
@Schema(description = "热词ID")
@Schema(description = "热词 ID")
private Long id;
@Schema(description = "租户ID平台管理员可传0表示平台范围")
@Schema(description = "租户 ID平台管理员可传 0 表示平台范围")
private Long tenantId;
@Schema(description = "热词内容")
@ -27,7 +27,7 @@ public class HotWordDTO {
@Schema(description = "热词分类")
private String category;
@Schema(description = "所属热词组ID")
@Schema(description = "所属热词组 ID")
private Long hotWordGroupId;
@Schema(description = "权重")

View File

@ -7,10 +7,10 @@ import lombok.Data;
@Schema(description = "热词组请求参数")
public class HotWordGroupDTO {
@Schema(description = "热词组ID")
@Schema(description = "热词组 ID")
private Long id;
@Schema(description = "租户ID平台管理员可传0表示平台范围")
@Schema(description = "租户 ID平台管理员可传 0 表示平台范围")
private Long tenantId;
@Schema(description = "热词组名称")

View File

@ -9,16 +9,16 @@ import java.time.LocalDateTime;
@Schema(description = "热词组信息")
public class HotWordGroupVO {
@Schema(description = "热词组ID")
@Schema(description = "热词组 ID")
private Long id;
@Schema(description = "租户ID")
@Schema(description = "租户 ID")
private Long tenantId;
@Schema(description = "热词组名称")
private String groupName;
@Schema(description = "创建ID")
@Schema(description = "创建ID")
private Long creatorId;
@Schema(description = "状态1-启用0-禁用")

View File

@ -10,7 +10,7 @@ import java.util.List;
@Schema(description = "热词信息")
public class HotWordVO {
@Schema(description = "热词ID")
@Schema(description = "热词 ID")
private Long id;
@Schema(description = "热词内容")
@ -22,7 +22,7 @@ public class HotWordVO {
@Schema(description = "是否公开,当前固定为公开")
private Integer isPublic;
@Schema(description = "创建ID")
@Schema(description = "创建ID")
private Long creatorId;
@Schema(description = "匹配策略")
@ -31,7 +31,7 @@ public class HotWordVO {
@Schema(description = "热词分类")
private String category;
@Schema(description = "所属热词组ID")
@Schema(description = "所属热词组 ID")
private Long hotWordGroupId;
@Schema(description = "所属热词组名称")

View File

@ -1,19 +0,0 @@
package com.imeeting.dto.biz;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Schema(description = "会议参会人信息")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class MeetingParticipantVO {
@Schema(description = "用户 ID")
private Long userId;
@Schema(description = "用户名称")
private String displayName;
}

View File

@ -42,9 +42,6 @@ public class MeetingVO {
@Schema(description = "参会人ID列表")
private List<Long> participantIds;
@Schema(description = "参会人列表ID 与名称一一对应")
private List<MeetingParticipantVO> participantUsers;
@Schema(description = "标签串")
private String tags;
@ -75,15 +72,9 @@ 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;

View File

@ -2,6 +2,9 @@ package com.imeeting.dto.biz;
import lombok.Data;
import java.util.List;
import java.util.Map;
@Data
public class OpenRealtimeSocketSessionCommand {
private Long asrModelId;
@ -12,5 +15,5 @@ public class OpenRealtimeSocketSessionCommand {
private Boolean enableItn;
private Boolean enableTextRefine;
private Boolean saveAudio;
private Long hotWordGroupId;
private List<Map<String, Object>> hotwords;
}

View File

@ -30,14 +30,6 @@ public class PromptTemplateVO {
private String hotWordGroupName;
@Schema(description = "绑定热词列表")
private List<String> hotWords;
@Schema(description = "是否为当前用户默认模板")
private Boolean isDefault;
@Schema(description = "默认模板来源PERSONAL-个人TENANT-租户PLATFORM-平台")
private String defaultScope;
@Schema(description = "是否为模板所属层级默认模板")
private Boolean isTemplateDefault;
@Schema(description = "默认模板当前是否有效")
private Boolean defaultAvailable;
@Schema(description = "使用次数")
private Integer usageCount;
@Schema(description = "提示词正文")

View File

@ -27,7 +27,7 @@ public class HotWord extends BaseEntity {
@Schema(description = "是否公共热词")
private Integer isPublic;
@Schema(description = "创建ID")
@Schema(description = "创建ID")
private Long creatorId;
@TableField(typeHandler = JacksonTypeHandler.class)
@ -40,7 +40,7 @@ public class HotWord extends BaseEntity {
@Schema(description = "热词分类")
private String category;
@Schema(description = "所属热词组ID")
@Schema(description = "所属热词组 ID")
private Long hotWordGroupId;
@Schema(description = "权重")

View File

@ -15,13 +15,13 @@ import lombok.EqualsAndHashCode;
public class HotWordGroup extends BaseEntity {
@TableId(value = "id", type = IdType.AUTO)
@Schema(description = "热词组ID")
@Schema(description = "热词组 ID")
private Long id;
@Schema(description = "热词组名称")
private String groupName;
@Schema(description = "创建ID")
@Schema(description = "创建ID")
private Long creatorId;
@Schema(description = "备注")

View File

@ -29,9 +29,6 @@ public class PromptTemplate extends BaseEntity {
@Schema(description = "是否系统内置")
private Integer isSystem;
@Schema(description = "是否为所属层级默认模板1-是0-否")
private Integer isDefault;
@Schema(description = "创建人ID")
private Long creatorId;

View File

@ -23,7 +23,4 @@ public class PromptTemplateUserConfig extends BaseEntity {
@Schema(description = "模板ID")
private Long templateId;
@Schema(description = "是否为当前用户默认模板1-是0-否")
private Integer isDefault;
}

View File

@ -1,56 +0,0 @@
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);
}
}

View File

@ -13,7 +13,6 @@ import com.imeeting.entity.biz.AiTask;
import com.imeeting.entity.biz.LlmModel;
import com.imeeting.entity.biz.Meeting;
import com.imeeting.entity.biz.MeetingTranscript;
import com.imeeting.enums.MeetingTerminalEnum;
import com.imeeting.enums.MeetingStatusEnum;
import com.imeeting.mapper.biz.LlmModelMapper;
import com.imeeting.mapper.biz.MeetingTranscriptMapper;
@ -117,7 +116,6 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ
: MeetingConstants.SUMMARY_DETAIL_STANDARD;
RealtimeMeetingRuntimeProfile runtimeProfile = runtimeProfileResolver.resolve(
tenantId,
creatorUserId,
null,
requestedSummaryModelId,
requestedPromptId,
@ -139,7 +137,7 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ
normalizeTags(request.getTags()),
null,
MeetingConstants.TYPE_OFFLINE,
MeetingTerminalEnum.CUSTOM_TERMINAL.getCode(),
MeetingConstants.SOURCE_ANDROID,
tenantId,
creatorUserId,
resolvedCreatorName,
@ -203,7 +201,6 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ
RealtimeMeetingRuntimeProfile profile = runtimeProfileResolver.resolve(
loginUser.getTenantId(),
loginUser.getUserId(),
null,
effectiveSummaryModelId,
effectivePromptId,
@ -281,7 +278,6 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ
}
RealtimeMeetingRuntimeProfile profile = runtimeProfileResolver.resolve(
meeting.getTenantId(),
loginUser.getUserId(),
null,
effectiveSummaryModelId,
effectivePromptId,

View File

@ -1,8 +1,6 @@
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;
@ -11,8 +9,6 @@ import java.util.List;
public interface HotWordService extends IService<HotWord> {
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<Long> ids, Long hotWordGroupId, Long tenantId);
List<String> generatePinyin(String word);

View File

@ -12,21 +12,12 @@ import com.imeeting.dto.biz.PublicDeviceMeetingCreateCommand;
import com.imeeting.dto.biz.RealtimeTranscriptItemDTO;
import com.imeeting.dto.biz.UpdateMeetingBasicCommand;
import com.imeeting.dto.biz.UpdateMeetingTranscriptCommand;
import com.imeeting.dto.android.QtMeetingUpdateCommand;
import java.util.List;
public interface MeetingCommandService {
MeetingVO createMeeting(CreateMeetingCommand command, Long tenantId, Long creatorId, String creatorName, String meetingSource);
MeetingVO createMeeting(CreateMeetingCommand command,
Long tenantId,
Long creatorId,
String creatorName,
String meetingSource,
String sourceDeviceCode,
String sourceDeviceMode);
MeetingVO createRealtimeMeeting(CreateRealtimeMeetingCommand command, Long tenantId, Long creatorId, String creatorName, String meetingSource);
MeetingVO createPublicDeviceMeeting(PublicDeviceMeetingCreateCommand command,
@ -55,8 +46,6 @@ public interface MeetingCommandService {
void updateSummaryContent(Long meetingId, String summaryContent);
void updateMeetingForQt(Long meetingId, QtMeetingUpdateCommand command);
void reSummary(Long meetingId, Long summaryModelId, Long chapterModelId, Long promptId, String userPrompt, String summaryDetailLevel);
void retryTranscription(Long meetingId);

View File

@ -6,7 +6,6 @@ import java.util.List;
public interface MeetingRuntimeProfileResolver {
RealtimeMeetingRuntimeProfile resolve(Long tenantId,
Long userId,
Long asrModelId,
Long summaryModelId,
Long promptId,

View File

@ -17,10 +17,4 @@ public interface PromptTemplateService extends IService<PromptTemplate> {
Long tenantId, Long userId, Boolean isPlatformAdmin, Boolean isTenantAdmin);
boolean updateUserTemplateStatus(Long templateId, Integer status, Long tenantId, Long userId, Boolean isPlatformAdmin, Boolean isTenantAdmin);
boolean isTemplateEnabledForUser(Long templateId, Long tenantId, Long userId, Boolean isPlatformAdmin, Boolean isTenantAdmin);
boolean setUserDefaultTemplate(Long templateId, Long tenantId, Long userId, Boolean isPlatformAdmin, Boolean isTenantAdmin);
boolean clearUserDefaultTemplate(Long templateId, Long tenantId, Long userId, Boolean isPlatformAdmin, Boolean isTenantAdmin);
PromptTemplate findEffectiveUserDefaultTemplate(Long tenantId, Long userId);
}

View File

@ -4,11 +4,14 @@ import com.imeeting.dto.biz.RealtimeSocketSessionData;
import com.imeeting.dto.biz.RealtimeSocketSessionVO;
import com.unisbase.security.LoginUser;
import java.util.List;
import java.util.Map;
public interface RealtimeMeetingSocketSessionService {
RealtimeSocketSessionVO createSession(Long meetingId, Long asrModelId, String mode, String language,
Integer useSpkId, Boolean enablePunctuation, Boolean enableItn,
Boolean enableTextRefine, Boolean saveAudio,
Long hotWordGroupId, LoginUser loginUser);
List<Map<String, Object>> hotwords, LoginUser loginUser);
RealtimeSocketSessionData getSessionData(String sessionToken);
}

View File

@ -1610,7 +1610,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
} catch (Exception ex) {
failPendingSummaryTask(summaryTask, ex.getMessage());
this.updateById(summaryTask);
updateProgress(meeting.getId(), -1, "更新状态失败 " + ex.getMessage(), 0);
updateProgress(meeting.getId(), -1, "闂佽崵鍠愰悷杈╃不閹达絻浜归柛灞剧☉缁剁偤鏌″搴″箹闁?n8n 缂傚倸鍊搁崐褰掓偋濡ゅ啯鏆滈柟鐐綑缁剁偤寮堕崼顐函鐞? " + ex.getMessage(), 0);
log.error("Failed to trigger external n8n webhook for meeting {}", meeting.getId(), ex);
}
}

View File

@ -146,5 +146,4 @@ public class HotWordGroupServiceImpl extends ServiceImpl<HotWordGroupMapper, Hot
vo.setUpdatedAt(entity.getUpdatedAt());
return vo;
}
}

View File

@ -3,8 +3,6 @@ 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;
@ -12,9 +10,6 @@ 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 com.unisbase.dto.SysDictItemDTO;
import com.unisbase.service.SysDictItemService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import net.sourceforge.pinyin4j.PinyinHelper;
@ -37,14 +32,9 @@ import java.util.stream.Collectors;
@RequiredArgsConstructor
public class HotWordServiceImpl extends ServiceImpl<HotWordMapper, HotWord> implements HotWordService {
private static final String HOT_WORD_GROUP_LIMIT_DICT_TYPE = "biz_hotword_group_limit";
private static final int DEFAULT_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 static final int MAX_HOT_WORDS_PER_GROUP = 200;
private final HotWordGroupMapper hotWordGroupMapper;
private final SysDictItemService sysDictItemService;
@Override
@Transactional(rollbackFor = Exception.class)
@ -62,39 +52,12 @@ public class HotWordServiceImpl extends ServiceImpl<HotWordMapper, HotWord> impl
return toVO(hotWord);
}
@Override
@Transactional(rollbackFor = Exception.class)
public HotWordBatchCreateResultVO saveHotWordsBatch(HotWordBatchCreateDTO dto, Long userId, Long tenantId, boolean platformAdmin) {
Set<String> words = normalizeWords(dto.getWords());
if (words.isEmpty()) {
throw new BusinessException("请选择有效热词");
}
Long groupId = validateGroupForBatchCreate(dto.getHotWordGroupId(), tenantId, platformAdmin);
Set<String> existingWords = findExistingWords(words, groupId);
List<String> existingWordList = words.stream().filter(existingWords::contains).toList();
List<HotWord> 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 BusinessException("热词不存在");
throw new IllegalArgumentException("热词不存在");
}
String oldWord = hotWord.getWord();
@ -113,20 +76,20 @@ public class HotWordServiceImpl extends ServiceImpl<HotWordMapper, HotWord> impl
@Transactional(rollbackFor = Exception.class)
public Integer updateHotWordGroupBatch(List<Long> ids, Long hotWordGroupId, Long tenantId) {
if (ids == null || ids.isEmpty()) {
throw new BusinessException("请选择热词");
throw new IllegalArgumentException("请选择热词");
}
Set<Long> uniqueIds = ids.stream()
.filter(id -> id != null)
.collect(Collectors.toCollection(LinkedHashSet::new));
if (uniqueIds.isEmpty()) {
throw new BusinessException("请选择热词");
throw new IllegalArgumentException("请选择热词");
}
List<HotWord> hotWords = this.list(new LambdaQueryWrapper<HotWord>()
.in(HotWord::getId, uniqueIds)
.eq(HotWord::getTenantId, tenantId));
if (hotWords.size() != uniqueIds.size()) {
throw new BusinessException("部分热词不存在或无权操作");
throw new IllegalArgumentException("部分热词不存在或无权操作");
}
if (hotWordGroupId != null) {
@ -197,17 +160,16 @@ public class HotWordServiceImpl extends ServiceImpl<HotWordMapper, HotWord> impl
}
HotWordGroup group = hotWordGroupMapper.selectById(groupId);
if (group == null || !tenantId.equals(group.getTenantId())) {
throw new BusinessException("热词组不存在");
throw new IllegalArgumentException("热词组不存在");
}
if (!Integer.valueOf(1).equals(group.getStatus())) {
throw new BusinessException("热词组已禁用");
throw new IllegalArgumentException("热词组已禁用");
}
long currentCount = this.count(new LambdaQueryWrapper<HotWord>()
.eq(HotWord::getHotWordGroupId, groupId)
.ne(currentHotWordId != null, HotWord::getId, currentHotWordId));
int maxHotWordsPerGroup = getMaxHotWordsPerGroup();
if (currentCount >= maxHotWordsPerGroup) {
throwGroupCapacityExceeded(maxHotWordsPerGroup);
if (currentCount >= MAX_HOT_WORDS_PER_GROUP) {
throw new IllegalArgumentException("热词组最多只能包含 200 个热词");
}
return group.getId();
}
@ -215,10 +177,10 @@ public class HotWordServiceImpl extends ServiceImpl<HotWordMapper, HotWord> impl
private void validateGroupCapacity(Long groupId, Long tenantId, List<HotWord> movingHotWords) {
HotWordGroup group = hotWordGroupMapper.selectById(groupId);
if (group == null || !tenantId.equals(group.getTenantId())) {
throw new BusinessException("热词组不存在");
throw new IllegalArgumentException("热词组不存在");
}
if (!Integer.valueOf(1).equals(group.getStatus())) {
throw new BusinessException("热词组已禁用");
throw new IllegalArgumentException("热词组已禁用");
}
Set<Long> movingIds = movingHotWords.stream().map(HotWord::getId).collect(Collectors.toSet());
long currentCount = this.count(new LambdaQueryWrapper<HotWord>()
@ -227,99 +189,11 @@ public class HotWordServiceImpl extends ServiceImpl<HotWordMapper, HotWord> impl
long incomingCount = movingHotWords.stream()
.filter(item -> !groupId.equals(item.getHotWordGroupId()))
.count();
int maxHotWordsPerGroup = getMaxHotWordsPerGroup();
if (currentCount + incomingCount > maxHotWordsPerGroup) {
throwGroupCapacityExceeded(maxHotWordsPerGroup);
if (currentCount + incomingCount > MAX_HOT_WORDS_PER_GROUP) {
throw new IllegalArgumentException("热词组最多只能包含 200 个热词");
}
}
private Set<String> normalizeWords(List<String> 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<String> findExistingWords(Set<String> words, Long groupId) {
return this.list(new LambdaQueryWrapper<HotWord>()
.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<HotWord>()
.eq(HotWord::getHotWordGroupId, groupId));
int maxHotWordsPerGroup = getMaxHotWordsPerGroup();
if (currentCount + incomingCount > maxHotWordsPerGroup) {
throwGroupCapacityExceeded(maxHotWordsPerGroup);
}
}
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 int getMaxHotWordsPerGroup() {
List<SysDictItemDTO> items = sysDictItemService.getItemsByTypeCode(HOT_WORD_GROUP_LIMIT_DICT_TYPE);
if (items == null || items.isEmpty()) {
return DEFAULT_MAX_HOT_WORDS_PER_GROUP;
}
for (SysDictItemDTO item : items) {
if (item == null || item.getItemValue() == null) {
continue;
}
try {
int configuredLimit = Integer.parseInt(item.getItemValue().trim());
if (configuredLimit > 0) {
return configuredLimit;
}
} catch (NumberFormatException exception) {
log.warn("热词组上限字典配置值非法: {}", item.getItemValue());
}
}
return DEFAULT_MAX_HOT_WORDS_PER_GROUP;
}
private void throwGroupCapacityExceeded(int maxHotWordsPerGroup) {
throw new BusinessException("热词组最多只能包含 " + maxHotWordsPerGroup + " 个热词");
}
private void generateCombinations(List<List<String>> matrix, int index, String current, List<String> result) {
if (index == matrix.size()) {
result.add(current.trim());
@ -364,5 +238,4 @@ public class HotWordServiceImpl extends ServiceImpl<HotWordMapper, HotWord> impl
}
return vo;
}
}

View File

@ -2,7 +2,6 @@ package com.imeeting.service.biz.impl;
import com.imeeting.common.MeetingConstants;
import com.imeeting.entity.biz.Meeting;
import com.imeeting.enums.MeetingTerminalEnum;
import com.imeeting.mapper.biz.MeetingMapper;
import com.imeeting.service.biz.MeetingAccessService;
import com.unisbase.security.LoginUser;
@ -107,10 +106,6 @@ public class MeetingAccessServiceImpl implements MeetingAccessService {
if (meeting.getMeetingSource() == null || meeting.getMeetingSource().isBlank()) {
return;
}
if (MeetingTerminalEnum.isCustomTerminalSource(meeting.getMeetingSource())
&& MeetingTerminalEnum.isCustomTerminalSource(currentPlatform)) {
return;
}
if (!meeting.getMeetingSource().equalsIgnoreCase(currentPlatform)) {
throw new RuntimeException("不允许跨平台接管实时会议");
}

View File

@ -3,7 +3,6 @@ package com.imeeting.service.biz.impl;
import com.imeeting.common.MeetingConstants;
import com.imeeting.dto.android.AndroidAuthContext;
import com.imeeting.entity.biz.Meeting;
import com.imeeting.enums.MeetingTerminalEnum;
import com.imeeting.service.biz.MeetingAccessService;
import com.imeeting.service.biz.MeetingAuthorizationService;
import com.unisbase.security.LoginUser;
@ -48,7 +47,7 @@ public class MeetingAuthorizationServiceImpl implements MeetingAuthorizationServ
}
if (meeting.getMeetingSource() != null
&& !meeting.getMeetingSource().isBlank()
&& !isSameRealtimePlatform(meeting.getMeetingSource(), currentPlatform)) {
&& !meeting.getMeetingSource().equalsIgnoreCase(currentPlatform)) {
throw new RuntimeException("不允许跨平台接管实时会议");
}
return;
@ -75,8 +74,4 @@ public class MeetingAuthorizationServiceImpl implements MeetingAuthorizationServ
loginUser.setDisplayName(authContext.getDisplayName());
return loginUser;
}
private boolean isSameRealtimePlatform(String meetingSource, String currentPlatform) {
return MeetingTerminalEnum.isSameTerminal(meetingSource, currentPlatform);
}
}

View File

@ -25,14 +25,12 @@ import com.imeeting.dto.biz.RealtimeMeetingSessionStatusVO;
import com.imeeting.dto.biz.RealtimeTranscriptItemDTO;
import com.imeeting.dto.biz.UpdateMeetingBasicCommand;
import com.imeeting.dto.biz.UpdateMeetingTranscriptCommand;
import com.imeeting.dto.android.QtMeetingUpdateCommand;
import com.imeeting.entity.biz.AiTask;
import com.imeeting.entity.biz.HotWord;
import com.imeeting.entity.biz.Meeting;
import com.imeeting.entity.biz.MeetingTranscript;
import com.imeeting.entity.biz.MeetingTranscriptChapterVersion;
import com.imeeting.enums.BusinessErrorCodeEnum;
import com.imeeting.enums.MeetingTerminalEnum;
import com.imeeting.enums.MeetingStatusEnum;
import com.imeeting.service.android.AndroidPendingMeetingDraftService;
import com.imeeting.service.android.AndroidPushMessageService;
@ -154,19 +152,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
@Override
@Transactional(rollbackFor = Exception.class)
public MeetingVO createMeeting(CreateMeetingCommand command, Long tenantId, Long creatorId, String creatorName, String meetingSource) {
return createMeeting(command, tenantId, creatorId, creatorName, meetingSource, null, null);
}
@Override
@Transactional(rollbackFor = Exception.class)
public MeetingVO createMeeting(CreateMeetingCommand command,
Long tenantId,
Long creatorId,
String creatorName,
String meetingSource,
String sourceDeviceCode,
String sourceDeviceMode) {
RealtimeMeetingRuntimeProfile runtimeProfile = resolveCreateProfile(command, tenantId, creatorId);
RealtimeMeetingRuntimeProfile runtimeProfile = resolveCreateProfile(command, tenantId);
Long hostUserId = resolveHostUserId(command.getHostUserId(), creatorId);
String resolvedCreatorName = resolveMeetingUserName(creatorId, creatorName);
String hostName = resolveMeetingUserName(hostUserId, resolvedCreatorName);
@ -174,7 +160,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
Meeting meeting = meetingDomainSupport.initMeeting(command.getTitle(), command.getMeetingTime(), command.getParticipants(), command.getTags(),
command.getAudioUrl(), MeetingConstants.TYPE_OFFLINE, meetingSource, tenantId, creatorId, resolvedCreatorName,
hostUserId, hostName, runtimeProfile.getResolvedSummaryModelId(), runtimeProfile.getResolvedPromptId(),
runtimeProfile.getResolvedHotWordGroupId(), summaryDetailLevel, 0, sourceDeviceCode, sourceDeviceMode);
runtimeProfile.getResolvedHotWordGroupId(), summaryDetailLevel, 0);
meetingService.save(meeting);
AiTask asrTask = new AiTask();
@ -246,7 +232,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
@Override
@Transactional(rollbackFor = Exception.class)
public MeetingVO createRealtimeMeeting(CreateRealtimeMeetingCommand command, Long tenantId, Long creatorId, String creatorName, String meetingSource) {
RealtimeMeetingRuntimeProfile runtimeProfile = resolveCreateProfile(command, tenantId, creatorId);
RealtimeMeetingRuntimeProfile runtimeProfile = resolveCreateProfile(command, tenantId);
Long hostUserId = resolveHostUserId(command.getHostUserId(), creatorId);
String resolvedCreatorName = resolveMeetingUserName(creatorId, creatorName);
String hostName = resolveMeetingUserName(hostUserId, resolvedCreatorName);
@ -300,7 +286,6 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
String deviceCode) {
RealtimeMeetingRuntimeProfile runtimeProfile = meetingRuntimeProfileResolver.resolve(
tenantId,
creatorId,
command.getAsrModelId(),
command.getSummaryModelId(),
command.getPromptId(),
@ -325,7 +310,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
command.getTags(),
null,
MeetingConstants.TYPE_OFFLINE,
MeetingTerminalEnum.CUSTOM_TERMINAL.getCode(),
MeetingConstants.SOURCE_ANDROID,
tenantId,
creatorId,
resolvedCreatorName,
@ -768,23 +753,6 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
meetingSummaryFileService.updateSummaryContent(meeting, summaryContent);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void updateMeetingForQt(Long meetingId, QtMeetingUpdateCommand command) {
Meeting meeting = meetingService.getById(meetingId);
if (meeting == null) {
throw new RuntimeException("会议不存在");
}
meetingService.update(new LambdaUpdateWrapper<Meeting>()
.eq(Meeting::getId, meetingId)
.set(Meeting::getTitle, command.getTitle().trim())
.set(Meeting::getParticipants, command.getParticipantIds().stream()
.filter(Objects::nonNull)
.map(String::valueOf)
.collect(Collectors.joining(","))));
meetingSummaryFileService.updateSummaryContent(meeting, command.getSummaryContent());
}
@Override
@Transactional(rollbackFor = Exception.class)
public MeetingTranscriptChapterImportResultVO importTranscriptChapters(MeetingTranscriptChapterImportDTO command) {
@ -1561,10 +1529,9 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
return resumeConfig;
}
private RealtimeMeetingRuntimeProfile resolveCreateProfile(CreateMeetingCommand command, Long tenantId, Long userId) {
private RealtimeMeetingRuntimeProfile resolveCreateProfile(CreateMeetingCommand command, Long tenantId) {
return meetingRuntimeProfileResolver.resolve(
tenantId,
userId,
command.getAsrModelId(),
command.getSummaryModelId(),
command.getPromptId(),
@ -1580,10 +1547,9 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
);
}
private RealtimeMeetingRuntimeProfile resolveCreateProfile(CreateRealtimeMeetingCommand command, Long tenantId, Long userId) {
private RealtimeMeetingRuntimeProfile resolveCreateProfile(CreateRealtimeMeetingCommand command, Long tenantId) {
return meetingRuntimeProfileResolver.resolve(
tenantId,
userId,
command.getAsrModelId(),
command.getSummaryModelId(),
command.getPromptId(),

View File

@ -1,28 +1,24 @@
package com.imeeting.service.biz.impl;
import cn.hutool.core.date.StopWatch;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.imeeting.common.MeetingConstants;
import com.imeeting.common.SysParamKeys;
import com.imeeting.entity.biz.AiTask;
import com.imeeting.entity.biz.HotWordGroup;
import com.imeeting.entity.biz.Meeting;
import com.imeeting.entity.biz.PromptTemplate;
import com.imeeting.dto.biz.MeetingParticipantVO;
import com.imeeting.entity.biz.MeetingTranscript;
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;
import com.unisbase.entity.SysUser;
import com.unisbase.mapper.SysUserMapper;
import com.unisbase.service.SysParamService;
import com.unisbase.service.SysTenantUserService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
@ -44,9 +40,9 @@ import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.Comparator;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import java.util.UUID;
import java.util.stream.Collectors;
@ -61,14 +57,11 @@ public class MeetingDomainSupport {
private final MeetingTranscriptMapper transcriptMapper;
private final MeetingPointsService meetingPointsService;
private final SysUserMapper sysUserMapper;
private final SysTenantUserService sysTenantUserService;
private final ApplicationEventPublisher eventPublisher;
private final MeetingSummaryFileService meetingSummaryFileService;
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}")
@ -440,7 +433,6 @@ 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()));
@ -461,31 +453,19 @@ public class MeetingDomainSupport {
.map(Long::valueOf)
.collect(Collectors.toList());
vo.setParticipantIds(userIds);
vo.setParticipantUsers(Collections.emptyList());
if (!userIds.isEmpty()) {
List<SysUser> users = sysUserMapper.selectBatchIds(userIds);
Map<Long, String> userNameMap = users.stream().collect(Collectors.toMap(
SysUser::getUserId,
user -> resolveParticipantName(user, meeting.getTenantId())
));
List<MeetingParticipantVO> participantUsers = userIds.stream()
.map(userId -> new MeetingParticipantVO(userId, userNameMap.get(userId)))
.collect(Collectors.toList());
vo.setParticipantUsers(participantUsers);
String names = participantUsers.stream()
.map(MeetingParticipantVO::getDisplayName)
.filter(Objects::nonNull)
String names = users.stream()
.map(u -> u.getDisplayName() != null ? u.getDisplayName() : u.getUsername())
.collect(Collectors.joining(", "));
vo.setParticipants(names);
}
} catch (Exception ex) {
vo.setParticipantIds(Collections.emptyList());
vo.setParticipantUsers(Collections.emptyList());
vo.setParticipants(meeting.getParticipants());
}
} else {
vo.setParticipantIds(Collections.emptyList());
vo.setParticipantUsers(Collections.emptyList());
}
fillLatestTaskAttemptInfo(meeting, vo);
if (includeSummary) {
@ -495,28 +475,6 @@ public class MeetingDomainSupport {
}
}
private String resolveParticipantName(SysUser user, Long tenantId) {
if (user == null || user.getUserId() == null) {
return "";
}
return user.getDisplayName() != null ? user.getDisplayName() : user.getUsername();
}
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);

View File

@ -34,7 +34,6 @@ public class MeetingRuntimeProfileResolverImpl implements MeetingRuntimeProfileR
@Override
public RealtimeMeetingRuntimeProfile resolve(Long tenantId,
Long userId,
Long asrModelId,
Long summaryModelId,
Long promptId,
@ -50,7 +49,7 @@ public class MeetingRuntimeProfileResolverImpl implements MeetingRuntimeProfileR
long resolvedTenantId = tenantId == null ? 0L : tenantId;
AiModelVO asrModel = resolveModel("ASR", asrModelId, resolvedTenantId);
AiModelVO summaryModel = resolveModel("LLM", summaryModelId, resolvedTenantId);
PromptTemplate promptTemplate = resolvePrompt(promptId, resolvedTenantId, userId);
PromptTemplate promptTemplate = resolvePrompt(promptId, resolvedTenantId);
RealtimeMeetingRuntimeProfile profile = new RealtimeMeetingRuntimeProfile();
profile.setResolvedAsrModelId(asrModel.getId());
@ -195,7 +194,7 @@ public class MeetingRuntimeProfileResolverImpl implements MeetingRuntimeProfileR
return entity == null ? null : entity.getId();
}
private PromptTemplate resolvePrompt(Long requestedId, Long tenantId, Long userId) {
private PromptTemplate resolvePrompt(Long requestedId, Long tenantId) {
if (requestedId != null) {
PromptTemplate template = promptTemplateService.getById(requestedId);
if (template == null) {
@ -205,12 +204,7 @@ public class MeetingRuntimeProfileResolverImpl implements MeetingRuntimeProfileR
return template;
}
PromptTemplate template = promptTemplateService.findEffectiveUserDefaultTemplate(tenantId, userId);
if (template != null) {
return template;
}
template = promptTemplateService.getOne(new LambdaQueryWrapper<PromptTemplate>()
PromptTemplate template = promptTemplateService.getOne(new LambdaQueryWrapper<PromptTemplate>()
.eq(PromptTemplate::getStatus, 1)
.eq(PromptTemplate::getIsSystem, 1)
.and(wrapper -> wrapper.eq(PromptTemplate::getTenantId, tenantId).or().eq(PromptTemplate::getTenantId, 0L))

View File

@ -11,7 +11,6 @@ import com.imeeting.entity.biz.AiTask;
import com.imeeting.entity.biz.Meeting;
import com.imeeting.entity.biz.MeetingTranscript;
import com.imeeting.entity.biz.MeetingTranscriptChapterVersion;
import com.imeeting.enums.MeetingTerminalEnum;
import com.imeeting.enums.MeetingStatusEnum;
import com.imeeting.mapper.biz.AiTaskMapper;
import com.imeeting.mapper.biz.MeetingMapper;
@ -164,23 +163,17 @@ public class MeetingUnifiedStatusServiceImpl implements MeetingUnifiedStatusServ
private boolean isAndroidOfflineEmptyUploadFailure(MeetingVO meeting) {
return meeting != null
&& MeetingConstants.TYPE_OFFLINE.equalsIgnoreCase(meeting.getMeetingType())
&& MeetingTerminalEnum.isCustomTerminalSource(meeting.getMeetingSource())
&& hasNoAudioUrl(meeting)
&& MeetingConstants.SOURCE_ANDROID.equalsIgnoreCase(meeting.getMeetingSource())
&& "FAILED".equalsIgnoreCase(meeting.getAudioSaveStatus());
}
private boolean isAndroidOfflineMeetingWaitingUpload(MeetingVO meeting) {
return meeting != null
&& MeetingConstants.TYPE_OFFLINE.equalsIgnoreCase(meeting.getMeetingType())
&& MeetingTerminalEnum.isCustomTerminalSource(meeting.getMeetingSource())
&& hasNoAudioUrl(meeting)
&& MeetingConstants.SOURCE_ANDROID.equalsIgnoreCase(meeting.getMeetingSource())
&& !MeetingConstants.OFFLINE_RECORDING_UPLOAD_FINISHED.equalsIgnoreCase(meeting.getOfflineRecordingStatus());
}
private boolean hasNoAudioUrl(MeetingVO meeting) {
return meeting.getAudioUrl() == null || meeting.getAudioUrl().isBlank();
}
private boolean isSummarizing(MeetingUnifiedStageContext context) {
return isTaskRunning(context.summaryTask())
|| isTaskRunning(context.chapterTask())

View File

@ -1,7 +1,6 @@
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.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.imeeting.dto.biz.PromptTemplateDTO;
@ -70,20 +69,9 @@ public class PromptTemplateServiceImpl extends ServiceImpl<PromptTemplateMapper,
Long tenantId, Long userId, Boolean isPlatformAdmin, Boolean isTenantAdmin) {
LambdaQueryWrapper<PromptTemplate> wrapper = buildVisibilityWrapper(tenantId, userId, isPlatformAdmin, isTenantAdmin);
wrapper.like(name != null && !name.isEmpty(), PromptTemplate::getTemplateName, name)
.eq(category != null && !category.isEmpty(), PromptTemplate::getCategory, category);
PromptTemplateUserConfig configuredDefault = findUserDefaultConfig(tenantId, userId);
Long configuredDefaultId = configuredDefault == null ? null : configuredDefault.getTemplateId();
DefaultTemplateSelection defaultSelection = findEffectiveDefaultTemplate(tenantId, userId);
PromptTemplate defaultTemplate = defaultSelection == null ? null : defaultSelection.template();
if (defaultTemplate == null) {
wrapper.orderByAsc(PromptTemplate::getIsSystem)
.orderByDesc(PromptTemplate::getTenantId)
.orderByDesc(PromptTemplate::getCreatedAt);
} else {
wrapper.last("ORDER BY CASE WHEN id = " + defaultTemplate.getId()
+ " THEN 0 ELSE 1 END, is_system ASC, tenant_id DESC, created_at DESC");
}
.eq(category != null && !category.isEmpty(), PromptTemplate::getCategory, category)
.orderByDesc(PromptTemplate::getIsSystem)
.orderByDesc(PromptTemplate::getCreatedAt);
Page<PromptTemplate> page = this.page(new Page<>(current, size), wrapper);
List<PromptTemplate> records = page.getRecords();
@ -91,16 +79,7 @@ public class PromptTemplateServiceImpl extends ServiceImpl<PromptTemplateMapper,
Map<Long, HotWordGroup> hotWordGroupMap = queryHotWordGroupMap(records.stream().map(PromptTemplate::getHotWordGroupId).toList());
List<PromptTemplateVO> vos = records.stream()
.map(template -> {
Integer status = effectiveStatus(template.getIsSystem(), template.getStatus(), userStatusMap.get(template.getId()));
PromptTemplateVO vo = toVO(template, status, hotWordGroupMap);
boolean isConfiguredPersonalDefault = Objects.equals(configuredDefaultId, template.getId());
boolean isEffectiveDefault = defaultTemplate != null && Objects.equals(defaultTemplate.getId(), template.getId());
vo.setIsDefault(isEffectiveDefault || isConfiguredPersonalDefault);
vo.setDefaultAvailable(isEffectiveDefault);
vo.setDefaultScope(isEffectiveDefault ? defaultSelection.scope() : isConfiguredPersonalDefault ? DEFAULT_SCOPE_PERSONAL : null);
return vo;
})
.map(template -> toVO(template, effectiveStatus(template.getIsSystem(), template.getStatus(), userStatusMap.get(template.getId())), hotWordGroupMap))
.collect(Collectors.toList());
PageResult<List<PromptTemplateVO>> result = new PageResult<>();
@ -118,17 +97,7 @@ public class PromptTemplateServiceImpl extends ServiceImpl<PromptTemplateMapper,
throw new IllegalArgumentException("模板不存在");
}
Map<Long, HotWordGroup> hotWordGroupMap = queryHotWordGroupMap(java.util.Collections.singletonList(template.getHotWordGroupId()));
Integer userStatus = queryUserStatusMap(tenantId, userId, java.util.Collections.singletonList(template.getId()))
.get(template.getId());
Integer status = effectiveStatus(template.getIsSystem(), template.getStatus(), userStatus);
PromptTemplateVO vo = toVO(template, status, hotWordGroupMap);
PromptTemplateUserConfig configuredDefault = findUserDefaultConfig(tenantId, userId);
DefaultTemplateSelection defaultSelection = findEffectiveDefaultTemplate(tenantId, userId);
boolean isConfiguredPersonalDefault = configuredDefault != null && Objects.equals(configuredDefault.getTemplateId(), template.getId());
boolean isEffectiveDefault = defaultSelection != null && Objects.equals(defaultSelection.template().getId(), template.getId());
vo.setIsDefault(isEffectiveDefault || isConfiguredPersonalDefault);
vo.setDefaultAvailable(isEffectiveDefault);
vo.setDefaultScope(isEffectiveDefault ? defaultSelection.scope() : isConfiguredPersonalDefault ? DEFAULT_SCOPE_PERSONAL : null);
PromptTemplateVO vo = toVO(template, template.getStatus(), hotWordGroupMap);
vo.setHotWords(resolveHotWords(template.getHotWordGroupId()));
return vo;
}
@ -186,138 +155,6 @@ public class PromptTemplateServiceImpl extends ServiceImpl<PromptTemplateMapper,
return effectiveStatus(template.getIsSystem(), template.getStatus(), userStatus) == 1;
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean setUserDefaultTemplate(Long templateId, Long tenantId, Long userId, Boolean isPlatformAdmin, Boolean isTenantAdmin) {
if (Boolean.TRUE.equals(isPlatformAdmin)) {
return setSystemDefaultTemplate(templateId, 0L);
}
if (Boolean.TRUE.equals(isTenantAdmin)) {
return setSystemDefaultTemplate(templateId, tenantId);
}
if (!isTemplateEnabledForUser(templateId, tenantId, userId, isPlatformAdmin, isTenantAdmin)) {
return false;
}
userConfigMapper.update(null, new LambdaUpdateWrapper<PromptTemplateUserConfig>()
.eq(PromptTemplateUserConfig::getTenantId, tenantId)
.eq(PromptTemplateUserConfig::getUserId, userId)
.eq(PromptTemplateUserConfig::getIsDefault, 1)
.set(PromptTemplateUserConfig::getIsDefault, 0));
PromptTemplateUserConfig existing = findUserConfig(tenantId, userId, templateId);
if (existing == null) {
PromptTemplateUserConfig entity = new PromptTemplateUserConfig();
entity.setTenantId(tenantId);
entity.setUserId(userId);
entity.setTemplateId(templateId);
entity.setStatus(1);
entity.setIsDefault(1);
return userConfigMapper.insert(entity) > 0;
}
existing.setIsDefault(1);
return userConfigMapper.updateById(existing) > 0;
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean clearUserDefaultTemplate(Long templateId, Long tenantId, Long userId, Boolean isPlatformAdmin, Boolean isTenantAdmin) {
if (Boolean.TRUE.equals(isPlatformAdmin)) {
return clearSystemDefaultTemplate(templateId, 0L);
}
if (Boolean.TRUE.equals(isTenantAdmin)) {
return clearSystemDefaultTemplate(templateId, tenantId);
}
userConfigMapper.update(null, new LambdaUpdateWrapper<PromptTemplateUserConfig>()
.eq(PromptTemplateUserConfig::getTenantId, tenantId)
.eq(PromptTemplateUserConfig::getUserId, userId)
.eq(PromptTemplateUserConfig::getTemplateId, templateId)
.eq(PromptTemplateUserConfig::getIsDefault, 1)
.set(PromptTemplateUserConfig::getIsDefault, 0));
return true;
}
@Override
public PromptTemplate findEffectiveUserDefaultTemplate(Long tenantId, Long userId) {
if (tenantId == null) {
return null;
}
DefaultTemplateSelection selection = findEffectiveDefaultTemplate(tenantId, userId);
return selection == null ? null : selection.template();
}
private boolean setSystemDefaultTemplate(Long templateId, Long scopeTenantId) {
PromptTemplate template = this.getById(templateId);
if (template == null
|| !Integer.valueOf(1).equals(template.getIsSystem())
|| !Objects.equals(template.getTenantId(), scopeTenantId)
|| !Integer.valueOf(1).equals(template.getStatus())) {
return false;
}
this.update(new LambdaUpdateWrapper<PromptTemplate>()
.eq(PromptTemplate::getTenantId, scopeTenantId)
.eq(PromptTemplate::getIsSystem, 1)
.eq(PromptTemplate::getIsDefault, 1)
.set(PromptTemplate::getIsDefault, 0));
template.setIsDefault(1);
return this.updateById(template);
}
private boolean clearSystemDefaultTemplate(Long templateId, Long scopeTenantId) {
return this.update(new LambdaUpdateWrapper<PromptTemplate>()
.eq(PromptTemplate::getId, templateId)
.eq(PromptTemplate::getTenantId, scopeTenantId)
.eq(PromptTemplate::getIsSystem, 1)
.eq(PromptTemplate::getIsDefault, 1)
.set(PromptTemplate::getIsDefault, 0));
}
private DefaultTemplateSelection findEffectiveDefaultTemplate(Long tenantId, Long userId) {
PromptTemplateUserConfig config = findUserDefaultConfig(tenantId, userId);
if (config != null && Integer.valueOf(1).equals(config.getStatus())) {
PromptTemplate template = findAvailableDefaultTemplate(config.getTemplateId(), tenantId, userId);
if (template != null) {
return new DefaultTemplateSelection(template, DEFAULT_SCOPE_PERSONAL);
}
}
PromptTemplate template = findAvailableSystemDefaultTemplate(tenantId, tenantId, userId);
if (template != null) {
return new DefaultTemplateSelection(template, DEFAULT_SCOPE_TENANT);
}
template = findAvailableSystemDefaultTemplate(0L, tenantId, userId);
return template == null ? null : new DefaultTemplateSelection(template, DEFAULT_SCOPE_PLATFORM);
}
private PromptTemplate findAvailableDefaultTemplate(Long templateId, Long tenantId, Long userId) {
PromptTemplate template = this.getById(templateId);
if (template == null || !Integer.valueOf(1).equals(template.getStatus())) {
return null;
}
if (Integer.valueOf(0).equals(template.getIsSystem()) && !Objects.equals(template.getCreatorId(), userId)) {
return null;
}
return Objects.equals(template.getTenantId(), tenantId) || Long.valueOf(0L).equals(template.getTenantId()) ? template : null;
}
private PromptTemplate findAvailableSystemDefaultTemplate(Long scopeTenantId, Long userTenantId, Long userId) {
PromptTemplate template = this.getOne(new LambdaQueryWrapper<PromptTemplate>()
.eq(PromptTemplate::getTenantId, scopeTenantId)
.eq(PromptTemplate::getIsSystem, 1)
.eq(PromptTemplate::getIsDefault, 1)
.eq(PromptTemplate::getStatus, 1)
.last("LIMIT 1"));
if (template == null) {
return null;
}
if (userId == null) {
return template;
}
PromptTemplateUserConfig config = findUserConfig(userTenantId, userId, template.getId());
return effectiveStatus(template.getIsSystem(), template.getStatus(), config == null ? null : config.getStatus()) == 1
? template
: null;
}
private void validateHotWordGroupBinding(Long hotWordGroupId, Long templateTenantId) {
if (hotWordGroupId == null) {
return;
@ -377,25 +214,6 @@ public class PromptTemplateServiceImpl extends ServiceImpl<PromptTemplateMapper,
return statusMap;
}
private PromptTemplateUserConfig findUserConfig(Long tenantId, Long userId, Long templateId) {
return userConfigMapper.selectOne(new LambdaQueryWrapper<PromptTemplateUserConfig>()
.eq(PromptTemplateUserConfig::getTenantId, tenantId)
.eq(PromptTemplateUserConfig::getUserId, userId)
.eq(PromptTemplateUserConfig::getTemplateId, templateId)
.last("LIMIT 1"));
}
private PromptTemplateUserConfig findUserDefaultConfig(Long tenantId, Long userId) {
if (tenantId == null || userId == null) {
return null;
}
return userConfigMapper.selectOne(new LambdaQueryWrapper<PromptTemplateUserConfig>()
.eq(PromptTemplateUserConfig::getTenantId, tenantId)
.eq(PromptTemplateUserConfig::getUserId, userId)
.eq(PromptTemplateUserConfig::getIsDefault, 1)
.last("LIMIT 1"));
}
private Map<Long, HotWordGroup> queryHotWordGroupMap(List<Long> hotWordGroupIds) {
List<Long> ids = hotWordGroupIds == null ? List.of() : hotWordGroupIds.stream()
.filter(Objects::nonNull)
@ -452,7 +270,6 @@ public class PromptTemplateServiceImpl extends ServiceImpl<PromptTemplateMapper,
vo.setDescription(entity.getDescription());
vo.setCategory(entity.getCategory());
vo.setIsSystem(entity.getIsSystem());
vo.setIsTemplateDefault(Integer.valueOf(1).equals(entity.getIsDefault()));
vo.setTags(entity.getTags());
Long hotWordGroupId = entity.getHotWordGroupId();
vo.setHotWordGroupId(hotWordGroupId);
@ -466,11 +283,4 @@ public class PromptTemplateServiceImpl extends ServiceImpl<PromptTemplateMapper,
vo.setUpdatedAt(entity.getUpdatedAt());
return vo;
}
private static final String DEFAULT_SCOPE_PERSONAL = "PERSONAL";
private static final String DEFAULT_SCOPE_TENANT = "TENANT";
private static final String DEFAULT_SCOPE_PLATFORM = "PLATFORM";
private record DefaultTemplateSelection(PromptTemplate template, String scope) {
}
}

View File

@ -6,13 +6,8 @@ import com.imeeting.dto.biz.RealtimeMeetingResumeConfig;
import com.imeeting.dto.biz.RealtimeMeetingSessionStatusVO;
import com.imeeting.dto.biz.RealtimeSocketSessionData;
import com.imeeting.dto.biz.RealtimeSocketSessionVO;
import com.imeeting.dto.biz.HotWordGroupVO;
import com.imeeting.entity.biz.HotWord;
import com.imeeting.entity.biz.Meeting;
import com.imeeting.enums.MeetingTerminalEnum;
import com.imeeting.service.biz.AiModelService;
import com.imeeting.service.biz.HotWordGroupService;
import com.imeeting.service.biz.HotWordService;
import com.imeeting.service.biz.MeetingAccessService;
import com.imeeting.service.biz.RealtimeMeetingSessionStateService;
import com.imeeting.service.biz.RealtimeMeetingSocketSessionService;
@ -26,8 +21,6 @@ import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.math.BigDecimal;
import java.math.RoundingMode;
@Service
@RequiredArgsConstructor
@ -40,16 +33,14 @@ public class RealtimeMeetingSocketSessionServiceImpl implements RealtimeMeetingS
private final RealtimeMeetingSocketSessionCache socketSessionCache;
private final MeetingAccessService meetingAccessService;
private final AiModelService aiModelService;
private final RealtimeMeetingSessionStateService realtimeMeetingSessionStateService;
private final RealtimeMeetingSessionStateService realtimeMeetingSessionStateService;
private final RealtimeAsrChannelFactory realtimeAsrChannelFactory;
private final HotWordService hotWordService;
private final HotWordGroupService hotWordGroupService;
@Override
public RealtimeSocketSessionVO createSession(Long meetingId, Long asrModelId, String mode, String language,
Integer useSpkId, Boolean enablePunctuation, Boolean enableItn,
Boolean enableTextRefine, Boolean saveAudio,
Long hotWordGroupId, LoginUser loginUser) {
List<Map<String, Object>> hotwords, LoginUser loginUser) {
if (meetingId == null) {
throw new RuntimeException("会议 ID 不能为空");
}
@ -58,7 +49,7 @@ public class RealtimeMeetingSocketSessionServiceImpl implements RealtimeMeetingS
}
Meeting meeting = meetingAccessService.requireMeeting(meetingId);
meetingAccessService.assertCanControlRealtimeMeeting(meeting, loginUser, MeetingTerminalEnum.WEB.getCode());
meetingAccessService.assertCanControlRealtimeMeeting(meeting, loginUser, MeetingConstants.SOURCE_WEB);
realtimeMeetingSessionStateService.initSessionIfAbsent(meetingId, loginUser.getTenantId(), loginUser.getUserId());
realtimeMeetingSessionStateService.assertCanOpenSession(meetingId);
@ -77,11 +68,6 @@ public class RealtimeMeetingSocketSessionServiceImpl implements RealtimeMeetingS
RealtimeMeetingSessionStatusVO existingStatus = realtimeMeetingSessionStateService.getStatus(meetingId);
RealtimeMeetingResumeConfig existingConfig = existingStatus == null ? null : existingStatus.getResumeConfig();
Long effectiveHotWordGroupId = resolveHotWordGroupId(hotWordGroupId, existingConfig, meeting);
List<Map<String, Object>> effectiveHotwords = effectiveHotWordGroupId == null
? limitHotwords(existingConfig == null ? List.of() : existingConfig.getHotwords())
: resolveGroupHotwords(effectiveHotWordGroupId, loginUser.getTenantId());
RealtimeMeetingResumeConfig resumeConfig = new RealtimeMeetingResumeConfig();
resumeConfig.setAsrModelId(asrModelId);
resumeConfig.setMode(mode);
@ -95,8 +81,10 @@ public class RealtimeMeetingSocketSessionServiceImpl implements RealtimeMeetingS
resumeConfig.setSpeakerContextId(existingConfig.getSpeakerContextId());
resumeConfig.setUpstreamSessionId(existingConfig.getUpstreamSessionId());
}
List<Map<String, Object>> effectiveHotwords = (hotwords == null || hotwords.isEmpty())
? (existingConfig == null ? List.of() : existingConfig.getHotwords())
: hotwords;
resumeConfig.setHotwords(effectiveHotwords);
resumeConfig.setHotWordGroupId(effectiveHotWordGroupId);
realtimeMeetingSessionStateService.rememberResumeConfig(meetingId, resumeConfig);
RealtimeSocketSessionData sessionData = new RealtimeSocketSessionData();
@ -130,40 +118,6 @@ public class RealtimeMeetingSocketSessionServiceImpl implements RealtimeMeetingS
return vo;
}
private Long resolveHotWordGroupId(Long requestedGroupId, RealtimeMeetingResumeConfig existingConfig, Meeting meeting) {
if (requestedGroupId != null) {
return requestedGroupId > 0 ? requestedGroupId : null;
}
if (existingConfig != null && existingConfig.getHotWordGroupId() != null) {
return existingConfig.getHotWordGroupId();
}
return meeting.getHotWordGroupId();
}
private List<Map<String, Object>> resolveGroupHotwords(Long hotWordGroupId, Long tenantId) {
boolean visible = hotWordGroupService.listVisibleOptions(tenantId).stream()
.map(HotWordGroupVO::getId)
.anyMatch(hotWordGroupId::equals);
if (!visible) {
throw new RuntimeException("热词组不存在或不可用");
}
return hotWordService.listEnabledByGroupIdIgnoreTenant(hotWordGroupId).stream()
.map(this::toRealtimeHotword)
.toList();
}
private List<Map<String, Object>> limitHotwords(List<Map<String, Object>> hotwords) {
return hotwords == null ? List.of() : hotwords;
}
private Map<String, Object> toRealtimeHotword(HotWord hotWord) {
return Map.of(
"hotword", hotWord.getWord(),
"weight", BigDecimal.valueOf(hotWord.getWeight() == null ? 20 : hotWord.getWeight())
.divide(BigDecimal.TEN, 2, RoundingMode.HALF_UP).doubleValue()
);
}
private String resolveRealtimeModelCode(AiModelVO asrModel) {
if (asrModel == null) {
return null;

View File

@ -21,8 +21,7 @@ public class AndroidPushMessageRetryTask {
private final AndroidGatewayPushService androidGatewayPushService;
private final TaskSecurityContextRunner taskSecurityContextRunner;
@Scheduled(fixedDelayString = "${imeeting.android.push.retry-interval-ms:15000}",
initialDelayString = "${imeeting.android.push.initial-delay-ms:10000}")
@Scheduled(fixedDelayString = "${imeeting.android.push.retry-interval-ms:15000}")
public void retryPendingMessages() {
taskSecurityContextRunner.callAsPlatformAdmin(() -> {
List<AndroidPushMessage> pendingMessages = androidPushMessageService.listPendingMeetingPushMessages();

View File

@ -53,13 +53,6 @@ spring:
writetimeout: 5000
# 启用调试日志(生产环境建议关闭)
debug: true
flyway:
enabled: true
locations: classpath:db/migrations
# New databases baseline at 0 and execute V1; manually initialized deployments set version to 1.
baseline-on-migrate: true
baseline-version: ${FLYWAY_BASELINE_VERSION:1}
springdoc:
api-docs:
enabled: true
@ -78,10 +71,6 @@ mybatis-plus:
logic-not-delete-value: 0
unisbase:
flyway:
base:
baseline-enabled: true
baseline-version: ${UNIS_BASELINE_VERSION:0.1.0}
web:
auth-endpoints-enabled: true
management-endpoints-enabled: true

File diff suppressed because it is too large Load Diff

View File

@ -28,12 +28,12 @@
</appender>
<springProfile name="dev">
<logger name="org.flywaydb" level="DEBUG"/>
<!-- 4. MyBatis 框架本身日志 -->
<logger name="org.apache.ibatis" level="INFO"/>
<!-- 5. MyBatis Plus -->
<logger name="com.baomidou.mybatisplus" level="DEBUG"/>
<logger name="io.grpc" level="DEBUG"/>
<logger name="io.grpc.netty.shaded.io.grpc.netty" level="DEBUG"/>
<logger name="com.imeeting.config.grpc" level="DEBUG"/>
<logger name="com.imeeting.grpc" level="DEBUG"/>
<logger name="com.imeeting.service.realtime.impl.RealtimeMeetingGrpcSessionServiceImpl" level="DEBUG"/>
<logger name="com.imeeting.service.realtime.impl.AsrUpstreamBridgeServiceImpl" level="DEBUG"/>
</springProfile>
<root level="INFO">

View File

@ -1,135 +1,71 @@
//package com.imeeting.service.biz.impl;
//
//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.unisbase.dto.SysDictItemDTO;
//import com.unisbase.service.SysDictItemService;
//import org.junit.jupiter.api.Test;
//
//import java.util.List;
//
//import static org.junit.jupiter.api.Assertions.assertEquals;
//import static org.junit.jupiter.api.Assertions.assertFalse;
//import static org.junit.jupiter.api.Assertions.assertThrows;
//import static org.mockito.ArgumentMatchers.any;
//import static org.mockito.Mockito.doAnswer;
//import static org.mockito.Mockito.doReturn;
//import static org.mockito.Mockito.mock;
//import static org.mockito.Mockito.spy;
//import static org.mockito.Mockito.when;
//
//class HotWordServiceImplTest {
//
// @Test
// void saveHotWordShouldRejectWhenGroupLimitReached() {
// HotWordGroupMapper hotWordGroupMapper = mock(HotWordGroupMapper.class);
// SysDictItemService sysDictItemService = mock(SysDictItemService.class);
// HotWordServiceImpl service = spy(new HotWordServiceImpl(hotWordGroupMapper, sysDictItemService));
// doReturn(200L).when(service).count(any());
//
// HotWordGroup group = new HotWordGroup();
// group.setId(5L);
// group.setTenantId(9L);
// group.setGroupName("客户名单");
// group.setStatus(1);
// when(hotWordGroupMapper.selectById(5L)).thenReturn(group);
//
// HotWordDTO dto = new HotWordDTO();
// dto.setWord("阿里");
// dto.setMatchStrategy(1);
// dto.setWeight(2);
// dto.setStatus(1);
// dto.setHotWordGroupId(5L);
//
// IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
// () -> service.saveHotWord(dto, 7L, 9L));
//
// assertEquals("热词组最多只能包含 200 个热词", exception.getMessage());
// }
// @Test
// void saveHotWordShouldGeneratePinyinWhenRequestDoesNotProvideIt() {
// HotWordGroupMapper hotWordGroupMapper = mock(HotWordGroupMapper.class);
// SysDictItemService sysDictItemService = mock(SysDictItemService.class);
// HotWordServiceImpl service = spy(new HotWordServiceImpl(hotWordGroupMapper, sysDictItemService));
// doAnswer(invocation -> {
// HotWord entity = invocation.getArgument(0);
// entity.setId(11L);
// return true;
// }).when(service).save(any(HotWord.class));
//
// HotWordDTO dto = new HotWordDTO();
// dto.setWord("会议");
// dto.setMatchStrategy(1);
// dto.setWeight(2);
// dto.setStatus(1);
// dto.setPinyinList(List.of());
//
// HotWordVO result = service.saveHotWord(dto, 7L, 9L);
//
// assertFalse(result.getPinyinList().isEmpty());
// assertEquals("hui yi", result.getPinyinList().get(0));
// }
//
// @Test
// void saveHotWordShouldUseConfiguredGroupLimit() {
// HotWordGroupMapper hotWordGroupMapper = mock(HotWordGroupMapper.class);
// SysDictItemService sysDictItemService = mock(SysDictItemService.class);
// HotWordServiceImpl service = spy(new HotWordServiceImpl(hotWordGroupMapper, sysDictItemService));
// doReturn(50L).when(service).count(any());
//
// HotWordGroup group = new HotWordGroup();
// group.setId(5L);
// group.setTenantId(9L);
// group.setStatus(1);
// when(hotWordGroupMapper.selectById(5L)).thenReturn(group);
//
// SysDictItemDTO item = new SysDictItemDTO();
// item.setItemValue("50");
// when(sysDictItemService.getItemsByTypeCode("biz_hotword_group_limit")).thenReturn(List.of(item));
//
// HotWordDTO dto = new HotWordDTO();
// dto.setWord("阿里");
// dto.setMatchStrategy(1);
// dto.setWeight(2);
// dto.setStatus(1);
// dto.setHotWordGroupId(5L);
//
// IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
// () -> service.saveHotWord(dto, 7L, 9L));
//
// assertEquals("热词组最多只能包含 50 个热词", exception.getMessage());
// }
//
// @Test
// void saveHotWordShouldUseDefaultLimitWhenConfiguredValueIsInvalid() {
// HotWordGroupMapper hotWordGroupMapper = mock(HotWordGroupMapper.class);
// SysDictItemService sysDictItemService = mock(SysDictItemService.class);
// HotWordServiceImpl service = spy(new HotWordServiceImpl(hotWordGroupMapper, sysDictItemService));
// doReturn(200L).when(service).count(any());
//
// HotWordGroup group = new HotWordGroup();
// group.setId(5L);
// group.setTenantId(9L);
// group.setStatus(1);
// when(hotWordGroupMapper.selectById(5L)).thenReturn(group);
//
// SysDictItemDTO item = new SysDictItemDTO();
// item.setItemValue("50abc");
// when(sysDictItemService.getItemsByTypeCode("biz_hotword_group_limit")).thenReturn(List.of(item));
//
// HotWordDTO dto = new HotWordDTO();
// dto.setWord("阿里");
// dto.setMatchStrategy(1);
// dto.setWeight(2);
// dto.setStatus(1);
// dto.setHotWordGroupId(5L);
//
// IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
// () -> service.saveHotWord(dto, 7L, 9L));
//
// assertEquals("热词组最多只能包含 200 个热词", exception.getMessage());
// }
//}
package com.imeeting.service.biz.impl;
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 org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.when;
class HotWordServiceImplTest {
@Test
void saveHotWordShouldRejectWhenGroupLimitReached() {
HotWordGroupMapper hotWordGroupMapper = mock(HotWordGroupMapper.class);
HotWordServiceImpl service = spy(new HotWordServiceImpl(hotWordGroupMapper));
doReturn(200L).when(service).count(any());
HotWordGroup group = new HotWordGroup();
group.setId(5L);
group.setTenantId(9L);
group.setGroupName("客户名单");
group.setStatus(1);
when(hotWordGroupMapper.selectById(5L)).thenReturn(group);
HotWordDTO dto = new HotWordDTO();
dto.setWord("阿里");
dto.setMatchStrategy(1);
dto.setWeight(2);
dto.setStatus(1);
dto.setHotWordGroupId(5L);
IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
() -> service.saveHotWord(dto, 7L, 9L));
assertEquals("热词组最多只能包含 200 个热词", exception.getMessage());
}
@Test
void saveHotWordShouldGeneratePinyinWhenRequestDoesNotProvideIt() {
HotWordGroupMapper hotWordGroupMapper = mock(HotWordGroupMapper.class);
HotWordServiceImpl service = spy(new HotWordServiceImpl(hotWordGroupMapper));
doAnswer(invocation -> {
HotWord entity = invocation.getArgument(0);
entity.setId(11L);
return true;
}).when(service).save(any(HotWord.class));
HotWordDTO dto = new HotWordDTO();
dto.setWord("会议");
dto.setMatchStrategy(1);
dto.setWeight(2);
dto.setStatus(1);
dto.setPinyinList(List.of());
HotWordVO result = service.saveHotWord(dto, 7L, 9L);
assertFalse(result.getPinyinList().isEmpty());
assertEquals("hui yi", result.getPinyinList().get(0));
}
}

View File

@ -2,7 +2,6 @@ package com.imeeting.service.biz.impl;
import com.imeeting.common.MeetingConstants;
import com.imeeting.entity.biz.Meeting;
import com.imeeting.enums.MeetingTerminalEnum;
import com.imeeting.mapper.biz.MeetingMapper;
import com.unisbase.security.LoginUser;
import org.junit.jupiter.api.Test;
@ -17,41 +16,33 @@ class MeetingAccessServiceImplTest {
@Test
void allowsRealtimeControlFromSourcePlatform() {
Meeting meeting = buildMeeting(MeetingConstants.TYPE_REALTIME, MeetingTerminalEnum.CUSTOM_TERMINAL.getCode());
LoginUser loginUser = buildLoginUser();
assertDoesNotThrow(() -> service.assertCanControlRealtimeMeeting(meeting, loginUser, MeetingTerminalEnum.CUSTOM_TERMINAL.getCode()));
}
@Test
void allowsCustomTerminalToControlLegacyAndroidRealtimeMeeting() {
Meeting meeting = buildMeeting(MeetingConstants.TYPE_REALTIME, "ANDROID");
Meeting meeting = buildMeeting(MeetingConstants.TYPE_REALTIME, MeetingConstants.SOURCE_ANDROID);
LoginUser loginUser = buildLoginUser();
assertDoesNotThrow(() -> service.assertCanControlRealtimeMeeting(meeting, loginUser, MeetingTerminalEnum.CUSTOM_TERMINAL.getCode()));
assertDoesNotThrow(() -> service.assertCanControlRealtimeMeeting(meeting, loginUser, MeetingConstants.SOURCE_ANDROID));
}
@Test
void rejectsCrossPlatformRealtimeControl() {
Meeting meeting = buildMeeting(MeetingConstants.TYPE_REALTIME, MeetingTerminalEnum.CUSTOM_TERMINAL.getCode());
Meeting meeting = buildMeeting(MeetingConstants.TYPE_REALTIME, MeetingConstants.SOURCE_ANDROID);
LoginUser loginUser = buildLoginUser();
assertThrows(RuntimeException.class,
() -> service.assertCanControlRealtimeMeeting(meeting, loginUser, MeetingTerminalEnum.WEB.getCode()));
() -> service.assertCanControlRealtimeMeeting(meeting, loginUser, MeetingConstants.SOURCE_WEB));
}
@Test
void rejectsRealtimeControlForOfflineMeeting() {
Meeting meeting = buildMeeting(MeetingConstants.TYPE_OFFLINE, MeetingTerminalEnum.WEB.getCode());
Meeting meeting = buildMeeting(MeetingConstants.TYPE_OFFLINE, MeetingConstants.SOURCE_WEB);
LoginUser loginUser = buildLoginUser();
assertThrows(RuntimeException.class,
() -> service.assertCanControlRealtimeMeeting(meeting, loginUser, MeetingTerminalEnum.WEB.getCode()));
() -> service.assertCanControlRealtimeMeeting(meeting, loginUser, MeetingConstants.SOURCE_WEB));
}
@Test
void allowsParticipantToViewAndExportButNotEdit() {
Meeting meeting = buildMeeting(MeetingConstants.TYPE_OFFLINE, MeetingTerminalEnum.WEB.getCode());
Meeting meeting = buildMeeting(MeetingConstants.TYPE_OFFLINE, MeetingConstants.SOURCE_WEB);
meeting.setParticipants("201,202,203");
LoginUser participant = new LoginUser(202L, 100L, "participant", false, false, null);
@ -63,7 +54,7 @@ class MeetingAccessServiceImplTest {
@Test
void allowsTenantAdminToEditMeeting() {
Meeting meeting = buildMeeting(MeetingConstants.TYPE_OFFLINE, MeetingTerminalEnum.WEB.getCode());
Meeting meeting = buildMeeting(MeetingConstants.TYPE_OFFLINE, MeetingConstants.SOURCE_WEB);
LoginUser tenantAdmin = new LoginUser(300L, 100L, "tenant-admin", false, true, null);
assertDoesNotThrow(() -> service.assertCanEditMeeting(meeting, tenantAdmin));

View File

@ -1,327 +1,327 @@
//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.HotWordGroupVO;
//import com.imeeting.dto.biz.RealtimeMeetingRuntimeProfile;
//import com.imeeting.entity.biz.AsrModel;
//import com.imeeting.entity.biz.HotWord;
//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.HotWordGroupService;
//import com.imeeting.service.biz.HotWordService;
//import com.imeeting.service.biz.PromptTemplateService;
//import org.junit.jupiter.api.Test;
//
//import java.util.Arrays;
//import java.util.List;
//
//import static org.junit.jupiter.api.Assertions.assertEquals;
//import static org.junit.jupiter.api.Assertions.assertIterableEquals;
//import static org.junit.jupiter.api.Assertions.assertThrows;
//import static org.mockito.ArgumentMatchers.any;
//import static org.mockito.Mockito.mock;
//import static org.mockito.Mockito.when;
//
//class MeetingRuntimeProfileResolverImplTest {
//
// @Test
// void resolveShouldUseRequestedResourcesAndNormalizeHotWords() {
// AiModelService aiModelService = mock(AiModelService.class);
// PromptTemplateService promptTemplateService = mock(PromptTemplateService.class);
// HotWordGroupService hotWordGroupService = mock(HotWordGroupService.class);
// HotWordService hotWordService = mock(HotWordService.class);
// MeetingRuntimeProfileResolverImpl resolver = new MeetingRuntimeProfileResolverImpl(
// aiModelService,
// promptTemplateService,
// hotWordGroupService,
// 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"));
// when(promptTemplateService.getById(33L)).thenReturn(enabledPrompt(33L, 1L, "Summary Prompt"));
//
// RealtimeMeetingRuntimeProfile profile = resolver.resolve(
// 1L,
// 11L,
// 22L,
// 33L,
// null,
// null,
// null,
// null,
// null,
// Boolean.TRUE,
// Boolean.TRUE,
// null,
// Arrays.asList(" alpha ", "", "alpha", "beta", null)
// );
//
// assertEquals(11L, profile.getResolvedAsrModelId());
// assertEquals("ASR-Model", profile.getResolvedAsrModelName());
// assertEquals(22L, profile.getResolvedSummaryModelId());
// assertEquals("LLM-Model", profile.getResolvedSummaryModelName());
// assertEquals(33L, profile.getResolvedPromptId());
// assertEquals("Summary Prompt", profile.getResolvedPromptName());
// assertEquals("2pass", profile.getResolvedMode());
// assertEquals("auto", profile.getResolvedLanguage());
// assertEquals(1, profile.getResolvedUseSpkId());
// assertEquals(Boolean.TRUE, profile.getResolvedEnablePunctuation());
// assertEquals(Boolean.TRUE, profile.getResolvedEnableItn());
// assertEquals(Boolean.TRUE, profile.getResolvedEnableTextRefine());
// assertEquals(Boolean.TRUE, profile.getResolvedSaveAudio());
// assertIterableEquals(List.of("alpha", "beta"), profile.getResolvedHotWords());
// }
//
// @Test
// void resolveShouldRejectCrossTenantModel() {
// AiModelService aiModelService = mock(AiModelService.class);
// PromptTemplateService promptTemplateService = mock(PromptTemplateService.class);
// HotWordGroupService hotWordGroupService = mock(HotWordGroupService.class);
// HotWordService hotWordService = mock(HotWordService.class);
// MeetingRuntimeProfileResolverImpl resolver = new MeetingRuntimeProfileResolverImpl(
// aiModelService,
// promptTemplateService,
// hotWordGroupService,
// hotWordService,
// mock(AsrModelMapper.class),
// mock(LlmModelMapper.class)
// );
//
// when(aiModelService.getModelById(11L, "ASR")).thenReturn(enabledModel(11L, 2L, "ASR-Model"));
//
// assertThrows(RuntimeException.class, () -> resolver.resolve(
// 1L,
// 11L,
// 22L,
// 33L,
// null,
// null,
// null,
// null,
// null,
// null,
// null,
// null,
// List.of()
// ));
// }
//
// @Test
// void resolveShouldUseTemplateBoundGroupWhenNoExplicitHotWords() {
// AiModelService aiModelService = mock(AiModelService.class);
// PromptTemplateService promptTemplateService = mock(PromptTemplateService.class);
// HotWordGroupService hotWordGroupService = mock(HotWordGroupService.class);
// HotWordService hotWordService = mock(HotWordService.class);
// MeetingRuntimeProfileResolverImpl resolver = new MeetingRuntimeProfileResolverImpl(
// aiModelService,
// promptTemplateService,
// hotWordGroupService,
// 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,
// null
// );
//
// assertEquals(99L, profile.getResolvedHotWordGroupId());
// assertIterableEquals(List.of("OpenAI", "Codex"), profile.getResolvedHotWords());
// }
//
// @Test
// void resolveShouldFallbackToFirstEnabledModelUsingSortOrder() {
// AiModelService aiModelService = mock(AiModelService.class);
// PromptTemplateService promptTemplateService = mock(PromptTemplateService.class);
// HotWordGroupService hotWordGroupService = mock(HotWordGroupService.class);
// HotWordService hotWordService = mock(HotWordService.class);
// AsrModelMapper asrModelMapper = mock(AsrModelMapper.class);
// LlmModelMapper llmModelMapper = mock(LlmModelMapper.class);
// MeetingRuntimeProfileResolverImpl resolver = new MeetingRuntimeProfileResolverImpl(
// aiModelService,
// promptTemplateService,
// hotWordGroupService,
// hotWordService,
// asrModelMapper,
// llmModelMapper
// );
//
// when(aiModelService.getDefaultModel("ASR", 1L)).thenReturn(null);
// when(aiModelService.getDefaultModel("LLM", 1L)).thenReturn(null);
// when(asrModelMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(asrEntity(11L));
// when(llmModelMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(llmEntity(22L));
// when(aiModelService.getModelById(11L, "ASR")).thenReturn(enabledModel(11L, 1L, "ASR-Model"));
// when(aiModelService.getModelById(22L, "LLM")).thenReturn(enabledModel(22L, 1L, "LLM-Model"));
// when(promptTemplateService.getOne(any(LambdaQueryWrapper.class))).thenReturn(enabledPrompt(33L, 1L, "Default Prompt"));
//
// RealtimeMeetingRuntimeProfile profile = resolver.resolve(
// 1L,
// null,
// null,
// null,
// null,
// null,
// null,
// null,
// null,
// null,
// null,
// null,
// List.of()
// );
//
// assertEquals(11L, profile.getResolvedAsrModelId());
// assertEquals(22L, profile.getResolvedSummaryModelId());
// }
//
// @Test
// void resolveShouldUseTenantDefaultLlmFromAiModelService() {
// AiModelService aiModelService = mock(AiModelService.class);
// PromptTemplateService promptTemplateService = mock(PromptTemplateService.class);
// HotWordGroupService hotWordGroupService = mock(HotWordGroupService.class);
// HotWordService hotWordService = mock(HotWordService.class);
// MeetingRuntimeProfileResolverImpl resolver = new MeetingRuntimeProfileResolverImpl(
// aiModelService,
// promptTemplateService,
// hotWordGroupService,
// hotWordService,
// mock(AsrModelMapper.class),
// mock(LlmModelMapper.class)
// );
//
// when(aiModelService.getDefaultModel("ASR", 1L)).thenReturn(enabledModel(11L, 1L, "ASR-Model"));
// when(aiModelService.getDefaultModel("LLM", 1L)).thenReturn(enabledModel(77L, 0L, "Tenant Default LLM"));
// when(promptTemplateService.getOne(any(LambdaQueryWrapper.class))).thenReturn(enabledPrompt(33L, 1L, "Default Prompt"));
//
// RealtimeMeetingRuntimeProfile profile = resolver.resolve(
// 1L,
// null,
// null,
// null,
// null,
// null,
// null,
// null,
// null,
// null,
// null,
// null,
// List.of()
// );
//
// assertEquals(77L, profile.getResolvedSummaryModelId());
// assertEquals("Tenant Default LLM", profile.getResolvedSummaryModelName());
// }
//
// @Test
// void resolveShouldPreferExplicitHotWordGroupOverTemplateBinding() {
// AiModelService aiModelService = mock(AiModelService.class);
// PromptTemplateService promptTemplateService = mock(PromptTemplateService.class);
// HotWordGroupService hotWordGroupService = mock(HotWordGroupService.class);
// HotWordService hotWordService = mock(HotWordService.class);
// MeetingRuntimeProfileResolverImpl resolver = new MeetingRuntimeProfileResolverImpl(
// aiModelService,
// promptTemplateService,
// hotWordGroupService,
// 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, 1L, "Summary Prompt");
// template.setHotWordGroupId(99L);
// when(promptTemplateService.getById(33L)).thenReturn(template);
//
// HotWordGroupVO explicitGroup = new HotWordGroupVO();
// explicitGroup.setId(88L);
// when(hotWordGroupService.listVisibleOptions(1L)).thenReturn(List.of(explicitGroup));
//
// HotWord hotWord = new HotWord();
// hotWord.setWord("override");
// when(hotWordService.listEnabledByGroupIdIgnoreTenant(88L)).thenReturn(List.of(hotWord));
//
// RealtimeMeetingRuntimeProfile profile = resolver.resolve(
// 1L,
// 11L,
// 22L,
// 33L,
// null,
// null,
// null,
// null,
// null,
// null,
// null,
// 88L,
// List.of()
// );
//
// assertEquals(88L, profile.getResolvedHotWordGroupId());
// assertIterableEquals(List.of("override"), profile.getResolvedHotWords());
// }
//
// private AiModelVO enabledModel(Long id, Long tenantId, String name) {
// AiModelVO model = new AiModelVO();
// model.setId(id);
// model.setTenantId(tenantId);
// model.setModelName(name);
// model.setStatus(1);
// return model;
// }
//
// private PromptTemplate enabledPrompt(Long id, Long tenantId, String name) {
// PromptTemplate template = new PromptTemplate();
// template.setId(id);
// template.setTenantId(tenantId);
// template.setTemplateName(name);
// template.setStatus(1);
// return template;
// }
//
// private AsrModel asrEntity(Long id) {
// AsrModel entity = new AsrModel();
// entity.setId(id);
// entity.setStatus(1);
// return entity;
// }
//
// private LlmModel llmEntity(Long id) {
// LlmModel entity = new LlmModel();
// entity.setId(id);
// entity.setStatus(1);
// return entity;
// }
//}
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.HotWordGroupVO;
import com.imeeting.dto.biz.RealtimeMeetingRuntimeProfile;
import com.imeeting.entity.biz.AsrModel;
import com.imeeting.entity.biz.HotWord;
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.HotWordGroupService;
import com.imeeting.service.biz.HotWordService;
import com.imeeting.service.biz.PromptTemplateService;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertIterableEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class MeetingRuntimeProfileResolverImplTest {
@Test
void resolveShouldUseRequestedResourcesAndNormalizeHotWords() {
AiModelService aiModelService = mock(AiModelService.class);
PromptTemplateService promptTemplateService = mock(PromptTemplateService.class);
HotWordGroupService hotWordGroupService = mock(HotWordGroupService.class);
HotWordService hotWordService = mock(HotWordService.class);
MeetingRuntimeProfileResolverImpl resolver = new MeetingRuntimeProfileResolverImpl(
aiModelService,
promptTemplateService,
hotWordGroupService,
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"));
when(promptTemplateService.getById(33L)).thenReturn(enabledPrompt(33L, 1L, "Summary Prompt"));
RealtimeMeetingRuntimeProfile profile = resolver.resolve(
1L,
11L,
22L,
33L,
null,
null,
null,
null,
null,
Boolean.TRUE,
Boolean.TRUE,
null,
Arrays.asList(" alpha ", "", "alpha", "beta", null)
);
assertEquals(11L, profile.getResolvedAsrModelId());
assertEquals("ASR-Model", profile.getResolvedAsrModelName());
assertEquals(22L, profile.getResolvedSummaryModelId());
assertEquals("LLM-Model", profile.getResolvedSummaryModelName());
assertEquals(33L, profile.getResolvedPromptId());
assertEquals("Summary Prompt", profile.getResolvedPromptName());
assertEquals("2pass", profile.getResolvedMode());
assertEquals("auto", profile.getResolvedLanguage());
assertEquals(1, profile.getResolvedUseSpkId());
assertEquals(Boolean.TRUE, profile.getResolvedEnablePunctuation());
assertEquals(Boolean.TRUE, profile.getResolvedEnableItn());
assertEquals(Boolean.TRUE, profile.getResolvedEnableTextRefine());
assertEquals(Boolean.TRUE, profile.getResolvedSaveAudio());
assertIterableEquals(List.of("alpha", "beta"), profile.getResolvedHotWords());
}
@Test
void resolveShouldRejectCrossTenantModel() {
AiModelService aiModelService = mock(AiModelService.class);
PromptTemplateService promptTemplateService = mock(PromptTemplateService.class);
HotWordGroupService hotWordGroupService = mock(HotWordGroupService.class);
HotWordService hotWordService = mock(HotWordService.class);
MeetingRuntimeProfileResolverImpl resolver = new MeetingRuntimeProfileResolverImpl(
aiModelService,
promptTemplateService,
hotWordGroupService,
hotWordService,
mock(AsrModelMapper.class),
mock(LlmModelMapper.class)
);
when(aiModelService.getModelById(11L, "ASR")).thenReturn(enabledModel(11L, 2L, "ASR-Model"));
assertThrows(RuntimeException.class, () -> resolver.resolve(
1L,
11L,
22L,
33L,
null,
null,
null,
null,
null,
null,
null,
null,
List.of()
));
}
@Test
void resolveShouldUseTemplateBoundGroupWhenNoExplicitHotWords() {
AiModelService aiModelService = mock(AiModelService.class);
PromptTemplateService promptTemplateService = mock(PromptTemplateService.class);
HotWordGroupService hotWordGroupService = mock(HotWordGroupService.class);
HotWordService hotWordService = mock(HotWordService.class);
MeetingRuntimeProfileResolverImpl resolver = new MeetingRuntimeProfileResolverImpl(
aiModelService,
promptTemplateService,
hotWordGroupService,
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,
null
);
assertEquals(99L, profile.getResolvedHotWordGroupId());
assertIterableEquals(List.of("OpenAI", "Codex"), profile.getResolvedHotWords());
}
@Test
void resolveShouldFallbackToFirstEnabledModelUsingSortOrder() {
AiModelService aiModelService = mock(AiModelService.class);
PromptTemplateService promptTemplateService = mock(PromptTemplateService.class);
HotWordGroupService hotWordGroupService = mock(HotWordGroupService.class);
HotWordService hotWordService = mock(HotWordService.class);
AsrModelMapper asrModelMapper = mock(AsrModelMapper.class);
LlmModelMapper llmModelMapper = mock(LlmModelMapper.class);
MeetingRuntimeProfileResolverImpl resolver = new MeetingRuntimeProfileResolverImpl(
aiModelService,
promptTemplateService,
hotWordGroupService,
hotWordService,
asrModelMapper,
llmModelMapper
);
when(aiModelService.getDefaultModel("ASR", 1L)).thenReturn(null);
when(aiModelService.getDefaultModel("LLM", 1L)).thenReturn(null);
when(asrModelMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(asrEntity(11L));
when(llmModelMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(llmEntity(22L));
when(aiModelService.getModelById(11L, "ASR")).thenReturn(enabledModel(11L, 1L, "ASR-Model"));
when(aiModelService.getModelById(22L, "LLM")).thenReturn(enabledModel(22L, 1L, "LLM-Model"));
when(promptTemplateService.getOne(any(LambdaQueryWrapper.class))).thenReturn(enabledPrompt(33L, 1L, "Default Prompt"));
RealtimeMeetingRuntimeProfile profile = resolver.resolve(
1L,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
List.of()
);
assertEquals(11L, profile.getResolvedAsrModelId());
assertEquals(22L, profile.getResolvedSummaryModelId());
}
@Test
void resolveShouldUseTenantDefaultLlmFromAiModelService() {
AiModelService aiModelService = mock(AiModelService.class);
PromptTemplateService promptTemplateService = mock(PromptTemplateService.class);
HotWordGroupService hotWordGroupService = mock(HotWordGroupService.class);
HotWordService hotWordService = mock(HotWordService.class);
MeetingRuntimeProfileResolverImpl resolver = new MeetingRuntimeProfileResolverImpl(
aiModelService,
promptTemplateService,
hotWordGroupService,
hotWordService,
mock(AsrModelMapper.class),
mock(LlmModelMapper.class)
);
when(aiModelService.getDefaultModel("ASR", 1L)).thenReturn(enabledModel(11L, 1L, "ASR-Model"));
when(aiModelService.getDefaultModel("LLM", 1L)).thenReturn(enabledModel(77L, 0L, "Tenant Default LLM"));
when(promptTemplateService.getOne(any(LambdaQueryWrapper.class))).thenReturn(enabledPrompt(33L, 1L, "Default Prompt"));
RealtimeMeetingRuntimeProfile profile = resolver.resolve(
1L,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
List.of()
);
assertEquals(77L, profile.getResolvedSummaryModelId());
assertEquals("Tenant Default LLM", profile.getResolvedSummaryModelName());
}
@Test
void resolveShouldPreferExplicitHotWordGroupOverTemplateBinding() {
AiModelService aiModelService = mock(AiModelService.class);
PromptTemplateService promptTemplateService = mock(PromptTemplateService.class);
HotWordGroupService hotWordGroupService = mock(HotWordGroupService.class);
HotWordService hotWordService = mock(HotWordService.class);
MeetingRuntimeProfileResolverImpl resolver = new MeetingRuntimeProfileResolverImpl(
aiModelService,
promptTemplateService,
hotWordGroupService,
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, 1L, "Summary Prompt");
template.setHotWordGroupId(99L);
when(promptTemplateService.getById(33L)).thenReturn(template);
HotWordGroupVO explicitGroup = new HotWordGroupVO();
explicitGroup.setId(88L);
when(hotWordGroupService.listVisibleOptions(1L)).thenReturn(List.of(explicitGroup));
HotWord hotWord = new HotWord();
hotWord.setWord("override");
when(hotWordService.listEnabledByGroupIdIgnoreTenant(88L)).thenReturn(List.of(hotWord));
RealtimeMeetingRuntimeProfile profile = resolver.resolve(
1L,
11L,
22L,
33L,
null,
null,
null,
null,
null,
null,
null,
88L,
List.of()
);
assertEquals(88L, profile.getResolvedHotWordGroupId());
assertIterableEquals(List.of("override"), profile.getResolvedHotWords());
}
private AiModelVO enabledModel(Long id, Long tenantId, String name) {
AiModelVO model = new AiModelVO();
model.setId(id);
model.setTenantId(tenantId);
model.setModelName(name);
model.setStatus(1);
return model;
}
private PromptTemplate enabledPrompt(Long id, Long tenantId, String name) {
PromptTemplate template = new PromptTemplate();
template.setId(id);
template.setTenantId(tenantId);
template.setTemplateName(name);
template.setStatus(1);
return template;
}
private AsrModel asrEntity(Long id) {
AsrModel entity = new AsrModel();
entity.setId(id);
entity.setStatus(1);
return entity;
}
private LlmModel llmEntity(Long id) {
LlmModel entity = new LlmModel();
entity.setId(id);
entity.setStatus(1);
return entity;
}
}

View File

@ -1,33 +1,14 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_441_732)">
<rect width="32" height="32" rx="8" fill="url(#paint0_linear_441_732)"/>
<path
d="M20 22C20 19.7909 18.2091 18 16 18C13.7909 18 12 19.7909 12 22V11C12 13.2091 13.7909 15 16 15C18.2091 15 20 13.2091 20 11V22Z"
fill="url(#paint1_linear_441_732)"/>
<rect x="5" y="6" width="7" height="20" rx="3.5" fill="url(#paint2_linear_441_732)"/>
<rect x="20" y="8" width="7" height="16" rx="3.5" fill="url(#paint3_linear_441_732)"/>
<rect width="32" height="32" rx="10" fill="#2D6BFF"/>
<g transform="translate(16 15.6) rotate(35)" stroke="white" fill="none" stroke-width="1.8">
<rect x="-5.2" y="-10.2" width="10.4" height="10.4" rx="5.2"/>
<path d="M-2.2 0.2 H2.2 L1.1 10.4 H-1.1 Z"/>
</g>
<defs>
<linearGradient id="paint0_linear_441_732" x1="16" y1="2.45643e-07" x2="24.2424" y2="32"
gradientUnits="userSpaceOnUse">
<stop stop-color="#FBFBFB"/>
<stop offset="1" stop-color="#D7E4F0"/>
</linearGradient>
<linearGradient id="paint1_linear_441_732" x1="19.625" y1="15.625" x2="12.125" y2="15.75"
gradientUnits="userSpaceOnUse">
<stop stop-color="#0088FF"/>
<stop offset="1" stop-color="#06CEEB"/>
</linearGradient>
<linearGradient id="paint2_linear_441_732" x1="8.5" y1="6" x2="8.5" y2="26" gradientUnits="userSpaceOnUse">
<stop offset="0.408628" stop-color="#0088FF"/>
<stop offset="1" stop-color="#006AFF"/>
</linearGradient>
<linearGradient id="paint3_linear_441_732" x1="23.5" y1="8" x2="23.5" y2="24" gradientUnits="userSpaceOnUse">
<stop stop-color="#20E3FF"/>
<stop offset="0.919108" stop-color="#00CAE7"/>
</linearGradient>
<clipPath id="clip0_441_732">
<rect width="32" height="32" fill="white"/>
</clipPath>
</defs>
<path
d="M12.5 22.6 C14.2 21.6 16 21.6 17.8 22.6 S21.4 23.8 23.2 22.6"
stroke="white"
stroke-width="1.8"
stroke-linecap="round"
fill="none"
/>
</svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 543 B

View File

@ -1,4 +1,4 @@
import http from "../http";
import http from "../http";
export interface HotWordVO {
id: number;
@ -36,18 +36,6 @@ 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;
@ -76,13 +64,6 @@ 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",
@ -110,6 +91,3 @@ export const getPinyinSuggestion = (word: string) => {
{ params: { word } }
);
};

View File

@ -14,6 +14,7 @@ export interface HotWordGroupVO {
export interface HotWordGroupDTO {
id?: number;
tenantId?: number;
groupName: string;
status: number;
remark?: string;
@ -24,6 +25,7 @@ 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",
@ -31,9 +33,10 @@ export const getHotWordGroupPage = (params: {
);
};
export const getHotWordGroupOptions = () => {
export const getHotWordGroupOptions = (tenantId?: number) => {
return http.get<{ code: string; data: HotWordGroupVO[]; msg: string }>(
"/api/biz/hotword-group/options",
{ params: { tenantId } }
);
};
@ -51,8 +54,9 @@ export const updateHotWordGroup = (data: HotWordGroupDTO) => {
);
};
export const deleteHotWordGroup = (id: number) => {
export const deleteHotWordGroup = (id: number, tenantId?: number) => {
return http.delete<{ code: string; data: boolean; msg: string }>(
`/api/biz/hotword-group/${id}`
`/api/biz/hotword-group/${id}`,
{ params: { tenantId } }
);
};

View File

@ -5,12 +5,6 @@ const MEETING_UPLOAD_FLOW_TIMEOUT = 600000;
const MEETING_DETAIL_TIMEOUT = 120000;
export type SummaryDetailLevel = "DETAILED" | "STANDARD" | "BRIEF";
export type MeetingSource = "WINDOWS" | "MACOS" | "KYLIN" | "UOS" | "HARMONYOS" | "WEB" | "CUSTOM_TERMINAL" | "ANDROID";
export interface MeetingParticipant {
userId: number;
displayName: string | null;
}
export interface MeetingCreateConfig {
offlineEnabled: boolean;
@ -32,19 +26,16 @@ export interface MeetingVO {
meetingTime: string;
participants: string;
participantIds?: number[];
participantUsers?: MeetingParticipant[];
tags: string;
audioUrl: string;
playbackAudioUrl?: string;
meetingType?: "OFFLINE" | "REALTIME";
meetingSource?: MeetingSource;
meetingSource?: "WEB" | "ANDROID";
sourceDeviceCode?: string;
sourceDeviceMode?: "PUBLIC" | "PRIVATE";
summaryDetailLevel?: SummaryDetailLevel;
summaryModelId: number;
summaryModelName?: string;
promptId?: number;
promptName?: string;
hotWordGroupId?: number;
hotWordGroupName?: string;
aiCatalogEnabled?: boolean;
@ -239,7 +230,7 @@ export interface RealtimeSocketSessionRequest {
enableItn?: boolean;
enableTextRefine?: boolean;
saveAudio?: boolean;
hotWordGroupId?: number;
hotwords?: Array<{ hotword: string; weight: number }>;
}
export interface RealtimeMeetingSessionStatus {

View File

@ -12,10 +12,6 @@ export interface PromptTemplateVO {
hotWordGroupId?: number;
hotWordGroupName?: string;
hotWords?: string[];
isDefault?: boolean;
defaultScope?: "PERSONAL" | "TENANT" | "PLATFORM";
isTemplateDefault?: boolean;
defaultAvailable?: boolean;
usageCount: number;
promptContent: string;
status: number;
@ -82,11 +78,3 @@ export const updatePromptStatus = (id: number, status: number) => {
{ params: { status } }
);
};
export const setPromptDefault = (id: number) => {
return http.put<{ code: string; data: boolean; msg: string }>(`/api/biz/prompt/${id}/default`);
};
export const clearPromptDefault = (id: number) => {
return http.delete<{ code: string; data: boolean; msg: string }>(`/api/biz/prompt/${id}/default`);
};

View File

@ -46,7 +46,6 @@ import {
uploadAudio,
} from "../../api/business/meeting";
import { getPromptPage, type PromptTemplateVO } from "../../api/business/prompt";
import {useHotWordGroupLimit} from "../../hooks/useHotWordGroupLimit";
import type { SysUser } from "../../types";
import "./MeetingCreateDrawer.css";
@ -83,7 +82,7 @@ type RealtimeMeetingSessionDraft = {
enableItn: boolean;
enableTextRefine: boolean;
saveAudio: boolean;
hotWordGroupId?: number;
hotwords: Array<{ hotword: string; weight: number }>;
};
function resolveAvailableCreateTypes(config: MeetingCreateConfig): MeetingCreateType[] {
@ -122,7 +121,6 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({
onSuccess,
}) => {
const { message } = App.useApp();
const {limit: hotWordGroupLimit} = useHotWordGroupLimit();
const navigate = useNavigate();
const [form] = Form.useForm();
@ -202,28 +200,24 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({
const asrRecords = asrRes.data?.data?.records || [];
const llmRecords = llmRes.data?.data?.records || [];
const hotwordRecords = hotwordRes.data?.data?.records || [];
const activeLlmModels = llmRecords.filter((item: AiModelVO) => item.status === 1);
const activePrompts = promptRecords.filter((item: PromptTemplateVO) => item.status === 1);
setCreateConfig(nextConfig);
setConfigLoaded(true);
setType(nextType);
setAsrModels(asrRecords.filter((item: AiModelVO) => item.status === 1));
setLlmModels(activeLlmModels);
setLlmModels(llmRecords.filter((item: AiModelVO) => item.status === 1));
setPrompts(activePrompts);
setHotwordList(hotwordRecords.filter((item: HotWordVO) => item.status === 1));
setHotWordGroups((hotWordGroupRes.data.data || []).filter((item: HotWordGroupVO) => item.status === 1));
setUserList(users || []);
const defaultPrompt = activePrompts.find((item: PromptTemplateVO) => item.isDefault && item.defaultAvailable) || activePrompts[0];
const defaultSummaryModel = activeLlmModels.find((item: AiModelVO) => item.id === defaultLlm.data.data?.id)
|| activeLlmModels.find((item: AiModelVO) => item.isDefault === 1)
|| activeLlmModels[0];
const defaultPrompt = activePrompts[0];
form.setFieldsValue({
title: nextType === "upload" ? `文件会议 ${dayjs().format("MM-DD HH:mm")}` : `实时会议 ${dayjs().format("MM-DD HH:mm")}`,
meetingTime: dayjs(),
asrModelId: defaultAsr.data.data?.id,
summaryModelId: defaultSummaryModel?.id,
summaryModelId: defaultLlm.data.data?.id,
promptId: defaultPrompt?.id,
hotWordGroupId: defaultPrompt?.hotWordGroupId ?? 0,
summaryDetailLevel: "STANDARD",
@ -301,7 +295,6 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({
message.warning("当前入口已关闭,已切换到可用创建方式");
return;
}
if (type === "upload" && !audioUrl) {
message.error("请先上传录音文件");
return;
@ -315,14 +308,17 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({
return;
}
}
if (!values.promptId) {
message.error("总结模板为空");
return;
}
setSubmitting(true);
try {
const { hostUserId, ...meetingValues } = values;
const selectedHotWords = meetingValues.hotWordGroupId == null || meetingValues.hotWordGroupId === 0
? undefined
: hotwordList
.filter((item) => item.hotWordGroupId === meetingValues.hotWordGroupId)
.map((item) => item.word)
.filter((word) => !!word?.trim());
if (type === "upload") {
await createMeeting({
...meetingValues,
@ -332,6 +328,7 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({
participants: meetingValues.participants?.join(","),
tags: meetingValues.tags?.join(","),
summaryDetailLevel: meetingValues.summaryDetailLevel as SummaryDetailLevel,
hotWords: selectedHotWords,
});
message.success("会议发起成功");
onSuccess();
@ -339,6 +336,13 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({
return;
}
const selectedHotwords = hotwordList
.filter((item) => item.hotWordGroupId === meetingValues.hotWordGroupId && meetingValues.hotWordGroupId !== 0)
.map((item) => ({
hotword: item.word,
weight: Number(item.weight || 2) / 10,
}));
const payload: CreateRealtimeMeetingCommand = {
...meetingValues,
...(hostUserId != null ? { hostUserId } : {}),
@ -353,6 +357,7 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({
enableItn: meetingValues.enableItn !== false,
enableTextRefine: !!meetingValues.enableTextRefine,
saveAudio: !!meetingValues.saveAudio,
hotWords: selectedHotWords,
};
const res = await createRealtimeMeeting(payload);
@ -370,7 +375,7 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({
enableItn: values.enableItn !== false,
enableTextRefine: !!values.enableTextRefine,
saveAudio: !!values.saveAudio,
hotWordGroupId: meetingValues.hotWordGroupId || undefined,
hotwords: selectedHotwords,
};
sessionStorage.setItem(getSessionKey(createdMeeting.id), JSON.stringify(sessionDraft));
@ -512,12 +517,9 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({
</Col>
</Row>
<Form.Item
name="promptId"
label="总结模板"
>
<Form.Item name="promptId" label="总结模板" rules={[{ required: true }]}>
{prompts.length > 15 ? (
<Select allowClear placeholder="请选择总结模板" showSearch optionFilterProp="children">
<Select placeholder="请选择总结模板" showSearch optionFilterProp="children">
{prompts.map(p => <Option key={p.id} value={p.id}>{p.templateName}</Option>)}
</Select>
) : (
@ -545,15 +547,7 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({
<Row gutter={24}>
<Col xs={24} md={12}>
<Form.Item name="hotWordGroupId" label="热词组" tooltip={selectedPrompt?.hotWordGroupName ? `默认跟随模板:${selectedPrompt.hotWordGroupName}` : "模板未绑定热词组时可手动选择"} extra={watchedHotWordGroupId != null ? "创建会议时会优先使用这里选中的热词组" : undefined}>
<Select
placeholder={selectedPrompt?.hotWordGroupId ? "默认已带出模板热词组,可按需修改" : "请选择热词组"}
options={[{
label: "不使用热词组",
value: 0
}, ...hotWordGroups.map((item) => ({
label: `${item.groupName} (${item.hotWordCount}/${hotWordGroupLimit})`,
value: item.id
}))]}/>
<Select placeholder={selectedPrompt?.hotWordGroupId ? "默认已带出模板热词组,可按需修改" : "请选择热词组"} options={[{ label: "不使用热词组", value: 0 }, ...hotWordGroups.map((item) => ({ label: `${item.groupName} (${item.hotWordCount}/200)`, value: item.id }))]} />
</Form.Item>
</Col>
<Col xs={24} md={12}>

View File

@ -276,7 +276,6 @@
"botCredentialHint": "Use this credential pair to access /mcp with X-Bot-Id and X-Bot-Secret.",
"botCredentialHintDesc": "The secret is shown only after generation. Store it securely after copying.",
"botBindStatus": "Binding Status",
"mcpAddress": "MCP Address",
"botBound": "Bound",
"botUnbound": "Not Generated",
"botSecretHidden": "Hidden. Generate or reset to get a new secret.",

View File

@ -276,7 +276,6 @@
"botCredentialHint": "使用这组凭证通过 X-Bot-Id 和 X-Bot-Secret 访问 /mcp。",
"botCredentialHintDesc": "Secret 只会在生成后显示一次,请复制后妥善保管。",
"botBindStatus": "绑定状态",
"mcpAddress": "MCP 地址",
"botBound": "已绑定",
"botUnbound": "未生成",
"botSecretHidden": "已隐藏。如需查看新的 Secret请重新生成。",

View File

@ -99,7 +99,7 @@
.permissions-name-cell {
display: flex;
width: 80%;
width: 100%;
min-width: 0;
}

View File

@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import React, { useEffect, useMemo, useRef, useState } from "react";
import {
App,
AutoComplete,
@ -248,7 +248,7 @@ const AiModels: React.FC = () => {
const rawModels = (res as any)?.data?.data ?? (Array.isArray(res) ? res : []);
const models = Array.isArray(rawModels) ? rawModels : [];
setRemoteModels(models);
message.success(`获取 ${models.length} 个模型`);
message.success(`获取 ${models.length} 个模型`);
} finally {
setFetchLoading(false);
}
@ -372,7 +372,7 @@ const AiModels: React.FC = () => {
const values = await form.validateFields(["provider", "baseUrl"]);
if (String(values.provider || "").toLowerCase() !== "local") {
message.warning("仅本地 ASR 模型支持连通性测试");
message.warning("只有本地 ASR 支持该连通性测试");
return;
}
@ -400,28 +400,28 @@ const AiModels: React.FC = () => {
const handleTenantToggle = async (record: AiModelVO, checked: boolean) => {
if (checked) {
await tenantEnableModel(record.id, activeType);
message.success(activeType === "ASR" ? "已启用当前 ASR 模型" : "已启用当前 LLM 模型");
message.success(activeType === "ASR" ? "已切换当前 ASR" : "已启用当前 LLM");
} else {
await tenantDisableModel(record.id, activeType);
message.success(activeType === "ASR" ? "已停用当前 ASR 模型" : "已停用当前 LLM 模型");
message.success(activeType === "ASR" ? "已关闭当前 ASR" : "已关闭当前 LLM");
}
await fetchData();
};
const handlePlatformStatusToggle = async (record: AiModelVO, checked: boolean) => {
await updatePlatformModelStatus(record.id, activeType, checked ? 1 : 0);
message.success(checked ? `平台级 ${activeType} 已启用` : `平台级 ${activeType} 已禁用`);
message.success(checked ? `平台级 ${activeType} 已启用` : `平台级 ${activeType} 已禁用`);
await fetchData();
};
const handleSyncCurrentAsr = async () => {
await syncCurrentAsrSpeakers();
message.success("当前 ASR 声纹已同步");
message.success("提交后台同步任务");
};
const handleSetTenantDefault = async (record: AiModelVO) => {
await setTenantDefaultModel(record.id, "LLM");
message.success("已设置为默认 LLM");
message.success("已设置为默认 LLM");
await fetchData();
};
@ -433,16 +433,16 @@ const AiModels: React.FC = () => {
render: (text: string, record: AiModelVO) => (
<Space>
{text}
{record.isDefault === 1 && <Tag color="gold"></Tag>}
{record.tenantDefault === 1 && <Tag color="blue"></Tag>}
{record.isDefault === 1 && <Tag color="gold"></Tag>}
{record.tenantDefault === 1 && <Tag color="blue"></Tag>}
{record.tenantId === 0 && (
<Tooltip title="平台透传模型">
<Tooltip title="平台透传模型">
<SafetyCertificateOutlined style={{ color: "#52c41a" }} />
</Tooltip>
)}
{record.scope && (
<Tag bordered={false} color={record.scope === "PLATFORM" ? "geekblue" : "default"}>
{record.scope === "PLATFORM" ? "平台级" : "租户级"}
{record.scope === "PLATFORM" ? "平台级" : "租户级"}
</Tag>
)}
</Space>
@ -458,7 +458,7 @@ const AiModels: React.FC = () => {
},
},
{
title: "模型编码",
title: "模型编码",
dataIndex: "modelCode",
key: "modelCode",
},
@ -486,8 +486,8 @@ const AiModels: React.FC = () => {
return (
<Switch
checked={record.tenantEnabled === 1}
checkedChildren={activeType === "ASR" ? "已启用" : "已启用"}
unCheckedChildren={activeType === "ASR" ? "未启用" : "已停用"}
checkedChildren={activeType === "ASR" ? "当前生效" : "已启用"}
unCheckedChildren={activeType === "ASR" ? "未启用" : "已关闭"}
disabled={status !== 1}
onChange={(checked) => void handleTenantToggle(record, checked)}
/>
@ -504,7 +504,7 @@ const AiModels: React.FC = () => {
<Space>
{canSetDefault && (
<Button type="link" onClick={() => void handleSetTenantDefault(record)}>
{record.tenantDefault === 1 ? "默认 LLM" : "设为默认"}
{record.tenantDefault === 1 ? "默认 LLM" : "设为默认"}
</Button>
)}
{canEdit && (
@ -513,7 +513,7 @@ const AiModels: React.FC = () => {
</Button>
)}
{canEdit && (
<Popconfirm title="确删除吗?" onConfirm={() => handleDelete(record)}>
<Popconfirm title="确删除吗?" onConfirm={() => handleDelete(record)}>
<Button type="link" danger icon={<DeleteOutlined />}>
</Button>
@ -528,11 +528,11 @@ const AiModels: React.FC = () => {
const leftActions = (
<Space wrap>
<Button type="primary" icon={<PlusOutlined/>} onClick={() => openDrawer()}>
</Button>
{activeType === "ASR" && (
<Button icon={<SyncOutlined/>} onClick={() => void handleSyncCurrentAsr()}>
ASR
ASR
</Button>
)}
</Space>
@ -542,7 +542,7 @@ const AiModels: React.FC = () => {
<PageContainer title={null} className="ai-models-page">
<SectionCard
title="AI 模型配置"
description="管理 ASR 语音识别模型和 LLM 大语言模型"
description="管理 ASR 语音识别和 LLM 大语言模型"
tabs={
<Tabs
activeKey={activeType}
@ -551,8 +551,8 @@ const AiModels: React.FC = () => {
setCurrent(1);
}}
items={[
{key: "ASR", label: "ASR 模型"},
{key: "LLM", label: "LLM 模型"},
{ key: "ASR", label: "ASR 模型" },
{ key: "LLM", label: "LLM 模型" },
]}
size="middle"
type="card"
@ -566,7 +566,7 @@ const AiModels: React.FC = () => {
rightActions={
<Input.Search
allowClear
placeholder="搜索模型名称"
placeholder="搜索模型名称"
prefix={<SearchOutlined />}
className="ai-models-search"
onSearch={(value) => {
@ -603,7 +603,7 @@ const AiModels: React.FC = () => {
width={600}
open={drawerVisible}
onClose={() => setDrawerVisible(false)}
title={<Title level={4} style={{margin: 0}}>{editingId ? "编辑模型" : "新增模型"}</Title>}
title={<Title level={4} style={{ margin: 0 }}>{editingId ? "编辑模型" : "新增模型"}</Title>}
forceRender
extra={
<Space>
@ -621,7 +621,7 @@ const AiModels: React.FC = () => {
<Form.Item label="模型类型">
<Tag color={activeType === "ASR" ? "blue" : "purple"}>
{activeType === "ASR" ? "语音识别 (ASR)" : "大语言模型 (LLM)"}
{activeType === "ASR" ? "语音识别 (ASR)" : "大语言模型 (LLM)"}
</Tag>
</Form.Item>
@ -629,19 +629,19 @@ const AiModels: React.FC = () => {
<Col xs={24} md={12}>
<Form.Item
name="modelName"
label="模型名称"
rules={[{required: true, message: "请输入显示名称"}, {max: 15, message: "模型名称不能超过15个字符"}]}
label="显示名称"
rules={[{ required: true, message: "请输入显示名称" }]}
>
<Input onChange={() => {
modelNameAutoFilledRef.current = false;
}} maxLength={15} showCount/>
}}/>
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item
name="provider"
label="提供商"
rules={[{required: true, message: "请选择提供商"}]}
rules={[{ required: true, message: "请选择提供商" }]}
>
<Select allowClear placeholder="请选择">
{providers.map((item) => (
@ -656,7 +656,7 @@ const AiModels: React.FC = () => {
<Row gutter={16} className="app-responsive-form-row">
<Col xs={24} md={12}>
<Form.Item name="sortOrder" label="排序">
<Form.Item name="sortOrder" label="排序">
<InputNumber min={0} style={{ width: "100%" }} />
</Form.Item>
</Col>
@ -664,7 +664,7 @@ const AiModels: React.FC = () => {
{!isTencentProvider && (
<>
<Form.Item name="baseUrl" label="Base URL" rules={[{required: true, message: "请输入 Base URL"}]}>
<Form.Item name="baseUrl" label="Base URL" rules={[{required: true, message: "请输入 Base URL"}]}>
<Input placeholder="https://api.example.com"/>
</Form.Item>
<Form.Item name="apiKey" label="API Key">
@ -686,10 +686,10 @@ const AiModels: React.FC = () => {
</Divider>
<Form.Item
label="模型编码"
label="模型编码"
required={activeType === "LLM"}
hidden={activeType === "ASR" && isTencentProvider}
tooltip="可从远程列表选择,也可手动输入;该值会作为模型编码传给后端"
tooltip="可从远程列表选择,也可手动输入;该值会作为模型编码传给后端"
>
<Space.Compact style={{ width: "100%" }}>
<Form.Item
@ -709,18 +709,18 @@ const AiModels: React.FC = () => {
isLocalProvider || String(option?.value || "").toLowerCase().includes(inputValue.toLowerCase())
}
>
<Input allowClear placeholder="请输入或选择模型编码"/>
<Input allowClear placeholder="可选择或手动输入模型编码"/>
</AutoComplete>
</Form.Item>
{!isTencentProvider && (
<Button icon={<SyncOutlined spin={fetchLoading}/>} onClick={handleFetchRemote} style={{width: 100}}>
</Button>
)}
</Space.Compact>
</Form.Item>
<Form.Item name="wsUrl" label="WebSocket 地址"
<Form.Item name="wsUrl" label="WebSocket 地址"
hidden={!(activeType === "ASR" && createConfig.realtimeEnabled)}>
<Input placeholder="wss://api.example.com/v1/ws" />
</Form.Item>
@ -728,7 +728,7 @@ const AiModels: React.FC = () => {
{activeType === "ASR" && isLocalProvider && (
<Row gutter={16} hidden className="app-responsive-form-row">
<Col xs={24} md={12}>
<Form.Item name="svThreshold" label="声纹阈值">
<Form.Item name="svThreshold" label="声纹阈值">
<InputNumber min={0} max={1} step={0.01} style={{ width: "100%" }} />
</Form.Item>
</Col>
@ -738,7 +738,7 @@ const AiModels: React.FC = () => {
{activeType === "ASR" && isTencentProvider && (
<Row gutter={16} className="app-responsive-form-row">
<Col xs={24} md={12}>
<Form.Item name="tencentAppId" label="App ID" rules={[{required: true, message: "请输入 App ID"}]}>
<Form.Item name="tencentAppId" label="App ID" rules={[{required: true, message: "请输入 App ID"}]}>
<Input/>
</Form.Item>
</Col>
@ -755,15 +755,15 @@ const AiModels: React.FC = () => {
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item name="tencentOfflineModelCode" label="离线识别模型"
rules={[{required: true, message: "请输入离线识别模型"}]}>
<Input placeholder="例如16k_zh"/>
<Form.Item name="tencentOfflineModelCode" label="离线识别模型"
rules={[{required: true, message: "请输入离线识别模型"}]}>
<Input placeholder="例如16k_zh"/>
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item name="tencentRealtimeModelCode" label="实时识别模型"
rules={[{required: true, message: "请输入实时识别模型"}]}>
<Input placeholder="例如16k_zh_realtime"/>
<Form.Item name="tencentRealtimeModelCode" label="实时识别模型"
rules={[{required: true, message: "请输入实时识别模型"}]}>
<Input placeholder="例如16k_zh_realtime"/>
</Form.Item>
</Col>
</Row>
@ -790,7 +790,7 @@ const AiModels: React.FC = () => {
name="max_tokens"
label="max_tokens"
rules={[
{required: true, message: "请输入 max_tokens"},
{ required: true, message: "请输入 max_tokens" },
{
validator: (_, value) => {
if (value === undefined || value === null || value === "") {
@ -827,7 +827,7 @@ const AiModels: React.FC = () => {
</Col>
<Col xs={24} md={8}>
<Form.Item name="statusChecked" label="状态" valuePropName="checked">
<Switch checkedChildren="启用" unCheckedChildren="禁用" disabled={Boolean(isDefaultChecked)}/>
<Switch checkedChildren="启用" unCheckedChildren="禁用" disabled={Boolean(isDefaultChecked)} />
</Form.Item>
</Col>
</Row>

View File

@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from "react";
import React, { useEffect, useMemo, useState } from "react";
import {
App,
Badge,
@ -32,7 +32,6 @@ import {
} from "@ant-design/icons";
import { useTranslation } from "react-i18next";
import { useDict } from "../../hooks/useDict";
import {useHotWordGroupLimit} from "../../hooks/useHotWordGroupLimit";
import {
deleteHotWord,
getHotWordPage,
@ -98,7 +97,6 @@ const HotWords: React.FC = () => {
const [groupForm] = Form.useForm<HotWordGroupFormValues>();
const [bulkGroupForm] = Form.useForm<BulkGroupFormValues>();
const { items: categories } = useDict("biz_hotword_category");
const {limit: hotWordGroupLimit} = useHotWordGroupLimit();
const userProfile = useMemo(() => {
const profileStr = sessionStorage.getItem("userProfile");
return profileStr ? JSON.parse(profileStr) : {};
@ -114,7 +112,7 @@ const HotWords: React.FC = () => {
const [searchWord, setSearchWord] = useState("");
const [searchCategory, setSearchCategory] = useState<string | undefined>(undefined);
const [searchGroupId, setSearchGroupId] = useState<number | undefined>(undefined);
const [hotWordGroupFilter, setHotWordGroupFilter] = useState<HotWordGroupFilter>("ungrouped");
const [hotWordGroupFilter, setHotWordGroupFilter] = useState<HotWordGroupFilter>("all");
const [modalVisible, setModalVisible] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
@ -198,7 +196,7 @@ const HotWords: React.FC = () => {
};
const loadGroupOptions = async () => {
const res = await getHotWordGroupOptions();
const res = await getHotWordGroupOptions(isPlatformAdmin ? activeTenantId : undefined);
setGroupOptions(res.data?.data || []);
};
@ -210,6 +208,7 @@ 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);
@ -316,10 +315,10 @@ const HotWords: React.FC = () => {
const values = await groupForm.validateFields();
setGroupSubmitLoading(true);
if (editingGroupId) {
await updateHotWordGroup({...values, id: editingGroupId});
await updateHotWordGroup({ ...values, id: editingGroupId, tenantId: isPlatformAdmin ? activeTenantId : undefined });
message.success("热词组更新成功");
} else {
await saveHotWordGroup(values);
await saveHotWordGroup({ ...values, tenantId: isPlatformAdmin ? activeTenantId : undefined });
message.success("热词组创建成功");
}
setGroupEditorVisible(false);
@ -331,7 +330,7 @@ const HotWords: React.FC = () => {
const handleDeleteGroup = async (id: number, e?: React.MouseEvent) => {
e?.stopPropagation();
await deleteHotWordGroup(id);
await deleteHotWordGroup(id, isPlatformAdmin ? activeTenantId : undefined);
message.success("热词组删除成功");
if (searchGroupId === id) {
setSearchGroupId(undefined);
@ -360,7 +359,7 @@ const HotWords: React.FC = () => {
if (assignedHotWordCount > 0) {
Modal.confirm({
title: "确认修改热词组?",
content: `当前选择的热词中,有 ${assignedHotWordCount} 个已分配热词组。继续后,这些热词的原分组会被后续选择的目标热词组覆盖。`,
content: `当前选择的热词中,有 ${assignedHotWordCount} 个已分配热词组。继续后,这些热词的原分组会被后续选择的目标热词组覆盖。`,
okText: "继续修改",
cancelText: "取消",
onOk: openEditor,
@ -402,36 +401,34 @@ const HotWords: React.FC = () => {
setSearchCategory(undefined);
setSearchGroupId(undefined);
setSelectedGroupName(undefined);
setHotWordGroupFilter("ungrouped");
setHotWordGroupFilter("all");
bulkGroupForm.resetFields();
setBulkGroupEditorVisible(false);
setCurrent(1);
void fetchData({current: 1, word: "", category: null, groupFilter: "ungrouped", searchGroupId: null});
void fetchData({ current: 1, word: "", category: null, groupFilter: "all", searchGroupId: null });
};
const handleSelectGroup = (item: GroupListItem) => {
setSearchGroupId(item.id);
setSelectedGroupName(item.id ? item.groupName : undefined);
setHotWordGroupFilter(item.id ?? "ungrouped");
setHotWordGroupFilter(item.id ?? "all");
setCurrent(1);
};
const hotWordGroupTitle = hotWordGroupFilter === "ungrouped"
? "未分组"
: typeof hotWordGroupFilter === "number"
? selectedGroupName || groupData.find((item) => item.id === hotWordGroupFilter)?.groupName || groupNameMap[hotWordGroupFilter] || "热词列表"
: "热词列表";
const hotWordGroupTitle = searchGroupId
? selectedGroupName || groupData.find((item) => item.id === searchGroupId)?.groupName || groupNameMap[searchGroupId] || "热词列表"
: "全部热词";
const groupFilterOptions = useMemo(
() => [
{ label: "全部词组", value: "all" as const },
{label: "未分组", value: "ungrouped" as const},
{ label: "未分配", value: "ungrouped" as const },
...groupOptions.map((item) => ({ label: item.groupName, value: item.id })),
],
[groupOptions]
);
const groupListData: GroupListItem[] = [{id: undefined, groupName: "未分组"}, ...groupData];
const groupListData: GroupListItem[] = [{ id: undefined, groupName: "全部热词" }, ...groupData];
const columns = [
{
@ -561,7 +558,7 @@ const HotWords: React.FC = () => {
loading={groupLoading}
dataSource={groupListData}
renderItem={(item) => {
const isSelected = item.id ? hotWordGroupFilter === item.id : hotWordGroupFilter === "ungrouped";
const isSelected = searchGroupId === item.id;
const actions = [];
if (item.id) {
actions.push(
@ -608,14 +605,13 @@ const HotWords: React.FC = () => {
item.id
? (
<span className="hotwords-group-item__desc">
<Tag
color={item.hotWordCount >= hotWordGroupLimit ? "red" : item.status === 1 ? "processing" : "default"}>
{item.hotWordCount}/{hotWordGroupLimit}
<Tag color={item.hotWordCount >= 200 ? "red" : item.status === 1 ? "processing" : "default"}>
{item.hotWordCount}/200
</Tag>
<span>{item.remark || "暂无备注"}</span>
</span>
)
: "查看未分组热词"
: "查看所有热词"
}
/>
</List.Item>
@ -693,8 +689,7 @@ const HotWords: React.FC = () => {
className="hotwords-search__category"
options={categories.map((c) => ({ label: c.itemLabel, value: c.itemValue }))}
/>
<Button onClick={handleResetFilters}
disabled={selectedHotWordIds.length === 0 && searchWord === "" && !searchCategory && !searchGroupId && hotWordGroupFilter === "ungrouped"}>
<Button onClick={handleResetFilters} disabled={selectedHotWordIds.length === 0 && searchWord === "" && !searchCategory && !searchGroupId && hotWordGroupFilter === "all"}>
</Button>
</Space>
@ -769,10 +764,7 @@ const HotWords: React.FC = () => {
</Col>
<Col xs={24} sm={12}>
<Form.Item name="hotWordGroupId" label="所属热词组">
<Select placeholder="请选择热词组" allowClear options={groupOptions.map((item) => ({
label: `${item.groupName} (${item.hotWordCount}/${hotWordGroupLimit})`,
value: item.id
}))}/>
<Select placeholder="请选择热词组" allowClear options={groupOptions.map((item) => ({ label: `${item.groupName} (${item.hotWordCount}/200)`, value: item.id }))} />
</Form.Item>
</Col>
</Row>
@ -812,11 +804,8 @@ const HotWords: React.FC = () => {
destroyOnHidden
>
<Form form={groupForm} layout="vertical" className="hotwords-modal-form">
<Form.Item name="groupName" label="热词组名称" rules={[{required: true, message: "请输入热词组名称"}, {
max: 15,
message: "热词组名称不能超过15个字符"
}]}>
<Input placeholder="例如:项目术语、客户名单" maxLength={15} showCount/>
<Form.Item name="groupName" label="热词组名称" rules={[{ required: true, message: "请输入热词组名称" }]}>
<Input placeholder="例如:项目术语、客户名单" maxLength={100} />
</Form.Item>
<Form.Item name="status" label="状态">
<Select>
@ -849,10 +838,7 @@ const HotWords: React.FC = () => {
placeholder="请选择热词组"
options={[
{ label: "未分组", value: 0 },
...groupOptions.map((item) => ({
label: `${item.groupName} (${item.hotWordCount}/${hotWordGroupLimit})`,
value: item.id
})),
...groupOptions.map((item) => ({ label: `${item.groupName} (${item.hotWordCount}/200)`, value: item.id })),
]}
/>
</Form.Item>

View File

@ -49,12 +49,10 @@ import {
updateSpeakerInfo,
} from '../../api/business/meeting';
import { getAiModelDefault, getAiModelPage, AiModelVO } from '../../api/business/aimodel';
import {createHotWordBatch} from '../../api/business/hotword';
import {getHotWordGroupOptions, type HotWordGroupVO} from '../../api/business/hotwordGroup';
import { getHotWordPage, getPinyinSuggestion, saveHotWord } from '../../api/business/hotword';
import { getPromptPage, PromptTemplateVO } from '../../api/business/prompt';
import { listUsers } from '../../api';
import { useDict } from '../../hooks/useDict';
import {useHotWordGroupLimit} from '../../hooks/useHotWordGroupLimit';
import { SysUser } from '../../types';
import PageContainer from "../../components/shared/PageContainer";
import SectionCard from "../../components/shared/SectionCard";
@ -250,20 +248,18 @@ 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(isValidKeyword);
.filter(Boolean);
if (fromSection.length) {
return Array.from(new Set(fromSection)).slice(0, 12);
}
return Array.from(new Set((tags || '').split(',').map((item) => item.trim()).filter(isValidKeyword))).slice(0, 12);
return Array.from(new Set((tags || '').split(',').map((item) => item.trim()).filter(Boolean))).slice(0, 12);
};
const buildMeetingAnalysis = (
@ -284,7 +280,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(isValidKeyword)),
new Set((Array.isArray(parsed.keywords) ? parsed.keywords : []).map((item) => String(item).trim()).filter(Boolean)),
).slice(0, 12),
chapters: chapters
.map((item: any) => ({
@ -1215,7 +1211,6 @@ const ActiveTranscriptRow = React.memo<ActiveTranscriptRowProps>(({
const MeetingDetail: React.FC = () => {
const { message } = App.useApp();
const {limit: hotWordGroupLimit, loading: hotWordGroupLimitLoading} = useHotWordGroupLimit();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [form] = Form.useForm();
@ -1232,10 +1227,6 @@ const MeetingDetail: React.FC = () => {
const [isEditingSummary, setIsEditingSummary] = useState(false);
const [summaryDraft, setSummaryDraft] = useState('');
const [selectedKeywords, setSelectedKeywords] = useState<string[]>([]);
const [hotWordGroupModalOpen, setHotWordGroupModalOpen] = useState(false);
const [hotWordGroupOptions, setHotWordGroupOptions] = useState<HotWordGroupVO[]>([]);
const [selectedHotWordGroupId, setSelectedHotWordGroupId] = useState<number | undefined>();
const [hotWordGroupLoading, setHotWordGroupLoading] = useState(false);
const [workspaceTab, setWorkspaceTab] = useState<WorkspaceTab>('transcript');
const [addingHotwords, setAddingHotwords] = useState(false);
const [editingTranscriptId, setEditingTranscriptId] = useState<number | null>(null);
@ -1297,20 +1288,15 @@ 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 = true;
const expandKeywords = false;
const visibleKeywords = expandKeywords ? analysis.keywords : analysis.keywords.slice(0, 9);
const meetingTags = useMemo(
() => (meeting?.tags?.split(',').map((item) => item.trim()).filter(isValidKeyword) || []),
() => (meeting?.tags?.split(',').map((item) => item.trim()).filter(Boolean) || []),
[meeting?.tags],
);
const discussionItems = useMemo(() => {
@ -1402,23 +1388,25 @@ const MeetingDetail: React.FC = () => {
return buildMeetingPreviewUrl(meetingShareBaseUrl, meetingId);
}, [meetingShareBaseUrl, meeting?.id, id]);
const summaryModelDisplayName = useMemo(() => {
if (meeting?.summaryModelName?.trim()) {
return meeting.summaryModelName.trim();
const matchedModel = llmModels.find((item) => item.id === meeting?.summaryModelId);
if (matchedModel?.modelName) {
return matchedModel.modelName;
}
if (meeting?.summaryModelId) {
return `模型 #${meeting.summaryModelId}`;
}
return '未配置';
}, [meeting?.summaryModelId, meeting?.summaryModelName]);
}, [llmModels, meeting?.summaryModelId]);
const promptDisplayName = useMemo(() => {
if (meeting?.promptName?.trim()) {
return meeting.promptName.trim();
const matchedPrompt = prompts.find((item) => item.id === meeting?.promptId);
if (matchedPrompt?.templateName) {
return matchedPrompt.templateName;
}
if (meeting?.promptId) {
return `模板 #${meeting.promptId}`;
}
return '未配置';
}, [meeting?.promptId, meeting?.promptName]);
}, [meeting?.promptId, prompts]);
const hotWordGroupDisplayName = useMemo(() => {
if (meeting?.hotWordGroupName?.trim()) {
return meeting.hotWordGroupName.trim();
@ -1594,6 +1582,7 @@ const MeetingDetail: React.FC = () => {
useEffect(() => {
if (!id) return;
fetchData(Number(id));
loadAiConfigs();
loadUsers();
}, [id, fetchData]);
@ -1659,13 +1648,11 @@ const MeetingDetail: React.FC = () => {
getPromptPage({ current: 1, size: 100 }),
getAiModelDefault('LLM'),
]);
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};
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 });
} catch {
return {models: [], promptTemplates: [], defaultModelId: undefined};
// ignore
}
};
@ -1756,19 +1743,17 @@ const MeetingDetail: React.FC = () => {
}
};
const handleOpenSummaryDrawer = async () => {
const {models, promptTemplates, defaultModelId} = await loadAiConfigs();
const handleOpenSummaryDrawer = () => {
summaryForm.setFieldsValue({
summaryModelId:
summaryForm.getFieldValue('summaryModelId') ??
meeting?.summaryModelId ??
defaultModelId ??
models.find((model) => model.isDefault === 1)?.id ??
models[0]?.id,
llmModels.find((model) => model.isDefault === 1)?.id ??
llmModels[0]?.id,
promptId:
summaryForm.getFieldValue('promptId') ??
meeting?.promptId ??
promptTemplates[0]?.id,
prompts[0]?.id,
userPrompt: meeting?.lastUserPrompt ?? '',
summaryDetailLevel:
summaryForm.getFieldValue('summaryDetailLevel') ??
@ -1883,55 +1868,58 @@ const MeetingDetail: React.FC = () => {
});
};
const handleOpenHotWordGroupModal = async () => {
if (!selectedKeywords.length) {
message.warning('请先选择关键词');
return;
}
if (hotWordGroupLimitLoading) {
message.info('热词组上限配置加载中,请稍后重试');
return;
}
setHotWordGroupLoading(true);
try {
const response = await getHotWordGroupOptions();
const options = (response.data?.data || []).filter((item) => item.status === 1 && item.hotWordCount < hotWordGroupLimit);
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(isValidKeyword);
const keywords = selectedKeywords.map((item) => item.trim()).filter(Boolean);
if (!keywords.length) {
message.warning('请先选择关键词');
return;
}
if (selectedHotWordGroupId === undefined) {
message.warning('请选择热词组');
return;
}
setAddingHotwords(true);
try {
const response = await createHotWordBatch({
words: keywords,
hotWordGroupId: selectedHotWordGroupId || undefined,
remark: meeting ? `来源于会议:${meeting.title}` : '来源于会议关键词',
});
const result = response.data?.data;
const existingWords = result?.existingWords || [];
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;
message.success(
`新增 ${result?.createdCount || 0} 个热词${existingWords.length ? `[${existingWords.join(', ')}]已存在热词组` : ''}`,
skippedCount > 0
? `已新增 ${toCreate.length} 个热词,跳过 ${skippedCount} 个重复项`
: `已新增 ${toCreate.length} 个热词`,
);
setSelectedKeywords([]);
setHotWordGroupModalOpen(false);
} catch (error) {
console.error(error);
} finally {
@ -2282,8 +2270,8 @@ const MeetingDetail: React.FC = () => {
</div>
<Switch
checked={sharePasswordEnabled}
checkedChildren={'开启'}
unCheckedChildren={'关闭'}
checkedChildren={'\u5f00\u542f'}
unCheckedChildren={'\u5173\u95ed'}
onChange={handleSharePasswordToggle}
/>
</div>
@ -2292,14 +2280,14 @@ const MeetingDetail: React.FC = () => {
<Input
value={sharePasswordDraft}
maxLength={4}
placeholder={'例如 A7K2'}
placeholder={'\u4f8b\u5982 A7K2'}
onChange={(event) => setSharePasswordDraft(normalizeAccessPasswordInput(event.target.value))}
/>
<Button onClick={handleRegenerateSharePassword}>{'重置默认'}</Button>
<Button onClick={handleRegenerateSharePassword}>{'\u91cd\u7f6e\u9ed8\u8ba4'}</Button>
</div>
) : null}
<Button type="primary" loading={shareSaving} onClick={handleSaveShareAccess}>
{'保存密码设置'}
{'\u4fdd\u5b58\u5bc6\u7801\u8bbe\u7f6e'}
</Button>
</div>
) : null}
@ -2315,7 +2303,7 @@ const MeetingDetail: React.FC = () => {
/>
</div>
<div className="meeting-share-caption">
{'使用手机扫码后将跳转到会议预览页,若已开启密码需手动输入。'}
{'\u4f7f\u7528\u624b\u673a\u626b\u7801\u540e\u5c06\u8df3\u8f6c\u5230\u4f1a\u8bae\u9884\u89c8\u9875\uff0c\u82e5\u5df2\u5f00\u542f\u5bc6\u7801\u9700\u624b\u52a8\u8f93\u5165\u3002'}
</div>
<div className="meeting-share-link-box">
<LinkOutlined />
@ -2323,10 +2311,10 @@ const MeetingDetail: React.FC = () => {
</div>
<div className="meeting-share-actions">
<Button size="small" icon={<CopyOutlined />} onClick={handleCopyPreviewLink}>
{'复制链接'}
{'\u590d\u5236\u94fe\u63a5'}
</Button>
<Button size="small" type="primary" ghost onClick={handleOpenPreview}>
{'打开预览'}
{'\u6253\u5f00\u9884\u89c8'}
</Button>
</div>
</div>
@ -2404,7 +2392,7 @@ const MeetingDetail: React.FC = () => {
</Button>
)}
{canRetrySummary && (
<Button icon={<SyncOutlined/>} onClick={() => void handleOpenSummaryDrawer()} disabled={actionLoading}>
<Button icon={<SyncOutlined />} onClick={handleOpenSummaryDrawer} disabled={actionLoading}>
</Button>
)}
@ -2590,9 +2578,9 @@ const MeetingDetail: React.FC = () => {
ghost
disabled={!selectedKeywords.length}
loading={addingHotwords}
onClick={() => void handleOpenHotWordGroupModal()}
onClick={handleAddSelectedHotwords}
>
{selectedKeywords.length > 0 ? `(${selectedKeywords.length})` : ''}
{selectedKeywords.length > 0 ? `(${selectedKeywords.length})` : ''}
</Button>
)}
</div>
@ -4250,33 +4238,6 @@ const MeetingDetail: React.FC = () => {
}
`}</style>
<Modal
title="加入热词组"
open={hotWordGroupModalOpen}
onCancel={() => setHotWordGroupModalOpen(false)}
onOk={() => void handleAddSelectedHotwords()}
confirmLoading={addingHotwords}
okText="加入"
destroyOnHidden
>
<Form layout="vertical" style={{marginTop: 16}}>
<Form.Item label="目标热词组" required>
<Select
value={selectedHotWordGroupId}
placeholder={hotWordGroupLoading ? '正在加载热词组' : '请选择热词组'}
loading={hotWordGroupLoading}
onChange={setSelectedHotWordGroupId}
options={[
...hotWordGroupOptions.map((item) => ({
label: `${item.groupName} (${item.hotWordCount}/${hotWordGroupLimit})`,
value: item.id,
})),
]}
/>
</Form.Item>
</Form>
</Modal>
{isOwner && (
<Modal title="编辑会议信息" open={editVisible} onOk={handleUpdateBasic} onCancel={() => setEditVisible(false)} confirmLoading={actionLoading} width={600} forceRender>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>

View File

@ -86,16 +86,6 @@ const DEFAULT_CREATE_CONFIG: MeetingCreateConfig = {
realtimeEnabled: true,
offlineAudioMaxSizeMb: 1024,
};
const MEETING_SOURCE_LABELS: Record<string, string> = {
WINDOWS: "Windows",
MACOS: "macOS",
KYLIN: "麒麟",
UOS: "统信",
HARMONYOS: "鸿蒙",
WEB: "Web端",
CUSTOM_TERMINAL: "定制终端",
ANDROID: "定制终端",
};
const isRealtimeMeetingCandidate = (item: MeetingVO) =>
item.meetingType === "REALTIME" || (!item.meetingType && item.status === 0 && !item.audioUrl);
@ -104,7 +94,7 @@ const canControlRealtimeFromCurrentPlatform = (item: MeetingVO) =>
!item.meetingSource || item.meetingSource === CURRENT_PLATFORM;
const getMeetingSourceLabel = (source?: MeetingVO["meetingSource"]) =>
source ? (MEETING_SOURCE_LABELS[source] ?? source) : MEETING_SOURCE_LABELS.WEB;
source === "ANDROID" ? "安卓端" : "Web端";
const getRealtimeSourceLabel = (item: MeetingVO) => getMeetingSourceLabel(item.meetingSource);
@ -323,7 +313,7 @@ const MeetingCardItem: React.FC<{
? (progress?.message || progress?.unifiedStatus?.message || config.text)
: (progress?.unifiedStatus?.message || progress?.message || "深度分析中...");
const sourceColor = item.meetingSource === "CUSTOM_TERMINAL" || item.meetingSource === "ANDROID" ? "#10b981" : "#3b82f6";
const sourceColor = item.meetingSource === "ANDROID" ? "#10b981" : "#3b82f6";
return (
<List.Item className="meeting-card-list-item">
@ -860,13 +850,7 @@ const Meetings: React.FC = () => {
</Button>
)}
{canManageMeeting(record) && (
<Popconfirm
title="确定删除吗?"
onConfirm={(event) => {
event?.stopPropagation();
return deleteMeeting(record.id).then(() => fetchData());
}}
>
<Popconfirm title="确定删除吗?" onConfirm={() => deleteMeeting(record.id).then(() => fetchData())}>
<Button type="link" danger size="small" onClick={(e) => e.stopPropagation()}></Button>
</Popconfirm>
)}

View File

@ -40,25 +40,11 @@
gap: 4px;
}
.prompt-template-name-cell__title-row {
display: flex;
align-items: center;
min-width: 0;
gap: 6px;
}
.prompt-template-name-cell__title-row > .ant-typography {
min-width: 0;
.prompt-template-name-cell > .ant-typography {
max-width: 100%;
margin: 0;
}
.prompt-template-name-cell__title-row > .ant-tag {
flex: 0 0 auto;
margin: 0;
border-radius: 4px;
}
.prompt-template-description.ant-typography {
display: block;
max-width: 360px;

View File

@ -21,28 +21,16 @@ import PageContainer from "@/components/shared/PageContainer";
import DataListPanel from "@/components/shared/DataListPanel";
import FormDrawer from "@/components/shared/FormDrawer";
import SectionCard from "@/components/shared/SectionCard";
import {
CopyOutlined,
DeleteOutlined,
EditOutlined,
EyeOutlined,
PlusOutlined,
SaveOutlined,
StarFilled,
StarOutlined
} from '@ant-design/icons';
import { CopyOutlined, DeleteOutlined, EditOutlined, EyeOutlined, PlusOutlined, SaveOutlined } from '@ant-design/icons';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { useTranslation } from 'react-i18next';
import { useDict } from '../../hooks/useDict';
import {useHotWordGroupLimit} from '../../hooks/useHotWordGroupLimit';
import {
deletePromptTemplate,
clearPromptDefault,
getPromptDetail,
getPromptPage,
savePromptTemplate,
setPromptDefault,
updatePromptStatus,
updatePromptTemplate,
type PromptTemplateVO,
@ -70,7 +58,6 @@ 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 {limit: hotWordGroupLimit} = useHotWordGroupLimit();
const [loading, setLoading] = useState(false);
const [data, setData] = useState<PromptTemplateVO[]>([]);
@ -108,7 +95,8 @@ const PromptTemplates: React.FC = () => {
}, [isPlatformAdmin, templateLevel, activeTenantId]);
const loadGroupOptions = async () => {
const res = await getHotWordGroupOptions();
const targetTenantId = isPlatformAdmin && Number(templateLevel) === 1 ? 0 : undefined;
const res = await getHotWordGroupOptions(targetTenantId ?? (isPlatformAdmin && activeTenantId === 0 ? 0 : undefined));
setGroupOptions(res.data?.data || []);
};
@ -165,7 +153,7 @@ const PromptTemplates: React.FC = () => {
}
if (!canEdit) {
message.warning("您无权限修改此层级的模板");
message.warning('您无权修改此层级的模板');
return;
}
@ -210,8 +198,7 @@ const PromptTemplates: React.FC = () => {
) : null}
<div className="prompt-template-detail__section">
<Space wrap>
{detail.hotWordGroupName ? <Tag color="blue">{detail.hotWordGroupName}</Tag> :
<Tag></Tag>}
{detail.hotWordGroupName ? <Tag color="blue">{detail.hotWordGroupName}</Tag> : <Tag></Tag>}
{normalizePromptTags(detail.tags).map((tag) => {
const dictItem = dictTags.find((item) => item.itemValue === tag);
return <Tag key={tag}>{dictItem ? dictItem.itemLabel : tag}</Tag>;
@ -233,7 +220,7 @@ const PromptTemplates: React.FC = () => {
<ReactMarkdown remarkPlugins={[remarkGfm]}>{detail.promptContent}</ReactMarkdown>
</div>
),
okText: '关闭',
okText: '关闭',
maskClosable: true,
});
})();
@ -250,7 +237,7 @@ const PromptTemplates: React.FC = () => {
message.success('更新成功');
} else {
await savePromptTemplate(values);
message.success("模板创建成功");
message.success('模板已创建');
}
setDrawerVisible(false);
await fetchData();
@ -308,13 +295,12 @@ const PromptTemplates: React.FC = () => {
width: 280,
render: (_: unknown, item: PromptTemplateVO) => (
<div className="prompt-template-name-cell">
<div className="prompt-template-name-cell__title-row">
<Text strong ellipsis={{tooltip: item.templateName}}>{item.templateName}</Text>
{item.isDefault ? <Tag
color={item.defaultAvailable ? 'gold' : 'default'}>{item.defaultAvailable ? '默认' : '默认已失效'}</Tag> : null}
</div>
{item.description ? <Text type="secondary" className="prompt-template-description"
ellipsis={{tooltip: item.description}}>{item.description}</Text> : null}
<Text strong ellipsis={{ tooltip: item.templateName }}>{item.templateName}</Text>
{item.description ? (
<Text type="secondary" className="prompt-template-description" ellipsis={{ tooltip: item.description }}>
{item.description}
</Text>
) : null}
</div>
),
},
@ -333,27 +319,44 @@ const PromptTemplates: React.FC = () => {
return <Tag color={level.color} className="prompt-template-level-tag">{level.label}</Tag>;
},
},
{
title: '热词组',
dataIndex: 'hotWordGroupName',
width: 180,
render: (name: string) => name ? <Tag color="blue">{name}</Tag> : <Text type="secondary"></Text>,
},
{
title: '业务标签',
dataIndex: 'tags',
width: 220,
minWidth: 220,
render: (tags: unknown) => {
const tagList = normalizePromptTags(tags);
if (!tagList.length) return <Text type="secondary"></Text>;
return <Space size={[4, 4]} wrap className="prompt-template-tags-cell">
{tagList.slice(0, 3).map((tag) => <Tag
key={tag}>{dictTags.find((item) => item.itemValue === tag)?.itemLabel || tag}</Tag>)}
{tagList.length > 3 ? <Tag>+{tagList.length - 3}</Tag> : null}
</Space>;
if (!tagList.length) {
return <Text type="secondary"></Text>;
}
return (
<Space size={[4, 4]} wrap className="prompt-template-tags-cell">
{tagList.slice(0, 3).map((tag) => {
const dictItem = dictTags.find((dt) => dt.itemValue === tag);
return <Tag key={tag}>{dictItem ? dictItem.itemLabel : tag}</Tag>;
})}
{tagList.length > 3 ? <Tag>+{tagList.length - 3}</Tag> : null}
</Space>
);
},
},
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (_: unknown, item: PromptTemplateVO) => <Switch checked={item.status === 1}
onChange={(checked) => void handleStatusChange(item.id, checked)}
onClick={(_, event) => event.stopPropagation()}/>,
render: (_: unknown, item: PromptTemplateVO) => (
<Switch
size="small"
checked={item.status === 1}
onClick={(_, event) => event.stopPropagation()}
onChange={(checked) => void handleStatusChange(item.id, checked)}
/>
),
},
{
title: '操作',
@ -362,61 +365,28 @@ const PromptTemplates: React.FC = () => {
fixed: 'right' as const,
render: (_: unknown, item: PromptTemplateVO) => {
const canEdit = canManageTemplate(item);
const canManageDefault = isPlatformAdmin
? item.isSystem === 1 && Number(item.tenantId) === 0
: isTenantAdmin
? item.isSystem === 1 && Number(item.tenantId) === activeTenantId
: item.status === 1;
const isCurrentScopeDefault = isPlatformAdmin || isTenantAdmin
? item.isTemplateDefault === true
: item.isDefault === true && item.defaultScope === "PERSONAL";
const defaultActionLabel = isPlatformAdmin
? "平台默认"
: isTenantAdmin
? "租户默认"
: "个人默认";
return (
<Space size={2} onClick={(e) => e.stopPropagation()}>
<Tooltip title="查看">
<Button type="text" size="small" icon={<EyeOutlined/>} onClick={() => showDetail(item)}
aria-label="查看模板"/>
<Button type="text" size="small" icon={<EyeOutlined />} onClick={() => showDetail(item)} aria-label="查看模板" />
</Tooltip>
{canEdit && (
<Tooltip title="编辑">
<Button type="text" size="small" icon={<EditOutlined/>} onClick={() => handleOpenDrawer(item)}
aria-label="编辑模板"/>
<Button type="text" size="small" icon={<EditOutlined />} onClick={() => handleOpenDrawer(item)} aria-label="编辑模板" />
</Tooltip>
)}
<Tooltip title="以此创建">
<Button type="text" size="small" icon={<CopyOutlined/>} onClick={() => handleOpenDrawer(item, true)}
aria-label="以此创建模板"/>
<Button type="text" size="small" icon={<CopyOutlined />} onClick={() => handleOpenDrawer(item, true)} aria-label="以此创建模板" />
</Tooltip>
{canManageDefault && (
<Tooltip title={isCurrentScopeDefault ? `取消${defaultActionLabel}` : `设为${defaultActionLabel}`}>
<Button
type="text"
size="small"
icon={isCurrentScopeDefault ? <StarFilled/> : <StarOutlined/>}
onClick={() => {
const request = isCurrentScopeDefault ? clearPromptDefault(item.id) : setPromptDefault(item.id);
request.then(() => {
message.success(isCurrentScopeDefault ? `已取消${defaultActionLabel}` : `已设为${defaultActionLabel}`);
void fetchData();
});
}}
aria-label={isCurrentScopeDefault ? `取消${defaultActionLabel}` : `设为${defaultActionLabel}`}
/>
</Tooltip>
)}
{canEdit && (
<Popconfirm
title="确认删除该模板吗"
title="确定删除?"
onConfirm={() => deletePromptTemplate(item.id).then(() => fetchData())}
okText={t('common.confirm')}
cancelText={t('common.cancel')}
>
<Tooltip title="删除">
<Button type="text" size="small" danger icon={<DeleteOutlined/>} aria-label="删除模板"/>
<Button type="text" size="small" danger icon={<DeleteOutlined />} aria-label="删除模板" />
</Tooltip>
</Popconfirm>
)}
@ -430,7 +400,7 @@ const PromptTemplates: React.FC = () => {
<PageContainer title={null} className="prompt-templates-page">
<SectionCard
title="提示词模板"
description="配置 AI 任务所需的提示词模板"
description="管理 AI 会议总结的提示词模板库。"
>
<DataListPanel
className="prompt-templates-list-panel"
@ -443,7 +413,7 @@ const PromptTemplates: React.FC = () => {
<Form layout="inline" onFinish={handleSearch} className="prompt-templates-search">
<Form.Item label="模板名称">
<Input
placeholder="请输入模板名称"
placeholder="请输入..."
className="prompt-templates-search__name"
value={queryDraft.name}
onChange={(event) => setQueryDraft((currentDraft) => ({ ...currentDraft, name: event.target.value }))}
@ -489,13 +459,13 @@ const PromptTemplates: React.FC = () => {
pagination={false}
scroll={{ x: "max(100%, 1400px)", y: "100%" }}
onRow={(record) => ({ onClick: () => showDetail(record) })}
locale={{emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无可用模板"/>}}
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无可用模板" /> }}
/>
</DataListPanel>
</SectionCard>
<FormDrawer
title={editingId ? '编辑模板' : '创建模板'}
title={editingId ? '编辑模板' : '创建模板'}
size="md"
width="min(1536px, 80vw)"
className="prompt-template-form-drawer"
@ -523,20 +493,19 @@ const PromptTemplates: React.FC = () => {
>
<Row gutter={24}>
<Col xs={24} md={12} xl={6}>
<Form.Item name="templateName" label="模板名称"
rules={[{required: true}, {max: 15, message: "模板名称不能超过15个字符"}]}>
<Input maxLength={15} showCount/>
<Form.Item name="templateName" label="模板名称" rules={[{ required: true }]}>
<Input />
</Form.Item>
</Col>
{(isPlatformAdmin || isTenantAdmin) && (
<Col xs={24} md={12} xl={6}>
<Form.Item name="isSystem" label="模板层级" rules={[{required: true}]}>
<Select placeholder="请选择模板层级">
<Form.Item name="isSystem" label="模板属性" rules={[{ required: true }]}>
<Select placeholder="选择属性">
{promptLevels.length > 0 ? (
promptLevels.map((i) => <Option key={i.itemValue} value={Number(i.itemValue)}>{i.itemLabel}</Option>)
) : (
<>
<Option value={1}>{isPlatformAdmin ? '系统预置(全局)' : '租户预置(全员)'}</Option>
<Option value={1}>{isPlatformAdmin ? '系统预置 (全局)' : '租户预置 (全员)'}</Option>
<Option value={0}></Option>
</>
)}
@ -545,7 +514,7 @@ const PromptTemplates: React.FC = () => {
</Col>
)}
<Col xs={24} md={12} xl={6}>
<Form.Item name="category" label="分类" rules={[{required: true}]}>
<Form.Item name="category" label="分类" rules={[{ required: true }]}>
<Select loading={dictLoading}>
{categories.map((i) => <Option key={i.itemValue} value={i.itemValue}>{i.itemLabel}</Option>)}
</Select>
@ -572,7 +541,7 @@ const PromptTemplates: React.FC = () => {
<Row gutter={24}>
<Col xs={24} xl={12}>
<Form.Item name="tags" label="业务标签" tooltip="可选择已有标签,也可直接输入新标签">
<Form.Item name="tags" label="业务标签" tooltip="可从现有标签中选择,也可输入新内容按回车保存">
<Select mode="tags" placeholder="选择或输入新标签" allowClear tokenSeparators={[',', ' ', ';']}>
{dictTags.map((item) => <Option key={item.itemValue} value={item.itemValue}>{item.itemLabel}</Option>)}
</Select>
@ -581,16 +550,13 @@ const PromptTemplates: React.FC = () => {
<Col xs={24} xl={12}>
<Form.Item
name="hotWordGroupId"
label="热词组"
label="绑定热词组"
tooltip="可选,未绑定则保持兼容"
>
<Select
placeholder="选择热词组"
placeholder="选择热词组"
allowClear
options={groupOptions.map((item) => ({
label: `${item.groupName} (${item.hotWordCount}/${hotWordGroupLimit})`,
value: item.id
}))}
options={groupOptions.map((item) => ({ label: `${item.groupName} (${item.hotWordCount}/200)`, value: item.id }))}
/>
</Form.Item>
</Col>
@ -599,7 +565,7 @@ const PromptTemplates: React.FC = () => {
<Row gutter={[12, 16]} className="prompt-template-editor-header">
<Col xs={24} xl={12} className="prompt-template-editor-header__col">
<div className="prompt-template-editor-title">
Markdown
(Markdown )
</div>
</Col>
<Col xs={24} xl={12} className="prompt-template-editor-header__col">
@ -621,7 +587,7 @@ const PromptTemplates: React.FC = () => {
<Input.TextArea
onChange={(e) => setPreviewContent(e.target.value)}
className="prompt-template-editor__input"
placeholder="在此输入 Markdown 提示词..."
placeholder="在此输入 Markdown 指令..."
/>
</Form.Item>
</Col>

View File

@ -29,16 +29,6 @@ import {
const SAMPLE_RATE = 16000;
const CHUNK_SIZE = 1280;
const CURRENT_PLATFORM = "WEB" as const;
const MEETING_SOURCE_LABELS: Record<string, string> = {
WINDOWS: "Windows",
MACOS: "macOS",
KYLIN: "麒麟",
UOS: "统信",
HARMONYOS: "鸿蒙",
WEB: "Web端",
CUSTOM_TERMINAL: "定制终端",
ANDROID: "定制终端",
};
type WsSpeaker = string | { name?: string; user_id?: string | number } | undefined;
type WsMessage = {
@ -97,7 +87,7 @@ type RealtimeMeetingSessionDraft = {
enableItn: boolean;
enableTextRefine: boolean;
saveAudio: boolean;
hotWordGroupId?: number;
hotwords: Array<{ hotword: string; weight: number }>;
};
function getSessionKey(meetingId: number) {
@ -122,7 +112,7 @@ function buildDraftFromStatus(meetingId: number, meeting: MeetingVO | null, stat
enableItn: config.enableItn !== false,
enableTextRefine: !!config.enableTextRefine,
saveAudio: !!config.saveAudio,
hotWordGroupId: config.hotWordGroupId,
hotwords: config.hotwords || [],
};
}
@ -293,7 +283,7 @@ export function RealtimeAsrSession() {
return;
}
if (detail.meetingSource && detail.meetingSource !== CURRENT_PLATFORM) {
const sourceLabel = MEETING_SOURCE_LABELS[detail.meetingSource] ?? detail.meetingSource;
const sourceLabel = detail.meetingSource === "ANDROID" ? "安卓端" : "Web 端";
message.warning(`该实时会议需在${sourceLabel}继续,当前仅支持查看详情`);
navigate(`/meetings/${meetingId}`);
return;
@ -616,7 +606,7 @@ export function RealtimeAsrSession() {
enableItn: sessionDraft.enableItn !== false,
enableTextRefine: !!sessionDraft.enableTextRefine,
saveAudio: !!sessionDraft.saveAudio,
hotWordGroupId: sessionDraft.hotWordGroupId,
hotwords: sessionDraft.hotwords || [],
});
const socketSession = socketSessionRes.data.data;

View File

@ -36,16 +36,6 @@ type RecentCard = {
};
const RECENT_CARD_READ_STORAGE_KEY = "home_recent_card_read_ids";
const MEETING_SOURCE_LABELS: Record<string, string> = {
WINDOWS: "Windows",
MACOS: "macOS",
KYLIN: "麒麟",
UOS: "统信",
HARMONYOS: "鸿蒙",
WEB: "Web端",
CUSTOM_TERMINAL: "定制终端",
ANDROID: "定制终端",
};
const fallbackRecentCards: RecentCard[] = [
{
@ -324,7 +314,7 @@ export default function HomePage() {
<div className="home-recent-card-tags">
{recentTaskMap.get(String(card.id))?.meetingSource && (
<Tag key={`${card.id}-source`} className="home-recent-card-tag" bordered={false}>
{MEETING_SOURCE_LABELS[recentTaskMap.get(String(card.id))?.meetingSource ?? "WEB"] ?? "Web端"}
{recentTaskMap.get(String(card.id))?.meetingSource === "ANDROID" ? "安卓端" : "Web端"}
</Tag>
)}
{card.tags.slice(0, 4).map((tag) => (

View File

@ -175,7 +175,6 @@ export default function Profile() {
};
const renderValue = (value?: string) => value || "-";
const mcpAddress = typeof window === "undefined" ? "/mcp" : `${window.location.origin}/mcp`;
const avatarUrlValue = Form.useWatch("avatarUrl", profileForm) as string | undefined;
const avatarUrl = avatarUrlValue?.trim() || undefined;
const userStatus = user ? (user.status === 0 ? <Tag color="red"></Tag> : <Tag color="green"></Tag>) : "-";
@ -479,12 +478,6 @@ export default function Profile() {
<span>{t("profile.botBindStatus")}</span>
<strong>{credential?.bound ? <Tag color="success">{t("profile.botBound")}</Tag> : <Tag>{t("profile.botUnbound")}</Tag>}</strong>
</div>
<div className="profile-credential-item">
<span>{t("profile.mcpAddress")}</span>
<Paragraph copyable={{text: mcpAddress}} className="profile-copy-value">
{mcpAddress}
</Paragraph>
</div>
<div className="profile-credential-item profile-credential-item--wide">
<span>X-Bot-Id</span>
{credential?.botId ? (

View File

@ -25,11 +25,6 @@ export interface TokenResponse {
refreshExpiresInDays: number;
}
export interface MeetingParticipant {
userId: number;
displayName: string | null;
}
export interface MeetingVO {
id: number;
tenantId: number;
@ -41,13 +36,12 @@ export interface MeetingVO {
meetingTime: string;
participants: string;
participantIds?: number[];
participantUsers?: MeetingParticipant[];
tags: string;
audioUrl: string;
playbackAudioUrl?: string;
duration?: number;
meetingType?: "OFFLINE" | "REALTIME";
meetingSource?: "WINDOWS" | "MACOS" | "KYLIN" | "UOS" | "HARMONYOS" | "WEB" | "CUSTOM_TERMINAL" | "ANDROID";
meetingSource?: "WEB" | "ANDROID";
sourceDeviceCode?: string;
sourceDeviceMode?: "PUBLIC" | "PRIVATE";
summaryDetailLevel?: "DETAILED" | "STANDARD" | "BRIEF";