diff --git a/backend/src/main/java/com/imeeting/controller/android/AndroidLlmModelController.java b/backend/src/main/java/com/imeeting/controller/android/AndroidLlmModelController.java index c4bb869..42fabaa 100644 --- a/backend/src/main/java/com/imeeting/controller/android/AndroidLlmModelController.java +++ b/backend/src/main/java/com/imeeting/controller/android/AndroidLlmModelController.java @@ -46,7 +46,7 @@ public class AndroidLlmModelController { AndroidRequestLogHelper.logRequest(log, "Android模型", "查询启用大模型列表接口"); AndroidAuthContext authContext = androidAuthService.authenticateHttp(request); LoginUser loginUser = AndroidLoginUserSupport.requireLoginUser(authContext); - PageResult> result = aiModelService.pageModels(1, 1000, null, "LLM", loginUser.getTenantId()); + PageResult> result = aiModelService.pageModels(1, 1000, null, "LLM", loginUser.getTenantId(), false); List enabledModels = result.getRecords() == null ? List.of() : result.getRecords().stream() diff --git a/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingController.java b/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingController.java index 0519e41..36c0112 100644 --- a/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingController.java +++ b/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingController.java @@ -453,7 +453,7 @@ public class AndroidMeetingController { .filter(item -> Integer.valueOf(1).equals(item.getStatus())) .toList(); resultVo.setTemplateList(enabledTemplates); - PageResult> modelList = aiModelService.pageModels(1, 1000, null, "LLM", tenantId); + PageResult> modelList = aiModelService.pageModels(1, 1000, null, "LLM", tenantId, false); List enabledModels = modelList.getRecords() == null ? List.of() : modelList.getRecords().stream() diff --git a/backend/src/main/java/com/imeeting/controller/android/legacy/LegacyLlmModelController.java b/backend/src/main/java/com/imeeting/controller/android/legacy/LegacyLlmModelController.java index 235dcf3..065a099 100644 --- a/backend/src/main/java/com/imeeting/controller/android/legacy/LegacyLlmModelController.java +++ b/backend/src/main/java/com/imeeting/controller/android/legacy/LegacyLlmModelController.java @@ -35,7 +35,7 @@ public class LegacyLlmModelController { public LegacyApiResponse> activeModels() { AndroidRequestLogHelper.logRequest(log, "兼容模型", "查询启用大模型列表接口"); LoginUser loginUser = currentLoginUser(); - PageResult> result = aiModelService.pageModels(1, 1000, null, "LLM", loginUser.getTenantId()); + PageResult> result = aiModelService.pageModels(1, 1000, null, "LLM", loginUser.getTenantId(), false); List enabledModels = result.getRecords() == null ? List.of() : result.getRecords().stream() diff --git a/backend/src/main/java/com/imeeting/controller/biz/AiModelController.java b/backend/src/main/java/com/imeeting/controller/biz/AiModelController.java index b405103..ad861e3 100644 --- a/backend/src/main/java/com/imeeting/controller/biz/AiModelController.java +++ b/backend/src/main/java/com/imeeting/controller/biz/AiModelController.java @@ -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 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 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 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 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 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; + } } diff --git a/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java b/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java index ffdbc8e..ea4ef80 100644 --- a/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java +++ b/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java @@ -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) diff --git a/backend/src/main/java/com/imeeting/controller/biz/SpeakerController.java b/backend/src/main/java/com/imeeting/controller/biz/SpeakerController.java index 26f76ea..e4e61cd 100644 --- a/backend/src/main/java/com/imeeting/controller/biz/SpeakerController.java +++ b/backend/src/main/java/com/imeeting/controller/biz/SpeakerController.java @@ -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 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()") diff --git a/backend/src/main/java/com/imeeting/dto/biz/AiModelVO.java b/backend/src/main/java/com/imeeting/dto/biz/AiModelVO.java index a1ede25..866d200 100644 --- a/backend/src/main/java/com/imeeting/dto/biz/AiModelVO.java +++ b/backend/src/main/java/com/imeeting/dto/biz/AiModelVO.java @@ -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 = "备注") diff --git a/backend/src/main/java/com/imeeting/dto/biz/PlatformAsrStatusUpdateCommand.java b/backend/src/main/java/com/imeeting/dto/biz/PlatformAsrStatusUpdateCommand.java new file mode 100644 index 0000000..89a81fa --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/biz/PlatformAsrStatusUpdateCommand.java @@ -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; +} diff --git a/backend/src/main/java/com/imeeting/dto/biz/SpeakerVO.java b/backend/src/main/java/com/imeeting/dto/biz/SpeakerVO.java index d2afba0..c199955 100644 --- a/backend/src/main/java/com/imeeting/dto/biz/SpeakerVO.java +++ b/backend/src/main/java/com/imeeting/dto/biz/SpeakerVO.java @@ -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") diff --git a/backend/src/main/java/com/imeeting/dto/biz/TenantModelDefaultCommand.java b/backend/src/main/java/com/imeeting/dto/biz/TenantModelDefaultCommand.java new file mode 100644 index 0000000..48eb2b4 --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/biz/TenantModelDefaultCommand.java @@ -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; +} diff --git a/backend/src/main/java/com/imeeting/entity/biz/Speaker.java b/backend/src/main/java/com/imeeting/entity/biz/Speaker.java index 974f65f..77a24b6 100644 --- a/backend/src/main/java/com/imeeting/entity/biz/Speaker.java +++ b/backend/src/main/java/com/imeeting/entity/biz/Speaker.java @@ -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 } diff --git a/backend/src/main/java/com/imeeting/entity/biz/SpeakerAsrSync.java b/backend/src/main/java/com/imeeting/entity/biz/SpeakerAsrSync.java new file mode 100644 index 0000000..556cd1a --- /dev/null +++ b/backend/src/main/java/com/imeeting/entity/biz/SpeakerAsrSync.java @@ -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; +} diff --git a/backend/src/main/java/com/imeeting/entity/biz/TenantModelActivation.java b/backend/src/main/java/com/imeeting/entity/biz/TenantModelActivation.java new file mode 100644 index 0000000..7af5924 --- /dev/null +++ b/backend/src/main/java/com/imeeting/entity/biz/TenantModelActivation.java @@ -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; +} diff --git a/backend/src/main/java/com/imeeting/enums/SpeakerAsrSyncStatusEnum.java b/backend/src/main/java/com/imeeting/enums/SpeakerAsrSyncStatusEnum.java new file mode 100644 index 0000000..a27d157 --- /dev/null +++ b/backend/src/main/java/com/imeeting/enums/SpeakerAsrSyncStatusEnum.java @@ -0,0 +1,10 @@ +package com.imeeting.enums; + +public enum SpeakerAsrSyncStatusEnum { + PENDING, + SYNCING, + SYNCED, + FAILED, + STALE, + DELETED +} diff --git a/backend/src/main/java/com/imeeting/mapper/biz/SpeakerAsrSyncMapper.java b/backend/src/main/java/com/imeeting/mapper/biz/SpeakerAsrSyncMapper.java new file mode 100644 index 0000000..2c9204c --- /dev/null +++ b/backend/src/main/java/com/imeeting/mapper/biz/SpeakerAsrSyncMapper.java @@ -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 { +} diff --git a/backend/src/main/java/com/imeeting/mapper/biz/TenantModelActivationMapper.java b/backend/src/main/java/com/imeeting/mapper/biz/TenantModelActivationMapper.java new file mode 100644 index 0000000..a91f650 --- /dev/null +++ b/backend/src/main/java/com/imeeting/mapper/biz/TenantModelActivationMapper.java @@ -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 { +} diff --git a/backend/src/main/java/com/imeeting/service/biz/AiModelService.java b/backend/src/main/java/com/imeeting/service/biz/AiModelService.java index 1948858..beb0688 100644 --- a/backend/src/main/java/com/imeeting/service/biz/AiModelService.java +++ b/backend/src/main/java/com/imeeting/service/biz/AiModelService.java @@ -11,11 +11,30 @@ import java.util.List; public interface AiModelService { AiModelVO saveModel(AiModelDTO dto); AiModelVO updateModel(AiModelDTO dto); - PageResult> pageModels(Integer current, Integer size, String name, String type, Long tenantId); + + PageResult> pageModels(Integer current, Integer size, String name, String type, Long tenantId, boolean platformAdmin); + + PageResult> pageModels(Integer current, Integer size, String name, String type, Long tenantId, boolean platformAdmin, boolean tenantEnabledOnly); List 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); } diff --git a/backend/src/main/java/com/imeeting/service/biz/MeetingExportService.java b/backend/src/main/java/com/imeeting/service/biz/MeetingExportService.java index 0e64f88..6b65bdc 100644 --- a/backend/src/main/java/com/imeeting/service/biz/MeetingExportService.java +++ b/backend/src/main/java/com/imeeting/service/biz/MeetingExportService.java @@ -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); } diff --git a/backend/src/main/java/com/imeeting/service/biz/SpeakerAsrGatewayService.java b/backend/src/main/java/com/imeeting/service/biz/SpeakerAsrGatewayService.java new file mode 100644 index 0000000..3d5e0e4 --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/biz/SpeakerAsrGatewayService.java @@ -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); +} diff --git a/backend/src/main/java/com/imeeting/service/biz/SpeakerAsrSyncService.java b/backend/src/main/java/com/imeeting/service/biz/SpeakerAsrSyncService.java new file mode 100644 index 0000000..5db7b6d --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/biz/SpeakerAsrSyncService.java @@ -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); +} diff --git a/backend/src/main/java/com/imeeting/service/biz/SpeakerService.java b/backend/src/main/java/com/imeeting/service/biz/SpeakerService.java index 80e092b..fa4cf3b 100644 --- a/backend/src/main/java/com/imeeting/service/biz/SpeakerService.java +++ b/backend/src/main/java/com/imeeting/service/biz/SpeakerService.java @@ -16,5 +16,7 @@ public interface SpeakerService extends IService { List listVisible(LoginUser loginUser); + void syncCurrentAsr(Long id, LoginUser loginUser); + void deleteSpeaker(Long id, LoginUser loginUser); } diff --git a/backend/src/main/java/com/imeeting/service/biz/TenantModelActivationService.java b/backend/src/main/java/com/imeeting/service/biz/TenantModelActivationService.java new file mode 100644 index 0000000..436b193 --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/biz/TenantModelActivationService.java @@ -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 refreshPlatformInheritanceForAsr(Long asrModelId); + + void disableAsrForAllTenants(Long asrModelId); + + boolean isTenantEnabled(String modelType, Long tenantId, Long modelId); + + List 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 refreshPlatformInheritanceForLlm(Long modelId); +} diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/AiModelServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/AiModelServiceImpl.java index bdfe5e8..f5e435b 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/AiModelServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/AiModelServiceImpl.java @@ -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> pageModels(Integer current, Integer size, String name, String type, Long tenantId) { + public PageResult> 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> 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 enabledModelIds = resolveTenantEnabledModelIds(TYPE_ASR, tenantId, platformAdmin, tenantEnabledOnly); + if (tenantEnabledOnly && !platformAdmin && enabledModelIds.isEmpty()) { + return emptyModelPage(); + } Page page = new Page<>(current, size); LambdaQueryWrapper wrapper = new LambdaQueryWrapper() - .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 resultPage = asrModelMapper.selectPage(page, wrapper); List 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> result = new PageResult<>(); - result.setTotal(resultPage.getTotal()); + result.setTotal(tenantEnabledOnly ? records.size() : resultPage.getTotal()); result.setRecords(records); return result; } + List enabledModelIds = resolveTenantEnabledModelIds(TYPE_LLM, tenantId, platformAdmin, tenantEnabledOnly); + if (tenantEnabledOnly && !platformAdmin && enabledModelIds.isEmpty()) { + return emptyModelPage(); + } Page page = new Page<>(current, size); LambdaQueryWrapper wrapper = new LambdaQueryWrapper() - .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 resultPage = llmModelMapper.selectPage(page, wrapper); List 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> 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 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 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() + .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() + .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() .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() + .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() - .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() + .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 wrapper = new LambdaQueryWrapper() + .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 resolveTenantEnabledModelIds(String modelType, Long tenantId, boolean platformAdmin, boolean tenantEnabledOnly) { + if (!tenantEnabledOnly || platformAdmin || tenantModelActivationService == null) { + return Collections.emptyList(); + } + return tenantModelActivationService.listEnabledModelIds(modelType, tenantId); + } + + private PageResult> emptyModelPage() { + PageResult> 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; diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java index efde5cb..d8e528c 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java @@ -1278,7 +1278,7 @@ public class AiTaskServiceImpl extends ServiceImpl impleme HttpResponse 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 summaryBundle = meetingSummaryFileService.parseSummaryBundle(content); diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingExportServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingExportServiceImpl.java index b1ad1b3..6c88d3c 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingExportServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingExportServiceImpl.java @@ -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 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 watermarkLines(String watermarkText) { + String[] parts = watermarkText == null ? new String[0] : watermarkText.split("\\R", 2); + List 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 orgMap = new HashMap<>(); + for (SysOrgDTO org : sysOrgService.listTree(loginUser.getTenantId())) { + if (org != null && org.getId() != null) { + orgMap.put(org.getId(), org); + } + } + + List 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); diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/SpeakerAsrGatewayServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/SpeakerAsrGatewayServiceImpl.java new file mode 100644 index 0000000..81a795f --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/biz/impl/SpeakerAsrGatewayServiceImpl.java @@ -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 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 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 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 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) dataMap).get("speaker_id"); + if (nestedSpeakerId == null) { + nestedSpeakerId = ((Map) 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; + } + } +} diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/SpeakerAsrSyncServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/SpeakerAsrSyncServiceImpl.java new file mode 100644 index 0000000..c40ef23 --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/biz/impl/SpeakerAsrSyncServiceImpl.java @@ -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 speakers = speakerMapper.selectList(new QueryWrapper() + .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 snapshots = syncMapper.selectList(new QueryWrapper() + .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() + .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); + } +} diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/SpeakerServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/SpeakerServiceImpl.java index d915e09..d7a2395 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/SpeakerServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/SpeakerServiceImpl.java @@ -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 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 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 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 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 impl .orderByDesc(Speaker::getUpdatedAt) .page(new Page<>(current, size)); + Long activeAsrId = tenantAsrActivationService.resolveActiveAsrId(loginUser.getTenantId()); + Map syncSnapshotMap = loadSyncSnapshotMap(loginUser.getTenantId(), activeAsrId, page.getRecords()); List 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> result = new PageResult<>(); @@ -139,13 +168,25 @@ public class SpeakerServiceImpl extends ServiceImpl impl .eq(!admin, Speaker::getUserId, loginUser.getUserId()) .orderByDesc(Speaker::getUpdatedAt) .list(); + Long activeAsrId = tenantAsrActivationService.resolveActiveAsrId(loginUser.getTenantId()); + Map syncSnapshotMap = loadSyncSnapshotMap(loginUser.getTenantId(), activeAsrId, list); List 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 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 impl return Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()) || Boolean.TRUE.equals(loginUser.getIsTenantAdmin()); } - private SpeakerVO toVO(Speaker speaker) { + private Map loadSyncSnapshotMap(Long tenantId, Long activeAsrId, List speakers) { + if (tenantId == null || activeAsrId == null || speakers == null || speakers.isEmpty()) { + return Collections.emptyMap(); + } + List speakerIds = speakers.stream() + .map(Speaker::getId) + .filter(Objects::nonNull) + .toList(); + if (speakerIds.isEmpty()) { + return Collections.emptyMap(); + } + return speakerAsrSyncMapper.selectList(new QueryWrapper() + .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 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()); diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/TenantModelActivationServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/TenantModelActivationServiceImpl.java new file mode 100644 index 0000000..4cb553e --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/biz/impl/TenantModelActivationServiceImpl.java @@ -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() + .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 refreshPlatformInheritanceForAsr(Long asrModelId) { + if (asrModelId == null || tenantManagementService == null) { + return List.of(); + } + List 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() + .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() + .eq("tenant_id", tenantId) + .eq("model_type", normalizeType(modelType)) + .eq("model_id", modelId) + .eq("enabled", 1) + .eq("is_deleted", 0)) > 0; + } + + @Override + public List listEnabledModelIds(String modelType, Long tenantId) { + if (tenantId == null) { + return List.of(); + } + return activationMapper.selectList(new QueryWrapper() + .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() + .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() + .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() + .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() + .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() + .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 refreshPlatformInheritanceForLlm(Long modelId) { + if (modelId == null || tenantManagementService == null) { + return List.of(); + } + List tenantIds = new ArrayList<>(); + pageTenants().forEach(tenantId -> { + upsert(TYPE_LLM, tenantId, modelId, 1, null); + tenantIds.add(tenantId); + }); + return tenantIds; + } + + private List pageTenants() { + List tenantIds = new ArrayList<>(); + int current = 1; + int size = 1000; + while (true) { + PageResult> pageResult = tenantManagementService.listTenants(current, size, null, null); + List 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() + .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(); + } +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index a7378a1..01718b4 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -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 diff --git a/backend/src/test/java/com/imeeting/service/biz/impl/AiModelServiceImplTest.java b/backend/src/test/java/com/imeeting/service/biz/impl/AiModelServiceImplTest.java index 4fa68a6..d471150 100644 --- a/backend/src/test/java/com/imeeting/service/biz/impl/AiModelServiceImplTest.java +++ b/backend/src/test/java/com/imeeting/service/biz/impl/AiModelServiceImplTest.java @@ -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 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 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 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 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 requestPath, AtomicReference authorization, diff --git a/backend/src/test/java/com/imeeting/service/biz/impl/MeetingRuntimeProfileResolverImplTest.java b/backend/src/test/java/com/imeeting/service/biz/impl/MeetingRuntimeProfileResolverImplTest.java index de53411..d3f0d65 100644 --- a/backend/src/test/java/com/imeeting/service/biz/impl/MeetingRuntimeProfileResolverImplTest.java +++ b/backend/src/test/java/com/imeeting/service/biz/impl/MeetingRuntimeProfileResolverImplTest.java @@ -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); diff --git a/frontend/src/api/business/aimodel.ts b/frontend/src/api/business/aimodel.ts index b856e1f..2b08f4d 100644 --- a/frontend/src/api/business/aimodel.ts +++ b/frontend/src/api/business/aimodel.ts @@ -16,6 +16,10 @@ export interface AiModelVO { mediaConfig?: Record; 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", diff --git a/frontend/src/api/business/speaker.ts b/frontend/src/api/business/speaker.ts index 23d370a..a992e87 100644 --- a/frontend/src/api/business/speaker.ts +++ b/frontend/src/api/business/speaker.ts @@ -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` + ); +}; diff --git a/frontend/src/components/business/MeetingCreateDrawer.tsx b/frontend/src/components/business/MeetingCreateDrawer.tsx index 86d1a5e..21c828b 100644 --- a/frontend/src/components/business/MeetingCreateDrawer.tsx +++ b/frontend/src/components/business/MeetingCreateDrawer.tsx @@ -183,8 +183,8 @@ export const MeetingCreateDrawer: React.FC = ({ 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(), diff --git a/frontend/src/pages/business/AiModels.tsx b/frontend/src/pages/business/AiModels.tsx index 5a0b487..e7746ef 100644 --- a/frontend/src/pages/business/AiModels.tsx +++ b/frontend/src/pages/business/AiModels.tsx @@ -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 = { 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([]); const [createConfig, setCreateConfig] = useState(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) => ( {text} - {record.isDefault === 1 && 默认} + {record.isDefault === 1 && 系统默认} + {record.tenantDefault === 1 && 租户默认} {record.tenantId === 0 && ( - + )} + {record.scope && ( + + {record.scope === "PLATFORM" ? "平台级" : "租户级"} + + )} ), }, @@ -406,7 +455,11 @@ const AiModels: React.FC = () => { return item ? {item.itemLabel} : 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 ? 启用 : 禁用, + render: (status: number, record: AiModelVO) => { + if (isPlatformAdmin && record.scope === "PLATFORM") { + return ( + void handlePlatformStatusToggle(record, checked)} + /> + ); + } + return ( + 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 ( + {canSetDefault && ( + + )} {canEdit && ( + {activeType === "ASR" && ( + + )} + + ); + return ( - + { > } onClick={() => openDrawer()}> - 新增模型 - - } + leftActions={leftActions} rightActions={ - } - 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 = () => { > { - {activeType === "ASR" ? "语音识别 (ASR)" : "总结模型 (LLM)"} + {activeType === "ASR" ? "语音识别 (ASR)" : "大语言模型 (LLM)"} @@ -541,11 +628,9 @@ const AiModels: React.FC = () => { label="显示名称" rules={[{ required: true, message: "请输入显示名称" }]} > - { - modelNameAutoFilledRef.current = false; - }} - /> + { + modelNameAutoFilledRef.current = false; + }}/> @@ -575,15 +660,11 @@ const AiModels: React.FC = () => { {!isTencentProvider && ( <> - - + + - - - + + )} @@ -601,19 +682,16 @@ const AiModels: React.FC = () => { - */} - {/* */} - {/* ({ label: model, value: model }))}*/} - {/* />*/} - {/* */} - {/**/} - + @@ -672,48 +734,32 @@ const AiModels: React.FC = () => { {activeType === "ASR" && isTencentProvider && ( - - + + - - + + - - + + - - + + - - + + @@ -726,12 +772,12 @@ const AiModels: React.FC = () => { - + - + diff --git a/frontend/src/pages/business/MeetingDetail.tsx b/frontend/src/pages/business/MeetingDetail.tsx index 046fae0..49a3cea 100644 --- a/frontend/src/pages/business/MeetingDetail.tsx +++ b/frontend/src/pages/business/MeetingDetail.tsx @@ -1771,6 +1771,7 @@ const MeetingDetail: React.FC = () => { console.error(error); } finally { setActionLoading(false); + setSummaryVisible(false) } }; diff --git a/frontend/src/pages/business/SpeakerReg.tsx b/frontend/src/pages/business/SpeakerReg.tsx index 0b070e8..c81b9b5 100644 --- a/frontend/src/pages/business/SpeakerReg.tsx +++ b/frontend/src/pages/business/SpeakerReg.tsx @@ -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 = { - 1: { label: '已保存', color: 'default' }, - 2: { label: '注册中', color: 'processing' }, - 3: { label: '已注册', color: 'success' }, - 4: { label: '本地已保存,声纹同步失败', color: 'error' } +const SPEAKER_STATUS_META: Record = { + 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(null); const [loading, setLoading] = useState(false); const [speakers, setSpeakers] = useState([]); - 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(null); const [userOptions, setUserOptions] = useState([]); const [editingSpeaker, setEditingSpeaker] = useState(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 = () => { 声纹引擎就绪} />} contentClassName="speaker-reg-section-content" > @@ -371,18 +419,15 @@ const SpeakerReg: React.FC = () => { - {editingSpeaker ? '更新声纹档案' : '新建声纹档案'} + {editingSpeaker ? "更新声纹档案" : "新建声纹档案"} } >
- 声纹名称} - rules={[{ required: true, message: '必填' }]} - > + 声纹名称} + rules={[{required: true, message: "必填"}]}> @@ -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, + }))} /> - 备注 (可选)}> + + 备注(可选)}> - 实时录制采集, children: (
@@ -417,36 +466,34 @@ const SpeakerReg: React.FC = () => { 录音文本内容:
{REG_CONTENT}
- +
- +
- {recording ? '正在采集声音...' : '等待录制'} + + {recording ? "正在采集声音..." : "等待录制"} + {seconds}s / {DEFAULT_DURATION}s
- +
- ) + ), }, { - key: "2", + key: "upload", label: 离线文件上传, children: ( @@ -456,8 +503,8 @@ const SpeakerReg: React.FC = () => { 支持 MP3 / WAV / M4A,建议时长 5-15 秒 - ) - } + ), + }, ]} /> @@ -465,7 +512,9 @@ const SpeakerReg: React.FC = () => {
采样文件已就绪 - +
@@ -480,16 +529,18 @@ const SpeakerReg: React.FC = () => { loading={loading} disabled={recording || (!audioBlob && !editingSpeaker)} > - {editingSpeaker ? '确认保存声纹变更' : '提交并同步到声纹库'} + {editingSpeaker ? "确认保存声纹变更" : "保存声纹"} {editingSpeaker && ( - + )}
- 数据将加密存储,仅用于会议期间的发言人识别与角色分离。同租户内声纹名称需保持唯一。 + 数据将加密存储,仅用于会议期间的发言人识别与角色分离。保存成功后如状态仍是“未同步”或“同步失败”,可在列表中点击“同步”按钮补同步。
@@ -500,7 +551,7 @@ const SpeakerReg: React.FC = () => { title={ - 已注册声纹库 + 声纹列表 } extra={ @@ -508,12 +559,12 @@ const SpeakerReg: React.FC = () => { { + 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 = () => { } > - 按名称快速筛选当前声纹记录,支持试听、编辑和删除。 + “已注册”表示已经同步到当前启用的 ASR;仅本地保存未同步时,可手动点击“同步”。 - +
- - {speakers.length === 0 ? ( - {queryName ? '未找到匹配的声纹记录' : '暂无声纹记录'}} - className="speaker-reg-empty" - /> - ) : ( - speakers.map((s) => { - const statusMeta = getSpeakerStatusMeta(s.status); - return ( -
-
-
- {s.name} -
- - {statusMeta.label} - - {s.userId && ID:{s.userId}} + + {speakers.length === 0 ? ( + {queryName ? "未找到匹配的声纹记录" : "暂无声纹记录"}} + className="speaker-reg-empty" + /> + ) : ( + speakers.map((speaker) => { + const statusMeta = getSpeakerStatusMeta(speaker); + const canSync = speaker.syncStatus !== "SYNCED" && syncingSpeakerId !== speaker.id; + const statusTag = ( + + {statusMeta.label} + + ); + return ( +
+
+
+ {speaker.name} +
+ {speaker.syncErrorMessage ? ( + {statusTag} + ) : statusTag} + {speaker.userId && ID:{speaker.userId}} + {speaker.syncStatus !== "SYNCED" && ( + + )} +
+
+ + +
+ + {speaker.remark &&
{speaker.remark}
} + +
- - -
- - {s.remark && ( -
{s.remark}
- )} - -
- ); - }) - )} - + ); + }) + )} +
+