Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
34 changes: 11 additions & 23 deletions internal/apps/dashboard/logic.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 仪表板数据结构
Expand All @@ -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
}
16 changes: 10 additions & 6 deletions internal/apps/dashboard/routers.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ package dashboard

import (
"net/http"
"strconv"

"github.com/gin-gonic/gin"
)
Expand All @@ -36,19 +35,24 @@ 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"
// @Produce json
// @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()})
Expand Down
61 changes: 61 additions & 0 deletions internal/apps/dashboard/tasks.go
Original file line number Diff line number Diff line change
@@ -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
}
61 changes: 61 additions & 0 deletions internal/apps/dashboard/utils.go
Original file line number Diff line number Diff line change
@@ -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
}
1 change: 1 addition & 0 deletions internal/config/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 工作配置
Expand Down
2 changes: 2 additions & 0 deletions internal/task/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,6 @@ const (
UpdateSingleUserBadgeScoreTask = "user:badge:update_single_score_task"

ExpireStalePaymentOrdersTask = "payment:expire_stale_orders"

UpdateDashboardStatsTask = "dashboard:update_stats_task"
)
5 changes: 5 additions & 0 deletions internal/task/schedule/schedule.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
Expand Down
11 changes: 11 additions & 0 deletions internal/task/worker/worker.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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{
Expand All @@ -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)
}
Loading