includePackages = new HashSet<>();
347 | }
348 | }
--------------------------------------------------------------------------------
/src/main/java/com/nolimit35/springkit/filter/DefaultExceptionFilter.java:
--------------------------------------------------------------------------------
1 | package com.nolimit35.springkit.filter;
2 |
3 | import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
4 | import org.springframework.stereotype.Component;
5 |
6 | /**
7 | * Default implementation of ExceptionFilter
8 | */
9 | @Component
10 | @ConditionalOnMissingBean(ExceptionFilter.class)
11 | public class DefaultExceptionFilter implements ExceptionFilter {
12 | @Override
13 | public boolean shouldNotify(Throwable throwable) {
14 | // By default, notify for all exceptions
15 | return true;
16 | }
17 | }
--------------------------------------------------------------------------------
/src/main/java/com/nolimit35/springkit/filter/ExceptionFilter.java:
--------------------------------------------------------------------------------
1 | package com.nolimit35.springkit.filter;
2 |
3 | /**
4 | * Interface for filtering exceptions
5 | */
6 | public interface ExceptionFilter {
7 | /**
8 | * Determine whether to notify for the given exception
9 | *
10 | * @param throwable the exception to check
11 | * @return true if notification should be sent, false otherwise
12 | */
13 | boolean shouldNotify(Throwable throwable);
14 | }
--------------------------------------------------------------------------------
/src/main/java/com/nolimit35/springkit/formatter/DefaultNotificationFormatter.java:
--------------------------------------------------------------------------------
1 | package com.nolimit35.springkit.formatter;
2 |
3 | import com.nolimit35.springkit.config.ExceptionNotifyProperties;
4 | import com.nolimit35.springkit.model.CodeAuthorInfo;
5 | import com.nolimit35.springkit.model.ExceptionInfo;
6 | import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
7 | import org.springframework.stereotype.Component;
8 |
9 | import java.time.format.DateTimeFormatter;
10 | import java.util.Arrays;
11 | import java.util.stream.Collectors;
12 |
13 | /**
14 | * Default implementation of NotificationFormatter
15 | */
16 | @Component
17 | @ConditionalOnMissingBean(NotificationFormatter.class)
18 | public class DefaultNotificationFormatter implements NotificationFormatter {
19 | private final ExceptionNotifyProperties properties;
20 | public static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
21 |
22 | public DefaultNotificationFormatter(ExceptionNotifyProperties properties) {
23 | this.properties = properties;
24 | }
25 |
26 | @Override
27 | public String format(ExceptionInfo exceptionInfo) {
28 | StringBuilder sb = new StringBuilder();
29 |
30 | // Format title as Markdown heading
31 | String title = properties.getNotification().getTitleTemplate()
32 | .replace("${appName}", exceptionInfo.getAppName());
33 | sb.append("# ").append(title).append("\n\n");
34 |
35 | // Horizontal rule in Markdown
36 | sb.append("---\n\n");
37 |
38 | // Format exception details in Markdown
39 | sb.append("**异常时间:** ").append(exceptionInfo.getTime().format(DATE_FORMATTER)).append("\n\n");
40 | sb.append("**异常类型:** ").append(exceptionInfo.getType()).append("\n\n");
41 | sb.append("**异常描述:** ").append(exceptionInfo.getMessage()).append("\n\n");
42 | sb.append("**异常位置:** ").append(exceptionInfo.getLocation()).append("\n\n");
43 |
44 | // Format environment if available
45 | if (exceptionInfo.getEnvironment() != null && !exceptionInfo.getEnvironment().isEmpty()) {
46 | sb.append("**当前环境:** ").append(exceptionInfo.getEnvironment()).append("\n\n");
47 | }
48 |
49 | // Add branch information from GitHub or Gitee configuration
50 | String branch = null;
51 | if (properties.getGithub() != null && properties.getGithub().getToken() != null && properties.getGithub().getBranch() != null) {
52 | branch = properties.getGithub().getBranch();
53 | } else if (properties.getGitee() != null && properties.getGitee().getToken() != null && properties.getGitee().getBranch() != null) {
54 | branch = properties.getGitee().getBranch();
55 | }
56 |
57 | if (branch != null && !branch.isEmpty()) {
58 | sb.append("**当前分支:** ").append(branch).append("\n\n");
59 | }
60 |
61 | // Format author info if available
62 | CodeAuthorInfo authorInfo = exceptionInfo.getAuthorInfo();
63 | if (authorInfo != null) {
64 | sb.append("**代码提交者:** ").append(authorInfo.getName())
65 | .append(" (").append(authorInfo.getEmail()).append(")\n\n");
66 |
67 | if (authorInfo.getLastCommitTime() != null) {
68 | sb.append("**最后提交时间:** ").append(authorInfo.getLastCommitTime().format(DATE_FORMATTER)).append("\n\n");
69 | }
70 |
71 | if(authorInfo.getCommitMessage() != null){
72 | sb.append("**提交信息:** ").append(authorInfo.getCommitMessage());
73 | }
74 | }
75 |
76 | // Format trace ID if available
77 | if (exceptionInfo.getTraceId() != null && !exceptionInfo.getTraceId().isEmpty()) {
78 | sb.append("**TraceID:** ").append(exceptionInfo.getTraceId()).append("\n\n");
79 |
80 | // Include CLS trace URL as a clickable link if available
81 | if (exceptionInfo.getTraceUrl() != null && !exceptionInfo.getTraceUrl().isEmpty()) {
82 | sb.append("**云日志链路:** [点击查看日志](").append(exceptionInfo.getTraceUrl()).append(")\n\n");
83 | }
84 | }
85 |
86 | sb.append("---\n\n");
87 |
88 | // Format stacktrace if enabled
89 | if (properties.getNotification().isIncludeStacktrace() && exceptionInfo.getStacktrace() != null) {
90 | sb.append("### 堆栈信息:\n\n");
91 | sb.append("```java\n");
92 |
93 | // Limit stacktrace lines if configured
94 | int maxLines = properties.getNotification().getMaxStacktraceLines();
95 | if (maxLines > 0) {
96 | String[] lines = exceptionInfo.getStacktrace().split("\n");
97 | String limitedStacktrace = Arrays.stream(lines)
98 | .limit(maxLines)
99 | .collect(Collectors.joining("\n"));
100 | sb.append(limitedStacktrace);
101 |
102 | if (lines.length > maxLines) {
103 | sb.append("\n... (").append(lines.length - maxLines).append(" more lines)");
104 | }
105 | } else {
106 | sb.append(exceptionInfo.getStacktrace());
107 | }
108 |
109 | sb.append("\n```");
110 | }
111 |
112 | return sb.toString();
113 | }
114 | }
--------------------------------------------------------------------------------
/src/main/java/com/nolimit35/springkit/formatter/NotificationFormatter.java:
--------------------------------------------------------------------------------
1 | package com.nolimit35.springkit.formatter;
2 |
3 | import com.nolimit35.springkit.model.ExceptionInfo;
4 |
5 | /**
6 | * Interface for formatting exception notifications
7 | */
8 | public interface NotificationFormatter {
9 | /**
10 | * Format exception information into notification content
11 | *
12 | * @param exceptionInfo the exception information
13 | * @return formatted notification content
14 | */
15 | String format(ExceptionInfo exceptionInfo);
16 | }
--------------------------------------------------------------------------------
/src/main/java/com/nolimit35/springkit/model/CodeAuthorInfo.java:
--------------------------------------------------------------------------------
1 | package com.nolimit35.springkit.model;
2 |
3 | import lombok.Builder;
4 | import lombok.Data;
5 |
6 | import java.time.LocalDateTime;
7 |
8 | /**
9 | * Code author information model
10 | */
11 | @Data
12 | @Builder
13 | public class CodeAuthorInfo {
14 | /**
15 | * Author name
16 | */
17 | private String name;
18 |
19 | /**
20 | * Author email
21 | */
22 | private String email;
23 |
24 | /**
25 | * Last commit time
26 | */
27 | private LocalDateTime lastCommitTime;
28 |
29 | /**
30 | * Source file name
31 | */
32 | private String fileName;
33 |
34 | /**
35 | * Source line number
36 | */
37 | private int lineNumber;
38 |
39 | /**
40 | * Commit message
41 | */
42 | private String commitMessage;
43 | }
--------------------------------------------------------------------------------
/src/main/java/com/nolimit35/springkit/model/ExceptionInfo.java:
--------------------------------------------------------------------------------
1 | package com.nolimit35.springkit.model;
2 |
3 | import lombok.Builder;
4 | import lombok.Data;
5 |
6 | import java.time.LocalDateTime;
7 |
8 | /**
9 | * Exception information model
10 | */
11 | @Data
12 | @Builder
13 | public class ExceptionInfo {
14 | /**
15 | * Exception time
16 | */
17 | private LocalDateTime time;
18 |
19 | /**
20 | * Exception type
21 | */
22 | private String type;
23 |
24 | /**
25 | * Exception message
26 | */
27 | private String message;
28 |
29 | /**
30 | * Exception location (file and line number)
31 | */
32 | private String location;
33 |
34 | /**
35 | * Exception stacktrace
36 | */
37 | private String stacktrace;
38 |
39 | /**
40 | * Trace ID
41 | */
42 | private String traceId;
43 |
44 | /**
45 | * Application name
46 | */
47 | private String appName;
48 |
49 | /**
50 | * Current environment (e.g., dev, test, prod)
51 | */
52 | private String environment;
53 |
54 | /**
55 | * Code author information
56 | */
57 | private CodeAuthorInfo authorInfo;
58 |
59 | /**
60 | * Trace URL
61 | */
62 | private String traceUrl;
63 | }
--------------------------------------------------------------------------------
/src/main/java/com/nolimit35/springkit/monitor/Monitor.java:
--------------------------------------------------------------------------------
1 | package com.nolimit35.springkit.monitor;
2 |
3 | import java.time.LocalDateTime;
4 | import java.util.Arrays;
5 | import java.util.stream.Collectors;
6 |
7 | import org.slf4j.Logger;
8 | import org.slf4j.LoggerFactory;
9 | import org.springframework.beans.factory.annotation.Autowired;
10 | import org.springframework.beans.factory.annotation.Value;
11 | import org.springframework.stereotype.Component;
12 |
13 | import com.nolimit35.springkit.config.ExceptionNotifyProperties;
14 | import com.nolimit35.springkit.model.ExceptionInfo;
15 | import com.nolimit35.springkit.notification.NotificationProviderManager;
16 | import com.nolimit35.springkit.trace.TraceInfoProvider;
17 |
18 | import lombok.extern.slf4j.Slf4j;
19 |
20 | /**
21 | * 监控工具,用于日志记录和发送通知
22 | *
23 | * 该类提供类似于SLF4J日志的方法,同时通过exception-notify中配置的通知渠道发送通知
24 | */
25 | @Slf4j
26 | @Component
27 | public class Monitor {
28 |
29 | private static NotificationProviderManager notificationManager;
30 | private static ExceptionNotifyProperties properties;
31 | private static TraceInfoProvider traceInfoProvider;
32 |
33 | @Value("${spring.application.name:unknown}")
34 | private String applicationName;
35 |
36 | private static String appName = "unknown";
37 |
38 | @Autowired
39 | public Monitor(NotificationProviderManager notificationManager,
40 | @Value("${spring.application.name:unknown}") String applicationName,
41 | ExceptionNotifyProperties properties,
42 | TraceInfoProvider traceInfoProvider) {
43 | Monitor.notificationManager = notificationManager;
44 | Monitor.appName = applicationName;
45 | Monitor.properties = properties;
46 | Monitor.traceInfoProvider = traceInfoProvider;
47 | }
48 |
49 | /**
50 | * 记录信息消息并发送通知
51 | *
52 | * @param message 信息消息
53 | */
54 | public static void info(String message) {
55 | log.info(message);
56 | // 获取调用堆栈
57 | StackTraceElement[] stackTrace = Thread.currentThread().getStackTrace();
58 | // 索引2通常是调用者(0是getStackTrace,1是当前方法)
59 | StackTraceElement caller = stackTrace.length > 2 ? stackTrace[2] : null;
60 | sendNotification("INFO: " + message, null, caller);
61 | }
62 |
63 | /**
64 | * 使用自定义日志记录器记录信息消息并发送通知
65 | *
66 | * @param logger 要使用的日志记录器
67 | * @param message 信息消息
68 | */
69 | public static void info(Logger logger, String message) {
70 | logger.info(message);
71 | // 获取调用堆栈
72 | StackTraceElement[] stackTrace = Thread.currentThread().getStackTrace();
73 | // 索引2通常是调用者(0是getStackTrace,1是当前方法)
74 | StackTraceElement caller = stackTrace.length > 2 ? stackTrace[2] : null;
75 | sendNotification("INFO: " + message, null, caller);
76 | }
77 |
78 | /**
79 | * 记录带有异常的信息消息并发送通知
80 | *
81 | * @param message 信息消息
82 | * @param throwable 异常
83 | */
84 | public static void info(String message, Throwable throwable) {
85 | log.info(message, throwable);
86 | // 异常情况下,优先使用异常的堆栈信息,不需要额外获取调用者位置
87 | sendNotification("INFO: " + message, throwable);
88 | }
89 |
90 | /**
91 | * 使用自定义日志记录器记录带有异常的信息消息并发送通知
92 | *
93 | * @param logger 要使用的日志记录器
94 | * @param message 信息消息
95 | * @param throwable 异常
96 | */
97 | public static void info(Logger logger, String message, Throwable throwable) {
98 | logger.info(message, throwable);
99 | // 异常情况下,优先使用异常的堆栈信息,不需要额外获取调用者位置
100 | sendNotification("INFO: " + message, throwable);
101 | }
102 |
103 | /**
104 | * 记录警告消息并发送通知
105 | *
106 | * @param message 警告消息
107 | */
108 | public static void warn(String message) {
109 | log.warn(message);
110 | // 获取调用堆栈
111 | StackTraceElement[] stackTrace = Thread.currentThread().getStackTrace();
112 | // 索引2通常是调用者(0是getStackTrace,1是当前方法)
113 | StackTraceElement caller = stackTrace.length > 2 ? stackTrace[2] : null;
114 | sendNotification("WARN: " + message, null, caller);
115 | }
116 |
117 | /**
118 | * 记录带有异常的警告消息并发送通知
119 | *
120 | * @param message 警告消息
121 | * @param throwable 异常
122 | */
123 | public static void warn(String message, Throwable throwable) {
124 | log.warn(message, throwable);
125 | // 异常情况下,优先使用异常的堆栈信息,不需要额外获取调用者位置
126 | sendNotification("WARN: " + message, throwable);
127 | }
128 |
129 | /**
130 | * 使用自定义日志记录器记录警告消息并发送通知
131 | *
132 | * @param logger 要使用的日志记录器
133 | * @param message 警告消息
134 | */
135 | public static void warn(Logger logger, String message) {
136 | logger.warn(message);
137 | // 获取调用堆栈
138 | StackTraceElement[] stackTrace = Thread.currentThread().getStackTrace();
139 | // 索引2通常是调用者(0是getStackTrace,1是当前方法)
140 | StackTraceElement caller = stackTrace.length > 2 ? stackTrace[2] : null;
141 | sendNotification("WARN: " + message, null, caller);
142 | }
143 |
144 | /**
145 | * 使用自定义日志记录器记录带有异常的警告消息并发送通知
146 | *
147 | * @param logger 要使用的日志记录器
148 | * @param message 警告消息
149 | * @param throwable 异常
150 | */
151 | public static void warn(Logger logger, String message, Throwable throwable) {
152 | logger.warn(message, throwable);
153 | // 异常情况下,优先使用异常的堆栈信息,不需要额外获取调用者位置
154 | sendNotification("WARN: " + message, throwable);
155 | }
156 |
157 | /**
158 | * 记录错误消息并发送通知
159 | *
160 | * @param message 错误消息
161 | */
162 | public static void error(String message) {
163 | log.error(message);
164 | // 获取调用堆栈
165 | StackTraceElement[] stackTrace = Thread.currentThread().getStackTrace();
166 | // 索引2通常是调用者(0是getStackTrace,1是当前方法)
167 | StackTraceElement caller = stackTrace.length > 2 ? stackTrace[2] : null;
168 | sendNotification(message, null, caller);
169 | }
170 |
171 | /**
172 | * 记录带有异常的错误消息并发送通知
173 | *
174 | * @param message 错误消息
175 | * @param throwable 异常
176 | */
177 | public static void error(String message, Throwable throwable) {
178 | log.error(message, throwable);
179 | // 异常情况下,优先使用异常的堆栈信息,不需要额外获取调用者位置
180 | sendNotification(message, throwable);
181 | }
182 |
183 | /**
184 | * 使用自定义日志记录器记录错误消息并发送通知
185 | *
186 | * @param logger 要使用的日志记录器
187 | * @param message 错误消息
188 | */
189 | public static void error(Logger logger, String message) {
190 | logger.error(message);
191 | // 获取调用堆栈
192 | StackTraceElement[] stackTrace = Thread.currentThread().getStackTrace();
193 | // 索引2通常是调用者(0是getStackTrace,1是当前方法)
194 | StackTraceElement caller = stackTrace.length > 2 ? stackTrace[2] : null;
195 | sendNotification(message, null, caller);
196 | }
197 |
198 | /**
199 | * 使用自定义日志记录器记录带有异常的错误消息并发送通知
200 | *
201 | * @param logger 要使用的日志记录器
202 | * @param message 错误消息
203 | * @param throwable 异常
204 | */
205 | public static void error(Logger logger, String message, Throwable throwable) {
206 | logger.error(message, throwable);
207 | // 异常情况下,优先使用异常的堆栈信息,不需要额外获取调用者位置
208 | sendNotification(message, throwable);
209 | }
210 |
211 | /**
212 | * 获取指定类的日志记录器
213 | *
214 | * @param clazz 要获取日志记录器的类
215 | * @return 日志记录器
216 | */
217 | public static Logger getLogger(Class> clazz) {
218 | return LoggerFactory.getLogger(clazz);
219 | }
220 |
221 | /**
222 | * 获取指定名称的日志记录器
223 | *
224 | * @param name 要获取日志记录器的名称
225 | * @return 日志记录器
226 | */
227 | public static Logger getLogger(String name) {
228 | return LoggerFactory.getLogger(name);
229 | }
230 |
231 | private static void sendNotification(String message, Throwable throwable) {
232 | sendNotification(message, throwable, null);
233 | }
234 |
235 | private static void sendNotification(String message, Throwable throwable, StackTraceElement caller) {
236 | if (notificationManager == null) {
237 | log.warn("Monitor尚未完全初始化,通知将不会被发送");
238 | return;
239 | }
240 |
241 | try {
242 | ExceptionInfo exceptionInfo = buildExceptionInfo(message, throwable, caller);
243 |
244 | // 通过通知管理器发送通知
245 | notificationManager.sendNotification(exceptionInfo);
246 | } catch (Exception e) {
247 | log.error("发送通知失败", e);
248 | }
249 | }
250 |
251 | private static ExceptionInfo buildExceptionInfo(String message, Throwable throwable) {
252 | return buildExceptionInfo(message, throwable, null);
253 | }
254 |
255 | private static ExceptionInfo buildExceptionInfo(String message, Throwable throwable, StackTraceElement caller) {
256 | ExceptionInfo.ExceptionInfoBuilder builder = ExceptionInfo.builder()
257 | .time(LocalDateTime.now())
258 | .appName(appName)
259 | .message(message);
260 |
261 | // 如果配置中启用了trace,获取traceId
262 | String traceId = null;
263 | if (properties != null && properties.getTrace().isEnabled() && traceInfoProvider != null) {
264 | // 使用TraceInfoProvider获取traceId
265 | traceId = traceInfoProvider.getTraceId();
266 |
267 | if (traceId != null && !traceId.isEmpty()) {
268 | builder.traceId(traceId);
269 |
270 | // 使用TraceInfoProvider生成trace URL
271 | String traceUrl = traceInfoProvider.generateTraceUrl(traceId);
272 | if (traceUrl != null) {
273 | builder.traceUrl(traceUrl);
274 | }
275 | }
276 | }
277 |
278 | if (throwable != null) {
279 | builder.type(throwable.getClass().getName())
280 | .stacktrace(getStackTraceAsString(throwable));
281 |
282 | // 如果有异常堆栈信息,查找可用的第一个应用程序元素
283 | StackTraceElement[] stackTraceElements = throwable.getStackTrace();
284 | if (stackTraceElements != null && stackTraceElements.length > 0) {
285 | // 仅使用堆栈跟踪中的第一个元素
286 | StackTraceElement element = stackTraceElements[0];
287 | builder.location(element.getClassName() + "." + element.getMethodName() +
288 | "(" + element.getFileName() + ":" + element.getLineNumber() + ")");
289 | }
290 | } else {
291 | builder.type("MonitoredMessage");
292 |
293 | // 如果没有异常但有调用者信息,使用调用者信息
294 | if (caller != null) {
295 | builder.location(caller.getClassName() + "." + caller.getMethodName() +
296 | "(" + caller.getFileName() + ":" + caller.getLineNumber() + ")");
297 | }
298 | }
299 |
300 | return builder.build();
301 | }
302 |
303 |
304 |
305 | private static String getStackTraceAsString(Throwable throwable) {
306 | if (throwable == null) {
307 | return "";
308 | }
309 |
310 | return Arrays.stream(throwable.getStackTrace())
311 | .limit(10) // 限制堆栈深度
312 | .map(StackTraceElement::toString)
313 | .collect(Collectors.joining("\n"));
314 | }
315 | }
--------------------------------------------------------------------------------
/src/main/java/com/nolimit35/springkit/notification/AbstractNotificationProvider.java:
--------------------------------------------------------------------------------
1 | package com.nolimit35.springkit.notification;
2 |
3 | import com.nolimit35.springkit.config.ExceptionNotifyProperties;
4 | import com.nolimit35.springkit.model.ExceptionInfo;
5 | import lombok.extern.slf4j.Slf4j;
6 |
7 | /**
8 | * Abstract base class for notification providers
9 | * Makes it easier to implement custom notification providers
10 | */
11 | @Slf4j
12 | public abstract class AbstractNotificationProvider implements NotificationProvider {
13 | protected final ExceptionNotifyProperties properties;
14 |
15 | public AbstractNotificationProvider(ExceptionNotifyProperties properties) {
16 | this.properties = properties;
17 | }
18 |
19 | @Override
20 | public boolean sendNotification(ExceptionInfo exceptionInfo) {
21 | if (!isEnabled()) {
22 | log.debug("{} notification provider is not enabled", getProviderName());
23 | return false;
24 | }
25 |
26 | try {
27 | return doSendNotification(exceptionInfo);
28 | } catch (Exception e) {
29 | log.error("Error sending notification through {}: {}",
30 | getProviderName(), e.getMessage(), e);
31 | return false;
32 | }
33 | }
34 |
35 | /**
36 | * Implement actual notification sending logic in subclasses
37 | *
38 | * @param exceptionInfo the complete exception information
39 | * @return true if notification was sent successfully
40 | * @throws Exception if an error occurs during sending
41 | */
42 | protected abstract boolean doSendNotification(ExceptionInfo exceptionInfo) throws Exception;
43 |
44 | /**
45 | * Get provider name for logging purposes
46 | *
47 | * @return name of the provider
48 | */
49 | protected String getProviderName() {
50 | return this.getClass().getSimpleName();
51 | }
52 | }
--------------------------------------------------------------------------------
/src/main/java/com/nolimit35/springkit/notification/NotificationProvider.java:
--------------------------------------------------------------------------------
1 | package com.nolimit35.springkit.notification;
2 |
3 | import com.nolimit35.springkit.model.ExceptionInfo;
4 |
5 | /**
6 | * Interface for notification providers
7 | */
8 | public interface NotificationProvider {
9 | /**
10 | * Send a notification
11 | *
12 | * @param exceptionInfo the complete exception information object
13 | * @return true if notification was sent successfully, false otherwise
14 | */
15 | boolean sendNotification(ExceptionInfo exceptionInfo);
16 |
17 | /**
18 | * Check if this provider is enabled
19 | *
20 | * @return true if the provider is enabled, false otherwise
21 | */
22 | boolean isEnabled();
23 | }
--------------------------------------------------------------------------------
/src/main/java/com/nolimit35/springkit/notification/NotificationProviderManager.java:
--------------------------------------------------------------------------------
1 | package com.nolimit35.springkit.notification;
2 |
3 | import com.nolimit35.springkit.model.ExceptionInfo;
4 | import lombok.extern.slf4j.Slf4j;
5 | import org.springframework.stereotype.Component;
6 |
7 | import java.util.List;
8 | import java.util.concurrent.atomic.AtomicBoolean;
9 |
10 | /**
11 | * Manager for notification providers
12 | */
13 | @Slf4j
14 | @Component
15 | public class NotificationProviderManager {
16 | private final List providers;
17 |
18 | public NotificationProviderManager(List providers) {
19 | this.providers = providers;
20 |
21 | if (log.isInfoEnabled()) {
22 | log.info("Initialized NotificationProviderManager with {} provider(s)", providers.size());
23 | for (NotificationProvider provider : providers) {
24 | log.info("Found notification provider: {} (enabled: {})",
25 | provider.getClass().getSimpleName(),
26 | provider.isEnabled());
27 | }
28 | }
29 | }
30 |
31 | /**
32 | * Send notification through all enabled providers
33 | *
34 | * @param exceptionInfo the complete exception information
35 | * @return true if at least one provider sent the notification successfully
36 | */
37 | public boolean sendNotification(ExceptionInfo exceptionInfo) {
38 | if (providers.isEmpty()) {
39 | log.warn("No notification providers available");
40 | return false;
41 | }
42 |
43 | AtomicBoolean atLeastOneSent = new AtomicBoolean(false);
44 |
45 | // Try sending through all enabled providers
46 | providers.stream()
47 | .filter(NotificationProvider::isEnabled)
48 | .forEach(provider -> {
49 | try {
50 | boolean sent = provider.sendNotification(exceptionInfo);
51 | if (sent) {
52 | atLeastOneSent.set(true);
53 | log.info("Notification sent successfully through {}",
54 | provider.getClass().getSimpleName());
55 | } else {
56 | log.warn("Failed to send notification through {}",
57 | provider.getClass().getSimpleName());
58 | }
59 | } catch (Exception e) {
60 | log.error("Error sending notification through {}: {}",
61 | provider.getClass().getSimpleName(), e.getMessage(), e);
62 | }
63 | });
64 |
65 | return atLeastOneSent.get();
66 | }
67 |
68 | /**
69 | * Get all available providers
70 | *
71 | * @return list of notification providers
72 | */
73 | public List getProviders() {
74 | return providers;
75 | }
76 | }
--------------------------------------------------------------------------------
/src/main/java/com/nolimit35/springkit/notification/provider/DingTalkNotificationProvider.java:
--------------------------------------------------------------------------------
1 | package com.nolimit35.springkit.notification.provider;
2 |
3 | import com.fasterxml.jackson.databind.ObjectMapper;
4 | import com.nolimit35.springkit.config.ExceptionNotifyProperties;
5 | import com.nolimit35.springkit.formatter.NotificationFormatter;
6 | import com.nolimit35.springkit.model.ExceptionInfo;
7 | import com.nolimit35.springkit.notification.AbstractNotificationProvider;
8 | import lombok.extern.slf4j.Slf4j;
9 | import okhttp3.*;
10 | import org.springframework.stereotype.Component;
11 | import org.springframework.util.StringUtils;
12 |
13 | import java.util.Collections;
14 | import java.util.HashMap;
15 | import java.util.List;
16 | import java.util.Map;
17 |
18 | /**
19 | * DingTalk implementation of NotificationProvider
20 | */
21 | @Slf4j
22 | @Component
23 | public class DingTalkNotificationProvider extends AbstractNotificationProvider {
24 | private final OkHttpClient httpClient;
25 | private final ObjectMapper objectMapper;
26 | private final NotificationFormatter formatter;
27 | private static final MediaType JSON = MediaType.parse("application/json; charset=utf-8");
28 |
29 | public DingTalkNotificationProvider(ExceptionNotifyProperties properties, NotificationFormatter formatter) {
30 | super(properties);
31 | this.httpClient = new OkHttpClient();
32 | this.objectMapper = new ObjectMapper();
33 | this.formatter = formatter;
34 | }
35 |
36 | @Override
37 | protected boolean doSendNotification(ExceptionInfo exceptionInfo) throws Exception {
38 | String webhook = properties.getDingtalk().getWebhook();
39 |
40 | // Format the exception info into a notification
41 | String content = formatter.format(exceptionInfo);
42 |
43 | // Extract title from the first line of content
44 | String title = content.split("\n")[0];
45 |
46 | // Build request body
47 | Map requestBody = new HashMap<>();
48 | requestBody.put("msgtype", "markdown");
49 |
50 | // Format content as markdown
51 | String formattedContent = "#### " + title + "\n" + content;
52 |
53 | Map text = new HashMap<>();
54 | text.put("text", formattedContent);
55 | text.put("title", "异常告警");
56 |
57 |
58 | // 添加处理人信息
59 | if (exceptionInfo.getAuthorInfo() != null && StringUtils.hasText(exceptionInfo.getAuthorInfo().getEmail()) &&
60 | properties.getDingtalk().getAt() != null && properties.getDingtalk().getAt().isEnabled()) {
61 |
62 | if (properties.getDingtalk().getAt().getUserIdMappingGitEmail() != null
63 | && !properties.getDingtalk().getAt().getUserIdMappingGitEmail().isEmpty()) {
64 | // at 具体用户
65 | String dingUserId = properties.getDingtalk().getAt().getUserIdMappingGitEmail().entrySet().stream()
66 | // 根据邮箱匹配对应的企微用户id
67 | .filter(entry -> entry.getValue().contains(exceptionInfo.getAuthorInfo().getEmail()))
68 | .map(Map.Entry::getKey)
69 | .findFirst()
70 | .orElse(null);
71 |
72 | if (StringUtils.hasText(dingUserId)) {
73 | Map> atUserId = new HashMap<>();
74 | atUserId.put("atUserIds", Collections.singletonList(dingUserId));
75 | requestBody.put("at", atUserId);
76 | }
77 | }
78 | }
79 |
80 | requestBody.put("markdown", text);
81 |
82 |
83 | String jsonBody = objectMapper.writeValueAsString(requestBody);
84 |
85 | Request request = new Request.Builder()
86 | .url(webhook)
87 | .header("Content-Type", "application/json")
88 | .post(RequestBody.create(jsonBody, JSON))
89 | .build();
90 |
91 | try (Response response = httpClient.newCall(request).execute()) {
92 | if (!response.isSuccessful()) {
93 | log.error("Failed to send DingTalk notification: {}", response.code());
94 | return false;
95 | }
96 |
97 | String responseBody = response.body().string();
98 | log.debug("DingTalk response: {}", responseBody);
99 | return true;
100 | }
101 | }
102 |
103 | @Override
104 | public boolean isEnabled() {
105 | return properties.isEnabled() &&
106 | properties.getDingtalk().getWebhook() != null &&
107 | !properties.getDingtalk().getWebhook().isEmpty();
108 | }
109 | }
--------------------------------------------------------------------------------
/src/main/java/com/nolimit35/springkit/notification/provider/FeishuNotificationProvider.java:
--------------------------------------------------------------------------------
1 | package com.nolimit35.springkit.notification.provider;
2 |
3 | import com.fasterxml.jackson.databind.ObjectMapper;
4 | import com.nolimit35.springkit.config.ExceptionNotifyProperties;
5 | import com.nolimit35.springkit.formatter.NotificationFormatter;
6 | import com.nolimit35.springkit.model.CodeAuthorInfo;
7 | import com.nolimit35.springkit.model.ExceptionInfo;
8 | import com.nolimit35.springkit.notification.AbstractNotificationProvider;
9 | import lombok.extern.slf4j.Slf4j;
10 | import okhttp3.*;
11 | import org.springframework.stereotype.Component;
12 | import org.springframework.util.StringUtils;
13 |
14 | import java.util.*;
15 | import java.util.stream.Collectors;
16 |
17 | import static com.nolimit35.springkit.formatter.DefaultNotificationFormatter.DATE_FORMATTER;
18 |
19 | /**
20 | * Feishu implementation of NotificationProvider
21 | */
22 | @Slf4j
23 | @Component
24 | public class FeishuNotificationProvider extends AbstractNotificationProvider {
25 | private final OkHttpClient httpClient;
26 | private final ObjectMapper objectMapper;
27 | private final NotificationFormatter formatter;
28 | private static final MediaType JSON = MediaType.parse("application/json; charset=utf-8");
29 |
30 | public FeishuNotificationProvider(ExceptionNotifyProperties properties, NotificationFormatter formatter) {
31 | super(properties);
32 | this.httpClient = new OkHttpClient();
33 | this.objectMapper = new ObjectMapper();
34 | this.formatter = formatter;
35 | }
36 |
37 | @Override
38 | protected boolean doSendNotification(ExceptionInfo exceptionInfo) throws Exception {
39 | String webhook = properties.getFeishu().getWebhook();
40 |
41 | // 飞书机器人不能直接支持 markdown 格式,不使用 formatter 格式化,直接使用原始的异常信息
42 | // String content = formatter.format(exceptionInfo);
43 | StringBuilder sb = new StringBuilder();
44 |
45 | // Format title as Markdown heading
46 | String title = properties.getNotification().getTitleTemplate()
47 | .replace("${appName}", exceptionInfo.getAppName());
48 | sb.append(title).append("\n");
49 |
50 | // Format exception details in Markdown
51 | sb.append("异常时间:").append(exceptionInfo.getTime().format(DATE_FORMATTER)).append("\n");
52 | sb.append("异常类型:").append(exceptionInfo.getType()).append("\n");
53 | sb.append("异常描述:").append(exceptionInfo.getMessage()).append("\n");
54 | sb.append("异常位置:").append(exceptionInfo.getLocation()).append("\n");
55 |
56 | // Format environment if available
57 | if (exceptionInfo.getEnvironment() != null && !exceptionInfo.getEnvironment().isEmpty()) {
58 | sb.append("当前环境:").append(exceptionInfo.getEnvironment()).append("\n");
59 | }
60 |
61 | // Add branch information from GitHub or Gitee configuration
62 | String branch = null;
63 | if (properties.getGithub() != null && properties.getGithub().getToken() != null && properties.getGithub().getBranch() != null) {
64 | branch = properties.getGithub().getBranch();
65 | } else if (properties.getGitee() != null && properties.getGitee().getToken() != null && properties.getGitee().getBranch() != null) {
66 | branch = properties.getGitee().getBranch();
67 | }
68 |
69 | if (branch != null && !branch.isEmpty()) {
70 | sb.append("当前分支:").append(branch).append("\n");
71 | }
72 |
73 | // Format author info if available
74 | CodeAuthorInfo authorInfo = exceptionInfo.getAuthorInfo();
75 | if (authorInfo != null) {
76 | sb.append("代码提交者:").append(authorInfo.getName())
77 | .append(" (").append(authorInfo.getEmail()).append(")\n");
78 |
79 | if (authorInfo.getLastCommitTime() != null) {
80 | sb.append("最后提交时间:").append(authorInfo.getLastCommitTime().format(DATE_FORMATTER)).append("\n");
81 | }
82 |
83 | if(authorInfo.getCommitMessage() != null){
84 | sb.append("提交信息:").append(authorInfo.getCommitMessage());
85 | }
86 | }
87 |
88 | // Format trace ID if available
89 | if (exceptionInfo.getTraceId() != null && !exceptionInfo.getTraceId().isEmpty()) {
90 | sb.append("TraceID:").append(exceptionInfo.getTraceId()).append("\n");
91 |
92 | // Include CLS trace URL as a clickable link if available
93 | if (exceptionInfo.getTraceUrl() != null && !exceptionInfo.getTraceUrl().isEmpty()) {
94 | sb.append("云日志链路:").append(exceptionInfo.getTraceUrl()).append("\n");
95 | }
96 | }
97 |
98 | // Format stacktrace if enabled
99 | if (properties.getNotification().isIncludeStacktrace() && exceptionInfo.getStacktrace() != null) {
100 | sb.append("堆栈信息:\n");
101 |
102 | // Limit stacktrace lines if configured
103 | int maxLines = properties.getNotification().getMaxStacktraceLines();
104 | if (maxLines > 0) {
105 | String[] lines = exceptionInfo.getStacktrace().split("\n");
106 | String limitedStacktrace = Arrays.stream(lines)
107 | .limit(maxLines)
108 | .collect(Collectors.joining("\n"));
109 | sb.append(limitedStacktrace);
110 |
111 | if (lines.length > maxLines) {
112 | sb.append("\n... (").append(lines.length - maxLines).append(" more lines)");
113 | }
114 | } else {
115 | sb.append(exceptionInfo.getStacktrace());
116 | }
117 | }
118 |
119 | // 添加处理人信息
120 | if (exceptionInfo.getAuthorInfo() != null && StringUtils.hasText(exceptionInfo.getAuthorInfo().getEmail()) &&
121 | properties.getFeishu().getAt() != null && properties.getFeishu().getAt().isEnabled()) {
122 | if (properties.getFeishu().getAt().getOpenIdMappingGitEmail() != null
123 | && !properties.getFeishu().getAt().getOpenIdMappingGitEmail().isEmpty()) {
124 | // at 具体用户
125 | String feishuOpenId = properties.getFeishu().getAt().getOpenIdMappingGitEmail().entrySet().stream()
126 | // 根据邮箱匹配对应的企微用户id
127 | .filter(entry -> entry.getValue().contains(exceptionInfo.getAuthorInfo().getEmail()))
128 | .map(Map.Entry::getKey)
129 | .findFirst()
130 | .orElse(null);
131 |
132 | if (StringUtils.hasText(feishuOpenId)) {
133 | sb.append(String.format("\n处理人: 名字", feishuOpenId));
134 | }
135 | }
136 | }
137 |
138 | // Build request body according to Feishu bot API
139 | Map requestBody = new HashMap<>();
140 | requestBody.put("msg_type", "text");
141 |
142 | Map contentMap = new HashMap<>();
143 | contentMap.put("text", sb.toString());
144 | requestBody.put("content", contentMap);
145 |
146 | String jsonBody = objectMapper.writeValueAsString(requestBody);
147 |
148 | Request request = new Request.Builder()
149 | .url(webhook)
150 | .header("Content-Type", "application/json")
151 | .post(RequestBody.create(jsonBody, JSON))
152 | .build();
153 |
154 | try (Response response = httpClient.newCall(request).execute()) {
155 | if (!response.isSuccessful()) {
156 | log.error("Failed to send Feishu notification: {}", response.code());
157 | return false;
158 | }
159 |
160 | String responseBody = response.body().string();
161 | log.debug("Feishu response: {}", responseBody);
162 | return true;
163 | }
164 | }
165 |
166 | @Override
167 | public boolean isEnabled() {
168 | return properties.isEnabled() &&
169 | properties.getFeishu().getWebhook() != null &&
170 | !properties.getFeishu().getWebhook().isEmpty();
171 | }
172 | }
--------------------------------------------------------------------------------
/src/main/java/com/nolimit35/springkit/notification/provider/WeChatWorkNotificationProvider.java:
--------------------------------------------------------------------------------
1 | package com.nolimit35.springkit.notification.provider;
2 |
3 | import com.fasterxml.jackson.databind.ObjectMapper;
4 | import com.nolimit35.springkit.config.ExceptionNotifyProperties;
5 | import com.nolimit35.springkit.formatter.NotificationFormatter;
6 | import com.nolimit35.springkit.model.ExceptionInfo;
7 | import com.nolimit35.springkit.notification.AbstractNotificationProvider;
8 | import lombok.extern.slf4j.Slf4j;
9 | import okhttp3.*;
10 | import org.springframework.stereotype.Component;
11 | import org.springframework.util.StringUtils;
12 |
13 | import java.util.HashMap;
14 | import java.util.Map;
15 |
16 | /**
17 | * WeChat Work implementation of NotificationProvider
18 | */
19 | @Slf4j
20 | @Component
21 | public class WeChatWorkNotificationProvider extends AbstractNotificationProvider {
22 | private final OkHttpClient httpClient;
23 | private final ObjectMapper objectMapper;
24 | private final NotificationFormatter formatter;
25 | private static final MediaType JSON = MediaType.parse("application/json; charset=utf-8");
26 |
27 | public WeChatWorkNotificationProvider(ExceptionNotifyProperties properties, NotificationFormatter formatter) {
28 | super(properties);
29 | this.httpClient = new OkHttpClient();
30 | this.objectMapper = new ObjectMapper();
31 | this.formatter = formatter;
32 | }
33 |
34 | @Override
35 | protected boolean doSendNotification(ExceptionInfo exceptionInfo) throws Exception {
36 | String webhook = properties.getWechatwork().getWebhook();
37 |
38 | // Format the exception info into a notification
39 | String content = formatter.format(exceptionInfo);
40 |
41 | // 添加处理人信息
42 | if (exceptionInfo.getAuthorInfo() != null && StringUtils.hasText(exceptionInfo.getAuthorInfo().getEmail()) &&
43 | properties.getWechatwork().getAt() != null && properties.getWechatwork().getAt().isEnabled()) {
44 |
45 | if (properties.getWechatwork().getAt().getUserIdMappingGitEmail() != null
46 | && !properties.getWechatwork().getAt().getUserIdMappingGitEmail().isEmpty()) {
47 | // at 具体用户
48 | String qwUserId = properties.getWechatwork().getAt().getUserIdMappingGitEmail().entrySet().stream()
49 | // 根据邮箱匹配对应的企微用户id
50 | .filter(entry -> entry.getValue().contains(exceptionInfo.getAuthorInfo().getEmail()))
51 | .map(entryKey -> {
52 | // 处理 yaml 配置中 key 为 [@] 会序列化为 .@. 的情况
53 | if (entryKey.getKey().contains(".@.")) {
54 | return entryKey.getKey().replace(".@.", "@");
55 | }
56 | return entryKey.getKey();
57 | })
58 | .findFirst()
59 | .orElse(null);
60 |
61 | if (StringUtils.hasText(qwUserId)) {
62 | content += new StringBuilder("\n**处理人:** <@").append(qwUserId).append(">\n");
63 | }
64 | }
65 | }
66 |
67 | // Prepare request body
68 | Map requestBody = new HashMap<>();
69 | requestBody.put("msgtype", "markdown");
70 |
71 | Map markdown = new HashMap<>();
72 | markdown.put("content", content);
73 | requestBody.put("markdown", markdown);
74 |
75 | String jsonBody = objectMapper.writeValueAsString(requestBody);
76 |
77 | Request request = new Request.Builder()
78 | .url(webhook)
79 | .post(RequestBody.create(jsonBody, JSON))
80 | .build();
81 |
82 | try (Response response = httpClient.newCall(request).execute()) {
83 | if (!response.isSuccessful()) {
84 | log.error("Failed to send WeChat Work notification: {}", response.code());
85 | return false;
86 | }
87 |
88 | String responseBody = response.body().string();
89 | log.debug("WeChat Work response: {}", responseBody);
90 | return true;
91 | }
92 | }
93 |
94 | @Override
95 | public boolean isEnabled() {
96 | return properties.isEnabled() &&
97 | properties.getWechatwork().getWebhook() != null &&
98 | !properties.getWechatwork().getWebhook().isEmpty();
99 | }
100 | }
--------------------------------------------------------------------------------
/src/main/java/com/nolimit35/springkit/service/AbstractGitSourceControlService.java:
--------------------------------------------------------------------------------
1 | package com.nolimit35.springkit.service;
2 |
3 | import java.time.format.DateTimeFormatter;
4 |
5 | import com.fasterxml.jackson.databind.ObjectMapper;
6 | import com.nolimit35.springkit.config.ExceptionNotifyProperties;
7 | import com.nolimit35.springkit.model.CodeAuthorInfo;
8 | import lombok.extern.slf4j.Slf4j;
9 | import okhttp3.OkHttpClient;
10 |
11 | /**
12 | * Abstract implementation of GitSourceControlService providing common functionality
13 | * for different git source control providers (GitHub, Gitee, etc.)
14 | */
15 | @Slf4j
16 | public abstract class AbstractGitSourceControlService implements GitSourceControlService {
17 |
18 | protected final ExceptionNotifyProperties properties;
19 | protected final OkHttpClient httpClient;
20 | protected final ObjectMapper objectMapper;
21 | protected final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
22 |
23 | protected AbstractGitSourceControlService(ExceptionNotifyProperties properties) {
24 | this.properties = properties;
25 | this.httpClient = new OkHttpClient();
26 | this.objectMapper = new ObjectMapper();
27 | }
28 |
29 | /**
30 | * Validates that the required configuration is present
31 | *
32 | * @param token The API token
33 | * @param repoOwner The repository owner
34 | * @param repoName The repository name
35 | * @param serviceName The name of the service (for logging)
36 | * @return true if configuration is valid, false otherwise
37 | */
38 | protected boolean validateConfiguration(String token, String repoOwner, String repoName, String serviceName) {
39 | if (token == null || repoOwner == null || repoName == null) {
40 | log.warn("{} configuration is incomplete. Cannot fetch author information.", serviceName);
41 | return false;
42 | }
43 | return true;
44 | }
45 |
46 | /**
47 | * Abstract method to be implemented by concrete services to get author information
48 | * for a specific file and line
49 | *
50 | * @param fileName the file name
51 | * @param lineNumber the line number
52 | * @return author information or null if not found
53 | */
54 | @Override
55 | public abstract CodeAuthorInfo getAuthorInfo(String fileName, int lineNumber);
56 | }
--------------------------------------------------------------------------------
/src/main/java/com/nolimit35/springkit/service/EnvironmentProvider.java:
--------------------------------------------------------------------------------
1 | package com.nolimit35.springkit.service;
2 |
3 | import lombok.extern.slf4j.Slf4j;
4 | import org.springframework.beans.factory.annotation.Autowired;
5 | import org.springframework.core.env.Environment;
6 | import org.springframework.stereotype.Component;
7 |
8 | /**
9 | * Service to provide the current environment from Spring's active profiles
10 | */
11 | @Slf4j
12 | @Component
13 | public class EnvironmentProvider {
14 |
15 | private final Environment environment;
16 |
17 | @Autowired
18 | public EnvironmentProvider(Environment environment) {
19 | this.environment = environment;
20 | }
21 |
22 | /**
23 | * Get the current environment from Spring's active profiles
24 | *
25 | * @return the current environment or "dev" if not found
26 | */
27 | public String getCurrentEnvironment() {
28 | String[] activeProfiles = environment.getActiveProfiles();
29 |
30 | if (activeProfiles.length > 0) {
31 | // Use the first active profile
32 | return activeProfiles[0];
33 | }
34 |
35 | // Check default profiles if no active profiles found
36 | String[] defaultProfiles = environment.getDefaultProfiles();
37 | if (defaultProfiles.length > 0) {
38 | return defaultProfiles[0];
39 | }
40 |
41 | // Default to "dev" if no profiles found
42 | return "dev";
43 | }
44 | }
--------------------------------------------------------------------------------
/src/main/java/com/nolimit35/springkit/service/ExceptionAnalyzerService.java:
--------------------------------------------------------------------------------
1 | package com.nolimit35.springkit.service;
2 |
3 | import com.nolimit35.springkit.config.ExceptionNotifyProperties;
4 | import com.nolimit35.springkit.model.CodeAuthorInfo;
5 | import com.nolimit35.springkit.model.ExceptionInfo;
6 | import com.nolimit35.springkit.trace.TraceInfoProvider;
7 | import lombok.extern.slf4j.Slf4j;
8 | import org.springframework.beans.factory.annotation.Value;
9 | import org.springframework.stereotype.Service;
10 |
11 | import java.time.LocalDateTime;
12 | import java.util.Arrays;
13 | import java.util.List;
14 | import java.util.stream.Collectors;
15 |
16 | /**
17 | * Service for analyzing exceptions
18 | */
19 | @Slf4j
20 | @Service
21 | public class ExceptionAnalyzerService {
22 | private final List gitSourceControlServices;
23 | private final ExceptionNotifyProperties properties;
24 | private final TraceInfoProvider traceInfoProvider;
25 |
26 | @Value("${spring.application.name:unknown}")
27 | private String applicationName;
28 |
29 | public ExceptionAnalyzerService(List gitSourceControlServices,
30 | ExceptionNotifyProperties properties,
31 | TraceInfoProvider traceInfoProvider) {
32 | this.gitSourceControlServices = gitSourceControlServices;
33 | this.properties = properties;
34 | this.traceInfoProvider = traceInfoProvider;
35 | }
36 |
37 | /**
38 | * Analyze exception and create ExceptionInfo
39 | *
40 | * @param throwable the exception to analyze
41 | * @param traceId the trace ID (optional)
42 | * @return exception information
43 | */
44 | public ExceptionInfo analyzeException(Throwable throwable, String traceId) {
45 | // Get exception details
46 | String exceptionType = throwable.getClass().getName();
47 | String message = throwable.getMessage();
48 | String stacktrace = getStackTraceAsString(throwable);
49 |
50 | // Find the first application-specific stack trace element
51 | StackTraceElement[] stackTraceElements = throwable.getStackTrace();
52 | StackTraceElement firstAppElement = findFirstApplicationElement(stackTraceElements);
53 |
54 | String location = null;
55 | CodeAuthorInfo authorInfo = null;
56 |
57 | if (firstAppElement != null) {
58 | location = firstAppElement.getClassName() + "." + firstAppElement.getMethodName() +
59 | "(" + firstAppElement.getFileName() + ":" + firstAppElement.getLineNumber() + ")";
60 |
61 | // Get author information from available git source control services
62 | try {
63 | String fileName = convertClassNameToFilePath(firstAppElement.getClassName()) + ".java";
64 | authorInfo = findAuthorInfo(fileName, firstAppElement.getLineNumber());
65 | } catch (Exception e) {
66 | log.error("Error getting author information", e);
67 | }
68 | }
69 |
70 | // Generate trace URL if trace is enabled and traceId is available
71 | String traceUrl = null;
72 | if (properties.getTrace().isEnabled() && traceId != null && !traceId.isEmpty()) {
73 | traceUrl = traceInfoProvider.generateTraceUrl(traceId);
74 | }
75 |
76 | // Build exception info
77 | return ExceptionInfo.builder()
78 | .time(LocalDateTime.now())
79 | .type(exceptionType)
80 | .message(message != null ? message : "No message")
81 | .location(location)
82 | .stacktrace(stacktrace)
83 | .traceId(traceId)
84 | .appName(applicationName)
85 | .environment(properties.getEnvironment().getCurrent())
86 | .authorInfo(authorInfo)
87 | .traceUrl(traceUrl)
88 | .build();
89 | }
90 |
91 | /**
92 | * Find author information by trying all available git source control services
93 | *
94 | * @param fileName the file name
95 | * @param lineNumber the line number
96 | * @return author information or null if not found
97 | */
98 | private CodeAuthorInfo findAuthorInfo(String fileName, int lineNumber) {
99 | for (GitSourceControlService service : gitSourceControlServices) {
100 | CodeAuthorInfo authorInfo = service.getAuthorInfo(fileName, lineNumber);
101 | if (authorInfo != null) {
102 | return authorInfo;
103 | }
104 | }
105 | return null;
106 | }
107 |
108 |
109 |
110 | /**
111 | * Find the first application-specific stack trace element
112 | *
113 | * @param stackTraceElements the stack trace elements
114 | * @return the first application-specific element or null if not found
115 | */
116 | private StackTraceElement findFirstApplicationElement(StackTraceElement[] stackTraceElements) {
117 | // Check if package filtering is enabled
118 | if (properties.getPackageFilter().isEnabled() && !properties.getPackageFilter().getIncludePackages().isEmpty()) {
119 | // Filter based on configured packages
120 | for (StackTraceElement element : stackTraceElements) {
121 | String className = element.getClassName();
122 |
123 | // Check if the class belongs to any of the configured packages
124 | for (String packageName : properties.getPackageFilter().getIncludePackages()) {
125 | if (className.startsWith(packageName)) {
126 | return element;
127 | }
128 | }
129 | }
130 |
131 | // If no stack trace element matches the configured packages, return the first element
132 | return stackTraceElements.length > 0 ? stackTraceElements[0] : null;
133 | } else {
134 | // Original behavior if package filtering is not enabled
135 | for (StackTraceElement element : stackTraceElements) {
136 | String className = element.getClassName();
137 |
138 | // Skip common framework packages
139 | if (!className.startsWith("java.") &&
140 | !className.startsWith("javax.") &&
141 | !className.startsWith("sun.") &&
142 | !className.startsWith("com.sun.") &&
143 | !className.startsWith("org.springframework.") &&
144 | !className.startsWith("org.apache.") &&
145 | !className.startsWith("com.nolimit35.springkit")) {
146 | return element;
147 | }
148 | }
149 |
150 | // If no application-specific element found, return the first element
151 | return stackTraceElements.length > 0 ? stackTraceElements[0] : null;
152 | }
153 | }
154 |
155 | /**
156 | * Convert class name to file path
157 | *
158 | * @param className the class name
159 | * @return file path
160 | */
161 | private String convertClassNameToFilePath(String className) {
162 | return className.replace('.', '/');
163 | }
164 |
165 | /**
166 | * Get stack trace as string
167 | *
168 | * @param throwable the exception
169 | * @return stack trace as string
170 | */
171 | private String getStackTraceAsString(Throwable throwable) {
172 | return Arrays.stream(throwable.getStackTrace())
173 | .map(StackTraceElement::toString)
174 | .collect(Collectors.joining("\n"));
175 | }
176 | }
--------------------------------------------------------------------------------
/src/main/java/com/nolimit35/springkit/service/ExceptionNotificationService.java:
--------------------------------------------------------------------------------
1 | package com.nolimit35.springkit.service;
2 |
3 | import com.nolimit35.springkit.config.ExceptionNotifyProperties;
4 | import com.nolimit35.springkit.filter.ExceptionFilter;
5 | import com.nolimit35.springkit.formatter.NotificationFormatter;
6 | import com.nolimit35.springkit.model.ExceptionInfo;
7 | import com.nolimit35.springkit.notification.NotificationProviderManager;
8 | import com.nolimit35.springkit.trace.TraceInfoProvider;
9 | import lombok.extern.slf4j.Slf4j;
10 | import org.springframework.stereotype.Service;
11 |
12 | /**
13 | * Service for handling exception notifications
14 | */
15 | @Slf4j
16 | @Service
17 | public class ExceptionNotificationService {
18 | private final ExceptionNotifyProperties properties;
19 | private final ExceptionAnalyzerService analyzerService;
20 | private final NotificationProviderManager notificationManager;
21 | private final NotificationFormatter formatter;
22 | private final ExceptionFilter filter;
23 | private final EnvironmentProvider environmentProvider;
24 | private final TraceInfoProvider traceInfoProvider;
25 |
26 | public ExceptionNotificationService(
27 | ExceptionNotifyProperties properties,
28 | ExceptionAnalyzerService analyzerService,
29 | NotificationProviderManager notificationManager,
30 | NotificationFormatter formatter,
31 | ExceptionFilter filter,
32 | EnvironmentProvider environmentProvider,
33 | TraceInfoProvider traceInfoProvider) {
34 | this.properties = properties;
35 | this.analyzerService = analyzerService;
36 | this.notificationManager = notificationManager;
37 | this.formatter = formatter;
38 | this.filter = filter;
39 | this.environmentProvider = environmentProvider;
40 | this.traceInfoProvider = traceInfoProvider;
41 | }
42 |
43 | /**
44 | * Process exception and send notification if needed
45 | *
46 | * @param throwable the exception to process
47 | */
48 | public void processException(Throwable throwable) {
49 | if (!properties.isEnabled()) {
50 | log.debug("Exception notification is disabled");
51 | return;
52 | }
53 |
54 | // Get current environment from Spring profiles
55 | String currentEnvironment = environmentProvider.getCurrentEnvironment();
56 |
57 | // Update the current environment in properties
58 | properties.getEnvironment().setCurrent(currentEnvironment);
59 |
60 | // Check if we should report from the current environment
61 | if (!properties.getEnvironment().shouldReportFromCurrentEnvironment()) {
62 | log.debug("Exception notification is disabled for the current environment: {}", currentEnvironment);
63 | return;
64 | }
65 |
66 | if (!filter.shouldNotify(throwable)) {
67 | log.debug("Exception filtered out: {}", throwable.getClass().getName());
68 | return;
69 | }
70 |
71 | try {
72 | // Get trace ID from provider
73 | String traceId = traceInfoProvider.getTraceId();
74 |
75 | // Analyze exception
76 | ExceptionInfo exceptionInfo = analyzerService.analyzeException(throwable, traceId);
77 |
78 | // Add current environment to exception info
79 | exceptionInfo.setEnvironment(currentEnvironment);
80 |
81 | // Send notification via notification manager
82 | boolean notificationSent = notificationManager.sendNotification(exceptionInfo);
83 |
84 | if (notificationSent) {
85 | log.info("Exception notification sent for: {}", exceptionInfo.getType());
86 | } else {
87 | log.warn("No notification channels were successful for exception: {}", exceptionInfo.getType());
88 | }
89 | } catch (Exception e) {
90 | log.error("Error processing exception notification", e);
91 | }
92 | }
93 |
94 |
95 |
96 | }
--------------------------------------------------------------------------------
/src/main/java/com/nolimit35/springkit/service/GitHubService.java:
--------------------------------------------------------------------------------
1 | package com.nolimit35.springkit.service;
2 |
3 | import java.io.IOException;
4 | import java.time.LocalDateTime;
5 | import java.time.format.DateTimeFormatter;
6 |
7 | import org.springframework.stereotype.Service;
8 |
9 | import com.fasterxml.jackson.databind.JsonNode;
10 | import com.nolimit35.springkit.config.ExceptionNotifyProperties;
11 | import com.nolimit35.springkit.model.CodeAuthorInfo;
12 |
13 | import lombok.extern.slf4j.Slf4j;
14 | import okhttp3.MediaType;
15 | import okhttp3.Request;
16 | import okhttp3.RequestBody;
17 | import okhttp3.Response;
18 |
19 | /**
20 | * Service for interacting with GitHub API
21 | */
22 | @Slf4j
23 | @Service
24 | public class GitHubService extends AbstractGitSourceControlService {
25 | private static final MediaType JSON = MediaType.get("application/json; charset=utf-8");
26 | private static final String GITHUB_GRAPHQL_ENDPOINT = "https://api.github.com/graphql";
27 |
28 | public GitHubService(ExceptionNotifyProperties properties) {
29 | super(properties);
30 | }
31 |
32 | /**
33 | * Get author information for a specific file and line using GitHub GraphQL API
34 | *
35 | * @param fileName the file name
36 | * @param lineNumber the line number
37 | * @return author information or null if not found
38 | */
39 | @Override
40 | public CodeAuthorInfo getAuthorInfo(String fileName, int lineNumber) {
41 | if (!validateConfiguration(
42 | properties.getGithub().getToken(),
43 | properties.getGithub().getRepoOwner(),
44 | properties.getGithub().getRepoName(),
45 | "GitHub")) {
46 | return null;
47 | }
48 |
49 | try {
50 | // Construct GraphQL query for blame information
51 | String graphQLQuery = String.format(
52 | "{\"query\":\"query {\\n" +
53 | " repository(name: \\\"%s\\\", owner: \\\"%s\\\") {\\n" +
54 | " ref(qualifiedName:\\\"%s\\\") {\\n" +
55 | " target {\\n" +
56 | " ... on Commit {\\n" +
57 | " blame(path:\\\"%s\\\") {\\n" +
58 | " ranges {\\n" +
59 | " commit {\\n" +
60 | " author {\\n" +
61 | " name\\n" +
62 | " email\\n" +
63 | " date\\n" +
64 | " }\\n" +
65 | " committer {\\n" +
66 | " date\\n" +
67 | " }\\n" +
68 | " message\\n" +
69 | " }\\n" +
70 | " startingLine\\n" +
71 | " endingLine\\n" +
72 | " }\\n" +
73 | " }\\n" +
74 | " }\\n" +
75 | " }\\n" +
76 | " }\\n" +
77 | " }\\n" +
78 | "}\"}",
79 | properties.getGithub().getRepoName(),
80 | properties.getGithub().getRepoOwner(),
81 | properties.getGithub().getBranch(),
82 | fileName
83 | );
84 |
85 | RequestBody body = RequestBody.create(graphQLQuery, JSON);
86 | Request request = new Request.Builder()
87 | .url(GITHUB_GRAPHQL_ENDPOINT)
88 | .header("Authorization", "Bearer " + properties.getGithub().getToken())
89 | .post(body)
90 | .build();
91 |
92 | try (Response response = httpClient.newCall(request).execute()) {
93 | if (!response.isSuccessful()) {
94 | log.error("Failed to get blame information: {}", response.code());
95 | return null;
96 | }
97 |
98 | String responseBody = response.body().string();
99 | JsonNode data = objectMapper.readTree(responseBody).get("data");
100 |
101 | if (data == null || data.has("errors")) {
102 | log.error("GraphQL query returned errors: {}", responseBody);
103 | return null;
104 | }
105 |
106 | JsonNode blame = data.get("repository")
107 | .get("ref")
108 | .get("target")
109 | .get("blame");
110 |
111 | JsonNode ranges = blame.get("ranges");
112 |
113 | // Find the blame range that contains the line
114 | for (JsonNode range : ranges) {
115 | int startLine = range.get("startingLine").asInt();
116 | int endLine = range.get("endingLine").asInt();
117 |
118 | if (lineNumber >= startLine && lineNumber <= endLine) {
119 | JsonNode commit = range.get("commit");
120 | JsonNode author = commit.get("author");
121 | JsonNode committer = commit.get("committer");
122 |
123 | return CodeAuthorInfo.builder()
124 | .name(author.get("name").asText())
125 | .email(author.get("email").asText())
126 | .lastCommitTime(LocalDateTime.parse(committer.get("date").asText(), DateTimeFormatter.ISO_DATE_TIME))
127 | .fileName(fileName)
128 | .lineNumber(lineNumber)
129 | .commitMessage(commit.get("message").asText())
130 | .build();
131 | }
132 | }
133 |
134 | log.warn("Could not find blame information for {}:{}", fileName, lineNumber);
135 | }
136 | } catch (IOException e) {
137 | log.error("Error fetching author information from GitHub", e);
138 | }
139 |
140 | return null;
141 | }
142 | }
--------------------------------------------------------------------------------
/src/main/java/com/nolimit35/springkit/service/GitLabService.java:
--------------------------------------------------------------------------------
1 | package com.nolimit35.springkit.service;
2 |
3 | import java.io.IOException;
4 | import java.time.LocalDateTime;
5 | import java.time.format.DateTimeFormatter;
6 | import java.net.URLEncoder;
7 | import java.nio.charset.StandardCharsets;
8 |
9 | import org.springframework.stereotype.Service;
10 |
11 | import com.fasterxml.jackson.databind.JsonNode;
12 | import com.nolimit35.springkit.config.ExceptionNotifyProperties;
13 | import com.nolimit35.springkit.model.CodeAuthorInfo;
14 |
15 | import lombok.extern.slf4j.Slf4j;
16 | import okhttp3.Request;
17 | import okhttp3.Response;
18 |
19 | /**
20 | * Service for interacting with GitLab API
21 | */
22 | @Slf4j
23 | @Service
24 | public class GitLabService extends AbstractGitSourceControlService {
25 | private static final String API_FILE_BLAME = "%s/projects/%s/repository/files/%s/blame";
26 |
27 | public GitLabService(ExceptionNotifyProperties properties) {
28 | super(properties);
29 | }
30 |
31 | /**
32 | * Get author information for a specific file and line using GitLab API
33 | *
34 | * @param fileName the file name
35 | * @param lineNumber the line number
36 | * @return author information or null if not found
37 | */
38 | @Override
39 | public CodeAuthorInfo getAuthorInfo(String fileName, int lineNumber) {
40 | if (!validateConfiguration(
41 | properties.getGitlab().getToken(),
42 | properties.getGitlab().getProjectId(),
43 | properties.getGitlab().getBranch(),
44 | "GitLab")) {
45 | return null;
46 | }
47 |
48 | try {
49 | // URL encode the file path for GitLab API
50 | String encodedFilePath = URLEncoder.encode(fileName, StandardCharsets.UTF_8.toString());
51 |
52 | // Construct GitLab API URL for blame information
53 | String apiUrl = String.format(
54 | API_FILE_BLAME,
55 | properties.getGitlab().getBaseUrl(),
56 | properties.getGitlab().getProjectId(),
57 | encodedFilePath
58 | );
59 |
60 | // Add query parameter for branch
61 | apiUrl += "?ref=" + properties.getGitlab().getBranch();
62 |
63 | Request request = new Request.Builder()
64 | .url(apiUrl)
65 | .header("PRIVATE-TOKEN", properties.getGitlab().getToken())
66 | .get()
67 | .build();
68 |
69 | try (Response response = httpClient.newCall(request).execute()) {
70 | if (!response.isSuccessful()) {
71 | log.error("Failed to get blame information from GitLab: {}", response.code());
72 | return null;
73 | }
74 |
75 | String responseBody = response.body().string();
76 | JsonNode blameData = objectMapper.readTree(responseBody);
77 |
78 | return processBlameData(blameData, fileName, lineNumber);
79 | }
80 | } catch (IOException e) {
81 | log.error("Error fetching author information from GitLab", e);
82 | }
83 |
84 | return null;
85 | }
86 |
87 | /**
88 | * Process the GitLab blame data to extract author information for a specific line
89 | *
90 | * @param blameData the GitLab blame response data as JsonNode
91 | * @param fileName the file name
92 | * @param lineNumber the line number to get author for
93 | * @return the author information or null if not found
94 | */
95 | private CodeAuthorInfo processBlameData(JsonNode blameData, String fileName, int lineNumber) {
96 | // Find the blame range that contains the line
97 | for (JsonNode blameRange : blameData) {
98 | JsonNode lines = blameRange.get("lines");
99 | if (lines == null || lines.isEmpty()) {
100 | continue;
101 | }
102 |
103 | // Get the line numbers in this range
104 | int startLine = -1;
105 | int endLine = -1;
106 |
107 | for (int i = 0; i < lines.size(); i++) {
108 | if (startLine == -1) {
109 | startLine = i + 1; // Line numbers are 1-based
110 | }
111 | endLine = i + 1;
112 | }
113 |
114 | if (lineNumber >= startLine && lineNumber <= endLine) {
115 | JsonNode commit = blameRange.get("commit");
116 |
117 | return CodeAuthorInfo.builder()
118 | .name(commit.get("author_name").asText())
119 | .email(commit.get("author_email").asText())
120 | .lastCommitTime(LocalDateTime.parse(
121 | commit.get("authored_date").asText(),
122 | DateTimeFormatter.ISO_DATE_TIME))
123 | .fileName(fileName)
124 | .lineNumber(lineNumber)
125 | .commitMessage(commit.get("message").asText())
126 | .build();
127 | }
128 | }
129 |
130 | return null;
131 | }
132 |
133 | /**
134 | * Validates that the required configuration is present
135 | *
136 | * @param token The API token
137 | * @param projectId The project ID
138 | * @param branch The branch name
139 | * @param serviceName The name of the service (for logging)
140 | * @return true if configuration is valid, false otherwise
141 | */
142 | @Override
143 | protected boolean validateConfiguration(String token, String projectId, String branch, String serviceName) {
144 | if (token == null || projectId == null || branch == null) {
145 | log.warn("{} configuration is incomplete. Cannot fetch author information.", serviceName);
146 | return false;
147 | }
148 | return true;
149 | }
150 | }
--------------------------------------------------------------------------------
/src/main/java/com/nolimit35/springkit/service/GitSourceControlService.java:
--------------------------------------------------------------------------------
1 | package com.nolimit35.springkit.service;
2 |
3 | import com.nolimit35.springkit.model.CodeAuthorInfo;
4 |
5 | /**
6 | * Interface for Git source control services (GitHub, Gitee, etc.)
7 | * Provides common operations for interacting with git repositories
8 | */
9 | public interface GitSourceControlService {
10 |
11 | /**
12 | * Get author information for a specific file and line
13 | *
14 | * @param fileName the file name
15 | * @param lineNumber the line number
16 | * @return author information or null if not found
17 | */
18 | CodeAuthorInfo getAuthorInfo(String fileName, int lineNumber);
19 | }
--------------------------------------------------------------------------------
/src/main/java/com/nolimit35/springkit/service/GiteeService.java:
--------------------------------------------------------------------------------
1 | package com.nolimit35.springkit.service;
2 |
3 | import java.io.IOException;
4 | import java.time.LocalDateTime;
5 |
6 | import org.springframework.stereotype.Service;
7 |
8 | import com.fasterxml.jackson.databind.JsonNode;
9 | import com.nolimit35.springkit.config.ExceptionNotifyProperties;
10 | import com.nolimit35.springkit.model.CodeAuthorInfo;
11 |
12 | import lombok.extern.slf4j.Slf4j;
13 | import okhttp3.Request;
14 | import okhttp3.Response;
15 |
16 | /**
17 | * Service for interacting with Gitee API
18 | */
19 | @Slf4j
20 | @Service
21 | public class GiteeService extends AbstractGitSourceControlService {
22 |
23 | public GiteeService(ExceptionNotifyProperties properties) {
24 | super(properties);
25 | }
26 |
27 | /**
28 | * Get the full file path in the repository based on a filename
29 | *
30 | * @param fileName the file name or partial path
31 | * @return the full path of the file in the repository, or null if not found
32 | */
33 | private String getFilePathFromName(String fileName) {
34 | if (!validateConfiguration(
35 | properties.getGitee().getToken(),
36 | properties.getGitee().getRepoOwner(),
37 | properties.getGitee().getRepoName(),
38 | "Gitee")) {
39 | return null;
40 | }
41 |
42 | // Check if fileName is null or empty
43 | if (fileName == null || fileName.trim().isEmpty()) {
44 | log.error("File name is empty or null");
45 | return null;
46 | }
47 |
48 | // Log if fileName already appears to be a path
49 | boolean isLikelyPath = fileName.contains("/") && !fileName.startsWith("/");
50 | if (isLikelyPath) {
51 | log.debug("File name already appears to be a path: {}", fileName);
52 | }
53 |
54 | try {
55 | String url = String.format(
56 | "https://gitee.com/api/v5/repos/%s/%s/git/trees/%s?access_token=%s&recursive=1",
57 | properties.getGitee().getRepoOwner(),
58 | properties.getGitee().getRepoName(),
59 | properties.getGitee().getBranch(),
60 | properties.getGitee().getToken()
61 | );
62 |
63 | log.debug("Fetching repository tree from: {}", url.replaceAll("access_token=[^&]+", "access_token=***"));
64 |
65 | Request request = new Request.Builder()
66 | .url(url)
67 | .header("Content-Type", "application/json;charset=UTF-8")
68 | .build();
69 |
70 | try (Response response = httpClient.newCall(request).execute()) {
71 | if (!response.isSuccessful()) {
72 | log.error("Failed to get repository tree from Gitee: {}", response.code());
73 | return null;
74 | }
75 |
76 | String responseBody = response.body().string();
77 | JsonNode treeData = objectMapper.readTree(responseBody);
78 | JsonNode tree = treeData.get("tree");
79 |
80 | if (tree == null || tree.isEmpty()) {
81 | log.warn("Repository tree is empty or not available");
82 | return null;
83 | }
84 |
85 | log.debug("Repository tree contains {} items", tree.size());
86 |
87 | // First check for exact match
88 | for (JsonNode item : tree) {
89 | String path = item.get("path").asText();
90 | String type = item.get("type").asText();
91 |
92 | // Check if path exactly matches the fileName (for full paths)
93 | if ("blob".equals(type) && path.equals(fileName)) {
94 | log.info("Found exact file path match: {}", path);
95 | return path;
96 | }
97 | }
98 |
99 | // If exact match not found, try other matching strategies
100 | String simpleFileName = getSimpleFileName(fileName);
101 | log.debug("Simple file name extracted: {}", simpleFileName);
102 |
103 | // Try to find the best match using different strategies
104 | String bestMatch = null;
105 | int bestMatchScore = 0;
106 |
107 | for (JsonNode item : tree) {
108 | String path = item.get("path").asText();
109 | String type = item.get("type").asText();
110 |
111 | if (!"blob".equals(type)) {
112 | continue; // Skip directories
113 | }
114 |
115 | int score = 0;
116 |
117 | // Strategy 1: Path ends with the simple file name
118 | if (path.endsWith("/" + simpleFileName) || path.equals(simpleFileName)) {
119 | score += 10;
120 | }
121 |
122 | // Strategy 2: For paths, check if path contains the full fileName
123 | if (isLikelyPath && path.contains(fileName)) {
124 | score += 20;
125 | }
126 |
127 | // Strategy 3: Check if the file name components match in order
128 | if (isLikelyPath) {
129 | String[] fileNameParts = fileName.split("/");
130 | String[] pathParts = path.split("/");
131 | int matchingParts = 0;
132 |
133 | // Check matching parts from the end
134 | for (int i = 0; i < Math.min(fileNameParts.length, pathParts.length); i++) {
135 | if (fileNameParts[fileNameParts.length - 1 - i].equals(
136 | pathParts[pathParts.length - 1 - i])) {
137 | matchingParts++;
138 | } else {
139 | break;
140 | }
141 | }
142 |
143 | score += matchingParts * 5;
144 | }
145 |
146 | // Update best match if this path has a higher score
147 | if (score > bestMatchScore) {
148 | bestMatch = path;
149 | bestMatchScore = score;
150 | }
151 | }
152 |
153 | if (bestMatch != null) {
154 | log.info("Found best matching file path: {} (score: {})", bestMatch, bestMatchScore);
155 | return bestMatch;
156 | }
157 |
158 | log.warn("File '{}' not found in repository tree", fileName);
159 | }
160 | } catch (IOException e) {
161 | log.error("Error fetching repository tree from Gitee", e);
162 | }
163 |
164 | return null;
165 | }
166 |
167 | /**
168 | * Extract simple file name from a path
169 | *
170 | * @param path Path or filename
171 | * @return Simple file name without directories
172 | */
173 | private String getSimpleFileName(String path) {
174 | if (path == null) {
175 | return "";
176 | }
177 | int lastSeparator = path.lastIndexOf('/');
178 | return lastSeparator >= 0 ? path.substring(lastSeparator + 1) : path;
179 | }
180 |
181 | /**
182 | * Get author information for a specific file and line
183 | *
184 | * @param fileName the file name
185 | * @param lineNumber the line number
186 | * @return author information or null if not found
187 | */
188 | @Override
189 | public CodeAuthorInfo getAuthorInfo(String fileName, int lineNumber) {
190 | if (!validateConfiguration(
191 | properties.getGitee().getToken(),
192 | properties.getGitee().getRepoOwner(),
193 | properties.getGitee().getRepoName(),
194 | "Gitee")) {
195 | return null;
196 | }
197 |
198 | // Get the full file path
199 | String filePath = getFilePathFromName(fileName);
200 | if (filePath == null) {
201 | log.error("Could not find file path for: {}", fileName);
202 | return null;
203 | }
204 |
205 | try {
206 | String url = String.format(
207 | "https://gitee.com/api/v5/repos/%s/%s/blame/%s?access_token=%s&ref=%s",
208 | properties.getGitee().getRepoOwner(),
209 | properties.getGitee().getRepoName(),
210 | filePath,
211 | properties.getGitee().getToken(),
212 | properties.getGitee().getBranch()
213 |
214 | );
215 |
216 | Request request = new Request.Builder()
217 | .url(url)
218 | .build();
219 |
220 | try (Response response = httpClient.newCall(request).execute()) {
221 | if (!response.isSuccessful()) {
222 | log.error("Failed to get blame information from Gitee: {}", response.code());
223 | return null;
224 | }
225 |
226 | String responseBody = response.body().string();
227 | JsonNode blameData = objectMapper.readTree(responseBody);
228 |
229 | // Track the current line number through all blame ranges
230 | int currentLineIndex = 1;
231 |
232 | // Iterate through blame ranges
233 | for (JsonNode range : blameData) {
234 | JsonNode lines = range.get("lines");
235 | int linesCount = lines.size();
236 |
237 | // Check if our target line is within this range
238 | if (lineNumber >= currentLineIndex && lineNumber < currentLineIndex + linesCount) {
239 | // Found the range containing our line
240 | JsonNode commit = range.get("commit");
241 | JsonNode commitAuthor = commit.get("committer");
242 | String dateStr = commitAuthor.get("date").asText()
243 | .replaceAll("\\+\\d{2}:\\d{2}$", "")
244 | .replaceAll("T"," ");
245 | return CodeAuthorInfo.builder()
246 | .name(commitAuthor.get("name").asText())
247 | .email(commitAuthor.get("email").asText())
248 | .lastCommitTime(LocalDateTime.parse(dateStr, this.DATE_FORMAT))
249 | .fileName(filePath)
250 | .lineNumber(lineNumber)
251 | .commitMessage(commit.get("message").asText())
252 | .build();
253 | }
254 |
255 | // Move to the next range
256 | currentLineIndex += linesCount;
257 | }
258 |
259 | log.warn("Could not find author information for line {} in file {}", lineNumber, filePath);
260 | }
261 | } catch (IOException e) {
262 | log.error("Error fetching author information from Gitee", e);
263 | }
264 |
265 | return null;
266 | }
267 | }
--------------------------------------------------------------------------------
/src/main/java/com/nolimit35/springkit/trace/DefaultTraceInfoProvider.java:
--------------------------------------------------------------------------------
1 | package com.nolimit35.springkit.trace;
2 |
3 | import com.nolimit35.springkit.config.ExceptionNotifyProperties;
4 | import lombok.extern.slf4j.Slf4j;
5 | import org.slf4j.MDC;
6 | import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
7 | import org.springframework.stereotype.Component;
8 | import org.springframework.web.context.request.RequestAttributes;
9 | import org.springframework.web.context.request.RequestContextHolder;
10 | import org.springframework.web.context.request.ServletRequestAttributes;
11 |
12 | import javax.servlet.http.HttpServletRequest;
13 | import java.nio.charset.StandardCharsets;
14 | import java.util.Base64;
15 |
16 | /**
17 | * Default implementation of TraceInfoProvider
18 | * Retrieves trace ID from MDC or request headers and generates Tencent CLS trace URLs
19 | */
20 | @Slf4j
21 | @Component
22 | @ConditionalOnMissingBean(TraceInfoProvider.class)
23 | public class DefaultTraceInfoProvider implements TraceInfoProvider {
24 | private final ExceptionNotifyProperties properties;
25 |
26 | public DefaultTraceInfoProvider(ExceptionNotifyProperties properties) {
27 | this.properties = properties;
28 | }
29 |
30 | @Override
31 | public String getTraceId() {
32 | if (!properties.getTrace().isEnabled()) {
33 | return null;
34 | }
35 |
36 | try {
37 | // First try to get traceId from MDC
38 | String traceId = MDC.get("traceId");
39 | if (traceId != null && !traceId.isEmpty()) {
40 | return traceId;
41 | }
42 |
43 | // If not found in MDC, try to get from request header
44 | RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
45 | if (requestAttributes instanceof ServletRequestAttributes) {
46 | HttpServletRequest request = ((ServletRequestAttributes) requestAttributes).getRequest();
47 | String headerName = properties.getTrace().getHeaderName();
48 | traceId = request.getHeader(headerName);
49 |
50 | if (traceId != null && !traceId.isEmpty()) {
51 | return traceId;
52 | }
53 | }
54 | } catch (Exception e) {
55 | log.debug("Error getting trace ID", e);
56 | }
57 |
58 | return null;
59 | }
60 |
61 | @Override
62 | public String generateTraceUrl(String traceId) {
63 | if (traceId == null || traceId.isEmpty()) {
64 | return null;
65 | }
66 |
67 | String region = properties.getTencentcls().getRegion();
68 | String topicId = properties.getTencentcls().getTopicId();
69 |
70 | if (region != null && !region.isEmpty() && topicId != null && !topicId.isEmpty()) {
71 | // Build query JSON
72 | String interactiveQuery = String.format(
73 | "{\"filters\":[{\"key\":\"traceId\",\"grammarName\":\"INCLUDE\",\"values\":[{\"values\":[{\"value\":\"%s\",\"isPartialEscape\":true}],\"isOpen\":false}],\"alias_name\":\"traceId\",\"cnName\":\"\"}],\"sql\":{\"quotas\":[],\"dimensions\":[],\"sequences\":[],\"limit\":1000,\"samplingRate\":1},\"sqlStr\":\"\"}",
74 | traceId
75 | );
76 |
77 | // Base64 encode query parameters
78 | String interactiveQueryBase64 = Base64.getEncoder().encodeToString(
79 | interactiveQuery.getBytes(StandardCharsets.UTF_8)
80 | );
81 |
82 | // Build complete URL
83 | return String.format(
84 | "https://console.cloud.tencent.com/cls/search?region=%s&topic_id=%s&interactiveQueryBase64=%s",
85 | region, topicId, interactiveQueryBase64
86 | ) + "&time=now%2Fd,now%2Fd";
87 | }
88 |
89 | return null;
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/src/main/java/com/nolimit35/springkit/trace/TraceInfoProvider.java:
--------------------------------------------------------------------------------
1 | package com.nolimit35.springkit.trace;
2 |
3 | /**
4 | * Interface for providing trace information
5 | * This interface allows customization of how trace IDs are retrieved and trace URLs are generated
6 | */
7 | public interface TraceInfoProvider {
8 | /**
9 | * Get trace ID from the current context
10 | *
11 | * @return trace ID or null if not available
12 | */
13 | String getTraceId();
14 |
15 | /**
16 | * Generate trace URL for the given trace ID
17 | *
18 | * @param traceId the trace ID
19 | * @return trace URL or null if not available
20 | */
21 | String generateTraceUrl(String traceId);
22 | }
23 |
--------------------------------------------------------------------------------
/src/main/resources/META-INF/spring.factories:
--------------------------------------------------------------------------------
1 | org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
2 | com.nolimit35.springkit.config.ExceptionNotifyAutoConfiguration
3 |
4 | org.springframework.context.ApplicationListener=\
5 | com.nolimit35.springkit.config.EnvironmentPostProcessor
--------------------------------------------------------------------------------
/src/main/resources/application-example.yaml:
--------------------------------------------------------------------------------
1 | exception:
2 | notify:
3 | enabled: true # 是否启用异常通知功能
4 | package-filter:
5 | enabled: true # 是否启用包名过滤功能
6 | include-packages: # 需要解析的包名列表,启用后只会分析这些包名下的异常堆栈
7 | - com.nolimit35
8 | dingtalk:
9 | webhook: https://oapi.dingtalk.com/robot/send?access_token=xxx # 钉钉机器人 Webhook 地址
10 | at:
11 | enabled: true # 是否启用@功能
12 | userIdMappingGitEmail: # 钉钉 用户id 与 git 提交邮箱的映射关系
13 | xxx: ['xxx@xx.com','xxxx@xx.com']
14 | feishu:
15 | webhook: https://open.feishu.cn/open-apis/bot/v2/hook/xxxxx # 飞书机器人 Webhook 地址
16 | at:
17 | enabled: true # 是否启用@功能
18 | openIdMappingGitEmail: # 飞书 openid 与 git 提交邮箱的映射关系
19 | ou_xxxxxxxx: ['xxx@xx.com','xxxx@xx.com']
20 | wechatwork:
21 | webhook: https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxxxxxx # 企业微信机器人 Webhook 地址
22 | at:
23 | enabled: true # 是否启用@功能
24 | userIdMappingGitEmail: # 企微 用户id 与 git 提交邮箱的映射关系
25 | # 企微用户 id 带有 @ 符号时,需要手动特殊处理成 [@]
26 | 'xxx[@]xx.com': ['xxx@xx.com','xxxx@xx.com']
27 | # GitHub 配置 (与 Gitee 配置互斥,只能选择其中一种)
28 | github:
29 | token: ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # GitHub 访问令牌
30 | repo-owner: your-github-username # GitHub 仓库所有者
31 | repo-name: your-repo-name # GitHub 仓库名称
32 | branch: master # GitHub 仓库分支
33 | # Gitee 配置 (与 GitHub 配置互斥,只能选择其中一种)
34 | gitee:
35 | token: xxxxxxxxxxxxxxxxxxxxxxx # Gitee 访问令牌
36 | repo-owner: your-gitee-username # Gitee 仓库所有者
37 | repo-name: your-repo-name # Gitee 仓库名称
38 | branch: master # Gitee 仓库分支
39 | tencentcls:
40 | region: ap-guangzhou # 腾讯云日志服务(CLS)的地域
41 | topic-id: xxx-xxx-xxx # 腾讯云日志服务(CLS)的主题ID
42 | trace:
43 | enabled: true # 是否启用链路追踪
44 | header-name: X-Trace-Id # 链路追踪 ID 的请求头名称
45 | notification:
46 | title-template: "【${appName}】异常告警" # 告警标题模板
47 | include-stacktrace: true # 是否包含完整堆栈信息
48 | max-stacktrace-lines: 10 # 堆栈信息最大行数
49 | environment:
50 | report-from: test,prod # 需要上报异常的环境列表,多个环境用逗号分隔
51 |
52 | # Spring 配置
53 | spring:
54 | # 应用名称,用于告警标题
55 | application:
56 | name: YourApplicationName
57 | # 当前环境配置,会自动用于确定异常通知的当前环境
58 | profiles:
59 | active: dev # 当前激活的环境配置
--------------------------------------------------------------------------------
/src/test/java/com/nolimit35/springkit/notification/provider/AllProvidersYamlTest.java:
--------------------------------------------------------------------------------
1 | package com.nolimit35.springkit.notification.provider;
2 |
3 | import com.nolimit35.springkit.notification.NotificationProvider;
4 | import com.nolimit35.springkit.notification.NotificationProviderManager;
5 | import org.junit.jupiter.api.BeforeEach;
6 | import org.junit.jupiter.api.Test;
7 | import org.springframework.test.context.TestPropertySource;
8 |
9 | import java.util.Arrays;
10 | import java.util.List;
11 |
12 | import static org.junit.jupiter.api.Assertions.assertEquals;
13 | import static org.junit.jupiter.api.Assertions.assertTrue;
14 |
15 | /**
16 | * 基于 YAML 配置的所有通知提供者组合测试
17 | */
18 | @TestPropertySource(properties = {
19 | "spring.config.location=classpath:application-test.yml"
20 | })
21 | class AllProvidersYamlTest extends YamlConfigNotificationProviderTest {
22 |
23 | private DingTalkNotificationProvider dingTalkProvider;
24 | private FeishuNotificationProvider feishuProvider;
25 | private WeChatWorkNotificationProvider weChatWorkProvider;
26 | private NotificationProviderManager providerManager;
27 |
28 | @BeforeEach
29 | void setUpProviders() {
30 | // 创建提供者实例
31 | dingTalkProvider = new DingTalkNotificationProvider(properties, formatter);
32 | feishuProvider = new FeishuNotificationProvider(properties, formatter);
33 | weChatWorkProvider = new WeChatWorkNotificationProvider(properties, formatter);
34 |
35 | // 创建提供者管理器,包含所有提供者
36 | List providers = Arrays.asList(
37 | dingTalkProvider,
38 | feishuProvider,
39 | weChatWorkProvider
40 | );
41 | providerManager = new NotificationProviderManager(providers);
42 | }
43 |
44 | @Test
45 | void testProviderEnabledStatus() {
46 | // 检查提供者是否根据 webhook 配置正确启用
47 | assertTrue(dingTalkProvider.isEnabled());
48 |
49 | assertTrue(feishuProvider.isEnabled());
50 |
51 | assertTrue(weChatWorkProvider.isEnabled());
52 | }
53 |
54 | @Test
55 | void testSendNotificationToAllEnabledProviders() {
56 | // 通过提供者管理器发送通知到所有启用的提供者
57 | boolean result = providerManager.sendNotification(exceptionInfo);
58 |
59 | // 如果任何提供者已启用,结果应该为 true
60 | assertEquals(isAnyProviderEnabled(), result);
61 | }
62 |
63 | private boolean hasEnvironmentVariable(String name) {
64 | String value = System.getenv(name);
65 | return value != null && !value.isEmpty();
66 | }
67 |
68 | private boolean isAnyProviderEnabled() {
69 | return dingTalkProvider.isEnabled() ||
70 | feishuProvider.isEnabled() ||
71 | weChatWorkProvider.isEnabled();
72 | }
73 | }
--------------------------------------------------------------------------------
/src/test/java/com/nolimit35/springkit/notification/provider/DingTalkNotificationProviderYamlTest.java:
--------------------------------------------------------------------------------
1 | package com.nolimit35.springkit.notification.provider;
2 |
3 | import org.junit.jupiter.api.BeforeEach;
4 | import org.junit.jupiter.api.Test;
5 | import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
6 | import org.springframework.test.context.TestPropertySource;
7 |
8 | import static org.junit.jupiter.api.Assertions.*;
9 |
10 | /**
11 | * 基于 YAML 配置的钉钉通知提供者测试
12 | * 需要设置 DINGTALK_WEBHOOK 环境变量才能运行
13 | */
14 | @TestPropertySource(properties = {
15 | "spring.config.location=classpath:application-test.yml"
16 | })
17 | class DingTalkNotificationProviderYamlTest extends YamlConfigNotificationProviderTest {
18 |
19 | private DingTalkNotificationProvider provider;
20 |
21 | @BeforeEach
22 | void setUpProvider() {
23 | // 创建提供者实例
24 | provider = new DingTalkNotificationProvider(properties, formatter);
25 | }
26 |
27 | @Test
28 | void testIsEnabled() {
29 | // 如果环境变量存在,应该是启用的
30 | assertTrue(provider.isEnabled());
31 |
32 | // 验证配置是否正确加载
33 | assertEquals(System.getenv("DINGTALK_WEBHOOK"), properties.getDingtalk().getWebhook());
34 | }
35 |
36 | @Test
37 | void testSendNotification() throws Exception {
38 | // 发送真实的通知请求
39 | boolean result = provider.doSendNotification(exceptionInfo);
40 |
41 | // 断言
42 | assertTrue(result, "通知应该成功发送");
43 | }
44 | }
--------------------------------------------------------------------------------
/src/test/java/com/nolimit35/springkit/notification/provider/FeishuNotificationProviderYamlTest.java:
--------------------------------------------------------------------------------
1 | package com.nolimit35.springkit.notification.provider;
2 |
3 | import org.junit.jupiter.api.BeforeEach;
4 | import org.junit.jupiter.api.Test;
5 | import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
6 | import org.springframework.test.context.TestPropertySource;
7 |
8 | import static org.junit.jupiter.api.Assertions.*;
9 |
10 | /**
11 | * 基于 YAML 配置的飞书通知提供者测试
12 | * 需要设置 FEISHU_WEBHOOK 环境变量才能运行
13 | */
14 | @TestPropertySource(properties = {
15 | "spring.config.location=classpath:application-test.yml"
16 | })
17 | class FeishuNotificationProviderYamlTest extends YamlConfigNotificationProviderTest {
18 |
19 | private FeishuNotificationProvider provider;
20 |
21 | @BeforeEach
22 | void setUpProvider() {
23 | // 创建提供者实例
24 | provider = new FeishuNotificationProvider(properties, formatter);
25 | }
26 |
27 | @Test
28 | void testIsEnabled() {
29 | // 如果环境变量存在,应该是启用的
30 | assertTrue(provider.isEnabled());
31 |
32 | // 验证配置是否正确加载
33 | assertEquals(System.getenv("FEISHU_WEBHOOK"), properties.getFeishu().getWebhook());
34 | }
35 |
36 | @Test
37 | void testSendNotification() throws Exception {
38 | // 发送真实的通知请求
39 | boolean result = provider.doSendNotification(exceptionInfo);
40 |
41 | // 断言
42 | assertTrue(result, "通知应该成功发送");
43 | }
44 | }
--------------------------------------------------------------------------------
/src/test/java/com/nolimit35/springkit/notification/provider/TestApplication.java:
--------------------------------------------------------------------------------
1 | package com.nolimit35.springkit.notification.provider;
2 |
3 | import org.springframework.boot.SpringApplication;
4 | import org.springframework.boot.autoconfigure.SpringBootApplication;
5 |
6 | /**
7 | * 用于测试的 Spring Boot 应用类
8 | */
9 | @SpringBootApplication
10 | public class TestApplication {
11 |
12 | public static void main(String[] args) {
13 | SpringApplication.run(TestApplication.class, args);
14 | }
15 | }
--------------------------------------------------------------------------------
/src/test/java/com/nolimit35/springkit/notification/provider/WeChatWorkNotificationProviderYamlTest.java:
--------------------------------------------------------------------------------
1 | package com.nolimit35.springkit.notification.provider;
2 |
3 | import org.junit.jupiter.api.BeforeEach;
4 | import org.junit.jupiter.api.Test;
5 | import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
6 | import org.springframework.test.context.TestPropertySource;
7 |
8 | import static org.junit.jupiter.api.Assertions.*;
9 |
10 | /**
11 | * 基于 YAML 配置的企业微信通知提供者测试
12 | * 需要设置 WECHATWORK_WEBHOOK 环境变量才能运行
13 | */
14 | @TestPropertySource(properties = {
15 | "spring.config.location=classpath:application-test.yml"
16 | })
17 | class WeChatWorkNotificationProviderYamlTest extends YamlConfigNotificationProviderTest {
18 |
19 | private WeChatWorkNotificationProvider provider;
20 |
21 | @BeforeEach
22 | void setUpProvider() {
23 | // 创建提供者实例
24 | provider = new WeChatWorkNotificationProvider(properties, formatter);
25 | }
26 |
27 | @Test
28 | void testIsEnabled() {
29 | // 如果环境变量存在,应该是启用的
30 | assertTrue(provider.isEnabled());
31 |
32 | // 验证配置是否正确加载
33 | assertEquals(System.getenv("WECHATWORK_WEBHOOK"), properties.getWechatwork().getWebhook());
34 | }
35 |
36 | @Test
37 | void testSendNotification() throws Exception {
38 | // 发送真实的通知请求
39 | boolean result = provider.doSendNotification(exceptionInfo);
40 |
41 | // 断言
42 | assertTrue(result, "通知应该成功发送");
43 | }
44 | }
--------------------------------------------------------------------------------
/src/test/java/com/nolimit35/springkit/notification/provider/YamlConfigNotificationProviderTest.java:
--------------------------------------------------------------------------------
1 | package com.nolimit35.springkit.notification.provider;
2 |
3 | import com.nolimit35.springkit.config.ExceptionNotifyProperties;
4 | import com.nolimit35.springkit.formatter.DefaultNotificationFormatter;
5 | import com.nolimit35.springkit.formatter.NotificationFormatter;
6 | import com.nolimit35.springkit.model.CodeAuthorInfo;
7 | import com.nolimit35.springkit.model.ExceptionInfo;
8 | import org.junit.jupiter.api.BeforeEach;
9 | import org.junit.jupiter.api.Test;
10 | import org.springframework.beans.factory.annotation.Autowired;
11 | import org.springframework.boot.context.properties.EnableConfigurationProperties;
12 | import org.springframework.boot.test.context.SpringBootTest;
13 | import org.springframework.test.context.ActiveProfiles;
14 |
15 | import java.time.LocalDateTime;
16 |
17 | import static org.junit.jupiter.api.Assertions.assertTrue;
18 |
19 | /**
20 | * 从 YAML 配置文件加载 ExceptionNotifyProperties 的集成测试基类
21 | */
22 | @SpringBootTest(classes = { TestApplication.class })
23 | @EnableConfigurationProperties(ExceptionNotifyProperties.class)
24 | @ActiveProfiles("test")
25 | public abstract class YamlConfigNotificationProviderTest {
26 |
27 | @Autowired
28 | protected ExceptionNotifyProperties properties;
29 |
30 | protected NotificationFormatter formatter;
31 | protected ExceptionInfo exceptionInfo;
32 |
33 | @BeforeEach
34 | void setUp() {
35 | // 创建实际的 formatter
36 | formatter = new DefaultNotificationFormatter(properties);
37 |
38 | // 设置测试异常信息
39 | RuntimeException testException = new RuntimeException("测试异常,请忽略");
40 |
41 | exceptionInfo = ExceptionInfo.builder()
42 | .time(LocalDateTime.now())
43 | .type(testException.getClass().getName())
44 | .message(testException.getMessage())
45 | .location("com.nolimit35.springkit.test.IntegrationTest.testMethod(IntegrationTest.java:42)")
46 | .stacktrace(getStackTraceAsString(testException))
47 | .appName("springkit-test")
48 | .authorInfo(CodeAuthorInfo.builder().name("xxx").email("xxx@xx.com").build())
49 | .build();
50 | }
51 |
52 | /**
53 | * 辅助方法,将栈追踪转换为字符串
54 | */
55 | public String getStackTraceAsString(Throwable throwable) {
56 | StringBuilder sb = new StringBuilder();
57 | sb.append(throwable.toString()).append("\n");
58 |
59 | for (StackTraceElement element : throwable.getStackTrace()) {
60 | sb.append("\tat ").append(element).append("\n");
61 | }
62 |
63 | return sb.toString();
64 | }
65 | }
--------------------------------------------------------------------------------
/src/test/java/com/nolimit35/springkit/service/GitHubServiceTest.java:
--------------------------------------------------------------------------------
1 | package com.nolimit35.springkit.service;
2 |
3 | import com.nolimit35.springkit.config.ExceptionNotifyProperties;
4 | import com.nolimit35.springkit.model.CodeAuthorInfo;
5 | import org.junit.jupiter.api.BeforeEach;
6 | import org.junit.jupiter.api.Tag;
7 | import org.junit.jupiter.api.Test;
8 | import org.junit.jupiter.api.extension.ExtendWith;
9 | import org.mockito.InjectMocks;
10 | import org.mockito.Mock;
11 | import org.mockito.junit.jupiter.MockitoExtension;
12 |
13 | import static org.junit.jupiter.api.Assertions.*;
14 | import static org.mockito.Mockito.when;
15 |
16 | /**
17 | * Integration test for GitHubService
18 | * Note: This test makes actual API calls to GitHub and requires a valid GitHub token
19 | */
20 | @ExtendWith(MockitoExtension.class)
21 | @Tag("integration")
22 | public class GitHubServiceTest {
23 |
24 | @Mock
25 | private ExceptionNotifyProperties properties;
26 |
27 | @Mock
28 | private ExceptionNotifyProperties.GitHub githubProperties;
29 |
30 | @InjectMocks
31 | private GitHubService gitHubService;
32 |
33 | private static final String TEST_FILE = "src/main/java/com/nolimit35/springfast/service/GitHubService.java";
34 | private static final int TEST_LINE = 15;
35 |
36 | @BeforeEach
37 | public void setUp() {
38 | // Set up properties with real GitHub credentials
39 | when(properties.getGithub()).thenReturn(githubProperties);
40 | when(githubProperties.getToken()).thenReturn("your_token");
41 | when(githubProperties.getRepoOwner()).thenReturn("GuangYiDing");
42 | when(githubProperties.getRepoName()).thenReturn("exception-notify");
43 | when(githubProperties.getBranch()).thenReturn("main");
44 | }
45 |
46 | @Test
47 | public void testGetAuthorInfo_Success() {
48 | // Call the actual service method
49 | CodeAuthorInfo authorInfo = gitHubService.getAuthorInfo(TEST_FILE, TEST_LINE);
50 |
51 | // Verify we got a result
52 | assertNotNull(authorInfo);
53 |
54 | // Verify the basic structure is correct (we can't assert exact values since they depend on the actual repository)
55 | assertNotNull(authorInfo.getName());
56 | assertNotNull(authorInfo.getEmail());
57 | assertNotNull(authorInfo.getLastCommitTime());
58 | assertEquals(TEST_FILE, authorInfo.getFileName());
59 | assertEquals(TEST_LINE, authorInfo.getLineNumber());
60 | }
61 |
62 | @Test
63 | public void testGetAuthorInfo_InvalidFile() {
64 | // Test with a file that doesn't exist
65 | CodeAuthorInfo authorInfo = gitHubService.getAuthorInfo("non-existent-file.txt", 1);
66 |
67 | // Should return null for non-existent file
68 | assertNull(authorInfo);
69 | }
70 |
71 | @Test
72 | public void testGetAuthorInfo_MissingConfiguration() {
73 | // Test with missing token
74 | when(githubProperties.getToken()).thenReturn(null);
75 |
76 | CodeAuthorInfo authorInfo = gitHubService.getAuthorInfo(TEST_FILE, TEST_LINE);
77 | assertNull(authorInfo);
78 |
79 | // Reset token and test with missing repo owner
80 | when(githubProperties.getToken()).thenReturn("your_token");
81 | when(githubProperties.getRepoOwner()).thenReturn(null);
82 |
83 | authorInfo = gitHubService.getAuthorInfo(TEST_FILE, TEST_LINE);
84 | assertNull(authorInfo);
85 |
86 | // Reset repo owner and test with missing repo name
87 | when(githubProperties.getRepoOwner()).thenReturn("GuangYiDing");
88 | when(githubProperties.getRepoName()).thenReturn(null);
89 |
90 | authorInfo = gitHubService.getAuthorInfo(TEST_FILE, TEST_LINE);
91 | assertNull(authorInfo);
92 | }
93 | }
--------------------------------------------------------------------------------
/src/test/java/com/nolimit35/springkit/service/GitLabServiceTest.java:
--------------------------------------------------------------------------------
1 | package com.nolimit35.springkit.service;
2 |
3 | import com.fasterxml.jackson.databind.JsonNode;
4 | import com.fasterxml.jackson.databind.ObjectMapper;
5 | import com.nolimit35.springkit.config.ExceptionNotifyProperties;
6 | import com.nolimit35.springkit.model.CodeAuthorInfo;
7 | import okhttp3.Call;
8 | import okhttp3.OkHttpClient;
9 | import okhttp3.Request;
10 | import okhttp3.Response;
11 | import okhttp3.ResponseBody;
12 | import okhttp3.Protocol;
13 | import org.junit.jupiter.api.BeforeEach;
14 | import org.junit.jupiter.api.Tag;
15 | import org.junit.jupiter.api.Test;
16 | import org.junit.jupiter.api.extension.ExtendWith;
17 | import org.junit.jupiter.api.Assumptions;
18 | import org.mockito.InjectMocks;
19 | import org.mockito.Mock;
20 | import org.mockito.Spy;
21 | import org.mockito.junit.jupiter.MockitoExtension;
22 |
23 | import java.io.ByteArrayOutputStream;
24 | import java.io.IOException;
25 | import java.io.InputStream;
26 | import java.lang.reflect.Field;
27 | import java.lang.reflect.Method;
28 | import java.nio.charset.StandardCharsets;
29 | import java.time.LocalDateTime;
30 | import java.time.format.DateTimeFormatter;
31 |
32 | import static org.junit.jupiter.api.Assertions.*;
33 | import static org.junit.jupiter.api.Assumptions.assumeTrue;
34 | import static org.mockito.Mockito.*;
35 |
36 | /**
37 | * Integration test for GitLabService
38 | * Note: This test makes actual API calls to GitLab and requires a valid GitLab token
39 | */
40 | @ExtendWith(MockitoExtension.class)
41 | @Tag("integration")
42 | public class GitLabServiceTest {
43 |
44 | @Mock
45 | private ExceptionNotifyProperties properties;
46 |
47 | @Mock
48 | private ExceptionNotifyProperties.GitLab gitlabProperties;
49 |
50 | @InjectMocks
51 | private GitLabService gitLabService;
52 |
53 | private static final String TEST_FILE = "src/main/java/com/nolimit35/springkit/service/GitLabService.java";
54 | private static final int TEST_LINE = 15;
55 |
56 | @BeforeEach
57 | public void setUp() {
58 | // Set up properties with test GitLab credentials
59 | // Replace these values with valid test values for your GitLab instance
60 | when(properties.getGitlab()).thenReturn(gitlabProperties);
61 | when(gitlabProperties.getToken()).thenReturn("your_gitlab_token");
62 | when(gitlabProperties.getProjectId()).thenReturn("your_project_id");
63 | when(gitlabProperties.getBranch()).thenReturn("main");
64 | when(gitlabProperties.getBaseUrl()).thenReturn("https://gitlab.com/api/v4");
65 | }
66 |
67 | @Test
68 | public void testGetAuthorInfo_Success() {
69 | // Skip this test if you don't have valid GitLab credentials for testing
70 | // Uncomment the following line to skip
71 | // assumeTrue(false, "Skipped because no valid GitLab credentials available");
72 |
73 | // Call the actual service method
74 | CodeAuthorInfo authorInfo = gitLabService.getAuthorInfo(TEST_FILE, TEST_LINE);
75 |
76 | // Verify we got a result
77 | assertNotNull(authorInfo);
78 |
79 | // Verify the basic structure is correct (we can't assert exact values since they depend on the actual repository)
80 | assertNotNull(authorInfo.getName());
81 | assertNotNull(authorInfo.getEmail());
82 | assertNotNull(authorInfo.getLastCommitTime());
83 | assertEquals(TEST_FILE, authorInfo.getFileName());
84 | assertEquals(TEST_LINE, authorInfo.getLineNumber());
85 | }
86 |
87 | @Test
88 | public void testGetAuthorInfo_InvalidFile() {
89 | // Test with a file that doesn't exist
90 | CodeAuthorInfo authorInfo = gitLabService.getAuthorInfo("non-existent-file.txt", 1);
91 |
92 | // Should return null for non-existent file
93 | assertNull(authorInfo);
94 | }
95 |
96 | }
--------------------------------------------------------------------------------
/src/test/java/com/nolimit35/springkit/trace/DefaultTraceInfoProviderTest.java:
--------------------------------------------------------------------------------
1 | package com.nolimit35.springkit.trace;
2 |
3 | import com.nolimit35.springkit.config.ExceptionNotifyProperties;
4 | import org.junit.jupiter.api.BeforeEach;
5 | import org.junit.jupiter.api.Test;
6 | import org.mockito.Mock;
7 | import org.mockito.MockitoAnnotations;
8 | import org.slf4j.MDC;
9 | import org.springframework.mock.web.MockHttpServletRequest;
10 | import org.springframework.web.context.request.RequestContextHolder;
11 | import org.springframework.web.context.request.ServletRequestAttributes;
12 |
13 | import static org.junit.jupiter.api.Assertions.*;
14 | import static org.mockito.Mockito.when;
15 |
16 | class DefaultTraceInfoProviderTest {
17 |
18 | @Mock
19 | private ExceptionNotifyProperties properties;
20 |
21 | @Mock
22 | private ExceptionNotifyProperties.Trace trace;
23 |
24 | @Mock
25 | private ExceptionNotifyProperties.TencentCls tencentCls;
26 |
27 | private DefaultTraceInfoProvider traceInfoProvider;
28 |
29 | @BeforeEach
30 | void setUp() {
31 | MockitoAnnotations.openMocks(this);
32 | when(properties.getTrace()).thenReturn(trace);
33 | when(properties.getTencentcls()).thenReturn(tencentCls);
34 | when(trace.isEnabled()).thenReturn(true);
35 | when(trace.getHeaderName()).thenReturn("X-Trace-Id");
36 |
37 | traceInfoProvider = new DefaultTraceInfoProvider(properties);
38 |
39 | // Clear MDC before each test
40 | MDC.clear();
41 | }
42 |
43 | @Test
44 | void getTraceId_fromMDC() {
45 | // Given
46 | String expectedTraceId = "test-trace-id-from-mdc";
47 | MDC.put("traceId", expectedTraceId);
48 |
49 | // When
50 | String traceId = traceInfoProvider.getTraceId();
51 |
52 | // Then
53 | assertEquals(expectedTraceId, traceId);
54 | }
55 |
56 | @Test
57 | void getTraceId_fromRequestHeader() {
58 | // Given
59 | String expectedTraceId = "test-trace-id-from-header";
60 | MockHttpServletRequest request = new MockHttpServletRequest();
61 | request.addHeader("X-Trace-Id", expectedTraceId);
62 | RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request));
63 |
64 | // When
65 | String traceId = traceInfoProvider.getTraceId();
66 |
67 | // Then
68 | assertEquals(expectedTraceId, traceId);
69 |
70 | // Clean up
71 | RequestContextHolder.resetRequestAttributes();
72 | }
73 |
74 | @Test
75 | void getTraceId_traceDisabled() {
76 | // Given
77 | when(trace.isEnabled()).thenReturn(false);
78 |
79 | // When
80 | String traceId = traceInfoProvider.getTraceId();
81 |
82 | // Then
83 | assertNull(traceId);
84 | }
85 |
86 | @Test
87 | void generateTraceUrl_withValidConfig() {
88 | // Given
89 | String traceId = "test-trace-id";
90 | when(tencentCls.getRegion()).thenReturn("ap-guangzhou");
91 | when(tencentCls.getTopicId()).thenReturn("test-topic-id");
92 |
93 | // When
94 | String traceUrl = traceInfoProvider.generateTraceUrl(traceId);
95 |
96 | // Then
97 | assertNotNull(traceUrl);
98 | assertTrue(traceUrl.contains("region=ap-guangzhou"));
99 | assertTrue(traceUrl.contains("topic_id=test-topic-id"));
100 | assertTrue(traceUrl.contains("traceId"));
101 | assertTrue(traceUrl.contains("test-trace-id"));
102 | }
103 |
104 | @Test
105 | void generateTraceUrl_withNullTraceId() {
106 | // When
107 | String traceUrl = traceInfoProvider.generateTraceUrl(null);
108 |
109 | // Then
110 | assertNull(traceUrl);
111 | }
112 |
113 | @Test
114 | void generateTraceUrl_withEmptyTraceId() {
115 | // When
116 | String traceUrl = traceInfoProvider.generateTraceUrl("");
117 |
118 | // Then
119 | assertNull(traceUrl);
120 | }
121 |
122 | @Test
123 | void generateTraceUrl_withMissingConfig() {
124 | // Given
125 | String traceId = "test-trace-id";
126 | when(tencentCls.getRegion()).thenReturn(null);
127 | when(tencentCls.getTopicId()).thenReturn(null);
128 |
129 | // When
130 | String traceUrl = traceInfoProvider.generateTraceUrl(traceId);
131 |
132 | // Then
133 | assertNull(traceUrl);
134 | }
135 | }
136 |
--------------------------------------------------------------------------------
/src/test/resources/application-test.yml:
--------------------------------------------------------------------------------
1 | exception:
2 | notify:
3 | enabled: true # 是否启用异常通知功能
4 | package-filter:
5 | enabled: true # 是否启用包名过滤功能
6 | include-packages: # 需要解析的包名列表,启用后只会分析这些包名下的异常堆栈
7 | - com.nolimit35
8 | dingtalk:
9 | webhook: https://oapi.dingtalk.com/robot/send?access_token=xxx # 钉钉机器人 Webhook 地址
10 | at:
11 | enabled: true # 是否启用@功能
12 | userIdMappingGitEmail: # 钉钉 用户id 与 git 提交邮箱的映射关系
13 | xxx: ['xxx@xx.com','xxxx@xx.com']
14 | feishu:
15 | webhook: https://open.feishu.cn/open-apis/bot/v2/hook/xxxxx # 飞书机器人 Webhook 地址
16 | at:
17 | enabled: true # 是否启用@功能
18 | openIdMappingGitEmail: # 飞书 openid 与 git 提交邮箱的映射关系
19 | ou_xxxxxxxx: ['xxx@xx.com','xxxx@xx.com']
20 | wechatwork:
21 | webhook: https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxxxxxx # 企业微信机器人 Webhook 地址
22 | at:
23 | enabled: true # 是否启用@功能
24 | userIdMappingGitEmail: # 企微 用户id 与 git 提交邮箱的映射关系
25 | # 企微用户 id 带有 @ 符号时,需要手动特殊处理成 [@]
26 | 'xxx[@]xx.com': ['xxx@xx.com','xxxx@xx.com']
27 |
28 | # GitHub 配置 (与 Gitee 配置互斥,只能选择其中一种)
29 | github:
30 | token: ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # GitHub 访问令牌
31 | repo-owner: your-github-username # GitHub 仓库所有者
32 | repo-name: your-repo-name # GitHub 仓库名称
33 | branch: master # GitHub 仓库分支
34 | # Gitee 配置 (与 GitHub 配置互斥,只能选择其中一种)
35 | gitee:
36 | token: xxxxxxxxxxxxxxxxxxxxxxx # Gitee 访问令牌
37 | repo-owner: your-gitee-username # Gitee 仓库所有者
38 | repo-name: your-repo-name # Gitee 仓库名称
39 | branch: master # Gitee 仓库分支
40 | tencentcls:
41 | region: ap-guangzhou # 腾讯云日志服务(CLS)的地域
42 | topic-id: xxx-xxx-xxx # 腾讯云日志服务(CLS)的主题ID
43 | trace:
44 | enabled: true # 是否启用链路追踪
45 | header-name: X-Trace-Id # 链路追踪 ID 的请求头名称
46 | notification:
47 | title-template: "【${appName}】异常告警" # 告警标题模板
48 | include-stacktrace: true # 是否包含完整堆栈信息
49 | max-stacktrace-lines: 10 # 堆栈信息最大行数
50 | environment:
51 | report-from: test,prod # 需要上报异常的环境列表,多个环境用逗号分隔
52 |
53 | # Spring 配置
54 | spring:
55 | # 应用名称,用于告警标题
56 | application:
57 | name: YourApplicationName
58 | # 当前环境配置,会自动用于确定异常通知的当前环境
59 | profiles:
60 | active: test # 当前激活的环境配置
61 |
62 | logging:
63 | level:
64 | root: INFO
65 | com.nolimit35: DEBUG
66 |
--------------------------------------------------------------------------------