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:
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 }
|
||||
Reference in New Issue
Block a user