package controllers import ( "crypto/hmac" "crypto/sha256" "encoding/json" "errors" "fmt" "strings" "time" "git.apinb.com/bsm-sdk/core/infra" "git.apinb.com/ops/logs/internal/config" "git.apinb.com/ops/logs/internal/impl" "git.apinb.com/ops/logs/internal/models" "github.com/gin-gonic/gin" "gorm.io/gorm" ) const ( resourceEventUpsert = "resource.upsert" resourceEventDelete = "resource.delete" ) type resourceEventRequest struct { EventID string `json:"event_id"` EventTime string `json:"event_time"` EventType string `json:"event_type"` ResourceType string `json:"resource_type"` ResourceID string `json:"resource_id"` ResourceName string `json:"resource_name"` IPs []string `json:"ips"` Hostnames []string `json:"hostnames"` Labels map[string]string `json:"labels"` Version int64 `json:"version"` } // ReceiveResourceEvent 接收 dc-control 推送的资源变更事件并落库。 func ReceiveResourceEvent(ctx *gin.Context) { raw, err := ctx.GetRawData() if err != nil { infra.Response.Error(ctx, err) return } if err := verifyResourceEventSignature(ctx.GetHeader("X-Event-Signature"), raw); err != nil { infra.Response.Error(ctx, err) return } var req resourceEventRequest if err := json.Unmarshal(raw, &req); err != nil { infra.Response.Error(ctx, err) return } eventTime, err := validateResourceEventRequest(&req) if err != nil { infra.Response.Error(ctx, err) return } if err := validateEventTimeSkew(eventTime); err != nil { infra.Response.Error(ctx, err) return } if ok, err := tryInsertResourceEventDedup(req.EventID, eventTime, req.ResourceType, req.ResourceID); err != nil { infra.Response.Error(ctx, err) return } else if !ok { infra.Response.Success(ctx, gin.H{ "ignored": true, "reason": "duplicate_event_id", "event_id": req.EventID, }) return } var row models.ResourceMapping err = impl.DBService.Where("resource_type = ? AND resource_id = ?", req.ResourceType, req.ResourceID).First(&row).Error if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { infra.Response.Error(ctx, err) return } // 已存在记录且版本回退时忽略该事件,避免乱序覆盖。 if err == nil && row.Version > req.Version { infra.Response.Success(ctx, gin.H{ "ignored": true, "reason": "stale_version", "current": row.Version, "incoming": req.Version, }) return } ipsJSON, _ := json.Marshal(nonEmptyUnique(req.IPs)) hostnamesJSON, _ := json.Marshal(nonEmptyUnique(req.Hostnames)) labelsJSON, _ := json.Marshal(req.Labels) row.ResourceType = req.ResourceType row.ResourceID = req.ResourceID row.ResourceName = req.ResourceName row.IPsJSON = string(ipsJSON) row.HostnamesJSON = string(hostnamesJSON) row.LabelsJSON = string(labelsJSON) row.Version = req.Version row.LastEventID = req.EventID row.EventTime = eventTime row.IsDeleted = req.EventType == resourceEventDelete if err := impl.DBService.Save(&row).Error; err != nil { infra.Response.Error(ctx, err) return } infra.Response.Success(ctx, gin.H{ "resource_type": row.ResourceType, "resource_id": row.ResourceID, "version": row.Version, "is_deleted": row.IsDeleted, }) } func validateResourceEventRequest(req *resourceEventRequest) (time.Time, error) { req.EventID = strings.TrimSpace(req.EventID) req.EventType = strings.TrimSpace(req.EventType) req.ResourceType = strings.TrimSpace(req.ResourceType) req.ResourceID = strings.TrimSpace(req.ResourceID) req.ResourceName = strings.TrimSpace(req.ResourceName) req.EventTime = strings.TrimSpace(req.EventTime) if req.EventID == "" { return time.Time{}, errors.New("event_id is required") } if req.EventType != resourceEventUpsert && req.EventType != resourceEventDelete { return time.Time{}, errors.New("event_type must be resource.upsert or resource.delete") } if req.ResourceType == "" { return time.Time{}, errors.New("resource_type is required") } if req.ResourceID == "" { return time.Time{}, errors.New("resource_id is required") } if req.Version <= 0 { return time.Time{}, errors.New("version must be positive") } if req.EventTime == "" { return time.Time{}, errors.New("event_time is required") } tm, err := time.Parse(time.RFC3339, req.EventTime) if err != nil { return time.Time{}, errors.New("event_time must be RFC3339") } return tm, nil } func nonEmptyUnique(items []string) []string { if len(items) == 0 { return nil } seen := make(map[string]struct{}, len(items)) out := make([]string, 0, len(items)) for _, item := range items { v := strings.TrimSpace(item) if v == "" { continue } if _, ok := seen[v]; ok { continue } seen[v] = struct{}{} out = append(out, v) } return out } func verifyResourceEventSignature(signature string, body []byte) error { signature = strings.TrimSpace(signature) signature = strings.TrimPrefix(strings.ToLower(signature), "sha256=") secret := strings.TrimSpace(config.Spec.ResourceEvent.HMACSecret) if secret == "" { return errors.New("resource_event hmac_secret is not configured") } if signature == "" { return errors.New("missing X-Event-Signature") } mac := hmac.New(sha256.New, []byte(secret)) mac.Write(body) expected := fmt.Sprintf("%x", mac.Sum(nil)) if !hmac.Equal([]byte(strings.ToLower(signature)), []byte(expected)) { return errors.New("invalid X-Event-Signature") } return nil } func validateEventTimeSkew(eventTime time.Time) error { maxSkew := config.Spec.ResourceEvent.MaxSkewSecs if maxSkew <= 0 { maxSkew = 300 } diff := time.Since(eventTime) if diff < 0 { diff = -diff } if diff > time.Duration(maxSkew)*time.Second { return errors.New("event_time out of allowed skew window") } return nil } func tryInsertResourceEventDedup(eventID string, eventTime time.Time, resourceType, resourceID string) (bool, error) { // 先查询再插入,避免依赖数据库唯一索引存在与否。 var existed models.ResourceEventDedup if err := impl.DBService.Where("event_id = ?", eventID).First(&existed).Error; err == nil { return false, nil } row := models.ResourceEventDedup{ EventID: eventID, EventTime: eventTime, ResourceType: resourceType, ResourceID: resourceID, } if err := impl.DBService.Create(&row).Error; err != nil { if strings.Contains(strings.ToLower(err.Error()), "duplicate") || strings.Contains(strings.ToLower(err.Error()), "unique") { return false, nil } return false, err } return true, nil }