| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433 |
- package service
- import (
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "sync/atomic"
- "time"
- "github.com/Wei-Shaw/sub2api/internal/config"
- "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
- "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
- DeleteDashboardStats(ctx context.Context) error
- }
- type dashboardStatsRangeFetcher interface {
- GetDashboardStatsWithRange(ctx context.Context, start, end time.Time) (*usagestats.DashboardStats, error)
- }
- type dashboardStatsCacheEntry struct {
- Stats *usagestats.DashboardStats `json:"stats"`
- UpdatedAt int64 `json:"updated_at"`
- }
- // DashboardService 提供管理员仪表盘统计服务。
- type DashboardService struct {
- usageRepo UsageLogRepository
- aggRepo DashboardAggregationRepository
- cache DashboardStatsCache
- cacheFreshTTL time.Duration
- cacheTTL time.Duration
- refreshTimeout time.Duration
- refreshing int32
- aggEnabled bool
- aggInterval time.Duration
- aggLookback time.Duration
- aggUsageDays int
- }
- func NewDashboardService(usageRepo UsageLogRepository, aggRepo DashboardAggregationRepository, cache DashboardStatsCache, cfg *config.Config) *DashboardService {
- freshTTL := defaultDashboardStatsFreshTTL
- cacheTTL := defaultDashboardStatsCacheTTL
- refreshTimeout := defaultDashboardStatsRefreshTimeout
- aggEnabled := true
- aggInterval := time.Minute
- aggLookback := 2 * time.Minute
- aggUsageDays := 90
- 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
- }
- aggEnabled = cfg.DashboardAgg.Enabled
- if cfg.DashboardAgg.IntervalSeconds > 0 {
- aggInterval = time.Duration(cfg.DashboardAgg.IntervalSeconds) * time.Second
- }
- if cfg.DashboardAgg.LookbackSeconds > 0 {
- aggLookback = time.Duration(cfg.DashboardAgg.LookbackSeconds) * time.Second
- }
- if cfg.DashboardAgg.Retention.UsageLogsDays > 0 {
- aggUsageDays = cfg.DashboardAgg.Retention.UsageLogsDays
- }
- }
- if aggRepo == nil {
- aggEnabled = false
- }
- return &DashboardService{
- usageRepo: usageRepo,
- aggRepo: aggRepo,
- cache: cache,
- cacheFreshTTL: freshTTL,
- cacheTTL: cacheTTL,
- refreshTimeout: refreshTimeout,
- aggEnabled: aggEnabled,
- aggInterval: aggInterval,
- aggLookback: aggLookback,
- aggUsageDays: aggUsageDays,
- }
- }
- func (s *DashboardService) GetDashboardStats(ctx context.Context) (*usagestats.DashboardStats, error) {
- if s.cache != nil {
- cached, fresh, err := s.getCachedDashboardStats(ctx)
- if err == nil && cached != nil {
- s.refreshAggregationStaleness(cached)
- if !fresh {
- s.refreshDashboardStatsAsync()
- }
- return cached, nil
- }
- if err != nil && !errors.Is(err, ErrDashboardStatsCacheMiss) {
- logger.LegacyPrintf("service.dashboard", "[Dashboard] 仪表盘缓存读取失败: %v", err)
- }
- }
- stats, err := s.refreshDashboardStats(ctx)
- if err != nil {
- return nil, fmt.Errorf("get dashboard stats: %w", err)
- }
- return stats, nil
- }
- func (s *DashboardService) GetUsageTrendWithFilters(ctx context.Context, startTime, endTime time.Time, granularity string, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8) ([]usagestats.TrendDataPoint, error) {
- trend, err := s.usageRepo.GetUsageTrendWithFilters(ctx, startTime, endTime, granularity, userID, apiKeyID, accountID, groupID, model, requestType, stream, billingType)
- if err != nil {
- return nil, fmt.Errorf("get usage trend with filters: %w", err)
- }
- return trend, nil
- }
- func (s *DashboardService) GetUsageTrendWithUsageFilters(ctx context.Context, startTime, endTime time.Time, granularity string, filters usagestats.UsageLogFilters) ([]usagestats.TrendDataPoint, error) {
- type usageTrendWithFiltersRepo interface {
- GetUsageTrendWithUsageFilters(context.Context, time.Time, time.Time, string, usagestats.UsageLogFilters) ([]usagestats.TrendDataPoint, error)
- }
- if repo, ok := s.usageRepo.(usageTrendWithFiltersRepo); ok {
- trend, err := repo.GetUsageTrendWithUsageFilters(ctx, startTime, endTime, granularity, filters)
- if err != nil {
- return nil, fmt.Errorf("get usage trend with usage filters: %w", err)
- }
- return trend, nil
- }
- return s.GetUsageTrendWithFilters(ctx, startTime, endTime, granularity, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.RequestType, filters.Stream, filters.BillingType)
- }
- func (s *DashboardService) GetModelStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8) ([]usagestats.ModelStat, error) {
- stats, err := s.usageRepo.GetModelStatsWithFilters(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType)
- if err != nil {
- return nil, fmt.Errorf("get model stats with filters: %w", err)
- }
- return stats, nil
- }
- func (s *DashboardService) GetModelStatsWithFiltersBySource(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8, modelSource string) ([]usagestats.ModelStat, error) {
- normalizedSource := usagestats.NormalizeModelSource(modelSource)
- if normalizedSource == usagestats.ModelSourceRequested {
- return s.GetModelStatsWithFilters(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType)
- }
- type modelStatsBySourceRepo interface {
- GetModelStatsWithFiltersBySource(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8, source string) ([]usagestats.ModelStat, error)
- }
- if sourceRepo, ok := s.usageRepo.(modelStatsBySourceRepo); ok {
- stats, err := sourceRepo.GetModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType, normalizedSource)
- if err != nil {
- return nil, fmt.Errorf("get model stats with filters by source: %w", err)
- }
- return stats, nil
- }
- return s.GetModelStatsWithFilters(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType)
- }
- func (s *DashboardService) GetModelStatsWithUsageFiltersBySource(ctx context.Context, startTime, endTime time.Time, filters usagestats.UsageLogFilters, modelSource string) ([]usagestats.ModelStat, error) {
- normalizedSource := usagestats.NormalizeModelSource(modelSource)
- type modelStatsWithFiltersRepo interface {
- GetModelStatsWithUsageFiltersBySource(context.Context, time.Time, time.Time, usagestats.UsageLogFilters, string) ([]usagestats.ModelStat, error)
- }
- if repo, ok := s.usageRepo.(modelStatsWithFiltersRepo); ok {
- stats, err := repo.GetModelStatsWithUsageFiltersBySource(ctx, startTime, endTime, filters, normalizedSource)
- if err != nil {
- return nil, fmt.Errorf("get model stats with usage filters by source: %w", err)
- }
- return stats, nil
- }
- return s.GetModelStatsWithFiltersBySource(ctx, startTime, endTime, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.RequestType, filters.Stream, filters.BillingType, normalizedSource)
- }
- func (s *DashboardService) GetGroupStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8) ([]usagestats.GroupStat, error) {
- stats, err := s.usageRepo.GetGroupStatsWithFilters(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType)
- if err != nil {
- return nil, fmt.Errorf("get group stats with filters: %w", err)
- }
- return stats, nil
- }
- func (s *DashboardService) GetGroupStatsWithUsageFilters(ctx context.Context, startTime, endTime time.Time, filters usagestats.UsageLogFilters) ([]usagestats.GroupStat, error) {
- type groupStatsWithFiltersRepo interface {
- GetGroupStatsWithUsageFilters(context.Context, time.Time, time.Time, usagestats.UsageLogFilters) ([]usagestats.GroupStat, error)
- }
- if repo, ok := s.usageRepo.(groupStatsWithFiltersRepo); ok {
- stats, err := repo.GetGroupStatsWithUsageFilters(ctx, startTime, endTime, filters)
- if err != nil {
- return nil, fmt.Errorf("get group stats with usage filters: %w", err)
- }
- return stats, nil
- }
- return s.GetGroupStatsWithFilters(ctx, startTime, endTime, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.RequestType, filters.Stream, filters.BillingType)
- }
- // GetGroupUsageSummary returns today's, yesterday's, and cumulative cost for all groups.
- func (s *DashboardService) GetGroupUsageSummary(ctx context.Context, todayStart time.Time) ([]usagestats.GroupUsageSummary, error) {
- results, err := s.usageRepo.GetAllGroupUsageSummary(ctx, todayStart)
- if err != nil {
- return nil, fmt.Errorf("get group usage summary: %w", err)
- }
- return results, 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 {
- s.evictDashboardStatsCache(err)
- return nil, false, ErrDashboardStatsCacheMiss
- }
- if entry.Stats == nil {
- s.evictDashboardStatsCache(errors.New("仪表盘缓存缺少统计数据"))
- return nil, false, ErrDashboardStatsCacheMiss
- }
- 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.fetchDashboardStats(ctx)
- if err != nil {
- return nil, err
- }
- s.applyAggregationStatus(ctx, stats)
- cacheCtx, cancel := s.cacheOperationContext()
- defer cancel()
- s.saveDashboardStatsCache(cacheCtx, 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.fetchDashboardStats(ctx)
- if err != nil {
- logger.LegacyPrintf("service.dashboard", "[Dashboard] 仪表盘缓存异步刷新失败: %v", err)
- return
- }
- s.applyAggregationStatus(ctx, stats)
- cacheCtx, cancel := s.cacheOperationContext()
- defer cancel()
- s.saveDashboardStatsCache(cacheCtx, stats)
- }()
- }
- func (s *DashboardService) fetchDashboardStats(ctx context.Context) (*usagestats.DashboardStats, error) {
- if !s.aggEnabled {
- if fetcher, ok := s.usageRepo.(dashboardStatsRangeFetcher); ok {
- now := time.Now().UTC()
- start := truncateToDayUTC(now.AddDate(0, 0, -s.aggUsageDays))
- return fetcher.GetDashboardStatsWithRange(ctx, start, now)
- }
- }
- return s.usageRepo.GetDashboardStats(ctx)
- }
- 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 {
- logger.LegacyPrintf("service.dashboard", "[Dashboard] 仪表盘缓存序列化失败: %v", err)
- return
- }
- if err := s.cache.SetDashboardStats(ctx, string(data), s.cacheTTL); err != nil {
- logger.LegacyPrintf("service.dashboard", "[Dashboard] 仪表盘缓存写入失败: %v", err)
- }
- }
- func (s *DashboardService) evictDashboardStatsCache(reason error) {
- if s.cache == nil {
- return
- }
- cacheCtx, cancel := s.cacheOperationContext()
- defer cancel()
- if err := s.cache.DeleteDashboardStats(cacheCtx); err != nil {
- logger.LegacyPrintf("service.dashboard", "[Dashboard] 仪表盘缓存清理失败: %v", err)
- }
- if reason != nil {
- logger.LegacyPrintf("service.dashboard", "[Dashboard] 仪表盘缓存异常,已清理: %v", reason)
- }
- }
- func (s *DashboardService) cacheOperationContext() (context.Context, context.CancelFunc) {
- return context.WithTimeout(context.Background(), s.refreshTimeout)
- }
- func (s *DashboardService) applyAggregationStatus(ctx context.Context, stats *usagestats.DashboardStats) {
- if stats == nil {
- return
- }
- updatedAt := s.fetchAggregationUpdatedAt(ctx)
- stats.StatsUpdatedAt = updatedAt.UTC().Format(time.RFC3339)
- stats.StatsStale = s.isAggregationStale(updatedAt, time.Now().UTC())
- }
- func (s *DashboardService) refreshAggregationStaleness(stats *usagestats.DashboardStats) {
- if stats == nil {
- return
- }
- updatedAt := parseStatsUpdatedAt(stats.StatsUpdatedAt)
- stats.StatsStale = s.isAggregationStale(updatedAt, time.Now().UTC())
- }
- func (s *DashboardService) fetchAggregationUpdatedAt(ctx context.Context) time.Time {
- if s.aggRepo == nil {
- return time.Unix(0, 0).UTC()
- }
- updatedAt, err := s.aggRepo.GetAggregationWatermark(ctx)
- if err != nil {
- logger.LegacyPrintf("service.dashboard", "[Dashboard] 读取聚合水位失败: %v", err)
- return time.Unix(0, 0).UTC()
- }
- if updatedAt.IsZero() {
- return time.Unix(0, 0).UTC()
- }
- return updatedAt.UTC()
- }
- func (s *DashboardService) isAggregationStale(updatedAt, now time.Time) bool {
- if !s.aggEnabled {
- return true
- }
- epoch := time.Unix(0, 0).UTC()
- if !updatedAt.After(epoch) {
- return true
- }
- threshold := s.aggInterval + s.aggLookback
- return now.Sub(updatedAt) > threshold
- }
- func parseStatsUpdatedAt(raw string) time.Time {
- if raw == "" {
- return time.Unix(0, 0).UTC()
- }
- parsed, err := time.Parse(time.RFC3339, raw)
- if err != nil {
- return time.Unix(0, 0).UTC()
- }
- return parsed.UTC()
- }
- 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 {
- return nil, fmt.Errorf("get api key usage trend: %w", err)
- }
- return trend, nil
- }
- func (s *DashboardService) GetUserUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.UserUsageTrendPoint, error) {
- trend, err := s.usageRepo.GetUserUsageTrend(ctx, startTime, endTime, granularity, limit)
- if err != nil {
- return nil, fmt.Errorf("get user usage trend: %w", err)
- }
- return trend, nil
- }
- func (s *DashboardService) GetUserSpendingRanking(ctx context.Context, startTime, endTime time.Time, limit int) (*usagestats.UserSpendingRankingResponse, error) {
- ranking, err := s.usageRepo.GetUserSpendingRanking(ctx, startTime, endTime, limit)
- if err != nil {
- return nil, fmt.Errorf("get user spending ranking: %w", err)
- }
- return ranking, nil
- }
- func (s *DashboardService) GetUserBreakdownStats(ctx context.Context, startTime, endTime time.Time, dim usagestats.UserBreakdownDimension, limit int) ([]usagestats.UserBreakdownItem, error) {
- stats, err := s.usageRepo.GetUserBreakdownStats(ctx, startTime, endTime, dim, limit)
- if err != nil {
- return nil, fmt.Errorf("get user breakdown stats: %w", err)
- }
- return stats, nil
- }
- func (s *DashboardService) GetBatchUserUsageStats(ctx context.Context, userIDs []int64, startTime, endTime time.Time) (map[int64]*usagestats.BatchUserUsageStats, error) {
- stats, err := s.usageRepo.GetBatchUserUsageStats(ctx, userIDs, startTime, endTime)
- if err != nil {
- return nil, fmt.Errorf("get batch user usage stats: %w", err)
- }
- return stats, nil
- }
- func (s *DashboardService) GetBatchAPIKeyUsageStats(ctx context.Context, apiKeyIDs []int64, startTime, endTime time.Time) (map[int64]*usagestats.BatchAPIKeyUsageStats, error) {
- stats, err := s.usageRepo.GetBatchAPIKeyUsageStats(ctx, apiKeyIDs, startTime, endTime)
- if err != nil {
- return nil, fmt.Errorf("get batch api key usage stats: %w", err)
- }
- return stats, nil
- }
|