From a6c36922611d08185f8f745a08fa434901610e3f Mon Sep 17 00:00:00 2001 From: tanglong <842690096@qq.com> Date: Tue, 28 Jul 2026 16:15:14 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B6=88=E6=81=AF=E9=A1=BA=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/semantic-routing.md | 1 + .../controller/WecomCallbackController.java | 50 ++++++++++++-- .../semantic/SemanticDispatchResult.java | 12 ++++ .../semantic/SemanticDispatchService.java | 13 +++- .../semantic/SemanticReplyAttachment.java | 15 +++++ .../semantic/SemanticRequestContext.java | 14 +++- .../EcCallComplianceReportIntentHandler.java | 15 ++--- .../WecomCallbackControllerTest.java | 66 +++++++++++++++++++ .../semantic/SemanticDispatchServiceTest.java | 25 +++++++ 9 files changed, 190 insertions(+), 21 deletions(-) create mode 100644 src/main/java/com/company/agentfoundation/semantic/SemanticDispatchResult.java create mode 100644 src/main/java/com/company/agentfoundation/semantic/SemanticReplyAttachment.java create mode 100644 src/test/java/com/company/agentfoundation/integration/wecom/controller/WecomCallbackControllerTest.java diff --git a/docs/semantic-routing.md b/docs/semantic-routing.md index e061504..f4a3946 100644 --- a/docs/semantic-routing.md +++ b/docs/semantic-routing.md @@ -9,6 +9,7 @@ 3. 后端根据意图选择预注册的白名单处理器。 4. 处理器根据企业微信 UserID 调用对应业务接口。 5. 后端将结构化结果格式化成企业微信文本并发送。 +6. 主消息发送成功后,再按登记顺序发送 Excel 等附件;附件不会先于业务回复出现。 模型不能输出或调用任意 URL,也不能传入校区、顾问或权限范围。业务接口仍会根据企业微信 UserID 做最终权限校验。 diff --git a/src/main/java/com/company/agentfoundation/integration/wecom/controller/WecomCallbackController.java b/src/main/java/com/company/agentfoundation/integration/wecom/controller/WecomCallbackController.java index 2d6aaf8..cd3ad6f 100644 --- a/src/main/java/com/company/agentfoundation/integration/wecom/controller/WecomCallbackController.java +++ b/src/main/java/com/company/agentfoundation/integration/wecom/controller/WecomCallbackController.java @@ -5,7 +5,9 @@ import com.company.agentfoundation.integration.wecom.client.WecomClient; import com.company.agentfoundation.integration.wecom.model.WecomEncryptedEnvelope; import com.company.agentfoundation.integration.wecom.model.WecomIncomingMessage; import com.company.agentfoundation.integration.wecom.service.WecomCryptoService; +import com.company.agentfoundation.semantic.SemanticDispatchResult; import com.company.agentfoundation.semantic.SemanticDispatchService; +import com.company.agentfoundation.semantic.SemanticReplyAttachment; import com.fasterxml.jackson.dataformat.xml.XmlMapper; import org.slf4j.Logger; @@ -20,6 +22,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.time.Instant; +import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executor; @@ -193,16 +196,19 @@ public class WecomCallbackController { String reportTaskKey ) { String reply; + List attachments = List.of(); try { - reply = callbackMode == CallbackMode.GENERAL_CHAT - ? cozeWorkflowService.chatDefault(userText) - : semanticDispatchService.dispatch(fromUser, userText); + if (callbackMode == CallbackMode.GENERAL_CHAT) { + reply = cozeWorkflowService.chatDefault(userText); + } else { + SemanticDispatchResult result = semanticDispatchService.dispatchWithAttachments(fromUser, userText); + reply = result.reply(); + attachments = result.attachments(); + } } catch (Exception e) { log.error("wecom callback processing failed mode={} fromUser={} err={}", callbackMode, fromUser, e.getMessage(), e); reply = errorReply(callbackMode); - } finally { - if (reportTaskKey != null) runningReports.remove(reportTaskKey); } if (reply == null || reply.isBlank()) { log.warn("wecom callback reply blank mode={}, fallback to error reply", callbackMode); @@ -211,7 +217,7 @@ public class WecomCallbackController { log.info("wecom callback sending reply mode={} toUser={} replyPreview={}", callbackMode, fromUser, preview(reply)); try { if (callbackMode == CallbackMode.SEMANTIC_ROUTING) { - wecomClient.sendMarkdown(appKey, fromUser, reply); + sendSemanticReply(appKey, fromUser, reply, attachments); } else { wecomClient.sendText(appKey, fromUser, reply); } @@ -219,6 +225,38 @@ public class WecomCallbackController { } catch (Exception e) { log.error("wecom callback reply send failed mode={} toUser={} err={}", callbackMode, fromUser, e.getMessage(), e); + } finally { + if (reportTaskKey != null) runningReports.remove(reportTaskKey); + } + } + + void sendSemanticReply( + String appKey, + String userId, + String reply, + List attachments + ) { + wecomClient.sendMarkdown(appKey, userId, reply); + sendReplyAttachments(appKey, userId, attachments); + } + + private void sendReplyAttachments( + String appKey, + String userId, + List attachments + ) { + for (SemanticReplyAttachment attachment : attachments) { + try { + switch (attachment.type()) { + case EXCEL -> wecomClient.sendExcelFile( + appKey, userId, attachment.fileName(), attachment.content()); + } + log.info("wecom callback attachment sent appKey={} toUser={} type={} fileName={}", + appKey, userId, attachment.type(), attachment.fileName()); + } catch (Exception exception) { + log.error("wecom callback attachment send failed appKey={} toUser={} type={} fileName={} err={}", + appKey, userId, attachment.type(), attachment.fileName(), exception.getMessage(), exception); + } } } diff --git a/src/main/java/com/company/agentfoundation/semantic/SemanticDispatchResult.java b/src/main/java/com/company/agentfoundation/semantic/SemanticDispatchResult.java new file mode 100644 index 0000000..2b37289 --- /dev/null +++ b/src/main/java/com/company/agentfoundation/semantic/SemanticDispatchResult.java @@ -0,0 +1,12 @@ +package com.company.agentfoundation.semantic; + +import java.util.List; + +public record SemanticDispatchResult( + String reply, + List attachments +) { + public SemanticDispatchResult { + attachments = attachments == null ? List.of() : List.copyOf(attachments); + } +} diff --git a/src/main/java/com/company/agentfoundation/semantic/SemanticDispatchService.java b/src/main/java/com/company/agentfoundation/semantic/SemanticDispatchService.java index 98feefb..e1e9f14 100644 --- a/src/main/java/com/company/agentfoundation/semantic/SemanticDispatchService.java +++ b/src/main/java/com/company/agentfoundation/semantic/SemanticDispatchService.java @@ -7,6 +7,7 @@ import org.springframework.stereotype.Service; import java.time.ZoneId; import java.time.ZonedDateTime; +import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -38,6 +39,10 @@ public class SemanticDispatchService { } public String dispatch(String wecomUserId, String userText) { + return dispatchWithAttachments(wecomUserId, userText).reply(); + } + + public SemanticDispatchResult dispatchWithAttachments(String wecomUserId, String userText) { ZonedDateTime now = ZonedDateTime.now(BUSINESS_ZONE); SemanticRecognition recognition = recognitionService.recognize(userText, now.toLocalDate()); log.info("semantic dispatch recognized wecomUserId={} intent={} confidence={} source={}", @@ -45,9 +50,13 @@ public class SemanticDispatchService { SemanticIntentHandler handler = handlers.get(recognition.intent()); if (handler == null || recognition.confidence() < properties.getSemantic().getMinimumConfidence()) { - return unsupportedReply(); + return new SemanticDispatchResult(unsupportedReply(), List.of()); } - return handler.handle(new SemanticRequestContext(wecomUserId, userText, now), recognition); + List attachments = new ArrayList<>(); + String reply = handler.handle( + new SemanticRequestContext(wecomUserId, userText, now, attachments::add), + recognition); + return new SemanticDispatchResult(reply, attachments); } private String unsupportedReply() { diff --git a/src/main/java/com/company/agentfoundation/semantic/SemanticReplyAttachment.java b/src/main/java/com/company/agentfoundation/semantic/SemanticReplyAttachment.java new file mode 100644 index 0000000..2b55815 --- /dev/null +++ b/src/main/java/com/company/agentfoundation/semantic/SemanticReplyAttachment.java @@ -0,0 +1,15 @@ +package com.company.agentfoundation.semantic; + +public record SemanticReplyAttachment( + Type type, + String fileName, + byte[] content +) { + public static SemanticReplyAttachment excel(String fileName, byte[] content) { + return new SemanticReplyAttachment(Type.EXCEL, fileName, content); + } + + public enum Type { + EXCEL + } +} diff --git a/src/main/java/com/company/agentfoundation/semantic/SemanticRequestContext.java b/src/main/java/com/company/agentfoundation/semantic/SemanticRequestContext.java index c5680ed..7acd6c7 100644 --- a/src/main/java/com/company/agentfoundation/semantic/SemanticRequestContext.java +++ b/src/main/java/com/company/agentfoundation/semantic/SemanticRequestContext.java @@ -1,9 +1,19 @@ package com.company.agentfoundation.semantic; import java.time.ZonedDateTime; +import java.util.function.Consumer; public record SemanticRequestContext( String wecomUserId, String userText, - ZonedDateTime requestTime -) {} + ZonedDateTime requestTime, + Consumer attachmentSink +) { + public SemanticRequestContext(String wecomUserId, String userText, ZonedDateTime requestTime) { + this(wecomUserId, userText, requestTime, ignored -> {}); + } + + public void deferExcel(String fileName, byte[] content) { + attachmentSink.accept(SemanticReplyAttachment.excel(fileName, content)); + } +} diff --git a/src/main/java/com/company/agentfoundation/semantic/handler/EcCallComplianceReportIntentHandler.java b/src/main/java/com/company/agentfoundation/semantic/handler/EcCallComplianceReportIntentHandler.java index 29fa0dc..67bd74c 100644 --- a/src/main/java/com/company/agentfoundation/semantic/handler/EcCallComplianceReportIntentHandler.java +++ b/src/main/java/com/company/agentfoundation/semantic/handler/EcCallComplianceReportIntentHandler.java @@ -2,7 +2,6 @@ package com.company.agentfoundation.semantic.handler; import com.company.agentfoundation.ec.report.EcCallComplianceReportRequest; import com.company.agentfoundation.ec.report.EcCallComplianceReportService; -import com.company.agentfoundation.integration.wecom.client.WecomClient; import com.company.agentfoundation.report.ExcelReportService; import com.company.agentfoundation.semantic.SemanticDateRangeResolver; import com.company.agentfoundation.semantic.SemanticIntentHandler; @@ -20,26 +19,21 @@ import java.util.Map; @Component public class EcCallComplianceReportIntentHandler implements SemanticIntentHandler { private static final Logger log = LoggerFactory.getLogger(EcCallComplianceReportIntentHandler.class); - private static final String SPORTS_VENUE_APP = "sports-venue"; - private final SemanticDateRangeResolver dateRangeResolver; private final EcCallComplianceReportService reportService; private final ObjectMapper objectMapper; private final ExcelReportService excelReportService; - private final WecomClient wecomClient; public EcCallComplianceReportIntentHandler( SemanticDateRangeResolver dateRangeResolver, EcCallComplianceReportService reportService, ObjectMapper objectMapper, - ExcelReportService excelReportService, - WecomClient wecomClient + ExcelReportService excelReportService ) { this.dateRangeResolver = dateRangeResolver; this.reportService = reportService; this.objectMapper = objectMapper; this.excelReportService = excelReportService; - this.wecomClient = wecomClient; } @Override @@ -67,12 +61,11 @@ public class EcCallComplianceReportIntentHandler implements SemanticIntentHandle Map reportData = reportService.generate( new EcCallComplianceReportRequest(range.startDate(), range.endDate(), context.wecomUserId())); JsonNode report = objectMapper.valueToTree(reportData); - wecomClient.sendExcelFile( - SPORTS_VENUE_APP, - context.wecomUserId(), + String reply = format(report, range); + context.deferExcel( fileName(range), excelReportService.ecCallCompliance(reportData)); - return format(report, range); + return reply; } catch (ResponseStatusException exception) { if (exception.getStatusCode().value() == 403) { return "无权限查看:你的企业微信账号不在可查询的组织范围内。UserID:" + context.wecomUserId(); diff --git a/src/test/java/com/company/agentfoundation/integration/wecom/controller/WecomCallbackControllerTest.java b/src/test/java/com/company/agentfoundation/integration/wecom/controller/WecomCallbackControllerTest.java new file mode 100644 index 0000000..86a7b83 --- /dev/null +++ b/src/test/java/com/company/agentfoundation/integration/wecom/controller/WecomCallbackControllerTest.java @@ -0,0 +1,66 @@ +package com.company.agentfoundation.integration.wecom.controller; + +import com.company.agentfoundation.integration.coze.service.CozeWorkflowService; +import com.company.agentfoundation.integration.wecom.client.WecomClient; +import com.company.agentfoundation.integration.wecom.service.WecomCryptoService; +import com.company.agentfoundation.semantic.SemanticDispatchService; +import com.company.agentfoundation.semantic.SemanticReplyAttachment; +import org.junit.jupiter.api.Test; +import org.mockito.InOrder; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +class WecomCallbackControllerTest { + @Test + void sendsMainReplyBeforeExcelAttachment() { + WecomClient wecom = mock(WecomClient.class); + WecomCallbackController controller = controller(wecom); + byte[] content = {1, 2, 3}; + + controller.sendSemanticReply( + "sports-venue", + "YiNianYiQingJing", + "EC日报消息", + List.of(SemanticReplyAttachment.excel("EC日报.xlsx", content))); + + InOrder order = inOrder(wecom); + order.verify(wecom).sendMarkdown("sports-venue", "YiNianYiQingJing", "EC日报消息"); + order.verify(wecom).sendExcelFile("sports-venue", "YiNianYiQingJing", "EC日报.xlsx", content); + } + + @Test + void doesNotSendAttachmentWhenMainReplyFails() { + WecomClient wecom = mock(WecomClient.class); + WecomCallbackController controller = controller(wecom); + doThrow(new IllegalStateException("send failed")) + .when(wecom).sendMarkdown("sports-venue", "YiNianYiQingJing", "EC日报消息"); + + assertThrows(IllegalStateException.class, () -> controller.sendSemanticReply( + "sports-venue", + "YiNianYiQingJing", + "EC日报消息", + List.of(SemanticReplyAttachment.excel("EC日报.xlsx", new byte[]{1})))); + + verify(wecom, never()).sendExcelFile( + org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.any(byte[].class)); + } + + private WecomCallbackController controller(WecomClient wecom) { + return new WecomCallbackController( + mock(SemanticDispatchService.class), + mock(CozeWorkflowService.class), + wecom, + mock(WecomCryptoService.class), + Runnable::run); + } +} diff --git a/src/test/java/com/company/agentfoundation/semantic/SemanticDispatchServiceTest.java b/src/test/java/com/company/agentfoundation/semantic/SemanticDispatchServiceTest.java index 3261165..153a95d 100644 --- a/src/test/java/com/company/agentfoundation/semantic/SemanticDispatchServiceTest.java +++ b/src/test/java/com/company/agentfoundation/semantic/SemanticDispatchServiceTest.java @@ -8,6 +8,7 @@ import java.util.List; import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; @@ -30,6 +31,30 @@ class SemanticDispatchServiceTest { assertEquals("日报结果", service.dispatch("DongPoLi", "查看今日客资管理日报")); } + @Test + void collectsAttachmentsForDeliveryAfterTheMainReply() { + SemanticRecognitionService recognitionService = mock(SemanticRecognitionService.class); + SemanticIntentHandler handler = mock(SemanticIntentHandler.class); + when(handler.intent()).thenReturn("EC_CALL_COMPLIANCE_DAILY_REPORT"); + when(handler.handle(any(), any())).thenAnswer(invocation -> { + SemanticRequestContext context = invocation.getArgument(0); + context.deferExcel("EC日报.xlsx", new byte[]{1, 2, 3}); + return "EC日报消息"; + }); + when(recognitionService.recognize(eq("查看EC日报"), any(LocalDate.class))) + .thenReturn(new SemanticRecognition( + "EC_CALL_COMPLIANCE_DAILY_REPORT", 0.95D, Map.of(), "TEST")); + SemanticDispatchService service = new SemanticDispatchService( + recognitionService, new FoundationProperties(), List.of(handler)); + + SemanticDispatchResult result = service.dispatchWithAttachments("DongPoLi", "查看EC日报"); + + assertEquals("EC日报消息", result.reply()); + assertEquals(1, result.attachments().size()); + assertEquals("EC日报.xlsx", result.attachments().get(0).fileName()); + assertArrayEquals(new byte[]{1, 2, 3}, result.attachments().get(0).content()); + } + @Test void rejectsDuplicateIntentHandlersAtStartup() { SemanticIntentHandler first = mock(SemanticIntentHandler.class);