feat(ops): 运维监控新增 OpenAI Token 请求统计表
- 新增管理端接口 /api/v1/admin/ops/dashboard/openai-token-stats,按模型聚合统计 gpt% 请求 - 支持 time_range=30m|1h|1d|15d|30d(默认 30d),支持 platform/group_id 过滤 - 支持分页(page/page_size)或 TopN(top_n)互斥查询 - 前端运维监控页新增统计表卡片,包含空态/错误态与分页/TopN 交互 - 补齐后端与前端测试
This commit is contained in:
@@ -58,6 +58,96 @@ func TestParseOpsDuration(t *testing.T) {
|
||||
require.False(t, ok)
|
||||
}
|
||||
|
||||
func TestParseOpsOpenAITokenStatsDuration(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want time.Duration
|
||||
ok bool
|
||||
}{
|
||||
{input: "30m", want: 30 * time.Minute, ok: true},
|
||||
{input: "1h", want: time.Hour, ok: true},
|
||||
{input: "1d", want: 24 * time.Hour, ok: true},
|
||||
{input: "15d", want: 15 * 24 * time.Hour, ok: true},
|
||||
{input: "30d", want: 30 * 24 * time.Hour, ok: true},
|
||||
{input: "7d", want: 0, ok: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got, ok := parseOpsOpenAITokenStatsDuration(tt.input)
|
||||
require.Equal(t, tt.ok, ok, "input=%s", tt.input)
|
||||
require.Equal(t, tt.want, got, "input=%s", tt.input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseOpsOpenAITokenStatsFilter_Defaults(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
|
||||
before := time.Now().UTC()
|
||||
filter, err := parseOpsOpenAITokenStatsFilter(c)
|
||||
after := time.Now().UTC()
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, filter)
|
||||
require.Equal(t, "30d", filter.TimeRange)
|
||||
require.Equal(t, 1, filter.Page)
|
||||
require.Equal(t, 20, filter.PageSize)
|
||||
require.Equal(t, 0, filter.TopN)
|
||||
require.Nil(t, filter.GroupID)
|
||||
require.Equal(t, "", filter.Platform)
|
||||
require.True(t, filter.StartTime.Before(filter.EndTime))
|
||||
require.WithinDuration(t, before.Add(-30*24*time.Hour), filter.StartTime, 2*time.Second)
|
||||
require.WithinDuration(t, after, filter.EndTime, 2*time.Second)
|
||||
}
|
||||
|
||||
func TestParseOpsOpenAITokenStatsFilter_WithTopN(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(
|
||||
http.MethodGet,
|
||||
"/?time_range=1h&platform=openai&group_id=12&top_n=50",
|
||||
nil,
|
||||
)
|
||||
|
||||
filter, err := parseOpsOpenAITokenStatsFilter(c)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "1h", filter.TimeRange)
|
||||
require.Equal(t, "openai", filter.Platform)
|
||||
require.NotNil(t, filter.GroupID)
|
||||
require.Equal(t, int64(12), *filter.GroupID)
|
||||
require.Equal(t, 50, filter.TopN)
|
||||
require.Equal(t, 0, filter.Page)
|
||||
require.Equal(t, 0, filter.PageSize)
|
||||
}
|
||||
|
||||
func TestParseOpsOpenAITokenStatsFilter_InvalidParams(t *testing.T) {
|
||||
tests := []string{
|
||||
"/?time_range=7d",
|
||||
"/?group_id=0",
|
||||
"/?group_id=abc",
|
||||
"/?top_n=0",
|
||||
"/?top_n=101",
|
||||
"/?top_n=10&page=1",
|
||||
"/?top_n=10&page_size=20",
|
||||
"/?page=0",
|
||||
"/?page_size=0",
|
||||
"/?page_size=101",
|
||||
}
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
for _, rawURL := range tests {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, rawURL, nil)
|
||||
|
||||
_, err := parseOpsOpenAITokenStatsFilter(c)
|
||||
require.Error(t, err, "url=%s", rawURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseOpsTimeRange(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -218,6 +219,115 @@ func (h *OpsHandler) GetDashboardErrorDistribution(c *gin.Context) {
|
||||
response.Success(c, data)
|
||||
}
|
||||
|
||||
// GetDashboardOpenAITokenStats returns OpenAI token efficiency stats grouped by model.
|
||||
// GET /api/v1/admin/ops/dashboard/openai-token-stats
|
||||
func (h *OpsHandler) GetDashboardOpenAITokenStats(c *gin.Context) {
|
||||
if h.opsService == nil {
|
||||
response.Error(c, http.StatusServiceUnavailable, "Ops service not available")
|
||||
return
|
||||
}
|
||||
if err := h.opsService.RequireMonitoringEnabled(c.Request.Context()); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
filter, err := parseOpsOpenAITokenStatsFilter(c)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
data, err := h.opsService.GetOpenAITokenStats(c.Request.Context(), filter)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, data)
|
||||
}
|
||||
|
||||
func parseOpsOpenAITokenStatsFilter(c *gin.Context) (*service.OpsOpenAITokenStatsFilter, error) {
|
||||
if c == nil {
|
||||
return nil, fmt.Errorf("invalid request")
|
||||
}
|
||||
|
||||
timeRange := strings.TrimSpace(c.Query("time_range"))
|
||||
if timeRange == "" {
|
||||
timeRange = "30d"
|
||||
}
|
||||
dur, ok := parseOpsOpenAITokenStatsDuration(timeRange)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid time_range")
|
||||
}
|
||||
end := time.Now().UTC()
|
||||
start := end.Add(-dur)
|
||||
|
||||
filter := &service.OpsOpenAITokenStatsFilter{
|
||||
TimeRange: timeRange,
|
||||
StartTime: start,
|
||||
EndTime: end,
|
||||
Platform: strings.TrimSpace(c.Query("platform")),
|
||||
}
|
||||
|
||||
if v := strings.TrimSpace(c.Query("group_id")); v != "" {
|
||||
id, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
return nil, fmt.Errorf("invalid group_id")
|
||||
}
|
||||
filter.GroupID = &id
|
||||
}
|
||||
|
||||
topNRaw := strings.TrimSpace(c.Query("top_n"))
|
||||
pageRaw := strings.TrimSpace(c.Query("page"))
|
||||
pageSizeRaw := strings.TrimSpace(c.Query("page_size"))
|
||||
if topNRaw != "" && (pageRaw != "" || pageSizeRaw != "") {
|
||||
return nil, fmt.Errorf("invalid query: top_n cannot be used with page/page_size")
|
||||
}
|
||||
|
||||
if topNRaw != "" {
|
||||
topN, err := strconv.Atoi(topNRaw)
|
||||
if err != nil || topN < 1 || topN > 100 {
|
||||
return nil, fmt.Errorf("invalid top_n")
|
||||
}
|
||||
filter.TopN = topN
|
||||
return filter, nil
|
||||
}
|
||||
|
||||
filter.Page = 1
|
||||
filter.PageSize = 20
|
||||
if pageRaw != "" {
|
||||
page, err := strconv.Atoi(pageRaw)
|
||||
if err != nil || page < 1 {
|
||||
return nil, fmt.Errorf("invalid page")
|
||||
}
|
||||
filter.Page = page
|
||||
}
|
||||
if pageSizeRaw != "" {
|
||||
pageSize, err := strconv.Atoi(pageSizeRaw)
|
||||
if err != nil || pageSize < 1 || pageSize > 100 {
|
||||
return nil, fmt.Errorf("invalid page_size")
|
||||
}
|
||||
filter.PageSize = pageSize
|
||||
}
|
||||
return filter, nil
|
||||
}
|
||||
|
||||
func parseOpsOpenAITokenStatsDuration(v string) (time.Duration, bool) {
|
||||
switch strings.TrimSpace(v) {
|
||||
case "30m":
|
||||
return 30 * time.Minute, true
|
||||
case "1h":
|
||||
return time.Hour, true
|
||||
case "1d":
|
||||
return 24 * time.Hour, true
|
||||
case "15d":
|
||||
return 15 * 24 * time.Hour, true
|
||||
case "30d":
|
||||
return 30 * 24 * time.Hour, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func pickThroughputBucketSeconds(window time.Duration) int {
|
||||
// Keep buckets predictable and avoid huge responses.
|
||||
switch {
|
||||
|
||||
145
backend/internal/repository/ops_repo_openai_token_stats.go
Normal file
145
backend/internal/repository/ops_repo_openai_token_stats.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
)
|
||||
|
||||
func (r *opsRepository) GetOpenAITokenStats(ctx context.Context, filter *service.OpsOpenAITokenStatsFilter) (*service.OpsOpenAITokenStatsResponse, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, fmt.Errorf("nil ops repository")
|
||||
}
|
||||
if filter == nil {
|
||||
return nil, fmt.Errorf("nil filter")
|
||||
}
|
||||
if filter.StartTime.IsZero() || filter.EndTime.IsZero() {
|
||||
return nil, fmt.Errorf("start_time/end_time required")
|
||||
}
|
||||
// 允许 start_time == end_time(结果为空),与 service 层校验口径保持一致。
|
||||
if filter.StartTime.After(filter.EndTime) {
|
||||
return nil, fmt.Errorf("start_time must be <= end_time")
|
||||
}
|
||||
|
||||
dashboardFilter := &service.OpsDashboardFilter{
|
||||
StartTime: filter.StartTime.UTC(),
|
||||
EndTime: filter.EndTime.UTC(),
|
||||
Platform: strings.TrimSpace(strings.ToLower(filter.Platform)),
|
||||
GroupID: filter.GroupID,
|
||||
}
|
||||
|
||||
join, where, baseArgs, next := buildUsageWhere(dashboardFilter, dashboardFilter.StartTime, dashboardFilter.EndTime, 1)
|
||||
where += " AND ul.model LIKE 'gpt%'"
|
||||
|
||||
baseCTE := `
|
||||
WITH stats AS (
|
||||
SELECT
|
||||
ul.model AS model,
|
||||
COUNT(*)::bigint AS request_count,
|
||||
ROUND(
|
||||
AVG(
|
||||
CASE
|
||||
WHEN ul.duration_ms > 0 AND ul.output_tokens > 0
|
||||
THEN ul.output_tokens * 1000.0 / ul.duration_ms
|
||||
END
|
||||
)::numeric,
|
||||
2
|
||||
)::float8 AS avg_tokens_per_sec,
|
||||
ROUND(AVG(ul.first_token_ms)::numeric, 2)::float8 AS avg_first_token_ms,
|
||||
COALESCE(SUM(ul.output_tokens), 0)::bigint AS total_output_tokens,
|
||||
COALESCE(ROUND(AVG(ul.duration_ms)::numeric, 0), 0)::bigint AS avg_duration_ms,
|
||||
COUNT(CASE WHEN ul.first_token_ms IS NOT NULL THEN 1 END)::bigint AS requests_with_first_token
|
||||
FROM usage_logs ul
|
||||
` + join + `
|
||||
` + where + `
|
||||
GROUP BY ul.model
|
||||
)
|
||||
`
|
||||
|
||||
countSQL := baseCTE + `SELECT COUNT(*) FROM stats`
|
||||
var total int64
|
||||
if err := r.db.QueryRowContext(ctx, countSQL, baseArgs...).Scan(&total); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
querySQL := baseCTE + `
|
||||
SELECT
|
||||
model,
|
||||
request_count,
|
||||
avg_tokens_per_sec,
|
||||
avg_first_token_ms,
|
||||
total_output_tokens,
|
||||
avg_duration_ms,
|
||||
requests_with_first_token
|
||||
FROM stats
|
||||
ORDER BY request_count DESC, model ASC`
|
||||
|
||||
args := make([]any, 0, len(baseArgs)+2)
|
||||
args = append(args, baseArgs...)
|
||||
|
||||
if filter.IsTopNMode() {
|
||||
querySQL += fmt.Sprintf("\nLIMIT $%d", next)
|
||||
args = append(args, filter.TopN)
|
||||
} else {
|
||||
offset := (filter.Page - 1) * filter.PageSize
|
||||
querySQL += fmt.Sprintf("\nLIMIT $%d OFFSET $%d", next, next+1)
|
||||
args = append(args, filter.PageSize, offset)
|
||||
}
|
||||
|
||||
rows, err := r.db.QueryContext(ctx, querySQL, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
items := make([]*service.OpsOpenAITokenStatsItem, 0, 32)
|
||||
for rows.Next() {
|
||||
item := &service.OpsOpenAITokenStatsItem{}
|
||||
var avgTPS sql.NullFloat64
|
||||
var avgFirstToken sql.NullFloat64
|
||||
if err := rows.Scan(
|
||||
&item.Model,
|
||||
&item.RequestCount,
|
||||
&avgTPS,
|
||||
&avgFirstToken,
|
||||
&item.TotalOutputTokens,
|
||||
&item.AvgDurationMs,
|
||||
&item.RequestsWithFirstToken,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if avgTPS.Valid {
|
||||
v := avgTPS.Float64
|
||||
item.AvgTokensPerSec = &v
|
||||
}
|
||||
if avgFirstToken.Valid {
|
||||
v := avgFirstToken.Float64
|
||||
item.AvgFirstTokenMs = &v
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp := &service.OpsOpenAITokenStatsResponse{
|
||||
TimeRange: strings.TrimSpace(filter.TimeRange),
|
||||
StartTime: dashboardFilter.StartTime,
|
||||
EndTime: dashboardFilter.EndTime,
|
||||
Platform: dashboardFilter.Platform,
|
||||
GroupID: dashboardFilter.GroupID,
|
||||
Items: items,
|
||||
Total: total,
|
||||
}
|
||||
if filter.IsTopNMode() {
|
||||
topN := filter.TopN
|
||||
resp.TopN = &topN
|
||||
} else {
|
||||
resp.Page = filter.Page
|
||||
resp.PageSize = filter.PageSize
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
156
backend/internal/repository/ops_repo_openai_token_stats_test.go
Normal file
156
backend/internal/repository/ops_repo_openai_token_stats_test.go
Normal file
@@ -0,0 +1,156 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestOpsRepositoryGetOpenAITokenStats_PaginationMode(t *testing.T) {
|
||||
db, mock := newSQLMock(t)
|
||||
repo := &opsRepository{db: db}
|
||||
|
||||
start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
end := start.Add(24 * time.Hour)
|
||||
groupID := int64(9)
|
||||
|
||||
filter := &service.OpsOpenAITokenStatsFilter{
|
||||
TimeRange: "1d",
|
||||
StartTime: start,
|
||||
EndTime: end,
|
||||
Platform: " OpenAI ",
|
||||
GroupID: &groupID,
|
||||
Page: 2,
|
||||
PageSize: 10,
|
||||
}
|
||||
|
||||
mock.ExpectQuery(`SELECT COUNT\(\*\) FROM stats`).
|
||||
WithArgs(start, end, groupID, "openai").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(int64(3)))
|
||||
|
||||
rows := sqlmock.NewRows([]string{
|
||||
"model",
|
||||
"request_count",
|
||||
"avg_tokens_per_sec",
|
||||
"avg_first_token_ms",
|
||||
"total_output_tokens",
|
||||
"avg_duration_ms",
|
||||
"requests_with_first_token",
|
||||
}).
|
||||
AddRow("gpt-4o-mini", int64(20), 21.56, 120.34, int64(3000), int64(850), int64(18)).
|
||||
AddRow("gpt-4.1", int64(20), 10.2, 240.0, int64(2500), int64(900), int64(20))
|
||||
|
||||
mock.ExpectQuery(`ORDER BY request_count DESC, model ASC\s+LIMIT \$5 OFFSET \$6`).
|
||||
WithArgs(start, end, groupID, "openai", 10, 10).
|
||||
WillReturnRows(rows)
|
||||
|
||||
resp, err := repo.GetOpenAITokenStats(context.Background(), filter)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
require.Equal(t, int64(3), resp.Total)
|
||||
require.Equal(t, 2, resp.Page)
|
||||
require.Equal(t, 10, resp.PageSize)
|
||||
require.Nil(t, resp.TopN)
|
||||
require.Equal(t, "openai", resp.Platform)
|
||||
require.NotNil(t, resp.GroupID)
|
||||
require.Equal(t, groupID, *resp.GroupID)
|
||||
require.Len(t, resp.Items, 2)
|
||||
require.Equal(t, "gpt-4o-mini", resp.Items[0].Model)
|
||||
require.NotNil(t, resp.Items[0].AvgTokensPerSec)
|
||||
require.InDelta(t, 21.56, *resp.Items[0].AvgTokensPerSec, 0.0001)
|
||||
require.NotNil(t, resp.Items[0].AvgFirstTokenMs)
|
||||
require.InDelta(t, 120.34, *resp.Items[0].AvgFirstTokenMs, 0.0001)
|
||||
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
}
|
||||
|
||||
func TestOpsRepositoryGetOpenAITokenStats_TopNMode(t *testing.T) {
|
||||
db, mock := newSQLMock(t)
|
||||
repo := &opsRepository{db: db}
|
||||
|
||||
start := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC)
|
||||
end := start.Add(time.Hour)
|
||||
filter := &service.OpsOpenAITokenStatsFilter{
|
||||
TimeRange: "1h",
|
||||
StartTime: start,
|
||||
EndTime: end,
|
||||
TopN: 5,
|
||||
}
|
||||
|
||||
mock.ExpectQuery(`SELECT COUNT\(\*\) FROM stats`).
|
||||
WithArgs(start, end).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(int64(1)))
|
||||
|
||||
rows := sqlmock.NewRows([]string{
|
||||
"model",
|
||||
"request_count",
|
||||
"avg_tokens_per_sec",
|
||||
"avg_first_token_ms",
|
||||
"total_output_tokens",
|
||||
"avg_duration_ms",
|
||||
"requests_with_first_token",
|
||||
}).
|
||||
AddRow("gpt-4o", int64(5), nil, nil, int64(0), int64(0), int64(0))
|
||||
|
||||
mock.ExpectQuery(`ORDER BY request_count DESC, model ASC\s+LIMIT \$3`).
|
||||
WithArgs(start, end, 5).
|
||||
WillReturnRows(rows)
|
||||
|
||||
resp, err := repo.GetOpenAITokenStats(context.Background(), filter)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
require.NotNil(t, resp.TopN)
|
||||
require.Equal(t, 5, *resp.TopN)
|
||||
require.Equal(t, 0, resp.Page)
|
||||
require.Equal(t, 0, resp.PageSize)
|
||||
require.Len(t, resp.Items, 1)
|
||||
require.Nil(t, resp.Items[0].AvgTokensPerSec)
|
||||
require.Nil(t, resp.Items[0].AvgFirstTokenMs)
|
||||
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
}
|
||||
|
||||
func TestOpsRepositoryGetOpenAITokenStats_EmptyResult(t *testing.T) {
|
||||
db, mock := newSQLMock(t)
|
||||
repo := &opsRepository{db: db}
|
||||
|
||||
start := time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC)
|
||||
end := start.Add(30 * time.Minute)
|
||||
filter := &service.OpsOpenAITokenStatsFilter{
|
||||
TimeRange: "30m",
|
||||
StartTime: start,
|
||||
EndTime: end,
|
||||
Page: 1,
|
||||
PageSize: 20,
|
||||
}
|
||||
|
||||
mock.ExpectQuery(`SELECT COUNT\(\*\) FROM stats`).
|
||||
WithArgs(start, end).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(int64(0)))
|
||||
|
||||
mock.ExpectQuery(`ORDER BY request_count DESC, model ASC\s+LIMIT \$3 OFFSET \$4`).
|
||||
WithArgs(start, end, 20, 0).
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"model",
|
||||
"request_count",
|
||||
"avg_tokens_per_sec",
|
||||
"avg_first_token_ms",
|
||||
"total_output_tokens",
|
||||
"avg_duration_ms",
|
||||
"requests_with_first_token",
|
||||
}))
|
||||
|
||||
resp, err := repo.GetOpenAITokenStats(context.Background(), filter)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
require.Equal(t, int64(0), resp.Total)
|
||||
require.Len(t, resp.Items, 0)
|
||||
require.Equal(t, 1, resp.Page)
|
||||
require.Equal(t, 20, resp.PageSize)
|
||||
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
}
|
||||
@@ -150,6 +150,7 @@ func registerOpsRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
||||
ops.GET("/dashboard/latency-histogram", h.Admin.Ops.GetDashboardLatencyHistogram)
|
||||
ops.GET("/dashboard/error-trend", h.Admin.Ops.GetDashboardErrorTrend)
|
||||
ops.GET("/dashboard/error-distribution", h.Admin.Ops.GetDashboardErrorDistribution)
|
||||
ops.GET("/dashboard/openai-token-stats", h.Admin.Ops.GetDashboardOpenAITokenStats)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
55
backend/internal/service/ops_openai_token_stats.go
Normal file
55
backend/internal/service/ops_openai_token_stats.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
)
|
||||
|
||||
func (s *OpsService) GetOpenAITokenStats(ctx context.Context, filter *OpsOpenAITokenStatsFilter) (*OpsOpenAITokenStatsResponse, error) {
|
||||
if err := s.RequireMonitoringEnabled(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.opsRepo == nil {
|
||||
return nil, infraerrors.ServiceUnavailable("OPS_REPO_UNAVAILABLE", "Ops repository not available")
|
||||
}
|
||||
if filter == nil {
|
||||
return nil, infraerrors.BadRequest("OPS_FILTER_REQUIRED", "filter is required")
|
||||
}
|
||||
if filter.StartTime.IsZero() || filter.EndTime.IsZero() {
|
||||
return nil, infraerrors.BadRequest("OPS_TIME_RANGE_REQUIRED", "start_time/end_time are required")
|
||||
}
|
||||
if filter.StartTime.After(filter.EndTime) {
|
||||
return nil, infraerrors.BadRequest("OPS_TIME_RANGE_INVALID", "start_time must be <= end_time")
|
||||
}
|
||||
|
||||
if filter.GroupID != nil && *filter.GroupID <= 0 {
|
||||
return nil, infraerrors.BadRequest("OPS_GROUP_ID_INVALID", "group_id must be > 0")
|
||||
}
|
||||
|
||||
// top_n cannot be mixed with page/page_size params.
|
||||
if filter.TopN > 0 && (filter.Page > 0 || filter.PageSize > 0) {
|
||||
return nil, infraerrors.BadRequest("OPS_PAGINATION_CONFLICT", "top_n cannot be used with page/page_size")
|
||||
}
|
||||
|
||||
if filter.TopN > 0 {
|
||||
if filter.TopN < 1 || filter.TopN > 100 {
|
||||
return nil, infraerrors.BadRequest("OPS_TOPN_INVALID", "top_n must be between 1 and 100")
|
||||
}
|
||||
} else {
|
||||
if filter.Page <= 0 {
|
||||
filter.Page = 1
|
||||
}
|
||||
if filter.PageSize <= 0 {
|
||||
filter.PageSize = 20
|
||||
}
|
||||
if filter.Page < 1 {
|
||||
return nil, infraerrors.BadRequest("OPS_PAGE_INVALID", "page must be >= 1")
|
||||
}
|
||||
if filter.PageSize < 1 || filter.PageSize > 100 {
|
||||
return nil, infraerrors.BadRequest("OPS_PAGE_SIZE_INVALID", "page_size must be between 1 and 100")
|
||||
}
|
||||
}
|
||||
|
||||
return s.opsRepo.GetOpenAITokenStats(ctx, filter)
|
||||
}
|
||||
54
backend/internal/service/ops_openai_token_stats_models.go
Normal file
54
backend/internal/service/ops_openai_token_stats_models.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package service
|
||||
|
||||
import "time"
|
||||
|
||||
type OpsOpenAITokenStatsFilter struct {
|
||||
TimeRange string
|
||||
StartTime time.Time
|
||||
EndTime time.Time
|
||||
|
||||
Platform string
|
||||
GroupID *int64
|
||||
|
||||
// Pagination mode (default): page/page_size
|
||||
Page int
|
||||
PageSize int
|
||||
|
||||
// TopN mode: top_n
|
||||
TopN int
|
||||
}
|
||||
|
||||
func (f *OpsOpenAITokenStatsFilter) IsTopNMode() bool {
|
||||
return f != nil && f.TopN > 0
|
||||
}
|
||||
|
||||
type OpsOpenAITokenStatsItem struct {
|
||||
Model string `json:"model"`
|
||||
RequestCount int64 `json:"request_count"`
|
||||
AvgTokensPerSec *float64 `json:"avg_tokens_per_sec"`
|
||||
AvgFirstTokenMs *float64 `json:"avg_first_token_ms"`
|
||||
TotalOutputTokens int64 `json:"total_output_tokens"`
|
||||
AvgDurationMs int64 `json:"avg_duration_ms"`
|
||||
RequestsWithFirstToken int64 `json:"requests_with_first_token"`
|
||||
}
|
||||
|
||||
type OpsOpenAITokenStatsResponse struct {
|
||||
TimeRange string `json:"time_range"`
|
||||
StartTime time.Time `json:"start_time"`
|
||||
EndTime time.Time `json:"end_time"`
|
||||
|
||||
Platform string `json:"platform,omitempty"`
|
||||
GroupID *int64 `json:"group_id,omitempty"`
|
||||
|
||||
Items []*OpsOpenAITokenStatsItem `json:"items"`
|
||||
|
||||
// Total model rows before pagination/topN trimming.
|
||||
Total int64 `json:"total"`
|
||||
|
||||
// Pagination mode metadata.
|
||||
Page int `json:"page,omitempty"`
|
||||
PageSize int `json:"page_size,omitempty"`
|
||||
|
||||
// TopN mode metadata.
|
||||
TopN *int `json:"top_n,omitempty"`
|
||||
}
|
||||
162
backend/internal/service/ops_openai_token_stats_test.go
Normal file
162
backend/internal/service/ops_openai_token_stats_test.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type openAITokenStatsRepoStub struct {
|
||||
OpsRepository
|
||||
resp *OpsOpenAITokenStatsResponse
|
||||
err error
|
||||
captured *OpsOpenAITokenStatsFilter
|
||||
}
|
||||
|
||||
func (s *openAITokenStatsRepoStub) GetOpenAITokenStats(ctx context.Context, filter *OpsOpenAITokenStatsFilter) (*OpsOpenAITokenStatsResponse, error) {
|
||||
s.captured = filter
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
if s.resp != nil {
|
||||
return s.resp, nil
|
||||
}
|
||||
return &OpsOpenAITokenStatsResponse{}, nil
|
||||
}
|
||||
|
||||
func TestOpsServiceGetOpenAITokenStats_Validation(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
filter *OpsOpenAITokenStatsFilter
|
||||
wantCode int
|
||||
wantReason string
|
||||
}{
|
||||
{
|
||||
name: "filter 不能为空",
|
||||
filter: nil,
|
||||
wantCode: 400,
|
||||
wantReason: "OPS_FILTER_REQUIRED",
|
||||
},
|
||||
{
|
||||
name: "start_time/end_time 必填",
|
||||
filter: &OpsOpenAITokenStatsFilter{
|
||||
StartTime: time.Time{},
|
||||
EndTime: now,
|
||||
},
|
||||
wantCode: 400,
|
||||
wantReason: "OPS_TIME_RANGE_REQUIRED",
|
||||
},
|
||||
{
|
||||
name: "start_time 不能晚于 end_time",
|
||||
filter: &OpsOpenAITokenStatsFilter{
|
||||
StartTime: now,
|
||||
EndTime: now.Add(-1 * time.Minute),
|
||||
},
|
||||
wantCode: 400,
|
||||
wantReason: "OPS_TIME_RANGE_INVALID",
|
||||
},
|
||||
{
|
||||
name: "group_id 必须大于 0",
|
||||
filter: &OpsOpenAITokenStatsFilter{
|
||||
StartTime: now.Add(-time.Hour),
|
||||
EndTime: now,
|
||||
GroupID: int64Ptr(0),
|
||||
},
|
||||
wantCode: 400,
|
||||
wantReason: "OPS_GROUP_ID_INVALID",
|
||||
},
|
||||
{
|
||||
name: "top_n 与分页参数互斥",
|
||||
filter: &OpsOpenAITokenStatsFilter{
|
||||
StartTime: now.Add(-time.Hour),
|
||||
EndTime: now,
|
||||
TopN: 10,
|
||||
Page: 1,
|
||||
},
|
||||
wantCode: 400,
|
||||
wantReason: "OPS_PAGINATION_CONFLICT",
|
||||
},
|
||||
{
|
||||
name: "top_n 参数越界",
|
||||
filter: &OpsOpenAITokenStatsFilter{
|
||||
StartTime: now.Add(-time.Hour),
|
||||
EndTime: now,
|
||||
TopN: 101,
|
||||
},
|
||||
wantCode: 400,
|
||||
wantReason: "OPS_TOPN_INVALID",
|
||||
},
|
||||
{
|
||||
name: "page_size 参数越界",
|
||||
filter: &OpsOpenAITokenStatsFilter{
|
||||
StartTime: now.Add(-time.Hour),
|
||||
EndTime: now,
|
||||
Page: 1,
|
||||
PageSize: 101,
|
||||
},
|
||||
wantCode: 400,
|
||||
wantReason: "OPS_PAGE_SIZE_INVALID",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
svc := &OpsService{
|
||||
opsRepo: &openAITokenStatsRepoStub{},
|
||||
}
|
||||
|
||||
_, err := svc.GetOpenAITokenStats(context.Background(), tt.filter)
|
||||
require.Error(t, err)
|
||||
require.Equal(t, tt.wantCode, infraerrors.Code(err))
|
||||
require.Equal(t, tt.wantReason, infraerrors.Reason(err))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpsServiceGetOpenAITokenStats_DefaultPagination(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
repo := &openAITokenStatsRepoStub{
|
||||
resp: &OpsOpenAITokenStatsResponse{
|
||||
Items: []*OpsOpenAITokenStatsItem{
|
||||
{Model: "gpt-4o-mini", RequestCount: 10},
|
||||
},
|
||||
Total: 1,
|
||||
},
|
||||
}
|
||||
svc := &OpsService{opsRepo: repo}
|
||||
|
||||
filter := &OpsOpenAITokenStatsFilter{
|
||||
TimeRange: "30d",
|
||||
StartTime: now.Add(-30 * 24 * time.Hour),
|
||||
EndTime: now,
|
||||
}
|
||||
resp, err := svc.GetOpenAITokenStats(context.Background(), filter)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
require.NotNil(t, repo.captured)
|
||||
require.Equal(t, 1, repo.captured.Page)
|
||||
require.Equal(t, 20, repo.captured.PageSize)
|
||||
require.Equal(t, 0, repo.captured.TopN)
|
||||
}
|
||||
|
||||
func TestOpsServiceGetOpenAITokenStats_RepoUnavailable(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
svc := &OpsService{}
|
||||
|
||||
_, err := svc.GetOpenAITokenStats(context.Background(), &OpsOpenAITokenStatsFilter{
|
||||
TimeRange: "1h",
|
||||
StartTime: now.Add(-time.Hour),
|
||||
EndTime: now,
|
||||
TopN: 10,
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, 503, infraerrors.Code(err))
|
||||
require.Equal(t, "OPS_REPO_UNAVAILABLE", infraerrors.Reason(err))
|
||||
}
|
||||
|
||||
func int64Ptr(v int64) *int64 { return &v }
|
||||
@@ -27,6 +27,7 @@ type OpsRepository interface {
|
||||
GetLatencyHistogram(ctx context.Context, filter *OpsDashboardFilter) (*OpsLatencyHistogramResponse, error)
|
||||
GetErrorTrend(ctx context.Context, filter *OpsDashboardFilter, bucketSeconds int) (*OpsErrorTrendResponse, error)
|
||||
GetErrorDistribution(ctx context.Context, filter *OpsDashboardFilter) (*OpsErrorDistributionResponse, error)
|
||||
GetOpenAITokenStats(ctx context.Context, filter *OpsOpenAITokenStatsFilter) (*OpsOpenAITokenStatsResponse, error)
|
||||
|
||||
InsertSystemMetrics(ctx context.Context, input *OpsInsertSystemMetricsInput) error
|
||||
GetLatestSystemMetrics(ctx context.Context, windowMinutes int) (*OpsSystemMetricsSnapshot, error)
|
||||
|
||||
Reference in New Issue
Block a user