mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
perf(usage): 优化 /admin/usage 打开速度与刷新响应
根因:页面 mount 并发 6 个请求,其中 5 个在原始 usage_logs 上 live 聚合, 且有 1 个重复 getModelStats。优化(不引入预聚合): 前端 - UsageView 经 :model-options 下传 model 列表,移除 UsageFilters 重复的 getModelStats(mount 请求 6→5,少一次 usage_logs 全表 GROUP BY model) - 刷新/换筛选保留旧模型数据(invalidateModelStatsCache 只失效标记不清空数据), 图表不再闪空,刷新期间页面保持可交互 后端 - GetStatsWithFilters 4 条聚合 errgroup 并行(仅 *sql.DB 连接池路径,ent.Tx 顺序回退以保事务内不并发),endpoint 明细 best-effort;抑制取消级联噪声日志 - /admin/usage/stats 复用 dashboard 的 newSnapshotCache 30s 处理器层缓存,按 filters+窗口为 key;前端手动刷新带 nocache=1 强制回源(刷新=最新) 注:自合并提交 4c8396c 迁移而来,仅取"列表打开速度与刷新响应"部分;原提交的 "审计查看弹窗大 body 渲染"改动(AuditLogModal / audit-log-format / 审计 i18n) 依赖尚未迁移的审计功能(d8389ade),本次已排除。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
QTom
co-authored by
Claude Opus 4.8
parent
b60d8bb4cc
commit
bf24b61139
@@ -325,10 +325,24 @@ func (h *UsageHandler) Stats(c *gin.Context) {
|
||||
EndTime: &endTime,
|
||||
}
|
||||
|
||||
stats, err := h.usageService.GetStatsWithFilters(c.Request.Context(), filters)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
var stats *usagestats.UsageStats
|
||||
// nocache: 绕过缓存直接回源,刷新者本人拿最新;不回写缓存(管理台"我刷新我自己拿最新"语义,非全局失效)。
|
||||
if parseBoolQueryWithDefault(c.Query("nocache"), false) {
|
||||
s, err := h.usageService.GetStatsWithFilters(c.Request.Context(), filters)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
stats = s
|
||||
c.Header("X-Usage-Stats-Cache", "bypass")
|
||||
} else {
|
||||
s, hit, err := h.getStatsCached(c.Request.Context(), filters)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
stats = s
|
||||
c.Header("X-Usage-Stats-Cache", cacheStatusValue(hit))
|
||||
}
|
||||
|
||||
response.Success(c, stats)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
|
||||
)
|
||||
|
||||
// 与 dashboard 查询缓存同款:30s TTL 进程内缓存,仅服务 /admin/usage/stats 读路径。
|
||||
var usageStatsCache = newSnapshotCache(30 * time.Second)
|
||||
|
||||
type usageStatsCacheKeyData struct {
|
||||
StartTime string `json:"start_time"`
|
||||
EndTime string `json:"end_time"`
|
||||
UserID int64 `json:"user_id"`
|
||||
APIKeyID int64 `json:"api_key_id"`
|
||||
AccountID int64 `json:"account_id"`
|
||||
GroupID int64 `json:"group_id"`
|
||||
Model string `json:"model"`
|
||||
BillingMode string `json:"billing_mode"`
|
||||
RequestType *int16 `json:"request_type"`
|
||||
Stream *bool `json:"stream"`
|
||||
BillingType *int8 `json:"billing_type"`
|
||||
}
|
||||
|
||||
func usageStatsCacheKey(filters usagestats.UsageLogFilters) string {
|
||||
start := ""
|
||||
if filters.StartTime != nil {
|
||||
start = filters.StartTime.UTC().Format(time.RFC3339)
|
||||
}
|
||||
end := ""
|
||||
if filters.EndTime != nil {
|
||||
end = filters.EndTime.UTC().Format(time.RFC3339)
|
||||
}
|
||||
return mustMarshalDashboardCacheKey(usageStatsCacheKeyData{
|
||||
StartTime: start,
|
||||
EndTime: end,
|
||||
UserID: filters.UserID,
|
||||
APIKeyID: filters.APIKeyID,
|
||||
AccountID: filters.AccountID,
|
||||
GroupID: filters.GroupID,
|
||||
Model: filters.Model,
|
||||
BillingMode: filters.BillingMode,
|
||||
RequestType: filters.RequestType,
|
||||
Stream: filters.Stream,
|
||||
BillingType: filters.BillingType,
|
||||
})
|
||||
}
|
||||
|
||||
// getStatsCached 命中则返回缓存,未命中则回源 usageService 并写缓存。
|
||||
func (h *UsageHandler) getStatsCached(ctx context.Context, filters usagestats.UsageLogFilters) (*usagestats.UsageStats, bool, error) {
|
||||
key := usageStatsCacheKey(filters)
|
||||
entry, hit, err := usageStatsCache.GetOrLoad(key, func() (any, error) {
|
||||
return h.usageService.GetStatsWithFilters(ctx, filters)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, hit, err
|
||||
}
|
||||
stats, err := snapshotPayloadAs[*usagestats.UsageStats](entry.Payload)
|
||||
return stats, hit, err
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestUsageStatsCacheKey_StableAndDistinct(t *testing.T) {
|
||||
start := time.Date(2026, 5, 29, 0, 0, 0, 0, time.UTC)
|
||||
end := time.Date(2026, 5, 31, 0, 0, 0, 0, time.UTC)
|
||||
base := usagestats.UsageLogFilters{StartTime: &start, EndTime: &end, Model: "claude-3"}
|
||||
|
||||
k1 := usageStatsCacheKey(base)
|
||||
k2 := usageStatsCacheKey(base)
|
||||
require.NotEmpty(t, k1)
|
||||
require.Equal(t, k1, k2, "same filters must produce same key")
|
||||
|
||||
other := base
|
||||
other.Model = "gpt-4o"
|
||||
require.NotEqual(t, k1, usageStatsCacheKey(other), "different model must change key")
|
||||
|
||||
withUser := base
|
||||
withUser.UserID = 7
|
||||
require.NotEqual(t, k1, usageStatsCacheKey(withUser), "different user must change key")
|
||||
}
|
||||
Reference in New Issue
Block a user