mirror of
https://github.com/EZ-Api/ez-api.git
synced 2026-01-13 17:47:51 +00:00
feat(admin/master): provider+master CRUD, token mgmt, logs APIs
This commit is contained in:
348
internal/api/log_handler.go
Normal file
348
internal/api/log_handler.go
Normal file
@@ -0,0 +1,348 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ez-api/ez-api/internal/model"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type LogView struct {
|
||||
ID uint `json:"id"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
Group string `json:"group"`
|
||||
KeyID uint `json:"key_id"`
|
||||
ModelName string `json:"model"`
|
||||
StatusCode int `json:"status_code"`
|
||||
LatencyMs int64 `json:"latency_ms"`
|
||||
TokensIn int64 `json:"tokens_in"`
|
||||
TokensOut int64 `json:"tokens_out"`
|
||||
ErrorMessage string `json:"error_message"`
|
||||
ClientIP string `json:"client_ip"`
|
||||
RequestSize int64 `json:"request_size"`
|
||||
ResponseSize int64 `json:"response_size"`
|
||||
AuditReason string `json:"audit_reason"`
|
||||
}
|
||||
|
||||
func toLogView(r model.LogRecord) LogView {
|
||||
return LogView{
|
||||
ID: r.ID,
|
||||
CreatedAt: r.CreatedAt.UTC().Unix(),
|
||||
Group: r.Group,
|
||||
KeyID: r.KeyID,
|
||||
ModelName: r.ModelName,
|
||||
StatusCode: r.StatusCode,
|
||||
LatencyMs: r.LatencyMs,
|
||||
TokensIn: r.TokensIn,
|
||||
TokensOut: r.TokensOut,
|
||||
ErrorMessage: r.ErrorMessage,
|
||||
ClientIP: r.ClientIP,
|
||||
RequestSize: r.RequestSize,
|
||||
ResponseSize: r.ResponseSize,
|
||||
AuditReason: r.AuditReason,
|
||||
}
|
||||
}
|
||||
|
||||
type ListLogsResponse struct {
|
||||
Total int64 `json:"total"`
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
Items []LogView `json:"items"`
|
||||
}
|
||||
|
||||
func parseLimitOffset(c *gin.Context) (limit, offset int) {
|
||||
limit = 50
|
||||
offset = 0
|
||||
if raw := strings.TrimSpace(c.Query("limit")); raw != "" {
|
||||
if v, err := strconv.Atoi(raw); err == nil && v > 0 {
|
||||
limit = v
|
||||
}
|
||||
}
|
||||
if limit > 200 {
|
||||
limit = 200
|
||||
}
|
||||
if raw := strings.TrimSpace(c.Query("offset")); raw != "" {
|
||||
if v, err := strconv.Atoi(raw); err == nil && v >= 0 {
|
||||
offset = v
|
||||
}
|
||||
}
|
||||
return limit, offset
|
||||
}
|
||||
|
||||
func parseUnixSeconds(raw string) (time.Time, bool) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
sec, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil || sec <= 0 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return time.Unix(sec, 0).UTC(), true
|
||||
}
|
||||
|
||||
// ListLogs godoc
|
||||
// @Summary List logs (admin)
|
||||
// @Description List request logs with basic filtering/pagination
|
||||
// @Tags admin
|
||||
// @Produce json
|
||||
// @Security AdminAuth
|
||||
// @Param limit query int false "limit (default 50, max 200)"
|
||||
// @Param offset query int false "offset"
|
||||
// @Param since query int false "unix seconds"
|
||||
// @Param until query int false "unix seconds"
|
||||
// @Param key_id query int false "key id"
|
||||
// @Param group query string false "route group"
|
||||
// @Param model query string false "model"
|
||||
// @Param status_code query int false "status code"
|
||||
// @Success 200 {object} ListLogsResponse
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Router /admin/logs [get]
|
||||
func (h *Handler) ListLogs(c *gin.Context) {
|
||||
limit, offset := parseLimitOffset(c)
|
||||
|
||||
q := h.db.Model(&model.LogRecord{})
|
||||
|
||||
if t, ok := parseUnixSeconds(c.Query("since")); ok {
|
||||
q = q.Where("created_at >= ?", t)
|
||||
}
|
||||
if t, ok := parseUnixSeconds(c.Query("until")); ok {
|
||||
q = q.Where("created_at <= ?", t)
|
||||
}
|
||||
if raw := strings.TrimSpace(c.Query("key_id")); raw != "" {
|
||||
if v, err := strconv.ParseUint(raw, 10, 64); err == nil && v > 0 {
|
||||
q = q.Where("key_id = ?", uint(v))
|
||||
}
|
||||
}
|
||||
if raw := strings.TrimSpace(c.Query("group")); raw != "" {
|
||||
q = q.Where(`"group" = ?`, raw)
|
||||
}
|
||||
if raw := strings.TrimSpace(c.Query("model")); raw != "" {
|
||||
q = q.Where("model_name = ?", raw)
|
||||
}
|
||||
if raw := strings.TrimSpace(c.Query("status_code")); raw != "" {
|
||||
if v, err := strconv.Atoi(raw); err == nil && v > 0 {
|
||||
q = q.Where("status_code = ?", v)
|
||||
}
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count logs", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var rows []model.LogRecord
|
||||
if err := q.Order("id desc").Limit(limit).Offset(offset).Find(&rows).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list logs", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
out := make([]LogView, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, toLogView(r))
|
||||
}
|
||||
c.JSON(http.StatusOK, ListLogsResponse{Total: total, Limit: limit, Offset: offset, Items: out})
|
||||
}
|
||||
|
||||
type LogStatsResponse struct {
|
||||
Total int64 `json:"total"`
|
||||
TokensIn int64 `json:"tokens_in"`
|
||||
TokensOut int64 `json:"tokens_out"`
|
||||
AvgLatency float64 `json:"avg_latency_ms"`
|
||||
ByStatus map[string]int64 `json:"by_status"`
|
||||
}
|
||||
|
||||
// LogStats godoc
|
||||
// @Summary Log stats (admin)
|
||||
// @Description Aggregate log stats with basic filtering
|
||||
// @Tags admin
|
||||
// @Produce json
|
||||
// @Security AdminAuth
|
||||
// @Param since query int false "unix seconds"
|
||||
// @Param until query int false "unix seconds"
|
||||
// @Success 200 {object} LogStatsResponse
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Router /admin/logs/stats [get]
|
||||
func (h *Handler) LogStats(c *gin.Context) {
|
||||
q := h.db.Model(&model.LogRecord{})
|
||||
if t, ok := parseUnixSeconds(c.Query("since")); ok {
|
||||
q = q.Where("created_at >= ?", t)
|
||||
}
|
||||
if t, ok := parseUnixSeconds(c.Query("until")); ok {
|
||||
q = q.Where("created_at <= ?", t)
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count logs", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
type sums struct {
|
||||
TokensIn int64
|
||||
TokensOut int64
|
||||
AvgLatency float64
|
||||
}
|
||||
var s sums
|
||||
if err := q.Select("COALESCE(SUM(tokens_in),0) as tokens_in, COALESCE(SUM(tokens_out),0) as tokens_out, COALESCE(AVG(latency_ms),0) as avg_latency").Scan(&s).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to aggregate logs", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
type bucket struct {
|
||||
StatusCode int
|
||||
Cnt int64
|
||||
}
|
||||
var buckets []bucket
|
||||
if err := q.Select("status_code, COUNT(*) as cnt").Group("status_code").Scan(&buckets).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to bucket logs", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
byStatus := make(map[string]int64, len(buckets))
|
||||
for _, b := range buckets {
|
||||
byStatus[strconv.Itoa(b.StatusCode)] = b.Cnt
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, LogStatsResponse{
|
||||
Total: total,
|
||||
TokensIn: s.TokensIn,
|
||||
TokensOut: s.TokensOut,
|
||||
AvgLatency: s.AvgLatency,
|
||||
ByStatus: byStatus,
|
||||
})
|
||||
}
|
||||
|
||||
// ListSelfLogs godoc
|
||||
// @Summary List logs (master)
|
||||
// @Description List request logs for the authenticated master
|
||||
// @Tags master
|
||||
// @Produce json
|
||||
// @Security MasterAuth
|
||||
// @Param limit query int false "limit (default 50, max 200)"
|
||||
// @Param offset query int false "offset"
|
||||
// @Param since query int false "unix seconds"
|
||||
// @Param until query int false "unix seconds"
|
||||
// @Param model query string false "model"
|
||||
// @Param status_code query int false "status code"
|
||||
// @Success 200 {object} ListLogsResponse
|
||||
// @Failure 401 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Router /v1/logs [get]
|
||||
func (h *MasterHandler) ListSelfLogs(c *gin.Context) {
|
||||
master, exists := c.Get("master")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "master key not found in context"})
|
||||
return
|
||||
}
|
||||
m := master.(*model.Master)
|
||||
limit, offset := parseLimitOffset(c)
|
||||
|
||||
q := h.db.Model(&model.LogRecord{}).
|
||||
Joins("JOIN keys ON keys.id = log_records.key_id").
|
||||
Where("keys.master_id = ?", m.ID)
|
||||
|
||||
if t, ok := parseUnixSeconds(c.Query("since")); ok {
|
||||
q = q.Where("log_records.created_at >= ?", t)
|
||||
}
|
||||
if t, ok := parseUnixSeconds(c.Query("until")); ok {
|
||||
q = q.Where("log_records.created_at <= ?", t)
|
||||
}
|
||||
if raw := strings.TrimSpace(c.Query("model")); raw != "" {
|
||||
q = q.Where("log_records.model_name = ?", raw)
|
||||
}
|
||||
if raw := strings.TrimSpace(c.Query("status_code")); raw != "" {
|
||||
if v, err := strconv.Atoi(raw); err == nil && v > 0 {
|
||||
q = q.Where("log_records.status_code = ?", v)
|
||||
}
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count logs", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
var rows []model.LogRecord
|
||||
if err := q.Order("log_records.id desc").Limit(limit).Offset(offset).Find(&rows).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list logs", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
out := make([]LogView, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, toLogView(r))
|
||||
}
|
||||
c.JSON(http.StatusOK, ListLogsResponse{Total: total, Limit: limit, Offset: offset, Items: out})
|
||||
}
|
||||
|
||||
// GetSelfStats godoc
|
||||
// @Summary Log stats (master)
|
||||
// @Description Aggregate request log stats for the authenticated master
|
||||
// @Tags master
|
||||
// @Produce json
|
||||
// @Security MasterAuth
|
||||
// @Param since query int false "unix seconds"
|
||||
// @Param until query int false "unix seconds"
|
||||
// @Success 200 {object} LogStatsResponse
|
||||
// @Failure 401 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Router /v1/stats [get]
|
||||
func (h *MasterHandler) GetSelfStats(c *gin.Context) {
|
||||
master, exists := c.Get("master")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "master key not found in context"})
|
||||
return
|
||||
}
|
||||
m := master.(*model.Master)
|
||||
|
||||
q := h.db.Model(&model.LogRecord{}).
|
||||
Joins("JOIN keys ON keys.id = log_records.key_id").
|
||||
Where("keys.master_id = ?", m.ID)
|
||||
|
||||
if t, ok := parseUnixSeconds(c.Query("since")); ok {
|
||||
q = q.Where("log_records.created_at >= ?", t)
|
||||
}
|
||||
if t, ok := parseUnixSeconds(c.Query("until")); ok {
|
||||
q = q.Where("log_records.created_at <= ?", t)
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count logs", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
type sums struct {
|
||||
TokensIn int64
|
||||
TokensOut int64
|
||||
AvgLatency float64
|
||||
}
|
||||
var s sums
|
||||
if err := q.Select("COALESCE(SUM(log_records.tokens_in),0) as tokens_in, COALESCE(SUM(log_records.tokens_out),0) as tokens_out, COALESCE(AVG(log_records.latency_ms),0) as avg_latency").Scan(&s).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to aggregate logs", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
type bucket struct {
|
||||
StatusCode int
|
||||
Cnt int64
|
||||
}
|
||||
var buckets []bucket
|
||||
if err := q.Select("log_records.status_code as status_code, COUNT(*) as cnt").Group("log_records.status_code").Scan(&buckets).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to bucket logs", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
byStatus := make(map[string]int64, len(buckets))
|
||||
for _, b := range buckets {
|
||||
byStatus[strconv.Itoa(b.StatusCode)] = b.Cnt
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, LogStatsResponse{
|
||||
Total: total,
|
||||
TokensIn: s.TokensIn,
|
||||
TokensOut: s.TokensOut,
|
||||
AvgLatency: s.AvgLatency,
|
||||
ByStatus: byStatus,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user