Fix loglist

This commit is contained in:
XOF
2025-11-21 19:33:05 +08:00
parent 1f7aa70810
commit 6a0f344e5c
22 changed files with 380 additions and 357 deletions

View File

@@ -7,7 +7,9 @@ import (
"gemini-balancer/internal/response"
"gemini-balancer/internal/service"
"gemini-balancer/internal/task"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
@@ -160,6 +162,29 @@ func (h *APIKeyHandler) ListAPIKeys(c *gin.Context) {
response.Error(c, errors.NewAPIError(errors.ErrBadRequest, err.Error()))
return
}
if params.IDs != "" {
idStrs := strings.Split(params.IDs, ",")
ids := make([]uint, 0, len(idStrs))
for _, s := range idStrs {
id, err := strconv.ParseUint(s, 10, 64)
if err == nil {
ids = append(ids, uint(id))
}
}
if len(ids) > 0 {
keys, err := h.apiKeyService.GetKeysByIds(ids)
if err != nil {
response.Error(c, &errors.APIError{
HTTPStatus: http.StatusInternalServerError,
Code: "DATA_FETCH_ERROR",
Message: err.Error(),
})
return
}
response.Success(c, keys)
return
}
}
if params.Page <= 0 {
params.Page = 1
}

View File

@@ -3,14 +3,13 @@ package handlers
import (
"gemini-balancer/internal/errors"
"gemini-balancer/internal/models"
"gemini-balancer/internal/response"
"gemini-balancer/internal/service"
"strconv"
"github.com/gin-gonic/gin"
)
// LogHandler 负责处理与日志相关的HTTP请求
type LogHandler struct {
logService *service.LogService
}
@@ -20,14 +19,22 @@ func NewLogHandler(logService *service.LogService) *LogHandler {
}
func (h *LogHandler) GetLogs(c *gin.Context) {
// 直接将Gin的上下文传递给Service层让Service自己去解析查询参
logs, err := h.logService.GetLogs(c)
// 调用新的服务函数,接收日志列表和总
logs, total, err := h.logService.GetLogs(c)
if err != nil {
response.Error(c, errors.ErrDatabase)
return
}
if logs == nil {
logs = []models.RequestLog{}
}
response.Success(c, logs)
// 解析分页参数用于响应体
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
// 使用标准的分页响应结构
response.Success(c, gin.H{
"items": logs,
"total": total,
"page": page,
"page_size": pageSize,
})
}

View File

@@ -136,34 +136,48 @@ func (h *ProxyHandler) serveTransparentProxy(c *gin.Context, requestBody []byte,
var finalPromptTokens, finalCompletionTokens int
var actualRetries int = 0
defer func() {
// 如果一次尝试都未成功(例如,在第一次获取资源时就失败),则不记录日志
if lastUsedResources == nil {
h.logger.WithField("id", correlationID).Warn("No resources were used, skipping final log event.")
return
}
finalEvent := h.createLogEvent(c, startTime, correlationID, modelName, lastUsedResources, models.LogTypeFinal, isPreciseRouting)
finalEvent.LatencyMs = int(time.Since(startTime).Milliseconds())
finalEvent.IsSuccess = isSuccess
finalEvent.Retries = actualRetries
finalEvent.RequestLog.LatencyMs = int(time.Since(startTime).Milliseconds())
finalEvent.RequestLog.IsSuccess = isSuccess
finalEvent.RequestLog.Retries = actualRetries
if isSuccess {
finalEvent.PromptTokens = finalPromptTokens
finalEvent.CompletionTokens = finalCompletionTokens
finalEvent.RequestLog.PromptTokens = finalPromptTokens
finalEvent.RequestLog.CompletionTokens = finalCompletionTokens
}
// 确保即使在成功的情况下如果recorder存在也记录最终的状态码
if finalRecorder != nil {
finalEvent.StatusCode = finalRecorder.Code
finalEvent.RequestLog.StatusCode = finalRecorder.Code
}
if !isSuccess {
// 将 finalProxyErr 的信息填充到 RequestLog 中
if finalProxyErr != nil {
finalEvent.Error = finalProxyErr
finalEvent.ErrorCode = finalProxyErr.Code
finalEvent.ErrorMessage = finalProxyErr.Message
finalEvent.Error = finalProxyErr // Error 字段用于事件传递,不会被序列化到数据库
finalEvent.RequestLog.ErrorCode = finalProxyErr.Code
finalEvent.RequestLog.ErrorMessage = finalProxyErr.Message
} else if finalRecorder != nil {
apiErr := errors.NewAPIErrorWithUpstream(finalRecorder.Code, "PROXY_ERROR", "Request failed after all retries.")
// 降级处理:如果 finalProxyErr 为空但 recorder 存在且失败
apiErr := errors.NewAPIErrorWithUpstream(finalRecorder.Code, fmt.Sprintf("UPSTREAM_%d", finalRecorder.Code), "Request failed after all retries.")
finalEvent.Error = apiErr
finalEvent.ErrorCode = apiErr.Code
finalEvent.ErrorMessage = apiErr.Message
finalEvent.RequestLog.ErrorCode = apiErr.Code
finalEvent.RequestLog.ErrorMessage = apiErr.Message
}
}
eventData, _ := json.Marshal(finalEvent)
_ = h.store.Publish(models.TopicRequestFinished, eventData)
// 将完整的事件发布
eventData, err := json.Marshal(finalEvent)
if err != nil {
h.logger.WithField("id", correlationID).WithError(err).Error("Failed to marshal final log event.")
return
}
if err := h.store.Publish(models.TopicRequestFinished, eventData); err != nil {
h.logger.WithField("id", correlationID).WithError(err).Error("Failed to publish final log event.")
}
}()
var maxRetries int
if isPreciseRouting {
@@ -417,18 +431,24 @@ func (h *ProxyHandler) createLogEvent(c *gin.Context, startTime time.Time, corrI
}
if authTokenValue, exists := c.Get("authToken"); exists {
if authToken, ok := authTokenValue.(*models.AuthToken); ok {
event.AuthTokenID = &authToken.ID
event.RequestLog.AuthTokenID = &authToken.ID
}
}
if res != nil {
event.KeyID = res.APIKey.ID
event.GroupID = res.KeyGroup.ID
// [核心修正] 填充到内嵌的 RequestLog 结构体中
if res.APIKey != nil {
event.RequestLog.KeyID = &res.APIKey.ID
}
if res.KeyGroup != nil {
event.RequestLog.GroupID = &res.KeyGroup.ID
}
if res.UpstreamEndpoint != nil {
event.UpstreamID = &res.UpstreamEndpoint.ID
event.RequestLog.UpstreamID = &res.UpstreamEndpoint.ID
// UpstreamURL 是事件传递字段,不是数据库字段,所以在这里赋值是正确的
event.UpstreamURL = &res.UpstreamEndpoint.URL
}
if res.ProxyConfig != nil {
event.ProxyID = &res.ProxyConfig.ID
event.RequestLog.ProxyID = &res.ProxyConfig.ID
}
}
return event

View File

@@ -57,6 +57,7 @@ type APIKeyQueryParams struct {
PageSize int `form:"limit"`
Status string `form:"status"`
Keyword string `form:"keyword"`
IDs string `form:"ids"`
}
// APIKeyDetails is a DTO that combines APIKey info with its contextual status from the mapping.

View File

@@ -17,15 +17,10 @@ const (
type RequestFinishedEvent struct {
RequestLog
KeyID uint
GroupID uint
IsSuccess bool
StatusCode int
Error *errors.APIError
CorrelationID string `json:"correlation_id,omitempty"`
UpstreamID *uint `json:"upstream_id"`
UpstreamURL *string `json:"upstream_url,omitempty"`
IsPreciseRouting bool `json:"is_precise_routing"`
Error *errors.APIError `json:"error,omitempty"` // Error 结构体不存入数据库,仅供事件传递
CorrelationID string `json:"correlation_id,omitempty"`
UpstreamURL *string `json:"upstream_url,omitempty"`
IsPreciseRouting bool `json:"is_precise_routing"`
}
type KeyStatusChangedEvent struct {

View File

@@ -14,6 +14,7 @@ type MasterAPIKeyStatus string
type PollingStrategy string
type FileProcessingState string
type LogType string
type ProtocolType string
const (
// --- 运营状态 (在中间表中使用) ---
@@ -35,8 +36,12 @@ const (
FileActive FileProcessingState = "ACTIVE"
FileFailed FileProcessingState = "FAILED"
LogTypeFinal LogType = "FINAL" // Represents the final outcome of a request, including all retries.
LogTypeRetry LogType = "RETRY" // Represents a single, failed attempt that triggered a retry.
LogTypeFinal LogType = "FINAL" // Represents the final outcome of a request, including all retries.
LogTypeRetry LogType = "RETRY" // Represents a single, failed attempt that triggered a retry.
LogTypeValidation LogType = "VALIDATION"
ProtocolOpenAI ProtocolType = "openai"
ProtocolGemini ProtocolType = "gemini"
)
// ========= 核心数据库模型 =========

View File

@@ -11,6 +11,7 @@ import (
"io"
"sort"
"strconv"
"strings"
"time"
"gorm.io/gorm"
@@ -81,13 +82,20 @@ func (r *gormKeyRepository) SelectOneActiveKey(group *models.KeyGroup) (*models.
// SelectOneActiveKeyFromBasePool 为智能聚合模式设计的全新轮询器。
func (r *gormKeyRepository) SelectOneActiveKeyFromBasePool(pool *BasePool) (*models.APIKey, *models.KeyGroup, error) {
protocol := "default"
if pool.Protocol != "" {
protocol = string(pool.Protocol)
}
// 生成唯一的池ID确保不同请求组合的轮询状态相互隔离
poolID := generatePoolID(pool.CandidateGroups)
log := r.logger.WithField("pool_id", poolID)
poolID := generatePoolID(pool.CandidateGroups, protocol)
log := r.logger.WithField("pool_id", poolID).WithField("protocol", protocol)
if err := r.ensureBasePoolCacheExists(pool, poolID); err != nil {
log.WithError(err).Error("Failed to ensure BasePool cache exists.")
return nil, nil, err
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil, err
}
return nil, nil, fmt.Errorf("unexpected error while ensuring base pool cache: %w", err)
}
var keyIDStr string
@@ -145,25 +153,40 @@ func (r *gormKeyRepository) SelectOneActiveKeyFromBasePool(pool *BasePool) (*mod
// ensureBasePoolCacheExists 动态创建 BasePool 的 Redis 结构
func (r *gormKeyRepository) ensureBasePoolCacheExists(pool *BasePool, poolID string) error {
// 使用 LIST 键作为存在性检查的标志
listKey := fmt.Sprintf(BasePoolSequential, poolID)
exists, err := r.store.Exists(listKey)
if err != nil {
r.logger.WithError(err).Errorf("Failed to check existence of basepool key: %s", listKey)
return err
}
if exists {
val, err := r.store.LIndex(listKey, 0)
if err == nil && val == EmptyPoolPlaceholder {
if err != nil {
return err
}
if val == EmptyPoolPlaceholder {
return gorm.ErrRecordNotFound
}
return nil
}
lockKey := fmt.Sprintf("lock:basepool:%s", poolID)
acquired, err := r.store.SetNX(lockKey, []byte("1"), 10*time.Second)
if err != nil {
r.logger.WithError(err).Errorf("Failed to acquire distributed lock for basepool build: %s", lockKey)
return err
}
if !acquired {
time.Sleep(100 * time.Millisecond)
return r.ensureBasePoolCacheExists(pool, poolID)
}
defer r.store.Del(lockKey)
if exists, _ := r.store.Exists(listKey); exists {
return nil
}
r.logger.Infof("BasePool cache for pool_id '%s' not found. Building now...", poolID)
var allActiveKeyIDs []string
lruMembers := make(map[string]float64)
for _, group := range pool.CandidateGroups {
activeKeySetKey := fmt.Sprintf(KeyGroup, group.ID)
groupKeyIDs, err := r.store.SMembers(activeKeySetKey)
@@ -171,17 +194,21 @@ func (r *gormKeyRepository) ensureBasePoolCacheExists(pool *BasePool, poolID str
r.logger.WithError(err).Warnf("Failed to get active keys for group %d during BasePool build", group.ID)
continue
}
allActiveKeyIDs = append(allActiveKeyIDs, groupKeyIDs...)
for _, keyIDStr := range groupKeyIDs {
keyID, _ := strconv.ParseUint(keyIDStr, 10, 64)
_, mapping, err := r.getKeyDetailsFromCache(uint(keyID), group.ID)
if err == nil && mapping != nil {
var score float64
if mapping.LastUsedAt != nil {
score = float64(mapping.LastUsedAt.UnixMilli())
if err != nil {
if errors.Is(err, store.ErrNotFound) || strings.Contains(err.Error(), "failed to get") {
r.logger.WithError(err).Warnf("Cache inconsistency detected for KeyID %s in GroupID %d. Skipping.", keyIDStr, group.ID)
continue
} else {
return err
}
lruMembers[keyIDStr] = score
}
allActiveKeyIDs = append(allActiveKeyIDs, keyIDStr)
if mapping != nil && mapping.LastUsedAt != nil {
lruMembers[keyIDStr] = float64(mapping.LastUsedAt.UnixMilli())
}
}
}
@@ -194,23 +221,16 @@ func (r *gormKeyRepository) ensureBasePoolCacheExists(pool *BasePool, poolID str
}
return gorm.ErrRecordNotFound
}
// 使用管道填充所有轮询结构
pipe := r.store.Pipeline()
// 1. 顺序
pipe.LPush(fmt.Sprintf(BasePoolSequential, poolID), toInterfaceSlice(allActiveKeyIDs)...)
// 2. 随机
pipe.SAdd(fmt.Sprintf(BasePoolRandomMain, poolID), toInterfaceSlice(allActiveKeyIDs)...)
// 设置合理的过期时间例如5分钟以防止孤儿数据
pipe.Expire(fmt.Sprintf(BasePoolSequential, poolID), CacheTTL)
pipe.Expire(fmt.Sprintf(BasePoolRandomMain, poolID), CacheTTL)
pipe.Expire(fmt.Sprintf(BasePoolRandomCooldown, poolID), CacheTTL)
pipe.Expire(fmt.Sprintf(BasePoolLRU, poolID), CacheTTL)
if err := pipe.Exec(); err != nil {
return err
}
if len(lruMembers) > 0 {
r.store.ZAdd(fmt.Sprintf(BasePoolLRU, poolID), lruMembers)
}
@@ -226,7 +246,7 @@ func (r *gormKeyRepository) updateKeyUsageTimestampForPool(poolID string, keyID
}
// generatePoolID 根据候选组ID列表生成一个稳定的、唯一的字符串ID
func generatePoolID(groups []*models.KeyGroup) string {
func generatePoolID(groups []*models.KeyGroup, protocol string) string {
ids := make([]int, len(groups))
for i, g := range groups {
ids[i] = int(g.ID)
@@ -234,7 +254,7 @@ func generatePoolID(groups []*models.KeyGroup) string {
sort.Ints(ids)
h := sha1.New()
io.WriteString(h, fmt.Sprintf("%v", ids))
io.WriteString(h, fmt.Sprintf("protocol:%s;groups:%v", protocol, ids))
return fmt.Sprintf("%x", h.Sum(nil))
}

View File

@@ -17,6 +17,7 @@ import (
type BasePool struct {
CandidateGroups []*models.KeyGroup
PollingStrategy models.PollingStrategy
Protocol models.ProtocolType
}
type KeyRepository interface {

View File

@@ -84,26 +84,24 @@ func (s *AnalyticsService) eventListener() {
}
func (s *AnalyticsService) handleAnalyticsEvent(event *models.RequestFinishedEvent) {
if event.GroupID == 0 {
if event.RequestLog.GroupID == nil {
return
}
key := fmt.Sprintf("analytics:hourly:%s", time.Now().UTC().Format("2006-01-02T15"))
fieldPrefix := fmt.Sprintf("%d:%s", event.GroupID, event.ModelName)
fieldPrefix := fmt.Sprintf("%d:%s", *event.RequestLog.GroupID, event.RequestLog.ModelName)
pipe := s.store.Pipeline()
pipe.HIncrBy(key, fieldPrefix+":requests", 1)
if event.IsSuccess {
if event.RequestLog.IsSuccess {
pipe.HIncrBy(key, fieldPrefix+":success", 1)
}
if event.PromptTokens > 0 {
pipe.HIncrBy(key, fieldPrefix+":prompt", int64(event.PromptTokens))
if event.RequestLog.PromptTokens > 0 {
pipe.HIncrBy(key, fieldPrefix+":prompt", int64(event.RequestLog.PromptTokens))
}
if event.CompletionTokens > 0 {
pipe.HIncrBy(key, fieldPrefix+":completion", int64(event.CompletionTokens))
if event.RequestLog.CompletionTokens > 0 {
pipe.HIncrBy(key, fieldPrefix+":completion", int64(event.RequestLog.CompletionTokens))
}
if err := pipe.Exec(); err != nil {
s.logger.Warnf("[%s] Failed to record analytics event to store for group %d: %v", event.CorrelationID, event.GroupID, err)
s.logger.Warnf("[%s] Failed to record analytics event to store for group %d: %v", event.CorrelationID, *event.RequestLog.GroupID, err)
}
}

View File

@@ -174,49 +174,40 @@ func (s *APIKeyService) Stop() {
}
func (s *APIKeyService) handleKeyUsageEvent(event *models.RequestFinishedEvent) {
if event.KeyID == 0 || event.GroupID == 0 {
if event.RequestLog.KeyID == nil || event.RequestLog.GroupID == nil {
return
}
// Handle success case: key recovery and timestamp update.
if event.IsSuccess {
mapping, err := s.keyRepo.GetMapping(event.GroupID, event.KeyID)
if event.RequestLog.IsSuccess {
mapping, err := s.keyRepo.GetMapping(*event.RequestLog.GroupID, *event.RequestLog.KeyID)
if err != nil {
// Log if mapping is not found, but don't proceed.
s.logger.Warnf("[%s] Could not find mapping for G:%d K:%d on successful request: %v", event.CorrelationID, event.GroupID, event.KeyID, err)
s.logger.Warnf("[%s] Could not find mapping for G:%d K:%d on successful request: %v", event.CorrelationID, *event.RequestLog.GroupID, *event.RequestLog.KeyID, err)
return
}
needsUpdate := false
statusChanged := false
oldStatus := mapping.Status
// If status was not active, it's a recovery.
if mapping.Status != models.StatusActive {
mapping.Status = models.StatusActive
mapping.ConsecutiveErrorCount = 0
mapping.LastError = ""
needsUpdate = true
statusChanged = true
}
// Always update LastUsedAt timestamp.
now := time.Now()
mapping.LastUsedAt = &now
needsUpdate = true
if needsUpdate {
if err := s.keyRepo.UpdateMapping(mapping); err != nil {
s.logger.Errorf("[%s] Failed to update mapping for G:%d K:%d after successful request: %v", event.CorrelationID, event.GroupID, event.KeyID, err)
} else if oldStatus != models.StatusActive {
// Only publish event if status actually changed.
go s.publishStatusChangeEvent(event.GroupID, event.KeyID, oldStatus, models.StatusActive, "key_recovered_after_use")
}
if err := s.keyRepo.UpdateMapping(mapping); err != nil {
s.logger.Errorf("[%s] Failed to update mapping for G:%d K:%d after successful request: %v", event.CorrelationID, *event.RequestLog.GroupID, *event.RequestLog.KeyID, err)
return
}
if statusChanged {
go s.publishStatusChangeEvent(*event.RequestLog.GroupID, *event.RequestLog.KeyID, oldStatus, models.StatusActive, "key_recovered_after_use")
}
return
}
// Handle failure case: delegate to the centralized judgment function.
if event.Error != nil {
s.judgeKeyErrors(
event.CorrelationID,
event.GroupID,
event.KeyID,
*event.RequestLog.GroupID,
*event.RequestLog.KeyID,
event.Error,
event.IsPreciseRouting,
)
@@ -354,6 +345,10 @@ func (s *APIKeyService) ListAPIKeys(params *models.APIKeyQueryParams) (*Paginate
}, nil
}
func (s *APIKeyService) GetKeysByIds(ids []uint) ([]models.APIKey, error) {
return s.keyRepo.GetKeysByIDs(ids)
}
func (s *APIKeyService) UpdateAPIKey(key *models.APIKey) error {
go func() {
var oldKey models.APIKey

View File

@@ -1,4 +1,4 @@
// Filename: internal/service/db_log_writer_service.go (全新文件)
// Filename: internal/service/db_log_writer_service.go
package service

View File

@@ -164,12 +164,15 @@ func (s *KeyValidationService) runTestKeysTask(taskID string, resourceID string,
var currentResult models.KeyTestResult
event := models.RequestFinishedEvent{
GroupID: groupID,
KeyID: apiKeyModel.ID,
RequestLog: models.RequestLog{
// GroupID 和 KeyID 在 RequestLog 模型中是指针,需要取地址
GroupID: &groupID,
KeyID: &apiKeyModel.ID,
},
}
if validationErr == nil {
currentResult = models.KeyTestResult{Key: apiKeyModel.APIKey, Status: "valid", Message: "Validation successful."}
event.IsSuccess = true
event.RequestLog.IsSuccess = true
} else {
var apiErr *CustomErrors.APIError
if CustomErrors.As(validationErr, &apiErr) {
@@ -179,7 +182,7 @@ func (s *KeyValidationService) runTestKeysTask(taskID string, resourceID string,
currentResult = models.KeyTestResult{Key: apiKeyModel.APIKey, Status: "error", Message: "Validation check failed: " + validationErr.Error()}
event.Error = &CustomErrors.APIError{Message: validationErr.Error()}
}
event.IsSuccess = false
event.RequestLog.IsSuccess = false
}
eventData, _ := json.Marshal(event)
if err := s.store.Publish(models.TopicRequestFinished, eventData); err != nil {

View File

@@ -1,3 +1,4 @@
// Filename: internal/service/log_service.go
package service
import (
@@ -16,28 +17,35 @@ func NewLogService(db *gorm.DB) *LogService {
return &LogService{db: db}
}
// Record 记录一条日志到数据库 (TODO 暂时保留简单实现,后续再重构为异步)
func (s *LogService) Record(log *models.RequestLog) error {
return s.db.Create(log).Error
}
func (s *LogService) GetLogs(c *gin.Context) ([]models.RequestLog, error) {
func (s *LogService) GetLogs(c *gin.Context) ([]models.RequestLog, int64, error) {
var logs []models.RequestLog
var total int64
query := s.db.Model(&models.RequestLog{}).Scopes(s.filtersScope(c)).Order("request_time desc")
query := s.db.Model(&models.RequestLog{}).Scopes(s.filtersScope(c))
// 简单的分页 ( TODO 后续可以做得更复杂)
// 先计算总数
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
if total == 0 {
return []models.RequestLog{}, 0, nil
}
// 再执行分页查询
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
offset := (page - 1) * pageSize
// 执行查询
err := query.Limit(pageSize).Offset(offset).Find(&logs).Error
err := query.Order("request_time desc").Limit(pageSize).Offset(offset).Find(&logs).Error
if err != nil {
return nil, err
return nil, 0, err
}
return logs, nil
return logs, total, nil
}
func (s *LogService) filtersScope(c *gin.Context) func(db *gorm.DB) *gorm.DB {
@@ -60,6 +68,11 @@ func (s *LogService) filtersScope(c *gin.Context) func(db *gorm.DB) *gorm.DB {
db = db.Where("key_id = ?", keyID)
}
}
if groupIDStr := c.Query("group_id"); groupIDStr != "" {
if groupID, err := strconv.ParseUint(groupIDStr, 10, 64); err == nil {
db = db.Where("group_id = ?", groupID)
}
}
return db
}
}