This commit is contained in:
van
2026-09-29 14:55:53 +08:00
parent 4e4def7f72
commit 459540819d
5 changed files with 585 additions and 46 deletions
@@ -0,0 +1,288 @@
package com.ruoyi.jarvis.task;
import com.alibaba.fastjson2.JSONObject;
import com.ruoyi.common.utils.Arith;
import com.ruoyi.framework.web.domain.Server;
import com.ruoyi.framework.web.domain.server.SysFile;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* 按服务监控同一套采样({@link Server#copyTo()})定时看 CPU 与磁盘。
* 任一超过阈值时,经 wxSend {@code /wx/send}、消息类型 QL(企微应用 1000002)连发告警。
*/
@Component
public class ServerResourceAlertTask {
private static final Logger log = LoggerFactory.getLogger(ServerResourceAlertTask.class);
private static final DateTimeFormatter TIME_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
/** 内存盘、容器层等不代表物理硬盘,避免误报 */
private static final Set<String> PSEUDO_FS = new HashSet<>(Arrays.asList(
"tmpfs", "devtmpfs", "overlay", "squashfs", "ramfs", "sysfs", "proc",
"cgroup", "cgroup2", "devfs", "autofs", "nsfs", "tracefs", "debugfs",
"securityfs", "pstore", "bpf", "configfs", "mqueue", "hugetlbfs",
"fusectl", "binfmt_misc"));
@Value("${jarvis.server.resource-alert.enabled:true}")
private boolean enabled;
/** 与服务监控页磁盘标红线一致,超过即告警 */
@Value("${jarvis.server.resource-alert.cpu-threshold:80}")
private double cpuThreshold;
@Value("${jarvis.server.resource-alert.disk-threshold:80}")
private double diskThreshold;
@Value("${jarvis.server.resource-alert.repeat-count:3}")
private int repeatCount;
/** 持续超标时两次连发之间的最短间隔,避免每轮刷新都刷屏 */
@Value("${jarvis.server.resource-alert.cooldown-minutes:30}")
private int cooldownMinutes;
@Value("${jarvis.server.resource-alert.touser:LinPingFan}")
private String touser;
/** wxSend 消息类型名,QL 对应企微应用 1000002 */
@Value("${jarvis.server.resource-alert.message-type:QL}")
private String messageType;
@Value("${jarvis.wecom.wxsend-base-url:}")
private String wxsendBaseUrl;
@Value("${jarvis.wecom.wxsend-van-token:}")
private String vanToken;
private final AtomicBoolean running = new AtomicBoolean(false);
private volatile long lastAlertAtMs;
@Scheduled(cron = "${jarvis.server.resource-alert.cron:0 */5 * * * ?}")
public void refreshAndAlert() {
if (!enabled) {
return;
}
if (!running.compareAndSet(false, true)) {
log.info("服务器资源监控上一轮尚未结束,跳过本次");
return;
}
try {
sampleAndMaybeAlert();
} catch (Exception e) {
log.error("服务器资源监控定时任务异常", e);
} finally {
running.set(false);
}
}
private void sampleAndMaybeAlert() throws Exception {
Server server = new Server();
server.copyTo();
double cpuFree = server.getCpu().getFree();
double cpuUsage = clampPercent(Arith.sub(100D, cpuFree));
boolean cpuHot = cpuUsage > cpuThreshold;
List<String> hotDisks = new ArrayList<>();
double maxDisk = -1D;
String maxDiskDir = "";
List<SysFile> files = server.getSysFiles();
if (files != null) {
for (SysFile file : files) {
if (file == null || !isPhysicalDisk(file)) {
continue;
}
double usage = file.getUsage();
if (usage > maxDisk) {
maxDisk = usage;
maxDiskDir = file.getDirName() == null ? "" : file.getDirName();
}
if (usage > diskThreshold) {
hotDisks.add(formatDisk(file));
}
}
}
boolean diskHot = !hotDisks.isEmpty();
String diskSummary = maxDisk < 0 ? "无物理盘" : String.format(Locale.ROOT, "%.2f%%(%s)", maxDisk, maxDiskDir);
log.info("服务器资源监控 CPU={}%(阈值{}%) 磁盘最高={}(阈值{}%)",
formatPercent(cpuUsage), formatPercent(cpuThreshold), diskSummary, formatPercent(diskThreshold));
if (!cpuHot && !diskHot) {
return;
}
long now = System.currentTimeMillis();
long cooldownMs = Math.max(1, cooldownMinutes) * 60_000L;
if (lastAlertAtMs > 0 && now - lastAlertAtMs < cooldownMs) {
log.info("资源仍超标,处于告警冷却({} 分钟),本次不重复推送", cooldownMinutes);
return;
}
String kind = cpuHot && diskHot ? "CPU与磁盘" : (cpuHot ? "CPU" : "磁盘");
String body = buildBody(server, cpuUsage, cpuHot, hotDisks, diskHot);
int times = Math.max(1, repeatCount);
int sent = 0;
for (int i = 1; i <= times; i++) {
String title = "【资源告警 " + i + "/" + times + "】" + kind + "过高";
if (postQl(title, body)) {
sent++;
}
if (i < times) {
Thread.sleep(400L);
}
}
if (sent > 0) {
lastAlertAtMs = System.currentTimeMillis();
log.warn("服务器资源告警已推送 {}/{} 条,渠道 messageType={}(企微应用 1000002)", sent, times, messageType);
} else {
log.error("服务器资源已超标,但 {} 条告警均未发出,下轮将重试", times);
}
}
private String buildBody(Server server, double cpuUsage, boolean cpuHot, List<String> hotDisks, boolean diskHot) {
String name = server.getSys() == null ? "" : nullToEmpty(server.getSys().getComputerName());
String ip = server.getSys() == null ? "" : nullToEmpty(server.getSys().getComputerIp());
StringBuilder sb = new StringBuilder();
sb.append("主机:").append(name);
if (StringUtils.hasText(ip)) {
sb.append("(").append(ip).append(")");
}
sb.append("\n时间:").append(TIME_FMT.format(LocalDateTime.now()));
sb.append("\nCPU:").append(formatPercent(cpuUsage)).append("%");
sb.append(cpuHot ? ",已超过阈值 " : ",未超过阈值 ");
sb.append(formatPercent(cpuThreshold)).append("%");
if (diskHot) {
sb.append("\n磁盘超标:");
for (String line : hotDisks) {
sb.append("\n- ").append(line);
}
} else {
sb.append("\n磁盘:未超过阈值 ").append(formatPercent(diskThreshold)).append("%");
}
return sb.toString();
}
private boolean postQl(String title, String text) {
if (!StringUtils.hasText(wxsendBaseUrl)) {
log.error("资源告警未发送:未配置 jarvis.wecom.wxsend-base-url");
return false;
}
if (!StringUtils.hasText(vanToken)) {
log.error("资源告警未发送:未配置 jarvis.wecom.wxsend-van-token");
return false;
}
String base = wxsendBaseUrl.trim();
if (base.endsWith("/")) {
base = base.substring(0, base.length() - 1);
}
String url = base + "/wx/send";
JSONObject body = new JSONObject();
body.put("title", title);
body.put("text", text);
body.put("touser", touser == null ? "" : touser.trim());
body.put("vanToken", vanToken.trim());
body.put("messageType", StringUtils.hasText(messageType) ? messageType.trim() : "QL");
HttpURLConnection conn = null;
try {
conn = (HttpURLConnection) new URL(url).openConnection();
conn.setRequestMethod("POST");
conn.setConnectTimeout(10000);
conn.setReadTimeout(20000);
conn.setDoOutput(true);
conn.setRequestProperty("Content-Type", "application/json;charset=UTF-8");
byte[] bytes = body.toJSONString().getBytes(StandardCharsets.UTF_8);
try (OutputStream os = conn.getOutputStream()) {
os.write(bytes);
}
int code = conn.getResponseCode();
InputStream is = code >= 200 && code < 300 ? conn.getInputStream() : conn.getErrorStream();
String resp = readAll(is);
boolean ok = code >= 200 && code < 300 && resp != null && resp.contains("成功");
if (!ok) {
log.error("资源告警推送失败 http={} url={} resp={}", code, url, resp);
return false;
}
log.info("资源告警已发出 title={} resp={}", title, resp);
return true;
} catch (Exception e) {
log.error("资源告警推送异常 url={} err={}", url, e.toString(), e);
return false;
} finally {
if (conn != null) {
conn.disconnect();
}
}
}
private static boolean isPhysicalDisk(SysFile file) {
String total = file.getTotal();
if (!StringUtils.hasText(total) || !total.toUpperCase(Locale.ROOT).contains("GB")) {
return false;
}
String fsType = file.getSysTypeName();
if (!StringUtils.hasText(fsType)) {
return true;
}
return !PSEUDO_FS.contains(fsType.trim().toLowerCase(Locale.ROOT));
}
private static String formatDisk(SysFile file) {
return nullToEmpty(file.getDirName())
+ " 已用 " + formatPercent(file.getUsage()) + "%"
+ "(可用 " + nullToEmpty(file.getFree())
+ " / 共 " + nullToEmpty(file.getTotal()) + ")";
}
private static double clampPercent(double value) {
if (value < 0D) {
return 0D;
}
if (value > 100D) {
return 100D;
}
return Arith.round(value, 2);
}
private static String formatPercent(double value) {
return String.format(Locale.ROOT, "%.2f", value);
}
private static String nullToEmpty(String value) {
return value == null ? "" : value;
}
private static String readAll(InputStream is) throws java.io.IOException {
if (is == null) {
return "";
}
byte[] buf = new byte[4096];
StringBuilder sb = new StringBuilder();
int n;
while ((n = is.read(buf)) >= 0) {
sb.append(new String(buf, 0, n, StandardCharsets.UTF_8));
}
return sb.toString();
}
}
@@ -219,6 +219,16 @@ jarvis:
order-delay-ms: 250
# 0=不限制;例如 40 可控制单轮最长耗时(余下下轮再扫)
max-orders-per-round: 0
# 服务监控定时刷新:CPU 或物理盘使用率超过阈值时,经企微应用 1000002(wxSend messageType=QL)连发告警
resource-alert:
enabled: true
cron: "0 */5 * * * ?"
cpu-threshold: 80
disk-threshold: 80
repeat-count: 3
cooldown-minutes: 30
touser: "LinPingFan"
message-type: QL
# 获取评论接口服务地址(后端转发,避免前端跨域)
fetch-comments:
base-url: http://192.168.8.60:5008
@@ -322,5 +332,11 @@ jarvis:
# true=仅拉 auto-ship-order-statuses(省调用,其它状态依赖推送);false=时间窗内全状态(推荐,与本地对齐)
pull-list-only-auto-ship-statuses: false
auto-ship-batch-size: 20
# 两次发货接口最小间隔(毫秒),避免连续/并发发货被平台返回「操作频繁」
ship-min-interval-ms: 2000
# 「操作频繁」、超时等可恢复错误的额外重试次数(不含首次)
ship-retry-times: 3
# 重试基础等待(毫秒),第 n 次等待 n × 该值
ship-retry-delay-ms: 2000
@@ -216,6 +216,16 @@ jarvis:
cron: "0 */5 * * * ?"
order-delay-ms: 250
max-orders-per-round: 0
# 服务监控定时刷新:CPU 或物理盘使用率超过阈值时,经企微应用 1000002(wxSend messageType=QL)连发告警
resource-alert:
enabled: true
cron: "0 */5 * * * ?"
cpu-threshold: 80
disk-threshold: 80
repeat-count: 3
cooldown-minutes: 30
touser: "LinPingFan"
message-type: QL
# 获取评论接口服务地址(后端转发)
fetch-comments:
base-url: http://192.168.8.60:5008