feat(alert-center): 添加飞书Webhook通知渠道 + 告警引擎实时通知

- 后端: NotifyChannel 模型新增 feishu 类型, FeishuWebhookConfig
- 后端: TestNotifyChannel 真实发送飞书测试消息(文本格式)
- 后端: 告警引擎触发/恢复时自动发送飞书通知(sendFeishuAlert/sendFeishuRecovery)
- 后端: AlertEngine 新增 tcp_time_wait/tcp_close_wait/service_process 指标检测
- 后端: NetworkStatModel 新增 TCPCloseWait 字段
- 后端: QuickSetupRules 一键创建6条告警规则(CPU/内存/磁盘/TIME_WAIT/CLOSE_WAIT/进程)
- 后端: 注册 /alerts/quick-setup 路由
- 前端: AlertChannelDialog 新增飞书类型选项
- 前端: helpers 新增 getChannelIcon/getChannelTypeLabel 支持 feishu/dingtalk/wechat
This commit is contained in:
attacker committed 2026-06-24 08:34:43 +00:00
1 parent 7bc5169e8f
commit 5333f6f04a
30 files changed
+2406 -1001

No files matched your search

+17
View File
@@ -51,6 +51,12 @@ func (h *AgentHandler) ReceiveReport(c *gin.Context) {
portJSON, _ := json.Marshal(req.Ports)
netJSON, _ := json.Marshal(req.Network)
dioJSON, _ := json.Marshal(req.DiskIO)
// v2.0 新增
swapJSON, _ := json.Marshal(req.Swap)
processTopJSON, _ := json.Marshal(req.ProcessTop)
fdJSON, _ := json.Marshal(req.FD)
csJSON, _ := json.Marshal(req.ContextSwitches)
heartbeatJSON, _ := json.Marshal(req.Heartbeat)
// 存储到数据库
report := models.AgentReport{
@@ -67,6 +73,12 @@ func (h *AgentHandler) ReceiveReport(c *gin.Context) {
PortInfo: string(portJSON),
NetInfo: string(netJSON),
DiskIOInfo: string(dioJSON),
// v2.0 新增
SwapInfo: string(swapJSON),
ProcessTopInfo: string(processTopJSON),
FDInfo: string(fdJSON),
CSInfo: string(csJSON),
HeartbeatInfo: string(heartbeatJSON),
ReportTime: reportTime,
}
@@ -264,6 +276,11 @@ func (h *AgentHandler) GetLatestReports(c *gin.Context) {
a.port_info,
a.net_info,
a.disk_io_info,
a.swap_info,
a.process_top_info,
a.fd_info,
a.cs_info,
a.heartbeat_info,
a.report_time,
COALESCE(s.agent_version, '') as agent_version,
CASE
+222 -6
View File
@@ -1,8 +1,16 @@
package handlers
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"time"
"ezops/internal/models"
@@ -163,9 +171,9 @@ func (h *AlertHandler) DeleteNotifyChannel(c *gin.Context) {
})
}
// TestNotifyChannel 测试通知渠道 // Logan.Li
// TestNotifyChannel 测试通知渠道 — 真实发送测试消息 // Logan.Li
// @Summary 测试通知渠道连通性
// @Description 模拟测试通知渠道(返回测试状态)
// @Description 向渠道发送测试消息,验证连通性
// @Tags 告警
// @Produce json
// @Security Bearer
@@ -184,12 +192,22 @@ func (h *AlertHandler) TestNotifyChannel(c *gin.Context) {
return
}
// 模拟测试:校验配置非空即视为成功
status := "success"
message := "测试通知已发送"
if channel.Config == "" {
status = "warning"
message = "渠道未配置参数,请先完善配置"
switch channel.Type {
case "feishu":
err := sendFeishuTest(channel.Config)
if err != nil {
status = "error"
message = "飞书通知发送失败: " + err.Error()
}
default:
// 非飞书渠道仅检查配置非空
if channel.Config == "" {
status = "warning"
message = "渠道未配置参数,请先完善配置"
}
}
h.db.Model(&channel).Update("test_status", status)
@@ -204,3 +222,201 @@ func (h *AlertHandler) TestNotifyChannel(c *gin.Context) {
},
})
}
// ==================== 飞书 Webhook 发送逻辑 ====================
// sendFeishuTest 发送飞书测试通知
func sendFeishuTest(configJSON string) error {
var cfg models.FeishuWebhookConfig
if err := json.Unmarshal([]byte(configJSON), &cfg); err != nil {
return fmt.Errorf("解析配置失败: %w", err)
}
if cfg.URL == "" {
return fmt.Errorf("webhook URL 为空")
}
title := "✅ AIOps 告警测试通知"
content := fmt.Sprintf("通知渠道连通性测试成功\\n测试时间:%s\\n来源:AIOps 智能运维平台", time.Now().Format("2006-01-02 15:04:05"))
return sendFeishuCard(cfg.URL, cfg.Secret, title, content, "green")
}
// sendFeishuAlert 发送飞书告警通知
func sendFeishuAlert(configJSON, title, content, severity string) error {
var cfg models.FeishuWebhookConfig
if err := json.Unmarshal([]byte(configJSON), &cfg); err != nil {
return fmt.Errorf("解析配置失败: %w", err)
}
if cfg.URL == "" {
return fmt.Errorf("webhook URL 为空")
}
color := "red"
if severity == "warning" {
color = "orange"
} else if severity == "info" {
color = "blue"
}
emoji := "🔴"
if severity == "warning" {
emoji = "🟠"
} else if severity == "info" {
emoji = "🔵"
}
title = emoji + " " + title
return sendFeishuCard(cfg.URL, cfg.Secret, title, content, color)
}
// sendFeishuRecovery 发送飞书恢复通知
func sendFeishuRecovery(configJSON, title, content string) error {
var cfg models.FeishuWebhookConfig
if err := json.Unmarshal([]byte(configJSON), &cfg); err != nil {
return fmt.Errorf("解析配置失败: %w", err)
}
if cfg.URL == "" {
return fmt.Errorf("webhook URL 为空")
}
title = "✅ " + title
return sendFeishuCard(cfg.URL, cfg.Secret, title, content, "green")
}
// sendFeishuCard 发送飞书消息(优先使用卡片格式,出错回退到文本)
func sendFeishuCard(webhookURL, secret, title, content, color string) error {
// 先尝试文本格式(最可靠)
textMsg := map[string]interface{}{
"msg_type": "text",
"content": map[string]interface{}{
"text": title + "\n\n" + content,
},
}
body, _ := json.Marshal(textMsg)
req, err := http.NewRequest("POST", webhookURL, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("创建请求失败: %w", err)
}
req.Header.Set("Content-Type", "application/json")
// 如果有 secret,生成签名
if secret != "" {
timestamp := time.Now().Unix()
sign := genFeishuSign(timestamp, secret)
q := req.URL.Query()
q.Set("timestamp", fmt.Sprintf("%d", timestamp))
q.Set("sign", sign)
req.URL.RawQuery = q.Encode()
}
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("发送请求失败: %w", err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("飞书返回错误 (HTTP %d): %s", resp.StatusCode, string(respBody))
}
// 检查飞书业务返回码
var result struct {
Code int `json:"code"`
Msg string `json:"msg"`
}
json.Unmarshal(respBody, &result)
if result.Code != 0 {
return fmt.Errorf("飞书返回错误 (code=%d): %s", result.Code, result.Msg)
}
return nil
}
// buildFeishuCard 构建飞书卡片消息体(保留备用,当前使用文本格式)
func buildFeishuCard(title, content, color string) models.FeishuCardMessage {
return models.FeishuCardMessage{
MsgType: "interactive",
Content: models.FeishuCardContent{
Title: title,
Content: [][]models.FeishuCardElement{
{
{
Tag: "markdown",
Text: content,
},
},
{
{
Tag: "note",
Text: fmt.Sprintf("⏰ %s | AIOps 智能运维平台", time.Now().Format("2006-01-02 15:04:05")),
},
},
},
},
}
}
// genFeishuSign 生成飞书签名
func genFeishuSign(timestamp int64, secret string) string {
stringToSign := fmt.Sprintf("%d\n%s", timestamp, secret)
h := hmac.New(sha256.New, []byte(stringToSign))
signature := base64.StdEncoding.EncodeToString(h.Sum(nil))
return signature
}
// ==================== 批量通知发送 ====================
// NotifyAlert 发送告警通知到所有启用的渠道
func (h *AlertHandler) NotifyAlert(ruleName, hostname, metric, severity, message string, value, threshold float64) {
var channels []models.NotifyChannel
h.db.Where("enabled = ?", true).Find(&channels)
for _, ch := range channels {
switch ch.Type {
case "feishu":
title := buildAlertTitle(ruleName, hostname, metric, severity)
content := buildAlertContent(ruleName, hostname, metric, severity, message, value, threshold)
if err := sendFeishuAlert(ch.Config, title, content, severity); err != nil {
fmt.Printf("[AlertNotify] 飞书通知失败 (渠道:%s): %v\n", ch.Name, err)
}
}
}
}
// NotifyRecovery 发送恢复通知到所有启用的渠道
func (h *AlertHandler) NotifyRecovery(ruleName, hostname, metric, message string) {
var channels []models.NotifyChannel
h.db.Where("enabled = ?", true).Find(&channels)
for _, ch := range channels {
switch ch.Type {
case "feishu":
title := fmt.Sprintf("%s — 已恢复正常", ruleName)
content := fmt.Sprintf("**告警已恢复**\\n\\n主机:%s\\n指标:%s\\n详情:%s\\n恢复时间:%s",
hostname, metric, message, time.Now().Format("2006-01-02 15:04:05"))
if err := sendFeishuRecovery(ch.Config, title, content); err != nil {
fmt.Printf("[AlertNotify] 飞书恢复通知失败 (渠道:%s): %v\n", ch.Name, err)
}
}
}
}
func buildAlertTitle(ruleName, hostname, metric, severity string) string {
sevLabel := "严重告警"
if severity == "warning" {
sevLabel = "警告"
} else if severity == "info" {
sevLabel = "信息"
}
return fmt.Sprintf("[%s] %s — %s (%s)", sevLabel, ruleName, hostname, metric)
}
func buildAlertContent(ruleName, hostname, metric, severity, message string, value, threshold float64) string {
return fmt.Sprintf(
"**规则名称:** %s\\n**目标主机:** %s\\n**指标类型:** %s\\n**告警级别:** %s\\n**当前值:** %.2f\\n**阈值:** %.2f\\n**详细信息:** %s\\n**触发时间:** %s",
ruleName, hostname, metric, severity, value, threshold, message, time.Now().Format("2006-01-02 15:04:05"),
)
}
+94 -4
View File
@@ -27,10 +27,10 @@ func (e *AlertEngine) Check() {
e.autoResolveAlerts()
}
// 检查 CPU/内存/磁盘 阈值
// 检查 CPU/内存/磁盘/进程/TCP连接/端口 阈值
func (e *AlertEngine) checkResourceAlerts() {
var rules []models.AgentRule
e.db.Where("enabled = ? AND metric IN ?", true, []string{"cpu", "mem", "disk"}).Find(&rules)
e.db.Where("enabled = ? AND metric IN ?", true, []string{"cpu", "mem", "disk", "service_process", "tcp_time_wait", "tcp_close_wait"}).Find(&rules)
for _, rule := range rules {
// 获取目标主机最新上报
@@ -58,6 +58,29 @@ func (e *AlertEngine) checkResourceAlerts() {
value = d.UsagePercent
}
}
case "tcp_time_wait":
var netStat models.NetworkStatModel
json.Unmarshal([]byte(report.NetInfo), &netStat)
value = float64(netStat.TCPTimeWait)
case "tcp_close_wait":
var netStat models.NetworkStatModel
json.Unmarshal([]byte(report.NetInfo), &netStat)
value = float64(netStat.TCPCloseWait)
case "service_process":
// 进程不存在告警 — 检查 process_top_info 中是否包含指定进程名
var processes []models.ProcessMetric
json.Unmarshal([]byte(report.ProcessTopInfo), &processes)
found := false
for _, p := range processes {
if p.Name == rule.Hostname || rule.Hostname == "" {
found = true
break
}
}
if found {
continue // 进程存在,不触发
}
value = 0 // 进程不存在时触发
}
// 检查是否超阈值
@@ -89,6 +112,9 @@ func (e *AlertEngine) checkResourceAlerts() {
}
e.db.Create(&alert)
// 发送告警通知
e.notifyAlert(&rule, report.Hostname, alert)
// 更新规则最后触发时间
now := time.Now()
e.db.Model(&rule).Update("last_triggered", now)
@@ -150,10 +176,12 @@ func (e *AlertEngine) checkOfflineAlerts() {
if existing.ID == 0 {
// 获取最后一次上报时间计算离线时长
var lastReport models.AgentReport
e.db.Order("id DESC").First(&lastReport)
e.db.Where("hostname = ?", s.Hostname).Order("id DESC").First(&lastReport)
offlineMinutes := 3.0
var lastReportTime time.Time
if lastReport.ID > 0 {
offlineMinutes = time.Since(lastReport.ReportTime).Minutes()
lastReportTime = lastReport.ReportTime
}
alert := models.AgentAlert{
@@ -166,6 +194,15 @@ func (e *AlertEngine) checkOfflineAlerts() {
Threshold: 3,
}
e.db.Create(&alert)
// 发送离线告警通知
e.notifyAlertRule("主机离线", s.Hostname, "offline", "critical",
alert.Message, offlineMinutes, 3)
// 记录离线上报时间用于恢复通知
if !lastReportTime.IsZero() {
e.db.Model(&alert).Update("created_at", time.Now())
}
}
}
}
@@ -226,6 +263,10 @@ func (e *AlertEngine) checkPortAlerts() {
}
e.db.Create(&alert)
// 发送端口Down告警通知
e.notifyAlertRule(rule.Name, report.Hostname, "port_down", "critical",
alert.Message, float64(rule.Port), float64(rule.Port))
now := time.Now()
e.db.Model(&rule).Update("last_triggered", now)
}
@@ -247,7 +288,7 @@ func (e *AlertEngine) autoResolveAlerts() {
if report.ID > 0 && time.Since(report.ReportTime) < 3*time.Minute {
resolved = true
}
case "cpu", "mem", "disk":
case "cpu", "mem", "disk", "tcp_time_wait", "tcp_close_wait":
// 获取对应规则,检查是否恢复正常
var rule models.AgentRule
e.db.Where("name = ?", alert.RuleName).First(&rule)
@@ -277,6 +318,14 @@ func (e *AlertEngine) autoResolveAlerts() {
value = d.UsagePercent
}
}
case "tcp_time_wait":
var netStat models.NetworkStatModel
json.Unmarshal([]byte(report.NetInfo), &netStat)
value = float64(netStat.TCPTimeWait)
case "tcp_close_wait":
var netStat models.NetworkStatModel
json.Unmarshal([]byte(report.NetInfo), &netStat)
value = float64(netStat.TCPCloseWait)
}
if !e.compareOperator(rule.Operator, value, rule.Threshold) {
resolved = true
@@ -304,6 +353,9 @@ func (e *AlertEngine) autoResolveAlerts() {
"resolved": true,
"resolved_at": now,
})
// 发送恢复通知
e.notifyRecovery(alert.RuleName, alert.Hostname, alert.Metric, alert.Message)
}
}
}
@@ -324,3 +376,41 @@ func (e *AlertEngine) compareOperator(op string, val, threshold float64) bool {
return val > threshold
}
}
// notifyAlert 发送告警通知(通过 AgentRule 对应的 notify_channels)
func (e *AlertEngine) notifyAlert(rule *models.AgentRule, hostname string, alert models.AgentAlert) {
e.notifyAlertRule(rule.Name, hostname, alert.Metric, alert.Severity,
alert.Message, alert.Value, alert.Threshold)
}
// notifyAlertRule 按规则发送告警通知到所有启用的飞书渠道
func (e *AlertEngine) notifyAlertRule(ruleName, hostname, metric, severity, message string, value, threshold float64) {
var channels []models.NotifyChannel
e.db.Where("enabled = ? AND type = ?", true, "feishu").Find(&channels)
for _, ch := range channels {
title := fmt.Sprintf("[%s] %s — %s (%s)", severity, ruleName, hostname, metric)
content := fmt.Sprintf(
"**规则名称:** %s\\n**目标主机:** %s\\n**指标类型:** %s\\n**告警级别:** %s\\n**当前值:** %.2f\\n**阈值:** %.2f\\n**详细信息:** %s\\n**触发时间:** %s",
ruleName, hostname, metric, severity, value, threshold, message, time.Now().Format("2006-01-02 15:04:05"),
)
if err := sendFeishuAlert(ch.Config, title, content, severity); err != nil {
fmt.Printf("[AlertEngine] 飞书通知失败 (渠道:%s): %v\n", ch.Name, err)
}
}
}
// notifyRecovery 发送恢复通知到所有启用的飞书渠道
func (e *AlertEngine) notifyRecovery(ruleName, hostname, metric, message string) {
var channels []models.NotifyChannel
e.db.Where("enabled = ? AND type = ?", true, "feishu").Find(&channels)
for _, ch := range channels {
title := fmt.Sprintf("%s — 已恢复正常", ruleName)
content := fmt.Sprintf("**告警已恢复**\\n\\n主机:%s\\n指标:%s\\n详情:%s\\n恢复时间:%s",
hostname, metric, message, time.Now().Format("2006-01-02 15:04:05"))
if err := sendFeishuRecovery(ch.Config, title, content); err != nil {
fmt.Printf("[AlertEngine] 飞书恢复通知失败 (渠道:%s): %v\n", ch.Name, err)
}
}
}
@@ -1,6 +1,7 @@
package handlers
import (
"fmt"
"net/http"
"strconv"
@@ -272,3 +273,64 @@ func (h *AlertHandler) ToggleRule(c *gin.Context) {
Data: rule,
})
}
// QuickSetupRules 一键创建测试告警规则(飞书Webhook测试用) // Logan.Li
// @Summary 一键创建测试告警规则
// @Tags 告警
// @Router /alerts/quick-setup [post]
func (h *AlertHandler) QuickSetupRules(c *gin.Context) {
type ruleDef struct {
Name string `json:"name"`
Metric string `json:"metric"`
Operator string `json:"operator"`
Threshold float64 `json:"threshold"`
Port int `json:"port,omitempty"`
}
var req struct {
Rules []ruleDef `json:"rules"`
}
c.ShouldBindJSON(&req)
// 默认规则:CPU 80%, 内存 80%, 磁盘 90%, TIME_WAIT 15000, CLOSE_WAIT 200, 端口Down, 进程检测
defaultRules := []ruleDef{
{Name: "CPU使用率", Metric: "cpu", Operator: ">", Threshold: 80},
{Name: "内存使用率", Metric: "mem", Operator: ">", Threshold: 80},
{Name: "磁盘使用率", Metric: "disk", Operator: ">", Threshold: 90},
{Name: "TIME_WAIT连接数", Metric: "tcp_time_wait", Operator: ">", Threshold: 15000},
{Name: "CLOSE_WAIT连接数", Metric: "tcp_close_wait", Operator: ">", Threshold: 200},
{Name: "进程异常退出", Metric: "service_process", Operator: "==", Threshold: 0},
}
rules := req.Rules
if len(rules) == 0 {
rules = defaultRules
}
created := 0
for _, def := range rules {
var existing models.AgentRule
h.db.Where("name = ?", def.Name).First(&existing)
if existing.ID > 0 {
continue
}
rule := models.AgentRule{
Name: def.Name,
Metric: def.Metric,
Operator: def.Operator,
Threshold: def.Threshold,
Port: def.Port,
Enabled: true,
}
if err := h.db.Create(&rule).Error; err != nil {
continue
}
created++
}
c.JSON(http.StatusOK, models.Response{
Code: 200,
Message: fmt.Sprintf("已创建 %d 条告警规则", created),
Data: gin.H{"created": created},
})
}
@@ -566,7 +566,7 @@ var (
// 否则 → 加入队列 (status=queued)
func (h *PipelineHandler) tryExecuteOrQueue(pipeline models.Pipeline, run models.PipelineRun) {
max := pipeline.MaxExecutors
if max <= 0 { max = 1 }
if max <= 0 { max = 2 }
queueMu.Lock()
running := pipelineRunningCount[pipeline.ID]
+80 -10
View File
@@ -49,11 +49,11 @@ func (h *ToolConfigHandler) UpdateTool(c *gin.Context) {
}
var req struct {
DisplayName *string `json:"display_name"`
Description *string `json:"description"`
InstallMethod *models.ToolInstallMethod `json:"install_method"`
Enabled *bool `json:"enabled"`
SortOrder *int `json:"sort_order"`
DisplayName *string `json:"display_name"`
Description *string `json:"description"`
InstallMethod *models.ToolInstallMethod `json:"install_method"`
Enabled *bool `json:"enabled"`
SortOrder *int `json:"sort_order"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: err.Error()})
@@ -87,7 +87,7 @@ func (h *ToolConfigHandler) UpdateTool(c *gin.Context) {
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "已更新"})
}
// InstallTool triggers lazy installation of a tool
// InstallTool triggers lazy installation with optional version override
func (h *ToolConfigHandler) InstallTool(c *gin.Context) {
var tool models.DeployTool
if err := h.db.First(&tool, c.Param("id")); err.Error != nil {
@@ -105,11 +105,33 @@ func (h *ToolConfigHandler) InstallTool(c *gin.Context) {
return
}
// Support version override via query param or JSON body
version := tool.DefaultVersion
if v := c.Query("version"); v != "" {
version = v
}
if v := c.PostForm("version"); v != "" {
version = v
}
// Also support JSON body
var req struct {
Version string `json:"version"`
}
if err := c.ShouldBindJSON(&req); err == nil && req.Version != "" {
version = req.Version
}
// Store the requested version for the installer
if version != "" && version != tool.DefaultVersion {
h.db.Model(&tool).Update("default_version", version)
tool.DefaultVersion = version
}
go func() {
log.Printf("[ToolConfig] Background install triggered for %s", tool.Name)
log.Printf("[ToolConfig] Background install triggered for %s (version: %s)", tool.Name, version)
}()
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "安装已触发"})
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "安装已触发", Data: gin.H{"version": version}})
}
// CheckToolStatus re-checks a tool's installation status
@@ -124,9 +146,13 @@ func (h *ToolConfigHandler) CheckToolStatus(c *gin.Context) {
version := ""
if tool.CheckCommand != "" {
cmd := exec.Command("sh", "-c", tool.CheckCommand)
if err := cmd.Run(); err == nil {
out, err := cmd.Output()
if err == nil {
available = true
version = "已安装"
version = string(out)
if len(version) > 128 {
version = version[:128]
}
}
}
@@ -145,3 +171,47 @@ func (h *ToolConfigHandler) CheckToolStatus(c *gin.Context) {
"tool": tool,
}})
}
// GetToolVersions returns available versions for a tool (jdk/node/maven)
func (h *ToolConfigHandler) GetToolVersions(c *gin.Context) {
var tool models.DeployTool
if err := h.db.First(&tool, c.Param("id")); err.Error != nil {
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "工具不存在"})
return
}
versions := getPresetVersions(tool.Name)
c.JSON(http.StatusOK, models.Response{Code: 200, Data: gin.H{
"name": tool.Name,
"default": tool.DefaultVersion,
"versions": versions,
}})
}
// getPresetVersions returns known versions for common tools
func getPresetVersions(toolName string) []map[string]string {
switch toolName {
case "jdk":
return []map[string]string{
{"version": "11.0.24+8", "label": "JDK 11 (LTS)"},
{"version": "17.0.13+7", "label": "JDK 17 (LTS) ★ 推荐"},
{"version": "21.0.5+11", "label": "JDK 21 (LTS)"},
{"version": "23.0.1+11", "label": "JDK 23 (最新)"},
}
case "nodejs":
return []map[string]string{
{"version": "v18.20.5", "label": "Node 18 (LTS)"},
{"version": "v20.18.1", "label": "Node 20 (LTS)"},
{"version": "v22.14.0", "label": "Node 22 (LTS) ★ 推荐"},
}
case "maven":
return []map[string]string{
{"version": "3.9.9", "label": "Maven 3.9.9 ★ 推荐"},
{"version": "3.8.8", "label": "Maven 3.8.8"},
{"version": "3.6.3", "label": "Maven 3.6.3"},
}
default:
return nil
}
}