feat: 添加外部总结编排触发和失败处理功能

- 新增 `MeetingSummaryOrchestrationTriggerResultVO` 数据传输对象
- 实现 `MeetingExternalSummaryWebhookTrigger` 服务,用于触发外部 n8n 总结编排
- 在 `MeetingCommandServiceImpl` 中添加 `triggerExternalSummaryOrchestration` 和 `markExternalSummaryOrchestrationFailed` 方法
- 更新 `MeetingCommandService` 接口以支持新的方法
- 在 `AiTaskServiceImpl` 中添加 `triggerExternalSummaryWebhook` 方法
- 在 `MeetingController` 中添加手动触发外部 n8n 总结编排的 API
- 新增 `MeetingMarkdownBundleMcpToolProvider` 以提供会议 Markdown 包工具
dev_na
chenhao 2026-05-12 16:44:09 +08:00
parent 3469884bca
commit 51190f330c
13 changed files with 841 additions and 4 deletions

View File

@ -5,6 +5,7 @@ import com.imeeting.common.RedisKeys;
import com.imeeting.dto.biz.CreateMeetingCommand; import com.imeeting.dto.biz.CreateMeetingCommand;
import com.imeeting.dto.biz.CreateRealtimeMeetingCommand; import com.imeeting.dto.biz.CreateRealtimeMeetingCommand;
import com.imeeting.dto.biz.MeetingResummaryDTO; import com.imeeting.dto.biz.MeetingResummaryDTO;
import com.imeeting.dto.biz.MeetingSummaryOrchestrationTriggerResultVO;
import com.imeeting.dto.biz.MeetingSpeakerUpdateDTO; import com.imeeting.dto.biz.MeetingSpeakerUpdateDTO;
import com.imeeting.dto.biz.MeetingSummaryExportResult; import com.imeeting.dto.biz.MeetingSummaryExportResult;
import com.imeeting.dto.biz.MeetingTranscriptExportResult; import com.imeeting.dto.biz.MeetingTranscriptExportResult;
@ -417,6 +418,17 @@ public class MeetingController {
return ApiResponse.ok(true); return ApiResponse.ok(true);
} }
@Operation(summary = "手动触发外部 n8n 总结编排")
@PostMapping("/{id}/summary/orchestration/trigger")
@PreAuthorize("isAuthenticated()")
public ApiResponse<MeetingSummaryOrchestrationTriggerResultVO> 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 = "重试音频转写") @Operation(summary = "重试音频转写")
@PostMapping("/{id}/transcripts/regenerate") @PostMapping("/{id}/transcripts/regenerate")
@PreAuthorize("isAuthenticated()") @PreAuthorize("isAuthenticated()")

View File

@ -1,6 +1,7 @@
package com.imeeting.controller.biz; package com.imeeting.controller.biz;
import com.imeeting.dto.biz.MeetingSummaryFinalizeDTO; import com.imeeting.dto.biz.MeetingSummaryFinalizeDTO;
import com.imeeting.dto.biz.MeetingExternalWorkflowFailureDTO;
import com.imeeting.dto.biz.MeetingSummaryPromptContextRequestDTO; import com.imeeting.dto.biz.MeetingSummaryPromptContextRequestDTO;
import com.imeeting.dto.biz.MeetingSummaryPromptContextVO; import com.imeeting.dto.biz.MeetingSummaryPromptContextVO;
import com.imeeting.dto.biz.MeetingTranscriptChapterImportDTO; import com.imeeting.dto.biz.MeetingTranscriptChapterImportDTO;
@ -102,6 +103,22 @@ public class MeetingInternalWorkflowController {
return ApiResponse.ok(true); return ApiResponse.ok(true);
} }
@Operation(summary = "回写外部编排失败")
@PostMapping("/{meetingId}/summary/fail")
public ApiResponse<Boolean> 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() { private boolean isExternalModeEnabled() {
return "EXTERNAL_N8N".equalsIgnoreCase(summaryOrchestrationMode); return "EXTERNAL_N8N".equalsIgnoreCase(summaryOrchestrationMode);
} }

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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<String, Object> buildInputSchema() {
Map<String, Object> properties = new LinkedHashMap<>();
properties.put("meetingId", integerProperty("会议 ID"));
return objectSchema(properties, "meetingId");
}
@Override
protected Object handle(Map<String, Object> params) {
Long meetingId = requireLong(params, "meetingId");
LegacyMeetingPreviewResult result = meetingMcpToolService.getMeetingPreview(meetingId);
Map<String, Object> detailData = meetingMcpToolService.getMeetingRichDetail(meetingId);
Map<String, Object> query = new LinkedHashMap<>();
query.put("meetingId", meetingId);
Map<String, Object> 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<String, Object> integerProperty(String description) {
Map<String, Object> property = new LinkedHashMap<>();
property.put("type", "integer");
property.put("description", description);
return property;
}
private Long requireLong(Map<String, Object> 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");
}
}
}

View File

@ -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<String, Object> buildInputSchema() {
Map<String, Object> properties = new LinkedHashMap<>();
properties.put("meetingId", integerProperty("会议 ID"));
return objectSchema(properties, "meetingId");
}
@Override
protected Object handle(Map<String, Object> params) {
Long meetingId = requireLong(params, "meetingId");
Map<String, Object> result = meetingMcpToolService.getMeetingMarkdownBundle(meetingId);
Map<String, Object> query = new LinkedHashMap<>();
query.put("meetingId", meetingId);
Map<String, Object> data = new LinkedHashMap<>();
data.put("code", "200");
data.put("message", "success");
data.put("data", result);
return response(mapOf("tool", getToolName()), query, data);
}
private Map<String, Object> integerProperty(String description) {
Map<String, Object> property = new LinkedHashMap<>();
property.put("type", "integer");
property.put("description", description);
return property;
}
private Long requireLong(Map<String, Object> 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");
}
}
}

View File

@ -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<String, Object> buildInputSchema() {
Map<String, Object> 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<String, Object> 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<String, Object> query = new LinkedHashMap<>();
query.put("page", page);
query.put("pageSize", pageSize);
if (!isBlank(title)) {
query.put("title", title);
}
Map<String, Object> data = new LinkedHashMap<>();
data.put("code", "200");
data.put("message", "success");
data.put("data", result);
return response(mapOf("tool", getToolName()), query, data);
}
private Map<String, Object> integerProperty(String description) {
Map<String, Object> property = new LinkedHashMap<>();
property.put("type", "integer");
property.put("description", description);
return property;
}
private Integer optionalInteger(Map<String, Object> 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");
}
}
}

View File

@ -2,6 +2,8 @@ package com.imeeting.service.biz;
import com.imeeting.dto.biz.CreateMeetingCommand; import com.imeeting.dto.biz.CreateMeetingCommand;
import com.imeeting.dto.biz.CreateRealtimeMeetingCommand; 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.MeetingSummaryFinalizeDTO;
import com.imeeting.dto.biz.MeetingTranscriptChapterImportDTO; import com.imeeting.dto.biz.MeetingTranscriptChapterImportDTO;
import com.imeeting.dto.biz.MeetingTranscriptChapterImportResultVO; import com.imeeting.dto.biz.MeetingTranscriptChapterImportResultVO;
@ -42,4 +44,8 @@ public interface MeetingCommandService {
MeetingTranscriptChapterImportResultVO importTranscriptChapters(MeetingTranscriptChapterImportDTO command); MeetingTranscriptChapterImportResultVO importTranscriptChapters(MeetingTranscriptChapterImportDTO command);
void finalizeSummary(MeetingSummaryFinalizeDTO command); void finalizeSummary(MeetingSummaryFinalizeDTO command);
MeetingSummaryOrchestrationTriggerResultVO triggerExternalSummaryOrchestration(Long meetingId, boolean force);
void markExternalSummaryOrchestrationFailed(MeetingExternalWorkflowFailureDTO command);
} }

View File

@ -67,6 +67,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
private final MeetingTranscriptChapterService meetingTranscriptChapterService; private final MeetingTranscriptChapterService meetingTranscriptChapterService;
private final MeetingSummaryPromptAssembler meetingSummaryPromptAssembler; private final MeetingSummaryPromptAssembler meetingSummaryPromptAssembler;
private final TaskSecurityContextRunner taskSecurityContextRunner; private final TaskSecurityContextRunner taskSecurityContextRunner;
private final MeetingExternalSummaryWebhookTrigger meetingExternalSummaryWebhookTrigger;
@Value("${unisbase.app.server-base-url}") @Value("${unisbase.app.server-base-url}")
private String serverBaseUrl; private String serverBaseUrl;
@ -170,7 +171,9 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
return; return;
} }
if (isExternalSummaryModeEnabled()) { 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; return;
} }
AiTask chapterTask = findLatestTask(meetingId, "CHAPTER"); AiTask chapterTask = findLatestTask(meetingId, "CHAPTER");
@ -642,8 +645,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
private void executeSummaryFlow(Meeting meeting, AiTask sumTask, AiTask chapterTask) throws Exception { private void executeSummaryFlow(Meeting meeting, AiTask sumTask, AiTask chapterTask) throws Exception {
if (isExternalSummaryModeEnabled()) { if (isExternalSummaryModeEnabled()) {
updateMeetingStatus(meeting.getId(), 2); triggerExternalSummaryWebhook(meeting, sumTask, chapterTask, "AUTO_AFTER_TRANSCRIPT_READY", false);
updateProgress(meeting.getId(), 95, "等待外部章节与总结编排...", 0);
return; return;
} }
String summaryLockKey = RedisKeys.meetingSummaryLockKey(meeting.getId()); String summaryLockKey = RedisKeys.meetingSummaryLockKey(meeting.getId());
@ -678,6 +680,30 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
return "EXTERNAL_N8N".equalsIgnoreCase(summaryOrchestrationMode); 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) { private boolean canExecuteTask(AiTask task) {
return task != null return task != null
&& !Integer.valueOf(2).equals(task.getStatus()) && !Integer.valueOf(2).equals(task.getStatus())
@ -701,6 +727,9 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
} }
private void updateProgress(Long meetingId, int percent, String msg, int eta) { private void updateProgress(Long meetingId, int percent, String msg, int eta) {
if (meetingId == null) {
return;
}
try { try {
Map<String, Object> progress = new HashMap<>(); Map<String, Object> progress = new HashMap<>();
progress.put("percent", percent); progress.put("percent", percent);

View File

@ -7,7 +7,9 @@ import com.imeeting.common.MeetingConstants;
import com.imeeting.common.RedisKeys; import com.imeeting.common.RedisKeys;
import com.imeeting.dto.biz.CreateMeetingCommand; import com.imeeting.dto.biz.CreateMeetingCommand;
import com.imeeting.dto.biz.CreateRealtimeMeetingCommand; import com.imeeting.dto.biz.CreateRealtimeMeetingCommand;
import com.imeeting.dto.biz.MeetingExternalWorkflowFailureDTO;
import com.imeeting.dto.biz.MeetingSummaryFinalizeDTO; import com.imeeting.dto.biz.MeetingSummaryFinalizeDTO;
import com.imeeting.dto.biz.MeetingSummaryOrchestrationTriggerResultVO;
import com.imeeting.dto.biz.MeetingTranscriptChapterImportDTO; import com.imeeting.dto.biz.MeetingTranscriptChapterImportDTO;
import com.imeeting.dto.biz.MeetingTranscriptChapterImportResultVO; import com.imeeting.dto.biz.MeetingTranscriptChapterImportResultVO;
import com.imeeting.dto.biz.MeetingVO; import com.imeeting.dto.biz.MeetingVO;
@ -70,6 +72,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
private final RealtimeMeetingAudioStorageService realtimeMeetingAudioStorageService; private final RealtimeMeetingAudioStorageService realtimeMeetingAudioStorageService;
private final StringRedisTemplate redisTemplate; private final StringRedisTemplate redisTemplate;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final MeetingExternalSummaryWebhookTrigger meetingExternalSummaryWebhookTrigger;
@Value("${imeeting.summary-orchestration.mode:INTERNAL_BUILTIN}") @Value("${imeeting.summary-orchestration.mode:INTERNAL_BUILTIN}")
private String summaryOrchestrationMode; private String summaryOrchestrationMode;
@ -706,6 +709,98 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
updateMeetingProgress(meeting.getId(), 100, "外部总结回填完成", 0); 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<AiTask>()
.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<AiTask>()
.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<AiTask>()
.eq(AiTask::getMeetingId, meeting.getId())
.eq(AiTask::getTaskType, "CHAPTER")
.orderByDesc(AiTask::getId)
.last("LIMIT 1"));
}
if (summaryTask == null) {
summaryTask = aiTaskService.getOne(new LambdaQueryWrapper<AiTask>()
.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 @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public void reSummary(Long meetingId, Long summaryModelId, Long chapterModelId, Long promptId, String userPrompt) { 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); 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<String, Object> responseData = task.getResponseData() == null
? new HashMap<>()
: new HashMap<>(task.getResponseData());
Map<String, Object> 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) { private void dispatchSummaryTaskAfterCommit(Long meetingId, Long tenantId, Long userId) {
if (!TransactionSynchronizationManager.isSynchronizationActive()) { if (!TransactionSynchronizationManager.isSynchronizationActive()) {
aiTaskService.dispatchSummaryTask(meetingId, tenantId, userId); aiTaskService.dispatchSummaryTask(meetingId, tenantId, userId);

View File

@ -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<String, Object> payload;
try {
payload = buildPayload(meeting, summaryTask, chapterTask, triggerSource, force);
} catch (Exception ex) {
markTriggerFailed(summaryTask, triggerSource, null, ex.getMessage());
throw ex;
}
Map<String, Object> requestData = copyMap(summaryTask.getRequestData());
requestData.put("externalOrchestrationTriggerPayload", payload);
summaryTask.setRequestData(requestData);
HttpResponse<String> 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<String, Object> buildPayload(Meeting meeting,
AiTask summaryTask,
AiTask chapterTask,
String triggerSource,
boolean force) {
String normalizedBaseUrl = normalizeBaseUrl(serverBaseUrl);
Map<String, Object> 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<String, Object> 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<String, Object> buildModelConfig(AiTask summaryTask) {
Map<String, Object> config = new LinkedHashMap<>();
config.put("chapter", resolveLlmExecutionConfig(longValue(summaryTask, "chapterModelId")));
config.put("summary", resolveLlmExecutionConfig(longValue(summaryTask, "summaryModelId")));
return config;
}
private Map<String, Object> resolveLlmExecutionConfig(Long modelId) {
if (modelId == null) {
return null;
}
AiModelVO model = aiModelService.getModelById(modelId, "LLM");
if (model == null) {
return null;
}
Map<String, Object> 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<String, Object> responseData = copyMap(summaryTask.getResponseData());
Map<String, Object> 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<String, Object> responseData = copyMap(summaryTask.getResponseData());
Map<String, Object> 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<String, Object> copyMap(Map<String, Object> 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);
}
}

View File

@ -87,6 +87,12 @@ unisbase:
imeeting: imeeting:
summary-orchestration: summary-orchestration:
mode: INTERNAL_BUILTIN 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: realtime:
resume-window-minutes: 30 resume-window-minutes: 30
empty-session-retention-minutes: 720 empty-session-retention-minutes: 720

View File

@ -296,7 +296,8 @@ class AiTaskServiceImplTest {
revisionService, revisionService,
chapterService, chapterService,
mock(MeetingSummaryPromptAssembler.class), mock(MeetingSummaryPromptAssembler.class),
taskSecurityContextRunner taskSecurityContextRunner,
mock(MeetingExternalSummaryWebhookTrigger.class)
); );
} }
} }