diff --git a/server/api/v1/enter.go b/server/api/v1/enter.go index 2cebe15..b4656df 100644 --- a/server/api/v1/enter.go +++ b/server/api/v1/enter.go @@ -5,6 +5,7 @@ import ( "github.com/flipped-aurora/gin-vue-admin/server/api/v1/example" "github.com/flipped-aurora/gin-vue-admin/server/api/v1/imageregistry" "github.com/flipped-aurora/gin-vue-admin/server/api/v1/instance" + "github.com/flipped-aurora/gin-vue-admin/server/api/v1/pcdn" "github.com/flipped-aurora/gin-vue-admin/server/api/v1/product" "github.com/flipped-aurora/gin-vue-admin/server/api/v1/system" ) @@ -18,4 +19,5 @@ type ApiGroup struct { ComputenodeApiGroup computenode.ApiGroup ProductApiGroup product.ApiGroup InstanceApiGroup instance.ApiGroup + PcdnApiGroup pcdn.ApiGroup } diff --git a/server/api/v1/pcdn/enter.go b/server/api/v1/pcdn/enter.go new file mode 100644 index 0000000..8ee817e --- /dev/null +++ b/server/api/v1/pcdn/enter.go @@ -0,0 +1,10 @@ +package pcdn + +import "github.com/flipped-aurora/gin-vue-admin/server/service" + +type ApiGroup struct{ PcdnMetricsApi } + +var ( + pcdnMetricsService = service.ServiceGroupApp.PcdnServiceGroup.MetricsService + healthScoreService = service.ServiceGroupApp.PcdnServiceGroup.HealthScoreService +) diff --git a/server/api/v1/pcdn/pcdn_metrics.go b/server/api/v1/pcdn/pcdn_metrics.go new file mode 100644 index 0000000..467a0b7 --- /dev/null +++ b/server/api/v1/pcdn/pcdn_metrics.go @@ -0,0 +1,69 @@ +package pcdn + +import ( + "errors" + + "github.com/flipped-aurora/gin-vue-admin/server/global" + "github.com/flipped-aurora/gin-vue-admin/server/model/common/response" + pcdnReq "github.com/flipped-aurora/gin-vue-admin/server/model/pcdn/request" + "github.com/gin-gonic/gin" + "go.uber.org/zap" + "gorm.io/gorm" +) + +type PcdnMetricsApi struct{} + +func (p *PcdnMetricsApi) ReportMetrics(c *gin.Context) { + ctx := c.Request.Context() + var req pcdnReq.PcdnMetricsReport + if err := c.ShouldBindJSON(&req); err != nil { + response.FailWithMessage(err.Error(), c) + return + } + if nodeIDFromToken, ok := c.Get("pcdnNodeID"); ok { + if id, ok := nodeIDFromToken.(uint); ok && id != req.NodeID { + response.FailWithMessage("节点身份与上报节点不匹配", c) + return + } + } + + snapshot, weight, err := pcdnMetricsService.ReportMetrics(ctx, req) + if err != nil { + global.GVA_LOG.Error("上报PCDN指标失败", zap.Error(err), zap.Uint("nodeId", req.NodeID)) + response.FailWithMessage("上报失败:"+err.Error(), c) + return + } + + response.OkWithDetailed(gin.H{ + "snapshot": snapshot, + "schedulerWeight": weight, + }, "上报成功", c) +} + +func (p *PcdnMetricsApi) GetLatestSnapshot(c *gin.Context) { + ctx := c.Request.Context() + var req struct { + NodeID uint `form:"nodeId" binding:"required"` + } + if err := c.ShouldBindQuery(&req); err != nil { + response.FailWithMessage(err.Error(), c) + return + } + + snapshot, err := pcdnMetricsService.GetLatestSnapshot(ctx, req.NodeID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + response.FailWithMessage("节点暂无指标快照", c) + return + } + global.GVA_LOG.Error("查询PCDN指标失败", zap.Error(err), zap.Uint("nodeId", req.NodeID)) + response.FailWithMessage("查询失败:"+err.Error(), c) + return + } + + weight := healthScoreService.SchedulerWeight(snapshot.Window1mScore, snapshot.Window5mScore, snapshot.Window15mScore) + response.OkWithData(gin.H{ + "snapshot": snapshot, + "schedulerWeight": weight, + }, c) +} diff --git a/server/initialize/gorm_biz.go b/server/initialize/gorm_biz.go index e5f2f97..7bee8af 100644 --- a/server/initialize/gorm_biz.go +++ b/server/initialize/gorm_biz.go @@ -5,12 +5,13 @@ import ( "github.com/flipped-aurora/gin-vue-admin/server/model/computenode" "github.com/flipped-aurora/gin-vue-admin/server/model/imageregistry" "github.com/flipped-aurora/gin-vue-admin/server/model/instance" + "github.com/flipped-aurora/gin-vue-admin/server/model/pcdn" "github.com/flipped-aurora/gin-vue-admin/server/model/product" ) func bizModel() error { db := global.GVA_DB - err := db.AutoMigrate(imageregistry.ImageRegistry{}, computenode.ComputeNode{}, product.ProductSpec{}, instance.Instance{}) + err := db.AutoMigrate(imageregistry.ImageRegistry{}, computenode.ComputeNode{}, product.ProductSpec{}, instance.Instance{}, pcdn.PcdnMetricSnapshot{}) if err != nil { return err } diff --git a/server/initialize/router_biz.go b/server/initialize/router_biz.go index ce44c87..266fedd 100644 --- a/server/initialize/router_biz.go +++ b/server/initialize/router_biz.go @@ -25,8 +25,13 @@ func initBizRouter(routers ...*gin.RouterGroup) { productRouter := router.RouterGroupApp.Product productRouter.InitProductSpecRouter(privateGroup, publicGroup) } // 占位方法,保证文件可以正确加载,避免go空变量检测报错,请勿删除。 + { instanceRouter := router.RouterGroupApp.Instance instanceRouter.InitInstanceRouter(privateGroup, publicGroup) } + { + pcdnRouter := router.RouterGroupApp.Pcdn + pcdnRouter.InitPcdnMetricsRouter(privateGroup, publicGroup) + } } diff --git a/server/middleware/pcdn_node_auth.go b/server/middleware/pcdn_node_auth.go new file mode 100644 index 0000000..682b706 --- /dev/null +++ b/server/middleware/pcdn_node_auth.go @@ -0,0 +1,73 @@ +package middleware + +import ( + "crypto/hmac" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "fmt" + "strconv" + "strings" + "time" + + "github.com/flipped-aurora/gin-vue-admin/server/global" + "github.com/flipped-aurora/gin-vue-admin/server/model/computenode" + "github.com/gin-gonic/gin" +) + +// PcdnNodeAuth 通过节点密钥签名校验上报请求,避免伪造上报。 +// Header: +// +// X-Node-Id: 节点ID +// X-Node-Timestamp: Unix秒时间戳 +// X-Node-Signature: hex(HMAC-SHA256("{nodeId}:{timestamp}", node.password)) +func PcdnNodeAuth() gin.HandlerFunc { + return func(c *gin.Context) { + nodeIDHeader := c.GetHeader("X-Node-Id") + tsHeader := c.GetHeader("X-Node-Timestamp") + signatureHeader := strings.ToLower(c.GetHeader("X-Node-Signature")) + if nodeIDHeader == "" || tsHeader == "" || signatureHeader == "" { + c.AbortWithStatusJSON(401, gin.H{"msg": "缺少节点鉴权头"}) + return + } + + nodeID64, err := strconv.ParseUint(nodeIDHeader, 10, 64) + if err != nil { + c.AbortWithStatusJSON(401, gin.H{"msg": "无效节点ID"}) + return + } + ts, err := strconv.ParseInt(tsHeader, 10, 64) + if err != nil { + c.AbortWithStatusJSON(401, gin.H{"msg": "无效时间戳"}) + return + } + + now := time.Now().Unix() + if ts < now-300 || ts > now+60 { + c.AbortWithStatusJSON(401, gin.H{"msg": "签名已过期"}) + return + } + + var node computenode.ComputeNode + if err = global.GVA_DB.Where("id = ?", uint(nodeID64)).First(&node).Error; err != nil { + c.AbortWithStatusJSON(401, gin.H{"msg": "节点不存在"}) + return + } + if node.Password == nil || *node.Password == "" { + c.AbortWithStatusJSON(401, gin.H{"msg": "节点未配置上报密钥"}) + return + } + + mac := hmac.New(sha256.New, []byte(*node.Password)) + mac.Write([]byte(fmt.Sprintf("%d:%d", nodeID64, ts))) + expectedSignature := hex.EncodeToString(mac.Sum(nil)) + + if subtle.ConstantTimeCompare([]byte(signatureHeader), []byte(expectedSignature)) != 1 { + c.AbortWithStatusJSON(401, gin.H{"msg": "签名校验失败"}) + return + } + + c.Set("pcdnNodeID", uint(nodeID64)) + c.Next() + } +} diff --git a/server/model/pcdn/pcdn_metric_snapshot.go b/server/model/pcdn/pcdn_metric_snapshot.go new file mode 100644 index 0000000..eec3f99 --- /dev/null +++ b/server/model/pcdn/pcdn_metric_snapshot.go @@ -0,0 +1,27 @@ +package pcdn + +import ( + "time" + + "github.com/flipped-aurora/gin-vue-admin/server/global" +) + +// PcdnMetricSnapshot PCDN 节点周期指标快照 +type PcdnMetricSnapshot struct { + global.GVA_MODEL + NodeID uint `json:"nodeId" gorm:"comment:节点ID;column:node_id;index:idx_node_reported_at,priority:1"` + ReportedAt time.Time `json:"reportedAt" gorm:"comment:节点上报时间;column:reported_at;index:idx_node_reported_at,priority:2"` + RTTMs float64 `json:"rttMs" gorm:"comment:RTT毫秒;column:rtt_ms;type:decimal(10,3)"` + PacketLossRate float64 `json:"packetLossRate" gorm:"comment:丢包率(0~1);column:packet_loss_rate;type:decimal(10,6)"` + BandwidthUtilization float64 `json:"bandwidthUtilization" gorm:"comment:带宽占用率(0~1);column:bandwidth_utilization;type:decimal(10,6)"` + CacheHitRate float64 `json:"cacheHitRate" gorm:"comment:缓存命中率(0~1);column:cache_hit_rate;type:decimal(10,6)"` + ActiveConnections int64 `json:"activeConnections" gorm:"comment:活跃连接数;column:active_connections"` + HealthScore float64 `json:"healthScore" gorm:"comment:健康分(0~100);column:health_score;type:decimal(10,3)"` + Window1mScore float64 `json:"window1mScore" gorm:"comment:1分钟窗口健康分;column:window_1m_score;type:decimal(10,3)"` + Window5mScore float64 `json:"window5mScore" gorm:"comment:5分钟窗口健康分;column:window_5m_score;type:decimal(10,3)"` + Window15mScore float64 `json:"window15mScore" gorm:"comment:15分钟窗口健康分;column:window_15m_score;type:decimal(10,3)"` +} + +func (PcdnMetricSnapshot) TableName() string { + return "pcdn_metric_snapshot" +} diff --git a/server/model/pcdn/request/pcdn_metrics.go b/server/model/pcdn/request/pcdn_metrics.go new file mode 100644 index 0000000..682e01b --- /dev/null +++ b/server/model/pcdn/request/pcdn_metrics.go @@ -0,0 +1,13 @@ +package request + +import "time" + +type PcdnMetricsReport struct { + NodeID uint `json:"nodeId" binding:"required"` + ReportedAt time.Time `json:"reportedAt"` + RTTMs float64 `json:"rttMs" binding:"required,gte=0"` + PacketLossRate float64 `json:"packetLossRate" binding:"required,gte=0,lte=1"` + BandwidthUtilization float64 `json:"bandwidthUtilization" binding:"required,gte=0,lte=1"` + CacheHitRate float64 `json:"cacheHitRate" binding:"required,gte=0,lte=1"` + ActiveConnections int64 `json:"activeConnections" binding:"required,gte=0"` +} diff --git a/server/router/enter.go b/server/router/enter.go index 8b8e800..0a72aa2 100644 --- a/server/router/enter.go +++ b/server/router/enter.go @@ -5,6 +5,7 @@ import ( "github.com/flipped-aurora/gin-vue-admin/server/router/example" "github.com/flipped-aurora/gin-vue-admin/server/router/imageregistry" "github.com/flipped-aurora/gin-vue-admin/server/router/instance" + "github.com/flipped-aurora/gin-vue-admin/server/router/pcdn" "github.com/flipped-aurora/gin-vue-admin/server/router/product" "github.com/flipped-aurora/gin-vue-admin/server/router/system" ) @@ -18,4 +19,5 @@ type RouterGroup struct { Computenode computenode.RouterGroup Product product.RouterGroup Instance instance.RouterGroup + Pcdn pcdn.RouterGroup } diff --git a/server/router/pcdn/enter.go b/server/router/pcdn/enter.go new file mode 100644 index 0000000..1268fa9 --- /dev/null +++ b/server/router/pcdn/enter.go @@ -0,0 +1,7 @@ +package pcdn + +import api "github.com/flipped-aurora/gin-vue-admin/server/api/v1" + +type RouterGroup struct{ PcdnMetricsRouter } + +var pcdnMetricsApi = api.ApiGroupApp.PcdnApiGroup.PcdnMetricsApi diff --git a/server/router/pcdn/pcdn_metrics.go b/server/router/pcdn/pcdn_metrics.go new file mode 100644 index 0000000..7b50de6 --- /dev/null +++ b/server/router/pcdn/pcdn_metrics.go @@ -0,0 +1,19 @@ +package pcdn + +import ( + "github.com/flipped-aurora/gin-vue-admin/server/middleware" + "github.com/gin-gonic/gin" +) + +type PcdnMetricsRouter struct{} + +func (r *PcdnMetricsRouter) InitPcdnMetricsRouter(privateRouter *gin.RouterGroup, publicRouter *gin.RouterGroup) { + pcdnPrivate := privateRouter.Group("pcdnMetrics") + pcdnPublic := publicRouter.Group("pcdnMetrics").Use(middleware.PcdnNodeAuth()) + { + pcdnPublic.POST("report", pcdnMetricsApi.ReportMetrics) + } + { + pcdnPrivate.GET("latest", pcdnMetricsApi.GetLatestSnapshot) + } +} diff --git a/server/service/enter.go b/server/service/enter.go index 79a8fd6..ad2616b 100644 --- a/server/service/enter.go +++ b/server/service/enter.go @@ -5,6 +5,7 @@ import ( "github.com/flipped-aurora/gin-vue-admin/server/service/example" "github.com/flipped-aurora/gin-vue-admin/server/service/imageregistry" "github.com/flipped-aurora/gin-vue-admin/server/service/instance" + "github.com/flipped-aurora/gin-vue-admin/server/service/pcdn" "github.com/flipped-aurora/gin-vue-admin/server/service/product" "github.com/flipped-aurora/gin-vue-admin/server/service/system" ) @@ -18,4 +19,5 @@ type ServiceGroup struct { ComputenodeServiceGroup computenode.ServiceGroup ProductServiceGroup product.ServiceGroup InstanceServiceGroup instance.ServiceGroup + PcdnServiceGroup pcdn.ServiceGroup } diff --git a/server/service/pcdn/enter.go b/server/service/pcdn/enter.go new file mode 100644 index 0000000..9882c9d --- /dev/null +++ b/server/service/pcdn/enter.go @@ -0,0 +1,6 @@ +package pcdn + +type ServiceGroup struct { + MetricsService + HealthScoreService +} diff --git a/server/service/pcdn/health_score.go b/server/service/pcdn/health_score.go new file mode 100644 index 0000000..da6366a --- /dev/null +++ b/server/service/pcdn/health_score.go @@ -0,0 +1,100 @@ +package pcdn + +import ( + "context" + "math" + "time" + + "github.com/flipped-aurora/gin-vue-admin/server/global" +) + +type HealthScoreService struct{} + +func (h *HealthScoreService) CalculateFromMetrics(rttMs, packetLossRate, bandwidthUtilization, cacheHitRate float64, activeConnections int64) float64 { + rttScore := metricLinearScore(rttMs, 20, 300, true) + packetLossScore := metricLinearScore(packetLossRate, 0, 0.1, true) + bandwidthScore := metricLinearScore(bandwidthUtilization, 0.2, 0.95, true) + cacheScore := metricLinearScore(cacheHitRate, 0.3, 0.95, false) + connectionsScore := metricLinearScore(float64(activeConnections), 50, 5000, true) + + raw := rttScore*0.28 + packetLossScore*0.24 + bandwidthScore*0.18 + cacheScore*0.22 + connectionsScore*0.08 + return round2(clamp(raw, 0, 100)) +} + +func (h *HealthScoreService) CalculateWindowScores(ctx context.Context, nodeID uint, fallbackScore float64) (window1m, window5m, window15m float64, err error) { + window1m, err = h.windowAvg(ctx, nodeID, time.Minute, fallbackScore) + if err != nil { + return 0, 0, 0, err + } + window5m, err = h.windowAvg(ctx, nodeID, 5*time.Minute, fallbackScore) + if err != nil { + return 0, 0, 0, err + } + window15m, err = h.windowAvg(ctx, nodeID, 15*time.Minute, fallbackScore) + if err != nil { + return 0, 0, 0, err + } + return round2(window1m), round2(window5m), round2(window15m), nil +} + +func (h *HealthScoreService) SchedulerWeight(window1m, window5m, window15m float64) float64 { + weighted := window1m*0.5 + window5m*0.3 + window15m*0.2 + return round4(clamp(weighted/100, 0.05, 1.0)) +} + +func (h *HealthScoreService) windowAvg(ctx context.Context, nodeID uint, window time.Duration, fallback float64) (float64, error) { + type result struct { + Avg float64 + Count int64 + } + var res result + err := global.GVA_DB.WithContext(ctx). + Table("pcdn_metric_snapshot"). + Select("COALESCE(AVG(health_score),0) as avg, COUNT(1) as count"). + Where("node_id = ? AND reported_at >= ?", nodeID, time.Now().Add(-window)). + Scan(&res).Error + if err != nil { + return 0, err + } + if res.Count == 0 { + return fallback, nil + } + return res.Avg, nil +} + +func metricLinearScore(value, good, bad float64, lowerBetter bool) float64 { + if lowerBetter { + if value <= good { + return 100 + } + if value >= bad { + return 0 + } + return (bad - value) / (bad - good) * 100 + } + if value >= bad { + return 100 + } + if value <= good { + return 0 + } + return (value - good) / (bad - good) * 100 +} + +func clamp(v, min, max float64) float64 { + if v < min { + return min + } + if v > max { + return max + } + return v +} + +func round2(v float64) float64 { + return math.Round(v*100) / 100 +} + +func round4(v float64) float64 { + return math.Round(v*10000) / 10000 +} diff --git a/server/service/pcdn/pcdn_metrics.go b/server/service/pcdn/pcdn_metrics.go new file mode 100644 index 0000000..1c21aa9 --- /dev/null +++ b/server/service/pcdn/pcdn_metrics.go @@ -0,0 +1,52 @@ +package pcdn + +import ( + "context" + "time" + + "github.com/flipped-aurora/gin-vue-admin/server/global" + "github.com/flipped-aurora/gin-vue-admin/server/model/pcdn" + pcdnReq "github.com/flipped-aurora/gin-vue-admin/server/model/pcdn/request" +) + +type MetricsService struct{} + +func (m *MetricsService) ReportMetrics(ctx context.Context, req pcdnReq.PcdnMetricsReport) (pcdn.PcdnMetricSnapshot, float64, error) { + reportedAt := req.ReportedAt + if reportedAt.IsZero() { + reportedAt = time.Now() + } + + healthSvc := HealthScoreService{} + healthScore := healthSvc.CalculateFromMetrics(req.RTTMs, req.PacketLossRate, req.BandwidthUtilization, req.CacheHitRate, req.ActiveConnections) + window1m, window5m, window15m, err := healthSvc.CalculateWindowScores(ctx, req.NodeID, healthScore) + if err != nil { + return pcdn.PcdnMetricSnapshot{}, 0, err + } + + snapshot := pcdn.PcdnMetricSnapshot{ + NodeID: req.NodeID, + ReportedAt: reportedAt, + RTTMs: req.RTTMs, + PacketLossRate: req.PacketLossRate, + BandwidthUtilization: req.BandwidthUtilization, + CacheHitRate: req.CacheHitRate, + ActiveConnections: req.ActiveConnections, + HealthScore: healthScore, + Window1mScore: window1m, + Window5mScore: window5m, + Window15mScore: window15m, + } + if err = global.GVA_DB.WithContext(ctx).Create(&snapshot).Error; err != nil { + return pcdn.PcdnMetricSnapshot{}, 0, err + } + + weight := healthSvc.SchedulerWeight(window1m, window5m, window15m) + return snapshot, weight, nil +} + +func (m *MetricsService) GetLatestSnapshot(ctx context.Context, nodeID uint) (pcdn.PcdnMetricSnapshot, error) { + var snapshot pcdn.PcdnMetricSnapshot + err := global.GVA_DB.WithContext(ctx).Where("node_id = ?", nodeID).Order("reported_at desc").First(&snapshot).Error + return snapshot, err +}