ccpoad / internal /storage /sql /metrics.go
anyalerob's picture
Upload folder using huggingface_hub
2986042 verified
Raw
History Blame Contribute Delete
27.6 kB
package sql
import (
"context"
"database/sql"
"fmt"
"log"
"sort"
"strings"
"time"
"ccLoad/internal/model"
)
// executeStatsQuery 构建并执行统计 SQL,返回行结果与渠道 ID 集合(供后续批量补全)。
// withLastSuccess=true 时额外 SELECT/扫描 last_success_at 列;isEmpty 表示渠道过滤后无候选。
func (s *SQLStore) executeStatsQuery(ctx context.Context, startTime, endTime time.Time, filter *model.LogFilter, withLastSuccess bool) (stats []model.StatsEntry, channelIDsToFetch map[int64]bool, err error) {
lastSuccessCol := ""
if withLastSuccess {
lastSuccessCol = `MAX(CASE WHEN status_code >= 200 AND status_code < 300 THEN time ELSE NULL END) AS last_success_at,
`
}
baseQuery := `
SELECT
channel_id,
COALESCE(model, '') AS model,
SUM(CASE WHEN status_code >= 200 AND status_code < 300 THEN 1 ELSE 0 END) AS success,
SUM(CASE WHEN (status_code < 200 OR status_code >= 300) AND status_code != 499 THEN 1 ELSE 0 END) AS error,
SUM(CASE WHEN status_code != 499 THEN 1 ELSE 0 END) AS total,
ROUND(
AVG(CASE WHEN is_streaming = 1 AND first_byte_time > 0 AND status_code >= 200 AND status_code < 300 THEN first_byte_time ELSE NULL END),
3
) as avg_first_byte_time,
ROUND(
AVG(CASE WHEN duration > 0 THEN duration ELSE NULL END),
3
) as avg_duration,
` + lastSuccessCol + `SUM(COALESCE(input_tokens, 0)) as total_input_tokens,
SUM(COALESCE(output_tokens, 0)) as total_output_tokens,
SUM(COALESCE(cache_read_input_tokens, 0)) as total_cache_read_input_tokens,
SUM(COALESCE(cache_creation_input_tokens, 0)) as total_cache_creation_input_tokens,
SUM(COALESCE(cost, 0.0)) as total_cost,
SUM(COALESCE(cost, 0.0) * COALESCE(cost_multiplier, 1)) as effective_cost
FROM logs`
startMs := startTime.UnixMilli()
endMs := endTime.UnixMilli()
qb := NewQueryBuilder(baseQuery).
Where("time >= ?", startMs).
Where("time <= ?", endMs).
Where("channel_id > 0")
_, isEmpty, err := s.applyChannelFilter(ctx, qb, filter)
if err != nil {
return nil, nil, err
}
if isEmpty {
return []model.StatsEntry{}, map[int64]bool{}, nil
}
qb.ApplyFilter(filter)
suffix := "GROUP BY channel_id, model ORDER BY channel_id ASC, model ASC"
query, args := qb.BuildWithSuffix(suffix)
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, nil, err
}
defer func() { _ = rows.Close() }()
stats = make([]model.StatsEntry, 0)
channelIDsToFetch = make(map[int64]bool)
for rows.Next() {
var entry model.StatsEntry
var avgFirstByteTime, avgDuration sql.NullFloat64
var lastSuccessAt sql.NullInt64
var totalInputTokens, totalOutputTokens, totalCacheReadTokens, totalCacheCreationTokens sql.NullInt64
var totalCost, effectiveCost sql.NullFloat64
scanArgs := []any{
&entry.ChannelID, &entry.Model,
&entry.Success, &entry.Error, &entry.Total,
&avgFirstByteTime, &avgDuration,
}
if withLastSuccess {
scanArgs = append(scanArgs, &lastSuccessAt)
}
scanArgs = append(scanArgs,
&totalInputTokens, &totalOutputTokens, &totalCacheReadTokens, &totalCacheCreationTokens,
&totalCost, &effectiveCost,
)
if err := rows.Scan(scanArgs...); err != nil {
return nil, nil, err
}
if avgFirstByteTime.Valid {
entry.AvgFirstByteTimeSeconds = &avgFirstByteTime.Float64
}
if avgDuration.Valid {
entry.AvgDurationSeconds = &avgDuration.Float64
}
if withLastSuccess && lastSuccessAt.Valid && lastSuccessAt.Int64 > 0 {
entry.LastSuccessAt = &lastSuccessAt.Int64
}
if totalInputTokens.Valid && totalInputTokens.Int64 > 0 {
entry.TotalInputTokens = &totalInputTokens.Int64
}
if totalOutputTokens.Valid && totalOutputTokens.Int64 > 0 {
entry.TotalOutputTokens = &totalOutputTokens.Int64
}
if totalCacheReadTokens.Valid && totalCacheReadTokens.Int64 > 0 {
entry.TotalCacheReadInputTokens = &totalCacheReadTokens.Int64
}
if totalCacheCreationTokens.Valid && totalCacheCreationTokens.Int64 > 0 {
entry.TotalCacheCreationInputTokens = &totalCacheCreationTokens.Int64
}
if totalCost.Valid && totalCost.Float64 > 0 {
entry.TotalCost = &totalCost.Float64
}
if effectiveCost.Valid && (effectiveCost.Float64 > 0 || (totalCost.Valid && totalCost.Float64 > 0)) {
entry.EffectiveCost = &effectiveCost.Float64
}
if entry.ChannelID != nil {
channelIDsToFetch[int64(*entry.ChannelID)] = true
}
stats = append(stats, entry)
}
if err := rows.Err(); err != nil {
return nil, nil, err
}
return stats, channelIDsToFetch, nil
}
// GetStats 实现统计功能,按渠道和模型统计成功/失败次数
// 消除 N+1:渠道过滤/名称解析用一次批量查询完成
// [FIX] 2025-12: 排除499(客户端取消)避免污染成功率和调用次数统计
func (s *SQLStore) GetStats(ctx context.Context, startTime, endTime time.Time, filter *model.LogFilter, isToday bool) ([]model.StatsEntry, error) {
stats, channelIDsToFetch, err := s.executeStatsQuery(ctx, startTime, endTime, filter, true)
if err != nil {
return nil, err
}
if len(stats) == 0 {
return stats, nil
}
if len(channelIDsToFetch) > 0 {
channelInfos, err := s.fetchChannelInfoBatch(ctx, channelIDsToFetch)
if err != nil {
// 降级处理:查询失败不影响统计返回,仅记录错误
log.Printf("[WARN] 批量查询渠道信息失败: %v", err)
channelInfos = make(map[int64]ChannelInfo)
}
// 填充渠道名称、优先级和类型
for i := range stats {
if stats[i].ChannelID != nil {
if info, ok := channelInfos[int64(*stats[i].ChannelID)]; ok {
stats[i].ChannelName = info.Name
stats[i].ChannelPriority = &info.Priority
stats[i].ChannelType = info.Type
if info.CostMultiplier != 1 {
costMultiplier := info.CostMultiplier
stats[i].CostMultiplier = &costMultiplier
}
} else {
// 如果查询不到渠道信息,使用默认值
stats[i].ChannelName = "未知渠道"
}
}
}
}
if len(stats) > 0 {
if err := s.fillStatsLastSuccesses(ctx, stats, filter); err != nil {
log.Printf("[WARN] 查询渠道最后成功时间失败: %v", err)
}
if err := s.fillStatsLastRequests(ctx, stats, filter); err != nil {
log.Printf("[WARN] 查询渠道最近请求失败: %v", err)
}
}
// 计算每个channel_id+model的RPM统计
if len(stats) > 0 {
if err := s.fillStatsRPM(ctx, stats, startTime, endTime, filter, isToday); err != nil {
// 降级处理:RPM计算失败不影响主要统计数据
log.Printf("[WARN] 计算RPM统计失败: %v", err)
}
}
return stats, nil
}
type statsRequestKey struct {
channelID int
model string
}
func cloneLogFilterWithoutStatusCode(filter *model.LogFilter) *model.LogFilter {
if filter == nil {
return nil
}
cloned := *filter
cloned.StatusCode = nil
return &cloned
}
func (s *SQLStore) fillStatsLastSuccesses(ctx context.Context, stats []model.StatsEntry, filter *model.LogFilter) error {
if hasStatsModelFilter(filter) {
return s.fillStatsLastSuccessesByEntry(ctx, stats, filter)
}
entryIndexesByChannel := make(map[int][]int, len(stats))
for i := range stats {
if stats[i].ChannelID == nil {
continue
}
channelID := *stats[i].ChannelID
entryIndexesByChannel[channelID] = append(entryIndexesByChannel[channelID], i)
}
if len(entryIndexesByChannel) == 0 {
return nil
}
lastStateFilter := cloneLogFilterWithoutStatusCode(filter)
query, args := buildLatestChannelSuccessQuery(entryIndexesByChannel, lastStateFilter)
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return err
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var channelID int
var successAt int64
var successID int64
if err := rows.Scan(&channelID, &successAt, &successID); err != nil {
return err
}
if successAt <= 0 {
continue
}
for _, idx := range entryIndexesByChannel[channelID] {
successAtValue := successAt
stats[idx].LastSuccessAt = &successAtValue
if successID > 0 {
successIDValue := successID
stats[idx].LastSuccessID = &successIDValue
}
}
}
if err := rows.Err(); err != nil {
return err
}
return nil
}
func (s *SQLStore) fillStatsLastSuccessesByEntry(ctx context.Context, stats []model.StatsEntry, filter *model.LogFilter) error {
entryIndexes := make(map[statsRequestKey]int, len(stats))
for i := range stats {
if stats[i].ChannelID == nil {
continue
}
key := statsRequestKey{channelID: *stats[i].ChannelID, model: stats[i].Model}
entryIndexes[key] = i
}
if len(entryIndexes) == 0 {
return nil
}
lastStateFilter := cloneLogFilterWithoutStatusCode(filter)
query, args := buildLatestEntrySuccessQuery(entryIndexes, lastStateFilter)
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return err
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var channelID int
var modelName string
var successAt int64
var successID int64
if err := rows.Scan(&channelID, &modelName, &successAt, &successID); err != nil {
return err
}
if successAt <= 0 {
continue
}
key := statsRequestKey{channelID: channelID, model: modelName}
idx, ok := entryIndexes[key]
if !ok {
continue
}
stats[idx].LastSuccessAt = &successAt
if successID > 0 {
stats[idx].LastSuccessID = &successID
}
}
if err := rows.Err(); err != nil {
return err
}
return nil
}
func (s *SQLStore) fillStatsLastRequests(ctx context.Context, stats []model.StatsEntry, filter *model.LogFilter) error {
if hasStatsModelFilter(filter) {
return s.fillStatsLastRequestsByEntry(ctx, stats, filter)
}
entryIndexesByChannel := make(map[int][]int, len(stats))
for i := range stats {
if stats[i].ChannelID == nil {
continue
}
channelID := *stats[i].ChannelID
entryIndexesByChannel[channelID] = append(entryIndexesByChannel[channelID], i)
}
if len(entryIndexesByChannel) == 0 {
return nil
}
lastStateFilter := cloneLogFilterWithoutStatusCode(filter)
query, args := buildLatestChannelRequestQuery(entryIndexesByChannel, lastStateFilter)
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return err
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var channelID int
var requestAt int64
var requestID int64
var status int
var message string
if err := rows.Scan(&channelID, &requestAt, &requestID, &status, &message); err != nil {
return err
}
if requestAt <= 0 {
continue
}
for _, idx := range entryIndexesByChannel[channelID] {
requestAtValue := requestAt
stats[idx].LastRequestAt = &requestAtValue
if requestID > 0 {
requestIDValue := requestID
stats[idx].LastRequestID = &requestIDValue
}
statusValue := status
stats[idx].LastRequestStatus = &statusValue
stats[idx].LastRequestMessage = message
}
}
if err := rows.Err(); err != nil {
return err
}
return nil
}
func (s *SQLStore) fillStatsLastRequestsByEntry(ctx context.Context, stats []model.StatsEntry, filter *model.LogFilter) error {
entryIndexes := make(map[statsRequestKey]int, len(stats))
for i := range stats {
if stats[i].ChannelID == nil {
continue
}
key := statsRequestKey{channelID: *stats[i].ChannelID, model: stats[i].Model}
entryIndexes[key] = i
}
if len(entryIndexes) == 0 {
return nil
}
lastStateFilter := cloneLogFilterWithoutStatusCode(filter)
query, args := buildLatestEntryRequestQuery(entryIndexes, lastStateFilter)
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return err
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var channelID int
var modelName string
var requestAt int64
var requestID int64
var status int
var message string
if err := rows.Scan(&channelID, &modelName, &requestAt, &requestID, &status, &message); err != nil {
return err
}
if requestAt <= 0 {
continue
}
key := statsRequestKey{channelID: channelID, model: modelName}
idx, ok := entryIndexes[key]
if !ok {
continue
}
stats[idx].LastRequestAt = &requestAt
if requestID > 0 {
stats[idx].LastRequestID = &requestID
}
stats[idx].LastRequestStatus = &status
stats[idx].LastRequestMessage = message
}
if err := rows.Err(); err != nil {
return err
}
return nil
}
func hasStatsModelFilter(filter *model.LogFilter) bool {
return filter != nil && (filter.Model != "" || filter.ModelLike != "")
}
func buildLatestChannelSuccessQuery(entryIndexesByChannel map[int][]int, filter *model.LogFilter) (string, []any) {
return buildLatestChannelLogQuery(entryIndexesByChannel, filter, []string{"l.time", "l.id"}, func(qb *QueryBuilder) {
qb.Where("status_code >= 200").
Where("status_code < 300")
})
}
func buildLatestChannelRequestQuery(entryIndexesByChannel map[int][]int, filter *model.LogFilter) (string, []any) {
return buildLatestChannelLogQuery(entryIndexesByChannel, filter, []string{"l.time", "l.id", "l.status_code", "l.message"}, func(qb *QueryBuilder) {
qb.Where("status_code != 499")
})
}
func buildLatestEntrySuccessQuery(entryIndexes map[statsRequestKey]int, filter *model.LogFilter) (string, []any) {
return buildLatestEntryLogQuery(entryIndexes, filter, []string{"l.time", "l.id"}, func(qb *QueryBuilder) {
qb.Where("status_code >= 200").
Where("status_code < 300")
})
}
func buildLatestEntryRequestQuery(entryIndexes map[statsRequestKey]int, filter *model.LogFilter) (string, []any) {
return buildLatestEntryLogQuery(entryIndexes, filter, []string{"l.time", "l.id", "l.status_code", "l.message"}, func(qb *QueryBuilder) {
qb.Where("status_code != 499")
})
}
func buildLatestChannelLogQuery(entryIndexesByChannel map[int][]int, filter *model.LogFilter, selectColumns []string, applyStatePredicate func(*QueryBuilder)) (string, []any) {
scopeSQL, scopeArgs := buildChannelScope(entryIndexesByChannel)
subQB := NewQueryBuilder("SELECT id FROM logs").
Where("channel_id = scope.channel_id").
Where("channel_id > 0")
applyStatePredicate(subQB)
subQB.ApplyFilter(filter)
subQuery, subArgs := subQB.BuildWithSuffix("ORDER BY time DESC, id DESC LIMIT 1")
query := fmt.Sprintf(`
SELECT scope.channel_id, %s
FROM (%s) scope
JOIN logs l ON l.id = (%s)`, strings.Join(selectColumns, ", "), scopeSQL, subQuery)
args := make([]any, 0, len(scopeArgs)+len(subArgs))
args = append(args, scopeArgs...)
args = append(args, subArgs...)
return query, args
}
func buildLatestEntryLogQuery(entryIndexes map[statsRequestKey]int, filter *model.LogFilter, selectColumns []string, applyStatePredicate func(*QueryBuilder)) (string, []any) {
scopeSQL, scopeArgs := buildEntryScope(entryIndexes)
subQB := NewQueryBuilder("SELECT id FROM logs").
Where("channel_id = scope.channel_id").
Where("COALESCE(model, '') = scope.model").
Where("channel_id > 0")
applyStatePredicate(subQB)
subQB.ApplyFilter(filter)
subQuery, subArgs := subQB.BuildWithSuffix("ORDER BY time DESC, id DESC LIMIT 1")
query := fmt.Sprintf(`
SELECT scope.channel_id, scope.model, %s
FROM (%s) scope
JOIN logs l ON l.id = (%s)`, strings.Join(selectColumns, ", "), scopeSQL, subQuery)
args := make([]any, 0, len(scopeArgs)+len(subArgs))
args = append(args, scopeArgs...)
args = append(args, subArgs...)
return query, args
}
func buildChannelScope(entryIndexesByChannel map[int][]int) (string, []any) {
channelIDs := make([]int, 0, len(entryIndexesByChannel))
for channelID := range entryIndexesByChannel {
channelIDs = append(channelIDs, channelID)
}
sort.Ints(channelIDs)
selects := make([]string, 0, len(channelIDs))
args := make([]any, 0, len(channelIDs))
for i, channelID := range channelIDs {
if i == 0 {
selects = append(selects, "SELECT ? AS channel_id")
} else {
selects = append(selects, "SELECT ?")
}
args = append(args, channelID)
}
return strings.Join(selects, " UNION ALL "), args
}
func buildEntryScope(entryIndexes map[statsRequestKey]int) (string, []any) {
keys := make([]statsRequestKey, 0, len(entryIndexes))
for key := range entryIndexes {
keys = append(keys, key)
}
sort.Slice(keys, func(i, j int) bool {
if keys[i].channelID != keys[j].channelID {
return keys[i].channelID < keys[j].channelID
}
return keys[i].model < keys[j].model
})
selects := make([]string, 0, len(keys))
args := make([]any, 0, len(entryIndexes)*2)
for i, key := range keys {
if i == 0 {
selects = append(selects, "SELECT ? AS channel_id, ? AS model")
} else {
selects = append(selects, "SELECT ?, ?")
}
args = append(args, key.channelID, key.model)
}
return strings.Join(selects, " UNION ALL "), args
}
// GetStatsLite 轻量版统计查询,跳过RPM计算和渠道名称填充
// 适用于 /public/summary 等只需要基础聚合数据的场景
func (s *SQLStore) GetStatsLite(ctx context.Context, startTime, endTime time.Time, filter *model.LogFilter) ([]model.StatsEntry, error) {
stats, _, err := s.executeStatsQuery(ctx, startTime, endTime, filter, false)
return stats, err
}
// GetRPMStats 获取RPM/QPS统计数据(峰值、平均、最近一分钟)
// isToday参数控制是否计算最近一分钟数据(仅本日有意义)
// [FIX] 2025-12: 排除499(客户端取消)避免污染RPM统计
func (s *SQLStore) GetRPMStats(ctx context.Context, startTime, endTime time.Time, filter *model.LogFilter, isToday bool) (*model.RPMStats, error) {
stats := &model.RPMStats{}
startBucket := startTime.UnixMilli() / minuteMs
endBucket := endTime.UnixMilli() / minuteMs
// 合并峰值RPM和总数查询为单次数据库往返
// 子查询按分钟桶分组统计,外层查询同时计算峰值和总数
// 排除499:客户端取消不应计入RPM
combinedBaseQuery := `
SELECT COALESCE(MAX(cnt), 0) as peak_rpm, COALESCE(SUM(cnt), 0) as total_count FROM (
SELECT COUNT(*) as cnt
FROM logs`
combinedQB := NewQueryBuilder(combinedBaseQuery).
Where("minute_bucket >= ?", startBucket).
Where("minute_bucket <= ?", endBucket).
Where("channel_id > 0").
Where("status_code != 499")
// 应用渠道类型或名称过滤
_, isEmpty, err := s.applyChannelFilter(ctx, combinedQB, filter)
if err != nil {
return nil, fmt.Errorf("apply channel filter: %w", err)
}
if isEmpty {
return stats, nil
}
// 应用其余过滤器(模型/状态码等)
combinedQB.ApplyFilter(filter)
combinedQuery, combinedArgs := combinedQB.BuildWithSuffix("GROUP BY minute_bucket) t")
var peakRPM float64
var totalCount int64
if err := s.db.QueryRowContext(ctx, combinedQuery, combinedArgs...).Scan(&peakRPM, &totalCount); err != nil {
return nil, fmt.Errorf("query peak RPM and total: %w", err)
}
stats.PeakRPM = peakRPM
stats.PeakQPS = peakRPM / 60
// 计算平均RPM/QPS
durationSeconds := endTime.Sub(startTime).Seconds()
if durationSeconds < 1 {
durationSeconds = 1
}
stats.AvgRPM = float64(totalCount) * 60 / durationSeconds
stats.AvgQPS = float64(totalCount) / durationSeconds
// 计算最近一分钟(仅本日有意义)
if isToday {
now := time.Now()
recentStartBucket := now.Add(-60*time.Second).UnixMilli() / minuteMs
recentEndBucket := now.UnixMilli() / minuteMs
recentBaseQuery := `SELECT COUNT(*) FROM logs`
recentQB := NewQueryBuilder(recentBaseQuery).
Where("minute_bucket >= ?", recentStartBucket).
Where("minute_bucket <= ?", recentEndBucket).
Where("channel_id > 0").
Where("status_code != 499")
// 应用渠道过滤
_, isEmpty, err = s.applyChannelFilter(ctx, recentQB, filter)
if err != nil {
return nil, fmt.Errorf("apply channel filter for recent: %w", err)
}
if !isEmpty {
// 应用其余过滤器
recentQB.ApplyFilter(filter)
recentQuery, recentArgs := recentQB.Build()
var recentCount int64
if err := s.db.QueryRowContext(ctx, recentQuery, recentArgs...).Scan(&recentCount); err != nil {
return nil, fmt.Errorf("query recent count: %w", err)
}
stats.RecentRPM = float64(recentCount)
stats.RecentQPS = float64(recentCount) / 60
// 峰值必须 >= 最近值(滑动窗口可能比固定分钟桶更高)
if stats.RecentRPM > stats.PeakRPM {
stats.PeakRPM = stats.RecentRPM
stats.PeakQPS = stats.RecentQPS
}
}
}
return stats, nil
}
// fillStatsRPM 计算每个channel_id+model组合的RPM统计数据
// [FIX] 2025-12: 排除499(客户端取消)避免污染RPM统计
func (s *SQLStore) fillStatsRPM(ctx context.Context, stats []model.StatsEntry, startTime, endTime time.Time, filter *model.LogFilter, isToday bool) error {
startBucket := startTime.UnixMilli() / minuteMs
endBucket := endTime.UnixMilli() / minuteMs
// 计算时间跨度(秒)用于平均RPM
durationSeconds := endTime.Sub(startTime).Seconds()
if durationSeconds < 1 {
durationSeconds = 1
}
type statsKey struct {
channelID int
model string
}
peakRPMMap := make(map[statsKey]float64)
// 1) 峰值RPM(分钟桶内最大请求数)
peakBaseQuery := `
SELECT channel_id, COALESCE(model, '') AS model, MAX(cnt) AS peak_rpm
FROM (
SELECT channel_id, COALESCE(model, '') AS model, COUNT(*) AS cnt
FROM logs`
peakQB := NewQueryBuilder(peakBaseQuery).
Where("minute_bucket >= ?", startBucket).
Where("minute_bucket <= ?", endBucket).
Where("channel_id > 0").
Where("status_code != 499")
_, isEmpty, err := s.applyChannelFilter(ctx, peakQB, filter)
if err != nil {
return fmt.Errorf("apply channel filter for peak: %w", err)
}
// 仅当渠道过滤非空时才执行查询
if !isEmpty {
peakQB.ApplyFilter(filter)
peakQuery, peakArgs := peakQB.BuildWithSuffix("GROUP BY channel_id, model, minute_bucket) t GROUP BY channel_id, model")
peakRows, err := s.db.QueryContext(ctx, peakQuery, peakArgs...)
if err != nil {
return fmt.Errorf("query peak RPM: %w", err)
}
defer func() { _ = peakRows.Close() }()
for peakRows.Next() {
var channelID int
var model string
var peakRPM float64
if err := peakRows.Scan(&channelID, &model, &peakRPM); err != nil {
return fmt.Errorf("scan peak RPM: %w", err)
}
peakRPMMap[statsKey{channelID, model}] = peakRPM
}
if err := peakRows.Err(); err != nil {
return fmt.Errorf("iterate peak RPM rows: %w", err)
}
}
// 2) 最近一分钟RPM(仅本日有效)
recentRPMMap := make(map[statsKey]float64)
if isToday {
now := time.Now()
recentStartBucket := now.Add(-60*time.Second).UnixMilli() / minuteMs
recentEndBucket := now.UnixMilli() / minuteMs
recentBaseQuery := `
SELECT channel_id, COALESCE(model, '') AS model, COUNT(*) AS cnt
FROM logs`
recentQB := NewQueryBuilder(recentBaseQuery).
Where("minute_bucket >= ?", recentStartBucket).
Where("minute_bucket <= ?", recentEndBucket).
Where("channel_id > 0").
Where("status_code != 499")
_, isEmpty, err := s.applyChannelFilter(ctx, recentQB, filter)
if err != nil {
return fmt.Errorf("apply channel filter for recent: %w", err)
}
// 仅当渠道过滤非空时才执行查询
if !isEmpty {
recentQB.ApplyFilter(filter)
recentQuery, recentArgs := recentQB.BuildWithSuffix("GROUP BY channel_id, model")
recentRows, err := s.db.QueryContext(ctx, recentQuery, recentArgs...)
if err != nil {
return fmt.Errorf("query recent RPM: %w", err)
}
defer func() { _ = recentRows.Close() }()
for recentRows.Next() {
var channelID int
var model string
var cnt float64
if err := recentRows.Scan(&channelID, &model, &cnt); err != nil {
return fmt.Errorf("scan recent RPM: %w", err)
}
recentRPMMap[statsKey{channelID, model}] = cnt
}
if err := recentRows.Err(); err != nil {
return fmt.Errorf("iterate recent RPM rows: %w", err)
}
}
}
// 3) 填充到stats中
for i := range stats {
entry := &stats[i]
if entry.ChannelID == nil {
continue
}
key := statsKey{*entry.ChannelID, entry.Model}
if peakRPM, ok := peakRPMMap[key]; ok && peakRPM > 0 {
entry.PeakRPM = &peakRPM
}
if entry.Total > 0 {
avgRPM := float64(entry.Total) * 60 / durationSeconds
entry.AvgRPM = &avgRPM
}
if isToday {
if recentRPM, ok := recentRPMMap[key]; ok && recentRPM > 0 {
entry.RecentRPM = &recentRPM
if entry.PeakRPM == nil || *entry.PeakRPM < recentRPM {
entry.PeakRPM = &recentRPM
}
}
}
}
return nil
}
// GetChannelSuccessRates 获取指定时间窗口内各渠道的成功率和样本量
// 返回 map[channelID]ChannelHealthStats
func (s *SQLStore) GetChannelSuccessRates(ctx context.Context, since time.Time) (map[int64]model.ChannelHealthStats, error) {
sinceBucket := since.UnixMilli() / minuteMs
untilBucket := time.Now().UnixMilli() / minuteMs
// 成功率统计口径:
// - 只统计能反映渠道/Key质量的结果(2xx成功 + 可重试/可冷却错误)
// - 排除客户端误用造成的4xx(404/415等)和客户端取消(499),避免"坏客户端把好渠道打残"
//
// 纳入统计的状态码:
// 2xx: 成功响应
// 401/402/403: Key认证/付费/权限错误(Key级)
// 429: 限流(Key级或渠道级)
// 500/502/503/504: 服务器错误(渠道级)
// 520/521/524: Cloudflare错误(渠道级)- 520未知错误/521服务器宕机/524超时
// 597: SSE流错误(Key级,自定义状态码)
// 注:596(1308配额超限)不纳入统计,因为它不反映渠道质量
// 598: 上游首字节超时(渠道级,自定义状态码)
// 599: 流式响应不完整(渠道级,自定义状态码)
// 注:408已改为客户端错误,不计入健康度
eligible := `
(status_code >= 200 AND status_code < 300)
OR status_code IN (401, 402, 403, 405, 429, 500, 502, 503, 504, 520, 521, 524, 597, 598, 599)
`
// 使用 minute_bucket 索引优化查询
//nolint:gosec // G202: eligible 为内部定义的常量SQL片段,安全可控
query := `
SELECT
channel_id,
SUM(CASE WHEN status_code >= 200 AND status_code < 300 THEN 1 ELSE 0 END) AS success,
SUM(CASE WHEN ` + eligible + ` THEN 1 ELSE 0 END) AS total
FROM logs
WHERE minute_bucket >= ? AND minute_bucket <= ? AND channel_id > 0 AND log_source = ?
GROUP BY channel_id`
rows, err := s.db.QueryContext(ctx, query, sinceBucket, untilBucket, model.LogSourceProxy)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
result := make(map[int64]model.ChannelHealthStats)
for rows.Next() {
var channelID int64
var success, total int64
if err := rows.Scan(&channelID, &success, &total); err != nil {
return nil, err
}
if total > 0 {
result[channelID] = model.ChannelHealthStats{
SuccessRate: float64(success) / float64(total),
SampleCount: total,
}
}
}
return result, rows.Err()
}
// GetTodayChannelCosts 获取今日各渠道倍率后成本(effective)
// 语义:与 CostCache 保持一致——累加 cost * cost_multiplier,用于每日限额检查
func (s *SQLStore) GetTodayChannelCosts(ctx context.Context, todayStart time.Time) (map[int64]float64, error) {
todayStartMs := todayStart.UnixMilli()
query := `
SELECT channel_id, COALESCE(SUM(COALESCE(cost, 0.0) * COALESCE(cost_multiplier, 1)), 0) as total_cost
FROM logs
WHERE time >= ? AND channel_id > 0 AND log_source = ?
GROUP BY channel_id`
rows, err := s.db.QueryContext(ctx, query, todayStartMs, model.LogSourceProxy)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
result := make(map[int64]float64)
for rows.Next() {
var channelID int64
var totalCost float64
if err := rows.Scan(&channelID, &totalCost); err != nil {
return nil, err
}
result[channelID] = totalCost
}
return result, rows.Err()
}