Skip to content

Commit eb60f67

Browse files
authored
Merge pull request Wei-Shaw#933 from xvhuan/fix/dashboard-read-pressure-20260311
降低 admin/dashboard 读路径压力,避免 snapshot-v2 并发击穿
2 parents 78193ce + 8c2dd7b commit eb60f67

6 files changed

Lines changed: 480 additions & 47 deletions

File tree

backend/internal/handler/admin/dashboard_handler.go

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -249,11 +249,12 @@ func (h *DashboardHandler) GetUsageTrend(c *gin.Context) {
249249
}
250250
}
251251

252-
trend, err := h.dashboardService.GetUsageTrendWithFilters(c.Request.Context(), startTime, endTime, granularity, userID, apiKeyID, accountID, groupID, model, requestType, stream, billingType)
252+
trend, hit, err := h.getUsageTrendCached(c.Request.Context(), startTime, endTime, granularity, userID, apiKeyID, accountID, groupID, model, requestType, stream, billingType)
253253
if err != nil {
254254
response.Error(c, 500, "Failed to get usage trend")
255255
return
256256
}
257+
c.Header("X-Snapshot-Cache", cacheStatusValue(hit))
257258

258259
response.Success(c, gin.H{
259260
"trend": trend,
@@ -321,11 +322,12 @@ func (h *DashboardHandler) GetModelStats(c *gin.Context) {
321322
}
322323
}
323324

324-
stats, err := h.dashboardService.GetModelStatsWithFilters(c.Request.Context(), startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType)
325+
stats, hit, err := h.getModelStatsCached(c.Request.Context(), startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType)
325326
if err != nil {
326327
response.Error(c, 500, "Failed to get model statistics")
327328
return
328329
}
330+
c.Header("X-Snapshot-Cache", cacheStatusValue(hit))
329331

330332
response.Success(c, gin.H{
331333
"models": stats,
@@ -391,11 +393,12 @@ func (h *DashboardHandler) GetGroupStats(c *gin.Context) {
391393
}
392394
}
393395

394-
stats, err := h.dashboardService.GetGroupStatsWithFilters(c.Request.Context(), startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType)
396+
stats, hit, err := h.getGroupStatsCached(c.Request.Context(), startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType)
395397
if err != nil {
396398
response.Error(c, 500, "Failed to get group statistics")
397399
return
398400
}
401+
c.Header("X-Snapshot-Cache", cacheStatusValue(hit))
399402

400403
response.Success(c, gin.H{
401404
"groups": stats,
@@ -416,11 +419,12 @@ func (h *DashboardHandler) GetAPIKeyUsageTrend(c *gin.Context) {
416419
limit = 5
417420
}
418421

419-
trend, err := h.dashboardService.GetAPIKeyUsageTrend(c.Request.Context(), startTime, endTime, granularity, limit)
422+
trend, hit, err := h.getAPIKeyUsageTrendCached(c.Request.Context(), startTime, endTime, granularity, limit)
420423
if err != nil {
421424
response.Error(c, 500, "Failed to get API key usage trend")
422425
return
423426
}
427+
c.Header("X-Snapshot-Cache", cacheStatusValue(hit))
424428

425429
response.Success(c, gin.H{
426430
"trend": trend,
@@ -442,11 +446,12 @@ func (h *DashboardHandler) GetUserUsageTrend(c *gin.Context) {
442446
limit = 12
443447
}
444448

445-
trend, err := h.dashboardService.GetUserUsageTrend(c.Request.Context(), startTime, endTime, granularity, limit)
449+
trend, hit, err := h.getUserUsageTrendCached(c.Request.Context(), startTime, endTime, granularity, limit)
446450
if err != nil {
447451
response.Error(c, 500, "Failed to get user usage trend")
448452
return
449453
}
454+
c.Header("X-Snapshot-Cache", cacheStatusValue(hit))
450455

451456
response.Success(c, gin.H{
452457
"trend": trend,
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
package admin
2+
3+
import (
4+
"context"
5+
"net/http"
6+
"net/http/httptest"
7+
"sync/atomic"
8+
"testing"
9+
"time"
10+
11+
"github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
12+
"github.com/Wei-Shaw/sub2api/internal/service"
13+
"github.com/gin-gonic/gin"
14+
"github.com/stretchr/testify/require"
15+
)
16+
17+
type dashboardUsageRepoCacheProbe struct {
18+
service.UsageLogRepository
19+
trendCalls atomic.Int32
20+
usersTrendCalls atomic.Int32
21+
}
22+
23+
func (r *dashboardUsageRepoCacheProbe) GetUsageTrendWithFilters(
24+
ctx context.Context,
25+
startTime, endTime time.Time,
26+
granularity string,
27+
userID, apiKeyID, accountID, groupID int64,
28+
model string,
29+
requestType *int16,
30+
stream *bool,
31+
billingType *int8,
32+
) ([]usagestats.TrendDataPoint, error) {
33+
r.trendCalls.Add(1)
34+
return []usagestats.TrendDataPoint{{
35+
Date: "2026-03-11",
36+
Requests: 1,
37+
TotalTokens: 2,
38+
Cost: 3,
39+
ActualCost: 4,
40+
}}, nil
41+
}
42+
43+
func (r *dashboardUsageRepoCacheProbe) GetUserUsageTrend(
44+
ctx context.Context,
45+
startTime, endTime time.Time,
46+
granularity string,
47+
limit int,
48+
) ([]usagestats.UserUsageTrendPoint, error) {
49+
r.usersTrendCalls.Add(1)
50+
return []usagestats.UserUsageTrendPoint{{
51+
Date: "2026-03-11",
52+
UserID: 1,
53+
Email: "cache@test.dev",
54+
Requests: 2,
55+
Tokens: 20,
56+
Cost: 2,
57+
ActualCost: 1,
58+
}}, nil
59+
}
60+
61+
func resetDashboardReadCachesForTest() {
62+
dashboardTrendCache = newSnapshotCache(30 * time.Second)
63+
dashboardUsersTrendCache = newSnapshotCache(30 * time.Second)
64+
dashboardAPIKeysTrendCache = newSnapshotCache(30 * time.Second)
65+
dashboardModelStatsCache = newSnapshotCache(30 * time.Second)
66+
dashboardGroupStatsCache = newSnapshotCache(30 * time.Second)
67+
dashboardSnapshotV2Cache = newSnapshotCache(30 * time.Second)
68+
}
69+
70+
func TestDashboardHandler_GetUsageTrend_UsesCache(t *testing.T) {
71+
t.Cleanup(resetDashboardReadCachesForTest)
72+
resetDashboardReadCachesForTest()
73+
74+
gin.SetMode(gin.TestMode)
75+
repo := &dashboardUsageRepoCacheProbe{}
76+
dashboardSvc := service.NewDashboardService(repo, nil, nil, nil)
77+
handler := NewDashboardHandler(dashboardSvc, nil)
78+
router := gin.New()
79+
router.GET("/admin/dashboard/trend", handler.GetUsageTrend)
80+
81+
req1 := httptest.NewRequest(http.MethodGet, "/admin/dashboard/trend?start_date=2026-03-01&end_date=2026-03-07&granularity=day", nil)
82+
rec1 := httptest.NewRecorder()
83+
router.ServeHTTP(rec1, req1)
84+
require.Equal(t, http.StatusOK, rec1.Code)
85+
require.Equal(t, "miss", rec1.Header().Get("X-Snapshot-Cache"))
86+
87+
req2 := httptest.NewRequest(http.MethodGet, "/admin/dashboard/trend?start_date=2026-03-01&end_date=2026-03-07&granularity=day", nil)
88+
rec2 := httptest.NewRecorder()
89+
router.ServeHTTP(rec2, req2)
90+
require.Equal(t, http.StatusOK, rec2.Code)
91+
require.Equal(t, "hit", rec2.Header().Get("X-Snapshot-Cache"))
92+
require.Equal(t, int32(1), repo.trendCalls.Load())
93+
}
94+
95+
func TestDashboardHandler_GetUserUsageTrend_UsesCache(t *testing.T) {
96+
t.Cleanup(resetDashboardReadCachesForTest)
97+
resetDashboardReadCachesForTest()
98+
99+
gin.SetMode(gin.TestMode)
100+
repo := &dashboardUsageRepoCacheProbe{}
101+
dashboardSvc := service.NewDashboardService(repo, nil, nil, nil)
102+
handler := NewDashboardHandler(dashboardSvc, nil)
103+
router := gin.New()
104+
router.GET("/admin/dashboard/users-trend", handler.GetUserUsageTrend)
105+
106+
req1 := httptest.NewRequest(http.MethodGet, "/admin/dashboard/users-trend?start_date=2026-03-01&end_date=2026-03-07&granularity=day&limit=8", nil)
107+
rec1 := httptest.NewRecorder()
108+
router.ServeHTTP(rec1, req1)
109+
require.Equal(t, http.StatusOK, rec1.Code)
110+
require.Equal(t, "miss", rec1.Header().Get("X-Snapshot-Cache"))
111+
112+
req2 := httptest.NewRequest(http.MethodGet, "/admin/dashboard/users-trend?start_date=2026-03-01&end_date=2026-03-07&granularity=day&limit=8", nil)
113+
rec2 := httptest.NewRecorder()
114+
router.ServeHTTP(rec2, req2)
115+
require.Equal(t, http.StatusOK, rec2.Code)
116+
require.Equal(t, "hit", rec2.Header().Get("X-Snapshot-Cache"))
117+
require.Equal(t, int32(1), repo.usersTrendCalls.Load())
118+
}
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
package admin
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"time"
8+
9+
"github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
10+
)
11+
12+
var (
13+
dashboardTrendCache = newSnapshotCache(30 * time.Second)
14+
dashboardModelStatsCache = newSnapshotCache(30 * time.Second)
15+
dashboardGroupStatsCache = newSnapshotCache(30 * time.Second)
16+
dashboardUsersTrendCache = newSnapshotCache(30 * time.Second)
17+
dashboardAPIKeysTrendCache = newSnapshotCache(30 * time.Second)
18+
)
19+
20+
type dashboardTrendCacheKey struct {
21+
StartTime string `json:"start_time"`
22+
EndTime string `json:"end_time"`
23+
Granularity string `json:"granularity"`
24+
UserID int64 `json:"user_id"`
25+
APIKeyID int64 `json:"api_key_id"`
26+
AccountID int64 `json:"account_id"`
27+
GroupID int64 `json:"group_id"`
28+
Model string `json:"model"`
29+
RequestType *int16 `json:"request_type"`
30+
Stream *bool `json:"stream"`
31+
BillingType *int8 `json:"billing_type"`
32+
}
33+
34+
type dashboardModelGroupCacheKey struct {
35+
StartTime string `json:"start_time"`
36+
EndTime string `json:"end_time"`
37+
UserID int64 `json:"user_id"`
38+
APIKeyID int64 `json:"api_key_id"`
39+
AccountID int64 `json:"account_id"`
40+
GroupID int64 `json:"group_id"`
41+
RequestType *int16 `json:"request_type"`
42+
Stream *bool `json:"stream"`
43+
BillingType *int8 `json:"billing_type"`
44+
}
45+
46+
type dashboardEntityTrendCacheKey struct {
47+
StartTime string `json:"start_time"`
48+
EndTime string `json:"end_time"`
49+
Granularity string `json:"granularity"`
50+
Limit int `json:"limit"`
51+
}
52+
53+
func cacheStatusValue(hit bool) string {
54+
if hit {
55+
return "hit"
56+
}
57+
return "miss"
58+
}
59+
60+
func mustMarshalDashboardCacheKey(value any) string {
61+
raw, err := json.Marshal(value)
62+
if err != nil {
63+
return ""
64+
}
65+
return string(raw)
66+
}
67+
68+
func snapshotPayloadAs[T any](payload any) (T, error) {
69+
typed, ok := payload.(T)
70+
if !ok {
71+
var zero T
72+
return zero, fmt.Errorf("unexpected cache payload type %T", payload)
73+
}
74+
return typed, nil
75+
}
76+
77+
func (h *DashboardHandler) getUsageTrendCached(
78+
ctx context.Context,
79+
startTime, endTime time.Time,
80+
granularity string,
81+
userID, apiKeyID, accountID, groupID int64,
82+
model string,
83+
requestType *int16,
84+
stream *bool,
85+
billingType *int8,
86+
) ([]usagestats.TrendDataPoint, bool, error) {
87+
key := mustMarshalDashboardCacheKey(dashboardTrendCacheKey{
88+
StartTime: startTime.UTC().Format(time.RFC3339),
89+
EndTime: endTime.UTC().Format(time.RFC3339),
90+
Granularity: granularity,
91+
UserID: userID,
92+
APIKeyID: apiKeyID,
93+
AccountID: accountID,
94+
GroupID: groupID,
95+
Model: model,
96+
RequestType: requestType,
97+
Stream: stream,
98+
BillingType: billingType,
99+
})
100+
entry, hit, err := dashboardTrendCache.GetOrLoad(key, func() (any, error) {
101+
return h.dashboardService.GetUsageTrendWithFilters(ctx, startTime, endTime, granularity, userID, apiKeyID, accountID, groupID, model, requestType, stream, billingType)
102+
})
103+
if err != nil {
104+
return nil, hit, err
105+
}
106+
trend, err := snapshotPayloadAs[[]usagestats.TrendDataPoint](entry.Payload)
107+
return trend, hit, err
108+
}
109+
110+
func (h *DashboardHandler) getModelStatsCached(
111+
ctx context.Context,
112+
startTime, endTime time.Time,
113+
userID, apiKeyID, accountID, groupID int64,
114+
requestType *int16,
115+
stream *bool,
116+
billingType *int8,
117+
) ([]usagestats.ModelStat, bool, error) {
118+
key := mustMarshalDashboardCacheKey(dashboardModelGroupCacheKey{
119+
StartTime: startTime.UTC().Format(time.RFC3339),
120+
EndTime: endTime.UTC().Format(time.RFC3339),
121+
UserID: userID,
122+
APIKeyID: apiKeyID,
123+
AccountID: accountID,
124+
GroupID: groupID,
125+
RequestType: requestType,
126+
Stream: stream,
127+
BillingType: billingType,
128+
})
129+
entry, hit, err := dashboardModelStatsCache.GetOrLoad(key, func() (any, error) {
130+
return h.dashboardService.GetModelStatsWithFilters(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType)
131+
})
132+
if err != nil {
133+
return nil, hit, err
134+
}
135+
stats, err := snapshotPayloadAs[[]usagestats.ModelStat](entry.Payload)
136+
return stats, hit, err
137+
}
138+
139+
func (h *DashboardHandler) getGroupStatsCached(
140+
ctx context.Context,
141+
startTime, endTime time.Time,
142+
userID, apiKeyID, accountID, groupID int64,
143+
requestType *int16,
144+
stream *bool,
145+
billingType *int8,
146+
) ([]usagestats.GroupStat, bool, error) {
147+
key := mustMarshalDashboardCacheKey(dashboardModelGroupCacheKey{
148+
StartTime: startTime.UTC().Format(time.RFC3339),
149+
EndTime: endTime.UTC().Format(time.RFC3339),
150+
UserID: userID,
151+
APIKeyID: apiKeyID,
152+
AccountID: accountID,
153+
GroupID: groupID,
154+
RequestType: requestType,
155+
Stream: stream,
156+
BillingType: billingType,
157+
})
158+
entry, hit, err := dashboardGroupStatsCache.GetOrLoad(key, func() (any, error) {
159+
return h.dashboardService.GetGroupStatsWithFilters(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType)
160+
})
161+
if err != nil {
162+
return nil, hit, err
163+
}
164+
stats, err := snapshotPayloadAs[[]usagestats.GroupStat](entry.Payload)
165+
return stats, hit, err
166+
}
167+
168+
func (h *DashboardHandler) getAPIKeyUsageTrendCached(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.APIKeyUsageTrendPoint, bool, error) {
169+
key := mustMarshalDashboardCacheKey(dashboardEntityTrendCacheKey{
170+
StartTime: startTime.UTC().Format(time.RFC3339),
171+
EndTime: endTime.UTC().Format(time.RFC3339),
172+
Granularity: granularity,
173+
Limit: limit,
174+
})
175+
entry, hit, err := dashboardAPIKeysTrendCache.GetOrLoad(key, func() (any, error) {
176+
return h.dashboardService.GetAPIKeyUsageTrend(ctx, startTime, endTime, granularity, limit)
177+
})
178+
if err != nil {
179+
return nil, hit, err
180+
}
181+
trend, err := snapshotPayloadAs[[]usagestats.APIKeyUsageTrendPoint](entry.Payload)
182+
return trend, hit, err
183+
}
184+
185+
func (h *DashboardHandler) getUserUsageTrendCached(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.UserUsageTrendPoint, bool, error) {
186+
key := mustMarshalDashboardCacheKey(dashboardEntityTrendCacheKey{
187+
StartTime: startTime.UTC().Format(time.RFC3339),
188+
EndTime: endTime.UTC().Format(time.RFC3339),
189+
Granularity: granularity,
190+
Limit: limit,
191+
})
192+
entry, hit, err := dashboardUsersTrendCache.GetOrLoad(key, func() (any, error) {
193+
return h.dashboardService.GetUserUsageTrend(ctx, startTime, endTime, granularity, limit)
194+
})
195+
if err != nil {
196+
return nil, hit, err
197+
}
198+
trend, err := snapshotPayloadAs[[]usagestats.UserUsageTrendPoint](entry.Payload)
199+
return trend, hit, err
200+
}

0 commit comments

Comments
 (0)