Compare commits

...

14 Commits

Author SHA1 Message Date
chenh 151236a2cd Merge pull request 'dev_na' (#1) from dev_na into master
Reviewed-on: https://git.unissense.tech/chenh/imeeting/pulls/1
2026-09-04 09:00:27 +00:00
chenhao 2c0caa16be refactor(realtime): 重构实时会议Socket会话与DTO
- 重构 OpenRealtimeSocketSessionCommand,将集合类型替换为明确字段
- 完善 RealtimeMeetingSocketSessionServiceImpl 逻辑及 WebSocket 配置
- 清理前端 MeetingCreateDrawer 中未使用的 Antd 组件导入
- 重构 HotWordServiceImplTest 测试用例并优化 logback 配置格式
2026-09-04 16:03:51 +08:00
chenhao 19a5efa66e Merge branch 'fix_0727' into dev_na 2026-08-11 09:18:00 +08:00
chenhao 72a2bfd8f7 refactor(core): 重构会议接口并统一代码换行符
- 调整前端会议接口类型,新增 MeetingParticipant 定义
- 统一全项目文件换行符为 LF,清理 CRLF 格式
- 完善 .gitignore 规则与 H5 端基础类型定义
2026-08-07 16:52:24 +08:00
chenhao 41a21f4d8e refactor(core): 重构会议接口并统一代码换行符
- 调整前端会议接口类型,新增 MeetingParticipant 定义
- 统一全项目文件换行符为 LF,清理 CRLF 格式
- 完善 .gitignore 规则与 H5 端基础类型定义
2026-08-07 10:58:12 +08:00
chenhao 2660f65bd2 refactor(core): 重构会议接口并统一代码换行符
- 调整前端会议接口类型,新增 MeetingParticipant 定义
- 统一全项目文件换行符为 LF,清理 CRLF 格式
- 完善 .gitignore 规则与 H5 端基础类型定义
2026-08-07 10:43:11 +08:00
chenhao e3a37757ba feat(prompt):返回h5预览地址 2026-08-04 16:51:27 +08:00
chenhao 3931143674 feat(prompt): 集成flyway 2026-08-04 15:50:44 +08:00
chenhao 44a27dadcb feat(prompt): QT新增接口 2026-08-03 16:30:31 +08:00
chenhao 7dcf4f0646 feat(prompt): 增加提示词模板默认配置与作用域
重构 PromptTemplate 相关服务与实体,增加 isDefault 和 defaultScope 字段以支持个人、租户和平台级别的默认模板配置。前端同步更新模板管理页面与 API 接口,并在会议创建组件中集成相关逻辑。
2026-08-03 15:32:35 +08:00
chenhao a53d85bbec refactor(core): 重构业务模块代码格式并清理冗余空行
统一前后端业务模块(热词组、提示词模板等)的代码换行符与导入语句格式,移除 Java 实体、DTO、VO 及 Service 实现类中的冗余空行,优化前端组件及 API 文件的 import 结构。
2026-07-31 17:07:57 +08:00
chenhao 49eaea32b2 refactor(biz): 重构热词与提示词模块并清理文档
主要变更:
1. 扩展热词组与提示词模板的实体及DTO/VO字段
2. 完善前后端相关接口、页面逻辑及国际化配置
3. 调整后端日志配置
4. 移除废弃的 AGENTS.md 设计文档
2026-07-31 16:16:48 +08:00
chenhao eb32f13507 feat(core): 实现热词批量创建及新增会议终端枚举
后端新增热词批量创建接口与相关DTO和VO,新增QtMeetingController支持Qt端会议业务,并新增MeetingTerminalEnum统一管理多终端类型。前端同步适配热词批量创建API并完善会议详情页的终端展示。
2026-07-28 16:44:16 +08:00
chenhao c296600060 refactor(core): 统一代码格式并更新前端图标
统一前后端代码换行符及 import 语句格式,更新前端项目 logo 图标样式。
2026-07-28 11:16:16 +08:00
72 changed files with 4735 additions and 1003 deletions

View File

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

View File

@ -4,9 +4,6 @@ public final class MeetingConstants {
public static final String TYPE_OFFLINE = "OFFLINE"; public static final String TYPE_OFFLINE = "OFFLINE";
public static final String TYPE_REALTIME = "REALTIME"; 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_PUBLIC = "PUBLIC";
public static final String DEVICE_MODE_PRIVATE = "PRIVATE"; public static final String DEVICE_MODE_PRIVATE = "PRIVATE";

View File

@ -1,15 +1,25 @@
package com.imeeting.config; package com.imeeting.config;
import cn.hutool.core.util.StrUtil;
import com.unisbase.common.ApiResponse; 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.core.MethodParameter;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConverter; import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.server.ServerHttpRequest; import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse; 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.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice; import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
import java.util.stream.Collectors;
@RestControllerAdvice @RestControllerAdvice
@Slf4j
public class ApiResponseSuccessCodeAdvice implements ResponseBodyAdvice<Object> { public class ApiResponseSuccessCodeAdvice implements ResponseBodyAdvice<Object> {
private static final String LEGACY_SUCCESS_CODE = "0"; private static final String LEGACY_SUCCESS_CODE = "0";
@ -32,4 +42,13 @@ public class ApiResponseSuccessCodeAdvice implements ResponseBodyAdvice<Object>
} }
return body; 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,6 +6,7 @@ import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactor
import org.springframework.boot.web.server.WebServerFactoryCustomizer; import org.springframework.boot.web.server.WebServerFactoryCustomizer;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; 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.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer; import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
@ -15,6 +16,9 @@ import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry
@RequiredArgsConstructor @RequiredArgsConstructor
public class RealtimeMeetingWebSocketConfig implements WebSocketConfigurer { 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; private final RealtimeMeetingProxyWebSocketHandler realtimeMeetingProxyWebSocketHandler;
@Override @Override
@ -43,4 +47,9 @@ 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 { try {
refresh = authService.refresh(resolveRefreshToken(request, authorization, androidAccessToken)); refresh = authService.refresh(resolveRefreshToken(request, authorization, androidAccessToken));
} catch (Exception e) { } catch (Exception e) {
throw new IllegalArgumentException(e.getMessage()); throw new IllegalArgumentException("刷新令牌已失效,请重新登录");
} }
return ApiResponse.ok(refresh); return ApiResponse.ok(refresh);
} }

View File

@ -8,6 +8,7 @@ import com.imeeting.dto.android.AndroidAuthContext;
import com.imeeting.dto.android.AndroidOfflineMeetingCreateCommand; import com.imeeting.dto.android.AndroidOfflineMeetingCreateCommand;
import com.imeeting.dto.android.AndroidMeetingCreateResponse; import com.imeeting.dto.android.AndroidMeetingCreateResponse;
import com.imeeting.dto.android.AndroidMeetingConfigVo; import com.imeeting.dto.android.AndroidMeetingConfigVo;
import com.imeeting.dto.android.QtMeetingUpdateCommand;
import com.imeeting.dto.android.AndroidMeetingListItemVO; import com.imeeting.dto.android.AndroidMeetingListItemVO;
import com.imeeting.dto.android.AndroidOfflineMeetingConflictVO; import com.imeeting.dto.android.AndroidOfflineMeetingConflictVO;
import com.imeeting.dto.android.AndroidOfflineMeetingFinishRequest; import com.imeeting.dto.android.AndroidOfflineMeetingFinishRequest;
@ -51,6 +52,7 @@ import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@ -76,6 +78,7 @@ import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit; import java.time.temporal.ChronoUnit;
import java.util.Arrays; import java.util.Arrays;
import java.util.Comparator;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@ -291,7 +294,7 @@ public class AndroidMeetingController {
loginUser.getTenantId(), loginUser.getTenantId(),
loginUser.getUserId(), loginUser.getUserId(),
AndroidLoginUserSupport.resolveDisplayName(authContext), AndroidLoginUserSupport.resolveDisplayName(authContext),
"all", "created",
null, null,
AndroidLoginUserSupport.isAdmin(authContext) AndroidLoginUserSupport.isAdmin(authContext)
); );
@ -407,6 +410,28 @@ public class AndroidMeetingController {
return ApiResponse.ok(password); 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会议") @Operation(summary = "删除Android会议")
@ApiResponses({ @ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse( @io.swagger.v3.oas.annotations.responses.ApiResponse(
@ -454,7 +479,13 @@ public class AndroidMeetingController {
? List.of() ? List.of()
: promptTemplateList.getRecords().stream() : promptTemplateList.getRecords().stream()
.filter(item -> Integer.valueOf(1).equals(item.getStatus())) .filter(item -> Integer.valueOf(1).equals(item.getStatus()))
.toList(); .collect(Collectors.toList());
PromptTemplate effectiveDefault = promptTemplateService.findEffectiveUserDefaultTemplate(tenantId, userId);
if (effectiveDefault != null) {
enabledTemplates.sort(Comparator.comparing(
item -> !Objects.equals(item.getId(), effectiveDefault.getId())
));
}
resultVo.setTemplateList(enabledTemplates); resultVo.setTemplateList(enabledTemplates);
PageResult<List<AiModelVO>> modelList = aiModelService.pageModels(1, 1000, null, "LLM", tenantId, false); PageResult<List<AiModelVO>> modelList = aiModelService.pageModels(1, 1000, null, "LLM", tenantId, false);
List<AiModelVO> enabledModels = modelList.getRecords() == null List<AiModelVO> enabledModels = modelList.getRecords() == null

View File

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

View File

@ -2,6 +2,8 @@ package com.imeeting.controller.biz;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; 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.HotWordBatchGroupDTO;
import com.imeeting.dto.biz.HotWordDTO; import com.imeeting.dto.biz.HotWordDTO;
import com.imeeting.dto.biz.HotWordVO; import com.imeeting.dto.biz.HotWordVO;
@ -52,6 +54,18 @@ public class HotWordController {
return ApiResponse.ok(hotWordService.saveHotWord(hotWordDTO, loginUser.getUserId(), targetTenantId)); 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 = "修改热词") @Operation(summary = "修改热词")
@PutMapping @PutMapping
@PreAuthorize("isAuthenticated()") @PreAuthorize("isAuthenticated()")

View File

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

View File

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

View File

@ -119,6 +119,37 @@ public class PromptTemplateController {
return ApiResponse.ok(true); 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 = "删除提示词模板") @Operation(summary = "删除提示词模板")
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
@PreAuthorize("isAuthenticated()") @PreAuthorize("isAuthenticated()")

View File

@ -0,0 +1,96 @@
package com.imeeting.controller.qt;
import com.imeeting.dto.android.AndroidAuthContext;
import com.imeeting.common.MeetingConstants;
import com.imeeting.dto.biz.CreateMeetingCommand;
import com.imeeting.dto.biz.MeetingVO;
import com.imeeting.enums.MeetingTerminalEnum;
import com.imeeting.service.android.AndroidAuthService;
import com.imeeting.service.biz.MeetingAuthorizationService;
import com.imeeting.service.biz.MeetingCommandService;
import com.imeeting.service.biz.PromptTemplateService;
import com.imeeting.support.AndroidRequestLogHelper;
import com.unisbase.annotation.Anonymous;
import com.unisbase.common.ApiResponse;
import com.unisbase.common.annotation.Log;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Tag(name = "Qt会议管理")
@RestController
@RequestMapping("/api/qt/meetings")
@RequiredArgsConstructor
@Slf4j
public class QtMeetingController {
private final AndroidAuthService androidAuthService;
private final MeetingAuthorizationService meetingAuthorizationService;
private final MeetingCommandService meetingCommandService;
private final PromptTemplateService promptTemplateService;
@Operation(summary = "创建 Qt 离线会议")
@PostMapping
@Anonymous
@Log(value = "新增 Qt 离线会议", type = "Qt会议管理")
public ApiResponse<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 = "音频地址不能为空") @NotBlank(message = "音频地址不能为空")
private String audioUrl; private String audioUrl;
@NotNull(message = "asrModelId must not be null") // @NotNull(message = "asrModelId must not be null")
private Long asrModelId; private Long asrModelId;
@NotNull(message = "summaryModelId must not be null") @NotNull(message = "summaryModelId must not be null")
@ -37,7 +37,7 @@ public class CreateMeetingCommand {
private Long chapterModelId; private Long chapterModelId;
@NotNull(message = "promptId must not be null") @NotNull(message = "总结模板不能为空")
private Long promptId; private Long promptId;
private Long hotWordGroupId; private Long hotWordGroupId;

View File

@ -0,0 +1,23 @@
package com.imeeting.dto.biz;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.List;
@Data
@Schema(description = "热词批量新增请求参数")
public class HotWordBatchCreateDTO {
@Schema(description = "租户 ID平台管理员可传 0 表示平台范围")
private Long tenantId;
@Schema(description = "待新增的热词内容列表")
private List<String> words;
@Schema(description = "所属热词组 ID为空表示未分组")
private Long hotWordGroupId;
@Schema(description = "备注")
private String remark;
}

View File

@ -0,0 +1,17 @@
package com.imeeting.dto.biz;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.List;
@Data
@Schema(description = "热词批量新增结果")
public class HotWordBatchCreateResultVO {
@Schema(description = "实际新增热词数量")
private Integer createdCount;
@Schema(description = "目标热词组中已存在的热词")
private List<String> existingWords;
}

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,19 @@
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,6 +42,9 @@ public class MeetingVO {
@Schema(description = "参会人ID列表") @Schema(description = "参会人ID列表")
private List<Long> participantIds; private List<Long> participantIds;
@Schema(description = "参会人列表ID 与名称一一对应")
private List<MeetingParticipantVO> participantUsers;
@Schema(description = "标签串") @Schema(description = "标签串")
private String tags; private String tags;
@ -72,9 +75,15 @@ public class MeetingVO {
@Schema(description = "总结模型ID") @Schema(description = "总结模型ID")
private Long summaryModelId; private Long summaryModelId;
@Schema(description = "总结模型名称")
private String summaryModelName;
@Schema(description = "总结模板ID") @Schema(description = "总结模板ID")
private Long promptId; private Long promptId;
@Schema(description = "总结模板名称")
private String promptName;
@Schema(description = "最终生效热词组ID") @Schema(description = "最终生效热词组ID")
private Long hotWordGroupId; private Long hotWordGroupId;

View File

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

View File

@ -30,6 +30,14 @@ public class PromptTemplateVO {
private String hotWordGroupName; private String hotWordGroupName;
@Schema(description = "绑定热词列表") @Schema(description = "绑定热词列表")
private List<String> hotWords; 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 = "使用次数") @Schema(description = "使用次数")
private Integer usageCount; private Integer usageCount;
@Schema(description = "提示词正文") @Schema(description = "提示词正文")

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,56 @@
package com.imeeting.enums;
import com.unisbase.common.exception.BusinessException;
import lombok.Getter;
@Getter
public enum MeetingTerminalEnum {
WINDOWS("WINDOWS", "Windows"),
MACOS("MACOS", "macOS"),
KYLIN("KYLIN", "麒麟"),
UOS("UOS", "统信"),
HARMONYOS("HARMONYOS", "鸿蒙"),
WEB("WEB", "Web端"),
CUSTOM_TERMINAL("CUSTOM_TERMINAL", "定制终端");
private static final String LEGACY_ANDROID_CODE = "ANDROID";
private final String code;
private final String description;
MeetingTerminalEnum(String code, String description) {
this.code = code;
this.description = description;
}
public static boolean isCustomTerminalSource(String source) {
return CUSTOM_TERMINAL.matches(source) || LEGACY_ANDROID_CODE.equalsIgnoreCase(source);
}
public static MeetingTerminalEnum resolve(String source) {
if (source == null || source.isBlank()) {
throw new BusinessException("会议终端类型不能为空");
}
String normalized = source.trim();
if (LEGACY_ANDROID_CODE.equalsIgnoreCase(normalized)) {
return CUSTOM_TERMINAL;
}
for (MeetingTerminalEnum terminal : values()) {
if (terminal.matches(normalized)) {
return terminal;
}
}
throw new BusinessException("会议终端类型无效: " + source);
}
public static boolean isSameTerminal(String source, String target) {
if (isCustomTerminalSource(source) && isCustomTerminalSource(target)) {
return true;
}
return source != null && target != null && source.equalsIgnoreCase(target);
}
public boolean matches(String source) {
return source != null && code.equalsIgnoreCase(source);
}
}

View File

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

View File

@ -1,6 +1,8 @@
package com.imeeting.service.biz; package com.imeeting.service.biz;
import com.baomidou.mybatisplus.extension.service.IService; 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.HotWordDTO;
import com.imeeting.dto.biz.HotWordVO; import com.imeeting.dto.biz.HotWordVO;
import com.imeeting.entity.biz.HotWord; import com.imeeting.entity.biz.HotWord;
@ -9,6 +11,8 @@ import java.util.List;
public interface HotWordService extends IService<HotWord> { public interface HotWordService extends IService<HotWord> {
HotWordVO saveHotWord(HotWordDTO hotWordDTO, Long userId, Long tenantId); 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); HotWordVO updateHotWord(HotWordDTO hotWordDTO, Long userId, Long tenantId);
Integer updateHotWordGroupBatch(List<Long> ids, Long hotWordGroupId, Long tenantId); Integer updateHotWordGroupBatch(List<Long> ids, Long hotWordGroupId, Long tenantId);
List<String> generatePinyin(String word); List<String> generatePinyin(String word);

View File

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

View File

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

View File

@ -17,4 +17,10 @@ public interface PromptTemplateService extends IService<PromptTemplate> {
Long tenantId, Long userId, Boolean isPlatformAdmin, Boolean isTenantAdmin); Long tenantId, Long userId, Boolean isPlatformAdmin, Boolean isTenantAdmin);
boolean updateUserTemplateStatus(Long templateId, Integer status, 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 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,14 +4,11 @@ import com.imeeting.dto.biz.RealtimeSocketSessionData;
import com.imeeting.dto.biz.RealtimeSocketSessionVO; import com.imeeting.dto.biz.RealtimeSocketSessionVO;
import com.unisbase.security.LoginUser; import com.unisbase.security.LoginUser;
import java.util.List;
import java.util.Map;
public interface RealtimeMeetingSocketSessionService { public interface RealtimeMeetingSocketSessionService {
RealtimeSocketSessionVO createSession(Long meetingId, Long asrModelId, String mode, String language, RealtimeSocketSessionVO createSession(Long meetingId, Long asrModelId, String mode, String language,
Integer useSpkId, Boolean enablePunctuation, Boolean enableItn, Integer useSpkId, Boolean enablePunctuation, Boolean enableItn,
Boolean enableTextRefine, Boolean saveAudio, Boolean enableTextRefine, Boolean saveAudio,
List<Map<String, Object>> hotwords, LoginUser loginUser); Long hotWordGroupId, LoginUser loginUser);
RealtimeSocketSessionData getSessionData(String sessionToken); RealtimeSocketSessionData getSessionData(String sessionToken);
} }

View File

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

View File

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

View File

@ -3,6 +3,8 @@ package com.imeeting.service.biz.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; 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.HotWordDTO;
import com.imeeting.dto.biz.HotWordVO; import com.imeeting.dto.biz.HotWordVO;
import com.imeeting.entity.biz.HotWord; import com.imeeting.entity.biz.HotWord;
@ -10,6 +12,9 @@ import com.imeeting.entity.biz.HotWordGroup;
import com.imeeting.mapper.biz.HotWordGroupMapper; import com.imeeting.mapper.biz.HotWordGroupMapper;
import com.imeeting.mapper.biz.HotWordMapper; import com.imeeting.mapper.biz.HotWordMapper;
import com.imeeting.service.biz.HotWordService; 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.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import net.sourceforge.pinyin4j.PinyinHelper; import net.sourceforge.pinyin4j.PinyinHelper;
@ -32,9 +37,14 @@ import java.util.stream.Collectors;
@RequiredArgsConstructor @RequiredArgsConstructor
public class HotWordServiceImpl extends ServiceImpl<HotWordMapper, HotWord> implements HotWordService { public class HotWordServiceImpl extends ServiceImpl<HotWordMapper, HotWord> implements HotWordService {
private static final int MAX_HOT_WORDS_PER_GROUP = 200; 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 final HotWordGroupMapper hotWordGroupMapper; private final HotWordGroupMapper hotWordGroupMapper;
private final SysDictItemService sysDictItemService;
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
@ -52,12 +62,39 @@ public class HotWordServiceImpl extends ServiceImpl<HotWordMapper, HotWord> impl
return toVO(hotWord); 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 @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public HotWordVO updateHotWord(HotWordDTO hotWordDTO, Long userId, Long tenantId) { public HotWordVO updateHotWord(HotWordDTO hotWordDTO, Long userId, Long tenantId) {
HotWord hotWord = this.getById(hotWordDTO.getId()); HotWord hotWord = this.getById(hotWordDTO.getId());
if (hotWord == null) { if (hotWord == null) {
throw new IllegalArgumentException("热词不存在"); throw new BusinessException("热词不存在");
} }
String oldWord = hotWord.getWord(); String oldWord = hotWord.getWord();
@ -76,20 +113,20 @@ public class HotWordServiceImpl extends ServiceImpl<HotWordMapper, HotWord> impl
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public Integer updateHotWordGroupBatch(List<Long> ids, Long hotWordGroupId, Long tenantId) { public Integer updateHotWordGroupBatch(List<Long> ids, Long hotWordGroupId, Long tenantId) {
if (ids == null || ids.isEmpty()) { if (ids == null || ids.isEmpty()) {
throw new IllegalArgumentException("请选择热词"); throw new BusinessException("请选择热词");
} }
Set<Long> uniqueIds = ids.stream() Set<Long> uniqueIds = ids.stream()
.filter(id -> id != null) .filter(id -> id != null)
.collect(Collectors.toCollection(LinkedHashSet::new)); .collect(Collectors.toCollection(LinkedHashSet::new));
if (uniqueIds.isEmpty()) { if (uniqueIds.isEmpty()) {
throw new IllegalArgumentException("请选择热词"); throw new BusinessException("请选择热词");
} }
List<HotWord> hotWords = this.list(new LambdaQueryWrapper<HotWord>() List<HotWord> hotWords = this.list(new LambdaQueryWrapper<HotWord>()
.in(HotWord::getId, uniqueIds) .in(HotWord::getId, uniqueIds)
.eq(HotWord::getTenantId, tenantId)); .eq(HotWord::getTenantId, tenantId));
if (hotWords.size() != uniqueIds.size()) { if (hotWords.size() != uniqueIds.size()) {
throw new IllegalArgumentException("部分热词不存在或无权操作"); throw new BusinessException("部分热词不存在或无权操作");
} }
if (hotWordGroupId != null) { if (hotWordGroupId != null) {
@ -160,16 +197,17 @@ public class HotWordServiceImpl extends ServiceImpl<HotWordMapper, HotWord> impl
} }
HotWordGroup group = hotWordGroupMapper.selectById(groupId); HotWordGroup group = hotWordGroupMapper.selectById(groupId);
if (group == null || !tenantId.equals(group.getTenantId())) { if (group == null || !tenantId.equals(group.getTenantId())) {
throw new IllegalArgumentException("热词组不存在"); throw new BusinessException("热词组不存在");
} }
if (!Integer.valueOf(1).equals(group.getStatus())) { if (!Integer.valueOf(1).equals(group.getStatus())) {
throw new IllegalArgumentException("热词组已禁用"); throw new BusinessException("热词组已禁用");
} }
long currentCount = this.count(new LambdaQueryWrapper<HotWord>() long currentCount = this.count(new LambdaQueryWrapper<HotWord>()
.eq(HotWord::getHotWordGroupId, groupId) .eq(HotWord::getHotWordGroupId, groupId)
.ne(currentHotWordId != null, HotWord::getId, currentHotWordId)); .ne(currentHotWordId != null, HotWord::getId, currentHotWordId));
if (currentCount >= MAX_HOT_WORDS_PER_GROUP) { int maxHotWordsPerGroup = getMaxHotWordsPerGroup();
throw new IllegalArgumentException("热词组最多只能包含 200 个热词"); if (currentCount >= maxHotWordsPerGroup) {
throwGroupCapacityExceeded(maxHotWordsPerGroup);
} }
return group.getId(); return group.getId();
} }
@ -177,10 +215,10 @@ public class HotWordServiceImpl extends ServiceImpl<HotWordMapper, HotWord> impl
private void validateGroupCapacity(Long groupId, Long tenantId, List<HotWord> movingHotWords) { private void validateGroupCapacity(Long groupId, Long tenantId, List<HotWord> movingHotWords) {
HotWordGroup group = hotWordGroupMapper.selectById(groupId); HotWordGroup group = hotWordGroupMapper.selectById(groupId);
if (group == null || !tenantId.equals(group.getTenantId())) { if (group == null || !tenantId.equals(group.getTenantId())) {
throw new IllegalArgumentException("热词组不存在"); throw new BusinessException("热词组不存在");
} }
if (!Integer.valueOf(1).equals(group.getStatus())) { if (!Integer.valueOf(1).equals(group.getStatus())) {
throw new IllegalArgumentException("热词组已禁用"); throw new BusinessException("热词组已禁用");
} }
Set<Long> movingIds = movingHotWords.stream().map(HotWord::getId).collect(Collectors.toSet()); Set<Long> movingIds = movingHotWords.stream().map(HotWord::getId).collect(Collectors.toSet());
long currentCount = this.count(new LambdaQueryWrapper<HotWord>() long currentCount = this.count(new LambdaQueryWrapper<HotWord>()
@ -189,11 +227,99 @@ public class HotWordServiceImpl extends ServiceImpl<HotWordMapper, HotWord> impl
long incomingCount = movingHotWords.stream() long incomingCount = movingHotWords.stream()
.filter(item -> !groupId.equals(item.getHotWordGroupId())) .filter(item -> !groupId.equals(item.getHotWordGroupId()))
.count(); .count();
if (currentCount + incomingCount > MAX_HOT_WORDS_PER_GROUP) { int maxHotWordsPerGroup = getMaxHotWordsPerGroup();
throw new IllegalArgumentException("热词组最多只能包含 200 个热词"); if (currentCount + incomingCount > maxHotWordsPerGroup) {
throwGroupCapacityExceeded(maxHotWordsPerGroup);
} }
} }
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) { private void generateCombinations(List<List<String>> matrix, int index, String current, List<String> result) {
if (index == matrix.size()) { if (index == matrix.size()) {
result.add(current.trim()); result.add(current.trim());
@ -238,4 +364,5 @@ public class HotWordServiceImpl extends ServiceImpl<HotWordMapper, HotWord> impl
} }
return vo; return vo;
} }
} }

View File

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

View File

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

View File

@ -25,12 +25,14 @@ import com.imeeting.dto.biz.RealtimeMeetingSessionStatusVO;
import com.imeeting.dto.biz.RealtimeTranscriptItemDTO; import com.imeeting.dto.biz.RealtimeTranscriptItemDTO;
import com.imeeting.dto.biz.UpdateMeetingBasicCommand; import com.imeeting.dto.biz.UpdateMeetingBasicCommand;
import com.imeeting.dto.biz.UpdateMeetingTranscriptCommand; import com.imeeting.dto.biz.UpdateMeetingTranscriptCommand;
import com.imeeting.dto.android.QtMeetingUpdateCommand;
import com.imeeting.entity.biz.AiTask; import com.imeeting.entity.biz.AiTask;
import com.imeeting.entity.biz.HotWord; import com.imeeting.entity.biz.HotWord;
import com.imeeting.entity.biz.Meeting; import com.imeeting.entity.biz.Meeting;
import com.imeeting.entity.biz.MeetingTranscript; import com.imeeting.entity.biz.MeetingTranscript;
import com.imeeting.entity.biz.MeetingTranscriptChapterVersion; import com.imeeting.entity.biz.MeetingTranscriptChapterVersion;
import com.imeeting.enums.BusinessErrorCodeEnum; import com.imeeting.enums.BusinessErrorCodeEnum;
import com.imeeting.enums.MeetingTerminalEnum;
import com.imeeting.enums.MeetingStatusEnum; import com.imeeting.enums.MeetingStatusEnum;
import com.imeeting.service.android.AndroidPendingMeetingDraftService; import com.imeeting.service.android.AndroidPendingMeetingDraftService;
import com.imeeting.service.android.AndroidPushMessageService; import com.imeeting.service.android.AndroidPushMessageService;
@ -152,7 +154,19 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public MeetingVO createMeeting(CreateMeetingCommand command, Long tenantId, Long creatorId, String creatorName, String meetingSource) { public MeetingVO createMeeting(CreateMeetingCommand command, Long tenantId, Long creatorId, String creatorName, String meetingSource) {
RealtimeMeetingRuntimeProfile runtimeProfile = resolveCreateProfile(command, tenantId); 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);
Long hostUserId = resolveHostUserId(command.getHostUserId(), creatorId); Long hostUserId = resolveHostUserId(command.getHostUserId(), creatorId);
String resolvedCreatorName = resolveMeetingUserName(creatorId, creatorName); String resolvedCreatorName = resolveMeetingUserName(creatorId, creatorName);
String hostName = resolveMeetingUserName(hostUserId, resolvedCreatorName); String hostName = resolveMeetingUserName(hostUserId, resolvedCreatorName);
@ -160,7 +174,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
Meeting meeting = meetingDomainSupport.initMeeting(command.getTitle(), command.getMeetingTime(), command.getParticipants(), command.getTags(), Meeting meeting = meetingDomainSupport.initMeeting(command.getTitle(), command.getMeetingTime(), command.getParticipants(), command.getTags(),
command.getAudioUrl(), MeetingConstants.TYPE_OFFLINE, meetingSource, tenantId, creatorId, resolvedCreatorName, command.getAudioUrl(), MeetingConstants.TYPE_OFFLINE, meetingSource, tenantId, creatorId, resolvedCreatorName,
hostUserId, hostName, runtimeProfile.getResolvedSummaryModelId(), runtimeProfile.getResolvedPromptId(), hostUserId, hostName, runtimeProfile.getResolvedSummaryModelId(), runtimeProfile.getResolvedPromptId(),
runtimeProfile.getResolvedHotWordGroupId(), summaryDetailLevel, 0); runtimeProfile.getResolvedHotWordGroupId(), summaryDetailLevel, 0, sourceDeviceCode, sourceDeviceMode);
meetingService.save(meeting); meetingService.save(meeting);
AiTask asrTask = new AiTask(); AiTask asrTask = new AiTask();
@ -232,7 +246,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public MeetingVO createRealtimeMeeting(CreateRealtimeMeetingCommand command, Long tenantId, Long creatorId, String creatorName, String meetingSource) { public MeetingVO createRealtimeMeeting(CreateRealtimeMeetingCommand command, Long tenantId, Long creatorId, String creatorName, String meetingSource) {
RealtimeMeetingRuntimeProfile runtimeProfile = resolveCreateProfile(command, tenantId); RealtimeMeetingRuntimeProfile runtimeProfile = resolveCreateProfile(command, tenantId, creatorId);
Long hostUserId = resolveHostUserId(command.getHostUserId(), creatorId); Long hostUserId = resolveHostUserId(command.getHostUserId(), creatorId);
String resolvedCreatorName = resolveMeetingUserName(creatorId, creatorName); String resolvedCreatorName = resolveMeetingUserName(creatorId, creatorName);
String hostName = resolveMeetingUserName(hostUserId, resolvedCreatorName); String hostName = resolveMeetingUserName(hostUserId, resolvedCreatorName);
@ -286,6 +300,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
String deviceCode) { String deviceCode) {
RealtimeMeetingRuntimeProfile runtimeProfile = meetingRuntimeProfileResolver.resolve( RealtimeMeetingRuntimeProfile runtimeProfile = meetingRuntimeProfileResolver.resolve(
tenantId, tenantId,
creatorId,
command.getAsrModelId(), command.getAsrModelId(),
command.getSummaryModelId(), command.getSummaryModelId(),
command.getPromptId(), command.getPromptId(),
@ -310,7 +325,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
command.getTags(), command.getTags(),
null, null,
MeetingConstants.TYPE_OFFLINE, MeetingConstants.TYPE_OFFLINE,
MeetingConstants.SOURCE_ANDROID, MeetingTerminalEnum.CUSTOM_TERMINAL.getCode(),
tenantId, tenantId,
creatorId, creatorId,
resolvedCreatorName, resolvedCreatorName,
@ -753,6 +768,23 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
meetingSummaryFileService.updateSummaryContent(meeting, summaryContent); 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 @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public MeetingTranscriptChapterImportResultVO importTranscriptChapters(MeetingTranscriptChapterImportDTO command) { public MeetingTranscriptChapterImportResultVO importTranscriptChapters(MeetingTranscriptChapterImportDTO command) {
@ -1529,9 +1561,10 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
return resumeConfig; return resumeConfig;
} }
private RealtimeMeetingRuntimeProfile resolveCreateProfile(CreateMeetingCommand command, Long tenantId) { private RealtimeMeetingRuntimeProfile resolveCreateProfile(CreateMeetingCommand command, Long tenantId, Long userId) {
return meetingRuntimeProfileResolver.resolve( return meetingRuntimeProfileResolver.resolve(
tenantId, tenantId,
userId,
command.getAsrModelId(), command.getAsrModelId(),
command.getSummaryModelId(), command.getSummaryModelId(),
command.getPromptId(), command.getPromptId(),
@ -1547,9 +1580,10 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
); );
} }
private RealtimeMeetingRuntimeProfile resolveCreateProfile(CreateRealtimeMeetingCommand command, Long tenantId) { private RealtimeMeetingRuntimeProfile resolveCreateProfile(CreateRealtimeMeetingCommand command, Long tenantId, Long userId) {
return meetingRuntimeProfileResolver.resolve( return meetingRuntimeProfileResolver.resolve(
tenantId, tenantId,
userId,
command.getAsrModelId(), command.getAsrModelId(),
command.getSummaryModelId(), command.getSummaryModelId(),
command.getPromptId(), command.getPromptId(),

View File

@ -1,24 +1,28 @@
package com.imeeting.service.biz.impl; package com.imeeting.service.biz.impl;
import cn.hutool.core.date.StopWatch;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.imeeting.common.MeetingConstants; import com.imeeting.common.MeetingConstants;
import com.imeeting.common.SysParamKeys; import com.imeeting.common.SysParamKeys;
import com.imeeting.entity.biz.AiTask; import com.imeeting.entity.biz.AiTask;
import com.imeeting.entity.biz.HotWordGroup; import com.imeeting.entity.biz.HotWordGroup;
import com.imeeting.entity.biz.Meeting; import com.imeeting.entity.biz.Meeting;
import com.imeeting.entity.biz.MeetingTranscript; import com.imeeting.entity.biz.PromptTemplate;
import com.imeeting.dto.biz.MeetingParticipantVO;
import com.imeeting.event.MeetingCreatedEvent; import com.imeeting.event.MeetingCreatedEvent;
import com.imeeting.mapper.biz.MeetingTranscriptMapper; 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.AiTaskService;
import com.imeeting.service.biz.HotWordGroupService; import com.imeeting.service.biz.HotWordGroupService;
import com.imeeting.service.biz.MeetingPointsService; import com.imeeting.service.biz.MeetingPointsService;
import com.imeeting.service.biz.PromptTemplateService;
import com.imeeting.service.biz.RealtimeMeetingSessionStateService; import com.imeeting.service.biz.RealtimeMeetingSessionStateService;
import com.imeeting.service.biz.MeetingSummaryFileService; import com.imeeting.service.biz.MeetingSummaryFileService;
import com.imeeting.service.realtime.RealtimeMeetingAudioStorageService; import com.imeeting.service.realtime.RealtimeMeetingAudioStorageService;
import com.unisbase.entity.SysUser; import com.unisbase.entity.SysUser;
import com.unisbase.mapper.SysUserMapper; import com.unisbase.mapper.SysUserMapper;
import com.unisbase.service.SysParamService; import com.unisbase.service.SysParamService;
import com.unisbase.service.SysTenantUserService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
@ -40,9 +44,9 @@ import java.time.LocalDateTime;
import java.util.Arrays; import java.util.Arrays;
import java.util.Comparator; import java.util.Comparator;
import java.util.Collections; import java.util.Collections;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.UUID; import java.util.UUID;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@ -57,11 +61,14 @@ public class MeetingDomainSupport {
private final MeetingTranscriptMapper transcriptMapper; private final MeetingTranscriptMapper transcriptMapper;
private final MeetingPointsService meetingPointsService; private final MeetingPointsService meetingPointsService;
private final SysUserMapper sysUserMapper; private final SysUserMapper sysUserMapper;
private final SysTenantUserService sysTenantUserService;
private final ApplicationEventPublisher eventPublisher; private final ApplicationEventPublisher eventPublisher;
private final MeetingSummaryFileService meetingSummaryFileService; private final MeetingSummaryFileService meetingSummaryFileService;
private final MeetingPlaybackAudioResolver meetingPlaybackAudioResolver; private final MeetingPlaybackAudioResolver meetingPlaybackAudioResolver;
private final HotWordGroupService hotWordGroupService; private final HotWordGroupService hotWordGroupService;
private final RealtimeMeetingSessionStateService realtimeMeetingSessionStateService; private final RealtimeMeetingSessionStateService realtimeMeetingSessionStateService;
private final AiModelService aiModelService;
private final PromptTemplateService promptTemplateService;
private final SysParamService sysParamService; private final SysParamService sysParamService;
@Value("${unisbase.app.upload-path}") @Value("${unisbase.app.upload-path}")
@ -433,6 +440,7 @@ public class MeetingDomainSupport {
vo.setOfflineRecordingStatus(meeting.getOfflineRecordingStatus()); vo.setOfflineRecordingStatus(meeting.getOfflineRecordingStatus());
vo.setSummaryModelId(meeting.getSummaryModelId()); vo.setSummaryModelId(meeting.getSummaryModelId());
vo.setPromptId(meeting.getPromptId()); vo.setPromptId(meeting.getPromptId());
fillSummaryConfigurationNames(meeting, vo);
fillEffectiveHotWordGroup(meeting, vo); fillEffectiveHotWordGroup(meeting, vo);
vo.setAiCatalogEnabled(resolveAiCatalogEnabled()); vo.setAiCatalogEnabled(resolveAiCatalogEnabled());
vo.setSummaryDetailLevel(normalizeSummaryDetailLevel(meeting.getSummaryDetailLevel())); vo.setSummaryDetailLevel(normalizeSummaryDetailLevel(meeting.getSummaryDetailLevel()));
@ -453,19 +461,31 @@ public class MeetingDomainSupport {
.map(Long::valueOf) .map(Long::valueOf)
.collect(Collectors.toList()); .collect(Collectors.toList());
vo.setParticipantIds(userIds); vo.setParticipantIds(userIds);
vo.setParticipantUsers(Collections.emptyList());
if (!userIds.isEmpty()) { if (!userIds.isEmpty()) {
List<SysUser> users = sysUserMapper.selectBatchIds(userIds); List<SysUser> users = sysUserMapper.selectBatchIds(userIds);
String names = users.stream() Map<Long, String> userNameMap = users.stream().collect(Collectors.toMap(
.map(u -> u.getDisplayName() != null ? u.getDisplayName() : u.getUsername()) 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)
.collect(Collectors.joining(", ")); .collect(Collectors.joining(", "));
vo.setParticipants(names); vo.setParticipants(names);
} }
} catch (Exception ex) { } catch (Exception ex) {
vo.setParticipantIds(Collections.emptyList()); vo.setParticipantIds(Collections.emptyList());
vo.setParticipantUsers(Collections.emptyList());
vo.setParticipants(meeting.getParticipants()); vo.setParticipants(meeting.getParticipants());
} }
} else { } else {
vo.setParticipantIds(Collections.emptyList()); vo.setParticipantIds(Collections.emptyList());
vo.setParticipantUsers(Collections.emptyList());
} }
fillLatestTaskAttemptInfo(meeting, vo); fillLatestTaskAttemptInfo(meeting, vo);
if (includeSummary) { if (includeSummary) {
@ -475,6 +495,28 @@ 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) { private void fillEffectiveHotWordGroup(Meeting meeting, com.imeeting.dto.biz.MeetingVO vo) {
Long hotWordGroupId = resolveEffectiveHotWordGroupId(meeting); Long hotWordGroupId = resolveEffectiveHotWordGroupId(meeting);
vo.setHotWordGroupId(hotWordGroupId); vo.setHotWordGroupId(hotWordGroupId);

View File

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

View File

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

View File

@ -1,6 +1,7 @@
package com.imeeting.service.biz.impl; package com.imeeting.service.biz.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; 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.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.imeeting.dto.biz.PromptTemplateDTO; import com.imeeting.dto.biz.PromptTemplateDTO;
@ -69,9 +70,20 @@ public class PromptTemplateServiceImpl extends ServiceImpl<PromptTemplateMapper,
Long tenantId, Long userId, Boolean isPlatformAdmin, Boolean isTenantAdmin) { Long tenantId, Long userId, Boolean isPlatformAdmin, Boolean isTenantAdmin) {
LambdaQueryWrapper<PromptTemplate> wrapper = buildVisibilityWrapper(tenantId, userId, isPlatformAdmin, isTenantAdmin); LambdaQueryWrapper<PromptTemplate> wrapper = buildVisibilityWrapper(tenantId, userId, isPlatformAdmin, isTenantAdmin);
wrapper.like(name != null && !name.isEmpty(), PromptTemplate::getTemplateName, name) wrapper.like(name != null && !name.isEmpty(), PromptTemplate::getTemplateName, name)
.eq(category != null && !category.isEmpty(), PromptTemplate::getCategory, category) .eq(category != null && !category.isEmpty(), PromptTemplate::getCategory, category);
.orderByDesc(PromptTemplate::getIsSystem)
.orderByDesc(PromptTemplate::getCreatedAt); 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");
}
Page<PromptTemplate> page = this.page(new Page<>(current, size), wrapper); Page<PromptTemplate> page = this.page(new Page<>(current, size), wrapper);
List<PromptTemplate> records = page.getRecords(); List<PromptTemplate> records = page.getRecords();
@ -79,7 +91,16 @@ public class PromptTemplateServiceImpl extends ServiceImpl<PromptTemplateMapper,
Map<Long, HotWordGroup> hotWordGroupMap = queryHotWordGroupMap(records.stream().map(PromptTemplate::getHotWordGroupId).toList()); Map<Long, HotWordGroup> hotWordGroupMap = queryHotWordGroupMap(records.stream().map(PromptTemplate::getHotWordGroupId).toList());
List<PromptTemplateVO> vos = records.stream() List<PromptTemplateVO> vos = records.stream()
.map(template -> toVO(template, effectiveStatus(template.getIsSystem(), template.getStatus(), userStatusMap.get(template.getId())), hotWordGroupMap)) .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;
})
.collect(Collectors.toList()); .collect(Collectors.toList());
PageResult<List<PromptTemplateVO>> result = new PageResult<>(); PageResult<List<PromptTemplateVO>> result = new PageResult<>();
@ -97,7 +118,17 @@ public class PromptTemplateServiceImpl extends ServiceImpl<PromptTemplateMapper,
throw new IllegalArgumentException("模板不存在"); throw new IllegalArgumentException("模板不存在");
} }
Map<Long, HotWordGroup> hotWordGroupMap = queryHotWordGroupMap(java.util.Collections.singletonList(template.getHotWordGroupId())); Map<Long, HotWordGroup> hotWordGroupMap = queryHotWordGroupMap(java.util.Collections.singletonList(template.getHotWordGroupId()));
PromptTemplateVO vo = toVO(template, template.getStatus(), hotWordGroupMap); 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);
vo.setHotWords(resolveHotWords(template.getHotWordGroupId())); vo.setHotWords(resolveHotWords(template.getHotWordGroupId()));
return vo; return vo;
} }
@ -155,6 +186,138 @@ public class PromptTemplateServiceImpl extends ServiceImpl<PromptTemplateMapper,
return effectiveStatus(template.getIsSystem(), template.getStatus(), userStatus) == 1; 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) { private void validateHotWordGroupBinding(Long hotWordGroupId, Long templateTenantId) {
if (hotWordGroupId == null) { if (hotWordGroupId == null) {
return; return;
@ -214,6 +377,25 @@ public class PromptTemplateServiceImpl extends ServiceImpl<PromptTemplateMapper,
return statusMap; 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) { private Map<Long, HotWordGroup> queryHotWordGroupMap(List<Long> hotWordGroupIds) {
List<Long> ids = hotWordGroupIds == null ? List.of() : hotWordGroupIds.stream() List<Long> ids = hotWordGroupIds == null ? List.of() : hotWordGroupIds.stream()
.filter(Objects::nonNull) .filter(Objects::nonNull)
@ -270,6 +452,7 @@ public class PromptTemplateServiceImpl extends ServiceImpl<PromptTemplateMapper,
vo.setDescription(entity.getDescription()); vo.setDescription(entity.getDescription());
vo.setCategory(entity.getCategory()); vo.setCategory(entity.getCategory());
vo.setIsSystem(entity.getIsSystem()); vo.setIsSystem(entity.getIsSystem());
vo.setIsTemplateDefault(Integer.valueOf(1).equals(entity.getIsDefault()));
vo.setTags(entity.getTags()); vo.setTags(entity.getTags());
Long hotWordGroupId = entity.getHotWordGroupId(); Long hotWordGroupId = entity.getHotWordGroupId();
vo.setHotWordGroupId(hotWordGroupId); vo.setHotWordGroupId(hotWordGroupId);
@ -283,4 +466,11 @@ public class PromptTemplateServiceImpl extends ServiceImpl<PromptTemplateMapper,
vo.setUpdatedAt(entity.getUpdatedAt()); vo.setUpdatedAt(entity.getUpdatedAt());
return vo; 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,8 +6,13 @@ import com.imeeting.dto.biz.RealtimeMeetingResumeConfig;
import com.imeeting.dto.biz.RealtimeMeetingSessionStatusVO; import com.imeeting.dto.biz.RealtimeMeetingSessionStatusVO;
import com.imeeting.dto.biz.RealtimeSocketSessionData; import com.imeeting.dto.biz.RealtimeSocketSessionData;
import com.imeeting.dto.biz.RealtimeSocketSessionVO; 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.entity.biz.Meeting;
import com.imeeting.enums.MeetingTerminalEnum;
import com.imeeting.service.biz.AiModelService; 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.MeetingAccessService;
import com.imeeting.service.biz.RealtimeMeetingSessionStateService; import com.imeeting.service.biz.RealtimeMeetingSessionStateService;
import com.imeeting.service.biz.RealtimeMeetingSocketSessionService; import com.imeeting.service.biz.RealtimeMeetingSocketSessionService;
@ -21,6 +26,8 @@ import org.springframework.stereotype.Service;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.UUID; import java.util.UUID;
import java.math.BigDecimal;
import java.math.RoundingMode;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@ -33,14 +40,16 @@ public class RealtimeMeetingSocketSessionServiceImpl implements RealtimeMeetingS
private final RealtimeMeetingSocketSessionCache socketSessionCache; private final RealtimeMeetingSocketSessionCache socketSessionCache;
private final MeetingAccessService meetingAccessService; private final MeetingAccessService meetingAccessService;
private final AiModelService aiModelService; private final AiModelService aiModelService;
private final RealtimeMeetingSessionStateService realtimeMeetingSessionStateService; private final RealtimeMeetingSessionStateService realtimeMeetingSessionStateService;
private final RealtimeAsrChannelFactory realtimeAsrChannelFactory; private final RealtimeAsrChannelFactory realtimeAsrChannelFactory;
private final HotWordService hotWordService;
private final HotWordGroupService hotWordGroupService;
@Override @Override
public RealtimeSocketSessionVO createSession(Long meetingId, Long asrModelId, String mode, String language, public RealtimeSocketSessionVO createSession(Long meetingId, Long asrModelId, String mode, String language,
Integer useSpkId, Boolean enablePunctuation, Boolean enableItn, Integer useSpkId, Boolean enablePunctuation, Boolean enableItn,
Boolean enableTextRefine, Boolean saveAudio, Boolean enableTextRefine, Boolean saveAudio,
List<Map<String, Object>> hotwords, LoginUser loginUser) { Long hotWordGroupId, LoginUser loginUser) {
if (meetingId == null) { if (meetingId == null) {
throw new RuntimeException("会议 ID 不能为空"); throw new RuntimeException("会议 ID 不能为空");
} }
@ -49,7 +58,7 @@ public class RealtimeMeetingSocketSessionServiceImpl implements RealtimeMeetingS
} }
Meeting meeting = meetingAccessService.requireMeeting(meetingId); Meeting meeting = meetingAccessService.requireMeeting(meetingId);
meetingAccessService.assertCanControlRealtimeMeeting(meeting, loginUser, MeetingConstants.SOURCE_WEB); meetingAccessService.assertCanControlRealtimeMeeting(meeting, loginUser, MeetingTerminalEnum.WEB.getCode());
realtimeMeetingSessionStateService.initSessionIfAbsent(meetingId, loginUser.getTenantId(), loginUser.getUserId()); realtimeMeetingSessionStateService.initSessionIfAbsent(meetingId, loginUser.getTenantId(), loginUser.getUserId());
realtimeMeetingSessionStateService.assertCanOpenSession(meetingId); realtimeMeetingSessionStateService.assertCanOpenSession(meetingId);
@ -68,6 +77,11 @@ public class RealtimeMeetingSocketSessionServiceImpl implements RealtimeMeetingS
RealtimeMeetingSessionStatusVO existingStatus = realtimeMeetingSessionStateService.getStatus(meetingId); RealtimeMeetingSessionStatusVO existingStatus = realtimeMeetingSessionStateService.getStatus(meetingId);
RealtimeMeetingResumeConfig existingConfig = existingStatus == null ? null : existingStatus.getResumeConfig(); 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(); RealtimeMeetingResumeConfig resumeConfig = new RealtimeMeetingResumeConfig();
resumeConfig.setAsrModelId(asrModelId); resumeConfig.setAsrModelId(asrModelId);
resumeConfig.setMode(mode); resumeConfig.setMode(mode);
@ -81,10 +95,8 @@ public class RealtimeMeetingSocketSessionServiceImpl implements RealtimeMeetingS
resumeConfig.setSpeakerContextId(existingConfig.getSpeakerContextId()); resumeConfig.setSpeakerContextId(existingConfig.getSpeakerContextId());
resumeConfig.setUpstreamSessionId(existingConfig.getUpstreamSessionId()); resumeConfig.setUpstreamSessionId(existingConfig.getUpstreamSessionId());
} }
List<Map<String, Object>> effectiveHotwords = (hotwords == null || hotwords.isEmpty())
? (existingConfig == null ? List.of() : existingConfig.getHotwords())
: hotwords;
resumeConfig.setHotwords(effectiveHotwords); resumeConfig.setHotwords(effectiveHotwords);
resumeConfig.setHotWordGroupId(effectiveHotWordGroupId);
realtimeMeetingSessionStateService.rememberResumeConfig(meetingId, resumeConfig); realtimeMeetingSessionStateService.rememberResumeConfig(meetingId, resumeConfig);
RealtimeSocketSessionData sessionData = new RealtimeSocketSessionData(); RealtimeSocketSessionData sessionData = new RealtimeSocketSessionData();
@ -118,6 +130,40 @@ public class RealtimeMeetingSocketSessionServiceImpl implements RealtimeMeetingS
return vo; 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) { private String resolveRealtimeModelCode(AiModelVO asrModel) {
if (asrModel == null) { if (asrModel == null) {
return null; return null;

View File

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

View File

@ -53,6 +53,13 @@ spring:
writetimeout: 5000 writetimeout: 5000
# 启用调试日志(生产环境建议关闭) # 启用调试日志(生产环境建议关闭)
debug: true 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: springdoc:
api-docs: api-docs:
enabled: true enabled: true
@ -71,6 +78,10 @@ mybatis-plus:
logic-not-delete-value: 0 logic-not-delete-value: 0
unisbase: unisbase:
flyway:
base:
baseline-enabled: true
baseline-version: ${UNIS_BASELINE_VERSION:0.1.0}
web: web:
auth-endpoints-enabled: true auth-endpoints-enabled: true
management-endpoints-enabled: true management-endpoints-enabled: true

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@ -1,14 +1,33 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="32" height="32" rx="10" fill="#2D6BFF"/> <g clip-path="url(#clip0_441_732)">
<g transform="translate(16 15.6) rotate(35)" stroke="white" fill="none" stroke-width="1.8"> <rect width="32" height="32" rx="8" fill="url(#paint0_linear_441_732)"/>
<rect x="-5.2" y="-10.2" width="10.4" height="10.4" rx="5.2"/> <path
<path d="M-2.2 0.2 H2.2 L1.1 10.4 H-1.1 Z"/> 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)"/>
</g> </g>
<path <defs>
d="M12.5 22.6 C14.2 21.6 16 21.6 17.8 22.6 S21.4 23.8 23.2 22.6" <linearGradient id="paint0_linear_441_732" x1="16" y1="2.45643e-07" x2="24.2424" y2="32"
stroke="white" gradientUnits="userSpaceOnUse">
stroke-width="1.8" <stop stop-color="#FBFBFB"/>
stroke-linecap="round" <stop offset="1" stop-color="#D7E4F0"/>
fill="none" </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>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 543 B

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -1,4 +1,4 @@
import http from "../http"; import http from "../http";
export interface HotWordVO { export interface HotWordVO {
id: number; id: number;
@ -36,6 +36,18 @@ export interface HotWordBatchGroupDTO {
hotWordGroupId?: number; hotWordGroupId?: number;
} }
export interface HotWordBatchCreateDTO {
tenantId?: number;
words: string[];
hotWordGroupId?: number;
remark?: string;
}
export interface HotWordBatchCreateResultVO {
createdCount: number;
existingWords: string[];
}
export const getHotWordPage = (params: { export const getHotWordPage = (params: {
current: number; current: number;
size: number; size: number;
@ -64,6 +76,13 @@ export const saveHotWord = (data: HotWordDTO) => {
); );
}; };
export const createHotWordBatch = (data: HotWordBatchCreateDTO) => {
return http.post<{ code: string; data: HotWordBatchCreateResultVO; msg: string }>(
"/api/biz/hotword/batch",
data
);
};
export const updateHotWord = (data: HotWordDTO) => { export const updateHotWord = (data: HotWordDTO) => {
return http.put<{ code: string; data: HotWordVO; msg: string }>( return http.put<{ code: string; data: HotWordVO; msg: string }>(
"/api/biz/hotword", "/api/biz/hotword",
@ -91,3 +110,6 @@ export const getPinyinSuggestion = (word: string) => {
{ params: { word } } { params: { word } }
); );
}; };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -49,10 +49,12 @@ import {
updateSpeakerInfo, updateSpeakerInfo,
} from '../../api/business/meeting'; } from '../../api/business/meeting';
import { getAiModelDefault, getAiModelPage, AiModelVO } from '../../api/business/aimodel'; import { getAiModelDefault, getAiModelPage, AiModelVO } from '../../api/business/aimodel';
import { getHotWordPage, getPinyinSuggestion, saveHotWord } from '../../api/business/hotword'; import {createHotWordBatch} from '../../api/business/hotword';
import {getHotWordGroupOptions, type HotWordGroupVO} from '../../api/business/hotwordGroup';
import { getPromptPage, PromptTemplateVO } from '../../api/business/prompt'; import { getPromptPage, PromptTemplateVO } from '../../api/business/prompt';
import { listUsers } from '../../api'; import { listUsers } from '../../api';
import { useDict } from '../../hooks/useDict'; import { useDict } from '../../hooks/useDict';
import {useHotWordGroupLimit} from '../../hooks/useHotWordGroupLimit';
import { SysUser } from '../../types'; import { SysUser } from '../../types';
import PageContainer from "../../components/shared/PageContainer"; import PageContainer from "../../components/shared/PageContainer";
import SectionCard from "../../components/shared/SectionCard"; import SectionCard from "../../components/shared/SectionCard";
@ -248,18 +250,20 @@ const parseBulletList = (content?: string | null) =>
const parseOverviewSection = (markdown: string) => const parseOverviewSection = (markdown: string) =>
extractSection(markdown, ['全文概要', '概要', '摘要', '概览']) || markdown.replace(/^---[\s\S]*?---/, '').trim(); extractSection(markdown, ['全文概要', '概要', '摘要', '概览']) || markdown.replace(/^---[\s\S]*?---/, '').trim();
const isValidKeyword = (value: string) => value.trim() !== '' && value.trim() !== '无';
const parseKeywordsSection = (markdown: string, tags: string) => { const parseKeywordsSection = (markdown: string, tags: string) => {
const section = extractSection(markdown, ['关键词', '关键字', '标签']); const section = extractSection(markdown, ['关键词', '关键字', '标签']);
const fromSection = parseBulletList(section) const fromSection = parseBulletList(section)
.flatMap((line) => line.split(/[,、/]/)) .flatMap((line) => line.split(/[,、/]/))
.map((item) => item.trim()) .map((item) => item.trim())
.filter(Boolean); .filter(isValidKeyword);
if (fromSection.length) { if (fromSection.length) {
return Array.from(new Set(fromSection)).slice(0, 12); return Array.from(new Set(fromSection)).slice(0, 12);
} }
return Array.from(new Set((tags || '').split(',').map((item) => item.trim()).filter(Boolean))).slice(0, 12); return Array.from(new Set((tags || '').split(',').map((item) => item.trim()).filter(isValidKeyword))).slice(0, 12);
}; };
const buildMeetingAnalysis = ( const buildMeetingAnalysis = (
@ -280,7 +284,7 @@ const buildMeetingAnalysis = (
return { return {
overview: String(parsed.overview || '').trim(), overview: String(parsed.overview || '').trim(),
keywords: Array.from( keywords: Array.from(
new Set((Array.isArray(parsed.keywords) ? parsed.keywords : []).map((item) => String(item).trim()).filter(Boolean)), new Set((Array.isArray(parsed.keywords) ? parsed.keywords : []).map((item) => String(item).trim()).filter(isValidKeyword)),
).slice(0, 12), ).slice(0, 12),
chapters: chapters chapters: chapters
.map((item: any) => ({ .map((item: any) => ({
@ -1211,6 +1215,7 @@ const ActiveTranscriptRow = React.memo<ActiveTranscriptRowProps>(({
const MeetingDetail: React.FC = () => { const MeetingDetail: React.FC = () => {
const { message } = App.useApp(); const { message } = App.useApp();
const {limit: hotWordGroupLimit, loading: hotWordGroupLimitLoading} = useHotWordGroupLimit();
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const [form] = Form.useForm(); const [form] = Form.useForm();
@ -1227,6 +1232,10 @@ const MeetingDetail: React.FC = () => {
const [isEditingSummary, setIsEditingSummary] = useState(false); const [isEditingSummary, setIsEditingSummary] = useState(false);
const [summaryDraft, setSummaryDraft] = useState(''); const [summaryDraft, setSummaryDraft] = useState('');
const [selectedKeywords, setSelectedKeywords] = useState<string[]>([]); 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 [workspaceTab, setWorkspaceTab] = useState<WorkspaceTab>('transcript');
const [addingHotwords, setAddingHotwords] = useState(false); const [addingHotwords, setAddingHotwords] = useState(false);
const [editingTranscriptId, setEditingTranscriptId] = useState<number | null>(null); const [editingTranscriptId, setEditingTranscriptId] = useState<number | null>(null);
@ -1288,15 +1297,20 @@ const MeetingDetail: React.FC = () => {
setLoading(false); 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( const analysis = useMemo(
() => buildMeetingAnalysis(meeting?.analysis, meeting?.summaryContent, meeting?.tags || ''), () => buildMeetingAnalysis(meeting?.analysis, meeting?.summaryContent, meeting?.tags || ''),
[meeting?.analysis, meeting?.summaryContent, meeting?.tags], [meeting?.analysis, meeting?.summaryContent, meeting?.tags],
); );
const expandKeywords = false; const expandKeywords = true;
const visibleKeywords = expandKeywords ? analysis.keywords : analysis.keywords.slice(0, 9); const visibleKeywords = expandKeywords ? analysis.keywords : analysis.keywords.slice(0, 9);
const meetingTags = useMemo( const meetingTags = useMemo(
() => (meeting?.tags?.split(',').map((item) => item.trim()).filter(Boolean) || []), () => (meeting?.tags?.split(',').map((item) => item.trim()).filter(isValidKeyword) || []),
[meeting?.tags], [meeting?.tags],
); );
const discussionItems = useMemo(() => { const discussionItems = useMemo(() => {
@ -1388,25 +1402,23 @@ const MeetingDetail: React.FC = () => {
return buildMeetingPreviewUrl(meetingShareBaseUrl, meetingId); return buildMeetingPreviewUrl(meetingShareBaseUrl, meetingId);
}, [meetingShareBaseUrl, meeting?.id, id]); }, [meetingShareBaseUrl, meeting?.id, id]);
const summaryModelDisplayName = useMemo(() => { const summaryModelDisplayName = useMemo(() => {
const matchedModel = llmModels.find((item) => item.id === meeting?.summaryModelId); if (meeting?.summaryModelName?.trim()) {
if (matchedModel?.modelName) { return meeting.summaryModelName.trim();
return matchedModel.modelName;
} }
if (meeting?.summaryModelId) { if (meeting?.summaryModelId) {
return `模型 #${meeting.summaryModelId}`; return `模型 #${meeting.summaryModelId}`;
} }
return '未配置'; return '未配置';
}, [llmModels, meeting?.summaryModelId]); }, [meeting?.summaryModelId, meeting?.summaryModelName]);
const promptDisplayName = useMemo(() => { const promptDisplayName = useMemo(() => {
const matchedPrompt = prompts.find((item) => item.id === meeting?.promptId); if (meeting?.promptName?.trim()) {
if (matchedPrompt?.templateName) { return meeting.promptName.trim();
return matchedPrompt.templateName;
} }
if (meeting?.promptId) { if (meeting?.promptId) {
return `模板 #${meeting.promptId}`; return `模板 #${meeting.promptId}`;
} }
return '未配置'; return '未配置';
}, [meeting?.promptId, prompts]); }, [meeting?.promptId, meeting?.promptName]);
const hotWordGroupDisplayName = useMemo(() => { const hotWordGroupDisplayName = useMemo(() => {
if (meeting?.hotWordGroupName?.trim()) { if (meeting?.hotWordGroupName?.trim()) {
return meeting.hotWordGroupName.trim(); return meeting.hotWordGroupName.trim();
@ -1582,7 +1594,6 @@ const MeetingDetail: React.FC = () => {
useEffect(() => { useEffect(() => {
if (!id) return; if (!id) return;
fetchData(Number(id)); fetchData(Number(id));
loadAiConfigs();
loadUsers(); loadUsers();
}, [id, fetchData]); }, [id, fetchData]);
@ -1648,11 +1659,13 @@ const MeetingDetail: React.FC = () => {
getPromptPage({ current: 1, size: 100 }), getPromptPage({ current: 1, size: 100 }),
getAiModelDefault('LLM'), getAiModelDefault('LLM'),
]); ]);
setLlmModels((modelRes.data?.data?.records || []).filter((item) => item.status === 1)); const models = (modelRes.data?.data?.records || []).filter((item) => item.status === 1);
setPrompts((promptRes.data?.data?.records || []).filter((item) => item.status === 1)); const promptTemplates = (promptRes.data?.data?.records || []).filter((item) => item.status === 1);
summaryForm.setFieldsValue({ summaryModelId: defaultRes.data.data?.id }); setLlmModels(models);
setPrompts(promptTemplates);
return {models, promptTemplates, defaultModelId: defaultRes.data.data?.id};
} catch { } catch {
// ignore return {models: [], promptTemplates: [], defaultModelId: undefined};
} }
}; };
@ -1743,17 +1756,19 @@ const MeetingDetail: React.FC = () => {
} }
}; };
const handleOpenSummaryDrawer = () => { const handleOpenSummaryDrawer = async () => {
const {models, promptTemplates, defaultModelId} = await loadAiConfigs();
summaryForm.setFieldsValue({ summaryForm.setFieldsValue({
summaryModelId: summaryModelId:
summaryForm.getFieldValue('summaryModelId') ?? summaryForm.getFieldValue('summaryModelId') ??
meeting?.summaryModelId ?? meeting?.summaryModelId ??
llmModels.find((model) => model.isDefault === 1)?.id ?? defaultModelId ??
llmModels[0]?.id, models.find((model) => model.isDefault === 1)?.id ??
models[0]?.id,
promptId: promptId:
summaryForm.getFieldValue('promptId') ?? summaryForm.getFieldValue('promptId') ??
meeting?.promptId ?? meeting?.promptId ??
prompts[0]?.id, promptTemplates[0]?.id,
userPrompt: meeting?.lastUserPrompt ?? '', userPrompt: meeting?.lastUserPrompt ?? '',
summaryDetailLevel: summaryDetailLevel:
summaryForm.getFieldValue('summaryDetailLevel') ?? summaryForm.getFieldValue('summaryDetailLevel') ??
@ -1868,58 +1883,55 @@ 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 handleAddSelectedHotwords = async () => {
const keywords = selectedKeywords.map((item) => item.trim()).filter(Boolean); const keywords = selectedKeywords.map((item) => item.trim()).filter(isValidKeyword);
if (!keywords.length) { if (!keywords.length) {
message.warning('请先选择关键词'); message.warning('请先选择关键词');
return; return;
} }
if (selectedHotWordGroupId === undefined) {
message.warning('请选择热词组');
return;
}
setAddingHotwords(true); setAddingHotwords(true);
try { try {
const existingRes = await getHotWordPage({ current: 1, size: 500, word: '' }); const response = await createHotWordBatch({
const existingWords = new Set( words: keywords,
(existingRes.data?.data?.records || []) hotWordGroupId: selectedHotWordGroupId || undefined,
.map((item) => item.word?.trim()) remark: meeting ? `来源于会议:${meeting.title}` : '来源于会议关键词',
.filter(Boolean), });
); const result = response.data?.data;
const toCreate = keywords.filter((item) => !existingWords.has(item)); const existingWords = result?.existingWords || [];
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( message.success(
skippedCount > 0 `新增 ${result?.createdCount || 0} 个热词${existingWords.length ? `[${existingWords.join(', ')}]已存在热词组` : ''}`,
? `已新增 ${toCreate.length} 个热词,跳过 ${skippedCount} 个重复项`
: `已新增 ${toCreate.length} 个热词`,
); );
setSelectedKeywords([]); setSelectedKeywords([]);
setHotWordGroupModalOpen(false);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
} finally { } finally {
@ -2270,8 +2282,8 @@ const MeetingDetail: React.FC = () => {
</div> </div>
<Switch <Switch
checked={sharePasswordEnabled} checked={sharePasswordEnabled}
checkedChildren={'\u5f00\u542f'} checkedChildren={'开启'}
unCheckedChildren={'\u5173\u95ed'} unCheckedChildren={'关闭'}
onChange={handleSharePasswordToggle} onChange={handleSharePasswordToggle}
/> />
</div> </div>
@ -2280,14 +2292,14 @@ const MeetingDetail: React.FC = () => {
<Input <Input
value={sharePasswordDraft} value={sharePasswordDraft}
maxLength={4} maxLength={4}
placeholder={'\u4f8b\u5982 A7K2'} placeholder={'例如 A7K2'}
onChange={(event) => setSharePasswordDraft(normalizeAccessPasswordInput(event.target.value))} onChange={(event) => setSharePasswordDraft(normalizeAccessPasswordInput(event.target.value))}
/> />
<Button onClick={handleRegenerateSharePassword}>{'\u91cd\u7f6e\u9ed8\u8ba4'}</Button> <Button onClick={handleRegenerateSharePassword}>{'重置默认'}</Button>
</div> </div>
) : null} ) : null}
<Button type="primary" loading={shareSaving} onClick={handleSaveShareAccess}> <Button type="primary" loading={shareSaving} onClick={handleSaveShareAccess}>
{'\u4fdd\u5b58\u5bc6\u7801\u8bbe\u7f6e'} {'保存密码设置'}
</Button> </Button>
</div> </div>
) : null} ) : null}
@ -2303,7 +2315,7 @@ const MeetingDetail: React.FC = () => {
/> />
</div> </div>
<div className="meeting-share-caption"> <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>
<div className="meeting-share-link-box"> <div className="meeting-share-link-box">
<LinkOutlined /> <LinkOutlined />
@ -2311,10 +2323,10 @@ const MeetingDetail: React.FC = () => {
</div> </div>
<div className="meeting-share-actions"> <div className="meeting-share-actions">
<Button size="small" icon={<CopyOutlined />} onClick={handleCopyPreviewLink}> <Button size="small" icon={<CopyOutlined />} onClick={handleCopyPreviewLink}>
{'\u590d\u5236\u94fe\u63a5'} {'复制链接'}
</Button> </Button>
<Button size="small" type="primary" ghost onClick={handleOpenPreview}> <Button size="small" type="primary" ghost onClick={handleOpenPreview}>
{'\u6253\u5f00\u9884\u89c8'} {'打开预览'}
</Button> </Button>
</div> </div>
</div> </div>
@ -2392,7 +2404,7 @@ const MeetingDetail: React.FC = () => {
</Button> </Button>
)} )}
{canRetrySummary && ( {canRetrySummary && (
<Button icon={<SyncOutlined />} onClick={handleOpenSummaryDrawer} disabled={actionLoading}> <Button icon={<SyncOutlined/>} onClick={() => void handleOpenSummaryDrawer()} disabled={actionLoading}>
</Button> </Button>
)} )}
@ -2578,9 +2590,9 @@ const MeetingDetail: React.FC = () => {
ghost ghost
disabled={!selectedKeywords.length} disabled={!selectedKeywords.length}
loading={addingHotwords} loading={addingHotwords}
onClick={handleAddSelectedHotwords} onClick={() => void handleOpenHotWordGroupModal()}
> >
{selectedKeywords.length > 0 ? `(${selectedKeywords.length})` : ''} {selectedKeywords.length > 0 ? `(${selectedKeywords.length})` : ''}
</Button> </Button>
)} )}
</div> </div>
@ -4238,6 +4250,33 @@ const MeetingDetail: React.FC = () => {
} }
`}</style> `}</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 && ( {isOwner && (
<Modal title="编辑会议信息" open={editVisible} onOk={handleUpdateBasic} onCancel={() => setEditVisible(false)} confirmLoading={actionLoading} width={600} forceRender> <Modal title="编辑会议信息" open={editVisible} onOk={handleUpdateBasic} onCancel={() => setEditVisible(false)} confirmLoading={actionLoading} width={600} forceRender>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}> <Form form={form} layout="vertical" style={{ marginTop: 16 }}>

View File

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

View File

@ -40,11 +40,25 @@
gap: 4px; gap: 4px;
} }
.prompt-template-name-cell > .ant-typography { .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;
max-width: 100%; max-width: 100%;
margin: 0; margin: 0;
} }
.prompt-template-name-cell__title-row > .ant-tag {
flex: 0 0 auto;
margin: 0;
border-radius: 4px;
}
.prompt-template-description.ant-typography { .prompt-template-description.ant-typography {
display: block; display: block;
max-width: 360px; max-width: 360px;

View File

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

View File

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

View File

@ -36,6 +36,16 @@ type RecentCard = {
}; };
const RECENT_CARD_READ_STORAGE_KEY = "home_recent_card_read_ids"; 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[] = [ const fallbackRecentCards: RecentCard[] = [
{ {
@ -314,7 +324,7 @@ export default function HomePage() {
<div className="home-recent-card-tags"> <div className="home-recent-card-tags">
{recentTaskMap.get(String(card.id))?.meetingSource && ( {recentTaskMap.get(String(card.id))?.meetingSource && (
<Tag key={`${card.id}-source`} className="home-recent-card-tag" bordered={false}> <Tag key={`${card.id}-source`} className="home-recent-card-tag" bordered={false}>
{recentTaskMap.get(String(card.id))?.meetingSource === "ANDROID" ? "安卓端" : "Web端"} {MEETING_SOURCE_LABELS[recentTaskMap.get(String(card.id))?.meetingSource ?? "WEB"] ?? "Web端"}
</Tag> </Tag>
)} )}
{card.tags.slice(0, 4).map((tag) => ( {card.tags.slice(0, 4).map((tag) => (

View File

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

View File

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