From 51190f330cd0e56f5e0356ab8dc799e88c1dba74 Mon Sep 17 00:00:00 2001 From: chenhao Date: Tue, 12 May 2026 16:44:09 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=A4=96=E9=83=A8?= =?UTF-8?q?=E6=80=BB=E7=BB=93=E7=BC=96=E6=8E=92=E8=A7=A6=E5=8F=91=E5=92=8C?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E5=A4=84=E7=90=86=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 `MeetingSummaryOrchestrationTriggerResultVO` 数据传输对象 - 实现 `MeetingExternalSummaryWebhookTrigger` 服务,用于触发外部 n8n 总结编排 - 在 `MeetingCommandServiceImpl` 中添加 `triggerExternalSummaryOrchestration` 和 `markExternalSummaryOrchestrationFailed` 方法 - 更新 `MeetingCommandService` 接口以支持新的方法 - 在 `AiTaskServiceImpl` 中添加 `triggerExternalSummaryWebhook` 方法 - 在 `MeetingController` 中添加手动触发外部 n8n 总结编排的 API - 新增 `MeetingMarkdownBundleMcpToolProvider` 以提供会议 Markdown 包工具 --- .../controller/biz/MeetingController.java | 12 + .../MeetingInternalWorkflowController.java | 17 + .../MeetingExternalWorkflowFailureDTO.java | 30 ++ ...ngSummaryOrchestrationTriggerResultVO.java | 33 ++ .../MeetingDetailPreviewMcpToolProvider.java | 72 ++++ .../MeetingMarkdownBundleMcpToolProvider.java | 70 ++++ .../MeetingRecordingListMcpToolProvider.java | 79 ++++ .../service/biz/MeetingCommandService.java | 6 + .../service/biz/impl/AiTaskServiceImpl.java | 35 +- .../biz/impl/MeetingCommandServiceImpl.java | 125 ++++++ .../MeetingExternalSummaryWebhookTrigger.java | 357 ++++++++++++++++++ backend/src/main/resources/application.yml | 6 + .../biz/impl/AiTaskServiceImplTest.java | 3 +- 13 files changed, 841 insertions(+), 4 deletions(-) create mode 100644 backend/src/main/java/com/imeeting/dto/biz/MeetingExternalWorkflowFailureDTO.java create mode 100644 backend/src/main/java/com/imeeting/dto/biz/MeetingSummaryOrchestrationTriggerResultVO.java create mode 100644 backend/src/main/java/com/imeeting/mcp/MeetingDetailPreviewMcpToolProvider.java create mode 100644 backend/src/main/java/com/imeeting/mcp/MeetingMarkdownBundleMcpToolProvider.java create mode 100644 backend/src/main/java/com/imeeting/mcp/MeetingRecordingListMcpToolProvider.java create mode 100644 backend/src/main/java/com/imeeting/service/biz/impl/MeetingExternalSummaryWebhookTrigger.java 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 5edd2f6..5b4ef23 100644 --- a/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java +++ b/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java @@ -5,6 +5,7 @@ import com.imeeting.common.RedisKeys; import com.imeeting.dto.biz.CreateMeetingCommand; import com.imeeting.dto.biz.CreateRealtimeMeetingCommand; import com.imeeting.dto.biz.MeetingResummaryDTO; +import com.imeeting.dto.biz.MeetingSummaryOrchestrationTriggerResultVO; import com.imeeting.dto.biz.MeetingSpeakerUpdateDTO; import com.imeeting.dto.biz.MeetingSummaryExportResult; import com.imeeting.dto.biz.MeetingTranscriptExportResult; @@ -417,6 +418,17 @@ public class MeetingController { return ApiResponse.ok(true); } + @Operation(summary = "手动触发外部 n8n 总结编排") + @PostMapping("/{id}/summary/orchestration/trigger") + @PreAuthorize("isAuthenticated()") + public ApiResponse triggerExternalSummaryOrchestration(@PathVariable Long id, + @RequestParam(defaultValue = "false") boolean force) { + LoginUser loginUser = currentLoginUser(); + Meeting meeting = meetingAccessService.requireMeeting(id); + meetingAccessService.assertCanEditMeeting(meeting, loginUser); + return ApiResponse.ok(meetingCommandService.triggerExternalSummaryOrchestration(id, force)); + } + @Operation(summary = "重试音频转写") @PostMapping("/{id}/transcripts/regenerate") @PreAuthorize("isAuthenticated()") diff --git a/backend/src/main/java/com/imeeting/controller/biz/MeetingInternalWorkflowController.java b/backend/src/main/java/com/imeeting/controller/biz/MeetingInternalWorkflowController.java index fe55eb2..8bf2dde 100644 --- a/backend/src/main/java/com/imeeting/controller/biz/MeetingInternalWorkflowController.java +++ b/backend/src/main/java/com/imeeting/controller/biz/MeetingInternalWorkflowController.java @@ -1,6 +1,7 @@ package com.imeeting.controller.biz; import com.imeeting.dto.biz.MeetingSummaryFinalizeDTO; +import com.imeeting.dto.biz.MeetingExternalWorkflowFailureDTO; import com.imeeting.dto.biz.MeetingSummaryPromptContextRequestDTO; import com.imeeting.dto.biz.MeetingSummaryPromptContextVO; import com.imeeting.dto.biz.MeetingTranscriptChapterImportDTO; @@ -102,6 +103,22 @@ public class MeetingInternalWorkflowController { return ApiResponse.ok(true); } + @Operation(summary = "回写外部编排失败") + @PostMapping("/{meetingId}/summary/fail") + public ApiResponse failSummary(HttpServletRequest request, + @PathVariable Long meetingId, + @Valid @RequestBody MeetingExternalWorkflowFailureDTO command) { + if (!isExternalModeEnabled()) { + return ApiResponse.error("External n8n summary orchestration is disabled"); + } + if (!isInternalSecretValid(request)) { + return ApiResponse.error("Invalid internal secret"); + } + command.setMeetingId(meetingId); + meetingCommandService.markExternalSummaryOrchestrationFailed(command); + return ApiResponse.ok(true); + } + private boolean isExternalModeEnabled() { return "EXTERNAL_N8N".equalsIgnoreCase(summaryOrchestrationMode); } diff --git a/backend/src/main/java/com/imeeting/dto/biz/MeetingExternalWorkflowFailureDTO.java b/backend/src/main/java/com/imeeting/dto/biz/MeetingExternalWorkflowFailureDTO.java new file mode 100644 index 0000000..71fe75a --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/biz/MeetingExternalWorkflowFailureDTO.java @@ -0,0 +1,30 @@ +package com.imeeting.dto.biz; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +@Schema(description = "外部总结编排失败回写请求") +public class MeetingExternalWorkflowFailureDTO { + + @Schema(description = "会议ID") + private Long meetingId; + + @Schema(description = "总结任务ID") + private Long summaryTaskId; + + @Schema(description = "章节任务ID") + private Long chapterTaskId; + + @NotBlank(message = "stage must not be blank") + @Schema(description = "失败阶段,建议值:CHAPTER / SUMMARY / WORKFLOW") + private String stage; + + @NotBlank(message = "errorMessage must not be blank") + @Schema(description = "失败错误信息") + private String errorMessage; + + @Schema(description = "失败详情或原始错误") + private String rawError; +} diff --git a/backend/src/main/java/com/imeeting/dto/biz/MeetingSummaryOrchestrationTriggerResultVO.java b/backend/src/main/java/com/imeeting/dto/biz/MeetingSummaryOrchestrationTriggerResultVO.java new file mode 100644 index 0000000..09378d9 --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/biz/MeetingSummaryOrchestrationTriggerResultVO.java @@ -0,0 +1,33 @@ +package com.imeeting.dto.biz; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "会议外部总结编排触发结果") +public class MeetingSummaryOrchestrationTriggerResultVO { + + @Schema(description = "会议ID") + private Long meetingId; + + @Schema(description = "总结任务ID") + private Long summaryTaskId; + + @Schema(description = "触发来源") + private String triggerSource; + + @Schema(description = "触发状态") + private String status; + + @Schema(description = "是否实际发起了 webhook 调用") + private Boolean triggered; + + @Schema(description = "是否因为已触发而跳过") + private Boolean skipped; + + @Schema(description = "HTTP 状态码") + private Integer httpStatus; + + @Schema(description = "结果说明") + private String message; +} diff --git a/backend/src/main/java/com/imeeting/mcp/MeetingDetailPreviewMcpToolProvider.java b/backend/src/main/java/com/imeeting/mcp/MeetingDetailPreviewMcpToolProvider.java new file mode 100644 index 0000000..de24f06 --- /dev/null +++ b/backend/src/main/java/com/imeeting/mcp/MeetingDetailPreviewMcpToolProvider.java @@ -0,0 +1,72 @@ +package com.imeeting.mcp; + +import com.imeeting.dto.android.legacy.LegacyMeetingPreviewResult; +import com.imeeting.service.mcp.MeetingMcpToolService; +import com.unisbase.llm.tools.support.AbstractMcpToolProvider; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.Map; + +@Component +@RequiredArgsConstructor +public class MeetingDetailPreviewMcpToolProvider extends AbstractMcpToolProvider { + + private final MeetingMcpToolService meetingMcpToolService; + + @Override + protected String getToolName() { + return "imeeting_get_meeting_detail_preview"; + } + + @Override + protected String getToolDescription() { + return "获取当前 bot 用户可见的指定会议详情,返回结构与现有会议预览接口保持一致。"; + } + + @Override + protected Map buildInputSchema() { + Map properties = new LinkedHashMap<>(); + properties.put("meetingId", integerProperty("会议 ID")); + return objectSchema(properties, "meetingId"); + } + + @Override + protected Object handle(Map params) { + Long meetingId = requireLong(params, "meetingId"); + LegacyMeetingPreviewResult result = meetingMcpToolService.getMeetingPreview(meetingId); + Map detailData = meetingMcpToolService.getMeetingRichDetail(meetingId); + + Map query = new LinkedHashMap<>(); + query.put("meetingId", meetingId); + + Map data = new LinkedHashMap<>(); + data.put("code", result.getCode()); + data.put("message", result.getMessage()); + data.put("data", detailData); + return response(mapOf("tool", getToolName()), query, data); + } + + private Map integerProperty(String description) { + Map property = new LinkedHashMap<>(); + property.put("type", "integer"); + property.put("description", description); + return property; + } + + private Long requireLong(Map params, String key) { + Object value = params == null ? null : params.get(key); + if (value == null) { + throw new IllegalArgumentException(key + " is required"); + } + if (value instanceof Number number) { + return number.longValue(); + } + try { + return Long.parseLong(String.valueOf(value).trim()); + } catch (NumberFormatException ex) { + throw new IllegalArgumentException(key + " must be numeric"); + } + } +} diff --git a/backend/src/main/java/com/imeeting/mcp/MeetingMarkdownBundleMcpToolProvider.java b/backend/src/main/java/com/imeeting/mcp/MeetingMarkdownBundleMcpToolProvider.java new file mode 100644 index 0000000..bdf57e3 --- /dev/null +++ b/backend/src/main/java/com/imeeting/mcp/MeetingMarkdownBundleMcpToolProvider.java @@ -0,0 +1,70 @@ +package com.imeeting.mcp; + +import com.imeeting.service.mcp.MeetingMcpToolService; +import com.unisbase.llm.tools.support.AbstractMcpToolProvider; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.Map; + +@Component +@RequiredArgsConstructor +public class MeetingMarkdownBundleMcpToolProvider extends AbstractMcpToolProvider { + + private final MeetingMcpToolService meetingMcpToolService; + + @Override + protected String getToolName() { + return "imeeting_get_meeting_markdown_bundle"; + } + + @Override + protected String getToolDescription() { + return "按会议 ID 直接获取会议总结 Markdown、会议转录 Markdown 和章节 Markdown。"; + } + + @Override + protected Map buildInputSchema() { + Map properties = new LinkedHashMap<>(); + properties.put("meetingId", integerProperty("会议 ID")); + return objectSchema(properties, "meetingId"); + } + + @Override + protected Object handle(Map params) { + Long meetingId = requireLong(params, "meetingId"); + Map result = meetingMcpToolService.getMeetingMarkdownBundle(meetingId); + + Map query = new LinkedHashMap<>(); + query.put("meetingId", meetingId); + + Map data = new LinkedHashMap<>(); + data.put("code", "200"); + data.put("message", "success"); + data.put("data", result); + return response(mapOf("tool", getToolName()), query, data); + } + + private Map integerProperty(String description) { + Map property = new LinkedHashMap<>(); + property.put("type", "integer"); + property.put("description", description); + return property; + } + + private Long requireLong(Map params, String key) { + Object value = params == null ? null : params.get(key); + if (value == null) { + throw new IllegalArgumentException(key + " is required"); + } + if (value instanceof Number number) { + return number.longValue(); + } + try { + return Long.parseLong(String.valueOf(value).trim()); + } catch (NumberFormatException ex) { + throw new IllegalArgumentException(key + " must be numeric"); + } + } +} diff --git a/backend/src/main/java/com/imeeting/mcp/MeetingRecordingListMcpToolProvider.java b/backend/src/main/java/com/imeeting/mcp/MeetingRecordingListMcpToolProvider.java new file mode 100644 index 0000000..ac4a30c --- /dev/null +++ b/backend/src/main/java/com/imeeting/mcp/MeetingRecordingListMcpToolProvider.java @@ -0,0 +1,79 @@ +package com.imeeting.mcp; + +import com.imeeting.dto.android.legacy.LegacyMeetingListResponse; +import com.imeeting.service.mcp.MeetingMcpToolService; +import com.unisbase.llm.tools.support.AbstractMcpToolProvider; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.Map; + +@Component +@RequiredArgsConstructor +public class MeetingRecordingListMcpToolProvider extends AbstractMcpToolProvider { + + private final MeetingMcpToolService meetingMcpToolService; + + @Override + protected String getToolName() { + return "imeeting_list_my_meeting_recordings"; + } + + @Override + protected String getToolDescription() { + return "获取当前 bot 用户可见的个人会议录音列表,返回结构与现有兼容会议列表保持一致。"; + } + + @Override + protected Map buildInputSchema() { + Map properties = new LinkedHashMap<>(); + properties.put("page", integerProperty("页码,从 1 开始,默认 1")); + properties.put("pageSize", integerProperty("每页数量,默认 10")); + properties.put("title", stringProperty("可选,按会议标题模糊过滤")); + return objectSchema(properties); + } + + @Override + protected Object handle(Map params) { + Integer page = optionalInteger(params, "page", 1); + Integer pageSize = optionalInteger(params, "pageSize", 10); + String title = getString(params, "title"); + LegacyMeetingListResponse result = meetingMcpToolService.listCurrentUserMeetings(page, pageSize, title); + + Map query = new LinkedHashMap<>(); + query.put("page", page); + query.put("pageSize", pageSize); + if (!isBlank(title)) { + query.put("title", title); + } + + Map data = new LinkedHashMap<>(); + data.put("code", "200"); + data.put("message", "success"); + data.put("data", result); + return response(mapOf("tool", getToolName()), query, data); + } + + private Map integerProperty(String description) { + Map property = new LinkedHashMap<>(); + property.put("type", "integer"); + property.put("description", description); + return property; + } + + private Integer optionalInteger(Map params, String key, int defaultValue) { + Object value = params == null ? null : params.get(key); + if (value == null) { + return defaultValue; + } + if (value instanceof Number number) { + return number.intValue(); + } + try { + return Integer.parseInt(String.valueOf(value).trim()); + } catch (NumberFormatException ex) { + throw new IllegalArgumentException(key + " must be numeric"); + } + } +} diff --git a/backend/src/main/java/com/imeeting/service/biz/MeetingCommandService.java b/backend/src/main/java/com/imeeting/service/biz/MeetingCommandService.java index ec80036..7e2ab0c 100644 --- a/backend/src/main/java/com/imeeting/service/biz/MeetingCommandService.java +++ b/backend/src/main/java/com/imeeting/service/biz/MeetingCommandService.java @@ -2,6 +2,8 @@ package com.imeeting.service.biz; import com.imeeting.dto.biz.CreateMeetingCommand; import com.imeeting.dto.biz.CreateRealtimeMeetingCommand; +import com.imeeting.dto.biz.MeetingExternalWorkflowFailureDTO; +import com.imeeting.dto.biz.MeetingSummaryOrchestrationTriggerResultVO; import com.imeeting.dto.biz.MeetingSummaryFinalizeDTO; import com.imeeting.dto.biz.MeetingTranscriptChapterImportDTO; import com.imeeting.dto.biz.MeetingTranscriptChapterImportResultVO; @@ -42,4 +44,8 @@ public interface MeetingCommandService { MeetingTranscriptChapterImportResultVO importTranscriptChapters(MeetingTranscriptChapterImportDTO command); void finalizeSummary(MeetingSummaryFinalizeDTO command); + + MeetingSummaryOrchestrationTriggerResultVO triggerExternalSummaryOrchestration(Long meetingId, boolean force); + + void markExternalSummaryOrchestrationFailed(MeetingExternalWorkflowFailureDTO command); } 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 014d560..78fc131 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 @@ -67,6 +67,7 @@ public class AiTaskServiceImpl extends ServiceImpl impleme private final MeetingTranscriptChapterService meetingTranscriptChapterService; private final MeetingSummaryPromptAssembler meetingSummaryPromptAssembler; private final TaskSecurityContextRunner taskSecurityContextRunner; + private final MeetingExternalSummaryWebhookTrigger meetingExternalSummaryWebhookTrigger; @Value("${unisbase.app.server-base-url}") private String serverBaseUrl; @@ -170,7 +171,9 @@ public class AiTaskServiceImpl extends ServiceImpl impleme return; } if (isExternalSummaryModeEnabled()) { - updateProgress(meetingId, 95, "等待外部章节与总结编排...", 0); + AiTask chapterTask = findLatestTask(meetingId, "CHAPTER"); + AiTask sumTask = findLatestTask(meetingId, "SUMMARY"); + triggerExternalSummaryWebhook(meeting, sumTask, chapterTask, "AUTO_SUMMARY_DISPATCH", false); return; } AiTask chapterTask = findLatestTask(meetingId, "CHAPTER"); @@ -642,8 +645,7 @@ public class AiTaskServiceImpl extends ServiceImpl impleme private void executeSummaryFlow(Meeting meeting, AiTask sumTask, AiTask chapterTask) throws Exception { if (isExternalSummaryModeEnabled()) { - updateMeetingStatus(meeting.getId(), 2); - updateProgress(meeting.getId(), 95, "等待外部章节与总结编排...", 0); + triggerExternalSummaryWebhook(meeting, sumTask, chapterTask, "AUTO_AFTER_TRANSCRIPT_READY", false); return; } String summaryLockKey = RedisKeys.meetingSummaryLockKey(meeting.getId()); @@ -678,6 +680,30 @@ public class AiTaskServiceImpl extends ServiceImpl impleme return "EXTERNAL_N8N".equalsIgnoreCase(summaryOrchestrationMode); } + private void triggerExternalSummaryWebhook(Meeting meeting, + AiTask summaryTask, + AiTask chapterTask, + String triggerSource, + boolean force) { + if (meeting == null || meeting.getId() == null) { + return; + } + if (summaryTask == null) { + updateProgress(meeting.getId(), -1, "缺少总结任务,无法触发外部 n8n 编排", 0); + return; + } + updateMeetingStatus(meeting.getId(), 2); + try { + var result = meetingExternalSummaryWebhookTrigger.trigger(meeting, summaryTask, chapterTask, triggerSource, force); + this.updateById(summaryTask); + updateProgress(meeting.getId(), 95, result.getMessage(), 0); + } catch (Exception ex) { + this.updateById(summaryTask); + updateProgress(meeting.getId(), -1, "触发外部 n8n 编排失败: " + ex.getMessage(), 0); + log.error("Failed to trigger external n8n webhook for meeting {}", meeting.getId(), ex); + } + } + private boolean canExecuteTask(AiTask task) { return task != null && !Integer.valueOf(2).equals(task.getStatus()) @@ -701,6 +727,9 @@ public class AiTaskServiceImpl extends ServiceImpl impleme } private void updateProgress(Long meetingId, int percent, String msg, int eta) { + if (meetingId == null) { + return; + } try { Map progress = new HashMap<>(); progress.put("percent", percent); diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingCommandServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingCommandServiceImpl.java index 31251cf..dfeceba 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingCommandServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingCommandServiceImpl.java @@ -7,7 +7,9 @@ import com.imeeting.common.MeetingConstants; import com.imeeting.common.RedisKeys; import com.imeeting.dto.biz.CreateMeetingCommand; import com.imeeting.dto.biz.CreateRealtimeMeetingCommand; +import com.imeeting.dto.biz.MeetingExternalWorkflowFailureDTO; import com.imeeting.dto.biz.MeetingSummaryFinalizeDTO; +import com.imeeting.dto.biz.MeetingSummaryOrchestrationTriggerResultVO; import com.imeeting.dto.biz.MeetingTranscriptChapterImportDTO; import com.imeeting.dto.biz.MeetingTranscriptChapterImportResultVO; import com.imeeting.dto.biz.MeetingVO; @@ -70,6 +72,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { private final RealtimeMeetingAudioStorageService realtimeMeetingAudioStorageService; private final StringRedisTemplate redisTemplate; private final ObjectMapper objectMapper; + private final MeetingExternalSummaryWebhookTrigger meetingExternalSummaryWebhookTrigger; @Value("${imeeting.summary-orchestration.mode:INTERNAL_BUILTIN}") private String summaryOrchestrationMode; @@ -706,6 +709,98 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { updateMeetingProgress(meeting.getId(), 100, "外部总结回填完成", 0); } + @Override + @Transactional(rollbackFor = Exception.class) + public MeetingSummaryOrchestrationTriggerResultVO triggerExternalSummaryOrchestration(Long meetingId, boolean force) { + ensureExternalSummaryModeEnabled(); + Meeting meeting = meetingService.getById(meetingId); + if (meeting == null) { + throw new RuntimeException("会议不存在"); + } + + AiTask summaryTask = aiTaskService.getOne(new LambdaQueryWrapper() + .eq(AiTask::getMeetingId, meetingId) + .eq(AiTask::getTaskType, "SUMMARY") + .orderByDesc(AiTask::getId) + .last("LIMIT 1")); + if (summaryTask == null) { + throw new RuntimeException("缺少可用的总结任务,无法触发外部编排"); + } + AiTask chapterTask = aiTaskService.getOne(new LambdaQueryWrapper() + .eq(AiTask::getMeetingId, meetingId) + .eq(AiTask::getTaskType, "CHAPTER") + .orderByDesc(AiTask::getId) + .last("LIMIT 1")); + + try { + MeetingSummaryOrchestrationTriggerResultVO result = meetingExternalSummaryWebhookTrigger.trigger( + meeting, + summaryTask, + chapterTask, + "MANUAL_API", + force + ); + aiTaskService.updateById(summaryTask); + updateMeetingProgress(meetingId, 95, result.getMessage(), 0); + return result; + } catch (Exception ex) { + aiTaskService.updateById(summaryTask); + updateMeetingProgress(meetingId, -1, "触发外部 n8n 编排失败: " + ex.getMessage(), 0); + throw ex; + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void markExternalSummaryOrchestrationFailed(MeetingExternalWorkflowFailureDTO command) { + ensureExternalSummaryModeEnabled(); + if (command == null || command.getMeetingId() == null) { + throw new RuntimeException("缺少会议ID,无法回写外部编排失败"); + } + String stage = command.getStage() == null ? "WORKFLOW" : command.getStage().trim().toUpperCase(); + String errorMessage = command.getErrorMessage() == null ? "" : command.getErrorMessage().trim(); + if (errorMessage.isEmpty()) { + throw new RuntimeException("缺少失败错误信息,无法回写外部编排失败"); + } + + Meeting meeting = meetingService.getById(command.getMeetingId()); + if (meeting == null) { + throw new RuntimeException("会议不存在"); + } + + AiTask chapterTask = resolveOwnedTask(command.getChapterTaskId(), meeting.getId(), "CHAPTER"); + AiTask summaryTask = resolveOwnedTask(command.getSummaryTaskId(), meeting.getId(), "SUMMARY"); + + if (chapterTask == null) { + chapterTask = aiTaskService.getOne(new LambdaQueryWrapper() + .eq(AiTask::getMeetingId, meeting.getId()) + .eq(AiTask::getTaskType, "CHAPTER") + .orderByDesc(AiTask::getId) + .last("LIMIT 1")); + } + if (summaryTask == null) { + summaryTask = aiTaskService.getOne(new LambdaQueryWrapper() + .eq(AiTask::getMeetingId, meeting.getId()) + .eq(AiTask::getTaskType, "SUMMARY") + .orderByDesc(AiTask::getId) + .last("LIMIT 1")); + } + + if ("CHAPTER".equals(stage)) { + markTaskFailed(chapterTask, "外部章节编排失败: " + errorMessage, command.getRawError()); + markTaskFailed(summaryTask, "外部章节编排失败,无法继续总结: " + errorMessage, command.getRawError()); + } else { + markTaskFailed(summaryTask, "外部总结编排失败: " + errorMessage, command.getRawError()); + } + + if (summaryTask != null) { + meeting.setLatestSummaryTaskId(summaryTask.getId()); + } + meeting.setStatus(4); + meetingService.updateById(meeting); + updateMeetingProgress(meeting.getId(), -1, errorMessage, 0); + } + @Override @Transactional(rollbackFor = Exception.class) public void reSummary(Long meetingId, Long summaryModelId, Long chapterModelId, Long promptId, String userPrompt) { @@ -867,6 +962,36 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { return value == null ? null : String.valueOf(value); } + private AiTask resolveOwnedTask(Long taskId, Long meetingId, String expectedType) { + if (taskId == null) { + return null; + } + AiTask task = aiTaskService.getById(taskId); + if (task == null || !Objects.equals(task.getMeetingId(), meetingId) || !expectedType.equals(task.getTaskType())) { + throw new RuntimeException(expectedType + "任务不存在或不属于当前会议"); + } + return task; + } + + private void markTaskFailed(AiTask task, String message, String rawError) { + if (task == null || Integer.valueOf(2).equals(task.getStatus())) { + return; + } + Map responseData = task.getResponseData() == null + ? new HashMap<>() + : new HashMap<>(task.getResponseData()); + Map failureData = new HashMap<>(); + failureData.put("message", message); + failureData.put("rawError", rawError); + failureData.put("failedAt", java.time.LocalDateTime.now().toString()); + responseData.put("externalWorkflowFailure", failureData); + task.setResponseData(responseData); + task.setStatus(3); + task.setErrorMsg(message); + task.setCompletedAt(java.time.LocalDateTime.now()); + aiTaskService.updateById(task); + } + private void dispatchSummaryTaskAfterCommit(Long meetingId, Long tenantId, Long userId) { if (!TransactionSynchronizationManager.isSynchronizationActive()) { aiTaskService.dispatchSummaryTask(meetingId, tenantId, userId); diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingExternalSummaryWebhookTrigger.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingExternalSummaryWebhookTrigger.java new file mode 100644 index 0000000..c0a814c --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingExternalSummaryWebhookTrigger.java @@ -0,0 +1,357 @@ +package com.imeeting.service.biz.impl; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.imeeting.dto.biz.AiModelVO; +import com.imeeting.dto.biz.MeetingSummaryOrchestrationTriggerResultVO; +import com.imeeting.entity.biz.AiTask; +import com.imeeting.entity.biz.Meeting; +import com.imeeting.service.biz.AiModelService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +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.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.Map; + +@Slf4j +@Component +@RequiredArgsConstructor +public class MeetingExternalSummaryWebhookTrigger { + + private static final String TRIGGER_STATUS_TRIGGERED = "TRIGGERED"; + private static final String TRIGGER_STATUS_SKIPPED = "SKIPPED"; + private static final String TRIGGER_STATUS_FAILED = "FAILED"; + + private final ObjectMapper objectMapper; + private final AiModelService aiModelService; + + @Value("${unisbase.app.server-base-url:}") + private String serverBaseUrl; + + @Value("${unisbase.internal-auth.header-name:X-Internal-Secret}") + private String internalAuthHeaderName; + + @Value("${imeeting.summary-orchestration.external-n8n.webhook-url:}") + private String webhookUrl; + + @Value("${imeeting.summary-orchestration.external-n8n.auth-header-name:}") + private String webhookAuthHeaderName; + + @Value("${imeeting.summary-orchestration.external-n8n.auth-header-value:}") + private String webhookAuthHeaderValue; + + @Value("${imeeting.summary-orchestration.external-n8n.connect-timeout-seconds:10}") + private Integer connectTimeoutSeconds; + + @Value("${imeeting.summary-orchestration.external-n8n.read-timeout-seconds:30}") + private Integer readTimeoutSeconds; + + public MeetingSummaryOrchestrationTriggerResultVO trigger(Meeting meeting, + AiTask summaryTask, + AiTask chapterTask, + String triggerSource, + boolean force) { + if (meeting == null || meeting.getId() == null) { + throw new RuntimeException("缺少会议上下文,无法触发外部总结编排"); + } + if (summaryTask == null || summaryTask.getId() == null || !"SUMMARY".equals(summaryTask.getTaskType())) { + throw new RuntimeException("缺少可用的总结任务,无法触发外部总结编排"); + } + if (webhookUrl == null || webhookUrl.isBlank()) { + markTriggerFailed(summaryTask, triggerSource, null, "未配置 n8n webhook-url"); + throw new RuntimeException("未配置 n8n webhook-url,无法触发外部总结编排"); + } + + if (!force && wasTriggered(summaryTask)) { + return buildResult( + meeting.getId(), + summaryTask.getId(), + triggerSource, + TRIGGER_STATUS_SKIPPED, + false, + true, + extractPreviousHttpStatus(summaryTask), + "当前总结任务已触发过 n8n webhook,已跳过重复触发" + ); + } + + Map payload; + try { + payload = buildPayload(meeting, summaryTask, chapterTask, triggerSource, force); + } catch (Exception ex) { + markTriggerFailed(summaryTask, triggerSource, null, ex.getMessage()); + throw ex; + } + Map requestData = copyMap(summaryTask.getRequestData()); + requestData.put("externalOrchestrationTriggerPayload", payload); + summaryTask.setRequestData(requestData); + + HttpResponse response; + try { + HttpClient client = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(safeSeconds(connectTimeoutSeconds, 10))) + .build(); + HttpRequest.Builder builder = HttpRequest.newBuilder() + .uri(URI.create(webhookUrl.trim())) + .timeout(Duration.ofSeconds(safeSeconds(readTimeoutSeconds, 30))) + .header("Content-Type", "application/json; charset=UTF-8") + .header("Accept", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(payload), StandardCharsets.UTF_8)); + if (webhookAuthHeaderName != null && !webhookAuthHeaderName.isBlank() + && webhookAuthHeaderValue != null && !webhookAuthHeaderValue.isBlank()) { + builder.header(webhookAuthHeaderName.trim(), webhookAuthHeaderValue); + } + response = client.send(builder.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + } catch (Exception ex) { + markTriggerFailed(summaryTask, triggerSource, null, "触发 n8n webhook 异常: " + ex.getMessage()); + throw new RuntimeException("触发 n8n webhook 异常: " + ex.getMessage(), ex); + } + + int httpStatus = response.statusCode(); + if (httpStatus >= 200 && httpStatus < 300) { + markTriggered(summaryTask, triggerSource, force, httpStatus, response.body()); + return buildResult( + meeting.getId(), + summaryTask.getId(), + triggerSource, + TRIGGER_STATUS_TRIGGERED, + true, + false, + httpStatus, + "已触发 n8n webhook" + ); + } + + markTriggerFailed(summaryTask, triggerSource, httpStatus, "触发 n8n webhook 失败: HTTP " + httpStatus); + throw new RuntimeException("触发 n8n webhook 失败: HTTP " + httpStatus + ", body=" + clip(response.body(), 500)); + } + + private Map buildPayload(Meeting meeting, + AiTask summaryTask, + AiTask chapterTask, + String triggerSource, + boolean force) { + String normalizedBaseUrl = normalizeBaseUrl(serverBaseUrl); + Map payload = new LinkedHashMap<>(); + payload.put("meetingId", meeting.getId()); + payload.put("meetingTitle", meeting.getTitle()); + payload.put("meetingType", meeting.getMeetingType()); + payload.put("meetingStatus", meeting.getStatus()); + payload.put("tenantId", meeting.getTenantId()); + payload.put("creatorId", meeting.getCreatorId()); + payload.put("summaryTaskId", summaryTask.getId()); + payload.put("summaryTaskStatus", summaryTask.getStatus()); + payload.put("chapterTaskId", chapterTask == null ? null : chapterTask.getId()); + payload.put("chapterTaskStatus", chapterTask == null ? null : chapterTask.getStatus()); + payload.put("triggerSource", triggerSource); + payload.put("force", force); + payload.put("triggeredAt", LocalDateTime.now().toString()); + payload.put("summaryOrchestrationMode", "EXTERNAL_N8N"); + payload.put("summaryTaskConfig", copyMap(summaryTask.getTaskConfig())); + payload.put("modelConfig", buildModelConfig(summaryTask)); + + Map internalApi = new LinkedHashMap<>(); + internalApi.put("baseUrl", normalizedBaseUrl); + internalApi.put("internalAuthHeaderName", internalAuthHeaderName); + internalApi.put("transcriptSourceUrl", normalizedBaseUrl + "/sys/internal/meetings/" + meeting.getId() + "/transcript-source"); + internalApi.put("chaptersImportUrl", normalizedBaseUrl + "/sys/internal/meetings/" + meeting.getId() + "/chapters/import"); + internalApi.put("summaryPromptContextUrl", normalizedBaseUrl + "/sys/internal/meetings/" + meeting.getId() + "/summary-prompt-context"); + internalApi.put("summaryFinalizeUrl", normalizedBaseUrl + "/sys/internal/meetings/" + meeting.getId() + "/summary/finalize"); + internalApi.put("summaryFailUrl", normalizedBaseUrl + "/sys/internal/meetings/" + meeting.getId() + "/summary/fail"); + payload.put("internalApi", internalApi); + return payload; + } + + private Map buildModelConfig(AiTask summaryTask) { + Map config = new LinkedHashMap<>(); + config.put("chapter", resolveLlmExecutionConfig(longValue(summaryTask, "chapterModelId"))); + config.put("summary", resolveLlmExecutionConfig(longValue(summaryTask, "summaryModelId"))); + return config; + } + + private Map resolveLlmExecutionConfig(Long modelId) { + if (modelId == null) { + return null; + } + AiModelVO model = aiModelService.getModelById(modelId, "LLM"); + if (model == null) { + return null; + } + Map config = new LinkedHashMap<>(); + config.put("id", model.getId()); + config.put("provider", model.getProvider()); + config.put("baseUrl", model.getBaseUrl()); + config.put("apiPath", model.getApiPath()); + config.put("resolvedUrl", appendPath(model.getBaseUrl(), + model.getApiPath() == null || model.getApiPath().isBlank() + ? "v1/chat/completions" + : model.getApiPath())); + config.put("apiKey", model.getApiKey()); + config.put("authHeaderName", "Authorization"); + config.put("authHeaderValue", buildBearer(model.getApiKey())); + config.put("modelCode", model.getModelCode()); + config.put("temperature", model.getTemperature()); + config.put("topP", model.getTopP()); + return config; + } + + private void markTriggered(AiTask summaryTask, + String triggerSource, + boolean force, + Integer httpStatus, + String responseBody) { + Map responseData = copyMap(summaryTask.getResponseData()); + Map triggerState = new LinkedHashMap<>(); + triggerState.put("status", TRIGGER_STATUS_TRIGGERED); + triggerState.put("triggerSource", triggerSource); + triggerState.put("triggeredAt", LocalDateTime.now().toString()); + triggerState.put("force", force); + triggerState.put("httpStatus", httpStatus); + triggerState.put("responsePreview", clip(responseBody, 1000)); + responseData.put("externalOrchestrationTrigger", triggerState); + summaryTask.setResponseData(responseData); + summaryTask.setErrorMsg(null); + } + + private void markTriggerFailed(AiTask summaryTask, + String triggerSource, + Integer httpStatus, + String message) { + if (summaryTask == null) { + return; + } + Map responseData = copyMap(summaryTask.getResponseData()); + Map triggerState = new LinkedHashMap<>(); + triggerState.put("status", TRIGGER_STATUS_FAILED); + triggerState.put("triggerSource", triggerSource); + triggerState.put("triggeredAt", LocalDateTime.now().toString()); + triggerState.put("httpStatus", httpStatus); + triggerState.put("message", message); + responseData.put("externalOrchestrationTrigger", triggerState); + summaryTask.setResponseData(responseData); + summaryTask.setErrorMsg(message); + } + + private boolean wasTriggered(AiTask summaryTask) { + if (summaryTask == null || summaryTask.getResponseData() == null) { + return false; + } + Object triggerState = summaryTask.getResponseData().get("externalOrchestrationTrigger"); + if (!(triggerState instanceof Map map)) { + return false; + } + Object status = map.get("status"); + return TRIGGER_STATUS_TRIGGERED.equals(String.valueOf(status)); + } + + private Integer extractPreviousHttpStatus(AiTask summaryTask) { + if (summaryTask == null || summaryTask.getResponseData() == null) { + return null; + } + Object triggerState = summaryTask.getResponseData().get("externalOrchestrationTrigger"); + if (!(triggerState instanceof Map map)) { + return null; + } + Object status = map.get("httpStatus"); + if (status == null) { + return null; + } + try { + return Integer.parseInt(String.valueOf(status)); + } catch (Exception ex) { + return null; + } + } + + private MeetingSummaryOrchestrationTriggerResultVO buildResult(Long meetingId, + Long summaryTaskId, + String triggerSource, + String status, + boolean triggered, + boolean skipped, + Integer httpStatus, + String message) { + MeetingSummaryOrchestrationTriggerResultVO result = new MeetingSummaryOrchestrationTriggerResultVO(); + result.setMeetingId(meetingId); + result.setSummaryTaskId(summaryTaskId); + result.setTriggerSource(triggerSource); + result.setStatus(status); + result.setTriggered(triggered); + result.setSkipped(skipped); + result.setHttpStatus(httpStatus); + result.setMessage(message); + return result; + } + + private Map copyMap(Map source) { + return source == null ? new LinkedHashMap<>() : new LinkedHashMap<>(source); + } + + private String normalizeBaseUrl(String raw) { + if (raw == null || raw.isBlank()) { + throw new RuntimeException("未配置 unisbase.app.server-base-url,无法生成 n8n 回调地址"); + } + String normalized = raw.trim(); + while (normalized.endsWith("/")) { + normalized = normalized.substring(0, normalized.length() - 1); + } + return normalized; + } + + private Long longValue(AiTask summaryTask, String key) { + if (summaryTask == null || summaryTask.getTaskConfig() == null || key == null) { + return null; + } + Object value = summaryTask.getTaskConfig().get(key); + if (value == null) { + return null; + } + try { + return Long.parseLong(String.valueOf(value).trim()); + } catch (Exception ex) { + return null; + } + } + + private String appendPath(String baseUrl, String path) { + String normalizedBaseUrl = normalizeBaseUrl(baseUrl); + String normalizedPath = path == null ? "" : path.trim(); + while (normalizedPath.startsWith("/")) { + normalizedPath = normalizedPath.substring(1); + } + if (normalizedPath.isEmpty()) { + return normalizedBaseUrl; + } + return normalizedBaseUrl + "/" + normalizedPath; + } + + private String buildBearer(String apiKey) { + if (apiKey == null || apiKey.isBlank()) { + return ""; + } + return apiKey.startsWith("Bearer ") ? apiKey : "Bearer " + apiKey; + } + + private int safeSeconds(Integer value, int fallback) { + return value == null || value <= 0 ? fallback : value; + } + + private String clip(String value, int maxLength) { + if (value == null) { + return null; + } + String normalized = value.trim(); + if (normalized.length() <= maxLength) { + return normalized; + } + return normalized.substring(0, maxLength); + } +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 320bec1..6019711 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -87,6 +87,12 @@ unisbase: imeeting: summary-orchestration: mode: INTERNAL_BUILTIN + external-n8n: + webhook-url: ${IMEETING_EXTERNAL_N8N_WEBHOOK_URL:https://n8n.oa.unissense.tech/webhook/imeeting-summary-external-template} + auth-header-name: ${IMEETING_EXTERNAL_N8N_AUTH_HEADER_NAME:test} + auth-header-value: ${IMEETING_EXTERNAL_N8N_AUTH_HEADER_VALUE:123456} + connect-timeout-seconds: ${IMEETING_EXTERNAL_N8N_CONNECT_TIMEOUT_SECONDS:10} + read-timeout-seconds: ${IMEETING_EXTERNAL_N8N_READ_TIMEOUT_SECONDS:1200} realtime: resume-window-minutes: 30 empty-session-retention-minutes: 720 diff --git a/backend/src/test/java/com/imeeting/service/biz/impl/AiTaskServiceImplTest.java b/backend/src/test/java/com/imeeting/service/biz/impl/AiTaskServiceImplTest.java index 394c404..fe99398 100644 --- a/backend/src/test/java/com/imeeting/service/biz/impl/AiTaskServiceImplTest.java +++ b/backend/src/test/java/com/imeeting/service/biz/impl/AiTaskServiceImplTest.java @@ -296,7 +296,8 @@ class AiTaskServiceImplTest { revisionService, chapterService, mock(MeetingSummaryPromptAssembler.class), - taskSecurityContextRunner + taskSecurityContextRunner, + mock(MeetingExternalSummaryWebhookTrigger.class) ); } }