dashboard_service.go 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433
  1. package service
  2. import (
  3. "context"
  4. "encoding/json"
  5. "errors"
  6. "fmt"
  7. "sync/atomic"
  8. "time"
  9. "github.com/Wei-Shaw/sub2api/internal/config"
  10. "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
  11. "github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
  12. )
  13. const (
  14. defaultDashboardStatsFreshTTL = 15 * time.Second
  15. defaultDashboardStatsCacheTTL = 30 * time.Second
  16. defaultDashboardStatsRefreshTimeout = 30 * time.Second
  17. )
  18. // ErrDashboardStatsCacheMiss 标记仪表盘缓存未命中。
  19. var ErrDashboardStatsCacheMiss = errors.New("仪表盘缓存未命中")
  20. // DashboardStatsCache 定义仪表盘统计缓存接口。
  21. type DashboardStatsCache interface {
  22. GetDashboardStats(ctx context.Context) (string, error)
  23. SetDashboardStats(ctx context.Context, data string, ttl time.Duration) error
  24. DeleteDashboardStats(ctx context.Context) error
  25. }
  26. type dashboardStatsRangeFetcher interface {
  27. GetDashboardStatsWithRange(ctx context.Context, start, end time.Time) (*usagestats.DashboardStats, error)
  28. }
  29. type dashboardStatsCacheEntry struct {
  30. Stats *usagestats.DashboardStats `json:"stats"`
  31. UpdatedAt int64 `json:"updated_at"`
  32. }
  33. // DashboardService 提供管理员仪表盘统计服务。
  34. type DashboardService struct {
  35. usageRepo UsageLogRepository
  36. aggRepo DashboardAggregationRepository
  37. cache DashboardStatsCache
  38. cacheFreshTTL time.Duration
  39. cacheTTL time.Duration
  40. refreshTimeout time.Duration
  41. refreshing int32
  42. aggEnabled bool
  43. aggInterval time.Duration
  44. aggLookback time.Duration
  45. aggUsageDays int
  46. }
  47. func NewDashboardService(usageRepo UsageLogRepository, aggRepo DashboardAggregationRepository, cache DashboardStatsCache, cfg *config.Config) *DashboardService {
  48. freshTTL := defaultDashboardStatsFreshTTL
  49. cacheTTL := defaultDashboardStatsCacheTTL
  50. refreshTimeout := defaultDashboardStatsRefreshTimeout
  51. aggEnabled := true
  52. aggInterval := time.Minute
  53. aggLookback := 2 * time.Minute
  54. aggUsageDays := 90
  55. if cfg != nil {
  56. if !cfg.Dashboard.Enabled {
  57. cache = nil
  58. }
  59. if cfg.Dashboard.StatsFreshTTLSeconds > 0 {
  60. freshTTL = time.Duration(cfg.Dashboard.StatsFreshTTLSeconds) * time.Second
  61. }
  62. if cfg.Dashboard.StatsTTLSeconds > 0 {
  63. cacheTTL = time.Duration(cfg.Dashboard.StatsTTLSeconds) * time.Second
  64. }
  65. if cfg.Dashboard.StatsRefreshTimeoutSeconds > 0 {
  66. refreshTimeout = time.Duration(cfg.Dashboard.StatsRefreshTimeoutSeconds) * time.Second
  67. }
  68. aggEnabled = cfg.DashboardAgg.Enabled
  69. if cfg.DashboardAgg.IntervalSeconds > 0 {
  70. aggInterval = time.Duration(cfg.DashboardAgg.IntervalSeconds) * time.Second
  71. }
  72. if cfg.DashboardAgg.LookbackSeconds > 0 {
  73. aggLookback = time.Duration(cfg.DashboardAgg.LookbackSeconds) * time.Second
  74. }
  75. if cfg.DashboardAgg.Retention.UsageLogsDays > 0 {
  76. aggUsageDays = cfg.DashboardAgg.Retention.UsageLogsDays
  77. }
  78. }
  79. if aggRepo == nil {
  80. aggEnabled = false
  81. }
  82. return &DashboardService{
  83. usageRepo: usageRepo,
  84. aggRepo: aggRepo,
  85. cache: cache,
  86. cacheFreshTTL: freshTTL,
  87. cacheTTL: cacheTTL,
  88. refreshTimeout: refreshTimeout,
  89. aggEnabled: aggEnabled,
  90. aggInterval: aggInterval,
  91. aggLookback: aggLookback,
  92. aggUsageDays: aggUsageDays,
  93. }
  94. }
  95. func (s *DashboardService) GetDashboardStats(ctx context.Context) (*usagestats.DashboardStats, error) {
  96. if s.cache != nil {
  97. cached, fresh, err := s.getCachedDashboardStats(ctx)
  98. if err == nil && cached != nil {
  99. s.refreshAggregationStaleness(cached)
  100. if !fresh {
  101. s.refreshDashboardStatsAsync()
  102. }
  103. return cached, nil
  104. }
  105. if err != nil && !errors.Is(err, ErrDashboardStatsCacheMiss) {
  106. logger.LegacyPrintf("service.dashboard", "[Dashboard] 仪表盘缓存读取失败: %v", err)
  107. }
  108. }
  109. stats, err := s.refreshDashboardStats(ctx)
  110. if err != nil {
  111. return nil, fmt.Errorf("get dashboard stats: %w", err)
  112. }
  113. return stats, nil
  114. }
  115. 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) {
  116. trend, err := s.usageRepo.GetUsageTrendWithFilters(ctx, startTime, endTime, granularity, userID, apiKeyID, accountID, groupID, model, requestType, stream, billingType)
  117. if err != nil {
  118. return nil, fmt.Errorf("get usage trend with filters: %w", err)
  119. }
  120. return trend, nil
  121. }
  122. func (s *DashboardService) GetUsageTrendWithUsageFilters(ctx context.Context, startTime, endTime time.Time, granularity string, filters usagestats.UsageLogFilters) ([]usagestats.TrendDataPoint, error) {
  123. type usageTrendWithFiltersRepo interface {
  124. GetUsageTrendWithUsageFilters(context.Context, time.Time, time.Time, string, usagestats.UsageLogFilters) ([]usagestats.TrendDataPoint, error)
  125. }
  126. if repo, ok := s.usageRepo.(usageTrendWithFiltersRepo); ok {
  127. trend, err := repo.GetUsageTrendWithUsageFilters(ctx, startTime, endTime, granularity, filters)
  128. if err != nil {
  129. return nil, fmt.Errorf("get usage trend with usage filters: %w", err)
  130. }
  131. return trend, nil
  132. }
  133. return s.GetUsageTrendWithFilters(ctx, startTime, endTime, granularity, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.RequestType, filters.Stream, filters.BillingType)
  134. }
  135. 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) {
  136. stats, err := s.usageRepo.GetModelStatsWithFilters(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType)
  137. if err != nil {
  138. return nil, fmt.Errorf("get model stats with filters: %w", err)
  139. }
  140. return stats, nil
  141. }
  142. 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) {
  143. normalizedSource := usagestats.NormalizeModelSource(modelSource)
  144. if normalizedSource == usagestats.ModelSourceRequested {
  145. return s.GetModelStatsWithFilters(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType)
  146. }
  147. type modelStatsBySourceRepo interface {
  148. GetModelStatsWithFiltersBySource(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8, source string) ([]usagestats.ModelStat, error)
  149. }
  150. if sourceRepo, ok := s.usageRepo.(modelStatsBySourceRepo); ok {
  151. stats, err := sourceRepo.GetModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType, normalizedSource)
  152. if err != nil {
  153. return nil, fmt.Errorf("get model stats with filters by source: %w", err)
  154. }
  155. return stats, nil
  156. }
  157. return s.GetModelStatsWithFilters(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType)
  158. }
  159. func (s *DashboardService) GetModelStatsWithUsageFiltersBySource(ctx context.Context, startTime, endTime time.Time, filters usagestats.UsageLogFilters, modelSource string) ([]usagestats.ModelStat, error) {
  160. normalizedSource := usagestats.NormalizeModelSource(modelSource)
  161. type modelStatsWithFiltersRepo interface {
  162. GetModelStatsWithUsageFiltersBySource(context.Context, time.Time, time.Time, usagestats.UsageLogFilters, string) ([]usagestats.ModelStat, error)
  163. }
  164. if repo, ok := s.usageRepo.(modelStatsWithFiltersRepo); ok {
  165. stats, err := repo.GetModelStatsWithUsageFiltersBySource(ctx, startTime, endTime, filters, normalizedSource)
  166. if err != nil {
  167. return nil, fmt.Errorf("get model stats with usage filters by source: %w", err)
  168. }
  169. return stats, nil
  170. }
  171. return s.GetModelStatsWithFiltersBySource(ctx, startTime, endTime, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.RequestType, filters.Stream, filters.BillingType, normalizedSource)
  172. }
  173. 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) {
  174. stats, err := s.usageRepo.GetGroupStatsWithFilters(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType)
  175. if err != nil {
  176. return nil, fmt.Errorf("get group stats with filters: %w", err)
  177. }
  178. return stats, nil
  179. }
  180. func (s *DashboardService) GetGroupStatsWithUsageFilters(ctx context.Context, startTime, endTime time.Time, filters usagestats.UsageLogFilters) ([]usagestats.GroupStat, error) {
  181. type groupStatsWithFiltersRepo interface {
  182. GetGroupStatsWithUsageFilters(context.Context, time.Time, time.Time, usagestats.UsageLogFilters) ([]usagestats.GroupStat, error)
  183. }
  184. if repo, ok := s.usageRepo.(groupStatsWithFiltersRepo); ok {
  185. stats, err := repo.GetGroupStatsWithUsageFilters(ctx, startTime, endTime, filters)
  186. if err != nil {
  187. return nil, fmt.Errorf("get group stats with usage filters: %w", err)
  188. }
  189. return stats, nil
  190. }
  191. return s.GetGroupStatsWithFilters(ctx, startTime, endTime, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.RequestType, filters.Stream, filters.BillingType)
  192. }
  193. // GetGroupUsageSummary returns today's, yesterday's, and cumulative cost for all groups.
  194. func (s *DashboardService) GetGroupUsageSummary(ctx context.Context, todayStart time.Time) ([]usagestats.GroupUsageSummary, error) {
  195. results, err := s.usageRepo.GetAllGroupUsageSummary(ctx, todayStart)
  196. if err != nil {
  197. return nil, fmt.Errorf("get group usage summary: %w", err)
  198. }
  199. return results, nil
  200. }
  201. func (s *DashboardService) getCachedDashboardStats(ctx context.Context) (*usagestats.DashboardStats, bool, error) {
  202. data, err := s.cache.GetDashboardStats(ctx)
  203. if err != nil {
  204. return nil, false, err
  205. }
  206. var entry dashboardStatsCacheEntry
  207. if err := json.Unmarshal([]byte(data), &entry); err != nil {
  208. s.evictDashboardStatsCache(err)
  209. return nil, false, ErrDashboardStatsCacheMiss
  210. }
  211. if entry.Stats == nil {
  212. s.evictDashboardStatsCache(errors.New("仪表盘缓存缺少统计数据"))
  213. return nil, false, ErrDashboardStatsCacheMiss
  214. }
  215. age := time.Since(time.Unix(entry.UpdatedAt, 0))
  216. return entry.Stats, age <= s.cacheFreshTTL, nil
  217. }
  218. func (s *DashboardService) refreshDashboardStats(ctx context.Context) (*usagestats.DashboardStats, error) {
  219. stats, err := s.fetchDashboardStats(ctx)
  220. if err != nil {
  221. return nil, err
  222. }
  223. s.applyAggregationStatus(ctx, stats)
  224. cacheCtx, cancel := s.cacheOperationContext()
  225. defer cancel()
  226. s.saveDashboardStatsCache(cacheCtx, stats)
  227. return stats, nil
  228. }
  229. func (s *DashboardService) refreshDashboardStatsAsync() {
  230. if s.cache == nil {
  231. return
  232. }
  233. if !atomic.CompareAndSwapInt32(&s.refreshing, 0, 1) {
  234. return
  235. }
  236. go func() {
  237. defer atomic.StoreInt32(&s.refreshing, 0)
  238. ctx, cancel := context.WithTimeout(context.Background(), s.refreshTimeout)
  239. defer cancel()
  240. stats, err := s.fetchDashboardStats(ctx)
  241. if err != nil {
  242. logger.LegacyPrintf("service.dashboard", "[Dashboard] 仪表盘缓存异步刷新失败: %v", err)
  243. return
  244. }
  245. s.applyAggregationStatus(ctx, stats)
  246. cacheCtx, cancel := s.cacheOperationContext()
  247. defer cancel()
  248. s.saveDashboardStatsCache(cacheCtx, stats)
  249. }()
  250. }
  251. func (s *DashboardService) fetchDashboardStats(ctx context.Context) (*usagestats.DashboardStats, error) {
  252. if !s.aggEnabled {
  253. if fetcher, ok := s.usageRepo.(dashboardStatsRangeFetcher); ok {
  254. now := time.Now().UTC()
  255. start := truncateToDayUTC(now.AddDate(0, 0, -s.aggUsageDays))
  256. return fetcher.GetDashboardStatsWithRange(ctx, start, now)
  257. }
  258. }
  259. return s.usageRepo.GetDashboardStats(ctx)
  260. }
  261. func (s *DashboardService) saveDashboardStatsCache(ctx context.Context, stats *usagestats.DashboardStats) {
  262. if s.cache == nil || stats == nil {
  263. return
  264. }
  265. entry := dashboardStatsCacheEntry{
  266. Stats: stats,
  267. UpdatedAt: time.Now().Unix(),
  268. }
  269. data, err := json.Marshal(entry)
  270. if err != nil {
  271. logger.LegacyPrintf("service.dashboard", "[Dashboard] 仪表盘缓存序列化失败: %v", err)
  272. return
  273. }
  274. if err := s.cache.SetDashboardStats(ctx, string(data), s.cacheTTL); err != nil {
  275. logger.LegacyPrintf("service.dashboard", "[Dashboard] 仪表盘缓存写入失败: %v", err)
  276. }
  277. }
  278. func (s *DashboardService) evictDashboardStatsCache(reason error) {
  279. if s.cache == nil {
  280. return
  281. }
  282. cacheCtx, cancel := s.cacheOperationContext()
  283. defer cancel()
  284. if err := s.cache.DeleteDashboardStats(cacheCtx); err != nil {
  285. logger.LegacyPrintf("service.dashboard", "[Dashboard] 仪表盘缓存清理失败: %v", err)
  286. }
  287. if reason != nil {
  288. logger.LegacyPrintf("service.dashboard", "[Dashboard] 仪表盘缓存异常,已清理: %v", reason)
  289. }
  290. }
  291. func (s *DashboardService) cacheOperationContext() (context.Context, context.CancelFunc) {
  292. return context.WithTimeout(context.Background(), s.refreshTimeout)
  293. }
  294. func (s *DashboardService) applyAggregationStatus(ctx context.Context, stats *usagestats.DashboardStats) {
  295. if stats == nil {
  296. return
  297. }
  298. updatedAt := s.fetchAggregationUpdatedAt(ctx)
  299. stats.StatsUpdatedAt = updatedAt.UTC().Format(time.RFC3339)
  300. stats.StatsStale = s.isAggregationStale(updatedAt, time.Now().UTC())
  301. }
  302. func (s *DashboardService) refreshAggregationStaleness(stats *usagestats.DashboardStats) {
  303. if stats == nil {
  304. return
  305. }
  306. updatedAt := parseStatsUpdatedAt(stats.StatsUpdatedAt)
  307. stats.StatsStale = s.isAggregationStale(updatedAt, time.Now().UTC())
  308. }
  309. func (s *DashboardService) fetchAggregationUpdatedAt(ctx context.Context) time.Time {
  310. if s.aggRepo == nil {
  311. return time.Unix(0, 0).UTC()
  312. }
  313. updatedAt, err := s.aggRepo.GetAggregationWatermark(ctx)
  314. if err != nil {
  315. logger.LegacyPrintf("service.dashboard", "[Dashboard] 读取聚合水位失败: %v", err)
  316. return time.Unix(0, 0).UTC()
  317. }
  318. if updatedAt.IsZero() {
  319. return time.Unix(0, 0).UTC()
  320. }
  321. return updatedAt.UTC()
  322. }
  323. func (s *DashboardService) isAggregationStale(updatedAt, now time.Time) bool {
  324. if !s.aggEnabled {
  325. return true
  326. }
  327. epoch := time.Unix(0, 0).UTC()
  328. if !updatedAt.After(epoch) {
  329. return true
  330. }
  331. threshold := s.aggInterval + s.aggLookback
  332. return now.Sub(updatedAt) > threshold
  333. }
  334. func parseStatsUpdatedAt(raw string) time.Time {
  335. if raw == "" {
  336. return time.Unix(0, 0).UTC()
  337. }
  338. parsed, err := time.Parse(time.RFC3339, raw)
  339. if err != nil {
  340. return time.Unix(0, 0).UTC()
  341. }
  342. return parsed.UTC()
  343. }
  344. func (s *DashboardService) GetAPIKeyUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.APIKeyUsageTrendPoint, error) {
  345. trend, err := s.usageRepo.GetAPIKeyUsageTrend(ctx, startTime, endTime, granularity, limit)
  346. if err != nil {
  347. return nil, fmt.Errorf("get api key usage trend: %w", err)
  348. }
  349. return trend, nil
  350. }
  351. func (s *DashboardService) GetUserUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.UserUsageTrendPoint, error) {
  352. trend, err := s.usageRepo.GetUserUsageTrend(ctx, startTime, endTime, granularity, limit)
  353. if err != nil {
  354. return nil, fmt.Errorf("get user usage trend: %w", err)
  355. }
  356. return trend, nil
  357. }
  358. func (s *DashboardService) GetUserSpendingRanking(ctx context.Context, startTime, endTime time.Time, limit int) (*usagestats.UserSpendingRankingResponse, error) {
  359. ranking, err := s.usageRepo.GetUserSpendingRanking(ctx, startTime, endTime, limit)
  360. if err != nil {
  361. return nil, fmt.Errorf("get user spending ranking: %w", err)
  362. }
  363. return ranking, nil
  364. }
  365. func (s *DashboardService) GetUserBreakdownStats(ctx context.Context, startTime, endTime time.Time, dim usagestats.UserBreakdownDimension, limit int) ([]usagestats.UserBreakdownItem, error) {
  366. stats, err := s.usageRepo.GetUserBreakdownStats(ctx, startTime, endTime, dim, limit)
  367. if err != nil {
  368. return nil, fmt.Errorf("get user breakdown stats: %w", err)
  369. }
  370. return stats, nil
  371. }
  372. func (s *DashboardService) GetBatchUserUsageStats(ctx context.Context, userIDs []int64, startTime, endTime time.Time) (map[int64]*usagestats.BatchUserUsageStats, error) {
  373. stats, err := s.usageRepo.GetBatchUserUsageStats(ctx, userIDs, startTime, endTime)
  374. if err != nil {
  375. return nil, fmt.Errorf("get batch user usage stats: %w", err)
  376. }
  377. return stats, nil
  378. }
  379. func (s *DashboardService) GetBatchAPIKeyUsageStats(ctx context.Context, apiKeyIDs []int64, startTime, endTime time.Time) (map[int64]*usagestats.BatchAPIKeyUsageStats, error) {
  380. stats, err := s.usageRepo.GetBatchAPIKeyUsageStats(ctx, apiKeyIDs, startTime, endTime)
  381. if err != nil {
  382. return nil, fmt.Errorf("get batch api key usage stats: %w", err)
  383. }
  384. return stats, nil
  385. }