- 新增 ops 主仓库(ops_repo.go) - 实现告警数据访问(ops_repo_alerts.go) - 实现仪表板数据访问(ops_repo_dashboard.go) - 实现直方图数据访问(ops_repo_histograms.go) - 实现延迟直方图桶逻辑(ops_repo_latency_histogram_buckets.go) - 新增延迟直方图桶测试(ops_repo_latency_histogram_buckets_test.go) - 实现指标数据访问(ops_repo_metrics.go) - 实现预聚合数据访问(ops_repo_preagg.go) - 实现请求详情数据访问(ops_repo_request_details.go) - 实现趋势数据访问(ops_repo_trends.go) - 实现窗口统计数据访问(ops_repo_window_stats.go) - 更新并发缓存支持 ops 场景 - 注册 repository 依赖注入
690 lines
14 KiB
Go
690 lines
14 KiB
Go
package repository
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
|
)
|
|
|
|
func (r *opsRepository) ListAlertRules(ctx context.Context) ([]*service.OpsAlertRule, error) {
|
|
if r == nil || r.db == nil {
|
|
return nil, fmt.Errorf("nil ops repository")
|
|
}
|
|
|
|
q := `
|
|
SELECT
|
|
id,
|
|
name,
|
|
COALESCE(description, ''),
|
|
enabled,
|
|
COALESCE(severity, ''),
|
|
metric_type,
|
|
operator,
|
|
threshold,
|
|
window_minutes,
|
|
sustained_minutes,
|
|
cooldown_minutes,
|
|
COALESCE(notify_email, true),
|
|
filters,
|
|
last_triggered_at,
|
|
created_at,
|
|
updated_at
|
|
FROM ops_alert_rules
|
|
ORDER BY id DESC`
|
|
|
|
rows, err := r.db.QueryContext(ctx, q)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := []*service.OpsAlertRule{}
|
|
for rows.Next() {
|
|
var rule service.OpsAlertRule
|
|
var filtersRaw []byte
|
|
var lastTriggeredAt sql.NullTime
|
|
if err := rows.Scan(
|
|
&rule.ID,
|
|
&rule.Name,
|
|
&rule.Description,
|
|
&rule.Enabled,
|
|
&rule.Severity,
|
|
&rule.MetricType,
|
|
&rule.Operator,
|
|
&rule.Threshold,
|
|
&rule.WindowMinutes,
|
|
&rule.SustainedMinutes,
|
|
&rule.CooldownMinutes,
|
|
&rule.NotifyEmail,
|
|
&filtersRaw,
|
|
&lastTriggeredAt,
|
|
&rule.CreatedAt,
|
|
&rule.UpdatedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
if lastTriggeredAt.Valid {
|
|
v := lastTriggeredAt.Time
|
|
rule.LastTriggeredAt = &v
|
|
}
|
|
if len(filtersRaw) > 0 && string(filtersRaw) != "null" {
|
|
var decoded map[string]any
|
|
if err := json.Unmarshal(filtersRaw, &decoded); err == nil {
|
|
rule.Filters = decoded
|
|
}
|
|
}
|
|
out = append(out, &rule)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (r *opsRepository) CreateAlertRule(ctx context.Context, input *service.OpsAlertRule) (*service.OpsAlertRule, error) {
|
|
if r == nil || r.db == nil {
|
|
return nil, fmt.Errorf("nil ops repository")
|
|
}
|
|
if input == nil {
|
|
return nil, fmt.Errorf("nil input")
|
|
}
|
|
|
|
filtersArg, err := opsNullJSONMap(input.Filters)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
q := `
|
|
INSERT INTO ops_alert_rules (
|
|
name,
|
|
description,
|
|
enabled,
|
|
severity,
|
|
metric_type,
|
|
operator,
|
|
threshold,
|
|
window_minutes,
|
|
sustained_minutes,
|
|
cooldown_minutes,
|
|
notify_email,
|
|
filters,
|
|
created_at,
|
|
updated_at
|
|
) VALUES (
|
|
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,NOW(),NOW()
|
|
)
|
|
RETURNING
|
|
id,
|
|
name,
|
|
COALESCE(description, ''),
|
|
enabled,
|
|
COALESCE(severity, ''),
|
|
metric_type,
|
|
operator,
|
|
threshold,
|
|
window_minutes,
|
|
sustained_minutes,
|
|
cooldown_minutes,
|
|
COALESCE(notify_email, true),
|
|
filters,
|
|
last_triggered_at,
|
|
created_at,
|
|
updated_at`
|
|
|
|
var out service.OpsAlertRule
|
|
var filtersRaw []byte
|
|
var lastTriggeredAt sql.NullTime
|
|
|
|
if err := r.db.QueryRowContext(
|
|
ctx,
|
|
q,
|
|
strings.TrimSpace(input.Name),
|
|
strings.TrimSpace(input.Description),
|
|
input.Enabled,
|
|
strings.TrimSpace(input.Severity),
|
|
strings.TrimSpace(input.MetricType),
|
|
strings.TrimSpace(input.Operator),
|
|
input.Threshold,
|
|
input.WindowMinutes,
|
|
input.SustainedMinutes,
|
|
input.CooldownMinutes,
|
|
input.NotifyEmail,
|
|
filtersArg,
|
|
).Scan(
|
|
&out.ID,
|
|
&out.Name,
|
|
&out.Description,
|
|
&out.Enabled,
|
|
&out.Severity,
|
|
&out.MetricType,
|
|
&out.Operator,
|
|
&out.Threshold,
|
|
&out.WindowMinutes,
|
|
&out.SustainedMinutes,
|
|
&out.CooldownMinutes,
|
|
&out.NotifyEmail,
|
|
&filtersRaw,
|
|
&lastTriggeredAt,
|
|
&out.CreatedAt,
|
|
&out.UpdatedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
if lastTriggeredAt.Valid {
|
|
v := lastTriggeredAt.Time
|
|
out.LastTriggeredAt = &v
|
|
}
|
|
if len(filtersRaw) > 0 && string(filtersRaw) != "null" {
|
|
var decoded map[string]any
|
|
if err := json.Unmarshal(filtersRaw, &decoded); err == nil {
|
|
out.Filters = decoded
|
|
}
|
|
}
|
|
|
|
return &out, nil
|
|
}
|
|
|
|
func (r *opsRepository) UpdateAlertRule(ctx context.Context, input *service.OpsAlertRule) (*service.OpsAlertRule, error) {
|
|
if r == nil || r.db == nil {
|
|
return nil, fmt.Errorf("nil ops repository")
|
|
}
|
|
if input == nil {
|
|
return nil, fmt.Errorf("nil input")
|
|
}
|
|
if input.ID <= 0 {
|
|
return nil, fmt.Errorf("invalid id")
|
|
}
|
|
|
|
filtersArg, err := opsNullJSONMap(input.Filters)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
q := `
|
|
UPDATE ops_alert_rules
|
|
SET
|
|
name = $2,
|
|
description = $3,
|
|
enabled = $4,
|
|
severity = $5,
|
|
metric_type = $6,
|
|
operator = $7,
|
|
threshold = $8,
|
|
window_minutes = $9,
|
|
sustained_minutes = $10,
|
|
cooldown_minutes = $11,
|
|
notify_email = $12,
|
|
filters = $13,
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
RETURNING
|
|
id,
|
|
name,
|
|
COALESCE(description, ''),
|
|
enabled,
|
|
COALESCE(severity, ''),
|
|
metric_type,
|
|
operator,
|
|
threshold,
|
|
window_minutes,
|
|
sustained_minutes,
|
|
cooldown_minutes,
|
|
COALESCE(notify_email, true),
|
|
filters,
|
|
last_triggered_at,
|
|
created_at,
|
|
updated_at`
|
|
|
|
var out service.OpsAlertRule
|
|
var filtersRaw []byte
|
|
var lastTriggeredAt sql.NullTime
|
|
|
|
if err := r.db.QueryRowContext(
|
|
ctx,
|
|
q,
|
|
input.ID,
|
|
strings.TrimSpace(input.Name),
|
|
strings.TrimSpace(input.Description),
|
|
input.Enabled,
|
|
strings.TrimSpace(input.Severity),
|
|
strings.TrimSpace(input.MetricType),
|
|
strings.TrimSpace(input.Operator),
|
|
input.Threshold,
|
|
input.WindowMinutes,
|
|
input.SustainedMinutes,
|
|
input.CooldownMinutes,
|
|
input.NotifyEmail,
|
|
filtersArg,
|
|
).Scan(
|
|
&out.ID,
|
|
&out.Name,
|
|
&out.Description,
|
|
&out.Enabled,
|
|
&out.Severity,
|
|
&out.MetricType,
|
|
&out.Operator,
|
|
&out.Threshold,
|
|
&out.WindowMinutes,
|
|
&out.SustainedMinutes,
|
|
&out.CooldownMinutes,
|
|
&out.NotifyEmail,
|
|
&filtersRaw,
|
|
&lastTriggeredAt,
|
|
&out.CreatedAt,
|
|
&out.UpdatedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if lastTriggeredAt.Valid {
|
|
v := lastTriggeredAt.Time
|
|
out.LastTriggeredAt = &v
|
|
}
|
|
if len(filtersRaw) > 0 && string(filtersRaw) != "null" {
|
|
var decoded map[string]any
|
|
if err := json.Unmarshal(filtersRaw, &decoded); err == nil {
|
|
out.Filters = decoded
|
|
}
|
|
}
|
|
|
|
return &out, nil
|
|
}
|
|
|
|
func (r *opsRepository) DeleteAlertRule(ctx context.Context, id int64) error {
|
|
if r == nil || r.db == nil {
|
|
return fmt.Errorf("nil ops repository")
|
|
}
|
|
if id <= 0 {
|
|
return fmt.Errorf("invalid id")
|
|
}
|
|
|
|
res, err := r.db.ExecContext(ctx, "DELETE FROM ops_alert_rules WHERE id = $1", id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
affected, err := res.RowsAffected()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if affected == 0 {
|
|
return sql.ErrNoRows
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *opsRepository) ListAlertEvents(ctx context.Context, filter *service.OpsAlertEventFilter) ([]*service.OpsAlertEvent, error) {
|
|
if r == nil || r.db == nil {
|
|
return nil, fmt.Errorf("nil ops repository")
|
|
}
|
|
if filter == nil {
|
|
filter = &service.OpsAlertEventFilter{}
|
|
}
|
|
|
|
limit := filter.Limit
|
|
if limit <= 0 {
|
|
limit = 100
|
|
}
|
|
if limit > 500 {
|
|
limit = 500
|
|
}
|
|
|
|
where, args := buildOpsAlertEventsWhere(filter)
|
|
args = append(args, limit)
|
|
limitArg := "$" + itoa(len(args))
|
|
|
|
q := `
|
|
SELECT
|
|
id,
|
|
COALESCE(rule_id, 0),
|
|
COALESCE(severity, ''),
|
|
COALESCE(status, ''),
|
|
COALESCE(title, ''),
|
|
COALESCE(description, ''),
|
|
metric_value,
|
|
threshold_value,
|
|
dimensions,
|
|
fired_at,
|
|
resolved_at,
|
|
email_sent,
|
|
created_at
|
|
FROM ops_alert_events
|
|
` + where + `
|
|
ORDER BY fired_at DESC
|
|
LIMIT ` + limitArg
|
|
|
|
rows, err := r.db.QueryContext(ctx, q, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := []*service.OpsAlertEvent{}
|
|
for rows.Next() {
|
|
var ev service.OpsAlertEvent
|
|
var metricValue sql.NullFloat64
|
|
var thresholdValue sql.NullFloat64
|
|
var dimensionsRaw []byte
|
|
var resolvedAt sql.NullTime
|
|
if err := rows.Scan(
|
|
&ev.ID,
|
|
&ev.RuleID,
|
|
&ev.Severity,
|
|
&ev.Status,
|
|
&ev.Title,
|
|
&ev.Description,
|
|
&metricValue,
|
|
&thresholdValue,
|
|
&dimensionsRaw,
|
|
&ev.FiredAt,
|
|
&resolvedAt,
|
|
&ev.EmailSent,
|
|
&ev.CreatedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
if metricValue.Valid {
|
|
v := metricValue.Float64
|
|
ev.MetricValue = &v
|
|
}
|
|
if thresholdValue.Valid {
|
|
v := thresholdValue.Float64
|
|
ev.ThresholdValue = &v
|
|
}
|
|
if resolvedAt.Valid {
|
|
v := resolvedAt.Time
|
|
ev.ResolvedAt = &v
|
|
}
|
|
if len(dimensionsRaw) > 0 && string(dimensionsRaw) != "null" {
|
|
var decoded map[string]any
|
|
if err := json.Unmarshal(dimensionsRaw, &decoded); err == nil {
|
|
ev.Dimensions = decoded
|
|
}
|
|
}
|
|
out = append(out, &ev)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (r *opsRepository) GetActiveAlertEvent(ctx context.Context, ruleID int64) (*service.OpsAlertEvent, error) {
|
|
if r == nil || r.db == nil {
|
|
return nil, fmt.Errorf("nil ops repository")
|
|
}
|
|
if ruleID <= 0 {
|
|
return nil, fmt.Errorf("invalid rule id")
|
|
}
|
|
|
|
q := `
|
|
SELECT
|
|
id,
|
|
COALESCE(rule_id, 0),
|
|
COALESCE(severity, ''),
|
|
COALESCE(status, ''),
|
|
COALESCE(title, ''),
|
|
COALESCE(description, ''),
|
|
metric_value,
|
|
threshold_value,
|
|
dimensions,
|
|
fired_at,
|
|
resolved_at,
|
|
email_sent,
|
|
created_at
|
|
FROM ops_alert_events
|
|
WHERE rule_id = $1 AND status = $2
|
|
ORDER BY fired_at DESC
|
|
LIMIT 1`
|
|
|
|
row := r.db.QueryRowContext(ctx, q, ruleID, service.OpsAlertStatusFiring)
|
|
ev, err := scanOpsAlertEvent(row)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
return ev, nil
|
|
}
|
|
|
|
func (r *opsRepository) GetLatestAlertEvent(ctx context.Context, ruleID int64) (*service.OpsAlertEvent, error) {
|
|
if r == nil || r.db == nil {
|
|
return nil, fmt.Errorf("nil ops repository")
|
|
}
|
|
if ruleID <= 0 {
|
|
return nil, fmt.Errorf("invalid rule id")
|
|
}
|
|
|
|
q := `
|
|
SELECT
|
|
id,
|
|
COALESCE(rule_id, 0),
|
|
COALESCE(severity, ''),
|
|
COALESCE(status, ''),
|
|
COALESCE(title, ''),
|
|
COALESCE(description, ''),
|
|
metric_value,
|
|
threshold_value,
|
|
dimensions,
|
|
fired_at,
|
|
resolved_at,
|
|
email_sent,
|
|
created_at
|
|
FROM ops_alert_events
|
|
WHERE rule_id = $1
|
|
ORDER BY fired_at DESC
|
|
LIMIT 1`
|
|
|
|
row := r.db.QueryRowContext(ctx, q, ruleID)
|
|
ev, err := scanOpsAlertEvent(row)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
return ev, nil
|
|
}
|
|
|
|
func (r *opsRepository) CreateAlertEvent(ctx context.Context, event *service.OpsAlertEvent) (*service.OpsAlertEvent, error) {
|
|
if r == nil || r.db == nil {
|
|
return nil, fmt.Errorf("nil ops repository")
|
|
}
|
|
if event == nil {
|
|
return nil, fmt.Errorf("nil event")
|
|
}
|
|
|
|
dimensionsArg, err := opsNullJSONMap(event.Dimensions)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
q := `
|
|
INSERT INTO ops_alert_events (
|
|
rule_id,
|
|
severity,
|
|
status,
|
|
title,
|
|
description,
|
|
metric_value,
|
|
threshold_value,
|
|
dimensions,
|
|
fired_at,
|
|
resolved_at,
|
|
email_sent,
|
|
created_at
|
|
) VALUES (
|
|
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,NOW()
|
|
)
|
|
RETURNING
|
|
id,
|
|
COALESCE(rule_id, 0),
|
|
COALESCE(severity, ''),
|
|
COALESCE(status, ''),
|
|
COALESCE(title, ''),
|
|
COALESCE(description, ''),
|
|
metric_value,
|
|
threshold_value,
|
|
dimensions,
|
|
fired_at,
|
|
resolved_at,
|
|
email_sent,
|
|
created_at`
|
|
|
|
row := r.db.QueryRowContext(
|
|
ctx,
|
|
q,
|
|
opsNullInt64(&event.RuleID),
|
|
opsNullString(event.Severity),
|
|
opsNullString(event.Status),
|
|
opsNullString(event.Title),
|
|
opsNullString(event.Description),
|
|
opsNullFloat64(event.MetricValue),
|
|
opsNullFloat64(event.ThresholdValue),
|
|
dimensionsArg,
|
|
event.FiredAt,
|
|
opsNullTime(event.ResolvedAt),
|
|
event.EmailSent,
|
|
)
|
|
return scanOpsAlertEvent(row)
|
|
}
|
|
|
|
func (r *opsRepository) UpdateAlertEventStatus(ctx context.Context, eventID int64, status string, resolvedAt *time.Time) error {
|
|
if r == nil || r.db == nil {
|
|
return fmt.Errorf("nil ops repository")
|
|
}
|
|
if eventID <= 0 {
|
|
return fmt.Errorf("invalid event id")
|
|
}
|
|
if strings.TrimSpace(status) == "" {
|
|
return fmt.Errorf("invalid status")
|
|
}
|
|
|
|
q := `
|
|
UPDATE ops_alert_events
|
|
SET status = $2,
|
|
resolved_at = $3
|
|
WHERE id = $1`
|
|
|
|
_, err := r.db.ExecContext(ctx, q, eventID, strings.TrimSpace(status), opsNullTime(resolvedAt))
|
|
return err
|
|
}
|
|
|
|
func (r *opsRepository) UpdateAlertEventEmailSent(ctx context.Context, eventID int64, emailSent bool) error {
|
|
if r == nil || r.db == nil {
|
|
return fmt.Errorf("nil ops repository")
|
|
}
|
|
if eventID <= 0 {
|
|
return fmt.Errorf("invalid event id")
|
|
}
|
|
|
|
_, err := r.db.ExecContext(ctx, "UPDATE ops_alert_events SET email_sent = $2 WHERE id = $1", eventID, emailSent)
|
|
return err
|
|
}
|
|
|
|
type opsAlertEventRow interface {
|
|
Scan(dest ...any) error
|
|
}
|
|
|
|
func scanOpsAlertEvent(row opsAlertEventRow) (*service.OpsAlertEvent, error) {
|
|
var ev service.OpsAlertEvent
|
|
var metricValue sql.NullFloat64
|
|
var thresholdValue sql.NullFloat64
|
|
var dimensionsRaw []byte
|
|
var resolvedAt sql.NullTime
|
|
|
|
if err := row.Scan(
|
|
&ev.ID,
|
|
&ev.RuleID,
|
|
&ev.Severity,
|
|
&ev.Status,
|
|
&ev.Title,
|
|
&ev.Description,
|
|
&metricValue,
|
|
&thresholdValue,
|
|
&dimensionsRaw,
|
|
&ev.FiredAt,
|
|
&resolvedAt,
|
|
&ev.EmailSent,
|
|
&ev.CreatedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
if metricValue.Valid {
|
|
v := metricValue.Float64
|
|
ev.MetricValue = &v
|
|
}
|
|
if thresholdValue.Valid {
|
|
v := thresholdValue.Float64
|
|
ev.ThresholdValue = &v
|
|
}
|
|
if resolvedAt.Valid {
|
|
v := resolvedAt.Time
|
|
ev.ResolvedAt = &v
|
|
}
|
|
if len(dimensionsRaw) > 0 && string(dimensionsRaw) != "null" {
|
|
var decoded map[string]any
|
|
if err := json.Unmarshal(dimensionsRaw, &decoded); err == nil {
|
|
ev.Dimensions = decoded
|
|
}
|
|
}
|
|
return &ev, nil
|
|
}
|
|
|
|
func buildOpsAlertEventsWhere(filter *service.OpsAlertEventFilter) (string, []any) {
|
|
clauses := []string{"1=1"}
|
|
args := []any{}
|
|
|
|
if filter == nil {
|
|
return "WHERE " + strings.Join(clauses, " AND "), args
|
|
}
|
|
|
|
if status := strings.TrimSpace(filter.Status); status != "" {
|
|
args = append(args, status)
|
|
clauses = append(clauses, "status = $"+itoa(len(args)))
|
|
}
|
|
if severity := strings.TrimSpace(filter.Severity); severity != "" {
|
|
args = append(args, severity)
|
|
clauses = append(clauses, "severity = $"+itoa(len(args)))
|
|
}
|
|
if filter.StartTime != nil && !filter.StartTime.IsZero() {
|
|
args = append(args, *filter.StartTime)
|
|
clauses = append(clauses, "fired_at >= $"+itoa(len(args)))
|
|
}
|
|
if filter.EndTime != nil && !filter.EndTime.IsZero() {
|
|
args = append(args, *filter.EndTime)
|
|
clauses = append(clauses, "fired_at < $"+itoa(len(args)))
|
|
}
|
|
|
|
// Dimensions are stored in JSONB. We filter best-effort without requiring GIN indexes.
|
|
if platform := strings.TrimSpace(filter.Platform); platform != "" {
|
|
args = append(args, platform)
|
|
clauses = append(clauses, "(dimensions->>'platform') = $"+itoa(len(args)))
|
|
}
|
|
if filter.GroupID != nil && *filter.GroupID > 0 {
|
|
args = append(args, fmt.Sprintf("%d", *filter.GroupID))
|
|
clauses = append(clauses, "(dimensions->>'group_id') = $"+itoa(len(args)))
|
|
}
|
|
|
|
return "WHERE " + strings.Join(clauses, " AND "), args
|
|
}
|
|
|
|
func opsNullJSONMap(v map[string]any) (any, error) {
|
|
if v == nil {
|
|
return sql.NullString{}, nil
|
|
}
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(b) == 0 {
|
|
return sql.NullString{}, nil
|
|
}
|
|
return sql.NullString{String: string(b), Valid: true}, nil
|
|
}
|