From 31a1d0bfbe0acac50cfb7602aba9a4daa6493202 Mon Sep 17 00:00:00 2001 From: yangyufan <936930947@qq.com> Date: Tue, 21 Apr 2026 17:34:34 +0800 Subject: [PATCH 1/3] feat: add notification system with DingTalk support - Add NotificationInfo and NotifierService interface - Implement DingTalkNotifier with HmacSHA256 signature - Add NotifierFactory for extensible notification channels - Integrate with GraphServiceImpl to notify on completion/error - Add unit tests for notification components --- .../config/DataAgentConfiguration.java | 3 +- .../properties/NotifyProperties.java | 75 ++++ .../service/graph/GraphServiceImpl.java | 23 +- .../service/notify/NotificationInfo.java | 82 ++++ .../service/notify/NotifierFactory.java | 55 +++ .../service/notify/NotifierService.java | 23 ++ .../service/notify/impl/DingTalkNotifier.java | 119 ++++++ .../src/main/resources/application.yml | 10 +- .../service/graph/GraphServiceImplTest.java | 6 +- .../service/notify/NotificationInfoTest.java | 66 +++ .../notify/impl/DingTalkNotifierTest.java | 65 +++ .../2026-04-21-notification-implementation.md | 386 ++++++++++++++++++ .../specs/2026-04-21-notification-design.md | 116 ++++++ 13 files changed, 1023 insertions(+), 6 deletions(-) create mode 100644 data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/properties/NotifyProperties.java create mode 100644 data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/notify/NotificationInfo.java create mode 100644 data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/notify/NotifierFactory.java create mode 100644 data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/notify/NotifierService.java create mode 100644 data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/notify/impl/DingTalkNotifier.java create mode 100644 data-agent-management/src/test/java/com/alibaba/cloud/ai/dataagent/service/notify/NotificationInfoTest.java create mode 100644 data-agent-management/src/test/java/com/alibaba/cloud/ai/dataagent/service/notify/impl/DingTalkNotifierTest.java create mode 100644 docs/superpowers/plans/2026-04-21-notification-implementation.md create mode 100644 docs/superpowers/specs/2026-04-21-notification-design.md diff --git a/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/config/DataAgentConfiguration.java b/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/config/DataAgentConfiguration.java index 3aab99704..2969baecb 100644 --- a/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/config/DataAgentConfiguration.java +++ b/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/config/DataAgentConfiguration.java @@ -18,6 +18,7 @@ import com.alibaba.cloud.ai.dataagent.properties.CodeExecutorProperties; import com.alibaba.cloud.ai.dataagent.properties.DataAgentProperties; import com.alibaba.cloud.ai.dataagent.properties.FileStorageProperties; +import com.alibaba.cloud.ai.dataagent.properties.NotifyProperties; import com.alibaba.cloud.ai.dataagent.service.vectorstore.SimpleVectorStoreInitialization; import com.alibaba.cloud.ai.dataagent.splitter.SentenceSplitter; import com.alibaba.cloud.ai.transformer.splitter.RecursiveCharacterTextSplitter; @@ -86,7 +87,7 @@ @Slf4j @Configuration @EnableAsync -@EnableConfigurationProperties({ CodeExecutorProperties.class, DataAgentProperties.class, FileStorageProperties.class }) +@EnableConfigurationProperties({ CodeExecutorProperties.class, DataAgentProperties.class, FileStorageProperties.class, NotifyProperties.class }) public class DataAgentConfiguration implements DisposableBean { /** diff --git a/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/properties/NotifyProperties.java b/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/properties/NotifyProperties.java new file mode 100644 index 000000000..d6297a6d3 --- /dev/null +++ b/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/properties/NotifyProperties.java @@ -0,0 +1,75 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.alibaba.cloud.ai.dataagent.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "spring.ai.alibaba.data-agent.notify") +public class NotifyProperties { + + private boolean enabled = false; + + private String channel = "dingtalk"; + + private DingTalkConfig dingtalk = new DingTalkConfig(); + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getChannel() { + return channel; + } + + public void setChannel(String channel) { + this.channel = channel; + } + + public DingTalkConfig getDingtalk() { + return dingtalk; + } + + public void setDingtalk(DingTalkConfig dingtalk) { + this.dingtalk = dingtalk; + } + + public static class DingTalkConfig { + + private String webhookUrl; + + private String secretKey; + + public String getWebhookUrl() { + return webhookUrl; + } + + public void setWebhookUrl(String webhookUrl) { + this.webhookUrl = webhookUrl; + } + + public String getSecretKey() { + return secretKey; + } + + public void setSecretKey(String secretKey) { + this.secretKey = secretKey; + } + } +} diff --git a/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/graph/GraphServiceImpl.java b/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/graph/GraphServiceImpl.java index 6325b9bbf..39af06db4 100644 --- a/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/graph/GraphServiceImpl.java +++ b/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/graph/GraphServiceImpl.java @@ -21,6 +21,9 @@ import com.alibaba.cloud.ai.dataagent.dto.GraphRequest; import com.alibaba.cloud.ai.dataagent.service.graph.Context.MultiTurnContextManager; import com.alibaba.cloud.ai.dataagent.service.graph.Context.StreamContext; +import com.alibaba.cloud.ai.dataagent.service.notify.NotificationInfo; +import com.alibaba.cloud.ai.dataagent.service.notify.NotifierFactory; +import com.alibaba.cloud.ai.dataagent.service.notify.NotifierService; import com.alibaba.cloud.ai.dataagent.vo.GraphNodeResponse; import com.alibaba.cloud.ai.graph.*; import com.alibaba.cloud.ai.graph.exception.GraphRunnerException; @@ -35,6 +38,7 @@ import reactor.core.publisher.Flux; import reactor.core.publisher.Sinks; +import java.time.LocalDateTime; import java.util.HashMap; import java.util.Map; import java.util.UUID; @@ -58,13 +62,16 @@ public class GraphServiceImpl implements GraphService { private final LangfuseService langfuseReporter; + private final NotifierFactory notifierFactory; + public GraphServiceImpl(StateGraph stateGraph, ExecutorService executorService, - MultiTurnContextManager multiTurnContextManager, LangfuseService langfuseReporter) - throws GraphStateException { + MultiTurnContextManager multiTurnContextManager, LangfuseService langfuseReporter, + NotifierFactory notifierFactory) throws GraphStateException { this.compiledGraph = stateGraph.compile(CompileConfig.builder().interruptBefore(HUMAN_FEEDBACK_NODE).build()); this.executor = executorService; this.multiTurnContextManager = multiTurnContextManager; this.langfuseReporter = langfuseReporter; + this.notifierFactory = notifierFactory; } @Override @@ -248,6 +255,12 @@ private void handleStreamError(String agentId, String threadId, Throwable error) } // 清理资源(cleanup 内部已经保证只执行一次) context.cleanup(); + // Send failure notification + NotificationInfo notifyInfo = new NotificationInfo("Unknown", "失败", LocalDateTime.now(), threadId, agentId); + NotifierService notifierService = notifierFactory.create(); + if (notifierService != null) { + notifierService.notify(notifyInfo); + } } } @@ -271,6 +284,12 @@ private void handleStreamComplete(String agentId, String threadId) { context.getSink().tryEmitComplete(); } context.cleanup(); + // Send notification on completion + NotificationInfo notifyInfo = new NotificationInfo("ReportGeneratorNode", "成功", LocalDateTime.now(), threadId, agentId); + NotifierService notifierService = notifierFactory.create(); + if (notifierService != null) { + notifierService.notify(notifyInfo); + } } } diff --git a/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/notify/NotificationInfo.java b/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/notify/NotificationInfo.java new file mode 100644 index 000000000..b08c8827a --- /dev/null +++ b/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/notify/NotificationInfo.java @@ -0,0 +1,82 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.alibaba.cloud.ai.dataagent.service.notify; + +import java.time.LocalDateTime; + +public class NotificationInfo { + + private String nodeName; + + private String status; + + private LocalDateTime timestamp; + + private String threadId; + + private String agentId; + + public NotificationInfo() { + } + + public NotificationInfo(String nodeName, String status, LocalDateTime timestamp, String threadId, String agentId) { + this.nodeName = nodeName; + this.status = status; + this.timestamp = timestamp; + this.threadId = threadId; + this.agentId = agentId; + } + + public String getNodeName() { + return nodeName; + } + + public void setNodeName(String nodeName) { + this.nodeName = nodeName; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public LocalDateTime getTimestamp() { + return timestamp; + } + + public void setTimestamp(LocalDateTime timestamp) { + this.timestamp = timestamp; + } + + public String getThreadId() { + return threadId; + } + + public void setThreadId(String threadId) { + this.threadId = threadId; + } + + public String getAgentId() { + return agentId; + } + + public void setAgentId(String agentId) { + this.agentId = agentId; + } +} diff --git a/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/notify/NotifierFactory.java b/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/notify/NotifierFactory.java new file mode 100644 index 000000000..8be5d799a --- /dev/null +++ b/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/notify/NotifierFactory.java @@ -0,0 +1,55 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.alibaba.cloud.ai.dataagent.service.notify; + +import com.alibaba.cloud.ai.dataagent.properties.NotifyProperties; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Slf4j +@Component +public class NotifierFactory { + + private final NotifyProperties properties; + + private final Map notifierMap; + + public NotifierFactory(NotifyProperties properties, List notifierServices) { + this.properties = properties; + this.notifierMap = notifierServices.stream() + .collect(Collectors.toMap(this::extractChannel, Function.identity(), (a, b) -> a)); + } + + private String extractChannel(NotifierService service) { + return service.getClass().getSimpleName().replace("Notifier", "").toLowerCase(); + } + + public NotifierService create() { + String channel = properties.getChannel(); + NotifierService notifier = notifierMap.get(channel); + if (notifier == null) { + log.warn("No notifier found for channel: {}, using first available", channel); + notifier = notifierMap.values().stream().findFirst().orElse(null); + } + return notifier; + } +} + diff --git a/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/notify/NotifierService.java b/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/notify/NotifierService.java new file mode 100644 index 000000000..dd584bcde --- /dev/null +++ b/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/notify/NotifierService.java @@ -0,0 +1,23 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.alibaba.cloud.ai.dataagent.service.notify; + +public interface NotifierService { + + void notify(NotificationInfo info); + + boolean supports(String channel); +} diff --git a/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/notify/impl/DingTalkNotifier.java b/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/notify/impl/DingTalkNotifier.java new file mode 100644 index 000000000..65a06a389 --- /dev/null +++ b/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/notify/impl/DingTalkNotifier.java @@ -0,0 +1,119 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.alibaba.cloud.ai.dataagent.service.notify.impl; + +import com.alibaba.cloud.ai.dataagent.properties.NotifyProperties; +import com.alibaba.cloud.ai.dataagent.service.notify.NotificationInfo; +import com.alibaba.cloud.ai.dataagent.service.notify.NotifierService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.format.DateTimeFormatter; +import java.util.Base64; + +@Slf4j +@Component +public class DingTalkNotifier implements NotifierService { + + private static final String CHANNEL_NAME = "dingtalk"; + + private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + private final NotifyProperties properties; + + public DingTalkNotifier(NotifyProperties properties) { + this.properties = properties; + } + + @Override + public void notify(NotificationInfo info) { + if (!properties.isEnabled()) { + log.debug("Notification is disabled, skipping"); + return; + } + + String markdownContent = buildMarkdownContent(info); + sendDingTalkMessage(markdownContent); + } + + @Override + public boolean supports(String channel) { + return CHANNEL_NAME.equals(channel); + } + + private String buildMarkdownContent(NotificationInfo info) { + String statusEmoji = "成功".equals(info.getStatus()) ? "✓" : "✗"; + return String.format(""" + ## DataAgent 任务通知 + + - **状态**: %s %s + - **触发节点**: %s + - **时间**: %s + """, info.getStatus(), statusEmoji, info.getNodeName(), + info.getTimestamp().format(FORMATTER)); + } + + private void sendDingTalkMessage(String markdownContent) { + try { + String webhookUrl = properties.getDingtalk().getWebhookUrl(); + String secretKey = properties.getDingtalk().getSecretKey(); + + String sign = generateSign(secretKey); + String urlWithSign = webhookUrl + "&sign=" + URLEncoder.encode(sign, StandardCharsets.UTF_8); + + String requestBody = String.format(""" + { + "msgtype": "markdown", + "markdown": { + "title": "DataAgent 任务通知", + "text": %s + } + } + """, toJsonString(markdownContent)); + + WebClient.create(urlWithSign).post().bodyValue(requestBody).retrieve().bodyToMono(String.class) + .block(); + + log.info("DingTalk notification sent successfully"); + } + catch (Exception e) { + log.error("Failed to send DingTalk notification", e); + } + } + + private String generateSign(String secretKey) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + String timestamp = String.valueOf(System.currentTimeMillis()); + SecretKeySpec secretKeySpec = new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), "HmacSHA256"); + mac.init(secretKeySpec); + byte[] hash = mac.doFinal((timestamp + "\n" + secretKey).getBytes(StandardCharsets.UTF_8)); + return Base64.getEncoder().encodeToString(hash); + } + catch (Exception e) { + throw new RuntimeException("Failed to generate signature", e); + } + } + + private String toJsonString(String text) { + return "\"" + text.replace("\"", "\\\"").replace("\n", "\\n") + "\""; + } +} diff --git a/data-agent-management/src/main/resources/application.yml b/data-agent-management/src/main/resources/application.yml index 30f606553..a99eaf8e8 100644 --- a/data-agent-management/src/main/resources/application.yml +++ b/data-agent-management/src/main/resources/application.yml @@ -10,9 +10,9 @@ springdoc: # 存储 DataAgent 业务数据的数据库,并非Agent分析数据的来源 spring: datasource: - url: ${DATA_AGENT_DATASOURCE_URL:jdbc:mysql://127.0.0.1:3306/saa_data_agent?useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&transformedBitIsBoolean=true&allowMultiQueries=true&allowPublicKeyRetrieval=true&useSSL=false&serverTimezone=Asia/Shanghai} + url: ${DATA_AGENT_DATASOURCE_URL:jdbc:mysql://127.0.0.1:3307/saa_data_agent?useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&transformedBitIsBoolean=true&allowMultiQueries=true&allowPublicKeyRetrieval=true&useSSL=false&serverTimezone=Asia/Shanghai} username: ${DATA_AGENT_DATASOURCE_USERNAME:root} - password: ${DATA_AGENT_DATASOURCE_PASSWORD:root} + password: ${DATA_AGENT_DATASOURCE_PASSWORD:1234} driver-class-name: com.mysql.cj.jdbc.Driver type: com.alibaba.druid.pool.DruidDataSource sql: @@ -59,6 +59,12 @@ spring: host: ${LANGFUSE_HOST:} public-key: ${LANGFUSE_PUBLIC_KEY:} secret-key: ${LANGFUSE_SECRET_KEY:} + notify: + enabled: true + channel: dingtalk + dingtalk: + webhook-url: ${DINGTALK_WEBHOOK_URL:} + secret-key: ${DINGTALK_SECRET_KEY:} webflux: multipart: max-file-size: 10MB diff --git a/data-agent-management/src/test/java/com/alibaba/cloud/ai/dataagent/service/graph/GraphServiceImplTest.java b/data-agent-management/src/test/java/com/alibaba/cloud/ai/dataagent/service/graph/GraphServiceImplTest.java index f469a9a58..f2d273e5c 100644 --- a/data-agent-management/src/test/java/com/alibaba/cloud/ai/dataagent/service/graph/GraphServiceImplTest.java +++ b/data-agent-management/src/test/java/com/alibaba/cloud/ai/dataagent/service/graph/GraphServiceImplTest.java @@ -18,6 +18,7 @@ import com.alibaba.cloud.ai.dataagent.dto.GraphRequest; import com.alibaba.cloud.ai.dataagent.service.graph.Context.MultiTurnContextManager; import com.alibaba.cloud.ai.dataagent.service.langfuse.LangfuseService; +import com.alibaba.cloud.ai.dataagent.service.notify.NotifierFactory; import com.alibaba.cloud.ai.dataagent.vo.GraphNodeResponse; import com.alibaba.cloud.ai.graph.CompiledGraph; import com.alibaba.cloud.ai.graph.OverAllState; @@ -58,6 +59,9 @@ class GraphServiceImplTest { @Mock private LangfuseService langfuseReporter; + @Mock + private NotifierFactory notifierFactory; + @Mock private Span mockSpan; @@ -72,7 +76,7 @@ void setUp() throws Exception { StateGraph mockStateGraph = mock(StateGraph.class); when(mockStateGraph.compile(any())).thenReturn(compiledGraph); - graphService = new GraphServiceImpl(mockStateGraph, executor, multiTurnContextManager, langfuseReporter); + graphService = new GraphServiceImpl(mockStateGraph, executor, multiTurnContextManager, langfuseReporter, notifierFactory); setField(graphService, "compiledGraph", compiledGraph); diff --git a/data-agent-management/src/test/java/com/alibaba/cloud/ai/dataagent/service/notify/NotificationInfoTest.java b/data-agent-management/src/test/java/com/alibaba/cloud/ai/dataagent/service/notify/NotificationInfoTest.java new file mode 100644 index 000000000..c0892fcdb --- /dev/null +++ b/data-agent-management/src/test/java/com/alibaba/cloud/ai/dataagent/service/notify/NotificationInfoTest.java @@ -0,0 +1,66 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.alibaba.cloud.ai.dataagent.service.notify; + +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.junit.jupiter.api.Assertions.*; + +class NotificationInfoTest { + + @Test + void testDefaultConstructor() { + NotificationInfo info = new NotificationInfo(); + assertNull(info.getNodeName()); + assertNull(info.getStatus()); + assertNull(info.getTimestamp()); + assertNull(info.getThreadId()); + assertNull(info.getAgentId()); + } + + @Test + void testAllArgsConstructor() { + LocalDateTime now = LocalDateTime.now(); + NotificationInfo info = new NotificationInfo("ReportGeneratorNode", "成功", now, "thread-123", "agent-456"); + + assertEquals("ReportGeneratorNode", info.getNodeName()); + assertEquals("成功", info.getStatus()); + assertEquals(now, info.getTimestamp()); + assertEquals("thread-123", info.getThreadId()); + assertEquals("agent-456", info.getAgentId()); + } + + @Test + void testSettersAndGetters() { + NotificationInfo info = new NotificationInfo(); + LocalDateTime now = LocalDateTime.now(); + + info.setNodeName("TestNode"); + info.setStatus("失败"); + info.setTimestamp(now); + info.setThreadId("thread-789"); + info.setAgentId("agent-101"); + + assertEquals("TestNode", info.getNodeName()); + assertEquals("失败", info.getStatus()); + assertEquals(now, info.getTimestamp()); + assertEquals("thread-789", info.getThreadId()); + assertEquals("agent-101", info.getAgentId()); + } +} + diff --git a/data-agent-management/src/test/java/com/alibaba/cloud/ai/dataagent/service/notify/impl/DingTalkNotifierTest.java b/data-agent-management/src/test/java/com/alibaba/cloud/ai/dataagent/service/notify/impl/DingTalkNotifierTest.java new file mode 100644 index 000000000..4233b18f2 --- /dev/null +++ b/data-agent-management/src/test/java/com/alibaba/cloud/ai/dataagent/service/notify/impl/DingTalkNotifierTest.java @@ -0,0 +1,65 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.alibaba.cloud.ai.dataagent.service.notify.impl; + +import com.alibaba.cloud.ai.dataagent.properties.NotifyProperties; +import com.alibaba.cloud.ai.dataagent.service.notify.NotificationInfo; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.junit.jupiter.api.Assertions.*; + +class DingTalkNotifierTest { + + private NotifyProperties properties; + + private DingTalkNotifier notifier; + + @BeforeEach + void setUp() { + properties = new NotifyProperties(); + properties.setEnabled(true); + properties.setChannel("dingtalk"); + notifier = new DingTalkNotifier(properties); + } + + @Test + void testSupports() { + assertTrue(notifier.supports("dingtalk")); + assertFalse(notifier.supports("feishu")); + assertFalse(notifier.supports("wecom")); + } + + @Test + void testNotifyWhenDisabled() { + properties.setEnabled(false); + // Should not throw exception + NotificationInfo info = new NotificationInfo("TestNode", "成功", LocalDateTime.now(), "t1", "a1"); + notifier.notify(info); + // If no exception, test passes + } + + @Test + void testNotifyBuildsCorrectContent() { + // This test verifies the content building logic indirectly + NotificationInfo info = new NotificationInfo("ReportGeneratorNode", "成功", LocalDateTime.now(), "thread-1", "agent-1"); + // Should complete without exception (actual webhook call will fail without valid URL) + notifier.notify(info); + } +} + diff --git a/docs/superpowers/plans/2026-04-21-notification-implementation.md b/docs/superpowers/plans/2026-04-21-notification-implementation.md new file mode 100644 index 000000000..bf70dd632 --- /dev/null +++ b/docs/superpowers/plans/2026-04-21-notification-implementation.md @@ -0,0 +1,386 @@ +# Notification Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add notification functionality that sends DingTalk messages when workflow completes or after human intervention. + +**Architecture:** Factory pattern with `NotifierService` interface, `NotifierFactory` creates channel-specific implementations. Default implementation is `DingTalkNotifier` with HmacSHA256 signature verification. + +**Tech Stack:** Spring Boot, WebClient (for HTTP calls), standard Java crypto (HmacSHA256) + +--- + +## File Structure + +| File | Purpose | +|------|---------| +| `service/notify/NotificationInfo.java` | Notification data object | +| `service/notify/NotifierService.java` | Interface for notifier implementations | +| `service/notify/NotifierFactory.java` | Factory to create notifier by channel | +| `service/notify/impl/DingTalkNotifier.java` | DingTalk implementation | +| `properties/NotifyProperties.java` | Configuration properties | +| `service/graph/GraphServiceImpl.java` | Trigger notifications on completion | + +--- + +## Task 1: NotificationInfo & NotifierService + +**Files:** +- Create: `service/notify/NotificationInfo.java` +- Create: `service/notify/NotifierService.java` +- Test: `test/service/notify/NotificationInfoTest.java` + +- [ ] **Step 1: Create NotificationInfo.java** + +```java +package com.alibaba.cloud.ai.dataagent.service.notify; + +import java.time.LocalDateTime; + +public class NotificationInfo { + + private String nodeName; + + private String status; + + private LocalDateTime timestamp; + + private String threadId; + + private String agentId; + + public NotificationInfo() { + } + + public NotificationInfo(String nodeName, String status, LocalDateTime timestamp, String threadId, String agentId) { + this.nodeName = nodeName; + this.status = status; + this.timestamp = timestamp; + this.threadId = threadId; + this.agentId = agentId; + } + + // getters and setters +} +``` + +- [ ] **Step 2: Create NotifierService.java** + +```java +package com.alibaba.cloud.ai.dataagent.service.notify; + +public interface NotifierService { + + void notify(NotificationInfo info); + + boolean supports(String channel); +} +``` + +- [ ] **Step 3: Write unit tests for NotificationInfo** + +Run: `./mvnw test -Dtest=NotificationInfoTest` +Expected: Tests pass + +--- + +## Task 2: DingTalkNotifier Implementation + +**Files:** +- Create: `service/notify/impl/DingTalkNotifier.java` +- Create: `properties/NotifyProperties.java` +- Test: `test/service/notify/impl/DingTalkNotifierTest.java` + +- [ ] **Step 1: Create NotifyProperties.java** + +```java +package com.alibaba.cloud.ai.dataagent.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "spring.ai.alibaba.data-agent.notify") +public class NotifyProperties { + + private boolean enabled = false; + + private String channel = "dingtalk"; + + private DingTalkConfig dingtalk = new DingTalkConfig(); + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getChannel() { + return channel; + } + + public void setChannel(String channel) { + this.channel = channel; + } + + public DingTalkConfig getDingtalk() { + return dingtalk; + } + + public void setDingtalk(DingTalkConfig dingtalk) { + this.dingtalk = dingtalk; + } + + public static class DingTalkConfig { + + private String webhookUrl; + + private String secretKey; + + public String getWebhookUrl() { + return webhookUrl; + } + + public void setWebhookUrl(String webhookUrl) { + this.webhookUrl = webhookUrl; + } + + public String getSecretKey() { + return secretKey; + } + + public void setSecretKey(String secretKey) { + this.secretKey = secretKey; + } + } +} +``` + +- [ ] **Step 2: Create DingTalkNotifier.java** + +```java +package com.alibaba.cloud.ai.dataagent.service.notify.impl; + +import com.alibaba.cloud.ai.dataagent.properties.NotifyProperties; +import com.alibaba.cloud.ai.dataagent.service.notify.NotificationInfo; +import com.alibaba.cloud.ai.dataagent.service.notify.NotifierService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.format.DateTimeFormatter; +import java.util.Base64; + +@Slf4j +@Component +public class DingTalkNotifier implements NotifierService { + + private static final String CHANNEL_NAME = "dingtalk"; + + private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + private final NotifyProperties properties; + + public DingTalkNotifier(NotifyProperties properties) { + this.properties = properties; + } + + @Override + public void notify(NotificationInfo info) { + if (!properties.isEnabled()) { + log.debug("Notification is disabled, skipping"); + return; + } + + String markdownContent = buildMarkdownContent(info); + sendDingTalkMessage(markdownContent); + } + + @Override + public boolean supports(String channel) { + return CHANNEL_NAME.equals(channel); + } + + private String buildMarkdownContent(NotificationInfo info) { + String statusEmoji = "成功".equals(info.getStatus()) ? "✓" : "✗"; + return String.format(""" + ## DataAgent 任务通知 + + - **状态**: %s %s + - **触发节点**: %s + - **时间**: %s + """, info.getStatus(), statusEmoji, info.getNodeName(), + info.getTimestamp().format(FORMATTER)); + } + + private void sendDingTalkMessage(String markdownContent) { + try { + String webhookUrl = properties.getDingtalk().getWebhookUrl(); + String secretKey = properties.getDingtalk().getSecretKey(); + + String sign = generateSign(secretKey); + String urlWithSign = webhookUrl + "&sign=" + URLEncoder.encode(sign, StandardCharsets.UTF_8); + + String requestBody = String.format(""" + { + "msgtype": "markdown", + "markdown": { + "title": "DataAgent 任务通知", + "text": %s + } + } + """, toJsonString(markdownContent)); + + WebClient.create(urlWithSign).post().bodyValue(requestBody).retrieve().bodyToMono(String.class) + .block(); + + log.info("DingTalk notification sent successfully"); + } + catch (Exception e) { + log.error("Failed to send DingTalk notification", e); + } + } + + private String generateSign(String secretKey) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + String timestamp = String.valueOf(System.currentTimeMillis()); + SecretKeySpec secretKeySpec = new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), "HmacSHA256"); + mac.init(secretKeySpec); + byte[] hash = mac.doFinal((timestamp + "\n" + secretKey).getBytes(StandardCharsets.UTF_8)); + return Base64.getEncoder().encodeToString(hash); + } + catch (Exception e) { + throw new RuntimeException("Failed to generate signature", e); + } + } + + private String toJsonString(String text) { + return "\"" + text.replace("\"", "\\\"").replace("\n", "\\n") + "\""; + } +} +``` + +- [ ] **Step 3: Write unit tests** + +Run: `./mvnw test -Dtest=DingTalkNotifierTest` +Expected: Tests pass + +--- + +## Task 3: NotifierFactory + +**Files:** +- Create: `service/notify/NotifierFactory.java` +- Modify: `config/DataAgentConfiguration.java` (add @EnableConfigurationProperties for NotifyProperties) + +- [ ] **Step 1: Create NotifierFactory.java** + +```java +package com.alibaba.cloud.ai.dataagent.service.notify; + +import com.alibaba.cloud.ai.dataagent.properties.NotifyProperties; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Slf4j +@Component +public class NotifierFactory { + + private final NotifyProperties properties; + + private final Map notifierMap; + + public NotifierFactory(NotifyProperties properties, List notifierServices) { + this.properties = properties; + this.notifierMap = notifierServices.stream() + .collect(Collectors.toMap(this::extractChannel, Function.identity(), (a, b) -> a)); + } + + private String extractChannel(NotifierService service) { + // Assume service class name contains channel name + return service.getClass().getSimpleName().replace("Notifier", "").toLowerCase(); + } + + public NotifierService create() { + String channel = properties.getChannel(); + NotifierService notifier = notifierMap.get(channel); + if (notifier == null) { + log.warn("No notifier found for channel: {}, using first available", channel); + notifier = notifierMap.values().stream().findFirst().orElse(null); + } + return notifier; + } +} +``` + +- [ ] **Step 2: Enable NotifyProperties in DataAgentConfiguration** + +Add `NotifyProperties.class` to `@EnableConfigurationProperties` annotation. + +Run: `./mvnw compile` +Expected: Compiles successfully + +--- + +## Task 4: Configuration & Integration + +**Files:** +- Modify: `application.yml` (add notify config) +- Modify: `service/graph/GraphServiceImpl.java` (trigger notifications) + +- [ ] **Step 1: Add notify config to application.yml** + +```yaml +spring: + ai: + alibaba: + data-agent: + notify: + enabled: true + channel: dingtalk + dingtalk: + webhook-url: ${DINGTALK_WEBHOOK_URL:} + secret-key: ${DINGTALK_SECRET_KEY:} +``` + +- [ ] **Step 2: Integrate with GraphServiceImpl** + +Inject `NotifierFactory` into `GraphServiceImpl`. In `handleStreamComplete()`, create `NotificationInfo` and call `notifierFactory.create().notify(info)`. + +In `handleStreamError()`, call with status "失败". + +Run: `./mvnw compile` +Expected: Compiles successfully + +--- + +## Task 5: Unit Tests + +**Files:** +- Modify: `GraphServiceImplTest.java` + +- [ ] **Step 1: Write integration test for notification flow** + +Run: `./mvnw test -Dtest=GraphServiceImplTest` +Expected: Tests pass + +--- + +## Task 6: Commit + +- [ ] **Step 1: Commit changes** + +```bash +git add -A +git commit -m "feat: add notification system with DingTalk support" +``` diff --git a/docs/superpowers/specs/2026-04-21-notification-design.md b/docs/superpowers/specs/2026-04-21-notification-design.md new file mode 100644 index 000000000..d4d368d26 --- /dev/null +++ b/docs/superpowers/specs/2026-04-21-notification-design.md @@ -0,0 +1,116 @@ +# 通知功能设计 + +## 1. 概述 + +为 DataAgent 添加通知功能,在工作流结束时向用户发送钉钉消息通知。采用工厂模式,支持用户扩展其他通知渠道(飞书、企业微信等)。 + +## 2. 触发时机 + +- **ReportGeneratorNode 完成时** - 分析任务正常结束 +- **HumanFeedbackNode 确认后** - 用户人工干预确认 + +## 3. 核心组件 + +### 3.1 NotificationInfo + +通知信息对象,包含以下字段: + +| 字段 | 类型 | 说明 | +|------|------|------| +| nodeName | String | 触发节点名称 | +| status | String | 任务状态(成功/失败) | +| timestamp | LocalDateTime | 触发时间 | +| threadId | String | 线程ID(可选) | +| agentId | String | 代理ID(可选) | + +### 3.2 NotifierService + +通知接口: + +```java +public interface NotifierService { + void notify(NotificationInfo info); + boolean supports(String channel); +} +``` + +### 3.3 NotifierFactory + +工厂类: +- 根据配置 `spring.ai.alibaba.data-agent.notify.channel` 创建对应通知实现 +- 使用 Spring SPI 机制加载用户自定义实现 +- 默认实现:DingTalkNotifier + +### 3.4 DingTalkNotifier + +钉钉通知实现: +- **安全验证**:HmacSHA256 签名 +- **消息格式**:Markdown +- **配置项**: + - `webhook-url`:钉钉机器人 Webhook 地址 + - `secret-key`:签名密钥 + +## 4. 配置项 + +```yaml +spring: + ai: + alibaba: + data-agent: + notify: + enabled: true + channel: dingtalk + dingtalk: + webhook-url: https://oapi.dingtalk.com/robot/send?access_token=xxx + secret-key: xxxxx +``` + +## 5. 消息格式 + +```markdown +## DataAgent 任务通知 + +- **状态**: 成功 ✓ +- **触发节点**: ReportGeneratorNode +- **时间**: 2026-04-21 15:30:25 +``` + +## 6. 数据流 + +``` +GraphServiceImpl + ├── ReportGeneratorNode.apply() 完成 + │ → NotifierFactory.create().notify(info) + │ + └── HumanFeedbackNode 处理完成 + → NotifierFactory.create().notify(info) +``` + +## 7. 文件清单 + +| 文件 | 说明 | +|------|------| +| `NotificationInfo.java` | 通知信息对象 | +| `NotifierService.java` | 通知服务接口 | +| `NotifierFactory.java` | 通知工厂类 | +| `DingTalkNotifier.java` | 钉钉通知实现 | +| `application.yml` | 配置项 | +| `GraphServiceImpl.java` | 触发通知调用 | + +## 8. 扩展方式 + +用户实现 `NotifierService` 接口并注册为 Spring Bean,工厂会自动加载: + +```java +@Component +public class CustomNotifier implements NotifierService { + @Override + public void notify(NotificationInfo info) { + // 自定义通知逻辑 + } + @Override + public boolean supports(String channel) { + return "custom".equals(channel); + } +} +``` From 128793405dcd4aa004fb59104d01fad976e6d805 Mon Sep 17 00:00:00 2001 From: yangyufan <936930947@qq.com> Date: Tue, 21 Apr 2026 21:30:30 +0800 Subject: [PATCH 2/3] feat:notification tool --- .../config/DataAgentConfiguration.java | 3 +- .../properties/NotifyProperties.java | 75 ----------------- .../service/graph/GraphServiceImpl.java | 23 +----- .../service/notify/NotificationInfo.java | 16 ++-- .../service/notify/NotifierFactory.java | 26 +++--- .../service/notify/NotifierService.java | 4 +- .../service/notify/impl/DingTalkNotifier.java | 82 +++++++++++-------- .../src/main/resources/application.yml | 10 +-- .../service/graph/GraphServiceImplTest.java | 6 +- .../notify/impl/DingTalkNotifierTest.java | 43 +++++----- 10 files changed, 102 insertions(+), 186 deletions(-) delete mode 100644 data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/properties/NotifyProperties.java diff --git a/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/config/DataAgentConfiguration.java b/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/config/DataAgentConfiguration.java index 2969baecb..3aab99704 100644 --- a/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/config/DataAgentConfiguration.java +++ b/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/config/DataAgentConfiguration.java @@ -18,7 +18,6 @@ import com.alibaba.cloud.ai.dataagent.properties.CodeExecutorProperties; import com.alibaba.cloud.ai.dataagent.properties.DataAgentProperties; import com.alibaba.cloud.ai.dataagent.properties.FileStorageProperties; -import com.alibaba.cloud.ai.dataagent.properties.NotifyProperties; import com.alibaba.cloud.ai.dataagent.service.vectorstore.SimpleVectorStoreInitialization; import com.alibaba.cloud.ai.dataagent.splitter.SentenceSplitter; import com.alibaba.cloud.ai.transformer.splitter.RecursiveCharacterTextSplitter; @@ -87,7 +86,7 @@ @Slf4j @Configuration @EnableAsync -@EnableConfigurationProperties({ CodeExecutorProperties.class, DataAgentProperties.class, FileStorageProperties.class, NotifyProperties.class }) +@EnableConfigurationProperties({ CodeExecutorProperties.class, DataAgentProperties.class, FileStorageProperties.class }) public class DataAgentConfiguration implements DisposableBean { /** diff --git a/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/properties/NotifyProperties.java b/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/properties/NotifyProperties.java deleted file mode 100644 index d6297a6d3..000000000 --- a/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/properties/NotifyProperties.java +++ /dev/null @@ -1,75 +0,0 @@ -/* - * Copyright 2026 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.alibaba.cloud.ai.dataagent.properties; - -import org.springframework.boot.context.properties.ConfigurationProperties; - -@ConfigurationProperties(prefix = "spring.ai.alibaba.data-agent.notify") -public class NotifyProperties { - - private boolean enabled = false; - - private String channel = "dingtalk"; - - private DingTalkConfig dingtalk = new DingTalkConfig(); - - public boolean isEnabled() { - return enabled; - } - - public void setEnabled(boolean enabled) { - this.enabled = enabled; - } - - public String getChannel() { - return channel; - } - - public void setChannel(String channel) { - this.channel = channel; - } - - public DingTalkConfig getDingtalk() { - return dingtalk; - } - - public void setDingtalk(DingTalkConfig dingtalk) { - this.dingtalk = dingtalk; - } - - public static class DingTalkConfig { - - private String webhookUrl; - - private String secretKey; - - public String getWebhookUrl() { - return webhookUrl; - } - - public void setWebhookUrl(String webhookUrl) { - this.webhookUrl = webhookUrl; - } - - public String getSecretKey() { - return secretKey; - } - - public void setSecretKey(String secretKey) { - this.secretKey = secretKey; - } - } -} diff --git a/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/graph/GraphServiceImpl.java b/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/graph/GraphServiceImpl.java index 39af06db4..6325b9bbf 100644 --- a/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/graph/GraphServiceImpl.java +++ b/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/graph/GraphServiceImpl.java @@ -21,9 +21,6 @@ import com.alibaba.cloud.ai.dataagent.dto.GraphRequest; import com.alibaba.cloud.ai.dataagent.service.graph.Context.MultiTurnContextManager; import com.alibaba.cloud.ai.dataagent.service.graph.Context.StreamContext; -import com.alibaba.cloud.ai.dataagent.service.notify.NotificationInfo; -import com.alibaba.cloud.ai.dataagent.service.notify.NotifierFactory; -import com.alibaba.cloud.ai.dataagent.service.notify.NotifierService; import com.alibaba.cloud.ai.dataagent.vo.GraphNodeResponse; import com.alibaba.cloud.ai.graph.*; import com.alibaba.cloud.ai.graph.exception.GraphRunnerException; @@ -38,7 +35,6 @@ import reactor.core.publisher.Flux; import reactor.core.publisher.Sinks; -import java.time.LocalDateTime; import java.util.HashMap; import java.util.Map; import java.util.UUID; @@ -62,16 +58,13 @@ public class GraphServiceImpl implements GraphService { private final LangfuseService langfuseReporter; - private final NotifierFactory notifierFactory; - public GraphServiceImpl(StateGraph stateGraph, ExecutorService executorService, - MultiTurnContextManager multiTurnContextManager, LangfuseService langfuseReporter, - NotifierFactory notifierFactory) throws GraphStateException { + MultiTurnContextManager multiTurnContextManager, LangfuseService langfuseReporter) + throws GraphStateException { this.compiledGraph = stateGraph.compile(CompileConfig.builder().interruptBefore(HUMAN_FEEDBACK_NODE).build()); this.executor = executorService; this.multiTurnContextManager = multiTurnContextManager; this.langfuseReporter = langfuseReporter; - this.notifierFactory = notifierFactory; } @Override @@ -255,12 +248,6 @@ private void handleStreamError(String agentId, String threadId, Throwable error) } // 清理资源(cleanup 内部已经保证只执行一次) context.cleanup(); - // Send failure notification - NotificationInfo notifyInfo = new NotificationInfo("Unknown", "失败", LocalDateTime.now(), threadId, agentId); - NotifierService notifierService = notifierFactory.create(); - if (notifierService != null) { - notifierService.notify(notifyInfo); - } } } @@ -284,12 +271,6 @@ private void handleStreamComplete(String agentId, String threadId) { context.getSink().tryEmitComplete(); } context.cleanup(); - // Send notification on completion - NotificationInfo notifyInfo = new NotificationInfo("ReportGeneratorNode", "成功", LocalDateTime.now(), threadId, agentId); - NotifierService notifierService = notifierFactory.create(); - if (notifierService != null) { - notifierService.notify(notifyInfo); - } } } diff --git a/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/notify/NotificationInfo.java b/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/notify/NotificationInfo.java index b08c8827a..d113e22f3 100644 --- a/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/notify/NotificationInfo.java +++ b/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/notify/NotificationInfo.java @@ -40,14 +40,6 @@ public NotificationInfo(String nodeName, String status, LocalDateTime timestamp, this.agentId = agentId; } - public String getNodeName() { - return nodeName; - } - - public void setNodeName(String nodeName) { - this.nodeName = nodeName; - } - public String getStatus() { return status; } @@ -79,4 +71,12 @@ public String getAgentId() { public void setAgentId(String agentId) { this.agentId = agentId; } + + public String getNodeName() { + return nodeName; + } + + public void setNodeName(String nodeName) { + this.nodeName = nodeName; + } } diff --git a/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/notify/NotifierFactory.java b/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/notify/NotifierFactory.java index 8be5d799a..ffb769127 100644 --- a/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/notify/NotifierFactory.java +++ b/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/notify/NotifierFactory.java @@ -15,8 +15,8 @@ */ package com.alibaba.cloud.ai.dataagent.service.notify; -import com.alibaba.cloud.ai.dataagent.properties.NotifyProperties; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import java.util.List; @@ -28,26 +28,28 @@ @Component public class NotifierFactory { - private final NotifyProperties properties; + @Value("${spring.ai.alibaba.data-agent.notify.channel:dingtalk}") + private String defaultChannel; private final Map notifierMap; - public NotifierFactory(NotifyProperties properties, List notifierServices) { - this.properties = properties; + public NotifierFactory(List notifierServices) { this.notifierMap = notifierServices.stream() - .collect(Collectors.toMap(this::extractChannel, Function.identity(), (a, b) -> a)); + .collect(Collectors.toMap(NotifierService::getName, Function.identity(), (a, b) -> a)); } - private String extractChannel(NotifierService service) { - return service.getClass().getSimpleName().replace("Notifier", "").toLowerCase(); + public NotifierService getByName(String name) { + NotifierService notifier = notifierMap.get(name); + if (notifier == null) { + throw new IllegalArgumentException("Notifier not found: " + name); + } + return notifier; } - public NotifierService create() { - String channel = properties.getChannel(); - NotifierService notifier = notifierMap.get(channel); + public NotifierService getDefault() { + NotifierService notifier = notifierMap.get(defaultChannel); if (notifier == null) { - log.warn("No notifier found for channel: {}, using first available", channel); - notifier = notifierMap.values().stream().findFirst().orElse(null); + throw new IllegalArgumentException("Default notifier not found for channel: " + defaultChannel); } return notifier; } diff --git a/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/notify/NotifierService.java b/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/notify/NotifierService.java index dd584bcde..63935bf9f 100644 --- a/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/notify/NotifierService.java +++ b/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/notify/NotifierService.java @@ -17,7 +17,9 @@ public interface NotifierService { + String getName(); + void notify(NotificationInfo info); - boolean supports(String channel); + void notify(String message); } diff --git a/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/notify/impl/DingTalkNotifier.java b/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/notify/impl/DingTalkNotifier.java index 65a06a389..433dcbac1 100644 --- a/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/notify/impl/DingTalkNotifier.java +++ b/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/notify/impl/DingTalkNotifier.java @@ -15,17 +15,20 @@ */ package com.alibaba.cloud.ai.dataagent.service.notify.impl; -import com.alibaba.cloud.ai.dataagent.properties.NotifyProperties; import com.alibaba.cloud.ai.dataagent.service.notify.NotificationInfo; import com.alibaba.cloud.ai.dataagent.service.notify.NotifierService; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; +import org.springframework.http.MediaType; import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientResponseException; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; +import java.time.Instant; import java.time.format.DateTimeFormatter; import java.util.Base64; @@ -33,20 +36,23 @@ @Component public class DingTalkNotifier implements NotifierService { - private static final String CHANNEL_NAME = "dingtalk"; - private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); - private final NotifyProperties properties; + @Value("${spring.ai.alibaba.data-agent.notify.dingtalk.webhook-url:}") + private String webhookUrl; + + @Value("${spring.ai.alibaba.data-agent.notify.dingtalk.secret-key:}") + private String secretKey; - public DingTalkNotifier(NotifyProperties properties) { - this.properties = properties; + @Override + public String getName() { + return "dingtalk"; } @Override public void notify(NotificationInfo info) { - if (!properties.isEnabled()) { - log.debug("Notification is disabled, skipping"); + if (webhookUrl == null || webhookUrl.isEmpty()) { + log.debug("DingTalk webhook-url is not configured, skipping notification"); return; } @@ -54,9 +60,17 @@ public void notify(NotificationInfo info) { sendDingTalkMessage(markdownContent); } - @Override - public boolean supports(String channel) { - return CHANNEL_NAME.equals(channel); + public void notify(String message) { + if (webhookUrl == null || webhookUrl.isEmpty()) { + log.debug("DingTalk webhook-url is not configured, skipping notification"); + return; + } + + String markdownContent = """ + ## DataAgent 任务通知 + + """ + message; + sendDingTalkMessage(markdownContent); } private String buildMarkdownContent(NotificationInfo info) { @@ -64,20 +78,19 @@ private String buildMarkdownContent(NotificationInfo info) { return String.format(""" ## DataAgent 任务通知 - - **状态**: %s %s - **触发节点**: %s + - **状态**: %s %s - **时间**: %s - """, info.getStatus(), statusEmoji, info.getNodeName(), + """, info.getNodeName() != null ? info.getNodeName() : "N/A", + info.getStatus(), statusEmoji, info.getTimestamp().format(FORMATTER)); } private void sendDingTalkMessage(String markdownContent) { try { - String webhookUrl = properties.getDingtalk().getWebhookUrl(); - String secretKey = properties.getDingtalk().getSecretKey(); - - String sign = generateSign(secretKey); - String urlWithSign = webhookUrl + "&sign=" + URLEncoder.encode(sign, StandardCharsets.UTF_8); + long timestamp = Instant.now().toEpochMilli(); + String sign = computeSign(timestamp, secretKey); + String urlWithSign = webhookUrl + "×tamp=" + timestamp + "&sign=" + URLEncoder.encode(sign, StandardCharsets.UTF_8); String requestBody = String.format(""" { @@ -89,31 +102,34 @@ private void sendDingTalkMessage(String markdownContent) { } """, toJsonString(markdownContent)); - WebClient.create(urlWithSign).post().bodyValue(requestBody).retrieve().bodyToMono(String.class) + String response = WebClient.create(urlWithSign) + .post() + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(requestBody) + .retrieve() + .bodyToMono(String.class) .block(); - log.info("DingTalk notification sent successfully"); + log.info("DingTalk response: {}", response); + } + catch (WebClientResponseException e) { + log.error("DingTalk API error: status={}, body={}", e.getStatusCode(), e.getResponseBodyAsString()); } catch (Exception e) { log.error("Failed to send DingTalk notification", e); } } - private String generateSign(String secretKey) { - try { - Mac mac = Mac.getInstance("HmacSHA256"); - String timestamp = String.valueOf(System.currentTimeMillis()); - SecretKeySpec secretKeySpec = new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), "HmacSHA256"); - mac.init(secretKeySpec); - byte[] hash = mac.doFinal((timestamp + "\n" + secretKey).getBytes(StandardCharsets.UTF_8)); - return Base64.getEncoder().encodeToString(hash); - } - catch (Exception e) { - throw new RuntimeException("Failed to generate signature", e); - } - } private String toJsonString(String text) { return "\"" + text.replace("\"", "\\\"").replace("\n", "\\n") + "\""; } + + private String computeSign(long timestamp, String secretKey) throws Exception { + String stringToSign = timestamp + "\n" + secretKey; + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + byte[] signData = mac.doFinal(stringToSign.getBytes(StandardCharsets.UTF_8)); + return Base64.getEncoder().encodeToString(signData); + } } diff --git a/data-agent-management/src/main/resources/application.yml b/data-agent-management/src/main/resources/application.yml index a99eaf8e8..30f606553 100644 --- a/data-agent-management/src/main/resources/application.yml +++ b/data-agent-management/src/main/resources/application.yml @@ -10,9 +10,9 @@ springdoc: # 存储 DataAgent 业务数据的数据库,并非Agent分析数据的来源 spring: datasource: - url: ${DATA_AGENT_DATASOURCE_URL:jdbc:mysql://127.0.0.1:3307/saa_data_agent?useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&transformedBitIsBoolean=true&allowMultiQueries=true&allowPublicKeyRetrieval=true&useSSL=false&serverTimezone=Asia/Shanghai} + url: ${DATA_AGENT_DATASOURCE_URL:jdbc:mysql://127.0.0.1:3306/saa_data_agent?useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&transformedBitIsBoolean=true&allowMultiQueries=true&allowPublicKeyRetrieval=true&useSSL=false&serverTimezone=Asia/Shanghai} username: ${DATA_AGENT_DATASOURCE_USERNAME:root} - password: ${DATA_AGENT_DATASOURCE_PASSWORD:1234} + password: ${DATA_AGENT_DATASOURCE_PASSWORD:root} driver-class-name: com.mysql.cj.jdbc.Driver type: com.alibaba.druid.pool.DruidDataSource sql: @@ -59,12 +59,6 @@ spring: host: ${LANGFUSE_HOST:} public-key: ${LANGFUSE_PUBLIC_KEY:} secret-key: ${LANGFUSE_SECRET_KEY:} - notify: - enabled: true - channel: dingtalk - dingtalk: - webhook-url: ${DINGTALK_WEBHOOK_URL:} - secret-key: ${DINGTALK_SECRET_KEY:} webflux: multipart: max-file-size: 10MB diff --git a/data-agent-management/src/test/java/com/alibaba/cloud/ai/dataagent/service/graph/GraphServiceImplTest.java b/data-agent-management/src/test/java/com/alibaba/cloud/ai/dataagent/service/graph/GraphServiceImplTest.java index f2d273e5c..f469a9a58 100644 --- a/data-agent-management/src/test/java/com/alibaba/cloud/ai/dataagent/service/graph/GraphServiceImplTest.java +++ b/data-agent-management/src/test/java/com/alibaba/cloud/ai/dataagent/service/graph/GraphServiceImplTest.java @@ -18,7 +18,6 @@ import com.alibaba.cloud.ai.dataagent.dto.GraphRequest; import com.alibaba.cloud.ai.dataagent.service.graph.Context.MultiTurnContextManager; import com.alibaba.cloud.ai.dataagent.service.langfuse.LangfuseService; -import com.alibaba.cloud.ai.dataagent.service.notify.NotifierFactory; import com.alibaba.cloud.ai.dataagent.vo.GraphNodeResponse; import com.alibaba.cloud.ai.graph.CompiledGraph; import com.alibaba.cloud.ai.graph.OverAllState; @@ -59,9 +58,6 @@ class GraphServiceImplTest { @Mock private LangfuseService langfuseReporter; - @Mock - private NotifierFactory notifierFactory; - @Mock private Span mockSpan; @@ -76,7 +72,7 @@ void setUp() throws Exception { StateGraph mockStateGraph = mock(StateGraph.class); when(mockStateGraph.compile(any())).thenReturn(compiledGraph); - graphService = new GraphServiceImpl(mockStateGraph, executor, multiTurnContextManager, langfuseReporter, notifierFactory); + graphService = new GraphServiceImpl(mockStateGraph, executor, multiTurnContextManager, langfuseReporter); setField(graphService, "compiledGraph", compiledGraph); diff --git a/data-agent-management/src/test/java/com/alibaba/cloud/ai/dataagent/service/notify/impl/DingTalkNotifierTest.java b/data-agent-management/src/test/java/com/alibaba/cloud/ai/dataagent/service/notify/impl/DingTalkNotifierTest.java index 4233b18f2..48dfb5c84 100644 --- a/data-agent-management/src/test/java/com/alibaba/cloud/ai/dataagent/service/notify/impl/DingTalkNotifierTest.java +++ b/data-agent-management/src/test/java/com/alibaba/cloud/ai/dataagent/service/notify/impl/DingTalkNotifierTest.java @@ -15,51 +15,52 @@ */ package com.alibaba.cloud.ai.dataagent.service.notify.impl; -import com.alibaba.cloud.ai.dataagent.properties.NotifyProperties; import com.alibaba.cloud.ai.dataagent.service.notify.NotificationInfo; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import java.lang.reflect.Field; import java.time.LocalDateTime; import static org.junit.jupiter.api.Assertions.*; class DingTalkNotifierTest { - private NotifyProperties properties; - private DingTalkNotifier notifier; @BeforeEach - void setUp() { - properties = new NotifyProperties(); - properties.setEnabled(true); - properties.setChannel("dingtalk"); - notifier = new DingTalkNotifier(properties); + void setUp() throws Exception { + notifier = new DingTalkNotifier(); + + // Use reflection to set @Value fields + setField(notifier, "webhookUrl", ""); + setField(notifier, "secretKey", ""); } - @Test - void testSupports() { - assertTrue(notifier.supports("dingtalk")); - assertFalse(notifier.supports("feishu")); - assertFalse(notifier.supports("wecom")); + private void setField(Object target, String fieldName, Object value) throws Exception { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); } @Test - void testNotifyWhenDisabled() { - properties.setEnabled(false); - // Should not throw exception - NotificationInfo info = new NotificationInfo("TestNode", "成功", LocalDateTime.now(), "t1", "a1"); - notifier.notify(info); - // If no exception, test passes + void testNotifyWhenNoWebhookUrl() throws Exception { + DingTalkNotifier notifierNoWebhook = new DingTalkNotifier(); + setField(notifierNoWebhook, "webhookUrl", ""); + + NotificationInfo info = new NotificationInfo("ReportGeneratorNode", "成功", LocalDateTime.now(), "t1", "a1"); + notifierNoWebhook.notify(info); } @Test void testNotifyBuildsCorrectContent() { - // This test verifies the content building logic indirectly NotificationInfo info = new NotificationInfo("ReportGeneratorNode", "成功", LocalDateTime.now(), "thread-1", "agent-1"); - // Should complete without exception (actual webhook call will fail without valid URL) notifier.notify(info); } + + @Test + void testNotifyStringMessage() { + notifier.notify("测试消息内容"); + } } From f64ec5c79b0418b73b1076f39c9753c3df1a507e Mon Sep 17 00:00:00 2001 From: yangyufan <936930947@qq.com> Date: Tue, 21 Apr 2026 21:39:50 +0800 Subject: [PATCH 3/3] feat(notification):drop useless files --- .../2026-04-21-notification-implementation.md | 386 ------------------ .../specs/2026-04-21-notification-design.md | 116 ------ 2 files changed, 502 deletions(-) delete mode 100644 docs/superpowers/plans/2026-04-21-notification-implementation.md delete mode 100644 docs/superpowers/specs/2026-04-21-notification-design.md diff --git a/docs/superpowers/plans/2026-04-21-notification-implementation.md b/docs/superpowers/plans/2026-04-21-notification-implementation.md deleted file mode 100644 index bf70dd632..000000000 --- a/docs/superpowers/plans/2026-04-21-notification-implementation.md +++ /dev/null @@ -1,386 +0,0 @@ -# Notification Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add notification functionality that sends DingTalk messages when workflow completes or after human intervention. - -**Architecture:** Factory pattern with `NotifierService` interface, `NotifierFactory` creates channel-specific implementations. Default implementation is `DingTalkNotifier` with HmacSHA256 signature verification. - -**Tech Stack:** Spring Boot, WebClient (for HTTP calls), standard Java crypto (HmacSHA256) - ---- - -## File Structure - -| File | Purpose | -|------|---------| -| `service/notify/NotificationInfo.java` | Notification data object | -| `service/notify/NotifierService.java` | Interface for notifier implementations | -| `service/notify/NotifierFactory.java` | Factory to create notifier by channel | -| `service/notify/impl/DingTalkNotifier.java` | DingTalk implementation | -| `properties/NotifyProperties.java` | Configuration properties | -| `service/graph/GraphServiceImpl.java` | Trigger notifications on completion | - ---- - -## Task 1: NotificationInfo & NotifierService - -**Files:** -- Create: `service/notify/NotificationInfo.java` -- Create: `service/notify/NotifierService.java` -- Test: `test/service/notify/NotificationInfoTest.java` - -- [ ] **Step 1: Create NotificationInfo.java** - -```java -package com.alibaba.cloud.ai.dataagent.service.notify; - -import java.time.LocalDateTime; - -public class NotificationInfo { - - private String nodeName; - - private String status; - - private LocalDateTime timestamp; - - private String threadId; - - private String agentId; - - public NotificationInfo() { - } - - public NotificationInfo(String nodeName, String status, LocalDateTime timestamp, String threadId, String agentId) { - this.nodeName = nodeName; - this.status = status; - this.timestamp = timestamp; - this.threadId = threadId; - this.agentId = agentId; - } - - // getters and setters -} -``` - -- [ ] **Step 2: Create NotifierService.java** - -```java -package com.alibaba.cloud.ai.dataagent.service.notify; - -public interface NotifierService { - - void notify(NotificationInfo info); - - boolean supports(String channel); -} -``` - -- [ ] **Step 3: Write unit tests for NotificationInfo** - -Run: `./mvnw test -Dtest=NotificationInfoTest` -Expected: Tests pass - ---- - -## Task 2: DingTalkNotifier Implementation - -**Files:** -- Create: `service/notify/impl/DingTalkNotifier.java` -- Create: `properties/NotifyProperties.java` -- Test: `test/service/notify/impl/DingTalkNotifierTest.java` - -- [ ] **Step 1: Create NotifyProperties.java** - -```java -package com.alibaba.cloud.ai.dataagent.properties; - -import org.springframework.boot.context.properties.ConfigurationProperties; - -@ConfigurationProperties(prefix = "spring.ai.alibaba.data-agent.notify") -public class NotifyProperties { - - private boolean enabled = false; - - private String channel = "dingtalk"; - - private DingTalkConfig dingtalk = new DingTalkConfig(); - - public boolean isEnabled() { - return enabled; - } - - public void setEnabled(boolean enabled) { - this.enabled = enabled; - } - - public String getChannel() { - return channel; - } - - public void setChannel(String channel) { - this.channel = channel; - } - - public DingTalkConfig getDingtalk() { - return dingtalk; - } - - public void setDingtalk(DingTalkConfig dingtalk) { - this.dingtalk = dingtalk; - } - - public static class DingTalkConfig { - - private String webhookUrl; - - private String secretKey; - - public String getWebhookUrl() { - return webhookUrl; - } - - public void setWebhookUrl(String webhookUrl) { - this.webhookUrl = webhookUrl; - } - - public String getSecretKey() { - return secretKey; - } - - public void setSecretKey(String secretKey) { - this.secretKey = secretKey; - } - } -} -``` - -- [ ] **Step 2: Create DingTalkNotifier.java** - -```java -package com.alibaba.cloud.ai.dataagent.service.notify.impl; - -import com.alibaba.cloud.ai.dataagent.properties.NotifyProperties; -import com.alibaba.cloud.ai.dataagent.service.notify.NotificationInfo; -import com.alibaba.cloud.ai.dataagent.service.notify.NotifierService; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; -import org.springframework.web.reactive.function.client.WebClient; - -import javax.crypto.Mac; -import javax.crypto.spec.SecretKeySpec; -import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; -import java.time.format.DateTimeFormatter; -import java.util.Base64; - -@Slf4j -@Component -public class DingTalkNotifier implements NotifierService { - - private static final String CHANNEL_NAME = "dingtalk"; - - private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); - - private final NotifyProperties properties; - - public DingTalkNotifier(NotifyProperties properties) { - this.properties = properties; - } - - @Override - public void notify(NotificationInfo info) { - if (!properties.isEnabled()) { - log.debug("Notification is disabled, skipping"); - return; - } - - String markdownContent = buildMarkdownContent(info); - sendDingTalkMessage(markdownContent); - } - - @Override - public boolean supports(String channel) { - return CHANNEL_NAME.equals(channel); - } - - private String buildMarkdownContent(NotificationInfo info) { - String statusEmoji = "成功".equals(info.getStatus()) ? "✓" : "✗"; - return String.format(""" - ## DataAgent 任务通知 - - - **状态**: %s %s - - **触发节点**: %s - - **时间**: %s - """, info.getStatus(), statusEmoji, info.getNodeName(), - info.getTimestamp().format(FORMATTER)); - } - - private void sendDingTalkMessage(String markdownContent) { - try { - String webhookUrl = properties.getDingtalk().getWebhookUrl(); - String secretKey = properties.getDingtalk().getSecretKey(); - - String sign = generateSign(secretKey); - String urlWithSign = webhookUrl + "&sign=" + URLEncoder.encode(sign, StandardCharsets.UTF_8); - - String requestBody = String.format(""" - { - "msgtype": "markdown", - "markdown": { - "title": "DataAgent 任务通知", - "text": %s - } - } - """, toJsonString(markdownContent)); - - WebClient.create(urlWithSign).post().bodyValue(requestBody).retrieve().bodyToMono(String.class) - .block(); - - log.info("DingTalk notification sent successfully"); - } - catch (Exception e) { - log.error("Failed to send DingTalk notification", e); - } - } - - private String generateSign(String secretKey) { - try { - Mac mac = Mac.getInstance("HmacSHA256"); - String timestamp = String.valueOf(System.currentTimeMillis()); - SecretKeySpec secretKeySpec = new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), "HmacSHA256"); - mac.init(secretKeySpec); - byte[] hash = mac.doFinal((timestamp + "\n" + secretKey).getBytes(StandardCharsets.UTF_8)); - return Base64.getEncoder().encodeToString(hash); - } - catch (Exception e) { - throw new RuntimeException("Failed to generate signature", e); - } - } - - private String toJsonString(String text) { - return "\"" + text.replace("\"", "\\\"").replace("\n", "\\n") + "\""; - } -} -``` - -- [ ] **Step 3: Write unit tests** - -Run: `./mvnw test -Dtest=DingTalkNotifierTest` -Expected: Tests pass - ---- - -## Task 3: NotifierFactory - -**Files:** -- Create: `service/notify/NotifierFactory.java` -- Modify: `config/DataAgentConfiguration.java` (add @EnableConfigurationProperties for NotifyProperties) - -- [ ] **Step 1: Create NotifierFactory.java** - -```java -package com.alibaba.cloud.ai.dataagent.service.notify; - -import com.alibaba.cloud.ai.dataagent.properties.NotifyProperties; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; - -import java.util.List; -import java.util.Map; -import java.util.function.Function; -import java.util.stream.Collectors; - -@Slf4j -@Component -public class NotifierFactory { - - private final NotifyProperties properties; - - private final Map notifierMap; - - public NotifierFactory(NotifyProperties properties, List notifierServices) { - this.properties = properties; - this.notifierMap = notifierServices.stream() - .collect(Collectors.toMap(this::extractChannel, Function.identity(), (a, b) -> a)); - } - - private String extractChannel(NotifierService service) { - // Assume service class name contains channel name - return service.getClass().getSimpleName().replace("Notifier", "").toLowerCase(); - } - - public NotifierService create() { - String channel = properties.getChannel(); - NotifierService notifier = notifierMap.get(channel); - if (notifier == null) { - log.warn("No notifier found for channel: {}, using first available", channel); - notifier = notifierMap.values().stream().findFirst().orElse(null); - } - return notifier; - } -} -``` - -- [ ] **Step 2: Enable NotifyProperties in DataAgentConfiguration** - -Add `NotifyProperties.class` to `@EnableConfigurationProperties` annotation. - -Run: `./mvnw compile` -Expected: Compiles successfully - ---- - -## Task 4: Configuration & Integration - -**Files:** -- Modify: `application.yml` (add notify config) -- Modify: `service/graph/GraphServiceImpl.java` (trigger notifications) - -- [ ] **Step 1: Add notify config to application.yml** - -```yaml -spring: - ai: - alibaba: - data-agent: - notify: - enabled: true - channel: dingtalk - dingtalk: - webhook-url: ${DINGTALK_WEBHOOK_URL:} - secret-key: ${DINGTALK_SECRET_KEY:} -``` - -- [ ] **Step 2: Integrate with GraphServiceImpl** - -Inject `NotifierFactory` into `GraphServiceImpl`. In `handleStreamComplete()`, create `NotificationInfo` and call `notifierFactory.create().notify(info)`. - -In `handleStreamError()`, call with status "失败". - -Run: `./mvnw compile` -Expected: Compiles successfully - ---- - -## Task 5: Unit Tests - -**Files:** -- Modify: `GraphServiceImplTest.java` - -- [ ] **Step 1: Write integration test for notification flow** - -Run: `./mvnw test -Dtest=GraphServiceImplTest` -Expected: Tests pass - ---- - -## Task 6: Commit - -- [ ] **Step 1: Commit changes** - -```bash -git add -A -git commit -m "feat: add notification system with DingTalk support" -``` diff --git a/docs/superpowers/specs/2026-04-21-notification-design.md b/docs/superpowers/specs/2026-04-21-notification-design.md deleted file mode 100644 index d4d368d26..000000000 --- a/docs/superpowers/specs/2026-04-21-notification-design.md +++ /dev/null @@ -1,116 +0,0 @@ -# 通知功能设计 - -## 1. 概述 - -为 DataAgent 添加通知功能,在工作流结束时向用户发送钉钉消息通知。采用工厂模式,支持用户扩展其他通知渠道(飞书、企业微信等)。 - -## 2. 触发时机 - -- **ReportGeneratorNode 完成时** - 分析任务正常结束 -- **HumanFeedbackNode 确认后** - 用户人工干预确认 - -## 3. 核心组件 - -### 3.1 NotificationInfo - -通知信息对象,包含以下字段: - -| 字段 | 类型 | 说明 | -|------|------|------| -| nodeName | String | 触发节点名称 | -| status | String | 任务状态(成功/失败) | -| timestamp | LocalDateTime | 触发时间 | -| threadId | String | 线程ID(可选) | -| agentId | String | 代理ID(可选) | - -### 3.2 NotifierService - -通知接口: - -```java -public interface NotifierService { - void notify(NotificationInfo info); - boolean supports(String channel); -} -``` - -### 3.3 NotifierFactory - -工厂类: -- 根据配置 `spring.ai.alibaba.data-agent.notify.channel` 创建对应通知实现 -- 使用 Spring SPI 机制加载用户自定义实现 -- 默认实现:DingTalkNotifier - -### 3.4 DingTalkNotifier - -钉钉通知实现: -- **安全验证**:HmacSHA256 签名 -- **消息格式**:Markdown -- **配置项**: - - `webhook-url`:钉钉机器人 Webhook 地址 - - `secret-key`:签名密钥 - -## 4. 配置项 - -```yaml -spring: - ai: - alibaba: - data-agent: - notify: - enabled: true - channel: dingtalk - dingtalk: - webhook-url: https://oapi.dingtalk.com/robot/send?access_token=xxx - secret-key: xxxxx -``` - -## 5. 消息格式 - -```markdown -## DataAgent 任务通知 - -- **状态**: 成功 ✓ -- **触发节点**: ReportGeneratorNode -- **时间**: 2026-04-21 15:30:25 -``` - -## 6. 数据流 - -``` -GraphServiceImpl - ├── ReportGeneratorNode.apply() 完成 - │ → NotifierFactory.create().notify(info) - │ - └── HumanFeedbackNode 处理完成 - → NotifierFactory.create().notify(info) -``` - -## 7. 文件清单 - -| 文件 | 说明 | -|------|------| -| `NotificationInfo.java` | 通知信息对象 | -| `NotifierService.java` | 通知服务接口 | -| `NotifierFactory.java` | 通知工厂类 | -| `DingTalkNotifier.java` | 钉钉通知实现 | -| `application.yml` | 配置项 | -| `GraphServiceImpl.java` | 触发通知调用 | - -## 8. 扩展方式 - -用户实现 `NotifierService` 接口并注册为 Spring Bean,工厂会自动加载: - -```java -@Component -public class CustomNotifier implements NotifierService { - @Override - public void notify(NotificationInfo info) { - // 自定义通知逻辑 - } - @Override - public boolean supports(String channel) { - return "custom".equals(channel); - } -} -```