chore: 统一本地服务器与远程仓库源码

- 合并 main 与 master 历史并以服务器结构为基线
- 保留迁移、数据保留、AWS 账单与部署安全能力
- 建立 SDD 规格和 CI 验证闭环
This commit is contained in:
Logan.Li committed 2026-08-05 20:03:25 +08:00
commit fd60361d93
460 files changed
+37492 -23871

No files matched your search

+98 -92
View File
@@ -11,6 +11,7 @@ import (
"strings"
"time"
"ezops/middleware"
"ezops/model"
ezredis "ezops/pkg/redis"
@@ -30,6 +31,24 @@ func NewAgentHandler(db *gorm.DB, cache ezredis.Cache, hub *MetricsHub) *AgentHa
return &AgentHandler{db: db, cache: cache, hubMetrics: hub}
}
// resolveAgentAsset 优先按上报主机名或内网 IP 关联现有资产,避免云实例名与系统 hostname 不一致。 // Logan.Li
func (h *AgentHandler) resolveAgentAsset(hostname, reportIP, clientIP string) (model.Server, string, bool) {
matchIP := strings.TrimSpace(reportIP)
if matchIP == "" || matchIP == "unknown" {
matchIP = strings.TrimSpace(clientIP)
}
var server model.Server
query := h.db.Where("hostname = ?", hostname)
if matchIP != "" {
query = h.db.Where("hostname = ? OR ip = ?", hostname, matchIP)
}
if err := query.Take(&server).Error; err == nil {
return server, matchIP, true
}
return server, matchIP, false
}
// ReceiveReport 接收 Agent 上报的指标数据
// POST /api/v1/agent/report
func (h *AgentHandler) ReceiveReport(c *gin.Context) {
@@ -38,6 +57,9 @@ func (h *AgentHandler) ReceiveReport(c *gin.Context) {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请求格式错误: " + err.Error()})
return
}
if !middleware.RequireAgentHostname(c, req.Hostname) {
return
}
// 解析上报时间
reportTime := time.Now()
@@ -46,6 +68,14 @@ func (h *AgentHandler) ReceiveReport(c *gin.Context) {
reportTime = t
}
}
// Agent 上报时间统一存储为 UTC,避免 SQLite 按带时区文本比较时误判。 // Logan.Li
reportTime = reportTime.UTC()
matchedServer, matchIP, assetMatched := h.resolveAgentAsset(req.Hostname, req.IP, c.ClientIP())
canonicalHostname := req.Hostname
if assetMatched {
canonicalHostname = matchedServer.Hostname
}
// 序列化磁盘和端口信息
diskJSON, _ := json.Marshal(req.Disks)
@@ -61,8 +91,7 @@ func (h *AgentHandler) ReceiveReport(c *gin.Context) {
// 存储到数据库
report := model.AgentReport{
Hostname: req.Hostname,
IP: strings.TrimSpace(req.IP), // 使用 Agent 自报 IP,避免反向代理地址误关联。 // Logan.Li
Hostname: canonicalHostname,
CPUUsage: req.CPU.UsagePercent,
CPUIOwait: req.CPU.IOWait,
CPUCores: req.CPU.CoreCount,
@@ -104,86 +133,71 @@ func (h *AgentHandler) ReceiveReport(c *gin.Context) {
memMB := int64(memGB * 1024) // GB → MB
diskGB := int64(totalDiskGB + 0.5) // 39.99 → 40
clientIP := c.ClientIP()
// 三级匹配:优先用 Agent 上报的 IP,其次用请求源 IP,最后用报文的 hostname
matchIP := req.IP
if matchIP == "" {
matchIP = clientIP
}
// 直接 SQL 更新 asset_servers 监控字段
// 若 AgentVersion 非空则同步写入,否则保留数据库中已有版本号(防止旧版Agent空字段覆盖已设置的版本)
updateSQL := `UPDATE asset_servers SET
cpu_usage = ?, cpu_iowait = ?, memory_usage = ?, disk_usage = ?,
cpu = ?, memory = ?, disk = ?,
status = ?, agent_status = ?` + func() string {
if req.AgentVersion != "" {
return `, agent_version = ?`
}
return ``
}() + `,
hostname = CASE WHEN hostname != ? AND ip = ? THEN ? ELSE hostname END,
updated_at = ?`
args := []interface{}{
req.CPU.UsagePercent, req.CPU.IOWait, req.Memory.UsagePercent, diskAvg,
req.CPU.CoreCount, memMB, diskGB,
"running", "online",
// 使用 map 按主键更新,避免可选系统字段导致原始 SQL 占位参数错位。 // Logan.Li
updates := map[string]interface{}{
"cpu_usage": req.CPU.UsagePercent, "memory_usage": req.Memory.UsagePercent,
"disk_usage": diskAvg,
"cpu": req.CPU.CoreCount, "memory": memMB, "disk": diskGB,
"status": "running", "agent_status": "online", "updated_at": time.Now(),
}
if req.AgentVersion != "" {
args = append(args, req.AgentVersion)
updates["agent_version"] = req.AgentVersion
}
args = append(args,
// CASE WHEN hostname != ? AND ip = ? THEN ? ELSE hostname END
req.Hostname, matchIP, req.Hostname,
)
if req.System != nil {
if req.System.OSVersion != "" {
updateSQL += ", os = ?"
args = append(args, req.System.OSVersion)
updates["os"] = req.System.OSVersion
}
if req.System.Kernel != "" {
updateSQL += ", kernel = ?"
args = append(args, req.System.Kernel)
updates["kernel"] = req.System.Kernel
}
if req.System.Uptime > 0 {
updateSQL += ", uptime = ?"
args = append(args, req.System.Uptime)
updates["uptime"] = req.System.Uptime
}
}
args = append(args, time.Now())
updateSQL += " WHERE hostname = ? OR ip = ?"
args = append(args, req.Hostname, matchIP)
result := h.db.Exec(updateSQL, args...)
// 如果 hostname 和 IP 都没匹配到,尝试用 hostname 反向匹配 IP 字段
if result.RowsAffected == 0 && req.Hostname != matchIP {
updateSQL2 := updateSQL + " OR ip = ?"
args2 := append(args, req.Hostname)
result = h.db.Exec(updateSQL2, args2...)
// 兼容旧版 Agent:系统信息缺席时使用心跳中的运行时长补齐资产元数据。 // Logan.Li
if _, ok := updates["uptime"]; !ok && req.Heartbeat.UptimeSec > 0 {
updates["uptime"] = req.Heartbeat.UptimeSec
}
var result *gorm.DB
if assetMatched {
result = h.db.Model(&model.Server{}).Where("id = ?", matchedServer.ID).Updates(updates)
} else {
result = h.db.Model(&model.Server{}).Where("hostname = ? OR ip = ?", req.Hostname, matchIP).Updates(updates)
}
// 如果还是没匹配到 —— 按 hostname 显式查重后再注册 // Logan.Li
// asset_servers.hostname 无 UNIQUE 索引,OnConflict 不可靠,故先 First 查重,
// 避免 Agent 每次上报(10s)都新建记录导致资产越来越多。
if result.RowsAffected == 0 {
if result.Error == nil && result.RowsAffected == 0 {
osName := ""
kernel := ""
uptime := req.Heartbeat.UptimeSec
if req.System != nil {
osName = req.System.OSVersion
kernel = req.System.Kernel
if req.System.Uptime > 0 {
uptime = req.System.Uptime
}
}
var existing model.Server
if h.db.Where("hostname = ?", req.Hostname).First(&existing).Error == nil {
if h.db.Where("hostname = ?", req.Hostname).Take(&existing).Error == nil {
// 已自动注册过 → 仅更新实时指标,不重复创建
h.db.Model(&existing).Updates(map[string]interface{}{
existingUpdates := map[string]interface{}{
"ip": matchIP, "os": osName, "cpu": req.CPU.CoreCount,
"memory": memMB, "disk": diskGB, "status": "running",
"agent_status": "online", "agent_version": req.AgentVersion,
"cpu_usage": req.CPU.UsagePercent, "memory_usage": req.Memory.UsagePercent,
"disk_usage": diskAvg,
})
}
if kernel != "" {
existingUpdates["kernel"] = kernel
}
if uptime > 0 {
existingUpdates["uptime"] = uptime
}
h.db.Model(&existing).Updates(existingUpdates)
} else {
// 真正的新主机 → 注册(OnConflict 作为并发双保险)
// 真正的新主机 → 注册,hostname 无唯一索引时不使用 OnConflict。 // Logan.Li
newServer := model.Server{
Hostname: req.Hostname,
IP: matchIP,
@@ -197,19 +211,13 @@ func (h *AgentHandler) ReceiveReport(c *gin.Context) {
Status: "running",
AgentStatus: "online",
AgentVersion: req.AgentVersion,
Kernel: kernel,
Uptime: uptime,
CPUUsage: req.CPU.UsagePercent,
MemoryUsage: req.Memory.UsagePercent,
DiskUsage: diskAvg,
}
if err := h.db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "hostname"}},
DoUpdates: clause.AssignmentColumns([]string{
"ip", "os", "cpu", "memory", "disk",
"status", "agent_status",
"cpu_usage", "memory_usage", "disk_usage",
"updated_at",
}),
}).Create(&newServer).Error; err != nil {
if err := h.db.Create(&newServer).Error; err != nil {
fmt.Printf("[agent] 自动注册主机 %q (IP: %s) 失败: %v\n", req.Hostname, matchIP, err)
} else {
fmt.Printf("[agent] 自动注册新主机: %q (IP: %s, CPU=%d核, 内存=%dMB, 磁盘=%dGB, OS=%q)\n",
@@ -222,7 +230,7 @@ func (h *AgentHandler) ReceiveReport(c *gin.Context) {
if h.hubMetrics != nil && result.RowsAffected > 0 {
h.hubMetrics.Broadcast(MetricsUpdate{
Type: "metrics_update",
Hostname: req.Hostname,
Hostname: canonicalHostname,
CPU: req.CPU.UsagePercent,
Memory: req.Memory.UsagePercent,
Disk: diskAvg,
@@ -240,13 +248,13 @@ func (h *AgentHandler) ReceiveReport(c *gin.Context) {
if h.cache != nil && h.cache.Capabilities().SortedSet {
timestamp := float64(reportTime.Unix())
ttl := 7 * 24 * time.Hour
h.writeHotMetric(c, "metrics:cpu:"+req.Hostname, req.CPU.UsagePercent, timestamp, ttl, reportTime)
h.writeHotMetric(c, "metrics:mem:"+req.Hostname, req.Memory.UsagePercent, timestamp, ttl, reportTime)
h.writeHotMetric(c, "metrics:load1:"+req.Hostname, req.CPU.Load1, timestamp, ttl, reportTime)
h.writeHotMetric(c, "metrics:cpu:"+canonicalHostname, req.CPU.UsagePercent, timestamp, ttl, reportTime)
h.writeHotMetric(c, "metrics:mem:"+canonicalHostname, req.Memory.UsagePercent, timestamp, ttl, reportTime)
h.writeHotMetric(c, "metrics:load1:"+canonicalHostname, req.CPU.Load1, timestamp, ttl, reportTime)
}
// 保存成功 — 响应中携带探针配置给 Agent
resp := h.makeProbeConfigResponse(req.Hostname, 200, "ok", req.ProbeResults)
resp := h.makeProbeConfigResponse(canonicalHostname, 200, "ok", req.ProbeResults)
c.JSON(http.StatusOK, resp)
}
@@ -277,8 +285,9 @@ func (h *AgentHandler) writeHotMetric(c *gin.Context, key string, value float64,
// GET /api/v1/agent/latest
func (h *AgentHandler) GetLatestReports(c *gin.Context) {
var results []model.AgentLatestResponse
threeMinAgo := time.Now().UTC().Add(-3 * time.Minute) // SQLite 上报时间统一以 UTC 持久化。 // Logan.Li
// 以 servers 表为主,按资产名或 Agent 自报 IP 关联最近 3 分钟内的上报。 // Logan.Li
// 查询边界与 Agent 上报的 UTC 时间保持一致。 // Logan.Li
threeMinAgo := time.Now().UTC().Add(-3 * time.Minute)
// 以 servers 表为主,LEFT JOIN 最近 3 分钟内的 Agent 上报
// 未装 Agent 的主机也会显示(agent_status='not_installed'),打破"不显示→无法安装"的循环
h.db.Raw(`
SELECT
@@ -303,8 +312,7 @@ func (h *AgentHandler) GetLatestReports(c *gin.Context) {
a.report_time,
COALESCE(s.agent_version, '') as agent_version,
CASE
WHEN a.id IS NOT NULL THEN 'online'
WHEN COALESCE(s.agent_version, '') <> '' OR COALESCE(s.agent_status, '') = 'online' THEN 'offline'
WHEN a.hostname IS NOT NULL THEN COALESCE(s.agent_status, 'online')
ELSE 'not_installed'
END as agent_status,
COALESCE(s.os, '') as os,
@@ -314,13 +322,14 @@ func (h *AgentHandler) GetLatestReports(c *gin.Context) {
COALESCE(r.name, '') as region,
CASE WHEN s.password != '' OR s.ssh_key != '' THEN true ELSE false END as has_auth
FROM asset_servers s
LEFT JOIN monitor_agent_reports a ON a.id = (
SELECT ar.id FROM monitor_agent_reports ar
WHERE ar.report_time >= ?
AND (ar.hostname = s.hostname OR (ar.ip <> '' AND ar.ip = s.ip))
ORDER BY ar.report_time DESC, ar.id DESC
LIMIT 1
)
LEFT JOIN (
SELECT * FROM monitor_agent_reports
WHERE id IN (
SELECT MAX(id) FROM monitor_agent_reports
WHERE report_time >= ?
GROUP BY hostname
)
) a ON a.hostname = s.hostname
LEFT JOIN asset_regions r ON r.id = s.region_id
ORDER BY a.report_time DESC, s.id ASC
`, threeMinAgo).Scan(&results)
@@ -348,7 +357,7 @@ func parseFlexibleTime(s string) (time.Time, error) {
}
for _, layout := range layouts {
if t, err := time.ParseInLocation(layout, s, time.Local); err == nil {
return t.UTC(), nil // 历史报告使用 UTC 存储,避免自定义时间范围跨时区漏数。 // Logan.Li
return t, nil
}
}
return time.Time{}, fmt.Errorf("无法解析时间: %s", s)
@@ -356,7 +365,7 @@ func parseFlexibleTime(s string) (time.Time, error) {
// resolveHostHistoryRange 解析历史查询时间范围(minutes / hours / start+end) // Logan.Li
func resolveHostHistoryRange(c *gin.Context) (since, until time.Time, err error) {
until = time.Now().UTC() // 历史报告使用 UTC 存储,避免本地时区造成范围比较失效。 // Logan.Li
until = time.Now()
if startStr := strings.TrimSpace(c.Query("start")); startStr != "" {
endStr := strings.TrimSpace(c.Query("end"))
if endStr == "" {
@@ -410,16 +419,9 @@ func (h *AgentHandler) GetHostHistory(c *gin.Context) {
return
}
var server model.Server
serverIP := ""
if h.db.Where("hostname = ?", hostname).Take(&server).Error == nil {
serverIP = strings.TrimSpace(server.IP)
}
var reports []model.AgentReport
// 资产名可能与操作系统主机名不同,优先兼容 Agent 自报 IP。 // Logan.Li
h.db.Where("hostname = ? OR (ip <> '' AND ip = ?)", hostname, serverIP).
Where("report_time >= ? AND report_time <= ?", since, until).
// SQLite 会按时间文本比较,查询边界需统一转换为 UTC。 // Logan.Li
h.db.Where("hostname = ? AND report_time >= ? AND report_time <= ?", hostname, since.UTC(), until.UTC()).
Order("report_time ASC").
Find(&reports)
@@ -431,7 +433,7 @@ func (h *AgentHandler) GetHostHistory(c *gin.Context) {
}
// AggregateMetrics 聚合指标到 monitor_metrics_aggregate(Redis ZSet 或 DB 直读)// Logan.Li
// POST /api/v1/agent/aggregate
// POST /api/v1/agent/aggregate(需要控制台 JWT) // Logan.Li
func (h *AgentHandler) AggregateMetrics(c *gin.Context) {
var req model.AggregateReq
if err := c.ShouldBindJSON(&req); err != nil {
@@ -504,7 +506,8 @@ func (h *AgentHandler) aggregateFromReports(req model.AggregateReq) (int64, erro
for _, iv := range intervals {
lookback := iv.duration * 200
since := time.Now().Add(-lookback)
// 本地 SQLite 模式下使用 UTC 边界查询 Agent 报告。 // Logan.Li
since := time.Now().UTC().Add(-lookback)
var reports []model.AgentReport
if err := h.db.Where("hostname = ? AND report_time >= ?", req.Hostname, since).
Order("report_time ASC").Find(&reports).Error; err != nil {
@@ -695,6 +698,9 @@ func avgDiskUsage(disks []model.DiskMetric) float64 {
// GET /api/v1/agent/port-policy?hostname=xxx
func (h *AgentHandler) GetPortPolicyForAgent(c *gin.Context) {
hostname := c.Query("hostname")
if !middleware.RequireAgentHostname(c, hostname) {
return
}
if hostname == "" {
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "success", Data: model.AgentPortPolicyDTO{
PortFilterMode: "whitelist",