"backend/internal/git@web.lueluesay.top:chenxi/sub2api.git" did not exist on "d95e04fd1f6b6caab8304513b7a8740912c79770"
Commit 3dfb62e9 authored by IanShaw027's avatar IanShaw027
Browse files

merge: 合并main分支最新改动

解决冲突:
- backend/internal/config/config.go: 合并Ops和Dashboard配置
- backend/internal/server/api_contract_test.go: 合并handler初始化
- backend/internal/service/openai_gateway_service.go: 保留Ops错误追踪逻辑
- backend/internal/service/wire.go: 合并Ops和APIKeyAuth provider

主要合并内容:
- Dashboard缓存和预聚合功能
- API Key认证缓存优化
- Codex转换支持
- 使用日志分区表
parents d5c711d0 2b2f7a6d
<user_instructions priority="0">
<environment_override priority="0">
YOU ARE IN A DIFFERENT ENVIRONMENT. These instructions override ALL previous tool references.
</environment_override>
<tool_replacements priority="0">
<critical_rule priority="0">
❌ APPLY_PATCH DOES NOT EXIST → ✅ USE "edit" INSTEAD
- NEVER use: apply_patch, applyPatch
- ALWAYS use: edit tool for ALL file modifications
- Before modifying files: Verify you're using "edit", NOT "apply_patch"
</critical_rule>
<critical_rule priority="0">
❌ UPDATE_PLAN DOES NOT EXIST → ✅ USE "todowrite" INSTEAD
- NEVER use: update_plan, updatePlan
- ALWAYS use: todowrite for ALL task/plan operations
- Use todoread to read current plan
- Before plan operations: Verify you're using "todowrite", NOT "update_plan"
</critical_rule>
</tool_replacements>
<available_tools priority="0">
File Operations:
• write - Create new files
• edit - Modify existing files (REPLACES apply_patch)
• patch - Apply diff patches
• read - Read file contents
Search/Discovery:
• grep - Search file contents
• glob - Find files by pattern
• list - List directories (use relative paths)
Execution:
• bash - Run shell commands
Network:
• webfetch - Fetch web content
Task Management:
• todowrite - Manage tasks/plans (REPLACES update_plan)
• todoread - Read current plan
</available_tools>
<substitution_rules priority="0">
Base instruction says: You MUST use instead:
apply_patch → edit
update_plan → todowrite
read_plan → todoread
absolute paths → relative paths
</substitution_rules>
<verification_checklist priority="0">
Before file/plan modifications:
1. Am I using "edit" NOT "apply_patch"?
2. Am I using "todowrite" NOT "update_plan"?
3. Is this tool in the approved list above?
4. Am I using relative paths?
If ANY answer is NO → STOP and correct before proceeding.
</verification_checklist>
</user_instructions>
\ No newline at end of file
...@@ -68,12 +68,13 @@ type RedeemCodeResponse struct { ...@@ -68,12 +68,13 @@ type RedeemCodeResponse struct {
// RedeemService 兑换码服务 // RedeemService 兑换码服务
type RedeemService struct { type RedeemService struct {
redeemRepo RedeemCodeRepository redeemRepo RedeemCodeRepository
userRepo UserRepository userRepo UserRepository
subscriptionService *SubscriptionService subscriptionService *SubscriptionService
cache RedeemCache cache RedeemCache
billingCacheService *BillingCacheService billingCacheService *BillingCacheService
entClient *dbent.Client entClient *dbent.Client
authCacheInvalidator APIKeyAuthCacheInvalidator
} }
// NewRedeemService 创建兑换码服务实例 // NewRedeemService 创建兑换码服务实例
...@@ -84,14 +85,16 @@ func NewRedeemService( ...@@ -84,14 +85,16 @@ func NewRedeemService(
cache RedeemCache, cache RedeemCache,
billingCacheService *BillingCacheService, billingCacheService *BillingCacheService,
entClient *dbent.Client, entClient *dbent.Client,
authCacheInvalidator APIKeyAuthCacheInvalidator,
) *RedeemService { ) *RedeemService {
return &RedeemService{ return &RedeemService{
redeemRepo: redeemRepo, redeemRepo: redeemRepo,
userRepo: userRepo, userRepo: userRepo,
subscriptionService: subscriptionService, subscriptionService: subscriptionService,
cache: cache, cache: cache,
billingCacheService: billingCacheService, billingCacheService: billingCacheService,
entClient: entClient, entClient: entClient,
authCacheInvalidator: authCacheInvalidator,
} }
} }
...@@ -324,18 +327,33 @@ func (s *RedeemService) Redeem(ctx context.Context, userID int64, code string) ( ...@@ -324,18 +327,33 @@ func (s *RedeemService) Redeem(ctx context.Context, userID int64, code string) (
// invalidateRedeemCaches 失效兑换相关的缓存 // invalidateRedeemCaches 失效兑换相关的缓存
func (s *RedeemService) invalidateRedeemCaches(ctx context.Context, userID int64, redeemCode *RedeemCode) { func (s *RedeemService) invalidateRedeemCaches(ctx context.Context, userID int64, redeemCode *RedeemCode) {
if s.billingCacheService == nil {
return
}
switch redeemCode.Type { switch redeemCode.Type {
case RedeemTypeBalance: case RedeemTypeBalance:
if s.authCacheInvalidator != nil {
s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID)
}
if s.billingCacheService == nil {
return
}
go func() { go func() {
cacheCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) cacheCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
_ = s.billingCacheService.InvalidateUserBalance(cacheCtx, userID) _ = s.billingCacheService.InvalidateUserBalance(cacheCtx, userID)
}() }()
case RedeemTypeConcurrency:
if s.authCacheInvalidator != nil {
s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID)
}
if s.billingCacheService == nil {
return
}
case RedeemTypeSubscription: case RedeemTypeSubscription:
if s.authCacheInvalidator != nil {
s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID)
}
if s.billingCacheService == nil {
return
}
if redeemCode.GroupID != nil { if redeemCode.GroupID != nil {
groupID := *redeemCode.GroupID groupID := *redeemCode.GroupID
go func() { go func() {
......
...@@ -54,17 +54,19 @@ type UsageStats struct { ...@@ -54,17 +54,19 @@ type UsageStats struct {
// UsageService 使用统计服务 // UsageService 使用统计服务
type UsageService struct { type UsageService struct {
usageRepo UsageLogRepository usageRepo UsageLogRepository
userRepo UserRepository userRepo UserRepository
entClient *dbent.Client entClient *dbent.Client
authCacheInvalidator APIKeyAuthCacheInvalidator
} }
// NewUsageService 创建使用统计服务实例 // NewUsageService 创建使用统计服务实例
func NewUsageService(usageRepo UsageLogRepository, userRepo UserRepository, entClient *dbent.Client) *UsageService { func NewUsageService(usageRepo UsageLogRepository, userRepo UserRepository, entClient *dbent.Client, authCacheInvalidator APIKeyAuthCacheInvalidator) *UsageService {
return &UsageService{ return &UsageService{
usageRepo: usageRepo, usageRepo: usageRepo,
userRepo: userRepo, userRepo: userRepo,
entClient: entClient, entClient: entClient,
authCacheInvalidator: authCacheInvalidator,
} }
} }
...@@ -118,10 +120,12 @@ func (s *UsageService) Create(ctx context.Context, req CreateUsageLogRequest) (* ...@@ -118,10 +120,12 @@ func (s *UsageService) Create(ctx context.Context, req CreateUsageLogRequest) (*
} }
// 扣除用户余额 // 扣除用户余额
balanceUpdated := false
if inserted && req.ActualCost > 0 { if inserted && req.ActualCost > 0 {
if err := s.userRepo.UpdateBalance(txCtx, req.UserID, -req.ActualCost); err != nil { if err := s.userRepo.UpdateBalance(txCtx, req.UserID, -req.ActualCost); err != nil {
return nil, fmt.Errorf("update user balance: %w", err) return nil, fmt.Errorf("update user balance: %w", err)
} }
balanceUpdated = true
} }
if tx != nil { if tx != nil {
...@@ -130,9 +134,18 @@ func (s *UsageService) Create(ctx context.Context, req CreateUsageLogRequest) (* ...@@ -130,9 +134,18 @@ func (s *UsageService) Create(ctx context.Context, req CreateUsageLogRequest) (*
} }
} }
s.invalidateUsageCaches(ctx, req.UserID, balanceUpdated)
return usageLog, nil return usageLog, nil
} }
func (s *UsageService) invalidateUsageCaches(ctx context.Context, userID int64, balanceUpdated bool) {
if !balanceUpdated || s.authCacheInvalidator == nil {
return
}
s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID)
}
// GetByID 根据ID获取使用日志 // GetByID 根据ID获取使用日志
func (s *UsageService) GetByID(ctx context.Context, id int64) (*UsageLog, error) { func (s *UsageService) GetByID(ctx context.Context, id int64) (*UsageLog, error) {
log, err := s.usageRepo.GetByID(ctx, id) log, err := s.usageRepo.GetByID(ctx, id)
......
...@@ -55,13 +55,15 @@ type ChangePasswordRequest struct { ...@@ -55,13 +55,15 @@ type ChangePasswordRequest struct {
// UserService 用户服务 // UserService 用户服务
type UserService struct { type UserService struct {
userRepo UserRepository userRepo UserRepository
authCacheInvalidator APIKeyAuthCacheInvalidator
} }
// NewUserService 创建用户服务实例 // NewUserService 创建用户服务实例
func NewUserService(userRepo UserRepository) *UserService { func NewUserService(userRepo UserRepository, authCacheInvalidator APIKeyAuthCacheInvalidator) *UserService {
return &UserService{ return &UserService{
userRepo: userRepo, userRepo: userRepo,
authCacheInvalidator: authCacheInvalidator,
} }
} }
...@@ -89,6 +91,7 @@ func (s *UserService) UpdateProfile(ctx context.Context, userID int64, req Updat ...@@ -89,6 +91,7 @@ func (s *UserService) UpdateProfile(ctx context.Context, userID int64, req Updat
if err != nil { if err != nil {
return nil, fmt.Errorf("get user: %w", err) return nil, fmt.Errorf("get user: %w", err)
} }
oldConcurrency := user.Concurrency
// 更新字段 // 更新字段
if req.Email != nil { if req.Email != nil {
...@@ -114,6 +117,9 @@ func (s *UserService) UpdateProfile(ctx context.Context, userID int64, req Updat ...@@ -114,6 +117,9 @@ func (s *UserService) UpdateProfile(ctx context.Context, userID int64, req Updat
if err := s.userRepo.Update(ctx, user); err != nil { if err := s.userRepo.Update(ctx, user); err != nil {
return nil, fmt.Errorf("update user: %w", err) return nil, fmt.Errorf("update user: %w", err)
} }
if s.authCacheInvalidator != nil && user.Concurrency != oldConcurrency {
s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID)
}
return user, nil return user, nil
} }
...@@ -169,6 +175,9 @@ func (s *UserService) UpdateBalance(ctx context.Context, userID int64, amount fl ...@@ -169,6 +175,9 @@ func (s *UserService) UpdateBalance(ctx context.Context, userID int64, amount fl
if err := s.userRepo.UpdateBalance(ctx, userID, amount); err != nil { if err := s.userRepo.UpdateBalance(ctx, userID, amount); err != nil {
return fmt.Errorf("update balance: %w", err) return fmt.Errorf("update balance: %w", err)
} }
if s.authCacheInvalidator != nil {
s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID)
}
return nil return nil
} }
...@@ -177,6 +186,9 @@ func (s *UserService) UpdateConcurrency(ctx context.Context, userID int64, concu ...@@ -177,6 +186,9 @@ func (s *UserService) UpdateConcurrency(ctx context.Context, userID int64, concu
if err := s.userRepo.UpdateConcurrency(ctx, userID, concurrency); err != nil { if err := s.userRepo.UpdateConcurrency(ctx, userID, concurrency); err != nil {
return fmt.Errorf("update concurrency: %w", err) return fmt.Errorf("update concurrency: %w", err)
} }
if s.authCacheInvalidator != nil {
s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID)
}
return nil return nil
} }
...@@ -192,12 +204,18 @@ func (s *UserService) UpdateStatus(ctx context.Context, userID int64, status str ...@@ -192,12 +204,18 @@ func (s *UserService) UpdateStatus(ctx context.Context, userID int64, status str
if err := s.userRepo.Update(ctx, user); err != nil { if err := s.userRepo.Update(ctx, user); err != nil {
return fmt.Errorf("update user: %w", err) return fmt.Errorf("update user: %w", err)
} }
if s.authCacheInvalidator != nil {
s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID)
}
return nil return nil
} }
// Delete 删除用户(管理员功能) // Delete 删除用户(管理员功能)
func (s *UserService) Delete(ctx context.Context, userID int64) error { func (s *UserService) Delete(ctx context.Context, userID int64) error {
if s.authCacheInvalidator != nil {
s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID)
}
if err := s.userRepo.Delete(ctx, userID); err != nil { if err := s.userRepo.Delete(ctx, userID); err != nil {
return fmt.Errorf("delete user: %w", err) return fmt.Errorf("delete user: %w", err)
} }
......
...@@ -49,6 +49,13 @@ func ProvideTokenRefreshService( ...@@ -49,6 +49,13 @@ func ProvideTokenRefreshService(
return svc return svc
} }
// ProvideDashboardAggregationService 创建并启动仪表盘聚合服务
func ProvideDashboardAggregationService(repo DashboardAggregationRepository, timingWheel *TimingWheelService, cfg *config.Config) *DashboardAggregationService {
svc := NewDashboardAggregationService(repo, timingWheel, cfg)
svc.Start()
return svc
}
// ProvideAccountExpiryService creates and starts AccountExpiryService. // ProvideAccountExpiryService creates and starts AccountExpiryService.
func ProvideAccountExpiryService(accountRepo AccountRepository) *AccountExpiryService { func ProvideAccountExpiryService(accountRepo AccountRepository) *AccountExpiryService {
svc := NewAccountExpiryService(accountRepo, time.Minute) svc := NewAccountExpiryService(accountRepo, time.Minute)
...@@ -145,12 +152,18 @@ func ProvideOpsScheduledReportService( ...@@ -145,12 +152,18 @@ func ProvideOpsScheduledReportService(
return svc return svc
} }
// ProvideAPIKeyAuthCacheInvalidator 提供 API Key 认证缓存失效能力
func ProvideAPIKeyAuthCacheInvalidator(apiKeyService *APIKeyService) APIKeyAuthCacheInvalidator {
return apiKeyService
}
// ProviderSet is the Wire provider set for all services // ProviderSet is the Wire provider set for all services
var ProviderSet = wire.NewSet( var ProviderSet = wire.NewSet(
// Core services // Core services
NewAuthService, NewAuthService,
NewUserService, NewUserService,
NewAPIKeyService, NewAPIKeyService,
ProvideAPIKeyAuthCacheInvalidator,
NewGroupService, NewGroupService,
NewAccountService, NewAccountService,
NewProxyService, NewProxyService,
...@@ -194,6 +207,7 @@ var ProviderSet = wire.NewSet( ...@@ -194,6 +207,7 @@ var ProviderSet = wire.NewSet(
ProvideTokenRefreshService, ProvideTokenRefreshService,
ProvideAccountExpiryService, ProvideAccountExpiryService,
ProvideTimingWheelService, ProvideTimingWheelService,
ProvideDashboardAggregationService,
ProvideDeferredService, ProvideDeferredService,
NewAntigravityQuotaFetcher, NewAntigravityQuotaFetcher,
NewUserAttributeService, NewUserAttributeService,
......
-- Usage dashboard aggregation tables (hourly/daily) + active-user dedup + watermark.
-- These tables support Admin Dashboard statistics without full-table scans on usage_logs.
-- Hourly aggregates (UTC buckets).
CREATE TABLE IF NOT EXISTS usage_dashboard_hourly (
bucket_start TIMESTAMPTZ PRIMARY KEY,
total_requests BIGINT NOT NULL DEFAULT 0,
input_tokens BIGINT NOT NULL DEFAULT 0,
output_tokens BIGINT NOT NULL DEFAULT 0,
cache_creation_tokens BIGINT NOT NULL DEFAULT 0,
cache_read_tokens BIGINT NOT NULL DEFAULT 0,
total_cost DECIMAL(20, 10) NOT NULL DEFAULT 0,
actual_cost DECIMAL(20, 10) NOT NULL DEFAULT 0,
total_duration_ms BIGINT NOT NULL DEFAULT 0,
active_users BIGINT NOT NULL DEFAULT 0,
computed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_usage_dashboard_hourly_bucket_start
ON usage_dashboard_hourly (bucket_start DESC);
COMMENT ON TABLE usage_dashboard_hourly IS 'Pre-aggregated hourly usage metrics for admin dashboard (UTC buckets).';
COMMENT ON COLUMN usage_dashboard_hourly.bucket_start IS 'UTC start timestamp of the hour bucket.';
COMMENT ON COLUMN usage_dashboard_hourly.computed_at IS 'When the hourly row was last computed/refreshed.';
-- Daily aggregates (UTC dates).
CREATE TABLE IF NOT EXISTS usage_dashboard_daily (
bucket_date DATE PRIMARY KEY,
total_requests BIGINT NOT NULL DEFAULT 0,
input_tokens BIGINT NOT NULL DEFAULT 0,
output_tokens BIGINT NOT NULL DEFAULT 0,
cache_creation_tokens BIGINT NOT NULL DEFAULT 0,
cache_read_tokens BIGINT NOT NULL DEFAULT 0,
total_cost DECIMAL(20, 10) NOT NULL DEFAULT 0,
actual_cost DECIMAL(20, 10) NOT NULL DEFAULT 0,
total_duration_ms BIGINT NOT NULL DEFAULT 0,
active_users BIGINT NOT NULL DEFAULT 0,
computed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_usage_dashboard_daily_bucket_date
ON usage_dashboard_daily (bucket_date DESC);
COMMENT ON TABLE usage_dashboard_daily IS 'Pre-aggregated daily usage metrics for admin dashboard (UTC dates).';
COMMENT ON COLUMN usage_dashboard_daily.bucket_date IS 'UTC date of the day bucket.';
COMMENT ON COLUMN usage_dashboard_daily.computed_at IS 'When the daily row was last computed/refreshed.';
-- Hourly active user dedup table.
CREATE TABLE IF NOT EXISTS usage_dashboard_hourly_users (
bucket_start TIMESTAMPTZ NOT NULL,
user_id BIGINT NOT NULL,
PRIMARY KEY (bucket_start, user_id)
);
CREATE INDEX IF NOT EXISTS idx_usage_dashboard_hourly_users_bucket_start
ON usage_dashboard_hourly_users (bucket_start);
-- Daily active user dedup table.
CREATE TABLE IF NOT EXISTS usage_dashboard_daily_users (
bucket_date DATE NOT NULL,
user_id BIGINT NOT NULL,
PRIMARY KEY (bucket_date, user_id)
);
CREATE INDEX IF NOT EXISTS idx_usage_dashboard_daily_users_bucket_date
ON usage_dashboard_daily_users (bucket_date);
-- Aggregation watermark table (single row).
CREATE TABLE IF NOT EXISTS usage_dashboard_aggregation_watermark (
id INT PRIMARY KEY,
last_aggregated_at TIMESTAMPTZ NOT NULL DEFAULT TIMESTAMPTZ '1970-01-01 00:00:00+00',
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
INSERT INTO usage_dashboard_aggregation_watermark (id)
VALUES (1)
ON CONFLICT (id) DO NOTHING;
-- usage_logs monthly partition bootstrap.
-- Only creates partitions when usage_logs is already partitioned.
-- Converting usage_logs to a partitioned table requires a manual migration plan.
DO $$
DECLARE
is_partitioned BOOLEAN := FALSE;
has_data BOOLEAN := FALSE;
month_start DATE;
prev_month DATE;
next_month DATE;
BEGIN
SELECT EXISTS(
SELECT 1
FROM pg_partitioned_table pt
JOIN pg_class c ON c.oid = pt.partrelid
WHERE c.relname = 'usage_logs'
) INTO is_partitioned;
IF NOT is_partitioned THEN
SELECT EXISTS(SELECT 1 FROM usage_logs LIMIT 1) INTO has_data;
IF NOT has_data THEN
-- Automatic conversion is intentionally skipped; see manual migration plan.
RAISE NOTICE 'usage_logs is not partitioned; skip automatic partitioning';
END IF;
END IF;
IF is_partitioned THEN
month_start := date_trunc('month', now() AT TIME ZONE 'UTC')::date;
prev_month := (month_start - INTERVAL '1 month')::date;
next_month := (month_start + INTERVAL '1 month')::date;
EXECUTE format(
'CREATE TABLE IF NOT EXISTS usage_logs_%s PARTITION OF usage_logs FOR VALUES FROM (%L) TO (%L)',
to_char(prev_month, 'YYYYMM'),
prev_month,
month_start
);
EXECUTE format(
'CREATE TABLE IF NOT EXISTS usage_logs_%s PARTITION OF usage_logs FOR VALUES FROM (%L) TO (%L)',
to_char(month_start, 'YYYYMM'),
month_start,
next_month
);
EXECUTE format(
'CREATE TABLE IF NOT EXISTS usage_logs_%s PARTITION OF usage_logs FOR VALUES FROM (%L) TO (%L)',
to_char(next_month, 'YYYYMM'),
next_month,
(next_month + INTERVAL '1 month')::date
);
END IF;
END $$;
...@@ -170,6 +170,87 @@ gateway: ...@@ -170,6 +170,87 @@ gateway:
# 允许在特定 400 错误时进行故障转移(默认:关闭) # 允许在特定 400 错误时进行故障转移(默认:关闭)
failover_on_400: false failover_on_400: false
# =============================================================================
# API Key Auth Cache Configuration
# API Key 认证缓存配置
# =============================================================================
api_key_auth_cache:
# L1 cache size (entries), in-process LRU/TTL cache
# L1 缓存容量(条目数),进程内 LRU/TTL 缓存
l1_size: 65535
# L1 cache TTL (seconds)
# L1 缓存 TTL(秒)
l1_ttl_seconds: 15
# L2 cache TTL (seconds), stored in Redis
# L2 缓存 TTL(秒),Redis 中存储
l2_ttl_seconds: 300
# Negative cache TTL (seconds)
# 负缓存 TTL(秒)
negative_ttl_seconds: 30
# TTL jitter percent (0-100)
# TTL 抖动百分比(0-100)
jitter_percent: 10
# Enable singleflight for cache misses
# 缓存未命中时启用 singleflight 合并回源
singleflight: true
# =============================================================================
# Dashboard Cache Configuration
# 仪表盘缓存配置
# =============================================================================
dashboard_cache:
# Enable dashboard cache
# 启用仪表盘缓存
enabled: true
# Redis key prefix for multi-environment isolation
# Redis key 前缀,用于多环境隔离
key_prefix: "sub2api:"
# Fresh TTL (seconds); within this window cached stats are considered fresh
# 新鲜阈值(秒);命中后处于该窗口视为新鲜数据
stats_fresh_ttl_seconds: 15
# Cache TTL (seconds) stored in Redis
# Redis 缓存 TTL(秒)
stats_ttl_seconds: 30
# Async refresh timeout (seconds)
# 异步刷新超时(秒)
stats_refresh_timeout_seconds: 30
# =============================================================================
# Dashboard Aggregation Configuration
# 仪表盘预聚合配置(重启生效)
# =============================================================================
dashboard_aggregation:
# Enable aggregation job
# 启用聚合作业
enabled: true
# Refresh interval (seconds)
# 刷新间隔(秒)
interval_seconds: 60
# Lookback window (seconds) for late-arriving data
# 回看窗口(秒),处理迟到数据
lookback_seconds: 120
# Allow manual backfill
# 允许手动回填
backfill_enabled: false
# Backfill max range (days)
# 回填最大跨度(天)
backfill_max_days: 31
# Recompute recent N days on startup
# 启动时重算最近 N 天
recompute_days: 2
# Retention windows (days)
# 保留窗口(天)
retention:
# Raw usage_logs retention
# 原始 usage_logs 保留天数
usage_logs_days: 90
# Hourly aggregation retention
# 小时聚合保留天数
hourly_days: 180
# Daily aggregation retention
# 日聚合保留天数
daily_days: 730
# ============================================================================= # =============================================================================
# Concurrency Wait Configuration # Concurrency Wait Configuration
# 并发等待配置 # 并发等待配置
......
...@@ -69,6 +69,33 @@ JWT_EXPIRE_HOUR=24 ...@@ -69,6 +69,33 @@ JWT_EXPIRE_HOUR=24
# Leave unset to use default ./config.yaml # Leave unset to use default ./config.yaml
#CONFIG_FILE=./config.yaml #CONFIG_FILE=./config.yaml
# -----------------------------------------------------------------------------
# Dashboard Aggregation (Optional)
# -----------------------------------------------------------------------------
# Enable aggregation job
# 启用仪表盘预聚合
DASHBOARD_AGGREGATION_ENABLED=true
# Refresh interval (seconds)
# 刷新间隔(秒)
DASHBOARD_AGGREGATION_INTERVAL_SECONDS=60
# Lookback window (seconds)
# 回看窗口(秒)
DASHBOARD_AGGREGATION_LOOKBACK_SECONDS=120
# Allow manual backfill
# 允许手动回填
DASHBOARD_AGGREGATION_BACKFILL_ENABLED=false
# Backfill max range (days)
# 回填最大跨度(天)
DASHBOARD_AGGREGATION_BACKFILL_MAX_DAYS=31
# Recompute recent N days on startup
# 启动时重算最近 N 天
DASHBOARD_AGGREGATION_RECOMPUTE_DAYS=2
# Retention windows (days)
# 保留窗口(天)
DASHBOARD_AGGREGATION_RETENTION_USAGE_LOGS_DAYS=90
DASHBOARD_AGGREGATION_RETENTION_HOURLY_DAYS=180
DASHBOARD_AGGREGATION_RETENTION_DAILY_DAYS=730
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Security Configuration # Security Configuration
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
......
...@@ -170,6 +170,87 @@ gateway: ...@@ -170,6 +170,87 @@ gateway:
# 允许在特定 400 错误时进行故障转移(默认:关闭) # 允许在特定 400 错误时进行故障转移(默认:关闭)
failover_on_400: false failover_on_400: false
# =============================================================================
# API Key Auth Cache Configuration
# API Key 认证缓存配置
# =============================================================================
api_key_auth_cache:
# L1 cache size (entries), in-process LRU/TTL cache
# L1 缓存容量(条目数),进程内 LRU/TTL 缓存
l1_size: 65535
# L1 cache TTL (seconds)
# L1 缓存 TTL(秒)
l1_ttl_seconds: 15
# L2 cache TTL (seconds), stored in Redis
# L2 缓存 TTL(秒),Redis 中存储
l2_ttl_seconds: 300
# Negative cache TTL (seconds)
# 负缓存 TTL(秒)
negative_ttl_seconds: 30
# TTL jitter percent (0-100)
# TTL 抖动百分比(0-100)
jitter_percent: 10
# Enable singleflight for cache misses
# 缓存未命中时启用 singleflight 合并回源
singleflight: true
# =============================================================================
# Dashboard Cache Configuration
# 仪表盘缓存配置
# =============================================================================
dashboard_cache:
# Enable dashboard cache
# 启用仪表盘缓存
enabled: true
# Redis key prefix for multi-environment isolation
# Redis key 前缀,用于多环境隔离
key_prefix: "sub2api:"
# Fresh TTL (seconds); within this window cached stats are considered fresh
# 新鲜阈值(秒);命中后处于该窗口视为新鲜数据
stats_fresh_ttl_seconds: 15
# Cache TTL (seconds) stored in Redis
# Redis 缓存 TTL(秒)
stats_ttl_seconds: 30
# Async refresh timeout (seconds)
# 异步刷新超时(秒)
stats_refresh_timeout_seconds: 30
# =============================================================================
# Dashboard Aggregation Configuration
# 仪表盘预聚合配置(重启生效)
# =============================================================================
dashboard_aggregation:
# Enable aggregation job
# 启用聚合作业
enabled: true
# Refresh interval (seconds)
# 刷新间隔(秒)
interval_seconds: 60
# Lookback window (seconds) for late-arriving data
# 回看窗口(秒),处理迟到数据
lookback_seconds: 120
# Allow manual backfill
# 允许手动回填
backfill_enabled: false
# Backfill max range (days)
# 回填最大跨度(天)
backfill_max_days: 31
# Recompute recent N days on startup
# 启动时重算最近 N 天
recompute_days: 2
# Retention windows (days)
# 保留窗口(天)
retention:
# Raw usage_logs retention
# 原始 usage_logs 保留天数
usage_logs_days: 90
# Hourly aggregation retention
# 小时聚合保留天数
hourly_days: 180
# Daily aggregation retention
# 日聚合保留天数
daily_days: 730
# ============================================================================= # =============================================================================
# Concurrency Wait Configuration # Concurrency Wait Configuration
# 并发等待配置 # 并发等待配置
......
...@@ -275,11 +275,15 @@ export async function bulkUpdate( ...@@ -275,11 +275,15 @@ export async function bulkUpdate(
): Promise<{ ): Promise<{
success: number success: number
failed: number failed: number
success_ids?: number[]
failed_ids?: number[]
results: Array<{ account_id: number; success: boolean; error?: string }> results: Array<{ account_id: number; success: boolean; error?: string }>
}> { }> {
const { data } = await apiClient.post<{ const { data } = await apiClient.post<{
success: number success: number
failed: number failed: number
success_ids?: number[]
failed_ids?: number[]
results: Array<{ account_id: number; success: boolean; error?: string }> results: Array<{ account_id: number; success: boolean; error?: string }>
}>('/admin/accounts/bulk-update', { }>('/admin/accounts/bulk-update', {
account_ids: accountIds, account_ids: accountIds,
......
...@@ -83,7 +83,7 @@ ...@@ -83,7 +83,7 @@
<tr <tr
v-else v-else
v-for="(row, index) in sortedData" v-for="(row, index) in sortedData"
:key="index" :key="resolveRowKey(row, index)"
class="hover:bg-gray-50 dark:hover:bg-dark-800" class="hover:bg-gray-50 dark:hover:bg-dark-800"
> >
<td <td
...@@ -210,6 +210,7 @@ interface Props { ...@@ -210,6 +210,7 @@ interface Props {
stickyActionsColumn?: boolean stickyActionsColumn?: boolean
expandableActions?: boolean expandableActions?: boolean
actionsCount?: number // 操作按钮总数,用于判断是否需要展开功能 actionsCount?: number // 操作按钮总数,用于判断是否需要展开功能
rowKey?: string | ((row: any) => string | number)
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
...@@ -222,6 +223,18 @@ const props = withDefaults(defineProps<Props>(), { ...@@ -222,6 +223,18 @@ const props = withDefaults(defineProps<Props>(), {
const sortKey = ref<string>('') const sortKey = ref<string>('')
const sortOrder = ref<'asc' | 'desc'>('asc') const sortOrder = ref<'asc' | 'desc'>('asc')
const actionsExpanded = ref(false) const actionsExpanded = ref(false)
const resolveRowKey = (row: any, index: number) => {
if (typeof props.rowKey === 'function') {
const key = props.rowKey(row)
return key ?? index
}
if (typeof props.rowKey === 'string' && props.rowKey) {
const key = row?.[props.rowKey]
return key ?? index
}
const key = row?.id
return key ?? index
}
// 数据/列变化时重新检查滚动状态 // 数据/列变化时重新检查滚动状态
// 注意:不能监听 actionsExpanded,因为 checkActionsColumnWidth 会临时修改它,会导致无限循环 // 注意:不能监听 actionsExpanded,因为 checkActionsColumnWidth 会临时修改它,会导致无限循环
......
...@@ -13,6 +13,7 @@ A generic data table component with sorting, loading states, and custom cell ren ...@@ -13,6 +13,7 @@ A generic data table component with sorting, loading states, and custom cell ren
- `columns: Column[]` - Array of column definitions with key, label, sortable, and formatter - `columns: Column[]` - Array of column definitions with key, label, sortable, and formatter
- `data: any[]` - Array of data objects to display - `data: any[]` - Array of data objects to display
- `loading?: boolean` - Show loading skeleton - `loading?: boolean` - Show loading skeleton
- `rowKey?: string | (row: any) => string | number` - Row key field or resolver (defaults to `row.id`, falls back to index)
**Slots:** **Slots:**
......
...@@ -28,8 +28,8 @@ ...@@ -28,8 +28,8 @@
{{ platformDescription }} {{ platformDescription }}
</p> </p>
<!-- Client Tabs (only for Antigravity platform) --> <!-- Client Tabs -->
<div v-if="platform === 'antigravity'" class="border-b border-gray-200 dark:border-dark-700"> <div v-if="clientTabs.length" class="border-b border-gray-200 dark:border-dark-700">
<nav class="-mb-px flex space-x-6" aria-label="Client"> <nav class="-mb-px flex space-x-6" aria-label="Client">
<button <button
v-for="tab in clientTabs" v-for="tab in clientTabs"
...@@ -51,7 +51,7 @@ ...@@ -51,7 +51,7 @@
</div> </div>
<!-- OS/Shell Tabs --> <!-- OS/Shell Tabs -->
<div class="border-b border-gray-200 dark:border-dark-700"> <div v-if="showShellTabs" class="border-b border-gray-200 dark:border-dark-700">
<nav class="-mb-px flex space-x-4" aria-label="Tabs"> <nav class="-mb-px flex space-x-4" aria-label="Tabs">
<button <button
v-for="tab in currentTabs" v-for="tab in currentTabs"
...@@ -111,7 +111,7 @@ ...@@ -111,7 +111,7 @@
</div> </div>
<!-- Usage Note --> <!-- Usage Note -->
<div class="flex items-start gap-3 p-3 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-100 dark:border-blue-800"> <div v-if="showPlatformNote" class="flex items-start gap-3 p-3 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-100 dark:border-blue-800">
<Icon name="infoCircle" size="md" class="text-blue-500 flex-shrink-0 mt-0.5" /> <Icon name="infoCircle" size="md" class="text-blue-500 flex-shrink-0 mt-0.5" />
<p class="text-sm text-blue-700 dark:text-blue-300"> <p class="text-sm text-blue-700 dark:text-blue-300">
{{ platformNote }} {{ platformNote }}
...@@ -173,17 +173,28 @@ const { copyToClipboard: clipboardCopy } = useClipboard() ...@@ -173,17 +173,28 @@ const { copyToClipboard: clipboardCopy } = useClipboard()
const copiedIndex = ref<number | null>(null) const copiedIndex = ref<number | null>(null)
const activeTab = ref<string>('unix') const activeTab = ref<string>('unix')
const activeClientTab = ref<string>('claude') // Level 1 tab for antigravity platform const activeClientTab = ref<string>('claude')
// Reset tabs when platform changes // Reset tabs when platform changes
watch(() => props.platform, (newPlatform) => { const defaultClientTab = computed(() => {
activeTab.value = 'unix' switch (props.platform) {
if (newPlatform === 'antigravity') { case 'openai':
activeClientTab.value = 'claude' return 'codex'
case 'gemini':
return 'gemini'
case 'antigravity':
return 'claude'
default:
return 'claude'
} }
}) })
// Reset shell tab when client changes (for antigravity) watch(() => props.platform, () => {
activeTab.value = 'unix'
activeClientTab.value = defaultClientTab.value
}, { immediate: true })
// Reset shell tab when client changes
watch(activeClientTab, () => { watch(activeClientTab, () => {
activeTab.value = 'unix' activeTab.value = 'unix'
}) })
...@@ -251,11 +262,32 @@ const SparkleIcon = { ...@@ -251,11 +262,32 @@ const SparkleIcon = {
} }
} }
// Client tabs for Antigravity platform (Level 1) const clientTabs = computed((): TabConfig[] => {
const clientTabs = computed((): TabConfig[] => [ if (!props.platform) return []
{ id: 'claude', label: t('keys.useKeyModal.antigravity.claudeCode'), icon: TerminalIcon }, switch (props.platform) {
{ id: 'gemini', label: t('keys.useKeyModal.antigravity.geminiCli'), icon: SparkleIcon } case 'openai':
]) return [
{ id: 'codex', label: t('keys.useKeyModal.cliTabs.codexCli'), icon: TerminalIcon },
{ id: 'opencode', label: t('keys.useKeyModal.cliTabs.opencode'), icon: TerminalIcon }
]
case 'gemini':
return [
{ id: 'gemini', label: t('keys.useKeyModal.cliTabs.geminiCli'), icon: SparkleIcon },
{ id: 'opencode', label: t('keys.useKeyModal.cliTabs.opencode'), icon: TerminalIcon }
]
case 'antigravity':
return [
{ id: 'claude', label: t('keys.useKeyModal.cliTabs.claudeCode'), icon: TerminalIcon },
{ id: 'gemini', label: t('keys.useKeyModal.cliTabs.geminiCli'), icon: SparkleIcon },
{ id: 'opencode', label: t('keys.useKeyModal.cliTabs.opencode'), icon: TerminalIcon }
]
default:
return [
{ id: 'claude', label: t('keys.useKeyModal.cliTabs.claudeCode'), icon: TerminalIcon },
{ id: 'opencode', label: t('keys.useKeyModal.cliTabs.opencode'), icon: TerminalIcon }
]
}
})
// Shell tabs (3 types for environment variable based configs) // Shell tabs (3 types for environment variable based configs)
const shellTabs: TabConfig[] = [ const shellTabs: TabConfig[] = [
...@@ -270,11 +302,13 @@ const openaiTabs: TabConfig[] = [ ...@@ -270,11 +302,13 @@ const openaiTabs: TabConfig[] = [
{ id: 'windows', label: 'Windows', icon: WindowsIcon } { id: 'windows', label: 'Windows', icon: WindowsIcon }
] ]
const showShellTabs = computed(() => activeClientTab.value !== 'opencode')
const currentTabs = computed(() => { const currentTabs = computed(() => {
if (!showShellTabs.value) return []
if (props.platform === 'openai') { if (props.platform === 'openai') {
return openaiTabs // 2 tabs: unix, windows return openaiTabs
} }
// All other platforms (anthropic, gemini, antigravity) use shell tabs
return shellTabs return shellTabs
}) })
...@@ -308,6 +342,8 @@ const platformNote = computed(() => { ...@@ -308,6 +342,8 @@ const platformNote = computed(() => {
} }
}) })
const showPlatformNote = computed(() => activeClientTab.value !== 'opencode')
const escapeHtml = (value: string) => value const escapeHtml = (value: string) => value
.replace(/&/g, '&amp;') .replace(/&/g, '&amp;')
.replace(/</g, '&lt;') .replace(/</g, '&lt;')
...@@ -329,6 +365,35 @@ const comment = (value: string) => wrapToken('text-slate-500', value) ...@@ -329,6 +365,35 @@ const comment = (value: string) => wrapToken('text-slate-500', value)
const currentFiles = computed((): FileConfig[] => { const currentFiles = computed((): FileConfig[] => {
const baseUrl = props.baseUrl || window.location.origin const baseUrl = props.baseUrl || window.location.origin
const apiKey = props.apiKey const apiKey = props.apiKey
const baseRoot = baseUrl.replace(/\/v1\/?$/, '').replace(/\/+$/, '')
const ensureV1 = (value: string) => {
const trimmed = value.replace(/\/+$/, '')
return trimmed.endsWith('/v1') ? trimmed : `${trimmed}/v1`
}
const apiBase = ensureV1(baseRoot)
const antigravityBase = ensureV1(`${baseRoot}/antigravity`)
const antigravityGeminiBase = (() => {
const trimmed = `${baseRoot}/antigravity`.replace(/\/+$/, '')
return trimmed.endsWith('/v1beta') ? trimmed : `${trimmed}/v1beta`
})()
if (activeClientTab.value === 'opencode') {
switch (props.platform) {
case 'anthropic':
return [generateOpenCodeConfig('anthropic', apiBase, apiKey)]
case 'openai':
return [generateOpenCodeConfig('openai', apiBase, apiKey)]
case 'gemini':
return [generateOpenCodeConfig('gemini', apiBase, apiKey)]
case 'antigravity':
return [
generateOpenCodeConfig('antigravity-claude', antigravityBase, apiKey, 'opencode.json (Claude)'),
generateOpenCodeConfig('antigravity-gemini', antigravityGeminiBase, apiKey, 'opencode.json (Gemini)')
]
default:
return [generateOpenCodeConfig('openai', apiBase, apiKey)]
}
}
switch (props.platform) { switch (props.platform) {
case 'openai': case 'openai':
...@@ -336,12 +401,11 @@ const currentFiles = computed((): FileConfig[] => { ...@@ -336,12 +401,11 @@ const currentFiles = computed((): FileConfig[] => {
case 'gemini': case 'gemini':
return [generateGeminiCliContent(baseUrl, apiKey)] return [generateGeminiCliContent(baseUrl, apiKey)]
case 'antigravity': case 'antigravity':
// Both Claude Code and Gemini CLI need /antigravity suffix for antigravity platform if (activeClientTab.value === 'gemini') {
if (activeClientTab.value === 'claude') { return [generateGeminiCliContent(`${baseUrl}/antigravity`, apiKey)]
return generateAnthropicFiles(`${baseUrl}/antigravity`, apiKey)
} }
return [generateGeminiCliContent(`${baseUrl}/antigravity`, apiKey)] return generateAnthropicFiles(`${baseUrl}/antigravity`, apiKey)
default: // anthropic default:
return generateAnthropicFiles(baseUrl, apiKey) return generateAnthropicFiles(baseUrl, apiKey)
} }
}) })
...@@ -456,6 +520,76 @@ requires_openai_auth = true` ...@@ -456,6 +520,76 @@ requires_openai_auth = true`
] ]
} }
function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: string, pathLabel?: string): FileConfig {
const provider: Record<string, any> = {
[platform]: {
options: {
baseURL: baseUrl,
apiKey,
...(platform === 'openai' ? { store: false } : {})
}
}
}
const openaiModels = {
'gpt-5.2-codex': {
name: 'GPT-5.2 Codex',
variants: {
low: {},
medium: {},
high: {},
xhigh: {}
}
}
}
const geminiModels = {
'gemini-3-pro-high': { name: 'Gemini 3 Pro High' },
'gemini-3-pro-low': { name: 'Gemini 3 Pro Low' },
'gemini-3-pro-preview': { name: 'Gemini 3 Pro Preview' },
'gemini-3-pro-image': { name: 'Gemini 3 Pro Image' },
'gemini-3-flash': { name: 'Gemini 3 Flash' },
'gemini-2.5-flash-thinking': { name: 'Gemini 2.5 Flash Thinking' },
'gemini-2.5-flash': { name: 'Gemini 2.5 Flash' },
'gemini-2.5-flash-lite': { name: 'Gemini 2.5 Flash Lite' }
}
const claudeModels = {
'claude-opus-4-5-thinking': { name: 'Claude Opus 4.5 Thinking' },
'claude-sonnet-4-5-thinking': { name: 'Claude Sonnet 4.5 Thinking' },
'claude-sonnet-4-5': { name: 'Claude Sonnet 4.5' }
}
if (platform === 'gemini') {
provider[platform].npm = '@ai-sdk/google'
provider[platform].models = geminiModels
} else if (platform === 'anthropic') {
provider[platform].npm = '@ai-sdk/anthropic'
} else if (platform === 'antigravity-claude') {
provider[platform].npm = '@ai-sdk/anthropic'
provider[platform].name = 'Antigravity (Claude)'
provider[platform].models = claudeModels
} else if (platform === 'antigravity-gemini') {
provider[platform].npm = '@ai-sdk/google'
provider[platform].name = 'Antigravity (Gemini)'
provider[platform].models = geminiModels
} else if (platform === 'openai') {
provider[platform].models = openaiModels
}
const content = JSON.stringify(
{
provider,
$schema: 'https://opencode.ai/config.json'
},
null,
2
)
return {
path: pathLabel ?? 'opencode.json',
content,
hint: t('keys.useKeyModal.opencode.hint')
}
}
const copyContent = async (content: string, index: number) => { const copyContent = async (content: string, index: number) => {
const success = await clipboardCopy(content, t('keys.copied')) const success = await clipboardCopy(content, t('keys.copied'))
if (success) { if (success) {
......
...@@ -368,6 +368,12 @@ export default { ...@@ -368,6 +368,12 @@ export default {
note: 'Make sure the config directory exists. macOS/Linux users can run mkdir -p ~/.codex to create it.', note: 'Make sure the config directory exists. macOS/Linux users can run mkdir -p ~/.codex to create it.',
noteWindows: 'Press Win+R and enter %userprofile%\\.codex to open the config directory. Create it manually if it does not exist.', noteWindows: 'Press Win+R and enter %userprofile%\\.codex to open the config directory. Create it manually if it does not exist.',
}, },
cliTabs: {
claudeCode: 'Claude Code',
geminiCli: 'Gemini CLI',
codexCli: 'Codex CLI',
opencode: 'OpenCode',
},
antigravity: { antigravity: {
description: 'Configure API access for Antigravity group. Select the configuration method based on your client.', description: 'Configure API access for Antigravity group. Select the configuration method based on your client.',
claudeCode: 'Claude Code', claudeCode: 'Claude Code',
...@@ -380,6 +386,11 @@ export default { ...@@ -380,6 +386,11 @@ export default {
modelComment: 'If you have Gemini 3 access, you can use: gemini-3-pro-preview', modelComment: 'If you have Gemini 3 access, you can use: gemini-3-pro-preview',
note: 'These environment variables will be active in the current terminal session. For permanent configuration, add them to ~/.bashrc, ~/.zshrc, or the appropriate configuration file.', note: 'These environment variables will be active in the current terminal session. For permanent configuration, add them to ~/.bashrc, ~/.zshrc, or the appropriate configuration file.',
}, },
opencode: {
title: 'OpenCode Example',
subtitle: 'opencode.json',
hint: 'This is a group configuration example. Adjust model and options as needed.',
},
}, },
customKeyLabel: 'Custom Key', customKeyLabel: 'Custom Key',
customKeyPlaceholder: 'Enter your custom key (min 16 chars)', customKeyPlaceholder: 'Enter your custom key (min 16 chars)',
...@@ -1109,6 +1120,8 @@ export default { ...@@ -1109,6 +1120,8 @@ export default {
rateLimitCleared: 'Rate limit cleared successfully', rateLimitCleared: 'Rate limit cleared successfully',
bulkSchedulableEnabled: 'Successfully enabled scheduling for {count} account(s)', bulkSchedulableEnabled: 'Successfully enabled scheduling for {count} account(s)',
bulkSchedulableDisabled: 'Successfully disabled scheduling for {count} account(s)', bulkSchedulableDisabled: 'Successfully disabled scheduling for {count} account(s)',
bulkSchedulablePartial: 'Scheduling updated partially: {success} succeeded, {failed} failed',
bulkSchedulableResultUnknown: 'Bulk scheduling result incomplete. Please retry or refresh.',
bulkActions: { bulkActions: {
selected: '{count} account(s) selected', selected: '{count} account(s) selected',
selectCurrentPage: 'Select this page', selectCurrentPage: 'Select this page',
......
...@@ -366,6 +366,12 @@ export default { ...@@ -366,6 +366,12 @@ export default {
note: '请确保配置目录存在。macOS/Linux 用户可运行 mkdir -p ~/.codex 创建目录。', note: '请确保配置目录存在。macOS/Linux 用户可运行 mkdir -p ~/.codex 创建目录。',
noteWindows: '按 Win+R,输入 %userprofile%\\.codex 打开配置目录。如目录不存在,请先手动创建。', noteWindows: '按 Win+R,输入 %userprofile%\\.codex 打开配置目录。如目录不存在,请先手动创建。',
}, },
cliTabs: {
claudeCode: 'Claude Code',
geminiCli: 'Gemini CLI',
codexCli: 'Codex CLI',
opencode: 'OpenCode',
},
antigravity: { antigravity: {
description: '为 Antigravity 分组配置 API 访问。请根据您使用的客户端选择对应的配置方式。', description: '为 Antigravity 分组配置 API 访问。请根据您使用的客户端选择对应的配置方式。',
claudeCode: 'Claude Code', claudeCode: 'Claude Code',
...@@ -378,6 +384,11 @@ export default { ...@@ -378,6 +384,11 @@ export default {
modelComment: '如果你有 Gemini 3 权限可以填:gemini-3-pro-preview', modelComment: '如果你有 Gemini 3 权限可以填:gemini-3-pro-preview',
note: '这些环境变量将在当前终端会话中生效。如需永久配置,请将其添加到 ~/.bashrc、~/.zshrc 或相应的配置文件中。', note: '这些环境变量将在当前终端会话中生效。如需永久配置,请将其添加到 ~/.bashrc、~/.zshrc 或相应的配置文件中。',
}, },
opencode: {
title: 'OpenCode 配置示例',
subtitle: 'opencode.json',
hint: '示例仅用于演示分组配置,模型与选项可按需调整。',
},
}, },
customKeyLabel: '自定义密钥', customKeyLabel: '自定义密钥',
customKeyPlaceholder: '输入自定义密钥(至少16个字符)', customKeyPlaceholder: '输入自定义密钥(至少16个字符)',
...@@ -1246,6 +1257,8 @@ export default { ...@@ -1246,6 +1257,8 @@ export default {
accountDeletedSuccess: '账号删除成功', accountDeletedSuccess: '账号删除成功',
bulkSchedulableEnabled: '成功启用 {count} 个账号的调度', bulkSchedulableEnabled: '成功启用 {count} 个账号的调度',
bulkSchedulableDisabled: '成功停止 {count} 个账号的调度', bulkSchedulableDisabled: '成功停止 {count} 个账号的调度',
bulkSchedulablePartial: '部分调度更新成功:成功 {success} 个,失败 {failed} 个',
bulkSchedulableResultUnknown: '批量调度结果不完整,请稍后重试或刷新列表',
bulkActions: { bulkActions: {
selected: '已选择 {count} 个账号', selected: '已选择 {count} 个账号',
selectCurrentPage: '本页全选', selectCurrentPage: '本页全选',
......
...@@ -652,6 +652,9 @@ export interface DashboardStats { ...@@ -652,6 +652,9 @@ export interface DashboardStats {
total_users: number total_users: number
today_new_users: number // 今日新增用户数 today_new_users: number // 今日新增用户数
active_users: number // 今日有请求的用户数 active_users: number // 今日有请求的用户数
hourly_active_users: number // 当前小时活跃用户数(UTC)
stats_updated_at: string // 统计更新时间(UTC RFC3339)
stats_stale: boolean // 统计是否过期
// API Key 统计 // API Key 统计
total_api_keys: number total_api_keys: number
......
...@@ -20,7 +20,7 @@ ...@@ -20,7 +20,7 @@
</template> </template>
<template #table> <template #table>
<AccountBulkActionsBar :selected-ids="selIds" @delete="handleBulkDelete" @edit="showBulkEdit = true" @clear="selIds = []" @select-page="selectPage" @toggle-schedulable="handleBulkToggleSchedulable" /> <AccountBulkActionsBar :selected-ids="selIds" @delete="handleBulkDelete" @edit="showBulkEdit = true" @clear="selIds = []" @select-page="selectPage" @toggle-schedulable="handleBulkToggleSchedulable" />
<DataTable :columns="cols" :data="accounts" :loading="loading"> <DataTable :columns="cols" :data="accounts" :loading="loading" row-key="id">
<template #cell-select="{ row }"> <template #cell-select="{ row }">
<input type="checkbox" :checked="selIds.includes(row.id)" @change="toggleSel(row.id)" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500" /> <input type="checkbox" :checked="selIds.includes(row.id)" @change="toggleSel(row.id)" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500" />
</template> </template>
...@@ -209,18 +209,107 @@ const openMenu = (a: Account, e: MouseEvent) => { menu.acc = a; menu.pos = { top ...@@ -209,18 +209,107 @@ const openMenu = (a: Account, e: MouseEvent) => { menu.acc = a; menu.pos = { top
const toggleSel = (id: number) => { const i = selIds.value.indexOf(id); if(i === -1) selIds.value.push(id); else selIds.value.splice(i, 1) } const toggleSel = (id: number) => { const i = selIds.value.indexOf(id); if(i === -1) selIds.value.push(id); else selIds.value.splice(i, 1) }
const selectPage = () => { selIds.value = [...new Set([...selIds.value, ...accounts.value.map(a => a.id)])] } const selectPage = () => { selIds.value = [...new Set([...selIds.value, ...accounts.value.map(a => a.id)])] }
const handleBulkDelete = async () => { if(!confirm(t('common.confirm'))) return; try { await Promise.all(selIds.value.map(id => adminAPI.accounts.delete(id))); selIds.value = []; reload() } catch (error) { console.error('Failed to bulk delete accounts:', error) } } const handleBulkDelete = async () => { if(!confirm(t('common.confirm'))) return; try { await Promise.all(selIds.value.map(id => adminAPI.accounts.delete(id))); selIds.value = []; reload() } catch (error) { console.error('Failed to bulk delete accounts:', error) } }
const updateSchedulableInList = (accountIds: number[], schedulable: boolean) => {
if (accountIds.length === 0) return
const idSet = new Set(accountIds)
accounts.value = accounts.value.map((account) => (idSet.has(account.id) ? { ...account, schedulable } : account))
}
const normalizeBulkSchedulableResult = (
result: {
success?: number
failed?: number
success_ids?: number[]
failed_ids?: number[]
results?: Array<{ account_id: number; success: boolean }>
},
accountIds: number[]
) => {
const responseSuccessIds = Array.isArray(result.success_ids) ? result.success_ids : []
const responseFailedIds = Array.isArray(result.failed_ids) ? result.failed_ids : []
if (responseSuccessIds.length > 0 || responseFailedIds.length > 0) {
return {
successIds: responseSuccessIds,
failedIds: responseFailedIds,
successCount: typeof result.success === 'number' ? result.success : responseSuccessIds.length,
failedCount: typeof result.failed === 'number' ? result.failed : responseFailedIds.length,
hasIds: true,
hasCounts: true
}
}
const results = Array.isArray(result.results) ? result.results : []
if (results.length > 0) {
const successIds = results.filter(item => item.success).map(item => item.account_id)
const failedIds = results.filter(item => !item.success).map(item => item.account_id)
return {
successIds,
failedIds,
successCount: typeof result.success === 'number' ? result.success : successIds.length,
failedCount: typeof result.failed === 'number' ? result.failed : failedIds.length,
hasIds: true,
hasCounts: true
}
}
const hasExplicitCounts = typeof result.success === 'number' || typeof result.failed === 'number'
const successCount = typeof result.success === 'number' ? result.success : 0
const failedCount = typeof result.failed === 'number' ? result.failed : 0
if (hasExplicitCounts && failedCount === 0 && successCount === accountIds.length && accountIds.length > 0) {
return {
successIds: accountIds,
failedIds: [],
successCount,
failedCount,
hasIds: true,
hasCounts: true
}
}
return {
successIds: [],
failedIds: [],
successCount,
failedCount,
hasIds: false,
hasCounts: hasExplicitCounts
}
}
const handleBulkToggleSchedulable = async (schedulable: boolean) => { const handleBulkToggleSchedulable = async (schedulable: boolean) => {
const count = selIds.value.length const accountIds = [...selIds.value]
try { try {
const result = await adminAPI.accounts.bulkUpdate(selIds.value, { schedulable }); const result = await adminAPI.accounts.bulkUpdate(accountIds, { schedulable })
const message = schedulable const { successIds, failedIds, successCount, failedCount, hasIds, hasCounts } = normalizeBulkSchedulableResult(result, accountIds)
? t('admin.accounts.bulkSchedulableEnabled', { count: result.success || count }) if (!hasIds && !hasCounts) {
: t('admin.accounts.bulkSchedulableDisabled', { count: result.success || count }); appStore.showError(t('admin.accounts.bulkSchedulableResultUnknown'))
appStore.showSuccess(message); selIds.value = accountIds
selIds.value = []; load().catch((error) => {
reload() console.error('Failed to refresh accounts:', error)
})
return
}
if (successIds.length > 0) {
updateSchedulableInList(successIds, schedulable)
}
if (successCount > 0 && failedCount === 0) {
const message = schedulable
? t('admin.accounts.bulkSchedulableEnabled', { count: successCount })
: t('admin.accounts.bulkSchedulableDisabled', { count: successCount })
appStore.showSuccess(message)
}
if (failedCount > 0) {
const message = hasCounts || hasIds
? t('admin.accounts.bulkSchedulablePartial', { success: successCount, failed: failedCount })
: t('admin.accounts.bulkSchedulableResultUnknown')
appStore.showError(message)
selIds.value = failedIds.length > 0 ? failedIds : accountIds
} else {
selIds.value = hasIds ? [] : accountIds
}
load().catch((error) => {
console.error('Failed to refresh accounts:', error)
})
} catch (error) { } catch (error) {
console.error('Failed to bulk toggle schedulable:', error); console.error('Failed to bulk toggle schedulable:', error)
appStore.showError(t('common.error')) appStore.showError(t('common.error'))
} }
} }
...@@ -236,7 +325,22 @@ const handleResetStatus = async (a: Account) => { try { await adminAPI.accounts. ...@@ -236,7 +325,22 @@ const handleResetStatus = async (a: Account) => { try { await adminAPI.accounts.
const handleClearRateLimit = async (a: Account) => { try { await adminAPI.accounts.clearRateLimit(a.id); appStore.showSuccess(t('common.success')); load() } catch (error) { console.error('Failed to clear rate limit:', error) } } const handleClearRateLimit = async (a: Account) => { try { await adminAPI.accounts.clearRateLimit(a.id); appStore.showSuccess(t('common.success')); load() } catch (error) { console.error('Failed to clear rate limit:', error) } }
const handleDelete = (a: Account) => { deletingAcc.value = a; showDeleteDialog.value = true } const handleDelete = (a: Account) => { deletingAcc.value = a; showDeleteDialog.value = true }
const confirmDelete = async () => { if(!deletingAcc.value) return; try { await adminAPI.accounts.delete(deletingAcc.value.id); showDeleteDialog.value = false; deletingAcc.value = null; reload() } catch (error) { console.error('Failed to delete account:', error) } } const confirmDelete = async () => { if(!deletingAcc.value) return; try { await adminAPI.accounts.delete(deletingAcc.value.id); showDeleteDialog.value = false; deletingAcc.value = null; reload() } catch (error) { console.error('Failed to delete account:', error) } }
const handleToggleSchedulable = async (a: Account) => { togglingSchedulable.value = a.id; try { await adminAPI.accounts.setSchedulable(a.id, !a.schedulable); load() } finally { togglingSchedulable.value = null } } const handleToggleSchedulable = async (a: Account) => {
const nextSchedulable = !a.schedulable
togglingSchedulable.value = a.id
try {
const updated = await adminAPI.accounts.setSchedulable(a.id, nextSchedulable)
updateSchedulableInList([a.id], updated?.schedulable ?? nextSchedulable)
load().catch((error) => {
console.error('Failed to refresh accounts:', error)
})
} catch (error) {
console.error('Failed to toggle schedulable:', error)
appStore.showError(t('admin.accounts.failedToToggleSchedulable'))
} finally {
togglingSchedulable.value = null
}
}
const handleShowTempUnsched = (a: Account) => { tempUnschedAcc.value = a; showTempUnsched.value = true } const handleShowTempUnsched = (a: Account) => { tempUnschedAcc.value = a; showTempUnsched.value = true }
const handleTempUnschedReset = async () => { if(!tempUnschedAcc.value) return; try { await adminAPI.accounts.clearError(tempUnschedAcc.value.id); showTempUnsched.value = false; tempUnschedAcc.value = null; load() } catch (error) { console.error('Failed to reset temp unscheduled:', error) } } const handleTempUnschedReset = async () => { if(!tempUnschedAcc.value) return; try { await adminAPI.accounts.clearError(tempUnschedAcc.value.id); showTempUnsched.value = false; tempUnschedAcc.value = null; load() } catch (error) { console.error('Failed to reset temp unscheduled:', error) } }
const formatExpiresAt = (value: number | null) => { const formatExpiresAt = (value: number | null) => {
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment