chore: 添加任务队列管理系统

This commit is contained in:
2025-12-09 14:31:02 +08:00
commit c88bde7b11
12 changed files with 1452 additions and 0 deletions

332
inspect.go Normal file
View File

@@ -0,0 +1,332 @@
// Package taskq 提供基于 Redis 的异步任务队列功能
// inspect.go 文件包含任务队列的监控和检查功能
package taskq
import (
_ "embed"
"encoding/json"
"fmt"
"html/template"
"net/http"
"sort"
"strconv"
"strings"
"time"
"github.com/hibiken/asynq"
)
//go:embed dashboard.html
var dashboardHTML string
// InspectOptions 配置监控服务的选项
type InspectOptions struct {
// RootPath 监控服务的根路径
// 默认为 "/monitor"
RootPath string
// ReadOnly 是否只读模式,禁用所有修改操作
// 默认为 false
ReadOnly bool
}
// HTTPHandler 监控服务的 HTTP 处理器
type HTTPHandler struct {
router *http.ServeMux
rootPath string
readOnly bool
inspector *asynq.Inspector
}
// NewInspectHandler 创建新的监控处理器
// 使用全局的 redisClient 创建 asynq.Inspector
func NewInspectHandler(opts InspectOptions) (*HTTPHandler, error) {
if redisClient == nil {
return nil, fmt.Errorf("taskq: redis client not initialized, call SetRedis() first")
}
// 设置默认值
if opts.RootPath == "" {
opts.RootPath = "/monitor"
}
// 确保路径以 / 开头且不以 / 结尾
if !strings.HasPrefix(opts.RootPath, "/") {
opts.RootPath = "/" + opts.RootPath
}
opts.RootPath = strings.TrimSuffix(opts.RootPath, "/")
// 创建 asynq inspector
inspector := asynq.NewInspectorFromRedisClient(redisClient)
handler := &HTTPHandler{
router: http.NewServeMux(),
rootPath: opts.RootPath,
readOnly: opts.ReadOnly,
inspector: inspector,
}
handler.setupRoutes()
return handler, nil
}
// ServeHTTP 实现 http.Handler 接口
func (h *HTTPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h.router.ServeHTTP(w, r)
}
// RootPath 返回监控服务的根路径
func (h *HTTPHandler) RootPath() string {
return h.rootPath
}
// Close 关闭 inspector 连接
func (h *HTTPHandler) Close() error {
return h.inspector.Close()
}
// setupRoutes 设置路由
func (h *HTTPHandler) setupRoutes() {
// API 路由
apiPath := h.rootPath + "/api/"
h.router.HandleFunc(apiPath+"queues", h.handleQueues)
h.router.HandleFunc(apiPath+"queues/", h.handleQueueDetail)
h.router.HandleFunc(apiPath+"tasks/", h.handleTasks)
// 主页路由
h.router.HandleFunc(h.rootPath+"/", h.handleDashboard)
h.router.HandleFunc(h.rootPath, h.handleDashboard)
}
// handleQueues 处理队列列表请求
func (h *HTTPHandler) handleQueues(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// 获取所有队列信息
asynqQueues, err := h.inspector.Queues()
if err != nil {
http.Error(w, fmt.Sprintf("Failed to get queues: %v", err), http.StatusInternalServerError)
return
}
fmt.Println("Redis中的队列:", asynqQueues)
fmt.Println("注册的队列:", queues)
// 获取每个队列的详细信息
type QueueInfo struct {
Name string `json:"name"`
Priority int `json:"priority"`
Active int `json:"active"`
Pending int `json:"pending"`
Retry int `json:"retry"`
Archived int `json:"archived"`
}
var queueInfos []QueueInfo
// 首先显示所有注册的队列即使Redis中还没有任务
for queueName, priority := range queues {
stats, err := h.inspector.GetQueueInfo(queueName)
if err != nil {
// 如果队列不存在,创建一个空的状态
queueInfos = append(queueInfos, QueueInfo{
Name: queueName,
Priority: priority,
Active: 0,
Pending: 0,
Retry: 0,
Archived: 0,
})
} else {
queueInfos = append(queueInfos, QueueInfo{
Name: queueName,
Priority: priority,
Active: stats.Active,
Pending: stats.Pending,
Retry: stats.Retry,
Archived: stats.Archived,
})
}
}
// 按优先级排序
sort.Slice(queueInfos, func(i, j int) bool {
return queueInfos[i].Priority > queueInfos[j].Priority
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(queueInfos)
}
// handleQueueDetail 处理队列详情请求
func (h *HTTPHandler) handleQueueDetail(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// 从 URL 中提取队列名称
path := strings.TrimPrefix(r.URL.Path, h.rootPath+"/api/queues/")
parts := strings.Split(path, "/")
if len(parts) == 0 || parts[0] == "" {
http.Error(w, "Queue name is required", http.StatusBadRequest)
return
}
queueName := parts[0]
// 检查队列是否已注册
if _, exists := queues[queueName]; !exists {
http.Error(w, "Queue not found", http.StatusNotFound)
return
}
// 获取队列详细信息
stats, err := h.inspector.GetQueueInfo(queueName)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to get queue info: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(stats)
}
// 转换任务信息
type TaskInfo struct {
ID string `json:"id"`
Type string `json:"type"`
Payload string `json:"payload"`
Queue string `json:"queue"`
Retried int `json:"retried"`
LastFailed string `json:"last_failed,omitempty"`
LastError string `json:"last_error,omitempty"`
NextProcess string `json:"next_process,omitempty"`
CompletedAt string `json:"completed_at,omitempty"`
}
// handleTasks 处理任务列表请求
func (h *HTTPHandler) handleTasks(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// 从 URL 中提取队列名称和任务状态
path := strings.TrimPrefix(r.URL.Path, h.rootPath+"/api/tasks/")
parts := strings.Split(path, "/")
if len(parts) < 2 {
http.Error(w, "Queue name and task state are required", http.StatusBadRequest)
return
}
queueName := parts[0]
taskState := parts[1]
// 检查队列是否已注册
if _, exists := queues[queueName]; !exists {
http.Error(w, "Queue not found", http.StatusNotFound)
return
}
// 解析分页参数
page := 1
pageSize := 20
if p := r.URL.Query().Get("page"); p != "" {
if parsed, err := strconv.Atoi(p); err == nil && parsed > 0 {
page = parsed
}
}
if ps := r.URL.Query().Get("page_size"); ps != "" {
if parsed, err := strconv.Atoi(ps); err == nil && parsed > 0 && parsed <= 100 {
pageSize = parsed
}
}
// 根据任务状态获取任务列表
var tasks []*asynq.TaskInfo
var err error
switch taskState {
case "active":
tasks, err = h.inspector.ListActiveTasks(queueName, asynq.PageSize(pageSize), asynq.Page(page-1))
case "pending":
tasks, err = h.inspector.ListPendingTasks(queueName, asynq.PageSize(pageSize), asynq.Page(page-1))
case "retry":
tasks, err = h.inspector.ListRetryTasks(queueName, asynq.PageSize(pageSize), asynq.Page(page-1))
case "archived":
tasks, err = h.inspector.ListArchivedTasks(queueName, asynq.PageSize(pageSize), asynq.Page(page-1))
case "completed":
tasks, err = h.inspector.ListCompletedTasks(queueName, asynq.PageSize(pageSize), asynq.Page(page-1))
default:
http.Error(w, "Invalid task state. Valid states: active, pending, retry, archived, completed", http.StatusBadRequest)
return
}
if err != nil {
http.Error(w, fmt.Sprintf("Failed to get tasks: %v", err), http.StatusInternalServerError)
return
}
var taskInfos []TaskInfo
for _, task := range tasks {
info := TaskInfo{
ID: task.ID,
Type: task.Type,
Payload: string(task.Payload),
Queue: task.Queue,
Retried: task.Retried,
}
if !task.LastFailedAt.IsZero() {
info.LastFailed = task.LastFailedAt.Format(time.RFC3339)
}
if task.LastErr != "" {
info.LastError = task.LastErr
}
if !task.NextProcessAt.IsZero() {
info.NextProcess = task.NextProcessAt.Format(time.RFC3339)
}
if !task.CompletedAt.IsZero() {
info.CompletedAt = task.CompletedAt.Format(time.RFC3339)
}
taskInfos = append(taskInfos, info)
}
response := map[string]any{
"tasks": taskInfos,
"page": page,
"page_size": pageSize,
"total": len(taskInfos),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// handleDashboard 处理仪表板页面
func (h *HTTPHandler) handleDashboard(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// 使用嵌入的 HTML 模板
tmpl, err := template.New("dashboard").Parse(dashboardHTML)
if err != nil {
http.Error(w, fmt.Sprintf("Template error: %v", err), http.StatusInternalServerError)
return
}
data := struct {
RootPath string
}{
RootPath: h.rootPath,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
tmpl.Execute(w, data)
}