mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 16:37:52 +08:00
- 在 /admin/usage 新增按用户聚合的 Token 排行表(输入/输出/缓存/总 Token、请求数、费用) - 支持按列排序、邮箱搜索、Top N (20/50/100/200),点击行下钻到该用户 - 复用现有筛选(用户/时间/模型/分组等)与既有 KPI 卡片、逐条日志 - 后端对 /admin/dashboard/user-breakdown 做加法扩展(向后兼容): - UserBreakdownItem 增加 input_tokens/output_tokens/cache_tokens - 新增 sort_by(白名单排序,防注入,缺省仍按 actual_cost) - 新增 sort_by 转发单元测试;更新 UsageView.spec 稳定桩 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
244 lines
7.8 KiB
Go
244 lines
7.8 KiB
Go
package admin
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
|
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// --- mock repo ---
|
|
|
|
type userBreakdownRepoCapture struct {
|
|
service.UsageLogRepository
|
|
capturedDim usagestats.UserBreakdownDimension
|
|
capturedLimit int
|
|
result []usagestats.UserBreakdownItem
|
|
}
|
|
|
|
func (r *userBreakdownRepoCapture) GetUserBreakdownStats(
|
|
_ context.Context, _, _ time.Time,
|
|
dim usagestats.UserBreakdownDimension, limit int,
|
|
) ([]usagestats.UserBreakdownItem, error) {
|
|
r.capturedDim = dim
|
|
r.capturedLimit = limit
|
|
if r.result != nil {
|
|
return r.result, nil
|
|
}
|
|
return []usagestats.UserBreakdownItem{}, nil
|
|
}
|
|
|
|
func newUserBreakdownRouter(repo *userBreakdownRepoCapture) *gin.Engine {
|
|
gin.SetMode(gin.TestMode)
|
|
svc := service.NewDashboardService(repo, nil, nil, nil)
|
|
h := NewDashboardHandler(svc, nil)
|
|
router := gin.New()
|
|
router.GET("/admin/dashboard/user-breakdown", h.GetUserBreakdown)
|
|
return router
|
|
}
|
|
|
|
// --- tests ---
|
|
|
|
func TestGetUserBreakdown_GroupIDFilter(t *testing.T) {
|
|
repo := &userBreakdownRepoCapture{}
|
|
router := newUserBreakdownRouter(repo)
|
|
|
|
req := httptest.NewRequest(http.MethodGet,
|
|
"/admin/dashboard/user-breakdown?start_date=2026-03-01&end_date=2026-03-16&group_id=42", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
require.Equal(t, int64(42), repo.capturedDim.GroupID)
|
|
require.Empty(t, repo.capturedDim.Model)
|
|
require.Empty(t, repo.capturedDim.Endpoint)
|
|
require.Equal(t, 50, repo.capturedLimit) // default limit
|
|
require.Empty(t, repo.capturedDim.SortBy) // no sort_by => empty (repo falls back to default)
|
|
}
|
|
|
|
func TestGetUserBreakdown_SortBy(t *testing.T) {
|
|
repo := &userBreakdownRepoCapture{}
|
|
router := newUserBreakdownRouter(repo)
|
|
|
|
req := httptest.NewRequest(http.MethodGet,
|
|
"/admin/dashboard/user-breakdown?start_date=2026-03-01&end_date=2026-03-16&sort_by=total_tokens", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
require.Equal(t, "total_tokens", repo.capturedDim.SortBy)
|
|
}
|
|
|
|
func TestGetUserBreakdown_ModelFilter(t *testing.T) {
|
|
repo := &userBreakdownRepoCapture{}
|
|
router := newUserBreakdownRouter(repo)
|
|
|
|
req := httptest.NewRequest(http.MethodGet,
|
|
"/admin/dashboard/user-breakdown?start_date=2026-03-01&end_date=2026-03-16&model=claude-opus-4-6", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
require.Equal(t, "claude-opus-4-6", repo.capturedDim.Model)
|
|
require.Equal(t, usagestats.ModelSourceRequested, repo.capturedDim.ModelType)
|
|
require.Equal(t, int64(0), repo.capturedDim.GroupID)
|
|
}
|
|
|
|
func TestGetUserBreakdown_ModelSourceFilter(t *testing.T) {
|
|
repo := &userBreakdownRepoCapture{}
|
|
router := newUserBreakdownRouter(repo)
|
|
|
|
req := httptest.NewRequest(http.MethodGet,
|
|
"/admin/dashboard/user-breakdown?start_date=2026-03-01&end_date=2026-03-16&model=claude-opus-4-6&model_source=upstream", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
require.Equal(t, usagestats.ModelSourceUpstream, repo.capturedDim.ModelType)
|
|
}
|
|
|
|
func TestGetUserBreakdown_InvalidModelSource(t *testing.T) {
|
|
repo := &userBreakdownRepoCapture{}
|
|
router := newUserBreakdownRouter(repo)
|
|
|
|
req := httptest.NewRequest(http.MethodGet,
|
|
"/admin/dashboard/user-breakdown?start_date=2026-03-01&end_date=2026-03-16&model_source=foobar", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusBadRequest, w.Code)
|
|
}
|
|
|
|
func TestGetUserBreakdown_EndpointFilter(t *testing.T) {
|
|
repo := &userBreakdownRepoCapture{}
|
|
router := newUserBreakdownRouter(repo)
|
|
|
|
req := httptest.NewRequest(http.MethodGet,
|
|
"/admin/dashboard/user-breakdown?start_date=2026-03-01&end_date=2026-03-16&endpoint=/v1/messages&endpoint_type=upstream", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
require.Equal(t, "/v1/messages", repo.capturedDim.Endpoint)
|
|
require.Equal(t, "upstream", repo.capturedDim.EndpointType)
|
|
}
|
|
|
|
func TestGetUserBreakdown_DefaultEndpointType(t *testing.T) {
|
|
repo := &userBreakdownRepoCapture{}
|
|
router := newUserBreakdownRouter(repo)
|
|
|
|
req := httptest.NewRequest(http.MethodGet,
|
|
"/admin/dashboard/user-breakdown?start_date=2026-03-01&end_date=2026-03-16&endpoint=/chat", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
require.Equal(t, "inbound", repo.capturedDim.EndpointType)
|
|
}
|
|
|
|
func TestGetUserBreakdown_CustomLimit(t *testing.T) {
|
|
repo := &userBreakdownRepoCapture{}
|
|
router := newUserBreakdownRouter(repo)
|
|
|
|
req := httptest.NewRequest(http.MethodGet,
|
|
"/admin/dashboard/user-breakdown?start_date=2026-03-01&end_date=2026-03-16&model=test&limit=100", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
require.Equal(t, 100, repo.capturedLimit)
|
|
}
|
|
|
|
func TestGetUserBreakdown_LimitClamped(t *testing.T) {
|
|
repo := &userBreakdownRepoCapture{}
|
|
router := newUserBreakdownRouter(repo)
|
|
|
|
// limit > 200 should fall back to default 50
|
|
req := httptest.NewRequest(http.MethodGet,
|
|
"/admin/dashboard/user-breakdown?start_date=2026-03-01&end_date=2026-03-16&model=test&limit=999", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
require.Equal(t, 50, repo.capturedLimit)
|
|
}
|
|
|
|
func TestGetUserBreakdown_ResponseFormat(t *testing.T) {
|
|
repo := &userBreakdownRepoCapture{
|
|
result: []usagestats.UserBreakdownItem{
|
|
{UserID: 1, Email: "alice@test.com", Requests: 100, TotalTokens: 50000, Cost: 1.5, ActualCost: 1.2},
|
|
{UserID: 2, Email: "bob@test.com", Requests: 50, TotalTokens: 25000, Cost: 0.8, ActualCost: 0.6},
|
|
},
|
|
}
|
|
router := newUserBreakdownRouter(repo)
|
|
|
|
req := httptest.NewRequest(http.MethodGet,
|
|
"/admin/dashboard/user-breakdown?start_date=2026-03-01&end_date=2026-03-16&group_id=1", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
|
|
var resp struct {
|
|
Code int `json:"code"`
|
|
Data struct {
|
|
Users []usagestats.UserBreakdownItem `json:"users"`
|
|
StartDate string `json:"start_date"`
|
|
EndDate string `json:"end_date"`
|
|
} `json:"data"`
|
|
}
|
|
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
|
require.NoError(t, err)
|
|
require.Equal(t, 0, resp.Code)
|
|
require.Len(t, resp.Data.Users, 2)
|
|
require.Equal(t, int64(1), resp.Data.Users[0].UserID)
|
|
require.Equal(t, "alice@test.com", resp.Data.Users[0].Email)
|
|
require.Equal(t, int64(100), resp.Data.Users[0].Requests)
|
|
require.InDelta(t, 1.2, resp.Data.Users[0].ActualCost, 0.001)
|
|
require.Equal(t, "2026-03-01", resp.Data.StartDate)
|
|
require.Equal(t, "2026-03-16", resp.Data.EndDate)
|
|
}
|
|
|
|
func TestGetUserBreakdown_EmptyResult(t *testing.T) {
|
|
repo := &userBreakdownRepoCapture{}
|
|
router := newUserBreakdownRouter(repo)
|
|
|
|
req := httptest.NewRequest(http.MethodGet,
|
|
"/admin/dashboard/user-breakdown?start_date=2026-03-01&end_date=2026-03-16&group_id=999", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
|
|
var resp struct {
|
|
Data struct {
|
|
Users []usagestats.UserBreakdownItem `json:"users"`
|
|
} `json:"data"`
|
|
}
|
|
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
|
require.NoError(t, err)
|
|
require.Empty(t, resp.Data.Users)
|
|
}
|
|
|
|
func TestGetUserBreakdown_NoFilters(t *testing.T) {
|
|
repo := &userBreakdownRepoCapture{}
|
|
router := newUserBreakdownRouter(repo)
|
|
|
|
req := httptest.NewRequest(http.MethodGet,
|
|
"/admin/dashboard/user-breakdown?start_date=2026-03-01&end_date=2026-03-16", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
require.Equal(t, int64(0), repo.capturedDim.GroupID)
|
|
require.Empty(t, repo.capturedDim.Model)
|
|
require.Empty(t, repo.capturedDim.Endpoint)
|
|
}
|