Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/ent/migrate/schema.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 1 addition & 2 deletions backend/ent/schema/api_key.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,7 @@ func (APIKey) Fields() []ent.Field {
field.Int64("user_id"),
field.String("key").
MaxLen(128).
NotEmpty().
Unique(),
NotEmpty(),
field.String("name").
MaxLen(100).
NotEmpty(),
Expand Down
6 changes: 6 additions & 0 deletions backend/internal/handler/sora_gateway_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,12 @@ func (s *stubUsageLogRepo) GetModelStatsAggregated(ctx context.Context, modelNam
func (s *stubUsageLogRepo) GetDailyStatsAggregated(ctx context.Context, userID int64, startTime, endTime time.Time) ([]map[string]any, error) {
return nil, nil
}
func (s *stubUsageLogRepo) GetAPIKeyModelDistribution(ctx context.Context, userID int64, startTime, endTime time.Time) ([]usagestats.APIKeyModelDistributionItem, error) {
return nil, nil
}
func (s *stubUsageLogRepo) GetUserAPIKeyTrend(ctx context.Context, userID int64, startTime, endTime time.Time, granularity string) ([]usagestats.APIKeyTrendItem, error) {
return nil, nil
}

func TestSoraGatewayHandler_ChatCompletions(t *testing.T) {
gin.SetMode(gin.TestMode)
Expand Down
48 changes: 48 additions & 0 deletions backend/internal/handler/usage_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,54 @@ func (h *UsageHandler) DashboardModels(c *gin.Context) {
})
}

// DashboardAPIKeyModelDistribution handles getting model distribution per API Key
// GET /api/v1/usage/dashboard/api-key-model-distribution
func (h *UsageHandler) DashboardAPIKeyModelDistribution(c *gin.Context) {
subject, ok := middleware2.GetAuthSubjectFromContext(c)
if !ok {
response.Unauthorized(c, "User not authenticated")
return
}

startTime, endTime := parseUserTimeRange(c)

distribution, err := h.usageService.GetUserAPIKeyModelDistribution(c.Request.Context(), subject.UserID, startTime, endTime)
if err != nil {
response.ErrorFrom(c, err)
return
}

response.Success(c, gin.H{
"distribution": distribution,
})
}

// DashboardAPIKeyTrend handles getting per-API-Key trend data
// GET /api/v1/usage/dashboard/api-key-trend
func (h *UsageHandler) DashboardAPIKeyTrend(c *gin.Context) {
subject, ok := middleware2.GetAuthSubjectFromContext(c)
if !ok {
response.Unauthorized(c, "User not authenticated")
return
}

startTime, endTime := parseUserTimeRange(c)
granularity := c.DefaultQuery("granularity", "day")

trends, err := h.usageService.GetUserAPIKeyTrend(c.Request.Context(), subject.UserID, startTime, endTime, granularity)
if err != nil {
response.ErrorFrom(c, err)
return
}

response.Success(c, gin.H{
"trends": trends,
"start_date": startTime.Format("2006-01-02"),
"end_date": endTime.Add(-24 * time.Hour).Format("2006-01-02"),
"granularity": granularity,
})
}

// BatchAPIKeysUsageRequest represents the request for batch API keys usage
type BatchAPIKeysUsageRequest struct {
APIKeyIDs []int64 `json:"api_key_ids" binding:"required"`
Expand Down
23 changes: 23 additions & 0 deletions backend/internal/pkg/usagestats/usage_log_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,29 @@ type APIKeyUsageTrendPoint struct {
Tokens int64 `json:"tokens"`
}

// APIKeyModelDistributionItem represents model usage for a single API Key.
type APIKeyModelDistributionItem struct {
APIKeyID int64 `json:"api_key_id"`
APIKeyName string `json:"api_key_name"`
Models []ModelStat `json:"models"`
}

// APIKeyTrendDataPoint represents a single trend data point for an API Key.
type APIKeyTrendDataPoint struct {
Date string `json:"date"`
Requests int64 `json:"requests"`
TotalTokens int64 `json:"total_tokens"`
Cost float64 `json:"cost"`
ActualCost float64 `json:"actual_cost"`
}

// APIKeyTrendItem represents trend data for a single API Key.
type APIKeyTrendItem struct {
APIKeyID int64 `json:"api_key_id"`
APIKeyName string `json:"api_key_name"`
Data []APIKeyTrendDataPoint `json:"data"`
}

// UserDashboardStats 用户仪表盘统计
type UserDashboardStats struct {
// API Key 统计
Expand Down
26 changes: 26 additions & 0 deletions backend/internal/repository/api_key_repo_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,32 @@ func (s *APIKeyRepoSuite) TestExistsByKey() {
s.Require().False(notExists)
}

func (s *APIKeyRepoSuite) TestCreateAfterDelete_SameKey() {
// 回归测试:key 软删除后应允许重新创建相同 key(部分唯一索引)
user := s.mustCreateUser("recreate@test.com")
key := s.mustCreateApiKey(user.ID, "sk-recreate", "Original", nil)

// 软删除
err := s.repo.Delete(s.ctx, key.ID)
s.Require().NoError(err)

// 删除后 ExistsByKey 应返回 false
exists, err := s.repo.ExistsByKey(s.ctx, "sk-recreate")
s.Require().NoError(err)
s.Require().False(exists, "deleted key should not be reported as existing")

// 重新创建同一 key,不应报 409 冲突
newKey := &service.APIKey{
UserID: user.ID,
Key: "sk-recreate",
Name: "Recreated",
Status: service.StatusActive,
}
err = s.repo.Create(s.ctx, newKey)
s.Require().NoError(err, "should be able to recreate a previously deleted key")
s.Require().Greater(newKey.ID, key.ID, "new record should have a different ID")
}

// --- SearchAPIKeys ---

func (s *APIKeyRepoSuite) TestSearchAPIKeys() {
Expand Down
125 changes: 125 additions & 0 deletions backend/internal/repository/usage_log_repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -1436,6 +1436,131 @@ func (r *usageLogRepository) GetUserModelStats(ctx context.Context, userID int64
return results, nil
}

// GetAPIKeyModelDistribution returns model usage distribution grouped by API Key for a user.
func (r *usageLogRepository) GetAPIKeyModelDistribution(ctx context.Context, userID int64, startTime, endTime time.Time) ([]usagestats.APIKeyModelDistributionItem, error) {
query := `
SELECT ul.api_key_id, COALESCE(ak.name, '') as api_key_name, ul.model,
COUNT(*) as requests,
COALESCE(SUM(ul.input_tokens), 0) as input_tokens,
COALESCE(SUM(ul.output_tokens), 0) as output_tokens,
COALESCE(SUM(ul.cache_creation_tokens), 0) as cache_creation_tokens,
COALESCE(SUM(ul.cache_read_tokens), 0) as cache_read_tokens,
COALESCE(SUM(ul.input_tokens + ul.output_tokens + ul.cache_creation_tokens + ul.cache_read_tokens), 0) as total_tokens,
COALESCE(SUM(ul.total_cost), 0) as cost,
COALESCE(SUM(ul.actual_cost), 0) as actual_cost
FROM usage_logs ul
LEFT JOIN api_keys ak ON ul.api_key_id = ak.id
WHERE ul.user_id = $1 AND ul.created_at >= $2 AND ul.created_at < $3
GROUP BY ul.api_key_id, ak.name, ul.model
ORDER BY ul.api_key_id, requests DESC
`

rows, err := r.sql.QueryContext(ctx, query, userID, startTime, endTime)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()

// Scan flat rows and group by api_key_id
itemMap := make(map[int64]*usagestats.APIKeyModelDistributionItem)
var orderedKeys []int64

for rows.Next() {
var apiKeyID int64
var apiKeyName string
var stat ModelStat
if err := rows.Scan(
&apiKeyID, &apiKeyName, &stat.Model,
&stat.Requests, &stat.InputTokens, &stat.OutputTokens,
&stat.CacheCreationTokens, &stat.CacheReadTokens, &stat.TotalTokens,
&stat.Cost, &stat.ActualCost,
); err != nil {
return nil, err
}
item, ok := itemMap[apiKeyID]
if !ok {
item = &usagestats.APIKeyModelDistributionItem{
APIKeyID: apiKeyID,
APIKeyName: apiKeyName,
Models: make([]ModelStat, 0),
}
itemMap[apiKeyID] = item
orderedKeys = append(orderedKeys, apiKeyID)
}
item.Models = append(item.Models, stat)
}
if err := rows.Err(); err != nil {
return nil, err
}

results := make([]usagestats.APIKeyModelDistributionItem, 0, len(orderedKeys))
for _, key := range orderedKeys {
results = append(results, *itemMap[key])
}
return results, nil
}

// GetUserAPIKeyTrend returns per-API-Key trend data for a user.
func (r *usageLogRepository) GetUserAPIKeyTrend(ctx context.Context, userID int64, startTime, endTime time.Time, granularity string) ([]usagestats.APIKeyTrendItem, error) {
dateFormat := safeDateFormat(granularity)

query := fmt.Sprintf(`
SELECT TO_CHAR(ul.created_at, '%s') as date,
ul.api_key_id, COALESCE(ak.name, '') as api_key_name,
COUNT(*) as requests,
COALESCE(SUM(ul.input_tokens + ul.output_tokens + ul.cache_creation_tokens + ul.cache_read_tokens), 0) as total_tokens,
COALESCE(SUM(ul.total_cost), 0) as cost,
COALESCE(SUM(ul.actual_cost), 0) as actual_cost
FROM usage_logs ul
LEFT JOIN api_keys ak ON ul.api_key_id = ak.id
WHERE ul.user_id = $1 AND ul.created_at >= $2 AND ul.created_at < $3
GROUP BY date, ul.api_key_id, ak.name
ORDER BY ul.api_key_id, date
`, dateFormat)

rows, err := r.sql.QueryContext(ctx, query, userID, startTime, endTime)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()

itemMap := make(map[int64]*usagestats.APIKeyTrendItem)
var orderedKeys []int64

for rows.Next() {
var date string
var apiKeyID int64
var apiKeyName string
var point usagestats.APIKeyTrendDataPoint

if err := rows.Scan(&date, &apiKeyID, &apiKeyName, &point.Requests, &point.TotalTokens, &point.Cost, &point.ActualCost); err != nil {
return nil, err
}
point.Date = date

item, ok := itemMap[apiKeyID]
if !ok {
item = &usagestats.APIKeyTrendItem{
APIKeyID: apiKeyID,
APIKeyName: apiKeyName,
Data: make([]usagestats.APIKeyTrendDataPoint, 0),
}
itemMap[apiKeyID] = item
orderedKeys = append(orderedKeys, apiKeyID)
}
item.Data = append(item.Data, point)
}
if err := rows.Err(); err != nil {
return nil, err
}

results := make([]usagestats.APIKeyTrendItem, 0, len(orderedKeys))
for _, key := range orderedKeys {
results = append(results, *itemMap[key])
}
return results, nil
}

// UsageLogFilters represents filters for usage log queries
type UsageLogFilters = usagestats.UsageLogFilters

Expand Down
4 changes: 4 additions & 0 deletions backend/internal/server/api_contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1769,6 +1769,10 @@ func (r *stubUsageLogRepo) GetStatsWithFilters(ctx context.Context, filters usag
return nil, errors.New("not implemented")
}

func (r *stubUsageLogRepo) GetAPIKeyModelDistribution(ctx context.Context, userID int64, startTime, endTime time.Time) ([]usagestats.APIKeyModelDistributionItem, error) {
return nil, errors.New("not implemented")
}

type stubSettingRepo struct {
all map[string]string
}
Expand Down
2 changes: 2 additions & 0 deletions backend/internal/server/routes/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ func RegisterUserRoutes(
usage.GET("/dashboard/stats", h.Usage.DashboardStats)
usage.GET("/dashboard/trend", h.Usage.DashboardTrend)
usage.GET("/dashboard/models", h.Usage.DashboardModels)
usage.GET("/dashboard/api-key-model-distribution", h.Usage.DashboardAPIKeyModelDistribution)
usage.GET("/dashboard/api-key-trend", h.Usage.DashboardAPIKeyTrend)
usage.POST("/dashboard/api-keys-usage", h.Usage.DashboardAPIKeysUsage)
}

Expand Down
2 changes: 2 additions & 0 deletions backend/internal/service/account_usage_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ type UsageLogRepository interface {
GetAPIKeyDashboardStats(ctx context.Context, apiKeyID int64) (*usagestats.UserDashboardStats, error)
GetUserUsageTrendByUserID(ctx context.Context, userID int64, startTime, endTime time.Time, granularity string) ([]usagestats.TrendDataPoint, error)
GetUserModelStats(ctx context.Context, userID int64, startTime, endTime time.Time) ([]usagestats.ModelStat, error)
GetAPIKeyModelDistribution(ctx context.Context, userID int64, startTime, endTime time.Time) ([]usagestats.APIKeyModelDistributionItem, error)
GetUserAPIKeyTrend(ctx context.Context, userID int64, startTime, endTime time.Time, granularity string) ([]usagestats.APIKeyTrendItem, error)

// Admin usage listing/stats
ListWithFilters(ctx context.Context, params pagination.PaginationParams, filters usagestats.UsageLogFilters) ([]UsageLog, *pagination.PaginationResult, error)
Expand Down
18 changes: 18 additions & 0 deletions backend/internal/service/usage_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,15 @@ func (s *UsageService) GetUserModelStats(ctx context.Context, userID int64, star
return stats, nil
}

// GetUserAPIKeyModelDistribution returns model distribution grouped by API Key for a user.
func (s *UsageService) GetUserAPIKeyModelDistribution(ctx context.Context, userID int64, startTime, endTime time.Time) ([]usagestats.APIKeyModelDistributionItem, error) {
distribution, err := s.usageRepo.GetAPIKeyModelDistribution(ctx, userID, startTime, endTime)
if err != nil {
return nil, fmt.Errorf("get api key model distribution: %w", err)
}
return distribution, nil
}

// GetAPIKeyModelStats returns per-model usage stats for a specific API Key.
func (s *UsageService) GetAPIKeyModelStats(ctx context.Context, apiKeyID int64, startTime, endTime time.Time) ([]usagestats.ModelStat, error) {
stats, err := s.usageRepo.GetModelStatsWithFilters(ctx, startTime, endTime, 0, apiKeyID, 0, 0, nil, nil, nil)
Expand All @@ -333,6 +342,15 @@ func (s *UsageService) GetBatchAPIKeyUsageStats(ctx context.Context, apiKeyIDs [
return stats, nil
}

// GetUserAPIKeyTrend returns per-API-Key trend data for a user.
func (s *UsageService) GetUserAPIKeyTrend(ctx context.Context, userID int64, startTime, endTime time.Time, granularity string) ([]usagestats.APIKeyTrendItem, error) {
items, err := s.usageRepo.GetUserAPIKeyTrend(ctx, userID, startTime, endTime, granularity)
if err != nil {
return nil, fmt.Errorf("get user api key trend: %w", err)
}
return items, nil
}

// ListWithFilters lists usage logs with admin filters.
func (s *UsageService) ListWithFilters(ctx context.Context, params pagination.PaginationParams, filters usagestats.UsageLogFilters) ([]UsageLog, *pagination.PaginationResult, error) {
logs, result, err := s.usageRepo.ListWithFilters(ctx, params, filters)
Expand Down
13 changes: 13 additions & 0 deletions backend/migrations/071_fix_api_key_partial_unique_index.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
-- 071_fix_api_key_partial_unique_index.sql
-- 修复 api_keys.key 软删除后无法重新创建相同 key 的问题
-- 将全局唯一约束替换为部分唯一索引(WHERE deleted_at IS NULL)
-- 软删除的 key 不占用唯一约束位置,允许删除后重新创建同一 key

-- 删除旧的全局唯一约束
ALTER TABLE api_keys DROP CONSTRAINT IF EXISTS api_keys_key_key;
DROP INDEX IF EXISTS api_keys_key_key;

-- 创建部分唯一索引:只对未删除的记录建立唯一约束
CREATE UNIQUE INDEX IF NOT EXISTS api_keys_key_unique_active
ON api_keys(key)
WHERE deleted_at IS NULL;
Loading
Loading