chore: initial import

This commit is contained in:
phamnazage-jpg
2026-05-12 17:47:32 +08:00
commit fc54ba84b2
104 changed files with 11575 additions and 0 deletions

View File

@@ -0,0 +1,43 @@
package handler
import (
"net/http"
"strconv"
"github.com/company/ai-ops/internal/domain/repository"
"github.com/company/ai-ops/pkg/errors"
"github.com/company/ai-ops/pkg/response"
)
// AlertHandler 是告警事件 HTTP 处理器
type AlertHandler struct {
repo repository.AlertRepository
}
func NewAlertHandler(repo repository.AlertRepository) *AlertHandler {
return &AlertHandler{repo: repo}
}
func (h *AlertHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/v1/ai-ops/alerts", h.ListAlerts)
}
func (h *AlertHandler) ListAlerts(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
status := query.Get("status")
page, _ := strconv.Atoi(query.Get("page"))
pageSize, _ := strconv.Atoi(query.Get("page_size"))
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
events, total, err := h.repo.ListEvents(r.Context(), status, page, pageSize)
if err != nil {
response.Error(w, errors.Wrap(err, errors.ErrInternal))
return
}
response.Success(w, map[string]any{"items": events, "total": total, "page": page, "page_size": pageSize})
}

View File

@@ -0,0 +1,55 @@
package handler
import (
"net/http"
"strconv"
"github.com/company/ai-ops/internal/service"
"github.com/company/ai-ops/pkg/errors"
"github.com/company/ai-ops/pkg/response"
)
// AuditHandler 是审计日志 HTTP 处理器
type AuditHandler struct {
service *service.AuditService
}
func NewAuditHandler(s *service.AuditService) *AuditHandler {
return &AuditHandler{service: s}
}
func (h *AuditHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/v1/ai-ops/audits", h.ListAudits)
mux.HandleFunc("POST /api/v1/ai-ops/audits/{id}/rollback", h.Rollback)
}
func (h *AuditHandler) ListAudits(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
objectType := query.Get("object_type")
objectID := query.Get("object_id")
page, _ := strconv.Atoi(query.Get("page"))
pageSize, _ := strconv.Atoi(query.Get("page_size"))
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
logs, total, err := h.service.List(r.Context(), objectType, objectID, page, pageSize)
if err != nil {
response.Error(w, errors.Wrap(err, errors.ErrInternal))
return
}
response.Success(w, map[string]any{"items": logs, "total": total, "page": page, "page_size": pageSize})
}
func (h *AuditHandler) Rollback(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
log, err := h.service.Rollback(r.Context(), id)
if err != nil {
response.Error(w, errors.Wrap(err, errors.ErrBadRequest).WithDetail(map[string]any{"error": err.Error()}))
return
}
response.Success(w, log)
}

View File

@@ -0,0 +1,59 @@
package handler
import (
"net/http"
"github.com/company/ai-ops/internal/service"
"github.com/company/ai-ops/pkg/errors"
"github.com/company/ai-ops/pkg/response"
)
// AuthHandler 是认证 HTTP 处理器
type AuthHandler struct {
authSvc *service.AuthService
}
func NewAuthHandler(authSvc *service.AuthService) *AuthHandler {
return &AuthHandler{authSvc: authSvc}
}
func (h *AuthHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /api/v1/ai-ops/login", h.Login)
}
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
var req struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := decodeJSON(r, &req); err != nil {
response.Error(w, errors.ErrBadRequest.WithDetail(map[string]any{"error": err.Error()}))
return
}
// TODO: 实现真实的用户验证(当前为简化实现)
if req.Username == "" || req.Password == "" {
response.Error(w, errors.ErrBadRequest.WithDetail(map[string]any{"error": "username and password required"}))
return
}
// 默认角色为 viewer
role := "viewer"
if req.Username == "admin" {
role = "admin"
} else if req.Username == "ops" {
role = "operator"
}
token, err := h.authSvc.IssueToken(req.Username, role)
if err != nil {
response.Error(w, errors.Wrap(err, errors.ErrInternal))
return
}
response.Success(w, map[string]any{
"token": token,
"expires_in": 28800,
"role": role,
})
}

View File

@@ -0,0 +1,97 @@
package handler
import (
"net/http"
"github.com/company/ai-ops/internal/domain/model"
"github.com/company/ai-ops/internal/service"
"github.com/company/ai-ops/pkg/errors"
"github.com/company/ai-ops/pkg/response"
)
// ChannelHandler 是通知渠道 HTTP 处理器
type ChannelHandler struct {
service *service.ChannelService
}
func NewChannelHandler(s *service.ChannelService) *ChannelHandler {
return &ChannelHandler{service: s}
}
func (h *ChannelHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/v1/ai-ops/channels", h.ListChannels)
mux.HandleFunc("GET /api/v1/ai-ops/channels/{id}", h.GetChannel)
mux.HandleFunc("POST /api/v1/ai-ops/channels", h.CreateChannel)
mux.HandleFunc("PUT /api/v1/ai-ops/channels/{id}", h.UpdateChannel)
mux.HandleFunc("DELETE /api/v1/ai-ops/channels/{id}", h.DeleteChannel)
mux.HandleFunc("POST /api/v1/ai-ops/channels/test", h.TestChannel)
}
func (h *ChannelHandler) ListChannels(w http.ResponseWriter, r *http.Request) {
channels, err := h.service.List(r.Context())
if err != nil {
response.Error(w, errors.Wrap(err, errors.ErrInternal))
return
}
response.Success(w, channels)
}
func (h *ChannelHandler) GetChannel(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
ch, err := h.service.Get(r.Context(), id)
if err != nil {
response.Error(w, errors.Wrap(err, errors.ErrNotFound))
return
}
response.Success(w, ch)
}
func (h *ChannelHandler) CreateChannel(w http.ResponseWriter, r *http.Request) {
var ch model.NotificationChannel
if err := decodeJSON(r, &ch); err != nil {
response.Error(w, errors.ErrBadRequest.WithDetail(map[string]any{"error": err.Error()}))
return
}
if err := h.service.Create(r.Context(), &ch); err != nil {
response.Error(w, errors.Wrap(err, errors.ErrBadRequest))
return
}
w.WriteHeader(http.StatusCreated)
response.Success(w, ch)
}
func (h *ChannelHandler) UpdateChannel(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var ch model.NotificationChannel
if err := decodeJSON(r, &ch); err != nil {
response.Error(w, errors.ErrBadRequest.WithDetail(map[string]any{"error": err.Error()}))
return
}
ch.ID = id
if err := h.service.Update(r.Context(), &ch); err != nil {
response.Error(w, errors.Wrap(err, errors.ErrBadRequest))
return
}
response.Success(w, ch)
}
func (h *ChannelHandler) DeleteChannel(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := h.service.Delete(r.Context(), id); err != nil {
response.Error(w, errors.Wrap(err, errors.ErrInternal))
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *ChannelHandler) TestChannel(w http.ResponseWriter, r *http.Request) {
var req struct {
ChannelID string `json:"channel_id"`
Message string `json:"message"`
}
if err := decodeJSON(r, &req); err != nil {
response.Error(w, errors.ErrBadRequest.WithDetail(map[string]any{"error": err.Error()}))
return
}
response.Success(w, map[string]any{"ok": true})
}

View File

@@ -0,0 +1,391 @@
package handler
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sort"
"strings"
"testing"
"time"
"github.com/company/ai-ops/internal/config"
"github.com/company/ai-ops/internal/database"
"github.com/company/ai-ops/internal/domain/model"
"github.com/company/ai-ops/internal/service"
)
type fakeHandlerAlertRepo struct {
rules []model.AlertRule
events []model.AlertEvent
err error
}
func (r *fakeHandlerAlertRepo) GetOpenCount(context.Context) (*model.AlertCount, error) {
return &model.AlertCount{}, r.err
}
func (r *fakeHandlerAlertRepo) ListRules(context.Context) ([]model.AlertRule, error) {
return r.rules, r.err
}
func (r *fakeHandlerAlertRepo) GetRuleByID(_ context.Context, id string) (*model.AlertRule, error) {
if r.err != nil {
return nil, r.err
}
return &model.AlertRule{ID: id, Name: "rule"}, nil
}
func (r *fakeHandlerAlertRepo) CreateRule(context.Context, *model.AlertRule) error { return r.err }
func (r *fakeHandlerAlertRepo) UpdateRule(context.Context, *model.AlertRule) error { return r.err }
func (r *fakeHandlerAlertRepo) DeleteRule(context.Context, string) error { return r.err }
func (r *fakeHandlerAlertRepo) ListEvents(context.Context, string, int, int) ([]model.AlertEvent, int, error) {
return r.events, len(r.events), r.err
}
func (r *fakeHandlerAlertRepo) CreateEvent(context.Context, *model.AlertEvent) error { return r.err }
func (r *fakeHandlerAlertRepo) CreateEventWithAggregation(_ context.Context, e *model.AlertEvent, _ time.Duration, _ int) (*model.AlertEvent, error) {
return e, r.err
}
func (r *fakeHandlerAlertRepo) UpdateEventStatus(context.Context, string, string) error { return r.err }
func (r *fakeHandlerAlertRepo) EscalateEvent(context.Context, string, string) error { return r.err }
type fakeHandlerChannelRepo struct {
channels []model.NotificationChannel
err error
}
func (r *fakeHandlerChannelRepo) List(context.Context) ([]model.NotificationChannel, error) {
return r.channels, r.err
}
func (r *fakeHandlerChannelRepo) GetByID(_ context.Context, id string) (*model.NotificationChannel, error) {
if r.err != nil {
return nil, r.err
}
return &model.NotificationChannel{ID: id, Name: "hook"}, nil
}
func (r *fakeHandlerChannelRepo) Create(context.Context, *model.NotificationChannel) error {
return r.err
}
func (r *fakeHandlerChannelRepo) Update(context.Context, *model.NotificationChannel) error {
return r.err
}
func (r *fakeHandlerChannelRepo) Delete(context.Context, string) error { return r.err }
type fakeHandlerLogRepo struct {
logs []model.RequestLog
total int
err error
}
func (r *fakeHandlerLogRepo) Query(context.Context, model.LogQueryFilter) ([]model.RequestLog, int, error) {
return r.logs, r.total, r.err
}
func TestAuthHandlerLoginRolesAndValidation(t *testing.T) {
h := NewAuthHandler(service.NewAuthService("secret"))
cases := []struct{ username, wantRole string }{{"admin", "admin"}, {"ops", "operator"}, {"alice", "viewer"}}
for _, tc := range cases {
w := httptest.NewRecorder()
h.Login(w, httptest.NewRequest(http.MethodPost, "/api/v1/ai-ops/login", strings.NewReader(`{"username":"`+tc.username+`","password":"pw"}`)))
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"role":"`+tc.wantRole+`"`) || !strings.Contains(w.Body.String(), `"token"`) {
t.Fatalf("login %s failed: status=%d body=%s", tc.username, w.Code, w.Body.String())
}
}
w := httptest.NewRecorder()
h.Login(w, httptest.NewRequest(http.MethodPost, "/api/v1/ai-ops/login", strings.NewReader(`{"username":"","password":""}`)))
if w.Code != http.StatusBadRequest {
t.Fatalf("invalid login status = %d", w.Code)
}
bad := httptest.NewRecorder()
h.Login(bad, httptest.NewRequest(http.MethodPost, "/api/v1/ai-ops/login", strings.NewReader(`{`)))
if bad.Code != http.StatusBadRequest {
t.Fatalf("bad json status = %d", bad.Code)
}
}
func TestHealthAndDashboardHandlers(t *testing.T) {
health := NewHealthHandler()
w := httptest.NewRecorder()
health.Health(w, httptest.NewRequest(http.MethodGet, "/actuator/health", nil))
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"status":"UP"`) {
t.Fatalf("health = %d %s", w.Code, w.Body.String())
}
live := httptest.NewRecorder()
health.Live(live, httptest.NewRequest(http.MethodGet, "/actuator/health/live", nil))
if live.Code != http.StatusOK {
t.Fatalf("live = %d", live.Code)
}
ready := httptest.NewRecorder()
health.Ready(ready, httptest.NewRequest(http.MethodGet, "/actuator/health/ready", nil))
if ready.Code != http.StatusServiceUnavailable || !strings.Contains(ready.Body.String(), `"status":"DOWN"`) {
t.Fatalf("ready = %d %s", ready.Code, ready.Body.String())
}
dash := httptest.NewRecorder()
NewDashboardHandler().Dashboard(dash, httptest.NewRequest(http.MethodGet, "/ops/dashboard", nil))
if dash.Code != http.StatusOK || !strings.Contains(dash.Body.String(), "AI-Ops 运维看板") {
t.Fatalf("dashboard = %d", dash.Code)
}
}
func TestRuleHandlerCRUDHappyAndErrorPaths(t *testing.T) {
repo := &fakeHandlerAlertRepo{rules: []model.AlertRule{{ID: "r1", Name: "rule"}}}
h := NewRuleHandler(service.NewRuleService(repo))
mux := http.NewServeMux()
h.RegisterRoutes(mux)
for _, tc := range []struct {
method, path string
body string
want int
}{
{http.MethodGet, "/api/v1/ai-ops/rules", "", http.StatusOK},
{http.MethodGet, "/api/v1/ai-ops/rules/r1", "", http.StatusOK},
{http.MethodPost, "/api/v1/ai-ops/rules", `{"id":"r2","name":"latency","metric_name":"p99"}`, http.StatusCreated},
{http.MethodPut, "/api/v1/ai-ops/rules/r2", `{"name":"latency","metric_name":"p99"}`, http.StatusOK},
{http.MethodDelete, "/api/v1/ai-ops/rules/r2", "", http.StatusNoContent},
{http.MethodPost, "/api/v1/ai-ops/rules", `{`, http.StatusBadRequest},
{http.MethodPost, "/api/v1/ai-ops/rules", `{}`, http.StatusBadRequest},
} {
w := httptest.NewRecorder()
mux.ServeHTTP(w, httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body)))
if w.Code != tc.want {
t.Fatalf("%s %s status=%d want=%d body=%s", tc.method, tc.path, w.Code, tc.want, w.Body.String())
}
}
errHandler := NewRuleHandler(service.NewRuleService(&fakeHandlerAlertRepo{err: errors.New("db")}))
errMux := http.NewServeMux()
errHandler.RegisterRoutes(errMux)
w := httptest.NewRecorder()
errMux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/v1/ai-ops/rules", nil))
if w.Code != http.StatusInternalServerError {
t.Fatalf("error list status = %d", w.Code)
}
}
func TestChannelHandlerCRUDHappyAndErrorPaths(t *testing.T) {
h := NewChannelHandler(service.NewChannelService(&fakeHandlerChannelRepo{channels: []model.NotificationChannel{{ID: "c1", Name: "hook"}}}))
mux := http.NewServeMux()
h.RegisterRoutes(mux)
for _, tc := range []struct {
method, path, body string
want int
}{
{http.MethodGet, "/api/v1/ai-ops/channels", "", http.StatusOK},
{http.MethodGet, "/api/v1/ai-ops/channels/c1", "", http.StatusOK},
{http.MethodPost, "/api/v1/ai-ops/channels", `{"name":"hook","channel_type":"webhook"}`, http.StatusCreated},
{http.MethodPut, "/api/v1/ai-ops/channels/c1", `{"name":"hook","channel_type":"webhook"}`, http.StatusOK},
{http.MethodDelete, "/api/v1/ai-ops/channels/c1", "", http.StatusNoContent},
{http.MethodPost, "/api/v1/ai-ops/channels/test", `{"channel_id":"c1","message":"hello"}`, http.StatusOK},
{http.MethodPost, "/api/v1/ai-ops/channels", `{}`, http.StatusBadRequest},
{http.MethodPost, "/api/v1/ai-ops/channels/test", `{`, http.StatusBadRequest},
} {
w := httptest.NewRecorder()
mux.ServeHTTP(w, httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body)))
if w.Code != tc.want {
t.Fatalf("%s %s status=%d want=%d body=%s", tc.method, tc.path, w.Code, tc.want, w.Body.String())
}
}
}
func TestAlertAndLogHandlers(t *testing.T) {
alertHandler := NewAlertHandler(&fakeHandlerAlertRepo{events: []model.AlertEvent{{ID: "e1", Status: "triggered"}}})
alertMux := http.NewServeMux()
alertHandler.RegisterRoutes(alertMux)
aw := httptest.NewRecorder()
alertMux.ServeHTTP(aw, httptest.NewRequest(http.MethodGet, "/api/v1/ai-ops/alerts?status=triggered&page=2&page_size=5", nil))
if aw.Code != http.StatusOK || !strings.Contains(aw.Body.String(), `"items"`) {
t.Fatalf("alerts = %d %s", aw.Code, aw.Body.String())
}
logHandler := NewLogHandler(service.NewLogService(&fakeHandlerLogRepo{logs: []model.RequestLog{{Timestamp: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC), Service: "api", Path: "/v1", Method: "GET", StatusCode: 200}}, total: 1}))
logMux := http.NewServeMux()
logHandler.RegisterRoutes(logMux)
lw := httptest.NewRecorder()
logMux.ServeHTTP(lw, httptest.NewRequest(http.MethodGet, "/api/v1/ai-ops/logs?page=2&page_size=5&status_code=200", nil))
if lw.Code != http.StatusOK || !strings.Contains(lw.Body.String(), `"total_pages"`) {
t.Fatalf("logs = %d %s", lw.Code, lw.Body.String())
}
csv := httptest.NewRecorder()
logMux.ServeHTTP(csv, httptest.NewRequest(http.MethodGet, "/api/v1/ai-ops/logs/export", nil))
if csv.Code != http.StatusOK || !strings.Contains(csv.Body.String(), "时间,服务名") {
t.Fatalf("csv = %d %s", csv.Code, csv.Body.String())
}
badCSV := httptest.NewRecorder()
badLogHandler := NewLogHandler(service.NewLogService(&fakeHandlerLogRepo{err: errors.New("export failed")}))
badLogMux := http.NewServeMux()
badLogHandler.RegisterRoutes(badLogMux)
badLogMux.ServeHTTP(badCSV, httptest.NewRequest(http.MethodGet, "/api/v1/ai-ops/logs/export?start=bad&end=bad&status_code=bad", nil))
if badCSV.Code != http.StatusInternalServerError {
t.Fatalf("csv error = %d %s", badCSV.Code, badCSV.Body.String())
}
}
type fakeHandlerMetricRepo struct {
realtime *model.RealtimeMetrics
points []model.MetricPoint
err error
}
func (r *fakeHandlerMetricRepo) GetRealtime(context.Context) (*model.RealtimeMetrics, error) {
if r.err != nil {
return nil, r.err
}
return r.realtime, nil
}
func (r *fakeHandlerMetricRepo) Query(context.Context, model.MetricQueryRequest) ([]model.MetricPoint, error) {
if r.err != nil {
return nil, r.err
}
return r.points, nil
}
func (r *fakeHandlerMetricRepo) GetLatest(context.Context, string, string) (*model.MetricPoint, error) {
if r.err != nil {
return nil, r.err
}
return &model.MetricPoint{Value: 1}, nil
}
func TestRegisterRoutesForSmallHandlers(t *testing.T) {
mux := http.NewServeMux()
NewAuthHandler(service.NewAuthService("secret")).RegisterRoutes(mux)
NewDashboardHandler().RegisterRoutes(mux)
NewHealthHandler().RegisterRoutes(mux)
NewHealingHandler().RegisterRoutes(mux)
w := httptest.NewRecorder()
mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/v1/ai-ops/healings", nil))
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"total":0`) {
t.Fatalf("healings = %d %s", w.Code, w.Body.String())
}
one := httptest.NewRecorder()
mux.ServeHTTP(one, httptest.NewRequest(http.MethodGet, "/api/v1/ai-ops/healings/h1", nil))
if one.Code != http.StatusOK || !strings.Contains(one.Body.String(), `"id":"h1"`) {
t.Fatalf("healing = %d %s", one.Code, one.Body.String())
}
}
func TestMetricHandlerRoutesAndErrors(t *testing.T) {
metricRepo := &fakeHandlerMetricRepo{realtime: &model.RealtimeMetrics{QPS: 9}, points: []model.MetricPoint{{Name: "qps", Value: 1}}}
alertRepo := &fakeHandlerAlertRepo{}
h := NewMetricHandler(service.NewMetricService(metricRepo, alertRepo))
mux := http.NewServeMux()
h.RegisterRoutes(mux)
for _, path := range []string{
"/api/v1/ai-ops/metrics/realtime",
"/api/v1/ai-ops/metrics/suppliers/count",
"/api/v1/ai-ops/alerts/open/count",
"/api/v1/ai-ops/metrics/query?source=prom&name=qps&start=2026-01-01T00:00:00Z&end=2026-01-01T01:00:00Z",
} {
w := httptest.NewRecorder()
mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
if w.Code != http.StatusOK {
t.Fatalf("%s status=%d body=%s", path, w.Code, w.Body.String())
}
}
errHandler := NewMetricHandler(service.NewMetricService(&fakeHandlerMetricRepo{err: errors.New("metrics down")}, &fakeHandlerAlertRepo{err: errors.New("alerts down")}))
errMux := http.NewServeMux()
errHandler.RegisterRoutes(errMux)
for _, path := range []string{"/api/v1/ai-ops/metrics/realtime", "/api/v1/ai-ops/metrics/query", "/api/v1/ai-ops/alerts/open/count"} {
w := httptest.NewRecorder()
errMux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
if w.Code != http.StatusInternalServerError {
t.Fatalf("%s status=%d", path, w.Code)
}
}
}
func setupHandlerAuditDB(t *testing.T) context.Context {
t.Helper()
ctx := context.Background()
if database.Pool == nil {
ports := []int{15432, 5432}
var lastErr error
for _, port := range ports {
lastErr = database.Init(config.DatabaseConfig{Host: "localhost", Port: port, User: "aiops", Password: "aiops123", DBName: "ai_ops", SSLMode: "disable", PoolSize: 4})
if lastErr == nil {
break
}
database.Close()
database.Pool = nil
}
if lastErr != nil {
t.Skipf("PostgreSQL integration database not available: %v", lastErr)
}
}
if _, err := database.Pool.Exec(ctx, `SELECT pg_advisory_lock(424242001)`); err != nil {
t.Fatal(err)
}
defer database.Pool.Exec(ctx, `SELECT pg_advisory_unlock(424242001)`)
files, err := filepath.Glob(filepath.Join("..", "..", "tech", "migrations", "*.up.sql"))
if err != nil {
t.Fatal(err)
}
sort.Strings(files)
for _, f := range files {
b, err := os.ReadFile(f)
if err != nil {
t.Fatal(err)
}
if _, err := database.Pool.Exec(ctx, string(b)); err != nil {
t.Fatalf("apply migration %s: %v", f, err)
}
}
return ctx
}
func handlerUUID(t *testing.T) string {
t.Helper()
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
t.Fatal(err)
}
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
return hex.EncodeToString(b[0:4]) + "-" + hex.EncodeToString(b[4:6]) + "-" + hex.EncodeToString(b[6:8]) + "-" + hex.EncodeToString(b[8:10]) + "-" + hex.EncodeToString(b[10:16])
}
func TestAuditHandlerListAndRollback(t *testing.T) {
ctx := setupHandlerAuditDB(t)
svc := service.NewAuditService()
id := handlerUUID(t)
defer database.Pool.Exec(ctx, `DELETE FROM ai_ops_audits WHERE id=$1 OR parent_audit_id=$1 OR object_id=$1`, id)
if err := svc.Record(ctx, &service.AuditLog{ID: id, TenantID: "tenant", ObjectType: "rule", ObjectID: id, Action: "update", BeforeState: map[string]any{"enabled": false}, AfterState: map[string]any{"enabled": true}, RequestID: "req", ResultCode: "SUCCESS", SourceIP: "127.0.0.1", ActorID: "actor", RiskLevel: "normal"}); err != nil {
t.Fatal(err)
}
h := NewAuditHandler(svc)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
list := httptest.NewRecorder()
mux.ServeHTTP(list, httptest.NewRequest(http.MethodGet, "/api/v1/ai-ops/audits?object_type=rule&object_id="+id+"&page=0&page_size=999", nil))
if list.Code != http.StatusOK || !strings.Contains(list.Body.String(), id) {
t.Fatalf("list audits = %d %s", list.Code, list.Body.String())
}
rollback := httptest.NewRecorder()
mux.ServeHTTP(rollback, httptest.NewRequest(http.MethodPost, "/api/v1/ai-ops/audits/"+id+"/rollback", nil))
if rollback.Code != http.StatusOK || !strings.Contains(rollback.Body.String(), `"action":"rollback"`) {
t.Fatalf("rollback = %d %s", rollback.Code, rollback.Body.String())
}
missing := httptest.NewRecorder()
mux.ServeHTTP(missing, httptest.NewRequest(http.MethodPost, "/api/v1/ai-ops/audits/"+handlerUUID(t)+"/rollback", nil))
if missing.Code != http.StatusBadRequest {
t.Fatalf("missing rollback status = %d", missing.Code)
}
}

View File

@@ -0,0 +1,118 @@
package handler
import (
"html/template"
"net/http"
)
// DashboardHandler 是前端页面路由处理器
type DashboardHandler struct {
templates *template.Template
}
func NewDashboardHandler() *DashboardHandler {
tmpl := template.Must(template.New("dashboard").Parse(dashboardHTML))
return &DashboardHandler{templates: tmpl}
}
// RegisterRoutes 注册页面路由
func (h *DashboardHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /ops/dashboard", h.Dashboard)
mux.HandleFunc("GET /ops/dashboard/logs", h.Dashboard)
mux.HandleFunc("GET /ops/dashboard/rules", h.Dashboard)
mux.HandleFunc("GET /ops/dashboard/alerts", h.Dashboard)
mux.HandleFunc("GET /ops/dashboard/channels", h.Dashboard)
}
// Dashboard 首页
func (h *DashboardHandler) Dashboard(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = h.templates.ExecuteTemplate(w, "dashboard", nil)
}
const dashboardHTML = `
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>AI-Ops 运维看板</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 0; background: #0f172a; color: #e2e8f0; }
header { padding: 18px 28px; background: #111827; border-bottom: 1px solid #334155; display:flex; justify-content:space-between; align-items:center; }
main { padding: 24px; display: grid; gap: 18px; }
.grid { display: grid; grid-template-columns: repeat(4, minmax(140px, 1fr)); gap: 14px; }
.card { background:#111827; border:1px solid #334155; border-radius:12px; padding:16px; box-shadow:0 10px 24px rgba(0,0,0,.18); }
.metric { font-size: 28px; font-weight: 700; color:#38bdf8; margin-top:8px; }
button, input { border-radius:8px; border:1px solid #475569; background:#0b1220; color:#e2e8f0; padding:8px 10px; }
button { cursor:pointer; background:#2563eb; border-color:#2563eb; }
table { width:100%; border-collapse: collapse; font-size: 14px; }
th, td { border-bottom: 1px solid #334155; padding: 8px; text-align:left; vertical-align: top; }
th { color:#93c5fd; }
.muted { color:#94a3b8; }
.row { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
.error { color:#fca5a5; white-space:pre-wrap; }
code { color:#bae6fd; }
</style>
</head>
<body>
<header>
<div><strong>AI-Ops 运维看板</strong><span class="muted"> · 规则 / 事件 / 渠道 / 日志</span></div>
<div class="row"><input id="username" placeholder="admin" value="admin"><input id="password" type="password" placeholder="admin" value="admin"><button onclick="login()">登录</button><button onclick="loadAll()">刷新</button></div>
</header>
<main>
<section class="grid">
<div class="card">QPS<div id="qps" class="metric">-</div></div>
<div class="card">平均延迟<div id="avg" class="metric">-</div></div>
<div class="card">P99<div id="p99" class="metric">-</div></div>
<div class="card">错误率<div id="err" class="metric">-</div></div>
</section>
<section class="card"><h3>告警事件</h3><div id="alerts"></div></section>
<section class="card"><h3>告警规则</h3><div id="rules"></div></section>
<section class="card"><h3>通知渠道</h3><div id="channels"></div></section>
<section class="card"><h3>日志</h3><div id="logs"></div></section>
<section class="card error" id="error"></section>
</main>
<script>
const api = '/api/v1/ai-ops';
function token(){ return localStorage.getItem('ai_ops_token') || ''; }
function setError(e){ document.getElementById('error').textContent = e ? String(e) : ''; }
async function login(){
setError('');
const res = await fetch(api + '/login', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({username: username.value, password: password.value})});
const data = await res.json();
const t = data?.data?.token || data?.token;
if(!res.ok || !t){ setError(JSON.stringify(data)); return; }
localStorage.setItem('ai_ops_token', t);
await loadAll();
}
async function get(path){
const res = await fetch(api + path, {headers:{Authorization:'Bearer ' + token()}});
const data = await res.json();
if(!res.ok) throw new Error(path + ' ' + JSON.stringify(data));
return data.data ?? data;
}
function table(rows, cols){
if(!Array.isArray(rows) || rows.length === 0) return '<p class="muted">暂无数据</p>';
return '<table><thead><tr>'+cols.map(c=>'<th>'+c[0]+'</th>').join('')+'</tr></thead><tbody>'+rows.map(r=>'<tr>'+cols.map(c=>'<td>'+escapeHtml(String(r[c[1]] ?? ''))+'</td>').join('')+'</tr>').join('')+'</tbody></table>';
}
function escapeHtml(s){ return s.replace(/[&<>"]/g, m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[m])); }
async function loadAll(){
try{
setError('');
const m = await get('/metrics/realtime');
qps.textContent = m.qps ?? '-'; avg.textContent = (m.avg_latency_ms ?? '-') + 'ms'; p99.textContent = (m.p99_latency_ms ?? '-') + 'ms'; err.textContent = m.error_rate ?? '-';
const ev = await get('/alerts?page=1&page_size=20');
alerts.innerHTML = table(ev.items || [], [['级别','level'],['资源','resource_id'],['状态','status'],['聚合','is_aggregated'],['数量','aggregated_count'],['开始时间','started_at']]);
const rs = await get('/rules');
rules.innerHTML = table(rs || [], [['名称','name'],['指标','metric_name'],['条件','threshold_type'],['阈值','threshold_value'],['级别','level'],['启用','enabled']]);
const cs = await get('/channels');
channels.innerHTML = table(cs || [], [['名称','name'],['类型','channel_type'],['优先级','priority'],['启用','enabled']]);
const lg = await get('/logs?page=1&page_size=20');
logs.innerHTML = table(lg.items || [], [['服务','service'],['级别','level'],['消息','message'],['时间','timestamp']]);
}catch(e){ setError(e); }
}
if(token()) loadAll();
</script>
</body>
</html>
`

View File

@@ -0,0 +1,29 @@
package handler
import (
"net/http"
"github.com/company/ai-ops/pkg/response"
)
// HealingHandler 是自愈管理 HTTP 处理器
type HealingHandler struct{}
func NewHealingHandler() *HealingHandler {
return &HealingHandler{}
}
func (h *HealingHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/v1/ai-ops/healings", h.ListHealings)
mux.HandleFunc("GET /api/v1/ai-ops/healings/{id}", h.GetHealing)
}
func (h *HealingHandler) ListHealings(w http.ResponseWriter, r *http.Request) {
// TODO: 实现列表查询
response.Success(w, map[string]any{"items": []any{}, "total": 0})
}
func (h *HealingHandler) GetHealing(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
response.Success(w, map[string]any{"id": id, "status": "pending"})
}

View File

@@ -0,0 +1,62 @@
package handler
import (
"net/http"
"github.com/company/ai-ops/internal/database"
"github.com/company/ai-ops/internal/redis"
"github.com/company/ai-ops/pkg/response"
)
// HealthHandler 是健康检查 HTTP 处理器
type HealthHandler struct{}
func NewHealthHandler() *HealthHandler {
return &HealthHandler{}
}
func (h *HealthHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /actuator/health", h.Health)
mux.HandleFunc("GET /actuator/health/live", h.Live)
mux.HandleFunc("GET /actuator/health/ready", h.Ready)
}
func (h *HealthHandler) Health(w http.ResponseWriter, r *http.Request) {
response.Success(w, map[string]any{
"status": "UP",
"components": map[string]any{
"self": map[string]any{"status": "UP"},
},
})
}
func (h *HealthHandler) Live(w http.ResponseWriter, r *http.Request) {
response.Success(w, map[string]any{"status": "UP"})
}
func (h *HealthHandler) Ready(w http.ResponseWriter, r *http.Request) {
status := "UP"
components := map[string]any{
"self": map[string]any{"status": "UP"},
}
// 检查 DB 连接
if database.Pool == nil {
status = "DOWN"
components["database"] = map[string]any{"status": "DOWN", "detail": "not initialized"}
} else {
components["database"] = map[string]any{"status": "UP"}
}
// 检查 Redis 连接
if redis.Client == nil {
components["redis"] = map[string]any{"status": "DOWN", "detail": "not initialized"}
} else {
components["redis"] = map[string]any{"status": "UP"}
}
if status == "DOWN" {
w.WriteHeader(http.StatusServiceUnavailable)
}
response.Success(w, map[string]any{"status": status, "components": components})
}

View File

@@ -0,0 +1,109 @@
package handler
import (
"net/http"
"strconv"
"time"
"github.com/company/ai-ops/internal/domain/model"
"github.com/company/ai-ops/internal/service"
"github.com/company/ai-ops/pkg/errors"
"github.com/company/ai-ops/pkg/response"
)
// LogHandler 是日志 HTTP 处理器
type LogHandler struct {
service *service.LogService
}
func NewLogHandler(s *service.LogService) *LogHandler {
return &LogHandler{service: s}
}
// RegisterRoutes 注册日志相关路由
func (h *LogHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/v1/ai-ops/logs", h.QueryLogs)
mux.HandleFunc("GET /api/v1/ai-ops/logs/export", h.ExportLogs)
}
// QueryLogs 日志查询
func (h *LogHandler) QueryLogs(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
filter := model.LogQueryFilter{
Service: query.Get("service"),
Path: query.Get("path"),
UserID: query.Get("user_id"),
SupplierID: query.Get("supplier_id"),
}
if startStr := query.Get("start"); startStr != "" {
if t, err := time.Parse(time.RFC3339, startStr); err == nil {
filter.StartTime = &t
}
}
if endStr := query.Get("end"); endStr != "" {
if t, err := time.Parse(time.RFC3339, endStr); err == nil {
filter.EndTime = &t
}
}
if codeStr := query.Get("status_code"); codeStr != "" {
if code, err := strconv.Atoi(codeStr); err == nil {
filter.StatusCode = &code
}
}
if page, err := strconv.Atoi(query.Get("page")); err == nil && page > 0 {
filter.Page = page
} else {
filter.Page = 1
}
if pageSize, err := strconv.Atoi(query.Get("page_size")); err == nil && pageSize > 0 && pageSize <= 100 {
filter.PageSize = pageSize
} else {
filter.PageSize = 20
}
logs, total, err := h.service.QueryLogs(r.Context(), filter)
if err != nil {
response.Error(w, errors.Wrap(err, errors.ErrInternal))
return
}
response.PaginatedResponse(w, logs, total, filter.Page, filter.PageSize)
}
// ExportLogs 导出日志为 CSV
func (h *LogHandler) ExportLogs(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
filter := model.LogQueryFilter{
Service: query.Get("service"),
Path: query.Get("path"),
UserID: query.Get("user_id"),
SupplierID: query.Get("supplier_id"),
}
if startStr := query.Get("start"); startStr != "" {
if t, err := time.Parse(time.RFC3339, startStr); err == nil {
filter.StartTime = &t
}
}
if endStr := query.Get("end"); endStr != "" {
if t, err := time.Parse(time.RFC3339, endStr); err == nil {
filter.EndTime = &t
}
}
if codeStr := query.Get("status_code"); codeStr != "" {
if code, err := strconv.Atoi(codeStr); err == nil {
filter.StatusCode = &code
}
}
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
w.Header().Set("Content-Disposition", "attachment; filename=logs_"+time.Now().Format("20060102_150405")+".csv")
if err := h.service.ExportLogsCSV(r.Context(), filter, w); err != nil {
response.Error(w, errors.Wrap(err, errors.ErrInternal))
return
}
}

View File

@@ -0,0 +1,86 @@
package handler
import (
"net/http"
"time"
"github.com/company/ai-ops/internal/domain/model"
"github.com/company/ai-ops/internal/service"
"github.com/company/ai-ops/pkg/errors"
"github.com/company/ai-ops/pkg/response"
)
// MetricHandler 是指标 HTTP 处理器
type MetricHandler struct {
service *service.MetricService
}
func NewMetricHandler(s *service.MetricService) *MetricHandler {
return &MetricHandler{service: s}
}
// RegisterRoutes 注册指标相关路由
func (h *MetricHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/v1/ai-ops/metrics/realtime", h.GetRealtime)
mux.HandleFunc("GET /api/v1/ai-ops/metrics/suppliers/count", h.GetSupplierCount)
mux.HandleFunc("GET /api/v1/ai-ops/alerts/open/count", h.GetOpenAlertCount)
mux.HandleFunc("GET /api/v1/ai-ops/metrics/query", h.QueryMetrics)
}
// GetRealtime 返回实时指标
func (h *MetricHandler) GetRealtime(w http.ResponseWriter, r *http.Request) {
metrics, err := h.service.GetRealtimeMetrics(r.Context())
if err != nil {
response.Error(w, errors.Wrap(err, errors.ErrInternal))
return
}
response.Success(w, metrics)
}
// GetSupplierCount 返回活跃供应商数量
func (h *MetricHandler) GetSupplierCount(w http.ResponseWriter, r *http.Request) {
count, err := h.service.GetSupplierCount(r.Context())
if err != nil {
response.Error(w, errors.Wrap(err, errors.ErrInternal))
return
}
response.Success(w, count)
}
// GetOpenAlertCount 返回未关闭告警数量
func (h *MetricHandler) GetOpenAlertCount(w http.ResponseWriter, r *http.Request) {
count, err := h.service.GetOpenAlertCount(r.Context())
if err != nil {
response.Error(w, errors.Wrap(err, errors.ErrInternal))
return
}
response.Success(w, count)
}
// QueryMetrics 指标下钻查询
func (h *MetricHandler) QueryMetrics(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
req := model.MetricQueryRequest{
Source: query.Get("source"),
Name: query.Get("name"),
}
if startStr := query.Get("start"); startStr != "" {
if t, err := time.Parse(time.RFC3339, startStr); err == nil {
req.StartTime = t
}
}
if endStr := query.Get("end"); endStr != "" {
if t, err := time.Parse(time.RFC3339, endStr); err == nil {
req.EndTime = t
}
}
points, err := h.service.QueryMetrics(r.Context(), req)
if err != nil {
response.Error(w, errors.Wrap(err, errors.ErrInternal))
return
}
response.Success(w, points)
}

View File

@@ -0,0 +1,93 @@
package handler
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/company/ai-ops/internal/domain/model"
"github.com/company/ai-ops/internal/service"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
type mockMetricRepo struct{ mock.Mock }
func (m *mockMetricRepo) GetRealtime(ctx context.Context) (*model.RealtimeMetrics, error) {
args := m.Called(ctx)
return args.Get(0).(*model.RealtimeMetrics), args.Error(1)
}
func (m *mockMetricRepo) Query(ctx context.Context, req model.MetricQueryRequest) ([]model.MetricPoint, error) {
args := m.Called(ctx, req)
return args.Get(0).([]model.MetricPoint), args.Error(1)
}
func (m *mockMetricRepo) GetLatest(ctx context.Context, source, name string) (*model.MetricPoint, error) {
args := m.Called(ctx, source, name)
return args.Get(0).(*model.MetricPoint), args.Error(1)
}
type mockAlertRepo struct{ mock.Mock }
func (m *mockAlertRepo) GetOpenCount(ctx context.Context) (*model.AlertCount, error) {
args := m.Called(ctx)
return args.Get(0).(*model.AlertCount), args.Error(1)
}
func (m *mockAlertRepo) ListRules(ctx context.Context) ([]model.AlertRule, error) {
args := m.Called(ctx)
return args.Get(0).([]model.AlertRule), args.Error(1)
}
func (m *mockAlertRepo) GetRuleByID(ctx context.Context, id string) (*model.AlertRule, error) {
args := m.Called(ctx, id)
return args.Get(0).(*model.AlertRule), args.Error(1)
}
func (m *mockAlertRepo) CreateRule(ctx context.Context, rule *model.AlertRule) error {
args := m.Called(ctx, rule)
return args.Error(0)
}
func (m *mockAlertRepo) UpdateRule(ctx context.Context, rule *model.AlertRule) error {
args := m.Called(ctx, rule)
return args.Error(0)
}
func (m *mockAlertRepo) DeleteRule(ctx context.Context, id string) error {
args := m.Called(ctx, id)
return args.Error(0)
}
func (m *mockAlertRepo) ListEvents(ctx context.Context, status string, page, pageSize int) ([]model.AlertEvent, int, error) {
args := m.Called(ctx, status, page, pageSize)
return args.Get(0).([]model.AlertEvent), args.Int(1), args.Error(2)
}
func (m *mockAlertRepo) CreateEvent(ctx context.Context, event *model.AlertEvent) error {
args := m.Called(ctx, event)
return args.Error(0)
}
func (m *mockAlertRepo) CreateEventWithAggregation(ctx context.Context, event *model.AlertEvent, window time.Duration, threshold int) (*model.AlertEvent, error) {
args := m.Called(ctx, event, window, threshold)
return args.Get(0).(*model.AlertEvent), args.Error(1)
}
func (m *mockAlertRepo) UpdateEventStatus(ctx context.Context, id, status string) error {
args := m.Called(ctx, id, status)
return args.Error(0)
}
func (m *mockAlertRepo) EscalateEvent(ctx context.Context, id, newLevel string) error {
args := m.Called(ctx, id, newLevel)
return args.Error(0)
}
func TestMetricHandler_GetRealtime(t *testing.T) {
mr := new(mockMetricRepo)
ar := new(mockAlertRepo)
svc := service.NewMetricService(mr, ar)
h := NewMetricHandler(svc)
expected := &model.RealtimeMetrics{QPS: 100, AvgLatency: 50, P99Latency: 100, ErrorRate: 0.01}
mr.On("GetRealtime", mock.Anything).Return(expected, nil)
req := httptest.NewRequest("GET", "/api/v1/ai-ops/metrics/realtime", nil)
w := httptest.NewRecorder()
h.GetRealtime(w, req)
assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), `"qps":100`)
}

View File

@@ -0,0 +1,84 @@
package handler
import (
"net/http"
"github.com/company/ai-ops/internal/domain/model"
"github.com/company/ai-ops/internal/service"
"github.com/company/ai-ops/pkg/errors"
"github.com/company/ai-ops/pkg/response"
)
// RuleHandler 是告警规则 HTTP 处理器
type RuleHandler struct {
service *service.RuleService
}
func NewRuleHandler(s *service.RuleService) *RuleHandler {
return &RuleHandler{service: s}
}
func (h *RuleHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/v1/ai-ops/rules", h.ListRules)
mux.HandleFunc("GET /api/v1/ai-ops/rules/{id}", h.GetRule)
mux.HandleFunc("POST /api/v1/ai-ops/rules", h.CreateRule)
mux.HandleFunc("PUT /api/v1/ai-ops/rules/{id}", h.UpdateRule)
mux.HandleFunc("DELETE /api/v1/ai-ops/rules/{id}", h.DeleteRule)
}
func (h *RuleHandler) ListRules(w http.ResponseWriter, r *http.Request) {
rules, err := h.service.ListRules(r.Context())
if err != nil {
response.Error(w, errors.Wrap(err, errors.ErrInternal))
return
}
response.Success(w, rules)
}
func (h *RuleHandler) GetRule(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
rule, err := h.service.GetRule(r.Context(), id)
if err != nil {
response.Error(w, errors.Wrap(err, errors.ErrNotFound))
return
}
response.Success(w, rule)
}
func (h *RuleHandler) CreateRule(w http.ResponseWriter, r *http.Request) {
var rule model.AlertRule
if err := decodeJSON(r, &rule); err != nil {
response.Error(w, errors.ErrBadRequest.WithDetail(map[string]any{"error": err.Error()}))
return
}
if err := h.service.CreateRule(r.Context(), &rule); err != nil {
response.Error(w, errors.Wrap(err, errors.ErrBadRequest))
return
}
w.WriteHeader(http.StatusCreated)
response.Success(w, rule)
}
func (h *RuleHandler) UpdateRule(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var rule model.AlertRule
if err := decodeJSON(r, &rule); err != nil {
response.Error(w, errors.ErrBadRequest.WithDetail(map[string]any{"error": err.Error()}))
return
}
rule.ID = id
if err := h.service.UpdateRule(r.Context(), &rule); err != nil {
response.Error(w, errors.Wrap(err, errors.ErrBadRequest))
return
}
response.Success(w, rule)
}
func (h *RuleHandler) DeleteRule(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := h.service.DeleteRule(r.Context(), id); err != nil {
response.Error(w, errors.Wrap(err, errors.ErrInternal))
return
}
w.WriteHeader(http.StatusNoContent)
}

10
internal/handler/utils.go Normal file
View File

@@ -0,0 +1,10 @@
package handler
import (
"encoding/json"
"net/http"
)
func decodeJSON(r *http.Request, v any) error {
return json.NewDecoder(r.Body).Decode(v)
}