dashboard_handler.go 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757
  1. package admin
  2. import (
  3. "encoding/json"
  4. "errors"
  5. "strconv"
  6. "strings"
  7. "time"
  8. "github.com/Wei-Shaw/sub2api/internal/pkg/response"
  9. "github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
  10. "github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
  11. "github.com/Wei-Shaw/sub2api/internal/service"
  12. "github.com/gin-gonic/gin"
  13. )
  14. // DashboardHandler handles admin dashboard statistics
  15. type DashboardHandler struct {
  16. dashboardService *service.DashboardService
  17. aggregationService *service.DashboardAggregationService
  18. startTime time.Time // Server start time for uptime calculation
  19. }
  20. // NewDashboardHandler creates a new admin dashboard handler
  21. func NewDashboardHandler(dashboardService *service.DashboardService, aggregationService *service.DashboardAggregationService) *DashboardHandler {
  22. return &DashboardHandler{
  23. dashboardService: dashboardService,
  24. aggregationService: aggregationService,
  25. startTime: time.Now(),
  26. }
  27. }
  28. // parseTimeRange parses start_date, end_date query parameters
  29. // Uses user's timezone if provided, otherwise falls back to server timezone
  30. func parseTimeRange(c *gin.Context) (time.Time, time.Time) {
  31. userTZ := c.Query("timezone") // Get user's timezone from request
  32. now := timezone.NowInUserLocation(userTZ)
  33. startDate := c.Query("start_date")
  34. endDate := c.Query("end_date")
  35. var startTime, endTime time.Time
  36. if startDate != "" {
  37. if t, err := timezone.ParseInUserLocation("2006-01-02", startDate, userTZ); err == nil {
  38. startTime = t
  39. } else {
  40. startTime = timezone.StartOfDayInUserLocation(now.AddDate(0, 0, -7), userTZ)
  41. }
  42. } else {
  43. startTime = timezone.StartOfDayInUserLocation(now.AddDate(0, 0, -7), userTZ)
  44. }
  45. if endDate != "" {
  46. if t, err := timezone.ParseInUserLocation("2006-01-02", endDate, userTZ); err == nil {
  47. endTime = t.Add(24 * time.Hour) // Include the end date
  48. } else {
  49. endTime = timezone.StartOfDayInUserLocation(now.AddDate(0, 0, 1), userTZ)
  50. }
  51. } else {
  52. endTime = timezone.StartOfDayInUserLocation(now.AddDate(0, 0, 1), userTZ)
  53. }
  54. return startTime, endTime
  55. }
  56. func parseOptionalBoolDashboardFilter(c *gin.Context, name string) (*bool, error) {
  57. raw := strings.TrimSpace(c.Query(name))
  58. if raw == "" {
  59. return nil, nil
  60. }
  61. value, err := strconv.ParseBool(raw)
  62. if err != nil {
  63. return nil, err
  64. }
  65. return &value, nil
  66. }
  67. // GetStats handles getting dashboard statistics
  68. // GET /api/v1/admin/dashboard/stats
  69. func (h *DashboardHandler) GetStats(c *gin.Context) {
  70. stats, err := h.dashboardService.GetDashboardStats(c.Request.Context())
  71. if err != nil {
  72. response.Error(c, 500, "Failed to get dashboard statistics")
  73. return
  74. }
  75. // Calculate uptime in seconds
  76. uptime := int64(time.Since(h.startTime).Seconds())
  77. response.Success(c, gin.H{
  78. // 用户统计
  79. "total_users": stats.TotalUsers,
  80. "today_new_users": stats.TodayNewUsers,
  81. "active_users": stats.ActiveUsers,
  82. // API Key 统计
  83. "total_api_keys": stats.TotalAPIKeys,
  84. "active_api_keys": stats.ActiveAPIKeys,
  85. // 账户统计
  86. "total_accounts": stats.TotalAccounts,
  87. "normal_accounts": stats.NormalAccounts,
  88. "error_accounts": stats.ErrorAccounts,
  89. "ratelimit_accounts": stats.RateLimitAccounts,
  90. "overload_accounts": stats.OverloadAccounts,
  91. // 累计 Token 使用统计
  92. "total_requests": stats.TotalRequests,
  93. "total_input_tokens": stats.TotalInputTokens,
  94. "total_output_tokens": stats.TotalOutputTokens,
  95. "total_cache_creation_tokens": stats.TotalCacheCreationTokens,
  96. "total_cache_read_tokens": stats.TotalCacheReadTokens,
  97. "total_tokens": stats.TotalTokens,
  98. "total_cost": stats.TotalCost, // 标准计费
  99. "total_actual_cost": stats.TotalActualCost, // 实际扣除
  100. // 今日 Token 使用统计
  101. "today_requests": stats.TodayRequests,
  102. "today_input_tokens": stats.TodayInputTokens,
  103. "today_output_tokens": stats.TodayOutputTokens,
  104. "today_cache_creation_tokens": stats.TodayCacheCreationTokens,
  105. "today_cache_read_tokens": stats.TodayCacheReadTokens,
  106. "today_tokens": stats.TodayTokens,
  107. "today_cost": stats.TodayCost, // 今日标准计费
  108. "today_actual_cost": stats.TodayActualCost, // 今日实际扣除
  109. // 系统运行统计
  110. "average_duration_ms": stats.AverageDurationMs,
  111. "uptime": uptime,
  112. // 性能指标
  113. "rpm": stats.Rpm,
  114. "tpm": stats.Tpm,
  115. // 预聚合新鲜度
  116. "hourly_active_users": stats.HourlyActiveUsers,
  117. "stats_updated_at": stats.StatsUpdatedAt,
  118. "stats_stale": stats.StatsStale,
  119. })
  120. }
  121. type DashboardAggregationBackfillRequest struct {
  122. Start string `json:"start"`
  123. End string `json:"end"`
  124. }
  125. // BackfillAggregation handles triggering aggregation backfill
  126. // POST /api/v1/admin/dashboard/aggregation/backfill
  127. func (h *DashboardHandler) BackfillAggregation(c *gin.Context) {
  128. if h.aggregationService == nil {
  129. response.InternalError(c, "Aggregation service not available")
  130. return
  131. }
  132. var req DashboardAggregationBackfillRequest
  133. if err := c.ShouldBindJSON(&req); err != nil {
  134. response.BadRequest(c, "Invalid request body")
  135. return
  136. }
  137. start, err := time.Parse(time.RFC3339, req.Start)
  138. if err != nil {
  139. response.BadRequest(c, "Invalid start time")
  140. return
  141. }
  142. end, err := time.Parse(time.RFC3339, req.End)
  143. if err != nil {
  144. response.BadRequest(c, "Invalid end time")
  145. return
  146. }
  147. if err := h.aggregationService.TriggerBackfill(start, end); err != nil {
  148. if errors.Is(err, service.ErrDashboardBackfillDisabled) {
  149. response.Forbidden(c, "Backfill is disabled")
  150. return
  151. }
  152. if errors.Is(err, service.ErrDashboardBackfillTooLarge) {
  153. response.BadRequest(c, "Backfill range too large")
  154. return
  155. }
  156. response.InternalError(c, "Failed to trigger backfill")
  157. return
  158. }
  159. response.Success(c, gin.H{
  160. "status": "accepted",
  161. })
  162. }
  163. // GetRealtimeMetrics handles getting real-time system metrics
  164. // GET /api/v1/admin/dashboard/realtime
  165. func (h *DashboardHandler) GetRealtimeMetrics(c *gin.Context) {
  166. // Return mock data for now
  167. response.Success(c, gin.H{
  168. "active_requests": 0,
  169. "requests_per_minute": 0,
  170. "average_response_time": 0,
  171. "error_rate": 0.0,
  172. })
  173. }
  174. // GetUsageTrend handles getting usage trend data
  175. // GET /api/v1/admin/dashboard/trend
  176. // Query params: start_date, end_date (YYYY-MM-DD), granularity (day/hour), user_id, api_key_id, model, account_id, group_id, request_type, stream, billing_type
  177. func (h *DashboardHandler) GetUsageTrend(c *gin.Context) {
  178. startTime, endTime := parseTimeRange(c)
  179. granularity := c.DefaultQuery("granularity", "day")
  180. // Parse optional filter params
  181. var userID, apiKeyID, accountID, groupID int64
  182. var model string
  183. var requestType *int16
  184. var stream *bool
  185. var billingType *int8
  186. var upstreamModelMismatch *bool
  187. if userIDStr := c.Query("user_id"); userIDStr != "" {
  188. if id, err := strconv.ParseInt(userIDStr, 10, 64); err == nil {
  189. userID = id
  190. }
  191. }
  192. if apiKeyIDStr := c.Query("api_key_id"); apiKeyIDStr != "" {
  193. if id, err := strconv.ParseInt(apiKeyIDStr, 10, 64); err == nil {
  194. apiKeyID = id
  195. }
  196. }
  197. if accountIDStr := c.Query("account_id"); accountIDStr != "" {
  198. if id, err := strconv.ParseInt(accountIDStr, 10, 64); err == nil {
  199. accountID = id
  200. }
  201. }
  202. if groupIDStr := c.Query("group_id"); groupIDStr != "" {
  203. if id, err := strconv.ParseInt(groupIDStr, 10, 64); err == nil {
  204. groupID = id
  205. }
  206. }
  207. if modelStr := c.Query("model"); modelStr != "" {
  208. model = modelStr
  209. }
  210. if requestTypeStr := strings.TrimSpace(c.Query("request_type")); requestTypeStr != "" {
  211. parsed, err := service.ParseUsageRequestType(requestTypeStr)
  212. if err != nil {
  213. response.BadRequest(c, err.Error())
  214. return
  215. }
  216. value := int16(parsed)
  217. requestType = &value
  218. } else if streamStr := c.Query("stream"); streamStr != "" {
  219. if streamVal, err := strconv.ParseBool(streamStr); err == nil {
  220. stream = &streamVal
  221. } else {
  222. response.BadRequest(c, "Invalid stream value, use true or false")
  223. return
  224. }
  225. }
  226. if billingTypeStr := c.Query("billing_type"); billingTypeStr != "" {
  227. if v, err := strconv.ParseInt(billingTypeStr, 10, 8); err == nil {
  228. bt := int8(v)
  229. billingType = &bt
  230. } else {
  231. response.BadRequest(c, "Invalid billing_type")
  232. return
  233. }
  234. }
  235. nativeCompactionV2, err := parseOptionalBoolDashboardFilter(c, "native_compaction_v2")
  236. if err != nil {
  237. response.BadRequest(c, "Invalid native_compaction_v2 value, use true or false")
  238. return
  239. }
  240. upstreamModelMismatch, err = parseOptionalBoolDashboardFilter(c, "upstream_model_mismatch")
  241. if err != nil {
  242. response.BadRequest(c, "Invalid upstream_model_mismatch value, use true or false")
  243. return
  244. }
  245. trend, hit, err := h.getUsageTrendCached(c.Request.Context(), startTime, endTime, granularity, userID, apiKeyID, accountID, groupID, model, requestType, stream, nativeCompactionV2, billingType, upstreamModelMismatch)
  246. if err != nil {
  247. response.Error(c, 500, "Failed to get usage trend")
  248. return
  249. }
  250. c.Header("X-Snapshot-Cache", cacheStatusValue(hit))
  251. response.Success(c, gin.H{
  252. "trend": trend,
  253. "start_date": startTime.Format("2006-01-02"),
  254. "end_date": endTime.Add(-24 * time.Hour).Format("2006-01-02"),
  255. "granularity": granularity,
  256. })
  257. }
  258. // GetModelStats handles getting model usage statistics
  259. // GET /api/v1/admin/dashboard/models
  260. // Query params: start_date, end_date (YYYY-MM-DD), user_id, api_key_id, account_id, group_id, request_type, stream, billing_type
  261. func (h *DashboardHandler) GetModelStats(c *gin.Context) {
  262. startTime, endTime := parseTimeRange(c)
  263. // Parse optional filter params
  264. var userID, apiKeyID, accountID, groupID int64
  265. modelSource := usagestats.ModelSourceRequested
  266. var requestType *int16
  267. var stream *bool
  268. var billingType *int8
  269. var upstreamModelMismatch *bool
  270. if userIDStr := c.Query("user_id"); userIDStr != "" {
  271. if id, err := strconv.ParseInt(userIDStr, 10, 64); err == nil {
  272. userID = id
  273. }
  274. }
  275. if apiKeyIDStr := c.Query("api_key_id"); apiKeyIDStr != "" {
  276. if id, err := strconv.ParseInt(apiKeyIDStr, 10, 64); err == nil {
  277. apiKeyID = id
  278. }
  279. }
  280. if accountIDStr := c.Query("account_id"); accountIDStr != "" {
  281. if id, err := strconv.ParseInt(accountIDStr, 10, 64); err == nil {
  282. accountID = id
  283. }
  284. }
  285. if groupIDStr := c.Query("group_id"); groupIDStr != "" {
  286. if id, err := strconv.ParseInt(groupIDStr, 10, 64); err == nil {
  287. groupID = id
  288. }
  289. }
  290. if rawModelSource := strings.TrimSpace(c.Query("model_source")); rawModelSource != "" {
  291. if !usagestats.IsValidModelSource(rawModelSource) {
  292. response.BadRequest(c, "Invalid model_source, use requested/upstream/mapping")
  293. return
  294. }
  295. modelSource = rawModelSource
  296. }
  297. if requestTypeStr := strings.TrimSpace(c.Query("request_type")); requestTypeStr != "" {
  298. parsed, err := service.ParseUsageRequestType(requestTypeStr)
  299. if err != nil {
  300. response.BadRequest(c, err.Error())
  301. return
  302. }
  303. value := int16(parsed)
  304. requestType = &value
  305. } else if streamStr := c.Query("stream"); streamStr != "" {
  306. if streamVal, err := strconv.ParseBool(streamStr); err == nil {
  307. stream = &streamVal
  308. } else {
  309. response.BadRequest(c, "Invalid stream value, use true or false")
  310. return
  311. }
  312. }
  313. if billingTypeStr := c.Query("billing_type"); billingTypeStr != "" {
  314. if v, err := strconv.ParseInt(billingTypeStr, 10, 8); err == nil {
  315. bt := int8(v)
  316. billingType = &bt
  317. } else {
  318. response.BadRequest(c, "Invalid billing_type")
  319. return
  320. }
  321. }
  322. nativeCompactionV2, err := parseOptionalBoolDashboardFilter(c, "native_compaction_v2")
  323. if err != nil {
  324. response.BadRequest(c, "Invalid native_compaction_v2 value, use true or false")
  325. return
  326. }
  327. upstreamModelMismatch, err = parseOptionalBoolDashboardFilter(c, "upstream_model_mismatch")
  328. if err != nil {
  329. response.BadRequest(c, "Invalid upstream_model_mismatch value, use true or false")
  330. return
  331. }
  332. stats, hit, err := h.getModelStatsCached(c.Request.Context(), startTime, endTime, userID, apiKeyID, accountID, groupID, modelSource, requestType, stream, nativeCompactionV2, billingType, upstreamModelMismatch)
  333. if err != nil {
  334. response.Error(c, 500, "Failed to get model statistics")
  335. return
  336. }
  337. c.Header("X-Snapshot-Cache", cacheStatusValue(hit))
  338. response.Success(c, gin.H{
  339. "models": stats,
  340. "start_date": startTime.Format("2006-01-02"),
  341. "end_date": endTime.Add(-24 * time.Hour).Format("2006-01-02"),
  342. })
  343. }
  344. // GetGroupStats handles getting group usage statistics
  345. // GET /api/v1/admin/dashboard/groups
  346. // Query params: start_date, end_date (YYYY-MM-DD), user_id, api_key_id, account_id, group_id, request_type, stream, billing_type
  347. func (h *DashboardHandler) GetGroupStats(c *gin.Context) {
  348. startTime, endTime := parseTimeRange(c)
  349. var userID, apiKeyID, accountID, groupID int64
  350. var requestType *int16
  351. var stream *bool
  352. var billingType *int8
  353. var upstreamModelMismatch *bool
  354. if userIDStr := c.Query("user_id"); userIDStr != "" {
  355. if id, err := strconv.ParseInt(userIDStr, 10, 64); err == nil {
  356. userID = id
  357. }
  358. }
  359. if apiKeyIDStr := c.Query("api_key_id"); apiKeyIDStr != "" {
  360. if id, err := strconv.ParseInt(apiKeyIDStr, 10, 64); err == nil {
  361. apiKeyID = id
  362. }
  363. }
  364. if accountIDStr := c.Query("account_id"); accountIDStr != "" {
  365. if id, err := strconv.ParseInt(accountIDStr, 10, 64); err == nil {
  366. accountID = id
  367. }
  368. }
  369. if groupIDStr := c.Query("group_id"); groupIDStr != "" {
  370. if id, err := strconv.ParseInt(groupIDStr, 10, 64); err == nil {
  371. groupID = id
  372. }
  373. }
  374. if requestTypeStr := strings.TrimSpace(c.Query("request_type")); requestTypeStr != "" {
  375. parsed, err := service.ParseUsageRequestType(requestTypeStr)
  376. if err != nil {
  377. response.BadRequest(c, err.Error())
  378. return
  379. }
  380. value := int16(parsed)
  381. requestType = &value
  382. } else if streamStr := c.Query("stream"); streamStr != "" {
  383. if streamVal, err := strconv.ParseBool(streamStr); err == nil {
  384. stream = &streamVal
  385. } else {
  386. response.BadRequest(c, "Invalid stream value, use true or false")
  387. return
  388. }
  389. }
  390. if billingTypeStr := c.Query("billing_type"); billingTypeStr != "" {
  391. if v, err := strconv.ParseInt(billingTypeStr, 10, 8); err == nil {
  392. bt := int8(v)
  393. billingType = &bt
  394. } else {
  395. response.BadRequest(c, "Invalid billing_type")
  396. return
  397. }
  398. }
  399. nativeCompactionV2, err := parseOptionalBoolDashboardFilter(c, "native_compaction_v2")
  400. if err != nil {
  401. response.BadRequest(c, "Invalid native_compaction_v2 value, use true or false")
  402. return
  403. }
  404. upstreamModelMismatch, err = parseOptionalBoolDashboardFilter(c, "upstream_model_mismatch")
  405. if err != nil {
  406. response.BadRequest(c, "Invalid upstream_model_mismatch value, use true or false")
  407. return
  408. }
  409. stats, hit, err := h.getGroupStatsCached(c.Request.Context(), startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, nativeCompactionV2, billingType, upstreamModelMismatch)
  410. if err != nil {
  411. response.Error(c, 500, "Failed to get group statistics")
  412. return
  413. }
  414. c.Header("X-Snapshot-Cache", cacheStatusValue(hit))
  415. response.Success(c, gin.H{
  416. "groups": stats,
  417. "start_date": startTime.Format("2006-01-02"),
  418. "end_date": endTime.Add(-24 * time.Hour).Format("2006-01-02"),
  419. })
  420. }
  421. // GetAPIKeyUsageTrend handles getting API key usage trend data
  422. // GET /api/v1/admin/dashboard/api-keys-trend
  423. // Query params: start_date, end_date (YYYY-MM-DD), granularity (day/hour), limit (default 5)
  424. func (h *DashboardHandler) GetAPIKeyUsageTrend(c *gin.Context) {
  425. startTime, endTime := parseTimeRange(c)
  426. granularity := c.DefaultQuery("granularity", "day")
  427. limitStr := c.DefaultQuery("limit", "5")
  428. limit, err := strconv.Atoi(limitStr)
  429. if err != nil || limit <= 0 {
  430. limit = 5
  431. }
  432. trend, hit, err := h.getAPIKeyUsageTrendCached(c.Request.Context(), startTime, endTime, granularity, limit)
  433. if err != nil {
  434. response.Error(c, 500, "Failed to get API key usage trend")
  435. return
  436. }
  437. c.Header("X-Snapshot-Cache", cacheStatusValue(hit))
  438. response.Success(c, gin.H{
  439. "trend": trend,
  440. "start_date": startTime.Format("2006-01-02"),
  441. "end_date": endTime.Add(-24 * time.Hour).Format("2006-01-02"),
  442. "granularity": granularity,
  443. })
  444. }
  445. // GetUserUsageTrend handles getting user usage trend data
  446. // GET /api/v1/admin/dashboard/users-trend
  447. // Query params: start_date, end_date (YYYY-MM-DD), granularity (day/hour), limit (default 12)
  448. func (h *DashboardHandler) GetUserUsageTrend(c *gin.Context) {
  449. startTime, endTime := parseTimeRange(c)
  450. granularity := c.DefaultQuery("granularity", "day")
  451. limitStr := c.DefaultQuery("limit", "12")
  452. limit, err := strconv.Atoi(limitStr)
  453. if err != nil || limit <= 0 {
  454. limit = 12
  455. }
  456. trend, hit, err := h.getUserUsageTrendCached(c.Request.Context(), startTime, endTime, granularity, limit)
  457. if err != nil {
  458. response.Error(c, 500, "Failed to get user usage trend")
  459. return
  460. }
  461. c.Header("X-Snapshot-Cache", cacheStatusValue(hit))
  462. response.Success(c, gin.H{
  463. "trend": trend,
  464. "start_date": startTime.Format("2006-01-02"),
  465. "end_date": endTime.Add(-24 * time.Hour).Format("2006-01-02"),
  466. "granularity": granularity,
  467. })
  468. }
  469. // BatchUsersUsageRequest represents the request body for batch user usage stats
  470. type BatchUsersUsageRequest struct {
  471. UserIDs []int64 `json:"user_ids" binding:"required"`
  472. }
  473. var dashboardUsersRankingCache = newSnapshotCache(5 * time.Minute)
  474. var dashboardBatchUsersUsageCache = newSnapshotCache(30 * time.Second)
  475. var dashboardBatchAPIKeysUsageCache = newSnapshotCache(30 * time.Second)
  476. func parseRankingLimit(raw string) int {
  477. limit, err := strconv.Atoi(strings.TrimSpace(raw))
  478. if err != nil || limit <= 0 {
  479. return 12
  480. }
  481. if limit > 50 {
  482. return 50
  483. }
  484. return limit
  485. }
  486. // GetUserSpendingRanking handles getting user spending ranking data.
  487. // GET /api/v1/admin/dashboard/users-ranking
  488. func (h *DashboardHandler) GetUserSpendingRanking(c *gin.Context) {
  489. startTime, endTime := parseTimeRange(c)
  490. limit := parseRankingLimit(c.DefaultQuery("limit", "12"))
  491. keyRaw, _ := json.Marshal(struct {
  492. Start string `json:"start"`
  493. End string `json:"end"`
  494. Limit int `json:"limit"`
  495. }{
  496. Start: startTime.UTC().Format(time.RFC3339),
  497. End: endTime.UTC().Format(time.RFC3339),
  498. Limit: limit,
  499. })
  500. cacheKey := string(keyRaw)
  501. if cached, ok := dashboardUsersRankingCache.Get(cacheKey); ok {
  502. c.Header("X-Snapshot-Cache", "hit")
  503. response.Success(c, cached.Payload)
  504. return
  505. }
  506. ranking, err := h.dashboardService.GetUserSpendingRanking(c.Request.Context(), startTime, endTime, limit)
  507. if err != nil {
  508. response.Error(c, 500, "Failed to get user spending ranking")
  509. return
  510. }
  511. payload := gin.H{
  512. "ranking": ranking.Ranking,
  513. "total_actual_cost": ranking.TotalActualCost,
  514. "total_requests": ranking.TotalRequests,
  515. "total_tokens": ranking.TotalTokens,
  516. "start_date": startTime.Format("2006-01-02"),
  517. "end_date": endTime.Add(-24 * time.Hour).Format("2006-01-02"),
  518. }
  519. dashboardUsersRankingCache.Set(cacheKey, payload)
  520. c.Header("X-Snapshot-Cache", "miss")
  521. response.Success(c, payload)
  522. }
  523. // GetBatchUsersUsage handles getting usage stats for multiple users
  524. // POST /api/v1/admin/dashboard/users-usage
  525. func (h *DashboardHandler) GetBatchUsersUsage(c *gin.Context) {
  526. var req BatchUsersUsageRequest
  527. if err := c.ShouldBindJSON(&req); err != nil {
  528. response.BadRequest(c, "Invalid request: "+err.Error())
  529. return
  530. }
  531. userIDs := normalizeInt64IDList(req.UserIDs)
  532. if len(userIDs) == 0 {
  533. response.Success(c, gin.H{"stats": map[string]any{}})
  534. return
  535. }
  536. // cacheKey 必须包含当日日期,否则跨午夜后 30s 内会复用昨天的 "today_*" 结果。
  537. keyRaw, _ := json.Marshal(struct {
  538. V int `json:"v"`
  539. Day string `json:"day"`
  540. UserIDs []int64 `json:"user_ids"`
  541. }{
  542. V: 2, // bump 当响应结构变化(如加入 by_platform 时)
  543. Day: timezone.Today().Format("2006-01-02"),
  544. UserIDs: userIDs,
  545. })
  546. cacheKey := string(keyRaw)
  547. if cached, ok := dashboardBatchUsersUsageCache.Get(cacheKey); ok {
  548. c.Header("X-Snapshot-Cache", "hit")
  549. response.Success(c, cached.Payload)
  550. return
  551. }
  552. stats, err := h.dashboardService.GetBatchUserUsageStats(c.Request.Context(), userIDs, time.Time{}, time.Time{})
  553. if err != nil {
  554. response.Error(c, 500, "Failed to get user usage stats")
  555. return
  556. }
  557. payload := gin.H{"stats": stats}
  558. dashboardBatchUsersUsageCache.Set(cacheKey, payload)
  559. c.Header("X-Snapshot-Cache", "miss")
  560. response.Success(c, payload)
  561. }
  562. // BatchAPIKeysUsageRequest represents the request body for batch api key usage stats
  563. type BatchAPIKeysUsageRequest struct {
  564. APIKeyIDs []int64 `json:"api_key_ids" binding:"required"`
  565. }
  566. // GetBatchAPIKeysUsage handles getting usage stats for multiple API keys
  567. // POST /api/v1/admin/dashboard/api-keys-usage
  568. func (h *DashboardHandler) GetBatchAPIKeysUsage(c *gin.Context) {
  569. var req BatchAPIKeysUsageRequest
  570. if err := c.ShouldBindJSON(&req); err != nil {
  571. response.BadRequest(c, "Invalid request: "+err.Error())
  572. return
  573. }
  574. apiKeyIDs := normalizeInt64IDList(req.APIKeyIDs)
  575. if len(apiKeyIDs) == 0 {
  576. response.Success(c, gin.H{"stats": map[string]any{}})
  577. return
  578. }
  579. keyRaw, _ := json.Marshal(struct {
  580. APIKeyIDs []int64 `json:"api_key_ids"`
  581. }{
  582. APIKeyIDs: apiKeyIDs,
  583. })
  584. cacheKey := string(keyRaw)
  585. if cached, ok := dashboardBatchAPIKeysUsageCache.Get(cacheKey); ok {
  586. c.Header("X-Snapshot-Cache", "hit")
  587. response.Success(c, cached.Payload)
  588. return
  589. }
  590. stats, err := h.dashboardService.GetBatchAPIKeyUsageStats(c.Request.Context(), apiKeyIDs, time.Time{}, time.Time{})
  591. if err != nil {
  592. response.Error(c, 500, "Failed to get API key usage stats")
  593. return
  594. }
  595. payload := gin.H{"stats": stats}
  596. dashboardBatchAPIKeysUsageCache.Set(cacheKey, payload)
  597. c.Header("X-Snapshot-Cache", "miss")
  598. response.Success(c, payload)
  599. }
  600. // GetUserBreakdown handles getting per-user usage breakdown within a dimension.
  601. // GET /api/v1/admin/dashboard/user-breakdown
  602. // Query params: start_date, end_date, group_id, model, endpoint, endpoint_type, limit
  603. func (h *DashboardHandler) GetUserBreakdown(c *gin.Context) {
  604. startTime, endTime := parseTimeRange(c)
  605. dim := usagestats.UserBreakdownDimension{}
  606. if v := c.Query("group_id"); v != "" {
  607. if id, err := strconv.ParseInt(v, 10, 64); err == nil {
  608. dim.GroupID = id
  609. }
  610. }
  611. dim.Model = c.Query("model")
  612. rawModelSource := strings.TrimSpace(c.DefaultQuery("model_source", usagestats.ModelSourceRequested))
  613. if !usagestats.IsValidModelSource(rawModelSource) {
  614. response.BadRequest(c, "Invalid model_source, use requested/upstream/mapping")
  615. return
  616. }
  617. dim.ModelType = rawModelSource
  618. dim.Endpoint = c.Query("endpoint")
  619. dim.EndpointType = c.DefaultQuery("endpoint_type", "inbound")
  620. // Additional filter conditions
  621. if v := c.Query("user_id"); v != "" {
  622. if id, err := strconv.ParseInt(v, 10, 64); err == nil {
  623. dim.UserID = id
  624. }
  625. }
  626. if v := c.Query("api_key_id"); v != "" {
  627. if id, err := strconv.ParseInt(v, 10, 64); err == nil {
  628. dim.APIKeyID = id
  629. }
  630. }
  631. if v := c.Query("account_id"); v != "" {
  632. if id, err := strconv.ParseInt(v, 10, 64); err == nil {
  633. dim.AccountID = id
  634. }
  635. }
  636. if v := strings.TrimSpace(c.Query("request_type")); v != "" {
  637. parsed, err := service.ParseUsageRequestType(v)
  638. if err != nil {
  639. response.BadRequest(c, err.Error())
  640. return
  641. }
  642. rtVal := int16(parsed)
  643. dim.RequestType = &rtVal
  644. }
  645. if v := c.Query("stream"); v != "" {
  646. if s, err := strconv.ParseBool(v); err == nil {
  647. dim.Stream = &s
  648. }
  649. }
  650. if v := strings.TrimSpace(c.Query("native_compaction_v2")); v != "" {
  651. value, err := strconv.ParseBool(v)
  652. if err != nil {
  653. response.BadRequest(c, "Invalid native_compaction_v2 value, use true or false")
  654. return
  655. }
  656. dim.NativeCompactionV2 = &value
  657. }
  658. if v := c.Query("billing_type"); v != "" {
  659. if bt, err := strconv.ParseInt(v, 10, 8); err == nil {
  660. btVal := int8(bt)
  661. dim.BillingType = &btVal
  662. }
  663. }
  664. // sort_by 由 repo 层 allowlist 校验;非法值静默回退默认排序(actual_cost)。
  665. dim.SortBy = strings.TrimSpace(c.Query("sort_by"))
  666. limit := 50
  667. if v := c.Query("limit"); v != "" {
  668. if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 200 {
  669. limit = n
  670. }
  671. }
  672. stats, err := h.dashboardService.GetUserBreakdownStats(
  673. c.Request.Context(), startTime, endTime, dim, limit,
  674. )
  675. if err != nil {
  676. response.Error(c, 500, "Failed to get user breakdown stats")
  677. return
  678. }
  679. response.Success(c, gin.H{
  680. "users": stats,
  681. "start_date": startTime.Format("2006-01-02"),
  682. "end_date": endTime.Add(-24 * time.Hour).Format("2006-01-02"),
  683. })
  684. }