diff --git a/config.example.yaml b/config.example.yaml index 2555bea2..84b5e25f 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -102,6 +102,7 @@ schedule: update_user_badges_scores_task_cron: "0 2 * * *" update_all_badges_task_cron: "0 1 * * *" expire_stale_payment_orders_cron: "*/1 * * * *" # 扫描超时未付款订单的频率 + update_dashboard_stats_cron: "*/10 * * * *" # 预缓存仪表板统计数据(7/15/30天)到Redis的频率 # Worker worker: diff --git a/internal/apps/dashboard/logic.go b/internal/apps/dashboard/logic.go index 9c546e9c..1b5269e0 100644 --- a/internal/apps/dashboard/logic.go +++ b/internal/apps/dashboard/logic.go @@ -27,10 +27,10 @@ package dashboard import ( "context" "encoding/json" - "fmt" - "time" + "errors" "github.com/linux-do/cdk/internal/db" + "github.com/redis/go-redis/v9" ) // DashboardData 仪表板数据结构 @@ -48,32 +48,20 @@ type DashboardData struct { // GetAllDashboardData get all data for dashboard func GetAllDashboardData(ctx context.Context, days int) (*map[string]interface{}, error) { - // 构造缓存键名 - cacheKey := fmt.Sprintf("dashboard:data:%d", days) + cacheKey := dashboardCacheKey(days) - // 尝试从Redis获取缓存 - cachedData, redisErr := db.Redis.Get(ctx, cacheKey).Result() - if redisErr == nil && cachedData != "" { - // 缓存命中,解析JSON - var result map[string]interface{} - if err := json.Unmarshal([]byte(cachedData), &result); err == nil { - return &result, nil + cachedData, err := db.Redis.Get(ctx, cacheKey).Result() + if err != nil { + if errors.Is(err, redis.Nil) { + return &map[string]interface{}{}, nil } - } - - // 调用存储过程获取仪表板数据 - query := "CALL get_dashboard_data(?,10)" - - var rawResult map[string]interface{} - if err := db.DB(ctx).Raw(query, days).Scan(&rawResult).Error; err != nil { return nil, err } - // 将结果存入Redis并设置过期时间 - if jsonData, err := json.Marshal(rawResult); err == nil { - // 设置5分钟过期时间 - db.Redis.Set(ctx, cacheKey, string(jsonData), 5*time.Minute) + var result map[string]interface{} + if err := json.Unmarshal([]byte(cachedData), &result); err != nil { + return nil, err } - return &rawResult, nil + return &result, nil } diff --git a/internal/apps/dashboard/routers.go b/internal/apps/dashboard/routers.go index 267a5bc5..3403045a 100644 --- a/internal/apps/dashboard/routers.go +++ b/internal/apps/dashboard/routers.go @@ -26,7 +26,6 @@ package dashboard import ( "net/http" - "strconv" "github.com/gin-gonic/gin" ) @@ -36,6 +35,10 @@ type DashboardDataResponse struct { Data interface{} `json:"data"` } +type GetAllStatsRequest struct { + Days int `json:"days" form:"days" binding:"oneof=7 15 30"` +} + // GetAllStats // @Tags Dashboard // @Param days query int false "request query" @@ -43,12 +46,13 @@ type DashboardDataResponse struct { // @Success 200 {object} DashboardDataResponse // @Router /api/v1/dashboard/stats/all [get] func GetAllStats(c *gin.Context) { - daysStr := c.DefaultQuery("days", "14") - days, err := strconv.Atoi(daysStr) - if err != nil || days <= 0 || days > 30 { - days = 14 + req := &GetAllStatsRequest{Days: 7} + if err := c.ShouldBindQuery(req); err != nil { + c.JSON(http.StatusBadRequest, DashboardDataResponse{ErrorMsg: err.Error()}) + return } - data, err := GetAllDashboardData(c.Request.Context(), days) + + data, err := GetAllDashboardData(c.Request.Context(), req.Days) if err != nil { c.JSON(http.StatusInternalServerError, DashboardDataResponse{ErrorMsg: err.Error()}) diff --git a/internal/apps/dashboard/tasks.go b/internal/apps/dashboard/tasks.go new file mode 100644 index 00000000..dfd6d3cd --- /dev/null +++ b/internal/apps/dashboard/tasks.go @@ -0,0 +1,61 @@ +/* + * MIT License + * + * Copyright (c) 2025 linux.do + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package dashboard + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/linux-do/cdk/internal/db" + "github.com/linux-do/cdk/internal/logger" + + "github.com/hibiken/asynq" +) + +// HandleUpdateDashboardStats 定时预缓存仪表板统计数据到 Redis。 +func HandleUpdateDashboardStats(ctx context.Context, _ *asynq.Task) error { + startedAt := time.Now() + + for _, days := range dashboardCacheDays { + rawResult, err := queryDashboardData(ctx, days) + if err != nil { + return fmt.Errorf("query dashboard data days=%d: %w", days, err) + } + + jsonData, err := json.Marshal(rawResult) + if err != nil { + return fmt.Errorf("marshal dashboard data days=%d: %w", days, err) + } + + if err := db.Redis.Set(ctx, dashboardCacheKey(days), string(jsonData), 0).Err(); err != nil { + return fmt.Errorf("cache dashboard data days=%d: %w", days, err) + } + } + + logger.InfoF(ctx, "dashboard stats: cached days=%v in %s", dashboardCacheDays, time.Since(startedAt)) + return nil +} diff --git a/internal/apps/dashboard/utils.go b/internal/apps/dashboard/utils.go new file mode 100644 index 00000000..079e7b46 --- /dev/null +++ b/internal/apps/dashboard/utils.go @@ -0,0 +1,61 @@ +/* + * MIT License + * + * Copyright (c) 2025 linux.do + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package dashboard + +import ( + "context" + "fmt" + + "github.com/linux-do/cdk/internal/db" +) + +// dashboardCacheDays 需要定时预缓存的天数范围 +var dashboardCacheDays = []int{7, 15, 30} + +// dashboardCacheKey 构造仪表板数据的 Redis 缓存键 +func dashboardCacheKey(days int) string { + return fmt.Sprintf("dashboard:data:%d", days) +} + +// StatsCacheReady 判断仪表板统计缓存是否已预热(以首个预缓存天数作为哨兵) +func StatsCacheReady(ctx context.Context) (bool, error) { + exists, err := db.Redis.Exists(ctx, dashboardCacheKey(dashboardCacheDays[0])).Result() + if err != nil { + return false, err + } + return exists > 0, nil +} + +// queryDashboardData 调用存储过程查询仪表板数据(较重,仅供定时任务预缓存使用) +func queryDashboardData(ctx context.Context, days int) (map[string]interface{}, error) { + query := "CALL get_dashboard_data(?,10)" + + var rawResult map[string]interface{} + if err := db.DB(ctx).Raw(query, days).Scan(&rawResult).Error; err != nil { + return nil, err + } + + return rawResult, nil +} diff --git a/internal/config/model.go b/internal/config/model.go index 68be9ee9..0448a9ea 100644 --- a/internal/config/model.go +++ b/internal/config/model.go @@ -150,6 +150,7 @@ type scheduleConfig struct { UpdateUserBadgeScoresTaskCron string `mapstructure:"update_user_badges_scores_task_cron"` UpdateAllBadgesTaskCron string `mapstructure:"update_all_badges_task_cron"` ExpireStalePaymentOrdersCron string `mapstructure:"expire_stale_payment_orders_cron"` + UpdateDashboardStatsCron string `mapstructure:"update_dashboard_stats_cron"` } // workerConfig 工作配置 diff --git a/internal/task/constants.go b/internal/task/constants.go index 68b73384..46fc3b9a 100644 --- a/internal/task/constants.go +++ b/internal/task/constants.go @@ -30,4 +30,6 @@ const ( UpdateSingleUserBadgeScoreTask = "user:badge:update_single_score_task" ExpireStalePaymentOrdersTask = "payment:expire_stale_orders" + + UpdateDashboardStatsTask = "dashboard:update_stats_task" ) diff --git a/internal/task/schedule/schedule.go b/internal/task/schedule/schedule.go index 02c818cc..cbfd02d3 100644 --- a/internal/task/schedule/schedule.go +++ b/internal/task/schedule/schedule.go @@ -74,6 +74,11 @@ func StartScheduler() error { return } + // 定时预缓存仪表板统计数据 + if _, err = scheduler.Register(config.Config.Schedule.UpdateDashboardStatsCron, asynq.NewTask(task.UpdateDashboardStatsTask, nil)); err != nil { + return + } + // 启动调度器 err = scheduler.Run() }) diff --git a/internal/task/worker/worker.go b/internal/task/worker/worker.go index 42fa22a1..f3959bbc 100644 --- a/internal/task/worker/worker.go +++ b/internal/task/worker/worker.go @@ -27,6 +27,7 @@ package worker import ( "context" "github.com/hibiken/asynq" + "github.com/linux-do/cdk/internal/apps/dashboard" "github.com/linux-do/cdk/internal/apps/oauth" "github.com/linux-do/cdk/internal/apps/payment" "github.com/linux-do/cdk/internal/config" @@ -50,6 +51,15 @@ func StartWorker() error { } } + // 预热仪表板统计缓存:首次启动或 Redis 清空后立即预缓存,避免接口空窗 + if ready, err := dashboard.StatsCacheReady(context.Background()); err != nil { + return err + } else if !ready { + if _, errTask := schedule.AsynqClient.Enqueue(asynq.NewTask(task.UpdateDashboardStatsTask, nil)); errTask != nil { + return errTask + } + } + asynqServer := asynq.NewServer( task.RedisOpt, asynq.Config{ @@ -71,6 +81,7 @@ func StartWorker() error { mux.HandleFunc(task.UpdateUserBadgeScoresTask, oauth.HandleUpdateUserBadgeScores) mux.HandleFunc(task.UpdateSingleUserBadgeScoreTask, oauth.HandleUpdateSingleUserBadgeScore) mux.HandleFunc(task.ExpireStalePaymentOrdersTask, payment.HandleExpireStaleOrders) + mux.HandleFunc(task.UpdateDashboardStatsTask, dashboard.HandleUpdateDashboardStats) // 启动服务器 return asynqServer.Run(mux) }