feat(core): 新增租户模型激活、ASR同步与会议导出功能

- 实现租户级AI模型激活与默认配置管理
- 新增发言人ASR状态网关及数据同步机制
- 完善会议导出服务的具体实现
- 补充AI模型与会议运行时的单元测试
- 适配前端AI模型管理与发言人注册页面
dev_na
chenhao 2026-07-02 15:19:11 +08:00
parent f5a6a22eb1
commit 17dbeefdf8
38 changed files with 2278 additions and 342 deletions

View File

@ -46,7 +46,7 @@ public class AndroidLlmModelController {
AndroidRequestLogHelper.logRequest(log, "Android模型", "查询启用大模型列表接口");
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
LoginUser loginUser = AndroidLoginUserSupport.requireLoginUser(authContext);
PageResult<List<AiModelVO>> result = aiModelService.pageModels(1, 1000, null, "LLM", loginUser.getTenantId());
PageResult<List<AiModelVO>> result = aiModelService.pageModels(1, 1000, null, "LLM", loginUser.getTenantId(), false);
List<AiModelVO> enabledModels = result.getRecords() == null
? List.of()
: result.getRecords().stream()

View File

@ -453,7 +453,7 @@ public class AndroidMeetingController {
.filter(item -> Integer.valueOf(1).equals(item.getStatus()))
.toList();
resultVo.setTemplateList(enabledTemplates);
PageResult<List<AiModelVO>> modelList = aiModelService.pageModels(1, 1000, null, "LLM", tenantId);
PageResult<List<AiModelVO>> modelList = aiModelService.pageModels(1, 1000, null, "LLM", tenantId, false);
List<AiModelVO> enabledModels = modelList.getRecords() == null
? List.of()
: modelList.getRecords().stream()

View File

@ -35,7 +35,7 @@ public class LegacyLlmModelController {
public LegacyApiResponse<List<LegacyLlmModelItemResponse>> activeModels() {
AndroidRequestLogHelper.logRequest(log, "兼容模型", "查询启用大模型列表接口");
LoginUser loginUser = currentLoginUser();
PageResult<List<AiModelVO>> result = aiModelService.pageModels(1, 1000, null, "LLM", loginUser.getTenantId());
PageResult<List<AiModelVO>> result = aiModelService.pageModels(1, 1000, null, "LLM", loginUser.getTenantId(), false);
List<AiModelVO> enabledModels = result.getRecords() == null
? List.of()
: result.getRecords().stream()

View File

@ -3,6 +3,8 @@ package com.imeeting.controller.biz;
import com.imeeting.dto.biz.AiLocalProfileVO;
import com.imeeting.dto.biz.AiModelDTO;
import com.imeeting.dto.biz.AiModelVO;
import com.imeeting.dto.biz.PlatformAsrStatusUpdateCommand;
import com.imeeting.dto.biz.TenantModelDefaultCommand;
import com.imeeting.enums.ModelProviderEnum;
import com.imeeting.service.biz.AiModelService;
import com.unisbase.common.ApiResponse;
@ -90,9 +92,11 @@ public class AiModelController {
@RequestParam(defaultValue = "1") Integer current,
@RequestParam(defaultValue = "10") Integer size,
@RequestParam(required = false) String name,
@RequestParam(required = false) String type) {
@RequestParam(required = false) String type,
@RequestParam(required = false, defaultValue = "false") Boolean tenantEnabledOnly) {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
return ApiResponse.ok(aiModelService.pageModels(current, size, name, type, loginUser.getTenantId()));
boolean platformAdmin = Boolean.TRUE.equals(loginUser.getIsPlatformAdmin());
return ApiResponse.ok(aiModelService.pageModels(current, size, name, type, loginUser.getTenantId(), platformAdmin, Boolean.TRUE.equals(tenantEnabledOnly)));
}
@Operation(summary = "拉取远程模型列表")
@ -139,4 +143,77 @@ public class AiModelController {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
return ApiResponse.ok(aiModelService.getDefaultModel(type, loginUser.getTenantId()));
}
@Operation(summary = "租户启用 ASR")
@PostMapping("/{id}/tenant-enable")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Boolean> tenantEnable(@PathVariable Long id,
@RequestParam(required = false, defaultValue = "ASR") String type) {
LoginUser loginUser = getLoginUser();
if (loginUser == null || loginUser.getTenantId() == null) {
return ApiResponse.error("未获取到用户信息");
}
aiModelService.enableModelForTenant(type, loginUser.getTenantId(), id);
return ApiResponse.ok(Boolean.TRUE);
}
@Operation(summary = "租户关闭 ASR")
@PostMapping("/{id}/tenant-disable")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Boolean> tenantDisable(@PathVariable Long id,
@RequestParam(required = false, defaultValue = "ASR") String type) {
LoginUser loginUser = getLoginUser();
if (loginUser == null || loginUser.getTenantId() == null) {
return ApiResponse.error("未获取到用户信息");
}
aiModelService.disableModelForTenant(type, loginUser.getTenantId(), id);
return ApiResponse.ok(Boolean.TRUE);
}
@Operation(summary = "平台更新 ASR 状态")
@PostMapping("/{id}/platform-status")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Boolean> updatePlatformStatus(@PathVariable Long id,
@RequestBody PlatformAsrStatusUpdateCommand command,
@RequestParam(required = false, defaultValue = "ASR") String type) {
LoginUser loginUser = getLoginUser();
if (loginUser == null) {
return ApiResponse.error("未获取到用户信息");
}
aiModelService.updatePlatformModelStatus(type, id, command.getStatus(), Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()));
return ApiResponse.ok(Boolean.TRUE);
}
@Operation(summary = "同步当前 ASR 声纹")
@PostMapping("/current/sync-speakers")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Boolean> syncCurrentSpeakers() {
LoginUser loginUser = getLoginUser();
if (loginUser == null || loginUser.getTenantId() == null) {
return ApiResponse.error("未获取到用户信息");
}
aiModelService.syncCurrentTenantActiveAsrSpeakers(loginUser.getTenantId());
return ApiResponse.ok(Boolean.TRUE);
}
@Operation(summary = "绉熸埛璁剧疆榛樿妯″瀷")
@PostMapping("/{id}/tenant-default")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Boolean> setTenantDefault(@PathVariable Long id,
@RequestBody TenantModelDefaultCommand command) {
LoginUser loginUser = getLoginUser();
if (loginUser == null || loginUser.getTenantId() == null) {
return ApiResponse.error("鏈幏鍙栧埌鐢ㄦ埛淇℃伅");
}
aiModelService.setDefaultModelForTenant(command.getModelType(), loginUser.getTenantId(), id);
return ApiResponse.ok(Boolean.TRUE);
}
private LoginUser getLoginUser() {
Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
if (principal instanceof LoginUser loginUser) {
return loginUser;
}
return null;
}
}

View File

@ -311,7 +311,7 @@ public class MeetingController {
throw new RuntimeException("会议不存在");
}
MeetingSummaryExportResult exportResult = meetingExportService.exportSummary(meeting, meetingDetail, format);
MeetingSummaryExportResult exportResult = meetingExportService.exportSummary(meeting, meetingDetail, format, loginUser);
String encodedFilename = URLEncoder.encode(exportResult.getFileName(), StandardCharsets.UTF_8).replace("+", "%20");
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + encodedFilename)

View File

@ -11,7 +11,14 @@ import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@ -64,6 +71,18 @@ public class SpeakerController {
return ApiResponse.ok(speakerService.listVisible(loginUser));
}
@Operation(summary = "同步当前 ASR 声纹")
@PostMapping("/{id}/sync")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Boolean> sync(@PathVariable Long id) {
LoginUser loginUser = getLoginUser();
if (loginUser == null || loginUser.getUserId() == null) {
return ApiResponse.error("未获取到用户信息");
}
speakerService.syncCurrentAsr(id, loginUser);
return ApiResponse.ok(Boolean.TRUE);
}
@Operation(summary = "删除讲话人")
@DeleteMapping("/{id}")
@PreAuthorize("isAuthenticated()")

View File

@ -39,6 +39,14 @@ public class AiModelVO {
private Integer isDefault;
@Schema(description = "启用状态")
private Integer status;
@Schema(description = "当前租户是否启用")
private Integer tenantEnabled;
@Schema(description = "当前租户是否默认")
private Integer tenantDefault;
@Schema(description = "记录作用域: PLATFORM/TENANT")
private String scope;
@Schema(description = "当前用户是否可编辑配置")
private Boolean canEditConfig;
@Schema(description = "排序值,越小越靠前")
private Integer sortOrder;
@Schema(description = "备注")

View File

@ -0,0 +1,13 @@
package com.imeeting.dto.biz;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Data
@Schema(description = "平台级 ASR 状态更新命令")
public class PlatformAsrStatusUpdateCommand {
@NotNull(message = "状态不能为空")
@Schema(description = "状态: 1-启用, 0-禁用", requiredMode = Schema.RequiredMode.REQUIRED)
private Integer status;
}

View File

@ -16,6 +16,8 @@ public class SpeakerVO {
private String voiceExt;
private Long voiceSize;
private Integer status;
private String syncStatus;
private String syncErrorMessage;
private String remark;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")

View File

@ -0,0 +1,17 @@
package com.imeeting.dto.biz;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Data
@Schema(description = "租户默认模型命令")
public class TenantModelDefaultCommand {
@NotNull(message = "模型 ID 不能为空")
@Schema(description = "模型 ID", requiredMode = Schema.RequiredMode.REQUIRED)
private Long modelId;
@NotNull(message = "模型类型不能为空")
@Schema(description = "模型类型: ASR/LLM", requiredMode = Schema.RequiredMode.REQUIRED)
private String modelType;
}

View File

@ -44,6 +44,9 @@ public class Speaker extends BaseEntity {
@Schema(description = "备注")
private String remark;
@Schema(description = "声纹业务版本")
private Long version;
// Note: status, createdAt, updatedAt, isDeleted are in BaseEntity
// embedding is reserved for future pgvector use
}

View File

@ -0,0 +1,48 @@
package com.imeeting.entity.biz;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.unisbase.entity.BaseEntity;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
@Data
@EqualsAndHashCode(callSuper = true)
@Schema(description = "声纹-ASR 同步快照")
@TableName("biz_speaker_asr_sync")
public class SpeakerAsrSync extends BaseEntity {
@TableId(value = "id", type = IdType.AUTO)
@Schema(description = "主键 ID")
private Long id;
@Schema(description = "租户 ID")
private Long tenantId;
@Schema(description = "声纹 ID")
private Long speakerId;
@Schema(description = "ASR 模型 ID")
private Long asrModelId;
@Schema(description = "声纹业务版本")
private Long speakerVersion;
@Schema(description = "第三方声纹 ID")
private String externalSpeakerId;
@Schema(description = "同步状态")
private String syncStatus;
@Schema(description = "上次同步时间")
private LocalDateTime lastSyncedAt;
@Schema(description = "最近一次错误信息")
private String lastErrorMessage;
@Schema(description = "最近一次同步批次 ID")
private String lastSyncBatchId;
}

View File

@ -0,0 +1,34 @@
package com.imeeting.entity.biz;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.unisbase.entity.BaseEntity;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
@Schema(description = "租户模型启用投影")
@TableName("biz_tenant_model_activation")
public class TenantModelActivation extends BaseEntity {
@TableId(value = "id", type = IdType.AUTO)
@Schema(description = "主键 ID")
private Long id;
@Schema(description = "租户 ID")
private Long tenantId;
@Schema(description = "模型类型: ASR/LLM")
private String modelType;
@Schema(description = "模型 ID")
private Long modelId;
@Schema(description = "是否启用: 1-启用, 0-关闭")
private Integer enabled;
@Schema(description = "是否默认: 1-默认, 0-非默认")
private Integer isDefault;
}

View File

@ -0,0 +1,10 @@
package com.imeeting.enums;
public enum SpeakerAsrSyncStatusEnum {
PENDING,
SYNCING,
SYNCED,
FAILED,
STALE,
DELETED
}

View File

@ -0,0 +1,9 @@
package com.imeeting.mapper.biz;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.imeeting.entity.biz.SpeakerAsrSync;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SpeakerAsrSyncMapper extends BaseMapper<SpeakerAsrSync> {
}

View File

@ -0,0 +1,9 @@
package com.imeeting.mapper.biz;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.imeeting.entity.biz.TenantModelActivation;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface TenantModelActivationMapper extends BaseMapper<TenantModelActivation> {
}

View File

@ -11,11 +11,30 @@ import java.util.List;
public interface AiModelService {
AiModelVO saveModel(AiModelDTO dto);
AiModelVO updateModel(AiModelDTO dto);
PageResult<List<AiModelVO>> pageModels(Integer current, Integer size, String name, String type, Long tenantId);
PageResult<List<AiModelVO>> pageModels(Integer current, Integer size, String name, String type, Long tenantId, boolean platformAdmin);
PageResult<List<AiModelVO>> pageModels(Integer current, Integer size, String name, String type, Long tenantId, boolean platformAdmin, boolean tenantEnabledOnly);
List<String> fetchRemoteModels(String provider, String baseUrl, String apiKey);
AiLocalProfileVO testLocalConnectivity(String baseUrl, String apiKey);
void testLlmConnectivity(AiModelDTO dto);
AiModelVO getDefaultModel(String type, Long tenantId);
AiModelVO getModelById(Long id, String type);
boolean removeModelById(Long id, String type);
void enableModelForTenant(String type, Long tenantId, Long modelId);
void disableModelForTenant(String type, Long tenantId, Long modelId);
void setDefaultModelForTenant(String type, Long tenantId, Long modelId);
void updatePlatformModelStatus(String type, Long modelId, Integer status, boolean platformAdmin);
void enableAsrForTenant(Long tenantId, Long asrModelId);
void disableAsrForTenant(Long tenantId, Long asrModelId);
void updatePlatformAsrStatus(Long asrModelId, Integer status, boolean platformAdmin);
void syncCurrentTenantActiveAsrSpeakers(Long tenantId);
}

View File

@ -3,7 +3,8 @@ package com.imeeting.service.biz;
import com.imeeting.dto.biz.MeetingSummaryExportResult;
import com.imeeting.dto.biz.MeetingVO;
import com.imeeting.entity.biz.Meeting;
import com.unisbase.security.LoginUser;
public interface MeetingExportService {
MeetingSummaryExportResult exportSummary(Meeting meeting, MeetingVO meetingDetail, String format);
MeetingSummaryExportResult exportSummary(Meeting meeting, MeetingVO meetingDetail, String format, LoginUser loginUser);
}

View File

@ -0,0 +1,11 @@
package com.imeeting.service.biz;
import com.imeeting.dto.biz.AiModelVO;
import com.imeeting.entity.biz.Speaker;
import com.imeeting.entity.biz.SpeakerAsrSync;
public interface SpeakerAsrGatewayService {
String registerSpeaker(Speaker speaker, AiModelVO asrModel);
void deleteSpeaker(SpeakerAsrSync snapshot, AiModelVO asrModel);
}

View File

@ -0,0 +1,11 @@
package com.imeeting.service.biz;
public interface SpeakerAsrSyncService {
void queueSyncForCurrentAsr(Long tenantId, Long speakerId);
void queueCatchUpForCurrentAsr(Long tenantId);
void invalidateOtherAsrSnapshots(Long tenantId, Long speakerId, Long keepAsrModelId);
void syncSpeakerToAsr(Long tenantId, Long speakerId, Long asrModelId);
}

View File

@ -16,5 +16,7 @@ public interface SpeakerService extends IService<Speaker> {
List<SpeakerVO> listVisible(LoginUser loginUser);
void syncCurrentAsr(Long id, LoginUser loginUser);
void deleteSpeaker(Long id, LoginUser loginUser);
}

View File

@ -0,0 +1,31 @@
package com.imeeting.service.biz;
import java.util.List;
public interface TenantModelActivationService {
Long resolveActiveAsrId(Long tenantId);
void enableAsrForTenant(Long tenantId, Long asrModelId);
void disableAsrForTenant(Long tenantId, Long asrModelId);
List<Long> refreshPlatformInheritanceForAsr(Long asrModelId);
void disableAsrForAllTenants(Long asrModelId);
boolean isTenantEnabled(String modelType, Long tenantId, Long modelId);
List<Long> listEnabledModelIds(String modelType, Long tenantId);
void enableModelForTenant(String modelType, Long tenantId, Long modelId, boolean singleSelect);
void disableModelForTenant(String modelType, Long tenantId, Long modelId);
void disableModelForAllTenants(String modelType, Long modelId);
void setDefaultModelForTenant(String modelType, Long tenantId, Long modelId);
Long resolveDefaultModelId(String modelType, Long tenantId);
List<Long> refreshPlatformInheritanceForLlm(Long modelId);
}

View File

@ -15,9 +15,12 @@ import com.imeeting.enums.ModelProviderEnum;
import com.imeeting.mapper.biz.AsrModelMapper;
import com.imeeting.mapper.biz.LlmModelMapper;
import com.imeeting.service.biz.AiModelService;
import com.imeeting.service.biz.SpeakerAsrSyncService;
import com.imeeting.service.biz.TenantModelActivationService;
import com.unisbase.dto.PageResult;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@ -70,6 +73,10 @@ public class AiModelServiceImpl implements AiModelService {
private final ObjectMapper objectMapper;
private final AsrModelMapper asrModelMapper;
private final LlmModelMapper llmModelMapper;
@Autowired
private TenantModelActivationService tenantModelActivationService;
@Autowired
private SpeakerAsrSyncService speakerAsrSyncService;
private final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(300))
@ -127,12 +134,23 @@ public class AiModelServiceImpl implements AiModelService {
}
@Override
public PageResult<List<AiModelVO>> pageModels(Integer current, Integer size, String name, String type, Long tenantId) {
public PageResult<List<AiModelVO>> pageModels(Integer current, Integer size, String name, String type, Long tenantId, boolean platformAdmin) {
return pageModels(current, size, name, type, tenantId, platformAdmin, false);
}
@Override
public PageResult<List<AiModelVO>> pageModels(Integer current, Integer size, String name, String type, Long tenantId, boolean platformAdmin, boolean tenantEnabledOnly) {
String resolvedType = normalizeType(type);
if (TYPE_ASR.equals(resolvedType)) {
List<Long> enabledModelIds = resolveTenantEnabledModelIds(TYPE_ASR, tenantId, platformAdmin, tenantEnabledOnly);
if (tenantEnabledOnly && !platformAdmin && enabledModelIds.isEmpty()) {
return emptyModelPage();
}
Page<AsrModel> page = new Page<>(current, size);
LambdaQueryWrapper<AsrModel> wrapper = new LambdaQueryWrapper<AsrModel>()
.and(w -> w.eq(AsrModel::getTenantId, tenantId).or().eq(AsrModel::getTenantId, 0L))
.and(!(tenantEnabledOnly && platformAdmin), w -> w.eq(AsrModel::getTenantId, tenantId).or().eq(AsrModel::getTenantId, 0L))
.eq((!platformAdmin || tenantEnabledOnly), AsrModel::getStatus, 1)
.in(tenantEnabledOnly && !platformAdmin, AsrModel::getId, enabledModelIds)
.like(name != null && !name.isBlank(), AsrModel::getModelName, name)
.orderByDesc(AsrModel::getIsDefault)
.orderByAsc(AsrModel::getSortOrder)
@ -141,17 +159,26 @@ public class AiModelServiceImpl implements AiModelService {
Page<AsrModel> resultPage = asrModelMapper.selectPage(page, wrapper);
List<AiModelVO> records = new ArrayList<>();
for (AsrModel entity : resultPage.getRecords()) {
records.add(toAsrVO(entity));
AiModelVO vo = toAsrVO(entity, tenantId, platformAdmin);
if (isVisibleForTenantEnabledOnly(vo, tenantEnabledOnly, platformAdmin)) {
records.add(vo);
}
}
PageResult<List<AiModelVO>> result = new PageResult<>();
result.setTotal(resultPage.getTotal());
result.setTotal(tenantEnabledOnly ? records.size() : resultPage.getTotal());
result.setRecords(records);
return result;
}
List<Long> enabledModelIds = resolveTenantEnabledModelIds(TYPE_LLM, tenantId, platformAdmin, tenantEnabledOnly);
if (tenantEnabledOnly && !platformAdmin && enabledModelIds.isEmpty()) {
return emptyModelPage();
}
Page<LlmModel> page = new Page<>(current, size);
LambdaQueryWrapper<LlmModel> wrapper = new LambdaQueryWrapper<LlmModel>()
.and(w -> w.eq(LlmModel::getTenantId, tenantId).or().eq(LlmModel::getTenantId, 0L))
.and(!(tenantEnabledOnly && platformAdmin), w -> w.eq(LlmModel::getTenantId, tenantId).or().eq(LlmModel::getTenantId, 0L))
.eq((!platformAdmin || tenantEnabledOnly), LlmModel::getStatus, 1)
.in(tenantEnabledOnly && !platformAdmin, LlmModel::getId, enabledModelIds)
.like(name != null && !name.isBlank(), LlmModel::getModelName, name)
.orderByDesc(LlmModel::getIsDefault)
.orderByAsc(LlmModel::getSortOrder)
@ -160,14 +187,114 @@ public class AiModelServiceImpl implements AiModelService {
Page<LlmModel> resultPage = llmModelMapper.selectPage(page, wrapper);
List<AiModelVO> records = new ArrayList<>();
for (LlmModel entity : resultPage.getRecords()) {
records.add(toLlmVO(entity));
AiModelVO vo = toLlmVO(entity, tenantId, platformAdmin);
if (isVisibleForTenantEnabledOnly(vo, tenantEnabledOnly, platformAdmin)) {
records.add(vo);
}
}
PageResult<List<AiModelVO>> result = new PageResult<>();
result.setTotal(resultPage.getTotal());
result.setTotal(tenantEnabledOnly ? records.size() : resultPage.getTotal());
result.setRecords(records);
return result;
}
@Override
public void enableAsrForTenant(Long tenantId, Long asrModelId) {
assertModelEnabled(asrModelId, TYPE_ASR);
tenantModelActivationService.enableAsrForTenant(tenantId, asrModelId);
speakerAsrSyncService.queueCatchUpForCurrentAsr(tenantId);
}
@Override
public void disableAsrForTenant(Long tenantId, Long asrModelId) {
tenantModelActivationService.disableAsrForTenant(tenantId, asrModelId);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void updatePlatformAsrStatus(Long asrModelId, Integer status, boolean platformAdmin) {
if (!platformAdmin) {
throw new RuntimeException("无权修改平台级 ASR 状态");
}
AsrModel entity = asrModelMapper.selectById(asrModelId);
if (entity == null) {
throw new RuntimeException("ASR 模型不存在");
}
if (!Long.valueOf(0L).equals(entity.getTenantId())) {
throw new RuntimeException("仅平台级 ASR 支持该操作");
}
entity.setStatus(status);
asrModelMapper.updateById(entity);
if (Integer.valueOf(1).equals(status)) {
List<Long> newlyEnabledTenantIds = tenantModelActivationService.refreshPlatformInheritanceForAsr(asrModelId);
for (Long tenantId : newlyEnabledTenantIds) {
speakerAsrSyncService.queueCatchUpForCurrentAsr(tenantId);
}
return;
}
tenantModelActivationService.disableAsrForAllTenants(asrModelId);
}
@Override
public void syncCurrentTenantActiveAsrSpeakers(Long tenantId) {
speakerAsrSyncService.queueCatchUpForCurrentAsr(tenantId);
}
@Override
public void enableModelForTenant(String type, Long tenantId, Long modelId) {
String resolvedType = normalizeType(type);
if (TYPE_ASR.equals(resolvedType)) {
enableAsrForTenant(tenantId, modelId);
return;
}
assertModelEnabled(modelId, TYPE_LLM);
tenantModelActivationService.enableModelForTenant(TYPE_LLM, tenantId, modelId, false);
}
@Override
public void disableModelForTenant(String type, Long tenantId, Long modelId) {
String resolvedType = normalizeType(type);
if (TYPE_ASR.equals(resolvedType)) {
disableAsrForTenant(tenantId, modelId);
return;
}
tenantModelActivationService.disableModelForTenant(TYPE_LLM, tenantId, modelId);
}
@Override
public void setDefaultModelForTenant(String type, Long tenantId, Long modelId) {
String resolvedType = normalizeType(type);
assertModelEnabled(modelId, resolvedType);
tenantModelActivationService.setDefaultModelForTenant(resolvedType, tenantId, modelId);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void updatePlatformModelStatus(String type, Long modelId, Integer status, boolean platformAdmin) {
String resolvedType = normalizeType(type);
if (TYPE_ASR.equals(resolvedType)) {
updatePlatformAsrStatus(modelId, status, platformAdmin);
return;
}
if (!platformAdmin) {
throw new RuntimeException("无权修改平台级模型状态");
}
LlmModel entity = llmModelMapper.selectById(modelId);
if (entity == null) {
throw new RuntimeException("LLM 模型不存在");
}
if (!Long.valueOf(0L).equals(entity.getTenantId())) {
throw new RuntimeException("仅平台级 LLM 支持该操作");
}
entity.setStatus(status);
llmModelMapper.updateById(entity);
if (Integer.valueOf(1).equals(status)) {
tenantModelActivationService.refreshPlatformInheritanceForLlm(modelId);
return;
}
tenantModelActivationService.disableModelForAllTenants(TYPE_LLM, modelId);
}
@Override
public List<String> fetchRemoteModels(String provider, String baseUrl, String apiKey) {
try {
@ -765,6 +892,30 @@ public class AiModelServiceImpl implements AiModelService {
public AiModelVO getDefaultModel(String type, Long tenantId) {
String resolvedType = normalizeType(type);
if (TYPE_ASR.equals(resolvedType)) {
AiModelVO activeAsr = resolveActiveAsrModel(tenantId);
if (activeAsr != null) {
return activeAsr;
}
if (tenantId == null) {
AsrModel model = asrModelMapper.selectOne(new LambdaQueryWrapper<AsrModel>()
.eq(AsrModel::getStatus, 1)
.eq(AsrModel::getTenantId, 0L)
.eq(AsrModel::getIsDefault, 1)
.orderByAsc(AsrModel::getSortOrder)
.orderByDesc(AsrModel::getCreatedAt)
.last("LIMIT 1"));
if (model != null) {
return toAsrVO(model);
}
AsrModel firstEnabled = asrModelMapper.selectOne(new LambdaQueryWrapper<AsrModel>()
.eq(AsrModel::getStatus, 1)
.eq(AsrModel::getTenantId, 0L)
.orderByDesc(AsrModel::getIsDefault)
.orderByAsc(AsrModel::getSortOrder)
.orderByDesc(AsrModel::getCreatedAt)
.last("LIMIT 1"));
return firstEnabled == null ? null : toAsrVO(firstEnabled);
}
AsrModel model = asrModelMapper.selectOne(new LambdaQueryWrapper<AsrModel>()
.eq(AsrModel::getStatus, 1)
.eq(AsrModel::getIsDefault, 1)
@ -773,18 +924,52 @@ public class AiModelServiceImpl implements AiModelService {
.orderByAsc(AsrModel::getSortOrder)
.orderByDesc(AsrModel::getCreatedAt)
.last("LIMIT 1"));
return model == null ? null : toAsrVO(model);
if (model != null) {
return toAsrVO(model);
}
AsrModel firstEnabled = asrModelMapper.selectOne(new LambdaQueryWrapper<AsrModel>()
.eq(AsrModel::getStatus, 1)
.and(w -> w.eq(AsrModel::getTenantId, tenantId).or().eq(AsrModel::getTenantId, 0L))
.orderByDesc(AsrModel::getTenantId)
.orderByDesc(AsrModel::getIsDefault)
.orderByAsc(AsrModel::getSortOrder)
.orderByDesc(AsrModel::getCreatedAt)
.last("LIMIT 1"));
return firstEnabled == null ? null : toAsrVO(firstEnabled);
}
if (tenantId == null) {
LlmModel model = llmModelMapper.selectOne(new LambdaQueryWrapper<LlmModel>()
.eq(LlmModel::getStatus, 1)
.eq(LlmModel::getIsDefault, 1)
.and(w -> w.eq(LlmModel::getTenantId, tenantId).or().eq(LlmModel::getTenantId, 0L))
.orderByDesc(LlmModel::getTenantId)
.orderByAsc(LlmModel::getSortOrder)
.orderByDesc(LlmModel::getCreatedAt)
.last("LIMIT 1"));
return model == null ? null : toLlmVO(model);
.eq(LlmModel::getStatus, 1)
.eq(LlmModel::getIsDefault, 1)
.eq(LlmModel::getTenantId, 0L)
.orderByAsc(LlmModel::getSortOrder)
.orderByDesc(LlmModel::getCreatedAt)
.last("LIMIT 1"));
if (model != null) {
return toLlmVO(model);
}
LlmModel firstEnabled = llmModelMapper.selectOne(new LambdaQueryWrapper<LlmModel>()
.eq(LlmModel::getStatus, 1)
.eq(LlmModel::getTenantId, 0L)
.orderByDesc(LlmModel::getIsDefault)
.orderByAsc(LlmModel::getSortOrder)
.orderByDesc(LlmModel::getCreatedAt)
.last("LIMIT 1"));
return firstEnabled == null ? null : toLlmVO(firstEnabled);
}
AiModelVO tenantDefault = resolveTenantDefaultLlm(tenantId);
if (tenantDefault != null) {
return tenantDefault;
}
AiModelVO inheritedDefault = findFirstTenantEnabledLlm(tenantId, true);
if (inheritedDefault != null) {
return inheritedDefault;
}
return findFirstTenantEnabledLlm(tenantId, false);
}
@Override
@ -1000,6 +1185,10 @@ public class AiModelServiceImpl implements AiModelService {
}
private AiModelVO toAsrVO(AsrModel entity) {
return toAsrVO(entity, null, true);
}
private AiModelVO toAsrVO(AsrModel entity, Long tenantId, boolean platformAdmin) {
AiModelVO vo = new AiModelVO();
vo.setId(entity.getId());
vo.setTenantId(entity.getTenantId());
@ -1013,13 +1202,79 @@ public class AiModelServiceImpl implements AiModelService {
vo.setMediaConfig(entity.getMediaConfig());
vo.setIsDefault(entity.getIsDefault());
vo.setStatus(entity.getStatus());
vo.setScope(Long.valueOf(0L).equals(entity.getTenantId()) ? "PLATFORM" : "TENANT");
vo.setTenantEnabled(tenantId == null ? 0 : (tenantModelActivationService.isTenantEnabled(TYPE_ASR, tenantId, entity.getId()) ? 1 : 0));
vo.setTenantDefault(0);
vo.setCanEditConfig(platformAdmin || !Long.valueOf(0L).equals(entity.getTenantId()));
vo.setSortOrder(entity.getSortOrder());
vo.setRemark(entity.getRemark());
vo.setCreatedAt(entity.getCreatedAt());
if (!platformAdmin && Long.valueOf(0L).equals(entity.getTenantId())) {
vo.setBaseUrl(null);
vo.setApiKey(null);
vo.setWsUrl(null);
vo.setMediaConfig(null);
}
return vo;
}
private AiModelVO toLlmVO(LlmModel entity) {
return toLlmVO(entity, null, true);
}
private AiModelVO resolveActiveAsrModel(Long tenantId) {
if (tenantId == null || tenantModelActivationService == null) {
return null;
}
Long activeAsrId = tenantModelActivationService.resolveActiveAsrId(tenantId);
if (activeAsrId == null) {
return null;
}
AiModelVO activeAsr = getModelById(activeAsrId, TYPE_ASR);
if (activeAsr == null || !Integer.valueOf(1).equals(activeAsr.getStatus())) {
return null;
}
return activeAsr;
}
private AiModelVO resolveTenantDefaultLlm(Long tenantId) {
if (tenantId == null || tenantModelActivationService == null) {
return null;
}
Long tenantDefaultId = tenantModelActivationService.resolveDefaultModelId(TYPE_LLM, tenantId);
if (tenantDefaultId == null || !tenantModelActivationService.isTenantEnabled(TYPE_LLM, tenantId, tenantDefaultId)) {
return null;
}
AiModelVO tenantDefault = getModelById(tenantDefaultId, TYPE_LLM);
if (tenantDefault == null || !Integer.valueOf(1).equals(tenantDefault.getStatus())) {
return null;
}
return tenantDefault;
}
private AiModelVO findFirstTenantEnabledLlm(Long tenantId, boolean onlyDefault) {
if (tenantId == null || tenantModelActivationService == null) {
return null;
}
LambdaQueryWrapper<LlmModel> wrapper = new LambdaQueryWrapper<LlmModel>()
.eq(LlmModel::getStatus, 1)
.and(w -> w.eq(LlmModel::getTenantId, tenantId).or().eq(LlmModel::getTenantId, 0L));
if (onlyDefault) {
wrapper.eq(LlmModel::getIsDefault, 1);
}
wrapper.orderByDesc(LlmModel::getTenantId)
.orderByDesc(!onlyDefault, LlmModel::getIsDefault)
.orderByAsc(LlmModel::getSortOrder)
.orderByDesc(LlmModel::getCreatedAt);
for (LlmModel candidate : llmModelMapper.selectList(wrapper)) {
if (tenantModelActivationService.isTenantEnabled(TYPE_LLM, tenantId, candidate.getId())) {
return toLlmVO(candidate);
}
}
return null;
}
private AiModelVO toLlmVO(LlmModel entity, Long tenantId, boolean platformAdmin) {
AiModelVO vo = new AiModelVO();
vo.setId(entity.getId());
vo.setTenantId(entity.getTenantId());
@ -1034,11 +1289,49 @@ public class AiModelServiceImpl implements AiModelService {
vo.setTopP(entity.getTopP());
vo.setIsDefault(entity.getIsDefault());
vo.setStatus(entity.getStatus());
vo.setScope(Long.valueOf(0L).equals(entity.getTenantId()) ? "PLATFORM" : "TENANT");
vo.setTenantEnabled(tenantId == null ? 0 : (tenantModelActivationService.isTenantEnabled(TYPE_LLM, tenantId, entity.getId()) ? 1 : 0));
Long tenantDefaultId = tenantId == null ? null : tenantModelActivationService.resolveDefaultModelId(TYPE_LLM, tenantId);
vo.setTenantDefault(tenantDefaultId != null && tenantDefaultId.equals(entity.getId()) ? 1 : 0);
vo.setCanEditConfig(platformAdmin || !Long.valueOf(0L).equals(entity.getTenantId()));
vo.setSortOrder(entity.getSortOrder());
vo.setRemark(entity.getRemark());
vo.setCreatedAt(entity.getCreatedAt());
return vo;
if (!platformAdmin && Long.valueOf(0L).equals(entity.getTenantId())) {
vo.setBaseUrl(null);
vo.setApiKey(null);
vo.setApiPath(null);
}
return vo;
}
private boolean isVisibleForTenantEnabledOnly(AiModelVO vo, boolean tenantEnabledOnly, boolean platformAdmin) {
if (!tenantEnabledOnly || platformAdmin) {
return true;
}
return Integer.valueOf(1).equals(vo.getTenantEnabled());
}
private List<Long> resolveTenantEnabledModelIds(String modelType, Long tenantId, boolean platformAdmin, boolean tenantEnabledOnly) {
if (!tenantEnabledOnly || platformAdmin || tenantModelActivationService == null) {
return Collections.emptyList();
}
return tenantModelActivationService.listEnabledModelIds(modelType, tenantId);
}
private PageResult<List<AiModelVO>> emptyModelPage() {
PageResult<List<AiModelVO>> result = new PageResult<>();
result.setTotal(0L);
result.setRecords(Collections.emptyList());
return result;
}
private void assertModelEnabled(Long modelId, String type) {
AiModelVO model = getModelById(modelId, type);
if (model == null || !Integer.valueOf(1).equals(model.getStatus())) {
throw new RuntimeException(type + " 模型不存在或未启用");
}
}
private Integer normalizeSortOrder(Integer sortOrder) {
return sortOrder == null ? DEFAULT_SORT_ORDER : sortOrder;

View File

@ -1278,7 +1278,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
log.info("LLM summary response status={}, body={}", response.statusCode(), response.body());
JsonNode respNode = objectMapper.readTree(response.body());
taskRecord.setResponseData(objectMapper.convertValue(respNode, Map.class));
if (response.statusCode() == 200 && respNode.has("choices")) {
String content = sanitizeSummaryContent(respNode.path("choices").path(0).path("message").path("content").asText());
Map<String, Object> summaryBundle = meetingSummaryFileService.parseSummaryBundle(content);

View File

@ -6,10 +6,27 @@ import com.imeeting.entity.biz.Meeting;
import com.imeeting.service.biz.MeetingExportService;
import com.imeeting.service.biz.MeetingSummaryFileService;
import com.openhtmltopdf.pdfboxout.PdfRendererBuilder;
import com.unisbase.dto.SysOrgDTO;
import com.unisbase.dto.SysTenantDTO;
import com.unisbase.dto.SysTenantUserDTO;
import com.unisbase.security.LoginUser;
import com.unisbase.service.SysOrgService;
import com.unisbase.service.SysTenantUserService;
import com.unisbase.service.TenantManagementService;
import lombok.RequiredArgsConstructor;
import org.apache.pdfbox.pdmodel.graphics.blend.BlendMode;
import org.apache.poi.xwpf.usermodel.XWPFDocument;
import org.apache.poi.xwpf.usermodel.XWPFParagraph;
import org.apache.poi.xwpf.usermodel.XWPFRun;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.PDPageContentStream.AppendMode;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDFont;
import org.apache.pdfbox.pdmodel.font.PDType0Font;
import org.apache.pdfbox.pdmodel.graphics.state.PDExtendedGraphicsState;
import org.apache.pdfbox.util.Matrix;
import org.commonmark.node.Node;
import org.commonmark.parser.Parser;
import org.commonmark.renderer.html.HtmlRenderer;
@ -25,8 +42,12 @@ import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@ -34,13 +55,24 @@ import java.util.regex.Pattern;
@RequiredArgsConstructor
public class MeetingExportServiceImpl implements MeetingExportService {
private static final DateTimeFormatter WATERMARK_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
private static final float WATERMARK_FONT_SIZE = 36f;
private static final float WATERMARK_ALPHA = 0.16f;
private static final float WATERMARK_ANGLE = (float) Math.toRadians(30);
private static final float WATERMARK_LINE_GAP = 34f;
private static final float WATERMARK_BASE_STEP_X = 430f;
private static final float WATERMARK_BASE_STEP_Y = 300f;
private final MeetingSummaryFileService meetingSummaryFileService;
private final SysTenantUserService sysTenantUserService;
private final SysOrgService sysOrgService;
private final TenantManagementService tenantManagementService;
@Value("${unisbase.app.upload-path}")
private String uploadPath;
@Override
public MeetingSummaryExportResult exportSummary(Meeting meeting, MeetingVO meetingDetail, String format) {
public MeetingSummaryExportResult exportSummary(Meeting meeting, MeetingVO meetingDetail, String format, LoginUser loginUser) {
Path summarySourcePath = meetingSummaryFileService.requireSummarySourcePath(meeting);
String safeTitle = (meetingDetail.getTitle() == null || meetingDetail.getTitle().trim().isEmpty())
? "meeting-summary-" + meeting.getId()
@ -64,19 +96,24 @@ public class MeetingExportServiceImpl implements MeetingExportService {
Files.createDirectories(exportDir);
Path exportPath = exportDir.resolve("summary." + ext);
boolean isPdf = "pdf".equals(ext);
boolean needRegenerate = !Files.exists(exportPath)
|| Files.getLastModifiedTime(exportPath).toMillis() < Files.getLastModifiedTime(summarySourcePath).toMillis();
byte[] bytes;
byte[] baseBytes;
if (needRegenerate) {
String markdown = Files.readString(summarySourcePath, StandardCharsets.UTF_8);
meetingDetail.setSummaryContent(meetingSummaryFileService.stripFrontMatter(markdown));
bytes = "docx".equals(ext) ? buildWordBytes(meetingDetail) : buildPdfBytes(meetingDetail);
Files.write(exportPath, bytes);
baseBytes = "docx".equals(ext) ? buildWordBytes(meetingDetail) : buildPdfBytes(meetingDetail);
Files.write(exportPath, baseBytes);
} else {
bytes = Files.readAllBytes(exportPath);
baseBytes = Files.readAllBytes(exportPath);
}
byte[] bytes = isPdf
? applyPdfWatermark(baseBytes, resolveWatermarkText(loginUser))
: baseBytes;
return new MeetingSummaryExportResult(bytes, contentType, safeTitle + "-AI-Summary." + ext);
} catch (IOException ex) {
throw new RuntimeException("导出失败:" + ex.getMessage(), ex);
@ -198,6 +235,190 @@ public class MeetingExportServiceImpl implements MeetingExportService {
}
}
private byte[] applyPdfWatermark(byte[] pdfBytes, String watermarkText) throws IOException {
if (watermarkText == null || watermarkText.isBlank()) {
return pdfBytes;
}
try (PDDocument document = PDDocument.load(pdfBytes);
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
PDFont font = loadWatermarkFont(document);
if (font == null) {
return pdfBytes;
}
for (PDPage page : document.getPages()) {
writeWatermarkGrid(document, page, font, watermarkText);
}
document.save(out);
return out.toByteArray();
}
}
private void writeWatermarkGrid(PDDocument document, PDPage page, PDFont font, String watermarkText) throws IOException {
PDRectangle box = page.getMediaBox();
float width = box.getWidth();
float height = box.getHeight();
List<String> lines = watermarkLines(watermarkText);
try (PDPageContentStream contentStream = new PDPageContentStream(
document,
page,
AppendMode.APPEND,
true,
true
)) {
contentStream.saveGraphicsState();
PDExtendedGraphicsState graphicsState = new PDExtendedGraphicsState();
graphicsState.setNonStrokingAlphaConstant(WATERMARK_ALPHA);
graphicsState.setBlendMode(BlendMode.MULTIPLY);
contentStream.setGraphicsStateParameters(graphicsState);
contentStream.setNonStrokingColor(80, 80, 80);
contentStream.setFont(font, WATERMARK_FONT_SIZE);
// 保持整体排布可读,同时用轻微错位破坏完全规则的网格特征。
int rowIndex = 0;
for (float y = -height * 0.25f; y < height * 1.35f; y += irregularStepY(rowIndex++)) {
float rowOffset = rowIndex % 2 == 0 ? 0 : WATERMARK_BASE_STEP_X * 0.34f;
int columnIndex = 0;
for (float x = -width * 0.45f + rowOffset; x < width * 1.45f; x += irregularStepX(rowIndex, columnIndex++)) {
float xOffset = irregularOffset(rowIndex * 31 + columnIndex * 17, WATERMARK_BASE_STEP_X * 0.05f);
float yOffset = irregularOffset(rowIndex * 19 + columnIndex * 23, WATERMARK_BASE_STEP_Y * 0.04f);
contentStream.beginText();
contentStream.setTextMatrix(Matrix.getRotateInstance(WATERMARK_ANGLE, x + xOffset, y + yOffset));
contentStream.showText(lines.get(0));
if (lines.size() > 1) {
contentStream.newLineAtOffset(0, -WATERMARK_LINE_GAP);
contentStream.showText(lines.get(1));
}
contentStream.endText();
}
}
contentStream.restoreGraphicsState();
}
}
private float irregularStepX(int rowIndex, int columnIndex) {
return WATERMARK_BASE_STEP_X + irregularOffset(rowIndex * 13 + columnIndex * 29, 24f);
}
private float irregularStepY(int rowIndex) {
return WATERMARK_BASE_STEP_Y + irregularOffset(rowIndex * 37, 18f);
}
private float irregularOffset(int seed, float amplitude) {
int normalized = Math.floorMod(seed * 1103515245 + 12345, 1000);
return ((normalized / 1000f) - 0.5f) * 2f * amplitude;
}
private List<String> watermarkLines(String watermarkText) {
String[] parts = watermarkText == null ? new String[0] : watermarkText.split("\\R", 2);
List<String> lines = new ArrayList<>();
for (String part : parts) {
if (part != null && !part.isBlank()) {
lines.add(part.trim());
}
}
return lines.isEmpty() ? List.of("内部资料请勿外传") : lines;
}
private PDFont loadWatermarkFont(PDDocument document) throws IOException {
java.io.InputStream notoStream = getClass().getResourceAsStream("/fonts/NotoSansSC-VF.ttf");
if (notoStream != null) {
try (java.io.InputStream fontStream = notoStream) {
return PDType0Font.load(document, fontStream, true);
}
}
java.io.InputStream simsunStream = getClass().getResourceAsStream("/fonts/simsunb.ttf");
if (simsunStream != null) {
try (java.io.InputStream fontStream = simsunStream) {
return PDType0Font.load(document, fontStream, true);
}
}
return null;
}
String resolveWatermarkText(LoginUser loginUser) {
String scope = resolveOrgPath(loginUser);
if (scope == null || scope.isBlank()) {
scope = resolveTenantName(loginUser);
}
String userName = resolveWatermarkUserName(loginUser);
String exportedAt = LocalDateTime.now().format(WATERMARK_TIME_FORMATTER);
String scopeText = scope == null || scope.isBlank() ? "内部资料" : scope.trim();
// return scopeText + " 内部资料请勿外传\n" + userName + " " + exportedAt;
return scopeText + " 内部资料请勿外传";
}
private String resolveWatermarkUserName(LoginUser loginUser) {
if (loginUser == null) {
return "未知用户";
}
if (loginUser.getDisplayName() != null && !loginUser.getDisplayName().isBlank()) {
return loginUser.getDisplayName().trim();
}
if (loginUser.getUsername() != null && !loginUser.getUsername().isBlank()) {
return loginUser.getUsername().trim();
}
return loginUser.getUserId() == null ? "未知用户" : "用户" + loginUser.getUserId();
}
private String resolveOrgPath(LoginUser loginUser) {
if (loginUser == null || loginUser.getUserId() == null || loginUser.getTenantId() == null) {
return null;
}
try {
SysTenantUserDTO membership = sysTenantUserService.listByUserId(loginUser.getUserId()).stream()
.filter(item -> loginUser.getTenantId().equals(item.getTenantId()))
.findFirst()
.orElse(null);
if (membership == null) {
return null;
}
if (membership.getOrgId() == null) {
return membership.getOrgName();
}
Map<Long, SysOrgDTO> orgMap = new HashMap<>();
for (SysOrgDTO org : sysOrgService.listTree(loginUser.getTenantId())) {
if (org != null && org.getId() != null) {
orgMap.put(org.getId(), org);
}
}
List<String> names = new ArrayList<>();
SysOrgDTO current = orgMap.get(membership.getOrgId());
while (current != null) {
if (current.getOrgName() != null && !current.getOrgName().isBlank()) {
names.add(0, current.getOrgName().trim());
}
Long parentId = current.getParentId();
if (parentId == null || parentId <= 0 || parentId.equals(current.getId())) {
break;
}
current = orgMap.get(parentId);
}
return names.isEmpty() ? membership.getOrgName() : String.join("/", names);
} catch (Exception ignored) {
return null;
}
}
private String resolveTenantName(LoginUser loginUser) {
if (loginUser == null || loginUser.getTenantId() == null) {
return null;
}
try {
SysTenantDTO tenant = tenantManagementService.getTenant(loginUser.getTenantId());
return tenant == null ? null : tenant.getTenantName();
} catch (Exception ignored) {
return null;
}
}
private void appendMarkdownRuns(XWPFParagraph paragraph, String text, boolean defaultBold, int size) {
String input = text == null ? "" : text;
Matcher matcher = Pattern.compile("\\*\\*(.+?)\\*\\*").matcher(input);

View File

@ -0,0 +1,145 @@
package com.imeeting.service.biz.impl;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.imeeting.dto.biz.AiModelVO;
import com.imeeting.entity.biz.Speaker;
import com.imeeting.entity.biz.SpeakerAsrSync;
import com.imeeting.service.biz.SpeakerAsrGatewayService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
@Slf4j
@Service
public class SpeakerAsrGatewayServiceImpl implements SpeakerAsrGatewayService {
@Value("${unisbase.app.server-base-url}")
private String serverBaseUrl;
@Value("${unisbase.app.resource-prefix}")
private String resourcePrefix;
private final ObjectMapper objectMapper;
private final HttpClient httpClient = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_1_1)
.connectTimeout(Duration.ofSeconds(10))
.build();
public SpeakerAsrGatewayServiceImpl(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
@Override
public String registerSpeaker(Speaker speaker, AiModelVO asrModel) {
if (asrModel == null || asrModel.getBaseUrl() == null || asrModel.getBaseUrl().isBlank()) {
throw new RuntimeException("当前 ASR 未配置 baseUrl");
}
if (speaker == null || speaker.getVoicePath() == null || speaker.getVoicePath().isBlank()) {
throw new RuntimeException("声纹样本不存在");
}
try {
Map<String, Object> body = new HashMap<>();
body.put("name", speaker.getName());
if (speaker.getUserId() != null) {
body.put("user_id", String.valueOf(speaker.getUserId()));
}
body.put("audio_address", buildFileUrl(speaker.getVoicePath()));
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
.uri(URI.create(appendPath(asrModel.getBaseUrl(), "api/v1/speakers")))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body), StandardCharsets.UTF_8));
applyAuthHeader(requestBuilder, asrModel);
HttpResponse<String> response = httpClient.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new RuntimeException("外部声纹注册失败: HTTP " + response.statusCode());
}
return readSpeakerId(response.body());
} catch (Exception e) {
throw new RuntimeException("外部声纹注册失败: " + e.getMessage(), e);
}
}
@Override
public void deleteSpeaker(SpeakerAsrSync snapshot, AiModelVO asrModel) {
if (snapshot == null || snapshot.getExternalSpeakerId() == null || snapshot.getExternalSpeakerId().isBlank()) {
return;
}
if (asrModel == null || asrModel.getBaseUrl() == null || asrModel.getBaseUrl().isBlank()) {
return;
}
try {
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
.uri(URI.create(appendPath(asrModel.getBaseUrl(), "api/v1/speakers/" + snapshot.getExternalSpeakerId())))
.DELETE();
applyAuthHeader(requestBuilder, asrModel);
HttpResponse<String> response = httpClient.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new RuntimeException("外部声纹删除失败: HTTP " + response.statusCode());
}
} catch (Exception e) {
throw new RuntimeException("外部声纹删除失败: " + e.getMessage(), e);
}
}
private void applyAuthHeader(HttpRequest.Builder requestBuilder, AiModelVO asrModel) {
if (asrModel.getApiKey() != null && !asrModel.getApiKey().isBlank()) {
requestBuilder.header("Authorization", "Bearer " + asrModel.getApiKey());
}
}
private String buildFileUrl(String voicePath) {
String fullPath = serverBaseUrl;
if (!fullPath.endsWith("/") && !resourcePrefix.startsWith("/")) {
fullPath += "/";
}
fullPath += resourcePrefix;
if (!fullPath.endsWith("/") && !voicePath.startsWith("/")) {
fullPath += "/";
}
return fullPath + voicePath;
}
private String appendPath(String baseUrl, String path) {
return baseUrl.endsWith("/") ? baseUrl + path : baseUrl + "/" + path;
}
@SuppressWarnings("unchecked")
private String readSpeakerId(String responseBody) {
try {
Map<String, Object> body = objectMapper.readValue(responseBody, Map.class);
Object speakerId = body.get("speaker_id");
if (speakerId == null) {
speakerId = body.get("id");
}
if (speakerId != null) {
return String.valueOf(speakerId);
}
Object data = body.get("data");
if (data instanceof Map<?, ?> dataMap) {
Object nestedSpeakerId = ((Map<String, Object>) dataMap).get("speaker_id");
if (nestedSpeakerId == null) {
nestedSpeakerId = ((Map<String, Object>) dataMap).get("id");
}
if (nestedSpeakerId != null) {
return String.valueOf(nestedSpeakerId);
}
}
return null;
} catch (Exception e) {
log.warn("Parse external speaker id failed, body={}", responseBody, e);
return null;
}
}
}

View File

@ -0,0 +1,198 @@
package com.imeeting.service.biz.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.imeeting.dto.biz.AiModelVO;
import com.imeeting.entity.biz.AsrModel;
import com.imeeting.entity.biz.Speaker;
import com.imeeting.entity.biz.SpeakerAsrSync;
import com.imeeting.enums.SpeakerAsrSyncStatusEnum;
import com.imeeting.mapper.biz.AsrModelMapper;
import com.imeeting.mapper.biz.SpeakerAsrSyncMapper;
import com.imeeting.mapper.biz.SpeakerMapper;
import com.imeeting.service.biz.SpeakerAsrGatewayService;
import com.imeeting.service.biz.SpeakerAsrSyncService;
import com.imeeting.service.biz.TenantModelActivationService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.Executor;
@Slf4j
@Service
@RequiredArgsConstructor
public class SpeakerAsrSyncServiceImpl implements SpeakerAsrSyncService {
private final SpeakerAsrSyncMapper syncMapper;
private final SpeakerMapper speakerMapper;
private final AsrModelMapper asrModelMapper;
private final TenantModelActivationService activationService;
private final SpeakerAsrGatewayService gatewayService;
@Qualifier("asrTaskExecutor")
private final Executor asrTaskExecutor;
@Override
public void queueSyncForCurrentAsr(Long tenantId, Long speakerId) {
Long asrModelId = activationService.resolveActiveAsrId(tenantId);
if (asrModelId == null || speakerId == null) {
return;
}
asrTaskExecutor.execute(() -> syncSpeakerToAsr(tenantId, speakerId, asrModelId));
}
@Override
public void queueCatchUpForCurrentAsr(Long tenantId) {
Long asrModelId = activationService.resolveActiveAsrId(tenantId);
if (tenantId == null || asrModelId == null) {
return;
}
asrTaskExecutor.execute(() -> {
List<Speaker> speakers = speakerMapper.selectList(new QueryWrapper<Speaker>()
.eq("tenant_id", tenantId)
.eq("is_deleted", 0));
for (Speaker speaker : speakers) {
syncSpeakerToAsr(tenantId, speaker.getId(), asrModelId);
}
});
}
@Override
public void invalidateOtherAsrSnapshots(Long tenantId, Long speakerId, Long keepAsrModelId) {
if (tenantId == null || speakerId == null) {
return;
}
List<SpeakerAsrSync> snapshots = syncMapper.selectList(new QueryWrapper<SpeakerAsrSync>()
.eq("tenant_id", tenantId)
.eq("speaker_id", speakerId)
.eq("is_deleted", 0));
for (SpeakerAsrSync snapshot : snapshots) {
if (keepAsrModelId != null && keepAsrModelId.equals(snapshot.getAsrModelId())) {
continue;
}
snapshot.setSyncStatus(SpeakerAsrSyncStatusEnum.STALE.name());
snapshot.setLastErrorMessage(null);
syncMapper.updateById(snapshot);
Long snapshotId = snapshot.getId();
if (snapshotId != null) {
deleteStaleSnapshot(snapshotId);
}
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public void syncSpeakerToAsr(Long tenantId, Long speakerId, Long asrModelId) {
Speaker speaker = speakerMapper.selectById(speakerId);
if (speaker == null || asrModelId == null) {
return;
}
SpeakerAsrSync snapshot = findSnapshot(speakerId, asrModelId);
if (snapshot != null
&& SpeakerAsrSyncStatusEnum.SYNCED.name().equals(snapshot.getSyncStatus())
&& speaker.getVersion() != null
&& speaker.getVersion().equals(snapshot.getSpeakerVersion())) {
return;
}
if (snapshot == null) {
snapshot = new SpeakerAsrSync();
snapshot.setTenantId(tenantId);
snapshot.setSpeakerId(speakerId);
snapshot.setAsrModelId(asrModelId);
}
boolean needsDeleteFirst = SpeakerAsrSyncStatusEnum.STALE.name().equals(snapshot.getSyncStatus())
&& snapshot.getExternalSpeakerId() != null
&& !snapshot.getExternalSpeakerId().isBlank();
try {
AiModelVO asrModel = loadAsrModel(asrModelId);
snapshot.setSyncStatus(SpeakerAsrSyncStatusEnum.SYNCING.name());
snapshot.setLastSyncBatchId(UUID.randomUUID().toString());
saveSnapshot(snapshot);
if (needsDeleteFirst) {
gatewayService.deleteSpeaker(snapshot, asrModel);
snapshot.setExternalSpeakerId(null);
}
String externalSpeakerId = gatewayService.registerSpeaker(speaker, asrModel);
snapshot.setSpeakerVersion(speaker.getVersion());
snapshot.setExternalSpeakerId(externalSpeakerId);
snapshot.setSyncStatus(SpeakerAsrSyncStatusEnum.SYNCED.name());
snapshot.setLastSyncedAt(LocalDateTime.now());
snapshot.setLastErrorMessage(null);
saveSnapshot(snapshot);
} catch (Exception e) {
snapshot.setSyncStatus(SpeakerAsrSyncStatusEnum.FAILED.name());
snapshot.setLastErrorMessage(e.getMessage());
saveSnapshot(snapshot);
log.warn("Sync speaker to asr failed, tenantId={}, speakerId={}, asrModelId={}", tenantId, speakerId, asrModelId, e);
}
}
private void deleteStaleSnapshot(Long snapshotId) {
SpeakerAsrSync snapshot = syncMapper.selectById(snapshotId);
if (snapshot == null || !SpeakerAsrSyncStatusEnum.STALE.name().equals(snapshot.getSyncStatus())) {
return;
}
try {
if (snapshot.getExternalSpeakerId() != null && !snapshot.getExternalSpeakerId().isBlank()) {
AiModelVO asrModel = loadAsrModel(snapshot.getAsrModelId());
gatewayService.deleteSpeaker(snapshot, asrModel);
}
snapshot.setExternalSpeakerId(null);
snapshot.setSyncStatus(SpeakerAsrSyncStatusEnum.DELETED.name());
snapshot.setLastErrorMessage(null);
saveSnapshot(snapshot);
} catch (Exception e) {
snapshot.setSyncStatus(SpeakerAsrSyncStatusEnum.FAILED.name());
snapshot.setLastErrorMessage(e.getMessage());
saveSnapshot(snapshot);
log.warn("Delete stale speaker snapshot failed, snapshotId={}", snapshot.getId(), e);
}
}
private SpeakerAsrSync findSnapshot(Long speakerId, Long asrModelId) {
return syncMapper.selectOne(new QueryWrapper<SpeakerAsrSync>()
.eq("speaker_id", speakerId)
.eq("asr_model_id", asrModelId)
.eq("is_deleted", 0)
.last("LIMIT 1"));
}
private AiModelVO loadAsrModel(Long asrModelId) {
AsrModel entity = asrModelMapper.selectById(asrModelId);
if (entity == null) {
throw new RuntimeException("ASR 模型不存在");
}
AiModelVO model = new AiModelVO();
model.setId(entity.getId());
model.setTenantId(entity.getTenantId());
model.setModelType("ASR");
model.setModelName(entity.getModelName());
model.setProvider(entity.getProvider());
model.setBaseUrl(entity.getBaseUrl());
model.setApiKey(entity.getApiKey());
model.setModelCode(entity.getModelCode());
model.setWsUrl(entity.getWsUrl());
model.setMediaConfig(entity.getMediaConfig());
model.setIsDefault(entity.getIsDefault());
model.setSortOrder(entity.getSortOrder());
model.setRemark(entity.getRemark());
model.setCreatedAt(entity.getCreatedAt());
return model;
}
private void saveSnapshot(SpeakerAsrSync snapshot) {
if (snapshot.getId() == null) {
syncMapper.insert(snapshot);
return;
}
syncMapper.updateById(snapshot);
}
}

View File

@ -1,5 +1,6 @@
package com.imeeting.service.biz.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.fasterxml.jackson.databind.ObjectMapper;
@ -7,13 +8,16 @@ import com.imeeting.dto.biz.AiModelVO;
import com.imeeting.dto.biz.SpeakerRegisterDTO;
import com.imeeting.dto.biz.SpeakerVO;
import com.imeeting.entity.biz.Speaker;
import com.imeeting.entity.biz.SpeakerAsrSync;
import com.imeeting.enums.SpeakerAsrSyncStatusEnum;
import com.imeeting.mapper.biz.SpeakerAsrSyncMapper;
import com.imeeting.mapper.biz.SpeakerMapper;
import com.imeeting.service.biz.AiModelService;
import com.imeeting.service.biz.SpeakerService;
import com.imeeting.service.biz.*;
import com.unisbase.annotation.DataScope;
import com.unisbase.dto.PageResult;
import com.unisbase.security.LoginUser;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@ -31,10 +35,14 @@ import java.nio.file.Paths;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Function;
import java.util.stream.Collectors;
@Slf4j
@Service
@ -51,6 +59,12 @@ public class SpeakerServiceImpl extends ServiceImpl<SpeakerMapper, Speaker> impl
private final AiModelService aiModelService;
private final ObjectMapper objectMapper;
@Autowired
private SpeakerAsrSyncMapper speakerAsrSyncMapper;
@Autowired
private TenantModelActivationService tenantAsrActivationService;
@Autowired
private SpeakerAsrSyncService speakerAsrSyncService;
private final HttpClient httpClient = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_1_1)
.connectTimeout(Duration.ofSeconds(10))
@ -73,6 +87,7 @@ public class SpeakerServiceImpl extends ServiceImpl<SpeakerMapper, Speaker> impl
boolean admin = isAdmin(loginUser);
Speaker speaker = prepareSpeaker(registerDTO, loginUser, admin);
Long previousVersion = speaker.getVersion();
String normalizedName = registerDTO.getName().trim();
validateDuplicateName(loginUser.getTenantId(), normalizedName, speaker.getId());
@ -83,6 +98,9 @@ public class SpeakerServiceImpl extends ServiceImpl<SpeakerMapper, Speaker> impl
}
speaker.setTenantId(loginUser.getTenantId());
Long finalUserId = !admin ? loginUser.getUserId() : registerDTO.getUserId();
boolean contentChanged = speaker.getId() != null
&& speakerChanged(speaker, normalizedName, finalUserId, registerDTO.getRemark(), file);
speaker.setName(normalizedName);
speaker.setRemark(registerDTO.getRemark());
if (speaker.getId() == null) {
@ -99,13 +117,22 @@ public class SpeakerServiceImpl extends ServiceImpl<SpeakerMapper, Speaker> impl
if (file != null && !file.isEmpty()) {
saveVoiceFile(speaker, file);
}
if (speaker.getId() == null) {
speaker.setVersion(1L);
} else if (contentChanged) {
speaker.setVersion(previousVersion == null ? 1L : previousVersion + 1);
} else if (speaker.getVersion() == null) {
speaker.setVersion(1L);
}
speaker.setStatus(1);
speaker.setUpdatedAt(LocalDateTime.now());
this.saveOrUpdate(speaker);
syncExternalVoiceprint(speaker, loginUser);
return toVO(speaker);
Long activeAsrId = tenantAsrActivationService.resolveActiveAsrId(loginUser.getTenantId());
speakerAsrSyncService.invalidateOtherAsrSnapshots(loginUser.getTenantId(), speaker.getId(), activeAsrId);
speakerAsrSyncService.queueSyncForCurrentAsr(loginUser.getTenantId(), speaker.getId());
return toVO(speaker, null, activeAsrId);
}
@Override
@ -120,9 +147,11 @@ public class SpeakerServiceImpl extends ServiceImpl<SpeakerMapper, Speaker> impl
.orderByDesc(Speaker::getUpdatedAt)
.page(new Page<>(current, size));
Long activeAsrId = tenantAsrActivationService.resolveActiveAsrId(loginUser.getTenantId());
Map<Long, SpeakerAsrSync> syncSnapshotMap = loadSyncSnapshotMap(loginUser.getTenantId(), activeAsrId, page.getRecords());
List<SpeakerVO> records = new ArrayList<>(page.getRecords().size());
for (Speaker speaker : page.getRecords()) {
records.add(toVO(speaker));
records.add(toVO(speaker, syncSnapshotMap.get(speaker.getId()), activeAsrId));
}
PageResult<List<SpeakerVO>> result = new PageResult<>();
@ -139,13 +168,25 @@ public class SpeakerServiceImpl extends ServiceImpl<SpeakerMapper, Speaker> impl
.eq(!admin, Speaker::getUserId, loginUser.getUserId())
.orderByDesc(Speaker::getUpdatedAt)
.list();
Long activeAsrId = tenantAsrActivationService.resolveActiveAsrId(loginUser.getTenantId());
Map<Long, SpeakerAsrSync> syncSnapshotMap = loadSyncSnapshotMap(loginUser.getTenantId(), activeAsrId, list);
List<SpeakerVO> vos = new ArrayList<>(list.size());
for (Speaker speaker : list) {
vos.add(toVO(speaker));
vos.add(toVO(speaker, syncSnapshotMap.get(speaker.getId()), activeAsrId));
}
return vos;
}
@Override
public void syncCurrentAsr(Long id, LoginUser loginUser) {
Speaker speaker = getSpeakerForWrite(id, loginUser);
Long activeAsrId = tenantAsrActivationService.resolveActiveAsrId(loginUser.getTenantId());
if (activeAsrId == null) {
throw new RuntimeException("当前未启用 ASR无法同步声纹");
}
speakerAsrSyncService.queueSyncForCurrentAsr(loginUser.getTenantId(), speaker.getId());
}
@Override
@Transactional(rollbackFor = Exception.class)
public void deleteSpeaker(Long id, LoginUser loginUser) {
@ -154,6 +195,23 @@ public class SpeakerServiceImpl extends ServiceImpl<SpeakerMapper, Speaker> impl
this.removeById(id);
}
private boolean speakerChanged(Speaker existing,
String normalizedName,
Long requestedUserId,
String requestedRemark,
MultipartFile file) {
if (!Objects.equals(existing.getName(), normalizedName)) {
return true;
}
if (!Objects.equals(existing.getUserId(), requestedUserId)) {
return true;
}
if (!Objects.equals(existing.getRemark(), requestedRemark)) {
return true;
}
return file != null && !file.isEmpty();
}
private Speaker prepareSpeaker(SpeakerRegisterDTO registerDTO, LoginUser loginUser, boolean admin) {
if (registerDTO.getId() != null) {
return getSpeakerForWrite(registerDTO.getId(), loginUser);
@ -360,7 +418,27 @@ public class SpeakerServiceImpl extends ServiceImpl<SpeakerMapper, Speaker> impl
return Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()) || Boolean.TRUE.equals(loginUser.getIsTenantAdmin());
}
private SpeakerVO toVO(Speaker speaker) {
private Map<Long, SpeakerAsrSync> loadSyncSnapshotMap(Long tenantId, Long activeAsrId, List<Speaker> speakers) {
if (tenantId == null || activeAsrId == null || speakers == null || speakers.isEmpty()) {
return Collections.emptyMap();
}
List<Long> speakerIds = speakers.stream()
.map(Speaker::getId)
.filter(Objects::nonNull)
.toList();
if (speakerIds.isEmpty()) {
return Collections.emptyMap();
}
return speakerAsrSyncMapper.selectList(new QueryWrapper<SpeakerAsrSync>()
.eq("tenant_id", tenantId)
.eq("asr_model_id", activeAsrId)
.in("speaker_id", speakerIds)
.eq("is_deleted", 0))
.stream()
.collect(Collectors.toMap(SpeakerAsrSync::getSpeakerId, Function.identity(), (left, right) -> right));
}
private SpeakerVO toVO(Speaker speaker, SpeakerAsrSync snapshot, Long activeAsrId) {
SpeakerVO vo = new SpeakerVO();
vo.setId(speaker.getId());
vo.setTenantId(speaker.getTenantId());
@ -372,6 +450,8 @@ public class SpeakerServiceImpl extends ServiceImpl<SpeakerMapper, Speaker> impl
vo.setVoiceExt(speaker.getVoiceExt());
vo.setVoiceSize(speaker.getVoiceSize());
vo.setStatus(speaker.getStatus());
vo.setSyncStatus(snapshot == null ? (activeAsrId == null ? null : SpeakerAsrSyncStatusEnum.PENDING.name()) : snapshot.getSyncStatus());
vo.setSyncErrorMessage(snapshot == null ? null : snapshot.getLastErrorMessage());
vo.setRemark(speaker.getRemark());
vo.setCreatedAt(speaker.getCreatedAt());
vo.setUpdatedAt(speaker.getUpdatedAt());

View File

@ -0,0 +1,252 @@
package com.imeeting.service.biz.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.imeeting.entity.biz.TenantModelActivation;
import com.imeeting.mapper.biz.TenantModelActivationMapper;
import com.imeeting.service.biz.TenantModelActivationService;
import com.unisbase.dto.PageResult;
import com.unisbase.dto.SysTenantDTO;
import com.unisbase.service.TenantManagementService;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
@Service
@RequiredArgsConstructor
public class TenantModelActivationServiceImpl implements TenantModelActivationService {
private static final String TYPE_ASR = "ASR";
private static final String TYPE_LLM = "LLM";
private final TenantModelActivationMapper activationMapper;
@Autowired(required = false)
private TenantManagementService tenantManagementService;
@Override
public Long resolveActiveAsrId(Long tenantId) {
if (tenantId == null) {
return null;
}
TenantModelActivation activation = activationMapper.selectOne(new QueryWrapper<TenantModelActivation>()
.eq("tenant_id", tenantId)
.eq("model_type", TYPE_ASR)
.eq("enabled", 1)
.eq("is_deleted", 0)
.last("LIMIT 1"));
return activation == null ? null : activation.getModelId();
}
@Override
public void enableAsrForTenant(Long tenantId, Long asrModelId) {
enableModelForTenant(TYPE_ASR, tenantId, asrModelId, true);
}
@Override
public void disableAsrForTenant(Long tenantId, Long asrModelId) {
disableModelForTenant(TYPE_ASR, tenantId, asrModelId);
}
@Override
public List<Long> refreshPlatformInheritanceForAsr(Long asrModelId) {
if (asrModelId == null || tenantManagementService == null) {
return List.of();
}
List<Long> newlyEnabledTenantIds = new ArrayList<>();
pageTenants().forEach(tenantId -> {
Long activeAsrId = resolveActiveAsrId(tenantId);
if (activeAsrId == null) {
upsert(TYPE_ASR, tenantId, asrModelId, 1, 0);
newlyEnabledTenantIds.add(tenantId);
return;
}
if (!asrModelId.equals(activeAsrId)) {
upsert(TYPE_ASR, tenantId, asrModelId, 0, 0);
}
});
return newlyEnabledTenantIds;
}
@Override
public void disableAsrForAllTenants(Long asrModelId) {
if (asrModelId == null) {
return;
}
activationMapper.update(null, new UpdateWrapper<TenantModelActivation>()
.set("enabled", 0)
.eq("model_type", TYPE_ASR)
.eq("model_id", asrModelId));
}
@Override
public boolean isTenantEnabled(String modelType, Long tenantId, Long modelId) {
if (tenantId == null || modelId == null) {
return false;
}
return activationMapper.selectCount(new QueryWrapper<TenantModelActivation>()
.eq("tenant_id", tenantId)
.eq("model_type", normalizeType(modelType))
.eq("model_id", modelId)
.eq("enabled", 1)
.eq("is_deleted", 0)) > 0;
}
@Override
public List<Long> listEnabledModelIds(String modelType, Long tenantId) {
if (tenantId == null) {
return List.of();
}
return activationMapper.selectList(new QueryWrapper<TenantModelActivation>()
.select("model_id")
.eq("tenant_id", tenantId)
.eq("model_type", normalizeType(modelType))
.eq("enabled", 1)
.eq("is_deleted", 0))
.stream()
.map(TenantModelActivation::getModelId)
.filter(modelId -> modelId != null)
.toList();
}
@Override
@Transactional(rollbackFor = Exception.class)
public void enableModelForTenant(String modelType, Long tenantId, Long modelId, boolean singleSelect) {
String normalizedType = normalizeType(modelType);
if (tenantId == null || modelId == null) {
return;
}
if (singleSelect) {
activationMapper.update(null, new UpdateWrapper<TenantModelActivation>()
.set("enabled", 0)
.eq("tenant_id", tenantId)
.eq("model_type", normalizedType)
.eq("enabled", 1));
}
upsert(normalizedType, tenantId, modelId, 1, null);
}
@Override
public void disableModelForTenant(String modelType, Long tenantId, Long modelId) {
if (tenantId == null || modelId == null) {
return;
}
activationMapper.update(null, new UpdateWrapper<TenantModelActivation>()
.set("enabled", 0)
.set("is_default", 0)
.eq("tenant_id", tenantId)
.eq("model_type", normalizeType(modelType))
.eq("model_id", modelId));
}
@Override
public void disableModelForAllTenants(String modelType, Long modelId) {
if (modelId == null) {
return;
}
activationMapper.update(null, new UpdateWrapper<TenantModelActivation>()
.set("enabled", 0)
.set("is_default", 0)
.eq("model_type", normalizeType(modelType))
.eq("model_id", modelId));
}
@Override
@Transactional(rollbackFor = Exception.class)
public void setDefaultModelForTenant(String modelType, Long tenantId, Long modelId) {
String normalizedType = normalizeType(modelType);
if (tenantId == null || modelId == null) {
return;
}
activationMapper.update(null, new UpdateWrapper<TenantModelActivation>()
.set("is_default", 0)
.eq("tenant_id", tenantId)
.eq("model_type", normalizedType)
.eq("is_default", 1));
upsert(normalizedType, tenantId, modelId, 1, 1);
}
@Override
public Long resolveDefaultModelId(String modelType, Long tenantId) {
if (tenantId == null) {
return null;
}
TenantModelActivation activation = activationMapper.selectOne(new QueryWrapper<TenantModelActivation>()
.eq("tenant_id", tenantId)
.eq("model_type", normalizeType(modelType))
.eq("enabled", 1)
.eq("is_default", 1)
.eq("is_deleted", 0)
.last("LIMIT 1"));
return activation == null ? null : activation.getModelId();
}
@Override
public List<Long> refreshPlatformInheritanceForLlm(Long modelId) {
if (modelId == null || tenantManagementService == null) {
return List.of();
}
List<Long> tenantIds = new ArrayList<>();
pageTenants().forEach(tenantId -> {
upsert(TYPE_LLM, tenantId, modelId, 1, null);
tenantIds.add(tenantId);
});
return tenantIds;
}
private List<Long> pageTenants() {
List<Long> tenantIds = new ArrayList<>();
int current = 1;
int size = 1000;
while (true) {
PageResult<List<SysTenantDTO>> pageResult = tenantManagementService.listTenants(current, size, null, null);
List<SysTenantDTO> tenants = pageResult == null || pageResult.getRecords() == null ? List.of() : pageResult.getRecords();
for (SysTenantDTO tenant : tenants) {
if (tenant != null && tenant.getId() != null) {
tenantIds.add(tenant.getId());
}
}
if (tenants.size() < size) {
break;
}
current++;
}
return tenantIds;
}
private void upsert(String modelType, Long tenantId, Long modelId, Integer enabled, Integer isDefault) {
TenantModelActivation record = activationMapper.selectOne(new QueryWrapper<TenantModelActivation>()
.eq("tenant_id", tenantId)
.eq("model_type", modelType)
.eq("model_id", modelId)
.eq("is_deleted", 0)
.last("LIMIT 1"));
if (record == null) {
record = new TenantModelActivation();
record.setTenantId(tenantId);
record.setModelType(modelType);
record.setModelId(modelId);
record.setEnabled(enabled == null ? 0 : enabled);
record.setIsDefault(isDefault == null ? 0 : isDefault);
activationMapper.insert(record);
return;
}
if (enabled != null) {
record.setEnabled(enabled);
}
if (isDefault != null) {
record.setIsDefault(isDefault);
}
activationMapper.updateById(record);
}
private String normalizeType(String modelType) {
if (modelType == null || modelType.isBlank()) {
return TYPE_ASR;
}
return modelType.trim().toUpperCase();
}
}

View File

@ -84,6 +84,7 @@ unisbase:
- biz_prompt_templates
- biz_meeting_transcript_chapter_versions
- biz_meeting_transcript_chapters
- biz_speaker_asr_sync
- biz_android_push_message
- biz_client_downloads
- biz_external_apps

View File

@ -1,13 +1,16 @@
package com.imeeting.service.biz.impl;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.imeeting.dto.biz.AiModelDTO;
import com.imeeting.dto.biz.AiLocalProfileVO;
import com.imeeting.dto.biz.AiModelVO;
import com.imeeting.entity.biz.AsrModel;
import com.imeeting.entity.biz.LlmModel;
import com.imeeting.mapper.biz.AsrModelMapper;
import com.imeeting.mapper.biz.LlmModelMapper;
import com.imeeting.service.biz.TenantModelActivationService;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.AfterEach;
@ -21,12 +24,14 @@ import java.net.InetSocketAddress;
import java.net.http.HttpClient;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
@ -266,6 +271,186 @@ class AiModelServiceImplTest {
assertNull(captor.getValue().getApiKey());
}
@Test
void getDefaultModelShouldPreferTenantActiveAsr() throws Exception {
AsrModelMapper asrModelMapper = mock(AsrModelMapper.class);
TenantModelActivationService tenantModelActivationService = mock(TenantModelActivationService.class);
when(tenantModelActivationService.resolveActiveAsrId(88L)).thenReturn(201L);
AsrModel activeModel = new AsrModel();
activeModel.setId(201L);
activeModel.setTenantId(0L);
activeModel.setModelName("active-asr");
activeModel.setStatus(1);
when(asrModelMapper.selectById(201L)).thenReturn(activeModel);
AiModelServiceImpl service = new AiModelServiceImpl(
objectMapper,
asrModelMapper,
mock(LlmModelMapper.class)
);
setField(service, "tenantModelActivationService", tenantModelActivationService);
AiModelVO result = service.getDefaultModel("ASR", 88L);
assertEquals(201L, result.getId());
assertEquals("active-asr", result.getModelName());
}
@Test
void getDefaultModelShouldPreferTenantDefaultLlmOverPlatformDefault() throws Exception {
LlmModelMapper llmModelMapper = mock(LlmModelMapper.class);
TenantModelActivationService tenantModelActivationService = mock(TenantModelActivationService.class);
when(tenantModelActivationService.resolveDefaultModelId("LLM", 88L)).thenReturn(302L);
when(tenantModelActivationService.isTenantEnabled("LLM", 88L, 302L)).thenReturn(true);
LlmModel tenantDefault = new LlmModel();
tenantDefault.setId(302L);
tenantDefault.setTenantId(0L);
tenantDefault.setModelName("tenant-default-llm");
tenantDefault.setStatus(1);
when(llmModelMapper.selectById(302L)).thenReturn(tenantDefault);
AiModelServiceImpl service = new AiModelServiceImpl(
objectMapper,
mock(AsrModelMapper.class),
llmModelMapper
);
setField(service, "tenantModelActivationService", tenantModelActivationService);
AiModelVO result = service.getDefaultModel("LLM", 88L);
assertEquals(302L, result.getId());
assertEquals("tenant-default-llm", result.getModelName());
}
@Test
void getDefaultModelShouldSkipTenantDisabledPlatformDefaultLlm() throws Exception {
LlmModelMapper llmModelMapper = mock(LlmModelMapper.class);
TenantModelActivationService tenantModelActivationService = mock(TenantModelActivationService.class);
when(tenantModelActivationService.resolveDefaultModelId("LLM", 88L)).thenReturn(null);
when(tenantModelActivationService.isTenantEnabled("LLM", 88L, 401L)).thenReturn(false);
when(tenantModelActivationService.isTenantEnabled("LLM", 88L, 402L)).thenReturn(true);
LlmModel disabledPlatformDefault = new LlmModel();
disabledPlatformDefault.setId(401L);
disabledPlatformDefault.setTenantId(0L);
disabledPlatformDefault.setModelName("platform-default");
disabledPlatformDefault.setIsDefault(1);
disabledPlatformDefault.setStatus(1);
LlmModel enabledCandidate = new LlmModel();
enabledCandidate.setId(402L);
enabledCandidate.setTenantId(0L);
enabledCandidate.setModelName("enabled-candidate");
enabledCandidate.setIsDefault(0);
enabledCandidate.setStatus(1);
when(llmModelMapper.selectList(any())).thenReturn(
List.of(disabledPlatformDefault),
List.of(disabledPlatformDefault, enabledCandidate)
);
AiModelServiceImpl service = new AiModelServiceImpl(
objectMapper,
mock(AsrModelMapper.class),
llmModelMapper
);
setField(service, "tenantModelActivationService", tenantModelActivationService);
AiModelVO result = service.getDefaultModel("LLM", 88L);
assertEquals(402L, result.getId());
assertEquals("enabled-candidate", result.getModelName());
}
@Test
void updatePlatformModelStatusShouldRefreshPlatformInheritanceForLlm() throws Exception {
LlmModelMapper llmModelMapper = mock(LlmModelMapper.class);
TenantModelActivationService tenantModelActivationService = mock(TenantModelActivationService.class);
LlmModel platformModel = new LlmModel();
platformModel.setId(501L);
platformModel.setTenantId(0L);
platformModel.setStatus(0);
when(llmModelMapper.selectById(501L)).thenReturn(platformModel);
AiModelServiceImpl service = new AiModelServiceImpl(
objectMapper,
mock(AsrModelMapper.class),
llmModelMapper
);
setField(service, "tenantModelActivationService", tenantModelActivationService);
service.updatePlatformModelStatus("LLM", 501L, 1, true);
verify(tenantModelActivationService, times(1)).refreshPlatformInheritanceForLlm(501L);
}
@Test
void pageModelsShouldReturnOnlyTenantEnabledAsrWhenRequested() throws Exception {
AsrModelMapper asrModelMapper = mock(AsrModelMapper.class);
TenantModelActivationService tenantModelActivationService = mock(TenantModelActivationService.class);
when(tenantModelActivationService.listEnabledModelIds("ASR", 88L)).thenReturn(List.of(201L, 203L));
when(tenantModelActivationService.isTenantEnabled("ASR", 88L, 201L)).thenReturn(true);
when(tenantModelActivationService.isTenantEnabled("ASR", 88L, 202L)).thenReturn(false);
when(tenantModelActivationService.isTenantEnabled("ASR", 88L, 203L)).thenReturn(true);
when(asrModelMapper.selectPage(any(Page.class), any())).thenAnswer(invocation -> {
Page<AsrModel> page = invocation.getArgument(0);
page.setRecords(List.of(
asrModel(201L, 0L, "enabled-platform-asr", 1),
asrModel(202L, 0L, "disabled-for-tenant-asr", 1),
asrModel(203L, 88L, "enabled-tenant-asr", 1)
));
page.setTotal(3);
return page;
});
AiModelServiceImpl service = new AiModelServiceImpl(
objectMapper,
asrModelMapper,
mock(LlmModelMapper.class)
);
setField(service, "tenantModelActivationService", tenantModelActivationService);
List<AiModelVO> records = service.pageModels(1, 100, null, "ASR", 88L, false, true).getRecords();
assertEquals(2, records.size());
assertEquals(201L, records.get(0).getId());
assertEquals(203L, records.get(1).getId());
}
@Test
void pageModelsShouldReturnAllEnabledLlmForPlatformAdminWhenRequested() throws Exception {
LlmModelMapper llmModelMapper = mock(LlmModelMapper.class);
TenantModelActivationService tenantModelActivationService = mock(TenantModelActivationService.class);
when(llmModelMapper.selectPage(any(Page.class), any())).thenAnswer(invocation -> {
Page<LlmModel> page = invocation.getArgument(0);
page.setRecords(List.of(
llmModel(301L, 0L, "platform-llm", 1),
llmModel(302L, 88L, "tenant-88-llm", 1),
llmModel(303L, 99L, "tenant-99-llm", 1)
));
page.setTotal(3);
return page;
});
AiModelServiceImpl service = new AiModelServiceImpl(
objectMapper,
mock(AsrModelMapper.class),
llmModelMapper
);
setField(service, "tenantModelActivationService", tenantModelActivationService);
List<AiModelVO> records = service.pageModels(1, 100, null, "LLM", 88L, true, true).getRecords();
assertEquals(3, records.size());
assertEquals(301L, records.get(0).getId());
assertEquals(302L, records.get(1).getId());
assertEquals(303L, records.get(2).getId());
verify(tenantModelActivationService, times(0)).listEnabledModelIds(any(), any());
}
@Test
void saveModelShouldPersistSortOrderForLlm() {
AsrModelMapper asrModelMapper = mock(AsrModelMapper.class);
@ -459,6 +644,34 @@ class AiModelServiceImplTest {
assertNull(captor.getValue().getBaseUrl());
}
private void setField(Object target, String fieldName, Object value) throws Exception {
Field field = AiModelServiceImpl.class.getDeclaredField(fieldName);
field.setAccessible(true);
field.set(target, value);
}
private AsrModel asrModel(Long id, Long tenantId, String modelName, Integer status) {
AsrModel model = new AsrModel();
model.setId(id);
model.setTenantId(tenantId);
model.setModelName(modelName);
model.setStatus(status);
model.setIsDefault(0);
model.setSortOrder(0);
return model;
}
private LlmModel llmModel(Long id, Long tenantId, String modelName, Integer status) {
LlmModel model = new LlmModel();
model.setId(id);
model.setTenantId(tenantId);
model.setModelName(modelName);
model.setStatus(status);
model.setIsDefault(0);
model.setSortOrder(0);
return model;
}
private void captureRequest(HttpExchange exchange,
AtomicReference<String> requestPath,
AtomicReference<String> authorization,

View File

@ -205,6 +205,45 @@ class MeetingRuntimeProfileResolverImplTest {
assertEquals(22L, profile.getResolvedSummaryModelId());
}
@Test
void resolveShouldUseTenantDefaultLlmFromAiModelService() {
AiModelService aiModelService = mock(AiModelService.class);
PromptTemplateService promptTemplateService = mock(PromptTemplateService.class);
HotWordGroupService hotWordGroupService = mock(HotWordGroupService.class);
HotWordService hotWordService = mock(HotWordService.class);
MeetingRuntimeProfileResolverImpl resolver = new MeetingRuntimeProfileResolverImpl(
aiModelService,
promptTemplateService,
hotWordGroupService,
hotWordService,
mock(AsrModelMapper.class),
mock(LlmModelMapper.class)
);
when(aiModelService.getDefaultModel("ASR", 1L)).thenReturn(enabledModel(11L, 1L, "ASR-Model"));
when(aiModelService.getDefaultModel("LLM", 1L)).thenReturn(enabledModel(77L, 0L, "Tenant Default LLM"));
when(promptTemplateService.getOne(any(LambdaQueryWrapper.class))).thenReturn(enabledPrompt(33L, 1L, "Default Prompt"));
RealtimeMeetingRuntimeProfile profile = resolver.resolve(
1L,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
List.of()
);
assertEquals(77L, profile.getResolvedSummaryModelId());
assertEquals("Tenant Default LLM", profile.getResolvedSummaryModelName());
}
@Test
void resolveShouldPreferExplicitHotWordGroupOverTemplateBinding() {
AiModelService aiModelService = mock(AiModelService.class);

View File

@ -16,6 +16,10 @@ export interface AiModelVO {
mediaConfig?: Record<string, any>;
isDefault: number;
status: number;
tenantEnabled?: number;
tenantDefault?: number;
scope?: "PLATFORM" | "TENANT";
canEditConfig?: boolean;
sortOrder?: number;
remark?: string;
createdAt: string;
@ -55,6 +59,7 @@ export const getAiModelPage = (params: {
size: number;
name?: string;
type?: string;
tenantEnabledOnly?: boolean;
}) => {
return http.get<{ code: string; data: { records: AiModelVO[]; total: number }; msg: string }>(
"/api/biz/aimodel/page",
@ -62,6 +67,47 @@ export const getAiModelPage = (params: {
);
};
export const tenantEnableModel = (id: number, type: 'ASR' | 'LLM' = 'ASR') => {
return http.post<{ code: string; data: boolean; msg: string }>(
`/api/biz/aimodel/${id}/tenant-enable`,
undefined,
{params: {type}}
);
};
export const tenantDisableModel = (id: number, type: 'ASR' | 'LLM' = 'ASR') => {
return http.post<{ code: string; data: boolean; msg: string }>(
`/api/biz/aimodel/${id}/tenant-disable`,
undefined,
{params: {type}}
);
};
export const updatePlatformModelStatus = (id: number, type: 'ASR' | 'LLM', status: number) => {
return http.post<{ code: string; data: boolean; msg: string }>(
`/api/biz/aimodel/${id}/platform-status`,
{status},
{params: {type}}
);
};
export const setTenantDefaultModel = (id: number, type: 'ASR' | 'LLM' = 'LLM') => {
return http.post<{ code: string; data: boolean; msg: string }>(
`/api/biz/aimodel/${id}/tenant-default`,
{modelId: id, modelType: type}
);
};
export const syncCurrentAsrSpeakers = () => {
return http.post<{ code: string; data: boolean; msg: string }>(
"/api/biz/aimodel/current/sync-speakers"
);
};
export const tenantEnableAsr = (id: number) => tenantEnableModel(id, 'ASR');
export const tenantDisableAsr = (id: number) => tenantDisableModel(id, 'ASR');
export const updatePlatformAsrStatus = (id: number, status: number) => updatePlatformModelStatus(id, 'ASR', status);
export const saveAiModel = (data: AiModelDTO) => {
return http.post<{ code: string; data: AiModelVO; msg: string }>(
"/api/biz/aimodel",

View File

@ -10,6 +10,8 @@ export interface SpeakerVO {
voiceExt: string;
voiceSize: number;
status: number;
syncStatus?: string;
syncErrorMessage?: string;
remark?: string;
createdAt: string;
updatedAt: string;
@ -69,3 +71,9 @@ export const deleteSpeaker = (id: number) => {
`/api/biz/speaker/${id}`
);
};
export const syncSpeaker = (id: number) => {
return http.post<{ code: string; data: boolean; msg: string }>(
`/api/biz/speaker/${id}/sync`
);
};

View File

@ -183,8 +183,8 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({
setLoading(true);
try {
const [asrRes, llmRes, promptRes, hotwordRes, hotWordGroupRes, users, defaultAsr, defaultLlm, createConfigRes] = await Promise.all([
getAiModelPage({ current: 1, size: 100, type: "ASR" }),
getAiModelPage({ current: 1, size: 100, type: "LLM" }),
getAiModelPage({current: 1, size: 100, type: "ASR", tenantEnabledOnly: true}),
getAiModelPage({current: 1, size: 100, type: "LLM", tenantEnabledOnly: true}),
getPromptPage({ current: 1, size: 100 }),
getHotWordPage({ current: 1, size: 1000 }),
getHotWordGroupOptions(),

View File

@ -1,8 +1,25 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { AutoComplete, Button, Col, Divider, Drawer, Form, Input, InputNumber, Popconfirm, Row, Select, Space, Switch, Table, Tabs, Tag, Tooltip, Typography, App } from 'antd';
import PageContainer from "@/components/shared/PageContainer";
import DataListPanel from "@/components/shared/DataListPanel";
import SectionCard from "@/components/shared/SectionCard";
import {
App,
AutoComplete,
Button,
Col,
Divider,
Drawer,
Form,
Input,
InputNumber,
Popconfirm,
Row,
Select,
Space,
Switch,
Table,
Tabs,
Tag,
Tooltip,
Typography,
} from "antd";
import {
DeleteOutlined,
EditOutlined,
@ -13,21 +30,29 @@ import {
SyncOutlined,
WifiOutlined,
} from "@ant-design/icons";
import PageContainer from "@/components/shared/PageContainer";
import DataListPanel from "@/components/shared/DataListPanel";
import SectionCard from "@/components/shared/SectionCard";
import AppPagination from "../../components/shared/AppPagination";
import { useDict } from "../../hooks/useDict";
import {
AiModelDTO,
AiLocalProfileVO,
AiModelDTO,
AiModelVO,
deleteAiModelByType,
getAiModelPage,
getRemoteModelList,
saveAiModel,
setTenantDefaultModel,
syncCurrentAsrSpeakers,
tenantDisableModel,
tenantEnableModel,
testLlmModelConnectivity,
testLocalModelConnectivity,
updateAiModel,
updatePlatformModelStatus,
} from "../../api/business/aimodel";
import {getMeetingCreateConfig, type MeetingCreateConfig} from "../../api/business/meeting";
import AppPagination from "../../components/shared/AppPagination";
import "./AiModels.css";
const { Option } = Select;
@ -52,7 +77,7 @@ const PROVIDER_BASE_URL_MAP: Record<string, string> = {
groq: "https://api.groq.com/openai",
};
const DEFAULT_LLM_TEST_MESSAGE = "请回复LLM 连通性测试成功。";
const DEFAULT_LLM_TEST_MESSAGE = "请只返回固定成功结果,用于 LLM 连通性测试。";
const AiModels: React.FC = () => {
const { message } = App.useApp();
@ -74,6 +99,7 @@ const AiModels: React.FC = () => {
const [connectivityLoading, setConnectivityLoading] = useState(false);
const [remoteModels, setRemoteModels] = useState<string[]>([]);
const [createConfig, setCreateConfig] = useState<MeetingCreateConfig>(DEFAULT_CREATE_CONFIG);
const modelNameAutoFilledRef = useRef(false);
const localProfileLoadedRef = useRef(false);
@ -87,9 +113,12 @@ const AiModels: React.FC = () => {
if (!profileStr) {
return false;
}
const profile = JSON.parse(profileStr);
return profile.isPlatformAdmin === true;
try {
const profile = JSON.parse(profileStr);
return profile.isPlatformAdmin === true;
} catch {
return false;
}
}, []);
useEffect(() => {
@ -129,7 +158,19 @@ const AiModels: React.FC = () => {
if (!baseUrl && defaultBaseUrl) {
form.setFieldValue("baseUrl", defaultBaseUrl);
}
}, [provider, drawerVisible, editingId, providers, form]);
}, [drawerVisible, editingId, form, provider, providers]);
useEffect(() => {
if (!drawerVisible || !isLocalProvider || !editingId || localProfileLoadedRef.current) {
return;
}
const values = form.getFieldsValue(["baseUrl", "apiKey"]);
if (!values.baseUrl || !values.apiKey) {
return;
}
localProfileLoadedRef.current = true;
void handleTestConnectivity();
}, [drawerVisible, editingId, form, isLocalProvider]);
const fetchData = async () => {
setLoading(true);
@ -155,31 +196,22 @@ const AiModels: React.FC = () => {
if (record) {
setEditingId(record.id);
const speakerModel = record.mediaConfig?.speakerModel;
const svThreshold = record.mediaConfig?.svThreshold;
const tencentAppId = record.mediaConfig?.tencentAppId;
const tencentSecretId = record.mediaConfig?.tencentSecretId;
const tencentSecretKey = record.mediaConfig?.tencentSecretKey;
const tencentOfflineModelCode = record.mediaConfig?.tencentOfflineModelCode || record.modelCode;
const tencentRealtimeModelCode = record.mediaConfig?.tencentRealtimeModelCode || record.modelCode;
form.setFieldsValue({
...record,
modelType: record.modelType,
speakerModel,
svThreshold,
tencentAppId,
tencentSecretId,
tencentSecretKey,
tencentOfflineModelCode,
tencentRealtimeModelCode,
speakerModel: record.mediaConfig?.speakerModel,
svThreshold: record.mediaConfig?.svThreshold,
tencentAppId: record.mediaConfig?.tencentAppId,
tencentSecretId: record.mediaConfig?.tencentSecretId,
tencentSecretKey: record.mediaConfig?.tencentSecretKey,
tencentOfflineModelCode: record.mediaConfig?.tencentOfflineModelCode || record.modelCode,
tencentRealtimeModelCode: record.mediaConfig?.tencentRealtimeModelCode || record.modelCode,
isDefaultChecked: record.isDefault === 1,
statusChecked: record.status === 1,
});
if (record.modelCode) {
setRemoteModels([record.modelCode]);
}
if (speakerModel) {
}
} else {
setEditingId(null);
form.resetFields();
@ -198,20 +230,6 @@ const AiModels: React.FC = () => {
setDrawerVisible(true);
};
useEffect(() => {
if (!drawerVisible || !isLocalProvider || !editingId || localProfileLoadedRef.current) {
return;
}
const values = form.getFieldsValue(["baseUrl", "apiKey"]);
if (!values.baseUrl || !values.apiKey) {
return;
}
localProfileLoadedRef.current = true;
void handleTestConnectivity();
}, [drawerVisible, isLocalProvider, editingId, form]);
const handleFetchRemote = async () => {
if (isLocalProvider) {
await handleTestConnectivity();
@ -277,6 +295,7 @@ const AiModels: React.FC = () => {
message.warning("默认模型必须保持启用状态");
return;
}
const payload: AiModelDTO = {
id: editingId ?? undefined,
modelType: values.modelType,
@ -295,13 +314,13 @@ const AiModels: React.FC = () => {
}
: activeType === "ASR" && isTencentProvider
? {
tencentAppId: values.tencentAppId,
tencentSecretId: values.tencentSecretId,
tencentSecretKey: values.tencentSecretKey,
tencentOfflineModelCode: values.tencentOfflineModelCode,
tencentRealtimeModelCode: values.tencentRealtimeModelCode,
}
: undefined,
tencentAppId: values.tencentAppId,
tencentSecretId: values.tencentSecretId,
tencentSecretKey: values.tencentSecretKey,
tencentOfflineModelCode: values.tencentOfflineModelCode,
tencentRealtimeModelCode: values.tencentRealtimeModelCode,
}
: undefined,
temperature: values.temperature,
topP: values.topP,
isDefault: values.isDefaultChecked ? 1 : 0,
@ -351,15 +370,11 @@ const AiModels: React.FC = () => {
const values = await form.validateFields(["provider", "baseUrl"]);
if (String(values.provider || "").toLowerCase() !== "local") {
message.warning("仅本地模型支持连通性测试");
message.warning("只有本地 ASR 支持该连通性测试");
return;
}
const { apiKey } = form.getFieldsValue(["apiKey"]);
// if (!apiKey) {
// message.warning("请先填写 API Key 后再测试连接");
// return;
// }
const { apiKey } = form.getFieldsValue(["apiKey"]);
setConnectivityLoading(true);
try {
const res = await testLocalModelConnectivity({
@ -380,7 +395,35 @@ const AiModels: React.FC = () => {
void fetchData();
};
const columns = [
const handleTenantToggle = async (record: AiModelVO, checked: boolean) => {
if (checked) {
await tenantEnableModel(record.id, activeType);
message.success(activeType === "ASR" ? "已切换当前 ASR" : "已启用当前 LLM");
} else {
await tenantDisableModel(record.id, activeType);
message.success(activeType === "ASR" ? "已关闭当前 ASR" : "已关闭当前 LLM");
}
await fetchData();
};
const handlePlatformStatusToggle = async (record: AiModelVO, checked: boolean) => {
await updatePlatformModelStatus(record.id, activeType, checked ? 1 : 0);
message.success(checked ? `平台级 ${activeType} 已启用` : `平台级 ${activeType} 已禁用`);
await fetchData();
};
const handleSyncCurrentAsr = async () => {
await syncCurrentAsrSpeakers();
message.success("已提交后台同步任务");
};
const handleSetTenantDefault = async (record: AiModelVO) => {
await setTenantDefaultModel(record.id, "LLM");
message.success("已设置为默认 LLM");
await fetchData();
};
const resolvedTableColumns = [
{
title: "模型名称",
dataIndex: "modelName",
@ -388,12 +431,18 @@ const AiModels: React.FC = () => {
render: (text: string, record: AiModelVO) => (
<Space>
{text}
{record.isDefault === 1 && <Tag color="gold"></Tag>}
{record.isDefault === 1 && <Tag color="gold"></Tag>}
{record.tenantDefault === 1 && <Tag color="blue"></Tag>}
{record.tenantId === 0 && (
<Tooltip title="系统预置">
<Tooltip title="平台透传模型">
<SafetyCertificateOutlined style={{ color: "#52c41a" }} />
</Tooltip>
)}
{record.scope && (
<Tag bordered={false} color={record.scope === "PLATFORM" ? "geekblue" : "default"}>
{record.scope === "PLATFORM" ? "平台级" : "租户级"}
</Tag>
)}
</Space>
),
},
@ -406,7 +455,11 @@ const AiModels: React.FC = () => {
return item ? <Tag>{item.itemLabel}</Tag> : value;
},
},
{ title: "模型名称(code)", dataIndex: "modelCode", key: "modelCode" },
{
title: "模型编码",
dataIndex: "modelCode",
key: "modelCode",
},
{
title: "排序",
dataIndex: "sortOrder",
@ -417,16 +470,41 @@ const AiModels: React.FC = () => {
title: "状态",
dataIndex: "status",
key: "status",
render: (status: number) =>
status === 1 ? <Tag color="green"></Tag> : <Tag></Tag>,
render: (status: number, record: AiModelVO) => {
if (isPlatformAdmin && record.scope === "PLATFORM") {
return (
<Switch
checked={status === 1}
checkedChildren="启用"
unCheckedChildren="禁用"
onChange={(checked) => void handlePlatformStatusToggle(record, checked)}
/>
);
}
return (
<Switch
checked={record.tenantEnabled === 1}
checkedChildren={activeType === "ASR" ? "当前生效" : "已启用"}
unCheckedChildren={activeType === "ASR" ? "未启用" : "已关闭"}
disabled={status !== 1}
onChange={(checked) => void handleTenantToggle(record, checked)}
/>
);
},
},
{
title: "操作",
key: "action",
render: (_: unknown, record: AiModelVO) => {
const canEdit = record.tenantId !== 0 || isPlatformAdmin;
const canEdit = record.canEditConfig ?? (record.tenantId !== 0 || isPlatformAdmin);
const canSetDefault = activeType === "LLM" && record.tenantEnabled === 1;
return (
<Space>
{canSetDefault && (
<Button type="link" onClick={() => void handleSetTenantDefault(record)}>
{record.tenantDefault === 1 ? "默认 LLM" : "设为默认"}
</Button>
)}
{canEdit && (
<Button type="link" icon={<EditOutlined />} onClick={() => openDrawer(record)}>
@ -445,11 +523,21 @@ const AiModels: React.FC = () => {
},
];
const leftActions = (
<Space wrap>
<Button type="primary" icon={<PlusOutlined/>} onClick={() => openDrawer()}>
</Button>
{activeType === "ASR" && (
<Button icon={<SyncOutlined/>} onClick={() => void handleSyncCurrentAsr()}>
ASR
</Button>
)}
</Space>
);
return (
<PageContainer
title={null}
className="ai-models-page"
>
<PageContainer title={null} className="ai-models-page">
<SectionCard
title="AI 模型配置"
description="管理 ASR 语音识别和 LLM 大语言模型。"
@ -471,18 +559,17 @@ const AiModels: React.FC = () => {
>
<DataListPanel
className="ai-models-data-panel"
leftActions={
<Button type="primary" icon={<PlusOutlined />} onClick={() => openDrawer()}>
</Button>
}
leftActions={leftActions}
rightActions={
<Input
<Input.Search
allowClear
placeholder="搜索模型名称"
prefix={<SearchOutlined />}
allowClear
onPressEnter={(event) => setSearchName((event.target as HTMLInputElement).value)}
className="ai-models-search"
onSearch={(value) => {
setCurrent(1);
setSearchName(value.trim());
}}
/>
}
footer={
@ -499,7 +586,7 @@ const AiModels: React.FC = () => {
>
<Table
rowKey="id"
columns={columns}
columns={resolvedTableColumns}
dataSource={data}
loading={loading}
scroll={{ x: "max-content", y: "100%" }}
@ -530,7 +617,7 @@ const AiModels: React.FC = () => {
<Form.Item label="模型类型">
<Tag color={activeType === "ASR" ? "blue" : "purple"}>
{activeType === "ASR" ? "语音识别 (ASR)" : "总结模型 (LLM)"}
{activeType === "ASR" ? "语音识别 (ASR)" : "大语言模型 (LLM)"}
</Tag>
</Form.Item>
@ -541,11 +628,9 @@ const AiModels: React.FC = () => {
label="显示名称"
rules={[{ required: true, message: "请输入显示名称" }]}
>
<Input
onChange={() => {
modelNameAutoFilledRef.current = false;
}}
/>
<Input onChange={() => {
modelNameAutoFilledRef.current = false;
}}/>
</Form.Item>
</Col>
<Col span={12}>
@ -575,15 +660,11 @@ const AiModels: React.FC = () => {
{!isTencentProvider && (
<>
<Form.Item name="baseUrl" label="Base URL" rules={[{required: true, message: "请输入 Base URL"}]}>
<Input placeholder="https://api.example.com"/>
<Form.Item name="baseUrl" label="Base URL" rules={[{required: true, message: "请输入 Base URL"}]}>
<Input placeholder="https://api.example.com"/>
</Form.Item>
<Form.Item
name="apiKey"
label="API Key"
>
<Input.Password/>
<Form.Item name="apiKey" label="API Key">
<Input.Password/>
</Form.Item>
</>
)}
@ -601,19 +682,16 @@ const AiModels: React.FC = () => {
</Divider>
<Form.Item
label="模型名称"
label="模型编码"
required={activeType === "LLM"}
hidden={activeType === "ASR" && isTencentProvider}
tooltip="可从远程列表选择,也可手动输入;值将作为模型 code 传给后端"
tooltip="可从远程列表选择,也可手动输入;该值会作为模型编码传给后端"
>
<Space.Compact style={{ width: "100%" }}>
<Form.Item
name="modelCode"
noStyle
rules={activeType === "LLM" ? [{
required: true,
message: "请输入或选择模型名称"
}] : []}
rules={activeType === "LLM" ? [{required: true, message: "请输入或选择模型编码"}] : []}
>
<AutoComplete
style={{ width: "calc(100% - 100px)" }}
@ -624,45 +702,29 @@ const AiModels: React.FC = () => {
}}
options={remoteModels.map((model) => ({ value: model }))}
filterOption={(inputValue, option) =>
isLocalProvider ||
String(option?.value || "").toLowerCase().includes(inputValue.toLowerCase())
isLocalProvider || String(option?.value || "").toLowerCase().includes(inputValue.toLowerCase())
}
>
<Input allowClear placeholder="可选择或自定义输入模型名称" />
<Input allowClear placeholder="可选择或手动输入模型编码"/>
</AutoComplete>
</Form.Item>
{!isTencentProvider && (
<Button icon={<SyncOutlined spin={fetchLoading}/>} onClick={handleFetchRemote} style={{width: 100}}>
<Button icon={<SyncOutlined spin={fetchLoading}/>} onClick={handleFetchRemote} style={{width: 100}}>
</Button>
)}
</Space.Compact>
</Form.Item>
<Form.Item name="wsUrl" label="WebSocket 地址"
hidden={!(activeType === "ASR" && createConfig.realtimeEnabled)}>
<Form.Item name="wsUrl" label="WebSocket 地址"
hidden={!(activeType === "ASR" && createConfig.realtimeEnabled)}>
<Input placeholder="wss://api.example.com/v1/ws" />
</Form.Item>
{activeType === "ASR" && isLocalProvider && (
<Row gutter={16} hidden>
{/*<Col span={12}>*/}
{/* <Form.Item*/}
{/* name="speakerModel"*/}
{/* label="声纹模型"*/}
{/* >*/}
{/* <Select*/}
{/* allowClear*/}
{/* placeholder="请先测试连接获取声纹模型"*/}
{/* options={speakerModels.map((model) => ({ label: model, value: model }))}*/}
{/* />*/}
{/* </Form.Item>*/}
{/*</Col>*/}
<Col span={12}>
<Form.Item
name="svThreshold"
label="声纹阈值"
>
<Form.Item name="svThreshold" label="声纹阈值">
<InputNumber min={0} max={1} step={0.01} style={{ width: "100%" }} />
</Form.Item>
</Col>
@ -672,48 +734,32 @@ const AiModels: React.FC = () => {
{activeType === "ASR" && isTencentProvider && (
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="tencentAppId"
label="App ID"
rules={[{required: true, message: "请输入 App ID"}]}
>
<Input/>
<Form.Item name="tencentAppId" label="App ID" rules={[{required: true, message: "请输入 App ID"}]}>
<Input/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="tencentSecretId"
label="Secret ID"
rules={[{required: true, message: "请输入 Secret ID"}]}
>
<Input/>
<Form.Item name="tencentSecretId" label="Secret ID"
rules={[{required: true, message: "请输入 Secret ID"}]}>
<Input/>
</Form.Item>
</Col>
<Col span={24}>
<Form.Item
name="tencentSecretKey"
label="Secret Key"
rules={[{required: true, message: "请输入 Secret Key"}]}
>
<Input.Password/>
<Form.Item name="tencentSecretKey" label="Secret Key"
rules={[{required: true, message: "请输入 Secret Key"}]}>
<Input.Password/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="tencentOfflineModelCode"
label="离线识别模型名"
rules={[{required: true, message: "请输入离线识别模型名"}]}
>
<Input placeholder="例如16k_zh"/>
<Form.Item name="tencentOfflineModelCode" label="离线识别模型"
rules={[{required: true, message: "请输入离线识别模型"}]}>
<Input placeholder="例如16k_zh"/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="tencentRealtimeModelCode"
label="实时识别模型名"
rules={[{required: true, message: "请输入实时识别模型名"}]}
>
<Input placeholder="例如16k_zh_realtime"/>
<Form.Item name="tencentRealtimeModelCode" label="实时识别模型"
rules={[{required: true, message: "请输入实时识别模型"}]}>
<Input placeholder="例如16k_zh_realtime"/>
</Form.Item>
</Col>
</Row>
@ -726,12 +772,12 @@ const AiModels: React.FC = () => {
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="temperature" label="温度">
<Form.Item name="temperature" label="Temperature">
<InputNumber min={0} max={2} step={0.1} style={{ width: "100%" }} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="topP" label="Top P(核采样)">
<Form.Item name="topP" label="Top P">
<InputNumber min={0} max={1} step={0.1} style={{ width: "100%" }} />
</Form.Item>
</Col>

View File

@ -1771,6 +1771,7 @@ const MeetingDetail: React.FC = () => {
console.error(error);
} finally {
setActionLoading(false);
setSummaryVisible(false)
}
};

View File

@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import React, {useEffect, useMemo, useRef, useState} from "react";
import {
AudioOutlined,
CheckCircleOutlined,
@ -6,56 +6,90 @@ import {
DeleteOutlined,
FormOutlined,
SearchOutlined,
StopOutlined,
UserOutlined,
SoundOutlined,
SafetyCertificateOutlined
} from '@ant-design/icons';
import { Badge, Button, Card, Col, Empty, Form, Input, Popconfirm, Progress, Row, Select, Spin, Space, Tabs, Tag, Typography, Upload, Tooltip, App } from 'antd';
import type { UploadProps } from 'antd';
import dayjs from 'dayjs';
import { listUsers } from '../../api';
import { deleteSpeaker, getSpeakerPage, registerSpeaker, SpeakerVO } from '../../api/business/speaker';
import AppPagination from '../../components/shared/AppPagination';
import PageContainer from '../../components/shared/PageContainer';
import SectionCard from '../../components/shared/SectionCard';
import { useAuth } from '../../hooks/useAuth';
import type { SysUser } from '../../types';
import './SpeakerReg.css';
StopOutlined,
SyncOutlined,
UserOutlined,
SafetyCertificateOutlined,
} from "@ant-design/icons";
import {
App,
Badge,
Button,
Card,
Col,
Empty,
Form,
Input,
Popconfirm,
Progress,
Row,
Select,
Space,
Spin,
Tabs,
Tag,
Tooltip,
Typography,
Upload,
} from "antd";
import type {UploadProps} from "antd";
import dayjs from "dayjs";
import {listUsers} from "../../api";
import {
deleteSpeaker,
getSpeakerPage,
registerSpeaker,
SpeakerVO,
syncSpeaker,
} from "../../api/business/speaker";
import AppPagination from "../../components/shared/AppPagination";
import PageContainer from "../../components/shared/PageContainer";
import SectionCard from "../../components/shared/SectionCard";
import {useAuth} from "../../hooks/useAuth";
import type {SysUser} from "../../types";
import "./SpeakerReg.css";
const { Text } = Typography;
const { Search } = Input;
const REG_CONTENT =
'iMeeting 智能会议系统,助力高效办公,让每一场讨论都有据可查。我正在进行声纹注册,以确保会议识别的准确性。';
"iMeeting 智能会议系统,助力高效办公,让每一场讨论都有据可查。我正在进行声纹注册,以确保会议识别的准确性。";
const DEFAULT_DURATION = 15;
const DEFAULT_PAGE_SIZE = 8;
const AUDIO_EXT_PATTERN = /\.(mp3|wav|m4a|aac|ogg|flac|webm)$/i;
const SPEAKER_STATUS_META: Record<number, { label: string; color: string }> = {
1: { label: '已保存', color: 'default' },
2: { label: '注册中', color: 'processing' },
3: { label: '已注册', color: 'success' },
4: { label: '本地已保存,声纹同步失败', color: 'error' }
const SPEAKER_STATUS_META: Record<string, { label: string; color: string }> = {
PENDING: {label: "未同步", color: "default"},
SYNCING: {label: "同步中", color: "processing"},
SYNCED: {label: "已注册", color: "success"},
FAILED: {label: "同步失败", color: "error"},
STALE: {label: "待重新同步", color: "warning"},
DELETED: {label: "待重新同步", color: "warning"},
};
const getSpeakerStatusMeta = (status?: number) => {
return SPEAKER_STATUS_META[Number(status)] || SPEAKER_STATUS_META[1];
const getSpeakerStatusMeta = (speaker: SpeakerVO) => {
return SPEAKER_STATUS_META[speaker.syncStatus || "PENDING"] || SPEAKER_STATUS_META.PENDING;
};
const isAudioFile = (file: File) => {
return file.type.startsWith('audio/') || AUDIO_EXT_PATTERN.test(file.name);
return file.type.startsWith("audio/") || AUDIO_EXT_PATTERN.test(file.name);
};
const buildResourceUrl = (prefix: string, resourcePath?: string) => {
if (!resourcePath) {
return '';
return "";
}
const normalizedPrefix = prefix.endsWith('/') ? prefix : `${prefix}/`;
const normalizedPath = resourcePath.startsWith('/') ? resourcePath.slice(1) : resourcePath;
const normalizedPrefix = prefix.endsWith("/") ? prefix : `${prefix}/`;
const normalizedPath = resourcePath.startsWith("/") ? resourcePath.slice(1) : resourcePath;
return `${normalizedPrefix}${normalizedPath}`;
};
const getErrorMessage = (err: unknown, fallback: string) => {
const responseMessage = (err as any)?.response?.data?.msg || (err as any)?.response?.data?.message;
return responseMessage || fallback;
};
const SpeakerReg: React.FC = () => {
const { message } = App.useApp();
const [form] = Form.useForm();
@ -64,12 +98,13 @@ const SpeakerReg: React.FC = () => {
const [audioUrl, setAudioUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [speakers, setSpeakers] = useState<SpeakerVO[]>([]);
const [searchKeyword, setSearchKeyword] = useState('');
const [queryName, setQueryName] = useState('');
const [searchKeyword, setSearchKeyword] = useState("");
const [queryName, setQueryName] = useState("");
const [current, setCurrent] = useState(1);
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
const [total, setTotal] = useState(0);
const [listLoading, setListLoading] = useState(false);
const [syncingSpeakerId, setSyncingSpeakerId] = useState<number | null>(null);
const [userOptions, setUserOptions] = useState<SysUser[]>([]);
const [editingSpeaker, setEditingSpeaker] = useState<SpeakerVO | null>(null);
const [seconds, setSeconds] = useState(0);
@ -83,15 +118,15 @@ const SpeakerReg: React.FC = () => {
const resourcePrefix = useMemo(() => {
try {
const configStr = sessionStorage.getItem('platformConfig');
const configStr = sessionStorage.getItem("platformConfig");
if (configStr) {
const config = JSON.parse(configStr);
return config.resourcePrefix || '/api/static/';
return config.resourcePrefix || "/api/static/";
}
} catch (err) {
console.warn('Parse platformConfig failed', err);
console.warn("Parse platformConfig failed", err);
}
return '/api/static/';
return "/api/static/";
}, []);
useEffect(() => {
@ -100,10 +135,10 @@ const SpeakerReg: React.FC = () => {
return () => {
mountedRef.current = false;
stopTimer();
if (mediaRecorderRef.current?.state === 'recording') {
if (mediaRecorderRef.current?.state === "recording") {
mediaRecorderRef.current.stop();
}
mediaRecorderRef.current?.stream.getTracks().forEach(track => track.stop());
mediaRecorderRef.current?.stream.getTracks().forEach((track) => track.stop());
};
}, []);
@ -124,8 +159,8 @@ const SpeakerReg: React.FC = () => {
if (!profile?.userId || isAdmin) {
return;
}
form.setFieldValue('userId', profile.userId);
form.setFieldValue('name', profile.displayName);
form.setFieldValue("userId", profile.userId);
form.setFieldValue("name", profile.displayName);
}, [form, isAdmin, profile?.displayName, profile?.userId]);
const fetchSpeakers = async (page = current, size = pageSize, name = queryName) => {
@ -134,7 +169,7 @@ const SpeakerReg: React.FC = () => {
const res = await getSpeakerPage({
current: page,
size,
name: name || undefined
name: name || undefined,
});
const payload: any = res.data || res;
const records = payload?.data?.records || payload?.records || [];
@ -149,7 +184,7 @@ const SpeakerReg: React.FC = () => {
setTotal(nextTotal);
} catch (err) {
console.error(err);
message.error('加载声纹库失败');
message.error("加载声纹库失败");
} finally {
setListLoading(false);
}
@ -162,7 +197,7 @@ const SpeakerReg: React.FC = () => {
} catch (err) {
console.error(err);
setUserOptions([]);
message.error('加载用户列表失败');
message.error("加载用户列表失败");
}
};
@ -174,10 +209,10 @@ const SpeakerReg: React.FC = () => {
const resetFormState = () => {
setEditingSpeaker(null);
form.resetFields(['id', 'name', 'userId', 'remark']);
form.resetFields(["id", "name", "userId", "remark"]);
if (!isAdmin && profile?.userId) {
form.setFieldValue('userId', profile.userId);
form.setFieldValue('name', profile.displayName);
form.setFieldValue("userId", profile.userId);
form.setFieldValue("name", profile.displayName);
}
resetAudioState();
};
@ -186,7 +221,7 @@ const SpeakerReg: React.FC = () => {
stopTimer();
setSeconds(0);
timerRef.current = setInterval(() => {
setSeconds(prev => Math.min(prev + 1, DEFAULT_DURATION));
setSeconds((prev) => Math.min(prev + 1, DEFAULT_DURATION));
}, 1000);
autoStopTimerRef.current = setTimeout(() => {
setSeconds(DEFAULT_DURATION);
@ -207,11 +242,11 @@ const SpeakerReg: React.FC = () => {
const startRecording = async () => {
if (loading) {
message.warning('声纹正在提交,请稍后再录制');
message.warning("声纹正在提交,请稍后再录制");
return;
}
if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
message.error('当前浏览器不支持录音,请使用音频文件上传');
if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === "undefined") {
message.error("当前浏览器不支持录音,请使用音频文件上传");
return;
}
try {
@ -220,7 +255,7 @@ const SpeakerReg: React.FC = () => {
mediaRecorderRef.current = mediaRecorder;
audioChunksRef.current = [];
mediaRecorder.ondataavailable = event => {
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
audioChunksRef.current.push(event.data);
}
@ -230,7 +265,7 @@ const SpeakerReg: React.FC = () => {
if (!mountedRef.current) {
return;
}
const blob = new Blob(audioChunksRef.current, { type: 'audio/wav' });
const blob = new Blob(audioChunksRef.current, {type: "audio/wav"});
resetAudioState();
setAudioBlob(blob);
setAudioUrl(URL.createObjectURL(blob));
@ -243,32 +278,31 @@ const SpeakerReg: React.FC = () => {
startTimer();
} catch (err) {
console.error(err);
message.error('无法访问麦克风,请检查权限设置');
message.error("无法访问麦克风,请检查权限设置");
}
};
const stopRecording = () => {
if (mediaRecorderRef.current && mediaRecorderRef.current.state === 'recording') {
if (mediaRecorderRef.current && mediaRecorderRef.current.state === "recording") {
mediaRecorderRef.current.stop();
mediaRecorderRef.current.stream.getTracks().forEach(track => track.stop());
mediaRecorderRef.current.stream.getTracks().forEach((track) => track.stop());
setRecording(false);
stopTimer();
}
};
const uploadProps: UploadProps = {
beforeUpload: file => {
beforeUpload: (file) => {
if (recording) {
message.warning('请先停止录音,再上传音频文件');
message.warning("请先停止录音,再上传音频文件");
return Upload.LIST_IGNORE;
}
if (loading) {
message.warning('声纹正在提交,请稍后再上传');
message.warning("声纹正在提交,请稍后再上传");
return Upload.LIST_IGNORE;
}
const isAudio = isAudioFile(file);
if (!isAudio) {
message.error('只能上传音频文件');
if (!isAudioFile(file)) {
message.error("只能上传音频文件");
return Upload.LIST_IGNORE;
}
resetAudioState();
@ -277,12 +311,12 @@ const SpeakerReg: React.FC = () => {
return false;
},
disabled: recording || loading,
showUploadList: false
showUploadList: false,
};
const handleSubmit = async () => {
if (!audioBlob && !editingSpeaker) {
message.warning('请先录制或上传声纹文件');
message.warning("请先录制或上传声纹文件");
return;
}
try {
@ -293,9 +327,9 @@ const SpeakerReg: React.FC = () => {
name: values.name.trim(),
userId: values.userId ? Number(values.userId) : undefined,
remark: values.remark?.trim(),
file: audioBlob || undefined
file: audioBlob || undefined,
});
message.success(editingSpeaker ? '声纹更新成功' : '声纹录入成功');
message.success(editingSpeaker ? "声纹保存成功,等待同步" : "声纹保存成功,等待同步");
resetFormState();
if (current !== 1) {
setCurrent(1);
@ -307,7 +341,7 @@ const SpeakerReg: React.FC = () => {
return;
}
console.error(err);
message.error('声纹录入失败');
message.error(getErrorMessage(err, "声纹保存失败"));
} finally {
setLoading(false);
}
@ -316,7 +350,7 @@ const SpeakerReg: React.FC = () => {
const handleDelete = async (speaker: SpeakerVO) => {
try {
await deleteSpeaker(speaker.id);
message.success('声纹已删除');
message.success("声纹已删除");
if (editingSpeaker?.id === speaker.id) {
resetFormState();
}
@ -327,7 +361,21 @@ const SpeakerReg: React.FC = () => {
}
} catch (err) {
console.error(err);
message.error('删除声纹失败');
message.error(getErrorMessage(err, "删除声纹失败"));
}
};
const handleSync = async (speaker: SpeakerVO) => {
setSyncingSpeakerId(speaker.id);
try {
await syncSpeaker(speaker.id);
message.success("已提交声纹同步任务");
void fetchSpeakers(current, pageSize, queryName);
} catch (err) {
console.error(err);
message.error(getErrorMessage(err, "同步声纹失败"));
} finally {
setSyncingSpeakerId(null);
}
};
@ -337,15 +385,15 @@ const SpeakerReg: React.FC = () => {
id: speaker.id,
name: speaker.name,
userId: speaker.userId,
remark: speaker.remark
remark: speaker.remark,
});
resetAudioState();
};
const handleUserChange = (userId?: number) => {
const selectedUser = userOptions.find(u => u.userId === userId);
const selectedUser = userOptions.find((user) => user.userId === userId);
if (selectedUser) {
form.setFieldValue('name', selectedUser.displayName || selectedUser.username);
form.setFieldValue("name", selectedUser.displayName || selectedUser.username);
}
};
@ -359,7 +407,7 @@ const SpeakerReg: React.FC = () => {
<PageContainer title={null} className="speaker-reg-page">
<SectionCard
title="声纹采集工作台"
description="采集或上传声纹样本,并维护当前租户下的发言人声纹库。"
description="采集或上传声纹样本,并维护当前租户下的发言人声纹库。状态以当前启用 ASR 的同步结果为准。"
extra={<Badge status="processing" text={<Text type="secondary"></Text>} />}
contentClassName="speaker-reg-section-content"
>
@ -371,18 +419,15 @@ const SpeakerReg: React.FC = () => {
<span className="speaker-reg-card-icon">
<FormOutlined />
</span>
<span>{editingSpeaker ? '更新声纹档案' : '新建声纹档案'}</span>
<span>{editingSpeaker ? "更新声纹档案" : "新建声纹档案"}</span>
</Space>
}
>
<Form form={form} layout="vertical" className="speaker-reg-form">
<Row gutter={16}>
<Col xs={24} md={14}>
<Form.Item
name="name"
label={<Text strong></Text>}
rules={[{ required: true, message: '必填' }]}
>
<Form.Item name="name" label={<Text strong></Text>}
rules={[{required: true, message: "必填"}]}>
<Input size="middle" placeholder="姓名 / 职位 / 编号" />
</Form.Item>
</Col>
@ -393,23 +438,27 @@ const SpeakerReg: React.FC = () => {
placeholder="系统关联"
disabled={!isAdmin}
onChange={handleUserChange}
options={userOptions.map(u => ({ label: u.displayName || u.username, value: u.userId }))}
options={userOptions.map((user) => ({
label: user.displayName || user.username,
value: user.userId,
}))}
/>
</Form.Item>
</Col>
</Row>
<Form.Item name="remark" label={<Text strong> ()</Text>}>
<Form.Item name="remark" label={<Text strong></Text>}>
<Input size="middle" placeholder="记录使用场景或特征说明" />
</Form.Item>
</Form>
<Tabs
defaultActiveKey="1"
<Tabs
defaultActiveKey="record"
className="speaker-reg-tabs"
size="middle"
items={[
{
key: "1",
key: "record",
label: <span><AudioOutlined /> </span>,
children: (
<div className="recording-area">
@ -417,36 +466,34 @@ const SpeakerReg: React.FC = () => {
<Text strong className="script-box__label"></Text>
<div className="script-box__content">{REG_CONTENT}</div>
</div>
<div className="record-controls">
<button
className={`btn-record ${recording ? 'recording' : 'idle'}`}
className={`btn-record ${recording ? "recording" : "idle"}`}
onClick={recording ? stopRecording : startRecording}
disabled={loading}
type="button"
aria-label={recording ? '停止录制声纹样本' : '开始录制声纹样本'}
aria-label={recording ? "停止录制声纹样本" : "开始录制声纹样本"}
>
{recording ? <StopOutlined /> : <AudioOutlined />}
</button>
<div className="record-progress">
<div className="record-progress__head">
<Text strong className={recording ? 'is-recording' : ''}>{recording ? '正在采集声音...' : '等待录制'}</Text>
<Text strong className={recording ? "is-recording" : ""}>
{recording ? "正在采集声音..." : "等待录制"}
</Text>
<Text type="secondary">{seconds}s / {DEFAULT_DURATION}s</Text>
</div>
<Progress
percent={(seconds / DEFAULT_DURATION) * 100}
showInfo={false}
strokeColor="#3c70f5"
size="small"
/>
<Progress percent={(seconds / DEFAULT_DURATION) * 100} showInfo={false} strokeColor="#3c70f5"
size="small"/>
</div>
</div>
</div>
)
),
},
{
key: "2",
key: "upload",
label: <span><CloudUploadOutlined /> 线</span>,
children: (
<Upload {...uploadProps} accept="audio/*" className="speaker-reg-upload">
@ -456,8 +503,8 @@ const SpeakerReg: React.FC = () => {
<Text type="secondary"> MP3 / WAV / M4A 5-15 </Text>
</div>
</Upload>
)
}
),
},
]}
/>
@ -465,7 +512,9 @@ const SpeakerReg: React.FC = () => {
<div className="speaker-reg-audio-ready">
<div className="speaker-reg-audio-ready__head">
<Text strong><CheckCircleOutlined /> </Text>
<Button type="text" danger size="small" onClick={resetAudioState} icon={<DeleteOutlined />}></Button>
<Button type="text" danger size="small" onClick={resetAudioState} icon={<DeleteOutlined/>}>
</Button>
</div>
<audio src={audioUrl} controls />
</div>
@ -480,16 +529,18 @@ const SpeakerReg: React.FC = () => {
loading={loading}
disabled={recording || (!audioBlob && !editingSpeaker)}
>
{editingSpeaker ? '确认保存声纹变更' : '提交并同步到声纹库'}
{editingSpeaker ? "确认保存声纹变更" : "保存声纹"}
</Button>
{editingSpeaker && (
<Button size="middle" block onClick={resetFormState}></Button>
<Button size="middle" block onClick={resetFormState}>
</Button>
)}
<div className="info-strip">
<SafetyCertificateOutlined />
<div>
</div>
</div>
</div>
@ -500,7 +551,7 @@ const SpeakerReg: React.FC = () => {
title={
<Space size={8}>
<SoundOutlined />
<span></span>
<span></span>
</Space>
}
extra={
@ -508,12 +559,12 @@ const SpeakerReg: React.FC = () => {
<Search
allowClear
value={searchKeyword}
onChange={e => {
onChange={(e) => {
const nextValue = e.target.value;
setSearchKeyword(nextValue);
if (!nextValue.trim() && queryName) {
setCurrent(1);
setQueryName('');
setQueryName("");
}
}}
onSearch={handleSearch}
@ -525,61 +576,79 @@ const SpeakerReg: React.FC = () => {
}
>
<Text type="secondary" className="speaker-reg-library__hint">
ASR
</Text>
<div className="speaker-list">
<Spin spinning={listLoading}>
{speakers.length === 0 ? (
<Empty
description={<Text type="secondary">{queryName ? '未找到匹配的声纹记录' : '暂无声纹记录'}</Text>}
className="speaker-reg-empty"
/>
) : (
speakers.map((s) => {
const statusMeta = getSpeakerStatusMeta(s.status);
return (
<div className={s.remark ? "speaker-card" : "speaker-card speaker-card--no-remark"} key={s.id}>
<div className="speaker-card__head">
<div className="speaker-card__identity">
<Text strong ellipsis>{s.name}</Text>
<div className="speaker-card__meta">
<Tag color={statusMeta.color} bordered={false}>
{statusMeta.label}
</Tag>
{s.userId && <Text type="secondary"><UserOutlined /> ID:{s.userId}</Text>}
<Spin spinning={listLoading}>
{speakers.length === 0 ? (
<Empty
description={<Text type="secondary">{queryName ? "未找到匹配的声纹记录" : "暂无声纹记录"}</Text>}
className="speaker-reg-empty"
/>
) : (
speakers.map((speaker) => {
const statusMeta = getSpeakerStatusMeta(speaker);
const canSync = speaker.syncStatus !== "SYNCED" && syncingSpeakerId !== speaker.id;
const statusTag = (
<Tag color={statusMeta.color} bordered={false}>
{statusMeta.label}
</Tag>
);
return (
<div className={speaker.remark ? "speaker-card" : "speaker-card speaker-card--no-remark"}
key={speaker.id}>
<div className="speaker-card__head">
<div className="speaker-card__identity">
<Text strong ellipsis>{speaker.name}</Text>
<div className="speaker-card__meta">
{speaker.syncErrorMessage ? (
<Tooltip title={speaker.syncErrorMessage}>{statusTag}</Tooltip>
) : statusTag}
{speaker.userId && <Text type="secondary"><UserOutlined/> ID:{speaker.userId}</Text>}
{speaker.syncStatus !== "SYNCED" && (
<Button
type="link"
size="small"
icon={<SyncOutlined/>}
loading={syncingSpeakerId === speaker.id}
onClick={() => void handleSync(speaker)}
>
</Button>
)}
</div>
</div>
<Space size={0}>
<Tooltip title="编辑档案">
<Button type="text" size="small" icon={<FormOutlined/>}
onClick={() => handleEdit(speaker)}/>
</Tooltip>
<Popconfirm title="确定要删除此声纹记录吗?" onConfirm={() => handleDelete(speaker)}>
<Button type="text" size="small" danger icon={<DeleteOutlined/>}/>
</Popconfirm>
</Space>
</div>
{speaker.remark && <div className="speaker-card__remark">{speaker.remark}</div>}
<audio
src={buildResourceUrl(resourcePrefix, speaker.voicePath)}
controls
controlsList="nodownload"
/>
<div className="speaker-card__footer">
<span> {dayjs(speaker.updatedAt).format("YYYY-MM-DD HH:mm")}</span>
<span>{((speaker.voiceSize || 0) / 1024).toFixed(1)} KB</span>
</div>
</div>
<Space size={0}>
<Tooltip title="编辑档案">
<Button type="text" size="small" icon={<FormOutlined />} onClick={() => handleEdit(s)} />
</Tooltip>
<Popconfirm title="确定要删除此声纹记录吗?" onConfirm={() => handleDelete(s)}>
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
</div>
{s.remark && (
<div className="speaker-card__remark">{s.remark}</div>
)}
<audio
src={buildResourceUrl(resourcePrefix, s.voicePath)}
controls
controlsList="nodownload"
/>
<div className="speaker-card__footer">
<span> {dayjs(s.updatedAt).format('YYYY-MM-DD HH:mm')}</span>
<span>{((s.voiceSize || 0) / 1024).toFixed(1)} KB</span>
</div>
</div>
);
})
)}
</Spin>
);
})
)}
</Spin>
</div>
<AppPagination
variant="card"
current={current}