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:
@@ -6,17 +6,20 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/ez-api/ez-api/internal/model"
|
||||
"github.com/ez-api/ez-api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type AdminHandler struct {
|
||||
db *gorm.DB
|
||||
masterService *service.MasterService
|
||||
syncService *service.SyncService
|
||||
}
|
||||
|
||||
func NewAdminHandler(masterService *service.MasterService, syncService *service.SyncService) *AdminHandler {
|
||||
return &AdminHandler{masterService: masterService, syncService: syncService}
|
||||
func NewAdminHandler(db *gorm.DB, masterService *service.MasterService, syncService *service.SyncService) *AdminHandler {
|
||||
return &AdminHandler{db: db, masterService: masterService, syncService: syncService}
|
||||
}
|
||||
|
||||
type CreateMasterRequest struct {
|
||||
@@ -74,6 +77,320 @@ func (h *AdminHandler) CreateMaster(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
type MasterView struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Group string `json:"group"`
|
||||
DefaultNamespace string `json:"default_namespace"`
|
||||
Namespaces string `json:"namespaces"`
|
||||
Epoch int64 `json:"epoch"`
|
||||
Status string `json:"status"`
|
||||
MaxChildKeys int `json:"max_child_keys"`
|
||||
GlobalQPS int `json:"global_qps"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
func toMasterView(m model.Master) MasterView {
|
||||
return MasterView{
|
||||
ID: m.ID,
|
||||
Name: m.Name,
|
||||
Group: m.Group,
|
||||
DefaultNamespace: strings.TrimSpace(m.DefaultNamespace),
|
||||
Namespaces: strings.TrimSpace(m.Namespaces),
|
||||
Epoch: m.Epoch,
|
||||
Status: m.Status,
|
||||
MaxChildKeys: m.MaxChildKeys,
|
||||
GlobalQPS: m.GlobalQPS,
|
||||
CreatedAt: m.CreatedAt.UTC().Unix(),
|
||||
UpdatedAt: m.UpdatedAt.UTC().Unix(),
|
||||
}
|
||||
}
|
||||
|
||||
// ListMasters godoc
|
||||
// @Summary List masters
|
||||
// @Description List all master tenants
|
||||
// @Tags admin
|
||||
// @Produce json
|
||||
// @Security AdminAuth
|
||||
// @Success 200 {array} MasterView
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Router /admin/masters [get]
|
||||
func (h *AdminHandler) ListMasters(c *gin.Context) {
|
||||
var masters []model.Master
|
||||
if err := h.db.Order("id desc").Find(&masters).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list masters", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
out := make([]MasterView, 0, len(masters))
|
||||
for _, m := range masters {
|
||||
out = append(out, toMasterView(m))
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
// GetMaster godoc
|
||||
// @Summary Get master
|
||||
// @Description Get a master tenant by id
|
||||
// @Tags admin
|
||||
// @Produce json
|
||||
// @Security AdminAuth
|
||||
// @Param id path int true "Master ID"
|
||||
// @Success 200 {object} MasterView
|
||||
// @Failure 400 {object} gin.H
|
||||
// @Failure 404 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Router /admin/masters/{id} [get]
|
||||
func (h *AdminHandler) GetMaster(c *gin.Context) {
|
||||
idRaw := strings.TrimSpace(c.Param("id"))
|
||||
idU64, err := strconv.ParseUint(idRaw, 10, 64)
|
||||
if err != nil || idU64 == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid master id"})
|
||||
return
|
||||
}
|
||||
var m model.Master
|
||||
if err := h.db.First(&m, uint(idU64)).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "master not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, toMasterView(m))
|
||||
}
|
||||
|
||||
type UpdateMasterRequest struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Group *string `json:"group,omitempty"`
|
||||
MaxChildKeys *int `json:"max_child_keys,omitempty"`
|
||||
GlobalQPS *int `json:"global_qps,omitempty"`
|
||||
PropagateToKeys bool `json:"propagate_to_keys,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateMaster godoc
|
||||
// @Summary Update master
|
||||
// @Description Update master fields; optionally propagate group to existing keys
|
||||
// @Tags admin
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security AdminAuth
|
||||
// @Param id path int true "Master ID"
|
||||
// @Param request body UpdateMasterRequest true "Update payload"
|
||||
// @Success 200 {object} MasterView
|
||||
// @Failure 400 {object} gin.H
|
||||
// @Failure 404 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Router /admin/masters/{id} [put]
|
||||
func (h *AdminHandler) UpdateMaster(c *gin.Context) {
|
||||
idRaw := strings.TrimSpace(c.Param("id"))
|
||||
idU64, err := strconv.ParseUint(idRaw, 10, 64)
|
||||
if err != nil || idU64 == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid master id"})
|
||||
return
|
||||
}
|
||||
var req UpdateMasterRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var m model.Master
|
||||
if err := h.db.First(&m, uint(idU64)).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "master not found"})
|
||||
return
|
||||
}
|
||||
|
||||
update := make(map[string]any)
|
||||
if req.Name != nil {
|
||||
update["name"] = strings.TrimSpace(*req.Name)
|
||||
}
|
||||
if req.Group != nil {
|
||||
g := strings.TrimSpace(*req.Group)
|
||||
if g != "" {
|
||||
update["group"] = g
|
||||
}
|
||||
}
|
||||
if req.MaxChildKeys != nil && *req.MaxChildKeys > 0 {
|
||||
update["max_child_keys"] = *req.MaxChildKeys
|
||||
}
|
||||
if req.GlobalQPS != nil && *req.GlobalQPS > 0 {
|
||||
update["global_qps"] = *req.GlobalQPS
|
||||
}
|
||||
if len(update) == 0 && !req.PropagateToKeys {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
|
||||
return
|
||||
}
|
||||
|
||||
if len(update) > 0 {
|
||||
if err := h.db.Model(&m).Updates(update).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update master", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := h.db.First(&m, uint(idU64)).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to reload master", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if req.PropagateToKeys && req.Group != nil {
|
||||
g := strings.TrimSpace(*req.Group)
|
||||
if g != "" {
|
||||
if err := h.db.Model(&model.Key{}).Where("master_id = ?", m.ID).Updates(map[string]any{"group": g}).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to propagate group to keys", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sync master metadata and (if propagated) keys.
|
||||
if err := h.syncService.SyncMaster(&m); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to sync master", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.PropagateToKeys {
|
||||
var keys []model.Key
|
||||
if err := h.db.Where("master_id = ?", m.ID).Find(&keys).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reload keys for sync", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
for i := range keys {
|
||||
if err := h.syncService.SyncKey(&keys[i]); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to sync key", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, toMasterView(m))
|
||||
}
|
||||
|
||||
type ManageMasterRequest struct {
|
||||
Action string `json:"action" binding:"required"` // freeze/unfreeze
|
||||
}
|
||||
|
||||
// ManageMaster godoc
|
||||
// @Summary Manage master status
|
||||
// @Description Freeze or unfreeze a master tenant
|
||||
// @Tags admin
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security AdminAuth
|
||||
// @Param id path int true "Master ID"
|
||||
// @Param request body ManageMasterRequest true "Action"
|
||||
// @Success 200 {object} MasterView
|
||||
// @Failure 400 {object} gin.H
|
||||
// @Failure 404 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Router /admin/masters/{id}/manage [post]
|
||||
func (h *AdminHandler) ManageMaster(c *gin.Context) {
|
||||
idRaw := strings.TrimSpace(c.Param("id"))
|
||||
idU64, err := strconv.ParseUint(idRaw, 10, 64)
|
||||
if err != nil || idU64 == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid master id"})
|
||||
return
|
||||
}
|
||||
var req ManageMasterRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
action := strings.ToLower(strings.TrimSpace(req.Action))
|
||||
var status string
|
||||
switch action {
|
||||
case "freeze":
|
||||
status = "suspended"
|
||||
case "unfreeze":
|
||||
status = "active"
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid action"})
|
||||
return
|
||||
}
|
||||
|
||||
var m model.Master
|
||||
if err := h.db.First(&m, uint(idU64)).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "master not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.db.Model(&m).Update("status", status).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update master status", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.db.First(&m, uint(idU64)).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to reload master", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.syncService.SyncMaster(&m); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to sync master", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, toMasterView(m))
|
||||
}
|
||||
|
||||
// DeleteMaster godoc
|
||||
// @Summary Delete (revoke) master
|
||||
// @Description Suspends a master and revokes all existing keys by bumping epoch and syncing to Redis
|
||||
// @Tags admin
|
||||
// @Produce json
|
||||
// @Security AdminAuth
|
||||
// @Param id path int true "Master ID"
|
||||
// @Success 200 {object} gin.H
|
||||
// @Failure 400 {object} gin.H
|
||||
// @Failure 404 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Router /admin/masters/{id} [delete]
|
||||
func (h *AdminHandler) DeleteMaster(c *gin.Context) {
|
||||
idRaw := strings.TrimSpace(c.Param("id"))
|
||||
idU64, err := strconv.ParseUint(idRaw, 10, 64)
|
||||
if err != nil || idU64 == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid master id"})
|
||||
return
|
||||
}
|
||||
|
||||
var m model.Master
|
||||
if err := h.db.First(&m, uint(idU64)).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "master not found"})
|
||||
return
|
||||
}
|
||||
|
||||
nextEpoch := m.Epoch + 1
|
||||
if nextEpoch <= 0 {
|
||||
nextEpoch = 1
|
||||
}
|
||||
if err := h.db.Model(&m).Updates(map[string]any{
|
||||
"status": "suspended",
|
||||
"epoch": nextEpoch,
|
||||
}).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to revoke master", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.db.First(&m, uint(idU64)).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to reload master", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Revoke all child keys too (defense-in-depth; master status already blocks in DP).
|
||||
if err := h.db.Model(&model.Key{}).Where("master_id = ?", m.ID).Update("status", "suspended").Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to revoke child keys", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.syncService.SyncMaster(&m); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to sync master", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
var keys []model.Key
|
||||
if err := h.db.Where("master_id = ?", m.ID).Find(&keys).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reload keys for sync", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
for i := range keys {
|
||||
if err := h.syncService.SyncKey(&keys[i]); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to sync key", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "revoked"})
|
||||
}
|
||||
|
||||
// IssueChildKeyForMaster godoc
|
||||
// @Summary Issue a child key on behalf of a master
|
||||
// @Description Issue a new access token (child key) for a specified master. The key still belongs to the master; issuer is recorded as admin for audit.
|
||||
|
||||
@@ -34,7 +34,7 @@ func TestAdmin_IssueChildKeyForMaster_IssuedByAdminAndSynced(t *testing.T) {
|
||||
|
||||
syncService := service.NewSyncService(rdb)
|
||||
masterService := service.NewMasterService(db)
|
||||
adminHandler := NewAdminHandler(masterService, syncService)
|
||||
adminHandler := NewAdminHandler(db, masterService, syncService)
|
||||
|
||||
m, _, err := masterService.CreateMaster("m1", "default", 5, 10)
|
||||
if err != nil {
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
80
internal/api/log_handler_test.go
Normal file
80
internal/api/log_handler_test.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/ez-api/ez-api/internal/model"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestMaster_ListSelfLogs_FiltersByMaster(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Master{}, &model.Key{}, &model.LogRecord{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
m1 := &model.Master{Name: "m1", Group: "g", Status: "active", Epoch: 1, MasterKeyDigest: "d1"}
|
||||
m2 := &model.Master{Name: "m2", Group: "g", Status: "active", Epoch: 1, MasterKeyDigest: "d2"}
|
||||
if err := db.Create(m1).Error; err != nil {
|
||||
t.Fatalf("create m1: %v", err)
|
||||
}
|
||||
if err := db.Create(m2).Error; err != nil {
|
||||
t.Fatalf("create m2: %v", err)
|
||||
}
|
||||
k1 := &model.Key{MasterID: m1.ID, TokenHash: "h1", Group: "g", Status: "active", IssuedAtEpoch: 1}
|
||||
k2 := &model.Key{MasterID: m2.ID, TokenHash: "h2", Group: "g", Status: "active", IssuedAtEpoch: 1}
|
||||
if err := db.Create(k1).Error; err != nil {
|
||||
t.Fatalf("create k1: %v", err)
|
||||
}
|
||||
if err := db.Create(k2).Error; err != nil {
|
||||
t.Fatalf("create k2: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Create(&model.LogRecord{Group: "rg", KeyID: k1.ID, ModelName: "ns.m", StatusCode: 200, LatencyMs: 10}).Error; err != nil {
|
||||
t.Fatalf("create log1: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.LogRecord{Group: "rg", KeyID: k2.ID, ModelName: "ns.m", StatusCode: 400, LatencyMs: 20}).Error; err != nil {
|
||||
t.Fatalf("create log2: %v", err)
|
||||
}
|
||||
|
||||
mh := &MasterHandler{db: db}
|
||||
|
||||
withMaster := func(next gin.HandlerFunc) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Set("master", m1)
|
||||
next(c)
|
||||
}
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
r.GET("/v1/logs", withMaster(mh.ListSelfLogs))
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/logs", nil)
|
||||
r.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var resp ListLogsResponse
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if resp.Total != 1 || len(resp.Items) != 1 {
|
||||
t.Fatalf("expected 1 item, got total=%d len=%d body=%s", resp.Total, len(resp.Items), rr.Body.String())
|
||||
}
|
||||
if resp.Items[0].KeyID != k1.ID {
|
||||
t.Fatalf("expected key_id %d, got %+v", k1.ID, resp.Items[0])
|
||||
}
|
||||
}
|
||||
@@ -3,20 +3,23 @@ package api
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/ez-api/ez-api/internal/model"
|
||||
"github.com/ez-api/ez-api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type MasterHandler struct {
|
||||
db *gorm.DB
|
||||
masterService *service.MasterService
|
||||
syncService *service.SyncService
|
||||
}
|
||||
|
||||
func NewMasterHandler(masterService *service.MasterService, syncService *service.SyncService) *MasterHandler {
|
||||
return &MasterHandler{masterService: masterService, syncService: syncService}
|
||||
func NewMasterHandler(db *gorm.DB, masterService *service.MasterService, syncService *service.SyncService) *MasterHandler {
|
||||
return &MasterHandler{db: db, masterService: masterService, syncService: syncService}
|
||||
}
|
||||
|
||||
type IssueChildKeyRequest struct {
|
||||
@@ -94,3 +97,243 @@ func (h *MasterHandler) IssueChildKey(c *gin.Context) {
|
||||
"scopes": key.Scopes,
|
||||
})
|
||||
}
|
||||
|
||||
// GetSelf godoc
|
||||
// @Summary Get current master info
|
||||
// @Description Returns master metadata for the authenticated master key
|
||||
// @Tags master
|
||||
// @Produce json
|
||||
// @Security MasterAuth
|
||||
// @Success 200 {object} MasterView
|
||||
// @Failure 401 {object} gin.H
|
||||
// @Router /v1/self [get]
|
||||
func (h *MasterHandler) GetSelf(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)
|
||||
c.JSON(http.StatusOK, toMasterView(*m))
|
||||
}
|
||||
|
||||
type TokenView struct {
|
||||
ID uint `json:"id"`
|
||||
Group string `json:"group"`
|
||||
Scopes string `json:"scopes"`
|
||||
Status string `json:"status"`
|
||||
IssuedBy string `json:"issued_by"`
|
||||
IssuedAtEpoch int64 `json:"issued_at_epoch"`
|
||||
DefaultNamespace string `json:"default_namespace"`
|
||||
Namespaces string `json:"namespaces"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
func toTokenView(k model.Key) TokenView {
|
||||
return TokenView{
|
||||
ID: k.ID,
|
||||
Group: k.Group,
|
||||
Scopes: k.Scopes,
|
||||
Status: k.Status,
|
||||
IssuedBy: k.IssuedBy,
|
||||
IssuedAtEpoch: k.IssuedAtEpoch,
|
||||
DefaultNamespace: strings.TrimSpace(k.DefaultNamespace),
|
||||
Namespaces: strings.TrimSpace(k.Namespaces),
|
||||
CreatedAt: k.CreatedAt.UTC().Unix(),
|
||||
UpdatedAt: k.UpdatedAt.UTC().Unix(),
|
||||
}
|
||||
}
|
||||
|
||||
// ListTokens godoc
|
||||
// @Summary List child keys
|
||||
// @Description List child keys issued under the authenticated master
|
||||
// @Tags master
|
||||
// @Produce json
|
||||
// @Security MasterAuth
|
||||
// @Success 200 {array} TokenView
|
||||
// @Failure 401 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Router /v1/tokens [get]
|
||||
func (h *MasterHandler) ListTokens(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)
|
||||
|
||||
var keys []model.Key
|
||||
if err := h.db.Where("master_id = ?", m.ID).Order("id desc").Find(&keys).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list tokens", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
out := make([]TokenView, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
out = append(out, toTokenView(k))
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
// GetToken godoc
|
||||
// @Summary Get child key
|
||||
// @Description Get a child key by id under the authenticated master
|
||||
// @Tags master
|
||||
// @Produce json
|
||||
// @Security MasterAuth
|
||||
// @Param id path int true "Token ID"
|
||||
// @Success 200 {object} TokenView
|
||||
// @Failure 400 {object} gin.H
|
||||
// @Failure 401 {object} gin.H
|
||||
// @Failure 404 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Router /v1/tokens/{id} [get]
|
||||
func (h *MasterHandler) GetToken(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)
|
||||
|
||||
idRaw := strings.TrimSpace(c.Param("id"))
|
||||
idU64, err := strconv.ParseUint(idRaw, 10, 64)
|
||||
if err != nil || idU64 == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid token id"})
|
||||
return
|
||||
}
|
||||
|
||||
var k model.Key
|
||||
if err := h.db.Where("master_id = ? AND id = ?", m.ID, uint(idU64)).First(&k).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "token not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, toTokenView(k))
|
||||
}
|
||||
|
||||
type UpdateTokenRequest struct {
|
||||
Scopes *string `json:"scopes,omitempty"`
|
||||
Status *string `json:"status,omitempty"` // active/suspended
|
||||
}
|
||||
|
||||
// UpdateToken godoc
|
||||
// @Summary Update child key
|
||||
// @Description Update token scopes/status under the authenticated master
|
||||
// @Tags master
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security MasterAuth
|
||||
// @Param id path int true "Token ID"
|
||||
// @Param request body UpdateTokenRequest true "Update payload"
|
||||
// @Success 200 {object} TokenView
|
||||
// @Failure 400 {object} gin.H
|
||||
// @Failure 401 {object} gin.H
|
||||
// @Failure 404 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Router /v1/tokens/{id} [put]
|
||||
func (h *MasterHandler) UpdateToken(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)
|
||||
|
||||
idRaw := strings.TrimSpace(c.Param("id"))
|
||||
idU64, err := strconv.ParseUint(idRaw, 10, 64)
|
||||
if err != nil || idU64 == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid token id"})
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateTokenRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
update := make(map[string]any)
|
||||
if req.Scopes != nil {
|
||||
update["scopes"] = strings.TrimSpace(*req.Scopes)
|
||||
}
|
||||
if req.Status != nil {
|
||||
st := strings.ToLower(strings.TrimSpace(*req.Status))
|
||||
if st != "active" && st != "suspended" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid status"})
|
||||
return
|
||||
}
|
||||
update["status"] = st
|
||||
}
|
||||
if len(update) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
|
||||
return
|
||||
}
|
||||
|
||||
var k model.Key
|
||||
if err := h.db.Where("master_id = ? AND id = ?", m.ID, uint(idU64)).First(&k).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "token not found"})
|
||||
return
|
||||
}
|
||||
if err := h.db.Model(&k).Updates(update).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update token", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.db.Where("master_id = ? AND id = ?", m.ID, uint(idU64)).First(&k).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reload token", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.syncService.SyncKey(&k); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to sync token", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, toTokenView(k))
|
||||
}
|
||||
|
||||
// DeleteToken godoc
|
||||
// @Summary Delete (revoke) child key
|
||||
// @Description Suspends a child key under the authenticated master
|
||||
// @Tags master
|
||||
// @Produce json
|
||||
// @Security MasterAuth
|
||||
// @Param id path int true "Token ID"
|
||||
// @Success 200 {object} gin.H
|
||||
// @Failure 400 {object} gin.H
|
||||
// @Failure 401 {object} gin.H
|
||||
// @Failure 404 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Router /v1/tokens/{id} [delete]
|
||||
func (h *MasterHandler) DeleteToken(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)
|
||||
|
||||
idRaw := strings.TrimSpace(c.Param("id"))
|
||||
idU64, err := strconv.ParseUint(idRaw, 10, 64)
|
||||
if err != nil || idU64 == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid token id"})
|
||||
return
|
||||
}
|
||||
|
||||
var k model.Key
|
||||
if err := h.db.Where("master_id = ? AND id = ?", m.ID, uint(idU64)).First(&k).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "token not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.db.Model(&k).Update("status", "suspended").Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to revoke token", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.db.Where("master_id = ? AND id = ?", m.ID, uint(idU64)).First(&k).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reload token", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.syncService.SyncKey(&k); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to sync token", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "revoked"})
|
||||
}
|
||||
|
||||
90
internal/api/master_tokens_handler_test.go
Normal file
90
internal/api/master_tokens_handler_test.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/ez-api/ez-api/internal/model"
|
||||
"github.com/ez-api/ez-api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestMaster_ListTokens_AndUpdateToken(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Master{}, &model.Key{}, &model.LogRecord{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
m := &model.Master{Name: "m1", Group: "g", Status: "active", Epoch: 1, DefaultNamespace: "ns", Namespaces: "ns", MaxChildKeys: 5, GlobalQPS: 3}
|
||||
if err := db.Create(m).Error; err != nil {
|
||||
t.Fatalf("create master: %v", err)
|
||||
}
|
||||
k := &model.Key{MasterID: m.ID, TokenHash: "h", Group: "g", Status: "active", Scopes: "chat:write", IssuedAtEpoch: 1, DefaultNamespace: "ns", Namespaces: "ns"}
|
||||
if err := db.Create(k).Error; err != nil {
|
||||
t.Fatalf("create key: %v", err)
|
||||
}
|
||||
|
||||
mr := miniredis.RunT(t)
|
||||
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
syncSvc := service.NewSyncService(rdb)
|
||||
masterSvc := service.NewMasterService(db)
|
||||
h := NewMasterHandler(db, masterSvc, syncSvc)
|
||||
|
||||
withMaster := func(next gin.HandlerFunc) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Set("master", m)
|
||||
next(c)
|
||||
}
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
r.GET("/v1/tokens", withMaster(h.ListTokens))
|
||||
r.PUT("/v1/tokens/:id", withMaster(h.UpdateToken))
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/tokens", nil)
|
||||
r.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var list []TokenView
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &list); err != nil {
|
||||
t.Fatalf("unmarshal list: %v", err)
|
||||
}
|
||||
if len(list) != 1 || list[0].ID != k.ID {
|
||||
t.Fatalf("unexpected list: %+v", list)
|
||||
}
|
||||
|
||||
body := []byte(`{"scopes":"chat:write,chat:read","status":"suspended"}`)
|
||||
rr = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodPut, fmt.Sprintf("/v1/tokens/%d", k.ID), bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var updated TokenView
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &updated); err != nil {
|
||||
t.Fatalf("unmarshal updated: %v", err)
|
||||
}
|
||||
if updated.Status != "suspended" {
|
||||
t.Fatalf("expected status suspended, got %+v", updated)
|
||||
}
|
||||
if mr.HGet("auth:token:h", "status") != "suspended" {
|
||||
t.Fatalf("expected redis token status suspended, got %q", mr.HGet("auth:token:h", "status"))
|
||||
}
|
||||
}
|
||||
182
internal/api/provider_admin_handler.go
Normal file
182
internal/api/provider_admin_handler.go
Normal file
@@ -0,0 +1,182 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ez-api/ez-api/internal/model"
|
||||
"github.com/ez-api/foundation/provider"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ListProviders godoc
|
||||
// @Summary List providers
|
||||
// @Description List all configured upstream providers
|
||||
// @Tags admin
|
||||
// @Produce json
|
||||
// @Security AdminAuth
|
||||
// @Success 200 {array} model.Provider
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Router /admin/providers [get]
|
||||
func (h *Handler) ListProviders(c *gin.Context) {
|
||||
var providers []model.Provider
|
||||
if err := h.db.Order("id desc").Find(&providers).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list providers", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, providers)
|
||||
}
|
||||
|
||||
// GetProvider godoc
|
||||
// @Summary Get provider
|
||||
// @Description Get a provider by id
|
||||
// @Tags admin
|
||||
// @Produce json
|
||||
// @Security AdminAuth
|
||||
// @Param id path int true "Provider ID"
|
||||
// @Success 200 {object} model.Provider
|
||||
// @Failure 400 {object} gin.H
|
||||
// @Failure 404 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Router /admin/providers/{id} [get]
|
||||
func (h *Handler) GetProvider(c *gin.Context) {
|
||||
id, ok := parseUintParam(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var p model.Provider
|
||||
if err := h.db.First(&p, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "provider not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, p)
|
||||
}
|
||||
|
||||
// DeleteProvider godoc
|
||||
// @Summary Delete provider
|
||||
// @Description Deletes a provider and triggers a full snapshot sync to avoid stale routing
|
||||
// @Tags admin
|
||||
// @Produce json
|
||||
// @Security AdminAuth
|
||||
// @Param id path int true "Provider ID"
|
||||
// @Success 200 {object} gin.H
|
||||
// @Failure 400 {object} gin.H
|
||||
// @Failure 404 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Router /admin/providers/{id} [delete]
|
||||
func (h *Handler) DeleteProvider(c *gin.Context) {
|
||||
id, ok := parseUintParam(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var p model.Provider
|
||||
if err := h.db.First(&p, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "provider not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.db.Delete(&p).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete provider", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Full sync is the simplest safe option because provider deletion needs to remove
|
||||
// stale entries in Redis snapshots (config:providers) and refresh bindings.
|
||||
if err := h.sync.SyncAll(h.db); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to sync snapshots", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
|
||||
}
|
||||
|
||||
type testProviderResponse struct {
|
||||
StatusCode int `json:"status_code"`
|
||||
OK bool `json:"ok"`
|
||||
URL string `json:"url"`
|
||||
Body string `json:"body,omitempty"`
|
||||
}
|
||||
|
||||
// TestProvider godoc
|
||||
// @Summary Test provider connectivity
|
||||
// @Description Performs a lightweight upstream request to verify the provider configuration
|
||||
// @Tags admin
|
||||
// @Produce json
|
||||
// @Security AdminAuth
|
||||
// @Param id path int true "Provider ID"
|
||||
// @Success 200 {object} testProviderResponse
|
||||
// @Failure 400 {object} gin.H
|
||||
// @Failure 404 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Router /admin/providers/{id}/test [post]
|
||||
func (h *Handler) TestProvider(c *gin.Context) {
|
||||
id, ok := parseUintParam(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var p model.Provider
|
||||
if err := h.db.First(&p, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "provider not found"})
|
||||
return
|
||||
}
|
||||
|
||||
pt := provider.NormalizeType(p.Type)
|
||||
baseURL := strings.TrimRight(strings.TrimSpace(p.BaseURL), "/")
|
||||
if baseURL == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "base_url required for provider test"})
|
||||
return
|
||||
}
|
||||
|
||||
url := ""
|
||||
var req *http.Request
|
||||
|
||||
switch pt {
|
||||
case provider.TypeOpenAI, provider.TypeCompatible:
|
||||
url = baseURL + "/models"
|
||||
r, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to build request", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
req = r
|
||||
if strings.TrimSpace(p.APIKey) != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(p.APIKey))
|
||||
}
|
||||
case provider.TypeAnthropic, provider.TypeClaude:
|
||||
url = baseURL + "/v1/models"
|
||||
r, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to build request", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
req = r
|
||||
if strings.TrimSpace(p.APIKey) != "" {
|
||||
req.Header.Set("x-api-key", strings.TrimSpace(p.APIKey))
|
||||
}
|
||||
req.Header.Set("anthropic-version", "2023-06-01")
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "provider type not supported for test"})
|
||||
return
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, testProviderResponse{StatusCode: 0, OK: false, URL: url, Body: err.Error()})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
ok = resp.StatusCode >= 200 && resp.StatusCode < 300
|
||||
|
||||
c.JSON(http.StatusOK, testProviderResponse{
|
||||
StatusCode: resp.StatusCode,
|
||||
OK: ok,
|
||||
URL: url,
|
||||
Body: string(body),
|
||||
})
|
||||
}
|
||||
60
internal/api/provider_admin_handler_test.go
Normal file
60
internal/api/provider_admin_handler_test.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/ez-api/ez-api/internal/model"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestAdmin_TestProvider_OpenAICompatible(t *testing.T) {
|
||||
h, db := newTestHandler(t)
|
||||
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/models" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer k" {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"object":"list","data":[]}`))
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
p := &model.Provider{
|
||||
Name: "p1",
|
||||
Type: "openai",
|
||||
BaseURL: upstream.URL + "/v1",
|
||||
APIKey: "k",
|
||||
Group: "default",
|
||||
Models: "gpt-4o-mini",
|
||||
Status: "active",
|
||||
}
|
||||
if err := db.Create(p).Error; err != nil {
|
||||
t.Fatalf("create provider: %v", err)
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
r.POST("/admin/providers/:id/test", h.TestProvider)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/providers/1/test", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if ok, _ := payload["ok"].(bool); !ok {
|
||||
t.Fatalf("expected ok=true, got %v body=%s", payload["ok"], rr.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ func newTestHandler(t *testing.T) (*Handler, *gorm.DB) {
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Provider{}, &model.Binding{}, &model.Model{}); err != nil {
|
||||
if err := db.AutoMigrate(&model.Master{}, &model.Key{}, &model.Provider{}, &model.Model{}, &model.Binding{}, &model.LogRecord{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ func (s *SyncService) SyncKey(key *model.Key) error {
|
||||
}
|
||||
|
||||
fields := map[string]interface{}{
|
||||
"id": key.ID,
|
||||
"master_id": key.MasterID,
|
||||
"issued_at_epoch": key.IssuedAtEpoch,
|
||||
"status": key.Status,
|
||||
@@ -260,6 +261,7 @@ func (s *SyncService) SyncAll(db *gorm.DB) error {
|
||||
return fmt.Errorf("token hash missing for key %d", k.ID)
|
||||
}
|
||||
pipe.HSet(ctx, fmt.Sprintf("auth:token:%s", tokenHash), map[string]interface{}{
|
||||
"id": k.ID,
|
||||
"master_id": k.MasterID,
|
||||
"issued_at_epoch": k.IssuedAtEpoch,
|
||||
"status": k.Status,
|
||||
|
||||
@@ -66,3 +66,29 @@ func TestSyncProvider_WritesSnapshotAndRouting(t *testing.T) {
|
||||
t.Fatalf("expected provider id 42 in routing set %q", routeKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncKey_WritesTokenID(t *testing.T) {
|
||||
mr := miniredis.RunT(t)
|
||||
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
svc := NewSyncService(rdb)
|
||||
|
||||
k := &model.Key{
|
||||
TokenHash: "hash",
|
||||
MasterID: 1,
|
||||
IssuedAtEpoch: 1,
|
||||
Status: "active",
|
||||
Group: "default",
|
||||
Scopes: "chat:write",
|
||||
DefaultNamespace: "default",
|
||||
Namespaces: "default",
|
||||
}
|
||||
k.ID = 123
|
||||
|
||||
if err := svc.SyncKey(k); err != nil {
|
||||
t.Fatalf("SyncKey: %v", err)
|
||||
}
|
||||
|
||||
if got := mr.HGet("auth:token:hash", "id"); got != "123" {
|
||||
t.Fatalf("expected auth:token:hash.id=123, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user