mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-04-06 00:10:21 +08:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d99a3ef14b | ||
|
|
fc8fa83fcc | ||
|
|
6dcd99468b | ||
|
|
d5ba7b80d3 | ||
|
|
a3b81ef7bc | ||
|
|
015974a27e | ||
|
|
4cf756ebe6 | ||
|
|
823497a2af | ||
|
|
66fe484f0d | ||
|
|
216321aa9e | ||
|
|
5a52cb608c | ||
|
|
1181b332f7 |
@@ -152,8 +152,8 @@ func (h *UsageHandler) List(c *gin.Context) {
|
|||||||
// Stats handles getting usage statistics with filters
|
// Stats handles getting usage statistics with filters
|
||||||
// GET /api/v1/admin/usage/stats
|
// GET /api/v1/admin/usage/stats
|
||||||
func (h *UsageHandler) Stats(c *gin.Context) {
|
func (h *UsageHandler) Stats(c *gin.Context) {
|
||||||
// Parse filters
|
// Parse filters - same as List endpoint
|
||||||
var userID, apiKeyID int64
|
var userID, apiKeyID, accountID, groupID int64
|
||||||
if userIDStr := c.Query("user_id"); userIDStr != "" {
|
if userIDStr := c.Query("user_id"); userIDStr != "" {
|
||||||
id, err := strconv.ParseInt(userIDStr, 10, 64)
|
id, err := strconv.ParseInt(userIDStr, 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -172,8 +172,49 @@ func (h *UsageHandler) Stats(c *gin.Context) {
|
|||||||
apiKeyID = id
|
apiKeyID = id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if accountIDStr := c.Query("account_id"); accountIDStr != "" {
|
||||||
|
id, err := strconv.ParseInt(accountIDStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
response.BadRequest(c, "Invalid account_id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
accountID = id
|
||||||
|
}
|
||||||
|
|
||||||
|
if groupIDStr := c.Query("group_id"); groupIDStr != "" {
|
||||||
|
id, err := strconv.ParseInt(groupIDStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
response.BadRequest(c, "Invalid group_id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
groupID = id
|
||||||
|
}
|
||||||
|
|
||||||
|
model := c.Query("model")
|
||||||
|
|
||||||
|
var stream *bool
|
||||||
|
if streamStr := c.Query("stream"); streamStr != "" {
|
||||||
|
val, err := strconv.ParseBool(streamStr)
|
||||||
|
if err != nil {
|
||||||
|
response.BadRequest(c, "Invalid stream value, use true or false")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stream = &val
|
||||||
|
}
|
||||||
|
|
||||||
|
var billingType *int8
|
||||||
|
if billingTypeStr := c.Query("billing_type"); billingTypeStr != "" {
|
||||||
|
val, err := strconv.ParseInt(billingTypeStr, 10, 8)
|
||||||
|
if err != nil {
|
||||||
|
response.BadRequest(c, "Invalid billing_type")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bt := int8(val)
|
||||||
|
billingType = &bt
|
||||||
|
}
|
||||||
|
|
||||||
// Parse date range
|
// Parse date range
|
||||||
userTZ := c.Query("timezone") // Get user's timezone from request
|
userTZ := c.Query("timezone")
|
||||||
now := timezone.NowInUserLocation(userTZ)
|
now := timezone.NowInUserLocation(userTZ)
|
||||||
var startTime, endTime time.Time
|
var startTime, endTime time.Time
|
||||||
|
|
||||||
@@ -208,28 +249,20 @@ func (h *UsageHandler) Stats(c *gin.Context) {
|
|||||||
endTime = now
|
endTime = now
|
||||||
}
|
}
|
||||||
|
|
||||||
if apiKeyID > 0 {
|
// Build filters and call GetStatsWithFilters
|
||||||
stats, err := h.usageService.GetStatsByAPIKey(c.Request.Context(), apiKeyID, startTime, endTime)
|
filters := usagestats.UsageLogFilters{
|
||||||
if err != nil {
|
UserID: userID,
|
||||||
response.ErrorFrom(c, err)
|
APIKeyID: apiKeyID,
|
||||||
return
|
AccountID: accountID,
|
||||||
}
|
GroupID: groupID,
|
||||||
response.Success(c, stats)
|
Model: model,
|
||||||
return
|
Stream: stream,
|
||||||
|
BillingType: billingType,
|
||||||
|
StartTime: &startTime,
|
||||||
|
EndTime: &endTime,
|
||||||
}
|
}
|
||||||
|
|
||||||
if userID > 0 {
|
stats, err := h.usageService.GetStatsWithFilters(c.Request.Context(), filters)
|
||||||
stats, err := h.usageService.GetStatsByUser(c.Request.Context(), userID, startTime, endTime)
|
|
||||||
if err != nil {
|
|
||||||
response.ErrorFrom(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.Success(c, stats)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get global stats
|
|
||||||
stats, err := h.usageService.GetGlobalStats(c.Request.Context(), startTime, endTime)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.ErrorFrom(c, err)
|
response.ErrorFrom(c, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -83,19 +83,33 @@ func NewConcurrencyHelper(concurrencyService *service.ConcurrencyService, pingFo
|
|||||||
|
|
||||||
// wrapReleaseOnDone ensures release runs at most once and still triggers on context cancellation.
|
// wrapReleaseOnDone ensures release runs at most once and still triggers on context cancellation.
|
||||||
// 用于避免客户端断开或上游超时导致的并发槽位泄漏。
|
// 用于避免客户端断开或上游超时导致的并发槽位泄漏。
|
||||||
|
// 修复:添加 quit channel 确保 goroutine 及时退出,避免泄露
|
||||||
func wrapReleaseOnDone(ctx context.Context, releaseFunc func()) func() {
|
func wrapReleaseOnDone(ctx context.Context, releaseFunc func()) func() {
|
||||||
if releaseFunc == nil {
|
if releaseFunc == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var once sync.Once
|
var once sync.Once
|
||||||
wrapped := func() {
|
quit := make(chan struct{})
|
||||||
once.Do(releaseFunc)
|
|
||||||
|
release := func() {
|
||||||
|
once.Do(func() {
|
||||||
|
releaseFunc()
|
||||||
|
close(quit) // 通知监听 goroutine 退出
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
<-ctx.Done()
|
select {
|
||||||
wrapped()
|
case <-ctx.Done():
|
||||||
|
// Context 取消时释放资源
|
||||||
|
release()
|
||||||
|
case <-quit:
|
||||||
|
// 正常释放已完成,goroutine 退出
|
||||||
|
return
|
||||||
|
}
|
||||||
}()
|
}()
|
||||||
return wrapped
|
|
||||||
|
return release
|
||||||
}
|
}
|
||||||
|
|
||||||
// IncrementWaitCount increments the wait count for a user
|
// IncrementWaitCount increments the wait count for a user
|
||||||
|
|||||||
141
backend/internal/handler/gateway_helper_test.go
Normal file
141
backend/internal/handler/gateway_helper_test.go
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"runtime"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestWrapReleaseOnDone_NoGoroutineLeak 验证 wrapReleaseOnDone 修复后不会泄露 goroutine
|
||||||
|
func TestWrapReleaseOnDone_NoGoroutineLeak(t *testing.T) {
|
||||||
|
// 记录测试开始时的 goroutine 数量
|
||||||
|
runtime.GC()
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
initialGoroutines := runtime.NumGoroutine()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var releaseCount int32
|
||||||
|
release := wrapReleaseOnDone(ctx, func() {
|
||||||
|
atomic.AddInt32(&releaseCount, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 正常释放
|
||||||
|
release()
|
||||||
|
|
||||||
|
// 等待足够时间确保 goroutine 退出
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
|
||||||
|
// 验证只释放一次
|
||||||
|
if count := atomic.LoadInt32(&releaseCount); count != 1 {
|
||||||
|
t.Errorf("expected release count to be 1, got %d", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 强制 GC,清理已退出的 goroutine
|
||||||
|
runtime.GC()
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
// 验证 goroutine 数量没有增加(允许±2的误差,考虑到测试框架本身可能创建的 goroutine)
|
||||||
|
finalGoroutines := runtime.NumGoroutine()
|
||||||
|
if finalGoroutines > initialGoroutines+2 {
|
||||||
|
t.Errorf("goroutine leak detected: initial=%d, final=%d, leaked=%d",
|
||||||
|
initialGoroutines, finalGoroutines, finalGoroutines-initialGoroutines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWrapReleaseOnDone_ContextCancellation 验证 context 取消时也能正确释放
|
||||||
|
func TestWrapReleaseOnDone_ContextCancellation(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
|
var releaseCount int32
|
||||||
|
_ = wrapReleaseOnDone(ctx, func() {
|
||||||
|
atomic.AddInt32(&releaseCount, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 取消 context,应该触发释放
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
// 等待释放完成
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
// 验证释放被调用
|
||||||
|
if count := atomic.LoadInt32(&releaseCount); count != 1 {
|
||||||
|
t.Errorf("expected release count to be 1, got %d", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWrapReleaseOnDone_MultipleCallsOnlyReleaseOnce 验证多次调用 release 只释放一次
|
||||||
|
func TestWrapReleaseOnDone_MultipleCallsOnlyReleaseOnce(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var releaseCount int32
|
||||||
|
release := wrapReleaseOnDone(ctx, func() {
|
||||||
|
atomic.AddInt32(&releaseCount, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 调用多次
|
||||||
|
release()
|
||||||
|
release()
|
||||||
|
release()
|
||||||
|
|
||||||
|
// 等待执行完成
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
// 验证只释放一次
|
||||||
|
if count := atomic.LoadInt32(&releaseCount); count != 1 {
|
||||||
|
t.Errorf("expected release count to be 1, got %d", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWrapReleaseOnDone_NilReleaseFunc 验证 nil releaseFunc 不会 panic
|
||||||
|
func TestWrapReleaseOnDone_NilReleaseFunc(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
release := wrapReleaseOnDone(ctx, nil)
|
||||||
|
|
||||||
|
if release != nil {
|
||||||
|
t.Error("expected nil release function when releaseFunc is nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWrapReleaseOnDone_ConcurrentCalls 验证并发调用的安全性
|
||||||
|
func TestWrapReleaseOnDone_ConcurrentCalls(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var releaseCount int32
|
||||||
|
release := wrapReleaseOnDone(ctx, func() {
|
||||||
|
atomic.AddInt32(&releaseCount, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 并发调用 release
|
||||||
|
const numGoroutines = 10
|
||||||
|
for i := 0; i < numGoroutines; i++ {
|
||||||
|
go release()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 等待所有 goroutine 完成
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
|
||||||
|
// 验证只释放一次
|
||||||
|
if count := atomic.LoadInt32(&releaseCount); count != 1 {
|
||||||
|
t.Errorf("expected release count to be 1, got %d", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BenchmarkWrapReleaseOnDone 性能基准测试
|
||||||
|
func BenchmarkWrapReleaseOnDone(b *testing.B) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
release := wrapReleaseOnDone(ctx, func() {})
|
||||||
|
release()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1388,6 +1388,81 @@ func (r *usageLogRepository) GetGlobalStats(ctx context.Context, startTime, endT
|
|||||||
return stats, nil
|
return stats, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetStatsWithFilters gets usage statistics with optional filters
|
||||||
|
func (r *usageLogRepository) GetStatsWithFilters(ctx context.Context, filters UsageLogFilters) (*UsageStats, error) {
|
||||||
|
conditions := make([]string, 0, 9)
|
||||||
|
args := make([]any, 0, 9)
|
||||||
|
|
||||||
|
if filters.UserID > 0 {
|
||||||
|
conditions = append(conditions, fmt.Sprintf("user_id = $%d", len(args)+1))
|
||||||
|
args = append(args, filters.UserID)
|
||||||
|
}
|
||||||
|
if filters.APIKeyID > 0 {
|
||||||
|
conditions = append(conditions, fmt.Sprintf("api_key_id = $%d", len(args)+1))
|
||||||
|
args = append(args, filters.APIKeyID)
|
||||||
|
}
|
||||||
|
if filters.AccountID > 0 {
|
||||||
|
conditions = append(conditions, fmt.Sprintf("account_id = $%d", len(args)+1))
|
||||||
|
args = append(args, filters.AccountID)
|
||||||
|
}
|
||||||
|
if filters.GroupID > 0 {
|
||||||
|
conditions = append(conditions, fmt.Sprintf("group_id = $%d", len(args)+1))
|
||||||
|
args = append(args, filters.GroupID)
|
||||||
|
}
|
||||||
|
if filters.Model != "" {
|
||||||
|
conditions = append(conditions, fmt.Sprintf("model = $%d", len(args)+1))
|
||||||
|
args = append(args, filters.Model)
|
||||||
|
}
|
||||||
|
if filters.Stream != nil {
|
||||||
|
conditions = append(conditions, fmt.Sprintf("stream = $%d", len(args)+1))
|
||||||
|
args = append(args, *filters.Stream)
|
||||||
|
}
|
||||||
|
if filters.BillingType != nil {
|
||||||
|
conditions = append(conditions, fmt.Sprintf("billing_type = $%d", len(args)+1))
|
||||||
|
args = append(args, int16(*filters.BillingType))
|
||||||
|
}
|
||||||
|
if filters.StartTime != nil {
|
||||||
|
conditions = append(conditions, fmt.Sprintf("created_at >= $%d", len(args)+1))
|
||||||
|
args = append(args, *filters.StartTime)
|
||||||
|
}
|
||||||
|
if filters.EndTime != nil {
|
||||||
|
conditions = append(conditions, fmt.Sprintf("created_at <= $%d", len(args)+1))
|
||||||
|
args = append(args, *filters.EndTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
query := fmt.Sprintf(`
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total_requests,
|
||||||
|
COALESCE(SUM(input_tokens), 0) as total_input_tokens,
|
||||||
|
COALESCE(SUM(output_tokens), 0) as total_output_tokens,
|
||||||
|
COALESCE(SUM(cache_creation_tokens + cache_read_tokens), 0) as total_cache_tokens,
|
||||||
|
COALESCE(SUM(total_cost), 0) as total_cost,
|
||||||
|
COALESCE(SUM(actual_cost), 0) as total_actual_cost,
|
||||||
|
COALESCE(AVG(duration_ms), 0) as avg_duration_ms
|
||||||
|
FROM usage_logs
|
||||||
|
%s
|
||||||
|
`, buildWhere(conditions))
|
||||||
|
|
||||||
|
stats := &UsageStats{}
|
||||||
|
if err := scanSingleRow(
|
||||||
|
ctx,
|
||||||
|
r.sql,
|
||||||
|
query,
|
||||||
|
args,
|
||||||
|
&stats.TotalRequests,
|
||||||
|
&stats.TotalInputTokens,
|
||||||
|
&stats.TotalOutputTokens,
|
||||||
|
&stats.TotalCacheTokens,
|
||||||
|
&stats.TotalCost,
|
||||||
|
&stats.TotalActualCost,
|
||||||
|
&stats.AverageDurationMs,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
stats.TotalTokens = stats.TotalInputTokens + stats.TotalOutputTokens + stats.TotalCacheTokens
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
|
||||||
// AccountUsageHistory represents daily usage history for an account
|
// AccountUsageHistory represents daily usage history for an account
|
||||||
type AccountUsageHistory = usagestats.AccountUsageHistory
|
type AccountUsageHistory = usagestats.AccountUsageHistory
|
||||||
|
|
||||||
|
|||||||
@@ -1065,6 +1065,10 @@ func (r *stubUsageLogRepo) GetAccountUsageStats(ctx context.Context, accountID i
|
|||||||
return nil, errors.New("not implemented")
|
return nil, errors.New("not implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *stubUsageLogRepo) GetStatsWithFilters(ctx context.Context, filters usagestats.UsageLogFilters) (*usagestats.UsageStats, error) {
|
||||||
|
return nil, errors.New("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
type stubSettingRepo struct {
|
type stubSettingRepo struct {
|
||||||
all map[string]string
|
all map[string]string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ type UsageLogRepository interface {
|
|||||||
// Admin usage listing/stats
|
// Admin usage listing/stats
|
||||||
ListWithFilters(ctx context.Context, params pagination.PaginationParams, filters usagestats.UsageLogFilters) ([]UsageLog, *pagination.PaginationResult, error)
|
ListWithFilters(ctx context.Context, params pagination.PaginationParams, filters usagestats.UsageLogFilters) ([]UsageLog, *pagination.PaginationResult, error)
|
||||||
GetGlobalStats(ctx context.Context, startTime, endTime time.Time) (*usagestats.UsageStats, error)
|
GetGlobalStats(ctx context.Context, startTime, endTime time.Time) (*usagestats.UsageStats, error)
|
||||||
|
GetStatsWithFilters(ctx context.Context, filters usagestats.UsageLogFilters) (*usagestats.UsageStats, error)
|
||||||
|
|
||||||
// Account stats
|
// Account stats
|
||||||
GetAccountUsageStats(ctx context.Context, accountID int64, startTime, endTime time.Time) (*usagestats.AccountUsageStatsResponse, error)
|
GetAccountUsageStats(ctx context.Context, accountID int64, startTime, endTime time.Time) (*usagestats.AccountUsageStatsResponse, error)
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ const (
|
|||||||
stickySessionTTL = time.Hour // 粘性会话TTL
|
stickySessionTTL = time.Hour // 粘性会话TTL
|
||||||
defaultMaxLineSize = 10 * 1024 * 1024
|
defaultMaxLineSize = 10 * 1024 * 1024
|
||||||
claudeCodeSystemPrompt = "You are Claude Code, Anthropic's official CLI for Claude."
|
claudeCodeSystemPrompt = "You are Claude Code, Anthropic's official CLI for Claude."
|
||||||
|
maxCacheControlBlocks = 4 // Anthropic API 允许的最大 cache_control 块数量
|
||||||
)
|
)
|
||||||
|
|
||||||
// sseDataRe matches SSE data lines with optional whitespace after colon.
|
// sseDataRe matches SSE data lines with optional whitespace after colon.
|
||||||
@@ -43,6 +44,16 @@ var (
|
|||||||
sseDataRe = regexp.MustCompile(`^data:\s*`)
|
sseDataRe = regexp.MustCompile(`^data:\s*`)
|
||||||
sessionIDRegex = regexp.MustCompile(`session_([a-f0-9-]{36})`)
|
sessionIDRegex = regexp.MustCompile(`session_([a-f0-9-]{36})`)
|
||||||
claudeCliUserAgentRe = regexp.MustCompile(`^claude-cli/\d+\.\d+\.\d+`)
|
claudeCliUserAgentRe = regexp.MustCompile(`^claude-cli/\d+\.\d+\.\d+`)
|
||||||
|
|
||||||
|
// claudeCodePromptPrefixes 用于检测 Claude Code 系统提示词的前缀列表
|
||||||
|
// 支持多种变体:标准版、Agent SDK 版、Explore Agent 版、Compact 版等
|
||||||
|
// 注意:前缀之间不应存在包含关系,否则会导致冗余匹配
|
||||||
|
claudeCodePromptPrefixes = []string{
|
||||||
|
"You are Claude Code, Anthropic's official CLI for Claude", // 标准版 & Agent SDK 版(含 running within...)
|
||||||
|
"You are a Claude agent, built on Anthropic's Claude Agent SDK", // Agent SDK 变体
|
||||||
|
"You are a file search specialist for Claude Code", // Explore Agent 版
|
||||||
|
"You are a helpful AI assistant tasked with summarizing conversations", // Compact 版
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// allowedHeaders 白名单headers(参考CRS项目)
|
// allowedHeaders 白名单headers(参考CRS项目)
|
||||||
@@ -355,17 +366,8 @@ func (s *GatewayService) SelectAccountForModelWithExclusions(ctx context.Context
|
|||||||
return s.selectAccountWithMixedScheduling(ctx, groupID, sessionHash, requestedModel, excludedIDs, platform)
|
return s.selectAccountWithMixedScheduling(ctx, groupID, sessionHash, requestedModel, excludedIDs, platform)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 强制平台模式:优先按分组查找,找不到再查全部该平台账户
|
|
||||||
if hasForcePlatform && groupID != nil {
|
|
||||||
account, err := s.selectAccountForModelWithPlatform(ctx, groupID, sessionHash, requestedModel, excludedIDs, platform)
|
|
||||||
if err == nil {
|
|
||||||
return account, nil
|
|
||||||
}
|
|
||||||
// 分组中找不到,回退查询全部该平台账户
|
|
||||||
groupID = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// antigravity 分组、强制平台模式或无分组使用单平台选择
|
// antigravity 分组、强制平台模式或无分组使用单平台选择
|
||||||
|
// 注意:强制平台模式也必须遵守分组限制,不再回退到全平台查询
|
||||||
return s.selectAccountForModelWithPlatform(ctx, groupID, sessionHash, requestedModel, excludedIDs, platform)
|
return s.selectAccountForModelWithPlatform(ctx, groupID, sessionHash, requestedModel, excludedIDs, platform)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -443,7 +445,8 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
|
|||||||
accountID, err := s.cache.GetSessionAccountID(ctx, sessionHash)
|
accountID, err := s.cache.GetSessionAccountID(ctx, sessionHash)
|
||||||
if err == nil && accountID > 0 && !isExcluded(accountID) {
|
if err == nil && accountID > 0 && !isExcluded(accountID) {
|
||||||
account, err := s.accountRepo.GetByID(ctx, accountID)
|
account, err := s.accountRepo.GetByID(ctx, accountID)
|
||||||
if err == nil && s.isAccountAllowedForPlatform(account, platform, useMixed) &&
|
if err == nil && s.isAccountInGroup(account, groupID) &&
|
||||||
|
s.isAccountAllowedForPlatform(account, platform, useMixed) &&
|
||||||
account.IsSchedulable() &&
|
account.IsSchedulable() &&
|
||||||
(requestedModel == "" || s.isModelSupportedByAccount(account, requestedModel)) {
|
(requestedModel == "" || s.isModelSupportedByAccount(account, requestedModel)) {
|
||||||
result, err := s.tryAcquireAccountSlot(ctx, accountID, account.Concurrency)
|
result, err := s.tryAcquireAccountSlot(ctx, accountID, account.Concurrency)
|
||||||
@@ -660,9 +663,7 @@ func (s *GatewayService) listSchedulableAccounts(ctx context.Context, groupID *i
|
|||||||
accounts, err = s.accountRepo.ListSchedulableByPlatform(ctx, platform)
|
accounts, err = s.accountRepo.ListSchedulableByPlatform(ctx, platform)
|
||||||
} else if groupID != nil {
|
} else if groupID != nil {
|
||||||
accounts, err = s.accountRepo.ListSchedulableByGroupIDAndPlatform(ctx, *groupID, platform)
|
accounts, err = s.accountRepo.ListSchedulableByGroupIDAndPlatform(ctx, *groupID, platform)
|
||||||
if err == nil && len(accounts) == 0 && hasForcePlatform {
|
// 分组内无账号则返回空列表,由上层处理错误,不再回退到全平台查询
|
||||||
accounts, err = s.accountRepo.ListSchedulableByPlatform(ctx, platform)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
accounts, err = s.accountRepo.ListSchedulableByPlatform(ctx, platform)
|
accounts, err = s.accountRepo.ListSchedulableByPlatform(ctx, platform)
|
||||||
}
|
}
|
||||||
@@ -685,6 +686,23 @@ func (s *GatewayService) isAccountAllowedForPlatform(account *Account, platform
|
|||||||
return account.Platform == platform
|
return account.Platform == platform
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isAccountInGroup checks if the account belongs to the specified group.
|
||||||
|
// Returns true if groupID is nil (no group restriction) or account belongs to the group.
|
||||||
|
func (s *GatewayService) isAccountInGroup(account *Account, groupID *int64) bool {
|
||||||
|
if groupID == nil {
|
||||||
|
return true // 无分组限制
|
||||||
|
}
|
||||||
|
if account == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, ag := range account.AccountGroups {
|
||||||
|
if ag.GroupID == *groupID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func (s *GatewayService) tryAcquireAccountSlot(ctx context.Context, accountID int64, maxConcurrency int) (*AcquireResult, error) {
|
func (s *GatewayService) tryAcquireAccountSlot(ctx context.Context, accountID int64, maxConcurrency int) (*AcquireResult, error) {
|
||||||
if s.concurrencyService == nil {
|
if s.concurrencyService == nil {
|
||||||
return &AcquireResult{Acquired: true, ReleaseFunc: func() {}}, nil
|
return &AcquireResult{Acquired: true, ReleaseFunc: func() {}}, nil
|
||||||
@@ -723,8 +741,8 @@ func (s *GatewayService) selectAccountForModelWithPlatform(ctx context.Context,
|
|||||||
if err == nil && accountID > 0 {
|
if err == nil && accountID > 0 {
|
||||||
if _, excluded := excludedIDs[accountID]; !excluded {
|
if _, excluded := excludedIDs[accountID]; !excluded {
|
||||||
account, err := s.accountRepo.GetByID(ctx, accountID)
|
account, err := s.accountRepo.GetByID(ctx, accountID)
|
||||||
// 检查账号平台是否匹配(确保粘性会话不会跨平台)
|
// 检查账号分组归属和平台匹配(确保粘性会话不会跨分组或跨平台)
|
||||||
if err == nil && account.Platform == platform && account.IsSchedulable() && (requestedModel == "" || s.isModelSupportedByAccount(account, requestedModel)) {
|
if err == nil && s.isAccountInGroup(account, groupID) && account.Platform == platform && account.IsSchedulable() && (requestedModel == "" || s.isModelSupportedByAccount(account, requestedModel)) {
|
||||||
if err := s.cache.RefreshSessionTTL(ctx, sessionHash, stickySessionTTL); err != nil {
|
if err := s.cache.RefreshSessionTTL(ctx, sessionHash, stickySessionTTL); err != nil {
|
||||||
log.Printf("refresh session ttl failed: session=%s err=%v", sessionHash, err)
|
log.Printf("refresh session ttl failed: session=%s err=%v", sessionHash, err)
|
||||||
}
|
}
|
||||||
@@ -812,8 +830,8 @@ func (s *GatewayService) selectAccountWithMixedScheduling(ctx context.Context, g
|
|||||||
if err == nil && accountID > 0 {
|
if err == nil && accountID > 0 {
|
||||||
if _, excluded := excludedIDs[accountID]; !excluded {
|
if _, excluded := excludedIDs[accountID]; !excluded {
|
||||||
account, err := s.accountRepo.GetByID(ctx, accountID)
|
account, err := s.accountRepo.GetByID(ctx, accountID)
|
||||||
// 检查账号是否有效:原生平台直接匹配,antigravity 需要启用混合调度
|
// 检查账号分组归属和有效性:原生平台直接匹配,antigravity 需要启用混合调度
|
||||||
if err == nil && account.IsSchedulable() && (requestedModel == "" || s.isModelSupportedByAccount(account, requestedModel)) {
|
if err == nil && s.isAccountInGroup(account, groupID) && account.IsSchedulable() && (requestedModel == "" || s.isModelSupportedByAccount(account, requestedModel)) {
|
||||||
if account.Platform == nativePlatform || (account.Platform == PlatformAntigravity && account.IsMixedSchedulingEnabled()) {
|
if account.Platform == nativePlatform || (account.Platform == PlatformAntigravity && account.IsMixedSchedulingEnabled()) {
|
||||||
if err := s.cache.RefreshSessionTTL(ctx, sessionHash, stickySessionTTL); err != nil {
|
if err := s.cache.RefreshSessionTTL(ctx, sessionHash, stickySessionTTL); err != nil {
|
||||||
log.Printf("refresh session ttl failed: session=%s err=%v", sessionHash, err)
|
log.Printf("refresh session ttl failed: session=%s err=%v", sessionHash, err)
|
||||||
@@ -1013,15 +1031,15 @@ func isClaudeCodeClient(userAgent string, metadataUserID string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// systemIncludesClaudeCodePrompt 检查 system 中是否已包含 Claude Code 提示词
|
// systemIncludesClaudeCodePrompt 检查 system 中是否已包含 Claude Code 提示词
|
||||||
// 支持 string 和 []any 两种格式
|
// 使用前缀匹配支持多种变体(标准版、Agent SDK 版等)
|
||||||
func systemIncludesClaudeCodePrompt(system any) bool {
|
func systemIncludesClaudeCodePrompt(system any) bool {
|
||||||
switch v := system.(type) {
|
switch v := system.(type) {
|
||||||
case string:
|
case string:
|
||||||
return v == claudeCodeSystemPrompt
|
return hasClaudeCodePrefix(v)
|
||||||
case []any:
|
case []any:
|
||||||
for _, item := range v {
|
for _, item := range v {
|
||||||
if m, ok := item.(map[string]any); ok {
|
if m, ok := item.(map[string]any); ok {
|
||||||
if text, ok := m["text"].(string); ok && text == claudeCodeSystemPrompt {
|
if text, ok := m["text"].(string); ok && hasClaudeCodePrefix(text) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1030,6 +1048,16 @@ func systemIncludesClaudeCodePrompt(system any) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hasClaudeCodePrefix 检查文本是否以 Claude Code 提示词的特征前缀开头
|
||||||
|
func hasClaudeCodePrefix(text string) bool {
|
||||||
|
for _, prefix := range claudeCodePromptPrefixes {
|
||||||
|
if strings.HasPrefix(text, prefix) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// injectClaudeCodePrompt 在 system 开头注入 Claude Code 提示词
|
// injectClaudeCodePrompt 在 system 开头注入 Claude Code 提示词
|
||||||
// 处理 null、字符串、数组三种格式
|
// 处理 null、字符串、数组三种格式
|
||||||
func injectClaudeCodePrompt(body []byte, system any) []byte {
|
func injectClaudeCodePrompt(body []byte, system any) []byte {
|
||||||
@@ -1073,6 +1101,124 @@ func injectClaudeCodePrompt(body []byte, system any) []byte {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// enforceCacheControlLimit 强制执行 cache_control 块数量限制(最多 4 个)
|
||||||
|
// 超限时优先从 messages 中移除 cache_control,保护 system 中的缓存控制
|
||||||
|
func enforceCacheControlLimit(body []byte) []byte {
|
||||||
|
var data map[string]any
|
||||||
|
if err := json.Unmarshal(body, &data); err != nil {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算当前 cache_control 块数量
|
||||||
|
count := countCacheControlBlocks(data)
|
||||||
|
if count <= maxCacheControlBlocks {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
// 超限:优先从 messages 中移除,再从 system 中移除
|
||||||
|
for count > maxCacheControlBlocks {
|
||||||
|
if removeCacheControlFromMessages(data) {
|
||||||
|
count--
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if removeCacheControlFromSystem(data) {
|
||||||
|
count--
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := json.Marshal(data)
|
||||||
|
if err != nil {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// countCacheControlBlocks 统计 system 和 messages 中的 cache_control 块数量
|
||||||
|
func countCacheControlBlocks(data map[string]any) int {
|
||||||
|
count := 0
|
||||||
|
|
||||||
|
// 统计 system 中的块
|
||||||
|
if system, ok := data["system"].([]any); ok {
|
||||||
|
for _, item := range system {
|
||||||
|
if m, ok := item.(map[string]any); ok {
|
||||||
|
if _, has := m["cache_control"]; has {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统计 messages 中的块
|
||||||
|
if messages, ok := data["messages"].([]any); ok {
|
||||||
|
for _, msg := range messages {
|
||||||
|
if msgMap, ok := msg.(map[string]any); ok {
|
||||||
|
if content, ok := msgMap["content"].([]any); ok {
|
||||||
|
for _, item := range content {
|
||||||
|
if m, ok := item.(map[string]any); ok {
|
||||||
|
if _, has := m["cache_control"]; has {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeCacheControlFromMessages 从 messages 中移除一个 cache_control(从头开始)
|
||||||
|
// 返回 true 表示成功移除,false 表示没有可移除的
|
||||||
|
func removeCacheControlFromMessages(data map[string]any) bool {
|
||||||
|
messages, ok := data["messages"].([]any)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, msg := range messages {
|
||||||
|
msgMap, ok := msg.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
content, ok := msgMap["content"].([]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, item := range content {
|
||||||
|
if m, ok := item.(map[string]any); ok {
|
||||||
|
if _, has := m["cache_control"]; has {
|
||||||
|
delete(m, "cache_control")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeCacheControlFromSystem 从 system 中移除一个 cache_control(从尾部开始,保护注入的 prompt)
|
||||||
|
// 返回 true 表示成功移除,false 表示没有可移除的
|
||||||
|
func removeCacheControlFromSystem(data map[string]any) bool {
|
||||||
|
system, ok := data["system"].([]any)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从尾部开始移除,保护开头注入的 Claude Code prompt
|
||||||
|
for i := len(system) - 1; i >= 0; i-- {
|
||||||
|
if m, ok := system[i].(map[string]any); ok {
|
||||||
|
if _, has := m["cache_control"]; has {
|
||||||
|
delete(m, "cache_control")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// Forward 转发请求到Claude API
|
// Forward 转发请求到Claude API
|
||||||
func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, parsed *ParsedRequest) (*ForwardResult, error) {
|
func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, parsed *ParsedRequest) (*ForwardResult, error) {
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
@@ -1093,6 +1239,9 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
|
|||||||
body = injectClaudeCodePrompt(body, parsed.System)
|
body = injectClaudeCodePrompt(body, parsed.System)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 强制执行 cache_control 块数量限制(最多 4 个)
|
||||||
|
body = enforceCacheControlLimit(body)
|
||||||
|
|
||||||
// 应用模型映射(仅对apikey类型账号)
|
// 应用模型映射(仅对apikey类型账号)
|
||||||
originalModel := reqModel
|
originalModel := reqModel
|
||||||
if account.Type == AccountTypeAPIKey {
|
if account.Type == AccountTypeAPIKey {
|
||||||
|
|||||||
@@ -319,3 +319,12 @@ func (s *UsageService) GetGlobalStats(ctx context.Context, startTime, endTime ti
|
|||||||
}
|
}
|
||||||
return stats, nil
|
return stats, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetStatsWithFilters returns usage stats with optional filters.
|
||||||
|
func (s *UsageService) GetStatsWithFilters(ctx context.Context, filters usagestats.UsageLogFilters) (*usagestats.UsageStats, error) {
|
||||||
|
stats, err := s.usageRepo.GetStatsWithFilters(ctx, filters)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get usage stats with filters: %w", err)
|
||||||
|
}
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
# Dependency Security
|
|
||||||
|
|
||||||
This document describes how dependency and toolchain security is managed in this repo.
|
|
||||||
|
|
||||||
## Go Toolchain Policy (Pinned to 1.25.5)
|
|
||||||
|
|
||||||
The Go toolchain is pinned to 1.25.5 to address known security issues.
|
|
||||||
|
|
||||||
Locations that MUST stay aligned:
|
|
||||||
- `backend/go.mod`: `go 1.25.5` and `toolchain go1.25.5`
|
|
||||||
- `Dockerfile`: `GOLANG_IMAGE=golang:1.25.5-alpine`
|
|
||||||
- Workflows: use `go-version-file: backend/go.mod` and verify `go1.25.5`
|
|
||||||
|
|
||||||
Update process:
|
|
||||||
1. Change `backend/go.mod` (go + toolchain) to the new patch version.
|
|
||||||
2. Update `Dockerfile` GOLANG_IMAGE to the same patch version.
|
|
||||||
3. Update workflows if needed and keep the `go version` check in place.
|
|
||||||
4. Run `govulncheck` and the CI security scan workflow.
|
|
||||||
|
|
||||||
## Security Scans
|
|
||||||
|
|
||||||
Automated scans run via `.github/workflows/security-scan.yml`:
|
|
||||||
- `govulncheck` for Go dependencies
|
|
||||||
- `gosec` for static security issues
|
|
||||||
- `pnpm audit` for frontend production dependencies
|
|
||||||
|
|
||||||
Policy:
|
|
||||||
- High/Critical findings fail the build unless explicitly exempted.
|
|
||||||
- Exemptions must include mitigation and an expiry date.
|
|
||||||
|
|
||||||
## Audit Exceptions
|
|
||||||
|
|
||||||
Exception list location: `.github/audit-exceptions.yml`
|
|
||||||
|
|
||||||
Required fields:
|
|
||||||
- `package`
|
|
||||||
- `advisory` (GHSA ID or advisory URL from pnpm audit)
|
|
||||||
- `severity`
|
|
||||||
- `mitigation`
|
|
||||||
- `expires_on` (recommended <= 90 days)
|
|
||||||
|
|
||||||
Process:
|
|
||||||
1. Add an exception with mitigation details and an expiry date.
|
|
||||||
2. Ensure the exception is reviewed before expiry.
|
|
||||||
3. Remove the exception when the dependency is upgraded or replaced.
|
|
||||||
|
|
||||||
## Frontend xlsx Mitigation (Plan A)
|
|
||||||
|
|
||||||
Current mitigation:
|
|
||||||
- Use dynamic import so `xlsx` only loads during export.
|
|
||||||
- Keep export access restricted and data scope limited.
|
|
||||||
|
|
||||||
## Rollback Guidance
|
|
||||||
|
|
||||||
If a change causes issues:
|
|
||||||
- Go: revert `backend/go.mod` and `Dockerfile` to the previous version.
|
|
||||||
- Frontend: revert the dynamic import change if needed.
|
|
||||||
- CI: remove exception entries and re-run scans to confirm status.
|
|
||||||
@@ -54,15 +54,21 @@ export async function list(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get usage statistics with optional filters (admin only)
|
* Get usage statistics with optional filters (admin only)
|
||||||
* @param params - Query parameters (user_id, api_key_id, period/date range)
|
* @param params - Query parameters for filtering
|
||||||
* @returns Usage statistics
|
* @returns Usage statistics
|
||||||
*/
|
*/
|
||||||
export async function getStats(params: {
|
export async function getStats(params: {
|
||||||
user_id?: number
|
user_id?: number
|
||||||
api_key_id?: number
|
api_key_id?: number
|
||||||
|
account_id?: number
|
||||||
|
group_id?: number
|
||||||
|
model?: string
|
||||||
|
stream?: boolean
|
||||||
|
billing_type?: number
|
||||||
period?: string
|
period?: string
|
||||||
start_date?: string
|
start_date?: string
|
||||||
end_date?: string
|
end_date?: string
|
||||||
|
timezone?: string
|
||||||
}): Promise<AdminUsageStatsResponse> {
|
}): Promise<AdminUsageStatsResponse> {
|
||||||
const { data } = await apiClient.get<AdminUsageStatsResponse>('/admin/usage/stats', {
|
const { data } = await apiClient.get<AdminUsageStatsResponse>('/admin/usage/stats', {
|
||||||
params
|
params
|
||||||
|
|||||||
@@ -1140,7 +1140,7 @@ const handleSubmit = async () => {
|
|||||||
emit('updated')
|
emit('updated')
|
||||||
handleClose()
|
handleClose()
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
appStore.showError(error.response?.data?.detail || t('admin.accounts.failedToUpdate'))
|
appStore.showError(error.response?.data?.message || error.response?.data?.detail || t('admin.accounts.failedToUpdate'))
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false
|
submitting.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@
|
|||||||
class="input pr-8"
|
class="input pr-8"
|
||||||
:placeholder="t('admin.usage.searchApiKeyPlaceholder')"
|
:placeholder="t('admin.usage.searchApiKeyPlaceholder')"
|
||||||
@input="debounceApiKeySearch"
|
@input="debounceApiKeySearch"
|
||||||
@focus="showApiKeyDropdown = true"
|
@focus="onApiKeyFocus"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
v-if="filters.api_key_id"
|
v-if="filters.api_key_id"
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
<div
|
<div
|
||||||
v-if="showApiKeyDropdown && (apiKeyResults.length > 0 || apiKeyKeyword)"
|
v-if="showApiKeyDropdown && apiKeyResults.length > 0"
|
||||||
class="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border bg-white shadow-lg dark:bg-gray-800"
|
class="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border bg-white shadow-lg dark:bg-gray-800"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
@@ -85,9 +85,40 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Account Filter -->
|
<!-- Account Filter -->
|
||||||
<div class="w-full sm:w-auto sm:min-w-[220px]">
|
<div ref="accountSearchRef" class="usage-filter-dropdown relative w-full sm:w-auto sm:min-w-[220px]">
|
||||||
<label class="input-label">{{ t('admin.usage.account') }}</label>
|
<label class="input-label">{{ t('admin.usage.account') }}</label>
|
||||||
<Select v-model="filters.account_id" :options="accountOptions" searchable @change="emitChange" />
|
<input
|
||||||
|
v-model="accountKeyword"
|
||||||
|
type="text"
|
||||||
|
class="input pr-8"
|
||||||
|
:placeholder="t('admin.usage.searchAccountPlaceholder')"
|
||||||
|
@input="debounceAccountSearch"
|
||||||
|
@focus="showAccountDropdown = true"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
v-if="filters.account_id"
|
||||||
|
type="button"
|
||||||
|
@click="clearAccount"
|
||||||
|
class="absolute right-2 top-9 text-gray-400"
|
||||||
|
aria-label="Clear account filter"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
v-if="showAccountDropdown && (accountResults.length > 0 || accountKeyword)"
|
||||||
|
class="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border bg-white shadow-lg dark:bg-gray-800"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="a in accountResults"
|
||||||
|
:key="a.id"
|
||||||
|
type="button"
|
||||||
|
@click="selectAccount(a)"
|
||||||
|
class="w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
<span class="truncate">{{ a.name }}</span>
|
||||||
|
<span class="ml-2 text-xs text-gray-400">#{{ a.id }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Stream Type Filter -->
|
<!-- Stream Type Filter -->
|
||||||
@@ -166,6 +197,7 @@ const filters = toRef(props, 'modelValue')
|
|||||||
|
|
||||||
const userSearchRef = ref<HTMLElement | null>(null)
|
const userSearchRef = ref<HTMLElement | null>(null)
|
||||||
const apiKeySearchRef = ref<HTMLElement | null>(null)
|
const apiKeySearchRef = ref<HTMLElement | null>(null)
|
||||||
|
const accountSearchRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
const userKeyword = ref('')
|
const userKeyword = ref('')
|
||||||
const userResults = ref<SimpleUser[]>([])
|
const userResults = ref<SimpleUser[]>([])
|
||||||
@@ -177,9 +209,17 @@ const apiKeyResults = ref<SimpleApiKey[]>([])
|
|||||||
const showApiKeyDropdown = ref(false)
|
const showApiKeyDropdown = ref(false)
|
||||||
let apiKeySearchTimeout: ReturnType<typeof setTimeout> | null = null
|
let apiKeySearchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
interface SimpleAccount {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
const accountKeyword = ref('')
|
||||||
|
const accountResults = ref<SimpleAccount[]>([])
|
||||||
|
const showAccountDropdown = ref(false)
|
||||||
|
let accountSearchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
const modelOptions = ref<SelectOption[]>([{ value: null, label: t('admin.usage.allModels') }])
|
const modelOptions = ref<SelectOption[]>([{ value: null, label: t('admin.usage.allModels') }])
|
||||||
const groupOptions = ref<SelectOption[]>([{ value: null, label: t('admin.usage.allGroups') }])
|
const groupOptions = ref<SelectOption[]>([{ value: null, label: t('admin.usage.allGroups') }])
|
||||||
const accountOptions = ref<SelectOption[]>([{ value: null, label: t('admin.usage.allAccounts') }])
|
|
||||||
|
|
||||||
const streamTypeOptions = ref<SelectOption[]>([
|
const streamTypeOptions = ref<SelectOption[]>([
|
||||||
{ value: null, label: t('admin.usage.allTypes') },
|
{ value: null, label: t('admin.usage.allTypes') },
|
||||||
@@ -223,14 +263,10 @@ const debounceUserSearch = () => {
|
|||||||
const debounceApiKeySearch = () => {
|
const debounceApiKeySearch = () => {
|
||||||
if (apiKeySearchTimeout) clearTimeout(apiKeySearchTimeout)
|
if (apiKeySearchTimeout) clearTimeout(apiKeySearchTimeout)
|
||||||
apiKeySearchTimeout = setTimeout(async () => {
|
apiKeySearchTimeout = setTimeout(async () => {
|
||||||
if (!apiKeyKeyword.value) {
|
|
||||||
apiKeyResults.value = []
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
apiKeyResults.value = await adminAPI.usage.searchApiKeys(
|
apiKeyResults.value = await adminAPI.usage.searchApiKeys(
|
||||||
filters.value.user_id,
|
filters.value.user_id,
|
||||||
apiKeyKeyword.value
|
apiKeyKeyword.value || ''
|
||||||
)
|
)
|
||||||
} catch {
|
} catch {
|
||||||
apiKeyResults.value = []
|
apiKeyResults.value = []
|
||||||
@@ -238,11 +274,19 @@ const debounceApiKeySearch = () => {
|
|||||||
}, 300)
|
}, 300)
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectUser = (u: SimpleUser) => {
|
const selectUser = async (u: SimpleUser) => {
|
||||||
userKeyword.value = u.email
|
userKeyword.value = u.email
|
||||||
showUserDropdown.value = false
|
showUserDropdown.value = false
|
||||||
filters.value.user_id = u.id
|
filters.value.user_id = u.id
|
||||||
clearApiKey()
|
clearApiKey()
|
||||||
|
|
||||||
|
// Auto-load API keys for this user
|
||||||
|
try {
|
||||||
|
apiKeyResults.value = await adminAPI.usage.searchApiKeys(u.id, '')
|
||||||
|
} catch {
|
||||||
|
apiKeyResults.value = []
|
||||||
|
}
|
||||||
|
|
||||||
emitChange()
|
emitChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,15 +318,56 @@ const onClearApiKey = () => {
|
|||||||
emitChange()
|
emitChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const debounceAccountSearch = () => {
|
||||||
|
if (accountSearchTimeout) clearTimeout(accountSearchTimeout)
|
||||||
|
accountSearchTimeout = setTimeout(async () => {
|
||||||
|
if (!accountKeyword.value) {
|
||||||
|
accountResults.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await adminAPI.accounts.list(1, 20, { search: accountKeyword.value })
|
||||||
|
accountResults.value = res.items.map((a) => ({ id: a.id, name: a.name }))
|
||||||
|
} catch {
|
||||||
|
accountResults.value = []
|
||||||
|
}
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectAccount = (a: SimpleAccount) => {
|
||||||
|
accountKeyword.value = a.name
|
||||||
|
showAccountDropdown.value = false
|
||||||
|
filters.value.account_id = a.id
|
||||||
|
emitChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearAccount = () => {
|
||||||
|
accountKeyword.value = ''
|
||||||
|
accountResults.value = []
|
||||||
|
showAccountDropdown.value = false
|
||||||
|
filters.value.account_id = undefined
|
||||||
|
emitChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onApiKeyFocus = () => {
|
||||||
|
showApiKeyDropdown.value = true
|
||||||
|
// Trigger search if no results yet
|
||||||
|
if (apiKeyResults.value.length === 0) {
|
||||||
|
debounceApiKeySearch()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const onDocumentClick = (e: MouseEvent) => {
|
const onDocumentClick = (e: MouseEvent) => {
|
||||||
const target = e.target as Node | null
|
const target = e.target as Node | null
|
||||||
if (!target) return
|
if (!target) return
|
||||||
|
|
||||||
const clickedInsideUser = userSearchRef.value?.contains(target) ?? false
|
const clickedInsideUser = userSearchRef.value?.contains(target) ?? false
|
||||||
const clickedInsideApiKey = apiKeySearchRef.value?.contains(target) ?? false
|
const clickedInsideApiKey = apiKeySearchRef.value?.contains(target) ?? false
|
||||||
|
const clickedInsideAccount = accountSearchRef.value?.contains(target) ?? false
|
||||||
|
|
||||||
if (!clickedInsideUser) showUserDropdown.value = false
|
if (!clickedInsideUser) showUserDropdown.value = false
|
||||||
if (!clickedInsideApiKey) showApiKeyDropdown.value = false
|
if (!clickedInsideApiKey) showApiKeyDropdown.value = false
|
||||||
|
if (!clickedInsideAccount) showAccountDropdown.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -321,20 +406,27 @@ watch(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => filters.value.account_id,
|
||||||
|
(accountId) => {
|
||||||
|
if (!accountId) {
|
||||||
|
accountKeyword.value = ''
|
||||||
|
accountResults.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
document.addEventListener('click', onDocumentClick)
|
document.addEventListener('click', onDocumentClick)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [gs, ms, as] = await Promise.all([
|
const [gs, ms] = await Promise.all([
|
||||||
adminAPI.groups.list(1, 1000),
|
adminAPI.groups.list(1, 1000),
|
||||||
adminAPI.dashboard.getModelStats({ start_date: props.startDate, end_date: props.endDate }),
|
adminAPI.dashboard.getModelStats({ start_date: props.startDate, end_date: props.endDate })
|
||||||
adminAPI.accounts.list(1, 1000)
|
|
||||||
])
|
])
|
||||||
|
|
||||||
groupOptions.value.push(...gs.items.map((g: any) => ({ value: g.id, label: g.name })))
|
groupOptions.value.push(...gs.items.map((g: any) => ({ value: g.id, label: g.name })))
|
||||||
|
|
||||||
accountOptions.value.push(...as.items.map((a: any) => ({ value: a.id, label: a.name })))
|
|
||||||
|
|
||||||
const uniqueModels = new Set<string>()
|
const uniqueModels = new Set<string>()
|
||||||
ms.models?.forEach((s: any) => s.model && uniqueModels.add(s.model))
|
ms.models?.forEach((s: any) => s.model && uniqueModels.add(s.model))
|
||||||
modelOptions.value.push(
|
modelOptions.value.push(
|
||||||
|
|||||||
@@ -4,17 +4,34 @@
|
|||||||
<div class="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30 text-blue-600">
|
<div class="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30 text-blue-600">
|
||||||
<Icon name="document" size="md" />
|
<Icon name="document" size="md" />
|
||||||
</div>
|
</div>
|
||||||
<div><p class="text-xs font-medium text-gray-500">{{ t('usage.totalRequests') }}</p><p class="text-xl font-bold">{{ stats?.total_requests?.toLocaleString() || '0' }}</p></div>
|
<div>
|
||||||
|
<p class="text-xs font-medium text-gray-500">{{ t('usage.totalRequests') }}</p>
|
||||||
|
<p class="text-xl font-bold">{{ stats?.total_requests?.toLocaleString() || '0' }}</p>
|
||||||
|
<p class="text-xs text-gray-400">{{ t('usage.inSelectedRange') }}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card p-4 flex items-center gap-3">
|
<div class="card p-4 flex items-center gap-3">
|
||||||
<div class="rounded-lg bg-amber-100 p-2 dark:bg-amber-900/30 text-amber-600"><svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m21 7.5-9-5.25L3 7.5m18 0-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9" /></svg></div>
|
<div class="rounded-lg bg-amber-100 p-2 dark:bg-amber-900/30 text-amber-600"><svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m21 7.5-9-5.25L3 7.5m18 0-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9" /></svg></div>
|
||||||
<div><p class="text-xs font-medium text-gray-500">{{ t('usage.totalTokens') }}</p><p class="text-xl font-bold">{{ formatTokens(stats?.total_tokens || 0) }}</p></div>
|
<div>
|
||||||
|
<p class="text-xs font-medium text-gray-500">{{ t('usage.totalTokens') }}</p>
|
||||||
|
<p class="text-xl font-bold">{{ formatTokens(stats?.total_tokens || 0) }}</p>
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
{{ t('usage.in') }}: {{ formatTokens(stats?.total_input_tokens || 0) }} /
|
||||||
|
{{ t('usage.out') }}: {{ formatTokens(stats?.total_output_tokens || 0) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card p-4 flex items-center gap-3">
|
<div class="card p-4 flex items-center gap-3">
|
||||||
<div class="rounded-lg bg-green-100 p-2 dark:bg-green-900/30 text-green-600">
|
<div class="rounded-lg bg-green-100 p-2 dark:bg-green-900/30 text-green-600">
|
||||||
<Icon name="dollar" size="md" />
|
<Icon name="dollar" size="md" />
|
||||||
</div>
|
</div>
|
||||||
<div><p class="text-xs font-medium text-gray-500">{{ t('usage.totalCost') }}</p><p class="text-xl font-bold text-green-600">${{ (stats?.total_actual_cost || 0).toFixed(4) }}</p></div>
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="text-xs font-medium text-gray-500">{{ t('usage.totalCost') }}</p>
|
||||||
|
<p class="text-xl font-bold text-green-600">${{ (stats?.total_actual_cost || 0).toFixed(4) }}</p>
|
||||||
|
<p class="text-xs text-gray-400">
|
||||||
|
{{ t('usage.standardCost') }}: <span class="line-through">${{ (stats?.total_cost || 0).toFixed(4) }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card p-4 flex items-center gap-3">
|
<div class="card p-4 flex items-center gap-3">
|
||||||
<div class="rounded-lg bg-purple-100 p-2 dark:bg-purple-900/30 text-purple-600">
|
<div class="rounded-lg bg-purple-100 p-2 dark:bg-purple-900/30 text-purple-600">
|
||||||
|
|||||||
@@ -44,32 +44,56 @@
|
|||||||
<span class="text-gray-400">({{ row.image_size || '2K' }})</span>
|
<span class="text-gray-400">({{ row.image_size || '2K' }})</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- Token 请求 -->
|
<!-- Token 请求 -->
|
||||||
<div v-else class="space-y-1 text-sm">
|
<div v-else class="flex items-center gap-1.5">
|
||||||
<div class="flex items-center gap-2">
|
<div class="space-y-1 text-sm">
|
||||||
<div class="inline-flex items-center gap-1">
|
<div class="flex items-center gap-2">
|
||||||
<Icon name="arrowDown" size="sm" class="h-3.5 w-3.5 text-emerald-500" />
|
<div class="inline-flex items-center gap-1">
|
||||||
<span class="font-medium text-gray-900 dark:text-white">{{ row.input_tokens?.toLocaleString() || 0 }}</span>
|
<Icon name="arrowDown" size="sm" class="h-3.5 w-3.5 text-emerald-500" />
|
||||||
|
<span class="font-medium text-gray-900 dark:text-white">{{ row.input_tokens?.toLocaleString() || 0 }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="inline-flex items-center gap-1">
|
||||||
|
<Icon name="arrowUp" size="sm" class="h-3.5 w-3.5 text-violet-500" />
|
||||||
|
<span class="font-medium text-gray-900 dark:text-white">{{ row.output_tokens?.toLocaleString() || 0 }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="inline-flex items-center gap-1">
|
<div v-if="row.cache_read_tokens > 0 || row.cache_creation_tokens > 0" class="flex items-center gap-2">
|
||||||
<Icon name="arrowUp" size="sm" class="h-3.5 w-3.5 text-violet-500" />
|
<div v-if="row.cache_read_tokens > 0" class="inline-flex items-center gap-1">
|
||||||
<span class="font-medium text-gray-900 dark:text-white">{{ row.output_tokens?.toLocaleString() || 0 }}</span>
|
<svg class="h-3.5 w-3.5 text-sky-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" /></svg>
|
||||||
|
<span class="font-medium text-sky-600 dark:text-sky-400">{{ formatCacheTokens(row.cache_read_tokens) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="row.cache_creation_tokens > 0" class="inline-flex items-center gap-1">
|
||||||
|
<svg class="h-3.5 w-3.5 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg>
|
||||||
|
<span class="font-medium text-amber-600 dark:text-amber-400">{{ formatCacheTokens(row.cache_creation_tokens) }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="row.cache_read_tokens > 0 || row.cache_creation_tokens > 0" class="flex items-center gap-2">
|
<!-- Token Detail Tooltip -->
|
||||||
<div v-if="row.cache_read_tokens > 0" class="inline-flex items-center gap-1">
|
<div
|
||||||
<svg class="h-3.5 w-3.5 text-sky-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" /></svg>
|
class="group relative"
|
||||||
<span class="font-medium text-sky-600 dark:text-sky-400">{{ formatCacheTokens(row.cache_read_tokens) }}</span>
|
@mouseenter="showTokenTooltip($event, row)"
|
||||||
</div>
|
@mouseleave="hideTokenTooltip"
|
||||||
<div v-if="row.cache_creation_tokens > 0" class="inline-flex items-center gap-1">
|
>
|
||||||
<svg class="h-3.5 w-3.5 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg>
|
<div class="flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-gray-100 transition-colors group-hover:bg-blue-100 dark:bg-gray-700 dark:group-hover:bg-blue-900/50">
|
||||||
<span class="font-medium text-amber-600 dark:text-amber-400">{{ formatCacheTokens(row.cache_creation_tokens) }}</span>
|
<Icon name="infoCircle" size="xs" class="text-gray-400 group-hover:text-blue-500 dark:text-gray-500 dark:group-hover:text-blue-400" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #cell-cost="{ row }">
|
<template #cell-cost="{ row }">
|
||||||
<span class="font-medium text-green-600 dark:text-green-400">${{ row.actual_cost?.toFixed(6) || '0.000000' }}</span>
|
<div class="flex items-center gap-1.5 text-sm">
|
||||||
|
<span class="font-medium text-green-600 dark:text-green-400">${{ row.actual_cost?.toFixed(6) || '0.000000' }}</span>
|
||||||
|
<!-- Cost Detail Tooltip -->
|
||||||
|
<div
|
||||||
|
class="group relative"
|
||||||
|
@mouseenter="showTooltip($event, row)"
|
||||||
|
@mouseleave="hideTooltip"
|
||||||
|
>
|
||||||
|
<div class="flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-gray-100 transition-colors group-hover:bg-blue-100 dark:bg-gray-700 dark:group-hover:bg-blue-900/50">
|
||||||
|
<Icon name="infoCircle" size="xs" class="text-gray-400 group-hover:text-blue-500 dark:text-gray-500 dark:group-hover:text-blue-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #cell-billing_type="{ row }">
|
<template #cell-billing_type="{ row }">
|
||||||
@@ -106,6 +130,98 @@
|
|||||||
</DataTable>
|
</DataTable>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Token Tooltip Portal -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="tokenTooltipVisible"
|
||||||
|
class="fixed z-[9999] pointer-events-none -translate-y-1/2"
|
||||||
|
:style="{
|
||||||
|
left: tokenTooltipPosition.x + 'px',
|
||||||
|
top: tokenTooltipPosition.y + 'px'
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="whitespace-nowrap rounded-lg border border-gray-700 bg-gray-900 px-3 py-2.5 text-xs text-white shadow-xl dark:border-gray-600 dark:bg-gray-800">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<div>
|
||||||
|
<div class="text-xs font-semibold text-gray-300 mb-1">{{ t('usage.tokenDetails') }}</div>
|
||||||
|
<div v-if="tokenTooltipData && tokenTooltipData.input_tokens > 0" class="flex items-center justify-between gap-4">
|
||||||
|
<span class="text-gray-400">{{ t('admin.usage.inputTokens') }}</span>
|
||||||
|
<span class="font-medium text-white">{{ tokenTooltipData.input_tokens.toLocaleString() }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="tokenTooltipData && tokenTooltipData.output_tokens > 0" class="flex items-center justify-between gap-4">
|
||||||
|
<span class="text-gray-400">{{ t('admin.usage.outputTokens') }}</span>
|
||||||
|
<span class="font-medium text-white">{{ tokenTooltipData.output_tokens.toLocaleString() }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="tokenTooltipData && tokenTooltipData.cache_creation_tokens > 0" class="flex items-center justify-between gap-4">
|
||||||
|
<span class="text-gray-400">{{ t('admin.usage.cacheCreationTokens') }}</span>
|
||||||
|
<span class="font-medium text-white">{{ tokenTooltipData.cache_creation_tokens.toLocaleString() }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="tokenTooltipData && tokenTooltipData.cache_read_tokens > 0" class="flex items-center justify-between gap-4">
|
||||||
|
<span class="text-gray-400">{{ t('admin.usage.cacheReadTokens') }}</span>
|
||||||
|
<span class="font-medium text-white">{{ tokenTooltipData.cache_read_tokens.toLocaleString() }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between gap-6 border-t border-gray-700 pt-1.5">
|
||||||
|
<span class="text-gray-400">{{ t('usage.totalTokens') }}</span>
|
||||||
|
<span class="font-semibold text-blue-400">{{ ((tokenTooltipData?.input_tokens || 0) + (tokenTooltipData?.output_tokens || 0) + (tokenTooltipData?.cache_creation_tokens || 0) + (tokenTooltipData?.cache_read_tokens || 0)).toLocaleString() }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="absolute right-full top-1/2 h-0 w-0 -translate-y-1/2 border-b-[6px] border-r-[6px] border-t-[6px] border-b-transparent border-r-gray-900 border-t-transparent dark:border-r-gray-800"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
|
<!-- Cost Tooltip Portal -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="tooltipVisible"
|
||||||
|
class="fixed z-[9999] pointer-events-none -translate-y-1/2"
|
||||||
|
:style="{
|
||||||
|
left: tooltipPosition.x + 'px',
|
||||||
|
top: tooltipPosition.y + 'px'
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="whitespace-nowrap rounded-lg border border-gray-700 bg-gray-900 px-3 py-2.5 text-xs text-white shadow-xl dark:border-gray-600 dark:bg-gray-800">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<!-- Cost Breakdown -->
|
||||||
|
<div class="mb-2 border-b border-gray-700 pb-1.5">
|
||||||
|
<div class="text-xs font-semibold text-gray-300 mb-1">{{ t('usage.costDetails') }}</div>
|
||||||
|
<div v-if="tooltipData && tooltipData.input_cost > 0" class="flex items-center justify-between gap-4">
|
||||||
|
<span class="text-gray-400">{{ t('admin.usage.inputCost') }}</span>
|
||||||
|
<span class="font-medium text-white">${{ tooltipData.input_cost.toFixed(6) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="tooltipData && tooltipData.output_cost > 0" class="flex items-center justify-between gap-4">
|
||||||
|
<span class="text-gray-400">{{ t('admin.usage.outputCost') }}</span>
|
||||||
|
<span class="font-medium text-white">${{ tooltipData.output_cost.toFixed(6) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="tooltipData && tooltipData.cache_creation_cost > 0" class="flex items-center justify-between gap-4">
|
||||||
|
<span class="text-gray-400">{{ t('admin.usage.cacheCreationCost') }}</span>
|
||||||
|
<span class="font-medium text-white">${{ tooltipData.cache_creation_cost.toFixed(6) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="tooltipData && tooltipData.cache_read_cost > 0" class="flex items-center justify-between gap-4">
|
||||||
|
<span class="text-gray-400">{{ t('admin.usage.cacheReadCost') }}</span>
|
||||||
|
<span class="font-medium text-white">${{ tooltipData.cache_read_cost.toFixed(6) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Rate and Summary -->
|
||||||
|
<div class="flex items-center justify-between gap-6">
|
||||||
|
<span class="text-gray-400">{{ t('usage.rate') }}</span>
|
||||||
|
<span class="font-semibold text-blue-400">{{ (tooltipData?.rate_multiplier || 1).toFixed(2) }}x</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between gap-6">
|
||||||
|
<span class="text-gray-400">{{ t('usage.original') }}</span>
|
||||||
|
<span class="font-medium text-white">${{ tooltipData?.total_cost?.toFixed(6) || '0.000000' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between gap-6 border-t border-gray-700 pt-1.5">
|
||||||
|
<span class="text-gray-400">{{ t('usage.billed') }}</span>
|
||||||
|
<span class="font-semibold text-green-400">${{ tooltipData?.actual_cost?.toFixed(6) || '0.000000' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="absolute right-full top-1/2 h-0 w-0 -translate-y-1/2 border-b-[6px] border-r-[6px] border-t-[6px] border-b-transparent border-r-gray-900 border-t-transparent dark:border-r-gray-800"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -116,12 +232,23 @@ import { useAppStore } from '@/stores/app'
|
|||||||
import DataTable from '@/components/common/DataTable.vue'
|
import DataTable from '@/components/common/DataTable.vue'
|
||||||
import EmptyState from '@/components/common/EmptyState.vue'
|
import EmptyState from '@/components/common/EmptyState.vue'
|
||||||
import Icon from '@/components/icons/Icon.vue'
|
import Icon from '@/components/icons/Icon.vue'
|
||||||
|
import type { UsageLog } from '@/types'
|
||||||
|
|
||||||
defineProps(['data', 'loading'])
|
defineProps(['data', 'loading'])
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
const copiedRequestId = ref<string | null>(null)
|
const copiedRequestId = ref<string | null>(null)
|
||||||
|
|
||||||
|
// Tooltip state - cost
|
||||||
|
const tooltipVisible = ref(false)
|
||||||
|
const tooltipPosition = ref({ x: 0, y: 0 })
|
||||||
|
const tooltipData = ref<UsageLog | null>(null)
|
||||||
|
|
||||||
|
// Tooltip state - token
|
||||||
|
const tokenTooltipVisible = ref(false)
|
||||||
|
const tokenTooltipPosition = ref({ x: 0, y: 0 })
|
||||||
|
const tokenTooltipData = ref<UsageLog | null>(null)
|
||||||
|
|
||||||
const cols = computed(() => [
|
const cols = computed(() => [
|
||||||
{ key: 'user', label: t('admin.usage.user'), sortable: false },
|
{ key: 'user', label: t('admin.usage.user'), sortable: false },
|
||||||
{ key: 'api_key', label: t('usage.apiKeyFilter'), sortable: false },
|
{ key: 'api_key', label: t('usage.apiKeyFilter'), sortable: false },
|
||||||
@@ -160,4 +287,34 @@ const copyRequestId = async (requestId: string) => {
|
|||||||
appStore.showError(t('common.copyFailed'))
|
appStore.showError(t('common.copyFailed'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cost tooltip functions
|
||||||
|
const showTooltip = (event: MouseEvent, row: UsageLog) => {
|
||||||
|
const target = event.currentTarget as HTMLElement
|
||||||
|
const rect = target.getBoundingClientRect()
|
||||||
|
tooltipData.value = row
|
||||||
|
tooltipPosition.value.x = rect.right + 8
|
||||||
|
tooltipPosition.value.y = rect.top + rect.height / 2
|
||||||
|
tooltipVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const hideTooltip = () => {
|
||||||
|
tooltipVisible.value = false
|
||||||
|
tooltipData.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token tooltip functions
|
||||||
|
const showTokenTooltip = (event: MouseEvent, row: UsageLog) => {
|
||||||
|
const target = event.currentTarget as HTMLElement
|
||||||
|
const rect = target.getBoundingClientRect()
|
||||||
|
tokenTooltipData.value = row
|
||||||
|
tokenTooltipPosition.value.x = rect.right + 8
|
||||||
|
tokenTooltipPosition.value.y = rect.top + rect.height / 2
|
||||||
|
tokenTooltipVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const hideTokenTooltip = () => {
|
||||||
|
tokenTooltipVisible.value = false
|
||||||
|
tokenTooltipData.value = null
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -105,10 +105,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Code Content -->
|
<!-- Code Content -->
|
||||||
<pre class="p-4 text-sm font-mono text-gray-100 overflow-x-auto">
|
<pre class="p-4 text-sm font-mono text-gray-100 overflow-x-auto"><code v-if="file.highlighted" v-html="file.highlighted"></code><code v-else v-text="file.content"></code></pre>
|
||||||
<code v-if="file.highlighted" v-html="file.highlighted"></code>
|
|
||||||
<code v-else v-text="file.content"></code>
|
|
||||||
</pre>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -376,6 +376,8 @@ export default {
|
|||||||
usage: {
|
usage: {
|
||||||
title: 'Usage Records',
|
title: 'Usage Records',
|
||||||
description: 'View and analyze your API usage history',
|
description: 'View and analyze your API usage history',
|
||||||
|
costDetails: 'Cost Breakdown',
|
||||||
|
tokenDetails: 'Token Breakdown',
|
||||||
totalRequests: 'Total Requests',
|
totalRequests: 'Total Requests',
|
||||||
totalTokens: 'Total Tokens',
|
totalTokens: 'Total Tokens',
|
||||||
totalCost: 'Total Cost',
|
totalCost: 'Total Cost',
|
||||||
@@ -1691,6 +1693,7 @@ export default {
|
|||||||
userFilter: 'User',
|
userFilter: 'User',
|
||||||
searchUserPlaceholder: 'Search user by email...',
|
searchUserPlaceholder: 'Search user by email...',
|
||||||
searchApiKeyPlaceholder: 'Search API key by name...',
|
searchApiKeyPlaceholder: 'Search API key by name...',
|
||||||
|
searchAccountPlaceholder: 'Search account by name...',
|
||||||
selectedUser: 'Selected',
|
selectedUser: 'Selected',
|
||||||
user: 'User',
|
user: 'User',
|
||||||
account: 'Account',
|
account: 'Account',
|
||||||
|
|||||||
@@ -373,6 +373,8 @@ export default {
|
|||||||
usage: {
|
usage: {
|
||||||
title: '使用记录',
|
title: '使用记录',
|
||||||
description: '查看和分析您的 API 使用历史',
|
description: '查看和分析您的 API 使用历史',
|
||||||
|
costDetails: '成本明细',
|
||||||
|
tokenDetails: 'Token 明细',
|
||||||
totalRequests: '总请求数',
|
totalRequests: '总请求数',
|
||||||
totalTokens: '总 Token',
|
totalTokens: '总 Token',
|
||||||
totalCost: '总消费',
|
totalCost: '总消费',
|
||||||
@@ -1836,6 +1838,7 @@ export default {
|
|||||||
userFilter: '用户',
|
userFilter: '用户',
|
||||||
searchUserPlaceholder: '按邮箱搜索用户...',
|
searchUserPlaceholder: '按邮箱搜索用户...',
|
||||||
searchApiKeyPlaceholder: '按名称搜索 API 密钥...',
|
searchApiKeyPlaceholder: '按名称搜索 API 密钥...',
|
||||||
|
searchAccountPlaceholder: '按名称搜索账号...',
|
||||||
selectedUser: '已选择',
|
selectedUser: '已选择',
|
||||||
user: '用户',
|
user: '用户',
|
||||||
account: '账户',
|
account: '账户',
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
<AccountTableFilters
|
<AccountTableFilters
|
||||||
v-model:searchQuery="params.search"
|
v-model:searchQuery="params.search"
|
||||||
:filters="params"
|
:filters="params"
|
||||||
|
@update:filters="(newFilters) => Object.assign(params, newFilters)"
|
||||||
@change="reload"
|
@change="reload"
|
||||||
@update:searchQuery="debouncedReload"
|
@update:searchQuery="debouncedReload"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -85,11 +85,48 @@ const exportToExcel = async () => {
|
|||||||
if (all.length >= total || res.items.length < 100) break; p++
|
if (all.length >= total || res.items.length < 100) break; p++
|
||||||
}
|
}
|
||||||
if(!c.signal.aborted) {
|
if(!c.signal.aborted) {
|
||||||
// 动态加载 xlsx,降低首屏包体并减少高危依赖的常驻暴露面。
|
|
||||||
const XLSX = await import('xlsx')
|
const XLSX = await import('xlsx')
|
||||||
const ws = XLSX.utils.json_to_sheet(all); const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, ws, 'Usage')
|
const headers = [
|
||||||
saveAs(new Blob([XLSX.write(wb, { bookType: 'xlsx', type: 'array' })], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }), `usage_${Date.now()}.xlsx`)
|
t('usage.time'), t('admin.usage.user'), t('usage.apiKeyFilter'),
|
||||||
appStore.showSuccess('Export Success')
|
t('admin.usage.account'), t('usage.model'), t('admin.usage.group'),
|
||||||
|
t('usage.type'),
|
||||||
|
t('admin.usage.inputTokens'), t('admin.usage.outputTokens'),
|
||||||
|
t('admin.usage.cacheReadTokens'), t('admin.usage.cacheCreationTokens'),
|
||||||
|
t('admin.usage.inputCost'), t('admin.usage.outputCost'),
|
||||||
|
t('admin.usage.cacheReadCost'), t('admin.usage.cacheCreationCost'),
|
||||||
|
t('usage.rate'), t('usage.original'), t('usage.billed'),
|
||||||
|
t('usage.billingType'), t('usage.firstToken'), t('usage.duration'),
|
||||||
|
t('admin.usage.requestId')
|
||||||
|
]
|
||||||
|
const rows = all.map(log => [
|
||||||
|
log.created_at,
|
||||||
|
log.user?.email || '',
|
||||||
|
log.api_key?.name || '',
|
||||||
|
log.account?.name || '',
|
||||||
|
log.model,
|
||||||
|
log.group?.name || '',
|
||||||
|
log.stream ? t('usage.stream') : t('usage.sync'),
|
||||||
|
log.input_tokens,
|
||||||
|
log.output_tokens,
|
||||||
|
log.cache_read_tokens,
|
||||||
|
log.cache_creation_tokens,
|
||||||
|
log.input_cost?.toFixed(6) || '0.000000',
|
||||||
|
log.output_cost?.toFixed(6) || '0.000000',
|
||||||
|
log.cache_read_cost?.toFixed(6) || '0.000000',
|
||||||
|
log.cache_creation_cost?.toFixed(6) || '0.000000',
|
||||||
|
log.rate_multiplier?.toFixed(2) || '1.00',
|
||||||
|
log.total_cost?.toFixed(6) || '0.000000',
|
||||||
|
log.actual_cost?.toFixed(6) || '0.000000',
|
||||||
|
log.billing_type === 1 ? t('usage.subscription') : t('usage.balance'),
|
||||||
|
log.first_token_ms ?? '',
|
||||||
|
log.duration_ms,
|
||||||
|
log.request_id || ''
|
||||||
|
])
|
||||||
|
const ws = XLSX.utils.aoa_to_sheet([headers, ...rows])
|
||||||
|
const wb = XLSX.utils.book_new()
|
||||||
|
XLSX.utils.book_append_sheet(wb, ws, 'Usage')
|
||||||
|
saveAs(new Blob([XLSX.write(wb, { bookType: 'xlsx', type: 'array' })], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }), `usage_${filters.value.start_date}_to_${filters.value.end_date}.xlsx`)
|
||||||
|
appStore.showSuccess(t('usage.exportSuccess'))
|
||||||
}
|
}
|
||||||
} catch (error) { console.error('Failed to export:', error); appStore.showError('Export Failed') }
|
} catch (error) { console.error('Failed to export:', error); appStore.showError('Export Failed') }
|
||||||
finally { if(exportAbortController === c) { exportAbortController = null; exporting.value = false; exportProgress.show = false } }
|
finally { if(exportAbortController === c) { exportAbortController = null; exporting.value = false; exportProgress.show = false } }
|
||||||
|
|||||||
@@ -342,8 +342,8 @@
|
|||||||
>
|
>
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<!-- Token Breakdown -->
|
<!-- Token Breakdown -->
|
||||||
<div class="mb-2 border-b border-gray-700 pb-1.5">
|
<div>
|
||||||
<div class="text-xs font-semibold text-gray-300 mb-1">Token 明细</div>
|
<div class="text-xs font-semibold text-gray-300 mb-1">{{ t('usage.tokenDetails') }}</div>
|
||||||
<div v-if="tokenTooltipData && tokenTooltipData.input_tokens > 0" class="flex items-center justify-between gap-4">
|
<div v-if="tokenTooltipData && tokenTooltipData.input_tokens > 0" class="flex items-center justify-between gap-4">
|
||||||
<span class="text-gray-400">{{ t('admin.usage.inputTokens') }}</span>
|
<span class="text-gray-400">{{ t('admin.usage.inputTokens') }}</span>
|
||||||
<span class="font-medium text-white">{{ tokenTooltipData.input_tokens.toLocaleString() }}</span>
|
<span class="font-medium text-white">{{ tokenTooltipData.input_tokens.toLocaleString() }}</span>
|
||||||
@@ -389,6 +389,27 @@
|
|||||||
class="whitespace-nowrap rounded-lg border border-gray-700 bg-gray-900 px-3 py-2.5 text-xs text-white shadow-xl dark:border-gray-600 dark:bg-gray-800"
|
class="whitespace-nowrap rounded-lg border border-gray-700 bg-gray-900 px-3 py-2.5 text-xs text-white shadow-xl dark:border-gray-600 dark:bg-gray-800"
|
||||||
>
|
>
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
|
<!-- Cost Breakdown -->
|
||||||
|
<div class="mb-2 border-b border-gray-700 pb-1.5">
|
||||||
|
<div class="text-xs font-semibold text-gray-300 mb-1">{{ t('usage.costDetails') }}</div>
|
||||||
|
<div v-if="tooltipData && tooltipData.input_cost > 0" class="flex items-center justify-between gap-4">
|
||||||
|
<span class="text-gray-400">{{ t('admin.usage.inputCost') }}</span>
|
||||||
|
<span class="font-medium text-white">${{ tooltipData.input_cost.toFixed(6) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="tooltipData && tooltipData.output_cost > 0" class="flex items-center justify-between gap-4">
|
||||||
|
<span class="text-gray-400">{{ t('admin.usage.outputCost') }}</span>
|
||||||
|
<span class="font-medium text-white">${{ tooltipData.output_cost.toFixed(6) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="tooltipData && tooltipData.cache_creation_cost > 0" class="flex items-center justify-between gap-4">
|
||||||
|
<span class="text-gray-400">{{ t('admin.usage.cacheCreationCost') }}</span>
|
||||||
|
<span class="font-medium text-white">${{ tooltipData.cache_creation_cost.toFixed(6) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="tooltipData && tooltipData.cache_read_cost > 0" class="flex items-center justify-between gap-4">
|
||||||
|
<span class="text-gray-400">{{ t('admin.usage.cacheReadCost') }}</span>
|
||||||
|
<span class="font-medium text-white">${{ tooltipData.cache_read_cost.toFixed(6) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Rate and Summary -->
|
||||||
<div class="flex items-center justify-between gap-6">
|
<div class="flex items-center justify-between gap-6">
|
||||||
<span class="text-gray-400">{{ t('usage.rate') }}</span>
|
<span class="text-gray-400">{{ t('usage.rate') }}</span>
|
||||||
<span class="font-semibold text-blue-400"
|
<span class="font-semibold text-blue-400"
|
||||||
|
|||||||
Reference in New Issue
Block a user