perf(仪表盘): 增强统计缓存与隔离配置
新增仪表盘缓存开关与 TTL 配置,支持 Redis key 前缀隔离,并补充单测与校验。 测试: make test-backend
This commit is contained in:
@@ -75,7 +75,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
|||||||
redeemService := service.NewRedeemService(redeemCodeRepository, userRepository, subscriptionService, redeemCache, billingCacheService, client, apiKeyAuthCacheInvalidator)
|
redeemService := service.NewRedeemService(redeemCodeRepository, userRepository, subscriptionService, redeemCache, billingCacheService, client, apiKeyAuthCacheInvalidator)
|
||||||
redeemHandler := handler.NewRedeemHandler(redeemService)
|
redeemHandler := handler.NewRedeemHandler(redeemService)
|
||||||
subscriptionHandler := handler.NewSubscriptionHandler(subscriptionService)
|
subscriptionHandler := handler.NewSubscriptionHandler(subscriptionService)
|
||||||
dashboardService := service.NewDashboardService(usageLogRepository)
|
dashboardStatsCache := repository.NewDashboardCache(redisClient, configConfig)
|
||||||
|
dashboardService := service.NewDashboardService(usageLogRepository, dashboardStatsCache, configConfig)
|
||||||
dashboardHandler := admin.NewDashboardHandler(dashboardService)
|
dashboardHandler := admin.NewDashboardHandler(dashboardService)
|
||||||
accountRepository := repository.NewAccountRepository(client, db)
|
accountRepository := repository.NewAccountRepository(client, db)
|
||||||
proxyRepository := repository.NewProxyRepository(client, db)
|
proxyRepository := repository.NewProxyRepository(client, db)
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ type Config struct {
|
|||||||
Pricing PricingConfig `mapstructure:"pricing"`
|
Pricing PricingConfig `mapstructure:"pricing"`
|
||||||
Gateway GatewayConfig `mapstructure:"gateway"`
|
Gateway GatewayConfig `mapstructure:"gateway"`
|
||||||
APIKeyAuth APIKeyAuthCacheConfig `mapstructure:"api_key_auth_cache"`
|
APIKeyAuth APIKeyAuthCacheConfig `mapstructure:"api_key_auth_cache"`
|
||||||
|
Dashboard DashboardCacheConfig `mapstructure:"dashboard_cache"`
|
||||||
Concurrency ConcurrencyConfig `mapstructure:"concurrency"`
|
Concurrency ConcurrencyConfig `mapstructure:"concurrency"`
|
||||||
TokenRefresh TokenRefreshConfig `mapstructure:"token_refresh"`
|
TokenRefresh TokenRefreshConfig `mapstructure:"token_refresh"`
|
||||||
RunMode string `mapstructure:"run_mode" yaml:"run_mode"`
|
RunMode string `mapstructure:"run_mode" yaml:"run_mode"`
|
||||||
@@ -372,6 +373,20 @@ type APIKeyAuthCacheConfig struct {
|
|||||||
Singleflight bool `mapstructure:"singleflight"`
|
Singleflight bool `mapstructure:"singleflight"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DashboardCacheConfig 仪表盘统计缓存配置
|
||||||
|
type DashboardCacheConfig struct {
|
||||||
|
// Enabled: 是否启用仪表盘缓存
|
||||||
|
Enabled bool `mapstructure:"enabled"`
|
||||||
|
// KeyPrefix: Redis key 前缀,用于多环境隔离
|
||||||
|
KeyPrefix string `mapstructure:"key_prefix"`
|
||||||
|
// StatsFreshTTLSeconds: 缓存命中认为“新鲜”的时间窗口(秒)
|
||||||
|
StatsFreshTTLSeconds int `mapstructure:"stats_fresh_ttl_seconds"`
|
||||||
|
// StatsTTLSeconds: Redis 缓存总 TTL(秒)
|
||||||
|
StatsTTLSeconds int `mapstructure:"stats_ttl_seconds"`
|
||||||
|
// StatsRefreshTimeoutSeconds: 异步刷新超时(秒)
|
||||||
|
StatsRefreshTimeoutSeconds int `mapstructure:"stats_refresh_timeout_seconds"`
|
||||||
|
}
|
||||||
|
|
||||||
func NormalizeRunMode(value string) string {
|
func NormalizeRunMode(value string) string {
|
||||||
normalized := strings.ToLower(strings.TrimSpace(value))
|
normalized := strings.ToLower(strings.TrimSpace(value))
|
||||||
switch normalized {
|
switch normalized {
|
||||||
@@ -437,6 +452,7 @@ func Load() (*Config, error) {
|
|||||||
cfg.LinuxDo.UserInfoEmailPath = strings.TrimSpace(cfg.LinuxDo.UserInfoEmailPath)
|
cfg.LinuxDo.UserInfoEmailPath = strings.TrimSpace(cfg.LinuxDo.UserInfoEmailPath)
|
||||||
cfg.LinuxDo.UserInfoIDPath = strings.TrimSpace(cfg.LinuxDo.UserInfoIDPath)
|
cfg.LinuxDo.UserInfoIDPath = strings.TrimSpace(cfg.LinuxDo.UserInfoIDPath)
|
||||||
cfg.LinuxDo.UserInfoUsernamePath = strings.TrimSpace(cfg.LinuxDo.UserInfoUsernamePath)
|
cfg.LinuxDo.UserInfoUsernamePath = strings.TrimSpace(cfg.LinuxDo.UserInfoUsernamePath)
|
||||||
|
cfg.Dashboard.KeyPrefix = strings.TrimSpace(cfg.Dashboard.KeyPrefix)
|
||||||
cfg.CORS.AllowedOrigins = normalizeStringSlice(cfg.CORS.AllowedOrigins)
|
cfg.CORS.AllowedOrigins = normalizeStringSlice(cfg.CORS.AllowedOrigins)
|
||||||
cfg.Security.ResponseHeaders.AdditionalAllowed = normalizeStringSlice(cfg.Security.ResponseHeaders.AdditionalAllowed)
|
cfg.Security.ResponseHeaders.AdditionalAllowed = normalizeStringSlice(cfg.Security.ResponseHeaders.AdditionalAllowed)
|
||||||
cfg.Security.ResponseHeaders.ForceRemove = normalizeStringSlice(cfg.Security.ResponseHeaders.ForceRemove)
|
cfg.Security.ResponseHeaders.ForceRemove = normalizeStringSlice(cfg.Security.ResponseHeaders.ForceRemove)
|
||||||
@@ -674,6 +690,13 @@ func setDefaults() {
|
|||||||
viper.SetDefault("api_key_auth_cache.jitter_percent", 10)
|
viper.SetDefault("api_key_auth_cache.jitter_percent", 10)
|
||||||
viper.SetDefault("api_key_auth_cache.singleflight", true)
|
viper.SetDefault("api_key_auth_cache.singleflight", true)
|
||||||
|
|
||||||
|
// Dashboard cache
|
||||||
|
viper.SetDefault("dashboard_cache.enabled", true)
|
||||||
|
viper.SetDefault("dashboard_cache.key_prefix", "sub2api:")
|
||||||
|
viper.SetDefault("dashboard_cache.stats_fresh_ttl_seconds", 15)
|
||||||
|
viper.SetDefault("dashboard_cache.stats_ttl_seconds", 30)
|
||||||
|
viper.SetDefault("dashboard_cache.stats_refresh_timeout_seconds", 30)
|
||||||
|
|
||||||
// Gateway
|
// Gateway
|
||||||
viper.SetDefault("gateway.response_header_timeout", 600) // 600秒(10分钟)等待上游响应头,LLM高负载时可能排队较久
|
viper.SetDefault("gateway.response_header_timeout", 600) // 600秒(10分钟)等待上游响应头,LLM高负载时可能排队较久
|
||||||
viper.SetDefault("gateway.log_upstream_error_body", false)
|
viper.SetDefault("gateway.log_upstream_error_body", false)
|
||||||
@@ -832,6 +855,30 @@ func (c *Config) Validate() error {
|
|||||||
if c.Redis.MinIdleConns > c.Redis.PoolSize {
|
if c.Redis.MinIdleConns > c.Redis.PoolSize {
|
||||||
return fmt.Errorf("redis.min_idle_conns cannot exceed redis.pool_size")
|
return fmt.Errorf("redis.min_idle_conns cannot exceed redis.pool_size")
|
||||||
}
|
}
|
||||||
|
if c.Dashboard.Enabled {
|
||||||
|
if c.Dashboard.StatsFreshTTLSeconds <= 0 {
|
||||||
|
return fmt.Errorf("dashboard_cache.stats_fresh_ttl_seconds must be positive")
|
||||||
|
}
|
||||||
|
if c.Dashboard.StatsTTLSeconds <= 0 {
|
||||||
|
return fmt.Errorf("dashboard_cache.stats_ttl_seconds must be positive")
|
||||||
|
}
|
||||||
|
if c.Dashboard.StatsRefreshTimeoutSeconds <= 0 {
|
||||||
|
return fmt.Errorf("dashboard_cache.stats_refresh_timeout_seconds must be positive")
|
||||||
|
}
|
||||||
|
if c.Dashboard.StatsFreshTTLSeconds > c.Dashboard.StatsTTLSeconds {
|
||||||
|
return fmt.Errorf("dashboard_cache.stats_fresh_ttl_seconds must be <= dashboard_cache.stats_ttl_seconds")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if c.Dashboard.StatsFreshTTLSeconds < 0 {
|
||||||
|
return fmt.Errorf("dashboard_cache.stats_fresh_ttl_seconds must be non-negative")
|
||||||
|
}
|
||||||
|
if c.Dashboard.StatsTTLSeconds < 0 {
|
||||||
|
return fmt.Errorf("dashboard_cache.stats_ttl_seconds must be non-negative")
|
||||||
|
}
|
||||||
|
if c.Dashboard.StatsRefreshTimeoutSeconds < 0 {
|
||||||
|
return fmt.Errorf("dashboard_cache.stats_refresh_timeout_seconds must be non-negative")
|
||||||
|
}
|
||||||
|
}
|
||||||
if c.Gateway.MaxBodySize <= 0 {
|
if c.Gateway.MaxBodySize <= 0 {
|
||||||
return fmt.Errorf("gateway.max_body_size must be positive")
|
return fmt.Errorf("gateway.max_body_size must be positive")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,3 +141,67 @@ func TestValidateLinuxDoPKCERequiredForPublicClient(t *testing.T) {
|
|||||||
t.Fatalf("Validate() expected use_pkce error, got: %v", err)
|
t.Fatalf("Validate() expected use_pkce error, got: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadDefaultDashboardCacheConfig(t *testing.T) {
|
||||||
|
viper.Reset()
|
||||||
|
|
||||||
|
cfg, err := Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !cfg.Dashboard.Enabled {
|
||||||
|
t.Fatalf("Dashboard.Enabled = false, want true")
|
||||||
|
}
|
||||||
|
if cfg.Dashboard.KeyPrefix != "sub2api:" {
|
||||||
|
t.Fatalf("Dashboard.KeyPrefix = %q, want %q", cfg.Dashboard.KeyPrefix, "sub2api:")
|
||||||
|
}
|
||||||
|
if cfg.Dashboard.StatsFreshTTLSeconds != 15 {
|
||||||
|
t.Fatalf("Dashboard.StatsFreshTTLSeconds = %d, want 15", cfg.Dashboard.StatsFreshTTLSeconds)
|
||||||
|
}
|
||||||
|
if cfg.Dashboard.StatsTTLSeconds != 30 {
|
||||||
|
t.Fatalf("Dashboard.StatsTTLSeconds = %d, want 30", cfg.Dashboard.StatsTTLSeconds)
|
||||||
|
}
|
||||||
|
if cfg.Dashboard.StatsRefreshTimeoutSeconds != 30 {
|
||||||
|
t.Fatalf("Dashboard.StatsRefreshTimeoutSeconds = %d, want 30", cfg.Dashboard.StatsRefreshTimeoutSeconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateDashboardCacheConfigEnabled(t *testing.T) {
|
||||||
|
viper.Reset()
|
||||||
|
|
||||||
|
cfg, err := Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.Dashboard.Enabled = true
|
||||||
|
cfg.Dashboard.StatsFreshTTLSeconds = 10
|
||||||
|
cfg.Dashboard.StatsTTLSeconds = 5
|
||||||
|
err = cfg.Validate()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("Validate() expected error for stats_fresh_ttl_seconds > stats_ttl_seconds, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "dashboard_cache.stats_fresh_ttl_seconds") {
|
||||||
|
t.Fatalf("Validate() expected stats_fresh_ttl_seconds error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateDashboardCacheConfigDisabled(t *testing.T) {
|
||||||
|
viper.Reset()
|
||||||
|
|
||||||
|
cfg, err := Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.Dashboard.Enabled = false
|
||||||
|
cfg.Dashboard.StatsTTLSeconds = -1
|
||||||
|
err = cfg.Validate()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("Validate() expected error for negative stats_ttl_seconds, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "dashboard_cache.stats_ttl_seconds") {
|
||||||
|
t.Fatalf("Validate() expected stats_ttl_seconds error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
51
backend/internal/repository/dashboard_cache.go
Normal file
51
backend/internal/repository/dashboard_cache.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
)
|
||||||
|
|
||||||
|
const dashboardStatsCacheKey = "dashboard:stats:v1"
|
||||||
|
|
||||||
|
type dashboardCache struct {
|
||||||
|
rdb *redis.Client
|
||||||
|
keyPrefix string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDashboardCache(rdb *redis.Client, cfg *config.Config) service.DashboardStatsCache {
|
||||||
|
prefix := "sub2api:"
|
||||||
|
if cfg != nil {
|
||||||
|
prefix = strings.TrimSpace(cfg.Dashboard.KeyPrefix)
|
||||||
|
}
|
||||||
|
return &dashboardCache{
|
||||||
|
rdb: rdb,
|
||||||
|
keyPrefix: prefix,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *dashboardCache) GetDashboardStats(ctx context.Context) (string, error) {
|
||||||
|
val, err := c.rdb.Get(ctx, c.buildKey()).Result()
|
||||||
|
if err != nil {
|
||||||
|
if err == redis.Nil {
|
||||||
|
return "", service.ErrDashboardStatsCacheMiss
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return val, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *dashboardCache) SetDashboardStats(ctx context.Context, data string, ttl time.Duration) error {
|
||||||
|
return c.rdb.Set(ctx, c.buildKey(), data, ttl).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *dashboardCache) buildKey() string {
|
||||||
|
if c.keyPrefix == "" {
|
||||||
|
return dashboardStatsCacheKey
|
||||||
|
}
|
||||||
|
return c.keyPrefix + dashboardStatsCacheKey
|
||||||
|
}
|
||||||
@@ -58,6 +58,7 @@ var ProviderSet = wire.NewSet(
|
|||||||
NewAPIKeyCache,
|
NewAPIKeyCache,
|
||||||
NewTempUnschedCache,
|
NewTempUnschedCache,
|
||||||
ProvideConcurrencyCache,
|
ProvideConcurrencyCache,
|
||||||
|
NewDashboardCache,
|
||||||
NewEmailCache,
|
NewEmailCache,
|
||||||
NewIdentityCache,
|
NewIdentityCache,
|
||||||
NewRedeemCache,
|
NewRedeemCache,
|
||||||
|
|||||||
@@ -2,25 +2,89 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DashboardService provides aggregated statistics for admin dashboard.
|
const (
|
||||||
type DashboardService struct {
|
defaultDashboardStatsFreshTTL = 15 * time.Second
|
||||||
usageRepo UsageLogRepository
|
defaultDashboardStatsCacheTTL = 30 * time.Second
|
||||||
|
defaultDashboardStatsRefreshTimeout = 30 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrDashboardStatsCacheMiss 标记仪表盘缓存未命中。
|
||||||
|
var ErrDashboardStatsCacheMiss = errors.New("仪表盘缓存未命中")
|
||||||
|
|
||||||
|
// DashboardStatsCache 定义仪表盘统计缓存接口。
|
||||||
|
type DashboardStatsCache interface {
|
||||||
|
GetDashboardStats(ctx context.Context) (string, error)
|
||||||
|
SetDashboardStats(ctx context.Context, data string, ttl time.Duration) error
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDashboardService(usageRepo UsageLogRepository) *DashboardService {
|
type dashboardStatsCacheEntry struct {
|
||||||
|
Stats *usagestats.DashboardStats `json:"stats"`
|
||||||
|
UpdatedAt int64 `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DashboardService provides aggregated statistics for admin dashboard.
|
||||||
|
type DashboardService struct {
|
||||||
|
usageRepo UsageLogRepository
|
||||||
|
cache DashboardStatsCache
|
||||||
|
cacheFreshTTL time.Duration
|
||||||
|
cacheTTL time.Duration
|
||||||
|
refreshTimeout time.Duration
|
||||||
|
refreshing int32
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDashboardService(usageRepo UsageLogRepository, cache DashboardStatsCache, cfg *config.Config) *DashboardService {
|
||||||
|
freshTTL := defaultDashboardStatsFreshTTL
|
||||||
|
cacheTTL := defaultDashboardStatsCacheTTL
|
||||||
|
refreshTimeout := defaultDashboardStatsRefreshTimeout
|
||||||
|
if cfg != nil {
|
||||||
|
if !cfg.Dashboard.Enabled {
|
||||||
|
cache = nil
|
||||||
|
}
|
||||||
|
if cfg.Dashboard.StatsFreshTTLSeconds > 0 {
|
||||||
|
freshTTL = time.Duration(cfg.Dashboard.StatsFreshTTLSeconds) * time.Second
|
||||||
|
}
|
||||||
|
if cfg.Dashboard.StatsTTLSeconds > 0 {
|
||||||
|
cacheTTL = time.Duration(cfg.Dashboard.StatsTTLSeconds) * time.Second
|
||||||
|
}
|
||||||
|
if cfg.Dashboard.StatsRefreshTimeoutSeconds > 0 {
|
||||||
|
refreshTimeout = time.Duration(cfg.Dashboard.StatsRefreshTimeoutSeconds) * time.Second
|
||||||
|
}
|
||||||
|
}
|
||||||
return &DashboardService{
|
return &DashboardService{
|
||||||
usageRepo: usageRepo,
|
usageRepo: usageRepo,
|
||||||
|
cache: cache,
|
||||||
|
cacheFreshTTL: freshTTL,
|
||||||
|
cacheTTL: cacheTTL,
|
||||||
|
refreshTimeout: refreshTimeout,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *DashboardService) GetDashboardStats(ctx context.Context) (*usagestats.DashboardStats, error) {
|
func (s *DashboardService) GetDashboardStats(ctx context.Context) (*usagestats.DashboardStats, error) {
|
||||||
stats, err := s.usageRepo.GetDashboardStats(ctx)
|
if s.cache != nil {
|
||||||
|
cached, fresh, err := s.getCachedDashboardStats(ctx)
|
||||||
|
if err == nil && cached != nil {
|
||||||
|
if !fresh {
|
||||||
|
s.refreshDashboardStatsAsync()
|
||||||
|
}
|
||||||
|
return cached, nil
|
||||||
|
}
|
||||||
|
if err != nil && !errors.Is(err, ErrDashboardStatsCacheMiss) {
|
||||||
|
log.Printf("[Dashboard] 仪表盘缓存读取失败: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stats, err := s.refreshDashboardStats(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("get dashboard stats: %w", err)
|
return nil, fmt.Errorf("get dashboard stats: %w", err)
|
||||||
}
|
}
|
||||||
@@ -43,6 +107,76 @@ func (s *DashboardService) GetModelStatsWithFilters(ctx context.Context, startTi
|
|||||||
return stats, nil
|
return stats, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *DashboardService) getCachedDashboardStats(ctx context.Context) (*usagestats.DashboardStats, bool, error) {
|
||||||
|
data, err := s.cache.GetDashboardStats(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var entry dashboardStatsCacheEntry
|
||||||
|
if err := json.Unmarshal([]byte(data), &entry); err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
if entry.Stats == nil {
|
||||||
|
return nil, false, errors.New("仪表盘缓存缺少统计数据")
|
||||||
|
}
|
||||||
|
|
||||||
|
age := time.Since(time.Unix(entry.UpdatedAt, 0))
|
||||||
|
return entry.Stats, age <= s.cacheFreshTTL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DashboardService) refreshDashboardStats(ctx context.Context) (*usagestats.DashboardStats, error) {
|
||||||
|
stats, err := s.usageRepo.GetDashboardStats(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.saveDashboardStatsCache(ctx, stats)
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DashboardService) refreshDashboardStatsAsync() {
|
||||||
|
if s.cache == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !atomic.CompareAndSwapInt32(&s.refreshing, 0, 1) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer atomic.StoreInt32(&s.refreshing, 0)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), s.refreshTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
stats, err := s.usageRepo.GetDashboardStats(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[Dashboard] 仪表盘缓存异步刷新失败: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.saveDashboardStatsCache(ctx, stats)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DashboardService) saveDashboardStatsCache(ctx context.Context, stats *usagestats.DashboardStats) {
|
||||||
|
if s.cache == nil || stats == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := dashboardStatsCacheEntry{
|
||||||
|
Stats: stats,
|
||||||
|
UpdatedAt: time.Now().Unix(),
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(entry)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[Dashboard] 仪表盘缓存序列化失败: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.cache.SetDashboardStats(ctx, string(data), s.cacheTTL); err != nil {
|
||||||
|
log.Printf("[Dashboard] 仪表盘缓存写入失败: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *DashboardService) GetAPIKeyUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.APIKeyUsageTrendPoint, error) {
|
func (s *DashboardService) GetAPIKeyUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.APIKeyUsageTrendPoint, error) {
|
||||||
trend, err := s.usageRepo.GetAPIKeyUsageTrend(ctx, startTime, endTime, granularity, limit)
|
trend, err := s.usageRepo.GetAPIKeyUsageTrend(ctx, startTime, endTime, granularity, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
189
backend/internal/service/dashboard_service_test.go
Normal file
189
backend/internal/service/dashboard_service_test.go
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
type usageRepoStub struct {
|
||||||
|
UsageLogRepository
|
||||||
|
stats *usagestats.DashboardStats
|
||||||
|
err error
|
||||||
|
calls int32
|
||||||
|
onCall chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *usageRepoStub) GetDashboardStats(ctx context.Context) (*usagestats.DashboardStats, error) {
|
||||||
|
atomic.AddInt32(&s.calls, 1)
|
||||||
|
if s.onCall != nil {
|
||||||
|
select {
|
||||||
|
case s.onCall <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.err != nil {
|
||||||
|
return nil, s.err
|
||||||
|
}
|
||||||
|
return s.stats, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type dashboardCacheStub struct {
|
||||||
|
get func(ctx context.Context) (string, error)
|
||||||
|
set func(ctx context.Context, data string, ttl time.Duration) error
|
||||||
|
getCalls int32
|
||||||
|
setCalls int32
|
||||||
|
lastSetMu sync.Mutex
|
||||||
|
lastSet string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *dashboardCacheStub) GetDashboardStats(ctx context.Context) (string, error) {
|
||||||
|
atomic.AddInt32(&c.getCalls, 1)
|
||||||
|
if c.get != nil {
|
||||||
|
return c.get(ctx)
|
||||||
|
}
|
||||||
|
return "", ErrDashboardStatsCacheMiss
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *dashboardCacheStub) SetDashboardStats(ctx context.Context, data string, ttl time.Duration) error {
|
||||||
|
atomic.AddInt32(&c.setCalls, 1)
|
||||||
|
c.lastSetMu.Lock()
|
||||||
|
c.lastSet = data
|
||||||
|
c.lastSetMu.Unlock()
|
||||||
|
if c.set != nil {
|
||||||
|
return c.set(ctx, data, ttl)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *dashboardCacheStub) readLastEntry(t *testing.T) dashboardStatsCacheEntry {
|
||||||
|
t.Helper()
|
||||||
|
c.lastSetMu.Lock()
|
||||||
|
data := c.lastSet
|
||||||
|
c.lastSetMu.Unlock()
|
||||||
|
|
||||||
|
var entry dashboardStatsCacheEntry
|
||||||
|
err := json.Unmarshal([]byte(data), &entry)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDashboardService_CacheHitFresh(t *testing.T) {
|
||||||
|
stats := &usagestats.DashboardStats{
|
||||||
|
TotalUsers: 10,
|
||||||
|
}
|
||||||
|
entry := dashboardStatsCacheEntry{
|
||||||
|
Stats: stats,
|
||||||
|
UpdatedAt: time.Now().Unix(),
|
||||||
|
}
|
||||||
|
payload, err := json.Marshal(entry)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
cache := &dashboardCacheStub{
|
||||||
|
get: func(ctx context.Context) (string, error) {
|
||||||
|
return string(payload), nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
repo := &usageRepoStub{
|
||||||
|
stats: &usagestats.DashboardStats{TotalUsers: 99},
|
||||||
|
}
|
||||||
|
cfg := &config.Config{Dashboard: config.DashboardCacheConfig{Enabled: true}}
|
||||||
|
svc := NewDashboardService(repo, cache, cfg)
|
||||||
|
|
||||||
|
got, err := svc.GetDashboardStats(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, stats, got)
|
||||||
|
require.Equal(t, int32(0), atomic.LoadInt32(&repo.calls))
|
||||||
|
require.Equal(t, int32(1), atomic.LoadInt32(&cache.getCalls))
|
||||||
|
require.Equal(t, int32(0), atomic.LoadInt32(&cache.setCalls))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDashboardService_CacheMiss_StoresCache(t *testing.T) {
|
||||||
|
stats := &usagestats.DashboardStats{
|
||||||
|
TotalUsers: 7,
|
||||||
|
}
|
||||||
|
cache := &dashboardCacheStub{
|
||||||
|
get: func(ctx context.Context) (string, error) {
|
||||||
|
return "", ErrDashboardStatsCacheMiss
|
||||||
|
},
|
||||||
|
}
|
||||||
|
repo := &usageRepoStub{stats: stats}
|
||||||
|
cfg := &config.Config{Dashboard: config.DashboardCacheConfig{Enabled: true}}
|
||||||
|
svc := NewDashboardService(repo, cache, cfg)
|
||||||
|
|
||||||
|
got, err := svc.GetDashboardStats(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, stats, got)
|
||||||
|
require.Equal(t, int32(1), atomic.LoadInt32(&repo.calls))
|
||||||
|
require.Equal(t, int32(1), atomic.LoadInt32(&cache.getCalls))
|
||||||
|
require.Equal(t, int32(1), atomic.LoadInt32(&cache.setCalls))
|
||||||
|
entry := cache.readLastEntry(t)
|
||||||
|
require.Equal(t, stats, entry.Stats)
|
||||||
|
require.WithinDuration(t, time.Now(), time.Unix(entry.UpdatedAt, 0), time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDashboardService_CacheDisabled_SkipsCache(t *testing.T) {
|
||||||
|
stats := &usagestats.DashboardStats{
|
||||||
|
TotalUsers: 3,
|
||||||
|
}
|
||||||
|
cache := &dashboardCacheStub{
|
||||||
|
get: func(ctx context.Context) (string, error) {
|
||||||
|
return "", nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
repo := &usageRepoStub{stats: stats}
|
||||||
|
cfg := &config.Config{Dashboard: config.DashboardCacheConfig{Enabled: false}}
|
||||||
|
svc := NewDashboardService(repo, cache, cfg)
|
||||||
|
|
||||||
|
got, err := svc.GetDashboardStats(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, stats, got)
|
||||||
|
require.Equal(t, int32(1), atomic.LoadInt32(&repo.calls))
|
||||||
|
require.Equal(t, int32(0), atomic.LoadInt32(&cache.getCalls))
|
||||||
|
require.Equal(t, int32(0), atomic.LoadInt32(&cache.setCalls))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDashboardService_CacheHitStale_TriggersAsyncRefresh(t *testing.T) {
|
||||||
|
staleStats := &usagestats.DashboardStats{
|
||||||
|
TotalUsers: 11,
|
||||||
|
}
|
||||||
|
entry := dashboardStatsCacheEntry{
|
||||||
|
Stats: staleStats,
|
||||||
|
UpdatedAt: time.Now().Add(-defaultDashboardStatsFreshTTL * 2).Unix(),
|
||||||
|
}
|
||||||
|
payload, err := json.Marshal(entry)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
cache := &dashboardCacheStub{
|
||||||
|
get: func(ctx context.Context) (string, error) {
|
||||||
|
return string(payload), nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
refreshCh := make(chan struct{}, 1)
|
||||||
|
repo := &usageRepoStub{
|
||||||
|
stats: &usagestats.DashboardStats{TotalUsers: 22},
|
||||||
|
onCall: refreshCh,
|
||||||
|
}
|
||||||
|
cfg := &config.Config{Dashboard: config.DashboardCacheConfig{Enabled: true}}
|
||||||
|
svc := NewDashboardService(repo, cache, cfg)
|
||||||
|
|
||||||
|
got, err := svc.GetDashboardStats(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, staleStats, got)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-refreshCh:
|
||||||
|
case <-time.After(1 * time.Second):
|
||||||
|
t.Fatal("等待异步刷新超时")
|
||||||
|
}
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
return atomic.LoadInt32(&cache.setCalls) >= 1
|
||||||
|
}, 1*time.Second, 10*time.Millisecond)
|
||||||
|
}
|
||||||
21
config.yaml
21
config.yaml
@@ -194,6 +194,27 @@ api_key_auth_cache:
|
|||||||
# 缓存未命中时启用 singleflight 合并回源
|
# 缓存未命中时启用 singleflight 合并回源
|
||||||
singleflight: true
|
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
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Concurrency Wait Configuration
|
# Concurrency Wait Configuration
|
||||||
# 并发等待配置
|
# 并发等待配置
|
||||||
|
|||||||
@@ -194,6 +194,27 @@ api_key_auth_cache:
|
|||||||
# 缓存未命中时启用 singleflight 合并回源
|
# 缓存未命中时启用 singleflight 合并回源
|
||||||
singleflight: true
|
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
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Concurrency Wait Configuration
|
# Concurrency Wait Configuration
|
||||||
# 并发等待配置
|
# 并发等待配置
|
||||||
|
|||||||
Reference in New Issue
Block a user