feat(usage): 用户 API Key 用量页支持按日明细
This commit is contained in:
parent
7ec61eb2f5
commit
90b2b2a757
@ -1038,9 +1038,15 @@ func (h *GatewayHandler) Usage(c *gin.Context) {
|
|||||||
|
|
||||||
// 解析可选的日期范围参数(用于 model_stats 查询)
|
// 解析可选的日期范围参数(用于 model_stats 查询)
|
||||||
startTime, endTime := h.parseUsageDateRange(c)
|
startTime, endTime := h.parseUsageDateRange(c)
|
||||||
|
days, ok := parseAPIKeyDailyUsageDays(c.DefaultQuery("days", ""))
|
||||||
|
if !ok {
|
||||||
|
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Invalid days, allowed range is 1-90")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Best-effort: 获取用量统计(按当前 API Key 过滤),失败不影响基础响应
|
// Best-effort: 获取用量统计(按当前 API Key 过滤),失败不影响基础响应
|
||||||
usageData := h.buildUsageData(ctx, apiKey.ID)
|
usageData := h.buildUsageData(ctx, apiKey.ID)
|
||||||
|
dailyUsage := h.buildAPIKeyDailyUsage(c, subject.UserID, apiKey.ID, days)
|
||||||
|
|
||||||
// Best-effort: 获取模型统计
|
// Best-effort: 获取模型统计
|
||||||
var modelStats any
|
var modelStats any
|
||||||
@ -1054,11 +1060,11 @@ func (h *GatewayHandler) Usage(c *gin.Context) {
|
|||||||
isQuotaLimited := apiKey.Quota > 0 || apiKey.HasRateLimits()
|
isQuotaLimited := apiKey.Quota > 0 || apiKey.HasRateLimits()
|
||||||
|
|
||||||
if isQuotaLimited {
|
if isQuotaLimited {
|
||||||
h.usageQuotaLimited(c, ctx, apiKey, usageData, modelStats)
|
h.usageQuotaLimited(c, ctx, apiKey, usageData, dailyUsage, modelStats)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
h.usageUnrestricted(c, ctx, apiKey, subject, usageData, modelStats)
|
h.usageUnrestricted(c, ctx, apiKey, subject, usageData, dailyUsage, modelStats)
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseUsageDateRange 解析 start_date / end_date query params,默认返回近 30 天范围
|
// parseUsageDateRange 解析 start_date / end_date query params,默认返回近 30 天范围
|
||||||
@ -1116,8 +1122,20 @@ func (h *GatewayHandler) buildUsageData(ctx context.Context, apiKeyID int64) gin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *GatewayHandler) buildAPIKeyDailyUsage(c *gin.Context, userID, apiKeyID int64, days int) any {
|
||||||
|
if h.usageService == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
startTime, endTime := apiKeyDailyUsageRange(days, c.Query("timezone"))
|
||||||
|
stats, err := h.usageService.GetAPIKeyDailyUsage(c.Request.Context(), userID, apiKeyID, startTime, endTime)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
// usageQuotaLimited 处理 quota_limited 模式的响应
|
// usageQuotaLimited 处理 quota_limited 模式的响应
|
||||||
func (h *GatewayHandler) usageQuotaLimited(c *gin.Context, ctx context.Context, apiKey *service.APIKey, usageData gin.H, modelStats any) {
|
func (h *GatewayHandler) usageQuotaLimited(c *gin.Context, ctx context.Context, apiKey *service.APIKey, usageData gin.H, dailyUsage any, modelStats any) {
|
||||||
resp := gin.H{
|
resp := gin.H{
|
||||||
"mode": "quota_limited",
|
"mode": "quota_limited",
|
||||||
"isValid": apiKey.Status == service.StatusAPIKeyActive || apiKey.Status == service.StatusAPIKeyQuotaExhausted || apiKey.Status == service.StatusAPIKeyExpired,
|
"isValid": apiKey.Status == service.StatusAPIKeyActive || apiKey.Status == service.StatusAPIKeyQuotaExhausted || apiKey.Status == service.StatusAPIKeyExpired,
|
||||||
@ -1199,6 +1217,9 @@ func (h *GatewayHandler) usageQuotaLimited(c *gin.Context, ctx context.Context,
|
|||||||
if usageData != nil {
|
if usageData != nil {
|
||||||
resp["usage"] = usageData
|
resp["usage"] = usageData
|
||||||
}
|
}
|
||||||
|
if dailyUsage != nil {
|
||||||
|
resp["daily_usage"] = dailyUsage
|
||||||
|
}
|
||||||
if modelStats != nil {
|
if modelStats != nil {
|
||||||
resp["model_stats"] = modelStats
|
resp["model_stats"] = modelStats
|
||||||
}
|
}
|
||||||
@ -1207,7 +1228,7 @@ func (h *GatewayHandler) usageQuotaLimited(c *gin.Context, ctx context.Context,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// usageUnrestricted 处理 unrestricted 模式的响应(向后兼容)
|
// usageUnrestricted 处理 unrestricted 模式的响应(向后兼容)
|
||||||
func (h *GatewayHandler) usageUnrestricted(c *gin.Context, ctx context.Context, apiKey *service.APIKey, subject middleware2.AuthSubject, usageData gin.H, modelStats any) {
|
func (h *GatewayHandler) usageUnrestricted(c *gin.Context, ctx context.Context, apiKey *service.APIKey, subject middleware2.AuthSubject, usageData gin.H, dailyUsage any, modelStats any) {
|
||||||
// 订阅模式
|
// 订阅模式
|
||||||
if apiKey.Group != nil && apiKey.Group.IsSubscriptionType() {
|
if apiKey.Group != nil && apiKey.Group.IsSubscriptionType() {
|
||||||
resp := gin.H{
|
resp := gin.H{
|
||||||
@ -1236,6 +1257,9 @@ func (h *GatewayHandler) usageUnrestricted(c *gin.Context, ctx context.Context,
|
|||||||
if usageData != nil {
|
if usageData != nil {
|
||||||
resp["usage"] = usageData
|
resp["usage"] = usageData
|
||||||
}
|
}
|
||||||
|
if dailyUsage != nil {
|
||||||
|
resp["daily_usage"] = dailyUsage
|
||||||
|
}
|
||||||
if modelStats != nil {
|
if modelStats != nil {
|
||||||
resp["model_stats"] = modelStats
|
resp["model_stats"] = modelStats
|
||||||
}
|
}
|
||||||
@ -1261,6 +1285,9 @@ func (h *GatewayHandler) usageUnrestricted(c *gin.Context, ctx context.Context,
|
|||||||
if usageData != nil {
|
if usageData != nil {
|
||||||
resp["usage"] = usageData
|
resp["usage"] = usageData
|
||||||
}
|
}
|
||||||
|
if dailyUsage != nil {
|
||||||
|
resp["daily_usage"] = dailyUsage
|
||||||
|
}
|
||||||
if modelStats != nil {
|
if modelStats != nil {
|
||||||
resp["model_stats"] = modelStats
|
resp["model_stats"] = modelStats
|
||||||
}
|
}
|
||||||
|
|||||||
@ -298,6 +298,29 @@ func parseUserTimeRange(c *gin.Context) (time.Time, time.Time) {
|
|||||||
return startTime, endTime
|
return startTime, endTime
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultAPIKeyDailyUsageDays = 30
|
||||||
|
maxAPIKeyDailyUsageDays = 90
|
||||||
|
)
|
||||||
|
|
||||||
|
func parseAPIKeyDailyUsageDays(raw string) (int, bool) {
|
||||||
|
if strings.TrimSpace(raw) == "" {
|
||||||
|
return defaultAPIKeyDailyUsageDays, true
|
||||||
|
}
|
||||||
|
days, err := strconv.Atoi(raw)
|
||||||
|
if err != nil || days <= 0 || days > maxAPIKeyDailyUsageDays {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return days, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func apiKeyDailyUsageRange(days int, userTZ string) (time.Time, time.Time) {
|
||||||
|
now := timezone.NowInUserLocation(userTZ)
|
||||||
|
startTime := timezone.StartOfDayInUserLocation(now.AddDate(0, 0, -(days-1)), userTZ)
|
||||||
|
endTime := timezone.StartOfDayInUserLocation(now.AddDate(0, 0, 1), userTZ)
|
||||||
|
return startTime, endTime
|
||||||
|
}
|
||||||
|
|
||||||
// DashboardStats handles getting user dashboard statistics
|
// DashboardStats handles getting user dashboard statistics
|
||||||
// GET /api/v1/usage/dashboard/stats
|
// GET /api/v1/usage/dashboard/stats
|
||||||
func (h *UsageHandler) DashboardStats(c *gin.Context) {
|
func (h *UsageHandler) DashboardStats(c *gin.Context) {
|
||||||
@ -416,3 +439,55 @@ func (h *UsageHandler) DashboardAPIKeysUsage(c *gin.Context) {
|
|||||||
|
|
||||||
response.Success(c, gin.H{"stats": stats})
|
response.Success(c, gin.H{"stats": stats})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetMyAPIKeyDailyUsage handles getting daily usage details for the current user's API key.
|
||||||
|
// GET /api/v1/user/api-keys/:id/usage/daily?days=30
|
||||||
|
func (h *UsageHandler) GetMyAPIKeyDailyUsage(c *gin.Context) {
|
||||||
|
subject, ok := middleware2.GetAuthSubjectFromContext(c)
|
||||||
|
if !ok {
|
||||||
|
response.Unauthorized(c, "User not authenticated")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
apiKeyID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
response.BadRequest(c, "Invalid API key ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
days, ok := parseAPIKeyDailyUsageDays(c.DefaultQuery("days", ""))
|
||||||
|
if !ok {
|
||||||
|
response.BadRequest(c, "Invalid days, allowed range is 1-90")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.apiKeyService == nil {
|
||||||
|
response.InternalError(c, "API key service is not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
apiKey, err := h.apiKeyService.GetByID(c.Request.Context(), apiKeyID)
|
||||||
|
if err != nil {
|
||||||
|
response.ErrorFrom(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if apiKey.UserID != subject.UserID {
|
||||||
|
response.Forbidden(c, "Not authorized to access this API key's usage")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userTZ := c.Query("timezone")
|
||||||
|
startTime, endTime := apiKeyDailyUsageRange(days, userTZ)
|
||||||
|
items, err := h.usageService.GetAPIKeyDailyUsage(c.Request.Context(), subject.UserID, apiKeyID, startTime, endTime)
|
||||||
|
if err != nil {
|
||||||
|
response.ErrorFrom(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Success(c, gin.H{
|
||||||
|
"items": items,
|
||||||
|
"days": days,
|
||||||
|
"start_date": startTime.Format("2006-01-02"),
|
||||||
|
"end_date": endTime.AddDate(0, 0, -1).Format("2006-01-02"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
195
backend/internal/handler/usage_handler_daily_test.go
Normal file
195
backend/internal/handler/usage_handler_daily_test.go
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
|
||||||
|
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
type dailyUsageRepoStub struct {
|
||||||
|
service.UsageLogRepository
|
||||||
|
trend []usagestats.TrendDataPoint
|
||||||
|
|
||||||
|
called bool
|
||||||
|
startTime time.Time
|
||||||
|
endTime time.Time
|
||||||
|
granularity string
|
||||||
|
userID int64
|
||||||
|
apiKeyID int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *dailyUsageRepoStub) GetUsageTrendWithFilters(
|
||||||
|
ctx context.Context,
|
||||||
|
startTime, endTime time.Time,
|
||||||
|
granularity string,
|
||||||
|
userID, apiKeyID, accountID, groupID int64,
|
||||||
|
model string,
|
||||||
|
requestType *int16,
|
||||||
|
stream *bool,
|
||||||
|
billingType *int8,
|
||||||
|
) ([]usagestats.TrendDataPoint, error) {
|
||||||
|
s.called = true
|
||||||
|
s.startTime = startTime
|
||||||
|
s.endTime = endTime
|
||||||
|
s.granularity = granularity
|
||||||
|
s.userID = userID
|
||||||
|
s.apiKeyID = apiKeyID
|
||||||
|
return s.trend, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type dailyUsageAPIKeyRepoStub struct {
|
||||||
|
service.APIKeyRepository
|
||||||
|
keys map[int64]*service.APIKey
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *dailyUsageAPIKeyRepoStub) GetByID(ctx context.Context, id int64) (*service.APIKey, error) {
|
||||||
|
key, ok := s.keys[id]
|
||||||
|
if !ok {
|
||||||
|
return nil, service.ErrAPIKeyNotFound
|
||||||
|
}
|
||||||
|
clone := *key
|
||||||
|
return &clone, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDailyUsageTestRouter(usageRepo *dailyUsageRepoStub, apiKeyRepo *dailyUsageAPIKeyRepoStub, userID int64) *gin.Engine {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
usageSvc := service.NewUsageService(usageRepo, nil, nil, nil)
|
||||||
|
apiKeySvc := service.NewAPIKeyService(apiKeyRepo, nil, nil, nil, nil, nil, nil)
|
||||||
|
handler := NewUsageHandler(usageSvc, apiKeySvc)
|
||||||
|
router := gin.New()
|
||||||
|
router.Use(func(c *gin.Context) {
|
||||||
|
c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: userID})
|
||||||
|
c.Next()
|
||||||
|
})
|
||||||
|
router.GET("/user/api-keys/:id/usage/daily", handler.GetMyAPIKeyDailyUsage)
|
||||||
|
return router
|
||||||
|
}
|
||||||
|
|
||||||
|
type dailyUsageHandlerResponse struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Data struct {
|
||||||
|
Items []usagestats.APIKeyDailyUsagePoint `json:"items"`
|
||||||
|
Days int `json:"days"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetMyAPIKeyDailyUsageRejectsCrossUserAccess(t *testing.T) {
|
||||||
|
usageRepo := &dailyUsageRepoStub{}
|
||||||
|
apiKeyRepo := &dailyUsageAPIKeyRepoStub{
|
||||||
|
keys: map[int64]*service.APIKey{
|
||||||
|
7: {ID: 7, UserID: 99, Status: service.StatusAPIKeyActive},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
router := newDailyUsageTestRouter(usageRepo, apiKeyRepo, 42)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/user/api-keys/7/usage/daily?days=30", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusForbidden, rec.Code)
|
||||||
|
require.False(t, usageRepo.called)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetMyAPIKeyDailyUsageRejectsInvalidDays(t *testing.T) {
|
||||||
|
for _, path := range []string{
|
||||||
|
"/user/api-keys/7/usage/daily?days=0",
|
||||||
|
"/user/api-keys/7/usage/daily?days=91",
|
||||||
|
} {
|
||||||
|
t.Run(path, func(t *testing.T) {
|
||||||
|
usageRepo := &dailyUsageRepoStub{}
|
||||||
|
apiKeyRepo := &dailyUsageAPIKeyRepoStub{
|
||||||
|
keys: map[int64]*service.APIKey{
|
||||||
|
7: {ID: 7, UserID: 42, Status: service.StatusAPIKeyActive},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
router := newDailyUsageTestRouter(usageRepo, apiKeyRepo, 42)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusBadRequest, rec.Code)
|
||||||
|
require.False(t, usageRepo.called)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetMyAPIKeyDailyUsageReturnsEmptyData(t *testing.T) {
|
||||||
|
usageRepo := &dailyUsageRepoStub{trend: []usagestats.TrendDataPoint{}}
|
||||||
|
apiKeyRepo := &dailyUsageAPIKeyRepoStub{
|
||||||
|
keys: map[int64]*service.APIKey{
|
||||||
|
7: {ID: 7, UserID: 42, Status: service.StatusAPIKeyActive},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
router := newDailyUsageTestRouter(usageRepo, apiKeyRepo, 42)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/user/api-keys/7/usage/daily", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
var got dailyUsageHandlerResponse
|
||||||
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||||
|
require.Equal(t, 30, got.Data.Days)
|
||||||
|
require.Empty(t, got.Data.Items)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetMyAPIKeyDailyUsageAggregatesByDayForOwnedKey(t *testing.T) {
|
||||||
|
usageRepo := &dailyUsageRepoStub{
|
||||||
|
trend: []usagestats.TrendDataPoint{
|
||||||
|
{
|
||||||
|
Date: "2026-05-19",
|
||||||
|
Requests: 3,
|
||||||
|
InputTokens: 10,
|
||||||
|
OutputTokens: 20,
|
||||||
|
CacheCreationTokens: 4,
|
||||||
|
CacheReadTokens: 6,
|
||||||
|
TotalTokens: 40,
|
||||||
|
Cost: 0.5,
|
||||||
|
ActualCost: 0.4,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
apiKeyRepo := &dailyUsageAPIKeyRepoStub{
|
||||||
|
keys: map[int64]*service.APIKey{
|
||||||
|
7: {ID: 7, UserID: 42, Status: service.StatusAPIKeyActive},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
router := newDailyUsageTestRouter(usageRepo, apiKeyRepo, 42)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/user/api-keys/7/usage/daily?days=7", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
require.True(t, usageRepo.called)
|
||||||
|
require.Equal(t, "day", usageRepo.granularity)
|
||||||
|
require.Equal(t, int64(42), usageRepo.userID)
|
||||||
|
require.Equal(t, int64(7), usageRepo.apiKeyID)
|
||||||
|
require.True(t, usageRepo.startTime.Before(usageRepo.endTime))
|
||||||
|
|
||||||
|
var got dailyUsageHandlerResponse
|
||||||
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||||
|
require.Equal(t, 7, got.Data.Days)
|
||||||
|
require.Len(t, got.Data.Items, 1)
|
||||||
|
require.Equal(t, usagestats.APIKeyDailyUsagePoint{
|
||||||
|
Date: "2026-05-19",
|
||||||
|
Requests: 3,
|
||||||
|
InputTokens: 10,
|
||||||
|
OutputTokens: 20,
|
||||||
|
CacheReadTokens: 6,
|
||||||
|
CacheWriteTokens: 4,
|
||||||
|
TotalTokens: 40,
|
||||||
|
Cost: 0.5,
|
||||||
|
ActualCost: 0.4,
|
||||||
|
}, got.Data.Items[0])
|
||||||
|
}
|
||||||
@ -198,6 +198,19 @@ type APIKeyUsageTrendPoint struct {
|
|||||||
Tokens int64 `json:"tokens"`
|
Tokens int64 `json:"tokens"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// APIKeyDailyUsagePoint represents one day of usage for a single API key.
|
||||||
|
type APIKeyDailyUsagePoint struct {
|
||||||
|
Date string `json:"date"`
|
||||||
|
Requests int64 `json:"requests"`
|
||||||
|
InputTokens int64 `json:"input_tokens"`
|
||||||
|
OutputTokens int64 `json:"output_tokens"`
|
||||||
|
CacheReadTokens int64 `json:"cache_read_tokens"`
|
||||||
|
CacheWriteTokens int64 `json:"cache_write_tokens"`
|
||||||
|
TotalTokens int64 `json:"total_tokens"`
|
||||||
|
Cost float64 `json:"cost"` // 标准计费
|
||||||
|
ActualCost float64 `json:"actual_cost"` // 实际扣除
|
||||||
|
}
|
||||||
|
|
||||||
// UserDashboardStats 用户仪表盘统计
|
// UserDashboardStats 用户仪表盘统计
|
||||||
type UserDashboardStats struct {
|
type UserDashboardStats struct {
|
||||||
// API Key 统计
|
// API Key 统计
|
||||||
|
|||||||
@ -31,6 +31,7 @@ func RegisterUserRoutes(
|
|||||||
user.POST("/account-bindings/email", h.User.BindEmailIdentity)
|
user.POST("/account-bindings/email", h.User.BindEmailIdentity)
|
||||||
user.DELETE("/account-bindings/:provider", h.User.UnbindIdentity)
|
user.DELETE("/account-bindings/:provider", h.User.UnbindIdentity)
|
||||||
user.POST("/auth-identities/bind/start", h.User.StartIdentityBinding)
|
user.POST("/auth-identities/bind/start", h.User.StartIdentityBinding)
|
||||||
|
user.GET("/api-keys/:id/usage/daily", h.Usage.GetMyAPIKeyDailyUsage)
|
||||||
|
|
||||||
// 通知邮箱管理
|
// 通知邮箱管理
|
||||||
notifyEmail := user.Group("/notify-email")
|
notifyEmail := user.Group("/notify-email")
|
||||||
|
|||||||
@ -324,6 +324,30 @@ func (s *UsageService) GetAPIKeyModelStats(ctx context.Context, apiKeyID int64,
|
|||||||
return stats, nil
|
return stats, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetAPIKeyDailyUsage returns daily usage stats for a user's API key.
|
||||||
|
func (s *UsageService) GetAPIKeyDailyUsage(ctx context.Context, userID, apiKeyID int64, startTime, endTime time.Time) ([]usagestats.APIKeyDailyUsagePoint, error) {
|
||||||
|
trend, err := s.usageRepo.GetUsageTrendWithFilters(ctx, startTime, endTime, "day", userID, apiKeyID, 0, 0, "", nil, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get api key daily usage: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
points := make([]usagestats.APIKeyDailyUsagePoint, 0, len(trend))
|
||||||
|
for _, row := range trend {
|
||||||
|
points = append(points, usagestats.APIKeyDailyUsagePoint{
|
||||||
|
Date: row.Date,
|
||||||
|
Requests: row.Requests,
|
||||||
|
InputTokens: row.InputTokens,
|
||||||
|
OutputTokens: row.OutputTokens,
|
||||||
|
CacheReadTokens: row.CacheReadTokens,
|
||||||
|
CacheWriteTokens: row.CacheCreationTokens,
|
||||||
|
TotalTokens: row.TotalTokens,
|
||||||
|
Cost: row.Cost,
|
||||||
|
ActualCost: row.ActualCost,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return points, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetBatchAPIKeyUsageStats returns today/total actual_cost for given api keys.
|
// GetBatchAPIKeyUsageStats returns today/total actual_cost for given api keys.
|
||||||
func (s *UsageService) GetBatchAPIKeyUsageStats(ctx context.Context, apiKeyIDs []int64, startTime, endTime time.Time) (map[int64]*usagestats.BatchAPIKeyUsageStats, error) {
|
func (s *UsageService) GetBatchAPIKeyUsageStats(ctx context.Context, apiKeyIDs []int64, startTime, endTime time.Time) (map[int64]*usagestats.BatchAPIKeyUsageStats, error) {
|
||||||
stats, err := s.usageRepo.GetBatchAPIKeyUsageStats(ctx, apiKeyIDs, startTime, endTime)
|
stats, err := s.usageRepo.GetBatchAPIKeyUsageStats(ctx, apiKeyIDs, startTime, endTime)
|
||||||
|
|||||||
@ -69,6 +69,25 @@ export interface ModelStatsResponse {
|
|||||||
end_date: string
|
end_date: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ApiKeyDailyUsagePoint {
|
||||||
|
date: string
|
||||||
|
requests: number
|
||||||
|
input_tokens: number
|
||||||
|
output_tokens: number
|
||||||
|
cache_read_tokens: number
|
||||||
|
cache_write_tokens: number
|
||||||
|
total_tokens: number
|
||||||
|
cost: number
|
||||||
|
actual_cost: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiKeyDailyUsageResponse {
|
||||||
|
items: ApiKeyDailyUsagePoint[]
|
||||||
|
days: number
|
||||||
|
start_date: string
|
||||||
|
end_date: string
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List usage logs with optional filters
|
* List usage logs with optional filters
|
||||||
* @param page - Page number (default: 1)
|
* @param page - Page number (default: 1)
|
||||||
@ -234,6 +253,23 @@ export async function getDashboardModels(params?: {
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get daily usage details for one API key owned by the current user.
|
||||||
|
* @param apiKeyId - API key ID
|
||||||
|
* @param days - Number of days to include (1-90)
|
||||||
|
* @returns Daily usage detail rows
|
||||||
|
*/
|
||||||
|
export async function getMyApiKeyDailyUsage(
|
||||||
|
apiKeyId: number,
|
||||||
|
days: number = 30
|
||||||
|
): Promise<ApiKeyDailyUsageResponse> {
|
||||||
|
const { data } = await apiClient.get<ApiKeyDailyUsageResponse>(
|
||||||
|
`/user/api-keys/${apiKeyId}/usage/daily`,
|
||||||
|
{ params: { days } }
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
export interface BatchApiKeyUsageStats {
|
export interface BatchApiKeyUsageStats {
|
||||||
api_key_id: number
|
api_key_id: number
|
||||||
today_actual_cost: number
|
today_actual_cost: number
|
||||||
@ -279,6 +315,7 @@ export const usageAPI = {
|
|||||||
getDashboardStats,
|
getDashboardStats,
|
||||||
getDashboardTrend,
|
getDashboardTrend,
|
||||||
getDashboardModels,
|
getDashboardModels,
|
||||||
|
getMyApiKeyDailyUsage,
|
||||||
getDashboardApiKeysUsage
|
getDashboardApiKeysUsage
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -122,19 +122,23 @@ export default {
|
|||||||
dateRangeToday: 'Today',
|
dateRangeToday: 'Today',
|
||||||
dateRange7d: '7 Days',
|
dateRange7d: '7 Days',
|
||||||
dateRange30d: '30 Days',
|
dateRange30d: '30 Days',
|
||||||
|
dateRange90d: '90 Days',
|
||||||
dateRangeCustom: 'Custom',
|
dateRangeCustom: 'Custom',
|
||||||
apply: 'Apply',
|
apply: 'Apply',
|
||||||
used: 'Used',
|
used: 'Used',
|
||||||
detailInfo: 'Detail Information',
|
detailInfo: 'Detail Information',
|
||||||
tokenStats: 'Token Statistics',
|
tokenStats: 'Token Statistics',
|
||||||
|
dailyDetail: 'Daily Detail',
|
||||||
modelStats: 'Model Usage Statistics',
|
modelStats: 'Model Usage Statistics',
|
||||||
// Table headers
|
// Table headers
|
||||||
|
date: 'Date',
|
||||||
model: 'Model',
|
model: 'Model',
|
||||||
requests: 'Requests',
|
requests: 'Requests',
|
||||||
inputTokens: 'Input Tokens',
|
inputTokens: 'Input Tokens',
|
||||||
outputTokens: 'Output Tokens',
|
outputTokens: 'Output Tokens',
|
||||||
cacheCreationTokens: 'Cache Creation',
|
cacheCreationTokens: 'Cache Creation',
|
||||||
cacheReadTokens: 'Cache Read',
|
cacheReadTokens: 'Cache Read',
|
||||||
|
cacheWriteTokens: 'Cache Write',
|
||||||
totalTokens: 'Total Tokens',
|
totalTokens: 'Total Tokens',
|
||||||
cost: 'Cost',
|
cost: 'Cost',
|
||||||
// Status
|
// Status
|
||||||
@ -178,6 +182,7 @@ export default {
|
|||||||
querySuccess: 'Query successful',
|
querySuccess: 'Query successful',
|
||||||
queryFailed: 'Query failed',
|
queryFailed: 'Query failed',
|
||||||
queryFailedRetry: 'Query failed, please try again later',
|
queryFailedRetry: 'Query failed, please try again later',
|
||||||
|
noDailyUsage: 'No daily usage data',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Setup Wizard
|
// Setup Wizard
|
||||||
@ -4148,6 +4153,22 @@ export default {
|
|||||||
},
|
},
|
||||||
userPrefix: 'User #{id}',
|
userPrefix: 'User #{id}',
|
||||||
exportCsv: 'Export CSV',
|
exportCsv: 'Export CSV',
|
||||||
|
batchUpdate: 'Batch Update',
|
||||||
|
batchUpdateTitle: 'Batch Update Redeem Codes',
|
||||||
|
selectedCount: '{count} redeem code(s) selected',
|
||||||
|
clearSelection: 'Clear selection',
|
||||||
|
selectCodesFirst: 'Select redeem codes first',
|
||||||
|
noBatchFieldsSelected: 'Select at least one field to update',
|
||||||
|
batchUpdateSuccess: 'Updated {count} redeem code(s)',
|
||||||
|
failedToBatchUpdate: 'Failed to batch update redeem codes',
|
||||||
|
batchFields: {
|
||||||
|
status: 'Status',
|
||||||
|
expiresAt: 'Expires At',
|
||||||
|
notes: 'Notes',
|
||||||
|
group: 'Group'
|
||||||
|
},
|
||||||
|
batchNotesPlaceholder: 'Enter the new note, or leave blank to clear it',
|
||||||
|
clearGroup: 'Clear group',
|
||||||
deleteAllUnused: 'Delete All Unused Codes',
|
deleteAllUnused: 'Delete All Unused Codes',
|
||||||
deleteCode: 'Delete Redeem Code',
|
deleteCode: 'Delete Redeem Code',
|
||||||
deleteCodeConfirm:
|
deleteCodeConfirm:
|
||||||
|
|||||||
@ -122,19 +122,23 @@ export default {
|
|||||||
dateRangeToday: '今日',
|
dateRangeToday: '今日',
|
||||||
dateRange7d: '7 天',
|
dateRange7d: '7 天',
|
||||||
dateRange30d: '30 天',
|
dateRange30d: '30 天',
|
||||||
|
dateRange90d: '90 天',
|
||||||
dateRangeCustom: '自定义',
|
dateRangeCustom: '自定义',
|
||||||
apply: '应用',
|
apply: '应用',
|
||||||
used: '已使用',
|
used: '已使用',
|
||||||
detailInfo: '详细信息',
|
detailInfo: '详细信息',
|
||||||
tokenStats: 'Token 统计',
|
tokenStats: 'Token 统计',
|
||||||
|
dailyDetail: '按日明细',
|
||||||
modelStats: '模型用量统计',
|
modelStats: '模型用量统计',
|
||||||
// Table headers
|
// Table headers
|
||||||
|
date: '日期',
|
||||||
model: '模型',
|
model: '模型',
|
||||||
requests: '请求数',
|
requests: '请求数',
|
||||||
inputTokens: '输入 Tokens',
|
inputTokens: '输入 Tokens',
|
||||||
outputTokens: '输出 Tokens',
|
outputTokens: '输出 Tokens',
|
||||||
cacheCreationTokens: '缓存创建',
|
cacheCreationTokens: '缓存创建',
|
||||||
cacheReadTokens: '缓存读取',
|
cacheReadTokens: '缓存读取',
|
||||||
|
cacheWriteTokens: '缓存写入',
|
||||||
totalTokens: '总 Tokens',
|
totalTokens: '总 Tokens',
|
||||||
cost: '费用',
|
cost: '费用',
|
||||||
// Status
|
// Status
|
||||||
@ -178,6 +182,7 @@ export default {
|
|||||||
querySuccess: '查询成功',
|
querySuccess: '查询成功',
|
||||||
queryFailed: '查询失败',
|
queryFailed: '查询失败',
|
||||||
queryFailedRetry: '查询失败,请稍后重试',
|
queryFailedRetry: '查询失败,请稍后重试',
|
||||||
|
noDailyUsage: '暂无按日用量数据',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Setup Wizard
|
// Setup Wizard
|
||||||
@ -4282,6 +4287,22 @@ export default {
|
|||||||
used: '已使用',
|
used: '已使用',
|
||||||
searchCodes: '搜索兑换码或邮箱...',
|
searchCodes: '搜索兑换码或邮箱...',
|
||||||
exportCsv: '导出 CSV',
|
exportCsv: '导出 CSV',
|
||||||
|
batchUpdate: '批量修改',
|
||||||
|
batchUpdateTitle: '批量修改兑换码',
|
||||||
|
selectedCount: '已选择 {count} 个兑换码',
|
||||||
|
clearSelection: '清空选择',
|
||||||
|
selectCodesFirst: '请先选择兑换码',
|
||||||
|
noBatchFieldsSelected: '请至少勾选一个要修改的字段',
|
||||||
|
batchUpdateSuccess: '成功修改 {count} 个兑换码',
|
||||||
|
failedToBatchUpdate: '批量修改兑换码失败',
|
||||||
|
batchFields: {
|
||||||
|
status: '状态',
|
||||||
|
expiresAt: '过期时间',
|
||||||
|
notes: '备注',
|
||||||
|
group: '分组'
|
||||||
|
},
|
||||||
|
batchNotesPlaceholder: '输入新的备注,留空可清空备注',
|
||||||
|
clearGroup: '清空分组',
|
||||||
deleteAllUnused: '删除全部未使用',
|
deleteAllUnused: '删除全部未使用',
|
||||||
deleteCodeConfirm: '确定要删除此兑换码吗?此操作无法撤销。',
|
deleteCodeConfirm: '确定要删除此兑换码吗?此操作无法撤销。',
|
||||||
deleteAllUnusedConfirm: '确定要删除全部未使用的兑换码吗?此操作无法撤销。',
|
deleteAllUnusedConfirm: '确定要删除全部未使用的兑换码吗?此操作无法撤销。',
|
||||||
|
|||||||
@ -289,6 +289,62 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Daily Usage Table -->
|
||||||
|
<div
|
||||||
|
v-if="showDailyUsage"
|
||||||
|
class="fade-up fade-up-delay-4 rounded-2xl border border-gray-200 bg-white/90 backdrop-blur-sm overflow-hidden dark:border-dark-700 dark:bg-dark-900/90"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-3 px-8 py-5 border-b border-gray-200 dark:border-dark-700 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<h3 class="text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-dark-400">{{ t('keyUsage.dailyDetail') }}</h3>
|
||||||
|
<div class="inline-flex rounded-lg border border-gray-200 bg-white p-0.5 dark:border-dark-700 dark:bg-dark-950">
|
||||||
|
<button
|
||||||
|
v-for="option in dailyUsageOptions"
|
||||||
|
:key="option.value"
|
||||||
|
@click="setDailyUsageDays(option.value)"
|
||||||
|
class="min-w-12 rounded-md px-3 py-1.5 text-xs font-medium transition-colors"
|
||||||
|
:class="dailyUsageDays === option.value
|
||||||
|
? 'bg-primary-500 text-white'
|
||||||
|
: 'text-gray-600 hover:bg-gray-100 dark:text-dark-300 dark:hover:bg-dark-800'"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="dailyUsageRows.length > 0" class="overflow-x-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b border-gray-200 bg-gray-50 dark:border-dark-700 dark:bg-dark-950">
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-dark-400">{{ t('keyUsage.date') }}</th>
|
||||||
|
<th class="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-dark-400">{{ t('keyUsage.requests') }}</th>
|
||||||
|
<th class="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-dark-400">{{ t('keyUsage.inputTokens') }}</th>
|
||||||
|
<th class="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-dark-400">{{ t('keyUsage.outputTokens') }}</th>
|
||||||
|
<th class="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-dark-400">{{ t('keyUsage.cacheReadTokens') }}</th>
|
||||||
|
<th class="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-dark-400">{{ t('keyUsage.cacheWriteTokens') }}</th>
|
||||||
|
<th class="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-dark-400">{{ t('keyUsage.cost') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="row in dailyUsageRows"
|
||||||
|
:key="row.date"
|
||||||
|
class="border-b border-gray-100 last:border-b-0 dark:border-dark-800"
|
||||||
|
>
|
||||||
|
<td class="px-4 py-3 text-sm font-medium whitespace-nowrap text-gray-900 dark:text-white">{{ row.date }}</td>
|
||||||
|
<td class="px-4 py-3 text-sm tabular-nums text-right text-gray-700 dark:text-dark-200">{{ fmtNum(row.requests) }}</td>
|
||||||
|
<td class="px-4 py-3 text-sm tabular-nums text-right text-gray-700 dark:text-dark-200">{{ fmtNum(row.input_tokens) }}</td>
|
||||||
|
<td class="px-4 py-3 text-sm tabular-nums text-right text-gray-700 dark:text-dark-200">{{ fmtNum(row.output_tokens) }}</td>
|
||||||
|
<td class="px-4 py-3 text-sm tabular-nums text-right text-gray-700 dark:text-dark-200">{{ fmtNum(row.cache_read_tokens) }}</td>
|
||||||
|
<td class="px-4 py-3 text-sm tabular-nums text-right text-gray-700 dark:text-dark-200">{{ fmtNum(row.cache_write_tokens) }}</td>
|
||||||
|
<td class="px-4 py-3 text-sm tabular-nums text-right font-medium text-gray-900 dark:text-white">{{ usd(row.actual_cost != null ? row.actual_cost : row.cost) }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div v-else class="px-8 py-8 text-center text-sm text-gray-500 dark:text-dark-400">
|
||||||
|
{{ t('keyUsage.noDailyUsage') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Model Stats Table -->
|
<!-- Model Stats Table -->
|
||||||
<div
|
<div
|
||||||
v-if="modelStats.length > 0"
|
v-if="modelStats.length > 0"
|
||||||
@ -408,6 +464,7 @@ type DateRangeKey = 'today' | '7d' | '30d' | 'custom'
|
|||||||
const currentRange = ref<DateRangeKey>('today')
|
const currentRange = ref<DateRangeKey>('today')
|
||||||
const customStartDate = ref('')
|
const customStartDate = ref('')
|
||||||
const customEndDate = ref('')
|
const customEndDate = ref('')
|
||||||
|
const dailyUsageDays = ref<7 | 30 | 90>(30)
|
||||||
|
|
||||||
const dateRanges = computed(() => [
|
const dateRanges = computed(() => [
|
||||||
{ key: 'today' as const, label: t('keyUsage.dateRangeToday') },
|
{ key: 'today' as const, label: t('keyUsage.dateRangeToday') },
|
||||||
@ -416,6 +473,12 @@ const dateRanges = computed(() => [
|
|||||||
{ key: 'custom' as const, label: t('keyUsage.dateRangeCustom') },
|
{ key: 'custom' as const, label: t('keyUsage.dateRangeCustom') },
|
||||||
])
|
])
|
||||||
|
|
||||||
|
const dailyUsageOptions = computed(() => [
|
||||||
|
{ value: 7 as const, label: t('keyUsage.dateRange7d') },
|
||||||
|
{ value: 30 as const, label: t('keyUsage.dateRange30d') },
|
||||||
|
{ value: 90 as const, label: t('keyUsage.dateRange90d') },
|
||||||
|
])
|
||||||
|
|
||||||
function setDateRange(key: DateRangeKey) {
|
function setDateRange(key: DateRangeKey) {
|
||||||
currentRange.value = key
|
currentRange.value = key
|
||||||
if (key !== 'custom') {
|
if (key !== 'custom') {
|
||||||
@ -426,23 +489,36 @@ function setDateRange(key: DateRangeKey) {
|
|||||||
function getDateParams(): string {
|
function getDateParams(): string {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const fmt = (d: Date) => d.toISOString().split('T')[0]
|
const fmt = (d: Date) => d.toISOString().split('T')[0]
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
|
||||||
if (currentRange.value === 'custom') {
|
if (currentRange.value === 'custom') {
|
||||||
if (customStartDate.value && customEndDate.value) {
|
if (customStartDate.value && customEndDate.value) {
|
||||||
return `start_date=${customStartDate.value}&end_date=${customEndDate.value}`
|
params.set('start_date', customStartDate.value)
|
||||||
|
params.set('end_date', customEndDate.value)
|
||||||
}
|
}
|
||||||
return ''
|
} else {
|
||||||
|
const end = fmt(now)
|
||||||
|
let start: string
|
||||||
|
switch (currentRange.value) {
|
||||||
|
case 'today': start = end; break
|
||||||
|
case '7d': start = fmt(new Date(now.getTime() - 7 * 86400000)); break
|
||||||
|
case '30d': start = fmt(new Date(now.getTime() - 30 * 86400000)); break
|
||||||
|
default: start = fmt(new Date(now.getTime() - 30 * 86400000))
|
||||||
|
}
|
||||||
|
params.set('start_date', start)
|
||||||
|
params.set('end_date', end)
|
||||||
}
|
}
|
||||||
|
params.set('days', String(dailyUsageDays.value))
|
||||||
|
params.set('timezone', getBrowserTimezone())
|
||||||
|
return params.toString()
|
||||||
|
}
|
||||||
|
|
||||||
const end = fmt(now)
|
function setDailyUsageDays(days: 7 | 30 | 90) {
|
||||||
let start: string
|
if (dailyUsageDays.value === days) return
|
||||||
switch (currentRange.value) {
|
dailyUsageDays.value = days
|
||||||
case 'today': start = end; break
|
if (resultData.value && apiKey.value.trim()) {
|
||||||
case '7d': start = fmt(new Date(now.getTime() - 7 * 86400000)); break
|
queryKey()
|
||||||
case '30d': start = fmt(new Date(now.getTime() - 30 * 86400000)); break
|
|
||||||
default: start = fmt(new Date(now.getTime() - 30 * 86400000))
|
|
||||||
}
|
}
|
||||||
return `start_date=${start}&end_date=${end}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Ring Animation ====================
|
// ==================== Ring Animation ====================
|
||||||
@ -731,6 +807,24 @@ const usageStatCells = computed<StatCell[]>(() => {
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const modelStats = computed<any[]>(() => resultData.value?.model_stats || [])
|
const modelStats = computed<any[]>(() => resultData.value?.model_stats || [])
|
||||||
|
|
||||||
|
interface DailyUsageRow {
|
||||||
|
date: string
|
||||||
|
requests: number
|
||||||
|
input_tokens: number
|
||||||
|
output_tokens: number
|
||||||
|
cache_read_tokens: number
|
||||||
|
cache_write_tokens: number
|
||||||
|
cost: number
|
||||||
|
actual_cost?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const dailyUsageRows = computed<DailyUsageRow[]>(() => {
|
||||||
|
const rows = resultData.value?.daily_usage
|
||||||
|
return Array.isArray(rows) ? rows : []
|
||||||
|
})
|
||||||
|
|
||||||
|
const showDailyUsage = computed(() => Boolean(resultData.value && Array.isArray(resultData.value.daily_usage)))
|
||||||
|
|
||||||
// ==================== Utility Functions ====================
|
// ==================== Utility Functions ====================
|
||||||
|
|
||||||
function usd(value: number | null | undefined): string {
|
function usd(value: number | null | undefined): string {
|
||||||
@ -750,6 +844,14 @@ function formatDate(iso: string | null | undefined): string {
|
|||||||
return d.toLocaleDateString(loc, { year: 'numeric', month: 'long', day: 'numeric' })
|
return d.toLocaleDateString(loc, { year: 'numeric', month: 'long', day: 'numeric' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getBrowserTimezone(): string {
|
||||||
|
try {
|
||||||
|
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
|
||||||
|
} catch {
|
||||||
|
return 'UTC'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== API Query ====================
|
// ==================== API Query ====================
|
||||||
|
|
||||||
async function fetchUsage(key: string) {
|
async function fetchUsage(key: string) {
|
||||||
|
|||||||
208
frontend/src/views/__tests__/KeyUsageView.spec.ts
Normal file
208
frontend/src/views/__tests__/KeyUsageView.spec.ts
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'
|
||||||
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
|
import { nextTick } from 'vue'
|
||||||
|
|
||||||
|
import KeyUsageView from '../KeyUsageView.vue'
|
||||||
|
|
||||||
|
const { showInfo, showSuccess, showError, fetchPublicSettings } = vi.hoisted(() => ({
|
||||||
|
showInfo: vi.fn(),
|
||||||
|
showSuccess: vi.fn(),
|
||||||
|
showError: vi.fn(),
|
||||||
|
fetchPublicSettings: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const messages: Record<string, string> = {
|
||||||
|
'keyUsage.title': 'API Key Usage',
|
||||||
|
'keyUsage.subtitle': 'Usage status',
|
||||||
|
'keyUsage.placeholder': 'sk-test',
|
||||||
|
'keyUsage.query': 'Query',
|
||||||
|
'keyUsage.querying': 'Querying...',
|
||||||
|
'keyUsage.privacyNote': 'Privacy note',
|
||||||
|
'keyUsage.dateRange': 'Date Range:',
|
||||||
|
'keyUsage.dateRangeToday': 'Today',
|
||||||
|
'keyUsage.dateRange7d': '7 Days',
|
||||||
|
'keyUsage.dateRange30d': '30 Days',
|
||||||
|
'keyUsage.dateRange90d': '90 Days',
|
||||||
|
'keyUsage.dateRangeCustom': 'Custom',
|
||||||
|
'keyUsage.apply': 'Apply',
|
||||||
|
'keyUsage.used': 'Used',
|
||||||
|
'keyUsage.detailInfo': 'Detail Information',
|
||||||
|
'keyUsage.tokenStats': 'Token Statistics',
|
||||||
|
'keyUsage.dailyDetail': 'Daily Detail',
|
||||||
|
'keyUsage.date': 'Date',
|
||||||
|
'keyUsage.requests': 'Requests',
|
||||||
|
'keyUsage.inputTokens': 'Input Tokens',
|
||||||
|
'keyUsage.outputTokens': 'Output Tokens',
|
||||||
|
'keyUsage.cacheReadTokens': 'Cache Read',
|
||||||
|
'keyUsage.cacheWriteTokens': 'Cache Write',
|
||||||
|
'keyUsage.cost': 'Cost',
|
||||||
|
'keyUsage.quotaMode': 'Key Quota Mode',
|
||||||
|
'keyUsage.walletBalance': 'Wallet Balance',
|
||||||
|
'keyUsage.totalQuota': 'Total Quota',
|
||||||
|
'keyUsage.limit5h': '5-Hour Limit',
|
||||||
|
'keyUsage.limitDaily': 'Daily Limit',
|
||||||
|
'keyUsage.limit7d': '7-Day Limit',
|
||||||
|
'keyUsage.limitWeekly': 'Weekly Limit',
|
||||||
|
'keyUsage.limitMonthly': 'Monthly Limit',
|
||||||
|
'keyUsage.remainingQuota': 'Remaining Quota',
|
||||||
|
'keyUsage.usedQuota': 'Used Quota',
|
||||||
|
'keyUsage.subscriptionType': 'Subscription Type',
|
||||||
|
'keyUsage.todayRequests': 'Today Requests',
|
||||||
|
'keyUsage.todayInputTokens': 'Today Input',
|
||||||
|
'keyUsage.todayOutputTokens': 'Today Output',
|
||||||
|
'keyUsage.todayTokens': 'Today Tokens',
|
||||||
|
'keyUsage.todayCacheCreation': 'Today Cache Creation',
|
||||||
|
'keyUsage.todayCacheRead': 'Today Cache Read',
|
||||||
|
'keyUsage.todayCost': 'Today Cost',
|
||||||
|
'keyUsage.rpmTpm': 'RPM / TPM',
|
||||||
|
'keyUsage.totalRequests': 'Total Requests',
|
||||||
|
'keyUsage.totalInputTokens': 'Total Input',
|
||||||
|
'keyUsage.totalOutputTokens': 'Total Output',
|
||||||
|
'keyUsage.totalTokensLabel': 'Total Tokens',
|
||||||
|
'keyUsage.totalCacheCreation': 'Total Cache Creation',
|
||||||
|
'keyUsage.totalCacheRead': 'Total Cache Read',
|
||||||
|
'keyUsage.totalCost': 'Total Cost',
|
||||||
|
'keyUsage.avgDuration': 'Avg Duration',
|
||||||
|
'keyUsage.querySuccess': 'Query successful',
|
||||||
|
'keyUsage.queryFailed': 'Query failed',
|
||||||
|
'keyUsage.queryFailedRetry': 'Query failed, please try again later',
|
||||||
|
'home.viewDocs': 'Docs',
|
||||||
|
'home.switchToLight': 'Light',
|
||||||
|
'home.switchToDark': 'Dark',
|
||||||
|
'home.footer.allRightsReserved': 'All rights reserved.',
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock('vue-i18n', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useI18n: () => ({
|
||||||
|
t: (key: string) => messages[key] ?? key,
|
||||||
|
locale: { value: 'en' },
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('@/stores', () => ({
|
||||||
|
useAppStore: () => ({
|
||||||
|
cachedPublicSettings: null,
|
||||||
|
siteName: 'Sub2API',
|
||||||
|
siteLogo: '',
|
||||||
|
docUrl: '',
|
||||||
|
publicSettingsLoaded: true,
|
||||||
|
fetchPublicSettings,
|
||||||
|
showInfo,
|
||||||
|
showSuccess,
|
||||||
|
showError,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('KeyUsageView daily detail', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
showInfo.mockReset()
|
||||||
|
showSuccess.mockReset()
|
||||||
|
showError.mockReset()
|
||||||
|
fetchPublicSettings.mockReset()
|
||||||
|
localStorage.clear()
|
||||||
|
|
||||||
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
|
configurable: true,
|
||||||
|
value: vi.fn().mockReturnValue({ matches: false }),
|
||||||
|
})
|
||||||
|
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => window.setTimeout(() => cb(0), 0))
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
mode: 'quota_limited',
|
||||||
|
isValid: true,
|
||||||
|
status: 'active',
|
||||||
|
quota: {
|
||||||
|
limit: 10,
|
||||||
|
used: 1,
|
||||||
|
remaining: 9,
|
||||||
|
unit: 'USD',
|
||||||
|
},
|
||||||
|
usage: {
|
||||||
|
today: {
|
||||||
|
requests: 1,
|
||||||
|
input_tokens: 10,
|
||||||
|
output_tokens: 20,
|
||||||
|
cache_creation_tokens: 0,
|
||||||
|
cache_read_tokens: 0,
|
||||||
|
total_tokens: 30,
|
||||||
|
actual_cost: 0.01,
|
||||||
|
},
|
||||||
|
total: {
|
||||||
|
requests: 12,
|
||||||
|
input_tokens: 100,
|
||||||
|
output_tokens: 200,
|
||||||
|
cache_creation_tokens: 10,
|
||||||
|
cache_read_tokens: 30,
|
||||||
|
total_tokens: 340,
|
||||||
|
actual_cost: 0.12,
|
||||||
|
},
|
||||||
|
rpm: 0,
|
||||||
|
tpm: 0,
|
||||||
|
},
|
||||||
|
daily_usage: [
|
||||||
|
{
|
||||||
|
date: '2026-05-19',
|
||||||
|
requests: 12,
|
||||||
|
input_tokens: 100,
|
||||||
|
output_tokens: 200,
|
||||||
|
cache_read_tokens: 30,
|
||||||
|
cache_write_tokens: 10,
|
||||||
|
total_tokens: 340,
|
||||||
|
cost: 0.15,
|
||||||
|
actual_cost: 0.12,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders daily usage detail rows after a successful query', async () => {
|
||||||
|
const wrapper = mount(KeyUsageView, {
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
RouterLink: { template: '<a><slot /></a>' },
|
||||||
|
LocaleSwitcher: true,
|
||||||
|
Icon: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await wrapper.find('input').setValue('sk-test-key')
|
||||||
|
await wrapper.find('input').trigger('keydown.enter')
|
||||||
|
await flushPromises()
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
const fetchMock = vi.mocked(fetch)
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('/v1/usage?'),
|
||||||
|
expect.objectContaining({
|
||||||
|
headers: { Authorization: 'Bearer sk-test-key' },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
expect(String(fetchMock.mock.calls[0][0])).toContain('days=30')
|
||||||
|
|
||||||
|
const text = wrapper.text()
|
||||||
|
expect(text).toContain('Daily Detail')
|
||||||
|
expect(text).toContain('Date')
|
||||||
|
expect(text).toContain('Cache Read')
|
||||||
|
expect(text).toContain('Cache Write')
|
||||||
|
expect(text).toContain('2026-05-19')
|
||||||
|
expect(text).toContain('12')
|
||||||
|
expect(text).toContain('100')
|
||||||
|
expect(text).toContain('200')
|
||||||
|
expect(text).toContain('30')
|
||||||
|
expect(text).toContain('10')
|
||||||
|
expect(text).toContain('$0.12')
|
||||||
|
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
x
Reference in New Issue
Block a user