Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
c2c865b0
Commit
c2c865b0
authored
Jan 11, 2026
by
yangjianbo
Browse files
perf(仪表盘): 增强统计缓存与隔离配置
新增仪表盘缓存开关与 TTL 配置,支持 Redis key 前缀隔离,并补充单测与校验。 测试: make test-backend
parent
a16f72f5
Changes
9
Show whitespace changes
Inline
Side-by-side
backend/cmd/server/wire_gen.go
View file @
c2c865b0
...
...
@@ -75,7 +75,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
redeemService
:=
service
.
NewRedeemService
(
redeemCodeRepository
,
userRepository
,
subscriptionService
,
redeemCache
,
billingCacheService
,
client
,
apiKeyAuthCacheInvalidator
)
redeemHandler
:=
handler
.
NewRedeemHandler
(
redeemService
)
subscriptionHandler
:=
handler
.
NewSubscriptionHandler
(
subscriptionService
)
dashboardService
:=
service
.
NewDashboardService
(
usageLogRepository
)
dashboardStatsCache
:=
repository
.
NewDashboardCache
(
redisClient
,
configConfig
)
dashboardService
:=
service
.
NewDashboardService
(
usageLogRepository
,
dashboardStatsCache
,
configConfig
)
dashboardHandler
:=
admin
.
NewDashboardHandler
(
dashboardService
)
accountRepository
:=
repository
.
NewAccountRepository
(
client
,
db
)
proxyRepository
:=
repository
.
NewProxyRepository
(
client
,
db
)
...
...
backend/internal/config/config.go
View file @
c2c865b0
...
...
@@ -50,6 +50,7 @@ type Config struct {
Pricing
PricingConfig
`mapstructure:"pricing"`
Gateway
GatewayConfig
`mapstructure:"gateway"`
APIKeyAuth
APIKeyAuthCacheConfig
`mapstructure:"api_key_auth_cache"`
Dashboard
DashboardCacheConfig
`mapstructure:"dashboard_cache"`
Concurrency
ConcurrencyConfig
`mapstructure:"concurrency"`
TokenRefresh
TokenRefreshConfig
`mapstructure:"token_refresh"`
RunMode
string
`mapstructure:"run_mode" yaml:"run_mode"`
...
...
@@ -372,6 +373,20 @@ type APIKeyAuthCacheConfig struct {
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
{
normalized
:=
strings
.
ToLower
(
strings
.
TrimSpace
(
value
))
switch
normalized
{
...
...
@@ -437,6 +452,7 @@ func Load() (*Config, error) {
cfg
.
LinuxDo
.
UserInfoEmailPath
=
strings
.
TrimSpace
(
cfg
.
LinuxDo
.
UserInfoEmailPath
)
cfg
.
LinuxDo
.
UserInfoIDPath
=
strings
.
TrimSpace
(
cfg
.
LinuxDo
.
UserInfoIDPath
)
cfg
.
LinuxDo
.
UserInfoUsernamePath
=
strings
.
TrimSpace
(
cfg
.
LinuxDo
.
UserInfoUsernamePath
)
cfg
.
Dashboard
.
KeyPrefix
=
strings
.
TrimSpace
(
cfg
.
Dashboard
.
KeyPrefix
)
cfg
.
CORS
.
AllowedOrigins
=
normalizeStringSlice
(
cfg
.
CORS
.
AllowedOrigins
)
cfg
.
Security
.
ResponseHeaders
.
AdditionalAllowed
=
normalizeStringSlice
(
cfg
.
Security
.
ResponseHeaders
.
AdditionalAllowed
)
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.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
viper
.
SetDefault
(
"gateway.response_header_timeout"
,
600
)
// 600秒(10分钟)等待上游响应头,LLM高负载时可能排队较久
viper
.
SetDefault
(
"gateway.log_upstream_error_body"
,
false
)
...
...
@@ -832,6 +855,30 @@ func (c *Config) Validate() error {
if
c
.
Redis
.
MinIdleConns
>
c
.
Redis
.
PoolSize
{
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
{
return
fmt
.
Errorf
(
"gateway.max_body_size must be positive"
)
}
...
...
backend/internal/config/config_test.go
View file @
c2c865b0
...
...
@@ -141,3 +141,67 @@ func TestValidateLinuxDoPKCERequiredForPublicClient(t *testing.T) {
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
)
}
}
backend/internal/repository/dashboard_cache.go
0 → 100644
View file @
c2c865b0
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
}
backend/internal/repository/wire.go
View file @
c2c865b0
...
...
@@ -58,6 +58,7 @@ var ProviderSet = wire.NewSet(
NewAPIKeyCache
,
NewTempUnschedCache
,
ProvideConcurrencyCache
,
NewDashboardCache
,
NewEmailCache
,
NewIdentityCache
,
NewRedeemCache
,
...
...
backend/internal/service/dashboard_service.go
View file @
c2c865b0
...
...
@@ -2,25 +2,89 @@ package service
import
(
"context"
"encoding/json"
"errors"
"fmt"
"log"
"sync/atomic"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
)
const
(
defaultDashboardStatsFreshTTL
=
15
*
time
.
Second
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
}
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
)
*
DashboardService
{
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
{
usageRepo
:
usageRepo
,
cache
:
cache
,
cacheFreshTTL
:
freshTTL
,
cacheTTL
:
cacheTTL
,
refreshTimeout
:
refreshTimeout
,
}
}
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
{
return
nil
,
fmt
.
Errorf
(
"get dashboard stats: %w"
,
err
)
}
...
...
@@ -43,6 +107,76 @@ func (s *DashboardService) GetModelStatsWithFilters(ctx context.Context, startTi
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
)
{
trend
,
err
:=
s
.
usageRepo
.
GetAPIKeyUsageTrend
(
ctx
,
startTime
,
endTime
,
granularity
,
limit
)
if
err
!=
nil
{
...
...
backend/internal/service/dashboard_service_test.go
0 → 100644
View file @
c2c865b0
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
)
}
config.yaml
View file @
c2c865b0
...
...
@@ -194,6 +194,27 @@ api_key_auth_cache:
# 缓存未命中时启用 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
# =============================================================================
# Concurrency Wait Configuration
# 并发等待配置
...
...
deploy/config.example.yaml
View file @
c2c865b0
...
...
@@ -194,6 +194,27 @@ api_key_auth_cache:
# 缓存未命中时启用 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
# =============================================================================
# Concurrency Wait Configuration
# 并发等待配置
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment